@lanes-sh/link 0.8.0 → 0.9.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/README.md +1 -25
- 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/brand.ts +20 -15
- package/src/cli/callback-page.ts +8 -7
- 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 +183 -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 +349 -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
|
@@ -1,119 +1,18 @@
|
|
|
1
|
-
import { ConfigError,
|
|
2
|
-
import {
|
|
1
|
+
import { ConfigError, LEGACY_DATA_DIR } from '#profile';
|
|
2
|
+
import { applyMoves, assertOneObjectPerDestination, claim, type Move } from './migrate-move.ts';
|
|
3
|
+
import { C3 } from './contract3-layout.ts';
|
|
3
4
|
import type { BlobStore } from '#stores/blobs';
|
|
5
|
+
import { CONNECTIONS_NAMESPACE, decodeSegment, objectKey } from '#stores/state';
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* The half of the contract-3 migration that moves bytes rather than YAML.
|
|
7
9
|
*
|
|
8
10
|
* Split from `contract3.ts` on the seam the migration already has: that file
|
|
9
11
|
* decides *what* the new shape is, and this one carries the credentials and
|
|
10
|
-
* objects into it. Both
|
|
11
|
-
*
|
|
12
|
-
* here — nothing is deleted until what replaced it has been read back.
|
|
12
|
+
* objects into it. Both are ordered so a crash between any two steps leaves a
|
|
13
|
+
* workspace that still opens — nothing is deleted until it has been read back.
|
|
13
14
|
*/
|
|
14
15
|
|
|
15
|
-
export interface Move {
|
|
16
|
-
readonly from: string;
|
|
17
|
-
readonly to: string;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/** `gmail.main` — how a connection is addressed in every file after this. */
|
|
21
|
-
function keyOf(connection: { provider: string; id: string }): string {
|
|
22
|
-
return `${connection.provider}.${connection.id}`;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Which credential refs the merged store will hold.
|
|
27
|
-
*
|
|
28
|
-
* Read-only, and it runs before anything is written so the report an operator
|
|
29
|
-
* confirms is the real one. A ref present in two profiles' stores with two
|
|
30
|
-
* different values is the one thing this cannot resolve, and it is reported
|
|
31
|
-
* rather than merged: both are real credentials, and picking either would point
|
|
32
|
-
* a connection at the wrong account's token.
|
|
33
|
-
*
|
|
34
|
-
* **A workspace in a bucket has nothing to merge, and this says so rather than
|
|
35
|
-
* finding out.** `workspacePath` refuses a filesystem adapter against a remote
|
|
36
|
-
* root, so the only credential store such a workspace can declare is
|
|
37
|
-
* `gcp-secret-manager` — whose refs were never scoped by profile, and are
|
|
38
|
-
* therefore already what contract 3 wants. Without the guard the path below is
|
|
39
|
-
* built by string interpolation into `gs://bucket/data/<profile>/credentials.enc`
|
|
40
|
-
* and handed to `Bun.file`, where the failure is swallowed by the `catch` and
|
|
41
|
-
* reads exactly like a workspace with no credentials in it.
|
|
42
|
-
*/
|
|
43
|
-
export async function planCredentials(root: string, profiles: readonly string[]): Promise<string[]> {
|
|
44
|
-
if (isRemoteWorkspace(root)) return [];
|
|
45
|
-
|
|
46
|
-
const refs = new Set<string>();
|
|
47
|
-
|
|
48
|
-
for (const profile of profiles) {
|
|
49
|
-
const store = createFileSecretStore({ path: `${root}/${DATA_DIR}/${profile}/credentials.enc` });
|
|
50
|
-
try {
|
|
51
|
-
for (const ref of await store.list()) refs.add(ref);
|
|
52
|
-
} catch {
|
|
53
|
-
// A store that will not open is reported by `doctor`, not here. This is a
|
|
54
|
-
// preview and must not fail on a workspace that is already broken.
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
return [...refs].sort();
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Copy every profile's credentials into the workspace store.
|
|
63
|
-
*
|
|
64
|
-
* Written and read back before the old stores are touched, which is the whole
|
|
65
|
-
* of the safety argument: a half-finished merge that has not deleted anything is
|
|
66
|
-
* recoverable by running it again, and one that deleted first is not.
|
|
67
|
-
*
|
|
68
|
-
* The old stores are left in place regardless. They are a few kilobytes, they
|
|
69
|
-
* are the only copy of anything if this went wrong, and `doctor` names them so
|
|
70
|
-
* an operator can remove them once the endpoint has served a request.
|
|
71
|
-
*
|
|
72
|
-
* Skipped entirely for a workspace in a bucket, for the reason `planCredentials`
|
|
73
|
-
* gives: its credentials are in Secret Manager under refs that were never
|
|
74
|
-
* per-profile, so there is no second store to fold in.
|
|
75
|
-
*/
|
|
76
|
-
export async function mergeCredentials(root: string, profiles: readonly string[]): Promise<void> {
|
|
77
|
-
if (isRemoteWorkspace(root)) return;
|
|
78
|
-
|
|
79
|
-
const destination = createFileSecretStore({ path: `${root}/${layout.credentials()}` });
|
|
80
|
-
|
|
81
|
-
for (const profile of profiles) {
|
|
82
|
-
const source = createFileSecretStore({ path: `${root}/${DATA_DIR}/${profile}/credentials.enc` });
|
|
83
|
-
|
|
84
|
-
let refs: string[];
|
|
85
|
-
try {
|
|
86
|
-
refs = await source.list();
|
|
87
|
-
} catch {
|
|
88
|
-
continue;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
for (const ref of refs) {
|
|
92
|
-
const value = await source.get(ref);
|
|
93
|
-
if (value === null) continue;
|
|
94
|
-
|
|
95
|
-
const held = await destination.get(ref);
|
|
96
|
-
if (held !== null) {
|
|
97
|
-
if (held === value) continue;
|
|
98
|
-
throw new ConfigError(
|
|
99
|
-
`Two profiles hold different values for the credential "${ref}", and this migration ` +
|
|
100
|
-
`cannot choose between them.\n` +
|
|
101
|
-
` Both are real credentials for different accounts, and picking either would point a ` +
|
|
102
|
-
`connection at the wrong one.\n` +
|
|
103
|
-
` Rename one connection before migrating, so its credential ref differs.`,
|
|
104
|
-
);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
await destination.set(ref, value);
|
|
108
|
-
if ((await destination.get(ref)) !== value) {
|
|
109
|
-
throw new ConfigError(
|
|
110
|
-
`The credential "${ref}" did not read back after being written to ` +
|
|
111
|
-
`${layout.credentials()}. Nothing has been deleted; fix the store and run this again.`,
|
|
112
|
-
);
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
16
|
|
|
118
17
|
/**
|
|
119
18
|
* Where every object under `data/<profile>/` is going.
|
|
@@ -136,10 +35,14 @@ export async function planMoves(
|
|
|
136
35
|
perProfile: ReadonlyMap<string, ReadonlyMap<string, string>>,
|
|
137
36
|
): Promise<Move[]> {
|
|
138
37
|
const moves: Move[] = [];
|
|
38
|
+
// Destinations inside `state.kv` that an earlier profile already claimed, for
|
|
39
|
+
// the two namespaces where a second claim is a duplicate rather than a
|
|
40
|
+
// conflict. See `stateMove`.
|
|
41
|
+
const claimed = new Set<string>();
|
|
139
42
|
|
|
140
43
|
for (const profile of profiles) {
|
|
141
44
|
const mapping = perProfile.get(profile) ?? new Map<string, string>();
|
|
142
|
-
const prefix = `${
|
|
45
|
+
const prefix = `${LEGACY_DATA_DIR}/${profile}/`;
|
|
143
46
|
|
|
144
47
|
for (const blob of await files.list(prefix)) {
|
|
145
48
|
const rest = blob.key.slice(prefix.length);
|
|
@@ -147,9 +50,7 @@ export async function planMoves(
|
|
|
147
50
|
if (head === undefined) continue;
|
|
148
51
|
|
|
149
52
|
// The credential store is merged rather than moved, and the old copy is
|
|
150
|
-
// deliberately left behind.
|
|
151
|
-
// become the workspace's, but their contents already carry the profile in
|
|
152
|
-
// every record, so they are concatenated by moving the objects across.
|
|
53
|
+
// deliberately left behind.
|
|
153
54
|
if (head === 'credentials.enc' || head === 'credentials.enc.key') continue;
|
|
154
55
|
|
|
155
56
|
// The instance this profile's single-instance surfaces became. Both are
|
|
@@ -159,26 +60,41 @@ export async function planMoves(
|
|
|
159
60
|
// which is the worst of the collisions because the wrong answer is a
|
|
160
61
|
// credential (ADR-059).
|
|
161
62
|
if (head === 'vault.enc') {
|
|
162
|
-
moves.push({ from: blob.key, to:
|
|
63
|
+
moves.push({ from: blob.key, to: C3.vault(instanceOf(mapping, 'vault')) });
|
|
163
64
|
continue;
|
|
164
65
|
}
|
|
165
66
|
if (head === 'vault.enc.key') {
|
|
166
|
-
moves.push({ from: blob.key, to: `${
|
|
67
|
+
moves.push({ from: blob.key, to: `${C3.vault(instanceOf(mapping, 'vault'))}.key` });
|
|
167
68
|
continue;
|
|
168
69
|
}
|
|
169
70
|
if (head === 'skills.d') {
|
|
170
71
|
moves.push({
|
|
171
72
|
from: blob.key,
|
|
172
|
-
to: `${
|
|
73
|
+
to: `${C3.skills(instanceOf(mapping, 'skills'))}/${tail.join('/')}`,
|
|
173
74
|
});
|
|
174
75
|
continue;
|
|
175
76
|
}
|
|
77
|
+
// Under ADR-030 a manifest lived in the profile, so an operator using
|
|
78
|
+
// their own connector in two profiles holds two copies of one file. They
|
|
79
|
+
// are the same manifest; refusing byte-identical files and asking for the
|
|
80
|
+
// data directory's layout was not a useful answer.
|
|
176
81
|
if (head === 'providers.d') {
|
|
177
|
-
|
|
82
|
+
const move = claim(claimed, {
|
|
83
|
+
from: blob.key,
|
|
84
|
+
to: `${C3.providers()}/${tail.join('/')}`,
|
|
85
|
+
});
|
|
86
|
+
if (move !== null) moves.push(move);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
// One object per event under a key already carrying the timestamp, so
|
|
90
|
+
// concatenating three profiles' logs is exactly moving them across.
|
|
91
|
+
if (head === 'audit.log') {
|
|
92
|
+
moves.push({ from: blob.key, to: `${C3.audit()}/${tail.join('/')}` });
|
|
178
93
|
continue;
|
|
179
94
|
}
|
|
180
|
-
if (head === 'state.kv'
|
|
181
|
-
|
|
95
|
+
if (head === 'state.kv') {
|
|
96
|
+
const move = stateMove(blob.key, tail, mapping, claimed);
|
|
97
|
+
if (move !== null) moves.push(move);
|
|
182
98
|
continue;
|
|
183
99
|
}
|
|
184
100
|
|
|
@@ -190,7 +106,7 @@ export async function planMoves(
|
|
|
190
106
|
// This profile's old key, through this profile's mapping.
|
|
191
107
|
const settled = mapping.get(`${head}.${connection}`);
|
|
192
108
|
const id = settled === undefined ? connection : (settled.split('.')[1] ?? connection);
|
|
193
|
-
moves.push({ from: blob.key, to: `${
|
|
109
|
+
moves.push({ from: blob.key, to: `${LEGACY_DATA_DIR}/${head}/${id}/${tail.slice(1).join('/')}` });
|
|
194
110
|
}
|
|
195
111
|
}
|
|
196
112
|
|
|
@@ -198,31 +114,120 @@ export async function planMoves(
|
|
|
198
114
|
return moves;
|
|
199
115
|
}
|
|
200
116
|
|
|
117
|
+
|
|
201
118
|
/**
|
|
202
|
-
*
|
|
119
|
+
* Where one object under a profile's `state.kv` is going.
|
|
120
|
+
*
|
|
121
|
+
* `state.kv` used to be moved verbatim, on the grounds that its records "already
|
|
122
|
+
* carry the profile". They do not. `connections.v1` is keyed on
|
|
123
|
+
* `<provider>.<id>` — precisely what the hoist renames — so every profile's
|
|
124
|
+
* owner layer wrote `connections.v1/vault.main`, and three profiles aimed three
|
|
125
|
+
* objects at one key. That is the collision `assertOneObjectPerDestination`
|
|
126
|
+
* called unreachable, and it was reachable from any workspace with two profiles
|
|
127
|
+
* in it.
|
|
203
128
|
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
129
|
+
* Keys are decoded rather than pattern-matched: on disk both halves are
|
|
130
|
+
* percent-encoded per segment (`connections%2Ev1/vault%2Emain.json`), and
|
|
131
|
+
* respelling that here would be a second copy of an encoding that must have one.
|
|
132
|
+
*
|
|
133
|
+
* `null` means leave it where it is — nothing is deleted by not moving it, and
|
|
134
|
+
* `doctor` names what is left.
|
|
210
135
|
*/
|
|
211
|
-
function
|
|
212
|
-
|
|
136
|
+
function stateMove(
|
|
137
|
+
from: string,
|
|
138
|
+
tail: readonly string[],
|
|
139
|
+
mapping: ReadonlyMap<string, string>,
|
|
140
|
+
claimed: Set<string>,
|
|
141
|
+
): Move | null {
|
|
142
|
+
const here = `${C3.state()}/${tail.join('/')}`;
|
|
143
|
+
const leaf = tail[tail.length - 1];
|
|
144
|
+
|
|
145
|
+
// Not an object this module wrote: moved as it is, and still held to the
|
|
146
|
+
// one-object-per-destination rule, because an unrecognised collision is a
|
|
147
|
+
// thing to refuse rather than to resolve by guessing.
|
|
148
|
+
if (tail.length < 2 || leaf === undefined || !leaf.endsWith('.json')) {
|
|
149
|
+
return { from, to: here };
|
|
150
|
+
}
|
|
213
151
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
152
|
+
// `decodeSegment` is `decodeURIComponent`, which throws `URIError` on a stray
|
|
153
|
+
// `%`. Aborting somebody's whole migration on `URI error`, naming no file, is
|
|
154
|
+
// not an answer — an undecodable key is one this does not understand, and
|
|
155
|
+
// those are moved as they are.
|
|
156
|
+
let segments: string[];
|
|
157
|
+
let key: string;
|
|
158
|
+
try {
|
|
159
|
+
segments = tail.slice(0, -1).map(decodeSegment);
|
|
160
|
+
key = decodeSegment(leaf.slice(0, -'.json'.length));
|
|
161
|
+
} catch {
|
|
162
|
+
return { from, to: here };
|
|
163
|
+
}
|
|
164
|
+
const namespace = segments.join('/');
|
|
165
|
+
|
|
166
|
+
// Keyed on the provider id, not a connection, so two profiles on the same
|
|
167
|
+
// vendor hold two entries under one key. `open.ts` treats a miss and a corrupt
|
|
168
|
+
// entry alike as "not discovered yet" and `connect` refreshes it: first wins.
|
|
169
|
+
if (namespace === 'discovery') {
|
|
170
|
+
return claim(claimed, { from, to: here });
|
|
225
171
|
}
|
|
172
|
+
|
|
173
|
+
// `<provider>/<connection>` — a provider's own state, keyed on the connection
|
|
174
|
+
// exactly as its blobs are. Left out of the first pass, so two profiles
|
|
175
|
+
// holding one provider's fixed-name object (`dav`'s home, `bunq`'s session)
|
|
176
|
+
// still aimed at one key.
|
|
177
|
+
if (segments.length === 2) {
|
|
178
|
+
const [provider, connection] = segments as [string, string];
|
|
179
|
+
const settled = mapping.get(`${provider}.${connection}`);
|
|
180
|
+
if (settled === undefined) return { from, to: here };
|
|
181
|
+
|
|
182
|
+
const id = settled.slice(settled.indexOf('.') + 1);
|
|
183
|
+
return claim(claimed, { from, to: `${C3.state()}/${objectKey(`${provider}/${id}`, key)}` });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (namespace !== CONNECTIONS_NAMESPACE) return { from, to: here };
|
|
187
|
+
|
|
188
|
+
// This profile's old key, through this profile's mapping — the same lookup
|
|
189
|
+
// the provider-blob branch does, and wrong in the same way if it is skipped.
|
|
190
|
+
const settled = mapping.get(key);
|
|
191
|
+
|
|
192
|
+
// Orphaned under contract 2 already — no row, and `hoistConnections` reads
|
|
193
|
+
// rows — so hoisting it would manufacture a connection nothing grants, and its
|
|
194
|
+
// key can collide with a rename that is real.
|
|
195
|
+
if (settled === undefined) return null;
|
|
196
|
+
|
|
197
|
+
const to = `${C3.state()}/${objectKey(CONNECTIONS_NAMESPACE, settled)}`;
|
|
198
|
+
|
|
199
|
+
// Two profiles on one account merge into one row, so both hold a record for
|
|
200
|
+
// it. One connection; the second differs only in when it was written.
|
|
201
|
+
if (settled === key) return claim(claimed, { from, to });
|
|
202
|
+
|
|
203
|
+
const dot = settled.indexOf('.');
|
|
204
|
+
const provider = settled.slice(0, dot);
|
|
205
|
+
const id = settled.slice(dot + 1);
|
|
206
|
+
return claim(claimed, { from, to, rewrite: (data) => retarget(data, provider, id) });
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* A connection record, told what it is now called.
|
|
212
|
+
*
|
|
213
|
+
* Spread rather than assigned field by field, so key order and anything a later
|
|
214
|
+
* version added survive. Bytes that are not a JSON object are returned
|
|
215
|
+
* untouched: refusing to move what would not parse strands it under a profile
|
|
216
|
+
* directory nothing reads.
|
|
217
|
+
*/
|
|
218
|
+
function retarget(data: Uint8Array, provider: string, id: string): Uint8Array {
|
|
219
|
+
let record: unknown;
|
|
220
|
+
try {
|
|
221
|
+
record = JSON.parse(new TextDecoder().decode(data));
|
|
222
|
+
} catch {
|
|
223
|
+
return data;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (record === null || typeof record !== 'object' || Array.isArray(record)) return data;
|
|
227
|
+
|
|
228
|
+
return new TextEncoder().encode(
|
|
229
|
+
JSON.stringify({ ...(record as Record<string, unknown>), provider, id }),
|
|
230
|
+
);
|
|
226
231
|
}
|
|
227
232
|
|
|
228
233
|
/**
|
|
@@ -254,75 +259,4 @@ function instanceOf(mapping: ReadonlyMap<string, string>, provider: string): str
|
|
|
254
259
|
return 'main';
|
|
255
260
|
}
|
|
256
261
|
|
|
257
|
-
/**
|
|
258
|
-
* How many objects are in flight at once.
|
|
259
|
-
*
|
|
260
|
-
* Serial was fine while this only ever ran against a local disk. A deployed
|
|
261
|
-
* workspace's audit log is one object per event, so the first real bucket this
|
|
262
|
-
* migrated held 1,906 of them — three round trips each, in series, is minutes of
|
|
263
|
-
* a deploy spent with nothing on screen. The same 16 the read paths in
|
|
264
|
-
* `#providers/memory` and `#providers/tasks` settled on, and for the same
|
|
265
|
-
* reason: enough to hide the latency, not enough to look like an incident to the
|
|
266
|
-
* other end.
|
|
267
|
-
*
|
|
268
|
-
* Safe to widen only while each move stays independent, which is what
|
|
269
|
-
* `assertOneObjectPerDestination` guarantees.
|
|
270
|
-
*/
|
|
271
|
-
const MOVE_CONCURRENCY = 16;
|
|
272
|
-
|
|
273
|
-
export async function applyMoves(files: BlobStore, moves: readonly Move[]): Promise<void> {
|
|
274
|
-
const pending = moves.filter((move) => move.from !== move.to);
|
|
275
|
-
|
|
276
|
-
for (let start = 0; start < pending.length; start += MOVE_CONCURRENCY) {
|
|
277
|
-
await Promise.all(
|
|
278
|
-
pending.slice(start, start + MOVE_CONCURRENCY).map((move) => applyMove(files, move)),
|
|
279
|
-
);
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
/** One object, moved or finished. Never deletes before the copy reads back. */
|
|
284
|
-
async function applyMove(files: BlobStore, move: Move): Promise<void> {
|
|
285
|
-
const data = await files.get(move.from);
|
|
286
|
-
if (data === null) return;
|
|
287
|
-
|
|
288
|
-
if (await files.has(move.to)) {
|
|
289
|
-
const held = await files.get(move.to);
|
|
290
|
-
|
|
291
|
-
// Raced away between the two calls, so there is nothing there after all and
|
|
292
|
-
// the ordinary path below is still the right one.
|
|
293
|
-
if (held !== null) {
|
|
294
|
-
if (!sameBytes(held, data)) {
|
|
295
|
-
throw new ConfigError(
|
|
296
|
-
`Two objects want to be at ${move.to}, and this migration cannot merge them.\n` +
|
|
297
|
-
` ${move.from} is the second, and what is already there is not a copy of it.\n` +
|
|
298
|
-
' Nothing has been deleted. This should be unreachable: the hoist gives every ' +
|
|
299
|
-
'profile its own instance of each owner-layer surface. Please report it with the ' +
|
|
300
|
-
'layout of your data directory.',
|
|
301
|
-
);
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
// Already copied, by a run that did not get to the delete.
|
|
305
|
-
await files.delete(move.from);
|
|
306
|
-
return;
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
await files.put(move.to, data);
|
|
311
|
-
if ((await files.get(move.to)) === null) {
|
|
312
|
-
throw new ConfigError(
|
|
313
|
-
`${move.to} did not read back after being written. Nothing has been deleted; ` +
|
|
314
|
-
`fix the store and run this again.`,
|
|
315
|
-
);
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
await files.delete(move.from);
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
function sameBytes(left: Uint8Array, right: Uint8Array): boolean {
|
|
322
|
-
if (left.length !== right.length) return false;
|
|
323
|
-
for (let index = 0; index < left.length; index += 1) {
|
|
324
|
-
if (left[index] !== right[index]) return false;
|
|
325
|
-
}
|
|
326
|
-
return true;
|
|
327
|
-
}
|
|
328
262
|
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { LEGACY_DATA_DIR, LEGACY_WORKSPACE_FILE } from '#profile';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Where contract 3 put things, frozen.
|
|
5
|
+
*
|
|
6
|
+
* `#profile`'s `layout` describes where things live *now*. A migration lives
|
|
7
|
+
* between two layouts and must not ask it: `contract3.ts` migrates to this one
|
|
8
|
+
* and `contract4.ts` migrates away from it, so both need it spelled somewhere
|
|
9
|
+
* that will not move when the live one does. Asking `layout` made the contract-3
|
|
10
|
+
* migration write a contract-4 tree, skipping the step contract 4 was about to
|
|
11
|
+
* take and leaving nothing for it to find.
|
|
12
|
+
*
|
|
13
|
+
* One module rather than a copy in each, for the reason `layout.ts` exists: the
|
|
14
|
+
* credential path was spelled in three files and they have to agree forever.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* The owner layer's ids through contract 3, before the `lanes_` prefix.
|
|
18
|
+
*
|
|
19
|
+
* Spelled here for the reason every other path in this file is: a migration
|
|
20
|
+
* reads the shape it is migrating *from*. `RESERVED_PROVIDER_IDS` is the live
|
|
21
|
+
* list and is `lanes_memory` now, so asking it whether a contract-2 row is
|
|
22
|
+
* owner-layer answers no for every one of them.
|
|
23
|
+
*/
|
|
24
|
+
export const C3_OWNER_PROVIDERS: readonly string[] = [
|
|
25
|
+
'memory',
|
|
26
|
+
'tasks',
|
|
27
|
+
'assets',
|
|
28
|
+
'skills',
|
|
29
|
+
'vault',
|
|
30
|
+
'setup',
|
|
31
|
+
'identity',
|
|
32
|
+
'entities',
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
export const C3 = {
|
|
36
|
+
/** The registry, before it became `workspaces.yaml`. */
|
|
37
|
+
workspace: LEGACY_WORKSPACE_FILE,
|
|
38
|
+
/** A profile's declaration, before it moved inside its own directory. */
|
|
39
|
+
profile: (profile: string): string => `profiles/${profile}.yaml`,
|
|
40
|
+
credentials: (): string => `${LEGACY_DATA_DIR}/credentials.enc`,
|
|
41
|
+
state: (): string => `${LEGACY_DATA_DIR}/state.kv`,
|
|
42
|
+
audit: (): string => `${LEGACY_DATA_DIR}/audit.log`,
|
|
43
|
+
providers: (): string => `${LEGACY_DATA_DIR}/providers.d`,
|
|
44
|
+
vault: (connection: string): string => `${LEGACY_DATA_DIR}/vault.d/${connection}.enc`,
|
|
45
|
+
skills: (connection: string): string => `${LEGACY_DATA_DIR}/skills.d/${connection}`,
|
|
46
|
+
} as const;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { RESERVED_PROVIDER_IDS } from '#connectivity';
|
|
2
2
|
import type { ContractRename, LegacyConnection, LegacyProfile } from './contract3.ts';
|
|
3
3
|
import { keyOf } from './contract3.ts';
|
|
4
|
+
import { C3_OWNER_PROVIDERS } from './contract3-layout.ts';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Turning contract 2's shape into contract 3's, without touching a file.
|
|
@@ -49,7 +50,7 @@ export function hoistConnections(profiles: ReadonlyMap<string, LegacyProfile>):
|
|
|
49
50
|
// merge. That is the one outcome ADR-059 forbids: interleaving two sets of
|
|
50
51
|
// notes is not reversible and not reviewable, and for the vault the wrong
|
|
51
52
|
// answer is a credential. Keying on the profile forces a rename instead.
|
|
52
|
-
const owner =
|
|
53
|
+
const owner = C3_OWNER_PROVIDERS.includes(connection.provider);
|
|
53
54
|
const identity = owner
|
|
54
55
|
? `${connection.provider} @${profile}`
|
|
55
56
|
: `${connection.provider} ${connection.account}`;
|
|
@@ -166,7 +167,20 @@ export function grantsFor(
|
|
|
166
167
|
return pattern === '*' || pattern.startsWith(`${provider}.`);
|
|
167
168
|
};
|
|
168
169
|
|
|
169
|
-
|
|
170
|
+
// Keyed on the *settled* connection, because two rows can become one. A
|
|
171
|
+
// profile could hold `gmail.main` and `gmail.archive` for the same mailbox at
|
|
172
|
+
// different scopes — which is legal under contract 2, and the reason tokens
|
|
173
|
+
// are per connection at all — and the hoist keys on provider-plus-account, so
|
|
174
|
+
// both settle on one row. Emitting a grant per original row then wrote the
|
|
175
|
+
// same `connection:` twice and `assertGrantsUnique` refused the profile,
|
|
176
|
+
// failing the migration on a merge it had just performed itself.
|
|
177
|
+
//
|
|
178
|
+
// The rules are unioned rather than picked between. Both rows were in force
|
|
179
|
+
// before, the connection they describe is now one connection, and dropping
|
|
180
|
+
// either would narrow a policy the operator never narrowed.
|
|
181
|
+
const rows = new Map<string, { connection: string; allow: Rule[]; deny: Rule[] }>();
|
|
182
|
+
|
|
183
|
+
for (const connection of config.connections ?? []) {
|
|
170
184
|
const provider = connection.provider;
|
|
171
185
|
// A bare `*` becomes the provider wildcard rather than being copied
|
|
172
186
|
// through. It would still mean the same thing inside a row, which is
|
|
@@ -177,10 +191,22 @@ export function grantsFor(
|
|
|
177
191
|
return rule.capability === '*' ? { ...rule, capability: `${provider}.*` } : rule;
|
|
178
192
|
};
|
|
179
193
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
194
|
+
const key = mapping.get(keyOf(connection)) ?? keyOf(connection);
|
|
195
|
+
const row = rows.get(key) ?? { connection: key, allow: [], deny: [] };
|
|
196
|
+
|
|
197
|
+
row.allow = union(row.allow, allow.filter((rule) => covers(rule, provider)).map(widen));
|
|
198
|
+
row.deny = union(row.deny, deny.filter((rule) => covers(rule, provider)).map(widen));
|
|
199
|
+
rows.set(key, row);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return [...rows.values()];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Rules from two merged rows, keeping the first spelling of each capability. */
|
|
206
|
+
function union(held: readonly Rule[], adding: readonly Rule[]): Rule[] {
|
|
207
|
+
const merged = [...held];
|
|
208
|
+
for (const rule of adding) {
|
|
209
|
+
if (!merged.some((one) => capabilityOf(one) === capabilityOf(rule))) merged.push(rule);
|
|
210
|
+
}
|
|
211
|
+
return merged;
|
|
186
212
|
}
|