@frockbot/plugin-flock 0.1.4 → 0.2.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/package.json +7 -7
- package/src/shared.test.ts +73 -0
- package/src/shared.ts +51 -0
- package/src/user.test.ts +47 -0
- package/src/user.ts +6 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/plugin-flock",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
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.1
|
|
31
|
-
"@frockbot/client-ui": "0.1
|
|
32
|
-
"@frockbot/configuration-core": "0.1
|
|
33
|
-
"@frockbot/kernel-contracts": "0.1
|
|
34
|
-
"@frockbot/plugin-shell": "0.1
|
|
30
|
+
"@frockbot/client-core": "0.2.1",
|
|
31
|
+
"@frockbot/client-ui": "0.2.1",
|
|
32
|
+
"@frockbot/configuration-core": "0.2.1",
|
|
33
|
+
"@frockbot/kernel-contracts": "0.2.1",
|
|
34
|
+
"@frockbot/plugin-shell": "0.2.1",
|
|
35
35
|
"cordis": "4.0.0-rc.8",
|
|
36
36
|
"vue": "3.5.41"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
|
-
"@frockbot/plugin-testkit": "0.1
|
|
39
|
+
"@frockbot/plugin-testkit": "0.2.1",
|
|
40
40
|
"@types/bun": "1.4.0",
|
|
41
41
|
"@vitejs/plugin-vue": "6.0.8",
|
|
42
42
|
"css-tree": "2.3.1",
|
package/src/shared.test.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
decodeCreateBotCommandV1,
|
|
10
10
|
decodeDirectoryViewV1,
|
|
11
11
|
decodeSheepRecipeV1,
|
|
12
|
+
migrateStoredBotDirectoryV1,
|
|
12
13
|
randomSheepRecipeV1,
|
|
13
14
|
sheepCatalog,
|
|
14
15
|
sheepLayerIds,
|
|
@@ -184,3 +185,75 @@ describe("Flock v1 contracts", () => {
|
|
|
184
185
|
).toThrow("duplicate IDs");
|
|
185
186
|
});
|
|
186
187
|
});
|
|
188
|
+
|
|
189
|
+
describe("stored Bot directory migration", () => {
|
|
190
|
+
const sheep = randomSheepRecipeV1(() => 0);
|
|
191
|
+
const legacyBot = () => ({
|
|
192
|
+
schemaVersion: 1,
|
|
193
|
+
botId: "alpha",
|
|
194
|
+
registeredAt: "2026-08-29T00:00:00.000Z",
|
|
195
|
+
initialName: "Alpha",
|
|
196
|
+
initialModel: { connectionId: "openai", providerModelId: "gpt-5" },
|
|
197
|
+
initialModelBinding: {
|
|
198
|
+
assignment: {
|
|
199
|
+
assignmentId: "assignment-1",
|
|
200
|
+
packageId: "models",
|
|
201
|
+
capabilityId: "chat",
|
|
202
|
+
connectionId: "openai",
|
|
203
|
+
state: "enabled",
|
|
204
|
+
},
|
|
205
|
+
generation: "generation-1",
|
|
206
|
+
},
|
|
207
|
+
initialAssignments: [],
|
|
208
|
+
sheep,
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("drops the retired model and Assignment seed fields", () => {
|
|
212
|
+
const stored = { schemaVersion: 1, revision: 1, bots: [legacyBot()] };
|
|
213
|
+
// Without migration this is exactly the failure the sidebar reported.
|
|
214
|
+
expect(() => decodeDirectoryViewV1(stored)).toThrow(
|
|
215
|
+
"unknown or missing field",
|
|
216
|
+
);
|
|
217
|
+
const directory = decodeDirectoryViewV1(
|
|
218
|
+
migrateStoredBotDirectoryV1(stored),
|
|
219
|
+
);
|
|
220
|
+
expect(directory.revision).toBe(1);
|
|
221
|
+
const [bot] = directory.bots;
|
|
222
|
+
expect(bot).toMatchObject({ botId: "alpha", initialName: "Alpha" });
|
|
223
|
+
for (const key of [
|
|
224
|
+
"initialModel",
|
|
225
|
+
"initialModelBinding",
|
|
226
|
+
"initialAssignments",
|
|
227
|
+
])
|
|
228
|
+
expect(Object.hasOwn(bot!, key)).toBe(false);
|
|
229
|
+
// Migration is read-time: the durable record itself is left alone.
|
|
230
|
+
expect(Object.hasOwn(stored.bots[0]!, "initialModel")).toBe(true);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("returns a current-shape record untouched", () => {
|
|
234
|
+
const current = {
|
|
235
|
+
schemaVersion: 1,
|
|
236
|
+
revision: 0,
|
|
237
|
+
bots: [
|
|
238
|
+
{
|
|
239
|
+
schemaVersion: 1,
|
|
240
|
+
botId: "alpha",
|
|
241
|
+
registeredAt: "2026-08-29T00:00:00.000Z",
|
|
242
|
+
initialName: "Alpha",
|
|
243
|
+
sheep,
|
|
244
|
+
},
|
|
245
|
+
],
|
|
246
|
+
};
|
|
247
|
+
expect(migrateStoredBotDirectoryV1(current)).toBe(current);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("leaves records it does not recognise for the decoder to reject", () => {
|
|
251
|
+
expect(migrateStoredBotDirectoryV1(undefined)).toBeUndefined();
|
|
252
|
+
expect(migrateStoredBotDirectoryV1("nope")).toBe("nope");
|
|
253
|
+
expect(() =>
|
|
254
|
+
decodeDirectoryViewV1(
|
|
255
|
+
migrateStoredBotDirectoryV1({ schemaVersion: 1, revision: 0 }),
|
|
256
|
+
),
|
|
257
|
+
).toThrow("unknown or missing field");
|
|
258
|
+
});
|
|
259
|
+
});
|
package/src/shared.ts
CHANGED
|
@@ -361,6 +361,57 @@ export function decodeBotRegistrationV1(input: unknown): BotRegistrationV1 {
|
|
|
361
361
|
};
|
|
362
362
|
}
|
|
363
363
|
|
|
364
|
+
/**
|
|
365
|
+
* Fields a Bot registration seed carried before per-Bot model bindings and
|
|
366
|
+
* Assignments were removed (commit 03034e0). A directory written before that
|
|
367
|
+
* refactor still holds them on disk, and the exact-field decoder rejects every
|
|
368
|
+
* read of it — which takes out the sidebar and Bot creation together, because
|
|
369
|
+
* both start by decoding the stored directory. Migration drops the fields
|
|
370
|
+
* without interpreting them: a Bot's model and tools now resolve from the
|
|
371
|
+
* User's enabled Packages and Connections at its next admitted Turn (ADR 0019).
|
|
372
|
+
*/
|
|
373
|
+
const PRE_ACCOUNT_WIDE_REGISTRATION_FIELDS_V1 = [
|
|
374
|
+
"initialModel",
|
|
375
|
+
"initialModelBinding",
|
|
376
|
+
"initialAssignments",
|
|
377
|
+
] as const;
|
|
378
|
+
|
|
379
|
+
/** A durable record only ever deserializes to a plain object; nothing else. */
|
|
380
|
+
function storedPlainRecordV1(
|
|
381
|
+
value: unknown,
|
|
382
|
+
): Record<string, unknown> | undefined {
|
|
383
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
384
|
+
return undefined;
|
|
385
|
+
const prototype = Object.getPrototypeOf(value);
|
|
386
|
+
return prototype === Object.prototype || prototype === null
|
|
387
|
+
? (value as Record<string, unknown>)
|
|
388
|
+
: undefined;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Migrates one raw stored Bot directory across known durable shapes before the
|
|
393
|
+
* current exact-field decoder sees it. A record already in the current shape is
|
|
394
|
+
* returned untouched, so this is safe to apply on every read.
|
|
395
|
+
*/
|
|
396
|
+
export function migrateStoredBotDirectoryV1(stored: unknown): unknown {
|
|
397
|
+
const directory = storedPlainRecordV1(stored);
|
|
398
|
+
if (!directory || !Array.isArray(directory.bots)) return stored;
|
|
399
|
+
let changed = false;
|
|
400
|
+
const bots = directory.bots.map((storedBot) => {
|
|
401
|
+
const bot = storedPlainRecordV1(storedBot);
|
|
402
|
+
if (!bot) return storedBot;
|
|
403
|
+
const removed = PRE_ACCOUNT_WIDE_REGISTRATION_FIELDS_V1.filter((key) =>
|
|
404
|
+
Object.hasOwn(bot, key),
|
|
405
|
+
);
|
|
406
|
+
if (removed.length === 0) return storedBot;
|
|
407
|
+
changed = true;
|
|
408
|
+
const next = { ...bot };
|
|
409
|
+
for (const key of removed) delete next[key];
|
|
410
|
+
return next;
|
|
411
|
+
});
|
|
412
|
+
return changed ? { ...directory, bots } : stored;
|
|
413
|
+
}
|
|
414
|
+
|
|
364
415
|
export function decodeBotMembershipViewV1(input: unknown): BotMembershipViewV1 {
|
|
365
416
|
const value = record(input, "Bot membership");
|
|
366
417
|
exact(value, ["schemaVersion", "botId", "registered"]);
|
package/src/user.test.ts
CHANGED
|
@@ -126,6 +126,53 @@ describe("Flock User contribution", () => {
|
|
|
126
126
|
);
|
|
127
127
|
});
|
|
128
128
|
|
|
129
|
+
test("lists and extends a directory stored before Bots lost their model seed", async () => {
|
|
130
|
+
const storage = new MemoryStorage();
|
|
131
|
+
const contribution = createFlockUserBackendContribution({
|
|
132
|
+
storage,
|
|
133
|
+
now: () => new Date("2026-09-02T00:00:00.000Z"),
|
|
134
|
+
commandBotLifecycle: () => Promise.reject(new Error("not used")),
|
|
135
|
+
readBotLifecycle: () => Promise.reject(new Error("not used")),
|
|
136
|
+
});
|
|
137
|
+
await storage.put("flock:directory:v1", {
|
|
138
|
+
schemaVersion: 1,
|
|
139
|
+
revision: 1,
|
|
140
|
+
bots: [
|
|
141
|
+
{
|
|
142
|
+
schemaVersion: 1,
|
|
143
|
+
botId: "legacy",
|
|
144
|
+
registeredAt: "2026-08-29T00:00:00.000Z",
|
|
145
|
+
initialName: "Legacy",
|
|
146
|
+
initialModel: { connectionId: "openai", providerModelId: "gpt-5" },
|
|
147
|
+
initialAssignments: [],
|
|
148
|
+
sheep: randomSheepRecipeV1(() => 0),
|
|
149
|
+
},
|
|
150
|
+
],
|
|
151
|
+
});
|
|
152
|
+
const directory = await contribution.listBots();
|
|
153
|
+
expect(directory.bots.map((bot) => bot.botId)).toEqual(["legacy"]);
|
|
154
|
+
expect(Object.hasOwn(directory.bots[0]!, "initialModel")).toBe(false);
|
|
155
|
+
// Creating a Bot reads the same record, so it died on the same field.
|
|
156
|
+
const receipt = await contribution.createBot(
|
|
157
|
+
"user-1",
|
|
158
|
+
command("create-1", 1),
|
|
159
|
+
);
|
|
160
|
+
expect(receipt.status).toBe("applied");
|
|
161
|
+
const migrated = await contribution.listBots();
|
|
162
|
+
expect(migrated.bots.map((bot) => bot.botId)).toEqual(["legacy", "alpha"]);
|
|
163
|
+
// The write settles the migrated shape durably.
|
|
164
|
+
expect(
|
|
165
|
+
Object.hasOwn(
|
|
166
|
+
(
|
|
167
|
+
storage.values.get("flock:directory:v1") as {
|
|
168
|
+
bots: Record<string, unknown>[];
|
|
169
|
+
}
|
|
170
|
+
).bots[0]!,
|
|
171
|
+
"initialModel",
|
|
172
|
+
),
|
|
173
|
+
).toBe(false);
|
|
174
|
+
});
|
|
175
|
+
|
|
129
176
|
test("coordinates archive through durable intent and reconciles a lost response", async () => {
|
|
130
177
|
const storage = new MemoryStorage();
|
|
131
178
|
let botStatus: "active" | "archived" = "active";
|
package/src/user.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
decodeStoredBotLifecycleReceiptV1,
|
|
12
12
|
decodeStoredFlockReceiptV1,
|
|
13
13
|
flockCommandFingerprint,
|
|
14
|
+
migrateStoredBotDirectoryV1,
|
|
14
15
|
randomSheepRecipeV1,
|
|
15
16
|
type BotDirectoryViewV1,
|
|
16
17
|
type BotLifecycleCommandV1,
|
|
@@ -99,7 +100,9 @@ export class FlockUserBackendContribution {
|
|
|
99
100
|
async listBots(): Promise<BotDirectoryViewV1> {
|
|
100
101
|
const stored = await this.host.storage.get<unknown>(DIRECTORY_KEY);
|
|
101
102
|
return structuredClone(
|
|
102
|
-
stored === undefined
|
|
103
|
+
stored === undefined
|
|
104
|
+
? initialDirectory()
|
|
105
|
+
: decodeDirectoryViewV1(migrateStoredBotDirectoryV1(stored)),
|
|
103
106
|
);
|
|
104
107
|
}
|
|
105
108
|
|
|
@@ -138,7 +141,7 @@ export class FlockUserBackendContribution {
|
|
|
138
141
|
const current =
|
|
139
142
|
currentValue === undefined
|
|
140
143
|
? initialDirectory()
|
|
141
|
-
: decodeDirectoryViewV1(currentValue);
|
|
144
|
+
: decodeDirectoryViewV1(migrateStoredBotDirectoryV1(currentValue));
|
|
142
145
|
if (current.revision !== command.expectedRevision)
|
|
143
146
|
throw new FlockConflictError(current.revision);
|
|
144
147
|
let receipt: FlockReceiptV1;
|
|
@@ -248,7 +251,7 @@ export class FlockUserBackendContribution {
|
|
|
248
251
|
const directory =
|
|
249
252
|
directoryValue === undefined
|
|
250
253
|
? initialDirectory()
|
|
251
|
-
: decodeDirectoryViewV1(directoryValue);
|
|
254
|
+
: decodeDirectoryViewV1(migrateStoredBotDirectoryV1(directoryValue));
|
|
252
255
|
if (!directory.bots.some((bot) => bot.botId === command.botId))
|
|
253
256
|
throw new BotNotFoundError(command.botId);
|
|
254
257
|
const lifecycleValue = await storage.get<unknown>(
|