Skip to content

Commit 2fd3175

Browse files
la14-1louisgvclaude
authored
fix: add schema versioning to history.json (v0 bare array → v1 wrapped) (#2256)
Fixes #2252 history.json now uses a versioned envelope: { "version": 1, "records": [...] } This creates a migration escape hatch for future SpawnRecord shape changes. loadHistory() transparently reads both v0 (bare array) and v1 formats, automatically migrating v0 files on next write. All write operations now use writeHistory() to stamp the current schema version consistently. Validation uses valibot schemas (VMConnectionSchema, SpawnRecordSchema, HistoryFileV1Schema) so the structure is verified and typed without `as` casts. Updated all affected tests to check data.records instead of data. Agent: issue-fixer Co-authored-by: B <6723574+louisgv@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent abc1510 commit 2fd3175

4 files changed

Lines changed: 211 additions & 56 deletions

File tree

packages/cli/src/__tests__/cmdrun-happy-path.test.ts

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:te
22
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
33
import { homedir } from "node:os";
44
import { join } from "node:path";
5+
import { HISTORY_SCHEMA_VERSION } from "../history.js";
56
import { loadManifest } from "../manifest";
67
import { isString } from "../shared/type-guards";
78
import { createConsoleMocks, createMockManifest, mockClackPrompts, restoreMocks } from "./test-helpers";
@@ -262,9 +263,10 @@ describe("cmdRun happy-path pipeline", () => {
262263

263264
const historyPath = join(historyDir, "history.json");
264265
expect(existsSync(historyPath)).toBe(true);
265-
const records = JSON.parse(readFileSync(historyPath, "utf-8"));
266-
expect(records.length).toBeGreaterThanOrEqual(1);
267-
const record = records[records.length - 1];
266+
const data = JSON.parse(readFileSync(historyPath, "utf-8"));
267+
expect(data.version).toBe(HISTORY_SCHEMA_VERSION);
268+
expect(data.records.length).toBeGreaterThanOrEqual(1);
269+
const record = data.records[data.records.length - 1];
268270
expect(record.agent).toBe("claude");
269271
expect(record.cloud).toBe("sprite");
270272
expect(record.timestamp).toBeDefined();
@@ -279,8 +281,8 @@ describe("cmdRun happy-path pipeline", () => {
279281
await cmdRun("claude", "sprite", "Fix all bugs");
280282

281283
const historyPath = join(historyDir, "history.json");
282-
const records = JSON.parse(readFileSync(historyPath, "utf-8"));
283-
const record = records[records.length - 1];
284+
const data = JSON.parse(readFileSync(historyPath, "utf-8"));
285+
const record = data.records[data.records.length - 1];
284286
expect(record.prompt).toBe("Fix all bugs");
285287
});
286288

@@ -293,8 +295,8 @@ describe("cmdRun happy-path pipeline", () => {
293295
await cmdRun("claude", "sprite");
294296

295297
const historyPath = join(historyDir, "history.json");
296-
const records = JSON.parse(readFileSync(historyPath, "utf-8"));
297-
const record = records[records.length - 1];
298+
const data = JSON.parse(readFileSync(historyPath, "utf-8"));
299+
const record = data.records[data.records.length - 1];
298300
expect(record.prompt).toBeUndefined();
299301
});
300302

@@ -309,8 +311,8 @@ describe("cmdRun happy-path pipeline", () => {
309311
const after = new Date().toISOString();
310312

311313
const historyPath = join(historyDir, "history.json");
312-
const records = JSON.parse(readFileSync(historyPath, "utf-8"));
313-
const record = records[records.length - 1];
314+
const data = JSON.parse(readFileSync(historyPath, "utf-8"));
315+
const record = data.records[data.records.length - 1];
314316
expect(record.timestamp >= before).toBe(true);
315317
expect(record.timestamp <= after).toBe(true);
316318
});
@@ -331,9 +333,9 @@ describe("cmdRun happy-path pipeline", () => {
331333

332334
const historyPath = join(historyDir, "history.json");
333335
expect(existsSync(historyPath)).toBe(true);
334-
const records = JSON.parse(readFileSync(historyPath, "utf-8"));
335-
expect(records.length).toBeGreaterThanOrEqual(1);
336-
expect(records[records.length - 1].agent).toBe("claude");
336+
const data = JSON.parse(readFileSync(historyPath, "utf-8"));
337+
expect(data.records.length).toBeGreaterThanOrEqual(1);
338+
expect(data.records[data.records.length - 1].agent).toBe("claude");
337339
});
338340

339341
it("should still execute script when history save fails", async () => {
@@ -381,10 +383,10 @@ describe("cmdRun happy-path pipeline", () => {
381383
await cmdRun("claude", "sprite");
382384

383385
const historyPath = join(historyDir, "history.json");
384-
const records = JSON.parse(readFileSync(historyPath, "utf-8"));
385-
expect(records).toHaveLength(2);
386-
expect(records[0].agent).toBe("codex");
387-
expect(records[1].agent).toBe("claude");
386+
const data = JSON.parse(readFileSync(historyPath, "utf-8"));
387+
expect(data.records).toHaveLength(2);
388+
expect(data.records[0].agent).toBe("codex");
389+
expect(data.records[1].agent).toBe("claude");
388390
});
389391
});
390392

packages/cli/src/__tests__/history-trimming.test.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
44
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
55
import { homedir } from "node:os";
66
import { join } from "node:path";
7-
import { filterHistory, loadHistory, saveSpawnRecord } from "../history.js";
7+
import { filterHistory, HISTORY_SCHEMA_VERSION, loadHistory, saveSpawnRecord } from "../history.js";
88

99
/**
1010
* Tests for history trimming and boundary behavior.
@@ -849,12 +849,13 @@ describe("History Trimming and Boundaries", () => {
849849
timestamp: "2026-01-02T00:00:00.000Z",
850850
});
851851

852-
// Read raw file and verify it's valid JSON
852+
// Read raw file and verify it's valid v1 JSON
853853
const raw = readFileSync(join(testDir, "history.json"), "utf-8");
854854
expect(() => JSON.parse(raw)).not.toThrow();
855855
const parsed = JSON.parse(raw);
856-
expect(Array.isArray(parsed)).toBe(true);
857-
expect(parsed).toHaveLength(100);
856+
expect(parsed.version).toBe(HISTORY_SCHEMA_VERSION);
857+
expect(Array.isArray(parsed.records)).toBe(true);
858+
expect(parsed.records).toHaveLength(100);
858859
});
859860

860861
it("should write pretty-printed JSON with trailing newline after trimming", () => {

packages/cli/src/__tests__/history.test.ts

Lines changed: 110 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,14 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
44
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
55
import { homedir } from "node:os";
66
import { join } from "node:path";
7-
import { filterHistory, getHistoryPath, getSpawnDir, loadHistory, saveSpawnRecord } from "../history.js";
7+
import {
8+
filterHistory,
9+
getHistoryPath,
10+
getSpawnDir,
11+
HISTORY_SCHEMA_VERSION,
12+
loadHistory,
13+
saveSpawnRecord,
14+
} from "../history.js";
815

916
describe("history", () => {
1017
let testDir: string;
@@ -185,6 +192,55 @@ describe("history", () => {
185192
writeFileSync(join(testDir, "history.json"), "");
186193
expect(loadHistory()).toEqual([]);
187194
});
195+
196+
it("loads v1 format: { version: 1, records: [...] }", () => {
197+
const records: SpawnRecord[] = [
198+
{
199+
agent: "claude",
200+
cloud: "sprite",
201+
timestamp: "2026-01-01T00:00:00.000Z",
202+
},
203+
];
204+
writeFileSync(
205+
join(testDir, "history.json"),
206+
JSON.stringify({
207+
version: 1,
208+
records,
209+
}),
210+
);
211+
expect(loadHistory()).toEqual(records);
212+
});
213+
214+
it("returns empty array for v1 format with unknown version", () => {
215+
const records: SpawnRecord[] = [
216+
{
217+
agent: "claude",
218+
cloud: "sprite",
219+
timestamp: "2026-01-01T00:00:00.000Z",
220+
},
221+
];
222+
writeFileSync(
223+
join(testDir, "history.json"),
224+
JSON.stringify({
225+
version: 99,
226+
records,
227+
}),
228+
);
229+
// Unknown version is not a recognized format; treated as invalid non-array
230+
expect(loadHistory()).toEqual([]);
231+
});
232+
233+
it("loads v0 format: bare array (backward compatibility)", () => {
234+
const records: SpawnRecord[] = [
235+
{
236+
agent: "claude",
237+
cloud: "sprite",
238+
timestamp: "2026-01-01T00:00:00.000Z",
239+
},
240+
];
241+
writeFileSync(join(testDir, "history.json"), JSON.stringify(records));
242+
expect(loadHistory()).toEqual(records);
243+
});
188244
});
189245

190246
// ── saveSpawnRecord ─────────────────────────────────────────────────────
@@ -202,8 +258,9 @@ describe("history", () => {
202258

203259
expect(existsSync(join(nestedDir, "history.json"))).toBe(true);
204260
const data = JSON.parse(readFileSync(join(nestedDir, "history.json"), "utf-8"));
205-
expect(data).toHaveLength(1);
206-
expect(data[0].agent).toBe("claude");
261+
expect(data.version).toBe(HISTORY_SCHEMA_VERSION);
262+
expect(data.records).toHaveLength(1);
263+
expect(data.records[0].agent).toBe("claude");
207264

208265
// Clean up
209266
rmSync(join(homedir(), ".spawn-test"), {
@@ -229,9 +286,10 @@ describe("history", () => {
229286
});
230287

231288
const data = JSON.parse(readFileSync(join(testDir, "history.json"), "utf-8"));
232-
expect(data).toHaveLength(2);
233-
expect(data[0].agent).toBe("claude");
234-
expect(data[1].agent).toBe("codex");
289+
expect(data.version).toBe(HISTORY_SCHEMA_VERSION);
290+
expect(data.records).toHaveLength(2);
291+
expect(data.records[0].agent).toBe("claude");
292+
expect(data.records[1].agent).toBe("codex");
235293
});
236294

237295
it("saves record with prompt field", () => {
@@ -243,7 +301,7 @@ describe("history", () => {
243301
});
244302

245303
const data = JSON.parse(readFileSync(join(testDir, "history.json"), "utf-8"));
246-
expect(data[0].prompt).toBe("Fix all linter errors");
304+
expect(data.records[0].prompt).toBe("Fix all linter errors");
247305
});
248306

249307
it("saves record without prompt field", () => {
@@ -254,7 +312,7 @@ describe("history", () => {
254312
});
255313

256314
const data = JSON.parse(readFileSync(join(testDir, "history.json"), "utf-8"));
257-
expect(data[0].prompt).toBeUndefined();
315+
expect(data.records[0].prompt).toBeUndefined();
258316
});
259317

260318
it("writes pretty-printed JSON with trailing newline", () => {
@@ -281,9 +339,47 @@ describe("history", () => {
281339
}
282340

283341
const data = JSON.parse(readFileSync(join(testDir, "history.json"), "utf-8"));
284-
expect(data).toHaveLength(5);
285-
expect(data[0].agent).toBe("agent-0");
286-
expect(data[4].agent).toBe("agent-4");
342+
expect(data.version).toBe(HISTORY_SCHEMA_VERSION);
343+
expect(data.records).toHaveLength(5);
344+
expect(data.records[0].agent).toBe("agent-0");
345+
expect(data.records[4].agent).toBe("agent-4");
346+
});
347+
348+
it("writes v1 format with version and records fields", () => {
349+
saveSpawnRecord({
350+
agent: "claude",
351+
cloud: "sprite",
352+
timestamp: "2026-01-01T00:00:00.000Z",
353+
});
354+
355+
const data = JSON.parse(readFileSync(join(testDir, "history.json"), "utf-8"));
356+
expect(data.version).toBe(HISTORY_SCHEMA_VERSION);
357+
expect(Array.isArray(data.records)).toBe(true);
358+
});
359+
360+
it("migrates v0 bare array to v1 format on next save", () => {
361+
const existing: SpawnRecord[] = [
362+
{
363+
agent: "claude",
364+
cloud: "sprite",
365+
timestamp: "2026-01-01T00:00:00.000Z",
366+
},
367+
];
368+
// Write v0 bare array
369+
writeFileSync(join(testDir, "history.json"), JSON.stringify(existing));
370+
371+
// Trigger a write via saveSpawnRecord
372+
saveSpawnRecord({
373+
agent: "codex",
374+
cloud: "hetzner",
375+
timestamp: "2026-01-02T00:00:00.000Z",
376+
});
377+
378+
const data = JSON.parse(readFileSync(join(testDir, "history.json"), "utf-8"));
379+
expect(data.version).toBe(HISTORY_SCHEMA_VERSION);
380+
expect(data.records).toHaveLength(2);
381+
expect(data.records[0].agent).toBe("claude");
382+
expect(data.records[1].agent).toBe("codex");
287383
});
288384

289385
it("recovers from corrupted existing history file", () => {
@@ -297,8 +393,9 @@ describe("history", () => {
297393

298394
// loadHistory returns [] for corrupted files, so saveSpawnRecord starts fresh
299395
const data = JSON.parse(readFileSync(join(testDir, "history.json"), "utf-8"));
300-
expect(data).toHaveLength(1);
301-
expect(data[0].agent).toBe("claude");
396+
expect(data.version).toBe(HISTORY_SCHEMA_VERSION);
397+
expect(data.records).toHaveLength(1);
398+
expect(data.records[0].agent).toBe("claude");
302399
});
303400
});
304401

0 commit comments

Comments
 (0)