DEV Community

Hirohisa Tanaka
Hirohisa Tanaka

Posted on

I Migrated a Cloudflare Agents Workspace and Measured Everything: What Changes When the Storage Format Changes?

The difference was not the table names.

TL;DR

On October 20, 2026, Cloudflare Agents switched its Workspace implementation from @cloudflare/shell to @cloudflare/computer. The change is not just a new API name. It changes how files are stored. The old implementation stored one row per path, with the file contents base64-encoded in that same row. The new one separates inodes and chunks, storing content-addressed blobs in 512 KiB pieces.

I ran the old and new implementations in the same Durable Object and compared the data before and after migration.

  • The tables are different, so old files are invisible after switching without migration. They are still there, though.
  • Storing a whole file in one row changes the size limit and performance. The old implementation hit a limit at about 6 MiB, and its 1 MB read/write cycle was 13.5 times slower.
  • The schema is versioned, so it migrates forward automatically but cannot go backward.
Aspect Old @cloudflare/shell New @cloudflare/computer
Tables cf_workspace_<namespace> vfs_* / _vfs_*
Representation of one file One row (path TEXT PRIMARY KEY) Inode + 512 KiB chunks
Where content lives Base64 in the same row's content column Blob addressed by SHA-256
Observed file-size limit About 6 MiB without R2 No single-row limit; content is chunked
1 MB write + read 87.25 ms 6.45 ms
Same content at two paths Stored twice Both paths share one blob row
Reopen with old implementation Works Fails with EIO

Test code: GitHub repository

Here is the main point upfront.

The old and new implementations differ in storage format, not API. Once you understand that, the reason files disappear after switching without a migration, why the file-size limit changes, why the new implementation is faster, and why you cannot switch back all follow from the same underlying change. The migration guide says to copy files manually because the tables differ. The deeper difference is their structure, not their names.

This article expands on my talk about migrating a Cloudflare Agents Workspace with measurements I could not fit into the presentation. It is for people using Cloudflare Agents who are preparing to move to @cloudflare/computer.


1. The announcement

On October 20, 2026, Cloudflare posted this changelog:

Think and Agent Skills now use the workspace from @cloudflare/computer as well as the filesystem tools.
@cloudflare/shell is deprecated, and so is Think's createExecuteTool. These will no longer be developed nor supported from today onwards.

On the same day, Cloudflare published @cloudflare/think@0.21.0, @cloudflare/computer@0.6.0, and agents@0.29.0. @cloudflare/shell also received version 0.5.0 that day to announce its deprecation. npm's deprecated flag applies retroactively to earlier versions too, so 0.4.3 shows the same warning.

The migration guide itself had landed earlier, in PR #2564 on October 14. It did not arrive at the same time as the announcement.

The Changelog also says:

This is a breaking change. @cloudflare/computer stores files in different tables from @cloudflare/shell, and there is no automatic data migration between workspace formats.

“Different tables.” That is where I started. Let's see how different they are.

2. The old Workspace stored one file per row

A Workspace is where an agent stores its files. A model interacts with them through read, write, edit, and ls; the files themselves live in Durable Object SQLite.

The old @cloudflare/shell schema is included in the migration guide:

CREATE TABLE cf_workspace_default (
  path             TEXT PRIMARY KEY,
  parent_path      TEXT NOT NULL,
  name             TEXT NOT NULL,
  type             TEXT NOT NULL CHECK(type IN ('file','directory','symlink')),
  mime_type        TEXT NOT NULL DEFAULT 'text/plain',
  size             INTEGER NOT NULL DEFAULT 0,
  storage_backend  TEXT NOT NULL DEFAULT 'inline' CHECK(storage_backend IN ('inline','r2')),
  r2_key           TEXT,
  target           TEXT,
  content_encoding TEXT NOT NULL DEFAULT 'utf8',
  content          TEXT,
  created_at       INTEGER NOT NULL DEFAULT (unixepoch()),
  modified_at      INTEGER NOT NULL DEFAULT (unixepoch())
);
Enter fullscreen mode Exit fullscreen mode

