From d24e8fdbe3f837d1fe756ac2b31d8cb81ae44c9f Mon Sep 17 00:00:00 2001 From: Abe Wheeler Date: Mon, 26 Oct 2026 11:01:50 -0500 Subject: [PATCH] Include dated source work in Work Queue summaries --- package-lock.json | 8 +- package.json | 2 +- packages/create-filegrc/package.json | 2 +- packages/create-filegrc/src/index.js | 4 +- packages/create-filegrc/template/AGENTS.md | 2 +- packages/create-filegrc/template/package.json | 2 +- packages/filegrc/README.md | 18 ++ packages/filegrc/package.json | 2 +- packages/filegrc/src/cli.js | 8 +- packages/filegrc/src/index.js | 1 + packages/filegrc/src/program-readiness.js | 16 +- packages/filegrc/src/server.js | 7 +- packages/filegrc/src/state.js | 18 +- packages/filegrc/src/web.js | 37 ++- packages/filegrc/src/work-queue.js | 232 ++++++++++++++++++ packages/filegrc/src/workflow.js | 181 +------------- packages/filegrc/test/render.test.js | 8 +- packages/filegrc/test/work-queue.test.js | 76 ++++++ packages/filegrc/test/workflow.test.js | 39 ++- 19 files changed, 444 insertions(+), 219 deletions(-) create mode 100644 packages/filegrc/src/work-queue.js create mode 100644 packages/filegrc/test/work-queue.test.js diff --git a/package-lock.json b/package-lock.json index 5f6a4d1..8179554 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "filegrc-monorepo", - "version": "0.21.2", + "version": "0.21.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "filegrc-monorepo", - "version": "0.21.2", + "version": "0.21.3", "workspaces": [ "packages/filegrc", "packages/create-filegrc" @@ -24,7 +24,7 @@ "link": true }, "packages/create-filegrc": { - "version": "0.21.2", + "version": "0.21.3", "license": "MIT", "bin": { "create-filegrc": "bin/create-filegrc.js" @@ -34,7 +34,7 @@ } }, "packages/filegrc": { - "version": "0.21.2", + "version": "0.21.3", "license": "MIT", "bin": { "filegrc": "bin/filegrc.js" diff --git a/package.json b/package.json index ed3fd52..10416fd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "filegrc-monorepo", - "version": "0.21.2", + "version": "0.21.3", "private": true, "workspaces": [ "packages/filegrc", diff --git a/packages/create-filegrc/package.json b/packages/create-filegrc/package.json index b10f646..4dc04f4 100644 --- a/packages/create-filegrc/package.json +++ b/packages/create-filegrc/package.json @@ -1,6 +1,6 @@ { "name": "create-filegrc", - "version": "0.21.2", + "version": "0.21.3", "description": "Create a filegrc workspace for a SOC 2 program", "license": "MIT", "repository": { diff --git a/packages/create-filegrc/src/index.js b/packages/create-filegrc/src/index.js index 11c776c..e57525f 100644 --- a/packages/create-filegrc/src/index.js +++ b/packages/create-filegrc/src/index.js @@ -524,13 +524,13 @@ async function runCombinedSetup(target, input) { async function writeMinimalLockfile(target, name, versionRange) { const lock = { name, - version: "0.21.2", + version: "0.21.3", lockfileVersion: 3, requires: true, packages: { "": { name, - version: "0.21.2", + version: "0.21.3", dependencies: { filegrc: versionRange } } } diff --git a/packages/create-filegrc/template/AGENTS.md b/packages/create-filegrc/template/AGENTS.md index dbdc181..9f1e6da 100644 --- a/packages/create-filegrc/template/AGENTS.md +++ b/packages/create-filegrc/template/AGENTS.md @@ -186,7 +186,7 @@ Program records, owners, Person email addresses, and dates remain authoritative ## Work Queue and Policy Events -Run `npx filegrc obligations --json` to see scheduled work, event tasks, owners, deadlines, and requested proof. In Step 4, record the work shown there. Use `npx filegrc guide obligation --json` and `data/AGENTS.md` for the completion and event-trigger commands. The resulting dated operating record or completed Action Item is the output. +Run `npx filegrc obligations --json` to see scheduled work, event tasks, owners, deadlines, and requested proof. Its `workQueue` includes dated source-record work such as risk reviews, treatments and Exception expirations; top-level `items` and `counts` retain the obligation-only view. In Step 4, record the work shown there. Use `npx filegrc guide obligation --json` and `data/AGENTS.md` for the completion and event-trigger commands. The resulting dated operating record or completed Action Item is the output. A periodic review or test may inspect a point in time without claiming continuous coverage. Read [Obligation population timing](https://github.com/Alignbase/filegrc/blob/main/docs/obligation-population-timing.md) before choosing `completionCoverage` and selector cutoff. A `review-date` snapshot requires the actual `--population-as-of` observation date and committed scope. A `whole-period` duty requires explicit proof range coverage after the recurrence period ends. Preserve existing end-cutoff campaigns and unfinished members. Waiting means current member work is recorded but the population is still open; final reconciliation remains required. Use `npx filegrc correct-obligation-rule RULE_ID --scaffold --json` for a reviewed successor correcting an inappropriate open snapshot rule, preserving the original window, proofs and approvals. Upgrade local and hosted readers to the 0.21 line before adopting these optional model v13 fields. diff --git a/packages/create-filegrc/template/package.json b/packages/create-filegrc/template/package.json index 523d131..3ff6007 100644 --- a/packages/create-filegrc/template/package.json +++ b/packages/create-filegrc/template/package.json @@ -1,6 +1,6 @@ { "name": "{{project_name}}", - "version": "0.21.2", + "version": "0.21.3", "private": true, "description": "filegrc workspace for a SOC 2 program", "type": "module", diff --git a/packages/filegrc/README.md b/packages/filegrc/README.md index bd01e51..90bdff0 100644 --- a/packages/filegrc/README.md +++ b/packages/filegrc/README.md @@ -176,3 +176,21 @@ A preservation rule covers the declared proof, such as control metadata, approva results or scoped exports. It does not extend raw log retention or restore deleted customer content. Keep those source retention decisions separate and use normal independent review when a new category, purpose or period is needed. + +## Complete Work Queue + +`filegrc obligations --json` preserves the obligation-only `items` and `counts` +and adds `workQueue` for the complete selected Program queue. This combines +recurring occurrences and assigned tasks with source-record deadlines, such as +risk reviews, treatment targets and Exception expirations. The browser, app-state +API, `GET /api/obligations`, program-operation summaries and +`filegrc workflow --json` use this same calculation. Headless callers can also +use `planWorkQueue(resources, obligationPlan, options)` from `filegrc`. + +The queue includes source deadlines through the obligation plan's `through` +date, including older overdue work. `scheduled` source work appears as Upcoming; +proposals remain proposals and canceled/superseded records stay out of the board. +`--complete` also includes completed sources. Program selection, source deadlines, +record lifecycle and existing owner/contact resolution remain authoritative. +The full workflow's `workItems` retains its existing complete deadline listing. +No deadline, completion, approval or assignee is inferred from displaying a card. diff --git a/packages/filegrc/package.json b/packages/filegrc/package.json index f58115d..3fdc338 100644 --- a/packages/filegrc/package.json +++ b/packages/filegrc/package.json @@ -1,6 +1,6 @@ { "name": "filegrc", - "version": "0.21.2", + "version": "0.21.3", "description": "Zero-dependency Git-native GRC engine", "license": "MIT", "repository": { diff --git a/packages/filegrc/src/cli.js b/packages/filegrc/src/cli.js index f140d81..fa36d07 100644 --- a/packages/filegrc/src/cli.js +++ b/packages/filegrc/src/cli.js @@ -1,3 +1,4 @@ +import { planWorkQueue } from "./work-queue.js"; import { scaffoldObligationRuleCorrection, previewObligationRuleCorrection, correctObligationRule } from "./obligation-rule-correction.js"; import { scaffoldRetentionApplication, saveRetentionApplication } from "./retention-applications.js"; import { execFile } from "node:child_process"; @@ -456,11 +457,13 @@ export async function runCli(argv = process.argv.slice(2)) { includeComplete: Boolean(flags.complete), model: loaded.model }); + result.workQueue = planWorkQueue(loaded.resources, result, { program: resolveProgram(loaded, flags.program), model: loaded.model, includeComplete: Boolean(flags.complete) }); result.recommendations = [hostedAutomationForConnection(OPERATING_HOSTED_AUTOMATION, loaded.hostedAutomationConnection)]; if (flags.json) console.log(JSON.stringify(result, null, 2)); else { printHostedAutomation(result.recommendations[0]); - console.log(`${result.counts.overdue} overdue, ${result.counts.blocked} blocked, ${result.counts.due} due, ${result.counts.waiting} waiting, ${result.counts.upcoming} upcoming, ${result.counts.proposed} starter proposals`); + console.log(`Work Queue: ${result.workQueue.counts.overdue} overdue, ${result.workQueue.counts.blocked} blocked, ${result.workQueue.counts.due} due, ${result.workQueue.counts.waiting} waiting, ${result.workQueue.counts.upcoming} upcoming`); + console.log(`Obligations and assigned tasks: ${result.counts.overdue} overdue, ${result.counts.blocked} blocked, ${result.counts.due} due, ${result.counts.waiting} waiting, ${result.counts.upcoming} upcoming, ${result.counts.proposed} starter proposals`); for (const item of result.items) { const deadline = item.dueWindowEndAt || item.dueWindowEnd; if (!deadline) throw new Error(`Planned work "${item.title}" is missing a deadline.`); @@ -481,6 +484,9 @@ export async function runCli(argv = process.argv.slice(2)) { : `filegrc complete ${item.obligationId} --scaffold --window-start ${item.dueWindowStart}${flags.program ? ` --program ${flags.program}` : ""} --completed-on YYYY-MM-DD` ].join("\t")); } + for (const item of result.workQueue.items.filter((item) => item.kind === "source-deadline")) { + console.log([item.status.toUpperCase(), item.dueOn, item.title, item.source.id, item.nextAction.command].join("\t")); + } if (result.triggers.length) { console.log("\nPolicy Events:"); for (const trigger of result.triggers) { diff --git a/packages/filegrc/src/index.js b/packages/filegrc/src/index.js index 2747a7e..ad5b6ea 100644 --- a/packages/filegrc/src/index.js +++ b/packages/filegrc/src/index.js @@ -1,3 +1,4 @@ +export { planWorkQueue } from "./work-queue.js"; export { ACTIVE_MODEL_VERSION, getResourceDefinition, diff --git a/packages/filegrc/src/program-readiness.js b/packages/filegrc/src/program-readiness.js index 88b7af3..379821f 100644 --- a/packages/filegrc/src/program-readiness.js +++ b/packages/filegrc/src/program-readiness.js @@ -1,3 +1,4 @@ +import { planWorkQueue } from "./work-queue.js"; import { withAssessmentCache } from "./calculation-cache.js"; import { assessOperatingPreparation, firstOperatingTasks } from "./operating-preparation.js"; import { reportingCutoverIssues } from "./activation-readiness.js"; @@ -2013,6 +2014,7 @@ async function operationStage(loaded, workspace, scope, records, byId, asOf, evi } const obligations = planObligations(records, { root: loaded.root, programId: workspace.id, asOf, through: asOf, model }); + const workQueue = planWorkQueue(records, obligations, { program: workspace, model }); const start = progressWindow?.start || null; const end = progressWindow?.end || null; const formalWindow = progressWindow?.source === "audit"; @@ -2066,15 +2068,15 @@ async function operationStage(loaded, workspace, scope, records, byId, asOf, evi )] : []), item( "ongoing-obligations", - obligations.counts.overdue || obligations.counts.blocked ? "action" : "complete", + workQueue.counts.overdue || workQueue.counts.blocked ? "action" : "complete", "Keep policy work current", - obligations.counts.overdue - ? `${obligations.counts.overdue} Work Queue ${obligations.counts.overdue === 1 ? "item is" : "items are"} overdue` - + (obligations.counts.blocked ? ` and ${obligations.counts.blocked} ${obligations.counts.blocked === 1 ? "is" : "are"} blocked` : "") + workQueue.counts.overdue + ? `${workQueue.counts.overdue} Work Queue ${workQueue.counts.overdue === 1 ? "item is" : "items are"} overdue` + + (workQueue.counts.blocked ? ` and ${workQueue.counts.blocked} ${workQueue.counts.blocked === 1 ? "is" : "are"} blocked` : "") + ". Resolve the work and retain its dated proof." - : obligations.counts.blocked - ? `${obligations.counts.blocked} Work Queue ${obligations.counts.blocked === 1 ? "item is" : "items are"} blocked. Open each task, review its named blockers, and resolve them before completion.` - : `${obligations.counts.due} due and ${obligations.counts.upcoming} upcoming Work Queue items; none are overdue or blocked.`, + : workQueue.counts.blocked + ? `${workQueue.counts.blocked} Work Queue ${workQueue.counts.blocked === 1 ? "item is" : "items are"} blocked. Open each task, review its named blockers, and resolve them before completion.` + : `${workQueue.counts.due} due Work Queue items; none are overdue or blocked.`, { type: "obligation" }, { commands: [ diff --git a/packages/filegrc/src/server.js b/packages/filegrc/src/server.js index 94827c8..7814b8a 100644 --- a/packages/filegrc/src/server.js +++ b/packages/filegrc/src/server.js @@ -1,3 +1,4 @@ +import { planWorkQueue } from "./work-queue.js"; import { scaffoldObligationRuleCorrection, previewObligationRuleCorrection, correctObligationRule } from "./obligation-rule-correction.js"; import { scaffoldRetentionApplication, saveRetentionApplication } from "./retention-applications.js"; import { firstOperatingTasks } from "./operating-preparation.js"; @@ -258,7 +259,7 @@ export function createFilegrcServer(input = process.cwd(), options = {}) { } if (request.method === "GET" && url.pathname === "/api/obligations") { const loaded = await loadWorkspace(input); - return json(response, 200, { ...planObligations(loaded.resources, { root: loaded.root, + const obligations = planObligations(loaded.resources, { root: loaded.root, programId: url.searchParams.get("programId") || undefined, asOf: url.searchParams.get("asOf") || undefined, from: url.searchParams.get("from") || undefined, @@ -266,7 +267,9 @@ export function createFilegrcServer(input = process.cwd(), options = {}) { now: url.searchParams.get("now") || undefined, includeComplete: url.searchParams.get("includeComplete") === "true", model: loaded.model - }), recommendations: [hostedAutomationForConnection(OPERATING_HOSTED_AUTOMATION, loaded.hostedAutomationConnection)] }); + }); + const workQueue = planWorkQueue(loaded.resources, obligations, { program: resolveProgram(loaded, url.searchParams.get("programId") || undefined), model: loaded.model, includeComplete: url.searchParams.get("includeComplete") === "true" }); + return json(response, 200, { ...obligations, workQueue, recommendations: [hostedAutomationForConnection(OPERATING_HOSTED_AUTOMATION, loaded.hostedAutomationConnection)] }); } if (request.method === "GET" && url.pathname === "/api/workflow") { const start = url.searchParams.get("start"); diff --git a/packages/filegrc/src/state.js b/packages/filegrc/src/state.js index c06e76f..43452a2 100644 --- a/packages/filegrc/src/state.js +++ b/packages/filegrc/src/state.js @@ -1,3 +1,5 @@ +import { loadWorkspace } from "./workspace.js"; +import { planWorkQueue } from "./work-queue.js"; import { amendedResourceView } from "./nonmaterial-amendments.js"; import { assessReportingRouteSets } from "./reporting-route-sets.js"; import { readFile } from "node:fs/promises"; @@ -88,6 +90,7 @@ export async function createAppBootstrap(input = process.cwd(), options = {}) { }, reconciliation: { contractVersion: 1, gitRevision: null, changedPaths: [], candidates: [] }, obligations: { loading: true, items: [], triggers: [], counts: {} }, + workQueue: { loading: true, items: [], counts: {} }, collectionReviews: {}, applicabilityConstraints: {}, applicabilityReviewStatuses: {}, @@ -169,16 +172,10 @@ export async function createAppStateSection(input, section, options = {}) { } if (section === "obligations") { const activeProgram = resolveProgram(loaded, options.programId); - return { - generatedAt, - asOf, - obligations: planObligations(loaded.resources, { root: loaded.root, - programId: activeProgram.id, - asOf, - now: options.now ?? generatedAt, - model: loaded.model - }) - }; + const obligations = planObligations(loaded.resources, { root: loaded.root, + programId: activeProgram.id, asOf, now: options.now ?? generatedAt, model: loaded.model }); + return { generatedAt, asOf, obligations, + workQueue: planWorkQueue(loaded.resources, obligations, { program: activeProgram, model: loaded.model }) }; } if (section === "audits") { const programReadiness = options.programReadiness; @@ -335,6 +332,7 @@ async function createAppStateUnlocked(input, options) { diagnostics: validation.diagnostics }, obligations, + workQueue: planWorkQueue(loaded.resources, obligations, { program: activeProgram, model: loaded.model }), reconciliation, collectionReviews, applicabilityConstraints, diff --git a/packages/filegrc/src/web.js b/packages/filegrc/src/web.js index 13769cb..dd6c0b5 100644 --- a/packages/filegrc/src/web.js +++ b/packages/filegrc/src/web.js @@ -568,7 +568,7 @@ function renderHome(main) { || setupSystem.status !== "active" || activeProgram().assuranceGoal === "none"; const acceptedEventTriggers = activePolicyEventTriggers(state.obligations.triggers); - const openObligations = activeOperationItems(state.obligations.items); + const openObligations = activeOperationItems(queueState().items); const previewObligations = distinctObligationPreviews(openObligations, 3); const setupBanner = setupPending ? initialSetupBanner() : ""; const auditPanel = program.evidenceReady @@ -576,7 +576,7 @@ function renderHome(main) { (activeAudit ? auditProgress(activeAudit.record) + auditEngagementPrompt(activeAudit.record) : auditEngagementPrompt()) + '' : ""; const obligationPanel = state.sections?.obligations === "complete" && (program.evidenceReady || openObligations.length) - ? '

