diff --git a/apps/api/src/modules/deployments/compose/pipeline.ts b/apps/api/src/modules/deployments/compose/pipeline.ts index fba79eb79..03ccf29ad 100644 --- a/apps/api/src/modules/deployments/compose/pipeline.ts +++ b/apps/api/src/modules/deployments/compose/pipeline.ts @@ -277,6 +277,8 @@ export async function executeComposePipeline(opts: ComposePipelineOpts): Promise ? routeIssuesWarning(composeResult.routeWarnings ?? [], composeResult.tlsPendingDomains ?? []) : undefined; const successWarning = routingWarning ?? composeResult.warning; + const decisionPending = + composeResult.summary.failed > 0 && composeResult.summary.successful > 0; sessionManager.broadcastInstallPhase(dep.id, { id: "ready", status: "done" }); // Every service was carried forward: this row owns no container and no image, so @@ -297,6 +299,7 @@ export async function executeComposePipeline(opts: ComposePipelineOpts): Promise url: composeResult.publicUrl, durationMs: composeBuild.durationMs, warningMessage: successWarning, + decisionPending, metaPatch: { composeDeployment: { totalServices: composeResult.summary.total, @@ -304,6 +307,7 @@ export async function executeComposePipeline(opts: ComposePipelineOpts): Promise failedServices: composeResult.summary.failed, failedServiceNames: composeResult.summary.failedServices, warningMessage: composeResult.warning, + ...(decisionPending ? { decision: "pending" } : {}), }, ...(routingWarning ? { edgeUnsynced: true, deployWarning: routingWarning } : {}), ...(composeResult.portChecks && composeResult.portChecks.length > 0 diff --git a/apps/api/src/modules/deployments/decision-vs-warning.test.ts b/apps/api/src/modules/deployments/decision-vs-warning.test.ts index cf86dbdab..b404a211f 100644 --- a/apps/api/src/modules/deployments/decision-vs-warning.test.ts +++ b/apps/api/src/modules/deployments/decision-vs-warning.test.ts @@ -21,6 +21,7 @@ import { routeIssuesWarning } from "./deployment-lifecycle"; */ const pipeline = readFileSync(new URL("./build-pipeline.ts", import.meta.url), "utf8"); const lifecycle = readFileSync(new URL("./deployment-lifecycle.ts", import.meta.url), "utf8"); +const deploymentService = readFileSync(new URL("./deployment.service.ts", import.meta.url), "utf8"); const codeOnly = (s: string) => s.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, ""); @@ -41,6 +42,13 @@ describe("the server announces a held decision on the live event", () => { }); }); +describe("resolved decisions stop replaying from the terminal session", () => { + it("clears cached decision state after both keep and reject", () => { + expect(codeOnly(deploymentService).match(/clearDecisionPending\(deploymentId\)/g) ?? []) + .toHaveLength(2); + }); +}); + describe("routeIssuesWarning is advisory only", () => { it("describes TLS-pending domains as routed, not as failures", () => { const msg = routeIssuesWarning([], ["api.example.com", "app.example.com"]); diff --git a/apps/api/src/modules/deployments/deployment-lifecycle.ts b/apps/api/src/modules/deployments/deployment-lifecycle.ts index 7236861de..ae402a4ef 100644 --- a/apps/api/src/modules/deployments/deployment-lifecycle.ts +++ b/apps/api/src/modules/deployments/deployment-lifecycle.ts @@ -649,6 +649,7 @@ export async function onSuccess( url?: string; durationMs: number; warningMessage?: string; + decisionPending?: boolean; metaPatch?: Record; }, ): Promise { @@ -799,6 +800,7 @@ export async function onSuccess( .filter(Boolean) .join(" ") : result.warningMessage, + decisionPending: result.decisionPending, // Advisory port-check results ride the live `complete` event so the dashboard // can raise the "wrong port?" modal immediately; the same data is persisted in // meta (above) for re-hydration on refresh. diff --git a/apps/api/src/modules/deployments/deployment.service.ts b/apps/api/src/modules/deployments/deployment.service.ts index 3a5ec05a3..657b6ddac 100644 --- a/apps/api/src/modules/deployments/deployment.service.ts +++ b/apps/api/src/modules/deployments/deployment.service.ts @@ -37,6 +37,7 @@ import { checkNoActiveBuild } from "./build.service"; import { livePrimaryContainerId } from "../services/service-container"; import { decryptEnvMap } from "../../lib/encryption"; import { inlineEmptyDefers } from "./compose/service-env-layers"; +import * as sessionManager from "./session-manager"; /** * #336: present a deployment to a CLIENT — masks `meta.composeServices[].environment`. @@ -417,6 +418,7 @@ export async function rejectDeployment( ? { meta: { ...rejectMeta, composeDeployment: { ...rejectedCompose, decision: "rejected" } } } : undefined, ); + sessionManager.clearDecisionPending(deploymentId); return { success: true, @@ -447,6 +449,7 @@ export async function keepDeployment( meta: { ...meta, composeDeployment: { ...existingCompose, decision: "kept" } }, }); } + sessionManager.clearDecisionPending(deploymentId); // Normally onSuccess already advanced the pointer to this release; ensure it // (the kept partial is the live one now). @@ -625,4 +628,3 @@ export async function getBuildLogs( return buildSession.logs as LogEntry[]; } - diff --git a/apps/api/src/modules/deployments/session-manager.decision-pending.test.ts b/apps/api/src/modules/deployments/session-manager.decision-pending.test.ts new file mode 100644 index 000000000..30763ba6d --- /dev/null +++ b/apps/api/src/modules/deployments/session-manager.decision-pending.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { + clearDecisionPending, + createSession, + subscribe, + updateStatus, + type SseWriter, +} from "./session-manager"; + +/** + * A partial-failure compose deploy is HELD for an explicit keep/reject decision. + * The compose pipeline announces it on its first SSE "ready" and finalization + * repeats the same terminal state after persisting `partial_failure`. Session-manager + * used to drop `meta.decisionPending` on the floor: the live `complete` event never + * carried it and nothing was stored for replay. The dashboard's handler reads ONLY + * the server's flag + * (`decisionPending: !!data?.decisionPending`, pinned by decision-pending.test.ts), + * so the keep/reject modal stayed hidden until a page refresh re-read it from the + * REST snapshot (#664). + */ +function recordingWriter(): { writer: SseWriter; events: Array<{ event: string; data: any }> } { + const events: Array<{ event: string; data: any }> = []; + const writer: SseWriter = (event, data) => { + events.push({ event, data: JSON.parse(data) }); + return true; + }; + return { writer, events }; +} + +describe("session-manager carries decisionPending", () => { + it("the live complete event carries the held decision", () => { + const dep = "dep_dp_live"; + createSession(dep, "proj_1"); + const { writer, events } = recordingWriter(); + subscribe(dep, writer); + events.length = 0; + + updateStatus(dep, "ready", { warningMessage: "Some services failed", decisionPending: true }); + + const complete = events.find((e) => e.event === "complete"); + expect(complete?.data).toMatchObject({ success: true, decisionPending: true }); + }); + + it("a reconnecting subscriber replays it after the stream closed", () => { + const dep = "dep_dp_replay"; + createSession(dep, "proj_1"); + + // Terminal write with no one watching — the refresh-before-reconnect case. + updateStatus(dep, "ready", { warningMessage: "Some services failed", decisionPending: true }); + + const { writer, events } = recordingWriter(); + subscribe(dep, writer); + + const complete = events.find((e) => e.event === "complete"); + expect(complete?.data).toMatchObject({ success: true, decisionPending: true }); + }); + + it("an absent flag stays absent — a clean deploy must not open the modal", () => { + const dep = "dep_dp_clean"; + createSession(dep, "proj_1"); + const { writer, events } = recordingWriter(); + subscribe(dep, writer); + events.length = 0; + + updateStatus(dep, "ready", { warningMessage: "routed but have no HTTPS certificate yet" }); + + const liveComplete = events.find((e) => e.event === "complete"); + expect(liveComplete?.data).not.toHaveProperty("decisionPending"); + + const late = recordingWriter(); + subscribe(dep, late.writer); + const replayed = late.events.find((e) => e.event === "complete"); + expect(replayed?.data).not.toHaveProperty("decisionPending"); + }); + + it("stops replaying the decision after keep or reject resolves it", () => { + const dep = "dep_dp_resolved"; + createSession(dep, "proj_1"); + updateStatus(dep, "ready", { warningMessage: "Some services failed", decisionPending: true }); + + clearDecisionPending(dep); + + const { writer, events } = recordingWriter(); + subscribe(dep, writer); + const complete = events.find((e) => e.event === "complete"); + expect(complete?.data).not.toHaveProperty("decisionPending"); + }); +}); diff --git a/apps/api/src/modules/deployments/session-manager.ts b/apps/api/src/modules/deployments/session-manager.ts index c140662a6..a28b5c079 100644 --- a/apps/api/src/modules/deployments/session-manager.ts +++ b/apps/api/src/modules/deployments/session-manager.ts @@ -32,6 +32,10 @@ export interface BuildSessionState { logs: LogEntry[]; warningMessage?: string; errorMessage?: string; + /** A partial-failure deploy is HELD for an explicit keep/reject decision + * (#664). Stored — like warningMessage — so a refresh/reconnect replays the + * modal trigger instead of hiding it until the next REST poll. */ + decisionPending?: boolean; /** Per-service deployment statuses (compose projects only, for replay on reconnect) */ serviceStatuses: Map; /** Latest state of each install phase (catalog-app installs). Stored — not just @@ -125,6 +129,12 @@ export function getSession(sessionId: string): BuildSessionState | null { return sessions.get(sessionId); } +/** Stop replaying a partial-failure decision after it has been resolved. */ +export function clearDecisionPending(sessionId: string): void { + const session = sessions.get(sessionId); + if (session) session.decisionPending = undefined; +} + /** Append a log entry and broadcast to subscribers */ export function appendLog(sessionId: string, entry: LogEntry): void { const session = sessions.get(sessionId); @@ -221,6 +231,9 @@ export function updateStatus( errorMessage?: string; /** Advisory post-deploy port-check results, forwarded on the `complete` event. */ portCheck?: PortCheckResult[]; + /** A partial-failure deploy is being HELD for an explicit keep/reject decision. + * Explicit because nothing else on the event implies it — see setDeploymentStatus. */ + decisionPending?: boolean; }, ): void { const session = sessions.get(sessionId); @@ -229,6 +242,7 @@ export function updateStatus( session.status = status; session.warningMessage = meta?.warningMessage; session.errorMessage = meta?.errorMessage; + session.decisionPending = meta?.decisionPending; // Broadcast typed events matching frontend expectations if (status === "ready") { @@ -236,6 +250,7 @@ export function updateStatus( type: "complete", success: true, ...(session.warningMessage ? { warningMessage: session.warningMessage } : {}), + ...(session.decisionPending ? { decisionPending: true } : {}), ...(meta?.portCheck && meta.portCheck.length > 0 ? { portCheck: meta.portCheck } : {}), }); for (const writer of session.subscribers) { @@ -365,6 +380,7 @@ export function subscribe( type: "complete", success: true, ...(session.warningMessage ? { warningMessage: session.warningMessage } : {}), + ...(session.decisionPending ? { decisionPending: true } : {}), })); } else if (session.status === "failed") { const lastError = [...session.logs].reverse().find((l) => l.level === "error"); diff --git a/apps/api/test/modules/deployments/compose-noop-settle.test.ts b/apps/api/test/modules/deployments/compose-noop-settle.test.ts index 983fdd565..aec51efe7 100644 --- a/apps/api/test/modules/deployments/compose-noop-settle.test.ts +++ b/apps/api/test/modules/deployments/compose-noop-settle.test.ts @@ -17,6 +17,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const h = vi.hoisted(() => ({ activePointer: [] as string[], statusWrites: [] as Array<{ id: string; status: string; extra?: Record }>, + sessionStatuses: [] as Array<{ id: string; status: string; detail?: Record }>, containerIdWrites: [] as Array, notifications: [] as string[], audits: [] as string[], @@ -50,7 +51,9 @@ vi.mock("@repo/db", () => ({ })); vi.mock("../../../src/modules/deployments/session-manager", () => ({ - updateStatus: () => {}, + updateStatus: (id: string, status: string, detail?: Record) => { + h.sessionStatuses.push({ id, status, detail }); + }, broadcastServiceStatus: () => {}, broadcastInstallPhase: () => {}, appendLog: () => {}, @@ -132,6 +135,7 @@ function optsFor(): PipelineOpts { beforeEach(() => { h.activePointer = []; h.statusWrites = []; + h.sessionStatuses = []; h.containerIdWrites = []; h.notifications = []; h.audits = []; @@ -223,4 +227,34 @@ describe("executeComposePipeline — an all-carried redeploy must not take over" expect(h.statusWrites.some((w) => w.status === "no_changes")).toBe(false); expect(h.activePointer).toEqual(["prj_1:dep_1"]); }); + + it("announces a partial-failure decision on the first terminal event", async () => { + h.deployResult = { + status: "ready", + summary: { + total: 2, + successful: 1, + deployed: 1, + failed: 1, + indeterminate: 0, + mutated: true, + failedServices: ["worker"], + }, + services: [ + { serviceId: "app", serviceName: "app", containerId: "cid-app", status: "running" }, + { serviceId: "worker", serviceName: "worker", status: "failed", error: "exit 1" }, + ], + primaryContainerId: "cid-app", + warning: "Some services failed", + portChecks: [], + }; + + await executeComposePipeline(optsFor()); + + const terminal = h.sessionStatuses.find((entry) => entry.status === "ready"); + expect(terminal?.detail).toMatchObject({ decisionPending: true }); + expect(h.statusWrites.at(-1)?.extra).toMatchObject({ + meta: { composeDeployment: { decision: "pending" } }, + }); + }); }); diff --git a/apps/dashboard/src/context/deployment/decision-pending.test.ts b/apps/dashboard/src/context/deployment/decision-pending.test.ts index a7d41a6e6..76645d883 100644 --- a/apps/dashboard/src/context/deployment/decision-pending.test.ts +++ b/apps/dashboard/src/context/deployment/decision-pending.test.ts @@ -35,4 +35,9 @@ describe("every other decisionPending read is already server-driven", () => { // These were correct before this change; pinned so the three sites can't diverge again. expect((code.match(/!!data\??\.decisionPending/g) ?? []).length).toBeGreaterThanOrEqual(3); }); + + it("forwards the REST flag through finished-deployment success hydration", () => { + const finished = code.slice(code.indexOf('else if (status === "ready")')); + expect(finished).toContain("decisionPending: data.decisionPending"); + }); }); diff --git a/apps/dashboard/src/context/deployment/useDeploymentBuild.tsx b/apps/dashboard/src/context/deployment/useDeploymentBuild.tsx index 6ba68811f..dfc6880bc 100644 --- a/apps/dashboard/src/context/deployment/useDeploymentBuild.tsx +++ b/apps/dashboard/src/context/deployment/useDeploymentBuild.tsx @@ -1319,6 +1319,7 @@ export function useDeploymentBuild( screenshots: data.screenshots, project_id: data.project_id, warningMessage: data.warningMessage, + decisionPending: data.decisionPending, }); if (data.warningMessage) { showToast(data.warningMessage, "success", "Deployment Ready With Warnings");