Skip to content

Commit f732fdb

Browse files
kshitij-heizenKshitijBhardwaj18claude
authored
fix(bundle): only sweep bundle-owned files in commit, preserving user files in --out dir (#162)
commitBundle's stale-file sweep removed EVERY directory entry not part of the fresh bundle, so 'test failure get --out <dir>' / 'test artifact get --out <dir>' pointed at a pre-existing, populated directory silently deleted the user's unrelated files (exit 0, no warning) — on the very first write, not just re-commits. Scope the sweep to entries the bundle format owns (result.json, failure.json, video.mp4, meta.json, steps, .tmp, .partial, code.<ext>). Stale bundle files are still cleaned — an old video.mp4 when the new bundle has no video, a code.py when the new bundle writes code.ts — but foreign files and directories are never touched. Fixes #159 Co-authored-by: Kshitij Bhardwaj <tothemoon202154@outlook.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 011208c commit f732fdb

2 files changed

Lines changed: 107 additions & 6 deletions

File tree

src/lib/bundle.test.ts

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
* the full http+fetch path is wired against MSW).
99
*/
1010

11-
import { existsSync, mkdtempSync } from 'node:fs';
11+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
1212
import { tmpdir } from 'node:os';
1313
import { join } from 'node:path';
1414
import { describe, expect, it } from 'vitest';
@@ -18,6 +18,7 @@ import {
1818
assertNoEscape,
1919
BUNDLE_SCHEMA_VERSION,
2020
buildMeta,
21+
isBundleOwnedEntry,
2122
pickCodeExtension,
2223
resolveBundleDir,
2324
STREAM_URL_MAX_RETRIES,
@@ -694,6 +695,37 @@ describe('streamUrlToFile retry', () => {
694695
});
695696
});
696697

