@open-agent-toolkit/cli 0.2.20 → 0.2.22
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/docs/workflows/projects/artifacts.md +1 -1
- package/assets/docs/workflows/skills/explainer-kit.md +188 -23
- package/assets/docs/workflows/skills/index.md +1 -1
- package/assets/public-package-versions.json +4 -4
- package/assets/skills/explainer-kit/SKILL.md +87 -25
- package/assets/skills/explainer-kit/briefs/deep-dive.md +35 -0
- package/assets/skills/explainer-kit/briefs/engineer-tour.md +45 -0
- package/assets/skills/explainer-kit/briefs/program-recap.md +41 -0
- package/assets/skills/explainer-kit/briefs/project-explainer.md +38 -0
- package/assets/skills/explainer-kit/briefs/project-page.md +38 -0
- package/assets/skills/explainer-kit/briefs/project-recap.md +47 -0
- package/assets/skills/explainer-kit/briefs/supporting-diagram.md +28 -0
- package/assets/skills/explainer-kit/briefs/walkthrough-deck.md +35 -0
- package/assets/skills/explainer-kit/examples/project-recap/content.md +57 -14
- package/assets/skills/explainer-kit/examples/project-recap/fact-base.json +104 -0
- package/assets/skills/explainer-kit/examples/project-recap/fact-base.md +26 -4
- package/assets/skills/explainer-kit/recipes/engineer-tour.json +27 -10
- package/assets/skills/explainer-kit/recipes/program-recap.json +35 -11
- package/assets/skills/explainer-kit/recipes/project-explainer.json +27 -10
- package/assets/skills/explainer-kit/recipes/project-recap.json +43 -11
- package/assets/skills/explainer-kit/references/contracts.md +45 -19
- package/assets/skills/explainer-kit/schemas/author-request.v2.schema.json +41 -0
- package/assets/skills/explainer-kit/schemas/author-result.v2.schema.json +52 -0
- package/assets/skills/explainer-kit/scripts/lib/browser-runtime.mjs +442 -0
- package/assets/skills/explainer-kit/scripts/lib/content-approval.mjs +223 -10
- package/assets/skills/explainer-kit/scripts/lib/contracts.mjs +28 -43
- package/assets/skills/explainer-kit/scripts/lib/diagram.mjs +237 -0
- package/assets/skills/explainer-kit/scripts/lib/html-safety.mjs +687 -0
- package/assets/skills/explainer-kit/scripts/lib/markdown.mjs +414 -0
- package/assets/skills/explainer-kit/scripts/lib/qa.mjs +313 -14
- package/assets/skills/explainer-kit/scripts/lib/recipes.mjs +314 -41
- package/assets/skills/explainer-kit/scripts/lib/records.mjs +61 -0
- package/assets/skills/explainer-kit/scripts/lib/render.mjs +166 -12
- package/assets/skills/explainer-kit/scripts/render-qa.mjs +152 -2
- package/assets/skills/explainer-kit/scripts/run.mjs +789 -272
- package/assets/skills/explainer-kit/templates/deck-shell.html +25 -5
- package/assets/skills/explainer-kit/templates/diagram-shell.html +29 -7
- package/assets/skills/explainer-kit/templates/engineer-tour.html +133 -9
- package/assets/skills/explainer-kit/templates/house-style.html +82 -0
- package/assets/skills/oat-brainstorm/SKILL.md +1 -1
- package/assets/skills/oat-brainstorm/scripts/helper.js +18 -11
- package/assets/skills/oat-brainstorm/scripts/server.cjs +109 -55
- package/assets/skills/oat-explainer-kit/SKILL.md +16 -9
- package/assets/skills/oat-explainer-kit/references/author-callback.md +51 -0
- package/assets/skills/oat-explainer-kit/references/lifecycle-contract.md +10 -8
- package/assets/skills/oat-explainer-kit/scripts/resolve-intent.mjs +14 -0
- package/assets/skills/oat-explainer-kit/scripts/run.mjs +7 -11
- package/assets/skills/oat-project-complete/SKILL.md +18 -2
- package/assets/skills/oat-project-implement/SKILL.md +1 -1
- package/assets/skills/oat-project-implement/references/completion-and-closeout.md +7 -1
- package/assets/skills/oat-wave-execute/SKILL.md +12 -19
- package/assets/skills/oat-wave-program/SKILL.md +12 -13
- package/dist/commands/init/tools/index.d.ts.map +1 -1
- package/dist/commands/init/tools/index.js +4 -4
- package/dist/engine/compute-plan.js +3 -3
- package/package.json +2 -2
- package/assets/skills/explainer-kit/schemas/author-request.schema.json +0 -85
- package/assets/skills/explainer-kit/schemas/author-result.schema.json +0 -65
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
|
|
3
|
-
const
|
|
4
|
-
const
|
|
5
|
-
'artifacts',
|
|
3
|
+
const RECIPE_SCHEMA_V2 = 'explainer-kit.recipe/v2';
|
|
4
|
+
const RECIPE_V2_ROOT_KEYS = [
|
|
6
5
|
'discoveryLimits',
|
|
6
|
+
'expansion',
|
|
7
|
+
'floor',
|
|
7
8
|
'id',
|
|
8
|
-
'requiredNarrative',
|
|
9
9
|
'schemaVersion',
|
|
10
10
|
'sourceRoles',
|
|
11
11
|
'version',
|
|
@@ -17,7 +17,25 @@ const SOURCE_ROLE_KEYS = [
|
|
|
17
17
|
'required',
|
|
18
18
|
'role',
|
|
19
19
|
];
|
|
20
|
-
const
|
|
20
|
+
const FLOOR_KEYS = [
|
|
21
|
+
'authoring',
|
|
22
|
+
'briefRef',
|
|
23
|
+
'id',
|
|
24
|
+
'required',
|
|
25
|
+
'requiredNarrative',
|
|
26
|
+
'template',
|
|
27
|
+
'type',
|
|
28
|
+
];
|
|
29
|
+
const EXPANSION_KEYS = ['limits', 'profiles'];
|
|
30
|
+
const PROFILE_REQUIRED_KEYS = [
|
|
31
|
+
'authoring',
|
|
32
|
+
'briefRef',
|
|
33
|
+
'maxCount',
|
|
34
|
+
'profileId',
|
|
35
|
+
'type',
|
|
36
|
+
];
|
|
37
|
+
const PROFILE_KEYS = [...PROFILE_REQUIRED_KEYS, 'shell'];
|
|
38
|
+
const EXPANSION_LIMIT_KEYS = ['maxArtifacts', 'maxPerType'];
|
|
21
39
|
const DISCOVERY_LIMIT_KEYS = ['consecutiveNoNewFindingsRounds', 'maxRounds'];
|
|
22
40
|
const SOURCE_KINDS = new Set([
|
|
23
41
|
'file',
|
|
@@ -27,7 +45,15 @@ const SOURCE_KINDS = new Set([
|
|
|
27
45
|
'session',
|
|
28
46
|
'other',
|
|
29
47
|
]);
|
|
30
|
-
const ARTIFACT_TYPES = new Set([
|
|
48
|
+
const ARTIFACT_TYPES = new Set([
|
|
49
|
+
'hub',
|
|
50
|
+
'diagram',
|
|
51
|
+
'explainer',
|
|
52
|
+
'deck',
|
|
53
|
+
'catalog',
|
|
54
|
+
]);
|
|
55
|
+
const AUTHORING_TYPES = new Set(['markdown', 'html']);
|
|
56
|
+
const SAFE_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
31
57
|
const RECIPE_FILES = [
|
|
32
58
|
'project-explainer.json',
|
|
33
59
|
'project-recap.json',
|
|
@@ -55,6 +81,143 @@ export function loadRecipe(id, version) {
|
|
|
55
81
|
return structuredClone(recipe);
|
|
56
82
|
}
|
|
57
83
|
|
|
84
|
+
export function recipeFloor(recipe) {
|
|
85
|
+
if (recipe?.schemaVersion === RECIPE_SCHEMA_V2) {
|
|
86
|
+
return structuredClone(recipe.floor);
|
|
87
|
+
}
|
|
88
|
+
throw new TypeError('Recipe has an unsupported schemaVersion');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function recipeExpansion(recipe) {
|
|
92
|
+
if (recipe?.schemaVersion === RECIPE_SCHEMA_V2) {
|
|
93
|
+
return structuredClone(recipe.expansion);
|
|
94
|
+
}
|
|
95
|
+
throw new TypeError('Recipe has an unsupported schemaVersion');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function recipeRequiredNarrative(recipe, artifactId) {
|
|
99
|
+
if (recipe?.schemaVersion === RECIPE_SCHEMA_V2) {
|
|
100
|
+
const artifact = recipe.floor.find(({ id }) => id === artifactId);
|
|
101
|
+
return [...(artifact?.requiredNarrative ?? [])];
|
|
102
|
+
}
|
|
103
|
+
throw new TypeError('Recipe has an unsupported schemaVersion');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function evaluateExpansionProposals(recipe, proposals) {
|
|
107
|
+
const floorIds = new Set(recipeFloor(recipe).map(({ id }) => id));
|
|
108
|
+
const expansion = recipeExpansion(recipe);
|
|
109
|
+
const profiles = new Map(
|
|
110
|
+
expansion.profiles.map((profile) => [profile.profileId, profile]),
|
|
111
|
+
);
|
|
112
|
+
const errors = [];
|
|
113
|
+
const accepted = [];
|
|
114
|
+
const rejected = [];
|
|
115
|
+
const warnings = new Set();
|
|
116
|
+
const seenIds = new Set();
|
|
117
|
+
const acceptedByProfile = new Map();
|
|
118
|
+
const acceptedByType = new Map();
|
|
119
|
+
const maxPerType = new Map(Object.entries(expansion.limits.maxPerType ?? {}));
|
|
120
|
+
|
|
121
|
+
if (!Array.isArray(proposals)) {
|
|
122
|
+
return {
|
|
123
|
+
valid: false,
|
|
124
|
+
accepted,
|
|
125
|
+
rejected,
|
|
126
|
+
warnings: [],
|
|
127
|
+
errors: ['Expansion proposals must be an array'],
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
for (const proposal of proposals) {
|
|
132
|
+
if (
|
|
133
|
+
!isObject(proposal) ||
|
|
134
|
+
!hasExactKeys(proposal, ['id', 'profileId', 'rationale'])
|
|
135
|
+
) {
|
|
136
|
+
errors.push(
|
|
137
|
+
'Expansion proposals require only id, profileId, and rationale',
|
|
138
|
+
);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (!SAFE_ID.test(proposal.id)) {
|
|
142
|
+
errors.push(`Unsafe expansion artifact id: ${proposal.id}`);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (floorIds.has(proposal.id)) {
|
|
146
|
+
errors.push(`Expansion artifact id collides with floor: ${proposal.id}`);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (seenIds.has(proposal.id)) {
|
|
150
|
+
errors.push(`Duplicate expansion artifact id: ${proposal.id}`);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
seenIds.add(proposal.id);
|
|
154
|
+
|
|
155
|
+
const profile = profiles.get(proposal.profileId);
|
|
156
|
+
if (!profile) {
|
|
157
|
+
errors.push(`Unknown expansion profile: ${proposal.profileId}`);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (
|
|
161
|
+
typeof proposal.rationale !== 'string' ||
|
|
162
|
+
proposal.rationale.length === 0
|
|
163
|
+
) {
|
|
164
|
+
errors.push(`Expansion proposal ${proposal.id} requires a rationale`);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const profileCount = acceptedByProfile.get(profile.profileId) ?? 0;
|
|
169
|
+
if (profileCount >= profile.maxCount) {
|
|
170
|
+
rejected.push({
|
|
171
|
+
...structuredClone(proposal),
|
|
172
|
+
status: 'rejected',
|
|
173
|
+
reason: 'profile-limit',
|
|
174
|
+
});
|
|
175
|
+
warnings.add('expansion-profile-limit-exceeded');
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
// A declared type cap binds across profiles, so proposals cannot spread
|
|
179
|
+
// over sibling profiles that share one artifact type to evade it.
|
|
180
|
+
const typeCount = acceptedByType.get(profile.type) ?? 0;
|
|
181
|
+
if (
|
|
182
|
+
maxPerType.has(profile.type) &&
|
|
183
|
+
typeCount >= maxPerType.get(profile.type)
|
|
184
|
+
) {
|
|
185
|
+
rejected.push({
|
|
186
|
+
...structuredClone(proposal),
|
|
187
|
+
status: 'rejected',
|
|
188
|
+
reason: 'type-limit',
|
|
189
|
+
});
|
|
190
|
+
warnings.add('expansion-type-limit-exceeded');
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (accepted.length >= expansion.limits.maxArtifacts) {
|
|
194
|
+
rejected.push({
|
|
195
|
+
...structuredClone(proposal),
|
|
196
|
+
status: 'rejected',
|
|
197
|
+
reason: 'recipe-limit',
|
|
198
|
+
});
|
|
199
|
+
warnings.add('expansion-artifact-limit-exceeded');
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
accepted.push({
|
|
204
|
+
...structuredClone(proposal),
|
|
205
|
+
status: 'accepted',
|
|
206
|
+
profile: structuredClone(profile),
|
|
207
|
+
});
|
|
208
|
+
acceptedByProfile.set(profile.profileId, profileCount + 1);
|
|
209
|
+
acceptedByType.set(profile.type, typeCount + 1);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
valid: errors.length === 0,
|
|
214
|
+
accepted,
|
|
215
|
+
rejected,
|
|
216
|
+
warnings: [...warnings],
|
|
217
|
+
errors,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
58
221
|
export function validateSourceBindings(recipe, bindings) {
|
|
59
222
|
const errors = [];
|
|
60
223
|
if (!Array.isArray(bindings)) {
|
|
@@ -100,7 +263,7 @@ export function validateContentModel(recipe, contentModel) {
|
|
|
100
263
|
}
|
|
101
264
|
|
|
102
265
|
if (
|
|
103
|
-
!recipe.
|
|
266
|
+
!recipeFloor(recipe).some(
|
|
104
267
|
(artifact) => artifact.id === contentModel.artifactId,
|
|
105
268
|
)
|
|
106
269
|
) {
|
|
@@ -129,11 +292,12 @@ export function validateContentModel(recipe, contentModel) {
|
|
|
129
292
|
}
|
|
130
293
|
}
|
|
131
294
|
|
|
132
|
-
for (const sectionId of
|
|
295
|
+
for (const sectionId of recipeRequiredNarrative(
|
|
296
|
+
recipe,
|
|
297
|
+
contentModel.artifactId,
|
|
298
|
+
)) {
|
|
133
299
|
const count = sectionCounts.get(sectionId) ?? 0;
|
|
134
|
-
if (count
|
|
135
|
-
errors.push(`Missing required narrative section: ${sectionId}`);
|
|
136
|
-
} else if (count > 1) {
|
|
300
|
+
if (count > 1) {
|
|
137
301
|
errors.push(`Duplicate narrative section: ${sectionId}`);
|
|
138
302
|
}
|
|
139
303
|
}
|
|
@@ -162,19 +326,16 @@ export function shouldStopDiscovery(recipe, findingsByRound) {
|
|
|
162
326
|
.every((count) => count === 0);
|
|
163
327
|
}
|
|
164
328
|
|
|
165
|
-
function validateRecipe(recipe, file) {
|
|
329
|
+
export function validateRecipe(recipe, file = 'recipe') {
|
|
166
330
|
assertObject(recipe, `${file} recipe`);
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
recipe
|
|
170
|
-
|
|
171
|
-
|
|
331
|
+
if (recipe.schemaVersion === RECIPE_SCHEMA_V2) {
|
|
332
|
+
assertExactKeys(recipe, RECIPE_V2_ROOT_KEYS, `${file} recipe`);
|
|
333
|
+
validateV2Shape(recipe, file);
|
|
334
|
+
} else {
|
|
335
|
+
assert(false, `${file} has unsupported schemaVersion`);
|
|
336
|
+
}
|
|
172
337
|
assertNonEmptyString(recipe.id, `${file} id`);
|
|
173
338
|
assertNonEmptyString(recipe.version, `${file} version`);
|
|
174
|
-
assertUniqueNonEmptyStrings(
|
|
175
|
-
recipe.requiredNarrative,
|
|
176
|
-
`${file} requiredNarrative`,
|
|
177
|
-
);
|
|
178
339
|
|
|
179
340
|
assert(
|
|
180
341
|
Array.isArray(recipe.sourceRoles) && recipe.sourceRoles.length > 0,
|
|
@@ -207,38 +368,124 @@ function validateRecipe(recipe, file) {
|
|
|
207
368
|
}
|
|
208
369
|
assertUnique(roleNames, `${file} source role names`);
|
|
209
370
|
|
|
371
|
+
validateDiscoveryLimits(recipe.discoveryLimits, file);
|
|
372
|
+
return recipe;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function validateV2Shape(recipe, file) {
|
|
210
376
|
assert(
|
|
211
|
-
Array.isArray(recipe.
|
|
212
|
-
`${file}
|
|
377
|
+
Array.isArray(recipe.floor) && recipe.floor.length > 0,
|
|
378
|
+
`${file} floor must be a non-empty array`,
|
|
213
379
|
);
|
|
214
|
-
const
|
|
215
|
-
for (const artifact of recipe.
|
|
216
|
-
assertObject(artifact, `${file}
|
|
217
|
-
assertExactKeys(artifact,
|
|
218
|
-
|
|
219
|
-
|
|
380
|
+
const floorIds = [];
|
|
381
|
+
for (const artifact of recipe.floor) {
|
|
382
|
+
assertObject(artifact, `${file} floor entry`);
|
|
383
|
+
assertExactKeys(artifact, FLOOR_KEYS, `${file} floor entry`);
|
|
384
|
+
assertSafeId(artifact.id, `${file} floor id`);
|
|
385
|
+
floorIds.push(artifact.id);
|
|
220
386
|
assert(
|
|
221
387
|
ARTIFACT_TYPES.has(artifact.type),
|
|
222
|
-
`${file}
|
|
388
|
+
`${file} floor has unsupported type`,
|
|
389
|
+
);
|
|
390
|
+
assert(
|
|
391
|
+
AUTHORING_TYPES.has(artifact.authoring),
|
|
392
|
+
`${file} floor has unsupported authoring`,
|
|
393
|
+
);
|
|
394
|
+
assertNonEmptyString(artifact.template, `${file} floor template`);
|
|
395
|
+
assertNonEmptyString(artifact.briefRef, `${file} floor briefRef`);
|
|
396
|
+
assert(typeof artifact.required === 'boolean', `${file} floor required`);
|
|
397
|
+
assertUniqueNonEmptyStrings(
|
|
398
|
+
artifact.requiredNarrative,
|
|
399
|
+
`${file} floor requiredNarrative`,
|
|
223
400
|
);
|
|
224
|
-
assertNonEmptyString(artifact.template, `${file} artifact template`);
|
|
225
|
-
assert(typeof artifact.required === 'boolean', `${file} artifact required`);
|
|
226
401
|
}
|
|
227
|
-
assertUnique(
|
|
402
|
+
assertUnique(floorIds, `${file} floor ids`);
|
|
228
403
|
|
|
229
|
-
assertObject(recipe.
|
|
404
|
+
assertObject(recipe.expansion, `${file} expansion`);
|
|
405
|
+
assertExactKeys(recipe.expansion, EXPANSION_KEYS, `${file} expansion`);
|
|
406
|
+
assert(
|
|
407
|
+
Array.isArray(recipe.expansion.profiles),
|
|
408
|
+
`${file} expansion profiles must be an array`,
|
|
409
|
+
);
|
|
410
|
+
const profileIds = [];
|
|
411
|
+
for (const profile of recipe.expansion.profiles) {
|
|
412
|
+
assertObject(profile, `${file} expansion profile`);
|
|
413
|
+
assertAllowedKeys(
|
|
414
|
+
profile,
|
|
415
|
+
PROFILE_KEYS,
|
|
416
|
+
PROFILE_REQUIRED_KEYS,
|
|
417
|
+
`${file} expansion profile`,
|
|
418
|
+
);
|
|
419
|
+
assertSafeId(profile.profileId, `${file} expansion profileId`);
|
|
420
|
+
profileIds.push(profile.profileId);
|
|
421
|
+
assert(
|
|
422
|
+
!floorIds.includes(profile.profileId),
|
|
423
|
+
`${file} expansion profileId collides with floor id`,
|
|
424
|
+
);
|
|
425
|
+
assert(
|
|
426
|
+
ARTIFACT_TYPES.has(profile.type),
|
|
427
|
+
`${file} expansion profile has unsupported type`,
|
|
428
|
+
);
|
|
429
|
+
assert(
|
|
430
|
+
AUTHORING_TYPES.has(profile.authoring),
|
|
431
|
+
`${file} expansion profile has unsupported authoring`,
|
|
432
|
+
);
|
|
433
|
+
assertNonEmptyString(
|
|
434
|
+
profile.briefRef,
|
|
435
|
+
`${file} expansion profile briefRef`,
|
|
436
|
+
);
|
|
437
|
+
assertFiniteCount(profile.maxCount, `${file} expansion profile maxCount`);
|
|
438
|
+
if (profile.authoring === 'html' || 'shell' in profile) {
|
|
439
|
+
assertNonEmptyString(
|
|
440
|
+
profile.shell,
|
|
441
|
+
`${file} expansion profile ${profile.profileId} shell`,
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
assertUnique(profileIds, `${file} expansion profileIds`);
|
|
446
|
+
|
|
447
|
+
assertObject(recipe.expansion.limits, `${file} expansion limits`);
|
|
448
|
+
assertAllowedKeys(
|
|
449
|
+
recipe.expansion.limits,
|
|
450
|
+
EXPANSION_LIMIT_KEYS,
|
|
451
|
+
['maxArtifacts'],
|
|
452
|
+
`${file} expansion limits`,
|
|
453
|
+
);
|
|
454
|
+
assertFiniteCount(
|
|
455
|
+
recipe.expansion.limits.maxArtifacts,
|
|
456
|
+
`${file} expansion maxArtifacts`,
|
|
457
|
+
);
|
|
458
|
+
if ('maxPerType' in recipe.expansion.limits) {
|
|
459
|
+
assertObject(
|
|
460
|
+
recipe.expansion.limits.maxPerType,
|
|
461
|
+
`${file} expansion maxPerType`,
|
|
462
|
+
);
|
|
463
|
+
for (const [type, limit] of Object.entries(
|
|
464
|
+
recipe.expansion.limits.maxPerType,
|
|
465
|
+
)) {
|
|
466
|
+
assert(
|
|
467
|
+
ARTIFACT_TYPES.has(type),
|
|
468
|
+
`${file} expansion maxPerType has unsupported type`,
|
|
469
|
+
);
|
|
470
|
+
assertFiniteCount(limit, `${file} expansion maxPerType ${type}`);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function validateDiscoveryLimits(discoveryLimits, file) {
|
|
476
|
+
assertObject(discoveryLimits, `${file} discoveryLimits`);
|
|
230
477
|
assertExactKeys(
|
|
231
|
-
|
|
478
|
+
discoveryLimits,
|
|
232
479
|
DISCOVERY_LIMIT_KEYS,
|
|
233
480
|
`${file} discoveryLimits`,
|
|
234
481
|
);
|
|
235
482
|
assert(
|
|
236
|
-
|
|
483
|
+
discoveryLimits.consecutiveNoNewFindingsRounds === 2,
|
|
237
484
|
`${file} discovery must stop after exactly two no-new-findings rounds`,
|
|
238
485
|
);
|
|
239
486
|
assert(
|
|
240
|
-
Number.isInteger(
|
|
241
|
-
|
|
487
|
+
Number.isInteger(discoveryLimits.maxRounds) &&
|
|
488
|
+
discoveryLimits.maxRounds >= 2,
|
|
242
489
|
`${file} discovery maxRounds must be an integer of at least two`,
|
|
243
490
|
);
|
|
244
491
|
}
|
|
@@ -248,14 +495,28 @@ function assertObject(value, label) {
|
|
|
248
495
|
}
|
|
249
496
|
|
|
250
497
|
function assertExactKeys(value, expected, label) {
|
|
251
|
-
const
|
|
498
|
+
const matches = hasExactKeys(value, expected);
|
|
499
|
+
assert(matches, `${label} has unknown or missing keys`);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function assertAllowedKeys(value, allowed, required, label) {
|
|
503
|
+
const actual = Object.keys(value);
|
|
252
504
|
assert(
|
|
253
|
-
actual.
|
|
254
|
-
|
|
505
|
+
actual.every((key) => allowed.includes(key)) &&
|
|
506
|
+
required.every((key) => key in value),
|
|
255
507
|
`${label} has unknown or missing keys`,
|
|
256
508
|
);
|
|
257
509
|
}
|
|
258
510
|
|
|
511
|
+
function hasExactKeys(value, expected) {
|
|
512
|
+
const actual = Object.keys(value).sort();
|
|
513
|
+
const sortedExpected = [...expected].sort();
|
|
514
|
+
return (
|
|
515
|
+
actual.length === sortedExpected.length &&
|
|
516
|
+
actual.every((key, index) => key === sortedExpected[index])
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
|
|
259
520
|
function assertNonEmptyString(value, label) {
|
|
260
521
|
assert(
|
|
261
522
|
typeof value === 'string' && value.length > 0,
|
|
@@ -263,6 +524,18 @@ function assertNonEmptyString(value, label) {
|
|
|
263
524
|
);
|
|
264
525
|
}
|
|
265
526
|
|
|
527
|
+
function assertSafeId(value, label) {
|
|
528
|
+
assertNonEmptyString(value, label);
|
|
529
|
+
assert(SAFE_ID.test(value), `${label} must be a safe slug`);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function assertFiniteCount(value, label) {
|
|
533
|
+
assert(
|
|
534
|
+
Number.isInteger(value) && Number.isFinite(value) && value >= 0,
|
|
535
|
+
`${label} must be a finite non-negative integer`,
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
|
|
266
539
|
function assertUniqueNonEmptyStrings(value, label) {
|
|
267
540
|
assert(
|
|
268
541
|
Array.isArray(value) && value.length > 0,
|
|
@@ -158,6 +158,67 @@ export async function updateBuildRecord(run, stage) {
|
|
|
158
158
|
return record;
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
+
export async function reopenBuildStages(run, { ids, reason }) {
|
|
162
|
+
assertRun(run);
|
|
163
|
+
if (
|
|
164
|
+
!Array.isArray(ids) ||
|
|
165
|
+
ids.length === 0 ||
|
|
166
|
+
ids.some((id) => !STAGE_IDS.includes(id)) ||
|
|
167
|
+
typeof reason !== 'string' ||
|
|
168
|
+
reason.length === 0
|
|
169
|
+
) {
|
|
170
|
+
throw new Error('Stage reopen requires supported stage ids and a reason.');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const indexes = ids.map((id) => STAGE_IDS.indexOf(id));
|
|
174
|
+
const firstIndex = Math.min(...indexes);
|
|
175
|
+
const lastIndex = Math.max(...indexes);
|
|
176
|
+
if (
|
|
177
|
+
new Set(indexes).size !== indexes.length ||
|
|
178
|
+
indexes.some((index, offset) => index !== firstIndex + offset) ||
|
|
179
|
+
ids.some((id, index) => STAGE_IDS[firstIndex + index] !== id)
|
|
180
|
+
) {
|
|
181
|
+
throw new Error('Reopened stages must be unique and contiguous in order.');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const record = JSON.parse(await readFile(run.buildRecordPath, 'utf8'));
|
|
185
|
+
if (record.runId !== run.runId) {
|
|
186
|
+
throw new Error('Build record does not belong to this run.');
|
|
187
|
+
}
|
|
188
|
+
if (
|
|
189
|
+
!record.stages
|
|
190
|
+
.slice(0, firstIndex)
|
|
191
|
+
.every(({ status }) => SUCCESSFUL_TERMINAL_STATUSES.has(status)) ||
|
|
192
|
+
!record.stages
|
|
193
|
+
.slice(firstIndex, lastIndex + 1)
|
|
194
|
+
.every(({ status }) => SUCCESSFUL_TERMINAL_STATUSES.has(status)) ||
|
|
195
|
+
!record.stages
|
|
196
|
+
.slice(lastIndex + 1)
|
|
197
|
+
.every(({ status }) => status === 'pending')
|
|
198
|
+
) {
|
|
199
|
+
throw new Error(
|
|
200
|
+
'Stages may reopen only after successful prerequisites and before downstream work.',
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const timestamp = new Date().toISOString();
|
|
205
|
+
for (let index = firstIndex; index <= lastIndex; index += 1) {
|
|
206
|
+
const previous = record.stages[index];
|
|
207
|
+
record.stages[index] = {
|
|
208
|
+
id: previous.id,
|
|
209
|
+
status: 'pending',
|
|
210
|
+
outputPaths: [],
|
|
211
|
+
warnings: [...previous.warnings, `stage-reopened:${reason}:${timestamp}`],
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
record.outcome = 'incomplete';
|
|
215
|
+
delete record.completedAt;
|
|
216
|
+
|
|
217
|
+
assertValidContract('build-record', record);
|
|
218
|
+
await writeJsonAtomic(run.runRoot, 'build-record.json', record);
|
|
219
|
+
return record;
|
|
220
|
+
}
|
|
221
|
+
|
|
161
222
|
export async function writeManifestAtomic(run, manifest) {
|
|
162
223
|
assertRun(run);
|
|
163
224
|
if (!isObject(manifest)) {
|