Skip to content

Commit ef102b5

Browse files
committed
feat(workspace): configure pull ignore paths
1 parent 45a5719 commit ef102b5

9 files changed

Lines changed: 138 additions & 28 deletions

File tree

docs/02_sync_protocol.md

Lines changed: 14 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -407,14 +407,12 @@ first-class conflict primitives.
407407

408408
## Ignore lists
409409

410-
411-
The `ignore` option hides path segments from the pull. Excluded
412-
paths are still written and read inside the container — the bytes just
413-
never cross the wire back to the DO. This is essential for any large
414-
directory of derived files: `node_modules`, `.next`, `target`,
415-
`__pycache__`, `dist`. Without an ignore, a single `npm install` would
416-
push tens of thousands of small files through the sync wire on the
417-
next pull.
410+
`WorkspaceOptions.ignore` omits matching path segments from each backend
411+
pull. Filtered paths remain available on the backend that created them, but
412+
their bytes do not cross the wire into the Durable Object. This is essential
413+
for any large directory of derived files: `node_modules`, `.next`, `target`,
414+
`__pycache__`, `dist`. Without an ignore, a single `npm install` would send
415+
tens of thousands of small files through the sync wire on the next pull.
418416

419417
The default is `["node_modules"]`, applied server-side when `ignore` is
420418
omitted. A caller-supplied list **replaces** the default — it does not
@@ -423,12 +421,11 @@ list (including `"node_modules"` if you still want it) to customise.
423421

424422
### Ignored entries
425423

426-
Ignored paths are **invisible to the `Workspace.fs` API**. They do not
427-
appear in `readdir`, `stat` returns `ENOENT`, and `readFile` returns
428-
`ENOENT`. The bytes still live inside the container, so anything that
429-
*uses* the ignored files — `exec("node ...")`, build tools, anything
430-
running container-side — keeps working. The exclusion only affects what
431-
crosses the wire **and** what the DO-side API surfaces.
424+
Paths filtered from a pull do not appear in `Workspace.fs`: they are absent
425+
from `readdir`, and `stat` and `readFile` return `ENOENT`. Commands on the
426+
backend that created the paths can still use them. The setting only filters
427+
backend-to-host pulls; matching files written through `Workspace.fs` remain
428+
visible and can still be pushed to backends.
432429

