@lanes-sh/link 0.8.0 → 0.9.0

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.
Files changed (87) hide show
  1. package/instructions/skills/lanes-link/SKILL.md +12 -12
  2. package/package.json +1 -1
  3. package/src/auth/oauth/store.ts +21 -5
  4. package/src/cli/accepts.ts +6 -5
  5. package/src/cli/commands/connect/index.ts +2 -1
  6. package/src/cli/commands/connect/settle.ts +12 -4
  7. package/src/cli/commands/connection.ts +2 -1
  8. package/src/cli/commands/identity.ts +4 -4
  9. package/src/cli/commands/knowledge/index.ts +9 -16
  10. package/src/cli/commands/knowledge/migrate.ts +9 -4
  11. package/src/cli/commands/knowledge/show.ts +14 -9
  12. package/src/cli/commands/operate/migrate.ts +5 -2
  13. package/src/cli/commands/operate/pair.ts +1 -1
  14. package/src/cli/commands/operate/serve.ts +1 -1
  15. package/src/cli/commands/owner/assets.ts +2 -2
  16. package/src/cli/commands/owner/entities.ts +2 -2
  17. package/src/cli/commands/owner/memory.ts +2 -2
  18. package/src/cli/commands/owner/tasks.ts +2 -2
  19. package/src/cli/commands/owner/vault.ts +3 -3
  20. package/src/cli/commands/profile/disposition.ts +236 -0
  21. package/src/cli/commands/profile/removal.ts +100 -13
  22. package/src/cli/commands/profile/remove.ts +67 -6
  23. package/src/cli/commands/profile.ts +33 -6
  24. package/src/cli/commands/secrets.ts +4 -4
  25. package/src/cli/commands/update-migration.ts +54 -0
  26. package/src/cli/commands/update.ts +36 -23
  27. package/src/cli/config-edit.ts +29 -8
  28. package/src/cli/config-repair-sweep.ts +119 -0
  29. package/src/cli/config-repair.ts +90 -141
  30. package/src/cli/config-templates.ts +26 -24
  31. package/src/cli/contract3-credentials.ts +294 -0
  32. package/src/cli/contract3-data.ts +143 -209
  33. package/src/cli/contract3-layout.ts +46 -0
  34. package/src/cli/contract3-shape.ts +34 -8
  35. package/src/cli/contract3.ts +141 -24
  36. package/src/cli/contract4-credentials.ts +207 -0
  37. package/src/cli/contract4-data.ts +399 -0
  38. package/src/cli/contract4-rename.ts +73 -0
  39. package/src/cli/contract4-yaml.ts +223 -0
  40. package/src/cli/contract4.ts +342 -0
  41. package/src/cli/identity.ts +44 -26
  42. package/src/cli/main.ts +6 -1
  43. package/src/cli/migrate-move.ts +166 -0
  44. package/src/cli/migrate-plan.ts +3 -3
  45. package/src/cli/publish.ts +1 -5
  46. package/src/cli/runtime/open.ts +5 -5
  47. package/src/cli/runtime/select.ts +2 -11
  48. package/src/cli/runtime/stores.ts +16 -11
  49. package/src/cli/runtime/vault.ts +2 -2
  50. package/src/cli/usage.ts +5 -1
  51. package/src/cli/workspace-migrate.ts +32 -11
  52. package/src/connectivity/manifest/provider.ts +31 -12
  53. package/src/connectivity/transports/imap/parser.ts +70 -9
  54. package/src/deployments/adapters/filesystem.ts +18 -3
  55. package/src/deployments/deploy.ts +5 -5
  56. package/src/deployments/gcp/bucket.ts +42 -6
  57. package/src/deployments/knowledge.ts +9 -4
  58. package/src/deployments/target.ts +28 -7
  59. package/src/deployments/upload.ts +39 -30
  60. package/src/profile/connections.ts +13 -1
  61. package/src/profile/deployments.ts +86 -8
  62. package/src/profile/index.ts +5 -1
  63. package/src/profile/knowledge.ts +18 -5
  64. package/src/profile/layout.ts +147 -71
  65. package/src/profile/load.ts +53 -17
  66. package/src/profile/schema.ts +11 -2
  67. package/src/profile/testing.ts +45 -10
  68. package/src/profile/workspace.ts +66 -30
  69. package/src/providers/assets/provider.ts +6 -6
  70. package/src/providers/entities/provider.ts +6 -6
  71. package/src/providers/entities/writes.ts +1 -1
  72. package/src/providers/identity/provider.ts +1 -1
  73. package/src/providers/memory/provider.ts +6 -6
  74. package/src/providers/setup/provider.ts +3 -3
  75. package/src/providers/skills/provider.ts +2 -2
  76. package/src/providers/tasks/provider.ts +6 -6
  77. package/src/providers/vault/provider.ts +1 -1
  78. package/src/registry/registry.ts +1 -1
  79. package/src/server/endpoint.ts +4 -0
  80. package/src/server/harness.ts +1 -1
  81. package/src/server/mcp/instructions.ts +21 -21
  82. package/src/server/mcp/routing.ts +3 -3
  83. package/src/server/mcp/tools.ts +16 -3
  84. package/src/server/mcp/visibility.ts +56 -4
  85. package/src/stores/blobs/conformance.ts +19 -0
  86. package/src/stores/state/index.ts +76 -10
  87. package/src/stores/state/testing.ts +5 -1
