@juangcarmona/lighthouse-sdlc 0.12.0 β†’ 0.14.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 (47) hide show
  1. package/README.md +86 -3
  2. package/completion.md +50 -0
  3. package/execution-environments.md +43 -0
  4. package/package.json +3 -1
  5. package/schemas/execution-environment.schema.json +12 -0
  6. package/skills/implement/SKILL.md +9 -8
  7. package/skills/integrate/SKILL.md +7 -1
  8. package/skills/propose/SKILL.md +1 -1
  9. package/skills/review/SKILL.md +7 -1
  10. package/src/actions-artifact.mjs +17 -0
  11. package/src/completion-actions.mjs +134 -0
  12. package/src/completion.mjs +188 -0
  13. package/src/delivery-events.mjs +42 -0
  14. package/src/execution-environment.mjs +78 -0
  15. package/src/github-profile.mjs +9 -1
  16. package/src/host-contract.mjs +7 -3
  17. package/src/implementation-runtime.mjs +90 -0
  18. package/src/implementation.mjs +155 -0
  19. package/src/install-workflows.mjs +14 -5
  20. package/src/integration-evidence.mjs +80 -0
  21. package/src/issue-actions.mjs +325 -38
  22. package/src/observe-protection.mjs +24 -0
  23. package/src/opencode-execute.mjs +128 -20
  24. package/src/opencode-progress.mjs +48 -0
  25. package/src/process-publication.mjs +90 -0
  26. package/src/proposal-review.mjs +91 -0
  27. package/src/proposal.mjs +110 -0
  28. package/src/stage-dispatch.mjs +148 -0
  29. package/src/stage-transition.mjs +25 -0
  30. package/src/worker-maintenance.mjs +83 -0
  31. package/src/worker-recovery.mjs +106 -0
  32. package/src/worker-reports.mjs +150 -0
  33. package/workflows/lighthouse-sdlc-apply-review.lock.yml +1335 -0
  34. package/workflows/lighthouse-sdlc-apply-review.md +205 -0
  35. package/workflows/lighthouse-sdlc-implement.lock.yml +285 -1158
  36. package/workflows/lighthouse-sdlc-implement.md +193 -28
  37. package/workflows/lighthouse-sdlc-integrate.lock.yml +288 -1154
  38. package/workflows/lighthouse-sdlc-integrate.md +197 -28
  39. package/workflows/lighthouse-sdlc-propose.lock.yml +274 -1157
  40. package/workflows/lighthouse-sdlc-propose.md +183 -28
  41. package/workflows/lighthouse-sdlc-refine.lock.yml +263 -90
  42. package/workflows/lighthouse-sdlc-refine.md +45 -4
  43. package/workflows/lighthouse-sdlc-review.lock.yml +288 -1154
  44. package/workflows/lighthouse-sdlc-review.md +197 -28
  45. package/workflows/lighthouse-sdlc-router.yml +91 -17
  46. package/workflows/lighthouse-worker-maintenance.yml +126 -0
  47. package/workflows/shared/lighthouse-opencode.md +4 -2
