@frockbot/plugin-fly-sprite 0.0.0 → 0.1.0
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/frockbot.json +16 -0
- package/package.json +42 -6
- package/src/agent.ts +6 -0
- package/src/computer.test.ts +538 -0
- package/src/computer.ts +1391 -0
- package/src/doctor.test.ts +154 -0
- package/src/host-client.test.ts +618 -0
- package/src/host-client.ts +794 -0
- package/src/host-double.ts +268 -0
- package/src/host.ts +331 -0
- package/src/index.ts +7 -0
- package/src/manifest.ts +3 -0
- package/src/processes.test.ts +176 -0
- package/src/provider.ts +591 -0
- package/src/screenshot.test.ts +145 -0
- package/src/sync.test.ts +1060 -0
- package/src/sync.ts +1275 -0
- package/src/workspace.test.ts +928 -0
- package/src/workspace.ts +782 -0
- package/tsconfig.json +15 -0
- package/README.md +0 -3
package/src/workspace.ts
ADDED
|
@@ -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
|
+
}
|