@frockbot/plugin-flock 0.3.11 → 0.3.13

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.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(