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.
- package/package.json +1 -1
- package/src/files.js +13 -5
- package/src/git.js +122 -25
- package/src/program-amendment.js +3 -2
- package/src/program-path.js +4 -4
- package/src/program-readiness.js +26 -7
- package/src/reconciliation.js +10 -5
- package/src/reporting-route-integrity.js +181 -0
- package/src/reporting-route-sets.js +157 -25
- package/src/server.js +154 -68
- package/src/state.js +30 -5
- package/src/validate.js +136 -14
- package/src/web.js +218 -22
|
@@ -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) =>
|
|
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) =>
|
|
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
|
-
|
|
115
|
+
fastResponse: prefersFastMutation(request),
|
|
116
|
+
beginStateMutation: () => {
|
|
117
|
+
stateInvalidationGeneration += 1;
|
|
118
|
+
activeStateMutations += 1;
|
|
119
|
+
invalidateStateSessions(stateSessions);
|
|
120
|
+
},
|
|
121
|
+
endStateMutation: () => {
|
|
122
|
+
stateInvalidationGeneration += 1;
|
|
123
|
+
activeStateMutations -= 1;
|
|
124
|
+
invalidateStateSessions(stateSessions);
|
|
125
|
+
if (activeStateMutations === 0) {
|
|
126
|
+
const waiters = stateMutationWaiters;
|
|
127
|
+
stateMutationWaiters = [];
|
|
128
|
+
for (const resolve of waiters) resolve();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
111
131
|
};
|
|
112
132
|
if (["POST", "PUT", "DELETE"].includes(request.method) && !sameOrigin(request)) {
|
|
113
133
|
return json(response, 403, { error: "Cross-origin writes are not allowed." });
|
|
@@ -133,45 +153,51 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
|
133
153
|
return json(response, 403, { error: "Cross-origin state requests are not allowed." });
|
|
134
154
|
}
|
|
135
155
|
const deadlineAt = performance.now() + STATE_SECTION_GIT_DEADLINE_MS;
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
156
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
157
|
+
if (activeStateMutations > 0) {
|
|
158
|
+
await awaitWithinDeadline(new Promise((resolve) => stateMutationWaiters.push(resolve)), deadlineAt);
|
|
159
|
+
}
|
|
160
|
+
const generation = stateInvalidationGeneration;
|
|
161
|
+
if (!bootstrapSnapshotPromise) {
|
|
162
|
+
bootstrapSnapshotPromise = withGitCommandDeadline(deadlineAt, () => stableStateSnapshot(input, {
|
|
139
163
|
fileDigestCache,
|
|
140
164
|
deadlineAt
|
|
141
|
-
})
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
])
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
stateSessions.
|
|
165
|
+
})).finally(() => {
|
|
166
|
+
bootstrapSnapshotPromise = null;
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
const [snapshot, repositorySignature] = await awaitWithinDeadline(bootstrapSnapshotPromise, deadlineAt);
|
|
170
|
+
const loaded = snapshot.loaded;
|
|
171
|
+
const token = randomUUID();
|
|
172
|
+
const session = {
|
|
173
|
+
loaded,
|
|
174
|
+
fingerprint: snapshot.fingerprint,
|
|
175
|
+
repositorySignature,
|
|
176
|
+
fileDigestCache,
|
|
177
|
+
generatedAt: new Date().toISOString(),
|
|
178
|
+
expiresAt: Date.now() + STATE_SESSION_MAX_AGE_MS,
|
|
179
|
+
revoked: false,
|
|
180
|
+
promises: new Map(),
|
|
181
|
+
verificationPromise: null,
|
|
182
|
+
gitCommandCache: new Map()
|
|
183
|
+
};
|
|
184
|
+
const state = await createAppBootstrap(loaded, {
|
|
185
|
+
generatedAt: session.generatedAt,
|
|
186
|
+
programId: url.searchParams.get("programId") || undefined
|
|
187
|
+
});
|
|
188
|
+
if (activeStateMutations > 0 || generation !== stateInvalidationGeneration) continue;
|
|
189
|
+
pruneStateSessions(stateSessions);
|
|
190
|
+
stateSessions.set(token, session);
|
|
191
|
+
while (stateSessions.size > MAX_STATE_SESSIONS) {
|
|
192
|
+
const oldestToken = stateSessions.keys().next().value;
|
|
193
|
+
const oldestSession = stateSessions.get(oldestToken);
|
|
194
|
+
if (oldestSession) oldestSession.revoked = true;
|
|
195
|
+
stateSessions.delete(oldestToken);
|
|
196
|
+
}
|
|
197
|
+
state.stateToken = token;
|
|
198
|
+
return json(response, 200, state);
|
|
168
199
|
}
|
|
169
|
-
|
|
170
|
-
generatedAt: session.generatedAt,
|
|
171
|
-
programId: url.searchParams.get("programId") || undefined
|
|
172
|
-
});
|
|
173
|
-
state.stateToken = token;
|
|
174
|
-
return json(response, 200, state);
|
|
200
|
+
throw stateSessionExpiredError();
|
|
175
201
|
}
|
|
176
202
|
if (request.method === "GET" && url.pathname.startsWith("/api/state/")) {
|
|
177
203
|
const section = url.pathname.slice("/api/state/".length);
|
|
@@ -526,29 +552,35 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
|
526
552
|
if (request.method === "POST" && url.pathname === "/api/policy-activations") {
|
|
527
553
|
const payload = await readJson(request);
|
|
528
554
|
const result = await browserMutation(input, requestOptions, {
|
|
529
|
-
message: (activation) => `Activate ${activation.policyIds.length} ${activation.policyIds.length === 1 ? "Policy" : "Policies"}
|
|
555
|
+
message: (activation) => `Activate ${activation.policyIds.length} ${activation.policyIds.length === 1 ? "Policy" : "Policies"}`,
|
|
556
|
+
prefetchToken: payload.prefetchToken
|
|
530
557
|
}, () => activatePolicies(input, { ...payload, confirmed: true }));
|
|
531
558
|
return json(response, 200, result);
|
|
532
559
|
}
|
|
533
560
|
if (request.method === "POST" && url.pathname === "/api/document-activations") {
|
|
534
561
|
const payload = await readJson(request);
|
|
535
562
|
const result = await browserMutation(input, requestOptions, {
|
|
536
|
-
message: (activation) => `Activate ${activation.documentIds.length} governed ${activation.documentIds.length === 1 ? "Document" : "Documents"}
|
|
563
|
+
message: (activation) => `Activate ${activation.documentIds.length} governed ${activation.documentIds.length === 1 ? "Document" : "Documents"}`,
|
|
564
|
+
prefetchToken: payload.prefetchToken
|
|
537
565
|
}, () => activateDocuments(input, { ...payload, confirmed: true }));
|
|
538
566
|
return json(response, 200, result);
|
|
539
567
|
}
|
|
540
568
|
if (request.method === "POST" && url.pathname === "/api/governed-content-activations") {
|
|
541
569
|
const payload = await readJson(request);
|
|
542
570
|
const result = await browserMutation(input, requestOptions, {
|
|
543
|
-
message: (activation) => `Activate ${activation.resourceIds.length} governed-content ${activation.resourceIds.length === 1 ? "record" : "records"}
|
|
571
|
+
message: (activation) => `Activate ${activation.resourceIds.length} governed-content ${activation.resourceIds.length === 1 ? "record" : "records"}`,
|
|
572
|
+
prefetchToken: payload.prefetchToken
|
|
544
573
|
}, () => activateGovernedContent(input, { ...payload, confirmed: true }));
|
|
545
574
|
return json(response, 200, result);
|
|
546
575
|
}
|
|
547
576
|
if (request.method === "POST" && url.pathname === "/api/resources") {
|
|
548
|
-
const
|
|
577
|
+
const requestPayload = await readJson(request);
|
|
578
|
+
const payload = normalizeResourceMutation(requestPayload);
|
|
549
579
|
const { record } = payload;
|
|
550
580
|
const result = await browserMutation(input, requestOptions, {
|
|
551
|
-
message: () => `Create ${resourceTypeLabel(record.type)}: ${record.title || record.id}
|
|
581
|
+
message: () => `Create ${resourceTypeLabel(record.type)}: ${record.title || record.id}`,
|
|
582
|
+
fastResponse: prefersFastMutation(request),
|
|
583
|
+
prefetchToken: requestPayload.prefetchToken
|
|
552
584
|
}, () => createResource(input, record, { content: payload.content }));
|
|
553
585
|
return json(response, 201, result);
|
|
554
586
|
}
|
|
@@ -566,19 +598,12 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
|
566
598
|
return json(response, 200, await manualGitResultWithState(input, requestOptions, () => pushWorkspace(input)));
|
|
567
599
|
}
|
|
568
600
|
if (request.method === "POST" && url.pathname === "/api/git/retry-sync") {
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
result = await retryBrowserSync(input, {
|
|
601
|
+
const result = await manualGitResultWithState(input, requestOptions, () => (
|
|
602
|
+
retryBrowserSync(input, {
|
|
572
603
|
allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites
|
|
573
|
-
})
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
}
|
|
577
|
-
const state = await createAppState(input, {
|
|
578
|
-
allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites,
|
|
579
|
-
includeDetails: false
|
|
580
|
-
});
|
|
581
|
-
return json(response, 200, { ...result, state });
|
|
604
|
+
})
|
|
605
|
+
));
|
|
606
|
+
return json(response, 200, result);
|
|
582
607
|
}
|
|
583
608
|
if (request.method === "GET" && url.pathname === "/api/git/sync-status") {
|
|
584
609
|
const git = { ...await getRepositorySnapshot(input) };
|
|
@@ -607,11 +632,16 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
|
607
632
|
if (request.method === "PUT" && url.pathname === "/api/content") {
|
|
608
633
|
const payload = await readJson(request);
|
|
609
634
|
const result = await browserMutation(input, requestOptions, {
|
|
610
|
-
message: () => `Update content: ${payload.path}
|
|
635
|
+
message: () => `Update content: ${payload.path}`,
|
|
636
|
+
fastResponse: prefersFastMutation(request),
|
|
637
|
+
prefetchToken: payload.prefetchToken
|
|
611
638
|
}, () => updateContent(input, payload.path, payload.source, {
|
|
612
639
|
expectedRevision: requireRevision(payload.revision, `content/${payload.path}`)
|
|
613
640
|
}));
|
|
614
|
-
return json(response, 200,
|
|
641
|
+
return json(response, 200, {
|
|
642
|
+
...result,
|
|
643
|
+
...(result.stateRefresh ? { html: renderMarkdown(result.source) } : {})
|
|
644
|
+
});
|
|
615
645
|
}
|
|
616
646
|
const match = /^\/api\/resource\/([^/]+)\/([^/]+)$/.exec(url.pathname);
|
|
617
647
|
if (match) {
|
|
@@ -624,9 +654,17 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
|
624
654
|
const session = token ? stateSessions.get(token) : null;
|
|
625
655
|
if (token && !session) return json(response, 409, { error: "The workspace state expired. Reload it and try again." });
|
|
626
656
|
const includeWorkflow = url.searchParams.get("workflow") === "true";
|
|
657
|
+
const historyOnly = url.searchParams.get("history") === "only";
|
|
658
|
+
if (historyOnly) {
|
|
659
|
+
const history = session
|
|
660
|
+
? await loadStateSessionResourceHistory(session, token, type, id, options)
|
|
661
|
+
: await createResourceHistory(input, type, id);
|
|
662
|
+
if (!history) return json(response, 404, { error: "Resource not found." });
|
|
663
|
+
return json(response, 200, history);
|
|
664
|
+
}
|
|
627
665
|
const entry = session
|
|
628
|
-
? await loadStateSessionResource(session, token, type, id, options, requestOptions.programId, includeWorkflow)
|
|
629
|
-
: await createResourceDetail(input, type, id);
|
|
666
|
+
? await loadStateSessionResource(session, token, type, id, options, requestOptions.programId, includeWorkflow, url.searchParams.get("history") !== "false")
|
|
667
|
+
: await createResourceDetail(input, type, id, { includeHistory: url.searchParams.get("history") !== "false" });
|
|
630
668
|
if (!entry) return json(response, 404, { error: "Resource not found." });
|
|
631
669
|
if (includeWorkflow && !session) {
|
|
632
670
|
const workflow = await assessWorkflow(input, { programId: requestOptions.programId });
|
|
@@ -635,10 +673,13 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
|
635
673
|
return json(response, 200, entry);
|
|
636
674
|
}
|
|
637
675
|
if (request.method === "PUT") {
|
|
638
|
-
const
|
|
676
|
+
const requestPayload = await readJson(request);
|
|
677
|
+
const payload = normalizeResourceMutation(requestPayload, { requireRevision: true });
|
|
639
678
|
const { record } = payload;
|
|
640
679
|
const result = await browserMutation(input, requestOptions, {
|
|
641
|
-
message: () => `Update ${resourceTypeLabel(type)}: ${record.title || id}
|
|
680
|
+
message: () => `Update ${resourceTypeLabel(type)}: ${record.title || id}`,
|
|
681
|
+
fastResponse: prefersFastMutation(request),
|
|
682
|
+
prefetchToken: requestPayload.prefetchToken
|
|
642
683
|
}, () => updateResource(input, type, id, record, {
|
|
643
684
|
content: payload.content,
|
|
644
685
|
expectedRevision: payload.revision,
|
|
@@ -650,7 +691,8 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
|
650
691
|
if (request.method === "DELETE") {
|
|
651
692
|
const revision = requireRevision(url.searchParams.get("revision"), `${type}/${id}`);
|
|
652
693
|
const result = await browserMutation(input, requestOptions, {
|
|
653
|
-
message: () => `Delete ${resourceTypeLabel(type)}: ${id}
|
|
694
|
+
message: () => `Delete ${resourceTypeLabel(type)}: ${id}`,
|
|
695
|
+
prefetchToken: url.searchParams.get("prefetchToken") || undefined
|
|
654
696
|
}, () => deleteResource(input, type, id, { expectedRevision: revision }));
|
|
655
697
|
return json(response, 200, {
|
|
656
698
|
deleted: true,
|
|
@@ -659,7 +701,8 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
|
659
701
|
deletedContent: result.deletedContent,
|
|
660
702
|
synchronization: result.synchronization,
|
|
661
703
|
workflowDelta: result.workflowDelta,
|
|
662
|
-
state: result.state
|
|
704
|
+
state: result.state,
|
|
705
|
+
stateRefresh: result.stateRefresh
|
|
663
706
|
});
|
|
664
707
|
}
|
|
665
708
|
}
|
|
@@ -703,6 +746,30 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
|
703
746
|
});
|
|
704
747
|
}
|
|
705
748
|
|
|
749
|
+
async function stableStateSnapshot(input, options) {
|
|
750
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
751
|
+
const first = await fingerprintWorkspace(input, {
|
|
752
|
+
fileDigestCache: options.fileDigestCache,
|
|
753
|
+
deadlineAt: options.deadlineAt
|
|
754
|
+
});
|
|
755
|
+
const firstRepositorySignature = await getRepositoryStateSignature(input, {
|
|
756
|
+
timeoutMs: Math.max(1, Math.ceil(options.deadlineAt - performance.now()))
|
|
757
|
+
});
|
|
758
|
+
const second = await fingerprintWorkspace(input, {
|
|
759
|
+
fileDigestCache: options.fileDigestCache,
|
|
760
|
+
deadlineAt: options.deadlineAt
|
|
761
|
+
});
|
|
762
|
+
const secondRepositorySignature = await getRepositoryStateSignature(input, {
|
|
763
|
+
timeoutMs: Math.max(1, Math.ceil(options.deadlineAt - performance.now()))
|
|
764
|
+
});
|
|
765
|
+
if (
|
|
766
|
+
first.fingerprint === second.fingerprint
|
|
767
|
+
&& firstRepositorySignature === secondRepositorySignature
|
|
768
|
+
) return [second, secondRepositorySignature];
|
|
769
|
+
}
|
|
770
|
+
throw stateSessionExpiredError();
|
|
771
|
+
}
|
|
772
|
+
|
|
706
773
|
export async function serveWorkspace(input = process.cwd(), options = {}) {
|
|
707
774
|
const host = String(options.host ?? "127.0.0.1").trim();
|
|
708
775
|
const port = Number(options.port ?? 8787);
|
|
@@ -761,11 +828,12 @@ function listen(server, port, host) {
|
|
|
761
828
|
|
|
762
829
|
function browserMutation(input, requestOptions, mutationOptions, task) {
|
|
763
830
|
const run = () => serializeWorkspaceMutation(input, async (root) => {
|
|
764
|
-
const fastResponse = mutationOptions.fastResponse
|
|
831
|
+
const fastResponse = mutationOptions.fastResponse ?? requestOptions.fastResponse;
|
|
765
832
|
const workflowBefore = fastResponse
|
|
766
833
|
? null
|
|
767
834
|
: await measureTiming("workflow-before", () => assessWorkflow(root, { programId: requestOptions.programId }));
|
|
768
835
|
let result;
|
|
836
|
+
requestOptions.beginStateMutation?.();
|
|
769
837
|
try {
|
|
770
838
|
result = await measureTiming("mutation", () => runBrowserMutation(root, {
|
|
771
839
|
...mutationOptions,
|
|
@@ -774,7 +842,7 @@ function browserMutation(input, requestOptions, mutationOptions, task) {
|
|
|
774
842
|
includeValidationProof: !fastResponse
|
|
775
843
|
}, task));
|
|
776
844
|
} finally {
|
|
777
|
-
requestOptions.
|
|
845
|
+
requestOptions.endStateMutation?.();
|
|
778
846
|
}
|
|
779
847
|
if (fastResponse) {
|
|
780
848
|
return {
|
|
@@ -899,7 +967,7 @@ async function loadStateSessionSection(session, section, serverOptions, programI
|
|
|
899
967
|
return state;
|
|
900
968
|
}
|
|
901
969
|
|
|
902
|
-
async function loadStateSessionResource(session, token, type, id, serverOptions, programId, includeWorkflow) {
|
|
970
|
+
async function loadStateSessionResource(session, token, type, id, serverOptions, programId, includeWorkflow, includeHistory = true) {
|
|
903
971
|
assertCurrentStateSession(session);
|
|
904
972
|
const detailDeadlineMs = Number.isFinite(serverOptions.resourceDetailDeadlineMs)
|
|
905
973
|
? Math.max(0, serverOptions.resourceDetailDeadlineMs)
|
|
@@ -907,7 +975,8 @@ async function loadStateSessionResource(session, token, type, id, serverOptions,
|
|
|
907
975
|
const deadlineAt = performance.now() + detailDeadlineMs;
|
|
908
976
|
return withGitCommandDeadline(deadlineAt, async () => {
|
|
909
977
|
const detail = await withGitCommandCache(session.gitCommandCache, () => createResourceDetail(session.loaded, type, id, {
|
|
910
|
-
historyDeadlineAt: deadlineAt
|
|
978
|
+
historyDeadlineAt: deadlineAt,
|
|
979
|
+
includeHistory
|
|
911
980
|
}));
|
|
912
981
|
if (detail && includeWorkflow) {
|
|
913
982
|
const repository = await loadStateSessionSection(
|
|
@@ -931,6 +1000,22 @@ async function loadStateSessionResource(session, token, type, id, serverOptions,
|
|
|
931
1000
|
});
|
|
932
1001
|
}
|
|
933
1002
|
|
|
1003
|
+
async function loadStateSessionResourceHistory(session, token, type, id, serverOptions) {
|
|
1004
|
+
assertCurrentStateSession(session);
|
|
1005
|
+
const detailDeadlineMs = Number.isFinite(serverOptions.resourceDetailDeadlineMs)
|
|
1006
|
+
? Math.max(0, serverOptions.resourceDetailDeadlineMs)
|
|
1007
|
+
: RESOURCE_DETAIL_GIT_DEADLINE_MS;
|
|
1008
|
+
const deadlineAt = performance.now() + detailDeadlineMs;
|
|
1009
|
+
return withGitCommandDeadline(deadlineAt, async () => {
|
|
1010
|
+
const history = await withGitCommandCache(session.gitCommandCache, () => createResourceHistory(session.loaded, type, id, {
|
|
1011
|
+
historyDeadlineAt: deadlineAt
|
|
1012
|
+
}));
|
|
1013
|
+
await verifyStateSessionSnapshot(session, performance.now(), deadlineAt);
|
|
1014
|
+
assertCurrentStateSession(session);
|
|
1015
|
+
return history ? { ...history, stateToken: token } : null;
|
|
1016
|
+
});
|
|
1017
|
+
}
|
|
1018
|
+
|
|
934
1019
|
function prefersFastMutation(request) {
|
|
935
1020
|
return String(request.headers.prefer || "")
|
|
936
1021
|
.split(",")
|
|
@@ -948,10 +1033,11 @@ async function requireManualBrowserGit(input, options) {
|
|
|
948
1033
|
|
|
949
1034
|
async function manualGitResultWithState(input, requestOptions, task) {
|
|
950
1035
|
let result;
|
|
1036
|
+
requestOptions.beginStateMutation?.();
|
|
951
1037
|
try {
|
|
952
1038
|
result = await task();
|
|
953
1039
|
} finally {
|
|
954
|
-
requestOptions.
|
|
1040
|
+
requestOptions.endStateMutation?.();
|
|
955
1041
|
}
|
|
956
1042
|
const state = await createAppState(input, {
|
|
957
1043
|
allowNonAuthoritativeWrites: requestOptions.allowNonAuthoritativeWrites,
|