@juangcarmona/lighthouse-sdlc 0.13.0 → 0.15.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.
Files changed (57) hide show
  1. package/README.md +65 -12
  2. package/capabilities.json +3 -0
  3. package/completion.md +6 -4
  4. package/execution-environments.md +43 -0
  5. package/model-profiles.md +101 -0
  6. package/package.json +9 -1
  7. package/request-triage.md +30 -0
  8. package/schemas/estimate.schema.json +23 -0
  9. package/schemas/execution-environment.schema.json +12 -0
  10. package/schemas/request-analysis.schema.json +37 -0
  11. package/schemas/triage.schema.json +1 -0
  12. package/skills/concise-results/SKILL.md +10 -0
  13. package/skills/implement/SKILL.md +9 -8
  14. package/skills/refine/SKILL.md +1 -1
  15. package/skills/triage/SKILL.md +3 -3
  16. package/src/actions-artifact.mjs +17 -0
  17. package/src/completion-actions.mjs +30 -10
  18. package/src/completion.mjs +20 -1
  19. package/src/delivery-events.mjs +42 -0
  20. package/src/execution-environment.mjs +78 -0
  21. package/src/host-contract.mjs +3 -0
  22. package/src/implementation-runtime.mjs +56 -32
  23. package/src/implementation.mjs +36 -19
  24. package/src/install-workflows.mjs +16 -7
  25. package/src/integration-evidence.mjs +25 -1
  26. package/src/issue-actions.mjs +315 -49
  27. package/src/model-profiles.mjs +58 -0
  28. package/src/opencode-execute.mjs +35 -12
  29. package/src/pr-state-actions.mjs +106 -0
  30. package/src/pr-state.mjs +75 -0
  31. package/src/process-publication.mjs +1 -1
  32. package/src/profile-delegation.mjs +111 -0
  33. package/src/proposal-review.mjs +91 -0
  34. package/src/proposal.mjs +22 -8
  35. package/src/request-triage.mjs +42 -0
  36. package/src/stage-dispatch.mjs +164 -0
  37. package/src/stage-transition.mjs +35 -0
  38. package/src/triage.mjs +4 -0
  39. package/src/worker-maintenance.mjs +83 -0
  40. package/src/worker-recovery.mjs +106 -0
  41. package/src/worker-reports.mjs +150 -0
  42. package/templates/change-request.yml +24 -0
  43. package/workflows/lighthouse-sdlc-apply-review.lock.yml +1335 -0
  44. package/workflows/lighthouse-sdlc-apply-review.md +206 -0
  45. package/workflows/lighthouse-sdlc-implement.lock.yml +51 -90
  46. package/workflows/lighthouse-sdlc-implement.md +40 -11
  47. package/workflows/lighthouse-sdlc-integrate.lock.yml +36 -83
  48. package/workflows/lighthouse-sdlc-integrate.md +25 -2
  49. package/workflows/lighthouse-sdlc-propose.lock.yml +44 -83
  50. package/workflows/lighthouse-sdlc-propose.md +33 -4
  51. package/workflows/lighthouse-sdlc-refine.lock.yml +45 -83
  52. package/workflows/lighthouse-sdlc-refine.md +37 -2
  53. package/workflows/lighthouse-sdlc-review.lock.yml +36 -83
  54. package/workflows/lighthouse-sdlc-review.md +25 -2
  55. package/workflows/lighthouse-sdlc-router.yml +104 -63
  56. package/workflows/lighthouse-sdlc-triage.md +174 -28
  57. package/workflows/lighthouse-worker-maintenance.yml +126 -0
package/src/proposal.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { loadDispatch, checkDispatchClaim } from './stage-dispatch.mjs';
1
2
  import { mkdtemp, mkdir, readFile, writeFile, rm } from 'node:fs/promises';
2
3
  import { tmpdir } from 'node:os';
3
4
  import { dirname, join } from 'node:path';
@@ -54,14 +55,13 @@ export async function prepareProposal(issue, files, provider = openspec) {
54
55
  } finally { await rm(dir, { recursive: true, force: true }); }
55
56
  }
56
57
 
