@juangcarmona/lighthouse-sdlc 0.17.1 → 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/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 +49 -12
- 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/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.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/stage-transition.mjs
CHANGED
|
@@ -1,17 +1,35 @@
|
|
|
1
1
|
import { readActionsJson } from './actions-artifact.mjs';
|
|
2
2
|
import { loadDispatch, checkDispatchClaim, stagePath, issueComments } from './stage-dispatch.mjs';
|
|
3
3
|
import { validateStageEnvelope } from './host-contract.mjs';
|
|
4
|
+
import { assessDecomposition } from './epic.mjs';
|
|
4
5
|
|
|
5
6
|
// Explicit dispatch also works when GitHub suppresses follow-up token events.
|
|
6
7
|
// This is a wake-up only; the router rechecks the completed run and owner intent.
|
|
7
8
|
export async function signalRefinementContinuation(api, request, envelope) {
|
|
8
9
|
if (request.stage !== 'refine' || !request.dispatch || request.autoImplement !== true || envelope.result?.status !== 'passed' || envelope.result?.gate !== 'ready') return false;
|
|
9
10
|
if (validateStageEnvelope(envelope, request).length) throw Error('Invalid refinement result');
|
|
11
|
+
if (envelope.result.output?.estimate !== undefined && assessDecomposition(envelope.result.output).status === 'planned') return false;
|
|
10
12
|
const repository = await api('');
|
|
11
13
|
await api('actions/workflows/lighthouse-sdlc-router.yml/dispatches', 'POST', { ref: repository.default_branch, inputs: { issue: String(request.issue), stage: 'implement', after_run: String(request.workflowRunId) } });
|
|
12
14
|
return true;
|
|
13
15
|
}
|
|
14
16
|
|
|
17
|
+
// Signal only after publication and cleanup; the router authenticates the completed run.
|
|
18
|
+
export async function signalProductGap(api, request, envelope) {
|
|
19
|
+
if (!request.dispatch || !request.productChangeEnabled || !['triage', 'refine', 'propose', 'implement'].includes(request.stage) || envelope.result?.status !== 'blocked' || !envelope.result.output?.productGap) return false;
|
|
20
|
+
if (validateStageEnvelope(envelope, request).length) throw Error('Invalid product prerequisite result');
|
|
21
|
+
const repository = await api('');
|
|
22
|
+
await api('actions/workflows/lighthouse-sdlc-router.yml/dispatches', 'POST', { ref: repository.default_branch, inputs: { issue: String(request.issue), stage: request.stage, workflow: 'product-change', after_run: String(request.workflowRunId) } });
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function signalProductApplyContinuation(api, issue, runId, checkpoint) {
|
|
27
|
+
if (!checkpoint.parent) return false;
|
|
28
|
+
const repository = await api('');
|
|
29
|
+
await api('actions/workflows/lighthouse-sdlc-router.yml/dispatches', 'POST', { ref: repository.default_branch, inputs: { issue: String(issue), stage: checkpoint.parent.stage, workflow: 'resume-product', after_run: String(runId) } });
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
|
|
15
33
|
// The completed execution is an observation. Its retained owner request grants the handoff.
|
|
16
34
|
export async function refinementAfterRun(api, repository, owner, event, readArtifact = readActionsJson) {
|
|
17
35
|
if (event.action !== 'completed' || !Number.isSafeInteger(event.workflow_run?.id)) return null;
|
|
@@ -24,12 +42,143 @@ export async function refinementAfterRun(api, repository, owner, event, readArti
|
|
|
24
42
|
const request = await readArtifact(api, repository, run.id, inputs[0].name, 'request.json');
|
|
25
43
|
if (request.stage !== 'refine' || !request.dispatch || !request.autoImplement || String(request.workflowRunId) !== String(run.id)) return null;
|
|
26
44
|
const origin = await loadDispatch(api, repository, owner, request.issue, request.dispatch.commentId, run, readArtifact);
|
|
27
|
-
if (origin.request.stage !== 'refine' || origin.request.autoImplement !== true || origin.request.id !== request.dispatch.requestId || origin.request.sourceRevision !== request.sourceRevision || origin.request.workflowRevision !== request.workflowRevision) throw Error('Refinement continuation identity mismatch');
|
|
45
|
+
if (origin.request.stage !== 'refine' || origin.request.autoImplement !== true || origin.request.id !== request.dispatch.requestId || origin.request.sourceRevision !== (request.baseRevision ?? request.sourceRevision) || origin.request.workflowRevision !== request.workflowRevision || JSON.stringify(origin.request.productResume) !== JSON.stringify(request.productResume)) throw Error('Refinement continuation identity mismatch');
|
|
28
46
|
await checkDispatchClaim(api, repository, request.issue, request.dispatch, run.id);
|
|
29
47
|
const envelope = await readArtifact(api, repository, run.id, `lighthouse-result-${request.issue}-${run.run_attempt}`, 'envelope.json');
|
|
30
48
|
if (validateStageEnvelope(envelope, request).length) throw Error('Invalid refinement result');
|
|
49
|
+
if (envelope.result.output?.estimate !== undefined && assessDecomposition(envelope.result.output).status === 'planned') return null;
|
|
31
50
|
if (envelope.result.status !== 'passed' || envelope.result.gate !== 'ready') return null;
|
|
32
51
|
const reports = await issueComments(api, request.issue);
|
|
33
52
|
if (!reports.some(c => c.user?.login === 'github-actions[bot]' && c.user.type === 'Bot' && c.body.includes(`<!-- lighthouse-actions:${request.runId} -->`) && c.body.includes('<!-- lighthouse-stage:refine:passed -->'))) return null;
|
|
34
|
-
return { run: true, stage: 'implement', issue: request.issue, predecessor: { runId: String(run.id), attempt: run.run_attempt }, reason: 'Verified refinement completed; implementation requested by the installed policy.' };
|
|
53
|
+
return { run: true, stage: 'implement', issue: request.issue, predecessor: { runId: String(run.id), attempt: run.run_attempt }, ...(request.productResume ? { productResume: request.productResume } : {}), reason: 'Verified refinement completed; implementation requested by the installed policy.' };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// A completed worker can identify a prerequisite, but cannot authorize product work.
|
|
57
|
+
// The retained dispatch and published result bind the observation to the owner's request.
|
|
58
|
+
export async function productGapAfterRun(api, repository, owner, event, readArtifact = readActionsJson) {
|
|
59
|
+
if (event.action !== 'completed' || !Number.isSafeInteger(event.workflow_run?.id)) return null;
|
|
60
|
+
const run = await api(`actions/runs/${event.workflow_run.id}`);
|
|
61
|
+
const directStage = ['triage', 'refine', 'propose', 'implement'].find(name => run.path === stagePath(name));
|
|
62
|
+
const reviewWorker = run.path === stagePath('apply-review');
|
|
63
|
+
if ((!directStage && !reviewWorker) || run.status !== 'completed' || run.conclusion !== 'success' || run.head_sha !== event.workflow_run.head_sha) return null;
|
|
64
|
+
const artifacts = await api(`actions/runs/${run.id}/artifacts?per_page=100`);
|
|
65
|
+
if (artifacts.total_count > 100) throw Error('Product prerequisite artifacts exceed bounds');
|
|
66
|
+
const inputs = artifacts.artifacts.filter(a => new RegExp(`^lighthouse-(?:implement-)?input-[1-9][0-9]*-${run.run_attempt}$`).test(a.name) && !a.expired);
|
|
67
|
+
if (inputs.length !== 1) return null;
|
|
68
|
+
const request = await readArtifact(api, repository, run.id, inputs[0].name, 'request.json');
|
|
69
|
+
const stage = request.stage;
|
|
70
|
+
if ((!reviewWorker && stage !== directStage) || (reviewWorker && (!['propose', 'implement'].includes(stage) || request.worker !== 'apply-review')) || !request.dispatch || String(request.workflowRunId) !== String(run.id) || !reviewWorker && request.worker && request.worker !== stage || inputs[0].name !== `${stage === 'implement' ? 'lighthouse-implement-input-' : 'lighthouse-input-'}${request.issue}-${run.run_attempt}`) return null;
|
|
71
|
+
const origin = await loadDispatch(api, repository, owner, request.issue, request.dispatch.commentId, run, readArtifact);
|
|
72
|
+
if (origin.request.stage !== stage || origin.request.id !== request.dispatch.requestId || origin.request.sourceRevision !== (request.baseRevision ?? request.sourceRevision) || origin.request.workflowRevision !== request.workflowRevision || origin.request.taskDigest !== request.taskDigest) throw Error('Product prerequisite parent identity mismatch');
|
|
73
|
+
await checkDispatchClaim(api, repository, request.issue, request.dispatch, run.id);
|
|
74
|
+
const resultName = `${stage === 'implement' ? 'lighthouse-implement-result-' : 'lighthouse-result-'}${request.issue}-${run.run_attempt}`;
|
|
75
|
+
const envelope = await readArtifact(api, repository, run.id, resultName, 'envelope.json');
|
|
76
|
+
if (validateStageEnvelope(envelope, request).length) throw Error('Invalid product prerequisite result');
|
|
77
|
+
if (envelope.result.status !== 'blocked' || !envelope.result.output?.productGap) return null;
|
|
78
|
+
const reports = await issueComments(api, request.issue);
|
|
79
|
+
const matches = reports.filter(c => c.user?.login === 'github-actions[bot]' && c.user.type === 'Bot' && c.body.includes(`<!-- lighthouse-actions:${request.runId} -->`) && c.body.includes(`<!-- lighthouse-stage:${stage}:blocked -->`));
|
|
80
|
+
if (matches.length !== 1) return null;
|
|
81
|
+
const proofs = [...matches[0].body.matchAll(/<!-- lighthouse-pr-state:(\{[^\n]*\}) -->/g)];
|
|
82
|
+
if (proofs.length > 1) throw Error('Product prerequisite has ambiguous existing PR evidence');
|
|
83
|
+
const proof = proofs.length ? JSON.parse(proofs[0][1]) : null;
|
|
84
|
+
if (proof && (proof.run !== run.id || proof.complete !== false)) throw Error('Product prerequisite PR evidence does not match the blocked parent');
|
|
85
|
+
const review = request.implementationReview ?? request.proposalReview;
|
|
86
|
+
if (proof && review && proof.pr !== review.number) throw Error('Product prerequisite references a different issue PR');
|
|
87
|
+
let reviewEvidence;
|
|
88
|
+
if (review) {
|
|
89
|
+
const { reviewThread } = await import('./proposal-review.mjs');
|
|
90
|
+
const { digest } = await import('./host-contract.mjs');
|
|
91
|
+
const relevant = rows => rows.filter(row => !/^\/product-change (?:approve|request-changes|answer) [a-z0-9]+(?:-[a-z0-9]+)* [a-f0-9]{64}(?:\n|$)/.test((row.body ?? '').trim()));
|
|
92
|
+
if (!Array.isArray(review.thread) || !/^[a-f0-9]{64}$/.test(review.feedbackDigest ?? '') || !/^[a-f0-9]{64}$/.test(review.threadDigest ?? '') || digest(JSON.stringify(review.thread)) !== review.threadDigest) throw Error('Parent PR review evidence is invalid');
|
|
93
|
+
const currentFeedback = await api(review.path);
|
|
94
|
+
const currentThread = await reviewThread(api, review.number, owner);
|
|
95
|
+
const threadDigest = digest(JSON.stringify(relevant(review.thread)));
|
|
96
|
+
if (digest(JSON.stringify(currentFeedback)) !== review.feedbackDigest || digest(JSON.stringify(relevant(currentThread))) !== threadDigest) throw Error('Parent PR feedback changed');
|
|
97
|
+
reviewEvidence = { number: review.number, path: review.path, feedbackDigest: review.feedbackDigest, threadDigest };
|
|
98
|
+
}
|
|
99
|
+
let pullRequest;
|
|
100
|
+
if (proof || review) {
|
|
101
|
+
const number = proof?.pr ?? review.number, head = proof?.head ?? review.head;
|
|
102
|
+
const pr = await api(`pulls/${number}`);
|
|
103
|
+
if (!Number.isSafeInteger(number) || number < 1 || !/^[a-f0-9]{40}$/.test(head ?? '') || pr.number !== number || pr.state !== 'open' || pr.merged || pr.user?.login !== 'github-actions[bot]' || pr.user.type !== 'Bot' || pr.head?.repo?.full_name !== repository || pr.base?.repo?.full_name !== repository || !new RegExp(`^lighthouse/(?:implement|propose)-${request.issue}-[a-f0-9]{12}$`).test(pr.head.ref)) throw Error('Existing issue PR is not an eligible parent');
|
|
104
|
+
pullRequest = { number, head, branch: pr.head.ref };
|
|
105
|
+
}
|
|
106
|
+
return { issue: request.issue, stage, predecessor: { runId: String(run.id), attempt: run.run_attempt }, gap: envelope.result.output.productGap, sourceRevision: request.sourceRevision, baseRevision: request.baseRevision ?? request.sourceRevision, taskDigest: request.taskDigest, autoImplement: request.autoImplement === true, ...(pullRequest ? { pullRequest } : {}), ...(reviewEvidence ? { review: reviewEvidence } : {}) };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Recheck the original parent and, when present, the authenticated answer to a
|
|
110
|
+
// product question. The answer changes context; it does not approve product intent.
|
|
111
|
+
export async function verifyProductParent(api, repository, owner, parent, issue, reports, currentBase, readArtifact = readActionsJson) {
|
|
112
|
+
const { feedback, ...original } = parent;
|
|
113
|
+
const parentRun = await api(`actions/runs/${original.predecessor.runId}`);
|
|
114
|
+
const observed = await productGapAfterRun(api, repository, owner, { action: 'completed', workflow_run: { id: parentRun.id, head_sha: parentRun.head_sha } }, readArtifact);
|
|
115
|
+
if (JSON.stringify(observed) !== JSON.stringify(original) || currentBase !== original.baseRevision) throw Error('Product prerequisite parent or base changed');
|
|
116
|
+
let expectedDigest = original.taskDigest;
|
|
117
|
+
if (feedback) {
|
|
118
|
+
const run = await api(`actions/runs/${feedback.runId}`);
|
|
119
|
+
if (run.path !== stagePath('product-change') || run.status !== 'completed' || run.conclusion !== 'success' || run.run_attempt !== feedback.attempt) throw Error('Product answer execution is unavailable');
|
|
120
|
+
const snapshot = await readArtifact(api, repository, run.id, `lighthouse-input-${issue.number}-${run.run_attempt}`, 'request.json');
|
|
121
|
+
const envelope = await readArtifact(api, repository, run.id, `lighthouse-result-${issue.number}-${run.run_attempt}`, 'envelope.json');
|
|
122
|
+
if (!snapshot.dispatch || snapshot.issue !== issue.number || String(snapshot.workflowRunId) !== String(run.id) || snapshot.taskDigest !== feedback.taskDigest || snapshot.replyTo !== feedback.replyTo || snapshot.commentId !== feedback.commentId || JSON.stringify(snapshot.productParent) !== JSON.stringify(original) || validateStageEnvelope(envelope, snapshot).length || envelope.result.status !== 'passed' || envelope.result.output?.productChange?.outcome !== 'candidate') throw Error('Product answer execution does not bind the parent and candidate');
|
|
123
|
+
const origin = await loadDispatch(api, repository, owner, issue.number, snapshot.dispatch.commentId, run, readArtifact);
|
|
124
|
+
await checkDispatchClaim(api, repository, issue.number, snapshot.dispatch, run.id);
|
|
125
|
+
const answer = origin.request.event?.comment;
|
|
126
|
+
if (origin.request.worker !== 'product-change' || origin.request.id !== snapshot.dispatch.requestId || origin.request.taskDigest !== snapshot.taskDigest || origin.request.productFeedback?.action !== 'answer' || Number(origin.request.productFeedback.sourceCommentId) !== feedback.commentId || answer?.id !== feedback.commentId || answer.user?.login !== owner || answer.user?.type !== 'User' || !reports.some(c => c.id === feedback.commentId && c.user?.login === owner && c.body === answer.body)) throw Error('Product answer is not the authenticated owner reply');
|
|
127
|
+
const priorReports = reports.filter(c => c.user?.login === 'github-actions[bot]' && c.user.type === 'Bot' && c.body.includes(`<!-- lighthouse-actions:${feedback.replyTo} -->`) && c.body.includes('<!-- lighthouse-question -->'));
|
|
128
|
+
if (priorReports.length !== 1) throw Error('Product question evidence is unavailable or ambiguous');
|
|
129
|
+
const prior = priorReports[0];
|
|
130
|
+
const priorRunId = prior?.body.match(/\[Workflow run\]\(https:\/\/github\.com\/[^/]+\/[^/]+\/actions\/runs\/(\d+)\)/)?.[1];
|
|
131
|
+
if (!priorRunId) throw Error('Product question evidence is unavailable');
|
|
132
|
+
const priorRun = await api(`actions/runs/${priorRunId}`);
|
|
133
|
+
const priorSnapshot = await readArtifact(api, repository, priorRun.id, `lighthouse-input-${issue.number}-${priorRun.run_attempt}`, 'request.json');
|
|
134
|
+
const priorEnvelope = await readArtifact(api, repository, priorRun.id, `lighthouse-result-${issue.number}-${priorRun.run_attempt}`, 'envelope.json');
|
|
135
|
+
if (priorRun.path !== stagePath('product-change') || priorRun.status !== 'completed' || priorRun.conclusion !== 'success' || priorSnapshot.runId !== feedback.replyTo || String(priorSnapshot.workflowRunId) !== String(priorRun.id) || priorSnapshot.issue !== issue.number || JSON.stringify(priorSnapshot.productParent) !== JSON.stringify(original) || validateStageEnvelope(priorEnvelope, priorSnapshot).length || priorEnvelope.result.status !== 'blocked' || priorEnvelope.result.output?.productChange?.question !== snapshot.answerHistory?.at(-1)?.question || snapshot.answerHistory?.at(-1)?.answer?.body !== answer.body) throw Error('Product answer no longer matches the pending question');
|
|
136
|
+
const priorOrigin = await loadDispatch(api, repository, owner, issue.number, priorSnapshot.dispatch.commentId, priorRun, readArtifact);
|
|
137
|
+
if (priorOrigin.request.id !== priorSnapshot.dispatch.requestId || priorOrigin.request.worker !== 'product-change' || !(priorOrigin.request.productParent || priorOrigin.request.productFeedback || priorOrigin.request.productReview?.parent)) throw Error('Product question dispatch changed');
|
|
138
|
+
await checkDispatchClaim(api, repository, issue.number, priorSnapshot.dispatch, priorRun.id);
|
|
139
|
+
if (reports.filter(c => c.user?.login === 'github-actions[bot]' && c.user.type === 'Bot' && c.body.includes(`<!-- lighthouse-actions:${snapshot.runId} -->`) && c.body.includes(`<!-- lighthouse-stage:${snapshot.stage}:passed -->`)).length !== 1) throw Error('Product answer publication is unavailable or ambiguous');
|
|
140
|
+
expectedDigest = feedback.taskDigest;
|
|
141
|
+
}
|
|
142
|
+
const { createHash } = await import('node:crypto');
|
|
143
|
+
const answers = reports.filter(c => c.user?.login === owner && !c.body.includes('<!-- lighthouse-run:') && !c.body.includes('<!-- lighthouse-actions:'));
|
|
144
|
+
const intent = createHash('sha256').update(JSON.stringify({ title: issue.title, body: issue.body, comments: answers.map(c => ({ id: c.id, body: c.body, updated_at: c.updated_at })) })).digest('hex');
|
|
145
|
+
if (intent !== expectedDigest) throw Error('Product parent owner intent changed');
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// A product apply is usable by a parent only when its original approval, provider
|
|
150
|
+
// result, published receipt, current PR head and retained parent still agree.
|
|
151
|
+
export async function productApplyAfterRun(api, repository, owner, event, readArtifact = readActionsJson) {
|
|
152
|
+
if (event.action !== 'completed' || !Number.isSafeInteger(event.workflow_run?.id)) return null;
|
|
153
|
+
const run = await api(`actions/runs/${event.workflow_run.id}`);
|
|
154
|
+
if (run.path !== stagePath('apply-product-change') || run.status !== 'completed' || run.conclusion !== 'success' || run.head_sha !== event.workflow_run.head_sha) return null;
|
|
155
|
+
const artifacts = await api(`actions/runs/${run.id}/artifacts?per_page=100`);
|
|
156
|
+
if (artifacts.total_count > 100) throw Error('Product apply artifacts exceed bounds');
|
|
157
|
+
const inputs = artifacts.artifacts.filter(a => new RegExp(`^lighthouse-product-apply-input-[1-9][0-9]*-${run.run_attempt}$`).test(a.name) && !a.expired);
|
|
158
|
+
if (inputs.length !== 1) return null;
|
|
159
|
+
const input = await readArtifact(api, repository, run.id, inputs[0].name, 'product-apply.json');
|
|
160
|
+
if (!input.checkpoint?.parent || input.repository !== repository || input.owner !== owner || !input.dispatch || input.issue !== input.checkpoint.parent.issue) return null;
|
|
161
|
+
const dispatch = await loadDispatch(api, repository, owner, input.issue, input.dispatch.commentId, run, readArtifact);
|
|
162
|
+
if (dispatch.request.worker !== 'apply-product-change' || dispatch.request.id !== input.dispatch.requestId || dispatch.request.productApproval?.reviewKey !== input.approval?.reviewKey || dispatch.request.productApproval?.head !== input.head || input.binding?.key !== input.approval?.reviewKey) throw Error('Product apply dispatch or approval identity changed');
|
|
163
|
+
await checkDispatchClaim(api, repository, input.issue, input.dispatch, run.id);
|
|
164
|
+
const output = await readArtifact(api, repository, run.id, `lighthouse-product-apply-proof-${input.issue}-${run.run_attempt}`, 'apply-proof.json');
|
|
165
|
+
if (output.head !== input.head || output.receipt?.headBefore !== input.head || output.receipt?.validated !== true || output.receipt?.proposalDigest !== input.checkpoint.proposalDigest || output.receipt?.baselineDigest !== input.checkpoint.baselineDigest || output.receipt?.resultingModelDigest !== output.resultingModelDigest) throw Error('Product apply provider result does not match its approval');
|
|
166
|
+
const pr = await api(`pulls/${input.pullRequest}`);
|
|
167
|
+
if (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) throw Error('Applied product PR is no longer eligible for continuation');
|
|
168
|
+
const matches = [...(pr.body ?? '').matchAll(/<!-- lighthouse-product-change(?:-checkpoint|:checkpoint):(\{[^\n]*\}) -->/g)];
|
|
169
|
+
if (matches.length !== 1) throw Error('Applied product checkpoint is unavailable or ambiguous');
|
|
170
|
+
const checkpoint = JSON.parse(matches[0][1]);
|
|
171
|
+
const { validateProductCheckpoint, productReviewBinding } = await import('./product-change.mjs');
|
|
172
|
+
const { createHash } = await import('node:crypto');
|
|
173
|
+
const hash = value => createHash('sha256').update(value).digest('hex');
|
|
174
|
+
const { key, ...bindingFields } = input.binding;
|
|
175
|
+
if (validateProductCheckpoint(checkpoint).length || checkpoint.state !== 'applied' || checkpoint.identity.head !== pr.head.sha || JSON.stringify(checkpoint.parent) !== JSON.stringify(input.checkpoint.parent) || input.binding.parentDigest !== hash(JSON.stringify(checkpoint.parent)) || productReviewBinding(bindingFields).key !== key) throw Error('Applied product checkpoint or parent binding changed');
|
|
176
|
+
const reports = await issueComments(api, input.issue);
|
|
177
|
+
const receipts = reports.filter(c => c.user?.login === 'github-actions[bot]' && c.user.type === 'Bot').flatMap(c => [...(c.body ?? '').matchAll(/<!-- lighthouse-product-apply:(\{[^\n]*\}) -->/g)].map(m => JSON.parse(m[1]))).filter(r => r.intentId === output.receipt.intentId);
|
|
178
|
+
if (receipts.length !== 1 || receipts[0].headBefore !== input.head || receipts[0].headAfter !== pr.head.sha || receipts[0].resultingModelDigest !== output.resultingModelDigest || !checkpoint.effectIds.includes(`apply:${receipts[0].intentId}:${pr.head.sha}`)) throw Error('Applied product publication is missing, stale or ambiguous');
|
|
179
|
+
const info = await api(''), currentBase = (await api(`commits/${info.default_branch}`)).sha;
|
|
180
|
+
const issue = await api(`issues/${input.issue}`);
|
|
181
|
+
await verifyProductParent(api, repository, owner, checkpoint.parent, issue, reports, currentBase, readArtifact);
|
|
182
|
+
if (issue.state !== 'open' || issue.labels?.some(l => ['future', 'human-working', 'agent-working'].includes(l.name)) || pr.base.ref !== info.default_branch) throw Error('Product parent authorization, base or issue changed before continuation');
|
|
183
|
+
return { issue: input.issue, stage: checkpoint.parent.stage, parent: checkpoint.parent, applyRun: { runId: String(run.id), attempt: run.run_attempt }, pullRequest: pr.number, head: pr.head.sha, branch: pr.head.ref, baseRevision: currentBase, receiptIntent: receipts[0].intentId };
|
|
35
184
|
}
|
package/src/triage.mjs
CHANGED
|
@@ -223,7 +223,8 @@ export function buildTriageReport(input) {
|
|
|
223
223
|
validateAssessment(input.assessment, errors);
|
|
224
224
|
|
|
225
225
|
const state = currentState(currentLabels, configuration);
|
|
226
|
-
|
|
226
|
+
const proposalReview = state.states.length === 2 && state.states.includes('ready') && state.states.includes('waiting-review');
|
|
227
|
+
if (state.states.length > 1 && !proposalReview) addIssue(errors, 'state.ambiguous', `work item has multiple workflow state labels: ${state.states.join(', ')}`, 'currentLabels');
|
|
227
228
|
if (state.states.length === 0) addIssue(errors, 'state.missing', 'work item needs one configured workflow state label', 'currentLabels');
|
|
228
229
|
const assessment = isRecord(input.assessment) ? input.assessment : {};
|
|
229
230
|
if (state.value && typeof assessment.nextState === 'string' && configuration.stateLabels.has(assessment.nextState)) {
|
|
@@ -244,11 +245,12 @@ export function buildTriageReport(input) {
|
|
|
244
245
|
const unavailableChecks = evidence.filter((item) => isRecord(item) && item.status === 'unavailable').map((item) => item.field);
|
|
245
246
|
const pendingReview = evidence.filter((item) => isRecord(item) && item.status === 'pending-review').map((item) => item.field);
|
|
246
247
|
const warnings = [
|
|
248
|
+
...(proposalReview ? [issue('state.review-pending', 'A native proposal review retains ready as a revocable gate. Complete or resolve that review before applying generic triage.', 'currentLabels')] : []),
|
|
247
249
|
...missingEvidence.map((field) => issue('evidence.missing', `${field} has no evidence record`, field)),
|
|
248
250
|
...unavailableChecks.map((field) => issue('evidence.unavailable', `${field} could not be checked`, field)),
|
|
249
251
|
...pendingReview.map((field) => issue('evidence.pending-review', `${field} is waiting for judgement`, field)),
|
|
250
252
|
];
|
|
251
|
-
const proposed = proposedLabels(currentLabels, assessment, configuration);
|
|
253
|
+
const proposed = proposalReview ? currentLabels : proposedLabels(currentLabels, assessment, configuration);
|
|
252
254
|
const status = errors.length ? 'invalid' : warnings.length ? 'blocked' : 'ready';
|
|
253
255
|
return {
|
|
254
256
|
schemaVersion: 1,
|
|
@@ -11,7 +11,8 @@ import { taskDigest } from './issue-actions.mjs';
|
|
|
11
11
|
import { list, inspectCompletion, assertChecks, mergeAccepted, postMergeRuns } from './completion.mjs';
|
|
12
12
|
import { completionBundle, publishProcessBundle } from './process-publication.mjs';
|
|
13
13
|
import { dispatchDeliveryChecks } from './delivery-events.mjs';
|
|
14
|
-
import { setPullRequestDraft } from './pr-state.mjs';
|
|
14
|
+
import { setPullRequestDraft, projectImplementationPrState } from './pr-state.mjs';
|
|
15
|
+
import { handoff, pendingQuestion } from './handoff.mjs';
|
|
15
16
|
|
|
16
17
|
const harness = fileURLToPath(new URL('../', import.meta.url));
|
|
17
18
|
const readJson = async path => JSON.parse(await readFile(path, 'utf8'));
|
|
@@ -60,7 +61,9 @@ export async function runGithubUnattended({ env = process.env, api = (...args) =
|
|
|
60
61
|
const issue = await api('issues/' + issueNumber);
|
|
61
62
|
if (issue.state !== 'open' || issue.pull_request || issue.user?.login !== owner || issue.labels.some(l => ['future', 'needs-info', 'waiting-review', 'human-working', 'agent-working'].includes(l.name))) throw Error('Issue is closed, reserved, paused or requires owner admission');
|
|
62
63
|
const comments = () => list(api, 'issues/' + issueNumber + '/comments');
|
|
63
|
-
const
|
|
64
|
+
const initialComments = await comments();
|
|
65
|
+
const initialCommentId = Math.max(0, ...initialComments.map(comment => Number.isSafeInteger(comment.id) ? comment.id : 0));
|
|
66
|
+
const ownerAnswers = owners(initialComments, owner);
|
|
64
67
|
const intent = taskDigest(issue, ownerAnswers);
|
|
65
68
|
const issueContext = { ...issue, ownerAnswers };
|
|
66
69
|
const runId = unattendedDigest({ repository, issue: issueNumber, route, run: env.GITHUB_RUN_ID });
|
|
@@ -99,7 +102,23 @@ export async function runGithubUnattended({ env = process.env, api = (...args) =
|
|
|
99
102
|
await assertUnattendedPullRequest(api, await comments(), current, owner);
|
|
100
103
|
}
|
|
101
104
|
};
|
|
102
|
-
const
|
|
105
|
+
const newerAttempt = async () => {
|
|
106
|
+
const latest = (await comments()).filter(c => c.id > initialCommentId && c.user?.login === 'github-actions[bot]' && c.user.type === 'Bot' && /<!-- lighthouse-(?:unattended|dispatch|actions|stop):/.test(c.body ?? '')).at(-1);
|
|
107
|
+
return !!latest && !latest.body.includes(marker);
|
|
108
|
+
};
|
|
109
|
+
const notify = async body => {
|
|
110
|
+
if (await newerAttempt()) throw Error('A newer workflow attempt has taken over');
|
|
111
|
+
return api('issues/' + issueNumber + '/comments', 'POST', { body: body + '\n\n[Execution](' + runUrl + ').\n\n' + marker });
|
|
112
|
+
};
|
|
113
|
+
const releaseReservation = async () => {
|
|
114
|
+
if (await newerAttempt()) { reserved = false; return 'stale'; }
|
|
115
|
+
if (!reserved) return 'released';
|
|
116
|
+
const fresh = await api('issues/' + issueNumber);
|
|
117
|
+
if (fresh.labels.some(label => label.name === 'human-working')) { reserved = false; return 'held'; }
|
|
118
|
+
if (fresh.labels.some(label => label.name === 'agent-working')) await api('issues/' + issueNumber + '/labels/agent-working', 'DELETE', undefined, true);
|
|
119
|
+
reserved = false;
|
|
120
|
+
return 'released';
|
|
121
|
+
};
|
|
103
122
|
const publicationApi = async (path, method, ...args) => {
|
|
104
123
|
if (method === 'POST' && ['git/refs', 'pulls'].includes(path)) {
|
|
105
124
|
await recheck();
|
|
@@ -121,6 +140,8 @@ export async function runGithubUnattended({ env = process.env, api = (...args) =
|
|
|
121
140
|
prompt: text, ...(worker === 'independent-review' ? { modelProfile: policy.reviewerProfile } : {}), signal: controller.signal });
|
|
122
141
|
if (result.runtime.execution?.status !== 'passed') throw Error('Host cannot verify completed model execution');
|
|
123
142
|
if (worker === 'independent-review' && result.runtime.model !== reviewer.model || stage === 'implement' && result.runtime.model !== producer.model) throw Error('Operator model selection changed during delivery');
|
|
143
|
+
const question = result.envelope.result.status === 'blocked' ? pendingQuestion(result.envelope.result.output?.handoff?.question) : null;
|
|
144
|
+
if (question) { const error = Error(result.envelope.effect.body); error.question = question; throw error; }
|
|
124
145
|
if (stage !== 'implement' && worker !== 'independent-review' && result.envelope.result.status !== 'passed') throw Error(result.envelope.effect.body);
|
|
125
146
|
return result;
|
|
126
147
|
};
|
|
@@ -146,8 +167,8 @@ export async function runGithubUnattended({ env = process.env, api = (...args) =
|
|
|
146
167
|
};
|
|
147
168
|
try {
|
|
148
169
|
await recheck();
|
|
149
|
-
await notify('Requested · ' + route + '\n\nAuthorized bounded delivery is starting. Your next action: none unless a product decision or blocker is reported.');
|
|
150
170
|
await api('issues/' + issueNumber + '/labels', 'POST', { labels: ['agent-working'] }); reserved = true;
|
|
171
|
+
await notify(handoff({ status: 'working', stage: route, happened: 'The authorized bounded delivery has started.', action: 'None. Wait for the result or a specific blocker.', next: 'The workflow will publish its actual outcome and any required decision.' }));
|
|
151
172
|
const outcome = await runUnattended({ route, policy, signal: controller.signal, host: {
|
|
152
173
|
recheck,
|
|
153
174
|
refine: async () => {
|
|
@@ -205,10 +226,10 @@ export async function runGithubUnattended({ env = process.env, api = (...args) =
|
|
|
205
226
|
const snapshot = { issue: issueNumber, sourceRevision: revision, defaultBranch: metadata.default_branch, runId, title: issue.title, implementation };
|
|
206
227
|
published = await createImplementationPullRequest(publicationApi, snapshot, candidate.changes, 'Bounded ' + route + ' delivery with independent review. ' + candidate.envelope.effect.body, runUrl, true, recheck);
|
|
207
228
|
await recheck();
|
|
208
|
-
await
|
|
209
|
-
await
|
|
229
|
+
await projectImplementationPrState(api, issueNumber, true);
|
|
230
|
+
if (await releaseReservation() !== 'released') throw Error('The execution reservation changed before PR handoff; inspect the current issue and PR.');
|
|
231
|
+
await notify(handoff({ status: policy.merge ? 'queued' : 'review', stage: route, happened: 'The candidate passed its executed checks and independent review.', action: policy.merge ? 'None. Wait for the required CI and integration check.' : 'Owner: review the linked PR and authorize integration if appropriate.', next: policy.merge ? 'The workflow will recheck the PR head, CI and delegated merge authority.' : 'Integration waits for separate authorization.', link: `[Candidate PR #${published.number}](${published.html_url})`, details: '<!-- lighthouse-pr-state:' + JSON.stringify({ pr: published.number, head: published.head.sha, complete: true, run: Number(env.GITHUB_RUN_ID) }) + ' -->' }));
|
|
210
232
|
await dispatchDeliveryChecks(dispatchApi, revision, published);
|
|
211
|
-
await notify('Ready for integration · ' + route + '\n\n[PR #' + published.number + '](' + published.html_url + ') contains the independently reviewed candidate. Required CI and current merge authority are checked before integration.');
|
|
212
233
|
return published;
|
|
213
234
|
},
|
|
214
235
|
integrate: async (candidate, pr) => {
|
|
@@ -265,22 +286,28 @@ export async function runGithubUnattended({ env = process.env, api = (...args) =
|
|
|
265
286
|
try { await notify(body); }
|
|
266
287
|
catch (error) { notifications.push({ status: 'unavailable', reason: error.message.slice(0, 3000) }); }
|
|
267
288
|
};
|
|
268
|
-
let postMerge;
|
|
289
|
+
let postMerge, postMergeError;
|
|
269
290
|
try { postMerge = await postMergeRuns(dispatchApi, observation, result.sha, !controller.signal.aborted); }
|
|
270
291
|
catch (error) {
|
|
292
|
+
postMergeError = error;
|
|
271
293
|
postMerge = observation.profile.completion.postMergeWorkflows.map(workflow => ({ workflow, revision: result.sha, state: 'missing', status: 'unavailable', reason: error.message.slice(0, 3000) }));
|
|
272
|
-
await notifyIntegrated('Post-merge verification needs attention. The merge at ' + result.sha + ' and its retained receipt are confirmed; inspect current CI runs before requesting verification again. ' + error.message.slice(0, 3000));
|
|
273
294
|
}
|
|
274
|
-
await notifyIntegrated('
|
|
295
|
+
await notifyIntegrated(handoff({ status: postMergeError ? 'blocked' : 'completed', stage: route, happened: `PR #${pr.number} was merged at ${result.sha}.${postMergeError ? ' Post-merge verification could not be requested.' : ''}`, action: postMergeError ? 'Workflow maintainer: inspect the execution and current CI before requesting verification again.' : 'None.', next: postMergeError ? 'Post-merge verification remains unconfirmed.' : 'Post-merge review follows the configured CI and completion policy; release and deployment remain separate.', link: `[Merged PR #${pr.number}](${pr.html_url})` }));
|
|
275
296
|
return { ...result, postMerge, notifications };
|
|
276
297
|
},
|
|
277
|
-
stop: async
|
|
298
|
+
stop: async (reason, _evidence, error) => {
|
|
299
|
+
const released = await releaseReservation();
|
|
300
|
+
if (released === 'stale') return;
|
|
301
|
+
const question = released === 'released' ? pendingQuestion(error?.question) : null;
|
|
302
|
+
if (question) await api('issues/' + issueNumber + '/labels', 'POST', { labels: ['needs-info'] });
|
|
303
|
+
await notify(handoff({ status: released === 'held' ? 'unknown' : question ? 'question' : 'blocked', stage: route, happened: String(reason).slice(0, 4000).replace(/<!--\s*lighthouse-/gi, '<!-- lighthouse-'), action: released === 'held' ? 'Workflow maintainer: inspect the human hold and existing execution reservation before releasing it.' : question ? `Owner: ${question} Reply here.` : 'Workflow maintainer: inspect the execution and retained artifacts, then resolve the blocker.', next: question ? 'The reply is retained. The maintainer must clear needs-info, then the owner can request a new bounded run; the reply does not approve another gate.' : 'No automatic retry is scheduled.', ...(published ? { link: `[Candidate PR #${published.number}](${published.html_url})` } : {}) }));
|
|
304
|
+
},
|
|
278
305
|
} });
|
|
279
306
|
await writeFile(join(root, 'report.json'), JSON.stringify(outcome, null, 2));
|
|
280
307
|
return outcome;
|
|
281
308
|
} finally {
|
|
282
309
|
clearTimeout(timer); process.removeListener('SIGTERM', onSignal); process.removeListener('SIGINT', onSignal);
|
|
283
|
-
if (reserved) await
|
|
310
|
+
if (reserved) await releaseReservation();
|
|
284
311
|
}
|
|
285
312
|
}
|
|
286
313
|
|
package/src/unattended.mjs
CHANGED
|
@@ -155,7 +155,7 @@ export async function runUnattended({ route, policy, host, signal }) {
|
|
|
155
155
|
evidence.push({ kind: 'integration', result: integrated });
|
|
156
156
|
return { status: 'passed', published, integrated, evidence, durationMs: Date.now() - started, nextAction: 'Follow existing post-merge review and release policy' };
|
|
157
157
|
} catch (error) {
|
|
158
|
-
await host.stop(error.message, evidence);
|
|
158
|
+
await host.stop(error.message, evidence, error);
|
|
159
159
|
return { status: 'blocked', evidence, reason: error.message, nextAction: 'Inspect execution evidence and resolve the identified blocker' };
|
|
160
160
|
}
|
|
161
161
|
}
|
|
@@ -2,7 +2,7 @@ import { readFile, writeFile, mkdir, appendFile, readdir } from 'node:fs/promise
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { pathToFileURL } from 'node:url';
|
|
4
4
|
import { dispatchArtifact, sendDispatch } from './stage-dispatch.mjs';
|
|
5
|
-
import { maintenancePath, prepareRecovery } from './worker-recovery.mjs';
|
|
5
|
+
import { maintenancePath, prepareRecovery, observeRecovery } from './worker-recovery.mjs';
|
|
6
6
|
import { publishReports } from './worker-reports.mjs';
|
|
7
7
|
|
|
8
8
|
export async function ownerIssues(api, owner) {
|
|
@@ -21,8 +21,9 @@ export async function ownerIssues(api, owner) {
|
|
|
21
21
|
async function main(command) {
|
|
22
22
|
const env = process.env, repository = env.GITHUB_REPOSITORY, owner = env.GITHUB_REPOSITORY_OWNER;
|
|
23
23
|
if (!/^[\w.-]+\/[\w.-]+$/.test(repository ?? '')) throw Error('Invalid repository');
|
|
24
|
-
const api = async (path, method = 'GET', body) => {
|
|
24
|
+
const api = async (path, method = 'GET', body, optional = false) => {
|
|
25
25
|
const response = await fetch(`https://api.github.com/repos/${repository}${path ? '/' + path : ''}`, { method, headers: { authorization: `Bearer ${env.GH_TOKEN}`, accept: 'application/vnd.github+json', 'content-type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(30000) });
|
|
26
|
+
if (optional && response.status === 404) return null;
|
|
26
27
|
if (!response.ok) throw Error(`GitHub ${method} ${path}: ${response.status}`);
|
|
27
28
|
return response.status === 204 ? null : response.json();
|
|
28
29
|
};
|
|
@@ -35,7 +36,7 @@ async function main(command) {
|
|
|
35
36
|
const directories = await readdir(root);
|
|
36
37
|
if (directories.length > 100) throw Error('Observation inventory exceeds bounds');
|
|
37
38
|
for (const directory of directories) {
|
|
38
|
-
const match = directory.match(/^lighthouse-maintenance-([1-9][0-9]*)$/);
|
|
39
|
+
const match = directory.match(/^lighthouse-(?:maintenance|observation)-([1-9][0-9]*)$/);
|
|
39
40
|
if (!match) throw Error('Unexpected observation artifact');
|
|
40
41
|
const source = await readFile(join(root, directory, 'observations.json'), 'utf8');
|
|
41
42
|
if (Buffer.byteLength(source) > 256000) throw Error('Observation artifact exceeds bounds');
|
|
@@ -59,6 +60,17 @@ async function main(command) {
|
|
|
59
60
|
const issue = Number(env.LIGHTHOUSE_ISSUE);
|
|
60
61
|
if (!Number.isSafeInteger(issue) || issue < 1) throw Error('Invalid issue');
|
|
61
62
|
const dir = join(env.RUNNER_TEMP, `lighthouse-maintenance-${env.GITHUB_RUN_ID}-${issue}`);
|
|
63
|
+
if (command === 'observe') {
|
|
64
|
+
await mkdir(dir, { recursive: true });
|
|
65
|
+
try {
|
|
66
|
+
const observations = await observeRecovery(api, repository, owner, issue);
|
|
67
|
+
await writeFile(join(dir, 'observations.json'), JSON.stringify(observations));
|
|
68
|
+
} catch (error) {
|
|
69
|
+
await writeFile(join(dir, 'observations.json'), JSON.stringify([{ issue, state: 'uncertain', code: 'evidence-unavailable', nextAction: 'Inspect maintenance evidence before requesting another execution.' }]));
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
62
74
|
if (command === 'send') {
|
|
63
75
|
if (env.LIGHTHOUSE_RECOVERY_ENABLED !== 'true') throw Error('Automatic recovery is disabled');
|
|
64
76
|
await sendDispatch(api, repository, JSON.parse(await readFile(join(dir, 'dispatch.json'), 'utf8')), env.LIGHTHOUSE_DISPATCH_COMMENT); return;
|