@frockbot/plugin-bot-template 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/src/user.ts ADDED
@@ -0,0 +1,913 @@
1
+ // The User Contribution: staging a template, and owning its shares.
2
+ //
3
+ // AUTHORITY. "Publication beyond the authoring User is a User action." So the
4
+ // split here is exact: staging *builds and stores* a template, always at
5
+ // `visibility: "private"`, and choosing `link` or `public` is a separate
6
+ // command a User issues from their own surface. A Bot's tool reaches only the
7
+ // staging half (`src/agent.ts`), which is why a Bot can describe what it packed
8
+ // and can never publish it.
9
+ //
10
+ // STATE. The blob is content-addressed and immutable, so it lives in object
11
+ // storage beside the Catalog's generations and is written through the same
12
+ // collision-checking `putImmutable` the Package publisher uses. What cannot
13
+ // live there is visibility: an immutable object can never be un-published, and
14
+ // a share must be revocable, so the `TemplateShareRecordV1` lives in this
15
+ // object's durable storage (D3). `shareId` carries the owning User's public id
16
+ // as its first component, so an unauthenticated read routes to exactly one User
17
+ // Durable Object with no global index anywhere.
18
+ import type {
19
+ BotSettingsViewV1,
20
+ UserSettingsViewV1,
21
+ } from "@frockbot/configuration-core";
22
+ import type {
23
+ UserSettingsBackendContribution,
24
+ UserSettingsStorage,
25
+ UserSettingsTransaction,
26
+ } from "@frockbot/plugin-settings/user";
27
+ import {
28
+ canonicalBotTemplateDocumentV1,
29
+ parseBotTemplateDocumentV1,
30
+ decodeTemplateShareRecordV1,
31
+ isTemplateShareReadableV1,
32
+ parseTemplateShareIdV1,
33
+ templateContentHashV1,
34
+ templateObjectKeyV1,
35
+ templateShareIdV1,
36
+ TemplateDecodeError,
37
+ type TemplateShareRecordV1,
38
+ type TemplateSheepRecipeV1,
39
+ } from "@frockbot/template-core";
40
+ import type { Plugin } from "cordis";
41
+ import {
42
+ buildBotTemplateV1,
43
+ type TemplateRoutineCandidateV1,
44
+ type TemplateSkillCandidateV1,
45
+ } from "./scrub.js";
46
+ import {
47
+ importedBotIdV1,
48
+ importedRoutineIdV1,
49
+ planBotTemplateImportV1,
50
+ type TemplateImportPlanV1,
51
+ } from "./import.js";
52
+ import {
53
+ decodeTemplateCommandV1,
54
+ decodeTemplateImportRecordV1,
55
+ MAX_TEMPLATE_IMPORTS_V1,
56
+ MAX_TEMPLATE_SHARES_V1,
57
+ type TemplateImportListViewV1,
58
+ type TemplateImportRecordV1,
59
+ type TemplateImportStepReceiptV1,
60
+ templateCommandFingerprintV1,
61
+ type TemplateCommandV1,
62
+ type TemplateExportSummaryV1,
63
+ type TemplateShareListViewV1,
64
+ type TemplateShareReceiptV1,
65
+ } from "./shared.js";
66
+
67
+ export const BOT_TEMPLATE_PACKAGE_ID = "bot-template";
68
+
69
+ const SHARE_PREFIX = "bot-template:share:";
70
+ const IMPORT_PREFIX = "bot-template:import:";
71
+ const IMPORT_INDEX_KEY = "bot-template:import-index";
72
+ const SHARE_INDEX_KEY = "bot-template:share-index";
73
+ const RECEIPT_PREFIX = "bot-template:receipt:";
74
+
75
+ /**
76
+ * The immutable blob store, named structurally so this Package holds no
77
+ * Cloudflare type. The adapter that owns the bucket implements it, and it is
78
+ * the same collision-checked write `apps/cloudflare/src/package-publication.ts`
79
+ * already performs: a key that exists with different bytes is a collision, and
80
+ * one that exists with identical bytes is a no-op.
81
+ */
82
+ export interface TemplateBlobStoreV1 {
83
+ putImmutable(key: string, document: string): Promise<void>;
84
+ read(key: string): Promise<string | undefined>;
85
+ }
86
+
87
+ /**
88
+ * What the Bot Durable Object contributes to one export.
89
+ *
90
+ * Three reads, all read-only, all of state the Bot already surfaces to its own
91
+ * User. Nothing here can widen what an export sees: a Bot that could not read
92
+ * its own instruction root cannot export from it either.
93
+ */
94
+ export interface TemplateBotReaderV1 {
95
+ readSettings(userId: string, botId: string): Promise<BotSettingsViewV1>;
96
+ /** This Bot's own generated sheep avatar (D1). */
97
+ readSheep(userId: string, botId: string): Promise<TemplateSheepRecipeV1>;
98
+ /** Own-root Skills, bodies included. Managed and plugin Skills never appear. */
99
+ readSkills(
100
+ userId: string,
101
+ botId: string,
102
+ ): Promise<readonly TemplateSkillCandidateV1[]>;
103
+ readRoutines(
104
+ userId: string,
105
+ botId: string,
106
+ ): Promise<readonly TemplateRoutineCandidateV1[]>;
107
+ }
108
+
109
+ /**
110
+ * What an import writes through.
111
+ *
112
+ * Every method is a command the importing User's own surfaces already issue —
113
+ * `bot/create` from the sidebar, `user/install-package` from the Plugins
114
+ * surface, a Skill write and a `routine/create` from the Bot's own settings.
115
+ * There is deliberately nothing here for a Connection or an Assignment: an
116
+ * import cannot create either, because this seam cannot express it.
117
+ */
118
+ export interface TemplateImportWriterV1 {
119
+ listBots(): Promise<{ revision: number; bots: { botId: string }[] }>;
120
+ createBot(input: {
121
+ userId: string;
122
+ commandId: string;
123
+ expectedRevision: number;
124
+ botId: string;
125
+ name: string;
126
+ description?: string;
127
+ sheep: TemplateSheepRecipeV1;
128
+ }): Promise<{ status: "applied" | "rejected"; failure?: string }>;
129
+ installPackage(input: {
130
+ userId: string;
131
+ commandId: string;
132
+ packageId: string;
133
+ version: string;
134
+ catalogId: string;
135
+ catalogGeneration: string;
136
+ }): Promise<{ status: string; failure?: string }>;
137
+ /** Written with `writer: { kind: "user" }`: the importing User authored it. */
138
+ writeSkill(input: {
139
+ userId: string;
140
+ botId: string;
141
+ slug: string;
142
+ name: string;
143
+ description: string;
144
+ body: string;
145
+ }): Promise<
146
+ | { status: "written"; generationId: string }
147
+ | { status: "refused"; reason: string }
148
+ >;
149
+ executeRoutineCommand(input: {
150
+ userId: string;
151
+ botId: string;
152
+ command: unknown;
153
+ }): Promise<{ status: string; routineId?: string }>;
154
+ }
155
+
156
+ export interface BotTemplateUserHostV1 {
157
+ storage: UserSettingsStorage;
158
+ settings: UserSettingsBackendContribution;
159
+ bots: TemplateBotReaderV1;
160
+ blobs: TemplateBlobStoreV1;
161
+ /** Absent on a host that does not import; the import commands then refuse. */
162
+ importer?: TemplateImportWriterV1;
163
+ /**
164
+ * One published share, of any User. The adapter routes by the share id's
165
+ * owner half; this Package never learns another User's storage exists.
166
+ */
167
+ readPublishedShare?(
168
+ shareId: string,
169
+ ): Promise<{ hash: string; document: string } | undefined>;
170
+ /** Every `catalogId` the given generation's index holds. */
171
+ readCatalogIds?(generation: string): Promise<readonly string[]>;
172
+ /**
173
+ * The Catalog display name of one entry at an exact generation. Optional: a
174
+ * deployment with no Catalog exports the `packageId` as the display name
175
+ * rather than failing, which is the same thing the install surface does.
176
+ */
177
+ readCatalogDisplayName?(
178
+ generation: string,
179
+ catalogId: string,
180
+ ): Promise<string | undefined>;
181
+ now?(): number;
182
+ /** 32 hex characters. Overridable so a test can pin a share id. */
183
+ randomSecret?(): string;
184
+ }
185
+
186
+ interface StoredTemplateReceipt {
187
+ fingerprint: string;
188
+ receipt: TemplateShareReceiptV1;
189
+ }
190
+
191
+ export class TemplateShareNotFoundError extends Error {
192
+ constructor(readonly shareId: string) {
193
+ super(`template share "${shareId}" was not found`);
194
+ this.name = "TemplateShareNotFoundError";
195
+ }
196
+ }
197
+
198
+ function hex(bytes: Uint8Array): string {
199
+ return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
200
+ }
201
+
202
+ function defaultSecret(): string {
203
+ return hex(crypto.getRandomValues(new Uint8Array(16)));
204
+ }
205
+
206
+ /**
207
+ * Whether the importer will have to bring their own credential for this
208
+ * Connection Type.
209
+ *
210
+ * Read off the Connection's declared authorization rather than off any
211
+ * Package's Connection Type id, so this stays provider-neutral: a Connection
212
+ * whose authorization is anything but `none` — or whose authorization is not
213
+ * recorded at all — is a placeholder in the template.
214
+ */
215
+ function isKeyedConnection(authorizationKind: string | undefined): boolean {
216
+ return authorizationKind !== "none";
217
+ }
218
+
219
+ export class BotTemplateUserBackendContribution {
220
+ readonly packageId = BOT_TEMPLATE_PACKAGE_ID;
221
+
222
+ private readonly now: () => number;
223
+ private readonly randomSecret: () => string;
224
+
225
+ constructor(private readonly host: BotTemplateUserHostV1) {
226
+ this.now = host.now ?? (() => Date.now());
227
+ this.randomSecret = host.randomSecret ?? defaultSecret;
228
+ }
229
+
230
+ async listShares(userId: string): Promise<TemplateShareListViewV1> {
231
+ await this.host.settings.read(userId);
232
+ const shares: TemplateShareRecordV1[] = [];
233
+ for (const shareId of await this.shareIndex(this.host.storage)) {
234
+ const share = await this.readShare(this.host.storage, shareId);
235
+ if (share) shares.push(share);
236
+ }
237
+ return { schemaVersion: 1, shares };
238
+ }
239
+
240
+ /**
241
+ * One share, for the unauthenticated `GET /templates/v1/:shareId`.
242
+ *
243
+ * The caller has proved nothing, so this answers only what a `link` or
244
+ * `public` share may say. A `private` or revoked share is `undefined` — the
245
+ * same answer a share that never existed gives, so an unauthenticated caller
246
+ * cannot probe for one.
247
+ */
248
+ async resolvePublicShare(
249
+ shareId: string,
250
+ ): Promise<{ share: TemplateShareRecordV1; document: string } | undefined> {
251
+ const share = await this.readShare(this.host.storage, shareId);
252
+ if (!share || !isTemplateShareReadableV1(share)) return undefined;
253
+ const document = await this.host.blobs.read(
254
+ templateObjectKeyV1(share.hash),
255
+ );
256
+ return document === undefined ? undefined : { share, document };
257
+ }
258
+
259
+ async execute(
260
+ userId: string,
261
+ input: unknown,
262
+ ): Promise<TemplateShareReceiptV1> {
263
+ const command = decodeTemplateCommandV1(input);
264
+ const fingerprint = templateCommandFingerprintV1(command);
265
+ const stored = await this.readReceipt(command.commandId);
266
+ if (stored) {
267
+ if (stored.fingerprint !== fingerprint) {
268
+ throw new TemplateDecodeError(
269
+ `template command "${command.commandId}" was reused for a different command`,
270
+ );
271
+ }
272
+ // A replay after eviction is a read: the durable receipt is the answer,
273
+ // so nothing is packed, stored, or published a second time.
274
+ return stored.receipt;
275
+ }
276
+ if (
277
+ command.type === "template/plan-import" ||
278
+ command.type === "template/apply-import"
279
+ ) {
280
+ throw new TemplateDecodeError(
281
+ "an import command is issued through executeImport, not execute",
282
+ );
283
+ }
284
+ const receipt =
285
+ command.type === "template/stage"
286
+ ? await this.stage(userId, command)
287
+ : await this.applyShareChange(userId, command);
288
+ await this.host.storage.put(`${RECEIPT_PREFIX}${command.commandId}`, {
289
+ fingerprint,
290
+ receipt,
291
+ } satisfies StoredTemplateReceipt);
292
+ return receipt;
293
+ }
294
+
295
+ private async stage(
296
+ userId: string,
297
+ command: Extract<TemplateCommandV1, { type: "template/stage" }>,
298
+ ): Promise<TemplateShareReceiptV1> {
299
+ // `readConfiguration` rather than `read`: the pinned generation is
300
+ // projected onto the view, and it is what the export records as its
301
+ // provenance and what an importer diffs against.
302
+ const user = await this.host.settings.readConfiguration({
303
+ schemaVersion: 1,
304
+ userId,
305
+ });
306
+ const built = await this.build(userId, command.botId, user);
307
+ const document = canonicalBotTemplateDocumentV1(built.template);
308
+ const hash = await templateContentHashV1(document);
309
+ // The blob is content-addressed, so an identical re-export lands on the
310
+ // same key and the collision check makes the write a no-op.
311
+ await this.host.blobs.putImmutable(templateObjectKeyV1(hash), document);
312
+
313
+ const share: TemplateShareRecordV1 = decodeTemplateShareRecordV1({
314
+ schemaVersion: 1,
315
+ shareId: templateShareIdV1(userId, this.randomSecret()),
316
+ hash,
317
+ botId: command.botId,
318
+ // A stage is never a publication. The User chooses `link` or `public`.
319
+ visibility: "private",
320
+ createdAt: new Date(this.now()).toISOString(),
321
+ });
322
+ await this.host.storage.transaction(async (transaction) => {
323
+ const index = await this.shareIndex(transaction);
324
+ if (index.length >= MAX_TEMPLATE_SHARES_V1) {
325
+ throw new TemplateDecodeError(
326
+ `this User already holds ${MAX_TEMPLATE_SHARES_V1} template shares`,
327
+ );
328
+ }
329
+ await transaction.put({
330
+ [`${SHARE_PREFIX}${share.shareId}`]: share,
331
+ [SHARE_INDEX_KEY]: [...index, share.shareId],
332
+ });
333
+ });
334
+ return {
335
+ schemaVersion: 1,
336
+ commandId: command.commandId,
337
+ status: "applied",
338
+ share,
339
+ summary: built.summary,
340
+ };
341
+ }
342
+
343
+ private async applyShareChange(
344
+ userId: string,
345
+ command: Extract<
346
+ TemplateCommandV1,
347
+ { type: "template/set-visibility" } | { type: "template/revoke" }
348
+ >,
349
+ ): Promise<TemplateShareReceiptV1> {
350
+ await this.host.settings.read(userId);
351
+ // The share id names its owner, and only that owner's Durable Object holds
352
+ // the record, so a share of another User is simply not here.
353
+ if (parseTemplateShareIdV1(command.shareId).ownerId !== userId) {
354
+ throw new TemplateShareNotFoundError(command.shareId);
355
+ }
356
+ const share = await this.host.storage.transaction(async (transaction) => {
357
+ const current = await this.readShare(transaction, command.shareId);
358
+ if (!current) throw new TemplateShareNotFoundError(command.shareId);
359
+ const next: TemplateShareRecordV1 =
360
+ command.type === "template/revoke"
361
+ ? {
362
+ ...current,
363
+ // Revocation is idempotent: a share already revoked keeps the
364
+ // moment it was revoked at, so a retry does not move it.
365
+ revokedAt:
366
+ current.revokedAt ?? new Date(this.now()).toISOString(),
367
+ }
368
+ : { ...current, visibility: command.visibility };
369
+ await transaction.put(`${SHARE_PREFIX}${command.shareId}`, next);
370
+ return next;
371
+ });
372
+ return {
373
+ schemaVersion: 1,
374
+ commandId: command.commandId,
375
+ status: "applied",
376
+ share,
377
+ };
378
+ }
379
+
380
+ /** Read what the Bot is, and hand it to the pure scrub. Reads only. */
381
+ private async build(
382
+ userId: string,
383
+ botId: string,
384
+ user: UserSettingsViewV1,
385
+ ): Promise<{
386
+ template: ReturnType<typeof buildBotTemplateV1>["template"];
387
+ summary: TemplateExportSummaryV1;
388
+ }> {
389
+ const settings = await this.host.bots.readSettings(userId, botId);
390
+ const [sheep, skills, routines] = await Promise.all([
391
+ this.host.bots.readSheep(userId, botId),
392
+ this.host.bots.readSkills(userId, botId),
393
+ this.host.bots.readRoutines(userId, botId),
394
+ ]);
395
+ const packages = await Promise.all(
396
+ user.packages.map(async (installation) => ({
397
+ packageId: installation.packageId,
398
+ version: installation.version,
399
+ state: installation.state,
400
+ ...(installation.catalogId === undefined
401
+ ? {}
402
+ : { catalogId: installation.catalogId }),
403
+ ...(installation.catalogGeneration === undefined
404
+ ? {}
405
+ : { catalogGeneration: installation.catalogGeneration }),
406
+ ...(installation.provenance === undefined
407
+ ? {}
408
+ : { provenance: installation.provenance }),
409
+ ...(installation.values === undefined
410
+ ? {}
411
+ : { values: installation.values }),
412
+ displayName: await this.displayName(installation),
413
+ })),
414
+ );
415
+ return buildBotTemplateV1({
416
+ botId,
417
+ profile: {
418
+ name: settings.profile.name,
419
+ ...(settings.profile.title === undefined
420
+ ? {}
421
+ : { title: settings.profile.title }),
422
+ ...(settings.profile.description === undefined
423
+ ? {}
424
+ : { description: settings.profile.description }),
425
+ },
426
+ sheep,
427
+ skills,
428
+ routines,
429
+ packages,
430
+ connections: user.connections.map((connection) => ({
431
+ packageId: connection.packageId,
432
+ connectionTypeId: connection.connectionTypeId,
433
+ displayName: connection.displayName,
434
+ state: connection.state,
435
+ keyed: isKeyedConnection(connection.authorization?.kind),
436
+ ...(connection.settings === undefined
437
+ ? {}
438
+ : {
439
+ settings: {
440
+ url: connection.settings.url,
441
+ transport: connection.settings.transport,
442
+ },
443
+ }),
444
+ })),
445
+ hasModelAssignment: settings.model !== undefined,
446
+ assignmentCount: settings.assignments.length,
447
+ ...(user.catalogGeneration === undefined
448
+ ? {}
449
+ : { sourceCatalogGeneration: user.catalogGeneration }),
450
+ });
451
+ }
452
+
453
+ private async displayName(installation: {
454
+ packageId: string;
455
+ catalogId?: string;
456
+ catalogGeneration?: string;
457
+ }): Promise<string> {
458
+ if (
459
+ !this.host.readCatalogDisplayName ||
460
+ !installation.catalogId ||
461
+ !installation.catalogGeneration
462
+ ) {
463
+ return installation.packageId;
464
+ }
465
+ try {
466
+ return (
467
+ (await this.host.readCatalogDisplayName(
468
+ installation.catalogGeneration,
469
+ installation.catalogId,
470
+ )) ?? installation.packageId
471
+ );
472
+ } catch {
473
+ // A Catalog that cannot be read costs the export a prettier name and
474
+ // nothing else; it never costs it the entry.
475
+ return installation.packageId;
476
+ }
477
+ }
478
+
479
+ // -------------------------------------------------------------------------
480
+ // Import.
481
+ //
482
+ // Two commands and one durable record. `template/plan-import` reads — it
483
+ // fetches the blob, decodes it strictly, diffs it against this User's own
484
+ // pinned generation and writes a `planned` record; it applies nothing.
485
+ // `template/apply-import` walks that record's steps, marking each one done as
486
+ // it goes, so an eviction mid-apply resumes at the first step that is not.
487
+ //
488
+ // Every step is idempotent on its own terms rather than on this record alone:
489
+ // the Bot id is derived so a replay collides with the Bot it already made,
490
+ // each install carries a derived `commandId` the Settings Contribution
491
+ // receipts, a Skill write is a content write to a derived path, and a Routine
492
+ // carries a derived `routineId` its store refuses to create twice. The record
493
+ // is the *cursor*, not the fence.
494
+ // -------------------------------------------------------------------------
495
+
496
+ async listImports(userId: string): Promise<TemplateImportListViewV1> {
497
+ await this.host.settings.read(userId);
498
+ const imports: TemplateImportRecordV1[] = [];
499
+ for (const importId of await this.importIndex()) {
500
+ const record = await this.readImport(importId);
501
+ if (record) imports.push(record);
502
+ }
503
+ return { schemaVersion: 1, imports };
504
+ }
505
+
506
+ async executeImport(
507
+ userId: string,
508
+ input: unknown,
509
+ ): Promise<TemplateImportRecordV1> {
510
+ const command = decodeTemplateCommandV1(input);
511
+ if (command.type === "template/plan-import") {
512
+ return this.planImport(userId, command.commandId, command.shareId);
513
+ }
514
+ if (command.type === "template/apply-import") {
515
+ return this.applyImport(userId, command.importId);
516
+ }
517
+ throw new TemplateDecodeError(
518
+ "only an import command is issued through executeImport",
519
+ );
520
+ }
521
+
522
+ /** Read-only. Nothing an import would do happens here. */
523
+ private async planImport(
524
+ userId: string,
525
+ importId: string,
526
+ shareId: string,
527
+ ): Promise<TemplateImportRecordV1> {
528
+ const existing = await this.readImport(importId);
529
+ // A replanned import is a read: the plan is what the User reviewed, and
530
+ // re-deriving it under a moved Catalog would change what they confirmed.
531
+ if (existing) return existing;
532
+ if (!this.host.readPublishedShare) {
533
+ throw new TemplateDecodeError("this deployment cannot import templates");
534
+ }
535
+ const found = await this.host.readPublishedShare(shareId);
536
+ if (!found) throw new TemplateShareNotFoundError(shareId);
537
+ const template = parseBotTemplateDocumentV1(found.document);
538
+ const user = await this.host.settings.readConfiguration({
539
+ schemaVersion: 1,
540
+ userId,
541
+ });
542
+ const generation = user.catalogGeneration;
543
+ const availableCatalogIds =
544
+ generation && this.host.readCatalogIds
545
+ ? await this.host.readCatalogIds(generation)
546
+ : [];
547
+ const plan = planBotTemplateImportV1({
548
+ importId,
549
+ shareId,
550
+ hash: found.hash,
551
+ botId: await importedBotIdV1(userId, importId, template.profile.name),
552
+ template,
553
+ installedPackages: user.packages.map((installation) => ({
554
+ packageId: installation.packageId,
555
+ state: installation.state,
556
+ ...(installation.catalogId === undefined
557
+ ? {}
558
+ : { catalogId: installation.catalogId }),
559
+ })),
560
+ ...(generation === undefined ? {} : { catalogGeneration: generation }),
561
+ availableCatalogIds,
562
+ });
563
+ const now = new Date(this.now()).toISOString();
564
+ const record: TemplateImportRecordV1 = decodeTemplateImportRecordV1({
565
+ schemaVersion: 1,
566
+ importId,
567
+ shareId,
568
+ hash: found.hash,
569
+ botId: plan.botId,
570
+ status: "planned",
571
+ botName: plan.profile.name,
572
+ packages: plan.packages,
573
+ connections: plan.connections,
574
+ skills: plan.skills.map((skill) => skill.slug),
575
+ routines: plan.routines.map((routine) => ({
576
+ slug: routine.slug,
577
+ disabled: routine.triggerKind === "webhook",
578
+ })),
579
+ steps: plan.steps.map((step) => ({
580
+ key: step.key,
581
+ kind: step.kind,
582
+ status: "pending",
583
+ ...(step.subject === undefined ? {} : { subject: step.subject }),
584
+ })),
585
+ createdAt: now,
586
+ updatedAt: now,
587
+ ...(plan.catalogGeneration === undefined
588
+ ? {}
589
+ : { catalogGeneration: plan.catalogGeneration }),
590
+ });
591
+ await this.putImport(record, plan);
592
+ return record;
593
+ }
594
+
595
+ /**
596
+ * Walk the plan. Resumable, and safe to call again after any failure.
597
+ *
598
+ * A failed step stops the walk and leaves a visible, repairable record: the
599
+ * Bot exists with whatever applied, the card is re-openable, and re-issuing
600
+ * the command retries from exactly that step.
601
+ */
602
+ async applyImport(
603
+ userId: string,
604
+ importId: string,
605
+ ): Promise<TemplateImportRecordV1> {
606
+ await this.host.settings.read(userId);
607
+ const writer = this.host.importer;
608
+ if (!writer) {
609
+ throw new TemplateDecodeError("this deployment cannot import templates");
610
+ }
611
+ let record = await this.readImport(importId);
612
+ if (!record) throw new TemplateShareNotFoundError(importId);
613
+ if (record.status === "applied") return record;
614
+ const plan = await this.readImportPlan(importId);
615
+ if (!plan) throw new TemplateShareNotFoundError(importId);
616
+
617
+ record = await this.patchImport(importId, (current) => ({
618
+ ...current,
619
+ status: "applying",
620
+ ...(current.failure === undefined ? {} : { failure: undefined }),
621
+ }));
622
+
623
+ for (const step of record.steps) {
624
+ if (step.status === "done" || step.status === "skipped") continue;
625
+ let outcome: { detail?: string; skipped?: boolean };
626
+ try {
627
+ outcome = await this.runImportStep(userId, plan, step, writer);
628
+ } catch (error) {
629
+ const failure = error instanceof Error ? error.message : String(error);
630
+ return this.patchImport(importId, (current) => ({
631
+ ...current,
632
+ status: "failed",
633
+ failure: `${step.key}: ${failure}`,
634
+ steps: current.steps.map((entry) =>
635
+ entry.key === step.key
636
+ ? { ...entry, status: "failed" as const, failure }
637
+ : entry,
638
+ ),
639
+ }));
640
+ }
641
+ record = await this.patchImport(importId, (current) => ({
642
+ ...current,
643
+ steps: current.steps.map((entry) =>
644
+ entry.key === step.key
645
+ ? {
646
+ ...entry,
647
+ status: outcome.skipped
648
+ ? ("skipped" as const)
649
+ : ("done" as const),
650
+ ...(outcome.detail === undefined
651
+ ? {}
652
+ : { detail: outcome.detail }),
653
+ }
654
+ : entry,
655
+ ),
656
+ }));
657
+ }
658
+ return this.patchImport(importId, (current) => ({
659
+ ...current,
660
+ status: "applied",
661
+ }));
662
+ }
663
+
664
+ /** Every import left mid-apply, resumed. Called from the User DO's alarm. */
665
+ async recoverImports(userId: string): Promise<void> {
666
+ if (!this.host.importer) return;
667
+ for (const importId of await this.importIndex()) {
668
+ const record = await this.readImport(importId);
669
+ if (record?.status !== "applying") continue;
670
+ try {
671
+ await this.applyImport(userId, importId);
672
+ } catch {
673
+ // The record already carries the failure; a recovery pass that cannot
674
+ // finish must not stop the other owners of this alarm from running.
675
+ }
676
+ }
677
+ }
678
+
679
+ private async runImportStep(
680
+ userId: string,
681
+ plan: TemplateImportPlanV1,
682
+ step: TemplateImportStepReceiptV1,
683
+ writer: TemplateImportWriterV1,
684
+ ): Promise<{ detail?: string; skipped?: boolean }> {
685
+ switch (step.kind) {
686
+ case "bot/create": {
687
+ const directory = await writer.listBots();
688
+ // The derived id is the fence. A replay finds the Bot it already made.
689
+ if (directory.bots.some((bot) => bot.botId === plan.botId)) {
690
+ return { detail: plan.botId };
691
+ }
692
+ const receipt = await writer.createBot({
693
+ userId,
694
+ commandId: `import-bot-${plan.importId}`,
695
+ expectedRevision: directory.revision,
696
+ botId: plan.botId,
697
+ name: plan.profile.name,
698
+ ...(plan.profile.description === undefined
699
+ ? {}
700
+ : { description: plan.profile.description }),
701
+ sheep: plan.sheep,
702
+ });
703
+ if (receipt.status === "rejected") {
704
+ throw new Error(receipt.failure ?? "the Flock refused bot/create");
705
+ }
706
+ return { detail: plan.botId };
707
+ }
708
+ case "user/install-package": {
709
+ const entry = plan.packages.find(
710
+ (candidate) => candidate.catalogId === step.subject,
711
+ );
712
+ if (!entry || entry.status !== "will-install") return { skipped: true };
713
+ if (!plan.catalogGeneration) return { skipped: true };
714
+ const receipt = await writer.installPackage({
715
+ userId,
716
+ // Derived, so the Settings Contribution's own receipt makes a replay
717
+ // a read rather than a second install.
718
+ commandId: `import-install-${plan.importId}-${entry.catalogId}`,
719
+ packageId: entry.packageId,
720
+ version: entry.version,
721
+ catalogId: entry.catalogId,
722
+ catalogGeneration: plan.catalogGeneration,
723
+ });
724
+ if (receipt.status === "rejected") {
725
+ throw new Error(receipt.failure ?? "the install was rejected");
726
+ }
727
+ return { detail: entry.catalogId };
728
+ }
729
+ case "skill/write": {
730
+ const skill = plan.skills.find(
731
+ (candidate) => candidate.slug === step.subject,
732
+ );
733
+ if (!skill) return { skipped: true };
734
+ const outcome = await writer.writeSkill({
735
+ userId,
736
+ botId: plan.botId,
737
+ slug: skill.slug,
738
+ name: skill.name,
739
+ description: skill.description ?? skill.name,
740
+ body: skill.body,
741
+ });
742
+ if (outcome.status === "refused") throw new Error(outcome.reason);
743
+ return { detail: outcome.generationId };
744
+ }
745
+ case "routine/create": {
746
+ const routine = plan.routines.find(
747
+ (candidate) => candidate.slug === step.subject,
748
+ );
749
+ if (!routine) return { skipped: true };
750
+ const routineId = importedRoutineIdV1(plan.importId, routine.slug);
751
+ const receipt = await writer.executeRoutineCommand({
752
+ userId,
753
+ botId: plan.botId,
754
+ command: {
755
+ schemaVersion: 1,
756
+ type: "routine/create",
757
+ commandId: `import-routine-${routineId}`,
758
+ botId: plan.botId,
759
+ routineId,
760
+ name: routine.name,
761
+ prompt: routine.prompt,
762
+ ...(routine.schedule === undefined
763
+ ? {}
764
+ : { schedule: routine.schedule }),
765
+ ...(routine.triggerKind === "webhook"
766
+ ? { trigger: { kind: "webhook" } }
767
+ : {}),
768
+ timezone: routine.timezone,
769
+ },
770
+ });
771
+ return { detail: receipt.routineId ?? routineId };
772
+ }
773
+ case "routine/disable": {
774
+ const routineId = importedRoutineIdV1(
775
+ plan.importId,
776
+ step.subject ?? "",
777
+ );
778
+ // An imported webhook Routine has no key in this deployment, and a
779
+ // stranger's trigger firing unannounced would be a surprise rather
780
+ // than a feature. It arrives paused, for its User to turn on.
781
+ await writer.executeRoutineCommand({
782
+ userId,
783
+ botId: plan.botId,
784
+ command: {
785
+ schemaVersion: 1,
786
+ type: "routine/pause",
787
+ commandId: `import-routine-pause-${routineId}`,
788
+ botId: plan.botId,
789
+ routineId,
790
+ },
791
+ });
792
+ return { detail: routineId };
793
+ }
794
+ }
795
+ }
796
+
797
+ private async putImport(
798
+ record: TemplateImportRecordV1,
799
+ plan: TemplateImportPlanV1,
800
+ ): Promise<void> {
801
+ await this.host.storage.transaction(async (transaction) => {
802
+ const index = await this.importIndex(transaction);
803
+ if (index.length >= MAX_TEMPLATE_IMPORTS_V1) {
804
+ throw new TemplateDecodeError(
805
+ `this User already holds ${MAX_TEMPLATE_IMPORTS_V1} template imports`,
806
+ );
807
+ }
808
+ await transaction.put({
809
+ [`${IMPORT_PREFIX}${record.importId}`]: record,
810
+ [`${IMPORT_PREFIX}plan:${record.importId}`]: plan,
811
+ [IMPORT_INDEX_KEY]: [...index, record.importId],
812
+ });
813
+ });
814
+ }
815
+
816
+ private async patchImport(
817
+ importId: string,
818
+ patch: (current: TemplateImportRecordV1) => TemplateImportRecordV1,
819
+ ): Promise<TemplateImportRecordV1> {
820
+ return this.host.storage.transaction(async (transaction) => {
821
+ const stored = await transaction.get<unknown>(
822
+ `${IMPORT_PREFIX}${importId}`,
823
+ );
824
+ if (stored === undefined) throw new TemplateShareNotFoundError(importId);
825
+ const next = decodeTemplateImportRecordV1({
826
+ ...patch(decodeTemplateImportRecordV1(stored)),
827
+ updatedAt: new Date(this.now()).toISOString(),
828
+ });
829
+ await transaction.put(`${IMPORT_PREFIX}${importId}`, next);
830
+ return next;
831
+ });
832
+ }
833
+
834
+ private async readImport(
835
+ importId: string,
836
+ ): Promise<TemplateImportRecordV1 | undefined> {
837
+ const stored = await this.host.storage.get<unknown>(
838
+ `${IMPORT_PREFIX}${importId}`,
839
+ );
840
+ return stored === undefined
841
+ ? undefined
842
+ : decodeTemplateImportRecordV1(stored);
843
+ }
844
+
845
+ private async readImportPlan(
846
+ importId: string,
847
+ ): Promise<TemplateImportPlanV1 | undefined> {
848
+ return this.host.storage.get<TemplateImportPlanV1>(
849
+ `${IMPORT_PREFIX}plan:${importId}`,
850
+ );
851
+ }
852
+
853
+ private async importIndex(
854
+ storage: UserSettingsTransaction = this.host.storage,
855
+ ): Promise<string[]> {
856
+ const stored = await storage.get<unknown>(IMPORT_INDEX_KEY);
857
+ return Array.isArray(stored)
858
+ ? stored
859
+ .filter((value): value is string => typeof value === "string")
860
+ .slice(0, MAX_TEMPLATE_IMPORTS_V1)
861
+ : [];
862
+ }
863
+
864
+ private async shareIndex(
865
+ storage: UserSettingsTransaction,
866
+ ): Promise<string[]> {
867
+ const stored = await storage.get<unknown>(SHARE_INDEX_KEY);
868
+ return Array.isArray(stored)
869
+ ? stored
870
+ .filter((value): value is string => typeof value === "string")
871
+ .slice(0, MAX_TEMPLATE_SHARES_V1)
872
+ : [];
873
+ }
874
+
875
+ private async readShare(
876
+ storage: UserSettingsTransaction,
877
+ shareId: string,
878
+ ): Promise<TemplateShareRecordV1 | undefined> {
879
+ const stored = await storage.get<unknown>(`${SHARE_PREFIX}${shareId}`);
880
+ if (stored === undefined) return undefined;
881
+ return decodeTemplateShareRecordV1(stored);
882
+ }
883
+
884
+ private async readReceipt(
885
+ commandId: string,
886
+ ): Promise<StoredTemplateReceipt | undefined> {
887
+ const stored = await this.host.storage.get<unknown>(
888
+ `${RECEIPT_PREFIX}${commandId}`,
889
+ );
890
+ if (!stored || typeof stored !== "object" || Array.isArray(stored)) {
891
+ return undefined;
892
+ }
893
+ const value = stored as Record<string, unknown>;
894
+ if (typeof value.fingerprint !== "string") return undefined;
895
+ return {
896
+ fingerprint: value.fingerprint,
897
+ receipt: value.receipt as TemplateShareReceiptV1,
898
+ };
899
+ }
900
+ }
901
+
902
+ export function createBotTemplateUserBackendContribution(
903
+ host: BotTemplateUserHostV1,
904
+ ): BotTemplateUserBackendContribution {
905
+ return new BotTemplateUserBackendContribution(host);
906
+ }
907
+
908
+ export function createBotTemplateUserBackendPlugin(
909
+ host: BotTemplateUserHostV1,
910
+ lifecycle: { mount(value: BotTemplateUserBackendContribution): () => void },
911
+ ): Plugin {
912
+ return () => lifecycle.mount(createBotTemplateUserBackendContribution(host));
913
+ }