@north-light/crouter 0.3.217 → 0.3.219
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/client.d.ts +5 -2
- package/dist/api/client.js +7 -1
- package/dist/api/dto/canvas.d.ts +9 -0
- package/dist/api/dto/nodes.d.ts +27 -1
- package/dist/builtin-memory/04-base-worker.md +7 -1
- package/dist/builtin-memory/internal/plugins.md +67 -0
- package/dist/builtin-memory/memory-read-orientation.md +13 -0
- package/dist/clients/attach/chrome/bash-jobs.js +1 -1
- package/dist/clients/attach/photon_rs_bg.wasm +0 -0
- package/dist/clients/attach/viewer.js +478 -478
- package/dist/commands/human/shared.js +1 -1
- package/dist/commands/memory/edit.js +6 -1
- package/dist/commands/memory/lint.d.ts +1 -6
- package/dist/commands/memory/lint.js +18 -126
- package/dist/commands/memory/list.d.ts +1 -0
- package/dist/commands/memory/list.js +17 -2
- package/dist/commands/memory/read.js +15 -6
- package/dist/commands/memory/shared.d.ts +32 -2
- package/dist/commands/memory/shared.js +179 -0
- package/dist/commands/memory/write.js +6 -1
- package/dist/commands/pkg/browse/catalog.js +2 -0
- package/dist/commands/pkg/browse/model.d.ts +4 -1
- package/dist/commands/pkg/plugin-manage.js +209 -148
- package/dist/core/bash-jobs.d.ts +2 -5
- package/dist/core/bash-jobs.js +4 -8
- package/dist/core/command-plugins/bundle.d.ts +4 -1
- package/dist/core/command-plugins/bundle.js +16 -2
- package/dist/core/human/component-docs.js +1 -0
- package/dist/core/human/scan.d.ts +5 -4
- package/dist/core/human/scan.js +7 -4
- package/dist/core/io.js +3 -2
- package/dist/core/manifest.d.ts +2 -0
- package/dist/core/manifest.js +5 -0
- package/dist/core/memory/extensions.d.ts +30 -0
- package/dist/core/memory/extensions.js +219 -0
- package/dist/core/preview-result-path.d.ts +4 -0
- package/dist/core/preview-result-path.js +25 -0
- package/dist/core/runtime/launch-config-worker.d.ts +1 -0
- package/dist/core/runtime/launch-config-worker.js +12 -0
- package/dist/core/runtime/launch.d.ts +16 -2
- package/dist/core/runtime/launch.js +55 -21
- package/dist/core/runtime/promote.d.ts +20 -2
- package/dist/core/runtime/promote.js +63 -59
- package/dist/core/runtime/recycle.js +2 -2
- package/dist/core/runtime/reset.js +2 -2
- package/dist/core/runtime/spawn.js +3 -3
- package/dist/core/runtime/warm-pool.js +9 -7
- package/dist/core/substrate/frontmatter-validation.d.ts +13 -0
- package/dist/core/substrate/frontmatter-validation.js +101 -0
- package/dist/core/substrate/index.d.ts +1 -0
- package/dist/core/substrate/index.js +1 -0
- package/dist/daemon/api/__tests__/bridge-heartbeat.test.d.ts +1 -0
- package/dist/daemon/api/__tests__/bridge-heartbeat.test.js +30 -0
- package/dist/daemon/api/__tests__/nodes-activity-query.test.d.ts +1 -0
- package/dist/daemon/api/__tests__/nodes-activity-query.test.js +101 -0
- package/dist/daemon/api/__tests__/seam/api-server.test.js +30 -0
- package/dist/daemon/api/bridge.d.ts +6 -0
- package/dist/daemon/api/bridge.js +39 -2
- package/dist/daemon/api/handlers/canvas.js +3 -0
- package/dist/daemon/api/handlers/inbox.js +4 -3
- package/dist/daemon/api/handlers/nodes.d.ts +1 -0
- package/dist/daemon/api/handlers/nodes.js +117 -36
- package/dist/daemon/api/handlers/reports.d.ts +4 -0
- package/dist/daemon/api/handlers/reports.js +14 -8
- package/dist/daemon/crtrd.js +7 -5
- package/dist/daemon/manage.d.ts +3 -0
- package/dist/daemon/manage.js +14 -0
- package/dist/pi-extensions/canvas-bash-valve.d.ts +4 -3
- package/dist/pi-extensions/canvas-bash-valve.js +19 -6
- package/dist/pi-extensions/canvas-preview-result.d.ts +0 -8
- package/dist/pi-extensions/canvas-preview-result.js +9 -23
- package/dist/types.d.ts +31 -0
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
|
@@ -33,6 +33,40 @@ import { probeViewSocket, waitForBrokerViewSocket } from '../../core/runtime/vie
|
|
|
33
33
|
* client cap) so a remote peer cannot buffer 256MiB before the broker's 24MiB
|
|
34
34
|
* decoder rejects the message. */
|
|
35
35
|
const wss = new WebSocketServer({ noServer: true, maxPayload: BROKER_READ_CAPS.maxLineBytes });
|
|
36
|
+
/** Keep an otherwise quiet observer connection alive through common 60s proxy
|
|
37
|
+
* idle limits. A viewer only observes a broker, so a missed heartbeat may close
|
|
38
|
+
* this socket but must never affect the broker or its in-flight work. */
|
|
39
|
+
export const REMOTE_ATTACH_HEARTBEAT_MS = 25_000;
|
|
40
|
+
export function startRemoteAttachHeartbeat(ws, intervalMs = REMOTE_ATTACH_HEARTBEAT_MS) {
|
|
41
|
+
let alive = true;
|
|
42
|
+
let stopped = false;
|
|
43
|
+
const acknowledge = () => { alive = true; };
|
|
44
|
+
const stop = () => {
|
|
45
|
+
if (stopped)
|
|
46
|
+
return;
|
|
47
|
+
stopped = true;
|
|
48
|
+
clearInterval(timer);
|
|
49
|
+
ws.off('pong', acknowledge);
|
|
50
|
+
};
|
|
51
|
+
ws.on('pong', acknowledge);
|
|
52
|
+
const timer = setInterval(() => {
|
|
53
|
+
if (!alive) {
|
|
54
|
+
stop();
|
|
55
|
+
ws.terminate();
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
alive = false;
|
|
59
|
+
try {
|
|
60
|
+
ws.ping();
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
stop();
|
|
64
|
+
ws.terminate();
|
|
65
|
+
}
|
|
66
|
+
}, intervalMs);
|
|
67
|
+
timer.unref();
|
|
68
|
+
return stop;
|
|
69
|
+
}
|
|
36
70
|
/** A WS close reason is capped at 123 UTF-8 bytes by the protocol. */
|
|
37
71
|
function clampReason(reason) {
|
|
38
72
|
const buf = Buffer.from(reason, 'utf8');
|
|
@@ -106,6 +140,7 @@ function bridgeConnection(ws, nodeId) {
|
|
|
106
140
|
const decoder = new FrameDecoder(CLIENT_READ_CAPS);
|
|
107
141
|
let closed = false;
|
|
108
142
|
let viewerFaultRecorded = false;
|
|
143
|
+
let stopHeartbeat;
|
|
109
144
|
socket.on('connect', () => {
|
|
110
145
|
clearFault(nodeId, { link: 'relay↔broker' });
|
|
111
146
|
});
|
|
@@ -113,6 +148,7 @@ function bridgeConnection(ws, nodeId) {
|
|
|
113
148
|
if (closed)
|
|
114
149
|
return;
|
|
115
150
|
closed = true;
|
|
151
|
+
stopHeartbeat?.();
|
|
116
152
|
if (!socket.destroyed) {
|
|
117
153
|
try {
|
|
118
154
|
socket.destroy();
|
|
@@ -128,6 +164,7 @@ function bridgeConnection(ws, nodeId) {
|
|
|
128
164
|
/* ignore */
|
|
129
165
|
}
|
|
130
166
|
};
|
|
167
|
+
stopHeartbeat = startRemoteAttachHeartbeat(ws);
|
|
131
168
|
// socket → WS: bounded decode, one WS text message per complete frame.
|
|
132
169
|
socket.on('data', (chunk) => {
|
|
133
170
|
let frames;
|
|
@@ -183,7 +220,7 @@ function bridgeConnection(ws, nodeId) {
|
|
|
183
220
|
link: 'viewer↔crtrd',
|
|
184
221
|
op: 'remote attach websocket',
|
|
185
222
|
kind: classification.kind,
|
|
186
|
-
retry: { disposition: '
|
|
223
|
+
retry: { disposition: 'auto' },
|
|
187
224
|
message: reasonText || `remote attach closed (${code})`,
|
|
188
225
|
});
|
|
189
226
|
}
|
|
@@ -197,7 +234,7 @@ function bridgeConnection(ws, nodeId) {
|
|
|
197
234
|
link: 'viewer↔crtrd',
|
|
198
235
|
op: 'remote attach websocket',
|
|
199
236
|
kind: classification.kind,
|
|
200
|
-
retry: { disposition: '
|
|
237
|
+
retry: { disposition: 'auto' },
|
|
201
238
|
message: 'remote attach websocket error',
|
|
202
239
|
});
|
|
203
240
|
}
|
|
@@ -278,6 +278,7 @@ function handleSnapshot() {
|
|
|
278
278
|
lifecycle: row.lifecycle ?? meta?.lifecycle,
|
|
279
279
|
status: row.status,
|
|
280
280
|
cwd: row.cwd,
|
|
281
|
+
profile_id: meta?.profile_id ?? null,
|
|
281
282
|
parent: meta?.parent ?? null,
|
|
282
283
|
created: row.created,
|
|
283
284
|
host_kind: hostKind,
|
|
@@ -289,6 +290,8 @@ function handleSnapshot() {
|
|
|
289
290
|
streaming: row.streaming ?? false,
|
|
290
291
|
hanging: (row.hanging ?? null),
|
|
291
292
|
viewed: row.viewed ?? false,
|
|
293
|
+
final_report: meta?.final_report ?? null,
|
|
294
|
+
finalized_at: meta?.finalized_at ?? null,
|
|
292
295
|
};
|
|
293
296
|
});
|
|
294
297
|
const subscriptions = Object.fromEntries(rows.map((row) => [row.node_id, subscriptionsOf(row.node_id).map((ref) => ref.node_id)]));
|
|
@@ -226,11 +226,12 @@ function handleList() {
|
|
|
226
226
|
// GET /v1/human/inbox/history?node_id=<id> — page ticket history
|
|
227
227
|
// ===========================================================================
|
|
228
228
|
function handleHistory(ctx) {
|
|
229
|
+
// Home-wide when `node_id` is absent; an explicitly empty param is a caller bug.
|
|
229
230
|
const nodeId = ctx.query.get('node_id');
|
|
230
|
-
if (
|
|
231
|
-
throw new ApiError(400, 'invalid_request', '
|
|
231
|
+
if (nodeId === '') {
|
|
232
|
+
throw new ApiError(400, 'invalid_request', 'node_id must be nonempty when present');
|
|
232
233
|
}
|
|
233
|
-
const history = scanPageHistory(nodeId);
|
|
234
|
+
const history = scanPageHistory(nodeId ?? undefined);
|
|
234
235
|
const canonicalRoot = realpathSync(ticketsRoot());
|
|
235
236
|
const tickets = history.map((item) => pageSummaryToDTO(item, canonicalRoot));
|
|
236
237
|
return { status: 200, body: { tickets } };
|
|
@@ -6,6 +6,7 @@ interface NodeHandlerDeps {
|
|
|
6
6
|
}
|
|
7
7
|
export declare function resolveCreateKind(kind: string | undefined, profile: ReturnType<typeof assertProfileActive>): string;
|
|
8
8
|
export declare function handleCreate(ctx: RequestContext, deps?: NodeHandlerDeps): Promise<HandlerResult>;
|
|
9
|
+
export declare function handleList(ctx: RequestContext): HandlerResult;
|
|
9
10
|
export declare function handlePromote(ctx: RequestContext, deps?: NodeHandlerDeps): Promise<HandlerResult>;
|
|
10
11
|
export declare function handleYield(ctx: RequestContext, deps?: NodeHandlerDeps): Promise<HandlerResult>;
|
|
11
12
|
export declare function handleConfig(ctx: RequestContext, deps?: NodeHandlerDeps): Promise<HandlerResult>;
|
|
@@ -7,26 +7,26 @@
|
|
|
7
7
|
//
|
|
8
8
|
// `{id}` is the target node.
|
|
9
9
|
import { readdirSync } from 'node:fs';
|
|
10
|
-
import { resolve } from 'node:path';
|
|
10
|
+
import { basename, resolve } from 'node:path';
|
|
11
11
|
import { appendSituationalContext, closeNode, getNode, listNodes, nowIso, reviveNode, } from '../../../index.js';
|
|
12
12
|
import { formatSituationalProse } from '../../../core/runtime/situational-context.js';
|
|
13
13
|
import { assertLaunchModelRegistered, forkNode, resolveProfileId, spawnChild } from '../../../core/runtime/spawn.js';
|
|
14
14
|
import { childrenOf, subscribersOf, subscriptionsOf, setMessageWait, updateNode } from '../../../core/canvas/canvas.js';
|
|
15
15
|
import { subtreeIds } from '../../../core/canvas/nav-model.js';
|
|
16
|
+
import { ticketCountsForNodes } from '../../../core/canvas/attention.js';
|
|
16
17
|
import { contextDir, reportsDir } from '../../../core/canvas/paths.js';
|
|
17
18
|
import { nodeArtifacts } from '../../../core/canvas/history.js';
|
|
18
19
|
import { readNodeMessagesPage, readNodeSession, readNodeSnapshot, transcriptMarkdown } from '../../../core/runtime/node-read.js';
|
|
19
20
|
import { assembleNodeSubject } from '../../../core/substrate/subject.js';
|
|
20
21
|
import { reviveAll } from '../../../core/runtime/revive-all.js';
|
|
21
|
-
import {
|
|
22
|
+
import { promoteAsync, requestYieldAsync, reshapeNodeAsync } from '../../../core/runtime/promote.js';
|
|
22
23
|
import { recycleNode } from '../../../core/runtime/recycle.js';
|
|
23
24
|
import { relaunchRoot } from '../../../core/runtime/reset.js';
|
|
24
25
|
import { assertReopenGate } from '../../../core/runtime/reopen.js';
|
|
25
26
|
import { writeYieldMessage, readGoal } from '../../../core/runtime/kickoff.js';
|
|
26
27
|
import { hasRoadmap, seedRoadmap } from '../../../core/runtime/roadmap.js';
|
|
27
28
|
import { renameWindow } from '../../../core/runtime/placement.js';
|
|
28
|
-
import {
|
|
29
|
-
import { buildLaunchSpec, isProviderPinnedModelToken, normalizeModel } from '../../../core/runtime/launch.js';
|
|
29
|
+
import { buildLaunchSpecAsync, isProviderPinnedModelToken, normalizeModel, readMergedLaunchConfigAsync } from '../../../core/runtime/launch.js';
|
|
30
30
|
import { modelRequestFromConfig } from '../../../core/model-routes.js';
|
|
31
31
|
import { BrokerUnreachableError } from '../../../core/runtime/broker-request.js';
|
|
32
32
|
import { isBrokerLive, persistDormantModel, setModelLive } from '../../../core/runtime/model-swap.js';
|
|
@@ -34,6 +34,7 @@ import { writeOutputSchema } from '../../../core/runtime/structured-output.js';
|
|
|
34
34
|
import { claimWarmNode, refillWarmPool, warmPoolEnabled } from '../../../core/runtime/warm-pool.js';
|
|
35
35
|
import { assertProfileActive, profileHome } from '../../../core/profiles/manifest.js';
|
|
36
36
|
import { fullName } from '../../../core/canvas/labels.js';
|
|
37
|
+
import { reportsForNode } from './reports.js';
|
|
37
38
|
import { getCron } from '../../../core/canvas/crons.js';
|
|
38
39
|
import { cronWakeOrigin } from '../../../core/runtime/bearings.js';
|
|
39
40
|
import { notFound, usage } from '../../../core/errors.js';
|
|
@@ -194,13 +195,13 @@ function requestFromLaunch(launch) {
|
|
|
194
195
|
return modelRequestFromConfig(launch.model, intent, launch.modelExact === true);
|
|
195
196
|
}
|
|
196
197
|
async function assertNodeModelChangeRegistered(meta, target, registry) {
|
|
197
|
-
const launch =
|
|
198
|
+
const launch = (await buildLaunchSpecAsync(target.kind, target.mode, {
|
|
198
199
|
lifecycle: target.lifecycle,
|
|
199
200
|
hasManager: meta.parent !== null,
|
|
200
201
|
model: target.model,
|
|
201
202
|
cwd: meta.cwd,
|
|
202
203
|
profileId: meta.profile_id ?? null,
|
|
203
|
-
}).launch;
|
|
204
|
+
})).launch;
|
|
204
205
|
await assertLaunchModelRegistered(requestFromLaunch(launch), { cwd: meta.cwd, profileId: meta.profile_id ?? null }, registry);
|
|
205
206
|
}
|
|
206
207
|
export async function handleCreate(ctx, deps = {}) {
|
|
@@ -232,16 +233,16 @@ export async function handleCreate(ctx, deps = {}) {
|
|
|
232
233
|
const profileId = await resolveProfileId(req.profile, parent ?? null, contextCwd);
|
|
233
234
|
const profile = assertProfileActive(profileId);
|
|
234
235
|
const kind = resolveCreateKind(req.kind, profile);
|
|
235
|
-
assertKindServer(kind);
|
|
236
236
|
const resolvedReq = { ...req, kind };
|
|
237
237
|
const cwd = resolveNodeCwd(resolvedReq, parent, profileId, contextCwd);
|
|
238
|
-
|
|
238
|
+
await assertKindServer(kind, cwd, profileId);
|
|
239
|
+
const launch = (await buildLaunchSpecAsync(kind, (resolvedReq.mode ?? 'base'), {
|
|
239
240
|
lifecycle: req.root === true ? (req.root_lifecycle ?? 'resident') : 'terminal',
|
|
240
241
|
hasManager: req.root !== true,
|
|
241
242
|
...(req.model !== undefined ? { model: req.model } : {}),
|
|
242
243
|
cwd,
|
|
243
244
|
profileId,
|
|
244
|
-
}).launch;
|
|
245
|
+
})).launch;
|
|
245
246
|
await assertLaunchModelRegistered(requestFromLaunch(launch), { cwd, profileId }, deps.modelRegistry);
|
|
246
247
|
// --- Warm path: hand out an already-booted spare instead of paying a full
|
|
247
248
|
// engine boot. A miss is not a failure — it falls through to the cold
|
|
@@ -266,26 +267,106 @@ export async function handleCreate(ctx, deps = {}) {
|
|
|
266
267
|
// ---------------------------------------------------------------------------
|
|
267
268
|
// GET /v1/nodes — list (filters) ; GET /v1/nodes/{id} — detail
|
|
268
269
|
// ---------------------------------------------------------------------------
|
|
269
|
-
function
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
if (
|
|
277
|
-
|
|
278
|
-
if (
|
|
279
|
-
|
|
280
|
-
if (
|
|
281
|
-
|
|
282
|
-
|
|
270
|
+
function singleQueryValue(ctx, name) {
|
|
271
|
+
const values = ctx.query.getAll(name);
|
|
272
|
+
if (values.length > 1)
|
|
273
|
+
throw usage(`${name} must appear at most once`);
|
|
274
|
+
return values[0];
|
|
275
|
+
}
|
|
276
|
+
function optionalBoolean(value, name) {
|
|
277
|
+
if (value === undefined)
|
|
278
|
+
return undefined;
|
|
279
|
+
if (value === '1' || value === 'true')
|
|
280
|
+
return true;
|
|
281
|
+
if (value === '0' || value === 'false')
|
|
282
|
+
return false;
|
|
283
|
+
throw usage(`${name} must be true or false`);
|
|
284
|
+
}
|
|
285
|
+
function oneOf(value, name, allowed) {
|
|
286
|
+
if (value === undefined)
|
|
287
|
+
return undefined;
|
|
288
|
+
if (allowed.includes(value))
|
|
289
|
+
return value;
|
|
290
|
+
throw usage(`${name} must be one of: ${allowed.join(', ')}`);
|
|
291
|
+
}
|
|
292
|
+
function parseListQuery(ctx) {
|
|
293
|
+
const allowed = new Set([
|
|
294
|
+
'status', 'lifecycle', 'kind', 'mode', 'profile_id', 'profile_prefix',
|
|
295
|
+
'parent', 'top_level', 'under', 'hanging', 'include',
|
|
296
|
+
]);
|
|
297
|
+
for (const [name] of ctx.query) {
|
|
298
|
+
if (!allowed.has(name))
|
|
299
|
+
throw usage(`unknown nodes query parameter: ${name}`);
|
|
283
300
|
}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
301
|
+
const status = oneOf(singleQueryValue(ctx, 'status'), 'status', ['active', 'idle', 'done', 'dead', 'canceled']);
|
|
302
|
+
const lifecycle = oneOf(singleQueryValue(ctx, 'lifecycle'), 'lifecycle', ['terminal', 'resident']);
|
|
303
|
+
const kind = singleQueryValue(ctx, 'kind');
|
|
304
|
+
const mode = oneOf(singleQueryValue(ctx, 'mode'), 'mode', ['base', 'orchestrator']);
|
|
305
|
+
const profileId = singleQueryValue(ctx, 'profile_id');
|
|
306
|
+
const profilePrefix = singleQueryValue(ctx, 'profile_prefix');
|
|
307
|
+
const parent = singleQueryValue(ctx, 'parent');
|
|
308
|
+
const topLevel = optionalBoolean(singleQueryValue(ctx, 'top_level'), 'top_level');
|
|
309
|
+
const under = singleQueryValue(ctx, 'under');
|
|
310
|
+
const hanging = optionalBoolean(singleQueryValue(ctx, 'hanging'), 'hanging');
|
|
311
|
+
const include = oneOf(singleQueryValue(ctx, 'include'), 'include', ['activity']);
|
|
312
|
+
if (parent !== undefined && topLevel === true) {
|
|
313
|
+
throw usage('parent and top_level=true cannot be combined');
|
|
287
314
|
}
|
|
288
|
-
|
|
315
|
+
return {
|
|
316
|
+
...(status !== undefined ? { status } : {}),
|
|
317
|
+
...(lifecycle !== undefined ? { lifecycle } : {}),
|
|
318
|
+
...(kind !== undefined ? { kind } : {}),
|
|
319
|
+
...(mode !== undefined ? { mode } : {}),
|
|
320
|
+
...(profileId !== undefined ? { profile_id: profileId } : {}),
|
|
321
|
+
...(profilePrefix !== undefined ? { profile_prefix: profilePrefix } : {}),
|
|
322
|
+
...(parent !== undefined ? { parent } : {}),
|
|
323
|
+
...(topLevel !== undefined ? { top_level: topLevel } : {}),
|
|
324
|
+
...(under !== undefined ? { under } : {}),
|
|
325
|
+
...(hanging !== undefined ? { hanging } : {}),
|
|
326
|
+
...(include !== undefined ? { include: include } : {}),
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
export function handleList(ctx) {
|
|
330
|
+
const query = parseListQuery(ctx);
|
|
331
|
+
let rows = query.status !== undefined ? listNodes({ status: query.status }) : listNodes();
|
|
332
|
+
if (query.lifecycle !== undefined)
|
|
333
|
+
rows = rows.filter((row) => row.lifecycle === query.lifecycle);
|
|
334
|
+
if (query.kind !== undefined)
|
|
335
|
+
rows = rows.filter((row) => row.kind === query.kind);
|
|
336
|
+
if (query.mode !== undefined)
|
|
337
|
+
rows = rows.filter((row) => row.mode === query.mode);
|
|
338
|
+
if (query.profile_id !== undefined)
|
|
339
|
+
rows = rows.filter((row) => row.profile_id === query.profile_id);
|
|
340
|
+
if (query.profile_prefix !== undefined)
|
|
341
|
+
rows = rows.filter((row) => row.profile_id?.startsWith(query.profile_prefix));
|
|
342
|
+
if (query.parent !== undefined)
|
|
343
|
+
rows = rows.filter((row) => row.parent === query.parent);
|
|
344
|
+
if (query.top_level === true)
|
|
345
|
+
rows = rows.filter((row) => row.parent === null);
|
|
346
|
+
if (query.under !== undefined) {
|
|
347
|
+
const scope = new Set(subtreeIds(query.under));
|
|
348
|
+
rows = rows.filter((row) => scope.has(row.node_id));
|
|
349
|
+
}
|
|
350
|
+
if (query.hanging === true) {
|
|
351
|
+
rows = rows.filter((row) => row.parent !== null && getNode(row.parent) === null);
|
|
352
|
+
}
|
|
353
|
+
const counts = query.include === 'activity'
|
|
354
|
+
? ticketCountsForNodes(rows.map((row) => row.node_id))
|
|
355
|
+
: null;
|
|
356
|
+
const body = rows.map((row) => {
|
|
357
|
+
const node = toNodeSummaryDTO(row);
|
|
358
|
+
if (counts !== null) {
|
|
359
|
+
const reports = reportsForNode(row.node_id);
|
|
360
|
+
node.activity = {
|
|
361
|
+
latest_report: reports[0] ?? null,
|
|
362
|
+
final_report: row.final_report === null
|
|
363
|
+
? null
|
|
364
|
+
: reports.find((report) => basename(report.path) === row.final_report) ?? null,
|
|
365
|
+
pending_human_count: counts[row.node_id] ?? 0,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
return node;
|
|
369
|
+
});
|
|
289
370
|
return { status: 200, body };
|
|
290
371
|
}
|
|
291
372
|
function handleDetail(ctx) {
|
|
@@ -473,12 +554,12 @@ async function handleRecycle(ctx) {
|
|
|
473
554
|
},
|
|
474
555
|
};
|
|
475
556
|
}
|
|
476
|
-
function handleDemote(ctx) {
|
|
557
|
+
async function handleDemote(ctx) {
|
|
477
558
|
const id = ctx.params['id'];
|
|
478
559
|
requireMeta(id);
|
|
479
560
|
// Use the canonical reshape primitive to ensure modelExact, provider
|
|
480
561
|
// pin/intent, and default-name migration are all preserved.
|
|
481
|
-
|
|
562
|
+
await reshapeNodeAsync(id, { lifecycle: 'terminal' });
|
|
482
563
|
return { status: 200, body: detailOf(id) };
|
|
483
564
|
}
|
|
484
565
|
export async function handlePromote(ctx, deps = {}) {
|
|
@@ -493,7 +574,7 @@ export async function handlePromote(ctx, deps = {}) {
|
|
|
493
574
|
model: req.model,
|
|
494
575
|
}, deps.modelRegistry);
|
|
495
576
|
}
|
|
496
|
-
const result =
|
|
577
|
+
const result = await promoteAsync(id, {
|
|
497
578
|
...(req.kind !== undefined ? { kind: req.kind } : {}),
|
|
498
579
|
...(req.resident === true ? { resident: true } : {}),
|
|
499
580
|
...(req.model !== undefined ? { model: req.model } : {}),
|
|
@@ -520,7 +601,7 @@ export async function handleYield(ctx, deps = {}) {
|
|
|
520
601
|
}
|
|
521
602
|
const note = req.note ?? 'Refresh: continue against your roadmap or goal.';
|
|
522
603
|
writeYieldMessage(id, note);
|
|
523
|
-
const result =
|
|
604
|
+
const result = await requestYieldAsync(id, {
|
|
524
605
|
...(req.promote === true ? { promote: true } : {}),
|
|
525
606
|
...(req.kind !== undefined ? { kind: req.kind } : {}),
|
|
526
607
|
...(req.model !== undefined ? { model: req.model } : {}),
|
|
@@ -563,7 +644,7 @@ export async function handleConfig(ctx, deps = {}) {
|
|
|
563
644
|
}
|
|
564
645
|
if (patch.model !== undefined) {
|
|
565
646
|
const pinned = isProviderPinnedModelToken(patch.model);
|
|
566
|
-
const ladders =
|
|
647
|
+
const ladders = (await readMergedLaunchConfigAsync(meta.cwd, meta.profile_id ?? null)).modelLadders;
|
|
567
648
|
const spec = normalizeModel(patch.model, ladders);
|
|
568
649
|
await assertLaunchModelRegistered({ kind: 'concrete', spec }, { cwd: meta.cwd, profileId: meta.profile_id ?? null }, deps.modelRegistry);
|
|
569
650
|
const persistDormant = () => {
|
|
@@ -599,7 +680,7 @@ export async function handleConfig(ctx, deps = {}) {
|
|
|
599
680
|
// --- kind / mode / lifecycle: reshape via canonical primitive (CLI parity). ---
|
|
600
681
|
if (patch.kind !== undefined || patch.mode !== undefined || patch.lifecycle !== undefined) {
|
|
601
682
|
if (patch.kind !== undefined)
|
|
602
|
-
assertKindServer(patch.kind);
|
|
683
|
+
await assertKindServer(patch.kind, meta.cwd, meta.profile_id ?? null);
|
|
603
684
|
const newKind = patch.kind ?? meta.kind;
|
|
604
685
|
const newMode = (patch.mode ?? meta.mode);
|
|
605
686
|
const newLifecycle = patch.lifecycle ?? meta.lifecycle;
|
|
@@ -615,7 +696,7 @@ export async function handleConfig(ctx, deps = {}) {
|
|
|
615
696
|
}
|
|
616
697
|
// Use canonical reshape primitive to preserve modelExact, provider pin/intent,
|
|
617
698
|
// and default-name migration.
|
|
618
|
-
meta =
|
|
699
|
+
meta = await reshapeNodeAsync(id, {
|
|
619
700
|
...(patch.kind !== undefined ? { kind: newKind } : {}),
|
|
620
701
|
...(patch.mode !== undefined ? { mode: newMode } : {}),
|
|
621
702
|
...(patch.lifecycle !== undefined ? { lifecycle: newLifecycle } : {}),
|
|
@@ -633,8 +714,8 @@ export async function handleConfig(ctx, deps = {}) {
|
|
|
633
714
|
}
|
|
634
715
|
/** Validate a `--kind` against the installed personas server-side (crtrd reads
|
|
635
716
|
* the merged launch config). Mirrors the CLI's assertKind. */
|
|
636
|
-
function assertKindServer(kind) {
|
|
637
|
-
const kinds = Object.keys(
|
|
717
|
+
async function assertKindServer(kind, cwd, profileId) {
|
|
718
|
+
const kinds = Object.keys((await readMergedLaunchConfigAsync(cwd, profileId)).kinds);
|
|
638
719
|
if (!kinds.includes(kind)) {
|
|
639
720
|
throw usage(`unknown kind: ${kind}`, { received: kind, next: `Valid kinds: ${kinds.sort().join(', ')}.` });
|
|
640
721
|
}
|
|
@@ -1,2 +1,6 @@
|
|
|
1
|
+
import type { ReportDTO } from '../../../api/index.js';
|
|
1
2
|
import type { RouteTable } from '../router.js';
|
|
3
|
+
/** Read one node's pushed reports, newest first. This stays in the daemon so
|
|
4
|
+
* collection projections can include report activity without client-side N+1s. */
|
|
5
|
+
export declare function reportsForNode(id: string): ReportDTO[];
|
|
2
6
|
export declare const reportRoutes: RouteTable;
|
|
@@ -110,12 +110,9 @@ function compactToIso(stamp) {
|
|
|
110
110
|
const m = stamp.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})$/);
|
|
111
111
|
return m === null ? stamp : `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}`;
|
|
112
112
|
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const tierFilter = ctx.query.get('tier');
|
|
117
|
-
const limitRaw = ctx.query.get('limit');
|
|
118
|
-
const limit = limitRaw !== null ? Number(limitRaw) : undefined;
|
|
113
|
+
/** Read one node's pushed reports, newest first. This stays in the daemon so
|
|
114
|
+
* collection projections can include report activity without client-side N+1s. */
|
|
115
|
+
export function reportsForNode(id) {
|
|
119
116
|
const dir = reportsDir(id);
|
|
120
117
|
let files;
|
|
121
118
|
try {
|
|
@@ -136,9 +133,18 @@ function handleList(ctx) {
|
|
|
136
133
|
(m ? compactToIso(m[1]) : new Date(statSync(abs).mtimeMs).toISOString());
|
|
137
134
|
reports.push({ path: abs, tier, body: body.trim(), created });
|
|
138
135
|
}
|
|
139
|
-
// Newest first, then apply the optional tier + limit filters.
|
|
140
136
|
reports.sort((a, b) => (a.created < b.created ? 1 : a.created > b.created ? -1 : 0));
|
|
141
|
-
|
|
137
|
+
return reports;
|
|
138
|
+
}
|
|
139
|
+
function handleList(ctx) {
|
|
140
|
+
const id = ctx.params['id'];
|
|
141
|
+
requireNode(id);
|
|
142
|
+
const tierFilter = ctx.query.get('tier');
|
|
143
|
+
const limitRaw = ctx.query.get('limit');
|
|
144
|
+
const limit = limitRaw !== null ? Number(limitRaw) : undefined;
|
|
145
|
+
let out = reportsForNode(id);
|
|
146
|
+
if (tierFilter !== null)
|
|
147
|
+
out = out.filter((report) => report.tier === tierFilter);
|
|
142
148
|
if (limit !== undefined && Number.isFinite(limit) && limit >= 0)
|
|
143
149
|
out = out.slice(0, limit);
|
|
144
150
|
return { status: 200, body: out };
|
package/dist/daemon/crtrd.js
CHANGED
|
@@ -62,7 +62,7 @@ import { StorageMaintenanceReconciler } from './reconcilers/storage-maintenance.
|
|
|
62
62
|
import { captureLivenessSnapshot, capturePidCommand, captureTeardownSnapshot, isPidAlive, killProcessTreePids, } from '../core/canvas/pid.js';
|
|
63
63
|
import { bindFleet, boundFleet } from '../core/runtime/fleet.js';
|
|
64
64
|
import { bindDaemonControl, clearDaemonControl } from './control.js';
|
|
65
|
-
import { spawnDaemon } from './manage.js';
|
|
65
|
+
import { execDaemon, spawnDaemon } from './manage.js';
|
|
66
66
|
import { reconcileAndMigrateBoot } from '../core/canvas/boot.js';
|
|
67
67
|
import { resolveInstallId } from '../core/canvas/install-id.js';
|
|
68
68
|
import { setInstallId } from '../core/runtime/nodes.js';
|
|
@@ -480,11 +480,10 @@ export async function runDaemon(opts = {}) {
|
|
|
480
480
|
}, HANDOVER_ACK_GRACE_MS);
|
|
481
481
|
return { graceMs: HANDOVER_ACK_GRACE_MS };
|
|
482
482
|
}
|
|
483
|
-
/** Quiesce exactly as a SIGTERM would, then
|
|
483
|
+
/** Quiesce exactly as a SIGTERM would, then start the successor.
|
|
484
484
|
* Ordering is forced: the successor's claim can only be won after this
|
|
485
|
-
* daemon has released its own
|
|
486
|
-
*
|
|
487
|
-
* picks up a generation this process never loaded. */
|
|
485
|
+
* daemon has released its own. PID 1 is replaced in place so a container
|
|
486
|
+
* does not stop between daemon generations; ordinary hosts spawn and exit. */
|
|
488
487
|
async function performHandover() {
|
|
489
488
|
try {
|
|
490
489
|
await requestCleanup();
|
|
@@ -495,6 +494,9 @@ export async function runDaemon(opts = {}) {
|
|
|
495
494
|
process.exit(1);
|
|
496
495
|
}
|
|
497
496
|
try {
|
|
497
|
+
if (process.pid === 1) {
|
|
498
|
+
await execDaemon();
|
|
499
|
+
}
|
|
498
500
|
const spawned = await spawnDaemon();
|
|
499
501
|
operationIdContext.fresh(() => {
|
|
500
502
|
emitEvent({
|
package/dist/daemon/manage.d.ts
CHANGED
|
@@ -36,6 +36,9 @@ export declare function findDaemonPids(): number[];
|
|
|
36
36
|
* reaped. Best-effort throughout: a pid that dies between enumeration and signal
|
|
37
37
|
* just no-ops. */
|
|
38
38
|
export declare function sweepStrayDaemons(keepPid: number | null): number[];
|
|
39
|
+
/** Replace PID 1 with the selected daemon generation. Docker keeps a container
|
|
40
|
+
* alive only while PID 1 lives, so a handover there must preserve its pid. */
|
|
41
|
+
export declare function execDaemon(): Promise<never>;
|
|
39
42
|
export interface SpawnDaemonResult {
|
|
40
43
|
/** True when a new daemon process was spawned. */
|
|
41
44
|
started: boolean;
|
package/dist/daemon/manage.js
CHANGED
|
@@ -190,6 +190,20 @@ export function sweepStrayDaemons(keepPid) {
|
|
|
190
190
|
}
|
|
191
191
|
return strays;
|
|
192
192
|
}
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
// daemon launch — low-level spawn/exec
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
/** Replace PID 1 with the selected daemon generation. Docker keeps a container
|
|
197
|
+
* alive only while PID 1 lives, so a handover there must preserve its pid. */
|
|
198
|
+
export async function execDaemon() {
|
|
199
|
+
const execve = process.execve;
|
|
200
|
+
if (execve === undefined) {
|
|
201
|
+
throw new Error(`this Node.js ${process.version} runtime cannot replace PID 1 with the successor daemon`);
|
|
202
|
+
}
|
|
203
|
+
const executable = hostExecPath();
|
|
204
|
+
const launchArgs = await resolveDaemonLaunch();
|
|
205
|
+
return execve(executable, [executable, ...launchArgs, '--canvas-home', crtrHome()], sanitizedDaemonEnv());
|
|
206
|
+
}
|
|
193
207
|
// Daemon lifecycle transitions are bounded so callers never claim a state
|
|
194
208
|
// change before the pidfile and process liveness agree.
|
|
195
209
|
//
|
|
@@ -4,9 +4,10 @@ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
|
4
4
|
* not open with a sleep (or its duration isn't statically knowable). */
|
|
5
5
|
export declare function leadingSleepSeconds(command: string): number | null;
|
|
6
6
|
/** The valve's BashOperations backend. Each tool execution closes over its own
|
|
7
|
-
* `takePurpose
|
|
8
|
-
*
|
|
9
|
-
|
|
7
|
+
* `takePurpose` and its own preview-result path, so concurrent calls cannot
|
|
8
|
+
* exchange labels or result mirrors. The purpose is consumed even when the
|
|
9
|
+
* command is invalid or refused, leaving no state to leak later. */
|
|
10
|
+
export declare function createValveOperations(nodeId: string, contextDir: string, takePurpose?: () => string | null, previewPath?: string): BashOperations;
|
|
10
11
|
type BashToolDefinition = ReturnType<typeof createBashToolDefinition>;
|
|
11
12
|
/** Build the valve-backed bash tool and, when configured, add `purpose` to its
|
|
12
13
|
* schema. Bind each purpose directly to its own execution: Pi may preflight a
|
|
@@ -27,8 +27,10 @@
|
|
|
27
27
|
import { spawn } from 'node:child_process';
|
|
28
28
|
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, rmSync, statSync, writeFileSync, } from 'node:fs';
|
|
29
29
|
import { homedir } from 'node:os';
|
|
30
|
+
import { dirname } from 'node:path';
|
|
30
31
|
import { backgroundBashJob, bashJobPaths, formatBashElapsed, newBashJobId, normalizeBashJobPurpose } from '../core/bash-jobs.js';
|
|
31
32
|
import { readConfig } from '../core/config.js';
|
|
33
|
+
import { PREVIEW_RESULT_PATH_ENV, previewResultPath } from '../core/preview-result-path.js';
|
|
32
34
|
import { resolveAdmittedHostCommand } from '../core/runtime/spawn-env.js';
|
|
33
35
|
import { Type } from 'typebox';
|
|
34
36
|
import { createBashToolDefinition } from '@earendil-works/pi-coding-agent';
|
|
@@ -218,9 +220,10 @@ function resolveExecutionCwd(cwd) {
|
|
|
218
220
|
};
|
|
219
221
|
}
|
|
220
222
|
/** The valve's BashOperations backend. Each tool execution closes over its own
|
|
221
|
-
* `takePurpose
|
|
222
|
-
*
|
|
223
|
-
|
|
223
|
+
* `takePurpose` and its own preview-result path, so concurrent calls cannot
|
|
224
|
+
* exchange labels or result mirrors. The purpose is consumed even when the
|
|
225
|
+
* command is invalid or refused, leaving no state to leak later. */
|
|
226
|
+
export function createValveOperations(nodeId, contextDir, takePurpose = () => null, previewPath) {
|
|
224
227
|
return {
|
|
225
228
|
exec: (command, cwd, { onData, signal, timeout, env }) => {
|
|
226
229
|
return new Promise((resolve, reject) => {
|
|
@@ -309,12 +312,20 @@ export function createValveOperations(nodeId, contextDir, takePurpose = () => nu
|
|
|
309
312
|
const execution = resolveExecutionCwd(cwd);
|
|
310
313
|
if (execution.warning)
|
|
311
314
|
onData(Buffer.from(execution.warning));
|
|
312
|
-
|
|
315
|
+
// The preview path reaches crtr through the environment, never through
|
|
316
|
+
// the command text: cmd.sh, job inspection, and the doc substrate's
|
|
317
|
+
// `command` surfaces all read that text as the agent's own words.
|
|
318
|
+
let childEnv = env ?? process.env;
|
|
319
|
+
if (previewPath !== undefined) {
|
|
320
|
+
mkdirSync(dirname(previewPath), { recursive: true });
|
|
321
|
+
childEnv = { ...childEnv, [PREVIEW_RESULT_PATH_ENV]: previewPath };
|
|
322
|
+
}
|
|
323
|
+
const shellPath = resolveAdmittedHostCommand('bash', childEnv, execution.cwd);
|
|
313
324
|
const child = spawn(shellPath, ['-c', SUPERVISOR_SCRIPT, '--', paths.cmdSh, paths.jobLog, paths.jobExit, paths.jobRun, paths.jobDone, paths.jobBg, paths.jobId, nodeId, shellPath], {
|
|
314
325
|
cwd: execution.cwd,
|
|
315
326
|
detached: true,
|
|
316
327
|
stdio: 'ignore',
|
|
317
|
-
env:
|
|
328
|
+
env: childEnv,
|
|
318
329
|
});
|
|
319
330
|
child.on('error', (err) => {
|
|
320
331
|
finish(() => reject(err));
|
|
@@ -418,7 +429,9 @@ export function createValveToolDefinition(nodeId, contextDir, cwd, exposePurpose
|
|
|
418
429
|
const schemaDefinition = exposePurpose ? withPurpose(baseDefinition) : baseDefinition;
|
|
419
430
|
const execute = (toolCallId, params, signal, onUpdate, ctx) => {
|
|
420
431
|
const purpose = exposePurpose ? normalizeBashJobPurpose(params['purpose']) : null;
|
|
421
|
-
const definition = createBashToolDefinition(cwd, {
|
|
432
|
+
const definition = createBashToolDefinition(cwd, {
|
|
433
|
+
operations: createValveOperations(nodeId, contextDir, () => purpose, previewResultPath(toolCallId)),
|
|
434
|
+
});
|
|
422
435
|
return definition.execute(toolCallId, params, signal, onUpdate, ctx);
|
|
423
436
|
};
|
|
424
437
|
return { ...schemaDefinition, execute };
|
|
@@ -1,17 +1,9 @@
|
|
|
1
|
-
interface ToolCallEvent {
|
|
2
|
-
toolName: string;
|
|
3
|
-
toolCallId: string;
|
|
4
|
-
input: {
|
|
5
|
-
command?: string;
|
|
6
|
-
};
|
|
7
|
-
}
|
|
8
1
|
interface ToolResultEvent {
|
|
9
2
|
toolName: string;
|
|
10
3
|
toolCallId: string;
|
|
11
4
|
details?: Record<string, unknown>;
|
|
12
5
|
}
|
|
13
6
|
interface PiLike {
|
|
14
|
-
on(event: 'tool_call', handler: (event: ToolCallEvent) => void): void;
|
|
15
7
|
on(event: 'tool_result', handler: (event: ToolResultEvent) => {
|
|
16
8
|
details: Record<string, unknown>;
|
|
17
9
|
} | void): void;
|