@lanes-sh/link 0.9.0 → 0.9.2
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/agents/lanes-link-scout.md +3 -3
- package/instructions/skills/lanes-link/SKILL.md +22 -9
- package/package.json +1 -1
- package/src/cli/brand.ts +20 -15
- package/src/cli/callback-page.ts +8 -7
- package/src/cli/commands/mcp/register.ts +36 -8
- package/src/cli/config-repair-sweep.ts +66 -2
- package/src/cli/contract4.ts +7 -0
- package/src/deployments/adapters/audit-blob.ts +22 -1
package/README.md
CHANGED
|
@@ -12,10 +12,7 @@ that speaks MCP — reaches all of it through that one endpoint. Change your AI
|
|
|
12
12
|
context, because none of it ever lived in the agent. Open source, self-hostable, no vendor in the
|
|
13
13
|
middle of your data.
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
<source media="(prefers-color-scheme: dark)" srcset="docs/images/lanes-link-dark.svg">
|
|
17
|
-
<img alt="Claude, Codex, and Gemini all reach one Lanes Link endpoint, which you run yourself. Beneath it sit the profiles it serves: personal, holding Gmail, Memory, and Skills; and work, holding Gmail, Calendar, and Docs." src="docs/images/lanes-link-light.svg">
|
|
18
|
-
</picture>
|
|
15
|
+