It is straightforward: the path is the primary key, and the metadata and content live in the same row. The content column holds file contents, base64-encoded for binary files.

Large files are handled separately. The default threshold is 1,500,000 bytes. Files above that threshold are moved to R2, leaving only an r2_key in the row. If no R2 bucket is provided, the implementation logs a warning and keeps the file inline:

if (size >= this.threshold && !r2)
  console.warn(`[Workspace] File ${path} is ${size} bytes but no R2 bucket was provided. Storing inline.`);
Enter fullscreen mode Exit fullscreen mode

This “put everything in one row” design matters later.

3. The new Workspace separates inodes and chunks

The new @cloudflare/computer uses several tables:

Table Stores
vfs_dirents Parent inode + name → child inode
vfs_nodes Inode metadata (type / mode / mtime / size / link_target)
vfs_chunks Inode + chunk index → blob SHA-256
vfs_blobs SHA-256 → size and last-reference time
vfs_blob_bytes SHA-256 → actual bytes
vfs_meta schema_version and revision counter

Path resolution walks vfs_dirents from the root (inode = 1). File contents are split into 512 KiB chunks, each stored in vfs_blob_bytes as a blob keyed by SHA-256:

const CHUNK_SIZE = 524288;
Enter fullscreen mode Exit fullscreen mode

The tables start with vfs_ or _vfs_, so they do not collide with cf_workspace_*. Both formats can coexist during migration.

From here, I measured what follows from the structural difference.

4. Consequence #1: switching does not make old files visible

I put both implementations on the same Durable Object. Splitting them across two DO namespaces would not test migration on the same storage. I switched between them inside one class.

I wrote 13 items with the old implementation, initialized the new one, and tried reading them:

Read through old Workspace before switch → {found: true, length: 23}
Initialize new Workspace
Read /workspace/notes.md through new     → ENOENT no such file
Read /notes.md through new               → ENOENT no such file
New-side tree                            → [{path: "/workspace", type: "dir"}]
Enter fullscreen mode Exit fullscreen mode

The new implementation could not see the files.

If I stopped here, the article would say “the data is gone.” That would be quick—and wrong.

Add two controls

“Not visible through the new implementation” does not tell us whether the original write failed. In the same run, I verified that the old Workspace could still read the file both before and after switching:

Read through old before switch        → {found: true, length: 23}
Read through old after switch         → {found: true, length: 23}
Rows in cf_workspace_default          → 14 (same as before)
Enter fullscreen mode Exit fullscreen mode

The old implementation could still read the file after the switch. The row count had not changed either.

The data was not gone. The new implementation was looking somewhere else. A table listing showed both formats coexisting:

cf_workspace_default
computer_execution_runtime
sqlite_sequence
vfs_blob_bytes   vfs_blobs    vfs_changes   vfs_chunks
vfs_dirents      vfs_manifests  vfs_meta    vfs_nodes
_vfs_fetch_cursor  _vfs_mounts  _vfs_push_cursor
_vfs_sync_operations  _vfs_sync_skips  _vfs_watermark
Enter fullscreen mode Exit fullscreen mode

I also learned that all vfs_* tables are created as soon as you call new Workspace(). They do not wait for the first filesystem operation. The schema_version was 10.

5. Consequence #2: the size limit changes when everything fits in one row

The old implementation base64-encodes file contents and puts them in one cell. A 4 MB file becomes 5,592,408 base64 characters.

Meanwhile, the Durable Objects Limits page says:

Item Documented limit
Maximum string, BLOB, or table row size 2 MB
KV key size Key and value combined cannot exceed 2 MB

A 4 MB file should not fit in the old Shell. I predicted that while designing the benchmark and prepared files of 1 KB, 100 KB, 1 MB, and 4 MB.

They all worked.

My prediction was wrong.

