@frockbot/plugin-flock 0.3.12 → 0.3.14

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/frockbot.json CHANGED
@@ -22,7 +22,8 @@
22
22
  { "slot": "frockbot.overlays", "order": 10 },
23
23
  { "slot": "frockbot.bot-identity", "order": 10 },
24
24
  { "slot": "frockbot.bot-avatar", "order": 10 },
25
- { "slot": "frockbot.bot-avatar-editor", "order": 10 }
25
+ { "slot": "frockbot.bot-avatar-editor", "order": 10 },
26
+ { "slot": "frockbot.bot-settings-primary-sections", "order": 900 }
26
27
  ],
27
28
  "outlets": []
28
29
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-flock",
3
- "version": "0.3.12",
3
+ "version": "0.3.14",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -27,16 +27,16 @@
27
27
  "typecheck": "vue-tsc --noEmit -p tsconfig.json"
28
28
  },
29
29
  "dependencies": {
30
- "@frockbot/client-core": "0.3.12",
31
- "@frockbot/client-ui": "0.3.12",
32
- "@frockbot/configuration-core": "0.3.12",
33
- "@frockbot/kernel-contracts": "0.3.12",
34
- "@frockbot/plugin-shell": "0.3.12",
30
+ "@frockbot/client-core": "0.3.14",
31
+ "@frockbot/client-ui": "0.3.14",
32
+ "@frockbot/configuration-core": "0.3.14",
33
+ "@frockbot/kernel-contracts": "0.3.14",
34
+ "@frockbot/plugin-shell": "0.3.14",
35
35
  "cordis": "4.0.0-rc.8",
36
36
  "vue": "3.5.41"
37
37
  },
38
38
  "devDependencies": {
39
- "@frockbot/plugin-testkit": "0.3.12",
39
+ "@frockbot/plugin-testkit": "0.3.14",
40
40
  "@types/bun": "1.4.0",
41
41
  "@vitejs/plugin-vue": "6.0.8",
42
42
  "css-tree": "2.3.1",
package/src/agent.ts CHANGED
@@ -318,7 +318,9 @@ export async function createdBotIdV1(
318
318
  .replace(/[^a-z0-9]+/g, "-")
319
319
  .replace(/^-|-$/g, "")
320
320
  .slice(0, 80) || "bot";
321
- const digest = await sha256HexV1(`${owner.userId}${owner.botId}${effectId}`);
321
+ const digest = await sha256HexV1(
322
+ `${owner.userId}\u0000${owner.botId}\u0000${effectId}`,
323
+ );
322
324
  return `${base}-${digest.slice(0, 12)}`;
323
325
  }
324
326
 
package/src/bot.test.ts CHANGED
@@ -49,6 +49,7 @@ describe("Flock Bot contribution", () => {
49
49
  return Promise.resolve();
50
50
  },
51
51
  archiveEligible: () => Promise.resolve(true),
52
+ tearDown: () => Promise.resolve(),
52
53
  });
53
54
  expect(await create().read(registration, "user-1")).toMatchObject({
54
55
  botId: "alpha",
@@ -95,6 +96,7 @@ describe("Flock Bot contribution", () => {
95
96
  storage,
96
97
  materializeSettings: () => Promise.resolve(),
97
98
  archiveEligible: () => Promise.resolve(eligible),
99
+ tearDown: () => Promise.resolve(),
98
100
  });
99
101
  const contribution = create();
100
102
  await contribution.read(registration, "user-1");
@@ -153,12 +155,95 @@ describe("Flock Bot contribution", () => {
153
155
  });
154
156
  });
155
157
 
