Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 40 additions & 9 deletions docs/node-maintenance-operator.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ reads the canonical PR and displays the selected package versions in the App's
and post `/repair selected` in the Conversation tab. The App replies there with
the request status and exact Actions link. The native Check action remains an
optional secondary entry point. D1 retains the active request and returned Actions run
identity. Dependency repairs and completion handling belong to later slices.
identity. Completed runs update that same reply and release their own request
lock. The current workflow remains a harmless fixture; dependency repairs belong
to later slices.

## Check the selected batch

Expand Down Expand Up @@ -90,14 +92,21 @@ Comment requests additionally retain the source comment ID, reply ID and exact
selections. A unique source-comment index prevents the same comment from
authorizing a second attempt, even under a different delivery UUID. Only the
winning reservation creates a status reply. Its ID is saved before dispatch;
a lost reply POST or failed save retains the lock and starts no run. Replays
update only the recorded reply owned by this App on this PR. They never retry
an ambiguous reply POST. PR refreshes use read-only PR tokens and do not edit
conversation replies.
a lost reply POST or failed save retains the lock and starts no run. Replays,
losing admissions and PR refreshes do not repaint request output or retry an
ambiguous reply POST. Exposed idle Checks are reused without PATCHing them,
or a fresh idle Check is created. Admission still validates current selections
and revisions; an older idle control cannot authorize a stale batch.

The control Check stays completed/neutral while its text changes. Real GitHub
did not reopen the previously completed Check when asked for `in_progress`.
The Actions link, and later canonical completion handling, supply run state.
The pending text stays **Request accepted**, with a workflow-page link, until
verified completion. No request-status writes occur after dispatch except
completion updates using the immutable verified outcome. An uncertain pending
write prevents dispatch and retains the reservation for operator recovery. The reply
says **Workflow succeeded**, **Workflow failed** or **Workflow cancelled** and
retains the exact run link and selected versions. The dependency report is not
rewritten: harmless success does not turn “needs review” into “upgraded”.

## Enable comment delivery after review

Expand Down Expand Up @@ -158,9 +167,31 @@ repeats its dispatch POST. Do not delete an ambiguous row or start another attem
until an operator proves no run remains active and reconciles any observed run
with the exact request inputs, repository, workflow and actor.

Completion updates and releasing finished requests arrive in slice 4. Until then,
even a finished harmless pilot keeps its active lock. Do not describe its
neutral Check as proof that Actions is still running.
For a known run, redeliver its `workflow_run.completed` event after correcting
an output-update failure. The service fetches the canonical run and checks the
saved installation, repository, run ID, workflow path, dispatch event, original
dispatched branch and App actor. It never searches by run title. An early event
that arrives before the run ID is saved can be redelivered afterward.

The service saves the verified conclusion and completion time before updating
the recorded App-owned reply and request Check. These fields can be populated
while the state is still active. A transient GitHub failure retains that lock;
redelivery retries safe output updates without dispatching or posting another
reply. Conflicting conclusions require inspection. A missing or replaced old
reply/Check is not overwritten or recreated; the verified run can still finish
its own record. A new PR head's Check is left alone.

After safe output updates, state becomes `success`, `failure` or `cancelled`.
Other unsuccessful GitHub conclusions map to `failure` and retain the exact
conclusion. The request Check stays neutral and request-bound. A separate idle
Check exposes the current selection action when the same PR head is eligible.
A later deliberate command can start a fresh attempt without manual SQL recovery.
Duplicate completion does not dispatch or create another conversation reply.

The daily Cron Trigger runs at 03:17 UTC and deletes terminal records older
than 90 days. It never expires active or unknown requests, including those with
a saved outcome awaiting output recovery. Retention bounds replay history;
it does not promise permanent deduplication after a record is deleted.

