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/package.json +1 -1
- package/src/git.js +284 -46
- package/src/program-amendment.js +3 -2
- package/src/program-path.js +4 -4
- package/src/program-readiness.js +26 -7
- package/src/reconciliation.js +19 -9
- package/src/reporting-route-integrity.js +181 -0
- package/src/reporting-route-sets.js +157 -25
- package/src/server.js +356 -52
- package/src/state.js +18 -10
- package/src/validate.js +158 -9
- package/src/web.js +153 -14
- package/src/workflow-history-integrity.js +19 -9
- package/src/workflow.js +7 -2
- package/src/workspace.js +3 -0
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
|
-
|
|
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
|
-
|
|
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
|
|
1238
|
-
|
|
1239
|
-
|
|
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
|
|
1251
|
-
|
|
1252
|
-
|
|
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"
|
|
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 =
|
|
203
|
-
.then((
|
|
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
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
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
|
-
|
|
2917
|
-
|
|
2918
|
-
|
|
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)
|
|
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
|
|
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
|
};
|