Skip to content

Extract legacy shell workspace - #2101

Open
aron-cf wants to merge 7 commits into
skill-runner-removalfrom
workspace-legacy-stacked
Open

Extract legacy shell workspace#2101
aron-cf wants to merge 7 commits into
skill-runner-removalfrom
workspace-legacy-stacked

Conversation

@aron-cf

@aron-cf aron-cf commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Think's workspace surface is coupled to the current @cloudflare/shell implementation. 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:

import { Think, type ThinkWorkspace } from "@cloudflare/think";
import { LegacyWorkspace } from "@cloudflare/think/workspace-legacy";

export class MyAgent extends Think<Env> {
  override workspace: ThinkWorkspace = new LegacyWorkspace({
    sql: this.ctx.storage.sql,
    r2: this.env.R2,
    name: () => this.name
  });

  async inspectNotes() {
    const notes = await this.workspace.fs.readFile("/notes.md", "utf8");
    const execution = await this.workspace.runtime.exec("wc -w /notes.md");
    const result = await execution.result();

    return { notes, wordCount: result.stdout };
  }
}

Alternative providers implement the same workspace contract:

export interface ThinkWorkspace {
  readonly fs: ThinkWorkspaceFilesystem;
  readonly runtime: ThinkWorkspaceRuntime;
}

export interface ThinkWorkspaceFilesystem {
  readFile(path: string): Promise<ReadableStream<Uint8Array>>;
  readFile(path: string, encoding: "utf8"): Promise<string>;
  readFile(
    path: string,
    options: { encoding?: "utf8" }
  ): Promise<string | ReadableStream<Uint8Array>>;
  stat(path: string): Promise<ThinkWorkspaceStat>;
  lstat(path: string): Promise<ThinkWorkspaceStat>;
  readlink(path: string): Promise<string>;
  readdir(
    path: string,
    options?: { limit?: number }
  ): Promise<ThinkWorkspaceDirent[]>;
  find(
    directory: string,
    pattern?: string
  ): Promise<ThinkWorkspaceFoundEntry[]>;
  writeFile(
    path: string,
    content: string | Uint8Array | ReadableStream<Uint8Array>,
    options?: { mode?: number; exclusive?: boolean }
  ): Promise<void>;
  mkdir(path: string, options?: { recursive?: boolean }): Promise<void>;
  rm(
    path: string,
    options?: { recursive?: boolean; force?: boolean }
  ): Promise<void>;
  symlink(target: string, path: string): Promise<void>;
}

export interface ThinkWorkspaceRuntime {
  isCallable?(id: string): boolean;
  exec(
    source: string,
    options?: {
      cwd?: string;
      encoding?: "utf8";
      backend?: string;
      timeoutMs?: number;
      env?: Record<string, string>;
      input?: ThinkWorkspaceRuntimeValue;
      stdin?: Uint8Array | string;
    }
  ): Promise<ThinkWorkspaceRuntimeHandle>;
}

export interface ThinkWorkspaceRuntimeHandle extends Partial<
  AsyncIterable<ThinkWorkspaceRuntimeEvent>
> {
  result(): Promise<ThinkWorkspaceRuntimeResult>;
  kill?(): Promise<void>;
  [Symbol.dispose]?(): void;
}

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.

@aron added 7 commits August 12, 2026 15:39
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-bot

changeset-bot Bot commented Aug 12, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 66f08c5

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@cloudflare/think Minor
@cloudflare/agent-think Patch

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

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 potential issues.

View 4 additional findings in Devin Review.

Open in Devin Review

Comment on lines +270 to +276
async function fileInfo(
workspace: WorkspaceLike,
path: string,
hint?: FileInfoHint
): Promise<FileInfo> {
const stat = await workspaceFilesystem(workspace).stat(path);
const source = hint ? { ...stat, ...hint } : stat;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +184 to +199
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
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@pkg-pr-new

pkg-pr-new Bot commented Aug 12, 2026

Copy link
Copy Markdown

Open in StackBlitz

agents

npm i https://pkg.pr.new/agents@2101

@cloudflare/ai-chat

npm i https://pkg.pr.new/@cloudflare/ai-chat@2101

@cloudflare/codemode

npm i https://pkg.pr.new/@cloudflare/codemode@2101

hono-agents

npm i https://pkg.pr.new/hono-agents@2101

@cloudflare/shell

npm i https://pkg.pr.new/@cloudflare/shell@2101

@cloudflare/think

npm i https://pkg.pr.new/@cloudflare/think@2101

@cloudflare/voice

npm i https://pkg.pr.new/@cloudflare/voice@2101

@cloudflare/worker-bundler

npm i https://pkg.pr.new/@cloudflare/worker-bundler@2101

commit: 66f08c5

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant