@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.
- package/instructions/skills/lanes-link/SKILL.md +12 -12
- package/package.json +1 -1
- package/src/auth/oauth/store.ts +21 -5
- package/src/cli/accepts.ts +6 -5
- package/src/cli/commands/connect/index.ts +2 -1
- package/src/cli/commands/connect/settle.ts +12 -4
- package/src/cli/commands/connection.ts +2 -1
- package/src/cli/commands/identity.ts +4 -4
- package/src/cli/commands/knowledge/index.ts +9 -16
- package/src/cli/commands/knowledge/migrate.ts +9 -4
- package/src/cli/commands/knowledge/show.ts +14 -9
- package/src/cli/commands/operate/migrate.ts +5 -2
- package/src/cli/commands/operate/pair.ts +1 -1
- package/src/cli/commands/operate/serve.ts +1 -1
- package/src/cli/commands/owner/assets.ts +2 -2
- package/src/cli/commands/owner/entities.ts +2 -2
- package/src/cli/commands/owner/memory.ts +2 -2
- package/src/cli/commands/owner/tasks.ts +2 -2
- package/src/cli/commands/owner/vault.ts +3 -3
- package/src/cli/commands/profile/disposition.ts +236 -0
- package/src/cli/commands/profile/removal.ts +100 -13
- package/src/cli/commands/profile/remove.ts +67 -6
- package/src/cli/commands/profile.ts +33 -6
- package/src/cli/commands/secrets.ts +4 -4
- package/src/cli/commands/update-migration.ts +54 -0
- package/src/cli/commands/update.ts +36 -23
- package/src/cli/config-edit.ts +29 -8
- package/src/cli/config-repair-sweep.ts +119 -0
- package/src/cli/config-repair.ts +90 -141
- package/src/cli/config-templates.ts +26 -24
- package/src/cli/contract3-credentials.ts +294 -0
- package/src/cli/contract3-data.ts +143 -209
- package/src/cli/contract3-layout.ts +46 -0
- package/src/cli/contract3-shape.ts +34 -8
- package/src/cli/contract3.ts +141 -24
- package/src/cli/contract4-credentials.ts +207 -0
- package/src/cli/contract4-data.ts +399 -0
- package/src/cli/contract4-rename.ts +73 -0
- package/src/cli/contract4-yaml.ts +223 -0
- package/src/cli/contract4.ts +342 -0
- package/src/cli/identity.ts +44 -26
- package/src/cli/main.ts +6 -1
- package/src/cli/migrate-move.ts +166 -0
- package/src/cli/migrate-plan.ts +3 -3
- package/src/cli/publish.ts +1 -5
- package/src/cli/runtime/open.ts +5 -5
- package/src/cli/runtime/select.ts +2 -11
- package/src/cli/runtime/stores.ts +16 -11
- package/src/cli/runtime/vault.ts +2 -2
- package/src/cli/usage.ts +5 -1
- package/src/cli/workspace-migrate.ts +32 -11
- package/src/connectivity/manifest/provider.ts +31 -12
- package/src/connectivity/transports/imap/parser.ts +70 -9
- package/src/deployments/adapters/filesystem.ts +18 -3
- package/src/deployments/deploy.ts +5 -5
- package/src/deployments/gcp/bucket.ts +42 -6
- package/src/deployments/knowledge.ts +9 -4
- package/src/deployments/target.ts +28 -7
- package/src/deployments/upload.ts +39 -30
- package/src/profile/connections.ts +13 -1
- package/src/profile/deployments.ts +86 -8
- package/src/profile/index.ts +5 -1
- package/src/profile/knowledge.ts +18 -5
- package/src/profile/layout.ts +147 -71
- package/src/profile/load.ts +53 -17
- package/src/profile/schema.ts +11 -2
- package/src/profile/testing.ts +45 -10
- package/src/profile/workspace.ts +66 -30
- package/src/providers/assets/provider.ts +6 -6
- package/src/providers/entities/provider.ts +6 -6
- package/src/providers/entities/writes.ts +1 -1
- package/src/providers/identity/provider.ts +1 -1
- package/src/providers/memory/provider.ts +6 -6
- package/src/providers/setup/provider.ts +3 -3
- package/src/providers/skills/provider.ts +2 -2
- package/src/providers/tasks/provider.ts +6 -6
- package/src/providers/vault/provider.ts +1 -1
- package/src/registry/registry.ts +1 -1
- package/src/server/endpoint.ts +4 -0
- package/src/server/harness.ts +1 -1
- package/src/server/mcp/instructions.ts +21 -21
- package/src/server/mcp/routing.ts +3 -3
- package/src/server/mcp/tools.ts +16 -3
- package/src/server/mcp/visibility.ts +56 -4
- package/src/stores/blobs/conformance.ts +19 -0
- package/src/stores/state/index.ts +76 -10
- 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
|
+
}
|