@lanes-sh/link 0.7.2 → 0.8.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 (156) hide show
  1. package/README.md +20 -10
  2. package/instructions/agents/lanes-link-scout.md +2 -2
  3. package/instructions/skills/lanes-link/SKILL.md +136 -61
  4. package/package.json +2 -1
  5. package/src/audit/index.ts +8 -1
  6. package/src/auth/index.ts +58 -2
  7. package/src/auth/lanes/assertion.ts +256 -0
  8. package/src/auth/lanes/callback.ts +135 -0
  9. package/src/auth/lanes/federation.ts +50 -0
  10. package/src/auth/lanes/login.ts +294 -0
  11. package/src/auth/lanes/members.ts +103 -0
  12. package/src/auth/lanes/session.ts +97 -0
  13. package/src/auth/oauth/grant.ts +183 -0
  14. package/src/auth/oauth/result.ts +27 -0
  15. package/src/auth/oauth/server.ts +176 -203
  16. package/src/auth/oauth/store.ts +65 -0
  17. package/src/auth/remote.ts +32 -9
  18. package/src/cli/accepts.ts +108 -0
  19. package/src/cli/argv.ts +57 -3
  20. package/src/cli/audit-change.ts +140 -0
  21. package/src/cli/callback-page.ts +36 -115
  22. package/src/cli/commands/auth-dispatch.ts +48 -0
  23. package/src/cli/commands/auth.ts +229 -0
  24. package/src/cli/commands/connect/accounts.ts +4 -4
  25. package/src/cli/commands/connect/authorise.ts +4 -4
  26. package/src/cli/commands/connect/bind-credential.ts +2 -1
  27. package/src/cli/commands/connect/custom/index.ts +1 -1
  28. package/src/cli/commands/connect/custom/write.ts +2 -2
  29. package/src/cli/commands/connect/grant.ts +29 -14
  30. package/src/cli/commands/connect/index.ts +88 -87
  31. package/src/cli/commands/connect/options.ts +83 -0
  32. package/src/cli/commands/connect/registration.ts +50 -0
  33. package/src/cli/commands/connect/requirements.ts +1 -1
  34. package/src/cli/commands/connect/settle.ts +4 -2
  35. package/src/cli/commands/connect/target-note.ts +7 -2
  36. package/src/cli/commands/connect/unknown.ts +1 -1
  37. package/src/cli/commands/connect/variables.ts +3 -2
  38. package/src/cli/commands/connection-list.ts +116 -0
  39. package/src/cli/commands/connection.ts +182 -165
  40. package/src/cli/commands/grant.ts +140 -0
  41. package/src/cli/commands/identity.ts +21 -9
  42. package/src/cli/commands/knowledge/index.ts +46 -79
  43. package/src/cli/commands/knowledge/migrate.ts +74 -13
  44. package/src/cli/commands/knowledge/show.ts +92 -0
  45. package/src/cli/commands/knowledge.ts +2 -1
  46. package/src/cli/commands/mcp/harnesses.ts +30 -8
  47. package/src/cli/commands/mcp/onboarding.ts +86 -0
  48. package/src/cli/commands/mcp/register.ts +16 -2
  49. package/src/cli/commands/mcp.ts +1 -0
  50. package/src/cli/commands/members.ts +288 -0
  51. package/src/cli/commands/operate/attach.ts +3 -3
  52. package/src/cli/commands/operate/audit.ts +11 -7
  53. package/src/cli/commands/operate/auth.ts +28 -11
  54. package/src/cli/commands/operate/findings.ts +2 -1
  55. package/src/cli/commands/operate/inspect.ts +37 -19
  56. package/src/cli/commands/operate/migrate.ts +29 -12
  57. package/src/cli/commands/operate/outputs.ts +3 -3
  58. package/src/cli/commands/operate/pair-certificate.ts +141 -0
  59. package/src/cli/commands/operate/pair.ts +324 -0
  60. package/src/cli/commands/operate/policy.ts +73 -22
  61. package/src/cli/commands/operate/serve.ts +52 -4
  62. package/src/cli/commands/operate/status.ts +18 -10
  63. package/src/cli/commands/operate/tools.ts +2 -2
  64. package/src/cli/commands/operate.ts +2 -0
  65. package/src/cli/commands/owner/shared.ts +13 -2
  66. package/src/cli/commands/owner/skills.ts +28 -8
  67. package/src/cli/commands/profile/removal.ts +79 -76
  68. package/src/cli/commands/profile/remove.ts +16 -1
  69. package/src/cli/commands/profile.ts +46 -10
  70. package/src/cli/commands/relabel.ts +112 -0
  71. package/src/cli/commands/secrets.ts +34 -12
  72. package/src/cli/commands/set-workspace.ts +96 -0
  73. package/src/cli/commands/setup.ts +2 -2
  74. package/src/cli/commands/sync.ts +8 -8
  75. package/src/cli/commands/target.ts +9 -7
  76. package/src/cli/commands/update.ts +58 -17
  77. package/src/cli/config-edit.ts +75 -140
  78. package/src/cli/config-migrate.ts +82 -64
  79. package/src/cli/config-repair.ts +89 -32
  80. package/src/cli/config-templates.ts +198 -0
  81. package/src/cli/contract3-data.ts +328 -0
  82. package/src/cli/contract3-shape.ts +186 -0
  83. package/src/cli/contract3.ts +282 -0
  84. package/src/cli/endpoint-url.ts +1 -1
  85. package/src/cli/lanes.ts +25 -1
  86. package/src/cli/main.ts +89 -14
  87. package/src/cli/migrate-plan.ts +12 -6
  88. package/src/cli/output.ts +34 -1
  89. package/src/cli/publish.ts +5 -2
  90. package/src/cli/runtime/open.ts +63 -98
  91. package/src/cli/runtime/registry.ts +6 -7
  92. package/src/cli/runtime/stores.ts +53 -0
  93. package/src/cli/runtime/types.ts +106 -0
  94. package/src/cli/runtime/vault.ts +19 -4
  95. package/src/cli/runtime/workspace.ts +60 -0
  96. package/src/cli/runtime.ts +2 -1
  97. package/src/cli/selection-require.ts +44 -13
  98. package/src/cli/selection.ts +127 -145
  99. package/src/cli/usage.ts +34 -18
  100. package/src/cli/workspace-migrate.ts +125 -16
  101. package/src/connectivity/manifest/provider.ts +3 -1
  102. package/src/connectivity/manifest/requirements.ts +1 -1
  103. package/src/deployments/bind.ts +1 -1
  104. package/src/deployments/deploy.ts +36 -27
  105. package/src/deployments/gcp/bucket.ts +18 -7
  106. package/src/deployments/gcp/provision.ts +7 -7
  107. package/src/deployments/prepare.ts +72 -24
  108. package/src/deployments/record.ts +1 -1
  109. package/src/deployments/report.ts +2 -2
  110. package/src/deployments/serving.ts +15 -74
  111. package/src/deployments/target.ts +15 -15
  112. package/src/deployments/upload.ts +46 -22
  113. package/src/dispatch/deps.ts +88 -0
  114. package/src/dispatch/dispatch.ts +21 -62
  115. package/src/policy/index.ts +47 -15
  116. package/src/profile/connections.ts +183 -0
  117. package/src/profile/deployments.ts +3 -3
  118. package/src/profile/index.ts +30 -5
  119. package/src/profile/layout.ts +86 -89
  120. package/src/profile/load.ts +80 -47
  121. package/src/profile/pairing.ts +32 -0
  122. package/src/profile/primitives.ts +35 -1
  123. package/src/profile/registry.ts +6 -6
  124. package/src/profile/schema.ts +172 -21
  125. package/src/profile/targets.ts +21 -9
  126. package/src/profile/testing.ts +69 -2
  127. package/src/profile/workspace.ts +58 -3
  128. package/src/providers/custom/index.ts +1 -1
  129. package/src/providers/custom/load.ts +2 -3
  130. package/src/providers/identity/provider.ts +1 -1
  131. package/src/providers/memory/provider.ts +20 -2
  132. package/src/providers/setup/plan.ts +1 -1
  133. package/src/providers/slack/index.ts +2 -2
  134. package/src/registry/policy-bridge.ts +33 -11
  135. package/src/registry/reconcile.ts +4 -4
  136. package/src/server/authorization.ts +94 -0
  137. package/src/server/edge.ts +14 -1
  138. package/src/server/endpoint.ts +85 -104
  139. package/src/server/generation.ts +10 -1
  140. package/src/server/harness.ts +71 -13
  141. package/src/server/index.ts +31 -0
  142. package/src/server/mcp/build.ts +20 -1
  143. package/src/server/mcp/client-info.ts +54 -0
  144. package/src/server/mcp/guide.ts +120 -0
  145. package/src/server/mcp/instructions.ts +1 -1
  146. package/src/server/mcp/prompts.ts +7 -3
  147. package/src/server/mcp/resources.ts +16 -8
  148. package/src/server/mcp/tools.ts +9 -3
  149. package/src/server/mcp/visibility.ts +18 -3
  150. package/src/server/oauth.ts +29 -109
  151. package/src/server/read/credential.ts +134 -0
  152. package/src/server/read/deployed.ts +56 -0
  153. package/src/server/read/listener.ts +54 -0
  154. package/src/server/read/open.ts +101 -0
  155. package/src/server/read/routes.ts +247 -0
  156. package/src/server/read/state.ts +171 -0
