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/AutomodeProposalSweep.test.ts b/apps/server/src/gits/Layers/AutomodeProposalSweep.test.ts index 7331b2e40d0..15a9b955016 100644 --- a/apps/server/src/gits/Layers/AutomodeProposalSweep.test.ts +++ b/apps/server/src/gits/Layers/AutomodeProposalSweep.test.ts @@ -6,6 +6,7 @@ import * as TestClock from "effect/testing/TestClock"; import type { AutomodeBudgetUsage, + AutomodeEpisode, DelamainPeerListResult, HermesExecutionDraft, HermesProposalCard, @@ -13,12 +14,14 @@ import type { import { HermesAdapterError } from "@t3tools/contracts"; import { ServerConfig } from "../../config.ts"; +import { AutomodeEpisodeLedger } from "../../persistence/Services/AutomodeEpisodeLedger.ts"; import { AutomodeLanding } from "../Services/AutomodeLanding.ts"; import { AutomodeProposalSweep } from "../Services/AutomodeProposalSweep.ts"; import { AutomodeSupervisor } from "../Services/AutomodeSupervisor.ts"; import { AutomodeUsageMeter } from "../Services/AutomodeUsageMeter.ts"; import { DelamainAdapter } from "../Services/DelamainAdapter.ts"; import { HermesAdapter } from "../Services/HermesAdapter.ts"; +import { HermesTelegramNotifier } from "../Services/HermesTelegramNotifier.ts"; import { AutomodeProposalSweepLive } from "./AutomodeProposalSweep.ts"; import { AutomodeSupervisorLive } from "./AutomodeSupervisor.ts"; @@ -97,7 +100,9 @@ function makeDraft(overrides: Partial): HermesExecutionDra interface HermesOptions { readonly inspect?: (projectDir: string) => Effect.Effect; readonly draft?: HermesExecutionDraft; - readonly onInspect?: (projectDir: string) => void; + readonly onInspect?: (input: { readonly projectDir: string; readonly prompt?: string }) => void; + readonly episodes?: ReadonlyArray; + readonly telegramSent?: string[]; } function makeLayer(hermesOptions: HermesOptions = {}) { @@ -125,8 +130,8 @@ function makeLayer(hermesOptions: HermesOptions = {}) { // cross-night dedup guard sees distinct episodes. let proposalSeq = 0; const hermes = Layer.mock(HermesAdapter)({ - inspectGitsAndPropose: ({ projectDir }) => { - hermesOptions.onInspect?.(projectDir); + inspectGitsAndPropose: ({ projectDir, prompt }) => { + hermesOptions.onInspect?.(prompt === undefined ? { projectDir } : { projectDir, prompt }); proposalSeq += 1; return hermesOptions.inspect === undefined ? Effect.succeed( @@ -141,9 +146,20 @@ function makeLayer(hermesOptions: HermesOptions = {}) { decideProposal: () => Effect.succeed(makeCard({ status: "approved" })), draftFromProposal: () => Effect.succeed(hermesOptions.draft ?? makeDraft({})), }); + const ledger = Layer.mock(AutomodeEpisodeLedger)({ + list_episodes: () => Effect.succeed(hermesOptions.episodes ?? []), + }); + const notifier = Layer.mock(HermesTelegramNotifier)({ + notify: ({ subject, text }) => + Effect.sync(() => { + hermesOptions.telegramSent?.push(`${subject}\n${text}`); + }), + }); return AutomodeProposalSweepLive.pipe( Layer.provideMerge(supervisor), Layer.provide(hermes), + Layer.provide(ledger), + Layer.provide(notifier), Layer.provideMerge(TestClock.layer()), ); } @@ -151,17 +167,22 @@ function makeLayer(hermesOptions: HermesOptions = {}) { function arm(options?: { readonly nightlyProposalSweep?: boolean; readonly proposalRepos?: string[]; + readonly sweepRequiresConfirmation?: boolean; + readonly requireApprovalForPeerSpawn?: boolean; }) { return Effect.gen(function* () { const supervisor = yield* AutomodeSupervisor; yield* supervisor.updatePolicy({ mode: "autonomous", killSwitchEnabled: false, - requireApprovalForPeerSpawn: false, + requireApprovalForPeerSpawn: options?.requireApprovalForPeerSpawn ?? false, maxBudgetUsd: 25, allowedRepos: [REPO, OTHER_REPO], nightlyProposalSweep: options?.nightlyProposalSweep ?? true, proposalRepos: options?.proposalRepos ?? [REPO], + // Legacy self-approved-queued path by default — matches every pre-existing test's + // expectations; only the new confirmation-loop tests below opt into the gate. + sweepRequiresConfirmation: options?.sweepRequiresConfirmation ?? false, }); return supervisor; }); @@ -304,4 +325,125 @@ describe("AutomodeProposalSweep", () => { assert.equal((yield* supervisor.getSnapshot()).goals.length, 0); }).pipe(Effect.provide(makeLayer({ onInspect: () => (inspects += 1) }))); }); + + it.effect( + "parks the sweep-drafted goal at waiting-approval and Telegram-announces it with its id when confirmation is required", + () => { + const telegramSent: string[] = []; + return Effect.gen(function* () { + const supervisor = yield* arm({ + sweepRequiresConfirmation: true, + requireApprovalForPeerSpawn: true, + }); + const sweep = yield* AutomodeProposalSweep; + yield* TestClock.setTime(EVENING); + yield* sweep.tick(); + + const snapshot = yield* supervisor.getSnapshot(); + assert.equal(snapshot.goals.length, 1); + const goal = snapshot.goals[0]!; + // The confirmation gate parks the goal — it must NOT be self-approved. + assert.equal(goal.status, "waiting-approval"); + assert.isNull(goal.approvedAt); + + assert.equal(telegramSent.length, 1); + const message = telegramSent[0] ?? ""; + assert.include(message, `[${goal.id}]`); + assert.match(message, /Improve sweep repo — sweep-repo/); + assert.include(message, "Reply: APPROVE · REJECT "); + }).pipe(Effect.provide(makeLayer({ telegramSent }))); + }, + ); + + it.effect( + "parks the sweep-drafted goal at waiting-approval even when requireApprovalForPeerSpawn is off", + () => { + return Effect.gen(function* () { + // sweepRequiresConfirmation must gate independent of requireApprovalForPeerSpawn — + // an operator who disables per-peer-spawn approval must not thereby also waive the + // owner's sweep confirmation contract. + const supervisor = yield* arm({ + sweepRequiresConfirmation: true, + requireApprovalForPeerSpawn: false, + }); + const sweep = yield* AutomodeProposalSweep; + yield* TestClock.setTime(EVENING); + yield* sweep.tick(); + + const snapshot = yield* supervisor.getSnapshot(); + assert.equal(snapshot.goals.length, 1); + const goal = snapshot.goals[0]!; + assert.equal(goal.status, "waiting-approval"); + assert.isNull(goal.approvedAt); + }).pipe(Effect.provide(makeLayer())); + }, + ); + + it.effect( + "keeps the legacy self-queued path (still Telegram-announced) when sweepRequiresConfirmation is off", + () => { + const telegramSent: string[] = []; + return Effect.gen(function* () { + const supervisor = yield* arm({ sweepRequiresConfirmation: false }); + const sweep = yield* AutomodeProposalSweep; + yield* TestClock.setTime(EVENING); + yield* sweep.tick(); + + const snapshot = yield* supervisor.getSnapshot(); + assert.equal(snapshot.goals.length, 1); + // Byte-identical to the pre-confirmation-loop behavior: queued, not approved, + // no dispatch-gate probe. + assert.equal(snapshot.goals[0]!.status, "queued"); + assert.isNull(snapshot.goals[0]!.approvedAt); + + assert.equal(telegramSent.length, 1); + assert.include(telegramSent[0] ?? "", `[${snapshot.goals[0]!.id}]`); + // With confirmation off the goals are already queued for autonomous dispatch — + // the message must not advertise an APPROVE/REJECT gate that does nothing. + assert.notInclude(telegramSent[0] ?? "", "APPROVE"); + assert.include(telegramSent[0] ?? "", "no confirmation required"); + assert.include(telegramSent[0] ?? "", "STOP"); + }).pipe(Effect.provide(makeLayer({ telegramSent }))); + }, + ); + + it.effect("folds recent ledger outcomes for the repo into the Hermes proposal prompt", () => { + const capturedPrompts: Array = []; + return Effect.gen(function* () { + yield* arm(); + const sweep = yield* AutomodeProposalSweep; + yield* TestClock.setTime(EVENING); + yield* sweep.tick(); + + assert.equal(capturedPrompts.length, 1); + const prompt = capturedPrompts[0] ?? ""; + assert.match(prompt, /Recent automode outcomes for this repo:/); + assert.match(prompt, /Fix flaky auth test: pass/); + assert.match(prompt, /Refactor logger: fail \(flagged\)/); + }).pipe( + Effect.provide( + makeLayer({ + episodes: [ + { goalTitle: "Fix flaky auth test", verdict: "pass", flagged: false }, + { goalTitle: "Refactor logger", verdict: "fail", flagged: true }, + ] as never, + onInspect: (input) => capturedPrompts.push(input.prompt), + }), + ), + ); + }); + + it.effect("omits the outcomes section when the ledger has no rows for the repo", () => { + const capturedPrompts: Array = []; + return Effect.gen(function* () { + yield* arm(); + const sweep = yield* AutomodeProposalSweep; + yield* TestClock.setTime(EVENING); + yield* sweep.tick(); + assert.equal(capturedPrompts.length, 1); + assert.isUndefined(capturedPrompts[0]); + }).pipe( + Effect.provide(makeLayer({ onInspect: (input) => capturedPrompts.push(input.prompt) })), + ); + }); }); diff --git a/apps/server/src/gits/Layers/AutomodeProposalSweep.ts b/apps/server/src/gits/Layers/AutomodeProposalSweep.ts index 628154f0191..1a3ff3c69b3 100644 --- a/apps/server/src/gits/Layers/AutomodeProposalSweep.ts +++ b/apps/server/src/gits/Layers/AutomodeProposalSweep.ts @@ -8,15 +8,21 @@ import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; +import type { AutomodeGoal } from "@t3tools/contracts"; + import { writeFileStringAtomically } from "../../atomicWrite.ts"; import { ServerConfig } from "../../config.ts"; +import { AutomodeEpisodeLedger } from "../../persistence/Services/AutomodeEpisodeLedger.ts"; import { HermesAdapter } from "../Services/HermesAdapter.ts"; import { AutomodeProposalSweep } from "../Services/AutomodeProposalSweep.ts"; import { AutomodeSupervisor } from "../Services/AutomodeSupervisor.ts"; +import { HermesTelegramNotifier } from "../Services/HermesTelegramNotifier.ts"; import { hasLiveGoalForRepo } from "./HermesAutomodeBridge.ts"; import { london_instant } from "./GitsSlotScheduler.ts"; import { repoAllowed } from "./AutomodeSupervisor.ts"; +const REPLY_HINT = "Reply: APPROVE · REJECT "; + const STATE_FILE_NAME = "automode-proposal-sweep-state.json"; const DEFAULT_START_MINUTES = 20 * 60; // 20:00 London dinner window. const START_TIME_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/; @@ -95,6 +101,8 @@ export const AutomodeProposalSweepLive = Layer.effect( const path = yield* Path.Path; const supervisor = yield* AutomodeSupervisor; const hermes = yield* HermesAdapter; + const ledger = yield* AutomodeEpisodeLedger; + const notifier = yield* HermesTelegramNotifier; const parsedStart = parseStartMinutes(process.env.GITS_PROPOSAL_SWEEP_START_HHMM); if (parsedStart.error !== null) { yield* Effect.logWarning("gits.sweep.start-invalid", { detail: parsedStart.error }); @@ -106,9 +114,10 @@ export const AutomodeProposalSweepLive = Layer.effect( // Propose to ONE repo: skip it if a goal is already live for it (dedup BEFORE the codex // spend), then spawn Hermes, approve the card, draft it, and enqueue the drafted - // delamain-peer as a waiting-approval goal carrying the card's episodeId. A failure here - // is logged and swallowed so the sweep continues to the next repo. - const sweepRepo = (repo: string) => + // delamain-peer as a queued goal carrying the card's episodeId. A failure here is + // logged and swallowed so the sweep continues to the next repo. Returns the enqueued + // goal (null on any skip/failure) so the caller can gate approval and notify. + const sweepRepo = (repo: string): Effect.Effect => Effect.gen(function* () { // Dedup by repo, ahead of hermes: a fresh inspect always mints a new episodeId, so // episode dedup could never fire here. A repo whose prior-night sweep or cockpit @@ -116,13 +125,27 @@ export const AutomodeProposalSweepLive = Layer.effect( const snapshot = yield* supervisor.getSnapshot(); if (hasLiveGoalForRepo(snapshot.goals, repo)) { yield* Effect.logInfo("gits.sweep.dedup-skipped", { repo }); - return; + return null; } + // Feedback loop: fold the repo's recent ledger outcomes into the proposal prompt so + // Hermes doesn't blindly repeat an idea that already ran, landed, or got flagged. + const episodes = yield* ledger.list_episodes({ repo, limit: 5 }); + const outcomesSection = + episodes.length === 0 + ? undefined + : [ + "Recent automode outcomes for this repo:", + ...episodes.map( + (episode) => + `- ${episode.goalTitle}: ${episode.verdict}${episode.flagged ? " (flagged)" : ""}`, + ), + ].join("\n"); // "worktree-spawn" makes the card actionable: a read-only inspect card can only ever // draft as "verification", never "delamain-peer" (draftKindFor keys on actionKind). const card = yield* hermes.inspectGitsAndPropose({ projectDir: repo, actionKind: "worktree-spawn", + ...(outcomesSection === undefined ? {} : { prompt: outcomesSection }), }); if (card.status === "blocked" || card.projectDir === null) { yield* Effect.logInfo("gits.sweep.card-skipped", { @@ -130,7 +153,7 @@ export const AutomodeProposalSweepLive = Layer.effect( status: card.status, blockedReason: card.blockedReason, }); - return; + return null; } // Approve before drafting — mirrors the approve bridge's decide→draft order. // draftFromProposal blocks any card whose status !== "approved". @@ -142,17 +165,22 @@ export const AutomodeProposalSweepLive = Layer.effect( kind: draft.kind, status: draft.status, }); - return; + return null; } - yield* supervisor.enqueueGoal({ + const enqueued = yield* supervisor.enqueueGoal({ title: draft.title, prompt: draft.prompt, repo: draft.repo, episodeId: card.episodeId, + origin: "sweep", }); + // enqueueGoal prepends the new goal, so it's always the freshest entry. + return enqueued.goals[0] ?? null; }).pipe( Effect.catchCause((cause) => - Effect.logWarning("gits.sweep.repo-failed", { repo, cause: Cause.pretty(cause) }), + Effect.logWarning("gits.sweep.repo-failed", { repo, cause: Cause.pretty(cause) }).pipe( + Effect.as(null), + ), ), ); @@ -183,12 +211,52 @@ export const AutomodeProposalSweepLive = Layer.effect( ); yield* Ref.set(stateRef, next); // Sequential: hermes invocations share codex capacity — never parallelise. + const newGoals: AutomodeGoal[] = []; for (const repo of policy.proposalRepos) { if (!repoAllowed(policy, repo)) { yield* Effect.logWarning("gits.sweep.repo-not-allowed", { repo }); continue; } - yield* sweepRepo(repo); + const goal = yield* sweepRepo(repo); + if (goal === null) continue; + newGoals.push(goal); + if (policy.sweepRequiresConfirmation) { + // Parks the goal at waiting-approval via the shared dispatch-time approval gate: + // the goal carries origin "sweep" (enqueued above), and goalNeedsApproval in + // AutomodeSupervisor.ts treats sweep + sweepRequiresConfirmation as needing + // approval independent of requireApprovalForPeerSpawn. + yield* supervisor.dispatchGoal({ goalId: goal.id }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("gits.sweep.confirm-gate-failed", { + goalId: goal.id, + cause: Cause.pretty(cause), + }), + ), + ); + } + } + if (newGoals.length > 0) { + const goalLines = newGoals.map( + (goal) => `${goal.title} — ${path.basename(goal.repo)} [${goal.id}]`, + ); + // The reply hint must reflect reality: with confirmation off, goals are already + // queued for autonomous dispatch — APPROVE/REJECT would be a false gate; STOP is + // the only real control. + const text = policy.sweepRequiresConfirmation + ? ["New goals from tonight's sweep:", ...goalLines, "", REPLY_HINT].join("\n") + : [ + "New goals from tonight's sweep (queued for autonomous run — no confirmation required):", + ...goalLines, + "", + "Reply: STOP to halt automode.", + ].join("\n"); + yield* notifier + .notify({ subject: "GITS nightly sweep", text }) + .pipe( + Effect.catchCause((cause) => + Effect.logWarning("gits.sweep.telegram-failed", { cause: Cause.pretty(cause) }), + ), + ); } }).pipe( Effect.catchCause((cause) => diff --git a/apps/server/src/gits/Layers/AutomodeSupervisor.test.ts b/apps/server/src/gits/Layers/AutomodeSupervisor.test.ts index d936b35f027..448417c8fdd 100644 --- a/apps/server/src/gits/Layers/AutomodeSupervisor.test.ts +++ b/apps/server/src/gits/Layers/AutomodeSupervisor.test.ts @@ -285,6 +285,64 @@ describe("AutomodeSupervisorLive", () => { ); }); + it.effect( + "sweep-origin goal needs approval when sweepRequiresConfirmation is on, even with requireApprovalForPeerSpawn off", + () => + Effect.gen(function* () { + const supervisor = yield* AutomodeSupervisor; + yield* supervisor.updatePolicy({ + mode: "autonomous", + killSwitchEnabled: false, + allowedRepos: ["/tmp/source-repo"], + maxBudgetUsd: 10, + maxRuntimeMinutes: null, + requireApprovalForPeerSpawn: false, + sweepRequiresConfirmation: true, + }); + const queued = yield* supervisor.enqueueGoal({ + title: "Sweep goal", + repo: "/tmp/source-repo", + prompt: "Run a safe task.", + origin: "sweep", + }); + + const result = yield* supervisor.dispatchGoal({ goalId: queued.goals[0]!.id }); + + assert.equal(result.approvalRequired, true); + assert.equal(result.peer, null); + assert.equal(result.goal.status, "waiting-approval"); + }).pipe(Effect.provide(makeLayer({ budgetUsage: availableBudgetUsage }))), + ); + + it.effect( + "does not gate a manual goal on sweepRequiresConfirmation when requireApprovalForPeerSpawn is off", + () => + Effect.gen(function* () { + const supervisor = yield* AutomodeSupervisor; + yield* supervisor.updatePolicy({ + mode: "autonomous", + killSwitchEnabled: false, + allowedRepos: ["/tmp/source-repo"], + maxBudgetUsd: 10, + maxRuntimeMinutes: null, + requireApprovalForPeerSpawn: false, + sweepRequiresConfirmation: true, + }); + const queued = yield* supervisor.enqueueGoal({ + title: "Manual goal", + repo: "/tmp/source-repo", + prompt: "Run a safe task.", + }); + assert.equal(queued.goals[0]!.origin, "manual"); + + const result = yield* supervisor.dispatchGoal({ goalId: queued.goals[0]!.id }); + + assert.equal(result.approvalRequired, false); + assert.equal(result.peer?.id, peer.id); + assert.equal(result.goal.status, "running"); + }).pipe(Effect.provide(makeLayer({ budgetUsage: availableBudgetUsage }))), + ); + it.effect("persists policy and queued goals across supervisor restart", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -540,6 +598,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 3c0a471d81e..fcfe8c235ce 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"; @@ -249,6 +250,8 @@ function defaultPolicy(updatedAt: string): AutomodePolicy { verificationCommands: [], integrationBranch: null, motokoAuthority: "observe", + telegramDigestEnabled: true, + sweepRequiresConfirmation: true, updatedAt, }; } @@ -293,6 +296,12 @@ function goalNeedsApproval(policy: AutomodePolicy, goal: AutomodeGoal): boolean if (goal.approvedAt !== null) { return false; } + // Sweep-drafted goals gate on sweepRequiresConfirmation alone, independent of + // requireApprovalForPeerSpawn — otherwise an operator who disables per-peer-spawn + // approval also (unintentionally) waives the owner's sweep confirmation contract. + if (goal.origin === "sweep" && policy.sweepRequiresConfirmation) { + return true; + } return promptNeedsApproval(policy, goal.prompt); } @@ -433,6 +442,8 @@ function applyPolicyUpdate( integrationBranch: input.integrationBranch === undefined ? policy.integrationBranch : input.integrationBranch, motokoAuthority: input.motokoAuthority ?? policy.motokoAuthority, + telegramDigestEnabled: input.telegramDigestEnabled ?? policy.telegramDigestEnabled, + sweepRequiresConfirmation: input.sweepRequiresConfirmation ?? policy.sweepRequiresConfirmation, updatedAt, }; } @@ -628,6 +639,7 @@ export const AutomodeSupervisorLive = Layer.effect( id: `goal-${randomUUID()}`, // Episode thread (decision 23): carried from the proposal when present. episodeId: input.episodeId ?? `epi-${randomUUID()}`, + origin: input.origin ?? "manual", title: input.title, prompt: input.prompt, repo: input.repo, @@ -962,6 +974,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/Layers/AutomodeTelegramDigest.test.ts b/apps/server/src/gits/Layers/AutomodeTelegramDigest.test.ts index d9b31f69a05..23003fa2613 100644 --- a/apps/server/src/gits/Layers/AutomodeTelegramDigest.test.ts +++ b/apps/server/src/gits/Layers/AutomodeTelegramDigest.test.ts @@ -27,9 +27,10 @@ const snapshot = { ...["one", "two", "three", "four", "five", "six"].map((title) => ({ title, status: "queued", + id: `goal-${title}`, })), - { title: "finished", status: "completed" }, - { title: "failed", status: "failed" }, + { title: "finished", status: "completed", id: "goal-finished" }, + { title: "failed", status: "failed", id: "goal-failed" }, ], heldPrUrl: "https://github.com/t3tools/gits/pull/42", }; @@ -38,6 +39,7 @@ function makeLayer(options: { readonly sent: string[]; readonly failFirst?: boolean; readonly baseDir?: string; + readonly telegramDigestEnabled?: boolean; }) { let attempts = 0; const notifier = Layer.mock(HermesTelegramNotifier)({ @@ -50,6 +52,8 @@ function makeLayer(options: { }); const supervisor = Layer.mock(AutomodeSupervisor)({ getSnapshot: () => Effect.succeed(snapshot as never), + getPolicy: () => + Effect.succeed({ telegramDigestEnabled: options.telegramDigestEnabled ?? true } as never), }); const scheduler = Layer.mock(GitsSlotScheduler)({ getSnapshot: () => Effect.succeed({ goalsStartedTonight: 1 } as never), @@ -91,12 +95,25 @@ describe("AutomodeTelegramDigest", () => { assert.match(sent[0] ?? "", /finished/); assert.match(sent[0] ?? "", /failed/); assert.match(sent[0] ?? "", /1/); - assert.match(sent[1] ?? "", /one/); - assert.match(sent[1] ?? "", /five/); + assert.match(sent[1] ?? "", /one \[goal-one\]/); + assert.match(sent[1] ?? "", /five \[goal-five\]/); assert.notMatch(sent[1] ?? "", /six/); + assert.match(sent[1] ?? "", /Reply: APPROVE · REJECT /); }).pipe(Effect.provide(makeLayer({ sent }))); }); + it.effect("sends nothing when telegramDigestEnabled is off", () => { + const sent: string[] = []; + return Effect.gen(function* () { + const digest = yield* AutomodeTelegramDigest; + yield* TestClock.setTime(MORNING); + yield* digest.tick(); + yield* TestClock.setTime(DIGEST); + yield* digest.tick(); + assert.equal(sent.length, 0); + }).pipe(Effect.provide(makeLayer({ sent, telegramDigestEnabled: false }))); + }); + it.effect("retries a failed delivery and retains successful dates across a fresh layer", () => { const sent: string[] = []; const baseDir = mkdtempSync(join(tmpdir(), "gits-digest-persistence-")); diff --git a/apps/server/src/gits/Layers/AutomodeTelegramDigest.ts b/apps/server/src/gits/Layers/AutomodeTelegramDigest.ts index 68ee6affb60..dca7128c6bf 100644 --- a/apps/server/src/gits/Layers/AutomodeTelegramDigest.ts +++ b/apps/server/src/gits/Layers/AutomodeTelegramDigest.ts @@ -17,6 +17,8 @@ import { GitsSlotScheduler } from "../Services/GitsSlotScheduler.ts"; import { HermesTelegramNotifier } from "../Services/HermesTelegramNotifier.ts"; import { london_instant } from "./GitsSlotScheduler.ts"; +const REPLY_HINT = "Reply: APPROVE · REJECT "; + const STATE_FILE_NAME = "automode-telegram-digest-state.json"; const PersistedState = Schema.Struct({ version: Schema.Literal(1), @@ -101,6 +103,9 @@ export const AutomodeTelegramDigestLive = Layer.effect( Effect.gen(function* () { const now = london_instant(yield* Clock.currentTimeMillis); const state = yield* Ref.get(stateRef); + // Owner kill-switch: skip both reports outright, before any read/send, when off. + const policy = yield* supervisor.getPolicy(); + if (!policy.telegramDigestEnabled) return; const morningDue = now.minutesOfDay >= 10 * 60 && state.lastMorningReportDate !== now.dateKey; const digestDue = now.minutesOfDay >= 22 * 60 && state.lastDigestDate !== now.dateKey; @@ -142,7 +147,9 @@ export const AutomodeTelegramDigestLive = Layer.effect( ...snapshot.goals .filter((goal) => goal.status === "queued" || goal.status === "waiting-approval") .slice(0, 5) - .map((goal) => `- ${goal.title}`), + .map((goal) => `- ${goal.title} [${goal.id}]`), + "", + REPLY_HINT, ].join("\n"), ); if (delivered) next = { ...next, lastDigestDate: now.dateKey }; 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/gits/Layers/HermesAutomodeBridge.ts b/apps/server/src/gits/Layers/HermesAutomodeBridge.ts index 1a926184912..62b34bb3f2b 100644 --- a/apps/server/src/gits/Layers/HermesAutomodeBridge.ts +++ b/apps/server/src/gits/Layers/HermesAutomodeBridge.ts @@ -83,6 +83,7 @@ export const decideProposalWithAutomodeBridge = ( title: draft.title, prompt: draft.prompt, repo: draft.repo, + origin: "proposal", ...(priorProposal === null ? {} : { episodeId: priorProposal.episodeId }), }) .pipe(Effect.asVoid) 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/server.test.ts b/apps/server/src/server.test.ts index 3a391855499..5e8bc0b26fb 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, @@ -255,6 +256,7 @@ const defaultAutomodeGoal: AutomodeGoal = { approvedAt: null, rejectedAt: null, workflowId: null, + origin: "manual", }; const defaultAutomodeSnapshot: AutomodeSnapshot = { policy: { @@ -275,6 +277,8 @@ const defaultAutomodeSnapshot: AutomodeSnapshot = { verificationCommands: [], integrationBranch: null, motokoAuthority: "observe", + telegramDigestEnabled: true, + sweepRequiresConfirmation: true, updatedAt: "2026-01-01T00:00:00.000Z", }, budgetUsage: { @@ -1142,6 +1146,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..d66e050b1e9 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,28 @@ 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" }, + ), + [WS_METHODS.gitsAutomodeStopAll]: (_input) => + 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, 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..cf6c6c80a26 100644 --- a/apps/web/src/components/gits/GitsCockpit.tsx +++ b/apps/web/src/components/gits/GitsCockpit.tsx @@ -1,4908 +1,60 @@ 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)} - /> -
-