4 MB → storage_backend=inline, content_encoding=base64,
       content_len=5,592,408 → read-back SHA-256 matches
8 MB → Error: string or blob too big: SQLITE_TOOBIG
Enter fullscreen mode Exit fullscreen mode

I got the same result in local Miniflare and a deployed production Durable Object.

Measurement: where is the actual limit?

Stepping through the library would only reveal its implementation constant, not the underlying limit, so I called ctx.storage.sql and ctx.storage.kv directly to find the boundary. Each probe used a new Durable Object.

Largest accepted  8,388,627 bytes
Smallest rejected 8,388,628 bytes
Enter fullscreen mode Exit fullscreen mode

8 MiB is 8,388,608 bytes. TEXT, BLOB, and a KV value all failed at the same boundary.

That is four times the documented 2 MB. I repeated probes around the boundary on a new Durable Object and got the same result. I reported it in cloudflare-docs#34502. Since I could not rule out 2 MB being the guaranteed limit and 8 MiB merely an implementation allowance, I did not open a PR asserting a new limit; I attached a reference branch instead.

Why does the old implementation stop at 6 MiB?

The old Shell hit its own boundary below the raw SQL limit:

Largest file accepted by shell.writeFileBytes  6,291,408 bytes (8,388,544 base64 chars)
Smallest rejected                             6,291,409 bytes (8,388,548 base64 chars)
Enter fullscreen mode Exit fullscreen mode

Base64 expands the content by about 4/3, so that works out to roughly 6 MiB of file data.

I could not explain why the Shell fails earlier than raw SQL at 8,388,627 bytes. I reproduced its DDL, indexes, INSERT statement, ON CONFLICT DO UPDATE clause, and base64 string, then ran them against the same Durable Object. Raw SQL accepted the value. I also suspected that UPSERT might duplicate the value through excluded, but an UPSERT against an existing row accepted the same size.

The stack trace points to the Shell's INSERT:

Error: string or blob too big: SQLITE_TOOBIG
  at Object.run (index.js:367:17)
  at Workspace.writeFileBytes (index.js:652:22)
Enter fullscreen mode Exit fullscreen mode

I stopped there. For practical purposes, remember that “without R2, the old Shell's file-size limit is about 6 MiB.”

The new Computer splits content into 512 KiB chunks, so this particular wall is gone. Files of 8 MB, 16 MB, and 32 MB all worked. Chunking may look like a performance design, but it also changes the size limit.

6. Consequence #3: base64 conversion on every operation is slower

I hit an oddity here: Date.now() does not advance inside a Worker. In production, even a 4 MB write was recorded as 0ms. It advanced in local Miniflare, so at first I thought I was misreading the production logs.

I measured from outside instead. I sent one request that repeated the operation 60 times and a control request that did nothing, then divided the difference by 60. The table shows the median of seven runs:

Size Old Shell New Computer
1 KB 1.58 ms -0.75 ms
100 KB 4.92 ms 0.80 ms
1 MB 87.25 ms 6.45 ms

I cannot draw conclusions from the 1 KB and 100 KB results. As the negative value shows, the differences are buried in HTTP round-trip variance.

At 1 MB, the gap was clear: 13.5×. The old Shell base64-encodes a 1 MB file into one row on every write and decodes it on every read, adding overhead. My measurement includes the write and the read together, so I cannot say which one is slower on its own.

7. Consequence #4: content addressing enables deduplication

The new implementation uses the blob's SHA-256 as its primary key. The same bytes can be shared by files anywhere in the tree.

I wrote the same 512 KiB of content to two paths and counted rows in vfs_blobs:

Initial blob count                 → 0
Write identical content to path 1  → 1
Write identical content to path 2  → 1  (no increase)
Control: write different content  → 2  (increases)
Enter fullscreen mode Exit fullscreen mode

Without the control, I could not distinguish deduplication from an implementation that never added a blob at all. The different byte sequence confirmed that a second row is created when the content differs.