698+
describe('isBundleOwnedEntry', () => {
699+
it('owns the fixed bundle file set', () => {
700+
for (const entry of [
701+
'result.json',
702+
'failure.json',
703+
'video.mp4',
704+
'meta.json',
705+
'steps',
706+
'.tmp',
707+
'.partial',
708+
]) {
709+
expect(isBundleOwnedEntry(entry)).toBe(true);
710+
}
711+
});
712+
713+
it('owns code.<ext> for any single-token extension', () => {
714+
expect(isBundleOwnedEntry('code.ts')).toBe(true);
715+
expect(isBundleOwnedEntry('code.js')).toBe(true);
716+
expect(isBundleOwnedEntry('code.py')).toBe(true);
717+
});
718+
719+
it('does not own foreign entries', () => {
720+
expect(isBundleOwnedEntry('notes.txt')).toBe(false);
721+
expect(isBundleOwnedEntry('src')).toBe(false);
722+
expect(isBundleOwnedEntry('.git')).toBe(false);
723+
expect(isBundleOwnedEntry('code.tar.gz')).toBe(false);
724+
expect(isBundleOwnedEntry('mycode.ts')).toBe(false);
725+
expect(isBundleOwnedEntry('code.')).toBe(false);
726+
});
727+
});
728+
697729
describe('step artifact path validation', () => {
698730
// A fetchImpl that fails the test if called — proves validation rejects
699731
// before any write happens.
@@ -807,6 +839,49 @@ describe('step artifact path validation', () => {
807839
expect(existsSync(join(res.dir, 'meta.json'))).toBe(true);
808840
});
809841

842+
describe('commit sweep ownership (data-loss guard)', () => {
843+
it('preserves pre-existing foreign files and directories in the --out dir', async () => {
844+
const dir = mkdtempSync(join(tmpdir(), 'bundle-test-'));
845+
writeFileSync(join(dir, 'notes.txt'), 'important notes\n', 'utf8');
846+
mkdirSync(join(dir, 'src'));
847+
writeFileSync(join(dir, 'src', 'app.js'), "console.log('app')\n", 'utf8');
848+
849+
const res = await writeBundle(stepCtx(3), {
850+
dir,
851+
failedOnly: false,
852+
fetchImpl: throwIfFetched,
853+
});
854+
855+
// The bundle landed…
856+
expect(existsSync(join(res.dir, 'meta.json'))).toBe(true);
857+
expect(existsSync(join(res.dir, 'result.json'))).toBe(true);
858+
// …and the user's unrelated files survived the commit sweep.
859+
expect(readFileSync(join(dir, 'notes.txt'), 'utf8')).toBe('important notes\n');
860+
expect(readFileSync(join(dir, 'src', 'app.js'), 'utf8')).toBe("console.log('app')\n");
861+
});
862+
863+
it('still sweeps a stale bundle-owned video.mp4 the new bundle does not write', async () => {
864+
const dir = mkdtempSync(join(tmpdir(), 'bundle-test-'));
865+
writeFileSync(join(dir, 'video.mp4'), 'stale-bytes', 'utf8');
866+
867+
// stepCtx has videoUrl: null → the fresh bundle ships no video.
868+
await writeBundle(stepCtx(3), { dir, failedOnly: false, fetchImpl: throwIfFetched });
869+
870+
expect(existsSync(join(dir, 'video.mp4'))).toBe(false);
871+
});
872+
873+
it('sweeps a stale code file with a different extension than the new bundle writes', async () => {
874+
const dir = mkdtempSync(join(tmpdir(), 'bundle-test-'));
875+
writeFileSync(join(dir, 'code.py'), '# stale python code\n', 'utf8');
876+
877+
// baseCtx.code.language is 'typescript' → the fresh bundle writes code.ts.
878+
await writeBundle(stepCtx(3), { dir, failedOnly: false, fetchImpl: throwIfFetched });
879+
880+
expect(existsSync(join(dir, 'code.ts'))).toBe(true);
881+
expect(existsSync(join(dir, 'code.py'))).toBe(false);
882+
});
883+
});
884+
810885
describe('assertNoEscape', () => {
811886
it('returns the resolved path for an in-bounds segment', () => {
812887
const base = mkdtempSync(join(tmpdir(), 'bundle-test-'));

src/lib/bundle.ts

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -543,6 +543,30 @@ async function freshTmpDir(dir: string): Promise<string> {
543543
* caught reading the dir during it sees no meta and refuses to consume
544544
* (per §7.3). That's what we want.
545545
*/
546+
/**
547+
* Whether a top-level directory entry belongs to the bundle format —
548+
* i.e. something a prior `writeBundle` could have produced and this
549+
* commit is therefore allowed to clean up. `code.<ext>` is matched by
550+
* pattern (not the current run's extension) so a stale `code.py` is
551+
* still swept when the new bundle writes `code.ts`. Everything else in
552+
* the directory is the user's and must never be deleted (`--out` can
553+
* point at a pre-existing, populated directory).
554+
*/
555+
export function isBundleOwnedEntry(entry: string): boolean {
556+
if (
557+
entry === 'result.json' ||
558+
entry === 'failure.json' ||
559+
entry === 'video.mp4' ||
560+
entry === 'meta.json' ||
561+
entry === 'steps' ||
562+
entry === '.tmp' ||
563+
entry === '.partial'
564+
) {
565+
return true;
566+
}
567+
return /^code\.[A-Za-z0-9]+$/.test(entry);
568+
}
569+
546570
async function commitBundle(
547571
tmpDir: string,
548572
dir: string,
@@ -553,20 +577,22 @@ async function commitBundle(
553577

554578
// (2) Sweep stale top-level files that the new bundle won't write.
555579
// If the prior run wrote `video.mp4` and the new run has no video,
556-
// an in-place rename leaves the old video lingering. Enumerate
557-
// current top-level entries and remove anything that isn't being
558-
// freshly renamed in.
580+
// an in-place rename leaves the old video lingering. Only entries the
581+
// bundle format OWNS are candidates: `--out` may point at a directory
582+
// that also holds the user's unrelated files, and those must survive
583+
// the commit (deleting them would be silent data loss).
559584
const topLevel = files.filter(f => !f.startsWith('steps/'));
560585
const newTopLevelSet = new Set(topLevel);
561586
newTopLevelSet.add('meta.json'); // about to land last, do not delete
562587
const existing = await readdir(dir).catch(() => [] as string[]);
563588
for (const entry of existing) {
564589
// Preserve the writer's own scratch dir + the .partial marker
565-
// (we'll re-evaluate .partial at the end of commit). Anything else
566-
// not-listed in the new bundle is stale.
590+
// (we'll re-evaluate .partial at the end of commit). Any other
591+
// bundle-owned entry not-listed in the new bundle is stale.
567592
if (entry === '.tmp' || entry === '.partial') continue;
568593
if (newTopLevelSet.has(entry)) continue;
569594
if (entry === 'steps') continue; // handled below
595+
if (!isBundleOwnedEntry(entry)) continue; // foreign file — never touch
570596
await rm(join(dir, entry), { recursive: true, force: true });
571597
}
572598

0 commit comments

Comments
 (0)