mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-02 15:04:37 +01:00
* feat(capy-install): project floor, --for none, WARN for behind and Node-only pins, in-place review-voice upgrade, /cso env hygiene (harness lane F)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* cso: tighten the env-hygiene and tooling-error paragraphs so the controller template stays under the 18,000-byte compactness bound
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* capy skill: name the fail heads once and describe the WARN behind outcome consistently
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* doctor: name the floor field PROJECT_FLOOR_FIELD (it is a JSON field name, not a key)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: pre-landing review fixes
Red team (/ship Step 9): a Drive copy at a bootstrapper revision below GSTACK_CAPY_MIN_WRAPPER is a WARN with the owner action in a direct (no-provenance) run too; only a pre-bootstrapper copy fails. A --for none session never self-upgrades. Stale fail-behind comment in gstack-capy-session; SKILL.md says ok not-needed is not an install verdict. Tests: UPGRADE_SKIPPED collapse, silent in-place message, direct-run WARN, wrapper-2 --for none handover.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: adversarial review fixes
Trailer fix after a skipped upgrade is one runnable command (the refusal prose stays on the WARN behind row); the floor reader is whitespace-insensitive and names a non-version value instead of calling it absent. Found by /ship Step 11 (Claude adversarial, Codex adversarial + structured).
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: review pass 2 fixes (floor reader bounds, cso start is resumed not retried)
The floor reader reads only the first 64 KiB, takes the first occurrence of the field (the top-level one) and names a non-version string truthfully; /cso resumes a start whose ID never printed instead of retrying it (a run may already exist). Found by /ship pass 2 (Claude adversarial, Codex adversarial + structured).
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test: the floor evaluation marker lives in the fixture, not /tmp
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test: take main's host-conformance C3 fix (#3479) so the remote gate can run on this tree
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* RESTAMP: 1.91.257.0 -> 1.91.258.0 (stamped; queue_mode=stamp-at-merge base=origin/main@7f53ddb1f3cc policy=base origin/main@7f53ddb1f3cc:.gstack/ship-policy.json)
ship, next in queue at 1.91.258.0
STAMP_VERSION: 1.91.258.0
claims: ignored (stamp-at-merge)
stage version: done (wrote 1.91.258.0)
stage release_tool: done (bun scripts/gen-agents-digest.ts)
stage changelog: done (Unreleased -> 1.91.258.0)
stage stamp_paths: skipped (no stamp_paths in policy)
stage mirror: skipped (no mirror in policy)
stage apply: done (4 file(s))
FILES: CHANGELOG.md VERSION agents-digest/gstack-AGENTS.md package.json
MERGE_CONDITION: sha=831ac3c3cb3077f16b2f1a7802297523a2db1349
next: NEW_VERSION=1.91.258.0; commit and push only the stamp diff above (FILES), with no edit of your own: the train's verify accepts the CHANGELOG only as the base's plus this PR's entry re-headed; merge with the expected head 831ac3c3cb and recompute on a stale queue.
GSTACK_RESULT: skill=restamp status=complete run=/workspace/gstack/.gstack/tmp/restamp-journal.json
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>
456 lines
29 KiB
TypeScript
456 lines
29 KiB
TypeScript
/**
|
|
* capy/skill/gstack/: the vendored Capy skill. scripts/install.sh is the
|
|
* frozen bootstrapper the Drive copy holds: it prints its wrapper revision
|
|
* first, learns the supported minimum from origin's default branch
|
|
* (`git show FETCH_HEAD:capy/support.env`), fast-forwards a clean, unpinned
|
|
* checkout below that minimum in install mode only (--check never moves the
|
|
* checkout), and execs the checkout's own full wrapper, bin/gstack-capy-session,
|
|
* handing over the held install lock (GSTACK_CAPY_LOCK_HELD=1). Pinned on the
|
|
* machine lines (`gstack-capy-skill: wrapper|upgraded|owner action`, the
|
|
* `gstack:` trailer as the last line), exit codes and HEAD before/after.
|
|
* Fixture: a bare remote whose main moves from A (1.0.0, no capy/ directory:
|
|
* an old checkout) to B (1.0.1, capy/support.env minimum 1.0.1, the vendored
|
|
* scripts and a stub bin/gstack-capy-install that logs argv and env), a
|
|
* depth-1 file:// clone at A, on the simulated Capy host.
|
|
*/
|
|
import { afterEach, describe, expect, test } from 'bun:test';
|
|
import { spawn, spawnSync } from 'child_process';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import { capyBootstrapCommand } from '../lib/doctor-components';
|
|
import { RESULT_CODES } from '../lib/result-codes';
|
|
import { capyHost, type CapyHost } from './helpers/capy-host';
|
|
|
|
const REPO = path.resolve(import.meta.dir, '..');
|
|
const SKILL = path.join(REPO, 'capy/skill/gstack');
|
|
const BOOT = path.join(SKILL, 'scripts/install.sh');
|
|
const hosts: CapyHost[] = [];
|
|
afterEach(() => { for (const h of hosts.splice(0)) h.cleanup(); });
|
|
|
|
const write = (file: string, text: string, mode = 0o644) => {
|
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
fs.writeFileSync(file, text, { mode });
|
|
};
|
|
const envValue = (text: string, key: string) => text.match(new RegExp(`^${key}=([0-9.]+)`, 'm'))?.[1] ?? '';
|
|
const below = (a: string, b: string) => {
|
|
const x = a.split('.').map(Number), y = b.split('.').map(Number);
|
|
for (let i = 0; i < Math.max(x.length, y.length); i++) if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) < (y[i] ?? 0);
|
|
return false;
|
|
};
|
|
|
|
const STUB_INSTALLER = [
|
|
'#!/usr/bin/env bash',
|
|
'echo "installer $* lock=${GSTACK_CAPY_LOCK_HELD:-} rev=${GSTACK_CAPY_WRAPPER_REV:-} min=${GSTACK_CAPY_REMOTE_MIN:-} from=${GSTACK_CAPY_UPGRADED_FROM:-}" >> "$WRAP_LOG"',
|
|
'cd "$(dirname "$0")/.."',
|
|
'if [ "${1:-}" != --check ] && [ -f "$WRAP_FAIL_ONCE" ]; then rm -f "$WRAP_FAIL_ONCE"; echo "gstack: fail claude"; exit 1; fi',
|
|
'echo "GSTACK_LAUNCH: GSTACK_SESSION_KIND=unattended GSTACK_CAPY=1"',
|
|
'echo "gstack: ok $(cat VERSION) for=autoplan"',
|
|
].join('\n') + '\n';
|
|
|
|
/** What bin/gstack-capy-session's self-upgrade runs: bin/gstack-upgrade and its sourced helpers. */
|
|
const UPGRADE_DEPS = [
|
|
'bin/gstack-upgrade', 'bin/gstack-bun-version.sh', 'bin/gstack-paths', 'bin/gstack-state-root.sh',
|
|
'bin/gstack-egress-lib.sh', 'bin/gstack-egress-receipt', 'lib/egress-receipt.ts', 'lib/state-root.ts',
|
|
'bin/gstack-lease', 'bin/gstack-harness-pid.sh',
|
|
];
|
|
|
|
/**
|
|
* cloneAt 'A' (default): the clone sits at A (1.0.0), below B's minimum 1.0.1.
|
|
* cloneAt 'B': the clone sits at B, AT the minimum, and the remote moves on to
|
|
* C (1.0.2, minimum unchanged): behind origin but not below the floor.
|
|
*/
|
|
function fixture(opts: { minWrapper?: number; cloneAt?: 'A' | 'B' } = {}) {
|
|
const host = capyHost({ systemBun: '1.4.2' });
|
|
hosts.push(host);
|
|
const git = (args: string[], cwd: string) => {
|
|
const r = spawnSync('git', args, { cwd, encoding: 'utf8', env: host.env, timeout: 30_000 });
|
|
if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`);
|
|
return r.stdout.trim();
|
|
};
|
|
const log = path.join(host.base, 'wrap.log');
|
|
const seed = path.join(host.base, 'seed');
|
|
const bare = path.join(host.base, 'remote.git');
|
|
fs.mkdirSync(seed, { recursive: true });
|
|
git(['init', '-q', '-b', 'main'], seed);
|
|
write(path.join(seed, 'VERSION'), '1.0.0\n');
|
|
write(path.join(seed, 'README.md'), 'old gstack\n');
|
|
write(path.join(seed, 'setup'), '#!/usr/bin/env bash\nexit 0\n', 0o755);
|
|
git(['add', '-A'], seed);
|
|
git(['commit', '-qm', 'A'], seed);
|
|
const a = git(['rev-parse', 'HEAD'], seed);
|
|
git(['init', '-q', '--bare', '-b', 'main', bare], host.base);
|
|
git(['push', '-q', bare, 'main'], seed);
|
|
const root = path.join(host.base, 'gstack');
|
|
if ((opts.cloneAt ?? 'A') === 'A') git(['clone', '-q', '--single-branch', '--depth', '1', `file://${bare}`, root], host.base);
|
|
write(path.join(seed, 'VERSION'), '1.0.1\n');
|
|
write(path.join(seed, 'capy/support.env'), `GSTACK_MIN_REVISION=1.0.1\nGSTACK_CAPY_MIN_WRAPPER=${opts.minWrapper ?? 1}\n`);
|
|
for (const rel of ['capy/skill/gstack/scripts/install.sh', 'capy/skill/gstack/SKILL.md', 'bin/gstack-capy-session', 'bin/gstack-install-verdict.sh', ...UPGRADE_DEPS]) {
|
|
if (fs.existsSync(path.join(REPO, rel))) write(path.join(seed, rel), fs.readFileSync(path.join(REPO, rel), 'utf8'), 0o755);
|
|
}
|
|
write(path.join(seed, 'bin/gstack-capy-install'), STUB_INSTALLER, 0o755);
|
|
git(['add', '-A'], seed);
|
|
git(['commit', '-qm', 'B'], seed);
|
|
const b = git(['rev-parse', 'HEAD'], seed);
|
|
git(['push', '-q', bare, 'main'], seed);
|
|
let c = '';
|
|
if (opts.cloneAt === 'B') {
|
|
git(['clone', '-q', '--single-branch', '--depth', '1', `file://${bare}`, root], host.base);
|
|
write(path.join(seed, 'VERSION'), '1.0.2\n');
|
|
git(['commit', '-qam', 'C'], seed);
|
|
c = git(['rev-parse', 'HEAD'], seed);
|
|
git(['push', '-q', bare, 'main'], seed);
|
|
}
|
|
const env = { ...host.env, GSTACK_CAPY_ROOT: root, GSTACK_CAPY_REPO: `file://${bare}`, WRAP_LOG: log, WRAP_FAIL_ONCE: path.join(host.base, 'fail-once') };
|
|
const run = (args: string[] = [], extra: Record<string, string> = {}) => {
|
|
const r = spawnSync('bash', [BOOT, ...args], { cwd: host.home, encoding: 'utf8', env: { ...env, ...extra }, timeout: 120_000 });
|
|
const lines = r.stdout.trim().split('\n');
|
|
return { ...r, lines, last: lines.at(-1) ?? '' };
|
|
};
|
|
return {
|
|
host, root, bare, a, b, c, run, git: (args: string[]) => git(args, root),
|
|
calls: () => fs.existsSync(log) ? fs.readFileSync(log, 'utf8').trim().split('\n').filter(Boolean) : [],
|
|
};
|
|
}
|
|
|
|
describe('capy skill bootstrapper (capy/skill/gstack/scripts/install.sh)', () => {
|
|
test('the first line names the wrapper revision and path; bash -n passes for the bootstrapper and the full wrapper', () => {
|
|
for (const file of [BOOT, path.join(REPO, 'bin/gstack-capy-session')]) {
|
|
expect(fs.existsSync(file), file).toBe(true);
|
|
const r = spawnSync('bash', ['-n', file], { encoding: 'utf8', timeout: 30_000 });
|
|
expect(r.status, `${file}: ${r.stderr}`).toBe(0);
|
|
}
|
|
const f = fixture();
|
|
const r = f.run(['--check', '--for', 'autoplan']);
|
|
expect(r.lines[0]).toMatch(new RegExp(`^gstack-capy-skill: wrapper \\d+ \\(${BOOT.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\)$`));
|
|
});
|
|
|
|
test('below the remote minimum: --check is read-only and names the fast-forwarding command; install fast-forwards, installs and ends ok; the next session start is ok without moving', () => {
|
|
const f = fixture();
|
|
expect(f.git(['rev-parse', '--is-shallow-repository'])).toBe('true');
|
|
const check = f.run(['--check', '--for', 'autoplan']);
|
|
expect(check.status, check.stdout + check.stderr).toBe(1);
|
|
expect(check.last).toBe(`gstack: fail revision — installed 1.0.0 is below the supported minimum 1.0.1 (fast-forward available); fix: bash ${BOOT} --for autoplan (fast-forwards) (REVISION_BELOW_MINIMUM)`);
|
|
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.a);
|
|
expect(f.calls()).toEqual([]);
|
|
|
|
const install = f.run(['--for', 'autoplan']);
|
|
expect(install.status, install.stdout + install.stderr).toBe(0);
|
|
expect(install.stdout).toContain('gstack-capy-skill: upgraded 1.0.0 -> 1.0.1');
|
|
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
|
|
expect(f.git(['rev-parse', '--is-shallow-repository'])).toBe('true');
|
|
expect(install.last).toBe('gstack: ok 1.0.1 for=autoplan');
|
|
expect(f.calls()).toEqual([`installer --root ${f.root} --for autoplan lock=1 rev=3 min=1.0.1 from=1.0.0`]);
|
|
|
|
const next = f.run(['--check', '--for', 'autoplan']);
|
|
expect(next.status, next.stdout + next.stderr).toBe(0);
|
|
expect(next.stdout).not.toContain('upgraded');
|
|
expect(next.last).toBe('gstack: ok 1.0.1 for=autoplan');
|
|
expect(f.calls().at(-1)).toBe(`installer --check --root ${f.root} --for autoplan lock= rev=3 min=1.0.1 from=`);
|
|
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
|
|
});
|
|
|
|
test('dirty, pinned, diverged or auto-upgrade disabled: no change, fail revision names the cause and the exact commands', () => {
|
|
const f = fixture();
|
|
fs.writeFileSync(path.join(f.root, 'README.md'), 'local edit\n');
|
|
const dirty = f.run(['--for', 'autoplan']);
|
|
expect(dirty.status).toBe(1);
|
|
expect(dirty.last).toMatch(/^gstack: fail revision — installed 1\.0\.0 is below the supported minimum 1\.0\.1 \(tracked files changed: README\.md\); fix: .*git -C \S+ status --short.*\(REVISION_BELOW_MINIMUM\)$/);
|
|
expect(fs.readFileSync(path.join(f.root, 'README.md'), 'utf8')).toBe('local edit\n');
|
|
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.a);
|
|
f.git(['checkout', '-q', '--', 'README.md']);
|
|
|
|
const pinned = f.run(['--for', 'autoplan', '--revision', f.a]);
|
|
expect(pinned.status).toBe(1);
|
|
expect(pinned.last).toContain('(pinned by --revision');
|
|
f.git(['checkout', '-q', '--detach', 'HEAD']);
|
|
const detached = f.run(['--for', 'autoplan']);
|
|
expect(detached.last).toContain('(pinned: HEAD is detached');
|
|
f.git(['checkout', '-q', 'main']);
|
|
|
|
const disabled = f.run(['--for', 'autoplan'], { GSTACK_CAPY_AUTO_UPGRADE: '0' });
|
|
expect(disabled.status).toBe(1);
|
|
expect(disabled.last).toContain('(auto-upgrade disabled)');
|
|
expect(disabled.last).toContain('GSTACK_CAPY_AUTO_UPGRADE');
|
|
|
|
fs.writeFileSync(path.join(f.root, 'README.md'), 'local commit\n');
|
|
f.git(['commit', '-qam', 'local']);
|
|
const head = f.git(['rev-parse', 'HEAD']);
|
|
const diverged = f.run(['--for', 'autoplan']);
|
|
expect(diverged.status).toBe(1);
|
|
expect(diverged.last).toContain('(diverged: ');
|
|
expect(f.git(['rev-parse', 'HEAD'])).toBe(head);
|
|
expect(f.git(['stash', 'list'])).toBe('');
|
|
expect(f.calls()).toEqual([]);
|
|
});
|
|
|
|
test('an interrupted install after the fast-forward leaves the trailer fail; the next install run completes it', () => {
|
|
const f = fixture();
|
|
write(path.join(f.host.base, 'fail-once'), '1');
|
|
const first = f.run(['--for', 'autoplan']);
|
|
expect(first.status).toBe(1);
|
|
expect(first.last).toBe('gstack: fail claude');
|
|
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
|
|
const second = f.run(['--for', 'autoplan']);
|
|
expect(second.status, second.stdout + second.stderr).toBe(0);
|
|
expect(second.last).toBe('gstack: ok 1.0.1 for=autoplan');
|
|
});
|
|
|
|
test('offline: the remote minimum is unknown and said so; an old checkout is never ok', () => {
|
|
const f = fixture();
|
|
fs.renameSync(f.bare, `${f.bare}.gone`);
|
|
const old = f.run(['--check', '--for', 'autoplan']);
|
|
expect(old.status).toBe(1);
|
|
expect(old.stdout).toContain('remote minimum unknown (fetch failed)');
|
|
expect(old.last).toMatch(/^gstack: fail /);
|
|
fs.renameSync(`${f.bare}.gone`, f.bare);
|
|
expect(f.run(['--for', 'autoplan']).status).toBe(0);
|
|
fs.renameSync(f.bare, `${f.bare}.gone`);
|
|
const current = f.run(['--check', '--for', 'autoplan']);
|
|
expect(current.stdout).toContain('remote minimum unknown (fetch failed)');
|
|
expect(f.calls().at(-1)).toContain('min=unknown');
|
|
});
|
|
|
|
test('a stale bootstrapper still runs the checkout wrapper and names the owner action', () => {
|
|
const f = fixture({ minWrapper: 99 });
|
|
const r = f.run(['--for', 'autoplan']);
|
|
expect(r.status, r.stdout + r.stderr).toBe(0);
|
|
expect(r.stdout).toMatch(new RegExp(`^gstack-capy-skill: owner action — Drive copy is wrapper \\d+, minimum 99; copy ${f.root}/capy/skill/gstack/ \\(CAPY_WRAPPER_STALE\\)$`, 'm'));
|
|
expect(r.last).toBe('gstack: ok 1.0.1 for=autoplan');
|
|
});
|
|
|
|
test('no checkout: --check records the not-installed trailer; install clones and runs the checkout wrapper', () => {
|
|
const f = fixture();
|
|
fs.rmSync(f.root, { recursive: true, force: true });
|
|
const check = f.run(['--check', '--for', 'autoplan']);
|
|
expect(check.status).toBe(1);
|
|
expect(check.last).toBe(`gstack: fail not-installed — no gstack checkout at ${f.root}; fix: bash ${BOOT} --for autoplan (installs; on a machine without this skill: ${capyBootstrapCommand('autoplan', f.root)})`);
|
|
const install = f.run(['--for', 'autoplan']);
|
|
expect(install.status, install.stdout + install.stderr).toBe(0);
|
|
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
|
|
expect(install.last).toBe('gstack: ok 1.0.1 for=autoplan');
|
|
});
|
|
|
|
test('--for none: a coordinator machine gets ok not-needed (exit 0) with or without a checkout, nothing is cloned, moved or certified, and the trailer admits no workflow', () => {
|
|
const f = fixture();
|
|
const withCheckout = f.run(['--check', '--for', 'none']);
|
|
expect(withCheckout.status, withCheckout.stdout + withCheckout.stderr).toBe(0);
|
|
expect(withCheckout.last).toBe(`gstack: ok not-needed for=none (checkout 1.0.0 at ${f.root}, not certified; latest: 1.0.1; before the first workflow run: bash ${BOOT} --for <skill>)`);
|
|
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.a);
|
|
expect(f.calls()).toEqual([]);
|
|
// Install mode with --for none is check-only too: an old checkout below the minimum is reported, never fast-forwarded.
|
|
const installMode = f.run(['--for', 'none']);
|
|
expect(installMode.status).toBe(0);
|
|
expect(installMode.last).toBe(withCheckout.last);
|
|
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.a);
|
|
fs.rmSync(f.root, { recursive: true, force: true });
|
|
const none = f.run(['--check', '--for', 'none']);
|
|
expect(none.status).toBe(0);
|
|
expect(none.last).toBe(`gstack: ok not-needed for=none (no checkout at ${f.root}; before the first workflow run: bash ${BOOT} --for <skill>)`);
|
|
expect(fs.existsSync(f.root)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('capy skill: always current when online (the minimum is the floor, not the trigger)', () => {
|
|
test('install mode: a clean checkout at the minimum but behind origin is fast-forwarded by the checkout half and installed from the new revision; the next start is current', () => {
|
|
const f = fixture({ cloneAt: 'B' });
|
|
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
|
|
expect(f.git(['rev-parse', '--is-shallow-repository'])).toBe('true');
|
|
const r = f.run(['--for', 'autoplan']);
|
|
expect(r.status, r.stdout + r.stderr).toBe(0);
|
|
expect(r.stdout).not.toContain('gstack-capy-skill: upgraded');
|
|
expect(r.stdout).toContain('gstack-upgrade: upgraded 1.0.1 -> 1.0.2');
|
|
expect(r.stdout).toMatch(/^SELF_UPGRADE: done 1\.0\.1 -> 1\.0\.2$/m);
|
|
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.c);
|
|
expect(r.last).toBe('gstack: ok 1.0.2 for=autoplan');
|
|
expect(f.calls()).toEqual([`installer --root ${f.root} --for autoplan lock=1 rev=3 min=1.0.1 from=1.0.1`]);
|
|
expect(fs.existsSync(path.join(f.root, '.git/gstack-upgrade.pending'))).toBe(false);
|
|
|
|
const next = f.run(['--for', 'autoplan']);
|
|
expect(next.status, next.stdout + next.stderr).toBe(0);
|
|
expect(next.stdout).toMatch(/^SELF_UPGRADE: none \(current\)$/m);
|
|
expect(next.stdout).not.toContain('SELF_UPGRADE: done');
|
|
expect(next.last).toBe('gstack: ok 1.0.2 for=autoplan');
|
|
expect(f.calls().at(-1)).toBe(`installer --root ${f.root} --for autoplan lock=1 rev=3 min=1.0.1 from=`);
|
|
});
|
|
|
|
test('--check mode never moves a behind checkout: it names the gap and the fast-forwarding command, then certifies the installed revision', () => {
|
|
const f = fixture({ cloneAt: 'B' });
|
|
const r = f.run(['--check', '--for', 'autoplan']);
|
|
expect(r.status, r.stdout + r.stderr).toBe(0);
|
|
expect(r.stdout).toMatch(new RegExp(`^gstack-capy-session: behind origin/main: 1\\.0\\.1 -> 1\\.0\\.2 \\(1 commits\\); fix: bash ${BOOT.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')} --for autoplan \\(fast-forwards\\)$`, 'm'));
|
|
expect(r.stdout).not.toContain('SELF_UPGRADE');
|
|
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
|
|
expect(r.last).toBe('gstack: ok 1.0.1 for=autoplan');
|
|
expect(f.calls()).toEqual([`installer --check --root ${f.root} --for autoplan lock= rev=3 min=1.0.1 from=`]);
|
|
});
|
|
|
|
test('a dirty behind checkout is skipped with gstack-upgrade\'s reason and still ends ok: above the floor, the trailer is the doctor\'s', () => {
|
|
const f = fixture({ cloneAt: 'B' });
|
|
fs.writeFileSync(path.join(f.root, 'README.md'), 'local edit\n');
|
|
const r = f.run(['--for', 'autoplan']);
|
|
expect(r.status, r.stdout + r.stderr).toBe(0);
|
|
expect(r.stdout).toMatch(/^SELF_UPGRADE: skipped \(tracked files changed: README\.md.*\)$/m);
|
|
expect(r.stdout).not.toContain('SELF_UPGRADE: done');
|
|
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
|
|
expect(fs.readFileSync(path.join(f.root, 'README.md'), 'utf8')).toBe('local edit\n');
|
|
expect(r.last).toBe('gstack: ok 1.0.1 for=autoplan');
|
|
expect(f.calls()).toEqual([`installer --root ${f.root} --for autoplan lock=1 rev=3 min=1.0.1 from=`]);
|
|
});
|
|
|
|
test('GSTACK_CAPY_SELF_UPGRADE=off and GSTACK_CAPY_AUTO_UPGRADE=0 each disable the fast-forward by name and leave HEAD where it is', () => {
|
|
const f = fixture({ cloneAt: 'B' });
|
|
const off = f.run(['--for', 'autoplan'], { GSTACK_CAPY_SELF_UPGRADE: 'off' });
|
|
expect(off.status, off.stdout + off.stderr).toBe(0);
|
|
expect(off.stdout).toMatch(/^SELF_UPGRADE: disabled \(GSTACK_CAPY_SELF_UPGRADE\)$/m);
|
|
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
|
|
const auto = f.run(['--for', 'autoplan'], { GSTACK_CAPY_AUTO_UPGRADE: '0' });
|
|
expect(auto.status, auto.stdout + auto.stderr).toBe(0);
|
|
expect(auto.stdout).toMatch(/^SELF_UPGRADE: disabled \(GSTACK_CAPY_AUTO_UPGRADE\)$/m);
|
|
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
|
|
expect(auto.last).toBe('gstack: ok 1.0.1 for=autoplan');
|
|
});
|
|
});
|
|
|
|
// Plan A9: the `latest:` trailer note comes only from origin's VERSION the
|
|
// bootstrapper read in this run; a FETCH_HEAD left by an earlier run never
|
|
// reaches the installer.
|
|
describe('capy skill bootstrapper: GSTACK_CAPY_REMOTE_VERSION', () => {
|
|
test('a fresh fetch exports origin\'s VERSION; a failed fetch exports nothing even with a stale FETCH_HEAD', () => {
|
|
const f = fixture({ cloneAt: 'B' });
|
|
write(path.join(f.root, 'bin/gstack-capy-install'), '#!/usr/bin/env bash\necho "gstack: ok $(cat "$(dirname "$0")/../VERSION") for=autoplan remote=${GSTACK_CAPY_REMOTE_VERSION:-none}"\n', 0o755);
|
|
const fresh = f.run(['--check', '--for', 'autoplan'], { GSTACK_CAPY_REMOTE_VERSION: 'from-parent' });
|
|
expect(fresh.last).toBe('gstack: ok 1.0.1 for=autoplan remote=1.0.2');
|
|
expect(f.git(['show', 'FETCH_HEAD:VERSION'])).toBe('1.0.2');
|
|
f.git(['remote', 'set-url', 'origin', `file://${f.host.base}/missing.git`]);
|
|
const stale = f.run(['--check', '--for', 'autoplan'], { GSTACK_CAPY_REMOTE_VERSION: 'from-parent' });
|
|
expect(stale.stdout).toContain('remote minimum unknown (fetch failed)');
|
|
expect(stale.last).toBe('gstack: ok 1.0.1 for=autoplan remote=none');
|
|
});
|
|
});
|
|
|
|
// Field list 2 item 13: lanes relay only the trailer, so a session that ends
|
|
// behind origin hands the installer the gap and the fix for its trailer note.
|
|
describe('capy skill: the session hands the installer GSTACK_CAPY_BEHIND', () => {
|
|
const ENV_INSTALLER = '#!/usr/bin/env bash\necho "gstack: ok $(cat "$(dirname "$0")/../VERSION") for=autoplan behind=${GSTACK_CAPY_BEHIND:-none} to=${GSTACK_CAPY_BEHIND_TO:-none} fix=${GSTACK_CAPY_BEHIND_FIX:-none}${GSTACK_CAPY_BEHIND_BLOCKED:+ blocked=$GSTACK_CAPY_BEHIND_BLOCKED}"\n';
|
|
const rerun = `bash ${BOOT} --for autoplan`;
|
|
|
|
test('--check behind origin: the commit count, origin\'s VERSION and the fast-forwarding command; a checkout the rerun would not move points at gstack-upgrade', () => {
|
|
const f = fixture({ cloneAt: 'B' });
|
|
write(path.join(f.root, 'bin/gstack-capy-install'), ENV_INSTALLER, 0o755);
|
|
f.git(['update-index', '--assume-unchanged', 'bin/gstack-capy-install']);
|
|
const r = f.run(['--check', '--for', 'autoplan']);
|
|
expect(r.status, r.stdout + r.stderr).toBe(0);
|
|
expect(r.last).toBe(`gstack: ok 1.0.1 for=autoplan behind=1 to=1.0.2 fix=${rerun}`);
|
|
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
|
|
fs.writeFileSync(path.join(f.root, 'README.md'), 'local edit\n');
|
|
expect(f.run(['--check', '--for', 'autoplan']).last).toBe(`gstack: ok 1.0.1 for=autoplan behind=1 to=1.0.2 fix=${f.root}/bin/gstack-upgrade`);
|
|
f.git(['checkout', '-q', '--', 'README.md']);
|
|
f.git(['checkout', '-q', '--detach']);
|
|
expect(f.run(['--check', '--for', 'autoplan']).last).toBe(`gstack: ok 1.0.1 for=autoplan behind=1 to=1.0.2 fix=${f.root}/bin/gstack-upgrade`);
|
|
f.git(['checkout', '-q', '-b', 'topic']);
|
|
expect(f.run(['--check', '--for', 'autoplan']).last).toBe(`gstack: ok 1.0.1 for=autoplan behind=1 to=1.0.2 fix=${f.root}/bin/gstack-upgrade`);
|
|
f.git(['checkout', '-q', 'main']);
|
|
expect(f.run(['--check', '--for', 'autoplan']).stdout).toMatch(/^gstack-capy-session: behind origin\/main: 1\.0\.1 -> 1\.0\.2 \(1 commits\); fix: bash .+ --for autoplan \(fast-forwards\)$/m);
|
|
expect(f.run(['--check', '--for', 'autoplan'], { GSTACK_CAPY_AUTO_UPGRADE: '0' }).last).toContain('fix=unset GSTACK_CAPY_AUTO_UPGRADE, then ');
|
|
});
|
|
|
|
test('install mode: a skipped self-upgrade points at gstack-upgrade (which names its refusal); a disabled one names the variable to unset; no fix carries the "; " note separator', () => {
|
|
const f = fixture({ cloneAt: 'B' });
|
|
write(path.join(f.root, 'bin/gstack-capy-install'), ENV_INSTALLER, 0o755);
|
|
const skipped = f.run(['--for', 'autoplan']);
|
|
expect(skipped.stdout).toMatch(/^SELF_UPGRADE: skipped \(tracked files changed: bin\/gstack-capy-install; fix: .+\)$/m);
|
|
expect(skipped.last).toMatch(new RegExp(`^gstack: ok 1\\.0\\.1 for=autoplan behind=1 to=1\\.0\\.2 fix=${f.root.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/bin/gstack-upgrade blocked=tracked files changed: bin/gstack-capy-install; fix: .+ \\(UPGRADE_REFUSED\\)$`));
|
|
const disabled = f.run(['--for', 'autoplan'], { GSTACK_CAPY_SELF_UPGRADE: 'off' });
|
|
expect(disabled.last).toBe(`gstack: ok 1.0.1 for=autoplan behind=1 to=1.0.2 fix=unset GSTACK_CAPY_SELF_UPGRADE, then ${rerun}`);
|
|
for (const r of [skipped, disabled]) expect(r.last.split(' fix=')[1].split(' blocked=')[0]).not.toContain('; ');
|
|
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
|
|
});
|
|
|
|
// GBRA-105 / GBRA-106: a lease naming a pid that a machine wake handed to
|
|
// another process wedged every upgrade, and the skipped upgrade still ended ok.
|
|
test('install mode: a lease whose pid was reused is reclaimed and the checkout upgrades; a live holder blocks it and the session hands the installer the busy reason with the holder', () => {
|
|
const f = fixture({ cloneAt: 'B' });
|
|
write(path.join(f.root, 'bin/gstack-capy-install'), ENV_INSTALLER, 0o755);
|
|
f.git(['update-index', '--assume-unchanged', 'bin/gstack-capy-install']);
|
|
const leaseDir = path.join(f.host.base, 'leases');
|
|
const holder = spawn('sleep', ['300'], { stdio: 'ignore' });
|
|
try {
|
|
const lease = (args: string[]) => spawnSync('bash', [path.join(f.root, 'bin/gstack-lease'), ...args, '--root', f.root], { encoding: 'utf8', env: { ...f.host.env, GSTACK_LEASE_DIR: leaseDir }, timeout: 30_000 });
|
|
expect(lease(['touch', '--pid', String(holder.pid)]).status).toBe(0);
|
|
const busy = f.run(['--for', 'autoplan'], { GSTACK_LEASE_DIR: leaseDir });
|
|
expect(busy.stdout).toMatch(new RegExp(`^SELF_UPGRADE: skipped \\(live gstack sessions hold this installation: pid ${holder.pid} \\(\\d+m sleep\\); fix: .+ \\(UPGRADE_BUSY\\)\\)$`, 'm'));
|
|
expect(busy.last).toMatch(new RegExp(`behind=1 to=1\\.0\\.2 fix=.+ blocked=live gstack sessions hold this installation: pid ${holder.pid} \\(\\d+m sleep\\); fix: .+GSTACK_UPGRADE_FORCE=1.+ \\(UPGRADE_BUSY\\)$`));
|
|
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
|
|
const file = path.join(leaseDir, String(holder.pid));
|
|
const recorded = fs.readFileSync(file, 'utf8').trim();
|
|
fs.writeFileSync(file, (recorded.startsWith('linux ') ? recorded.replace(/\d+$/, (n) => String(Number(n) + 1)) : 'ps Thu Jan 1 00:00:00 1970') + '\n');
|
|
const reclaimed = f.run(['--for', 'autoplan'], { GSTACK_LEASE_DIR: leaseDir });
|
|
expect(reclaimed.stdout).toMatch(/^SELF_UPGRADE: done 1\.0\.1 -> 1\.0\.2$/m);
|
|
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.c);
|
|
expect(fs.existsSync(file)).toBe(false);
|
|
} finally {
|
|
holder.kill('SIGKILL');
|
|
}
|
|
});
|
|
|
|
test('an older bootstrapper handing --for none to the session in install mode: the session certifies nothing and moves nothing (no self-upgrade, HEAD unchanged), and reports behind like --check', () => {
|
|
const f = fixture({ cloneAt: 'B' });
|
|
write(path.join(f.root, 'bin/gstack-capy-install'), ENV_INSTALLER, 0o755);
|
|
f.git(['update-index', '--assume-unchanged', 'bin/gstack-capy-install']);
|
|
f.git(['fetch', '-q', 'origin', 'main']);
|
|
const r = spawnSync('bash', [path.join(f.root, 'bin/gstack-capy-session'), '--root', f.root, '--for', 'none'], { cwd: f.host.home, encoding: 'utf8', env: { ...f.host.env, GSTACK_CAPY_REMOTE_MIN: '1.0.1', GSTACK_CAPY_WRAPPER_REV: '2', GSTACK_CAPY_WRAPPER_PATH: BOOT }, timeout: 120_000 });
|
|
expect(r.status, r.stdout + r.stderr).toBe(0);
|
|
expect(r.stdout).not.toMatch(/^SELF_UPGRADE:/m);
|
|
expect(r.stdout.trim().split('\n').at(-1)).toBe(`gstack: ok 1.0.1 for=autoplan behind=1 to=1.0.2 fix=bash ${BOOT} --for none`);
|
|
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
|
|
});
|
|
|
|
test('current, or the fetch failed: nothing is exported, and values inherited from a parent session are dropped', () => {
|
|
const f = fixture({ cloneAt: 'B' });
|
|
f.git(['fetch', '-q', 'origin', 'main']);
|
|
f.git(['merge', '--ff-only', '-q', 'FETCH_HEAD']);
|
|
write(path.join(f.root, 'bin/gstack-capy-install'), ENV_INSTALLER, 0o755);
|
|
const inherited = { GSTACK_CAPY_BEHIND: '9', GSTACK_CAPY_BEHIND_TO: '9.9.9', GSTACK_CAPY_BEHIND_FIX: 'stale' };
|
|
expect(f.run(['--check', '--for', 'autoplan'], inherited).last).toBe('gstack: ok 1.0.2 for=autoplan behind=none to=none fix=none');
|
|
f.git(['reset', '-q', '--hard', f.b]);
|
|
write(path.join(f.root, 'bin/gstack-capy-install'), ENV_INSTALLER, 0o755);
|
|
f.git(['remote', 'set-url', 'origin', `file://${f.host.base}/missing.git`]);
|
|
const offline = f.run(['--check', '--for', 'autoplan'], inherited);
|
|
expect(offline.stdout).toContain('remote minimum unknown (fetch failed)');
|
|
expect(offline.last).toBe('gstack: ok 1.0.1 for=autoplan behind=none to=none fix=none');
|
|
});
|
|
});
|
|
|
|
describe('capy skill: vendored text and support file', () => {
|
|
const support = fs.existsSync(path.join(REPO, 'capy/support.env')) ? fs.readFileSync(path.join(REPO, 'capy/support.env'), 'utf8') : '';
|
|
test('the wrapper revision meets the checkout minimum; GSTACK_MIN_REVISION <= VERSION', () => {
|
|
const wrapper = Number(fs.readFileSync(BOOT, 'utf8').match(/^GSTACK_CAPY_WRAPPER=(\d+)$/m)?.[1] ?? 0);
|
|
expect(wrapper).toBeGreaterThanOrEqual(Number(envValue(support, 'GSTACK_CAPY_MIN_WRAPPER') || 999));
|
|
const min = envValue(support, 'GSTACK_MIN_REVISION');
|
|
expect(min).toMatch(/^\d+\.\d+\.\d+\.\d+$/);
|
|
expect(below(fs.readFileSync(path.join(REPO, 'VERSION'), 'utf8').trim(), min)).toBe(false);
|
|
});
|
|
|
|
test('SKILL.md: frontmatter, line cap, the session protocol tokens, and every command it names exists', () => {
|
|
const text = fs.readFileSync(path.join(SKILL, 'SKILL.md'), 'utf8');
|
|
expect(text).toMatch(/^---\nname: gstack\ndescription: .+\n---\n/);
|
|
expect(text.split('\n').length).toBeLessThanOrEqual(90);
|
|
for (const token of ['--check', 'GSTACK_LAUNCH', '^gstack: ok', 'gstack: fail not-installed', 'gstack-pregate sealed', 'autoplan/DRIVER.md', 'first report']) {
|
|
expect(text, token).toContain(token);
|
|
}
|
|
const named = [...text.matchAll(/\b(?:bin\/)?(gstack-[a-z0-9-]+)\b/g)].map(m => m[1]).filter(n => n !== 'gstack-capy-skill');
|
|
const missing = [...new Set(named)].filter(n => !fs.existsSync(path.join(REPO, 'bin', n)) && n !== 'gstack-pregate');
|
|
expect(missing).toEqual([]);
|
|
expect(fs.existsSync(path.join(REPO, 'bin/gstack-pregate'))).toBe(true);
|
|
});
|
|
|
|
test('every (CODE) the install-side bash bins print resolves to a troubleshooting anchor', () => {
|
|
const bins = ['bin/gstack-capy-install', 'bin/gstack-upgrade', 'bin/gstack-capy-session', 'bin/gstack-install-verdict.sh', 'capy/skill/gstack/scripts/install.sh'];
|
|
const codes = new Set<string>();
|
|
for (const rel of bins) for (const m of fs.readFileSync(path.join(REPO, rel), 'utf8').matchAll(/\(([A-Z][A-Z0-9]+_[A-Z0-9_]+)[;)]/g)) codes.add(m[1]);
|
|
for (const m of fs.readFileSync(path.join(REPO, 'bin/gstack-install-verdict.sh'), 'utf8').matchAll(/code=([A-Z][A-Z0-9_]+)/g)) codes.add(m[1]);
|
|
expect([...codes].sort()).toEqual(expect.arrayContaining(['CAPY_WRAPPER_STALE', 'INSTALL_UNVERIFIED', 'REVISION_BELOW_MINIMUM', 'RUNTIME_BELOW_MINIMUM', 'UPGRADE_FETCH_FAILED', 'UPGRADE_REFUSED']));
|
|
expect([...codes].filter(c => !(c in RESULT_CODES))).toEqual([]);
|
|
});
|
|
});
|