433430
This is a deliberately narrow surface for the initial release. Whether
434431
ignored entries should be representable to the DO at all (as stubs, as
@@ -442,7 +439,7 @@ case depends on a particular resolution.
442439

443440
### Representing ignored entries to the DO
444441

445-
Today ignored paths are entirely invisible to `Workspace.fs`. That is
442+
Today backend-only ignored paths are invisible to `Workspace.fs`. That is
446443
the simplest contract but it loses one piece of information: tools that
447444
want to enumerate "everything the agent's exec can see" can't get it
448445
from the DO. Two options worth weighing later:
@@ -454,8 +451,8 @@ from the DO. Two options worth weighing later:
454451
returns container-only entries, `workspace.fs.readdir` stays clean.
455452
Cleaner separation, larger API surface.
456453

457-
Either way, the bytes never cross the wire; the question is purely how
458-
much the DO admits exists.
454+
Either way, backend-only bytes stay out of the DO; the question is purely
455+
how much the DO admits exists.
459456

460457
### Bloom/cuckoo filter over `vfs_blobs.hash`
461458

docs/03_filesystem_schema.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,8 @@ The `vfs_nodes_by_rev` index supports `coalesceChanges`'s cursor scan
7474
over live inodes, which the sync protocol calls once per pull to
7575
enumerate everything modified after the last fetch cursor.
7676

77-
There is no `ignored` column: ignored paths are entirely invisible to
78-
the DO-side filesystem API (see
77+
There is no `ignored` column: backend-only paths filtered from a pull are
78+
not represented in the DO-side filesystem API (see
7979
[02. Sync Protocol → Ignored entries](./02_sync_protocol.md#ignored-entries)).
8080

8181
### `vfs_dirents` — name → inode mapping

docs/08_capnweb_interface.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,7 @@ type WireError = {
309309

310310
| Code | Meaning |
311311
| --- | --- |
312-
| `ENOENT` | Path does not exist on the receiver (covers ignored paths, which are invisible to `Workspace.fs`), or `getExec` / `disposeExec` referenced an unknown id. |
312+
| `ENOENT` | Path does not exist on the receiver (including backend-only paths filtered from a pull), or `getExec` / `disposeExec` referenced an unknown id. |
313313
| `EUNKNOWN_HASH` | **(reserved, planned)** `fetchObjects` or `pushObjects` referenced a hash the receiver has no record of. Reserved in `WireErrorCode` but not raised today; `pushObjects` should throw it via `createWorkspaceError`. |
314314
| `EEXEC_BUSY` | `exec` was called with an `id` that's already in use by a live run. |
315315
| `ELOG_TRUNCATED` | `getExec` resume point is older than the retained log. |

examples/think/src/agent.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,9 @@ export class TriageAgent extends withWorkspaceContainer(TriageBase) {
205205
}),
206206
this.#containerBackend,
207207
],
208+
// Keep generated output on the backend that created it while source
209+
// files still pull into the durable workspace.
210+
ignore: ["node_modules", "dist", "build", ".cache"],
208211
// Mount the shared skills bucket at /workspace/.agents. The
209212
// R2 keys live under `.agents/` (e.g.
210213
// `.agents/skills/triage/SKILL.md`); the prefix is stripped

packages/rpc/src/sync-driver.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -65,15 +65,21 @@ const PULL_BATCH_SIZE = 256;
6565
// (rev, path), so a retry can resume inside a single large rev. The
6666
// cursor is read and written per backend so concurrent backends keep
6767
// independent resume points.
68+
export interface PullOptions {
69+
backend?: string;
70+
// Path segments the remote should omit from this pull.
71+
ignore?: string[];
72+
}
73+
6874
export async function pullOnce(
6975
db: Database,
7076
remote: SyncRPC,
71-
backend?: string,
77+
options: PullOptions = {},
7278
): Promise<ApplyResult> {
7379
// Delegate to the inner implementation with retried=false. See
7480
// pullOnceImpl for the fetchChanges round trip, invariant check,
7581
// reset-and-retry path, and batched apply loop.
76-
return pullOnceImpl(db, remote, backend, false);
82+
return pullOnceImpl(db, remote, options, false);
7783
}
7884

7985
// Inner pullOnce that knows whether it is already a retry. The
@@ -84,9 +90,10 @@ export async function pullOnce(
8490
async function pullOnceImpl(
8591
db: Database,
8692
remote: SyncRPC,
87-
backend: string | undefined,
93+
options: PullOptions,
8894
retried: boolean,
8995
): Promise<ApplyResult> {
96+
const { backend, ignore } = options;
9097
const after = readFetchCursor(db, backend);
9198
const localPushRev = readWatermark(db, "pushRev", backend);
9299
// fetchChanges hands back the remote's currentCursor (cursor we
@@ -103,7 +110,7 @@ async function pullOnceImpl(
103110
// invariant trip, and any throw inside the batch loop. Disposing the
104111
// envelope tears down the contained stream stub, releasing the
105112
// remote iterator.
106-
const fetchResult = await remote.fetchChanges({ after });
113+
const fetchResult = await remote.fetchChanges({ after, ignore });
107114
try {
108115
const { currentCursor, appliedPushCursor } = fetchResult;
109116
// Cross-side watermark divergence. Two shapes are recoverable:
@@ -163,7 +170,7 @@ async function pullOnceImpl(
163170
if (fetchDiverged) {
164171
writeFetchCursor(db, { rev: 0, path: null }, backend);
165172
}
166-
return pullOnceImpl(db, remote, backend, true);
173+
return pullOnceImpl(db, remote, options, true);
167174
}
168175
// After the retry path above, this assertion guards a
169176
// divergence that survived a reset. Tear down rather than loop.

packages/rpc/tests/wire.test.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -232,10 +232,12 @@ describe("SyncRPC pull convergence", () => {
232232
harness = undefined;
233233
});
234234

235-
it("client pulls a file written via writeFileSync on the server", async () => {
235+
it("client applies pull ignore paths across the wire", async () => {
236236
harness = await startHarness();
237237
const provider = new SQLiteWorkspaceProvider(harness.db, { now: () => 1500 });
238238
provider.writeFileSync("/whole.txt", "whole-file write");
239+
provider.mkdirSync("/dist");
240+
provider.writeFileSync("/dist/generated.js", "generated");
239241

240242
// Receiver DB — the host's local store in the production setup.
241243
const recvStorage = new SQLiteTestStorage();
@@ -245,10 +247,11 @@ describe("SyncRPC pull convergence", () => {
245247
const client = createSyncClient({ url: harness.url });
246248
try {
247249
const { pullOnce } = await import("../src/sync-driver.js");
248-
const applied = await pullOnce(recvDb, client);
250+
const applied = await pullOnce(recvDb, client, { ignore: ["dist"] });
249251
expect(applied.applied).toBeGreaterThan(0);
250252
const recvProvider = new SQLiteWorkspaceProvider(recvDb, { now: () => 2500 });
251253
expect(recvProvider.readFileSync("/whole.txt", "utf8")).toBe("whole-file write");
254+
expect(() => recvProvider.statSync("/dist/generated.js")).toThrow();
252255
} finally {
253256
await client.close();
254257
recvStorage.close();

packages/workspace/README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,28 @@ A workspace with two backends that both write into
239239
[`docs/05_shell_interface.md`](../../docs/05_shell_interface.md)
240240
for the caveat.
241241

242+
## Keep generated paths on execution backends
243+
244+
Use `ignore` to omit generated directories when pulling changes into
245+
`Workspace.fs`:
246+
247+
```ts
248+
const workspace = new Workspace({
249+
storage: ctx.storage,
250+
backends: [containerBackend],
251+
ignore: ["node_modules", "dist", "build", ".cache"],
252+
});
253+
```
254+
255+
Patterns match whole path segments and apply to every backend. Omit `ignore` to
256+
use the remote default (`["node_modules"]`). A supplied list replaces that
257+
default; pass `[]` to pull every path.
258+
259+
Filtered files remain available to commands on the backend where they were
260+
created. Pushes are unaffected: matching files written through `Workspace.fs`
261+
still sync to backends. Choose the list before the first pull; changing it later
262+
does not backfill previously filtered changes or remove local files.
263+
242264
## Worker-side consumption
243265

244266
```ts

packages/workspace/src/workspace.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import { Database, initializeSchema, SQLiteWorkspaceProvider } from "@cloudflare/dofs";
12
import { SQLiteTestStorage } from "@cloudflare/dofs/testing";
3+
import { createSyncServer } from "@cloudflare/workspace-rpc/server";
24
import { describe, expect, it, vi } from "vitest";
35

46
import type { BackendHandle, WorkspaceBackend } from "./backend.js";
@@ -9,6 +11,21 @@ function makeStorage(): SQLiteTestStorage {
911
return new SQLiteTestStorage();
1012
}
1113

14+
function makeRemoteFiles(files: Record<string, string>): {
15+
rpc: import("@cloudflare/workspace-rpc").SyncRPC;
16+
close(): void;
17+
} {
18+
const storage = makeStorage();
19+
const db = new Database(storage);
20+
initializeSchema(db, () => 1);
21+
const provider = new SQLiteWorkspaceProvider(db, { now: () => 1 });
22+
for (const [path, content] of Object.entries(files)) {
23+
provider.mkdirSync(path.slice(0, path.lastIndexOf("/")), { recursive: true });
24+
provider.writeFileSync(path, content);
25+
}
26+
return { rpc: createSyncServer(db), close: () => storage.close() };
27+
}
28+
1229
// In-process fakes. We never spawn anything from the package
1330
// code; the backend's only contract is "produce a SyncRPC
1431
// stub that wsd would speak". A plain object is enough.
@@ -652,6 +669,56 @@ describe("Workspace.fs against the local store", () => {
652669
});
653670
});
654671

672+
describe("Workspace.pull ignore", () => {
673+
it("replaces the remote default with the configured path segments", async () => {
674+
const remote = makeRemoteFiles({
675+
"/workspace/src/index.ts": "source",
676+
"/workspace/dist/index.js": "build",
677+
"/workspace/node_modules/pkg/index.js": "dependency",
678+
});
679+
try {
680+
const ws = new Workspace({
681+
storage: makeStorage(),
682+
backends: [makeBackend("container", remote.rpc)],
683+
ignore: ["dist"],
684+
});
685+
686+
await ws.pull();
687+
688+
expect(await ws.fs.readFile("/workspace/src/index.ts", "utf8")).toBe("source");
689+
expect(await ws.fs.readFile("/workspace/node_modules/pkg/index.js", "utf8")).toBe(
690+
"dependency",
691+
);
692+
await expect(ws.fs.stat("/workspace/dist/index.js")).rejects.toMatchObject({
693+
code: "ENOENT",
694+
});
695+
} finally {
696+
remote.close();
697+
}
698+
});
699+
700+
it("pulls node_modules when the configured list is empty", async () => {
701+
const remote = makeRemoteFiles({
702+
"/workspace/node_modules/pkg/index.js": "dependency",
703+
});
704+
try {
705+
const ws = new Workspace({
706+
storage: makeStorage(),
707+
backends: [makeBackend("container", remote.rpc)],
708+
ignore: [],
709+
});
710+
711+
await ws.pull();
712+
713+
expect(await ws.fs.readFile("/workspace/node_modules/pkg/index.js", "utf8")).toBe(
714+
"dependency",
715+
);
716+
} finally {
717+
remote.close();
718+
}
719+
});
720+
});
721+
655722
describe("Workspace.pull return shape", () => {
656723
it("resolves to the dofs ApplyResult shape", async () => {
657724
// The fake SyncRPC's fetchChanges returns an empty stream, so

packages/workspace/src/workspace.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,12 @@ export interface WorkspaceOptions {
5858
// factories via MountContext.sessionId. Optional; defaults to "".
5959
sessionId?: string;
6060

61+
// Path segments omitted from changes pulled from every backend.
62+
// Omit to use the remote default (`node_modules`). A supplied list
63+
// replaces that default; pass [] to pull every path. Pushes are
64+
// unaffected.
65+
ignore?: string[];
66+
6167
// Mounts to register against the workspace. Keys are absolute
6268
// mount roots (no trailing slash, no nesting). Values are either
6369
// bare Mount objects or factories that take a MountContext and
@@ -112,6 +118,7 @@ export class Workspace {
112118
readonly #observer: WorkspaceObserver;
113119
readonly #now: () => number;
114120
readonly #sessionId: string;
121+
readonly #ignore: string[] | undefined;
115122
readonly #defaultGitIdentity: GitIdentity | undefined;
116123
readonly #assets: AssetsClient | undefined;
117124
readonly #artifacts: ArtifactClient;
@@ -143,6 +150,7 @@ export class Workspace {
143150
constructor(options: WorkspaceOptions) {
144151
this.#now = options.now ?? Date.now;
145152
this.#sessionId = options.sessionId ?? "";
153+
this.#ignore = options.ignore?.slice();
146154
this.#defaultGitIdentity = options.defaultGitIdentity;
147155
this.#artifacts = options.artifacts
148156
? createArtifact(
@@ -419,7 +427,10 @@ export class Workspace {
419427
const handle = await this.#handleFor(resolvedId);
420428
if (handle.sync === "none") return { applied: 0, skipped: [] };
421429
return this.#runWithInvalidation(resolvedId, handle, () =>
422-
pullOnce(this.#db, handle.rpc.sync, resolvedId),
430+
pullOnce(this.#db, handle.rpc.sync, {
431+
backend: resolvedId,
432+
ignore: this.#ignore,
433+
}),
423434
);
424435
},
425436
(span, outcome) => {

0 commit comments

Comments
 (0)