Files
gstack/design/test/persist.test.ts
T
Garry TanandMatt Van Horn fe6d1ae62a v1.91.17.0 fix: mvanhorn wave (checkpoint provenance, never-overwrite design images) (#3022)
* fix: mark Remaining Work provenance so restore verifies guessed steps first (#3004)

/context-save now starts every Remaining Work item with `Open.` and ends it
with how this session knows it: `(path run)` with the observed outcome,
`(path read)` or `(path assumed)` for concrete paths, and
`(target state checked)`, `(code read)` or `(path assumed)` for writing
steps. /context-restore keeps saved order and original text but splits the
list into Next steps and Verify first (assumed, code-read, failed runs and
unmarked concrete or writing items), prints a banner for pre-marker
checkpoints, and option A walks saved order so an unverified first item is
verified before any later step runs.

Adds a prompt-byte contract test for the marker wire format and a periodic
behavior E2E (context-restore-provenance-order) that seeds an assumed item
ahead of a run item plus a code-read write.

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>

* fix(design): never overwrite a paid design image (#1529)

Every image the API returns now goes through one persistence path
(design/src/persist.ts): mkdir, exclusive `wx` claim that bumps
x.png -> x-2.png ... x-999.png, write, fsync, close. A failure after the
bytes arrived removes the claimed file and keeps a 0600 recovery copy in
the temp dir; empty image data is a failure, never a 0-byte PNG. The API
call is the only thing inside each retry loop, so a local save failure
never buys a second image (variants, iterate fallback, generate --retry).

generate, iterate, evolve and variants (both branches) print the same run
fields (requested, saved, selected, failures, recovered) on every exit,
with exit 0 / 2 (nothing saved) / 3 (stopped after saving). Each
generate --retry attempt is its own saved image listed in `attempts`.
compare accepts --images-file and JSON-array --images. The gallery lists
bumped names, orders history by mtime then board-images.json, and marks
approvals by approved_path. New bin/gstack-design-claim publishes staged
images without clobbering; bin/gstack-design-approved resolves an
approval record (legacy letter-only records still resolve; a missing
approved_path is an error, never a substitute).

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>

* fix(design skills): read the paths $D printed, never fixed variant names (#1529)

Every design flow now captures each generating $D command's JSON and exit
code without set -e, does round accounting before the first board, check
or inline view (stopping when nothing was saved), and carries the printed
paths forward. Boards are built from board-images.json via
compare --images-file after archiving any earlier feedback.json, and
approvals map the confirmed letter through that file and record
approved_path. design-shotgun subagents generate into a per-run mktemp
staging directory and publish with bin/gstack-design-claim instead of a
shared /tmp/variant-X.png plus cp. design-html and taste memory resolve
approvals with gstack-design-approved; design-review verifies against the
printed target path.

test/design-printed-paths.test.ts bans fixed variant names, variant globs,
variant-{letter} reads, cp/mv of $D output and literal check paths in the
generated skills and resolvers (one content-anchored allowlist entry: the
design-html discovery probe), and pins the accounting/board marker order.
test/design-approval-flow.test.ts runs the generated board and approval
blocks through a stubbed two-round flow.

* chore: release v1.91.17.0 (mvanhorn fix wave: checkpoint provenance, never-overwrite design images)

* fix(design): keep iterate sessions in the OS temp dir and normalize tripwire paths on Windows

* fix(setup-deploy): report failing status commands, specify merge-method, health and workflow checks

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
2026-10-03 16:13:45 -07:00

166 lines
8.4 KiB
TypeScript

/**
* persistImage / claimOutputPath: a paid image is never overwritten and never lost.
*/
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import fs from "fs";
import os from "os";
import path from "path";
import { CLAIM_CAP, bumpedName, claimOutputPath, persistImage, type PersistFs } from "../src/persist";
const PNG = Buffer.from("fake paid image bytes");
const B64 = PNG.toString("base64");
let dir: string;
let tmp: string;
let stderr: string[];
const savedError = console.error;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), "design-persist-"));
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "design-persist-tmp-"));
stderr = [];
console.error = (...a: unknown[]) => { stderr.push(a.map(String).join(" ")); };
});
afterEach(() => {
console.error = savedError;
fs.rmSync(dir, { recursive: true, force: true });
fs.rmSync(tmp, { recursive: true, force: true });
});
function failingFs(fail: { op: keyof PersistFs; code: string; when?: (p: unknown) => boolean }): PersistFs {
const wrapped: any = {};
for (const op of ["mkdirSync", "openSync", "writeSync", "fsyncSync", "closeSync", "unlinkSync"] as const) {
wrapped[op] = (...args: any[]) => {
if (op === fail.op && (!fail.when || fail.when(args[0]))) {
throw Object.assign(new Error(`${fail.code}: simulated ${op} failure`), { code: fail.code });
}
return (fs as any)[op](...args);
};
}
return wrapped;
}
describe("claimOutputPath", () => {
// Value: protects=an existing image is never truncated and the next free -N name is claimed;
// fails_when=the claim opens with "w" instead of "wx" or the bump rule changes; why_new=no claim existed before; seam=none
test("keeps existing files and bumps sequentially", () => {
const requested = path.join(dir, "x.png");
fs.writeFileSync(requested, "round one");
const second = claimOutputPath(requested);
fs.closeSync(second.fd);
const third = claimOutputPath(requested);
fs.closeSync(third.fd);
expect([second.path, third.path]).toEqual([path.join(dir, "x-2.png"), path.join(dir, "x-3.png")]);
expect(fs.readFileSync(requested, "utf8")).toBe("round one");
expect(bumpedName(path.join(dir, "x-2.png"), 2)).toBe(path.join(dir, "x-2-2.png"));
});
// Value: protects=parallel writers (variants, shotgun subagents) each get their own file;
// fails_when=the claim is check-then-write instead of an exclusive create; why_new=concurrency was untested; seam=none
test("N concurrent processes claim N distinct files", async () => {
const requested = path.join(dir, "variant-A.png");
const script = `import { claimOutputPath } from ${JSON.stringify(path.join(import.meta.dir, "../src/persist.ts"))};
const c = claimOutputPath(process.argv[2]); console.log(c.path);`;
const scriptPath = path.join(tmp, "claim.ts");
fs.writeFileSync(scriptPath, script);
const procs = Array.from({ length: 8 }, () =>
Bun.spawn([process.execPath, scriptPath, requested], { stdout: "pipe", stderr: "pipe" }));
const outs = await Promise.all(procs.map(p => new Response(p.stdout).text()));
await Promise.all(procs.map(p => p.exited));
const claimed = outs.map(o => o.trim());
expect(new Set(claimed).size).toBe(8);
expect(fs.readdirSync(dir).length).toBe(8);
});
// Value: protects=a permission error fails at once instead of walking 999 names; fails_when=every open error is treated as EEXIST;
// why_new=new behavior; seam=PersistFs fault injection
test("non-EEXIST open errors do not bump", () => {
let opens = 0;
const fsImpl = failingFs({ op: "openSync", code: "EACCES", when: () => { opens++; return true; } });
expect(() => claimOutputPath(path.join(dir, "x.png"), fsImpl)).toThrow("EACCES");
expect(opens).toBe(1);
});
// Value: protects=the claim stops at -999 with an error; fails_when=the cap is removed (unbounded loop) or off by one;
// why_new=new behavior; seam=PersistFs fault injection
test("stops at the -999 cap", () => {
const fsImpl = failingFs({ op: "openSync", code: "EEXIST" });
expect(() => claimOutputPath(path.join(dir, "x.png"), fsImpl)).toThrow(`x-${CLAIM_CAP}.png already exist`);
});
});
describe("persistImage", () => {
// Value: protects=the exact bump notice and actual-path reporting scripts rely on; fails_when=the notice text or returned path drifts;
// why_new=bumping is new; seam=none
test("bumps a taken name, prints the notice and leaves the old bytes untouched", () => {
const requested = path.join(dir, "nested", "x.png");
fs.mkdirSync(path.dirname(requested));
fs.writeFileSync(requested, "old");
const outcome = persistImage(B64, requested, { tmpdir: tmp });
const actual = path.join(dir, "nested", "x-2.png");
expect(outcome).toEqual({ ok: true, path: actual, bytes: PNG.length });
expect(stderr).toContain(`note: ${requested} exists; saved to ${actual} (existing file kept)`);
expect(fs.readFileSync(requested, "utf8")).toBe("old");
expect(fs.readFileSync(actual)).toEqual(PNG);
});
// Value: protects=an empty API image is a failure, not a 0-byte PNG; fails_when=the empty-bytes guard is removed;
// why_new=closes the CEO empty-b64 warning; seam=none
test("empty image data is a failure with no claim and no file", () => {
const outcome = persistImage("", path.join(dir, "x.png"), { tmpdir: tmp });
expect(outcome.ok).toBe(false);
expect(fs.readdirSync(dir)).toEqual([]);
expect(fs.readdirSync(tmp)).toEqual([]);
});
// Value: protects=a mid-write ENOSPC removes the partial claimed file and keeps a 0600 recovery copy; fails_when=cleanup or recovery is skipped;
// why_new=new behavior; seam=PersistFs fault injection
test("post-claim write failure removes the claimed file and saves a private recovery copy", () => {
const requested = path.join(dir, "x.png");
let writes = 0;
const fsImpl = failingFs({ op: "writeSync", code: "ENOSPC", when: () => writes++ === 0 });
const outcome = persistImage(B64, requested, { tmpdir: tmp, fs: fsImpl });
expect(outcome.ok).toBe(false);
if (outcome.ok) return;
expect(fs.existsSync(requested)).toBe(false);
const recovery = outcome.recovered!.path;
expect(path.dirname(recovery)).toBe(tmp);
expect(path.basename(recovery)).toMatch(/^gstack-design-unsaved-.*\.png$/);
expect(fs.readFileSync(recovery)).toEqual(PNG);
expect(fs.statSync(recovery).mode & 0o777).toBe(0o600);
expect(outcome.failure.file).toBe(requested);
expect(outcome.failure.reason).toContain(`cannot save paid image to ${requested}: ENOSPC`);
expect(outcome.failure.reason).toContain(`saved a recovery copy to ${recovery}`);
expect(outcome.failure.reason).toContain("Fix: free disk space");
});
// Value: protects=a mkdir/claim failure (EACCES) still recovers the bytes with problem, cause and fix; fails_when=recovery only covers write errors;
// why_new=new behavior; seam=PersistFs fault injection
test("EACCES on the claim recovers the bytes and names the fix", () => {
const requested = path.join(dir, "x.png");
const fsImpl = failingFs({ op: "openSync", code: "EACCES", when: p => p === requested });
const outcome = persistImage(B64, requested, { tmpdir: tmp, fs: fsImpl });
expect(outcome.ok).toBe(false);
if (outcome.ok) return;
expect(outcome.failure.reason).toMatch(/cannot save paid image to .*x\.png: EACCES \(.*\)\. Image bytes were received; saved a recovery copy to .*\. Fix: make .* writable/);
expect(fs.readFileSync(outcome.recovered!.path)).toEqual(PNG);
});
// Value: protects=when the recovery copy also fails both errors and the byte count are reported and no recovery is claimed;
// fails_when=the message claims a recovery copy that does not exist; why_new=new behavior; seam=PersistFs fault injection
test("recovery failure reports both errors and the byte count", () => {
const requested = path.join(dir, "x.png");
const fsImpl = failingFs({ op: "openSync", code: "EROFS" });
const outcome = persistImage(B64, requested, { tmpdir: tmp, fs: fsImpl });
expect(outcome.ok).toBe(false);
if (outcome.ok) return;
expect(outcome.recovered).toBeUndefined();
expect(outcome.failure.reason).toContain(`(${PNG.length} bytes)`);
expect(outcome.failure.reason).toContain("recovery copy");
expect(outcome.failure.reason.match(/EROFS/g)!.length).toBeGreaterThanOrEqual(2);
expect(outcome.failure.reason).not.toContain("saved a recovery copy");
});
});