@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
|
@@ -0,0 +1,913 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
decodeSkillSourceV1,
|
|
4
|
+
decodeWorkspaceEntryV1,
|
|
5
|
+
decodeWorkspaceFailureV1,
|
|
6
|
+
decodeWorkspaceGenerationV1,
|
|
7
|
+
decodeWorkspacePathV1,
|
|
8
|
+
decodeWorkspaceRootV1,
|
|
9
|
+
decodeWorkspaceWriterV1,
|
|
10
|
+
decodeWorkspaceConflictV1,
|
|
11
|
+
decodeWorkspaceGenerationRecordV1,
|
|
12
|
+
isLoadableSkillSourceV1,
|
|
13
|
+
isWorkspaceComputerReadOnlyRootV1,
|
|
14
|
+
isWorkspaceInstructionRootV1,
|
|
15
|
+
isWorkspaceMemoryRootV1,
|
|
16
|
+
isWorkspaceSharedMemoryRootV1,
|
|
17
|
+
memoryShardOwnerV1,
|
|
18
|
+
memoryShardPathV1,
|
|
19
|
+
memoryShardPrefixV1,
|
|
20
|
+
workspaceMemoryShardV1,
|
|
21
|
+
writerOwnsMemoryPathV1,
|
|
22
|
+
normalizeWorkspaceRelativePathV1,
|
|
23
|
+
workspaceMemoryProjectionV1,
|
|
24
|
+
workspaceRootAcceptsKernelWriteV1,
|
|
25
|
+
workspaceRootKeyV1,
|
|
26
|
+
workspaceWriterMayWriteV1,
|
|
27
|
+
WORKSPACE_MAX_PATH_LENGTH,
|
|
28
|
+
WORKSPACE_MAX_PATH_SEGMENTS,
|
|
29
|
+
WORKSPACE_MAX_SEGMENT_LENGTH,
|
|
30
|
+
WORKSPACE_MEMORY_SHARD_PREFIX,
|
|
31
|
+
type SkillSourceV1,
|
|
32
|
+
type WorkspaceMemoryRootV1,
|
|
33
|
+
type WorkspaceFilesV1,
|
|
34
|
+
type WorkspaceGenerationV1,
|
|
35
|
+
type WorkspaceRootV1,
|
|
36
|
+
type WorkspaceWriterV1,
|
|
37
|
+
} from "./workspace.js";
|
|
38
|
+
|
|
39
|
+
const HASH = "a".repeat(64);
|
|
40
|
+
|
|
41
|
+
function generation(
|
|
42
|
+
overrides: Record<string, unknown> = {},
|
|
43
|
+
): Record<string, unknown> {
|
|
44
|
+
return {
|
|
45
|
+
schemaVersion: 1,
|
|
46
|
+
generationId: "2026-08-31T00:00:00.000Z:0000000000000001",
|
|
47
|
+
contentHash: HASH,
|
|
48
|
+
size: 12,
|
|
49
|
+
writer: { kind: "user", userId: "user-1" },
|
|
50
|
+
writtenAt: "2026-08-31T00:00:00.000Z",
|
|
51
|
+
...overrides,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function instructionRoot(): WorkspaceRootV1 {
|
|
56
|
+
return { kind: "bot-instructions", userId: "user-1", botId: "bot-1" };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function userInstructionRoot(userId = "user-1"): WorkspaceRootV1 {
|
|
60
|
+
return { kind: "user-instructions", userId };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function skillSource(
|
|
64
|
+
root: WorkspaceRootV1,
|
|
65
|
+
writer: WorkspaceWriterV1,
|
|
66
|
+
): SkillSourceV1 {
|
|
67
|
+
return {
|
|
68
|
+
path: { root, path: "skills/deploy.md" },
|
|
69
|
+
writer,
|
|
70
|
+
generation: decodeWorkspaceGenerationV1(generation({ writer })),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
describe("durable roots", () => {
|
|
75
|
+
test("decodes each kind with its owner and rejects everything else", () => {
|
|
76
|
+
expect(decodeWorkspaceRootV1(instructionRoot())).toEqual(instructionRoot());
|
|
77
|
+
expect(
|
|
78
|
+
decodeWorkspaceRootV1({ kind: "user-memory", userId: "user-1" }),
|
|
79
|
+
).toEqual({ kind: "user-memory", userId: "user-1" });
|
|
80
|
+
expect(
|
|
81
|
+
decodeWorkspaceRootV1({
|
|
82
|
+
kind: "package-declared",
|
|
83
|
+
userId: "user-1",
|
|
84
|
+
packageId: "notes",
|
|
85
|
+
rootId: "notes-archive",
|
|
86
|
+
}).kind,
|
|
87
|
+
).toBe("package-declared");
|
|
88
|
+
|
|
89
|
+
for (const invalid of [
|
|
90
|
+
null,
|
|
91
|
+
[],
|
|
92
|
+
{ kind: "unknown", userId: "user-1" },
|
|
93
|
+
// a per-Bot kind without its Bot
|
|
94
|
+
{ kind: "bot-instructions", userId: "user-1" },
|
|
95
|
+
// a User-scoped kind carrying a Bot
|
|
96
|
+
{ kind: "user-memory", userId: "user-1", botId: "bot-1" },
|
|
97
|
+
{ kind: "bot-memory", userId: "", botId: "bot-1" },
|
|
98
|
+
{ kind: "package-declared", userId: "user-1", packageId: "notes" },
|
|
99
|
+
{
|
|
100
|
+
kind: "package-declared",
|
|
101
|
+
userId: "user-1",
|
|
102
|
+
packageId: "notes",
|
|
103
|
+
rootId: "../escape",
|
|
104
|
+
},
|
|
105
|
+
]) {
|
|
106
|
+
expect(() => decodeWorkspaceRootV1(invalid)).toThrow();
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("keys are stable and never collide across kinds or owners", () => {
|
|
111
|
+
const keys = [
|
|
112
|
+
workspaceRootKeyV1(instructionRoot()),
|
|
113
|
+
workspaceRootKeyV1({
|
|
114
|
+
kind: "bot-memory",
|
|
115
|
+
userId: "user-1",
|
|
116
|
+
botId: "bot-1",
|
|
117
|
+
}),
|
|
118
|
+
workspaceRootKeyV1({ kind: "user-memory", userId: "user-1" }),
|
|
119
|
+
workspaceRootKeyV1(userInstructionRoot()),
|
|
120
|
+
workspaceRootKeyV1(userInstructionRoot("user-2")),
|
|
121
|
+
workspaceRootKeyV1({
|
|
122
|
+
kind: "user-memory",
|
|
123
|
+
userId: "user-1:bot-1",
|
|
124
|
+
}),
|
|
125
|
+
workspaceRootKeyV1({
|
|
126
|
+
kind: "package-declared",
|
|
127
|
+
userId: "user-1",
|
|
128
|
+
packageId: "notes",
|
|
129
|
+
rootId: "archive",
|
|
130
|
+
}),
|
|
131
|
+
];
|
|
132
|
+
expect(new Set(keys).size).toBe(keys.length);
|
|
133
|
+
expect(workspaceRootKeyV1(instructionRoot())).toBe(
|
|
134
|
+
workspaceRootKeyV1(instructionRoot()),
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("the User-global instruction root round-trips through its key and its decoder", () => {
|
|
139
|
+
// ADR 0016 names the root `users/<id>/skills/`, and the object store keys
|
|
140
|
+
// every file under `workspace/<root key>/`, so the key is that location.
|
|
141
|
+
expect(workspaceRootKeyV1(userInstructionRoot())).toBe(
|
|
142
|
+
"users/user-1/skills",
|
|
143
|
+
);
|
|
144
|
+
expect(workspaceRootKeyV1(userInstructionRoot("owner/one"))).toBe(
|
|
145
|
+
"users/owner%2Fone/skills",
|
|
146
|
+
);
|
|
147
|
+
const decoded = decodeWorkspaceRootV1({
|
|
148
|
+
kind: "user-instructions",
|
|
149
|
+
userId: "user-1",
|
|
150
|
+
});
|
|
151
|
+
expect(decoded).toEqual(userInstructionRoot());
|
|
152
|
+
expect(workspaceRootKeyV1(decoded)).toBe(
|
|
153
|
+
workspaceRootKeyV1(userInstructionRoot()),
|
|
154
|
+
);
|
|
155
|
+
// A Bot id would name a root this kind does not have.
|
|
156
|
+
expect(() =>
|
|
157
|
+
decodeWorkspaceRootV1({
|
|
158
|
+
kind: "user-instructions",
|
|
159
|
+
userId: "user-1",
|
|
160
|
+
botId: "bot-1",
|
|
161
|
+
}),
|
|
162
|
+
).toThrow();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("both instruction roots are instruction roots; only the User-global one is read-only on the Computer", () => {
|
|
166
|
+
expect(isWorkspaceInstructionRootV1(instructionRoot())).toBe(true);
|
|
167
|
+
expect(isWorkspaceInstructionRootV1(userInstructionRoot())).toBe(true);
|
|
168
|
+
expect(
|
|
169
|
+
isWorkspaceInstructionRootV1({
|
|
170
|
+
kind: "user-memory",
|
|
171
|
+
userId: "user-1",
|
|
172
|
+
}),
|
|
173
|
+
).toBe(false);
|
|
174
|
+
// ADR 0013 for Memory, ADR 0016 for the User-global instruction root: both
|
|
175
|
+
// have a single writer over object storage, so the Computer presents them
|
|
176
|
+
// read-only and the sync never pushes out of them.
|
|
177
|
+
expect(isWorkspaceComputerReadOnlyRootV1(userInstructionRoot())).toBe(true);
|
|
178
|
+
expect(
|
|
179
|
+
isWorkspaceComputerReadOnlyRootV1({
|
|
180
|
+
kind: "user-memory",
|
|
181
|
+
userId: "user-1",
|
|
182
|
+
}),
|
|
183
|
+
).toBe(true);
|
|
184
|
+
expect(isWorkspaceComputerReadOnlyRootV1(instructionRoot())).toBe(false);
|
|
185
|
+
// The kernel-consumed surface still writes it: read-only is a statement
|
|
186
|
+
// about the Computer, not about the Skills Package.
|
|
187
|
+
expect(workspaceRootAcceptsKernelWriteV1(userInstructionRoot())).toBe(true);
|
|
188
|
+
expect(isWorkspaceMemoryRootV1(userInstructionRoot())).toBe(false);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe("workspace paths", () => {
|
|
193
|
+
test("accepts normalized relative POSIX paths", () => {
|
|
194
|
+
for (const path of [
|
|
195
|
+
"notes.md",
|
|
196
|
+
"skills/deploy.md",
|
|
197
|
+
"a/b/c/d.txt",
|
|
198
|
+
"spaced name.md",
|
|
199
|
+
"..hidden/..leading.md",
|
|
200
|
+
]) {
|
|
201
|
+
expect(normalizeWorkspaceRelativePathV1(path)).toBe(path);
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("rejects traversal, absolute paths, and every other escape", () => {
|
|
206
|
+
for (const path of [
|
|
207
|
+
"",
|
|
208
|
+
"/etc/passwd",
|
|
209
|
+
"..",
|
|
210
|
+
"../secret",
|
|
211
|
+
"skills/../../etc/passwd",
|
|
212
|
+
"skills/./deploy.md",
|
|
213
|
+
"skills//deploy.md",
|
|
214
|
+
"skills/",
|
|
215
|
+
"windows\\path.md",
|
|
216
|
+
"nul\u0000byte.md",
|
|
217
|
+
"line\nbreak.md",
|
|
218
|
+
"bell\u0007.md",
|
|
219
|
+
"delete\u007f.md",
|
|
220
|
+
" leading.md",
|
|
221
|
+
"trailing.md ",
|
|
222
|
+
"dir/ padded.md",
|
|
223
|
+
"a".repeat(WORKSPACE_MAX_SEGMENT_LENGTH + 1),
|
|
224
|
+
"a".repeat(WORKSPACE_MAX_PATH_LENGTH + 1),
|
|
225
|
+
Array.from({ length: WORKSPACE_MAX_PATH_SEGMENTS + 1 }, () => "a").join(
|
|
226
|
+
"/",
|
|
227
|
+
),
|
|
228
|
+
123,
|
|
229
|
+
null,
|
|
230
|
+
undefined,
|
|
231
|
+
]) {
|
|
232
|
+
expect(() => normalizeWorkspaceRelativePathV1(path)).toThrow();
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("rejects the segments the object-storage key scheme and the sync reserve", () => {
|
|
237
|
+
// `workspace/<root>/<relative>.conflict/<generationId>` preserves a losing
|
|
238
|
+
// write, so a file may not occupy a `.conflict` segment: `notes.conflict`
|
|
239
|
+
// would shadow its own conflicts, and `notes.conflict/a.md` would be read
|
|
240
|
+
// as one. `.frockbot-generations` and `.frockbot-sync` belong to the
|
|
241
|
+
// Computer-side sync agent.
|
|
242
|
+
for (const path of [
|
|
243
|
+
"notes.conflict",
|
|
244
|
+
"notes.conflict/a.md",
|
|
245
|
+
"notes.conflict/0000-0001",
|
|
246
|
+
".conflict",
|
|
247
|
+
"skills/deploy.conflict/SKILL.md",
|
|
248
|
+
".frockbot-generations",
|
|
249
|
+
".frockbot-generations/notes.md",
|
|
250
|
+
"skills/.frockbot-sync/tombstones/notes.md",
|
|
251
|
+
]) {
|
|
252
|
+
expect(() => normalizeWorkspaceRelativePathV1(path)).toThrow();
|
|
253
|
+
}
|
|
254
|
+
// Nothing else that merely mentions the word is refused.
|
|
255
|
+
expect(normalizeWorkspaceRelativePathV1("conflict/notes.md")).toBe(
|
|
256
|
+
"conflict/notes.md",
|
|
257
|
+
);
|
|
258
|
+
expect(normalizeWorkspaceRelativePathV1("notes.conflicted.md")).toBe(
|
|
259
|
+
"notes.conflicted.md",
|
|
260
|
+
);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("a decoded path carries its root and rejects extra fields", () => {
|
|
264
|
+
expect(
|
|
265
|
+
decodeWorkspacePathV1({ root: instructionRoot(), path: "skills/a.md" }),
|
|
266
|
+
).toEqual({ root: instructionRoot(), path: "skills/a.md" });
|
|
267
|
+
expect(() =>
|
|
268
|
+
decodeWorkspacePathV1({
|
|
269
|
+
root: instructionRoot(),
|
|
270
|
+
path: "skills/a.md",
|
|
271
|
+
absolute: "/home/box/skills/a.md",
|
|
272
|
+
}),
|
|
273
|
+
).toThrow();
|
|
274
|
+
expect(() =>
|
|
275
|
+
decodeWorkspacePathV1({ root: instructionRoot(), path: "../a.md" }),
|
|
276
|
+
).toThrow();
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
describe("writer provenance and generations", () => {
|
|
281
|
+
test("decodes an unattributed writer, which records that nobody was recorded", () => {
|
|
282
|
+
expect(decodeWorkspaceWriterV1({ kind: "unattributed" })).toEqual({
|
|
283
|
+
kind: "unattributed",
|
|
284
|
+
});
|
|
285
|
+
for (const invalid of [
|
|
286
|
+
{ kind: "unattributed", userId: "user-1" },
|
|
287
|
+
{ kind: "unattributed", packageId: "memory" },
|
|
288
|
+
]) {
|
|
289
|
+
expect(() => decodeWorkspaceWriterV1(invalid)).toThrow();
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("decodes the three provenance kinds and nothing else", () => {
|
|
294
|
+
expect(
|
|
295
|
+
decodeWorkspaceWriterV1({ kind: "first-party", packageId: "memory" }),
|
|
296
|
+
).toEqual({ kind: "first-party", packageId: "memory" });
|
|
297
|
+
expect(decodeWorkspaceWriterV1({ kind: "user", userId: "user-1" })).toEqual(
|
|
298
|
+
{
|
|
299
|
+
kind: "user",
|
|
300
|
+
userId: "user-1",
|
|
301
|
+
},
|
|
302
|
+
);
|
|
303
|
+
expect(
|
|
304
|
+
decodeWorkspaceWriterV1({
|
|
305
|
+
kind: "bot",
|
|
306
|
+
botId: "bot-1",
|
|
307
|
+
sessionId: "session-1",
|
|
308
|
+
turnId: "turn-1",
|
|
309
|
+
runId: "run-1",
|
|
310
|
+
}).kind,
|
|
311
|
+
).toBe("bot");
|
|
312
|
+
|
|
313
|
+
for (const invalid of [
|
|
314
|
+
{ kind: "anonymous" },
|
|
315
|
+
{ kind: "user" },
|
|
316
|
+
{ kind: "bot", botId: "bot-1" },
|
|
317
|
+
{ kind: "user", userId: "user-1", botId: "bot-1" },
|
|
318
|
+
]) {
|
|
319
|
+
expect(() => decodeWorkspaceWriterV1(invalid)).toThrow();
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("a generation records its writer, its minted id, and its content address", () => {
|
|
324
|
+
const decoded = decodeWorkspaceGenerationV1(generation());
|
|
325
|
+
expect(decoded.writer).toEqual({ kind: "user", userId: "user-1" });
|
|
326
|
+
expect(decoded.contentHash).toBe(HASH);
|
|
327
|
+
expect(decoded.conflictsWith).toBeUndefined();
|
|
328
|
+
expect(
|
|
329
|
+
decodeWorkspaceGenerationV1(generation({ conflictsWith: "gen-0" }))
|
|
330
|
+
.conflictsWith,
|
|
331
|
+
).toBe("gen-0");
|
|
332
|
+
|
|
333
|
+
for (const invalid of [
|
|
334
|
+
generation({ schemaVersion: 2 }),
|
|
335
|
+
generation({ contentHash: "not-a-hash" }),
|
|
336
|
+
generation({ size: -1 }),
|
|
337
|
+
generation({ size: 1.5 }),
|
|
338
|
+
generation({ size: Number.MAX_SAFE_INTEGER }),
|
|
339
|
+
generation({ writer: { kind: "anonymous" } }),
|
|
340
|
+
{ ...generation(), unexpected: true },
|
|
341
|
+
]) {
|
|
342
|
+
expect(() => decodeWorkspaceGenerationV1(invalid)).toThrow();
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test("an entry pairs a validated path with its generation", () => {
|
|
347
|
+
const entry = decodeWorkspaceEntryV1({
|
|
348
|
+
path: { root: instructionRoot(), path: "skills/a.md" },
|
|
349
|
+
generation: generation(),
|
|
350
|
+
});
|
|
351
|
+
expect(entry.path.path).toBe("skills/a.md");
|
|
352
|
+
expect(entry.generation.generationId).toBe(
|
|
353
|
+
"2026-08-31T00:00:00.000Z:0000000000000001",
|
|
354
|
+
);
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
describe("declared failure variants", () => {
|
|
359
|
+
test("decodes each status and refuses an undeclared one", () => {
|
|
360
|
+
for (const status of [
|
|
361
|
+
"not-found",
|
|
362
|
+
"refused",
|
|
363
|
+
"conflict",
|
|
364
|
+
"unavailable",
|
|
365
|
+
] as const) {
|
|
366
|
+
expect(decodeWorkspaceFailureV1({ status, reason: "why" })).toEqual({
|
|
367
|
+
status,
|
|
368
|
+
reason: "why",
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
expect(() =>
|
|
372
|
+
decodeWorkspaceFailureV1({ status: "ok", reason: "why" }),
|
|
373
|
+
).toThrow();
|
|
374
|
+
expect(() => decodeWorkspaceFailureV1({ status: "refused" })).toThrow();
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
describe("Memory roots are single-writer", () => {
|
|
379
|
+
test("no Memory root accepts a write through the kernel-consumed interface", () => {
|
|
380
|
+
expect(
|
|
381
|
+
workspaceRootAcceptsKernelWriteV1({
|
|
382
|
+
kind: "bot-memory",
|
|
383
|
+
userId: "user-1",
|
|
384
|
+
botId: "bot-1",
|
|
385
|
+
}),
|
|
386
|
+
).toBe(false);
|
|
387
|
+
expect(
|
|
388
|
+
workspaceRootAcceptsKernelWriteV1({
|
|
389
|
+
kind: "user-memory",
|
|
390
|
+
userId: "user-1",
|
|
391
|
+
}),
|
|
392
|
+
).toBe(false);
|
|
393
|
+
expect(workspaceRootAcceptsKernelWriteV1(instructionRoot())).toBe(true);
|
|
394
|
+
expect(
|
|
395
|
+
workspaceRootAcceptsKernelWriteV1({
|
|
396
|
+
kind: "package-declared",
|
|
397
|
+
userId: "user-1",
|
|
398
|
+
packageId: "notes",
|
|
399
|
+
rootId: "archive",
|
|
400
|
+
}),
|
|
401
|
+
).toBe(true);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
test("no write may name an unattributed writer, whatever the root", () => {
|
|
405
|
+
const unattributed: WorkspaceWriterV1 = { kind: "unattributed" };
|
|
406
|
+
expect(workspaceWriterMayWriteV1(unattributed)).toBe(false);
|
|
407
|
+
expect(workspaceWriterMayWriteV1({ kind: "user", userId: "user-1" })).toBe(
|
|
408
|
+
true,
|
|
409
|
+
);
|
|
410
|
+
expect(
|
|
411
|
+
workspaceRootAcceptsKernelWriteV1(instructionRoot(), unattributed),
|
|
412
|
+
).toBe(false);
|
|
413
|
+
expect(
|
|
414
|
+
workspaceRootAcceptsKernelWriteV1(
|
|
415
|
+
{
|
|
416
|
+
kind: "package-declared",
|
|
417
|
+
userId: "user-1",
|
|
418
|
+
packageId: "notes",
|
|
419
|
+
rootId: "archive",
|
|
420
|
+
},
|
|
421
|
+
unattributed,
|
|
422
|
+
),
|
|
423
|
+
).toBe(false);
|
|
424
|
+
expect(
|
|
425
|
+
workspaceRootAcceptsKernelWriteV1(instructionRoot(), {
|
|
426
|
+
kind: "user",
|
|
427
|
+
userId: "user-1",
|
|
428
|
+
}),
|
|
429
|
+
).toBe(true);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test("the Memory projection of a full file interface exposes no write path", () => {
|
|
433
|
+
const calls: string[] = [];
|
|
434
|
+
const files: WorkspaceFilesV1 = {
|
|
435
|
+
read: async (path) => {
|
|
436
|
+
calls.push(`read:${path.path}`);
|
|
437
|
+
return { status: "not-found", reason: "absent" };
|
|
438
|
+
},
|
|
439
|
+
list: async () => ({ status: "ok", entries: [] }),
|
|
440
|
+
stat: async () => ({ status: "not-found", reason: "absent" }),
|
|
441
|
+
write: async () => {
|
|
442
|
+
calls.push("write");
|
|
443
|
+
return {
|
|
444
|
+
status: "ok",
|
|
445
|
+
generation: decodeWorkspaceGenerationV1(
|
|
446
|
+
generation(),
|
|
447
|
+
) satisfies WorkspaceGenerationV1,
|
|
448
|
+
};
|
|
449
|
+
},
|
|
450
|
+
delete: async () => {
|
|
451
|
+
calls.push("delete");
|
|
452
|
+
return {
|
|
453
|
+
status: "ok",
|
|
454
|
+
generation: decodeWorkspaceGenerationV1(generation()),
|
|
455
|
+
};
|
|
456
|
+
},
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
const projection = workspaceMemoryProjectionV1(files);
|
|
460
|
+
|
|
461
|
+
expect(Object.keys(projection).sort()).toEqual(["list", "read", "stat"]);
|
|
462
|
+
expect("write" in projection).toBe(false);
|
|
463
|
+
expect("delete" in projection).toBe(false);
|
|
464
|
+
expect(
|
|
465
|
+
(projection as unknown as Record<string, unknown>).write,
|
|
466
|
+
).toBeUndefined();
|
|
467
|
+
expect(calls).toEqual([]);
|
|
468
|
+
});
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
describe("Skill sources", () => {
|
|
472
|
+
const bot: WorkspaceWriterV1 = {
|
|
473
|
+
kind: "bot",
|
|
474
|
+
botId: "bot-1",
|
|
475
|
+
sessionId: "session-1",
|
|
476
|
+
turnId: "turn-1",
|
|
477
|
+
runId: "run-1",
|
|
478
|
+
};
|
|
479
|
+
const owner = { botId: "bot-1", userId: "user-1" };
|
|
480
|
+
|
|
481
|
+
test("a Skill under the Bot's own instruction root, written by the Bot or its User, is loadable", () => {
|
|
482
|
+
expect(
|
|
483
|
+
isLoadableSkillSourceV1(skillSource(instructionRoot(), bot), owner),
|
|
484
|
+
).toBe(true);
|
|
485
|
+
expect(
|
|
486
|
+
isLoadableSkillSourceV1(
|
|
487
|
+
skillSource(instructionRoot(), { kind: "user", userId: "user-1" }),
|
|
488
|
+
owner,
|
|
489
|
+
),
|
|
490
|
+
).toBe(true);
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
test("no other root kind is ever a Skill source", () => {
|
|
494
|
+
for (const root of [
|
|
495
|
+
{ kind: "bot-memory", userId: "user-1", botId: "bot-1" },
|
|
496
|
+
{ kind: "user-memory", userId: "user-1" },
|
|
497
|
+
{
|
|
498
|
+
kind: "package-declared",
|
|
499
|
+
userId: "user-1",
|
|
500
|
+
packageId: "notes",
|
|
501
|
+
rootId: "archive",
|
|
502
|
+
},
|
|
503
|
+
] satisfies WorkspaceRootV1[]) {
|
|
504
|
+
expect(isLoadableSkillSourceV1(skillSource(root, bot), owner)).toBe(
|
|
505
|
+
false,
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
test("another Bot's instruction root, or another User's, is not a Skill source", () => {
|
|
511
|
+
expect(
|
|
512
|
+
isLoadableSkillSourceV1(
|
|
513
|
+
skillSource(
|
|
514
|
+
{ kind: "bot-instructions", userId: "user-1", botId: "bot-2" },
|
|
515
|
+
bot,
|
|
516
|
+
),
|
|
517
|
+
owner,
|
|
518
|
+
),
|
|
519
|
+
).toBe(false);
|
|
520
|
+
expect(
|
|
521
|
+
isLoadableSkillSourceV1(
|
|
522
|
+
skillSource(
|
|
523
|
+
{ kind: "bot-instructions", userId: "user-2", botId: "bot-1" },
|
|
524
|
+
bot,
|
|
525
|
+
),
|
|
526
|
+
owner,
|
|
527
|
+
),
|
|
528
|
+
).toBe(false);
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
test("a writer that is neither the Bot nor its User is refused", () => {
|
|
532
|
+
for (const writer of [
|
|
533
|
+
{ kind: "first-party", packageId: "memory" },
|
|
534
|
+
{ kind: "user", userId: "user-2" },
|
|
535
|
+
{ ...bot, botId: "bot-2" },
|
|
536
|
+
] satisfies WorkspaceWriterV1[]) {
|
|
537
|
+
expect(
|
|
538
|
+
isLoadableSkillSourceV1(skillSource(instructionRoot(), writer), owner),
|
|
539
|
+
).toBe(false);
|
|
540
|
+
}
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
test("a file with no recorded writer is never a Skill source", () => {
|
|
544
|
+
// A shell command on the Computer can drop a SKILL.md into any root it can
|
|
545
|
+
// reach. Nothing recorded who wrote it, so it is data, never an
|
|
546
|
+
// instruction — even under the Bot's own instruction root.
|
|
547
|
+
expect(
|
|
548
|
+
isLoadableSkillSourceV1(
|
|
549
|
+
skillSource(instructionRoot(), { kind: "unattributed" }),
|
|
550
|
+
owner,
|
|
551
|
+
),
|
|
552
|
+
).toBe(false);
|
|
553
|
+
expect(
|
|
554
|
+
isLoadableSkillSourceV1(
|
|
555
|
+
decodeSkillSourceV1({
|
|
556
|
+
path: { root: instructionRoot(), path: "skills/dropped/SKILL.md" },
|
|
557
|
+
writer: { kind: "unattributed" },
|
|
558
|
+
generation: generation({ writer: { kind: "unattributed" } }),
|
|
559
|
+
}),
|
|
560
|
+
owner,
|
|
561
|
+
),
|
|
562
|
+
).toBe(false);
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
test("a Skill in the User-global root written by any of the User's Bots is loadable", () => {
|
|
566
|
+
// The whole point of the shared tier: Bot A authors, Bot B follows. ADR
|
|
567
|
+
// 0016 — authority does not widen, because a Bot writing there writes
|
|
568
|
+
// under authority it already holds.
|
|
569
|
+
expect(
|
|
570
|
+
isLoadableSkillSourceV1(
|
|
571
|
+
skillSource(userInstructionRoot(), { ...bot, botId: "bot-2" }),
|
|
572
|
+
owner,
|
|
573
|
+
),
|
|
574
|
+
).toBe(true);
|
|
575
|
+
expect(
|
|
576
|
+
isLoadableSkillSourceV1(skillSource(userInstructionRoot(), bot), owner),
|
|
577
|
+
).toBe(true);
|
|
578
|
+
expect(
|
|
579
|
+
isLoadableSkillSourceV1(
|
|
580
|
+
skillSource(userInstructionRoot(), {
|
|
581
|
+
kind: "user",
|
|
582
|
+
userId: "user-1",
|
|
583
|
+
}),
|
|
584
|
+
owner,
|
|
585
|
+
),
|
|
586
|
+
).toBe(true);
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
test("another User's global root, and an unattributed or first-party writer in this User's, are refused", () => {
|
|
590
|
+
expect(
|
|
591
|
+
isLoadableSkillSourceV1(
|
|
592
|
+
skillSource(userInstructionRoot("user-2"), bot),
|
|
593
|
+
owner,
|
|
594
|
+
),
|
|
595
|
+
).toBe(false);
|
|
596
|
+
expect(
|
|
597
|
+
isLoadableSkillSourceV1(
|
|
598
|
+
skillSource(userInstructionRoot(), { kind: "user", userId: "user-2" }),
|
|
599
|
+
owner,
|
|
600
|
+
),
|
|
601
|
+
).toBe(false);
|
|
602
|
+
for (const writer of [
|
|
603
|
+
{ kind: "unattributed" },
|
|
604
|
+
{ kind: "first-party", packageId: "skills" },
|
|
605
|
+
] satisfies WorkspaceWriterV1[]) {
|
|
606
|
+
expect(
|
|
607
|
+
isLoadableSkillSourceV1(
|
|
608
|
+
skillSource(userInstructionRoot(), writer),
|
|
609
|
+
owner,
|
|
610
|
+
),
|
|
611
|
+
).toBe(false);
|
|
612
|
+
}
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
test("a decoded Skill source keeps its recorded writer", () => {
|
|
616
|
+
const decoded = decodeSkillSourceV1({
|
|
617
|
+
path: { root: instructionRoot(), path: "skills/deploy.md" },
|
|
618
|
+
writer: bot,
|
|
619
|
+
generation: generation({ writer: bot }),
|
|
620
|
+
});
|
|
621
|
+
expect(decoded.writer).toEqual(bot);
|
|
622
|
+
expect(isLoadableSkillSourceV1(decoded, owner)).toBe(true);
|
|
623
|
+
expect(() =>
|
|
624
|
+
decodeSkillSourceV1({
|
|
625
|
+
path: { root: instructionRoot(), path: "../deploy.md" },
|
|
626
|
+
writer: bot,
|
|
627
|
+
generation: generation({ writer: bot }),
|
|
628
|
+
}),
|
|
629
|
+
).toThrow();
|
|
630
|
+
});
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
describe("the three Memory tiers", () => {
|
|
634
|
+
const botRoot: WorkspaceMemoryRootV1 = {
|
|
635
|
+
kind: "bot-memory",
|
|
636
|
+
userId: "user-1",
|
|
637
|
+
botId: "bot-1",
|
|
638
|
+
};
|
|
639
|
+
const userRoot: WorkspaceMemoryRootV1 = {
|
|
640
|
+
kind: "user-memory",
|
|
641
|
+
userId: "user-1",
|
|
642
|
+
};
|
|
643
|
+
const projectRoot: WorkspaceMemoryRootV1 = {
|
|
644
|
+
kind: "project-memory",
|
|
645
|
+
userId: "user-1",
|
|
646
|
+
projectId: "school-run",
|
|
647
|
+
};
|
|
648
|
+
|
|
649
|
+
test("decodes a Project Memory root and bounds its Project id", () => {
|
|
650
|
+
expect(decodeWorkspaceRootV1(projectRoot)).toEqual(projectRoot);
|
|
651
|
+
expect(() =>
|
|
652
|
+
decodeWorkspaceRootV1({ kind: "project-memory", userId: "user-1" }),
|
|
653
|
+
).toThrow();
|
|
654
|
+
expect(() =>
|
|
655
|
+
decodeWorkspaceRootV1({ ...projectRoot, projectId: "Not A Slug" }),
|
|
656
|
+
).toThrow();
|
|
657
|
+
expect(() =>
|
|
658
|
+
decodeWorkspaceRootV1({ ...projectRoot, projectId: "a".repeat(129) }),
|
|
659
|
+
).toThrow();
|
|
660
|
+
expect(() =>
|
|
661
|
+
decodeWorkspaceRootV1({ ...projectRoot, botId: "bot-1" }),
|
|
662
|
+
).toThrow();
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
test("all three kinds are Memory roots, and only two are shared", () => {
|
|
666
|
+
expect(
|
|
667
|
+
[botRoot, userRoot, projectRoot].map(isWorkspaceMemoryRootV1),
|
|
668
|
+
).toEqual([true, true, true]);
|
|
669
|
+
expect(isWorkspaceMemoryRootV1(instructionRoot())).toBe(false);
|
|
670
|
+
expect(
|
|
671
|
+
[botRoot, userRoot, projectRoot].map(isWorkspaceSharedMemoryRootV1),
|
|
672
|
+
).toEqual([false, true, true]);
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
test("no Memory root accepts a kernel write, Project Memory included", () => {
|
|
676
|
+
expect(workspaceRootAcceptsKernelWriteV1(projectRoot)).toBe(false);
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
test("a Project Memory root key never collides with another root", () => {
|
|
680
|
+
expect(workspaceRootKeyV1(projectRoot)).toBe(
|
|
681
|
+
"project-memory:user-1:school-run",
|
|
682
|
+
);
|
|
683
|
+
const keys = [
|
|
684
|
+
workspaceRootKeyV1(botRoot),
|
|
685
|
+
workspaceRootKeyV1(userRoot),
|
|
686
|
+
workspaceRootKeyV1(projectRoot),
|
|
687
|
+
workspaceRootKeyV1(instructionRoot()),
|
|
688
|
+
];
|
|
689
|
+
expect(new Set(keys).size).toBe(keys.length);
|
|
690
|
+
});
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
describe("shared Memory tiers are sharded per writing Bot", () => {
|
|
694
|
+
const userRoot: WorkspaceMemoryRootV1 = {
|
|
695
|
+
kind: "user-memory",
|
|
696
|
+
userId: "user-1",
|
|
697
|
+
};
|
|
698
|
+
const projectRoot: WorkspaceMemoryRootV1 = {
|
|
699
|
+
kind: "project-memory",
|
|
700
|
+
userId: "user-1",
|
|
701
|
+
projectId: "school-run",
|
|
702
|
+
};
|
|
703
|
+
const botRoot: WorkspaceMemoryRootV1 = {
|
|
704
|
+
kind: "bot-memory",
|
|
705
|
+
userId: "user-1",
|
|
706
|
+
botId: "bot-1",
|
|
707
|
+
};
|
|
708
|
+
|
|
709
|
+
test("a shared root places a Bot's files under its own shard", () => {
|
|
710
|
+
expect(memoryShardPrefixV1(userRoot, "bot-1")).toBe(
|
|
711
|
+
`${WORKSPACE_MEMORY_SHARD_PREFIX}/bot-1/`,
|
|
712
|
+
);
|
|
713
|
+
expect(memoryShardPathV1(userRoot, "bot-1", "profile.md")).toEqual({
|
|
714
|
+
root: userRoot,
|
|
715
|
+
path: "by-agent/bot-1/profile.md",
|
|
716
|
+
});
|
|
717
|
+
expect(memoryShardPathV1(projectRoot, "bot-2", "log/2026-08.md")).toEqual({
|
|
718
|
+
root: projectRoot,
|
|
719
|
+
path: "by-agent/bot-2/log/2026-08.md",
|
|
720
|
+
});
|
|
721
|
+
expect(workspaceMemoryShardV1(projectRoot, "bot-2")).toEqual({
|
|
722
|
+
root: projectRoot,
|
|
723
|
+
botId: "bot-2",
|
|
724
|
+
prefix: "by-agent/bot-2/",
|
|
725
|
+
});
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
test("a Bot Memory root has one writer already, so it has no shard prefix", () => {
|
|
729
|
+
expect(memoryShardPrefixV1(botRoot, "bot-1")).toBe("");
|
|
730
|
+
expect(memoryShardPathV1(botRoot, "bot-1", "profile.md")).toEqual({
|
|
731
|
+
root: botRoot,
|
|
732
|
+
path: "profile.md",
|
|
733
|
+
});
|
|
734
|
+
expect(workspaceMemoryShardV1(botRoot, "bot-1").prefix).toBe("");
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
test("a shard path refuses an escaping relative path or Bot id", () => {
|
|
738
|
+
expect(() =>
|
|
739
|
+
memoryShardPathV1(userRoot, "bot-1", "../escape.md"),
|
|
740
|
+
).toThrow();
|
|
741
|
+
expect(() =>
|
|
742
|
+
memoryShardPathV1(userRoot, "bot-1", "/absolute.md"),
|
|
743
|
+
).toThrow();
|
|
744
|
+
expect(() => memoryShardPathV1(userRoot, "a/b", "profile.md")).toThrow();
|
|
745
|
+
expect(() => memoryShardPathV1(userRoot, "..", "profile.md")).toThrow();
|
|
746
|
+
expect(() => memoryShardPathV1(userRoot, "", "profile.md")).toThrow();
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
test("the shard owner is read back off the path", () => {
|
|
750
|
+
expect(
|
|
751
|
+
memoryShardOwnerV1(memoryShardPathV1(userRoot, "bot-9", "profile.md")),
|
|
752
|
+
).toBe("bot-9");
|
|
753
|
+
expect(memoryShardOwnerV1({ root: userRoot, path: "profile.md" })).toBe(
|
|
754
|
+
undefined,
|
|
755
|
+
);
|
|
756
|
+
expect(memoryShardOwnerV1({ root: botRoot, path: "profile.md" })).toBe(
|
|
757
|
+
"bot-1",
|
|
758
|
+
);
|
|
759
|
+
expect(
|
|
760
|
+
memoryShardOwnerV1({ root: instructionRoot(), path: "a/SKILL.md" }),
|
|
761
|
+
).toBe(undefined);
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
test("a Bot writer owns only its own shard", () => {
|
|
765
|
+
const bot = (botId: string): WorkspaceWriterV1 => ({
|
|
766
|
+
kind: "bot",
|
|
767
|
+
botId,
|
|
768
|
+
sessionId: "user-1:bot",
|
|
769
|
+
turnId: "turn-1",
|
|
770
|
+
runId: "run-1",
|
|
771
|
+
});
|
|
772
|
+
const own = memoryShardPathV1(userRoot, "bot-1", "profile.md");
|
|
773
|
+
expect(writerOwnsMemoryPathV1(own, bot("bot-1"))).toBe(true);
|
|
774
|
+
expect(writerOwnsMemoryPathV1(own, bot("bot-2"))).toBe(false);
|
|
775
|
+
// An unsharded file in a shared root belongs to no Bot's shard.
|
|
776
|
+
expect(
|
|
777
|
+
writerOwnsMemoryPathV1(
|
|
778
|
+
{ root: userRoot, path: "profile.md" },
|
|
779
|
+
bot("bot-1"),
|
|
780
|
+
),
|
|
781
|
+
).toBe(false);
|
|
782
|
+
// A Bot Memory root is its own Bot's shard, and no other Bot's.
|
|
783
|
+
expect(
|
|
784
|
+
writerOwnsMemoryPathV1(
|
|
785
|
+
{ root: botRoot, path: "profile.md" },
|
|
786
|
+
bot("bot-1"),
|
|
787
|
+
),
|
|
788
|
+
).toBe(true);
|
|
789
|
+
expect(
|
|
790
|
+
writerOwnsMemoryPathV1(
|
|
791
|
+
{ root: botRoot, path: "profile.md" },
|
|
792
|
+
bot("bot-2"),
|
|
793
|
+
),
|
|
794
|
+
).toBe(false);
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
test("a User owns every shard of their own root; a Package owns none", () => {
|
|
798
|
+
const user: WorkspaceWriterV1 = { kind: "user", userId: "user-1" };
|
|
799
|
+
const other: WorkspaceWriterV1 = { kind: "user", userId: "user-2" };
|
|
800
|
+
expect(
|
|
801
|
+
writerOwnsMemoryPathV1(
|
|
802
|
+
memoryShardPathV1(projectRoot, "bot-7", "profile.md"),
|
|
803
|
+
user,
|
|
804
|
+
),
|
|
805
|
+
).toBe(true);
|
|
806
|
+
expect(
|
|
807
|
+
writerOwnsMemoryPathV1(
|
|
808
|
+
memoryShardPathV1(projectRoot, "bot-7", "profile.md"),
|
|
809
|
+
other,
|
|
810
|
+
),
|
|
811
|
+
).toBe(false);
|
|
812
|
+
expect(
|
|
813
|
+
writerOwnsMemoryPathV1(memoryShardPathV1(userRoot, "bot-1", "p.md"), {
|
|
814
|
+
kind: "first-party",
|
|
815
|
+
packageId: "memory",
|
|
816
|
+
}),
|
|
817
|
+
).toBe(false);
|
|
818
|
+
expect(
|
|
819
|
+
writerOwnsMemoryPathV1(memoryShardPathV1(userRoot, "bot-1", "p.md"), {
|
|
820
|
+
kind: "unattributed",
|
|
821
|
+
}),
|
|
822
|
+
).toBe(false);
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
test("the predicate answers only the Memory sharding rule", () => {
|
|
826
|
+
expect(
|
|
827
|
+
writerOwnsMemoryPathV1(
|
|
828
|
+
{ root: instructionRoot(), path: "skills/a/SKILL.md" },
|
|
829
|
+
{ kind: "user", userId: "user-1" },
|
|
830
|
+
),
|
|
831
|
+
).toBe(false);
|
|
832
|
+
});
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
describe("conflicting generations survive as a declared variant", () => {
|
|
836
|
+
test("a conflict carries both generations and decodes exactly", () => {
|
|
837
|
+
const current = generation({ generationId: "000000000000001-000001" });
|
|
838
|
+
const preserved = generation({
|
|
839
|
+
generationId: "000000000000002-000001",
|
|
840
|
+
conflictsWith: "000000000000001-000001",
|
|
841
|
+
});
|
|
842
|
+
const decoded = decodeWorkspaceConflictV1({
|
|
843
|
+
status: "conflict",
|
|
844
|
+
reason: "the file changed since the writer last saw it",
|
|
845
|
+
current,
|
|
846
|
+
preserved,
|
|
847
|
+
});
|
|
848
|
+
expect(decoded.current?.generationId).toBe("000000000000001-000001");
|
|
849
|
+
expect(decoded.preserved?.conflictsWith).toBe("000000000000001-000001");
|
|
850
|
+
expect(
|
|
851
|
+
decodeWorkspaceFailureV1({
|
|
852
|
+
status: "conflict",
|
|
853
|
+
reason: "the file changed since the writer last saw it",
|
|
854
|
+
current,
|
|
855
|
+
preserved,
|
|
856
|
+
}),
|
|
857
|
+
).toEqual(decoded);
|
|
858
|
+
expect(() =>
|
|
859
|
+
decodeWorkspaceConflictV1({ status: "refused", reason: "no" }),
|
|
860
|
+
).toThrow();
|
|
861
|
+
expect(() =>
|
|
862
|
+
decodeWorkspaceFailureV1({ status: "refused", reason: "no", current }),
|
|
863
|
+
).toThrow();
|
|
864
|
+
});
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
describe("durable generation records", () => {
|
|
868
|
+
test("a record names its root, path, generation, and object etag", () => {
|
|
869
|
+
const entry = decodeWorkspaceGenerationRecordV1({
|
|
870
|
+
schemaVersion: 1,
|
|
871
|
+
root: instructionRoot(),
|
|
872
|
+
path: "skills/deploy/SKILL.md",
|
|
873
|
+
generation: generation(),
|
|
874
|
+
etag: "abc123",
|
|
875
|
+
});
|
|
876
|
+
expect(entry.root).toEqual(instructionRoot());
|
|
877
|
+
expect(entry.etag).toBe("abc123");
|
|
878
|
+
expect(entry.deleted).toBe(undefined);
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
test("a tombstone is a record, so a delete leaves durable evidence", () => {
|
|
882
|
+
const entry = decodeWorkspaceGenerationRecordV1({
|
|
883
|
+
schemaVersion: 1,
|
|
884
|
+
root: instructionRoot(),
|
|
885
|
+
path: "skills/deploy/SKILL.md",
|
|
886
|
+
generation: generation({
|
|
887
|
+
contentHash:
|
|
888
|
+
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
|
889
|
+
size: 0,
|
|
890
|
+
}),
|
|
891
|
+
deleted: true,
|
|
892
|
+
});
|
|
893
|
+
expect(entry.deleted).toBe(true);
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
test("rejects an unknown field, a bad path, and a wrong schema version", () => {
|
|
897
|
+
const base = {
|
|
898
|
+
schemaVersion: 1,
|
|
899
|
+
root: instructionRoot(),
|
|
900
|
+
path: "a.md",
|
|
901
|
+
generation: generation(),
|
|
902
|
+
};
|
|
903
|
+
expect(() =>
|
|
904
|
+
decodeWorkspaceGenerationRecordV1({ ...base, surprise: 1 }),
|
|
905
|
+
).toThrow();
|
|
906
|
+
expect(() =>
|
|
907
|
+
decodeWorkspaceGenerationRecordV1({ ...base, path: "../a.md" }),
|
|
908
|
+
).toThrow();
|
|
909
|
+
expect(() =>
|
|
910
|
+
decodeWorkspaceGenerationRecordV1({ ...base, schemaVersion: 2 }),
|
|
911
|
+
).toThrow();
|
|
912
|
+
});
|
|
913
|
+
});
|