@@ -0,0 +1,148 @@
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 = ['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
+ const sha = value => /^[a-f0-9]{40}$/.test(value ?? '');
13
+ const hash = value => /^[a-f0-9]{64}$/.test(value ?? '');
14
+ export const dispatchId = (repository, runId, stage = 'implement') => digest(`${repository}:${runId}:${stage}`);
15
+ 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');
16
+ export const dispatchArtifact = request => `lighthouse-dispatch-${request.id}`;
17
+
18
+ export function readDispatchReceipt(comment, repository, issue) {
19
+ 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');
20
+ const matches = [...comment.body.matchAll(/<!-- lighthouse-dispatch:(\{[^\n]*\}) -->/g)];
21
+ if (matches.length !== 1) throw Error('Missing or ambiguous dispatch receipt');
22
+ const receipt = JSON.parse(matches[0][1]);
23
+ 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');
24
+ 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');
25
+ return receipt;
26
+ }
27
+
28
+ export function dispatchReceiptBody(repository, receipt) {
29
+ const root = `https://github.com/${repository}/actions/runs/`;
30
+ const heading = statusText(workerFor(receipt), receipt.status ?? (receipt.workerRunId ? 'running' : 'requested'));
31
+ 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.';
32
+ const recovery = receipt.recovery ? `\n\nRetry ${receipt.recovery.attempt}/2 Β· [Previous request](https://github.com/${repository}/issues/${receipt.issue ?? ''}#issuecomment-${receipt.recovery.previousComment})` : '';
33
+ return `${heading}\n\n${status}${recovery}\n\n[Dispatch origin](${root}${receipt.routerRunId})\n\n<!-- lighthouse-dispatch:${JSON.stringify(receipt)} -->`;
34
+ }
35
+
36
+ export async function issueComments(api, issue) {
37
+ const all = [];
38
+ for (let page = 1; page <= 10; page++) {
39
+ const batch = await api(`issues/${issue}/comments?per_page=100&page=${page}`);
40
+ all.push(...batch);
41
+ if (batch.length < 100) return all;
42
+ }
43
+ throw Error('Comment history exceeds bounded intake');
44
+ }
45
+
46
+ // The router dispatch job serializes receipt creation per issue. Workers acquire
47
+ // the existing issue concurrency group before claiming a receipt.
48
+ export async function recordDispatch(api, repository, request) {
49
+ if (Buffer.byteLength(JSON.stringify(request)) > 128000) throw Error('Dispatch request exceeds its artifact bound');
50
+ const comments = await issueComments(api, request.issue);
51
+ for (const comment of comments) {
52
+ if (comment.user?.login !== 'github-actions[bot]' || !comment.body.includes('<!-- lighthouse-dispatch:')) continue;
53
+ const receipt = readDispatchReceipt(comment, repository, request.issue);
54
+ if (receipt.id === request.id) return { send: false, comment, receipt };
55
+ if (!receipt.workerRunId) throw Error('An earlier stage dispatch is unconfirmed; reconcile it before requesting another');
56
+ const run = await api(`actions/runs/${receipt.workerRunId}`);
57
+ if (run.path !== stagePath(workerFor(receipt)) || run.status !== 'completed' && request.queue !== true) throw Error('A stage execution is still pending or running');
58
+ }
59
+ 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 };
60
+ const comment = await api(`issues/${request.issue}/comments`, 'POST', { body: dispatchReceiptBody(repository, receipt) });
61
+ return { send: true, comment, receipt };
62
+ }
63
+
64
+ export async function sendDispatch(api, repository, request, commentId) {
65
+ const receipt = readDispatchReceipt(await api(`issues/comments/${commentId}`), repository, request.issue);
66
+ if (receipt.id !== request.id || receipt.digest !== digest(JSON.stringify(request))) throw Error('Dispatch request changed');
67
+ if (receipt.workerRunId) return;
68
+ // Never retry this POST automatically: its response can be lost after admission.
69
+ return api(`actions/workflows/lighthouse-sdlc-${workerFor(request)}.lock.yml/dispatches`, 'POST', { ref: request.ref, inputs: { issue: String(request.issue), 'dispatch-request': String(commentId) } });
70
+ }
71
+
72
+ export function verifyDispatchOrigin(request, receipt, parent, worker, repository, owner, issue) {
73
+ 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');
74
+ if (JSON.stringify(request.recovery) !== JSON.stringify(receipt.recovery)) throw Error('Recovery receipt identity mismatch');
75
+ const events = ['issues', 'issue_comment', 'pull_request_review', 'pull_request_review_comment', 'workflow_dispatch', 'workflow_run'];
76
+ 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');
77
+ const recovery = !!request.recovery;
78
+ const originPath = recovery ? '.github/workflows/lighthouse-worker-maintenance.yml' : routerPath;
79
+ const originEvent = recovery ? request.routerEventName : request.eventName;
80
+ 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' && (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');
81
+ const eventRunId = request.eventName === 'workflow_run' ? request.event?.workflow_run?.id : request.event?.inputs?.after_run;
82
+ if (String(eventRunId ?? '') !== String(receipt.eventRunId ?? '') || String(request.eventRunId ?? '') !== String(receipt.eventRunId ?? '')) throw Error('Completion event identity mismatch');
83
+ 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');
84
+ }
85
+
86
+ export async function loadDispatch(api, repository, owner, issue, commentId, worker, readArtifact = readActionsJson, depth = 0) {
87
+ if (depth > 2) throw Error('Recovery chain exceeds its bound');
88
+ if (!positive(commentId)) throw Error('Invalid dispatch receipt identifier');
89
+ const comment = await api(`issues/comments/${commentId}`);
90
+ const receipt = readDispatchReceipt(comment, repository, issue);
91
+ const parent = await api(`actions/runs/${receipt.routerRunId}/attempts/${receipt.routerAttempt}`);
92
+ const request = await readArtifact(api, repository, receipt.routerRunId, dispatchArtifact(receipt), 'dispatch.json');
93
+ verifyDispatchOrigin(request, receipt, parent, worker, repository, owner, issue);
94
+ const dispatch = { request, receipt, commentId: Number(commentId) };
95
+ if (request.recovery) await (await import('./worker-recovery.mjs')).verifyRecoveryOrigin(api, repository, owner, dispatch, parent, readArtifact, depth);
96
+ return dispatch;
97
+ }
98
+
99
+ export async function claimDispatch(api, repository, dispatch, workerId) {
100
+ const fresh = readDispatchReceipt(await api(`issues/comments/${dispatch.commentId}`), repository, dispatch.request.issue);
101
+ if (fresh.id !== dispatch.receipt.id || fresh.digest !== dispatch.receipt.digest) throw Error('Dispatch receipt changed before admission');
102
+ if (fresh.workerRunId) return false;
103
+ fresh.workerRunId = String(workerId); fresh.status = 'running';
104
+ await api(`issues/comments/${dispatch.commentId}`, 'PATCH', { body: dispatchReceiptBody(repository, fresh) });
105
+ dispatch.receipt = fresh;
106
+ return true;
107
+ }
108
+
109
+ export async function checkDispatchClaim(api, repository, issue, dispatch, workerId) {
110
+ const receipt = readDispatchReceipt(await api(`issues/comments/${dispatch.commentId}`), repository, issue);
111
+ if (receipt.id !== dispatch.requestId || receipt.digest !== dispatch.digest || String(receipt.workerRunId) !== String(workerId)) throw Error('Dispatch admission receipt revoked or changed');
112
+ }
113
+
114
+
115
+ // Every dispatched worker shares the literal issue concurrency key before this call.
116
+ export async function admitDispatch(api, repository, owner, env, event) {
117
+ const issue = Number(env.LIGHTHOUSE_ISSUE);
118
+ if (!Number.isSafeInteger(issue) || issue < 1 || String(issue) !== env.LIGHTHOUSE_ISSUE) throw Error('Dispatch issue must be a canonical positive integer');
119
+ const worker = await api(`actions/runs/${env.GITHUB_RUN_ID}/attempts/${env.GITHUB_RUN_ATTEMPT}`);
120
+ const dispatch = await loadDispatch(api, repository, owner, issue, event.inputs?.['dispatch-request'], worker);
121
+ 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');
122
+ if (dispatch.request.recovery) {
123
+ if (env.LIGHTHOUSE_RECOVERY_ENABLED !== 'true') throw Error('Automatic recovery has been disabled');
124
+ const parent = await api(`actions/runs/${dispatch.receipt.routerRunId}/attempts/${dispatch.receipt.routerAttempt}`);
125
+ await (await import('./worker-recovery.mjs')).verifyRecoveryOrigin(api, repository, owner, dispatch, parent, readActionsJson, 0, true);
126
+ }
127
+ const admitted = await claimDispatch(api, repository, dispatch, env.GITHUB_RUN_ID);
128
+ return { ...dispatch, admitted };
129
+ }
130
+
131
+ export async function finishDispatch(api, repository, issue, commentId, workerId, outcome) {
132
+ if (!positive(commentId)) return;
133
+ const receipt = readDispatchReceipt(await api(`issues/comments/${commentId}`), repository, issue);
134
+ if (String(receipt.workerRunId) !== String(workerId)) return;
135
+ receipt.status = outcome === 'passed' ? 'completed' : outcome === 'blocked' ? 'waiting' : 'failed';
136
+ await api(`issues/comments/${commentId}`, 'PATCH', { body: dispatchReceiptBody(repository, receipt) });
137
+ }
138
+
139
+ // An owner can replay a completed run on a reviewed workflow branch for acceptance.
140
+ // Automated events and manual replays use the same fresh GitHub observation.
141
+ export async function completedEvent(api, event, env) {
142
+ if (env.GITHUB_EVENT_NAME === 'workflow_run') return event;
143
+ const id = event.inputs?.after_run;
144
+ if (!id) return null;
145
+ if (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');
146
+ const run = await api(`actions/runs/${id}`);
147
+ return { action: 'completed', workflow_run: { id: run.id, head_sha: run.head_sha } };
148
+ }
@@ -0,0 +1,25 @@
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
+ // The completed execution is an observation. Its retained owner request grants the handoff.
6
+ export async function refinementAfterRun(api, repository, owner, event, readArtifact = readActionsJson) {
7
+ if (event.action !== 'completed' || !Number.isSafeInteger(event.workflow_run?.id)) return null;
8
+ const run = await api(`actions/runs/${event.workflow_run.id}`);
9
+ if (run.path !== stagePath('refine') || run.status !== 'completed' || run.conclusion !== 'success' || run.head_sha !== event.workflow_run.head_sha) return null;
10
+ const artifacts = await api(`actions/runs/${run.id}/artifacts?per_page=100`);
11
+ if (artifacts.total_count > 100) throw Error('Refinement artifacts exceed bounds');
12
+ const inputs = artifacts.artifacts.filter(a => new RegExp(`^lighthouse-input-[1-9][0-9]*-${run.run_attempt}$`).test(a.name) && !a.expired);
13
+ if (inputs.length !== 1) return null; // Duplicate/no-op executions have no stage input.
14
+ const request = await readArtifact(api, repository, run.id, inputs[0].name, 'request.json');
15
+ if (request.stage !== 'refine' || !request.dispatch || !request.autoImplement || String(request.workflowRunId) !== String(run.id)) return null;
16
+ const origin = await loadDispatch(api, repository, owner, request.issue, request.dispatch.commentId, run, readArtifact);
17
+ 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');
18
+ await checkDispatchClaim(api, repository, request.issue, request.dispatch, run.id);
19
+ const envelope = await readArtifact(api, repository, run.id, `lighthouse-result-${request.issue}-${run.run_attempt}`, 'envelope.json');
20
+ if (validateStageEnvelope(envelope, request).length) throw Error('Invalid refinement result');
21
+ if (envelope.result.status !== 'passed' || envelope.result.gate !== 'ready') return null;
22
+ const reports = await issueComments(api, request.issue);
23
+ 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;
24
+ 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.' };
25
+ }
@@ -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
+ }
@@ -0,0 +1,150 @@
1
+ import { digest } from './host-contract.mjs';
2
+ import { workers } from './stage-dispatch.mjs';
3
+
4
+ export const causes = Object.freeze({
5
+ 'worker-technical-failure': ['Technical execution failure', 'Inspect the failed step and retry budget; unresolved work needs an owner decision.'],
6
+ 'worker-stalled': ['Execution has stopped updating', 'Inspect the runner and execution; do not duplicate active work.'],
7
+ 'dispatch-unconfirmed': ['Dispatch confirmation is missing', 'Reconcile the recorded request with Actions before requesting more work.'],
8
+ 'execution-rerun': ['Execution was rerun outside recovery', 'Inspect all attempts and the original claim.'],
9
+ 'authority-changed': ['Source or owner intent changed', 'Request fresh work against the current source and intent.'],
10
+ 'manual-recovery': ['Outcome requires operator recovery', 'Inspect actual effects and make a new owner decision.'],
11
+ 'publication-uncertain': ['Publication effects are uncertain', 'Inspect the PR, issue and publication job before another request.'],
12
+ 'execution-time-unknown': ['Execution timestamp is unavailable', 'Inspect the Actions execution timestamps.'],
13
+ 'evidence-unavailable': ['Recovery evidence is unavailable', 'Inspect the maintenance run and original request artifacts.'],
14
+ });
15
+ const states = ['retryable', 'delayed', 'exhausted', 'uncertain', 'stalled', 'blocked', 'recovered'];
16
+ const positive = x => Number.isSafeInteger(Number(x)) && /^[1-9][0-9]*$/.test(String(x));
17
+ const hash = x => /^[a-f0-9]{64}$/.test(x ?? '');
18
+ export const reportSignature = (repository, worker, code) => digest(`${repository}:${worker}:${code}`);
19
+ const marker = 'lighthouse-worker-report';
20
+ const indexStart = '<!-- lighthouse-worker-report-index:start -->';
21
+ const indexEnd = '<!-- lighthouse-worker-report-index:end -->';
22
+
23
+ function parseMarker(body, name) {
24
+ if (typeof body !== 'string' || Buffer.byteLength(body) > 128000) throw Error('Report body exceeds bounds');
25
+ const values = [...body.matchAll(new RegExp(`<!-- ${name}:(\\{[^\\n]*\\}) -->`, 'g'))];
26
+ if (values.length !== 1) throw Error('Missing or ambiguous report marker');
27
+ return JSON.parse(values[0][1]);
28
+ }
29
+
30
+ function validateEntry(entry) {
31
+ if (!positive(entry.issue) || !hash(entry.rootId) || !Number.isSafeInteger(entry.attempt) || entry.attempt < 0 || entry.attempt > 2 || !states.includes(entry.state) || (entry.runId !== null && !positive(entry.runId)) || entry.key !== (entry.runId ? `run:${entry.runId}` : `request:${entry.rootId}`)) throw Error('Invalid report occurrence');
32
+ if (Object.keys(entry).sort().join(',') !== 'attempt,issue,key,rootId,runId,state') throw Error('Unexpected report occurrence fields');
33
+ }
34
+
35
+ export function readReport(issue, repository, worker, code) {
36
+ if (issue.pull_request || issue.user?.login !== 'github-actions[bot]' || issue.user.type !== 'Bot') throw Error('Report is not bot-owned');
37
+ const report = parseMarker(issue.body, marker);
38
+ if (report.schemaVersion !== 1 || report.worker !== worker || report.code !== code || report.signature !== reportSignature(repository, worker, code) || !Array.isArray(report.entries) || report.entries.length > 200 || Object.keys(report).sort().join(',') !== 'code,entries,schemaVersion,signature,worker') throw Error('Invalid report identity');
39
+ report.entries.forEach(validateEntry);
40
+ if (new Set(report.entries.map(e => e.key)).size !== report.entries.length) throw Error('Duplicate report occurrences');
41
+ return report;
42
+ }
43
+
44
+ function normalizedObservation(repository, observation) {
45
+ if (!positive(observation.issue)) throw Error('Invalid observed issue');
46
+ const worker = observation.worker ?? 'maintenance';
47
+ if (![...workers, 'maintenance'].includes(worker)) throw Error('Unknown observed worker');
48
+ const rootId = observation.rootId ?? digest(`${repository}:${observation.issue}:unavailable`);
49
+ if (!hash(rootId)) throw Error('Invalid observed request');
50
+ if (observation.state === 'succeeded') return { worker, rootId, succeeded: true };
51
+ if (!Object.hasOwn(causes, observation.code) || !states.includes(observation.state) || observation.state === 'recovered') return null;
52
+ const runId = observation.runId ?? observation.observedRunId ?? null;
53
+ const entry = { key: runId ? `run:${runId}` : `request:${rootId}`, rootId, issue: observation.issue, runId: runId === null ? null : String(runId), attempt: observation.attempt ?? 0, state: observation.state };
54
+ validateEntry(entry);
55
+ return { worker, code: observation.code, entry };
56
+ }
57
+
58
+ export function mergeObservations(repository, previous, observations) {
59
+ const entries = new Map(previous.entries.map(e => [e.key, { ...e }]));
60
+ const recoveredRoots = new Set(previous.entries.filter(e => e.state === 'recovered').map(e => e.rootId));
61
+ const normalized = observations.map(o => normalizedObservation(repository, o)).filter(Boolean);
62
+ for (const o of normalized) if (o.worker === previous.worker && o.code === previous.code) entries.set(o.entry.key, { ...o.entry, ...(recoveredRoots.has(o.entry.rootId) ? { state: 'recovered' } : {}) });
63
+ for (const o of normalized) if (o.succeeded && o.worker === previous.worker) for (const entry of entries.values()) if (entry.rootId === o.rootId) entry.state = 'recovered';
64
+ if (entries.size > 200) throw Error('Report exceeds 200 distinct observations; operator archival is required');
65
+ return { ...previous, entries: [...entries.values()].sort((a, b) => a.key.localeCompare(b.key)) };
66
+ }
67
+
68
+ export function reportBody(repository, report) {
69
+ const active = report.entries.filter(e => e.state !== 'recovered').length;
70
+ const rows = report.entries.map(e => `| [#${e.issue}](https://github.com/${repository}/issues/${e.issue}) | ${e.runId ? `[Execution](https://github.com/${repository}/actions/runs/${e.runId})` : 'Unconfirmed request'} | ${e.attempt}/2 | ${e.state} |`);
71
+ const body = `## ${active ? '❌ Attention required' : 'βœ… Recovery confirmed'}\n\nWorker: \`${report.worker}\`. Cause: ${causes[report.code][0]}.\n\n${report.entries.length} distinct observed occurrence(s); ${active} unresolved. Counts cover retained observations, not all repository history.\n\nNext action: ${active ? causes[report.code][1] : 'No action for these occurrences. A new failure reuses this report.'}\n\n| Work item | Evidence | Retry | State |\n| --- | --- | --- | --- |\n${rows.join('\n')}\n\n<!-- ${marker}:${JSON.stringify(report)} -->`;
72
+ if (Buffer.byteLength(body) > 60000) throw Error('Report body exceeds issue bounds; operator archival is required');
73
+ return body;
74
+ }
75
+
76
+ function readIndex(issue, owner) {
77
+ if (issue.pull_request || issue.user?.login !== owner || issue.state !== 'open') throw Error('Report index must be an open owner-authored issue');
78
+ const body = issue.body ?? '';
79
+ if (!body.includes(indexStart) && !body.includes(indexEnd) && !body.includes('<!-- lighthouse-worker-report-index:')) return { schemaVersion: 1, reports: {} };
80
+ if (body.split(indexStart).length !== 2 || body.split(indexEnd).length !== 2 || body.indexOf(indexStart) > body.indexOf(indexEnd)) throw Error('Ambiguous index block');
81
+ const index = parseMarker(body, 'lighthouse-worker-report-index');
82
+ if (index.schemaVersion !== 1 || !index.reports || typeof index.reports !== 'object' || Array.isArray(index.reports) || Object.keys(index.reports).length > 100) throw Error('Invalid report index');
83
+ return index;
84
+ }
85
+
86
+ async function writeIndex(api, issue, index) {
87
+ const current = await api(`issues/${issue.number}`);
88
+ if (current.body !== issue.body || current.state !== 'open') throw Error('Report index changed; no overwrite');
89
+ const links = Object.values(index.reports).map(r => r.issue ? `- #${r.issue}: ${r.worker} / ${r.code}` : `- Pending confirmation: ${r.worker} / ${r.code}`);
90
+ const block = `${indexStart}\n## Worker error reports\n\n${links.join('\n')}\n\n<!-- lighthouse-worker-report-index:${JSON.stringify(index)} -->\n${indexEnd}`;
91
+ const body = (issue.body ?? '').includes(indexStart) ? issue.body.slice(0, issue.body.indexOf(indexStart)) + block + issue.body.slice(issue.body.indexOf(indexEnd) + indexEnd.length) : `${issue.body ?? ''}\n\n${block}`;
92
+ if (Buffer.byteLength(body) > 60000) throw Error('Report index exceeds issue bounds');
93
+ const updated = await api(`issues/${issue.number}`, 'PATCH', { body });
94
+ return updated;
95
+ }
96
+
97
+ async function reportIssues(api) {
98
+ const all = [];
99
+ for (let page = 1; page <= 5; page++) {
100
+ const rows = await api(`issues?state=all&per_page=100&page=${page}`);
101
+ all.push(...rows.filter(i => !i.pull_request && i.body?.includes(`<!-- ${marker}:`)));
102
+ if (rows.length < 100) return all;
103
+ }
104
+ throw Error('Report reconciliation exceeds 500 issue/PR entries');
105
+ }
106
+
107
+ // Caller serializes all report publication with the repository-wide report job.
108
+ export async function publishReports(api, repository, owner, indexNumber, observations) {
109
+ if (!/^[\w.-]+\/[\w.-]+$/.test(repository) || !positive(indexNumber) || !Array.isArray(observations) || observations.length > 1000) throw Error('Invalid report publication input');
110
+ let indexIssue = await api(`issues/${indexNumber}`);
111
+ const index = readIndex(indexIssue, owner), existing = await reportIssues(api), desired = new Map();
112
+ for (const observation of observations) {
113
+ const o = normalizedObservation(repository, observation);
114
+ if (o?.entry) desired.set(reportSignature(repository, o.worker, o.code), { worker: o.worker, code: o.code });
115
+ }
116
+ for (const [signature, row] of Object.entries(index.reports)) {
117
+ if (![...workers, 'maintenance'].includes(row.worker) || !Object.hasOwn(causes, row.code) || signature !== reportSignature(repository, row.worker, row.code) || row.issue !== null && !positive(row.issue)) throw Error('Invalid indexed report');
118
+ desired.set(signature, row);
119
+ }
120
+ if (desired.size > 100) throw Error('Report index exceeds 100 causes');
121
+ const results = [];
122
+ for (const [signature, definition] of desired) {
123
+ const { worker, code } = definition;
124
+ const matches = existing.filter(i => { try { return parseMarker(i.body, marker).signature === signature; } catch { return false; } });
125
+ if (matches.length > 1) throw Error('Ambiguous report identity; inspect duplicates');
126
+ let issue = index.reports[signature]?.issue ? await api(`issues/${index.reports[signature].issue}`) : matches[0];
127
+ if (issue) readReport(issue, repository, worker, code);
128
+ if (!issue && Object.hasOwn(index.reports, signature)) throw Error('Report creation is unconfirmed; inspect the index before any resend');
129
+ if (!issue) {
130
+ const report = mergeObservations(repository, { schemaVersion: 1, signature, worker, code, entries: [] }, observations);
131
+ const body = reportBody(repository, report);
132
+ index.reports[signature] = { worker, code, issue: null };
133
+ indexIssue = await writeIndex(api, indexIssue, index); // Durable intent precedes the non-idempotent POST.
134
+ issue = await api('issues', 'POST', { title: `Worker ${worker}: ${causes[code][0]}`, body });
135
+ readReport(issue, repository, worker, code);
136
+ existing.push(issue);
137
+ }
138
+ if (index.reports[signature]?.issue !== issue.number) {
139
+ index.reports[signature] = { worker, code, issue: issue.number };
140
+ indexIssue = await writeIndex(api, indexIssue, index);
141
+ }
142
+ const previous = readReport(issue, repository, worker, code), report = mergeObservations(repository, previous, observations);
143
+ const body = reportBody(repository, report), active = report.entries.some(e => e.state !== 'recovered');
144
+ const newlyFailed = report.entries.some(e => e.state !== 'recovered' && !previous.entries.some(p => p.key === e.key && p.state !== 'recovered'));
145
+ const state = !active ? 'closed' : newlyFailed ? 'open' : issue.state;
146
+ if (issue.body !== body || issue.state !== state) await api(`issues/${issue.number}`, 'PATCH', { body, state });
147
+ results.push({ number: issue.number, signature, count: report.entries.length, state });
148
+ }
149
+ return results;
150
+ }