diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 000000000000..8d9563531ac5 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,41 @@ +# GITS Autonomy + +GITS autonomy turns operator-reviewed improvement ideas into safely queued work while keeping the operator-visible history distinct from execution state. + +## Language + +**Proposal**: +A candidate piece of work presented for operator review. It is not executable work until accepted. +_Avoid_: Goal, task, notification + +**Launch Configuration**: +The proposal-specific choices that define how accepted work should run, including its model and optional execution constraints. +_Avoid_: Global policy, settings form + +**Acceptance**: +The operator decision that approves a Proposal with its Launch Configuration and requests exactly one Goal. +_Avoid_: Dispatch, start, run + +**Goal**: +The durable execution record created from an accepted Proposal. A Goal may wait in the Queue before execution starts. +_Avoid_: Proposal, notification + +**Queue**: +The ordered collection of Goals eligible for future execution. Entering the Queue does not mean execution has started. +_Avoid_: Inbox, scheduler + +**Inbox Item**: +The durable operator-facing history of a Proposal and its resulting Goal. It is not execution truth. +_Avoid_: Goal, push notification + +**Notification**: +An optional delivery signal that points to an Inbox Item. Delivery failure does not remove or alter the Inbox Item. +_Avoid_: Inbox Item, Proposal + +**Pause**: +An operator state that prevents new proposals and future Goal starts without terminating work already running. +_Avoid_: Emergency Stop, kill + +**Emergency Stop**: +An operator action that terminates running autonomous work and prevents new Goal starts until autonomy is explicitly enabled again. +_Avoid_: Pause, reject diff --git a/apps/server/src/gits/Layers/AutomodeSupervisor.test.ts b/apps/server/src/gits/Layers/AutomodeSupervisor.test.ts index fe5477f6324b..4421e3a08d89 100644 --- a/apps/server/src/gits/Layers/AutomodeSupervisor.test.ts +++ b/apps/server/src/gits/Layers/AutomodeSupervisor.test.ts @@ -256,6 +256,53 @@ describe("AutomodeSupervisorLive", () => { }).pipe(Effect.provide(makeLayer())), ); + it.effect("enqueues at most one live goal for an episode", () => + Effect.gen(function* () { + const supervisor = yield* AutomodeSupervisor; + yield* Effect.all( + [ + supervisor.enqueueGoal({ + episodeId: "episode-concurrent", + title: "First acceptance", + repo: "/tmp/source-repo", + prompt: "Run once.", + }), + supervisor.enqueueGoal({ + episodeId: "episode-concurrent", + title: "Second acceptance", + repo: "/tmp/source-repo", + prompt: "Run once.", + }), + ], + { concurrency: "unbounded" }, + ); + + const snapshot = yield* supervisor.getSnapshot(); + assert.equal( + snapshot.goals.filter( + (goal) => goal.episodeId === "episode-concurrent" && goal.status !== "completed", + ).length, + 1, + ); + }).pipe(Effect.provide(makeLayer())), + ); + + it.effect("rejects a zero-minute Goal runtime override", () => + Effect.gen(function* () { + const supervisor = yield* AutomodeSupervisor; + const error = yield* supervisor + .enqueueGoal({ + title: "Uncapped goal", + repo: "/tmp/source-repo", + prompt: "Run without a cap.", + maxRuntimeMinutes: 0, + }) + .pipe(Effect.flip); + + assert.include(error.message, "positive"); + }).pipe(Effect.provide(makeLayer())), + ); + it.effect("blocks dispatch when the kill switch is enabled", () => Effect.gen(function* () { const supervisor = yield* AutomodeSupervisor; diff --git a/apps/server/src/gits/Layers/AutomodeSupervisor.ts b/apps/server/src/gits/Layers/AutomodeSupervisor.ts index fbfb9c54f6b0..d44a91753985 100644 --- a/apps/server/src/gits/Layers/AutomodeSupervisor.ts +++ b/apps/server/src/gits/Layers/AutomodeSupervisor.ts @@ -67,6 +67,12 @@ interface AutomodeState { } const ACTIVE_PEER_STATUSES = new Set(["pending", "running", "blocked", "waiting"]); +const TERMINAL_GOAL_STATUSES = new Set([ + "completed", + "failed", + "blocked", + "rejected", +]); const INTEGRATION_PATTERN = /\b(merge|admin-merge|integrate|pull request|pr)\b/i; const DESTRUCTIVE_PATTERN = /\b(reset --hard|rm -rf|delete|destroy|drop|truncate|force push)\b/i; const AUTOMODE_STATE_FILE_NAME = "automode-state.json"; @@ -112,6 +118,10 @@ function toAutomodeError(message: string, cause?: unknown) { }); } +function hasValidGoalRuntime(runtime: number | null | undefined): boolean { + return runtime === null || runtime === undefined || runtime > 0; +} + function unavailableBudgetUsage(note: string): AutomodeBudgetUsage { return { source: "unavailable", @@ -692,6 +702,9 @@ export const AutomodeSupervisorLive = Layer.effect( }), enqueueGoal: (input) => Effect.gen(function* () { + if (!hasValidGoalRuntime(input.maxRuntimeMinutes)) { + return yield* toAutomodeError("Goal runtime must be a positive number of minutes."); + } const createdAt = yield* nowIso; const goal: AutomodeGoal = { id: `goal-${randomUUID()}`, @@ -718,12 +731,21 @@ export const AutomodeSupervisorLive = Layer.effect( planningNotes: null, planningBoundary: null, }; - const nextState = yield* commitState((state) => ({ - ...state, - goals: [goal, ...state.goals], - lastEvent: `Queued ${input.title}.`, - updatedAt: createdAt, - })); + const nextState = yield* commitState((state) => + input.episodeId !== undefined && + state.goals.some( + (existing) => + existing.episodeId === input.episodeId && + !TERMINAL_GOAL_STATUSES.has(existing.status), + ) + ? state + : { + ...state, + goals: [goal, ...state.goals], + lastEvent: `Queued ${input.title}.`, + updatedAt: createdAt, + }, + ); return yield* snapshotFromState(nextState); }), deferGoal: (input) => @@ -754,6 +776,9 @@ export const AutomodeSupervisorLive = Layer.effect( }), updateQueuedGoal: (input) => Effect.gen(function* () { + if (!hasValidGoalRuntime(input.maxRuntimeMinutes)) { + return yield* toAutomodeError("Goal runtime must be a positive number of minutes."); + } const updatedAt = yield* nowIso; const nextState = yield* commitStateOrFail((state) => { const current = findGoal(state, input.goalId); diff --git a/apps/server/src/gits/Layers/AutopilotControl.test.ts b/apps/server/src/gits/Layers/AutopilotControl.test.ts new file mode 100644 index 000000000000..64095047291c --- /dev/null +++ b/apps/server/src/gits/Layers/AutopilotControl.test.ts @@ -0,0 +1,184 @@ +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import { describe, expect, it } from "vitest"; + +import { + GitsSlotSchedulerError, + type AutomodeSnapshot, + type GitsSchedulerSnapshot, +} from "@t3tools/contracts"; + +import { configureAutopilot, emergencyStopAutopilot } from "./AutopilotControl.ts"; + +const automode = { policy: { mode: "manual" } } as AutomodeSnapshot; +const scheduler = { config: { enabled: false } } as GitsSchedulerSnapshot; + +describe("AutopilotControl", () => { + it("enables the scheduler before switching to the safe autonomous policy", async () => { + const calls: Array = []; + const result = await Effect.runPromise( + configureAutopilot( + { + scheduler: { + setConfig: (input) => + Effect.sync(() => { + calls.push(["scheduler.setConfig", input]); + return scheduler; + }), + disarm: () => Effect.succeed(scheduler), + }, + supervisor: { + updatePolicy: (input) => + Effect.sync(() => { + calls.push(["supervisor.updatePolicy", input]); + return automode; + }), + }, + }, + { enabled: true, repositories: ["/srv/repo"] }, + ), + ); + + expect(calls).toEqual([ + ["scheduler.setConfig", { enabled: true }], + [ + "supervisor.updatePolicy", + { + mode: "autonomous", + killSwitchEnabled: false, + maxActivePeers: 1, + allowedRepos: ["/srv/repo"], + proposalRepos: ["/srv/repo"], + nightlyProposalSweep: true, + sweepRequiresConfirmation: true, + autoEnqueueApprovedProposals: true, + gitsNotificationsEnabled: true, + }, + ], + ]); + expect(result).toEqual({ automode, scheduler }); + }); + + it("rejects enabling without a repository", async () => { + const exit = await Effect.runPromiseExit( + configureAutopilot( + { + scheduler: { + setConfig: () => Effect.succeed(scheduler), + disarm: () => Effect.succeed(scheduler), + }, + supervisor: { updatePolicy: () => Effect.succeed(automode) }, + }, + { enabled: true, repositories: [] }, + ), + ); + + expect(Exit.isFailure(exit)).toBe(true); + }); + + it("does not switch policy when scheduler enable fails", async () => { + let policyCalls = 0; + const exit = await Effect.runPromiseExit( + configureAutopilot( + { + scheduler: { + setConfig: () => + Effect.fail(new GitsSlotSchedulerError({ message: "scheduler unavailable" })), + disarm: () => Effect.succeed(scheduler), + }, + supervisor: { + updatePolicy: () => + Effect.sync(() => { + policyCalls += 1; + return automode; + }), + }, + }, + { enabled: true, repositories: ["/srv/repo"] }, + ), + ); + + expect(Exit.isFailure(exit)).toBe(true); + expect(policyCalls).toBe(0); + }); + + it("disarms before pausing and stops peers before disarming an emergency stop", async () => { + const pauseCalls: Array = []; + await Effect.runPromise( + configureAutopilot( + { + scheduler: { + setConfig: () => Effect.succeed(scheduler), + disarm: (input) => + Effect.sync(() => { + pauseCalls.push(["scheduler.disarm", input]); + return scheduler; + }), + }, + supervisor: { + updatePolicy: (input) => + Effect.sync(() => { + pauseCalls.push(["supervisor.updatePolicy", input]); + return automode; + }), + }, + }, + { enabled: false, repositories: ["/srv/repo"] }, + ), + ); + expect(pauseCalls).toEqual([ + ["scheduler.disarm", { reason: "Autopilot paused." }], + [ + "supervisor.updatePolicy", + { + mode: "manual", + allowedRepos: ["/srv/repo"], + proposalRepos: ["/srv/repo"], + nightlyProposalSweep: false, + }, + ], + ]); + + const stopCalls: Array = []; + await Effect.runPromise( + emergencyStopAutopilot({ + scheduler: { + disarm: () => + Effect.sync(() => { + stopCalls.push("scheduler.disarm"); + return scheduler; + }), + }, + supervisor: { + stopAll: () => + Effect.sync(() => { + stopCalls.push("supervisor.stopAll"); + return { stoppedPeers: 1, failures: 0 }; + }), + }, + }), + ); + expect(stopCalls).toEqual(["supervisor.stopAll", "scheduler.disarm"]); + }); + + it("still stops peers when emergency scheduler disarm fails", async () => { + let stopCalls = 0; + const exit = await Effect.runPromiseExit( + emergencyStopAutopilot({ + scheduler: { + disarm: () => Effect.fail(new GitsSlotSchedulerError({ message: "cannot disarm" })), + }, + supervisor: { + stopAll: () => + Effect.sync(() => { + stopCalls += 1; + return { stoppedPeers: 1, failures: 0 }; + }), + }, + }), + ); + + expect(Exit.isFailure(exit)).toBe(true); + expect(stopCalls).toBe(1); + }); +}); diff --git a/apps/server/src/gits/Layers/AutopilotControl.ts b/apps/server/src/gits/Layers/AutopilotControl.ts new file mode 100644 index 000000000000..f2094003e6ca --- /dev/null +++ b/apps/server/src/gits/Layers/AutopilotControl.ts @@ -0,0 +1,77 @@ +import * as Effect from "effect/Effect"; +import * as Result from "effect/Result"; + +import { + AutomodeSupervisorError, + type AutopilotConfigureInput, + type AutopilotControlSnapshot, + type AutomodeStopAllResult, +} from "@t3tools/contracts"; + +import type { AutomodeSupervisorShape } from "../Services/AutomodeSupervisor.ts"; +import type { GitsSlotSchedulerShape } from "../Services/GitsSlotScheduler.ts"; + +const schedulerError = (message: string) => (cause: unknown) => + new AutomodeSupervisorError({ message, cause }); + +export function configureAutopilot( + dependencies: { + readonly supervisor: Pick; + readonly scheduler: Pick; + }, + input: AutopilotConfigureInput, +): Effect.Effect { + if (input.enabled && input.repositories.length === 0) { + return Effect.fail( + new AutomodeSupervisorError({ message: "Choose at least one repository for Autopilot." }), + ); + } + + return Effect.gen(function* () { + const scheduler = input.enabled + ? yield* dependencies.scheduler + .setConfig({ enabled: true }) + .pipe(Effect.mapError(schedulerError("Failed to enable the Autopilot scheduler."))) + : yield* dependencies.scheduler + .disarm({ reason: "Autopilot paused." }) + .pipe(Effect.mapError(schedulerError("Failed to pause the Autopilot scheduler."))); + const automode = yield* dependencies.supervisor.updatePolicy( + input.enabled + ? { + mode: "autonomous", + killSwitchEnabled: false, + maxActivePeers: 1, + allowedRepos: input.repositories, + proposalRepos: input.repositories, + nightlyProposalSweep: true, + sweepRequiresConfirmation: true, + autoEnqueueApprovedProposals: true, + gitsNotificationsEnabled: true, + } + : { + mode: "manual", + allowedRepos: input.repositories, + proposalRepos: input.repositories, + nightlyProposalSweep: false, + }, + ); + return { automode, scheduler }; + }); +} + +export function emergencyStopAutopilot(dependencies: { + readonly supervisor: Pick; + readonly scheduler: Pick; +}): Effect.Effect { + return Effect.gen(function* () { + const stopped = yield* Effect.result(dependencies.supervisor.stopAll()); + const disarmed = yield* Effect.result( + dependencies.scheduler + .disarm({ reason: "Autopilot emergency stop." }) + .pipe(Effect.mapError(schedulerError("Failed to disarm the Autopilot scheduler."))), + ); + if (Result.isFailure(stopped)) return yield* stopped.failure; + if (Result.isFailure(disarmed)) return yield* disarmed.failure; + return stopped.success; + }); +} diff --git a/apps/server/src/gits/Layers/HermesAutomodeBridge.test.ts b/apps/server/src/gits/Layers/HermesAutomodeBridge.test.ts index bf3794595db4..3c8e94d08fa7 100644 --- a/apps/server/src/gits/Layers/HermesAutomodeBridge.test.ts +++ b/apps/server/src/gits/Layers/HermesAutomodeBridge.test.ts @@ -12,6 +12,7 @@ import type { HermesProposalDecisionInput, HermesProposalStatus, } from "@t3tools/contracts"; +import { AutomodeSupervisorError, GitsSlotSchedulerError } from "@t3tools/contracts"; import { ServerConfig } from "../../config.ts"; import { AutomodeLanding } from "../Services/AutomodeLanding.ts"; @@ -219,6 +220,35 @@ const armAutonomous = Effect.gen(function* () { }); describe("decideProposalWithAutomodeBridge", () => { + it.effect("concurrent acceptance creates one live Goal", () => + Effect.gen(function* () { + const supervisor = yield* armAutonomous; + const { hermes } = makeFakeHermes(); + + yield* Effect.all( + [ + decideProposalWithAutomodeBridge(hermes, supervisor, { + proposalId: "proposal-1", + decision: "approve", + }), + decideProposalWithAutomodeBridge(hermes, supervisor, { + proposalId: "proposal-1", + decision: "approve", + }), + ], + { concurrency: "unbounded" }, + ); + + const snapshot = yield* supervisor.getSnapshot(); + assert.equal( + snapshot.goals.filter( + (goal) => goal.episodeId === "epi-proposal-1" && goal.status === "queued", + ).length, + 1, + ); + }).pipe(Effect.provide(makeSupervisorLayer())), + ); + it.effect("approval queues once, arms for eligibility, and records the durable transition", () => Effect.gen(function* () { const supervisor = yield* armAutonomous; @@ -422,6 +452,14 @@ describe("decideProposalWithAutomodeBridge", () => { readonly input: HermesProposalDecisionInput; readonly message: string; }> = [ + { + input: { + proposalId: "proposal-1", + decision: "approve", + maxRuntimeMinutes: 0, + }, + message: "positive", + }, { input: { proposalId: "proposal-1", @@ -555,4 +593,110 @@ describe("decideProposalWithAutomodeBridge", () => { assert.equal((yield* supervisor.getSnapshot()).goals.length, 0); }).pipe(Effect.provide(makeSupervisorLayer())), ); + + it.effect("records enqueue failure for retry and remains idempotent", () => + Effect.gen(function* () { + const supervisor = yield* armAutonomous; + const { hermes } = makeFakeHermes(); + const events: CockpitInboxRecordInput[] = []; + let enqueueCalls = 0; + const automode = { + getSnapshot: supervisor.getSnapshot, + updateQueuedGoal: supervisor.updateQueuedGoal, + enqueueGoal: (input: Parameters[0]) => { + enqueueCalls += 1; + return enqueueCalls === 1 + ? Effect.fail(new AutomodeSupervisorError({ message: "queue unavailable" })) + : supervisor.enqueueGoal(input); + }, + }; + const dependencies = { + scheduler: { + scheduleApprovedGoal: () => + Effect.succeed({ snapshot: {} as never, targetsCurrentNight: false }), + }, + inbox: { + record: (input: CockpitInboxRecordInput) => + Effect.sync(() => { + events.push(input); + return {} as never; + }), + }, + }; + + yield* decideProposalWithAutomodeBridge( + hermes, + automode, + { proposalId: "proposal-1", decision: "approve" }, + dependencies, + ).pipe(Effect.flip); + assert.deepInclude(events.at(-1)!, { + proposalId: "proposal-1", + episodeId: "epi-proposal-1", + goalId: null, + eventKey: "proposal:proposal-1:acceptance-failed", + state: "attention-required", + }); + assert.include(events.at(-1)!.reason, "Retry"); + assert.equal(events.at(-1)!.deepLink, "/gits?panel=autopilot&proposal=proposal-1"); + + yield* decideProposalWithAutomodeBridge( + hermes, + automode, + { proposalId: "proposal-1", decision: "approve" }, + dependencies, + ); + assert.equal((yield* supervisor.getSnapshot()).goals.length, 1); + assert.equal(events.at(-1)!.state, "approved-queued"); + }).pipe(Effect.provide(makeSupervisorLayer())), + ); + + it.effect("records the existing goal when scheduling fails and retry does not duplicate it", () => + Effect.gen(function* () { + const supervisor = yield* armAutonomous; + const { hermes } = makeFakeHermes(); + const events: CockpitInboxRecordInput[] = []; + let scheduleCalls = 0; + const dependencies = { + scheduler: { + scheduleApprovedGoal: () => { + scheduleCalls += 1; + return scheduleCalls === 1 + ? Effect.fail(new GitsSlotSchedulerError({ message: "schedule unavailable" })) + : Effect.succeed({ snapshot: {} as never, targetsCurrentNight: false }); + }, + }, + inbox: { + record: (input: CockpitInboxRecordInput) => + Effect.sync(() => { + events.push(input); + return {} as never; + }), + }, + }; + + yield* decideProposalWithAutomodeBridge( + hermes, + supervisor, + { proposalId: "proposal-1", decision: "approve" }, + dependencies, + ).pipe(Effect.flip); + const goalId = (yield* supervisor.getSnapshot()).goals[0]!.id; + assert.deepInclude(events.at(-1)!, { + goalId, + eventKey: "proposal:proposal-1:acceptance-failed", + state: "attention-required", + }); + assert.equal(events.at(-1)!.deepLink, "/gits?panel=autopilot&proposal=proposal-1"); + + yield* decideProposalWithAutomodeBridge( + hermes, + supervisor, + { proposalId: "proposal-1", decision: "approve" }, + dependencies, + ); + assert.equal((yield* supervisor.getSnapshot()).goals.length, 1); + assert.equal(events.at(-1)!.state, "approved-queued"); + }).pipe(Effect.provide(makeSupervisorLayer())), + ); }); diff --git a/apps/server/src/gits/Layers/HermesAutomodeBridge.ts b/apps/server/src/gits/Layers/HermesAutomodeBridge.ts index 74240de6b9c2..8a2b53033701 100644 --- a/apps/server/src/gits/Layers/HermesAutomodeBridge.ts +++ b/apps/server/src/gits/Layers/HermesAutomodeBridge.ts @@ -96,6 +96,11 @@ export const decideProposalWithAutomodeBridge = ( message: "Proposal model is not allowed by Automode policy.", }); } + if (effectiveRuntime !== null && effectiveRuntime <= 0) { + return yield* new HermesAdapterError({ + message: "Proposal runtime must be a positive number of minutes.", + }); + } if ( snapshot.policy.maxRuntimeMinutes !== null && effectiveRuntime !== null && @@ -128,119 +133,149 @@ export const decideProposalWithAutomodeBridge = ( } } const decided = yield* hermes.decideProposal(input); - if (snapshot !== null && priorProposal !== null) { - const draft = yield* hermes.draftFromProposal({ - proposalId: input.proposalId, - }); - if (draft.kind === "delamain-peer" && draft.status === "draft" && draft.repo !== null) { - const existing = snapshot.goals.find( - (goal) => - goal.episodeId === priorProposal.episodeId && !TERMINAL_GOAL_STATUSES.has(goal.status), - ); - const editable = - existing !== undefined && ["queued", "waiting-approval"].includes(existing.status); - const update = { - title: draft.title, - prompt: draft.prompt, - repo: draft.repo, - notBefore: decided.notBefore, - maxRuntimeMinutes: decided.maxRuntimeMinutes, - verificationCommands: decided.verificationCommands, - integrationBranch: decided.integrationBranch, - }; - if (existing === undefined) { - yield* automode - .enqueueGoal({ - ...update, - ...(decided.model === null ? {} : { model: decided.model }), - origin: "proposal", - episodeId: priorProposal.episodeId, - }) - .pipe( - Effect.mapError( - (cause) => - new HermesAdapterError({ - message: "Failed to queue approved proposal.", - cause, - }), - ), - ); - } else if (editable) { - yield* automode - .updateQueuedGoal({ - ...update, - goalId: existing.id, - model: decided.model, - }) - .pipe( - Effect.mapError( - (cause) => - new HermesAdapterError({ - message: "Failed to queue approved proposal.", - cause, - }), - ), - ); + yield* Effect.gen(function* () { + if (snapshot !== null && priorProposal !== null) { + const draft = yield* hermes.draftFromProposal({ + proposalId: input.proposalId, + }); + if (draft.kind === "delamain-peer" && draft.status === "draft" && draft.repo !== null) { + const existing = snapshot.goals.find( + (goal) => + goal.episodeId === priorProposal.episodeId && + !TERMINAL_GOAL_STATUSES.has(goal.status), + ); + const editable = + existing !== undefined && ["queued", "waiting-approval"].includes(existing.status); + const update = { + title: draft.title, + prompt: draft.prompt, + repo: draft.repo, + notBefore: decided.notBefore, + maxRuntimeMinutes: decided.maxRuntimeMinutes, + verificationCommands: decided.verificationCommands, + integrationBranch: decided.integrationBranch, + }; + if (existing === undefined) { + yield* automode + .enqueueGoal({ + ...update, + ...(decided.model === null ? {} : { model: decided.model }), + origin: "proposal", + episodeId: priorProposal.episodeId, + }) + .pipe( + Effect.mapError( + (cause) => + new HermesAdapterError({ + message: "Failed to queue approved proposal.", + cause, + }), + ), + ); + } else if (editable) { + yield* automode + .updateQueuedGoal({ + ...update, + goalId: existing.id, + model: decided.model, + }) + .pipe( + Effect.mapError( + (cause) => + new HermesAdapterError({ + message: "Failed to queue approved proposal.", + cause, + }), + ), + ); + } } } - } - if (dependencies !== undefined) { - const current = - input.decision === "approve" - ? (yield* automode.getSnapshot()).goals.find( - (goal) => - goal.episodeId === decided.episodeId && !TERMINAL_GOAL_STATUSES.has(goal.status), - ) - : undefined; - if (input.decision !== "approve" || current !== undefined) { - let targetsCurrentNight = false; - if (input.decision === "approve") { - const scheduled = yield* dependencies.scheduler.scheduleApprovedGoal({ - eligibleAt: decided.notBefore, - }); - targetsCurrentNight = scheduled.targetsCurrentNight; - } - const eventKey = `proposal:${decided.id}:${input.decision}`; - const deepLink = - current === undefined - ? `/gits?panel=autopilot&proposal=${encodeURIComponent(decided.id)}` - : `/gits?panel=autopilot&goal=${encodeURIComponent(current.id)}`; - yield* dependencies.inbox.record({ - episodeId: decided.episodeId, - proposalId: decided.id, - goalId: current?.id ?? null, - title: decided.title, - repository: decided.projectDir, - eventKey, - state: - input.decision === "approve" - ? "approved-queued" - : input.decision === "reject" - ? "rejected" - : "deferred", - reason: - input.decision === "approve" - ? "Approved and queued for autonomous work." - : input.decision === "reject" - ? "Proposal rejected." - : "Proposal deferred.", - deepLink, - }); - if (targetsCurrentNight) { + if (dependencies !== undefined) { + const current = + input.decision === "approve" + ? (yield* automode.getSnapshot()).goals.find( + (goal) => + goal.episodeId === decided.episodeId && !TERMINAL_GOAL_STATUSES.has(goal.status), + ) + : undefined; + if (input.decision !== "approve" || current !== undefined) { + let targetsCurrentNight = false; + if (input.decision === "approve") { + const scheduled = yield* dependencies.scheduler.scheduleApprovedGoal({ + eligibleAt: decided.notBefore, + }); + targetsCurrentNight = scheduled.targetsCurrentNight; + } + const eventKey = `proposal:${decided.id}:${input.decision}`; + const deepLink = `/gits?panel=autopilot&proposal=${encodeURIComponent(decided.id)}`; yield* dependencies.inbox.record({ episodeId: decided.episodeId, proposalId: decided.id, - goalId: current!.id, + goalId: current?.id ?? null, title: decided.title, repository: decided.projectDir, - eventKey: `goal:${current!.id}:scheduled`, - state: "scheduled-tonight", - reason: "Scheduled for the current autonomy night.", + eventKey, + state: + input.decision === "approve" + ? "approved-queued" + : input.decision === "reject" + ? "rejected" + : "deferred", + reason: + input.decision === "approve" + ? "Approved and queued for autonomous work." + : input.decision === "reject" + ? "Proposal rejected." + : "Proposal deferred.", deepLink, }); + if (targetsCurrentNight) { + yield* dependencies.inbox.record({ + episodeId: decided.episodeId, + proposalId: decided.id, + goalId: current!.id, + title: decided.title, + repository: decided.projectDir, + eventKey: `goal:${current!.id}:scheduled`, + state: "scheduled-tonight", + reason: "Scheduled for the current autonomy night.", + deepLink, + }); + } } } - } + }).pipe( + Effect.tapError(() => { + if (input.decision !== "approve" || dependencies === undefined || priorProposal === null) { + return Effect.void; + } + return automode.getSnapshot().pipe( + Effect.map((currentSnapshot) => + currentSnapshot.goals.find( + (goal) => + goal.episodeId === decided.episodeId && !TERMINAL_GOAL_STATUSES.has(goal.status), + ), + ), + Effect.catch(() => Effect.succeed(undefined)), + Effect.flatMap((goal) => + dependencies.inbox.record({ + episodeId: decided.episodeId, + proposalId: decided.id, + goalId: goal?.id ?? null, + title: decided.title, + repository: decided.projectDir, + eventKey: `proposal:${decided.id}:acceptance-failed`, + state: "attention-required", + reason: + "Acceptance did not finish. Retry Accept & Queue; any existing Goal will be reused.", + deepLink: `/gits?panel=autopilot&proposal=${encodeURIComponent(decided.id)}`, + }), + ), + Effect.catch(() => Effect.void), + ); + }), + ); return decided; }).pipe( Effect.mapError((cause) => diff --git a/apps/server/src/push/Layers/PushNotificationService.test.ts b/apps/server/src/push/Layers/PushNotificationService.test.ts index f80c6482da64..5fe1de959570 100644 --- a/apps/server/src/push/Layers/PushNotificationService.test.ts +++ b/apps/server/src/push/Layers/PushNotificationService.test.ts @@ -174,3 +174,109 @@ it.effect("sends configured push notifications and removes expired subscriptions assert.deepStrictEqual(deletedEndpoints, [expired.endpoint]); }).pipe(Effect.provide(layer)); }); + +it.effect("sends a test only to the exact registered endpoint", () => { + const active = makeSubscription("https://push.example.test/active"); + const other = makeSubscription("https://push.example.test/other"); + const sentEndpoints: Array = []; + const layer = PushNotificationServiceLive.pipe( + Layer.provide(configLayer), + Layer.provide(environmentLayer), + Layer.provide( + Layer.succeed(WebPushSubscriptionRepository, { + upsert: () => Effect.void, + deleteByEndpoint: () => Effect.void, + list: () => Effect.succeed([persisted(active), persisted(other)]), + }), + ), + Layer.provide( + Layer.succeed(WebPushSender, { + send: (subscription) => + Effect.sync(() => { + sentEndpoints.push(subscription.endpoint); + }), + }), + ), + ); + + return Effect.gen(function* () { + const service = yield* PushNotificationService; + const payload = { title: "Test", body: "Body", tag: "test-1", url: "/gits" }; + assert.strictEqual(yield* service.sendToEndpoint(active.endpoint, payload), "sent"); + assert.strictEqual( + yield* service.sendToEndpoint("https://push.example.test/missing", payload), + "not-found", + ); + assert.deepStrictEqual(sentEndpoints, [active.endpoint]); + }).pipe(Effect.provide(layer)); +}); + +it.effect("reports disabled and failed targeted delivery and deletes expired endpoints", () => { + const expired = makeSubscription("https://push.example.test/expired"); + const deletedEndpoints: Array = []; + const repositoryLayer = Layer.succeed(WebPushSubscriptionRepository, { + upsert: () => Effect.void, + deleteByEndpoint: (endpoint: string) => + Effect.sync(() => { + deletedEndpoints.push(endpoint); + }), + list: () => Effect.succeed([persisted(expired)]), + }); + const senderLayer = Layer.succeed(WebPushSender, { + send: () => Effect.fail(new WebPushSendError({ endpoint: expired.endpoint, statusCode: 410 })), + }); + const payload = { title: "Test", body: "Body", tag: "test-2", url: "/gits" }; + + return Effect.gen(function* () { + const configuredService = yield* PushNotificationService; + assert.strictEqual( + yield* configuredService.sendToEndpoint(expired.endpoint, payload), + "failed", + ); + assert.deepStrictEqual(deletedEndpoints, [expired.endpoint]); + }).pipe( + Effect.provide( + PushNotificationServiceLive.pipe( + Layer.provide(configLayer), + Layer.provide(environmentLayer), + Layer.provide(repositoryLayer), + Layer.provide(senderLayer), + ), + ), + ); +}); + +it.effect("reports targeted delivery as disabled without VAPID configuration", () => { + const subscription = makeSubscription("https://push.example.test/device"); + const layer = PushNotificationServiceLive.pipe( + Layer.provide( + Layer.succeed(ServerConfig, { + vapidPublicKey: undefined, + vapidPrivateKey: undefined, + vapidSubject: undefined, + } as ServerConfigShape), + ), + Layer.provide(environmentLayer), + Layer.provide( + Layer.succeed(WebPushSubscriptionRepository, { + upsert: () => Effect.void, + deleteByEndpoint: () => Effect.void, + list: () => Effect.succeed([persisted(subscription)]), + }), + ), + Layer.provide(Layer.succeed(WebPushSender, { send: () => Effect.void })), + ); + + return Effect.gen(function* () { + const service = yield* PushNotificationService; + assert.strictEqual( + yield* service.sendToEndpoint(subscription.endpoint, { + title: "Test", + body: "Body", + tag: "test-3", + url: "/gits", + }), + "disabled", + ); + }).pipe(Effect.provide(layer)); +}); diff --git a/apps/server/src/push/Layers/PushNotificationService.ts b/apps/server/src/push/Layers/PushNotificationService.ts index 149a3036b3aa..7ee124cdcf1d 100644 --- a/apps/server/src/push/Layers/PushNotificationService.ts +++ b/apps/server/src/push/Layers/PushNotificationService.ts @@ -12,6 +12,7 @@ import { type PushNotificationPayload, PushNotificationService, type PushNotificationServiceShape, + type TargetedPushResult, } from "../Services/PushNotificationService.ts"; function activityRequestId(payload: unknown): string | null { @@ -163,9 +164,54 @@ const makePushNotificationService = Effect.gen(function* () { }); }; + const sendToEndpoint: PushNotificationServiceShape["sendToEndpoint"] = (endpoint, payload) => { + if (!configured) { + return Effect.succeed("disabled"); + } + + return Effect.gen(function* () { + const subscriptions = yield* repository.list().pipe( + Effect.map((entries) => entries as ReadonlyArray<(typeof entries)[number]> | null), + Effect.catch((cause) => + Effect.logWarning("failed to list web push subscriptions for test", { cause }).pipe( + Effect.as(null), + ), + ), + ); + if (subscriptions === null) { + return "failed" as TargetedPushResult; + } + const entry = subscriptions.find((candidate) => candidate.subscription.endpoint === endpoint); + if (entry === undefined) { + return "not-found" as TargetedPushResult; + } + + // @effect-diagnostics-next-line preferSchemaOverJson:off + const body = JSON.stringify(payload); + return yield* sender.send(entry.subscription, body).pipe( + Effect.as("sent"), + Effect.catchTag("WebPushSendError", (error) => + Effect.gen(function* () { + if (isExpiredSubscriptionError(error)) { + yield* repository.deleteByEndpoint(endpoint).pipe(Effect.ignore); + } else { + yield* Effect.logWarning("failed to send targeted web push notification", { + endpoint, + statusCode: error.statusCode, + cause: error.cause, + }); + } + return "failed" as const; + }), + ), + ); + }); + }; + return { getPublicConfig, sendToAll, + sendToEndpoint, sendForOrchestrationEvent, }; }); diff --git a/apps/server/src/push/Services/PushNotificationService.ts b/apps/server/src/push/Services/PushNotificationService.ts index 6b8845d2dd99..bc39c4641cc1 100644 --- a/apps/server/src/push/Services/PushNotificationService.ts +++ b/apps/server/src/push/Services/PushNotificationService.ts @@ -11,12 +11,18 @@ export interface PushNotificationPayload { readonly url: string; } +export type TargetedPushResult = "sent" | "disabled" | "not-found" | "failed"; + export interface PushNotificationServiceShape { readonly getPublicConfig: () => { readonly enabled: boolean; readonly publicKey: string | null; }; readonly sendToAll: (payload: PushNotificationPayload) => Effect.Effect; + readonly sendToEndpoint: ( + endpoint: string, + payload: PushNotificationPayload, + ) => Effect.Effect; readonly sendForOrchestrationEvent: (event: OrchestrationEvent) => Effect.Effect; } diff --git a/apps/server/src/push/http.test.ts b/apps/server/src/push/http.test.ts new file mode 100644 index 000000000000..bb284a9125c7 --- /dev/null +++ b/apps/server/src/push/http.test.ts @@ -0,0 +1,119 @@ +import { AuthSessionId } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { HttpRouter, HttpServerRequest } from "effect/unstable/http"; + +import { ServerAuth, type AuthenticatedSession } from "../auth/Services/ServerAuth.ts"; +import { + type PushNotificationPayload, + PushNotificationService, + type TargetedPushResult, +} from "./Services/PushNotificationService.ts"; +import { pushTestRouteLayer } from "./http.ts"; + +const session = (role: AuthenticatedSession["role"]): AuthenticatedSession => ({ + sessionId: AuthSessionId.make(`session-${role}`), + subject: role, + method: "bearer-session-token", + role, +}); + +const execute = ( + role: AuthenticatedSession["role"], + body: unknown, + result: TargetedPushResult, + sent: Array<{ readonly endpoint: string; readonly payload: PushNotificationPayload }>, +) => { + const request = HttpServerRequest.fromWeb( + new Request("http://localhost/api/push/test", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + ); + const dependencies = Layer.mergeAll( + Layer.mock(ServerAuth)({ + authenticateHttpRequest: () => Effect.succeed(session(role)), + }), + Layer.mock(PushNotificationService)({ + getPublicConfig: () => ({ enabled: true, publicKey: "test" }), + sendToAll: () => Effect.void, + sendToEndpoint: (endpoint, payload) => + Effect.sync(() => { + sent.push({ endpoint, payload }); + return result; + }), + sendForOrchestrationEvent: () => Effect.void, + }), + ); + return Effect.scoped( + HttpRouter.toHttpEffect(pushTestRouteLayer).pipe( + Effect.flatMap((handler) => handler), + Effect.provideService(HttpServerRequest.HttpServerRequest, request), + Effect.provide(dependencies), + ), + ); +}; + +it.effect("push test route uses fixed server payload and a unique test tag", () => + Effect.gen(function* () { + const sent: Array<{ readonly endpoint: string; readonly payload: PushNotificationPayload }> = + []; + const response = yield* execute( + "owner", + { + endpoint: "https://push.example/device", + kind: "proposal", + title: "Caller title", + body: "Caller body", + url: "https://evil.example", + }, + "sent", + sent, + ); + + assert.equal(response.status, 200); + assert.equal(sent.length, 1); + assert.equal(sent[0]!.endpoint, "https://push.example/device"); + assert.equal(sent[0]!.payload.title, "Test proposal ready"); + assert.equal(sent[0]!.payload.body, "Tap to test the guided proposal launch."); + assert.equal(sent[0]!.payload.url, "/gits?panel=autopilot¬ificationTest=proposal"); + assert.match(sent[0]!.payload.tag, /^gits:test:proposal:/); + }), +); + +it.effect("push test route only allows owner sessions", () => + Effect.gen(function* () { + for (const role of ["client", "thread-scoped"] as const) { + const sent: Array<{ readonly endpoint: string; readonly payload: PushNotificationPayload }> = + []; + const response = yield* execute( + role, + { endpoint: "https://push.example/device", kind: "delivery" }, + "sent", + sent, + ); + assert.equal(response.status, 403); + assert.equal(sent.length, 0); + } + }), +); + +it.effect("push test route maps closed delivery results", () => + Effect.gen(function* () { + for (const [result, status] of [ + ["disabled", 503], + ["not-found", 404], + ["failed", 502], + ] as const) { + const response = yield* execute( + "owner", + { endpoint: "https://push.example/device", kind: "delivery" }, + result, + [], + ); + assert.equal(response.status, status); + } + }), +); diff --git a/apps/server/src/push/http.ts b/apps/server/src/push/http.ts index 8820350db7a0..8dc558d5af2f 100644 --- a/apps/server/src/push/http.ts +++ b/apps/server/src/push/http.ts @@ -1,10 +1,16 @@ +import { randomUUID } from "node:crypto"; + import { WebPushPublicConfig, WebPushRegisterInput, + WebPushTestInput, + type WebPushTestKind, + type WebPushTestResult, WebPushUnregisterInput, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; import { AuthError, ServerAuth } from "../auth/Services/ServerAuth.ts"; @@ -12,6 +18,23 @@ import { respondToAuthError } from "../auth/http.ts"; import { ServerConfig } from "../config.ts"; import { browserApiCorsHeaders } from "../httpCors.ts"; import { WebPushSubscriptionRepository } from "../persistence/Services/WebPushSubscriptions.ts"; +import { + type PushNotificationPayload, + PushNotificationService, +} from "./Services/PushNotificationService.ts"; + +export const TEST_PUSH_PAYLOADS = { + delivery: { + title: "GITS notification test", + body: "Android PWA delivery is working. Tap to confirm.", + url: "/gits?panel=autopilot¬ificationTest=delivery", + }, + proposal: { + title: "Test proposal ready", + body: "Tap to test the guided proposal launch.", + url: "/gits?panel=autopilot¬ificationTest=proposal", + }, +} satisfies Record>; const authenticatePushSession = Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest; @@ -26,6 +49,17 @@ const authenticatePushSession = Effect.gen(function* () { return session; }); +const authenticatePushOwnerSession = Effect.gen(function* () { + const session = yield* authenticatePushSession; + if (session.role !== "owner") { + return yield* new AuthError({ + message: "Only owner sessions can send web push tests.", + status: 403, + }); + } + return session; +}); + export const pushPublicConfigRouteLayer = HttpRouter.add( "GET", "/api/push/config", @@ -139,3 +173,44 @@ export const pushUnregisterRouteLayer = HttpRouter.add( }), ), ); + +export const pushTestRouteLayer = HttpRouter.add( + "POST", + "/api/push/test", + Effect.gen(function* () { + yield* authenticatePushOwnerSession; + const input = yield* HttpServerRequest.schemaBodyJson(WebPushTestInput).pipe( + Effect.mapError( + (cause) => + new AuthError({ + message: "Invalid web push test payload.", + status: 400, + cause, + }), + ), + ); + const service = yield* Effect.serviceOption(PushNotificationService); + const fixed = TEST_PUSH_PAYLOADS[input.kind]; + const result = yield* Option.match(service, { + onNone: () => Effect.succeed("disabled" as const), + onSome: (available) => + available.sendToEndpoint(input.endpoint, { + ...fixed, + tag: `gits:test:${input.kind}:${randomUUID()}`, + }), + }); + if (result === "sent") { + return HttpServerResponse.jsonUnsafe( + { accepted: true, url: fixed.url } satisfies WebPushTestResult, + { status: 200, headers: browserApiCorsHeaders }, + ); + } + const [status, error] = + result === "disabled" + ? [503, "Web push is not configured."] + : result === "not-found" + ? [404, "This device subscription is not registered."] + : [502, "The push provider rejected the test notification."]; + return HttpServerResponse.jsonUnsafe({ error }, { status, headers: browserApiCorsHeaders }); + }).pipe(Effect.catchTag("AuthError", respondToAuthError)), +); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index bc0ff55755e4..8c8d5740842c 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -5039,6 +5039,19 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const calls: string[] = []; yield* buildAppUnderTest({ layers: { + gitsSlotScheduler: { + setConfig: (input) => + Effect.sync(() => { + calls.push(`scheduler:${input.enabled}`); + return { + ...defaultGitsSchedulerSnapshot, + config: { + ...defaultGitsSchedulerSnapshot.config, + enabled: input.enabled ?? false, + }, + }; + }), + }, automodeSupervisor: { getSnapshot: () => Effect.sync(() => { @@ -5114,6 +5127,17 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ); assert.equal(snapshot.policy.mode, "manual"); + const configured = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.gitsAutomodeConfigure]({ + enabled: true, + repositories: ["/tmp/source-repo"], + }), + ), + ); + assert.equal(configured.automode.policy.mode, "autonomous"); + assert.equal(configured.scheduler.config.enabled, true); + const policy = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => client[WS_METHODS.gitsAutomodeUpdatePolicy]({ @@ -5162,6 +5186,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(dispatched.peer?.id, defaultDelamainPeer.id); assert.deepEqual(calls, [ "snapshot", + "scheduler:true", + "policy:autonomous", "policy:supervised", "enqueue:/tmp/source-repo", "approve:goal-test", diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 8e2bb49664c9..d89feb18a087 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -156,6 +156,7 @@ import { PushNotificationReactorLive } from "./push/Layers/PushNotificationReact import { pushPublicConfigRouteLayer, pushRegisterRouteLayer, + pushTestRouteLayer, pushUnregisterRouteLayer, } from "./push/http.ts"; @@ -268,6 +269,10 @@ const WebPushSubscriptionRepositoryLayerLive = WebPushSubscriptionRepositoryLive Layer.provide(PersistenceLayerLive), ); +const RegisteredPushNotificationLayerLive = PushNotificationLayerLive.pipe( + Layer.provide(WebPushSubscriptionRepositoryLayerLive), +); + const VcsDriverRegistryLayerLive = VcsDriverRegistry.layer.pipe( Layer.provide(VcsProjectConfig.layer), ); @@ -346,9 +351,7 @@ const GitsSlotSchedulerLayerLive = GitsSlotSchedulerLive.pipe( const AutomodeNotificationsLayerLive = AutomodeNotificationsLive.pipe( Layer.provide(HermesTelegramNotifierLive), - Layer.provide( - PushNotificationLayerLive.pipe(Layer.provide(WebPushSubscriptionRepositoryLayerLive)), - ), + Layer.provide(RegisteredPushNotificationLayerLive), ); const CockpitInboxLayerLive = CockpitInboxLive; @@ -610,6 +613,7 @@ const GitsRoutesLayer = Layer.mergeAll( const PushRoutesLayer = Layer.mergeAll( pushPublicConfigRouteLayer, pushRegisterRouteLayer, + pushTestRouteLayer, pushUnregisterRouteLayer, ); @@ -737,7 +741,7 @@ export const makeServerLayer = Layer.unwrap( runtimeStateLayer, tailscaleServeLayer, browserPreviewCleanupLayer, - ); + ).pipe(Layer.provideMerge(RegisteredPushNotificationLayerLive)); return serverApplicationLayer.pipe( Layer.provideMerge(RuntimeServicesLive), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index ffff6c1d8f1b..af5b54258e3d 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -113,6 +113,7 @@ import { hasLiveGoalForEpisode, } from "./gits/Layers/HermesAutomodeBridge.ts"; import { AutomodeNotifications } from "./gits/Layers/AutomodeNotifications.ts"; +import { configureAutopilot, emergencyStopAutopilot } from "./gits/Layers/AutopilotControl.ts"; import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; import { denyThreadAccess, @@ -2293,6 +2294,15 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "gits" }, ), + [WS_METHODS.gitsAutomodeConfigure]: (input) => + observeRpcEffect( + WS_METHODS.gitsAutomodeConfigure, + configureAutopilot( + { supervisor: automodeSupervisor, scheduler: gitsSlotScheduler }, + input, + ), + { "rpc.aggregate": "gits" }, + ), [WS_METHODS.gitsAutomodeUpdatePolicy]: (input) => observeRpcEffect( WS_METHODS.gitsAutomodeUpdatePolicy, @@ -2366,9 +2376,14 @@ const makeWsRpcLayer = ( { "rpc.aggregate": "gits" }, ), [WS_METHODS.gitsAutomodeStopAll]: (_input) => - observeRpcEffect(WS_METHODS.gitsAutomodeStopAll, automodeSupervisor.stopAll(), { - "rpc.aggregate": "gits", - }), + observeRpcEffect( + WS_METHODS.gitsAutomodeStopAll, + emergencyStopAutopilot({ + supervisor: automodeSupervisor, + scheduler: gitsSlotScheduler, + }), + { "rpc.aggregate": "gits" }, + ), [WS_METHODS.gitsAutomodeGoalsKill]: (input) => observeRpcEffect(WS_METHODS.gitsAutomodeGoalsKill, automodeSupervisor.killGoal(input), { "rpc.aggregate": "gits", diff --git a/apps/web/src/components/gits/GitsCockpit.tsx b/apps/web/src/components/gits/GitsCockpit.tsx index ca8d8701a5b3..2f688b9380e9 100644 --- a/apps/web/src/components/gits/GitsCockpit.tsx +++ b/apps/web/src/components/gits/GitsCockpit.tsx @@ -9,7 +9,7 @@ import type { } from "@t3tools/contracts"; import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; import { resolveRepositoryProfile } from "@t3tools/shared/repositoryProfiles"; -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { RefreshCwIcon } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { useShallow } from "zustand/react/shallow"; @@ -72,6 +72,7 @@ import { useDevCommandSessions } from "./cockpit/useDevCommandSessions"; import { readCockpitDeepLink } from "./cockpit/motoko/motoko.logic"; export function GitsCockpit() { + const queryClient = useQueryClient(); const deepLink = readCockpitDeepLink(typeof window === "undefined" ? "" : window.location.search); const [activeTab, setActiveTab] = useState(deepLink.panel); const [focusedProposalId] = useState(deepLink.proposalId); @@ -115,10 +116,6 @@ export function GitsCockpit() { const [gsdMaxBudget, setGsdMaxBudget] = useState(""); const [skillReviews, setSkillReviews] = useState(() => loadSkillReviewState()); const [mcpOverrides, setMcpOverrides] = useState(() => loadMcpOverrideState()); - const [automodeGoalTitle, setAutomodeGoalTitle] = useState(""); - const [automodeGoalRepo, setAutomodeGoalRepo] = useState(""); - const [automodeGoalModel, setAutomodeGoalModel] = useState(""); - const [automodeGoalPrompt, setAutomodeGoalPrompt] = useState(""); const [openGsdCommandResult, setOpenGsdCommandResult] = useState< OpenGsdCommandResult | undefined >(undefined); @@ -413,14 +410,15 @@ export function GitsCockpit() { ...input.edits, }); if (input.decision !== "approve" || decided.status !== "approved") { - return { decided, draft: null }; + return { decided, draft: null, goal: null }; } - // Approval means "go" through the automode rails: the server-side approve bridge - // enqueues delamain-peer drafts as automode goals (own branch, verify floor, held - // PR). Never spawn a raw peer here — an unpinned spawn would let delamain merge - // unreviewed work straight into the origin default branch. - const draft = await client.hermes.draftFromProposal({ proposalId: input.proposalId }); - return { decided, draft }; + const automode = await client.automode.getSnapshot(); + const goal = automode.goals.find( + (candidate) => + candidate.episodeId === decided.episodeId && + !["completed", "failed", "blocked", "rejected"].includes(candidate.status), + ); + return { decided, draft: null, goal: goal ?? null }; }, onSuccess: async (result, input) => { appendMotokoTranscript(input.routeKey, { @@ -429,7 +427,15 @@ export function GitsCockpit() { message: motokoDecisionSummary(input.decision, input.title, result), createdAt: new Date().toISOString(), }); - await Promise.all([hermesProposalsQuery.refetch(), hermesQuery.refetch()]); + await Promise.all([ + hermesProposalsQuery.refetch(), + hermesQuery.refetch(), + automodeQuery.refetch(), + schedulerQuery.refetch(), + queryClient.invalidateQueries({ + queryKey: ["gits", "cockpit-inbox", targetEnvironmentId], + }), + ]); }, onError: (error, input) => { appendMotokoTranscript(input.routeKey, { @@ -560,73 +566,6 @@ export function GitsCockpit() { await query.refetch(); }, }); - const automodeKillSwitchEnabled = automodeQuery.data?.policy.killSwitchEnabled ?? true; - const automodeKillSwitchMutation = useMutation({ - mutationFn: async (killSwitchEnabled: boolean) => - readGitsClient().automode.updatePolicy({ killSwitchEnabled }), - onSuccess: async () => { - await automodeQuery.refetch(); - }, - }); - const automodeEnqueueMutation = useMutation({ - mutationFn: async () => - readGitsClient().automode.enqueueGoal({ - title: automodeGoalTitle.trim(), - repo: automodeGoalRepo.trim(), - prompt: automodeGoalPrompt.trim(), - ...(automodeGoalModel.trim().length > 0 ? { model: automodeGoalModel.trim() } : {}), - }), - onSuccess: async () => { - setAutomodeGoalTitle(""); - setAutomodeGoalPrompt(""); - await automodeQuery.refetch(); - }, - }); - const automodeApproveMutation = useMutation({ - mutationFn: async (goalId: string) => readGitsClient().automode.approveGoal({ goalId }), - onSuccess: async () => { - await automodeQuery.refetch(); - }, - }); - const automodeRejectMutation = useMutation({ - mutationFn: async (goalId: string) => - readGitsClient().automode.rejectGoal({ goalId, reason: "Rejected in cockpit." }), - onSuccess: async () => { - await automodeQuery.refetch(); - }, - }); - const automodeDispatchMutation = useMutation({ - mutationFn: async (goalId: string) => readGitsClient().automode.dispatchGoal({ goalId }), - onSuccess: async () => { - await Promise.all([automodeQuery.refetch(), delamainQuery.refetch()]); - }, - }); - const schedulerSetConfigMutation = useMutation({ - mutationFn: async (input: { enabled: boolean }) => - readGitsClient().automode.schedulerSetConfig(input), - onSuccess: async () => { - await schedulerQuery.refetch(); - }, - }); - const schedulerArmMutation = useMutation({ - mutationFn: async () => readGitsClient().automode.schedulerArm(), - onSuccess: async () => { - await schedulerQuery.refetch(); - }, - }); - const schedulerDisarmMutation = useMutation({ - mutationFn: async () => - readGitsClient().automode.schedulerDisarm({ reason: "Disarmed in cockpit." }), - onSuccess: async () => { - await schedulerQuery.refetch(); - }, - }); - const driverResumeMutation = useMutation({ - mutationFn: async () => readGitsClient().automode.resumeDriver(), - onSuccess: async () => { - await automodeQuery.refetch(); - }, - }); const actionError = spawnMutation.error ?? replyMutation.error ?? @@ -661,26 +600,6 @@ export function GitsCockpit() { hermesDecisionMutation.isPending || hermesDraftMutation.isPending || hermesScheduleMutation.isPending; - const automodeActionError = - automodeKillSwitchMutation.error ?? - automodeEnqueueMutation.error ?? - automodeApproveMutation.error ?? - automodeRejectMutation.error ?? - automodeDispatchMutation.error ?? - schedulerSetConfigMutation.error ?? - schedulerArmMutation.error ?? - schedulerDisarmMutation.error ?? - driverResumeMutation.error; - const automodeActionPending = - automodeKillSwitchMutation.isPending || - automodeEnqueueMutation.isPending || - automodeApproveMutation.isPending || - automodeRejectMutation.isPending || - automodeDispatchMutation.isPending || - schedulerSetConfigMutation.isPending || - schedulerArmMutation.isPending || - schedulerDisarmMutation.isPending || - driverResumeMutation.isPending; const updateSkillReview = ( skillId: string, updater: (current: SkillReviewState[string]) => SkillReviewState[string], @@ -729,13 +648,6 @@ export function GitsCockpit() { setSelectedProjectRoot(MOTOKO_ROOT_ROUTE_VALUE); }, [query.data?.projects, selectedProjectRoot]); - useEffect(() => { - if (automodeGoalRepo.trim().length > 0) { - return; - } - setAutomodeGoalRepo(selectedProjectRoot); - }, [automodeGoalRepo, selectedProjectRoot]); - const tabCounts = useMemo>(() => { const runningGoals = automodeQuery.data?.goals.filter((goal) => goal.status === "running").length ?? 0; @@ -948,48 +860,22 @@ export function GitsCockpit() { {activeTab === "autopilot" ? ( - void hermesDecisionMutation.mutate({ + onProposalDecision={async (proposal, decision, edits) => { + const result = await hermesDecisionMutation.mutateAsync({ proposalId: proposal.id, decision, routeKey: motokoRoute, title: proposal.title, edits, - }) - } - onRefresh={() => void automodeQuery.refetch()} - onKillSwitchChange={(next) => void automodeKillSwitchMutation.mutate(next)} - onGoalTitleChange={setAutomodeGoalTitle} - onGoalRepoChange={setAutomodeGoalRepo} - onGoalModelChange={setAutomodeGoalModel} - onGoalPromptChange={setAutomodeGoalPrompt} - onEnqueueGoal={() => void automodeEnqueueMutation.mutate()} - onApproveGoal={(goalId) => void automodeApproveMutation.mutate(goalId)} - onRejectGoal={(goalId) => { - if (window.confirm(`Reject automode goal ${goalId}?`)) { - void automodeRejectMutation.mutate(goalId); - } + }); + return result.goal; }} - onDispatchGoal={(goalId) => void automodeDispatchMutation.mutate(goalId)} - onSchedulerEnabledChange={(enabled) => - void schedulerSetConfigMutation.mutate({ enabled }) - } - onSchedulerArm={() => void schedulerArmMutation.mutate()} - onSchedulerDisarm={() => void schedulerDisarmMutation.mutate()} - onResumeDriver={() => void driverResumeMutation.mutate()} + onRefresh={() => void automodeQuery.refetch()} /> ) : null} {activeTab === "fleet" ? ( diff --git a/apps/web/src/components/gits/cockpit/AutopilotPanel.browser.tsx b/apps/web/src/components/gits/cockpit/AutopilotPanel.browser.tsx new file mode 100644 index 000000000000..39669a87ed06 --- /dev/null +++ b/apps/web/src/components/gits/cockpit/AutopilotPanel.browser.tsx @@ -0,0 +1,145 @@ +import "../../../index.css"; + +import type { AutomodeSnapshot, HermesProposalListResult } from "@t3tools/contracts"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { page } from "vitest/browser"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { render } from "vitest-browser-react"; + +const ENVIRONMENT_ID = "autopilot-environment" as never; +const automode = vi.hoisted(() => ({ configure: vi.fn(), stopAll: vi.fn() })); +const cockpitInbox = vi.hoisted(() => ({ + list: vi.fn(), + markAllRead: vi.fn(), + markRead: vi.fn(), + pin: vi.fn(), +})); + +vi.mock("~/environments/primary", async (importOriginal) => ({ + ...(await importOriginal()), + usePrimaryEnvironmentId: () => ENVIRONMENT_ID, +})); +vi.mock("~/gitsClient", () => ({ + readGitsEnvironmentClient: () => ({ automode, cockpitInbox }), +})); +vi.mock("~/components/ui/toast", () => ({ toastManager: { add: vi.fn() } })); + +import { AutopilotPanel } from "./AutopilotPanel"; +import { TEST_PROPOSAL } from "./proposal-launch/proposalLaunch.logic"; + +const snapshot = (on: boolean, repositories = ["/srv/project"]): AutomodeSnapshot => + ({ + policy: { + mode: on ? "autonomous" : "manual", + killSwitchEnabled: !on, + proposalRepos: repositories, + allowedRepos: repositories, + allowedModels: ["gpt-5.6-terra"], + defaultModel: "gpt-5.6-terra", + maxRuntimeMinutes: 60, + verificationCommands: [], + integrationBranch: null, + }, + goals: [ + { + id: "goal-1", + title: "Ship the simple flow", + repo: "/srv/project", + status: "queued", + createdAt: "2026-08-09T00:00:00.000Z", + }, + ], + activePeerCount: 0, + pendingApprovalCount: 0, + }) as unknown as AutomodeSnapshot; + +function panel( + value: AutomodeSnapshot, + focusedProposalId: string | null = null, + proposals: HermesProposalListResult | undefined = undefined, +) { + return ( + + + + ); +} + +function renderPanel(value: AutomodeSnapshot) { + return render(panel(value)); +} + +beforeEach(() => { + automode.configure.mockResolvedValue(undefined); + automode.stopAll.mockResolvedValue({ stoppedPeers: 0, failures: 0 }); + cockpitInbox.list.mockResolvedValue({ + items: [], + counts: { unread: 0, active: 0, "needs-attention": 0, all: 0 }, + }); +}); + +afterEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ""; +}); + +describe("AutopilotPanel", () => { + it("shows only the simple operator controls", async () => { + renderPanel(snapshot(false)); + await expect.element(page.getByText("Paused", { exact: true })).toBeVisible(); + await expect.element(page.getByText("Watched repositories")).toBeVisible(); + await expect.element(page.getByText("Inbox", { exact: true })).toBeVisible(); + await expect.element(page.getByText("Queue", { exact: true })).toBeVisible(); + await expect.element(page.getByRole("button", { name: "Emergency Stop" })).toBeVisible(); + expect(document.body.textContent).not.toContain("Automation switchboard"); + expect(document.body.textContent).not.toContain("Max budget"); + expect(document.body.textContent).not.toContain("Scheduler arm"); + expect(document.body.textContent).not.toContain("Manual goal"); + }); + + it("pauses through the narrow configure operation", async () => { + renderPanel(snapshot(true)); + await page.getByRole("button", { name: "Pause" }).click(); + expect(automode.configure).toHaveBeenCalledWith({ + enabled: false, + repositories: ["/srv/project"], + }); + }); + + it("refuses to enable without a watched repository", async () => { + renderPanel(snapshot(false, [])); + await page.getByRole("button", { name: "Turn on" }).click(); + await expect.element(page.getByRole("alert")).toHaveTextContent("Choose at least one"); + expect(automode.configure).not.toHaveBeenCalled(); + }); + + it("does not reopen a consumed deep-linked proposal after polling", async () => { + const proposals = { + proposals: [TEST_PROPOSAL], + checkedAt: "2026-08-09T00:00:00.000Z", + } satisfies HermesProposalListResult; + const screen = await render(panel(snapshot(false), TEST_PROPOSAL.id, proposals)); + await expect.element(page.getByText("Validate and queue")).toBeVisible(); + await page.getByRole("button", { name: "Close" }).click(); + await expect.element(page.getByText("Validate and queue")).not.toBeInTheDocument(); + + await screen.rerender( + panel(snapshot(false), TEST_PROPOSAL.id, { + ...proposals, + checkedAt: "2026-08-09T00:01:00.000Z", + }), + ); + await expect.element(page.getByText("Validate and queue")).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/gits/cockpit/AutopilotPanel.tsx b/apps/web/src/components/gits/cockpit/AutopilotPanel.tsx index 1aeb95839997..af31a608d4f6 100644 --- a/apps/web/src/components/gits/cockpit/AutopilotPanel.tsx +++ b/apps/web/src/components/gits/cockpit/AutopilotPanel.tsx @@ -1,35 +1,14 @@ import type { - AutomodeEpisode, AutomodeGoal, - AutomodeGoalStatus, - AutomodeMode, - AutomodePolicyUpdateInput, AutomodeSnapshot, - CodexModelTier, GitsCockpitProject, - GitsSchedulerSnapshot, HermesProposalCard, HermesProposalListResult, - MotokoAuthority, } from "@t3tools/contracts"; -import { CODEX_MODEL_TIER_LABELS, CODEX_MODEL_TIERS } from "@t3tools/contracts"; -import { useMutation, useQuery } from "@tanstack/react-query"; -import { - ListChecksIcon, - OctagonAlertIcon, - PlayIcon, - PowerIcon, - RefreshCwIcon, - ShieldCheckIcon, - SkullIcon, -} from "lucide-react"; -import { useEffect, useState } from "react"; +import { useMutation } from "@tanstack/react-query"; +import { OctagonAlertIcon, RefreshCwIcon, RocketIcon } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { usePrimaryEnvironmentId } from "~/environments/primary"; -import type { GitsEnvironmentClient } from "~/gitsClient"; -import { readGitsEnvironmentClient } from "~/gitsClient"; -import { cn } from "~/lib/utils"; -import { useStore } from "~/store"; import { AlertDialog, AlertDialogClose, @@ -40,7 +19,6 @@ import { AlertDialogTitle, } from "~/components/ui/alert-dialog"; import { Button } from "~/components/ui/button"; -import { Checkbox } from "~/components/ui/checkbox"; import { Combobox, ComboboxChip, @@ -51,158 +29,25 @@ import { ComboboxList, ComboboxPopup, } from "~/components/ui/combobox"; -import { Input } from "~/components/ui/input"; -import { - MotokoProposalReview, - type MotokoProposalDecision, - type MotokoProposalEdits, -} from "./MotokoPanel"; -import { - Select, - SelectGroup, - SelectItem, - SelectPopup, - SelectTrigger, - SelectValue, -} from "~/components/ui/select"; -import { Switch } from "~/components/ui/switch"; import { Textarea } from "~/components/ui/textarea"; import { toastManager } from "~/components/ui/toast"; -import { InboxSection } from "./InboxSection"; +import { usePrimaryEnvironmentId } from "~/environments/primary"; +import type { GitsEnvironmentClient } from "~/gitsClient"; +import { readGitsEnvironmentClient } from "~/gitsClient"; +import { cn } from "~/lib/utils"; +import { useStore } from "~/store"; +import type { MotokoProposalDecision, MotokoProposalEdits } from "./MotokoPanel"; +import { InboxSection } from "./InboxSection"; +import { ProposalLaunchSheet } from "./ProposalLaunchSheet"; import { - type AutopilotPolicyForm, - formStateToPolicyUpdate, - formatGateDecision, - modelTierOf, - nextPolicyFormState, - policyToFormState, - reposNotAllowed, - verdictTone, -} from "./autopilot/autopilot.logic"; -import { - EmptyState, - SectionHeader, - StatusPill, - formatCount, - formatIsoDate, - formatSlotRemaining, - formatUsd, - parseLines, - statusTone, -} from "./primitives"; - -const MODEL_TIERS = Object.keys(CODEX_MODEL_TIERS) as ReadonlyArray; -const AUTOMODE_MODES: ReadonlyArray = ["manual", "supervised", "autonomous"]; -const GOAL_STATUS_FILTERS: ReadonlyArray<"all" | AutomodeGoalStatus> = [ - "all", - "waiting-approval", - "queued", - "running", - "blocked", - "completed", - "failed", - "rejected", -]; -const MOTOKO_AUTHORITY_OPTIONS: ReadonlyArray<{ - value: MotokoAuthority; - label: string; - description: string; -}> = [ - { - value: "observe", - label: "Observe", - description: "Read-only — Motoko cannot send peer messages.", - }, - { - value: "respond", - label: "Respond", - description: "Motoko may reply to open messages, but not start new ones.", - }, - { - value: "dispatch", - label: "Dispatch", - description: - "Motoko may send new peer messages. Integrate, merge, and destructive actions stay human-gated.", - }, -]; -const SWITCHBOARD_FIELDS: ReadonlyArray<{ - field: keyof Pick< - AutomodePolicyUpdateInput, - | "nightlyProposalSweep" - | "sweepRequiresConfirmation" - | "autoEnqueueApprovedProposals" - | "telegramDigestEnabled" - | "gitsNotificationsEnabled" - | "telegramNotificationsEnabled" - >; - label: string; - description: string; -}> = [ - { - field: "nightlyProposalSweep", - label: "Nightly proposal sweep", - description: "Motoko proposes changes nightly from 20:00 London for opted-in repos.", - }, - { - field: "sweepRequiresConfirmation", - label: "Sweep requires confirmation", - description: "Sweep goals wait for your APPROVE before running.", - }, - { - field: "autoEnqueueApprovedProposals", - label: "Auto-enqueue approved proposals", - description: - "Approved Motoko proposals join the goal queue automatically (autonomous mode only).", - }, - { - field: "gitsNotificationsEnabled", - label: "GITS/PWA notifications", - description: "Notify this device about proposals and Automode attention states.", - }, - { - field: "telegramNotificationsEnabled", - label: "Telegram notifications", - description: "Send proposal and Automode attention notifications through Telegram.", - }, - { - field: "telegramDigestEnabled", - label: "Telegram digest", - description: "Send the daily Telegram digest/report.", - }, -]; + guidedAutopilotRepositories, + isGuidedAutopilotProposal, +} from "./proposal-launch/proposalLaunch.logic"; +import { EmptyState, StatusPill, formatCount } from "./primitives"; -function automodeGoalTone(goal: AutomodeGoal): ReturnType { - if (goal.status === "running" || goal.status === "completed") { - return "success"; - } - if (goal.status === "waiting-approval" || goal.status === "queued") { - return "warning"; - } - if (goal.status === "blocked" || goal.status === "failed" || goal.status === "rejected") { - return "danger"; - } - return "default"; -} +const TERMINAL_GOAL_STATUSES = new Set(["completed", "failed", "blocked", "rejected"]); -function formatFilterLabel(value: "all" | AutomodeGoalStatus): string { - return value === "all" ? "All" : value.replace(/-/g, " "); -} - -function tierLabelShort(tier: CodexModelTier): string { - return CODEX_MODEL_TIER_LABELS[tier].split(" —")[0] ?? tier; -} - -function repoBasename(repo: string): string { - const trimmed = repo.replace(/\/+$/, ""); - const parts = trimmed.split("/").filter(Boolean); - return parts[parts.length - 1] ?? repo; -} - -/** Panel-local client access: `GitsCockpit.tsx` (owned by another agent) doesn't thread - * `readGitsClient` down to this panel, so this mirrors the same construction - * `useGitsCockpitQueries.ts` uses (primary vs. active-environment resolution) rather than - * waiting on a shell change. See `~/gitsClient.ts` for the shared environmentId->client lookup. */ function useAutopilotGitsClient(): { readGitsClient: () => GitsEnvironmentClient; targetEnvironmentId: string | null; @@ -213,63 +58,54 @@ function useAutopilotGitsClient(): { return { readGitsClient: () => { const client = targetEnvironmentId ? readGitsEnvironmentClient(targetEnvironmentId) : null; - if (!client) { - throw new Error("No connected GITS environment."); - } + if (!client) throw new Error("No connected GITS environment."); return client; }, targetEnvironmentId, }; } -function toastError(title: string, error: unknown) { - toastManager.add({ - title, - description: error instanceof Error ? error.message : "Unknown error.", - type: "error", - }); -} - -/** Multi-repo picker: a Combobox chip-list when `projects` is available (the shell doesn't - * pass it today — see the `projects` prop below), otherwise the same one-path-per-line - * textarea every other repo list field already uses. */ -function RepoMultiSelect({ +function RepositoryPicker({ value, onChange, projects, - placeholder, }: { - value: ReadonlyArray; - onChange: (next: ReadonlyArray) => void; - projects: ReadonlyArray | undefined; - placeholder: string; + readonly value: ReadonlyArray; + readonly onChange: (value: ReadonlyArray) => void; + readonly projects: ReadonlyArray | undefined; }) { if (!projects || projects.length === 0) { return (