filegrc 0.12.4 → 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.
@@ -3,14 +3,25 @@ import { applyResourceBatch, INTERNAL_WORKFLOW_CAPABILITIES, updateResource } fr
3
3
  import {
4
4
  effectiveReportingRouteRequirements as deriveEffectiveReportingRouteRequirements,
5
5
  reportingRouteAssertionTiming,
6
+ reportingRouteCommitTimestamp,
6
7
  reportingRouteEventCommit,
7
8
  reportingRouteEventAuthorityIssue,
8
9
  reportingRouteEventAuthorityIssueAtCommit,
10
+ reportingRouteExactHistoryEntry,
9
11
  reportingRouteFixedEvidence,
12
+ reportingRouteLanesIndependent,
10
13
  reportingRouteOngoingAuthorities,
14
+ reportingRouteProposalIssues,
15
+ reportingRouteProposalAssessmentTime,
16
+ reportingRouteProposalIssuesForRequirements,
11
17
  reportingRouteRecordAtRevision,
18
+ reportingRouteRequirementAppliesToProgram,
19
+ reportingRouteRequirementsForProposal,
20
+ reportingRouteSupportIssues,
21
+ recordsAtRevision,
12
22
  reportingRouteSourceEffective,
13
23
  reportingRouteSourceMayApply,
24
+ reportingRouteSourceMayBecomeEffective,
14
25
  reportingRouteSourceAppliesToProgram
15
26
  } from "./reporting-route-integrity.js";
16
27
  import { localDateTimeValue } from "./time.js";
@@ -23,6 +34,8 @@ const SOURCE_TYPES = new Set(["policy", "document", "commitment", "risk"]);
23
34
  const MAX_PERIOD_BOUNDARIES = 512;
24
35
  const PERIOD_REPOSITORY_SNAPSHOT = Symbol("filegrc.reportingRoutePeriodRepositorySnapshot");
25
36
 
