@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-flock",
3
- "version": "0.1.4",
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.4",
31
- "@frockbot/client-ui": "0.1.4",
32
- "@frockbot/configuration-core": "0.1.4",
33
- "@frockbot/kernel-contracts": "0.1.4",
34
- "@frockbot/plugin-shell": "0.1.4",
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.4",
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",
@@ -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 ? initialDirectory() : decodeDirectoryViewV1(stored),
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>(