@frockbot/configuration-core 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/configuration-core",
3
- "version": "0.1.4",
3
+ "version": "0.2.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -12,8 +12,8 @@
12
12
  "typecheck": "tsc --noEmit -p tsconfig.json"
13
13
  },
14
14
  "dependencies": {
15
- "@frockbot/connection-core": "0.1.4",
16
- "@frockbot/kernel-composition": "0.1.4"
15
+ "@frockbot/connection-core": "0.2.1",
16
+ "@frockbot/kernel-composition": "0.2.1"
17
17
  },
18
18
  "devDependencies": {
19
19
  "@types/bun": "1.3.6",
@@ -1,8 +1,8 @@
1
1
  // Bot identity: the title, the name's provenance, and the sidebar-hidden flag.
2
2
  //
3
3
  // The first test is the one that matters most: a Bot settings record written
4
- // before any of this existed must still decode, because widening a durable DTO
5
- // with optional fields is the only migration this project permits.
4
+ // before any of this existed must still decode. Optional additions need no
5
+ // transform; incompatible stored shapes use explicit forward migrations.
6
6
  import { describe, expect, test } from "bun:test";
7
7
  import {
8
8
  applyBotProfilePatchV1,
package/src/index.test.ts CHANGED
@@ -18,6 +18,8 @@ import {
18
18
  decodeUserConfigurationReadRpcV1,
19
19
  decodeUserSettingsViewV1,
20
20
  initializeBotSettingsV1,
21
+ migrateStoredBotSettingsV1,
22
+ migrateStoredUserSettingsV1,
21
23
  modelBindingFailureV1,
22
24
  resolveBotExecutionPlanV1,
23
25
  resolveBotModelBindingV1,
@@ -32,6 +34,190 @@ import {
32
34
  isRpcIdentifier,
33
35
  } from "./identifiers.js";
34
36
 
37
+ describe("stored configuration migrations", () => {
38
+ test("migrates the pre-account-wide User record without honouring removed features", () => {
39
+ // Literal durable shape from eb0283edcce5daea976a21a9f6a6414bedc6e2bc,
40
+ // the first parent of PR #134's merge commit.
41
+ const stored = {
42
+ schemaVersion: 1,
43
+ revision: 7,
44
+ profile: { name: "Existing User" },
45
+ packages: [
46
+ {
47
+ packageId: "provider-ollama-cloud",
48
+ version: "0.0.1",
49
+ state: "installed" as const,
50
+ values: { "web-search-max-results": 5 },
51
+ },
52
+ ],
53
+ connections: [
54
+ {
55
+ connectionId: "ollama-1",
56
+ packageId: "provider-ollama-cloud",
57
+ connectionTypeId: "ollama-cloud-account",
58
+ displayName: "Work",
59
+ state: "ready" as const,
60
+ safeMetadata: {
61
+ region: "au",
62
+ dependentAssignments: [
63
+ {
64
+ botId: "bot-1",
65
+ generation: "assignment-1",
66
+ packageId: "provider-ollama-cloud",
67
+ capabilityId: "ollama-cloud-models",
68
+ claimOrder: 7,
69
+ status: "acknowledged",
70
+ },
71
+ ],
72
+ },
73
+ },
74
+ ],
75
+ newBotModelTemplate: {
76
+ connectionId: "ollama-1",
77
+ providerModelId: "glm-5.3-flash:cloud",
78
+ },
79
+ newBotModelTemplateSource: "user",
80
+ };
81
+
82
+ expect(
83
+ decodeUserSettingsViewV1(migrateStoredUserSettingsV1(stored)),
84
+ ).toEqual({
85
+ schemaVersion: 1,
86
+ revision: 7,
87
+ profile: { name: "Existing User" },
88
+ packages: stored.packages,
89
+ connections: [
90
+ {
91
+ ...stored.connections[0],
92
+ safeMetadata: { region: "au" },
93
+ },
94
+ ],
95
+ });
96
+ expect(stored.connections[0]?.safeMetadata).toHaveProperty(
97
+ "dependentAssignments",
98
+ );
99
+ });
100
+
101
+ test("keeps unknown User, Package, and Connection fields strict", () => {
102
+ const current = {
103
+ schemaVersion: 1,
104
+ revision: 0,
105
+ profile: { name: "Existing User" },
106
+ packages: [],
107
+ connections: [],
108
+ };
109
+ for (const stored of [
110
+ { ...current, unknown: true },
111
+ {
112
+ ...current,
113
+ packages: [
114
+ {
115
+ packageId: "settings",
116
+ version: "0.0.1",
117
+ state: "installed",
118
+ unknown: true,
119
+ },
120
+ ],
121
+ },
122
+ {
123
+ ...current,
124
+ connections: [
125
+ {
126
+ connectionId: "connection-1",
127
+ packageId: "provider-ollama-cloud",
128
+ connectionTypeId: "ollama-cloud-account",
129
+ displayName: "Work",
130
+ state: "ready",
131
+ safeMetadata: {},
132
+ unknown: true,
133
+ },
134
+ ],
135
+ },
136
+ ]) {
137
+ expect(() =>
138
+ decodeUserSettingsViewV1(migrateStoredUserSettingsV1(stored)),
139
+ ).toThrow(ConfigurationDecodeError);
140
+ }
141
+ expect(migrateStoredUserSettingsV1(current)).toBe(current);
142
+ });
143
+
144
+ test("migrates the pre-account-wide Bot record to an empty Package value bag", () => {
145
+ // Literal durable shape from eb0283edcce5daea976a21a9f6a6414bedc6e2bc,
146
+ // the first parent of PR #134's merge commit.
147
+ const stored = {
148
+ schemaVersion: 1,
149
+ botId: "primary",
150
+ revision: 4,
151
+ profile: { name: "Primary" },
152
+ notifications: { enabled: true },
153
+ assignments: [
154
+ {
155
+ assignmentId: "ollama-model",
156
+ packageId: "provider-ollama-cloud",
157
+ capabilityId: "ollama-cloud-models",
158
+ connectionId: "ollama-1",
159
+ state: "enabled",
160
+ },
161
+ ],
162
+ assignmentOperations: [
163
+ {
164
+ commandId: "replace-model",
165
+ kind: "replacing",
166
+ assignmentId: "ollama-model",
167
+ state: "retrying",
168
+ target: {
169
+ assignmentId: "ollama-model",
170
+ packageId: "provider-ollama-cloud",
171
+ capabilityId: "ollama-cloud-models",
172
+ connectionId: "ollama-1",
173
+ },
174
+ },
175
+ ],
176
+ model: {
177
+ connectionId: "ollama-1",
178
+ providerModelId: "glm-5.3-flash:cloud",
179
+ },
180
+ };
181
+
182
+ expect(decodeBotSettingsViewV1(migrateStoredBotSettingsV1(stored))).toEqual(
183
+ {
184
+ schemaVersion: 1,
185
+ botId: "primary",
186
+ revision: 4,
187
+ profile: { name: "Primary" },
188
+ notifications: { enabled: true },
189
+ packageValues: {},
190
+ },
191
+ );
192
+ });
193
+
194
+ test("keeps unknown Bot fields strict and current records unchanged", () => {
195
+ const current = initializeBotSettingsV1("primary");
196
+ expect(migrateStoredBotSettingsV1(current)).toBe(current);
197
+ const { packageValues: _packageValues, ...withoutPackageValues } = current;
198
+ expect(
199
+ decodeBotSettingsViewV1(
200
+ migrateStoredBotSettingsV1({
201
+ ...withoutPackageValues,
202
+ modelAssignment: {
203
+ connectionId: "ollama-1",
204
+ providerModelId: "glm-5.3-flash:cloud",
205
+ },
206
+ }),
207
+ ),
208
+ ).toEqual(current);
209
+ expect(() =>
210
+ decodeBotSettingsViewV1(
211
+ migrateStoredBotSettingsV1({
212
+ ...current,
213
+ assignments: [],
214
+ unknown: true,
215
+ }),
216
+ ),
217
+ ).toThrow(ConfigurationDecodeError);
218
+ });
219
+ });
220
+
35
221
  describe("shared public identifier policy", () => {
36
222
  test("accepts the bounded cross-runtime identifier grammar", () => {
37
223
  expect(isPublicIdentifier("Bot_1.release-candidate")).toBe(true);
package/src/index.ts CHANGED
@@ -136,6 +136,8 @@ export interface PackageInstallationView {
136
136
  catalogId?: string;
137
137
  /** The immutable Catalog generation `catalogId` was read from. */
138
138
  catalogGeneration?: string;
139
+ /** Exact non-first-party bundle admitted from the Catalog, when it carries code. */
140
+ contentHash?: string;
139
141
  provenance?: PackageProvenanceV1;
140
142
  /** The setup values the install carried, as GrokBot's `InstallPlugin{values}`. */
141
143
  values?: Record<string, JsonValue | ModelBindingV1>;
@@ -145,9 +147,9 @@ export interface PackageInstallationView {
145
147
  * A durable pending decision for the User: this Connection needs authorizing
146
148
  * before it will do anything again.
147
149
  *
148
- * It carries **no URL**, and that is the whole design. A Bot may write one —
149
- * `mcp_authenticate_server` does, and so does a mount that met a 401 but a
150
- * redirect is minted only by an authenticated User action. A single-use
150
+ * It carries **no URL**, and that is the whole design. A mount that meets a
151
+ * 401 may write one, but a Bot has no command that requests authorization and
152
+ * a redirect is minted only by an authenticated User action. A single-use
151
153
  * ten-minute link stored in a projection every client reads, and replayed into
152
154
  * every transcript, would outlive the decision it belonged to and would let a
153
155
  * Bot hand its User a link it authored. The card is drawn from this record and
@@ -300,6 +302,7 @@ export type ConfigurationCommandV1 =
300
302
  */
301
303
  catalogId?: string;
302
304
  catalogGeneration?: string;
305
+ contentHash?: string;
303
306
  values?: Record<string, JsonValue>;
304
307
  })
305
308
  | (CommandMetaV1 & {
@@ -1190,7 +1193,7 @@ export function decodeConfigurationCommandV1(
1190
1193
  const command = exactCommand(
1191
1194
  input,
1192
1195
  ["packageId", "version"],
1193
- ["catalogId", "catalogGeneration", "values", "enabled"],
1196
+ ["catalogId", "catalogGeneration", "contentHash", "values", "enabled"],
1194
1197
  );
1195
1198
  // A Catalog install is all three of identity, generation and (optional)
1196
1199
  // values or none of them: half a Catalog install would be an install
@@ -1208,6 +1211,14 @@ export function decodeConfigurationCommandV1(
1208
1211
  "install values require a Catalog entry",
1209
1212
  );
1210
1213
  }
1214
+ if (
1215
+ command.catalogId === undefined &&
1216
+ command.contentHash !== undefined
1217
+ ) {
1218
+ throw new ConfigurationDecodeError(
1219
+ "install contentHash requires a Catalog entry",
1220
+ );
1221
+ }
1211
1222
  if (
1212
1223
  command.enabled !== undefined &&
1213
1224
  typeof command.enabled !== "boolean"
@@ -1228,6 +1239,14 @@ export function decodeConfigurationCommandV1(
1228
1239
  command.catalogGeneration,
1229
1240
  "catalogGeneration",
1230
1241
  ),
1242
+ ...(command.contentHash === undefined
1243
+ ? {}
1244
+ : {
1245
+ contentHash: compositionHash(
1246
+ command.contentHash,
1247
+ "contentHash",
1248
+ ),
1249
+ }),
1231
1250
  }),
1232
1251
  ...(command.values === undefined
1233
1252
  ? {}
@@ -1614,7 +1633,14 @@ function packageInstallation(value: unknown): PackageInstallationView {
1614
1633
  value,
1615
1634
  "Package installation",
1616
1635
  ["packageId", "version", "state"],
1617
- ["failure", "catalogId", "catalogGeneration", "provenance", "values"],
1636
+ [
1637
+ "failure",
1638
+ "catalogId",
1639
+ "catalogGeneration",
1640
+ "contentHash",
1641
+ "provenance",
1642
+ "values",
1643
+ ],
1618
1644
  );
1619
1645
  if (
1620
1646
  installation.state !== "installed" &&
@@ -1648,6 +1674,11 @@ function packageInstallation(value: unknown): PackageInstallationView {
1648
1674
  "catalogGeneration",
1649
1675
  ),
1650
1676
  }),
1677
+ ...(installation.contentHash === undefined
1678
+ ? {}
1679
+ : {
1680
+ contentHash: compositionHash(installation.contentHash, "contentHash"),
1681
+ }),
1651
1682
  ...(installation.provenance === undefined
1652
1683
  ? {}
1653
1684
  : { provenance: installation.provenance }),
@@ -1777,6 +1808,115 @@ function schemaVersion(value: Record<string, unknown>): void {
1777
1808
  }
1778
1809
  }
1779
1810
 
1811
+ function storedPlainRecordV1(
1812
+ value: unknown,
1813
+ ): Record<string, unknown> | undefined {
1814
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
1815
+ return undefined;
1816
+ }
1817
+ const prototype = Object.getPrototypeOf(value);
1818
+ return prototype === Object.prototype || prototype === null
1819
+ ? (value as Record<string, unknown>)
1820
+ : undefined;
1821
+ }
1822
+
1823
+ function storedDataValueV1(
1824
+ value: Record<string, unknown>,
1825
+ key: string,
1826
+ ): unknown {
1827
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
1828
+ return descriptor && "value" in descriptor ? descriptor.value : undefined;
1829
+ }
1830
+
1831
+ function cloneStoredRecordV1(
1832
+ value: Record<string, unknown>,
1833
+ changes: Readonly<Record<string, unknown>>,
1834
+ removed: readonly string[] = [],
1835
+ ): Record<string, unknown> {
1836
+ const descriptors = Object.getOwnPropertyDescriptors(value);
1837
+ for (const key of removed) delete descriptors[key];
1838
+ for (const [key, next] of Object.entries(changes)) {
1839
+ const descriptor = descriptors[key];
1840
+ descriptors[key] =
1841
+ descriptor && "value" in descriptor
1842
+ ? { ...descriptor, value: next }
1843
+ : {
1844
+ configurable: true,
1845
+ enumerable: true,
1846
+ value: next,
1847
+ writable: true,
1848
+ };
1849
+ }
1850
+ return Object.create(Object.getPrototypeOf(value), descriptors) as Record<
1851
+ string,
1852
+ unknown
1853
+ >;
1854
+ }
1855
+
1856
+ const PRE_ACCOUNT_WIDE_USER_FIELDS_V1 = [
1857
+ "newBotModelTemplate",
1858
+ "newBotModelTemplateSource",
1859
+ ] as const;
1860
+ const PRE_ACCOUNT_WIDE_CONNECTION_METADATA_FIELDS_V1 = [
1861
+ "dependentAssignments",
1862
+ ] as const;
1863
+
1864
+ /**
1865
+ * Migrates one raw User settings record across known durable shapes before the
1866
+ * current exact-field decoder sees it. Commit 1571b62 removed the model
1867
+ * template and commit d6730ad removed the Connection dependency ledger along
1868
+ * with the Assignment feature; migration drops those fields without
1869
+ * interpreting them.
1870
+ */
1871
+ export function migrateStoredUserSettingsV1(stored: unknown): unknown {
1872
+ const settings = storedPlainRecordV1(stored);
1873
+ if (!settings || storedDataValueV1(settings, "schemaVersion") !== 1) {
1874
+ return stored;
1875
+ }
1876
+
1877
+ let changed = false;
1878
+ const removedSettingsFields = PRE_ACCOUNT_WIDE_USER_FIELDS_V1.filter((key) =>
1879
+ Object.hasOwn(settings, key),
1880
+ );
1881
+ changed ||= removedSettingsFields.length > 0;
1882
+
1883
+ const storedConnections = storedDataValueV1(settings, "connections");
1884
+ let connections = storedConnections;
1885
+ if (Array.isArray(storedConnections)) {
1886
+ let connectionsChanged = false;
1887
+ const nextConnections = storedConnections.map((storedConnection) => {
1888
+ const connection = storedPlainRecordV1(storedConnection);
1889
+ if (!connection) return storedConnection;
1890
+ const storedMetadata = storedDataValueV1(connection, "safeMetadata");
1891
+ const metadata = storedPlainRecordV1(storedMetadata);
1892
+ if (!metadata) return storedConnection;
1893
+ const removedMetadataFields =
1894
+ PRE_ACCOUNT_WIDE_CONNECTION_METADATA_FIELDS_V1.filter((key) =>
1895
+ Object.hasOwn(metadata, key),
1896
+ );
1897
+ if (removedMetadataFields.length === 0) return storedConnection;
1898
+ connectionsChanged = true;
1899
+ const safeMetadata = cloneStoredRecordV1(
1900
+ metadata,
1901
+ {},
1902
+ removedMetadataFields,
1903
+ );
1904
+ return cloneStoredRecordV1(connection, { safeMetadata });
1905
+ });
1906
+ if (connectionsChanged) {
1907
+ changed = true;
1908
+ connections = nextConnections;
1909
+ }
1910
+ }
1911
+
1912
+ if (!changed) return stored;
1913
+ return cloneStoredRecordV1(
1914
+ settings,
1915
+ connections === storedConnections ? {} : { connections },
1916
+ removedSettingsFields,
1917
+ );
1918
+ }
1919
+
1780
1920
  export function decodeUserSettingsViewV1(input: unknown): UserSettingsViewV1 {
1781
1921
  const value = exactRecord(
1782
1922
  input,
@@ -1896,6 +2036,35 @@ export function decodeBotSettingsViewV1(input: unknown): BotSettingsViewV1 {
1896
2036
  };
1897
2037
  }
1898
2038
 
2039
+ const PRE_ACCOUNT_WIDE_BOT_FIELDS_V1 = [
2040
+ "assignments",
2041
+ "assignmentOperations",
2042
+ "model",
2043
+ // Some pre-release records used this name for the same removed feature.
2044
+ "modelAssignment",
2045
+ ] as const;
2046
+
2047
+ /**
2048
+ * Migrates one raw Bot settings record across known durable shapes before the
2049
+ * current exact-field decoder sees it. The removed Assignment/model fields are
2050
+ * discarded; their presence identifies the shape that predates packageValues.
2051
+ */
2052
+ export function migrateStoredBotSettingsV1(stored: unknown): unknown {
2053
+ const settings = storedPlainRecordV1(stored);
2054
+ if (!settings || storedDataValueV1(settings, "schemaVersion") !== 1) {
2055
+ return stored;
2056
+ }
2057
+ const removed = PRE_ACCOUNT_WIDE_BOT_FIELDS_V1.filter((key) =>
2058
+ Object.hasOwn(settings, key),
2059
+ );
2060
+ if (removed.length === 0) return stored;
2061
+ return cloneStoredRecordV1(
2062
+ settings,
2063
+ Object.hasOwn(settings, "packageValues") ? {} : { packageValues: {} },
2064
+ removed,
2065
+ );
2066
+ }
2067
+
1899
2068
  export function decodeConfigurationViewV1(input: unknown): ConfigurationViewV1 {
1900
2069
  const value = record(input, "configuration");
1901
2070
  return "botId" in value
@@ -1957,6 +2126,11 @@ const COMPOSITION_GENERATION_STATUSES_V1: readonly CompositionGenerationStatusVi
1957
2126
 
1958
2127
  export type CompositionProvenanceViewV1 =
1959
2128
  | { kind: "first-party" }
2129
+ | {
2130
+ kind: "catalog";
2131
+ catalogId: string;
2132
+ catalogGeneration: string;
2133
+ }
1960
2134
  | { kind: "user"; userId: string; authoredAt: string }
1961
2135
  | {
1962
2136
  kind: "bot";
@@ -1971,7 +2145,14 @@ export type CompositionOriginViewV1 =
1971
2145
  | { kind: "bootstrap" }
1972
2146
  | { kind: "bot-authored"; runId: string; sessionId: string; turnId: string }
1973
2147
  | { kind: "user-install"; userId: string }
1974
- | { kind: "revert"; revertsTo: string; userId: string };
2148
+ | { kind: "revert"; revertsTo: string; userId: string }
2149
+ | {
2150
+ kind: "revert";
2151
+ revertsTo: string;
2152
+ botId: string;
2153
+ runId: string;
2154
+ turnId: string;
2155
+ };
1975
2156
 
1976
2157
  export interface CompositionMemberViewV1 {
1977
2158
  packageId: string;
@@ -2109,6 +2290,25 @@ function compositionProvenanceView(
2109
2290
  exactRecord(input, "Composition provenance", ["kind"]);
2110
2291
  return { kind: "first-party" };
2111
2292
  }
2293
+ if (kind === "catalog") {
2294
+ const value = exactRecord(input, "Composition provenance", [
2295
+ "kind",
2296
+ "catalogId",
2297
+ "catalogGeneration",
2298
+ ]);
2299
+ return {
2300
+ kind: "catalog",
2301
+ catalogId: identifier(
2302
+ value.catalogId,
2303
+ "Composition provenance catalogId",
2304
+ ),
2305
+ catalogGeneration: text(
2306
+ value.catalogGeneration,
2307
+ "Composition provenance catalogGeneration",
2308
+ 256,
2309
+ ),
2310
+ };
2311
+ }
2112
2312
  if (kind === "user") {
2113
2313
  const value = exactRecord(input, "Composition provenance", [
2114
2314
  "kind",
@@ -2176,18 +2376,32 @@ function compositionOriginView(input: unknown): CompositionOriginViewV1 {
2176
2376
  };
2177
2377
  }
2178
2378
  if (kind === "revert") {
2179
- const value = exactRecord(input, "Composition origin", [
2379
+ const value = record(input, "Composition origin");
2380
+ const revertsTo = decodeCompositionGenerationIdV1(
2381
+ value.revertsTo,
2382
+ "Composition origin revertsTo",
2383
+ );
2384
+ if (Object.hasOwn(value, "userId")) {
2385
+ exactRecord(input, "Composition origin", ["kind", "revertsTo", "userId"]);
2386
+ return {
2387
+ kind: "revert",
2388
+ revertsTo,
2389
+ userId: text(value.userId, "Composition origin userId", 256),
2390
+ };
2391
+ }
2392
+ const bot = exactRecord(input, "Composition origin", [
2180
2393
  "kind",
2181
2394
  "revertsTo",
2182
- "userId",
2395
+ "botId",
2396
+ "runId",
2397
+ "turnId",
2183
2398
  ]);
2184
2399
  return {
2185
2400
  kind: "revert",
2186
- revertsTo: decodeCompositionGenerationIdV1(
2187
- value.revertsTo,
2188
- "Composition origin revertsTo",
2189
- ),
2190
- userId: text(value.userId, "Composition origin userId", 256),
2401
+ revertsTo,
2402
+ botId: decodeBotIdV1(bot.botId),
2403
+ runId: text(bot.runId, "Composition origin runId", 128),
2404
+ turnId: text(bot.turnId, "Composition origin turnId", 128),
2191
2405
  };
2192
2406
  }
2193
2407
  throw new ConfigurationDecodeError("Composition origin kind is invalid");