filegrc 0.12.3 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/validate.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { createReadStream } from "node:fs";
2
3
  import { readFile, stat } from "node:fs/promises";
3
4
  import { performance } from "node:perf_hooks";
4
5
  import { getResourceDefinition, modelSupports } from "../model/index.js";
@@ -29,13 +30,31 @@ import { collectionReviewRevision, historicalCollectionReviewSnapshot } from "./
29
30
  import {
30
31
  reportingRouteRevision,
31
32
  reportingRouteBindingExpectationForValidation,
33
+ reportingRouteCommitTimestamp,
32
34
  reportingRouteEventCommit,
33
35
  reportingRouteEventAuthorityIssueAtCommit,
36
+ reportingRouteExactHistoryEntry,
34
37
  reportingRouteFixedEvidence,
35
- reportingRouteRecordAtRevision
38
+ reportingRouteProposalIssues,
39
+ reportingRouteProposalAssessmentTime,
40
+ reportingRouteRecordAtRevision,
41
+ reportingRouteRequirementsForProposal,
42
+ reportingRouteSupportIssues,
43
+ recordsAtRevision
36
44
  } from "./reporting-route-integrity.js";
37
45
  import { validateWorkflowHistoryIntegrity } from "./workflow-history-integrity.js";
38
46
 
47
+ let fingerprintFileReadObserver = null;
48
+
49
+ export function setFingerprintFileReadObserverForTests(observer) {
50
+ if (observer !== null && typeof observer !== "function") {
51
+ throw new TypeError("The fingerprint file-read observer must be a function or null.");
52
+ }
53
+ const previous = fingerprintFileReadObserver;
54
+ fingerprintFileReadObserver = observer;
55
+ return () => { fingerprintFileReadObserver = previous; };
56
+ }
57
+
39
58
  const ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
40
59
  const NAMESPACE_PATTERN = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/;
41
60
  const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
@@ -229,6 +248,7 @@ async function validateWorkspaceUnmeasured(input) {
229
248
  ));
230
249
  }
