@frockbot/plugin-bot-template 0.0.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,455 @@
1
+ // The apply saga: per-step receipts, replay, resumption and visible failure.
2
+ //
3
+ // The writer seam is a recording fake, because the claims here are about the
4
+ // *saga* — how many times each command is issued, in what order, and what the
5
+ // record says when one fails. That the underlying commands are themselves
6
+ // idempotent is the workerd suite's claim, against the real authorities.
7
+ import { describe, expect, it } from "bun:test";
8
+ import {
9
+ createUserSettingsBackendContribution,
10
+ type UserSettingsStorage,
11
+ type UserSettingsTransaction,
12
+ } from "@frockbot/plugin-settings/user";
13
+ import {
14
+ canonicalBotTemplateDocumentV1,
15
+ templateContentHashV1,
16
+ type BotTemplateV1,
17
+ } from "@frockbot/template-core";
18
+ import { createBotTemplateUserBackendContribution } from "./user.ts";
19
+ import type {
20
+ TemplateBlobStoreV1,
21
+ TemplateBotReaderV1,
22
+ TemplateImportWriterV1,
23
+ } from "./user.ts";
24
+
25
+ const USER = "user-b";
26
+ const SHARE_ID = `user-a.${"a".repeat(32)}`;
27
+
28
+ const sheep = {
29
+ schemaVersion: 1 as const,
30
+ background: "meadow",
31
+ upper: "wool",
32
+ middle: "scarf",
33
+ lower: "boots",
34
+ };
35
+
36
+ class MemoryStorage implements UserSettingsStorage {
37
+ readonly values = new Map<string, unknown>();
38
+ get<T>(key: string): Promise<T | undefined> {
39
+ return Promise.resolve(this.values.get(key) as T | undefined);
40
+ }
41
+ put<T>(key: string, value: T): Promise<void>;
42
+ put(entries: Record<string, unknown>): Promise<void>;
43
+ put<T>(
44
+ keyOrEntries: string | Record<string, unknown>,
45
+ value?: T,
46
+ ): Promise<void> {
47
+ if (typeof keyOrEntries === "string") this.values.set(keyOrEntries, value);
48
+ else {
49
+ for (const [key, entry] of Object.entries(keyOrEntries)) {
50
+ this.values.set(key, entry);
51
+ }
52
+ }
53
+ return Promise.resolve();
54
+ }
55
+ async transaction<T>(
56
+ callback: (storage: UserSettingsTransaction) => Promise<T>,
57
+ ): Promise<T> {
58
+ const before = new Map(this.values);
59
+ try {
60
+ return await callback(this);
61
+ } catch (error) {
62
+ this.values.clear();
63
+ for (const [key, entry] of before) this.values.set(key, entry);
64
+ throw error;
65
+ }
66
+ }
67
+ }
68
+
69
+ /** One immutable generation holding the entry the template names. */
70
+ const CATALOG_ENTRY = {
71
+ schemaVersion: 1 as const,
72
+ catalogId: "example-connector",
73
+ packageId: "mcp",
74
+ displayName: "Example",
75
+ description: "An example connector.",
76
+ version: "0.0.1",
77
+ kind: "package" as const,
78
+ manifestHash: "d".repeat(64),
79
+ servers: [],
80
+ setupFields: [],
81
+ skills: [],
82
+ };
83
+
84
+ function catalogHost() {
85
+ return {
86
+ readCurrentIndex: () =>
87
+ Promise.resolve({
88
+ pin: { generation: "gen-7", indexHash: "c".repeat(64) },
89
+ index: {
90
+ schemaVersion: 1 as const,
91
+ generation: "gen-7",
92
+ entries: [
93
+ {
94
+ catalogId: CATALOG_ENTRY.catalogId,
95
+ packageId: CATALOG_ENTRY.packageId,
96
+ displayName: CATALOG_ENTRY.displayName,
97
+ description: CATALOG_ENTRY.description,
98
+ version: CATALOG_ENTRY.version,
99
+ manifestHash: CATALOG_ENTRY.manifestHash,
100
+ kind: CATALOG_ENTRY.kind,
101
+ },
102
+ ],
103
+ },
104
+ }),
105
+ readEntry: (generation: string, catalogId: string) =>
106
+ Promise.resolve(
107
+ generation === "gen-7" && catalogId === CATALOG_ENTRY.catalogId
108
+ ? CATALOG_ENTRY
109
+ : undefined,
110
+ ),
111
+ };
112
+ }
113
+
114
+ const blobs: TemplateBlobStoreV1 = {
115
+ putImmutable: () => Promise.resolve(),
116
+ read: () => Promise.resolve(undefined),
117
+ };
118
+
119
+ const bots: TemplateBotReaderV1 = {
120
+ readSettings: () => Promise.reject(new Error("not used")),
121
+ readSheep: () => Promise.resolve(sheep),
122
+ readSkills: () => Promise.resolve([]),
123
+ readRoutines: () => Promise.resolve([]),
124
+ };
125
+
126
+ function template(overrides: Partial<BotTemplateV1> = {}): BotTemplateV1 {
127
+ return {
128
+ schemaVersion: 1,
129
+ profile: {
130
+ name: "Budget",
131
+ description: "Watches the ledger.",
132
+ avatar: { kind: "sheep", recipe: sheep },
133
+ },
134
+ skills: [
135
+ { slug: "reconcile", name: "Reconcile", body: "# Reconcile\nSteps." },
136
+ ],
137
+ routines: [
138
+ {
139
+ slug: "on-delivery",
140
+ name: "On delivery",
141
+ prompt: "Handle it.",
142
+ timezone: "UTC",
143
+ triggerKind: "webhook",
144
+ },
145
+ ],
146
+ packages: [
147
+ {
148
+ packageId: "mcp",
149
+ catalogId: "example-connector",
150
+ version: "0.0.1",
151
+ displayName: "Example",
152
+ },
153
+ ],
154
+ mcpServers: [
155
+ {
156
+ kind: "needs-connection",
157
+ name: "Beeper",
158
+ connectionTypeId: "mcp-remote-key",
159
+ },
160
+ ],
161
+ ...overrides,
162
+ };
163
+ }
164
+
165
+ interface Recorder {
166
+ calls: string[];
167
+ installs: Record<string, unknown>[];
168
+ bots: Set<string>;
169
+ writer: TemplateImportWriterV1;
170
+ }
171
+
172
+ function recorder(
173
+ failures: Record<string, string> = {},
174
+ options: { botExistsAfterCreate?: boolean } = {},
175
+ ): Recorder {
176
+ const calls: string[] = [];
177
+ const installs: Record<string, unknown>[] = [];
178
+ const created = new Set<string>();
179
+ const writer: TemplateImportWriterV1 = {
180
+ listBots: () =>
181
+ Promise.resolve({
182
+ revision: created.size,
183
+ bots: [...created].map((botId) => ({ botId })),
184
+ }),
185
+ createBot: (create) => {
186
+ calls.push(`bot/create:${create.botId}`);
187
+ if (failures["bot/create"]) {
188
+ return Promise.resolve({
189
+ status: "rejected" as const,
190
+ failure: failures["bot/create"],
191
+ });
192
+ }
193
+ if (options.botExistsAfterCreate !== false) created.add(create.botId);
194
+ return Promise.resolve({ status: "applied" as const });
195
+ },
196
+ installPackage: (install) => {
197
+ calls.push(`install:${install.catalogId}`);
198
+ installs.push(install as unknown as Record<string, unknown>);
199
+ if (failures["install"]) throw new Error(failures["install"]);
200
+ return Promise.resolve({ status: "applied" });
201
+ },
202
+ writeSkill: (skill) => {
203
+ calls.push(`skill:${skill.slug}`);
204
+ if (failures["skill"]) {
205
+ return Promise.resolve({
206
+ status: "refused" as const,
207
+ reason: failures["skill"],
208
+ });
209
+ }
210
+ return Promise.resolve({
211
+ status: "written" as const,
212
+ generationId: `gen-${skill.slug}`,
213
+ });
214
+ },
215
+ executeRoutineCommand: ({ command }) => {
216
+ const typed = command as { type: string; routineId?: string };
217
+ calls.push(`${typed.type}:${typed.routineId}`);
218
+ return Promise.resolve({
219
+ status: "applied",
220
+ ...(typed.routineId === undefined
221
+ ? {}
222
+ : { routineId: typed.routineId }),
223
+ });
224
+ },
225
+ };
226
+ return { calls, installs, bots: created, writer };
227
+ }
228
+
229
+ async function harness(
230
+ options: {
231
+ failures?: Record<string, string>;
232
+ availableCatalogIds?: string[];
233
+ installed?: boolean;
234
+ } = {},
235
+ ) {
236
+ const storage = new MemoryStorage();
237
+ const settings = createUserSettingsBackendContribution({
238
+ storage,
239
+ availablePackages: [{ packageId: "mcp", version: "0.0.1" }],
240
+ catalog: catalogHost(),
241
+ });
242
+ // The first read pins the generation, exactly as production's first read does.
243
+ await settings.readConfiguration({ schemaVersion: 1, userId: USER });
244
+ if (options.installed) {
245
+ await settings.executeConfiguration({
246
+ schemaVersion: 1,
247
+ userId: USER,
248
+ command: {
249
+ schemaVersion: 1,
250
+ type: "user/install-package",
251
+ commandId: "pre-install",
252
+ expectedRevision: 0,
253
+ packageId: "mcp",
254
+ version: "0.0.1",
255
+ },
256
+ });
257
+ }
258
+ const document = canonicalBotTemplateDocumentV1(template());
259
+ const hash = await templateContentHashV1(document);
260
+ const recording = recorder(options.failures ?? {});
261
+ const contribution = createBotTemplateUserBackendContribution({
262
+ storage,
263
+ settings,
264
+ bots,
265
+ blobs,
266
+ importer: recording.writer,
267
+ readPublishedShare: () => Promise.resolve({ hash, document }),
268
+ readCatalogIds: () =>
269
+ Promise.resolve(options.availableCatalogIds ?? ["example-connector"]),
270
+ now: () => Date.parse("2026-09-01T00:00:00.000Z"),
271
+ });
272
+ return { contribution, recording, storage, hash };
273
+ }
274
+
275
+ async function plan(
276
+ contribution: Awaited<ReturnType<typeof harness>>["contribution"],
277
+ ) {
278
+ return contribution.executeImport(USER, {
279
+ schemaVersion: 1,
280
+ type: "template/plan-import",
281
+ commandId: "import-1",
282
+ shareId: SHARE_ID,
283
+ });
284
+ }
285
+
286
+ async function apply(
287
+ contribution: Awaited<ReturnType<typeof harness>>["contribution"],
288
+ ) {
289
+ return contribution.executeImport(USER, {
290
+ schemaVersion: 1,
291
+ type: "template/apply-import",
292
+ commandId: "apply-1",
293
+ importId: "import-1",
294
+ });
295
+ }
296
+
297
+ describe("planning", () => {
298
+ it("applies nothing and records a planned card", async () => {
299
+ const { contribution, recording } = await harness();
300
+ const record = await plan(contribution);
301
+ expect(record.status).toBe("planned");
302
+ expect(record.botName).toBe("Budget");
303
+ expect(record.skills).toEqual(["reconcile"]);
304
+ expect(record.routines).toEqual([{ slug: "on-delivery", disabled: true }]);
305
+ expect(record.connections).toHaveLength(1);
306
+ expect(record.steps.every((step) => step.status === "pending")).toBe(true);
307
+ // Nothing applied before the User confirms.
308
+ expect(recording.calls).toEqual([]);
309
+ });
310
+
311
+ it("replans as a read, so the card the User confirmed cannot move", async () => {
312
+ const { contribution } = await harness();
313
+ const first = await plan(contribution);
314
+ const second = await plan(contribution);
315
+ expect(second).toEqual(first);
316
+ expect((await contribution.listImports(USER)).imports).toHaveLength(1);
317
+ });
318
+ });
319
+
320
+ describe("applying", () => {
321
+ it("walks every step once, in order, and marks each done", async () => {
322
+ const { contribution, recording } = await harness();
323
+ const planned = await plan(contribution);
324
+ const applied = await apply(contribution);
325
+ expect(applied.status).toBe("applied");
326
+ expect(applied.steps.map((step) => step.status)).toEqual(
327
+ planned.steps.map(() => "done"),
328
+ );
329
+ expect(recording.calls).toEqual([
330
+ `bot/create:${planned.botId}`,
331
+ "install:example-connector",
332
+ "skill:reconcile",
333
+ "routine/create:import-1-on-delivery",
334
+ "routine/pause:import-1-on-delivery",
335
+ ]);
336
+ });
337
+
338
+ it("replays without a second Bot or a duplicate install", async () => {
339
+ const { contribution, recording } = await harness();
340
+ await plan(contribution);
341
+ await apply(contribution);
342
+ const before = [...recording.calls];
343
+ const again = await apply(contribution);
344
+ expect(again.status).toBe("applied");
345
+ expect(recording.calls).toEqual(before);
346
+ });
347
+
348
+ it("skips an install the pinned generation does not hold", async () => {
349
+ const { contribution, recording } = await harness({
350
+ availableCatalogIds: [],
351
+ });
352
+ const planned = await plan(contribution);
353
+ expect(planned.packages[0]!.status).toBe("missing");
354
+ await apply(contribution);
355
+ expect(recording.calls).not.toContain("install:example-connector");
356
+ });
357
+
358
+ it("installs with no setup values, because a template exports none", async () => {
359
+ const { contribution, recording } = await harness();
360
+ await plan(contribution);
361
+ await apply(contribution);
362
+ expect(recording.installs).toHaveLength(1);
363
+ // `PluginInstallationView.values` is one store with two writers, and a
364
+ // template is neither: setup values may hold keys, so they never travel
365
+ // and an import writes none. The User's own values survive untouched.
366
+ expect(Object.keys(recording.installs[0]!)).not.toContain("values");
367
+ });
368
+
369
+ it("creates no Connection and no Assignment", async () => {
370
+ const { contribution, recording } = await harness();
371
+ await plan(contribution);
372
+ const applied = await apply(contribution);
373
+ expect(recording.calls.some((call) => call.includes("connection"))).toBe(
374
+ false,
375
+ );
376
+ expect(JSON.stringify(applied)).not.toContain("assignmentId");
377
+ expect(applied.connections[0]!.name).toBe("Beeper");
378
+ });
379
+ });
380
+
381
+ describe("failure is a visible, repairable record", () => {
382
+ it("stops at the failing step and says which one and why", async () => {
383
+ const { contribution, recording } = await harness({
384
+ failures: { skill: "the instruction root is unavailable" },
385
+ });
386
+ await plan(contribution);
387
+ const failed = await apply(contribution);
388
+ expect(failed.status).toBe("failed");
389
+ expect(failed.failure).toContain("skill:reconcile");
390
+ expect(failed.failure).toContain("instruction root is unavailable");
391
+ const steps = Object.fromEntries(
392
+ failed.steps.map((step) => [step.key, step.status]),
393
+ );
394
+ expect(steps["bot/create"]).toBe("done");
395
+ expect(steps["install:example-connector"]).toBe("done");
396
+ expect(steps["skill:reconcile"]).toBe("failed");
397
+ // The Routine steps were never reached, so nothing half-fired.
398
+ expect(steps["routine:on-delivery"]).toBe("pending");
399
+ expect(recording.calls).not.toContain(
400
+ "routine/create:import-1-on-delivery",
401
+ );
402
+ });
403
+
404
+ it("resumes from the failed step and does not redo the ones that took", async () => {
405
+ const failures: Record<string, string> = { skill: "transient" };
406
+ const storage = new MemoryStorage();
407
+ const settings = createUserSettingsBackendContribution({
408
+ storage,
409
+ availablePackages: [{ packageId: "mcp", version: "0.0.1" }],
410
+ catalog: catalogHost(),
411
+ });
412
+ await settings.readConfiguration({ schemaVersion: 1, userId: USER });
413
+ const document = canonicalBotTemplateDocumentV1(template());
414
+ const hash = await templateContentHashV1(document);
415
+ const recording = recorder(failures);
416
+ const contribution = createBotTemplateUserBackendContribution({
417
+ storage,
418
+ settings,
419
+ bots,
420
+ blobs,
421
+ importer: recording.writer,
422
+ readPublishedShare: () => Promise.resolve({ hash, document }),
423
+ readCatalogIds: () => Promise.resolve(["example-connector"]),
424
+ now: () => Date.parse("2026-09-01T00:00:00.000Z"),
425
+ });
426
+ await plan(contribution);
427
+ expect((await apply(contribution)).status).toBe("failed");
428
+
429
+ // The Workspace comes back.
430
+ delete failures.skill;
431
+ const resumed = await apply(contribution);
432
+ expect(resumed.status).toBe("applied");
433
+ // One Bot, one install: the steps that already took were not repeated.
434
+ expect(
435
+ recording.calls.filter((call) => call.startsWith("bot/create")),
436
+ ).toHaveLength(1);
437
+ expect(
438
+ recording.calls.filter((call) => call === "install:example-connector"),
439
+ ).toHaveLength(1);
440
+ });
441
+
442
+ it("resumes an import left mid-apply from the recovery pass", async () => {
443
+ const { contribution, recording, storage } = await harness();
444
+ const record = await plan(contribution);
445
+ // An eviction between the record moving to `applying` and the first step.
446
+ await storage.put(`bot-template:import:${record.importId}`, {
447
+ ...record,
448
+ status: "applying",
449
+ });
450
+ await contribution.recoverImports(USER);
451
+ const listed = (await contribution.listImports(USER)).imports[0]!;
452
+ expect(listed.status).toBe("applied");
453
+ expect(recording.calls).toContain(`bot/create:${record.botId}`);
454
+ });
455
+ });