@intentic/sandbox-contract 1.217.0 → 1.218.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/publish-drafts.js +1 -1
- package/dist/publish-drafts.js.map +1 -1
- package/dist/schemas.d.ts +6 -1
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +2 -1
- package/dist/schemas.js.map +1 -1
- package/dist/workspace-state.d.ts +64 -54
- package/dist/workspace-state.d.ts.map +1 -1
- package/dist/workspace-state.js +93 -64
- package/dist/workspace-state.js.map +1 -1
- package/package.json +4 -4
- package/src/contracts/extensions.contract.ts +1 -1
- package/src/contracts/settings.contract.ts +1 -1
- package/src/events.ts +2 -2
- package/src/publish-drafts.ts +1 -1
- package/src/schemas.ts +45 -39
- package/src/workspace-state.test.ts +226 -79
- package/src/workspace-state.ts +240 -68
|
@@ -2,11 +2,18 @@ import { STATE_DIR } from "@intentic/constants";
|
|
|
2
2
|
import type { FileContribution } from "@intentic/extension-manifest";
|
|
3
3
|
import { describe, expect, it } from "vitest";
|
|
4
4
|
import {
|
|
5
|
+
BACKED_UP_STATE_PATHS,
|
|
5
6
|
isLockedWorkspacePath,
|
|
6
7
|
isReportedManifest,
|
|
7
8
|
isReviewableLockedPath,
|
|
8
9
|
REPORTED_MANIFEST_PATHS,
|
|
10
|
+
SEARCHABLE_STATE_PATHS,
|
|
11
|
+
STATE_GROUP_DIR,
|
|
12
|
+
type StateGroup,
|
|
13
|
+
stateGroupOf,
|
|
14
|
+
stateGroupPaths,
|
|
9
15
|
staleQueryKeys,
|
|
16
|
+
UNBACKED_STATE_PATHS,
|
|
10
17
|
VERSIONED_STATE_PATHS,
|
|
11
18
|
WORKSPACE_STATE_FILES,
|
|
12
19
|
} from "./workspace-state.js";
|
|
@@ -16,13 +23,13 @@ import {
|
|
|
16
23
|
// the dependency direction, so reaching back for them here would invert it. The real manifests are checked
|
|
17
24
|
// against this rule where they are loaded — web's fileBindings.test.ts and the daemon's file-bindings.test.ts.
|
|
18
25
|
const AUTOMATIONS: readonly FileContribution[] = [
|
|
19
|
-
{ path: `${STATE_DIR}/automations.json`, invalidates: [`automations`] },
|
|
20
|
-
{ path: `${STATE_DIR}/approvals/`, invalidates: [`automation-approvals`] },
|
|
26
|
+
{ path: `${STATE_DIR}/config/automations.json`, invalidates: [`automations`] },
|
|
27
|
+
{ path: `${STATE_DIR}/records/approvals/`, invalidates: [`automation-approvals`] },
|
|
21
28
|
];
|
|
22
29
|
|
|
23
30
|
describe(`staleQueryKeys`, () => {
|
|
24
31
|
it(`maps a manifest write to the queries it makes stale`, () => {
|
|
25
|
-
expect(staleQueryKeys([`.intentic/capabilities.json`], [])).toEqual([`capabilities`, `environment`, `panels`, `manifests`]);
|
|
32
|
+
expect(staleQueryKeys([`.intentic/config/capabilities.json`], [])).toEqual([`capabilities`, `environment`, `panels`, `manifests`]);
|
|
26
33
|
});
|
|
27
34
|
|
|
28
35
|
it(`refreshes the unreadable-manifest notice for the three files a person hand-edits`, () => {
|
|
@@ -30,33 +37,37 @@ describe(`staleQueryKeys`, () => {
|
|
|
30
37
|
// the twenty-odd DAEMON-written ones cannot introduce a typo, and billing every browser a refetch for
|
|
31
38
|
// each of them is the amplification this table exists to avoid.
|
|
32
39
|
const carries = WORKSPACE_STATE_FILES.filter((file) => file.invalidates.includes(`manifests`)).map((file) => file.path);
|
|
33
|
-
expect(carries.toSorted()).toEqual([
|
|
40
|
+
expect(carries.toSorted()).toEqual([
|
|
41
|
+
`.intentic/config/capabilities.json`,
|
|
42
|
+
`.intentic/config/personas.json`,
|
|
43
|
+
`.intentic/config/settings.json`,
|
|
44
|
+
]);
|
|
34
45
|
});
|
|
35
46
|
|
|
36
47
|
it(`matches a name family and a one-file-per-entry directory through one prefix each`, () => {
|
|
37
48
|
// environment.Dockerfile, environment.custom.Dockerfile, environment.approved.Dockerfile — one entry.
|
|
38
|
-
expect(staleQueryKeys([`.intentic/environment.custom.Dockerfile`], [])).toEqual([`environment`]);
|
|
39
|
-
expect(staleQueryKeys([`.intentic/drafts/post-1.json`], [])).toEqual([`drafts`]);
|
|
49
|
+
expect(staleQueryKeys([`.intentic/config/environment.custom.Dockerfile`], [])).toEqual([`environment`]);
|
|
50
|
+
expect(staleQueryKeys([`.intentic/config/drafts/post-1.json`], [])).toEqual([`drafts`]);
|
|
40
51
|
});
|
|
41
52
|
|
|
42
53
|
it(`refreshes the Drafts view when the AGENT writes a draft`, () => {
|
|
43
54
|
// The regression this table was reorganized around: the drafts skill writes these files directly, so
|
|
44
55
|
// there is no browser mutation to hang an invalidate on — the watcher push is the only signal, and it
|
|
45
56
|
// used to be dropped on the floor.
|
|
46
|
-
expect(staleQueryKeys([`.intentic/drafts/post-1.json`], [])).toEqual([`drafts`]);
|
|
57
|
+
expect(staleQueryKeys([`.intentic/config/drafts/post-1.json`], [])).toEqual([`drafts`]);
|
|
47
58
|
});
|
|
48
59
|
|
|
49
60
|
it(`ignores unrelated churn under .intentic/`, () => {
|
|
50
61
|
// The amplification that turned an iq index rebuild into an endless request storm: a prefix test on
|
|
51
62
|
// `.intentic/` alone would invalidate every one of these queries for each index write.
|
|
52
|
-
expect(staleQueryKeys([`.intentic/cache/iq/index.db`, `.intentic/sessions/claude/projects/p/session.jsonl`], [])).toEqual([]);
|
|
63
|
+
expect(staleQueryKeys([`.intentic/local/cache/iq/index.db`, `.intentic/records/sessions/claude/projects/p/session.jsonl`], [])).toEqual([]);
|
|
53
64
|
});
|
|
54
65
|
|
|
55
66
|
it(`ignores a store's own temp file while it is mid-swap`, () => {
|
|
56
67
|
// jsonFile writes `.<name>.<pid>.tmp` beside the target precisely so the atomic rename can't be read as
|
|
57
68
|
// a write to the target itself. A trailing-tag temp would prefix-match and bill an extra refetch.
|
|
58
69
|
expect(staleQueryKeys([`.intentic/.settings.json.42.tmp`], [])).toEqual([]);
|
|
59
|
-
expect(staleQueryKeys([`.intentic/settings.json`], [])).toEqual([`settings`, `manifests`]);
|
|
70
|
+
expect(staleQueryKeys([`.intentic/config/settings.json`], [])).toEqual([`settings`, `manifests`]);
|
|
60
71
|
});
|
|
61
72
|
|
|
62
73
|
it(`ignores ordinary workspace edits`, () => {
|
|
@@ -65,7 +76,7 @@ describe(`staleQueryKeys`, () => {
|
|
|
65
76
|
|
|
66
77
|
it(`dedupes keys across a batch that touches several manifests`, () => {
|
|
67
78
|
// A capability add recomposes the overlay, so both entries claim `environment` — one refetch, not two.
|
|
68
|
-
expect(staleQueryKeys([`.intentic/capabilities.json`, `.intentic/environment.Dockerfile`], [])).toEqual([
|
|
79
|
+
expect(staleQueryKeys([`.intentic/config/capabilities.json`, `.intentic/config/environment.Dockerfile`], [])).toEqual([
|
|
69
80
|
`capabilities`,
|
|
70
81
|
`environment`,
|
|
71
82
|
`panels`,
|
|
@@ -74,33 +85,33 @@ describe(`staleQueryKeys`, () => {
|
|
|
74
85
|
});
|
|
75
86
|
|
|
76
87
|
it(`invalidates an extension's queries from its own declaration`, () => {
|
|
77
|
-
expect(staleQueryKeys([`.intentic/automations.json`], AUTOMATIONS)).toEqual([`automations`]);
|
|
78
|
-
expect(staleQueryKeys([`.intentic/approvals/a1.json`], AUTOMATIONS)).toEqual([`automation-approvals`]);
|
|
88
|
+
expect(staleQueryKeys([`.intentic/config/automations.json`], AUTOMATIONS)).toEqual([`automations`]);
|
|
89
|
+
expect(staleQueryKeys([`.intentic/records/approvals/a1.json`], AUTOMATIONS)).toEqual([`automation-approvals`]);
|
|
79
90
|
});
|
|
80
91
|
|
|
81
92
|
it(`makes nothing stale for an extension that is not running`, () => {
|
|
82
93
|
// The reason the live set is passed in rather than read off the installed list: `automations` is the
|
|
83
94
|
// extension's query key, so with the extension gone there is no cache entry for it to be about. The core
|
|
84
95
|
// table used to carry these two keys itself, and would have kept invalidating them either way.
|
|
85
|
-
expect(staleQueryKeys([`.intentic/automations.json`, `.intentic/approvals/a1.json`], [])).toEqual([]);
|
|
96
|
+
expect(staleQueryKeys([`.intentic/config/automations.json`, `.intentic/records/approvals/a1.json`], [])).toEqual([]);
|
|
86
97
|
});
|
|
87
98
|
|
|
88
99
|
it(`lets an extension claim a path the core table deliberately ignores`, () => {
|
|
89
100
|
// The two lists are unioned flat, not layered: a narrow extension entry under a broad core entry that
|
|
90
101
|
// invalidates nothing must still fire. Without this, every path beneath one of the daemon's
|
|
91
102
|
// machine-state prefixes would be unreachable to extensions.
|
|
92
|
-
const nested: readonly FileContribution[] = [{ path: `${STATE_DIR}/sessions/claude/projects/p/memory/`, invalidates: [`memory`] }];
|
|
93
|
-
expect(staleQueryKeys([`.intentic/sessions/claude/projects/p/memory/note.md`], nested)).toEqual([`memory`]);
|
|
103
|
+
const nested: readonly FileContribution[] = [{ path: `${STATE_DIR}/records/sessions/claude/projects/p/memory/`, invalidates: [`memory`] }];
|
|
104
|
+
expect(staleQueryKeys([`.intentic/records/sessions/claude/projects/p/memory/note.md`], nested)).toEqual([`memory`]);
|
|
94
105
|
// …and a sibling under the same core prefix stays ignored.
|
|
95
|
-
expect(staleQueryKeys([`.intentic/sessions/claude/projects/p/session.jsonl`], nested)).toEqual([]);
|
|
106
|
+
expect(staleQueryKeys([`.intentic/records/sessions/claude/projects/p/session.jsonl`], nested)).toEqual([]);
|
|
96
107
|
});
|
|
97
108
|
|
|
98
109
|
it(`dedupes a key two extensions both claim`, () => {
|
|
99
110
|
const twice: readonly FileContribution[] = [
|
|
100
|
-
{ path: `${STATE_DIR}/automations.json`, invalidates: [`automations`] },
|
|
101
|
-
{ path: `${STATE_DIR}/automations.json`, invalidates: [`automations`] },
|
|
111
|
+
{ path: `${STATE_DIR}/config/automations.json`, invalidates: [`automations`] },
|
|
112
|
+
{ path: `${STATE_DIR}/config/automations.json`, invalidates: [`automations`] },
|
|
102
113
|
];
|
|
103
|
-
expect(staleQueryKeys([`.intentic/automations.json`], twice)).toEqual([`automations`]);
|
|
114
|
+
expect(staleQueryKeys([`.intentic/config/automations.json`], twice)).toEqual([`automations`]);
|
|
104
115
|
});
|
|
105
116
|
});
|
|
106
117
|
|
|
@@ -113,7 +124,7 @@ describe(`isReportedManifest`, () => {
|
|
|
113
124
|
.map((file) => file.path)
|
|
114
125
|
.toSorted(),
|
|
115
126
|
);
|
|
116
|
-
expect(isReportedManifest(`.intentic/settings.json`)).toBe(true);
|
|
127
|
+
expect(isReportedManifest(`.intentic/config/settings.json`)).toBe(true);
|
|
117
128
|
});
|
|
118
129
|
|
|
119
130
|
it(`stays quiet about daemon-written state the owner cannot repair`, () => {
|
|
@@ -121,13 +132,13 @@ describe(`isReportedManifest`, () => {
|
|
|
121
132
|
// field, so every read reported the whole file ignored — advice ("fix the file") addressed to nobody,
|
|
122
133
|
// about sixty kilobytes of machine JSON, and refreshed by no write because the ledger feeds no query, so
|
|
123
134
|
// it sat on the card until the daemon restarted. A ledger recovers on its own next write instead.
|
|
124
|
-
expect(isReportedManifest(`.intentic/workflow-runs.json`)).toBe(false);
|
|
125
|
-
expect(isReportedManifest(`.intentic/loops.json`)).toBe(false);
|
|
126
|
-
expect(isReportedManifest(`.intentic/thread-sessions.json`)).toBe(false);
|
|
135
|
+
expect(isReportedManifest(`.intentic/records/workflow-runs.json`)).toBe(false);
|
|
136
|
+
expect(isReportedManifest(`.intentic/records/loops.json`)).toBe(false);
|
|
137
|
+
expect(isReportedManifest(`.intentic/records/thread-sessions.json`)).toBe(false);
|
|
127
138
|
});
|
|
128
139
|
|
|
129
140
|
it(`reads a platform path with either separator`, () => {
|
|
130
|
-
expect(isReportedManifest(`.intentic\\settings.json`)).toBe(true);
|
|
141
|
+
expect(isReportedManifest(`.intentic\\config\\settings.json`)).toBe(true);
|
|
131
142
|
});
|
|
132
143
|
|
|
133
144
|
it(`does not report a file outside the workspace`, () => {
|
|
@@ -159,7 +170,7 @@ describe(`WORKSPACE_STATE_FILES`, () => {
|
|
|
159
170
|
});
|
|
160
171
|
|
|
161
172
|
it(`keeps directory entries slash-terminated so they cannot swallow a sibling`, () => {
|
|
162
|
-
// `.intentic/drafts` without the slash would also prefix-match a future `.intentic/drafts-archive.json`.
|
|
173
|
+
// `.intentic/config/drafts` without the slash would also prefix-match a future `.intentic/drafts-archive.json`.
|
|
163
174
|
for (const file of WORKSPACE_STATE_FILES.filter((entry) => entry.invalidates.length > 0)) {
|
|
164
175
|
const isFamilyPrefix = file.path.endsWith(`.`);
|
|
165
176
|
const isFile = file.path.endsWith(`.json`) || file.path.endsWith(`.Dockerfile`);
|
|
@@ -199,19 +210,19 @@ describe(`WORKSPACE_STATE_FILES`, () => {
|
|
|
199
210
|
* padlock on a row and the refusal behind it can no longer disagree. */
|
|
200
211
|
describe(`isLockedWorkspacePath`, () => {
|
|
201
212
|
it(`covers the root state dir's credential entries, and their subtrees whole`, () => {
|
|
202
|
-
expect(isLockedWorkspacePath(`.intentic/capabilities.json`)).toBe(true);
|
|
203
|
-
expect(isLockedWorkspacePath(`.intentic/owner.json`)).toBe(true);
|
|
213
|
+
expect(isLockedWorkspacePath(`.intentic/config/capabilities.json`)).toBe(true);
|
|
214
|
+
expect(isLockedWorkspacePath(`.intentic/identity/owner.json`)).toBe(true);
|
|
204
215
|
// A whole lifecycle root, so a provider added under it is covered without a second edit.
|
|
205
|
-
expect(isLockedWorkspacePath(`.intentic/auth`)).toBe(true);
|
|
206
|
-
expect(isLockedWorkspacePath(`.intentic/auth/codex/auth.json`)).toBe(true);
|
|
207
|
-
expect(isLockedWorkspacePath(`.intentic/browser/Default/Cookies`)).toBe(true);
|
|
216
|
+
expect(isLockedWorkspacePath(`.intentic/secrets/auth`)).toBe(true);
|
|
217
|
+
expect(isLockedWorkspacePath(`.intentic/secrets/auth/codex/auth.json`)).toBe(true);
|
|
218
|
+
expect(isLockedWorkspacePath(`.intentic/local/browser/Default/Cookies`)).toBe(true);
|
|
208
219
|
});
|
|
209
220
|
|
|
210
221
|
it(`leaves the state dir's ordinary manifests alone`, () => {
|
|
211
222
|
// The dir itself is browsable, and most of what is in it is a file a person may legitimately read.
|
|
212
223
|
expect(isLockedWorkspacePath(`.intentic`)).toBe(false);
|
|
213
|
-
expect(isLockedWorkspacePath(`.intentic/settings.json`)).toBe(false);
|
|
214
|
-
expect(isLockedWorkspacePath(`.intentic/drafts/post-1.json`)).toBe(false);
|
|
224
|
+
expect(isLockedWorkspacePath(`.intentic/config/settings.json`)).toBe(false);
|
|
225
|
+
expect(isLockedWorkspacePath(`.intentic/config/drafts/post-1.json`)).toBe(false);
|
|
215
226
|
});
|
|
216
227
|
|
|
217
228
|
it(`locks the ROOT's own .git and nobody else's`, () => {
|
|
@@ -221,12 +232,12 @@ describe(`isLockedWorkspacePath`, () => {
|
|
|
221
232
|
expect(isLockedWorkspacePath(`.git/config`)).toBe(true);
|
|
222
233
|
expect(isLockedWorkspacePath(`myrepo/.git/config`)).toBe(false);
|
|
223
234
|
// …and a repo's own nested state dir is its project's, not the daemon's.
|
|
224
|
-
expect(isLockedWorkspacePath(`myrepo/.intentic/capabilities.json`)).toBe(false);
|
|
235
|
+
expect(isLockedWorkspacePath(`myrepo/.intentic/config/capabilities.json`)).toBe(false);
|
|
225
236
|
});
|
|
226
237
|
|
|
227
238
|
it(`reads a platform path the same as a posix one`, () => {
|
|
228
|
-
expect(isLockedWorkspacePath(`.intentic\\auth\\codex`)).toBe(true);
|
|
229
|
-
expect(isLockedWorkspacePath(`./.intentic/capabilities.json`)).toBe(true);
|
|
239
|
+
expect(isLockedWorkspacePath(`.intentic\\secrets\\auth\\codex`)).toBe(true);
|
|
240
|
+
expect(isLockedWorkspacePath(`./.intentic/config/capabilities.json`)).toBe(true);
|
|
230
241
|
});
|
|
231
242
|
});
|
|
232
243
|
|
|
@@ -234,30 +245,30 @@ describe(`isLockedWorkspacePath`, () => {
|
|
|
234
245
|
* a diff by construction, and refusing to serve it made the Changes panel list a row it could not open. */
|
|
235
246
|
describe(`isReviewableLockedPath`, () => {
|
|
236
247
|
it(`admits the locked entry the root repo tracks, and nothing else locked`, () => {
|
|
237
|
-
expect(isReviewableLockedPath(`.intentic/capabilities.json`)).toBe(true);
|
|
248
|
+
expect(isReviewableLockedPath(`.intentic/config/capabilities.json`)).toBe(true);
|
|
238
249
|
// Every other locked entry is a credential, an identity binding or private runtime state. None is
|
|
239
250
|
// versioned, so none is reachable through a diff — the carve-out cannot widen without the flag.
|
|
240
|
-
expect(isReviewableLockedPath(`.intentic/owner.json`)).toBe(false);
|
|
241
|
-
expect(isReviewableLockedPath(`.intentic/members.json`)).toBe(false);
|
|
242
|
-
expect(isReviewableLockedPath(`.intentic/ci.json`)).toBe(false);
|
|
243
|
-
expect(isReviewableLockedPath(`.intentic/auth/codex/auth.json`)).toBe(false);
|
|
244
|
-
expect(isReviewableLockedPath(`.intentic/sessions/claude/x.jsonl`)).toBe(false);
|
|
245
|
-
expect(isReviewableLockedPath(`.intentic/browser/Default/Cookies`)).toBe(false);
|
|
251
|
+
expect(isReviewableLockedPath(`.intentic/identity/owner.json`)).toBe(false);
|
|
252
|
+
expect(isReviewableLockedPath(`.intentic/identity/members.json`)).toBe(false);
|
|
253
|
+
expect(isReviewableLockedPath(`.intentic/secrets/ci.json`)).toBe(false);
|
|
254
|
+
expect(isReviewableLockedPath(`.intentic/secrets/auth/codex/auth.json`)).toBe(false);
|
|
255
|
+
expect(isReviewableLockedPath(`.intentic/records/sessions/claude/x.jsonl`)).toBe(false);
|
|
256
|
+
expect(isReviewableLockedPath(`.intentic/local/browser/Default/Cookies`)).toBe(false);
|
|
246
257
|
expect(isReviewableLockedPath(`.git/config`)).toBe(false);
|
|
247
258
|
});
|
|
248
259
|
|
|
249
260
|
it(`answers only for the locked set — an ordinary path was never refused to begin with`, () => {
|
|
250
261
|
// Tracked, but not locked: the guards never ask this of them, and a `true` here would read as "this
|
|
251
262
|
// path needed a carve-out", which is a different and wrong statement.
|
|
252
|
-
expect(isReviewableLockedPath(`.intentic/settings.json`)).toBe(false);
|
|
263
|
+
expect(isReviewableLockedPath(`.intentic/config/settings.json`)).toBe(false);
|
|
253
264
|
expect(isReviewableLockedPath(`src/app.ts`)).toBe(false);
|
|
254
265
|
// A repo's own nested state dir is its project's content, exactly as the lock reads it.
|
|
255
|
-
expect(isReviewableLockedPath(`myrepo/.intentic/capabilities.json`)).toBe(false);
|
|
266
|
+
expect(isReviewableLockedPath(`myrepo/.intentic/config/capabilities.json`)).toBe(false);
|
|
256
267
|
});
|
|
257
268
|
|
|
258
269
|
it(`reads a platform path and a dot-relative one the same as a posix one`, () => {
|
|
259
|
-
expect(isReviewableLockedPath(`.intentic\\capabilities.json`)).toBe(true);
|
|
260
|
-
expect(isReviewableLockedPath(`./.intentic/capabilities.json`)).toBe(true);
|
|
270
|
+
expect(isReviewableLockedPath(`.intentic\\config\\capabilities.json`)).toBe(true);
|
|
271
|
+
expect(isReviewableLockedPath(`./.intentic/config/capabilities.json`)).toBe(true);
|
|
261
272
|
});
|
|
262
273
|
|
|
263
274
|
it(`stays a strict subset of the lock`, () => {
|
|
@@ -288,16 +299,16 @@ describe(`VERSIONED_STATE_PATHS`, () => {
|
|
|
288
299
|
* megabytes. Tracking any of them buries the owner's code review under machine noise. */
|
|
289
300
|
it(`leaves the ledgers and the bulk out even though they travel`, () => {
|
|
290
301
|
for (const path of [
|
|
291
|
-
`.intentic/workflow-runs.json`,
|
|
302
|
+
`.intentic/records/workflow-runs.json`,
|
|
292
303
|
// Split out of the tracked automations manifest precisely so a fire stops dirtying it — the one
|
|
293
304
|
// entry here that would be a REGRESSION rather than an oversight if it ever went tracked.
|
|
294
|
-
`.intentic/automation-runs.json`,
|
|
295
|
-
`.intentic/loops.json`,
|
|
296
|
-
`.intentic/thread-sessions.json`,
|
|
297
|
-
`.intentic/extension-usage.json`,
|
|
298
|
-
`.intentic/webchat-installs.json`,
|
|
299
|
-
`.intentic/sessions/claude/`,
|
|
300
|
-
`.intentic/artifacts/`,
|
|
305
|
+
`.intentic/records/automation-runs.json`,
|
|
306
|
+
`.intentic/records/loops.json`,
|
|
307
|
+
`.intentic/records/thread-sessions.json`,
|
|
308
|
+
`.intentic/records/extension-usage.json`,
|
|
309
|
+
`.intentic/records/webchat-installs.json`,
|
|
310
|
+
`.intentic/records/sessions/claude/`,
|
|
311
|
+
`.intentic/records/artifacts/`,
|
|
301
312
|
]) {
|
|
302
313
|
expect([path, VERSIONED_STATE_PATHS.includes(path)]).toEqual([path, false]);
|
|
303
314
|
}
|
|
@@ -307,43 +318,43 @@ describe(`VERSIONED_STATE_PATHS`, () => {
|
|
|
307
318
|
* consequence of editing the table above — the review the flag itself exists to force. */
|
|
308
319
|
it(`tracks exactly the configuration slice plus the agent's own authored output`, () => {
|
|
309
320
|
expect(VERSIONED_STATE_PATHS.toSorted()).toEqual([
|
|
310
|
-
`.intentic/automations.json`,
|
|
321
|
+
`.intentic/config/automations.json`,
|
|
311
322
|
/* The connections themselves, and the entry that took the longest to earn its place — it was classed
|
|
312
323
|
* `secret` on the strength of holding each capability's credential, which stopped being true when the
|
|
313
324
|
* vault took the values out and left the shape behind. Connecting a deployment orchestrator, or
|
|
314
325
|
* granting a connected computer shell and screen control, is the largest change made to what this
|
|
315
326
|
* sandbox can DO, and it used to leave no diff. */
|
|
316
|
-
`.intentic/capabilities.json`,
|
|
317
|
-
`.intentic/capability-dismissals.json`,
|
|
327
|
+
`.intentic/config/capabilities.json`,
|
|
328
|
+
`.intentic/config/capability-dismissals.json`,
|
|
318
329
|
/* The two entries the AGENT authors on its own initiative, and the reason `versioned` is not read as
|
|
319
330
|
* config-only. Both are the sandbox acting outward — a draft publishes words under the owner's name,
|
|
320
331
|
* a workspace extension is code that runs in the app and can serve HTTP with the workspace under
|
|
321
332
|
* node:fs — and both used to reach that far with no diff anywhere. Kept rather than consumed, one
|
|
322
333
|
* small file at a time, so tracking them yields a record instead of churn. */
|
|
323
|
-
`.intentic/drafts/`,
|
|
324
|
-
`.intentic/environment.Dockerfile`,
|
|
325
|
-
`.intentic/environment.custom.Dockerfile`,
|
|
326
|
-
`.intentic/environment.d/`,
|
|
327
|
-
`.intentic/extension-enablement.json`,
|
|
334
|
+
`.intentic/config/drafts/`,
|
|
335
|
+
`.intentic/config/environment.Dockerfile`,
|
|
336
|
+
`.intentic/config/environment.custom.Dockerfile`,
|
|
337
|
+
`.intentic/config/environment.d/`,
|
|
338
|
+
`.intentic/config/extension-enablement.json`,
|
|
328
339
|
/* Its twin, and the pair is the argument: the SWITCH was already tracked while the configuration
|
|
329
340
|
* behind it was not, so a commit could record turning an extension on and say nothing about what it
|
|
330
341
|
* was told to do. Tracked once its declared-secret values moved to the vault. */
|
|
331
|
-
`.intentic/extension-settings.json`,
|
|
342
|
+
`.intentic/config/extension-settings.json`,
|
|
332
343
|
// The owner's per-extension update posture (notify / agent / auto) — a standing decision about
|
|
333
344
|
// what may run unattended, which is exactly the kind of edit worth a line in `git log`.
|
|
334
|
-
`.intentic/extension-update-policy.json`,
|
|
335
|
-
`.intentic/loop-designs.json`,
|
|
336
|
-
`.intentic/personas.json`,
|
|
345
|
+
`.intentic/config/extension-update-policy.json`,
|
|
346
|
+
`.intentic/config/loop-designs.json`,
|
|
347
|
+
`.intentic/config/personas.json`,
|
|
337
348
|
// A persona's own kit — the prompt it runs on and the skills only its turns reach. Tracked for the
|
|
338
349
|
// reason its card is, one step further: this is the text that decides how that persona behaves.
|
|
339
|
-
`.intentic/personas/`,
|
|
340
|
-
`.intentic/settings.json`,
|
|
350
|
+
`.intentic/config/personas/`,
|
|
351
|
+
`.intentic/config/settings.json`,
|
|
341
352
|
// The skills the owner wrote. Tracked for the reason the rules in settings.json are: text that
|
|
342
353
|
// changes how the agent behaves is worth a diff and a line in `git log`.
|
|
343
|
-
`.intentic/skills/`,
|
|
344
|
-
`.intentic/templates.json`,
|
|
345
|
-
`.intentic/workflows.json`,
|
|
346
|
-
`.intentic/workspace-extensions/`,
|
|
354
|
+
`.intentic/config/skills/`,
|
|
355
|
+
`.intentic/config/templates.json`,
|
|
356
|
+
`.intentic/config/workflows.json`,
|
|
357
|
+
`.intentic/config/workspace-extensions/`,
|
|
347
358
|
]);
|
|
348
359
|
});
|
|
349
360
|
|
|
@@ -351,8 +362,8 @@ describe(`VERSIONED_STATE_PATHS`, () => {
|
|
|
351
362
|
* the switch was tracked and the thing it switched was not, so a commit could record turning on an extension
|
|
352
363
|
* whose code nobody else could read — and a workspace extension has no install moment to review at instead. */
|
|
353
364
|
it(`tracks a workspace extension's code, not just the switch that enables it`, () => {
|
|
354
|
-
expect(VERSIONED_STATE_PATHS).toContain(`.intentic/extension-enablement.json`);
|
|
355
|
-
expect(VERSIONED_STATE_PATHS).toContain(`.intentic/workspace-extensions/`);
|
|
365
|
+
expect(VERSIONED_STATE_PATHS).toContain(`.intentic/config/extension-enablement.json`);
|
|
366
|
+
expect(VERSIONED_STATE_PATHS).toContain(`.intentic/config/workspace-extensions/`);
|
|
356
367
|
});
|
|
357
368
|
|
|
358
369
|
/* A QUEUE IS A LEDGER, which is the distinction the two entries above turn on. A draft is kept after it
|
|
@@ -361,13 +372,13 @@ describe(`VERSIONED_STATE_PATHS`, () => {
|
|
|
361
372
|
* a decision whose outcome lives elsewhere. Same for the staged docs: publishing copies them into the repo,
|
|
362
373
|
* where they are tracked as ordinary content, so tracking the staging tree too would double every page. */
|
|
363
374
|
it(`leaves the consumed queues and the staging trees out even though they are authored`, () => {
|
|
364
|
-
for (const path of [`.intentic/approvals/`, `.intentic/docs/`]) {
|
|
375
|
+
for (const path of [`.intentic/records/approvals/`, `.intentic/config/docs/`]) {
|
|
365
376
|
expect([path, VERSIONED_STATE_PATHS.includes(path)]).toEqual([path, false]);
|
|
366
377
|
}
|
|
367
378
|
// Both still reach workspace search — searchability is a property of the content, not of tracking.
|
|
368
|
-
for (const path of [`.intentic/approvals/`, `.intentic/docs/`]) {
|
|
379
|
+
for (const path of [`.intentic/records/approvals/`, `.intentic/config/docs/`]) {
|
|
369
380
|
const entry = WORKSPACE_STATE_FILES.find((file) => file.path === path);
|
|
370
|
-
expect([path, entry?.versioned === true || entry?.authored === true]).toEqual([path, path === `.intentic/docs/`]);
|
|
381
|
+
expect([path, entry?.versioned === true || entry?.authored === true]).toEqual([path, path === `.intentic/config/docs/`]);
|
|
371
382
|
}
|
|
372
383
|
});
|
|
373
384
|
|
|
@@ -375,7 +386,143 @@ describe(`VERSIONED_STATE_PATHS`, () => {
|
|
|
375
386
|
* whatever base image this container happens to be on. Tracking it would put a rewritten-at-startup file in
|
|
376
387
|
* front of the owner as a change they made. */
|
|
377
388
|
it(`tracks the environment overlay's source but not its composed output`, () => {
|
|
378
|
-
expect(VERSIONED_STATE_PATHS).toContain(`.intentic/environment.custom.Dockerfile`);
|
|
379
|
-
expect(VERSIONED_STATE_PATHS).not.toContain(`.intentic/environment.approved.Dockerfile`);
|
|
389
|
+
expect(VERSIONED_STATE_PATHS).toContain(`.intentic/config/environment.custom.Dockerfile`);
|
|
390
|
+
expect(VERSIONED_STATE_PATHS).not.toContain(`.intentic/local/environment.approved.Dockerfile`);
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
/* THE GROUPING, AND THE INVARIANT THAT LETS IT BE DERIVED.
|
|
395
|
+
*
|
|
396
|
+
* `stateGroupOf` reads three existing answers instead of adding a fourth, which is only sound while those answers
|
|
397
|
+
* nest: reviewed and authored entries must all be `carry`, so "did a person write this" never has to be asked of
|
|
398
|
+
* a credential. Nothing in the type system says so — `versioned: true` on a `secret` entry compiles — and the
|
|
399
|
+
* failure would be quiet in the worst way: the entry would group as `secrets` (correctly kept out of the backup)
|
|
400
|
+
* while the git exclude and the search floor, which read `versioned` directly, went on tracking and indexing it.
|
|
401
|
+
* A credential in the owner's diff and in the search index, from one plausible-looking flag. */
|
|
402
|
+
describe(`state groups`, () => {
|
|
403
|
+
const GROUPS: readonly StateGroup[] = [`config`, `records`, `local`, `identity`, `secrets`];
|
|
404
|
+
|
|
405
|
+
it(`puts every entry in exactly one group, and the groups add back up to the table`, () => {
|
|
406
|
+
const grouped = GROUPS.flatMap((group) => stateGroupPaths(group));
|
|
407
|
+
expect(grouped.toSorted()).toEqual(WORKSPACE_STATE_FILES.map((file) => file.path).toSorted());
|
|
408
|
+
expect(new Set(grouped).size).toBe(WORKSPACE_STATE_FILES.length);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it(`keeps everything a person writes inside the class that travels`, () => {
|
|
412
|
+
for (const file of WORKSPACE_STATE_FILES) {
|
|
413
|
+
if (file.versioned === true || file.authored === true) {
|
|
414
|
+
expect(file.portability, `${file.path} is authored or reviewed, so it must be carry`).toBe(`carry`);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it(`agrees with the two lists that were derived before it`, () => {
|
|
420
|
+
// The git exclude reads `versioned` and the search floor reads `versioned || authored`. Both must land
|
|
421
|
+
// inside `config`, or the folder a rule points at stops matching the rule.
|
|
422
|
+
const written = stateGroupPaths(`config`);
|
|
423
|
+
for (const path of VERSIONED_STATE_PATHS) {
|
|
424
|
+
expect([path, written.includes(path)]).toEqual([path, true]);
|
|
425
|
+
}
|
|
426
|
+
expect(SEARCHABLE_STATE_PATHS.toSorted()).toEqual(written.toSorted());
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it(`sorts the entries a browsing owner would recognise`, () => {
|
|
430
|
+
expect(stateGroupPaths(`config`)).toContain(`.intentic/config/personas/`);
|
|
431
|
+
expect(stateGroupPaths(`config`)).toContain(`.intentic/config/drafts/`);
|
|
432
|
+
expect(stateGroupPaths(`records`)).toContain(`.intentic/records/sessions/claude/`);
|
|
433
|
+
expect(stateGroupPaths(`records`)).toContain(`.intentic/records/artifacts/`);
|
|
434
|
+
expect(stateGroupPaths(`local`)).toContain(`.intentic/local/cache/`);
|
|
435
|
+
expect(stateGroupPaths(`local`)).toContain(`.intentic/local/browser/`);
|
|
436
|
+
expect(stateGroupPaths(`identity`)).toContain(`.intentic/identity/owner.json`);
|
|
437
|
+
expect(stateGroupPaths(`secrets`)).toContain(`.intentic/secrets/auth/`);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
/* THE LAYOUT GUARD, and the reason the folders can carry the rules at all.
|
|
441
|
+
*
|
|
442
|
+
* Five prefixes replaced five hand-kept path lists — the git exclude, the search allow-list, the sync backup,
|
|
443
|
+
* the watcher skip, the export bundle — and every one of them is now only as true as the claim that an entry
|
|
444
|
+
* physically SITS in the folder its class puts it in. Nothing else checks that: the paths are literals, and a
|
|
445
|
+
* credential typed into `config/` by mistake would compile, track in git, index in search and copy to the
|
|
446
|
+
* owner's laptop, with each of those rules behaving exactly as designed. This is the one assertion standing
|
|
447
|
+
* between a mistyped literal and that. */
|
|
448
|
+
it(`puts every entry physically inside its own group's folder`, () => {
|
|
449
|
+
for (const file of WORKSPACE_STATE_FILES) {
|
|
450
|
+
const expected = `${STATE_GROUP_DIR[stateGroupOf(file)]}/`;
|
|
451
|
+
expect(file.path.startsWith(expected), `${file.path} should sit under ${expected}`).toBe(true);
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
/* The browser profiles' path is copied into @intentic/workspace-ignore (isBrowserProfilePath), which cannot
|
|
456
|
+
* import this package — it is the browser-safe half, deliberately free of zod and the contract surface, so
|
|
457
|
+
* the platform's web bundle can take it. This is the guard that keeps the copy honest. If it fails, the
|
|
458
|
+
* profiles moved and `BROWSER_PROFILE_GROUP` in _sandbox/workspace-ignore/src/constants.ts must move too, or
|
|
459
|
+
* the tree will start eagerly walking a Chromium user-data dir and the watcher will report its churn. */
|
|
460
|
+
it(`keeps the browser profiles where workspace-ignore's copy of the path expects them`, () => {
|
|
461
|
+
expect(WORKSPACE_STATE_FILES.map((file) => file.path)).toContain(`.intentic/local/browser/`);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it(`gives each group a folder of its own, all directly under the state dir`, () => {
|
|
465
|
+
const dirs = GROUPS.map((group) => STATE_GROUP_DIR[group]);
|
|
466
|
+
expect(new Set(dirs).size).toBe(GROUPS.length);
|
|
467
|
+
for (const dir of dirs) {
|
|
468
|
+
expect(dir.startsWith(`${STATE_DIR}/`)).toBe(true);
|
|
469
|
+
expect(dir.slice(`${STATE_DIR}/`.length).includes(`/`)).toBe(false);
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it(`reads the group off the class, not off the name`, () => {
|
|
474
|
+
const entry = (path: string) => WORKSPACE_STATE_FILES.find((file) => file.path === path)!;
|
|
475
|
+
expect(stateGroupOf(entry(`.intentic/config/settings.json`))).toBe(`config`);
|
|
476
|
+
expect(stateGroupOf(entry(`.intentic/records/loops.json`))).toBe(`records`);
|
|
477
|
+
expect(stateGroupOf(entry(`.intentic/local/tmp/`))).toBe(`local`);
|
|
478
|
+
expect(stateGroupOf(entry(`.intentic/identity/members.json`))).toBe(`identity`);
|
|
479
|
+
expect(stateGroupOf(entry(`.intentic/secrets/ci.json`))).toBe(`secrets`);
|
|
480
|
+
});
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
/* WHAT THE OWNER'S MACHINE KEEPS. The sync ignored the whole state dir, so losing a sandbox lost every persona,
|
|
484
|
+
* skill, automation, draft and transcript in it — a backup that held only the source tree. These pin the two
|
|
485
|
+
* halves of the fix: the slice that now comes down, and the credentials that still must not. */
|
|
486
|
+
describe(`BACKED_UP_STATE_PATHS`, () => {
|
|
487
|
+
it(`splits the table in two with nothing falling between`, () => {
|
|
488
|
+
expect([...BACKED_UP_STATE_PATHS, ...UNBACKED_STATE_PATHS].toSorted()).toEqual(WORKSPACE_STATE_FILES.map((file) => file.path).toSorted());
|
|
489
|
+
for (const path of BACKED_UP_STATE_PATHS) {
|
|
490
|
+
expect([path, UNBACKED_STATE_PATHS.includes(path)]).toEqual([path, false]);
|
|
491
|
+
}
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it(`copies down everything a person wrote and everything that happened`, () => {
|
|
495
|
+
for (const path of [...stateGroupPaths(`config`), ...stateGroupPaths(`records`)]) {
|
|
496
|
+
expect([path, BACKED_UP_STATE_PATHS.includes(path)]).toEqual([path, true]);
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it(`never copies a credential, whatever else the entry is`, () => {
|
|
501
|
+
for (const path of stateGroupPaths(`secrets`)) {
|
|
502
|
+
expect([path, BACKED_UP_STATE_PATHS.includes(path)]).toEqual([path, false]);
|
|
503
|
+
}
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
it(`leaves the rebuildable bulk behind — it is size, not secrecy`, () => {
|
|
507
|
+
for (const path of stateGroupPaths(`local`)) {
|
|
508
|
+
expect([path, BACKED_UP_STATE_PATHS.includes(path)]).toEqual([path, false]);
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
/* The distinction `portability` alone cannot draw, and the reason the `backup` flag exists. Ownership records
|
|
513
|
+
* may never RESTORE into another sandbox — that is what `identity` means, and members.json's own entry argues
|
|
514
|
+
* why. None of that stops the owner keeping a copy of who could drive their own machine. */
|
|
515
|
+
it(`copies the ownership records that may never travel`, () => {
|
|
516
|
+
for (const path of [`.intentic/identity/owner.json`, `.intentic/identity/members.json`, `.intentic/identity/workspace.json`]) {
|
|
517
|
+
expect([path, BACKED_UP_STATE_PATHS.includes(path)]).toEqual([path, true]);
|
|
518
|
+
}
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
it(`still withholds the tokens that authenticate against this sandbox`, () => {
|
|
522
|
+
expect(BACKED_UP_STATE_PATHS).not.toContain(`.intentic/identity/control-tokens.json`);
|
|
523
|
+
// And it is the ONLY entry that opts out by hand — everything else follows from its class.
|
|
524
|
+
expect(WORKSPACE_STATE_FILES.filter((file) => file.backup === false).map((file) => file.path)).toEqual([
|
|
525
|
+
`.intentic/identity/control-tokens.json`,
|
|
526
|
+
]);
|
|
380
527
|
});
|
|
381
528
|
});
|