231
250
  } catch (cause) {
251
+ if (cause?.code === "FILEGRC_GIT_DEADLINE") throw cause;
232
252
  diagnostics.push(error(
233
253
  "reconciliation-history-unavailable",
234
254
  "data/workspace.json",
@@ -889,6 +909,29 @@ function validateReportingRouteSets(loaded, byId, pathById, diagnostics) {
889
909
  }
890
910
  for (const route of routeSets) {
891
911
  const path = pathById.get(route.id);
912
+ if (route.status === "proposed") {
913
+ const proposalHistory = repository.commit
914
+ ? reportingRouteExactHistoryEntry(loaded, route, repository.commit)
915
+ : null;
916
+ const proposalRecords = proposalHistory ? recordsAtRevision(loaded, proposalHistory.commit) : loaded.resources;
917
+ const proposalRecord = proposalHistory
918
+ ? proposalRecords.find(({ id }) => id === route.id) || route
919
+ : route;
920
+ const proposalAssessmentAt = proposalHistory
921
+ ? reportingRouteProposalAssessmentTime(proposalHistory.timestamp, new Date())
922
+ : new Date();
923
+ if (proposalHistory && !proposalAssessmentAt) {
924
+ diagnostics.push(error("invalid-reporting-route-proposal-time", path, "The proposal commit time is too far in the future to establish a reliable proposal."));
925
+ }
926
+ for (const issue of reportingRouteProposalIssues(proposalRecords, proposalRecord, {
927
+ at: proposalAssessmentAt || new Date(),
928
+ timezone: proposalRecords.find(({ type }) => type === "workspace")?.timezone || loaded.workspace?.timezone || "UTC",
929
+ root: loaded.root,
930
+ commit: proposalHistory?.commit
931
+ })) {
932
+ diagnostics.push(error(issue.code, path, issue.message));
933
+ }
934
+ }
892
935
  if (["draft", "proposed", "approved"].includes(route.status)) {
893
936
  const key = `${route.programId}\0${route.purposeKey}`;
894
937
  const current = currentByPurpose.get(key) || { approved: [], pending: [] };
@@ -924,6 +967,60 @@ function validateReportingRouteSets(loaded, byId, pathById, diagnostics) {
924
967
  const proposal = entry ? reportingRouteRecordAtRevision(loaded, entry, route.proposalCommit) : null;
925
968
  if (!proposal || proposal.status !== "proposed" || !sameRouteProposal(proposal, route)) {
926
969
  diagnostics.push(error("changed-reporting-route-proposal", path, "The approved Route Set facts must exactly match the committed proposal; only managed approval fields may differ."));
970
+ } else {
971
+ const proposalRecords = recordsAtRevision(loaded, route.proposalCommit);
972
+ const proposalTimestamp = reportingRouteCommitTimestamp(loaded, route.id, route.proposalCommit);
973
+ const proposalAssessmentAt = proposalTimestamp
974
+ ? reportingRouteProposalAssessmentTime(proposalTimestamp, new Date())
975
+ : null;
976
+ if (!proposalTimestamp) {
977
+ diagnostics.push(error("invalid-reporting-route-proposal", path, "The proposal commit must be an exact Reporting Channel Set history entry."));
978
+ } else if (!proposalAssessmentAt) {
979
+ diagnostics.push(error("invalid-reporting-route-proposal-time", path, "The proposal commit time is too far in the future to establish a reliable proposal."));
980
+ } else {
981
+ const proposalIssues = reportingRouteProposalIssues(
982
+ proposalRecords,
983
+ proposal,
984
+ {
985
+ at: proposalAssessmentAt,
986
+ timezone: proposalRecords.find(({ type }) => type === "workspace")?.timezone || loaded.workspace?.timezone || "UTC",
987
+ root: loaded.root,
988
+ commit: route.proposalCommit
989
+ }
990
+ );
991
+ for (const issue of proposalIssues) {
992
+ diagnostics.push(error(issue.code, path, issue.message));
993
+ }
994
+ if (!proposalIssues.length) {
995
+ const approvalCommit = reportingRouteEventCommit(loaded, route, "approval");
996
+ const approvalRecords = approvalCommit ? recordsAtRevision(loaded, approvalCommit) : loaded.resources;
997
+ const liveCommit = route.status === "canceled"
998
+ ? reportingRouteEventCommit(loaded, route, "cancellation")
999
+ : null;
1000
+ const liveRecords = liveCommit ? recordsAtRevision(loaded, liveCommit) : loaded.resources;
1001
+ const cutoverAt = route.approval?.effectiveAt || route.approval?.approvedAt || new Date();
1002
+ const cutoverTimezone = route.approval?.timezone || loaded.workspace?.timezone || "UTC";
1003
+ for (const issue of reportingRouteSupportIssues(
1004
+ reportingRouteRequirementsForProposal(approvalRecords, route, {
1005
+ at: cutoverAt,
1006
+ timezone: cutoverTimezone
1007
+ }),
1008
+ route,
1009
+ proposalRecords,
1010
+ liveRecords,
1011
+ {
1012
+ at: cutoverAt,
1013
+ availableAt: route.approval?.approvedAt || new Date(),
1014
+ timezone: cutoverTimezone,
1015
+ root: loaded.root,
1016
+ proposalCommit: route.proposalCommit,
1017
+ currentCommit: liveCommit
1018
+ }
1019
+ )) {
1020
+ diagnostics.push(error(issue.code, path, issue.message));
1021
+ }
1022
+ }
1023
+ }
927
1024
  }
928
1025
  for (const markdown of markdownEntries(loaded.model, route)) {
929
1026
  const currentPath = `data/${markdown.path}`;
@@ -1224,19 +1321,27 @@ function validateCollectionReviewSet(resources, pathById, diagnostics) {
1224
1321
  }
1225
1322
  }
1226
1323
 
1227
- export async function fingerprintWorkspace(input = process.cwd()) {
1324
+ export async function fingerprintWorkspace(input = process.cwd(), options = {}) {
1228
1325
  const loaded = typeof input === "object" && input.entries ? input : await loadWorkspace(input);
1229
1326
  const hash = createHash("sha256");
1230
1327
  hash.update(`model\0${loaded.model.modelVersion}\0`);
1231
- for (const entry of [...loaded.entries].sort((a, b) => a.relativePath.localeCompare(b.relativePath))) {
1328
+ const sourceEntries = loaded.sourceEntries || loaded.entries;
1329
+ const fingerprintBudget = { uncachedBytes: 0 };
1330
+ for (const entry of [...sourceEntries].sort((a, b) => a.relativePath.localeCompare(b.relativePath))) {
1232
1331
  hash.update(`record\0${entry.relativePath}\0${entry.source.length}\0${entry.source}`);
1332
+ }
1333
+ for (const entry of [...loaded.entries].sort((a, b) => a.relativePath.localeCompare(b.relativePath))) {
1233
1334
  const definition = loaded.model.resources[entry.record?.type];
1234
1335
  if (!definition) continue;
1235
1336
  for (const item of markdownEntries(loaded.model, entry.record).sort((a, b) => a.path.localeCompare(b.path))) {
1236
1337
  try {
1237
- const source = await readFile(resolveDataPath(loaded.root, item.path), "utf8");
1238
- hash.update(`markdown\0${item.path}\0${source.length}\0${source}`);
1239
- } catch {
1338
+ const resolvedPath = resolveDataPath(loaded.root, item.path);
1339
+ const file = await stat(resolvedPath);
1340
+ if (!file.isFile()) throw new Error("Markdown path is not a file.");
1341
+ const content = await fingerprintFileContent(resolvedPath, file, options, fingerprintBudget);
1342
+ hash.update(`markdown\0${item.path}\0${content.size}\0${content.digest}\0`);
1343
+ } catch (error) {
1344
+ if (error?.code === "FILEGRC_FINGERPRINT_BUDGET") throw error;
1240
1345
  hash.update(`markdown-missing\0${item.path}\0`);
1241
1346
  }
1242
1347
  }
@@ -1247,9 +1352,18 @@ export async function fingerprintWorkspace(input = process.cwd()) {
1247
1352
  const paths = Array.isArray(value) ? value : value === undefined || value === null ? [] : [value];
1248
1353
  for (const path of [...paths].sort()) {
1249
1354
  try {
1250
- const file = await stat(resolveDataPath(loaded.root, path));
1251
- hash.update(`data-path\0${path}\0${file.isFile() ? "file" : "other"}\0`);
1252
- } catch {
1355
+ const resolvedPath = resolveDataPath(loaded.root, path);
1356
+ const file = await stat(resolvedPath);
1357
+ if (file.isFile()) {
1358
+ const identity = `${file.dev}:${file.ino}:${file.mode}:${file.size}:${file.mtimeMs}:${file.ctimeMs}`;
1359
+ let content = options.fileDigestCache?.get(resolvedPath);
1360
+ if (!content || content.identity !== identity) {
1361
+ content = await fingerprintFileContent(resolvedPath, file, options, fingerprintBudget);
1362
+ }
1363
+ hash.update(`data-path\0${path}\0file\0${content.size}\0${content.digest}\0`);
1364
+ } else hash.update(`data-path\0${path}\0other\0`);
1365
+ } catch (error) {
1366
+ if (error?.code === "FILEGRC_FINGERPRINT_BUDGET") throw error;
1253
1367
  hash.update(`data-path-missing\0${path}\0`);
1254
1368
  }
1255
1369
  }
@@ -1258,6 +1372,41 @@ export async function fingerprintWorkspace(input = process.cwd()) {
1258
1372
  return { fingerprint: hash.digest("hex"), loaded };
1259
1373
  }
1260
1374
 
1375
+ async function fingerprintFileContent(path, file, options, budget) {
1376
+ const identity = `${file.dev}:${file.ino}:${file.mode}:${file.size}:${file.mtimeMs}:${file.ctimeMs}`;
1377
+ const cached = options.fileDigestCache?.get(path);
1378
+ if (cached?.identity === identity) return cached;
1379
+ assertFingerprintBudget(options, budget.uncachedBytes + file.size);
1380
+ budget.uncachedBytes += file.size;
1381
+ const contentHash = createHash("sha256");
1382
+ let size = 0;
1383
+ await fingerprintFileReadObserver?.(path);
1384
+ for await (const chunk of createReadStream(path)) {
1385
+ assertFingerprintBudget(options, budget.uncachedBytes - file.size + size + chunk.length);
1386
+ size += chunk.length;
1387
+ contentHash.update(chunk);
1388
+ }
1389
+ const content = { identity, size, digest: contentHash.digest("hex") };
1390
+ if (options.fileDigestCache?.size < 20_000 || options.fileDigestCache?.has(path)) {
1391
+ options.fileDigestCache.set(path, content);
1392
+ }
1393
+ return content;
1394
+ }
1395
+
1396
+ function assertFingerprintBudget(options, uncachedBytes) {
1397
+ if (options.deadlineAt !== undefined && performance.now() >= options.deadlineAt) {
1398
+ const error = new Error("Workspace attachment verification exceeded its time limit. Reload and try again.");
1399
+ error.code = "FILEGRC_FINGERPRINT_BUDGET";
1400
+ throw error;
1401
+ }
1402
+ if (options.maxUncachedFileBytes !== undefined
1403
+ && uncachedBytes > options.maxUncachedFileBytes) {
1404
+ const error = new Error("Workspace file verification exceeded its byte limit. Reload and try again.");
1405
+ error.code = "FILEGRC_FINGERPRINT_BUDGET";
1406
+ throw error;
1407
+ }
1408
+ }
1409
+
1261
1410
  function validateImplementedControlSchedules(record, obligationsByControl, path, diagnostics) {
1262
1411
  if (record.type !== "control" || record.status !== "implemented") return;
1263
1412
  const schedules = obligationsByControl.get(record.id) || [];
package/src/web.js CHANGED
@@ -95,6 +95,7 @@ let repositorySyncPollInFlight = false;
95
95
  let mutationStateRefreshInFlight = false;
96
96
  let mutationStateRefreshTimer = null;
97
97
  let programSelectionGeneration = 0;
98
+ let expiredStateRefresh = null;
98
99
 
99
100
  start().catch((error) => {
100
101
  root.innerHTML = '<main class="fatal"><h1>Could Not Load the Workspace</h1><pre></pre></main>';
@@ -173,7 +174,7 @@ function desiredStateSections(route) {
173
174
  const sections = new Set(["repository", ...blockingStateSections(route)]);
174
175
  if (route.name === "list" && (["requirement", "requirement-mapping"].includes(route.type) || state.model.collectionReviews?.[route.type])) sections.add("program");
175
176
  if (route.name === "detail" && ["policy", "document", "training", "control", "component", "requirement-mapping", "retention-schedule-item"].includes(route.type)) sections.add("program");
176
- if (route.name === "detail" && ["policy", "document", "framework", "requirement", "commitment", "system", "component", "vendor", "information-type", "source-coverage", "requirement-mapping", "retention-schedule-item"].includes(route.type)) sections.add("workflow");
177
+ if (route.name === "detail") sections.add("workflow");
177
178
  if (route.name === "detail" && ["obligation", "action-item", "obligation-event"].includes(route.type)) sections.add("obligations");
178
179
  if (route.name === "detail" && route.type === "audit") sections.add("audits");
179
180
  return [...sections];
@@ -193,14 +194,22 @@ function loadStateForRoute() {
193
194
  for (const section of desiredStateSections(route)) loadStateSection(section);
194
195
  }
195
196
 
196
- function loadStateSection(section) {
197
+ function loadStateSection(section, refreshExpired = true) {
197
198
  if (!state.stateToken || state.sections?.[section] === "complete") return Promise.resolve();
198
199
  if (stateSectionRequests.has(section)) return stateSectionRequests.get(section);
199
200
  state.sections[section] = "loading";
200
201
  const token = state.stateToken;
201
202
  const programQuery = state.selectedProgramId ? "&programId=" + encodeURIComponent(state.selectedProgramId) : "";
202
- const request = fetchJson("/api/state/" + encodeURIComponent(section) + "?token=" + encodeURIComponent(token) + programQuery)
203
- .then((result) => {
203
+ const request = localFetch("/api/state/" + encodeURIComponent(section) + "?token=" + encodeURIComponent(token) + programQuery)
204
+ .then(async (response) => {
205
+ if (response.status === 409 && refreshExpired && state.stateToken === token) {
206
+ await refreshExpiredAppState(token);
207
+ render();
208
+ if (stateSectionRequests.get(section) === request) stateSectionRequests.delete(section);
209
+ return loadStateSection(section, false);
210
+ }
211
+ if (!response.ok) throw new Error(await responseMessage(response));
212
+ const result = await response.json();
204
213
  if (state.stateToken !== result.stateToken) return;
205
214
  Object.assign(state, result.state);
206
215
  state.sections[section] = "complete";
@@ -992,10 +1001,12 @@ function workflowItemHref(item) {
992
1001
  if (source && ["assigned-work", "obligation-occurrence"].includes(item.kind)) {
993
1002
  return "#/stage/run?work=" + encodeURIComponent(source.type + ":" + source.id);
994
1003
  }
995
- const commands = [
996
- ...(item.actions || []).map((action) => action.command),
997
- item.nextAction?.command
998
- ].filter(Boolean);
1004
+ const actions = [...(item.actions || []), item.nextAction].filter(Boolean);
1005
+ const reconciliationAction = actions.find((action) => action.kind === "reconcile-transition");
1006
+ if (reconciliationAction?.candidateId) {
1007
+ return "#/stage/run?reconcile=" + encodeURIComponent(reconciliationAction.candidateId);
1008
+ }
1009
+ const commands = actions.map((action) => action.command).filter(Boolean);
999
1010
  if (item.createResourceType && state.model.resources[item.createResourceType]) {
1000
1011
  const params = new URLSearchParams({ new: "1" });
1001
1012
  if (item.title) params.set("title", item.createResourceType === "commitment"
@@ -1603,6 +1614,16 @@ function renderObligations(main, params = new URLSearchParams()) {
1603
1614
  if (requestedEvent) main.querySelector('[data-start-event="' + CSS.escape(requestedEvent) + '"]')?.click();
1604
1615
  });
1605
1616
  }
1617
+ const requestedReconciliation = params.get("reconcile");
1618
+ if (requestedReconciliation) {
1619
+ queueMicrotask(() => {
1620
+ const candidate = state.reconciliation?.candidates?.find((item) => (
1621
+ item.transitionFingerprint === requestedReconciliation
1622
+ || item.id === requestedReconciliation
1623
+ ));
1624
+ if (candidate) openReconciliationConfirmation(candidate);
1625
+ });
1626
+ }
1606
1627
  const requestedWork = params.get("work");
1607
1628
  if (requestedWork) {
1608
1629
  queueMicrotask(() => {
@@ -2909,17 +2930,35 @@ function evidenceAttachmentPanel(entry) {
2909
2930
  }
2910
2931
 
2911
2932
  async function loadResourceDetail(type, id) {
2912
- const key = type + "\0" + id;
2933
+ const key = (state.stateToken || "live") + "\0" + type + "\0" + id;
2913
2934
  if (resourceDetailRequests.has(key)) return resourceDetailRequests.get(key);
2914
2935
  const request = (async () => {
2915
2936
  try {
2916
- const response = await localFetch("/api/resource/" + encodeURIComponent(type) + "/" + encodeURIComponent(id));
2917
- if (!response.ok) throw new Error(await responseMessage(response));
2918
- const detail = await response.json();
2937
+ let token = state.stateToken;
2938
+ let detail = null;
2939
+ for (let attempt = 0; attempt < 3; attempt += 1) {
2940
+ const tokenQuery = token ? "?token=" + encodeURIComponent(token) : "";
2941
+ const response = await localFetch("/api/resource/" + encodeURIComponent(type) + "/" + encodeURIComponent(id) + tokenQuery);
2942
+ if (response.status === 409 && token) {
2943
+ await refreshExpiredAppState(token);
2944
+ token = state.stateToken;
2945
+ continue;
2946
+ }
2947
+ if (!response.ok) throw new Error(await responseMessage(response));
2948
+ detail = await response.json();
2949
+ if (!token || (detail.stateToken === token && state.stateToken === token)) break;
2950
+ token = state.stateToken;
2951
+ detail = null;
2952
+ }
2953
+ if (!detail) throw new Error("The workspace state changed while loading this record. Reload and try again.");
2954
+ delete detail.stateToken;
2919
2955
  const index = state.resources.findIndex(({ record }) => record.type === type && record.id === id);
2920
2956
  if (index >= 0) state.resources[index] = detail;
2921
2957
  const route = parseRoute();
2922
- if (route.name === "detail" && route.type === type && route.id === id) render();
2958
+ if (route.name === "detail" && route.type === type && route.id === id) {
2959
+ render();
2960
+ loadStateForRoute();
2961
+ }
2923
2962
  } catch (error) {
2924
2963
  const route = parseRoute();
2925
2964
  if (route.name === "detail" && route.type === type && route.id === id) {
@@ -2927,13 +2966,52 @@ async function loadResourceDetail(type, id) {
2927
2966
  if (main) main.innerHTML = '<div class="page"><section class="panel"><div class="dialog-error" role="alert">' + esc(error.message) + '</div></section></div>';
2928
2967
  }
2929
2968
  } finally {
2930
- resourceDetailRequests.delete(key);
2969
+ for (const [requestKey, pending] of resourceDetailRequests) {
2970
+ if (pending === request) resourceDetailRequests.delete(requestKey);
2971
+ }
2931
2972
  }
2932
2973
  })();
2933
2974
  resourceDetailRequests.set(key, request);
2934
2975
  return request;
2935
2976
  }
2936
2977
 
2978
+ function refreshExpiredAppState(expectedToken) {
2979
+ if (expiredStateRefresh) {
2980
+ if (expiredStateRefresh.token === expectedToken) return expiredStateRefresh.promise;
2981
+ return expiredStateRefresh.promise
2982
+ .catch(() => {})
2983
+ .then(() => refreshExpiredAppState(expectedToken));
2984
+ }
2985
+ if (expectedToken && state.stateToken !== expectedToken) return Promise.resolve();
2986
+ const programQuery = state.selectedProgramId ? "?programId=" + encodeURIComponent(state.selectedProgramId) : "";
2987
+ const holder = { token: expectedToken, promise: null };
2988
+ const refresh = (async () => {
2989
+ const response = await fetch("/api/state/bootstrap" + programQuery);
2990
+ if (!response.ok) throw new Error(await responseMessage(response));
2991
+ const next = normalizeAppState(await response.json());
2992
+ if (expectedToken && state.stateToken !== expectedToken) return;
2993
+ rekeyResourceDetailRequests(state.stateToken, next.stateToken);
2994
+ state = next;
2995
+ stateSectionRequests.clear();
2996
+ })().finally(() => {
2997
+ if (expiredStateRefresh === holder) expiredStateRefresh = null;
2998
+ });
2999
+ holder.promise = refresh;
3000
+ expiredStateRefresh = holder;
3001
+ return refresh;
3002
+ }
3003
+
3004
+ function rekeyResourceDetailRequests(previousToken, nextToken) {
3005
+ if (!previousToken || !nextToken || previousToken === nextToken) return;
3006
+ const prefix = previousToken + "\0";
3007
+ for (const [key, request] of resourceDetailRequests) {
3008
+ if (!key.startsWith(prefix)) continue;
3009
+ const nextKey = nextToken + key.slice(previousToken.length);
3010
+ resourceDetailRequests.delete(key);
3011
+ if (!resourceDetailRequests.has(nextKey)) resourceDetailRequests.set(nextKey, request);
3012
+ }
3013
+ }
3014
+
2937
3015
  function issueSeed(type, source) {
2938
3016
  const owners = source.ownerIds || source.reviewerIds || source.assessorIds || source.testerIds || [];
2939
3017
  if (type === "finding") {
@@ -3208,6 +3286,67 @@ function openReconciliationDismissal(candidate) {
3208
3286
  });
3209
3287
  }
3210
3288
 
3289
+ function openReconciliationConfirmation(candidate) {
3290
+ if (document.querySelector('[data-reconciliation-confirmation="' + CSS.escape(candidate.transitionFingerprint) + '"]')) return;
3291
+ const writeDisabled = state.readOnly
3292
+ ? ' disabled title="Writes are not available in this repository state"'
3293
+ : "";
3294
+ const needsTimestamp = (candidate.requiredFacts || []).includes("occurredAt");
3295
+ const eventField = needsTimestamp
3296
+ ? '<label><span>Event time <small>your local time</small></span><input name="occurredAt" type="datetime-local" required value="' + esc(currentLocalDateTime()) + '"></label>'
3297
+ : '<label><span>Event date</span><input name="occurredOn" type="date" required value="' + esc(currentDate()) + '"></label>';
3298
+ const riskField = (candidate.requiredFacts || []).includes("riskLevel")
3299
+ ? '<label><span>Departure risk</span><select name="riskLevel" required><option value="normal">Normal</option><option value="high">High or involuntary</option></select></label>'
3300
+ : "";
3301
+ const dialog = document.createElement("dialog");
3302
+ dialog.className = "commit-dialog event-dialog";
3303
+ dialog.dataset.reconciliationConfirmation = candidate.transitionFingerprint;
3304
+ dialog.setAttribute("aria-labelledby", "reconciliation-confirmation-title");
3305
+ dialog.innerHTML = '<form><div class="dialog-head"><div><p class="kicker">Git transition review</p><h2 id="reconciliation-confirmation-title">' + esc(policyEventName(candidate.eventType)) + '</h2></div><button type="button" class="icon-button" aria-label="Close">×</button></div><p>' + esc(candidate.message) + '</p><section class="event-dialog-steps"><div><strong>' + esc(candidate.subject.title || candidate.subject.id) + '</strong><small>' + esc(candidate.sourcePath) + '</small></div></section>' + eventField + riskField + '<label><span>Workflow name <small>optional</small></span><input name="title" maxlength="200" placeholder="' + esc(policyEventName(candidate.eventType)) + '"' + writeDisabled + '></label>' + (state.readOnly ? '<p class="dialog-note">Open this workspace in the local writable renderer or use the CLI to confirm or dismiss this transition.</p>' : "") + '<div class="dialog-error" role="alert"></div><div class="save-status" role="status" aria-live="polite"></div><div class="dialog-actions"><button type="button" class="button" data-dismiss-candidate' + writeDisabled + '>Dismiss false positive</button><button type="button" class="button" data-dismiss-dialog>Cancel</button><button type="submit" class="button primary"' + writeDisabled + '>Confirm and add work</button></div></form>';
3306
+ document.body.append(dialog);
3307
+ dialog.showModal();
3308
+ dialog.querySelector(".icon-button").addEventListener("click", () => dialog.close());
3309
+ dialog.querySelector("[data-dismiss-dialog]").addEventListener("click", () => dialog.close());
3310
+ dialog.querySelector("[data-dismiss-candidate]").addEventListener("click", () => {
3311
+ dialog.close();
3312
+ openReconciliationDismissal(candidate);
3313
+ });
3314
+ dialog.addEventListener("close", () => dialog.remove());
3315
+ dialog.querySelector("form").addEventListener("submit", async (event) => {
3316
+ event.preventDefault();
3317
+ const form = event.currentTarget;
3318
+ if (!form.reportValidity()) return;
3319
+ try {
3320
+ setMutationBusy(dialog, true, "Confirming…", "Confirm and add work");
3321
+ const response = await localFetch("/api/reconciliation", {
3322
+ method: "POST",
3323
+ headers: { "content-type": "application/json" },
3324
+ body: JSON.stringify({
3325
+ candidateId: candidate.transitionFingerprint,
3326
+ occurredOn: form.elements.occurredOn?.value || undefined,
3327
+ occurredAt: form.elements.occurredAt?.value ? new Date(form.elements.occurredAt.value).toISOString() : undefined,
3328
+ riskLevel: form.elements.riskLevel?.value || undefined,
3329
+ title: form.elements.title.value,
3330
+ confirmed: true
3331
+ })
3332
+ });
3333
+ if (!response.ok) throw new Error(await responseMessage(response));
3334
+ const created = await response.json();
3335
+ policyEventFeedback = {
3336
+ name: policyEventName(candidate.eventType),
3337
+ taskCount: created.actions?.length || 0
3338
+ };
3339
+ applyMutationState(created);
3340
+ dialog.close();
3341
+ history.replaceState(null, "", "#/stage/run");
3342
+ render();
3343
+ } catch (error) {
3344
+ setMutationBusy(dialog, false, "", "Confirm and add work");
3345
+ dialog.querySelector(".dialog-error").textContent = error.message;
3346
+ }
3347
+ });
3348
+ }
3349
+
3211
3350
  async function runRepositoryGitAction(action) {
3212
3351
  const buttons = [...document.querySelectorAll("[data-git-action]")];
3213
3352
  const disabled = buttons.map((button) => button.disabled);
@@ -116,7 +116,7 @@ export async function validateWorkflowHistoryIntegrity(loaded, diagnostics) {
116
116
  entry.record.type === "exception"
117
117
  && historical.record.reportingRouteSetId
118
118
  && historical.commit !== commits.find((commit) => {
119
- try { return Number(JSON.parse(getFileAtRevision(loaded.root, commit, "data/workspace.json"))?.dataModelVersion) >= 10; } catch { return false; }
119
+ try { return Number(JSON.parse(getFileAtRevision(loaded.root, commit, "data/workspace.json"))?.dataModelVersion) >= 10; } catch (error) { rethrowGitDeadline(error); return false; }
120
120
  })
121
121
  ) {
122
122
  const records = recordsAtRevision(loaded, historical.commit);
@@ -146,7 +146,7 @@ export async function validateWorkflowHistoryIntegrity(loaded, diagnostics) {
146
146
  }
147
147
 
148
148
  const firstV10Commit = commits.find((commit) => {
149
- try { return Number(JSON.parse(getFileAtRevision(loaded.root, commit, "data/workspace.json"))?.dataModelVersion) >= 10; } catch { return false; }
149
+ try { return Number(JSON.parse(getFileAtRevision(loaded.root, commit, "data/workspace.json"))?.dataModelVersion) >= 10; } catch (error) { rethrowGitDeadline(error); return false; }
150
150
  });
151
151
  const protectedRouteSources = new Map();
152
152
  for (const [commit, records] of revisionRecords) {
@@ -307,7 +307,8 @@ function legacyCollectionReviewTransition(loaded, previous, current) {
307
307
  try {
308
308
  const workspace = JSON.parse(getFileAtRevision(loaded.root, current.commit, "data/workspace.json"));
309
309
  return Number(workspace?.dataModelVersion) < 9;
310
- } catch {
310
+ } catch (error) {
311
+ rethrowGitDeadline(error);
311
312
  return false;
312
313
  }
313
314
  }
@@ -319,7 +320,8 @@ function historicalState(loaded) {
319
320
  try {
320
321
  const workspace = JSON.parse(getFileAtRevision(loaded.root, commit, "data/workspace.json"));
321
322
  return Number(workspace?.dataModelVersion) >= 9;
322
- } catch {
323
+ } catch (error) {
324
+ rethrowGitDeadline(error);
323
325
  return false;
324
326
  }
325
327
  });
@@ -392,7 +394,8 @@ function legacyCollectionReview(loaded, historical) {
392
394
  let workspace;
393
395
  try {
394
396
  workspace = JSON.parse(getFileAtRevision(loaded.root, historical.commit, "data/workspace.json"));
395
- } catch {
397
+ } catch (error) {
398
+ rethrowGitDeadline(error);
396
399
  return false;
397
400
  }
398
401
  return Number(workspace?.dataModelVersion) < 9
@@ -408,7 +411,8 @@ function recordAtRevision(loaded, commit, id, identityHistory) {
408
411
  try {
409
412
  const record = JSON.parse(source);
410
413
  return record?.id === id ? { record, path: item.path } : null;
411
- } catch {
414
+ } catch (error) {
415
+ rethrowGitDeadline(error);
412
416
  return null;
413
417
  }
414
418
  }
@@ -454,7 +458,7 @@ async function validateReportingRouteAuthorityHistory(loaded, commits, revisionR
454
458
  ));
455
459
  if (!routeSets.length) return;
456
460
  const firstV10Commit = commits.find((commit) => {
457
- try { return Number(JSON.parse(getFileAtRevision(loaded.root, commit, "data/workspace.json"))?.dataModelVersion) >= 10; } catch { return false; }
461
+ try { return Number(JSON.parse(getFileAtRevision(loaded.root, commit, "data/workspace.json"))?.dataModelVersion) >= 10; } catch (error) { rethrowGitDeadline(error); return false; }
458
462
  });
459
463
  for (const route of routeSets) {
460
464
  const versions = changedVersions(route.id, commits, revisionRecords, historiesById);
@@ -733,7 +737,8 @@ function authorityAppointmentOverlapsRoute(appointment, route, workspaceId, time
733
737
  routeEnd = route.cancellation?.canceledAt
734
738
  ? currentCalendarDate(timezone, new Date(route.cancellation.canceledAt))
735
739
  : null;
736
- } catch {
740
+ } catch (error) {
741
+ rethrowGitDeadline(error);
737
742
  return false;
738
743
  }
739
744
  return (!routeEnd || appointment.startsOn <= routeEnd)
@@ -786,7 +791,8 @@ function historicalWorkspaceTimezoneAtRevision(loaded, commit) {
786
791
  if (typeof workspace?.timezone !== "string" || !workspace.timezone.trim()) return null;
787
792
  new Intl.DateTimeFormat("en", { timeZone: workspace.timezone }).format();
788
793
  return workspace.timezone;
789
- } catch {
794
+ } catch (error) {
795
+ rethrowGitDeadline(error);
790
796
  return null;
791
797
  }
792
798
  }
@@ -901,3 +907,7 @@ function integrityError(entry, message) {
901
907
  message
902
908
  };
903
909
  }
910
+
911
+ function rethrowGitDeadline(error) {
912
+ if (error?.code === "FILEGRC_GIT_DEADLINE") throw error;
913
+ }
package/src/workflow.js CHANGED
@@ -141,7 +141,9 @@ async function assessWorkflowUnmeasured(input, options = {}) {
141
141
  asOf,
142
142
  evaluatedAt,
143
143
  programId: programRecord.type === "program" ? programRecord.id : undefined,
144
- controlIds: options.auditId ? audits[0]?.controlIds : programRecord?.controlIds
144
+ controlIds: options.auditId ? audits[0]?.controlIds : programRecord?.controlIds,
145
+ historyDeadlineAt: options.historyDeadlineAt,
146
+ strictHistory: options.strictHistory
145
147
  });
146
148
  const guidedFindings = [
147
149
  ...programFindings(program),
@@ -732,7 +734,10 @@ async function assessPeriodHealth(loaded, options) {
732
734
  "component"
733
735
  ].includes(record.type));
734
736
  const paths = relevantEntries.map(({ relativePath }) => `data/${relativePath}`);
735
- const histories = getWorkspaceHistories(loaded.root, paths, 50);
737
+ const histories = getWorkspaceHistories(loaded.root, paths, 50, {
738
+ deadlineAt: options.historyDeadlineAt,
739
+ strict: options.strictHistory === true
740
+ });
736
741
  const allHistory = [...histories.values()].flat().filter(Boolean);
737
742
  const earliestCommitDate = allHistory
738
743
  .map(({ timestamp }) => timestamp?.slice(0, 10))
package/src/workspace.js CHANGED
@@ -14,11 +14,13 @@ async function loadWorkspaceUnmeasured(input) {
14
14
  const diagnostics = [];
15
15
  const files = await collectJsonFiles(dataRoot);
16
16
  const resources = [];
17
+ const sourceEntries = [];
17
18
 
18
19
  for (const path of files) {
19
20
  const relativePath = relative(dataRoot, path).split(sep).join("/");
20
21
  try {
21
22
  const source = await readFile(path, "utf8");
23
+ sourceEntries.push({ path, relativePath, source });
22
24
  const record = JSON.parse(source);
23
25
  resources.push({ record, path, relativePath, source });
24
26
  } catch (error) {
@@ -68,6 +70,7 @@ async function loadWorkspaceUnmeasured(input) {
68
70
  model,
69
71
  workspace: workspaceEntry?.record ?? null,
70
72
  entries: resources,
73
+ sourceEntries,
71
74
  resources: resources.map(({ record }) => record),
72
75
  diagnostics
73
76
  };