@@ -0,0 +1,399 @@
1
+ import { layout } from '#profile';
2
+ import type { BlobStore } from '#stores/blobs';
3
+ import {
4
+ decodeSegment,
5
+ DISCOVERY_NAMESPACE,
6
+ encodeSegment,
7
+ isWorkspaceNamespace,
8
+ OAUTH_NAMESPACE,
9
+ } from '#stores/state';
10
+ import { C3 } from './contract3-layout.ts';
11
+ import { planRenames, renamedProvider } from './contract4-rename.ts';
12
+ import { claim, type Move } from './migrate-move.ts';
13
+
14
+ /**
15
+ * The half of the contract-4 migration that moves bytes rather than YAML.
16
+ *
17
+ * Contract 3 hoisted everything a profile owned up to the workspace; this
18
+ * carries it back down (ADR-066), and flattens what stays at the workspace out
19
+ * of `data/` while it is here (ADR-067). Same seam as `contract3-data.ts`, and
20
+ * the same ordering rule: everything that can refuse happens while this is
21
+ * still a plan.
22
+ *
23
+ * **The interesting decision is what to do with a store two profiles grant.**
24
+ * Contract 3's default gives every profile a grant on the same `.main`
25
+ * instances, so the common case in a freshly created workspace is that both
26
+ * profiles point at one memory. There is no answer that is right for everyone:
27
+ * moving it into one profile takes it away from the other, and merging is not a
28
+ * thing bytes can do. So it is **copied into each, and the original is left**.
29
+ * The operator is told, and deletes what they do not want — which is the only
30
+ * step in this migration that is theirs rather than ours.
31
+ */
32
+
33
+ /** Which profiles grant a connection, by `<provider>.<id>`. */
34
+ export type Granting = ReadonlyMap<string, readonly string[]>;
35
+
36
+ /** Old `<provider>.<id>` to new, for every connection this workspace holds. */
37
+ export type Renames = ReadonlyMap<string, string>;
38
+
39
+ /**
40
+ * The new id for every connection, allocated in file order.
41
+ *
42
+ * File order rather than anything derived, so a rerun produces the same map and
43
+ * a preview matches the apply. A row that already carries an allocated id keeps
44
+ * it — this migration is not a renumbering, and running it twice must not walk
45
+ * `con1` to `con2`.
46
+ *
47
+ * The reserved ids are the owner layer's, and they are what the `lan` prefix is
48
+ * for: a reader can tell a surface built into Lanes from somebody's account
49
+ * without resolving anything.
50
+ */
51
+
52
+ export interface DataPlan {
53
+ readonly moves: readonly Move[];
54
+ /**
55
+ * What was copied rather than moved, and into which profiles.
56
+ *
57
+ * Reported so the operator can delete the original. Never deleted here: a
58
+ * store two profiles were sharing is one whose owner has to decide, and this
59
+ * migration guessing would be the silent wrong answer ADR-059 warns about for
60
+ * the vault in particular.
61
+ */
62
+ readonly shared: readonly { readonly key: string; readonly profiles: readonly string[] }[];
63
+ /** Granted by nobody. Left exactly where it is, and named. */
64
+ readonly orphaned: readonly string[];
65
+ /**
66
+ * Per-profile credential stores contract 3 merged but did not delete.
67
+ *
68
+ * Named separately: they are a decryptable document and its key, and "no
69
+ * profile grants it" is the wrong sentence about one.
70
+ */
71
+ readonly leftover: readonly string[];
72
+ }
73
+
74
+ /**
75
+ * Where every object under `data/` is going.
76
+ *
77
+ * Anything matching no rule is left where it is — this moves what it
78
+ * understands and never deletes what it does not.
79
+ */
80
+ export async function planMoves(
81
+ files: BlobStore,
82
+ granting: Granting,
83
+ profiles: readonly string[],
84
+ renames: Renames = new Map(),
85
+ ): Promise<DataPlan> {
86
+ /** The id a connection ends up with — itself, where nothing renamed it. */
87
+ const renamed = (provider: string, id: string): string => {
88
+ const to = renames.get(`${provider}.${id}`);
89
+ return to === undefined ? id : to.slice(to.indexOf('.') + 1);
90
+ };
91
+
92
+ const moves: Move[] = [];
93
+ const shared: { key: string; profiles: readonly string[] }[] = [];
94
+ const orphaned: string[] = [];
95
+ const leftover: string[] = [];
96
+ const claimed = new Set<string>();
97
+
98
+ for (const blob of await files.list('data/')) {
99
+ const parts = blob.key.split('/').slice(1);
100
+ const head = parts[0];
101
+ const tail = parts.slice(1);
102
+ if (head === undefined) continue;
103
+
104
+ // The four the workspace keeps, out of `data/` and up to the root.
105
+ if (head === 'credentials.enc' || head === 'credentials.enc.key') {
106
+ moves.push({ from: blob.key, to: head });
107
+ continue;
108
+ }
109
+ if (head === 'providers.d') {
110
+ moves.push({ from: blob.key, to: `${layout.providers()}/${tail.join('/')}` });
111
+ continue;
112
+ }
113
+ if (head === 'audit.log') {
114
+ moves.push({ from: blob.key, to: `${layout.audit()}/${tail.join('/')}` });
115
+ continue;
116
+ }
117
+ if (head === 'state.kv') {
118
+ for (const move of stateMoves(blob.key, tail, granting, claimed, renames, orphaned)) {
119
+ moves.push(move);
120
+ }
121
+ continue;
122
+ }
123
+
124
+ // `vault.d/<id>.enc` and `skills.d/<id>/...` — the two the profile takes
125
+ // back, keyed by the connection that already owns them.
126
+ if (head === 'vault.d' || head === 'skills.d') {
127
+ const connection = tail[0];
128
+ if (connection === undefined) continue;
129
+
130
+ const provider = head === 'vault.d' ? 'vault' : 'skills';
131
+ // A vault document is `<id>.enc`, so the id has the suffix stripped; a
132
+ // skills directory is the id itself.
133
+ const id = provider === 'vault' ? connection.replace(/\.enc(\.key)?$/, '') : connection;
134
+ const owners = granting.get(`${provider}.${id}`) ?? [];
135
+
136
+ const to = renamed(provider, id);
137
+ const into = (profile: string): string =>
138
+ provider === 'vault'
139
+ ? `${layout.vaultRoot(profile)}/${connection.replace(id, to)}`
140
+ : `${layout.skills(profile, to)}/${tail.slice(1).join('/')}`;
141
+
142
+ record(blob.key, owners, into, moves, shared, orphaned);
143
+ continue;
144
+ }
145
+
146
+ // **A contract-2 leftover is a credential, not an ungranted store.**
147
+ // Contract 3 merges `data/<profile>/credentials.enc` and never deletes it,
148
+ // so a workspace that came through it still holds one per profile — and
149
+ // "no profile grants it" is true and is the wrong sentence about a
150
+ // decryptable document: the operator reads it as tidy-up and leaves it.
151
+ if (tail[0] === 'credentials.enc' || tail[0] === 'credentials.enc.key') {
152
+ leftover.push(blob.key);
153
+ continue;
154
+ }
155
+
156
+ // Otherwise `<provider>/<connection>/…`, the namespace every provider's
157
+ // blobs are scoped into — memory, tasks, assets, entities and every vendor.
158
+ const connection = tail[0];
159
+ if (connection === undefined) continue;
160
+
161
+ const ref = `${head}.${connection}`;
162
+ const owners = granting.get(ref) ?? [];
163
+
164
+ // Both segments from the one rename: one without the other reads nothing.
165
+ const settled = renames.get(ref) ?? ref;
166
+ const dot = settled.indexOf('.');
167
+ const into = `${settled.slice(0, dot)}/${settled.slice(dot + 1)}`;
168
+
169
+ record(
170
+ blob.key,
171
+ owners,
172
+ (profile) => `${layout.blobs(profile)}/${into}/${tail.slice(1).join('/')}`,
173
+ moves,
174
+ shared,
175
+ orphaned,
176
+ );
177
+ }
178
+
179
+ // The declarations themselves, into the directory each now owns.
180
+ for (const profile of profiles) {
181
+ const from = C3.profile(profile);
182
+ if (await files.has(from)) moves.push({ from, to: layout.profileConfig(profile) });
183
+ }
184
+
185
+ return { moves, shared, orphaned, leftover };
186
+ }
187
+
188
+ /**
189
+ * One object, routed by how many profiles grant the connection it belongs to.
190
+ *
191
+ * Exactly one is a move. Several is a copy into each, with the original left
192
+ * behind — see the header. None is left alone and named: a store nothing grants
193
+ * is not this migration's to place, and deleting it would be the one
194
+ * irreversible thing in here.
195
+ */
196
+ function record(
197
+ key: string,
198
+ owners: readonly string[],
199
+ into: (profile: string) => string,
200
+ moves: Move[],
201
+ shared: { key: string; profiles: readonly string[] }[],
202
+ orphaned: string[],
203
+ ): void {
204
+ if (owners.length === 0) {
205
+ orphaned.push(key);
206
+ return;
207
+ }
208
+
209
+ if (owners.length === 1) {
210
+ moves.push({ from: key, to: into(owners[0]!) });
211
+ return;
212
+ }
213
+
214
+ // `keep` rather than a move: `applyMoves` deletes the source once the copy
215
+ // reads back, and there is a second destination still to write.
216
+ for (const profile of owners) moves.push({ from: key, to: into(profile), keep: true });
217
+ shared.push({ key, profiles: owners });
218
+ }
219
+
220
+ /**
221
+ * Where one object under `data/state.kv` goes — and it may go to two places.
222
+ *
223
+ * The split is by namespace rather than by key: connection records, the
224
+ * discovery cache and the endpoint's own OAuth server are the workspace's, and
225
+ * everything else is one profile's use of an account. `isWorkspaceNamespace` in
226
+ * `#stores/state` is that rule and this reads it rather than restating it.
227
+ *
228
+ * A cursor belongs to whichever profiles grant its connection, so it takes the
229
+ * same fan-out as a blob does. The alternative — one cursor shared — is exactly
230
+ * the bug ADR-066 is fixing, two agents consuming each other's position.
231
+ */
232
+ /**
233
+ * The namespaces contract 3 spelled without a dot, and contract 4 does.
234
+ *
235
+ * `discovery` and `oauth/…` were reachable by a provider — only `custom` is
236
+ * refused by grammar, so a manifest with id `oauth` landed inside them — and a
237
+ * dotted name is one no provider can reach. Renaming them is only half the job:
238
+ * the objects already written under the old spelling have to come across, or
239
+ * every client that had signed in is signed out and every discovered spec is
240
+ * fetched again. The rehearsal that found this left 75 OAuth records behind.
241
+ */
242
+ const RENAMED_NAMESPACES: readonly [string, string][] = [
243
+ ['discovery', DISCOVERY_NAMESPACE],
244
+ ['oauth', OAUTH_NAMESPACE],
245
+ ];
246
+
247
+ /** `<provider>/<connection>` as a state namespace is `<provider>.<connection>` as a ref. */
248
+ function refOf(namespace: string): string {
249
+ const slash = namespace.indexOf('/');
250
+ return slash === -1 ? namespace : `${namespace.slice(0, slash)}.${namespace.slice(slash + 1)}`;
251
+ }
252
+
253
+ function stateMoves(
254
+ from: string,
255
+ tail: readonly string[],
256
+ granting: Granting,
257
+ claimed: Set<string>,
258
+ renames: Renames = new Map(),
259
+ orphaned: string[] = [],
260
+ ): Move[] {
261
+ const leaf = tail[tail.length - 1];
262
+
263
+ // **Guarded, the way `contract3-data.ts` guards it and for its reason.**
264
+ // `decodeSegment` throws `URIError` on a stray `%`, and contract 3's catch
265
+ // moves such a key *verbatim* — so the class of key that survives contract 3
266
+ // by design is exactly the class this read unguarded, and one of them threw
267
+ // `URI malformed` naming no file out of every command including the preview.
268
+ // A key this cannot parse is carried across unchanged rather than dropped.
269
+ if (leaf === undefined || tail.length < 2 || !leaf.endsWith('.json')) {
270
+ const move = claim(claimed, { from, to: `${layout.state()}/${tail.join('/')}` });
271
+ return move === null ? [] : [move];
272
+ }
273
+
274
+ let namespace: string;
275
+ let key: string;
276
+ try {
277
+ namespace = tail.slice(0, -1).map(decodeSegment).join('/');
278
+ key = decodeSegment(leaf.replace(/\.json$/, ''));
279
+ } catch {
280
+ const move = claim(claimed, { from, to: `${layout.state()}/${tail.join('/')}` });
281
+ return move === null ? [] : [move];
282
+ }
283
+
284
+ // The undotted spellings, carried across before anything else looks at them.
285
+ for (const [was, now] of RENAMED_NAMESPACES) {
286
+ if (namespace !== was && !namespace.startsWith(`${was}/`)) continue;
287
+
288
+ // Only the first segment. `oauth/tokens` is two segments and each is
289
+ // encoded on its own, so encoding `oauth.v1/tokens` whole would escape the
290
+ // separator too and write one segment named `oauth%2Ev1%2Ftokens`.
291
+ const move = claim(claimed, {
292
+ from,
293
+ to: `${layout.state()}/${[encodeSegment(now), ...tail.slice(1)].join('/')}`,
294
+ });
295
+ return move === null ? [] : [move];
296
+ }
297
+
298
+ if (isWorkspaceNamespace(namespace)) {
299
+ // A connection record is keyed on the ref *and* carries it in the body:
300
+ // `ConnectionRepository.list` reads `provider` and `id` out of the record,
301
+ // not out of the key. A renamed connection whose bytes moved verbatim would
302
+ // sit at `connections.v1/gmail.con1` still calling itself `gmail.main`, and
303
+ // the next reconcile would write a second record beside it.
304
+ const settled = namespace === 'connections.v1' ? renames.get(key) : undefined;
305
+ const to = `${layout.state()}/${
306
+ settled === undefined ? tail.join('/') : `${tail[0]!}/${encodeSegment(settled)}.json`
307
+ }`;
308
+
309
+ // Two profiles both hold a record for a connection they share, and the
310
+ // second differs only in when it was written.
311
+ const move = claim(claimed, {
312
+ from,
313
+ to,
314
+ ...(settled === undefined
315
+ ? {}
316
+ : { rewrite: (data: Uint8Array) => retarget(data, settled) }),
317
+ });
318
+ return move === null ? [] : [move];
319
+ }
320
+
321
+ // A cursor's namespace is `cursors.v1` and its *key* is the connection; a
322
+ // provider's own store is namespaced `<provider>/<connection>` instead — and
323
+ // that slash is not the dot a grant is keyed on. Looking the one up with the
324
+ // other matched nothing and dropped every provider's state in silence, which
325
+ // is the same shape of bug `contract3-data.ts` records having shipped once.
326
+ const cursor = namespace === 'cursors.v1';
327
+ const ref = cursor ? key : refOf(namespace);
328
+ const owners = granting.get(ref) ?? [];
329
+
330
+ if (owners.length === 0) {
331
+ orphaned.push(from);
332
+ return [];
333
+ }
334
+
335
+ const settled = renames.get(ref) ?? ref;
336
+ const dot = settled.indexOf('.');
337
+
338
+ // The rename reaches whichever half carries the ref, and only that half.
339
+ const moved = cursor
340
+ ? `${tail[0]!}/${encodeSegment(settled)}.json`
341
+ : [
342
+ encodeSegment(settled.slice(0, dot)),
343
+ encodeSegment(settled.slice(dot + 1)),
344
+ ...tail.slice(2),
345
+ ].join('/');
346
+
347
+ return owners.map((profile) => ({
348
+ from,
349
+ to: `${layout.profileState(profile)}/${moved}`,
350
+ keep: owners.length > 1,
351
+ }));
352
+ }
353
+
354
+ /**
355
+ * A connection record, told what it is now called.
356
+ *
357
+ * Spread rather than assigned field by field, so key order and anything a later
358
+ * version added survive. Bytes that are not a JSON object come back untouched:
359
+ * refusing to move what will not parse strands it where nothing reads it.
360
+ */
361
+ function retarget(data: Uint8Array, ref: string): Uint8Array {
362
+ try {
363
+ const held = JSON.parse(new TextDecoder().decode(data)) as Record<string, unknown>;
364
+ if (held === null || typeof held !== 'object' || Array.isArray(held)) return data;
365
+
366
+ const dot = ref.indexOf('.');
367
+ return new TextEncoder().encode(
368
+ JSON.stringify({ ...held, provider: ref.slice(0, dot), id: ref.slice(dot + 1) }),
369
+ );
370
+ } catch {
371
+ return data;
372
+ }
373
+ }
374
+
375
+ /**
376
+ * Which profiles grant each connection, read off the profiles themselves.
377
+ *
378
+ * Derived rather than assumed, because the fan-out above is the whole decision:
379
+ * a connection nobody grants must not be placed anywhere, and one two profiles
380
+ * grant must not be placed in only the first. `connections.yaml` cannot answer
381
+ * this — it says what exists, not who selected it (ADR-057).
382
+ */
383
+ export function grantingProfiles(
384
+ configs: ReadonlyMap<string, { grants?: { connection?: unknown }[] }>,
385
+ ): Granting {
386
+ const granting = new Map<string, string[]>();
387
+
388
+ for (const [profile, config] of configs) {
389
+ for (const grant of config.grants ?? []) {
390
+ const ref = grant.connection;
391
+ if (typeof ref !== 'string') continue;
392
+ const owners = granting.get(ref) ?? [];
393
+ if (!owners.includes(profile)) owners.push(profile);
394
+ granting.set(ref, owners);
395
+ }
396
+ }
397
+
398
+ return granting;
399
+ }
@@ -0,0 +1,73 @@
1
+ import { nextConnectionId } from './identity.ts';
2
+ import { C3_OWNER_PROVIDERS } from './contract3-layout.ts';
3
+ import type { Renames } from './contract4-data.ts';
4
+
5
+ /**
6
+ * What every connection is called after contract 4.
7
+ *
8
+ * Its own module because it is one decision read by four rewrites — the rows,
9
+ * the grants, the blob paths and the state keys — and because a map keyed one
10
+ * way and queried another is the bug `contract3-data.ts` records shipping once.
11
+ * One place to build it, one place for everything else to read it from.
12
+ */
13
+
14
+ /**
15
+ * The id and provider every connection ends up with.
16
+ *
17
+ * Two things happen here, and the merge is the interesting one.
18
+ *
19
+ * **The owner layer merges to one row per surface.** Contract 3 made the
20
+ * *connection* the isolation boundary, so three profiles each needed their own
21
+ * `memory` — a workspace with three profiles came out of that migration holding
22
+ * `memory.main`, `memory.work` and `memory.demo`, and the file read as
23
+ * duplicated because it was. ADR-066 makes the *profile* the boundary, so one
24
+ * row serves every profile and each still keeps its own bytes at
25
+ * `profiles/<name>/lanes_memory/lan1/`. Merging the rows merges no notes, which
26
+ * is exactly why contract 3 could not do it and this can.
27
+ *
28
+ * **Both sequences are numbered cleanly**, `con1..conN` over the accounts in
29
+ * file order and `lan1..lan8` over the merged owner rows. A rerun produces the
30
+ * same map because the order is the file's, and a row already carrying an
31
+ * allocated id keeps it — this is not a renumbering, and running it twice must
32
+ * not walk `con1` to `con2`.
33
+ */
34
+ export function planRenames(
35
+ connections: readonly { id: string; provider: string }[],
36
+ ): Renames {
37
+ const renames = new Map<string, string>();
38
+ const taken = connections
39
+ .map((row) => row.id)
40
+ .filter((id) => /^(lan|con)[0-9]+$/.test(id));
41
+
42
+ // One destination per owner-layer *provider*, so every profile's instance of
43
+ // it lands on the same row.
44
+ const merged = new Map<string, string>();
45
+
46
+ for (const row of connections) {
47
+ const owner = C3_OWNER_PROVIDERS.includes(row.provider);
48
+ const provider = renamedProvider(row.provider);
49
+ const from = `${row.provider}.${row.id}`;
50
+
51
+ if (owner) {
52
+ const already = merged.get(provider);
53
+ if (already !== undefined) {
54
+ renames.set(from, already);
55
+ continue;
56
+ }
57
+ }
58
+
59
+ const id = /^(lan|con)[0-9]+$/.test(row.id) ? row.id : nextConnectionId(taken, owner);
60
+ if (!taken.includes(id)) taken.push(id);
61
+
62
+ const to = `${provider}.${id}`;
63
+ renames.set(from, to);
64
+ if (owner) merged.set(provider, to);
65
+ }
66
+
67
+ return renames;
68
+ }
69
+
70
+ /** `memory` becomes `lanes_memory`; everything else keeps its id. */
71
+ export function renamedProvider(provider: string): string {
72
+ return C3_OWNER_PROVIDERS.includes(provider) ? `lanes_${provider}` : provider;
73
+ }
@@ -0,0 +1,223 @@
1
+ import {
2
+ CONNECTIONS_FILE,
3
+ connectionsFileSchema,
4
+ ConfigError,
5
+ layout,
6
+ readWorkspaceFile,
7
+ workspaceFiles,
8
+ } from '#profile';
9
+ import { parseDocument } from 'yaml';
10
+ import { ConfigDocument } from './config-edit.ts';
11
+ import type { Renames } from './contract4-data.ts';
12
+
13
+ /**
14
+ * The two YAML rewrites contract 4 makes, and the order between them.
15
+ *
16
+ * The grants go first and the rows second, with the stamp after both — every
17
+ * window that leaves is one a rerun closes, because both rewrites are
18
+ * idempotent: a grant or a row already naming the new ref is not in the map and
19
+ * is left alone. Renaming the rows first destroyed the only source the map is
20
+ * derived from, and the rerun then stamped a profile whose grants named a
21
+ * connection nothing declared.
22
+ */
23
+
24
+ /**
25
+ * `memory.*` becomes `lanes_memory.*`, in either spelling a rule may take.
26
+ *
27
+ * A rule is a bare pattern string or `{ capability, expires_at }`, and an
28
+ * expiry has to survive: dropping it would turn a lapsing grant into a
29
+ * permanent one, which is the direction that fails unsafely.
30
+ */
31
+ function retitle(rules: unknown, was: string, now: string): unknown {
32
+ if (!Array.isArray(rules)) return rules;
33
+
34
+ const moved = (pattern: string): string =>
35
+ pattern === was || pattern.startsWith(`${was}.`) ? `${now}${pattern.slice(was.length)}` : pattern;
36
+
37
+ return rules.map((rule) => {
38
+ if (typeof rule === 'string') return moved(rule);
39
+ const held = rule as { capability?: unknown };
40
+ return typeof held.capability === 'string'
41
+ ? { ...held, capability: moved(held.capability) }
42
+ : rule;
43
+ });
44
+ }
45
+
46
+ /**
47
+ * Every profile's grants, onto the ids and providers the rename settled.
48
+ *
49
+ * Its own pass rather than a step inside the stamping loop, because the two
50
+ * have to happen either side of `renameConnections` — the grants while the rows
51
+ * still carry the old ids, the stamp once everything else is done.
52
+ *
53
+ * A rule may only name the provider its row grants (ADR-058), so the
54
+ * `allow`/`deny` patterns move with the `connection` or the profile is one the
55
+ * loader refuses.
56
+ */
57
+ export async function rewriteGrants(
58
+ root: string,
59
+ profiles: readonly string[],
60
+ renames: Renames,
61
+ ): Promise<void> {
62
+ for (const profile of profiles) {
63
+ const document = await ConfigDocument.openKey(root, layout.profileConfig(profile));
64
+
65
+ const held = document.toJSON() as { grants?: unknown };
66
+ const grants = held.grants;
67
+ if (!Array.isArray(grants)) continue;
68
+
69
+ // **Deduplicated, because the owner layer merges.** Every profile's own
70
+ // `memory` grant renames to `lanes_memory.lan1` (ADR-066), and a profile
71
+ // that granted two instances of one surface would emit two rows at one
72
+ // address — which `validateConfig` refuses, on the save below, after the
73
+ // registry and every byte have already moved.
74
+ const seen = new Set<string>();
75
+
76
+ document.setIn(
77
+ ['grants'],
78
+ grants.flatMap((grant) => {
79
+ const one = grant as { connection?: unknown; allow?: unknown; deny?: unknown };
80
+ const ref = typeof one.connection === 'string' ? one.connection : undefined;
81
+ if (ref === undefined) return [grant];
82
+
83
+ const to = renames.get(ref) ?? ref;
84
+ if (seen.has(to)) return [];
85
+ seen.add(to);
86
+
87
+ const was = ref.slice(0, ref.indexOf('.'));
88
+ const now = to.slice(0, to.indexOf('.'));
89
+
90
+ return [
91
+ {
92
+ ...one,
93
+ connection: to,
94
+ ...(was === now
95
+ ? {}
96
+ : { allow: retitle(one.allow, was, now), deny: retitle(one.deny, was, now) }),
97
+ },
98
+ ];
99
+ }),
100
+ );
101
+
102
+ // `shapeOnly`, because the rows still name the old ids at this point and
103
+ // `assertGrantsResolve` would refuse a grant it cannot yet resolve. The
104
+ // secret-shaped-value check and the schema both still run.
105
+ await document.save({ shapeOnly: true, contract: 3 });
106
+ }
107
+ }
108
+
109
+ /** Every connection row, however far through the migration this workspace is. */
110
+ /**
111
+ * Refuse a `connections.yaml` the rewrite at the end could not have saved.
112
+ *
113
+ * `readConnectionRows` reads raw and drops a row it cannot understand, which is
114
+ * what lets it run before the guard that would block its own fix — but it means
115
+ * a malformed file yields *fewer* rows rather than an error, and a rehearsal
116
+ * found what that costs: the rename map came out empty, every byte moved
117
+ * verbatim into the profiles, and the validating save at the very end refused.
118
+ * `data/` was then empty, so a rerun had nothing to move and left the bytes at
119
+ * their old ids while the rows moved on without them.
120
+ *
121
+ * So it is checked here, before the first byte moves, which is where this
122
+ * migration says everything that can fail belongs. Deliberately *not*
123
+ * `readConnections`: that one also runs `assertNoRenamedProviders`, and a
124
+ * workspace holding `tasks` is the workspace this migration exists to repair.
125
+ */
126
+ export async function assertConnectionsSavable(root: string): Promise<void> {
127
+ const text = await readWorkspaceFile(workspaceFiles(root), CONNECTIONS_FILE);
128
+ if (text === null) return;
129
+
130
+ const parsed = connectionsFileSchema.safeParse(parseDocument(text).toJSON());
131
+ if (parsed.success) return;
132
+
133
+ throw new ConfigError(
134
+ `${CONNECTIONS_FILE} is not valid, and this migration rewrites it — so it stops here,\n` +
135
+ ' with the workspace exactly as it was. Fix these, then run it again:\n' +
136
+ parsed.error.issues.map((issue) => ` ${issue.path.join('.')}: ${issue.message}`).join('\n'),
137
+ );
138
+ }
139
+
140
+ export async function readConnectionRows(
141
+ root: string,
142
+ full = false,
143
+ ): Promise<{ id: string; provider: string; account: string; credential_ref?: string }[]> {
144
+ const text = await readWorkspaceFile(workspaceFiles(root), CONNECTIONS_FILE);
145
+ if (text === null) return [];
146
+
147
+ const held = parseDocument(text).toJSON() as { connections?: unknown };
148
+ if (!Array.isArray(held.connections)) return [];
149
+
150
+ return held.connections.flatMap((row) => {
151
+ const one = row as { id?: unknown; provider?: unknown; account?: unknown };
152
+ if (typeof one.id !== 'string' || typeof one.provider !== 'string') return [];
153
+
154
+ // `account` matters only to `credentialRefFor`, which reads a row's own
155
+ // `credential_ref` off it; the id and provider are all the rest wants.
156
+ return [
157
+ {
158
+ ...(full ? (row as object) : {}),
159
+ id: one.id,
160
+ provider: one.provider,
161
+ account: typeof one.account === 'string' ? one.account : '',
162
+ },
163
+ ];
164
+ });
165
+ }
166
+
167
+ /**
168
+ * The ids, in `connections.yaml` and in the credential store's refs.
169
+ *
170
+ * A `credential_ref` defaults to `<provider>/<connection>` and is derived
171
+ * rather than written, so the rows carry no ref to update — but a connection
172
+ * that *declares* one, and every `oauth_apps` entry, is a string naming an id
173
+ * and has to move with it.
174
+ */
175
+ export async function renameConnections(root: string, renames: Renames): Promise<void> {
176
+ const document = await ConfigDocument.openKey(root, CONNECTIONS_FILE);
177
+
178
+ // `toJSON()`, not `getIn`: `getIn` hands back YAML nodes rather than plain
179
+ // JS, so an `Array.isArray` on the result is false and the rewrite silently
180
+ // does nothing — which is exactly what it did, and the migration reported
181
+ // success with every id unchanged.
182
+ const held = document.toJSON() as { connections?: unknown };
183
+ if (!Array.isArray(held.connections)) return;
184
+
185
+ // **Deduplicated, because the owner layer merges.** Three profiles' own
186
+ // `memory` rows all rename to `lanes_memory.lan1` (ADR-066), and keeping
187
+ // three of them would be three rows at one address — which
188
+ // `assertConnectionsUnique` refuses, on the save at the end of this. The
189
+ // first row wins and the rest are dropped; the *bytes* are not merged, they
190
+ // land under each profile's own directory.
191
+ const seen = new Set<string>();
192
+ const rows = held.connections.flatMap((row) => {
193
+ const one = row as { id?: unknown; provider?: unknown };
194
+ if (typeof one.id !== 'string' || typeof one.provider !== 'string') return [row];
195
+
196
+ const to = renames.get(`${one.provider}.${one.id}`);
197
+ if (to === undefined) return [row];
198
+ if (seen.has(to)) return [];
199
+ seen.add(to);
200
+
201
+ // Both halves. The provider moved for the owner layer (`memory` became
202
+ // `lanes_memory`) and the id moved for everything unallocated, and a row
203
+ // that took one without the other names a connection nothing resolves.
204
+ const dot = to.indexOf('.');
205
+ return [{ ...one, provider: to.slice(0, dot), id: to.slice(dot + 1) }];
206
+ });
207
+
208
+ // **Lanes' own rows last, and the accounts in file order.** Nothing reads the
209
+ // order, but somebody does: interleaved, `con17` landed between `lan14` and
210
+ // `lan15` and the file read as though the numbering had gone wrong.
211
+ const owned = (row: unknown): boolean =>
212
+ typeof (row as { provider?: unknown }).provider === 'string' &&
213
+ (row as { provider: string }).provider.startsWith('lanes_');
214
+
215
+ document.setIn(['connections'], [...rows.filter((r) => !owned(r)), ...rows.filter(owned)]);
216
+
217
+ // The stamp this file was missing. Nothing reads it — every contract check is
218
+ // on a profile — which is exactly why it went stale, and a marker that lies
219
+ // is worse than no marker for whoever writes the next migration.
220
+ document.setIn(['contract'], 4);
221
+
222
+ await document.save();
223
+ }