@@ -0,0 +1,198 @@
1
+ import { SUPPORTED_CONTRACT } from '#profile';
2
+
3
+ /**
4
+ * The files a fresh workspace and a fresh profile are written from.
5
+ *
6
+ * Split out of `config-edit.ts` because they are prose rather than machinery:
7
+ * that file knows how to edit YAML without disturbing what an operator wrote,
8
+ * and these are the comments an operator reads. Keeping both in one file put it
9
+ * over the size budget, and the seam was already there.
10
+ *
11
+ * **The template and the repair must write a row in one spelling.** Two
12
+ * spellings of one row is how they drift apart, and `config-edit.test.ts`
13
+ * asserts that a fresh profile and workspace need no repair, which is the check
14
+ * that catches it (ADR-050).
15
+ */
16
+
17
+ export function newProfileTemplate(profile: string, port: number, subject?: string): string {
18
+ return `# Lanes Link profile: ${profile}
19
+ #
20
+ # A profile is a *selection*: which of the workspace's accounts this agent may
21
+ # reach, what it may do with each, and who may use it. The accounts themselves
22
+ # live in connections.yaml beside this file, because authorising an account and
23
+ # deciding what may be done with it are two different acts (ADR-057).
24
+ #
25
+ # This file never contains a credential value — only "_ref" pointers into the
26
+ # credential store, which is the workspace's and is encrypted at rest.
27
+ #
28
+ # Edit it by hand or through the CLI; both are supported, and CLI edits preserve
29
+ # your comments and ordering.
30
+ contract: 3
31
+
32
+ instance:
33
+ profile: ${profile}
34
+ port: ${port}
35
+ host: 127.0.0.1
36
+
37
+ # What this profile is for, in your own words. Members see it, and so does
38
+ # setup_overview — "reads my mail, keeps the calendar, never sends" is what
39
+ # somebody needs to know before accepting it.
40
+ # description:
41
+
42
+ # This file says nothing about where it runs, and that is the point.
43
+ #
44
+ # A profile lives in exactly one workspace, and that workspace declares its own
45
+ # adapters, once, in lanes-link.yaml beside the profiles/ directory (ADR-052).
46
+ # Moving this profile somewhere else is copying the file there.
47
+ #
48
+ # lanes link status --profile ${profile} --workspace <name>
49
+ #
50
+ # The bearer token below is for CI. People sign in instead: a client that asks
51
+ # for authorization is sent to the Lanes login, and comes back as somebody
52
+ # (ADR-062). "lanes link token show" is for a runner with no browser.
53
+ auth:
54
+ mode: bearer
55
+ token_ref: profile/token
56
+ authorization:
57
+ mode: self
58
+
59
+ limits:
60
+ requests_per_minute: 120 # per profile
61
+ upstream_calls_per_minute: 60 # per connection, protects vendor quota
62
+
63
+ # One row per connection this profile may reach, and what it may do with each.
64
+ #
65
+ # A row is the grant. There is no separate list of accounts and list of rules
66
+ # that have to agree — naming a connection here is what makes it reachable, and
67
+ # the allow list is what makes any of its capabilities callable. An account the
68
+ # workspace holds and this file does not name is simply absent: not denied, not
69
+ # advertised, not there.
70
+ #
71
+ # Rules name capabilities of that row's own provider. "gmail.*" covers
72
+ # everything Gmail offers *for that one account*, which is what lets a second
73
+ # row over a second mailbox allow something different (ADR-058).
74
+ #
75
+ # The seven below hold no account, and that is why they are here already: they
76
+ # reach your own material rather than anybody's API, so there was never anything
77
+ # for a connect step to authorise (ADR-050). What each one is:
78
+ #
79
+ # memory what you want remembered between sessions
80
+ # tasks what you have to do, each with a status
81
+ # assets files you want kept, by name
82
+ # skills procedures you have written, handed to an agent as instructions
83
+ # vault passwords and API keys, released one at a time
84
+ # setup what is connected here, and what connecting more would take
85
+ # entities the people, companies and projects you deal with, and how to
86
+ # reach each of them — so an agent looks an address up rather
87
+ # than recalling one
88
+ #
89
+ # To switch one off, add it to that row's deny — deleting the row no longer
90
+ # works, because the next connect or deploy puts it back. The three narrowings
91
+ # worth knowing:
92
+ #
93
+ # deny: [memory.write] remember nothing new
94
+ # deny: [skills.manage.*] invoke procedures, do not write them
95
+ # deny: [vault.put, vault.remove]
96
+ grants:
97
+ - { connection: memory.main, allow: [memory.*], deny: [] }
98
+ - { connection: tasks.main, allow: [tasks.*], deny: [] }
99
+ - { connection: assets.main, allow: [assets.*], deny: [] }
100
+ - { connection: skills.main, allow: [skills.*], deny: [] }
101
+ - { connection: vault.main, allow: [vault.*], deny: [] }
102
+ - { connection: setup.main, allow: [setup.*], deny: [] }
103
+ - { connection: entities.main, allow: [entities.*], deny: [] }
104
+
105
+ # Who may consume this profile (ADR-060).
106
+ #
107
+ # Empty is nobody, not everybody — default deny on the identity axis. A caller
108
+ # proves who they are by signing in to Lanes, and reaches this profile only if
109
+ # their subject is listed here.
110
+ #
111
+ # "owner" may edit this list. Both roles reach exactly what the grants above
112
+ # allow: a role that changed what an agent could call would be a second policy
113
+ # system beside grants, answering a question the first one already answers.
114
+ members:${
115
+ subject
116
+ ? `
117
+ - { subject: ${subject}, role: owner }`
118
+ : ' []'
119
+ }
120
+ `;
121
+ }
122
+
123
+ export function newWorkspaceTemplate(): string {
124
+ return `# Lanes Link workspace
125
+ #
126
+ # A workspace holds the accounts you have authorised (connections.yaml) and the
127
+ # profiles that select among them (profiles/). One endpoint serves all of them:
128
+ # every call names the profile it means, with --profile.
129
+ #
130
+ # "workspaces:" below says where this one's bytes go, once, for every profile in
131
+ # it — a profile says nothing about where it runs, so there is one copy of it and
132
+ # nothing to keep in step (ADR-052).
133
+ #
134
+ # A workspace somewhere else is a pointer, and "deploy" writes one:
135
+ #
136
+ # workspaces:
137
+ # cloud:
138
+ # at: gs://your-bucket
139
+ # lanes_workspace: <id> # whose members may be delegated to
140
+ #
141
+ # The workspace at that address declares its own adapters, and is the only thing
142
+ # that does. Reading it is a network call, which is why "--workspace cloud" needs
143
+ # that bucket reachable.
144
+ #
145
+ # default_workspace is used when --workspace is absent, and every command that
146
+ # uses it prints which one it got. Commands that publish or destroy — deploy,
147
+ # sync, secrets push, profile remove, disconnect, token rotate — refuse it and
148
+ # make you type the name (ADR-061).
149
+ contract: 3
150
+ default_workspace: local
151
+ workspaces:
152
+ local:
153
+ credentials: { adapter: file }
154
+ storage: { adapter: filesystem }
155
+ `;
156
+ }
157
+
158
+ /**
159
+ * `connections.yaml` for a workspace that has just been created.
160
+ *
161
+ * The owner layer arrives here rather than in the profile, because these are
162
+ * connections now (ADR-059) and a second profile should select the same stores
163
+ * rather than get its own empty ones. `ensureOwnerLayer` keeps this in one
164
+ * spelling with the repair, which `config-edit.test.ts` asserts by checking that
165
+ * a fresh workspace needs no repair.
166
+ */
167
+ export function newConnectionsTemplate(): string {
168
+ return `# Lanes Link connections
169
+ #
170
+ # Every account authorised in this workspace, in one place. A profile names the
171
+ # ones it may reach in its own "grants:" block — connecting an account and
172
+ # deciding what may be done with it are two acts, and only the second belongs to
173
+ # a profile (ADR-057).
174
+ #
175
+ # "account" is the identity the provider reports — an address, a workspace — so
176
+ # this list says whose data is reachable without having to look anything up.
177
+ # "label" is your own word for the same row, and only ever displayed.
178
+ #
179
+ # The seven below hold no account: they reach your own material rather than
180
+ # anybody's API, so there was never anything for a connect step to authorise
181
+ # (ADR-050). Make a second one — "lanes link connect memory --id work" — when you
182
+ # want two profiles to share nothing.
183
+ contract: 3
184
+
185
+ connections:
186
+ - { id: main, provider: memory, account: Memory }
187
+ - { id: main, provider: tasks, account: Tasks }
188
+ - { id: main, provider: assets, account: Assets }
189
+ - { id: main, provider: skills, account: Skills }
190
+ - { id: main, provider: vault, account: Vault }
191
+ - { id: main, provider: setup, account: Setup }
192
+ - { id: main, provider: entities, account: Entities }
193
+
194
+ # App registrations, shared by every connection of that vendor.
195
+ oauth_apps: {}
196
+ `;
197
+ }
198
+
@@ -0,0 +1,328 @@
1
+ import { ConfigError, DATA_DIR, isRemoteWorkspace, layout } from '#profile';
2
+ import { createFileSecretStore } from '#secrets';
3
+ import type { BlobStore } from '#stores/blobs';
4
+
5
+ /**
6
+ * The half of the contract-3 migration that moves bytes rather than YAML.
7
+ *
8
+ * Split from `contract3.ts` on the seam the migration already has: that file
9
+ * decides *what* the new shape is, and this one carries the credentials and
10
+ * objects into it. Both halves are ordered so a crash between any two steps
11
+ * leaves a workspace that still opens, and the rule that makes that true lives
12
+ * here — nothing is deleted until what replaced it has been read back.
13
+ */
14
+
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
+
118
+ /**
119
+ * Where every object under `data/<profile>/` is going.
120
+ *
121
+ * Driven by `perProfile`, the per-profile map the hoist already built from old
122
+ * key to new — because the rename that matters is *this profile's*. The first
123
+ * version of this keyed a lookup by the hoisted (new) key and queried it with
124
+ * the old one, which made the resolution an unconditional no-op: provider and
125
+ * connection ids contain no dot, so anything the map returned already had the id
126
+ * being looked up. Two profiles holding `gmail.main` for different mailboxes
127
+ * both sent their blobs to `data/gmail/main/`, and the second one's landed in
128
+ * the first one's namespace.
129
+ *
130
+ * Anything that matches no rule is left exactly where it is: this moves what it
131
+ * understands and never deletes what it does not.
132
+ */
133
+ export async function planMoves(
134
+ files: BlobStore,
135
+ profiles: readonly string[],
136
+ perProfile: ReadonlyMap<string, ReadonlyMap<string, string>>,
137
+ ): Promise<Move[]> {
138
+ const moves: Move[] = [];
139
+
140
+ for (const profile of profiles) {
141
+ const mapping = perProfile.get(profile) ?? new Map<string, string>();
142
+ const prefix = `${DATA_DIR}/${profile}/`;
143
+
144
+ for (const blob of await files.list(prefix)) {
145
+ const rest = blob.key.slice(prefix.length);
146
+ const [head, ...tail] = rest.split('/');
147
+ if (head === undefined) continue;
148
+
149
+ // The credential store is merged rather than moved, and the old copy is
150
+ // deliberately left behind. `state.kv` and `audit.log` are per profile and
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.
153
+ if (head === 'credentials.enc' || head === 'credentials.enc.key') continue;
154
+
155
+ // The instance this profile's single-instance surfaces became. Both are
156
+ // one store per profile in contract 2 and one per *connection* in
157
+ // contract 3, so two profiles' vaults are two documents — sending both to
158
+ // `vault('main')` orphaned the second and silently gave it the first's,
159
+ // which is the worst of the collisions because the wrong answer is a
160
+ // credential (ADR-059).
161
+ if (head === 'vault.enc') {
162
+ moves.push({ from: blob.key, to: layout.vault(instanceOf(mapping, 'vault')) });
163
+ continue;
164
+ }
165
+ if (head === 'vault.enc.key') {
166
+ moves.push({ from: blob.key, to: `${layout.vault(instanceOf(mapping, 'vault'))}.key` });
167
+ continue;
168
+ }
169
+ if (head === 'skills.d') {
170
+ moves.push({
171
+ from: blob.key,
172
+ to: `${layout.skills(instanceOf(mapping, 'skills'))}/${tail.join('/')}`,
173
+ });
174
+ continue;
175
+ }
176
+ if (head === 'providers.d') {
177
+ moves.push({ from: blob.key, to: `${layout.providers()}/${tail.join('/')}` });
178
+ continue;
179
+ }
180
+ if (head === 'state.kv' || head === 'audit.log') {
181
+ moves.push({ from: blob.key, to: `${DATA_DIR}/${head}/${tail.join('/')}` });
182
+ continue;
183
+ }
184
+
185
+ // Otherwise it is `<provider>/<connection>/...`, the namespace every
186
+ // provider's blobs are scoped into.
187
+ const connection = tail[0];
188
+ if (connection === undefined) continue;
189
+
190
+ // This profile's old key, through this profile's mapping.
191
+ const settled = mapping.get(`${head}.${connection}`);
192
+ const id = settled === undefined ? connection : (settled.split('.')[1] ?? connection);
193
+ moves.push({ from: blob.key, to: `${DATA_DIR}/${head}/${id}/${tail.slice(1).join('/')}` });
194
+ }
195
+ }
196
+
197
+ assertOneObjectPerDestination(moves);
198
+ return moves;
199
+ }
200
+
201
+ /**
202
+ * Two objects aimed at one key, caught while this is still a plan.
203
+ *
204
+ * `applyMoves` checks the destination per object as well, but that check cannot
205
+ * see a collision between two objects *in this run* once the moves are applied
206
+ * concurrently — both would look at an absent destination and both would write.
207
+ * Hoisting it here also puts it where this file says it belongs: everything that
208
+ * can fail happens before the first byte moves, so a refusal leaves the
209
+ * workspace exactly as it was.
210
+ */
211
+ function assertOneObjectPerDestination(moves: readonly Move[]): void {
212
+ const seen = new Map<string, string>();
213
+
214
+ for (const move of moves) {
215
+ const first = seen.get(move.to);
216
+ if (first !== undefined) {
217
+ throw new ConfigError(
218
+ `Two objects want to be at ${move.to}, and this migration cannot merge them.\n` +
219
+ ` ${first} and ${move.from}. Nothing has been written.\n` +
220
+ ' This should be unreachable: the hoist gives every profile its own instance of ' +
221
+ 'each owner-layer surface. Please report it with the layout of your data directory.',
222
+ );
223
+ }
224
+ seen.set(move.to, move.from);
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Copy, verify, then delete. In that order, per object.
230
+ *
231
+ * A move that deleted first would lose an object on any failure, and these are
232
+ * the owner's notes, tasks and entities.
233
+ *
234
+ * A destination that already holds *different* bytes is a bug rather than a case
235
+ * to handle, now that the hoist gives every profile's owner layer its own
236
+ * instance: two sets of notes can no longer be aimed at one key. It used to be
237
+ * skipped silently, which is how work's vault came to be orphaned while `moved`
238
+ * reported it as moved. `assertOneObjectPerDestination` refuses that while this
239
+ * is still a plan, and the check here catches what a plan cannot see.
240
+ *
241
+ * **The same bytes at the destination is the interrupted move, and it finishes
242
+ * it.** Copy-then-delete has a window between the two, and this migration now
243
+ * runs against buckets — where the window is a network round trip rather than a
244
+ * syscall, and an interruption is something that happens rather than something
245
+ * to reason about. Refusing there would have meant a workspace that could not be
246
+ * migrated by running the migration again, which is the one recovery this file
247
+ * promises.
248
+ */
249
+ /** Which instance of a single-instance surface this profile's store became. */
250
+ function instanceOf(mapping: ReadonlyMap<string, string>, provider: string): string {
251
+ for (const [from, to] of mapping) {
252
+ if (from.startsWith(`${provider}.`)) return to.split('.')[1] ?? 'main';
253
+ }
254
+ return 'main';
255
+ }
256
+
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
+