Policy obligations

Due Windows

Open board
' + obligationPreview(previewObligations) + '
' + ? '

Work Queue

Due Windows

Open board
' + obligationPreview(previewObligations) + '
' : ""; const eventPanel = acceptedEventTriggers.length ? '

Event reminders

Did Something Change?

Trigger work
' + eventReminderPreview(orderedPolicyEventTriggers(acceptedEventTriggers).slice(0, 4)) + '
' @@ -2178,7 +2178,12 @@ function workflowUiStage(stageId) { return ["operate", "operation"].includes(stageId) ? "run" : stageId; } +function queueState() { + return state.workQueue || state.obligations; +} + function operationProgress() { + if (state.workQueue?.loading) return { percent: 0, complete: 0, total: 1, status: "Checking work", tone: "neutral", detail: "Checking the Work Queue and source-record deadlines." }; const program = state.programReadiness; const goal = program?.target?.goal || activeProgram().assuranceGoal || "none"; const asOf = program?.asOf || currentDate(); @@ -2187,10 +2192,10 @@ function operationProgress() { : goal === "soc-2-type-1" ? Boolean(program?.target?.candidateCoverage?.kind === "as-of") : Boolean(program?.evidenceReady); - const overdue = state.obligations.counts.overdue || 0; - const blocked = state.obligations.counts.blocked || 0; + const overdue = queueState().counts.overdue || 0; + const blocked = queueState().counts.blocked || 0; const complete = Boolean(program?.operating); - if (complete) { + if (complete && !overdue && !blocked) { return { percent: 100, complete: 1, @@ -2309,11 +2314,12 @@ function renderExternalEvidenceSection() { function renderObligations(main, params = new URLSearchParams()) { const stage = READINESS_STAGES.find((candidate) => candidate.id === "run"); const plan = state.obligations; + const queue = queueState(); const operationLocked = !state.programReadiness?.evidenceReady; const visibleCardLimit = 6; const renderBoard = (items, statuses) => statuses.map((status) => { const statusItems = obligationBoardItems(items, status); - const cards = statusItems.map((item, index) => obligationCard(item, index >= visibleCardLimit)).join(""); + const cards = statusItems.map((item, index) => item.kind === "source-deadline" ? sourceDeadlineCard(item, index >= visibleCardLimit) : obligationCard(item, index >= visibleCardLimit)).join(""); const more = statusItems.length > visibleCardLimit ? '' : ""; @@ -2323,8 +2329,8 @@ function renderObligations(main, params = new URLSearchParams()) { const orderedTriggers = orderedPolicyEventTriggers(plan.triggers); const acceptedTriggers = activePolicyEventTriggers(orderedTriggers); const proposedTriggers = orderedTriggers.filter(({ programStatus }) => programStatus === "proposed"); - const currentItems = activeOperationItems(plan.items); - const proposedItems = plan.items.filter(({ status }) => status === "proposed"); + const currentItems = activeOperationItems(queue.items); + const proposedItems = queue.items.filter(({ status }) => status === "proposed"); const renderEvents = (items, scope, description) => { const triggers = items.map((trigger, index) => policyEventTrigger(trigger, index, index >= eventTriggerLimit, scope)).join(""); const eventMore = items.length > eventTriggerLimit @@ -2332,7 +2338,7 @@ function renderObligations(main, params = new URLSearchParams()) { : ""; return '