The old implementation stores content per path, so putting the same file in two places uses storage twice.

8. Consequence #5: there is nowhere to put some metadata

The old FileInfo has mimeType, createdAt, and updatedAt. The new WorkspaceStatResult has only these fields:

{ name, inode, mode, mtime, size, isFile, isDirectory, isSymbolicLink }
Enter fullscreen mode Exit fullscreen mode

There is no API in workspace.fs to read or write MIME types. There is only one timestamp, mtime.

The migration guide also says this metadata is not retained:

Things the copy does not keep:

  • created_at and modified_at. Computer stamps the time of the copy.
  • mime_type. Computer does not store a MIME type per file.

In my measurements, every migrated file's mtime was the time the copy ran. It is not just that the data is “not retained”; the new schema has nowhere to store it.

9. Consequence #6: you can move forward, but not backward

The new implementation versions its schema. Its initialization code looks like this:

const onDiskVersion = db.one("SELECT v FROM vfs_meta WHERE k = ?", "schema_version")?.v ?? 0;
if (onDiskVersion > 10) throw createWorkspaceError("EIO", `Unsupported workspace filesystem schema version ${onDiskVersion}`);
if (onDiskVersion > 0 && onDiskVersion < 10) runMigrations(db, onDiskVersion, 10);
Enter fullscreen mode Exit fullscreen mode

Migrations run forward automatically. There are nine registered migrations, from v1 to v10.

There is no migration backward.

Rather than deploying an old version, I changed the on-disk schema_version to 11 to simulate a database newer than the binary, then ran the same initialization path:

Current schema_version                 → 10
Control: reconstruct with same version → {read: "hello"}
Set schema_version to 11
Open again with older binary            → EIO Unsupported workspace filesystem schema version 11
Set schema_version back to 10
Can it open now?                        → {read: "hello"}
Enter fullscreen mode Exit fullscreen mode

What I measured is that the binary cannot open a database whose on-disk schema_version is newer than the version it supports. This means “you cannot roll back” only if a new release raises the schema version. It has already been raised nine times, so it is reasonable to expect that it could happen again.

Neither the migration guide nor the Changelog mentions this. There is one sentence in the implementation document 03_filesystem_schema.md:

Open() refuses to run if the binary is older than the on-disk schema_version.

The old data remains, so if migration fails you can still read from the old tables. The inability to roll back is the less recoverable part.

10. Running the migration guide's script

Now that I understood the structural difference, I ran the migration.

The guide provides a copy function named copyShellWorkspace(). It walks the old Workspace with glob("/**") and writes the files through the new fs API. The destination is under /workspace, which Think also uses as its working directory.

I ran the script without changing it. My test data included UTF-8 Japanese text, TypeScript in a nested directory, JSON for exact-content comparison, 64 KB of binary data generated with a deterministic pseudorandom sequence, an empty directory, and four symlinks covering relative, absolute, and root-escaping targets.

comparison → {
  total: 13, missing: [], typeChanged: [],
  contentMismatch: [], extra: []
}
Enter fullscreen mode Exit fullscreen mode

All 13 items existed after migration, their types had not changed, and the 64 KB binary's SHA-256 matched. The empty directory remained too. I got the same result in local wrangler dev and in a deployed production environment.

I also verified that the hash comparison could actually detect a mismatch. I changed one bit in a byte sequence and confirmed that it produced a different hash: af2ba40f… versus 73734ea1…. A comparison that cannot detect a mismatch does not prove that two files match.

I checked one assumption the script relies on as well. A comment says, “glob returns rows ordered by path, so parents come before children.” If that were false, writeFile could run before mkdir. In my tests, results were sorted lexicographically, so each parent appeared before its children.

Symlinks need one extra check

When I followed each link individually, one of them failed:

