@juangcarmona/lighthouse-sdlc 0.17.0 → 0.18.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 +15 -3
- package/capabilities.json +2 -1
- package/completion.md +2 -0
- package/epic-delivery.md +35 -0
- package/github-actions.md +19 -3
- package/host-contract.md +2 -0
- package/package.json +4 -1
- package/schemas/product-change.schema.json +23 -4
- package/schemas/stage-result.schema.json +23 -1
- package/skills/product-change/SKILL.md +3 -3
- package/skills/refine/SKILL.md +3 -2
- package/src/completion-actions.mjs +57 -13
- package/src/completion.mjs +1 -1
- package/src/contracts.mjs +10 -0
- package/src/decompose.mjs +5 -2
- package/src/epic-dispatch.mjs +59 -0
- package/src/epic-github.mjs +111 -0
- package/src/epic-journal.mjs +104 -0
- package/src/epic-merge.mjs +91 -0
- package/src/epic-native.mjs +465 -0
- package/src/epic.mjs +86 -0
- package/src/handoff.mjs +97 -0
- package/src/host-contract.mjs +22 -6
- package/src/implementation.mjs +33 -5
- package/src/install-workflows.mjs +1 -1
- package/src/issue-actions.mjs +360 -78
- package/src/opencode-execute.mjs +20 -0
- package/src/pr-state-actions.mjs +21 -7
- package/src/pr-state.mjs +18 -1
- package/src/process-publication.mjs +2 -1
- package/src/product-change-actions.mjs +158 -46
- package/src/product-change.mjs +92 -11
- package/src/proposal-review.mjs +16 -6
- package/src/proposal.mjs +13 -1
- package/src/stacked.mjs +14 -3
- package/src/stage-dispatch.mjs +66 -19
- package/src/stage-transition.mjs +151 -2
- package/src/triage.mjs +4 -2
- package/src/unattended-actions.mjs +39 -12
- package/src/unattended-planning-evidence.mjs +44 -0
- package/src/unattended.mjs +1 -1
- package/src/worker-maintenance.mjs +15 -3
- package/src/worker-recovery.mjs +204 -6
- package/workflows/lighthouse-sdlc-apply-product-change.lock.yml +97 -11
- package/workflows/lighthouse-sdlc-apply-product-change.md +75 -6
- package/workflows/lighthouse-sdlc-apply-review.lock.yml +33 -2
- package/workflows/lighthouse-sdlc-apply-review.md +29 -1
- package/workflows/lighthouse-sdlc-epic.yml +79 -0
- package/workflows/lighthouse-sdlc-implement.lock.yml +38 -2
- package/workflows/lighthouse-sdlc-implement.md +33 -2
- package/workflows/lighthouse-sdlc-integrate.lock.yml +4 -1
- package/workflows/lighthouse-sdlc-integrate.md +3 -0
- package/workflows/lighthouse-sdlc-product-change.lock.yml +39 -4
- package/workflows/lighthouse-sdlc-product-change.md +39 -6
- package/workflows/lighthouse-sdlc-propose.lock.yml +13 -1
- package/workflows/lighthouse-sdlc-propose.md +11 -1
- package/workflows/lighthouse-sdlc-refine.lock.yml +29 -2
- package/workflows/lighthouse-sdlc-refine.md +25 -2
- package/workflows/lighthouse-sdlc-review.lock.yml +4 -1
- package/workflows/lighthouse-sdlc-review.md +3 -0
- package/workflows/lighthouse-sdlc-router.yml +47 -3
- package/workflows/lighthouse-sdlc-triage.lock.yml +30 -3
- package/workflows/lighthouse-sdlc-triage.md +26 -3
- package/workflows/lighthouse-worker-maintenance.yml +70 -1
package/src/product-change.mjs
CHANGED
|
@@ -37,27 +37,33 @@ export function validateProductRequest(value) {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
export function validateProductChangeOutput(value) {
|
|
40
|
-
const fields = ['schemaVersion', 'outcome', 'entry', 'affectedAreas', 'decisions', ...(['changeName', 'changeId', 'files', 'question', 'proposalDigest', 'baselineDigest', 'providerValidation'].filter(key => Object.hasOwn(value ?? {}, key)))];
|
|
40
|
+
const fields = ['schemaVersion', 'outcome', 'entry', 'affectedAreas', 'decisions', ...(['changeName', 'changeId', 'files', 'question', 'blocker', 'recovery', 'proposalDigest', 'baselineDigest', 'providerValidation'].filter(key => Object.hasOwn(value ?? {}, key)))];
|
|
41
41
|
if (!exact(value, fields)) return ['result.output.productChange has unsupported or missing fields'];
|
|
42
42
|
const errors = [];
|
|
43
43
|
if (value.schemaVersion !== 1 || !['aligned', 'candidate', 'blocked'].includes(value.outcome) || !['idea', 'parent-handoff', 'feedback'].includes(value.entry)) errors.push('result.output.productChange identity is unsupported');
|
|
44
44
|
if (!Array.isArray(value.affectedAreas) || value.affectedAreas.length > 32 || value.affectedAreas.some(item => typeof item !== 'string' || !item.trim() || item.length > 256)) errors.push('result.output.productChange affectedAreas is invalid');
|
|
45
45
|
if (!Array.isArray(value.decisions) || value.decisions.length > 32 || value.decisions.some(item => typeof item !== 'string' || !item.trim() || item.length > 1000)) errors.push('result.output.productChange decisions is invalid');
|
|
46
|
-
if (value.question !== undefined && (typeof value.question !== 'string' || value.question.length > 4000)) errors.push('result.output.productChange question
|
|
46
|
+
if (value.question !== undefined && (typeof value.question !== 'string' || !value.question.trim() || value.question.length > 4000 || /<!--\s*lighthouse-/i.test(value.question))) errors.push('result.output.productChange question is invalid');
|
|
47
|
+
if (value.blocker !== undefined && (typeof value.blocker !== 'string' || !value.blocker.trim() || value.blocker.length > 4000 || /<!--\s*lighthouse-/i.test(value.blocker))) errors.push('result.output.productChange blocker is invalid');
|
|
48
|
+
if (value.recovery !== undefined && (typeof value.recovery !== 'string' || !value.recovery.trim() || value.recovery.length > 4000 || /<!--\s*lighthouse-/i.test(value.recovery))) errors.push('result.output.productChange recovery is invalid');
|
|
47
49
|
if (value.changeName !== undefined && !namePattern.test(value.changeName)) errors.push('result.output.productChange changeName is invalid');
|
|
48
50
|
if (value.changeId !== undefined && !/^CHG-[A-Z0-9-]+$/.test(value.changeId)) errors.push('result.output.productChange changeId is invalid');
|
|
49
51
|
if (value.proposalDigest !== undefined && !hashPattern.test(value.proposalDigest)) errors.push('result.output.productChange proposalDigest is invalid');
|
|
50
52
|
if (value.baselineDigest !== undefined && !hashPattern.test(value.baselineDigest)) errors.push('result.output.productChange baselineDigest is invalid');
|
|
51
53
|
if (value.outcome === 'candidate') {
|
|
52
|
-
if (!value.changeName || !value.changeId || !value.
|
|
54
|
+
if (!value.changeName || !value.changeId || !value.baselineDigest || !Array.isArray(value.files)) errors.push('candidate Product Change output needs its change identity, baseline digest, and files');
|
|
53
55
|
else try {
|
|
54
56
|
const checked = validateProductChangeFiles(value.files, { changeName: value.changeName });
|
|
55
|
-
if (checked.changeId !== value.changeId || checked.digest !== value.proposalDigest) errors.push('candidate Product Change identity or digest does not match its files');
|
|
57
|
+
if (checked.changeId !== value.changeId || value.proposalDigest !== undefined && checked.digest !== value.proposalDigest) errors.push('candidate Product Change identity or digest does not match its files');
|
|
56
58
|
} catch (error) { errors.push(error.message); }
|
|
57
|
-
if (!exact(value.providerValidation, ['status', 'diagnosticCount']) || value.providerValidation.status
|
|
59
|
+
if (!exact(value.providerValidation, ['status', 'diagnosticCount']) || !['pending', 'passed'].includes(value.providerValidation.status) || !Number.isSafeInteger(value.providerValidation.diagnosticCount) || value.providerValidation.diagnosticCount < 0 || value.providerValidation.status === 'pending' && value.providerValidation.diagnosticCount !== 0 || value.providerValidation.status === 'passed' && !value.proposalDigest) errors.push('candidate Product Change needs pending host validation or a bound successful provider validation');
|
|
58
60
|
}
|
|
59
61
|
if (value.outcome === 'aligned' && (value.files !== undefined || value.changeName !== undefined || value.changeId !== undefined)) errors.push('aligned Product Change output cannot include a delta');
|
|
60
|
-
|
|
62
|
+
const hasQuestion = typeof value.question === 'string' && !!value.question.trim();
|
|
63
|
+
const hasBlocker = typeof value.blocker === 'string' && !!value.blocker.trim();
|
|
64
|
+
const hasRecovery = typeof value.recovery === 'string' && !!value.recovery.trim();
|
|
65
|
+
if (value.outcome === 'blocked' && ((!hasQuestion && !hasBlocker) || (value.question !== undefined && (value.blocker !== undefined || value.recovery !== undefined)) || (hasBlocker && !hasRecovery) || value.files !== undefined)) errors.push('blocked Product Change output needs either one decision question or one technical blocker with recovery and no candidate files');
|
|
66
|
+
if (value.outcome !== 'blocked' && (value.blocker !== undefined || value.recovery !== undefined)) errors.push('only a blocked Product Change can include a technical blocker or recovery');
|
|
61
67
|
if (value.files !== undefined && value.outcome !== 'candidate') errors.push('only a candidate Product Change can include files');
|
|
62
68
|
return errors;
|
|
63
69
|
}
|
|
@@ -106,7 +112,7 @@ export function validateProductIdentity(value) {
|
|
|
106
112
|
|
|
107
113
|
export function validateProductCheckpoint(value) {
|
|
108
114
|
const fields = ['schemaVersion', 'kind', 'identity', 'requestDigest', 'changeName', 'changeId', 'proposalDigest', 'baselineDigest', 'state', 'effectIds'];
|
|
109
|
-
const optional = ['pendingQuestion'];
|
|
115
|
+
const optional = ['pendingQuestion', 'parent'];
|
|
110
116
|
if (!exact(value, [...fields, ...optional.filter(key => Object.hasOwn(value ?? {}, key))])) return ['Product Change checkpoint has unsupported or missing fields'];
|
|
111
117
|
const errors = [...validateProductIdentity(value.identity)];
|
|
112
118
|
if (value.schemaVersion !== 1 || value.kind !== 'checkpoint') errors.push('Product Change checkpoint identity is unsupported');
|
|
@@ -114,6 +120,22 @@ export function validateProductCheckpoint(value) {
|
|
|
114
120
|
for (const field of ['requestDigest', 'proposalDigest', 'baselineDigest']) if (!hashPattern.test(value[field] ?? '')) errors.push(`${field} must be SHA-256`);
|
|
115
121
|
if (!['preparing', 'waiting-product-decision', 'approved', 'applying', 'applied', 'blocked'].includes(value.state)) errors.push('Product Change checkpoint state is unsupported');
|
|
116
122
|
if (!Array.isArray(value.effectIds) || value.effectIds.length > 32 || value.effectIds.some(item => typeof item !== 'string' || !item.trim() || item.length > 256) || new Set(value.effectIds).size !== value.effectIds.length) errors.push('Product Change checkpoint effectIds are invalid');
|
|
123
|
+
if (value.parent !== undefined) {
|
|
124
|
+
const parent = value.parent;
|
|
125
|
+
if (!exact(parent, ['issue', 'stage', 'predecessor', 'gap', 'sourceRevision', 'baseRevision', 'taskDigest', 'autoImplement', ...(['feedback', 'pullRequest', 'review'].filter(key => Object.hasOwn(parent ?? {}, key)))]) || parent.issue !== value.identity?.issue || !['triage', 'refine', 'propose', 'implement'].includes(parent.stage) || !exact(parent.predecessor, ['runId', 'attempt']) || !/^[1-9][0-9]*$/.test(parent.predecessor.runId ?? '') || !Number.isSafeInteger(parent.predecessor.attempt) || parent.predecessor.attempt < 1 || !exact(parent.gap, ['summary', 'scope']) || !['summary', 'scope'].every(key => typeof parent.gap[key] === 'string' && parent.gap[key].trim() && parent.gap[key].length <= 4000 && !/<!--\s*lighthouse-/i.test(parent.gap[key])) || !revisionPattern.test(parent.sourceRevision ?? '') || !revisionPattern.test(parent.baseRevision ?? '') || !hashPattern.test(parent.taskDigest ?? '') || typeof parent.autoImplement !== 'boolean') errors.push('Product Change parent checkpoint is invalid');
|
|
126
|
+
}
|
|
127
|
+
if (value.parent?.pullRequest !== undefined) {
|
|
128
|
+
const pr = value.parent.pullRequest;
|
|
129
|
+
if (!exact(pr, ['number', 'head', 'branch']) || !Number.isSafeInteger(pr.number) || pr.number < 1 || !revisionPattern.test(pr.head ?? '') || typeof pr.branch !== 'string' || !new RegExp(`^lighthouse/(?:implement|propose)-${value.identity?.issue}-[a-f0-9]{12}$`).test(pr.branch)) errors.push('Product Change parent PR evidence is invalid');
|
|
130
|
+
}
|
|
131
|
+
if (value.parent?.review !== undefined) {
|
|
132
|
+
const review = value.parent.review;
|
|
133
|
+
if (!exact(review, ['number', 'path', 'feedbackDigest', 'threadDigest']) || !Number.isSafeInteger(review.number) || review.number < 1 || review.number !== value.parent.pullRequest?.number || typeof review.path !== 'string' || !/^(?:issues\/comments|pulls\/comments|pulls\/[1-9][0-9]*\/reviews)\/[1-9][0-9]*$/.test(review.path) || !hashPattern.test(review.feedbackDigest ?? '') || !hashPattern.test(review.threadDigest ?? '')) errors.push('Product Change parent review evidence is invalid');
|
|
134
|
+
}
|
|
135
|
+
if (value.parent?.feedback !== undefined) {
|
|
136
|
+
const feedback = value.parent.feedback;
|
|
137
|
+
if (!exact(feedback, ['runId', 'attempt', 'taskDigest', 'replyTo', 'commentId']) || !/^[1-9][0-9]*$/.test(feedback.runId ?? '') || !Number.isSafeInteger(feedback.attempt) || feedback.attempt < 1 || !hashPattern.test(feedback.taskDigest ?? '') || !hashPattern.test(feedback.replyTo ?? '') || !Number.isSafeInteger(feedback.commentId) || feedback.commentId < 1) errors.push('Product Change parent answer evidence is invalid');
|
|
138
|
+
}
|
|
117
139
|
if (value.pendingQuestion !== undefined && (typeof value.pendingQuestion !== 'string' || value.pendingQuestion.length > 4000)) errors.push('Product Change checkpoint pendingQuestion exceeds its bound');
|
|
118
140
|
return errors;
|
|
119
141
|
}
|
|
@@ -183,14 +205,17 @@ export async function prepareProductChange(root, changeName, files, bridge) {
|
|
|
183
205
|
openspec(temp, ['new', 'change', changeName, '--schema', 'product-change', '--json']);
|
|
184
206
|
const rootPath = `openspec/changes/${changeName}`;
|
|
185
207
|
if (!files) {
|
|
208
|
+
const schemaPin = await readFile(join(temp, rootPath, '.openspec.yaml'), 'utf8');
|
|
209
|
+
if ((schemaPin.match(/^skip_specs:\s*true\s*$/gm) ?? []).length !== 1) throw Error('OpenSpec scaffold must declare skip_specs exactly once');
|
|
186
210
|
const instructions = {};
|
|
187
211
|
for (const artifact of ['intent', 'delta']) {
|
|
188
212
|
const value = JSON.parse(openspec(temp, ['instructions', artifact, '--change', changeName, '--json']));
|
|
189
213
|
instructions[artifact] = { outputPath: value.outputPath, instruction: value.instruction, template: value.template };
|
|
190
214
|
}
|
|
191
|
-
return { provider: '@fission-ai/openspec@1.15.0', schema: 'product-change', root: rootPath, changeName, instructions };
|
|
215
|
+
return { provider: '@fission-ai/openspec@1.15.0', schema: 'product-change', root: rootPath, changeName, schemaPin, instructions };
|
|
192
216
|
}
|
|
193
217
|
const summary = validateProductChangeFiles(files, { changeName });
|
|
218
|
+
const schemaPin = files.find(file => file.path === `${rootPath}/.openspec.yaml`).content;
|
|
194
219
|
for (const file of files) {
|
|
195
220
|
const target = join(temp, file.path);
|
|
196
221
|
await mkdir(dirname(target), { recursive: true });
|
|
@@ -203,8 +228,8 @@ export async function prepareProductChange(root, changeName, files, bridge) {
|
|
|
203
228
|
const value = JSON.parse(openspec(temp, ['instructions', artifact, '--change', changeName, '--json']));
|
|
204
229
|
instructions[artifact] = { outputPath: value.outputPath, instruction: value.instruction, template: value.template };
|
|
205
230
|
}
|
|
206
|
-
if (bridge && validation) return { ...summary, provider: '@prodshape/integration-openspec', root: rootPath, openSpec: 'passed', diagnostics: validation.diagnostics, instructions, files };
|
|
207
|
-
return { ...summary, root: rootPath, openSpec: 'passed', provider: 'unavailable', instructions, files };
|
|
231
|
+
if (bridge && validation) return { ...summary, provider: '@prodshape/integration-openspec', root: rootPath, openSpec: 'passed', diagnostics: validation.diagnostics, schemaPin, instructions, files };
|
|
232
|
+
return { ...summary, root: rootPath, openSpec: 'passed', provider: 'unavailable', schemaPin, instructions, files };
|
|
208
233
|
} finally { await rm(temp, { recursive: true, force: true }); }
|
|
209
234
|
}
|
|
210
235
|
|
|
@@ -215,12 +240,45 @@ export async function loadProductBridge({ entry = process.env.PRODSHAPE_INTEGRAT
|
|
|
215
240
|
return bridge;
|
|
216
241
|
}
|
|
217
242
|
|
|
243
|
+
export async function loadProductCore() {
|
|
244
|
+
const entry = require.resolve('@prodshape/core');
|
|
245
|
+
const metadata = JSON.parse(await readFile(join(dirname(dirname(entry)), 'package.json'), 'utf8'));
|
|
246
|
+
if (metadata.name !== '@prodshape/core' || metadata.version !== '0.25.0') throw Error('Pinned ProductShape core 0.25.0 is unavailable');
|
|
247
|
+
const core = await import(pathToFileURL(entry).href);
|
|
248
|
+
for (const name of ['openRepository', 'validateBaseline', 'inspectArtifact', 'analyzeImpact', 'emitCitation']) if (typeof core[name] !== 'function') throw Error(`Pinned ProductShape core does not provide ${name}`);
|
|
249
|
+
return { ...core, version: metadata.version };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export async function prepareProductEvidence(root, destination, core, { baselineDigest } = {}) {
|
|
253
|
+
if (baselineDigest && await productModelDigest(root) !== baselineDigest) throw Error('ProductShape baseline changed before evidence preparation');
|
|
254
|
+
const repository = await core.openRepository(resolve(root));
|
|
255
|
+
const baseline = await core.validateBaseline(repository);
|
|
256
|
+
const errors = baseline.diagnostics.filter(item => item.severity === 'error');
|
|
257
|
+
if (errors.length) throw Error(`ProductShape baseline has ${errors.length} blocking diagnostic(s); validate the adopter model before retrying`);
|
|
258
|
+
const nodes = baseline.graph.nodes;
|
|
259
|
+
if (!Array.isArray(nodes) || nodes.length > 512) throw Error('ProductShape evidence exceeds the artifact bound');
|
|
260
|
+
await mkdir(destination, { recursive: true });
|
|
261
|
+
const index = []; let bytes = 0;
|
|
262
|
+
for (const node of nodes) {
|
|
263
|
+
if (!/^[A-Z0-9][A-Z0-9-]{0,159}$/.test(node.id ?? '')) throw Error('ProductShape evidence has an unsafe artifact identity');
|
|
264
|
+
const report = { inspect: core.inspectArtifact(baseline.graph, node.id), impact: core.analyzeImpact(baseline.graph, node.id), citation: core.emitCitation({ id: node.id, digest: node.digest, form: 'payload' }) };
|
|
265
|
+
const content = JSON.stringify(report, null, 2) + '\n'; bytes += Buffer.byteLength(content);
|
|
266
|
+
if (Buffer.byteLength(content) > 512 * 1024 || bytes > 16 * 1024 * 1024) throw Error('ProductShape evidence exceeds the byte bound');
|
|
267
|
+
await writeFile(join(destination, `${node.id}.json`), content, { flag: 'wx' });
|
|
268
|
+
index.push({ id: node.id, type: node.type, title: node.title, report: `${node.id}.json` });
|
|
269
|
+
}
|
|
270
|
+
if (baselineDigest && await productModelDigest(root) !== baselineDigest) throw Error('ProductShape baseline changed during evidence preparation');
|
|
271
|
+
await writeFile(join(destination, 'index.json'), JSON.stringify({ schemaVersion: 1, source: '@prodshape/core', version: core.version, ...(baselineDigest ? { baselineDigest } : {}), artifacts: index }, null, 2) + '\n', { flag: 'wx' });
|
|
272
|
+
return { artifacts: index.length, bytes };
|
|
273
|
+
}
|
|
274
|
+
|
|
218
275
|
export function productReviewBinding(input) {
|
|
219
|
-
const fields = ['repository', 'issue', 'pullRequest', 'changeName', 'changeId', 'proposalDigest', 'baselineDigest', 'policyDigest', 'authorityDigest', 'head'];
|
|
276
|
+
const fields = ['repository', 'issue', 'pullRequest', 'changeName', 'changeId', 'proposalDigest', 'baselineDigest', 'policyDigest', 'authorityDigest', 'head', ...(Object.hasOwn(input ?? {}, 'parentDigest') ? ['parentDigest'] : [])];
|
|
220
277
|
if (!exact(input, fields)) throw Error('Product review binding has unsupported or missing fields');
|
|
221
278
|
if (typeof input.repository !== 'string' || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(input.repository) || !Number.isSafeInteger(input.issue) || input.issue < 1 || !Number.isSafeInteger(input.pullRequest) || input.pullRequest < 1) throw Error('Product review target is invalid');
|
|
222
279
|
if (!namePattern.test(input.changeName) || !/^CHG-[A-Z0-9-]+$/.test(input.changeId)) throw Error('Product review change identity is invalid');
|
|
223
280
|
for (const field of ['proposalDigest', 'baselineDigest', 'policyDigest', 'authorityDigest']) if (!hashPattern.test(input[field] ?? '')) throw Error(`Product review ${field} is invalid`);
|
|
281
|
+
if (input.parentDigest !== undefined && !hashPattern.test(input.parentDigest)) throw Error('Product review parent digest is invalid');
|
|
224
282
|
if (!revisionPattern.test(input.head ?? '')) throw Error('Product review head must be a full commit SHA');
|
|
225
283
|
const key = digest(JSON.stringify(input));
|
|
226
284
|
return { ...input, key };
|
|
@@ -275,3 +333,26 @@ export function verifyProductChangePath(root, relativePath) {
|
|
|
275
333
|
return absolute;
|
|
276
334
|
});
|
|
277
335
|
}
|
|
336
|
+
|
|
337
|
+
export function checkProductResumePullRequest(pr, repository, resume) {
|
|
338
|
+
if (!resume || pr?.number !== resume.pullRequest || pr.state !== 'open' || pr.merged || !pr.draft || pr.user?.login !== 'github-actions[bot]' || pr.user?.type !== 'Bot' || pr.head?.repo?.full_name !== repository || pr.base?.repo?.full_name !== repository || pr.head.sha !== resume.head || pr.head.ref !== resume.branch) throw Error('Applied product PR changed before parent continuation');
|
|
339
|
+
const matches = [...(pr.body ?? '').matchAll(/<!-- lighthouse-product-change(?:-checkpoint|:checkpoint):(\{[^\n]*\}) -->/g)];
|
|
340
|
+
if (matches.length !== 1) throw Error('Applied product checkpoint is unavailable');
|
|
341
|
+
const checkpoint = JSON.parse(matches[0][1]);
|
|
342
|
+
if (validateProductCheckpoint(checkpoint).length || checkpoint.state !== 'applied' || checkpoint.identity.issue !== resume.issue || checkpoint.identity.head !== resume.head || JSON.stringify(checkpoint.parent) !== JSON.stringify(resume.parent) || !checkpoint.effectIds.includes(`apply:${resume.receiptIntent}:${resume.head}`)) throw Error('Applied product checkpoint changed before parent continuation');
|
|
343
|
+
return checkpoint;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function productDeliveryProof(pr, kind) {
|
|
347
|
+
const expression = new RegExp(`<!-- lighthouse-product-${kind}:(\\{[^\\n]*\\}) -->`, 'g');
|
|
348
|
+
const proofs = [...(pr?.body ?? '').matchAll(expression)];
|
|
349
|
+
const checkpoints = [...(pr?.body ?? '').matchAll(/<!-- lighthouse-product-change(?:-checkpoint|:checkpoint):(\{[^\n]*\}) -->/g)];
|
|
350
|
+
if (proofs.length !== 1 || checkpoints.length !== 1) return null;
|
|
351
|
+
let proof, checkpoint;
|
|
352
|
+
try { proof = JSON.parse(proofs[0][1]); checkpoint = JSON.parse(checkpoints[0][1]); } catch { return null; }
|
|
353
|
+
if (!exact(proof, ['schemaVersion', 'issue', 'runId', 'applyIntent', 'head']) || proof.schemaVersion !== 1 || !Number.isSafeInteger(proof.issue) || proof.issue < 1 || !hashPattern.test(proof.runId ?? '') || !hashPattern.test(proof.applyIntent ?? '') || proof.head !== pr.head?.sha || validateProductCheckpoint(checkpoint).length || checkpoint.state !== 'applied' || checkpoint.identity.issue !== proof.issue || !checkpoint.parent || !checkpoint.effectIds.includes(`apply:${proof.applyIntent}:${checkpoint.identity.head}`)) return null;
|
|
354
|
+
return proof;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export const productImplementationProof = pr => productDeliveryProof(pr, 'implementation');
|
|
358
|
+
export const productProposalProof = pr => productDeliveryProof(pr, 'proposal');
|
package/src/proposal-review.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { digest } from './host-contract.mjs';
|
|
2
2
|
import { proposalRoot, validateProposalFiles } from './proposal.mjs';
|
|
3
|
+
import { productImplementationProof, productProposalProof } from './product-change.mjs';
|
|
3
4
|
|
|
4
5
|
export const feedbackKey = review => digest(`${review.number}:${review.kind}:${review.id}:${review.feedbackDigest}`);
|
|
5
6
|
export const feedbackWorker = review => review && !review.approved ? 'apply-review' : undefined;
|
|
@@ -11,17 +12,21 @@ export function reviewTarget(event, eventName) {
|
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
export function checkProposal(pr, repo, expected) {
|
|
14
|
-
const match = pr.head?.ref?.match(/^lighthouse\/propose-([1-9][0-9]*)-([a-f0-9]{12})$/);
|
|
15
|
+
const match = pr.head?.ref?.match(/^lighthouse\/(?:propose|implement)-([1-9][0-9]*)-([a-f0-9]{12})$/);
|
|
15
16
|
const marker = pr.body?.match(/<!-- lighthouse-proposal:([a-f0-9]{64}) -->/)?.[1];
|
|
16
|
-
|
|
17
|
+
const productProof = productProposalProof(pr);
|
|
18
|
+
const standard = pr.head?.ref?.startsWith('lighthouse/propose-') && !/<!-- lighthouse-product-change(?:-checkpoint|:checkpoint):/.test(pr.body ?? '') && marker?.startsWith(match?.[2] ?? 'invalid');
|
|
19
|
+
if (!match || !(standard || productProof?.issue === Number(match[1])) || pr.user?.login !== 'github-actions[bot]' || pr.user.type !== 'Bot' || pr.state !== 'open' || pr.merged || pr.head.repo?.full_name !== repo || pr.base.repo?.full_name !== repo) throw Error('Not an open repository-owned Lighthouse proposal');
|
|
17
20
|
if (expected && (pr.head.sha !== expected.head || pr.head.ref !== expected.branch || Number(match[1]) !== expected.issue || pr.base.ref !== expected.base)) throw Error('Proposal changed during review; no update published');
|
|
18
|
-
return { number: pr.number, issue: Number(match[1]), head: pr.head.sha, branch: pr.head.ref, base: pr.base.ref, url: pr.html_url };
|
|
21
|
+
return { number: pr.number, issue: Number(match[1]), head: pr.head.sha, branch: pr.head.ref, base: pr.base.ref, url: pr.html_url, ...(productProof ? { productProposal: true } : {}) };
|
|
19
22
|
}
|
|
20
23
|
|
|
21
24
|
export function checkImplementationPullRequest(pr, repo, expected) {
|
|
22
|
-
const match = pr.head?.ref?.match(/^lighthouse\/implement-([1-9][0-9]*)-([a-f0-9]{12})$/);
|
|
25
|
+
const match = pr.head?.ref?.match(/^lighthouse\/(?:implement|propose)-([1-9][0-9]*)-([a-f0-9]{12})$/);
|
|
23
26
|
const marker = pr.body?.match(/<!-- lighthouse-implementation:([a-f0-9]{64}) -->/)?.[1];
|
|
24
|
-
|
|
27
|
+
const productProof = productImplementationProof(pr);
|
|
28
|
+
const standard = pr.head?.ref?.startsWith('lighthouse/implement-') && !pr.body?.includes('<!-- lighthouse-product-change:') && !/<!-- lighthouse-product-change(?:-checkpoint|:checkpoint):/.test(pr.body ?? '') && marker?.startsWith(match?.[2] ?? 'invalid');
|
|
29
|
+
if (!match || !(standard || productProof?.issue === Number(match[1])) || pr.user?.login !== 'github-actions[bot]' || pr.user.type !== 'Bot' || pr.state !== 'open' || pr.merged || pr.head.repo?.full_name !== repo || pr.base.repo?.full_name !== repo) throw Error('Not an open repository-owned implementation PR');
|
|
25
30
|
if (expected && (pr.head.sha !== expected.head || pr.head.ref !== expected.branch || Number(match[1]) !== expected.issue || pr.base.ref !== expected.base)) throw Error('Implementation changed during review');
|
|
26
31
|
return { number: pr.number, issue: Number(match[1]), head: pr.head.sha, branch: pr.head.ref, base: pr.base.ref, url: pr.html_url };
|
|
27
32
|
}
|
|
@@ -60,9 +65,10 @@ export async function reviewThread(api, number, owner) {
|
|
|
60
65
|
export async function loadProposalFiles(api, review) {
|
|
61
66
|
const changed = await api(`pulls/${review.number}/files?per_page=100`);
|
|
62
67
|
const root = proposalRoot(review.issue);
|
|
63
|
-
if (changed.length >= 100 || changed.some(f => !f.filename.startsWith(`${root}/`) || f.status === 'removed' || f.status === 'renamed')) throw Error('Proposal PR includes
|
|
68
|
+
if (changed.length >= 100 || changed.some(f => (!review.productProposal || f.filename.startsWith(`${root}/`)) && (!f.filename.startsWith(`${root}/`) || f.status === 'removed' || f.status === 'renamed'))) throw Error('Proposal PR includes unsupported proposal changes');
|
|
64
69
|
const files = [];
|
|
65
70
|
for (const f of changed) {
|
|
71
|
+
if (!f.filename.startsWith(`${root}/`)) continue;
|
|
66
72
|
const value = await api(`contents/${f.filename}?ref=${review.head}`);
|
|
67
73
|
if (value.type !== 'file' || value.encoding !== 'base64' || value.size > 96000) throw Error('Unsupported proposal file');
|
|
68
74
|
const content = Buffer.from(value.content, 'base64').toString('utf8');
|
|
@@ -86,7 +92,11 @@ export async function updateProposal(api, repo, snapshot, files, beforeWrite) {
|
|
|
86
92
|
const created = await api('git/commits', 'POST', { message: `docs: address proposal review for #${snapshot.issue}`, tree: tree.sha, parents: [review.head] });
|
|
87
93
|
await beforeWrite();
|
|
88
94
|
await checkReviewCurrent(api, repo, review);
|
|
95
|
+
const current = review.productProposal ? await api(`pulls/${review.number}`) : null;
|
|
96
|
+
const proof = current ? productProposalProof(current) : null;
|
|
97
|
+
if (review.productProposal && (!proof || current.head.sha !== review.head)) throw Error('Product proposal changed before publication');
|
|
89
98
|
// Non-forced update rejects a concurrent writer after the final head check.
|
|
90
99
|
await api(`git/refs/heads/${review.branch}`, 'PATCH', { sha: created.sha, force: false });
|
|
100
|
+
if (proof) await api(`pulls/${review.number}`, 'PATCH', { body: current.body.replace(/<!-- lighthouse-product-proposal:\{[^\n]*\} -->/g, `<!-- lighthouse-product-proposal:${JSON.stringify({ ...proof, runId: snapshot.runId, head: created.sha })} -->`) });
|
|
91
101
|
return { number: review.number, html_url: review.url };
|
|
92
102
|
}
|
package/src/proposal.mjs
CHANGED
|
@@ -3,6 +3,8 @@ import { mkdtemp, mkdir, readFile, writeFile, rm } from 'node:fs/promises';
|
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
5
|
import { execFileSync } from 'node:child_process';
|
|
6
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
7
|
+
import { checkProductResumePullRequest } from './product-change.mjs';
|
|
6
8
|
|
|
7
9
|
export const changeName = issue => {
|
|
8
10
|
if (!Number.isSafeInteger(issue) || issue < 1) throw Error('Invalid proposal issue');
|
|
@@ -89,7 +91,8 @@ export async function loadStageReport(api, repo, report, current) {
|
|
|
89
91
|
if (request.dispatch) {
|
|
90
92
|
if (!['triage', 'refine', 'propose', 'implement'].includes(request.stage) || !report.body.includes(`<!-- lighthouse-stage:${request.stage}:`)) throw Error('Unexpected dispatched stage');
|
|
91
93
|
const dispatch = await loadDispatch(api, repo, current.owner, current.issue, request.dispatch.commentId, run);
|
|
92
|
-
|
|
94
|
+
const dispatchedRevision = request.epic && request.stage === 'implement' ? request.baseRevision : request.sourceRevision;
|
|
95
|
+
if (dispatch.request.stage !== request.stage || dispatch.request.id !== request.dispatch.requestId || dispatch.request.sourceRevision !== dispatchedRevision || dispatch.request.workflowRevision !== request.workflowRevision || !isDeepStrictEqual(dispatch.request.epic, request.epic)) throw Error('Dispatched report identity mismatch');
|
|
93
96
|
await checkDispatchClaim(api, repo, current.issue, request.dispatch, run.id);
|
|
94
97
|
} else if (!['issues', 'issue_comment', 'workflow_dispatch'].includes(run.event) || run.path !== '.github/workflows/lighthouse-sdlc-router.yml' || run.actor?.login !== current.owner || run.head_sha !== request.sourceRevision) throw Error('Stage report identity mismatch');
|
|
95
98
|
if (run.status !== 'completed' || run.conclusion !== 'success' || String(run.id) !== String(request.workflowRunId) || request.issue !== current.issue || marker !== request.runId) throw Error('Stage report identity mismatch');
|
|
@@ -111,6 +114,15 @@ export async function createProposalPullRequest(api, snapshot, files, body, runU
|
|
|
111
114
|
const tree = await api('git/trees', 'POST', { base_tree: commit.tree.sha, tree: files.map(file => ({ path: file.path, mode: '100644', type: 'blob', content: file.content })) });
|
|
112
115
|
const created = await api('git/commits', 'POST', { message: `docs: propose issue #${snapshot.issue}`, tree: tree.sha, parents: [snapshot.sourceRevision] });
|
|
113
116
|
await beforeWrite();
|
|
117
|
+
if (snapshot.productResume) {
|
|
118
|
+
const resume = snapshot.productResume;
|
|
119
|
+
const current = await api(`pulls/${resume.pullRequest}`);
|
|
120
|
+
checkProductResumePullRequest(current, snapshot.repository, resume);
|
|
121
|
+
await api(`git/refs/heads/${resume.branch}`, 'PATCH', { sha: created.sha, force: false });
|
|
122
|
+
const proof = { schemaVersion: 1, issue: snapshot.issue, runId: snapshot.runId, applyIntent: resume.receiptIntent, head: created.sha };
|
|
123
|
+
await api(`pulls/${resume.pullRequest}`, 'PATCH', { body: `${current.body}\n\n${body.trim()}\n\n[Proposal evidence](${runUrl}).\n\n<!-- lighthouse-product-proposal:${JSON.stringify(proof)} -->` });
|
|
124
|
+
return { number: resume.pullRequest, html_url: current.html_url };
|
|
125
|
+
}
|
|
114
126
|
await api('git/refs', 'POST', { ref: `refs/heads/${branch}`, sha: created.sha });
|
|
115
127
|
const pr = await api('pulls', 'POST', { title: `Proposal for #${snapshot.issue}: ${snapshot.title.slice(0, 160)}`, head: branch, base: snapshot.defaultBranch, body: `${body}\n\nRelated to #${snapshot.issue}. Proposal only; implementation requires a separate owner decision.\n\nOpenSpec 1.15.0 strict validation passed. [Execution evidence](${runUrl}).\n\n<!-- lighthouse-proposal:${snapshot.runId} -->` });
|
|
116
128
|
return pr;
|
package/src/stacked.mjs
CHANGED
|
@@ -14,6 +14,11 @@ export function buildStackedPlan(input) {
|
|
|
14
14
|
const errors = [];
|
|
15
15
|
if (!isRecord(input) || !isRecord(input.parent) || !Array.isArray(input.slices)) return { schemaVersion: 1, status: 'invalid', valid: false, canApply: false, errors: [issue('stacked.invalid', 'stacked input needs parent and slices')] };
|
|
16
16
|
const parentRef = input.parent.number ?? input.parent.id;
|
|
17
|
+
const epic = input.mode === 'epic';
|
|
18
|
+
if (epic && input.slices.length === 0) errors.push(issue('stacked.empty', 'epic mode requires at least one child', 'slices'));
|
|
19
|
+
if (input.mode !== undefined && !['epic', 'nested'].includes(input.mode)) errors.push(issue('stacked.mode', 'mode must be epic or nested', 'mode'));
|
|
20
|
+
const epicParent = String(parentRef ?? '').match(/^(?:GH-)?([1-9]\d*)$/)?.[1];
|
|
21
|
+
if (epic && (!epicParent || input.parent.integrationBranch !== `lighthouse/epic-${epicParent}` || input.parent.integrationBranch === (input.parent.base ?? 'main'))) errors.push(issue('stacked.integration-branch', 'epic mode needs the integration branch for its parent issue', 'parent.integrationBranch'));
|
|
17
22
|
if (parentRef === undefined || parentRef === null || String(parentRef).trim() === '') errors.push(issue('stacked.parent', 'parent needs an id or number', 'parent'));
|
|
18
23
|
const seenBranches = new Set();
|
|
19
24
|
const slices = input.slices.map((slice, index) => {
|
|
@@ -23,9 +28,14 @@ export function buildStackedPlan(input) {
|
|
|
23
28
|
if (!branch) { errors.push(issue('stacked.branch', `slice ${slice.id} needs an explicit branch when the parent has no numeric issue id`, `slices[${index}].branch`)); return null; }
|
|
24
29
|
if (seenBranches.has(branch)) errors.push(issue('stacked.branch-duplicate', `branch ${branch} appears more than once`, `slices[${index}].branch`));
|
|
25
30
|
seenBranches.add(branch);
|
|
26
|
-
|
|
31
|
+
if (epic && slice.base !== undefined && slice.base !== input.parent.integrationBranch) errors.push(issue('stacked.epic-base', 'epic children must target the shared integration branch', `slices[${index}].base`));
|
|
32
|
+
const strings = value => Array.isArray(value) && value.every(item => typeof item === 'string' && item.trim());
|
|
33
|
+
if ((epic || slice.dependencies != null) && !strings(slice.dependencies)) errors.push(issue('stacked.dependencies', 'dependencies must be a string array', `slices[${index}].dependencies`));
|
|
34
|
+
if (slice.acceptance != null && !strings(slice.acceptance)) errors.push(issue('stacked.acceptance', 'acceptance must be a string array', `slices[${index}].acceptance`));
|
|
35
|
+
return { id: slice.id, title: slice.title, branch, base: slice.base ?? null, dependencies: strings(slice.dependencies) ? [...slice.dependencies].sort() : [], acceptance: strings(slice.acceptance) ? [...slice.acceptance] : [] };
|
|
27
36
|
}).filter(Boolean);
|
|
28
37
|
const byId = new Map(slices.map((slice) => [slice.id, slice]));
|
|
38
|
+
if (byId.size !== slices.length) errors.push(issue('stacked.duplicate', 'slice identities must be unique', 'slices'));
|
|
29
39
|
const order = [];
|
|
30
40
|
const visiting = new Set();
|
|
31
41
|
const visited = new Set();
|
|
@@ -44,7 +54,8 @@ export function buildStackedPlan(input) {
|
|
|
44
54
|
for (const slice of slices) visit(slice.id);
|
|
45
55
|
const operations = order.map((id, index) => {
|
|
46
56
|
const slice = byId.get(id);
|
|
47
|
-
return { type: 'open-pull-request', sliceId: id, branch: slice.branch, base: slice.base ?? (index === 0 ? input.parent.base ?? 'main' : slices.find((candidate) => candidate.id === order[index - 1])?.branch) };
|
|
57
|
+
return { type: 'open-pull-request', sliceId: id, branch: slice.branch, base: epic ? input.parent.integrationBranch : slice.base ?? (index === 0 ? input.parent.base ?? 'main' : slices.find((candidate) => candidate.id === order[index - 1])?.branch) };
|
|
48
58
|
});
|
|
49
|
-
|
|
59
|
+
if (epic) operations.push({ type: 'open-aggregate-pull-request', branch: input.parent.integrationBranch, base: input.parent.base ?? 'main', requires: [...order] });
|
|
60
|
+
return { schemaVersion: 1, status: errors.length ? 'invalid' : 'ready', valid: errors.length === 0, canApply: errors.length === 0, parent: input.parent, slices, order, operations: errors.length ? [] : operations, errors };
|
|
50
61
|
}
|
package/src/stage-dispatch.mjs
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { digest } from './host-contract.mjs';
|
|
2
2
|
import { readActionsJson } from './actions-artifact.mjs';
|
|
3
|
+
import { handoff } from './handoff.mjs';
|
|
4
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
3
5
|
|
|
4
6
|
const routerPath = '.github/workflows/lighthouse-sdlc-router.yml';
|
|
5
7
|
export const stages = ['triage', 'refine', 'propose', 'implement', 'integrate', 'review'];
|
|
@@ -7,13 +9,15 @@ export const workers = [...stages, 'apply-review', 'product-change', 'apply-prod
|
|
|
7
9
|
export const workerFor = request => request.worker ?? request.stage ?? 'implement';
|
|
8
10
|
export const stagePath = stage => { if (!workers.includes(stage)) throw Error('Unknown dispatch worker'); return `.github/workflows/lighthouse-sdlc-${stage}.lock.yml`; };
|
|
9
11
|
export const implementPath = stagePath('implement');
|
|
10
|
-
export const statusText = (stage, status) => `${({ requested: '
|
|
12
|
+
export const statusText = (stage, status) => `${({ requested: 'Requested', running: 'Queued', completed: 'Stage completed', waiting: 'Waiting for your answer', blocked: 'Blocked', failed: 'Execution failed', cancelled: 'Cancelled', unknown: 'Execution status unknown' })[status] ?? 'Execution status unknown'} · ${stage}`;
|
|
11
13
|
const positive = value => /^[1-9][0-9]*$/.test(String(value));
|
|
12
|
-
export const isBotContinuation = (event, eventName, actor, triggeringActor) => eventName === 'workflow_dispatch' && actor === 'github-actions[bot]' && triggeringActor === actor && event.inputs?.stage === 'implement' && positive(event.inputs?.issue) && positive(event.inputs?.after_run);
|
|
14
|
+
export const isBotContinuation = (event, eventName, actor, triggeringActor) => eventName === 'workflow_dispatch' && actor === 'github-actions[bot]' && triggeringActor === actor && (event.inputs?.stage === 'implement' && event.inputs?.workflow !== 'product-change' || ['triage', 'refine', 'propose', 'implement'].includes(event.inputs?.stage) && ['product-change', 'resume-product'].includes(event.inputs?.workflow)) && positive(event.inputs?.issue) && positive(event.inputs?.after_run);
|
|
15
|
+
export const isBotEpicTriage = (event, eventName, actor, triggeringActor) => eventName === 'workflow_dispatch' && actor === 'github-actions[bot]' && triggeringActor === actor && event.inputs?.stage === 'triage' && positive(event.inputs?.issue) && positive(event.inputs?.epic_parent) && positive(event.inputs?.epic_run);
|
|
13
16
|
const sha = value => /^[a-f0-9]{40}$/.test(value ?? '');
|
|
14
17
|
const hash = value => /^[a-f0-9]{64}$/.test(value ?? '');
|
|
15
18
|
export const dispatchId = (repository, runId, stage = 'implement') => digest(`${repository}:${runId}:${stage}`);
|
|
16
|
-
export const
|
|
19
|
+
export const epicTriageKey = (repository, parent, child, planDigest) => digest(`${repository}:epic-triage:${parent}:${child}:${planDigest}`);
|
|
20
|
+
export const receiptId = (repository, receipt) => receipt.recovery ? digest(`${repository}:${receipt.recovery.rootId}:retry:${receipt.recovery.attempt}`) : dispatchId(repository, receipt.epicKey ?? receipt.feedbackKey ?? receipt.eventRunId ?? receipt.routerRunId, receipt.stage ?? 'implement');
|
|
17
21
|
export const dispatchArtifact = request => `lighthouse-dispatch-${request.id}`;
|
|
18
22
|
|
|
19
23
|
export function readDispatchReceipt(comment, repository, issue) {
|
|
@@ -22,16 +26,28 @@ export function readDispatchReceipt(comment, repository, issue) {
|
|
|
22
26
|
if (matches.length !== 1) throw Error('Missing or ambiguous dispatch receipt');
|
|
23
27
|
const receipt = JSON.parse(matches[0][1]);
|
|
24
28
|
if (receipt.recovery && (!hash(receipt.recovery.rootId) || !positive(receipt.recovery.previousComment) || !Number.isSafeInteger(receipt.recovery.attempt) || receipt.recovery.attempt < 1 || receipt.recovery.attempt > 2)) throw Error('Invalid recovery receipt');
|
|
25
|
-
if (receipt.schemaVersion !== 1 || !hash(receipt.id) || !hash(receipt.digest) || !positive(receipt.routerRunId) || !positive(receipt.routerAttempt) || !stages.includes(receipt.stage ?? 'implement') || !workers.includes(workerFor(receipt)) || (receipt.feedbackKey !== undefined && !hash(receipt.feedbackKey)) || (receipt.eventRunId !== undefined && !positive(receipt.eventRunId)) || receipt.id !== receiptId(repository, receipt) || (receipt.workerRunId !== null && !positive(receipt.workerRunId))) throw Error('Invalid dispatch receipt');
|
|
29
|
+
if (receipt.schemaVersion !== 1 || !hash(receipt.id) || !hash(receipt.digest) || !positive(receipt.routerRunId) || !positive(receipt.routerAttempt) || !stages.includes(receipt.stage ?? 'implement') || !workers.includes(workerFor(receipt)) || (receipt.epicKey !== undefined && !hash(receipt.epicKey)) || (receipt.feedbackKey !== undefined && !hash(receipt.feedbackKey)) || (receipt.eventRunId !== undefined && !positive(receipt.eventRunId)) || receipt.id !== receiptId(repository, receipt) || (receipt.workerRunId !== null && !positive(receipt.workerRunId))) throw Error('Invalid dispatch receipt');
|
|
26
30
|
return receipt;
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
export function dispatchReceiptBody(repository, receipt) {
|
|
30
34
|
const root = `https://github.com/${repository}/actions/runs/`;
|
|
31
|
-
const
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
+
const status = receipt.status ?? (receipt.workerRunId ? 'running' : 'requested');
|
|
36
|
+
const states = {
|
|
37
|
+
requested: ['requested', 'The request is recorded; execution is not confirmed.', 'None. Wait for admission.', 'The workflow will report the outcome when the request is processed.'],
|
|
38
|
+
running: ['queued', 'The worker admitted the request; agent execution is not yet confirmed.', 'None. Wait for the result.', 'The workflow will publish a result or an actionable stop.'],
|
|
39
|
+
completed: ['completed', 'The stage published a validated result.', 'Follow the latest stage handoff.', 'The configured continuation applies only when authorized.'],
|
|
40
|
+
waiting: ['blocked', 'The stage published a blocked result.', 'Follow the latest stage handoff.', 'Continuation depends on the recorded blocker and authorization.'],
|
|
41
|
+
blocked: ['blocked', 'The stage published a blocker without a pending owner question.', 'The workflow maintainer should follow the latest stage handoff.', 'Work remains stopped until the blocker is resolved and continuation is authorized.'],
|
|
42
|
+
failed: ['failed', 'This attempt did not publish a validated result.', 'The workflow maintainer should inspect the execution and recovery policy.', 'Any retry requires the configured authorization.'],
|
|
43
|
+
cancelled: ['cancelled', 'This attempt was cancelled before a validated result was published.', 'The workflow maintainer should inspect the execution.', 'Any restart requires the configured authorization.'],
|
|
44
|
+
unknown: ['unknown', 'Publication of a validated result could not be confirmed.', 'The workflow maintainer should inspect the execution.', 'Reconcile the outcome before requesting another attempt.'],
|
|
45
|
+
};
|
|
46
|
+
const item = states[status];
|
|
47
|
+
if (!item) throw Error('Unknown dispatch status');
|
|
48
|
+
const recovery = receipt.recovery ? `\nRetry ${receipt.recovery.attempt}/2 · [Previous request](https://github.com/${repository}/issues/${receipt.issue ?? ''}#issuecomment-${receipt.recovery.previousComment})` : '';
|
|
49
|
+
const details = `[Dispatch origin](${root}${receipt.routerRunId})${receipt.workerRunId ? ` · [Execution](${root}${receipt.workerRunId})` : ''}${recovery}\n\n<!-- lighthouse-dispatch:${JSON.stringify(receipt)} -->`;
|
|
50
|
+
return handoff({ status: item[0], stage: workerFor(receipt), happened: item[1], action: item[2], next: item[3], details });
|
|
35
51
|
}
|
|
36
52
|
|
|
37
53
|
export async function issueComments(api, issue) {
|
|
@@ -57,7 +73,7 @@ export async function recordDispatch(api, repository, request) {
|
|
|
57
73
|
const run = await api(`actions/runs/${receipt.workerRunId}`);
|
|
58
74
|
if (run.path !== stagePath(workerFor(receipt)) || run.status !== 'completed' && request.queue !== true) throw Error('A stage execution is still pending or running');
|
|
59
75
|
}
|
|
60
|
-
const receipt = { schemaVersion: 1, stage: request.stage, ...(request.recovery ? { recovery: request.recovery, issue: request.issue } : {}), ...(request.worker ? { worker: request.worker } : {}), ...(request.feedbackKey ? { feedbackKey: request.feedbackKey } : {}), status: 'requested', ...(request.eventRunId ? { eventRunId: request.eventRunId } : {}), id: request.id, digest: digest(JSON.stringify(request)), routerRunId: request.routerRunId, routerAttempt: request.routerAttempt, workerRunId: null };
|
|
76
|
+
const receipt = { schemaVersion: 1, stage: request.stage, ...(request.recovery ? { recovery: request.recovery, issue: request.issue } : {}), ...(request.worker ? { worker: request.worker } : {}), ...(request.feedbackKey ? { feedbackKey: request.feedbackKey } : {}), ...(request.epicKey ? { epicKey: request.epicKey } : {}), status: 'requested', ...(request.eventRunId ? { eventRunId: request.eventRunId } : {}), id: request.id, digest: digest(JSON.stringify(request)), routerRunId: request.routerRunId, routerAttempt: request.routerAttempt, workerRunId: null };
|
|
61
77
|
const comment = await api(`issues/${request.issue}/comments`, 'POST', { body: dispatchReceiptBody(repository, receipt) });
|
|
62
78
|
return { send: true, comment, receipt };
|
|
63
79
|
}
|
|
@@ -67,7 +83,20 @@ export async function sendDispatch(api, repository, request, commentId) {
|
|
|
67
83
|
if (receipt.id !== request.id || receipt.digest !== digest(JSON.stringify(request))) throw Error('Dispatch request changed');
|
|
68
84
|
if (receipt.workerRunId) return;
|
|
69
85
|
// Never retry this POST automatically: its response can be lost after admission.
|
|
70
|
-
|
|
86
|
+
const parent = epicLeaseParent(request);
|
|
87
|
+
return api(`actions/workflows/lighthouse-sdlc-${workerFor(request)}.lock.yml/dispatches`, 'POST', { ref: request.ref, inputs: { issue: String(request.issue), 'dispatch-request': String(commentId), ...(parent ? { 'epic-parent': String(parent) } : {}), ...(workerFor(request) === 'apply-product-change' ? { 'pull-request': String(request.productApproval.pullRequest), head: request.productApproval.head } : {}) } });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function epicLeaseParent(request) {
|
|
91
|
+
if (request.epic === undefined) return null;
|
|
92
|
+
const epic = request.epic;
|
|
93
|
+
if (!epic || !Number.isSafeInteger(epic.parent) || epic.parent < 1 || epic.parent === request.issue || !(['triage', 'refine', 'implement'].includes(workerFor(request)) || workerFor(request) === 'apply-review' && request.stage === 'implement') || epic.base !== `lighthouse/epic-${epic.parent}` || !hash(epic.planDigest)) throw Error('Invalid epic dispatch lease');
|
|
94
|
+
return epic.parent;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function checkEpicWorkerLease(request, inputs) {
|
|
98
|
+
const parent = epicLeaseParent(request);
|
|
99
|
+
if ((inputs?.['epic-parent'] ?? '') !== (parent ? String(parent) : '')) throw Error('Worker epic lease does not match the authenticated dispatch');
|
|
71
100
|
}
|
|
72
101
|
|
|
73
102
|
// Called only for a dispatch whose router identity is authenticated by the host.
|
|
@@ -83,13 +112,14 @@ export function executionModelSelection(request) {
|
|
|
83
112
|
}
|
|
84
113
|
|
|
85
114
|
export function verifyDispatchOrigin(request, receipt, parent, worker, repository, owner, issue) {
|
|
86
|
-
const delegatedWorker = workerFor(request) === 'apply-review' && ['propose', 'implement'].includes(request.stage) || workerFor(request) === 'product-change' && ['propose', 'implement'].includes(request.stage);
|
|
115
|
+
const delegatedWorker = workerFor(request) === 'apply-review' && ['propose', 'implement'].includes(request.stage) || workerFor(request) === 'product-change' && ['triage', 'refine', 'propose', 'implement'].includes(request.stage);
|
|
87
116
|
const applyProductWorker = workerFor(request) === 'apply-product-change' && ['propose', 'implement'].includes(request.stage);
|
|
88
117
|
if (workerFor(request) !== workerFor(receipt) || request.feedbackKey !== receipt.feedbackKey || workerFor(request) !== request.stage && !delegatedWorker && !applyProductWorker) throw Error('Dispatch worker responsibility mismatch');
|
|
89
118
|
if (workerFor(request) === 'product-change') {
|
|
90
119
|
const idea = request.eventName === 'workflow_dispatch' && request.event?.inputs?.workflow === 'product-change';
|
|
91
120
|
const feedback = request.productFeedback && ['answer', 'request-changes'].includes(request.productFeedback.action) && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(request.productFeedback.changeName ?? '') && typeof request.productFeedback.text === 'string' && request.productFeedback.text.trim().length > 0 && request.productFeedback.text.length <= 12000 && ['issue_comment', 'pull_request_review'].includes(request.eventName);
|
|
92
|
-
|
|
121
|
+
const parent = request.productParent && request.productParent.issue === issue && request.productParent.stage === request.stage && (request.eventName === 'workflow_run' && String(request.productParent.predecessor?.runId) === String(request.event?.workflow_run?.id) || request.eventName === 'workflow_dispatch' && request.event?.inputs?.workflow === 'product-change' && String(request.productParent.predecessor?.runId) === String(request.event.inputs.after_run));
|
|
122
|
+
if (!idea && !feedback && !parent) throw Error('Product Change worker needs an authorized idea, owner feedback or authenticated parent prerequisite');
|
|
93
123
|
}
|
|
94
124
|
if (workerFor(request) === 'apply-product-change' && (!request.productApproval || request.productApproval.kind !== 'approval' || !hash(request.productApproval.reviewKey))) throw Error('Trusted Product Change apply requires a bound approval');
|
|
95
125
|
if (JSON.stringify(request.recovery) !== JSON.stringify(receipt.recovery)) throw Error('Recovery receipt identity mismatch');
|
|
@@ -98,10 +128,12 @@ export function verifyDispatchOrigin(request, receipt, parent, worker, repositor
|
|
|
98
128
|
const recovery = !!request.recovery;
|
|
99
129
|
const originPath = recovery ? '.github/workflows/lighthouse-worker-maintenance.yml' : routerPath;
|
|
100
130
|
const originEvent = recovery ? request.routerEventName : request.eventName;
|
|
101
|
-
const continuation = !recovery && isBotContinuation(request.event, parent.event, parent.actor?.login, parent.triggering_actor?.login) && request.stage === 'implement' && request.handoff && !request.worker && !request.feedbackKey &&
|
|
102
|
-
|
|
131
|
+
const continuation = !recovery && isBotContinuation(request.event, parent.event, parent.actor?.login, parent.triggering_actor?.login) && Number(request.event.inputs.issue) === issue && (request.stage === 'implement' && request.handoff && !request.worker && !request.feedbackKey && request.predecessor?.runId === request.event.inputs.after_run || request.worker === 'product-change' && request.productParent?.predecessor?.runId === request.event.inputs.after_run || (!request.worker || request.worker === 'apply-review' && request.productResume?.parent.review) && request.event.inputs.workflow === 'resume-product' && request.productResume?.applyRun?.runId === request.event.inputs.after_run);
|
|
132
|
+
const epicTriage = !recovery && isBotEpicTriage(request.event, parent.event, parent.actor?.login, parent.triggering_actor?.login) && request.stage === 'triage' && !request.handoff && !request.worker && Number(request.event.inputs.issue) === issue && Number(request.event.inputs.epic_parent) === request.epic?.parent;
|
|
133
|
+
if (epicTriage ? request.epicKey !== epicTriageKey(repository, request.epic.parent, issue, request.epic.planDigest) || receipt.epicKey !== request.epicKey : request.epicKey !== undefined || receipt.epicKey !== undefined) throw Error('Epic triage identity is invalid');
|
|
134
|
+
if (String(parent.id) !== String(receipt.routerRunId) || parent.run_attempt !== receipt.routerAttempt || request.routerRunId !== receipt.routerRunId || request.routerAttempt !== receipt.routerAttempt || parent.path !== originPath || (parent.event !== 'workflow_run' && !continuation && !epicTriage && (parent.actor?.login !== owner || parent.triggering_actor?.login !== owner)) || !(recovery ? ['schedule', 'workflow_dispatch'] : events).includes(parent.event) || parent.event !== originEvent || parent.head_sha !== (request.routerRevision ?? request.workflowRevision) || parent.head_branch !== (request.routerRef ?? request.ref) || parent.repository?.full_name !== repository) throw Error('Dispatch origin is not an authorized router execution');
|
|
103
135
|
executionModelSelection(request);
|
|
104
|
-
const eventRunId = request.eventName === 'workflow_run' ? request.event?.workflow_run?.id : request.event?.inputs?.after_run;
|
|
136
|
+
const eventRunId = request.eventName === 'workflow_run' ? request.event?.workflow_run?.id : request.event?.inputs?.epic_run ?? request.event?.inputs?.after_run;
|
|
105
137
|
if (String(eventRunId ?? '') !== String(receipt.eventRunId ?? '') || String(request.eventRunId ?? '') !== String(receipt.eventRunId ?? '')) throw Error('Completion event identity mismatch');
|
|
106
138
|
if (worker.path !== stagePath(workerFor(request)) || worker.event !== 'workflow_dispatch' || worker.head_sha !== request.workflowRevision || worker.head_branch !== request.ref || worker.repository?.full_name !== repository || ![owner, 'github-actions[bot]'].includes(worker.actor?.login) || ![owner, 'github-actions[bot]'].includes(worker.triggering_actor?.login)) throw Error('Dispatch worker identity mismatch');
|
|
107
139
|
}
|
|
@@ -114,11 +146,25 @@ export async function loadDispatch(api, repository, owner, issue, commentId, wor
|
|
|
114
146
|
const parent = await api(`actions/runs/${receipt.routerRunId}/attempts/${receipt.routerAttempt}`);
|
|
115
147
|
const request = await readArtifact(api, repository, receipt.routerRunId, dispatchArtifact(receipt), 'dispatch.json');
|
|
116
148
|
verifyDispatchOrigin(request, receipt, parent, worker, repository, owner, issue);
|
|
117
|
-
if (isBotContinuation(request.event, parent.event, parent.actor?.login, parent.triggering_actor?.login)) {
|
|
149
|
+
if (isBotContinuation(request.event, parent.event, parent.actor?.login, parent.triggering_actor?.login) && !request.productParent && request.event?.inputs?.workflow !== 'resume-product') {
|
|
118
150
|
const predecessor = await api(`actions/runs/${request.predecessor.runId}`);
|
|
119
151
|
const next = await (await import('./stage-transition.mjs')).refinementAfterRun(api, repository, owner, { action: 'completed', workflow_run: { id: predecessor.id, head_sha: predecessor.head_sha } }, readArtifact);
|
|
120
152
|
if (next?.issue !== issue || JSON.stringify(next.predecessor) !== JSON.stringify(request.predecessor)) throw Error('Bot continuation lacks an authenticated refinement');
|
|
121
153
|
}
|
|
154
|
+
if (request.productResume) {
|
|
155
|
+
const apply = await api(`actions/runs/${request.productResume.applyRun.runId}`);
|
|
156
|
+
const observed = await (await import('./stage-transition.mjs')).productApplyAfterRun(api, repository, owner, { action: 'completed', workflow_run: { id: apply.id, head_sha: apply.head_sha } }, readArtifact);
|
|
157
|
+
if (JSON.stringify(observed) !== JSON.stringify(request.productResume) || observed.baseRevision !== request.sourceRevision || (observed.parent.feedback?.taskDigest ?? observed.parent.taskDigest) !== request.taskDigest) throw Error('Applied product prerequisite or parent authorization changed');
|
|
158
|
+
}
|
|
159
|
+
if (request.productParent) {
|
|
160
|
+
const parentRun = await api(`actions/runs/${request.productParent.predecessor.runId}`);
|
|
161
|
+
const observed = await (await import('./stage-transition.mjs')).productGapAfterRun(api, repository, owner, { action: 'completed', workflow_run: { id: parentRun.id, head_sha: parentRun.head_sha } }, readArtifact);
|
|
162
|
+
if (JSON.stringify(observed) !== JSON.stringify(request.productParent) || observed.baseRevision !== request.sourceRevision || observed.taskDigest !== request.taskDigest) throw Error('Product prerequisite or parent authorization changed');
|
|
163
|
+
}
|
|
164
|
+
if (isBotEpicTriage(request.event, parent.event, parent.actor?.login, parent.triggering_actor?.login)) {
|
|
165
|
+
const verified = await (await import('./epic-dispatch.mjs')).verifyEpicTriageOrigin(api, { repository, owner, child: issue, parent: Number(request.event.inputs.epic_parent), runId: request.event.inputs.epic_run }, { readArtifact });
|
|
166
|
+
if (!isDeepStrictEqual(verified.current, request.epic)) throw Error('Epic triage authority changed');
|
|
167
|
+
}
|
|
122
168
|
const dispatch = { request, receipt, commentId: Number(commentId) };
|
|
123
169
|
if (request.recovery) await (await import('./worker-recovery.mjs')).verifyRecoveryOrigin(api, repository, owner, dispatch, parent, readArtifact, depth);
|
|
124
170
|
return dispatch;
|
|
@@ -146,6 +192,7 @@ export async function admitDispatch(api, repository, owner, env, event) {
|
|
|
146
192
|
if (!Number.isSafeInteger(issue) || issue < 1 || String(issue) !== env.LIGHTHOUSE_ISSUE) throw Error('Dispatch issue must be a canonical positive integer');
|
|
147
193
|
const worker = await api(`actions/runs/${env.GITHUB_RUN_ID}/attempts/${env.GITHUB_RUN_ATTEMPT}`);
|
|
148
194
|
const dispatch = await loadDispatch(api, repository, owner, issue, event.inputs?.['dispatch-request'], worker);
|
|
195
|
+
checkEpicWorkerLease(dispatch.request, event.inputs);
|
|
149
196
|
if (env.LIGHTHOUSE_WORKER ? workerFor(dispatch.request) !== env.LIGHTHOUSE_WORKER : dispatch.request.stage !== env.LIGHTHOUSE_STAGE) throw Error('Dispatch targets a different stage or worker');
|
|
150
197
|
if (dispatch.request.recovery) {
|
|
151
198
|
if (env.LIGHTHOUSE_RECOVERY_ENABLED !== 'true') throw Error('Automatic recovery has been disabled');
|
|
@@ -160,7 +207,7 @@ export async function finishDispatch(api, repository, issue, commentId, workerId
|
|
|
160
207
|
if (!positive(commentId)) return;
|
|
161
208
|
const receipt = readDispatchReceipt(await api(`issues/comments/${commentId}`), repository, issue);
|
|
162
209
|
if (String(receipt.workerRunId) !== String(workerId)) return;
|
|
163
|
-
receipt.status = outcome === 'passed' ? 'completed' : outcome === '
|
|
210
|
+
receipt.status = outcome === 'passed' ? 'completed' : outcome === 'question' ? 'waiting' : outcome === 'blocked' ? 'blocked' : outcome === 'failed' ? 'failed' : outcome === 'cancelled' ? 'cancelled' : 'unknown';
|
|
164
211
|
await api(`issues/comments/${commentId}`, 'PATCH', { body: dispatchReceiptBody(repository, receipt) });
|
|
165
212
|
}
|
|
166
213
|
|
|
@@ -174,12 +221,12 @@ export async function completedEvent(api, event, env, wait = ms => new Promise(r
|
|
|
174
221
|
if (!bot && (env.GITHUB_EVENT_NAME !== 'workflow_dispatch' || env.GITHUB_ACTOR !== env.GITHUB_REPOSITORY_OWNER || env.GITHUB_TRIGGERING_ACTOR !== env.GITHUB_REPOSITORY_OWNER || !positive(id))) throw Error('Completion replay requires an owner and a run ID, or a verified refinement continuation');
|
|
175
222
|
let run = await api(`actions/runs/${id}`);
|
|
176
223
|
if (bot) {
|
|
177
|
-
if (run.path !== stagePath('refine')) throw Error('Bot continuation must reference a refinement');
|
|
224
|
+
if (event.inputs?.workflow === 'resume-product' ? run.path !== stagePath('apply-product-change') : event.inputs?.workflow === 'product-change' ? !['triage', 'refine', 'propose', 'implement', 'apply-review'].some(stage => run.path === stagePath(stage)) : run.path !== stagePath('refine')) throw Error(event.inputs?.workflow === 'resume-product' ? 'Bot continuation must reference a product apply' : event.inputs?.workflow === 'product-change' ? 'Bot continuation must reference a supported parent execution' : 'Bot continuation must reference a refinement');
|
|
178
225
|
// The publisher signals before its own workflow has finished its final jobs.
|
|
179
226
|
for (let attempt = 0; run.status !== 'completed' && attempt < 24; attempt++) {
|
|
180
227
|
await wait(5000); run = await api(`actions/runs/${id}`);
|
|
181
228
|
}
|
|
182
|
-
if (run.status !== 'completed') throw Error('
|
|
229
|
+
if (run.status !== 'completed') throw Error('Parent completion timed out; replay after the execution completes');
|
|
183
230
|
}
|
|
184
231
|
return { action: 'completed', workflow_run: { id: run.id, head_sha: run.head_sha } };
|
|
185
232
|
}
|