Changes that create work

Policy Events

' + description + '

' + (triggers || empty("No event-driven obligations are configured.")) + '
' + eventMore + '
'; }; - const renderQueue = (items, statuses, description) => '

Recurring, event, and assigned work

Work Queue

' + description + '

' + (!state.readOnly ? '' : "") + 'Edit schedules
' + renderBoard(items, statuses) + '
'; + const renderQueue = (items, statuses, description) => '

Recurring work and dated follow-up

Work Queue

' + description + '

' + (!state.readOnly ? '' : "") + 'Edit schedules
' + renderBoard(items, statuses) + '
'; const feedback = policyEventFeedback ? '
Work added to the Work Queue

' + esc(policyEventFeedback.name + " created " + policyEventFeedback.taskCount + " " + pluralize("task", policyEventFeedback.taskCount) + ".") + '

' : ""; @@ -2344,7 +2350,7 @@ function renderObligations(main, params = new URLSearchParams()) { (currentItems.length ? renderQueue(currentItems, ["upcoming", "due", "overdue", "blocked", "waiting"], "Complete work the program has already adopted. Each card shows its due window, source, and next action.") : "") : ""; const setupTriggers = operationLocked ? proposedTriggers : orderedTriggers; - const setupItems = operationLocked ? proposedItems : plan.items; + const setupItems = operationLocked ? proposedItems : queue.items; const setupStatuses = operationLocked ? ["proposed"] : ["upcoming", "due", "overdue", "blocked", "waiting", "proposed"]; const operationSetupOpen = operationLocked ? "" : " open"; const operationSetupSummary = operationLocked @@ -2439,7 +2445,7 @@ function obligationBoardItems(items, status) { if (status !== "proposed") return matching; const seen = new Set(); return matching.filter((item) => { - const source = item.actionItemId ? "action:" + item.actionItemId : "obligation:" + item.obligationId; + const source = item.kind === "source-deadline" ? item.key : item.actionItemId ? "action:" + item.actionItemId : "obligation:" + item.obligationId; if (seen.has(source)) return false; seen.add(source); return true; @@ -2518,6 +2524,12 @@ function defaultClassificationId() { return Object.hasOwn(definitions, "internal") ? "internal" : Object.keys(definitions)[0] || ""; } +function sourceDeadlineCard(item, collapsed = false) { + const href = "#/resource/" + encodeURIComponent(item.source.type) + "/" + encodeURIComponent(item.source.id); + const owners = (item.ownerIds || []).map(formatReference).join("") || 'Owner not assigned'; + return '
' + esc(state.model.resources[item.source.type]?.title || properCase(item.source.type)) + '' + esc(timingText(item)) + '

