@frockbot/plugin-flock 0.3.12 → 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 +7 -7
- package/src/agent.ts +3 -1
- package/src/bot.test.ts +85 -0
- package/src/bot.ts +111 -1
- package/src/client/FlockDangerZone.vue +71 -0
- package/src/client/FlockOverlay.vue +36 -1
- package/src/client/FlockSidebar.vue +8 -0
- package/src/client/index.test.ts +108 -0
- package/src/client/index.ts +61 -0
- package/src/client/state.ts +4 -1
- package/src/client/styles.css +16 -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/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.
|
|
3
|
+
"version": "0.3.13",
|
|
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.
|
|
31
|
-
"@frockbot/client-ui": "0.3.
|
|
32
|
-
"@frockbot/configuration-core": "0.3.
|
|
33
|
-
"@frockbot/kernel-contracts": "0.3.
|
|
34
|
-
"@frockbot/plugin-shell": "0.3.
|
|
30
|
+
"@frockbot/client-core": "0.3.13",
|
|
31
|
+
"@frockbot/client-ui": "0.3.13",
|
|
32
|
+
"@frockbot/configuration-core": "0.3.13",
|
|
33
|
+
"@frockbot/kernel-contracts": "0.3.13",
|
|
34
|
+
"@frockbot/plugin-shell": "0.3.13",
|
|
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.
|
|
39
|
+
"@frockbot/plugin-testkit": "0.3.13",
|
|
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(
|
|
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>
|
package/src/client/index.test.ts
CHANGED
|
@@ -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" };
|
package/src/client/index.ts
CHANGED
|
@@ -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;
|
package/src/client/state.ts
CHANGED
|
@@ -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;
|
package/src/client/styles.css
CHANGED
|
@@ -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;
|
package/src/shared.test.ts
CHANGED
|
@@ -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
|
-
|
|
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" &&
|
|
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" &&
|
|
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
|
|
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(
|