@frockbot/plugin-flock 0.0.0 → 0.1.1
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/README.md +8 -1
- package/assets/amber-glasses.webp +0 -0
- package/assets/background-bright-orange.webp +0 -0
- package/assets/background-canary-yellow.webp +0 -0
- package/assets/background-electric-blue.webp +0 -0
- package/assets/background-hot-pink.webp +0 -0
- package/assets/background-lime-green.webp +0 -0
- package/assets/background-vivid-purple.webp +0 -0
- package/assets/blue-sailor-cap.webp +0 -0
- package/assets/bronze-aviator-goggles.webp +0 -0
- package/assets/burgundy-beret.webp +0 -0
- package/assets/canary-party-hat.webp +0 -0
- package/assets/canary-rain-cap.webp +0 -0
- package/assets/canonical.webp +0 -0
- package/assets/chef-toque.webp +0 -0
- package/assets/cobalt-round-glasses.webp +0 -0
- package/assets/coral-bucket-hat.webp +0 -0
- package/assets/emerald-necktie.webp +0 -0
- package/assets/forest-ranger-fedora.webp +0 -0
- package/assets/gold-royal-crown.webp +0 -0
- package/assets/gold-sun-medallion.webp +0 -0
- package/assets/graduation-cap.webp +0 -0
- package/assets/green-square-glasses.webp +0 -0
- package/assets/layers.css +159 -0
- package/assets/leather-bell.webp +0 -0
- package/assets/lilac-flower-crown.webp +0 -0
- package/assets/manifest.json +769 -0
- package/assets/matte-black-top-hat.webp +0 -0
- package/assets/navy-wizard-hat.webp +0 -0
- package/assets/orange-hard-hat.webp +0 -0
- package/assets/plum-glasses.webp +0 -0
- package/assets/purple-scarf.webp +0 -0
- package/assets/purple-witch-hat.webp +0 -0
- package/assets/rainbow-propeller-cap.webp +0 -0
- package/assets/rainbow-visor.webp +0 -0
- package/assets/ranger-fedora-feather.webp +0 -0
- package/assets/red-polka-bowtie.webp +0 -0
- package/assets/retro-3d-glasses.webp +0 -0
- package/assets/rose-heart-sunglasses.webp +0 -0
- package/assets/royal-crown-rubies.webp +0 -0
- package/assets/sailor-cap-anchor.webp +0 -0
- package/assets/silver-space-helmet.webp +0 -0
- package/assets/straw-boater.webp +0 -0
- package/assets/teal-bowtie.webp +0 -0
- package/assets/teal-glasses.webp +0 -0
- package/assets/terracotta-beanie.webp +0 -0
- package/assets/tortoiseshell-readers.webp +0 -0
- package/assets/turquoise-bandana.webp +0 -0
- package/assets/violet-masquerade-mask.webp +0 -0
- package/assets/white-snow-goggles.webp +0 -0
- package/assets/white-tennis-visor.webp +0 -0
- package/assets/witch-hat-moon.webp +0 -0
- package/assets/yellow-star-glasses.webp +0 -0
- package/frockbot.json +38 -0
- package/package.json +47 -6
- package/src/agent.test.ts +401 -0
- package/src/agent.ts +571 -0
- package/src/assets.test.ts +31 -0
- package/src/backend.test.ts +205 -0
- package/src/backend.ts +283 -0
- package/src/bot.test.ts +190 -0
- package/src/bot.ts +255 -0
- package/src/client/BotAvatar.vue +20 -0
- package/src/client/FlockAvatar.vue +33 -0
- package/src/client/FlockAvatarEditor.vue +44 -0
- package/src/client/FlockCreateButton.vue +18 -0
- package/src/client/FlockIdentity.vue +31 -0
- package/src/client/FlockOverlay.vue +209 -0
- package/src/client/FlockSidebar.vue +254 -0
- package/src/client/SheepAvatar.vue +28 -0
- package/src/client/dialog-focus.test.ts +15 -0
- package/src/client/dialog-focus.ts +13 -0
- package/src/client/index.test.ts +502 -0
- package/src/client/index.ts +619 -0
- package/src/client/mobile-dialog-layout.test.ts +50 -0
- package/src/client/pending-create.test.ts +71 -0
- package/src/client/pending-create.ts +93 -0
- package/src/client/sidebar.test.ts +77 -0
- package/src/client/sidebar.ts +77 -0
- package/src/client/state.ts +53 -0
- package/src/client/styles.css +611 -0
- package/src/env.d.ts +9 -0
- package/src/index.ts +4 -0
- package/src/manifest.ts +3 -0
- package/src/package.test.ts +12 -0
- package/src/shared.test.ts +197 -0
- package/src/shared.ts +735 -0
- package/src/user.test.ts +455 -0
- package/src/user.ts +465 -0
- package/tsconfig.json +15 -0
- package/validate_readability.py +64 -0
- package/vite.config.ts +30 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createFlockBackendContribution } from "./backend.js";
|
|
3
|
+
import { randomSheepRecipeV1 } from "./shared.js";
|
|
4
|
+
|
|
5
|
+
const sheep = randomSheepRecipeV1(() => 0);
|
|
6
|
+
function request(path: string, body?: unknown) {
|
|
7
|
+
return new Request(`https://bot.example${path}`, {
|
|
8
|
+
method: body ? "POST" : "GET",
|
|
9
|
+
headers: body ? { "content-type": "application/json" } : undefined,
|
|
10
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("Flock gateway Contribution", () => {
|
|
15
|
+
test("maps RPC-serialized decode failures to definitive requests", async () => {
|
|
16
|
+
const contribution = createFlockBackendContribution({
|
|
17
|
+
listBots: () =>
|
|
18
|
+
Promise.resolve({ schemaVersion: 1, revision: 0, bots: [] }),
|
|
19
|
+
createBot: () =>
|
|
20
|
+
Promise.reject({ name: "FlockDecodeError", message: "collision" }),
|
|
21
|
+
listBotLifecycles: () =>
|
|
22
|
+
Promise.resolve({ schemaVersion: 1, lifecycles: [] }),
|
|
23
|
+
executeBotLifecycle: () => Promise.reject(new Error("not used")),
|
|
24
|
+
readSheep: () => Promise.reject(new Error("not used")),
|
|
25
|
+
updateSheep: () => Promise.reject(new Error("not used")),
|
|
26
|
+
listBotIdentities: () =>
|
|
27
|
+
Promise.resolve({ schemaVersion: 1 as const, identities: [] }),
|
|
28
|
+
listBotUnread: () =>
|
|
29
|
+
Promise.resolve({ schemaVersion: 1 as const, unread: [] }),
|
|
30
|
+
listBotNotifications: () =>
|
|
31
|
+
Promise.resolve({ schemaVersion: 1 as const, notifications: [] }),
|
|
32
|
+
executeBotUnreadCommand: () => Promise.reject(new Error("not used")),
|
|
33
|
+
});
|
|
34
|
+
const response = await contribution.route(
|
|
35
|
+
request("/api/bots", {
|
|
36
|
+
schemaVersion: 1,
|
|
37
|
+
type: "bot/create",
|
|
38
|
+
commandId: "create-1",
|
|
39
|
+
expectedRevision: 0,
|
|
40
|
+
botId: "alpha",
|
|
41
|
+
name: "Alpha",
|
|
42
|
+
sheep,
|
|
43
|
+
}),
|
|
44
|
+
new URL("https://bot.example/api/bots"),
|
|
45
|
+
{ userId: "user-1", client: "browser" },
|
|
46
|
+
);
|
|
47
|
+
expect(response?.status).toBe(400);
|
|
48
|
+
expect(await response?.json()).toEqual({
|
|
49
|
+
error: "Flock request is invalid",
|
|
50
|
+
code: "invalid-request",
|
|
51
|
+
definitive: true,
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("routes exact authenticated create/read/update DTOs", async () => {
|
|
56
|
+
const contribution = createFlockBackendContribution({
|
|
57
|
+
listBots: () =>
|
|
58
|
+
Promise.resolve({ schemaVersion: 1, revision: 0, bots: [] }),
|
|
59
|
+
createBot: (_user, command) =>
|
|
60
|
+
Promise.resolve({
|
|
61
|
+
schemaVersion: 1,
|
|
62
|
+
commandId: command.commandId,
|
|
63
|
+
status: "applied",
|
|
64
|
+
revision: 1,
|
|
65
|
+
}),
|
|
66
|
+
listBotLifecycles: () =>
|
|
67
|
+
Promise.resolve({
|
|
68
|
+
schemaVersion: 1,
|
|
69
|
+
lifecycles: [
|
|
70
|
+
{ schemaVersion: 1, botId: "alpha", status: "active", revision: 0 },
|
|
71
|
+
],
|
|
72
|
+
}),
|
|
73
|
+
executeBotLifecycle: (_user, command) =>
|
|
74
|
+
Promise.resolve({
|
|
75
|
+
schemaVersion: 1,
|
|
76
|
+
commandId: command.commandId,
|
|
77
|
+
botId: command.botId,
|
|
78
|
+
status: "applied",
|
|
79
|
+
lifecycle: {
|
|
80
|
+
schemaVersion: 1,
|
|
81
|
+
botId: command.botId,
|
|
82
|
+
status: command.type === "bot/archive" ? "archived" : "active",
|
|
83
|
+
revision: 1,
|
|
84
|
+
},
|
|
85
|
+
}),
|
|
86
|
+
readSheep: (_user, botId) =>
|
|
87
|
+
Promise.resolve({ schemaVersion: 1, botId, revision: 0, sheep }),
|
|
88
|
+
updateSheep: (_user, _bot, command) =>
|
|
89
|
+
Promise.resolve({
|
|
90
|
+
schemaVersion: 1,
|
|
91
|
+
commandId: command.commandId,
|
|
92
|
+
status: "applied",
|
|
93
|
+
revision: 1,
|
|
94
|
+
}),
|
|
95
|
+
listBotIdentities: () =>
|
|
96
|
+
Promise.resolve({
|
|
97
|
+
schemaVersion: 1 as const,
|
|
98
|
+
identities: [
|
|
99
|
+
{
|
|
100
|
+
schemaVersion: 1 as const,
|
|
101
|
+
botId: "alpha",
|
|
102
|
+
name: "Alpha",
|
|
103
|
+
namedBy: "user" as const,
|
|
104
|
+
hiddenFromSidebar: false,
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
}),
|
|
108
|
+
listBotUnread: () =>
|
|
109
|
+
Promise.resolve({ schemaVersion: 1 as const, unread: [] }),
|
|
110
|
+
listBotNotifications: () =>
|
|
111
|
+
Promise.resolve({ schemaVersion: 1 as const, notifications: [] }),
|
|
112
|
+
executeBotUnreadCommand: (_user, botId, command) =>
|
|
113
|
+
Promise.resolve({
|
|
114
|
+
schemaVersion: 1 as const,
|
|
115
|
+
commandId: command.commandId,
|
|
116
|
+
status: "applied" as const,
|
|
117
|
+
unread: {
|
|
118
|
+
schemaVersion: 1 as const,
|
|
119
|
+
botId,
|
|
120
|
+
count: 0,
|
|
121
|
+
capped: false,
|
|
122
|
+
unread: command.type === "bot/mark-unread",
|
|
123
|
+
manuallyUnread: command.type === "bot/mark-unread",
|
|
124
|
+
},
|
|
125
|
+
}),
|
|
126
|
+
});
|
|
127
|
+
const context = { userId: "user-1", client: "browser" as const };
|
|
128
|
+
expect(
|
|
129
|
+
(
|
|
130
|
+
await contribution.route(
|
|
131
|
+
request("/api/bots"),
|
|
132
|
+
new URL("https://bot.example/api/bots"),
|
|
133
|
+
context,
|
|
134
|
+
)
|
|
135
|
+
)?.status,
|
|
136
|
+
).toBe(200);
|
|
137
|
+
const create = {
|
|
138
|
+
schemaVersion: 1,
|
|
139
|
+
type: "bot/create",
|
|
140
|
+
commandId: "create-1",
|
|
141
|
+
expectedRevision: 0,
|
|
142
|
+
botId: "alpha",
|
|
143
|
+
name: "Alpha",
|
|
144
|
+
sheep,
|
|
145
|
+
};
|
|
146
|
+
expect(
|
|
147
|
+
(
|
|
148
|
+
await contribution.route(
|
|
149
|
+
request("/api/bots", create),
|
|
150
|
+
new URL("https://bot.example/api/bots"),
|
|
151
|
+
context,
|
|
152
|
+
)
|
|
153
|
+
)?.status,
|
|
154
|
+
).toBe(201);
|
|
155
|
+
const archive = await contribution.route(
|
|
156
|
+
request("/api/bots/alpha/lifecycle", {
|
|
157
|
+
schemaVersion: 1,
|
|
158
|
+
type: "bot/archive",
|
|
159
|
+
commandId: "archive-1",
|
|
160
|
+
botId: "alpha",
|
|
161
|
+
}),
|
|
162
|
+
new URL("https://bot.example/api/bots/alpha/lifecycle"),
|
|
163
|
+
context,
|
|
164
|
+
);
|
|
165
|
+
expect(archive?.status).toBe(200);
|
|
166
|
+
expect(await archive?.json()).toMatchObject({
|
|
167
|
+
status: "applied",
|
|
168
|
+
lifecycle: { status: "archived" },
|
|
169
|
+
});
|
|
170
|
+
const invalid = await contribution.route(
|
|
171
|
+
request("/api/bots", { ...create, extra: true }),
|
|
172
|
+
new URL("https://bot.example/api/bots"),
|
|
173
|
+
context,
|
|
174
|
+
);
|
|
175
|
+
expect(invalid?.status).toBe(400);
|
|
176
|
+
expect(await invalid?.json()).toMatchObject({ definitive: true });
|
|
177
|
+
const invalidBotId = await contribution.route(
|
|
178
|
+
request("/api/bots", { ...create, botId: "bad@bot" }),
|
|
179
|
+
new URL("https://bot.example/api/bots"),
|
|
180
|
+
context,
|
|
181
|
+
);
|
|
182
|
+
expect(invalidBotId?.status).toBe(400);
|
|
183
|
+
expect(await invalidBotId?.json()).toMatchObject({
|
|
184
|
+
code: "invalid-request",
|
|
185
|
+
definitive: true,
|
|
186
|
+
});
|
|
187
|
+
expect(
|
|
188
|
+
await contribution.route(
|
|
189
|
+
request("/api/bots"),
|
|
190
|
+
new URL("https://bot.example/api/bots"),
|
|
191
|
+
{ client: "browser" },
|
|
192
|
+
),
|
|
193
|
+
).toBeUndefined();
|
|
194
|
+
|
|
195
|
+
const identities = await contribution.route(
|
|
196
|
+
request("/api/bots/identities"),
|
|
197
|
+
new URL("https://bot.example/api/bots/identities"),
|
|
198
|
+
context,
|
|
199
|
+
);
|
|
200
|
+
expect(identities?.status).toBe(200);
|
|
201
|
+
expect(await identities?.json()).toMatchObject({
|
|
202
|
+
identities: [{ botId: "alpha", name: "Alpha", namedBy: "user" }],
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
});
|
package/src/backend.ts
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import type { Plugin } from "cordis";
|
|
2
|
+
import {
|
|
3
|
+
BotNotFoundError,
|
|
4
|
+
FlockConflictError,
|
|
5
|
+
isFlockIdentifier,
|
|
6
|
+
FlockDecodeError,
|
|
7
|
+
decodeBotLifecycleCommandV1,
|
|
8
|
+
decodeCreateBotCommandV1,
|
|
9
|
+
decodeUpdateSheepCommandV1,
|
|
10
|
+
type BotDirectoryViewV1,
|
|
11
|
+
type BotIdentityDirectoryViewV1,
|
|
12
|
+
type BotLifecycleCommandV1,
|
|
13
|
+
type BotLifecycleDirectoryViewV1,
|
|
14
|
+
type BotLifecycleReceiptV1,
|
|
15
|
+
type CreateBotCommandV1,
|
|
16
|
+
type FlockReceiptV1,
|
|
17
|
+
type SheepIdentityViewV1,
|
|
18
|
+
type UpdateSheepCommandV1,
|
|
19
|
+
} from "./shared.js";
|
|
20
|
+
import {
|
|
21
|
+
decodeBotUnreadCommandV1,
|
|
22
|
+
UnreadDecodeError,
|
|
23
|
+
type BotNotificationDirectoryViewV1,
|
|
24
|
+
type BotUnreadCommandV1,
|
|
25
|
+
type BotUnreadDirectoryViewV1,
|
|
26
|
+
type BotUnreadReceiptV1,
|
|
27
|
+
} from "@frockbot/plugin-shell/unread";
|
|
28
|
+
|
|
29
|
+
export interface FlockGatewayHost {
|
|
30
|
+
listBots(userId: string): Promise<BotDirectoryViewV1>;
|
|
31
|
+
createBot(
|
|
32
|
+
userId: string,
|
|
33
|
+
command: CreateBotCommandV1,
|
|
34
|
+
): Promise<FlockReceiptV1>;
|
|
35
|
+
listBotLifecycles(userId: string): Promise<BotLifecycleDirectoryViewV1>;
|
|
36
|
+
executeBotLifecycle(
|
|
37
|
+
userId: string,
|
|
38
|
+
command: BotLifecycleCommandV1,
|
|
39
|
+
): Promise<BotLifecycleReceiptV1>;
|
|
40
|
+
readSheep(userId: string, botId: string): Promise<SheepIdentityViewV1>;
|
|
41
|
+
updateSheep(
|
|
42
|
+
userId: string,
|
|
43
|
+
botId: string,
|
|
44
|
+
command: UpdateSheepCommandV1,
|
|
45
|
+
): Promise<FlockReceiptV1>;
|
|
46
|
+
/** The live identity of every registered Bot, read through to its owner. */
|
|
47
|
+
listBotIdentities(userId: string): Promise<BotIdentityDirectoryViewV1>;
|
|
48
|
+
/**
|
|
49
|
+
* Unread state for every non-archived Bot, through the same bounded fan-out
|
|
50
|
+
* the identity directory uses: one round trip for the whole sidebar.
|
|
51
|
+
*/
|
|
52
|
+
listBotUnread(userId: string): Promise<BotUnreadDirectoryViewV1>;
|
|
53
|
+
/**
|
|
54
|
+
* Pending notification intents across every non-archived Bot, so a
|
|
55
|
+
* completion on a Bot the User is not looking at still surfaces.
|
|
56
|
+
*/
|
|
57
|
+
listBotNotifications(userId: string): Promise<BotNotificationDirectoryViewV1>;
|
|
58
|
+
/** `bot/mark-read` / `bot/mark-unread`, applied by the Bot Durable Object. */
|
|
59
|
+
executeBotUnreadCommand(
|
|
60
|
+
userId: string,
|
|
61
|
+
botId: string,
|
|
62
|
+
command: BotUnreadCommandV1,
|
|
63
|
+
): Promise<BotUnreadReceiptV1>;
|
|
64
|
+
}
|
|
65
|
+
export interface FlockBackendRouteContribution {
|
|
66
|
+
packageId: string;
|
|
67
|
+
route(
|
|
68
|
+
request: Request,
|
|
69
|
+
url: URL,
|
|
70
|
+
context: { userId?: string; client: "browser" | "desktop" },
|
|
71
|
+
): Promise<Response | undefined>;
|
|
72
|
+
}
|
|
73
|
+
function errorResponse(error: unknown): Response {
|
|
74
|
+
if (
|
|
75
|
+
error instanceof FlockDecodeError ||
|
|
76
|
+
error instanceof UnreadDecodeError ||
|
|
77
|
+
(typeof error === "object" &&
|
|
78
|
+
error !== null &&
|
|
79
|
+
"name" in error &&
|
|
80
|
+
(error.name === "FlockDecodeError" || error.name === "UnreadDecodeError"))
|
|
81
|
+
)
|
|
82
|
+
return Response.json(
|
|
83
|
+
{
|
|
84
|
+
error:
|
|
85
|
+
error instanceof Error ? error.message : "Flock request is invalid",
|
|
86
|
+
code: "invalid-request",
|
|
87
|
+
definitive: true,
|
|
88
|
+
},
|
|
89
|
+
{ status: 400 },
|
|
90
|
+
);
|
|
91
|
+
if (
|
|
92
|
+
error instanceof BotNotFoundError ||
|
|
93
|
+
(typeof error === "object" &&
|
|
94
|
+
error !== null &&
|
|
95
|
+
"name" in error &&
|
|
96
|
+
error.name === "BotNotFoundError")
|
|
97
|
+
)
|
|
98
|
+
return Response.json(
|
|
99
|
+
{
|
|
100
|
+
error: error instanceof Error ? error.message : "Bot not found",
|
|
101
|
+
code: "bot-not-found",
|
|
102
|
+
definitive: true,
|
|
103
|
+
},
|
|
104
|
+
{ status: 404 },
|
|
105
|
+
);
|
|
106
|
+
if (
|
|
107
|
+
error instanceof FlockConflictError ||
|
|
108
|
+
(typeof error === "object" &&
|
|
109
|
+
error !== null &&
|
|
110
|
+
"name" in error &&
|
|
111
|
+
error.name === "FlockConflictError")
|
|
112
|
+
) {
|
|
113
|
+
const currentRevision =
|
|
114
|
+
typeof error === "object" &&
|
|
115
|
+
error !== null &&
|
|
116
|
+
"currentRevision" in error &&
|
|
117
|
+
typeof error.currentRevision === "number"
|
|
118
|
+
? error.currentRevision
|
|
119
|
+
: 0;
|
|
120
|
+
return Response.json(
|
|
121
|
+
{
|
|
122
|
+
error: `flock revision is ${currentRevision}`,
|
|
123
|
+
code: "revision-conflict",
|
|
124
|
+
currentRevision,
|
|
125
|
+
definitive: true,
|
|
126
|
+
},
|
|
127
|
+
{ status: 409 },
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
return Response.json(
|
|
131
|
+
{ error: error instanceof Error ? error.message : "Flock request failed" },
|
|
132
|
+
{ status: 500 },
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
function decodePathId(value: string): string {
|
|
136
|
+
let decoded: string;
|
|
137
|
+
try {
|
|
138
|
+
decoded = decodeURIComponent(value);
|
|
139
|
+
} catch {
|
|
140
|
+
throw new FlockDecodeError("botId is invalid");
|
|
141
|
+
}
|
|
142
|
+
if (!isFlockIdentifier(decoded))
|
|
143
|
+
throw new FlockDecodeError("botId is invalid");
|
|
144
|
+
return decoded;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function createFlockBackendContribution(
|
|
148
|
+
host: FlockGatewayHost,
|
|
149
|
+
): FlockBackendRouteContribution {
|
|
150
|
+
return {
|
|
151
|
+
packageId: "flock",
|
|
152
|
+
async route(request, url, context) {
|
|
153
|
+
if (!context.userId) return undefined;
|
|
154
|
+
const sheep = url.pathname.match(/^\/api\/bots\/([^/]+)\/sheep$/);
|
|
155
|
+
const unread = url.pathname.match(/^\/api\/bots\/([^/]+)\/unread$/);
|
|
156
|
+
const lifecycle = url.pathname.match(/^\/api\/bots\/([^/]+)\/lifecycle$/);
|
|
157
|
+
if (
|
|
158
|
+
url.pathname !== "/api/bots" &&
|
|
159
|
+
url.pathname !== "/api/bots/lifecycles" &&
|
|
160
|
+
url.pathname !== "/api/bots/identities" &&
|
|
161
|
+
url.pathname !== "/api/bots/unread" &&
|
|
162
|
+
url.pathname !== "/api/bots/notifications" &&
|
|
163
|
+
!sheep &&
|
|
164
|
+
!unread &&
|
|
165
|
+
!lifecycle
|
|
166
|
+
)
|
|
167
|
+
return undefined;
|
|
168
|
+
try {
|
|
169
|
+
if (url.pathname === "/api/bots/identities") {
|
|
170
|
+
if (request.method !== "GET")
|
|
171
|
+
return Response.json(
|
|
172
|
+
{ error: "method not allowed" },
|
|
173
|
+
{ status: 405 },
|
|
174
|
+
);
|
|
175
|
+
return Response.json(await host.listBotIdentities(context.userId));
|
|
176
|
+
}
|
|
177
|
+
if (url.pathname === "/api/bots/unread") {
|
|
178
|
+
if (request.method !== "GET")
|
|
179
|
+
return Response.json(
|
|
180
|
+
{ error: "method not allowed" },
|
|
181
|
+
{ status: 405 },
|
|
182
|
+
);
|
|
183
|
+
return Response.json(await host.listBotUnread(context.userId));
|
|
184
|
+
}
|
|
185
|
+
if (url.pathname === "/api/bots/notifications") {
|
|
186
|
+
if (request.method !== "GET")
|
|
187
|
+
return Response.json(
|
|
188
|
+
{ error: "method not allowed" },
|
|
189
|
+
{ status: 405 },
|
|
190
|
+
);
|
|
191
|
+
return Response.json(await host.listBotNotifications(context.userId));
|
|
192
|
+
}
|
|
193
|
+
if (unread) {
|
|
194
|
+
const botId = decodePathId(unread[1]!);
|
|
195
|
+
if (request.method !== "POST")
|
|
196
|
+
return Response.json(
|
|
197
|
+
{ error: "method not allowed" },
|
|
198
|
+
{ status: 405 },
|
|
199
|
+
);
|
|
200
|
+
const command = decodeBotUnreadCommandV1(await request.json());
|
|
201
|
+
if (command.botId !== botId)
|
|
202
|
+
throw new FlockDecodeError(
|
|
203
|
+
"unread command does not match request path",
|
|
204
|
+
);
|
|
205
|
+
return Response.json(
|
|
206
|
+
await host.executeBotUnreadCommand(context.userId, botId, command),
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
if (url.pathname === "/api/bots/lifecycles") {
|
|
210
|
+
if (request.method !== "GET")
|
|
211
|
+
return Response.json(
|
|
212
|
+
{ error: "method not allowed" },
|
|
213
|
+
{ status: 405 },
|
|
214
|
+
);
|
|
215
|
+
return Response.json(await host.listBotLifecycles(context.userId));
|
|
216
|
+
}
|
|
217
|
+
if (url.pathname === "/api/bots") {
|
|
218
|
+
if (request.method === "GET")
|
|
219
|
+
return Response.json(await host.listBots(context.userId));
|
|
220
|
+
if (request.method !== "POST")
|
|
221
|
+
return Response.json(
|
|
222
|
+
{ error: "method not allowed" },
|
|
223
|
+
{ status: 405 },
|
|
224
|
+
);
|
|
225
|
+
return Response.json(
|
|
226
|
+
await host.createBot(
|
|
227
|
+
context.userId,
|
|
228
|
+
decodeCreateBotCommandV1(await request.json()),
|
|
229
|
+
),
|
|
230
|
+
{ status: 201 },
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
if (lifecycle) {
|
|
234
|
+
const botId = decodePathId(lifecycle[1]!);
|
|
235
|
+
if (request.method !== "POST")
|
|
236
|
+
return Response.json(
|
|
237
|
+
{ error: "method not allowed" },
|
|
238
|
+
{ status: 405 },
|
|
239
|
+
);
|
|
240
|
+
const command = decodeBotLifecycleCommandV1(await request.json());
|
|
241
|
+
if (command.botId !== botId)
|
|
242
|
+
throw new FlockDecodeError(
|
|
243
|
+
"lifecycle command does not match request path",
|
|
244
|
+
);
|
|
245
|
+
const receipt = await host.executeBotLifecycle(
|
|
246
|
+
context.userId,
|
|
247
|
+
command,
|
|
248
|
+
);
|
|
249
|
+
return Response.json(receipt, {
|
|
250
|
+
status: receipt.status === "pending" ? 202 : 200,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
const botId = decodePathId(sheep![1]!);
|
|
254
|
+
if (request.method === "GET")
|
|
255
|
+
return Response.json(await host.readSheep(context.userId, botId));
|
|
256
|
+
if (request.method !== "POST")
|
|
257
|
+
return Response.json(
|
|
258
|
+
{ error: "method not allowed" },
|
|
259
|
+
{ status: 405 },
|
|
260
|
+
);
|
|
261
|
+
const command = decodeUpdateSheepCommandV1(await request.json());
|
|
262
|
+
if (command.botId !== botId)
|
|
263
|
+
throw new FlockDecodeError(
|
|
264
|
+
"sheep command does not match request path",
|
|
265
|
+
);
|
|
266
|
+
return Response.json(
|
|
267
|
+
await host.updateSheep(context.userId, botId, command),
|
|
268
|
+
);
|
|
269
|
+
} catch (error) {
|
|
270
|
+
return errorResponse(error);
|
|
271
|
+
}
|
|
272
|
+
},
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export namespace createFlockBackendContribution {
|
|
277
|
+
export function plugin(
|
|
278
|
+
host: FlockGatewayHost,
|
|
279
|
+
lifecycle: { mount(value: FlockBackendRouteContribution): () => void },
|
|
280
|
+
): Plugin {
|
|
281
|
+
return () => lifecycle.mount(createFlockBackendContribution(host));
|
|
282
|
+
}
|
|
283
|
+
}
|
package/src/bot.test.ts
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createFlockBotBackendContribution } from "./bot.js";
|
|
3
|
+
import { FlockConflictError, randomSheepRecipeV1 } from "./shared.js";
|
|
4
|
+
|
|
5
|
+
class MemoryStorage {
|
|
6
|
+
values = new Map<string, unknown>();
|
|
7
|
+
get<T>(key: string): Promise<T | undefined> {
|
|
8
|
+
return Promise.resolve(
|
|
9
|
+
structuredClone(this.values.get(key)) as T | undefined,
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
put<T>(key: string | Record<string, unknown>, value?: T): Promise<void> {
|
|
13
|
+
if (typeof key === "string") this.values.set(key, structuredClone(value));
|
|
14
|
+
else
|
|
15
|
+
for (const [name, entry] of Object.entries(key))
|
|
16
|
+
this.values.set(name, structuredClone(entry));
|
|
17
|
+
return Promise.resolve();
|
|
18
|
+
}
|
|
19
|
+
transaction<T>(callback: (storage: MemoryStorage) => Promise<T>): Promise<T> {
|
|
20
|
+
return callback(this);
|
|
21
|
+
}
|
|
22
|
+
list<T>({ prefix }: { prefix: string }): Promise<Map<string, T>> {
|
|
23
|
+
return Promise.resolve(
|
|
24
|
+
new Map(
|
|
25
|
+
[...this.values.entries()].filter(([key]) =>
|
|
26
|
+
key.startsWith(prefix),
|
|
27
|
+
) as Array<[string, T]>,
|
|
28
|
+
),
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const registration = {
|
|
33
|
+
schemaVersion: 1 as const,
|
|
34
|
+
botId: "alpha",
|
|
35
|
+
registeredAt: "2026-08-29T00:00:00.000Z",
|
|
36
|
+
initialName: "Alpha",
|
|
37
|
+
sheep: randomSheepRecipeV1(() => 0),
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
describe("Flock Bot contribution", () => {
|
|
41
|
+
test("materializes once and replays sheep updates after reconstruction", async () => {
|
|
42
|
+
const storage = new MemoryStorage();
|
|
43
|
+
let settingsMaterializations = 0;
|
|
44
|
+
const create = () =>
|
|
45
|
+
createFlockBotBackendContribution({
|
|
46
|
+
storage,
|
|
47
|
+
materializeSettings: () => {
|
|
48
|
+
settingsMaterializations += 1;
|
|
49
|
+
return Promise.resolve();
|
|
50
|
+
},
|
|
51
|
+
archiveEligible: () => Promise.resolve(true),
|
|
52
|
+
});
|
|
53
|
+
expect(await create().read(registration, "user-1")).toMatchObject({
|
|
54
|
+
botId: "alpha",
|
|
55
|
+
revision: 0,
|
|
56
|
+
});
|
|
57
|
+
const nextSheep = randomSheepRecipeV1(() => 0.8);
|
|
58
|
+
const command = {
|
|
59
|
+
schemaVersion: 1 as const,
|
|
60
|
+
type: "bot/update-sheep" as const,
|
|
61
|
+
commandId: "sheep-1",
|
|
62
|
+
expectedRevision: 0,
|
|
63
|
+
botId: "alpha",
|
|
64
|
+
sheep: nextSheep,
|
|
65
|
+
};
|
|
66
|
+
const first = await create().update(registration, "user-1", command);
|
|
67
|
+
expect(await create().update(registration, "user-1", command)).toEqual(
|
|
68
|
+
first,
|
|
69
|
+
);
|
|
70
|
+
expect(await create().read(registration, "user-1")).toMatchObject({
|
|
71
|
+
revision: 1,
|
|
72
|
+
sheep: nextSheep,
|
|
73
|
+
});
|
|
74
|
+
await expect(
|
|
75
|
+
create().update(registration, "user-1", {
|
|
76
|
+
...command,
|
|
77
|
+
commandId: "stale",
|
|
78
|
+
expectedRevision: 0,
|
|
79
|
+
}),
|
|
80
|
+
).rejects.toBeInstanceOf(FlockConflictError);
|
|
81
|
+
await expect(
|
|
82
|
+
create().update(registration, "user-1", {
|
|
83
|
+
...command,
|
|
84
|
+
sheep: registration.sheep,
|
|
85
|
+
}),
|
|
86
|
+
).rejects.toThrow("command ID collision");
|
|
87
|
+
expect(settingsMaterializations).toBeGreaterThan(0);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("archives idempotently, fences mutations, rejects active work, and restores data", async () => {
|
|
91
|
+
const storage = new MemoryStorage();
|
|
92
|
+
let eligible = true;
|
|
93
|
+
const create = () =>
|
|
94
|
+
createFlockBotBackendContribution({
|
|
95
|
+
storage,
|
|
96
|
+
materializeSettings: () => Promise.resolve(),
|
|
97
|
+
archiveEligible: () => Promise.resolve(eligible),
|
|
98
|
+
});
|
|
99
|
+
const contribution = create();
|
|
100
|
+
await contribution.read(registration, "user-1");
|
|
101
|
+
const archive = {
|
|
102
|
+
schemaVersion: 1 as const,
|
|
103
|
+
type: "bot/archive" as const,
|
|
104
|
+
commandId: "archive-1",
|
|
105
|
+
botId: "alpha",
|
|
106
|
+
};
|
|
107
|
+
eligible = false;
|
|
108
|
+
expect(
|
|
109
|
+
await contribution.executeLifecycle(registration, "user-1", archive),
|
|
110
|
+
).toMatchObject({ status: "rejected", lifecycle: { status: "active" } });
|
|
111
|
+
eligible = true;
|
|
112
|
+
const applied = await contribution.executeLifecycle(
|
|
113
|
+
registration,
|
|
114
|
+
"user-1",
|
|
115
|
+
{
|
|
116
|
+
...archive,
|
|
117
|
+
commandId: "archive-2",
|
|
118
|
+
},
|
|
119
|
+
);
|
|
120
|
+
expect(applied).toMatchObject({
|
|
121
|
+
status: "applied",
|
|
122
|
+
lifecycle: { status: "archived" },
|
|
123
|
+
});
|
|
124
|
+
expect(
|
|
125
|
+
await contribution.executeLifecycle(registration, "user-1", {
|
|
126
|
+
...archive,
|
|
127
|
+
commandId: "archive-2",
|
|
128
|
+
}),
|
|
129
|
+
).toEqual(applied);
|
|
130
|
+
const reconstructed = create();
|
|
131
|
+
expect(
|
|
132
|
+
await reconstructed.readLifecycle(registration, "user-1"),
|
|
133
|
+
).toMatchObject({ status: "archived" });
|
|
134
|
+
await expect(
|
|
135
|
+
reconstructed.update(registration, "user-1", {
|
|
136
|
+
schemaVersion: 1,
|
|
137
|
+
type: "bot/update-sheep",
|
|
138
|
+
commandId: "sheep-archived",
|
|
139
|
+
expectedRevision: 0,
|
|
140
|
+
botId: "alpha",
|
|
141
|
+
sheep: registration.sheep,
|
|
142
|
+
}),
|
|
143
|
+
).rejects.toThrow("archived");
|
|
144
|
+
await reconstructed.executeLifecycle(registration, "user-1", {
|
|
145
|
+
schemaVersion: 1,
|
|
146
|
+
type: "bot/restore",
|
|
147
|
+
commandId: "restore-1",
|
|
148
|
+
botId: "alpha",
|
|
149
|
+
});
|
|
150
|
+
expect(await contribution.read(registration, "user-1")).toMatchObject({
|
|
151
|
+
revision: 0,
|
|
152
|
+
sheep: registration.sheep,
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("rejects malformed durable sheep identity and receipt records", async () => {
|
|
157
|
+
const storage = new MemoryStorage();
|
|
158
|
+
const contribution = createFlockBotBackendContribution({
|
|
159
|
+
storage,
|
|
160
|
+
materializeSettings: () => Promise.resolve(),
|
|
161
|
+
archiveEligible: () => Promise.resolve(true),
|
|
162
|
+
});
|
|
163
|
+
await storage.put("flock:sheep:v1", {
|
|
164
|
+
schemaVersion: 1,
|
|
165
|
+
botId: "alpha",
|
|
166
|
+
revision: 0,
|
|
167
|
+
sheep: registration.sheep,
|
|
168
|
+
unknown: true,
|
|
169
|
+
});
|
|
170
|
+
await expect(contribution.read(registration, "user-1")).rejects.toThrow(
|
|
171
|
+
"unknown or missing field",
|
|
172
|
+
);
|
|
173
|
+
storage.values.delete("flock:sheep:v1");
|
|
174
|
+
await contribution.read(registration, "user-1");
|
|
175
|
+
await storage.put("flock:sheep-receipt:sheep-1", {
|
|
176
|
+
fingerprint: "{}",
|
|
177
|
+
receipt: { schemaVersion: 1, status: "applied" },
|
|
178
|
+
});
|
|
179
|
+
await expect(
|
|
180
|
+
contribution.update(registration, "user-1", {
|
|
181
|
+
schemaVersion: 1,
|
|
182
|
+
type: "bot/update-sheep",
|
|
183
|
+
commandId: "sheep-1",
|
|
184
|
+
expectedRevision: 0,
|
|
185
|
+
botId: "alpha",
|
|
186
|
+
sheep: registration.sheep,
|
|
187
|
+
}),
|
|
188
|
+
).rejects.toThrow("unknown or missing field");
|
|
189
|
+
});
|
|
190
|
+
});
|