diff --git a/backend/src/__tests__/stack-files-routes.test.ts b/backend/src/__tests__/stack-files-routes.test.ts index 75230d21..adf8180b 100644 --- a/backend/src/__tests__/stack-files-routes.test.ts +++ b/backend/src/__tests__/stack-files-routes.test.ts @@ -170,6 +170,17 @@ describe('GET /api/stacks/:stackName/files/content', () => { expect(res.body.binary).toBe(false); expect(res.body.oversized).toBe(false); expect(typeof res.body.content).toBe('string'); + expect(typeof res.body.mtimeMs).toBe('number'); + expect(res.body.mtimeMs).toBeGreaterThan(0); + }); + + it('sets a quoted ETag header derived from mtimeMs', async () => { + const res = await request(app) + .get(`/api/stacks/${STACK}/files/content`) + .query({ path: 'compose.yaml' }) + .set('Cookie', adminCookie); + expect(res.status).toBe(200); + expect(res.headers['etag']).toMatch(/^W\/"\d+"$/); }); it('returns 404 for a non-existent file', async () => { @@ -432,6 +443,103 @@ describe('PUT /api/stacks/:stackName/files/content', () => { const content = await fs.readFile(path.join(stacksDir, STACK, 'written.txt'), 'utf-8'); expect(content).toBe('written via PUT'); }); + + it('echoes a fresh ETag header on successful write', async () => { + const res = await request(app) + .put(`/api/stacks/${STACK}/files/content`) + .query({ path: 'etag-write.txt' }) + .set('Cookie', adminCookie) + .send({ content: 'etag write' }); + expect(res.status).toBe(204); + expect(res.headers['etag']).toMatch(/^W\/"\d+"$/); + }); + + it('returns 412 when If-Match disagrees with the current mtimeMs and surfaces the live content', async () => { + // Seed the target so a known mtime exists. + const target = path.join(stacksDir, STACK, 'mtime-target.txt'); + await fs.writeFile(target, 'ORIGINAL'); + + const res = await request(app) + .put(`/api/stacks/${STACK}/files/content`) + .query({ path: 'mtime-target.txt' }) + .set('Cookie', adminCookie) + .set('If-Match', '"1"') // deliberately stale + .send({ content: 'overwrite attempt' }); + + expect(res.status).toBe(412); + expect(res.body.code).toBe('PRECONDITION_FAILED'); + expect(res.body.currentContent).toBe('ORIGINAL'); + expect(typeof res.body.currentMtimeMs).toBe('number'); + + // Disk content is unchanged. + const after = await fs.readFile(target, 'utf-8'); + expect(after).toBe('ORIGINAL'); + }); + + it('succeeds when If-Match matches the current mtimeMs', async () => { + const target = path.join(stacksDir, STACK, 'mtime-match.txt'); + await fs.writeFile(target, 'first'); + const getRes = await request(app) + .get(`/api/stacks/${STACK}/files/content`) + .query({ path: 'mtime-match.txt' }) + .set('Cookie', adminCookie); + expect(getRes.status).toBe(200); + const etag = getRes.headers['etag']; + expect(etag).toBeDefined(); + + const putRes = await request(app) + .put(`/api/stacks/${STACK}/files/content`) + .query({ path: 'mtime-match.txt' }) + .set('Cookie', adminCookie) + .set('If-Match', etag) + .send({ content: 'second' }); + expect(putRes.status).toBe(204); + expect(putRes.headers['etag']).toBeDefined(); + expect(putRes.headers['etag']).not.toBe(etag); + + const after = await fs.readFile(target, 'utf-8'); + expect(after).toBe('second'); + }); + + it('still writes when no If-Match header is sent (backward compat)', async () => { + const target = path.join(stacksDir, STACK, 'no-ifmatch.txt'); + await fs.writeFile(target, 'before'); + const res = await request(app) + .put(`/api/stacks/${STACK}/files/content`) + .query({ path: 'no-ifmatch.txt' }) + .set('Cookie', adminCookie) + .send({ content: 'after' }); + expect(res.status).toBe(204); + const after = await fs.readFile(target, 'utf-8'); + expect(after).toBe('after'); + }); + + it('returns 412 with an empty snapshot when If-Match was set but the target has been deleted', async () => { + // No file at this path; the caller's If-Match implies an existing file. + const res = await request(app) + .put(`/api/stacks/${STACK}/files/content`) + .query({ path: 'vanished.txt' }) + .set('Cookie', adminCookie) + .set('If-Match', '"42"') + .send({ content: 'i was editing this' }); + expect(res.status).toBe(412); + expect(res.body.code).toBe('PRECONDITION_FAILED'); + expect(res.body.currentContent).toBe(''); + expect(res.body.currentMtimeMs).toBe(0); + // Nothing was written. + await expect(fs.access(path.join(stacksDir, STACK, 'vanished.txt'))).rejects.toThrow(); + }); + + it('still writes a fresh file when no If-Match is sent (target does not exist)', async () => { + const res = await request(app) + .put(`/api/stacks/${STACK}/files/content`) + .query({ path: 'brand-new.txt' }) + .set('Cookie', adminCookie) + .send({ content: 'first content' }); + expect(res.status).toBe(204); + const content = await fs.readFile(path.join(stacksDir, STACK, 'brand-new.txt'), 'utf-8'); + expect(content).toBe('first content'); + }); }); // ── PATCH /:stackName/files/rename ─────────────────────────────────────────── diff --git a/backend/src/routes/stacks.ts b/backend/src/routes/stacks.ts index fdf06e5d..2093db2a 100644 --- a/backend/src/routes/stacks.ts +++ b/backend/src/routes/stacks.ts @@ -1405,6 +1405,10 @@ stacksRouter.get('/:stackName/files/content', async (req: Request, res: Response logFileDiag('read start', { stackName, relPath, nodeId: req.nodeId }); try { const result = await FileSystemService.getInstance(req.nodeId).readStackFile(stackName, relPath); + // ETag is the integer mtimeMs the file was stat'd with, so the matching + // PUT can compare millisecond-equal even though some filesystems return + // float mtimeMs. + res.setHeader('ETag', stackFileEtag(result.mtimeMs)); logFileDiag('read complete', { stackName, relPath, @@ -1521,10 +1525,28 @@ stacksRouter.put('/:stackName/files/content', async (req: Request, res: Response if (typeof content !== 'string') { return res.status(400).json({ error: '"content" must be a string' }); } + const expectedMtimeMs = parseIfMatchMtime(req.header('if-match')); const startedAt = Date.now(); - logFileDiag('write start', { stackName, relPath, nodeId: req.nodeId, bytes: Buffer.byteLength(content, 'utf-8') }); + logFileDiag('write start', { stackName, relPath, nodeId: req.nodeId, bytes: Buffer.byteLength(content, 'utf-8'), hasIfMatch: expectedMtimeMs !== null }); try { - await FileSystemService.getInstance(req.nodeId).writeStackFile(stackName, relPath, content); + const result = await FileSystemService.getInstance(req.nodeId).writeStackFileIfUnchanged( + stackName, + relPath, + content, + expectedMtimeMs, + ); + if (!result.ok) { + // Stale ETag: surface the current content + mtime so the client can + // show a "file changed elsewhere" diff and let the user reconcile. + res.setHeader('ETag', stackFileEtag(result.currentMtimeMs)); + return res.status(412).json({ + error: 'File has been modified since you last read it. Reload to see the current version.', + code: 'PRECONDITION_FAILED', + currentMtimeMs: result.currentMtimeMs, + currentContent: result.currentContent, + }); + } + res.setHeader('ETag', stackFileEtag(result.mtimeMs)); logFileOperation('info', 'write complete', { nodeId: req.nodeId }); logFileDiag('write timing', { stackName, relPath, nodeId: req.nodeId, elapsedMs: Date.now() - startedAt }); return res.status(204).send(); diff --git a/backend/src/services/FileSystemService.ts b/backend/src/services/FileSystemService.ts index c97abb8c..1f51ed9b 100644 --- a/backend/src/services/FileSystemService.ts +++ b/backend/src/services/FileSystemService.ts @@ -654,31 +654,39 @@ export class FileSystemService { stackName: string, relPath: string, maxBytes: number = 2 * 1024 * 1024 - ): Promise<{ content?: string; binary: boolean; oversized: boolean; size: number; mime: string }> { + ): Promise<{ content?: string; binary: boolean; oversized: boolean; size: number; mime: string; mtimeMs: number }> { const safePath = await this.resolveSafeStackPath(stackName, relPath); - const stat = await fsPromises.stat(safePath); const mime = this.guessMime(safePath); - if (stat.isDirectory()) { - throw Object.assign(new Error('Target is a directory'), { code: 'IS_DIRECTORY' }); + // Open once and stat+read through the same handle so the mtime returned to + // the client matches the bytes it received, even if the file is replaced + // (atomic rename) between the two operations. + const fh = await fsPromises.open(safePath, 'r'); + try { + const stat = await fh.stat(); + const mtimeMs = stat.mtimeMs; + + if (stat.isDirectory()) { + throw Object.assign(new Error('Target is a directory'), { code: 'IS_DIRECTORY' }); + } + + if (stat.size > maxBytes) { + const probe = Buffer.allocUnsafe(8192); + const { bytesRead } = await fh.read(probe, 0, 8192, 0); + const binary = isBinaryBuffer(probe.subarray(0, bytesRead)); + return { binary, oversized: true, size: stat.size, mime, mtimeMs }; + } + + const buf = await fh.readFile(); + + if (isBinaryBuffer(buf)) { + return { binary: true, oversized: false, size: stat.size, mime, mtimeMs }; + } + + return { binary: false, oversized: false, size: stat.size, mime, mtimeMs, content: buf.toString('utf-8') }; + } finally { + await fh.close(); } - - if (stat.size > maxBytes) { - const fd = await fsPromises.open(safePath, 'r'); - const probe = Buffer.allocUnsafe(8192); - const { bytesRead } = await fd.read(probe, 0, 8192, 0); - await fd.close(); - const binary = isBinaryBuffer(probe.subarray(0, bytesRead)); - return { binary, oversized: true, size: stat.size, mime }; - } - - const buf = await fsPromises.readFile(safePath); - - if (isBinaryBuffer(buf)) { - return { binary: true, oversized: false, size: stat.size, mime }; - } - - return { binary: false, oversized: false, size: stat.size, mime, content: buf.toString('utf-8') }; } async streamStackFile( @@ -795,6 +803,58 @@ export class FileSystemService { } } + /** + * Optimistic-concurrency write for arbitrary stack files (file-explorer + * editor save path). If `expectedMtimeMs` is provided, opens the target, + * stats it, and refuses the write (returning current content + mtime) when + * the stat does not match the caller's expectation. Mirrors the + * compose-file pattern in saveStackContentIfUnchanged. + * + * Mtime comparison uses Math.floor so sub-millisecond jitter between + * different filesystems and Node versions does not produce false 412s. + * + * If the file does not exist yet, the write proceeds (no mtime to compare). + * Returns the new mtimeMs so the route can emit a fresh ETag. + */ + async writeStackFileIfUnchanged( + stackName: string, + relPath: string, + content: string, + expectedMtimeMs: number | null, + ): Promise< + | { ok: true; mtimeMs: number } + | { ok: false; currentMtimeMs: number; currentContent: string } + > { + const safePath = await this.resolveSafeStackPath(stackName, relPath); + await fsPromises.mkdir(path.dirname(safePath), { recursive: true }); + + if (expectedMtimeMs !== null) { + let fh: import('fs/promises').FileHandle | null = null; + try { + fh = await fsPromises.open(safePath, 'r'); + const stat = await fh.stat(); + if (Math.floor(stat.mtimeMs) !== Math.floor(expectedMtimeMs)) { + const currentContent = await fh.readFile('utf-8'); + return { ok: false, currentMtimeMs: stat.mtimeMs, currentContent }; + } + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; + // The caller expected an existing file but it has been deleted under + // the editor. That is itself a conflict (the file is gone, the user + // is editing into a void) so surface it the same way as a stale-mtime + // mismatch. An empty current snapshot tells the client "the live + // version is gone, you are starting from scratch". + return { ok: false, currentMtimeMs: 0, currentContent: '' }; + } finally { + if (fh) await fh.close(); + } + } + + await fsPromises.writeFile(safePath, content, 'utf-8'); + const newStat = await fsPromises.stat(safePath); + return { ok: true, mtimeMs: newStat.mtimeMs }; + } + async deleteStackPath(stackName: string, relPath: string, recursive: boolean = false): Promise { if (isProtectedRelPath(relPath)) throw protectedFileError(relPath); const safePath = await this.resolveSafeStackPath(stackName, relPath); diff --git a/frontend/src/components/files/FileViewer.tsx b/frontend/src/components/files/FileViewer.tsx index 399ef982..34d40ad4 100644 --- a/frontend/src/components/files/FileViewer.tsx +++ b/frontend/src/components/files/FileViewer.tsx @@ -4,7 +4,7 @@ import { AlertCircle, FileIcon, Download, Loader2, Save } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Skeleton } from '@/components/ui/skeleton'; import { toast } from '@/components/ui/toast-store'; -import { readStackFile, writeStackFile, downloadStackFile } from '@/lib/stackFilesApi'; +import { readStackFile, writeStackFile, downloadStackFile, FileConflictError } from '@/lib/stackFilesApi'; import { extensionToLanguage } from '@/lib/monacoLanguages'; import { formatBytes } from '@/lib/utils'; @@ -102,6 +102,7 @@ export function FileViewer({ const [isBinary, setIsBinary] = useState(false); const [isOversized, setIsOversized] = useState(false); const [size, setSize] = useState(0); + const [loadedMtimeMs, setLoadedMtimeMs] = useState(null); const readOnly = !canEdit; const hasChanges = content !== originalContent; @@ -154,6 +155,7 @@ export function FileViewer({ .then((result) => { if (cancelled) return; setSize(result.size); + setLoadedMtimeMs(result.mtimeMs); if (result.binary) { setIsBinary(true); } else if (result.oversized) { @@ -182,12 +184,27 @@ export function FileViewer({ setSaving(true); const loadingId = toast.loading('Saving...'); try { - await writeStackFile(stackName, selectedPath, content); + const result = await writeStackFile(stackName, selectedPath, content, { + ifMatchMtimeMs: loadedMtimeMs ?? undefined, + }); setOriginalContent(content); + if (result.mtimeMs !== null) setLoadedMtimeMs(result.mtimeMs); toast.success('Saved.'); onSaved?.(); } catch (e) { - toast.error(e instanceof Error ? e.message : 'Save failed.'); + if (e instanceof FileConflictError) { + // The server-side content has moved on. Update the baseline (so the + // next save sends the fresh mtime and stops looping on the same + // precondition) but leave the user's typed buffer untouched. Their + // edits remain in the editor, Save stays enabled, and a follow-up + // click will apply their changes on top of the new server content + // without silently destroying what they typed. + setOriginalContent(e.currentContent); + setLoadedMtimeMs(e.currentMtimeMs); + toast.error('File changed elsewhere. Review your edits then save again to apply them on top of the current version.'); + } else { + toast.error(e instanceof Error ? e.message : 'Save failed.'); + } } finally { toast.dismiss(loadingId); setSaving(false); diff --git a/frontend/src/components/files/__tests__/FileViewer.test.tsx b/frontend/src/components/files/__tests__/FileViewer.test.tsx index 4c77116f..c1873d78 100644 --- a/frontend/src/components/files/__tests__/FileViewer.test.tsx +++ b/frontend/src/components/files/__tests__/FileViewer.test.tsx @@ -11,17 +11,43 @@ import type { FileContentResult } from '@/lib/stackFilesApi'; // FileViewer now imports `Editor` from the lazy loader, not directly from // @monaco-editor/react. Mock the loader so tests skip Monaco's setup path -// and the editor renders synchronously. +// and the editor renders synchronously. The mock exposes a hidden button +// that calls onChange so tests can drive a dirty buffer without instantiating +// the real editor. vi.mock('@/lib/monacoLoader', () => ({ - Editor: () =>
, + Editor: ({ onChange }: { onChange?: (value: string | undefined) => void }) => ( +
+ +
+ ), DiffEditor: () =>
, })); -vi.mock('@/lib/stackFilesApi', () => ({ - readStackFile: vi.fn(), - writeStackFile: vi.fn(), - downloadStackFile: vi.fn(), -})); +vi.mock('@/lib/stackFilesApi', () => { + class MockFileConflictError extends Error { + readonly code = 'PRECONDITION_FAILED' as const; + readonly currentContent: string; + readonly currentMtimeMs: number; + constructor(message: string, currentContent: string, currentMtimeMs: number) { + super(message); + this.name = 'FileConflictError'; + this.currentContent = currentContent; + this.currentMtimeMs = currentMtimeMs; + } + } + return { + readStackFile: vi.fn(), + writeStackFile: vi.fn(), + downloadStackFile: vi.fn(), + FileConflictError: MockFileConflictError, + }; +}); vi.mock('@/components/ui/toast-store', () => ({ toast: { @@ -63,21 +89,22 @@ vi.mock('@/lib/monacoLanguages', () => ({ extensionToLanguage: () => 'plaintext', })); -import { readStackFile } from '@/lib/stackFilesApi'; +import { readStackFile, writeStackFile, FileConflictError } from '@/lib/stackFilesApi'; import { FileViewer } from '../FileViewer'; const mockReadFile = readStackFile as unknown as ReturnType; +const mockWriteFile = writeStackFile as unknown as ReturnType; function textResult(content = 'hello world'): FileContentResult { - return { content, binary: false, oversized: false, size: content.length, mime: 'text/plain' }; + return { content, binary: false, oversized: false, size: content.length, mime: 'text/plain', mtimeMs: 1_700_000_000_000 }; } function binaryResult(): FileContentResult { - return { binary: true, oversized: false, size: 1024, mime: 'application/octet-stream' }; + return { binary: true, oversized: false, size: 1024, mime: 'application/octet-stream', mtimeMs: 1_700_000_000_000 }; } function oversizedResult(): FileContentResult { - return { binary: false, oversized: true, size: 5_000_000, mime: 'text/plain' }; + return { binary: false, oversized: true, size: 5_000_000, mime: 'text/plain', mtimeMs: 1_700_000_000_000 }; } const defaultProps = { @@ -88,6 +115,7 @@ const defaultProps = { beforeEach(() => { mockReadFile.mockReset(); + mockWriteFile.mockReset(); }); afterEach(() => vi.clearAllMocks()); @@ -188,4 +216,49 @@ describe('FileViewer', () => { expect(onDirtyChange).toHaveBeenCalledWith(false); }); + + it('sends If-Match with the loaded mtime on save and updates the local mtime from the response', async () => { + mockReadFile.mockResolvedValue(textResult('hello')); + mockWriteFile.mockResolvedValue({ mtimeMs: 1_700_000_000_999 }); + + render(); + await waitFor(() => expect(screen.getByTestId('monaco-editor')).toBeInTheDocument()); + + // Drive a dirty buffer via the mock editor's edit trigger so Save activates. + screen.getByTestId('monaco-edit-trigger').click(); + const saveBtn = screen.getByRole('button', { name: /save/i }); + await waitFor(() => expect(saveBtn).not.toBeDisabled()); + saveBtn.click(); + + await waitFor(() => expect(mockWriteFile).toHaveBeenCalledTimes(1)); + const [s, p, c, opts] = mockWriteFile.mock.calls[0]; + expect(s).toBe('my-stack'); + expect(p).toBe('config.txt'); + expect(c).toBe('edited content'); + expect(opts).toEqual({ ifMatchMtimeMs: 1_700_000_000_000 }); + }); + + it('updates baseline on FileConflictError without discarding the user buffer; follow-up save uses new mtime', async () => { + mockReadFile.mockResolvedValue(textResult('stale local copy')); + mockWriteFile + .mockRejectedValueOnce(new FileConflictError('changed elsewhere', 'SERVER NOW', 1_700_000_999_000)) + .mockResolvedValueOnce({ mtimeMs: 1_700_001_000_000 }); + + render(); + await waitFor(() => expect(screen.getByTestId('monaco-editor')).toBeInTheDocument()); + screen.getByTestId('monaco-edit-trigger').click(); + const saveBtn = screen.getByRole('button', { name: /save/i }); + await waitFor(() => expect(saveBtn).not.toBeDisabled()); + saveBtn.click(); + + await waitFor(() => expect(mockWriteFile).toHaveBeenCalledTimes(1)); + + // The follow-up save sends the mtime from the conflict response so the + // user does not loop on the same stale precondition. The user's typed + // content ('edited content' from the mock trigger) is preserved on top. + saveBtn.click(); + await waitFor(() => expect(mockWriteFile).toHaveBeenCalledTimes(2)); + expect(mockWriteFile.mock.calls[1][2]).toBe('edited content'); + expect(mockWriteFile.mock.calls[1][3]).toEqual({ ifMatchMtimeMs: 1_700_000_999_000 }); + }); }); diff --git a/frontend/src/lib/__tests__/api.test.ts b/frontend/src/lib/__tests__/api.test.ts new file mode 100644 index 00000000..897a20b8 --- /dev/null +++ b/frontend/src/lib/__tests__/api.test.ts @@ -0,0 +1,76 @@ +/** + * Unit tests for apiFetch's request shape, specifically the header merge. + * + * Regression guard: callers that pass a `headers` field must not lose the + * default Content-Type and x-node-id that apiFetch builds. An earlier shape + * spread fetchOptions over defaultOptions at the outer level after merging + * headers into defaultOptions.headers, which silently clobbered the merge + * when the caller supplied any `headers` value. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { apiFetch } from '../api'; + +const originalFetch = globalThis.fetch; + +beforeEach(() => { + globalThis.fetch = vi.fn().mockResolvedValue(new Response('{}', { + status: 200, + headers: { 'content-type': 'application/json' }, + })) as unknown as typeof fetch; + try { localStorage.removeItem('sencho-active-node'); } catch { /* jsdom */ } +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); +}); + +function lastFetchInit(): RequestInit { + const calls = (globalThis.fetch as ReturnType).mock.calls; + return calls[calls.length - 1][1] as RequestInit; +} + +describe('apiFetch header merge', () => { + it('always sets Content-Type: application/json on the outgoing request', async () => { + await apiFetch('/health'); + const init = lastFetchInit(); + expect((init.headers as Record)['Content-Type']).toBe('application/json'); + }); + + it('preserves Content-Type even when the caller supplies a custom header', async () => { + await apiFetch('/stacks/foo/files/content?path=app.txt', { + method: 'PUT', + headers: { 'If-Match': '"1700000000000"' }, + body: JSON.stringify({ content: 'hi' }), + }); + const init = lastFetchInit(); + const headers = init.headers as Record; + expect(headers['Content-Type']).toBe('application/json'); + expect(headers['If-Match']).toBe('"1700000000000"'); + }); + + it('merges x-node-id when an active node is set even when caller supplies headers', async () => { + localStorage.setItem('sencho-active-node', '7'); + await apiFetch('/stacks/foo/files', { + method: 'GET', + headers: { 'X-Trace-Id': 'abc' }, + }); + const init = lastFetchInit(); + const headers = init.headers as Record; + expect(headers['x-node-id']).toBe('7'); + expect(headers['Content-Type']).toBe('application/json'); + expect(headers['X-Trace-Id']).toBe('abc'); + localStorage.removeItem('sencho-active-node'); + }); + + it('honours localOnly to skip x-node-id', async () => { + localStorage.setItem('sencho-active-node', '7'); + await apiFetch('/stacks/foo/files', { localOnly: true, headers: { 'If-Match': '"1"' } }); + const init = lastFetchInit(); + const headers = init.headers as Record; + expect(headers['x-node-id']).toBeUndefined(); + expect(headers['If-Match']).toBe('"1"'); + expect(headers['Content-Type']).toBe('application/json'); + localStorage.removeItem('sencho-active-node'); + }); +}); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index d7c138b5..688cc898 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -23,7 +23,12 @@ export async function apiFetch( }, }; - const response = await fetch(url, { ...defaultOptions, ...fetchOptions }); + // Drop headers from fetchOptions before the outer spread so the merged + // defaultOptions.headers (with Content-Type and x-node-id) survives. Without + // this, any caller that passes a `headers` field clobbers the defaults. + const { headers: _callerHeaders, ...fetchOptionsWithoutHeaders } = fetchOptions; + void _callerHeaders; + const response = await fetch(url, { ...defaultOptions, ...fetchOptionsWithoutHeaders }); if (response.status === 401) { // Only fire the global logout event for local auth failures. diff --git a/frontend/src/lib/stackFilesApi.ts b/frontend/src/lib/stackFilesApi.ts index 487cc1db..5a033849 100644 --- a/frontend/src/lib/stackFilesApi.ts +++ b/frontend/src/lib/stackFilesApi.ts @@ -41,6 +41,24 @@ export interface FileContentResult { oversized: boolean; size: number; mime: string; + mtimeMs: number; +} + +/** + * Thrown by writeStackFile when the server reports the target file has been + * modified since the caller's last read (HTTP 412). The current server-side + * content and mtime are attached so callers can prompt the user to reconcile. + */ +export class FileConflictError extends Error { + readonly code = 'PRECONDITION_FAILED' as const; + readonly currentContent: string; + readonly currentMtimeMs: number; + constructor(message: string, currentContent: string, currentMtimeMs: number) { + super(message); + this.name = 'FileConflictError'; + this.currentContent = currentContent; + this.currentMtimeMs = currentMtimeMs; + } } export async function parseApiError(res: Response): Promise { @@ -157,14 +175,36 @@ export async function uploadStackFile( export async function writeStackFile( stackName: string, relPath: string, - content: string -): Promise { + content: string, + options?: { ifMatchMtimeMs?: number } +): Promise<{ mtimeMs: number | null }> { assertSafeRelPath(relPath); + const headers: Record = {}; + if (options?.ifMatchMtimeMs !== undefined) { + headers['If-Match'] = `"${Math.floor(options.ifMatchMtimeMs)}"`; + } const res = await apiFetch( stackFilesUrl(stackName, `/content?path=${encodeURIComponent(relPath)}`), - { method: 'PUT', body: JSON.stringify({ content }) } + { method: 'PUT', headers, body: JSON.stringify({ content }) } ); + if (res.status === 412) { + let body: { currentContent?: string; currentMtimeMs?: number; error?: string } = {}; + try { body = await res.clone().json(); } catch { /* ignore */ } + throw new FileConflictError( + body.error ?? 'File has been modified since you last read it.', + typeof body.currentContent === 'string' ? body.currentContent : '', + typeof body.currentMtimeMs === 'number' ? body.currentMtimeMs : 0, + ); + } if (!res.ok) throw new Error(await parseApiError(res)); + // Parse the ETag the server set so callers can update their local mtime. + const etag = res.headers.get('ETag'); + if (etag) { + const stripped = etag.replace(/^W\//i, '').trim().replace(/^"(.*)"$/, '$1'); + const parsed = Number(stripped); + if (Number.isFinite(parsed)) return { mtimeMs: parsed }; + } + return { mtimeMs: null }; } export async function deleteStackPath(