Link Target in Shell Target after migration Can follow it?
/links/rel.md ../notes.md /workspace/notes.md Yes
/links/abs.md /notes.md /workspace/notes.md Yes
/links/dotdot.md /src/../notes.md /workspace/src/../notes.md Yes
/links/esc.md /../notes.md /workspace/../notes.md ENOENT

/workspace/../notes.md resolves to /notes.md, which is outside the migration destination /workspace.

The cause is in the script's resolveTarget():

// Shell resolves a relative target against the link's directory and never
// goes above `/`.
function resolveTarget(link: string, target: string): string {
  if (target.startsWith("/")) return target;   // Absolute paths pass through
  const parts = link.split("/").slice(1, -1);
  for (const part of target.split("/")) {
    if (part === "..") parts.pop();
    else if (part !== "" && part !== ".") parts.push(part);
  }
  return `/${parts.join("/")}`;
}
Enter fullscreen mode Exit fullscreen mode

The @cloudflare/shell implementation (resolveSymlink) also sends absolute paths through normalizePath. normalizePath stops .. at the root, so /../notes.md becomes /notes.md in Shell. The migration script passes the path through unchanged. When it prefixes /workspace, the target escapes that directory.

The conditions are narrow. Relative paths all migrate correctly. Absolute paths containing ., //, or .. that does not go above the root also work. The only failure is an absolute path containing .. that goes above the root.

The script does not report an error. symlink() saves the target without validating it, and following it stops at the root rather than throwing. This is like a legal dangling link on POSIX. The copy completes successfully.

A path/type/hash comparison will not catch this. The link exists, so it is not missing. Its type is still symlink, so the type matches. It has no file content, so it is not included in the hash comparison. My own comparison script reported missing: [], contentMismatch: []. To catch this, you have to follow and read the link:

await to.fs.readFile(linkPath, "utf8");   // ENOENT if the target cannot be followed
Enter fullscreen mode Exit fullscreen mode

A second issue is that the migration is not safe to rerun:

First migration  → ok
Second migration → EEXIST path exists: /workspace/links/abs.md
Enter fullscreen mode Exit fullscreen mode

mkdir({ recursive: true }) silently succeeds for an existing directory. writeFile overwrites unless exclusive is specified. symlink(target, path) has no equivalent of a force option; if the path already exists, it always throws EEXIST. That matches Node's fs.symlink behavior and is not a library bug.

The guide's onStart example sets a flag after copying completes. If the copy fails partway through, the flag is never set and the next startup fails again on the same link. Remove the link before recreating it:

const link = at(entry.path);
if (await to.fs.readlink(link).then(() => true, () => false)) {
  await to.fs.rm(link);
}
await to.fs.symlink(at(target), link);
Enter fullscreen mode Exit fullscreen mode

I reported these two issues in cloudflare/agents#2645. Before filing, I checked for duplicates and found that both had been raised in earlier reviews. One did not survive a PR replacement; the other had been marked resolved based on the incorrect assumption that absolute paths were already rebased. Checking first meant I did not present either as a new discovery.

If your Workspace has no symlinks, you will not hit this part.

11. Persistence survives hibernation

The storage format changed, but the files still live in Durable Object SQLite. I checked whether they survive restarts.

“Readable from another request” is not enough evidence. The same instance might still be in memory and return the file from there. I generated a crypto.randomUUID() in the constructor and included it in the response. If that value changes, the Durable Object is a new incarnation.

                     bootId                                  Read
1. Write             1cadfbdd-7595-4879-94c1-43abca4688c0   ok
2. Read immediately  1cadfbdd-7595-4879-94c1-43abca4688c0   ok   (same)
3. After 15 seconds  e4f5f2ab-3d7d-4389-8e2d-ee32fdd97b02   ok   (changed)
4. After 150 seconds c602f7f1-c9f6-465e-9b36-0c3259706d64   ok   (changed again)
Enter fullscreen mode Exit fullscreen mode

Reads 3 and 4 came from different incarnations. The file content included the old bootId from when it was written, showing it was the same data.

