@frockbot/plugin-fly-sprite 0.3.23 → 0.3.25
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/computer.test.ts +9 -5
- package/src/computer.ts +8 -1
- package/src/sync.test.ts +5 -3
- package/src/sync.ts +11 -41
- package/src/workspace.test.ts +275 -18
- package/src/workspace.ts +173 -17
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.25",
|
|
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.25",
|
|
29
|
+
"@frockbot/computer-host-protocol": "0.3.25",
|
|
30
|
+
"@frockbot/computer-host-runtime": "0.3.25",
|
|
31
|
+
"@frockbot/kernel-contracts": "0.3.25",
|
|
32
|
+
"@frockbot/plugin-computer": "0.3.25",
|
|
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.25",
|
|
37
|
+
"@frockbot/workspace-store": "0.3.25",
|
|
38
38
|
"@types/bun": "1.4.0",
|
|
39
39
|
"@types/node": "26.2.0",
|
|
40
40
|
"typescript": "^7.0.2"
|
package/src/computer.test.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/// <reference types="bun" />
|
|
2
2
|
|
|
3
3
|
import { describe, expect, test } from "bun:test";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
4
5
|
import type { ComputerConnectionProgressV1 } from "@frockbot/computer-core";
|
|
5
6
|
import { COMPUTER_UNCONFIGURED_MESSAGE_V1 } from "@frockbot/computer-core";
|
|
6
7
|
import { verifyPluginPackage } from "@frockbot/plugin-testkit";
|
|
@@ -63,15 +64,18 @@ function computerRunner(script: string): FakeComputerRunV1 {
|
|
|
63
64
|
}
|
|
64
65
|
if (script.includes('mv "$TMP" "$TARGET"'))
|
|
65
66
|
return { stdout: "__WRITTEN__\n" };
|
|
66
|
-
if (script.includes('base64 -w0
|
|
67
|
-
// meta, content hash, size, mtime,
|
|
67
|
+
if (script.includes('"$TARGET" | base64 -w0')) {
|
|
68
|
+
// meta, content hash, size, mtime, first chunk — the Workspace file shape.
|
|
69
|
+
// The digest is the file's real one, because the read proves the chunks it
|
|
70
|
+
// stitched are one file against exactly this value.
|
|
71
|
+
const bytes = Buffer.from("remember");
|
|
68
72
|
return {
|
|
69
73
|
stdout: [
|
|
70
74
|
"",
|
|
71
|
-
"
|
|
72
|
-
|
|
75
|
+
createHash("sha256").update(bytes).digest("hex"),
|
|
76
|
+
String(bytes.byteLength),
|
|
73
77
|
"1700000000",
|
|
74
|
-
|
|
78
|
+
bytes.toString("base64"),
|
|
75
79
|
"",
|
|
76
80
|
].join("\n"),
|
|
77
81
|
};
|
package/src/computer.ts
CHANGED
|
@@ -53,7 +53,14 @@ import type {
|
|
|
53
53
|
export { SLOT_IDLE_SECONDS, WORKSPACE_SYNC_SERVICE };
|
|
54
54
|
|
|
55
55
|
const MAX_OUTPUT = 30_000;
|
|
56
|
-
|
|
56
|
+
/**
|
|
57
|
+
* The most a Workspace or sync command may answer with.
|
|
58
|
+
*
|
|
59
|
+
* Exported because it is the ceiling those commands are written to stay under,
|
|
60
|
+
* and a test double that answered past it would prove a read works at a size
|
|
61
|
+
* the Computer would have refused.
|
|
62
|
+
*/
|
|
63
|
+
export const MAX_STORAGE_OUTPUT = 500_000;
|
|
57
64
|
const EXEC_EXIT_MARKER = "__FROCKBOT_EXIT__";
|
|
58
65
|
/** Largest screenshot this provider will carry back off a Computer. */
|
|
59
66
|
export const SCREENSHOT_MAX_BYTES = 8 * 1024 * 1024;
|
package/src/sync.test.ts
CHANGED
|
@@ -25,13 +25,15 @@ import {
|
|
|
25
25
|
FlySpriteSyncSurface,
|
|
26
26
|
isWorkspaceSyncIgnoredPathV1,
|
|
27
27
|
WORKSPACE_SYNC_IGNORED_DIRECTORIES_V1,
|
|
28
|
-
WORKSPACE_SYNC_CHUNK_BYTES_V1,
|
|
29
28
|
WORKSPACE_SYNC_MANIFEST_MAX_BYTES_V1,
|
|
30
29
|
WORKSPACE_SYNC_MANIFEST_MAX_ENTRIES_V1,
|
|
31
30
|
WORKSPACE_SYNC_MAX_FILE_BYTES_V1,
|
|
32
31
|
type WorkspaceSyncReportV1,
|
|
33
32
|
} from "./sync.ts";
|
|
34
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
WORKSPACE_CHUNK_BYTES_V1,
|
|
35
|
+
WORKSPACE_EMPTY_SHA256,
|
|
36
|
+
} from "./workspace.ts";
|
|
35
37
|
|
|
36
38
|
const USER = "owner";
|
|
37
39
|
const BOT = "health";
|
|
@@ -219,7 +221,7 @@ class FakeSyncSprite {
|
|
|
219
221
|
if (bytes.byteLength > WORKSPACE_SYNC_MAX_FILE_BYTES_V1) {
|
|
220
222
|
return "__TOO_LARGE__\n";
|
|
221
223
|
}
|
|
222
|
-
const head = bytes.subarray(0,
|
|
224
|
+
const head = bytes.subarray(0, WORKSPACE_CHUNK_BYTES_V1);
|
|
223
225
|
return `${bytes.byteLength}\t${sha256(bytes)}\n${Buffer.from(head).toString("base64")}\n`;
|
|
224
226
|
}
|
|
225
227
|
|
package/src/sync.ts
CHANGED
|
@@ -105,9 +105,12 @@ import {
|
|
|
105
105
|
SYNC_CONFLICTS_DIR,
|
|
106
106
|
SYNC_STAGING_DIR,
|
|
107
107
|
SYNC_TOMBSTONES_DIR,
|
|
108
|
+
WORKSPACE_CHUNK_BYTES_V1,
|
|
108
109
|
WORKSPACE_EMPTY_SHA256,
|
|
109
110
|
WORKSPACE_GENERATIONS_DIR,
|
|
110
111
|
WORKSPACE_SYNC_DIR,
|
|
112
|
+
workspaceChunkOffsetsV1,
|
|
113
|
+
workspaceStagingNameV1,
|
|
111
114
|
} from "./workspace.js";
|
|
112
115
|
|
|
113
116
|
/** Where the sync keeps notes that are not scoped to one root. */
|
|
@@ -146,17 +149,6 @@ export const WORKSPACE_SYNC_MANIFEST_MAX_BYTES_V1 = 400_000;
|
|
|
146
149
|
/** Bounds work and rows even when unusually short paths fit under the bytes. */
|
|
147
150
|
export const WORKSPACE_SYNC_MANIFEST_MAX_ENTRIES_V1 = 2_000;
|
|
148
151
|
|
|
149
|
-
/**
|
|
150
|
-
* The most file bytes one storage command carries, in either direction.
|
|
151
|
-
*
|
|
152
|
-
* A command's answer travels as base64, so a chunk this size comes back as
|
|
153
|
-
* roughly 350 KB — inside the 500 KB an answer may be — and goes out inside a
|
|
154
|
-
* script well inside the 1 MB a script may be. A file larger than one chunk
|
|
155
|
-
* travels as several commands rather than as one command that would be refused
|
|
156
|
-
* for its size, which is the whole reason the sync reads and writes in chunks.
|
|
157
|
-
*/
|
|
158
|
-
export const WORKSPACE_SYNC_CHUNK_BYTES_V1 = 256 * 1024;
|
|
159
|
-
|
|
160
152
|
/**
|
|
161
153
|
* The largest durable-root file the sync carries between the Computer and the
|
|
162
154
|
* store.
|
|
@@ -168,25 +160,6 @@ export const WORKSPACE_SYNC_CHUNK_BYTES_V1 = 256 * 1024;
|
|
|
168
160
|
*/
|
|
169
161
|
export const WORKSPACE_SYNC_MAX_FILE_BYTES_V1 = 4 * 1024 * 1024;
|
|
170
162
|
|
|
171
|
-
/** Every chunk offset after the first, in bytes, for a file of this size. */
|
|
172
|
-
function chunkOffsets(size: number): number[] {
|
|
173
|
-
const offsets: number[] = [];
|
|
174
|
-
for (
|
|
175
|
-
let offset = WORKSPACE_SYNC_CHUNK_BYTES_V1;
|
|
176
|
-
offset < size;
|
|
177
|
-
offset += WORKSPACE_SYNC_CHUNK_BYTES_V1
|
|
178
|
-
) {
|
|
179
|
-
offsets.push(offset);
|
|
180
|
-
}
|
|
181
|
-
return offsets;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/** A staging file name that is one path segment whatever the id looks like. */
|
|
185
|
-
function stagingName(generationId: string, kind: string): string {
|
|
186
|
-
const safe = generationId.replaceAll(/[^A-Za-z0-9._-]/g, "-").slice(0, 128);
|
|
187
|
-
return `${safe}.${kind}`;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
163
|
const SHA256_HEX = /^[0-9a-f]{64}$/;
|
|
191
164
|
|
|
192
165
|
const ignoredDirectoryNames = new Set<string>(
|
|
@@ -1205,7 +1178,7 @@ export class FlySpriteSyncSurface implements ComputerSyncSurfaceV1 {
|
|
|
1205
1178
|
const head = await this.run(
|
|
1206
1179
|
[
|
|
1207
1180
|
...prelude,
|
|
1208
|
-
`CHUNK=${
|
|
1181
|
+
`CHUNK=${WORKSPACE_CHUNK_BYTES_V1}`,
|
|
1209
1182
|
'if [ ! -f "$FILE" ]; then echo __MISSING__; exit 0; fi',
|
|
1210
1183
|
'SIZE=$(stat -c %s "$FILE")',
|
|
1211
1184
|
`if [ "$SIZE" -gt ${WORKSPACE_SYNC_MAX_FILE_BYTES_V1} ]; then echo __TOO_LARGE__; exit 0; fi`,
|
|
@@ -1230,13 +1203,13 @@ export class FlySpriteSyncSurface implements ComputerSyncSurfaceV1 {
|
|
|
1230
1203
|
return failure("unavailable", "Invalid Fly Workspace sync response");
|
|
1231
1204
|
}
|
|
1232
1205
|
const chunks = [Buffer.from(first.trim(), "base64")];
|
|
1233
|
-
for (const offset of
|
|
1206
|
+
for (const offset of workspaceChunkOffsetsV1(size)) {
|
|
1234
1207
|
const next = await this.run(
|
|
1235
1208
|
[
|
|
1236
1209
|
...prelude,
|
|
1237
1210
|
// `tail -c +N` counts from one, so the offset is one past the bytes
|
|
1238
1211
|
// already carried.
|
|
1239
|
-
`tail -c +${offset + 1} "$FILE" | head -c ${
|
|
1212
|
+
`tail -c +${offset + 1} "$FILE" | head -c ${WORKSPACE_CHUNK_BYTES_V1} | base64 -w0; echo`,
|
|
1240
1213
|
].join("\n"),
|
|
1241
1214
|
);
|
|
1242
1215
|
if (typeof next !== "string") return next;
|
|
@@ -1271,17 +1244,14 @@ export class FlySpriteSyncSurface implements ComputerSyncSurfaceV1 {
|
|
|
1271
1244
|
name: string,
|
|
1272
1245
|
bytes: Uint8Array,
|
|
1273
1246
|
): Promise<string | WorkspaceFailureV1 | undefined> {
|
|
1274
|
-
if (bytes.byteLength <=
|
|
1247
|
+
if (bytes.byteLength <= WORKSPACE_CHUNK_BYTES_V1) return undefined;
|
|
1275
1248
|
const staged = `${mount}/${WORKSPACE_SYNC_DIR}/${SYNC_STAGING_DIR}/${name}`;
|
|
1276
1249
|
for (
|
|
1277
1250
|
let offset = 0;
|
|
1278
1251
|
offset < bytes.byteLength;
|
|
1279
|
-
offset +=
|
|
1252
|
+
offset += WORKSPACE_CHUNK_BYTES_V1
|
|
1280
1253
|
) {
|
|
1281
|
-
const chunk = bytes.subarray(
|
|
1282
|
-
offset,
|
|
1283
|
-
offset + WORKSPACE_SYNC_CHUNK_BYTES_V1,
|
|
1284
|
-
);
|
|
1254
|
+
const chunk = bytes.subarray(offset, offset + WORKSPACE_CHUNK_BYTES_V1);
|
|
1285
1255
|
const output = await this.run(
|
|
1286
1256
|
[
|
|
1287
1257
|
"set -eu",
|
|
@@ -1315,7 +1285,7 @@ export class FlySpriteSyncSurface implements ComputerSyncSurfaceV1 {
|
|
|
1315
1285
|
if (oversized) return oversized;
|
|
1316
1286
|
const staged = await this.stage(
|
|
1317
1287
|
mount,
|
|
1318
|
-
|
|
1288
|
+
workspaceStagingNameV1(generation.generationId, "pull"),
|
|
1319
1289
|
bytes,
|
|
1320
1290
|
);
|
|
1321
1291
|
if (staged && typeof staged !== "string") return staged;
|
|
@@ -1462,7 +1432,7 @@ export class FlySpriteSyncSurface implements ComputerSyncSurfaceV1 {
|
|
|
1462
1432
|
if (oversized) return oversized;
|
|
1463
1433
|
const staged = await this.stage(
|
|
1464
1434
|
mount,
|
|
1465
|
-
|
|
1435
|
+
workspaceStagingNameV1(generation.generationId, "conflict"),
|
|
1466
1436
|
bytes,
|
|
1467
1437
|
);
|
|
1468
1438
|
if (staged && typeof staged !== "string") return staged;
|
package/src/workspace.test.ts
CHANGED
|
@@ -20,10 +20,11 @@ import {
|
|
|
20
20
|
computerBotKey,
|
|
21
21
|
type ComputerHostFactoryV1,
|
|
22
22
|
FlySpriteComputer,
|
|
23
|
+
MAX_STORAGE_OUTPUT,
|
|
23
24
|
} from "./computer.ts";
|
|
24
25
|
import { FakeComputerHost, type FakeComputerRunV1 } from "./host-double.ts";
|
|
25
26
|
import { FLY_WORKSPACE_LAYOUT, FlySpriteComputerProvider } from "./provider.ts";
|
|
26
|
-
import { FlyComputerWorkspace } from "./workspace.ts";
|
|
27
|
+
import { FlyComputerWorkspace, WORKSPACE_CHUNK_BYTES_V1 } from "./workspace.ts";
|
|
27
28
|
|
|
28
29
|
const USER = "owner";
|
|
29
30
|
const BOT = "health";
|
|
@@ -69,6 +70,19 @@ function sha256(bytes: Uint8Array): string {
|
|
|
69
70
|
return createHash("sha256").update(bytes).digest("hex");
|
|
70
71
|
}
|
|
71
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Bytes of an exact length whose every chunk differs from every other, so a
|
|
75
|
+
* chunk carried twice, dropped, or reordered changes the bytes rather than
|
|
76
|
+
* hiding inside a repeated pattern.
|
|
77
|
+
*/
|
|
78
|
+
function largeBytes(length: number): Uint8Array {
|
|
79
|
+
let text = "";
|
|
80
|
+
for (let index = 0; text.length < length; index += 1) {
|
|
81
|
+
text += `${index}:${"abcdefghijklmnopqrstuvwxyz".repeat(3)}\n`;
|
|
82
|
+
}
|
|
83
|
+
return new TextEncoder().encode(text.slice(0, length));
|
|
84
|
+
}
|
|
85
|
+
|
|
72
86
|
function quoted(shell: string, name: string): string | undefined {
|
|
73
87
|
return new RegExp(`${name}='([^']*)'`).exec(shell)?.[1];
|
|
74
88
|
}
|
|
@@ -83,28 +97,39 @@ class FakeWorkspaceDisk {
|
|
|
83
97
|
offline = false;
|
|
84
98
|
modifiedSeconds = 1_700_000_000;
|
|
85
99
|
|
|
100
|
+
/** Every script this disk was handed, in order. */
|
|
101
|
+
readonly scripts: string[] = [];
|
|
102
|
+
/** Rewrites the file at this path between two chunk commands of one read. */
|
|
103
|
+
midReadRewrite?: { path: string; bytes: Uint8Array };
|
|
104
|
+
|
|
86
105
|
/** The runner the shared host double hands every script to. */
|
|
87
106
|
readonly run = (script: string): FakeComputerRunV1 => {
|
|
107
|
+
this.scripts.push(script);
|
|
88
108
|
if (this.offline) return { exitCode: 1, stderr: "Sprite is paused" };
|
|
109
|
+
// The real host's storage surface refuses an answer past this, so a double
|
|
110
|
+
// that returned one would let a suite prove a read works at a size the
|
|
111
|
+
// Computer would never have carried.
|
|
112
|
+
const bounded = (stdout: string): FakeComputerRunV1 =>
|
|
113
|
+
stdout.length > MAX_STORAGE_OUTPUT
|
|
114
|
+
? { stdout: stdout.slice(0, MAX_STORAGE_OUTPUT), outputTruncated: true }
|
|
115
|
+
: { stdout };
|
|
116
|
+
if (script.includes("__STAGED__")) {
|
|
117
|
+
return bounded(this.stageChunk(script));
|
|
118
|
+
}
|
|
89
119
|
const root = quoted(script, "ROOT");
|
|
90
120
|
const relative = quoted(script, "REL");
|
|
91
121
|
if (!root) return {};
|
|
92
122
|
if (script.includes("__WRITTEN__") && relative) {
|
|
93
|
-
return
|
|
123
|
+
return bounded(this.write(`${root}/${relative}`, script));
|
|
94
124
|
}
|
|
95
125
|
if (script.includes("__DELETED__") && relative) {
|
|
96
|
-
return
|
|
126
|
+
return bounded(this.remove(`${root}/${relative}`, script));
|
|
97
127
|
}
|
|
98
128
|
if (script.includes('find "$ROOT"')) {
|
|
99
|
-
return
|
|
129
|
+
return bounded(this.list(root, script));
|
|
100
130
|
}
|
|
101
131
|
if (relative) {
|
|
102
|
-
return {
|
|
103
|
-
stdout: this.load(
|
|
104
|
-
`${root}/${relative}`,
|
|
105
|
-
script.includes('base64 -w0 "$TARGET"'),
|
|
106
|
-
),
|
|
107
|
-
};
|
|
132
|
+
return bounded(this.load(`${root}/${relative}`, script));
|
|
108
133
|
}
|
|
109
134
|
return {};
|
|
110
135
|
};
|
|
@@ -122,14 +147,47 @@ class FakeWorkspaceDisk {
|
|
|
122
147
|
return /if \[ "\$CURRENT" != '([^']*)' \]/.exec(shell)?.[1] ?? "";
|
|
123
148
|
}
|
|
124
149
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const
|
|
128
|
-
const
|
|
150
|
+
/** One appended chunk of a staged file, as the write stages it. */
|
|
151
|
+
private stageChunk(shell: string): string {
|
|
152
|
+
const path = quoted(shell, "STAGE") ?? "";
|
|
153
|
+
const encoded =
|
|
154
|
+
/printf %s '([^']*)' \| base64 -d >> "\$STAGE"/.exec(shell)?.[1] ?? "";
|
|
155
|
+
const chunk = Buffer.from(encoded, "base64");
|
|
156
|
+
const held = shell.includes('rm -f "$STAGE"')
|
|
157
|
+
? undefined
|
|
158
|
+
: this.files.get(path)?.bytes;
|
|
129
159
|
this.files.set(path, {
|
|
130
|
-
bytes: Uint8Array.from(
|
|
131
|
-
|
|
160
|
+
bytes: Uint8Array.from(
|
|
161
|
+
held ? Buffer.concat([Buffer.from(held), chunk]) : chunk,
|
|
162
|
+
),
|
|
132
163
|
});
|
|
164
|
+
return "__STAGED__\n";
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private write(path: string, shell: string): string {
|
|
168
|
+
const stage = quoted(shell, "STAGE");
|
|
169
|
+
if (this.current(path) !== this.expected(shell)) {
|
|
170
|
+
if (stage) this.files.delete(stage);
|
|
171
|
+
return "__CONFLICT__\n";
|
|
172
|
+
}
|
|
173
|
+
const meta = /printf %s '([^']*)' \| base64 -d > "\$MTMP"/.exec(shell)?.[1];
|
|
174
|
+
let bytes: Uint8Array;
|
|
175
|
+
if (stage) {
|
|
176
|
+
// The staged bytes are digest-checked exactly as the emitted
|
|
177
|
+
// `sha256sum` line checks them, so a torn staging file is __CORRUPT__
|
|
178
|
+
// here for the same reason it would be on the Sprite.
|
|
179
|
+
const staged = this.files.get(stage)?.bytes ?? new Uint8Array();
|
|
180
|
+
this.files.delete(stage);
|
|
181
|
+
const expected = /f1\)" != '([0-9a-f]{64})'/.exec(shell)?.[1];
|
|
182
|
+
if (expected && sha256(staged) !== expected) return "__CORRUPT__\n";
|
|
183
|
+
bytes = staged;
|
|
184
|
+
} else {
|
|
185
|
+
const inline = /printf %s '([^']*)' \| base64 -d > "\$TMP"/.exec(
|
|
186
|
+
shell,
|
|
187
|
+
)?.[1];
|
|
188
|
+
bytes = Uint8Array.from(Buffer.from(inline ?? "", "base64"));
|
|
189
|
+
}
|
|
190
|
+
this.files.set(path, { bytes, meta });
|
|
133
191
|
return "__WRITTEN__\n";
|
|
134
192
|
}
|
|
135
193
|
|
|
@@ -140,7 +198,28 @@ class FakeWorkspaceDisk {
|
|
|
140
198
|
return "__DELETED__\n";
|
|
141
199
|
}
|
|
142
200
|
|
|
143
|
-
|
|
201
|
+
/**
|
|
202
|
+
* The chunked file read: a header of sidecar, digest, size, and mtime with
|
|
203
|
+
* the first chunk, then one chunk per further command. `head -c` and
|
|
204
|
+
* `tail -c +N` are the coreutils the Workspace emits; the arithmetic is
|
|
205
|
+
* theirs, not an approximation.
|
|
206
|
+
*/
|
|
207
|
+
private load(path: string, shell: string): string {
|
|
208
|
+
const chunk = /tail -c \+(\d+) "\$TARGET" \| head -c (\d+)/.exec(shell);
|
|
209
|
+
if (chunk) {
|
|
210
|
+
// A rewrite between two chunk commands is what makes a read report
|
|
211
|
+
// rather than stitch, so the double performs one where a test asks.
|
|
212
|
+
const rewrite = this.midReadRewrite;
|
|
213
|
+
if (rewrite) {
|
|
214
|
+
this.midReadRewrite = undefined;
|
|
215
|
+
this.files.set(rewrite.path, { bytes: rewrite.bytes });
|
|
216
|
+
}
|
|
217
|
+
const offset = Number(chunk[1]) - 1;
|
|
218
|
+
const limit = Number(chunk[2]);
|
|
219
|
+
const bytes = this.files.get(path)?.bytes;
|
|
220
|
+
const slice = bytes?.subarray(offset, offset + limit) ?? new Uint8Array();
|
|
221
|
+
return `${Buffer.from(slice).toString("base64")}\n`;
|
|
222
|
+
}
|
|
144
223
|
const entry = this.files.get(path);
|
|
145
224
|
if (!entry) return "__MISSING__\n";
|
|
146
225
|
if (entry.bytes.byteLength > WORKSPACE_MAX_FILE_BYTES) {
|
|
@@ -152,7 +231,14 @@ class FakeWorkspaceDisk {
|
|
|
152
231
|
String(entry.bytes.byteLength),
|
|
153
232
|
String(this.modifiedSeconds),
|
|
154
233
|
];
|
|
155
|
-
|
|
234
|
+
const head = /head -c (\d+) "\$TARGET" \| base64 -w0/.exec(shell);
|
|
235
|
+
if (head) {
|
|
236
|
+
lines.push(
|
|
237
|
+
Buffer.from(entry.bytes.subarray(0, Number(head[1]))).toString(
|
|
238
|
+
"base64",
|
|
239
|
+
),
|
|
240
|
+
);
|
|
241
|
+
}
|
|
156
242
|
return `${lines.join("\n")}\n`;
|
|
157
243
|
}
|
|
158
244
|
|
|
@@ -163,6 +249,9 @@ class FakeWorkspaceDisk {
|
|
|
163
249
|
const rows = [...this.files.entries()]
|
|
164
250
|
.filter(([path]) => path.startsWith(`${root}/`))
|
|
165
251
|
.map(([path, entry]) => [path.slice(root.length + 1), entry] as const)
|
|
252
|
+
// The emitted `find` prunes the lock, generation, and sync directories,
|
|
253
|
+
// so a listing never shows a staging file mid-write.
|
|
254
|
+
.filter(([relative]) => !relative.startsWith(".frockbot-"))
|
|
166
255
|
.filter(
|
|
167
256
|
([relative]) =>
|
|
168
257
|
!prefix || relative === prefix || relative.startsWith(`${prefix}/`),
|
|
@@ -949,3 +1038,171 @@ describe("the Computer's sidecar is a hint; the Durable Object is the authority"
|
|
|
949
1038
|
expect(read.file.generation.writer).toEqual({ kind: "unattributed" });
|
|
950
1039
|
});
|
|
951
1040
|
});
|
|
1041
|
+
|
|
1042
|
+
/**
|
|
1043
|
+
* The Workspace's own per-file rule is what bounds a file; the shape of the
|
|
1044
|
+
* commands that carry it never is.
|
|
1045
|
+
*
|
|
1046
|
+
* A Bot's file tools read and write through this surface, and both directions
|
|
1047
|
+
* used to be one command carrying the whole file as base64: a 470 KB built
|
|
1048
|
+
* Applet page is 627 KB of base64, past what a storage command may answer, so
|
|
1049
|
+
* a Bot could not read a file it had just built. These prove the transport is
|
|
1050
|
+
* no longer the constraint, in both directions, at sizes the Workspace admits.
|
|
1051
|
+
*/
|
|
1052
|
+
describe("Fly Workspace files past one storage command", () => {
|
|
1053
|
+
const mount = workspaceMountPathV1(
|
|
1054
|
+
FLY_WORKSPACE_LAYOUT,
|
|
1055
|
+
packageRoot,
|
|
1056
|
+
computerBotKey,
|
|
1057
|
+
);
|
|
1058
|
+
|
|
1059
|
+
test("reads a file far larger than one storage command can answer, byte for byte", async () => {
|
|
1060
|
+
const disk = new FakeWorkspaceDisk();
|
|
1061
|
+
const bytes = largeBytes(900 * 1024);
|
|
1062
|
+
disk.files.set(`${mount}/dist/ui.html`, { bytes });
|
|
1063
|
+
const { workspace } = await openWorkspace(BOT, disk);
|
|
1064
|
+
|
|
1065
|
+
const read = await workspace.read({
|
|
1066
|
+
root: packageRoot,
|
|
1067
|
+
path: "dist/ui.html",
|
|
1068
|
+
});
|
|
1069
|
+
|
|
1070
|
+
if (read.status !== "ok") throw new Error(read.reason);
|
|
1071
|
+
expect(read.file.bytes).toEqual(bytes);
|
|
1072
|
+
expect(read.file.generation.contentHash).toBe(sha256(bytes));
|
|
1073
|
+
// The first command answers the size, the digest, and one chunk; the rest
|
|
1074
|
+
// of the file is one command per further chunk.
|
|
1075
|
+
expect(
|
|
1076
|
+
disk.scripts.filter((script) => script.includes("$TARGET")).length,
|
|
1077
|
+
).toBe(Math.ceil(bytes.byteLength / WORKSPACE_CHUNK_BYTES_V1));
|
|
1078
|
+
});
|
|
1079
|
+
|
|
1080
|
+
test("writes a file far larger than one storage command can carry, byte for byte", async () => {
|
|
1081
|
+
const disk = new FakeWorkspaceDisk();
|
|
1082
|
+
const { workspace } = await openWorkspace(BOT, disk);
|
|
1083
|
+
const bytes = largeBytes(700 * 1024);
|
|
1084
|
+
|
|
1085
|
+
const written = await workspace.write({
|
|
1086
|
+
path: { root: packageRoot, path: "dist/bundle.js" },
|
|
1087
|
+
bytes,
|
|
1088
|
+
writer: BOT_WRITER,
|
|
1089
|
+
expectedGenerationId: null,
|
|
1090
|
+
});
|
|
1091
|
+
|
|
1092
|
+
if (written.status !== "ok") throw new Error(written.reason);
|
|
1093
|
+
expect(written.generation.contentHash).toBe(sha256(bytes));
|
|
1094
|
+
expect(disk.files.get(`${mount}/dist/bundle.js`)?.bytes).toEqual(bytes);
|
|
1095
|
+
// The staging file is gone the moment its bytes are in place, so nothing
|
|
1096
|
+
// half-written is left under a durable root.
|
|
1097
|
+
expect(
|
|
1098
|
+
[...disk.files.keys()].filter((path) => path.includes("/staging/")),
|
|
1099
|
+
).toEqual([]);
|
|
1100
|
+
const read = await workspace.read({
|
|
1101
|
+
root: packageRoot,
|
|
1102
|
+
path: "dist/bundle.js",
|
|
1103
|
+
});
|
|
1104
|
+
if (read.status !== "ok") throw new Error(read.reason);
|
|
1105
|
+
expect(read.file.bytes).toEqual(bytes);
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
test("a file that fits in one command still takes one command, in each direction", async () => {
|
|
1109
|
+
const disk = new FakeWorkspaceDisk();
|
|
1110
|
+
const { workspace } = await openWorkspace(BOT, disk);
|
|
1111
|
+
|
|
1112
|
+
const written = await workspace.write({
|
|
1113
|
+
path: { root: packageRoot, path: "note.md" },
|
|
1114
|
+
bytes: new TextEncoder().encode("small"),
|
|
1115
|
+
writer: BOT_WRITER,
|
|
1116
|
+
expectedGenerationId: null,
|
|
1117
|
+
});
|
|
1118
|
+
if (written.status !== "ok") throw new Error(written.reason);
|
|
1119
|
+
expect(disk.scripts.filter((script) => script.includes("$STAGE"))).toEqual(
|
|
1120
|
+
[],
|
|
1121
|
+
);
|
|
1122
|
+
expect(
|
|
1123
|
+
disk.scripts.filter((script) => script.includes("__WRITTEN__")),
|
|
1124
|
+
).toHaveLength(1);
|
|
1125
|
+
|
|
1126
|
+
disk.scripts.length = 0;
|
|
1127
|
+
const read = await workspace.read({ root: packageRoot, path: "note.md" });
|
|
1128
|
+
if (read.status !== "ok") throw new Error(read.reason);
|
|
1129
|
+
expect(new TextDecoder().decode(read.file.bytes)).toBe("small");
|
|
1130
|
+
expect(disk.scripts).toHaveLength(1);
|
|
1131
|
+
});
|
|
1132
|
+
|
|
1133
|
+
test("reports a rewrite between two chunks rather than stitching two versions", async () => {
|
|
1134
|
+
const disk = new FakeWorkspaceDisk();
|
|
1135
|
+
const path = `${mount}/dist/ui.html`;
|
|
1136
|
+
disk.files.set(path, { bytes: largeBytes(900 * 1024) });
|
|
1137
|
+
// A shell on the Computer replaces the file after its size and digest were
|
|
1138
|
+
// answered but before the last chunk arrives.
|
|
1139
|
+
disk.midReadRewrite = { path, bytes: largeBytes(900 * 1024).reverse() };
|
|
1140
|
+
const { workspace } = await openWorkspace(BOT, disk);
|
|
1141
|
+
|
|
1142
|
+
expect(
|
|
1143
|
+
await workspace.read({ root: packageRoot, path: "dist/ui.html" }),
|
|
1144
|
+
).toMatchObject({
|
|
1145
|
+
status: "unavailable",
|
|
1146
|
+
reason: `"dist/ui.html" changed on the Computer while it was being read`,
|
|
1147
|
+
});
|
|
1148
|
+
});
|
|
1149
|
+
|
|
1150
|
+
// The write used to hold the lock across the whole script, which was fine
|
|
1151
|
+
// while the whole file arrived inside it. Chunks arrive in commands of their
|
|
1152
|
+
// own, and holding the lock across all of them would keep one writer's file
|
|
1153
|
+
// open for as long as its bytes take to travel.
|
|
1154
|
+
test("stages the chunks unlocked and holds the lock only around the final verify-and-rename", async () => {
|
|
1155
|
+
const disk = new FakeWorkspaceDisk();
|
|
1156
|
+
const { workspace } = await openWorkspace(BOT, disk);
|
|
1157
|
+
|
|
1158
|
+
const written = await workspace.write({
|
|
1159
|
+
path: { root: packageRoot, path: "dist/bundle.js" },
|
|
1160
|
+
bytes: largeBytes(700 * 1024),
|
|
1161
|
+
writer: BOT_WRITER,
|
|
1162
|
+
expectedGenerationId: null,
|
|
1163
|
+
});
|
|
1164
|
+
|
|
1165
|
+
if (written.status !== "ok") throw new Error(written.reason);
|
|
1166
|
+
const staging = disk.scripts.filter((script) =>
|
|
1167
|
+
script.includes("__STAGED__"),
|
|
1168
|
+
);
|
|
1169
|
+
expect(staging.length).toBeGreaterThan(1);
|
|
1170
|
+
for (const script of staging) expect(script).not.toContain("flock");
|
|
1171
|
+
const commit = disk.scripts.filter((script) =>
|
|
1172
|
+
script.includes("__WRITTEN__"),
|
|
1173
|
+
);
|
|
1174
|
+
expect(commit).toHaveLength(1);
|
|
1175
|
+
expect(commit[0]).toContain("flock -x 9");
|
|
1176
|
+
// Under the lock: the expected generation, the staged bytes' digest, and
|
|
1177
|
+
// the rename. No file bytes travel inside it.
|
|
1178
|
+
expect(commit[0]).toContain('mv "$STAGE" "$TMP"');
|
|
1179
|
+
expect(commit[0]).not.toContain('base64 -d > "$TMP"');
|
|
1180
|
+
expect(commit[0]!.length).toBeLessThan(WORKSPACE_CHUNK_BYTES_V1);
|
|
1181
|
+
});
|
|
1182
|
+
|
|
1183
|
+
test("refuses a file past the Workspace's own limit in plain words, in each direction", async () => {
|
|
1184
|
+
const disk = new FakeWorkspaceDisk();
|
|
1185
|
+
disk.files.set(`${mount}/huge.bin`, {
|
|
1186
|
+
bytes: new Uint8Array(WORKSPACE_MAX_FILE_BYTES + 1),
|
|
1187
|
+
});
|
|
1188
|
+
const { workspace } = await openWorkspace(BOT, disk);
|
|
1189
|
+
|
|
1190
|
+
expect(
|
|
1191
|
+
await workspace.read({ root: packageRoot, path: "huge.bin" }),
|
|
1192
|
+
).toMatchObject({
|
|
1193
|
+
status: "refused",
|
|
1194
|
+
reason: `"huge.bin" is past the 1 MB limit on a Workspace file`,
|
|
1195
|
+
});
|
|
1196
|
+
expect(
|
|
1197
|
+
await workspace.write({
|
|
1198
|
+
path: { root: packageRoot, path: "other.bin" },
|
|
1199
|
+
bytes: new Uint8Array(WORKSPACE_MAX_FILE_BYTES + 1),
|
|
1200
|
+
writer: BOT_WRITER,
|
|
1201
|
+
expectedGenerationId: null,
|
|
1202
|
+
}),
|
|
1203
|
+
).toMatchObject({
|
|
1204
|
+
status: "refused",
|
|
1205
|
+
reason: `"other.bin" is past the 1 MB limit on a Workspace file`,
|
|
1206
|
+
});
|
|
1207
|
+
});
|
|
1208
|
+
});
|
package/src/workspace.ts
CHANGED
|
@@ -76,6 +76,47 @@ export const SYNC_CONFLICTS_DIR = "conflicts";
|
|
|
76
76
|
* is moved into place, under `WORKSPACE_SYNC_DIR`.
|
|
77
77
|
*/
|
|
78
78
|
export const SYNC_STAGING_DIR = "staging";
|
|
79
|
+
/**
|
|
80
|
+
* The most file bytes one storage command carries, in either direction.
|
|
81
|
+
*
|
|
82
|
+
* A command's answer travels as base64, so a chunk this size comes back as
|
|
83
|
+
* roughly 350 KB — inside the half megabyte an answer may be — and goes out
|
|
84
|
+
* inside a script well inside the megabyte a script may be. A file larger than
|
|
85
|
+
* one chunk travels as several commands rather than as one command that would
|
|
86
|
+
* be refused for its size, so the Workspace's own per-file rule is what bounds
|
|
87
|
+
* a file and the shape of the commands never is.
|
|
88
|
+
*/
|
|
89
|
+
export const WORKSPACE_CHUNK_BYTES_V1 = 256 * 1024;
|
|
90
|
+
|
|
91
|
+
/** Every chunk offset after the first, in bytes, for a file of this size. */
|
|
92
|
+
export function workspaceChunkOffsetsV1(size: number): number[] {
|
|
93
|
+
const offsets: number[] = [];
|
|
94
|
+
for (
|
|
95
|
+
let offset = WORKSPACE_CHUNK_BYTES_V1;
|
|
96
|
+
offset < size;
|
|
97
|
+
offset += WORKSPACE_CHUNK_BYTES_V1
|
|
98
|
+
) {
|
|
99
|
+
offsets.push(offset);
|
|
100
|
+
}
|
|
101
|
+
return offsets;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A staging file name that is one path segment whatever the id looks like. */
|
|
105
|
+
export function workspaceStagingNameV1(
|
|
106
|
+
generationId: string,
|
|
107
|
+
kind: string,
|
|
108
|
+
): string {
|
|
109
|
+
const safe = generationId.replaceAll(/[^A-Za-z0-9._-]/g, "-").slice(0, 128);
|
|
110
|
+
return `${safe}.${kind}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The Workspace's per-file limit as a person reads it. Derived, so it cannot
|
|
115
|
+
* drift from the rule it describes, and stated in the refusal instead of a
|
|
116
|
+
* byte count nobody outside this code recognizes.
|
|
117
|
+
*/
|
|
118
|
+
const MAX_FILE_LABEL = `${Math.round(WORKSPACE_MAX_FILE_BYTES / (1024 * 1024))} MB`;
|
|
119
|
+
|
|
79
120
|
const GENERATIONS_DIR = WORKSPACE_GENERATIONS_DIR;
|
|
80
121
|
const LOCKS_DIR = ".frockbot-locks";
|
|
81
122
|
/** The sha-256 of no bytes; a deletion tombstone's content address. */
|
|
@@ -402,10 +443,13 @@ export class FlyWorkspaceFiles implements WorkspaceFilesV1 {
|
|
|
402
443
|
const relative = this.path(path);
|
|
403
444
|
if (typeof relative !== "string") return relative;
|
|
404
445
|
const mount = this.mount(path.root);
|
|
405
|
-
const
|
|
446
|
+
const prelude = [
|
|
406
447
|
`ROOT=${shellQuote(mount)}`,
|
|
407
448
|
`REL=${shellQuote(relative)}`,
|
|
408
449
|
'TARGET="$ROOT/$REL"',
|
|
450
|
+
];
|
|
451
|
+
const script = [
|
|
452
|
+
...prelude,
|
|
409
453
|
`META="$ROOT/${GENERATIONS_DIR}/$REL"`,
|
|
410
454
|
'if [ ! -f "$TARGET" ]; then echo __MISSING__; exit 0; fi',
|
|
411
455
|
'SIZE=$(stat -c %s "$TARGET")',
|
|
@@ -414,7 +458,15 @@ export class FlyWorkspaceFiles implements WorkspaceFilesV1 {
|
|
|
414
458
|
'sha256sum "$TARGET" | cut -d" " -f1',
|
|
415
459
|
'printf "%s\\n" "$SIZE"',
|
|
416
460
|
'stat -c %Y "$TARGET"',
|
|
417
|
-
|
|
461
|
+
// The size and the digest come back before any bytes do, so the rest of
|
|
462
|
+
// the file can be asked for one bounded chunk at a time and the pieces
|
|
463
|
+
// proven to be one file. A whole file as one base64 answer stopped
|
|
464
|
+
// working long before a file got large: half a megabyte of built Applet
|
|
465
|
+
// page is two thirds of a megabyte of answer, which the storage surface
|
|
466
|
+
// refuses outright rather than truncating.
|
|
467
|
+
withBytes
|
|
468
|
+
? `head -c ${WORKSPACE_CHUNK_BYTES_V1} "$TARGET" | base64 -w0; echo`
|
|
469
|
+
: "",
|
|
418
470
|
]
|
|
419
471
|
.filter(Boolean)
|
|
420
472
|
.join("\n");
|
|
@@ -425,10 +477,7 @@ export class FlyWorkspaceFiles implements WorkspaceFilesV1 {
|
|
|
425
477
|
return failure("not-found", `No such Workspace file: ${relative}`);
|
|
426
478
|
}
|
|
427
479
|
if (lines[0]?.trim() === "__TOO_LARGE__") {
|
|
428
|
-
return
|
|
429
|
-
"refused",
|
|
430
|
-
`Workspace file exceeds ${WORKSPACE_MAX_FILE_BYTES} bytes`,
|
|
431
|
-
);
|
|
480
|
+
return this.tooLarge(relative);
|
|
432
481
|
}
|
|
433
482
|
const [
|
|
434
483
|
meta = "",
|
|
@@ -446,15 +495,50 @@ export class FlyWorkspaceFiles implements WorkspaceFilesV1 {
|
|
|
446
495
|
size: Number(size.trim()),
|
|
447
496
|
modifiedSeconds: Number(modified.trim()),
|
|
448
497
|
};
|
|
449
|
-
|
|
498
|
+
if (!Number.isSafeInteger(raw.size) || raw.size < 0) {
|
|
499
|
+
return failure("unavailable", "Invalid Fly Workspace file response");
|
|
500
|
+
}
|
|
501
|
+
if (!withBytes) {
|
|
502
|
+
return { generation: await this.generationOf(path.root, relative, raw) };
|
|
503
|
+
}
|
|
504
|
+
const chunks = [Buffer.from(encoded.trim(), "base64")];
|
|
505
|
+
for (const offset of workspaceChunkOffsetsV1(raw.size)) {
|
|
506
|
+
const next = await this.run(
|
|
507
|
+
[
|
|
508
|
+
...prelude,
|
|
509
|
+
// `tail -c +N` counts from one, so the offset is one past the bytes
|
|
510
|
+
// already carried.
|
|
511
|
+
`tail -c +${offset + 1} "$TARGET" | head -c ${WORKSPACE_CHUNK_BYTES_V1} | base64 -w0; echo`,
|
|
512
|
+
].join("\n"),
|
|
513
|
+
signal,
|
|
514
|
+
);
|
|
515
|
+
if (typeof next !== "string") return next;
|
|
516
|
+
chunks.push(Buffer.from(next.trim(), "base64"));
|
|
517
|
+
}
|
|
518
|
+
const bytes = Buffer.concat(chunks);
|
|
519
|
+
// The digest is the proof the chunks are one file. A file rewritten
|
|
520
|
+
// between two commands is reported, never stitched together out of two
|
|
521
|
+
// different versions of itself.
|
|
522
|
+
if (bytes.byteLength !== raw.size || digest(bytes) !== raw.contentHash) {
|
|
523
|
+
return failure(
|
|
524
|
+
"unavailable",
|
|
525
|
+
`"${relative}" changed on the Computer while it was being read`,
|
|
526
|
+
);
|
|
527
|
+
}
|
|
450
528
|
return {
|
|
451
|
-
generation,
|
|
452
|
-
|
|
453
|
-
? { bytes: Uint8Array.from(Buffer.from(encoded.trim(), "base64")) }
|
|
454
|
-
: {}),
|
|
529
|
+
generation: await this.generationOf(path.root, relative, raw),
|
|
530
|
+
bytes: Uint8Array.from(bytes),
|
|
455
531
|
};
|
|
456
532
|
}
|
|
457
533
|
|
|
534
|
+
/** The one refusal for a file past the Workspace's own per-file rule. */
|
|
535
|
+
private tooLarge(relative: string): WorkspaceFailureV1 {
|
|
536
|
+
return failure(
|
|
537
|
+
"refused",
|
|
538
|
+
`"${relative}" is past the ${MAX_FILE_LABEL} limit on a Workspace file`,
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
|
|
458
542
|
async read(path: WorkspacePathV1): Promise<WorkspaceReadOutcomeV1> {
|
|
459
543
|
const loaded = await this.load(path, true);
|
|
460
544
|
if ("status" in loaded) return loaded;
|
|
@@ -559,6 +643,50 @@ export class FlyWorkspaceFiles implements WorkspaceFilesV1 {
|
|
|
559
643
|
};
|
|
560
644
|
}
|
|
561
645
|
|
|
646
|
+
/**
|
|
647
|
+
* Puts bytes past one command on the Computer under a staging file, chunk by
|
|
648
|
+
* chunk, and answers where they landed.
|
|
649
|
+
*
|
|
650
|
+
* Bytes that fit in one command take no staging file at all — the caller
|
|
651
|
+
* writes them inline, which is one command and the overwhelmingly common
|
|
652
|
+
* case. Anything larger is appended chunk by chunk with no lock held, and
|
|
653
|
+
* the caller's own locked command moves it into place, so a file only ever
|
|
654
|
+
* appears at its real path complete.
|
|
655
|
+
*/
|
|
656
|
+
private async stage(
|
|
657
|
+
mount: string,
|
|
658
|
+
name: string,
|
|
659
|
+
bytes: Uint8Array,
|
|
660
|
+
): Promise<string | WorkspaceFailureV1 | undefined> {
|
|
661
|
+
if (bytes.byteLength <= WORKSPACE_CHUNK_BYTES_V1) return undefined;
|
|
662
|
+
const staged = `${mount}/${WORKSPACE_SYNC_DIR}/${SYNC_STAGING_DIR}/${name}`;
|
|
663
|
+
for (
|
|
664
|
+
let offset = 0;
|
|
665
|
+
offset < bytes.byteLength;
|
|
666
|
+
offset += WORKSPACE_CHUNK_BYTES_V1
|
|
667
|
+
) {
|
|
668
|
+
const chunk = bytes.subarray(offset, offset + WORKSPACE_CHUNK_BYTES_V1);
|
|
669
|
+
const output = await this.run(
|
|
670
|
+
[
|
|
671
|
+
"set -eu",
|
|
672
|
+
`STAGE=${shellQuote(staged)}`,
|
|
673
|
+
'mkdir -p "$(dirname "$STAGE")"',
|
|
674
|
+
// A staging name is this write's own, so a leftover file under it is
|
|
675
|
+
// a previous attempt's and never another writer's.
|
|
676
|
+
...(offset === 0 ? ['rm -f "$STAGE"'] : []),
|
|
677
|
+
`printf %s ${shellQuote(Buffer.from(chunk).toString("base64"))} | base64 -d >> "$STAGE"`,
|
|
678
|
+
'chmod 600 "$STAGE"',
|
|
679
|
+
"echo __STAGED__",
|
|
680
|
+
].join("\n"),
|
|
681
|
+
);
|
|
682
|
+
if (typeof output !== "string") return output;
|
|
683
|
+
if (!output.includes("__STAGED__")) {
|
|
684
|
+
return failure("unavailable", "Invalid Fly Workspace write response");
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
return staged;
|
|
688
|
+
}
|
|
689
|
+
|
|
562
690
|
async write(
|
|
563
691
|
request: WorkspaceWriteRequestV1,
|
|
564
692
|
): Promise<WorkspaceWriteOutcomeV1> {
|
|
@@ -567,10 +695,7 @@ export class FlyWorkspaceFiles implements WorkspaceFilesV1 {
|
|
|
567
695
|
const relative = this.path(request.path);
|
|
568
696
|
if (typeof relative !== "string") return relative;
|
|
569
697
|
if (request.bytes.byteLength > WORKSPACE_MAX_FILE_BYTES) {
|
|
570
|
-
return
|
|
571
|
-
"refused",
|
|
572
|
-
`Workspace file exceeds ${WORKSPACE_MAX_FILE_BYTES} bytes`,
|
|
573
|
-
);
|
|
698
|
+
return this.tooLarge(relative);
|
|
574
699
|
}
|
|
575
700
|
const writtenAt = new Date();
|
|
576
701
|
// The ledger mints when there is one, so the id this write records is the
|
|
@@ -595,12 +720,26 @@ export class FlyWorkspaceFiles implements WorkspaceFilesV1 {
|
|
|
595
720
|
writtenAt: writtenAt.toISOString(),
|
|
596
721
|
};
|
|
597
722
|
const mount = this.mount(request.path.root);
|
|
723
|
+
// Bytes past one command are staged first, outside the lock: the file
|
|
724
|
+
// takes several commands to carry, and holding the lock across all of them
|
|
725
|
+
// would keep one writer's file open for as long as its bytes take to
|
|
726
|
+
// arrive. The staging name is this write's own minted generation, so two
|
|
727
|
+
// writers of the same path never share a staging file, and the lock is
|
|
728
|
+
// taken only for the last command — the one that checks the generation the
|
|
729
|
+
// writer expected, proves the staged bytes, and renames them into place.
|
|
730
|
+
const staged = await this.stage(
|
|
731
|
+
mount,
|
|
732
|
+
workspaceStagingNameV1(generationId, "write"),
|
|
733
|
+
request.bytes,
|
|
734
|
+
);
|
|
735
|
+
if (staged !== undefined && typeof staged !== "string") return staged;
|
|
598
736
|
const script = [
|
|
599
737
|
"set -eu",
|
|
600
738
|
`ROOT=${shellQuote(mount)}`,
|
|
601
739
|
`REL=${shellQuote(relative)}`,
|
|
602
740
|
'TARGET="$ROOT/$REL"',
|
|
603
741
|
`META="$ROOT/${GENERATIONS_DIR}/$REL"`,
|
|
742
|
+
...(staged ? [`STAGE=${shellQuote(staged)}`] : []),
|
|
604
743
|
`mkdir -p "$(dirname "$TARGET")" "$(dirname "$META")" "$ROOT/${LOCKS_DIR}"`,
|
|
605
744
|
'LOCK=$(printf %s "$REL" | sha256sum | cut -d" " -f1)',
|
|
606
745
|
`exec 9>"$ROOT/${LOCKS_DIR}/$LOCK"`,
|
|
@@ -608,9 +747,20 @@ export class FlyWorkspaceFiles implements WorkspaceFilesV1 {
|
|
|
608
747
|
"CURRENT=",
|
|
609
748
|
'if [ -f "$TARGET" ] && [ -f "$META" ]; then CURRENT=$(sed -n 1p "$META"); fi',
|
|
610
749
|
'if [ -f "$TARGET" ] && [ ! -f "$META" ]; then CURRENT=__UNRECORDED__; fi',
|
|
611
|
-
`if [ "$CURRENT" != ${shellQuote(request.expectedGenerationId ?? "")} ]; then echo __CONFLICT__; exit 0; fi`,
|
|
750
|
+
`if [ "$CURRENT" != ${shellQuote(request.expectedGenerationId ?? "")} ]; then ${staged ? 'rm -f "$STAGE"; ' : ""}echo __CONFLICT__; exit 0; fi`,
|
|
751
|
+
// A half-written staging file must never become a durable-root file, so
|
|
752
|
+
// the staged bytes answer for themselves before anything is renamed.
|
|
753
|
+
...(staged
|
|
754
|
+
? [
|
|
755
|
+
`if [ "$(sha256sum "$STAGE" | cut -d" " -f1)" != ${shellQuote(generation.contentHash)} ]; then rm -f "$STAGE"; echo __CORRUPT__; exit 0; fi`,
|
|
756
|
+
]
|
|
757
|
+
: []),
|
|
612
758
|
'TMP=$(mktemp "${TARGET}.XXXXXX")',
|
|
613
|
-
|
|
759
|
+
...(staged
|
|
760
|
+
? ['mv "$STAGE" "$TMP"']
|
|
761
|
+
: [
|
|
762
|
+
`printf %s ${shellQuote(Buffer.from(request.bytes).toString("base64"))} | base64 -d > "$TMP"`,
|
|
763
|
+
]),
|
|
614
764
|
'chmod 600 "$TMP"',
|
|
615
765
|
'mv "$TMP" "$TARGET"',
|
|
616
766
|
'MTMP=$(mktemp "${META}.XXXXXX")',
|
|
@@ -627,6 +777,12 @@ export class FlyWorkspaceFiles implements WorkspaceFilesV1 {
|
|
|
627
777
|
`Workspace file changed since the writer last saw it: ${relative}`,
|
|
628
778
|
);
|
|
629
779
|
}
|
|
780
|
+
if (output.includes("__CORRUPT__")) {
|
|
781
|
+
return failure(
|
|
782
|
+
"unavailable",
|
|
783
|
+
`"${relative}" did not reach the Computer intact`,
|
|
784
|
+
);
|
|
785
|
+
}
|
|
630
786
|
if (!output.includes("__WRITTEN__")) {
|
|
631
787
|
return failure("unavailable", "Invalid Fly Workspace write response");
|
|
632
788
|
}
|