37
+ export { reportingRouteLanesIndependent };
38
+
26
39
  export async function assessReportingRouteSets(input = process.cwd(), options = {}) {
27
40
  const loaded = typeof input === "object" && input?.resources ? input : await loadWorkspace(input);
28
41
  if (!loaded.model.resources?.["reporting-route-set"]) {
@@ -46,13 +59,19 @@ export async function assessReportingRouteSets(input = process.cwd(), options =
46
59
  const requirements = deriveEffectiveReportingRouteRequirements(loaded.resources, at, programId, timezone);
47
60
  const proposedRequirements = loaded.resources.flatMap((source) => (
48
61
  SOURCE_TYPES.has(source.type)
62
+ && reportingRouteSourceMayBecomeEffective(source)
49
63
  && (!programId || reportingRouteSourceAppliesToProgram(
50
64
  source,
51
65
  loaded.resources.find(({ type, id }) => type === "program" && id === programId),
52
66
  loaded.resources
53
67
  ))
54
68
  ? (Array.isArray(source.reportingRouteRequirements) ? source.reportingRouteRequirements : [])
55
- .filter((requirement) => requirement && typeof requirement === "object" && !Array.isArray(requirement))
69
+ .filter((requirement) => (
70
+ requirement
71
+ && typeof requirement === "object"
72
+ && !Array.isArray(requirement)
73
+ && (!programId || reportingRouteRequirementAppliesToProgram(requirement, programId))
74
+ ))
56
75
  .map((requirement) => ({
57
76
  ...requirement,
58
77
  sourceId: source.id,
@@ -66,7 +85,10 @@ export async function assessReportingRouteSets(input = process.cwd(), options =
66
85
  ));
67
86
  const repository = options[PERIOD_REPOSITORY_SNAPSHOT]
68
87
  || await getRepositorySnapshot(loaded.root, { fresh: true });
69
- const assessments = routeSets.map((record) => assessRouteSet(record, loaded, at, repository));
88
+ const assessments = routeSets.map((record) => ({
89
+ ...assessRouteSet(record, loaded, at, repository),
90
+ ...proposedRequirementAssessment(record, loaded, repository, proposedRequirements, at, timezone)
91
+ }));
70
92
  const issues = loaded.resources.flatMap((source) => {
71
93
  if (!SOURCE_TYPES.has(source.type) || !Object.hasOwn(source, "reportingRouteRequirements")) return [];
72
94
  if (!reportingRouteSourceMayApply(source)) return [];
@@ -118,7 +140,10 @@ export async function assessReportingRouteSets(input = process.cwd(), options =
118
140
  if (requirement.distinctChannels && route.alternateLane?.channelKind === route.primaryLane?.channelKind) {
119
141
  issues.push(issue("reporting-route-channel-not-distinct", route.id, `${requirement.sourceId} requires different normal and fallback channel types.`));
120
142
  }
121
- if (requirement.independentDependencies && !reportingRouteLanesIndependent(route, loaded.resources, at)) {
143
+ if (requirement.independentDependencies && !reportingRouteLanesIndependent(route, loaded.resources, at, {
144
+ timezone,
145
+ root: loaded.root
146
+ })) {
122
147
  issues.push(issue("reporting-route-dependencies-not-independent", route.id, `${requirement.sourceId} requires independent channel dependencies or an applicable Exception.`));
123
148
  }
124
149
  }
@@ -331,6 +356,12 @@ export async function proposeReportingRouteSet(input, options = {}) {
331
356
  const loaded = await loadWorkspace(input);
332
357
  const entry = routeEntry(loaded, options.routeSetId);
333
358
  if (entry.record.status !== "draft") throw new Error(`Reporting Channel Set "${entry.record.id}" must be draft before it is proposed.`);
359
+ const proposalIssues = reportingRouteProposalIssues(loaded.resources, entry.record, {
360
+ at: new Date(),
361
+ timezone: loaded.workspace?.timezone || "UTC",
362
+ root: loaded.root
363
+ });
364
+ if (proposalIssues.length) throw new Error(proposalIssues[0].message);
334
365
  const result = await updateResource(loaded.root, entry.record.type, entry.record.id, {
335
366
  ...entry.record,
336
367
  status: "proposed"
@@ -347,12 +378,38 @@ export async function approveReportingRouteSet(input, options = {}) {
347
378
  if (entry.record.status !== "proposed") throw new Error(`Reporting Channel Set "${entry.record.id}" must be proposed before approval.`);
348
379
  const proposalCommit = fullCommit(options.proposalCommit, "Proposal commit");
349
380
  assertProposalMatches(loaded, entry, proposalCommit);
381
+ const proposalRecords = recordsAtRevision(loaded, proposalCommit);
382
+ const proposalTimestamp = reportingRouteCommitTimestamp(loaded, entry.record.id, proposalCommit);
383
+ if (!proposalTimestamp) throw new Error(`Proposal commit ${proposalCommit} is not present in Reporting Channel Set history.`);
384
+ const proposalAssessmentAt = reportingRouteProposalAssessmentTime(proposalTimestamp, new Date());
385
+ if (!proposalAssessmentAt) throw new Error("Proposal commit time is too far in the future to establish a reliable proposal.");
386
+ const proposalIssues = reportingRouteProposalIssues(proposalRecords, entry.record, {
387
+ at: proposalAssessmentAt,
388
+ timezone: proposalRecords.find(({ type }) => type === "workspace")?.timezone || loaded.workspace?.timezone || "UTC",
389
+ root: loaded.root,
390
+ commit: proposalCommit
391
+ });
392
+ if (proposalIssues.length) throw new Error(proposalIssues[0].message);
350
393
  const approvedAt = pastOrPresentTimestamp(options.approvedAt, "Approval time");
351
394
  const effectiveAt = instant(options.effectiveAt, "Effective time");
352
395
  const timezone = timezoneName(options.timezone);
353
396
  assertTimestampZone(options.approvedAt, timezone, "Approval time");
354
397
  assertTimestampZone(options.effectiveAt, timezone, "Effective time");
355
398
  if (effectiveAt < approvedAt) throw new Error("A Reporting Channel Set cannot become effective before it is approved.");
399
+ const cutoverIssues = reportingRouteSupportIssues(
400
+ reportingRouteRequirementsForProposal(loaded.resources, entry.record, { at: effectiveAt, timezone }),
401
+ entry.record,
402
+ proposalRecords,
403
+ loaded.resources,
404
+ {
405
+ at: effectiveAt,
406
+ availableAt: approvedAt,
407
+ timezone,
408
+ root: loaded.root,
409
+ proposalCommit
410
+ }
411
+ );
412
+ if (cutoverIssues.length) throw new Error(cutoverIssues[0].message);
356
413
  const authority = approvalAuthority(loaded, entry.record, options, approvedAt, timezone);
357
414
  const approvalEvidenceIds = [...new Set(options.evidenceIds || [])];
358
415
  if (!reportingRouteFixedEvidence(
@@ -527,6 +584,83 @@ function assessRouteSet(record, loaded, at, repository) {
527
584
  if (COMMIT_REQUIRED_STATUSES.has(record.status) && !committed) {
528
585
  issues.push(issue("reporting-route-commit-required", record.id, `${record.title} must be committed before this lifecycle state can be relied on.`));
529
586
  }
587
+ const proposalEntry = loaded.entries.find(({ record: candidate }) => candidate.id === record.id);
588
+ const historicalProposal = ["approved", "canceled"].includes(record.status)
589
+ && proposalEntry
590
+ && /^[a-f0-9]{40}$/i.test(String(record.proposalCommit || ""))
591
+ ? reportingRouteRecordAtRevision(loaded, proposalEntry, record.proposalCommit)
592
+ : null;
593
+ if (record.status === "proposed") {
594
+ const proposalHistory = committed && repository.commit
595
+ ? reportingRouteExactHistoryEntry(loaded, record, repository.commit)
596
+ : null;
597
+ const proposalRecords = proposalHistory ? recordsAtRevision(loaded, proposalHistory.commit) : loaded.resources;
598
+ const proposalRecord = proposalHistory
599
+ ? proposalRecords.find(({ id }) => id === record.id) || record
600
+ : record;
601
+ const proposalAssessmentAt = proposalHistory
602
+ ? reportingRouteProposalAssessmentTime(proposalHistory.timestamp, at)
603
+ : at;
604
+ if (proposalHistory && !proposalAssessmentAt) {
605
+ issues.push(issue("invalid-reporting-route-proposal-time", record.id, "The proposal commit time is too far in the future to establish a reliable proposal."));
606
+ }
607
+ issues.push(...reportingRouteProposalIssues(proposalRecords, proposalRecord, {
608
+ at: proposalAssessmentAt || at,
609
+ timezone: proposalRecords.find(({ type }) => type === "workspace")?.timezone || loaded.workspace?.timezone || "UTC",
610
+ root: loaded.root,
611
+ commit: proposalHistory?.commit
612
+ }));
613
+ } else if (historicalProposal) {
614
+ const proposalRecords = recordsAtRevision(loaded, record.proposalCommit);
615
+ const proposalTimestamp = reportingRouteCommitTimestamp(loaded, record.id, record.proposalCommit);
616
+ const proposalAssessmentAt = proposalTimestamp
617
+ ? reportingRouteProposalAssessmentTime(proposalTimestamp, at)
618
+ : null;
619
+ if (!proposalTimestamp) {
620
+ issues.push(issue("invalid-reporting-route-proposal", record.id, "The proposal commit must be an exact Reporting Channel Set history entry."));
621
+ } else if (!proposalAssessmentAt) {
622
+ issues.push(issue("invalid-reporting-route-proposal-time", record.id, "The proposal commit time is too far in the future to establish a reliable proposal."));
623
+ } else {
624
+ const proposalIssues = reportingRouteProposalIssues(
625
+ proposalRecords,
626
+ historicalProposal,
627
+ {
628
+ at: proposalAssessmentAt,
629
+ timezone: proposalRecords.find(({ type }) => type === "workspace")?.timezone || loaded.workspace?.timezone || "UTC",
630
+ root: loaded.root,
631
+ commit: record.proposalCommit
632
+ }
633
+ );
634
+ issues.push(...proposalIssues);
635
+ if (!proposalIssues.length) {
636
+ const approvalCommit = reportingRouteEventCommit(loaded, record, "approval");
637
+ const approvalRecords = approvalCommit ? recordsAtRevision(loaded, approvalCommit) : loaded.resources;
638
+ const liveCommit = record.status === "canceled"
639
+ ? reportingRouteEventCommit(loaded, record, "cancellation")
640
+ : null;
641
+ const liveRecords = liveCommit ? recordsAtRevision(loaded, liveCommit) : loaded.resources;
642
+ const cutoverAt = record.approval?.effectiveAt || record.approval?.approvedAt || at;
643
+ const cutoverTimezone = record.approval?.timezone || loaded.workspace?.timezone || "UTC";
644
+ issues.push(...reportingRouteSupportIssues(
645
+ reportingRouteRequirementsForProposal(approvalRecords, record, {
646
+ at: cutoverAt,
647
+ timezone: cutoverTimezone
648
+ }),
649
+ record,
650
+ proposalRecords,
651
+ liveRecords,
652
+ {
653
+ at: cutoverAt,
654
+ availableAt: record.approval?.approvedAt || at,
655
+ timezone: cutoverTimezone,
656
+ root: loaded.root,
657
+ proposalCommit: record.proposalCommit,
658
+ currentCommit: liveCommit
659
+ }
660
+ ));
661
+ }
662
+ }
663
+ }
530
664
  if (record.status === "approved" && record.approval && repository.commit && !isDataHistoryAncestor(loaded, record.proposalCommit, repository.commit)) {
531
665
  issues.push(issue("invalid-reporting-route-proposal-lineage", record.id, "The approved revision must descend from the exact committed proposal."));
532
666
  }
@@ -576,6 +710,26 @@ function assessRouteSet(record, loaded, at, repository) {
576
710
  };
577
711
  }
578
712
 
713
+ function proposedRequirementAssessment(record, loaded, repository, requirements, at, timezone) {
714
+ const history = repository.commit
715
+ ? reportingRouteExactHistoryEntry(loaded, record, repository.commit)
716
+ : null;
717
+ const records = history ? recordsAtRevision(loaded, history.commit) : loaded.resources;
718
+ return {
719
+ proposedRequirementIssues: reportingRouteProposalIssuesForRequirements(
720
+ requirements,
721
+ record,
722
+ records,
723
+ {
724
+ at: history ? reportingRouteProposalAssessmentTime(history.timestamp, at) || at : at,
725
+ timezone,
726
+ root: loaded.root,
727
+ commit: history?.commit
728
+ }
729
+ )
730
+ };
731
+ }
732
+
579
733
  function committedRecordMatches(loaded, record, commit) {
580
734
  const entry = loaded.entries.find(({ record: candidate }) => candidate.id === record.id);
581
735
  const historical = entry ? reportingRouteRecordAtRevision(loaded, entry, commit) : null;
@@ -677,28 +831,6 @@ function approvalAuthority(loaded, routeSet, options, at, timezone) {
677
831
  return appointment;
678
832
  }
679
833
 
680
- export function reportingRouteLanesIndependent(record, records, at) {
681
- if (!record.alternateLane) return false;
682
- for (const lane of [record.primaryLane, record.alternateLane]) {
683
- if (!lane?.dependencyBasis) return false;
684
- if (lane.dependencyBasis === "cataloged" && !lane.dependencySystemIds?.length) return false;
685
- if (lane.dependencyBasis === "none" && !String(lane.dependencyRationale || "").trim()) return false;
686
- }
687
- const primary = new Set(record.primaryLane?.dependencySystemIds || []);
688
- const overlap = (record.alternateLane.dependencySystemIds || []).filter((id) => primary.has(id));
689
- if (!overlap.length) return true;
690
- const date = localDateTimeValue(at, record.approval?.timezone || "UTC").slice(0, 10);
691
- return records.some((candidate) => (
692
- candidate.type === "exception"
693
- && candidate.status === "approved"
694
- && candidate.reportingRouteSetId === record.id
695
- && candidate.reportingRouteLanePair === "primary-alternate"
696
- && overlap.every((id) => candidate.dependencySystemIds?.includes(id))
697
- && candidate.approval?.approvedOn <= date
698
- && candidate.approval?.expiresOn >= date
699
- ));
700
- }
701
-
702
834
  function routeEntry(loaded, id) {
703
835
  const entry = loaded.entries.find(({ record }) => record.type === "reporting-route-set" && record.id === id);
704
836
  if (!entry) throw new Error(`Reporting Channel Set "${id || ""}" was not found.`);
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,