Extract legacy shell workspace - #2101
Conversation
Move existing storage and snapshot Bash behind workspace-legacy, adapt it to Think's Computer-shaped contract, and keep the existing state.* codemode interface across workspace implementations.
Keep workspace types, state compatibility, and provider entrypoints together. Remove the unused generated adapter import and document the deprecated state compatibility bridge.
Extend the structural runtime handle with optional streamed events, cancellation, and callable backend discovery while keeping aggregate result compatibility.
Keep the provider implementation grouped under src/workspace while building the public entrypoint at dist/workspace-legacy.
🦋 Changeset detectedLatest commit: 66f08c5 The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
| async function fileInfo( | ||
| workspace: WorkspaceLike, | ||
| path: string, | ||
| hint?: FileInfoHint | ||
| ): Promise<FileInfo> { | ||
| const stat = await workspaceFilesystem(workspace).stat(path); | ||
| const source = hint ? { ...stat, ...hint } : stat; |
There was a problem hiding this comment.
🔴 Listing a folder fails completely when one item inside it cannot be inspected
Every entry returned by a directory listing is now individually inspected (workspaceFilesystem(workspace).stat(path) at packages/think/src/tools/workspace.ts:275) and a single failing entry rejects the whole listing, so an agent can no longer list, find, or search anything in that folder.
Impact: One broken shortcut file (or a file deleted while the listing runs) makes the agent's list/find/search tools error out instead of returning the other files.
Mechanism: per-entry follow-symlink stat inside Promise.all
workspaceListOps.readDir (packages/think/src/tools/workspace.ts:151-180) and workspaceFindOps.glob (packages/think/src/tools/workspace.ts:184-199) map every directory entry / glob match through fileInfo, which calls fs.stat(path). For the legacy workspace this resolves to LegacyWorkspaceFilesystem.stat (packages/think/src/workspace/types.ts:217-221), which throws a synthetic ENOENT when the underlying Workspace.stat returns null. Workspace.stat resolves symlinks (packages/shell/src/filesystem.ts:499-521), so a dangling symlink — or an entry removed between the readdir and the stat — yields null and the rejection propagates out of Promise.all, failing the entire list/find/grep tool call. Previously ws.readDir() / ws.glob() returned the entries in one shot with no follow-up stat, so unresolvable entries were simply listed.
The same pattern was introduced in packages/think/src/think.ts:7098-7120 (_hostListFiles, used by the extension host bridge) and in packages/think/src/workspace/state.ts:101-119 and :173-179 (codemode state.readdir / state.glob).
Prompt for agents
In packages/think/src/tools/workspace.ts, `fileInfo()` performs a `stat()` per directory entry / glob match, and the callers (`workspaceListOps.readDir`, `workspaceFindOps.glob`) wrap those calls in `Promise.all`. For the legacy workspace, `LegacyWorkspaceFilesystem.stat` throws ENOENT whenever the underlying shell `Workspace.stat` returns null, which happens for dangling symlinks (stat resolves symlinks) and for entries deleted between the listing and the stat. The result is that one unresolvable entry rejects the whole listing, so the `list`, `find` and `grep` tools fail entirely instead of returning the remaining entries. The same pattern exists in `_hostListFiles` in packages/think/src/think.ts and in `WorkspaceStateFilesystem.readDir`/`glob` in packages/think/src/workspace/state.ts. Consider making the per-entry metadata lookup tolerant: fall back to `lstat`, or catch not-found errors per entry and synthesize a FileInfo from the dirent/glob data already available (name, type) with size 0, rather than rejecting the whole batch.
Was this helpful? React with 👍 or 👎 to provide feedback.
| function workspaceFindOps(workspace: WorkspaceLike): FindOperations { | ||
| return { | ||
| glob: (pattern) => ws.glob(pattern), | ||
| readFile: (path) => ws.readFile(path) | ||
| async glob(pattern) { | ||
| const { directory, relativePattern } = splitGlobPattern(pattern); | ||
| const entries = await workspaceFilesystem(workspace).find( | ||
| directory, | ||
| relativePattern | ||
| ); | ||
| return Promise.all( | ||
| entries.map((entry) => | ||
| fileInfo(workspace, entry.path, { | ||
| name: basename(entry.path), | ||
| isFile: entry.type === "file", | ||
| isDirectory: entry.type === "dir", | ||
| isSymbolicLink: false | ||
| }) |
There was a problem hiding this comment.
🟡 Directory listings and file searches issue one extra database query per file
Each file returned by a directory listing or search is looked up a second time (fileInfo(...) per entry at packages/think/src/tools/workspace.ts:186-198) even though the size and type were already returned by the first lookup, so listing or searching a large workspace now costs one extra storage query per file.
Impact: Searching or listing a workspace with many files becomes markedly slower, and a whole-workspace search (grep defaults to matching every file) multiplies the number of storage round-trips by the file count.
Mechanism: information lost at the new filesystem contract forces re-stat
ThinkWorkspaceFilesystem.find returns only { path, type } and readdir returns only dirent-style flags (packages/think/src/workspace/types.ts:26-49), so workspaceFindOps.glob / workspaceListOps.readDir call fileInfo() for every entry to recover size, which issues fs.stat(path) — one SQL query each for the legacy workspace. The underlying Workspace.glob / Workspace.readDir already return full FileInfo rows including size, type and mimeType in a single query (packages/shell/src/filesystem.ts:1040-1104), and the previous implementation passed those straight through. createGrepTool globs **/* by default (packages/think/src/tools/workspace.ts:1177-1178), so a search over an N-file workspace now performs N additional stat queries before reading any content. The same amplification applies to _hostListFiles in packages/think/src/think.ts:7098-7120 and to state.glob/state.readdir in packages/think/src/workspace/state.ts.
Prompt for agents
The new Think workspace filesystem contract (packages/think/src/workspace/types.ts) makes `find` return only `{path, type}` and `readdir` return only name/type flags, so packages/think/src/tools/workspace.ts has to call `stat()` once per entry to rebuild the `FileInfo` (size, mime) that the previous `Workspace.glob`/`Workspace.readDir` already returned in a single query. This turns every `list`, `find` and `grep` call into an N+1 query pattern against DO SQLite (grep globs `**/*` by default). Consider widening the contract so `find`/`readdir` can optionally carry size/mtime, or having the legacy adapter return the richer rows it already has, so the per-entry stat can be skipped.
Was this helpful? React with 👍 or 👎 to provide feedback.
agents
@cloudflare/ai-chat
@cloudflare/codemode
hono-agents
@cloudflare/shell
@cloudflare/think
@cloudflare/voice
@cloudflare/worker-bundler
commit: |
Think's workspace surface is coupled to the current
@cloudflare/shellimplementation. That makes it difficult to add another workspace provider without changing file tools, execution tools, fetch spillover, and state connectors together.This PR extracts the existing shell + just-bash workspace into a separate entry point behind a Think-owned workspace contract with separate filesystem and runtime surfaces.
The existing Shell-backed storage, R2 support, snapshot Bash behavior, and state connector move behind an explicit legacy workspace export. The legacy implementation remains the default, so existing data and behavior do not change.
Existing applications can select the compatibility workspace explicitly and use the new structural surface:
Alternative providers implement the same workspace contract:
All 885 Think worker tests pass. The compatibility changeset documents the new export and preserves the existing output path. A following pull request can add the Computer-backed provider and make it the default; existing legacy data will continue to require the legacy workspace and will not be migrated automatically.