diff --git a/apps/api/src/server.integration.test.ts b/apps/api/src/server.integration.test.ts index fd6ed94..ed867e7 100644 --- a/apps/api/src/server.integration.test.ts +++ b/apps/api/src/server.integration.test.ts @@ -3842,4 +3842,511 @@ describe("@runroot/api integration", () => { await peerApp.close(); } }); + + it("serves acknowledge, list-acknowledged, inspect-acknowledgment, clear-acknowledgment, and apply paths through the network API", async () => { + const workspaceRoot = await mkdtemp( + join(tmpdir(), "runroot-api-checklist-acknowledgment-"), + ); + const sqlitePath = join(workspaceRoot, "runroot.sqlite"); + const inlineOperator = createRunrootOperatorService({ + executionMode: "inline", + persistenceDriver: "sqlite", + sqlitePath, + }); + const queuedOperator = createRunrootOperatorService({ + executionMode: "queued", + persistenceDriver: "sqlite", + sqlitePath, + }); + const workerService = ( + await import("@runroot/sdk") + ).createRunrootWorkerService({ + persistenceDriver: "sqlite", + sqlitePath, + workerId: "worker_acknowledgment_api", + }); + const ownerApp = buildServer({ + operator: createRunrootOperatorService({ + catalogEntryIdGenerator: () => "catalog_entry_acknowledgment_api", + operatorId: "ops_oncall", + operatorScopeId: "ops", + persistenceDriver: "sqlite", + savedViewIdGenerator: () => "saved_view_acknowledgment_api", + sqlitePath, + }), + }); + const peerApp = buildServer({ + operator: createRunrootOperatorService({ + operatorId: "ops_backup", + operatorScopeId: "ops", + persistenceDriver: "sqlite", + sqlitePath, + }), + }); + + try { + const inlineRun = await inlineOperator.startRun({ + input: { + approvalRequired: false, + commandAlias: "print-ready", + runbookId: "node-health-check", + }, + templateId: "shell-runbook-flow", + }); + const queuedRun = await queuedOperator.startRun({ + input: { + approvalRequired: false, + commandAlias: "print-ready", + runbookId: "node-health-check", + }, + templateId: "shell-runbook-flow", + }); + + await workerService.processNextJob(); + + const ownerAddress = await ownerApp.listen({ + host: "127.0.0.1", + port: 0, + }); + const peerAddress = await peerApp.listen({ + host: "127.0.0.1", + port: 0, + }); + + const saveResponse = await fetch(`${ownerAddress}/audit/saved-views`, { + body: JSON.stringify({ + description: "Queued worker acknowledgment preset", + name: "Queued worker acknowledgment preset", + navigation: { + drilldown: { + workerId: "worker_acknowledgment_api", + }, + summary: { + executionMode: "queued", + }, + }, + refs: { + auditViewRunId: queuedRun.id, + drilldownRunId: queuedRun.id, + }, + }), + headers: { + "content-type": "application/json", + }, + method: "POST", + }); + const savedViewPayload = (await saveResponse.json()) as { + savedView: { + id: string; + }; + }; + const publishResponse = await fetch(`${ownerAddress}/audit/catalog`, { + body: JSON.stringify({ + savedViewId: savedViewPayload.savedView.id, + }), + headers: { + "content-type": "application/json", + }, + method: "POST", + }); + const publishedPayload = (await publishResponse.json()) as { + catalogEntry: { + entry: { + id: string; + }; + }; + }; + const shareResponse = await fetch( + `${ownerAddress}/audit/catalog/${publishedPayload.catalogEntry.entry.id}/share`, + { + method: "POST", + }, + ); + const reviewResponse = await fetch( + `${ownerAddress}/audit/catalog/${publishedPayload.catalogEntry.entry.id}/review`, + { + body: JSON.stringify({ + note: `Acknowledgment ready after inline ${inlineRun.id} and queued ${queuedRun.id}`, + state: "recommended", + }), + headers: { + "content-type": "application/json", + }, + method: "POST", + }, + ); + const assignmentResponse = await fetch( + `${ownerAddress}/audit/catalog/${publishedPayload.catalogEntry.entry.id}/assignment`, + { + body: JSON.stringify({ + assigneeId: "ops_backup", + handoffNote: `Queued worker ${queuedRun.id} handed to backup`, + }), + headers: { + "content-type": "application/json", + }, + method: "POST", + }, + ); + const checklistResponse = await fetch( + `${ownerAddress}/audit/catalog/${publishedPayload.catalogEntry.entry.id}/checklist`, + { + body: JSON.stringify({ + items: ["Validate queued follow-up", "Close backup handoff"], + state: "pending", + }), + headers: { + "content-type": "application/json", + }, + method: "POST", + }, + ); + const progressResponse = await fetch( + `${ownerAddress}/audit/catalog/${publishedPayload.catalogEntry.entry.id}/progress`, + { + body: JSON.stringify({ + completionNote: "Queued follow-up is almost complete", + items: [ + { + item: "Validate queued follow-up", + state: "completed", + }, + { + item: "Close backup handoff", + state: "pending", + }, + ], + }), + headers: { + "content-type": "application/json", + }, + method: "POST", + }, + ); + const blockerResponse = await fetch( + `${ownerAddress}/audit/catalog/${publishedPayload.catalogEntry.entry.id}/blocker`, + { + body: JSON.stringify({ + blockerNote: "Waiting for the overnight handoff", + items: [ + { + item: "Validate queued follow-up", + state: "cleared", + }, + { + item: "Close backup handoff", + state: "blocked", + }, + ], + }), + headers: { + "content-type": "application/json", + }, + method: "POST", + }, + ); + const resolutionResponse = await fetch( + `${ownerAddress}/audit/catalog/${publishedPayload.catalogEntry.entry.id}/resolution`, + { + body: JSON.stringify({ + resolutionNote: "Backup confirmed the follow-up closure", + items: [ + { + item: "Validate queued follow-up", + state: "resolved", + }, + { + item: "Close backup handoff", + state: "unresolved", + }, + ], + }), + headers: { + "content-type": "application/json", + }, + method: "POST", + }, + ); + const verificationResponse = await fetch( + `${ownerAddress}/audit/catalog/${publishedPayload.catalogEntry.entry.id}/verification`, + { + body: JSON.stringify({ + verificationNote: "Backup verified the follow-up closure", + items: [ + { + item: "Validate queued follow-up", + state: "verified", + }, + { + item: "Close backup handoff", + state: "unverified", + }, + ], + }), + headers: { + "content-type": "application/json", + }, + method: "POST", + }, + ); + const evidenceResponse = await fetch( + `${ownerAddress}/audit/catalog/${publishedPayload.catalogEntry.entry.id}/evidence`, + { + body: JSON.stringify({ + evidenceNote: "Backup collected stable follow-up references", + items: [ + { + item: "Validate queued follow-up", + references: [ + "run://queued-follow-up", + "note://backup-closeout", + ], + }, + { + item: "Close backup handoff", + references: ["doc://backup-handoff"], + }, + ], + }), + headers: { + "content-type": "application/json", + }, + method: "POST", + }, + ); + const attestationResponse = await fetch( + `${ownerAddress}/audit/catalog/${publishedPayload.catalogEntry.entry.id}/attestation`, + { + body: JSON.stringify({ + attestationNote: "Backup attested the stable follow-up evidence", + items: [ + { + item: "Validate queued follow-up", + state: "attested", + }, + { + item: "Close backup handoff", + state: "unattested", + }, + ], + }), + headers: { + "content-type": "application/json", + }, + method: "POST", + }, + ); + const acknowledgmentResponse = await fetch( + `${ownerAddress}/audit/catalog/${publishedPayload.catalogEntry.entry.id}/acknowledgment`, + { + body: JSON.stringify({ + acknowledgmentNote: "Backup acknowledged the attested follow-up", + items: [ + { + item: "Validate queued follow-up", + state: "acknowledged", + }, + { + item: "Close backup handoff", + state: "unacknowledged", + }, + ], + }), + headers: { + "content-type": "application/json", + }, + method: "POST", + }, + ); + const acknowledgedResponse = await fetch( + `${peerAddress}/audit/catalog/acknowledged`, + ); + const inspectResponse = await fetch( + `${peerAddress}/audit/catalog/${publishedPayload.catalogEntry.entry.id}/acknowledgment`, + ); + const applyResponse = await fetch( + `${peerAddress}/audit/catalog/${publishedPayload.catalogEntry.entry.id}/apply`, + ); + const clearResponse = await fetch( + `${ownerAddress}/audit/catalog/${publishedPayload.catalogEntry.entry.id}/acknowledgment/clear`, + { + method: "POST", + }, + ); + const acknowledgedAfterClearResponse = await fetch( + `${peerAddress}/audit/catalog/acknowledged`, + ); + const acknowledgmentPayload = (await acknowledgmentResponse.json()) as { + acknowledgment: { + acknowledgment: { + acknowledgmentNote?: string; + items: Array<{ + item: string; + state: "acknowledged" | "unacknowledged"; + }>; + }; + attestation: { + attestation: { + attestationNote?: string; + }; + evidence: { + evidence: { + evidenceNote?: string; + }; + verification: { + verification: { + verificationNote?: string; + }; + }; + }; + }; + }; + }; + const acknowledgedPayload = (await acknowledgedResponse.json()) as { + acknowledged: { + items: Array<{ + acknowledgment: { + acknowledgmentNote?: string; + }; + attestation: { + evidence: { + verification: { + resolution: { + blocker: { + progress: { + checklist: { + assignment: { + review: { + visibility: { + catalogEntry: { + entry: { + id: string; + }; + }; + }; + }; + }; + }; + }; + }; + }; + }; + }; + }; + }>; + totalCount: number; + }; + }; + const inspectPayload = (await inspectResponse.json()) as { + acknowledgment: { + acknowledgment: { + acknowledgmentNote?: string; + items: Array<{ + item: string; + state: "acknowledged" | "unacknowledged"; + }>; + }; + }; + }; + const applyPayload = (await applyResponse.json()) as { + application: { + application: { + navigation: { + drilldowns: Array<{ + result: { + runId: string; + }; + }>; + totalSummaryCount: number; + }; + savedView: { + id: string; + }; + }; + }; + }; + const clearPayload = (await clearResponse.json()) as { + acknowledgment: { + acknowledgment: { + acknowledgmentNote?: string; + }; + }; + }; + const acknowledgedAfterClearPayload = + (await acknowledgedAfterClearResponse.json()) as { + acknowledged: { + totalCount: number; + }; + }; + + expect(saveResponse.status).toBe(201); + expect(publishResponse.status).toBe(201); + expect(shareResponse.status).toBe(200); + expect(reviewResponse.status).toBe(200); + expect(assignmentResponse.status).toBe(200); + expect(checklistResponse.status).toBe(200); + expect(progressResponse.status).toBe(200); + expect(blockerResponse.status).toBe(200); + expect(resolutionResponse.status).toBe(200); + expect(verificationResponse.status).toBe(200); + expect(evidenceResponse.status).toBe(200); + expect(attestationResponse.status).toBe(200); + expect(acknowledgmentResponse.status).toBe(200); + expect( + acknowledgmentPayload.acknowledgment.attestation.evidence.verification + .verification.verificationNote, + ).toBe("Backup verified the follow-up closure"); + expect( + acknowledgmentPayload.acknowledgment.attestation.evidence.evidence + .evidenceNote, + ).toBe("Backup collected stable follow-up references"); + expect( + acknowledgmentPayload.acknowledgment.attestation.attestation + .attestationNote, + ).toBe("Backup attested the stable follow-up evidence"); + expect( + acknowledgmentPayload.acknowledgment.acknowledgment.acknowledgmentNote, + ).toBe("Backup acknowledged the attested follow-up"); + expect(acknowledgmentPayload.acknowledgment.acknowledgment.items).toEqual( + [ + { + item: "Validate queued follow-up", + state: "acknowledged", + }, + { + item: "Close backup handoff", + state: "unacknowledged", + }, + ], + ); + expect(acknowledgedResponse.status).toBe(200); + expect(acknowledgedPayload.acknowledged.totalCount).toBe(1); + expect( + acknowledgedPayload.acknowledged.items[0]?.attestation.evidence + .verification.resolution.blocker.progress.checklist.assignment.review + .visibility.catalogEntry.entry.id, + ).toBe("catalog_entry_acknowledgment_api"); + expect(inspectResponse.status).toBe(200); + expect( + inspectPayload.acknowledgment.acknowledgment.acknowledgmentNote, + ).toBe("Backup acknowledged the attested follow-up"); + expect(applyResponse.status).toBe(200); + expect(applyPayload.application.application.savedView.id).toBe( + "saved_view_acknowledgment_api", + ); + expect( + applyPayload.application.application.navigation.drilldowns[0]?.result + .runId, + ).toBe(queuedRun.id); + expect(clearResponse.status).toBe(200); + expect( + clearPayload.acknowledgment.acknowledgment.acknowledgmentNote, + ).toBe("Backup acknowledged the attested follow-up"); + expect(acknowledgedAfterClearResponse.status).toBe(200); + expect(acknowledgedAfterClearPayload.acknowledged.totalCount).toBe(0); + } finally { + await ownerApp.close(); + await peerApp.close(); + } + }); }); diff --git a/apps/api/src/server.test.ts b/apps/api/src/server.test.ts index eb0f13e..5a1b448 100644 --- a/apps/api/src/server.test.ts +++ b/apps/api/src/server.test.ts @@ -25,7 +25,7 @@ describe("@runroot/api", () => { expect(response.json()).toEqual({ status: "ok", project: "Runroot", - phase: 26, + phase: 27, }); }); diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 15f6f4b..c61b7b0 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -279,6 +279,12 @@ export function buildServer(options: BuildServerOptions = {}) { })), ); + app.get("/audit/catalog/acknowledged", async (_request, reply) => + handleOperatorResponse(reply, async () => ({ + acknowledged: await operator.listAcknowledgedCatalogEntries(), + })), + ); + app.post("/audit/saved-views", async (request, reply) => handleOperatorResponse(reply, async () => { const body = request.body as { @@ -512,6 +518,22 @@ export function buildServer(options: BuildServerOptions = {}) { }), ); + app.get( + "/audit/catalog/:catalogEntryId/acknowledgment", + async (request, reply) => + handleOperatorResponse(reply, async () => { + const params = request.params as { + readonly catalogEntryId: string; + }; + + return { + acknowledgment: await operator.getCatalogChecklistItemAcknowledgment( + params.catalogEntryId, + ), + }; + }), + ); + app.get("/audit/catalog/:catalogEntryId", async (request, reply) => handleOperatorResponse(reply, async () => { const params = request.params as { @@ -812,6 +834,42 @@ export function buildServer(options: BuildServerOptions = {}) { }), ); + app.post( + "/audit/catalog/:catalogEntryId/acknowledgment", + async (request, reply) => + handleOperatorResponse(reply, async () => { + const params = request.params as { + readonly catalogEntryId: string; + }; + const body = request.body as { + readonly acknowledgmentNote?: string; + readonly items?: unknown; + }; + const items = readChecklistItemAcknowledgmentItems( + body?.items, + "items", + ); + + if (items.length === 0) { + throw new OperatorInputError( + "items must include at least one checklist item acknowledgment entry.", + ); + } + + return { + acknowledgment: await operator.acknowledgeCatalogEntry( + params.catalogEntryId, + { + ...(body?.acknowledgmentNote !== undefined + ? { acknowledgmentNote: body.acknowledgmentNote } + : {}), + items, + }, + ), + }; + }), + ); + app.post( "/audit/catalog/:catalogEntryId/review/clear", async (request, reply) => @@ -956,6 +1014,23 @@ export function buildServer(options: BuildServerOptions = {}) { }), ); + app.post( + "/audit/catalog/:catalogEntryId/acknowledgment/clear", + async (request, reply) => + handleOperatorResponse(reply, async () => { + const params = request.params as { + readonly catalogEntryId: string; + }; + + return { + acknowledgment: + await operator.clearCatalogChecklistItemAcknowledgment( + params.catalogEntryId, + ), + }; + }), + ); + app.get("/audit/saved-views/:savedViewId/apply", async (request, reply) => handleOperatorResponse(reply, async () => { const params = request.params as { @@ -1496,3 +1571,30 @@ function readChecklistItemAttestationItems( return value; } + +function readChecklistItemAcknowledgmentItems( + value: unknown, + fieldName: string, +): readonly { + readonly item: string; + readonly state: "acknowledged" | "unacknowledged"; +}[] { + if ( + !Array.isArray(value) || + !value.every( + (entry) => + typeof entry === "object" && + entry !== null && + "item" in entry && + typeof entry.item === "string" && + "state" in entry && + (entry.state === "acknowledged" || entry.state === "unacknowledged"), + ) + ) { + throw new OperatorInputError( + `${fieldName} must be an array of { item, state } objects with state acknowledged|unacknowledged.`, + ); + } + + return value; +} diff --git a/apps/web/src/app/runs/catalog/route.ts b/apps/web/src/app/runs/catalog/route.ts index d7f2483..c1c276c 100644 --- a/apps/web/src/app/runs/catalog/route.ts +++ b/apps/web/src/app/runs/catalog/route.ts @@ -504,6 +504,53 @@ export async function POST(request: Request) { return Response.redirect(redirectUrl, 303); } + if (intent === "acknowledge") { + const catalogEntryId = readTrimmedFormValue(formData, "catalogEntryId"); + const acknowledgmentItems = readChecklistItemAcknowledgmentItems( + formData.get("acknowledgmentItems"), + ); + const acknowledgmentNoteValue = formData.get("acknowledgmentNote"); + + if (!catalogEntryId) { + appendFlashMessage( + redirectUrl, + "error", + "A catalog entry id is required to update checklist item acknowledgment metadata.", + ); + + return Response.redirect(redirectUrl, 303); + } + + if (acknowledgmentItems.length === 0) { + appendFlashMessage( + redirectUrl, + "error", + "At least one checklist item acknowledgment entry is required.", + ); + + return Response.redirect(redirectUrl, 303); + } + + const acknowledgment = + await createRunrootApiClient().setAuditCatalogChecklistItemAcknowledgment( + catalogEntryId, + { + ...(typeof acknowledgmentNoteValue === "string" + ? { acknowledgmentNote: acknowledgmentNoteValue } + : {}), + items: acknowledgmentItems, + }, + ); + + appendFlashMessage( + redirectUrl, + "notice", + `Checklist item acknowledgments for ${acknowledgment.attestation.evidence.verification.resolution.blocker.progress.checklist.assignment.review.visibility.catalogEntry.entry.name} updated.`, + ); + + return Response.redirect(redirectUrl, 303); + } + if (intent === "clear-review") { const catalogEntryId = readTrimmedFormValue(formData, "catalogEntryId"); @@ -693,6 +740,33 @@ export async function POST(request: Request) { return Response.redirect(redirectUrl, 303); } + if (intent === "clear-acknowledgment") { + const catalogEntryId = readTrimmedFormValue(formData, "catalogEntryId"); + + if (!catalogEntryId) { + appendFlashMessage( + redirectUrl, + "error", + "A catalog entry id is required to clear checklist item acknowledgment metadata.", + ); + + return Response.redirect(redirectUrl, 303); + } + + const acknowledgment = + await createRunrootApiClient().clearAuditCatalogChecklistItemAcknowledgment( + catalogEntryId, + ); + + appendFlashMessage( + redirectUrl, + "notice", + `Checklist item acknowledgments for ${acknowledgment.attestation.evidence.verification.resolution.blocker.progress.checklist.assignment.review.visibility.catalogEntry.entry.name} cleared.`, + ); + + return Response.redirect(redirectUrl, 303); + } + if (intent === "clear-progress") { const catalogEntryId = readTrimmedFormValue(formData, "catalogEntryId"); @@ -1096,3 +1170,49 @@ function readChecklistItemAttestationItems( }; }); } + +function readChecklistItemAcknowledgmentItems( + value: FormDataEntryValue | null, +): readonly { + readonly item: string; + readonly state: "acknowledged" | "unacknowledged"; +}[] { + if (typeof value !== "string") { + return []; + } + + return value + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => { + const separatorIndex = line.indexOf(":"); + + if (separatorIndex < 0) { + return { + item: line, + state: "unacknowledged" as const, + }; + } + + const rawState = line.slice(0, separatorIndex).trim(); + const item = line.slice(separatorIndex + 1).trim(); + + if (item.length === 0) { + throw new Error( + "Checklist item acknowledgment lines require a non-empty item.", + ); + } + + if (rawState !== "acknowledged" && rawState !== "unacknowledged") { + throw new Error( + `Checklist item acknowledgment state must be acknowledged or unacknowledged for "${item}".`, + ); + } + + return { + item, + state: rawState, + }; + }); +} diff --git a/apps/web/src/app/runs/page.tsx b/apps/web/src/app/runs/page.tsx index 5a9be54..6c4efeb 100644 --- a/apps/web/src/app/runs/page.tsx +++ b/apps/web/src/app/runs/page.tsx @@ -3,6 +3,7 @@ import { AuditViewCatalogsView, CatalogReviewAssignmentsView, CatalogReviewSignalsView, + ChecklistItemAcknowledgmentsView, ChecklistItemAttestationsView, ChecklistItemBlockersView, ChecklistItemEvidencesView, @@ -23,6 +24,7 @@ import { } from "../../lib/navigation"; import { type ApiAuditCatalogAssignmentChecklistView, + type ApiAuditCatalogChecklistItemAcknowledgmentView, type ApiAuditCatalogChecklistItemAttestationView, type ApiAuditCatalogChecklistItemBlockerView, type ApiAuditCatalogChecklistItemEvidenceView, @@ -62,6 +64,7 @@ export default async function RunsPage({ const drilldownFilters = readAuditDrilldownFilters(resolvedSearchParams); const [ runs, + acknowledgedEntries, blockedEntries, evidencedEntries, attestedEntries, @@ -78,6 +81,7 @@ export default async function RunsPage({ catalogChecklistItemBlocker, catalogChecklistItemResolution, catalogChecklistItemEvidence, + catalogChecklistItemAcknowledgment, catalogChecklistItemAttestation, catalogChecklistItemVerification, catalogChecklistItemProgress, @@ -86,6 +90,7 @@ export default async function RunsPage({ catalogReviewAssignment, ] = await Promise.all([ api.listRuns(), + api.listAcknowledgedAuditCatalogEntries(), api.listBlockedAuditCatalogEntries(), api.listEvidencedAuditCatalogEntries(), api.listAttestedAuditCatalogEntries(), @@ -123,6 +128,11 @@ export default async function RunsPage({ .getAuditCatalogChecklistItemEvidence(catalogEntryId) .catch(() => undefined) : Promise.resolve(undefined), + catalogEntryId + ? api + .getAuditCatalogChecklistItemAcknowledgment(catalogEntryId) + .catch(() => undefined) + : Promise.resolve(undefined), catalogEntryId ? api .getAuditCatalogChecklistItemAttestation(catalogEntryId) @@ -166,6 +176,9 @@ export default async function RunsPage({ let activeCatalogChecklistItemEvidence: | ApiAuditCatalogChecklistItemEvidenceView | undefined; + let activeCatalogChecklistItemAcknowledgment: + | ApiAuditCatalogChecklistItemAcknowledgmentView + | undefined; let activeCatalogChecklistItemAttestation: | ApiAuditCatalogChecklistItemAttestationView | undefined; @@ -187,6 +200,8 @@ export default async function RunsPage({ activeCatalogChecklistItemBlocker = catalogChecklistItemBlocker; activeCatalogChecklistItemResolution = catalogChecklistItemResolution; activeCatalogChecklistItemEvidence = catalogChecklistItemEvidence; + activeCatalogChecklistItemAcknowledgment = + catalogChecklistItemAcknowledgment; activeCatalogChecklistItemAttestation = catalogChecklistItemAttestation; activeCatalogChecklistItemVerification = catalogChecklistItemVerification; activeCatalogChecklistItemProgress = catalogChecklistItemProgress; @@ -226,6 +241,12 @@ export default async function RunsPage({ ? { activeCatalogChecklistItemEvidence } : {})} /> + { await peerApp.close(); } }); + + it("renders, records, clears, and reapplies checklist item acknowledgments through the existing API surface", async () => { + const workspaceRoot = await mkdtemp( + join(tmpdir(), "runroot-web-checklist-acknowledgment-"), + ); + const sqlitePath = join(workspaceRoot, "runroot.sqlite"); + const inlineOperator = createRunrootOperatorService({ + executionMode: "inline", + operatorId: "ops_oncall", + operatorScopeId: "ops", + persistenceDriver: "sqlite", + sqlitePath, + }); + const queuedOperator = createRunrootOperatorService({ + executionMode: "queued", + persistenceDriver: "sqlite", + sqlitePath, + }); + const workerService = ( + await import("@runroot/sdk") + ).createRunrootWorkerService({ + persistenceDriver: "sqlite", + sqlitePath, + workerId: "worker_web_acknowledgment", + }); + const ownerApp = buildServer({ + operator: createRunrootOperatorService({ + catalogEntryIdGenerator: () => "catalog_entry_acknowledgment_web", + operatorId: "ops_oncall", + operatorScopeId: "ops", + persistenceDriver: "sqlite", + savedViewIdGenerator: () => "saved_view_acknowledgment_web", + sqlitePath, + }), + }); + const peerApp = buildServer({ + operator: createRunrootOperatorService({ + operatorId: "ops_backup", + operatorScopeId: "ops", + persistenceDriver: "sqlite", + sqlitePath, + }), + }); + + try { + const inlineRun = await inlineOperator.startRun({ + input: { + approvalRequired: false, + commandAlias: "print-ready", + runbookId: "node-health-check", + }, + templateId: "shell-runbook-flow", + }); + const queuedRun = await queuedOperator.startRun({ + input: { + approvalRequired: false, + commandAlias: "print-ready", + runbookId: "node-health-check", + }, + templateId: "shell-runbook-flow", + }); + + await workerService.processNextJob(); + + const ownerAddress = await ownerApp.listen({ + host: "127.0.0.1", + port: 0, + }); + const peerAddress = await peerApp.listen({ + host: "127.0.0.1", + port: 0, + }); + + process.env.RUNROOT_API_BASE_URL = ownerAddress; + + const saveForm = new FormData(); + saveForm.set("name", "Queued acknowledgment preset"); + saveForm.set("description", "Saved queued worker acknowledgment preset"); + saveForm.set("summaryExecutionMode", "queued"); + saveForm.set("drilldownWorkerId", "worker_web_acknowledgment"); + saveForm.set("auditViewRunId", queuedRun.id); + saveForm.set("drilldownRunId", queuedRun.id); + saveForm.set("returnTo", "/runs"); + + const saveResponse = await saveSavedView( + new Request("http://localhost/runs/saved-views", { + body: saveForm, + method: "POST", + }), + ); + + expect(saveResponse.status).toBe(303); + + const publishForm = new FormData(); + publishForm.set("intent", "publish"); + publishForm.set("savedViewId", "saved_view_acknowledgment_web"); + + const publishResponse = await mutateCatalog( + new Request("http://localhost/runs/catalog", { + body: publishForm, + method: "POST", + }), + ); + + expect(publishResponse.status).toBe(303); + + const shareForm = new FormData(); + shareForm.set("catalogEntryId", "catalog_entry_acknowledgment_web"); + shareForm.set("intent", "share"); + + const shareResponse = await mutateCatalog( + new Request("http://localhost/runs/catalog", { + body: shareForm, + method: "POST", + }), + ); + + expect(shareResponse.status).toBe(303); + + const reviewForm = new FormData(); + reviewForm.set("catalogEntryId", "catalog_entry_acknowledgment_web"); + reviewForm.set("intent", "review"); + reviewForm.set( + "note", + `Acknowledgment ready after inline ${inlineRun.id}`, + ); + reviewForm.set("reviewState", "recommended"); + reviewForm.set( + "returnTo", + "/runs?catalogEntryId=catalog_entry_acknowledgment_web", + ); + + const reviewResponse = await mutateCatalog( + new Request("http://localhost/runs/catalog", { + body: reviewForm, + method: "POST", + }), + ); + + expect(reviewResponse.status).toBe(303); + + const assignForm = new FormData(); + assignForm.set("catalogEntryId", "catalog_entry_acknowledgment_web"); + assignForm.set("intent", "assign"); + assignForm.set("assigneeId", "ops_backup"); + assignForm.set( + "handoffNote", + `Queued worker ${queuedRun.id} handed to backup`, + ); + assignForm.set( + "returnTo", + "/runs?catalogEntryId=catalog_entry_acknowledgment_web", + ); + + const assignResponse = await mutateCatalog( + new Request("http://localhost/runs/catalog", { + body: assignForm, + method: "POST", + }), + ); + + expect(assignResponse.status).toBe(303); + + const checklistForm = new FormData(); + checklistForm.set("catalogEntryId", "catalog_entry_acknowledgment_web"); + checklistForm.set("intent", "checklist"); + checklistForm.set("checklistState", "pending"); + checklistForm.set( + "checklistItems", + "Validate queued follow-up\nClose backup handoff", + ); + checklistForm.set( + "returnTo", + "/runs?catalogEntryId=catalog_entry_acknowledgment_web", + ); + + const checklistResponse = await mutateCatalog( + new Request("http://localhost/runs/catalog", { + body: checklistForm, + method: "POST", + }), + ); + + expect(checklistResponse.status).toBe(303); + + const progressForm = new FormData(); + progressForm.set("catalogEntryId", "catalog_entry_acknowledgment_web"); + progressForm.set("intent", "progress"); + progressForm.set( + "progressItems", + "completed: Validate queued follow-up\npending: Close backup handoff", + ); + progressForm.set("completionNote", "Queued follow-up is almost complete"); + progressForm.set( + "returnTo", + "/runs?catalogEntryId=catalog_entry_acknowledgment_web", + ); + + const progressResponse = await mutateCatalog( + new Request("http://localhost/runs/catalog", { + body: progressForm, + method: "POST", + }), + ); + + expect(progressResponse.status).toBe(303); + + const blockerForm = new FormData(); + blockerForm.set("catalogEntryId", "catalog_entry_acknowledgment_web"); + blockerForm.set("intent", "block"); + blockerForm.set( + "blockerItems", + "cleared: Validate queued follow-up\nblocked: Close backup handoff", + ); + blockerForm.set("blockerNote", "Waiting for the overnight handoff"); + blockerForm.set( + "returnTo", + "/runs?catalogEntryId=catalog_entry_acknowledgment_web", + ); + + const blockerResponse = await mutateCatalog( + new Request("http://localhost/runs/catalog", { + body: blockerForm, + method: "POST", + }), + ); + + expect(blockerResponse.status).toBe(303); + + const resolutionForm = new FormData(); + resolutionForm.set("catalogEntryId", "catalog_entry_acknowledgment_web"); + resolutionForm.set("intent", "resolve"); + resolutionForm.set( + "resolutionItems", + "resolved: Validate queued follow-up\nunresolved: Close backup handoff", + ); + resolutionForm.set( + "resolutionNote", + "Backup confirmed the follow-up closure", + ); + resolutionForm.set( + "returnTo", + "/runs?catalogEntryId=catalog_entry_acknowledgment_web", + ); + + const resolutionResponse = await mutateCatalog( + new Request("http://localhost/runs/catalog", { + body: resolutionForm, + method: "POST", + }), + ); + + expect(resolutionResponse.status).toBe(303); + + const verificationForm = new FormData(); + verificationForm.set( + "catalogEntryId", + "catalog_entry_acknowledgment_web", + ); + verificationForm.set("intent", "verify"); + verificationForm.set( + "verificationItems", + "verified: Validate queued follow-up\nunverified: Close backup handoff", + ); + verificationForm.set( + "verificationNote", + "Backup verified the follow-up closure", + ); + verificationForm.set( + "returnTo", + "/runs?catalogEntryId=catalog_entry_acknowledgment_web", + ); + + const verificationResponse = await mutateCatalog( + new Request("http://localhost/runs/catalog", { + body: verificationForm, + method: "POST", + }), + ); + + expect(verificationResponse.status).toBe(303); + + const evidenceForm = new FormData(); + evidenceForm.set("catalogEntryId", "catalog_entry_acknowledgment_web"); + evidenceForm.set("intent", "record-evidence"); + evidenceForm.set( + "evidenceItems", + "Validate queued follow-up: run://queued-follow-up | note://backup-closeout\nClose backup handoff: doc://backup-handoff", + ); + evidenceForm.set( + "evidenceNote", + "Backup collected stable follow-up references", + ); + evidenceForm.set( + "returnTo", + "/runs?catalogEntryId=catalog_entry_acknowledgment_web", + ); + + const evidenceResponse = await mutateCatalog( + new Request("http://localhost/runs/catalog", { + body: evidenceForm, + method: "POST", + }), + ); + + expect(evidenceResponse.status).toBe(303); + + const attestationForm = new FormData(); + attestationForm.set("catalogEntryId", "catalog_entry_acknowledgment_web"); + attestationForm.set("intent", "attest"); + attestationForm.set( + "attestationItems", + "attested: Validate queued follow-up\nunattested: Close backup handoff", + ); + attestationForm.set( + "attestationNote", + "Backup attested the stable follow-up evidence", + ); + attestationForm.set( + "returnTo", + "/runs?catalogEntryId=catalog_entry_acknowledgment_web", + ); + + const attestationResponse = await mutateCatalog( + new Request("http://localhost/runs/catalog", { + body: attestationForm, + method: "POST", + }), + ); + + expect(attestationResponse.status).toBe(303); + + const acknowledgmentForm = new FormData(); + acknowledgmentForm.set( + "catalogEntryId", + "catalog_entry_acknowledgment_web", + ); + acknowledgmentForm.set("intent", "acknowledge"); + acknowledgmentForm.set( + "acknowledgmentItems", + "acknowledged: Validate queued follow-up\nunacknowledged: Close backup handoff", + ); + acknowledgmentForm.set( + "acknowledgmentNote", + "Backup acknowledged the attested follow-up", + ); + acknowledgmentForm.set( + "returnTo", + "/runs?catalogEntryId=catalog_entry_acknowledgment_web", + ); + + const acknowledgmentResponse = await mutateCatalog( + new Request("http://localhost/runs/catalog", { + body: acknowledgmentForm, + method: "POST", + }), + ); + + expect(acknowledgmentResponse.status).toBe(303); + + process.env.RUNROOT_API_BASE_URL = peerAddress; + + const acknowledgedMarkup = renderToStaticMarkup( + await RunsPage({ + searchParams: Promise.resolve({ + catalogEntryId: "catalog_entry_acknowledgment_web", + }), + }), + ); + + expect(acknowledgedMarkup).toContain("Checklist item acknowledgments"); + expect(acknowledgedMarkup).toContain("Queued acknowledgment preset"); + expect(acknowledgedMarkup).toContain("1/2 acknowledged"); + expect(acknowledgedMarkup).toContain("Validate queued follow-up"); + expect(acknowledgedMarkup).toContain("Close backup handoff"); + expect(acknowledgedMarkup).toContain( + "Backup acknowledged the attested follow-up", + ); + expect(acknowledgedMarkup).toContain("Apply acknowledged preset"); + expect(acknowledgedMarkup).toContain( + `Queued worker ${queuedRun.id} handed to backup`, + ); + expect(acknowledgedMarkup).toContain("Active acknowledgments selected"); + + process.env.RUNROOT_API_BASE_URL = ownerAddress; + + const clearForm = new FormData(); + clearForm.set("catalogEntryId", "catalog_entry_acknowledgment_web"); + clearForm.set("intent", "clear-acknowledgment"); + clearForm.set( + "returnTo", + "/runs?catalogEntryId=catalog_entry_acknowledgment_web", + ); + + const clearResponse = await mutateCatalog( + new Request("http://localhost/runs/catalog", { + body: clearForm, + method: "POST", + }), + ); + + expect(clearResponse.status).toBe(303); + + process.env.RUNROOT_API_BASE_URL = peerAddress; + + const clearedMarkup = renderToStaticMarkup( + await RunsPage({ + searchParams: Promise.resolve({ + catalogEntryId: "catalog_entry_acknowledgment_web", + }), + }), + ); + + expect(clearedMarkup).toContain("Checklist item acknowledgments"); + expect(clearedMarkup).toContain("No checklist item acknowledgments yet"); + expect(clearedMarkup).not.toContain( + "Backup acknowledged the attested follow-up", + ); + } finally { + await ownerApp.close(); + await peerApp.close(); + } + }); }); diff --git a/apps/web/src/components/console.tsx b/apps/web/src/components/console.tsx index 844dc20..6db2d9d 100644 --- a/apps/web/src/components/console.tsx +++ b/apps/web/src/components/console.tsx @@ -4,6 +4,9 @@ import type { ApiApproval, ApiAuditCatalogAssignmentChecklistCollection, ApiAuditCatalogAssignmentChecklistView, + ApiAuditCatalogChecklistItemAcknowledgmentCollection, + ApiAuditCatalogChecklistItemAcknowledgmentItem, + ApiAuditCatalogChecklistItemAcknowledgmentView, ApiAuditCatalogChecklistItemAttestationCollection, ApiAuditCatalogChecklistItemAttestationItem, ApiAuditCatalogChecklistItemAttestationView, @@ -1210,6 +1213,171 @@ export function ChecklistItemAttestationsView({ ); } +export function ChecklistItemAcknowledgmentsView({ + activeCatalogChecklistItemAcknowledgment, + acknowledgedEntries, +}: Readonly<{ + activeCatalogChecklistItemAcknowledgment?: ApiAuditCatalogChecklistItemAcknowledgmentView; + acknowledgedEntries: ApiAuditCatalogChecklistItemAcknowledgmentCollection; +}>) { + return ( +
+
+
+
+ Phase 27 / Checklist Item Acknowledgments +
+

Checklist item acknowledgments

+

+ Track thin per-item acknowledgments and a single acknowledgment note + on attested presets without turning the console into an approval + product, workflow engine, or collaboration surface. +

+
+
+ {acknowledgedEntries.totalCount} acknowledged preset(s) +
+
+ + {activeCatalogChecklistItemAcknowledgment ? ( +
+ Active acknowledgments:{" "} + + { + activeCatalogChecklistItemAcknowledgment.attestation.evidence + .verification.resolution.blocker.progress.checklist.assignment + .review.visibility.catalogEntry.entry.name + } + + {" · "} + {formatAcknowledgmentSummary( + activeCatalogChecklistItemAcknowledgment.acknowledgment.items, + )} + {activeCatalogChecklistItemAcknowledgment.acknowledgment + .acknowledgmentNote + ? ` · ${activeCatalogChecklistItemAcknowledgment.acknowledgment.acknowledgmentNote}` + : ""} +
+ ) : null} + + {acknowledgedEntries.items.length === 0 ? ( +

+ No checklist item acknowledgments yet. Record attestations first, then + save thin acknowledgment metadata through the shared operator seam. +

+ ) : ( +
    + {acknowledgedEntries.items.map((acknowledgmentView) => ( +
  1. +
    +
    + + { + acknowledgmentView.attestation.evidence.verification + .resolution.blocker.progress.checklist.assignment.review + .visibility.catalogEntry.entry.name + } + +
    + {formatAcknowledgmentSummary( + acknowledgmentView.acknowledgment.items, + )} + {" · "} + {formatAttestationSummary( + acknowledgmentView.attestation.attestation.items, + )} + {" · "} + {formatEvidenceSummary( + acknowledgmentView.attestation.evidence.evidence.items, + )} +
    +
    + + {formatTimestamp(acknowledgmentView.acknowledgment.updatedAt)} + +
    +
      + {acknowledgmentView.acknowledgment.items.map((item) => ( +
    • + {item.state}: {item.item} +
    • + ))} +
    + {acknowledgmentView.acknowledgment.acknowledgmentNote ? ( +

    + {acknowledgmentView.acknowledgment.acknowledgmentNote} +

    + ) : null} +
    + operator {acknowledgmentView.acknowledgment.operatorId} · scope{" "} + {acknowledgmentView.acknowledgment.scopeId} +
    +
    + + Apply acknowledged preset + +
    + + + + +
    +
    + {activeCatalogChecklistItemAcknowledgment?.acknowledgment + .catalogEntryId === + acknowledgmentView.acknowledgment.catalogEntryId ? ( +
    + Active acknowledgments selected +
    + ) : null} +
  2. + ))} +
+ )} +
+ ); +} + export function CatalogReviewAssignmentsView({ activeCatalogReviewAssignment, assignedEntries, @@ -1445,12 +1613,14 @@ export function CatalogReviewSignalsView({ export function AuditViewCatalogsView({ activeCatalogEntry, activeCatalogChecklistItemBlocker, + activeCatalogChecklistItemAcknowledgment, activeCatalogChecklistItemAttestation, activeCatalogChecklistItemEvidence, activeCatalogChecklistItemResolution, activeCatalogChecklistItemVerification, activeCatalogChecklistItemProgress, assignedEntries, + acknowledgedEntries, attestedEntries, blockedEntries, catalogEntries, @@ -1463,12 +1633,14 @@ export function AuditViewCatalogsView({ }: Readonly<{ activeCatalogEntry?: ApiAuditCatalogVisibilityView; activeCatalogChecklistItemBlocker?: ApiAuditCatalogChecklistItemBlockerView; + activeCatalogChecklistItemAcknowledgment?: ApiAuditCatalogChecklistItemAcknowledgmentView; activeCatalogChecklistItemAttestation?: ApiAuditCatalogChecklistItemAttestationView; activeCatalogChecklistItemEvidence?: ApiAuditCatalogChecklistItemEvidenceView; activeCatalogChecklistItemResolution?: ApiAuditCatalogChecklistItemResolutionView; activeCatalogChecklistItemVerification?: ApiAuditCatalogChecklistItemVerificationView; activeCatalogChecklistItemProgress?: ApiAuditCatalogChecklistItemProgressView; assignedEntries: ApiAuditCatalogReviewAssignmentCollection; + acknowledgedEntries: ApiAuditCatalogChecklistItemAcknowledgmentCollection; attestedEntries: ApiAuditCatalogChecklistItemAttestationCollection; blockedEntries: ApiAuditCatalogChecklistItemBlockerCollection; catalogEntries: ApiAuditCatalogVisibilityCollection; @@ -1557,6 +1729,16 @@ export function AuditViewCatalogsView({ ] as const, ), ); + const acknowledgmentsByCatalogEntryId = new Map( + acknowledgedEntries.items.map( + (item) => + [ + item.attestation.evidence.verification.resolution.blocker.progress + .checklist.assignment.review.visibility.catalogEntry.entry.id, + item, + ] as const, + ), + ); return (
@@ -1634,6 +1816,15 @@ export function AuditViewCatalogsView({ : attestationsByCatalogEntryId.get( catalogEntry.catalogEntry.entry.id, ); + const acknowledgment = + activeCatalogChecklistItemAcknowledgment?.attestation.evidence + .verification.resolution.blocker.progress.checklist + .assignment.review.visibility.catalogEntry.entry.id === + catalogEntry.catalogEntry.entry.id + ? activeCatalogChecklistItemAcknowledgment + : acknowledgmentsByCatalogEntryId.get( + catalogEntry.catalogEntry.entry.id, + ); const reviewSignal = reviewSignalsByCatalogEntryId.get( catalogEntry.catalogEntry.entry.id, ); @@ -1783,6 +1974,20 @@ export function AuditViewCatalogsView({ {attestation.attestation.attestationNote}

) : null} + {acknowledgment?.acknowledgment.items.length ? ( +

+ acknowledgments:{" "} + {acknowledgment.acknowledgment.items + .map((item) => `${item.state}: ${item.item}`) + .join(", ")} +

+ ) : null} + {acknowledgment?.acknowledgment.acknowledgmentNote ? ( +

+ acknowledgment note:{" "} + {acknowledgment.acknowledgment.acknowledgmentNote} +

+ ) : null}
saved view {catalogEntry.catalogEntry.savedView.id} {catalogEntry.catalogEntry.savedView.refs.auditViewRunId @@ -1822,6 +2027,9 @@ export function AuditViewCatalogsView({ {attestation ? ` · attestation owner ${attestation.attestation.operatorId}` : ""} + {acknowledgment + ? ` · acknowledgment owner ${acknowledgment.acknowledgment.operatorId}` + : ""}
summary filters{" "} @@ -2313,6 +2521,66 @@ export function AuditViewCatalogsView({
) : null} + {attestation ? ( +
+ + + +
+