diff --git a/docs/architecture.md b/docs/architecture.md
index efaabb3..a1b42d5 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -818,7 +818,7 @@ A risk baseline covers every selected System, active Component, material provide
Operating ownership can use existing implemented Control procedures and approved program Documents. Dated rehearsal Evidence selects the procedures actually used, or management selects their IDs on the Program. Existing Document approval revisions and normal Control oversight remain authoritative. Material changes follow the existing review process; no separate Document or duplicate independent approval is introduced solely for this group.
-Tokenized resource navigation projects the same snapshot-scoped workflow section used by root navigation. Previously, detail requests repeated full synchronous workflow and Git-history work on the HTTP thread after the shared worker had already calculated it. Detail now joins or reuses that worker calculation, retains each caller's deadline and verifies live file and Git signatures before responding. The 10-second request budgets remain unchanged. Tests cover warm computation bounds, concurrent cold requests, recovery after an earlier caller deadline, transient Git failures and invalidation after source changes.
+Tokenized resource navigation projects the same snapshot-scoped workflow section used by root navigation. Previously, detail requests repeated full synchronous workflow and Git-history work on the HTTP thread after the shared worker had already calculated it. Detail now joins or reuses that worker calculation, retains each caller's deadline and verifies live file and Git signatures before responding. Cold section and resource calculations have a separate two-minute aggregate budget, while every individual Git subprocess remains limited to ten seconds. Browser reads opt into `Prefer: respond-async`: after a one-second response wait, a still-running shared calculation returns `202` with a token-bound pending status and `Retry-After: 1`. Polls join that same bounded read, retain loading UI, and deliver data only after live snapshot verification. Actual failures remain errors with an explicit Retry loading action. Other HTTP clients retain the wait-for-completion contract. Tests cover concurrent pending sections and details, effective subprocess limits, bounded failures, explicit retries and source-change invalidation.
Finalized operating proof freezes explicit completion, Evidence, Exception and Attestation relationships at the owner's first finalized Git revision. Evidence source-resource links describe context, so current Controls, inventories and ongoing reviews can evolve without rewriting that proof. Source links to Evidence, Exceptions and Attestations still protect those proof records. Mutation guards and historical validation share the same traversal; historical validation resolves the traversal from committed records rather than current links. Evidence packets retain committed source versions alongside current context.
diff --git a/package-lock.json b/package-lock.json
index a0045d5..96ce311 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "filegrc-monorepo",
- "version": "0.21.6",
+ "version": "0.21.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "filegrc-monorepo",
- "version": "0.21.6",
+ "version": "0.21.7",
"workspaces": [
"packages/filegrc",
"packages/create-filegrc"
@@ -24,7 +24,7 @@
"link": true
},
"packages/create-filegrc": {
- "version": "0.21.6",
+ "version": "0.21.7",
"license": "MIT",
"bin": {
"create-filegrc": "bin/create-filegrc.js"
@@ -34,7 +34,7 @@
}
},
"packages/filegrc": {
- "version": "0.21.6",
+ "version": "0.21.7",
"license": "MIT",
"bin": {
"filegrc": "bin/filegrc.js"
diff --git a/package.json b/package.json
index 7d883ff..e34f3b6 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "filegrc-monorepo",
- "version": "0.21.6",
+ "version": "0.21.7",
"private": true,
"workspaces": [
"packages/filegrc",
diff --git a/packages/create-filegrc/package.json b/packages/create-filegrc/package.json
index 6e93dc4..b162ea4 100644
--- a/packages/create-filegrc/package.json
+++ b/packages/create-filegrc/package.json
@@ -1,6 +1,6 @@
{
"name": "create-filegrc",
- "version": "0.21.6",
+ "version": "0.21.7",
"description": "Create a filegrc workspace for a SOC 2 program",
"license": "MIT",
"repository": {
diff --git a/packages/create-filegrc/src/index.js b/packages/create-filegrc/src/index.js
index cc7abb0..d268796 100644
--- a/packages/create-filegrc/src/index.js
+++ b/packages/create-filegrc/src/index.js
@@ -524,13 +524,13 @@ async function runCombinedSetup(target, input) {
async function writeMinimalLockfile(target, name, versionRange) {
const lock = {
name,
- version: "0.21.6",
+ version: "0.21.7",
lockfileVersion: 3,
requires: true,
packages: {
"": {
name,
- version: "0.21.6",
+ version: "0.21.7",
dependencies: { filegrc: versionRange }
}
}
diff --git a/packages/create-filegrc/template/package.json b/packages/create-filegrc/template/package.json
index 19bf343..7b927cd 100644
--- a/packages/create-filegrc/template/package.json
+++ b/packages/create-filegrc/template/package.json
@@ -1,6 +1,6 @@
{
"name": "{{project_name}}",
- "version": "0.21.6",
+ "version": "0.21.7",
"private": true,
"description": "filegrc workspace for a SOC 2 program",
"type": "module",
diff --git a/packages/filegrc/package.json b/packages/filegrc/package.json
index 8dee208..9cb0668 100644
--- a/packages/filegrc/package.json
+++ b/packages/filegrc/package.json
@@ -1,6 +1,6 @@
{
"name": "filegrc",
- "version": "0.21.6",
+ "version": "0.21.7",
"description": "Zero-dependency Git-native GRC engine",
"license": "MIT",
"repository": {
diff --git a/packages/filegrc/src/git.js b/packages/filegrc/src/git.js
index 1f2123e..0bb4a3e 100644
--- a/packages/filegrc/src/git.js
+++ b/packages/filegrc/src/git.js
@@ -50,6 +50,7 @@ const repositorySnapshotPromises = new Map();
const repositoryObjectFormats = new Map();
const gitCommandCaches = new AsyncLocalStorage();
const gitCommandDeadlines = new AsyncLocalStorage();
+const gitCommandTimeoutLimits = new AsyncLocalStorage();
const gitCommandAdapters = new AsyncLocalStorage();
const gitCommandCacheBytes = new WeakMap();
let gitCommandInterceptor = null;
@@ -80,10 +81,16 @@ export function withGitCommandCache(cache, callback) {
return gitCommandCaches.run(cache, callback);
}
-export function withGitCommandDeadline(deadlineAt, callback) {
+export function withGitCommandDeadline(deadlineAt, callback, options = {}) {
if (!Number.isFinite(deadlineAt))
throw new TypeError("The Git command deadline must be finite.");
- return gitCommandDeadlines.run(deadlineAt, callback);
+ const limit = options.commandTimeoutMs;
+ if (limit === undefined) return gitCommandDeadlines.run(deadlineAt, callback);
+ if (!Number.isFinite(limit) || limit <= 0)
+ throw new TypeError("The individual Git command timeout must be positive and finite.");
+ const inherited = gitCommandTimeoutLimits.getStore();
+ return gitCommandTimeoutLimits.run(Math.min(limit, inherited ?? limit), () =>
+ gitCommandDeadlines.run(deadlineAt, callback));
}
function gitEnvironment(overrides = {}) {
@@ -4081,6 +4088,7 @@ function runGitCommandNative(cwd, args, options = {}) {
cwd,
args: [...args],
options: { ...options },
+ timeoutMs,
});
return new Promise((resolveCommand, rejectCommand) => {
const child = spawn("git", args, {
@@ -4344,7 +4352,7 @@ function boundedGitTimeout(configured, fallback) {
}
function gitTimeoutPlan(configured, fallback) {
- const requested = Math.max(1, Number(configured) || fallback);
+ const requested = Math.min(Math.max(1, Number(configured) || fallback), gitCommandTimeoutLimits.getStore() ?? Infinity);
const deadlineAt = gitCommandDeadlines.getStore();
if (deadlineAt === undefined)
return { timeoutMs: requested, deadlineLimited: false };
diff --git a/packages/filegrc/src/server.js b/packages/filegrc/src/server.js
index 28c9f64..98c60ea 100644
--- a/packages/filegrc/src/server.js
+++ b/packages/filegrc/src/server.js
@@ -94,7 +94,11 @@ const STATE_SESSION_MAX_AGE_MS = 5 * 60_000;
const MAX_STATE_SESSIONS = 8;
const MAX_STATE_SESSION_PROMISES = 64;
const RESOURCE_DETAIL_GIT_DEADLINE_MS = 10_000;
-const STATE_SECTION_GIT_DEADLINE_MS = 10_000;
+// A whole cold calculation can legitimately exceed a single Git command's
+// timeout. Keep Git's per-command protection, but bound the complete read too.
+const STATE_CALCULATION_DEADLINE_MS = 120_000;
+const STATE_RESPONSE_WAIT_MS = 1_000;
+const STATE_PENDING = Symbol("pending state calculation");
export function createFilegrcServer(input = process.cwd(), options = {}) {
assertWorkspaceEngineVersion(input, { allowModelDiagnostics: true });
@@ -169,14 +173,14 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
if (!sameOriginBrowserRead(request)) {
return json(response, 403, { error: "Cross-origin state requests are not allowed." });
}
- const deadlineAt = performance.now() + STATE_SECTION_GIT_DEADLINE_MS;
+ const deadlineAt = performance.now() + RESOURCE_DETAIL_GIT_DEADLINE_MS;
for (let attempt = 0; attempt < 3; attempt += 1) {
if (activeStateMutations > 0) {
await awaitWithinDeadline(new Promise((resolve) => stateMutationWaiters.push(resolve)), deadlineAt);
}
const generation = stateInvalidationGeneration;
if (!bootstrapSnapshotPromise) {
- bootstrapSnapshotPromise = withGitCommandDeadline(deadlineAt, () => stableStateSnapshot(input, {
+ bootstrapSnapshotPromise = withReadGitDeadline(deadlineAt, () => stableStateSnapshot(input, {
fileDigestCache,
deadlineAt
})).finally(() => {
@@ -210,6 +214,7 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
expiresAt: Date.now() + STATE_SESSION_MAX_AGE_MS,
revoked: false,
promises: new Map(),
+ reads: new Map(),
verificationPromise: null,
calculationCache: verifiedGitCache,
gitCommandCache: verifiedGitCache.commands
@@ -243,14 +248,18 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
if (!session) return json(response, 409, { error: "The workspace state expired. Reload it and try again." });
const sectionDeadlineMs = Number.isFinite(options.stateSectionDeadlineMs)
? Math.max(0, options.stateSectionDeadlineMs)
- : STATE_SECTION_GIT_DEADLINE_MS;
+ : STATE_CALCULATION_DEADLINE_MS;
const deadlineAt = performance.now() + sectionDeadlineMs;
- const calculate = () => withGitCommandDeadline(deadlineAt, () => (
+ const calculate = () => withReadGitDeadline(deadlineAt, () => (
loadStateSessionSection(session, section, options, url.searchParams.get("programId") || undefined, null, deadlineAt)
));
- const measured = timingEnabled() ? await collectTimings(calculate) : null;
- const state = measured ? measured.result : await calculate();
- if (measured) console.error(`[filegrc timing] ${JSON.stringify({ operation: "state-section", section, ...measured.timings })}`);
+ const measuredCalculate = timingEnabled() ? async () => {
+ const measured = await collectTimings(calculate);
+ console.error(`[filegrc timing] ${JSON.stringify({ operation: "state-section", section, ...measured.timings })}`);
+ return measured.result;
+ } : calculate;
+ const state = await respondToStateRead(session, `section:${section}:${requestOptions.programId || ""}`, request, options, measuredCalculate);
+ if (state === STATE_PENDING) return pendingStateResponse(response, token);
assertCurrentStateSession(session);
return json(response, 200, { stateToken: token, section, state });
}
@@ -780,14 +789,18 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
const historyOnly = url.searchParams.get("history") === "only";
if (historyOnly) {
const history = session
- ? await loadStateSessionResourceHistory(session, token, type, id, options)
+ ? await respondToStateRead(session, `history:${type}:${id}`, request, options,
+ () => loadStateSessionResourceHistory(session, token, type, id, options))
: await createResourceHistory(input, type, id);
+ if (history === STATE_PENDING) return pendingStateResponse(response, token);
if (!history) return json(response, 404, { error: "Resource not found." });
return json(response, 200, history);
}
const entry = session
- ? await loadStateSessionResource(session, token, type, id, options, requestOptions.programId, includeWorkflow, url.searchParams.get("history") !== "false")
+ ? await respondToStateRead(session, `detail:${type}:${id}:${requestOptions.programId || ""}:${includeWorkflow}:${url.searchParams.get("history") !== "false"}`, request, options,
+ () => loadStateSessionResource(session, token, type, id, options, requestOptions.programId, includeWorkflow, url.searchParams.get("history") !== "false"))
: await createResourceDetail(input, type, id, { includeHistory: url.searchParams.get("history") !== "false" });
+ if (entry === STATE_PENDING) return pendingStateResponse(response, token);
if (!entry) return json(response, 404, { error: "Resource not found." });
if (includeWorkflow && !session) {
const loaded = await loadWorkspace(input);
@@ -922,7 +935,8 @@ export async function serveWorkspace(input = process.cwd(), options = {}) {
allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites === true,
backgroundPushDelayMs: options.backgroundPushDelayMs,
stateSectionDeadlineMs: options.stateSectionDeadlineMs,
- resourceDetailDeadlineMs: options.resourceDetailDeadlineMs
+ resourceDetailDeadlineMs: options.resourceDetailDeadlineMs,
+ stateResponseWaitMs: options.stateResponseWaitMs
});
let usedFallbackPort = false;
try {
@@ -1121,9 +1135,9 @@ async function loadStateSessionResource(session, token, type, id, serverOptions,
assertCurrentStateSession(session);
const detailDeadlineMs = Number.isFinite(serverOptions.resourceDetailDeadlineMs)
? Math.max(0, serverOptions.resourceDetailDeadlineMs)
- : RESOURCE_DETAIL_GIT_DEADLINE_MS;
+ : STATE_CALCULATION_DEADLINE_MS;
const deadlineAt = performance.now() + detailDeadlineMs;
- return withGitCommandDeadline(deadlineAt, async () => {
+ return withReadGitDeadline(deadlineAt, async () => {
const detail = await withGitCommandCache(session.gitCommandCache, () => createResourceDetail(session.loaded, type, id, {
historyDeadlineAt: deadlineAt,
includeHistory
@@ -1144,9 +1158,9 @@ async function loadStateSessionResourceHistory(session, token, type, id, serverO
assertCurrentStateSession(session);
const detailDeadlineMs = Number.isFinite(serverOptions.resourceDetailDeadlineMs)
? Math.max(0, serverOptions.resourceDetailDeadlineMs)
- : RESOURCE_DETAIL_GIT_DEADLINE_MS;
+ : STATE_CALCULATION_DEADLINE_MS;
const deadlineAt = performance.now() + detailDeadlineMs;
- return withGitCommandDeadline(deadlineAt, async () => {
+ return withReadGitDeadline(deadlineAt, async () => {
const history = await withGitCommandCache(session.gitCommandCache, () => createResourceHistory(session.loaded, type, id, {
historyDeadlineAt: deadlineAt
}));
@@ -1156,6 +1170,53 @@ async function loadStateSessionResourceHistory(session, token, type, id, serverO
});
}
+function withReadGitDeadline(deadlineAt, task) {
+ return withGitCommandDeadline(deadlineAt, task, { commandTimeoutMs: RESOURCE_DETAIL_GIT_DEADLINE_MS });
+}
+
+// Only opt-in browser reads use pending responses. Other API clients retain the
+// original await-until-complete contract. A pending response is never valid data.
+async function respondToStateRead(session, key, request, options, calculate) {
+ if (!prefersFastMutation(request)) return calculate();
+ assertCurrentStateSession(session);
+ let job = session.reads.get(key);
+ if (!job) {
+ if (session.reads.size >= MAX_STATE_SESSION_PROMISES) {
+ const completed = [...session.reads].find(([, value]) => value.completed)?.[0];
+ if (completed === undefined) throw stateSessionCapacityError();
+ session.reads.delete(completed);
+ }
+ job = { completed: false, promise: null };
+ // Retain failures until a caller receives them, then allow an explicit retry.
+ job.promise = Promise.resolve().then(calculate).finally(() => { job.completed = true; });
+ job.promise.catch(() => {});
+ session.reads.set(key, job);
+ }
+ const waitMs = Number.isFinite(options.stateResponseWaitMs)
+ ? Math.max(0, options.stateResponseWaitMs) : STATE_RESPONSE_WAIT_MS;
+ let timer;
+ let result;
+ try {
+ result = await Promise.race([
+ job.promise,
+ new Promise((resolve) => { timer = setTimeout(() => resolve(STATE_PENDING), waitMs); })
+ ]);
+ if (result === STATE_PENDING) { assertCurrentStateSession(session); return result; }
+ // A completed read may have waited for its next poll. Verify its snapshot
+ // again before delivering it; no stale result may enable browser writes.
+ await verifyStateSessionSnapshot(session, performance.now(), performance.now() + RESOURCE_DETAIL_GIT_DEADLINE_MS);
+ return result;
+ } finally {
+ clearTimeout(timer);
+ if (job.completed && session.reads.get(key) === job) session.reads.delete(key);
+ }
+}
+
+function pendingStateResponse(response, token) {
+ response.setHeader("Retry-After", "1");
+ return json(response, 202, { stateToken: token, status: "pending" });
+}
+
function prefersFastMutation(request) {
return String(request.headers.prefer || "")
.split(",")
diff --git a/packages/filegrc/src/state-calculator-worker.js b/packages/filegrc/src/state-calculator-worker.js
index bd8e189..28d2dc4 100644
--- a/packages/filegrc/src/state-calculator-worker.js
+++ b/packages/filegrc/src/state-calculator-worker.js
@@ -21,7 +21,7 @@ parentPort.on("message", async ({ id, snapshotId, loaded, section, options, dead
if (section !== "validation") return createAppStateSection(snapshot.loaded, section, options);
const { ok, counts, diagnostics, reconciliation } = await validateWorkspace(snapshot.loaded);
return { ok, counts, diagnostics, reconciliation };
- })));
+ })), { commandTimeoutMs: 10_000 });
parentPort.postMessage({ id, result });
} catch (error) {
parentPort.postMessage({ id, error: { message: error.message, code: error.code, source: error.source } });
diff --git a/packages/filegrc/src/web.js b/packages/filegrc/src/web.js
index a0567f4..35eb3a6 100644
--- a/packages/filegrc/src/web.js
+++ b/packages/filegrc/src/web.js
@@ -318,7 +318,7 @@ function renderStateLoading(main, route, sections) {
const message = failed
? state.sectionErrors?.[failed] || "Reload the workspace and try again."
: "Records are ready. FileGRC is calculating the information needed for this page.";
- main.innerHTML = '
' + (failed ? "" : '') + '' + (failed ? "Loading error" : "One moment") + '
' + esc(title) + '
' + esc(message) + '
';
+ main.innerHTML = '' + (failed ? "" : '') + '' + (failed ? "Loading error" : "One moment") + '
' + esc(title) + '
' + esc(message) + '
' + (failed ? '
' : '') + '
';
}
function loadStateForRoute() {
@@ -332,7 +332,7 @@ function loadStateSection(section, refreshExpired = true) {
state.sections[section] = "loading";
const token = state.stateToken;
const programQuery = state.selectedProgramId ? "&programId=" + encodeURIComponent(state.selectedProgramId) : "";
- const request = localFetch("/api/state/" + encodeURIComponent(section) + "?token=" + encodeURIComponent(token) + programQuery)
+ const request = pendingStateFetch("/api/state/" + encodeURIComponent(section) + "?token=" + encodeURIComponent(token) + programQuery)
.then(async (response) => {
if (response.status === 409 && refreshExpired && state.stateToken === token) {
await refreshExpiredAppState(token);
@@ -4082,7 +4082,7 @@ async function loadResourceDetail(type, id) {
let detail = null;
for (let attempt = 0; attempt < 3; attempt += 1) {
const tokenQuery = token ? "?token=" + encodeURIComponent(token) + "&history=false&workflow=true" : "?history=false&workflow=true";
- const response = await localFetch("/api/resource/" + encodeURIComponent(type) + "/" + encodeURIComponent(id) + tokenQuery);
+ const response = await pendingStateFetch("/api/resource/" + encodeURIComponent(type) + "/" + encodeURIComponent(id) + tokenQuery);
if (response.status === 409 && token) {
await refreshExpiredAppState(token);
token = state.stateToken;
@@ -4107,7 +4107,10 @@ async function loadResourceDetail(type, id) {
const route = parseRoute();
if (route.name === "detail" && route.type === type && route.id === id) {
const main = root.querySelector("main");
- if (main) main.innerHTML = '' + esc(error.message) + '
';
+ if (main) {
+ main.innerHTML = '' + esc(error.message) + '
';
+ main.querySelector("[data-retry-state]").addEventListener("click", () => location.reload());
+ }
}
} finally {
for (const [requestKey, pending] of resourceDetailRequests) {
@@ -4126,7 +4129,7 @@ async function loadResourceHistory(type, id) {
const token = state.stateToken;
try {
const tokenQuery = (token ? "?token=" + encodeURIComponent(token) + "&" : "?") + "history=only";
- const response = await localFetch("/api/resource/" + encodeURIComponent(type) + "/" + encodeURIComponent(id) + tokenQuery);
+ const response = await pendingStateFetch("/api/resource/" + encodeURIComponent(type) + "/" + encodeURIComponent(id) + tokenQuery);
if (response.status === 409 && token) {
await refreshExpiredAppState(token);
const route = parseRoute();
@@ -6345,6 +6348,7 @@ async function selectProgram(programId, destination = null) {
}
function bindCommon() {
+ root.querySelector("[data-retry-state]")?.addEventListener("click", () => location.reload());
root.querySelector("[data-program-select]")?.addEventListener("change", (event) => {
selectProgram(event.currentTarget.value);
});
@@ -7039,6 +7043,25 @@ async function responseMessage(response) {
const source = await response.text();
try { return JSON.parse(source).error || source; } catch { return source; }
}
+// Pending reads share a bounded server calculation. Keep the loading state,
+// rather than treating an ordinary cold calculation as failed Git or bad data.
+async function pendingStateFetch(url) {
+ const token = state.stateToken;
+ const generation = programSelectionGeneration;
+ const expiresAt = Date.now() + 135_000;
+ while (true) {
+ const response = await localFetch(url, { headers: { prefer: "respond-async" } });
+ if (response.status !== 202) return response;
+ const pending = await response.json();
+ if (pending.status !== "pending" || pending.stateToken !== token) throw new Error("The workspace calculation returned an invalid pending response. Retry loading.");
+ if (state.stateToken !== token || generation !== programSelectionGeneration) {
+ return new Response(JSON.stringify({ error: "The workspace state changed. Retry loading." }), { status: 409 });
+ }
+ if (Date.now() >= expiresAt) throw new Error("This workspace calculation is taking too long. Retry loading.");
+ await new Promise((resolve) => setTimeout(resolve, 1_000));
+ }
+}
+
async function localFetch(url, options) {
const requestProgramId = state?.selectedProgramId || null;
const requestProgramGeneration = programSelectionGeneration;
diff --git a/packages/filegrc/test/render.test.js b/packages/filegrc/test/render.test.js
index 8877e31..4d2e623 100644
--- a/packages/filegrc/test/render.test.js
+++ b/packages/filegrc/test/render.test.js
@@ -1126,6 +1126,7 @@ test("clears review revision binding busy state after a request failure", async
const harness = new Function("dependencies", `
const esc = String;
const localFetch = dependencies.localFetch;
+ const pendingStateFetch = dependencies.localFetch;
const responseMessage = async () => "request failed";
const retentionUseReviewIds = () => [];
${source}
@@ -1304,6 +1305,7 @@ test("ends supplemental history loading after a request failure", async () => {
const state = dependencies.state;
const resourceDetailRequests = new Map();
const localFetch = dependencies.localFetch;
+ const pendingStateFetch = dependencies.localFetch;
const refreshExpiredAppState = async () => {};
const responseMessage = async () => "request failed";
const parseRoute = () => ({ name: "detail", type: "person", id: "person-owner" });
@@ -1338,6 +1340,7 @@ test("ignores a supplemental history failure from a stale state snapshot", async
let state = dependencies.state;
const resourceDetailRequests = new Map();
const localFetch = dependencies.localFetch;
+ const pendingStateFetch = dependencies.localFetch;
const refreshExpiredAppState = async () => {};
const responseMessage = async () => "request failed";
const parseRoute = () => ({ name: "detail", type: "person", id: "person-owner" });
@@ -2508,6 +2511,7 @@ test("resource detail retries when a concurrent section refresh replaces its sta
const stateSectionRequests = new Map();
let expiredStateRefresh = null;
const localFetch = dependencies.localFetch;
+ const pendingStateFetch = dependencies.localFetch;
const fetch = dependencies.fetch;
const normalizeAppState = (value) => value;
const responseMessage = async () => "request failed";
@@ -2581,6 +2585,7 @@ test("state sections start a new request after refreshing an expired token", asy
};
const stateSectionRequests = new Map();
const localFetch = dependencies.localFetch;
+ const pendingStateFetch = dependencies.localFetch;
const refreshExpiredAppState = async () => { state.stateToken = "token-b"; state.sections.program = "idle"; };
const responseMessage = async () => "request failed";
const render = () => {};
@@ -3068,3 +3073,43 @@ test("snapshot population copy requests an actual observation date without inven
assert.match(vm.runInContext("obligationCard(item)", context), /review the required period end/);
assert.match(vm.runInContext("occurrenceMemberReview(record, true, false)", context), /Review the required period end/);
});
+
+
+test("browser pending reads keep waiting for actual state and stop on errors or changed tokens", async () => {
+ const start = APP_SCRIPT.indexOf("async function pendingStateFetch");
+ const end = APP_SCRIPT.indexOf("async function localFetch", start);
+ const source = APP_SCRIPT.slice(start, end);
+ function harness(responses, now = () => 0) {
+ let calls = 0;
+ const state = { stateToken: "token-a" };
+ const load = new Function("dependencies", `
+ const state = dependencies.state;
+ const programSelectionGeneration = 0;
+ const Date = { now: dependencies.now };
+ const setTimeout = (resolve) => { resolve(); };
+ const localFetch = dependencies.fetch;
+ ${source}
+ return pendingStateFetch;
+ `)({ state, now, fetch: async (_url, options) => {
+ assert.equal(options.headers.prefer, "respond-async");
+ const next = responses[calls++];
+ return typeof next === "function" ? next(state) : next;
+ } });
+ return { load, calls: () => calls };
+ }
+ const pending = () => new Response(JSON.stringify({ stateToken: "token-a", status: "pending" }), { status: 202 });
+ const complete = new Response(JSON.stringify({ stateToken: "token-a", state: {} }), { status: 200 });
+ const ordinary = harness([pending(), pending(), complete]);
+ assert.equal(await ordinary.load("/api/state/repository?token=token-a"), complete);
+ assert.equal(ordinary.calls(), 3);
+ const failed = harness([new Response("failure", { status: 503 })]);
+ assert.equal((await failed.load("/api/state/repository?token=token-a")).status, 503);
+ assert.equal(failed.calls(), 1);
+ const changed = harness([(state) => { state.stateToken = "token-b"; return pending(); }]);
+ assert.equal((await changed.load("/api/state/repository?token=token-a")).status, 409);
+ assert.equal(changed.calls(), 1);
+ let clock = 0;
+ const bounded = harness([pending()], () => clock++ === 0 ? 0 : 135_000);
+ await assert.rejects(bounded.load("/api/state/repository?token=token-a"), /taking too long/);
+ assert.equal(bounded.calls(), 1);
+});
diff --git a/packages/filegrc/test/state-pending.test.js b/packages/filegrc/test/state-pending.test.js
new file mode 100644
index 0000000..c9c3e64
--- /dev/null
+++ b/packages/filegrc/test/state-pending.test.js
@@ -0,0 +1,219 @@
+import assert from "node:assert/strict";
+import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { setTimeout as delay } from "node:timers/promises";
+import test from "node:test";
+import { serveWorkspace } from "../src/server.js";
+import { runGitCommand, runGitCommandSync, setGitCommandInterceptorForTests, setGitSubprocessObserverForTests, withGitCommandDeadline } from "../src/git.js";
+import { initializeGitWorkspace, makeWorkspace } from "./helpers.js";
+
+const asyncHeaders = { prefer: "respond-async" };
+
+async function fixture(context, options = {}) {
+ const root = await mkdtemp(join(tmpdir(), "filegrc-state-pending-"));
+ await makeWorkspace(root);
+ await initializeGitWorkspace(root);
+ const running = await serveWorkspace(root, {
+ port: 0,
+ stateResponseWaitMs: 0,
+ ...options
+ });
+ context.after(async () => {
+ await new Promise((resolve) => running.server.close(resolve));
+ await rm(root, { recursive: true, force: true });
+ });
+ const response = await fetch(`${running.url}/api/state/bootstrap`);
+ assert.equal(response.status, 200);
+ const bootstrap = await response.json();
+ return { root, url: running.url, token: bootstrap.stateToken };
+}
+
+function sectionPath(section, token) {
+ return `/api/state/${section}?token=${encodeURIComponent(token)}`;
+}
+
+function resourcePath(token, parameters = "history=false&workflow=true") {
+ return `/api/resource/person/person-owner?${parameters}&token=${encodeURIComponent(token)}`;
+}
+
+async function assertPending(response, token) {
+ assert.equal(response.status, 202);
+ assert.equal(response.headers.get("retry-after"), "1");
+ const body = await response.json();
+ assert.equal(body.stateToken, token);
+ assert.equal(body.status, "pending");
+ assert.equal(body.state, undefined);
+ assert.equal(body.validation, undefined);
+ assert.equal(body.record, undefined);
+}
+
+async function poll(url) {
+ const expiresAt = performance.now() + 15_000;
+ while (performance.now() < expiresAt) {
+ const response = await fetch(url, { headers: asyncHeaders });
+ if (response.status !== 202) return response;
+ assert.equal(response.headers.get("retry-after"), "1");
+ const pending = await response.json();
+ assert.equal(pending.status, "pending");
+ await delay(20);
+ }
+ assert.fail("The same state calculation stayed pending for more than 15 seconds.");
+}
+
+test("cold state sections return pending and finish for async and legacy callers", async (context) => {
+ const { url, token } = await fixture(context);
+ const sections = ["repository", "program", "obligations", "audits", "workflow"];
+ const pending = await Promise.all(sections.map((section) => (
+ fetch(`${url}${sectionPath(section, token)}`, { headers: asyncHeaders })
+ )));
+ await Promise.all(pending.map((response) => assertPending(response, token)));
+
+ // A normal caller joins work started by the short async response and still
+ // receives the complete state contract in one response.
+ const completed = await Promise.all(sections.map(async (section) => {
+ const response = await fetch(`${url}${sectionPath(section, token)}`);
+ assert.equal(response.status, 200);
+ const body = await response.json();
+ assert.equal(body.stateToken, token);
+ assert.equal(body.section, section);
+ assert.equal(typeof body.state, "object");
+ assert.notEqual(body.state, null);
+ return body;
+ }));
+ assert.equal(completed[0].state.validation.ok, true);
+ assert.ok(completed[1].state.programReadiness);
+ assert.ok(completed[4].state.workflow);
+
+ const retry = await poll(`${url}${sectionPath("workflow", token)}`);
+ assert.equal(retry.status, 200);
+ assert.deepEqual(await retry.json(), completed[4]);
+});
+
+test("concurrent pending resource details and history retain the complete result", async (context) => {
+ const { url, token } = await fixture(context);
+ const paths = [resourcePath(token), resourcePath(token, "history=only")];
+ for (const path of paths) {
+ const responses = await Promise.all(Array.from({ length: 3 }, () => (
+ fetch(`${url}${path}`, { headers: asyncHeaders })
+ )));
+ await Promise.all(responses.map((response) => assertPending(response, token)));
+ const legacy = await fetch(`${url}${path}`);
+ assert.equal(legacy.status, 200);
+ const expected = await legacy.json();
+ assert.equal(expected.stateToken, token);
+ if (path.includes("history=only")) {
+ assert.equal(expected.historyLoaded, true);
+ assert.ok(expected.history.length > 0);
+ } else {
+ assert.equal(expected.record.id, "person-owner");
+ assert.equal(expected.detailsLoaded, true);
+ assert.equal(expected.historyLoaded, false);
+ assert.ok(expected.workflow);
+ }
+ const retries = await Promise.all(Array.from({ length: 3 }, () => poll(`${url}${path}`)));
+ for (const response of retries) {
+ assert.equal(response.status, 200);
+ assert.deepEqual(await response.json(), expected);
+ }
+ }
+});
+
+test("cold requests without the async preference still wait for actual state", async (context) => {
+ const { url, token } = await fixture(context);
+ const responses = await Promise.all([
+ fetch(`${url}${sectionPath("program", token)}`),
+ fetch(`${url}${resourcePath(token)}`)
+ ]);
+ for (const response of responses) assert.equal(response.status, 200);
+ const [program, resource] = await Promise.all(responses.map((response) => response.json()));
+ assert.ok(program.state.programReadiness);
+ assert.equal(resource.record.id, "person-owner");
+ assert.ok(resource.workflow);
+});
+
+test("async response budgets do not replace the total calculation deadlines", async (context) => {
+ for (const options of [{ stateSectionDeadlineMs: 0 }, { resourceDetailDeadlineMs: 0 }]) {
+ const { url, token } = await fixture(context, options);
+ const path = options.stateSectionDeadlineMs === 0
+ ? sectionPath("repository", token)
+ : resourcePath(token);
+ const response = await poll(`${url}${path}`);
+ assert.equal(response.status, 503);
+ const failure = await response.json();
+ assert.equal(typeof failure.error, "string");
+ assert.equal(failure.state, undefined);
+ assert.equal(failure.validation, undefined);
+ assert.equal(failure.record, undefined);
+ }
+});
+
+test("pending state and detail requests reject changed snapshot tokens", async (context) => {
+ const { root, url, token } = await fixture(context);
+ const paths = [sectionPath("workflow", token), resourcePath(token), resourcePath(token, "history=only")];
+ const initial = await Promise.all(paths.map((path) => fetch(`${url}${path}`, { headers: asyncHeaders })));
+ await Promise.all(initial.map((response) => assertPending(response, token)));
+
+ const ownerPath = join(root, "data", "people", "person-owner.json");
+ const owner = JSON.parse(await readFile(ownerPath, "utf8"));
+ await writeFile(ownerPath, `${JSON.stringify({ ...owner, title: "Updated Program Owner" }, null, 2)}\n`);
+ for (const path of paths) {
+ const response = await poll(`${url}${path}`);
+ assert.equal(response.status, 409);
+ assert.equal(typeof (await response.json()).error, "string");
+ }
+});
+
+test("a failed pending Git calculation returns failure and permits a fresh retry", async (context) => {
+ const { url, token } = await fixture(context);
+ let failRevisionRead = true;
+ const restore = setGitCommandInterceptorForTests(({ args, run }) => {
+ if (failRevisionRead && args[0] === "rev-parse" && args.includes("--verify")) {
+ const error = new Error("State pending fixture revision-read deadline");
+ error.code = "FILEGRC_GIT_DEADLINE";
+ throw error;
+ }
+ return run();
+ });
+ context.after(restore);
+ const path = `${url}${sectionPath("repository", token)}`;
+ const response = await poll(path);
+ assert.equal(response.status, 503);
+ const failure = await response.json();
+ assert.equal(typeof failure.error, "string");
+ assert.equal(failure.state, undefined);
+ assert.equal(failure.validation, undefined);
+
+ failRevisionRead = false;
+ const recovered = await poll(path);
+ assert.equal(recovered.status, 200);
+ assert.equal((await recovered.json()).state.validation.ok, true);
+});
+
+
+test("calculation budgets preserve individual history and snapshot Git limits", async (context) => {
+ const { url, token } = await fixture(context);
+ const commands = [];
+ context.after(setGitSubprocessObserverForTests((event) => commands.push(event)));
+ const response = await fetch(`${url}${resourcePath(token, "history=only")}`);
+ assert.equal(response.status, 200);
+ assert.ok(commands.some(({ kind, args }) => kind === "sync" && args[0] === "log"));
+ assert.ok(commands.some(({ kind }) => kind === "async"));
+ for (const command of commands) {
+ const timeout = command.kind === "sync" ? command.options?.timeout : command.timeoutMs;
+ if (timeout !== undefined) assert.ok(timeout > 0 && timeout <= 10_000, `${command.args[0]} exceeded the individual command budget: ${timeout}`);
+ }
+});
+
+test("individual command limits survive nested aggregate budgets and kill hung Git", async (context) => {
+ const { root } = await fixture(context);
+ const args = ["-c", "alias.filegrc-pause=!sleep 1", "filegrc-pause"];
+ const started = performance.now();
+ await withGitCommandDeadline(performance.now() + 120_000, async () => {
+ await withGitCommandDeadline(performance.now() + 120_000, async () => {
+ await assert.rejects(runGitCommand(root, args, { timeoutMs: 120_000 }), (error) => error.kind === "timeout");
+ assert.throws(() => runGitCommandSync(root, args, { timeoutMs: 120_000 }), (error) => error.code === "ETIMEDOUT");
+ });
+ }, { commandTimeoutMs: 30 });
+ assert.ok(performance.now() - started < 1_000, "the aggregate deadline replaced the individual timeout");
+});