@frockbot/plugin-fly-sprite 0.3.15 → 0.3.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-fly-sprite",
3
- "version": "0.3.15",
3
+ "version": "0.3.17",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -25,16 +25,16 @@
25
25
  },
26
26
  "dependencies": {
27
27
  "@cordisjs/plugin-webui": "0.8.2",
28
- "@frockbot/computer-core": "0.3.15",
29
- "@frockbot/computer-host-protocol": "0.3.15",
30
- "@frockbot/computer-host-runtime": "0.3.15",
31
- "@frockbot/kernel-contracts": "0.3.15",
32
- "@frockbot/plugin-computer": "0.3.15",
28
+ "@frockbot/computer-core": "0.3.17",
29
+ "@frockbot/computer-host-protocol": "0.3.17",
30
+ "@frockbot/computer-host-runtime": "0.3.17",
31
+ "@frockbot/kernel-contracts": "0.3.17",
32
+ "@frockbot/plugin-computer": "0.3.17",
33
33
  "cordis": "4.0.0-rc.8"
34
34
  },
35
35
  "devDependencies": {
36
- "@frockbot/plugin-testkit": "0.3.15",
37
- "@frockbot/workspace-store": "0.3.15",
36
+ "@frockbot/plugin-testkit": "0.3.17",
37
+ "@frockbot/workspace-store": "0.3.17",
38
38
  "@types/bun": "1.4.0",
39
39
  "@types/node": "26.2.0",
40
40
  "typescript": "^7.0.2"
package/src/provider.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  type ComputerHandle,
13
13
  type ComputerIdentityV1,
14
14
  type ComputerOperationOptions,
15
+ type ComputerRootSyncOptionsV1,
15
16
  type ComputerProvider,
16
17
  type ComputerSyncHostV1,
17
18
  type ComputerSyncReasonV1,
@@ -254,7 +255,7 @@ class FlySpriteComputerSync implements ComputerSyncV1 {
254
255
  async reconcileRoot(
255
256
  root: WorkspaceRootV1,
256
257
  _reason: ComputerSyncReasonV1,
257
- options?: ComputerOperationOptions,
258
+ options?: ComputerRootSyncOptionsV1,
258
259
  ): Promise<ComputerSyncSummaryV1> {
259
260
  if (options?.signal?.aborted) {
260
261
  return computerSyncSummaryV1("skipped", "the Turn was cancelled");
@@ -267,7 +268,7 @@ class FlySpriteComputerSync implements ComputerSyncV1 {
267
268
  );
268
269
  }
269
270
  try {
270
- const report = await this.sync.syncRoot(root);
271
+ const report = await this.sync.syncRoot(root, options?.requiredPaths);
271
272
  return summarize({
272
273
  roots: [report],
273
274
  conflicts: report.conflicts,
@@ -299,15 +300,35 @@ function summarize(report: WorkspaceSyncReportV1): ComputerSyncSummaryV1 {
299
300
  pick: (root: WorkspaceSyncReportV1["roots"][number]) => number,
300
301
  ) => report.roots.reduce((sum, root) => sum + pick(root), 0);
301
302
  const failed = report.failures[0];
303
+ const ignored = total((root) => root.ignored);
304
+ const omitted = total((root) => root.omitted);
305
+ const detail: string[] = [];
306
+ if (ignored > 0) {
307
+ detail.push(
308
+ `Excluded ${ignored} reproducible Workspace ${ignored === 1 ? "item" : "items"} from sync.`,
309
+ );
310
+ }
311
+ if (omitted > 0) {
312
+ detail.push(
313
+ `Omitted ${omitted} manifest ${omitted === 1 ? "entry" : "entries"} at the sync safety limit.`,
314
+ );
315
+ }
316
+ if (failed) {
317
+ detail.push(
318
+ `${report.failures.length} sync ${report.failures.length === 1 ? "operation" : "operations"} failed: ${failed.status}: ${failed.reason}.`,
319
+ );
320
+ }
302
321
  const summary: ComputerSyncSummaryV1 = {
303
322
  // Every root failing is `unavailable` — the usual shape of a paused
304
- // Sprite. A partial failure is still an `ok` run that says what it missed.
323
+ // Sprite. Any partial failure or bounded exclusion is truthfully degraded.
305
324
  status:
306
325
  report.failures.length > 0 &&
307
326
  report.roots.every((root) => root.failures.length > 0)
308
327
  ? "unavailable"
309
- : "ok",
310
- detail: failed ? `${failed.status}: ${failed.reason}`.slice(0, 512) : "",
328
+ : report.failures.length > 0 || ignored > 0 || omitted > 0
329
+ ? "degraded"
330
+ : "ok",
331
+ detail: detail.join(" ").slice(0, 512),
311
332
  pulled: total((root) => root.pulled.length),
312
333
  pushed: total((root) => root.pushed.length),
313
334
  restored: total((root) => root.restored.length),
@@ -315,6 +336,8 @@ function summarize(report: WorkspaceSyncReportV1): ComputerSyncSummaryV1 {
315
336
  (root) => root.removedOnComputer.length + root.removedInStore.length,
316
337
  ),
317
338
  adopted: total((root) => root.adopted.length),
339
+ ignored,
340
+ omitted,
318
341
  conflicts: report.conflicts.length,
319
342
  failures: report.failures.length,
320
343
  };
package/src/sync.test.ts CHANGED
@@ -22,6 +22,11 @@ import { FLY_WORKSPACE_LAYOUT, FlySpriteComputerProvider } from "./provider.ts";
22
22
  import {
23
23
  createFlySpriteSyncV1,
24
24
  declaredWorkspaceRootsV1,
25
+ FlySpriteSyncSurface,
26
+ isWorkspaceSyncIgnoredPathV1,
27
+ WORKSPACE_SYNC_IGNORED_DIRECTORIES_V1,
28
+ WORKSPACE_SYNC_MANIFEST_MAX_BYTES_V1,
29
+ WORKSPACE_SYNC_MANIFEST_MAX_ENTRIES_V1,
25
30
  type WorkspaceSyncReportV1,
26
31
  } from "./sync.ts";
27
32
  import { WORKSPACE_EMPTY_SHA256 } from "./workspace.ts";
@@ -84,6 +89,10 @@ class FakeSyncSprite {
84
89
  readonly files = new Map<string, Uint8Array>();
85
90
  /** Set to fail every storage call, as a paused Sprite does. */
86
91
  paused = false;
92
+ /** Simulates the shared host truncating an oversized storage response. */
93
+ maxScanOutputBytes?: number;
94
+ /** The exact remote Bash document, for syntax validation. */
95
+ lastScanScript?: string;
87
96
  /** Drops the next sidecar write, as a pause between store and Computer does. */
88
97
  dropNextMaterialize = false;
89
98
 
@@ -92,7 +101,19 @@ class FakeSyncSprite {
92
101
  // A paused Sprite answers nothing, and the host reports the failed exit;
93
102
  // the provider turns that into `Sprite storage operation failed: …`.
94
103
  if (this.paused) return { exitCode: 1, stderr: "Sprite is paused" };
95
- return { stdout: this.interpret(script) };
104
+ if (script.includes("append_manifest")) this.lastScanScript = script;
105
+ const stdout = this.interpret(script);
106
+ if (
107
+ script.includes("append_manifest") &&
108
+ this.maxScanOutputBytes !== undefined &&
109
+ stdout.length > this.maxScanOutputBytes
110
+ ) {
111
+ return {
112
+ stdout: stdout.slice(0, this.maxScanOutputBytes),
113
+ outputTruncated: true,
114
+ };
115
+ }
116
+ return { stdout };
96
117
  };
97
118
 
98
119
  /** Writes a file the way a shell command on the Computer would: no sidecar. */
@@ -118,7 +139,16 @@ class FakeSyncSprite {
118
139
  private interpret(shell: string): string {
119
140
  const root = quoted(shell, "ROOT");
120
141
  const relative = quoted(shell, "REL");
121
- if (shell.includes('printf "F\\t')) return this.scan(root ?? "");
142
+ if (shell.includes("append_manifest") && shell.includes("sha256sum")) {
143
+ const encoded = quoted(shell, "REQUIRED_PATHS") ?? "W10=";
144
+ const required = new Set<string>(
145
+ Buffer.from(encoded, "base64")
146
+ .toString("utf8")
147
+ .split("\n")
148
+ .filter(Boolean),
149
+ );
150
+ return this.scan(root ?? "", required);
151
+ }
122
152
  if (root && relative) {
123
153
  if (shell.includes("__SYNCED__")) {
124
154
  if (this.dropNextMaterialize) {
@@ -160,14 +190,39 @@ class FakeSyncSprite {
160
190
  : `${decoder.decode(bytes)}\n`;
161
191
  }
162
192
 
163
- private scan(root: string): string {
193
+ private scan(root: string, required: ReadonlySet<string>): string {
164
194
  const rows: string[] = [];
195
+ const ignoredDirectories = new Set<string>();
196
+ const requiredIgnoredDirectories = new Set(
197
+ [...required].map((path) => {
198
+ const segments = path.split("/");
199
+ const index = segments.findIndex((segment) =>
200
+ (WORKSPACE_SYNC_IGNORED_DIRECTORIES_V1 as readonly string[]).includes(
201
+ segment,
202
+ ),
203
+ );
204
+ return segments.slice(0, index + 1).join("/");
205
+ }),
206
+ );
165
207
  const generations = `${root}/.frockbot-generations/`;
166
208
  const graves = `${root}/.frockbot-sync/tombstones/`;
167
209
  for (const [path, bytes] of [...this.files].sort()) {
168
210
  if (!path.startsWith(`${root}/`)) continue;
169
211
  const relative = path.slice(root.length + 1);
170
212
  if (relative.startsWith(".frockbot-")) continue;
213
+ if (isWorkspaceSyncIgnoredPathV1(relative) && !required.has(relative)) {
214
+ const segments = relative.split("/");
215
+ const index = segments.findIndex((segment) =>
216
+ (WORKSPACE_SYNC_IGNORED_DIRECTORIES_V1 as readonly string[]).includes(
217
+ segment,
218
+ ),
219
+ );
220
+ const ignoredRoot = segments.slice(0, index + 1).join("/");
221
+ if (!requiredIgnoredDirectories.has(ignoredRoot)) {
222
+ ignoredDirectories.add(ignoredRoot);
223
+ }
224
+ continue;
225
+ }
171
226
  const meta = this.files.get(`${generations}${relative}`);
172
227
  rows.push(
173
228
  [
@@ -181,10 +236,13 @@ class FakeSyncSprite {
181
236
  }
182
237
  for (const [path, bytes] of [...this.files].sort()) {
183
238
  if (path.startsWith(graves)) {
239
+ const relative = path.slice(graves.length);
240
+ if (isWorkspaceSyncIgnoredPathV1(relative) && !required.has(relative))
241
+ continue;
184
242
  rows.push(
185
243
  [
186
244
  "T",
187
- Buffer.from(path.slice(graves.length)).toString("base64"),
245
+ Buffer.from(relative).toString("base64"),
188
246
  Buffer.from(bytes).toString("base64"),
189
247
  ].join("\t"),
190
248
  );
@@ -192,6 +250,8 @@ class FakeSyncSprite {
192
250
  }
193
251
  if (!path.startsWith(generations)) continue;
194
252
  const relative = path.slice(generations.length);
253
+ if (isWorkspaceSyncIgnoredPathV1(relative) && !required.has(relative))
254
+ continue;
195
255
  if (this.files.has(`${root}/${relative}`)) continue;
196
256
  rows.push(
197
257
  [
@@ -201,7 +261,22 @@ class FakeSyncSprite {
201
261
  ].join("\t"),
202
262
  );
203
263
  }
204
- return rows.length ? `${rows.join("\n")}\n` : "";
264
+ const bounded: string[] = [];
265
+ let bytes = 0;
266
+ let omitted = 0;
267
+ for (const row of rows) {
268
+ const rowBytes = Buffer.byteLength(`${row}\n`);
269
+ if (
270
+ bounded.length >= WORKSPACE_SYNC_MANIFEST_MAX_ENTRIES_V1 ||
271
+ bytes + rowBytes > WORKSPACE_SYNC_MANIFEST_MAX_BYTES_V1
272
+ ) {
273
+ omitted += 1;
274
+ continue;
275
+ }
276
+ bounded.push(row);
277
+ bytes += rowBytes;
278
+ }
279
+ return `${bounded.length ? `${bounded.join("\n")}\n` : ""}X\t${ignoredDirectories.size}\t${omitted}\n`;
205
280
  }
206
281
 
207
282
  private materialize(root: string, relative: string, shell: string): string {
@@ -631,6 +706,78 @@ describe("the durable-root sync, Computer to store", () => {
631
706
  });
632
707
 
633
708
  describe("the durable-root sync, Package-declared roots", () => {
709
+ test("ignores reproducible dependency, VCS, cache, and build directories", () => {
710
+ for (const directory of WORKSPACE_SYNC_IGNORED_DIRECTORIES_V1) {
711
+ expect(
712
+ isWorkspaceSyncIgnoredPathV1(`to-dos/${directory}/nested/file.js`),
713
+ ).toBe(true);
714
+ }
715
+ expect(isWorkspaceSyncIgnoredPathV1("to-dos/src/build-result.ts")).toBe(
716
+ false,
717
+ );
718
+ expect(isWorkspaceSyncIgnoredPathV1("to-dos/.gitignore")).toBe(false);
719
+ });
720
+
721
+ test("bounds a source-only manifest and reports every omitted row", async () => {
722
+ const sprite = new FakeSyncSprite();
723
+ const surface = new FlySpriteSyncSurface({
724
+ computer: attach(sprite).bot(BOT),
725
+ layout: FLY_WORKSPACE_LAYOUT,
726
+ userId: USER,
727
+ botDirectoryKey: computerBotKey,
728
+ });
729
+ for (
730
+ let index = 0;
731
+ index < WORKSPACE_SYNC_MANIFEST_MAX_ENTRIES_V1 + 100;
732
+ index += 1
733
+ ) {
734
+ sprite.shellWrite(
735
+ MOUNTS.appletSource,
736
+ `to-dos/src/file-${index.toString().padStart(4, "0")}.ts`,
737
+ `export const value${index} = ${index};`,
738
+ );
739
+ }
740
+
741
+ const outcome = await surface.scan(appletSourceRoot);
742
+
743
+ if (outcome.status !== "ok") throw new Error(outcome.reason);
744
+ expect(outcome.scan.entries).toHaveLength(
745
+ WORKSPACE_SYNC_MANIFEST_MAX_ENTRIES_V1,
746
+ );
747
+ expect(outcome.scan.ignored).toBe(0);
748
+ expect(outcome.scan.omitted).toBe(100);
749
+ });
750
+
751
+ test("emits valid Bash with exact required build paths", async () => {
752
+ const sprite = new FakeSyncSprite();
753
+ const surface = new FlySpriteSyncSurface({
754
+ computer: attach(sprite).bot(BOT),
755
+ layout: FLY_WORKSPACE_LAYOUT,
756
+ userId: USER,
757
+ botDirectoryKey: computerBotKey,
758
+ });
759
+
760
+ await surface.scan(appletSourceRoot, [
761
+ "todo/dist/server.js",
762
+ "todo/dist/ui.html",
763
+ "todo/dist/manifest.json",
764
+ "todo/dist/it's-$(not-a-command).js",
765
+ ]);
766
+
767
+ expect(sprite.lastScanScript).toBeString();
768
+ const process = Bun.spawn(["bash", "-n"], {
769
+ stdin: new Blob([sprite.lastScanScript!]),
770
+ stdout: "ignore",
771
+ stderr: "pipe",
772
+ });
773
+ const [exitCode, stderr] = await Promise.all([
774
+ process.exited,
775
+ new Response(process.stderr).text(),
776
+ ]);
777
+ expect(stderr).toBe("");
778
+ expect(exitCode).toBe(0);
779
+ });
780
+
634
781
  // Constitution — Computer and Workspace: "durable roots, declared by the
635
782
  // Computer Package's Workspace layout **and by Package manifests**". The
636
783
  // layout half was always here; a `package-declared` root reaches the sync
@@ -643,10 +790,9 @@ describe("the durable-root sync, Package-declared roots", () => {
643
790
  });
644
791
 
645
792
  test("a declared root round-trips: store to Computer, and a shell write back", async () => {
646
- // ADR 0022 decision 7: this is the root Applet source lives in, and both
647
- // directions matter the Bot edits source the store holds, and `applet
648
- // build` writes `dist/` with a shell that a publish then reads from the
649
- // store.
793
+ // ADR 0022 decision 7: this is the root Applet source lives in, and source
794
+ // edits still move in both directions while reproducible build trees do
795
+ // not enter the ordinary whole-Workspace manifest.
650
796
  const { sprite, store, sync, roots } = harness({
651
797
  packageRoots: [APPLET_SOURCE_PACKAGE_ROOT],
652
798
  });
@@ -667,25 +813,103 @@ describe("the durable-root sync, Package-declared roots", () => {
667
813
  "export class TodoApplet {}",
668
814
  );
669
815
 
670
- // `applet build` is an ordinary shell write on the Computer.
816
+ // An ordinary shell source edit on the Computer is mirrored back.
671
817
  sprite.shellWrite(
672
818
  MOUNTS.appletSource,
673
- `${appletId}/dist/server.js`,
674
- "export class A{}",
819
+ `${appletId}/server.ts`,
820
+ "export class TodoApplet { health() {} }",
675
821
  );
676
822
  const pushed = await sync();
677
823
 
678
824
  const built = await store.read({
679
825
  root: appletSourceRoot,
680
- path: `${appletId}/dist/server.js`,
826
+ path: `${appletId}/server.ts`,
681
827
  });
682
828
  if (built.status !== "ok") throw new Error(built.reason);
683
- expect(decoder.decode(built.file.bytes)).toBe("export class A{}");
829
+ expect(decoder.decode(built.file.bytes)).toBe(
830
+ "export class TodoApplet { health() {} }",
831
+ );
684
832
  // A shell wrote it, so nothing claims to know which Bot did: the artifact
685
- // is data the publish reads, never provenance.
833
+ // is data, never provenance.
686
834
  expect(built.file.generation.writer).toEqual({ kind: "unattributed" });
687
835
  expect(pushed.failures).toEqual([]);
688
836
  });
837
+
838
+ test("a large node_modules tree cannot prevent Applet source from syncing", async () => {
839
+ const { sprite, store, sync } = harness({
840
+ packageRoots: [APPLET_SOURCE_PACKAGE_ROOT],
841
+ });
842
+ const appletId = "to-dos";
843
+ sprite.maxScanOutputBytes = 10_000;
844
+ sprite.shellWrite(
845
+ MOUNTS.appletSource,
846
+ `${appletId}/server.ts`,
847
+ "export class TodoApplet {}",
848
+ );
849
+ for (let index = 0; index < 200; index += 1) {
850
+ sprite.shellWrite(
851
+ MOUNTS.appletSource,
852
+ `${appletId}/node_modules/dependency-${index}/package.json`,
853
+ JSON.stringify({ name: `dependency-${index}` }),
854
+ );
855
+ }
856
+
857
+ const report = await sync();
858
+
859
+ expect(report.failures).toEqual([]);
860
+ expect(report.roots.at(-1)).toMatchObject({ ignored: 1, omitted: 0 });
861
+ const source = await store.read({
862
+ root: appletSourceRoot,
863
+ path: `${appletId}/server.ts`,
864
+ });
865
+ if (source.status !== "ok") throw new Error(source.reason);
866
+ expect(decoder.decode(source.file.bytes)).toBe(
867
+ "export class TodoApplet {}",
868
+ );
869
+ expect(
870
+ await store.read({
871
+ root: appletSourceRoot,
872
+ path: `${appletId}/node_modules/dependency-0/package.json`,
873
+ }),
874
+ ).toMatchObject({ status: "not-found" });
875
+ });
876
+
877
+ test("an empty replacement Computer restores source but not legacy project dependencies", async () => {
878
+ const { sprite, store, sync } = harness({
879
+ packageRoots: [APPLET_SOURCE_PACKAGE_ROOT],
880
+ });
881
+ const appletId = "pub-user-1.0123456789abcdef0123456789abcdef";
882
+ await writeToStore(
883
+ store,
884
+ appletSourceRoot,
885
+ `${appletId}/server.ts`,
886
+ "export class TodoApplet {}",
887
+ BOT_WRITER,
888
+ );
889
+ // A pre-policy generation stays in object storage for audit/recovery, but
890
+ // a new Computer must not materialize it back into the project.
891
+ await writeToStore(
892
+ store,
893
+ appletSourceRoot,
894
+ `${appletId}/node_modules/dependency/package.json`,
895
+ '{"name":"dependency"}',
896
+ BOT_WRITER,
897
+ );
898
+
899
+ const report = await sync();
900
+
901
+ expect(sprite.text(`${MOUNTS.appletSource}/${appletId}/server.ts`)).toBe(
902
+ "export class TodoApplet {}",
903
+ );
904
+ expect(
905
+ sprite.text(
906
+ `${MOUNTS.appletSource}/${appletId}/node_modules/dependency/package.json`,
907
+ ),
908
+ ).toBeUndefined();
909
+ expect(
910
+ report.roots.find((item) => item.root.kind === "package-declared"),
911
+ ).toMatchObject({ pulled: [`${appletId}/server.ts`], ignored: 1 });
912
+ });
689
913
  });
690
914
 
691
915
  describe("the durable-root sync, conflicts", () => {
@@ -1108,6 +1332,34 @@ describe("the durable-root sync on the Computer handle", () => {
1108
1332
  }
1109
1333
  });
1110
1334
 
1335
+ test("reports a bounded dependency exclusion as degraded while source still syncs", async () => {
1336
+ const { sprite, open } = providerHarness([APPLET_SOURCE_PACKAGE_ROOT]);
1337
+ const handle = await open();
1338
+ sprite.shellWrite(
1339
+ MOUNTS.appletSource,
1340
+ "todo/src/index.ts",
1341
+ "export const todo = true;",
1342
+ );
1343
+ sprite.shellWrite(
1344
+ MOUNTS.appletSource,
1345
+ "todo/node_modules/dependency/package.json",
1346
+ '{"name":"dependency"}',
1347
+ );
1348
+
1349
+ const summary = await handle.sync!.reconcile("turn-end");
1350
+
1351
+ expect(summary).toMatchObject({
1352
+ status: "degraded",
1353
+ pushed: 1,
1354
+ ignored: 1,
1355
+ omitted: 0,
1356
+ failures: 0,
1357
+ });
1358
+ expect(summary.detail).toBe(
1359
+ "Excluded 1 reproducible Workspace item from sync.",
1360
+ );
1361
+ });
1362
+
1111
1363
  // The sync-now seam of ADR 0022 decision 7, provider side: an Applet publish
1112
1364
  // needs the bytes `applet build` left on the Computer to be in the store
1113
1365
  // before it reads them, and it needs that for one root, not the Workspace.
@@ -1127,6 +1379,7 @@ describe("the durable-root sync on the Computer handle", () => {
1127
1379
  const summary = await handle.sync!.reconcileRoot!(
1128
1380
  appletSourceRoot,
1129
1381
  "publish",
1382
+ { requiredPaths: [`${appletId}/dist/server.js`] },
1130
1383
  );
1131
1384
 
1132
1385
  expect(summary.status).toBe("ok");
package/src/sync.ts CHANGED
@@ -116,6 +116,44 @@ const EFFECT_NOTE_KIND = "effects";
116
116
  const MAX_STORE_PAGES = 100;
117
117
  const STORE_PAGE_LIMIT = 500;
118
118
 
119
+ /**
120
+ * Reproducible dependency, cache, VCS, and build trees never enter a durable
121
+ * Workspace manifest. Names are matched as path segments at any depth, so one
122
+ * policy covers a project nested inside a Package-declared root.
123
+ */
124
+ export const WORKSPACE_SYNC_IGNORED_DIRECTORIES_V1 = [
125
+ "node_modules",
126
+ ".git",
127
+ "dist",
128
+ "build",
129
+ "out",
130
+ "target",
131
+ "coverage",
132
+ ".cache",
133
+ ".parcel-cache",
134
+ ".turbo",
135
+ ".next",
136
+ ".nuxt",
137
+ ".output",
138
+ ".svelte-kit",
139
+ ".vite",
140
+ ".pnpm-store",
141
+ ] as const;
142
+
143
+ /** Leaves headroom below `runStorageForAgent`'s 500 KB response ceiling. */
144
+ export const WORKSPACE_SYNC_MANIFEST_MAX_BYTES_V1 = 400_000;
145
+ /** Bounds work and rows even when unusually short paths fit under the bytes. */
146
+ export const WORKSPACE_SYNC_MANIFEST_MAX_ENTRIES_V1 = 2_000;
147
+
148
+ const ignoredDirectoryNames = new Set<string>(
149
+ WORKSPACE_SYNC_IGNORED_DIRECTORIES_V1,
150
+ );
151
+
152
+ /** True when a Workspace-relative path belongs to a reproducible tree. */
153
+ export function isWorkspaceSyncIgnoredPathV1(path: string): boolean {
154
+ return path.split("/").some((segment) => ignoredDirectoryNames.has(segment));
155
+ }
156
+
119
157
  function failure(
120
158
  status: WorkspaceFailureV1["status"],
121
159
  reason: string,
@@ -162,6 +200,10 @@ export interface ComputerSyncRemovalV1 {
162
200
  export interface ComputerSyncScanV1 {
163
201
  entries: ComputerSyncEntryV1[];
164
202
  removed: ComputerSyncRemovalV1[];
203
+ /** Reproducible directories or already-stored paths excluded by policy. */
204
+ ignored: number;
205
+ /** Rows omitted after the manifest hit its entry or byte ceiling. */
206
+ omitted: number;
165
207
  }
166
208
 
167
209
  export type ComputerSyncOutcomeV1 = { status: "ok" } | WorkspaceFailureV1;
@@ -183,7 +225,10 @@ export type ComputerSyncNoteOutcomeV1 =
183
225
  */
184
226
  export interface ComputerSyncSurfaceV1 {
185
227
  /** Every file and every recorded removal under one durable root. */
186
- scan(root: WorkspaceRootV1): Promise<ComputerSyncScanOutcomeV1>;
228
+ scan(
229
+ root: WorkspaceRootV1,
230
+ requiredPaths?: readonly string[],
231
+ ): Promise<ComputerSyncScanOutcomeV1>;
187
232
  read(
188
233
  root: WorkspaceRootV1,
189
234
  path: string,
@@ -261,6 +306,10 @@ export interface WorkspaceSyncRootReportV1 {
261
306
  removedInStore: string[];
262
307
  /** Pushes a previous run had already applied, adopted rather than repeated. */
263
308
  adopted: string[];
309
+ /** Reproducible directories and store entries excluded by policy. */
310
+ ignored: number;
311
+ /** Computer manifest rows omitted at the hard bound. */
312
+ omitted: number;
264
313
  conflicts: WorkspaceSyncConflictV1[];
265
314
  failures: WorkspaceSyncFailureV1[];
266
315
  }
@@ -293,7 +342,10 @@ export interface WorkspaceRootSyncOptionsV1 {
293
342
  export interface WorkspaceRootSyncV1 {
294
343
  /** Reconciles every declared root. */
295
344
  sync(): Promise<WorkspaceSyncReportV1>;
296
- syncRoot(root: WorkspaceRootV1): Promise<WorkspaceSyncRootReportV1>;
345
+ syncRoot(
346
+ root: WorkspaceRootV1,
347
+ requiredPaths?: readonly string[],
348
+ ): Promise<WorkspaceSyncRootReportV1>;
297
349
  /**
298
350
  * The on-Sprite watcher's change signal. A caller runs the sync on wake and
299
351
  * whenever this changes, rather than scanning every root every Turn.
@@ -332,6 +384,8 @@ function emptyReport(root: WorkspaceRootV1): WorkspaceSyncRootReportV1 {
332
384
  removedOnComputer: [],
333
385
  removedInStore: [],
334
386
  adopted: [],
387
+ ignored: 0,
388
+ omitted: 0,
335
389
  conflicts: [],
336
390
  failures: [],
337
391
  };
@@ -363,18 +417,24 @@ class WorkspaceRootSync implements WorkspaceRootSyncV1 {
363
417
  };
364
418
  }
365
419
 
366
- async syncRoot(root: WorkspaceRootV1): Promise<WorkspaceSyncRootReportV1> {
420
+ async syncRoot(
421
+ root: WorkspaceRootV1,
422
+ requiredPaths: readonly string[] = [],
423
+ ): Promise<WorkspaceSyncRootReportV1> {
367
424
  const report = emptyReport(root);
368
- const scanned = await this.options.computer.scan(root);
425
+ const scanned = await this.options.computer.scan(root, requiredPaths);
369
426
  if (isFailure(scanned)) {
370
427
  report.failures.push({ ...scanned, root });
371
428
  return report;
372
429
  }
373
- const stored = await this.listStore(root);
430
+ report.ignored = scanned.scan.ignored;
431
+ report.omitted = scanned.scan.omitted;
432
+ const stored = await this.listStore(root, requiredPaths);
374
433
  if (isFailure(stored)) {
375
434
  report.failures.push({ ...stored, root });
376
435
  return report;
377
436
  }
437
+ report.ignored += stored.ignored;
378
438
  const local = new Map(
379
439
  scanned.scan.entries.map((entry) => [entry.path, entry] as const),
380
440
  );
@@ -458,11 +518,18 @@ class WorkspaceRootSync implements WorkspaceRootSyncV1 {
458
518
 
459
519
  private async listStore(
460
520
  root: WorkspaceRootV1,
521
+ requiredPaths: readonly string[],
461
522
  ): Promise<
462
- | { status: "ok"; generations: Map<string, WorkspaceGenerationV1> }
523
+ | {
524
+ status: "ok";
525
+ generations: Map<string, WorkspaceGenerationV1>;
526
+ ignored: number;
527
+ }
463
528
  | WorkspaceFailureV1
464
529
  > {
465
530
  const generations = new Map<string, WorkspaceGenerationV1>();
531
+ const required = new Set(requiredPaths);
532
+ let ignored = 0;
466
533
  let cursor: string | undefined;
467
534
  for (let page = 0; page < MAX_STORE_PAGES; page += 1) {
468
535
  const listed = await this.options.store.list({
@@ -472,9 +539,16 @@ class WorkspaceRootSync implements WorkspaceRootSyncV1 {
472
539
  });
473
540
  if (listed.status !== "ok") return listed;
474
541
  for (const entry of listed.entries) {
542
+ if (
543
+ isWorkspaceSyncIgnoredPathV1(entry.path.path) &&
544
+ !required.has(entry.path.path)
545
+ ) {
546
+ ignored += 1;
547
+ continue;
548
+ }
475
549
  generations.set(entry.path.path, entry.generation);
476
550
  }
477
- if (!listed.cursor) return { status: "ok", generations };
551
+ if (!listed.cursor) return { status: "ok", generations, ignored };
478
552
  cursor = listed.cursor;
479
553
  }
480
554
  return failure("unavailable", "Durable root listing did not terminate");
@@ -888,38 +962,137 @@ export class FlySpriteSyncSurface implements ComputerSyncSurfaceV1 {
888
962
  ).toString("base64");
889
963
  }
890
964
 
891
- async scan(root: WorkspaceRootV1): Promise<ComputerSyncScanOutcomeV1> {
965
+ async scan(
966
+ root: WorkspaceRootV1,
967
+ requiredPaths: readonly string[] = [],
968
+ ): Promise<ComputerSyncScanOutcomeV1> {
892
969
  const mount = this.mount(root);
893
970
  if (typeof mount !== "string") return mount;
971
+ const requiredIgnoredPaths: string[] = [];
972
+ for (const path of requiredPaths) {
973
+ const relative = this.relative(path);
974
+ if (typeof relative !== "string") return relative;
975
+ if (
976
+ isWorkspaceSyncIgnoredPathV1(relative) &&
977
+ !requiredIgnoredPaths.includes(relative)
978
+ ) {
979
+ requiredIgnoredPaths.push(relative);
980
+ }
981
+ }
982
+ const requiredIgnoredRoots = [
983
+ ...new Set(
984
+ requiredIgnoredPaths.map((path) => {
985
+ const segments = path.split("/");
986
+ const index = segments.findIndex((segment) =>
987
+ ignoredDirectoryNames.has(segment),
988
+ );
989
+ return segments.slice(0, index + 1).join("/");
990
+ }),
991
+ ),
992
+ ];
993
+ const ignoredExpression = WORKSPACE_SYNC_IGNORED_DIRECTORIES_V1.map(
994
+ (name) => `-name ${shellQuote(name)}`,
995
+ ).join(" -o ");
894
996
  const script = [
997
+ "set -eu",
895
998
  `ROOT=${shellQuote(mount)}`,
999
+ `REQUIRED_PATHS=${shellQuote(Buffer.from(requiredIgnoredPaths.join("\n")).toString("base64"))}`,
896
1000
  `mkdir -p "$ROOT" "$ROOT/${WORKSPACE_GENERATIONS_DIR}" "$ROOT/${WORKSPACE_SYNC_DIR}/${SYNC_TOMBSTONES_DIR}"`,
897
- `find "$ROOT" -type f ! -path "$ROOT/${WORKSPACE_GENERATIONS_DIR}/*" ! -path "$ROOT/${WORKSPACE_SYNC_DIR}/*" ! -path "$ROOT/.frockbot-locks/*" -print0 | sort -z | while IFS= read -r -d "" FILE; do`,
898
- ' REL=${FILE#"$ROOT"/}',
1001
+ "MANIFEST=$(mktemp)",
1002
+ "trap 'rm -f \"$MANIFEST\"' EXIT",
1003
+ "MANIFEST_BYTES=0",
1004
+ "MANIFEST_ENTRIES=0",
1005
+ "OMITTED=0",
1006
+ "IGNORED=0",
1007
+ "SATURATED=0",
1008
+ "append_manifest() {",
1009
+ " ROW=$1",
1010
+ " ROW_BYTES=$((${#ROW} + 1))",
1011
+ ` if [ "$MANIFEST_ENTRIES" -ge ${WORKSPACE_SYNC_MANIFEST_MAX_ENTRIES_V1} ] || [ $((MANIFEST_BYTES + ROW_BYTES)) -gt ${WORKSPACE_SYNC_MANIFEST_MAX_BYTES_V1} ]; then`,
1012
+ " OMITTED=$((OMITTED + 1))",
1013
+ " SATURATED=1",
1014
+ " return",
1015
+ " fi",
1016
+ ' printf \'%s\\n\' "$ROW" >> "$MANIFEST"',
1017
+ " MANIFEST_BYTES=$((MANIFEST_BYTES + ROW_BYTES))",
1018
+ " MANIFEST_ENTRIES=$((MANIFEST_ENTRIES + 1))",
1019
+ "}",
1020
+ "ignored_relative() {",
1021
+ ' included_relative "$1" && return 1',
1022
+ ' IFS=/ read -r -a PARTS <<< "$1"',
1023
+ ' for PART in "${PARTS[@]}"; do',
1024
+ ` case "$PART" in ${WORKSPACE_SYNC_IGNORED_DIRECTORIES_V1.map((name) => shellQuote(name)).join("|")}) return 0 ;; esac`,
1025
+ " done",
1026
+ " return 1",
1027
+ "}",
1028
+ "included_relative() {",
1029
+ requiredIgnoredPaths.length > 0
1030
+ ? ` case "$1" in ${requiredIgnoredPaths.map((path) => shellQuote(path)).join("|")}) return 0 ;; esac`
1031
+ : " :",
1032
+ " return 1",
1033
+ "}",
1034
+ "required_directory() {",
1035
+ requiredIgnoredRoots.length > 0
1036
+ ? ` case "$1" in ${requiredIgnoredRoots.map((path) => shellQuote(path)).join("|")}) return 0 ;; esac`
1037
+ : " :",
1038
+ " return 1",
1039
+ "}",
1040
+ // Required artifact files go first: the source manifest may be full,
1041
+ // but an explicit one-root publisher must still receive the exact bytes
1042
+ // it named. They remain subject to the same byte and entry bounds.
1043
+ "while IFS= read -r REL; do",
1044
+ ' [ -n "$REL" ] || continue',
1045
+ ' FILE="$ROOT/$REL"',
1046
+ ' if [ -f "$FILE" ]; then',
899
1047
  ` META="$ROOT/${WORKSPACE_GENERATIONS_DIR}/$REL"`,
900
- ' printf "F\\t%s\\t%s\\t%s\\t%s\\n" "$(printf %s "$REL" | base64 -w0)" "$({ cat "$META" 2>/dev/null || printf \'\'; } | base64 -w0)" "$(sha256sum "$FILE" | cut -d" " -f1)" "$(stat -c %s "$FILE")"',
901
- "done",
1048
+ ' printf -v ROW "F\\t%s\\t%s\\t%s\\t%s" "$(printf %s "$REL" | base64 -w0)" "$({ cat "$META" 2>/dev/null || printf \'\'; } | base64 -w0)" "$(sha256sum "$FILE" | cut -d" " -f1)" "$(stat -c %s "$FILE")"',
1049
+ ' append_manifest "$ROW"',
1050
+ " fi",
1051
+ 'done < <(printf %s "$REQUIRED_PATHS" | base64 -d)',
1052
+ // Count pruned directory roots, not every file below them: the count is
1053
+ // useful and bounded work even when a dependency tree holds millions.
1054
+ `while IFS= read -r -d "" DIR; do REL=\${DIR#"$ROOT"/}; required_directory "$REL" || IGNORED=$((IGNORED + 1)); done < <(find "$ROOT" \\( -path "$ROOT/${WORKSPACE_GENERATIONS_DIR}" -o -path "$ROOT/${WORKSPACE_SYNC_DIR}" -o -path "$ROOT/.frockbot-locks" \\) -prune -o -type d \\( ${ignoredExpression} \\) -print0 -prune)`,
902
1055
  `GRAVES="$ROOT/${WORKSPACE_SYNC_DIR}/${SYNC_TOMBSTONES_DIR}"`,
903
- 'find "$GRAVES" -type f -print0 | sort -z | while IFS= read -r -d "" FILE; do',
1056
+ 'while IFS= read -r -d "" FILE; do',
904
1057
  ' REL=${FILE#"$GRAVES"/}',
905
- ' printf "T\\t%s\\t%s\\n" "$(printf %s "$REL" | base64 -w0)" "$(base64 -w0 "$FILE")"',
906
- "done",
1058
+ ' if ignored_relative "$REL"; then IGNORED=$((IGNORED + 1)); continue; fi',
1059
+ ' printf -v ROW "T\\t%s\\t%s" "$(printf %s "$REL" | base64 -w0)" "$(base64 -w0 "$FILE")"',
1060
+ ' append_manifest "$ROW"',
1061
+ 'done < <(find "$GRAVES" -type f -print0 | sort -z)',
907
1062
  `METAS="$ROOT/${WORKSPACE_GENERATIONS_DIR}"`,
908
- 'find "$METAS" -type f -print0 | sort -z | while IFS= read -r -d "" FILE; do',
1063
+ 'while IFS= read -r -d "" FILE; do',
909
1064
  ' REL=${FILE#"$METAS"/}',
1065
+ ' if ignored_relative "$REL"; then IGNORED=$((IGNORED + 1)); continue; fi',
910
1066
  ' if [ -f "$ROOT/$REL" ]; then continue; fi',
911
- ' printf "S\\t%s\\t%s\\n" "$(printf %s "$REL" | base64 -w0)" "$(base64 -w0 "$FILE")"',
912
- "done",
1067
+ ' printf -v ROW "S\\t%s\\t%s" "$(printf %s "$REL" | base64 -w0)" "$(base64 -w0 "$FILE")"',
1068
+ ' append_manifest "$ROW"',
1069
+ 'done < <(find "$METAS" -type f -print0 | sort -z)',
1070
+ `while IFS= read -r -d "" FILE; do`,
1071
+ ' REL=${FILE#"$ROOT"/}',
1072
+ ' if [ "$SATURATED" -eq 1 ]; then OMITTED=$((OMITTED + 1)); continue; fi',
1073
+ ` META="$ROOT/${WORKSPACE_GENERATIONS_DIR}/$REL"`,
1074
+ ' printf -v ROW "F\\t%s\\t%s\\t%s\\t%s" "$(printf %s "$REL" | base64 -w0)" "$({ cat "$META" 2>/dev/null || printf \'\'; } | base64 -w0)" "$(sha256sum "$FILE" | cut -d" " -f1)" "$(stat -c %s "$FILE")"',
1075
+ ' append_manifest "$ROW"',
1076
+ `done < <(find "$ROOT" \\( -path "$ROOT/${WORKSPACE_GENERATIONS_DIR}" -o -path "$ROOT/${WORKSPACE_SYNC_DIR}" -o -path "$ROOT/.frockbot-locks" -o -type d \\( ${ignoredExpression} \\) \\) -prune -o -type f -print0 | sort -z)`,
1077
+ 'cat "$MANIFEST"',
1078
+ 'printf "X\\t%s\\t%s\\n" "$IGNORED" "$OMITTED"',
913
1079
  ].join("\n");
914
1080
  const output = await this.run(script);
915
1081
  if (typeof output !== "string") return output;
916
1082
  const entries: ComputerSyncEntryV1[] = [];
917
1083
  const removed = new Map<string, ComputerSyncRemovalV1>();
1084
+ let ignored = 0;
1085
+ let omitted = 0;
918
1086
  for (const row of output.split("\n")) {
919
1087
  if (!row.trim()) continue;
920
1088
  const [tag, encodedPath, second = "", third = "", fourth = ""] =
921
1089
  row.split("\t");
922
1090
  if (!encodedPath) continue;
1091
+ if (tag === "X") {
1092
+ ignored = Number(encodedPath);
1093
+ omitted = Number(second);
1094
+ continue;
1095
+ }
923
1096
  const path = Buffer.from(encodedPath, "base64").toString("utf8");
924
1097
  const relative = this.relative(path);
925
1098
  if (typeof relative !== "string") continue;
@@ -955,7 +1128,7 @@ export class FlySpriteSyncSurface implements ComputerSyncSurfaceV1 {
955
1128
  }
956
1129
  return {
957
1130
  status: "ok",
958
- scan: { entries, removed: [...removed.values()] },
1131
+ scan: { entries, removed: [...removed.values()], ignored, omitted },
959
1132
  };
960
1133
  }
961
1134