@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/frockbot.json +2 -1
- package/package.json +8 -8
- package/src/agent.ts +3 -1
- package/src/bot.test.ts +85 -0
- package/src/bot.ts +111 -1
- package/src/client/BotAvatar.vue +94 -12
- package/src/client/FlockDangerZone.vue +71 -0
- package/src/client/FlockOverlay.vue +36 -1
- package/src/client/FlockSidebar.vue +42 -4
- package/src/client/delivered-notifications.test.ts +103 -0
- package/src/client/delivered-notifications.ts +100 -0
- package/src/client/index.test.ts +205 -2
- package/src/client/index.ts +0 -0
- package/src/client/state.ts +8 -3
- package/src/client/styles.css +34 -0
- package/src/shared.test.ts +41 -0
- package/src/shared.ts +26 -4
- package/src/user.test.ts +104 -0
- package/src/user.ts +76 -7
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
|
|
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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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(
|