diff --git a/apps/daemon/src/projects.ts b/apps/daemon/src/projects.ts index 8ce69f39e..f7c804aaa 100644 --- a/apps/daemon/src/projects.ts +++ b/apps/daemon/src/projects.ts @@ -32,10 +32,16 @@ const FORBIDDEN_SEGMENT = /^$|^\.\.?$/; const RESERVED_PROJECT_FILE_SEGMENTS = new Set(['.live-artifacts']); const DESIGN_HANDOFF_FILENAME = 'DESIGN-HANDOFF.md'; const DESIGN_MANIFEST_FILENAME = 'DESIGN-MANIFEST.json'; +export const RUN_ARTIFACT_RECONCILE_MTIME_GRACE_MS = 1000; export const projectFileRenameTestHooks = { beforeCommit: null as null | ((paths: { source: string; target: string }) => Promise | void), }; +export function isRunTouchedProjectFile(fileMtimeMs, runStartTimeMs) { + if (!Number.isFinite(fileMtimeMs) || !Number.isFinite(runStartTimeMs)) return false; + return fileMtimeMs + RUN_ARTIFACT_RECONCILE_MTIME_GRACE_MS >= runStartTimeMs; +} + export function projectDir(projectsRoot, projectId) { if (!isSafeId(projectId)) throw new Error('invalid project id'); return path.join(projectsRoot, projectId); diff --git a/apps/daemon/src/server.ts b/apps/daemon/src/server.ts index 797caaf85..c254faa51 100644 --- a/apps/daemon/src/server.ts +++ b/apps/daemon/src/server.ts @@ -353,6 +353,7 @@ import { assertSandboxProjectRootAvailable, detectEntryFile, ensureProject, + isRunTouchedProjectFile, isSafeId, listFiles, mimeFor, @@ -12751,7 +12752,7 @@ export async function startServer({ try { const filePath = path.join(dir, f.name); const st = await fs.promises.stat(filePath); - if (st.mtimeMs < runStartTimeMs) continue; + if (!isRunTouchedProjectFile(st.mtimeMs, runStartTimeMs)) continue; await reconcileHtmlArtifactManifest( PROJECTS_DIR, run.projectId, diff --git a/apps/daemon/tests/artifact-manifest-reconcile-on-run-end.test.ts b/apps/daemon/tests/artifact-manifest-reconcile-on-run-end.test.ts index 885f977c7..fbbe80c7b 100644 --- a/apps/daemon/tests/artifact-manifest-reconcile-on-run-end.test.ts +++ b/apps/daemon/tests/artifact-manifest-reconcile-on-run-end.test.ts @@ -9,7 +9,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { closeDatabase, insertProject, openDatabase } from '../src/db.js'; -import { reconcileHtmlArtifactManifest, writeProjectFile } from '../src/projects.js'; +import { isRunTouchedProjectFile, reconcileHtmlArtifactManifest, writeProjectFile } from '../src/projects.js'; const PROJECT_ID = 'reconcile-test'; let tempDir = null; @@ -146,6 +146,9 @@ describe('run-end artifact manifest reconciliation (#2893)', () => { // File written during the run await writeProjectFile(projectsRoot, PROJECT_ID, 'new-output.html', '

new

'); + const newPath = path.join(projectsRoot, PROJECT_ID, 'new-output.html'); + const coarseFsTime = new Date(runStartTimeMs - 500); + fs.utimesSync(newPath, coarseFsTime, coarseFsTime); // Simulate the close-handler reconciliation with mtime filter const dir = path.join(projectsRoot, PROJECT_ID); @@ -154,7 +157,7 @@ describe('run-end artifact manifest reconciliation (#2893)', () => { const ext = path.extname(name).toLowerCase(); if (ext !== '.html' && ext !== '.htm') continue; const st = fs.statSync(path.join(dir, name)); - if (st.mtimeMs < runStartTimeMs) continue; + if (!isRunTouchedProjectFile(st.mtimeMs, runStartTimeMs)) continue; await reconcileHtmlArtifactManifest(projectsRoot, PROJECT_ID, name); }