@north-light/crouter 0.3.174 → 0.3.176
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/api/dto/nodes.d.ts +7 -1
- package/dist/api/dto/profiles.d.ts +3 -0
- package/dist/clients/attach/render/crtr-output.d.ts +3 -3
- package/dist/clients/attach/render/crtr-output.js +25 -13
- package/dist/clients/attach/viewer.js +531 -561
- package/dist/commands/memory/write.js +13 -12
- package/dist/commands/node/create.js +7 -3
- package/dist/commands/profile/list.js +3 -1
- package/dist/commands/profile/new.js +4 -1
- package/dist/commands/profile/project.js +23 -9
- package/dist/commands/profile/show.js +3 -1
- package/dist/commands/profile.js +2 -2
- package/dist/commands/sys/logs.js +4 -2
- package/dist/core/__tests__/branded-host-concurrency.test.js +1 -1
- package/dist/core/__tests__/on-read-crouter-home-fence.test.js +1 -1
- package/dist/core/__tests__/on-read-dedup-resume.test.js +1 -1
- package/dist/core/canvas/extensions.d.ts +1 -1
- package/dist/core/canvas/extensions.js +7 -1
- package/dist/core/command-plugins/transport/http-invoke.js +10 -2
- package/dist/core/command.js +3 -1
- package/dist/core/config.js +4 -27
- package/dist/core/exclusive-lock.d.ts +14 -0
- package/dist/core/exclusive-lock.js +124 -0
- package/dist/core/human/claim.js +2 -1
- package/dist/core/human/convention.d.ts +0 -10
- package/dist/core/human/convention.js +1 -123
- package/dist/core/io.d.ts +23 -0
- package/dist/core/io.js +60 -0
- package/dist/core/preview-registry.d.ts +10 -5
- package/dist/core/preview-registry.js +136 -2537
- package/dist/core/profiles/manifest.d.ts +12 -1
- package/dist/core/profiles/manifest.js +57 -48
- package/dist/core/review/store.d.ts +4 -1
- package/dist/core/review/store.js +10 -0
- package/dist/core/runtime/branded-host.js +6 -84
- package/dist/core/runtime/broker-extension-render.js +1 -1
- package/dist/core/runtime/canvas-extensions.d.ts +1 -0
- package/dist/core/runtime/canvas-extensions.js +2 -0
- package/dist/core/runtime/tmux-bindings.js +1 -1
- package/dist/core/runtime/warm-pool.d.ts +9 -8
- package/dist/core/runtime/warm-pool.js +58 -62
- package/dist/core/substrate/index.d.ts +2 -1
- package/dist/core/substrate/index.js +2 -1
- package/dist/core/substrate/on-read-node.d.ts +7 -0
- package/dist/core/substrate/on-read-node.js +37 -0
- package/dist/core/substrate/on-read.d.ts +7 -5
- package/dist/core/substrate/on-read.js +15 -23
- package/dist/core/termrender/termrender.js +16 -96
- package/dist/core/worktree.js +11 -100
- package/dist/daemon/api/handlers/nodes.js +32 -9
- package/dist/daemon/api/handlers/review-comments.js +5 -44
- package/dist/daemon/api/handlers/reviews.js +5 -53
- package/dist/daemon/api/handlers/validate.d.ts +22 -0
- package/dist/daemon/api/handlers/validate.js +59 -0
- package/dist/daemon/api/map.js +1 -0
- package/dist/daemon/review/finish.js +1 -10
- package/dist/pi-extensions/canvas-doc-substrate.js +1 -1
- package/dist/pi-extensions/canvas-preview-result.d.ts +22 -0
- package/dist/pi-extensions/canvas-preview-result.js +60 -0
- package/dist/types.d.ts +4 -0
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
- package/dist/core/substrate/on-read-pure.d.ts +0 -7
- package/dist/core/substrate/on-read-pure.js +0 -272
|
@@ -4,6 +4,15 @@ export declare function profilesRoot(): string;
|
|
|
4
4
|
export declare function profileRoot(profileId: string): string;
|
|
5
5
|
export declare function profileManifestPath(profileId: string): string;
|
|
6
6
|
export declare function profileMemoryDir(profileId: string): string;
|
|
7
|
+
/** The directory a node running under `profileId` is pinned to — the profile's
|
|
8
|
+
* home, but only while it still exists on disk (a node pinned to a deleted dir
|
|
9
|
+
* cannot boot). Null for the root profile, a profile with no projects, a home
|
|
10
|
+
* that has since been deleted, and an unresolvable id.
|
|
11
|
+
*
|
|
12
|
+
* This is the whole of the profile→cwd direction: cwd SELECTS a profile at the
|
|
13
|
+
* front door, and the selected profile then SUPPLIES the node's cwd. Never
|
|
14
|
+
* throws — it sits on the node-create path. */
|
|
15
|
+
export declare function profileHome(profileId: string | null): string | null;
|
|
7
16
|
export interface ProfileEntry {
|
|
8
17
|
profileId: string;
|
|
9
18
|
manifest: ProfileManifest;
|
|
@@ -30,6 +39,8 @@ export declare function ensureRootProfile(): ProfileEntry;
|
|
|
30
39
|
export declare function createProfile(name: string, projects?: string[]): ProfileEntry;
|
|
31
40
|
export declare function updateProfileLastUsed(profileId: string): ProfileEntry;
|
|
32
41
|
export declare function renameProfile(profileId: string, name: string): ProfileEntry;
|
|
33
|
-
export declare function addProfileProject(profileId: string, dir: string
|
|
42
|
+
export declare function addProfileProject(profileId: string, dir: string, opts?: {
|
|
43
|
+
home?: boolean;
|
|
44
|
+
}): ProfileEntry;
|
|
34
45
|
export declare function removeProfileProject(profileId: string, dir: string): ProfileEntry;
|
|
35
46
|
export declare function deleteProfile(profileId: string): void;
|
|
@@ -6,12 +6,13 @@
|
|
|
6
6
|
// here, so no caller ever passes a raw CLI operand or `CRTR_PROFILE_ID`
|
|
7
7
|
// straight into `join(profilesRoot(), value)`.
|
|
8
8
|
import { randomUUID } from 'node:crypto';
|
|
9
|
-
import { existsSync,
|
|
9
|
+
import { existsSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
|
|
10
10
|
import { dirname, join, relative, resolve as resolvePath, sep } from 'node:path';
|
|
11
11
|
import { SCHEMA_VERSION, PROFILE_DIR } from '../../types.js';
|
|
12
12
|
import { userScopeRoot, resetScopeCache } from '../scope.js';
|
|
13
13
|
import { ensureDir, nowIso } from '../fs-utils.js';
|
|
14
14
|
import { usage, notFound, ambiguous, general } from '../errors.js';
|
|
15
|
+
import { withExclusiveDirectoryLock } from '../exclusive-lock.js';
|
|
15
16
|
// ---------------------------------------------------------------------------
|
|
16
17
|
// Path safety — the only code in the tree that builds a path under the
|
|
17
18
|
// profiles root. Every other module reaches profile paths through this file.
|
|
@@ -73,11 +74,47 @@ export function profileMemoryDir(profileId) {
|
|
|
73
74
|
// a profile id. Both walk immediate children of the canonical root; neither
|
|
74
75
|
// ever joins an unvalidated operand into a path.
|
|
75
76
|
// ---------------------------------------------------------------------------
|
|
76
|
-
|
|
77
|
+
/** A profile's `home` DEFAULTS to its first project dir. Normalizing here (the
|
|
78
|
+
* single read path) rather than at write time means a manifest written before
|
|
79
|
+
* homes existed, and one whose explicit home was later removed from purview,
|
|
80
|
+
* both still answer with a usable home — no migration pass.
|
|
81
|
+
*
|
|
82
|
+
* The root profile is the exception and never takes the default: it is the
|
|
83
|
+
* identity a directory nothing covers falls back to, so its nodes belong where
|
|
84
|
+
* the user started them, not in whatever dir was last added to its purview. */
|
|
85
|
+
function normalizeHome(profileId, manifest) {
|
|
86
|
+
const stored = typeof manifest.home === 'string' && manifest.home !== '' ? manifest.home : null;
|
|
87
|
+
if (profileId === ROOT_PROFILE_ID)
|
|
88
|
+
return { ...manifest, home: stored };
|
|
89
|
+
return { ...manifest, home: stored ?? manifest.projects[0] ?? null };
|
|
90
|
+
}
|
|
91
|
+
function readManifestFile(path, profileId) {
|
|
77
92
|
if (!existsSync(path))
|
|
78
93
|
return null;
|
|
79
94
|
try {
|
|
80
|
-
return JSON.parse(readFileSync(path, 'utf8'));
|
|
95
|
+
return normalizeHome(profileId, JSON.parse(readFileSync(path, 'utf8')));
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** The directory a node running under `profileId` is pinned to — the profile's
|
|
102
|
+
* home, but only while it still exists on disk (a node pinned to a deleted dir
|
|
103
|
+
* cannot boot). Null for the root profile, a profile with no projects, a home
|
|
104
|
+
* that has since been deleted, and an unresolvable id.
|
|
105
|
+
*
|
|
106
|
+
* This is the whole of the profile→cwd direction: cwd SELECTS a profile at the
|
|
107
|
+
* front door, and the selected profile then SUPPLIES the node's cwd. Never
|
|
108
|
+
* throws — it sits on the node-create path. */
|
|
109
|
+
export function profileHome(profileId) {
|
|
110
|
+
if (profileId === null || profileId === '')
|
|
111
|
+
return null;
|
|
112
|
+
try {
|
|
113
|
+
const { manifest } = loadProfileManifest(profileId);
|
|
114
|
+
const home = manifest.home;
|
|
115
|
+
if (home === null)
|
|
116
|
+
return null;
|
|
117
|
+
return existsSync(home) && statSync(home).isDirectory() ? home : null;
|
|
81
118
|
}
|
|
82
119
|
catch {
|
|
83
120
|
return null;
|
|
@@ -99,7 +136,7 @@ export function listProfiles() {
|
|
|
99
136
|
const profileId = dirent.name;
|
|
100
137
|
if (!ID_SHAPE.test(profileId))
|
|
101
138
|
continue;
|
|
102
|
-
const manifest = readManifestFile(join(root, profileId, 'profile.json'));
|
|
139
|
+
const manifest = readManifestFile(join(root, profileId, 'profile.json'), profileId);
|
|
103
140
|
if (manifest === null)
|
|
104
141
|
continue;
|
|
105
142
|
out.push({ profileId, manifest });
|
|
@@ -142,55 +179,20 @@ export function loadProfileManifest(profileIdOrName) {
|
|
|
142
179
|
: 'No profiles exist on this host yet — create one with `crtr profile new --name <name>`.',
|
|
143
180
|
});
|
|
144
181
|
}
|
|
145
|
-
// ---------------------------------------------------------------------------
|
|
146
|
-
// Locking — a plain mkdir-based mutex under `profilesRoot()/.locks/`. mkdir is
|
|
147
|
-
// atomic (EEXIST when already held), so this needs no extra dependency. The
|
|
148
|
-
// blocking wait uses Atomics.wait on a throwaway SharedArrayBuffer to stay
|
|
149
|
-
// synchronous, matching every other sync fs call in this module.
|
|
150
|
-
// ---------------------------------------------------------------------------
|
|
151
|
-
const LOCK_TIMEOUT_MS = 5000;
|
|
152
|
-
const LOCK_POLL_MS = 25;
|
|
153
182
|
function locksDir() {
|
|
154
183
|
return join(profilesRoot(), '.locks');
|
|
155
184
|
}
|
|
156
|
-
function sleepSync(ms) {
|
|
157
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
158
|
-
}
|
|
159
185
|
/** Hold the per-profile manifest lock for the duration of `fn`. ALL manifest
|
|
160
186
|
* mutations (create/rename/add-project/remove-project/delete/last-used)
|
|
161
187
|
* below run inside this. */
|
|
162
188
|
export function withProfileManifestLock(profileId, fn) {
|
|
163
189
|
assertSafeProfileId(profileId);
|
|
164
190
|
ensureDir(locksDir());
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
break;
|
|
171
|
-
}
|
|
172
|
-
catch (err) {
|
|
173
|
-
if (err.code !== 'EEXIST')
|
|
174
|
-
throw err;
|
|
175
|
-
if (Date.now() > deadline) {
|
|
176
|
-
throw general(`timed out waiting for the profile lock: ${profileId}`, {
|
|
177
|
-
next: 'Retry — another `crtr profile` command may be running concurrently on this profile.',
|
|
178
|
-
});
|
|
179
|
-
}
|
|
180
|
-
sleepSync(LOCK_POLL_MS);
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
try {
|
|
184
|
-
return fn();
|
|
185
|
-
}
|
|
186
|
-
finally {
|
|
187
|
-
try {
|
|
188
|
-
rmSync(lockPath, { recursive: true, force: true });
|
|
189
|
-
}
|
|
190
|
-
catch {
|
|
191
|
-
/* best-effort release */
|
|
192
|
-
}
|
|
193
|
-
}
|
|
191
|
+
return withExclusiveDirectoryLock(join(locksDir(), `${profileId}.lock`), fn, {
|
|
192
|
+
timeoutError: () => general(`timed out waiting for the profile lock: ${profileId}`, {
|
|
193
|
+
next: 'Retry — another `crtr profile` command may be running concurrently on this profile.',
|
|
194
|
+
}),
|
|
195
|
+
});
|
|
194
196
|
}
|
|
195
197
|
function writeManifestAtomic(path, manifest) {
|
|
196
198
|
ensureDir(dirname(path));
|
|
@@ -254,7 +256,7 @@ export function ensureRootProfile() {
|
|
|
254
256
|
return withProfileManifestLock(ROOT_PROFILE_ID, () => {
|
|
255
257
|
const manifestPath = profileManifestPath(ROOT_PROFILE_ID);
|
|
256
258
|
const manifestExists = existsSync(manifestPath);
|
|
257
|
-
const manifest = manifestExists ? readManifestFile(manifestPath) : null;
|
|
259
|
+
const manifest = manifestExists ? readManifestFile(manifestPath, ROOT_PROFILE_ID) : null;
|
|
258
260
|
if (manifestExists && manifest === null) {
|
|
259
261
|
throw general(`root profile manifest is unreadable: ${manifestPath}`);
|
|
260
262
|
}
|
|
@@ -267,6 +269,7 @@ export function ensureRootProfile() {
|
|
|
267
269
|
schema_version: SCHEMA_VERSION,
|
|
268
270
|
name: ROOT_PROFILE_NAME,
|
|
269
271
|
projects: [],
|
|
272
|
+
home: null,
|
|
270
273
|
created_at: nowIso(),
|
|
271
274
|
last_used_at: nowIso(),
|
|
272
275
|
};
|
|
@@ -286,6 +289,7 @@ export function createProfile(name, projects = []) {
|
|
|
286
289
|
schema_version: SCHEMA_VERSION,
|
|
287
290
|
name: trimmed,
|
|
288
291
|
projects: resolvedProjects,
|
|
292
|
+
home: resolvedProjects[0] ?? null,
|
|
289
293
|
created_at: nowIso(),
|
|
290
294
|
last_used_at: null,
|
|
291
295
|
};
|
|
@@ -307,7 +311,7 @@ function mutateManifest(profileId, mutate) {
|
|
|
307
311
|
requireExistingProfile(profileId);
|
|
308
312
|
return withProfileManifestLock(profileId, () => {
|
|
309
313
|
const path = profileManifestPath(profileId);
|
|
310
|
-
const current = readManifestFile(path);
|
|
314
|
+
const current = readManifestFile(path, profileId);
|
|
311
315
|
if (current === null) {
|
|
312
316
|
throw notFound(`profile not found: ${profileId}`, { received: profileId });
|
|
313
317
|
}
|
|
@@ -327,11 +331,12 @@ export function renameProfile(profileId, name) {
|
|
|
327
331
|
resetScopeCache();
|
|
328
332
|
return result;
|
|
329
333
|
}
|
|
330
|
-
export function addProfileProject(profileId, dir) {
|
|
334
|
+
export function addProfileProject(profileId, dir, opts = {}) {
|
|
331
335
|
const resolved = resolveExistingProjectDir(dir);
|
|
332
336
|
const result = mutateManifest(profileId, (m) => ({
|
|
333
337
|
...m,
|
|
334
338
|
projects: dedupeOrdered([...m.projects, resolved]),
|
|
339
|
+
...(opts.home === true ? { home: resolved } : {}),
|
|
335
340
|
}));
|
|
336
341
|
resetScopeCache();
|
|
337
342
|
return result;
|
|
@@ -351,13 +356,17 @@ export function removeProfileProject(profileId, dir) {
|
|
|
351
356
|
}
|
|
352
357
|
const result = mutateManifest(profileId, (m) => {
|
|
353
358
|
const filtered = m.projects.filter((p) => p !== real && p !== abs);
|
|
359
|
+
// Dropping the home dir from purview falls the home back to the first
|
|
360
|
+
// remaining project, exactly as `normalizeHome` would for a manifest that
|
|
361
|
+
// never named one — never leaves the profile pointing outside its purview.
|
|
362
|
+
const home = m.home === real || m.home === abs ? filtered[0] ?? null : m.home;
|
|
354
363
|
if (filtered.length === m.projects.length) {
|
|
355
364
|
throw notFound(`project not found on profile: ${dir}`, {
|
|
356
365
|
received: dir,
|
|
357
366
|
next: `Current projects: ${m.projects.length > 0 ? m.projects.join(', ') : '(none)'}.`,
|
|
358
367
|
});
|
|
359
368
|
}
|
|
360
|
-
return { ...m, projects: filtered };
|
|
369
|
+
return { ...m, projects: filtered, home };
|
|
361
370
|
});
|
|
362
371
|
resetScopeCache();
|
|
363
372
|
return result;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { DatabaseSync } from 'node:sqlite';
|
|
2
|
-
import type
|
|
2
|
+
import { type NewReviewRow, type ReviewRecord, type ReviewState } from './types.js';
|
|
3
3
|
/** Fields written when an open review is conditionally approved. */
|
|
4
4
|
export interface ApproveReviewArgs {
|
|
5
5
|
review_id: string;
|
|
@@ -17,6 +17,9 @@ export declare function commitBindingReview(row: NewReviewRow): {
|
|
|
17
17
|
won: boolean;
|
|
18
18
|
};
|
|
19
19
|
export declare function getReview(reviewId: string): ReviewRecord | null;
|
|
20
|
+
/** The review `reviewId` names, for a caller that cannot proceed without one.
|
|
21
|
+
* A `binding` row is still mid-creation and reads as absent. */
|
|
22
|
+
export declare function requireVisibleReview(reviewId: string): ReviewRecord;
|
|
20
23
|
export declare function getReviewByIdempotencyKey(key: string): ReviewRecord | null;
|
|
21
24
|
export declare function getReviewByCompanion(nodeId: string): ReviewRecord | null;
|
|
22
25
|
export declare function getReviewByBridge(nodeId: string): ReviewRecord | null;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { newNodeId } from '../runtime/nodes.js';
|
|
3
3
|
import { openDb, withCanvasWrite } from '../canvas/db.js';
|
|
4
|
+
import { ReviewOperationError, } from './types.js';
|
|
4
5
|
function recordFromRow(raw) {
|
|
5
6
|
return {
|
|
6
7
|
review_id: raw['review_id'],
|
|
@@ -79,6 +80,15 @@ export function commitBindingReview(row) {
|
|
|
79
80
|
export function getReview(reviewId) {
|
|
80
81
|
return readRecord(openDb(), 'review_id = ?', reviewId);
|
|
81
82
|
}
|
|
83
|
+
/** The review `reviewId` names, for a caller that cannot proceed without one.
|
|
84
|
+
* A `binding` row is still mid-creation and reads as absent. */
|
|
85
|
+
export function requireVisibleReview(reviewId) {
|
|
86
|
+
const review = getReview(reviewId);
|
|
87
|
+
if (review === null || review.state === 'binding') {
|
|
88
|
+
throw new ReviewOperationError('unknown_review', 'review was not found', { received: reviewId });
|
|
89
|
+
}
|
|
90
|
+
return review;
|
|
91
|
+
}
|
|
82
92
|
export function getReviewByIdempotencyKey(key) {
|
|
83
93
|
return readRecord(openDb(), 'idempotency_key = ?', key);
|
|
84
94
|
}
|
|
@@ -21,12 +21,13 @@
|
|
|
21
21
|
// (a `brew upgrade node` moves realpath(execPath) to a new Cellar version) or the
|
|
22
22
|
// copy is missing. The drift check is a cheap marker compare — no subprocess, no
|
|
23
23
|
// copy — on the happy path.
|
|
24
|
-
import { copyFileSync, mkdirSync, existsSync, readFileSync, writeFileSync, chmodSync, readdirSync, renameSync, realpathSync, unlinkSync, rmSync,
|
|
24
|
+
import { copyFileSync, mkdirSync, existsSync, readFileSync, writeFileSync, chmodSync, readdirSync, renameSync, realpathSync, unlinkSync, rmSync, linkSync, } from 'node:fs';
|
|
25
25
|
import { homedir } from 'node:os';
|
|
26
26
|
import { dirname, join } from 'node:path';
|
|
27
27
|
import { spawnSync } from 'node:child_process';
|
|
28
28
|
import { CRTR_DIR_NAME } from '../../types.js';
|
|
29
29
|
import { emitEvent } from '../events/emit.js';
|
|
30
|
+
import { withExclusiveDirectoryLock } from '../exclusive-lock.js';
|
|
30
31
|
/** The filename the FDA / TCC entry is shown under. */
|
|
31
32
|
const BRANDED_BIN_NAME = 'crouter';
|
|
32
33
|
/** Machine-global host dir. Deliberately NOT under `CRTR_HOME` (tests override
|
|
@@ -75,95 +76,16 @@ function readMarker(path = markerPath()) {
|
|
|
75
76
|
}
|
|
76
77
|
}
|
|
77
78
|
const HOST_LOCK_TIMEOUT_MS = 30_000;
|
|
78
|
-
const HOST_LOCK_POLL_MS = 25;
|
|
79
|
-
const UNOWNED_LOCK_GRACE_MS = 1_000;
|
|
80
|
-
function sleepSync(ms) {
|
|
81
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
82
|
-
}
|
|
83
|
-
function processIsAlive(pid) {
|
|
84
|
-
try {
|
|
85
|
-
process.kill(pid, 0);
|
|
86
|
-
return true;
|
|
87
|
-
}
|
|
88
|
-
catch (error) {
|
|
89
|
-
return error.code === 'EPERM';
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
function reclaimDeadHostLock(lockPath) {
|
|
93
|
-
let reclaimable = false;
|
|
94
|
-
try {
|
|
95
|
-
const owner = JSON.parse(readFileSync(join(lockPath, 'owner.json'), 'utf8'));
|
|
96
|
-
reclaimable = typeof owner.pid === 'number' && !processIsAlive(owner.pid);
|
|
97
|
-
}
|
|
98
|
-
catch {
|
|
99
|
-
try {
|
|
100
|
-
reclaimable = Date.now() - statSync(lockPath).mtimeMs > UNOWNED_LOCK_GRACE_MS;
|
|
101
|
-
}
|
|
102
|
-
catch {
|
|
103
|
-
return true;
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
if (!reclaimable)
|
|
107
|
-
return false;
|
|
108
|
-
const stalePath = `${lockPath}.stale.${process.pid}.${Math.random().toString(36).slice(2)}`;
|
|
109
|
-
try {
|
|
110
|
-
renameSync(lockPath, stalePath);
|
|
111
|
-
}
|
|
112
|
-
catch (error) {
|
|
113
|
-
if (error.code === 'ENOENT')
|
|
114
|
-
return true;
|
|
115
|
-
return false;
|
|
116
|
-
}
|
|
117
|
-
rmSync(stalePath, { recursive: true, force: true });
|
|
118
|
-
return true;
|
|
119
|
-
}
|
|
120
79
|
/** Serialize reconciliation of the one machine-global host tuple. This lock is
|
|
121
80
|
* deliberately separate from the immutable runtime-generation install lock. */
|
|
122
81
|
function withBrandedHostLock(run) {
|
|
123
82
|
const root = join(homedir(), CRTR_DIR_NAME);
|
|
124
83
|
const lockPath = join(root, 'host.lock');
|
|
125
|
-
const token = `${process.pid}.${Math.random().toString(36).slice(2)}`;
|
|
126
|
-
const candidatePath = `${lockPath}.candidate.${token}`;
|
|
127
84
|
mkdirSync(root, { recursive: true });
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
try {
|
|
133
|
-
// The owner record and lock become visible together, so another process
|
|
134
|
-
// can never mistake an in-progress live acquisition for an abandoned lock.
|
|
135
|
-
renameSync(candidatePath, lockPath);
|
|
136
|
-
break;
|
|
137
|
-
}
|
|
138
|
-
catch (error) {
|
|
139
|
-
const code = error.code;
|
|
140
|
-
if (code !== 'EEXIST' && code !== 'ENOTEMPTY') {
|
|
141
|
-
rmSync(candidatePath, { recursive: true, force: true });
|
|
142
|
-
throw error;
|
|
143
|
-
}
|
|
144
|
-
if (reclaimDeadHostLock(lockPath))
|
|
145
|
-
continue;
|
|
146
|
-
if (Date.now() > deadline) {
|
|
147
|
-
rmSync(candidatePath, { recursive: true, force: true });
|
|
148
|
-
throw new Error(`timed out waiting for branded host lock: ${lockPath}`);
|
|
149
|
-
}
|
|
150
|
-
sleepSync(HOST_LOCK_POLL_MS);
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
try {
|
|
154
|
-
return run();
|
|
155
|
-
}
|
|
156
|
-
finally {
|
|
157
|
-
try {
|
|
158
|
-
const owner = JSON.parse(readFileSync(join(lockPath, 'owner.json'), 'utf8'));
|
|
159
|
-
if (owner.token === token)
|
|
160
|
-
rmSync(lockPath, { recursive: true, force: true });
|
|
161
|
-
}
|
|
162
|
-
catch {
|
|
163
|
-
/* A reclaimed/replaced lock is not ours to remove. */
|
|
164
|
-
}
|
|
165
|
-
rmSync(candidatePath, { recursive: true, force: true });
|
|
166
|
-
}
|
|
85
|
+
return withExclusiveDirectoryLock(lockPath, run, {
|
|
86
|
+
timeoutMs: HOST_LOCK_TIMEOUT_MS,
|
|
87
|
+
timeoutError: () => new Error(`timed out waiting for branded host lock: ${lockPath}`),
|
|
88
|
+
});
|
|
167
89
|
}
|
|
168
90
|
/** macOS keys FDA grants on the Mach-O cdhash. A real developer identity
|
|
169
91
|
* keeps that hash stable across branded-host rebuilds; ad-hoc signing is the
|
|
@@ -7,7 +7,7 @@ import { hostname } from 'node:os';
|
|
|
7
7
|
import { interpolateNodePaths } from '../canvas/paths.js';
|
|
8
8
|
import { fullName } from '../canvas/labels.js';
|
|
9
9
|
import { renderKnowledgeForSubject, renderPreferencesForSubject } from '../substrate/render.js';
|
|
10
|
-
import { renderWorkspaceOpenDocsForSubject } from '../substrate/on-read
|
|
10
|
+
import { renderWorkspaceOpenDocsForSubject } from '../substrate/on-read.js';
|
|
11
11
|
import { loadProfileManifest } from '../profiles/manifest.js';
|
|
12
12
|
export function editorLabelForBrokerNode(node) {
|
|
13
13
|
const icon = (node.icon ?? '').trim();
|
|
@@ -9,6 +9,7 @@ export declare const CANVAS_TOOL_GUIDE_PATH: string;
|
|
|
9
9
|
export declare const CANVAS_DOC_SUBSTRATE_PATH: string;
|
|
10
10
|
export declare const CANVAS_STRUCTURED_OUTPUT_PATH: string;
|
|
11
11
|
export declare const CANVAS_BASH_VALVE_PATH: string;
|
|
12
|
+
export declare const CANVAS_PREVIEW_RESULT_PATH: string;
|
|
12
13
|
/** The canvas extensions every node loads, in order. The inbox watcher commits
|
|
13
14
|
* its cursor at agent_settled before the stophook checks it for idle release.
|
|
14
15
|
* The review boundary must run before the stophook commits companion session
|
|
@@ -21,6 +21,7 @@ export const CANVAS_TOOL_GUIDE_PATH = resolveExtension('canvas-tool-guide');
|
|
|
21
21
|
export const CANVAS_DOC_SUBSTRATE_PATH = resolveExtension('canvas-doc-substrate');
|
|
22
22
|
export const CANVAS_STRUCTURED_OUTPUT_PATH = resolveExtension('canvas-structured-output');
|
|
23
23
|
export const CANVAS_BASH_VALVE_PATH = resolveExtension('canvas-bash-valve');
|
|
24
|
+
export const CANVAS_PREVIEW_RESULT_PATH = resolveExtension('canvas-preview-result');
|
|
24
25
|
/** The canvas extensions every node loads, in order. The inbox watcher commits
|
|
25
26
|
* its cursor at agent_settled before the stophook checks it for idle release.
|
|
26
27
|
* The review boundary must run before the stophook commits companion session
|
|
@@ -37,6 +38,7 @@ export const CANVAS_EXTENSIONS = [
|
|
|
37
38
|
CANVAS_DOC_SUBSTRATE_PATH,
|
|
38
39
|
CANVAS_STRUCTURED_OUTPUT_PATH,
|
|
39
40
|
CANVAS_BASH_VALVE_PATH,
|
|
41
|
+
CANVAS_PREVIEW_RESULT_PATH,
|
|
40
42
|
];
|
|
41
43
|
/** Preserve the invariant: mandatory extensions are always present exactly once.
|
|
42
44
|
*
|
|
@@ -7,7 +7,7 @@ import { tmpdir } from 'node:os';
|
|
|
7
7
|
import { join } from 'node:path';
|
|
8
8
|
import { ATTACH_CONTROL_BINDINGS, BINDING_CATALOG, BINDING_IDS, encodeAttachControlInput, formatGesture, isAttachPaneBinding, resolveUserKeybindingSettings, } from '../keybindings/index.js';
|
|
9
9
|
import { normalizeTmuxPassthrough } from '../config.js';
|
|
10
|
-
import { withExclusiveDirectoryLock } from '../
|
|
10
|
+
import { withExclusiveDirectoryLock } from '../exclusive-lock.js';
|
|
11
11
|
import { surfaceTmuxStyleArgs } from './surface-bg.js';
|
|
12
12
|
import { runTmux as tmux, shellQuote } from './tmux-driver.js';
|
|
13
13
|
// ---------------------------------------------------------------------------
|
|
@@ -16,10 +16,11 @@ export declare function warmPoolEnabled(): boolean;
|
|
|
16
16
|
export interface WarmRequest {
|
|
17
17
|
kind: string;
|
|
18
18
|
mode: Mode;
|
|
19
|
+
/** Where the node runs — already resolved by the create handler (pin >
|
|
20
|
+
* spawner > profile home), never a raw caller directory. */
|
|
19
21
|
cwd: string;
|
|
20
|
-
/** The
|
|
21
|
-
|
|
22
|
-
profile: string | null | undefined;
|
|
22
|
+
/** The RESOLVED profile id, or null for explicitly no profile. */
|
|
23
|
+
profileId: string | null;
|
|
23
24
|
model: string | null;
|
|
24
25
|
situationalContext: string | null;
|
|
25
26
|
name: string | null;
|
|
@@ -43,9 +44,9 @@ export declare function reapStaleSpares(): string[];
|
|
|
43
44
|
* throws and never blocks a caller: a failed mint just means the next create
|
|
44
45
|
* for this key is cold again. */
|
|
45
46
|
export declare function refillWarmPool(request: WarmRequest): void;
|
|
46
|
-
/** Mint a spare for the most recently used
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
47
|
+
/** Mint a spare for the most recently used profiles so the FIRST create after
|
|
48
|
+
* crtrd boots is warm too, not only the second. The profile is the whole
|
|
49
|
+
* recipe: it supplies the cwd, and everything else a claim reshapes live.
|
|
50
|
+
* Fire-and-forget — returns as soon as the mints are scheduled, never blocking
|
|
51
|
+
* the daemon's work lane. */
|
|
51
52
|
export declare function prewarmRecentRecipes(): void;
|
|
@@ -44,8 +44,13 @@
|
|
|
44
44
|
//
|
|
45
45
|
// Pool depth is 1 per key. It is populated lazily (the first `prefer_warm`
|
|
46
46
|
// create for a key falls back to a cold spawn and mints the spare behind it)
|
|
47
|
-
// and at daemon start for the most
|
|
48
|
-
// sitting down is warm too.
|
|
47
|
+
// and at daemon start for the most recently used profiles, so the FIRST node
|
|
48
|
+
// after sitting down is warm too.
|
|
49
|
+
//
|
|
50
|
+
// The pool no longer fans out per directory: a node runs in its profile's home
|
|
51
|
+
// (`resolveNodeCwd` in the create handler), so the cwd a spare freezes is a
|
|
52
|
+
// function of its profile, and "which spare serves this create" is in practice
|
|
53
|
+
// a question about the profile alone.
|
|
49
54
|
//
|
|
50
55
|
// Nothing ever RESUMES a spare: the daemon's recovery sweep reads `listNodes`,
|
|
51
56
|
// which hides the pool, so a spare whose broker dies (every daemon handover
|
|
@@ -57,7 +62,8 @@ import { createHash } from 'node:crypto';
|
|
|
57
62
|
import { claimWarmSpare, deleteNode, getNode, listNodes, listWarmSpares, registerWarmSpare, unregisterWarmSpare, updateNode, warmSpareCount, } from '../canvas/index.js';
|
|
58
63
|
import { recordedPidLiveness } from '../canvas/pid.js';
|
|
59
64
|
import { nowIso } from '../fs-utils.js';
|
|
60
|
-
import {
|
|
65
|
+
import { spawnChildPrepared } from './spawn.js';
|
|
66
|
+
import { profileHome } from '../profiles/manifest.js';
|
|
61
67
|
import { buildLaunchSpec } from './launch.js';
|
|
62
68
|
import { setModelLive } from './model-swap.js';
|
|
63
69
|
import { setSituationalContextLive } from './situational-live.js';
|
|
@@ -94,31 +100,23 @@ function warmRecipeKey(cwd, profileId, launch) {
|
|
|
94
100
|
]);
|
|
95
101
|
return createHash('sha256').update(canonical).digest('hex').slice(0, 32);
|
|
96
102
|
}
|
|
97
|
-
/** Resolve a request's frozen half.
|
|
98
|
-
*
|
|
99
|
-
|
|
100
|
-
* resolved id costs nothing extra and keys what actually matters. */
|
|
101
|
-
async function resolveRecipe(request) {
|
|
102
|
-
const profileId = await resolveProfileId(request.profile, null, request.cwd);
|
|
103
|
+
/** Resolve a request's frozen half. Both cwd and profile arrive already
|
|
104
|
+
* resolved from the create handler, so this is pure launch-spec construction. */
|
|
105
|
+
function resolveRecipe(request) {
|
|
103
106
|
const { launch } = buildLaunchSpec(request.kind, request.mode, {
|
|
104
107
|
lifecycle: 'resident',
|
|
105
108
|
hasManager: false,
|
|
106
109
|
...(request.model !== null ? { model: request.model } : {}),
|
|
107
110
|
cwd: request.cwd,
|
|
108
|
-
profileId,
|
|
111
|
+
profileId: request.profileId,
|
|
109
112
|
});
|
|
110
|
-
return {
|
|
111
|
-
key: warmRecipeKey(request.cwd, profileId, launch),
|
|
112
|
-
launch,
|
|
113
|
-
profileId,
|
|
114
|
-
request,
|
|
115
|
-
};
|
|
113
|
+
return { key: warmRecipeKey(request.cwd, request.profileId, launch), launch, request };
|
|
116
114
|
}
|
|
117
115
|
/** Claim a spare for `request`, reshaped onto its persona — or null when the
|
|
118
116
|
* pool cannot serve it. The caller falls back to a cold spawn; a claim never
|
|
119
117
|
* fails the create. */
|
|
120
118
|
export async function claimWarmNode(request) {
|
|
121
|
-
const recipe =
|
|
119
|
+
const recipe = resolveRecipe(request);
|
|
122
120
|
const created = nowIso();
|
|
123
121
|
// The row's stamp moves inside the claim transaction; meta.json (the identity
|
|
124
122
|
// source of truth `rebuildIndex` re-derives the row from) follows with the
|
|
@@ -261,18 +259,15 @@ const minting = new Set();
|
|
|
261
259
|
export function refillWarmPool(request) {
|
|
262
260
|
if (!warmPoolEnabled())
|
|
263
261
|
return;
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
console.error(`[warm-pool] recipe resolution failed: ${err.message}`);
|
|
274
|
-
}
|
|
275
|
-
})();
|
|
262
|
+
try {
|
|
263
|
+
// Mint at the kind's DEFAULT model and with NO situational context: both
|
|
264
|
+
// are claim-time properties, so baking either in would only narrow who the
|
|
265
|
+
// spare can serve.
|
|
266
|
+
mint(resolveRecipe({ ...request, model: null, situationalContext: null }));
|
|
267
|
+
}
|
|
268
|
+
catch (err) {
|
|
269
|
+
console.error(`[warm-pool] recipe resolution failed: ${err.message}`);
|
|
270
|
+
}
|
|
276
271
|
}
|
|
277
272
|
/** Mint one spare for an already-resolved recipe, if the pool is short. */
|
|
278
273
|
function mint(recipe) {
|
|
@@ -298,7 +293,7 @@ function mint(recipe) {
|
|
|
298
293
|
// Mint at the kind's DEFAULT model: `model_override` then stays unset on
|
|
299
294
|
// the spare, so a claim only ever SETS it (updateNode cannot clear a
|
|
300
295
|
// field) and a no-model claim gets a clean row.
|
|
301
|
-
|
|
296
|
+
profile: request.profileId,
|
|
302
297
|
// No situational context either: the claimant's is delivered live at
|
|
303
298
|
// claim time, so a spare that baked one in would serve nobody else.
|
|
304
299
|
// Mark it unclaimed the instant its row exists and BEFORE the broker
|
|
@@ -317,14 +312,14 @@ function mint(recipe) {
|
|
|
317
312
|
// ---------------------------------------------------------------------------
|
|
318
313
|
// Daemon-start pre-warm
|
|
319
314
|
// ---------------------------------------------------------------------------
|
|
320
|
-
/** How many
|
|
321
|
-
*
|
|
322
|
-
const
|
|
323
|
-
/** Mint a spare for the most recently used
|
|
324
|
-
*
|
|
325
|
-
*
|
|
326
|
-
*
|
|
327
|
-
*
|
|
315
|
+
/** How many profiles get a spare at daemon start. Each spare is a resident
|
|
316
|
+
* broker at ~150-300 MB, so this is deliberately tiny. */
|
|
317
|
+
const PREWARM_PROFILES = 2;
|
|
318
|
+
/** Mint a spare for the most recently used profiles so the FIRST create after
|
|
319
|
+
* crtrd boots is warm too, not only the second. The profile is the whole
|
|
320
|
+
* recipe: it supplies the cwd, and everything else a claim reshapes live.
|
|
321
|
+
* Fire-and-forget — returns as soon as the mints are scheduled, never blocking
|
|
322
|
+
* the daemon's work lane. */
|
|
328
323
|
export function prewarmRecentRecipes() {
|
|
329
324
|
if (!warmPoolEnabled())
|
|
330
325
|
return;
|
|
@@ -336,12 +331,15 @@ export function prewarmRecentRecipes() {
|
|
|
336
331
|
for (const row of listNodes({ status: ['active', 'idle'] }).reverse()) {
|
|
337
332
|
if (row.parent !== null || row.kind === 'human')
|
|
338
333
|
continue;
|
|
339
|
-
const
|
|
340
|
-
if (seen.has(
|
|
334
|
+
const profileId = row.profile_id ?? null;
|
|
335
|
+
if (seen.has(profileId ?? ''))
|
|
341
336
|
continue;
|
|
342
|
-
seen.add(
|
|
343
|
-
|
|
344
|
-
|
|
337
|
+
seen.add(profileId ?? '');
|
|
338
|
+
// The profile's home is where its next node will land. A profile without
|
|
339
|
+
// one (root, or a home since deleted) still gets a spare, keyed on the
|
|
340
|
+
// directory its last root actually ran in.
|
|
341
|
+
candidates.push({ cwd: profileHome(profileId) ?? row.cwd, profileId });
|
|
342
|
+
if (candidates.length >= PREWARM_PROFILES)
|
|
345
343
|
break;
|
|
346
344
|
}
|
|
347
345
|
}
|
|
@@ -350,24 +348,22 @@ export function prewarmRecentRecipes() {
|
|
|
350
348
|
return;
|
|
351
349
|
}
|
|
352
350
|
for (const candidate of candidates) {
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
}
|
|
371
|
-
})();
|
|
351
|
+
try {
|
|
352
|
+
// `general`/`base` is only the shape the spare BOOTS as — the key it is
|
|
353
|
+
// filed under excludes everything a claim can reshape, so any kind can
|
|
354
|
+
// claim it.
|
|
355
|
+
mint(resolveRecipe({
|
|
356
|
+
kind: 'general',
|
|
357
|
+
mode: 'base',
|
|
358
|
+
cwd: candidate.cwd,
|
|
359
|
+
profileId: candidate.profileId,
|
|
360
|
+
model: null,
|
|
361
|
+
situationalContext: null,
|
|
362
|
+
name: null,
|
|
363
|
+
}));
|
|
364
|
+
}
|
|
365
|
+
catch (err) {
|
|
366
|
+
console.error(`[warm-pool] prewarm failed for ${candidate.cwd}: ${err.message}`);
|
|
367
|
+
}
|
|
372
368
|
}
|
|
373
369
|
}
|