@juangcarmona/lighthouse-sdlc 0.13.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.
- package/README.md +61 -12
- package/completion.md +6 -4
- package/execution-environments.md +43 -0
- package/package.json +2 -1
- package/schemas/execution-environment.schema.json +12 -0
- package/skills/implement/SKILL.md +9 -8
- package/src/actions-artifact.mjs +17 -0
- package/src/completion-actions.mjs +30 -10
- package/src/completion.mjs +19 -0
- package/src/delivery-events.mjs +42 -0
- package/src/execution-environment.mjs +78 -0
- package/src/implementation-runtime.mjs +56 -32
- package/src/implementation.mjs +31 -19
- package/src/install-workflows.mjs +10 -7
- package/src/integration-evidence.mjs +25 -1
- package/src/issue-actions.mjs +260 -38
- package/src/opencode-execute.mjs +20 -10
- package/src/process-publication.mjs +1 -1
- package/src/proposal-review.mjs +91 -0
- package/src/proposal.mjs +22 -8
- package/src/stage-dispatch.mjs +148 -0
- package/src/stage-transition.mjs +25 -0
- package/src/worker-maintenance.mjs +83 -0
- package/src/worker-recovery.mjs +106 -0
- package/src/worker-reports.mjs +150 -0
- package/workflows/lighthouse-sdlc-apply-review.lock.yml +1335 -0
- package/workflows/lighthouse-sdlc-apply-review.md +205 -0
- package/workflows/lighthouse-sdlc-implement.lock.yml +51 -90
- package/workflows/lighthouse-sdlc-implement.md +40 -11
- package/workflows/lighthouse-sdlc-integrate.lock.yml +36 -83
- package/workflows/lighthouse-sdlc-integrate.md +25 -2
- package/workflows/lighthouse-sdlc-propose.lock.yml +44 -83
- package/workflows/lighthouse-sdlc-propose.md +33 -4
- package/workflows/lighthouse-sdlc-refine.lock.yml +45 -83
- package/workflows/lighthouse-sdlc-refine.md +32 -2
- package/workflows/lighthouse-sdlc-review.lock.yml +36 -83
- package/workflows/lighthouse-sdlc-review.md +25 -2
- package/workflows/lighthouse-sdlc-router.yml +70 -62
- package/workflows/lighthouse-worker-maintenance.yml +126 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { digest } from './host-contract.mjs';
|
|
2
|
+
import { proposalRoot, validateProposalFiles } from './proposal.mjs';
|
|
3
|
+
|
|
4
|
+
export const feedbackKey = review => digest(`${review.number}:${review.kind}:${review.id}:${review.feedbackDigest}`);
|
|
5
|
+
export const feedbackWorker = review => review && !review.approved ? 'apply-review' : undefined;
|
|
6
|
+
|
|
7
|
+
export function reviewTarget(event, eventName) {
|
|
8
|
+
if (eventName === 'issue_comment' && event.issue?.pull_request) return event.issue.number;
|
|
9
|
+
if (['pull_request_review', 'pull_request_review_comment'].includes(eventName)) return event.pull_request?.number;
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function checkProposal(pr, repo, expected) {
|
|
14
|
+
const match = pr.head?.ref?.match(/^lighthouse\/propose-([1-9][0-9]*)-([a-f0-9]{12})$/);
|
|
15
|
+
const marker = pr.body?.match(/<!-- lighthouse-proposal:([a-f0-9]{64}) -->/)?.[1];
|
|
16
|
+
if (!match || !marker?.startsWith(match[2]) || pr.user?.login !== 'github-actions[bot]' || pr.user.type !== 'Bot' || pr.state !== 'open' || pr.merged || pr.head.repo?.full_name !== repo || pr.base.repo?.full_name !== repo) throw Error('Not an open repository-owned Lighthouse proposal');
|
|
17
|
+
if (expected && (pr.head.sha !== expected.head || pr.head.ref !== expected.branch || Number(match[1]) !== expected.issue || pr.base.ref !== expected.base)) throw Error('Proposal changed during review; no update published');
|
|
18
|
+
return { number: pr.number, issue: Number(match[1]), head: pr.head.sha, branch: pr.head.ref, base: pr.base.ref, url: pr.html_url };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function checkImplementationPullRequest(pr, repo, expected) {
|
|
22
|
+
const match = pr.head?.ref?.match(/^lighthouse\/implement-([1-9][0-9]*)-([a-f0-9]{12})$/);
|
|
23
|
+
const marker = pr.body?.match(/<!-- lighthouse-implementation:([a-f0-9]{64}) -->/)?.[1];
|
|
24
|
+
if (!match || !marker?.startsWith(match[2]) || pr.user?.login !== 'github-actions[bot]' || pr.user.type !== 'Bot' || pr.state !== 'open' || pr.merged || pr.head.repo?.full_name !== repo || pr.base.repo?.full_name !== repo) throw Error('Not an open repository-owned implementation PR');
|
|
25
|
+
if (expected && (pr.head.sha !== expected.head || pr.head.ref !== expected.branch || Number(match[1]) !== expected.issue || pr.base.ref !== expected.base)) throw Error('Implementation changed during review');
|
|
26
|
+
return { number: pr.number, issue: Number(match[1]), head: pr.head.sha, branch: pr.head.ref, base: pr.base.ref, url: pr.html_url };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function loadProposalReview(api, repo, owner, event, eventName, implementation = false) {
|
|
30
|
+
const number = reviewTarget(event, eventName);
|
|
31
|
+
if (!number) return null;
|
|
32
|
+
const pr = await api(`pulls/${number}`), target = (implementation ? checkImplementationPullRequest : checkProposal)(pr, repo);
|
|
33
|
+
const kind = eventName === 'pull_request_review' ? 'review' : eventName === 'pull_request_review_comment' ? 'inline' : 'comment';
|
|
34
|
+
const id = kind === 'review' ? event.review?.id : event.comment?.id;
|
|
35
|
+
if (!Number.isSafeInteger(id) || id < 1) throw Error('Missing feedback identity');
|
|
36
|
+
const path = kind === 'review' ? `pulls/${number}/reviews/${id}` : kind === 'inline' ? `pulls/comments/${id}` : `issues/comments/${id}`;
|
|
37
|
+
const feedback = await api(path);
|
|
38
|
+
if (feedback.user?.login !== owner || feedback.user.type === 'Bot') throw Error('Feedback is not from the repository owner');
|
|
39
|
+
if (kind === 'comment' && feedback.issue_url !== `https://api.github.com/repos/${repo}/issues/${number}` || kind === 'inline' && feedback.pull_request_url !== `https://api.github.com/repos/${repo}/pulls/${number}`) throw Error('Feedback belongs to another PR');
|
|
40
|
+
if (kind !== 'comment' && feedback.commit_id !== target.head) throw Error('Feedback targets an older proposal commit; review the current diff');
|
|
41
|
+
const approved = kind === 'review' && feedback.state?.toLowerCase() === 'approved';
|
|
42
|
+
if (kind === 'review' && !['approved', 'commented', 'changes_requested'].includes(feedback.state?.toLowerCase())) throw Error('Review is not submitted feedback');
|
|
43
|
+
const inline = kind === 'review' ? await api(`pulls/${number}/reviews/${id}/comments?per_page=100`) : [];
|
|
44
|
+
if (inline.length >= 100) throw Error('Review exceeds bounded feedback collection');
|
|
45
|
+
const body = [feedback.body, ...inline.map(c => `${c.path}:${c.line ?? c.original_line}\n${c.body}`)].filter(Boolean).join('\n\n');
|
|
46
|
+
if (!approved && !body.trim()) throw Error('No review feedback to apply');
|
|
47
|
+
return { ...target, kind, id, path, feedback: body, feedbackDigest: digest(JSON.stringify(feedback)), receipt: `<!-- lighthouse-review:${kind}:${id}:${digest(JSON.stringify(feedback))} -->`, url: pr.html_url, feedbackUrl: feedback.html_url, approved };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function reviewThread(api, number, owner) {
|
|
51
|
+
const messages = [];
|
|
52
|
+
for (const path of [`issues/${number}/comments`, `pulls/${number}/comments`, `pulls/${number}/reviews`]) {
|
|
53
|
+
const rows = await api(`${path}?per_page=100`);
|
|
54
|
+
if (rows.length >= 100) throw Error('Proposal discussion exceeds bounded collection');
|
|
55
|
+
for (const c of rows) if (c.user?.login === owner && c.user.type !== 'Bot' && (!c.state || ['commented', 'changes_requested'].includes(c.state.toLowerCase()))) messages.push({ id: c.id, body: c.body, path: c.path, url: c.html_url, updated: c.updated_at, submitted: c.submitted_at });
|
|
56
|
+
}
|
|
57
|
+
return messages;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function loadProposalFiles(api, review) {
|
|
61
|
+
const changed = await api(`pulls/${review.number}/files?per_page=100`);
|
|
62
|
+
const root = proposalRoot(review.issue);
|
|
63
|
+
if (changed.length >= 100 || changed.some(f => !f.filename.startsWith(`${root}/`) || f.status === 'removed' || f.status === 'renamed')) throw Error('Proposal PR includes changes outside the proposal');
|
|
64
|
+
const files = [];
|
|
65
|
+
for (const f of changed) {
|
|
66
|
+
if (f.filename === `${root}/.openspec.yaml`) continue;
|
|
67
|
+
const value = await api(`contents/${f.filename}?ref=${review.head}`);
|
|
68
|
+
if (value.type !== 'file' || value.encoding !== 'base64' || value.size > 96000) throw Error('Unsupported proposal file');
|
|
69
|
+
files.push({ path: f.filename, content: Buffer.from(value.content, 'base64').toString('utf8') });
|
|
70
|
+
}
|
|
71
|
+
return validateProposalFiles(files, review.issue);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function checkReviewCurrent(api, repo, review, implementation = false) {
|
|
75
|
+
(implementation ? checkImplementationPullRequest : checkProposal)(await api(`pulls/${review.number}`), repo, review);
|
|
76
|
+
if (digest(JSON.stringify(await api(review.path))) !== review.feedbackDigest) throw Error('Review feedback changed; no update published');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function updateProposal(api, repo, snapshot, files, beforeWrite) {
|
|
80
|
+
const review = snapshot.proposalReview;
|
|
81
|
+
const commit = await api(`git/commits/${review.head}`);
|
|
82
|
+
const old = await loadProposalFiles(api, review);
|
|
83
|
+
const removed = old.filter(f => !files.some(n => n.path === f.path)).map(f => ({ path: f.path, mode: '100644', type: 'blob', sha: null }));
|
|
84
|
+
const tree = await api('git/trees', 'POST', { base_tree: commit.tree.sha, tree: [...files.map(f => ({ path: f.path, mode: '100644', type: 'blob', content: f.content })), ...removed] });
|
|
85
|
+
const created = await api('git/commits', 'POST', { message: `docs: address proposal review for #${snapshot.issue}`, tree: tree.sha, parents: [review.head] });
|
|
86
|
+
await beforeWrite();
|
|
87
|
+
await checkReviewCurrent(api, repo, review);
|
|
88
|
+
// Non-forced update rejects a concurrent writer after the final head check.
|
|
89
|
+
await api(`git/refs/heads/${review.branch}`, 'PATCH', { sha: created.sha, force: false });
|
|
90
|
+
return { number: review.number, html_url: review.url };
|
|
91
|
+
}
|
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
|
|
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
|
-
|
|
82
|
-
|
|
81
|
+
const request = values.input;
|
|
82
|
+
if (request.dispatch) {
|
|
83
|
+
if (!['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,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
|
+
}
|