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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "filegrc",
3
- "version": "0.13.0",
3
+ "version": "0.13.1",
4
4
  "description": "Zero-dependency Git-native GRC engine",
5
5
  "license": "MIT",
6
6
  "repository": {
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 before = await validateWorkspace(loaded);
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
- const result = await validateWorkspace(loaded.root);
973
- const introduced = newErrors(result, before);
974
- if (introduced.length) throw new Error(formatWriteFailure(introduced, dataRelativePath));
975
- return { path, dataRelativePath };
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 objectId = git(root, ["hash-object", "--no-filters", "--", relativePath]);
685
- return /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/i.test(objectId) ? objectId : null;
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 bytes = readFileSync(descriptor);
2302
- const objectId = hashWorkspaceBytes(root, bytes);
2303
- return [path, { objectId, mode: opened.mode & 0o111 ? "100755" : "100644", bytes }];
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 hashWorkspaceBytes(root, bytes, write = false) {
2320
- return observedExecFileSync("git", ["hash-object", ...(write ? ["-w"] : []), "--stdin"], {
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: bytes,
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
- function writeWorkspaceManifestObjects(root, manifest) {
2332
- return new Map([...manifest].map(([path, value]) => (
2333
- value.objectId
2334
- ? [path, { ...value, objectId: hashWorkspaceBytes(root, value.bytes, true) }]
2335
- : [path, value]
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 && value.objectId === other.objectId && value.mode === other.mode;
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
- invalidateStateSessions: () => invalidateStateSessions(stateSessions)
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
- const [snapshot, repositorySignature] = await withGitCommandDeadline(deadlineAt, () => (
137
- serializeWorkspaceMutation(input, (root) => Promise.all([
138
- fingerprintWorkspace(root, {
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
- getRepositoryStateSignature(root, {
143
- timeoutMs: Math.max(1, Math.ceil(deadlineAt - performance.now()))
144
- })
145
- ]))
146
- ));
147
- const loaded = snapshot.loaded;
148
- const token = randomUUID();
149
- const session = {
150
- loaded,
151
- fingerprint: snapshot.fingerprint,
152
- repositorySignature,
153
- fileDigestCache,
154
- generatedAt: new Date().toISOString(),
155
- expiresAt: Date.now() + STATE_SESSION_MAX_AGE_MS,
156
- revoked: false,
157
- promises: new Map(),
158
- verificationPromise: null,
159
- gitCommandCache: new Map()
160
- };
161
- pruneStateSessions(stateSessions);
162
- stateSessions.set(token, session);
163
- while (stateSessions.size > MAX_STATE_SESSIONS) {
164
- const oldestToken = stateSessions.keys().next().value;
165
- const oldestSession = stateSessions.get(oldestToken);
166
- if (oldestSession) oldestSession.revoked = true;
167
- stateSessions.delete(oldestToken);
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
- const state = await createAppBootstrap(loaded, {
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 payload = normalizeResourceMutation(await readJson(request));
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
- let result;
570
- try {
571
- result = await retryBrowserSync(input, {
601
+ const result = await manualGitResultWithState(input, requestOptions, () => (
602
+ retryBrowserSync(input, {
572
603
  allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites
573
- });
574
- } finally {
575
- requestOptions.invalidateStateSessions();
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, result);
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 payload = normalizeResourceMutation(await readJson(request), { requireRevision: true });
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 === true;
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.invalidateStateSessions?.();
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.invalidateStateSessions?.();
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 histories = getWorkspaceHistories(loaded.root, [relativePath], 12, {
372
- deadlineAt: options.historyDeadlineAt
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
- history: histories.get(relativePath) ?? []
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
- await validateAttestationBinding(record, loaded.model, loaded.root, byId, displayPath, diagnostics);
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
- try {
177
- const path = resolveDataPath(loaded.root, item);
178
- if (!(await stat(path)).isFile()) throw new Error("The data path is not a file.");
179
- } catch {
180
- diagnostics.push(error(
181
- "missing-content",
182
- displayPath,
183
- `${fieldName} points to unavailable data path "${item}".`
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
- await validateMarkdown(record, definition, loaded.model, loaded.root, displayPath, diagnostics);
213
- await validateApprovalBinding(record, loaded.model, loaded.root, displayPath, diagnostics);
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 (waitingFor.length) renderStateLoading(main, route, waitingFor);
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 content and file history…</section></div>';
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: personParticipation(entry),
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 response = await localFetch("/api/resource/" + encodeURIComponent(type) + "/" + encodeURIComponent(id) + "?revision=" + encodeURIComponent(entry.revision), { method: "DELETE" });
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: { "content-type": "application/json" },
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 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 }) });
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
- for (const record of [result.workspace, result.program, result.system, result.renderer, result.commitment].filter(Boolean)) {
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 { error };
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
- && ["POST", "PUT", "DELETE"].includes(method)
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, options);
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 || ""));