filegrc 0.3.4 → 0.5.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.
- package/README.md +24 -6
- package/model/index.js +41 -3
- package/model/v1.json +81 -47
- package/model/v2.json +8022 -0
- package/model/v3.json +9391 -0
- package/package.json +2 -2
- package/src/agent.js +89 -8
- package/src/appointments.js +19 -0
- package/src/audit-preparation.js +109 -65
- package/src/audit-transition.js +96 -0
- package/src/batch-review.js +109 -0
- package/src/cli.js +563 -148
- package/src/collection-review.js +185 -0
- package/src/coverage.js +50 -0
- package/src/evidence-packet.js +149 -77
- package/src/external-reviewer.js +165 -0
- package/src/files.js +267 -29
- package/src/git.js +239 -41
- package/src/index.js +41 -7
- package/src/model-docs.js +103 -7
- package/src/model-migration.js +1958 -0
- package/src/mutation.js +42 -0
- package/src/obligations.js +502 -95
- package/src/parties.js +17 -2
- package/src/program-lifecycle.js +1 -0
- package/src/program-path.js +70 -60
- package/src/program-readiness.js +470 -130
- package/src/reconciliation.js +277 -0
- package/src/resource-status.js +17 -0
- package/src/server.js +347 -48
- package/src/setup.js +57 -26
- package/src/source-coverage.js +61 -0
- package/src/state.js +122 -25
- package/src/timing.js +41 -0
- package/src/validate.js +707 -44
- package/src/web.js +1440 -304
- package/src/workflow.js +1595 -0
- package/src/workspace.js +15 -7
- package/src/evidence-tests.js +0 -69
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { applyResourceBatch } from "./files.js";
|
|
2
|
+
import { createResourceId } from "./id.js";
|
|
3
|
+
import { currentCalendarDate } from "./time.js";
|
|
4
|
+
import { loadWorkspace } from "./workspace.js";
|
|
5
|
+
|
|
6
|
+
export async function scaffoldExternalReviewerGovernance(input = process.cwd()) {
|
|
7
|
+
const loaded = await loadWorkspace(input);
|
|
8
|
+
if (String(loaded.model.modelVersion) !== "3") {
|
|
9
|
+
throw new Error("External reviewer setup requires a model v3 workspace.");
|
|
10
|
+
}
|
|
11
|
+
return {
|
|
12
|
+
reviewerName: null,
|
|
13
|
+
jobTitle: null,
|
|
14
|
+
email: null,
|
|
15
|
+
organization: null,
|
|
16
|
+
startsOn: currentCalendarDate(loaded.workspace.timezone),
|
|
17
|
+
independenceRationale: null,
|
|
18
|
+
appointedByIds: [],
|
|
19
|
+
instructions: "Replace every null required value with current facts. The reviewer must be independent from policy ownership and operating work. Preview the completed file before applying it."
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function planExternalReviewerGovernance(input = process.cwd(), options = {}) {
|
|
24
|
+
const loaded = await loadWorkspace(input);
|
|
25
|
+
if (String(loaded.model.modelVersion) !== "3") {
|
|
26
|
+
throw new Error("External reviewer setup requires a model v3 workspace.");
|
|
27
|
+
}
|
|
28
|
+
const name = required(options.reviewerName, "External reviewer name");
|
|
29
|
+
const startsOn = required(options.startsOn, "Appointment start date");
|
|
30
|
+
const existingPerson = loaded.resources.find((record) => (
|
|
31
|
+
record.type === "person"
|
|
32
|
+
&& record.affiliation === "external"
|
|
33
|
+
&& (
|
|
34
|
+
record.id === options.reviewerId
|
|
35
|
+
|| record.email && options.email && record.email.toLowerCase() === String(options.email).toLowerCase()
|
|
36
|
+
)
|
|
37
|
+
));
|
|
38
|
+
const jobTitle = required(
|
|
39
|
+
options.jobTitle || existingPerson?.jobTitle,
|
|
40
|
+
"External reviewer organizational job title"
|
|
41
|
+
);
|
|
42
|
+
const person = {
|
|
43
|
+
...(existingPerson || {
|
|
44
|
+
id: options.reviewerId || createResourceId("person", name, loaded.resources.map(({ id }) => id)),
|
|
45
|
+
type: "person"
|
|
46
|
+
}),
|
|
47
|
+
title: name,
|
|
48
|
+
status: "active",
|
|
49
|
+
affiliation: "external",
|
|
50
|
+
jobTitle,
|
|
51
|
+
...(options.email ? { email: String(options.email) } : {}),
|
|
52
|
+
...(options.organization ? { organization: String(options.organization) } : {})
|
|
53
|
+
};
|
|
54
|
+
const workspaceId = loaded.workspace.id;
|
|
55
|
+
const creates = existingPerson ? [] : [person];
|
|
56
|
+
const updates = existingPerson ? [person] : [];
|
|
57
|
+
const appointmentIds = [];
|
|
58
|
+
const appointmentSpecs = [
|
|
59
|
+
{
|
|
60
|
+
kind: "independent-policy-reviewer",
|
|
61
|
+
title: "Independent Policy Reviewer",
|
|
62
|
+
responsibilities: "Review and approve policies and governed documents independently from the owner, chair security and risk oversight, and challenge management decisions."
|
|
63
|
+
}
|
|
64
|
+
];
|
|
65
|
+
for (const spec of appointmentSpecs) {
|
|
66
|
+
const existing = loaded.resources.find((record) => (
|
|
67
|
+
record.type === "appointment"
|
|
68
|
+
&& record.appointmentKind === spec.kind
|
|
69
|
+
&& record.status !== "ended"
|
|
70
|
+
));
|
|
71
|
+
const appointment = {
|
|
72
|
+
...(existing || {
|
|
73
|
+
id: createResourceId("appointment", spec.title, [
|
|
74
|
+
...loaded.resources.map(({ id }) => id),
|
|
75
|
+
...creates.map(({ id }) => id)
|
|
76
|
+
]),
|
|
77
|
+
type: "appointment",
|
|
78
|
+
title: spec.title
|
|
79
|
+
}),
|
|
80
|
+
status: "active",
|
|
81
|
+
appointmentKind: spec.kind,
|
|
82
|
+
holderId: person.id,
|
|
83
|
+
scopeResourceIds: [workspaceId],
|
|
84
|
+
startsOn,
|
|
85
|
+
responsibilities: existing?.responsibilities || spec.responsibilities,
|
|
86
|
+
independenceRationale: required(
|
|
87
|
+
options.independenceRationale,
|
|
88
|
+
"Independence rationale"
|
|
89
|
+
),
|
|
90
|
+
...(options.appointedByIds?.length
|
|
91
|
+
? { appointedByIds: [...new Set(options.appointedByIds.map(String))] }
|
|
92
|
+
: {})
|
|
93
|
+
};
|
|
94
|
+
appointmentIds.push(appointment.id);
|
|
95
|
+
if (existing) updates.push(appointment);
|
|
96
|
+
else creates.push(appointment);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const team = loaded.resources.find((record) => (
|
|
100
|
+
record.type === "team" && record.id === "team-security-risk-oversight"
|
|
101
|
+
)) || loaded.resources.find((record) => record.type === "team" && /oversight/i.test(record.title));
|
|
102
|
+
if (team) {
|
|
103
|
+
updates.push({
|
|
104
|
+
...team,
|
|
105
|
+
status: "active",
|
|
106
|
+
memberIds: [...new Set([...(team.memberIds || []), person.id])],
|
|
107
|
+
chairIds: [...new Set([...(team.chairIds || []), appointmentIds[0]])]
|
|
108
|
+
});
|
|
109
|
+
} else {
|
|
110
|
+
creates.push({
|
|
111
|
+
id: createResourceId("team", "Security and Risk Oversight", [
|
|
112
|
+
...loaded.resources.map(({ id }) => id),
|
|
113
|
+
...creates.map(({ id }) => id)
|
|
114
|
+
]),
|
|
115
|
+
type: "team",
|
|
116
|
+
title: "Security and Risk Oversight",
|
|
117
|
+
status: "active",
|
|
118
|
+
purpose: "Provide independent review of security, risk, policies, incidents, findings, and overdue work.",
|
|
119
|
+
memberIds: [person.id],
|
|
120
|
+
chairIds: [appointmentIds[0]]
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
for (const policy of loaded.resources.filter((record) => (
|
|
125
|
+
record.type === "policy"
|
|
126
|
+
&& ["draft", "in-review"].includes(record.status)
|
|
127
|
+
&& !(record.approverIds || []).includes(person.id)
|
|
128
|
+
))) {
|
|
129
|
+
updates.push({
|
|
130
|
+
...policy,
|
|
131
|
+
approverIds: [...new Set([...(policy.approverIds || []), person.id])]
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
operation: "external-reviewer-governance",
|
|
136
|
+
reviewerId: person.id,
|
|
137
|
+
appointmentIds,
|
|
138
|
+
changes: {
|
|
139
|
+
create: creates,
|
|
140
|
+
update: deduplicateUpdates(updates),
|
|
141
|
+
validateWholeWorkspace: true
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function setupExternalReviewerGovernance(input = process.cwd(), options = {}) {
|
|
147
|
+
if (options.confirmed !== true) {
|
|
148
|
+
throw new Error("Preview the external reviewer governance bundle and confirm the write.");
|
|
149
|
+
}
|
|
150
|
+
const plan = await planExternalReviewerGovernance(input, options);
|
|
151
|
+
const result = await applyResourceBatch(input, plan.changes);
|
|
152
|
+
return { ...plan, result };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function required(value, label) {
|
|
156
|
+
const normalized = String(value || "").trim();
|
|
157
|
+
if (!normalized) throw new Error(`${label} is required.`);
|
|
158
|
+
return normalized;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function deduplicateUpdates(records) {
|
|
162
|
+
const byId = new Map();
|
|
163
|
+
for (const record of records) byId.set(record.id, record);
|
|
164
|
+
return [...byId.values()];
|
|
165
|
+
}
|
package/src/files.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { constants, link, lstat, mkdir, open, readFile, rename, rm, stat } from "node:fs/promises";
|
|
3
3
|
import { basename, dirname, join, resolve } from "node:path";
|
|
4
|
-
import { getResourceDefinition } from "../model/index.js";
|
|
5
|
-
import { serializeWorkspaceMutation } from "./mutation.js";
|
|
4
|
+
import { getResourceDefinition, loadModel } from "../model/index.js";
|
|
5
|
+
import { serializeWorkspaceMutation, workspaceValidationDeferred } from "./mutation.js";
|
|
6
6
|
import { isCanonicalDataPath, resolveDataPath, resolveWorkspaceRoot } from "./paths.js";
|
|
7
7
|
import { markdownEntries } from "./resource-markdown.js";
|
|
8
|
+
import { measureTiming } from "./timing.js";
|
|
8
9
|
import { loadWorkspace } from "./workspace.js";
|
|
9
10
|
import { validateWorkspace } from "./validate.js";
|
|
10
11
|
|
|
@@ -198,6 +199,120 @@ export async function createResources(input, records) {
|
|
|
198
199
|
return serializeWorkspaceMutation(input, (root) => createResourcesUnlocked(root, records));
|
|
199
200
|
}
|
|
200
201
|
|
|
202
|
+
export async function applyResourceBatch(input, changes) {
|
|
203
|
+
return serializeWorkspaceMutation(input, (root) => applyResourceBatchUnlocked(root, changes));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function applyResourceBatchUnlocked(input, changes = {}) {
|
|
207
|
+
const creates = changes.create || [];
|
|
208
|
+
const updates = changes.update || [];
|
|
209
|
+
const expectedRevisions = changes.expectedRevisions || {};
|
|
210
|
+
if (!Array.isArray(creates) || !Array.isArray(updates) || (!creates.length && !updates.length)) {
|
|
211
|
+
throw new Error("A resource batch needs at least one create or update.");
|
|
212
|
+
}
|
|
213
|
+
if (Array.isArray(expectedRevisions) || typeof expectedRevisions !== "object") {
|
|
214
|
+
throw new Error("Batch expected revisions must be keyed by resource ID.");
|
|
215
|
+
}
|
|
216
|
+
const loaded = await loadWorkspace(input);
|
|
217
|
+
const workspaceUpdate = updates.find((record) => (
|
|
218
|
+
record.type === "workspace" && record.id === loaded.workspace?.id
|
|
219
|
+
));
|
|
220
|
+
const changesModelVersion = workspaceUpdate
|
|
221
|
+
&& String(workspaceUpdate.dataModelVersion || "") !== String(loaded.workspace?.dataModelVersion || "");
|
|
222
|
+
const targetModelVersion = changes.targetModelVersion
|
|
223
|
+
? String(changes.targetModelVersion)
|
|
224
|
+
: null;
|
|
225
|
+
if (changesModelVersion && !targetModelVersion) {
|
|
226
|
+
throw new Error(
|
|
227
|
+
"A resource batch that changes dataModelVersion must declare targetModelVersion."
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
if (
|
|
231
|
+
targetModelVersion
|
|
232
|
+
&& (
|
|
233
|
+
changes.validateWholeWorkspace !== true
|
|
234
|
+
|| String(workspaceUpdate?.dataModelVersion || "") !== targetModelVersion
|
|
235
|
+
)
|
|
236
|
+
) {
|
|
237
|
+
throw new Error(
|
|
238
|
+
"A cross-model resource batch must validate the whole workspace and update its dataModelVersion to the target model."
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
const writeModel = targetModelVersion ? loadModel(targetModelVersion) : loaded.model;
|
|
242
|
+
const deferValidation = workspaceValidationDeferred();
|
|
243
|
+
const before = deferValidation || changes.validateWholeWorkspace
|
|
244
|
+
? null
|
|
245
|
+
: await validateWorkspace(loaded);
|
|
246
|
+
const existingById = new Map(loaded.entries.map((entry) => [entry.record.id, entry]));
|
|
247
|
+
const ids = new Set();
|
|
248
|
+
const writes = [];
|
|
249
|
+
for (const record of creates) {
|
|
250
|
+
validateBatchRecord(record, ids);
|
|
251
|
+
if (existingById.has(record.id)) throw new Error(`Resource "${record.id}" already exists.`);
|
|
252
|
+
const path = resourcePath(loaded.root, writeModel, record);
|
|
253
|
+
writes.push({ operation: "create", path, record, previous: null, fileMode: 0o666 });
|
|
254
|
+
}
|
|
255
|
+
for (const record of updates) {
|
|
256
|
+
validateBatchRecord(record, ids);
|
|
257
|
+
const existing = existingById.get(record.id);
|
|
258
|
+
if (!existing) throw new Error(`Resource "${record.id}" was not found.`);
|
|
259
|
+
if (existing.record.type !== record.type) {
|
|
260
|
+
throw new Error(`Resource "${record.id}" cannot change type.`);
|
|
261
|
+
}
|
|
262
|
+
const path = resourcePath(loaded.root, writeModel, record);
|
|
263
|
+
const previous = await readFile(path, "utf8");
|
|
264
|
+
const mode = (await stat(path)).mode & 0o777;
|
|
265
|
+
assertRevision(
|
|
266
|
+
previous,
|
|
267
|
+
expectedRevisions[record.id] || existing.revision,
|
|
268
|
+
`Resource "${record.id}"`
|
|
269
|
+
);
|
|
270
|
+
writes.push({ operation: "update", path, record, previous, fileMode: mode });
|
|
271
|
+
}
|
|
272
|
+
const written = [];
|
|
273
|
+
try {
|
|
274
|
+
for (const item of writes) {
|
|
275
|
+
await writeAtomic(item.path, item.record, { exclusive: item.operation === "create" });
|
|
276
|
+
written.push(item);
|
|
277
|
+
}
|
|
278
|
+
let validation = null;
|
|
279
|
+
if (!deferValidation) {
|
|
280
|
+
validation = await validateWorkspace(loaded.root);
|
|
281
|
+
const errors = changes.validateWholeWorkspace
|
|
282
|
+
? validation.diagnostics.filter(({ severity }) => severity === "error")
|
|
283
|
+
: newErrors(validation, before);
|
|
284
|
+
if (errors.length) throw new Error(formatWriteFailure(errors, "resource batch"));
|
|
285
|
+
}
|
|
286
|
+
return {
|
|
287
|
+
created: creates,
|
|
288
|
+
updated: updates,
|
|
289
|
+
validation
|
|
290
|
+
};
|
|
291
|
+
} catch (error) {
|
|
292
|
+
const rollbackErrors = [];
|
|
293
|
+
for (const item of written.reverse()) {
|
|
294
|
+
try {
|
|
295
|
+
if (item.operation === "create") await rm(item.path, { force: true });
|
|
296
|
+
else await writeTextAtomic(item.path, item.previous, { mode: item.fileMode });
|
|
297
|
+
} catch (rollbackError) {
|
|
298
|
+
rollbackErrors.push(rollbackError.message);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
if (rollbackErrors.length) {
|
|
302
|
+
throw new Error(`${error.message} FileGRC could not restore every file in the resource batch: ${rollbackErrors.join(" ")}`);
|
|
303
|
+
}
|
|
304
|
+
throw error;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function validateBatchRecord(record, ids) {
|
|
309
|
+
if (!record || Array.isArray(record) || typeof record !== "object") {
|
|
310
|
+
throw new Error("Every resource in a batch must be a JSON object.");
|
|
311
|
+
}
|
|
312
|
+
if (ids.has(record.id)) throw new Error(`Resource "${record.id}" appears more than once in the batch.`);
|
|
313
|
+
ids.add(record.id);
|
|
314
|
+
}
|
|
315
|
+
|
|
201
316
|
export async function createResourceAndLink(input, record, linkTarget, options = {}) {
|
|
202
317
|
return serializeWorkspaceMutation(input, (root) => createResourceAndLinkUnlocked(root, record, linkTarget, options));
|
|
203
318
|
}
|
|
@@ -254,7 +369,8 @@ async function createResourceAndLinkUnlocked(input, record, linkTarget, options)
|
|
|
254
369
|
async function createResourcesUnlocked(input, records) {
|
|
255
370
|
if (!Array.isArray(records) || records.length === 0) throw new Error("At least one resource is required.");
|
|
256
371
|
const loaded = await loadWorkspace(input);
|
|
257
|
-
const
|
|
372
|
+
const deferValidation = workspaceValidationDeferred();
|
|
373
|
+
const before = deferValidation ? null : await validateWorkspace(loaded);
|
|
258
374
|
const ids = new Set();
|
|
259
375
|
const writes = [];
|
|
260
376
|
for (const record of records) {
|
|
@@ -276,9 +392,11 @@ async function createResourcesUnlocked(input, records) {
|
|
|
276
392
|
await writeAtomic(item.path, item.record, { exclusive: true });
|
|
277
393
|
written.push(item);
|
|
278
394
|
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
395
|
+
if (!deferValidation) {
|
|
396
|
+
const result = await validateWorkspace(loaded.root);
|
|
397
|
+
const introduced = newErrors(result, before);
|
|
398
|
+
if (introduced.length) throw new Error(formatWriteFailure(introduced, "resource batch"));
|
|
399
|
+
}
|
|
282
400
|
} catch (error) {
|
|
283
401
|
for (const item of written.reverse()) await rm(item.path, { force: true });
|
|
284
402
|
throw error;
|
|
@@ -288,7 +406,8 @@ async function createResourcesUnlocked(input, records) {
|
|
|
288
406
|
|
|
289
407
|
async function createResourceUnlocked(input, record, options) {
|
|
290
408
|
const loaded = await loadWorkspace(input);
|
|
291
|
-
const
|
|
409
|
+
const deferValidation = workspaceValidationDeferred();
|
|
410
|
+
const before = deferValidation ? null : await validateWorkspace(loaded);
|
|
292
411
|
const path = resourcePath(loaded.root, loaded.model, record);
|
|
293
412
|
try {
|
|
294
413
|
await stat(path);
|
|
@@ -297,6 +416,7 @@ async function createResourceUnlocked(input, record, options) {
|
|
|
297
416
|
if (error.code !== "ENOENT") throw error;
|
|
298
417
|
}
|
|
299
418
|
const contentWrites = await prepareContentWrites(loaded, record, options.content, { exclusive: true });
|
|
419
|
+
const nextRecord = await prepareApprovalBinding(loaded, record, contentWrites);
|
|
300
420
|
const written = [];
|
|
301
421
|
let recordWritten = false;
|
|
302
422
|
try {
|
|
@@ -304,17 +424,19 @@ async function createResourceUnlocked(input, record, options) {
|
|
|
304
424
|
await writeTextAtomic(item.path, item.source, { exclusive: true });
|
|
305
425
|
written.push(item);
|
|
306
426
|
}
|
|
307
|
-
await writeAtomic(path,
|
|
427
|
+
await writeAtomic(path, nextRecord, { exclusive: true });
|
|
308
428
|
recordWritten = true;
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
429
|
+
if (!deferValidation) {
|
|
430
|
+
const result = await validateWorkspace(loaded.root);
|
|
431
|
+
const introduced = newErrors(result, before);
|
|
432
|
+
if (introduced.length) throw new Error(formatWriteFailure(introduced, record.id));
|
|
433
|
+
}
|
|
312
434
|
} catch (error) {
|
|
313
435
|
if (recordWritten) await rm(path, { force: true });
|
|
314
436
|
for (const item of written) await rm(item.path, { force: true });
|
|
315
437
|
throw error;
|
|
316
438
|
}
|
|
317
|
-
return { record, path };
|
|
439
|
+
return { record: nextRecord, path };
|
|
318
440
|
}
|
|
319
441
|
|
|
320
442
|
export async function updateResource(input, type, id, record, options = {}) {
|
|
@@ -326,19 +448,25 @@ async function updateResourceUnlocked(input, type, id, record, options) {
|
|
|
326
448
|
throw new Error("The type and ID in the record must match the resource being updated.");
|
|
327
449
|
}
|
|
328
450
|
const loaded = await loadWorkspace(input);
|
|
329
|
-
const
|
|
451
|
+
const deferValidation = workspaceValidationDeferred();
|
|
452
|
+
const before = deferValidation ? null : await validateWorkspace(loaded);
|
|
330
453
|
const path = resourcePath(loaded.root, loaded.model, record);
|
|
331
454
|
const previous = await readFile(path, "utf8");
|
|
332
455
|
assertRevision(previous, options.expectedRevision, "The record");
|
|
333
456
|
const contentWrites = await prepareContentWrites(loaded, record, options.content, {
|
|
334
|
-
expectedRevisions: options.expectedContentRevisions
|
|
457
|
+
expectedRevisions: options.expectedContentRevisions,
|
|
458
|
+
requireExpectedRevisions: options.requireExpectedContentRevisions
|
|
335
459
|
});
|
|
460
|
+
const existing = loaded.entries.find(({ record: candidate }) => candidate.id === id)?.record;
|
|
461
|
+
const nextRecord = await prepareApprovalBinding(loaded, record, contentWrites, existing);
|
|
336
462
|
try {
|
|
337
463
|
for (const item of contentWrites) await writeTextAtomic(item.path, item.source);
|
|
338
|
-
await writeAtomic(path,
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
464
|
+
await writeAtomic(path, nextRecord);
|
|
465
|
+
if (!deferValidation) {
|
|
466
|
+
const result = await validateWorkspace(loaded.root);
|
|
467
|
+
const introduced = newErrors(result, before);
|
|
468
|
+
if (introduced.length) throw new Error(formatWriteFailure(introduced, id));
|
|
469
|
+
}
|
|
342
470
|
} catch (error) {
|
|
343
471
|
await writeTextAtomic(path, previous);
|
|
344
472
|
for (const item of contentWrites) {
|
|
@@ -347,7 +475,7 @@ async function updateResourceUnlocked(input, type, id, record, options) {
|
|
|
347
475
|
}
|
|
348
476
|
throw error;
|
|
349
477
|
}
|
|
350
|
-
return { record, path };
|
|
478
|
+
return { record: nextRecord, path };
|
|
351
479
|
}
|
|
352
480
|
|
|
353
481
|
export async function updateContent(input, dataRelativePath, source, options = {}) {
|
|
@@ -368,8 +496,18 @@ async function updateContentUnlocked(input, dataRelativePath, source, options) {
|
|
|
368
496
|
const path = resolveDataPath(loaded.root, dataRelativePath);
|
|
369
497
|
const previous = await readFile(path, "utf8");
|
|
370
498
|
assertRevision(previous, options.expectedRevision, "The Markdown file");
|
|
371
|
-
|
|
372
|
-
|
|
499
|
+
const before = await validateWorkspace(loaded);
|
|
500
|
+
const nextSource = source.endsWith("\n") ? source : `${source}\n`;
|
|
501
|
+
await writeTextAtomic(path, nextSource);
|
|
502
|
+
try {
|
|
503
|
+
const result = await validateWorkspace(loaded.root);
|
|
504
|
+
const introduced = newErrors(result, before);
|
|
505
|
+
if (introduced.length) throw new Error(formatWriteFailure(introduced, dataRelativePath));
|
|
506
|
+
return { path, dataRelativePath };
|
|
507
|
+
} catch (error) {
|
|
508
|
+
await writeTextAtomic(path, previous);
|
|
509
|
+
throw error;
|
|
510
|
+
}
|
|
373
511
|
}
|
|
374
512
|
|
|
375
513
|
export async function deleteResource(input, type, id, options = {}) {
|
|
@@ -378,7 +516,8 @@ export async function deleteResource(input, type, id, options = {}) {
|
|
|
378
516
|
|
|
379
517
|
async function deleteResourceUnlocked(input, type, id, options) {
|
|
380
518
|
const loaded = await loadWorkspace(input);
|
|
381
|
-
const
|
|
519
|
+
const deferValidation = workspaceValidationDeferred();
|
|
520
|
+
const before = deferValidation ? null : await validateWorkspace(loaded);
|
|
382
521
|
const definition = getResourceDefinition(loaded.model, type);
|
|
383
522
|
if (definition.singleton) throw new Error("Singleton records cannot be deleted.");
|
|
384
523
|
const path = resourcePath(loaded.root, loaded.model, { type, id });
|
|
@@ -393,9 +532,11 @@ async function deleteResourceUnlocked(input, type, id, options) {
|
|
|
393
532
|
try {
|
|
394
533
|
await rm(path);
|
|
395
534
|
for (const item of contentFiles) await rm(item.path, { force: true });
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
535
|
+
if (!deferValidation) {
|
|
536
|
+
const result = await validateWorkspace(loaded.root);
|
|
537
|
+
const introduced = newErrors(result, before);
|
|
538
|
+
if (introduced.length) throw new Error(formatWriteFailure(introduced, id));
|
|
539
|
+
}
|
|
399
540
|
} catch (error) {
|
|
400
541
|
await writeTextAtomic(path, source, { mode });
|
|
401
542
|
for (const item of contentFiles) {
|
|
@@ -418,11 +559,17 @@ export function resourcePath(input, model, record) {
|
|
|
418
559
|
}
|
|
419
560
|
|
|
420
561
|
async function writeAtomic(path, value, options = {}) {
|
|
421
|
-
|
|
422
|
-
|
|
562
|
+
return measureTiming("writes", async () => {
|
|
563
|
+
const source = `${JSON.stringify(value, null, 2)}\n`;
|
|
564
|
+
await writeTextAtomicUnmeasured(path, source, options);
|
|
565
|
+
});
|
|
423
566
|
}
|
|
424
567
|
|
|
425
568
|
async function writeTextAtomic(path, source, options = {}) {
|
|
569
|
+
return measureTiming("writes", () => writeTextAtomicUnmeasured(path, source, options));
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
async function writeTextAtomicUnmeasured(path, source, options = {}) {
|
|
426
573
|
await mkdir(dirname(path), { recursive: true });
|
|
427
574
|
const temp = join(dirname(path), `.${randomUUID()}.tmp`);
|
|
428
575
|
let mode = options.mode ?? 0o666;
|
|
@@ -454,7 +601,9 @@ async function writeTextAtomic(path, source, options = {}) {
|
|
|
454
601
|
|
|
455
602
|
function newErrors(after, before) {
|
|
456
603
|
const existing = new Set(before.diagnostics.filter(({ severity }) => severity === "error").map(diagnosticKey));
|
|
457
|
-
return after.diagnostics
|
|
604
|
+
return after.diagnostics
|
|
605
|
+
.filter(({ severity }) => severity === "error")
|
|
606
|
+
.filter((item) => item.code === "unsupported-model" || !existing.has(diagnosticKey(item)));
|
|
458
607
|
}
|
|
459
608
|
|
|
460
609
|
function diagnosticKey(item) {
|
|
@@ -490,6 +639,12 @@ async function prepareContentWrites(loaded, record, content, options = {}) {
|
|
|
490
639
|
try {
|
|
491
640
|
previous = await readFile(path, "utf8");
|
|
492
641
|
if (options.exclusive) throw new Error(`Content already exists at data/${dataRelativePath}.`);
|
|
642
|
+
if (
|
|
643
|
+
options.requireExpectedRevisions
|
|
644
|
+
&& !Object.hasOwn(options.expectedRevisions ?? {}, dataRelativePath)
|
|
645
|
+
) {
|
|
646
|
+
throw new Error(`A content revision is required for existing content at data/${dataRelativePath}.`);
|
|
647
|
+
}
|
|
493
648
|
assertRevision(previous, options.expectedRevisions?.[dataRelativePath], `Content at data/${dataRelativePath}`);
|
|
494
649
|
} catch (error) {
|
|
495
650
|
if (error.code !== "ENOENT") throw error;
|
|
@@ -505,10 +660,93 @@ function assertRevision(source, expected, label) {
|
|
|
505
660
|
}
|
|
506
661
|
}
|
|
507
662
|
|
|
508
|
-
function contentRevision(source) {
|
|
663
|
+
export function contentRevision(source) {
|
|
509
664
|
return createHash("sha256").update(source).digest("hex");
|
|
510
665
|
}
|
|
511
666
|
|
|
667
|
+
async function prepareApprovalBinding(loaded, record, contentWrites, previousRecord = null) {
|
|
668
|
+
if (record.type === "attestation") {
|
|
669
|
+
return prepareAttestationBinding(loaded, record, previousRecord);
|
|
670
|
+
}
|
|
671
|
+
const bindingField = approvalBindingField(record, loaded.model);
|
|
672
|
+
if (!bindingField) return record;
|
|
673
|
+
const nextRecord = structuredClone(record);
|
|
674
|
+
if (!approvalBound(record)) {
|
|
675
|
+
delete nextRecord[bindingField];
|
|
676
|
+
return nextRecord;
|
|
677
|
+
}
|
|
678
|
+
if (approvalBound(previousRecord) && previousRecord[bindingField]) {
|
|
679
|
+
nextRecord[bindingField] = structuredClone(previousRecord[bindingField]);
|
|
680
|
+
return nextRecord;
|
|
681
|
+
}
|
|
682
|
+
const proposed = new Map(contentWrites.map((item) => [item.dataRelativePath, item.source]));
|
|
683
|
+
const revisions = {};
|
|
684
|
+
for (const item of markdownEntries(loaded.model, nextRecord)) {
|
|
685
|
+
let source = proposed.get(item.path);
|
|
686
|
+
if (source === undefined) {
|
|
687
|
+
try {
|
|
688
|
+
source = await readFile(resolveDataPath(loaded.root, item.path), "utf8");
|
|
689
|
+
} catch (error) {
|
|
690
|
+
if (error.code === "ENOENT") continue;
|
|
691
|
+
throw error;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
revisions[item.path] = contentRevision(source);
|
|
695
|
+
}
|
|
696
|
+
nextRecord[bindingField] = revisions;
|
|
697
|
+
return nextRecord;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
async function prepareAttestationBinding(loaded, record, previousRecord = null) {
|
|
701
|
+
const nextRecord = structuredClone(record);
|
|
702
|
+
const bound = record.status === "completed" && record.attestationMethod === "git-approval";
|
|
703
|
+
if (!bound) {
|
|
704
|
+
delete nextRecord.contentRevisions;
|
|
705
|
+
return nextRecord;
|
|
706
|
+
}
|
|
707
|
+
if (
|
|
708
|
+
previousRecord?.status === "completed"
|
|
709
|
+
&& previousRecord.attestationMethod === "git-approval"
|
|
710
|
+
&& previousRecord.contentRevisions
|
|
711
|
+
) {
|
|
712
|
+
nextRecord.contentRevisions = structuredClone(previousRecord.contentRevisions);
|
|
713
|
+
return nextRecord;
|
|
714
|
+
}
|
|
715
|
+
const revisions = {};
|
|
716
|
+
for (const id of record.subjectResourceIds || []) {
|
|
717
|
+
const subject = loaded.resources.find((candidate) => candidate.id === id);
|
|
718
|
+
if (!subject) continue;
|
|
719
|
+
for (const item of markdownEntries(loaded.model, subject)) {
|
|
720
|
+
try {
|
|
721
|
+
const source = await readFile(resolveDataPath(loaded.root, item.path), "utf8");
|
|
722
|
+
revisions[item.path] = contentRevision(source);
|
|
723
|
+
} catch (error) {
|
|
724
|
+
if (error.code !== "ENOENT") throw error;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
nextRecord.contentRevisions = revisions;
|
|
729
|
+
return nextRecord;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function approvalBound(record) {
|
|
733
|
+
if (!record || !["policy", "document", "training"].includes(record.type)) return false;
|
|
734
|
+
const statuses = record.type === "policy"
|
|
735
|
+
? ["approved", "active", "superseded", "retired"]
|
|
736
|
+
: record.type === "document"
|
|
737
|
+
? ["active", "superseded", "retired"]
|
|
738
|
+
: ["active", "retired"];
|
|
739
|
+
return statuses.includes(record.status);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function approvalBindingField(record, model) {
|
|
743
|
+
if (["policy", "document"].includes(record?.type)) return "approvedContentRevisions";
|
|
744
|
+
if (record?.type === "training" && model.resources.training?.fields?.effectiveContentRevisions) {
|
|
745
|
+
return "effectiveContentRevisions";
|
|
746
|
+
}
|
|
747
|
+
return null;
|
|
748
|
+
}
|
|
749
|
+
|
|
512
750
|
async function exclusiveContentFiles(loaded, record) {
|
|
513
751
|
const candidates = markdownEntries(loaded.model, record).map(({ path }) => path);
|
|
514
752
|
const files = [];
|