@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
|
@@ -61,8 +61,21 @@ export interface TargetInput {
|
|
|
61
61
|
*/
|
|
62
62
|
export type StorageFactory = (area?: string) => BlobStore;
|
|
63
63
|
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
/**
|
|
65
|
+
* The workspace's credential store.
|
|
66
|
+
*
|
|
67
|
+
* Takes the adapters and the root, not a `TargetInput` — it never read the
|
|
68
|
+
* profile, and since contract 3 there is nothing in one for it to read: the
|
|
69
|
+
* store is the workspace's. Narrowing the parameter is what lets a *migration*
|
|
70
|
+
* open it, which it must be able to do while the profiles on disk still
|
|
71
|
+
* declare the contract being migrated away from and therefore will not parse.
|
|
72
|
+
*/
|
|
73
|
+
export async function openSecrets(input: {
|
|
74
|
+
readonly declared: TargetConfig;
|
|
75
|
+
readonly root: string;
|
|
76
|
+
readonly target: string;
|
|
77
|
+
}): Promise<SecretStore> {
|
|
78
|
+
const { declared, root, target } = input;
|
|
66
79
|
|
|
67
80
|
switch (declared.credentials.adapter) {
|
|
68
81
|
case 'file':
|
|
@@ -96,9 +109,17 @@ export async function openSecrets(input: TargetInput): Promise<SecretStore> {
|
|
|
96
109
|
*
|
|
97
110
|
* Its own root, `layout.state`, so it is not addressable from a provider's
|
|
98
111
|
* blob namespace — the same containment `openAudit` relies on.
|
|
112
|
+
*
|
|
113
|
+
* Two roots, because state divides by what a key is *about*. Connection
|
|
114
|
+
* records, the discovery cache and the endpoint's own OAuth server belong to
|
|
115
|
+
* the workspace: a `connect` run once must read as connected from every
|
|
116
|
+
* profile. Cursors and each provider's own keys belong to the profile, because
|
|
117
|
+
* two agents reading one mailbox at different rates must not consume each
|
|
118
|
+
* other's cursor. `isWorkspaceNamespace` in `#stores/state` is the whole rule,
|
|
119
|
+
* and it is closed — a namespace it does not name is the profile's.
|
|
99
120
|
*/
|
|
100
|
-
export function openState(storage: StorageFactory): RuntimeState {
|
|
101
|
-
return createRuntimeState(storage(layout.state()));
|
|
121
|
+
export function openState(storage: StorageFactory, profile: string): RuntimeState {
|
|
122
|
+
return createRuntimeState(storage(layout.state()), storage(layout.profileState(profile)));
|
|
102
123
|
}
|
|
103
124
|
|
|
104
125
|
/**
|
|
@@ -201,7 +222,7 @@ export async function openStorage(
|
|
|
201
222
|
switch (declared.storage.adapter) {
|
|
202
223
|
case 'filesystem': {
|
|
203
224
|
const { createFilesystemBlobStore } = await import('./adapters/filesystem.ts');
|
|
204
|
-
const base = declared.storage.path ?? layout.blobs();
|
|
225
|
+
const base = declared.storage.path ?? layout.blobs(config.instance.profile);
|
|
205
226
|
return (area) =>
|
|
206
227
|
createFilesystemBlobStore({ root: workspacePath(root, area === undefined ? base : area) });
|
|
207
228
|
}
|
|
@@ -219,7 +240,7 @@ export async function openStorage(
|
|
|
219
240
|
|
|
220
241
|
const { createGcsBlobStore } = await import('./adapters/gcs.ts');
|
|
221
242
|
const base = prefix ?? '';
|
|
222
|
-
const root = layout.blobs();
|
|
243
|
+
const root = layout.blobs(config.instance.profile);
|
|
223
244
|
|
|
224
245
|
return (area) =>
|
|
225
246
|
createGcsBlobStore({
|
|
@@ -249,7 +270,7 @@ export async function openStorage(
|
|
|
249
270
|
|
|
250
271
|
const { createS3BlobStore } = await import('./adapters/s3.ts');
|
|
251
272
|
const base = prefix ?? '';
|
|
252
|
-
const root = layout.blobs();
|
|
273
|
+
const root = layout.blobs(config.instance.profile);
|
|
253
274
|
|
|
254
275
|
return (area) =>
|
|
255
276
|
createS3BlobStore({
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
2
|
openTarget,
|
|
3
|
-
DATA_DIR,
|
|
4
3
|
CONNECTIONS_FILE,
|
|
5
4
|
WORKSPACE_FILE,
|
|
6
5
|
layout,
|
|
@@ -58,16 +57,17 @@ export function deployedWorkspace(declared: TargetConfig): string | undefined {
|
|
|
58
57
|
*
|
|
59
58
|
* **Two areas inside `data/` are authored rather than accumulated.** Skills and
|
|
60
59
|
* provider manifests have to go up or a deployed instance loses both — the
|
|
61
|
-
* regression ADR-014 §2 fixed for skills. So
|
|
62
|
-
*
|
|
63
|
-
* `
|
|
64
|
-
* it and not is a credential in a bucket.
|
|
60
|
+
* regression ADR-014 §2 fixed for skills. So the allowlist names those two
|
|
61
|
+
* areas, by whole path segment and never by prefix:
|
|
62
|
+
* `profiles/work/skills.detour/` is not `skills.d`, and the difference between
|
|
63
|
+
* matching it and not is a credential in a bucket.
|
|
65
64
|
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
* deploy
|
|
65
|
+
* The two are filtered differently, and that follows from where they sit.
|
|
66
|
+
* A manifest is the workspace's (ADR-057) and defines a connection any profile
|
|
67
|
+
* may grant, so it goes up whole. Skills are back inside a profile (ADR-066),
|
|
68
|
+
* so they are filtered by the profile set exactly as the declarations are —
|
|
69
|
+
* sending a profile's procedures on a deploy that does not carry that profile
|
|
70
|
+
* would put one profile's material in front of another's endpoint.
|
|
71
71
|
*/
|
|
72
72
|
export function isWorkspaceConfig(key: string, profiles?: readonly string[]): boolean {
|
|
73
73
|
// **Never the workspace file.** It was sent, and it is the one file that must
|
|
@@ -86,39 +86,48 @@ export function isWorkspaceConfig(key: string, profiles?: readonly string[]): bo
|
|
|
86
86
|
// wherever the workspace is read from (ADR-057).
|
|
87
87
|
if (key === CONNECTIONS_FILE) return true;
|
|
88
88
|
|
|
89
|
-
if (isAuthoredArea(key)) return true;
|
|
90
|
-
|
|
91
89
|
// A set rather than one name, because a deploy now sends every profile that
|
|
92
90
|
// declares the target rather than the single one it was told. `undefined`
|
|
93
91
|
// still means the whole workspace, and an *empty* set means nothing — which
|
|
94
92
|
// is a distinction a bare string could not make.
|
|
95
93
|
const wanted = profiles === undefined ? undefined : new Set(profiles);
|
|
94
|
+
const carried = (profile: string): boolean => wanted === undefined || wanted.has(profile);
|
|
95
|
+
|
|
96
|
+
if (isManifest(key)) return true;
|
|
97
|
+
|
|
98
|
+
const segments = key.split('/');
|
|
99
|
+
if (segments[0] !== layout.profilesRoot()) return false;
|
|
100
|
+
|
|
101
|
+
const profile = segments[1];
|
|
102
|
+
if (profile === undefined || !carried(profile)) return false;
|
|
103
|
+
|
|
104
|
+
// The declaration itself.
|
|
105
|
+
if (key === layout.profileConfig(profile)) return true;
|
|
96
106
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
107
|
+
// `profiles/<profile>/skills.d/<connection>/…`, and at least one segment
|
|
108
|
+
// past the connection — a directory is not a file to send. Both skill
|
|
109
|
+
// layouts satisfy it: `<name>.md` is five segments and `<name>/SKILL.md` is
|
|
110
|
+
// six.
|
|
111
|
+
if (`${segments[0]}/${segments[1]}/${segments[2]}` === layout.skillsRoot(profile)) {
|
|
112
|
+
return segments.length >= 5;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Everything else under a profile is state, a sealed vault, or a provider's
|
|
116
|
+
// own blobs. None of it is configuration and the vault is a credential.
|
|
117
|
+
return false;
|
|
100
118
|
}
|
|
101
119
|
|
|
102
120
|
/**
|
|
103
|
-
* Whether `key` is
|
|
121
|
+
* Whether `key` is one of the workspace's own provider manifests.
|
|
104
122
|
*
|
|
105
|
-
* Composed back out of `layout` rather than compared against
|
|
123
|
+
* Composed back out of `layout` rather than compared against a literal, so a
|
|
106
124
|
* renamed directory moves both this and the store that reads it, or neither.
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
* directory is not a file to send" rule: a manifest is `data/providers.d/<file>`
|
|
110
|
-
* and a skill is `data/skills.d/<connection>/<file>`, so requiring one more
|
|
111
|
-
* segment than the area itself has is what stops the bare directory key
|
|
112
|
-
* matching.
|
|
125
|
+
* Two segments at least, which is the "a directory is not a file to send" rule
|
|
126
|
+
* — `providers.d` alone is the area, `providers.d/<file>` is a manifest.
|
|
113
127
|
*/
|
|
114
|
-
function
|
|
128
|
+
function isManifest(key: string): boolean {
|
|
115
129
|
const segments = key.split('/');
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const area = `${DATA_DIR}/${segments[1]}`;
|
|
119
|
-
if (area === layout.providers()) return segments.length >= 3;
|
|
120
|
-
if (area === layout.skillsRoot()) return segments.length >= 4;
|
|
121
|
-
return false;
|
|
130
|
+
return segments.length >= 2 && segments[0] === layout.providers();
|
|
122
131
|
}
|
|
123
132
|
|
|
124
133
|
/**
|
|
@@ -153,7 +153,19 @@ export function soleGrantFor(config: Config, provider: string): string | undefin
|
|
|
153
153
|
* under one name has to keep opening it.
|
|
154
154
|
*/
|
|
155
155
|
export function vaultRef(declared: TargetConfig | undefined, config: Config): string {
|
|
156
|
-
|
|
156
|
+
// **The profile, then the connection.** This was `vault/<connection>`, which
|
|
157
|
+
// was distinct per profile only while each profile had its own vault
|
|
158
|
+
// instance. ADR-066 merges the owner layer to one row per surface, so every
|
|
159
|
+
// profile grants `lanes_vault.lan5` and every profile opened one sealed
|
|
160
|
+
// document — `vault_put` from `personal` overwriting `work`'s item of the
|
|
161
|
+
// same id, and `vault_get` reading the other profile's credential. The `file`
|
|
162
|
+
// and `blob` adapters take the profile from `layout`; this is the same fact
|
|
163
|
+
// for the adapter every deployment uses.
|
|
164
|
+
//
|
|
165
|
+
// A `ref` the target states outright still wins: a deployment already sealing
|
|
166
|
+
// under one name has to keep opening it.
|
|
167
|
+
const connection = soleGrantFor(config, 'lanes_vault') ?? 'main';
|
|
168
|
+
return declared?.vault?.ref ?? `vault/${config.instance.profile}/${connection}`;
|
|
157
169
|
}
|
|
158
170
|
|
|
159
171
|
/**
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { parseDocument } from 'yaml';
|
|
2
2
|
import { readWorkspaceFile, workspaceFiles, writeWorkspaceFile } from './files.ts';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
SUPPORTED_CONTRACT,
|
|
5
|
+
workspaceSchema,
|
|
6
|
+
workspaceTargetSchema,
|
|
7
|
+
type WorkspaceTarget,
|
|
8
|
+
} from './schema.ts';
|
|
4
9
|
import { WORKSPACE_FILE } from './workspace.ts';
|
|
10
|
+
import { LEGACY_WORKSPACE_FILE } from './layout.ts';
|
|
5
11
|
|
|
6
12
|
/**
|
|
7
13
|
* Writing the target registry.
|
|
@@ -74,24 +80,96 @@ async function editRegistry(
|
|
|
74
80
|
edit: (targets: Record<string, WorkspaceTarget>) => void,
|
|
75
81
|
): Promise<void> {
|
|
76
82
|
const files = workspaceFiles(workspaceRoot);
|
|
83
|
+
|
|
84
|
+
// **Either name, and the one it read is the one it writes.** Reading only the
|
|
85
|
+
// new name meant an unmigrated workspace fell through to a bare
|
|
86
|
+
// `contract: 4` document: `deploy` then wrote a registry holding one target
|
|
87
|
+
// beside the `lanes-link.yaml` that declared them all, and `readWorkspace`
|
|
88
|
+
// prefers the new name — so every other target vanished from a command whose
|
|
89
|
+
// job was to record one.
|
|
90
|
+
const held = await readWorkspaceFile(files, WORKSPACE_FILE);
|
|
91
|
+
const key = held === null ? LEGACY_WORKSPACE_FILE : WORKSPACE_FILE;
|
|
77
92
|
const text =
|
|
78
|
-
|
|
93
|
+
held ??
|
|
94
|
+
(await readWorkspaceFile(files, LEGACY_WORKSPACE_FILE)) ??
|
|
95
|
+
`contract: ${SUPPORTED_CONTRACT}\n`;
|
|
79
96
|
|
|
80
97
|
const document = parseDocument(text);
|
|
81
|
-
const
|
|
98
|
+
const raw = (document.toJSON() ?? {}) as {
|
|
99
|
+
workspaces?: Record<string, WorkspaceTarget>;
|
|
100
|
+
targets?: Record<string, LegacyTargetEntry>;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// **Whichever block this file already keeps its registry in.**
|
|
104
|
+
//
|
|
105
|
+
// This read and wrote `workspaces:` unconditionally. A contract-2 file keeps
|
|
106
|
+
// it under `targets:`, so recording a deploy into one found no registry,
|
|
107
|
+
// added the entry to an empty object, and wrote a *second* block beside the
|
|
108
|
+
// first. `workspaceSchema` has no `targets` key and zod strips what it does
|
|
109
|
+
// not declare, so the hybrid validated and landed.
|
|
110
|
+
//
|
|
111
|
+
// That is reachable from an ordinary command: `deploy` migrates the target
|
|
112
|
+
// workspace, never the local one, so deploying to a bucket from a laptop that
|
|
113
|
+
// has not run `update` yet does exactly this. The result is a file whose two
|
|
114
|
+
// registries disagree — and `rewriteRegistry` then rebuilds `workspaces:`
|
|
115
|
+
// from the stale `targets:`, discarding the newer record without a word.
|
|
116
|
+
//
|
|
117
|
+
// Writing into the block the file already has keeps it coherent at whatever
|
|
118
|
+
// contract it is, and leaves converting the two to the migration that owns
|
|
119
|
+
// that job.
|
|
120
|
+
const legacy = raw.workspaces === undefined && raw.targets !== undefined;
|
|
121
|
+
const block = legacy ? 'targets' : 'workspaces';
|
|
122
|
+
const targets = legacy ? current(raw.targets ?? {}) : (raw.workspaces ?? {});
|
|
82
123
|
|
|
83
124
|
edit(targets);
|
|
84
125
|
|
|
85
|
-
if (Object.keys(targets).length === 0) document.deleteIn([
|
|
86
|
-
else document.setIn([
|
|
126
|
+
if (Object.keys(targets).length === 0) document.deleteIn([block]);
|
|
127
|
+
else document.setIn([block], sorted(legacy ? asLegacy(targets) : targets));
|
|
87
128
|
|
|
88
129
|
// Validated before it lands, on the rendered tree rather than the input, so
|
|
89
130
|
// what is checked is what would be read back.
|
|
90
|
-
|
|
131
|
+
//
|
|
132
|
+
// `workspaceSchema` declares no `targets` key and zod strips what it does not
|
|
133
|
+
// declare, so on the legacy branch parsing the whole document checks nothing
|
|
134
|
+
// at all — an entry that is neither pointer nor declaration, or one trying to
|
|
135
|
+
// be both, landed silently and only surfaced weeks later when the migration
|
|
136
|
+
// converted it and refused. The entries are checked directly there, in the
|
|
137
|
+
// contract-3 shape `current` normalised them to.
|
|
138
|
+
if (legacy) for (const entry of Object.values(targets)) workspaceTargetSchema.parse(entry);
|
|
139
|
+
else workspaceSchema.parse(document.toJSON());
|
|
91
140
|
|
|
92
|
-
await writeWorkspaceFile(files,
|
|
141
|
+
await writeWorkspaceFile(files, key, String(document));
|
|
93
142
|
}
|
|
94
143
|
|
|
95
|
-
function sorted(targets: Record<string,
|
|
144
|
+
function sorted<T>(targets: Record<string, T>): Record<string, T> {
|
|
96
145
|
return Object.fromEntries(Object.entries(targets).sort(([a], [b]) => a.localeCompare(b)));
|
|
97
146
|
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* A contract-2 entry spelled a pointer `workspace:`; contract 3 spells it `at:`.
|
|
150
|
+
*
|
|
151
|
+
* Normalised on the way in and back on the way out, so `edit` and `pick` see one
|
|
152
|
+
* shape and never have to ask which contract they are looking at — and so a
|
|
153
|
+
* legacy file keeps the spelling its own schema expects.
|
|
154
|
+
*/
|
|
155
|
+
interface LegacyTargetEntry extends Omit<WorkspaceTarget, 'at'> {
|
|
156
|
+
workspace?: string;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function current(targets: Record<string, LegacyTargetEntry>): Record<string, WorkspaceTarget> {
|
|
160
|
+
return Object.fromEntries(
|
|
161
|
+
Object.entries(targets).map(([name, entry]) => {
|
|
162
|
+
const { workspace, ...rest } = entry;
|
|
163
|
+
return [name, workspace === undefined ? rest : { at: workspace, ...rest }];
|
|
164
|
+
}),
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function asLegacy(targets: Record<string, WorkspaceTarget>): Record<string, LegacyTargetEntry> {
|
|
169
|
+
return Object.fromEntries(
|
|
170
|
+
Object.entries(targets).map(([name, entry]) => {
|
|
171
|
+
const { at, ...rest } = entry;
|
|
172
|
+
return [name, at === undefined ? rest : { workspace: at, ...rest }];
|
|
173
|
+
}),
|
|
174
|
+
);
|
|
175
|
+
}
|
package/src/profile/index.ts
CHANGED
|
@@ -56,6 +56,7 @@ export { PAIR_CERT_REF, PAIR_KEY_REF, PAIR_TOKEN_REF } from './pairing.ts';
|
|
|
56
56
|
|
|
57
57
|
export {
|
|
58
58
|
KNOWLEDGE_LAYOUT,
|
|
59
|
+
KNOWLEDGE_PREFIX,
|
|
59
60
|
knowledgeRoot,
|
|
60
61
|
knowledgeTargetSchema,
|
|
61
62
|
parseRepository,
|
|
@@ -135,6 +136,9 @@ export {
|
|
|
135
136
|
} from './workspace.ts';
|
|
136
137
|
|
|
137
138
|
export {
|
|
138
|
-
|
|
139
|
+
PROFILE_FILE,
|
|
140
|
+
LEGACY_DATA_DIR,
|
|
141
|
+
LEGACY_WORKSPACE_FILE,
|
|
142
|
+
legacyProfileConfig,
|
|
139
143
|
layout,
|
|
140
144
|
} from './layout.ts';
|
package/src/profile/knowledge.ts
CHANGED
|
@@ -96,11 +96,17 @@ export type KnowledgeConfig = z.infer<typeof knowledgeTargetSchema>;
|
|
|
96
96
|
/**
|
|
97
97
|
* Where each area sits inside the repository.
|
|
98
98
|
*
|
|
99
|
-
* Three directories, named after the three things that move
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
99
|
+
* Three directories, named after the three things that move — and named for a
|
|
100
|
+
* *reader*, because a knowledge repository is somebody's own and they browse it
|
|
101
|
+
* on GitHub. `memory/` is what belongs at the top of that tree, not
|
|
102
|
+
* `lanes_memory/`.
|
|
103
|
+
*
|
|
104
|
+
* **This used to be the provider's blob prefix as well**, on the reasoning that
|
|
105
|
+
* one word doing both jobs could not come to disagree with itself. Contract 4
|
|
106
|
+
* prefixed the owner layer (`lanes_memory`), so the two are no longer the same
|
|
107
|
+
* string and the choice has to be made rather than avoided: the route below
|
|
108
|
+
* reads `PROVIDER_PREFIX`, and this stays the readable name. `KNOWLEDGE_ROUTES`
|
|
109
|
+
* pairs them in one place so a rename still cannot move one without the other.
|
|
104
110
|
*
|
|
105
111
|
* Declared here, beside the schema, so the runtime that opens these stores and
|
|
106
112
|
* the command that migrates into them read one definition.
|
|
@@ -113,6 +119,13 @@ export const KNOWLEDGE_LAYOUT = {
|
|
|
113
119
|
|
|
114
120
|
export type KnowledgeArea = keyof typeof KNOWLEDGE_LAYOUT;
|
|
115
121
|
|
|
122
|
+
/** The blob prefix each area is scoped into locally — the provider's own id. */
|
|
123
|
+
export const KNOWLEDGE_PREFIX = {
|
|
124
|
+
memory: 'lanes_memory',
|
|
125
|
+
skills: 'lanes_skills',
|
|
126
|
+
entities: 'lanes_entities',
|
|
127
|
+
} as const;
|
|
128
|
+
|
|
116
129
|
/** The directory one area occupies, under the profile's optional path prefix. */
|
|
117
130
|
export function knowledgeRoot(knowledge: KnowledgeConfig, area: KnowledgeArea): string {
|
|
118
131
|
const directory = KNOWLEDGE_LAYOUT[area];
|
package/src/profile/layout.ts
CHANGED
|
@@ -1,63 +1,85 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Where a workspace's
|
|
2
|
+
* Where a workspace's files live, in one place.
|
|
3
3
|
*
|
|
4
4
|
* ```
|
|
5
5
|
* ~/.lanes-link/
|
|
6
|
-
* ├──
|
|
6
|
+
* ├── workspaces.yaml the workspaces this machine knows, and the default
|
|
7
7
|
* ├── connections.yaml every account authorised in this workspace
|
|
8
|
-
* ├──
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
8
|
+
* ├── credentials.enc system credentials, and its .key
|
|
9
|
+
* ├── providers.d/ the operator's own provider manifests
|
|
10
|
+
* ├── audit.log/ one object per event, one chain
|
|
11
|
+
* ├── state.kv/ connections, discovery, the endpoint's own OAuth server
|
|
12
|
+
* └── profiles/
|
|
13
|
+
* └── <profile>/ everything one profile is
|
|
14
|
+
* ├── profile.yaml what it selects, and who may use it
|
|
15
|
+
* ├── state.kv/ cursors, and each provider's own keys
|
|
16
|
+
* ├── vault.d/<id>.enc one sealed document per vault connection
|
|
17
|
+
* ├── skills.d/<id>/ procedures, one <name>/SKILL.md each
|
|
18
|
+
* └── <provider>/<connection>/… whatever that provider stores
|
|
17
19
|
* ```
|
|
18
20
|
*
|
|
19
|
-
* **
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
21
|
+
* **A profile is one directory, and it owns its bytes.** Both halves reverse
|
|
22
|
+
* something recent. ADR-059 put the owner layer's bytes beside the *connection*,
|
|
23
|
+
* so a profile owned nothing and two profiles granting `memory.lan1` read one
|
|
24
|
+
* note; ADR-066 makes the profile the container again, so they read two. And the
|
|
25
|
+
* declaration used to sit at `profiles/<name>.yaml` while the data sat under
|
|
26
|
+
* `data/<name>/` — the same structure in two places, for no reason anybody could
|
|
27
|
+
* state (ADR-067).
|
|
26
28
|
*
|
|
27
|
-
* **
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* `audit` are all real provider ids, and without the dot each would be handed a
|
|
33
|
-
* store rooted inside the thing it is meant to be walled off from, which is a
|
|
34
|
-
* hole in ADR-007's wall rather than an untidy filename.
|
|
29
|
+
* **There is no `data/` any more.** It meant "what a deployed revision writes,
|
|
30
|
+
* as against the config it reads", which was worth a directory. `profile.yaml`
|
|
31
|
+
* lives beside the bytes now, so that line has to be drawn inside it regardless
|
|
32
|
+
* — and a directory holding everything names nothing. The IAM grant says which
|
|
33
|
+
* prefixes are writable instead of saying which one is not.
|
|
35
34
|
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
35
|
+
* **The dot is load-bearing one level down, not here.** The root is not a
|
|
36
|
+
* provider blob root; a *profile's* directory is. A provider is namespaced
|
|
37
|
+
* `<provider>/<connection>` under it and a provider id is `[a-z][a-z0-9_]*`, so
|
|
38
|
+
* a name carrying a dot is one no provider can be scoped into. That is not
|
|
39
|
+
* hypothetical for three of the four names there — `skills`, `vault` and `state`
|
|
40
|
+
* would each otherwise be handed a store rooted inside the thing it is meant to
|
|
41
|
+
* be walled off from, which is a hole in ADR-007's wall rather than an untidy
|
|
42
|
+
* filename. `profile.yaml` carries one for the same reason.
|
|
43
|
+
*
|
|
44
|
+
* `vault.d/<id>.enc` rather than `vault/<id>.enc` on the same grounds: the
|
|
45
|
+
* sealed document is not a blob the vault provider serves, and a shared `vault/`
|
|
46
|
+
* prefix would put the ciphertext inside the namespace the provider is given.
|
|
47
|
+
*
|
|
48
|
+
* `audit.log/` and `state.kv/` stay at the workspace. One endpoint serves every
|
|
49
|
+
* profile and every event already records the profile it acted in, so one chain
|
|
50
|
+
* is both sufficient and better evidence than several; and a connection's
|
|
51
|
+
* reconcile status is a fact about an account, which a `connect` in one profile
|
|
52
|
+
* must not have to repeat in the next. What follows the profile is what a
|
|
53
|
+
* profile's use of an account produces — cursors, and the provider's own keys.
|
|
40
54
|
*
|
|
41
55
|
* These are **defaults**. A workspace that declares its own paths keeps them.
|
|
42
56
|
*/
|
|
43
57
|
|
|
44
|
-
/** The directory
|
|
45
|
-
export const
|
|
58
|
+
/** The directory holding one subdirectory per profile. */
|
|
59
|
+
export const PROFILES_DIR = 'profiles';
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* What a profile's declaration is called inside its own directory.
|
|
63
|
+
*
|
|
64
|
+
* Named here rather than spelled at the two call sites that need it — the path
|
|
65
|
+
* builder below and `listProfiles`, which matches on it to tell a profile
|
|
66
|
+
* directory from anything else under `profiles/`. Two spellings of one filename
|
|
67
|
+
* is how a listing and a loader come to disagree about what exists.
|
|
68
|
+
*/
|
|
69
|
+
export const PROFILE_FILE = 'profile.yaml';
|
|
46
70
|
|
|
47
71
|
/**
|
|
48
72
|
* No leading `./` on any of these.
|
|
49
73
|
*
|
|
50
74
|
* It used to carry one, which `path.resolve` discards and an object key does
|
|
51
|
-
* not: a bucket read `./
|
|
75
|
+
* not: a bucket read `./state.kv/x` as a directory literally named `.`, so
|
|
52
76
|
* every deployed key landed one level away from where the config said it did.
|
|
53
77
|
* The visible cost was that the conditioned IAM binding
|
|
54
|
-
* `deployments/gcp/provision.ts` writes
|
|
55
|
-
*
|
|
56
|
-
*
|
|
78
|
+
* `deployments/gcp/provision.ts` writes matched nothing, and the first revision
|
|
79
|
+
* 403'd on its boot reconcile. A relative path is relative without being
|
|
80
|
+
* spelled that way.
|
|
57
81
|
*/
|
|
58
82
|
export const layout = {
|
|
59
|
-
/** Connections, provider state, and cursors: one object per key. */
|
|
60
|
-
state: (): string => `${DATA_DIR}/state.kv`,
|
|
61
83
|
/**
|
|
62
84
|
* System credentials — OAuth refresh tokens, the CI token. Never reachable
|
|
63
85
|
* from MCP.
|
|
@@ -69,45 +91,16 @@ export const layout = {
|
|
|
69
91
|
* "silent until one profile is reading the other's account" (ADR-043,
|
|
70
92
|
* ADR-057).
|
|
71
93
|
*/
|
|
72
|
-
credentials: (): string =>
|
|
73
|
-
/** Every vault connection's sealed document lives under here. */
|
|
74
|
-
vaultRoot: (): string => `${DATA_DIR}/vault.d`,
|
|
75
|
-
/** One sealed document per vault connection, each under its own key. */
|
|
76
|
-
vault: (connection: string): string => `${DATA_DIR}/vault.d/${connection}.enc`,
|
|
77
|
-
/**
|
|
78
|
-
* The same document, keyed relative to the blob store.
|
|
79
|
-
*
|
|
80
|
-
* `blobs()` is already rooted at `data/`, so a blob adapter handed the path
|
|
81
|
-
* above would write `data/data/vault.d/...`. Two spellings of one location is
|
|
82
|
-
* exactly what this file exists to prevent, so the second one lives here
|
|
83
|
-
* beside the first rather than being assembled at the call site.
|
|
84
|
-
*/
|
|
85
|
-
vaultKey: (connection: string): string => `vault.d/${connection}.enc`,
|
|
86
|
-
/**
|
|
87
|
-
* Every skills connection's procedures live under here.
|
|
88
|
-
*
|
|
89
|
-
* The root is exported beside the per-connection path because `deploy` needs
|
|
90
|
-
* to recognise the *area* without knowing which connections exist — and
|
|
91
|
-
* `upload.ts` composes its allowlist back out of these rather than comparing
|
|
92
|
-
* against literals, so a renamed directory moves the store and the thing that
|
|
93
|
-
* sends it together, or neither.
|
|
94
|
-
*/
|
|
95
|
-
skillsRoot: (): string => `${DATA_DIR}/skills.d`,
|
|
96
|
-
/** One skills connection's procedures — `<name>.md` or `<name>/SKILL.md`, either layout. */
|
|
97
|
-
skills: (connection: string): string => `${DATA_DIR}/skills.d/${connection}`,
|
|
94
|
+
credentials: (): string => 'credentials.enc',
|
|
98
95
|
/**
|
|
99
96
|
* The provider manifests this workspace declares.
|
|
100
97
|
*
|
|
101
98
|
* Workspace-level, where ADR-030 put them in the profile. A manifest names a
|
|
102
99
|
* host, an OpenAPI document, and the credential refs that reach them — which
|
|
103
100
|
* is to say it *defines a connection*, and connections do not live in a
|
|
104
|
-
* profile any more
|
|
105
|
-
* as the knowledge it operates on; that argument is answered by ADR-059's
|
|
106
|
-
* instances, not by where a manifest sits.
|
|
101
|
+
* profile any more (ADR-057).
|
|
107
102
|
*/
|
|
108
|
-
providers: (): string =>
|
|
109
|
-
/** The blob root every provider is namespaced under. */
|
|
110
|
-
blobs: (): string => DATA_DIR,
|
|
103
|
+
providers: (): string => 'providers.d',
|
|
111
104
|
/**
|
|
112
105
|
* The audit log: one object per event, one chain for the workspace.
|
|
113
106
|
*
|
|
@@ -116,5 +109,88 @@ export const layout = {
|
|
|
116
109
|
* also what lets `audit tail` filter where it used to select, and what gives
|
|
117
110
|
* the dashboard a single log to read.
|
|
118
111
|
*/
|
|
119
|
-
audit: (): string =>
|
|
112
|
+
audit: (): string => 'audit.log',
|
|
113
|
+
/**
|
|
114
|
+
* What the workspace knows about its accounts, and about itself.
|
|
115
|
+
*
|
|
116
|
+
* Connection records and the discovery cache, because both are facts about an
|
|
117
|
+
* account or a provider rather than about anybody's selection of it — a
|
|
118
|
+
* `connect` run once must read as connected from every profile. And the
|
|
119
|
+
* endpoint's own OAuth authorization-server state, which is not about a
|
|
120
|
+
* connection at all: it is the clients that have signed in *to* this endpoint,
|
|
121
|
+
* and there is one endpoint.
|
|
122
|
+
*/
|
|
123
|
+
state: (): string => 'state.kv',
|
|
124
|
+
|
|
125
|
+
/** Every profile's directory sits under here. */
|
|
126
|
+
profilesRoot: (): string => PROFILES_DIR,
|
|
127
|
+
/** Everything one profile is. Also the blob root its providers are scoped under. */
|
|
128
|
+
profileDir: (profile: string): string => `${PROFILES_DIR}/${profile}`,
|
|
129
|
+
/**
|
|
130
|
+
* What this profile selects, and who may use it.
|
|
131
|
+
*
|
|
132
|
+
* Inside the profile's own directory, which is the whole of ADR-067 — but it
|
|
133
|
+
* is therefore inside the tree a running endpoint writes, so the IAM condition
|
|
134
|
+
* carves it back out. ADR-007 says a deployed revision never mutates its own
|
|
135
|
+
* configuration, and that rule is older than where the file sits.
|
|
136
|
+
*/
|
|
137
|
+
profileConfig: (profile: string): string => `${PROFILES_DIR}/${profile}/${PROFILE_FILE}`,
|
|
138
|
+
/** Cursors, and each provider's own keys, for this profile's use of an account. */
|
|
139
|
+
profileState: (profile: string): string => `${PROFILES_DIR}/${profile}/state.kv`,
|
|
140
|
+
/** Every vault connection's sealed document, for this profile. */
|
|
141
|
+
vaultRoot: (profile: string): string => `${PROFILES_DIR}/${profile}/vault.d`,
|
|
142
|
+
/** One sealed document per vault connection, each under its own key. */
|
|
143
|
+
vault: (profile: string, connection: string): string =>
|
|
144
|
+
`${PROFILES_DIR}/${profile}/vault.d/${connection}.enc`,
|
|
145
|
+
/**
|
|
146
|
+
* The same document, keyed relative to the blob store.
|
|
147
|
+
*
|
|
148
|
+
* `blobs()` is already rooted at the profile's directory, so a blob adapter
|
|
149
|
+
* handed the path above would write `profiles/p/profiles/p/vault.d/...`. Two
|
|
150
|
+
* spellings of one location is exactly what this file exists to prevent, so
|
|
151
|
+
* the second one lives here beside the first rather than being assembled at
|
|
152
|
+
* the call site.
|
|
153
|
+
*/
|
|
154
|
+
vaultKey: (connection: string): string => `vault.d/${connection}.enc`,
|
|
155
|
+
/**
|
|
156
|
+
* Every skills connection's procedures, for this profile.
|
|
157
|
+
*
|
|
158
|
+
* The root is exported beside the per-connection path because `deploy` needs
|
|
159
|
+
* to recognise the *area* without knowing which connections exist — and
|
|
160
|
+
* `upload.ts` composes its allowlist back out of these rather than comparing
|
|
161
|
+
* against literals, so a renamed directory moves the store and the thing that
|
|
162
|
+
* sends it together, or neither.
|
|
163
|
+
*/
|
|
164
|
+
skillsRoot: (profile: string): string => `${PROFILES_DIR}/${profile}/skills.d`,
|
|
165
|
+
/** One skills connection's procedures — `<name>.md` or `<name>/SKILL.md`, either layout. */
|
|
166
|
+
skills: (profile: string, connection: string): string =>
|
|
167
|
+
`${PROFILES_DIR}/${profile}/skills.d/${connection}`,
|
|
168
|
+
/** The blob root this profile's providers are namespaced under. */
|
|
169
|
+
blobs: (profile: string): string => `${PROFILES_DIR}/${profile}`,
|
|
120
170
|
} as const;
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* `data/`, which no longer exists — kept for the migrations that address it.
|
|
174
|
+
*
|
|
175
|
+
* Contracts 1 through 3 put everything a profile owned under `data/<profile>/`,
|
|
176
|
+
* and contract 4 moves it out (ADR-067). A migration reads the layout it is
|
|
177
|
+
* migrating *from*, and that layout is frozen: asking `layout` above would
|
|
178
|
+
* compare a contract-3 path against a contract-4 default and match nothing.
|
|
179
|
+
* `migrate-plan.ts` already spells the contract-1 defaults out for the same
|
|
180
|
+
* reason. Nothing outside a migration should import this.
|
|
181
|
+
*/
|
|
182
|
+
export const LEGACY_DATA_DIR = 'data';
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The registry's name, and a profile's path, through contract 3.
|
|
186
|
+
*
|
|
187
|
+
* Recognised, never written. Two live functions answer to these — the ancestor
|
|
188
|
+
* walk and `listProfiles` — for one reason: a workspace that needs migrating
|
|
189
|
+
* has to be findable by the command that migrates it, or `doctor --fix` reports
|
|
190
|
+
* no workspace here and the operator has a wall with no door.
|
|
191
|
+
*/
|
|
192
|
+
export const LEGACY_WORKSPACE_FILE = 'lanes-link.yaml';
|
|
193
|
+
|
|
194
|
+
export function legacyProfileConfig(profile: string): string {
|
|
195
|
+
return `${PROFILES_DIR}/${profile}.yaml`;
|
|
196
|
+
}
|