@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 +8 -8
- package/src/provider.ts +28 -5
- package/src/sync.test.ts +268 -15
- package/src/sync.ts +192 -19
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/plugin-fly-sprite",
|
|
3
|
-
"version": "0.3.
|
|
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.
|
|
29
|
-
"@frockbot/computer-host-protocol": "0.3.
|
|
30
|
-
"@frockbot/computer-host-runtime": "0.3.
|
|
31
|
-
"@frockbot/kernel-contracts": "0.3.
|
|
32
|
-
"@frockbot/plugin-computer": "0.3.
|
|
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.
|
|
37
|
-
"@frockbot/workspace-store": "0.3.
|
|
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?:
|
|
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.
|
|
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
|
-
:
|
|
310
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
647
|
-
//
|
|
648
|
-
//
|
|
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
|
-
//
|
|
816
|
+
// An ordinary shell source edit on the Computer is mirrored back.
|
|
671
817
|
sprite.shellWrite(
|
|
672
818
|
MOUNTS.appletSource,
|
|
673
|
-
`${appletId}/
|
|
674
|
-
"export class
|
|
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}/
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
| {
|
|
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(
|
|
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
|
-
|
|
898
|
-
'
|
|
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
|
|
901
|
-
"
|
|
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
|
-
'
|
|
1056
|
+
'while IFS= read -r -d "" FILE; do',
|
|
904
1057
|
' REL=${FILE#"$GRAVES"/}',
|
|
905
|
-
'
|
|
906
|
-
"
|
|
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
|
-
'
|
|
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
|
|
912
|
-
"
|
|
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
|
|