From 9c5f4f9a36d78e7fd7f7caf96143239dd478c12c Mon Sep 17 00:00:00 2001 From: Pippa-2571 Date: Mon, 26 Oct 2026 01:21:18 -0400 Subject: [PATCH 1/3] fix(workspace): restore contents the store lost from disk's matching copy A file the workspace holds is a tree entry naming its contents by their SHA-256, and chronicle keeps those contents as a blob file. When that file was gone, read, edit, edit_many and readBinary answered "Blob not found for:", and grep left the file unsearched, though disk usually holds the same bytes. A blob file chronicle can't read failed the whole grep, the whole materialize that pushed it, and a listing that read a branch intent stored that way. Neither sync nor materialize helped a lost one: disk agrees with the entry by hash, so neither stores anything. contentsOf now reads a version's contents from the store, and where they are missing or unreadable there, reads disk's copy within the mount's boundary and takes it only when its size and SHA-256 are the version's. It stores the copy again, reads the store back to see whether that took, and answers with the bytes. Where disk can't supply them it reads the store once more before reporting them lost. Whoever runs the host gets an ops alert: workspace-contents-restored; workspace-contents-unstored when the store doesn't keep them, as chronicle 0.4.0 keeps a blob file it can't read in place; workspace-contents-lost when no copy with their hash could be read. Each alert names the blob's hash, and the lost and unstored ones say what brings the contents back. - read, readBinary and read_image's read of the workspace's copy restore in place. - edit and edit_many read each file's contents after their pass, restore lost ones, and run the pass again, keeping both passes' changes for the receipt. The plan reads each entry and sets it with nothing awaited between, using the bytes read for that entry's hash. - grep searches readable texts in its loop, restores lost versions (the workspace's and a conflict's recorded disk version) after it, and records every answer in path order, so one unreadable blob no longer fails it. - pushPaths and readIntent read through storedBytes, now in sync.ts: an unreadable workspace copy is skipped, saying so, and an unreadable intent gives its unreadable reason. - Where nothing can restore them, the answer says what the store and disk hold, what didn't happen, and a remedy the mount can take (writeRefusal decides which), each checked by a test that takes it. - read_image serving disk's other image says that it's disk's copy, and where disk's copy can't be shown, its lost answer keeps disk's reason. Co-Authored-By: Claude Opus 5.5 --- .../workspace-restore-lost-contents.fixed.md | 3 + src/modules/workspace/branch-intent.ts | 12 +- src/modules/workspace/index.ts | 301 +++++++-- src/modules/workspace/reconcile.ts | 20 +- src/modules/workspace/sync.ts | 16 + test/workspace-edits.test.ts | 3 +- test/workspace-lost-contents.test.ts | 602 ++++++++++++++++++ test/workspace-three-way.test.ts | 36 +- 8 files changed, 906 insertions(+), 87 deletions(-) create mode 100644 changelog.d/workspace-restore-lost-contents.fixed.md create mode 100644 test/workspace-lost-contents.test.ts diff --git a/changelog.d/workspace-restore-lost-contents.fixed.md b/changelog.d/workspace-restore-lost-contents.fixed.md new file mode 100644 index 000000000..d4070a859 --- /dev/null +++ b/changelog.d/workspace-restore-lost-contents.fixed.md @@ -0,0 +1,3 @@ +- A workspace file whose stored contents the store has lost, or can't read, is now read from disk's copy when that copy has exactly those bytes (the same size and SHA-256), by `read`, `edit`, `edit_many`, `grep`, `read_image` and `readBinary`. The contents are stored again, and whoever runs the host gets an ops alert: `workspace-contents-restored`, `workspace-contents-unstored` when the store doesn't keep them, or `workspace-contents-lost` when no copy with their hash can be read. Before, `read`, `edit`, `edit_many` and `readBinary` answered "Blob not found for:", `grep` left the file unsearched, and a blob it couldn't read failed the whole `grep`. Where nothing can restore them, the answer says what the store and disk hold and what the agent can do instead. +- `read_image` serving disk's copy of a file whose workspace copy is lost now says so in its text. +- `materialize` lists a workspace copy the store can't read as skipped, saying so, rather than failing the whole call, and a branch intent whose stored record can't be read leaves its path unverified with that reason rather than failing the listing. diff --git a/src/modules/workspace/branch-intent.ts b/src/modules/workspace/branch-intent.ts index 07b26c0a6..e9afe5aab 100644 --- a/src/modules/workspace/branch-intent.ts +++ b/src/modules/workspace/branch-intent.ts @@ -19,6 +19,7 @@ */ import type { JsStore } from '@animalabs/chronicle'; +import { storedBytes } from './sync.js'; export type ConflictKind = /** Disk, workspace and the last agreement all differ. */ @@ -56,8 +57,9 @@ export interface BranchIntent { origin?: 'store'; conflict?: ConflictRecord; /** - * Why a recorded intent can't be read (its blob missing from the store, or - * not an intent): a tombstone or conflict it held is unknown, never absent. + * Why a recorded intent can't be read (its blob missing from the store, + * a blob chronicle can't read, or not an intent): a tombstone or conflict + * it held is unknown, never absent. * Never written; a put replaces the damaged record. */ unreadable?: string; @@ -70,8 +72,10 @@ function isEmpty(intent: BranchIntent): boolean { /** A recorded intent's blob, read; a damaged one says why it can't be. */ function readIntent(store: JsStore, blobHash: string): BranchIntent { - const blob = store.getBlob(blobHash); - if (!blob) return { unreadable: "its record's blob is missing from the store" }; + const stored = storedBytes(store, blobHash); + if ('missing' in stored) return { unreadable: "its record's blob is missing from the store" }; + if ('unreadable' in stored) return { unreadable: `its record's blob can't be read from the store (${stored.unreadable})` }; + const blob = stored.bytes; try { const parsed = JSON.parse(blob.toString('utf-8')) as unknown; if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed as BranchIntent; diff --git a/src/modules/workspace/index.ts b/src/modules/workspace/index.ts index bffc0b3ab..2bb28c4a0 100644 --- a/src/modules/workspace/index.ts +++ b/src/modules/workspace/index.ts @@ -37,7 +37,7 @@ import type { } from './types.js'; import { WORKSPACE_FS_EVENT_TYPES, opToEventType } from './types.js'; import { MountWatcher, type FsChange } from './watcher.js'; -import { hashContent, DEFAULT_MAX_FILE_SIZE } from './sync.js'; +import { hashContent, storedBytes, DEFAULT_MAX_FILE_SIZE, type StoredBytes } from './sync.js'; import { DiskAgreement, sameRootIdentity } from './disk-agreement.js'; import { type BranchIntent, BranchIntents } from './branch-intent.js'; import { IGNORED, NONBLOCK, OTHER_MOUNT, OUTSIDE_INCLUDE, ROOT_REPLACED, ROOT_UNAVAILABLE, beginsWithDir, checkDir, checkRoot, coversLanding, diskSpelling, filesystemTrustsCtime, foldsCase, includePatternReadable, inOtherMount, insideMount, leadsInto, linkOnTheWay, linkRefusal, linkWords, locateRoot, looksBinary, namesHolder, namesNestedPlace, nestedAtReal, nestedHolder, observePath, parsedSpelling, passedOverAt, pathSegments, readBytes, readPrefix, shownLeads, sniffImageMime, walkScope, withinIncludes, type CheckedDir, type LinkFound, type MountView, type NestedMount, type WorkspaceEntries } from './observe.js'; @@ -149,8 +149,7 @@ class WorkspaceImageReadError extends Error { | 'too_large' | 'truncated' | 'invalid' - | 'unsupported' - | 'blob_missing', + | 'unsupported', message: string, ) { super(message); @@ -2492,9 +2491,9 @@ export class WorkspaceModule implements Module { const { report, undecided } = await this.ensureSynced(mount, relativePath); const entry = store.treeGet(mount.treeStateId, relativePath); if (!entry) return { error: undecided !== null ? this.notCheckedYet(mountPrefixedPath, undecided) : this.notInWorkspace(mountPrefixedPath, report) }; - const blob = store.getBlob(entry.blobHash); - if (!blob) return { error: `Blob not found for: ${mountPrefixedPath}` }; - return { data: blob }; + const contents = await this.contentsOf(mount, relativePath, mountPrefixedPath, entry); + if ('lost' in contents) return { error: nothingToRead(contents.lost) }; + return { data: contents.bytes }; }); } @@ -3149,22 +3148,17 @@ export class WorkspaceModule implements Module { }; } - private tryReadImageFromTree( + private async readImageFromTree( mount: MountState, relativePath: string, mountPrefixedPath: string, maxSize: number, - ): { bytes: Buffer; mimeType: SupportedImageMimeType } | null { - const store = this.getStore(); - const entry = store.treeGet(mount.treeStateId, relativePath); + ): Promise<{ bytes: Buffer; mimeType: SupportedImageMimeType } | { lost: LostContents } | null> { + const entry = this.getStore().treeGet(mount.treeStateId, relativePath); if (!entry) return null; - - const blob = store.getBlob(entry.blobHash); - if (!blob) { - throw new WorkspaceImageReadError('blob_missing', `Blob not found for: ${mountPrefixedPath}`); - } - - return this.validateImageBytes(blob, mountPrefixedPath, maxSize); + const contents = await this.contentsOf(mount, relativePath, mountPrefixedPath, entry); + if ('lost' in contents) return contents; + return this.validateImageBytes(contents.bytes, mountPrefixedPath, maxSize); } /** @@ -3971,9 +3965,9 @@ export class WorkspaceModule implements Module { : [this.notInWorkspace(path, report), ...again].join(' '), }; } - const blob = store.getBlob(entry.blobHash); - if (!blob) return { error: `Blob not found for: ${path}` }; - return { bytes: blob, notes: [...whichCopyRead(report, took('modified'), mount.config.mode === 'read-only'), ...again] }; + const contents = await this.contentsOf(mount, relativePath, path, entry); + if ('lost' in contents) return { error: nothingToRead(contents.lost) }; + return { bytes: contents.bytes, notes: [...whichCopyRead(report, took('modified'), mount.config.mode === 'read-only'), ...again] }; }); } @@ -4014,6 +4008,73 @@ export class WorkspaceModule implements Module { } } + private async contentsOf( + mount: MountState, + relativePath: string, + path: string, + version: { blobHash: string; size: number }, + what: 'saved' | 'conflict-disk' = 'saved', + ): Promise<{ bytes: Buffer } | { lost: LostContents }> { + const store = this.getStore(); + const stored = storedBytes(store, version.blobHash); + if ('bytes' in stored) return stored; + const otherSize = new Error('other size'); + let found: { bytes: Buffer } | { disk: LostContents['disk'] }; + try { + const opened = await this.openContainedFile(mount, relativePath, path, (fileStat) => { + if (fileStat.size !== version.size) throw otherSize; + }); + try { + const read = await readPrefix(opened.handle, version.size, opened.fileStat.size); + found = !read.more && hashContent(read.bytes) === version.blobHash ? { bytes: read.bytes } : { disk: 'other' }; + } finally { + await opened.handle.close(); + } + } catch (err) { + found = { + disk: err === otherSize ? 'other' + : err instanceof WorkspaceReadError && err.stage === 'lstat' && (err.errno === 'ENOENT' || err.errno === 'ENOTDIR') ? 'none' + : { unreadable: containedRefusal(err, path) }, + }; + } + const subject = what === 'saved' ? `The saved contents of ${path}` : `The version of ${path} that its conflict recorded as disk's`; + const inStore = 'missing' in stored ? "missing from the workspace's store" : `unreadable in the workspace's store (${stored.unreadable})`; + const data = { mount: mount.config.name, path, blobHash: version.blobHash, ...('unreadable' in stored ? { storeError: stored.unreadable } : {}) }; + if ('disk' in found) { + const now = storedBytes(store, version.blobHash); + if ('bytes' in now) return now; + const lost: LostContents = { path, store: stored, disk: found.disk, writable: writeRefusal(mount) === null }; + this.ctx?.notifyOps?.('workspace-contents-lost', 'workspace', + `${subject} (blob ${version.blobHash}) are ${inStore}, and no copy with their hash could be read from disk to restore them from: ${lostOnDisk(lost)}. ` + + "Putting back the store's blob file for that hash, from a copy of the store, brings them back.", data); + return { lost }; + } + const { bytes } = found; + if ('bytes' in storedBytes(store, version.blobHash)) return { bytes }; + let thrown: string | null = null; + try { + store.storeBlob(bytes, 'application/octet-stream'); + } catch (error) { + thrown = error instanceof Error ? error.message : String(error); + } + // Chronicle's storeBlob takes any file already at the hash's path as + // stored, so whether the bytes took is what a read of them gives now. + const now = storedBytes(store, version.blobHash); + if ('bytes' in now) { + this.ctx?.notifyOps?.('workspace-contents-restored', 'workspace', + `${subject} (blob ${version.blobHash}) were ${inStore}, and were stored again from disk's copy, whose bytes have their hash.`, data); + } else { + const unstored = thrown ?? ('missing' in now ? 'the store still has no file for them' : `the store still can't read its file for them (${now.unreadable})`); + this.ctx?.notifyOps?.('workspace-contents-unstored', 'workspace', + `${subject} (blob ${version.blobHash}) are ${inStore}. Disk's copy has their hash, so reads are answered from it, but storing it again didn't take: ${unstored}. ` + + ('unreadable' in now + ? "Each read does this again while that lasts. Chronicle keeps a blob file it can't read in place, so where the file is damaged, removing it lets the next read store them." + : 'Each read does this again until the store takes them.'), + { ...data, unstored }); + } + return { bytes }; + } + // ========================================================================== // Tool Handlers // ========================================================================== @@ -4072,13 +4133,15 @@ export class WorkspaceModule implements Module { const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE; let treeError: WorkspaceImageReadError | null = null; + let lost: LostContents | null = null; // The workspace's copy first, where it holds one, then disk's. A direct // mount holds none, so its image is disk's. if (!mount.direct) { try { - const image = this.tryReadImageFromTree(mount, relativePath, input.path, maxSize); - if (image) return imageResult(input.path, image); + const image = await this.readImageFromTree(mount, relativePath, input.path, maxSize); + if (image && 'lost' in image) lost = image.lost; + else if (image) return imageResult(input.path, image); } catch (err) { if (err instanceof WorkspaceImageReadError) { treeError = err; @@ -4089,9 +4152,18 @@ export class WorkspaceModule implements Module { } try { - return imageResult(input.path, await this.readImageFromFilesystem(mount, relativePath, input.path, maxSize)); + const note = lost === null ? undefined + : `The workspace's copy of ${input.path} is ${lostInStore(lost)}, so this is disk's copy${lost.disk === 'other' ? ', which holds other contents' : ''}.`; + return imageResult(input.path, await this.readImageFromFilesystem(mount, relativePath, input.path, maxSize), note); } catch (err) { if (err instanceof WorkspaceImageReadError) { + if (lost) { + return { + success: false, + isError: true, + error: err.code === 'not_found' ? nothingToRead(lost) : `${lostText(lost)}, so it can't be restored, and disk's copy can't be shown (${err.message}). ${lostRemedy(lost)}`, + }; + } if (err.code === 'not_found' && treeError) { return { success: false, error: treeError.message, isError: true }; } @@ -4191,12 +4263,14 @@ export class WorkspaceModule implements Module { * stored where its record landed, and that it can't tell where they * couldn't be read back. * Under the mount's turn, one pass settles every path with disk, by the - * rule a listing uses, so the edits apply to what disk now holds. That pass - * is the only step that awaits before the commit: every file is then read, - * every edit planned, and every entry set (commitToolChanges) with no turn - * of the event loop between, so no pass, branch change or other call moves - * the tree between a file's read and its set. Disk then takes what it can - * of the stored change. + * rule a listing uses, so the edits apply to what disk now holds. Each + * file's stored contents are then read; where the store has lost some, + * they're restored from disk's matching copy (contentsOf) and the pass runs + * again, so the plan reads a pass taken after every await. From there + * nothing awaits before the commit: every edit is planned and every entry + * set (commitToolChanges) with no turn of the event loop between, so no + * pass, branch change or other call moves the tree between a file's read + * and its set. Disk then takes what it can of the stored change. * * The answer is held to `maxChars`, the length the call's result may have * (ToolCall.resultMaxChars), as edit-text.ts describes. @@ -4291,7 +4365,28 @@ export class WorkspaceModule implements Module { return await this.withMount(mount, async (): Promise => { // As every file tool's pass does, it names the paths it acts on, so a // file the mount passes over by design is observed (see PassOptions.named). - const pass = await this.passUnlocked(mount, { kind: 'paths', paths: [...targets.values()].map((target) => target.relativePath) }, { named: true }); + let pass = await this.passUnlocked(mount, { kind: 'paths', paths: [...targets.values()].map((target) => target.relativePath) }, { named: true }); + let ops = pass.ops; + const contents = new Map(); + const lost = new Map(); + const toRecover: Array<{ target: EditTarget; entry: JsTreeEntry }> = []; + for (const target of targets.values()) { + const entry = store.treeGet(mount.treeStateId, target.relativePath); + if (!entry || contents.has(entry.blobHash)) continue; + const stored = storedBytes(store, entry.blobHash); + if ('bytes' in stored) contents.set(entry.blobHash, stored.bytes); + else toRecover.push({ target, entry }); + } + if (toRecover.length > 0) { + for (const { target, entry } of toRecover) { + if (contents.has(entry.blobHash)) continue; + const found = await this.contentsOf(mount, target.relativePath, target.path, entry); + if ('bytes' in found) contents.set(entry.blobHash, found.bytes); + else lost.set(target.relativePath, { blobHash: entry.blobHash, lost: found.lost }); + } + pass = await this.passUnlocked(mount, { kind: 'paths', paths: [...targets.values()].map((target) => target.relativePath) }, { named: true }); + ops = [...ops, ...pass.ops]; + } // A pass that decided nothing checked no file's disk copy. const undecided = undecidedOf(pass); // Nothing below awaits until the commit's push. @@ -4310,7 +4405,7 @@ export class WorkspaceModule implements Module { // A mode taken in alone isn't a change to the text: it brings nothing // new to read, as it makes no event (emitFsEvents), and the edits // then write at the mode disk has, so it leaves nothing to say. - const took = (op: TreeOp) => pass.ops.some((o) => o.path === target.relativePath && o.op === op && !o.modeOnly); + const took = (op: TreeOp) => ops.some((o) => o.path === target.relativePath && o.op === op && !o.modeOnly); // Which copy the edits used, when it isn't the one the workspace // last agreed on with disk: disk's newer text, or the workspace's // copy because disk's couldn't be read or disk has none. A file the @@ -4380,13 +4475,23 @@ export class WorkspaceModule implements Module { problems.push({ kind: 'file', edits: target.edits, path: target.path, text, whole: true }); continue; } - notes.push(...lookedIn); - const blob = store.getBlob(entry.blobHash); - if (!blob) { - problems.push({ kind: 'file', edits: target.edits, path: target.path, text: `Blob not found for: ${target.path}`, whole: true }); + const known = contents.get(entry.blobHash); + const stored = known !== undefined ? { bytes: known } : storedBytes(store, entry.blobHash); + if (!('bytes' in stored)) { + const found = lost.get(target.relativePath); + problems.push({ + kind: 'file', + edits: target.edits, + path: target.path, + text: found?.blobHash === entry.blobHash + ? `${lostText(found.lost)}, so it can't be restored, and ${subject} changed nothing. ${lostRemedy(found.lost)}` + : `The workspace's copy of ${target.path} changed while ${subject} restored lost contents from disk, and is ${lostInStore({ store: stored })} too, so ${subject} changed nothing. Make the same call again.`, + whole: true, + }); continue; } - const plan = planTargetEdits(tool, target, blob, hints, maxSize, counts, problems); + notes.push(...lookedIn); + const plan = planTargetEdits(tool, target, stored.bytes, hints, maxSize, counts, problems); if (plan === null) continue; plans.push({ target, @@ -5369,11 +5474,9 @@ export class WorkspaceModule implements Module { // read doesn't show it. const crlfFiles = new Map(); const bomFiles = new Map(); - const search = (text: GrepText): Searched => { + const search = (text: GrepText): Searched & { bom: boolean } => { const { lines, crlfEnds, lfEnds, bom } = fileText(text.bytes); const lineEndings = crlfEnds > 0 ? (lfEnds > 0 ? 'mixed' : 'CRLF') : undefined; - if (lineEndings !== undefined && !crlfFiles.has(text.file)) crlfFiles.set(text.file, { ends: lineEndings, version: text.version }); - if (bom && !bomFiles.has(text.file)) bomFiles.set(text.file, text.version); const found: GrepMatch[] = []; for (let i = 0; i < lines.length; i++) { if (regex.test(lines[i])) { @@ -5386,7 +5489,7 @@ export class WorkspaceModule implements Module { found.push(match); } } - return { ...(lineEndings !== undefined ? { lineEndings } : {}), matches: found }; + return { ...(lineEndings !== undefined ? { lineEndings } : {}), matches: found, bom }; }; for (const { mount, relativePath, moved, root } of mountsToSearch) { @@ -5412,16 +5515,21 @@ export class WorkspaceModule implements Module { return { success: false, error: await this.disjointRefusal('grep', input.path!, input.glob!, prefixed !== null, mount, relativePath, root), isError: true }; } const start = await this.walkStart(mount, relativePath, deeper === relativePath ? '' : deeper.slice(relativePath ? relativePath.length + 1 : 0)); - const searched = (text: GrepText): void => { - const hits = search(text); - if (hits.matches.length > 0) results.push({ file: text.file, ...(text.state !== undefined ? { state: text.state } : {}), version: text.version, ...hits }); + const searched = (text: GrepText): (() => void) => { + const { bom, ...hits } = search(text); + const { file, state, version } = text; + return () => { + if (hits.lineEndings !== undefined && !crlfFiles.has(file)) crlfFiles.set(file, { ends: hits.lineEndings, version }); + if (bom && !bomFiles.has(file)) bomFiles.set(file, version); + if (hits.matches.length > 0) results.push({ file, ...(state !== undefined ? { state } : {}), version, ...hits }); + }; }; const kept = prefixed ? (path: string): boolean => prefixed.regex.test(path) : (path: string): boolean => fileGlob === null || fileGlob.test(path) || (byName && fileGlob.test(path.slice(path.lastIndexOf('/') + 1))); const found = mount.direct - ? await this.textsFromDisk(mount, relativePath, root, start, kept, searched) + ? await this.textsFromDisk(mount, relativePath, root, start, kept, (text) => searched(text)()) : await this.textsFromStore(mount, relativePath, start, kept, searched); skipped.push(...found.skipped); incomplete.push(...found.incomplete); @@ -5471,8 +5579,9 @@ export class WorkspaceModule implements Module { /** * The texts a 'chronicle' mount's grep searches beneath `start`, or in the * single file `relativePath` names, those `keep` lets through, each handed - * to `each` in path order: a pass over the scope, then each path's - * workspace copy, labelled with its state, and a conflict's disk side. + * to `search`, whose answers are recorded in path order: a pass over the + * scope, then each path's workspace copy, labelled with its state, and a + * conflict's disk side. * What it doesn't search is in `skipped`, and what it couldn't see in * `incomplete`, each mount-prefixed. */ @@ -5481,7 +5590,7 @@ export class WorkspaceModule implements Module { relativePath: string, start: string, keep: (path: string) => boolean, - each: (text: GrepText) => void, + search: (text: GrepText) => () => void, ): Promise<{ skipped: GrepSkip[]; incomplete: Array<{ path: string; reason: string }> }> { const store = this.getStore(); const skipped: GrepSkip[] = []; @@ -5534,16 +5643,18 @@ export class WorkspaceModule implements Module { } const intents = this.intents.get(mount.config.name)!; + type Lost = Omit & { relativePath: string; hash: string; size: number; what: 'saved' | 'conflict-disk' }; + const found: Array<{ searched: () => void } | { skip: GrepSkip } | { lost: Lost }> = []; for (const report of [...pass.reports.values()].sort((a, b) => a.path.localeCompare(b.path))) { if (!keep(report.path)) continue; const file = `${mount.config.name}/${report.path}`; if (report.state === 'disk-only') { - skipped.push({ + found.push({ skip: { file, reason: 'disk-only: binary or over the size limit, never stored', ...(report.size !== undefined ? { size: report.size } : {}), ...(report.mimeType ? { mimeType: report.mimeType } : {}), - }); + } }); continue; } // A path in another mount's directory is that mount's: grep gives @@ -5552,16 +5663,16 @@ export class WorkspaceModule implements Module { if (inOtherMount(report.path, this.othersOf(mount.config.name))) continue; const refusal = refused.get(report.path); if (refusal !== undefined) { - skipped.push({ file, reason: refusal }); + found.push({ skip: { file, reason: refusal } }); continue; } // The workspace's version, labelled with the path's state. const entry = store.treeGet(mount.treeStateId, report.path); - const blob = entry ? store.getBlob(entry.blobHash) : null; - if (blob) { - each({ file, state: report.state, version: 'workspace', bytes: blob }); - } else if (entry) { - skipped.push({ file, reason: 'the workspace copy is missing from the store, so it was not searched' }); + if (entry) { + const stored = storedBytes(store, entry.blobHash); + found.push('bytes' in stored + ? { searched: search({ file, state: report.state, version: 'workspace', bytes: stored.bytes }) } + : { lost: { file, state: report.state, version: 'workspace', relativePath: report.path, hash: entry.blobHash, size: entry.size, what: 'saved' } }); } if (report.state !== 'conflict') continue; // A conflict's disk side. The version recorded with the conflict is @@ -5571,19 +5682,45 @@ export class WorkspaceModule implements Module { // disk version was never stored either. const disk = intents.get(report.path)?.conflict?.disk; const changed = report.conflict?.diskChangedSinceRecorded === true; - const diskBlob = disk?.stored ? store.getBlob(disk.stored) : null; - if (diskBlob) { - each({ file, state: report.state, version: changed ? 'recorded-disk' : 'conflicting-disk', bytes: diskBlob }); - } - if (disk?.stored && !diskBlob) { - skipped.push({ file, reason: 'conflict: the disk version recorded with the conflict is missing from the store, so it was not searched' }); + if (disk?.stored) { + const version = changed ? 'recorded-disk' : 'conflicting-disk'; + const stored = storedBytes(store, disk.stored); + found.push('bytes' in stored + ? { searched: search({ file, state: report.state, version, bytes: stored.bytes }) } + : { lost: { file, state: report.state, version, relativePath: report.path, hash: disk.stored, size: disk.size, what: 'conflict-disk' } }); } if (changed) { if (report.diskNow === 'file') { - skipped.push({ file, reason: 'conflict: disk changed since the conflict was recorded; grep searches stored text, so the file disk holds now was not searched' }); + found.push({ skip: { file, reason: 'conflict: disk changed since the conflict was recorded; grep searches stored text, so the file disk holds now was not searched' } }); } } else if (disk && !disk.stored) { - skipped.push({ file, reason: 'conflict: the disk version is binary or over the size limit, never stored', size: disk.size }); + found.push({ skip: { file, reason: 'conflict: the disk version is binary or over the size limit, never stored', size: disk.size } }); + } + } + const restored = new Map(); + for (const item of found) { + if (!('lost' in item)) continue; + const { file, relativePath, hash, size, what } = item.lost; + restored.set(item.lost, await this.contentsOf(mount, relativePath, file, { blobHash: hash, size }, what)); + } + for (const item of found) { + if ('searched' in item) item.searched(); + else if ('skip' in item) skipped.push(item.skip); + else { + const { file, state, version, hash, what } = item.lost; + let contents = restored.get(item.lost)!; + if ('lost' in contents) { + const now = storedBytes(store, hash); + if ('bytes' in now) contents = now; + } + if ('bytes' in contents) search({ file, state, version, bytes: contents.bytes })(); + else { + skipped.push({ + file, + reason: `${what === 'saved' ? "the workspace's copy" : 'conflict: the disk version recorded with the conflict'} is ${lostInStore(contents.lost)}, ` + + `and ${lostOnDisk(contents.lost)}, so it was not searched`, + }); + } } } }); @@ -6408,13 +6545,13 @@ export async function inOrder(items: readonly T[], width: number, work: (i } /** read_image's answer for an image, wherever it was read from. */ -function imageResult(path: string, image: { bytes: Buffer; mimeType: SupportedImageMimeType }): ToolResult { +function imageResult(path: string, image: { bytes: Buffer; mimeType: SupportedImageMimeType }, note?: string): ToolResult { return { success: true, data: [ { type: 'text', - text: `Path: ${path}\nMIME: ${image.mimeType}\nBytes: ${image.bytes.byteLength}`, + text: `Path: ${path}\nMIME: ${image.mimeType}\nBytes: ${image.bytes.byteLength}${note === undefined ? '' : `\n${note}`}`, }, { type: 'image', @@ -6607,6 +6744,40 @@ function containedRefusal(err: unknown, path: string): string { return bare ? `${err.message} (${err.errno})` : err instanceof Error ? err.message : String(err); } +type LostContents = { + path: string; + store: Exclude; + disk: 'other' | 'none' | { unreadable: string }; + writable: boolean; +}; + +function lostInStore(lost: Pick): string { + return 'missing' in lost.store ? 'missing from the store' : `unreadable in the store (${lost.store.unreadable})`; +} + +function lostOnDisk(lost: LostContents): string { + return lost.disk === 'other' ? "disk's copy holds other contents" + : lost.disk === 'none' ? 'disk has no copy' + : `disk's copy can't be read (${lost.disk.unreadable})`; +} + +function lostText(lost: LostContents): string { + return `The workspace's copy of ${lost.path} is ${lostInStore(lost)}, and ${lostOnDisk(lost)}`; +} + +function nothingToRead(lost: LostContents): string { + return `${lostText(lost)}, so it can't be restored or read. ${lostRemedy(lost)}`; +} + +function lostRemedy(lost: LostContents): string { + const rewrite = lost.writable && 'missing' in lost.store; + if (lost.disk === 'other') return `Sync the path to keep disk's copy and give up the workspace's${rewrite ? ', or write the file again' : ''}.`; + if (lost.writable) return rewrite ? 'Write the file again, or delete it.' : 'Delete the file to clear it.'; + return lost.disk === 'none' + ? "Sync the path to take disk's deletion, which removes the workspace's copy." + : "Only a change on disk can end this: once disk's copy can be read, read the file again, which restores it from a copy with the same contents, or sync the path to keep disk's copy and give up the workspace's."; +} + /** * The notice that writes an agent made direct remain on disk from * turns the agent's history no longer holds. `git status` stands beside diff --git a/src/modules/workspace/reconcile.ts b/src/modules/workspace/reconcile.ts index 93e72a995..4866fc8aa 100644 --- a/src/modules/workspace/reconcile.ts +++ b/src/modules/workspace/reconcile.ts @@ -53,6 +53,7 @@ import { sameRootIdentity, } from './disk-agreement.js'; import type { BranchIntent, BranchIntents, ConflictKind, ConflictRecord, DiskCounterpart } from './branch-intent.js'; +import { storedBytes, type StoredBytes } from './sync.js'; import { IGNORED, type MountView, @@ -1386,12 +1387,12 @@ export async function pushPaths( continue; } const priorP = p && (p.kind === 'absent' || p.kind === 'content') ? p : null; - const blob = store.getBlob(s.hash); + const stored = storedBytes(store, s.hash); const forcedMode = mount.modesObserved && s.mode !== undefined && s.mode !== (d.kind === 'file' ? d.mode : undefined) ? s.mode : undefined; const agreedMode = priorP?.kind === 'content' ? priorP.mode : undefined; - if (blob) { + if ('bytes' in stored) { plans.push({ - path, kind: 'write', blob, hash: s.hash, prior: priorP, before: agreement.get(mount.name, path), expect, + path, kind: 'write', blob: stored.bytes, hash: s.hash, prior: priorP, before: agreement.get(mount.name, path), expect, ...(forcedMode !== undefined ? { apply: forcedMode } : {}), ...(forcedMode !== undefined && d.kind === 'file' && agreedMode !== d.mode ? { overDisk: true as const } : {}), ...(s.mode !== undefined ? { storeMode: s.mode } : {}), @@ -1400,7 +1401,7 @@ export async function pushPaths( ...(agreedMode !== undefined ? { priorMode: agreedMode } : {}), ...(d.kind === 'file' && d.mode !== undefined ? { seenMode: d.mode } : {}), }); - } else result.skipped.push({ path, reason: 'the workspace copy is missing from the store' }); + } else result.skipped.push({ path, reason: unstoredCopy(stored) }); continue; } if (v.mode === 'adopt' && s && d.kind === 'file') { @@ -1551,11 +1552,12 @@ export async function pushPaths( plans.push({ path, kind: 'unlink', prior, before: agreement.get(mount.name, path), expect }); continue; } - const blob = store.getBlob(s.hash); - if (!blob) { - result.skipped.push({ path, reason: 'the workspace copy is missing from the store' }); + const stored = storedBytes(store, s.hash); + if (!('bytes' in stored)) { + result.skipped.push({ path, reason: unstoredCopy(stored) }); continue; } + const blob = stored.bytes; // The store's mode goes to disk with its bytes where the file is new, // the push is forced, or disk still has the mode they last agreed on. A // push that completes our own unconfirmed one, whose bytes landed, is a @@ -1774,3 +1776,7 @@ export async function pushPaths( agreement.maybeCheckpoint(); return result; } + +function unstoredCopy(stored: Exclude): string { + return 'missing' in stored ? 'the workspace copy is missing from the store' : `the workspace copy is unreadable in the store (${stored.unreadable})`; +} diff --git a/src/modules/workspace/sync.ts b/src/modules/workspace/sync.ts index 189491ae3..8e44cfc2f 100644 --- a/src/modules/workspace/sync.ts +++ b/src/modules/workspace/sync.ts @@ -6,6 +6,7 @@ */ import { createHash } from 'node:crypto'; +import type { JsStore } from '@animalabs/chronicle'; export const DEFAULT_MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB @@ -18,3 +19,18 @@ export function hashContent(content: string | Buffer): string { hash.update(content); return hash.digest('hex'); } + +export type StoredBytes = { bytes: Buffer } | { missing: true } | { unreadable: string }; + +export function storedBytes(store: JsStore, hash: string): StoredBytes { + try { + const blob = store.getBlob(hash); + return blob ? { bytes: blob } : { missing: true }; + } catch (error) { + if (store.isClosed()) throw error; + // Chronicle's getBlob throws, rather than answering null, for a blob file + // it can't read whole or whose format, checksum or hash doesn't check out, + // such as one a write cut short left truncated. + return { unreadable: error instanceof Error ? error.message : String(error) }; + } +} diff --git a/test/workspace-edits.test.ts b/test/workspace-edits.test.ts index 65a11f382..ac48d4e39 100644 --- a/test/workspace-edits.test.ts +++ b/test/workspace-edits.test.ts @@ -978,6 +978,7 @@ test("edit_many refuses a file whose stored copy is missing from the store, and const { store, workspace, call, stored, file } = setup(t); await file('a.txt', 'alpha\n'); await file('b.txt', 'beta\n'); + assert.equal((await call('write', { path: 'work/b.txt', content: 'beta, a draft\n' })).success, true); const missing = store.treeGet(treeStateOf(workspace), 'b.txt')!.blobHash; const s = store as unknown as Record unknown>; const getBlob = store.getBlob; @@ -988,7 +989,7 @@ test("edit_many refuses a file whose stored copy is missing from the store, and { path: 'work/a.txt', oldString: 'alpha', newString: 'ALPHA' }, { path: 'work/b.txt', oldString: 'beta', newString: 'BETA' }, ], - })), 'The edit_many call stored none of its edits: 1 problem.\n\nedit 2: Blob not found for: work/b.txt'); + })), "The edit_many call stored none of its edits: 1 problem.\n\nedit 2: The workspace's copy of work/b.txt is missing from the store, and disk's copy holds other contents, so it can't be restored, and edit 2 changed nothing. Sync the path to keep disk's copy and give up the workspace's, or write the file again."); } finally { delete s.getBlob; } diff --git a/test/workspace-lost-contents.test.ts b/test/workspace-lost-contents.test.ts new file mode 100644 index 000000000..75cd4555c --- /dev/null +++ b/test/workspace-lost-contents.test.ts @@ -0,0 +1,602 @@ +import { describe, test, type TestContext } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { JsStore } from '@animalabs/chronicle'; +import { WorkspaceModule } from '../src/modules/workspace/index.js'; +import type { MountConfig } from '../src/modules/workspace/types.js'; +import type { ModuleContext } from '../src/types/module.js'; +import type { ToolResult } from '../src/types/events.js'; + +const TREE = 'workspace/work/tree'; +const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', 'base64'); +const GIF = Buffer.from('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', 'base64'); +const sha = (bytes: string | Buffer): string => createHash('sha256').update(bytes).digest('hex'); + +type Alert = { kind: string; agent: string; message: string; data?: Record }; + +class Env { + readonly root = mkdtempSync(join(tmpdir(), 'af-ws-lost-')); + readonly dir = join(this.root, 'work'); + readonly outside = join(this.root, 'outside'); + readonly storePath = join(this.root, 'ws.chronicle'); + store: JsStore; + alerts: Alert[] = []; + private live: WorkspaceModule[] = []; + + constructor(t: TestContext) { + mkdirSync(this.dir, { recursive: true }); + mkdirSync(this.outside, { recursive: true }); + this.store = JsStore.openOrCreate({ path: this.storePath }); + t.after(async () => { + for (const m of this.live) await m.stop().catch(() => {}); + if (!this.store.isClosed()) this.store.close(); + rmSync(this.root, { recursive: true, force: true }); + }); + } + + async open(mount: Partial = {}): Promise { + const m = new WorkspaceModule({ mounts: [{ name: 'work', path: this.dir, mode: 'read-write', watch: 'never', ...mount }] }); + m.initStore(this.store); + await m.start({ + isRestart: false, + getState: () => null, + setState: () => {}, + pushEvent: () => {}, + notifyOps: (kind: string, agent: string, message: string, data?: Record) => this.alerts.push({ kind, agent, message, data }), + } as unknown as ModuleContext); + this.live.push(m); + return m; + } + + async restartAfter(m: WorkspaceModule, change: (blobFile: (hash: string) => string) => void, mount: Partial = {}): Promise { + await m.stop(); + this.live = this.live.filter((x) => x !== m); + this.store.close(); + change((hash) => join(this.storePath, 'blobs', hash.slice(0, 2), hash)); + this.store = JsStore.openOrCreate({ path: this.storePath }); + return this.open(mount); + } + + lose(m: WorkspaceModule, ...hashes: string[]): Promise { + return this.restartAfter(m, (blobFile) => { + for (const hash of hashes) rmSync(blobFile(hash)); + }); + } + + damage(m: WorkspaceModule, ...hashes: string[]): Promise { + return this.restartAfter(m, (blobFile) => { + for (const hash of hashes) writeFileSync(blobFile(hash), readFileSync(blobFile(hash)).subarray(0, -6)); + }); + } + + hashOf(path: string): string { + return this.store.treeGet(TREE, path)!.blobHash; + } + + storedText(hash: string): string | null { + return this.store.getBlob(hash)?.toString('utf8') ?? null; + } + + writeDisk(path: string, content: string | Buffer): void { + mkdirSync(dirname(join(this.dir, path)), { recursive: true }); + writeFileSync(join(this.dir, path), content); + } + + readDisk(path: string): string { + return readFileSync(join(this.dir, path), 'utf8'); + } + + kinds(): string[] { + return this.alerts.map((a) => a.kind); + } +} + +async function call(m: WorkspaceModule, name: string, input: Record): Promise { + return m.handleToolCall({ id: 't', name, input }); +} + +async function ok(m: WorkspaceModule, name: string, input: Record): Promise { + const result = await call(m, name, input); + assert.equal(result.success, true, `${name} ${JSON.stringify(input)} failed: ${result.error}`); + return result.structured ?? result.data; +} + +async function refused(m: WorkspaceModule, name: string, input: Record): Promise { + const result = await call(m, name, input); + assert.equal(result.success, false, `${name} ${JSON.stringify(input)} unexpectedly succeeded`); + return String(result.error); +} + +async function textOf(m: WorkspaceModule, path: string): Promise { + return (await ok(m, 'read', { path, offsetChars: 0, limitChars: 1_000_000 })).content as string; +} + +async function synced(env: Env, m: WorkspaceModule, path: string, content: string): Promise { + await ok(m, 'write', { path: `work/${path}`, content }); + await ok(m, 'materialize', { path: `work/${path}` }); + assert.equal(env.readDisk(path), content); + return env.hashOf(path); +} + +async function draft(env: Env, m: WorkspaceModule, path: string, disk: string, content: string): Promise { + await synced(env, m, path, disk); + await ok(m, 'write', { path: `work/${path}`, content }); + assert.equal(env.readDisk(path), disk); + return env.hashOf(path); +} + +describe('contents the store lost, where disk holds the same bytes', () => { + test('read answers with them, the store holds them again, and whoever runs the host is told once', async (t) => { + const env = new Env(t); + let m = await env.open(); + const hash = await synced(env, m, 'a.txt', 'alpha\nneedle\n'); + m = await env.lose(m, hash); + assert.equal(env.store.getBlob(hash), null); + + assert.equal(await textOf(m, 'work/a.txt'), 'alpha\nneedle\n'); + assert.equal(env.storedText(hash), 'alpha\nneedle\n'); + assert.deepEqual(env.alerts.map(({ kind, agent, data }) => ({ kind, agent, data })), [ + { kind: 'workspace-contents-restored', agent: 'workspace', data: { mount: 'work', path: 'work/a.txt', blobHash: hash } }, + ]); + assert.match(env.alerts[0]!.message, /work\/a\.txt.*missing.*stored again from disk's copy/); + + assert.equal(await textOf(m, 'work/a.txt'), 'alpha\nneedle\n'); + assert.equal(env.alerts.length, 1, 'contents back in the store need no restore'); + }); + + test('edit and edit_many make their edits to them', async (t) => { + const env = new Env(t); + let m = await env.open(); + const a = await synced(env, m, 'a.txt', 'alpha\n'); + const b = await synced(env, m, 'b.txt', 'beta\n'); + const c = await synced(env, m, 'c.txt', 'gamma\n'); + m = await env.lose(m, a, b, c); + + await ok(m, 'edit', { path: 'work/a.txt', oldString: 'alpha', newString: 'ALPHA' }); + await ok(m, 'edit_many', { + edits: [ + { path: 'work/b.txt', oldString: 'beta', newString: 'BETA' }, + { path: 'work/c.txt', oldString: 'gamma', newString: 'GAMMA' }, + ], + }); + assert.equal(await textOf(m, 'work/a.txt'), 'ALPHA\n'); + assert.equal(await textOf(m, 'work/b.txt'), 'BETA\n'); + assert.equal(await textOf(m, 'work/c.txt'), 'GAMMA\n'); + await ok(m, 'materialize', { path: 'work' }); + assert.deepEqual(['a.txt', 'b.txt', 'c.txt'].map((p) => env.readDisk(p)), ['ALPHA\n', 'BETA\n', 'GAMMA\n']); + assert.deepEqual(env.kinds(), ['workspace-contents-restored', 'workspace-contents-restored', 'workspace-contents-restored']); + }); + + test('grep searches them, in path order among the files around them', async (t) => { + const env = new Env(t); + let m = await env.open(); + await synced(env, m, 'a.txt', 'needle a\n'); + const b = await synced(env, m, 'b.txt', 'needle b\n'); + await synced(env, m, 'c.txt', 'needle c\n'); + m = await env.lose(m, b); + + const found = await ok(m, 'grep', { pattern: 'needle' }); + assert.deepEqual(found.results.map((r: { file: string }) => r.file), ['work/a.txt', 'work/b.txt', 'work/c.txt']); + assert.equal(found.skipped, undefined); + assert.equal(env.storedText(b), 'needle b\n'); + }); + + test('read_image and readBinary answer with them', async (t) => { + const env = new Env(t); + let m = await env.open(); + assert.equal((await m.writeBinary('work/p.png', PNG, 'image/png')).success, true); + await ok(m, 'materialize', { path: 'work/p.png' }); + const png = env.hashOf('p.png'); + const text = await synced(env, m, 'a.txt', 'alpha\n'); + m = await env.lose(m, png, text); + + const image = await ok(m, 'read_image', { path: 'work/p.png' }); + assert.deepEqual(Buffer.from(image.find((b: { type: string }) => b.type === 'image').data, 'base64'), PNG); + assert.equal(image.find((b: { type: string }) => b.type === 'text').text, `Path: work/p.png\nMIME: image/png\nBytes: ${PNG.byteLength}`); + const read = await m.readBinary('work/a.txt'); + assert.ok('data' in read, JSON.stringify(read)); + assert.equal(read.data.toString(), 'alpha\n'); + assert.deepEqual(env.store.getBlob(png), PNG); + }); + + test('contents the store took though its write then failed are told as restored', async (t) => { + const env = new Env(t); + let m = await env.open(); + const hash = await synced(env, m, 'a.txt', 'alpha\n'); + m = await env.lose(m, hash); + const store = env.store as unknown as { storeBlob: (content: Buffer, type: string) => string }; + const storeBlob = store.storeBlob.bind(env.store); + store.storeBlob = (content, type) => { + storeBlob(content, type); + throw new Error('fsync failed after the write'); + }; + + assert.equal(await textOf(m, 'work/a.txt'), 'alpha\n'); + assert.equal(env.storedText(hash), 'alpha\n'); + assert.deepEqual(env.kinds(), ['workspace-contents-restored']); + }); + + test('a store write that fails is told, and the read answers from disk', async (t) => { + const env = new Env(t); + let m = await env.open(); + const hash = await synced(env, m, 'a.txt', 'alpha\n'); + m = await env.lose(m, hash); + const store = env.store as unknown as { storeBlob: (content: Buffer, type: string) => string }; + store.storeBlob = () => { + throw new Error('No space left on device'); + }; + + assert.equal(await textOf(m, 'work/a.txt'), 'alpha\n'); + assert.equal(env.store.getBlob(hash), null); + assert.deepEqual(env.kinds(), ['workspace-contents-unstored']); + assert.equal(env.alerts[0]!.data!.unstored, 'No space left on device'); + }); + + test("a copy disk holds only through a link the mount doesn't follow isn't taken", async (t) => { + const env = new Env(t); + let m = await env.open(); + const hash = await draft(env, m, 'a.txt', 'v1\n', 'alpha\n'); + m = await env.lose(m, hash); + writeFileSync(join(env.outside, 'a.txt'), 'alpha\n'); + rmSync(join(env.dir, 'a.txt')); + symlinkSync(join(env.outside, 'a.txt'), join(env.dir, 'a.txt')); + + const error = await refused(m, 'read', { path: 'work/a.txt' }); + assert.equal(error, "The workspace's copy of work/a.txt is missing from the store, and disk's copy can't be read (Symlinks are not allowed: work/a.txt), so it can't be restored or read. Write the file again, or delete it."); + assert.equal(env.store.getBlob(hash), null); + assert.deepEqual(env.kinds(), ['workspace-contents-lost']); + + await ok(m, 'write', { path: 'work/a.txt', content: 'alpha again\n' }); + assert.equal(await textOf(m, 'work/a.txt'), 'alpha again\n'); + }); + + test("on a read-only mount, the answer asks for a change on disk rather than a write, and once disk's copy can be read a read restores it", async (t) => { + const env = new Env(t); + let m = await env.open({ mode: 'read-only' }); + env.writeDisk('a.txt', 'alpha\n'); + await ok(m, 'ls', { path: 'work' }); + const hash = env.hashOf('a.txt'); + m = await env.restartAfter(m, (blobFile) => rmSync(blobFile(hash)), { mode: 'read-only' }); + writeFileSync(join(env.outside, 'a.txt'), 'alpha\n'); + rmSync(join(env.dir, 'a.txt')); + symlinkSync(join(env.outside, 'a.txt'), join(env.dir, 'a.txt')); + + assert.equal(await refused(m, 'read', { path: 'work/a.txt' }), + "The workspace's copy of work/a.txt is missing from the store, and disk's copy can't be read (Symlinks are not allowed: work/a.txt), so it can't be restored or read. " + + "Only a change on disk can end this: once disk's copy can be read, read the file again, which restores it from a copy with the same contents, or sync the path to keep disk's copy and give up the workspace's."); + rmSync(join(env.dir, 'a.txt')); + env.writeDisk('a.txt', 'alpha\n'); + assert.equal(await textOf(m, 'work/a.txt'), 'alpha\n'); + assert.equal(env.storedText(hash), 'alpha\n'); + assert.deepEqual(env.kinds(), ['workspace-contents-lost', 'workspace-contents-restored']); + }); + + test('grep searches contents two files share once either restores them, whichever comes first', async (t) => { + const env = new Env(t); + let m = await env.open(); + const hash = await draft(env, m, 'a.txt', 'other\n', 'needle\n'); + assert.equal(await synced(env, m, 'b.txt', 'needle\n'), hash); + m = await env.lose(m, hash); + + const found = await ok(m, 'grep', { pattern: 'needle' }); + assert.deepEqual(found.results.map((r: { file: string }) => r.file), ['work/a.txt', 'work/b.txt']); + assert.equal(found.skipped, undefined); + assert.deepEqual(env.alerts.map(({ kind, data }) => [kind, data?.path]), [ + ['workspace-contents-lost', 'work/a.txt'], + ['workspace-contents-restored', 'work/b.txt'], + ]); + }); + + test("edit_many that restores one file still says what it took in from disk for another", async (t) => { + const env = new Env(t); + let m = await env.open(); + const a = await synced(env, m, 'a.txt', 'alpha\n'); + await synced(env, m, 'b.txt', 'beta\n'); + m = await env.lose(m, a); + env.writeDisk('b.txt', 'beta, from disk\n'); + + const result = await call(m, 'edit_many', { + edits: [ + { path: 'work/a.txt', oldString: 'alpha', newString: 'ALPHA' }, + { path: 'work/b.txt', oldString: 'beta', newString: 'BETA' }, + ], + }); + assert.equal(result.success, true, result.error); + assert.match(JSON.stringify(result.data), /Disk's copy of work\/b\.txt had changed since the workspace last agreed with it, so it was taken in first, and edit 2 applied to that text\./); + assert.equal(await textOf(m, 'work/a.txt'), 'ALPHA\n'); + assert.equal(await textOf(m, 'work/b.txt'), 'BETA, from disk\n'); + }); + + test('two reads restoring the same contents at once both answer, and whoever runs the host is told once', async (t) => { + const env = new Env(t); + let m = await env.open(); + assert.equal((await m.writeBinary('work/p.png', PNG, 'image/png')).success, true); + await ok(m, 'materialize', { path: 'work/p.png' }); + m = await env.lose(m, env.hashOf('p.png')); + + const images = await Promise.all([ok(m, 'read_image', { path: 'work/p.png' }), ok(m, 'read_image', { path: 'work/p.png' })]); + for (const image of images) assert.deepEqual(Buffer.from(image.find((b: { type: string }) => b.type === 'image').data, 'base64'), PNG); + assert.deepEqual(env.kinds(), ['workspace-contents-restored']); + }); +}); + +describe('contents the store lost, where disk holds no copy of them', () => { + test('a lost draft: read says disk holds other contents, nothing is stored for them, and a sync takes disk\'s copy in their place', async (t) => { + const env = new Env(t); + let m = await env.open(); + const hash = await draft(env, m, 'a.txt', 'v1\n', 'v2, a draft\n'); + m = await env.lose(m, hash); + + assert.equal(await refused(m, 'read', { path: 'work/a.txt' }), + "The workspace's copy of work/a.txt is missing from the store, and disk's copy holds other contents, so it can't be restored or read. Sync the path to keep disk's copy and give up the workspace's, or write the file again."); + assert.equal(env.store.getBlob(hash), null); + assert.equal(env.store.getBlob(sha('v1\n'))?.toString(), 'v1\n', "disk's own text is the version it was synced at"); + assert.deepEqual(env.alerts.map(({ kind, data }) => ({ kind, data })), [ + { kind: 'workspace-contents-lost', data: { mount: 'work', path: 'work/a.txt', blobHash: hash } }, + ]); + + await ok(m, 'sync', { path: 'work/a.txt' }); + assert.equal(await textOf(m, 'work/a.txt'), 'v1\n'); + }); + + test('a lost draft: edit refuses it, and writing the file again replaces it', async (t) => { + const env = new Env(t); + let m = await env.open(); + const hash = await draft(env, m, 'a.txt', 'v1\n', 'v2, a draft\n'); + m = await env.lose(m, hash); + + assert.equal(await refused(m, 'edit', { path: 'work/a.txt', oldString: 'draft', newString: 'DRAFT' }), + "The workspace's copy of work/a.txt is missing from the store, and disk's copy holds other contents, so it can't be restored, and the edit changed nothing. Sync the path to keep disk's copy and give up the workspace's, or write the file again."); + await ok(m, 'write', { path: 'work/a.txt', content: 'v3\n' }); + assert.equal(await textOf(m, 'work/a.txt'), 'v3\n'); + }); + + test('a conflict whose workspace copy is lost: a sync takes the copy disk holds now', async (t) => { + const env = new Env(t); + let m = await env.open(); + const hash = await draft(env, m, 'a.txt', 'v1\n', 'v2, the workspace\n'); + env.writeDisk('a.txt', 'v3, disk\n'); + m = await env.lose(m, hash); + + assert.equal(await refused(m, 'read', { path: 'work/a.txt' }), + "The workspace's copy of work/a.txt is missing from the store, and disk's copy holds other contents, so it can't be restored or read. Sync the path to keep disk's copy and give up the workspace's, or write the file again."); + assert.equal(await refused(m, 'edit', { path: 'work/a.txt', oldString: 'v2', newString: 'V2' }), + "The workspace's copy of work/a.txt is missing from the store, and disk's copy holds other contents, so it can't be restored, and the edit changed nothing. Sync the path to keep disk's copy and give up the workspace's, or write the file again."); + await ok(m, 'sync', { path: 'work/a.txt' }); + assert.equal(await textOf(m, 'work/a.txt'), 'v3, disk\n'); + }); + + test('disk has no copy: read says so, and deleting the file or writing it again settles it', async (t) => { + const env = new Env(t); + let m = await env.open(); + const a = await draft(env, m, 'a.txt', 'v1\n', 'a draft\n'); + const b = await draft(env, m, 'b.txt', 'v1\n', 'b draft\n'); + rmSync(join(env.dir, 'a.txt')); + rmSync(join(env.dir, 'b.txt')); + m = await env.lose(m, a, b); + + for (const path of ['work/a.txt', 'work/b.txt']) { + assert.equal(await refused(m, 'read', { path }), + `The workspace's copy of ${path} is missing from the store, and disk has no copy, so it can't be restored or read. Write the file again, or delete it.`); + } + await ok(m, 'delete', { path: 'work/a.txt' }); + assert.match(await refused(m, 'read', { path: 'work/a.txt' }), /^File not found/); + await ok(m, 'write', { path: 'work/b.txt', content: 'b again\n' }); + assert.equal(await textOf(m, 'work/b.txt'), 'b again\n'); + }); + + test('edit_many refuses a file whose contents are lost, and stores none of its edits', async (t) => { + const env = new Env(t); + let m = await env.open(); + await synced(env, m, 'a.txt', 'alpha\n'); + const b = await draft(env, m, 'b.txt', 'beta\n', 'beta, a draft\n'); + m = await env.lose(m, b); + + assert.equal(await refused(m, 'edit_many', { + edits: [ + { path: 'work/a.txt', oldString: 'alpha', newString: 'ALPHA' }, + { path: 'work/b.txt', oldString: 'beta', newString: 'BETA' }, + ], + }), "The edit_many call stored none of its edits: 1 problem.\n\nedit 2: The workspace's copy of work/b.txt is missing from the store, and disk's copy holds other contents, so it can't be restored, and edit 2 changed nothing. Sync the path to keep disk's copy and give up the workspace's, or write the file again."); + assert.equal(await textOf(m, 'work/a.txt'), 'alpha\n'); + assert.equal(await refused(m, 'edit', { path: 'work/b.txt', oldString: 'beta', newString: 'BETA' }), + "The workspace's copy of work/b.txt is missing from the store, and disk's copy holds other contents, so it can't be restored, and the edit changed nothing. Sync the path to keep disk's copy and give up the workspace's, or write the file again."); + }); + + test('grep lists it as not searched, saying why', async (t) => { + const env = new Env(t); + let m = await env.open(); + const hash = await draft(env, m, 'a.txt', 'v1\n', 'needle in a draft\n'); + m = await env.lose(m, hash); + + const found = await ok(m, 'grep', { pattern: 'needle' }); + assert.deepEqual(found.results, []); + assert.deepEqual(found.skipped, [ + { file: 'work/a.txt', reason: "the workspace's copy is missing from the store, and disk's copy holds other contents, so it was not searched" }, + ]); + }); + + test("read_image serves disk's other image saying whose it is, and with none on disk says the copy is lost", async (t) => { + const env = new Env(t); + let m = await env.open(); + assert.equal((await m.writeBinary('work/p.png', PNG, 'image/png')).success, true); + await ok(m, 'materialize', { path: 'work/p.png' }); + assert.equal((await m.writeBinary('work/q.png', PNG, 'image/png')).success, true); + await ok(m, 'materialize', { path: 'work/q.png' }); + m = await env.lose(m, env.hashOf('p.png')); + env.writeDisk('p.png', GIF); + rmSync(join(env.dir, 'q.png')); + + const image = await ok(m, 'read_image', { path: 'work/p.png' }); + assert.deepEqual(Buffer.from(image.find((b: { type: string }) => b.type === 'image').data, 'base64'), GIF); + assert.equal(image.find((b: { type: string }) => b.type === 'text').text, + `Path: work/p.png\nMIME: image/gif\nBytes: ${GIF.byteLength}\nThe workspace's copy of work/p.png is missing from the store, so this is disk's copy, which holds other contents.`); + assert.equal(await refused(m, 'read_image', { path: 'work/q.png' }), + "The workspace's copy of work/q.png is missing from the store, and disk has no copy, so it can't be restored or read. Write the file again, or delete it."); + }); + test("edit_many tells each file's own state when files share contents the store lost", async (t) => { + const env = new Env(t); + let m = await env.open(); + const hash = await draft(env, m, 'a.txt', 'v1\n', 'the same draft\n'); + assert.equal(await draft(env, m, 'b.txt', 'v1\n', 'the same draft\n'), hash); + rmSync(join(env.dir, 'b.txt')); + m = await env.lose(m, hash); + + assert.equal(await refused(m, 'edit_many', { + edits: [ + { path: 'work/a.txt', oldString: 'same', newString: 'SAME' }, + { path: 'work/b.txt', oldString: 'same', newString: 'SAME' }, + ], + }), 'The edit_many call stored none of its edits: 2 problems.\n\n' + + "edit 1: The workspace's copy of work/a.txt is missing from the store, and disk's copy holds other contents, so it can't be restored, and edit 1 changed nothing. Sync the path to keep disk's copy and give up the workspace's, or write the file again.\n\n" + + "edit 2: The workspace's copy of work/b.txt is missing from the store, and disk has no copy, so it can't be restored, and edit 2 changed nothing. Write the file again, or delete it."); + assert.deepEqual(env.alerts.map(({ kind, data }) => [kind, data?.path]), [ + ['workspace-contents-lost', 'work/a.txt'], + ['workspace-contents-lost', 'work/b.txt'], + ]); + }); + + test("read_image says the workspace's copy is lost where disk's copy isn't an image it can show", async (t) => { + const env = new Env(t); + let m = await env.open(); + assert.equal((await m.writeBinary('work/p.png', PNG, 'image/png')).success, true); + await ok(m, 'materialize', { path: 'work/p.png' }); + m = await env.lose(m, env.hashOf('p.png')); + env.writeDisk('p.png', 'not an image\n'); + + assert.equal(await refused(m, 'read_image', { path: 'work/p.png' }), + "The workspace's copy of work/p.png is missing from the store, and disk's copy holds other contents, so it can't be restored, and disk's copy can't be shown (Unsupported image format: work/p.png). Sync the path to keep disk's copy and give up the workspace's, or write the file again."); + }); + + test("on a mount configured direct that runs as 'chronicle', the answer names the sync it takes, not a write", async (t) => { + const env = new Env(t); + let m = await env.open({ backend: 'chronicle' }); + await ok(m, 'write', { path: 'work/drafted.txt', content: 'a draft\n' }); + const hash = env.hashOf('drafted.txt'); + m = await env.restartAfter(m, (blobFile) => rmSync(blobFile(hash)), { backend: 'direct', mode: undefined }); + assert.match(await refused(m, 'write', { path: 'work/other.txt', content: 'x\n' }), /is configured direct, and runs as 'chronicle' this session/); + + assert.equal(await refused(m, 'read', { path: 'work/drafted.txt' }), + "The workspace's copy of work/drafted.txt is missing from the store, and disk has no copy, so it can't be restored or read. Sync the path to take disk's deletion, which removes the workspace's copy."); + await ok(m, 'sync', { path: 'work/drafted.txt' }); + assert.match(await refused(m, 'read', { path: 'work/drafted.txt' }), /^File not found/); + }); + + test("whoever runs the host is told that putting back the store's blob file brings the contents back, and it does", async (t) => { + const env = new Env(t); + let m = await env.open(); + const hash = await draft(env, m, 'a.txt', 'v1\n', 'v2, a draft\n'); + const blob = join(env.storePath, 'blobs', hash.slice(0, 2), hash); + const saved = readFileSync(blob); + m = await env.lose(m, hash); + + await refused(m, 'read', { path: 'work/a.txt' }); + assert.deepEqual(env.kinds(), ['workspace-contents-lost']); + assert.match(env.alerts[0]!.message, new RegExp(`\\(blob ${hash}\\)`)); + assert.match(env.alerts[0]!.message, /Putting back the store's blob file for that hash, from a copy of the store, brings them back\.$/); + writeFileSync(blob, saved); + assert.equal(await textOf(m, 'work/a.txt'), 'v2, a draft\n'); + }); +}); + +describe("contents whose blob file the store can't read", () => { + test("a read is answered from disk's matching copy, whoever runs the host is told the store kept the bad file, and grep searches the rest", async (t) => { + const env = new Env(t); + let m = await env.open(); + const a = await synced(env, m, 'a.txt', 'needle a\n'); + await synced(env, m, 'b.txt', 'needle b\n'); + m = await env.damage(m, a); + assert.throws(() => env.store.getBlob(a)); + + const found = await ok(m, 'grep', { pattern: 'needle' }); + assert.deepEqual(found.results.map((r: { file: string }) => r.file), ['work/a.txt', 'work/b.txt']); + assert.equal(await textOf(m, 'work/a.txt'), 'needle a\n'); + assert.deepEqual(env.kinds(), ['workspace-contents-unstored', 'workspace-contents-unstored']); + assert.equal(typeof env.alerts[0]!.data!.storeError, 'string'); + assert.match(env.alerts[0]!.message, /unreadable in the workspace's store .*reads are answered from it, but storing it again didn't take: the store still can't read its file for them/); + assert.equal(env.alerts[0]!.data!.blobHash, a); + }); + + test("with other contents on disk, read says so, and a sync takes disk's copy in their place", async (t) => { + const env = new Env(t); + let m = await env.open(); + const hash = await draft(env, m, 'a.txt', 'v1\n', 'v2, a draft\n'); + m = await env.damage(m, hash); + + assert.match(await refused(m, 'read', { path: 'work/a.txt' }), + /^The workspace's copy of work\/a\.txt is unreadable in the store \(.+\), and disk's copy holds other contents, so it can't be restored or read\. Sync the path to keep disk's copy and give up the workspace's\.$/); + await ok(m, 'sync', { path: 'work/a.txt' }); + assert.equal(await textOf(m, 'work/a.txt'), 'v1\n'); + }); + + test('with no copy on disk, deleting the file clears it', async (t) => { + const env = new Env(t); + let m = await env.open(); + const hash = await draft(env, m, 'a.txt', 'v1\n', 'v2, a draft\n'); + rmSync(join(env.dir, 'a.txt')); + m = await env.damage(m, hash); + + assert.match(await refused(m, 'read', { path: 'work/a.txt' }), + /^The workspace's copy of work\/a\.txt is unreadable in the store \(.+\), and disk has no copy, so it can't be restored or read\. Delete the file to clear it\.$/); + await ok(m, 'delete', { path: 'work/a.txt' }); + assert.match(await refused(m, 'read', { path: 'work/a.txt' }), /^File not found/); + }); + test('edit and edit_many plan from disk\'s matching copy and store their change', async (t) => { + const env = new Env(t); + let m = await env.open(); + const a = await synced(env, m, 'a.txt', 'alpha\n'); + const b = await synced(env, m, 'b.txt', 'beta\n'); + m = await env.damage(m, a, b); + + await ok(m, 'edit', { path: 'work/a.txt', oldString: 'alpha', newString: 'ALPHA' }); + await ok(m, 'edit_many', { edits: [{ path: 'work/b.txt', oldString: 'beta', newString: 'BETA' }] }); + await ok(m, 'materialize', { path: 'work' }); + assert.deepEqual([env.readDisk('a.txt'), env.readDisk('b.txt')], ['ALPHA\n', 'BETA\n']); + assert.deepEqual(env.kinds(), ['workspace-contents-unstored', 'workspace-contents-unstored']); + }); + + test('whoever runs the host is told that removing the unreadable blob file lets a read store the contents again, and it does', async (t) => { + const env = new Env(t); + let m = await env.open(); + const hash = await synced(env, m, 'a.txt', 'alpha\n'); + m = await env.damage(m, hash); + + assert.equal(await textOf(m, 'work/a.txt'), 'alpha\n'); + assert.match(env.alerts[0]!.message, /Chronicle keeps a blob file it can't read in place, so where the file is damaged, removing it lets the next read store them\.$/); + rmSync(join(env.storePath, 'blobs', hash.slice(0, 2), hash)); + assert.equal(await textOf(m, 'work/a.txt'), 'alpha\n'); + assert.equal(env.storedText(hash), 'alpha\n'); + assert.deepEqual(env.kinds(), ['workspace-contents-unstored', 'workspace-contents-restored']); + }); + + test('materialize lists a draft whose blob it can\'t read as skipped, saying why, and writes the rest', async (t) => { + const env = new Env(t); + let m = await env.open(); + const a = await draft(env, m, 'a.txt', 'v1\n', 'a draft\n'); + await draft(env, m, 'b.txt', 'v1\n', 'b draft\n'); + m = await env.damage(m, a); + + const done = await ok(m, 'materialize', { path: 'work' }); + assert.deepEqual(done.materialized, [{ mount: 'work', path: 'b.txt' }]); + assert.equal(done.skipped.length, 1); + assert.match(done.skipped[0].reason, /^a\.txt: the workspace copy is unreadable in the store \(.+\)$/); + assert.deepEqual([env.readDisk('a.txt'), env.readDisk('b.txt')], ['v1\n', 'b draft\n']); + }); + + test("a branch intent whose blob the store can't read leaves its path unverified, saying why, rather than failing the listing", async (t) => { + const env = new Env(t); + let m = await env.open(); + await synced(env, m, 'a.txt', 'alpha\n'); + await ok(m, 'delete', { path: 'work/a.txt' }); + const intent = env.store.treeGet('workspace/work/intent', 'a.txt'); + assert.ok(intent, 'the deletion is recorded as an intent'); + m = await env.damage(m, intent.blobHash); + + const listed = (await ok(m, 'ls', { path: 'work' })).entries.find((e: { name: string }) => e.name === 'a.txt'); + assert.equal(listed?.state, 'unverified'); + assert.match(String(listed?.note), /its branch intent can't be read: its record's blob can't be read from the store \(.+\)/); + }); +}); diff --git a/test/workspace-three-way.test.ts b/test/workspace-three-way.test.ts index 276fb77ec..92ebbbac4 100644 --- a/test/workspace-three-way.test.ts +++ b/test/workspace-three-way.test.ts @@ -7048,24 +7048,40 @@ describe('grep, when the branch or the store changes under it', () => { assert.match(JSON.stringify(data.incomplete), /the selected branch kept changing while grep ran/); }); - test("a version missing from the store is listed as not searched, not taken for no match", async (t) => { + async function conflictWithBothVersionsLost(t: TestContext): Promise<{ env: Env; m: WorkspaceModule }> { const env = new Env(t); - const m = await env.open(); + let m = await env.open(); await seedSynced(env, m, 'conflict.txt', 'nothing'); env.writeDisk('conflict.txt', 'needle on disk'); await call(m, 'write', { path: 'work/conflict.txt', content: 'needle in the workspace' }); const listed = (await call(m, 'ls', { path: 'work' })).entries.find((e: { name: string }) => e.name === 'conflict.txt'); assert.equal(listed?.conflict?.diskCopy, 'stored'); - // Both stored versions go missing. - const store = env.store as unknown as { getBlob: (hash: string) => Buffer | null }; - const getBlob = store.getBlob.bind(env.store); - const gone = new Set([sha('needle on disk'), sha('needle in the workspace')]); - store.getBlob = (hash: string) => (gone.has(hash) ? null : getBlob(hash)); + await env.shutdown(m); + for (const text of ['needle on disk', 'needle in the workspace']) rmSync(join(env.storePath, 'blobs', sha(text).slice(0, 2), sha(text))); + env.store = JsStore.openOrCreate({ path: env.storePath }); + m = await env.open(); + return { env, m }; + } + + test("a conflict's disk version missing from the store is searched from disk while disk still holds it", async (t) => { + const { m } = await conflictWithBothVersionsLost(t); + const data = await call(m, 'grep', { pattern: 'needle' }); + assert.deepEqual(data.results.map((r: { file: string; version: string; matches: Array<{ text: string }> }) => [r.file, r.version, r.matches.map((x) => x.text)]), + [['work/conflict.txt', 'conflicting-disk', ['needle on disk']]]); + assert.deepEqual(data.skipped, [ + { file: 'work/conflict.txt', reason: "the workspace's copy is missing from the store, and disk's copy holds other contents, so it was not searched" }, + ]); + }); + + test("a version missing from the store that disk can't restore is listed as not searched, not taken for no match", async (t) => { + const { env, m } = await conflictWithBothVersionsLost(t); + env.rmDisk('conflict.txt'); const data = await call(m, 'grep', { pattern: 'needle' }); assert.deepEqual(data.results, []); - const reasons = (data.skipped ?? []).map((x: { reason: string }) => x.reason).join('\n'); - assert.match(reasons, /the workspace copy is missing from the store/); - assert.match(reasons, /the disk version recorded with the conflict is missing from the store/); + assert.deepEqual((data.skipped ?? []).map((x: { reason: string }) => x.reason), [ + "the workspace's copy is missing from the store, and disk has no copy, so it was not searched", + 'conflict: the disk version recorded with the conflict is missing from the store, and disk has no copy, so it was not searched', + ]); }); }); From 99b43d84d153538adaa780218ee285f3da951379 Mon Sep 17 00:00:00 2001 From: Pippa-2571 Date: Mon, 26 Oct 2026 03:07:09 -0400 Subject: [PATCH 2/3] fix(workspace): tell a loss only when no file restores it, and no copy only where sync proves absence - edit, edit_many and grep restore lost versions by hash (restoreEach): each hash from the first of its files whose disk copy has it, every file holding it taking those bytes. workspace-contents-lost goes out, once per file, only when none of them restored it, so a file whose own disk copy differs no longer reports a loss another file then restores, and a hash the store won't keep is read and alerted once. - grep searches each restored version as soon as its bytes are in hand and keeps only the answer, rather than holding every restored file's bytes until all restores end. - A disk read that finds nothing counts as no copy only where confirmAbsent, the proof sync uses before taking a deletion, holds. A path beneath a link that leads nowhere is a copy disk can't read, with the proof's reason, so its remedy is no longer a sync that sync can't take. - The read-only remedy no longer says only a change on disk can end the loss (sync with acceptRoot, or putting back the blob file, also can), and no longer says a read restores what a damaged blob file blocks: it says a read reads it from disk's copy if that copy has the same contents. - Each alert's message opens with the blob's hash, so the webhook's 500-character bound can't cut it; a loss reports the store's state as last read; read_image gives disk's reason once; BranchIntent.unreadable's doc no longer says a put of the same contents replaces a damaged record. Co-Authored-By: Claude Opus 5.5 --- src/modules/workspace/branch-intent.ts | 3 +- src/modules/workspace/index.ts | 136 +++++++++++++------- src/modules/workspace/observe.ts | 2 +- test/workspace-lost-contents.test.ts | 169 +++++++++++++++++++++++-- 4 files changed, 246 insertions(+), 64 deletions(-) diff --git a/src/modules/workspace/branch-intent.ts b/src/modules/workspace/branch-intent.ts index e9afe5aab..ed7c35465 100644 --- a/src/modules/workspace/branch-intent.ts +++ b/src/modules/workspace/branch-intent.ts @@ -60,7 +60,8 @@ export interface BranchIntent { * Why a recorded intent can't be read (its blob missing from the store, * a blob chronicle can't read, or not an intent): a tombstone or conflict * it held is unknown, never absent. - * Never written; a put replaces the damaged record. + * Never written; a put replaces the damaged record, unless it puts the same + * contents over a blob file chronicle can't read, which chronicle keeps. */ unreadable?: string; } diff --git a/src/modules/workspace/index.ts b/src/modules/workspace/index.ts index 2bb28c4a0..c0001147c 100644 --- a/src/modules/workspace/index.ts +++ b/src/modules/workspace/index.ts @@ -40,7 +40,7 @@ import { MountWatcher, type FsChange } from './watcher.js'; import { hashContent, storedBytes, DEFAULT_MAX_FILE_SIZE, type StoredBytes } from './sync.js'; import { DiskAgreement, sameRootIdentity } from './disk-agreement.js'; import { type BranchIntent, BranchIntents } from './branch-intent.js'; -import { IGNORED, NONBLOCK, OTHER_MOUNT, OUTSIDE_INCLUDE, ROOT_REPLACED, ROOT_UNAVAILABLE, beginsWithDir, checkDir, checkRoot, coversLanding, diskSpelling, filesystemTrustsCtime, foldsCase, includePatternReadable, inOtherMount, insideMount, leadsInto, linkOnTheWay, linkRefusal, linkWords, locateRoot, looksBinary, namesHolder, namesNestedPlace, nestedAtReal, nestedHolder, observePath, parsedSpelling, passedOverAt, pathSegments, readBytes, readPrefix, shownLeads, sniffImageMime, walkScope, withinIncludes, type CheckedDir, type LinkFound, type MountView, type NestedMount, type WorkspaceEntries } from './observe.js'; +import { CHANGED, IGNORED, NONBLOCK, OTHER_MOUNT, OUTSIDE_INCLUDE, ROOT_REPLACED, ROOT_UNAVAILABLE, beginsWithDir, checkDir, checkRoot, confirmAbsent, coversLanding, diskSpelling, filesystemTrustsCtime, foldsCase, includePatternReadable, inOtherMount, insideMount, leadsInto, linkOnTheWay, linkRefusal, linkWords, locateRoot, looksBinary, namesHolder, namesNestedPlace, nestedAtReal, nestedHolder, observePath, parsedSpelling, passedOverAt, pathSegments, readBytes, readPrefix, shownLeads, sniffImageMime, walkScope, withinIncludes, type CheckedDir, type LinkFound, type MountView, type NestedMount, type WorkspaceEntries } from './observe.js'; import { TOOL_BATCH_HOOK_TIMEOUT_MS, ensureStateRegistered } from '../../module-registry.js'; import { DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS } from '../../tool-result-history.js'; import { type EditSpec, type FilePlan, countBudget, decodeForEdit, hintBudget, planFile } from './edit-plan.js'; @@ -4014,6 +4014,7 @@ export class WorkspaceModule implements Module { path: string, version: { blobHash: string; size: number }, what: 'saved' | 'conflict-disk' = 'saved', + tell: 'now' | 'later' = 'now', ): Promise<{ bytes: Buffer } | { lost: LostContents }> { const store = this.getStore(); const stored = storedBytes(store, version.blobHash); @@ -4031,22 +4032,15 @@ export class WorkspaceModule implements Module { await opened.handle.close(); } } catch (err) { - found = { - disk: err === otherSize ? 'other' - : err instanceof WorkspaceReadError && err.stage === 'lstat' && (err.errno === 'ENOENT' || err.errno === 'ENOTDIR') ? 'none' - : { unreadable: containedRefusal(err, path) }, - }; + found = { disk: err === otherSize ? 'other' : await this.diskLacks(mount, relativePath, path, err) }; } - const subject = what === 'saved' ? `The saved contents of ${path}` : `The version of ${path} that its conflict recorded as disk's`; - const inStore = 'missing' in stored ? "missing from the workspace's store" : `unreadable in the workspace's store (${stored.unreadable})`; + const subject = contentsSubject(what, path); const data = { mount: mount.config.name, path, blobHash: version.blobHash, ...('unreadable' in stored ? { storeError: stored.unreadable } : {}) }; if ('disk' in found) { const now = storedBytes(store, version.blobHash); if ('bytes' in now) return now; - const lost: LostContents = { path, store: stored, disk: found.disk, writable: writeRefusal(mount) === null }; - this.ctx?.notifyOps?.('workspace-contents-lost', 'workspace', - `${subject} (blob ${version.blobHash}) are ${inStore}, and no copy with their hash could be read from disk to restore them from: ${lostOnDisk(lost)}. ` - + "Putting back the store's blob file for that hash, from a copy of the store, brings them back.", data); + const lost: LostContents = { path, store: now, disk: found.disk, writable: writeRefusal(mount) === null }; + if (tell === 'now') this.tellLost(mount.config.name, what, version.blobHash, lost); return { lost }; } const { bytes } = found; @@ -4062,11 +4056,11 @@ export class WorkspaceModule implements Module { const now = storedBytes(store, version.blobHash); if ('bytes' in now) { this.ctx?.notifyOps?.('workspace-contents-restored', 'workspace', - `${subject} (blob ${version.blobHash}) were ${inStore}, and were stored again from disk's copy, whose bytes have their hash.`, data); + `Blob ${version.blobHash}: ${subject} were ${inTheStore(stored)}, and were stored again from disk's copy, whose bytes have their hash.`, data); } else { const unstored = thrown ?? ('missing' in now ? 'the store still has no file for them' : `the store still can't read its file for them (${now.unreadable})`); this.ctx?.notifyOps?.('workspace-contents-unstored', 'workspace', - `${subject} (blob ${version.blobHash}) are ${inStore}. Disk's copy has their hash, so reads are answered from it, but storing it again didn't take: ${unstored}. ` + `Blob ${version.blobHash}: ${subject} are ${inTheStore(stored)}. Disk's copy has their hash, so reads are answered from it, but storing it again didn't take: ${unstored}. ` + ('unreadable' in now ? "Each read does this again while that lasts. Chronicle keeps a blob file it can't read in place, so where the file is damaged, removing it lets the next read store them." : 'Each read does this again until the store takes them.'), @@ -4075,6 +4069,55 @@ export class WorkspaceModule implements Module { return { bytes }; } + private tellLost(mountName: string, what: 'saved' | 'conflict-disk', blobHash: string, lost: LostContents): void { + this.ctx?.notifyOps?.('workspace-contents-lost', 'workspace', + `Blob ${blobHash}: ${contentsSubject(what, lost.path)} are ${inTheStore(lost.store)}, and no copy with their hash could be read from disk to restore them from: ${lostOnDisk(lost)}. ` + + "Putting back the store's blob file for that hash, from a copy of the store, brings them back.", + { mount: mountName, path: lost.path, blobHash, ...('unreadable' in lost.store ? { storeError: lost.store.unreadable } : {}) }); + } + + private async diskLacks(mount: MountState, relativePath: string, path: string, err: unknown): Promise { + const absent = err instanceof WorkspaceReadError && (err.errno === 'ENOENT' || err.errno === 'ENOTDIR'); + if (!absent) return { unreadable: containedRefusal(err, path) }; + const { view, rootReal } = await this.runtime(mount); + const proof = await confirmAbsent(view, rootReal, relativePath); + if (typeof proof !== 'string') return 'none'; + return { unreadable: proof === CHANGED ? containedRefusal(err, path) : proof }; + } + + private async restoreEach( + mount: MountState, + versions: readonly T[], + each: (version: T, contents: { bytes: Buffer } | { lost: LostContents }) => void, + ): Promise { + const byHash = new Map(); + for (const version of versions) { + const sharing = byHash.get(version.hash); + if (sharing) sharing.push(version); + else byHash.set(version.hash, [version]); + } + for (const [hash, sharing] of byHash) { + let bytes: Buffer | null = null; + const lost: Array<[T, LostContents]> = []; + for (const version of sharing) { + const contents = await this.contentsOf(mount, version.relativePath, version.file, { blobHash: hash, size: version.size }, version.what, 'later'); + if ('bytes' in contents) { + bytes = contents.bytes; + break; + } + lost.push([version, contents.lost]); + } + if (bytes === null) { + for (const [version, found] of lost) { + this.tellLost(mount.config.name, version.what, hash, found); + each(version, { lost: found }); + } + } else { + for (const version of sharing) each(version, { bytes }); + } + } + } + // ========================================================================== // Tool Handlers // ========================================================================== @@ -4161,7 +4204,8 @@ export class WorkspaceModule implements Module { return { success: false, isError: true, - error: err.code === 'not_found' ? nothingToRead(lost) : `${lostText(lost)}, so it can't be restored, and disk's copy can't be shown (${err.message}). ${lostRemedy(lost)}`, + error: err.code === 'not_found' || lost.disk !== 'other' ? nothingToRead(lost) + : `${lostText(lost)}, so it can't be restored, and disk's copy can't be shown (${err.message}). ${lostRemedy(lost)}`, }; } if (err.code === 'not_found' && treeError) { @@ -4369,21 +4413,19 @@ export class WorkspaceModule implements Module { let ops = pass.ops; const contents = new Map(); const lost = new Map(); - const toRecover: Array<{ target: EditTarget; entry: JsTreeEntry }> = []; + const toRecover: Array<{ relativePath: string; file: string; hash: string; size: number; what: 'saved' }> = []; for (const target of targets.values()) { const entry = store.treeGet(mount.treeStateId, target.relativePath); if (!entry || contents.has(entry.blobHash)) continue; const stored = storedBytes(store, entry.blobHash); if ('bytes' in stored) contents.set(entry.blobHash, stored.bytes); - else toRecover.push({ target, entry }); + else toRecover.push({ relativePath: target.relativePath, file: target.path, hash: entry.blobHash, size: entry.size, what: 'saved' }); } if (toRecover.length > 0) { - for (const { target, entry } of toRecover) { - if (contents.has(entry.blobHash)) continue; - const found = await this.contentsOf(mount, target.relativePath, target.path, entry); - if ('bytes' in found) contents.set(entry.blobHash, found.bytes); - else lost.set(target.relativePath, { blobHash: entry.blobHash, lost: found.lost }); - } + await this.restoreEach(mount, toRecover, (version, found) => { + if ('bytes' in found) contents.set(version.hash, found.bytes); + else lost.set(version.relativePath, { blobHash: version.hash, lost: found.lost }); + }); pass = await this.passUnlocked(mount, { kind: 'paths', paths: [...targets.values()].map((target) => target.relativePath) }, { named: true }); ops = [...ops, ...pass.ops]; } @@ -5697,31 +5739,21 @@ export class WorkspaceModule implements Module { found.push({ skip: { file, reason: 'conflict: the disk version is binary or over the size limit, never stored', size: disk.size } }); } } - const restored = new Map(); - for (const item of found) { - if (!('lost' in item)) continue; - const { file, relativePath, hash, size, what } = item.lost; - restored.set(item.lost, await this.contentsOf(mount, relativePath, file, { blobHash: hash, size }, what)); - } + const answers = new Map void } | { skip: GrepSkip }>(); + await this.restoreEach(mount, found.flatMap((item) => ('lost' in item ? [item.lost] : [])), (lost, contents) => { + const { file, state, version, what } = lost; + answers.set(lost, 'bytes' in contents + ? { searched: search({ file, state, version, bytes: contents.bytes }) } + : { skip: { + file, + reason: `${what === 'saved' ? "the workspace's copy" : 'conflict: the disk version recorded with the conflict'} is ${lostInStore(contents.lost)}, ` + + `and ${lostOnDisk(contents.lost)}, so it was not searched`, + } }); + }); for (const item of found) { - if ('searched' in item) item.searched(); - else if ('skip' in item) skipped.push(item.skip); - else { - const { file, state, version, hash, what } = item.lost; - let contents = restored.get(item.lost)!; - if ('lost' in contents) { - const now = storedBytes(store, hash); - if ('bytes' in now) contents = now; - } - if ('bytes' in contents) search({ file, state, version, bytes: contents.bytes })(); - else { - skipped.push({ - file, - reason: `${what === 'saved' ? "the workspace's copy" : 'conflict: the disk version recorded with the conflict'} is ${lostInStore(contents.lost)}, ` - + `and ${lostOnDisk(contents.lost)}, so it was not searched`, - }); - } - } + const answer = 'lost' in item ? answers.get(item.lost)! : item; + if ('searched' in answer) answer.searched(); + else skipped.push(answer.skip); } }); return { skipped, incomplete }; @@ -6751,6 +6783,14 @@ type LostContents = { writable: boolean; }; +function contentsSubject(what: 'saved' | 'conflict-disk', path: string): string { + return what === 'saved' ? `the saved contents of ${path}` : `the version of ${path} that its conflict recorded as disk's`; +} + +function inTheStore(stored: Exclude): string { + return 'missing' in stored ? "missing from the workspace's store" : `unreadable in the workspace's store (${stored.unreadable})`; +} + function lostInStore(lost: Pick): string { return 'missing' in lost.store ? 'missing from the store' : `unreadable in the store (${lost.store.unreadable})`; } @@ -6775,7 +6815,7 @@ function lostRemedy(lost: LostContents): string { if (lost.writable) return rewrite ? 'Write the file again, or delete it.' : 'Delete the file to clear it.'; return lost.disk === 'none' ? "Sync the path to take disk's deletion, which removes the workspace's copy." - : "Only a change on disk can end this: once disk's copy can be read, read the file again, which restores it from a copy with the same contents, or sync the path to keep disk's copy and give up the workspace's."; + : "Once disk's copy can be read, read the file again, which reads it from disk's copy if that copy has the same contents, or sync the path to keep disk's copy and give up the workspace's."; } /** diff --git a/src/modules/workspace/observe.ts b/src/modules/workspace/observe.ts index a19794623..793532607 100644 --- a/src/modules/workspace/observe.ts +++ b/src/modules/workspace/observe.ts @@ -149,7 +149,7 @@ export const ROOT_UNAVAILABLE = 'the mount root is unavailable'; export const ROOT_REPLACED = 'the mount root is not the directory disk last agreed with (an unmounted drive, or a replaced directory): ' + 'reconnect it; if it was replaced on purpose, sync the mount with acceptRoot to take it as it is now'; -const CHANGED = 'it changed while it was observed'; +export const CHANGED = 'it changed while it was observed'; const CHANGED_WALK = 'the directory changed while the walk ran'; const BENEATH_SYMLINK = 'beneath a symlink that does not lead to a directory inside the mount'; diff --git a/test/workspace-lost-contents.test.ts b/test/workspace-lost-contents.test.ts index 75cd4555c..27a1835b2 100644 --- a/test/workspace-lost-contents.test.ts +++ b/test/workspace-lost-contents.test.ts @@ -141,7 +141,7 @@ describe('contents the store lost, where disk holds the same bytes', () => { assert.deepEqual(env.alerts.map(({ kind, agent, data }) => ({ kind, agent, data })), [ { kind: 'workspace-contents-restored', agent: 'workspace', data: { mount: 'work', path: 'work/a.txt', blobHash: hash } }, ]); - assert.match(env.alerts[0]!.message, /work\/a\.txt.*missing.*stored again from disk's copy/); + assert.ok(env.alerts[0]!.message.startsWith(`Blob ${hash}: the saved contents of work/a.txt were missing from the workspace's store, and were stored again from disk's copy`), env.alerts[0]!.message); assert.equal(await textOf(m, 'work/a.txt'), 'alpha\nneedle\n'); assert.equal(env.alerts.length, 1, 'contents back in the store need no restore'); @@ -233,6 +233,32 @@ describe('contents the store lost, where disk holds the same bytes', () => { assert.equal(env.store.getBlob(hash), null); assert.deepEqual(env.kinds(), ['workspace-contents-unstored']); assert.equal(env.alerts[0]!.data!.unstored, 'No space left on device'); + assert.ok(env.alerts[0]!.message.startsWith(`Blob ${hash}: the saved contents of work/a.txt are missing from the workspace's store.`), env.alerts[0]!.message); + }); + + test("a store write that leaves a file it can't read is told with the store's error, and removing that file lets a read store the contents", async (t) => { + const env = new Env(t); + let m = await env.open(); + const hash = await synced(env, m, 'a.txt', 'alpha\n'); + const blob = join(env.storePath, 'blobs', hash.slice(0, 2), hash); + m = await env.lose(m, hash); + const store = env.store as unknown as { storeBlob: (content: Buffer, type: string) => string }; + const storeBlob = store.storeBlob.bind(env.store); + store.storeBlob = () => { + writeFileSync(blob, 'CHB'); + throw new Error('No space left on device'); + }; + + assert.equal(await textOf(m, 'work/a.txt'), 'alpha\n'); + assert.deepEqual(env.kinds(), ['workspace-contents-unstored']); + assert.equal(env.alerts[0]!.data!.unstored, 'No space left on device'); + assert.match(env.alerts[0]!.message, new RegExp(`^Blob ${hash}: .*storing it again didn't take: No space left on device\\. .*where the file is damaged, removing it lets the next read store them\\.$`)); + + store.storeBlob = storeBlob; + rmSync(blob); + assert.equal(await textOf(m, 'work/a.txt'), 'alpha\n'); + assert.equal(env.storedText(hash), 'alpha\n'); + assert.deepEqual(env.kinds(), ['workspace-contents-unstored', 'workspace-contents-restored']); }); test("a copy disk holds only through a link the mount doesn't follow isn't taken", async (t) => { @@ -253,7 +279,7 @@ describe('contents the store lost, where disk holds the same bytes', () => { assert.equal(await textOf(m, 'work/a.txt'), 'alpha again\n'); }); - test("on a read-only mount, the answer asks for a change on disk rather than a write, and once disk's copy can be read a read restores it", async (t) => { + test("on a read-only mount, the answer says what a read or a sync does once disk's copy can be read, rather than offering a write, and a read then restores it", async (t) => { const env = new Env(t); let m = await env.open({ mode: 'read-only' }); env.writeDisk('a.txt', 'alpha\n'); @@ -266,7 +292,7 @@ describe('contents the store lost, where disk holds the same bytes', () => { assert.equal(await refused(m, 'read', { path: 'work/a.txt' }), "The workspace's copy of work/a.txt is missing from the store, and disk's copy can't be read (Symlinks are not allowed: work/a.txt), so it can't be restored or read. " - + "Only a change on disk can end this: once disk's copy can be read, read the file again, which restores it from a copy with the same contents, or sync the path to keep disk's copy and give up the workspace's."); + + "Once disk's copy can be read, read the file again, which reads it from disk's copy if that copy has the same contents, or sync the path to keep disk's copy and give up the workspace's."); rmSync(join(env.dir, 'a.txt')); env.writeDisk('a.txt', 'alpha\n'); assert.equal(await textOf(m, 'work/a.txt'), 'alpha\n'); @@ -274,20 +300,38 @@ describe('contents the store lost, where disk holds the same bytes', () => { assert.deepEqual(env.kinds(), ['workspace-contents-lost', 'workspace-contents-restored']); }); - test('grep searches contents two files share once either restores them, whichever comes first', async (t) => { + test('grep searches contents two files share once either restores them, whichever comes first, and tells no loss', async (t) => { + for (const [unrestorable, restoring] of [['a.txt', 'b.txt'], ['b.txt', 'a.txt']]) { + const env = new Env(t); + let m = await env.open(); + const hash = await draft(env, m, unrestorable!, 'other\n', 'needle\n'); + assert.equal(await synced(env, m, restoring!, 'needle\n'), hash); + m = await env.lose(m, hash); + + const found = await ok(m, 'grep', { pattern: 'needle' }); + assert.deepEqual(found.results.map((r: { file: string }) => r.file), ['work/a.txt', 'work/b.txt']); + assert.equal(found.skipped, undefined); + assert.deepEqual(env.alerts.map(({ kind, data }) => [kind, data?.path]), [['workspace-contents-restored', `work/${restoring}`]]); + assert.equal(env.storedText(hash), 'needle\n'); + } + }); + + test('edit_many edits files that share contents once either restores them, and tells no loss', async (t) => { const env = new Env(t); let m = await env.open(); - const hash = await draft(env, m, 'a.txt', 'other\n', 'needle\n'); - assert.equal(await synced(env, m, 'b.txt', 'needle\n'), hash); + const hash = await draft(env, m, 'a.txt', 'other\n', 'the same\n'); + assert.equal(await synced(env, m, 'b.txt', 'the same\n'), hash); m = await env.lose(m, hash); - const found = await ok(m, 'grep', { pattern: 'needle' }); - assert.deepEqual(found.results.map((r: { file: string }) => r.file), ['work/a.txt', 'work/b.txt']); - assert.equal(found.skipped, undefined); - assert.deepEqual(env.alerts.map(({ kind, data }) => [kind, data?.path]), [ - ['workspace-contents-lost', 'work/a.txt'], - ['workspace-contents-restored', 'work/b.txt'], - ]); + await ok(m, 'edit_many', { + edits: [ + { path: 'work/a.txt', oldString: 'same', newString: 'SAME' }, + { path: 'work/b.txt', oldString: 'same', newString: 'SAME' }, + ], + }); + assert.equal(await textOf(m, 'work/a.txt'), 'the SAME\n'); + assert.equal(await textOf(m, 'work/b.txt'), 'the SAME\n'); + assert.deepEqual(env.alerts.map(({ kind, data }) => [kind, data?.path]), [['workspace-contents-restored', 'work/b.txt']]); }); test("edit_many that restores one file still says what it took in from disk for another", async (t) => { @@ -419,6 +463,75 @@ describe('contents the store lost, where disk holds no copy of them', () => { ]); }); + test("grep tells each file's own state when files share contents the store lost", async (t) => { + const env = new Env(t); + let m = await env.open(); + const hash = await draft(env, m, 'a.txt', 'v1\n', 'needle\n'); + assert.equal(await draft(env, m, 'b.txt', 'v1\n', 'needle\n'), hash); + rmSync(join(env.dir, 'b.txt')); + m = await env.lose(m, hash); + + const found = await ok(m, 'grep', { pattern: 'needle' }); + assert.deepEqual(found.results, []); + assert.deepEqual(found.skipped, [ + { file: 'work/a.txt', reason: "the workspace's copy is missing from the store, and disk's copy holds other contents, so it was not searched" }, + { file: 'work/b.txt', reason: "the workspace's copy is missing from the store, and disk has no copy, so it was not searched" }, + ]); + assert.deepEqual(env.alerts.map(({ kind, data }) => [kind, data?.path]), [ + ['workspace-contents-lost', 'work/a.txt'], + ['workspace-contents-lost', 'work/b.txt'], + ]); + assert.match(env.alerts[0]!.message, /: disk's copy holds other contents\. /); + assert.match(env.alerts[1]!.message, /: disk has no copy\. /); + }); + + test('disk bytes of the same size but other contents are not taken', async (t) => { + const env = new Env(t); + let m = await env.open(); + const hash = await draft(env, m, 'a.txt', 'abcdef\n', 'ghijkl\n'); + m = await env.lose(m, hash); + + assert.equal(await refused(m, 'read', { path: 'work/a.txt' }), + "The workspace's copy of work/a.txt is missing from the store, and disk's copy holds other contents, so it can't be restored or read. Sync the path to keep disk's copy and give up the workspace's, or write the file again."); + assert.equal(env.store.getBlob(hash), null); + assert.deepEqual(env.kinds(), ['workspace-contents-lost']); + }); + + test("on a mount that follows links, a copy disk holds through a link leading nowhere is one it can't read, not one it lacks", async (t) => { + const env = new Env(t); + let m = await env.open({ followSymlinks: true }); + const leaf = await draft(env, m, 'a.txt', 'v1\n', 'a draft\n'); + const beneath = await draft(env, m, 'dir/b.txt', 'v1\n', 'b draft\n'); + m = await env.restartAfter(m, (blobFile) => { + rmSync(blobFile(leaf)); + rmSync(blobFile(beneath)); + }, { followSymlinks: true }); + rmSync(join(env.dir, 'a.txt')); + symlinkSync(join(env.dir, 'gone.txt'), join(env.dir, 'a.txt')); + rmSync(join(env.dir, 'dir'), { recursive: true }); + symlinkSync(join(env.dir, 'nowhere'), join(env.dir, 'dir')); + + assert.equal(await refused(m, 'read', { path: 'work/a.txt' }), + "The workspace's copy of work/a.txt is missing from the store, and disk's copy can't be read (File not found: work/a.txt), so it can't be restored or read. Write the file again, or delete it."); + assert.equal(await refused(m, 'read', { path: 'work/dir/b.txt' }), + "The workspace's copy of work/dir/b.txt is missing from the store, and disk's copy can't be read (beneath a symlink that does not lead to a directory inside the mount), so it can't be restored or read. Write the file again, or delete it."); + }); + + test("a store closed while a read restores contents fails the read with the store's own error, and tells no damaged blob", async (t) => { + const env = new Env(t); + let m = await env.open(); + const hash = await synced(env, m, 'a.txt', 'alpha\n'); + m = await env.lose(m, hash); + const store = env.store as unknown as { storeBlob: (content: Buffer, type: string) => string }; + store.storeBlob = () => { + env.store.close(); + throw new Error('Store has been closed'); + }; + + assert.match(await refused(m, 'read', { path: 'work/a.txt' }), /Store has been closed/); + assert.deepEqual(env.kinds(), []); + }); + test("read_image serves disk's other image saying whose it is, and with none on disk says the copy is lost", async (t) => { const env = new Env(t); let m = await env.open(); @@ -471,6 +584,20 @@ describe('contents the store lost, where disk holds no copy of them', () => { "The workspace's copy of work/p.png is missing from the store, and disk's copy holds other contents, so it can't be restored, and disk's copy can't be shown (Unsupported image format: work/p.png). Sync the path to keep disk's copy and give up the workspace's, or write the file again."); }); + test("read_image gives disk's reason once where disk's copy can't be read", async (t) => { + const env = new Env(t); + let m = await env.open(); + assert.equal((await m.writeBinary('work/p.png', PNG, 'image/png')).success, true); + await ok(m, 'materialize', { path: 'work/p.png' }); + m = await env.lose(m, env.hashOf('p.png')); + writeFileSync(join(env.outside, 'p.png'), PNG); + rmSync(join(env.dir, 'p.png')); + symlinkSync(join(env.outside, 'p.png'), join(env.dir, 'p.png')); + + assert.equal(await refused(m, 'read_image', { path: 'work/p.png' }), + "The workspace's copy of work/p.png is missing from the store, and disk's copy can't be read (Symlinks are not allowed: work/p.png), so it can't be restored or read. Write the file again, or delete it."); + }); + test("on a mount configured direct that runs as 'chronicle', the answer names the sync it takes, not a write", async (t) => { const env = new Env(t); let m = await env.open({ backend: 'chronicle' }); @@ -495,7 +622,7 @@ describe('contents the store lost, where disk holds no copy of them', () => { await refused(m, 'read', { path: 'work/a.txt' }); assert.deepEqual(env.kinds(), ['workspace-contents-lost']); - assert.match(env.alerts[0]!.message, new RegExp(`\\(blob ${hash}\\)`)); + assert.ok(env.alerts[0]!.message.startsWith(`Blob ${hash}: the saved contents of work/a.txt are missing from the workspace's store, and no copy`), env.alerts[0]!.message); assert.match(env.alerts[0]!.message, /Putting back the store's blob file for that hash, from a copy of the store, brings them back\.$/); writeFileSync(blob, saved); assert.equal(await textOf(m, 'work/a.txt'), 'v2, a draft\n'); @@ -586,6 +713,20 @@ describe("contents whose blob file the store can't read", () => { assert.deepEqual([env.readDisk('a.txt'), env.readDisk('b.txt')], ['v1\n', 'b draft\n']); }); + test("a forced materialize over disk's newer copy skips a file whose blob it can't read, saying why, and leaves disk's copy", async (t) => { + const env = new Env(t); + let m = await env.open(); + const a = await synced(env, m, 'a.txt', 'v1\n'); + m = await env.damage(m, a); + env.writeDisk('a.txt', 'v2, on disk\n'); + + const done = await ok(m, 'materialize', { path: 'work/a.txt', force: true }); + assert.deepEqual(done.materialized ?? [], []); + assert.equal(done.skipped.length, 1); + assert.match(done.skipped[0].reason, /^a\.txt: the workspace copy is unreadable in the store \(.+\)$/); + assert.equal(env.readDisk('a.txt'), 'v2, on disk\n'); + }); + test("a branch intent whose blob the store can't read leaves its path unverified, saying why, rather than failing the listing", async (t) => { const env = new Env(t); let m = await env.open(); From 1b165fe79f6b459fb0d69acb8fe65ac005c6aec0 Mon Sep 17 00:00:00 2001 From: Pippa-2571 Date: Mon, 26 Oct 2026 04:12:38 -0400 Subject: [PATCH 3/3] fix(workspace): judge absence with a read's root identity, and word the unstored alert from the store after the write - diskLacks is part of a read and records nothing, so it builds its view with rootIdentityForRead's identity, as readRoot does, rather than runtime()'s rootIdentity, which throws while the disk-agreement evidence awaits reopening and so failed a read that had passed its root check, losing its workspace-contents-lost alert. A root the read couldn't reach (mount_unavailable) is a copy disk can't read, in the read's own words, without asking for a proof of absence. - workspace-contents-unstored speaks of the store as it is after the write, so a write that leaves a blob file chronicle can't read is told as unreadable, with its storeError, rather than as missing. Its reason, where the write threw nothing, no longer says the store "still" lacks them, which assumed the state before the write was the same. - A test: read_image of a lost image with no copy on disk, while the disk-agreement evidence awaits reopening after a failed append, answers the loss and tells workspace-contents-lost. - A test: two files share contents whose blob the store can't read, one with other contents on disk and one with the matching copy. grep and edit_many answer both from that copy, in both path orders, telling one workspace-contents-unstored and no loss; and where both hold the matching copy, it's read from disk and told once, not once per file. For grep, the loss alert is held for the files one mount's grep reads, each mount in its own turn. Co-Authored-By: Claude Opus 5.5 --- src/modules/workspace/index.ts | 12 ++-- test/workspace-lost-contents.test.ts | 90 +++++++++++++++++++++++++++- 2 files changed, 95 insertions(+), 7 deletions(-) diff --git a/src/modules/workspace/index.ts b/src/modules/workspace/index.ts index c0001147c..88eea3dcf 100644 --- a/src/modules/workspace/index.ts +++ b/src/modules/workspace/index.ts @@ -4058,13 +4058,13 @@ export class WorkspaceModule implements Module { this.ctx?.notifyOps?.('workspace-contents-restored', 'workspace', `Blob ${version.blobHash}: ${subject} were ${inTheStore(stored)}, and were stored again from disk's copy, whose bytes have their hash.`, data); } else { - const unstored = thrown ?? ('missing' in now ? 'the store still has no file for them' : `the store still can't read its file for them (${now.unreadable})`); + const unstored = thrown ?? ('missing' in now ? 'the store has no file for them' : `the store can't read its file for them (${now.unreadable})`); this.ctx?.notifyOps?.('workspace-contents-unstored', 'workspace', - `Blob ${version.blobHash}: ${subject} are ${inTheStore(stored)}. Disk's copy has their hash, so reads are answered from it, but storing it again didn't take: ${unstored}. ` + `Blob ${version.blobHash}: ${subject} are ${inTheStore(now)}. Disk's copy has their hash, so reads are answered from it, but storing it again didn't take: ${unstored}. ` + ('unreadable' in now ? "Each read does this again while that lasts. Chronicle keeps a blob file it can't read in place, so where the file is damaged, removing it lets the next read store them." : 'Each read does this again until the store takes them.'), - { ...data, unstored }); + { mount: mount.config.name, path, blobHash: version.blobHash, ...('unreadable' in now ? { storeError: now.unreadable } : {}), unstored }); } return { bytes }; } @@ -4077,9 +4077,11 @@ export class WorkspaceModule implements Module { } private async diskLacks(mount: MountState, relativePath: string, path: string, err: unknown): Promise { - const absent = err instanceof WorkspaceReadError && (err.errno === 'ENOENT' || err.errno === 'ENOTDIR'); + const absent = err instanceof WorkspaceReadError && err.code !== 'mount_unavailable' && (err.errno === 'ENOENT' || err.errno === 'ENOTDIR'); if (!absent) return { unreadable: containedRefusal(err, path) }; - const { view, rootReal } = await this.runtime(mount); + const root = resolve(mount.config.path); + const rootReal = await realpath(root).catch(() => root); + const view = this.viewOf(mount, this.agreementOrThrow().rootIdentityForRead(mount.config.name)); const proof = await confirmAbsent(view, rootReal, relativePath); if (typeof proof !== 'string') return 'none'; return { unreadable: proof === CHANGED ? containedRefusal(err, path) : proof }; diff --git a/test/workspace-lost-contents.test.ts b/test/workspace-lost-contents.test.ts index 27a1835b2..4031de683 100644 --- a/test/workspace-lost-contents.test.ts +++ b/test/workspace-lost-contents.test.ts @@ -252,7 +252,8 @@ describe('contents the store lost, where disk holds the same bytes', () => { assert.equal(await textOf(m, 'work/a.txt'), 'alpha\n'); assert.deepEqual(env.kinds(), ['workspace-contents-unstored']); assert.equal(env.alerts[0]!.data!.unstored, 'No space left on device'); - assert.match(env.alerts[0]!.message, new RegExp(`^Blob ${hash}: .*storing it again didn't take: No space left on device\\. .*where the file is damaged, removing it lets the next read store them\\.$`)); + assert.match(env.alerts[0]!.message, new RegExp(`^Blob ${hash}: the saved contents of work/a\\.txt are unreadable in the workspace's store \\(.+\\)\\. .*storing it again didn't take: No space left on device\\. .*where the file is damaged, removing it lets the next read store them\\.$`)); + assert.equal(typeof env.alerts[0]!.data!.storeError, 'string'); store.storeBlob = storeBlob; rmSync(blob); @@ -598,6 +599,39 @@ describe('contents the store lost, where disk holds no copy of them', () => { "The workspace's copy of work/p.png is missing from the store, and disk's copy can't be read (Symlinks are not allowed: work/p.png), so it can't be restored or read. Write the file again, or delete it."); }); + test("read_image tells a loss while the disk-agreement evidence awaits reopening, since its read checks the root as before", async (t) => { + const env = new Env(t); + let m = await env.open(); + assert.equal((await m.writeBinary('work/p.png', PNG, 'image/png')).success, true); + await ok(m, 'materialize', { path: 'work/p.png' }); + m = await env.lose(m, env.hashOf('p.png')); + rmSync(join(env.dir, 'p.png')); + + const store = env.store as unknown as { appendJson: (type: string, payload: unknown) => unknown }; + const append = store.appendJson; + let fired = false; + store.appendJson = function (this: unknown, type: string, payload: unknown) { + const record = append.call(this, type, payload); + if (!fired && type === 'workspace/disk-agreement') { + fired = true; + throw new Error('injected: the append landed, then the store failed'); + } + return record; + }; + try { + await call(m, 'write', { path: 'work/b.txt', content: 'b\n' }); + await call(m, 'materialize', { path: 'work/b.txt' }); + } finally { + store.appendJson = append; + } + assert.equal(fired, true); + assert.match(await refused(m, 'ls', { path: 'work' }), /evidence is unavailable until the store is reopened/); + + assert.equal(await refused(m, 'read_image', { path: 'work/p.png' }), + "The workspace's copy of work/p.png is missing from the store, and disk has no copy, so it can't be restored or read. Write the file again, or delete it."); + assert.deepEqual(env.kinds().filter((kind) => kind.startsWith('workspace-contents-')), ['workspace-contents-lost']); + }); + test("on a mount configured direct that runs as 'chronicle', the answer names the sync it takes, not a write", async (t) => { const env = new Env(t); let m = await env.open({ backend: 'chronicle' }); @@ -643,7 +677,7 @@ describe("contents whose blob file the store can't read", () => { assert.equal(await textOf(m, 'work/a.txt'), 'needle a\n'); assert.deepEqual(env.kinds(), ['workspace-contents-unstored', 'workspace-contents-unstored']); assert.equal(typeof env.alerts[0]!.data!.storeError, 'string'); - assert.match(env.alerts[0]!.message, /unreadable in the workspace's store .*reads are answered from it, but storing it again didn't take: the store still can't read its file for them/); + assert.match(env.alerts[0]!.message, /unreadable in the workspace's store .*reads are answered from it, but storing it again didn't take: the store can't read its file for them/); assert.equal(env.alerts[0]!.data!.blobHash, a); }); @@ -671,6 +705,58 @@ describe("contents whose blob file the store can't read", () => { await ok(m, 'delete', { path: 'work/a.txt' }); assert.match(await refused(m, 'read', { path: 'work/a.txt' }), /^File not found/); }); + test("files sharing contents whose blob the store can't read are all answered from the one file whose disk copy has them, told once", async (t) => { + for (const [unrestorable, restoring] of [['a.txt', 'b.txt'], ['b.txt', 'a.txt']]) { + for (const tool of ['grep', 'edit_many']) { + const env = new Env(t); + let m = await env.open(); + const hash = await draft(env, m, unrestorable!, 'other\n', 'the same needle\n'); + assert.equal(await synced(env, m, restoring!, 'the same needle\n'), hash); + m = await env.damage(m, hash); + + if (tool === 'grep') { + const found = await ok(m, 'grep', { pattern: 'needle' }); + assert.deepEqual(found.results.map((r: { file: string }) => r.file), ['work/a.txt', 'work/b.txt']); + assert.equal(found.skipped, undefined); + } else { + await ok(m, 'edit_many', { + edits: [ + { path: 'work/a.txt', oldString: 'needle', newString: 'NEEDLE' }, + { path: 'work/b.txt', oldString: 'needle', newString: 'NEEDLE' }, + ], + }); + assert.equal(await textOf(m, 'work/a.txt'), 'the same NEEDLE\n'); + assert.equal(await textOf(m, 'work/b.txt'), 'the same NEEDLE\n'); + } + assert.deepEqual(env.alerts.map(({ kind, data }) => [kind, data?.path]), [['workspace-contents-unstored', `work/${restoring}`]], `${tool}, ${unrestorable} first`); + } + } + }); + + test("files sharing contents whose blob the store can't read, each with the matching copy on disk, are read from disk and told once", async (t) => { + for (const tool of ['grep', 'edit_many']) { + const env = new Env(t); + let m = await env.open(); + const hash = await synced(env, m, 'a.txt', 'the same needle\n'); + assert.equal(await synced(env, m, 'b.txt', 'the same needle\n'), hash); + m = await env.damage(m, hash); + + if (tool === 'grep') { + const found = await ok(m, 'grep', { pattern: 'needle' }); + assert.deepEqual(found.results.map((r: { file: string }) => r.file), ['work/a.txt', 'work/b.txt']); + } else { + await ok(m, 'edit_many', { + edits: [ + { path: 'work/a.txt', oldString: 'needle', newString: 'NEEDLE' }, + { path: 'work/b.txt', oldString: 'needle', newString: 'NEEDLE' }, + ], + }); + assert.equal(await textOf(m, 'work/b.txt'), 'the same NEEDLE\n'); + } + assert.deepEqual(env.alerts.map(({ kind, data }) => [kind, data?.path]), [['workspace-contents-unstored', 'work/a.txt']], tool); + } + }); + test('edit and edit_many plan from disk\'s matching copy and store their change', async (t) => { const env = new Env(t); let m = await env.open();