' + esc(titleCase(item.title)) + '

Due ' + esc(item.dueOn) + '.

' + (item.blockingReason ? '

' + esc(item.blockingReason) + '

' : '') + '
Review record
'; +} + function obligationCard(item, collapsed = false) { const type = item.actionItemId ? "action-item" : item.occurrenceId ? "obligation-occurrence" : "obligation"; const id = item.actionItemId || item.occurrenceId || item.obligationId; @@ -3279,7 +3291,7 @@ function renderAuditPacket(main, params = new URLSearchParams()) { ["Engagement", selected ? selected.title : "No audit record", "#/resources/audit", selected ? "good" : "warn"], ["filegrc Evidence", filegrcRecords.length + " operating " + pluralize("record", filegrcRecords.length), "#/stage/run", "neutral"], ["Evidence Artifacts", evidence.length + " " + pluralize("record", evidence.length), "#/resources/evidence", evidence.length ? "good" : "neutral"], - ["Policy work", state.obligations.counts.overdue ? state.obligations.counts.overdue + " overdue" : state.obligations.counts.blocked ? state.obligations.counts.blocked + " blocked" : state.obligations.counts.due ? state.obligations.counts.due + " due" : state.obligations.counts.proposed ? state.obligations.counts.proposed + " proposals" : "No work due", "#/stage/run", state.obligations.counts.overdue || state.obligations.counts.blocked ? "bad" : state.obligations.counts.due ? "warn" : state.obligations.counts.proposed ? "neutral" : "good"] + ["Policy work", queueState().counts.overdue ? queueState().counts.overdue + " overdue" : queueState().counts.blocked ? queueState().counts.blocked + " blocked" : queueState().counts.due ? queueState().counts.due + " due" : queueState().counts.proposed ? queueState().counts.proposed + " proposals" : "No work due", "#/stage/run", queueState().counts.overdue || queueState().counts.blocked ? "bad" : queueState().counts.due ? "warn" : queueState().counts.proposed ? "neutral" : "good"] ]; const evidencePaths = '

Evidence workflow

Review both evidence paths

Use the formal audit date or period. Each selected control may need one or both paths.