158
+ test("deletes every key, cancels the alarm, and refuses to come back", async () => {
159
+ const storage = new MemoryStorage();
160
+ // Whatever else the Bot owned: a run, a Routine schedule, its transcript.
161
+ // The teardown is the host's, so the test plays the host and asserts that
162
+ // the Contribution asked for one and left nothing but the tombstone.
163
+ let alarms = 1;
164
+ const torn: Array<{ userId: string; botId: string }> = [];
165
+ const create = () =>
166
+ createFlockBotBackendContribution({
167
+ storage,
168
+ materializeSettings: () => Promise.resolve(),
169
+ archiveEligible: () => Promise.resolve(false),
170
+ tearDown: (identity) => {
171
+ torn.push(identity);
172
+ alarms = 0;
173
+ storage.values.clear();
174
+ return Promise.resolve();
175
+ },
176
+ });
177
+ const contribution = create();
178
+ await contribution.read(registration, "user-1");
179
+ await storage.put("run:run-1", { schemaVersion: 1 });
180
+ await storage.put("conversation", { schemaVersion: 1 });
181
+ await storage.put("routine-schedule:daily", { schemaVersion: 1 });
182
+ const command = {
183
+ schemaVersion: 1 as const,
184
+ type: "bot/delete" as const,
185
+ commandId: "delete-1",
186
+ botId: "alpha",
187
+ };
188
+ const applied = await contribution.executeLifecycle(
189
+ registration,
190
+ "user-1",
191
+ command,
192
+ );
193
+ expect(applied).toMatchObject({
194
+ status: "applied",
195
+ lifecycle: { botId: "alpha", status: "deleted" },
196
+ });
197
+ // Deletion is not gated on `archiveEligible`: a Bot mid-run is still
198
+ // deleted when its owner says so.
199
+ expect(torn).toEqual([{ userId: "user-1", botId: "alpha" }]);
200
+ expect(alarms).toBe(0);
201
+ // Nothing survives but the tombstone and the receipt that proves it.
202
+ expect([...storage.values.keys()].sort()).toEqual([
203
+ "flock:lifecycle-receipt:delete-1",
204
+ "flock:lifecycle:v1",
205
+ ]);
206
+ // Replaying the command, and replaying it against a fresh instance, both
207
+ // settle from the tombstone without tearing anything down again.
208
+ expect(
209
+ await contribution.executeLifecycle(registration, "user-1", command),
210
+ ).toEqual(applied);
211
+ const reconstructed = create();
212
+ expect(
213
+ await reconstructed.executeLifecycle(registration, "user-1", {
214
+ ...command,
215
+ commandId: "delete-2",
216
+ }),
217
+ ).toMatchObject({ status: "applied", lifecycle: { status: "deleted" } });
218
+ expect(torn).toHaveLength(1);
219
+ expect(
220
+ await reconstructed.readLifecycle(registration, "user-1"),
221
+ ).toMatchObject({ botId: "alpha", status: "deleted" });
222
+ // Neither restore nor archive resurrects it, and no Turn or sheep command
223
+ // may run against it.
224
+ expect(
225
+ await reconstructed.executeLifecycle(registration, "user-1", {
226
+ schemaVersion: 1,
227
+ type: "bot/restore",
228
+ commandId: "restore-1",
229
+ botId: "alpha",
230
+ }),
231
+ ).toMatchObject({ status: "rejected", failure: 'Bot "alpha" is deleted' });
232
+ await expect(reconstructed.read(registration, "user-1")).rejects.toThrow(
233
+ "deleted",
234
+ );
235
+ await expect(reconstructed.assertActive(storage, "alpha")).rejects.toThrow(
236
+ "deleted",
237
+ );
238
+ });
239
+
156
240
  test("rejects malformed durable sheep identity and receipt records", async () => {
157
241
  const storage = new MemoryStorage();
158
242
  const contribution = createFlockBotBackendContribution({
159
243
  storage,
160
244
  materializeSettings: () => Promise.resolve(),
161
245
  archiveEligible: () => Promise.resolve(true),
246
+ tearDown: () => Promise.resolve(),
162
247
  });
163
248
  await storage.put("flock:sheep:v1", {
164
249
  schemaVersion: 1,
package/src/bot.ts CHANGED
@@ -42,6 +42,17 @@ export interface FlockBotBackendHost {
42
42
  userId: string,
43
43
  ): Promise<void>;
44
44
  archiveEligible(storage: FlockBotTransaction): Promise<boolean>;
45
+ /**
46
+ * Destroy everything this Bot owns: every durable key in its own Durable
47
+ * Object, its scheduled alarm, and any object-store root keyed to it.
48
+ *
49
+ * The host owns this rather than the Contribution because the surfaces are
50
+ * the application's — `deleteAll()` and `deleteAlarm()` are not part of the
51
+ * transaction seam this Package writes against, and the object store is not
52
+ * named here at all. It must be idempotent: the delete saga replays, and a
53
+ * Bot torn down twice is torn down once.
54
+ */
55
+ tearDown(identity: { userId: string; botId: string }): Promise<void>;
45
56
  }
46
57
 
47
58
  export class BotArchivedError extends Error {
@@ -51,6 +62,19 @@ export class BotArchivedError extends Error {
51
62
  }
52
63
  }
53
64
 
65
+ /**
66
+ * A Bot that has been permanently deleted. Distinct from `BotNotFoundError`
67
+ * because the tombstone is the one thing a deleted Bot still knows about
68
+ * itself: the registration may already be gone from the User's directory, or
69
+ * it may not be gone yet, and either way no Turn, command or routine may run.
70
+ */
71
+ export class BotDeletedError extends Error {
72
+ constructor(readonly botId: string) {
73
+ super(`Bot "${botId}" is deleted`);
74
+ this.name = "BotDeletedError";
75
+ }
76
+ }
77
+
54
78
  export class FlockBotBackendContribution {
55
79
  constructor(private readonly host: FlockBotBackendHost) {}
56
80
 
@@ -59,6 +83,10 @@ export class FlockBotBackendContribution {
59
83
  userId: string,
60
84
  ): Promise<SheepIdentityViewV1> {
61
85
  const registration = decodeBotRegistrationV1(registrationInput);
86
+ // A tombstone is checked before anything is written back: materializing a
87
+ // deleted Bot would recreate the very rows the delete removed.
88
+ const tombstone = await this.deletedLifecycle();
89
+ if (tombstone) throw new BotDeletedError(registration.botId);
62
90
  await this.host.materializeSettings(registration, userId);
63
91
  return this.host.storage.transaction(async (storage) => {
64
92
  const existingValue = await storage.get<unknown>(IDENTITY_KEY);
@@ -155,10 +183,28 @@ export class FlockBotBackendContribution {
155
183
  });
156
184
  }
157
185
 
186
+ /**
187
+ * The tombstone, if this Bot has been deleted.
188
+ *
189
+ * The delete teardown wipes every key and then writes the lifecycle back as
190
+ * `deleted`, so this single read is the whole of what a deleted Bot is.
191
+ */
192
+ private async deletedLifecycle(): Promise<BotLifecycleViewV1 | undefined> {
193
+ const stored = await this.host.storage.get<unknown>(LIFECYCLE_KEY);
194
+ if (stored === undefined) return undefined;
195
+ const lifecycle = decodeBotLifecycleViewV1(stored);
196
+ return lifecycle.status === "deleted" ? lifecycle : undefined;
197
+ }
198
+
158
199
  async readLifecycle(
159
200
  registration: BotRegistrationV1,
160
201
  userId: string,
161
202
  ): Promise<BotLifecycleViewV1> {
203
+ // The saga reconciles an uncertain delete by reading the lifecycle back,
204
+ // so a tombstone answers here rather than throwing: it is the proof the
205
+ // teardown ran.
206
+ const tombstone = await this.deletedLifecycle();
207
+ if (tombstone) return structuredClone(tombstone);
162
208
  await this.materialize(registration, userId);
163
209
  const stored = await this.host.storage.get<unknown>(LIFECYCLE_KEY);
164
210
  if (stored === undefined)
@@ -174,8 +220,25 @@ export class FlockBotBackendContribution {
174
220
  const command = decodeBotLifecycleCommandV1(input);
175
221
  if (registration.botId !== command.botId)
176
222
  throw new Error("lifecycle command does not match Bot registration");
177
- await this.materialize(registration, userId);
178
223
  const fingerprint = flockCommandFingerprint(command);
224
+ // Deletion is terminal, so the tombstone is read before the Bot is
225
+ // materialized: a replayed `bot/delete` settles from it, and archive or
226
+ // restore is refused instead of resurrecting the Bot.
227
+ const tombstone = await this.deletedLifecycle();
228
+ if (tombstone)
229
+ return {
230
+ schemaVersion: 1,
231
+ commandId: command.commandId,
232
+ botId: command.botId,
233
+ status: command.type === "bot/delete" ? "applied" : "rejected",
234
+ lifecycle: structuredClone(tombstone),
235
+ ...(command.type === "bot/delete"
236
+ ? {}
237
+ : { failure: `Bot "${command.botId}" is deleted` }),
238
+ } satisfies BotLifecycleReceiptV1;
239
+ if (command.type === "bot/delete")
240
+ return this.delete(registration, userId, command, fingerprint);
241
+ await this.materialize(registration, userId);
179
242
  return this.host.storage.transaction(async (storage) => {
180
243
  const receiptKey = `${LIFECYCLE_RECEIPT_PREFIX}${command.commandId}`;
181
244
  const storedReceipt = await storage.get<unknown>(receiptKey);
@@ -229,6 +292,52 @@ export class FlockBotBackendContribution {
229
292
  });
230
293
  }
231
294
 
295
+ /**
296
+ * Permanent deletion, as one replayable step.
297
+ *
298
+ * The teardown runs first and the tombstone is written after it, so a crash
299
+ * anywhere in between leaves an empty Durable Object that the saga's retry
300
+ * tears down again — `tearDown` is idempotent, and an empty object has
301
+ * nothing left to remove. Writing the tombstone first would be the unsafe
302
+ * order: `deleteAll` would take it out again and the Bot would look alive.
303
+ */
304
+ private async delete(
305
+ registration: BotRegistrationV1,
306
+ userId: string,
307
+ command: BotLifecycleCommandV1,
308
+ fingerprint: string,
309
+ ): Promise<BotLifecycleReceiptV1> {
310
+ const currentValue = await this.host.storage.get<unknown>(LIFECYCLE_KEY);
311
+ const current =
312
+ currentValue === undefined
313
+ ? undefined
314
+ : decodeBotLifecycleViewV1(currentValue);
315
+ if (current && current.botId !== registration.botId)
316
+ throw new Error("Bot lifecycle identity does not match");
317
+ await this.host.tearDown({ userId, botId: registration.botId });
318
+ const lifecycle = {
319
+ schemaVersion: 1,
320
+ botId: command.botId,
321
+ status: "deleted",
322
+ revision: (current?.revision ?? 0) + 1,
323
+ } satisfies BotLifecycleViewV1;
324
+ const receipt = {
325
+ schemaVersion: 1,
326
+ commandId: command.commandId,
327
+ botId: command.botId,
328
+ status: "applied",
329
+ lifecycle,
330
+ } satisfies BotLifecycleReceiptV1;
331
+ await this.host.storage.put({
332
+ [LIFECYCLE_KEY]: lifecycle,
333
+ [`${LIFECYCLE_RECEIPT_PREFIX}${command.commandId}`]: {
334
+ fingerprint,
335
+ receipt,
336
+ },
337
+ });
338
+ return decodeBotLifecycleReceiptV1(receipt);
339
+ }
340
+
232
341
  async assertActive(
233
342
  storage: FlockBotTransaction,
234
343
  botId: string,
@@ -238,6 +347,7 @@ export class FlockBotBackendContribution {
238
347
  const lifecycle = decodeBotLifecycleViewV1(value);
239
348
  if (lifecycle.botId !== botId)
240
349
  throw new Error("Bot lifecycle identity does not match");
350
+ if (lifecycle.status === "deleted") throw new BotDeletedError(botId);
241
351
  if (lifecycle.status === "archived") throw new BotArchivedError(botId);
242
352
  }
243
353
  }
@@ -0,0 +1,71 @@
1
+ <script setup lang="ts">
2
+ // Deleting a Bot is Flock's authority — the directory it removes the
3
+ // registration from is Flock's — so the affordance is contributed into the Bot
4
+ // settings screen from here rather than reimplemented inside the Settings
5
+ // Package, which would have to reach across a Package seam for the store.
6
+ //
7
+ // The confirmation itself is `FlockOverlay`'s: one dialog, one focus trap, one
8
+ // mobile layout, and no second copy of the copy.
9
+ import { inject } from "vue";
10
+ import { flockWebDataKey } from "./state.js";
11
+ import { frockBotWebDataKey } from "@frockbot/plugin-shell/shared";
12
+
13
+ const flock = inject(flockWebDataKey);
14
+ if (!flock) throw new Error("Flock client data was not provided");
15
+ const shell = inject(frockBotWebDataKey);
16
+ if (!shell) throw new Error("FrockBot client data was not provided");
17
+ </script>
18
+ <template>
19
+ <section v-if="shell.activeBotId" class="flock-danger-zone">
20
+ <div>
21
+ <strong>Delete Bot</strong>
22
+ <small>This Bot and its chat history will be permanently deleted.</small>
23
+ </div>
24
+ <button
25
+ type="button"
26
+ class="flock-danger-zone__action"
27
+ @click="flock.openDelete(shell.activeBotId)"
28
+ >
29
+ Delete Bot
30
+ </button>
31
+ </section>
32
+ </template>
33
+ <style scoped>
34
+ .flock-danger-zone {
35
+ display: flex;
36
+ align-items: center;
37
+ justify-content: space-between;
38
+ gap: 16px;
39
+ padding: 12px 14px;
40
+ border: 1px solid var(--frock-danger-border);
41
+ border-radius: var(--frock-radius-surface, 12px);
42
+ background: var(--frock-danger-surface);
43
+ }
44
+
45
+ .flock-danger-zone div {
46
+ display: flex;
47
+ flex-direction: column;
48
+ gap: 2px;
49
+ }
50
+
51
+ .flock-danger-zone small {
52
+ color: var(--frock-text-muted);
53
+ }
54
+
55
+ .flock-danger-zone__action {
56
+ flex: none;
57
+ height: var(--frock-control-md, 32px);
58
+ padding: 0 14px;
59
+ border: 1px solid var(--frock-danger-strong);
60
+ border-radius: 999px;
61
+ background: var(--frock-danger-strong);
62
+ color: var(--frock-on-accent);
63
+ font: inherit;
64
+ font-weight: 700;
65
+ cursor: pointer;
66
+ }
67
+
68
+ .flock-danger-zone__action:hover {
69
+ background: var(--frock-danger-text);
70
+ }
71
+ </style>
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { inject, nextTick, onBeforeUnmount, ref, watch } from "vue";
2
+ import { computed, inject, nextTick, onBeforeUnmount, ref, watch } from "vue";
3
3
  import { sheepCatalog } from "../shared.js";
4
4
  import { flockWebDataKey } from "./state.js";
5
5
  import { dialogFocusWrapTarget } from "./dialog-focus.js";
@@ -8,6 +8,22 @@ const providedFlock = inject(flockWebDataKey);
8
8
  if (!providedFlock) throw new Error("Flock client data was not provided");
9
9
  const flock = providedFlock;
10
10
 
11
+ /**
12
+ * The name the confirmation names. The live profile is the Bot's current name;
13
+ * the registration seed is what it was called when it was made, and is the
14
+ * fallback for a Bot whose identity read has not landed.
15
+ */
16
+ const pendingName = computed(() => {
17
+ const botId = flock.value.lifecyclePending;
18
+ if (!botId) return "this Bot";
19
+ return (
20
+ flock.value.profiles[botId]?.name ??
21
+ flock.value.directory.bots.find((bot) => bot.botId === botId)
22
+ ?.initialName ??
23
+ "this Bot"
24
+ );
25
+ });
26
+
11
27
  const dialog = ref<HTMLElement>();
12
28
  let restoreFocus: HTMLElement | undefined;
13
29
  const focusable =
@@ -90,6 +106,25 @@ onBeforeUnmount(() => restoreFocus?.focus());
90
106
  </button>
91
107
  </div>
92
108
  </div>
109
+ <div v-else-if="flock.overlay === 'delete'" class="flock-form">
110
+ <span class="flock-eyebrow">Delete Bot</span>
111
+ <h1 id="flock-title">Delete {{ pendingName }}?</h1>
112
+ <p>This Bot and its chat history will be permanently deleted.</p>
113
+ <p
114
+ v-if="flock.error"
115
+ class="flock-error"
116
+ role="alert"
117
+ aria-live="assertive"
118
+ >
119
+ {{ flock.error }}
120
+ </p>
121
+ <div class="flock-actions">
122
+ <button type="button" @click="flock.closeOverlay">Cancel</button>
123
+ <button class="danger" type="button" @click="flock.deleteBot">
124
+ Delete
125
+ </button>
126
+ </div>
127
+ </div>
93
128
  <template v-else>
94
129
  <div class="flock-preview">
95
130
  <SheepAvatar
@@ -279,6 +279,14 @@ onMounted(() => void flock.value.load());
279
279
  >
280
280
  Archive
281
281
  </button>
282
+ <button
283
+ v-if="flock.showArchived"
284
+ type="button"
285
+ class="flock-lifecycle flock-lifecycle--danger"
286
+ @click="flock.openDelete(bot.botId)"
287
+ >
288
+ Delete
289
+ </button>
282
290
  </div>
283
291
  </TransitionGroup>
284
292
  </section>
@@ -411,6 +411,114 @@ describe("Flock client reconciliation", () => {
411
411
  expect(state.value.directory.bots).toEqual([]);
412
412
  });
413
413
 
414
+ test("deletes with confirmation and drops the Bot from the list", async () => {
415
+ installStorage();
416
+ const location = { href: "https://app.example/?bot=alpha" };
417
+ Object.defineProperty(globalThis, "window", {
418
+ configurable: true,
419
+ value: {
420
+ location,
421
+ history: {
422
+ state: null,
423
+ replaceState: (_state: unknown, _title: string, url: URL) => {
424
+ location.href = url.href;
425
+ },
426
+ },
427
+ },
428
+ });
429
+ const sheep = randomSheepRecipeV1(() => 0);
430
+ // A deleted Bot leaves the directory outright, unlike an archived one.
431
+ let bots = ["alpha", "beta"];
432
+ let deleteCommands = 0;
433
+ const state = mount((path, method, body) => {
434
+ if (path === "/api/bots/identities")
435
+ return Promise.resolve({ schemaVersion: 1, identities: [] });
436
+ if (path === "/api/bots/alpha/lifecycle" && method === "POST") {
437
+ const command = JSON.parse(body ?? "") as {
438
+ commandId: string;
439
+ botId: string;
440
+ type: string;
441
+ };
442
+ expect(command.type).toBe("bot/delete");
443
+ deleteCommands += 1;
444
+ bots = bots.filter((botId) => botId !== "alpha");
445
+ return Promise.resolve({
446
+ schemaVersion: 1,
447
+ commandId: command.commandId,
448
+ botId: command.botId,
449
+ status: "applied",
450
+ lifecycle: {
451
+ schemaVersion: 1,
452
+ botId: command.botId,
453
+ status: "deleted",
454
+ revision: 1,
455
+ },
456
+ });
457
+ }
458
+ if (path === "/api/bots")
459
+ return Promise.resolve({
460
+ schemaVersion: 1,
461
+ revision: 3,
462
+ bots: bots.map((botId) => ({
463
+ schemaVersion: 1,
464
+ botId,
465
+ registeredAt: new Date(0).toISOString(),
466
+ initialName: botId,
467
+ sheep,
468
+ })),
469
+ });
470
+ if (path === "/api/bots/lifecycles")
471
+ return Promise.resolve({
472
+ schemaVersion: 1,
473
+ lifecycles: bots.map((botId) => ({
474
+ schemaVersion: 1,
475
+ botId,
476
+ status: "active",
477
+ revision: 0,
478
+ })),
479
+ });
480
+ if (path.endsWith("/sheep")) {
481
+ const botId = path.split("/")[3]!;
482
+ return Promise.resolve({ schemaVersion: 1, botId, revision: 0, sheep });
483
+ }
484
+ return Promise.reject(new Error(`unexpected request: ${path}`));
485
+ });
486
+ const selected: string[] = [];
487
+ const forgotten: (string | undefined)[] = [];
488
+ state.value.bindShell(
489
+ ref({
490
+ activeBotId: "alpha",
491
+ selectBot: (botId: string) => {
492
+ selected.push(botId);
493
+ return Promise.resolve();
494
+ },
495
+ transcripts: {
496
+ rememberViewport: () => undefined,
497
+ viewportFor: () => undefined,
498
+ forget: (botId?: string) => forgotten.push(botId),
499
+ },
500
+ }) as unknown as Ref<FrockBotWebData>,
501
+ );
502
+ await state.value.load();
503
+ state.value.openDelete("alpha");
504
+ expect(state.value.overlay).toBe("delete");
505
+ expect(state.value.lifecyclePending).toBe("alpha");
506
+ await state.value.deleteBot();
507
+ expect(deleteCommands).toBe(1);
508
+ expect(state.value.overlay).toBeUndefined();
509
+ expect(state.value.directory.bots.map((bot) => bot.botId)).toEqual([
510
+ "beta",
511
+ ]);
512
+ expect(selected.at(-1)).toBe("beta");
513
+ expect(new URL(location.href).searchParams.get("bot")).toBe("beta");
514
+ // The held transcript goes with the Bot: unlike an archive, there is no
515
+ // Bot left to read it back from.
516
+ expect(forgotten).toEqual(["alpha"]);
517
+ // A Bot that is no longer in the directory cannot be confirmed for delete.
518
+ state.value.openDelete("alpha");
519
+ expect(state.value.overlay).toBeUndefined();
520
+ });
521
+
414
522
  test("archives with confirmation, hides archived Bots, and selects a fallback", async () => {
415
523
  installStorage();
416
524
  const location = { href: "https://app.example/?bot=alpha" };
@@ -20,6 +20,7 @@ import FlockIdentity from "./FlockIdentity.vue";
20
20
  import FlockAvatar from "./FlockAvatar.vue";
21
21
  import FlockAvatarEditor from "./FlockAvatarEditor.vue";
22
22
  import FlockCreateButton from "./FlockCreateButton.vue";
23
+ import FlockDangerZone from "./FlockDangerZone.vue";
23
24
  import {
24
25
  decodeBotNotificationDirectoryViewV1,
25
26
  decodeBotUnreadDirectoryViewV1,
@@ -559,6 +560,59 @@ export const flockClientPlugin: ClientPlugin = (ctx) => {
559
560
  console.debug("bot archive failed", clientFailureDetailV1(error));
560
561
  }
561
562
  },
563
+ openDelete(botId) {
564
+ if (!state.value.directory.bots.some((bot) => bot.botId === botId))
565
+ return;
566
+ state.value.lifecyclePending = botId;
567
+ state.value.overlay = "delete";
568
+ state.value.error = undefined;
569
+ },
570
+ async deleteBot() {
571
+ const botId = state.value.lifecyclePending;
572
+ if (!botId) return;
573
+ try {
574
+ const receipt = decodeBotLifecycleReceiptV1(
575
+ await request(
576
+ `/api/bots/${encodeURIComponent(botId)}/lifecycle`,
577
+ "POST",
578
+ JSON.stringify({
579
+ schemaVersion: 1,
580
+ type: "bot/delete",
581
+ commandId: crypto.randomUUID(),
582
+ botId,
583
+ }),
584
+ ),
585
+ );
586
+ if (receipt.status === "rejected")
587
+ throw new Error(receipt.failure ?? "Couldn't delete this Bot.");
588
+ if (receipt.status === "pending") {
589
+ state.value.error = "Still deleting — this will finish shortly.";
590
+ return;
591
+ }
592
+ state.value.overlay = undefined;
593
+ state.value.lifecyclePending = undefined;
594
+ // An archived Bot's cached transcript is merely stale; a deleted one's
595
+ // is a lie, and there is no Bot left to read it from again.
596
+ shell?.value.transcripts.forget(botId);
597
+ // Move off the deleted Bot before anything else. Selecting aborts the
598
+ // Shell's in-flight reads for it, and every one of those is now a 404
599
+ // waiting to happen: the panels poll the Bot the User is looking at,
600
+ // and that Bot has just stopped existing.
601
+ const next = state.value.directory.bots.find(
602
+ (bot) =>
603
+ bot.botId !== botId &&
604
+ state.value.lifecycles[bot.botId] !== "archived",
605
+ );
606
+ if (next && shell?.value.activeBotId === botId)
607
+ await state.value.select(next.botId);
608
+ // The Bot is gone from the directory, so the reload re-derives the
609
+ // list, the selection and the `?bot=` parameter without a page reload.
610
+ await state.value.load();
611
+ } catch (error) {
612
+ state.value.error = presentClientFailureV1(error, "delete this Bot");
613
+ console.debug("bot delete failed", clientFailureDetailV1(error));
614
+ }
615
+ },
562
616
  async restore(botId) {
563
617
  try {
564
618
  const receipt = decodeBotLifecycleReceiptV1(
@@ -845,6 +899,13 @@ export const flockClientPlugin: ClientPlugin = (ctx) => {
845
899
  order: 10,
846
900
  component: FlockAvatarEditor,
847
901
  }),
902
+ // Last on the Bot's settings screen, below every setting it could still
903
+ // change: the one action that ends the Bot.
904
+ ctx.slot({
905
+ slot: "frockbot.bot-settings-primary-sections",
906
+ order: 900,
907
+ component: FlockDangerZone,
908
+ }),
848
909
  ];
849
910
  };
850
911
  export default flockClientPlugin;
@@ -31,7 +31,7 @@ export interface FlockWebData {
31
31
  */
32
32
  loaded: boolean;
33
33
  error?: string;
34
- overlay?: "create" | "edit" | "archive";
34
+ overlay?: "create" | "edit" | "archive" | "delete";
35
35
  lifecycles: Record<string, BotLifecycleStatusV1>;
36
36
  showArchived: boolean;
37
37
  lifecyclePending?: string;
@@ -52,6 +52,9 @@ export interface FlockWebData {
52
52
  toggleHidden(): void;
53
53
  openArchive(botId: string): void;
54
54
  archive(): Promise<void>;
55
+ /** Confirmation first: deleting a Bot destroys its chat history. */
56
+ openDelete(botId: string): void;
57
+ deleteBot(): Promise<void>;
55
58
  restore(botId: string): Promise<void>;
56
59
  closeOverlay(): void;
57
60
  reroll(): void;
@@ -70,6 +70,11 @@
70
70
  color: var(--frock-text);
71
71
  }
72
72
 
73
+ .flock-lifecycle--danger,
74
+ .flock-lifecycle--danger:hover {
75
+ color: var(--frock-danger-text);
76
+ }
77
+
73
78
  /*
74
79
  * Pinned Bots. A wrapping row of large round tiles above the labelled groups,
75
80
  * each one a whole Bot rather than a line of text about it.
@@ -624,6 +629,17 @@
624
629
  background: var(--frock-action-primary-pressed);
625
630
  }
626
631
 
632
+ /* The one irreversible action in this dialog set, and it says so. */
633
+ .flock-actions .danger {
634
+ border-color: var(--frock-danger-strong);
635
+ background: var(--frock-danger-strong);
636
+ color: var(--frock-on-accent);
637
+ }
638
+
639
+ .flock-actions .danger:hover {
640
+ background: var(--frock-danger-text);
641
+ }
642
+
627
643
  @media (max-width: 600px) {
628
644
  .flock-backdrop {
629
645
  padding: 10px;
@@ -4,11 +4,13 @@ import {
4
4
  decodeBotIdentityViewV1,
5
5
  decodeBotLifecycleCommandV1,
6
6
  decodeBotLifecycleReceiptV1,
7
+ decodeBotLifecycleViewV1,
7
8
  decodeBotMembershipViewV1,
8
9
  decodeBotRegistrationV1,
9
10
  decodeCreateBotCommandV1,
10
11
  decodeDirectoryViewV1,
11
12
  decodeSheepRecipeV1,
13
+ lifecycleTargetStatusV1,
12
14
  migrateStoredBotDirectoryV1,
13
15
  randomSheepRecipeV1,
14
16
  sheepCatalog,
@@ -38,6 +40,45 @@ describe("Flock v1 contracts", () => {
38
40
  ).toThrow("unknown or missing field");
39
41
  });
40
42
 
43
+ test("strictly decodes the delete command and its terminal status", () => {
44
+ const command = {
45
+ schemaVersion: 1 as const,
46
+ type: "bot/delete" as const,
47
+ commandId: "delete-1",
48
+ botId: "alpha",
49
+ };
50
+ expect(decodeBotLifecycleCommandV1(command)).toEqual(command);
51
+ expect(lifecycleTargetStatusV1("bot/delete")).toBe("deleted");
52
+ expect(lifecycleTargetStatusV1("bot/archive")).toBe("archived");
53
+ expect(lifecycleTargetStatusV1("bot/restore")).toBe("active");
54
+ expect(
55
+ decodeBotLifecycleReceiptV1({
56
+ schemaVersion: 1,
57
+ commandId: "delete-1",
58
+ botId: "alpha",
59
+ status: "applied",
60
+ lifecycle: {
61
+ schemaVersion: 1,
62
+ botId: "alpha",
63
+ status: "deleted",
64
+ revision: 1,
65
+ },
66
+ }),
67
+ ).toMatchObject({ lifecycle: { status: "deleted" } });
68
+ // A near-miss is still a rejection: the union is exact, not prefixed.
69
+ expect(() =>
70
+ decodeBotLifecycleCommandV1({ ...command, type: "bot/delete-all" }),
71
+ ).toThrow("unsupported Bot lifecycle command");
72
+ expect(() =>
73
+ decodeBotLifecycleViewV1({
74
+ schemaVersion: 1,
75
+ botId: "alpha",
76
+ status: "removed",
77
+ revision: 1,
78
+ }),
79
+ ).toThrow("Bot lifecycle is invalid");
80
+ });
81
+
41
82
  test("strictly decodes archive and restore DTOs", () => {
42
83
  const command = {
43
84
  schemaVersion: 1 as const,
package/src/shared.ts CHANGED
@@ -52,7 +52,12 @@ export interface BotDirectoryViewV1 {
52
52
  revision: number;
53
53
  bots: BotRegistrationV1[];
54
54
  }
55
- export type BotLifecycleStatusV1 = "active" | "archived";
55
+ /**
56
+ * A Bot's durable lifecycle. `deleted` is terminal: the Bot and its chat
57
+ * history are gone, and the status survives only as a tombstone so a late
58
+ * command or Turn is refused rather than resurrecting the Bot.
59
+ */
60
+ export type BotLifecycleStatusV1 = "active" | "archived" | "deleted";
56
61
  export interface BotLifecycleViewV1 {
57
62
  schemaVersion: 1;
58
63
  botId: string;
@@ -65,7 +70,7 @@ export interface BotLifecycleDirectoryViewV1 {
65
70
  }
66
71
  export interface BotLifecycleCommandV1 {
67
72
  schemaVersion: 1;
68
- type: "bot/archive" | "bot/restore";
73
+ type: "bot/archive" | "bot/restore" | "bot/delete";
69
74
  commandId: string;
70
75
  botId: string;
71
76
  }
@@ -162,6 +167,19 @@ export class FlockConflictError extends Error {
162
167
  this.name = "FlockConflictError";
163
168
  }
164
169
  }
170
+ /**
171
+ * The status a lifecycle command settles on. The saga on the User side and the
172
+ * Bot Durable Object both read the target from here rather than each writing
173
+ * out the same mapping.
174
+ */
175
+ export function lifecycleTargetStatusV1(
176
+ type: BotLifecycleCommandV1["type"],
177
+ ): BotLifecycleStatusV1 {
178
+ if (type === "bot/archive") return "archived";
179
+ if (type === "bot/delete") return "deleted";
180
+ return "active";
181
+ }
182
+
165
183
  export class BotNotFoundError extends Error {
166
184
  constructor(readonly botId: string) {
167
185
  super(`Bot "${botId}" is not registered`);
@@ -456,7 +474,9 @@ export function decodeBotLifecycleCommandV1(
456
474
  exact(value, ["schemaVersion", "type", "commandId", "botId"]);
457
475
  if (
458
476
  value.schemaVersion !== 1 ||
459
- (value.type !== "bot/archive" && value.type !== "bot/restore")
477
+ (value.type !== "bot/archive" &&
478
+ value.type !== "bot/restore" &&
479
+ value.type !== "bot/delete")
460
480
  )
461
481
  throw new FlockDecodeError("unsupported Bot lifecycle command");
462
482
  return {
@@ -472,7 +492,9 @@ export function decodeBotLifecycleViewV1(input: unknown): BotLifecycleViewV1 {
472
492
  exact(value, ["schemaVersion", "botId", "status", "revision"]);
473
493
  if (
474
494
  value.schemaVersion !== 1 ||
475
- (value.status !== "active" && value.status !== "archived")
495
+ (value.status !== "active" &&
496
+ value.status !== "archived" &&
497
+ value.status !== "deleted")
476
498
  )
477
499
  throw new FlockDecodeError("Bot lifecycle is invalid");
478
500
  return {
package/src/user.test.ts CHANGED
@@ -287,6 +287,110 @@ describe("Flock User contribution", () => {
287
287
  ).toMatchObject({ status: "applied", lifecycle: { status: "archived" } });
288
288
  });
289
289
 
290
+ test("removes a deleted Bot from the directory and queues its sweep", async () => {
291
+ const storage = new MemoryStorage();
292
+ let botStatus: "active" | "deleted" = "active";
293
+ const contribution = createFlockUserBackendContribution({
294
+ storage,
295
+ commandBotLifecycle: (_userId, lifecycleCommand) => {
296
+ botStatus = "deleted";
297
+ return Promise.resolve({
298
+ schemaVersion: 1,
299
+ commandId: lifecycleCommand.commandId,
300
+ botId: lifecycleCommand.botId,
301
+ status: "applied",
302
+ lifecycle: {
303
+ schemaVersion: 1,
304
+ botId: lifecycleCommand.botId,
305
+ status: "deleted",
306
+ revision: 1,
307
+ },
308
+ });
309
+ },
310
+ readBotLifecycle: (_userId, botId) =>
311
+ Promise.resolve({
312
+ schemaVersion: 1,
313
+ botId,
314
+ status: botStatus,
315
+ revision: 1,
316
+ }),
317
+ });
318
+ await contribution.createBot("user-1", command());
319
+ const remove = {
320
+ schemaVersion: 1 as const,
321
+ type: "bot/delete" as const,
322
+ commandId: "delete-1",
323
+ botId: "alpha",
324
+ };
325
+ const applied = await contribution.executeLifecycle("user-1", remove);
326
+ expect(applied).toMatchObject({
327
+ status: "applied",
328
+ lifecycle: { status: "deleted" },
329
+ });
330
+ // Gone from every read the sidebar, the fan-outs and the debug surface do.
331
+ expect(await contribution.listBots()).toMatchObject({
332
+ revision: 2,
333
+ bots: [],
334
+ });
335
+ expect(await contribution.listBotLifecycles()).toEqual({
336
+ schemaVersion: 1,
337
+ lifecycles: [],
338
+ });
339
+ expect(storage.values.has("flock:lifecycle:alpha")).toBe(false);
340
+ // The User-scoped projections are somebody else's to sweep, so the
341
+ // removal leaves the to-do entry that says so.
342
+ expect(await contribution.listDeletedBotIds()).toEqual(["alpha"]);
343
+ await contribution.forgetDeletedBot("alpha");
344
+ expect(await contribution.listDeletedBotIds()).toEqual([]);
345
+ // Replaying the command settles from the stored receipt rather than
346
+ // reporting a Bot that is no longer registered.
347
+ expect(await contribution.executeLifecycle("user-1", remove)).toEqual(
348
+ applied,
349
+ );
350
+ // A fresh delete of a Bot that is already gone is a plain 404.
351
+ await expect(
352
+ contribution.executeLifecycle("user-1", {
353
+ ...remove,
354
+ commandId: "delete-2",
355
+ }),
356
+ ).rejects.toThrow('Bot "alpha" is not registered');
357
+ });
358
+
359
+ test("finishes a half-done delete from the User alarm", async () => {
360
+ const storage = new MemoryStorage();
361
+ // The Bot tore itself down but the reply never arrived, so the saga is the
362
+ // only thing that knows the registration still has to go.
363
+ const contribution = createFlockUserBackendContribution({
364
+ storage,
365
+ commandBotLifecycle: () => Promise.reject(new Error("response lost")),
366
+ readBotLifecycle: (_userId, botId) =>
367
+ Promise.resolve({
368
+ schemaVersion: 1,
369
+ botId,
370
+ status: "deleted",
371
+ revision: 1,
372
+ }),
373
+ });
374
+ await contribution.createBot("user-1", command());
375
+ const failing = createFlockUserBackendContribution({
376
+ storage,
377
+ commandBotLifecycle: () => Promise.reject(new Error("response lost")),
378
+ readBotLifecycle: () => Promise.reject(new Error("Bot unavailable")),
379
+ });
380
+ expect(
381
+ await failing.executeLifecycle("user-1", {
382
+ schemaVersion: 1,
383
+ type: "bot/delete",
384
+ commandId: "delete-alarm",
385
+ botId: "alpha",
386
+ }),
387
+ ).toMatchObject({ status: "pending" });
388
+ expect((await contribution.listBots()).bots).toHaveLength(1);
389
+ await contribution.alarm();
390
+ expect((await contribution.listBots()).bots).toEqual([]);
391
+ expect(await contribution.listDeletedBotIds()).toEqual(["alpha"]);
392
+ });
393
+
290
394
  test("reconciles uncorrelated, pending, and wrong-target Bot replies", async () => {
291
395
  for (const variant of [
292
396
  "wrong-command",
package/src/user.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  decodeStoredBotLifecycleReceiptV1,
12
12
  decodeStoredFlockReceiptV1,
13
13
  flockCommandFingerprint,
14
+ lifecycleTargetStatusV1,
14
15
  migrateStoredBotDirectoryV1,
15
16
  randomSheepRecipeV1,
16
17
  type BotDirectoryViewV1,
@@ -30,6 +31,18 @@ const LIFECYCLE_PREFIX = "flock:lifecycle:";
30
31
  const LIFECYCLE_RECEIPT_PREFIX = "flock:lifecycle-receipt:";
31
32
  const LIFECYCLE_SAGA_PREFIX = "flock:lifecycle-saga:";
32
33
  const LIFECYCLE_OPERATION_PREFIX = "flock:lifecycle-operation:";
34
+ /**
35
+ * One key per Bot whose registration this object has removed, and whose
36
+ * User-scoped projections — the transcript index and the audit table — have
37
+ * not yet been swept.
38
+ *
39
+ * The saga can settle a delete on its alarm rather than on the command that
40
+ * started it, and the projections live outside this Package. Without a durable
41
+ * to-do list the sweep would be lost with the call that missed it, so the
42
+ * removal writes one and the application clears it once the projections are
43
+ * gone.
44
+ */
45
+ const DELETED_PREFIX = "flock:deleted:";
33
46
  export interface FlockUserTransaction {
34
47
  get<T>(key: string): Promise<T | undefined>;
35
48
  put<T>(key: string, value: T): Promise<void>;
@@ -207,6 +220,53 @@ export class FlockUserBackendContribution {
207
220
  });
208
221
  }
209
222
 
223
+ /**
224
+ * Drops one Bot out of the directory and out of the lifecycle projection,
225
+ * and records that its User-scoped projections still need sweeping.
226
+ *
227
+ * Idempotent by construction: a directory that no longer holds the Bot is
228
+ * left at its current revision, and both deletes and the tombstone write are
229
+ * safe to repeat.
230
+ */
231
+ private async removeRegistration(
232
+ storage: FlockUserTransaction,
233
+ botId: string,
234
+ ): Promise<void> {
235
+ const currentValue = await storage.get<unknown>(DIRECTORY_KEY);
236
+ const current =
237
+ currentValue === undefined
238
+ ? initialDirectory()
239
+ : decodeDirectoryViewV1(migrateStoredBotDirectoryV1(currentValue));
240
+ if (current.bots.some((bot) => bot.botId === botId)) {
241
+ await storage.put(DIRECTORY_KEY, {
242
+ ...current,
243
+ revision: current.revision + 1,
244
+ bots: current.bots.filter((bot) => bot.botId !== botId),
245
+ } satisfies BotDirectoryViewV1);
246
+ }
247
+ await storage.delete(`${LIFECYCLE_PREFIX}${botId}`);
248
+ await storage.put(`${DELETED_PREFIX}${botId}`, {
249
+ schemaVersion: 1,
250
+ botId,
251
+ });
252
+ }
253
+
254
+ /**
255
+ * Bots whose registration is gone but whose User-scoped projections have not
256
+ * been swept yet. The application sweeps and then calls `forgetDeletedBot`.
257
+ */
258
+ async listDeletedBotIds(): Promise<string[]> {
259
+ const entries = await this.host.storage.list<unknown>({
260
+ prefix: DELETED_PREFIX,
261
+ });
262
+ return [...entries.keys()].map((key) => key.slice(DELETED_PREFIX.length));
263
+ }
264
+
265
+ /** The sweep is done; drop the to-do entry. */
266
+ async forgetDeletedBot(botId: string): Promise<void> {
267
+ await this.host.storage.delete(`${DELETED_PREFIX}${botId}`);
268
+ }
269
+
210
270
  async listBotLifecycles(): Promise<BotLifecycleDirectoryViewV1> {
211
271
  const directory = await this.listBots();
212
272
  const lifecycles = await Promise.all(
@@ -317,7 +377,7 @@ export class FlockUserBackendContribution {
317
377
  return decodeStoredBotLifecycleReceiptV1(receipt).receipt;
318
378
  }
319
379
  const saga = decodeStoredLifecycleSagaV1(sagaValue);
320
- const target = saga.command.type === "bot/archive" ? "archived" : "active";
380
+ const target = lifecycleTargetStatusV1(saga.command.type);
321
381
  let outcome: BotLifecycleReceiptV1 | undefined;
322
382
  let reconcileMarker = false;
323
383
  try {
@@ -383,12 +443,21 @@ export class FlockUserBackendContribution {
383
443
  const currentSaga = decodeStoredLifecycleSagaV1(currentSagaValue);
384
444
  if (currentSaga.fingerprint !== saga.fingerprint)
385
445
  throw new FlockDecodeError(`command ID collision: ${commandId}`);
386
- await storage.put({
387
- [`${LIFECYCLE_PREFIX}${saga.command.botId}`]: outcome!.lifecycle,
388
- [`${LIFECYCLE_RECEIPT_PREFIX}${commandId}`]: {
389
- fingerprint: saga.fingerprint,
390
- receipt: outcome,
391
- },
446
+ if (outcome!.lifecycle.status === "deleted") {
447
+ // The Bot has torn itself down, so the registration leaves the
448
+ // directory in the same transaction that settles the receipt: the
449
+ // sidebar, every fan-out and the debug surface all read the directory,
450
+ // and none of them may see a Bot whose history is already gone.
451
+ await this.removeRegistration(storage, saga.command.botId);
452
+ } else {
453
+ await storage.put(
454
+ `${LIFECYCLE_PREFIX}${saga.command.botId}`,
455
+ outcome!.lifecycle,
456
+ );
457
+ }
458
+ await storage.put(`${LIFECYCLE_RECEIPT_PREFIX}${commandId}`, {
459
+ fingerprint: saga.fingerprint,
460
+ receipt: outcome,
392
461
  });
393
462
  await storage.delete(`${LIFECYCLE_SAGA_PREFIX}${commandId}`);
394
463
  await storage.delete(