|
|
19
16
|
|
|
20
17
|
## Why
|
|
21
18
|
|
|
@@ -81,27 +78,6 @@ tool and worth stating plainly; what it is not is a dependency per request. The
|
|
|
81
78
|
to sign in and to refresh, and a machine offline for a day keeps serving. `lanes link token
|
|
82
79
|
show` still mints a static token for CI, which has no browser to sign in with.
|
|
83
80
|
|
|
84
|
-
## In the Lanes desktop app
|
|
85
|
-
|
|
86
|
-
Prefer not to use a terminal? The [Lanes desktop app](https://lanes.sh/desktop) drives this CLI from
|
|
87
|
-
a settings page. **Settings → Integrations → Lanes Link** installs it, holds the profile and
|
|
88
|
-
workspace every command runs against, starts and stops the endpoint, and registers it with Claude
|
|
89
|
-
Code or Codex. From 0.8.0 your connections, profiles and audit log are on the
|
|
90
|
-
[Lanes dashboard](https://lanes.sh/dashboard/link) instead, which reads your endpoint directly over
|
|
91
|
-
loopback: run `lanes link pair` once to let it.
|
|
92
|
-
|
|
93
|
-

|
|
94
|
-
|
|
95
|
-
It runs the commands above rather than reimplementing them, so consent and the token stay here where
|
|
96
|
-
they belong, and an endpoint set up in the app is the same one you get from a shell. Available from
|
|
97
|
-
Lanes v0.47.0, as a research preview.
|
|
98
|
-
|
|
99
|
-
```console
|
|
100
|
-
$ lanes link desktop # opens the app on that page, installing it if it is not there
|
|
101
|
-
```
|
|
102
|
-
|
|
103
|
-
**[How to use it →](https://lanes.sh/docs/desktop/lanes-link)**
|
|
104
|
-
|
|
105
81
|
## What you keep in it
|
|
106
82
|
|
|
107
83
|
Not "what your agent gets" — the distinction is the whole point. These are yours. An agent is a
|
|
@@ -38,10 +38,10 @@ then a narrower fragment, before concluding something is not there.
|
|
|
38
38
|
Tasks are worth the second look rather than an afterthought: an open one is often
|
|
39
39
|
the actual answer to "what do we already know about X", and it carries something
|
|
40
40
|
memory cannot — that the matter is unfinished. Say so when it is, and say which
|
|
41
|
-
status: *blocked since June* is a different answer from *open*. `
|
|
41
|
+
status: *blocked since June* is a different answer from *open*. `lanes_tasks_list`
|
|
42
42
|
hides finished work by default, so ask for `done` when the question is historical.
|
|
43
43
|
|
|
44
|
-
**Assets are a listing, not a corpus.** `
|
|
44
|
+
**Assets are a listing, not a corpus.** `lanes_assets_list` tells you a file exists,
|
|
45
45
|
its type and its size; only a text one reads back. Report that a document is
|
|
46
46
|
there and let the main thread decide what to do with it — do not try to get at
|
|
47
47
|
the contents of a binary, and never ask for it as base64.
|
|
@@ -67,7 +67,7 @@ to every future session.
|
|
|
67
67
|
stops a write is policy on the endpoint:
|
|
68
68
|
|
|
69
69
|
```console
|
|
70
|
-
$ lanes link policy deny
|
|
70
|
+
$ lanes link policy deny lanes_memory.write --connection lanes_memory.lan1 --profile <name> --workspace <name>
|
|
71
71
|
$ lanes link policy list --profile <name> --workspace <name>
|
|
72
72
|
```
|
|
73
73
|
|
|
@@ -43,6 +43,12 @@ workspace*. A profile lives in exactly one, so `personal` on `local` and
|
|
|
43
43
|
`personal` on `cloud` are two profiles that share a name rather than one profile
|
|
44
44
|
in two places.
|
|
45
45
|
|
|
46
|
+
**A profile owns its data.** Two profiles granting the same connection — every
|
|
47
|
+
profile grants `lanes_memory.lan1` — still read and write different notes,
|
|
48
|
+
tasks, files and entities, because the profile is part of where they are kept.
|
|
49
|
+
So there is nothing to be found in one by asking another, and a note taken under
|
|
50
|
+
`work` is not available under `personal`.
|
|
51
|
+
|
|
46
52
|
**What a command must be told is never inferred from a profile — but the
|
|
47
53
|
workspace may have a default.** `lanes set-workspace <name>` writes one, every
|
|
48
54
|
command that uses it echoes the name it resolved, and the commands where being
|
|
@@ -74,12 +80,19 @@ disagree with it.
|
|
|
74
80
|
When you write a command out for the owner, fill in what that command needs or
|
|
75
81
|
leave it as `<name>` for them to complete — never drop a required one.
|
|
76
82
|
|
|
77
|
-
A `connection` names an account the profile grants
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
+
A `connection` names an account the profile grants, and it is a **fully
|
|
84
|
+
qualified** `<provider>.<id>` — `lanes_memory.lan1`, not `lan1`. The bare id is
|
|
85
|
+
refused, so take the value out of the enum rather than assembling one.
|
|
86
|
+
|
|
87
|
+
Ids are opaque: `con1`, `con2` for accounts and `lan1`, `lan2` for Lanes' own
|
|
88
|
+
surfaces. They carry no meaning and are not worth guessing at — the enum prints
|
|
89
|
+
each one's account and label beside it, and that is what tells `con1` from
|
|
90
|
+
`con2`. One profile may grant several of the same kind and govern each
|
|
91
|
+
differently, so `gmail.con1` may be readable where `gmail.con2` is writable.
|
|
92
|
+
|
|
93
|
+
Naming a connection the profile does not grant is refused rather than guessed
|
|
94
|
+
at, and one it does not grant is absent from the enum entirely: if you cannot
|
|
95
|
+
see it there, it was not withheld by accident.
|
|
83
96
|
|
|
84
97
|
## Which store a thing goes in
|
|
85
98
|
|
|
@@ -107,7 +120,7 @@ when Y", that is a skill they should write, not a memory entry describing it.
|
|
|
107
120
|
|
|
108
121
|
## Reach for memory before answering from nothing
|
|
109
122
|
|
|
110
|
-
`
|
|
123
|
+
`lanes_memory_search` before concluding you do not know something about this person or
|
|
111
124
|
their work. It is a substring search over their own notes, not a ranked index —
|
|
112
125
|
try more than one wording before deciding it is not there.
|
|
113
126
|
|
|
@@ -118,7 +131,7 @@ entries with `lanes link memory list --profile <name> --workspace <name>` and a
|
|
|
118
131
|
|
|
119
132
|
## Tasks have a status, so finish them rather than deleting them
|
|
120
133
|
|
|
121
|
-
`
|
|
134
|
+
`lanes_tasks_list` answers what is outstanding. It shows `in_progress`, `open` and
|
|
122
135
|
`blocked` and hides the rest, so a listing is what is left to do rather than
|
|
123
136
|
everything that ever was — name a status to see more.
|
|
124
137
|
|
|
@@ -133,7 +146,7 @@ Six of them, and the two that are easy to confuse are worth learning:
|
|
|
133
146
|
| `done` | finished |
|
|
134
147
|
| `dropped` | decided against, which is not the same as finished |
|
|
135
148
|
|
|
136
|
-
**Closing a task is `
|
|
149
|
+
**Closing a task is `lanes_tasks_update` with a status, never `lanes_tasks_remove`.** The
|
|
137
150
|
record of having done it is the useful half, and it is what stops the same thing
|
|
138
151
|
being suggested again next week. Remove is for something recorded by mistake.
|
|
139
152
|
|
package/package.json
CHANGED
package/src/cli/brand.ts
CHANGED
|
@@ -13,17 +13,19 @@
|
|
|
13
13
|
* Values are transcribed from https://lanes.sh/design/foundations. The rules
|
|
14
14
|
* they express, which the CSS below encodes rather than restates:
|
|
15
15
|
*
|
|
16
|
-
* - **
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
16
|
+
* - **Emerald is the only accent.** "Accent for positive, neutral tokens
|
|
17
|
+
* otherwise." A connection that works is `positive`, which is the accent, and
|
|
18
|
+
* everything else is one of two neutrals. `--destructive` is for errors, not
|
|
19
|
+
* for a state that merely needs attention. The accent was gold until emerald
|
|
20
|
+
* replaced it site-wide; there is still only one.
|
|
20
21
|
* - **Two weights, 400 and 500.** Nothing else is available in the loaded faces,
|
|
21
22
|
* so a heavier rule would silently synthesise.
|
|
22
23
|
* - **Lora heads, Geist speaks, Geist Mono for code and eyebrow labels.**
|
|
23
24
|
*
|
|
24
|
-
* `--border
|
|
25
|
-
*
|
|
26
|
-
*
|
|
25
|
+
* `--border` and `--destructive` are constant across themes. The five neutrals
|
|
26
|
+
* swap, and so does `--accent-brand`: the deep emerald goes illegible on the
|
|
27
|
+
* dark ground, so dark mode takes the brighter value. That is why the dark block
|
|
28
|
+
* below is six lines rather than a second stylesheet.
|
|
27
29
|
*/
|
|
28
30
|
|
|
29
31
|
/**
|
|
@@ -61,7 +63,7 @@ export const FONTS =
|
|
|
61
63
|
|
|
62
64
|
/** The one-row footer, identical to the one the Lanes API's own pages carry. */
|
|
63
65
|
export const FOOTER =
|
|
64
|
-
'<div class="footer"><p>
|
|
66
|
+
'<div class="footer"><p>Run many agents at once. Connect them to your tools once - ' +
|
|
65
67
|
'<a href="https://lanes.sh/">lanes.sh</a></p></div>';
|
|
66
68
|
|
|
67
69
|
/**
|
|
@@ -85,13 +87,13 @@ export const TOKENS = `
|
|
|
85
87
|
--foreground: #171717;
|
|
86
88
|
--muted-foreground: #202329;
|
|
87
89
|
--border: rgba(120,113,108,0.2);
|
|
88
|
-
--accent-
|
|
90
|
+
--accent-brand: #059669;
|
|
89
91
|
--destructive: #A06060;
|
|
90
92
|
/* The two tints the badge variants need. Spelled as rgba rather than
|
|
91
93
|
color-mix because the consent screen opens in whatever browser a phone
|
|
92
94
|
happens to use, and both accents are constant across themes anyway. */
|
|
93
|
-
--
|
|
94
|
-
--
|
|
95
|
+
--brand-fill: rgba(5,150,105,0.1);
|
|
96
|
+
--brand-ring: rgba(5,150,105,0.25);
|
|
95
97
|
--destructive-fill: rgba(160,96,96,0.1);
|
|
96
98
|
--destructive-ring: rgba(160,96,96,0.25);
|
|
97
99
|
--sans: "Geist", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
@@ -105,6 +107,9 @@ export const TOKENS = `
|
|
|
105
107
|
--muted: #202329;
|
|
106
108
|
--foreground: #EBEAE7;
|
|
107
109
|
--muted-foreground: #A8A29E;
|
|
110
|
+
--accent-brand: #34D399;
|
|
111
|
+
--brand-fill: rgba(52,211,153,0.1);
|
|
112
|
+
--brand-ring: rgba(52,211,153,0.25);
|
|
108
113
|
}
|
|
109
114
|
}
|
|
110
115
|
* { box-sizing: border-box; }
|
|
@@ -124,21 +129,21 @@ a { color: inherit; }
|
|
|
124
129
|
/* Surfaces are dashed and 10px; controls are 6px; pills are round. */
|
|
125
130
|
.surface { background: var(--card); border: 1px dashed var(--border); border-radius: 10px; }
|
|
126
131
|
|
|
127
|
-
/* The four badge variants, as the design system defines them:
|
|
132
|
+
/* The four badge variants, as the design system defines them: the accent for
|
|
128
133
|
positive, neutral tokens otherwise, destructive for an error only. The ring
|
|
129
134
|
is an inset shadow so it costs no layout, exactly as \`ring-inset\` does. */
|
|
130
135
|
.pill { display: inline-flex; align-items: center; flex: none; white-space: nowrap;
|
|
131
136
|
font-size: 11px; font-weight: 500; letter-spacing: 0.02em;
|
|
132
137
|
padding: 2px 8px; border-radius: 999px; box-shadow: inset 0 0 0 1px var(--border); }
|
|
133
|
-
.pill.positive { background: var(--
|
|
134
|
-
box-shadow: inset 0 0 0 1px var(--
|
|
138
|
+
.pill.positive { background: var(--brand-fill); color: var(--accent-brand);
|
|
139
|
+
box-shadow: inset 0 0 0 1px var(--brand-ring); }
|
|
135
140
|
.pill.neutral { background: var(--muted); color: var(--muted-foreground); }
|
|
136
141
|
.pill.quiet { background: transparent; color: var(--muted-foreground); opacity: 0.7; }
|
|
137
142
|
.pill.negative { background: var(--destructive-fill); color: var(--destructive);
|
|
138
143
|
box-shadow: inset 0 0 0 1px var(--destructive-ring); }
|
|
139
144
|
|
|
140
145
|
/* Ghost is the variant every button on these pages is: muted until it is
|
|
141
|
-
wanted, and never
|
|
146
|
+
wanted, and never the accent, which is reserved for saying a thing is good. */
|
|
142
147
|
.btn { font: inherit; font-size: 12px; font-weight: 500; line-height: 1;
|
|
143
148
|
padding: 6px 9px; border-radius: 6px; cursor: pointer;
|
|
144
149
|
background: transparent; color: var(--muted-foreground);
|
package/src/cli/callback-page.ts
CHANGED
|
@@ -16,11 +16,12 @@
|
|
|
16
16
|
* hardcoded `#0d1117` that read as a black rectangle on a light machine. The
|
|
17
17
|
* system names a background per mode, so the fix is now to paint the right
|
|
18
18
|
* one. `color-scheme` stays, for form controls and scrollbars.
|
|
19
|
-
* - **The success mark
|
|
20
|
-
*
|
|
21
|
-
* integration.
|
|
22
|
-
*
|
|
23
|
-
*
|
|
19
|
+
* - **The success mark carries the accent.** It is lucide's `check` at
|
|
20
|
+
* `--accent-brand`, which matches the tick the Lanes app puts against a
|
|
21
|
+
* connected integration. This went the long way round: the mark was `#059669`,
|
|
22
|
+
* was changed to gold because the design system allowed one accent and gold
|
|
23
|
+
* was it, and is emerald again now that emerald is that one accent. The rule
|
|
24
|
+
* never moved, only the colour it points at.
|
|
24
25
|
*
|
|
25
26
|
* What has not changed is that colour is reserved for status and spent nowhere
|
|
26
27
|
* else — not on emphasis, and not on the heading.
|
|
@@ -49,14 +50,14 @@ body { display: flex; align-items: center; justify-content: center;
|
|
|
49
50
|
.label { margin: 0 0 6px; font-size: 15px; font-weight: 500; color: var(--muted-foreground); }
|
|
50
51
|
h1 { margin: 0 0 18px; }
|
|
51
52
|
.detail { margin: 0; font-size: 15px; line-height: 1.6; color: var(--muted-foreground); }
|
|
52
|
-
.icon { display: block; margin: 0 auto 18px; color: var(--accent-
|
|
53
|
+
.icon { display: block; margin: 0 auto 18px; color: var(--accent-brand); }
|
|
53
54
|
.err h1 { color: var(--destructive); }
|
|
54
55
|
`.trim();
|
|
55
56
|
|
|
56
57
|
/**
|
|
57
58
|
* The success mark, above the label.
|
|
58
59
|
*
|
|
59
|
-
* lucide's `check`, in `--accent-
|
|
60
|
+
* lucide's `check`, in `--accent-brand` - which is what "accent for positive"
|
|
60
61
|
* means when the positive thing is a connection that now works. It carries no
|
|
61
62
|
* colour of its own; `.icon` sets it, so it follows the token if the token moves.
|
|
62
63
|
*
|
|
@@ -45,6 +45,41 @@ export interface McpAddOptions extends GlobalFlags {
|
|
|
45
45
|
readonly headless?: boolean | undefined;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* What to say about the token, which depends on whether one was actually stored.
|
|
50
|
+
*
|
|
51
|
+
* **`token`, not `harness.storesToken`.** The harness property says the client
|
|
52
|
+
* *can* hold a token; whether one was passed is a different question, and only
|
|
53
|
+
* the headless path passes one — the ordinary path registers the bare URL and
|
|
54
|
+
* lets the client discover the protected-resource document and run the
|
|
55
|
+
* authorization itself. Keyed on the property, this told an operator who had
|
|
56
|
+
* just registered against a deployed endpoint that "the token was stored" and
|
|
57
|
+
* that a rotate meant re-registering, when nothing had been stored and a rotate
|
|
58
|
+
* would not touch that entry at all.
|
|
59
|
+
*
|
|
60
|
+
* A separate function so the decision can be tested without spawning a client
|
|
61
|
+
* binary, which is the only reason the branch above it cannot be.
|
|
62
|
+
*/
|
|
63
|
+
export function tokenNote(
|
|
64
|
+
harness: { readonly storesToken: boolean; readonly label: string },
|
|
65
|
+
token: string | undefined,
|
|
66
|
+
): readonly string[] {
|
|
67
|
+
if (!harness.storesToken) return [];
|
|
68
|
+
|
|
69
|
+
if (token) {
|
|
70
|
+
return [
|
|
71
|
+
'The token was stored as a value, not a command, so "lanes link token rotate"',
|
|
72
|
+
'means running this again with --force.',
|
|
73
|
+
];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return [
|
|
77
|
+
`No token was stored: ${harness.label} reads the endpoint's own`,
|
|
78
|
+
'protected-resource document and signs you in. A "lanes link token rotate"',
|
|
79
|
+
'does not affect this registration.',
|
|
80
|
+
];
|
|
81
|
+
}
|
|
82
|
+
|
|
48
83
|
export async function mcpAdd(target: string | undefined, options: McpAddOptions): Promise<void> {
|
|
49
84
|
// No harness named: every one that is actually installed. Registering with
|
|
50
85
|
// whatever is present is what someone means by "add my mcp", and naming one
|
|
@@ -174,14 +209,7 @@ async function register(
|
|
|
174
209
|
|
|
175
210
|
if (!options.noSkill) await installFor(harness, input.scope, {});
|
|
176
211
|
|
|
177
|
-
|
|
178
|
-
print(
|
|
179
|
-
style.dim(
|
|
180
|
-
' The token was stored as a value, not a command, so "lanes link token rotate"\n' +
|
|
181
|
-
' means running this again with --force.',
|
|
182
|
-
),
|
|
183
|
-
);
|
|
184
|
-
}
|
|
212
|
+
for (const line of tokenNote(harness, input.token)) print(style.dim(` ${line}`));
|
|
185
213
|
|
|
186
214
|
const after = harness.afterAdd?.(input);
|
|
187
215
|
if (after) {
|
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
CONNECTIONS_FILE,
|
|
3
|
+
listProfiles,
|
|
4
|
+
SUPPORTED_CONTRACT,
|
|
5
|
+
WORKSPACE_FILE,
|
|
6
|
+
workspaceFiles,
|
|
7
|
+
writeWorkspaceFile,
|
|
8
|
+
} from '#profile';
|
|
2
9
|
import { newConnectionsTemplate } from './config-templates.ts';
|
|
3
10
|
import { ConfigDocument } from './config-edit.ts';
|
|
4
11
|
import { ok, print, style, warn } from './output.ts';
|
|
@@ -14,6 +21,56 @@ import { DEFAULT_SURFACES, ensureOwnerLayer, repairLines, repaired } from './con
|
|
|
14
21
|
* what to say when a profile will not open.
|
|
15
22
|
*/
|
|
16
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Stamp the registry with the contract the workspace is actually in.
|
|
26
|
+
*
|
|
27
|
+
* `renameRegistry` copies `lanes-link.yaml` to `workspaces.yaml` byte for byte,
|
|
28
|
+
* which is what makes an interruption survivable — but it carried the old
|
|
29
|
+
* `contract:` across with everything else, so a migrated workspace sat at 3
|
|
30
|
+
* while every profile in it said 4, and a workspace `profile add` created said
|
|
31
|
+
* 4 from the start. Two workspaces at the same contract disagreeing about which
|
|
32
|
+
* one they are in.
|
|
33
|
+
*
|
|
34
|
+
* Cosmetic in most commands, which read the profiles. Not in `isUnmigrated`
|
|
35
|
+
* (`src/profile/registry.ts`), the one place a registry's own contract is read:
|
|
36
|
+
* it compares against `SUPPORTED_CONTRACT` to tell a pointer at an out-of-date
|
|
37
|
+
* bucket from a pointer at the wrong target, so a stale stamp there answers a
|
|
38
|
+
* question about contract 4 with a refusal naming *contract 1* and sends the
|
|
39
|
+
* operator to a `deploy` that changes nothing.
|
|
40
|
+
*
|
|
41
|
+
* Called from the migration, so its output needs no repair, and from the sweep,
|
|
42
|
+
* for the workspaces 0.9.0 already migrated. One spelling, for the reason the
|
|
43
|
+
* template and `ensureOwnerLayer` share one: two would have to agree forever.
|
|
44
|
+
*/
|
|
45
|
+
export async function ensureRegistryContract(workspaceRoot: string): Promise<boolean> {
|
|
46
|
+
const files = workspaceFiles(workspaceRoot);
|
|
47
|
+
if (!(await files.has(WORKSPACE_FILE))) return false;
|
|
48
|
+
|
|
49
|
+
const document = await ConfigDocument.openKey(workspaceRoot, WORKSPACE_FILE);
|
|
50
|
+
if (document.getIn(['contract']) === SUPPORTED_CONTRACT) return false;
|
|
51
|
+
|
|
52
|
+
document.setIn(['contract'], SUPPORTED_CONTRACT);
|
|
53
|
+
await document.save();
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The first line of an error that actually says something.
|
|
59
|
+
*
|
|
60
|
+
* `message.split('\n')[0]` was the whole of this, and a `ConfigError` from a
|
|
61
|
+
* schema failure is `<path>:\n <field>: <reason>` — so the warning rendered as
|
|
62
|
+
* "could not give personal its owner layer: /…/personal.yaml:" and named no
|
|
63
|
+
* reason at all. Seen for real on an upgrade, twice, with nothing after the
|
|
64
|
+
* colon.
|
|
65
|
+
*/
|
|
66
|
+
function reasonOf(error: unknown): string {
|
|
67
|
+
if (!(error instanceof Error)) return String(error);
|
|
68
|
+
|
|
69
|
+
const lines = error.message.split('\n').map((line) => line.trim());
|
|
70
|
+
const said = lines.find((line) => line !== '' && !line.endsWith(':'));
|
|
71
|
+
return said ?? lines.find((line) => line !== '') ?? error.message;
|
|
72
|
+
}
|
|
73
|
+
|
|
17
74
|
/** `memory, tasks, assets, skills, vault, setup and entities`, in repair order. */
|
|
18
75
|
function listSurfaces(): string {
|
|
19
76
|
const names = [...DEFAULT_SURFACES];
|
|
@@ -109,11 +166,18 @@ export async function repairOwnerLayer(
|
|
|
109
166
|
} catch (error) {
|
|
110
167
|
say(
|
|
111
168
|
warn(
|
|
112
|
-
`could not give ${name} its owner layer: ${
|
|
169
|
+
`could not give ${name} its owner layer: ${reasonOf(error)}`,
|
|
113
170
|
),
|
|
114
171
|
);
|
|
115
172
|
}
|
|
116
173
|
}
|
|
117
174
|
|
|
118
175
|
if (connectionsChanged) await connections.save();
|
|
176
|
+
|
|
177
|
+
// After the profiles, so a workspace that could not be repaired is not told
|
|
178
|
+
// it is current. The stamp says what the workspace is; the profiles are what
|
|
179
|
+
// makes it true.
|
|
180
|
+
if (await ensureRegistryContract(workspaceRoot)) {
|
|
181
|
+
say(ok(`stamped ${style.bold(WORKSPACE_FILE)} as contract ${SUPPORTED_CONTRACT}`));
|
|
182
|
+
}
|
|
119
183
|
}
|
package/src/cli/contract4.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { ConfigDocument } from './config-edit.ts';
|
|
|
14
14
|
import { C3 } from './contract3-layout.ts';
|
|
15
15
|
import { grantingProfiles, planMoves, type DataPlan, type Renames } from './contract4-data.ts';
|
|
16
16
|
import { planRenames } from './contract4-rename.ts';
|
|
17
|
+
import { ensureRegistryContract } from './config-repair-sweep.ts';
|
|
17
18
|
import {
|
|
18
19
|
assertConnectionsSavable,
|
|
19
20
|
readConnectionRows,
|
|
@@ -153,6 +154,12 @@ export async function migrateToContract4(
|
|
|
153
154
|
}
|
|
154
155
|
|
|
155
156
|
await renameRegistry(workspaceRoot);
|
|
157
|
+
|
|
158
|
+
// The registry's own stamp, which the byte-for-byte rename carries across
|
|
159
|
+
// unchanged. Here rather than inside `renameRegistry` because that function
|
|
160
|
+
// returns early on a workspace already holding `workspaces.yaml`, which is
|
|
161
|
+
// exactly the workspace whose stamp is stale.
|
|
162
|
+
await ensureRegistryContract(workspaceRoot);
|
|
156
163
|
await applyMoves(files, plan.moves);
|
|
157
164
|
|
|
158
165
|
// Credentials before the rows: a ref is derived from the id, so the rows must
|
|
@@ -59,6 +59,25 @@ const TAIL_YEAR_WINDOW = 10;
|
|
|
59
59
|
|
|
60
60
|
const MARKER_PREFIX = 'runs.closed/';
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Whether a key is something this store wrote, rather than something the OS did.
|
|
64
|
+
*
|
|
65
|
+
* `verify` enumerates the whole prefix and feeds every non-marker key to the
|
|
66
|
+
* chain, so a `.DS_Store` that Finder dropped in the audit directory arrived as
|
|
67
|
+
* a record, failed to decode, and was reported as `malformed run ? at seq -1` —
|
|
68
|
+
* the whole log **BROKEN** because somebody opened the folder. Seen on a real
|
|
69
|
+
* workspace, where the file had ridden through two contract migrations.
|
|
70
|
+
*
|
|
71
|
+
* A dotfile is the narrowest rule that covers it: no key this store writes
|
|
72
|
+
* begins with a dot, in either segment. Deliberately not "does it look like an
|
|
73
|
+
* event" — a corrupt event must still fail loudly, because reporting `ok` for a
|
|
74
|
+
* record it could not read is the one thing `verify` must never do.
|
|
75
|
+
*/
|
|
76
|
+
function isOurs(key: string): boolean {
|
|
77
|
+
return !key.split('/').some((segment) => segment.startsWith('.'));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
62
81
|
export interface BlobAuditOptions {
|
|
63
82
|
/** Scoped to the audit root — `data/<profile>/audit.log` or its bucket prefix. */
|
|
64
83
|
readonly storage: BlobStore;
|
|
@@ -113,7 +132,7 @@ export function createBlobAuditStore(options: BlobAuditOptions): AuditStore {
|
|
|
113
132
|
for (let year = start; year >= floor && found.length < limit; year -= 1) {
|
|
114
133
|
const keys = (await storage.list(`${year}/`))
|
|
115
134
|
.map((entry) => entry.key)
|
|
116
|
-
.filter((key) => !key.startsWith(MARKER_PREFIX))
|
|
135
|
+
.filter((key) => !key.startsWith(MARKER_PREFIX) && isOurs(key))
|
|
117
136
|
// Keys are compact ISO within a day and the day is in the path, so
|
|
118
137
|
// lexicographic order is chronological — no parsing to sort.
|
|
119
138
|
.sort((a, b) => (a < b ? 1 : a > b ? -1 : 0));
|
|
@@ -160,6 +179,8 @@ export function createBlobAuditStore(options: BlobAuditOptions): AuditStore {
|
|
|
160
179
|
const markers: RunMarker[] = [];
|
|
161
180
|
|
|
162
181
|
for (const entry of await storage.list('')) {
|
|
182
|
+
if (!isOurs(entry.key)) continue;
|
|
183
|
+
|
|
163
184
|
const bytes = await storage.get(entry.key);
|
|
164
185
|
if (bytes === null) continue;
|
|
165
186
|
|