'; const dateFields = typeOne @@ -3418,6 +3430,7 @@ function windowText(item) { } function timingText(item) { + if (item.kind === "source-deadline") return (item.status === "overdue" ? "Overdue · " : item.status === "blocked" ? "Blocked · " : "Due ") + item.dueOn; if (item.status === "waiting") return item.awaitingPeriodEnd ? "Awaiting period end" : "Awaiting population cutoff"; if (item.canceledAction) return "Action canceled; resolve or cancel the event"; if (item.missingCompletion) return "Link required completion proof"; diff --git a/packages/filegrc/src/work-queue.js b/packages/filegrc/src/work-queue.js new file mode 100644 index 0000000..b9b56c2 --- /dev/null +++ b/packages/filegrc/src/work-queue.js @@ -0,0 +1,232 @@ +import { loadModel } from "../model/index.js"; +import { recordBelongsToProgram, resolveProgram } from "./program.js"; +import { notificationContacts } from "./notifications.js"; + +const TERMINAL_STATUSES = new Set([ + "accepted", + "approved", + "closed", + "complete", + "completed", + "done", + "expired", + "inactive", + "not-applicable", + "reconciled", + "remediated", + "resolved", + "retired", + "superseded", + "terminated", + "verified" +]); + +const DATE_FIELDS = [ + "dueOn", + "expiresOn", + "targetRemediationOn", + "treatmentTargetOn", + "reviewDueOn", + "scheduledFor", + "validThrough", + "fieldworkStart", + "fieldworkEnd", + "reportDate", + "endsOn", + "endDate", + "proposedEffectiveOn" +]; + +const OWNER_FIELDS = [ + "assigneeIds", + "ownerIds", + "responsibleIds", + "reviewerIds" +]; + +export function workPriority(state, dueOn, asOf) { + if (state === "waiting") return 60; + if (state === "overdue") return 0; + if (dueOn && asOf && dueOn < asOf) return 5; + if (state === "due") return 10; + if (state === "ready" || state === "open") return 20; + if (state === "blocked") return 30; + if (state === "upcoming" || state === "scheduled") return 50; + return 90; +} + + +function firstDate(record) { + if (record.type === "exception" && typeof record.approval?.expiresOn === "string") return record.approval.expiresOn; + if (typeof record.completionWindow?.dueAt === "string" && record.completionWindow.dueAt) return record.completionWindow.dueAt; + if (typeof record.completionWindow?.dueOn === "string" && record.completionWindow.dueOn) return record.completionWindow.dueOn; + for (const field of DATE_FIELDS) { + if (typeof record[field] === "string" && record[field]) return record[field]; + } + return null; +} + + +function workScope(record) { + for (const field of [ + "scopeResourceIds", + "systemIds", + "controlIds", + "vendorIds", + "requirementIds" + ]) { + if (Array.isArray(record[field]) && record[field].length) return record[field]; + } + return []; +} + + +function sourceCompletionProfile(record) { + if (record.type === "action-item") return record.obligationId ? "assigned-obligation-work" : "assigned-action"; + if (record.type === "audit-request") return "audit-request-response"; + if (record.type === "risk") return "risk-treatment-and-review"; + if (record.type === "vulnerability") return "vulnerability-remediation"; + if (record.type === "finding") return "finding-remediation-and-verification"; + if (record.type === "exception") return "exception-expiry-and-compensating-control-review"; + if (record.type === "service-account") return "service-account-review-or-disablement"; + if (record.type === "vendor") return "vendor-assurance-renewal-or-termination"; + if (record.type === "access-grant") return "access-renewal-or-revocation"; + if (record.type === "audit") return "audit-lifecycle-transition"; + return `${record.type}-lifecycle`; +} + + +function sourceNextAction(record, due, asOf, records) { + const eventType = record.type === "exception" + ? (record.status === "approved" && due < asOf ? "exception-expired" : null) + : due <= asOf ? { + "service-account": "service-account-expired", + vulnerability: "vulnerability-overdue" + }[record.type] : null; + const alreadyRecorded = eventType && records.some((item) => ( + item.type === "obligation-event" + && item.eventType === eventType + && (item.subjectResourceIds || []).includes(record.id) + && item.occurredOn >= due.slice(0, 10) + )); + if (eventType && !alreadyRecorded) { + return { + kind: "command", + command: `npx filegrc trigger ${eventType} --occurred-on YYYY-MM-DD --subject ${shellArgument(record.id)} --json` + }; + } + return { + kind: "command", + command: `npx filegrc get ${shellArgument(record.id)} --mutation` + }; +} + + +function firstOwners(record) { + for (const field of OWNER_FIELDS) { + if (Array.isArray(record[field]) && record[field].length) return record[field]; + } + return []; +} + + +function sourceWorkState(record, due, asOf) { + if (record.type === "exception" && ["closed", "revoked"].includes(record.status)) return "complete"; + if (!(record.type === "exception" && record.status === "approved") && TERMINAL_STATUSES.has(record.status)) return "complete"; + if (["canceled", "superseded"].includes(record.status)) return record.status; + if (["draft", "in-review", "partially-implemented", "planned", "proposed"].includes(record.status)) { + return "proposed"; + } + if (due < asOf) return "overdue"; + if (due === asOf) return "due"; + return "scheduled"; +} + + +function blockingDependencies(ids = [], byId) { + return ids.map((id) => { + const record = byId.get(id); + return { + type: record?.type || "unknown", + id, + reasons: ["Resolve this prerequisite before continuing the blocked work."] + }; + }); +} + + +function shellArgument(value) { + const text = String(value); + return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(text) + ? text + : `'${text.replaceAll("'", "'\\''")}'`; +} + + +// Shared source deadlines preserve the workflow's lifecycle and Program scope. +// Action Items and Obligation occurrences remain owned by the obligation planner. +export function sourceDeadlineWorkItems(records, options) { + const byId = new Map(records.map((record) => [record.id, record])); + const items = []; + for (const record of records) { + // The queue planner owns all Action Items, including filtered and terminal + // work. Recreating them from deadlines would undo its scope and lifecycle. + if (["obligation", "action-item"].includes(record.type) || options.excludedSourceIds?.has(record.id)) continue; + if (!recordBelongsToProgram(record, options.program, byId, options.model)) continue; + const due = firstDate(record); + if (!due) continue; + const state = sourceWorkState(record, due, options.asOf); + if (state === "complete" && !options.includeComplete) continue; + items.push({ + key: `source:${record.type}:${record.id}:${due}`, + kind: "source-deadline", + source: { type: record.type, id: record.id }, + subject: { type: record.type, id: record.id }, + title: record.title, + ownerIds: firstOwners(record), + dueOn: due, + state, + priority: workPriority(state, due, options.asOf), + scope: workScope(record), + blockingReason: record.status === "blocked" + ? actionBlockingReason(record, byId) + : null, + dependencies: record.status === "blocked" + ? blockingDependencies(record.blockingResourceIds, byId) + : [], + requiredCompletionProfile: sourceCompletionProfile(record), + nextAction: sourceNextAction(record, due, options.asOf, records) + }); + } + return items; +} + +export function planWorkQueue(records, obligationPlan, options = {}) { + const asOf = options.asOf || obligationPlan.asOf; + const model = options.model || loadModel(obligationPlan.dataModelVersion); + const program = options.program || resolveProgram({ resources: records, model, workspace: records.find((record) => record.type === "workspace") }, options.programId); + const excludedSourceIds = new Set(obligationPlan.items.map((item) => item.actionItemId || item.occurrenceId || item.obligationId).filter(Boolean)); + const sourceItems = sourceDeadlineWorkItems(records, { ...options, model, asOf, program, excludedSourceIds }) + .filter((item) => !["canceled", "superseded", "not-applicable"].includes(item.state)) + .filter((item) => !obligationPlan.through || item.dueOn.slice(0, 10) <= obligationPlan.through) + .map((item) => ({ ...item, status: item.state === "scheduled" ? "upcoming" : item.state, + notificationContacts: notificationContacts(item.ownerIds, records) })); + const items = [...obligationPlan.items, ...sourceItems].sort((a, b) => ( + workPriority(a.status, a.dueOn || a.dueWindowEnd, asOf) - workPriority(b.status, b.dueOn || b.dueWindowEnd, asOf) + || String(a.dueOn || a.dueWindowEnd || "").localeCompare(String(b.dueOn || b.dueWindowEnd || "")) + || String(a.key).localeCompare(String(b.key)) + )); + const counts = { overdue: 0, blocked: 0, due: 0, waiting: 0, upcoming: 0, proposed: 0, complete: 0 }; + for (const item of items) if (Object.hasOwn(counts, item.status)) counts[item.status]++; + return { asOf, through: obligationPlan.through, items, counts }; +} + +export function actionBlockingReason(record, byId) { + if (record.status !== "blocked") return null; + const blockers = (record.blockingResourceIds || []) + .map((id) => byId.get(id)?.title || id) + .filter(Boolean); + return blockers.length + ? `Blocked by ${blockers.join(", ")}.` + : "The source record is blocked."; +} diff --git a/packages/filegrc/src/workflow.js b/packages/filegrc/src/workflow.js index 33dd5a4..4fbe75e 100644 --- a/packages/filegrc/src/workflow.js +++ b/packages/filegrc/src/workflow.js @@ -1,3 +1,4 @@ +import { actionBlockingReason, planWorkQueue, sourceDeadlineWorkItems, workPriority } from "./work-queue.js"; import { withAssessmentCache } from "./calculation-cache.js"; import { resourceIndex } from "./resource-index.js"; import { assertWorkspaceEngineVersion } from "./runtime-version.js"; @@ -42,48 +43,6 @@ import { loadWorkspace } from "./workspace.js"; export const WORKFLOW_CONTRACT_VERSION = 1; -const TERMINAL_STATUSES = new Set([ - "accepted", - "approved", - "closed", - "complete", - "completed", - "done", - "expired", - "inactive", - "not-applicable", - "reconciled", - "remediated", - "resolved", - "retired", - "superseded", - "terminated", - "verified" -]); - -const DATE_FIELDS = [ - "dueOn", - "expiresOn", - "targetRemediationOn", - "treatmentTargetOn", - "reviewDueOn", - "scheduledFor", - "validThrough", - "fieldworkStart", - "fieldworkEnd", - "reportDate", - "endsOn", - "endDate", - "proposedEffectiveOn" -]; - -const OWNER_FIELDS = [ - "assigneeIds", - "ownerIds", - "responsibleIds", - "reviewerIds" -]; - /** * Return the shared, interface-neutral workflow assessment for a workspace. * @@ -178,12 +137,14 @@ async function assessWorkflowUnmeasured(input, options = {}) { ...item, notificationContacts: notificationContacts(item.ownerIds, loaded.resources) })); + const workQueue = planWorkQueue(loaded.resources, obligationPlan, { asOf, program: programRecord, model: loaded.model, includeComplete: Boolean(options.includeComplete) }); const git = options.git || safeGitSummary(loaded.root); const assessments = buildAssessments({ program, audits, auditPreparations, obligationPlan, + workQueue, findings, validation, coverage @@ -205,6 +166,7 @@ async function assessWorkflowUnmeasured(input, options = {}) { recommendations: [program.hostedAutomation, program.hostedAutomation?.operating].filter(Boolean), findings: findings.sort(compareFindings), workItems: workItems.sort(compareWorkItems), + workQueue, recommended: recommended ? { ...recommended, rankingReason: rankingReason(recommended), context: buildActionContext(loaded, recommended) } : null, @@ -1263,36 +1225,7 @@ function buildWorkItems(records, obligationPlan, options) { const obligationSources = new Set(items .map((item) => item.source?.id) .filter(Boolean)); - for (const record of records) { - // The queue planner owns all Action Items, including filtered and terminal - // work. Recreating them from deadlines would undo its scope and lifecycle. - if (["obligation", "action-item"].includes(record.type) || obligationSources.has(record.id)) continue; - if (!recordBelongsToProgram(record, options.program, byId, options.model)) continue; - const due = firstDate(record); - if (!due) continue; - const state = sourceWorkState(record, due, options.asOf); - if (state === "complete" && !options.includeComplete) continue; - items.push({ - key: `source:${record.type}:${record.id}:${due}`, - kind: "source-deadline", - source: { type: record.type, id: record.id }, - subject: { type: record.type, id: record.id }, - title: record.title, - ownerIds: firstOwners(record), - dueOn: due, - state, - priority: workPriority(state, due, options.asOf), - scope: workScope(record), - blockingReason: record.status === "blocked" - ? actionBlockingReason(record, byId) - : null, - dependencies: record.status === "blocked" - ? blockingDependencies(record.blockingResourceIds, byId) - : [], - requiredCompletionProfile: sourceCompletionProfile(record), - nextAction: sourceNextAction(record, due, options.asOf, records) - }); - } + items.push(...sourceDeadlineWorkItems(records, { ...options, excludedSourceIds: obligationSources })); return uniqueByKey(items); } @@ -1382,15 +1315,6 @@ function obligationNextAction(item, programId) { }; } -function actionBlockingReason(record, byId) { - if (record.status !== "blocked") return null; - const blockers = (record.blockingResourceIds || []) - .map((id) => byId.get(id)?.title || id) - .filter(Boolean); - return blockers.length - ? `Blocked by ${blockers.join(", ")}.` - : "The source record is blocked."; -} function blockingDependencies(ids = [], byId) { return ids.map((id) => { @@ -1403,15 +1327,15 @@ function blockingDependencies(ids = [], byId) { }); } -function buildAssessments({ program, audits, auditPreparations, obligationPlan, findings, validation, coverage }) { +function buildAssessments({ program, audits, auditPreparations, obligationPlan, workQueue, findings, validation, coverage }) { const scopeStage = program.stages.find((stage) => stage.id === "scope"); const configurationReady = stageComplete(scopeStage); const evidenceReady = program.evidenceReady; const periodFindings = findings.filter((finding) => finding.assessment === "period-health"); const periodStarted = evidenceReady && Boolean(coverage?.start && coverage?.end); const periodHealthy = periodStarted - && obligationPlan.counts.overdue === 0 - && obligationPlan.counts.blocked === 0 + && workQueue.counts.overdue === 0 + && workQueue.counts.blocked === 0 && !periodFindings.some((finding) => blockingFinding(finding) || finding.state === "waiting"); const auditValues = Object.values(auditPreparations); const auditReady = audits.length > 0 @@ -1583,101 +1507,16 @@ function findingPriority(finding) { return 90; } -function workPriority(state, dueOn, asOf) { - if (state === "waiting") return 60; - if (state === "overdue") return 0; - if (dueOn && asOf && dueOn < asOf) return 5; - if (state === "due") return 10; - if (state === "ready" || state === "open") return 20; - if (state === "blocked") return 30; - if (state === "upcoming" || state === "scheduled") return 50; - return 90; -} function activeWork(item) { return !["canceled", "complete", "not-applicable", "superseded"].includes(item.state); } -function firstDate(record) { - if (record.type === "exception" && typeof record.approval?.expiresOn === "string") return record.approval.expiresOn; - if (record.completionWindow?.dueAt) return record.completionWindow.dueAt; - if (record.completionWindow?.dueOn) return record.completionWindow.dueOn; - for (const field of DATE_FIELDS) { - if (typeof record[field] === "string" && record[field]) return record[field]; - } - return null; -} -function workScope(record) { - for (const field of [ - "scopeResourceIds", - "systemIds", - "controlIds", - "vendorIds", - "requirementIds" - ]) { - if (Array.isArray(record[field]) && record[field].length) return record[field]; - } - return []; -} - -function sourceCompletionProfile(record) { - if (record.type === "action-item") return record.obligationId ? "assigned-obligation-work" : "assigned-action"; - if (record.type === "audit-request") return "audit-request-response"; - if (record.type === "risk") return "risk-treatment-and-review"; - if (record.type === "vulnerability") return "vulnerability-remediation"; - if (record.type === "finding") return "finding-remediation-and-verification"; - if (record.type === "exception") return "exception-expiry-and-compensating-control-review"; - if (record.type === "service-account") return "service-account-review-or-disablement"; - if (record.type === "vendor") return "vendor-assurance-renewal-or-termination"; - if (record.type === "access-grant") return "access-renewal-or-revocation"; - if (record.type === "audit") return "audit-lifecycle-transition"; - return `${record.type}-lifecycle`; -} - -function sourceNextAction(record, due, asOf, records) { - const eventType = record.type === "exception" - ? (record.status === "approved" && due < asOf ? "exception-expired" : null) - : due <= asOf ? { - "service-account": "service-account-expired", - vulnerability: "vulnerability-overdue" - }[record.type] : null; - const alreadyRecorded = eventType && records.some((item) => ( - item.type === "obligation-event" - && item.eventType === eventType - && (item.subjectResourceIds || []).includes(record.id) - && item.occurredOn >= due.slice(0, 10) - )); - if (eventType && !alreadyRecorded) { - return { - kind: "command", - command: `npx filegrc trigger ${eventType} --occurred-on YYYY-MM-DD --subject ${shellArgument(record.id)} --json` - }; - } - return { - kind: "command", - command: `npx filegrc get ${shellArgument(record.id)} --mutation` - }; -} -function firstOwners(record) { - for (const field of OWNER_FIELDS) { - if (Array.isArray(record[field]) && record[field].length) return record[field]; - } - return []; -} -function sourceWorkState(record, due, asOf) { - if (record.type === "exception" && ["closed", "revoked"].includes(record.status)) return "complete"; - if (!(record.type === "exception" && record.status === "approved") && TERMINAL_STATUSES.has(record.status)) return "complete"; - if (["canceled", "superseded"].includes(record.status)) return record.status; - if (["draft", "in-review", "partially-implemented", "planned", "proposed"].includes(record.status)) { - return "proposed"; - } - if (due < asOf) return "overdue"; - if (due === asOf) return "due"; - return "scheduled"; -} + + function candidateCoverage(workspace) { const coverage = workspace?.candidateCoverage; diff --git a/packages/filegrc/test/render.test.js b/packages/filegrc/test/render.test.js index 0eb6ae9..8877e31 100644 --- a/packages/filegrc/test/render.test.js +++ b/packages/filegrc/test/render.test.js @@ -2337,7 +2337,7 @@ test("uses the Step 4 page for compact policy-event triggers and the Work Queue" assert.match(APP_SCRIPT, /Evidence collection is running and the Work Queue has no overdue or blocked work/); assert.match(stepFour, /const operationLocked = !state\.programReadiness\?\.evidenceReady/); assert.match(stepFour, /const acceptedTriggers = activePolicyEventTriggers\(orderedTriggers\)/); - assert.match(stepFour, /const currentItems = activeOperationItems\(plan\.items\)/); + assert.match(stepFour, /const currentItems = activeOperationItems\(queue\.items\)/); assert.match(stepFour, /activeOperation \+/); assert.match(stepFour, /Any adopted work and Policy Events that can happen now stay available below/); assert.match(stepFour, /setupTriggers = operationLocked \? proposedTriggers : orderedTriggers/); @@ -2738,9 +2738,9 @@ test("renders shared obligation and evidence-packet workflows", () => { assert.match(APP_SCRIPT, /function renderObligations\(main, params = new URLSearchParams\(\)\)/); assert.match(APP_SCRIPT, /const renderBoard = \(items, statuses\) => statuses\.map/); assert.doesNotMatch(APP_SCRIPT, /class="metrics obligation-metrics"/); - assert.match(APP_SCRIPT, /state\.obligations\.counts\.overdue/); - assert.match(APP_SCRIPT, /state\.obligations\.counts\.blocked/); - assert.match(APP_SCRIPT, /state\.obligations\.counts\.proposed/); + assert.match(APP_SCRIPT, /queueState\(\)\.counts\.overdue/); + assert.match(APP_SCRIPT, /queueState\(\)\.counts\.blocked/); + assert.match(APP_SCRIPT, /queueState\(\)\.counts\.proposed/); assert.match(APP_SCRIPT, /daysUntilOverdue/); assert.match(APP_SCRIPT, /dueWindowStart/); assert.match(APP_SCRIPT, /dueWindowEnd/); diff --git a/packages/filegrc/test/work-queue.test.js b/packages/filegrc/test/work-queue.test.js new file mode 100644 index 0000000..2867647 --- /dev/null +++ b/packages/filegrc/test/work-queue.test.js @@ -0,0 +1,76 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { loadModel } from "../model/index.js"; +import { planWorkQueue } from "../src/work-queue.js"; + +const model = loadModel("13"); +const program = { type: "program", id: "program-primary", status: "active", systemIds: ["system-primary"] }; +const plan = (items = []) => ({ dataModelVersion: "13", asOf: "2026-07-10", through: "2026-08-10", items }); +const assessment = (extra = {}) => ({ type: "risk-assessment", id: "assessment-current", title: "Current risk review", status: "in-progress", scheduledFor: "2026-07-09", systemIds: ["system-primary"], ...extra }); + +test("queue includes overdue source reviews without changing the obligation plan or assigning an actor", () => { + const obligation = { kind: "calendar", key: "annual-review", obligationId: "obligation-review", title: "Annual review", status: "due", dueWindowEnd: "2026-07-31" }; + const obligations = plan([obligation]); + const record = assessment(); + const queue = planWorkQueue([program, record], obligations, { program, model }); + assert.equal(queue.counts.overdue, 1); + assert.equal(queue.counts.due, 1); + assert.equal(queue.items[0].source.id, record.id); + assert.equal(queue.items[0].dueOn, "2026-07-09"); + assert.deepEqual(queue.items[0].ownerIds, []); + assert.deepEqual(queue.items[0].notificationContacts, []); + assert.deepEqual(obligations.items, [obligation]); + assert.equal(record.status, "in-progress"); +}); + +test("source deadlines use the selected Program, lifecycle and queue horizon", () => { + const records = [program, assessment(), assessment({ id: "review-other-program", programId: "program-other" }), + assessment({ id: "review-finished", status: "complete" }), assessment({ id: "review-canceled", status: "canceled" }), + assessment({ id: "review-today", scheduledFor: "2026-07-10" }), assessment({ id: "review-upcoming", scheduledFor: "2026-07-11" }), + assessment({ id: "review-outside-horizon", scheduledFor: "2026-09-01" })]; + const queue = planWorkQueue(records, plan(), { program, model }); + assert.deepEqual(queue.items.map(x => x.source.id), ["assessment-current", "review-today", "review-upcoming"]); + assert.equal(queue.counts.overdue, 1);assert.equal(queue.counts.due, 1);assert.equal(queue.counts.upcoming, 1); +}); + +test("queue retains Waiting and blocked obligations and does not duplicate Action Items", () => { + const records = [program, { type: "action-item", id: "action-work", title: "Assigned work", status: "open", dueOn: "2026-07-09" }]; + const items = [{ kind: "action", key: "action", actionItemId: "action-work", status: "overdue" }, + { kind: "calendar", key: "waiting", obligationId: "obligation-wait", status: "waiting" }, + { kind: "calendar", key: "blocked", obligationId: "obligation-blocked", status: "blocked" }]; + const queue = planWorkQueue(records, plan(items), { program, model }); + assert.equal(queue.items.length, 3);assert.equal(queue.counts.overdue, 1);assert.equal(queue.counts.blocked, 1);assert.equal(queue.counts.waiting, 1); +}); + +test("independent source proposals and approved Exception expiry remain separate work", () => { + const records = [program, assessment({ id: "draft-a", status: "planned" }), assessment({ id: "draft-b", status: "draft" }), + { type: "exception", id: "exception-current", title: "Review expired exception", status: "approved", systemIds: ["system-primary"], approval: { expiresOn: "2026-07-09" }, ownerIds: ["person-owner"] }, + { type: "person", id: "person-owner", status: "active", email: "owner@example.com" }]; + const queue = planWorkQueue(records, plan()); + assert.equal(queue.counts.proposed, 2);assert.equal(queue.counts.overdue, 1); + const exception = queue.items.find(x => x.source.id === "exception-current"); + assert.match(exception.nextAction.command, /trigger exception-expired/); + assert.deepEqual(exception.notificationContacts, [{ personId: "person-owner", email: "owner@example.com" }]); + assert.equal(records[2].status, "draft"); +}); + +test("an operating program cannot show a healthy summary while a source deadline is overdue", async () => { + const { APP_SCRIPT } = await import("../src/web.js"); + const { default: vm } = await import("node:vm"); + const source = APP_SCRIPT.slice(APP_SCRIPT.indexOf("function queueState()"), APP_SCRIPT.indexOf("function progressFromCounts")); + const result = vm.runInNewContext(source + "; operationProgress()", { + state: { programReadiness: { operating: true, target: { goal: "none" } }, workQueue: { counts: { overdue: 1, blocked: 0 } } }, + activeProgram: () => ({ assuranceGoal: "none" }), currentDate: () => "2026-07-10", pluralize: (word, n) => n === 1 ? word : word + "s" + }); + assert.equal(result.status, "Needs attention"); + assert.match(result.detail, /1 overdue Work Queue item/); +}); + +test("invalid blocked source records remain inspectable while model diagnostics are repaired", () => { + const record = assessment({ status: "blocked", blockingResourceIds: ["assessment-prerequisite"] }); + const prerequisite = assessment({ id: "assessment-prerequisite", title: "Prerequisite review", status: "complete" }); + const queue = planWorkQueue([program, record, prerequisite], plan(), { program, model }); + assert.equal(queue.counts.overdue, 1); + assert.equal(queue.items[0].blockingReason, "Blocked by Prerequisite review."); + assert.deepEqual(queue.items[0].dependencies.map(x => x.id), [prerequisite.id]); +}); diff --git a/packages/filegrc/test/workflow.test.js b/packages/filegrc/test/workflow.test.js index ef3d081..d07e7b6 100644 --- a/packages/filegrc/test/workflow.test.js +++ b/packages/filegrc/test/workflow.test.js @@ -1,3 +1,4 @@ +import { fileURLToPath } from "node:url"; import assert from "node:assert/strict"; import { execFile } from "node:child_process"; import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; @@ -788,6 +789,10 @@ test("Program workflow excludes unrelated source deadlines and does not recreate assert.equal(workflow.workItems.some(({ source }) => source.id === "evidence-deadline-unrelated"), false); assert.equal(workflow.workItems.find(({ source }) => source.id === "evidence-audit-selected")?.state, "overdue"); assert.equal(workflow.workItems.some(({ source }) => source.id === "evidence-audit-unrelated"), false); + assert.equal(workflow.workQueue.items.find(({ source }) => source?.id === "evidence-deadline-selected")?.status, "overdue"); + assert.equal(workflow.workQueue.items.some(({ source }) => source?.id === "evidence-deadline-unrelated"), false); + assert.equal(workflow.workQueue.items.some(({ source }) => source?.id === action.id), false); + }); test("approved Exception expiry remains actionable through its lifecycle and Program scope", async (context) => { @@ -841,11 +846,43 @@ test("unfinalized population work prevents period completeness without becoming assessment: (status) => ({ status }), findingKeys: () => [] }); - const input = { program: { stages: [], evidenceReady: true }, audits: [], auditPreparations: {}, obligationPlan: { counts: { overdue: 0, blocked: 0 } }, findings: [], validation: { ok: true }, coverage: { start: "2026-10-01", end: "2026-10-31" } }; + const input = { program: { stages: [], evidenceReady: true }, audits: [], auditPreparations: {}, obligationPlan: { counts: { overdue: 0, blocked: 0 } }, workQueue: { counts: { overdue: 0, blocked: 0 } }, findings: [], validation: { ok: true }, coverage: { start: "2026-10-01", end: "2026-10-31" } }; assert.equal(build(input).periodHealth.status, "complete"); + input.workQueue.counts.overdue = 1; + assert.equal(build(input).periodHealth.status, "at-risk"); + input.workQueue.counts.overdue = 0; input.findings.push({ assessment: "period-health", state: "waiting" }); assert.equal(build(input).periodHealth.status, "at-risk"); const recommendationSource = source.slice(source.indexOf("function blockingFinding"), source.indexOf("function compareRecommended")); const recommend = vm.runInNewContext(recommendationSource + "\nrecommendedAction", { activeWork: () => true, compareRecommended: () => 0 }); assert.equal(recommend(input.findings, [{ state: "waiting" }], {}), null); }); + +test("dated review deadlines agree across workflow, CLI and lazy app/API queue summaries", async (context) => { + const root = await mkdtemp(join(tmpdir(), "filegrc-source-deadline-summary-")); + context.after(() => import("node:fs/promises").then(({ rm }) => rm(root, { recursive: true, force: true }))); + await makeWorkspace(root); + const record = { id: "assessment-current-review", type: "risk-assessment", title: "Current operating risk review", status: "in-progress", scope: "Example service risks", assessorIds: ["person-owner"], scheduledFor: "2026-07-09" }; + await mkdir(join(root, "data", "risk-assessments"), { recursive: true }); + await writeJson(join(root, "data", "risk-assessments", record.id + ".json"), record); + const asOf = "2026-07-10";const now = "2026-07-10T12:00:00Z"; + const cli = fileURLToPath(new URL("../bin/filegrc.js", import.meta.url)); + const { stdout } = await promisify(execFile)(process.execPath, [cli, "obligations", "--root", root, "--as-of", asOf, "--now", now, "--json"]); + const result = JSON.parse(stdout); + const running = await serveWorkspace(root, { port: 0 }); + context.after(() => new Promise((resolve) => running.server.close(resolve))); + const api = await (await fetch(running.url + "/api/obligations?asOf=" + asOf + "&now=" + now)).json(); + const { createAppStateSection } = await import("../src/state.js"); + const section = await createAppStateSection(root, "obligations", { asOf, now }); + const workflow = await assessWorkflow(root, { asOf, now }); + for (const queue of [result.workQueue, api.workQueue, section.workQueue, workflow.workQueue]) { + const item = queue.items.find(({ source }) => source?.id === record.id); + assert.equal(item.status, "overdue");assert.equal(item.dueOn, record.scheduledFor); + assert.equal(queue.counts.overdue, result.workQueue.counts.overdue); + } + assert.deepEqual(result.counts, api.counts); + assert.equal(result.items.some(({ source }) => source?.id === record.id), false); + assert.equal(workflow.workItems.find(({ source }) => source?.id === record.id).state, "overdue"); + const current = JSON.parse(await readFile(join(root, "data", "risk-assessments", record.id + ".json"), "utf8")); + assert.deepEqual(current, record); +});