@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.
- package/package.json +21 -6
- package/src/authoring.test.ts +143 -0
- package/src/authoring.ts +189 -0
- package/src/index.ts +11 -0
- package/src/isolate.test.ts +417 -0
- package/src/isolate.ts +704 -0
- package/src/model-invocation.ts +74 -0
- package/src/prompt-assembly.ts +54 -0
- package/src/send-to-user.test.ts +234 -0
- package/src/send-to-user.ts +384 -0
- package/src/session.test.ts +708 -0
- package/src/session.ts +521 -0
- package/src/skills.test.ts +123 -0
- package/src/skills.ts +164 -0
- package/src/tool-attachments.test.ts +100 -0
- package/src/tool-execution.ts +220 -0
- package/src/turn-history.test.ts +54 -0
- package/src/turn-history.ts +33 -0
- package/src/turn-type.test.ts +101 -0
- package/src/types.ts +1791 -0
- package/src/workspace.test.ts +913 -0
- package/src/workspace.ts +1176 -0
- package/tsconfig.json +14 -0
- package/README.md +0 -3
package/src/workspace.ts
ADDED
|
@@ -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
|
+
}
|