filegrc 0.13.0 → 0.13.1
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/files.js +13 -5
- package/src/git.js +122 -25
- package/src/server.js +154 -68
- package/src/state.js +30 -5
- package/src/validate.js +51 -13
- package/src/web.js +140 -17
package/package.json
CHANGED
package/src/files.js
CHANGED
|
@@ -965,14 +965,22 @@ async function updateContentUnlocked(input, dataRelativePath, source, options) {
|
|
|
965
965
|
const path = resolveDataPath(loaded.root, dataRelativePath);
|
|
966
966
|
const previous = await readFile(path, "utf8");
|
|
967
967
|
assertRevision(previous, options.expectedRevision, "The Markdown file");
|
|
968
|
-
const
|
|
968
|
+
const deferValidation = workspaceValidationDeferred();
|
|
969
|
+
const before = deferValidation ? null : await validateWorkspace(loaded);
|
|
969
970
|
const nextSource = source.endsWith("\n") ? source : `${source}\n`;
|
|
970
971
|
await writeTextAtomic(path, nextSource);
|
|
971
972
|
try {
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
973
|
+
if (!deferValidation) {
|
|
974
|
+
const result = await validateWorkspace(loaded.root);
|
|
975
|
+
const introduced = newErrors(result, before);
|
|
976
|
+
if (introduced.length) throw new Error(formatWriteFailure(introduced, dataRelativePath));
|
|
977
|
+
}
|
|
978
|
+
return {
|
|
979
|
+
path,
|
|
980
|
+
dataRelativePath,
|
|
981
|
+
source: nextSource,
|
|
982
|
+
revision: contentRevision(nextSource)
|
|
983
|
+
};
|
|
976
984
|
} catch (error) {
|
|
977
985
|
await writeTextAtomic(path, previous);
|
|
978
986
|
throw error;
|
package/src/git.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { execFileSync, spawn } from "node:child_process";
|
|
2
2
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
3
3
|
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
-
import { closeSync, constants, existsSync, fstatSync, fsyncSync, lstatSync, openSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { closeSync, constants, existsSync, fstatSync, fsyncSync, lstatSync, openSync, readFileSync, readSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { rm } from "node:fs/promises";
|
|
6
6
|
import { devNull } from "node:os";
|
|
7
7
|
import { relative, resolve, sep } from "node:path";
|
|
@@ -23,6 +23,7 @@ const backgroundSynchronizations = new Map();
|
|
|
23
23
|
const browserRemotePrefetches = new Map();
|
|
24
24
|
const browserRemotePrefetchPromises = new Map();
|
|
25
25
|
const repositorySnapshotPromises = new Map();
|
|
26
|
+
const repositoryObjectFormats = new Map();
|
|
26
27
|
const gitCommandCaches = new AsyncLocalStorage();
|
|
27
28
|
const gitCommandDeadlines = new AsyncLocalStorage();
|
|
28
29
|
const gitCommandCacheBytes = new WeakMap();
|
|
@@ -30,6 +31,7 @@ let gitCommandInterceptor = null;
|
|
|
30
31
|
let gitSubprocessObserver = null;
|
|
31
32
|
let historicalBatchInterceptor = null;
|
|
32
33
|
let historicalRevisionReadObserver = null;
|
|
34
|
+
let workspaceBlobObjectIdOverride = null;
|
|
33
35
|
const BROWSER_REMOTE_PREFETCH_MAX_AGE_MS = 30_000;
|
|
34
36
|
const GIT_DEFAULT_TIMEOUT_MS = 10_000;
|
|
35
37
|
const GIT_REMOTE_TIMEOUT_MS = 30_000;
|
|
@@ -680,15 +682,50 @@ export function getFileObjectIdAtRevision(input, revision, relativePath) {
|
|
|
680
682
|
export function getWorkingFileObjectId(input, relativePath) {
|
|
681
683
|
if (!isSafeDataGitPath(relativePath)) return null;
|
|
682
684
|
const root = resolveWorkspaceRoot(input);
|
|
685
|
+
let descriptor;
|
|
683
686
|
try {
|
|
684
|
-
const
|
|
685
|
-
|
|
687
|
+
const objectFormat = repositoryObjectFormat(root);
|
|
688
|
+
if (!objectFormat) return null;
|
|
689
|
+
descriptor = openSync(resolve(root, relativePath), constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
|
|
690
|
+
const before = fstatSync(descriptor);
|
|
691
|
+
if (!before.isFile()) return null;
|
|
692
|
+
const hash = createHash(objectFormat).update(`blob ${before.size}\0`);
|
|
693
|
+
const buffer = Buffer.allocUnsafe(64 * 1024);
|
|
694
|
+
let position = 0;
|
|
695
|
+
while (position < before.size) {
|
|
696
|
+
const count = readSync(descriptor, buffer, 0, Math.min(buffer.length, before.size - position), position);
|
|
697
|
+
if (!count) return null;
|
|
698
|
+
hash.update(buffer.subarray(0, count));
|
|
699
|
+
position += count;
|
|
700
|
+
}
|
|
701
|
+
const after = fstatSync(descriptor);
|
|
702
|
+
if (
|
|
703
|
+
after.size !== before.size
|
|
704
|
+
|| after.dev !== before.dev
|
|
705
|
+
|| after.ino !== before.ino
|
|
706
|
+
|| after.mtimeMs !== before.mtimeMs
|
|
707
|
+
|| after.ctimeMs !== before.ctimeMs
|
|
708
|
+
) return null;
|
|
709
|
+
return hash.digest("hex");
|
|
686
710
|
} catch (error) {
|
|
687
711
|
rethrowGitDeadline(error);
|
|
688
712
|
return null;
|
|
713
|
+
} finally {
|
|
714
|
+
if (descriptor !== undefined) closeSync(descriptor);
|
|
689
715
|
}
|
|
690
716
|
}
|
|
691
717
|
|
|
718
|
+
function repositoryObjectFormat(root) {
|
|
719
|
+
if (repositoryObjectFormats.has(root)) return repositoryObjectFormats.get(root);
|
|
720
|
+
if (!tryGit(root, ["rev-parse", "--git-dir"])) return null;
|
|
721
|
+
const objectFormat = tryGit(root, ["config", "--get", "extensions.objectFormat"]) || "sha1";
|
|
722
|
+
if (!["sha1", "sha256"].includes(objectFormat)) {
|
|
723
|
+
throw new Error(`FileGRC does not support the repository object format "${objectFormat}".`);
|
|
724
|
+
}
|
|
725
|
+
repositoryObjectFormats.set(root, objectFormat);
|
|
726
|
+
return objectFormat;
|
|
727
|
+
}
|
|
728
|
+
|
|
692
729
|
export function getWorkspaceHistories(input, relativePaths, limitPerFile = 12, options = {}) {
|
|
693
730
|
const root = resolveWorkspaceRoot(input);
|
|
694
731
|
const wanted = new Set(relativePaths);
|
|
@@ -1415,8 +1452,8 @@ async function runTrunkMutationUnlocked(root, config, options, task) {
|
|
|
1415
1452
|
})));
|
|
1416
1453
|
subject = generatedCommitMessage(typeof options?.message === "function" ? options.message(result) : options?.message);
|
|
1417
1454
|
assertNoIgnoredAuthoritativeFiles(root);
|
|
1418
|
-
const beforeValidation = workspaceByteManifest(root);
|
|
1419
|
-
const validation = await validateWorkspace(root);
|
|
1455
|
+
const beforeValidation = measureTimingSync("workspace-manifest", () => workspaceByteManifest(root));
|
|
1456
|
+
const validation = await withGitCommandCache(new Map(), () => validateWorkspace(root));
|
|
1420
1457
|
if (!validation.ok) {
|
|
1421
1458
|
throw new Error(`The workspace has ${validation.counts.errors} validation ${validation.counts.errors === 1 ? "error" : "errors"}.`);
|
|
1422
1459
|
}
|
|
@@ -1426,9 +1463,9 @@ async function runTrunkMutationUnlocked(root, config, options, task) {
|
|
|
1426
1463
|
validation,
|
|
1427
1464
|
fingerprint: (await measureTiming("fingerprint", () => fingerprintWorkspace(validation.loaded))).fingerprint
|
|
1428
1465
|
};
|
|
1429
|
-
validatedManifest = workspaceByteManifest(root);
|
|
1466
|
+
validatedManifest = measureTimingSync("workspace-manifest", () => workspaceByteManifest(root));
|
|
1430
1467
|
assertWorkspaceManifestEqual(beforeValidation, validatedManifest);
|
|
1431
|
-
await assertNoOutsideWorktreeChangesAsync(root);
|
|
1468
|
+
await measureTiming("outside-worktree-check", () => assertNoOutsideWorktreeChangesAsync(root));
|
|
1432
1469
|
} catch (error) {
|
|
1433
1470
|
throw new Error(`${error.message} FileGRC preserved every current file instead of guessing which edits it owns. Review the Git diff; later browser mutations are blocked until the worktree is reconciled.`);
|
|
1434
1471
|
}
|
|
@@ -1454,13 +1491,13 @@ async function runTrunkMutationUnlocked(root, config, options, task) {
|
|
|
1454
1491
|
}
|
|
1455
1492
|
assertValidGitIdentity(root);
|
|
1456
1493
|
assertNoWorkspaceContentFilters(root);
|
|
1457
|
-
await assertNoOutsideWorktreeChangesAsync(root);
|
|
1494
|
+
await measureTiming("outside-worktree-check", () => assertNoOutsideWorktreeChangesAsync(root));
|
|
1458
1495
|
let commit;
|
|
1459
1496
|
let indexReconciled;
|
|
1460
1497
|
try {
|
|
1461
1498
|
const expectedRef = `refs/heads/${config.authoritativeBranch}`;
|
|
1462
1499
|
assertExpectedCheckout(root, expectedRef, synchronized.currentCommit);
|
|
1463
|
-
validatedManifest = writeWorkspaceManifestObjects(root, validatedManifest);
|
|
1500
|
+
validatedManifest = measureTimingSync("workspace-manifest-objects", () => writeWorkspaceManifestObjects(root, validatedManifest));
|
|
1464
1501
|
({ commit, indexReconciled } = await measureTiming("commit", () => commitValidatedIndexAsync(root, subject, validatedManifest, {
|
|
1465
1502
|
expectedParent: synchronized.currentCommit,
|
|
1466
1503
|
expectedRef
|
|
@@ -2261,6 +2298,8 @@ function assertNoIgnoredAuthoritativeFiles(root) {
|
|
|
2261
2298
|
|
|
2262
2299
|
function workspaceByteManifest(root) {
|
|
2263
2300
|
assertNoHiddenIndexEntries(root);
|
|
2301
|
+
const objectFormat = repositoryObjectFormat(root);
|
|
2302
|
+
if (!objectFormat) throw new Error("FileGRC could not determine the Git repository object format.");
|
|
2264
2303
|
const paths = [...new Set(nulFields(gitRaw(root, [
|
|
2265
2304
|
"ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", "."
|
|
2266
2305
|
])))].sort();
|
|
@@ -2298,9 +2337,36 @@ function workspaceByteManifest(root) {
|
|
|
2298
2337
|
) {
|
|
2299
2338
|
throw new Error(`FileGRC will not commit workspace entry "${path}" because it changed while its bytes were being inspected.`);
|
|
2300
2339
|
}
|
|
2301
|
-
const
|
|
2302
|
-
const
|
|
2303
|
-
|
|
2340
|
+
const objectHash = createHash(objectFormat).update(`blob ${opened.size}\0`);
|
|
2341
|
+
const byteHash = createHash("sha256");
|
|
2342
|
+
const buffer = Buffer.allocUnsafe(64 * 1024);
|
|
2343
|
+
let position = 0;
|
|
2344
|
+
while (position < opened.size) {
|
|
2345
|
+
const count = readSync(descriptor, buffer, 0, Math.min(buffer.length, opened.size - position), position);
|
|
2346
|
+
if (!count) throw new Error(`FileGRC could not read complete workspace entry "${path}".`);
|
|
2347
|
+
const chunk = buffer.subarray(0, count);
|
|
2348
|
+
objectHash.update(chunk);
|
|
2349
|
+
byteHash.update(chunk);
|
|
2350
|
+
position += count;
|
|
2351
|
+
}
|
|
2352
|
+
const after = fstatSync(descriptor);
|
|
2353
|
+
if (
|
|
2354
|
+
after.size !== opened.size
|
|
2355
|
+
|| after.dev !== opened.dev
|
|
2356
|
+
|| after.ino !== opened.ino
|
|
2357
|
+
|| after.mtimeMs !== opened.mtimeMs
|
|
2358
|
+
|| after.ctimeMs !== opened.ctimeMs
|
|
2359
|
+
) {
|
|
2360
|
+
throw new Error(`FileGRC will not commit workspace entry "${path}" because it changed while its bytes were being inspected.`);
|
|
2361
|
+
}
|
|
2362
|
+
const calculatedObjectId = objectHash.digest("hex");
|
|
2363
|
+
const objectId = workspaceBlobObjectIdOverride?.(null, objectFormat, path) ?? calculatedObjectId;
|
|
2364
|
+
return [path, {
|
|
2365
|
+
objectId,
|
|
2366
|
+
byteDigest: byteHash.digest("hex"),
|
|
2367
|
+
size: opened.size,
|
|
2368
|
+
mode: opened.mode & 0o111 ? "100755" : "100644"
|
|
2369
|
+
}];
|
|
2304
2370
|
} finally {
|
|
2305
2371
|
closeSync(descriptor);
|
|
2306
2372
|
}
|
|
@@ -2316,24 +2382,42 @@ function assertNoHiddenIndexEntries(root) {
|
|
|
2316
2382
|
}
|
|
2317
2383
|
}
|
|
2318
2384
|
|
|
2319
|
-
function
|
|
2320
|
-
|
|
2385
|
+
function writeWorkspaceManifestObjects(root, manifest) {
|
|
2386
|
+
const valuesByObjectId = new Map();
|
|
2387
|
+
for (const value of manifest.values()) {
|
|
2388
|
+
if (!value.objectId) continue;
|
|
2389
|
+
const prior = valuesByObjectId.get(value.objectId);
|
|
2390
|
+
if (prior && (prior.size !== value.size || prior.byteDigest !== value.byteDigest)) {
|
|
2391
|
+
throw new Error(`Git object ${value.objectId} identifies different validated workspace bytes.`);
|
|
2392
|
+
}
|
|
2393
|
+
valuesByObjectId.set(value.objectId, value);
|
|
2394
|
+
}
|
|
2395
|
+
if (!valuesByObjectId.size) return manifest;
|
|
2396
|
+
const entries = [...manifest].filter(([, value]) => value.objectId);
|
|
2397
|
+
const topLevel = git(root, ["rev-parse", "--show-toplevel"]);
|
|
2398
|
+
const workspacePrefix = relative(topLevel, root).split(sep).join("/");
|
|
2399
|
+
if (workspacePrefix === ".." || workspacePrefix.startsWith("../")) {
|
|
2400
|
+
throw new Error("The FileGRC workspace is outside its Git repository.");
|
|
2401
|
+
}
|
|
2402
|
+
const written = observedExecFileSync("git", ["hash-object", "-w", "--stdin-paths"], {
|
|
2321
2403
|
cwd: root,
|
|
2322
|
-
input:
|
|
2404
|
+
input: `${entries.map(([path]) => workspacePrefix ? `${workspacePrefix}/${path}` : path).join("\n")}\n`,
|
|
2323
2405
|
encoding: "utf8",
|
|
2324
2406
|
stdio: ["pipe", "pipe", "ignore"],
|
|
2325
2407
|
timeout: 30_000,
|
|
2326
2408
|
maxBuffer: 20_000_000,
|
|
2327
2409
|
env: gitEnvironment()
|
|
2328
|
-
}).trim();
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2410
|
+
}).trim().split("\n");
|
|
2411
|
+
if (written.length !== entries.length) {
|
|
2412
|
+
throw new Error("Git returned an unexpected number of workspace objects.");
|
|
2413
|
+
}
|
|
2414
|
+
for (let index = 0; index < entries.length; index += 1) {
|
|
2415
|
+
if (written[index] !== entries[index][1].objectId) {
|
|
2416
|
+
throw new Error(`Git wrote an unexpected object while preparing validated workspace bytes for commit.`);
|
|
2417
|
+
}
|
|
2418
|
+
}
|
|
2419
|
+
assertWorkspaceManifestEqual(manifest, workspaceByteManifest(root));
|
|
2420
|
+
return manifest;
|
|
2337
2421
|
}
|
|
2338
2422
|
|
|
2339
2423
|
function assertWorkspaceManifestEqual(expected, current) {
|
|
@@ -2346,7 +2430,11 @@ function workspaceManifestsEqual(expected, current) {
|
|
|
2346
2430
|
return expected.size === current.size
|
|
2347
2431
|
&& [...expected].every(([path, value]) => {
|
|
2348
2432
|
const other = current.get(path);
|
|
2349
|
-
return other
|
|
2433
|
+
return other
|
|
2434
|
+
&& value.objectId === other.objectId
|
|
2435
|
+
&& value.mode === other.mode
|
|
2436
|
+
&& value.size === other.size
|
|
2437
|
+
&& value.byteDigest === other.byteDigest;
|
|
2350
2438
|
});
|
|
2351
2439
|
}
|
|
2352
2440
|
|
|
@@ -2621,6 +2709,15 @@ export function setHistoricalRevisionReadObserverForTests(observer) {
|
|
|
2621
2709
|
return () => { historicalRevisionReadObserver = previous; };
|
|
2622
2710
|
}
|
|
2623
2711
|
|
|
2712
|
+
export function setWorkspaceBlobObjectIdOverrideForTests(override) {
|
|
2713
|
+
if (override !== null && typeof override !== "function") {
|
|
2714
|
+
throw new TypeError("The workspace blob object ID override must be a function or null.");
|
|
2715
|
+
}
|
|
2716
|
+
const previous = workspaceBlobObjectIdOverride;
|
|
2717
|
+
workspaceBlobObjectIdOverride = override;
|
|
2718
|
+
return () => { workspaceBlobObjectIdOverride = previous; };
|
|
2719
|
+
}
|
|
2720
|
+
|
|
2624
2721
|
export function runGitCommand(cwd, args, options = {}) {
|
|
2625
2722
|
if (gitCommandInterceptor) {
|
|
2626
2723
|
return Promise.resolve().then(() => gitCommandInterceptor({
|
package/src/server.js
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
withGitCommandDeadline
|
|
38
38
|
} from "./git.js";
|
|
39
39
|
import { normalizeResourceMutation, serializeWorkspaceMutation } from "./mutation.js";
|
|
40
|
+
import { renderMarkdown } from "./markdown.js";
|
|
40
41
|
import {
|
|
41
42
|
approveReportingRouteSet,
|
|
42
43
|
assessReportingRouteSets,
|
|
@@ -64,7 +65,7 @@ import { activatePolicies } from "./policy-activation.js";
|
|
|
64
65
|
import { resolveProgram } from "./program.js";
|
|
65
66
|
import { applyReconciliation, dismissReconciliation, planReconciliation } from "./reconciliation.js";
|
|
66
67
|
import { resourceReviewRevisions } from "./retention.js";
|
|
67
|
-
import { createAppBootstrap, createAppState, createAppStateSection, createResourceDetail } from "./state.js";
|
|
68
|
+
import { createAppBootstrap, createAppState, createAppStateSection, createResourceDetail, createResourceHistory } from "./state.js";
|
|
68
69
|
import { setupWorkspace } from "./setup.js";
|
|
69
70
|
import { collectTimings, measureTiming, timingEnabled } from "./timing.js";
|
|
70
71
|
import { fingerprintWorkspace } from "./validate.js";
|
|
@@ -86,6 +87,10 @@ const STATE_SECTION_GIT_DEADLINE_MS = 10_000;
|
|
|
86
87
|
export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
87
88
|
const stateSessions = new Map();
|
|
88
89
|
const fileDigestCache = new Map();
|
|
90
|
+
let bootstrapSnapshotPromise = null;
|
|
91
|
+
let stateInvalidationGeneration = 0;
|
|
92
|
+
let activeStateMutations = 0;
|
|
93
|
+
let stateMutationWaiters = [];
|
|
89
94
|
return createHttpServer(async (request, response) => {
|
|
90
95
|
const requestStarted = performance.now();
|
|
91
96
|
if (timingEnabled()) {
|
|
@@ -107,7 +112,22 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
|
107
112
|
const requestOptions = {
|
|
108
113
|
...options,
|
|
109
114
|
programId: url.searchParams.get("programId") || undefined,
|
|
110
|
-
|
|
115
|
+
fastResponse: prefersFastMutation(request),
|
|
116
|
+
beginStateMutation: () => {
|
|
117
|
+
stateInvalidationGeneration += 1;
|
|
118
|
+
activeStateMutations += 1;
|
|
119
|
+
invalidateStateSessions(stateSessions);
|
|
120
|
+
},
|
|
121
|
+
endStateMutation: () => {
|
|
122
|
+
stateInvalidationGeneration += 1;
|
|
123
|
+
activeStateMutations -= 1;
|
|
124
|
+
invalidateStateSessions(stateSessions);
|
|
125
|
+
if (activeStateMutations === 0) {
|
|
126
|
+
const waiters = stateMutationWaiters;
|
|
127
|
+
stateMutationWaiters = [];
|
|
128
|
+
for (const resolve of waiters) resolve();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
111
131
|
};
|
|
112
132
|
if (["POST", "PUT", "DELETE"].includes(request.method) && !sameOrigin(request)) {
|
|
113
133
|
return json(response, 403, { error: "Cross-origin writes are not allowed." });
|
|
@@ -133,45 +153,51 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
|
133
153
|
return json(response, 403, { error: "Cross-origin state requests are not allowed." });
|
|
134
154
|
}
|
|
135
155
|
const deadlineAt = performance.now() + STATE_SECTION_GIT_DEADLINE_MS;
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
156
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
157
|
+
if (activeStateMutations > 0) {
|
|
158
|
+
await awaitWithinDeadline(new Promise((resolve) => stateMutationWaiters.push(resolve)), deadlineAt);
|
|
159
|
+
}
|
|
160
|
+
const generation = stateInvalidationGeneration;
|
|
161
|
+
if (!bootstrapSnapshotPromise) {
|
|
162
|
+
bootstrapSnapshotPromise = withGitCommandDeadline(deadlineAt, () => stableStateSnapshot(input, {
|
|
139
163
|
fileDigestCache,
|
|
140
164
|
deadlineAt
|
|
141
|
-
})
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
])
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
stateSessions.
|
|
165
|
+
})).finally(() => {
|
|
166
|
+
bootstrapSnapshotPromise = null;
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
const [snapshot, repositorySignature] = await awaitWithinDeadline(bootstrapSnapshotPromise, deadlineAt);
|
|
170
|
+
const loaded = snapshot.loaded;
|
|
171
|
+
const token = randomUUID();
|
|
172
|
+
const session = {
|
|
173
|
+
loaded,
|
|
174
|
+
fingerprint: snapshot.fingerprint,
|
|
175
|
+
repositorySignature,
|
|
176
|
+
fileDigestCache,
|
|
177
|
+
generatedAt: new Date().toISOString(),
|
|
178
|
+
expiresAt: Date.now() + STATE_SESSION_MAX_AGE_MS,
|
|
179
|
+
revoked: false,
|
|
180
|
+
promises: new Map(),
|
|
181
|
+
verificationPromise: null,
|
|
182
|
+
gitCommandCache: new Map()
|
|
183
|
+
};
|
|
184
|
+
const state = await createAppBootstrap(loaded, {
|
|
185
|
+
generatedAt: session.generatedAt,
|
|
186
|
+
programId: url.searchParams.get("programId") || undefined
|
|
187
|
+
});
|
|
188
|
+
if (activeStateMutations > 0 || generation !== stateInvalidationGeneration) continue;
|
|
189
|
+
pruneStateSessions(stateSessions);
|
|
190
|
+
stateSessions.set(token, session);
|
|
191
|
+
while (stateSessions.size > MAX_STATE_SESSIONS) {
|
|
192
|
+
const oldestToken = stateSessions.keys().next().value;
|
|
193
|
+
const oldestSession = stateSessions.get(oldestToken);
|
|
194
|
+
if (oldestSession) oldestSession.revoked = true;
|
|
195
|
+
stateSessions.delete(oldestToken);
|
|
196
|
+
}
|
|
197
|
+
state.stateToken = token;
|
|
198
|
+
return json(response, 200, state);
|
|
168
199
|
}
|
|
169
|
-
|
|
170
|
-
generatedAt: session.generatedAt,
|
|
171
|
-
programId: url.searchParams.get("programId") || undefined
|
|
172
|
-
});
|
|
173
|
-
state.stateToken = token;
|
|
174
|
-
return json(response, 200, state);
|
|
200
|
+
throw stateSessionExpiredError();
|
|
175
201
|
}
|
|
176
202
|
if (request.method === "GET" && url.pathname.startsWith("/api/state/")) {
|
|
177
203
|
const section = url.pathname.slice("/api/state/".length);
|
|
@@ -526,29 +552,35 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
|
526
552
|
if (request.method === "POST" && url.pathname === "/api/policy-activations") {
|
|
527
553
|
const payload = await readJson(request);
|
|
528
554
|
const result = await browserMutation(input, requestOptions, {
|
|
529
|
-
message: (activation) => `Activate ${activation.policyIds.length} ${activation.policyIds.length === 1 ? "Policy" : "Policies"}
|
|
555
|
+
message: (activation) => `Activate ${activation.policyIds.length} ${activation.policyIds.length === 1 ? "Policy" : "Policies"}`,
|
|
556
|
+
prefetchToken: payload.prefetchToken
|
|
530
557
|
}, () => activatePolicies(input, { ...payload, confirmed: true }));
|
|
531
558
|
return json(response, 200, result);
|
|
532
559
|
}
|
|
533
560
|
if (request.method === "POST" && url.pathname === "/api/document-activations") {
|
|
534
561
|
const payload = await readJson(request);
|
|
535
562
|
const result = await browserMutation(input, requestOptions, {
|
|
536
|
-
message: (activation) => `Activate ${activation.documentIds.length} governed ${activation.documentIds.length === 1 ? "Document" : "Documents"}
|
|
563
|
+
message: (activation) => `Activate ${activation.documentIds.length} governed ${activation.documentIds.length === 1 ? "Document" : "Documents"}`,
|
|
564
|
+
prefetchToken: payload.prefetchToken
|
|
537
565
|
}, () => activateDocuments(input, { ...payload, confirmed: true }));
|
|
538
566
|
return json(response, 200, result);
|
|
539
567
|
}
|
|
540
568
|
if (request.method === "POST" && url.pathname === "/api/governed-content-activations") {
|
|
541
569
|
const payload = await readJson(request);
|
|
542
570
|
const result = await browserMutation(input, requestOptions, {
|
|
543
|
-
message: (activation) => `Activate ${activation.resourceIds.length} governed-content ${activation.resourceIds.length === 1 ? "record" : "records"}
|
|
571
|
+
message: (activation) => `Activate ${activation.resourceIds.length} governed-content ${activation.resourceIds.length === 1 ? "record" : "records"}`,
|
|
572
|
+
prefetchToken: payload.prefetchToken
|
|
544
573
|
}, () => activateGovernedContent(input, { ...payload, confirmed: true }));
|
|
545
574
|
return json(response, 200, result);
|
|
546
575
|
}
|
|
547
576
|
if (request.method === "POST" && url.pathname === "/api/resources") {
|
|
548
|
-
const
|
|
577
|
+
const requestPayload = await readJson(request);
|
|
578
|
+
const payload = normalizeResourceMutation(requestPayload);
|
|
549
579
|
const { record } = payload;
|
|
550
580
|
const result = await browserMutation(input, requestOptions, {
|
|
551
|
-
message: () => `Create ${resourceTypeLabel(record.type)}: ${record.title || record.id}
|
|
581
|
+
message: () => `Create ${resourceTypeLabel(record.type)}: ${record.title || record.id}`,
|
|
582
|
+
fastResponse: prefersFastMutation(request),
|
|
583
|
+
prefetchToken: requestPayload.prefetchToken
|
|
552
584
|
}, () => createResource(input, record, { content: payload.content }));
|
|
553
585
|
return json(response, 201, result);
|
|
554
586
|
}
|
|
@@ -566,19 +598,12 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
|
566
598
|
return json(response, 200, await manualGitResultWithState(input, requestOptions, () => pushWorkspace(input)));
|
|
567
599
|
}
|
|
568
600
|
if (request.method === "POST" && url.pathname === "/api/git/retry-sync") {
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
result = await retryBrowserSync(input, {
|
|
601
|
+
const result = await manualGitResultWithState(input, requestOptions, () => (
|
|
602
|
+
retryBrowserSync(input, {
|
|
572
603
|
allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites
|
|
573
|
-
})
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
}
|
|
577
|
-
const state = await createAppState(input, {
|
|
578
|
-
allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites,
|
|
579
|
-
includeDetails: false
|
|
580
|
-
});
|
|
581
|
-
return json(response, 200, { ...result, state });
|
|
604
|
+
})
|
|
605
|
+
));
|
|
606
|
+
return json(response, 200, result);
|
|
582
607
|
}
|
|
583
608
|
if (request.method === "GET" && url.pathname === "/api/git/sync-status") {
|
|
584
609
|
const git = { ...await getRepositorySnapshot(input) };
|
|
@@ -607,11 +632,16 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
|
607
632
|
if (request.method === "PUT" && url.pathname === "/api/content") {
|
|
608
633
|
const payload = await readJson(request);
|
|
609
634
|
const result = await browserMutation(input, requestOptions, {
|
|
610
|
-
message: () => `Update content: ${payload.path}
|
|
635
|
+
message: () => `Update content: ${payload.path}`,
|
|
636
|
+
fastResponse: prefersFastMutation(request),
|
|
637
|
+
prefetchToken: payload.prefetchToken
|
|
611
638
|
}, () => updateContent(input, payload.path, payload.source, {
|
|
612
639
|
expectedRevision: requireRevision(payload.revision, `content/${payload.path}`)
|
|
613
640
|
}));
|
|
614
|
-
return json(response, 200,
|
|
641
|
+
return json(response, 200, {
|
|
642
|
+
...result,
|
|
643
|
+
...(result.stateRefresh ? { html: renderMarkdown(result.source) } : {})
|
|
644
|
+
});
|
|
615
645
|
}
|
|
616
646
|
const match = /^\/api\/resource\/([^/]+)\/([^/]+)$/.exec(url.pathname);
|
|
617
647
|
if (match) {
|
|
@@ -624,9 +654,17 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
|
624
654
|
const session = token ? stateSessions.get(token) : null;
|
|
625
655
|
if (token && !session) return json(response, 409, { error: "The workspace state expired. Reload it and try again." });
|
|
626
656
|
const includeWorkflow = url.searchParams.get("workflow") === "true";
|
|
657
|
+
const historyOnly = url.searchParams.get("history") === "only";
|
|
658
|
+
if (historyOnly) {
|
|
659
|
+
const history = session
|
|
660
|
+
? await loadStateSessionResourceHistory(session, token, type, id, options)
|
|
661
|
+
: await createResourceHistory(input, type, id);
|
|
662
|
+
if (!history) return json(response, 404, { error: "Resource not found." });
|
|
663
|
+
return json(response, 200, history);
|
|
664
|
+
}
|
|
627
665
|
const entry = session
|
|
628
|
-
? await loadStateSessionResource(session, token, type, id, options, requestOptions.programId, includeWorkflow)
|
|
629
|
-
: await createResourceDetail(input, type, id);
|
|
666
|
+
? await loadStateSessionResource(session, token, type, id, options, requestOptions.programId, includeWorkflow, url.searchParams.get("history") !== "false")
|
|
667
|
+
: await createResourceDetail(input, type, id, { includeHistory: url.searchParams.get("history") !== "false" });
|
|
630
668
|
if (!entry) return json(response, 404, { error: "Resource not found." });
|
|
631
669
|
if (includeWorkflow && !session) {
|
|
632
670
|
const workflow = await assessWorkflow(input, { programId: requestOptions.programId });
|
|
@@ -635,10 +673,13 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
|
635
673
|
return json(response, 200, entry);
|
|
636
674
|
}
|
|
637
675
|
if (request.method === "PUT") {
|
|
638
|
-
const
|
|
676
|
+
const requestPayload = await readJson(request);
|
|
677
|
+
const payload = normalizeResourceMutation(requestPayload, { requireRevision: true });
|
|
639
678
|
const { record } = payload;
|
|
640
679
|
const result = await browserMutation(input, requestOptions, {
|
|
641
|
-
message: () => `Update ${resourceTypeLabel(type)}: ${record.title || id}
|
|
680
|
+
message: () => `Update ${resourceTypeLabel(type)}: ${record.title || id}`,
|
|
681
|
+
fastResponse: prefersFastMutation(request),
|
|
682
|
+
prefetchToken: requestPayload.prefetchToken
|
|
642
683
|
}, () => updateResource(input, type, id, record, {
|
|
643
684
|
content: payload.content,
|
|
644
685
|
expectedRevision: payload.revision,
|
|
@@ -650,7 +691,8 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
|
650
691
|
if (request.method === "DELETE") {
|
|
651
692
|
const revision = requireRevision(url.searchParams.get("revision"), `${type}/${id}`);
|
|
652
693
|
const result = await browserMutation(input, requestOptions, {
|
|
653
|
-
message: () => `Delete ${resourceTypeLabel(type)}: ${id}
|
|
694
|
+
message: () => `Delete ${resourceTypeLabel(type)}: ${id}`,
|
|
695
|
+
prefetchToken: url.searchParams.get("prefetchToken") || undefined
|
|
654
696
|
}, () => deleteResource(input, type, id, { expectedRevision: revision }));
|
|
655
697
|
return json(response, 200, {
|
|
656
698
|
deleted: true,
|
|
@@ -659,7 +701,8 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
|
659
701
|
deletedContent: result.deletedContent,
|
|
660
702
|
synchronization: result.synchronization,
|
|
661
703
|
workflowDelta: result.workflowDelta,
|
|
662
|
-
state: result.state
|
|
704
|
+
state: result.state,
|
|
705
|
+
stateRefresh: result.stateRefresh
|
|
663
706
|
});
|
|
664
707
|
}
|
|
665
708
|
}
|
|
@@ -703,6 +746,30 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
|
703
746
|
});
|
|
704
747
|
}
|
|
705
748
|
|
|
749
|
+
async function stableStateSnapshot(input, options) {
|
|
750
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
751
|
+
const first = await fingerprintWorkspace(input, {
|
|
752
|
+
fileDigestCache: options.fileDigestCache,
|
|
753
|
+
deadlineAt: options.deadlineAt
|
|
754
|
+
});
|
|
755
|
+
const firstRepositorySignature = await getRepositoryStateSignature(input, {
|
|
756
|
+
timeoutMs: Math.max(1, Math.ceil(options.deadlineAt - performance.now()))
|
|
757
|
+
});
|
|
758
|
+
const second = await fingerprintWorkspace(input, {
|
|
759
|
+
fileDigestCache: options.fileDigestCache,
|
|
760
|
+
deadlineAt: options.deadlineAt
|
|
761
|
+
});
|
|
762
|
+
const secondRepositorySignature = await getRepositoryStateSignature(input, {
|
|
763
|
+
timeoutMs: Math.max(1, Math.ceil(options.deadlineAt - performance.now()))
|
|
764
|
+
});
|
|
765
|
+
if (
|
|
766
|
+
first.fingerprint === second.fingerprint
|
|
767
|
+
&& firstRepositorySignature === secondRepositorySignature
|
|
768
|
+
) return [second, secondRepositorySignature];
|
|
769
|
+
}
|
|
770
|
+
throw stateSessionExpiredError();
|
|
771
|
+
}
|
|
772
|
+
|
|
706
773
|
export async function serveWorkspace(input = process.cwd(), options = {}) {
|
|
707
774
|
const host = String(options.host ?? "127.0.0.1").trim();
|
|
708
775
|
const port = Number(options.port ?? 8787);
|
|
@@ -761,11 +828,12 @@ function listen(server, port, host) {
|
|
|
761
828
|
|
|
762
829
|
function browserMutation(input, requestOptions, mutationOptions, task) {
|
|
763
830
|
const run = () => serializeWorkspaceMutation(input, async (root) => {
|
|
764
|
-
const fastResponse = mutationOptions.fastResponse
|
|
831
|
+
const fastResponse = mutationOptions.fastResponse ?? requestOptions.fastResponse;
|
|
765
832
|
const workflowBefore = fastResponse
|
|
766
833
|
? null
|
|
767
834
|
: await measureTiming("workflow-before", () => assessWorkflow(root, { programId: requestOptions.programId }));
|
|
768
835
|
let result;
|
|
836
|
+
requestOptions.beginStateMutation?.();
|
|
769
837
|
try {
|
|
770
838
|
result = await measureTiming("mutation", () => runBrowserMutation(root, {
|
|
771
839
|
...mutationOptions,
|
|
@@ -774,7 +842,7 @@ function browserMutation(input, requestOptions, mutationOptions, task) {
|
|
|
774
842
|
includeValidationProof: !fastResponse
|
|
775
843
|
}, task));
|
|
776
844
|
} finally {
|
|
777
|
-
requestOptions.
|
|
845
|
+
requestOptions.endStateMutation?.();
|
|
778
846
|
}
|
|
779
847
|
if (fastResponse) {
|
|
780
848
|
return {
|
|
@@ -899,7 +967,7 @@ async function loadStateSessionSection(session, section, serverOptions, programI
|
|
|
899
967
|
return state;
|
|
900
968
|
}
|
|
901
969
|
|
|
902
|
-
async function loadStateSessionResource(session, token, type, id, serverOptions, programId, includeWorkflow) {
|
|
970
|
+
async function loadStateSessionResource(session, token, type, id, serverOptions, programId, includeWorkflow, includeHistory = true) {
|
|
903
971
|
assertCurrentStateSession(session);
|
|
904
972
|
const detailDeadlineMs = Number.isFinite(serverOptions.resourceDetailDeadlineMs)
|
|
905
973
|
? Math.max(0, serverOptions.resourceDetailDeadlineMs)
|
|
@@ -907,7 +975,8 @@ async function loadStateSessionResource(session, token, type, id, serverOptions,
|
|
|
907
975
|
const deadlineAt = performance.now() + detailDeadlineMs;
|
|
908
976
|
return withGitCommandDeadline(deadlineAt, async () => {
|
|
909
977
|
const detail = await withGitCommandCache(session.gitCommandCache, () => createResourceDetail(session.loaded, type, id, {
|
|
910
|
-
historyDeadlineAt: deadlineAt
|
|
978
|
+
historyDeadlineAt: deadlineAt,
|
|
979
|
+
includeHistory
|
|
911
980
|
}));
|
|
912
981
|
if (detail && includeWorkflow) {
|
|
913
982
|
const repository = await loadStateSessionSection(
|
|
@@ -931,6 +1000,22 @@ async function loadStateSessionResource(session, token, type, id, serverOptions,
|
|
|
931
1000
|
});
|
|
932
1001
|
}
|
|
933
1002
|
|
|
1003
|
+
async function loadStateSessionResourceHistory(session, token, type, id, serverOptions) {
|
|
1004
|
+
assertCurrentStateSession(session);
|
|
1005
|
+
const detailDeadlineMs = Number.isFinite(serverOptions.resourceDetailDeadlineMs)
|
|
1006
|
+
? Math.max(0, serverOptions.resourceDetailDeadlineMs)
|
|
1007
|
+
: RESOURCE_DETAIL_GIT_DEADLINE_MS;
|
|
1008
|
+
const deadlineAt = performance.now() + detailDeadlineMs;
|
|
1009
|
+
return withGitCommandDeadline(deadlineAt, async () => {
|
|
1010
|
+
const history = await withGitCommandCache(session.gitCommandCache, () => createResourceHistory(session.loaded, type, id, {
|
|
1011
|
+
historyDeadlineAt: deadlineAt
|
|
1012
|
+
}));
|
|
1013
|
+
await verifyStateSessionSnapshot(session, performance.now(), deadlineAt);
|
|
1014
|
+
assertCurrentStateSession(session);
|
|
1015
|
+
return history ? { ...history, stateToken: token } : null;
|
|
1016
|
+
});
|
|
1017
|
+
}
|
|
1018
|
+
|
|
934
1019
|
function prefersFastMutation(request) {
|
|
935
1020
|
return String(request.headers.prefer || "")
|
|
936
1021
|
.split(",")
|
|
@@ -948,10 +1033,11 @@ async function requireManualBrowserGit(input, options) {
|
|
|
948
1033
|
|
|
949
1034
|
async function manualGitResultWithState(input, requestOptions, task) {
|
|
950
1035
|
let result;
|
|
1036
|
+
requestOptions.beginStateMutation?.();
|
|
951
1037
|
try {
|
|
952
1038
|
result = await task();
|
|
953
1039
|
} finally {
|
|
954
|
-
requestOptions.
|
|
1040
|
+
requestOptions.endStateMutation?.();
|
|
955
1041
|
}
|
|
956
1042
|
const state = await createAppState(input, {
|
|
957
1043
|
allowNonAuthoritativeWrites: requestOptions.allowNonAuthoritativeWrites,
|
package/src/state.js
CHANGED
|
@@ -368,15 +368,39 @@ async function createResourceDetailFromLoaded(loaded, type, id, options) {
|
|
|
368
368
|
const entry = loaded.entries.find(({ record }) => record.type === type && record.id === id);
|
|
369
369
|
if (!entry) return null;
|
|
370
370
|
const relativePath = `data/${entry.relativePath}`;
|
|
371
|
-
const
|
|
372
|
-
|
|
373
|
-
|
|
371
|
+
const includeHistory = options.includeHistory !== false;
|
|
372
|
+
const histories = includeHistory
|
|
373
|
+
? getWorkspaceHistories(loaded.root, [relativePath], 12, {
|
|
374
|
+
deadlineAt: options.historyDeadlineAt
|
|
375
|
+
})
|
|
376
|
+
: new Map();
|
|
374
377
|
return createStateEntry(loaded, entry, {
|
|
375
378
|
includeDetails: true,
|
|
376
|
-
|
|
379
|
+
includeHistory,
|
|
380
|
+
history: includeHistory ? histories.get(relativePath) ?? [] : undefined
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export async function createResourceHistory(input, type, id, options = {}) {
|
|
385
|
+
if (input?.entries && input?.root) return createResourceHistoryFromLoaded(input, type, id, options);
|
|
386
|
+
return serializeWorkspaceMutation(input, async (root) => {
|
|
387
|
+
const validation = await validateWorkspace(root);
|
|
388
|
+
return createResourceHistoryFromLoaded(validation.loaded, type, id, options);
|
|
377
389
|
});
|
|
378
390
|
}
|
|
379
391
|
|
|
392
|
+
function createResourceHistoryFromLoaded(loaded, type, id, options) {
|
|
393
|
+
const entry = loaded.entries.find(({ record }) => record.type === type && record.id === id);
|
|
394
|
+
if (!entry) return null;
|
|
395
|
+
const relativePath = `data/${entry.relativePath}`;
|
|
396
|
+
return {
|
|
397
|
+
history: getWorkspaceHistories(loaded.root, [relativePath], 12, {
|
|
398
|
+
deadlineAt: options.historyDeadlineAt
|
|
399
|
+
}).get(relativePath) ?? [],
|
|
400
|
+
historyLoaded: true
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
380
404
|
async function createStateEntry(loaded, entry, options) {
|
|
381
405
|
const record = structuredClone(entry.record);
|
|
382
406
|
const content = {};
|
|
@@ -401,7 +425,8 @@ async function createStateEntry(loaded, entry, options) {
|
|
|
401
425
|
relativePath: `data/${entry.relativePath}`,
|
|
402
426
|
revision: contentRevision(entry.source),
|
|
403
427
|
content,
|
|
404
|
-
history: options.includeDetails ? options.history : undefined,
|
|
428
|
+
history: options.includeDetails && options.includeHistory !== false ? options.history : undefined,
|
|
429
|
+
historyLoaded: options.includeDetails ? options.includeHistory !== false : false,
|
|
405
430
|
detailsLoaded: options.includeDetails
|
|
406
431
|
};
|
|
407
432
|
}
|
package/src/validate.js
CHANGED
|
@@ -68,6 +68,7 @@ const COMPLETION_DATE_FIELDS = [
|
|
|
68
68
|
const COMPLETION_TIMESTAMP_FIELDS = [
|
|
69
69
|
"completedAt", "endedAt", "closedAt", "provisionedOn", "deprovisionedOn"
|
|
70
70
|
];
|
|
71
|
+
const DEFERRED_VALIDATION_CONCURRENCY = 16;
|
|
71
72
|
|
|
72
73
|
export async function validateWorkspace(input = process.cwd()) {
|
|
73
74
|
const timingStarted = performance.now();
|
|
@@ -90,6 +91,8 @@ async function validateWorkspaceUnmeasured(input) {
|
|
|
90
91
|
]));
|
|
91
92
|
const asOf = currentCalendarDate(loaded.workspace?.timezone || "UTC");
|
|
92
93
|
const obligationsByControl = new Map();
|
|
94
|
+
const deferredDiagnosticTasks = [];
|
|
95
|
+
const serialContentDiagnosticTasks = [];
|
|
93
96
|
const reviewRecords = loaded.resources.filter((record) => (
|
|
94
97
|
record.status === "active" && ["retention-schedule-item", "requirement-mapping"].includes(record.type)
|
|
95
98
|
));
|
|
@@ -163,7 +166,11 @@ async function validateWorkspaceUnmeasured(input) {
|
|
|
163
166
|
validateClassification(record, loaded, displayPath, diagnostics);
|
|
164
167
|
validateCompletionDates(record, displayPath, diagnostics);
|
|
165
168
|
validateReportingRouteBinding(record, loaded, displayPath, diagnostics);
|
|
166
|
-
|
|
169
|
+
serialContentDiagnosticTasks.push(async () => {
|
|
170
|
+
const deferredDiagnostics = [];
|
|
171
|
+
await validateAttestationBinding(record, loaded.model, loaded.root, byId, displayPath, deferredDiagnostics);
|
|
172
|
+
return deferredDiagnostics;
|
|
173
|
+
});
|
|
167
174
|
|
|
168
175
|
const fields = { ...loaded.model.commonFields, ...definition.fields };
|
|
169
176
|
for (const [fieldName, field] of Object.entries(fields)) {
|
|
@@ -173,16 +180,19 @@ async function validateWorkspaceUnmeasured(input) {
|
|
|
173
180
|
const values = Array.isArray(value) ? value : [value];
|
|
174
181
|
for (const item of values) {
|
|
175
182
|
if (typeof item !== "string") continue;
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
183
|
+
deferredDiagnosticTasks.push(async () => {
|
|
184
|
+
try {
|
|
185
|
+
const path = resolveDataPath(loaded.root, item);
|
|
186
|
+
if (!(await stat(path)).isFile()) throw new Error("The data path is not a file.");
|
|
187
|
+
return [];
|
|
188
|
+
} catch {
|
|
189
|
+
return [error(
|
|
190
|
+
"missing-content",
|
|
191
|
+
displayPath,
|
|
192
|
+
`${fieldName} points to unavailable data path "${item}".`
|
|
193
|
+
)];
|
|
194
|
+
}
|
|
195
|
+
});
|
|
186
196
|
}
|
|
187
197
|
}
|
|
188
198
|
if (field.relation) {
|
|
@@ -209,8 +219,19 @@ async function validateWorkspaceUnmeasured(input) {
|
|
|
209
219
|
validateCompletedObligationEvent(record, byId, loaded.model, displayPath, diagnostics);
|
|
210
220
|
validateActionObligationRule(record, byId, displayPath, diagnostics);
|
|
211
221
|
validateImplementedControlSchedules(record, obligationsByControl, displayPath, diagnostics);
|
|
212
|
-
|
|
213
|
-
|
|
222
|
+
serialContentDiagnosticTasks.push(async () => {
|
|
223
|
+
const markdownDiagnostics = [];
|
|
224
|
+
const approvalDiagnostics = [];
|
|
225
|
+
await validateMarkdown(record, definition, loaded.model, loaded.root, displayPath, markdownDiagnostics);
|
|
226
|
+
await validateApprovalBinding(record, loaded.model, loaded.root, displayPath, approvalDiagnostics);
|
|
227
|
+
return [...markdownDiagnostics, ...approvalDiagnostics];
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
for (const deferredDiagnostics of await runDeferredValidationTasks(deferredDiagnosticTasks)) {
|
|
231
|
+
diagnostics.push(...deferredDiagnostics);
|
|
232
|
+
}
|
|
233
|
+
for (const task of serialContentDiagnosticTasks) {
|
|
234
|
+
diagnostics.push(...await task());
|
|
214
235
|
}
|
|
215
236
|
validateRelationshipConstraints(
|
|
216
237
|
loaded.resources,
|
|
@@ -285,6 +306,23 @@ async function validateWorkspaceUnmeasured(input) {
|
|
|
285
306
|
return result;
|
|
286
307
|
}
|
|
287
308
|
|
|
309
|
+
async function runDeferredValidationTasks(tasks) {
|
|
310
|
+
const results = new Array(tasks.length);
|
|
311
|
+
let nextIndex = 0;
|
|
312
|
+
const workers = Array.from(
|
|
313
|
+
{ length: Math.min(DEFERRED_VALIDATION_CONCURRENCY, tasks.length) },
|
|
314
|
+
async () => {
|
|
315
|
+
while (nextIndex < tasks.length) {
|
|
316
|
+
const index = nextIndex;
|
|
317
|
+
nextIndex += 1;
|
|
318
|
+
results[index] = await tasks[index]();
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
);
|
|
322
|
+
await Promise.all(workers);
|
|
323
|
+
return results;
|
|
324
|
+
}
|
|
325
|
+
|
|
288
326
|
function validateDocumentWorkflowScopes(resources, model, byId, pathById, diagnostics) {
|
|
289
327
|
const managementFields = [
|
|
290
328
|
"engagementTermsDocumentId",
|
package/src/web.js
CHANGED
|
@@ -94,6 +94,7 @@ let repositorySyncPollTimer = null;
|
|
|
94
94
|
let repositorySyncPollInFlight = false;
|
|
95
95
|
let mutationStateRefreshInFlight = false;
|
|
96
96
|
let mutationStateRefreshTimer = null;
|
|
97
|
+
let mutationStateRefreshRequested = false;
|
|
97
98
|
let programSelectionGeneration = 0;
|
|
98
99
|
let expiredStateRefresh = null;
|
|
99
100
|
|
|
@@ -132,7 +133,8 @@ function render() {
|
|
|
132
133
|
if (nextNavigation) nextNavigation.scrollTop = navigationScrollTop;
|
|
133
134
|
const main = root.querySelector("main");
|
|
134
135
|
const waitingFor = blockingStateSections(route).filter((section) => state.sections?.[section] !== "complete");
|
|
135
|
-
if (
|
|
136
|
+
if (state.refreshingAfterMutation) renderStateLoading(main, route, []);
|
|
137
|
+
else if (waitingFor.length) renderStateLoading(main, route, waitingFor);
|
|
136
138
|
else if (route.name === "home") renderHome(main);
|
|
137
139
|
else if (route.name === "stage") renderStageOverview(main, route.stageId, route.params);
|
|
138
140
|
else if (route.name === "obligations") renderObligations(main, route.params);
|
|
@@ -660,6 +662,7 @@ function openDocumentActivationDialog(auditId = null) {
|
|
|
660
662
|
'<label><span>Effective date</span><input name="effectiveOn" type="date" min="' + esc(today) + '" value="' + esc(today) + '" required></label>' +
|
|
661
663
|
'<div class="dialog-error" role="alert"></div><div class="dialog-actions"><span class="save-status" role="status" aria-live="polite"></span><button type="button" class="button" data-event="cancel">Cancel</button><button type="submit" class="button primary">Activate selected content</button></div></form>';
|
|
662
664
|
document.body.append(dialog);
|
|
665
|
+
const repositoryPrefetch = prefetchRepositoryForReview(dialog.querySelector(".save-status"));
|
|
663
666
|
const close = () => dialog.close();
|
|
664
667
|
dialog.querySelector(".icon-button").addEventListener("click", close);
|
|
665
668
|
dialog.querySelector('[data-event="cancel"]').addEventListener("click", close);
|
|
@@ -674,6 +677,8 @@ function openDocumentActivationDialog(auditId = null) {
|
|
|
674
677
|
}
|
|
675
678
|
setMutationBusy(dialog, true, "Activating…", "Activate selected content");
|
|
676
679
|
try {
|
|
680
|
+
const prefetch = await repositoryPrefetch;
|
|
681
|
+
if (prefetch?.error) throw prefetch.error;
|
|
677
682
|
const response = await localFetch(auditId ? "/api/document-activations" : "/api/governed-content-activations", {
|
|
678
683
|
method: "POST",
|
|
679
684
|
headers: { "content-type": "application/json" },
|
|
@@ -683,6 +688,7 @@ function openDocumentActivationDialog(auditId = null) {
|
|
|
683
688
|
activatedByIds: [event.currentTarget.elements.activatedById.value],
|
|
684
689
|
activatedOn: event.currentTarget.elements.activatedOn.value,
|
|
685
690
|
effectiveOn: event.currentTarget.elements.effectiveOn.value,
|
|
691
|
+
prefetchToken: prefetch?.token,
|
|
686
692
|
expectedRevisions: Object.fromEntries(resourceIds.map((resourceId) => [resourceId, entryById.get(resourceId).revision]))
|
|
687
693
|
})
|
|
688
694
|
});
|
|
@@ -759,6 +765,7 @@ function openPolicyActivationDialog() {
|
|
|
759
765
|
'<label><span>Effective date</span><input name="effectiveOn" type="date" min="' + esc(today) + '" value="' + esc(today) + '" required></label>' +
|
|
760
766
|
'<div class="dialog-error" role="alert"></div><div class="dialog-actions"><span class="save-status" role="status" aria-live="polite"></span><button type="button" class="button" data-event="cancel">Cancel</button><button id="activate-policy" type="submit" class="button primary">Activate selected Policies</button></div></form>';
|
|
761
767
|
document.body.append(dialog);
|
|
768
|
+
const repositoryPrefetch = prefetchRepositoryForReview(dialog.querySelector(".save-status"));
|
|
762
769
|
const close = () => dialog.close();
|
|
763
770
|
dialog.querySelector(".icon-button").addEventListener("click", close);
|
|
764
771
|
dialog.querySelector('[data-event="cancel"]').addEventListener("click", close);
|
|
@@ -773,12 +780,15 @@ function openPolicyActivationDialog() {
|
|
|
773
780
|
}
|
|
774
781
|
setMutationBusy(dialog, true, "Activating…", "Activate selected Policies");
|
|
775
782
|
try {
|
|
783
|
+
const prefetch = await repositoryPrefetch;
|
|
784
|
+
if (prefetch?.error) throw prefetch.error;
|
|
776
785
|
const response = await localFetch("/api/policy-activations", {
|
|
777
786
|
method: "POST",
|
|
778
787
|
headers: { "content-type": "application/json" },
|
|
779
788
|
body: JSON.stringify({
|
|
780
789
|
policyIds,
|
|
781
790
|
effectiveOn: event.currentTarget.elements.effectiveOn.value,
|
|
791
|
+
prefetchToken: prefetch?.token,
|
|
782
792
|
expectedRevisions: Object.fromEntries(policyIds.map((policyId) => [policyId, entryById.get(policyId).revision]))
|
|
783
793
|
})
|
|
784
794
|
});
|
|
@@ -2686,10 +2696,11 @@ function renderDetail(main, type, id, params = new URLSearchParams()) {
|
|
|
2686
2696
|
const definition = state.model.resources[type];
|
|
2687
2697
|
if (!entry || !definition) return renderNotFound(main);
|
|
2688
2698
|
if (entry.detailsLoaded === false) {
|
|
2689
|
-
main.innerHTML = '<div class="page"><div class="detail-head"><div><div class="breadcrumbs header-breadcrumbs"><a href="#/resources/' + encodeURIComponent(type) + '">' + esc(titleCase(definition.pluralTitle)) + '</a><span>/</span><span>' + esc(entry.record.title) + '</span></div><h2>' + esc(titleCase(entry.record.title)) + '</h2></div></div><section class="panel detail-loading" role="status">Loading record
|
|
2699
|
+
main.innerHTML = '<div class="page"><div class="detail-head"><div><div class="breadcrumbs header-breadcrumbs"><a href="#/resources/' + encodeURIComponent(type) + '">' + esc(titleCase(definition.pluralTitle)) + '</a><span>/</span><span>' + esc(entry.record.title) + '</span></div><h2>' + esc(titleCase(entry.record.title)) + '</h2></div></div><section class="panel detail-loading" role="status">Loading record…</section></div>';
|
|
2690
2700
|
loadResourceDetail(type, id);
|
|
2691
2701
|
return;
|
|
2692
2702
|
}
|
|
2703
|
+
if (entry.historyLoaded === false) loadResourceHistory(type, id);
|
|
2693
2704
|
const fields = { ...state.model.commonFields, ...definition.fields };
|
|
2694
2705
|
const recordContent = recordContentDefinition(type);
|
|
2695
2706
|
const narrative = recordNarrative(entry.record, fields);
|
|
@@ -2756,13 +2767,16 @@ function renderDetail(main, type, id, params = new URLSearchParams()) {
|
|
|
2756
2767
|
const historyPanel = entry.history?.length
|
|
2757
2768
|
? '<section class="panel detail-history-panel"><div class="panel-head"><h3>File History</h3></div><div class="history">' + entry.history.map((commit) => '<div><code>' + esc(commit.shortCommit) + '</code><span><strong>' + esc(commit.subject) + '</strong><small>' + esc(commit.author) + ' · ' + esc(formatLocalDateTime(commit.timestamp)) + '</small></span></div>').join("") + '</div></section>'
|
|
2758
2769
|
: "";
|
|
2770
|
+
const participationPanel = entry.historyLoaded === false
|
|
2771
|
+
? '<section class="panel detail-support-panel" role="status"><div class="panel-head"><h3>Participation</h3></div><p class="muted">Loading participation and file history…</p></section>'
|
|
2772
|
+
: personParticipation(entry);
|
|
2759
2773
|
const supportPanels = renderDetailSupport({
|
|
2760
2774
|
hasRecordBody,
|
|
2761
2775
|
workflowPanel: workflowGuidance({ type, id, title: "Next steps" }),
|
|
2762
2776
|
reviewPanel: resourceReviewCriteria(type),
|
|
2763
2777
|
metadataPanel: '<section class="panel detail-support-panel detail-metadata-panel"><div class="panel-head"><h3>Record details</h3></div><dl class="metadata">' + sourceMetadata + visible.map(([name, value]) => '<div><dt>' + esc(fields[name]?.label || humanize(name)) + '</dt><dd>' + formatValue(name === "status" ? displayStatus(entry.record) : value, name, type) + '</dd></div>').join("") + '</dl></section>',
|
|
2764
2778
|
attachmentPanel,
|
|
2765
|
-
participationPanel
|
|
2779
|
+
participationPanel,
|
|
2766
2780
|
connectionsPanel: resourceConnections(entry),
|
|
2767
2781
|
historyPanel
|
|
2768
2782
|
});
|
|
@@ -2879,6 +2893,7 @@ function renderDetail(main, type, id, params = new URLSearchParams()) {
|
|
|
2879
2893
|
main.querySelector("#add-record-content")?.addEventListener("click", () => openEditor(type, entry, { addRecordContent: true }));
|
|
2880
2894
|
main.querySelectorAll("[data-edit-content]").forEach((button) => button.addEventListener("click", () => openContentEditor(entry, button.dataset.editContent)));
|
|
2881
2895
|
main.querySelector("#delete-resource")?.addEventListener("click", async () => {
|
|
2896
|
+
const repositoryPrefetch = prefetchRepositoryForReview();
|
|
2882
2897
|
if (!await confirmAction({
|
|
2883
2898
|
kicker: "Delete record",
|
|
2884
2899
|
title: entry.record.title,
|
|
@@ -2887,7 +2902,9 @@ function renderDetail(main, type, id, params = new URLSearchParams()) {
|
|
|
2887
2902
|
danger: true
|
|
2888
2903
|
})) return;
|
|
2889
2904
|
try {
|
|
2890
|
-
const
|
|
2905
|
+
const prefetch = await repositoryPrefetch;
|
|
2906
|
+
if (prefetch?.error) throw prefetch.error;
|
|
2907
|
+
const response = await localFetch("/api/resource/" + encodeURIComponent(type) + "/" + encodeURIComponent(id) + "?revision=" + encodeURIComponent(entry.revision) + (prefetch?.token ? "&prefetchToken=" + encodeURIComponent(prefetch.token) : ""), { method: "DELETE" });
|
|
2891
2908
|
if (!response.ok) return showError(await responseMessage(response));
|
|
2892
2909
|
applyMutationState(await response.json());
|
|
2893
2910
|
location.hash = "#/resources/" + encodeURIComponent(type);
|
|
@@ -2937,7 +2954,7 @@ async function loadResourceDetail(type, id) {
|
|
|
2937
2954
|
let token = state.stateToken;
|
|
2938
2955
|
let detail = null;
|
|
2939
2956
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
2940
|
-
const tokenQuery = token ? "?token=" + encodeURIComponent(token) : "";
|
|
2957
|
+
const tokenQuery = token ? "?token=" + encodeURIComponent(token) + "&history=false" : "?history=false";
|
|
2941
2958
|
const response = await localFetch("/api/resource/" + encodeURIComponent(type) + "/" + encodeURIComponent(id) + tokenQuery);
|
|
2942
2959
|
if (response.status === 409 && token) {
|
|
2943
2960
|
await refreshExpiredAppState(token);
|
|
@@ -2975,6 +2992,44 @@ async function loadResourceDetail(type, id) {
|
|
|
2975
2992
|
return request;
|
|
2976
2993
|
}
|
|
2977
2994
|
|
|
2995
|
+
async function loadResourceHistory(type, id) {
|
|
2996
|
+
const key = (state.stateToken || "live") + "\0history\0" + type + "\0" + id;
|
|
2997
|
+
if (resourceDetailRequests.has(key)) return resourceDetailRequests.get(key);
|
|
2998
|
+
const request = (async () => {
|
|
2999
|
+
try {
|
|
3000
|
+
const token = state.stateToken;
|
|
3001
|
+
const tokenQuery = (token ? "?token=" + encodeURIComponent(token) + "&" : "?") + "history=only";
|
|
3002
|
+
const response = await localFetch("/api/resource/" + encodeURIComponent(type) + "/" + encodeURIComponent(id) + tokenQuery);
|
|
3003
|
+
if (response.status === 409 && token) {
|
|
3004
|
+
await refreshExpiredAppState(token);
|
|
3005
|
+
const route = parseRoute();
|
|
3006
|
+
if (route.name === "detail" && route.type === type && route.id === id) {
|
|
3007
|
+
render();
|
|
3008
|
+
loadStateForRoute();
|
|
3009
|
+
}
|
|
3010
|
+
return;
|
|
3011
|
+
}
|
|
3012
|
+
if (!response.ok) throw new Error(await responseMessage(response));
|
|
3013
|
+
const history = await response.json();
|
|
3014
|
+
if (token && (history.stateToken !== token || state.stateToken !== token)) return;
|
|
3015
|
+
const entry = state.resources.find(({ record }) => record.type === type && record.id === id);
|
|
3016
|
+
if (!entry) return;
|
|
3017
|
+
entry.history = history.history || [];
|
|
3018
|
+
entry.historyLoaded = true;
|
|
3019
|
+
const route = parseRoute();
|
|
3020
|
+
if (route.name === "detail" && route.type === type && route.id === id) render();
|
|
3021
|
+
} catch {
|
|
3022
|
+
// History and participation are supplemental. The record stays usable.
|
|
3023
|
+
} finally {
|
|
3024
|
+
for (const [requestKey, pending] of resourceDetailRequests) {
|
|
3025
|
+
if (pending === request) resourceDetailRequests.delete(requestKey);
|
|
3026
|
+
}
|
|
3027
|
+
}
|
|
3028
|
+
})();
|
|
3029
|
+
resourceDetailRequests.set(key, request);
|
|
3030
|
+
return request;
|
|
3031
|
+
}
|
|
3032
|
+
|
|
2978
3033
|
function refreshExpiredAppState(expectedToken) {
|
|
2979
3034
|
if (expiredStateRefresh) {
|
|
2980
3035
|
if (expiredStateRefresh.token === expectedToken) return expiredStateRefresh.promise;
|
|
@@ -4182,6 +4237,14 @@ function openEditor(type, entry = null, options = {}) {
|
|
|
4182
4237
|
'<details class="advanced-editor"><summary>Advanced JSON</summary><p>Use this for optional fields, extensions, or bulk edits. Changes here replace the guided fields above.</p><textarea spellcheck="false" aria-label="Advanced resource JSON">' + esc(JSON.stringify(record, null, 2)) + '</textarea></details><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-editor-dismiss>Cancel</button><button type="submit" class="button primary" id="save-record">' + esc(options.saveLabel || "Save file") + '</button></div></form>';
|
|
4183
4238
|
document.body.append(dialog);
|
|
4184
4239
|
dialog.showModal();
|
|
4240
|
+
const saveStatus = dialog.querySelector(".save-status");
|
|
4241
|
+
const fastResourceSave = !options.occurrenceReconciliation
|
|
4242
|
+
&& !options.auditPopulationCorrection
|
|
4243
|
+
&& !options.actionCompletion
|
|
4244
|
+
&& !options.obligationCompletion;
|
|
4245
|
+
const repositoryPrefetch = fastResourceSave
|
|
4246
|
+
? prefetchRepositoryForReview(saveStatus)
|
|
4247
|
+
: Promise.resolve(null);
|
|
4185
4248
|
dialog.addEventListener("close", () => dialog.remove());
|
|
4186
4249
|
dialog.querySelectorAll("[data-editor-dismiss]").forEach((button) => button.addEventListener("click", () => dialog.close()));
|
|
4187
4250
|
wireEditorRequirements(dialog, record, fields, oneOfGroups, markdownDefinitions);
|
|
@@ -4254,9 +4317,14 @@ function openEditor(type, entry = null, options = {}) {
|
|
|
4254
4317
|
[recordContentItem?.path, recordContentItem?.revision]
|
|
4255
4318
|
].filter(([path, revision]) => path && revision));
|
|
4256
4319
|
setMutationBusy(dialog, true, "Saving…", options.saveLabel || "Save file");
|
|
4320
|
+
const prefetch = await repositoryPrefetch;
|
|
4321
|
+
if (prefetch?.error) throw prefetch.error;
|
|
4257
4322
|
const response = await localFetch(url, {
|
|
4258
4323
|
method: options.occurrenceReconciliation ? "POST" : entry ? "PUT" : "POST",
|
|
4259
|
-
headers: {
|
|
4324
|
+
headers: {
|
|
4325
|
+
"content-type": "application/json",
|
|
4326
|
+
...(fastResourceSave ? { prefer: "respond-async" } : {})
|
|
4327
|
+
},
|
|
4260
4328
|
body: JSON.stringify({
|
|
4261
4329
|
record: updated,
|
|
4262
4330
|
content,
|
|
@@ -4264,7 +4332,8 @@ function openEditor(type, entry = null, options = {}) {
|
|
|
4264
4332
|
contentRevisions,
|
|
4265
4333
|
obligationId: options.obligationCompletion?.obligationId,
|
|
4266
4334
|
actionItemId: options.actionCompletion?.actionItemId,
|
|
4267
|
-
completedOn: options.actionCompletion?.completedOn
|
|
4335
|
+
completedOn: options.actionCompletion?.completedOn,
|
|
4336
|
+
prefetchToken: prefetch?.token
|
|
4268
4337
|
})
|
|
4269
4338
|
});
|
|
4270
4339
|
if (!response.ok) throw new Error(await responseMessage(response));
|
|
@@ -5025,12 +5094,15 @@ function openContentEditor(entry, name) {
|
|
|
5025
5094
|
dialog.innerHTML = '<form method="dialog"><div class="dialog-head"><div><p class="kicker">Edit Markdown</p><h2 id="content-editor-title">' + esc(titleCase(entry.record.title)) + '</h2></div><button value="cancel" class="icon-button" aria-label="Close">×</button></div><p><code>' + esc(item.path) + '</code></p><textarea class="markdown-source" spellcheck="true" aria-label="Markdown content">' + esc(item.source) + '</textarea><div class="dialog-error" role="alert"></div><div class="save-status" role="status" aria-live="polite"></div><div class="dialog-actions"><button value="cancel" class="button">Cancel</button><button type="button" class="button primary" id="save-content">Save Markdown</button></div></form>';
|
|
5026
5095
|
document.body.append(dialog);
|
|
5027
5096
|
dialog.showModal();
|
|
5097
|
+
const repositoryPrefetch = prefetchRepositoryForReview(dialog.querySelector(".save-status"));
|
|
5028
5098
|
dialog.addEventListener("close", () => dialog.remove());
|
|
5029
5099
|
dialog.querySelector("#save-content").addEventListener("click", async () => {
|
|
5030
5100
|
if (dialog.dataset.mutationBusy === "true") return;
|
|
5031
5101
|
try {
|
|
5032
5102
|
setMutationBusy(dialog, true, "Saving…", "Save Markdown");
|
|
5033
|
-
const
|
|
5103
|
+
const prefetch = await repositoryPrefetch;
|
|
5104
|
+
if (prefetch?.error) throw prefetch.error;
|
|
5105
|
+
const response = await localFetch("/api/content", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ path: item.path, source: dialog.querySelector(".markdown-source").value, revision: item.revision, prefetchToken: prefetch?.token }) });
|
|
5034
5106
|
if (!response.ok) throw new Error(await responseMessage(response));
|
|
5035
5107
|
applyMutationState(await response.json());
|
|
5036
5108
|
dialog.close();
|
|
@@ -5503,7 +5575,16 @@ function applyMutationState(result) {
|
|
|
5503
5575
|
state = normalizeAppState(result.state);
|
|
5504
5576
|
} else if (result?.stateRefresh) {
|
|
5505
5577
|
applyFastMutationPatch(result);
|
|
5578
|
+
state.refreshingAfterMutation = true;
|
|
5579
|
+
state.readOnly = true;
|
|
5580
|
+
state.resources = state.resources.map((entry) => ({
|
|
5581
|
+
...entry,
|
|
5582
|
+
content: {},
|
|
5583
|
+
history: undefined,
|
|
5584
|
+
detailsLoaded: false
|
|
5585
|
+
}));
|
|
5506
5586
|
scheduleMutationStateRefresh();
|
|
5587
|
+
render();
|
|
5507
5588
|
} else {
|
|
5508
5589
|
throw new Error("The save response did not include the current workspace state.");
|
|
5509
5590
|
}
|
|
@@ -5512,6 +5593,19 @@ function applyMutationState(result) {
|
|
|
5512
5593
|
|
|
5513
5594
|
function applyFastMutationPatch(result) {
|
|
5514
5595
|
state.stateToken = null;
|
|
5596
|
+
if (result.deleted && result.type && result.id) {
|
|
5597
|
+
state.resources = state.resources.filter(({ record }) => record.type !== result.type || record.id !== result.id);
|
|
5598
|
+
}
|
|
5599
|
+
if (result.dataRelativePath && typeof result.source === "string") {
|
|
5600
|
+
for (const entry of state.resources) {
|
|
5601
|
+
for (const item of Object.values(entry.content || {})) {
|
|
5602
|
+
if (item.path !== result.dataRelativePath) continue;
|
|
5603
|
+
item.source = result.source;
|
|
5604
|
+
item.revision = result.revision;
|
|
5605
|
+
if (typeof result.html === "string") item.html = result.html;
|
|
5606
|
+
}
|
|
5607
|
+
}
|
|
5608
|
+
}
|
|
5515
5609
|
if (result.operation === "collection-review" && result.assessment?.resourceType) {
|
|
5516
5610
|
state.collectionReviews[result.assessment.resourceType] = result.assessment;
|
|
5517
5611
|
}
|
|
@@ -5524,7 +5618,24 @@ function applyFastMutationPatch(result) {
|
|
|
5524
5618
|
state.resources.push({ record, content: {}, history: [], detailsLoaded: false });
|
|
5525
5619
|
}
|
|
5526
5620
|
}
|
|
5527
|
-
|
|
5621
|
+
const immediateRecords = [
|
|
5622
|
+
result.record,
|
|
5623
|
+
result.workspace,
|
|
5624
|
+
result.program,
|
|
5625
|
+
result.system,
|
|
5626
|
+
result.renderer,
|
|
5627
|
+
result.commitment,
|
|
5628
|
+
result.audit,
|
|
5629
|
+
result.event,
|
|
5630
|
+
result.created,
|
|
5631
|
+
result.linked,
|
|
5632
|
+
result.dismissal,
|
|
5633
|
+
result.result?.record,
|
|
5634
|
+
result.result?.created,
|
|
5635
|
+
result.result?.linked,
|
|
5636
|
+
...(result.actions || [])
|
|
5637
|
+
].filter((record) => record?.id && record?.type);
|
|
5638
|
+
for (const record of immediateRecords) {
|
|
5528
5639
|
const entry = state.resources.find(({ record: current }) => current.id === record.id);
|
|
5529
5640
|
if (entry) entry.record = record;
|
|
5530
5641
|
else state.resources.push({ record, content: {}, history: [], detailsLoaded: false });
|
|
@@ -5544,6 +5655,7 @@ function applyFastMutationPatch(result) {
|
|
|
5544
5655
|
}
|
|
5545
5656
|
|
|
5546
5657
|
function scheduleMutationStateRefresh(delay = 0) {
|
|
5658
|
+
mutationStateRefreshRequested = true;
|
|
5547
5659
|
if (mutationStateRefreshTimer || mutationStateRefreshInFlight) return;
|
|
5548
5660
|
mutationStateRefreshTimer = setTimeout(refreshMutationState, delay);
|
|
5549
5661
|
}
|
|
@@ -5552,6 +5664,7 @@ async function refreshMutationState() {
|
|
|
5552
5664
|
mutationStateRefreshTimer = null;
|
|
5553
5665
|
if (mutationStateRefreshInFlight) return;
|
|
5554
5666
|
mutationStateRefreshInFlight = true;
|
|
5667
|
+
mutationStateRefreshRequested = false;
|
|
5555
5668
|
let retry = false;
|
|
5556
5669
|
try {
|
|
5557
5670
|
const programQuery = state.selectedProgramId ? "?programId=" + encodeURIComponent(state.selectedProgramId) : "";
|
|
@@ -5567,24 +5680,24 @@ async function refreshMutationState() {
|
|
|
5567
5680
|
} finally {
|
|
5568
5681
|
mutationStateRefreshInFlight = false;
|
|
5569
5682
|
}
|
|
5570
|
-
if (retry) scheduleMutationStateRefresh(1_000);
|
|
5683
|
+
if (retry || mutationStateRefreshRequested) scheduleMutationStateRefresh(retry ? 1_000 : 0);
|
|
5571
5684
|
}
|
|
5572
5685
|
|
|
5573
|
-
function prefetchRepositoryForReview(status) {
|
|
5686
|
+
function prefetchRepositoryForReview(status = null) {
|
|
5574
5687
|
if (state.repository?.mode !== "trunk" || state.repository?.developmentOverride) {
|
|
5575
5688
|
return Promise.resolve(null);
|
|
5576
5689
|
}
|
|
5577
|
-
status.textContent = "Checking repository…";
|
|
5690
|
+
if (status) status.textContent = "Checking repository…";
|
|
5578
5691
|
return fetch("/api/git/prefetch", { method: "POST" })
|
|
5579
5692
|
.then(async (response) => {
|
|
5580
5693
|
if (!response.ok) throw new Error(await responseMessage(response));
|
|
5581
5694
|
const result = await response.json();
|
|
5582
|
-
status.textContent = result.status === "checked" ? "Repository checked" : "Ready to save";
|
|
5695
|
+
if (status) status.textContent = result.status === "checked" ? "Repository checked" : "Ready to save";
|
|
5583
5696
|
return result;
|
|
5584
5697
|
})
|
|
5585
5698
|
.catch((error) => {
|
|
5586
|
-
status.textContent = "Repository check failed";
|
|
5587
|
-
return
|
|
5699
|
+
if (status) status.textContent = "Repository check failed";
|
|
5700
|
+
return null;
|
|
5588
5701
|
});
|
|
5589
5702
|
}
|
|
5590
5703
|
|
|
@@ -5697,15 +5810,25 @@ async function localFetch(url, options) {
|
|
|
5697
5810
|
}
|
|
5698
5811
|
const scopedUrl = requestUrl.pathname + requestUrl.search + requestUrl.hash;
|
|
5699
5812
|
const method = String(options?.method || "GET").toUpperCase();
|
|
5813
|
+
const mutation = ["POST", "PUT", "DELETE"].includes(method);
|
|
5814
|
+
const requestOptions = mutation
|
|
5815
|
+
? {
|
|
5816
|
+
...options,
|
|
5817
|
+
headers: {
|
|
5818
|
+
...Object.fromEntries(new Headers(options?.headers || {}).entries()),
|
|
5819
|
+
prefer: "respond-async"
|
|
5820
|
+
}
|
|
5821
|
+
}
|
|
5822
|
+
: options;
|
|
5700
5823
|
const synchronizing = state?.repository?.mode === "trunk"
|
|
5701
|
-
&&
|
|
5824
|
+
&& mutation
|
|
5702
5825
|
&& !["/api/evidence-packet", "/api/git/prefetch"].includes(requestUrl.pathname);
|
|
5703
5826
|
const chip = synchronizing ? document.querySelector(".repo-chip") : null;
|
|
5704
5827
|
const previousChip = chip?.innerHTML;
|
|
5705
5828
|
let repositoryRefreshed = false;
|
|
5706
5829
|
if (chip) chip.innerHTML = '<span class="status-dot neutral"></span>Syncing';
|
|
5707
5830
|
try {
|
|
5708
|
-
const response = await fetch(scopedUrl,
|
|
5831
|
+
const response = await fetch(scopedUrl, requestOptions);
|
|
5709
5832
|
if (synchronizing && !response.ok) {
|
|
5710
5833
|
try {
|
|
5711
5834
|
const stateResponse = await fetch("/api/state?programId=" + encodeURIComponent(state.selectedProgramId || ""));
|