@intentic/sandbox-contract 1.176.2 → 1.176.3
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/dist/chores/probes.d.ts +1 -0
- package/dist/chores/probes.d.ts.map +1 -1
- package/dist/chores/probes.js +4 -0
- package/dist/chores/probes.js.map +1 -1
- package/dist/chores/verdict.d.ts.map +1 -1
- package/dist/chores/verdict.js +4 -1
- package/dist/chores/verdict.js.map +1 -1
- package/dist/contracts/system.contract.d.ts +14 -14
- package/dist/history-state.d.ts +3 -0
- package/dist/history-state.d.ts.map +1 -0
- package/dist/history-state.js +43 -0
- package/dist/history-state.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/schemas.d.ts +37 -0
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +18 -0
- package/dist/schemas.js.map +1 -1
- package/dist/state-portability.d.ts +8 -0
- package/dist/state-portability.d.ts.map +1 -0
- package/dist/state-portability.js +4 -0
- package/dist/state-portability.js.map +1 -0
- package/dist/workspace-state.d.ts +2 -2
- package/dist/workspace-state.d.ts.map +1 -1
- package/dist/workspace-state.js +52 -8
- package/dist/workspace-state.js.map +1 -1
- package/package.json +3 -3
- package/src/chores/probes.ts +9 -0
- package/src/chores/verdict.ts +7 -1
- package/src/history-state.ts +100 -0
- package/src/index.ts +2 -0
- package/src/schemas.ts +51 -1
- package/src/state-portability.ts +56 -0
- package/src/workspace-state.test.ts +23 -5
- package/src/workspace-state.ts +97 -19
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentic/sandbox-contract",
|
|
3
|
-
"version": "1.176.
|
|
3
|
+
"version": "1.176.3",
|
|
4
4
|
"description": "oRPC wire contract for the intentic sandbox daemon — shared by the daemon and its browser client",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -67,8 +67,8 @@
|
|
|
67
67
|
"@orpc/contract": "1.14.13",
|
|
68
68
|
"tslib": "2.8.1",
|
|
69
69
|
"zod": "4.4.3",
|
|
70
|
-
"@intentic/
|
|
71
|
-
"@intentic/
|
|
70
|
+
"@intentic/registry": "0.0.0",
|
|
71
|
+
"@intentic/extension-api": "1.176.3"
|
|
72
72
|
},
|
|
73
73
|
"devDependencies": {
|
|
74
74
|
"@types/node": "24.13.2",
|
package/src/chores/probes.ts
CHANGED
|
@@ -36,6 +36,11 @@ export interface ProbeSpec {
|
|
|
36
36
|
readonly timeoutMs: number;
|
|
37
37
|
// Exit 0 ⇒ this repo can be measured. Runs in the repo's own directory, like the command.
|
|
38
38
|
readonly available: string;
|
|
39
|
+
/* What is MISSING when `available` says no, named here rather than derived from the title. The obvious
|
|
40
|
+
* derivation — "this repository has no security advisories to measure" — states the one thing an unmeasured
|
|
41
|
+
* probe must never claim, that there are none, and it is the same conflation the block above exists to
|
|
42
|
+
* prevent. Phrased as a bare clause ("no lockfile"), because the panel groups these under its own lead-in. */
|
|
43
|
+
readonly unavailable: string;
|
|
39
44
|
// `sh -c`, in the repo's directory. Stdout is the parser's input; a non-zero exit is NOT a failure by itself
|
|
40
45
|
// (pnpm outdated and pnpm audit both exit non-zero precisely when they have something to report), so the
|
|
41
46
|
// runner judges by whether the parser recognised the output.
|
|
@@ -216,6 +221,7 @@ export const PROBES: readonly ProbeSpec[] = [
|
|
|
216
221
|
ttlMs: DAY_MS,
|
|
217
222
|
timeoutMs: 5 * 60_000,
|
|
218
223
|
available: `test -f package.json`,
|
|
224
|
+
unavailable: `no package.json`,
|
|
219
225
|
// `-r` so a monorepo reports every workspace package, not just the root's own handful. `|| true` because
|
|
220
226
|
// pnpm exits non-zero exactly when it HAS findings, and the runner judges by whether the parse succeeded.
|
|
221
227
|
command: `pnpm outdated -r --json 2>/dev/null || true`,
|
|
@@ -231,6 +237,7 @@ export const PROBES: readonly ProbeSpec[] = [
|
|
|
231
237
|
// A lockfile, not a package.json: auditing resolves the actual installed tree, and without one pnpm
|
|
232
238
|
// reports against nothing.
|
|
233
239
|
available: `test -f pnpm-lock.yaml || test -f package-lock.json`,
|
|
240
|
+
unavailable: `no lockfile`,
|
|
234
241
|
command: `pnpm audit --json 2>/dev/null || true`,
|
|
235
242
|
parse: parseAudit,
|
|
236
243
|
},
|
|
@@ -244,6 +251,7 @@ export const PROBES: readonly ProbeSpec[] = [
|
|
|
244
251
|
// The repo's OWN knip, never a floating one: `pnpm dlx knip` would download a version that disagrees with
|
|
245
252
|
// the repo's knip.json about what counts as an entry point, and then report its whole public API as dead.
|
|
246
253
|
available: `pnpm exec knip --version >/dev/null 2>&1`,
|
|
254
|
+
unavailable: `knip is not a devDependency`,
|
|
247
255
|
command: `pnpm exec knip --reporter json --no-exit-code 2>/dev/null || true`,
|
|
248
256
|
parse: parseKnip,
|
|
249
257
|
},
|
|
@@ -255,6 +263,7 @@ export const PROBES: readonly ProbeSpec[] = [
|
|
|
255
263
|
ttlMs: 7 * DAY_MS,
|
|
256
264
|
timeoutMs: 20 * 60_000,
|
|
257
265
|
available: `test -f package.json`,
|
|
266
|
+
unavailable: `no package.json`,
|
|
258
267
|
// `--threshold 100` so jscpd never fails the command on its own opinion of what is too much duplication —
|
|
259
268
|
// that judgement is the chore's, made from the percentage, not the tool's exit code.
|
|
260
269
|
command:
|
package/src/chores/verdict.ts
CHANGED
|
@@ -73,7 +73,13 @@ const unmeasuredDetail = (needs: readonly ProbeId[], probes: ReadonlyMap<ProbeId
|
|
|
73
73
|
if (probe.state === `ok`) {
|
|
74
74
|
return [];
|
|
75
75
|
}
|
|
76
|
-
|
|
76
|
+
// An unavailable probe's reason already says what is missing ("no lockfile"), so prefixing it with "not
|
|
77
|
+
// available in this repository" only says the same thing twice. A failure has to keep its label: its
|
|
78
|
+
// reason is the tool's own output, which on its own reads as a fact rather than as a breakage.
|
|
79
|
+
if (probe.state === `unavailable`) {
|
|
80
|
+
return [`${spec.title} · ${probe.reason ?? `not available in this repository`}`];
|
|
81
|
+
}
|
|
82
|
+
return [`${spec.title} · failed${probe.reason === undefined ? `` : ` — ${probe.reason}`}`];
|
|
77
83
|
});
|
|
78
84
|
|
|
79
85
|
export const assessChore = (chore: Chore, context: ChoreContext, ledger: ChoreLedgerEntry | undefined): ChoreVerdict => {
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { StateFile } from "./state-portability.js";
|
|
2
|
+
|
|
3
|
+
/* WHAT LIVES ON /history — the second half of the daemon's state, and the half nothing declared until an
|
|
4
|
+
* export had to reason about it.
|
|
5
|
+
*
|
|
6
|
+
* `WORKSPACE_STATE_FILES` covers `<workspace>/.intentic/`, which is where the manifests live. It is not where
|
|
7
|
+
* the machinery lives. Every repo's REAL git dir is here (a repo's in-tree `.git` is a pointer file — see
|
|
8
|
+
* git/repo-git-dirs.ts for the invariant that forces it), and so are the fleet registry, the turn journal, the
|
|
9
|
+
* ledgers, the checkpoint scopes and the isolated agents' checkouts. A "workspace export" that took `/work`
|
|
10
|
+
* alone would carry a tree of repos with dangling gitdir pointers — every git command in the restored sandbox
|
|
11
|
+
* answering `fatal: not a git repository` — and an empty agent board.
|
|
12
|
+
*
|
|
13
|
+
* The two tables stay separate rather than becoming one keyed by volume, because they answer different
|
|
14
|
+
* questions. A `.intentic` entry also declares which browser QUERY it makes stale, since the file watcher
|
|
15
|
+
* reports it; nothing here is watched at all (that is the point of the volume), so an `invalidates` field on
|
|
16
|
+
* these entries would be a column of empty arrays. What they share is the portability class, and that is
|
|
17
|
+
* imported rather than duplicated.
|
|
18
|
+
*
|
|
19
|
+
* `history-state.test.ts` fails when a daemon store builds a `/history` path this list doesn't carry, in both
|
|
20
|
+
* directions — the same shape-recognizing guard that covers the workspace table.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
// Paths are historyRoot-relative, forward-slash, matched by PREFIX; a directory entry keeps its trailing slash
|
|
24
|
+
// so it cannot prefix-match a sibling file. See stateFileFor for how nesting resolves.
|
|
25
|
+
export const HISTORY_STATE_FILES: readonly StateFile[] = [
|
|
26
|
+
/* ---- the machinery a restored workspace is inert without ---- */
|
|
27
|
+
|
|
28
|
+
/* THE ONE THAT MAKES A BUNDLE A WORKSPACE. Every repo's real git dir, including the /work root's own
|
|
29
|
+
* ("root"), keyed by URI-encoded repo id. Carrying the working tree without this hands the target files
|
|
30
|
+
* whose `.git` points at a path that does not exist there — which is not a degraded repo but a broken one,
|
|
31
|
+
* and it takes the Changes review, the diff, land and every agent branch with it. The agent BRANCHES live
|
|
32
|
+
* in here too, which is what lets the checkouts below be left out. */
|
|
33
|
+
{ path: "gits/", portability: "carry" },
|
|
34
|
+
// The checkpoint timeline (one bare repo per scope, snapshots on refs/snapshots/head). Restoring it is what
|
|
35
|
+
// makes "restore to before that turn" still reach back past the move.
|
|
36
|
+
{ path: "scopes/", portability: "carry" },
|
|
37
|
+
// The fleet: every conversation card, its branch, its session ids, its standing.
|
|
38
|
+
{ path: "agents.json", portability: "carry" },
|
|
39
|
+
{ path: "turns/", portability: "carry" },
|
|
40
|
+
{ path: "transcripts/", portability: "carry" },
|
|
41
|
+
{ path: "activity.jsonl", portability: "carry" },
|
|
42
|
+
{ path: "usage.jsonl", portability: "carry" },
|
|
43
|
+
{ path: "account-usage.json", portability: "carry" },
|
|
44
|
+
{ path: "provider-refusals.json", portability: "carry" },
|
|
45
|
+
// The deploy engine's own ledgers — a run's events and the check results the Pipelines view reads back.
|
|
46
|
+
{ path: "apply-events.ndjson", portability: "carry" },
|
|
47
|
+
{ path: "check-events/", portability: "carry" },
|
|
48
|
+
|
|
49
|
+
/* ---- regenerated by the target ---- */
|
|
50
|
+
|
|
51
|
+
/* THE DELIBERATE OMISSION, and the difference between a bundle of gigabytes and one of hundreds.
|
|
52
|
+
*
|
|
53
|
+
* A conversation's worktree is a full checkout of the monorepo per agent (plus its overlay upper dir), and
|
|
54
|
+
* there can be a hundred of them. None of it is unique: the branch it holds is in `gits/` above, and the
|
|
55
|
+
* registry entry naming it travels in agents.json — so an imported conversation arrives in exactly the
|
|
56
|
+
* shape the system already has a name for. `attached()` reports its checkout as absent, the board renders
|
|
57
|
+
* it, and the next turn's `ensure()` re-creates it from the recorded composition, which is the same path an
|
|
58
|
+
* archived agent takes when it runs again. The boot sweep's `git worktree prune` clears the stale admin
|
|
59
|
+
* entries the restored git dirs still carry. */
|
|
60
|
+
{
|
|
61
|
+
path: "worktrees/",
|
|
62
|
+
portability: "derived",
|
|
63
|
+
note: "Each conversation re-attaches its checkout from its branch on its next turn.",
|
|
64
|
+
},
|
|
65
|
+
{ path: "overlays/", portability: "derived" },
|
|
66
|
+
{ path: "logs/", portability: "derived" },
|
|
67
|
+
{ path: "trash/", portability: "derived" },
|
|
68
|
+
{ path: ".isolation-probe", portability: "derived" },
|
|
69
|
+
|
|
70
|
+
/* ---- credentials ---- */
|
|
71
|
+
|
|
72
|
+
// The ssh alias dir ~/.ssh/intentic-hosts symlinks to: per-host config, private keys and passphrases for
|
|
73
|
+
// every host capability and git remote the sandbox reaches.
|
|
74
|
+
{
|
|
75
|
+
path: "ssh-hosts/",
|
|
76
|
+
portability: "secret",
|
|
77
|
+
note: "Re-add each ssh host on the Capabilities view — its key does not travel.",
|
|
78
|
+
},
|
|
79
|
+
// The cli-proxy's config, which holds the routed subscriptions' provider tokens.
|
|
80
|
+
{
|
|
81
|
+
path: "translator/",
|
|
82
|
+
portability: "secret",
|
|
83
|
+
note: "Sign the routed AI subscriptions in again on the Agent tab.",
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
/* ---- identity: what binds this sandbox to its owner, its browsers and its host ---- */
|
|
87
|
+
|
|
88
|
+
/* Signs every browser session cookie. Carrying it would let a bundle's holder mint sessions against the
|
|
89
|
+
* target — an export becomes a credential — and the target minting its own costs exactly one sign-in. */
|
|
90
|
+
{ path: "session-secret", portability: "identity", note: "Sign in again — the target signs its own sessions." },
|
|
91
|
+
{
|
|
92
|
+
path: "push.json",
|
|
93
|
+
portability: "identity",
|
|
94
|
+
note: "Re-enable notifications in the browsers you use — a push subscription is bound to the sandbox that minted it.",
|
|
95
|
+
},
|
|
96
|
+
{ path: "sync-enrollments.json", portability: "identity", note: "Re-pair desktop sync from the Sync tab." },
|
|
97
|
+
{ path: "sync-pair-consumed.json", portability: "identity" },
|
|
98
|
+
{ path: "host-enrollments.json", portability: "identity" },
|
|
99
|
+
{ path: "local-cert/", portability: "identity" },
|
|
100
|
+
];
|
package/src/index.ts
CHANGED
|
@@ -76,6 +76,8 @@ export * from "./events.js";
|
|
|
76
76
|
export * from "./sse.js";
|
|
77
77
|
export * from "./routes.js";
|
|
78
78
|
export * from "./workspace-state.js";
|
|
79
|
+
export * from "./state-portability.js";
|
|
80
|
+
export * from "./history-state.js";
|
|
79
81
|
export * from "./agent-catalog.js";
|
|
80
82
|
export * from "./host-protocol.js";
|
|
81
83
|
export * from "./hostnames.js";
|
package/src/schemas.ts
CHANGED
|
@@ -3207,6 +3207,53 @@ export const EnvironmentSchema = z.object({
|
|
|
3207
3207
|
export type Environment = z.infer<typeof EnvironmentSchema>;
|
|
3208
3208
|
export const EnvironmentApproveSchema = z.object({ hash: z.string().min(1) });
|
|
3209
3209
|
|
|
3210
|
+
/* ---- portability: exporting a sandbox's environment and restoring it into a fresh one ----
|
|
3211
|
+
*
|
|
3212
|
+
* A sandbox is four stores, not one: `/work` (the workspace and the daemon's manifests), `/history` (every
|
|
3213
|
+
* repo's real git dir, the fleet registry, the ledgers), the CONTAINER (the built overlay image plus the env
|
|
3214
|
+
* the run contract replays) and the AI-provider credential root. A bundle carries the first two, declared entry
|
|
3215
|
+
* by entry in WORKSPACE_STATE_FILES / HISTORY_STATE_FILES. It cannot carry the other two, and the honest
|
|
3216
|
+
* consequence is that an import ends in a REPORT rather than a claim of equivalence — the container has no
|
|
3217
|
+
* docker socket, so only the host can rebuild the image the overlay describes.
|
|
3218
|
+
*/
|
|
3219
|
+
|
|
3220
|
+
// What the bundle says about itself, written as its first tar entry so a reader learns the shape before the
|
|
3221
|
+
// bytes. `secrets` is the owner's export-time choice; the restorer re-derives every decision from the manifests
|
|
3222
|
+
// rather than trusting this, and uses it only to explain what is missing.
|
|
3223
|
+
export const BundleManifestSchema = z.object({
|
|
3224
|
+
// Bumped when the layout changes in a way an older daemon would misread. Refused rather than guessed at.
|
|
3225
|
+
version: z.literal(1),
|
|
3226
|
+
// Where it came from, for the report's first line. Never used to authorize anything.
|
|
3227
|
+
sandbox: z.object({ name: z.string() }).optional(),
|
|
3228
|
+
createdAt: z.number(),
|
|
3229
|
+
secrets: z.boolean(),
|
|
3230
|
+
/* The environment the target has to reproduce, carried as FACTS rather than as the composed file (which the
|
|
3231
|
+
* target recomposes against its OWN base image on first boot). `customDockerfile` is the owner-approved
|
|
3232
|
+
* source section; `capabilities` names what contributed the remaining fragments, so the report can list what
|
|
3233
|
+
* to re-add when the configs themselves did not travel. */
|
|
3234
|
+
environment: z.object({
|
|
3235
|
+
customDockerfile: z.string().optional(),
|
|
3236
|
+
baseImage: z.string().optional(),
|
|
3237
|
+
approvedHash: z.string().optional(),
|
|
3238
|
+
capabilities: z.array(z.object({ id: z.string(), kind: z.string() })),
|
|
3239
|
+
}),
|
|
3240
|
+
// Every path class the bundle deliberately left out, with the manifest's own note where it has one. This is
|
|
3241
|
+
// what turns "the export skipped things" from a silence into a list the owner can act on.
|
|
3242
|
+
excluded: z.array(z.object({ path: z.string(), portability: z.string(), note: z.string().optional() })),
|
|
3243
|
+
});
|
|
3244
|
+
export type BundleManifest = z.infer<typeof BundleManifestSchema>;
|
|
3245
|
+
|
|
3246
|
+
// What a restore actually did. `needsAction` is the part that matters: the environment rebuild command, the
|
|
3247
|
+
// credentials to re-enter, the logins to redo — each one a thing the target cannot do for itself.
|
|
3248
|
+
export const ImportReportSchema = z.object({
|
|
3249
|
+
restored: z.object({ workspaceFiles: z.number(), historyFiles: z.number(), repos: z.array(z.string()), bytes: z.number() }),
|
|
3250
|
+
// Entries the bundle carried that this daemon refused to write (an identity file, an escaping path) — empty
|
|
3251
|
+
// for any bundle a matching exporter produced, and a tamper signal when it is not.
|
|
3252
|
+
refused: z.array(z.string()),
|
|
3253
|
+
needsAction: z.array(z.object({ subject: z.string(), detail: z.string() })),
|
|
3254
|
+
});
|
|
3255
|
+
export type ImportReport = z.infer<typeof ImportReportSchema>;
|
|
3256
|
+
|
|
3210
3257
|
// ---- secrets: user-supplied env-var secrets the daemon writes to desired-state/.env ----
|
|
3211
3258
|
// The web posts a Cloudflare token / GitHub PAT / another-host SSH key straight to the sandbox daemon (never
|
|
3212
3259
|
// through the platform); `apply` reloads .env each run so a new secret is picked up with NO restart. `list`
|
|
@@ -3693,7 +3740,10 @@ export const ProbeResultSchema = z.object({
|
|
|
3693
3740
|
// deciding whether to force a refresh deserves to know what they are asking for.
|
|
3694
3741
|
tookMs: z.number().int().nonnegative(),
|
|
3695
3742
|
facts: ProbeFactsSchema.optional(),
|
|
3696
|
-
//
|
|
3743
|
+
// On `failed`, how it broke — a bounded quote of the tool's own output, never a summary of it. On
|
|
3744
|
+
// `unavailable`, what is missing, in the probe spec's own words ("no lockfile"): there is no tool output to
|
|
3745
|
+
// quote when the tool never ran, and the alternative — a sentence built from the probe's name — would have an
|
|
3746
|
+
// unmeasured probe claiming there is nothing to measure.
|
|
3697
3747
|
reason: z.string().optional(),
|
|
3698
3748
|
});
|
|
3699
3749
|
export type ProbeResult = z.infer<typeof ProbeResultSchema>;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/* WHETHER A PIECE OF DAEMON STATE TRAVELS — the second thing every state manifest declares.
|
|
2
|
+
*
|
|
3
|
+
* A sandbox's environment is not one directory. `/work` holds the workspace and the daemon's manifests,
|
|
4
|
+
* `/history` holds the real git dirs, the fleet registry and the ledgers, and the container carries the built
|
|
5
|
+
* image and its env. "Export the environment" therefore means walking state that lives in two volumes and
|
|
6
|
+
* deciding, per entry, which of four things it is — and that decision has to be written down beside the entry
|
|
7
|
+
* itself, or the exporter becomes the hand-kept list that workspace-state.ts exists to argue against.
|
|
8
|
+
*
|
|
9
|
+
* So each manifest entry carries a class, and both the bundler and the restorer derive from it. Adding a store
|
|
10
|
+
* without saying whether it travels is a failing coverage test, not a file that silently goes missing from
|
|
11
|
+
* every bundle.
|
|
12
|
+
*
|
|
13
|
+
* The classes are exhaustive on purpose — there is no "unset" — because the failure mode of a default is
|
|
14
|
+
* exactly the one that matters: a credential store added later would inherit `carry` and ride out of the
|
|
15
|
+
* sandbox in a file the owner thought was safe to email.
|
|
16
|
+
*/
|
|
17
|
+
export type Portability =
|
|
18
|
+
/* Ordinary state. Travels in every bundle. */
|
|
19
|
+
| "carry"
|
|
20
|
+
/* Holds a credential — a provider token, a webhook secret, an ssh key. Travels ONLY when the owner opts
|
|
21
|
+
* in at export, and the bundle records that it did. Never the default. */
|
|
22
|
+
| "secret"
|
|
23
|
+
/* Binds this sandbox to its owner, its browsers or its host: who may drive it, the session-signing secret,
|
|
24
|
+
* push subscriptions, enrollments, certificates. Never travels — the target mints its own, and carrying
|
|
25
|
+
* one would either hand the target's ownership to the source or leave two sandboxes claiming one identity. */
|
|
26
|
+
| "identity"
|
|
27
|
+
/* Regenerated by the target from state that does travel: composed files, caches, indexes, checkouts. Left
|
|
28
|
+
* out for size, not for safety — carrying it would only ship a stale copy of something the daemon rebuilds
|
|
29
|
+
* on its own. */
|
|
30
|
+
| "derived";
|
|
31
|
+
|
|
32
|
+
// One entry of a state manifest: the path it owns and what happens to it at export. `note` is user-facing —
|
|
33
|
+
// the import report prints it beside a skipped entry, so it is written as an instruction to the owner ("log in
|
|
34
|
+
// again"), not as a rationale for the next reader. Rationale belongs in a comment on the entry.
|
|
35
|
+
export interface StateFile {
|
|
36
|
+
readonly path: string;
|
|
37
|
+
readonly portability: Portability;
|
|
38
|
+
readonly note?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/* The entry that owns a path, LONGEST PREFIX FIRST.
|
|
42
|
+
*
|
|
43
|
+
* Manifests deliberately nest: `.intentic/claude/` is a provider credential store that also contains
|
|
44
|
+
* `.intentic/claude/projects/`, the agent's memory notes and transcripts, which are the single most valuable
|
|
45
|
+
* thing in a bundle. First-match resolution would classify the notes by whichever entry happened to be listed
|
|
46
|
+
* first, so the specific entry has to win over the general one by construction rather than by list order —
|
|
47
|
+
* the same reason a .gitignore consults its deepest matcher first.
|
|
48
|
+
*
|
|
49
|
+
* Undefined for a path no entry claims. Callers decide what that means: the bundler treats an unclaimed
|
|
50
|
+
* `.intentic` path as a bug (the coverage test is what makes that safe to assume) and unclaimed ordinary
|
|
51
|
+
* workspace files as `carry`, which is what they are.
|
|
52
|
+
*/
|
|
53
|
+
export const stateFileFor = <T extends StateFile>(path: string, files: readonly T[]): T | undefined =>
|
|
54
|
+
files
|
|
55
|
+
.filter((file) => path === file.path || path.startsWith(file.path) || `${path}/` === file.path)
|
|
56
|
+
.reduce<T | undefined>((best, file) => (best === undefined || file.path.length > best.path.length ? file : best), undefined);
|
|
@@ -117,13 +117,31 @@ describe(`WORKSPACE_STATE_FILES`, () => {
|
|
|
117
117
|
}
|
|
118
118
|
});
|
|
119
119
|
|
|
120
|
-
it(`
|
|
121
|
-
|
|
122
|
-
|
|
120
|
+
it(`only nests under an entry that invalidates nothing, so one write can't be billed twice`, () => {
|
|
121
|
+
// Entries nest when one store answers PORTABILITY in two halves (`.intentic/claude/` is a credential
|
|
122
|
+
// root whose `projects/` subtree is the thing a bundle exists to carry) — stateFileFor's longest match
|
|
123
|
+
// is what keeps that unambiguous. Invalidation has no longest-match rule: staleQueryKeys unions every
|
|
124
|
+
// matching entry, so a nest under an entry that DOES invalidate would bill the outer view's queries for
|
|
125
|
+
// a write that belongs to the inner one. Nesting is therefore only legal beneath an empty `invalidates`.
|
|
126
|
+
for (const file of WORKSPACE_STATE_FILES.filter((entry) => entry.invalidates.length > 0)) {
|
|
127
|
+
const nested = WORKSPACE_STATE_FILES.filter((other) => other !== file && other.path.startsWith(file.path));
|
|
123
128
|
expect(
|
|
124
|
-
|
|
125
|
-
`${file.path} is a prefix of another entry`,
|
|
129
|
+
nested.map((other) => other.path),
|
|
130
|
+
`${file.path} invalidates ${file.invalidates.join(`, `)} and is a prefix of another entry`,
|
|
126
131
|
).toEqual([]);
|
|
127
132
|
}
|
|
128
133
|
});
|
|
134
|
+
|
|
135
|
+
it(`splits a nested entry from its parent for a reason the parent doesn't already carry`, () => {
|
|
136
|
+
// A nest that agrees with the entry it sits under is a duplicate: stateFileFor resolves to the same
|
|
137
|
+
// answer either way, so the split is dead weight the next reader has to diff to discover.
|
|
138
|
+
for (const file of WORKSPACE_STATE_FILES) {
|
|
139
|
+
for (const parent of WORKSPACE_STATE_FILES.filter((other) => other !== file && file.path.startsWith(other.path))) {
|
|
140
|
+
expect(
|
|
141
|
+
parent.portability === file.portability && parent.invalidates.join() === file.invalidates.join(),
|
|
142
|
+
`${file.path} says nothing its parent ${parent.path} doesn't already say`,
|
|
143
|
+
).toBe(false);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
});
|
|
129
147
|
});
|
package/src/workspace-state.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { FileContribution } from "@intentic/extension-api";
|
|
2
|
+
import type { StateFile } from "./state-portability.js";
|
|
2
3
|
|
|
3
4
|
/* WHICH WORKSPACE FILE BACKS WHICH CORE VIEW — one declaration, read by both sides of the wire.
|
|
4
5
|
*
|
|
@@ -29,16 +30,18 @@ import type { FileContribution } from "@intentic/extension-api";
|
|
|
29
30
|
* by the automations extension, because the extension had no way to say so itself. A key belongs to whoever
|
|
30
31
|
* queries it. */
|
|
31
32
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
export
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
33
|
+
/* A core entry is an extension's `contributes.files` entry plus the two things only the core list needs: the
|
|
34
|
+
* right to declare NO invalidations (for a daemon-owned file, the answer more often than not), and a
|
|
35
|
+
* portability class, because the daemon's own state is what an environment export has to reason about.
|
|
36
|
+
*
|
|
37
|
+
* `path` is workspace-root-relative, forward-slash — the space `workspaceChanged` paths arrive in. Matching is
|
|
38
|
+
* by PREFIX, which lets one entry cover three shapes without a second matching rule:
|
|
39
|
+
* - an exact file `.intentic/settings.json`
|
|
40
|
+
* - a directory `.intentic/drafts/` (one file per draft)
|
|
41
|
+
* - a name family `.intentic/environment.custom.` (…Dockerfile and anything later named beside it)
|
|
42
|
+
* A directory entry keeps its trailing slash so it can never prefix-match a sibling file. Entries may NEST —
|
|
43
|
+
* see stateFileFor, which resolves the longest match rather than the first. */
|
|
44
|
+
export interface WorkspaceStateFile extends StateFile {
|
|
42
45
|
/* The browser query keys this file's contents feed. EMPTY is a real answer, not a gap — a file the browser
|
|
43
46
|
* renders nothing from, or one deliberately kept off the push path — and `why` says which. Never a prefix
|
|
44
47
|
* test over `.intentic/` as a whole: one stray write must not cost every view a refetch, which is the
|
|
@@ -49,13 +52,39 @@ export interface WorkspaceStateFile {
|
|
|
49
52
|
}
|
|
50
53
|
|
|
51
54
|
export const WORKSPACE_STATE_FILES: readonly WorkspaceStateFile[] = [
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
55
|
+
/* A capability add/remove recomposes the environment overlay and can add or drop a repo's panel.
|
|
56
|
+
*
|
|
57
|
+
* Each entry's `config` carries that capability's credential (an mcp server's token, a Komodo key, an ssh
|
|
58
|
+
* key), so the manifest is a secret in full. It is also what composeEnvironment reads its Dockerfile
|
|
59
|
+
* fragments from, which makes this the entry where the owner's export choice has the most visible
|
|
60
|
+
* consequence: a bundle exported WITHOUT secrets rebuilds a stock overlay, and the import report has to
|
|
61
|
+
* name every capability the target needs re-added before its environment matches again. */
|
|
62
|
+
{ path: ".intentic/capabilities.json", invalidates: ["capabilities", "environment", "panels"], portability: "secret" },
|
|
63
|
+
|
|
64
|
+
/* The overlay Dockerfile, four files that a single `.intentic/environment.` prefix used to cover. They are
|
|
65
|
+
* split here because they answer PORTABILITY differently while answering invalidation identically, and the
|
|
66
|
+
* split is the whole difference between an export that reproduces an environment and one that reproduces a
|
|
67
|
+
* stale copy of it:
|
|
68
|
+
* - custom is the owner-approved SOURCE OF TRUTH and the only one that must travel;
|
|
69
|
+
* - approved is COMPOSED from custom + the capability fragments + this container's base image, and is
|
|
70
|
+
* rewritten on the target's first boot — carrying it would ship a FROM naming an image the target may
|
|
71
|
+
* not be on (see composeEnvironment's baseImageOf);
|
|
72
|
+
* - the proposal and the per-tool drafts under environment.d/ are the agent's pending requests, which the
|
|
73
|
+
* owner has not answered yet; they travel so the question survives the move. */
|
|
74
|
+
{ path: ".intentic/environment.custom.Dockerfile", invalidates: ["environment"], portability: "carry" },
|
|
75
|
+
{ path: ".intentic/environment.Dockerfile", invalidates: ["environment"], portability: "carry" },
|
|
76
|
+
{ path: ".intentic/environment.d/", invalidates: ["environment"], portability: "carry" },
|
|
77
|
+
{
|
|
78
|
+
path: ".intentic/environment.approved.Dockerfile",
|
|
79
|
+
invalidates: ["environment"],
|
|
80
|
+
portability: "derived",
|
|
81
|
+
note: "The target composes its own overlay on first boot; rebuild it there to install the tools it names.",
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
{ path: ".intentic/settings.json", invalidates: ["settings"], portability: "carry" },
|
|
56
85
|
// Written by the AGENT's file tools (the drafts skill), read by the owner's approval inbox — the one entry
|
|
57
86
|
// here whose whole point is that a change arrives from outside the browser that renders it.
|
|
58
|
-
{ path: ".intentic/drafts/", invalidates: ["drafts"] },
|
|
87
|
+
{ path: ".intentic/drafts/", invalidates: ["drafts"], portability: "carry" },
|
|
59
88
|
// ---- declared by the extension that renders them (contributes.files), not here ----
|
|
60
89
|
// The path is the DAEMON's (automations-store writes both), the query keys are the intentic.automations
|
|
61
90
|
// extension's. It declares them in its own manifest and the browser unions the two lists, so uninstalling
|
|
@@ -64,11 +93,13 @@ export const WORKSPACE_STATE_FILES: readonly WorkspaceStateFile[] = [
|
|
|
64
93
|
path: ".intentic/automations.json",
|
|
65
94
|
invalidates: [],
|
|
66
95
|
why: "Declared by the intentic.automations extension's contributes.files — `automations` is its query key, not core's.",
|
|
96
|
+
portability: "carry",
|
|
67
97
|
},
|
|
68
98
|
{
|
|
69
99
|
path: ".intentic/approvals/",
|
|
70
100
|
invalidates: [],
|
|
71
101
|
why: "Declared by the intentic.automations extension's contributes.files — `automation-approvals` is its query key, not core's.",
|
|
102
|
+
portability: "carry",
|
|
72
103
|
},
|
|
73
104
|
|
|
74
105
|
/* ---- reached by no query, for reasons that are not oversights ----
|
|
@@ -81,16 +112,24 @@ export const WORKSPACE_STATE_FILES: readonly WorkspaceStateFile[] = [
|
|
|
81
112
|
path: ".intentic/webchat-installs.json",
|
|
82
113
|
invalidates: [],
|
|
83
114
|
why: "Which origins have loaded a Doorbell's widget, written on a 30s flush timer while a customer's site serves page views. The install panel that renders it fetches on open and polls itself while it is on screen, which is the whole window in which the answer changes for anyone. Pushing instead would bill every connected browser a refetch per flush, for a panel almost nobody has open.",
|
|
115
|
+
portability: "carry",
|
|
84
116
|
},
|
|
85
117
|
{
|
|
86
118
|
path: ".intentic/thread-sessions.json",
|
|
87
119
|
invalidates: [],
|
|
88
120
|
why: "Thread bookkeeping (an inbound thread — a Doorbell visitor, a Discord or Slack channel — → sandbox conversation + provider session), written on EVERY inbound message. Nothing in the browser reads it: what a thread produces is a conversation, and the fleet board already learns about that from the agent registry's own push. Naming a key here would bill every connected browser a refetch per inbound message — the request storm this table's own note warns about — to refresh nothing it can see.",
|
|
121
|
+
portability: "carry",
|
|
89
122
|
},
|
|
123
|
+
/* Values are a primitive union an extension chooses the meaning of, and "an API key for the service I talk
|
|
124
|
+
* to" is squarely within it — so this is classed by what it CAN hold, not by what any particular extension
|
|
125
|
+
* happens to put there. The alternative reads the wrong way round: a bundle that leaked one extension's
|
|
126
|
+
* token would have been correct about all the others. */
|
|
90
127
|
{
|
|
91
128
|
path: ".intentic/extension-settings.json",
|
|
92
129
|
invalidates: [],
|
|
93
130
|
why: "Held in a module-level shallowRef store per extension (web's extensionSettingsStore) with no query observer, and deliberately so: api.settings.get must answer SYNCHRONOUSLY from an extension's first activate() line, and the store outlives every component scope. A module-level QueryObserver is the one shape that would make invalidation refetch, and this app already ruled it out — it detaches on the queryClient.clear() at logout (see useSandbox's sandbox-list mirror). So a remote member's setting edit reaches this browser on its next load, not live.",
|
|
131
|
+
portability: "secret",
|
|
132
|
+
note: "Re-enter each extension's settings on the Extensions tab.",
|
|
94
133
|
},
|
|
95
134
|
/* Unlike the settings file above it, the on/off switch IS observed by a query — the Extensions tab's list,
|
|
96
135
|
* which carries each row's switch position — so a flip made elsewhere (another member, the agent writing the
|
|
@@ -99,11 +138,14 @@ export const WORKSPACE_STATE_FILES: readonly WorkspaceStateFile[] = [
|
|
|
99
138
|
{
|
|
100
139
|
path: ".intentic/extension-enablement.json",
|
|
101
140
|
invalidates: ["extensions"],
|
|
141
|
+
portability: "carry",
|
|
102
142
|
},
|
|
103
143
|
{
|
|
104
144
|
path: ".intentic/members.json",
|
|
105
145
|
invalidates: [],
|
|
106
146
|
why: "Not this view's source at all: SandboxAccess renders the PLATFORM's invite records (apiClient.invite.list), and this file is the daemon's ENFORCED copy — written first so a grant the enforcer never got is never recorded, then never read back. A change here means the two disagreed, which the write order makes fail-closed rather than stale.",
|
|
147
|
+
portability: "identity",
|
|
148
|
+
note: "Re-invite collaborators from the Access tab — a grant is the platform's record, and the target enforces its own copy.",
|
|
107
149
|
},
|
|
108
150
|
|
|
109
151
|
// ---- daemon-owned, nothing derives from watching them ----
|
|
@@ -116,40 +158,76 @@ export const WORKSPACE_STATE_FILES: readonly WorkspaceStateFile[] = [
|
|
|
116
158
|
* is +119 watched directories against ~593 today (a fifth more), with 314 continuously-rewritten transcripts
|
|
117
159
|
* inside the newly-watched set, to make ONE memory directory live. Notes change at agent-turn cadence, so the
|
|
118
160
|
* poll costs a request a minute and the alternative costs a permanent 20% on the watcher. */
|
|
161
|
+
/* THE STORE THAT IS TWO THINGS, hence two entries — this is the case stateFileFor's longest-match rule
|
|
162
|
+
* exists for. `.intentic/claude/` is where linkClaudeState parks the Claude CLI's per-conversation state,
|
|
163
|
+
* and (when AGENT_AUTH_DIR is unset, which is the production layout) it is ALSO the provider credential
|
|
164
|
+
* root. The subtree under `projects/` is the half a bundle exists to carry — the agent's memory notes and
|
|
165
|
+
* every conversation's transcript — while its siblings hold the OAuth that must not leave the sandbox. */
|
|
119
166
|
{
|
|
120
167
|
path: ".intentic/claude/",
|
|
121
168
|
invalidates: [],
|
|
122
169
|
why: "Agent session transcripts — see the note above on why the memory notes under it stay polled.",
|
|
170
|
+
portability: "secret",
|
|
171
|
+
note: "Sign the agent's AI accounts in again on the Agent tab.",
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
path: ".intentic/claude/projects/",
|
|
175
|
+
invalidates: [],
|
|
176
|
+
why: "Same store as the entry above; split from it for portability, not for invalidation.",
|
|
177
|
+
portability: "carry",
|
|
123
178
|
},
|
|
124
179
|
{
|
|
125
180
|
path: ".intentic/ci.json",
|
|
126
181
|
invalidates: [],
|
|
127
182
|
why: "Webhook secret + conclusion memory; the Pipelines view reads it through /ci/runs, not off disk.",
|
|
183
|
+
portability: "secret",
|
|
184
|
+
note: "Re-add the CI webhook on the Pipelines view — its secret is per-sandbox.",
|
|
128
185
|
},
|
|
129
186
|
{
|
|
130
187
|
path: ".intentic/komodo.json",
|
|
131
188
|
invalidates: [],
|
|
132
189
|
why: "Per-connection 'when the owner last looked at Deployments'; the view reads it through /komodo/{capability}/overview, not off disk — and it is written BY that view being opened, so invalidating on it would refetch the board in answer to the browser's own click.",
|
|
190
|
+
portability: "carry",
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
path: ".intentic/bridge-tokens.json",
|
|
194
|
+
invalidates: [],
|
|
195
|
+
why: "Hashed ACP bridge tokens, listed on demand by the owner.",
|
|
196
|
+
portability: "identity",
|
|
197
|
+
note: "Mint fresh ACP bridge tokens — the old ones authenticate against the source sandbox.",
|
|
133
198
|
},
|
|
134
|
-
{ path: ".intentic/bridge-tokens.json", invalidates: [], why: "Hashed ACP bridge tokens, listed on demand by the owner." },
|
|
135
199
|
{
|
|
136
200
|
path: ".intentic/owner.json",
|
|
137
201
|
invalidates: [],
|
|
138
202
|
why: "Bound once on first use; a change here means the sandbox was re-owned, which re-authenticates anyway.",
|
|
203
|
+
portability: "identity",
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
path: ".intentic/workspace.json",
|
|
207
|
+
invalidates: [],
|
|
208
|
+
why: "The workspace identity, read from the /events hello frame rather than as a file.",
|
|
209
|
+
portability: "identity",
|
|
139
210
|
},
|
|
140
|
-
{ path: ".intentic/
|
|
141
|
-
|
|
211
|
+
{ path: ".intentic/templates.json", invalidates: [], why: "Scaffold templates, read when the scaffold dialog opens.", portability: "carry" },
|
|
212
|
+
/* Classed `derived` for size rather than for safety, and it is the one entry where that costs the owner
|
|
213
|
+
* something real: the profiles ARE logged-in sessions. They are also gigabytes of a store Chromium rewrites
|
|
214
|
+
* constantly and versions against its own build, so carrying them ships bulk that the target's Chromium may
|
|
215
|
+
* refuse anyway. The note is what keeps the loss visible instead of silent. */
|
|
142
216
|
{
|
|
143
217
|
path: ".intentic/browser/",
|
|
144
218
|
invalidates: [],
|
|
145
219
|
why: "Browser-login profiles: Chromium rewrites these constantly. Descent-ignored by the watcher outright.",
|
|
220
|
+
portability: "derived",
|
|
221
|
+
note: "Log the agent's browser back into any site it needs — profiles do not travel.",
|
|
146
222
|
},
|
|
147
223
|
{
|
|
148
224
|
path: ".intentic/extensions/",
|
|
149
225
|
invalidates: [],
|
|
150
226
|
why: "Extension checkouts — whole git clones. The `extensions` query is driven by the capability manifest above, not by their contents.",
|
|
227
|
+
portability: "derived",
|
|
228
|
+
note: "Extensions re-clone from the capability manifest on the target's next reconcile.",
|
|
151
229
|
},
|
|
152
|
-
{ path: ".intentic/plugins/", invalidates: [], why: "Agent plugin dirs, read by the SDK's loader each turn." },
|
|
230
|
+
{ path: ".intentic/plugins/", invalidates: [], why: "Agent plugin dirs, read by the SDK's loader each turn.", portability: "carry" },
|
|
153
231
|
];
|
|
154
232
|
|
|
155
233
|
/* The query keys a batch of changed paths makes stale, deduped and stable. The browser's `/events` handler calls
|