@open-agent-toolkit/cli 0.2.26 → 0.2.27
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/assets/NOTICES.md +156 -0
- package/assets/docs/contributing/explainer-kit-verification.md +125 -0
- package/assets/docs/contributing/index.md +1 -0
- package/assets/docs/reference/troubleshooting.md +47 -0
- package/assets/docs/workflows/projects/artifacts.md +24 -6
- package/assets/docs/workflows/skills/explainer-kit-providers.md +144 -0
- package/assets/docs/workflows/skills/explainer-kit.md +121 -69
- package/assets/docs/workflows/skills/index.md +1 -0
- package/assets/public-package-versions.json +4 -4
- package/assets/skills/explainer-kit/SKILL.md +18 -3
- package/assets/skills/explainer-kit/recipes/project-recap.json +43 -16
- package/assets/skills/explainer-kit/references/contracts.md +167 -20
- package/assets/skills/explainer-kit/references/golden-conformance.md +80 -0
- package/assets/skills/explainer-kit/references/visual-authoring.md +92 -0
- package/assets/skills/explainer-kit/references/visual-review.md +57 -0
- package/assets/skills/explainer-kit/schemas/author-request.v2.schema.json +172 -1
- package/assets/skills/explainer-kit/schemas/build-record.schema.json +7 -1
- package/assets/skills/explainer-kit/schemas/fact-base.schema.json +38 -2
- package/assets/skills/explainer-kit/schemas/manifest.schema.json +25 -1
- package/assets/skills/explainer-kit/schemas/run-request.schema.json +4 -0
- package/assets/skills/explainer-kit/schemas/set-plan.v1.schema.json +149 -0
- package/assets/skills/explainer-kit/schemas/visual-review-request.v1.schema.json +117 -0
- package/assets/skills/explainer-kit/schemas/visual-review-result.v1.schema.json +80 -0
- package/assets/skills/explainer-kit/scripts/lib/browser-runtime.mjs +148 -4
- package/assets/skills/explainer-kit/scripts/lib/catalog.mjs +243 -0
- package/assets/skills/explainer-kit/scripts/lib/contracts.mjs +586 -8
- package/assets/skills/explainer-kit/scripts/lib/diagram.mjs +285 -8
- package/assets/skills/explainer-kit/scripts/lib/durability.mjs +35 -0
- package/assets/skills/explainer-kit/scripts/lib/fact-base.mjs +144 -8
- package/assets/skills/explainer-kit/scripts/lib/package-coverage.mjs +379 -0
- package/assets/skills/explainer-kit/scripts/lib/png.mjs +287 -0
- package/assets/skills/explainer-kit/scripts/lib/qa.mjs +280 -8
- package/assets/skills/explainer-kit/scripts/lib/recipes.mjs +132 -3
- package/assets/skills/explainer-kit/scripts/lib/records.mjs +513 -21
- package/assets/skills/explainer-kit/scripts/lib/render.mjs +67 -3
- package/assets/skills/explainer-kit/scripts/lib/s3-static.mjs +43 -1
- package/assets/skills/explainer-kit/scripts/lib/set-plan.mjs +208 -0
- package/assets/skills/explainer-kit/scripts/lib/source-backlinks.mjs +218 -0
- package/assets/skills/explainer-kit/scripts/lib/visual-review.mjs +380 -0
- package/assets/skills/explainer-kit/scripts/render-qa.mjs +48 -10
- package/assets/skills/explainer-kit/scripts/run.mjs +859 -134
- package/assets/skills/oat-explainer-kit/SKILL.md +40 -12
- package/assets/skills/oat-explainer-kit/references/author-callback.md +12 -10
- package/assets/skills/oat-explainer-kit/references/lifecycle-contract.md +40 -2
- package/assets/skills/oat-explainer-kit/references/visual-review-callback.md +72 -0
- package/assets/skills/oat-explainer-kit/scripts/bind-project-sources.mjs +167 -5
- package/assets/skills/oat-explainer-kit/scripts/finalize-tracked-run.mjs +92 -5
- package/assets/skills/oat-explainer-kit/scripts/run.mjs +324 -2
- package/dist/commands/project/archive/archive-utils.d.ts +1 -0
- package/dist/commands/project/archive/archive-utils.d.ts.map +1 -1
- package/dist/commands/project/archive/archive-utils.js +109 -42
- package/dist/commands/project/archive/explainer-package-coverage.d.ts +14 -0
- package/dist/commands/project/archive/explainer-package-coverage.d.ts.map +1 -0
- package/dist/commands/project/archive/explainer-package-coverage.js +27 -0
- package/dist/commands/project/archive/explainer-source-backlinks.d.ts +18 -0
- package/dist/commands/project/archive/explainer-source-backlinks.d.ts.map +1 -0
- package/dist/commands/project/archive/explainer-source-backlinks.js +27 -0
- package/dist/commands/project/archive/push-runner.d.ts +2 -1
- package/dist/commands/project/archive/push-runner.d.ts.map +1 -1
- package/dist/commands/project/archive/push-runner.js +5 -1
- package/dist/release/public-package-contract.d.ts +6 -0
- package/dist/release/public-package-contract.d.ts.map +1 -1
- package/dist/release/public-package-contract.js +75 -0
- package/package.json +2 -2
|
@@ -1,15 +1,26 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
|
-
import { readFile } from 'node:fs/promises';
|
|
5
|
-
import { join, resolve } from 'node:path';
|
|
4
|
+
import { lstat, readFile, realpath } from 'node:fs/promises';
|
|
5
|
+
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
6
6
|
import { pathToFileURL } from 'node:url';
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import { assertBrowserProbeSession } from './lib/browser-runtime.mjs';
|
|
9
|
+
import { catalogFromManifest, initiativeCatalogPath } from './lib/catalog.mjs';
|
|
10
|
+
import {
|
|
11
|
+
readContentApproval,
|
|
12
|
+
resolveContentApproval,
|
|
13
|
+
} from './lib/content-approval.mjs';
|
|
9
14
|
import { canonicalHash, validateContract } from './lib/contracts.mjs';
|
|
15
|
+
import {
|
|
16
|
+
assertAuthoredGraphSemantics,
|
|
17
|
+
graphSemanticsForArtisticAuthor,
|
|
18
|
+
parseDiagram,
|
|
19
|
+
} from './lib/diagram.mjs';
|
|
10
20
|
import { processFactBase } from './lib/fact-base.mjs';
|
|
11
21
|
import { writeJsonAtomic, writeTextAtomic } from './lib/fs-safe.mjs';
|
|
12
22
|
import { validateHtmlSafety } from './lib/html-safety.mjs';
|
|
23
|
+
import { parseMarkdown } from './lib/markdown.mjs';
|
|
13
24
|
import {
|
|
14
25
|
auditArtifactSet,
|
|
15
26
|
checkGuidelines,
|
|
@@ -19,32 +30,63 @@ import {
|
|
|
19
30
|
renderWarningIds,
|
|
20
31
|
} from './lib/qa.mjs';
|
|
21
32
|
import {
|
|
22
|
-
evaluateExpansionProposals,
|
|
23
33
|
loadRecipe,
|
|
24
34
|
recipeExpansion,
|
|
25
35
|
recipeFloor,
|
|
26
36
|
recipeRequiredNarrative,
|
|
37
|
+
resolveDiagramRenderingRoute,
|
|
38
|
+
selectRecipeAuthoring,
|
|
27
39
|
shouldStopDiscovery,
|
|
28
40
|
validateContentModel,
|
|
41
|
+
validatePlannedPortfolio,
|
|
29
42
|
validateSourceBindings,
|
|
30
43
|
} from './lib/recipes.mjs';
|
|
31
44
|
import {
|
|
45
|
+
canonicalPersistedRunRequest,
|
|
46
|
+
createSetPlanResumeToken,
|
|
32
47
|
initializeRun,
|
|
48
|
+
readSetPlanRecords,
|
|
33
49
|
reopenBuildStages,
|
|
34
50
|
updateBuildRecord,
|
|
51
|
+
verifySetPlanResumeToken,
|
|
35
52
|
writeManifestAtomic,
|
|
53
|
+
writeSetPlanRecords,
|
|
54
|
+
writeVisualReviewAttempt,
|
|
55
|
+
writeVisualReviewFailure,
|
|
56
|
+
writeVisualRevision,
|
|
36
57
|
} from './lib/records.mjs';
|
|
37
58
|
import { artifactPath, renderArtifact } from './lib/render.mjs';
|
|
59
|
+
import { resolveRootConfinedPath } from './lib/safe-paths.mjs';
|
|
60
|
+
import { plannedArtifacts, planExplainerSet } from './lib/set-plan.mjs';
|
|
38
61
|
import { resolveTheme } from './lib/theme.mjs';
|
|
62
|
+
import { runVisualReview } from './lib/visual-review.mjs';
|
|
63
|
+
|
|
64
|
+
export {
|
|
65
|
+
assertBrowserProbeSession,
|
|
66
|
+
createBrowserProbeSession,
|
|
67
|
+
createFixtureBrowserProbeSession,
|
|
68
|
+
} from './lib/browser-runtime.mjs';
|
|
39
69
|
|
|
40
70
|
// Stages a rejected draft reruns once its content is corrected.
|
|
41
71
|
const REOPENED_ON_REJECTION = Object.freeze(['render', 'qa']);
|
|
42
72
|
|
|
43
73
|
export async function runExplainer(request, options = {}) {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
|
|
74
|
+
const normalizedRequest = normalizeRunRequest(request);
|
|
75
|
+
assertValidRequest(normalizedRequest);
|
|
76
|
+
const recipe = selectRecipeAuthoring(
|
|
77
|
+
loadRecipe(normalizedRequest.recipe.id, normalizedRequest.recipe.version),
|
|
78
|
+
normalizedRequest.recapMode,
|
|
79
|
+
);
|
|
80
|
+
const browserProvider = resolveBrowserProvider(
|
|
81
|
+
normalizedRequest,
|
|
82
|
+
recipe,
|
|
83
|
+
options,
|
|
84
|
+
);
|
|
85
|
+
const resumed = await loadResumableRun(
|
|
86
|
+
normalizedRequest,
|
|
87
|
+
options.reviewedSource?.resumeToken,
|
|
88
|
+
);
|
|
89
|
+
const run = resumed ?? (await initializeRun(normalizedRequest));
|
|
48
90
|
const now = options.now ?? (() => new Date().toISOString());
|
|
49
91
|
const state = {
|
|
50
92
|
run,
|
|
@@ -52,6 +94,8 @@ export async function runExplainer(request, options = {}) {
|
|
|
52
94
|
factBase: null,
|
|
53
95
|
factBaseHash: null,
|
|
54
96
|
inputHashes: {},
|
|
97
|
+
setPlan: null,
|
|
98
|
+
setPlanPaths: [],
|
|
55
99
|
contentModels: [],
|
|
56
100
|
contentPaths: new Map(),
|
|
57
101
|
authorResultPaths: [],
|
|
@@ -74,6 +118,13 @@ export async function runExplainer(request, options = {}) {
|
|
|
74
118
|
reopenedWarnings: {},
|
|
75
119
|
discovery: { rounds: 0, findings: [], reason: 'not-requested' },
|
|
76
120
|
approval: null,
|
|
121
|
+
browserProvider,
|
|
122
|
+
browserEvidence: [],
|
|
123
|
+
visualReview: null,
|
|
124
|
+
visualReviewPaths: [],
|
|
125
|
+
visualReviewAttempt: 0,
|
|
126
|
+
reviewGateBlocked: false,
|
|
127
|
+
resumeToken: null,
|
|
77
128
|
resumedApprovalStatus: null,
|
|
78
129
|
};
|
|
79
130
|
|
|
@@ -114,9 +165,28 @@ export async function runExplainer(request, options = {}) {
|
|
|
114
165
|
await prepareTheme(state);
|
|
115
166
|
await executeStage(run, 'content', options, async () => {
|
|
116
167
|
state.discovery = await runDiscovery(recipe, state.factBase, options);
|
|
168
|
+
const planned = await planExplainerSet({
|
|
169
|
+
recipe,
|
|
170
|
+
factBase: state.factBase,
|
|
171
|
+
discovery: state.discovery,
|
|
172
|
+
planSet: options.planSet,
|
|
173
|
+
});
|
|
174
|
+
state.setPlan = planned.plan;
|
|
175
|
+
const portfolioValidation = validatePlannedPortfolio(
|
|
176
|
+
recipe,
|
|
177
|
+
state.setPlan.portfolio,
|
|
178
|
+
);
|
|
179
|
+
if (!portfolioValidation.valid) {
|
|
180
|
+
throw codedError(
|
|
181
|
+
'E_SET_PLAN',
|
|
182
|
+
`Invalid planned portfolio: ${portfolioValidation.errors.join('; ')}`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
state.setPlanPaths = await writeSetPlanRecords(run, planned);
|
|
117
186
|
await createAuthoredContent(state, options, now);
|
|
118
187
|
return {
|
|
119
188
|
outputPaths: [
|
|
189
|
+
...state.setPlanPaths,
|
|
120
190
|
...state.contentPaths.values(),
|
|
121
191
|
...state.authorResultPaths,
|
|
122
192
|
],
|
|
@@ -145,7 +215,7 @@ export async function runExplainer(request, options = {}) {
|
|
|
145
215
|
);
|
|
146
216
|
}
|
|
147
217
|
await executeRenderStage(state, options);
|
|
148
|
-
await executeQaStage(state, options);
|
|
218
|
+
await executeQaStage(state, options, now);
|
|
149
219
|
}
|
|
150
220
|
|
|
151
221
|
state.approval = await resolveContentApproval(
|
|
@@ -156,11 +226,27 @@ export async function runExplainer(request, options = {}) {
|
|
|
156
226
|
approvalArtifacts(state),
|
|
157
227
|
);
|
|
158
228
|
if (!state.approval.canResume) {
|
|
229
|
+
if (run.request.mode === 'interactive') {
|
|
230
|
+
state.resumeToken = await createSetPlanResumeToken(run);
|
|
231
|
+
}
|
|
159
232
|
return resultFor(state);
|
|
160
233
|
}
|
|
161
234
|
|
|
162
|
-
|
|
163
|
-
|
|
235
|
+
if (state.reviewGateBlocked) {
|
|
236
|
+
await updateBuildRecord(run, { id: 'durability', status: 'skipped' });
|
|
237
|
+
await updateBuildRecord(run, { id: 'publish', status: 'skipped' });
|
|
238
|
+
await persistManifest(state, now());
|
|
239
|
+
return resultFor(state);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const manifestFinalized = await executeDurabilityAndPublish(
|
|
243
|
+
state,
|
|
244
|
+
options,
|
|
245
|
+
now,
|
|
246
|
+
);
|
|
247
|
+
if (!manifestFinalized) {
|
|
248
|
+
await persistManifest(state, now());
|
|
249
|
+
}
|
|
164
250
|
return resultFor(state);
|
|
165
251
|
} catch (error) {
|
|
166
252
|
if (state.theme && state.factBase) {
|
|
@@ -205,6 +291,7 @@ async function executeRenderStage(state, options) {
|
|
|
205
291
|
content: state.contentModels.find(
|
|
206
292
|
({ artifactId }) => artifactId === artifact.id,
|
|
207
293
|
),
|
|
294
|
+
factBase: state.factBase,
|
|
208
295
|
theme: state.theme,
|
|
209
296
|
renderStrategy: state.renderStrategy,
|
|
210
297
|
...(state.run.request.publicBaseUrl && {
|
|
@@ -237,37 +324,110 @@ async function executeRenderStage(state, options) {
|
|
|
237
324
|
});
|
|
238
325
|
}
|
|
239
326
|
|
|
240
|
-
async function executeQaStage(state, options) {
|
|
327
|
+
async function executeQaStage(state, options, now) {
|
|
241
328
|
await executeStage(state.run, 'qa', options, async () => {
|
|
242
329
|
const htmlSafetyErrors = [];
|
|
243
330
|
const qaWarnings = [];
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
331
|
+
try {
|
|
332
|
+
return await auditRenderedArtifacts(state, options, now, {
|
|
333
|
+
browserProvider: state.browserProvider,
|
|
334
|
+
htmlSafetyErrors,
|
|
335
|
+
qaWarnings,
|
|
336
|
+
});
|
|
337
|
+
} catch (error) {
|
|
338
|
+
const reviewError = normalizeReviewGateError(state, error);
|
|
339
|
+
if (!reviewError) {
|
|
340
|
+
throw error;
|
|
341
|
+
}
|
|
342
|
+
const warning = reviewGateWarning(reviewError);
|
|
343
|
+
state.reviewGateBlocked = true;
|
|
344
|
+
state.warnings.push(warning);
|
|
345
|
+
state.visualReviewPaths.push(
|
|
346
|
+
...(await writeVisualReviewFailure(state.run, {
|
|
347
|
+
attempt: state.visualReviewAttempt || 1,
|
|
348
|
+
error: reviewError,
|
|
349
|
+
evidence: state.browserEvidence,
|
|
350
|
+
})),
|
|
351
|
+
);
|
|
352
|
+
return {
|
|
353
|
+
outputPaths: [
|
|
354
|
+
...state.rendered.map(({ renderedPath }) => renderedPath),
|
|
355
|
+
...state.visualReviewPaths,
|
|
356
|
+
],
|
|
357
|
+
warnings: [warning],
|
|
358
|
+
status: 'warned',
|
|
359
|
+
};
|
|
360
|
+
}
|
|
249
361
|
});
|
|
250
362
|
}
|
|
251
363
|
|
|
252
364
|
/**
|
|
253
|
-
* Render QA drives a caller-supplied
|
|
254
|
-
* headless runtime itself
|
|
365
|
+
* Render QA drives a caller-supplied session only. The core never launches a
|
|
366
|
+
* headless runtime itself. Production recap evidence requires a session whose
|
|
367
|
+
* identity was derived and branded by createBrowserProbeSession().
|
|
255
368
|
*/
|
|
256
|
-
function
|
|
369
|
+
function resolveBrowserProvider(request, recipe, options) {
|
|
370
|
+
if (
|
|
371
|
+
options.browserSession !== undefined &&
|
|
372
|
+
options.browserProbe !== undefined
|
|
373
|
+
) {
|
|
374
|
+
throw codedError(
|
|
375
|
+
'E_BROWSER_PROBE',
|
|
376
|
+
'Supply either options.browserSession or the legacy non-retaining options.browserProbe callback, not both.',
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
const productionRecap =
|
|
380
|
+
recipe.id === 'project-recap' && request.mode === 'unattended';
|
|
381
|
+
if (options.browserSession !== undefined) {
|
|
382
|
+
try {
|
|
383
|
+
return {
|
|
384
|
+
session: assertBrowserProbeSession(options.browserSession, {
|
|
385
|
+
allowFixture: true,
|
|
386
|
+
}),
|
|
387
|
+
probe: null,
|
|
388
|
+
productionRecap,
|
|
389
|
+
};
|
|
390
|
+
} catch (error) {
|
|
391
|
+
throw codedError(
|
|
392
|
+
'E_BROWSER_PROBE',
|
|
393
|
+
error?.message ?? 'Browser session validation failed.',
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
257
397
|
if (options.browserProbe === undefined) return null;
|
|
398
|
+
if (productionRecap) {
|
|
399
|
+
throw codedError(
|
|
400
|
+
'E_BROWSER_PROBE',
|
|
401
|
+
'Unattended project recaps require a trusted launched-Chromium browserSession; bare callbacks and caller-asserted runtime metadata are not accepted.',
|
|
402
|
+
);
|
|
403
|
+
}
|
|
258
404
|
if (typeof options.browserProbe !== 'function') {
|
|
259
405
|
throw codedError(
|
|
260
406
|
'E_BROWSER_PROBE',
|
|
261
407
|
'options.browserProbe must be a function when supplied.',
|
|
262
408
|
);
|
|
263
409
|
}
|
|
264
|
-
return
|
|
410
|
+
return {
|
|
411
|
+
session: null,
|
|
412
|
+
probe: async (...args) => {
|
|
413
|
+
try {
|
|
414
|
+
return await options.browserProbe(...args);
|
|
415
|
+
} catch (error) {
|
|
416
|
+
throw codedError(
|
|
417
|
+
'E_VISUAL_REVIEW',
|
|
418
|
+
`Browser evidence callback failed: ${error?.message ?? String(error)}`,
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
},
|
|
422
|
+
productionRecap,
|
|
423
|
+
};
|
|
265
424
|
}
|
|
266
425
|
|
|
267
426
|
async function auditRenderedArtifacts(
|
|
268
427
|
state,
|
|
269
428
|
options,
|
|
270
|
-
|
|
429
|
+
now,
|
|
430
|
+
{ browserProvider, htmlSafetyErrors, qaWarnings },
|
|
271
431
|
) {
|
|
272
432
|
for (const artifact of state.resolvedArtifacts.filter(
|
|
273
433
|
({ authoring }) => authoring === 'html',
|
|
@@ -293,9 +453,19 @@ async function auditRenderedArtifacts(
|
|
|
293
453
|
}));
|
|
294
454
|
const report = await auditArtifactSet({
|
|
295
455
|
artifacts: probeArtifacts,
|
|
456
|
+
setPlan: state.setPlan,
|
|
296
457
|
...(options.denylist && { denylist: options.denylist }),
|
|
297
|
-
...(
|
|
298
|
-
|
|
458
|
+
...(browserProvider?.session && {
|
|
459
|
+
browserSession: browserProvider.session,
|
|
460
|
+
}),
|
|
461
|
+
...(browserProvider?.probe && { browserProbe: browserProvider.probe }),
|
|
462
|
+
...(options.widths &&
|
|
463
|
+
!requiresRecapVisualReview(state) && { widths: options.widths }),
|
|
464
|
+
...(browserProvider &&
|
|
465
|
+
requiresRecapVisualReview(state) && {
|
|
466
|
+
evidenceRoot: state.run.runRoot,
|
|
467
|
+
requireBrowserEvidence: true,
|
|
468
|
+
}),
|
|
299
469
|
});
|
|
300
470
|
const hardIssues = report.issues.filter((issue) => isHardQaIssue(issue.code));
|
|
301
471
|
const warningIssues = report.issues.filter(
|
|
@@ -309,7 +479,7 @@ async function auditRenderedArtifacts(
|
|
|
309
479
|
.filter(({ code }) => renderQaWarningIds([{ code }]).length === 0)
|
|
310
480
|
.map(({ code }) => `qa-${code}`),
|
|
311
481
|
);
|
|
312
|
-
if (!
|
|
482
|
+
if (!browserProvider) {
|
|
313
483
|
qaWarnings.push(RENDER_QA_WARNING_IDS.skippedNoProbe);
|
|
314
484
|
}
|
|
315
485
|
const guidelines = checkGuidelines({
|
|
@@ -326,18 +496,264 @@ async function auditRenderedArtifacts(
|
|
|
326
496
|
errors.map(({ code, message }) => `${code}: ${message}`).join('; '),
|
|
327
497
|
);
|
|
328
498
|
}
|
|
499
|
+
state.browserEvidence = report.browser?.evidence ?? [];
|
|
500
|
+
const visualCritic = resolveVisualCritic(options);
|
|
501
|
+
const reviewRequired = requiresRecapVisualReview(state);
|
|
502
|
+
const fixtureBrowserSession =
|
|
503
|
+
browserProvider?.session?.runtime.kind === 'fixture';
|
|
504
|
+
if (reviewRequired && !browserProvider) {
|
|
505
|
+
qaWarnings.push('visual-review-required:browser-probe-missing');
|
|
506
|
+
} else if (reviewRequired && !visualCritic) {
|
|
507
|
+
qaWarnings.push('visual-review-required:visual-critic-missing');
|
|
508
|
+
} else if (visualCritic) {
|
|
509
|
+
await reviewAndRetain(state, visualCritic, 1);
|
|
510
|
+
if (state.visualReview.result.disposition === 'correct') {
|
|
511
|
+
await applyVisualCorrection(state, options, now);
|
|
512
|
+
const correctedArtifacts = state.rendered.map((artifact) => ({
|
|
513
|
+
id: artifact.artifactId,
|
|
514
|
+
type: artifact.type,
|
|
515
|
+
html: artifact.html,
|
|
516
|
+
}));
|
|
517
|
+
const finalReport = await auditArtifactSet({
|
|
518
|
+
artifacts: correctedArtifacts,
|
|
519
|
+
setPlan: state.setPlan,
|
|
520
|
+
...(options.denylist && { denylist: options.denylist }),
|
|
521
|
+
...(browserProvider?.session && {
|
|
522
|
+
browserSession: browserProvider.session,
|
|
523
|
+
}),
|
|
524
|
+
...(browserProvider?.probe && { browserProbe: browserProvider.probe }),
|
|
525
|
+
...(options.widths &&
|
|
526
|
+
!requiresRecapVisualReview(state) && { widths: options.widths }),
|
|
527
|
+
...(browserProvider &&
|
|
528
|
+
requiresRecapVisualReview(state) && {
|
|
529
|
+
evidenceRoot: state.run.runRoot,
|
|
530
|
+
requireBrowserEvidence: true,
|
|
531
|
+
}),
|
|
532
|
+
});
|
|
533
|
+
const finalHardIssues = finalReport.issues.filter((issue) =>
|
|
534
|
+
isHardQaIssue(issue.code),
|
|
535
|
+
);
|
|
536
|
+
if (finalHardIssues.length > 0) {
|
|
537
|
+
throw codedError(
|
|
538
|
+
'E_VISUAL_CORRECTION',
|
|
539
|
+
finalHardIssues
|
|
540
|
+
.map(({ code, message }) => `${code}: ${message}`)
|
|
541
|
+
.join('; '),
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
const finalWarnings = finalReport.issues.filter(
|
|
545
|
+
(issue) => !isHardQaIssue(issue.code),
|
|
546
|
+
);
|
|
547
|
+
qaWarnings.push(
|
|
548
|
+
...renderQaWarningIds(finalWarnings),
|
|
549
|
+
...finalWarnings
|
|
550
|
+
.filter(({ code }) => renderQaWarningIds([{ code }]).length === 0)
|
|
551
|
+
.map(({ code }) => `qa-${code}`),
|
|
552
|
+
);
|
|
553
|
+
state.browserEvidence = finalReport.browser?.evidence ?? [];
|
|
554
|
+
await reviewAndRetain(state, visualCritic, 2);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
if (reviewRequired && fixtureBrowserSession) {
|
|
558
|
+
qaWarnings.push('visual-review-required:fixture-browser-session');
|
|
559
|
+
}
|
|
560
|
+
if (
|
|
561
|
+
reviewRequired &&
|
|
562
|
+
state.visualReview &&
|
|
563
|
+
state.visualReview.result.disposition !== 'pass'
|
|
564
|
+
) {
|
|
565
|
+
qaWarnings.push(
|
|
566
|
+
state.visualReview.result.disposition === 'fail'
|
|
567
|
+
? 'visual-review-required:critic-failed'
|
|
568
|
+
: 'visual-review-required:correction-unresolved',
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
state.reviewGateBlocked = qaWarnings.some((warning) =>
|
|
572
|
+
warning.startsWith('visual-review-required:'),
|
|
573
|
+
);
|
|
329
574
|
state.warnings.push(...qaWarnings);
|
|
330
575
|
const warnings = [
|
|
331
576
|
...(state.reopenedWarnings.qa ?? []),
|
|
332
577
|
...new Set(qaWarnings),
|
|
333
578
|
];
|
|
334
579
|
return {
|
|
335
|
-
outputPaths:
|
|
580
|
+
outputPaths: [
|
|
581
|
+
...state.rendered.map(({ renderedPath }) => renderedPath),
|
|
582
|
+
...state.visualReviewPaths,
|
|
583
|
+
],
|
|
336
584
|
warnings,
|
|
337
585
|
status: warnings.length > 0 ? 'warned' : 'passed',
|
|
338
586
|
};
|
|
339
587
|
}
|
|
340
588
|
|
|
589
|
+
function resolveVisualCritic(options) {
|
|
590
|
+
if (options.visualCritic === undefined) return null;
|
|
591
|
+
if (typeof options.visualCritic !== 'function') {
|
|
592
|
+
throw codedError(
|
|
593
|
+
'E_VISUAL_REVIEW',
|
|
594
|
+
'options.visualCritic must be a function when supplied.',
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
if (
|
|
598
|
+
options.visualCritic === options.author ||
|
|
599
|
+
options.visualCritic === options.critic
|
|
600
|
+
) {
|
|
601
|
+
throw codedError(
|
|
602
|
+
'E_VISUAL_REVIEW',
|
|
603
|
+
'The visual critic must be distinct from the artifact author and fact critic.',
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
return options.visualCritic;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function requiresRecapVisualReview(state) {
|
|
610
|
+
return (
|
|
611
|
+
state.recipe.id === 'project-recap' &&
|
|
612
|
+
state.run.request.mode === 'unattended'
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
async function reviewAndRetain(state, visualCritic, attempt) {
|
|
617
|
+
state.visualReviewAttempt = attempt;
|
|
618
|
+
try {
|
|
619
|
+
state.visualReview = await runVisualReview({
|
|
620
|
+
plan: state.setPlan,
|
|
621
|
+
rendered: state.rendered,
|
|
622
|
+
evidence: state.browserEvidence,
|
|
623
|
+
visualCritic,
|
|
624
|
+
runRoot: state.run.runRoot,
|
|
625
|
+
});
|
|
626
|
+
state.visualReviewPaths.push(
|
|
627
|
+
...(await writeVisualReviewAttempt(state.run, {
|
|
628
|
+
attempt,
|
|
629
|
+
review: state.visualReview,
|
|
630
|
+
})),
|
|
631
|
+
);
|
|
632
|
+
} catch (error) {
|
|
633
|
+
throw codedError('E_VISUAL_REVIEW', error?.message ?? String(error));
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
async function applyVisualCorrection(state, options, now) {
|
|
638
|
+
const findings = state.visualReview.result.findings;
|
|
639
|
+
const artifactIds = [
|
|
640
|
+
...new Set(findings.map(({ artifactId }) => artifactId)),
|
|
641
|
+
];
|
|
642
|
+
const correctionAuthor = options.correctArtifact ?? options.author;
|
|
643
|
+
if (typeof correctionAuthor !== 'function') {
|
|
644
|
+
throw codedError(
|
|
645
|
+
'E_VISUAL_CORRECTION',
|
|
646
|
+
'A correct disposition requires an artifact correction callback.',
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
const trust = authorTrustContext(options, now);
|
|
650
|
+
const changes = [];
|
|
651
|
+
for (const artifactId of artifactIds) {
|
|
652
|
+
const artifactIndex = state.resolvedArtifacts.findIndex(
|
|
653
|
+
({ id }) => id === artifactId,
|
|
654
|
+
);
|
|
655
|
+
if (artifactIndex < 0) {
|
|
656
|
+
throw codedError(
|
|
657
|
+
'E_VISUAL_CORRECTION',
|
|
658
|
+
`Visual correction references unknown artifact ${artifactId}.`,
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
const artifact = state.resolvedArtifacts[artifactIndex];
|
|
662
|
+
const previousContent = state.authoredContent.get(artifactId);
|
|
663
|
+
let item;
|
|
664
|
+
try {
|
|
665
|
+
item = await authorArtifact(state, artifact, correctionAuthor, trust, {
|
|
666
|
+
attempt: 1,
|
|
667
|
+
findings: structuredClone(
|
|
668
|
+
findings.filter((finding) => finding.artifactId === artifactId),
|
|
669
|
+
),
|
|
670
|
+
previousContentPath: state.contentPaths.get(artifactId),
|
|
671
|
+
});
|
|
672
|
+
} catch (error) {
|
|
673
|
+
throw codedError(
|
|
674
|
+
'E_VISUAL_CORRECTION',
|
|
675
|
+
`Visual correction callback failed for ${artifactId}: ${error?.message ?? String(error)}`,
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
if ((item.result.proposedArtifacts ?? []).length > 0) {
|
|
679
|
+
throw codedError(
|
|
680
|
+
'E_VISUAL_CORRECTION',
|
|
681
|
+
'Visual correction cannot change the validated artifact portfolio.',
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
if (item.artifact.authoring === 'html') {
|
|
685
|
+
const safety = validateHtmlSafety({
|
|
686
|
+
html: item.content,
|
|
687
|
+
shell: item.artifact.shellContent,
|
|
688
|
+
shellName: item.artifact.shell ?? item.artifact.template,
|
|
689
|
+
});
|
|
690
|
+
if (!safety.valid) {
|
|
691
|
+
throw codedError(
|
|
692
|
+
'E_QA',
|
|
693
|
+
`Corrected artifact ${artifactId} failed DOM safety validation.`,
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
await writeJsonAtomic(state.run.runRoot, item.resultPath, item.result);
|
|
698
|
+
await writeTextAtomic(state.run.runRoot, item.contentPath, item.content);
|
|
699
|
+
state.resolvedArtifacts[artifactIndex] = item.artifact;
|
|
700
|
+
state.authoredContent.set(artifactId, item.content);
|
|
701
|
+
state.contentPaths.set(artifactId, item.contentPath);
|
|
702
|
+
if (item.artifact.authoring === 'markdown') {
|
|
703
|
+
const links = expansionLinks(state.resolvedArtifacts);
|
|
704
|
+
const model = assertValidContentModel(
|
|
705
|
+
state.recipe,
|
|
706
|
+
markdownContentModel(
|
|
707
|
+
item.artifact,
|
|
708
|
+
state.run.slug,
|
|
709
|
+
item.content,
|
|
710
|
+
item.artifact.origin === 'floor' ? links : [],
|
|
711
|
+
),
|
|
712
|
+
item.artifact,
|
|
713
|
+
);
|
|
714
|
+
const modelIndex = state.contentModels.findIndex(
|
|
715
|
+
({ artifactId: id }) => id === artifactId,
|
|
716
|
+
);
|
|
717
|
+
state.contentModels[modelIndex] = model;
|
|
718
|
+
}
|
|
719
|
+
const rendered =
|
|
720
|
+
item.artifact.authoring === 'markdown'
|
|
721
|
+
? await renderArtifact({
|
|
722
|
+
recipeArtifact: renderDescriptor(item.artifact),
|
|
723
|
+
content: state.contentModels.find(
|
|
724
|
+
({ artifactId: id }) => id === artifactId,
|
|
725
|
+
),
|
|
726
|
+
factBase: state.factBase,
|
|
727
|
+
theme: state.theme,
|
|
728
|
+
renderStrategy: state.renderStrategy,
|
|
729
|
+
...(state.run.request.publicBaseUrl && {
|
|
730
|
+
publicBaseUrl: state.run.request.publicBaseUrl,
|
|
731
|
+
}),
|
|
732
|
+
})
|
|
733
|
+
: artisticRender(state, item.artifact);
|
|
734
|
+
await writeTextAtomic(
|
|
735
|
+
state.run.runRoot,
|
|
736
|
+
rendered.renderedPath,
|
|
737
|
+
rendered.html,
|
|
738
|
+
);
|
|
739
|
+
const renderedIndex = state.rendered.findIndex(
|
|
740
|
+
({ artifactId: id }) => id === artifactId,
|
|
741
|
+
);
|
|
742
|
+
state.rendered[renderedIndex] = rendered;
|
|
743
|
+
state.artifacts[renderedIndex] = artifactRecord(state, rendered);
|
|
744
|
+
changes.push({
|
|
745
|
+
artifactId,
|
|
746
|
+
contentPath: item.contentPath,
|
|
747
|
+
authorResultPath: item.resultPath,
|
|
748
|
+
previousHash: hashBytes(previousContent),
|
|
749
|
+
revisedHash: hashBytes(item.content),
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
state.visualReviewPaths.push(
|
|
753
|
+
...(await writeVisualRevision(state.run, { artifactIds, changes })),
|
|
754
|
+
);
|
|
755
|
+
}
|
|
756
|
+
|
|
341
757
|
function isHardQaIssue(code) {
|
|
342
758
|
return (
|
|
343
759
|
[
|
|
@@ -350,6 +766,27 @@ function isHardQaIssue(code) {
|
|
|
350
766
|
);
|
|
351
767
|
}
|
|
352
768
|
|
|
769
|
+
function isReviewGateError(error) {
|
|
770
|
+
return ['E_VISUAL_REVIEW', 'E_VISUAL_CORRECTION'].includes(error?.code);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function normalizeReviewGateError(state, error) {
|
|
774
|
+
if (!requiresRecapVisualReview(state)) return null;
|
|
775
|
+
if (isReviewGateError(error)) return error;
|
|
776
|
+
if (/^Browser (?:layout |theme |deck )?probe\b/.test(error?.message ?? '')) {
|
|
777
|
+
return codedError('E_VISUAL_REVIEW', error.message);
|
|
778
|
+
}
|
|
779
|
+
return null;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function reviewGateWarning(error) {
|
|
783
|
+
const reason =
|
|
784
|
+
error?.code === 'E_VISUAL_CORRECTION'
|
|
785
|
+
? 'correction-failed'
|
|
786
|
+
: 'review-chain-failed';
|
|
787
|
+
return `visual-review-required:${reason}:${String(error?.message ?? 'unknown visual review failure')}`;
|
|
788
|
+
}
|
|
789
|
+
|
|
353
790
|
function artisticRender(state, artifact) {
|
|
354
791
|
const renderedPath = artifactPath(renderDescriptor(artifact), state.run.slug);
|
|
355
792
|
const publicBaseUrl = state.run.request.publicBaseUrl?.replace(/\/+$/g, '');
|
|
@@ -366,27 +803,67 @@ function artisticRender(state, artifact) {
|
|
|
366
803
|
};
|
|
367
804
|
}
|
|
368
805
|
|
|
369
|
-
|
|
806
|
+
function normalizeRunRequest(request) {
|
|
370
807
|
const normalized = structuredClone(request);
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
808
|
+
if (
|
|
809
|
+
normalized.recipe?.id === 'project-recap' &&
|
|
810
|
+
normalized.recapMode === undefined
|
|
811
|
+
) {
|
|
812
|
+
normalized.recapMode = 'artistic';
|
|
813
|
+
}
|
|
814
|
+
return normalized;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
async function loadResumableRun(request, resumeToken) {
|
|
818
|
+
const normalized = structuredClone(request);
|
|
819
|
+
let canonicalOutputRoot;
|
|
820
|
+
try {
|
|
821
|
+
canonicalOutputRoot = await realpath(resolve(normalized.outputRoot));
|
|
822
|
+
} catch (error) {
|
|
823
|
+
if (error?.code === 'ENOENT') return null;
|
|
824
|
+
throw error;
|
|
825
|
+
}
|
|
826
|
+
const runRoot = join(canonicalOutputRoot, normalized.slug);
|
|
827
|
+
let runRootStats;
|
|
828
|
+
try {
|
|
829
|
+
runRootStats = await lstat(runRoot);
|
|
830
|
+
} catch (error) {
|
|
831
|
+
if (error?.code === 'ENOENT') return null;
|
|
832
|
+
throw error;
|
|
833
|
+
}
|
|
834
|
+
if (runRootStats.isSymbolicLink() || !runRootStats.isDirectory()) {
|
|
835
|
+
throw codedError(
|
|
836
|
+
'E_APPROVAL_RESUME',
|
|
837
|
+
'The resumable run root must be a real directory, not a symbolic link.',
|
|
838
|
+
);
|
|
839
|
+
}
|
|
840
|
+
const canonicalRunRoot = await realpath(runRoot);
|
|
841
|
+
if (
|
|
842
|
+
canonicalRunRoot !== runRoot ||
|
|
843
|
+
!isStrictDescendant(canonicalOutputRoot, canonicalRunRoot)
|
|
844
|
+
) {
|
|
845
|
+
throw codedError(
|
|
846
|
+
'E_APPROVAL_RESUME',
|
|
847
|
+
'The resumable run root escapes the configured output root.',
|
|
848
|
+
);
|
|
849
|
+
}
|
|
376
850
|
let approval;
|
|
377
851
|
let record;
|
|
378
|
-
let persistedRequest;
|
|
379
852
|
try {
|
|
380
|
-
[approval, record
|
|
853
|
+
[approval, record] = await Promise.all([
|
|
381
854
|
readJson(join(runRoot, 'source/content-approval.json')),
|
|
382
855
|
readJson(join(runRoot, 'build-record.json')),
|
|
383
|
-
readJson(join(runRoot, 'run-request.json')),
|
|
384
856
|
]);
|
|
385
857
|
} catch (error) {
|
|
386
858
|
if (error?.code === 'ENOENT') return null;
|
|
387
859
|
throw error;
|
|
388
860
|
}
|
|
389
|
-
|
|
861
|
+
if (typeof record.runId !== 'string') {
|
|
862
|
+
throw codedError(
|
|
863
|
+
'E_APPROVAL_RESUME',
|
|
864
|
+
'The resumable run has no valid retained run identity.',
|
|
865
|
+
);
|
|
866
|
+
}
|
|
390
867
|
const approvalUnresolved = ['pending', 'rejected'].includes(approval.status);
|
|
391
868
|
const completedBeforeApproval = ['content', 'theme', 'render', 'qa'].every(
|
|
392
869
|
(id) =>
|
|
@@ -401,41 +878,112 @@ async function loadResumableRun(request) {
|
|
|
401
878
|
) {
|
|
402
879
|
return null;
|
|
403
880
|
}
|
|
881
|
+
const resumableRun = {
|
|
882
|
+
runId: record.runId,
|
|
883
|
+
slug: normalized.slug,
|
|
884
|
+
outputRoot: canonicalOutputRoot,
|
|
885
|
+
runRoot: canonicalRunRoot,
|
|
886
|
+
requestPath: join(canonicalRunRoot, 'run-request.json'),
|
|
887
|
+
buildRecordPath: join(canonicalRunRoot, 'build-record.json'),
|
|
888
|
+
manifestPath: join(canonicalRunRoot, 'manifest.json'),
|
|
889
|
+
};
|
|
890
|
+
await verifySetPlanResumeToken(resumableRun, resumeToken);
|
|
891
|
+
|
|
892
|
+
let persistedRequest;
|
|
893
|
+
try {
|
|
894
|
+
persistedRequest = await readJson(resumableRun.requestPath);
|
|
895
|
+
} catch (error) {
|
|
896
|
+
if (error?.code === 'ENOENT') return null;
|
|
897
|
+
throw error;
|
|
898
|
+
}
|
|
899
|
+
if (
|
|
900
|
+
typeof persistedRequest.outputRoot !== 'string' ||
|
|
901
|
+
(isAbsolute(persistedRequest.outputRoot) &&
|
|
902
|
+
persistedRequest.outputRoot !== canonicalOutputRoot)
|
|
903
|
+
) {
|
|
904
|
+
throw codedError(
|
|
905
|
+
'E_APPROVAL_RESUME',
|
|
906
|
+
'The resumable run does not match the original canonical output root.',
|
|
907
|
+
);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
const currentPersistedRequest = canonicalPersistedRunRequest(normalized, {
|
|
911
|
+
outputRoot: canonicalOutputRoot,
|
|
912
|
+
});
|
|
404
913
|
if (
|
|
405
|
-
persistedRequest
|
|
406
|
-
persistedRequest.recipe?.id !== normalized.recipe.id ||
|
|
407
|
-
persistedRequest.recipe?.version !== normalized.recipe.version ||
|
|
408
|
-
persistedRequest.mode !== normalized.mode ||
|
|
409
|
-
canonicalHash(persistedRequest.factBase) !==
|
|
410
|
-
canonicalHash(normalized.factBase)
|
|
914
|
+
canonicalHash(persistedRequest) !== canonicalHash(currentPersistedRequest)
|
|
411
915
|
) {
|
|
412
916
|
throw codedError(
|
|
413
917
|
'E_APPROVAL_RESUME',
|
|
414
|
-
'The resumable run does not match the
|
|
918
|
+
'The resumable run does not match the complete canonical request.',
|
|
415
919
|
);
|
|
416
920
|
}
|
|
417
921
|
|
|
418
|
-
const
|
|
922
|
+
const resumedRequest = structuredClone(currentPersistedRequest);
|
|
923
|
+
if (
|
|
924
|
+
normalized.theme?.artDirection !== undefined &&
|
|
925
|
+
resumedRequest.theme.artDirection === undefined
|
|
926
|
+
) {
|
|
927
|
+
resumedRequest.theme.artDirection = normalized.theme.artDirection;
|
|
928
|
+
}
|
|
419
929
|
return {
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
outputRoot: persistedRequest.outputRoot,
|
|
423
|
-
runRoot: canonicalRunRoot,
|
|
424
|
-
requestPath: join(canonicalRunRoot, 'run-request.json'),
|
|
425
|
-
buildRecordPath: join(canonicalRunRoot, 'build-record.json'),
|
|
426
|
-
manifestPath: join(canonicalRunRoot, 'manifest.json'),
|
|
427
|
-
request: normalized,
|
|
930
|
+
...resumableRun,
|
|
931
|
+
request: resumedRequest,
|
|
428
932
|
};
|
|
429
933
|
}
|
|
430
934
|
|
|
935
|
+
function isStrictDescendant(root, candidate) {
|
|
936
|
+
const pathFromRoot = relative(root, candidate);
|
|
937
|
+
return (
|
|
938
|
+
pathFromRoot.length > 0 &&
|
|
939
|
+
pathFromRoot !== '..' &&
|
|
940
|
+
!pathFromRoot.startsWith(`..${sep}`) &&
|
|
941
|
+
!isAbsolute(pathFromRoot)
|
|
942
|
+
);
|
|
943
|
+
}
|
|
944
|
+
|
|
431
945
|
async function hydrateResumableState(state) {
|
|
432
946
|
const [factBase, approval, theme, record] = await Promise.all([
|
|
433
947
|
readJson(join(state.run.runRoot, 'source/fact-base.json')),
|
|
434
|
-
|
|
948
|
+
readContentApproval(state.run).catch((error) => {
|
|
949
|
+
throw codedError(
|
|
950
|
+
'E_APPROVAL_RESUME',
|
|
951
|
+
`Retained content approval is invalid: ${error.message}`,
|
|
952
|
+
);
|
|
953
|
+
}),
|
|
435
954
|
readJson(join(state.run.runRoot, 'theme.resolved.json')),
|
|
436
955
|
readJson(state.run.buildRecordPath),
|
|
437
956
|
]);
|
|
957
|
+
for (const [kind, value] of [
|
|
958
|
+
['fact-base', factBase],
|
|
959
|
+
['theme', theme],
|
|
960
|
+
['build-record', record],
|
|
961
|
+
]) {
|
|
962
|
+
const validation = validateContract(kind, value);
|
|
963
|
+
if (!validation.valid) {
|
|
964
|
+
throw codedError(
|
|
965
|
+
'E_APPROVAL_RESUME',
|
|
966
|
+
`Retained ${kind} record is invalid during approval resume.`,
|
|
967
|
+
);
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
const retainedPlan = await readSetPlanRecords(state.run, {
|
|
971
|
+
factBase,
|
|
972
|
+
recipe: state.recipe,
|
|
973
|
+
});
|
|
974
|
+
const portfolioValidation = validatePlannedPortfolio(
|
|
975
|
+
state.recipe,
|
|
976
|
+
retainedPlan.plan.portfolio,
|
|
977
|
+
);
|
|
978
|
+
if (!portfolioValidation.valid) {
|
|
979
|
+
throw codedError(
|
|
980
|
+
'E_APPROVAL_RESUME',
|
|
981
|
+
`Retained set-plan portfolio is invalid: ${portfolioValidation.errors.join('; ')}`,
|
|
982
|
+
);
|
|
983
|
+
}
|
|
438
984
|
state.factBase = factBase;
|
|
985
|
+
state.setPlan = retainedPlan.plan;
|
|
986
|
+
state.setPlanPaths = retainedPlan.paths;
|
|
439
987
|
state.theme = theme;
|
|
440
988
|
state.themeWarnings = [];
|
|
441
989
|
state.resumedApprovalStatus = approval.status;
|
|
@@ -452,14 +1000,41 @@ async function hydrateResumableState(state) {
|
|
|
452
1000
|
state.inputHashes = inputHashes(state.factBase);
|
|
453
1001
|
state.factBaseHash = canonicalHash(state.factBase);
|
|
454
1002
|
state.contentModels = [];
|
|
455
|
-
const persistedArtifacts =
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
1003
|
+
const persistedArtifacts = validateResumedArtifactBindings(state, approval);
|
|
1004
|
+
for (const persisted of persistedArtifacts) {
|
|
1005
|
+
const authorPath = await resolveRootConfinedPath(
|
|
1006
|
+
state.run.runRoot,
|
|
1007
|
+
persisted.authorResultPath,
|
|
1008
|
+
);
|
|
1009
|
+
if (!authorPath.valid) {
|
|
1010
|
+
throw codedError(
|
|
1011
|
+
'E_APPROVAL_RESUME',
|
|
1012
|
+
`Retained author path for ${persisted.artifactId} is not confined to the run.`,
|
|
1013
|
+
);
|
|
1014
|
+
}
|
|
1015
|
+
const authorResult = await readJson(authorPath.absolutePath);
|
|
1016
|
+
const validation = validateContract('author-result/v2', authorResult);
|
|
1017
|
+
if (!validation.valid || authorResult.artifactId !== persisted.artifactId) {
|
|
1018
|
+
throw codedError(
|
|
1019
|
+
'E_APPROVAL_RESUME',
|
|
1020
|
+
`Retained author result identity for ${persisted.artifactId} is invalid.`,
|
|
1021
|
+
);
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
const confinedContent = new Map();
|
|
1025
|
+
for (const persisted of persistedArtifacts) {
|
|
1026
|
+
const contentPath = await resolveRootConfinedPath(
|
|
1027
|
+
state.run.runRoot,
|
|
1028
|
+
persisted.contentPath,
|
|
1029
|
+
);
|
|
1030
|
+
if (!contentPath.valid) {
|
|
1031
|
+
throw codedError(
|
|
1032
|
+
'E_APPROVAL_RESUME',
|
|
1033
|
+
`Retained content path for ${persisted.artifactId} is not confined to the run.`,
|
|
1034
|
+
);
|
|
1035
|
+
}
|
|
1036
|
+
confinedContent.set(persisted.artifactId, contentPath.absolutePath);
|
|
1037
|
+
}
|
|
463
1038
|
for (const persisted of persistedArtifacts) {
|
|
464
1039
|
const artifact = resolvedArtifactFromApproval(state.recipe, persisted);
|
|
465
1040
|
if (artifact.authoring === 'html') {
|
|
@@ -468,15 +1043,13 @@ async function hydrateResumableState(state) {
|
|
|
468
1043
|
);
|
|
469
1044
|
}
|
|
470
1045
|
const content = await readFile(
|
|
471
|
-
|
|
1046
|
+
confinedContent.get(persisted.artifactId),
|
|
472
1047
|
'utf8',
|
|
473
1048
|
);
|
|
474
1049
|
state.resolvedArtifacts.push(artifact);
|
|
475
1050
|
state.authoredContent.set(artifact.id, content);
|
|
476
1051
|
state.contentPaths.set(artifact.id, persisted.contentPath);
|
|
477
|
-
|
|
478
|
-
state.authorResultPaths.push(persisted.authorResultPath);
|
|
479
|
-
}
|
|
1052
|
+
state.authorResultPaths.push(persisted.authorResultPath);
|
|
480
1053
|
}
|
|
481
1054
|
const links = expansionLinks(state.resolvedArtifacts);
|
|
482
1055
|
for (const artifact of state.resolvedArtifacts) {
|
|
@@ -513,6 +1086,7 @@ async function hydrateRenderedState(state) {
|
|
|
513
1086
|
content: state.contentModels.find(
|
|
514
1087
|
({ artifactId }) => artifactId === artifact.id,
|
|
515
1088
|
),
|
|
1089
|
+
factBase: state.factBase,
|
|
516
1090
|
theme: state.theme,
|
|
517
1091
|
renderStrategy: state.renderStrategy,
|
|
518
1092
|
...(state.run.request.publicBaseUrl && {
|
|
@@ -641,7 +1215,8 @@ async function buildFactBase(binding, options, now) {
|
|
|
641
1215
|
return {
|
|
642
1216
|
source: {
|
|
643
1217
|
...source,
|
|
644
|
-
|
|
1218
|
+
...(options.sourceProvenance?.[source.id] ?? {}),
|
|
1219
|
+
hash: raw.sourceHash ?? hashBytes(serialized),
|
|
645
1220
|
observedAt: raw.observedAt ?? now(),
|
|
646
1221
|
},
|
|
647
1222
|
claims: raw.claims,
|
|
@@ -694,7 +1269,7 @@ async function executeDurabilityAndPublish(state, options, now) {
|
|
|
694
1269
|
if (strategy === 'none') {
|
|
695
1270
|
await updateBuildRecord(state.run, { id: 'durability', status: 'skipped' });
|
|
696
1271
|
await updateBuildRecord(state.run, { id: 'publish', status: 'skipped' });
|
|
697
|
-
return;
|
|
1272
|
+
return false;
|
|
698
1273
|
}
|
|
699
1274
|
|
|
700
1275
|
if (strategy === 'commit') {
|
|
@@ -719,30 +1294,44 @@ async function executeDurabilityAndPublish(state, options, now) {
|
|
|
719
1294
|
],
|
|
720
1295
|
});
|
|
721
1296
|
await updateBuildRecord(state.run, { id: 'publish', status: 'skipped' });
|
|
722
|
-
return;
|
|
1297
|
+
return false;
|
|
723
1298
|
}
|
|
724
1299
|
|
|
725
1300
|
await updateBuildRecord(state.run, { id: 'durability', status: 'skipped' });
|
|
726
1301
|
await updateBuildRecord(state.run, { id: 'publish', status: 'running' });
|
|
727
|
-
await persistManifest(state, now());
|
|
728
1302
|
if (typeof options.publish !== 'function') {
|
|
729
1303
|
throw codedError(
|
|
730
1304
|
'E_PUBLISH',
|
|
731
1305
|
'Publish durability was requested without an explicit publisher callback.',
|
|
732
1306
|
);
|
|
733
1307
|
}
|
|
734
|
-
await options.publish({
|
|
735
|
-
request: structuredClone(state.run.request.durability.publish),
|
|
736
|
-
runRoot: state.run.runRoot,
|
|
737
|
-
manifestPath: state.run.manifestPath,
|
|
738
|
-
});
|
|
739
1308
|
await updateBuildRecord(state.run, {
|
|
740
1309
|
id: 'publish',
|
|
741
1310
|
status: 'warned',
|
|
742
1311
|
warnings: [
|
|
743
|
-
'Publishing
|
|
1312
|
+
'Publishing requires separately retained verified receipt evidence.',
|
|
744
1313
|
],
|
|
745
1314
|
});
|
|
1315
|
+
await persistManifest(state, now());
|
|
1316
|
+
try {
|
|
1317
|
+
await options.publish({
|
|
1318
|
+
request: structuredClone(state.run.request.durability.publish),
|
|
1319
|
+
runRoot: state.run.runRoot,
|
|
1320
|
+
manifestPath: state.run.manifestPath,
|
|
1321
|
+
});
|
|
1322
|
+
} catch (error) {
|
|
1323
|
+
await updateBuildRecord(state.run, {
|
|
1324
|
+
id: 'publish',
|
|
1325
|
+
status: 'failed',
|
|
1326
|
+
error: {
|
|
1327
|
+
code: error.code ?? 'E_PUBLISH',
|
|
1328
|
+
message: safeMessage(error),
|
|
1329
|
+
recovery: ['Correct the publish failure and start a new run.'],
|
|
1330
|
+
},
|
|
1331
|
+
});
|
|
1332
|
+
throw error;
|
|
1333
|
+
}
|
|
1334
|
+
return true;
|
|
746
1335
|
}
|
|
747
1336
|
|
|
748
1337
|
async function persistManifest(state, createdAt) {
|
|
@@ -754,6 +1343,16 @@ async function persistManifest(state, createdAt) {
|
|
|
754
1343
|
await immutableHashesFor(state),
|
|
755
1344
|
);
|
|
756
1345
|
await writeManifestAtomic(state.run, manifest);
|
|
1346
|
+
const publicBaseUrl =
|
|
1347
|
+
state.run.request.publicBaseUrl ??
|
|
1348
|
+
state.run.request.durability?.publish?.publicBaseUrl;
|
|
1349
|
+
if (publicBaseUrl) {
|
|
1350
|
+
await writeJsonAtomic(
|
|
1351
|
+
state.run.runRoot,
|
|
1352
|
+
initiativeCatalogPath(manifest.slug),
|
|
1353
|
+
catalogFromManifest(manifest, publicBaseUrl),
|
|
1354
|
+
);
|
|
1355
|
+
}
|
|
757
1356
|
return manifest;
|
|
758
1357
|
}
|
|
759
1358
|
|
|
@@ -796,6 +1395,7 @@ function manifestFor(state, buildRecord, createdAt, immutableHashes) {
|
|
|
796
1395
|
factBasePath: 'source/fact-base.json',
|
|
797
1396
|
factBaseHash: state.factBaseHash,
|
|
798
1397
|
inputHashes: state.inputHashes,
|
|
1398
|
+
backlinks: manifestSourceBacklinks(state.factBase),
|
|
799
1399
|
...(state.authorResultPaths.length > 0 && {
|
|
800
1400
|
authorResultPaths: state.authorResultPaths,
|
|
801
1401
|
}),
|
|
@@ -816,6 +1416,33 @@ function manifestFor(state, buildRecord, createdAt, immutableHashes) {
|
|
|
816
1416
|
};
|
|
817
1417
|
}
|
|
818
1418
|
|
|
1419
|
+
function manifestSourceBacklinks(factBase) {
|
|
1420
|
+
const backlinks = [
|
|
1421
|
+
...(factBase?.sources ?? [])
|
|
1422
|
+
.filter(({ url }) => typeof url === 'string')
|
|
1423
|
+
.map(({ id, url }) => ({ sourceId: id, url })),
|
|
1424
|
+
...(factBase?.claims ?? [])
|
|
1425
|
+
.flatMap(({ citations }) => citations ?? [])
|
|
1426
|
+
.filter(({ url }) => typeof url === 'string')
|
|
1427
|
+
.map(({ sourceId, url }) => ({ sourceId, url })),
|
|
1428
|
+
...(factBase?.unresolvedClaims ?? [])
|
|
1429
|
+
.flatMap(({ citations }) => citations ?? [])
|
|
1430
|
+
.filter(({ url }) => typeof url === 'string')
|
|
1431
|
+
.map(({ sourceId, url }) => ({ sourceId, url })),
|
|
1432
|
+
];
|
|
1433
|
+
return [
|
|
1434
|
+
...new Map(
|
|
1435
|
+
backlinks
|
|
1436
|
+
.sort(
|
|
1437
|
+
(left, right) =>
|
|
1438
|
+
left.sourceId.localeCompare(right.sourceId) ||
|
|
1439
|
+
left.url.localeCompare(right.url),
|
|
1440
|
+
)
|
|
1441
|
+
.map((backlink) => [`${backlink.sourceId}\0${backlink.url}`, backlink]),
|
|
1442
|
+
).values(),
|
|
1443
|
+
];
|
|
1444
|
+
}
|
|
1445
|
+
|
|
819
1446
|
async function createAuthoredContent(state, options, now) {
|
|
820
1447
|
const author = options.author;
|
|
821
1448
|
if (typeof author !== 'function') {
|
|
@@ -825,64 +1452,33 @@ async function createAuthoredContent(state, options, now) {
|
|
|
825
1452
|
);
|
|
826
1453
|
}
|
|
827
1454
|
const trust = authorTrustContext(options, now);
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
)
|
|
846
|
-
|
|
847
|
-
if (!state.expansion.valid) {
|
|
848
|
-
throw codedError(
|
|
849
|
-
'E_AUTHOR_RESULT',
|
|
850
|
-
`Invalid expansion proposals: ${state.expansion.errors.join('; ')}`,
|
|
851
|
-
);
|
|
852
|
-
}
|
|
853
|
-
|
|
854
|
-
const expansions = [];
|
|
855
|
-
for (const accepted of state.expansion.accepted) {
|
|
856
|
-
const profile = accepted.profile;
|
|
857
|
-
const item = await authorArtifact(
|
|
858
|
-
state,
|
|
859
|
-
{
|
|
860
|
-
id: accepted.id,
|
|
861
|
-
type: profile.type,
|
|
862
|
-
authoring: profile.authoring,
|
|
863
|
-
briefRef: profile.briefRef,
|
|
864
|
-
shell: profile.shell,
|
|
865
|
-
template:
|
|
866
|
-
profile.authoring === 'markdown'
|
|
867
|
-
? templateForType(profile.type)
|
|
868
|
-
: profile.shell,
|
|
869
|
-
required: false,
|
|
870
|
-
origin: 'expansion',
|
|
871
|
-
profileId: profile.profileId,
|
|
872
|
-
},
|
|
873
|
-
author,
|
|
874
|
-
trust,
|
|
875
|
-
);
|
|
1455
|
+
const artifacts = plannedArtifacts(state.recipe, state.setPlan);
|
|
1456
|
+
state.expansion = {
|
|
1457
|
+
valid: true,
|
|
1458
|
+
accepted: artifacts
|
|
1459
|
+
.filter(({ origin }) => origin === 'expansion')
|
|
1460
|
+
.map((artifact) => ({
|
|
1461
|
+
id: artifact.id,
|
|
1462
|
+
profileId: artifact.profileId,
|
|
1463
|
+
rationale: artifact.plannedArtifact.justification.rationale,
|
|
1464
|
+
status: 'accepted',
|
|
1465
|
+
profile: expansionProfile(state.recipe, artifact.profileId),
|
|
1466
|
+
})),
|
|
1467
|
+
rejected: [],
|
|
1468
|
+
warnings: [],
|
|
1469
|
+
errors: [],
|
|
1470
|
+
};
|
|
1471
|
+
const authored = [];
|
|
1472
|
+
for (const artifact of artifacts) {
|
|
1473
|
+
const item = await authorArtifact(state, artifact, author, trust);
|
|
876
1474
|
if ((item.result.proposedArtifacts ?? []).length > 0) {
|
|
877
1475
|
throw codedError(
|
|
878
1476
|
'E_AUTHOR_RESULT',
|
|
879
|
-
`
|
|
1477
|
+
`Artifact ${artifact.id} cannot change the validated set plan.`,
|
|
880
1478
|
);
|
|
881
1479
|
}
|
|
882
|
-
|
|
1480
|
+
authored.push(item);
|
|
883
1481
|
}
|
|
884
|
-
|
|
885
|
-
const authored = [...floor, ...expansions];
|
|
886
1482
|
state.resolvedArtifacts = authored.map(({ artifact }) => artifact);
|
|
887
1483
|
const links = expansionLinks(state.resolvedArtifacts);
|
|
888
1484
|
for (const item of authored) {
|
|
@@ -982,12 +1578,20 @@ function resolveAuthorProvenance(claimed, trust, artifactId) {
|
|
|
982
1578
|
};
|
|
983
1579
|
}
|
|
984
1580
|
|
|
985
|
-
async function authorArtifact(
|
|
986
|
-
|
|
987
|
-
|
|
1581
|
+
async function authorArtifact(
|
|
1582
|
+
state,
|
|
1583
|
+
artifact,
|
|
1584
|
+
author,
|
|
1585
|
+
trust,
|
|
1586
|
+
correctionContext,
|
|
1587
|
+
) {
|
|
1588
|
+
const [brief, visualAuthoringGuidance, shellContent] = await Promise.all([
|
|
1589
|
+
readSkillFile(artifact.briefRef),
|
|
1590
|
+
readSkillFile('references/visual-authoring.md'),
|
|
988
1591
|
artifact.authoring === 'html'
|
|
989
|
-
?
|
|
990
|
-
: undefined
|
|
1592
|
+
? readSkillFile(`templates/${artifact.shell}.html`)
|
|
1593
|
+
: undefined,
|
|
1594
|
+
]);
|
|
991
1595
|
const resolvedArtifact = {
|
|
992
1596
|
...artifact,
|
|
993
1597
|
...(shellContent && { shellContent }),
|
|
@@ -996,15 +1600,33 @@ async function authorArtifact(state, artifact, author, trust) {
|
|
|
996
1600
|
artifact.origin === 'floor'
|
|
997
1601
|
? recipeRequiredNarrative(state.recipe, artifact.id)
|
|
998
1602
|
: [];
|
|
1603
|
+
const plannedDiagrams = diagramAnalyses(artifact.plannedArtifact.draft);
|
|
1604
|
+
const graphSemantics = graphSemanticsForArtisticAuthor(plannedDiagrams);
|
|
1605
|
+
if (
|
|
1606
|
+
graphSemantics.length > 0 &&
|
|
1607
|
+
resolveDiagramRenderingRoute(state.recipe, artifact, plannedDiagrams) !==
|
|
1608
|
+
'artistic'
|
|
1609
|
+
) {
|
|
1610
|
+
throw codedError(
|
|
1611
|
+
'E_DIAGRAM_TOPOLOGY',
|
|
1612
|
+
`Artifact ${artifact.id} cannot preserve its planner-owned non-linear graph through inline rendering.`,
|
|
1613
|
+
);
|
|
1614
|
+
}
|
|
999
1615
|
const authorRequest = {
|
|
1000
1616
|
schemaVersion: 'explainer-kit.author-request/v2',
|
|
1001
1617
|
artifactId: artifact.id,
|
|
1002
1618
|
artifactType: artifact.type,
|
|
1003
1619
|
authoring: artifact.authoring,
|
|
1004
1620
|
brief,
|
|
1621
|
+
visualAuthoringGuidance,
|
|
1005
1622
|
factBase: structuredClone(state.factBase),
|
|
1006
1623
|
...(shellContent && { shell: shellContent }),
|
|
1007
1624
|
theme: structuredClone(state.theme),
|
|
1625
|
+
setContext: structuredClone(state.setPlan),
|
|
1626
|
+
plannedArtifact: structuredClone(artifact.plannedArtifact),
|
|
1627
|
+
...(graphSemantics.length > 0 && {
|
|
1628
|
+
graphSemantics: structuredClone(graphSemantics),
|
|
1629
|
+
}),
|
|
1008
1630
|
...(artifact.origin === 'floor' &&
|
|
1009
1631
|
requiredNarrative.length > 0 && {
|
|
1010
1632
|
floor: { requiredNarrative },
|
|
@@ -1021,7 +1643,13 @@ async function authorArtifact(state, artifact, author, trust) {
|
|
|
1021
1643
|
);
|
|
1022
1644
|
}
|
|
1023
1645
|
|
|
1024
|
-
const result =
|
|
1646
|
+
const result =
|
|
1647
|
+
correctionContext === undefined
|
|
1648
|
+
? await author(structuredClone(authorRequest))
|
|
1649
|
+
: await author(
|
|
1650
|
+
structuredClone(authorRequest),
|
|
1651
|
+
structuredClone(correctionContext),
|
|
1652
|
+
);
|
|
1025
1653
|
const resultValidation = validateContract('author-result/v2', result);
|
|
1026
1654
|
if (!resultValidation.valid) {
|
|
1027
1655
|
throw codedError(
|
|
@@ -1036,6 +1664,26 @@ async function authorArtifact(state, artifact, author, trust) {
|
|
|
1036
1664
|
`Author result for ${artifact.id} must match its identity and ${artifact.authoring} path.`,
|
|
1037
1665
|
);
|
|
1038
1666
|
}
|
|
1667
|
+
if (artifact.authoring === 'markdown') {
|
|
1668
|
+
const diagrams = diagramAnalyses(content);
|
|
1669
|
+
if (
|
|
1670
|
+
diagrams.length > 0 &&
|
|
1671
|
+
resolveDiagramRenderingRoute(state.recipe, artifact, diagrams) ===
|
|
1672
|
+
'reject'
|
|
1673
|
+
) {
|
|
1674
|
+
const features = [
|
|
1675
|
+
...new Set(
|
|
1676
|
+
diagrams.flatMap(({ topology }) => topology?.features ?? []),
|
|
1677
|
+
),
|
|
1678
|
+
].join(', ');
|
|
1679
|
+
throw codedError(
|
|
1680
|
+
'E_DIAGRAM_TOPOLOGY',
|
|
1681
|
+
`Artifact ${artifact.id} contains ${features || 'non-linear'} diagram topology that requires artistic composition; inline rendering is rejected.`,
|
|
1682
|
+
);
|
|
1683
|
+
}
|
|
1684
|
+
} else if (graphSemantics.length > 0) {
|
|
1685
|
+
assertAuthoredGraphSemantics(content, graphSemantics);
|
|
1686
|
+
}
|
|
1039
1687
|
const retained = {
|
|
1040
1688
|
...structuredClone(result),
|
|
1041
1689
|
provenance: resolveAuthorProvenance(result.provenance, trust, artifact.id),
|
|
@@ -1070,6 +1718,23 @@ async function authorArtifact(state, artifact, author, trust) {
|
|
|
1070
1718
|
};
|
|
1071
1719
|
}
|
|
1072
1720
|
|
|
1721
|
+
function diagramAnalyses(markdown) {
|
|
1722
|
+
const ast = parseMarkdown(markdown);
|
|
1723
|
+
const diagrams = [];
|
|
1724
|
+
const visit = (nodes) => {
|
|
1725
|
+
for (const node of nodes) {
|
|
1726
|
+
if (node.type === 'diagram') {
|
|
1727
|
+
diagrams.push(parseDiagram(node.source));
|
|
1728
|
+
}
|
|
1729
|
+
if (Array.isArray(node.children)) {
|
|
1730
|
+
visit(node.children);
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
};
|
|
1734
|
+
visit(ast.children);
|
|
1735
|
+
return diagrams;
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1073
1738
|
async function readSkillFile(relativePath) {
|
|
1074
1739
|
return readFile(new URL(`../${relativePath}`, import.meta.url), 'utf8');
|
|
1075
1740
|
}
|
|
@@ -1193,6 +1858,54 @@ function approvalArtifacts(state) {
|
|
|
1193
1858
|
}));
|
|
1194
1859
|
}
|
|
1195
1860
|
|
|
1861
|
+
function validateResumedArtifactBindings(state, approval) {
|
|
1862
|
+
const expected = plannedArtifacts(state.recipe, state.setPlan).map(
|
|
1863
|
+
(artifact) => ({
|
|
1864
|
+
artifactId: artifact.id,
|
|
1865
|
+
origin: artifact.origin,
|
|
1866
|
+
...(artifact.profileId && { profileId: artifact.profileId }),
|
|
1867
|
+
authoring: artifact.authoring,
|
|
1868
|
+
contentPath: `source/content/${artifact.id}.${artifact.authoring === 'markdown' ? 'md' : 'html'}`,
|
|
1869
|
+
authorResultPath: `source/author/${artifact.id}.json`,
|
|
1870
|
+
}),
|
|
1871
|
+
);
|
|
1872
|
+
if (
|
|
1873
|
+
!Array.isArray(approval.artifacts) ||
|
|
1874
|
+
!Array.isArray(approval.authorResultPaths)
|
|
1875
|
+
) {
|
|
1876
|
+
throw codedError(
|
|
1877
|
+
'E_APPROVAL_RESUME',
|
|
1878
|
+
'Approval resume requires retained artifact and author-result bindings.',
|
|
1879
|
+
);
|
|
1880
|
+
}
|
|
1881
|
+
const persistedById = new Map(
|
|
1882
|
+
approval.artifacts.map((artifact) => [artifact.artifactId, artifact]),
|
|
1883
|
+
);
|
|
1884
|
+
if (
|
|
1885
|
+
persistedById.size !== expected.length ||
|
|
1886
|
+
canonicalHash(approval.authorResultPaths) !==
|
|
1887
|
+
canonicalHash(expected.map(({ authorResultPath }) => authorResultPath))
|
|
1888
|
+
) {
|
|
1889
|
+
throw codedError(
|
|
1890
|
+
'E_APPROVAL_RESUME',
|
|
1891
|
+
'Approval artifact set does not match the retained set-plan portfolio.',
|
|
1892
|
+
);
|
|
1893
|
+
}
|
|
1894
|
+
for (const artifact of expected) {
|
|
1895
|
+
const persisted = persistedById.get(artifact.artifactId);
|
|
1896
|
+
if (
|
|
1897
|
+
persisted === undefined ||
|
|
1898
|
+
canonicalHash(persisted) !== canonicalHash(artifact)
|
|
1899
|
+
) {
|
|
1900
|
+
throw codedError(
|
|
1901
|
+
'E_APPROVAL_RESUME',
|
|
1902
|
+
`Approval artifact binding for ${artifact.artifactId} has drifted from the retained set plan.`,
|
|
1903
|
+
);
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
1906
|
+
return expected;
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1196
1909
|
function expansionLinks(artifacts) {
|
|
1197
1910
|
return artifacts
|
|
1198
1911
|
.filter(({ origin }) => origin === 'expansion')
|
|
@@ -1283,8 +1996,14 @@ async function immutableHashesFor(state) {
|
|
|
1283
1996
|
'source/fact-base.json',
|
|
1284
1997
|
'source/fact-base.md',
|
|
1285
1998
|
'source/content-approval.json',
|
|
1999
|
+
...state.setPlanPaths,
|
|
1286
2000
|
...state.authorResultPaths,
|
|
1287
2001
|
...state.contentPaths.values(),
|
|
2002
|
+
...state.browserEvidence.flatMap(({ screenshotPath, metricsPath }) => [
|
|
2003
|
+
screenshotPath,
|
|
2004
|
+
metricsPath,
|
|
2005
|
+
]),
|
|
2006
|
+
...state.visualReviewPaths,
|
|
1288
2007
|
...(state.theme ? ['theme.resolved.json'] : []),
|
|
1289
2008
|
...state.artifacts
|
|
1290
2009
|
.filter(
|
|
@@ -1341,7 +2060,9 @@ function resultFor(state, error) {
|
|
|
1341
2060
|
? 'failed'
|
|
1342
2061
|
: state.approval?.canResume === false
|
|
1343
2062
|
? 'incomplete'
|
|
1344
|
-
:
|
|
2063
|
+
: state.reviewGateBlocked
|
|
2064
|
+
? 'built-needs-review'
|
|
2065
|
+
: 'built-not-durable',
|
|
1345
2066
|
...(state.approval?.record?.marking && {
|
|
1346
2067
|
marking: state.approval.record.marking,
|
|
1347
2068
|
}),
|
|
@@ -1354,8 +2075,12 @@ function resultFor(state, error) {
|
|
|
1354
2075
|
...(state.approval.record.marking && {
|
|
1355
2076
|
marking: state.approval.record.marking,
|
|
1356
2077
|
}),
|
|
2078
|
+
...(state.resumeToken && { resumeToken: state.resumeToken }),
|
|
1357
2079
|
},
|
|
1358
2080
|
}),
|
|
2081
|
+
...(state.visualReview && {
|
|
2082
|
+
visualReview: structuredClone(state.visualReview.result),
|
|
2083
|
+
}),
|
|
1359
2084
|
...(error && {
|
|
1360
2085
|
errors: [{ code: error.code ?? 'E_RUN', message: safeMessage(error) }],
|
|
1361
2086
|
}),
|