From 56daa3f2d63153b2984270384faf6324a1db2c78 Mon Sep 17 00:00:00 2001 From: Joshua Date: Mon, 20 Jul 2026 23:19:59 +0100 Subject: [PATCH 01/12] feat(automode): gpt-5.6 model tiers, episode ledger RPC, digest toggle field - CODEX_MODEL_TIERS (luna/terra/sol) + aliases in contracts model catalog - verifier defaults fold onto tiers (kills drifted gpt-5.4-mini/gpt-5.5 dual source) - gits.automode.episodes.list wired to AutomodeEpisodeLedger - gits.automode.stopAll + goals.kill schemas (typed not-implemented stubs, wave 2) - AutomodePolicy.telegramDigestEnabled (decoding default true) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Au2F2zbx3i5NauQHWJn4Gb --- .../src/gits/Layers/AutomodeSupervisor.ts | 2 + .../Layers/GitsCodexVerifierAdapter.test.ts | 34 ++++++++ .../gits/Layers/GitsCodexVerifierAdapter.ts | 5 +- apps/server/src/server.test.ts | 6 ++ apps/server/src/server.ts | 3 + apps/server/src/ws.ts | 38 +++++++++ .../src/automode-policy-fields.test.ts | 82 ++++++++++++++++++- packages/contracts/src/gits.ts | 40 +++++++++ packages/contracts/src/model.ts | 20 +++++ packages/contracts/src/rpc.ts | 31 +++++++ packages/shared/src/model.test.ts | 23 ++++++ 11 files changed, 281 insertions(+), 3 deletions(-) diff --git a/apps/server/src/gits/Layers/AutomodeSupervisor.ts b/apps/server/src/gits/Layers/AutomodeSupervisor.ts index 3c0a471d81e..e793104a95a 100644 --- a/apps/server/src/gits/Layers/AutomodeSupervisor.ts +++ b/apps/server/src/gits/Layers/AutomodeSupervisor.ts @@ -249,6 +249,7 @@ function defaultPolicy(updatedAt: string): AutomodePolicy { verificationCommands: [], integrationBranch: null, motokoAuthority: "observe", + telegramDigestEnabled: true, updatedAt, }; } @@ -433,6 +434,7 @@ function applyPolicyUpdate( integrationBranch: input.integrationBranch === undefined ? policy.integrationBranch : input.integrationBranch, motokoAuthority: input.motokoAuthority ?? policy.motokoAuthority, + telegramDigestEnabled: input.telegramDigestEnabled ?? policy.telegramDigestEnabled, updatedAt, }; } diff --git a/apps/server/src/gits/Layers/GitsCodexVerifierAdapter.test.ts b/apps/server/src/gits/Layers/GitsCodexVerifierAdapter.test.ts index 7dc5cd8d5eb..d8c1de9361b 100644 --- a/apps/server/src/gits/Layers/GitsCodexVerifierAdapter.test.ts +++ b/apps/server/src/gits/Layers/GitsCodexVerifierAdapter.test.ts @@ -3,6 +3,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import { ChildProcessSpawner } from "effect/unstable/process"; import { vi } from "vitest"; +import { CODEX_MODEL_TIERS } from "@t3tools/contracts"; import { ProcessRunner, type ProcessRunnerShape } from "../../processRunner.ts"; import { GitsSemanticVerifier } from "../Services/GitsSemanticVerifier.ts"; @@ -161,6 +162,39 @@ describe("GitsCodexVerifierAdapter", () => { }).pipe(Effect.provide(TestLayer)), ); + it.effect( + "defaults to the light tier when no model is given and escalates to the medium tier", + () => + Effect.gen(function* () { + runMock.mockImplementationOnce(() => execOk(0, DIFF)); // diff + runMock.mockImplementationOnce((input) => { + expect(input.args[5]).toBe(CODEX_MODEL_TIERS.light); + return execOk( + 0, + '{"verdict":"uncertain","confidence":"low","reasons":["not sure"],"missed":[]}', + ); + }); + runMock.mockImplementationOnce((input) => { + expect(input.args[5]).toBe(CODEX_MODEL_TIERS.medium); // escalated, no env override + return execOk( + 0, + '{"verdict":"pass","confidence":"high","reasons":["clear on review"],"missed":[]}', + ); + }); + const verifier = yield* GitsSemanticVerifier; + const r = yield* verifier.verify({ + worktree: "/wt", + baseRef: "origin/main", + acceptanceCriteria: ["c"], + sliceTitle: "x", + // no `model` — exercises DEFAULT_MODEL / default escalationModel() + }); + expect(r.model).toBe(CODEX_MODEL_TIERS.medium); + expect(r.verdict).toBe("pass"); + expect(r.recommendation).toBe("auto-merge"); + }).pipe(Effect.provide(TestLayer)), + ); + it.effect("falls back to uncertain+hold when codex output has no parseable verdict", () => Effect.gen(function* () { vi.stubEnv("GITS_VERIFIER_ESCALATION_MODEL", "gpt-5.5"); diff --git a/apps/server/src/gits/Layers/GitsCodexVerifierAdapter.ts b/apps/server/src/gits/Layers/GitsCodexVerifierAdapter.ts index 249243c2fc9..76f0d5317cd 100644 --- a/apps/server/src/gits/Layers/GitsCodexVerifierAdapter.ts +++ b/apps/server/src/gits/Layers/GitsCodexVerifierAdapter.ts @@ -5,6 +5,7 @@ import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import { + CODEX_MODEL_TIERS, GitsSemanticVerifierError, type GitsSemanticVerifyInput, type GitsSemanticVerifyResult, @@ -22,7 +23,7 @@ import { ProcessRunner, layer as ProcessRunnerLive } from "../../processRunner.t const MAX_DIFF_BYTES = 256 * 1024; const MAX_OUTPUT_BYTES = 2 * 1024 * 1024; const TRUNCATED_MARKER = "\n\n[truncated]"; -const DEFAULT_MODEL = "gpt-5.4-mini"; +const DEFAULT_MODEL = CODEX_MODEL_TIERS.light; const DEFAULT_TIMEOUT_MS = 300_000; function toError(message: string, cause?: unknown) { @@ -46,7 +47,7 @@ function codexHome() { ); } function escalationModel() { - return process.env.GITS_VERIFIER_ESCALATION_MODEL?.trim() || "gpt-5.5"; + return process.env.GITS_VERIFIER_ESCALATION_MODEL?.trim() || CODEX_MODEL_TIERS.medium; } function resolveModel(input: GitsSemanticVerifyInput) { return input.model?.trim() || process.env.GITS_VERIFIER_MODEL?.trim() || DEFAULT_MODEL; diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 3a391855499..d3f2fa3934a 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -129,6 +129,7 @@ import { AutomodeSupervisor, type AutomodeSupervisorShape, } from "./gits/Services/AutomodeSupervisor.ts"; +import { AutomodeEpisodeLedger } from "./persistence/Services/AutomodeEpisodeLedger.ts"; import { CritSidecarManager } from "./crit/crit-sidecar-manager.ts"; import { GitsCapacityMonitor, @@ -275,6 +276,7 @@ const defaultAutomodeSnapshot: AutomodeSnapshot = { verificationCommands: [], integrationBranch: null, motokoAuthority: "observe", + telegramDigestEnabled: true, updatedAt: "2026-01-01T00:00:00.000Z", }, budgetUsage: { @@ -1142,6 +1144,10 @@ const buildAppUnderTest = (options?: { dispatchGoal: () => Effect.succeed(defaultAutomodeDispatchResult), ...options?.layers?.automodeSupervisor, }), + Layer.mock(AutomodeEpisodeLedger)({ + record_episode: () => Effect.void, + list_episodes: () => Effect.succeed([]), + }), Layer.mock(GitsSlotScheduler)({ getSnapshot: () => Effect.succeed(defaultGitsSchedulerSnapshot), setConfig: () => Effect.succeed(defaultGitsSchedulerSnapshot), diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 9f4d2975a8e..3519632a345 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -407,6 +407,9 @@ const GitsLayerLive = Layer.empty.pipe( Layer.provideMerge(AutomodeSupervisorLayerLive), Layer.provideMerge(GitsSlotSchedulerLayerLive), Layer.provideMerge(AutomodeDriverLayerLive), + // Exposed directly (not just as AutomodeDriverLayerLive's internal dependency) so the ws.ts + // RPC layer can `yield* AutomodeEpisodeLedger` for gits.automode.episodes.list. + Layer.provideMerge(AutomodeEpisodeLedgerLayerLive), // GitsReviewPipeline composes the gate/verifier/criteria services. Provide them // directly to it so its own requirements are satisfied here rather than leaking // into the server launch layer (which must only require ServerConfig). The dep diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index c7e383ac07b..4b2be531899 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -104,6 +104,7 @@ import { GitsSlotScheduler } from "./gits/Services/GitsSlotScheduler.ts"; import { HermesAdapter } from "./gits/Services/HermesAdapter.ts"; import { OpenGsdAdapter } from "./gits/Services/OpenGsdAdapter.ts"; import { AutomodeSupervisor } from "./gits/Services/AutomodeSupervisor.ts"; +import { AutomodeEpisodeLedger } from "./persistence/Services/AutomodeEpisodeLedger.ts"; import { decideProposalWithAutomodeBridge } from "./gits/Layers/HermesAutomodeBridge.ts"; import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; import { @@ -423,6 +424,7 @@ const makeWsRpcLayer = ( const hermesAdapter = yield* HermesAdapter; const openGsdAdapter = yield* OpenGsdAdapter; const automodeSupervisor = yield* AutomodeSupervisor; + const automodeEpisodeLedger = yield* AutomodeEpisodeLedger; // R#1 (kill-switch-only manual gate): manual peer actions stay human-driven, but the // global automode kill switch also freezes them. ponytail: reuse DelamainAdapterError // (these RPC channels already carry it) so no rpc.ts/client contract change is needed. @@ -2206,6 +2208,42 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.gitsAutomodeDriverResume, automodeSupervisor.resumeDriver(), { "rpc.aggregate": "gits", }), + [WS_METHODS.gitsAutomodeEpisodesList]: (input) => + observeRpcEffect( + WS_METHODS.gitsAutomodeEpisodesList, + automodeEpisodeLedger.list_episodes(input).pipe( + Effect.mapError( + (cause) => + new AutomodeSupervisorError({ + message: "Failed to list automode episodes.", + cause, + }), + ), + ), + { "rpc.aggregate": "gits" }, + ), + // ponytail: schema-only RPCs (WsRpcGroup.of requires an exhaustive handler map, so a + // stub is unavoidable to keep this file compiling) — real logic lands next wave. + [WS_METHODS.gitsAutomodeStopAll]: (_input) => + observeRpcEffect( + WS_METHODS.gitsAutomodeStopAll, + Effect.fail( + new AutomodeSupervisorError({ + message: "gits.automode.stopAll is not implemented yet.", + }), + ), + { "rpc.aggregate": "gits" }, + ), + [WS_METHODS.gitsAutomodeGoalsKill]: (_input) => + observeRpcEffect( + WS_METHODS.gitsAutomodeGoalsKill, + Effect.fail( + new AutomodeSupervisorError({ + message: "gits.automode.goals.kill is not implemented yet.", + }), + ), + { "rpc.aggregate": "gits" }, + ), [WS_METHODS.gitsCapacityGetSnapshot]: (_input) => observeRpcEffect( WS_METHODS.gitsCapacityGetSnapshot, diff --git a/packages/contracts/src/automode-policy-fields.test.ts b/packages/contracts/src/automode-policy-fields.test.ts index 872401b7ac4..419439bfc02 100644 --- a/packages/contracts/src/automode-policy-fields.test.ts +++ b/packages/contracts/src/automode-policy-fields.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vitest"; import * as Schema from "effect/Schema"; -import { AutomodePolicy, AutomodePolicyUpdateInput } from "./gits.ts"; +import { + AutomodeEpisode, + AutomodeEpisodesListInput, + AutomodePolicy, + AutomodePolicyUpdateInput, +} from "./gits.ts"; describe("AutomodePolicy held-PR fields", () => { it("decodes verificationCommands + integrationBranch", () => { @@ -44,6 +49,7 @@ describe("AutomodePolicy held-PR fields", () => { expect(decoded.autoEnqueueApprovedProposals).toBe(false); expect(decoded.nightlyProposalSweep).toBe(false); expect(decoded.proposalRepos).toEqual([]); + expect(decoded.telegramDigestEnabled).toBe(true); }); it("accepts the new fields on the update input", () => { @@ -56,4 +62,78 @@ describe("AutomodePolicy held-PR fields", () => { expect(decoded.integrationBranch).toBe("auto/x"); expect(decoded.autoEnqueueApprovedProposals).toBe(true); }); + + it("decodes an explicit telegramDigestEnabled and accepts it on the update input", () => { + const decoded = Schema.decodeUnknownSync(AutomodePolicy)({ + mode: "manual", + killSwitchEnabled: true, + maxActivePeers: 1, + allowedRepos: [], + allowedModels: [], + defaultModel: null, + maxBudgetUsd: null, + maxRuntimeMinutes: 60, + requireApprovalForPeerSpawn: true, + requireApprovalBeforeIntegrate: true, + requireApprovalBeforeDestructiveAction: true, + telegramDigestEnabled: false, + updatedAt: "2026-01-01T00:00:00.000Z", + }); + expect(decoded.telegramDigestEnabled).toBe(false); + + const updateDecoded = Schema.decodeUnknownSync(AutomodePolicyUpdateInput)({ + telegramDigestEnabled: false, + }); + expect(updateDecoded.telegramDigestEnabled).toBe(false); + }); +}); + +describe("Automode episode ledger contract", () => { + const baseEpisode = { + id: "epi-1", + episodeId: "epi-thread-1", + repo: "/tmp/repo", + goalId: "goal-1", + goalTitle: "Ship the thing", + sliceBranch: "auto/slice-1", + verdict: "pass" as const, + confidence: "high" as const, + recommendation: "auto-merge" as const, + flagged: false, + summary: "All good.", + review: { + sliceId: "slice-1", + recommendation: "auto-merge" as const, + mechanicalPassed: true, + mechanical: { + worktree: "/tmp/repo", + confined: true, + passed: true, + results: [], + checkedAt: "2026-01-01T00:00:00.000Z", + }, + semantic: null, + criteriaSource: "authored" as const, + summary: "All good.", + checkedAt: "2026-01-01T00:00:00.000Z", + }, + createdAt: "2026-01-01T00:00:00.000Z", + }; + + it("decodes an AutomodeEpisode row", () => { + const decoded = Schema.decodeUnknownSync(AutomodeEpisode)(baseEpisode); + expect(decoded.id).toBe("epi-1"); + expect(decoded.review.recommendation).toBe("auto-merge"); + }); + + it("decodes AutomodeEpisodesListInput with both fields optional", () => { + expect(Schema.decodeUnknownSync(AutomodeEpisodesListInput)({})).toEqual({}); + expect( + Schema.decodeUnknownSync(AutomodeEpisodesListInput)({ limit: 10, repo: "/tmp/repo" }), + ).toEqual({ limit: 10, repo: "/tmp/repo" }); + }); + + it("rejects a non-positive limit", () => { + expect(() => Schema.decodeUnknownSync(AutomodeEpisodesListInput)({ limit: 0 })).toThrow(); + }); }); diff --git a/packages/contracts/src/gits.ts b/packages/contracts/src/gits.ts index 8b64e18993b..83d22ec382c 100644 --- a/packages/contracts/src/gits.ts +++ b/packages/contracts/src/gits.ts @@ -892,6 +892,10 @@ export const AutomodePolicy = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed(null)), ), motokoAuthority: MotokoAuthority.pipe(Schema.withDecodingDefault(Effect.succeed("observe"))), + // Owner kill-switch for the daily Telegram digest/report. On by default (existing behavior); + // decoding default is load-bearing — PersistedAutomodeState embeds this schema, so a legacy + // automode-state.json without telegramDigestEnabled must still decode. + telegramDigestEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), updatedAt: IsoDateTime, }); export type AutomodePolicy = typeof AutomodePolicy.Type; @@ -979,6 +983,7 @@ export const AutomodePolicyUpdateInput = Schema.Struct({ verificationCommands: Schema.optional(Schema.Array(GitsVerifyCommand)), integrationBranch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), motokoAuthority: Schema.optional(MotokoAuthority), + telegramDigestEnabled: Schema.optional(Schema.Boolean), }); export type AutomodePolicyUpdateInput = typeof AutomodePolicyUpdateInput.Type; @@ -1029,6 +1034,13 @@ export const AutomodeDispatchResult = Schema.Struct({ }); export type AutomodeDispatchResult = typeof AutomodeDispatchResult.Type; +// Mirrors the counters the Telegram STOP command already reports (HermesTelegramCommand.ts). +export const AutomodeStopAllResult = Schema.Struct({ + stoppedPeers: NonNegativeInt, + failures: NonNegativeInt, +}); +export type AutomodeStopAllResult = typeof AutomodeStopAllResult.Type; + // --- Slot scheduler (off-hours autonomy phase 1) ------------------------------------------- // Gates autonomous goal STARTS to London night slots (decisions 6, 7, 11, 20). // Disabled scheduler = bypass (today's interactive behavior); slots never stop a running goal. @@ -1789,3 +1801,31 @@ export class GitsReviewError extends Schema.TaggedErrorClass()( message: TrimmedNonEmptyString, cause: Schema.optional(Schema.Defect), }) {} + +// --- Automode episode ledger (RPC exposure) ------------------------------------------------ +// Mirrors apps/server/src/persistence/Services/AutomodeEpisodeLedger.ts's (server-only, +// SQL-backed) AutomodeEpisode row shape 1:1 so it can be exposed over +// gits.automode.episodes.list. Keep both definitions in sync by hand. + +export const AutomodeEpisode = Schema.Struct({ + id: TrimmedNonEmptyString, + episodeId: Schema.NullOr(TrimmedNonEmptyString), + repo: PathString, + goalId: TrimmedNonEmptyString, + goalTitle: TrimmedNonEmptyString, + sliceBranch: Schema.NullOr(TrimmedNonEmptyString), + verdict: GitsVerifierVerdict, + confidence: Schema.NullOr(GitsVerifierConfidence), + recommendation: GitsVerifierRecommendation, + flagged: Schema.Boolean, + summary: SummaryString, + review: GitsReviewResult, + createdAt: IsoDateTime, +}); +export type AutomodeEpisode = typeof AutomodeEpisode.Type; + +export const AutomodeEpisodesListInput = Schema.Struct({ + limit: Schema.optional(PositiveInt), + repo: Schema.optional(PathString), +}); +export type AutomodeEpisodesListInput = typeof AutomodeEpisodesListInput.Type; diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 562d196b24f..c67a0bb5bdc 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -165,6 +165,10 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Partial< "5.5": "gpt-5.5", "5.6": "gpt-5.6-sol", sol: "gpt-5.6-sol", + luna: "gpt-5.6-luna", + "5.6-luna": "gpt-5.6-luna", + terra: "gpt-5.6-terra", + "5.6-terra": "gpt-5.6-terra", }, [CLAUDE_DRIVER_KIND]: { opus: "claude-opus-4-8", @@ -201,6 +205,22 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Partial< [OPENCODE_DRIVER_KIND]: {}, }; +// ── Codex gpt-5.6 tier family ────────────────────────────────────────── +// Three fixed-purpose tiers on the gpt-5.6 codex line: light/fast for cheap +// mechanical passes, balanced for escalation, deep for the primary driver model. +export const CODEX_MODEL_TIERS = { + light: "gpt-5.6-luna", + medium: "gpt-5.6-terra", + high: "gpt-5.6-sol", +} as const; +export type CodexModelTier = keyof typeof CODEX_MODEL_TIERS; + +export const CODEX_MODEL_TIER_LABELS: Record = { + light: "Luna — light/fast", + medium: "Terra — balanced", + high: "Sol — deep analysis & execution", +}; + // ── Provider display names ──────────────────────────────────────────── export const PROVIDER_DISPLAY_NAMES: Partial> = { diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index fd29091e063..55796310a8e 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -18,12 +18,15 @@ import { import { AutomodeDispatchResult, AutomodeEnqueueGoalInput, + AutomodeEpisode, + AutomodeEpisodesListInput, AutomodeGoal, AutomodeGoalInput, AutomodePolicyUpdateInput, AutomodeRejectGoalInput, AutomodeSnapshot, AutomodeSnapshotInput, + AutomodeStopAllResult, AutomodeSupervisorError, DelamainAdapterError, DelamainInboxResult, @@ -318,6 +321,9 @@ export const WS_METHODS = { gitsAutomodeSchedulerArm: "gits.automode.scheduler.arm", gitsAutomodeSchedulerDisarm: "gits.automode.scheduler.disarm", gitsAutomodeDriverResume: "gits.automode.driver.resume", + gitsAutomodeEpisodesList: "gits.automode.episodes.list", + gitsAutomodeStopAll: "gits.automode.stopAll", + gitsAutomodeGoalsKill: "gits.automode.goals.kill", gitsCapacityGetSnapshot: "gits.capacity.snapshot", gitsHermesGetStatus: "gits.hermes.status", gitsHermesGetConfig: "gits.hermes.config", @@ -984,6 +990,28 @@ export const WsGitsAutomodeDriverResumeRpc = Rpc.make(WS_METHODS.gitsAutomodeDri error: AutomodeSupervisorError, }); +export const WsGitsAutomodeEpisodesListRpc = Rpc.make(WS_METHODS.gitsAutomodeEpisodesList, { + payload: AutomodeEpisodesListInput, + success: Schema.Array(AutomodeEpisode), + error: AutomodeSupervisorError, +}); + +// Schema-only for now — no server handler wired yet (next wave). Mirrors the counters the +// Telegram STOP command already computes (HermesTelegramCommand.ts `case "stop"`). +export const WsGitsAutomodeStopAllRpc = Rpc.make(WS_METHODS.gitsAutomodeStopAll, { + payload: Schema.Struct({}), + success: AutomodeStopAllResult, + error: AutomodeSupervisorError, +}); + +// Schema-only for now — no server handler wired yet (next wave). Payload/success shape mirrors +// the other single-goal siblings (approveGoal/rejectGoal: {goalId} -> AutomodeGoal). +export const WsGitsAutomodeGoalsKillRpc = Rpc.make(WS_METHODS.gitsAutomodeGoalsKill, { + payload: AutomodeGoalInput, + success: AutomodeGoal, + error: AutomodeSupervisorError, +}); + export const WsGitsCapacityGetSnapshotRpc = Rpc.make(WS_METHODS.gitsCapacityGetSnapshot, { payload: GitsCapacitySnapshotInput, success: GitsCapacitySnapshot, @@ -1294,6 +1322,9 @@ export const WsRpcGroup = RpcGroup.make( WsGitsAutomodeSchedulerArmRpc, WsGitsAutomodeSchedulerDisarmRpc, WsGitsAutomodeDriverResumeRpc, + WsGitsAutomodeEpisodesListRpc, + WsGitsAutomodeStopAllRpc, + WsGitsAutomodeGoalsKillRpc, WsGitsCapacityGetSnapshotRpc, WsGitsHermesGetStatusRpc, WsGitsHermesGetConfigRpc, diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index ea5a52707d7..41a9b3fa273 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vitest"; import { + CODEX_MODEL_TIERS, + CODEX_MODEL_TIER_LABELS, DEFAULT_MODEL, ProviderDriverKind, ProviderInstanceId, @@ -79,6 +81,14 @@ describe("normalizeModelSlug", () => { expect(normalizeModelSlug("sonnet-4.6", claude)).toBe("claude-sonnet-4-6"); }); + it("maps the gpt-5.6 tier aliases (luna/terra/sol) to canonical slugs", () => { + expect(normalizeModelSlug("luna")).toBe(CODEX_MODEL_TIERS.light); + expect(normalizeModelSlug("5.6-luna")).toBe(CODEX_MODEL_TIERS.light); + expect(normalizeModelSlug("terra")).toBe(CODEX_MODEL_TIERS.medium); + expect(normalizeModelSlug("5.6-terra")).toBe(CODEX_MODEL_TIERS.medium); + expect(normalizeModelSlug("sol")).toBe(CODEX_MODEL_TIERS.high); + }); + it("returns null for empty or missing values", () => { expect(normalizeModelSlug("")).toBeNull(); expect(normalizeModelSlug(" ")).toBeNull(); @@ -123,6 +133,19 @@ describe("resolveSelectableModel", () => { }); }); +describe("CODEX_MODEL_TIERS", () => { + it("exports the light/medium/high gpt-5.6 tier slugs and labels", () => { + expect(CODEX_MODEL_TIERS).toEqual({ + light: "gpt-5.6-luna", + medium: "gpt-5.6-terra", + high: "gpt-5.6-sol", + }); + expect(CODEX_MODEL_TIER_LABELS.light).toContain("Luna"); + expect(CODEX_MODEL_TIER_LABELS.medium).toContain("Terra"); + expect(CODEX_MODEL_TIER_LABELS.high).toContain("Sol"); + }); +}); + describe("misc helpers", () => { it("detects ultrathink prompts", () => { expect(isClaudeUltrathinkPrompt("Please ultrathink about this")).toBe(true); From 47dfc8a3d9048a0f740e95aa6541872b43aa4b2c Mon Sep 17 00:00:00 2001 From: Joshua Date: Mon, 20 Jul 2026 23:21:15 +0100 Subject: [PATCH 02/12] feat(automode): sweepRequiresConfirmation policy field (default on) Human-in-the-loop gate for the nightly sweep: drafted goals will stop at waiting-approval for owner confirmation (Telegram APPROVE ) before dispatch. Sweep behavior change lands next commit; this is the schema + supervisor threading. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Au2F2zbx3i5NauQHWJn4Gb --- apps/server/src/gits/Layers/AutomodeSupervisor.ts | 2 ++ apps/server/src/server.test.ts | 1 + packages/contracts/src/automode-policy-fields.test.ts | 3 +++ packages/contracts/src/gits.ts | 6 ++++++ 4 files changed, 12 insertions(+) diff --git a/apps/server/src/gits/Layers/AutomodeSupervisor.ts b/apps/server/src/gits/Layers/AutomodeSupervisor.ts index e793104a95a..b605e4056d4 100644 --- a/apps/server/src/gits/Layers/AutomodeSupervisor.ts +++ b/apps/server/src/gits/Layers/AutomodeSupervisor.ts @@ -250,6 +250,7 @@ function defaultPolicy(updatedAt: string): AutomodePolicy { integrationBranch: null, motokoAuthority: "observe", telegramDigestEnabled: true, + sweepRequiresConfirmation: true, updatedAt, }; } @@ -435,6 +436,7 @@ function applyPolicyUpdate( input.integrationBranch === undefined ? policy.integrationBranch : input.integrationBranch, motokoAuthority: input.motokoAuthority ?? policy.motokoAuthority, telegramDigestEnabled: input.telegramDigestEnabled ?? policy.telegramDigestEnabled, + sweepRequiresConfirmation: input.sweepRequiresConfirmation ?? policy.sweepRequiresConfirmation, updatedAt, }; } diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index d3f2fa3934a..cefb85d68f5 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -277,6 +277,7 @@ const defaultAutomodeSnapshot: AutomodeSnapshot = { integrationBranch: null, motokoAuthority: "observe", telegramDigestEnabled: true, + sweepRequiresConfirmation: true, updatedAt: "2026-01-01T00:00:00.000Z", }, budgetUsage: { diff --git a/packages/contracts/src/automode-policy-fields.test.ts b/packages/contracts/src/automode-policy-fields.test.ts index 419439bfc02..2869fb19907 100644 --- a/packages/contracts/src/automode-policy-fields.test.ts +++ b/packages/contracts/src/automode-policy-fields.test.ts @@ -50,6 +50,7 @@ describe("AutomodePolicy held-PR fields", () => { expect(decoded.nightlyProposalSweep).toBe(false); expect(decoded.proposalRepos).toEqual([]); expect(decoded.telegramDigestEnabled).toBe(true); + expect(decoded.sweepRequiresConfirmation).toBe(true); }); it("accepts the new fields on the update input", () => { @@ -83,8 +84,10 @@ describe("AutomodePolicy held-PR fields", () => { const updateDecoded = Schema.decodeUnknownSync(AutomodePolicyUpdateInput)({ telegramDigestEnabled: false, + sweepRequiresConfirmation: false, }); expect(updateDecoded.telegramDigestEnabled).toBe(false); + expect(updateDecoded.sweepRequiresConfirmation).toBe(false); }); }); diff --git a/packages/contracts/src/gits.ts b/packages/contracts/src/gits.ts index 83d22ec382c..02b779c8128 100644 --- a/packages/contracts/src/gits.ts +++ b/packages/contracts/src/gits.ts @@ -896,6 +896,11 @@ export const AutomodePolicy = Schema.Struct({ // decoding default is load-bearing — PersistedAutomodeState embeds this schema, so a legacy // automode-state.json without telegramDigestEnabled must still decode. telegramDigestEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + // Human-in-the-loop gate for the nightly sweep: when true (default), sweep-drafted goals + // enter waiting-approval so the owner confirms them (e.g. Telegram APPROVE ) before + // dispatch; when false, the sweep keeps its legacy self-approved queued behavior. + // Decoding default is load-bearing — see telegramDigestEnabled above. + sweepRequiresConfirmation: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), updatedAt: IsoDateTime, }); export type AutomodePolicy = typeof AutomodePolicy.Type; @@ -984,6 +989,7 @@ export const AutomodePolicyUpdateInput = Schema.Struct({ integrationBranch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), motokoAuthority: Schema.optional(MotokoAuthority), telegramDigestEnabled: Schema.optional(Schema.Boolean), + sweepRequiresConfirmation: Schema.optional(Schema.Boolean), }); export type AutomodePolicyUpdateInput = typeof AutomodePolicyUpdateInput.Type; From 70b01cbfd9399957e9656d7429ba3f7060cf5973 Mon Sep 17 00:00:00 2001 From: Joshua Date: Mon, 20 Jul 2026 23:27:37 +0100 Subject: [PATCH 03/12] feat(automode): stopAll + killGoal as first-class supervisor RPCs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promotes the Telegram STOP logic (kill switch first, then kill every live goal's peer/workflow) into AutomodeSupervisor.stopAll; STOP delegates with byte-identical replies. killGoal kills one goal's peer and marks it failed ("Killed from cockpit."). Replaces the wave-1 ws.ts stubs — the cockpit control panel can now emergency-stop and kill per-goal. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Au2F2zbx3i5NauQHWJn4Gb --- .../src/gits/HermesTelegramCommand.test.ts | 50 ++++------- apps/server/src/gits/HermesTelegramCommand.ts | 32 +------ .../gits/Layers/AutomodeSupervisor.test.ts | 88 +++++++++++++++++++ .../src/gits/Layers/AutomodeSupervisor.ts | 54 ++++++++++++ .../src/gits/Services/AutomodeSupervisor.ts | 8 ++ apps/server/src/ws.ts | 28 ++---- 6 files changed, 179 insertions(+), 81 deletions(-) diff --git a/apps/server/src/gits/HermesTelegramCommand.test.ts b/apps/server/src/gits/HermesTelegramCommand.test.ts index 8b3ae07bbc2..bbb015ed9d8 100644 --- a/apps/server/src/gits/HermesTelegramCommand.test.ts +++ b/apps/server/src/gits/HermesTelegramCommand.test.ts @@ -7,7 +7,6 @@ import { parseHermesTelegramCommand, } from "./HermesTelegramCommand.ts"; import { AutomodeSupervisor, type AutomodeSupervisorShape } from "./Services/AutomodeSupervisor.ts"; -import { DelamainAdapter, type DelamainAdapterShape } from "./Services/DelamainAdapter.ts"; import { GitsSlotScheduler, type GitsSlotSchedulerShape } from "./Services/GitsSlotScheduler.ts"; describe("parseHermesTelegramCommand", () => { @@ -43,15 +42,7 @@ describe("parseHermesTelegramCommand", () => { }); }); -function testLayer(calls: string[], killFailures = new Set()) { - const snapshot = { - goals: [ - { peerId: "peer-running", status: "running" }, - { peerId: "peer-pending", status: "pending" }, - { peerId: "peer-completed", status: "completed" }, - { peerId: null, status: "running" }, - ], - }; +function testLayer(calls: string[], stopAllResult = { stoppedPeers: 2, failures: 0 }) { const supervisor = { approveGoal: ({ goalId }: { readonly goalId: string }) => { calls.push(`approve:${goalId}`); @@ -61,13 +52,9 @@ function testLayer(calls: string[], killFailures = new Set()) { calls.push(`reject:${goalId}:${reason ?? ""}`); return Effect.succeed({}); }, - updatePolicy: ({ killSwitchEnabled }: { readonly killSwitchEnabled?: boolean }) => { - calls.push(`policy:${String(killSwitchEnabled)}`); - return Effect.succeed(snapshot); - }, - getSnapshot: () => { - calls.push("snapshot"); - return Effect.succeed(snapshot); + stopAll: () => { + calls.push("stopAll"); + return Effect.succeed(stopAllResult); }, } as unknown as AutomodeSupervisorShape; const scheduler = { @@ -76,24 +63,21 @@ function testLayer(calls: string[], killFailures = new Set()) { return Effect.succeed({}); }, } as unknown as GitsSlotSchedulerShape; - const delamain = { - killPeer: ({ peerId }: { readonly peerId: string }) => { - calls.push(`kill:${peerId}`); - return killFailures.has(peerId) ? Effect.fail({ _tag: "TestFailure" }) : Effect.succeed({}); - }, - } as unknown as DelamainAdapterShape; return Layer.mergeAll( Layer.succeed(AutomodeSupervisor, supervisor), Layer.succeed(GitsSlotScheduler, scheduler), - Layer.succeed(DelamainAdapter, delamain), ); } -async function dispatch(text: string, calls: string[], killFailures?: Set) { +async function dispatch( + text: string, + calls: string[], + stopAllResult?: { stoppedPeers: number; failures: number }, +) { return Effect.runPromise( dispatchHermesTelegramCommand(parseHermesTelegramCommand(text)).pipe( - Effect.provide(testLayer(calls, killFailures)), + Effect.provide(testLayer(calls, stopAllResult)), ), ); } @@ -128,22 +112,24 @@ describe("dispatchHermesTelegramCommand", () => { expect(calls).toEqual(["arm"]); }); - it("enables the kill switch before reading and terminating active peers", async () => { + it("delegates STOP to supervisor.stopAll and formats its counts", async () => { const calls: string[] = []; - await expect(dispatch("STOP", calls)).resolves.toBe("Stop requested for 2 peer(s)."); + await expect(dispatch("STOP", calls, { stoppedPeers: 2, failures: 0 })).resolves.toBe( + "Stop requested for 2 peer(s).", + ); - expect(calls).toEqual(["policy:true", "snapshot", "kill:peer-running", "kill:peer-pending"]); + expect(calls).toEqual(["stopAll"]); }); - it("keeps the kill switch enabled and reports bounded stop failures", async () => { + it("reports bounded stop failures from supervisor.stopAll", async () => { const calls: string[] = []; - await expect(dispatch("STOP", calls, new Set(["peer-pending"]))).resolves.toBe( + await expect(dispatch("STOP", calls, { stoppedPeers: 1, failures: 1 })).resolves.toBe( "Stop requested for 1 peer(s); 1 failed.", ); - expect(calls[0]).toBe("policy:true"); + expect(calls).toEqual(["stopAll"]); }); it("returns help without calling a service for invalid commands", async () => { diff --git a/apps/server/src/gits/HermesTelegramCommand.ts b/apps/server/src/gits/HermesTelegramCommand.ts index f0caa84b596..b858aeeee80 100644 --- a/apps/server/src/gits/HermesTelegramCommand.ts +++ b/apps/server/src/gits/HermesTelegramCommand.ts @@ -1,7 +1,6 @@ import * as Effect from "effect/Effect"; import { AutomodeSupervisor } from "./Services/AutomodeSupervisor.ts"; -import { DelamainAdapter } from "./Services/DelamainAdapter.ts"; import { GitsSlotScheduler } from "./Services/GitsSlotScheduler.ts"; export type HermesTelegramCommand = @@ -69,33 +68,10 @@ export function dispatchHermesTelegramCommand(command: HermesTelegramCommand) { } case "stop": { const supervisor = yield* AutomodeSupervisor; - const delamain = yield* DelamainAdapter; - yield* supervisor.updatePolicy({ killSwitchEnabled: true }); - const snapshot = yield* supervisor.getSnapshot(); - let stopped = 0; - let failed = 0; - - for (const goal of snapshot.goals) { - const status: string = goal.status; - if (goal.peerId === null || (status !== "running" && status !== "pending")) continue; - // Workflow-dispatched goals track the run id as peerId — kill the whole run - // (runner + live leaves), not the run record as a lone peer. Both branches are - // mapped to a boolean so the differing success types don't form an Effect union. - const ok = yield* ( - goal.workflowId - ? delamain.workflowKill({ workflowId: goal.workflowId }).pipe(Effect.as(true)) - : delamain.killPeer({ peerId: goal.peerId }).pipe(Effect.as(true)) - ).pipe(Effect.orElseSucceed(() => false)); - if (ok) { - stopped += 1; - } else { - failed += 1; - } - } - - return failed === 0 - ? `Stop requested for ${stopped} peer(s).` - : `Stop requested for ${stopped} peer(s); ${failed} failed.`; + const { stoppedPeers, failures } = yield* supervisor.stopAll(); + return failures === 0 + ? `Stop requested for ${stoppedPeers} peer(s).` + : `Stop requested for ${stoppedPeers} peer(s); ${failures} failed.`; } case "invalid": return HELP; diff --git a/apps/server/src/gits/Layers/AutomodeSupervisor.test.ts b/apps/server/src/gits/Layers/AutomodeSupervisor.test.ts index d936b35f027..cc96d5a931a 100644 --- a/apps/server/src/gits/Layers/AutomodeSupervisor.test.ts +++ b/apps/server/src/gits/Layers/AutomodeSupervisor.test.ts @@ -540,6 +540,94 @@ describe("AutomodeSupervisorLive", () => { }).pipe(Effect.provide(makeLayer())), ); + it.effect("stopAll flips the kill switch, kills live peers, and returns counts", () => { + let killCount = 0; + return Effect.gen(function* () { + const supervisor = yield* AutomodeSupervisor; + yield* supervisor.updatePolicy({ + mode: "autonomous", + killSwitchEnabled: false, + allowedRepos: ["/tmp/source-repo"], + maxBudgetUsd: 10, + maxRuntimeMinutes: null, + requireApprovalForPeerSpawn: false, + }); + const queued = yield* supervisor.enqueueGoal({ + title: "Live goal", + repo: "/tmp/source-repo", + prompt: "Run a safe task.", + }); + const dispatched = yield* supervisor.dispatchGoal({ goalId: queued.goals[0]!.id }); + assert.equal(dispatched.goal.status, "running"); + + const result = yield* supervisor.stopAll(); + + assert.equal(killCount, 1); + assert.deepEqual(result, { stoppedPeers: 1, failures: 0 }); + + const snapshot = yield* supervisor.getSnapshot(); + assert.equal(snapshot.policy.killSwitchEnabled, true); + // Mirrors STOP today: goal status is left untouched by the bulk stop. + const goal = snapshot.goals.find((g) => g.id === dispatched.goal.id); + assert.equal(goal?.status, "running"); + }).pipe( + Effect.provide( + makeLayer({ + budgetUsage: availableBudgetUsage, + onKill: () => { + killCount += 1; + }, + }), + ), + ); + }); + + it.effect("killGoal kills the peer and marks the goal failed", () => { + let killCount = 0; + return Effect.gen(function* () { + const supervisor = yield* AutomodeSupervisor; + yield* supervisor.updatePolicy({ + mode: "autonomous", + killSwitchEnabled: false, + allowedRepos: ["/tmp/source-repo"], + maxBudgetUsd: 10, + maxRuntimeMinutes: null, + requireApprovalForPeerSpawn: false, + }); + const queued = yield* supervisor.enqueueGoal({ + title: "Cockpit-killed goal", + repo: "/tmp/source-repo", + prompt: "Run a safe task.", + }); + const dispatched = yield* supervisor.dispatchGoal({ goalId: queued.goals[0]!.id }); + assert.equal(dispatched.goal.status, "running"); + + const killed = yield* supervisor.killGoal({ goalId: dispatched.goal.id }); + + assert.equal(killCount, 1); + assert.equal(killed.status, "failed"); + assert.equal(killed.blockedReason, "Killed from cockpit."); + }).pipe( + Effect.provide( + makeLayer({ + budgetUsage: availableBudgetUsage, + onKill: () => { + killCount += 1; + }, + }), + ), + ); + }); + + it.effect("killGoal errors when the goal is unknown", () => + Effect.gen(function* () { + const supervisor = yield* AutomodeSupervisor; + const error = yield* Effect.flip(supervisor.killGoal({ goalId: "goal-missing" })); + assert.include(error.message, "goal-missing"); + assert.include(error.message, "not found"); + }).pipe(Effect.provide(makeLayer())), + ); + it.effect("driverHalted persists across supervisor restart", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/gits/Layers/AutomodeSupervisor.ts b/apps/server/src/gits/Layers/AutomodeSupervisor.ts index b605e4056d4..f26cb122a20 100644 --- a/apps/server/src/gits/Layers/AutomodeSupervisor.ts +++ b/apps/server/src/gits/Layers/AutomodeSupervisor.ts @@ -23,6 +23,7 @@ import { type AutomodePolicyUpdateInput, type AutomodePolicy, type AutomodeSnapshot, + type AutomodeStopAllResult, type DelamainPeer, type PeerStatus, } from "@t3tools/contracts"; @@ -966,6 +967,59 @@ export const AutomodeSupervisorLive = Layer.effect( } return goal; }), + // ponytail: promoted verbatim from HermesTelegramCommand.ts `case "stop"` — same + // kill-switch-first + best-effort peer/workflow kill loop, now callable from ws.ts too. + // Self-referencing supervisor.updatePolicy/getSnapshot (closure) instead of duplicating + // their logic. Goal status is left untouched, matching STOP's existing behavior exactly. + stopAll: () => + Effect.gen(function* () { + yield* supervisor.updatePolicy({ killSwitchEnabled: true }); + const snapshot = yield* supervisor.getSnapshot(); + let stoppedPeers = 0; + let failures = 0; + + for (const goal of snapshot.goals) { + const status: string = goal.status; + if (goal.peerId === null || (status !== "running" && status !== "pending")) continue; + const ok = yield* ( + goal.workflowId + ? delamainAdapter + .workflowKill({ workflowId: goal.workflowId }) + .pipe(Effect.as(true)) + : delamainAdapter.killPeer({ peerId: goal.peerId }).pipe(Effect.as(true)) + ).pipe(Effect.orElseSucceed(() => false)); + if (ok) { + stoppedPeers += 1; + } else { + failures += 1; + } + } + + return { stoppedPeers, failures } satisfies AutomodeStopAllResult; + }), + killGoal: (input) => + Effect.gen(function* () { + const state = yield* Ref.get(stateRef); + const goal = findGoal(state, input.goalId); + if (goal === null) { + return yield* toAutomodeError(`Automode goal ${input.goalId} was not found.`); + } + + if (goal.peerId !== null) { + // ponytail: kill failure is swallowed (peer may already be dead) — the goal is + // still marked failed below, mirroring stopAll's per-goal tolerance. + yield* ( + goal.workflowId + ? delamainAdapter.workflowKill({ workflowId: goal.workflowId }).pipe(Effect.asVoid) + : delamainAdapter.killPeer({ peerId: goal.peerId }).pipe(Effect.asVoid) + ).pipe(Effect.orElseSucceed(() => undefined)); + } + + return yield* supervisor.failGoal({ + goalId: input.goalId, + reason: "Killed from cockpit.", + }); + }), haltDriver: (input) => Effect.gen(function* () { const updatedAt = yield* nowIso; diff --git a/apps/server/src/gits/Services/AutomodeSupervisor.ts b/apps/server/src/gits/Services/AutomodeSupervisor.ts index a1529661f01..6cbb2e799c4 100644 --- a/apps/server/src/gits/Services/AutomodeSupervisor.ts +++ b/apps/server/src/gits/Services/AutomodeSupervisor.ts @@ -13,6 +13,7 @@ import type { AutomodeRecordHeldPrInput, AutomodeRejectGoalInput, AutomodeSnapshot, + AutomodeStopAllResult, AutomodeSupervisorError, DelamainSendMessageInput, DelamainSendMessageResult, @@ -47,6 +48,13 @@ export interface AutomodeSupervisorShape { readonly failGoal: ( input: AutomodeGoalOutcomeInput, ) => Effect.Effect; + /** Kill switch on first, then best-effort kill every live goal's peer/workflow. Mirrors the + * Telegram STOP command; goal status is left untouched (same as STOP today). */ + readonly stopAll: () => Effect.Effect; + /** Kill one goal's peer/workflow (if any) and mark it failed. Errors if the goal is unknown. */ + readonly killGoal: ( + input: AutomodeGoalInput, + ) => Effect.Effect; readonly haltDriver: ( input: AutomodeDriverHaltInput, ) => Effect.Effect; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 4b2be531899..d66e050b1e9 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2222,28 +2222,14 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "gits" }, ), - // ponytail: schema-only RPCs (WsRpcGroup.of requires an exhaustive handler map, so a - // stub is unavoidable to keep this file compiling) — real logic lands next wave. [WS_METHODS.gitsAutomodeStopAll]: (_input) => - observeRpcEffect( - WS_METHODS.gitsAutomodeStopAll, - Effect.fail( - new AutomodeSupervisorError({ - message: "gits.automode.stopAll is not implemented yet.", - }), - ), - { "rpc.aggregate": "gits" }, - ), - [WS_METHODS.gitsAutomodeGoalsKill]: (_input) => - observeRpcEffect( - WS_METHODS.gitsAutomodeGoalsKill, - Effect.fail( - new AutomodeSupervisorError({ - message: "gits.automode.goals.kill is not implemented yet.", - }), - ), - { "rpc.aggregate": "gits" }, - ), + observeRpcEffect(WS_METHODS.gitsAutomodeStopAll, automodeSupervisor.stopAll(), { + "rpc.aggregate": "gits", + }), + [WS_METHODS.gitsAutomodeGoalsKill]: (input) => + observeRpcEffect(WS_METHODS.gitsAutomodeGoalsKill, automodeSupervisor.killGoal(input), { + "rpc.aggregate": "gits", + }), [WS_METHODS.gitsCapacityGetSnapshot]: (_input) => observeRpcEffect( WS_METHODS.gitsCapacityGetSnapshot, From 906a17fca1e02c2c1f921fd2edf0cbc75b3382e0 Mon Sep 17 00:00:00 2001 From: Joshua Date: Mon, 20 Jul 2026 23:38:21 +0100 Subject: [PATCH 04/12] refactor(cockpit): split 6386-line GitsCockpit.tsx into cockpit/ modules Move-only, zero behavior change: 10 panels, tab registry, ARIA tab nav, shared primitives/formatters, useGitsCockpitQueries (20 queries) and useDevCommandSessions extracted; shell down to 1150 lines. localStorage keys, query keys, poll intervals, and RPC calls byte-identical. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Au2F2zbx3i5NauQHWJn4Gb --- .../gits/GitsCockpit.mcp.browser.tsx | 2 +- apps/web/src/components/gits/GitsCockpit.tsx | 5408 +---------------- .../components/gits/cockpit/AutomodePanel.tsx | 558 ++ .../components/gits/cockpit/CockpitTabNav.tsx | 80 + .../src/components/gits/cockpit/DevPanel.tsx | 201 + .../components/gits/cockpit/FleetPanel.tsx | 330 + .../src/components/gits/cockpit/GsdPanel.tsx | 209 + .../src/components/gits/cockpit/McpPanel.tsx | 471 ++ .../components/gits/cockpit/MotokoPanel.tsx | 1379 +++++ .../components/gits/cockpit/OverviewPanel.tsx | 645 ++ .../components/gits/cockpit/ProjectsPanel.tsx | 279 + .../components/gits/cockpit/SkillsPanel.tsx | 431 ++ .../components/gits/cockpit/UsagePanel.tsx | 174 + .../gits/cockpit/charts/LineAreaChart.tsx | 264 + .../gits/cockpit/charts/chartTheme.ts | 111 + .../gits/cockpit/charts/scale.logic.test.ts | 161 + .../gits/cockpit/charts/scale.logic.ts | 151 + .../components/gits/cockpit/primitives.tsx | 206 + apps/web/src/components/gits/cockpit/tabs.ts | 41 + .../gits/cockpit/useDevCommandSessions.ts | 278 + .../gits/cockpit/useGitsCockpitQueries.ts | 336 + 21 files changed, 6392 insertions(+), 5323 deletions(-) create mode 100644 apps/web/src/components/gits/cockpit/AutomodePanel.tsx create mode 100644 apps/web/src/components/gits/cockpit/CockpitTabNav.tsx create mode 100644 apps/web/src/components/gits/cockpit/DevPanel.tsx create mode 100644 apps/web/src/components/gits/cockpit/FleetPanel.tsx create mode 100644 apps/web/src/components/gits/cockpit/GsdPanel.tsx create mode 100644 apps/web/src/components/gits/cockpit/McpPanel.tsx create mode 100644 apps/web/src/components/gits/cockpit/MotokoPanel.tsx create mode 100644 apps/web/src/components/gits/cockpit/OverviewPanel.tsx create mode 100644 apps/web/src/components/gits/cockpit/ProjectsPanel.tsx create mode 100644 apps/web/src/components/gits/cockpit/SkillsPanel.tsx create mode 100644 apps/web/src/components/gits/cockpit/UsagePanel.tsx create mode 100644 apps/web/src/components/gits/cockpit/charts/LineAreaChart.tsx create mode 100644 apps/web/src/components/gits/cockpit/charts/chartTheme.ts create mode 100644 apps/web/src/components/gits/cockpit/charts/scale.logic.test.ts create mode 100644 apps/web/src/components/gits/cockpit/charts/scale.logic.ts create mode 100644 apps/web/src/components/gits/cockpit/primitives.tsx create mode 100644 apps/web/src/components/gits/cockpit/tabs.ts create mode 100644 apps/web/src/components/gits/cockpit/useDevCommandSessions.ts create mode 100644 apps/web/src/components/gits/cockpit/useGitsCockpitQueries.ts diff --git a/apps/web/src/components/gits/GitsCockpit.mcp.browser.tsx b/apps/web/src/components/gits/GitsCockpit.mcp.browser.tsx index 2ca634f9692..22798dab71d 100644 --- a/apps/web/src/components/gits/GitsCockpit.mcp.browser.tsx +++ b/apps/web/src/components/gits/GitsCockpit.mcp.browser.tsx @@ -5,7 +5,7 @@ import { page } from "vitest/browser"; import { afterEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; -import { McpServersPanel } from "./GitsCockpit"; +import { McpServersPanel } from "./cockpit/McpPanel"; const snapshot: GitsMcpInventorySnapshot = { scannedAt: "2026-07-17T12:00:00.000Z", diff --git a/apps/web/src/components/gits/GitsCockpit.tsx b/apps/web/src/components/gits/GitsCockpit.tsx index 15845df9cea..8372d11bf0f 100644 --- a/apps/web/src/components/gits/GitsCockpit.tsx +++ b/apps/web/src/components/gits/GitsCockpit.tsx @@ -1,4908 +1,65 @@ import type { - AgentSession, - AutomodeGoal, AutomodeSnapshot, - DelamainInboxResult, - DelamainPeer, - DelamainPeerListResult, - GitsCapacitySnapshot, - GitsCodexMcpAuthAvailability, GitsCodexMcpAuthStartResult, - GitsCodexMcpAuthStatus, - GitsCockpitProject, - GitsCockpitSnapshot, - GitsDevCommand, - GitsDevCommandListResult, - GitsMcpInventorySnapshot, GitsMcpServerItem, - GitsMcpServerProvider, - GitsSchedulerSnapshot, - GitsSkillInventoryItem, - GitsSkillInventorySnapshot, - GitsSkillProvider, - GsdPhase, - HermesChatResult, HermesCommandResult, - HermesExecutionDraft, - HermesLogTailResult, - HermesProposalCard, - HermesProposalListResult, HermesScheduleKind, - HermesScheduleRunResult, - HermesSessionListResult, - HermesStatusResult, OpenGsdCommandResult, - OpenGsdStatusResult, - ServerProcessResourceHistoryResult, - TerminalAttachStreamEvent, - UsageSummary, - VerificationGate, - YourTurnCard, } from "@t3tools/contracts"; -import { useMutation, useQuery } from "@tanstack/react-query"; -import * as Option from "effect/Option"; -import { - AlertTriangleIcon, - ArrowUpIcon, - BookOpenCheckIcon, - BotIcon, - CheckCircle2Icon, - CircleStopIcon, - CircleDollarSignIcon, - CopyIcon, - CircleIcon, - FilePlus2Icon, - GaugeIcon, - GitBranchIcon, - ListChecksIcon, - LockIcon, - LockOpenIcon, - MessageSquarePlusIcon, - PenLineIcon, - PlayIcon, - PlugIcon, - PowerIcon, - RefreshCwIcon, - SearchIcon, - SendIcon, - ShieldCheckIcon, - SparklesIcon, - StarIcon, - ExternalLinkIcon, - SquareTerminalIcon, - Trash2Icon, - type LucideIcon, -} from "lucide-react"; -import { useEffect, useMemo, useRef, useState } from "react"; - -import { readEnvironmentApi } from "../../environmentApi"; -import { readLocalApi } from "../../localApi"; -import { - getPrimaryEnvironmentConnection, - readEnvironmentConnection, - useSavedEnvironmentRuntimeStore, -} from "../../environments/runtime"; -import { usePrimaryEnvironmentId } from "../../environments/primary"; -import { cn } from "../../lib/utils"; -import { readUsageSummary } from "../../lib/providerUsage"; -import { useStore } from "../../store"; -import { Button } from "../ui/button"; -import { Input } from "../ui/input"; -import { ScrollArea } from "../ui/scroll-area"; -import { Separator } from "../ui/separator"; -import { - Select, - SelectGroup, - SelectGroupLabel, - SelectItem, - SelectPopup, - SelectTrigger, - SelectValue, -} from "../ui/select"; -import { - Sheet, - SheetDescription, - SheetHeader, - SheetPanel, - SheetPopup, - SheetTitle, -} from "../ui/sheet"; -import { SidebarInset, SidebarTrigger } from "../ui/sidebar"; -import { Textarea } from "../ui/textarea"; -import { ComposerVoiceButton } from "../chat/ComposerVoiceButton"; -import { useVoiceTranscription } from "../../hooks/useVoiceTranscription"; - -const NUMBER_FORMAT = new Intl.NumberFormat(); -const USD_FORMAT = new Intl.NumberFormat(undefined, { - currency: "USD", - maximumFractionDigits: 2, - minimumFractionDigits: 2, - style: "currency", -}); - -function formatBytes(value: number): string { - if (value < 1024) { - return `${value} B`; - } - const units = ["KB", "MB", "GB"] as const; - let unitIndex = -1; - let next = value; - do { - next /= 1024; - unitIndex += 1; - } while (next >= 1024 && unitIndex < units.length - 1); - return `${next.toFixed(next >= 10 ? 1 : 2)} ${units[unitIndex]}`; -} - -function formatCpuTime(seconds: number): string { - if (seconds < 60) { - return `${seconds.toFixed(seconds >= 10 ? 1 : 2)}s`; - } - const minutes = seconds / 60; - if (minutes < 60) { - return `${minutes.toFixed(minutes >= 10 ? 1 : 2)}m`; - } - return `${(minutes / 60).toFixed(2)}h`; -} - -const PHASE_STATUS_LABELS: Record = { - unknown: "Unknown", - discussing: "Discussing", - planned: "Planned", - executing: "Executing", - blocked: "Blocked", - completed: "Completed", - verified: "Verified", -}; - -const GATE_STATUS_LABELS: Record = { - unknown: "Unknown", - missing: "Missing", - pending: "Pending", - blocked: "Blocked", - failed: "Failed", - passed: "Passed", -}; - -type GitsCockpitTab = - | "overview" - | "motoko" - | "dev" - | "fleet" - | "automode" - | "usage" - | "gsd" - | "skills" - | "mcp" - | "projects"; - -const GITS_COCKPIT_TABS: ReadonlyArray<{ - id: GitsCockpitTab; - label: string; - icon: typeof CircleIcon; -}> = [ - { id: "overview", label: "Overview", icon: GaugeIcon }, - { id: "motoko", label: "Motoko", icon: SparklesIcon }, - { id: "dev", label: "Dev", icon: SquareTerminalIcon }, - { id: "fleet", label: "Fleet", icon: GitBranchIcon }, - { id: "automode", label: "Automode", icon: PowerIcon }, - { id: "usage", label: "Usage", icon: CircleDollarSignIcon }, - { id: "gsd", label: "Open GSD", icon: ListChecksIcon }, - { id: "skills", label: "Skills", icon: BookOpenCheckIcon }, - { id: "mcp", label: "MCP", icon: PlugIcon }, - { id: "projects", label: "Projects", icon: CircleIcon }, -]; - -type BuildInfoField = { - readonly label: string; - readonly value: string; -}; - -type BuildInfoSnapshot = - | { - readonly status: "available"; - readonly fields: ReadonlyArray; - readonly note: string | null; - } - | { - readonly status: "missing"; - readonly fields: []; - readonly note: null; - }; - -type SkillReviewState = Record< - string, - { - readonly rating: number | null; - readonly review: string; - } ->; - -const SKILL_REVIEW_STORAGE_KEY = "gits:skills:reviews:v1"; - -function formatCount(value: number): string { - return NUMBER_FORMAT.format(value); -} - -function formatTokenLimit(value: number | null | undefined): string { - if (value === null || value === undefined) { - return "unknown"; - } - if (value >= 1_000_000) { - const millions = value / 1_000_000; - return `${Number.isInteger(millions) ? millions.toFixed(0) : millions.toFixed(1)}m`; - } - if (value >= 1_000) { - const thousands = value / 1_000; - return `${Number.isInteger(thousands) ? thousands.toFixed(0) : thousands.toFixed(1)}k`; - } - return formatCount(value); -} - -function formatUsd(value: number): string { - return USD_FORMAT.format(value); -} - -function formatIsoDate(value: string | null | undefined): string { - if (!value) { - return "unknown"; - } - const date = new Date(value); - return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); -} - -function formatPercent(value: number | null | undefined): string { - return value === null || value === undefined ? "..." : `${value.toFixed(0)}%`; -} - -function formatSlotRemaining(ms: number): string { - const minutes = Math.max(0, Math.round(ms / 60_000)); - return minutes >= 60 ? `${Math.floor(minutes / 60)}h ${minutes % 60}m` : `${minutes}m`; -} - -function tallyValues(values: ReadonlyArray): Record { - return values.reduce>((counts, value) => { - counts[value] = (counts[value] ?? 0) + 1; - return counts; - }, {}); -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -function getNestedString(value: unknown, path: ReadonlyArray): string | null { - let current: unknown = value; - for (const key of path) { - if (!isRecord(current) || !(key in current)) { - return null; - } - current = current[key]; - } - if (typeof current !== "string") { - return null; - } - const trimmed = current.trim(); - return trimmed.length > 0 ? trimmed : null; -} - -function dedupeBuildInfoFields( - fields: ReadonlyArray, -): ReadonlyArray { - const seen = new Set(); - return fields.filter((field) => { - const key = `${field.label}:${field.value}`; - if (seen.has(key)) { - return false; - } - seen.add(key); - return true; - }); -} - -function normalizeBuildInfo(value: unknown): BuildInfoSnapshot { - if (!isRecord(value)) { - return { status: "available", fields: [], note: null }; - } - const fields = dedupeBuildInfoFields( - [ - { label: "Version", value: getNestedString(value, ["version"]) }, - { label: "Commit", value: getNestedString(value, ["commit"]) }, - { label: "Commit", value: getNestedString(value, ["commitSha"]) }, - { label: "Commit", value: getNestedString(value, ["gitSha"]) }, - { label: "Commit", value: getNestedString(value, ["git", "sha"]) }, - { label: "Branch", value: getNestedString(value, ["branch"]) }, - { label: "Branch", value: getNestedString(value, ["gitBranch"]) }, - { label: "Branch", value: getNestedString(value, ["git", "branch"]) }, - { label: "Built", value: getNestedString(value, ["time"]) }, - { label: "Built", value: getNestedString(value, ["builtAt"]) }, - { label: "Built", value: getNestedString(value, ["buildTime"]) }, - { label: "Built", value: getNestedString(value, ["buildTimeUtc"]) }, - { label: "Built", value: getNestedString(value, ["build", "time"]) }, - { label: "Built", value: getNestedString(value, ["timestamp"]) }, - { label: "Environment", value: getNestedString(value, ["environment"]) }, - { label: "Environment", value: getNestedString(value, ["deploymentEnv"]) }, - { label: "Environment", value: getNestedString(value, ["deploy", "environment"]) }, - { label: "Source", value: getNestedString(value, ["sourcePath"]) }, - { label: "Source", value: getNestedString(value, ["source"]) }, - { label: "Source", value: getNestedString(value, ["worktree"]) }, - { label: "Source", value: getNestedString(value, ["builder"]) }, - ] - .filter((field): field is BuildInfoField => field.value !== null) - .slice(0, 6), - ); - const note = - getNestedString(value, ["generatedBy"]) ?? - getNestedString(value, ["deploy", "provider"]) ?? - getNestedString(value, ["provider"]); - return { status: "available", fields, note }; -} - -function loadSkillReviewState(): SkillReviewState { - if (typeof window === "undefined") { - return {}; - } - try { - const raw = window.localStorage.getItem(SKILL_REVIEW_STORAGE_KEY); - if (!raw) { - return {}; - } - const parsed = JSON.parse(raw) as unknown; - if (!isRecord(parsed)) { - return {}; - } - const reviews: SkillReviewState = {}; - for (const [skillId, value] of Object.entries(parsed)) { - if (!isRecord(value)) { - continue; - } - const rating = typeof value.rating === "number" ? value.rating : null; - const review = typeof value.review === "string" ? value.review : ""; - reviews[skillId] = { - rating: - rating !== null && Number.isInteger(rating) && rating >= 1 && rating <= 5 ? rating : null, - review, - }; - } - return reviews; - } catch { - return {}; - } -} - -function saveSkillReviewState(reviews: SkillReviewState): void { - if (typeof window === "undefined") { - return; - } - window.localStorage.setItem(SKILL_REVIEW_STORAGE_KEY, JSON.stringify(reviews)); -} - -function formatSkillProvider(provider: GitsSkillProvider): string { - if (provider === "gits") { - return "GITS"; - } - return provider.replace(/\b\w/g, (letter) => letter.toUpperCase()); -} - -function formatSkillKind(kind: GitsSkillInventoryItem["kind"]): string { - return kind.replaceAll("-", " "); -} - -function portabilityTone( - portability: GitsSkillInventoryItem["portability"], -): ReturnType { - if (portability === "native" || portability === "ported") { - return "success"; - } - if (portability === "missing-port" || portability === "candidate") { - return "warning"; - } - return "default"; -} - -type McpOverrideState = Record; - -const MCP_SERVER_OVERRIDE_STORAGE_KEY = "gits:mcp:overrides:v1"; - -function loadMcpOverrideState(): McpOverrideState { - if (typeof window === "undefined") { - return {}; - } - try { - const raw = window.localStorage.getItem(MCP_SERVER_OVERRIDE_STORAGE_KEY); - if (!raw) { - return {}; - } - const parsed = JSON.parse(raw) as unknown; - if (!isRecord(parsed)) { - return {}; - } - const overrides: McpOverrideState = {}; - for (const [serverId, value] of Object.entries(parsed)) { - if (typeof value === "boolean") { - overrides[serverId] = value; - } - } - return overrides; - } catch { - return {}; - } -} - -function saveMcpOverrideState(overrides: McpOverrideState): void { - if (typeof window === "undefined") { - return; - } - window.localStorage.setItem(MCP_SERVER_OVERRIDE_STORAGE_KEY, JSON.stringify(overrides)); -} - -function formatMcpProvider(provider: GitsMcpServerProvider): string { - if (provider === "unknown") { - return "Unknown"; - } - return provider.replace(/\b\w/g, (letter) => letter.toUpperCase()); -} - -function isMcpServerEnabled(server: GitsMcpServerItem, overrides: McpOverrideState): boolean { - return overrides[server.id] ?? server.enabled; -} - -function mcpStatusTone(server: GitsMcpServerItem, enabled: boolean): ReturnType { - if (!enabled) { - return "default"; - } - if (server.status === "running") { - return "success"; - } - if (server.status === "error") { - return "danger"; - } - if (server.status === "stopped") { - return "warning"; - } - return "default"; -} - -function applySkillReviews( - skill: GitsSkillInventoryItem, - reviews: SkillReviewState, -): GitsSkillInventoryItem { - const review = reviews[skill.id]; - if (!review) { - return skill; - } - return { - ...skill, - rating: review.rating, - review: review.review.trim().length > 0 ? review.review : null, - }; -} - -function statusTone(status: string): "default" | "warning" | "danger" | "success" { - if (status === "passed" || status === "verified" || status === "completed") { - return "success"; - } - if (status === "blocked" || status === "failed") { - return "danger"; - } - if (status === "missing" || status === "pending" || status === "planned") { - return "warning"; - } - return "default"; -} - -function StatusPill({ label, tone }: { label: string; tone: ReturnType }) { - return ( - - {label} - - ); -} - -function StatBlock({ - label, - value, - icon: Icon, -}: { - label: string; - value: string; - icon: typeof CircleIcon; -}) { - return ( -
-
- - {label} -
-
{value}
-
- ); -} - -function EmptyState({ label }: { label: string }) { - return
{label}
; -} - -function SectionHeader({ title, count }: { title: string; count: number }) { - return ( -
-

{title}

- {formatCount(count)} -
- ); -} - -function YourTurnList({ cards }: { cards: ReadonlyArray }) { - if (cards.length === 0) { - return ; - } - - return ( -
- {cards.slice(0, 6).map((card) => ( -
-
- - {card.title} - -
-

{card.detail}

-
- ))} -
- ); -} - -function PhaseTable({ phases }: { phases: ReadonlyArray }) { - if (phases.length === 0) { - return ; - } - - return ( - - - - - - - - - - - - {phases.map((phase) => ( - - - - - - - ))} - -
PhaseStateArtifactsFlags
-
{phase.title}
-
{phase.id}
-
- - - {[ - phase.hasContext ? "context" : null, - phase.hasSpec ? "spec" : null, - phase.hasPlan ? "plan" : null, - phase.hasFrozenContract ? "frozen" : null, - phase.hasVerification ? "verification" : null, - phase.hasSummary ? "summary" : null, - ] - .filter(Boolean) - .join(", ") || "none"} - - {phase.riskFlags.length > 0 ? phase.riskFlags.join(", ") : "none"} -
-
- ); -} - -function GateList({ gates }: { gates: ReadonlyArray }) { - if (gates.length === 0) { - return ; - } - - return ( -
- {gates.slice(0, 8).map((gate) => ( -
- -
-
{gate.label}
-
- {gate.evidenceSummary ?? gate.phaseId ?? "Project gate"} -
-
- -
- ))} -
- ); -} - -function AgentSessionList({ sessions }: { sessions: ReadonlyArray }) { - if (sessions.length === 0) { - return ; - } - - return ( -
- {sessions.map((session) => ( -
- -
-
{session.provider}
-
- {session.model ?? "No model"} | {session.worktreePath ?? session.cwd} -
-
- -
- ))} -
- ); -} - -function barToneClass(tone: ReturnType): string { - if (tone === "success") { - return "bg-emerald-500"; - } - if (tone === "warning") { - return "bg-amber-500"; - } - if (tone === "danger") { - return "bg-destructive"; - } - return "bg-muted-foreground/45"; -} - -function DistributionPanel({ - title, - rows, -}: { - title: string; - rows: ReadonlyArray<{ - label: string; - value: number; - tone: ReturnType; - }>; -}) { - const total = rows.reduce((sum, row) => sum + row.value, 0); - - return ( -
- -
- {rows.length === 0 ? ( - - ) : ( - rows.map((row) => { - const width = total === 0 ? 0 : Math.max(4, Math.round((row.value / total) * 100)); - return ( -
-
- {row.label} - - {formatCount(row.value)} - -
-
-
-
-
- ); - }) - )} -
-
- ); -} - -function SignalRow({ - label, - value, - detail, - tone, -}: { - label: string; - value: string; - detail: string; - tone: ReturnType; -}) { - return ( -
-
- {label} - -
-
{detail}
-
- ); -} - -function CockpitTabNav({ - activeTab, - counts, - onTabChange, -}: { - activeTab: GitsCockpitTab; - counts: Record; - onTabChange: (tab: GitsCockpitTab) => void; -}) { - return ( -
-
{ - const count = GITS_COCKPIT_TABS.length; - const index = GITS_COCKPIT_TABS.findIndex((tab) => tab.id === activeTab); - let nextIndex: number | null = null; - if (event.key === "ArrowRight") { - nextIndex = (index + 1) % count; - } else if (event.key === "ArrowLeft") { - nextIndex = (index - 1 + count) % count; - } else if (event.key === "Home") { - nextIndex = 0; - } else if (event.key === "End") { - nextIndex = count - 1; - } - if (nextIndex === null) { - return; - } - event.preventDefault(); - const nextTab = GITS_COCKPIT_TABS[nextIndex]!; - onTabChange(nextTab.id); - event.currentTarget - .querySelector(`#gits-cockpit-tab-${nextTab.id}`) - ?.focus(); - }} - > - {GITS_COCKPIT_TABS.map((tab) => { - const Icon = tab.icon; - const selected = activeTab === tab.id; - return ( - - ); - })} -
-
- ); -} - -function BuildProvenancePanel({ - buildInfo, - loading, - error, - onRefresh, -}: { - buildInfo: BuildInfoSnapshot | undefined; - loading: boolean; - error: unknown; - onRefresh: () => void; -}) { - const errorMessage = error instanceof Error ? error.message : null; - const fields = buildInfo?.status === "available" ? buildInfo.fields : []; - const note = buildInfo?.status === "available" ? buildInfo.note : null; - - return ( -
-
-
-
-

Build Provenance

- - -
-

- {buildInfo?.status === "missing" - ? "This host does not expose /api/gits/build-info." - : (note ?? "Compact host build metadata when available.")} -

-
- -
- - {errorMessage ? ( -
- {errorMessage} -
- ) : null} - - {buildInfo?.status === "missing" ? ( - - ) : loading && !buildInfo ? ( - - ) : fields.length === 0 ? ( - - ) : ( -
- {fields.map((field) => ( -
-
- {field.label} -
-
- {field.label === "Built" ? formatIsoDate(field.value) : field.value} -
-
- ))} -
- )} -
- ); -} - -function UsagePanel({ - usage, - loading, - error, - onRefresh, -}: { - usage: UsageSummary | undefined; - loading: boolean; - error: unknown; - onRefresh: () => void; -}) { - const errorMessage = error instanceof Error ? error.message : null; - const topModels = usage?.models.slice(0, 8) ?? []; - - return ( -
-
-
-
-

Usage

- - - -
-

- {usage - ? `${formatCount(usage.totals.totalTokens)} tokens scanned | ${formatIsoDate( - usage.checkedAt, - )}` - : "Reading local provider JSONL usage logs."} -

-
- -
- - {errorMessage ? ( -
- {errorMessage} -
- ) : null} - - {loading && !usage ? ( - - ) : !usage ? ( - - ) : ( - <> -
- - - total + row.requestCount, 0))} - icon={BotIcon} - /> - -
- -
- {usage.windows.length === 0 ? ( - - ) : ( - usage.windows.map((window) => ( - - )) - )} -
- - - - - - - - - - - - - - - {topModels.map((row) => ( - - - - - - - - - ))} - -
Provider / modelRequestsInputCachedOutputCost
-
- - {row.model} -
-
- {formatCount(row.requestCount)} - - {formatCount(row.tokens.inputTokens)} - - {formatCount( - row.tokens.cachedInputTokens + - row.tokens.cacheCreationInputTokens + - row.tokens.cacheReadInputTokens, - )} - - {formatCount(row.tokens.outputTokens + row.tokens.reasoningOutputTokens)} - - {formatUsd(row.estimatedCostUsd)} -
-
- -
- {usage.sources.map((source) => ( -
- {source.provider} |{" "} - {source.status} | {formatCount(source.scannedFilePaths.length)} files |{" "} - {source.note ?? source.homePath} -
- ))} -
- - )} -
- ); -} - -function CockpitOverviewPanel({ - snapshot, - delamain, - automode, - openGsd, - hermes, - proposals, - capacity, - history, - buildInfo, -}: { - snapshot: GitsCockpitSnapshot; - delamain: DelamainPeerListResult | undefined; - automode: AutomodeSnapshot | undefined; - openGsd: OpenGsdStatusResult | undefined; - hermes: HermesStatusResult | undefined; - proposals: HermesProposalListResult | undefined; - capacity: GitsCapacitySnapshot | undefined; - history: ServerProcessResourceHistoryResult | undefined; - buildInfo: BuildInfoSnapshot | undefined; -}) { - const phases = snapshot.projects.flatMap((project) => project.phases); - const gates = snapshot.projects.flatMap((project) => project.verificationGates); - const yourTurn = snapshot.projects.flatMap((project) => project.yourTurn); - const peers = delamain?.peers ?? []; - const phaseCounts = tallyValues(phases.map((phase) => phase.status)); - const gateCounts = tallyValues(gates.map((gate) => gate.status)); - const peerCounts = tallyValues(peers.map((peer) => peer.status)); - const phaseRows = (Object.keys(PHASE_STATUS_LABELS) as Array) - .map((status) => ({ - label: PHASE_STATUS_LABELS[status], - value: phaseCounts[status] ?? 0, - tone: statusTone(status), - })) - .filter((row) => row.value > 0); - const gateRows = (Object.keys(GATE_STATUS_LABELS) as Array) - .map((status) => ({ - label: GATE_STATUS_LABELS[status], - value: gateCounts[status] ?? 0, - tone: statusTone(status), - })) - .filter((row) => row.value > 0); - const peerRows = Object.entries(peerCounts) - .map(([status, value]) => ({ - label: status, - value, - tone: statusTone(status), - })) - .sort((a, b) => b.value - a.value || a.label.localeCompare(b.label)); - const blockedPhaseCount = phases.filter((phase) => phase.status === "blocked").length; - const failedGateCount = gates.filter( - (gate) => gate.status === "failed" || gate.status === "blocked", - ).length; - const criticalTurnCount = yourTurn.filter((card) => card.severity === "critical").length; - const issueCount = - blockedPhaseCount + failedGateCount + criticalTurnCount + (automode?.pendingApprovalCount ?? 0); - const resourceError = history ? Option.getOrNull(history.error) : null; - const pendingProposalCount = - proposals?.proposals.filter((proposal) => proposal.status === "proposed").length ?? 0; - const topProposal = proposals?.proposals.find((proposal) => proposal.status === "proposed"); - - return ( -
-
-
-
-

Overview

- - -
-

- scanned {formatIsoDate(snapshot.scannedAt)} |{" "} - {formatCount(snapshot.totals.projectCount)} projects |{" "} - {formatCount(snapshot.totals.phaseCount)} phases -

-
-
- -
- - - - - - - - -
- -
- - 0 ? "warning" : "success"} - detail={topProposal?.title ?? "No pending Motoko proposals"} - /> - - - - - - - field.label === "Built" - ? `${field.label} ${formatIsoDate(field.value)}` - : `${field.label} ${field.value}`, - ) - .join(" | ") || "Build metadata detected" - : buildInfo?.status === "missing" - ? "No /api/gits/build-info endpoint" - : "Checking build provenance" - } - /> -
- -
- - - -
-
- ); -} - -function ResourceVisibilityPanel({ - snapshot, - automode, - history, - loading, - error, - onRefresh, -}: { - snapshot: GitsCockpitSnapshot; - automode: AutomodeSnapshot | undefined; - history: ServerProcessResourceHistoryResult | undefined; - loading: boolean; - error: unknown; - onRefresh: () => void; -}) { - const resourceError = history ? Option.getOrNull(history.error) : null; - const errorMessage = - error instanceof Error ? error.message : resourceError ? resourceError.message : null; - const topProcesses = history?.topProcesses.slice(0, 5) ?? []; - const maxRssBytes = - history?.topProcesses.reduce((max, process) => Math.max(max, process.maxRssBytes), 0) ?? 0; - const budgetUsage = automode?.budgetUsage; - const costLabel = - budgetUsage?.totalCostUsd !== null && budgetUsage?.totalCostUsd !== undefined - ? formatUsd(budgetUsage.totalCostUsd) - : "unavailable"; - const costPillLabel = - budgetUsage?.totalCostUsd !== null && budgetUsage?.totalCostUsd !== undefined - ? "cost tracked" - : "cost unavailable"; - - return ( -
-
-
-
-

Runtime Visibility

- - -
-

- {history - ? `${formatCount(history.retainedSampleCount)} retained samples | ${formatCount( - history.topProcesses.length, - )} processes` - : "Collecting process samples"} -

-
- -
- - {errorMessage ? ( -
- {errorMessage} -
- ) : null} - -
- - - - -
- -
- - {topProcesses.length === 0 ? ( - - ) : ( -
- {topProcesses.map((process) => ( -
-
-
{process.command}
-
- pid {process.pid} | samples {formatCount(process.sampleCount)} -
-
-
- {formatCpuTime(process.cpuSecondsApprox)} -
-
- {process.currentCpuPercent.toFixed(1)}% -
-
- {formatBytes(process.maxRssBytes)} -
-
- ))} -
- )} -
-
- ); -} - -function peerStatusTone(peer: DelamainPeer): ReturnType { - return statusTone(peer.status); -} - -function PeerFleetPanel({ - list, - loading, - error, - selectedPeerId, - logText, - logLoading, - inbox, - actionError, - spawnRepo, - spawnName, - spawnPrompt, - replyText, - actionPending, - onRefresh, - onSelectPeer, - onSpawnRepoChange, - onSpawnNameChange, - onSpawnPromptChange, - onReplyTextChange, - onSpawn, - onReply, - onWait, - onKill, - onIntegrate, - killSwitchEnabled, -}: { - list: DelamainPeerListResult | undefined; - loading: boolean; - error: unknown; - selectedPeerId: string | null; - logText: string | undefined; - logLoading: boolean; - inbox: DelamainInboxResult | undefined; - actionError: unknown; - spawnRepo: string; - spawnName: string; - spawnPrompt: string; - replyText: string; - actionPending: boolean; - onRefresh: () => void; - onSelectPeer: (peerId: string) => void; - onSpawnRepoChange: (value: string) => void; - onSpawnNameChange: (value: string) => void; - onSpawnPromptChange: (value: string) => void; - onReplyTextChange: (value: string) => void; - onSpawn: () => void; - onReply: () => void; - onWait: () => void; - onKill: () => void; - onIntegrate: () => void; - killSwitchEnabled: boolean; -}) { - const peers = list?.peers ?? []; - const selectedPeer = selectedPeerId - ? (peers.find((peer) => peer.id === selectedPeerId) ?? null) - : null; - const supported = new Set(list?.capabilities.supported ?? []); - // The automode kill switch (R#1) freezes the guarded manual actions - // (spawn/reply/kill/integrate) server-side; disable them here so the block is a - // visible, explained state instead of a bare RPC error. `wait` is read-only and - // stays enabled. - const canSpawn = - supported.has("spawn") && - spawnRepo.trim().length > 0 && - spawnPrompt.trim().length > 0 && - !killSwitchEnabled; - const canReply = - supported.has("reply") && - selectedPeer !== null && - replyText.trim().length > 0 && - !killSwitchEnabled; - const canWait = supported.has("wait") && selectedPeer !== null; - const canKill = supported.has("kill") && selectedPeer !== null && !killSwitchEnabled; - const canIntegrate = supported.has("integrate") && selectedPeer !== null && !killSwitchEnabled; - const errorMessage = - error instanceof Error - ? error.message - : actionError instanceof Error - ? actionError.message - : null; - - return ( -
-
-
-
-

Delamain Peer Fleet

- -
-

- {list?.capabilities.binaryPath ?? "delamain"} |{" "} - {list ? `${list.capabilities.supported.length} controls detected` : "checking"} -

-
- -
- - {errorMessage ? ( -
- {errorMessage} -
- ) : null} - -
-
- - {loading && peers.length === 0 ? ( - - ) : peers.length === 0 ? ( - - ) : ( -
- {peers.map((peer) => ( - - ))} -
- )} - -
-
-
- onSpawnRepoChange(event.currentTarget.value)} - /> - onSpawnNameChange(event.currentTarget.value)} - /> -
-