@frockbot/plugin-fly-sprite 0.0.0 → 0.1.1

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.
@@ -0,0 +1,782 @@
1
+ // The Fly Computer's Workspace: `WorkspaceFilesV1` over one User's Sprite.
2
+ //
3
+ // Every durable root is named by `WorkspaceRootV1` — kind and owner — and this
4
+ // module is the only place that turns one into an absolute path, through the
5
+ // provider's declared `WorkspaceLayoutV1`. There is no `namespace` string and
6
+ // no `"bot" | "user"` scope argument any more: a caller that wants the Bot's
7
+ // instruction root asks for `{ kind: "bot-instructions", userId, botId }`.
8
+ //
9
+ // Two constitutional rules are enforced here rather than described:
10
+ //
11
+ // - "every write to a durable root records its writer" — a write mints a
12
+ // generation from the owning Durable Object's ledger, records it there, and
13
+ // stores a sidecar beside the file naming it. A read or a list answers with
14
+ // an attributed writer only when the ledger still holds that generation for
15
+ // those exact bytes: the Workspace holds files, never authority, so the
16
+ // sidecar is a hint and the Durable Object decides. A file a shell wrote —
17
+ // with or without a sidecar it forged — is answered as `unattributed`,
18
+ // which is data and never an instruction. A write may not *claim* that
19
+ // writer: `unattributed` is refused on `write` and `delete`.
20
+ // - "a Bot's instruction root and Bot Memory root are writable only by that
21
+ // Bot or its User" — a write whose writer is neither is `refused`, as is
22
+ // every write to a Memory root through the kernel-consumed surface, because
23
+ // "The Memory Package is the single writer of Memory roots", and every
24
+ // write to the User-global instruction root on any surface, because the
25
+ // Skills Package is its single writer and the Computer sees it read-only
26
+ // (ADR 0016). The writer a
27
+ // request names is a claim, and the handle's tenant decides it: a `bot`
28
+ // writer must be the Bot the handle was opened for, and a `user` writer is
29
+ // admitted only from a handle opened under User authority.
30
+ //
31
+ // Failures are declared variants, never exceptions: the Computer host is
32
+ // non-authoritative and its connections drop on every pause, so `unavailable`
33
+ // is an ordinary answer.
34
+ import { createHash } from "node:crypto";
35
+ import {
36
+ ComputerError,
37
+ workspaceMountPathV1,
38
+ type ComputerWorkspace,
39
+ type WorkspaceLayoutV1,
40
+ } from "@frockbot/computer-core";
41
+ import {
42
+ WORKSPACE_MAX_FILE_BYTES,
43
+ WORKSPACE_MAX_LIST_ENTRIES,
44
+ decodeWorkspaceGenerationV1,
45
+ normalizeWorkspaceRelativePathV1,
46
+ workspaceRootAcceptsKernelWriteV1,
47
+ workspaceWriterMayWriteV1,
48
+ type WorkspaceDeleteRequestV1,
49
+ type WorkspaceEntryV1,
50
+ type WorkspaceFailureV1,
51
+ type WorkspaceFilesV1,
52
+ type WorkspaceGenerationV1,
53
+ type WorkspaceGenerationsV1,
54
+ type WorkspaceListOutcomeV1,
55
+ type WorkspaceListRequestV1,
56
+ type WorkspacePathV1,
57
+ type WorkspaceReadOutcomeV1,
58
+ type WorkspaceRootV1,
59
+ type WorkspaceStatOutcomeV1,
60
+ type WorkspaceWriteOutcomeV1,
61
+ type WorkspaceWriteRequestV1,
62
+ type WorkspaceWriterV1,
63
+ } from "@frockbot/kernel-contracts";
64
+ import type { FlySpriteAgentComputer } from "./computer.js";
65
+
66
+ /** Where a root records the generation of each file beneath it. */
67
+ export const WORKSPACE_GENERATIONS_DIR = ".frockbot-generations";
68
+ /** Where the durable-root sync keeps its own per-root bookkeeping. */
69
+ export const WORKSPACE_SYNC_DIR = ".frockbot-sync";
70
+ /** Where a removal is recorded, under `WORKSPACE_SYNC_DIR`. */
71
+ export const SYNC_TOMBSTONES_DIR = "tombstones";
72
+ /** Where a losing write is preserved on the Computer, under `WORKSPACE_SYNC_DIR`. */
73
+ export const SYNC_CONFLICTS_DIR = "conflicts";
74
+ const GENERATIONS_DIR = WORKSPACE_GENERATIONS_DIR;
75
+ const LOCKS_DIR = ".frockbot-locks";
76
+ /** The sha-256 of no bytes; a deletion tombstone's content address. */
77
+ export const WORKSPACE_EMPTY_SHA256 =
78
+ "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
79
+ const EMPTY_SHA256 = WORKSPACE_EMPTY_SHA256;
80
+ const DEFAULT_LIST_LIMIT = 100;
81
+
82
+ function shellQuote(value: string): string {
83
+ return `'${value.replaceAll("'", `'\\''`)}'`;
84
+ }
85
+
86
+ function failure(
87
+ status: WorkspaceFailureV1["status"],
88
+ reason: string,
89
+ ): WorkspaceFailureV1 {
90
+ return { status, reason: reason.slice(0, 512) };
91
+ }
92
+
93
+ function digest(bytes: Uint8Array): string {
94
+ return createHash("sha256").update(bytes).digest("hex");
95
+ }
96
+
97
+ let generationCounter = 0;
98
+
99
+ /**
100
+ * A sortable, DO-shaped generation id. The Workspace is not an authority, so
101
+ * this id orders generations the Computer minted; a Durable Object that adopts
102
+ * a Workspace generation records its own alongside it.
103
+ */
104
+ function mintGenerationId(at: Date): string {
105
+ generationCounter = (generationCounter + 1) % 1_000_000;
106
+ const millis = at.getTime().toString().padStart(15, "0");
107
+ const counter = generationCounter.toString().padStart(6, "0");
108
+ return `${millis}-${counter}`;
109
+ }
110
+
111
+ interface RecordedFile {
112
+ generation: WorkspaceGenerationV1;
113
+ bytes?: Uint8Array;
114
+ }
115
+
116
+ /** The four fields the Sprite emits for one file, plus its sidecar. */
117
+ interface RawFile {
118
+ meta: string;
119
+ contentHash: string;
120
+ size: number;
121
+ modifiedSeconds: number;
122
+ }
123
+
124
+ export interface FlyWorkspaceFilesOptions {
125
+ computer: FlySpriteAgentComputer;
126
+ layout: WorkspaceLayoutV1;
127
+ /** The User whose Computer this is; every root must belong to them. */
128
+ userId: string;
129
+ /** The Bot tenant making the call. */
130
+ botId: string;
131
+ /**
132
+ * Whether this handle was opened under the User's own authority rather than
133
+ * a Bot's. Default `false`: the shell and Turn paths open a Computer as the
134
+ * Bot, and a Bot may not present its User as the writer of a file.
135
+ *
136
+ * "every write to a durable root records its writer" is only true if the
137
+ * recorded writer is the one that actually called: the handle's tenant is
138
+ * the authority on that, and the `writer` field of a request is a claim.
139
+ */
140
+ userAuthority?: boolean;
141
+ /** Maps a Bot id to the provider's directory key for that Bot. */
142
+ botDirectoryKey: (botId: string) => string;
143
+ /**
144
+ * `"kernel"` is the surface the kernel consumes: it refuses every Memory
145
+ * root. `"memory"` is the Memory Package's single-writer seam: it refuses
146
+ * every other root. Nothing accepts both.
147
+ */
148
+ surface: "kernel" | "memory";
149
+ /**
150
+ * The generation ledger of the Durable Object that owns these roots.
151
+ *
152
+ * It is what makes an attributed answer possible at all. "The Workspace and
153
+ * its object-storage twin ... hold files, never authority": the sidecar
154
+ * beside a file on the Computer is a hint, and a shell can write both the
155
+ * file and a perfectly-formed sidecar for it, so a writer is believed only
156
+ * when the ledger records the same generation for the same bytes. Where no
157
+ * ledger is injected — Electron, local development, a Computer opened
158
+ * outside an admitted Turn — every file is `unattributed`, which is the
159
+ * truth: nothing durable recorded who wrote it.
160
+ */
161
+ generations?: WorkspaceGenerationsV1;
162
+ }
163
+
164
+ /** `WorkspaceFilesV1` backed by one Fly Sprite's durable filesystem. */
165
+ export class FlyWorkspaceFiles implements WorkspaceFilesV1 {
166
+ constructor(private readonly options: FlyWorkspaceFilesOptions) {}
167
+
168
+ private mount(root: WorkspaceRootV1): string {
169
+ return workspaceMountPathV1(
170
+ this.options.layout,
171
+ root,
172
+ this.options.botDirectoryKey,
173
+ );
174
+ }
175
+
176
+ /**
177
+ * The one place a root is admitted. It answers a failure rather than
178
+ * throwing, because refusal is an ordinary outcome of this interface.
179
+ */
180
+ private admit(root: WorkspaceRootV1): WorkspaceFailureV1 | undefined {
181
+ if (root.userId !== this.options.userId) {
182
+ return failure(
183
+ "refused",
184
+ "This Computer belongs to a different User's Workspace",
185
+ );
186
+ }
187
+ const isMemory = !workspaceRootAcceptsKernelWriteV1(root);
188
+ if (this.options.surface === "memory" && !isMemory) {
189
+ return failure("refused", "The Memory writer accepts Memory roots only");
190
+ }
191
+ try {
192
+ this.mount(root);
193
+ } catch (error) {
194
+ return failure(
195
+ error instanceof ComputerError &&
196
+ error.code === "capability-unavailable"
197
+ ? "not-found"
198
+ : "refused",
199
+ error instanceof Error ? error.message : String(error),
200
+ );
201
+ }
202
+ return undefined;
203
+ }
204
+
205
+ /**
206
+ * "a Bot's instruction root and Bot Memory root are writable only by that
207
+ * Bot or its User". A first-party Package is neither, and `unattributed` is
208
+ * not a writer at all.
209
+ *
210
+ * The writer a request names is a claim, and this handle's tenant is the
211
+ * authority on it: a `bot` writer must be the tenant that opened the handle,
212
+ * and a `user` writer is admitted only from a handle opened under User
213
+ * authority. Without that, one Bot could write another Bot's root by naming
214
+ * that Bot, or write its own instruction root as its User and so author
215
+ * itself a loadable Skill under an authority it does not hold.
216
+ */
217
+ private admitWrite(
218
+ root: WorkspaceRootV1,
219
+ writer: WorkspaceWriterV1,
220
+ ): WorkspaceFailureV1 | undefined {
221
+ const refused = this.admit(root);
222
+ if (refused) return refused;
223
+ if (!workspaceWriterMayWriteV1(writer)) {
224
+ return failure(
225
+ "refused",
226
+ "Every write to a durable root records its writer; an unattributed writer records none",
227
+ );
228
+ }
229
+ if (writer.kind === "bot" && writer.botId !== this.options.botId) {
230
+ return failure(
231
+ "refused",
232
+ `This Computer handle is open for Bot "${this.options.botId}"; it may not write as another Bot`,
233
+ );
234
+ }
235
+ if (writer.kind === "user") {
236
+ if (!this.options.userAuthority) {
237
+ return failure(
238
+ "refused",
239
+ "This Computer handle is open for a Bot; only a handle opened under User authority may write as the User",
240
+ );
241
+ }
242
+ if (writer.userId !== this.options.userId) {
243
+ return failure(
244
+ "refused",
245
+ "This Computer belongs to a different User's Workspace",
246
+ );
247
+ }
248
+ }
249
+ if (
250
+ this.options.surface === "kernel" &&
251
+ !workspaceRootAcceptsKernelWriteV1(root)
252
+ ) {
253
+ return failure(
254
+ "refused",
255
+ "The Workspace presents Memory roots read-only; the Memory Package is their only writer",
256
+ );
257
+ }
258
+ // The User-global instruction root is written through object storage
259
+ // only, whatever surface asks here: the Computer presents it read-only,
260
+ // so this implementation has no write path to it at all (ADR 0016). It is
261
+ // refused on every surface rather than on the kernel one, because the
262
+ // point of a single writer is that the Computer is never the writer.
263
+ if (root.kind === "user-instructions") {
264
+ return failure(
265
+ "refused",
266
+ "The Computer presents the User-global instruction root read-only; the Skills Package is its only writer",
267
+ );
268
+ }
269
+ if (root.kind === "bot-instructions" || root.kind === "bot-memory") {
270
+ const byBot = writer.kind === "bot" && writer.botId === root.botId;
271
+ const byUser = writer.kind === "user" && writer.userId === root.userId;
272
+ if (!byBot && !byUser) {
273
+ return failure(
274
+ "refused",
275
+ `Only Bot "${root.botId}" or its User may write this root`,
276
+ );
277
+ }
278
+ }
279
+ return undefined;
280
+ }
281
+
282
+ private path(path: WorkspacePathV1): WorkspaceFailureV1 | string {
283
+ try {
284
+ return normalizeWorkspaceRelativePathV1(path.path);
285
+ } catch (error) {
286
+ return failure(
287
+ "refused",
288
+ error instanceof Error ? error.message : String(error),
289
+ );
290
+ }
291
+ }
292
+
293
+ private async run(
294
+ script: string,
295
+ signal?: AbortSignal,
296
+ ): Promise<string | WorkspaceFailureV1> {
297
+ try {
298
+ const output = await this.options.computer.runStorage(
299
+ script,
300
+ signal ?? new AbortController().signal,
301
+ );
302
+ return output;
303
+ } catch (error) {
304
+ return failure(
305
+ "unavailable",
306
+ error instanceof Error ? error.message : String(error),
307
+ );
308
+ }
309
+ }
310
+
311
+ /**
312
+ * Recovers a generation for a file.
313
+ *
314
+ * The sidecar beside the file is a *hint*, never the authority. It is an
315
+ * ordinary file in an ordinary directory on a host the constitution calls
316
+ * non-authoritative, so a shell can write bytes and a perfectly-formed
317
+ * sidecar for them in the same command — including the sha-256 of the bytes
318
+ * it just wrote, and a `writer` naming any Bot it likes. Believing a
319
+ * self-consistent sidecar would hand any process with a shell a loadable
320
+ * Skill under the Bot's own authority, which is the one thing "Only Skills
321
+ * under the Bot's own instruction root, written under the Bot's own
322
+ * authority or its User's, are loaded as instructions" exists to prevent.
323
+ *
324
+ * So the Durable Object decides. A writer is answered only when the ledger
325
+ * holds a record for this path naming the same `generationId` *and* the same
326
+ * `contentHash` as the bytes on disk: the sidecar then merely says which
327
+ * record to look for, and the ledger — which no shell can reach — says
328
+ * whether it is real. Anything else is `{ kind: "unattributed" }`, which is
329
+ * the truth: not the User, not a Bot, nobody recorded. Unattributed files
330
+ * stay visible and readable, and are never loaded as instructions.
331
+ *
332
+ * With no ledger injected there is no authority to ask, so every file is
333
+ * `unattributed`. That is the honest answer on a Computer opened outside an
334
+ * admitted Turn, and it fails closed rather than open.
335
+ */
336
+ private async generationOf(
337
+ root: WorkspaceRootV1,
338
+ relative: string,
339
+ raw: RawFile,
340
+ ): Promise<WorkspaceGenerationV1> {
341
+ const hinted = this.decodeMeta(raw.meta);
342
+ const ledger = this.options.generations;
343
+ if (hinted && ledger && hinted.contentHash === raw.contentHash) {
344
+ let recorded;
345
+ try {
346
+ recorded = await ledger.current(root, relative);
347
+ } catch {
348
+ // The Durable Object is briefly unreachable. Nothing here may stand in
349
+ // for it, so the file is data until it answers again.
350
+ recorded = undefined;
351
+ }
352
+ if (
353
+ recorded &&
354
+ !recorded.deleted &&
355
+ recorded.generation.generationId === hinted.generationId &&
356
+ recorded.generation.contentHash === raw.contentHash
357
+ ) {
358
+ return recorded.generation;
359
+ }
360
+ }
361
+ return {
362
+ schemaVersion: 1,
363
+ generationId: `${raw.modifiedSeconds.toString().padStart(15, "0")}-shell`,
364
+ contentHash: raw.contentHash,
365
+ size: Math.min(raw.size, WORKSPACE_MAX_FILE_BYTES),
366
+ writer: { kind: "unattributed" },
367
+ writtenAt: new Date(raw.modifiedSeconds * 1000).toISOString(),
368
+ };
369
+ }
370
+
371
+ private decodeMeta(encoded: string): WorkspaceGenerationV1 | undefined {
372
+ if (!encoded) return undefined;
373
+ try {
374
+ const text = Buffer.from(encoded, "base64").toString("utf8");
375
+ const body = text.slice(text.indexOf("\n") + 1);
376
+ // Decoded at the seam, never cast: the sidecar is a file on the
377
+ // Computer, which is non-authoritative, so it is inbound data.
378
+ return decodeWorkspaceGenerationV1(JSON.parse(body));
379
+ } catch {
380
+ return undefined;
381
+ }
382
+ }
383
+
384
+ private encodeMeta(generation: WorkspaceGenerationV1): string {
385
+ return Buffer.from(
386
+ `${generation.generationId}\n${JSON.stringify(generation)}`,
387
+ ).toString("base64");
388
+ }
389
+
390
+ private async load(
391
+ path: WorkspacePathV1,
392
+ withBytes: boolean,
393
+ signal?: AbortSignal,
394
+ ): Promise<RecordedFile | WorkspaceFailureV1> {
395
+ const refused = this.admit(path.root);
396
+ if (refused) return refused;
397
+ const relative = this.path(path);
398
+ if (typeof relative !== "string") return relative;
399
+ const mount = this.mount(path.root);
400
+ const script = [
401
+ `ROOT=${shellQuote(mount)}`,
402
+ `REL=${shellQuote(relative)}`,
403
+ 'TARGET="$ROOT/$REL"',
404
+ `META="$ROOT/${GENERATIONS_DIR}/$REL"`,
405
+ 'if [ ! -f "$TARGET" ]; then echo __MISSING__; exit 0; fi',
406
+ 'SIZE=$(stat -c %s "$TARGET")',
407
+ `if [ "$SIZE" -gt ${WORKSPACE_MAX_FILE_BYTES} ]; then echo __TOO_LARGE__; exit 0; fi`,
408
+ '{ cat "$META" 2>/dev/null || printf ""; } | base64 -w0; echo',
409
+ 'sha256sum "$TARGET" | cut -d" " -f1',
410
+ 'printf "%s\\n" "$SIZE"',
411
+ 'stat -c %Y "$TARGET"',
412
+ withBytes ? 'base64 -w0 "$TARGET"; echo' : "",
413
+ ]
414
+ .filter(Boolean)
415
+ .join("\n");
416
+ const output = await this.run(script, signal);
417
+ if (typeof output !== "string") return output;
418
+ const lines = output.split("\n");
419
+ if (lines[0]?.trim() === "__MISSING__") {
420
+ return failure("not-found", `No such Workspace file: ${relative}`);
421
+ }
422
+ if (lines[0]?.trim() === "__TOO_LARGE__") {
423
+ return failure(
424
+ "refused",
425
+ `Workspace file exceeds ${WORKSPACE_MAX_FILE_BYTES} bytes`,
426
+ );
427
+ }
428
+ const [
429
+ meta = "",
430
+ contentHash = "",
431
+ size = "",
432
+ modified = "",
433
+ encoded = "",
434
+ ] = lines;
435
+ if (!contentHash.trim() || !size.trim() || !modified.trim()) {
436
+ return failure("unavailable", "Invalid Fly Workspace file response");
437
+ }
438
+ const raw: RawFile = {
439
+ meta: meta.trim(),
440
+ contentHash: contentHash.trim(),
441
+ size: Number(size.trim()),
442
+ modifiedSeconds: Number(modified.trim()),
443
+ };
444
+ const generation = await this.generationOf(path.root, relative, raw);
445
+ return {
446
+ generation,
447
+ ...(withBytes
448
+ ? { bytes: Uint8Array.from(Buffer.from(encoded.trim(), "base64")) }
449
+ : {}),
450
+ };
451
+ }
452
+
453
+ async read(path: WorkspacePathV1): Promise<WorkspaceReadOutcomeV1> {
454
+ const loaded = await this.load(path, true);
455
+ if ("status" in loaded) return loaded;
456
+ return {
457
+ status: "ok",
458
+ file: {
459
+ path,
460
+ generation: loaded.generation,
461
+ bytes: loaded.bytes ?? new Uint8Array(),
462
+ },
463
+ };
464
+ }
465
+
466
+ async stat(path: WorkspacePathV1): Promise<WorkspaceStatOutcomeV1> {
467
+ const loaded = await this.load(path, false);
468
+ if ("status" in loaded) return loaded;
469
+ return {
470
+ status: "ok",
471
+ entry: { path, generation: loaded.generation },
472
+ };
473
+ }
474
+
475
+ async list(request: WorkspaceListRequestV1): Promise<WorkspaceListOutcomeV1> {
476
+ const refused = this.admit(request.root);
477
+ if (refused) return refused;
478
+ let prefix = "";
479
+ if (request.prefix !== undefined) {
480
+ const normalized = this.path({
481
+ root: request.root,
482
+ path: request.prefix,
483
+ });
484
+ if (typeof normalized !== "string") return normalized;
485
+ prefix = normalized;
486
+ }
487
+ const limit = Math.max(
488
+ 1,
489
+ Math.min(request.limit ?? DEFAULT_LIST_LIMIT, WORKSPACE_MAX_LIST_ENTRIES),
490
+ );
491
+ const offset = request.cursor ? Number(request.cursor) : 0;
492
+ if (!Number.isSafeInteger(offset) || offset < 0) {
493
+ return failure("refused", "Invalid Workspace list cursor");
494
+ }
495
+ const mount = this.mount(request.root);
496
+ const script = [
497
+ `ROOT=${shellQuote(mount)}`,
498
+ `PREFIX=${shellQuote(prefix)}`,
499
+ `OFFSET=${offset}`,
500
+ `LIMIT=${limit}`,
501
+ 'mkdir -p "$ROOT"',
502
+ "INDEX=0",
503
+ "EMITTED=0",
504
+ `find "$ROOT" -type f ! -path "$ROOT/${LOCKS_DIR}/*" ! -path "$ROOT/${GENERATIONS_DIR}/*" ! -path "$ROOT/${WORKSPACE_SYNC_DIR}/*" -print0 | sort -z | while IFS= read -r -d "" FILE; do`,
505
+ ' REL=${FILE#"$ROOT"/}',
506
+ ' if [ -n "$PREFIX" ]; then case "$REL" in "$PREFIX"|"$PREFIX"/*) ;; *) continue ;; esac; fi',
507
+ ' if [ "$INDEX" -lt "$OFFSET" ]; then INDEX=$((INDEX + 1)); continue; fi',
508
+ ` META="$ROOT/${GENERATIONS_DIR}/$REL"`,
509
+ ' printf "%s\\t%s\\t%s\\t%s\\t%s\\n" "$(printf %s "$REL" | base64 -w0)" "$({ cat "$META" 2>/dev/null || printf \'\'; } | base64 -w0)" "$(sha256sum "$FILE" | cut -d" " -f1)" "$(stat -c %s "$FILE")" "$(stat -c %Y "$FILE")"',
510
+ " EMITTED=$((EMITTED + 1))",
511
+ ' if [ "$EMITTED" -gt "$LIMIT" ]; then break; fi',
512
+ "done",
513
+ ].join("\n");
514
+ const output = await this.run(script);
515
+ if (typeof output !== "string") return output;
516
+ const rows = output.trim() ? output.trim().split("\n") : [];
517
+ const entries: WorkspaceEntryV1[] = [];
518
+ for (const row of rows) {
519
+ const [
520
+ encodedPath,
521
+ meta = "",
522
+ contentHash = "",
523
+ size = "",
524
+ modified = "",
525
+ ] = row.split("\t");
526
+ if (!encodedPath || !contentHash || !size || !modified) {
527
+ return failure("unavailable", "Invalid Fly Workspace listing response");
528
+ }
529
+ const relative = Buffer.from(encodedPath, "base64").toString("utf8");
530
+ let path: WorkspacePathV1;
531
+ try {
532
+ path = {
533
+ root: request.root,
534
+ path: normalizeWorkspaceRelativePathV1(relative),
535
+ };
536
+ } catch {
537
+ continue;
538
+ }
539
+ entries.push({
540
+ path,
541
+ generation: await this.generationOf(request.root, path.path, {
542
+ meta,
543
+ contentHash,
544
+ size: Number(size),
545
+ modifiedSeconds: Number(modified),
546
+ }),
547
+ });
548
+ }
549
+ const hasMore = entries.length > limit;
550
+ return {
551
+ status: "ok",
552
+ entries: entries.slice(0, limit),
553
+ ...(hasMore ? { cursor: String(offset + limit) } : {}),
554
+ };
555
+ }
556
+
557
+ async write(
558
+ request: WorkspaceWriteRequestV1,
559
+ ): Promise<WorkspaceWriteOutcomeV1> {
560
+ const refused = this.admitWrite(request.path.root, request.writer);
561
+ if (refused) return refused;
562
+ const relative = this.path(request.path);
563
+ if (typeof relative !== "string") return relative;
564
+ if (request.bytes.byteLength > WORKSPACE_MAX_FILE_BYTES) {
565
+ return failure(
566
+ "refused",
567
+ `Workspace file exceeds ${WORKSPACE_MAX_FILE_BYTES} bytes`,
568
+ );
569
+ }
570
+ const writtenAt = new Date();
571
+ // The ledger mints when there is one, so the id this write records is the
572
+ // Durable Object's own and a later read can find it there.
573
+ let generationId: string;
574
+ try {
575
+ generationId = this.options.generations
576
+ ? await this.options.generations.mint(writtenAt, request.path.root)
577
+ : mintGenerationId(writtenAt);
578
+ } catch (error) {
579
+ return failure(
580
+ "unavailable",
581
+ error instanceof Error ? error.message : String(error),
582
+ );
583
+ }
584
+ const generation: WorkspaceGenerationV1 = {
585
+ schemaVersion: 1,
586
+ generationId,
587
+ contentHash: digest(request.bytes),
588
+ size: request.bytes.byteLength,
589
+ writer: request.writer,
590
+ writtenAt: writtenAt.toISOString(),
591
+ };
592
+ const mount = this.mount(request.path.root);
593
+ const script = [
594
+ "set -eu",
595
+ `ROOT=${shellQuote(mount)}`,
596
+ `REL=${shellQuote(relative)}`,
597
+ 'TARGET="$ROOT/$REL"',
598
+ `META="$ROOT/${GENERATIONS_DIR}/$REL"`,
599
+ `mkdir -p "$(dirname "$TARGET")" "$(dirname "$META")" "$ROOT/${LOCKS_DIR}"`,
600
+ 'LOCK=$(printf %s "$REL" | sha256sum | cut -d" " -f1)',
601
+ `exec 9>"$ROOT/${LOCKS_DIR}/$LOCK"`,
602
+ "flock -x 9",
603
+ "CURRENT=",
604
+ 'if [ -f "$TARGET" ] && [ -f "$META" ]; then CURRENT=$(sed -n 1p "$META"); fi',
605
+ 'if [ -f "$TARGET" ] && [ ! -f "$META" ]; then CURRENT=__UNRECORDED__; fi',
606
+ `if [ "$CURRENT" != ${shellQuote(request.expectedGenerationId ?? "")} ]; then echo __CONFLICT__; exit 0; fi`,
607
+ 'TMP=$(mktemp "${TARGET}.XXXXXX")',
608
+ `printf %s ${shellQuote(Buffer.from(request.bytes).toString("base64"))} | base64 -d > "$TMP"`,
609
+ 'chmod 600 "$TMP"',
610
+ 'mv "$TMP" "$TARGET"',
611
+ 'MTMP=$(mktemp "${META}.XXXXXX")',
612
+ `printf %s ${shellQuote(this.encodeMeta(generation))} | base64 -d > "$MTMP"`,
613
+ 'chmod 600 "$MTMP"',
614
+ 'mv "$MTMP" "$META"',
615
+ "echo __WRITTEN__",
616
+ ].join("\n");
617
+ const output = await this.run(script);
618
+ if (typeof output !== "string") return output;
619
+ if (output.includes("__CONFLICT__")) {
620
+ return failure(
621
+ "conflict",
622
+ `Workspace file changed since the writer last saw it: ${relative}`,
623
+ );
624
+ }
625
+ if (!output.includes("__WRITTEN__")) {
626
+ return failure("unavailable", "Invalid Fly Workspace write response");
627
+ }
628
+ // The bytes are on the Computer; the authority over who wrote them is the
629
+ // record. Without it the file reads back `unattributed`, so a failure to
630
+ // record is a failure of the write, not a detail.
631
+ if (this.options.generations) {
632
+ try {
633
+ await this.options.generations.record({
634
+ schemaVersion: 1,
635
+ root: request.path.root,
636
+ path: relative,
637
+ generation,
638
+ });
639
+ } catch (error) {
640
+ return failure(
641
+ "unavailable",
642
+ error instanceof Error ? error.message : String(error),
643
+ );
644
+ }
645
+ }
646
+ return { status: "ok", generation };
647
+ }
648
+
649
+ async delete(
650
+ request: WorkspaceDeleteRequestV1,
651
+ ): Promise<WorkspaceWriteOutcomeV1> {
652
+ const refused = this.admitWrite(request.path.root, request.writer);
653
+ if (refused) return refused;
654
+ const relative = this.path(request.path);
655
+ if (typeof relative !== "string") return relative;
656
+ const writtenAt = new Date();
657
+ let removalId: string;
658
+ try {
659
+ removalId = this.options.generations
660
+ ? await this.options.generations.mint(writtenAt, request.path.root)
661
+ : mintGenerationId(writtenAt);
662
+ } catch (error) {
663
+ return failure(
664
+ "unavailable",
665
+ error instanceof Error ? error.message : String(error),
666
+ );
667
+ }
668
+ const tombstone: WorkspaceGenerationV1 = {
669
+ schemaVersion: 1,
670
+ generationId: removalId,
671
+ contentHash: EMPTY_SHA256,
672
+ size: 0,
673
+ writer: request.writer,
674
+ writtenAt: writtenAt.toISOString(),
675
+ };
676
+ const mount = this.mount(request.path.root);
677
+ const script = [
678
+ "set -eu",
679
+ `ROOT=${shellQuote(mount)}`,
680
+ `REL=${shellQuote(relative)}`,
681
+ 'TARGET="$ROOT/$REL"',
682
+ `META="$ROOT/${GENERATIONS_DIR}/$REL"`,
683
+ `GRAVE="$ROOT/${WORKSPACE_SYNC_DIR}/${SYNC_TOMBSTONES_DIR}/$REL"`,
684
+ `mkdir -p "$ROOT/${LOCKS_DIR}" "$(dirname "$META")"`,
685
+ 'LOCK=$(printf %s "$REL" | sha256sum | cut -d" " -f1)',
686
+ `exec 9>"$ROOT/${LOCKS_DIR}/$LOCK"`,
687
+ "flock -x 9",
688
+ 'if [ ! -f "$TARGET" ]; then echo __MISSING__; exit 0; fi',
689
+ "CURRENT=__UNRECORDED__",
690
+ 'if [ -f "$META" ]; then CURRENT=$(sed -n 1p "$META"); fi',
691
+ `if [ "$CURRENT" != ${shellQuote(request.expectedGenerationId)} ]; then echo __CONFLICT__; exit 0; fi`,
692
+ // A delete leaves a durable tombstone on the Computer: the removal is
693
+ // recorded with the generation it superseded and the writer that
694
+ // performed it, so "this file is gone, deliberately, and here is who
695
+ // removed it" survives the removal, and the durable-root sync carries it
696
+ // to object storage instead of reading the absence as a file that never
697
+ // existed. Its first line is the superseded generation id, which is what
698
+ // a conditional delete against the store must present.
699
+ `mkdir -p "$(dirname "$GRAVE")"`,
700
+ 'rm -f "$TARGET" "$META"',
701
+ 'GTMP=$(mktemp "${GRAVE}.XXXXXX")',
702
+ `printf %s ${shellQuote(Buffer.from(`${request.expectedGenerationId}\n${JSON.stringify(tombstone)}`).toString("base64"))} | base64 -d > "$GTMP"`,
703
+ 'chmod 600 "$GTMP"',
704
+ 'mv "$GTMP" "$GRAVE"',
705
+ "echo __DELETED__",
706
+ ].join("\n");
707
+ const output = await this.run(script);
708
+ if (typeof output !== "string") return output;
709
+ if (output.includes("__MISSING__")) {
710
+ return failure("not-found", `No such Workspace file: ${relative}`);
711
+ }
712
+ if (output.includes("__CONFLICT__")) {
713
+ return failure(
714
+ "conflict",
715
+ `Workspace file changed since the writer last saw it: ${relative}`,
716
+ );
717
+ }
718
+ // A removal is a recorded generation like any other: the Computer forgets
719
+ // the file, and the ledger is then the only durable evidence of who
720
+ // removed it.
721
+ if (this.options.generations) {
722
+ try {
723
+ await this.options.generations.tombstone({
724
+ schemaVersion: 1,
725
+ root: request.path.root,
726
+ path: relative,
727
+ generation: tombstone,
728
+ deleted: true,
729
+ });
730
+ } catch (error) {
731
+ return failure(
732
+ "unavailable",
733
+ error instanceof Error ? error.message : String(error),
734
+ );
735
+ }
736
+ }
737
+ return { status: "ok", generation: tombstone };
738
+ }
739
+ }
740
+
741
+ /**
742
+ * The Fly Computer's Workspace: the kernel-consumed surface, which refuses
743
+ * every Memory root.
744
+ *
745
+ * There is no Computer-side Memory writer, and no seam that could become one.
746
+ * The Memory Package writes object storage, and the durable-root sync
747
+ * (`./sync.ts`) materializes Memory roots here read-only.
748
+ */
749
+ export class FlyComputerWorkspace implements ComputerWorkspace {
750
+ private readonly files: FlyWorkspaceFiles;
751
+
752
+ constructor(
753
+ readonly layout: WorkspaceLayoutV1,
754
+ options: Omit<FlyWorkspaceFilesOptions, "layout" | "surface">,
755
+ ) {
756
+ this.files = new FlyWorkspaceFiles({
757
+ ...options,
758
+ layout,
759
+ surface: "kernel",
760
+ });
761
+ }
762
+
763
+ read(path: WorkspacePathV1): Promise<WorkspaceReadOutcomeV1> {
764
+ return this.files.read(path);
765
+ }
766
+
767
+ list(request: WorkspaceListRequestV1): Promise<WorkspaceListOutcomeV1> {
768
+ return this.files.list(request);
769
+ }
770
+
771
+ stat(path: WorkspacePathV1): Promise<WorkspaceStatOutcomeV1> {
772
+ return this.files.stat(path);
773
+ }
774
+
775
+ write(request: WorkspaceWriteRequestV1): Promise<WorkspaceWriteOutcomeV1> {
776
+ return this.files.write(request);
777
+ }
778
+
779
+ delete(request: WorkspaceDeleteRequestV1): Promise<WorkspaceWriteOutcomeV1> {
780
+ return this.files.delete(request);
781
+ }
782
+ }