mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
430 lines
20 KiB
JavaScript
430 lines
20 KiB
JavaScript
#!/usr/bin/env node
|
|
// Bounded reconciliation for the event-driven S4/S7 controller (#555).
|
|
//
|
|
// The label remains the state. This script never publishes a verdict and never
|
|
// merges anything; it only wakes the ordinary controller after a proven lost or
|
|
// transiently terminated request, or leaves one deduplicated diagnostic when
|
|
// recovery would require guessing. One invocation reads one snapshot and exits:
|
|
// it does not poll and therefore cannot turn a healthy wait into model usage.
|
|
|
|
import { createHash } from 'node:crypto';
|
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { dirname, join } from 'node:path';
|
|
import { isMainModule } from './spawn-portable.mjs';
|
|
|
|
export const REVIEW_LABELS = ['S4-spec-review', 'S7-code-review'];
|
|
export const ACTIVE_RUN_STATES = new Set(['queued', 'in_progress', 'pending', 'requested', 'waiting']);
|
|
export const RETRYABLE_CONCLUSIONS = new Set(['cancelled', 'timed_out', 'stale', 'startup_failure', 'skipped']);
|
|
export const DEFAULT_GRACE_MS = 5 * 60_000;
|
|
export const DEFAULT_ACTIVE_LIMIT_MS = 4 * 60 * 60_000;
|
|
|
|
const STAGE = { 'S4-spec-review': 'spec', 'S7-code-review': 'code' };
|
|
const RUN_TITLE = /^process #(\d+) · (S4-spec-review|S7-code-review)(?: ·|$)/;
|
|
const MARKER_PREFIX = 'houseplan-process-reconcile:v1';
|
|
const RETRY_COMMENT = /houseplan-process-reconcile:v1:[^\s]+[\s\S]*Автосверка процесса (?:пытается )?повторно/;
|
|
|
|
const at = (value) => {
|
|
const parsed = Date.parse(String(value || ''));
|
|
return Number.isFinite(parsed) ? parsed : NaN;
|
|
};
|
|
|
|
const labelsOf = (issue) => (issue?.labels || []).map((label) =>
|
|
typeof label === 'string' ? label : label?.name).filter(Boolean);
|
|
|
|
export function parseProcessRun(run = {}) {
|
|
const match = String(run.displayTitle || run.display_title || run.name || '').match(RUN_TITLE);
|
|
if (!match) return null;
|
|
return {
|
|
id: Number(run.databaseId ?? run.id),
|
|
attempt: Number(run.attempt ?? run.run_attempt ?? 1),
|
|
issue: Number(match[1]),
|
|
label: match[2],
|
|
stage: STAGE[match[2]],
|
|
status: String(run.status || ''),
|
|
conclusion: run.conclusion == null ? '' : String(run.conclusion),
|
|
createdAt: run.createdAt || run.created_at || run.run_started_at || null,
|
|
updatedAt: run.updatedAt || run.updated_at || run.completedAt || null,
|
|
url: run.url || run.html_url || null,
|
|
prepared: run.prepared || null,
|
|
preparedArtifact: Boolean(run.preparedArtifact),
|
|
resultArtifact: Boolean(run.resultArtifact),
|
|
evidenceError: run.evidenceError || null,
|
|
};
|
|
}
|
|
|
|
export function latestReviewRequest(events = [], label = null) {
|
|
const requests = events
|
|
.filter((event) => event?.event === 'labeled' && REVIEW_LABELS.includes(event?.label?.name))
|
|
.map((event) => ({
|
|
id: String(event.id || event.node_id || event.createdAt || event.created_at || ''),
|
|
at: event.createdAt || event.created_at || null,
|
|
label: event.label.name,
|
|
actor: event.actor?.login || null,
|
|
}))
|
|
.filter((request) => Number.isFinite(at(request.at)) && (!label || request.label === label));
|
|
requests.sort((a, b) => at(a.at) - at(b.at) || a.id.localeCompare(b.id));
|
|
return requests.at(-1) || null;
|
|
}
|
|
|
|
export function preparedEvidenceError(prepared, { issue, stage, run }) {
|
|
if (!prepared) return null;
|
|
const badIdentity = prepared.schema !== 1
|
|
|| String(prepared.run_id) !== String(run.id)
|
|
|| String(prepared.run_attempt) !== String(run.attempt)
|
|
|| String(prepared.issue) !== String(issue.number)
|
|
|| prepared.stage !== stage;
|
|
if (badIdentity) return 'prepared artifact belongs to another issue/stage/run attempt';
|
|
if (!/^[0-9a-f]{40}$/i.test(String(prepared.material_sha || ''))
|
|
|| !/^[0-9a-f]{40}$/i.test(String(prepared.material_tree || ''))
|
|
|| typeof prepared.material_specs !== 'string'
|
|
|| !/^[1-9][0-9]*$/.test(String(prepared.cycle || ''))) {
|
|
return 'prepared artifact has incomplete material SHA/tree/spec hash or round';
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function result(action, reason, context = {}) {
|
|
return { action, reason, ...context };
|
|
}
|
|
|
|
/** Pure controller decision. It never interprets or applies a model verdict. */
|
|
export function decideReconciliation({
|
|
issue, request = null, runs = [], now = Date.now(), graceMs = DEFAULT_GRACE_MS,
|
|
activeLimitMs = DEFAULT_ACTIVE_LIMIT_MS,
|
|
}) {
|
|
const labels = labelsOf(issue);
|
|
const statuses = REVIEW_LABELS.filter((label) => labels.includes(label));
|
|
if (statuses.length === 0) return result('noop', 'verdict already applied or issue is not awaiting review');
|
|
if (statuses.length !== 1) return result('escalate', 'ambiguous review status labels', { label: null });
|
|
const label = statuses[0];
|
|
const stage = STAGE[label];
|
|
if (labels.includes('blocked') || labels.includes('review-4')) {
|
|
return result('noop', 'owner intentionally stopped review', { label, stage });
|
|
}
|
|
if (!request || request.label !== label || !Number.isFinite(at(request.at))) {
|
|
return result('escalate', 'current review label has no unambiguous timeline request', { label, stage });
|
|
}
|
|
|
|
const requestAt = at(request.at);
|
|
const matching = runs
|
|
.map((run) => run.issue ? run : parseProcessRun(run))
|
|
.filter(Boolean)
|
|
.filter((run) => run.issue === Number(issue.number) && run.label === label
|
|
&& Number.isFinite(at(run.createdAt)) && at(run.createdAt) >= requestAt - 120_000)
|
|
.sort((a, b) => at(b.createdAt) - at(a.createdAt) || Number(b.id) - Number(a.id));
|
|
const run = matching[0] || null;
|
|
|
|
if (!run) {
|
|
if (now - requestAt < graceMs) return result('wait', 'label event is still within delivery grace', { label, stage });
|
|
const retryAlreadyIssued = (issue?.comments || []).some((comment) =>
|
|
RETRY_COMMENT.test(String(comment?.body || '')) && at(comment?.createdAt) >= requestAt);
|
|
if (retryAlreadyIssued) {
|
|
return result('escalate', 'one automatic retry was already issued but still has no matching run', { label, stage });
|
|
}
|
|
return result('retry', 'label event has no matching process run', { label, stage });
|
|
}
|
|
if (run.evidenceError) {
|
|
return result('escalate', `run evidence is invalid: ${run.evidenceError}`, { label, stage, run });
|
|
}
|
|
const evidenceError = preparedEvidenceError(run.prepared, { issue, stage, run });
|
|
if (evidenceError) return result('escalate', evidenceError, { label, stage, run });
|
|
|
|
if (ACTIVE_RUN_STATES.has(run.status)) {
|
|
const age = now - at(run.createdAt);
|
|
if (age <= activeLimitMs) return result('wait', 'matching process run is healthy and active', { label, stage, run });
|
|
return result('escalate', 'matching process run is active beyond the controller budget', { label, stage, run });
|
|
}
|
|
if (run.status !== 'completed') {
|
|
return result('escalate', `unknown process run status: ${run.status || 'missing'}`, { label, stage, run });
|
|
}
|
|
const settledAt = at(run.updatedAt || run.createdAt);
|
|
if (Number.isFinite(settledAt) && now - settledAt < graceMs) {
|
|
return result('wait', 'completed run is still within label-application grace', { label, stage, run });
|
|
}
|
|
if (run.conclusion === 'success') {
|
|
return result('escalate', 'successful run did not move the review label', { label, stage, run });
|
|
}
|
|
if (run.resultArtifact) {
|
|
return result('escalate', 'sealed model result exists but was not integrated; automatic rerun would spend the model twice', { label, stage, run });
|
|
}
|
|
if (RETRYABLE_CONCLUSIONS.has(run.conclusion)) {
|
|
return result('retry', `transient process run conclusion: ${run.conclusion}`, { label, stage, run });
|
|
}
|
|
return result('escalate', `non-transient process run conclusion: ${run.conclusion || 'missing'}`, { label, stage, run });
|
|
}
|
|
|
|
export function reconciliationKey(issue, request, decision) {
|
|
const raw = [issue.number, request?.id || 'no-request', decision.label || 'ambiguous',
|
|
decision.run?.id || 'no-run', decision.run?.attempt || '0', decision.action, decision.reason].join('|');
|
|
return createHash('sha256').update(raw).digest('hex').slice(0, 20);
|
|
}
|
|
|
|
export function markerFor(key) {
|
|
return `<!-- ${MARKER_PREFIX}:${key} -->`;
|
|
}
|
|
|
|
export function alreadyReported(issue, key) {
|
|
const marker = markerFor(key);
|
|
return (issue?.comments || []).some((comment) => String(comment?.body || '').includes(marker));
|
|
}
|
|
|
|
function gh(args, { allowFailure = false } = {}) {
|
|
const result = spawnSync('gh', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
if (!allowFailure && (result.error || result.status !== 0)) {
|
|
throw new Error(`gh ${args.join(' ')} → ${(result.stderr || result.error?.message || '').trim()}`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function ghJson(args) {
|
|
const output = gh(args).stdout || 'null';
|
|
return JSON.parse(output);
|
|
}
|
|
|
|
function issueView(repo, number) {
|
|
return ghJson(['issue', 'view', String(number), '--repo', repo, '--json', 'number,title,labels,comments,updatedAt']);
|
|
}
|
|
|
|
function issueEvents(repo, number) {
|
|
const pages = ghJson(['api', '--paginate', '--slurp', `repos/${repo}/issues/${number}/events?per_page=100`]);
|
|
return Array.isArray(pages?.[0]) ? pages.flat() : (Array.isArray(pages) ? pages : []);
|
|
}
|
|
|
|
function openReviewIssues(repo) {
|
|
const fields = 'number,title,labels,comments,updatedAt';
|
|
const rows = REVIEW_LABELS.flatMap((label) => ghJson([
|
|
'issue', 'list', '--repo', repo, '--state', 'open', '--label', label,
|
|
'--limit', '500', '--json', fields,
|
|
]));
|
|
return [...new Map(rows.map((issue) => [issue.number, issue])).values()]
|
|
.sort((a, b) => a.number - b.number);
|
|
}
|
|
|
|
function processRuns(repo, issues = []) {
|
|
const pages = [1, 2].flatMap((page) => {
|
|
const response = ghJson(['api', `repos/${repo}/actions/workflows/process.yml/runs?event=issues&per_page=100&page=${page}`]);
|
|
return response.workflow_runs || [];
|
|
});
|
|
return pages.map((raw) => {
|
|
const stable = parseProcessRun(raw);
|
|
if (stable) return stable;
|
|
// Runs created before #555 used the issue title as display_title. Accept
|
|
// that legacy identity only when it names exactly one current S4/S7 issue;
|
|
// title ambiguity must never wake a potentially different task.
|
|
const title = String(raw.display_title || raw.displayTitle || '');
|
|
const matches = issues.filter((issue) => issue.title === title);
|
|
if (matches.length !== 1) return null;
|
|
const labels = labelsOf(matches[0]);
|
|
const statuses = REVIEW_LABELS.filter((label) => labels.includes(label));
|
|
if (statuses.length !== 1) return null;
|
|
const label = statuses[0];
|
|
return parseProcessRun({
|
|
...raw,
|
|
display_title: `process #${matches[0].number} · ${label} · ${title}`,
|
|
});
|
|
}).filter(Boolean);
|
|
}
|
|
|
|
function artifactNames(repo, run) {
|
|
const response = ghJson(['api', `repos/${repo}/actions/runs/${run.id}/artifacts?per_page=100`]);
|
|
return response.artifacts || [];
|
|
}
|
|
|
|
function loadPreparedArtifact(repo, run, artifact) {
|
|
const dir = mkdtempSync(join(tmpdir(), 'houseplan-process-reconcile-'));
|
|
try {
|
|
const downloaded = gh(['run', 'download', String(run.id), '--repo', repo,
|
|
'--name', artifact.name, '--dir', dir], { allowFailure: true });
|
|
if (downloaded.status !== 0) throw new Error(`artifact download failed: ${(downloaded.stderr || '').trim()}`);
|
|
const file = join(dir, 'prepared.json');
|
|
const manifest = join(dir, 'manifest.sha256');
|
|
if (!existsSync(file) || !existsSync(manifest)) throw new Error('prepared artifact is incomplete');
|
|
const body = readFileSync(file);
|
|
const expected = readFileSync(manifest, 'utf8').trim().split(/\s+/)[0];
|
|
const actual = createHash('sha256').update(body).digest('hex');
|
|
if (expected !== actual) throw new Error('prepared artifact checksum mismatch');
|
|
return JSON.parse(body.toString('utf8'));
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
function hydrateRunEvidence(repo, issue, run) {
|
|
if (!run || run.status !== 'completed') return run;
|
|
try {
|
|
const artifacts = artifactNames(repo, run);
|
|
const preparedName = `review-prepared-${issue.number}-${run.id}-${run.attempt}`;
|
|
const resultName = `review-result-${issue.number}-${run.id}-${run.attempt}`;
|
|
const preparedArtifacts = artifacts.filter((artifact) => artifact.name === preparedName && !artifact.expired);
|
|
const resultArtifacts = artifacts.filter((artifact) => artifact.name === resultName && !artifact.expired);
|
|
if (preparedArtifacts.length > 1 || resultArtifacts.length > 1) {
|
|
return { ...run, evidenceError: 'duplicate prepared/result artifacts' };
|
|
}
|
|
return {
|
|
...run,
|
|
preparedArtifact: preparedArtifacts.length === 1,
|
|
resultArtifact: resultArtifacts.length === 1,
|
|
prepared: preparedArtifacts.length === 1
|
|
? loadPreparedArtifact(repo, run, preparedArtifacts[0]) : null,
|
|
};
|
|
} catch (error) {
|
|
return { ...run, evidenceError: error instanceof Error ? error.message : String(error) };
|
|
}
|
|
}
|
|
|
|
function commentBody(issue, request, decision, key) {
|
|
const run = decision.run;
|
|
const evidence = run?.prepared
|
|
? ` Материал: \`${String(run.prepared.material_sha).slice(0, 12)}\`, tree \`${String(run.prepared.material_tree).slice(0, 12)}\`, ТЗ \`${run.prepared.material_specs || 'нет файлового ТЗ'}\`, раунд r${run.prepared.cycle}.`
|
|
: '';
|
|
const link = run?.url ? ` [Прогон](${run.url}).` : '';
|
|
if (decision.action === 'retry') {
|
|
return `${markerFor(key)}\nАвтосверка процесса пытается повторно разбудить \`${decision.label}\`: ${decision.reason}.${link}${evidence}\n\n`
|
|
+ 'Вердикт не применялся, цикл ревью не расходуется самой сверкой; после успешного восстановления новый запуск заново проверит актуальные метки и материал.';
|
|
}
|
|
return `${markerFor(key)}\n**Автосверка процесса не стала угадывать результат.** ${decision.reason}.${link}${evidence}\n\n`
|
|
+ `Запрос: \`${request?.id || 'не найден'}\`, текущая метка: \`${decision.label || labelsOf(issue).join(', ') || 'нет'}\`. `
|
|
+ 'Метка и вердикт не изменены; требуется разбор человеком.';
|
|
}
|
|
|
|
function addComment(repo, issue, body) {
|
|
gh(['issue', 'comment', String(issue.number), '--repo', repo, '--body', body]);
|
|
}
|
|
|
|
export function relabel(repo, issue, label, execute = gh) {
|
|
execute(['issue', 'edit', String(issue.number), '--repo', repo, '--remove-label', label]);
|
|
const args = ['issue', 'edit', String(issue.number), '--repo', repo, '--add-label', label];
|
|
const first = execute(args, { allowFailure: true });
|
|
if (first.status === 0) return;
|
|
// One bounded restore attempt. Its result is authoritative; unlike the old
|
|
// best-effort call, a second failure is retained in summary.json.
|
|
const restore = execute(args, { allowFailure: true });
|
|
if (restore.status !== 0) throw new Error(`could not restore ${label}: ${(restore.stderr || first.stderr || '').trim()}`);
|
|
}
|
|
|
|
const DEFAULT_APPLY_OPS = {
|
|
comment: (repo, issue, body) => addComment(repo, issue, body),
|
|
relabel: (repo, issue, label) => relabel(repo, issue, label),
|
|
};
|
|
|
|
/** The write path is deliberately small and dependency-injected for failure fixtures. */
|
|
export async function applyReconciliationDecision({ repo, issue, request, decision, key, ops = DEFAULT_APPLY_OPS }) {
|
|
// Persist the dedupe/diagnostic marker before touching the status. If comment
|
|
// publication fails, no relabel happens; if relabel fails, the owner still
|
|
// gets one durable explanation and the next schedule cannot loop silently.
|
|
await ops.comment(repo, issue, commentBody(issue, request, decision, key));
|
|
if (decision.action === 'retry') await ops.relabel(repo, issue, decision.label);
|
|
}
|
|
|
|
async function snapshot(repo, baseRuns, issue) {
|
|
const fresh = issueView(repo, issue.number);
|
|
const events = issueEvents(repo, issue.number);
|
|
const labels = labelsOf(fresh);
|
|
const label = REVIEW_LABELS.find((candidate) => labels.includes(candidate)) || null;
|
|
const request = latestReviewRequest(events, label);
|
|
const candidates = baseRuns.filter((run) => run.issue === issue.number && run.label === label)
|
|
.sort((a, b) => at(b.createdAt) - at(a.createdAt));
|
|
const hydrated = candidates.length ? [hydrateRunEvidence(repo, fresh, candidates[0]), ...candidates.slice(1)] : candidates;
|
|
return { issue: fresh, request, runs: hydrated };
|
|
}
|
|
|
|
const DEFAULT_RUNTIME = {
|
|
openReviewIssues,
|
|
processRuns,
|
|
snapshot,
|
|
applyDecision: applyReconciliationDecision,
|
|
};
|
|
|
|
export async function reconcileAll({
|
|
repo, apply = false, maxActions = 5, now = Date.now(), runtime = DEFAULT_RUNTIME,
|
|
}) {
|
|
const issues = await runtime.openReviewIssues(repo);
|
|
const runs = await runtime.processRuns(repo, issues);
|
|
const records = [];
|
|
let mutations = 0;
|
|
let failures = 0;
|
|
for (const listed of issues) {
|
|
const first = await runtime.snapshot(repo, runs, listed);
|
|
const decision = decideReconciliation({ ...first, now });
|
|
const key = reconciliationKey(first.issue, first.request, decision);
|
|
const record = {
|
|
issue: first.issue.number,
|
|
title: first.issue.title,
|
|
request: first.request,
|
|
action: decision.action,
|
|
reason: decision.reason,
|
|
run: decision.run ? {
|
|
id: decision.run.id, attempt: decision.run.attempt, status: decision.run.status,
|
|
conclusion: decision.run.conclusion, url: decision.run.url,
|
|
materialSha: decision.run.prepared?.material_sha || null,
|
|
materialTree: decision.run.prepared?.material_tree || null,
|
|
materialSpecs: decision.run.prepared?.material_specs || null,
|
|
round: decision.run.prepared?.cycle || null,
|
|
} : null,
|
|
key,
|
|
applied: false,
|
|
};
|
|
if (apply && ['retry', 'escalate'].includes(decision.action) && mutations < maxActions
|
|
&& !alreadyReported(first.issue, key)) {
|
|
// Re-read immediately before a write. A label/body/owner decision may have
|
|
// changed while runs and artifacts were inspected.
|
|
const freshRuns = await runtime.processRuns(repo, [first.issue]);
|
|
const second = await runtime.snapshot(repo, freshRuns, first.issue);
|
|
const confirmed = decideReconciliation({ ...second, now: Date.now() });
|
|
const confirmedKey = reconciliationKey(second.issue, second.request, confirmed);
|
|
if (confirmed.action === decision.action && confirmedKey === key && !alreadyReported(second.issue, key)) {
|
|
try {
|
|
await runtime.applyDecision({ repo, issue: second.issue, request: second.request, decision: confirmed, key });
|
|
record.applied = true;
|
|
mutations++;
|
|
} catch (error) {
|
|
record.error = error instanceof Error ? error.message : String(error);
|
|
failures++;
|
|
}
|
|
} else {
|
|
record.reason = `state changed before write: ${confirmed.action}/${confirmed.reason}`;
|
|
record.action = 'noop';
|
|
}
|
|
}
|
|
records.push(record);
|
|
}
|
|
return {
|
|
schema: 'houseplan-process-reconcile/v1',
|
|
generatedAt: new Date(now).toISOString(),
|
|
repo,
|
|
apply,
|
|
counts: records.reduce((out, row) => ({ ...out, [row.action]: (out[row.action] || 0) + 1 }), {}),
|
|
mutations,
|
|
failures,
|
|
records,
|
|
};
|
|
}
|
|
|
|
if (isMainModule(import.meta.url)) {
|
|
const args = process.argv.slice(2);
|
|
const value = (name, fallback = '') => {
|
|
const eq = args.find((arg) => arg.startsWith(`--${name}=`));
|
|
if (eq) return eq.slice(name.length + 3);
|
|
const index = args.indexOf(`--${name}`);
|
|
return index >= 0 ? (args[index + 1] || 'true') : fallback;
|
|
};
|
|
const repo = value('repo', process.env.GITHUB_REPOSITORY || 'Matysh/houseplan-card');
|
|
const apply = value('apply', 'false') === 'true';
|
|
const maxActions = Number(value('max-actions', '5'));
|
|
const output = value('output', '');
|
|
reconcileAll({ repo, apply, maxActions }).then((summary) => {
|
|
const body = `${JSON.stringify(summary, null, 2)}\n`;
|
|
if (output) {
|
|
mkdirSync(dirname(output), { recursive: true });
|
|
writeFileSync(output, body);
|
|
}
|
|
process.stdout.write(`${JSON.stringify({ schema: summary.schema, counts: summary.counts, mutations: summary.mutations, failures: summary.failures })}\n`);
|
|
if (summary.failures) process.exitCode = 1;
|
|
}).catch((error) => {
|
|
console.error(`process-reconcile: ${error instanceof Error ? error.stack || error.message : String(error)}`);
|
|
process.exitCode = 2;
|
|
});
|
|
}
|