filegrc 0.5.0 → 0.6.0

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.
@@ -6,6 +6,7 @@ import { planObligations } from "./obligations.js";
6
6
  import { resolveDataPath } from "./paths.js";
7
7
  import { obligationIsRunning } from "./program-lifecycle.js";
8
8
  import { currentPartyPeople, partiesIndependent, partyPeople } from "./parties.js";
9
+ import { programComponents, resolveProgram, selectedRequirementIds } from "./program.js";
9
10
  import { markdownEntries } from "./resource-markdown.js";
10
11
  import { assessSourceCoverageReadiness } from "./source-coverage.js";
11
12
  import { currentCalendarDate } from "./time.js";
@@ -18,9 +19,10 @@ export async function assessProgramReadiness(input, options = {}) {
18
19
  const records = loaded.resources;
19
20
  const byId = new Map(records.map((record) => [record.id, record]));
20
21
  const workspace = loaded.workspace || records.find((record) => record.type === "workspace");
22
+ const program = resolveProgram(loaded, options.programId);
21
23
  const asOf = options.asOf || currentCalendarDate(workspace?.timezone || "UTC");
22
- const scope = programScope(workspace, records, byId);
23
- const collectionReviews = assessCollectionReviews(loaded);
24
+ const scope = programScope(program, records, byId, loaded.model, loaded);
25
+ const collectionReviews = assessCollectionReviews(loaded, { programId: program.id });
24
26
  const markdown = new Map();
25
27
  const readMarkdown = async (record) => {
26
28
  if (!record) return "";
@@ -34,10 +36,10 @@ export async function assessProgramReadiness(input, options = {}) {
34
36
  .map(collectionReviewReadinessItem));
35
37
  const sourceStage = await evidenceSourcesStage(scope, byId, loaded.model, readMarkdown);
36
38
  controlStage.items.push(...sourceStage.items);
37
- controlStage.description = "Each implemented control needs an owner, actual procedure, scope, operation pattern, mappings, an implementation date, and complete authoritative source Systems with the required evidence roles, access owners, and retrieval instructions.";
39
+ controlStage.description = `Each implemented Control needs an owner, actual procedure, scope, operation pattern, mappings, an implementation date, and complete authoritative source ${String(loaded.model.modelVersion) === "4" ? "Components" : "Systems"} with the required evidence roles, access owners, and retrieval instructions.`;
38
40
  const evidenceGateStages = [
39
41
  scopeStage(
40
- workspace,
42
+ program,
41
43
  scope,
42
44
  records,
43
45
  byId,
@@ -51,20 +53,20 @@ export async function assessProgramReadiness(input, options = {}) {
51
53
  const evidenceReady = evidenceGateStages.every((current) => current.counts.action === 0);
52
54
  const stages = [
53
55
  ...evidenceGateStages,
54
- operationStage(loaded, workspace, scope, records, byId, asOf, evidenceReady, loaded.model)
56
+ operationStage(loaded, program, scope, records, byId, asOf, evidenceReady, loaded.model)
55
57
  ];
56
58
  finalizeStage(stages.at(-1));
57
59
  const candidateStarted = Boolean(
58
- workspace?.assuranceGoal === "soc-2-type-2"
59
- && workspace.candidateCoverage?.kind === "range"
60
- && coverageStart(workspace.candidateCoverage) <= asOf
60
+ program?.assuranceGoal === "soc-2-type-2"
61
+ && program.candidateCoverage?.kind === "range"
62
+ && coverageStart(program.candidateCoverage) <= asOf
61
63
  );
62
64
  const obligations = planObligations(records, { asOf, through: asOf, model: loaded.model });
63
65
  const operating = evidenceReady && candidateStarted && stages.at(-1).counts.action === 0;
64
66
  const canStartCandidatePeriod = Boolean(
65
67
  evidenceReady
66
- && workspace?.assuranceGoal === "soc-2-type-2"
67
- && !workspace.candidateCoverage
68
+ && program?.assuranceGoal === "soc-2-type-2"
69
+ && !program.candidateCoverage
68
70
  );
69
71
  const items = stages.flatMap((current) => current.items);
70
72
  const managedItems = items.filter((current) => !["info", "later"].includes(current.status));
@@ -73,12 +75,14 @@ export async function assessProgramReadiness(input, options = {}) {
73
75
 
74
76
  return {
75
77
  schemaVersion: 1,
78
+ dataModelVersion: String(loaded.model.modelVersion),
76
79
  generatedAt: options.generatedAt || new Date().toISOString(),
77
80
  asOf,
78
81
  target: {
79
- goal: workspace?.assuranceGoal || "none",
80
- label: assuranceGoalLabel(workspace?.assuranceGoal),
81
- candidateCoverage: workspace?.candidateCoverage || null
82
+ programId: program?.id || null,
83
+ goal: program?.assuranceGoal || "none",
84
+ label: assuranceGoalLabel(program?.assuranceGoal),
85
+ candidateCoverage: program?.candidateCoverage || null
82
86
  },
83
87
  status: operating ? "operating" : evidenceReady ? "evidence-ready" : "needs-work",
84
88
  evidenceReady,
@@ -94,6 +98,7 @@ export async function assessProgramReadiness(input, options = {}) {
94
98
  firstAction,
95
99
  scope: {
96
100
  systemIds: scope.systems.map((record) => record.id),
101
+ componentIds: scope.components.map((record) => record.id),
97
102
  frameworkIds: scope.frameworks.map((record) => record.id),
98
103
  requirementIds: scope.requirements.map((record) => record.id),
99
104
  controlIds: scope.controls.map((record) => record.id)
@@ -115,31 +120,32 @@ export async function assessEvidenceMap(input, options = {}) {
115
120
  status: counts.action ? "action" : "complete",
116
121
  counts,
117
122
  workflow: [
118
- "Choose an existing System or create the System that is authoritative for each evidence family.",
119
- "On every source System, set an evidence source role, name current evidence access owners, and write repeatable retrieval instructions in Record Markdown.",
120
- "Set each selected Control's evidenceSourceIds to the authoritative Systems that produce its evidence.",
123
+ `Choose an existing ${String(readiness.dataModelVersion) === "4" ? "Component" : "System"} or create one that is authoritative for each evidence family.`,
124
+ `On every source ${String(readiness.dataModelVersion) === "4" ? "Component" : "System"}, set an evidence source role, name current evidence access owners, and write repeatable retrieval instructions in Record Markdown.`,
125
+ `Map each selected Control to the authoritative source ${String(readiness.dataModelVersion) === "4" ? "Components with evidenceSourceComponentIds" : "Systems with evidenceSourceIds"} that produce its evidence.`,
121
126
  "Run program-readiness again and resolve every incomplete source check and control mapping before marking the Controls implemented."
122
127
  ],
123
128
  items: evidenceItems
124
129
  };
125
130
  }
126
131
 
127
- function programScope(workspace, records, byId) {
132
+ function programScope(program, records, byId, model, loaded) {
128
133
  const select = (ids, type, fallback) => {
129
134
  if (ids?.length) return ids.map((id) => byId.get(id)).filter((record) => record?.type === type);
130
135
  return records.filter(fallback);
131
136
  };
132
137
  return {
133
- systems: (workspace?.systemIds || [])
138
+ systems: (program?.systemIds || [])
134
139
  .map((id) => byId.get(id))
135
140
  .filter((record) => record?.type === "system" && record.status !== "retired"),
136
- frameworks: select(workspace?.frameworkIds, "framework", (record) => (
141
+ components: programComponents(loaded, program),
142
+ frameworks: select(program?.frameworkIds, "framework", (record) => (
137
143
  record.type === "framework" && record.status === "active"
138
144
  )),
139
- requirements: select(workspace?.requirementIds, "requirement", (record) => (
145
+ requirements: select(selectedRequirementIds(program, model), "requirement", (record) => (
140
146
  record.type === "requirement" && record.applicability === "applicable"
141
- )).filter((record) => record.applicability === "applicable"),
142
- controls: select(workspace?.controlIds, "control", (record) => (
147
+ )).filter((record) => String(model.modelVersion) === "4" || record.applicability === "applicable"),
148
+ controls: select(program?.controlIds, "control", (record) => (
143
149
  record.type === "control" && !["not-applicable", "retired"].includes(record.status)
144
150
  )).filter((record) => !["not-applicable", "retired"].includes(record.status))
145
151
  };
@@ -164,6 +170,29 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
164
170
  }
165
171
  ));
166
172
 
173
+ if (String(model.modelVersion) === "4") {
174
+ const completeComponents = scope.components.filter((component) => (
175
+ component.status === "active"
176
+ && component.description
177
+ && (component.ownerIds || []).length
178
+ && (component.systemUses || []).some(({ systemId, roles, rationale }) => (
179
+ scope.systems.some(({ id }) => id === systemId)
180
+ && (roles || []).length
181
+ && String(rationale || "").trim()
182
+ ))
183
+ ));
184
+ items.push(item(
185
+ "system-components",
186
+ scope.components.length === completeComponents.length ? "complete" : "action",
187
+ "Confirm scoped Components",
188
+ scope.components.length
189
+ ? `${completeComponents.length} of ${scope.components.length} Components have an active, owned, rationalized role in the selected Systems.`
190
+ : "No Components are selected. This is valid only when the bounded Systems do not rely on a separately managed service-delivery, Control-support, evidence-source, or supporting-operations building block.",
191
+ scope.components[0] || { type: "component" },
192
+ { componentIds: scope.components.map(({ id }) => id) }
193
+ ));
194
+ }
195
+
167
196
  items.push(programOwnershipItem(records, byId));
168
197
  items.push(requiredAppointmentsItem(records, model));
169
198
  for (const assessment of collectionReviews) {
@@ -172,7 +201,7 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
172
201
 
173
202
  const completeSystems = scope.systems.filter((system) => (
174
203
  system.status === "active"
175
- && system.description
204
+ && (String(model.modelVersion) === "4" ? system.purpose && system.boundary && (system.servicesProvided || []).length : system.description)
176
205
  && system.classificationId
177
206
  && (system.ownerIds || []).length
178
207
  ));
@@ -186,7 +215,7 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
186
215
  scope.systems[0] || { type: "system" }
187
216
  ));
188
217
 
189
- if (String(model.modelVersion) === "3") {
218
+ if (["3", "4"].includes(String(model.modelVersion))) {
190
219
  const commitments = records.filter((record) => (
191
220
  record.type === "commitment"
192
221
  && !["superseded", "retired"].includes(record.status)
@@ -196,7 +225,7 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
196
225
  record.status === "active"
197
226
  && record.statement
198
227
  && record.effectiveOn
199
- && record.applicabilityReview?.decision === "applicable"
228
+ && (String(model.modelVersion) === "4" || record.applicabilityReview?.decision === "applicable")
200
229
  && currentPartyPeople(record.ownerIds, byId).size > 0
201
230
  && (record.requirementIds || []).length > 0
202
231
  && (record.controlIds || []).length > 0
@@ -225,15 +254,16 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
225
254
  }
226
255
 
227
256
  const selectedRequirementIds = new Set(scope.requirements.map((record) => record.id));
257
+ const v4Decisions = new Map((workspace?.requirementApplicability || []).map((decision) => [decision.requirementId, decision.decision]));
228
258
  const applicableRequirements = records.filter((record) => (
229
259
  record.type === "requirement"
230
260
  && scope.frameworks.some((framework) => framework.id === record.frameworkId)
231
- && record.applicability === "applicable"
261
+ && (String(model.modelVersion) === "4" ? v4Decisions.get(record.id) === "applicable" : record.applicability === "applicable")
232
262
  ));
233
263
  const unresolvedRequirements = records.filter((record) => (
234
264
  record.type === "requirement"
235
265
  && scope.frameworks.some((framework) => framework.id === record.frameworkId)
236
- && record.applicability === "undetermined"
266
+ && (String(model.modelVersion) === "4" ? !v4Decisions.has(record.id) || v4Decisions.get(record.id) === "undetermined" : record.applicability === "undetermined")
237
267
  ));
238
268
  const missingRequirements = applicableRequirements.filter((record) => !selectedRequirementIds.has(record.id));
239
269
  const criteriaComplete = Boolean(
@@ -256,7 +286,7 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
256
286
  "npx filegrc review-applicability --scaffold --type requirement > decisions.json",
257
287
  "npx filegrc review-applicability decisions.json --preview --json",
258
288
  "npx filegrc review-applicability decisions.json --yes --json",
259
- "npx filegrc get workspace --mutation"
289
+ `npx filegrc get ${shellArgument(workspace?.id || "workspace")} --mutation`
260
290
  ]
261
291
  }
262
292
  ));
@@ -595,7 +625,11 @@ async function controlsStage(scope, byId, readMarkdown, asOf, model) {
595
625
  }
596
626
  for (const control of scope.controls) {
597
627
  const source = await readMarkdown(control);
598
- const sourceSystems = (control.evidenceSourceIds || []).map((id) => byId.get(id)).filter((record) => record?.type === "system");
628
+ const sourceSystems = (
629
+ String(model.modelVersion) === "4"
630
+ ? control.evidenceSourceComponentIds || []
631
+ : control.evidenceSourceIds || []
632
+ ).map((id) => byId.get(id)).filter((record) => record?.type === (String(model.modelVersion) === "4" ? "component" : "system"));
599
633
  const queueSchedules = [...byId.values()].filter((record) => (
600
634
  record.type === "obligation"
601
635
  && record.status !== "retired"
@@ -662,17 +696,20 @@ async function evidenceSourcesStage(scope, byId, model, readMarkdown) {
662
696
  const families = selectedControlFamilies(scope.controls, model);
663
697
  const items = [];
664
698
  for (const family of families) {
665
- const selectedSources = [...new Set(family.controls.flatMap((control) => control.evidenceSourceIds || []))]
699
+ const componentSources = String(model.modelVersion) === "4";
700
+ const sourceField = componentSources ? "evidenceSourceComponentIds" : "evidenceSourceIds";
701
+ const sourceType = componentSources ? "component" : "system";
702
+ const selectedSources = [...new Set(family.controls.flatMap((control) => control[sourceField] || []))]
666
703
  .map((id) => byId.get(id))
667
704
  .filter((record) => (
668
- record?.type === "system"
705
+ record?.type === sourceType
669
706
  && (
670
707
  !(record.evidenceSourceKinds || []).length
671
708
  || family.sourceKinds.some((kind) => (record.evidenceSourceKinds || []).includes(kind))
672
709
  )
673
710
  ));
674
711
  const completeSources = [];
675
- const sourceSystemChecks = [];
712
+ const sourceChecks = [];
676
713
  for (const source of selectedSources) {
677
714
  const instructions = await readMarkdown(source);
678
715
  const matchesRole = !family.sourceKinds.length
@@ -684,8 +721,8 @@ async function evidenceSourcesStage(scope, byId, model, readMarkdown) {
684
721
  retrievalInstructions: substantiveMarkdown(instructions) && openPlaceholderCount(instructions) === 0
685
722
  };
686
723
  const complete = Object.values(checks).every(Boolean);
687
- sourceSystemChecks.push({
688
- sourceSystemId: source.id,
724
+ sourceChecks.push({
725
+ [componentSources ? "sourceComponentId" : "sourceSystemId"]: source.id,
689
726
  complete,
690
727
  checks
691
728
  });
@@ -694,28 +731,31 @@ async function evidenceSourcesStage(scope, byId, model, readMarkdown) {
694
731
  }
695
732
  }
696
733
  const controlMappings = family.controls.map((control) => {
697
- const sourceSystemIds = (control.evidenceSourceIds || []).filter((id) => selectedSources.some((source) => source.id === id));
698
- const completeSourceSystemIds = sourceSystemIds.filter((id) => completeSources.some((source) => source.id === id));
734
+ const sourceIds = (control[sourceField] || []).filter((id) => selectedSources.some((source) => source.id === id));
735
+ const completeSourceIds = sourceIds.filter((id) => completeSources.some((source) => source.id === id));
699
736
  return {
700
737
  controlId: control.id,
701
- sourceSystemIds,
702
- completeSourceSystemIds,
703
- mapped: sourceSystemIds.length > 0,
704
- complete: completeSourceSystemIds.length > 0
738
+ [componentSources ? "sourceComponentIds" : "sourceSystemIds"]: sourceIds,
739
+ [componentSources ? "completeSourceComponentIds" : "completeSourceSystemIds"]: completeSourceIds,
740
+ mapped: sourceIds.length > 0,
741
+ complete: completeSourceIds.length > 0
705
742
  };
706
743
  });
707
744
  const coveredControls = controlMappings.filter(({ complete }) => complete);
708
745
  const complete = completeSources.length > 0 && coveredControls.length === family.controls.length;
709
746
  const commands = [
710
747
  ...(selectedSources.length
711
- ? sourceSystemChecks.filter(({ complete: sourceComplete }) => !sourceComplete).flatMap(({ sourceSystemId }) => [
712
- `npx filegrc get ${shellArgument(sourceSystemId)} --mutation > /tmp/${shellArgument(sourceSystemId)}.json`,
713
- `npx filegrc update system ${shellArgument(sourceSystemId)} /tmp/${shellArgument(sourceSystemId)}.json --json`
714
- ])
748
+ ? sourceChecks.filter(({ complete: sourceComplete }) => !sourceComplete).flatMap((sourceCheck) => {
749
+ const sourceId = sourceCheck.sourceComponentId || sourceCheck.sourceSystemId;
750
+ return [
751
+ `npx filegrc get ${shellArgument(sourceId)} --mutation > /tmp/${shellArgument(sourceId)}.json`,
752
+ `npx filegrc update ${sourceType} ${shellArgument(sourceId)} /tmp/${shellArgument(sourceId)}.json --json`
753
+ ];
754
+ })
715
755
  : [
716
- "npx filegrc list system --json",
717
- 'npx filegrc scaffold system --title "SYSTEM NAME" > /tmp/filegrc-system.json',
718
- "npx filegrc create /tmp/filegrc-system.json --json"
756
+ `npx filegrc list ${sourceType} --json`,
757
+ `npx filegrc scaffold ${sourceType} --title "${componentSources ? "COMPONENT" : "SYSTEM"} NAME" > /tmp/filegrc-${sourceType}.json`,
758
+ `npx filegrc create /tmp/filegrc-${sourceType}.json --json`
719
759
  ]),
720
760
  ...controlMappings.filter(({ mapped }) => !mapped).flatMap(({ controlId }) => [
721
761
  `npx filegrc get ${shellArgument(controlId)} --mutation > /tmp/${shellArgument(controlId)}.json`,
@@ -729,15 +769,15 @@ async function evidenceSourcesStage(scope, byId, model, readMarkdown) {
729
769
  family.title,
730
770
  complete
731
771
  ? `${completeSources.map((source) => source.title).join(", ")} ${completeSources.length === 1 ? "covers" : "cover"} all ${family.controls.length} selected controls and record access owners and extraction instructions.`
732
- : `${coveredControls.length} of ${family.controls.length} selected controls have an active authoritative system with the required source role, access owners, and extraction instructions.`,
733
- completeSources[0] || selectedSources[0] || { type: "system" },
772
+ : `${coveredControls.length} of ${family.controls.length} selected controls have an active authoritative ${componentSources ? "Component" : "System"} with the required source role, access owners, and extraction instructions.`,
773
+ completeSources[0] || selectedSources[0] || { type: sourceType },
734
774
  {
735
775
  familyId: family.id,
736
776
  sourceKinds: family.sourceKinds,
737
777
  controlIds: family.controls.map((control) => control.id),
738
- sourceSystemIds: selectedSources.map((source) => source.id),
739
- completeSourceSystemIds: completeSources.map((source) => source.id),
740
- sourceSystemChecks,
778
+ [componentSources ? "sourceComponentIds" : "sourceSystemIds"]: selectedSources.map((source) => source.id),
779
+ [componentSources ? "completeSourceComponentIds" : "completeSourceSystemIds"]: completeSources.map((source) => source.id),
780
+ [componentSources ? "sourceComponentChecks" : "sourceSystemChecks"]: sourceChecks,
741
781
  controlMappings,
742
782
  evidenceForm: family.evidenceForm,
743
783
  evidencePrompt: family.evidencePrompt,
@@ -748,7 +788,7 @@ async function evidenceSourcesStage(scope, byId, model, readMarkdown) {
748
788
  }
749
789
  ));
750
790
  }
751
- return stage("sources", "Control Evidence Sources", "Complete the authoritative Systems for every selected control family before marking the Controls implemented.", items);
791
+ return stage("sources", "Control Evidence Sources", `Complete the authoritative ${String(model.modelVersion) === "4" ? "Components" : "Systems"} for every selected control family before marking the Controls implemented.`, items);
752
792
  }
753
793
 
754
794
  function operationStage(loaded, workspace, scope, records, byId, asOf, evidenceReady, model) {
@@ -790,7 +830,7 @@ function operationStage(loaded, workspace, scope, records, byId, asOf, evidenceR
790
830
  ? coverageEnd(workspace.candidateCoverage)
791
831
  : null;
792
832
  const startStatus = !start ? "action" : start <= asOf ? "complete" : "later";
793
- const sourceCoverage = assessSourceCoverageReadiness(loaded, scope.controls.map(({ id }) => id));
833
+ const sourceCoverage = assessSourceCoverageReadiness(loaded, scope.controls.map(({ id }) => id), workspace);
794
834
  const incompleteSourceCoverage = sourceCoverage.filter(({ complete }) => !complete);
795
835
  return stage("operation", "Operate the Program", "Start the management candidate Type 2 period only after the Evidence Ready gate, then keep collection running.", [
796
836
  item(
package/src/program.js ADDED
@@ -0,0 +1,52 @@
1
+ export function resolveProgram(loaded, requestedId) {
2
+ if (String(loaded.model.modelVersion) !== "4") return loaded.workspace;
3
+ const programs = loaded.resources.filter((record) => record.type === "program" && record.status !== "retired");
4
+ if (requestedId) {
5
+ const program = programs.find(({ id }) => id === requestedId);
6
+ if (requestedId === "program-unconfigured" && !programs.length) {
7
+ return resolveProgram(loaded);
8
+ }
9
+ if (!program) throw new Error(`Program "${requestedId}" was not found or is retired.`);
10
+ return program;
11
+ }
12
+ const active = programs.filter(({ status }) => status === "active");
13
+ if (active.length === 1) return active[0];
14
+ if (active.length > 1) {
15
+ throw new Error(`More than one active Program is available. Select one of: ${active.map(({ id }) => id).join(", ")}.`);
16
+ }
17
+ if (programs.length === 1) return programs[0];
18
+ if (!programs.length) {
19
+ return {
20
+ id: "program-unconfigured",
21
+ type: "program",
22
+ title: "Program not configured",
23
+ status: "planned",
24
+ assuranceGoal: "none",
25
+ frameworkIds: [],
26
+ requirementApplicability: [],
27
+ systemIds: [],
28
+ controlIds: [],
29
+ policyIds: []
30
+ };
31
+ }
32
+ throw new Error(`More than one planned Program is available. Select one of: ${programs.map(({ id }) => id).join(", ")}.`);
33
+ }
34
+
35
+ export function selectedRequirementIds(program, model) {
36
+ if (String(model.modelVersion) === "4") {
37
+ return (program.requirementApplicability || [])
38
+ .filter(({ decision }) => decision === "applicable")
39
+ .map(({ requirementId }) => requirementId);
40
+ }
41
+ return program.requirementIds || [];
42
+ }
43
+
44
+ export function programComponents(loaded, program) {
45
+ if (String(loaded.model.modelVersion) !== "4") return [];
46
+ const systemIds = new Set(program.systemIds || []);
47
+ return loaded.resources.filter((record) => (
48
+ record.type === "component"
49
+ && record.status !== "retired"
50
+ && (record.systemUses || []).some(({ systemId }) => systemIds.has(systemId))
51
+ ));
52
+ }
@@ -92,13 +92,13 @@ const TRANSITIONS = {
92
92
 
93
93
  export async function planReconciliation(input = process.cwd()) {
94
94
  const loaded = await loadWorkspace(input);
95
- if (String(loaded.model.modelVersion) !== "3") {
95
+ if (!["3", "4"].includes(String(loaded.model.modelVersion))) {
96
96
  return {
97
97
  contractVersion: 1,
98
98
  gitRevision: gitRevision(loaded.root),
99
99
  changedPaths: [],
100
100
  candidates: [],
101
- message: "Direct-file transition reconciliation is available after migrating this workspace to model v3."
101
+ message: "Direct-file transition reconciliation is available in model v3 and v4 workspaces."
102
102
  };
103
103
  }
104
104
  const changedPaths = gitChangedPaths(loaded.root);
package/src/server.js CHANGED
@@ -2,6 +2,7 @@ import { createServer as createHttpServer } from "node:http";
2
2
  import { mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { extname, join, resolve } from "node:path";
5
+ import { performance } from "node:perf_hooks";
5
6
  import { getResourceDefinition } from "../model/index.js";
6
7
  import { prepareAuditWorkspace } from "./audit-preparation.js";
7
8
  import { createNextAuditCycle, planNextAuditCycle } from "./audit-transition.js";
@@ -23,6 +24,7 @@ import {
23
24
  getBrowserRepositoryState,
24
25
  getFileHistory,
25
26
  getGitSummary,
27
+ prefetchBrowserRemote,
26
28
  pullWorkspace,
27
29
  pushWorkspace,
28
30
  retryBrowserSync,
@@ -56,6 +58,18 @@ import { APP_SCRIPT, APP_STYLES, renderIndex } from "./web.js";
56
58
 
57
59
  export function createFilegrcServer(input = process.cwd(), options = {}) {
58
60
  return createHttpServer(async (request, response) => {
61
+ const requestStarted = performance.now();
62
+ if (timingEnabled()) {
63
+ response.once("finish", () => {
64
+ console.error(`[filegrc timing] ${JSON.stringify({
65
+ operation: "http-request",
66
+ method: request.method,
67
+ path: request.url?.split("?")[0],
68
+ status: response.statusCode,
69
+ durationMs: performance.now() - requestStarted
70
+ })}`);
71
+ });
72
+ }
59
73
  try {
60
74
  if (!expectedHost(request, options.allowedHosts)) {
61
75
  return json(response, 403, { error: "The request host is not allowed." });
@@ -99,12 +113,18 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
99
113
  const end = url.searchParams.get("end");
100
114
  return json(response, 200, await assessWorkflow(input, {
101
115
  auditId: url.searchParams.get("auditId") || undefined,
116
+ programId: url.searchParams.get("programId") || undefined,
102
117
  asOf: url.searchParams.get("asOf") || undefined,
103
118
  through: url.searchParams.get("through") || undefined,
104
119
  coverage: start || end ? { kind: "range", startsOn: start, endsOn: end } : undefined,
105
120
  includeComplete: url.searchParams.get("includeComplete") === "true"
106
121
  }));
107
122
  }
123
+ if (request.method === "POST" && url.pathname === "/api/git/prefetch") {
124
+ return json(response, 200, await prefetchBrowserRemote(input, {
125
+ allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites
126
+ }));
127
+ }
108
128
  if (request.method === "POST" && url.pathname === "/api/workflow/preview") {
109
129
  return json(response, 200, await previewWorkflowMutation(input, await readJson(request)));
110
130
  }
@@ -149,10 +169,13 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
149
169
  }
150
170
  if (request.method === "POST" && url.pathname === "/api/applicability-review") {
151
171
  const payload = await readJson(request);
172
+ const { prefetchToken, ...reviewPayload } = payload;
152
173
  return json(response, 201, await browserMutation(input, options, {
153
- message: (result) => `Record ${result.reviewedIds?.length || 0} applicability decisions`
174
+ message: (result) => `Record ${result.reviewedIds?.length || 0} applicability decisions`,
175
+ fastResponse: prefersFastMutation(request),
176
+ prefetchToken
154
177
  }, () => applyApplicabilityReview(input, {
155
- ...payload,
178
+ ...reviewPayload,
156
179
  confirmed: payload.confirmed === true
157
180
  })));
158
181
  }
@@ -161,10 +184,13 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
161
184
  }
162
185
  if (request.method === "POST" && url.pathname === "/api/collection-review") {
163
186
  const payload = await readJson(request);
187
+ const { prefetchToken, ...reviewPayload } = payload;
164
188
  return json(response, 201, await browserMutation(input, options, {
165
- message: (result) => `Confirm ${result.assessment?.configuration?.title || payload.resourceType}`
189
+ message: (result) => `Confirm ${result.assessment?.configuration?.title || payload.resourceType}`,
190
+ fastResponse: prefersFastMutation(request),
191
+ prefetchToken
166
192
  }, () => applyCollectionReview(input, {
167
- ...payload,
193
+ ...reviewPayload,
168
194
  confirmed: payload.confirmed === true
169
195
  })));
170
196
  }
@@ -484,12 +510,22 @@ function listen(server, port, host) {
484
510
 
485
511
  function browserMutation(input, options, mutationOptions, task) {
486
512
  const run = () => serializeWorkspaceMutation(input, async (root) => {
487
- const workflowBefore = await assessWorkflow(root);
488
- const result = await runBrowserMutation(root, {
513
+ const fastResponse = mutationOptions.fastResponse === true;
514
+ const workflowBefore = fastResponse
515
+ ? null
516
+ : await measureTiming("workflow-before", () => assessWorkflow(root));
517
+ const result = await measureTiming("mutation", () => runBrowserMutation(root, {
489
518
  ...mutationOptions,
490
519
  allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites === true,
491
- backgroundPushDelayMs: options.backgroundPushDelayMs
492
- }, task);
520
+ backgroundPushDelayMs: options.backgroundPushDelayMs,
521
+ includeValidationProof: !fastResponse
522
+ }, task));
523
+ if (fastResponse) {
524
+ return {
525
+ ...result,
526
+ stateRefresh: true
527
+ };
528
+ }
493
529
  const state = await measureTiming("state", () => createAppState(root, {
494
530
  allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites,
495
531
  includeDetails: false,
@@ -516,6 +552,12 @@ function browserMutation(input, options, mutationOptions, task) {
516
552
  });
517
553
  }
518
554
 
555
+ function prefersFastMutation(request) {
556
+ return String(request.headers.prefer || "")
557
+ .split(",")
558
+ .some((value) => value.trim().toLowerCase() === "respond-async");
559
+ }
560
+
519
561
  async function requireManualBrowserGit(input, options) {
520
562
  const repository = await getBrowserRepositoryState(input, {
521
563
  allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites