From dc5d5140e4a80789f2aec379fa0cc826f490f673 Mon Sep 17 00:00:00 2001 From: antra-tess Date: Fri, 9 Oct 2026 21:19:07 -0700 Subject: [PATCH 1/2] feat(workspace): per-mount `backend: 'direct'` that bypasses the tree A workspace mount can now be configured with `backend: 'direct'`. The same tools (read/write/edit/delete/ls/glob/grep/read_image) then operate on the mounted directory itself: no Chronicle tree state, nothing to materialize or sync, and no cached copy that can drift behind shell-side edits. `materialize` and `sync` report direct mounts as skipped; `status` and a path-less `ls` show each mount's backend. Writes are atomic, `edit` refuses a file changed since it was read, containment matches the chronicle backend's disk reads, and capped walks say so. Switching a mount back to chronicle syncs disk into the store before the first materialize so the stale tree cannot overwrite disk. `write` gains `append: true` on both backends. Co-Authored-By: Claude Fable 5.1 --- changelog.d/workspace-direct-backend.added.md | 16 + src/modules/workspace/direct.ts | 529 ++++++++++++++++++ src/modules/workspace/index.ts | 272 +++++++-- src/modules/workspace/sync.ts | 2 +- src/modules/workspace/types.ts | 14 + test/workspace-direct-backend.test.ts | 286 ++++++++++ 6 files changed, 1085 insertions(+), 34 deletions(-) create mode 100644 changelog.d/workspace-direct-backend.added.md create mode 100644 src/modules/workspace/direct.ts create mode 100644 test/workspace-direct-backend.test.ts diff --git a/changelog.d/workspace-direct-backend.added.md b/changelog.d/workspace-direct-backend.added.md new file mode 100644 index 000000000..9361b76e4 --- /dev/null +++ b/changelog.d/workspace-direct-backend.added.md @@ -0,0 +1,16 @@ +- Workspace mounts take a `backend` (recipe: `modules.workspace.mounts[].backend`, + default `chronicle`). With `backend: 'direct'` the same tools — `read`, + `write`, `edit`, `delete`, `ls`, `glob`, `grep`, `read_image` — operate on the + mounted directory itself: no Chronicle tree state, so there is no second + copy that can go stale behind shell-side edits, and nothing to materialize + or sync. `materialize` and `sync` report a direct mount under `skipped` + instead of touching it; `status` and a path-less `ls` show each mount's + backend. Direct writes are atomic (temp file + rename), `edit` refuses a + file another writer changed since it was read, containment and symlink + rules match the chronicle backend's disk reads, and recursive listings + report `truncated` when they stop at the file cap rather than going quiet. + Direct edits are not branch-scoped or undoable. A mount switched back from + direct to chronicle syncs disk into the store before its first materialize, + so the stale tree never overwrites disk. +- `write` takes `append: true` on both backends, adding to the end of the file + (creating it if missing) instead of replacing it. diff --git a/src/modules/workspace/direct.ts b/src/modules/workspace/direct.ts new file mode 100644 index 000000000..c1aa62745 --- /dev/null +++ b/src/modules/workspace/direct.ts @@ -0,0 +1,529 @@ +/** + * Direct filesystem backend for workspace mounts (`backend: 'direct'`). + * + * Every tool call on a direct mount reads or writes the mounted directory + * itself. No Chronicle tree state is kept for the mount, so there is nothing + * to materialize, nothing to sync, and no second copy of a file that can + * drift from the one on disk. What a shell appended a second ago is what + * `read` returns; what `write` returned success for is what `cat` shows. + * + * The price is history: edits on a direct mount are not branch-scoped, an + * undo does not rewind them, and a branch switch does not re-materialize + * them. Choose per mount in the recipe. + * + * Containment is the same as the chronicle backend's disk reads: lexical + * containment at parse time, symlinks refused unless `followSymlinks`, and + * the real path of every touched parent checked against the mount root. + */ + +import { randomBytes } from 'node:crypto'; +import { constants as fsConstants, type Stats } from 'node:fs'; +import { lstat, mkdir, open, readdir, realpath, rename, rm, stat, unlink } from 'node:fs/promises'; +import { basename, dirname, join, resolve, relative, sep } from 'node:path'; +import type { MountState } from './types.js'; +import { DEFAULT_MAX_FILE_SIZE, hashContent, isBinary, shouldIgnore } from './sync.js'; + +/** Files visited by one recursive listing/search before it reports `truncated`. */ +export const DIRECT_WALK_CAP = 5000; + +export type DirectErrorCode = + | 'not_found' | 'directory' | 'symlink' | 'escape' | 'mount_unavailable' + | 'too_large' | 'binary' | 'changed' | 'read_only' | 'io'; + +export class DirectFsError extends Error { + constructor(readonly code: DirectErrorCode, message: string, readonly errno?: string) { + super(message); + this.name = 'DirectFsError'; + } +} + +/** What the module's containment-checked opener hands back. */ +export interface OpenedFile { + handle: Awaited>; + fileStat: Stats; + realFilePath: string; +} + +/** The module's `openContainedFile`, bound to a mount. */ +export type ContainedOpener = (relativePath: string, mountPrefixedPath: string) => Promise; + +function errnoOf(err: unknown): string | undefined { + return typeof (err as { code?: unknown })?.code === 'string' ? (err as { code: string }).code : undefined; +} + +function isContained(root: string, candidate: string): boolean { + const r = root.endsWith(sep) ? root : root + sep; + return candidate === root || candidate.startsWith(r); +} + +function toLogical(base: string, abs: string): string { + return relative(base, abs).split(sep).join('/'); +} + +// ============================================================================ +// Read +// ============================================================================ + +export interface DirectReadResult { + content: string; + size: number; + mtimeMs: number; + ino: number; +} + +/** + * Read a text file from disk through the containment-checked opener. + * Oversize and binary files are refused with a reason the agent can act on + * (`read_image` for images, a shell tool for the rest). + */ +export async function directReadText( + mount: MountState, + relativePath: string, + mountPrefixedPath: string, + openContained: ContainedOpener, +): Promise { + if (!relativePath) throw new DirectFsError('directory', `Path is a directory: ${mountPrefixedPath}`); + const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE; + const { handle, fileStat } = await openContained(relativePath, mountPrefixedPath); + try { + if (fileStat.size > maxSize) { + throw new DirectFsError('too_large', `File exceeds max file size (${fileStat.size} > ${maxSize} bytes): ${mountPrefixedPath}. Read it with a shell tool.`); + } + const bytes = await handle.readFile(); + if (isBinary(bytes)) { + throw new DirectFsError('binary', `Binary file: ${mountPrefixedPath}. Use read_image for images, or a shell tool.`); + } + return { content: bytes.toString('utf-8'), size: bytes.length, mtimeMs: fileStat.mtimeMs, ino: fileStat.ino }; + } finally { + await handle.close(); + } +} + +// ============================================================================ +// Write targets +// ============================================================================ + +interface WriteTarget { + /** Real, contained absolute path of the file (parent resolved through realpath). */ + absolutePath: string; + /** lstat of the existing entry, or null when nothing is there yet. */ + existing: Stats | null; +} + +/** + * Resolve where a write/delete lands and prove it is inside the mount. + * + * Symlinked targets are refused unless the mount follows symlinks; the + * deepest existing ancestor must realpath inside the real mount root before + * any missing directories are created, so a symlinked parent cannot redirect + * a write out of the mount. + */ +async function resolveWriteTarget( + mount: MountState, + relativePath: string, + mountPrefixedPath: string, + opts: { createParents: boolean }, +): Promise { + if (mount.config.mode === 'read-only') { + throw new DirectFsError('read_only', `Mount "${mount.config.name}" is read-only`); + } + if (!relativePath) throw new DirectFsError('directory', `Path is a directory: ${mountPrefixedPath}`); + const follow = mount.config.followSymlinks === true; + + let realRoot: string; + try { + realRoot = await realpath(mount.config.path); + } catch (err) { + throw new DirectFsError('mount_unavailable', `Mount unavailable: ${mount.config.name}`, errnoOf(err)); + } + + const lexical = resolve(mount.config.path, relativePath); + let existing: Stats | null = null; + try { + existing = await lstat(lexical); + } catch (err) { + const code = errnoOf(err); + if (code !== 'ENOENT' && code !== 'ENOTDIR') { + throw new DirectFsError('io', `Unable to access ${mountPrefixedPath}: ${code ?? String(err)}`, code); + } + } + if (existing?.isDirectory()) throw new DirectFsError('directory', `Path is a directory: ${mountPrefixedPath}`); + if (existing?.isSymbolicLink() && !follow) throw new DirectFsError('symlink', `Symlinks are not allowed: ${mountPrefixedPath}`); + + // Deepest existing ancestor must be inside the real root. + const parentLexical = dirname(lexical); + let probe = parentLexical; + let realProbe: string | null = null; + while (realProbe === null) { + try { + realProbe = await realpath(probe); + } catch (err) { + const code = errnoOf(err); + if (code !== 'ENOENT' && code !== 'ENOTDIR') { + throw new DirectFsError('io', `Unable to resolve parent of ${mountPrefixedPath}: ${code ?? String(err)}`, code); + } + const up = dirname(probe); + if (up === probe) throw new DirectFsError('mount_unavailable', `Mount unavailable: ${mount.config.name}`); + probe = up; + } + } + if (!isContained(realRoot, realProbe)) { + throw new DirectFsError('escape', `Symlink escape detected: ${mountPrefixedPath}`); + } + + if (probe !== parentLexical) { + if (!opts.createParents) throw new DirectFsError('not_found', `File not found: ${mountPrefixedPath}`); + await mkdir(parentLexical, { recursive: true }); + } + let realParent: string; + try { + realParent = await realpath(parentLexical); + } catch (err) { + throw new DirectFsError('io', `Unable to resolve parent of ${mountPrefixedPath}`, errnoOf(err)); + } + if (!isContained(realRoot, realParent)) { + throw new DirectFsError('escape', `Symlink escape detected: ${mountPrefixedPath}`); + } + + let absolutePath = join(realParent, basename(lexical)); + if (existing?.isSymbolicLink() && follow) { + try { + absolutePath = await realpath(lexical); + } catch (err) { + throw new DirectFsError('not_found', `Dangling symlink: ${mountPrefixedPath}`, errnoOf(err)); + } + if (!isContained(realRoot, absolutePath)) { + throw new DirectFsError('escape', `Symlink escape detected: ${mountPrefixedPath}`); + } + existing = await stat(absolutePath); + if (existing.isDirectory()) throw new DirectFsError('directory', `Path is a directory: ${mountPrefixedPath}`); + } + return { absolutePath, existing }; +} + +/** + * Replace a file's contents atomically: write a sibling temp file, fsync it, + * rename over the target. A crash mid-write leaves either the old file or + * the new one, never a torn one. + */ +async function atomicReplace(absolutePath: string, content: Buffer): Promise { + const tmp = join(dirname(absolutePath), `.${basename(absolutePath)}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`); + let handle: Awaited> | undefined; + try { + handle = await open(tmp, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL, 0o644); + await handle.writeFile(content); + await handle.sync(); + await handle.close(); + handle = undefined; + await rename(tmp, absolutePath); + } catch (err) { + if (handle) await handle.close().catch(() => {}); + await rm(tmp, { force: true }).catch(() => {}); + throw new DirectFsError('io', `Write failed: ${err instanceof Error ? err.message : String(err)}`, errnoOf(err)); + } +} + +export interface DirectWriteResult { + size: number; + hash: string; + appended: boolean; +} + +/** + * Write (or append) text to a file on disk. Overwrites are atomic; appends + * go through a single O_APPEND write so interleaving with other appenders + * (a shell `>>`) never tears an entry. + */ +export async function directWrite( + mount: MountState, + relativePath: string, + mountPrefixedPath: string, + content: Buffer, + opts: { append?: boolean } = {}, +): Promise { + const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE; + if (content.length > maxSize) { + throw new DirectFsError('too_large', `Content exceeds max file size (${maxSize} bytes)`); + } + const { absolutePath, existing } = await resolveWriteTarget(mount, relativePath, mountPrefixedPath, { createParents: true }); + + if (opts.append && existing) { + let handle: Awaited> | undefined; + try { + handle = await open(absolutePath, fsConstants.O_WRONLY | fsConstants.O_APPEND, 0o644); + await handle.writeFile(content); + await handle.sync(); + const after = await handle.stat(); + return { size: after.size, hash: hashContent(content), appended: true }; + } catch (err) { + throw new DirectFsError('io', `Append failed: ${err instanceof Error ? err.message : String(err)}`, errnoOf(err)); + } finally { + await handle?.close().catch(() => {}); + } + } + + await atomicReplace(absolutePath, content); + return { size: content.length, hash: hashContent(content), appended: false }; +} + +export interface DirectEditResult { + size: number; + replacements: number; +} + +/** + * Find-and-replace in a file on disk. Uniqueness rules match the chronicle + * backend. The file's identity and mtime are re-checked right before the + * atomic replace; if another writer touched it in between, the edit is + * refused rather than silently dropping their change. + */ +export async function directEdit( + mount: MountState, + relativePath: string, + mountPrefixedPath: string, + openContained: ContainedOpener, + edit: { oldString: string; newString: string; replaceAll?: boolean }, +): Promise { + if (mount.config.mode === 'read-only') { + throw new DirectFsError('read_only', `Mount "${mount.config.name}" is read-only`); + } + const before = await directReadText(mount, relativePath, mountPrefixedPath, openContained); + const count = before.content.split(edit.oldString).length - 1; + if (count === 0) throw new DirectFsError('not_found', `String not found in ${mountPrefixedPath}`); + if (!edit.replaceAll && count > 1) { + throw new DirectFsError('io', `String found ${count} times in ${mountPrefixedPath}. Use replaceAll: true or provide more context.`); + } + const next = edit.replaceAll + ? before.content.replaceAll(edit.oldString, edit.newString) + : before.content.replace(edit.oldString, edit.newString); + const buffer = Buffer.from(next, 'utf-8'); + const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE; + if (buffer.length > maxSize) throw new DirectFsError('too_large', `Edited content exceeds max file size (${maxSize} bytes)`); + + const { absolutePath, existing } = await resolveWriteTarget(mount, relativePath, mountPrefixedPath, { createParents: false }); + if (!existing || existing.ino !== before.ino || existing.mtimeMs !== before.mtimeMs || existing.size !== before.size) { + throw new DirectFsError('changed', `File changed during edit: ${mountPrefixedPath}. Re-read it and retry.`); + } + await atomicReplace(absolutePath, buffer); + return { size: buffer.length, replacements: edit.replaceAll ? count : 1 }; +} + +/** Unlink a file on disk. Directories and (unless followed) symlinks are refused. */ +export async function directDelete( + mount: MountState, + relativePath: string, + mountPrefixedPath: string, +): Promise { + const { absolutePath, existing } = await resolveWriteTarget(mount, relativePath, mountPrefixedPath, { createParents: false }); + if (!existing) throw new DirectFsError('not_found', `File not found: ${mountPrefixedPath}`); + try { + await unlink(absolutePath); + } catch (err) { + const code = errnoOf(err); + if (code === 'ENOENT') throw new DirectFsError('not_found', `File not found: ${mountPrefixedPath}`, code); + throw new DirectFsError('io', `Delete failed: ${err instanceof Error ? err.message : String(err)}`, code); + } +} + +// ============================================================================ +// Listing and search +// ============================================================================ + +export interface DirectEntry { + name: string; + type: 'file' | 'directory' | 'symlink' | 'other'; + size?: number; +} + +export interface DirectWalk { + /** Files found, logical '/'-separated paths relative to the mount root. */ + files: Array<{ path: string; size: number }>; + /** True when the walk stopped at the cap; files beyond it were not visited. */ + truncated: boolean; + /** Directories the walk could not read. */ + unreadable: string[]; +} + +async function realDirOrNull(path: string): Promise { + try { return await realpath(path); } catch { return null; } +} + +/** + * Walk a directory inside the mount. Ignore patterns are honored, symlinks + * are skipped unless the mount follows them (and then loops and escapes are + * cut), and the walk stops at `cap` files — reported, never silent. + */ +export async function directWalk( + mount: MountState, + scopeRelative: string, + opts: { recursive: boolean; cap?: number }, +): Promise { + const cap = opts.cap ?? DIRECT_WALK_CAP; + const follow = mount.config.followSymlinks === true; + const ignore = mount.config.ignore ?? []; + const root = mount.config.path; + const realRoot = await realDirOrNull(root); + if (realRoot === null) throw new DirectFsError('mount_unavailable', `Mount unavailable: ${mount.config.name}`); + const start = resolve(root, scopeRelative); + const walk: DirectWalk = { files: [], truncated: false, unreadable: [] }; + const visited = new Set(); + + const visit = async (dir: string): Promise => { + if (walk.truncated) return; + const realDir = await realDirOrNull(dir); + if (realDir === null || !isContained(realRoot, realDir)) { + if (dir !== start) return; + throw new DirectFsError('escape', `Path resolves outside the mount: ${scopeRelative}`); + } + if (visited.has(realDir)) return; + visited.add(realDir); + + let entries; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch (err) { + if (dir === start) { + const code = errnoOf(err); + if (code === 'ENOENT' || code === 'ENOTDIR') throw new DirectFsError('not_found', `Directory not found: ${scopeRelative || '/'}`, code); + throw new DirectFsError('io', `Cannot list ${scopeRelative || '/'}: ${code ?? String(err)}`, code); + } + walk.unreadable.push(toLogical(root, dir)); + return; + } + for (const entry of entries) { + if (walk.truncated) return; + const full = join(dir, entry.name); + const rel = toLogical(root, full); + if (shouldIgnore(rel, entry.name, ignore)) continue; + + let isFile = entry.isFile(); + let isDir = entry.isDirectory(); + let size: number | undefined; + if (entry.isSymbolicLink()) { + if (!follow) continue; + try { + const target = await stat(full); + const realTarget = await realpath(full); + if (!isContained(realRoot, realTarget)) continue; + isFile = target.isFile(); + isDir = target.isDirectory(); + size = target.size; + } catch { + continue; + } + } + if (isFile) { + if (walk.files.length >= cap) { walk.truncated = true; return; } + if (size === undefined) { + try { size = (await stat(full)).size; } catch { continue; } + } + walk.files.push({ path: rel, size }); + } else if (isDir && opts.recursive) { + await visit(full); + } + } + }; + + await visit(start); + return walk; +} + +/** Immediate children of a directory, typed, sizes for files. */ +export async function directListDir(mount: MountState, scopeRelative: string): Promise { + const root = mount.config.path; + const realRoot = await realDirOrNull(root); + if (realRoot === null) throw new DirectFsError('mount_unavailable', `Mount unavailable: ${mount.config.name}`); + const dir = resolve(root, scopeRelative); + const realDir = await realDirOrNull(dir); + if (realDir === null) throw new DirectFsError('not_found', `Directory not found: ${scopeRelative || '/'}`); + if (!isContained(realRoot, realDir)) throw new DirectFsError('escape', `Path resolves outside the mount: ${scopeRelative}`); + let entries; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch (err) { + const code = errnoOf(err); + if (code === 'ENOTDIR') throw new DirectFsError('not_found', `Not a directory: ${scopeRelative}`, code); + throw new DirectFsError('io', `Cannot list ${scopeRelative || '/'}: ${code ?? String(err)}`, code); + } + const ignore = mount.config.ignore ?? []; + const out: DirectEntry[] = []; + for (const entry of entries) { + const rel = toLogical(root, join(dir, entry.name)); + if (shouldIgnore(rel, entry.name, ignore)) continue; + if (entry.isSymbolicLink()) { out.push({ name: entry.name, type: 'symlink' }); continue; } + if (entry.isDirectory()) { out.push({ name: entry.name, type: 'directory' }); continue; } + if (entry.isFile()) { + let size: number | undefined; + try { size = (await stat(join(dir, entry.name))).size; } catch { /* vanished */ } + out.push({ name: entry.name, type: 'file', ...(size !== undefined ? { size } : {}) }); + continue; + } + out.push({ name: entry.name, type: 'other' }); + } + out.sort((a, b) => a.name.localeCompare(b.name)); + return out; +} + +export interface DirectGrepMatch { line: number; text: string; context?: string[] } +export interface DirectGrepResult { + results: Array<{ file: string; matches: DirectGrepMatch[] }>; + truncated: boolean; + skipped: { binary: number; tooLarge: number; unreadable: number }; +} + +/** + * Regex search over text files under a scope (or a single file). Binary and + * oversize files are counted in `skipped`, not silently dropped. + */ +export async function directGrep( + mount: MountState, + scopeRelative: string, + openContained: ContainedOpener, + opts: { regex: RegExp; fileGlob: RegExp | null; contextBefore: number; contextAfter: number; cap?: number }, +): Promise { + const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE; + const result: DirectGrepResult = { results: [], truncated: false, skipped: { binary: 0, tooLarge: 0, unreadable: 0 } }; + + let files: Array<{ path: string; size: number }>; + let single = false; + if (scopeRelative) { + try { + const info = await lstat(resolve(mount.config.path, scopeRelative)); + single = info.isFile() || (info.isSymbolicLink() && mount.config.followSymlinks === true); + } catch { /* treat as directory below; walk reports not_found */ } + } + if (single) { + files = [{ path: scopeRelative, size: 0 }]; + } else { + const walk = await directWalk(mount, scopeRelative, { recursive: true, cap: opts.cap }); + files = walk.files; + result.truncated = walk.truncated; + result.skipped.unreadable += walk.unreadable.length; + } + + for (const file of files) { + if (opts.fileGlob && !opts.fileGlob.test(file.path)) continue; + let content: string; + try { + content = (await directReadText(mount, file.path, `${mount.config.name}/${file.path}`, openContained)).content; + } catch (err) { + if (err instanceof DirectFsError && err.code === 'binary') result.skipped.binary++; + else if (err instanceof DirectFsError && err.code === 'too_large') result.skipped.tooLarge++; + else result.skipped.unreadable++; + continue; + } + if (content.length > maxSize) { result.skipped.tooLarge++; continue; } + const lines = content.split('\n'); + const matches: DirectGrepMatch[] = []; + for (let i = 0; i < lines.length; i++) { + if (!opts.regex.test(lines[i])) continue; + const m: DirectGrepMatch = { line: i + 1, text: lines[i] }; + if (opts.contextBefore > 0 || opts.contextAfter > 0) { + m.context = lines.slice(Math.max(0, i - opts.contextBefore), Math.min(lines.length, i + opts.contextAfter + 1)); + } + matches.push(m); + } + if (matches.length > 0) result.results.push({ file: `${mount.config.name}/${file.path}`, matches }); + } + return result; +} diff --git a/src/modules/workspace/index.ts b/src/modules/workspace/index.ts index 9091ef1fb..3c2b3e6b6 100644 --- a/src/modules/workspace/index.ts +++ b/src/modules/workspace/index.ts @@ -36,6 +36,10 @@ import type { import { WORKSPACE_FS_EVENT_TYPES, opToEventType } from './types.js'; import { MountWatcher, type FsChange } from './watcher.js'; import { syncFromFs, materializeToFs, hashContent, isBinary, DEFAULT_MAX_FILE_SIZE, type ConflictInfo } from './sync.js'; +import { + DirectFsError, directReadText, directWrite, directEdit, directDelete, + directWalk, directListDir, directGrep, type ContainedOpener, +} from './direct.js'; export type { WorkspaceConfig, @@ -606,6 +610,8 @@ export class WorkspaceModule implements Module { private config: WorkspaceConfig; private mounts = new Map(); private watchers = new Map(); + /** Chronicle mounts whose saved state says they last ran as direct: sync disk→store before the first materialize. */ + private resyncBeforeMaterialize = new Set(); /** Persisted state decoded in start(), held until mounts exist to apply it * to — in the Host ordering start() runs before initStore() creates the * mounts, so restoration must be second-callback-safe like watcher @@ -654,15 +660,18 @@ export class WorkspaceModule implements Module { // Register tree states for each mount for (const mount of this.config.mounts) { const treeStateId = `workspace/${mount.name}/tree`; - try { - store.registerState({ - id: treeStateId, - strategy: 'tree', - deltaSnapshotEvery: this.config.deltaSnapshotEvery ?? 50, - fullSnapshotEvery: this.config.fullSnapshotEvery ?? 10, - }); - } catch { - // State already registered (restart scenario) + // A direct mount keeps no tree state: disk is the only copy. + if (mount.backend !== 'direct') { + try { + store.registerState({ + id: treeStateId, + strategy: 'tree', + deltaSnapshotEvery: this.config.deltaSnapshotEvery ?? 50, + fullSnapshotEvery: this.config.fullSnapshotEvery ?? 10, + }); + } catch { + // State already registered (restart scenario) + } } const mountState: MountState = { @@ -712,6 +721,13 @@ export class WorkspaceModule implements Module { if (mount) { mount.lastMaterializedSeq = meta.lastMaterializedSeq; mount.lastMaterializedBranchId = meta.lastMaterializedBranchId ?? null; + // Switched back from direct to chronicle: the tree state is whatever + // it held before the direct stint, disk has moved on without it. A + // materialize from that tree would revert disk, so the first one + // waits for a full disk→store sync. + if (meta.backend === 'direct' && !this.isDirect(mount)) { + this.resyncBeforeMaterialize.add(name); + } // watcherReadyAt intentionally not restored — each session must // observe its own watcher attach, otherwise a stale timestamp // would hide a new-session attach failure. @@ -855,6 +871,9 @@ export class WorkspaceModule implements Module { watcher.start(); this.watchers.set(name, watcher); + // A direct mount has no tree to seed; its listings read disk live. + if (this.isDirect(mount)) continue; + // Chokidar is started with ignoreInitial:true, so files already on // disk at session start would be invisible. Trigger one syncFromFs // pass — syncFromFs diffs disk against the tree state, so only files @@ -875,7 +894,7 @@ export class WorkspaceModule implements Module { private async initialScan(mountName: string): Promise { const store = this.store; const mount = this.mounts.get(mountName); - if (!store || !mount) return; + if (!store || !mount || this.isDirect(mount)) return; try { const result = await syncFromFs(store, mount); @@ -902,6 +921,7 @@ export class WorkspaceModule implements Module { lastMaterializedBranchId: mount.lastMaterializedBranchId ?? undefined, watcherReadyAt: mount.watcherReadyAt, watcherError: mount.watcherError, + backend: this.isDirect(mount) ? 'direct' : 'chronicle', }; } this.ctx.setState(state); @@ -962,12 +982,15 @@ export class WorkspaceModule implements Module { }, { name: 'write', - description: 'Create or overwrite a file in the workspace.', + description: 'Create or overwrite a file in the workspace. With append: true, adds to the end of the file instead ' + + '(creating it if missing). On a direct-backend mount the write lands on disk immediately; on a chronicle mount ' + + 'it lands in the workspace store and reaches disk on materialize (or immediately if the mount auto-materializes).', inputSchema: { type: 'object' as const, properties: { path: { type: 'string', description: 'File path (mount-prefixed)' }, content: { type: 'string', description: 'Content to write' }, + append: { type: 'boolean', description: 'Append instead of replacing (default: false)' }, }, required: ['path', 'content'], }, @@ -999,7 +1022,8 @@ export class WorkspaceModule implements Module { }, { name: 'ls', - description: 'List directory contents from the workspace tree.', + description: 'List directory contents. Without a path, lists the mounts and their backends. Chronicle mounts list the ' + + 'workspace tree; direct mounts list the disk.', inputSchema: { type: 'object' as const, properties: { @@ -1047,7 +1071,9 @@ export class WorkspaceModule implements Module { }, { name: 'materialize', - description: 'Write workspace files to the real filesystem. Use after writing/editing to push changes to disk.', + description: 'Write workspace files to the real filesystem (chronicle mounts only — a direct mount is already on disk ' + + 'and is reported as skipped). Use after writing/editing to push changes to disk. Prefer naming a path: without one, ' + + 'every file changed in the store since the last materialize is written.', inputSchema: { type: 'object' as const, properties: { @@ -1059,7 +1085,8 @@ export class WorkspaceModule implements Module { }, { name: 'sync', - description: 'Pull filesystem state into the workspace. Detects user changes on disk.', + description: 'Pull filesystem state into the workspace (chronicle mounts only — a direct mount always reads disk and ' + + 'is reported as skipped). Detects user changes on disk.', inputSchema: { type: 'object' as const, properties: { @@ -1296,6 +1323,10 @@ export class WorkspaceModule implements Module { } catch (error) { return { error: error instanceof Error ? error.message : String(error) }; } + if (this.isDirect(mount)) { + const fromDisk = await this.readBinaryFromDisk(mountPrefixedPath); + return 'error' in fromDisk ? fromDisk : { data: fromDisk.data }; + } const store = this.getStore(); await this.ensureSynced(mount, relativePath); const entry = store.treeGet(mount.treeStateId, relativePath); @@ -1371,6 +1402,22 @@ export class WorkspaceModule implements Module { return { mount, relativePath }; } + private isDirect(mount: MountState): boolean { + return mount.config.backend === 'direct'; + } + + /** The containment-checked opener, bound to a mount, for the direct backend. */ + private opener(mount: MountState): ContainedOpener { + return (relativePath, mountPrefixedPath) => this.openContainedFile(mount, relativePath, mountPrefixedPath); + } + + private directFailure(err: unknown): ToolResult { + if (err instanceof DirectFsError || err instanceof WorkspaceReadError) { + return { success: false, error: err.message, isError: true }; + } + throw err; + } + private getStore(): JsStore { if (!this.store) throw new Error('WorkspaceModule: store not initialized. Call initStore() first.'); return this.store; @@ -1751,6 +1798,7 @@ export class WorkspaceModule implements Module { * Ensure a file is synced from filesystem if not yet in tree (lazy sync). */ private async ensureSynced(mount: MountState, relativePath: string): Promise { + if (this.isDirect(mount)) return; const store = this.getStore(); const existing = store.treeGet(mount.treeStateId, relativePath); if (existing) return; // Already in tree @@ -1801,22 +1849,30 @@ export class WorkspaceModule implements Module { } let content = generatedContent; if (content === undefined) { - const { mount, relativePath } = this.parsePath(input.path); - const store = this.getStore(); + const { mount, relativePath } = this.parsePath(input.path); + if (this.isDirect(mount)) { + try { + content = (await directReadText(mount, relativePath, input.path, this.opener(mount))).content; + } catch (err) { + return this.directFailure(err); + } + } else { + const store = this.getStore(); - await this.ensureSynced(mount, relativePath); + await this.ensureSynced(mount, relativePath); - const entry = store.treeGet(mount.treeStateId, relativePath); - if (!entry) { - return { success: false, error: `File not found: ${input.path}`, isError: true }; - } + const entry = store.treeGet(mount.treeStateId, relativePath); + if (!entry) { + return { success: false, error: `File not found: ${input.path}`, isError: true }; + } - const blob = store.getBlob(entry.blobHash); - if (!blob) { - return { success: false, error: `Blob not found for: ${input.path}`, isError: true }; - } + const blob = store.getBlob(entry.blobHash); + if (!blob) { + return { success: false, error: `Blob not found for: ${input.path}`, isError: true }; + } - content = blob.toString('utf-8'); + content = blob.toString('utf-8'); + } } if (characterPaging) { const start = Math.min(offsetChars, content.length); @@ -1893,7 +1949,7 @@ export class WorkspaceModule implements Module { let treeError: WorkspaceImageReadError | null = null; try { - const image = this.tryReadImageFromTree(mount, relativePath, input.path, maxSize); + const image = this.isDirect(mount) ? null : this.tryReadImageFromTree(mount, relativePath, input.path, maxSize); if (image) { return { success: true, @@ -1951,17 +2007,37 @@ export class WorkspaceModule implements Module { return { success: false, error: `Mount "${mount.config.name}" is read-only`, isError: true }; } + if (this.isDirect(mount)) { + try { + const written = await directWrite(mount, relativePath, input.path, Buffer.from(input.content, 'utf-8'), { append: input.append === true }); + return { + success: true, + data: { path: input.path, size: written.size, hash: written.hash, ...(written.appended ? { appended: true } : {}) }, + }; + } catch (err) { + return this.directFailure(err); + } + } + const store = this.getStore(); const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE; - if (Buffer.byteLength(input.content) > maxSize) { + + let text = input.content; + if (input.append === true) { + await this.ensureSynced(mount, relativePath); + const existing = store.treeGet(mount.treeStateId, relativePath); + const prior = existing ? store.getBlob(existing.blobHash) : null; + if (prior) text = prior.toString('utf-8') + input.content; + } + if (Buffer.byteLength(text) > maxSize) { return { success: false, error: `Content exceeds max file size (${maxSize} bytes)`, isError: true }; } - const buffer = Buffer.from(input.content, 'utf-8'); + const buffer = Buffer.from(text, 'utf-8'); const blobHash = store.storeBlob(buffer, 'text/plain'); store.treeSet(mount.treeStateId, relativePath, { blobHash, - size: Buffer.byteLength(input.content), + size: buffer.length, mode: 0o644, }); const materializeError = await this.autoMaterialize(mount, relativePath, 'write', buffer); @@ -1977,8 +2053,9 @@ export class WorkspaceModule implements Module { success: true, data: { path: input.path, - size: Buffer.byteLength(input.content), - hash: hashContent(input.content), + size: buffer.length, + hash: hashContent(text), + ...(input.append === true ? { appended: true } : {}), }, }; } @@ -1989,6 +2066,17 @@ export class WorkspaceModule implements Module { return { success: false, error: `Mount "${mount.config.name}" is read-only`, isError: true }; } + if (this.isDirect(mount)) { + try { + const edited = await directEdit(mount, relativePath, input.path, this.opener(mount), { + oldString: input.oldString, newString: input.newString, replaceAll: input.replaceAll, + }); + return { success: true, data: { path: input.path, size: edited.size } }; + } catch (err) { + return this.directFailure(err); + } + } + const store = this.getStore(); await this.ensureSynced(mount, relativePath); @@ -2054,6 +2142,15 @@ export class WorkspaceModule implements Module { return { success: false, error: `Mount "${mount.config.name}" is read-only`, isError: true }; } + if (this.isDirect(mount)) { + try { + await directDelete(mount, relativePath, input.path); + return { success: true, data: { path: input.path, deleted: true } }; + } catch (err) { + return this.directFailure(err); + } + } + const store = this.getStore(); const entry = store.treeGet(mount.treeStateId, relativePath); if (!entry) { @@ -2082,12 +2179,35 @@ export class WorkspaceModule implements Module { name, path: m.config.path, mode: m.config.mode, + backend: this.isDirect(m) ? 'direct' : 'chronicle', })); return { success: true, data: { mounts } }; } const { mount, relativePath } = this.parsePath(input.path); + if (this.isDirect(mount)) { + try { + if (input.recursive) { + const walk = await directWalk(mount, relativePath, { recursive: true }); + return { + success: true, + data: { + path: input.path, + entries: walk.files, + count: walk.files.length, + ...(walk.truncated ? { truncated: true, note: `Listing stopped at ${walk.files.length} files; narrow the path or add ignore patterns.` } : {}), + ...(walk.unreadable.length > 0 ? { unreadable: walk.unreadable } : {}), + }, + }; + } + const entries = await directListDir(mount, relativePath); + return { success: true, data: { path: input.path, entries, count: entries.length } }; + } catch (err) { + return this.directFailure(err); + } + } + // Ensure initial sync — always sync on first access regardless of watch mode, // so that ls/glob/grep see filesystem contents even for unwatched mounts if (!mount.initialSyncDone) { @@ -2150,7 +2270,24 @@ export class WorkspaceModule implements Module { ? [this.parsePath(input.path)] : [...this.mounts.values()].map(m => ({ mount: m, relativePath: '' })); + let truncated = false; for (const { mount, relativePath } of mountsToSearch) { + if (this.isDirect(mount)) { + let walk; + try { + walk = await directWalk(mount, relativePath, { recursive: true }); + } catch (err) { + if (mountsToSearch.length === 1) return this.directFailure(err); + continue; + } + truncated ||= walk.truncated; + for (const file of walk.files) { + const testPath = relativePath ? file.path.slice(relativePath.length + 1) : file.path; + if (regex.test(testPath)) matches.push(`${mount.config.name}/${file.path}`); + } + continue; + } + if (!mount.initialSyncDone) { await syncFromFs(store, mount); mount.initialSyncDone = true; @@ -2173,6 +2310,7 @@ export class WorkspaceModule implements Module { pattern: input.pattern, matches, count: matches.length, + ...(truncated ? { truncated: true, note: 'A direct mount hit the file cap; some files were not visited.' } : {}), }, }; } @@ -2195,8 +2333,26 @@ export class WorkspaceModule implements Module { : [...this.mounts.values()].map(m => ({ mount: m, relativePath: '' })); const results: Array<{ file: string; matches: Array<{ line: number; text: string; context?: string[] }> }> = []; + let truncated = false; + const skipped = { binary: 0, tooLarge: 0, unreadable: 0 }; for (const { mount, relativePath } of mountsToSearch) { + if (this.isDirect(mount)) { + try { + const found = await directGrep(mount, relativePath, this.opener(mount), { + regex, fileGlob, contextBefore, contextAfter, + }); + results.push(...found.results); + truncated ||= found.truncated; + skipped.binary += found.skipped.binary; + skipped.tooLarge += found.skipped.tooLarge; + skipped.unreadable += found.skipped.unreadable; + } catch (err) { + if (mountsToSearch.length === 1) return this.directFailure(err); + } + continue; + } + if (!mount.initialSyncDone) { await syncFromFs(store, mount); mount.initialSyncDone = true; @@ -2251,12 +2407,15 @@ export class WorkspaceModule implements Module { } } + const skippedTotal = skipped.binary + skipped.tooLarge + skipped.unreadable; return { success: true, data: { pattern: input.pattern, results, totalMatches: results.reduce((sum, r) => sum + r.matches.length, 0), + ...(skippedTotal > 0 ? { skipped } : {}), + ...(truncated ? { truncated: true, note: 'A direct mount hit the file cap; some files were not searched.' } : {}), }, }; } @@ -2266,6 +2425,16 @@ export class WorkspaceModule implements Module { const status: Record = {}; for (const [name, mount] of this.mounts) { + if (this.isDirect(mount)) { + status[name] = { + path: mount.config.path, + mode: mount.config.mode, + backend: 'direct', + watch: mount.config.watch ?? 'always', + note: 'Direct backend: tools read and write disk; materialize and sync do not apply.', + }; + continue; + } const entries = store.treeList(mount.treeStateId); const currentSeq = store.currentSequence(); const changes = mount.lastMaterializedSeq > 0 @@ -2276,6 +2445,7 @@ export class WorkspaceModule implements Module { status[name] = { path: mount.config.path, mode: mount.config.mode, + backend: 'chronicle', watch: mount.config.watch ?? 'always', fileCount: entries.length, lastMaterializedSeq: mount.lastMaterializedSeq, @@ -2303,12 +2473,34 @@ export class WorkspaceModule implements Module { return { success: false, error: `Unknown mount: ${input.mount}`, isError: true }; } mountsToMaterialize = [{ name: input.mount, mount: m }]; + } else if (input.path) { + const { mount } = this.parsePath(input.path); + mountsToMaterialize = [{ name: mount.config.name, mount }]; } else { mountsToMaterialize = [...this.mounts.entries()] .filter(([, m]) => m.config.mode === 'read-write') .map(([name, mount]) => ({ name, mount })); } + // Direct mounts have nothing to materialize: every write already landed. + const notApplicable = mountsToMaterialize + .filter(({ mount }) => this.isDirect(mount)) + .map(({ name }) => ({ mount: name, reason: 'direct backend — files are already on disk' })); + mountsToMaterialize = mountsToMaterialize.filter(({ mount }) => !this.isDirect(mount)); + if (mountsToMaterialize.length === 0) { + return { success: true, data: { materialized: [], count: 0, skipped: notApplicable } }; + } + + // A mount that just came back from the direct backend syncs disk into the + // store before its first materialize, so the stale tree never wins. + for (const { name, mount } of mountsToMaterialize) { + if (!this.resyncBeforeMaterialize.has(name)) continue; + const result = await syncFromFs(store, mount); + mount.initialSyncDone = true; + this.emitFsEvents(name, result.synced, result.conflicts); + this.resyncBeforeMaterialize.delete(name); + } + // Branch guard, scoped to the mounts actually being materialized: a // linear continuation (current branch descends from the pinned branch at // or after the pinned seq) passes; genuine divergence refuses unless @@ -2366,12 +2558,13 @@ export class WorkspaceModule implements Module { } } + const skipped = [...blocked, ...notApplicable]; return { success: true, data: { materialized: allWritten, count: allWritten.length, - ...(blocked.length > 0 ? { skipped: blocked } : {}), + ...(skipped.length > 0 ? { skipped } : {}), }, }; } @@ -2413,11 +2606,18 @@ export class WorkspaceModule implements Module { return { success: false, error: `Unknown mount: ${input.mount}`, isError: true }; } mountsToSync = [{ name: input.mount, mount: m }]; + } else if (input.path) { + const { mount } = this.parsePath(input.path); + mountsToSync = [{ name: mount.config.name, mount }]; } else { mountsToSync = [...this.mounts.entries()].map(([name, mount]) => ({ name, mount })); } for (const { name, mount } of mountsToSync) { + if (this.isDirect(mount)) { + allSkipped.push({ mount: name, path: '*', reason: 'direct backend — reads always come from disk' }); + continue; + } let paths: string[] | undefined; if (input.path) { @@ -2472,6 +2672,12 @@ export class WorkspaceModule implements Module { const mount = this.mounts.get(mountName); if (!store || !mount) return; + if (this.isDirect(mount)) { + // Nothing to ingest — the watcher's op is the event. + this.emitFsEvents(mountName, changes.map(c => ({ path: c.path, op: c.op })), []); + return; + } + const touchedPaths = changes.filter(c => c.op !== 'deleted').map(c => c.path); const deletedPaths = changes.filter(c => c.op === 'deleted').map(c => c.path); diff --git a/src/modules/workspace/sync.ts b/src/modules/workspace/sync.ts index 324e72c50..70a0d5b15 100644 --- a/src/modules/workspace/sync.ts +++ b/src/modules/workspace/sync.ts @@ -325,7 +325,7 @@ async function walkDirectory( /** * Simple ignore pattern matching. */ -function shouldIgnore( +export function shouldIgnore( relativePath: string, name: string, patterns: string[], diff --git a/src/modules/workspace/types.ts b/src/modules/workspace/types.ts index e0473a8ca..204b19085 100644 --- a/src/modules/workspace/types.ts +++ b/src/modules/workspace/types.ts @@ -16,6 +16,16 @@ export interface MountConfig { path: string; /** Access mode */ mode: 'read-write' | 'read-only'; + /** + * Storage backend for the mount (default: 'chronicle'). + * - 'chronicle': files live in a Chronicle tree state; disk is a projection + * kept current by `materialize` (store → disk) and `sync` (disk → store). + * Edits are branch-scoped and undoable. + * - 'direct': every tool call reads and writes the directory itself. No + * tree state, nothing to materialize or sync, no copy that can drift + * from disk. Edits are not branch-scoped and not undoable. + */ + backend?: 'chronicle' | 'direct'; /** * Watch mode for filesystem changes: * - 'always': chokidar watches continuously, syncs on debounce @@ -117,6 +127,8 @@ export interface WorkspaceModuleState { lastMaterializedBranchId?: string; watcherReadyAt?: number | null; watcherError?: string | null; + /** Backend the mount ran with when this state was saved. */ + backend?: 'chronicle' | 'direct'; }>; /** Branch ID considered "active" for materialization */ activeBranchId?: string; @@ -150,6 +162,8 @@ export interface WriteInput { path: string; /** Content to write */ content: string; + /** Append to the file instead of replacing it (default: false). Creates the file if missing. */ + append?: boolean; } export interface EditInput { diff --git a/test/workspace-direct-backend.test.ts b/test/workspace-direct-backend.test.ts new file mode 100644 index 000000000..d268c3d34 --- /dev/null +++ b/test/workspace-direct-backend.test.ts @@ -0,0 +1,286 @@ +import test, { type TestContext } from 'node:test'; +import assert from 'node:assert/strict'; +import { + mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync, mkdirSync, symlinkSync, appendFileSync, statSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { JsStore } from '@animalabs/chronicle'; +import { WorkspaceModule } from '../src/modules/workspace/index.js'; +import { directWalk } from '../src/modules/workspace/direct.js'; +import type { ModuleContext } from '../src/types/module.js'; +import type { MountConfig, MountState, WorkspaceModuleState } from '../src/modules/workspace/types.js'; +import type { ToolResult } from '../src/types/events.js'; + +function makeCtx(opts: { + isRestart: boolean; + saved?: WorkspaceModuleState; + events?: unknown[]; + onSave?: (s: WorkspaceModuleState) => void; +}): ModuleContext { + return { + isRestart: opts.isRestart, + getState: () => (opts.saved ?? null) as T | null, + setState: (s: unknown) => opts.onSave?.(s as WorkspaceModuleState), + pushEvent: (e: unknown) => opts.events?.push(e), + } as unknown as ModuleContext; +} + +type Call = (name: string, input?: Record) => Promise; + +function setup(t: TestContext, mounts: Array & { name: string }>) { + const root = mkdtempSync(join(tmpdir(), 'workspace-direct-')); + const store = JsStore.openOrCreate({ path: join(root, 'store') }); + const configs: MountConfig[] = mounts.map(m => { + const path = join(root, m.name); + mkdirSync(path, { recursive: true }); + return { mode: 'read-write', watch: 'never', ...m, path } as MountConfig; + }); + const module = new WorkspaceModule({ mounts: configs }); + module.initStore(store); + t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); }); + const call: Call = (name, input = {}) => module.handleToolCall({ id: name, name, input }); + const data = async (name: string, input?: Record) => { + const r = await call(name, input); + assert.equal(r.success, true, `${name} failed: ${r.error}`); + return r.data as Record; + }; + return { root, store, module, call, data }; +} + +test('direct mount: writes land on disk, reads see shell-side changes, edit and delete act on disk', async t => { + const { root, call, data } = setup(t, [{ name: 'd', backend: 'direct' }]); + const file = join(root, 'd', 'notes', 'log.md'); + + const w = await data('write', { path: 'd/notes/log.md', content: 'one\n' }); + assert.equal(readFileSync(file, 'utf-8'), 'one\n'); + assert.equal(w.size, 4); + + // A shell append is visible to the very next read — there is no second copy to go stale. + appendFileSync(file, 'two\n'); + const r = await data('read', { path: 'd/notes/log.md' }); + assert.equal(r.content, ' 1\tone\n 2\ttwo\n 3\t'); + assert.equal(r.totalLines, 3); + + await data('edit', { path: 'd/notes/log.md', oldString: 'two', newString: 'TWO' }); + assert.equal(readFileSync(file, 'utf-8'), 'one\nTWO\n'); + + const dup = await call('edit', { path: 'd/notes/log.md', oldString: '\n', newString: ';' }); + assert.equal(dup.success, false); + assert.match(dup.error ?? '', /found 2 times/); + await data('edit', { path: 'd/notes/log.md', oldString: '\n', newString: ';', replaceAll: true }); + assert.equal(readFileSync(file, 'utf-8'), 'one;TWO;'); + + await data('delete', { path: 'd/notes/log.md' }); + assert.equal(existsSync(file), false); + const gone = await call('read', { path: 'd/notes/log.md' }); + assert.equal(gone.success, false); + assert.match(gone.error ?? '', /File not found/); + + const status = (await data('status')).d as Record; + assert.equal(status.backend, 'direct'); + assert.equal('fileCount' in status, false); +}); + +test('direct mount: append adds to the end and creates a missing file', async t => { + const { root, data } = setup(t, [{ name: 'd', backend: 'direct' }]); + const file = join(root, 'd', 'LOG.md'); + const first = await data('write', { path: 'd/LOG.md', content: 'entry 1\n', append: true }); + assert.equal(first.appended, undefined, 'creating a missing file is a plain write'); + const second = await data('write', { path: 'd/LOG.md', content: 'entry 2 — ✓\n', append: true }); + assert.equal(second.appended, true); + assert.equal(second.size, statSync(file).size); + assert.equal(readFileSync(file, 'utf-8'), 'entry 1\nentry 2 — ✓\n'); + await data('write', { path: 'd/LOG.md', content: 'fresh\n' }); + assert.equal(readFileSync(file, 'utf-8'), 'fresh\n'); +}); + +test('chronicle mount: append concatenates in the store', async t => { + const { data } = setup(t, [{ name: 'c' }]); + await data('write', { path: 'c/a.txt', content: 'x' }); + const r = await data('write', { path: 'c/a.txt', content: 'y', append: true }); + assert.equal(r.appended, true); + assert.equal((await data('read', { path: 'c/a.txt' })).content, ' 1\txy'); +}); + +test('direct mount: ls, glob and grep read the disk — shell creations visible, deletions gone, binaries reported', async t => { + const { root, call, data } = setup(t, [{ name: 'd', backend: 'direct', ignore: ['*.tmp'] }]); + mkdirSync(join(root, 'd', 'sub')); + writeFileSync(join(root, 'd', 'a.md'), 'alpha needle\n'); + writeFileSync(join(root, 'd', 'sub', 'b.md'), 'beta\nneedle here\n'); + writeFileSync(join(root, 'd', 'sub', 'c.tmp'), 'ignored needle\n'); + const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', 'base64'); + writeFileSync(join(root, 'd', 'img.png'), png); + + const top = await data('ls', { path: 'd' }); + assert.deepEqual(top.entries, [ + { name: 'a.md', type: 'file', size: 13 }, + { name: 'img.png', type: 'file', size: png.length }, + { name: 'sub', type: 'directory' }, + ]); + const rec = await data('ls', { path: 'd', recursive: true }); + assert.deepEqual((rec.entries as Array<{ path: string }>).map(e => e.path).sort(), ['a.md', 'img.png', 'sub/b.md']); + assert.equal(rec.truncated, undefined); + + const mounts = (await data('ls')).mounts as Array<{ name: string; backend: string }>; + assert.deepEqual(mounts, [{ name: 'd', path: join(root, 'd'), mode: 'read-write', backend: 'direct' }]); + + // Same glob semantics as the chronicle backend: `*` never crosses a slash, `**/` needs one. + assert.deepEqual((await data('glob', { pattern: '**/*.md' })).matches, ['d/sub/b.md']); + assert.deepEqual((await data('glob', { pattern: '*.md', path: 'd' })).matches, ['d/a.md']); + + const grep = await data('grep', { pattern: 'needle', path: 'd' }); + assert.deepEqual(grep.results, [ + { file: 'd/a.md', matches: [{ line: 1, text: 'alpha needle' }] }, + { file: 'd/sub/b.md', matches: [{ line: 2, text: 'needle here' }] }, + ]); + assert.deepEqual(grep.skipped, { binary: 1, tooLarge: 0, unreadable: 0 }); + + const one = await data('grep', { pattern: 'beta', path: 'd/sub/b.md' }); + assert.equal(one.totalMatches, 1); + + rmSync(join(root, 'd', 'a.md')); + assert.deepEqual((await data('glob', { pattern: '*.md', path: 'd' })).matches, []); + const asText = await call('read', { path: 'd/img.png' }); + assert.equal(asText.success, false); + assert.match(asText.error ?? '', /Binary file/); + const image = await call('read_image', { path: 'd/img.png' }); + assert.equal(image.success, true, image.error); + assert.equal((image.data as Array<{ type: string }>)[1]?.type, 'image'); +}); + +test('direct mount: materialize and sync report it as not applicable and never touch its files', async t => { + const { root, call, data } = setup(t, [{ name: 'c' }, { name: 'd', backend: 'direct' }]); + const file = join(root, 'd', 'keep.md'); + await data('write', { path: 'd/keep.md', content: 'disk truth\n' }); + appendFileSync(file, 'shell append\n'); + const before = statSync(file).mtimeMs; + + await data('write', { path: 'c/x.md', content: 'chronicle\n' }); + const bare = await data('materialize'); + assert.deepEqual(bare.materialized, [{ mount: 'c', path: 'x.md' }]); + assert.deepEqual(bare.skipped, [{ mount: 'd', reason: 'direct backend — files are already on disk' }]); + assert.equal(readFileSync(join(root, 'c', 'x.md'), 'utf-8'), 'chronicle\n'); + + const named = await data('materialize', { mount: 'd' }); + assert.equal(named.count, 0); + assert.equal((named.skipped as unknown[]).length, 1); + const byPath = await data('materialize', { path: 'd/keep.md' }); + assert.equal(byPath.count, 0); + + const sync = await data('sync', { path: 'd/keep.md' }); + assert.equal(sync.totalSynced, 0); + assert.deepEqual(sync.skipped, [{ mount: 'd', path: '*', reason: 'direct backend — reads always come from disk' }]); + const syncAll = await data('sync'); + assert.equal((syncAll.skipped as Array<{ mount: string }>).some(s => s.mount === 'd'), true); + + assert.equal(readFileSync(file, 'utf-8'), 'disk truth\nshell append\n'); + assert.equal(statSync(file).mtimeMs, before); + const r = await call('materialize', { mount: 'nope' }); + assert.equal(r.success, false); +}); + +test('direct mount: traversal and symlink escapes are refused on every op', async t => { + const { root, call } = setup(t, [{ name: 'd', backend: 'direct' }]); + const outside = join(root, 'outside.txt'); + writeFileSync(outside, 'secret'); + symlinkSync(outside, join(root, 'd', 'link.txt')); + mkdirSync(join(root, 'elsewhere')); + symlinkSync(join(root, 'elsewhere'), join(root, 'd', 'linkdir')); + + for (const [name, input] of [ + ['read', { path: 'd/../outside.txt' }], + ['write', { path: 'd/../x.txt', content: 'no' }], + ['delete', { path: 'd/../outside.txt' }], + ] as const) { + const r = await call(name, input as Record); + assert.equal(r.success, false, name); + assert.match(r.error ?? '', /traversal/, name); + } + for (const [name, input] of [ + ['read', { path: 'd/link.txt' }], + ['write', { path: 'd/link.txt', content: 'no' }], + ['edit', { path: 'd/link.txt', oldString: 's', newString: 'S' }], + ['delete', { path: 'd/link.txt' }], + ['write', { path: 'd/linkdir/new.txt', content: 'no' }], + ] as const) { + const r = await call(name, input as Record); + assert.equal(r.success, false, name); + assert.match(r.error ?? '', /[Ss]ymlink/, `${name}: ${r.error}`); + } + assert.equal(readFileSync(outside, 'utf-8'), 'secret'); + assert.equal(existsSync(join(root, 'elsewhere', 'new.txt')), false); + // Symlinks are listed as such, never followed, and skipped by recursive walks. + const ls = await call('ls', { path: 'd' }); + assert.deepEqual(ls.data, { path: 'd', entries: [{ name: 'link.txt', type: 'symlink' }, { name: 'linkdir', type: 'symlink' }], count: 2 }); + assert.deepEqual((await call('ls', { path: 'd', recursive: true })).data, { path: 'd', entries: [], count: 0 }); +}); + +test('direct walk: the file cap is reported, not silent', async t => { + const { root, module } = setup(t, [{ name: 'd', backend: 'direct' }]); + for (let i = 0; i < 7; i++) writeFileSync(join(root, 'd', `f${i}.txt`), String(i)); + const mount = (module as unknown as { mounts: Map }).mounts.get('d')!; + const walk = await directWalk(mount, '', { recursive: true, cap: 3 }); + assert.equal(walk.files.length, 3); + assert.equal(walk.truncated, true); + const full = await directWalk(mount, '', { recursive: true }); + assert.equal(full.files.length, 7); + assert.equal(full.truncated, false); +}); + +test('switching a mount back from direct to chronicle syncs disk before the first materialize', async t => { + const root = mkdtempSync(join(tmpdir(), 'workspace-direct-switch-')); + const dir = join(root, 'w'); + mkdirSync(dir); + const store = JsStore.openOrCreate({ path: join(root, 'store') }); + t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); }); + const base = { name: 'w', path: dir, mode: 'read-write' as const, watch: 'never' as const }; + const run = (name: string, m: WorkspaceModule, input: Record = {}) => m.handleToolCall({ id: name, name, input }); + + // 1. Chronicle: write v1 and materialize it. + const chron1 = new WorkspaceModule({ mounts: [base] }); + chron1.initStore(store); + let saved: WorkspaceModuleState | undefined; + await chron1.start(makeCtx({ isRestart: false, onSave: s => { saved = s; } })); + assert.equal((await run('write', chron1, { path: 'w/a.md', content: 'v1\n' })).success, true); + assert.equal((await run('materialize', chron1)).success, true); + await chron1.stop(); + assert.equal(readFileSync(join(dir, 'a.md'), 'utf-8'), 'v1\n'); + assert.equal(saved?.mounts.w.backend, 'chronicle'); + + // 2. Direct stint: v2 goes straight to disk; the tree still says v1. + const direct = new WorkspaceModule({ mounts: [{ ...base, backend: 'direct' }] }); + direct.initStore(store); + await direct.start(makeCtx({ isRestart: true, saved, onSave: s => { saved = s; } })); + assert.equal((await run('write', direct, { path: 'w/a.md', content: 'v2\n' })).success, true); + await direct.stop(); + assert.equal(saved?.mounts.w.backend, 'direct'); + + // 3. Back to chronicle: a bare materialize must not revert disk to v1. + const chron2 = new WorkspaceModule({ mounts: [base] }); + chron2.initStore(store); + await chron2.start(makeCtx({ isRestart: true, saved })); + const mat = await run('materialize', chron2); + assert.equal(mat.success, true, mat.error); + assert.equal(readFileSync(join(dir, 'a.md'), 'utf-8'), 'v2\n'); + const read = await run('read', chron2, { path: 'w/a.md' }); + assert.equal((read.data as { content: string }).content, ' 1\tv2\n 2\t'); + await chron2.stop(); +}); + +test('direct mount with watch: always emits filesystem events without a tree', async t => { + const { root, module } = setup(t, [{ name: 'd', backend: 'direct', watch: 'always', watchDebounceMs: 50 }]); + const events: Array<{ type: string; paths?: string[] }> = []; + await module.start(makeCtx({ isRestart: false, events })); + const mount = (module as unknown as { mounts: Map }).mounts.get('d')!; + const deadline = Date.now() + 5000; + while (mount.watcherReadyAt === null && Date.now() < deadline) await new Promise(r => setTimeout(r, 25)); + assert.notEqual(mount.watcherReadyAt, null, 'watcher attached'); + + writeFileSync(join(root, 'd', 'ext.md'), 'from outside\n'); + while (!events.some(e => e.type === 'workspace:created') && Date.now() < deadline) await new Promise(r => setTimeout(r, 25)); + const created = events.find(e => e.type === 'workspace:created'); + assert.deepEqual(created?.paths, ['d/ext.md']); + assert.equal(events.some(e => e.type === 'workspace:initial-scan-failed'), false); + await module.stop(); +}); From 6d3c4aa9be543e516b1a5fd1e55dbf828d4307b9 Mon Sep 17 00:00:00 2001 From: antra-tess Date: Sun, 11 Oct 2026 10:36:00 -0700 Subject: [PATCH 2/2] feat(workspace): default mounts to the direct backend; add `watch` tool `backend` now defaults to 'direct'; `backend: 'chronicle'` opts a mount back into the tree. `writeBinary`/`readBinary`/`materializeMount` honor the backend, and a direct mount's own writes are suppressed from its watcher so they never read as outside changes. New `watch` tool: register a file, directory or mount and be woken with a context line when something other than this agent's tools creates, modifies or deletes under it. Watches persist in module state. A direct mount starts its watcher on demand; a chronicle mount needs watch: 'always'. Tests that exercise chronicle semantics pin backend: 'chronicle'. Co-Authored-By: Claude Fable 5.1 --- changelog.d/workspace-direct-backend.added.md | 37 ++- .../workspace-direct-default.breaking.md | 12 + src/modules/workspace/index.ts | 256 ++++++++++++++---- src/modules/workspace/types.ts | 43 ++- test/workspace-branch-continuation.test.ts | 2 +- test/workspace-direct-backend.test.ts | 95 ++++++- test/workspace-read-image.test.ts | 5 +- test/workspace-restore-order.test.ts | 2 +- 8 files changed, 373 insertions(+), 79 deletions(-) create mode 100644 changelog.d/workspace-direct-default.breaking.md diff --git a/changelog.d/workspace-direct-backend.added.md b/changelog.d/workspace-direct-backend.added.md index 9361b76e4..3954a0ec7 100644 --- a/changelog.d/workspace-direct-backend.added.md +++ b/changelog.d/workspace-direct-backend.added.md @@ -1,16 +1,23 @@ -- Workspace mounts take a `backend` (recipe: `modules.workspace.mounts[].backend`, - default `chronicle`). With `backend: 'direct'` the same tools — `read`, - `write`, `edit`, `delete`, `ls`, `glob`, `grep`, `read_image` — operate on the - mounted directory itself: no Chronicle tree state, so there is no second - copy that can go stale behind shell-side edits, and nothing to materialize - or sync. `materialize` and `sync` report a direct mount under `skipped` - instead of touching it; `status` and a path-less `ls` show each mount's - backend. Direct writes are atomic (temp file + rename), `edit` refuses a - file another writer changed since it was read, containment and symlink - rules match the chronicle backend's disk reads, and recursive listings - report `truncated` when they stop at the file cap rather than going quiet. - Direct edits are not branch-scoped or undoable. A mount switched back from - direct to chronicle syncs disk into the store before its first materialize, - so the stale tree never overwrites disk. +- Workspace mounts take a `backend` (recipe: `modules.workspace.mounts[].backend`). + With `'direct'` — now the default — the same tools (`read`, `write`, `edit`, + `delete`, `ls`, `glob`, `grep`, `read_image`) operate on the mounted + directory itself: no Chronicle tree state, so there is no second copy that + can go stale behind shell-side edits, and nothing to materialize or sync. + `materialize` and `sync` report a direct mount under `skipped` instead of + touching it; `status` and a path-less `ls` show each mount's backend. Direct + writes are atomic (temp file + rename), `edit` refuses a file another writer + changed since it was read, containment and symlink rules match the + chronicle backend's disk reads, and recursive listings report `truncated` + when they stop at the file cap rather than going quiet. `backend: + 'chronicle'` keeps the previous behaviour (branch-scoped, undoable edits + that reach disk on materialize). A mount switched back from direct to + chronicle syncs disk into the store before its first materialize, so the + stale tree never overwrites disk. - `write` takes `append: true` on both backends, adding to the end of the file - (creating it if missing) instead of replacing it. + (creating it if missing) instead of replacing it. On a direct mount the + append is a single `O_APPEND` write, safe to interleave with a shell `>>`. +- New workspace tool `watch`: the agent registers a file or directory (or a + whole mount) and is woken, with a context line naming the path and op, + whenever something other than its own tools creates, modifies or deletes + under it. Watches persist in module state across restarts. On a direct mount + the watcher starts on demand; a chronicle mount needs `watch: 'always'`. diff --git a/changelog.d/workspace-direct-default.breaking.md b/changelog.d/workspace-direct-default.breaking.md new file mode 100644 index 000000000..a6e985406 --- /dev/null +++ b/changelog.d/workspace-direct-default.breaking.md @@ -0,0 +1,12 @@ +- **Recipe authors and hosts:** workspace mounts now default to + `backend: 'direct'`. A mount that omits `backend` reads and writes its + directory directly: its Chronicle tree state is no longer registered or + consulted, `materialize`/`sync` skip it, and its edits are not branch-scoped + or undoable. Set `backend: 'chronicle'` on any mount that needs the tree + (a config mount the host rewinds with `materializeMount`, a mount whose + files must follow conversation branches). Hosts that construct + `MountConfig`s programmatically must pass `backend` through from the recipe + and pin their own tree-dependent mounts to `'chronicle'`. Existing stores + keep their tree states untouched; upgrading a mount to direct abandons the + tree in place (disk is what the resident already saw via shell), and a + later switch back syncs disk into the store before the first materialize. diff --git a/src/modules/workspace/index.ts b/src/modules/workspace/index.ts index 3c2b3e6b6..bcc063539 100644 --- a/src/modules/workspace/index.ts +++ b/src/modules/workspace/index.ts @@ -6,6 +6,7 @@ */ import { constants as fsConstants } from 'node:fs'; +import { randomBytes } from 'node:crypto'; import type { Stats } from 'node:fs'; import { open, readFile, stat, access, writeFile, unlink, mkdir, lstat, realpath } from 'node:fs/promises'; import { join, resolve, relative, dirname, sep } from 'node:path'; @@ -28,6 +29,8 @@ import type { StatusInput, MaterializeInput, SyncInput, + WatchInput, + WorkspaceWatch, WorkspaceCreatedEvent, WorkspaceModifiedEvent, WorkspaceDeletedEvent, @@ -612,6 +615,8 @@ export class WorkspaceModule implements Module { private watchers = new Map(); /** Chronicle mounts whose saved state says they last ran as direct: sync disk→store before the first materialize. */ private resyncBeforeMaterialize = new Set(); + /** Agent-registered path watches, keyed by id. Persisted in module state. */ + private watches = new Map(); /** Persisted state decoded in start(), held until mounts exist to apply it * to — in the Host ordering start() runs before initStore() creates the * mounts, so restoration must be second-callback-safe like watcher @@ -661,7 +666,7 @@ export class WorkspaceModule implements Module { for (const mount of this.config.mounts) { const treeStateId = `workspace/${mount.name}/tree`; // A direct mount keeps no tree state: disk is the only copy. - if (mount.backend !== 'direct') { + if ((mount.backend ?? 'direct') !== 'direct') { try { store.registerState({ id: treeStateId, @@ -716,6 +721,10 @@ export class WorkspaceModule implements Module { private applySavedState(): void { if (!this.savedState || this.mounts.size === 0) return; + for (const w of this.savedState.watches ?? []) { + if (this.mounts.has(w.mount)) this.watches.set(w.id, w); + } + for (const [name, meta] of Object.entries(this.savedState.mounts)) { const mount = this.mounts.get(name); if (mount) { @@ -838,38 +847,7 @@ export class WorkspaceModule implements Module { const watchMode = mount.config.watch ?? 'always'; if (watchMode === 'always') { - const watcher = new MountWatcher( - mount.config, - (changes) => { - this.handleFsChanges(name, changes); - }, - { - onReady: () => { - mount.watcherReadyAt = Date.now(); - mount.watcherError = null; - }, - onError: (err) => { - mount.watcherError = err.message; - this.ctx?.pushEvent({ - type: 'workspace:watcher-error', - mount: name, - path: mount.config.path, - error: err.message, - } as ProcessEvent); - }, - onReattach: () => { - mount.watcherError = null; - void this.initialScan(name); - this.ctx?.pushEvent({ - type: 'workspace:watcher-reattached', - mount: name, - path: mount.config.path, - } as ProcessEvent); - }, - }, - ); - watcher.start(); - this.watchers.set(name, watcher); + this.startWatcher(name, mount); // A direct mount has no tree to seed; its listings read disk live. if (this.isDirect(mount)) continue; @@ -891,6 +869,48 @@ export class WorkspaceModule implements Module { } } + /** + * Attach a chokidar watcher to a mount. Called for `watch: 'always'` + * mounts at startup, and on demand by the `watch` tool for direct mounts + * (no ingest happens there, so a late-started watcher changes nothing but + * event delivery). + */ + private startWatcher(name: string, mount: MountState): void { + if (this.watchers.has(name)) return; + const watcher = new MountWatcher( + mount.config, + (changes) => { + this.handleFsChanges(name, changes); + }, + { + onReady: () => { + mount.watcherReadyAt = Date.now(); + mount.watcherError = null; + }, + onError: (err) => { + mount.watcherError = err.message; + this.ctx?.pushEvent({ + type: 'workspace:watcher-error', + mount: name, + path: mount.config.path, + error: err.message, + } as ProcessEvent); + }, + onReattach: () => { + mount.watcherError = null; + void this.initialScan(name); + this.ctx?.pushEvent({ + type: 'workspace:watcher-reattached', + mount: name, + path: mount.config.path, + } as ProcessEvent); + }, + }, + ); + watcher.start(); + this.watchers.set(name, watcher); + } + private async initialScan(mountName: string): Promise { const store = this.store; const mount = this.mounts.get(mountName); @@ -910,22 +930,26 @@ export class WorkspaceModule implements Module { } } - async stop(): Promise { - // Persist state - if (this.ctx) { - const activeBranchId = this.store ? this.store.currentBranch().id : undefined; - const state: WorkspaceModuleState = { mounts: {}, activeBranchId }; - for (const [name, mount] of this.mounts) { - state.mounts[name] = { - lastMaterializedSeq: mount.lastMaterializedSeq, - lastMaterializedBranchId: mount.lastMaterializedBranchId ?? undefined, - watcherReadyAt: mount.watcherReadyAt, - watcherError: mount.watcherError, - backend: this.isDirect(mount) ? 'direct' : 'chronicle', - }; - } - this.ctx.setState(state); + /** Write per-mount metadata and watches to module state. */ + private persistState(): void { + if (!this.ctx) return; + const activeBranchId = this.store ? this.store.currentBranch().id : undefined; + const state: WorkspaceModuleState = { mounts: {}, activeBranchId }; + for (const [name, mount] of this.mounts) { + state.mounts[name] = { + lastMaterializedSeq: mount.lastMaterializedSeq, + lastMaterializedBranchId: mount.lastMaterializedBranchId ?? undefined, + watcherReadyAt: mount.watcherReadyAt, + watcherError: mount.watcherError, + backend: this.isDirect(mount) ? 'direct' : 'chronicle', + }; } + if (this.watches.size > 0) state.watches = [...this.watches.values()]; + this.ctx.setState(state); + } + + async stop(): Promise { + this.persistState(); // Stop watchers for (const watcher of this.watchers.values()) { @@ -1095,6 +1119,25 @@ export class WorkspaceModule implements Module { }, }, }, + { + name: 'watch', + description: 'Watch a file or directory for changes made outside this agent (a shell, another agent, a person). ' + + 'When a watched path — or anything beneath it — is created, modified or deleted, you are woken with a ' + + 'context line naming the path and op. Your own writes through these tools do not fire it. ' + + 'Call with a path to add a watch (same path again updates it); with remove: true and a path or id to ' + + 'remove one; with no arguments to list. Watches persist across restarts. A direct-backend mount starts ' + + 'watching on demand; a chronicle mount must have watch: "always" in its recipe.', + inputSchema: { + type: 'object' as const, + properties: { + path: { type: 'string', description: 'File or directory to watch (mount-prefixed, e.g. "files/inbox"). A bare mount name watches the whole mount.' }, + ops: { type: 'array', items: { type: 'string', enum: ['created', 'modified', 'deleted'] }, description: 'Ops that fire the watch (default: all)' }, + note: { type: 'string', description: 'Reminder echoed in the wake line, e.g. "review new tickets"' }, + remove: { type: 'boolean', description: 'Remove the watch named by id or path' }, + id: { type: 'string', description: 'Watch id (for remove)' }, + }, + }, + }, ]; } @@ -1156,6 +1199,7 @@ export class WorkspaceModule implements Module { case 'status': return await this.handleStatus(input as unknown as StatusInput); case 'materialize': return await this.handleMaterialize(input as unknown as MaterializeInput); case 'sync': return await this.handleSync(input as unknown as SyncInput); + case 'watch': return await this.handleWatch(input as unknown as WatchInput); default: return { success: false, error: `Unknown tool: ${call.name}`, isError: true }; } @@ -1176,12 +1220,10 @@ export class WorkspaceModule implements Module { const mount = this.mounts.get(mountName); if (!mount) return {}; - const flag = mount.config.wakeOnChange; - if (!flag) return {}; - const op = event.type.slice('workspace:'.length) as WorkspaceFsOp; + const flag = mount.config.wakeOnChange; const opAllowed = flag === true || (Array.isArray(flag) && flag.includes(op)); - if (!opAllowed) return {}; + if (!opAllowed) return this.watchResponse(mountName, op, event); // Inject a context message so the model actually sees the event. // The wake signal alone (requestInference) only starts an inference; the @@ -1230,6 +1272,100 @@ export class WorkspaceModule implements Module { }; } + /** + * Wake for agent-registered watches: every touched path that sits at or + * under a watched path (of a watch that includes this op) produces one + * context line. No match, no wake. + */ + private watchResponse(mountName: string, op: WorkspaceFsOp, event: ProcessEvent): EventResponse { + if (this.watches.size === 0) return {}; + const paths = ((event as { paths?: string[] }).paths ?? []).filter(p => typeof p === 'string'); + const lines: string[] = []; + const hit: string[] = []; + for (const full of paths) { + const rel = full.startsWith(mountName + '/') ? full.slice(mountName.length + 1) : full; + for (const w of this.watches.values()) { + if (w.mount !== mountName || !w.ops.includes(op)) continue; + if (w.path !== '' && rel !== w.path && !rel.startsWith(w.path + '/')) continue; + lines.push(`[workspace watch · ${op} · ${full}${w.note ? ` · ${w.note}` : ''}]`); + hit.push(w.id); + break; + } + } + if (lines.length === 0) return {}; + return { + requestInference: true, + addMessages: [{ + participant: 'user', + content: [{ type: 'text', text: lines.join('\n') }], + metadata: { source: 'workspace', mount: mountName, op, paths, triggered: true, watches: hit }, + }], + }; + } + + private async handleWatch(input: WatchInput): Promise { + const list = () => [...this.watches.values()].map(w => ({ + id: w.id, path: w.path ? `${w.mount}/${w.path}` : w.mount, ops: w.ops, ...(w.note ? { note: w.note } : {}), + })); + + if (input.remove) { + let target: WorkspaceWatch | undefined; + if (input.id) target = this.watches.get(input.id); + else if (input.path) { + const { mount, relativePath } = this.parsePath(input.path); + target = [...this.watches.values()].find(w => w.mount === mount.config.name && w.path === relativePath); + } else { + return { success: false, error: 'remove needs an id or a path', isError: true }; + } + if (!target) return { success: false, error: `No such watch: ${input.id ?? input.path}`, isError: true }; + this.watches.delete(target.id); + this.persistState(); + return { success: true, data: { removed: target.id, watches: list() } }; + } + + if (!input.path) { + return { success: true, data: { watches: list(), count: this.watches.size } }; + } + + const { mount, relativePath } = this.parsePath(input.path); + const name = mount.config.name; + const ops = (input.ops && input.ops.length > 0 ? input.ops : ['created', 'modified', 'deleted']) as WorkspaceFsOp[]; + for (const op of ops) { + if (!['created', 'modified', 'deleted'].includes(op)) { + return { success: false, error: `Unknown op "${op}" — use created, modified or deleted`, isError: true }; + } + } + + if (!this.watchers.has(name)) { + if (this.isDirect(mount) && this.ctx) { + this.startWatcher(name, mount); + } else { + const mode = mount.config.watch ?? 'always'; + return { + success: false, + error: `Mount "${name}" is not being watched (watch: "${mode}"). Set watch: "always" on the mount in the recipe, ` + + `or use the direct backend, which starts a watcher on demand.`, + isError: true, + }; + } + } + + const existing = [...this.watches.values()].find(w => w.mount === name && w.path === relativePath); + const watch: WorkspaceWatch = existing + ? { ...existing, ops, ...(input.note !== undefined ? { note: input.note } : {}) } + : { id: randomBytes(3).toString('hex'), mount: name, path: relativePath, ops, ...(input.note ? { note: input.note } : {}), createdAt: Date.now() }; + this.watches.set(watch.id, watch); + this.persistState(); + return { + success: true, + data: { + watch: { id: watch.id, path: input.path, ops: watch.ops, ...(watch.note ? { note: watch.note } : {}) }, + ...(existing ? { updated: true } : {}), + note: `You will be woken when ${input.path} ${relativePath ? 'or anything beneath it ' : ''}is ${ops.join('/')} by someone other than you.`, + }, + }; + } + // ========================================================================== // Path Resolution // ========================================================================== @@ -1287,6 +1423,15 @@ export class WorkspaceModule implements Module { if (data.byteLength > maxSize) { return { success: false, error: `Content exceeds max file size (${maxSize} bytes)`, isError: true }; } + if (this.isDirect(mount)) { + try { + this.watchers.get(mount.config.name)?.suppress(relativePath); + const written = await directWrite(mount, relativePath, mountPrefixedPath, data); + return { success: true, data: { path: mountPrefixedPath, size: written.size, mimeType } }; + } catch (err) { + return this.directFailure(err); + } + } const store = this.getStore(); const blobHash = store.storeBlob(data, mimeType); store.treeSet(mount.treeStateId, relativePath, { @@ -1403,7 +1548,7 @@ export class WorkspaceModule implements Module { } private isDirect(mount: MountState): boolean { - return mount.config.backend === 'direct'; + return (mount.config.backend ?? 'direct') === 'direct'; } /** The containment-checked opener, bound to a mount, for the direct backend. */ @@ -2009,6 +2154,7 @@ export class WorkspaceModule implements Module { if (this.isDirect(mount)) { try { + this.watchers.get(mount.config.name)?.suppress(relativePath); const written = await directWrite(mount, relativePath, input.path, Buffer.from(input.content, 'utf-8'), { append: input.append === true }); return { success: true, @@ -2068,6 +2214,7 @@ export class WorkspaceModule implements Module { if (this.isDirect(mount)) { try { + this.watchers.get(mount.config.name)?.suppress(relativePath); const edited = await directEdit(mount, relativePath, input.path, this.opener(mount), { oldString: input.oldString, newString: input.newString, replaceAll: input.replaceAll, }); @@ -2144,6 +2291,7 @@ export class WorkspaceModule implements Module { if (this.isDirect(mount)) { try { + this.watchers.get(mount.config.name)?.suppress(relativePath); await directDelete(mount, relativePath, input.path); return { success: true, data: { path: input.path, deleted: true } }; } catch (err) { @@ -2577,7 +2725,7 @@ export class WorkspaceModule implements Module { async materializeMount(mountName: string): Promise { const store = this.getStore(); const mount = this.mounts.get(mountName); - if (!mount || mount.config.mode === 'read-only') return []; + if (!mount || mount.config.mode === 'read-only' || this.isDirect(mount)) return []; // Reset tracking — we're deliberately materializing on the new branch mount.lastMaterializedBranchId = null; diff --git a/src/modules/workspace/types.ts b/src/modules/workspace/types.ts index 204b19085..f0b6b1b0f 100644 --- a/src/modules/workspace/types.ts +++ b/src/modules/workspace/types.ts @@ -17,13 +17,15 @@ export interface MountConfig { /** Access mode */ mode: 'read-write' | 'read-only'; /** - * Storage backend for the mount (default: 'chronicle'). - * - 'chronicle': files live in a Chronicle tree state; disk is a projection - * kept current by `materialize` (store → disk) and `sync` (disk → store). - * Edits are branch-scoped and undoable. + * Storage backend for the mount (default: 'direct'). * - 'direct': every tool call reads and writes the directory itself. No * tree state, nothing to materialize or sync, no copy that can drift * from disk. Edits are not branch-scoped and not undoable. + * - 'chronicle': files live in a Chronicle tree state; disk is a projection + * kept current by `materialize` (store → disk) and `sync` (disk → store). + * Edits are branch-scoped and undoable. Choose this when a mount needs + * history that follows the conversation's branches (e.g. a config mount + * the host rewinds with undo/redo). */ backend?: 'chronicle' | 'direct'; /** @@ -132,6 +134,26 @@ export interface WorkspaceModuleState { }>; /** Branch ID considered "active" for materialization */ activeBranchId?: string; + /** Agent-registered path watches (see the `watch` tool). */ + watches?: WorkspaceWatch[]; +} + +/** + * A path watch registered through the `watch` tool. Fires (wakes the agent + * with a context line) when a filesystem event on the mount touches the path + * itself or anything beneath it. + */ +export interface WorkspaceWatch { + id: string; + /** Mount name */ + mount: string; + /** Path relative to the mount root; '' watches the whole mount. */ + path: string; + /** Ops that fire the watch */ + ops: WorkspaceFsOp[]; + /** Free-text reminder, echoed in the wake line */ + note?: string; + createdAt: number; } // ============================================================================ @@ -235,6 +257,19 @@ export interface SyncInput { mount?: string; } +export interface WatchInput { + /** File or directory to watch (mount-prefixed). Omit everything to list watches. */ + path?: string; + /** Ops that fire the watch (default: all three) */ + ops?: WorkspaceFsOp[]; + /** Reminder echoed in the wake line */ + note?: string; + /** Remove instead of add (by `id` or `path`) */ + remove?: boolean; + /** Watch id, for remove */ + id?: string; +} + // ============================================================================ // Events // ============================================================================ diff --git a/test/workspace-branch-continuation.test.ts b/test/workspace-branch-continuation.test.ts index a8eff3f9f..ea7a2989e 100644 --- a/test/workspace-branch-continuation.test.ts +++ b/test/workspace-branch-continuation.test.ts @@ -46,7 +46,7 @@ function setup(t: TestContext, mountNames: string[] = ['work']) { const makeModule = () => new WorkspaceModule({ mounts: mountNames.map((name) => ({ - name, path: dirs[name], mode: 'read-write' as const, watch: 'never' as const, + name, path: dirs[name], mode: 'read-write' as const, watch: 'never' as const, backend: 'chronicle' as const, })), }); return { store, dirs, makeModule }; diff --git a/test/workspace-direct-backend.test.ts b/test/workspace-direct-backend.test.ts index d268c3d34..5ddb97e29 100644 --- a/test/workspace-direct-backend.test.ts +++ b/test/workspace-direct-backend.test.ts @@ -96,7 +96,7 @@ test('direct mount: append adds to the end and creates a missing file', async t }); test('chronicle mount: append concatenates in the store', async t => { - const { data } = setup(t, [{ name: 'c' }]); + const { data } = setup(t, [{ name: 'c', backend: 'chronicle' }]); await data('write', { path: 'c/a.txt', content: 'x' }); const r = await data('write', { path: 'c/a.txt', content: 'y', append: true }); assert.equal(r.appended, true); @@ -150,7 +150,7 @@ test('direct mount: ls, glob and grep read the disk — shell creations visible, }); test('direct mount: materialize and sync report it as not applicable and never touch its files', async t => { - const { root, call, data } = setup(t, [{ name: 'c' }, { name: 'd', backend: 'direct' }]); + const { root, call, data } = setup(t, [{ name: 'c', backend: 'chronicle' }, { name: 'd', backend: 'direct' }]); const file = join(root, 'd', 'keep.md'); await data('write', { path: 'd/keep.md', content: 'disk truth\n' }); appendFileSync(file, 'shell append\n'); @@ -234,7 +234,7 @@ test('switching a mount back from direct to chronicle syncs disk before the firs mkdirSync(dir); const store = JsStore.openOrCreate({ path: join(root, 'store') }); t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); }); - const base = { name: 'w', path: dir, mode: 'read-write' as const, watch: 'never' as const }; + const base = { name: 'w', path: dir, mode: 'read-write' as const, watch: 'never' as const, backend: 'chronicle' as const }; const run = (name: string, m: WorkspaceModule, input: Record = {}) => m.handleToolCall({ id: name, name, input }); // 1. Chronicle: write v1 and materialize it. @@ -284,3 +284,92 @@ test('direct mount with watch: always emits filesystem events without a tree', a assert.equal(events.some(e => e.type === 'workspace:initial-scan-failed'), false); await module.stop(); }); + +test('backend defaults to direct when the mount does not say', async t => { + const { root, store, data } = setup(t, [{ name: 'm' }]); + const status = (await data('status')).m as Record; + assert.equal(status.backend, 'direct'); + await data('write', { path: 'm/hello.txt', content: 'hi\n' }); + assert.equal(readFileSync(join(root, 'm', 'hello.txt'), 'utf-8'), 'hi\n'); + // No tree state was registered for the mount: nothing was written to a tree. + const tree = (() => { try { return store.treeList('workspace/m/tree'); } catch { return []; } })(); + assert.equal(tree.length, 0); +}); + +test('writeBinary and readBinary on a direct mount go straight to disk', async t => { + const { root, module } = setup(t, [{ name: 'd' }]); + const bytes = Buffer.from([0, 1, 2, 3, 250, 251]); + const w = await module.writeBinary('d/blob.bin', bytes, 'application/octet-stream'); + assert.equal(w.success, true, w.error); + assert.deepEqual(readFileSync(join(root, 'd', 'blob.bin')), bytes); + const r = await module.readBinary('d/blob.bin'); + assert.ok('data' in r && r.data.equals(bytes)); + writeFileSync(join(root, 'd', 'blob.bin'), Buffer.from([9])); + const again = await module.readBinary('d/blob.bin'); + assert.ok('data' in again && again.data.equals(Buffer.from([9]))); +}); + +test('watch tool: wakes on outside changes under the path, ignores own writes, persists, removes', async t => { + // watch: 'never' is what hosts pass by default — the tool starts the watcher on demand for a direct mount. + const { root, module, call, data } = setup(t, [{ name: 'd', watch: 'never', watchDebounceMs: 50 }]); + const events: Array<{ type: string; paths?: string[]; mount?: string }> = []; + let saved: WorkspaceModuleState | undefined; + await module.start(makeCtx({ isRestart: false, events, onSave: s => { saved = s; } })); + mkdirSync(join(root, 'd', 'inbox')); + + const added = await data('watch', { path: 'd/inbox', note: 'tickets', ops: ['created', 'modified'] }); + const id = (added.watch as { id: string }).id; + assert.match(String(added.note), /woken when d\/inbox or anything beneath it is created\/modified/); + assert.equal(saved?.watches?.length, 1); + + const mount = (module as unknown as { mounts: Map }).mounts.get('d')!; + const deadline = Date.now() + 5000; + while (mount.watcherReadyAt === null && Date.now() < deadline) await new Promise(r => setTimeout(r, 25)); + assert.notEqual(mount.watcherReadyAt, null, 'watcher started on demand'); + + // The agent's own write is suppressed. + await data('write', { path: 'd/inbox/mine.md', content: 'me\n' }); + await new Promise(r => setTimeout(r, 700)); + assert.equal(events.filter(e => e.type.startsWith('workspace:') && e.paths?.includes('d/inbox/mine.md')).length, 0); + + // An outside write fires: event → onProcess → wake with a context line. + writeFileSync(join(root, 'd', 'inbox', 't1.md'), 'new ticket\n'); + while (!events.some(e => e.paths?.includes('d/inbox/t1.md')) && Date.now() < deadline) await new Promise(r => setTimeout(r, 25)); + const ev = events.find(e => e.paths?.includes('d/inbox/t1.md')); + assert.ok(ev, 'event delivered'); + const response = await module.onProcess(ev as never, {} as never); + assert.equal(response.requestInference, true); + const text = (response.addMessages?.[0]?.content as Array<{ text: string }>)[0].text; + assert.equal(text, '[workspace watch · created · d/inbox/t1.md · tickets]'); + + // Outside the watched path, or an op the watch does not include: no wake. + assert.deepEqual(await module.onProcess({ type: 'workspace:created', mount: 'd', paths: ['d/other.md'] } as never, {} as never), {}); + assert.deepEqual(await module.onProcess({ type: 'workspace:deleted', mount: 'd', paths: ['d/inbox/t1.md'] } as never, {} as never), {}); + + const listed = await data('watch'); + assert.deepEqual(listed.watches, [{ id, path: 'd/inbox', ops: ['created', 'modified'], note: 'tickets' }]); + + // Survives a restart through saved state. + const again = new WorkspaceModule({ mounts: [{ name: 'd', path: join(root, 'd'), mode: 'read-write', watch: 'never' }] }); + again.initStore((module as unknown as { store: JsStore }).store); + await again.start(makeCtx({ isRestart: true, saved })); + assert.equal(((await again.handleToolCall({ id: 'w', name: 'watch', input: {} })).data as { count: number }).count, 1); + await again.stop(); + + const removed = await data('watch', { remove: true, path: 'd/inbox' }); + assert.equal(removed.removed, id); + assert.deepEqual(await module.onProcess(ev as never, {} as never), {}); + const missing = await call('watch', { remove: true, id: 'nope' }); + assert.equal(missing.success, false); + await module.stop(); +}); + +test('watch tool: a chronicle mount without a watcher is refused with the fix spelled out', async t => { + const { module, call } = setup(t, [{ name: 'c', backend: 'chronicle', watch: 'never' }]); + await module.start(makeCtx({ isRestart: false })); + const r = await call('watch', { path: 'c/notes' }); + assert.equal(r.success, false); + assert.match(r.error ?? '', /watch: "never"/); + assert.match(r.error ?? '', /watch: "always"/); + await module.stop(); +}); diff --git a/test/workspace-read-image.test.ts b/test/workspace-read-image.test.ts index f0d60e24a..114e592c4 100644 --- a/test/workspace-read-image.test.ts +++ b/test/workspace-read-image.test.ts @@ -235,6 +235,7 @@ function setupWorkspace( name: 'work', path: mountDir, mode: options?.mode ?? 'read-write', + backend: 'chronicle', watch: 'never', followSymlinks: options?.followSymlinks, maxFileSize: options?.maxFileSize, @@ -292,6 +293,7 @@ test('tool inventory/schema includes workspace--read_image', async (t) => { path: join(frameworkRoot, 'mount'), mode: 'read-write', watch: 'never', + backend: 'chronicle', }, ], }); @@ -498,6 +500,7 @@ test('process logging persists a redacted image receipt while the live round kee path: mountDir, mode: 'read-write', watch: 'never', + backend: 'chronicle', }, ], }); @@ -631,7 +634,7 @@ test('bare read_image dispatch falls back to disk when the tree blob is utf-8 ma const membrane = new MockMembrane(); const workspaceModule = new WorkspaceModule({ - mounts: [{ name: 'work', path: mountDir, mode: 'read-write', watch: 'never' }], + mounts: [{ name: 'work', path: mountDir, mode: 'read-write', watch: 'never', backend: 'chronicle' }], }); // BARE tool name — the framework-synthesized surface Fable actually called. membrane.pushResponse(createStreamResponse([ diff --git a/test/workspace-restore-order.test.ts b/test/workspace-restore-order.test.ts index 4cf8401a0..49d33df7b 100644 --- a/test/workspace-restore-order.test.ts +++ b/test/workspace-restore-order.test.ts @@ -43,7 +43,7 @@ function setup(t: TestContext) { }); const makeModule = () => new WorkspaceModule({ - mounts: [{ name: 'work', path: mountDir, mode: 'read-write', watch: 'never' }], + mounts: [{ name: 'work', path: mountDir, mode: 'read-write', watch: 'never', backend: 'chronicle' }], }); return { store, mountDir, makeModule }; }