57
- export function checkRefinement(predecessor, envelope, run, current, marker) {
58
- if (run.status !== 'completed' || run.conclusion !== 'success' || run.event !== 'issues' && run.event !== 'issue_comment' && run.event !== 'workflow_dispatch' || run.path !== '.github/workflows/lighthouse-sdlc-router.yml' || run.actor?.login !== current.owner) throw Error('Predecessor is not a successful owner workflow');
59
- if (String(run.id) !== String(predecessor.workflowRunId) || run.head_sha !== predecessor.sourceRevision || predecessor.stage !== 'refine' || predecessor.issue !== current.issue || envelope.result?.stage !== 'refine' || envelope.result?.status !== 'passed' || envelope.result?.gate !== 'ready' || envelope.result?.runId !== predecessor.runId || marker !== predecessor.runId) throw Error('Refinement evidence identity mismatch');
58
+ export function checkRefinement(predecessor, envelope, run, current, marker, dispatched = false) {
59
+ if (run.status !== 'completed' || run.conclusion !== 'success' || (dispatched ? run.path !== '.github/workflows/lighthouse-sdlc-refine.lock.yml' : run.event !== 'issues' && run.event !== 'issue_comment' && run.event !== 'workflow_dispatch' || run.path !== '.github/workflows/lighthouse-sdlc-router.yml' || run.actor?.login !== current.owner)) throw Error('Predecessor is not a successful owner workflow');
60
+ if (String(run.id) !== String(predecessor.workflowRunId) || run.head_sha !== (dispatched ? predecessor.workflowRevision : predecessor.sourceRevision) || predecessor.stage !== 'refine' || predecessor.issue !== current.issue || envelope.result?.stage !== 'refine' || envelope.result?.status !== 'passed' || envelope.result?.gate !== 'ready' || envelope.result?.runId !== predecessor.runId || marker !== predecessor.runId) throw Error('Refinement evidence identity mismatch');
60
61
  if (predecessor.sourceRevision !== current.sourceRevision || predecessor.taskDigest !== current.taskDigest) throw Error('Refinement is stale; run refine against the current source and owner intent first');
61
62
  }
62
63
 
63
- export async function loadRefinement(api, repo, reports, current) {
64
- const report = [...reports].reverse().find(c => c.user.login === 'github-actions[bot]' && /<!-- lighthouse-actions:[a-f0-9]{64} -->/.test(c.body));
64
+ export async function loadStageReport(api, repo, report, current) {
65
65
  const marker = report?.body.match(/<!-- lighthouse-actions:([a-f0-9]{64}) -->/)?.[1];
66
66
  const runId = report?.body.match(/\[Workflow run\]\(https:\/\/github\.com\/[^/]+\/[^/]+\/actions\/runs\/(\d+)\)/)?.[1];
67
67
  if (!marker || !runId) throw Error('No authenticated refinement report; run refine first');
@@ -71,18 +71,32 @@ export async function loadRefinement(api, repo, reports, current) {
71
71
  try {
72
72
  const values = {};
73
73
  for (const [kind, file] of [['input', 'request.json'], ['result', 'envelope.json']]) {
74
- const artifact = artifacts.artifacts.find(a => a.name === `lighthouse-${kind}-${current.issue}-${run.run_attempt}` && !a.expired);
74
+ const artifact = artifacts.artifacts.find(a => a.name === `lighthouse-${/<!-- lighthouse-stage:implement:/.test(report.body) ? 'implement-' : ''}${kind}-${current.issue}-${run.run_attempt}` && !a.expired);
75
75
  if (!artifact || artifact.size_in_bytes > 2 * 1024 * 1024) throw Error('Refinement artifact unavailable or too large; run refine first');
76
76
  const archive = join(dir, `${kind}.zip`);
77
77
  const bytes = execFileSync('gh', ['api', `repos/${repo}/actions/artifacts/${artifact.id}/zip`], { maxBuffer: 2 * 1024 * 1024, timeout: 30000 });
78
78
  await writeFile(archive, bytes);
79
79
  values[kind] = JSON.parse(execFileSync('python3', ['-c', 'import sys,zipfile; z=zipfile.ZipFile(sys.argv[1]); matches=[i for i in z.infolist() if i.filename==sys.argv[2]]; assert len(matches)==1 and matches[0].file_size<=128000, "Invalid artifact member"; data=z.open(matches[0]).read(128001); assert len(data)<=128000; sys.stdout.buffer.write(data)', archive, file], { encoding: 'utf8', maxBuffer: 128000, timeout: 10000 }));
80
80
  }
81
- checkRefinement(values.input, values.result, run, current, marker);
82
- return { request: values.input, envelope: values.result, report: report.html_url };
81
+ const request = values.input;
82
+ if (request.dispatch) {
83
+ if (!['triage', 'refine', 'propose', 'implement'].includes(request.stage) || !report.body.includes(`<!-- lighthouse-stage:${request.stage}:`)) throw Error('Unexpected dispatched stage');
84
+ const dispatch = await loadDispatch(api, repo, current.owner, current.issue, request.dispatch.commentId, run);
85
+ if (dispatch.request.stage !== request.stage || dispatch.request.id !== request.dispatch.requestId || dispatch.request.sourceRevision !== request.sourceRevision || dispatch.request.workflowRevision !== request.workflowRevision) throw Error('Dispatched report identity mismatch');
86
+ await checkDispatchClaim(api, repo, current.issue, request.dispatch, run.id);
87
+ } 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');
88
+ 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');
89
+ return { request: values.input, envelope: values.result, report: report.html_url, run, marker };
83
90
  } finally { await rm(dir, { recursive: true, force: true }); }
84
91
  }
85
92
 
93
+ export async function loadRefinement(api, repo, reports, current) {
94
+ const report = [...reports].reverse().find(c => c.user.login === 'github-actions[bot]' && (/<!-- lighthouse-stage:refine:passed -->/.test(c.body) || /^## Refinement:/m.test(c.body)) && /<!-- lighthouse-actions:[a-f0-9]{64} -->/.test(c.body));
95
+ const evidence = await loadStageReport(api, repo, report, current);
96
+ checkRefinement(evidence.request, evidence.envelope, evidence.run, current, evidence.marker, !!evidence.request.dispatch);
97
+ return { request: evidence.request, envelope: evidence.envelope, report: evidence.report };
98
+ }
99
+
86
100
  export async function createProposalPullRequest(api, snapshot, files, body, runUrl, beforeWrite) {
87
101
  const branch = `lighthouse/propose-${snapshot.issue}-${snapshot.runId.slice(0, 12)}`;
88
102
  // A fresh immutable branch per run avoids updating a human's or another run's work.
@@ -0,0 +1,42 @@
1
+ import { isDeepStrictEqual } from 'node:util';
2
+
3
+ // Semantic assessments come from the agent; these checks enforce the public contract.
4
+ export const storyPoints = Object.freeze([1, 2, 3, 5, 8, 13]);
5
+ const text = value => typeof value === 'string' && value.trim().length > 0;
6
+ const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
7
+
8
+ export function validateEstimate(estimate, { provisional = false } = {}) {
9
+ const errors = [];
10
+ if (!record(estimate)) return ['estimate must be an object'];
11
+ if (!storyPoints.includes(estimate.points)) errors.push('estimate.points must be 1, 2, 3, 5, 8 or 13');
12
+ if (!['low', 'medium', 'high'].includes(estimate.confidence)) errors.push('estimate.confidence must be low, medium or high');
13
+ if (!['provisional', 'confirmed'].includes(estimate.status) || provisional && estimate.status !== 'provisional') errors.push('estimate.status must match the provisional triage or confirmed refinement assessment');
14
+ for (const factor of ['scope', 'dependencies', 'verification']) if (!text(estimate.rationale?.[factor])) errors.push(`estimate.rationale.${factor} must explain the estimate`);
15
+ if (!Array.isArray(estimate.uncertainty) || estimate.uncertainty.some(item => !text(item))) errors.push('estimate.uncertainty must be a separate array of descriptions');
16
+ return errors;
17
+ }
18
+
19
+ export function validateRequestTriage(output, originalRequest) {
20
+ if (!record(output)) return ['triage output must be an object'];
21
+ const errors = validateEstimate(output.estimate, { provisional: true });
22
+ const analysis = output.requestAnalysis;
23
+ if (!record(analysis)) return [...errors, 'requestAnalysis must preserve the request and distinguish inferences, unknowns and blocking questions'];
24
+ if (typeof analysis.originalRequest !== 'string' || originalRequest !== undefined && analysis.originalRequest !== originalRequest) errors.push('requestAnalysis.originalRequest must preserve the exact original request');
25
+ for (const field of ['problem', 'desiredResult']) if (!text(analysis[field])) errors.push(`requestAnalysis.${field} is required`);
26
+ if (!Array.isArray(analysis.inferences) || analysis.inferences.some(item => !text(item?.inference) || !text(item?.source))) errors.push('each inference needs a description and evidence source');
27
+ if (!Array.isArray(analysis.unknowns) || analysis.unknowns.some(item => !text(item))) errors.push('unknowns must be a separate array of descriptions');
28
+ if (!Array.isArray(analysis.questions) || analysis.questions.some(item => !text(item?.question) || !text(item?.blocks))) errors.push('each question must state the outcome it blocks');
29
+ if (analysis.questions?.length) errors.push('blocking questions prevent passed triage');
30
+ return errors;
31
+ }
32
+
33
+ export function validateEstimateRevision(output, prior) {
34
+ if (!prior) return [];
35
+ const errors = validateEstimate(output?.estimate);
36
+ if (!isDeepStrictEqual(output?.estimateRevision?.previous, prior)) errors.push('refinement must preserve the prior estimate');
37
+ if (!text(output?.estimateRevision?.reason)) errors.push('refinement must explain whether and why the estimate changed');
38
+ return errors;
39
+ }
40
+
41
+ // Later owner requests on external issues need the triage receipt checked in preparation.
42
+ export const eligibleIssueAuthor = (issue, owner, stage) => issue?.user?.login === owner || stage === 'triage' || issue?.labels?.some(label => label.name === 'triaged');
@@ -0,0 +1,164 @@
1
+ import { digest } from './host-contract.mjs';
2
+ import { readActionsJson } from './actions-artifact.mjs';
3
+
4
+ const routerPath = '.github/workflows/lighthouse-sdlc-router.yml';
5
+ export const stages = ['triage', 'refine', 'propose', 'implement', 'integrate', 'review'];
6
+ export const workers = [...stages, 'apply-review'];
7
+ export const workerFor = request => request.worker ?? request.stage ?? 'implement';
8
+ export const stagePath = stage => { if (!workers.includes(stage)) throw Error('Unknown dispatch worker'); return `.github/workflows/lighthouse-sdlc-${stage}.lock.yml`; };
9
+ export const implementPath = stagePath('implement');
10
+ export const statusText = (stage, status) => `${({ requested: '📥 Requested', running: '▶️ Execution admitted', completed: '✅ Completed', waiting: '⏸️ Waiting for your response', failed: '❌ Failed or cancelled' })[status]} · ${stage}`;
11
+ 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);
13
+ const sha = value => /^[a-f0-9]{40}$/.test(value ?? '');
14
+ const hash = value => /^[a-f0-9]{64}$/.test(value ?? '');
15
+ export const dispatchId = (repository, runId, stage = 'implement') => digest(`${repository}:${runId}:${stage}`);
16
+ export const receiptId = (repository, receipt) => receipt.recovery ? digest(`${repository}:${receipt.recovery.rootId}:retry:${receipt.recovery.attempt}`) : dispatchId(repository, receipt.feedbackKey ?? receipt.eventRunId ?? receipt.routerRunId, receipt.stage ?? 'implement');
17
+ export const dispatchArtifact = request => `lighthouse-dispatch-${request.id}`;
18
+
19
+ export function readDispatchReceipt(comment, repository, issue) {
20
+ if (comment?.user?.login !== 'github-actions[bot]' || comment.user.type !== 'Bot' || comment.issue_url !== `https://api.github.com/repos/${repository}/issues/${issue}`) throw Error('Untrusted dispatch receipt');
21
+ const matches = [...comment.body.matchAll(/<!-- lighthouse-dispatch:(\{[^\n]*\}) -->/g)];
22
+ if (matches.length !== 1) throw Error('Missing or ambiguous dispatch receipt');
23
+ const receipt = JSON.parse(matches[0][1]);
24
+ 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');
26
+ return receipt;
27
+ }
28
+
29
+ export function dispatchReceiptBody(repository, receipt) {
30
+ const root = `https://github.com/${repository}/actions/runs/`;
31
+ const heading = statusText(workerFor(receipt), receipt.status ?? (receipt.workerRunId ? 'running' : 'requested'));
32
+ const status = receipt.workerRunId ? `[Execution](${root}${receipt.workerRunId}). Open the execution for its current status and result.` : 'Execution requested; execution is not yet confirmed. Do not resend while confirmation is pending.';
33
+ const recovery = receipt.recovery ? `\n\nRetry ${receipt.recovery.attempt}/2 · [Previous request](https://github.com/${repository}/issues/${receipt.issue ?? ''}#issuecomment-${receipt.recovery.previousComment})` : '';
34
+ return `${heading}\n\n${status}${recovery}\n\n[Dispatch origin](${root}${receipt.routerRunId})\n\n<!-- lighthouse-dispatch:${JSON.stringify(receipt)} -->`;
35
+ }
36
+
37
+ export async function issueComments(api, issue) {
38
+ const all = [];
39
+ for (let page = 1; page <= 10; page++) {
40
+ const batch = await api(`issues/${issue}/comments?per_page=100&page=${page}`);
41
+ all.push(...batch);
42
+ if (batch.length < 100) return all;
43
+ }
44
+ throw Error('Comment history exceeds bounded intake');
45
+ }
46
+
47
+ // The router dispatch job serializes receipt creation per issue. Workers acquire
48
+ // the existing issue concurrency group before claiming a receipt.
49
+ export async function recordDispatch(api, repository, request) {
50
+ if (Buffer.byteLength(JSON.stringify(request)) > 128000) throw Error('Dispatch request exceeds its artifact bound');
51
+ const comments = await issueComments(api, request.issue);
52
+ for (const comment of comments) {
53
+ if (comment.user?.login !== 'github-actions[bot]' || !comment.body.includes('<!-- lighthouse-dispatch:')) continue;
54
+ const receipt = readDispatchReceipt(comment, repository, request.issue);
55
+ if (receipt.id === request.id) return { send: false, comment, receipt };
56
+ if (!receipt.workerRunId) throw Error('An earlier stage dispatch is unconfirmed; reconcile it before requesting another');
57
+ const run = await api(`actions/runs/${receipt.workerRunId}`);
58
+ if (run.path !== stagePath(workerFor(receipt)) || run.status !== 'completed' && request.queue !== true) throw Error('A stage execution is still pending or running');
59
+ }
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 };
61
+ const comment = await api(`issues/${request.issue}/comments`, 'POST', { body: dispatchReceiptBody(repository, receipt) });
62
+ return { send: true, comment, receipt };
63
+ }
64
+
65
+ export async function sendDispatch(api, repository, request, commentId) {
66
+ const receipt = readDispatchReceipt(await api(`issues/comments/${commentId}`), repository, request.issue);
67
+ if (receipt.id !== request.id || receipt.digest !== digest(JSON.stringify(request))) throw Error('Dispatch request changed');
68
+ if (receipt.workerRunId) return;
69
+ // Never retry this POST automatically: its response can be lost after admission.
70
+ return api(`actions/workflows/lighthouse-sdlc-${workerFor(request)}.lock.yml/dispatches`, 'POST', { ref: request.ref, inputs: { issue: String(request.issue), 'dispatch-request': String(commentId) } });
71
+ }
72
+
73
+ export function verifyDispatchOrigin(request, receipt, parent, worker, repository, owner, issue) {
74
+ if (workerFor(request) !== workerFor(receipt) || request.feedbackKey !== receipt.feedbackKey || workerFor(request) !== request.stage && !(workerFor(request) === 'apply-review' && ['propose', 'implement'].includes(request.stage))) throw Error('Dispatch worker responsibility mismatch');
75
+ if (JSON.stringify(request.recovery) !== JSON.stringify(receipt.recovery)) throw Error('Recovery receipt identity mismatch');
76
+ const events = ['issues', 'issue_comment', 'pull_request_review', 'pull_request_review_comment', 'workflow_dispatch', 'workflow_run'];
77
+ if (request.schemaVersion !== 1 || request.id !== receipt.id || request.repository !== repository || request.issue !== issue || request.stage !== (receipt.stage ?? 'implement') || !stages.includes(request.stage) || !sha(request.sourceRevision) || !sha(request.workflowRevision) || !hash(request.taskDigest) || typeof request.handoff !== 'boolean' || digest(JSON.stringify(request)) !== receipt.digest) throw Error('Dispatch artifact identity mismatch');
78
+ const recovery = !!request.recovery;
79
+ const originPath = recovery ? '.github/workflows/lighthouse-worker-maintenance.yml' : routerPath;
80
+ const originEvent = recovery ? request.routerEventName : request.eventName;
81
+ const continuation = !recovery && isBotContinuation(request.event, parent.event, parent.actor?.login, parent.triggering_actor?.login) && request.stage === 'implement' && request.handoff && !request.worker && !request.feedbackKey && Number(request.event.inputs.issue) === issue && request.predecessor?.runId === request.event.inputs.after_run;
82
+ 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 && (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');
83
+ const eventRunId = request.eventName === 'workflow_run' ? request.event?.workflow_run?.id : request.event?.inputs?.after_run;
84
+ if (String(eventRunId ?? '') !== String(receipt.eventRunId ?? '') || String(request.eventRunId ?? '') !== String(receipt.eventRunId ?? '')) throw Error('Completion event identity mismatch');
85
+ 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');
86
+ }
87
+
88
+ export async function loadDispatch(api, repository, owner, issue, commentId, worker, readArtifact = readActionsJson, depth = 0) {
89
+ if (depth > 2) throw Error('Recovery chain exceeds its bound');
90
+ if (!positive(commentId)) throw Error('Invalid dispatch receipt identifier');
91
+ const comment = await api(`issues/comments/${commentId}`);
92
+ const receipt = readDispatchReceipt(comment, repository, issue);
93
+ const parent = await api(`actions/runs/${receipt.routerRunId}/attempts/${receipt.routerAttempt}`);
94
+ const request = await readArtifact(api, repository, receipt.routerRunId, dispatchArtifact(receipt), 'dispatch.json');
95
+ verifyDispatchOrigin(request, receipt, parent, worker, repository, owner, issue);
96
+ if (isBotContinuation(request.event, parent.event, parent.actor?.login, parent.triggering_actor?.login)) {
97
+ const predecessor = await api(`actions/runs/${request.predecessor.runId}`);
98
+ 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);
99
+ if (next?.issue !== issue || JSON.stringify(next.predecessor) !== JSON.stringify(request.predecessor)) throw Error('Bot continuation lacks an authenticated refinement');
100
+ }
101
+ const dispatch = { request, receipt, commentId: Number(commentId) };
102
+ if (request.recovery) await (await import('./worker-recovery.mjs')).verifyRecoveryOrigin(api, repository, owner, dispatch, parent, readArtifact, depth);
103
+ return dispatch;
104
+ }
105
+
106
+ export async function claimDispatch(api, repository, dispatch, workerId) {
107
+ const fresh = readDispatchReceipt(await api(`issues/comments/${dispatch.commentId}`), repository, dispatch.request.issue);
108
+ if (fresh.id !== dispatch.receipt.id || fresh.digest !== dispatch.receipt.digest) throw Error('Dispatch receipt changed before admission');
109
+ if (fresh.workerRunId) return false;
110
+ fresh.workerRunId = String(workerId); fresh.status = 'running';
111
+ await api(`issues/comments/${dispatch.commentId}`, 'PATCH', { body: dispatchReceiptBody(repository, fresh) });
112
+ dispatch.receipt = fresh;
113
+ return true;
114
+ }
115
+
116
+ export async function checkDispatchClaim(api, repository, issue, dispatch, workerId) {
117
+ const receipt = readDispatchReceipt(await api(`issues/comments/${dispatch.commentId}`), repository, issue);
118
+ if (receipt.id !== dispatch.requestId || receipt.digest !== dispatch.digest || String(receipt.workerRunId) !== String(workerId)) throw Error('Dispatch admission receipt revoked or changed');
119
+ }
120
+
121
+
122
+ // Every dispatched worker shares the literal issue concurrency key before this call.
123
+ export async function admitDispatch(api, repository, owner, env, event) {
124
+ const issue = Number(env.LIGHTHOUSE_ISSUE);
125
+ if (!Number.isSafeInteger(issue) || issue < 1 || String(issue) !== env.LIGHTHOUSE_ISSUE) throw Error('Dispatch issue must be a canonical positive integer');
126
+ const worker = await api(`actions/runs/${env.GITHUB_RUN_ID}/attempts/${env.GITHUB_RUN_ATTEMPT}`);
127
+ const dispatch = await loadDispatch(api, repository, owner, issue, event.inputs?.['dispatch-request'], worker);
128
+ 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');
129
+ if (dispatch.request.recovery) {
130
+ if (env.LIGHTHOUSE_RECOVERY_ENABLED !== 'true') throw Error('Automatic recovery has been disabled');
131
+ const parent = await api(`actions/runs/${dispatch.receipt.routerRunId}/attempts/${dispatch.receipt.routerAttempt}`);
132
+ await (await import('./worker-recovery.mjs')).verifyRecoveryOrigin(api, repository, owner, dispatch, parent, readActionsJson, 0, true);
133
+ }
134
+ const admitted = await claimDispatch(api, repository, dispatch, env.GITHUB_RUN_ID);
135
+ return { ...dispatch, admitted };
136
+ }
137
+
138
+ export async function finishDispatch(api, repository, issue, commentId, workerId, outcome) {
139
+ if (!positive(commentId)) return;
140
+ const receipt = readDispatchReceipt(await api(`issues/comments/${commentId}`), repository, issue);
141
+ if (String(receipt.workerRunId) !== String(workerId)) return;
142
+ receipt.status = outcome === 'passed' ? 'completed' : outcome === 'blocked' ? 'waiting' : 'failed';
143
+ await api(`issues/comments/${commentId}`, 'PATCH', { body: dispatchReceiptBody(repository, receipt) });
144
+ }
145
+
146
+ // An owner can replay a completed run on a reviewed workflow branch for acceptance.
147
+ // Automated events and manual replays use the same fresh GitHub observation.
148
+ export async function completedEvent(api, event, env, wait = ms => new Promise(resolve => setTimeout(resolve, ms))) {
149
+ if (env.GITHUB_EVENT_NAME === 'workflow_run') return event;
150
+ const id = event.inputs?.after_run;
151
+ if (!id) return null;
152
+ const bot = isBotContinuation(event, env.GITHUB_EVENT_NAME, env.GITHUB_ACTOR, env.GITHUB_TRIGGERING_ACTOR);
153
+ 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');
154
+ let run = await api(`actions/runs/${id}`);
155
+ if (bot) {
156
+ if (run.path !== stagePath('refine')) throw Error('Bot continuation must reference a refinement');
157
+ // The publisher signals before its own workflow has finished its final jobs.
158
+ for (let attempt = 0; run.status !== 'completed' && attempt < 24; attempt++) {
159
+ await wait(5000); run = await api(`actions/runs/${id}`);
160
+ }
161
+ if (run.status !== 'completed') throw Error('Refinement completion timed out; replay after the execution completes');
162
+ }
163
+ return { action: 'completed', workflow_run: { id: run.id, head_sha: run.head_sha } };
164
+ }
@@ -0,0 +1,35 @@
1
+ import { readActionsJson } from './actions-artifact.mjs';
2
+ import { loadDispatch, checkDispatchClaim, stagePath, issueComments } from './stage-dispatch.mjs';
3
+ import { validateStageEnvelope } from './host-contract.mjs';
4
+
5
+ // Explicit dispatch also works when GitHub suppresses follow-up token events.
6
+ // This is a wake-up only; the router rechecks the completed run and owner intent.
7
+ export async function signalRefinementContinuation(api, request, envelope) {
8
+ if (request.stage !== 'refine' || !request.dispatch || request.autoImplement !== true || envelope.result?.status !== 'passed' || envelope.result?.gate !== 'ready') return false;
9
+ if (validateStageEnvelope(envelope, request).length) throw Error('Invalid refinement result');
10
+ const repository = await api('');
11
+ 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
+ return true;
13
+ }
14
+
15
+ // The completed execution is an observation. Its retained owner request grants the handoff.
16
+ export async function refinementAfterRun(api, repository, owner, event, readArtifact = readActionsJson) {
17
+ if (event.action !== 'completed' || !Number.isSafeInteger(event.workflow_run?.id)) return null;
18
+ const run = await api(`actions/runs/${event.workflow_run.id}`);
19
+ if (run.path !== stagePath('refine') || run.status !== 'completed' || run.conclusion !== 'success' || run.head_sha !== event.workflow_run.head_sha) return null;
20
+ const artifacts = await api(`actions/runs/${run.id}/artifacts?per_page=100`);
21
+ if (artifacts.total_count > 100) throw Error('Refinement artifacts exceed bounds');
22
+ const inputs = artifacts.artifacts.filter(a => new RegExp(`^lighthouse-input-[1-9][0-9]*-${run.run_attempt}$`).test(a.name) && !a.expired);
23
+ if (inputs.length !== 1) return null; // Duplicate/no-op executions have no stage input.
24
+ const request = await readArtifact(api, repository, run.id, inputs[0].name, 'request.json');
25
+ if (request.stage !== 'refine' || !request.dispatch || !request.autoImplement || String(request.workflowRunId) !== String(run.id)) return null;
26
+ 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');
28
+ await checkDispatchClaim(api, repository, request.issue, request.dispatch, run.id);
29
+ const envelope = await readArtifact(api, repository, run.id, `lighthouse-result-${request.issue}-${run.run_attempt}`, 'envelope.json');
30
+ if (validateStageEnvelope(envelope, request).length) throw Error('Invalid refinement result');
31
+ if (envelope.result.status !== 'passed' || envelope.result.gate !== 'ready') return null;
32
+ const reports = await issueComments(api, request.issue);
33
+ 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.' };
35
+ }
package/src/triage.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ import { validateEstimate } from './request-triage.mjs';
2
+
1
3
  const DIMENSIONS = Object.freeze({
2
4
  size: ['S', 'M', 'L', 'XL'],
3
5
  urgency: ['normal', 'asap', 'now'],
@@ -126,6 +128,7 @@ function validateAssessment(assessment, errors) {
126
128
  addIssue(errors, 'assessment.invalid', 'assessment must be an object', 'assessment');
127
129
  return;
128
130
  }
131
+ if (assessment.estimate !== undefined) for (const message of validateEstimate(assessment.estimate, { provisional: true })) addIssue(errors, 'assessment.estimate', message, 'assessment.estimate');
129
132
  for (const dimension of Object.keys(DIMENSIONS)) {
130
133
  const value = normalizedValue(dimension, assessment[dimension === 'blast-radius' ? 'blastRadius' : dimension]);
131
134
  if (!DIMENSIONS[dimension].includes(value) && !(dimension === 'blast-radius' && value === 'unknown')) {
@@ -254,6 +257,7 @@ export function buildTriageReport(input) {
254
257
  canApply: status === 'ready',
255
258
  workItem: input.workItem ?? null,
256
259
  assessment: {
260
+ ...(assessment.estimate !== undefined ? { estimate: assessment.estimate } : {}),
257
261
  size: normalizedValue('size', assessment.size),
258
262
  urgency: normalizedValue('urgency', assessment.urgency),
259
263
  impact: normalizedValue('impact', assessment.impact),
@@ -0,0 +1,83 @@
1
+ import { readFile, writeFile, mkdir, appendFile, readdir } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
4
+ import { dispatchArtifact, sendDispatch } from './stage-dispatch.mjs';
5
+ import { maintenancePath, prepareRecovery } from './worker-recovery.mjs';
6
+ import { publishReports } from './worker-reports.mjs';
7
+
8
+ export async function ownerIssues(api, owner) {
9
+ const issues = [];
10
+ for (let page = 1; page <= 5; page++) {
11
+ const rows = await api(`issues?state=open&per_page=100&page=${page}`);
12
+ issues.push(...rows.filter(i => !i.pull_request && i.user?.login === owner).map(i => i.number));
13
+ if (rows.length < 100) {
14
+ if (issues.length > 100) throw Error('Maintenance exceeds 100 open owner issues');
15
+ return issues;
16
+ }
17
+ }
18
+ throw Error('Issue inventory exceeds maintenance bounds');
19
+ }
20
+
21
+ async function main(command) {
22
+ const env = process.env, repository = env.GITHUB_REPOSITORY, owner = env.GITHUB_REPOSITORY_OWNER;
23
+ if (!/^[\w.-]+\/[\w.-]+$/.test(repository ?? '')) throw Error('Invalid repository');
24
+ const api = async (path, method = 'GET', body) => {
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 (!response.ok) throw Error(`GitHub ${method} ${path}: ${response.status}`);
27
+ return response.status === 204 ? null : response.json();
28
+ };
29
+ const output = async (name, value) => { if (env.GITHUB_OUTPUT) await appendFile(env.GITHUB_OUTPUT, `${name}=${value}\n`); };
30
+ const parent = await api(`actions/runs/${env.GITHUB_RUN_ID}/attempts/${env.GITHUB_RUN_ATTEMPT}`);
31
+ if (parent.path !== maintenancePath || parent.repository?.full_name !== repository || !['schedule', 'workflow_dispatch'].includes(parent.event) || parent.actor?.login !== owner || parent.triggering_actor?.login !== owner) throw Error('Maintenance is not an authorized execution');
32
+ if (env.LIGHTHOUSE_MAINTENANCE_ENABLED !== 'true') throw Error('Maintenance is not enabled');
33
+ if (command === 'report') {
34
+ const root = join(env.RUNNER_TEMP, 'worker-observations'), observations = [], seen = [];
35
+ const directories = await readdir(root);
36
+ if (directories.length > 100) throw Error('Observation inventory exceeds bounds');
37
+ for (const directory of directories) {
38
+ const match = directory.match(/^lighthouse-maintenance-([1-9][0-9]*)$/);
39
+ if (!match) throw Error('Unexpected observation artifact');
40
+ const source = await readFile(join(root, directory, 'observations.json'), 'utf8');
41
+ if (Buffer.byteLength(source) > 256000) throw Error('Observation artifact exceeds bounds');
42
+ const rows = JSON.parse(source);
43
+ if (!Array.isArray(rows) || rows.some(r => r.issue !== Number(match[1]))) throw Error('Observation issue differs from its artifact');
44
+ observations.push(...rows); seen.push(Number(match[1]));
45
+ }
46
+ const expected = JSON.parse(env.LIGHTHOUSE_OBSERVED_ISSUES ?? '[]');
47
+ if (JSON.stringify(seen.sort((a, b) => a - b)) !== JSON.stringify(expected.sort((a, b) => a - b))) throw Error('Incomplete maintenance observations');
48
+ const reports = await publishReports(api, repository, owner, env.LIGHTHOUSE_REPORT_INDEX_ISSUE, observations);
49
+ if (env.GITHUB_STEP_SUMMARY) await appendFile(env.GITHUB_STEP_SUMMARY, reports.map(r => `- [Report #${r.number}](https://github.com/${repository}/issues/${r.number}): ${r.count} distinct observation(s), ${r.state}.`).join('\n') + '\n');
50
+ return;
51
+ }
52
+ if (command === 'scan') {
53
+ const event = JSON.parse(await readFile(env.GITHUB_EVENT_PATH, 'utf8'));
54
+ const requested = event.inputs?.issue;
55
+ if (requested && !/^[1-9][0-9]*$/.test(requested)) throw Error('Issue must be a canonical positive integer');
56
+ const issues = requested ? [Number(requested)] : await ownerIssues(api, owner);
57
+ await output('issues', JSON.stringify(issues)); return;
58
+ }
59
+ const issue = Number(env.LIGHTHOUSE_ISSUE);
60
+ if (!Number.isSafeInteger(issue) || issue < 1) throw Error('Invalid issue');
61
+ const dir = join(env.RUNNER_TEMP, `lighthouse-maintenance-${env.GITHUB_RUN_ID}-${issue}`);
62
+ if (command === 'send') {
63
+ if (env.LIGHTHOUSE_RECOVERY_ENABLED !== 'true') throw Error('Automatic recovery is disabled');
64
+ await sendDispatch(api, repository, JSON.parse(await readFile(join(dir, 'dispatch.json'), 'utf8')), env.LIGHTHOUSE_DISPATCH_COMMENT); return;
65
+ }
66
+ if (command !== 'prepare') throw Error('Unknown maintenance command');
67
+ await mkdir(dir, { recursive: true });
68
+ try {
69
+ const result = await prepareRecovery(api, repository, owner, issue, parent, env.LIGHTHOUSE_RECOVERY_ENABLED === 'true');
70
+ await writeFile(join(dir, 'observations.json'), JSON.stringify(result.observations));
71
+ if (env.GITHUB_STEP_SUMMARY) await appendFile(env.GITHUB_STEP_SUMMARY, result.observations.map(o => `- Issue #${o.issue}, ${o.worker}: ${o.state}. ${o.nextAction}`).join('\n') + '\n');
72
+ await output('send', String(!!result.request));
73
+ if (result.request) {
74
+ await writeFile(join(dir, 'dispatch.json'), JSON.stringify(result.request));
75
+ await output('artifact', dispatchArtifact(result.request)); await output('comment', result.commentId);
76
+ }
77
+ } catch (error) {
78
+ await writeFile(join(dir, 'observations.json'), JSON.stringify([{ issue, state: 'uncertain', code: 'evidence-unavailable', nextAction: 'Inspect maintenance evidence before requesting another execution.' }]));
79
+ throw error;
80
+ }
81
+ }
82
+
83
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main(process.argv[2]).catch(error => { console.error(error.message); process.exitCode = 1; });
@@ -0,0 +1,106 @@
1
+ import { digest } from './host-contract.mjs';
2
+ import { issueComments, readDispatchReceipt, loadDispatch, workerFor, stagePath, recordDispatch } from './stage-dispatch.mjs';
3
+ import { readActionsJson } from './actions-artifact.mjs';
4
+
5
+ export const maintenancePath = '.github/workflows/lighthouse-worker-maintenance.yml';
6
+ export const retryLimit = 2;
7
+ export const retryDelayMs = 30 * 60 * 1000;
8
+ export const recoveryId = (repository, rootId, attempt) => digest(`${repository}:${rootId}:retry:${attempt}`);
9
+ const identity = r => ({ issue: r.request.issue, worker: workerFor(r.request), runId: String(r.run.id), rootId: r.request.recovery?.rootId ?? r.request.id, attempt: r.request.recovery?.attempt ?? 0 });
10
+ const ownerAnswers = comments => comments.filter(c => c.user?.login === c.owner && !c.body.includes('<!-- lighthouse-run:') && !c.body.includes('<!-- lighthouse-actions:'));
11
+ const intent = (issue, comments, owner) => digest(JSON.stringify({ title: issue.title, body: issue.body, comments: ownerAnswers(comments.map(c => ({ ...c, owner }))).map(c => ({ id: c.id, body: c.body, updated_at: c.updated_at })) }));
12
+
13
+ export function recoveryDecision({ request, receipt, run, jobs, issue, comments, owner, revision, workflowRevision, now = Date.now() }) {
14
+ const base = identity({ request, run });
15
+ const result = (state, code, nextAction, extra = {}) => ({ ...base, state, code, nextAction, ...extra });
16
+ if (run.status !== 'completed') {
17
+ const updated = Date.parse(run.updated_at);
18
+ if (Number.isFinite(updated) && now - updated >= 2 * 60 * 60 * 1000) return result('stalled', 'worker-stalled', 'Inspect the runner and current execution; maintenance never cancels or duplicates active work.');
19
+ return result('active', 'worker-active', 'Inspect the current Actions execution; do not start another.');
20
+ }
21
+ if (run.run_attempt !== 1) return result('uncertain', 'execution-rerun', 'Inspect all attempts; a rerun does not reclaim the original request.');
22
+ if (String(receipt.workerRunId) !== String(run.id)) return result('uncertain', 'dispatch-unconfirmed', 'Reconcile the request with the observed execution.');
23
+ if (receipt.status === 'waiting') return result('waiting-human', 'human-decision', 'Answer the recorded question; no automatic retry.');
24
+ if (receipt.status === 'completed' && run.conclusion === 'success') return result('succeeded', 'worker-completed', 'No recovery needed.');
25
+ if (issue.state !== 'open' || issue.user?.login !== owner || issue.pull_request || issue.labels.some(l => ['future', 'needs-info', 'waiting-review', 'human-working', 'agent-working'].includes(l.name))) return result('blocked', 'issue-paused', 'Inspect the issue decision or reservation before requesting work.');
26
+ if (request.sourceRevision !== revision || request.workflowRevision !== workflowRevision || request.taskDigest !== intent(issue, comments, owner)) return result('blocked', 'authority-changed', 'Request fresh work against the current source and owner intent.');
27
+ if (!['refine', 'propose', 'implement'].includes(request.stage) || !['failure', 'timed_out'].includes(run.conclusion)) return result('uncertain', 'manual-recovery', 'Inspect the outcome and request a new authorized execution if needed.');
28
+ const preparation = jobs.find(j => j.name === 'Authorize and reserve issue');
29
+ const publication = jobs.find(j => j.name === 'Validate, publish and release');
30
+ const publishStep = publication?.steps?.find(s => s.name === 'Recheck authority and publish the validated result');
31
+ const failedExecution = jobs.some(j => ['pre_activation', 'activation', 'agent'].includes(j.name) && ['failure', 'timed_out'].includes(j.conclusion));
32
+ if (preparation?.conclusion !== 'success' || !failedExecution || publishStep?.conclusion !== 'skipped') return result('uncertain', 'publication-uncertain', 'Inspect publication and remote effects before creating another request.');
33
+ if (base.attempt >= retryLimit) return result('exhausted', 'worker-technical-failure', 'Retry budget exhausted. Inspect the failure and make a new owner decision.');
34
+ const ended = Date.parse(run.updated_at);
35
+ if (!Number.isFinite(ended) || !Number.isFinite(now)) return result('uncertain', 'execution-time-unknown', 'Inspect the execution timestamps.');
36
+ const retryAt = new Date(ended + retryDelayMs).toISOString();
37
+ if (now < ended + retryDelayMs) return result('delayed', 'worker-technical-failure', 'Wait until the recovery delay has elapsed.', { retryAt });
38
+ return result('retryable', 'worker-technical-failure', 'Record one linked retry under the adopted policy.', { retryAt });
39
+ }
40
+
41
+ export async function observeDispatch(api, repository, owner, comment, issue, comments, now = Date.now(), readArtifact = readActionsJson) {
42
+ const receipt = readDispatchReceipt(comment, repository, issue.number);
43
+ if (!receipt.workerRunId) {
44
+ // A visible matching run helps the operator reconcile; absence is never permission to resend.
45
+ const found = await api(`actions/workflows/${stagePath(workerFor(receipt)).split('/').at(-1)}/runs?event=workflow_dispatch&per_page=100`);
46
+ const matches = found.workflow_runs.filter(r => r.display_title === `${workerFor(receipt)} #${issue.number} · request ${comment.id}`);
47
+ let observedRunId;
48
+ if (matches.length === 1 && found.total_count <= 100) {
49
+ const run = await api(`actions/runs/${matches[0].id}`);
50
+ await loadDispatch(api, repository, owner, issue.number, comment.id, run, readArtifact);
51
+ observedRunId = String(run.id);
52
+ }
53
+ return { issue: issue.number, worker: workerFor(receipt), rootId: receipt.recovery?.rootId ?? receipt.id, attempt: receipt.recovery?.attempt ?? 0, state: 'uncertain', code: 'dispatch-unconfirmed', nextAction: 'Inspect the request and matching Actions execution; never resend an unconfirmed dispatch.', ...(observedRunId ? { observedRunId } : {}) };
54
+ }
55
+ const run = await api(`actions/runs/${receipt.workerRunId}`);
56
+ const dispatch = await loadDispatch(api, repository, owner, issue.number, comment.id, run, readArtifact);
57
+ const jobs = await api(`actions/runs/${run.id}/attempts/${run.run_attempt}/jobs?per_page=100`);
58
+ if (jobs.total_count > 100) throw Error('Execution job inventory exceeds recovery bounds');
59
+ const repo = await api('');
60
+ const revision = (await api(`commits/${encodeURIComponent(repo.default_branch)}`)).sha;
61
+ const workflowRevision = (await api(`commits/${encodeURIComponent(dispatch.request.ref)}`)).sha;
62
+ return { ...recoveryDecision({ ...dispatch, run, jobs: jobs.jobs, issue, comments, owner, revision, workflowRevision, now }), dispatch };
63
+ }
64
+
65
+ // Called again by the worker, before claiming a maintenance-created retry.
66
+ export async function verifyRecoveryOrigin(api, repository, owner, dispatch, parent, readArtifact, depth, revalidate = false) {
67
+ const r = dispatch.request, retry = r.recovery;
68
+ if (parent.path !== maintenancePath || !retry || !Number.isSafeInteger(retry.attempt) || retry.attempt < 1 || retry.attempt > retryLimit || !/^[1-9][0-9]*$/.test(String(retry.previousComment))) throw Error('Invalid recovery origin');
69
+ const comment = await api(`issues/comments/${retry.previousComment}`);
70
+ const receipt = readDispatchReceipt(comment, repository, r.issue);
71
+ if (!receipt.workerRunId) throw Error('Cannot recover unconfirmed dispatch');
72
+ const priorRun = await api(`actions/runs/${receipt.workerRunId}`);
73
+ const prior = await loadDispatch(api, repository, owner, r.issue, comment.id, priorRun, readArtifact, depth + 1);
74
+ if (retry.rootId !== (prior.request.recovery?.rootId ?? prior.request.id) || retry.attempt !== (prior.request.recovery?.attempt ?? 0) + 1) throw Error('Recovery chain or budget changed');
75
+ const originalFields = value => Object.fromEntries(Object.entries(value).filter(([key]) => !['id', 'routerRunId', 'routerAttempt', 'routerRevision', 'routerRef', 'routerEventName', 'recovery'].includes(key)).sort(([a], [b]) => a.localeCompare(b)));
76
+ if (JSON.stringify(originalFields(r)) !== JSON.stringify(originalFields(prior.request))) throw Error('Recovery changed the original authorization');
77
+ if (!revalidate) return;
78
+ const jobs = await api(`actions/runs/${priorRun.id}/attempts/${priorRun.run_attempt}/jobs?per_page=100`);
79
+ if (jobs.total_count > 100) throw Error('Execution job inventory exceeds recovery bounds');
80
+ const issue = await api(`issues/${r.issue}`), comments = await issueComments(api, r.issue), repo = await api('');
81
+ const revision = (await api(`commits/${encodeURIComponent(repo.default_branch)}`)).sha;
82
+ const workflowRevision = (await api(`commits/${encodeURIComponent(r.ref)}`)).sha;
83
+ const decision = recoveryDecision({ ...prior, run: priorRun, jobs: jobs.jobs, issue, comments, owner, revision, workflowRevision });
84
+ if (decision.state !== 'retryable') throw Error(`Recovery no longer authorized: ${decision.code}`);
85
+ }
86
+
87
+ export async function prepareRecovery(api, repository, owner, issueNumber, parent, enabled, now = Date.now(), readArtifact = readActionsJson) {
88
+ const issue = await api(`issues/${issueNumber}`), comments = await issueComments(api, issueNumber);
89
+ const entries = comments.filter(c => c.user?.login === 'github-actions[bot]' && c.body.includes('<!-- lighthouse-dispatch:')).map(comment => ({ comment, receipt: readDispatchReceipt(comment, repository, issueNumber) }));
90
+ const latest = new Map();
91
+ for (const entry of entries) latest.set(entry.receipt.recovery?.rootId ?? entry.receipt.id, entry);
92
+ const observations = [];
93
+ const currentRoot = entries.at(-1)?.receipt.recovery?.rootId ?? entries.at(-1)?.receipt.id;
94
+ for (const { comment } of latest.values()) {
95
+ const observation = await observeDispatch(api, repository, owner, comment, issue, comments, now, readArtifact);
96
+ const { dispatch, ...publicObservation } = observation;
97
+ observations.push(publicObservation);
98
+ if (observation.state !== 'retryable' || enabled !== true || observation.rootId !== currentRoot) continue;
99
+ const attempt = observation.attempt + 1;
100
+ const request = { ...dispatch.request, id: recoveryId(repository, observation.rootId, attempt), routerRunId: String(parent.id), routerAttempt: parent.run_attempt, routerRevision: parent.head_sha, routerRef: parent.head_branch, routerEventName: parent.event, recovery: { rootId: observation.rootId, previousComment: comment.id, attempt } };
101
+ const recorded = await recordDispatch(api, repository, request);
102
+ // One new retry per issue per maintenance execution, even if several historical roots failed.
103
+ return { observations, ...(recorded.send ? { request, commentId: recorded.comment.id } : {}) };
104
+ }
105
+ return { observations };
106
+ }