Keep App credentials in operator storage and Worker secrets. Keep verification
receipts outside the repository, without credentials or webhook payloads. A
Expand Down
11 changes: 8 additions & 3 deletions docs/node-maintenance.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,13 +286,18 @@ not enable automatic upgrades. Nothing merges automatically. See
The repair integration under development keeps selection and submission in the
PR's **Conversation** tab. Tick the fixes you allow in the PR description, then
post a new comment containing only `/repair selected` using **Comment**. The App
replies with the request status and the exact Actions link. Checkbox edits alone
replies **Request accepted** with a link to the repair workflow page. It keeps
that message until verified completion supplies the final result and exact run
link. It does not show live progress. Checkbox edits alone
start no work. The Checks tab is optional for this flow.

This requires an installed repair App, a current report that supports comment
requests, and the fixed repair workflow. The current pilot only validates inputs;
it changes no dependencies. Completion reporting and the production repair
renderer are still later delivery slices. Nothing merges automatically.
it changes no dependencies. Its existing reply reports success, failure or
cancellation and links to the run. Once completion is safely recorded, a new
command can request another attempt. A failed status update requires operator
recovery before the next attempt. Production repair and report integration
remain later delivery slices. Nothing merges automatically.
See the [service reference](node-maintenance-operator.md) for current limits.

## Toolchain selection
Expand Down
19 changes: 14 additions & 5 deletions repair-app/pilot/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,17 @@ to fail after writing the receipt. Select `pilot-cancellation` to wait for two
minutes so an operator can cancel the run. Use any increasing stable same-minor
patch pair. These probes belong only to this installation fixture.

Slice 3 proves action admission, replay rejection and one returned run ID.
Completion and releasing active requests belong to slice 4. Until that slice is
deployed, a finished pilot still retains its active request lock. Inspect its
known Actions run and D1 record; do not remove the lock or dispatch again merely
because the Check still says the workflow started.
Slice 3 proves admission, replay rejection and one returned run ID. Completion
handling updates the existing App reply and request Check from that canonical
run. Success, failure and cancellation release only their own request lock after
safe output updates. The reply explicitly states that this fixture performs no
dependency repair. The package report remains unchanged.

