@frockbot/plugin-fly-sprite 0.3.28 → 0.3.30
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/sync-script.test.ts +232 -0
- package/src/sync.test.ts +52 -0
- package/src/sync.ts +30 -3
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.30",
|
|
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.30",
|
|
29
|
+
"@frockbot/computer-host-protocol": "0.3.30",
|
|
30
|
+
"@frockbot/computer-host-runtime": "0.3.30",
|
|
31
|
+
"@frockbot/kernel-contracts": "0.3.30",
|
|
32
|
+
"@frockbot/plugin-computer": "0.3.30",
|
|
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.30",
|
|
37
|
+
"@frockbot/workspace-store": "0.3.30",
|
|
38
38
|
"@types/bun": "1.4.0",
|
|
39
39
|
"@types/node": "26.2.0",
|
|
40
40
|
"typescript": "^7.0.2"
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/// <reference types="bun" />
|
|
2
|
+
|
|
3
|
+
// The scan manifest, run as the shell the Sprite runs.
|
|
4
|
+
//
|
|
5
|
+
// Every other test in this Package interprets the sync's bash in TypeScript
|
|
6
|
+
// (`FakeSyncSprite`), which is fast, hermetic, and exactly as wrong as the
|
|
7
|
+
// interpreter is. It was wrong about one thing for two production incidents:
|
|
8
|
+
// the list of required paths is newline-*separated*, so `while read` dropped
|
|
9
|
+
// the last entry of it, and the last entry is always `dist/manifest.json`. The
|
|
10
|
+
// interpreter split on "\n" and saw three paths where the shell saw two, so
|
|
11
|
+
// every publish lost its manifest and every test said the sync was fine.
|
|
12
|
+
//
|
|
13
|
+
// So this file runs the real emitted document under a real bash against a real
|
|
14
|
+
// directory. It needs bash 4+ and GNU coreutils, which is what a Sprite has and
|
|
15
|
+
// what CI has; on a machine without them it says so rather than pretending to
|
|
16
|
+
// pass.
|
|
17
|
+
import { describe, expect, test } from "bun:test";
|
|
18
|
+
import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
19
|
+
import { tmpdir } from "node:os";
|
|
20
|
+
import { dirname, join } from "node:path";
|
|
21
|
+
import type { WorkspaceRootV1 } from "@frockbot/kernel-contracts";
|
|
22
|
+
import { computerBotKey } from "./computer.ts";
|
|
23
|
+
import { FLY_WORKSPACE_LAYOUT } from "./provider.ts";
|
|
24
|
+
import { FlySpriteSyncSurface } from "./sync.ts";
|
|
25
|
+
|
|
26
|
+
const USER = "owner";
|
|
27
|
+
// The Applet id from the production failure: a dot in the middle, which is
|
|
28
|
+
// exactly the shape a careless path normalization would mangle.
|
|
29
|
+
const APPLET = "vgpqfaCcwnPlz.e1f813c4b3398e3ee947b323b9996491";
|
|
30
|
+
|
|
31
|
+
const appletSourceRoot: WorkspaceRootV1 = {
|
|
32
|
+
kind: "package-declared",
|
|
33
|
+
userId: USER,
|
|
34
|
+
packageId: "applets",
|
|
35
|
+
rootId: "source",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A bash that supports process substitution and `printf -v`, with GNU
|
|
40
|
+
* `base64 -w0`, `stat -c`, and `sha256sum` on its PATH. Linux has both as
|
|
41
|
+
* itself; macOS needs Homebrew's bash and coreutils.
|
|
42
|
+
*/
|
|
43
|
+
async function gnuShell(): Promise<{ bash: string; path: string } | undefined> {
|
|
44
|
+
const candidates = ["/opt/homebrew/bin/bash", "/usr/local/bin/bash", "bash"];
|
|
45
|
+
const prefixes = [
|
|
46
|
+
"/opt/homebrew/opt/coreutils/libexec/gnubin",
|
|
47
|
+
"/usr/local/opt/coreutils/libexec/gnubin",
|
|
48
|
+
].filter((entry) => existsSync(`${entry}/base64`));
|
|
49
|
+
for (const bash of candidates) {
|
|
50
|
+
for (const prefix of [...prefixes, undefined]) {
|
|
51
|
+
const path = prefix ? `${prefix}:${process.env.PATH}` : process.env.PATH;
|
|
52
|
+
try {
|
|
53
|
+
// A missing interpreter throws out of `spawn` rather than exiting
|
|
54
|
+
// non-zero, so the probe for one has to catch as well as check.
|
|
55
|
+
const probe = Bun.spawn([bash, "-c", "printf '' | base64 -w0"], {
|
|
56
|
+
env: { ...process.env, ...(path ? { PATH: path } : {}) },
|
|
57
|
+
stdout: "ignore",
|
|
58
|
+
stderr: "ignore",
|
|
59
|
+
});
|
|
60
|
+
if ((await probe.exited) === 0) return { bash, path: path ?? "" };
|
|
61
|
+
} catch {
|
|
62
|
+
// No such bash on this machine; try the next candidate.
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface ScanRow {
|
|
70
|
+
tag: string;
|
|
71
|
+
path: string;
|
|
72
|
+
meta: string;
|
|
73
|
+
contentHash: string;
|
|
74
|
+
size: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Captures the exact document `scan` would send, without a Computer. */
|
|
78
|
+
async function emitted(requiredPaths: readonly string[]): Promise<string> {
|
|
79
|
+
let script = "";
|
|
80
|
+
const surface = new FlySpriteSyncSurface({
|
|
81
|
+
computer: {
|
|
82
|
+
runStorage: (text: string) => {
|
|
83
|
+
if (text.includes("append_manifest")) script = text;
|
|
84
|
+
return Promise.resolve("");
|
|
85
|
+
},
|
|
86
|
+
} as never,
|
|
87
|
+
layout: FLY_WORKSPACE_LAYOUT,
|
|
88
|
+
userId: USER,
|
|
89
|
+
botDirectoryKey: computerBotKey,
|
|
90
|
+
});
|
|
91
|
+
await surface.scan(appletSourceRoot, requiredPaths);
|
|
92
|
+
if (!script) throw new Error("the scan emitted no manifest script");
|
|
93
|
+
return script;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function write(root: string, relative: string, text: string): void {
|
|
97
|
+
const target = join(root, relative);
|
|
98
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
99
|
+
writeFileSync(target, text);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** A generation sidecar in the shape `decodeMeta` reads back. */
|
|
103
|
+
function sidecar(id: string): string {
|
|
104
|
+
return `${id}\n${JSON.stringify({
|
|
105
|
+
schemaVersion: 1,
|
|
106
|
+
generationId: id,
|
|
107
|
+
contentHash: "0".repeat(64),
|
|
108
|
+
size: 1,
|
|
109
|
+
writer: { kind: "unattributed" },
|
|
110
|
+
writtenAt: "2026-09-05T00:00:00.000Z",
|
|
111
|
+
})}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function run(
|
|
115
|
+
shell: { bash: string; path: string },
|
|
116
|
+
script: string,
|
|
117
|
+
root: string,
|
|
118
|
+
): Promise<ScanRow[]> {
|
|
119
|
+
const process_ = Bun.spawn([shell.bash], {
|
|
120
|
+
stdin: new Blob([script.replace(/^ROOT='[^']*'$/m, `ROOT='${root}'`)]),
|
|
121
|
+
env: { ...process.env, ...(shell.path ? { PATH: shell.path } : {}) },
|
|
122
|
+
stdout: "pipe",
|
|
123
|
+
stderr: "pipe",
|
|
124
|
+
});
|
|
125
|
+
const [code, out, err] = await Promise.all([
|
|
126
|
+
process_.exited,
|
|
127
|
+
new Response(process_.stdout).text(),
|
|
128
|
+
new Response(process_.stderr).text(),
|
|
129
|
+
]);
|
|
130
|
+
expect(err).toBe("");
|
|
131
|
+
expect(code).toBe(0);
|
|
132
|
+
return out
|
|
133
|
+
.split("\n")
|
|
134
|
+
.filter((row) => row.trim().length > 0)
|
|
135
|
+
.map((row) => {
|
|
136
|
+
const [tag = "", path = "", meta = "", contentHash = "", size = ""] =
|
|
137
|
+
row.split("\t");
|
|
138
|
+
return {
|
|
139
|
+
tag,
|
|
140
|
+
path: tag === "X" ? path : Buffer.from(path, "base64").toString("utf8"),
|
|
141
|
+
meta,
|
|
142
|
+
contentHash,
|
|
143
|
+
size,
|
|
144
|
+
};
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const shell = await gnuShell();
|
|
149
|
+
|
|
150
|
+
describe("the scan manifest, under a real shell", () => {
|
|
151
|
+
if (!shell) {
|
|
152
|
+
test("needs bash 4+ and GNU coreutils, which this machine has not got", () => {
|
|
153
|
+
expect(shell).toBeUndefined();
|
|
154
|
+
});
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Production, 2026-09-04 and 2026-09-05: `applet build` wrote all three
|
|
159
|
+
// files, the publish sync reported `ok` and moved nothing, and the publish
|
|
160
|
+
// said `"dist/manifest.json" is not-found: run applet build first`. On the
|
|
161
|
+
// Computer the two other files had generation sidecars and the manifest had
|
|
162
|
+
// none — the signature of a manifest row that was never emitted at all.
|
|
163
|
+
test("emits a row for every required path, including the last one", async () => {
|
|
164
|
+
const required = [
|
|
165
|
+
`${APPLET}/dist/server.js`,
|
|
166
|
+
`${APPLET}/dist/ui.html`,
|
|
167
|
+
`${APPLET}/dist/manifest.json`,
|
|
168
|
+
];
|
|
169
|
+
const root = mkdtempSync(join(tmpdir(), "frockbot-scan-"));
|
|
170
|
+
write(root, required[0]!, "export class Applet {}");
|
|
171
|
+
write(root, required[1]!, "<h1>hi</h1>");
|
|
172
|
+
write(root, required[2]!, '{"contract":1,"tools":[]}');
|
|
173
|
+
// The state the Computer was actually in: sidecars for the two files
|
|
174
|
+
// earlier runs had managed to push, and none for the manifest.
|
|
175
|
+
write(root, `.frockbot-generations/${required[0]!}`, sidecar("gen-1"));
|
|
176
|
+
write(root, `.frockbot-generations/${required[1]!}`, sidecar("gen-2"));
|
|
177
|
+
|
|
178
|
+
const rows = await run(shell, await emitted(required), root);
|
|
179
|
+
|
|
180
|
+
expect(
|
|
181
|
+
rows.filter((row) => row.tag === "F").map((row) => row.path),
|
|
182
|
+
).toEqual(required);
|
|
183
|
+
const manifest = rows.find((row) => row.path === required[2]);
|
|
184
|
+
expect(manifest?.meta).toBe("");
|
|
185
|
+
expect(manifest?.size).toBe("25");
|
|
186
|
+
expect(manifest?.contentHash).toMatch(/^[0-9a-f]{64}$/);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("emits the one row of a single-path required list", async () => {
|
|
190
|
+
const required = [`${APPLET}/dist/manifest.json`];
|
|
191
|
+
const root = mkdtempSync(join(tmpdir(), "frockbot-scan-"));
|
|
192
|
+
write(root, required[0]!, "{}");
|
|
193
|
+
|
|
194
|
+
const rows = await run(shell, await emitted(required), root);
|
|
195
|
+
|
|
196
|
+
expect(
|
|
197
|
+
rows.filter((row) => row.tag === "F").map((row) => row.path),
|
|
198
|
+
).toEqual(required);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("emits nothing for a required path the Computer does not hold", async () => {
|
|
202
|
+
const required = [`${APPLET}/dist/manifest.json`];
|
|
203
|
+
const root = mkdtempSync(join(tmpdir(), "frockbot-scan-"));
|
|
204
|
+
mkdirSync(root, { recursive: true });
|
|
205
|
+
|
|
206
|
+
const rows = await run(shell, await emitted(required), root);
|
|
207
|
+
|
|
208
|
+
expect(rows.filter((row) => row.tag === "F")).toEqual([]);
|
|
209
|
+
expect(rows.at(-1)?.tag).toBe("X");
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// The bound the required list exists to get around: `dist/` is pruned from
|
|
213
|
+
// the ordinary walk, so a required path is the only way its bytes are seen,
|
|
214
|
+
// and the walk must still prune everything it always pruned.
|
|
215
|
+
test("still prunes reproducible trees while carrying the required paths", async () => {
|
|
216
|
+
const required = [`${APPLET}/dist/manifest.json`];
|
|
217
|
+
const root = mkdtempSync(join(tmpdir(), "frockbot-scan-"));
|
|
218
|
+
write(root, required[0]!, "{}");
|
|
219
|
+
write(root, `${APPLET}/dist/ui.html`, "<h1>not required</h1>");
|
|
220
|
+
write(root, `${APPLET}/src/index.ts`, "export const x = 1;");
|
|
221
|
+
write(root, `${APPLET}/node_modules/dep/package.json`, "{}");
|
|
222
|
+
|
|
223
|
+
const rows = await run(shell, await emitted(required), root);
|
|
224
|
+
|
|
225
|
+
expect(
|
|
226
|
+
rows.filter((row) => row.tag === "F").map((row) => row.path),
|
|
227
|
+
).toEqual([required[0]!, `${APPLET}/src/index.ts`]);
|
|
228
|
+
// One pruned directory: `node_modules`. `dist` is not counted, because a
|
|
229
|
+
// required path made it a directory the caller asked to see into.
|
|
230
|
+
expect(rows.at(-1)).toMatchObject({ tag: "X", path: "1", meta: "0" });
|
|
231
|
+
});
|
|
232
|
+
});
|
package/src/sync.test.ts
CHANGED
|
@@ -1498,6 +1498,58 @@ describe("the durable-root sync on the Computer handle", () => {
|
|
|
1498
1498
|
// nothing to do — while the store held no object for it at all. A required
|
|
1499
1499
|
// path is the one thing the caller has asserted about, so the Computer's
|
|
1500
1500
|
// copy of it decides, not its sidecar.
|
|
1501
|
+
// The state a fresh `applet build` leaves: three files under an ignored
|
|
1502
|
+
// directory, none of them ever synced, so none of them has a sidecar. The
|
|
1503
|
+
// publish names all three, and all three must be readable from the store
|
|
1504
|
+
// when the reconcile returns — the last one included. (`sync-script.test.ts`
|
|
1505
|
+
// is where the shell that emits their manifest rows is held to that; this is
|
|
1506
|
+
// the reconcile above it.)
|
|
1507
|
+
test("carries every required file of a fresh build that has no sidecars", async () => {
|
|
1508
|
+
const { sprite, store, open } = providerHarness([
|
|
1509
|
+
APPLET_SOURCE_PACKAGE_ROOT,
|
|
1510
|
+
]);
|
|
1511
|
+
const handle = await open();
|
|
1512
|
+
const appletId = "pub-user-1.0123456789abcdef0123456789abcdef";
|
|
1513
|
+
const built = {
|
|
1514
|
+
"dist/server.js": "export class Applet {}",
|
|
1515
|
+
"dist/ui.html": "<h1>todo</h1>",
|
|
1516
|
+
"dist/manifest.json": '{"contract":1,"tools":[]}',
|
|
1517
|
+
};
|
|
1518
|
+
for (const [file, text] of Object.entries(built)) {
|
|
1519
|
+
sprite.shellWrite(MOUNTS.appletSource, `${appletId}/${file}`, text);
|
|
1520
|
+
}
|
|
1521
|
+
expect(sprite.keys(`${MOUNTS.appletSource}/.frockbot-generations`)).toEqual(
|
|
1522
|
+
[],
|
|
1523
|
+
);
|
|
1524
|
+
|
|
1525
|
+
const summary = await handle.sync!.reconcileRoot!(
|
|
1526
|
+
appletSourceRoot,
|
|
1527
|
+
"publish",
|
|
1528
|
+
{
|
|
1529
|
+
requiredPaths: Object.keys(built).map((file) => `${appletId}/${file}`),
|
|
1530
|
+
},
|
|
1531
|
+
);
|
|
1532
|
+
|
|
1533
|
+
expect(summary).toMatchObject({ status: "ok", pushed: 3, failures: 0 });
|
|
1534
|
+
expect(summary.required).toEqual(
|
|
1535
|
+
Object.entries(built).map(([file, text]) => ({
|
|
1536
|
+
path: `${appletId}/${file}`,
|
|
1537
|
+
contentHash: sha256(encoder.encode(text)),
|
|
1538
|
+
durable: true,
|
|
1539
|
+
})),
|
|
1540
|
+
);
|
|
1541
|
+
for (const [file, text] of Object.entries(built)) {
|
|
1542
|
+
const read = await store.read({
|
|
1543
|
+
root: appletSourceRoot,
|
|
1544
|
+
path: `${appletId}/${file}`,
|
|
1545
|
+
});
|
|
1546
|
+
expect(read.status).toBe("ok");
|
|
1547
|
+
if (read.status === "ok") {
|
|
1548
|
+
expect(decoder.decode(read.file.bytes)).toBe(text);
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
});
|
|
1552
|
+
|
|
1501
1553
|
test("pushes a required file the store is missing even when its sidecar says clean", async () => {
|
|
1502
1554
|
const { sprite, store, open } = providerHarness([
|
|
1503
1555
|
APPLET_SOURCE_PACKAGE_ROOT,
|
package/src/sync.ts
CHANGED
|
@@ -186,6 +186,19 @@ function isFailure(value: { status: string }): value is WorkspaceFailureV1 {
|
|
|
186
186
|
return value.status !== "ok";
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
+
/**
|
|
190
|
+
* A durable-root-relative path in the one shape everything here compares.
|
|
191
|
+
* A path the normalizer refuses is kept as it came: it will match nothing,
|
|
192
|
+
* which is the truth about it, rather than throwing out of a reconcile.
|
|
193
|
+
*/
|
|
194
|
+
function normalizedOrRaw(path: string): string {
|
|
195
|
+
try {
|
|
196
|
+
return normalizeWorkspaceRelativePathV1(path);
|
|
197
|
+
} catch {
|
|
198
|
+
return path;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
189
202
|
/** A file the Computer holds under one durable root. */
|
|
190
203
|
export interface ComputerSyncEntryV1 {
|
|
191
204
|
path: string;
|
|
@@ -456,7 +469,11 @@ class WorkspaceRootSync implements WorkspaceRootSyncV1 {
|
|
|
456
469
|
requiredPaths: readonly string[] = [],
|
|
457
470
|
): Promise<WorkspaceSyncRootReportV1> {
|
|
458
471
|
const report = emptyReport(root);
|
|
459
|
-
|
|
472
|
+
// Normalized on the way in, because every path this compares it against —
|
|
473
|
+
// a scan row, a store listing entry — has been through the same
|
|
474
|
+
// normalization, and a required path that missed it would silently match
|
|
475
|
+
// nothing.
|
|
476
|
+
const required = new Set(requiredPaths.map(normalizedOrRaw));
|
|
460
477
|
const scanned = await this.options.computer.scan(root, requiredPaths);
|
|
461
478
|
if (isFailure(scanned)) {
|
|
462
479
|
report.failures.push({ ...scanned, root });
|
|
@@ -658,7 +675,7 @@ class WorkspaceRootSync implements WorkspaceRootSyncV1 {
|
|
|
658
675
|
| WorkspaceFailureV1
|
|
659
676
|
> {
|
|
660
677
|
const generations = new Map<string, WorkspaceGenerationV1>();
|
|
661
|
-
const required = new Set(requiredPaths);
|
|
678
|
+
const required = new Set(requiredPaths.map(normalizedOrRaw));
|
|
662
679
|
let ignored = 0;
|
|
663
680
|
let cursor: string | undefined;
|
|
664
681
|
for (let page = 0; page < MAX_STORE_PAGES; page += 1) {
|
|
@@ -1186,7 +1203,17 @@ export class FlySpriteSyncSurface implements ComputerSyncSurfaceV1 {
|
|
|
1186
1203
|
// Required artifact files go first: the source manifest may be full,
|
|
1187
1204
|
// but an explicit one-root publisher must still receive the exact bytes
|
|
1188
1205
|
// it named. They remain subject to the same byte and entry bounds.
|
|
1189
|
-
|
|
1206
|
+
//
|
|
1207
|
+
// `|| [ -n "$REL" ]` is not defensive style, it is the loop's last
|
|
1208
|
+
// iteration. The list is newline-*separated*, not newline-terminated, so
|
|
1209
|
+
// `read` consumes the final path and then reports EOF, and a plain
|
|
1210
|
+
// `while read` throws that path away. Every publish therefore lost the
|
|
1211
|
+
// last file it named: `dist/manifest.json`, which is why an Applet that
|
|
1212
|
+
// had just been built was published as "not-found: run `applet build`"
|
|
1213
|
+
// (production, 2026-09-04 and 2026-09-05). `REL` is primed so `set -u`
|
|
1214
|
+
// has something to read on an empty list.
|
|
1215
|
+
'REL=""',
|
|
1216
|
+
'while IFS= read -r REL || [ -n "$REL" ]; do',
|
|
1190
1217
|
' [ -n "$REL" ] || continue',
|
|
1191
1218
|
' FILE="$ROOT/$REL"',
|
|
1192
1219
|
' if [ -f "$FILE" ]; then',
|