I decided which fields to record before running the test: a case number for writing back the verdict, bootId to show whether a restart happened, env to distinguish local from production, sha256 and size to identify the file, the operation's elapsed time, and ok and error fields that hold results instead of throwing exceptions.

This control proved useful. After redeploying, my first immediate read succeeded, but the bootId was unchanged. The DO was still in memory and running the old code; it had not restarted. Without the control, I might have concluded that the file persisted across a restart. I waited 15 seconds and tried again, then confirmed that the bootId changed and the file was still readable.

How long until hibernation?

I varied the wait time and checked bootId. The zero-second wait was the control:

Wait (s)  Restarted  Read
      0   No         ok   (control)
      3   No         ok
      6   No         ok
      9   No         ok
     11   Yes        ok
     13   Yes        ok
     15   Yes        ok
Enter fullscreen mode Exit fullscreen mode

The boundary is between 9 and 11 seconds, matching the documentation's “hibernate after 10 seconds of inactivity.”

12. Before you migrate

  • Count your symlinks. Run glob("/**") and count entries with type === "symlink". If there are none, the official script should work as-is.
  • If you have links, follow and read them after migration. Path/type/hash comparisons will not catch the issue.
  • Check for files larger than 6 MiB. If the old implementation stored them in R2, pass the same r2 bucket and name during migration.
  • Deploy assuming you cannot roll back. Once the new schema has been written, an older @cloudflare/computer cannot open that Durable Object.
  • Do not delete the old data immediately. The old tables remain, so wait to DROP TABLE until your comparison is done.

The dependency arrangement changes too. In @cloudflare/think@0.20.2, @cloudflare/shell was a direct dependency; you did not install it yourself. In 0.21.0, @cloudflare/computer is a peer dependency. The mechanism behind “I upgraded and my Workspace is empty” is right there.

13. Conclusion

The storage format changed, and the rest followed from it:

  • Different tables → files are invisible after switching without a migration, but the old tables remain and the old Workspace can still read them.
  • One row versus chunks → the old implementation hits a limit at about 6 MiB; the new one accepts files of 32 MB.
  • Base64 conversion on every operation → a 13.5× difference in the 1 MB read/write test.
  • Content-addressed blobs → the same content at two paths takes one blob row.
  • Fewer metadata columns → there is nowhere to store mime_type or created_at.
  • A versioned schema → it migrates forward automatically and cannot be opened by an older implementation after the version changes.

The migration guide says to copy files manually because the tables differ. The difference is not just the table names; it is the structure. Understanding that structure tells you what to verify before you migrate.

I also found two mismatches with the documentation. The Durable Object SQLite value limit I tested was 8 MiB, not 2 MB, and the user-facing docs do not mention that an older implementation cannot open a Durable Object after its schema has been upgraded.

The most surprising result was that a 4 MB file worked. I had predicted the result from the official limit and got it wrong. Because I had trusted the documented number, I no longer trusted my other predictions once it failed. I ended up measuring both the size limit and performance myself. That took more time than the migration itself.

References

Top comments (2)

Collapse
 
officialmailkr profile image
오피셜메일 • • Edited

새 구현에서 파일이 안 보이는 것과 원본 데이터가 사라진 것을 대조군으로 분리한 과정이 특히 좋았습니다. 내용 해시가 같아도 MIME·시각 정보와 롤백 가능 범위는 별도라는 점이 마이그레이션 체크리스트에 중요하겠네요. 작은 파일의 음수 측정값을 성능 향상으로 해석하지 않은 설명도 비교 결과의 한계를 이해하는 데 도움이 됐습니다.

Collapse
 
tanahiro2010 profile image
Hirohisa Tanaka •

Thank you for the thoughtful comment!

One of my main goals was to distinguish between data becoming inaccessible and data actually being lost, since those can look very similar during a migration.

I also wanted to avoid drawing conclusions from measurements that were too small or noisy to be meaningful.

I'm really glad those details were useful to you. Thanks for reading!