-
Notifications
You must be signed in to change notification settings - Fork 2
feat: surface invalid trajectory files via status -v and trail doctor #26
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| /** | ||
| * trail doctor command | ||
| * | ||
| * Diagnose and (optionally) repair trajectory files that fail to load. | ||
| * Reconcile silently skips bad files so the CLI keeps working; doctor | ||
| * surfaces the path + first validation error for each one and can move | ||
| * them into `.trajectories/invalid/` so reconcile stops complaining. | ||
| */ | ||
|
|
||
| import type { Command } from "commander"; | ||
| import { FileStorage } from "../../storage/file.js"; | ||
|
|
||
| export function registerDoctorCommand(program: Command): void { | ||
| program | ||
| .command("doctor") | ||
| .description( | ||
| "List trajectory files that fail to load; optionally quarantine them", | ||
| ) | ||
| .option( | ||
| "--quarantine", | ||
| "Move invalid files to .trajectories/invalid/ so reconcile stops scanning them", | ||
| ) | ||
| .action(async (opts: { quarantine?: boolean }) => { | ||
| const storage = new FileStorage(); | ||
| await storage.initialize(); | ||
|
|
||
| const summary = storage.getLastReconcileSummary(); | ||
| const failures = summary?.failures ?? []; | ||
|
|
||
| if (failures.length === 0) { | ||
| console.log("No invalid trajectory files found."); | ||
| return; | ||
| } | ||
|
|
||
| console.log(`Found ${failures.length} invalid trajectory file(s):`); | ||
| for (const failure of failures) { | ||
| console.log(` ${failure.path}`); | ||
| console.log(` reason: ${failure.reason}`); | ||
| console.log(` detail: ${failure.message}`); | ||
| } | ||
|
|
||
| if (!opts.quarantine) { | ||
| console.log( | ||
| "\nRun `trail doctor --quarantine` to move these files into .trajectories/invalid/.", | ||
| ); | ||
| return; | ||
| } | ||
|
|
||
| const result = await storage.quarantineInvalid(); | ||
| if (result.moved.length === 0) { | ||
| console.log( | ||
| "\nNo files were moved (io_error failures are not auto-quarantined).", | ||
| ); | ||
| return; | ||
| } | ||
| console.log( | ||
| `\nMoved ${result.moved.length} file(s) to ${result.targetDir}:`, | ||
| ); | ||
| for (const failure of result.moved) { | ||
| console.log(` ${failure.path}`); | ||
| } | ||
| }); | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -181,4 +181,61 @@ describe("FileStorage reconcile — real workforce fixtures", () => { | |||||||||||||||||||||
| expect(summary.skippedMalformedJson).toBe(0); | ||||||||||||||||||||||
| expect(summary.skippedSchemaViolation).toBe(1); | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| it("quarantineInvalid preserves both files when basenames collide across dirs", async () => { | ||||||||||||||||||||||
| // Regression: an earlier version of quarantineInvalid used basename(), | ||||||||||||||||||||||
| // so two invalid files at active/foo.json and completed/.../foo.json | ||||||||||||||||||||||
| // collapsed onto the same destination and one was silently lost. | ||||||||||||||||||||||
| const { FileStorage } = await import("../../src/storage/file.js"); | ||||||||||||||||||||||
| const { writeFile, readdir } = await import("node:fs/promises"); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| const trajRoot = join(tempDir, ".trajectories"); | ||||||||||||||||||||||
| await mkdir(join(trajRoot, "active"), { recursive: true }); | ||||||||||||||||||||||
| await mkdir(join(trajRoot, "completed", "2026-04"), { recursive: true }); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| // Same basename, different parent dirs, both schema-invalid. | ||||||||||||||||||||||
| const invalidPayload = JSON.stringify({ | ||||||||||||||||||||||
| id: "traj_dup00000_0000", | ||||||||||||||||||||||
| version: 1, | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
| await writeFile( | ||||||||||||||||||||||
| join(trajRoot, "active", "traj_dup00000_0000.json"), | ||||||||||||||||||||||
| invalidPayload, | ||||||||||||||||||||||
| "utf-8", | ||||||||||||||||||||||
| ); | ||||||||||||||||||||||
| await writeFile( | ||||||||||||||||||||||
| join(trajRoot, "completed", "2026-04", "traj_dup00000_0000.json"), | ||||||||||||||||||||||
| invalidPayload, | ||||||||||||||||||||||
| "utf-8", | ||||||||||||||||||||||
| ); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| const storage = new FileStorage(tempDir); | ||||||||||||||||||||||
| const result = await storage.quarantineInvalid(); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| expect(result.moved).toHaveLength(2); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| // Both files should now live under invalid/, with the relative path | ||||||||||||||||||||||
| // preserved so neither overwrites the other. | ||||||||||||||||||||||
| const activeQuarantined = join( | ||||||||||||||||||||||
| trajRoot, | ||||||||||||||||||||||
| "invalid", | ||||||||||||||||||||||
| "active", | ||||||||||||||||||||||
| "traj_dup00000_0000.json", | ||||||||||||||||||||||
| ); | ||||||||||||||||||||||
| const completedQuarantined = join( | ||||||||||||||||||||||
| trajRoot, | ||||||||||||||||||||||
| "invalid", | ||||||||||||||||||||||
| "completed", | ||||||||||||||||||||||
| "2026-04", | ||||||||||||||||||||||
| "traj_dup00000_0000.json", | ||||||||||||||||||||||
| ); | ||||||||||||||||||||||
| const activeContent = await readFile(activeQuarantined, "utf-8"); | ||||||||||||||||||||||
| const completedContent = await readFile(completedQuarantined, "utf-8"); | ||||||||||||||||||||||
| expect(activeContent).toBe(invalidPayload); | ||||||||||||||||||||||
| expect(completedContent).toBe(invalidPayload); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| // And the originals must be gone from active/ and completed/. | ||||||||||||||||||||||
| const activeAfter = await readdir(join(trajRoot, "active")); | ||||||||||||||||||||||
| expect(activeAfter).not.toContain("traj_dup00000_0000.json"); | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
|
Comment on lines
+237
to
+240
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Assert source removal in The intent says both originals are removed, but only Patch suggestion // And the originals must be gone from active/ and completed/.
const activeAfter = await readdir(join(trajRoot, "active"));
expect(activeAfter).not.toContain("traj_dup00000_0000.json");
+ const completedAfter = await readdir(join(trajRoot, "completed", "2026-04"));
+ expect(completedAfter).not.toContain("traj_dup00000_0000.json");
});📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||
| }); | ||||||||||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.