From ec35451582171df9721b21e913c4e7d3f2e82e09 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 06:30:06 +0000 Subject: [PATCH 1/2] fix(projects): reset stale example source on pre-#400 duplicates (#403) Before #400, duplicating a project copied the original's source, so copies of example projects kept reporting the original's exampleId to metrics on every run. When a library project is opened, detect such a copy (example source and a "(copy)" display name or a -copy / -copy-N dirName) and rewrite its source to { type: "duplicate" } once. Quick projects and genuine example projects, renamed or not, are left alone. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01HB39E3Y4jpon2ijSXT7emN --- src/store/projects.integration.test.ts | 107 ++++++++++++++++++++++--- src/store/projects.test.ts | 53 ++++++++++++ src/store/projects.ts | 24 +++++- 3 files changed, 171 insertions(+), 13 deletions(-) create mode 100644 src/store/projects.test.ts diff --git a/src/store/projects.integration.test.ts b/src/store/projects.integration.test.ts index 4c9c3df2..358c10ec 100644 --- a/src/store/projects.integration.test.ts +++ b/src/store/projects.integration.test.ts @@ -150,12 +150,10 @@ describe("projects store integration", () => { const engine = createFakeEngine(); const start = vi.spyOn(engine.lammps, "start"); const { store, libraryStorage } = await createTestStore(engine); - await store - .getActions() - .projects.createProject({ - displayName: "Continue", - files: [{ fileName: "in.lmp", content: SCRIPT }], - }); + await store.getActions().projects.createProject({ + displayName: "Continue", + files: [{ fileName: "in.lmp", content: SCRIPT }], + }); await store.getActions().projects.startRuns([runRequest()]); expect(start).toHaveBeenCalledTimes(1); for (const continueFrom of [ @@ -179,12 +177,10 @@ describe("projects store integration", () => { const engine = createFakeEngine(); const start = vi.spyOn(engine.lammps, "start"); const { store, libraryStorage } = await createTestStore(engine); - await store - .getActions() - .projects.createProject({ - displayName: "Continue", - files: [{ fileName: "in.lmp", content: SCRIPT }], - }); + await store.getActions().projects.createProject({ + displayName: "Continue", + files: [{ fileName: "in.lmp", content: SCRIPT }], + }); await store.getActions().projects.startRuns([runRequest()]); const dir = activeDirName(store); const parent = await readRunMeta(libraryStorage, dir, "run-001"); @@ -1044,6 +1040,93 @@ describe("projects store integration", () => { }); }); + describe("pre-#400 duplicates on openProject (#403)", () => { + it("rewrites a stale example source to duplicate so runs stop reporting the exampleId", async () => { + const { store, libraryStorage } = + await createTestStore(createFakeEngine()); + // As duplicateProject wrote it before #400: the original's source. + const stale = await libraryStorage.createProject({ + displayName: "Water vapor (copy)", + source: { type: "example", exampleId: "watervapor" }, + inputScript: "in.lmp", + }); + await libraryStorage.write(stale.dirName, "in.lmp", SCRIPT); + + await store.getActions().projects.openProject({ dirName: stale.dirName }); + + const onDisk = await readJson( + libraryStorage, + stale.dirName, + PROJECT_META_PATH, + ); + expect(onDisk.source).toEqual({ type: "duplicate" }); + expect(store.getState().projects.active?.meta.source).toEqual({ + type: "duplicate", + }); + + vi.mocked(track).mockClear(); + await store.getActions().projects.startRuns([runRequest()]); + expect(byName("Run.Start")[0]).toMatchObject({ exampleId: undefined }); + expect(byName("Run.Finish")[0]).toMatchObject({ exampleId: undefined }); + expect(byName("Simulation.Start")[0]).toMatchObject({ + simulationId: stale.dirName, + }); + }); + + it("leaves genuine example projects alone, including renamed ones", async () => { + const { store, libraryStorage } = + await createTestStore(createFakeEngine()); + const example = await libraryStorage.createProject({ + displayName: "Water vapor", + source: { type: "example", exampleId: "watervapor" }, + }); + await libraryStorage.updateProjectMeta(example.dirName, { + displayName: "My water run", + }); + + await store.getActions().projects.openProject({ + dirName: example.dirName, + }); + + const onDisk = await readJson( + libraryStorage, + example.dirName, + PROJECT_META_PATH, + ); + expect(onDisk.source).toEqual({ + type: "example", + exampleId: "watervapor", + }); + expect(store.getState().projects.active?.meta.source).toEqual({ + type: "example", + exampleId: "watervapor", + }); + }); + + it("does not touch quick projects", async () => { + const { store, scratchStorage } = + await createTestStore(createFakeEngine()); + const quick = await scratchStorage.createProject({ + displayName: "Water vapor (copy)", + source: { type: "example", exampleId: "watervapor" }, + }); + + await store + .getActions() + .projects.openProject({ dirName: quick.dirName, quick: true }); + + const onDisk = await readJson( + scratchStorage, + quick.dirName, + PROJECT_META_PATH, + ); + expect(onDisk.source).toEqual({ + type: "example", + exampleId: "watervapor", + }); + }); + }); + describe("duplicateProject: nested files and pending edits", () => { it("copies nested working-tree files and flushes the debounce buffer", async () => { const { store, libraryStorage } = diff --git a/src/store/projects.test.ts b/src/store/projects.test.ts new file mode 100644 index 00000000..7cbf4d03 --- /dev/null +++ b/src/store/projects.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from "vitest"; +import { isStaleExampleCopy } from "./projects"; +import type { ProjectMeta } from "../storage"; + +describe("isStaleExampleCopy", () => { + it.each([ + ["display name ends in (copy)", "Water vapor (copy)", "water-vapor-copy"], + ["dirName ends in -copy", "My water run", "water-vapor-copy"], + ["dirName ends in -copy-N", "My water run", "water-vapor-copy-3"], + ["display name only", "Water vapor (copy)", "renamed-dir"], + ])("detects an example-sourced copy: %s", (_, displayName, dirName) => { + expect(isStaleExampleCopy(projectMeta({ displayName, dirName }))).toBe( + true, + ); + }); + + it.each([ + ["a genuine example", "Water vapor", "water-vapor"], + ["a second instance of an example", "Water vapor", "water-vapor-2"], + ["a renamed example", "My water run", "water-vapor"], + ["copy only mid-name", "Copy of water", "copy-of-water"], + ])("leaves %s alone", (_, displayName, dirName) => { + expect(isStaleExampleCopy(projectMeta({ displayName, dirName }))).toBe( + false, + ); + }); + + it("ignores copies that are not example-sourced", () => { + const names = { displayName: "Mine (copy)", dirName: "mine-copy" }; + expect( + isStaleExampleCopy(projectMeta({ ...names, source: undefined })), + ).toBe(false); + expect( + isStaleExampleCopy(projectMeta({ ...names, source: { type: "blank" } })), + ).toBe(false); + expect( + isStaleExampleCopy( + projectMeta({ ...names, source: { type: "duplicate" } }), + ), + ).toBe(false); + }); +}); + +function projectMeta( + overrides: Partial & { displayName: string; dirName: string }, +): ProjectMeta { + return { + schemaVersion: 1, + createdAt: "2026-07-01T00:00:00.000Z", + source: { type: "example", exampleId: "watervapor" }, + ...overrides, + }; +} diff --git a/src/store/projects.ts b/src/store/projects.ts index 29c61998..a79617ab 100644 --- a/src/store/projects.ts +++ b/src/store/projects.ts @@ -129,6 +129,22 @@ export function isScriptFile(fileName: string): boolean { return SCRIPT_FILE.test(fileName); } +const COPY_DIR_NAME = /-copy(-\d+)?$/; + +/** + * A duplicate made before #400, which copied the original's example source + * and so reports the original's exampleId to metrics on every run (#403). + * Recognized by duplicateProject's naming (" (copy)" -> + * "-copy[-N]"); a genuine example's dirName is slug(displayName) and + * often differs from its exampleId, so that comparison cannot be used. + */ +export function isStaleExampleCopy(meta: ProjectMeta): boolean { + return ( + meta.source?.type === "example" && + (meta.displayName.endsWith("(copy)") || COPY_DIR_NAME.test(meta.dirName)) + ); +} + // Debounced editor saves live outside the (immer) store: content buffers and // timers are not state. flushPendingSaves is awaited before every snapshot so // what runs is always what is recorded (ADR-001 ยง8). @@ -425,11 +441,17 @@ export const projectsModel: ProjectsModel = { async (actions, { dirName, quick = false, tab }, helpers) => { const storage = storageFor(helpers.injections, quick); const metas = await storage.listProjects(); - const meta = metas.find((m) => m.dirName === dirName); + let meta = metas.find((m) => m.dirName === dirName); if (!meta) { actions.setNotice(`Project ${dirName} was not found.`); return; } + if (!quick && isStaleExampleCopy(meta)) { + // One-time migration: rewritten on disk, so the next open is a no-op. + meta = await storage.updateProjectMeta(dirName, { + source: { type: "duplicate" }, + }); + } const owned = new Set( helpers.getState().activeRun ? [helpers.getState().activeRun!.runId] From 8016b5b75d5da5d308985a1d7fd2dc1cace74bef Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 13:54:16 +0000 Subject: [PATCH 2/2] Don't block opening a project if the provenance migration write fails Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01HB39E3Y4jpon2ijSXT7emN --- src/store/projects/library.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/store/projects/library.ts b/src/store/projects/library.ts index a98959de..e4db8d67 100644 --- a/src/store/projects/library.ts +++ b/src/store/projects/library.ts @@ -43,9 +43,14 @@ export const libraryThunks: Pick< } if (!quick && isStaleExampleCopy(meta)) { // One-time migration: rewritten on disk, so the next open is a no-op. - meta = await storage.updateProjectMeta(dirName, { - source: { type: "duplicate" }, - }); + // Only metrics depend on it, so a failed write must not block opening. + try { + meta = await storage.updateProjectMeta(dirName, { + source: { type: "duplicate" }, + }); + } catch (error) { + console.warn("Could not migrate stale example source", error); + } } const owned = new Set( helpers.getState().activeRun