@frockbot/kernel-contracts 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.
@@ -0,0 +1,1176 @@
1
+ // The Workspace file-access contract: how durable roots are named, how a
2
+ // relative path inside one is validated, what a write records, and the narrow
3
+ // interface the kernel *consumes* to reach any of it.
4
+ //
5
+ // "The kernel declares the narrow interfaces it consumes, including model
6
+ // invocation, tool execution, and Memory access, and owns no implementation of
7
+ // them." This module is the declaration half. The Computer Package implements
8
+ // the Workspace side, the Memory Package implements the Memory roots, and
9
+ // neither appears here.
10
+ //
11
+ // Three constitutional rules shape every type below:
12
+ //
13
+ // 1. "every write to a durable root records its writer" — a write is not a
14
+ // byte push, it is `bytes + writer + the generation the writer last saw`,
15
+ // and it answers with the generation it produced.
16
+ // 2. "The Memory Package is the single writer of Memory roots ... the
17
+ // Workspace presents Memory roots read-only" — so the kernel-consumed
18
+ // interface for a Memory root is `WorkspaceReadsV1`, which has no `write`
19
+ // and no `delete`. There is no flag to flip; the write methods are absent
20
+ // from the type.
21
+ // 3. "The kernel treats every Workspace file as data. Only Skills under a
22
+ // Bot's instruction roots — its own and its User's — written under the
23
+ // Bot's own authority or its User's, are loaded as instructions." —
24
+ // `LoadableSkillSourceV1` and `isLoadableSkillSourceV1` are that sentence
25
+ // as a type and a predicate.
26
+ //
27
+ // Everything decoded here is untrusted: a durable root synchronizes
28
+ // bidirectionally with object storage (ADR 0013), so a path, a writer, and a
29
+ // generation can all arrive from the Computer side.
30
+ //
31
+ // Policy that is deliberately *not* enforced here: "Memory contains no secrets
32
+ // and no credential references" (`AGENTS.md` § Memory). That is Package policy
33
+ // belonging to the Memory Package, which owns what may be written into a
34
+ // Memory root; the kernel's file contract carries bytes and cannot classify
35
+ // them.
36
+ import type {} from "cordis";
37
+
38
+ /** Longest relative path accepted inside a durable root, in UTF-16 units. */
39
+ export const WORKSPACE_MAX_PATH_LENGTH = 1024;
40
+ /** Longest single path segment, matching the POSIX filename limit. */
41
+ export const WORKSPACE_MAX_SEGMENT_LENGTH = 255;
42
+ /** Deepest relative path accepted inside a durable root. */
43
+ export const WORKSPACE_MAX_PATH_SEGMENTS = 32;
44
+ /** Longest Package-declared root id, matching the Package id bound. */
45
+ export const WORKSPACE_MAX_ROOT_ID_LENGTH = 128;
46
+ /** Longest owner identifier, matching `IsolateIdentityV1.botId`. */
47
+ export const WORKSPACE_MAX_OWNER_ID_LENGTH = 256;
48
+ /** Longest Project identifier, matching the Package-declared root id bound. */
49
+ export const WORKSPACE_MAX_PROJECT_ID_LENGTH = 128;
50
+ /** Upper bound on a single durable-root file. */
51
+ export const WORKSPACE_MAX_FILE_BYTES = 1_048_576;
52
+ /** Upper bound on one `list` page. */
53
+ export const WORKSPACE_MAX_LIST_ENTRIES = 1_000;
54
+
55
+ const SHA256_HEX = /^[0-9a-f]{64}$/;
56
+ const ROOT_ID = /^[a-z][a-z0-9-]{0,127}$/;
57
+ /** A Project is named by a slug, exactly as GrokBot names one on disk. */
58
+ const PROJECT_ID = /^[a-z0-9][a-z0-9-]{0,127}$/;
59
+
60
+ /**
61
+ * The kinds of durable root. "durable roots, declared by the Computer
62
+ * Package's Workspace layout and by Package manifests" — the first three are
63
+ * layout roots the constitution names directly, the fourth is a root a Package
64
+ * manifest declares.
65
+ */
66
+ export type WorkspaceRootKindV1 =
67
+ | "bot-instructions"
68
+ | "user-instructions"
69
+ | "bot-memory"
70
+ | "user-memory"
71
+ | "project-memory"
72
+ | "package-declared";
73
+
74
+ /**
75
+ * A durable root, identified by kind and owner. Every root belongs to a User —
76
+ * the User's Computer is the trust boundary (ADR 0012) — and the per-Bot kinds
77
+ * additionally name the Bot whose authority governs writes to them.
78
+ */
79
+ export type WorkspaceRootV1 =
80
+ | { kind: "bot-instructions"; userId: string; botId: string }
81
+ | { kind: "user-instructions"; userId: string }
82
+ | { kind: "bot-memory"; userId: string; botId: string }
83
+ | { kind: "user-memory"; userId: string }
84
+ | { kind: "project-memory"; userId: string; projectId: string }
85
+ | {
86
+ kind: "package-declared";
87
+ userId: string;
88
+ packageId: string;
89
+ rootId: string;
90
+ };
91
+
92
+ /**
93
+ * A root whose files the kernel may load as instructions.
94
+ *
95
+ * Two kinds, because a Bot has instruction roots, plural (ADR 0016): its own,
96
+ * which only it and its User may write, and its User's, which every Bot of
97
+ * that User shares. Both are named here so a loader that walks "the Bot's
98
+ * instruction roots" is walking a type rather than a convention.
99
+ */
100
+ export type WorkspaceInstructionRootV1 = Extract<
101
+ WorkspaceRootV1,
102
+ { kind: "bot-instructions" | "user-instructions" }
103
+ >;
104
+
105
+ /**
106
+ * The User-global instruction root: `users/<id>/skills/`, shared by every Bot
107
+ * one User owns.
108
+ */
109
+ export type WorkspaceUserInstructionRootV1 = Extract<
110
+ WorkspaceRootV1,
111
+ { kind: "user-instructions" }
112
+ >;
113
+
114
+ /** True for the two roots whose files may be loaded as instructions. */
115
+ export function isWorkspaceInstructionRootV1(
116
+ root: WorkspaceRootV1,
117
+ ): root is WorkspaceInstructionRootV1 {
118
+ return root.kind === "bot-instructions" || root.kind === "user-instructions";
119
+ }
120
+
121
+ /**
122
+ * A Memory root. The Memory Package is its only writer.
123
+ *
124
+ * "Memory is Markdown files under durable roots of the Workspace in three
125
+ * tiers: a Bot Memory root per Bot, a User Memory root shared by the User's
126
+ * Bots, and a Project Memory root per Project that a Bot has joined." All
127
+ * three kinds are covered here; the two shared ones are additionally
128
+ * `WorkspaceSharedMemoryRootV1`, because sharding is what makes a shared tier
129
+ * single-writer per file.
130
+ */
131
+ export type WorkspaceMemoryRootV1 = Extract<
132
+ WorkspaceRootV1,
133
+ { kind: "bot-memory" | "user-memory" | "project-memory" }
134
+ >;
135
+
136
+ /**
137
+ * A Memory root more than one Bot writes. "Shared tiers are sharded per
138
+ * writing Bot on disk so every Memory file has exactly one writer; readers
139
+ * merge shards, newest fact wins on conflict, and every shared fact records
140
+ * which Bot learned it."
141
+ */
142
+ export type WorkspaceSharedMemoryRootV1 = Extract<
143
+ WorkspaceRootV1,
144
+ { kind: "user-memory" | "project-memory" }
145
+ >;
146
+
147
+ /** True for the three Memory kinds and nothing else. */
148
+ export function isWorkspaceMemoryRootV1(
149
+ root: WorkspaceRootV1,
150
+ ): root is WorkspaceMemoryRootV1 {
151
+ return (
152
+ root.kind === "bot-memory" ||
153
+ root.kind === "user-memory" ||
154
+ root.kind === "project-memory"
155
+ );
156
+ }
157
+
158
+ /**
159
+ * True for a root the Computer presents read-only, whatever it declares.
160
+ *
161
+ * Two kinds qualify, for one reason. "The Memory Package is the single writer
162
+ * of Memory roots ... the Workspace presents Memory roots read-only through
163
+ * the durable-root sync" (ADR 0013), and ADR 0016 extends exactly that
164
+ * exception to the User-global instruction root: the Skills Package is its
165
+ * only writer, writing object storage directly, so a Turn that needs a Skill
166
+ * never wakes a Computer and a shared root never grows a second writer. The
167
+ * durable-root sync reads these roots and materializes them, and never pushes
168
+ * a Computer-side edit back out of them.
169
+ */
170
+ export function isWorkspaceComputerReadOnlyRootV1(
171
+ root: WorkspaceRootV1,
172
+ ): boolean {
173
+ return isWorkspaceMemoryRootV1(root) || root.kind === "user-instructions";
174
+ }
175
+
176
+ /** True for the Memory kinds whose files are sharded per writing Bot. */
177
+ export function isWorkspaceSharedMemoryRootV1(
178
+ root: WorkspaceRootV1,
179
+ ): root is WorkspaceSharedMemoryRootV1 {
180
+ return root.kind === "user-memory" || root.kind === "project-memory";
181
+ }
182
+
183
+ /** A validated relative path inside one durable root. */
184
+ export interface WorkspacePathV1 {
185
+ root: WorkspaceRootV1;
186
+ /** Relative, POSIX-separated, normalized. Never absolute, never `..`. */
187
+ path: string;
188
+ }
189
+
190
+ /** A path whose root is a Bot's instruction root. */
191
+ export interface WorkspaceInstructionPathV1 extends WorkspacePathV1 {
192
+ root: WorkspaceInstructionRootV1;
193
+ }
194
+
195
+ /**
196
+ * Who performed a durable-root write.
197
+ *
198
+ * The first three kinds are the constitution's one provenance vocabulary —
199
+ * "The recorded origin of a Package or change: first-party, User, or Bot, and
200
+ * for a Bot the Session and Turn that produced it" — narrowed to a file
201
+ * writer. They are not a second provenance type: `PackageProvenanceV1` in
202
+ * `@frockbot/kernel-composition` names the same three kinds for a Package
203
+ * artifact, and cannot be imported here because `kernel-composition` depends
204
+ * on `kernel-contracts`, not the other way round. The two must stay in step;
205
+ * the kinds are the contract.
206
+ *
207
+ * `unattributed` is the fourth kind, and it is not a fourth provenance: it
208
+ * denotes a file whose writer was *not recorded* — written by a process on the
209
+ * Computer outside the Workspace file surface (`computer_exec`, an installer,
210
+ * a shell redirect), so no generation names who produced it. It is what a
211
+ * reader answers about such a file, never what a writer may claim: "every
212
+ * write to a durable root records its writer", so a `write` or a `delete` that
213
+ * names `unattributed` is refused. An unattributed file is ordinary data — it
214
+ * can be read, listed, and overwritten by an authorized writer — but it
215
+ * carries no authority, so `isLoadableSkillSourceV1` refuses it and it is
216
+ * never loaded as an instruction.
217
+ */
218
+ export type WorkspaceWriterV1 =
219
+ | { kind: "first-party"; packageId: string }
220
+ | { kind: "user"; userId: string }
221
+ | {
222
+ kind: "bot";
223
+ botId: string;
224
+ sessionId: string;
225
+ turnId: string;
226
+ runId: string;
227
+ }
228
+ | { kind: "unattributed" };
229
+
230
+ /**
231
+ * One immutable version of a file under a durable root. Generations are
232
+ * superseded, never edited.
233
+ *
234
+ * It carries both identifiers the rest of the kernel already uses: a
235
+ * Durable-Object-minted `generationId`, lexicographically sortable and
236
+ * monotonic per root, exactly like `CompositionGenerationV1.generationId`; and
237
+ * `contentHash`, the sha-256 content address, exactly like
238
+ * `PackageBundleArtifactV1.contentHash`. Ordering needs the minted id;
239
+ * conflict detection and rebuildable indexes need the content address.
240
+ */
241
+ export interface WorkspaceGenerationV1 {
242
+ schemaVersion: 1;
243
+ generationId: string;
244
+ /** sha-256 hex of the file bytes; the empty hash for a deletion tombstone. */
245
+ contentHash: string;
246
+ size: number;
247
+ writer: WorkspaceWriterV1;
248
+ writtenAt: string;
249
+ /** Set when this generation lost a conditional write and was preserved. */
250
+ conflictsWith?: string;
251
+ }
252
+
253
+ /** A file's metadata without its bytes. */
254
+ export interface WorkspaceEntryV1 {
255
+ path: WorkspacePathV1;
256
+ generation: WorkspaceGenerationV1;
257
+ }
258
+
259
+ /** A file and its bytes. */
260
+ export interface WorkspaceFileV1 extends WorkspaceEntryV1 {
261
+ bytes: Uint8Array;
262
+ }
263
+
264
+ /**
265
+ * A call the Workspace could not serve. A declared variant, not an exception:
266
+ * the same reasoning as `IsolateCapabilityFailureV1` — the Computer host is
267
+ * non-authoritative and its connections drop on every pause, so "unavailable"
268
+ * is an ordinary answer the caller must handle, not an error condition.
269
+ */
270
+ export type WorkspaceFailureStatusV1 =
271
+ "not-found" | "refused" | "conflict" | "unavailable";
272
+
273
+ export interface WorkspaceFailureV1 {
274
+ status: WorkspaceFailureStatusV1;
275
+ reason: string;
276
+ }
277
+
278
+ export type WorkspaceReadOutcomeV1 =
279
+ { status: "ok"; file: WorkspaceFileV1 } | WorkspaceFailureV1;
280
+
281
+ export type WorkspaceStatOutcomeV1 =
282
+ { status: "ok"; entry: WorkspaceEntryV1 } | WorkspaceFailureV1;
283
+
284
+ export type WorkspaceListOutcomeV1 =
285
+ | { status: "ok"; entries: WorkspaceEntryV1[]; cursor?: string }
286
+ | WorkspaceFailureV1;
287
+
288
+ /**
289
+ * A write that lost a conditional write. "a write that would overwrite a
290
+ * generation its writer has not seen is preserved as a conflicting generation
291
+ * and surfaced, never merged or dropped" (ADR 0013), so the outcome carries
292
+ * both sides: the generation that holds the file now, and the losing write,
293
+ * preserved under its own generation with `conflictsWith` set.
294
+ */
295
+ export interface WorkspaceConflictV1 extends WorkspaceFailureV1 {
296
+ status: "conflict";
297
+ /** The generation the file holds now, when the store could read one. */
298
+ current?: WorkspaceGenerationV1;
299
+ /** The losing write, preserved rather than dropped. */
300
+ preserved?: WorkspaceGenerationV1;
301
+ }
302
+
303
+ /**
304
+ * Narrows a write outcome to the conflict variant. `WorkspaceFailureV1` can
305
+ * also carry the `conflict` status — a store that has no generations to report
306
+ * still answers `conflict` — so the status alone does not discriminate the
307
+ * union, and this predicate is how a caller reaches the two generations.
308
+ */
309
+ export function isWorkspaceConflictV1(
310
+ outcome: WorkspaceWriteOutcomeV1,
311
+ ): outcome is WorkspaceConflictV1 {
312
+ return outcome.status === "conflict";
313
+ }
314
+
315
+ export type WorkspaceWriteOutcomeV1 =
316
+ | { status: "ok"; generation: WorkspaceGenerationV1 }
317
+ | WorkspaceConflictV1
318
+ | WorkspaceFailureV1;
319
+
320
+ export interface WorkspaceListRequestV1 {
321
+ root: WorkspaceRootV1;
322
+ /** A validated relative path prefix, or absent for the whole root. */
323
+ prefix?: string;
324
+ cursor?: string;
325
+ limit?: number;
326
+ }
327
+
328
+ /**
329
+ * A write. `expectedGenerationId` is the generation the writer has seen:
330
+ * `null` asserts the file does not exist. A mismatch answers `conflict`; the
331
+ * losing write is preserved as a conflicting generation and surfaced, never
332
+ * merged or dropped (ADR 0013).
333
+ */
334
+ export interface WorkspaceWriteRequestV1 {
335
+ path: WorkspacePathV1;
336
+ bytes: Uint8Array;
337
+ writer: WorkspaceWriterV1;
338
+ expectedGenerationId: string | null;
339
+ mediaType?: string;
340
+ }
341
+
342
+ export interface WorkspaceDeleteRequestV1 {
343
+ path: WorkspacePathV1;
344
+ writer: WorkspaceWriterV1;
345
+ expectedGenerationId: string;
346
+ }
347
+
348
+ /**
349
+ * The read half of the Workspace, and the whole of what the kernel consumes
350
+ * for a Memory root. Nothing here mutates.
351
+ */
352
+ export interface WorkspaceReadsV1 {
353
+ read(path: WorkspacePathV1): Promise<WorkspaceReadOutcomeV1>;
354
+ list(request: WorkspaceListRequestV1): Promise<WorkspaceListOutcomeV1>;
355
+ stat(path: WorkspacePathV1): Promise<WorkspaceStatOutcomeV1>;
356
+ }
357
+
358
+ /**
359
+ * The narrow file interface the kernel declares and consumes. Writes require a
360
+ * writer and answer with the generation they produced.
361
+ */
362
+ export interface WorkspaceFilesV1 extends WorkspaceReadsV1 {
363
+ write(request: WorkspaceWriteRequestV1): Promise<WorkspaceWriteOutcomeV1>;
364
+ delete(request: WorkspaceDeleteRequestV1): Promise<WorkspaceWriteOutcomeV1>;
365
+ }
366
+
367
+ /**
368
+ * The read-only projection of the roots the Memory Package owns. A Memory root
369
+ * reaches the kernel only through this type, which has no `write` and no
370
+ * `delete` to call.
371
+ */
372
+ export type WorkspaceMemoryProjectionV1 = WorkspaceReadsV1;
373
+
374
+ /**
375
+ * True when a root accepts a write through the kernel-consumed interface, and
376
+ * — when a writer is supplied — when that writer may name itself on a write.
377
+ *
378
+ * False for every Memory root: the Memory Package writes object storage
379
+ * directly and the Workspace presents Memory read-only. False for an
380
+ * `unattributed` writer whatever the root: "every write to a durable root
381
+ * records its writer", so a write must always name a real one. `unattributed`
382
+ * is an answer a reader gives about a file nobody recorded, not a writer a
383
+ * caller may present.
384
+ */
385
+ export function workspaceRootAcceptsKernelWriteV1(
386
+ root: WorkspaceRootV1,
387
+ writer?: WorkspaceWriterV1,
388
+ ): boolean {
389
+ if (writer !== undefined && !workspaceWriterMayWriteV1(writer)) return false;
390
+ return !isWorkspaceMemoryRootV1(root);
391
+ }
392
+
393
+ /**
394
+ * True when a writer may be named on a `write` or a `delete`. Only
395
+ * `unattributed` may not: it records the absence of a recorded writer, and a
396
+ * write that recorded nothing would be a write with no writer.
397
+ */
398
+ export function workspaceWriterMayWriteV1(writer: WorkspaceWriterV1): boolean {
399
+ return writer.kind !== "unattributed";
400
+ }
401
+
402
+ /**
403
+ * Narrows a full file interface to the read-only projection. The returned
404
+ * object carries only `read`, `list`, and `stat`, so a Memory root handed
405
+ * across this function cannot be written even by a caller that reaches for
406
+ * `write` dynamically.
407
+ */
408
+ export function workspaceMemoryProjectionV1(
409
+ files: WorkspaceReadsV1,
410
+ ): WorkspaceMemoryProjectionV1 {
411
+ return {
412
+ read: (path) => files.read(path),
413
+ list: (request) => files.list(request),
414
+ stat: (path) => files.stat(path),
415
+ };
416
+ }
417
+
418
+ /**
419
+ * The directory a shared Memory tier gives one writing Bot. GrokBot's own
420
+ * layout, kept verbatim: `user-memory/by-agent/<agent-uuid>/`, and
421
+ * `projects/<slug>/memory/by-agent/<assistantId>/` for a Project. The prefix
422
+ * is the mechanism behind "every Memory file has exactly one writer".
423
+ */
424
+ export const WORKSPACE_MEMORY_SHARD_PREFIX = "by-agent";
425
+
426
+ /**
427
+ * One writing Bot's slice of a Memory root.
428
+ *
429
+ * A Bot Memory root has exactly one writer already, so its shard is the whole
430
+ * root and its `prefix` is empty. A shared root's shard is
431
+ * `by-agent/<botId>/`, and a reader that wants the tier merges every shard by
432
+ * listing the root without one.
433
+ */
434
+ export interface WorkspaceShardV1 {
435
+ root: WorkspaceMemoryRootV1;
436
+ /** The Bot whose files live under `prefix`. */
437
+ botId: string;
438
+ /** Relative prefix inside the root; `""` for a Bot Memory root. */
439
+ prefix: string;
440
+ }
441
+
442
+ /**
443
+ * The relative prefix a Bot's files sit under inside a Memory root. Empty for
444
+ * `bot-memory` — that root is already single-writer, so sharding it would add
445
+ * a directory level that means nothing.
446
+ */
447
+ export function memoryShardPrefixV1(
448
+ root: WorkspaceMemoryRootV1,
449
+ botId: string,
450
+ ): string {
451
+ if (root.kind === "bot-memory") return "";
452
+ const shard = boundedString(
453
+ botId,
454
+ "shard botId",
455
+ WORKSPACE_MAX_OWNER_ID_LENGTH,
456
+ );
457
+ if (shard.includes("/") || shard === "." || shard === "..") {
458
+ throw new Error("shard botId is not a single path segment");
459
+ }
460
+ return `${WORKSPACE_MEMORY_SHARD_PREFIX}/${encodeURIComponent(shard)}/`;
461
+ }
462
+
463
+ /** The shard a Bot writes in one Memory root. */
464
+ export function workspaceMemoryShardV1(
465
+ root: WorkspaceMemoryRootV1,
466
+ botId: string,
467
+ ): WorkspaceShardV1 {
468
+ return { root, botId, prefix: memoryShardPrefixV1(root, botId) };
469
+ }
470
+
471
+ /**
472
+ * Places one writing Bot's Memory file inside the root that owns it: under
473
+ * `by-agent/<botId>/` in a shared tier, directly in the root for `bot-memory`.
474
+ * The result is a validated path, so a `relative` that escapes its root is
475
+ * refused here rather than reaching object storage.
476
+ */
477
+ export function memoryShardPathV1(
478
+ root: WorkspaceMemoryRootV1,
479
+ botId: string,
480
+ relative: string,
481
+ ): WorkspacePathV1 {
482
+ const tail = normalizeWorkspaceRelativePathV1(relative, "memory shard path");
483
+ return {
484
+ root,
485
+ path: normalizeWorkspaceRelativePathV1(
486
+ `${memoryShardPrefixV1(root, botId)}${tail}`,
487
+ "memory shard path",
488
+ ),
489
+ };
490
+ }
491
+
492
+ /**
493
+ * The Bot whose shard a path falls in, or `undefined` when the path is not
494
+ * inside a shard. `bot-memory` answers the root's own Bot: the whole root is
495
+ * that Bot's shard.
496
+ */
497
+ export function memoryShardOwnerV1(path: WorkspacePathV1): string | undefined {
498
+ const root = path.root;
499
+ if (root.kind === "bot-memory") return root.botId;
500
+ if (!isWorkspaceSharedMemoryRootV1(root)) return undefined;
501
+ const segments = path.path.split("/");
502
+ if (segments.length < 3 || segments[0] !== WORKSPACE_MEMORY_SHARD_PREFIX) {
503
+ return undefined;
504
+ }
505
+ const shard = segments[1] ?? "";
506
+ if (!shard) return undefined;
507
+ try {
508
+ return decodeURIComponent(shard);
509
+ } catch {
510
+ return undefined;
511
+ }
512
+ }
513
+
514
+ /**
515
+ * "within a shared root each Bot's shard is written only on that Bot's
516
+ * behalf".
517
+ *
518
+ * True only when the writer may own the file at `path`: a Bot writer when the
519
+ * path is inside its own shard, a User writer for any shard of a root the User
520
+ * owns — the User's Computer is the trust boundary, and a User may correct
521
+ * their own Memory — and never a first-party Package (a Package that wants to
522
+ * ship instructions ships a Package) or an `unattributed` writer (nothing
523
+ * recorded who wrote it, so ownership is not merely false but unprovable).
524
+ *
525
+ * Pure, total, and false for every non-Memory root: this predicate answers the
526
+ * Memory sharding rule only, never the wider question of who may write a root.
527
+ */
528
+ export function writerOwnsMemoryPathV1(
529
+ path: WorkspacePathV1,
530
+ writer: WorkspaceWriterV1,
531
+ ): boolean {
532
+ const root = path.root;
533
+ if (!isWorkspaceMemoryRootV1(root)) return false;
534
+ if (writer.kind === "user") return writer.userId === root.userId;
535
+ if (writer.kind !== "bot") return false;
536
+ if (root.kind === "bot-memory") return writer.botId === root.botId;
537
+ return memoryShardOwnerV1(path) === writer.botId;
538
+ }
539
+
540
+ /**
541
+ * A candidate Skill: a file under some durable root together with the writer
542
+ * the root recorded for it. The kernel loads it as an instruction only when
543
+ * `isLoadableSkillSourceV1` says so.
544
+ */
545
+ export interface SkillSourceV1 {
546
+ path: WorkspacePathV1;
547
+ writer: WorkspaceWriterV1;
548
+ generation: WorkspaceGenerationV1;
549
+ }
550
+
551
+ /** A Skill source the kernel may load: the root kind is proven by the type. */
552
+ export interface LoadableSkillSourceV1 extends SkillSourceV1 {
553
+ path: WorkspaceInstructionPathV1;
554
+ }
555
+
556
+ /**
557
+ * "Only Skills under a Bot's instruction roots — its own and its User's —
558
+ * written under the Bot's own authority or its User's, are loaded as
559
+ * instructions."
560
+ *
561
+ * Pure, total, and the only place that sentence is decided. A first-party
562
+ * writer is not the Bot's authority nor its User's, so it is refused too: a
563
+ * Package that wants to ship instructions ships a Package, not a Skill. An
564
+ * `unattributed` writer is refused for a stronger reason: nothing recorded who
565
+ * wrote the file, so "written under the Bot's own authority or its User's" is
566
+ * not merely false but unprovable. A file a shell command dropped into an
567
+ * instruction root is data the Bot can read, never an instruction it loads.
568
+ *
569
+ * The two roots differ in exactly one clause, and authority does not widen
570
+ * between them (ADR 0016). Under the Bot's own root only that Bot or its User
571
+ * may have written a loadable Skill. Under the User-global root any Bot of
572
+ * that User may have, which is the whole point of a shared tier: a Bot writing
573
+ * there writes under authority it already holds, and the reading Bot is told
574
+ * whose Skill it is rather than being handed an anonymous instruction. A root
575
+ * belonging to another User is refused before the writer is even read, so a
576
+ * Bot never loads a Skill from a Workspace that is not its User's.
577
+ */
578
+ export function isLoadableSkillSourceV1(
579
+ source: SkillSourceV1,
580
+ owner: { botId: string; userId: string },
581
+ ): source is LoadableSkillSourceV1 {
582
+ const root = source.path.root;
583
+ if (!isWorkspaceInstructionRootV1(root)) return false;
584
+ if (root.userId !== owner.userId) return false;
585
+ const writer = source.writer;
586
+ if (root.kind === "bot-instructions") {
587
+ if (root.botId !== owner.botId) return false;
588
+ if (writer.kind === "user") return writer.userId === owner.userId;
589
+ if (writer.kind === "bot") return writer.botId === owner.botId;
590
+ return false;
591
+ }
592
+ if (writer.kind === "user") return writer.userId === owner.userId;
593
+ // A Bot of this User. `WorkspaceWriterV1` names no User on a `bot` writer,
594
+ // and it does not need to: a durable root belongs to one User, and the file
595
+ // surface that recorded the write serves that User's roots alone, so a
596
+ // recorded `bot` generation under this root is a Bot of this User.
597
+ return writer.kind === "bot";
598
+ }
599
+
600
+ /** A stable, collision-free key for one durable root. */
601
+ export function workspaceRootKeyV1(root: WorkspaceRootV1): string {
602
+ const user = encodeURIComponent(root.userId);
603
+ // The User-global instruction root is named by its location rather than by
604
+ // `<kind>:<owner>`, because ADR 0016 names it `users/<id>/skills/` and the
605
+ // object store keys every file under `workspace/<root key>/`. No other kind
606
+ // produces a key beginning `users/`, so it collides with none of them.
607
+ if (root.kind === "user-instructions") return `users/${user}/skills`;
608
+ if (root.kind === "user-memory") return `user-memory:${user}`;
609
+ if (root.kind === "project-memory") {
610
+ return `project-memory:${user}:${encodeURIComponent(root.projectId)}`;
611
+ }
612
+ if (root.kind === "package-declared") {
613
+ return `package-declared:${user}:${encodeURIComponent(root.packageId)}:${root.rootId}`;
614
+ }
615
+ return `${root.kind}:${user}:${encodeURIComponent(root.botId)}`;
616
+ }
617
+
618
+ function record(value: unknown, label: string): Record<string, unknown> {
619
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
620
+ throw new Error(`${label} must be an object`);
621
+ }
622
+ return value as Record<string, unknown>;
623
+ }
624
+
625
+ function exactKeys(
626
+ value: Record<string, unknown>,
627
+ required: readonly string[],
628
+ label: string,
629
+ optional: readonly string[] = [],
630
+ ): void {
631
+ const allowed = new Set<string>([...required, ...optional]);
632
+ if (
633
+ !required.every((key) => Object.hasOwn(value, key)) ||
634
+ !Object.keys(value).every((key) => allowed.has(key))
635
+ ) {
636
+ throw new Error(`${label} has invalid fields`);
637
+ }
638
+ }
639
+
640
+ function boundedString(
641
+ value: unknown,
642
+ label: string,
643
+ maximum: number,
644
+ allowEmpty = false,
645
+ ): string {
646
+ if (
647
+ typeof value !== "string" ||
648
+ (!allowEmpty && value.length === 0) ||
649
+ value.length > maximum
650
+ ) {
651
+ throw new Error(`${label} must be a bounded string`);
652
+ }
653
+ return value;
654
+ }
655
+
656
+ function ownerId(value: unknown, label: string): string {
657
+ return boundedString(value, label, WORKSPACE_MAX_OWNER_ID_LENGTH);
658
+ }
659
+
660
+ /** The suffix the object-storage conflict key scheme reserves. */
661
+ export const WORKSPACE_CONFLICT_SEGMENT_SUFFIX = ".conflict";
662
+
663
+ /**
664
+ * Segments no durable-root path may use, because the object-storage key scheme
665
+ * and the Computer-side sync already own them.
666
+ *
667
+ * A segment ending in `.conflict` would collide with the conflict key scheme —
668
+ * `workspace/<root>/<relative>.conflict/<generationId>` — so `notes.conflict`
669
+ * and `notes.conflict/a.md` are refused rather than silently shadowing, or
670
+ * being shadowed by, a preserved losing write. `.frockbot-generations` and
671
+ * `.frockbot-sync` are the sync agent's own directories on the Computer; a
672
+ * durable-root file may not occupy them either.
673
+ */
674
+ const WORKSPACE_RESERVED_SEGMENTS = new Set([
675
+ ".frockbot-generations",
676
+ ".frockbot-sync",
677
+ ]);
678
+
679
+ /**
680
+ * Validates one relative path inside a durable root. Rejects absolute paths,
681
+ * `.` and `..` segments, empty segments, backslashes, NUL and other control
682
+ * characters, untrimmed text, the reserved segments above, and anything past
683
+ * the length or depth bound. Returns the path unchanged: a path that needs
684
+ * normalizing is refused rather than rewritten, so what a caller asked for is
685
+ * what a generation records.
686
+ */
687
+ export function normalizeWorkspaceRelativePathV1(
688
+ input: unknown,
689
+ label = "workspace path",
690
+ ): string {
691
+ const path = boundedString(input, label, WORKSPACE_MAX_PATH_LENGTH);
692
+ if (
693
+ path !== path.trim() ||
694
+ path.startsWith("/") ||
695
+ path.includes("\\") ||
696
+ /[\u0000-\u001f\u007f]/.test(path)
697
+ ) {
698
+ throw new Error(`${label} must be a relative POSIX path`);
699
+ }
700
+ const segments = path.split("/");
701
+ if (segments.length > WORKSPACE_MAX_PATH_SEGMENTS) {
702
+ throw new Error(`${label} exceeds its depth bound`);
703
+ }
704
+ for (const segment of segments) {
705
+ if (
706
+ !segment ||
707
+ segment === "." ||
708
+ segment === ".." ||
709
+ segment.length > WORKSPACE_MAX_SEGMENT_LENGTH ||
710
+ segment !== segment.trim()
711
+ ) {
712
+ throw new Error(`${label} has an invalid segment`);
713
+ }
714
+ if (
715
+ segment.endsWith(WORKSPACE_CONFLICT_SEGMENT_SUFFIX) ||
716
+ WORKSPACE_RESERVED_SEGMENTS.has(segment)
717
+ ) {
718
+ throw new Error(`${label} uses a reserved segment: ${segment}`);
719
+ }
720
+ }
721
+ return path;
722
+ }
723
+
724
+ export function decodeWorkspaceRootV1(
725
+ input: unknown,
726
+ label = "workspace root",
727
+ ): WorkspaceRootV1 {
728
+ const value = record(input, label);
729
+ if (value.kind === "bot-instructions" || value.kind === "bot-memory") {
730
+ exactKeys(value, ["kind", "userId", "botId"], label);
731
+ return {
732
+ kind: value.kind,
733
+ userId: ownerId(value.userId, `${label}.userId`),
734
+ botId: ownerId(value.botId, `${label}.botId`),
735
+ };
736
+ }
737
+ if (value.kind === "user-instructions" || value.kind === "user-memory") {
738
+ exactKeys(value, ["kind", "userId"], label);
739
+ return {
740
+ kind: value.kind,
741
+ userId: ownerId(value.userId, `${label}.userId`),
742
+ };
743
+ }
744
+ if (value.kind === "project-memory") {
745
+ exactKeys(value, ["kind", "userId", "projectId"], label);
746
+ const projectId = boundedString(
747
+ value.projectId,
748
+ `${label}.projectId`,
749
+ WORKSPACE_MAX_PROJECT_ID_LENGTH,
750
+ );
751
+ if (!PROJECT_ID.test(projectId)) {
752
+ throw new Error(`${label}.projectId is invalid`);
753
+ }
754
+ return {
755
+ kind: "project-memory",
756
+ userId: ownerId(value.userId, `${label}.userId`),
757
+ projectId,
758
+ };
759
+ }
760
+ if (value.kind === "package-declared") {
761
+ exactKeys(value, ["kind", "userId", "packageId", "rootId"], label);
762
+ const rootId = boundedString(
763
+ value.rootId,
764
+ `${label}.rootId`,
765
+ WORKSPACE_MAX_ROOT_ID_LENGTH,
766
+ );
767
+ if (!ROOT_ID.test(rootId)) throw new Error(`${label}.rootId is invalid`);
768
+ return {
769
+ kind: "package-declared",
770
+ userId: ownerId(value.userId, `${label}.userId`),
771
+ packageId: boundedString(value.packageId, `${label}.packageId`, 128),
772
+ rootId,
773
+ };
774
+ }
775
+ throw new Error(`${label}.kind is invalid`);
776
+ }
777
+
778
+ export function decodeWorkspacePathV1(
779
+ input: unknown,
780
+ label = "workspace path",
781
+ ): WorkspacePathV1 {
782
+ const value = record(input, label);
783
+ exactKeys(value, ["root", "path"], label);
784
+ return {
785
+ root: decodeWorkspaceRootV1(value.root, `${label}.root`),
786
+ path: normalizeWorkspaceRelativePathV1(value.path, `${label}.path`),
787
+ };
788
+ }
789
+
790
+ export function decodeWorkspaceWriterV1(
791
+ input: unknown,
792
+ label = "workspace writer",
793
+ ): WorkspaceWriterV1 {
794
+ const value = record(input, label);
795
+ if (value.kind === "first-party") {
796
+ exactKeys(value, ["kind", "packageId"], label);
797
+ return {
798
+ kind: "first-party",
799
+ packageId: boundedString(value.packageId, `${label}.packageId`, 128),
800
+ };
801
+ }
802
+ if (value.kind === "user") {
803
+ exactKeys(value, ["kind", "userId"], label);
804
+ return { kind: "user", userId: ownerId(value.userId, `${label}.userId`) };
805
+ }
806
+ if (value.kind === "unattributed") {
807
+ exactKeys(value, ["kind"], label);
808
+ return { kind: "unattributed" };
809
+ }
810
+ if (value.kind === "bot") {
811
+ exactKeys(value, ["kind", "botId", "sessionId", "turnId", "runId"], label);
812
+ return {
813
+ kind: "bot",
814
+ botId: ownerId(value.botId, `${label}.botId`),
815
+ sessionId: boundedString(value.sessionId, `${label}.sessionId`, 257),
816
+ turnId: boundedString(value.turnId, `${label}.turnId`, 128),
817
+ runId: boundedString(value.runId, `${label}.runId`, 128),
818
+ };
819
+ }
820
+ throw new Error(`${label}.kind is invalid`);
821
+ }
822
+
823
+ export function decodeWorkspaceGenerationV1(
824
+ input: unknown,
825
+ label = "workspace generation",
826
+ ): WorkspaceGenerationV1 {
827
+ const value = record(input, label);
828
+ exactKeys(
829
+ value,
830
+ [
831
+ "schemaVersion",
832
+ "generationId",
833
+ "contentHash",
834
+ "size",
835
+ "writer",
836
+ "writtenAt",
837
+ ],
838
+ label,
839
+ ["conflictsWith"],
840
+ );
841
+ if (value.schemaVersion !== 1) {
842
+ throw new Error(`${label}.schemaVersion is unsupported`);
843
+ }
844
+ if (
845
+ typeof value.contentHash !== "string" ||
846
+ !SHA256_HEX.test(value.contentHash)
847
+ ) {
848
+ throw new Error(`${label}.contentHash must be a sha-256 hex digest`);
849
+ }
850
+ if (
851
+ !Number.isSafeInteger(value.size) ||
852
+ (value.size as number) < 0 ||
853
+ (value.size as number) > WORKSPACE_MAX_FILE_BYTES
854
+ ) {
855
+ throw new Error(`${label}.size is out of range`);
856
+ }
857
+ const generation: WorkspaceGenerationV1 = {
858
+ schemaVersion: 1,
859
+ generationId: boundedString(
860
+ value.generationId,
861
+ `${label}.generationId`,
862
+ 256,
863
+ ),
864
+ contentHash: value.contentHash,
865
+ size: value.size as number,
866
+ writer: decodeWorkspaceWriterV1(value.writer, `${label}.writer`),
867
+ writtenAt: boundedString(value.writtenAt, `${label}.writtenAt`, 64),
868
+ };
869
+ if (value.conflictsWith !== undefined) {
870
+ generation.conflictsWith = boundedString(
871
+ value.conflictsWith,
872
+ `${label}.conflictsWith`,
873
+ 256,
874
+ );
875
+ }
876
+ return generation;
877
+ }
878
+
879
+ export function decodeWorkspaceEntryV1(
880
+ input: unknown,
881
+ label = "workspace entry",
882
+ ): WorkspaceEntryV1 {
883
+ const value = record(input, label);
884
+ exactKeys(value, ["path", "generation"], label);
885
+ return {
886
+ path: decodeWorkspacePathV1(value.path, `${label}.path`),
887
+ generation: decodeWorkspaceGenerationV1(
888
+ value.generation,
889
+ `${label}.generation`,
890
+ ),
891
+ };
892
+ }
893
+
894
+ const FAILURE_STATUSES: readonly WorkspaceFailureStatusV1[] = [
895
+ "not-found",
896
+ "refused",
897
+ "conflict",
898
+ "unavailable",
899
+ ];
900
+
901
+ export function decodeWorkspaceFailureV1(
902
+ input: unknown,
903
+ label = "workspace failure",
904
+ ): WorkspaceFailureV1 {
905
+ const value = record(input, label);
906
+ exactKeys(value, ["status", "reason"], label, ["current", "preserved"]);
907
+ const status = FAILURE_STATUSES.find(
908
+ (candidate) => candidate === value.status,
909
+ );
910
+ if (!status) throw new Error(`${label}.status is invalid`);
911
+ const failure: WorkspaceFailureV1 = {
912
+ status,
913
+ reason: boundedString(value.reason, `${label}.reason`, 512),
914
+ };
915
+ if (value.current === undefined && value.preserved === undefined) {
916
+ return failure;
917
+ }
918
+ if (status !== "conflict") {
919
+ throw new Error(
920
+ `${label} carries conflicting generations without a conflict`,
921
+ );
922
+ }
923
+ return decodeWorkspaceConflictV1(value, label);
924
+ }
925
+
926
+ /**
927
+ * A conflict outcome with the two generations ADR 0013 requires to survive.
928
+ * Both are optional: a store that could not read the current generation still
929
+ * answers `conflict` rather than inventing one.
930
+ */
931
+ export function decodeWorkspaceConflictV1(
932
+ input: unknown,
933
+ label = "workspace conflict",
934
+ ): WorkspaceConflictV1 {
935
+ const value = record(input, label);
936
+ exactKeys(value, ["status", "reason"], label, ["current", "preserved"]);
937
+ if (value.status !== "conflict") {
938
+ throw new Error(`${label}.status must be "conflict"`);
939
+ }
940
+ const conflict: WorkspaceConflictV1 = {
941
+ status: "conflict",
942
+ reason: boundedString(value.reason, `${label}.reason`, 512),
943
+ };
944
+ if (value.current !== undefined) {
945
+ conflict.current = decodeWorkspaceGenerationV1(
946
+ value.current,
947
+ `${label}.current`,
948
+ );
949
+ }
950
+ if (value.preserved !== undefined) {
951
+ conflict.preserved = decodeWorkspaceGenerationV1(
952
+ value.preserved,
953
+ `${label}.preserved`,
954
+ );
955
+ }
956
+ return conflict;
957
+ }
958
+
959
+ export function decodeSkillSourceV1(
960
+ input: unknown,
961
+ label = "skill source",
962
+ ): SkillSourceV1 {
963
+ const value = record(input, label);
964
+ exactKeys(value, ["path", "writer", "generation"], label);
965
+ return {
966
+ path: decodeWorkspacePathV1(value.path, `${label}.path`),
967
+ writer: decodeWorkspaceWriterV1(value.writer, `${label}.writer`),
968
+ generation: decodeWorkspaceGenerationV1(
969
+ value.generation,
970
+ `${label}.generation`,
971
+ ),
972
+ };
973
+ }
974
+
975
+ /**
976
+ * One durable generation record: what a Durable Object stores about a single
977
+ * file in a durable root.
978
+ *
979
+ * "The Workspace and its object-storage twin are the only durable state
980
+ * outside a Durable Object. They hold files, never authority: a Durable Object
981
+ * records every intent, effect, and generation that concerns them." The bytes
982
+ * live in object storage; this record is the authority for which generation
983
+ * those bytes are, who wrote them, and — through `etag` — which conditional
984
+ * write may replace them.
985
+ */
986
+ export interface WorkspaceGenerationRecordV1 {
987
+ schemaVersion: 1;
988
+ root: WorkspaceRootV1;
989
+ /** Validated relative path inside `root`. */
990
+ path: string;
991
+ generation: WorkspaceGenerationV1;
992
+ /**
993
+ * The object-storage entity tag the generation's bytes landed under. It is
994
+ * what an `If-Match` write is conditioned on, so a writer that has seen
995
+ * `generation.generationId` can prove it. Absent on a tombstone, and absent
996
+ * when the record was recovered from a store that reported none.
997
+ */
998
+ etag?: string;
999
+ /** True when the record is a deletion tombstone rather than a file. */
1000
+ deleted?: boolean;
1001
+ /** Object key holding a preserved losing write, on a conflict record. */
1002
+ conflictKey?: string;
1003
+ }
1004
+
1005
+ /**
1006
+ * The generation ledger a durable root's owning Durable Object keeps, declared
1007
+ * here and implemented there. "The User's Durable Object is the authority for
1008
+ * ... the generation records of User Memory roots"; the Bot's Durable Object is
1009
+ * the authority for its own roots. An object-storage implementation of
1010
+ * `WorkspaceFilesV1` consumes this interface and owns none of it.
1011
+ */
1012
+ export interface WorkspaceGenerationsV1 {
1013
+ /**
1014
+ * A sortable generation id, minted by the authority that owns `root` and
1015
+ * monotonic within it.
1016
+ *
1017
+ * The root is a parameter because ordering is only meaningful inside one
1018
+ * authority: "The User's Durable Object is the authority for ... the
1019
+ * generation records of User Memory roots", so a shared Memory root's ids
1020
+ * must come from the User object even when the Bot object is doing the
1021
+ * writing. Two Bots minting from two counters would produce ids that do not
1022
+ * order, and "newest fact wins on conflict" would have no answer.
1023
+ */
1024
+ mint(at: Date, root: WorkspaceRootV1): Promise<string>;
1025
+ /** The generation the authority believes the file currently holds. */
1026
+ current(
1027
+ root: WorkspaceRootV1,
1028
+ path: string,
1029
+ ): Promise<WorkspaceGenerationRecordV1 | undefined>;
1030
+ /** Records a generation that won its conditional write. */
1031
+ record(entry: WorkspaceGenerationRecordV1): Promise<void>;
1032
+ /**
1033
+ * Records a deletion. A delete leaves a durable tombstone, so "nothing is
1034
+ * here" is a recorded outcome with a writer rather than an absence nobody
1035
+ * can account for after a Durable Object is evicted.
1036
+ */
1037
+ tombstone(entry: WorkspaceGenerationRecordV1): Promise<void>;
1038
+ /** Records a losing write, preserved beside the winner and surfaced. */
1039
+ conflict(entry: WorkspaceGenerationRecordV1): Promise<void>;
1040
+ /** Every preserved losing write for one file, oldest first. */
1041
+ conflicts(
1042
+ root: WorkspaceRootV1,
1043
+ path: string,
1044
+ ): Promise<WorkspaceGenerationRecordV1[]>;
1045
+ }
1046
+
1047
+ export function decodeWorkspaceGenerationRecordV1(
1048
+ input: unknown,
1049
+ label = "workspace generation record",
1050
+ ): WorkspaceGenerationRecordV1 {
1051
+ const value = record(input, label);
1052
+ exactKeys(value, ["schemaVersion", "root", "path", "generation"], label, [
1053
+ "etag",
1054
+ "deleted",
1055
+ "conflictKey",
1056
+ ]);
1057
+ if (value.schemaVersion !== 1) {
1058
+ throw new Error(`${label}.schemaVersion is unsupported`);
1059
+ }
1060
+ const entry: WorkspaceGenerationRecordV1 = {
1061
+ schemaVersion: 1,
1062
+ root: decodeWorkspaceRootV1(value.root, `${label}.root`),
1063
+ path: normalizeWorkspaceRelativePathV1(value.path, `${label}.path`),
1064
+ generation: decodeWorkspaceGenerationV1(
1065
+ value.generation,
1066
+ `${label}.generation`,
1067
+ ),
1068
+ };
1069
+ if (value.etag !== undefined) {
1070
+ entry.etag = boundedString(value.etag, `${label}.etag`, 256);
1071
+ }
1072
+ if (value.deleted !== undefined) {
1073
+ if (typeof value.deleted !== "boolean") {
1074
+ throw new Error(`${label}.deleted must be a boolean`);
1075
+ }
1076
+ entry.deleted = value.deleted;
1077
+ }
1078
+ if (value.conflictKey !== undefined) {
1079
+ entry.conflictKey = boundedString(
1080
+ value.conflictKey,
1081
+ `${label}.conflictKey`,
1082
+ 2048,
1083
+ );
1084
+ }
1085
+ return entry;
1086
+ }
1087
+
1088
+ /**
1089
+ * One recorded push intent of the durable-root sync (ADR 0013).
1090
+ *
1091
+ * Constitution, Computer and Workspace: "A mutation ... records intent and an
1092
+ * effect identifier in the Bot's Durable Object and in the Workspace before it
1093
+ * runs, so recovery can read its outcome or classify it as unknown without
1094
+ * repeating it." A sync push is such a mutation, and this is the record it
1095
+ * writes first. `effectId` is deterministic in the root, path, kind, bytes and
1096
+ * expected generation, so the same pending push resolves to the same key
1097
+ * however many times a dropped connection makes the sync try again.
1098
+ */
1099
+ export interface WorkspaceSyncEffectV1 {
1100
+ effectId: string;
1101
+ root: WorkspaceRootV1;
1102
+ path: string;
1103
+ kind: "push" | "remove";
1104
+ /** sha-256 of the bytes the push carries; the empty digest for a remove. */
1105
+ contentHash: string;
1106
+ expectedGenerationId: string | null;
1107
+ at: string;
1108
+ }
1109
+
1110
+ /**
1111
+ * Where a durable-root push records its intent.
1112
+ *
1113
+ * Declared by the kernel and implemented by the Bot's Durable Object
1114
+ * (`packages/kernel-do/src/workspace-sync-effects.ts`), the same way
1115
+ * `WorkspaceGenerationsV1` is: the sync agent lives in a Computer provider
1116
+ * Package and holds no authority, so the record it depends on belongs to the
1117
+ * object that owns the root. A provider with no Durable Object reachable falls
1118
+ * back to the Workspace sidecar half, which § Durable effects also allows
1119
+ * ("in the Bot's Durable Object **and** in the Workspace").
1120
+ */
1121
+ export interface WorkspaceSyncEffectsV1 {
1122
+ intent(effect: WorkspaceSyncEffectV1): Promise<void>;
1123
+ settle(effect: WorkspaceSyncEffectV1): Promise<void>;
1124
+ pending(effectId: string): Promise<WorkspaceSyncEffectV1 | undefined>;
1125
+ }
1126
+
1127
+ export function decodeWorkspaceSyncEffectV1(
1128
+ input: unknown,
1129
+ label = "workspace sync effect",
1130
+ ): WorkspaceSyncEffectV1 {
1131
+ const value = record(input, label);
1132
+ exactKeys(
1133
+ value,
1134
+ [
1135
+ "effectId",
1136
+ "root",
1137
+ "path",
1138
+ "kind",
1139
+ "contentHash",
1140
+ "expectedGenerationId",
1141
+ "at",
1142
+ ],
1143
+ label,
1144
+ );
1145
+ if (value.kind !== "push" && value.kind !== "remove") {
1146
+ throw new Error(`${label}.kind is invalid`);
1147
+ }
1148
+ if (
1149
+ typeof value.contentHash !== "string" ||
1150
+ !SHA256_HEX.test(value.contentHash)
1151
+ ) {
1152
+ throw new Error(`${label}.contentHash must be a sha-256 hex digest`);
1153
+ }
1154
+ if (
1155
+ value.expectedGenerationId !== null &&
1156
+ typeof value.expectedGenerationId !== "string"
1157
+ ) {
1158
+ throw new Error(`${label}.expectedGenerationId must be a string or null`);
1159
+ }
1160
+ return {
1161
+ effectId: boundedString(value.effectId, `${label}.effectId`, 256),
1162
+ root: decodeWorkspaceRootV1(value.root, `${label}.root`),
1163
+ path: normalizeWorkspaceRelativePathV1(value.path, `${label}.path`),
1164
+ kind: value.kind,
1165
+ contentHash: value.contentHash,
1166
+ expectedGenerationId:
1167
+ value.expectedGenerationId === null
1168
+ ? null
1169
+ : boundedString(
1170
+ value.expectedGenerationId,
1171
+ `${label}.expectedGenerationId`,
1172
+ 256,
1173
+ ),
1174
+ at: boundedString(value.at, `${label}.at`, 64),
1175
+ };
1176
+ }