To verify completion, retain one real signed `workflow_run.completed` delivery
for each outcome, its known run ID, request state and existing reply URL. Redeliver
one completion to prove idempotence. Start a fresh deliberate command after
completion to prove admission without manual SQL recovery. A transient output
failure must retain the active lock and saved conclusion until redelivery.
Restore the original PR description after temporary probe selections, checking
that no concurrent edit would be overwritten. Record actual source/deployment
identities and distinguish local intercepted API tests from real GitHub events.
79 changes: 65 additions & 14 deletions repair-app/runtime-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ const commandComment = {id: 501, body: '/repair selected', user: {id: 9, type: '
issue_url: 'https://api.github.com/repos/fixture/repo/issues/12',
created_at: '2026-10-24T05:00:00Z', updated_at: '2026-10-24T05:00:00Z'};
let statusComment;
const statusComments = new Map(), idleChecks = [], nativeRuns = new Map();
let nativeRunID = 900, completionReplyFailure = false;
const admissionService = new Miniflare(convertV4MiniflareOptions({
modules: true, scriptPath: new URL('./worker.mjs', import.meta.url).pathname,
compatibilityDate: '2026-10-19', d1Databases: ['DB'],
Expand All @@ -108,32 +110,46 @@ const admissionService = new Miniflare(convertV4MiniflareOptions({
assert.equal(body.permissions.checks, 'write');
return Response.json({token: 'ghs_42.native-action-token'});
}
if (path === '/app') return Response.json({id: 42, slug: 'fixture-maintenance'});
assert.equal(request.headers.get('authorization'), 'Bearer ghs_42.native-action-token');
if (path === '/repos/fixture/repo') return Response.json({id: 7, full_name: 'fixture/repo', default_branch: 'main'});
if (path === '/repos/fixture/repo/pulls/12') return Response.json(nativePR);
if (path === '/repos/fixture/repo/collaborators/maintainer/permission') return Response.json({permission: 'maintain', user: {login: 'maintainer'}});
if (path === '/repos/fixture/repo/issues/comments/501') return Response.json(commandComment);
if (path === `/repos/fixture/repo/issues/comments/${commandComment.id}`) return Response.json(commandComment);
if (path === '/repos/fixture/repo/issues/12/comments' && request.method === 'POST') {
statusComment = {id: 600, ...body, performed_via_github_app: {id: 42}, issue_url: commandComment.issue_url};
statusComment = {id: 600 + statusComments.size, ...body, performed_via_github_app: {id: 42}, issue_url: commandComment.issue_url};
statusComments.set(statusComment.id, statusComment);
return Response.json(statusComment);
}
if (path === '/repos/fixture/repo/issues/comments/600') {
if (request.method === 'PATCH') Object.assign(statusComment, body);
return Response.json(statusComment);
const reply = statusComments.get(Number(path.match(/\/issues\/comments\/([0-9]+)$/)?.[1]));
if (reply) {
if (request.method === 'PATCH' && completionReplyFailure) return new Response(null, {status: 503});
if (request.method === 'PATCH') Object.assign(reply, body);
return Response.json(reply);
}
const check = [nativeCheck, ...idleChecks].find(c => path === `/repos/fixture/repo/check-runs/${c.id}`);
if (check) {
if (request.method === 'PATCH') Object.assign(check, body);
return Response.json(request.method === 'GET' ? documentedCheck(check) : check);
}
if (path === '/repos/fixture/repo/check-runs/77') {
if (request.method === 'PATCH') Object.assign(nativeCheck, body);
return Response.json(request.method === 'GET' ? documentedCheck(nativeCheck) : nativeCheck);
if (path === '/repos/fixture/repo/check-runs' && request.method === 'POST') {
const idle = {id: 78 + idleChecks.length, app: {id: 42}, ...body};idleChecks.push(idle);return Response.json(idle);
}
if (path.includes('/commits/') && path.endsWith('/check-runs')) return Response.json({total_count: 1, check_runs: [documentedCheck(nativeCheck)]});
if (path.includes('/commits/') && path.endsWith('/check-runs')) return Response.json({total_count: 1 + idleChecks.length,
check_runs: [nativeCheck, ...idleChecks].map(documentedCheck)});
if (path.startsWith('/repos/fixture/repo/actions/runs/')) return Response.json(nativeRuns.get(Number(path.split('/').at(-1))));
assert.equal(path, '/repos/fixture/repo/actions/workflows/dependency-repair.yml/dispatches');
assert.equal(request.method, 'POST');
assert.equal(body.ref, 'main');
assert.deepEqual(Object.keys(body.inputs), ['request-id', 'pull-request', 'expected-head', 'expected-base', 'selections']);
assert.equal(body.inputs['pull-request'], '12');assert.equal(body.inputs['expected-head'], nativePR.head.sha);
assert.equal(body.inputs['expected-base'], nativePR.base.sha);assert.equal(body.inputs.selections, JSON.stringify(nativeItems));
return Response.json({workflow_run_id: 900, run_url: 'https://api.github.com/repos/fixture/repo/actions/runs/900',
html_url: 'https://github.com/fixture/repo/actions/runs/900'});
nativeRuns.set(nativeRunID, {id: nativeRunID, repository: {id: 7, full_name: 'fixture/repo'},
path: '.github/workflows/dependency-repair.yml', event: 'workflow_dispatch', head_branch: 'main',
actor: {type: 'Bot', login: 'fixture-maintenance[bot]'}, status: 'completed', conclusion: 'success',
updated_at: '2026-10-24T10:00:00Z', html_url: `https://github.com/fixture/repo/actions/runs/${nativeRunID}`});
return Response.json({workflow_run_id: nativeRunID, run_url: `https://api.github.com/repos/fixture/repo/actions/runs/${nativeRunID}`,
html_url: `https://github.com/fixture/repo/actions/runs/${nativeRunID}`});
},
}));
try {
Expand Down Expand Up @@ -220,12 +236,47 @@ try {
const commentRows = (await db.prepare('SELECT * FROM requests').all()).results;
assert.equal(commentRows.length, 1);assert.equal(commentRows[0].comment_id, 501);
assert.equal(commentRows[0].status_comment_id, 600);assert.equal(commentRows[0].run_id, 900);
assert.match(statusComment.body, /Workflow started/);assert.match(statusComment.body, /launch-editor/);
assert.match(statusComment.body, /actions\/runs\/900/);
assert.match(statusComment.body, /Request accepted/);assert.match(statusComment.body, /launch-editor/);
assert.match(statusComment.body, /actions\/workflows\/dependency-repair\.yml/);
assert.equal(nativeCalls.filter(c => c.path.endsWith('/dispatches')).length, 3);
assert.equal(nativeCalls.filter(c => c.path.endsWith('/12/comments') && c.method === 'POST').length, 1);
assert.ok(nativeCalls.every(c => c.body?.status !== 'in_progress'));
console.log('Native conversation command passed: real local D1 deduplicates concurrent/replayed comments, retains one reply, and links the exact dispatched run without requiring Check navigation.');
console.log('Native conversation command passed: real local D1 deduplicates concurrent/replayed comments, retains one accepted reply with a workflow-page link without requiring Check navigation.');

const complete = (runID, installation = 3) => admissionService.dispatchFetch('https://worker.test/webhook',
signed({action: 'completed', installation: {id: installation}, repository: {id: 7, full_name: 'fixture/repo'},
workflow_run: {id: runID}}, 'workflow_run'));
const callsBeforeUnknown = nativeCalls.length;
assert.equal((await complete(900, 4)).status, 200);assert.equal((await complete(999)).status, 200);
assert.equal(nativeCalls.length, callsBeforeUnknown);
completionReplyFailure = true;
assert.equal((await complete(900)).status, 500);
const pending = await db.prepare('SELECT * FROM requests WHERE id = ?').bind(commandID).first();
assert.equal(pending.state, 'dispatched');assert.equal(pending.conclusion, 'success');assert.ok(pending.completed_at);
assert.equal((await insert(randomUUID())).meta.changes, 0);
completionReplyFailure = false;
assert.equal((await complete(900)).status, 200);
assert.equal((await db.prepare('SELECT state FROM requests WHERE id = ?').bind(commandID).first()).state, 'success');
assert.match(statusComments.get(600).body, /Workflow succeeded/);
assert.equal(nativeCheck.output.title, 'Workflow succeeded');assert.equal(idleChecks.length, 1);
assert.equal((await complete(900)).status, 200);assert.equal(idleChecks.length, 1);

for (const [index, outcome] of ['failure', 'cancelled'].entries()) {
nativeRunID = 901 + index;commandComment.id = 502 + index;
const id = randomUUID();assert.equal((await submit(id)).status, 200);
nativeRuns.get(nativeRunID).conclusion = outcome;
assert.equal((await complete(nativeRunID)).status, 200);
const row = await db.prepare('SELECT * FROM requests WHERE id = ?').bind(id).first();
assert.equal(row.state, outcome);assert.equal(row.conclusion, outcome);
assert.match(statusComment.body, new RegExp(`Workflow ${outcome === 'failure' ? 'failed' : 'cancelled'}`));
assert.match(statusComment.body, /pilot performs no dependency repair/);
assert.equal(idleChecks.length, index + 2);
}
assert.equal((await db.prepare("SELECT COUNT(*) AS count FROM requests WHERE state IN ('reserved','dispatched','unknown')").first()).count, 0);
assert.equal(nativeCalls.filter(c => c.path.endsWith('/dispatches')).length, 5);
assert.equal(nativeCalls.filter(c => c.path.endsWith('/12/comments') && c.method === 'POST').length, 3);
assert.match(statusComments.get(600).body, /Workflow succeeded/);
console.log('Native completion passed: actual D1 retains the lock after reply failure; canonical redelivery completes it; isolated success/failure/cancellation update the same replies and admit fresh commands without SQL recovery. GitHub responses are intercepted local fixtures, not live evidence.');
} finally {
await admissionService.dispose();
}
Loading
Loading