mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
* feat(ship-queue): require a current /ship receipt before READY, train build and train merge Garry, 2026-10-25: use the gstack ship skill before merging PRs. ready printed SHIP_RECEIPT: none as information and returned ok, so the pool was READY'd without /ship. Now ready, train build (dry run included) and verify --train refuse with QUEUE_RECEIPT_MISSING or QUEUE_RECEIPT_STALE from the PR body alone; verify accepts the receipt written for the pinned head when the current head is its stamp commit. gstack-ship-receipt result is /ship's terminal step (GSTACK_RESULT: skill=ship, run dir with the head and receipt), unattended /ship may update an existing PR's body, and INSTALL_UNVERIFIED with no verdict names the project directory to install from (GSTA-85). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(ship): pin Step 22 in workflow-required-steps and tighten the ship skeleton Gate edit: test/fixtures/workflow-required-steps.json pins the new "## Step 22: Terminal line (every run)" heading (strengthens: a later edit that drops the terminal line turns the required-steps test red). Steps 14, 16, 18, 20 and 22 prose tightened so the carve-guards raise shrinks to 81,600 / 1.6740 (measured skeleton 81,535, union 314,027). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (ready at the stamp commit, screen on a stale merge ref, ship-run.json) - ready accepts the pin's receipt at a slot's stamp commit (plus the slot-2 merge of main), as verify --train already did; a real edit stays STALE - ready --screen refuses QUEUE_SCREEN_STALE_REF when refs/pull/<n>/merge is missing or merges an older head; a finished screen that planned 0 cases is never followed, and train build seats that slot unscreened (GSTA-95 on #3431) - gstack-ship-receipt result writes ship-run.json (a /ship record, not a run v1 manifest), validates --head, and prints status=incomplete on gh failures - bindReceipt takes an optional pr and renders one-line SHIP_RECEIPT lines - tests for every state-matrix cell the review named; the train fixture's merge ref is a real merge commit Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: cycle-2 review fixes (private screen scratch, no bare-merge receipt reuse, 12-hex --head, one receipt scrub) and align the unattended hook text with the GSTACK_RESULT writers (GSTA-91) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: adversarial-review fixes (result refuses an unpushed HEAD, resolves --out and reports unwritable records; ready names a failed merge-ref fetch; unattended /ship never runs gh pr ready) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: close review-cycle gaps (a stamp must change a stamp file, result requires a full --head and fails closed without headRefOid, digits-only --pr) with tests for every cycle-3 branch Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: give the fsck-rejected merge-ref spawns a timeout (spawnsync tripwire) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: detect a hollow screen from the plan it uploads (no paid-plan artifact or nothing planned), correct two troubleshooting facts, and normalise result --pr Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(hollow-screen): read manifest.json by name, list artifacts by name, say when the plan is unreadable; lowercase result --head Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(hollow-screen): expired and unreadable run rows, uppercase --head; docs name every SCREEN: literal Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(train build): say when the repository slug is unreadable instead of counting the screen silently Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: name an ls-remote failure in ready --screen, refuse result --pr with --body, cap echoed receipt errors, tell an unreadable install verdict from a missing one Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: ls-remote refusal, --pr with --body, capped receipt errors, unreadable slug and verdict (both twins); docs name the new literals Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: scrub Unicode line separators from receipt text, report an unreadable --body as incomplete, refuse unsupported verdict schemas in the bash twin Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
209 lines
12 KiB
TypeScript
209 lines
12 KiB
TypeScript
/**
|
|
* install-verdict — the TypeScript twin of bin/gstack-install-verdict.sh: the
|
|
* one reader of the install verdict gstack-capy-install writes, and the
|
|
* install-admission rule gstack-skill-start (Bash) and gstack-autoplan's
|
|
* mutating verbs share. test/install-verdict.test.ts pins both sides.
|
|
*
|
|
* Verdict file: <state root>/install-verdicts/<sha256(checkout realpath)>/<sha256(project realpath | "none")>.json,
|
|
* then the path the GSTACK_LAUNCH line names as GSTACK_INSTALL_VERDICT, then
|
|
* the schema 1 file keyed by the checkout alone (<sha256(checkout)>.json).
|
|
* The project is the working directory's git toplevel unless that is the
|
|
* checkout itself. It is the one record the bun shim, the Bash twin,
|
|
* lib/pregate/runtime.ts and admission read.
|
|
*
|
|
* Admission: applies to unattended and spawned sessions. It refuses (exit 3,
|
|
* `status=refused`) in unattended sessions and explicit
|
|
* GSTACK_SESSION_KIND=spawned sessions with GSTACK_CAPY=1 when the running Bun
|
|
* is below gstack's supported minimum and, with GSTACK_CAPY=1 only, when the
|
|
* verdict is missing, failing, for another checkout or HEAD, narrower than
|
|
* the workflow, for another project when the workflow runs the repo's tests,
|
|
* or records a Bun other than the one running (BUN_RESOLUTION_DRIFT). A
|
|
* verdict for an install profile (`for=review-voice`) covers no host, so it
|
|
* refuses every workflow with the full-install `--for <skill>` command. Other
|
|
* spawned sessions only warn.
|
|
* GSTACK_ALLOW_UNSUPPORTED_RUNTIME=1 admits and sets `overridden` (the caller
|
|
* records it as a run deviation).
|
|
*/
|
|
import { spawnSync } from 'node:child_process';
|
|
import { createHash } from 'node:crypto';
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import { INSTALL_PROFILES, PINS_ADVISORY_SKILLS, SKILL_REQUIREMENTS } from './doctor-components';
|
|
import type { ResultCodeName } from './result-codes';
|
|
import { resolveStateRoot } from './state-root';
|
|
|
|
export interface InstallVerdict {
|
|
schema_version: 1 | 2;
|
|
checkout: string;
|
|
head: string;
|
|
for: string;
|
|
required: string[];
|
|
pins_planning_only: boolean;
|
|
ok: boolean;
|
|
trailer: string;
|
|
min_revision: string;
|
|
launch_path: string;
|
|
time: string;
|
|
/** Schema 2: the project realpath ('' for none) and the Bun resolution recorded for it. */
|
|
project?: string;
|
|
pin?: string;
|
|
bun_path?: string;
|
|
bun_version?: string;
|
|
bun_rule?: string;
|
|
bun_install?: string;
|
|
}
|
|
|
|
export interface InstallAdmission {
|
|
/** The session kind is one admission covers (unattended or spawned). */
|
|
applies: boolean;
|
|
/** Stop: print `line`, then `GSTACK_RESULT: skill=<s> status=refused run=<dir>`, exit 3. */
|
|
refuse: boolean;
|
|
/** Ambient spawned session: print `line` and continue. */
|
|
warn: boolean;
|
|
/** GSTACK_ALLOW_UNSUPPORTED_RUNTIME=1 admitted a failing check; record `detail` as a deviation. */
|
|
overridden: boolean;
|
|
status: 'ok' | 'runtime' | 'verdict';
|
|
detail: string;
|
|
code?: ResultCodeName;
|
|
/** The `INSTALL: fail|warn …` or `INSTALL_VERDICT: …` line; empty when nothing to say. */
|
|
line: string;
|
|
}
|
|
|
|
type Env = Record<string, string | undefined>;
|
|
|
|
/** The source checkout behind an install root: its recorded .source-path, else the root itself (realpath). */
|
|
export function sourceRoot(gstackRoot: string): string {
|
|
const recorded = path.join(gstackRoot, '.source-path');
|
|
let src = gstackRoot;
|
|
if (fs.existsSync(recorded)) {
|
|
const line = fs.readFileSync(recorded, 'utf8').split('\n')[0].trim();
|
|
if (line && fs.existsSync(line)) src = line;
|
|
}
|
|
return fs.realpathSync(src);
|
|
}
|
|
|
|
const sha256 = (text: string) => createHash('sha256').update(text).digest('hex');
|
|
|
|
/** The verdict for (checkout, project); `project` null or '' is "none". */
|
|
export function installVerdictPath(checkout: string, stateRoot: string, project: string | null = null): string {
|
|
return path.join(stateRoot, 'install-verdicts', sha256(checkout), `${sha256(project || 'none')}.json`);
|
|
}
|
|
|
|
/** The schema 1 verdict keyed by the checkout alone (read-only fallback). */
|
|
export function legacyInstallVerdictPath(checkout: string, stateRoot: string): string {
|
|
return path.join(stateRoot, 'install-verdicts', `${sha256(checkout)}.json`);
|
|
}
|
|
|
|
/** The project a verdict is keyed by for `cwd`: its git toplevel realpath, null outside a repo or inside the checkout. */
|
|
export function installProject(checkout: string, cwd: string = process.cwd()): string | null {
|
|
const r = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8', timeout: 10_000 });
|
|
const top = r.status === 0 ? (r.stdout ?? '').trim() : '';
|
|
if (!top) return null;
|
|
try { const real = fs.realpathSync(top); return real === checkout ? null : real; } catch { return null; }
|
|
}
|
|
|
|
/** The verdict file admission reads: (checkout, project), then GSTACK_INSTALL_VERDICT, then the schema 1 file. */
|
|
export function findInstallVerdict(checkout: string, project: string | null, env: Env): string {
|
|
const stateRoot = resolveStateRoot(env);
|
|
const keyed = installVerdictPath(checkout, stateRoot, project);
|
|
if (fs.existsSync(keyed)) return keyed;
|
|
if (env.GSTACK_INSTALL_VERDICT && fs.existsSync(env.GSTACK_INSTALL_VERDICT)) return env.GSTACK_INSTALL_VERDICT;
|
|
const legacy = legacyInstallVerdictPath(checkout, stateRoot);
|
|
return fs.existsSync(legacy) ? legacy : keyed;
|
|
}
|
|
|
|
export function readInstallVerdict(file: string): InstallVerdict | null {
|
|
try {
|
|
const v = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
return v && (v.schema_version === 1 || v.schema_version === 2) ? v as InstallVerdict : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
const below = (a: string, b: string) => {
|
|
const x = a.split(/[.+-]/).map(Number), y = b.split('.').map(Number);
|
|
for (let i = 0; i < 3; i++) if ((x[i] || 0) !== (y[i] || 0)) return (x[i] || 0) < (y[i] || 0);
|
|
return /-/.test(a);
|
|
};
|
|
|
|
function supportedBun(gstackRoot: string): string {
|
|
const text = fs.readFileSync(path.join(gstackRoot, 'bin/gstack-bun-version.sh'), 'utf8');
|
|
return text.match(/^GSTACK_BUN_TESTED="([0-9.]+)"/m)?.[1] ?? '0.0.0';
|
|
}
|
|
|
|
export interface VerdictProblem { detail: string; code: ResultCodeName; fix?: string }
|
|
|
|
/** Why the install verdict for (checkout, project of `cwd`) does not admit `skill`; null when it does. `bunPath` '' skips the running-Bun check. */
|
|
export function verdictProblem(skill: string, gstackRoot: string, env: Env, bunPath: string, bunVersion: string, cwd?: string): VerdictProblem | null {
|
|
const src = sourceRoot(gstackRoot);
|
|
const project = installProject(src, cwd);
|
|
const file = findInstallVerdict(src, project, env);
|
|
const v = readInstallVerdict(file);
|
|
const problem = (detail: string): VerdictProblem => ({ detail, code: 'INSTALL_UNVERIFIED' });
|
|
if (!v && fs.existsSync(file)) return problem(`the install verdict at ${file} is unreadable or has an unsupported schema_version; rerun the installer`);
|
|
if (!v) {
|
|
return { ...problem(`no install verdict at ${file} for ${src}`),
|
|
fix: `run the Capy skill installer (scripts/install.sh --for ${skill}) from ${project ?? 'this directory'}: the verdict is keyed by the project directory the installer runs in, so a run from the skill's own folder does not cover ${project ?? 'it'}; then launch with the GSTACK_LAUNCH line it prints` };
|
|
}
|
|
if (!v.ok) return problem(`the install verdict says: ${v.trailer}`);
|
|
if (v.checkout !== src) return problem(`the install verdict is for checkout ${v.checkout}, not ${src}`);
|
|
const head = spawnSync('git', ['-C', src, 'rev-parse', 'HEAD'], { encoding: 'utf8', timeout: 10_000 }).stdout?.trim() ?? '';
|
|
if (!head || v.head !== head) return problem(`the install verdict is for another HEAD (${v.head.slice(0, 12)}; ${src} is at ${head.slice(0, 12)})`);
|
|
const advisory = PINS_ADVISORY_SKILLS.includes(skill);
|
|
if (v.pins_planning_only && !advisory) return problem(`the install verdict is planning-only (pins skipped for ${v.for}) and ${skill} runs the repo's tests`);
|
|
if (v.schema_version !== 1 && !advisory && (v.project ?? '') !== (project ?? '')) {
|
|
return { ...problem(`the install verdict checked ${v.project || 'no project'}, not ${project ?? 'this directory (no project)'}, and ${skill} runs the repo's tests`),
|
|
fix: `run the Capy skill installer (scripts/install.sh --for ${skill}) from ${project ?? 'this directory'}, then launch with the GSTACK_LAUNCH line it prints` };
|
|
}
|
|
const missing = (SKILL_REQUIREMENTS[skill]?.requires ?? []).find(id => !v.required.includes(id));
|
|
if (missing && INSTALL_PROFILES.includes(v.for)) {
|
|
return { ...problem(`the install verdict certified for=${v.for}, which does not cover ${missing} that ${skill} needs (${v.for} certifies only the read-only helpers)`),
|
|
fix: `run GSTACK_CAPY_ROOT=${src} bash ${src}/capy/skill/gstack/scripts/install.sh --for ${skill} (the full install: ./setup and host profiles), then launch with the GSTACK_LAUNCH line it prints` };
|
|
}
|
|
if (missing) return problem(`the install verdict certified for=${v.for}, which does not cover ${missing} that ${skill} needs`);
|
|
if (v.bun_path && bunPath && bunPath !== v.bun_path) {
|
|
return { detail: `bun ${bunVersion} at ${bunPath} is running, but the install verdict recorded bun ${v.bun_version} at ${v.bun_path}`, code: 'BUN_RESOLUTION_DRIFT',
|
|
fix: `launch with the GSTACK_LAUNCH line (PATH=${path.dirname(v.bun_path)}:$PATH), or re-run the Capy skill installer with --check (scripts/install.sh --check --for ${skill}) to see what changed` };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function realBunPath(): string {
|
|
try { return fs.realpathSync(process.execPath); } catch { return process.execPath; }
|
|
}
|
|
|
|
/**
|
|
* The admission decision for `skill` run from the gstack install at
|
|
* `gstackRoot` (the directory holding bin/). `bunVersion` defaults to the Bun
|
|
* running this process; `env` to process.env.
|
|
*/
|
|
export function checkInstallAdmission(opts: { skill: string; gstackRoot: string; env?: Env; bunVersion?: string; bunPath?: string; cwd?: string }): InstallAdmission {
|
|
const env = opts.env ?? process.env;
|
|
const kind = env.GSTACK_SESSION_KIND === 'unattended' || env.GSTACK_SESSION_KIND === 'spawned' ? env.GSTACK_SESSION_KIND
|
|
: env.OPENCLAW_SESSION ? 'spawned' : 'other';
|
|
const none: InstallAdmission = { applies: false, refuse: false, warn: false, overridden: false, status: 'ok', detail: '', line: '' };
|
|
if (kind === 'other') return none;
|
|
const capy = env.GSTACK_CAPY === '1';
|
|
const hard = kind === 'unattended' || (env.GSTACK_SESSION_KIND === 'spawned' && capy);
|
|
const minimum = supportedBun(opts.gstackRoot);
|
|
const bun = opts.bunVersion ?? process.versions.bun ?? '0.0.0';
|
|
let status: InstallAdmission['status'] = 'ok', detail = '', fix = '', code: ResultCodeName | undefined;
|
|
if (below(bun, minimum)) {
|
|
status = 'runtime'; code = 'RUNTIME_BELOW_MINIMUM';
|
|
detail = `bun ${bun} is below the supported minimum ${minimum}`;
|
|
fix = `run the Capy skill installer (scripts/install.sh --for ${opts.skill}), then launch with its GSTACK_LAUNCH line`;
|
|
} else if (capy) {
|
|
const p = verdictProblem(opts.skill, opts.gstackRoot, env, opts.bunPath ?? (opts.bunVersion ? '' : realBunPath()), bun, opts.cwd);
|
|
if (p) {
|
|
status = p.code === 'BUN_RESOLUTION_DRIFT' ? 'runtime' : 'verdict'; code = p.code; detail = p.detail;
|
|
fix = p.fix ?? `run the Capy skill installer (scripts/install.sh --for ${opts.skill}) on this machine, then launch with the GSTACK_LAUNCH line it prints`;
|
|
}
|
|
}
|
|
const base = { ...none, applies: true, status, detail, code };
|
|
if (status === 'ok') return base;
|
|
if (env.GSTACK_ALLOW_UNSUPPORTED_RUNTIME === '1') return { ...base, overridden: true, line: `INSTALL_VERDICT: overridden (GSTACK_ALLOW_UNSUPPORTED_RUNTIME=1): ${detail}` };
|
|
const line = `INSTALL: ${hard ? 'fail' : 'warn'} ${status} — ${detail}; fix: ${fix} (${code})`;
|
|
return { ...base, refuse: hard, warn: !hard, line };
|
|
}
|