@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
|
@@ -21,75 +21,48 @@
|
|
|
21
21
|
import { getNode, updateNode } from '../canvas/index.js';
|
|
22
22
|
import { transition } from './lifecycle.js';
|
|
23
23
|
import { ensureDaemon } from '../../daemon/manage.js';
|
|
24
|
-
import { buildLaunchSpec } from './launch.js';
|
|
24
|
+
import { buildLaunchSpec, buildLaunchSpecAsync } from './launch.js';
|
|
25
25
|
import { hasRoadmap, seedRoadmap, roadmapPath } from './roadmap.js';
|
|
26
26
|
import { readGoal, goalPath } from './kickoff.js';
|
|
27
|
-
|
|
28
|
-
* caller supplies them, mode/lifecycle — by rebuilding the launch spec so the
|
|
29
|
-
* change is durable across a future revive. MODE-PRESERVING by default (omit
|
|
30
|
-
* `mode`/`lifecycle` and the node's own current values carry through) — this
|
|
31
|
-
* is what a plain `crtr node yield --kind/--model` reshape rides, with no
|
|
32
|
-
* mode flip and no roadmap seeding. `promote()` below is a thin wrapper that
|
|
33
|
-
* also forces mode→orchestrator and seeds the roadmap. */
|
|
34
|
-
export function reshapeNode(nodeId, opts = {}, beforePersist) {
|
|
27
|
+
function reshapeInputs(nodeId, opts) {
|
|
35
28
|
const node = getNode(nodeId);
|
|
36
29
|
if (node === null)
|
|
37
30
|
throw new Error(`unknown node: ${nodeId}`);
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
// default and is persisted below; absent one, the existing pin carries
|
|
55
|
-
// across the polymorph (the persona default is recomputed fresh for
|
|
56
|
-
// targetKind).
|
|
57
|
-
model: targetModel,
|
|
58
|
-
// opts.model given — an explicit new pick — lets buildLaunchSpec re-decide
|
|
59
|
-
// the pin from ITS raw shape (undefined here). No opts.model — targetModel
|
|
60
|
-
// just re-supplies the current value for resolution, not a new user
|
|
61
|
-
// choice — so carry the node's existing pin verbatim instead.
|
|
62
|
-
modelProviderPinned: opts.model === undefined ? node.launch?.modelProviderPinned : undefined,
|
|
63
|
-
modelIntent: opts.model === undefined ? node.launch?.modelIntent : undefined,
|
|
64
|
-
// Exact-model human-work forks must stay fail-closed after a polymorph.
|
|
65
|
-
// Dropping this bit produces a recipe reviveNode refuses to relaunch.
|
|
66
|
-
modelExact: opts.model === undefined ? node.launch?.modelExact : undefined,
|
|
67
|
-
// Resolve kind/ladder config against the NODE's own cwd + profile, not
|
|
68
|
-
// this process's ambient scope.
|
|
69
|
-
cwd: node.cwd,
|
|
70
|
-
profileId: targetProfileId,
|
|
71
|
-
});
|
|
72
|
-
// Promotion seeds after its target launch has been built but before the
|
|
73
|
-
// mutation is persisted, preserving the original promote() failure order.
|
|
31
|
+
const kind = opts.kind ?? node.kind;
|
|
32
|
+
const mode = opts.mode ?? node.mode;
|
|
33
|
+
const lifecycle = opts.lifecycle ?? node.lifecycle;
|
|
34
|
+
const profileId = opts.profileId === undefined ? node.profile_id ?? null : opts.profileId;
|
|
35
|
+
return {
|
|
36
|
+
node, kind, mode, lifecycle, profileId,
|
|
37
|
+
launchOpts: {
|
|
38
|
+
lifecycle, hasManager: node.parent !== null, model: opts.model ?? node.model_override ?? undefined,
|
|
39
|
+
modelProviderPinned: opts.model === undefined ? node.launch?.modelProviderPinned : undefined,
|
|
40
|
+
modelIntent: opts.model === undefined ? node.launch?.modelIntent : undefined,
|
|
41
|
+
modelExact: opts.model === undefined ? node.launch?.modelExact : undefined,
|
|
42
|
+
cwd: node.cwd, profileId,
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
function persistReshape(nodeId, opts, input, launch, beforePersist) {
|
|
74
47
|
beforePersist?.();
|
|
75
|
-
|
|
76
|
-
// carry it to the new kind so the "name === kind ⇒ default, drop it" invariant
|
|
77
|
-
// in fullName() holds across a kind change. Otherwise a node spawned `general`
|
|
78
|
-
// (name="general") that reshapes to `plan` would surface a stale "general"
|
|
79
|
-
// handle in front of its real description.
|
|
80
|
-
const carriesDefaultHandle = node.name === node.kind && targetKind !== node.kind;
|
|
48
|
+
const carriesDefaultHandle = input.node.name === input.node.kind && input.kind !== input.node.kind;
|
|
81
49
|
return updateNode(nodeId, {
|
|
82
|
-
kind:
|
|
83
|
-
|
|
84
|
-
lifecycle: targetLifecycle,
|
|
85
|
-
profile_id: targetProfileId,
|
|
86
|
-
launch,
|
|
87
|
-
...(carriesDefaultHandle ? { name: targetKind } : {}),
|
|
88
|
-
// Persist a newly-chosen tier so it is durable across future revives; omit
|
|
89
|
-
// when unchanged so the existing pin (or persona default) stands.
|
|
50
|
+
kind: input.kind, mode: input.mode, lifecycle: input.lifecycle, profile_id: input.profileId, launch,
|
|
51
|
+
...(carriesDefaultHandle ? { name: input.kind } : {}),
|
|
90
52
|
...(opts.model !== undefined ? { model_override: opts.model } : {}),
|
|
91
53
|
});
|
|
92
54
|
}
|
|
55
|
+
/** Synchronous reshape for CLI callers outside the daemon. */
|
|
56
|
+
export function reshapeNode(nodeId, opts = {}, beforePersist) {
|
|
57
|
+
const input = reshapeInputs(nodeId, opts);
|
|
58
|
+
return persistReshape(nodeId, opts, input, buildLaunchSpec(input.kind, input.mode, input.launchOpts).launch, beforePersist);
|
|
59
|
+
}
|
|
60
|
+
/** Daemon-safe reshape: network-backed config resolution happens off its event loop. */
|
|
61
|
+
export async function reshapeNodeAsync(nodeId, opts = {}, beforePersist) {
|
|
62
|
+
const input = reshapeInputs(nodeId, opts);
|
|
63
|
+
const { launch } = await buildLaunchSpecAsync(input.kind, input.mode, input.launchOpts);
|
|
64
|
+
return persistReshape(nodeId, opts, input, launch, beforePersist);
|
|
65
|
+
}
|
|
93
66
|
/** Promote a node to an orchestrator (mode→orchestrator), optionally
|
|
94
67
|
* specializing its kind (e.g. a `general` worker becoming a
|
|
95
68
|
* `developer.orchestrator`) and optionally also making it resident. Idempotent:
|
|
@@ -129,6 +102,25 @@ export function promote(nodeId, opts = {}) {
|
|
|
129
102
|
goalPath: goalPath(nodeId),
|
|
130
103
|
};
|
|
131
104
|
}
|
|
105
|
+
/** Daemon-safe promotion variant; preserves the same validation/seed ordering. */
|
|
106
|
+
export async function promoteAsync(nodeId, opts = {}) {
|
|
107
|
+
const node = getNode(nodeId);
|
|
108
|
+
if (node === null)
|
|
109
|
+
throw new Error(`unknown node: ${nodeId}`);
|
|
110
|
+
let roadmapWritten = false;
|
|
111
|
+
const meta = await reshapeNodeAsync(nodeId, {
|
|
112
|
+
...(opts.kind !== undefined ? { kind: opts.kind } : {}),
|
|
113
|
+
...(opts.model !== undefined ? { model: opts.model } : {}),
|
|
114
|
+
mode: 'orchestrator', lifecycle: opts.resident === true ? 'resident' : node.lifecycle,
|
|
115
|
+
}, () => {
|
|
116
|
+
if (!hasRoadmap(nodeId)) {
|
|
117
|
+
const goal = readGoal(nodeId);
|
|
118
|
+
seedRoadmap(nodeId, goal !== null && goal.trim() !== '' ? { goal: goal.trim() } : {});
|
|
119
|
+
roadmapWritten = true;
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
return { meta, roadmapWritten, roadmapPath: roadmapPath(nodeId), goalPath: goalPath(nodeId) };
|
|
123
|
+
}
|
|
132
124
|
/** Request a refresh-yield: discard in-memory context and revive fresh, mode
|
|
133
125
|
* and lifecycle UNCHANGED, against the roadmap if one exists else the
|
|
134
126
|
* original goal (`buildReviveKickoff` falls back to it). A plain yield is
|
|
@@ -138,6 +130,18 @@ export function promote(nodeId, opts = {}) {
|
|
|
138
130
|
* same path `crtr node promote` would — mode flips to orchestrator and a
|
|
139
131
|
* roadmap scaffold is seeded. Sets intent='refresh'; the stophook shuts the
|
|
140
132
|
* process down on the next stop and the daemon revives it fresh. */
|
|
133
|
+
export async function requestYieldAsync(nodeId, opts = {}, deps = {}) {
|
|
134
|
+
const node = getNode(nodeId);
|
|
135
|
+
if (node === null)
|
|
136
|
+
throw new Error(`unknown node: ${nodeId}`);
|
|
137
|
+
if (opts.promote === true) {
|
|
138
|
+
await promoteAsync(nodeId, { ...(opts.kind !== undefined ? { kind: opts.kind } : {}), ...(opts.model !== undefined ? { model: opts.model } : {}) });
|
|
139
|
+
}
|
|
140
|
+
else if (opts.kind !== undefined || opts.model !== undefined) {
|
|
141
|
+
await reshapeNodeAsync(nodeId, { ...(opts.kind !== undefined ? { kind: opts.kind } : {}), ...(opts.model !== undefined ? { model: opts.model } : {}) });
|
|
142
|
+
}
|
|
143
|
+
return { ...requestYield(nodeId, {}, deps), promoted: opts.promote === true };
|
|
144
|
+
}
|
|
141
145
|
export function requestYield(nodeId, opts = {}, deps = {}) {
|
|
142
146
|
const ensure = deps.ensure ?? ensureDaemon;
|
|
143
147
|
const node = getNode(nodeId);
|
|
@@ -21,7 +21,7 @@ import { getNode, setPresence, setFocusOccupant, fullName } from '../canvas/inde
|
|
|
21
21
|
import { reportsDir } from '../canvas/paths.js';
|
|
22
22
|
import { pushFinal } from '../feed/feed.js';
|
|
23
23
|
import { spawnNode, rootOfSpine } from './nodes.js';
|
|
24
|
-
import {
|
|
24
|
+
import { buildLaunchSpecAsync, buildPiArgv } from './launch.js';
|
|
25
25
|
import { focusOf, respawnPaneSync, setPaneOption } from './placement.js';
|
|
26
26
|
import { waitForBrokerViewSocket, viewerSplitEnv } from './placement-tmux.js';
|
|
27
27
|
import { headlessBrokerHost } from './host.js';
|
|
@@ -102,7 +102,7 @@ export async function recycleNode(nodeId, callerPane) {
|
|
|
102
102
|
// ambient scope) and carry the profile onto the fresh root — it is the same
|
|
103
103
|
// person's seat, so profile-scope defaults (e.g. a persisted /model default)
|
|
104
104
|
// must keep applying.
|
|
105
|
-
const { launch } =
|
|
105
|
+
const { launch } = await buildLaunchSpecAsync('general', 'base', { lifecycle: 'resident', hasManager: false, cwd: meta.cwd, profileId: meta.profile_id });
|
|
106
106
|
const root = spawnNode({
|
|
107
107
|
kind: 'general',
|
|
108
108
|
mode: 'base',
|
|
@@ -25,7 +25,7 @@ import { transition } from './lifecycle.js';
|
|
|
25
25
|
import { headlessBrokerHost } from './host.js';
|
|
26
26
|
import { tearDownNode, focusOf, registerViewerFocus, respawnPaneSync, windowOfPane, renameWindow, } from './placement.js';
|
|
27
27
|
import { viewerSplitEnv, waitForBrokerViewSocket } from './placement-tmux.js';
|
|
28
|
-
import {
|
|
28
|
+
import { buildLaunchSpecAsync, buildPiArgv } from './launch.js';
|
|
29
29
|
import { spawnNode, rootOfSpine } from './nodes.js';
|
|
30
30
|
// ---------------------------------------------------------------------------
|
|
31
31
|
// reapDescendants — tear down a root's descendant sub-DAG (shared helper)
|
|
@@ -83,7 +83,7 @@ export async function relaunchRoot(oldId, deps = {}) {
|
|
|
83
83
|
// --- mint + boot the NEW broker FIRST (a failure here leaves the old root
|
|
84
84
|
// untouched) ---
|
|
85
85
|
// A relaunched root is a fresh resident base, exactly like the front door.
|
|
86
|
-
const { launch } =
|
|
86
|
+
const { launch } = await buildLaunchSpecAsync(old.kind, 'base', {
|
|
87
87
|
lifecycle: 'resident',
|
|
88
88
|
hasManager: false,
|
|
89
89
|
model: old.model_override ?? undefined,
|
|
@@ -14,7 +14,7 @@ import { isAbsolute, resolve, join } from 'node:path';
|
|
|
14
14
|
import { spawnNode, currentNodeContext, rootOfSpine, newNodeId, preflightNodeId } from './nodes.js';
|
|
15
15
|
import { loadProfileManifest } from '../profiles/manifest.js';
|
|
16
16
|
import { selectProfileForCwd } from '../profiles/select.js';
|
|
17
|
-
import {
|
|
17
|
+
import { buildLaunchSpecAsync, buildPiArgv } from './launch.js';
|
|
18
18
|
import { createManagedWorktree, rollbackManagedWorktree } from '../worktree.js';
|
|
19
19
|
import { usage, brokerLaunchFailed } from '../errors.js';
|
|
20
20
|
import { writeGoal } from './kickoff.js';
|
|
@@ -237,7 +237,7 @@ export async function spawnChildPrepared(opts, beforeBrokerLaunch) {
|
|
|
237
237
|
if (opts.nodeId !== undefined)
|
|
238
238
|
preflightNodeId(opts.nodeId);
|
|
239
239
|
const profileId = await resolveProfileId(opts.profile, spawner, opts.cwd);
|
|
240
|
-
const preflightLaunch =
|
|
240
|
+
const preflightLaunch = (await buildLaunchSpecAsync(opts.kind, mode, { lifecycle, hasManager: !root, model: opts.model, cwd: opts.cwd, profileId })).launch;
|
|
241
241
|
await assertLaunchModelRegistered(requestFromLaunch(preflightLaunch), { cwd: opts.cwd, profileId });
|
|
242
242
|
const forkFrom = opts.forkFrom !== undefined ? resolveForkSource(opts.forkFrom) : undefined;
|
|
243
243
|
let managedWorktree;
|
|
@@ -255,7 +255,7 @@ export async function spawnChildPrepared(opts, beforeBrokerLaunch) {
|
|
|
255
255
|
// this process's ambient scope (a front-door shell has no CRTR_PROFILE_ID,
|
|
256
256
|
// which silently hid every profile-scope kind override).
|
|
257
257
|
const launch = wantsWorktree
|
|
258
|
-
?
|
|
258
|
+
? (await buildLaunchSpecAsync(opts.kind, mode, { lifecycle, hasManager: !root, model: opts.model, cwd: spawnCwd, profileId })).launch
|
|
259
259
|
: preflightLaunch;
|
|
260
260
|
if (wantsWorktree) {
|
|
261
261
|
await assertLaunchModelRegistered(requestFromLaunch(launch), { cwd: spawnCwd, profileId });
|
|
@@ -62,7 +62,7 @@ import { claimWarmSpare, deleteNode, getNode, listNodes, listWarmSpares, registe
|
|
|
62
62
|
import { recordedPidLiveness } from '../canvas/pid.js';
|
|
63
63
|
import { nowIso } from '../fs-utils.js';
|
|
64
64
|
import { spawnChildPrepared } from './spawn.js';
|
|
65
|
-
import {
|
|
65
|
+
import { buildLaunchSpecAsync } from './launch.js';
|
|
66
66
|
import { setModelLive } from './model-swap.js';
|
|
67
67
|
import { setSituationalContextLive } from './situational-live.js';
|
|
68
68
|
import { deliverBearingsLive } from './deliver-live.js';
|
|
@@ -100,8 +100,8 @@ function warmRecipeKey(cwd, profileId, launch) {
|
|
|
100
100
|
}
|
|
101
101
|
/** Resolve a request's frozen half. Both cwd and profile arrive already
|
|
102
102
|
* resolved from the create handler, so this is pure launch-spec construction. */
|
|
103
|
-
function resolveRecipe(request) {
|
|
104
|
-
const { launch } =
|
|
103
|
+
async function resolveRecipe(request) {
|
|
104
|
+
const { launch } = await buildLaunchSpecAsync(request.kind, request.mode, {
|
|
105
105
|
lifecycle: 'resident',
|
|
106
106
|
hasManager: false,
|
|
107
107
|
...(request.model !== null ? { model: request.model } : {}),
|
|
@@ -114,7 +114,7 @@ function resolveRecipe(request) {
|
|
|
114
114
|
* pool cannot serve it. The caller falls back to a cold spawn; a claim never
|
|
115
115
|
* fails the create. */
|
|
116
116
|
export async function claimWarmNode(request) {
|
|
117
|
-
const recipe = resolveRecipe(request);
|
|
117
|
+
const recipe = await resolveRecipe(request);
|
|
118
118
|
const created = nowIso();
|
|
119
119
|
// The row's stamp moves inside the claim transaction; meta.json (the identity
|
|
120
120
|
// source of truth `rebuildIndex` re-derives the row from) follows with the
|
|
@@ -261,7 +261,9 @@ export function refillWarmPool(request) {
|
|
|
261
261
|
// Mint at the kind's DEFAULT model and with NO situational context: both
|
|
262
262
|
// are claim-time properties, so baking either in would only narrow who the
|
|
263
263
|
// spare can serve.
|
|
264
|
-
|
|
264
|
+
void resolveRecipe({ ...request, model: null, situationalContext: null })
|
|
265
|
+
.then(mint)
|
|
266
|
+
.catch((err) => console.error(`[warm-pool] recipe resolution failed: ${err.message}`));
|
|
265
267
|
}
|
|
266
268
|
catch (err) {
|
|
267
269
|
console.error(`[warm-pool] recipe resolution failed: ${err.message}`);
|
|
@@ -349,7 +351,7 @@ export function prewarmRecentRecipes() {
|
|
|
349
351
|
// `general`/`base` is only the shape the spare BOOTS as — the key it is
|
|
350
352
|
// filed under excludes everything a claim can reshape, so any kind can
|
|
351
353
|
// claim it.
|
|
352
|
-
|
|
354
|
+
void resolveRecipe({
|
|
353
355
|
kind: 'general',
|
|
354
356
|
mode: 'base',
|
|
355
357
|
cwd: candidate.cwd,
|
|
@@ -357,7 +359,7 @@ export function prewarmRecentRecipes() {
|
|
|
357
359
|
model: null,
|
|
358
360
|
situationalContext: null,
|
|
359
361
|
name: null,
|
|
360
|
-
}));
|
|
362
|
+
}).then(mint).catch((err) => console.error(`[warm-pool] prewarm failed for ${candidate.cwd}: ${err.message}`));
|
|
361
363
|
}
|
|
362
364
|
catch (err) {
|
|
363
365
|
console.error(`[warm-pool] prewarm failed for ${candidate.cwd}: ${err.message}`);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Strict validation of one raw routing entry. Runtime parsing deliberately
|
|
2
|
+
* tolerates malformed docs; this authoring/preflight contract does not. */
|
|
3
|
+
export declare function lintSubstrateSurfaces(value: unknown): string | null;
|
|
4
|
+
/** Strict substrate frontmatter checker shared by lint and package preflight. */
|
|
5
|
+
export declare function lintSubstrateFrontmatter(fm: Record<string, unknown> | null): string | null;
|
|
6
|
+
/** Compatibility name for consumers that adopted lint's former export. */
|
|
7
|
+
export declare const lintSubstrateSchema: typeof lintSubstrateFrontmatter;
|
|
8
|
+
/** Parsed routing entries for checks that run after strict validation. */
|
|
9
|
+
export declare function parsedSubstrateSurfaces(fm: Record<string, unknown>): {
|
|
10
|
+
on: string;
|
|
11
|
+
at: string;
|
|
12
|
+
match?: string[];
|
|
13
|
+
}[];
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { isDocKind, parseSubstrateFrontmatter, SURFACE_EVENTS, SURFACE_RUNGS } from './schema.js';
|
|
2
|
+
const RETIRED_FIELDS = ['system-prompt-visibility', 'file-read-visibility', 'applies-to', 'read-when'];
|
|
3
|
+
const SURFACE_ENTRY_KEYS = ['on', 'match', 'match-frontmatter', 'at'];
|
|
4
|
+
const SUPPRESSIBLE_RULES = ['length', 'broad-memory-read'];
|
|
5
|
+
/** Strict validation of one raw routing entry. Runtime parsing deliberately
|
|
6
|
+
* tolerates malformed docs; this authoring/preflight contract does not. */
|
|
7
|
+
export function lintSubstrateSurfaces(value) {
|
|
8
|
+
if (value === undefined)
|
|
9
|
+
return null;
|
|
10
|
+
if (!Array.isArray(value))
|
|
11
|
+
return `invalid surfaces: ${JSON.stringify(value)} (expected a list of {on, at, match?, match-frontmatter?} entries)`;
|
|
12
|
+
for (const raw of value) {
|
|
13
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
14
|
+
return `invalid surfaces entry: ${JSON.stringify(raw)} (expected an {on, at, match?, match-frontmatter?} object)`;
|
|
15
|
+
}
|
|
16
|
+
const entry = raw;
|
|
17
|
+
for (const key of Object.keys(entry)) {
|
|
18
|
+
if (!SURFACE_ENTRY_KEYS.includes(key)) {
|
|
19
|
+
return `invalid surfaces entry: unknown key \`${key}\` (an entry carries only on, match, match-frontmatter, at)`;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const on = entry['on'];
|
|
23
|
+
if (typeof on !== 'string' || !SURFACE_EVENTS.includes(on)) {
|
|
24
|
+
return `invalid surfaces entry \`on\`: ${JSON.stringify(on)} (expected ${SURFACE_EVENTS.join('|')})`;
|
|
25
|
+
}
|
|
26
|
+
const at = entry['at'];
|
|
27
|
+
if (typeof at !== 'string' || !SURFACE_RUNGS.includes(at)) {
|
|
28
|
+
return `invalid surfaces entry \`at\`: ${JSON.stringify(at)} (expected ${SURFACE_RUNGS.join('|')})`;
|
|
29
|
+
}
|
|
30
|
+
let hasMatch = false;
|
|
31
|
+
if (entry['match'] !== undefined) {
|
|
32
|
+
if (on === 'boot' || on === 'workspace-open') {
|
|
33
|
+
return `invalid surfaces entry: \`match\` is meaningless on \`${on}\` — the entry's presence is the match; drop it`;
|
|
34
|
+
}
|
|
35
|
+
const match = entry['match'];
|
|
36
|
+
const globs = typeof match === 'string' ? [match] : Array.isArray(match) && match.every((glob) => typeof glob === 'string') ? match : null;
|
|
37
|
+
if (globs === null || globs.length === 0 || globs.some((glob) => glob.trim() === '')) {
|
|
38
|
+
return `invalid surfaces entry \`match\`: ${JSON.stringify(match)} (expected a non-empty glob or non-empty glob list)`;
|
|
39
|
+
}
|
|
40
|
+
hasMatch = true;
|
|
41
|
+
}
|
|
42
|
+
const matchFrontmatter = entry['match-frontmatter'];
|
|
43
|
+
if (matchFrontmatter !== undefined) {
|
|
44
|
+
if (on !== 'read')
|
|
45
|
+
return 'invalid surfaces entry: `match-frontmatter` is a `read`-event predicate only';
|
|
46
|
+
if (matchFrontmatter === null || typeof matchFrontmatter !== 'object' || Array.isArray(matchFrontmatter)) {
|
|
47
|
+
return `invalid surfaces entry \`match-frontmatter\`: ${JSON.stringify(matchFrontmatter)} (expected a field→matcher object)`;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if ((on === 'read' || on === 'memory-read' || on === 'command') && !hasMatch && matchFrontmatter === undefined) {
|
|
51
|
+
return `invalid surfaces entry: a \`${on}\` entry requires \`match\`${on === 'read' ? ' or `match-frontmatter`' : ''}`;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
/** Strict substrate frontmatter checker shared by lint and package preflight. */
|
|
57
|
+
export function lintSubstrateFrontmatter(fm) {
|
|
58
|
+
if (fm === null)
|
|
59
|
+
return 'missing frontmatter: a memory store doc requires `kind: knowledge|preference`';
|
|
60
|
+
if (!isDocKind(fm.kind))
|
|
61
|
+
return `invalid kind: ${JSON.stringify(fm.kind)} (expected knowledge|preference)`;
|
|
62
|
+
if ('when' in fm || 'why' in fm) {
|
|
63
|
+
return 'retired `when`/`why` keys: merge them into one `when-and-why-to-read` line — "When <circumstance>, this <kind> should be read because <broader downstream payoff>." WHY is the benefit unlocked by reading, not the document summary, rule, or obedience rationale.';
|
|
64
|
+
}
|
|
65
|
+
if (typeof fm['when-and-why-to-read'] !== 'string' || fm['when-and-why-to-read'].trim() === '') {
|
|
66
|
+
return 'missing `when-and-why-to-read`: one routing line — "When <circumstance>, this <kind> should be read because <broader downstream payoff>." WHY is the benefit unlocked by reading, not the document summary, rule, or obedience rationale.';
|
|
67
|
+
}
|
|
68
|
+
for (const field of RETIRED_FIELDS) {
|
|
69
|
+
if (field in fm) {
|
|
70
|
+
return `retired \`${field}\` key: the visibility axes were replaced by \`surfaces\` event routing — run \`crtr sys migrate\` to convert old-format docs, or author \`surfaces\` entries by hand (\`crtr memory write -h\`)`;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (fm['unlisted'] !== undefined && typeof fm['unlisted'] !== 'boolean') {
|
|
74
|
+
return `invalid unlisted: ${JSON.stringify(fm['unlisted'])} (expected a boolean)`;
|
|
75
|
+
}
|
|
76
|
+
const surfacesError = lintSubstrateSurfaces(fm['surfaces']);
|
|
77
|
+
if (surfacesError !== null)
|
|
78
|
+
return surfacesError;
|
|
79
|
+
if (fm.gate !== undefined && (fm.gate === null || typeof fm.gate !== 'object' || Array.isArray(fm.gate))) {
|
|
80
|
+
return `invalid gate: ${JSON.stringify(fm.gate)} (expected a field→matcher object)`;
|
|
81
|
+
}
|
|
82
|
+
if (fm.slash !== undefined && typeof fm.slash !== 'boolean') {
|
|
83
|
+
return `invalid slash: ${JSON.stringify(fm.slash)} (expected a boolean)`;
|
|
84
|
+
}
|
|
85
|
+
if (fm.rationale !== undefined && typeof fm.rationale !== 'string') {
|
|
86
|
+
return `invalid rationale: ${JSON.stringify(fm.rationale)} (expected a string)`;
|
|
87
|
+
}
|
|
88
|
+
if (fm['lint-ignore'] !== undefined) {
|
|
89
|
+
const rules = Array.isArray(fm['lint-ignore']) ? fm['lint-ignore'] : [fm['lint-ignore']];
|
|
90
|
+
if (rules.length === 0 || !rules.every((rule) => typeof rule === 'string' && SUPPRESSIBLE_RULES.includes(rule))) {
|
|
91
|
+
return `invalid lint-ignore: ${JSON.stringify(fm['lint-ignore'])} (suppressible rules: ${SUPPRESSIBLE_RULES.map((rule) => `\`${rule}\``).join(', ')})`;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
/** Compatibility name for consumers that adopted lint's former export. */
|
|
97
|
+
export const lintSubstrateSchema = lintSubstrateFrontmatter;
|
|
98
|
+
/** Parsed routing entries for checks that run after strict validation. */
|
|
99
|
+
export function parsedSubstrateSurfaces(fm) {
|
|
100
|
+
return parseSubstrateFrontmatter(fm)?.surfaces ?? [];
|
|
101
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { KINDS, isDocKind, RUNGS, rungRank, rungAtLeast, SURFACE_EVENTS, SURFACE_RUNGS, bootRung, parseSubstrateFrontmatter, parseSubstrateDoc, previewLine, normalizeNameSegment, normalizeDocName, resolveDocName, } from './schema.js';
|
|
2
2
|
export type { DocKind, Rung, GatePredicate, SubstrateSchema, SubstrateDoc, SurfaceEntry, SurfaceEvent, SurfaceRung } from './schema.js';
|
|
3
|
+
export { lintSubstrateFrontmatter, lintSubstrateSchema, lintSubstrateSurfaces, parsedSubstrateSurfaces } from './frontmatter-validation.js';
|
|
3
4
|
export { matchesReadEntry, matchesMemoryReadEntry, matchesCommandEntry, readDeliveryRung, workspaceOpenRung, memoryReadDeliveryRung, commandDeliveryRung, owningRootOf, underOwningRoot } from './surface-match.js';
|
|
4
5
|
export { dirDedupKey, docsByName, isDirName, ancestorDirsOf, renderDirListing } from './listings.js';
|
|
5
6
|
export { deliveredAtOrAbove, recordDelivery, mergeInjectedDocs } from './injected-store.js';
|
|
@@ -16,6 +16,7 @@ parseSubstrateFrontmatter, parseSubstrateDoc, previewLine,
|
|
|
16
16
|
normalizeNameSegment, normalizeDocName,
|
|
17
17
|
// substrate identity (explicit-name-then-path-fallback)
|
|
18
18
|
resolveDocName, } from './schema.js';
|
|
19
|
+
export { lintSubstrateFrontmatter, lintSubstrateSchema, lintSubstrateSurfaces, parsedSubstrateSurfaces } from './frontmatter-validation.js';
|
|
19
20
|
export { matchesReadEntry, matchesMemoryReadEntry, matchesCommandEntry, readDeliveryRung, workspaceOpenRung, memoryReadDeliveryRung, commandDeliveryRung, owningRootOf, underOwningRoot } from './surface-match.js';
|
|
20
21
|
export { dirDedupKey, docsByName, isDirName, ancestorDirsOf, renderDirListing } from './listings.js';
|
|
21
22
|
export { deliveredAtOrAbove, recordDelivery, mergeInjectedDocs } from './injected-store.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
3
|
+
import test from 'node:test';
|
|
4
|
+
import { startRemoteAttachHeartbeat } from '../bridge.js';
|
|
5
|
+
class FakeSocket extends EventEmitter {
|
|
6
|
+
pings = 0;
|
|
7
|
+
terminated = 0;
|
|
8
|
+
ping() { this.pings += 1; }
|
|
9
|
+
terminate() { this.terminated += 1; }
|
|
10
|
+
}
|
|
11
|
+
function sleep(ms) {
|
|
12
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
13
|
+
}
|
|
14
|
+
test('remote attach heartbeat pings an idle viewer and keeps a ponging observer alive', async () => {
|
|
15
|
+
const ws = new FakeSocket();
|
|
16
|
+
const stop = startRemoteAttachHeartbeat(ws, 20);
|
|
17
|
+
await sleep(25);
|
|
18
|
+
assert.equal(ws.pings, 1, 'an idle attach receives a WebSocket ping before proxy idle timeout');
|
|
19
|
+
ws.emit('pong');
|
|
20
|
+
await sleep(10);
|
|
21
|
+
assert.equal(ws.terminated, 0, 'a pong renews the heartbeat without terminating the observer');
|
|
22
|
+
stop();
|
|
23
|
+
});
|
|
24
|
+
test('remote attach heartbeat drops only an unresponsive observer socket', async () => {
|
|
25
|
+
const ws = new FakeSocket();
|
|
26
|
+
const stop = startRemoteAttachHeartbeat(ws, 5);
|
|
27
|
+
await sleep(16);
|
|
28
|
+
assert.equal(ws.terminated, 1, 'two missed heartbeat intervals terminate the detached viewer');
|
|
29
|
+
stop();
|
|
30
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { after, before, beforeEach, test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { createNode } from '../../../core/canvas/canvas.js';
|
|
7
|
+
import { closeDb } from '../../../core/canvas/db.js';
|
|
8
|
+
import { reportsDir } from '../../../core/canvas/paths.js';
|
|
9
|
+
import { invalidateTicketCounts } from '../../../core/canvas/attention.js';
|
|
10
|
+
import { bindTicketReplyRoute } from '../../../core/human/convention.js';
|
|
11
|
+
import { ticketDir } from '../../../core/human/root.js';
|
|
12
|
+
import { submitPage } from '../../../core/human/tickets.js';
|
|
13
|
+
import { handleList } from '../handlers/nodes.js';
|
|
14
|
+
const previousCrtrHome = process.env['CRTR_HOME'];
|
|
15
|
+
let home = '';
|
|
16
|
+
before(() => {
|
|
17
|
+
closeDb();
|
|
18
|
+
home = mkdtempSync(join(tmpdir(), 'crtr-nodes-activity-query-'));
|
|
19
|
+
process.env['CRTR_HOME'] = home;
|
|
20
|
+
});
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
closeDb();
|
|
23
|
+
invalidateTicketCounts();
|
|
24
|
+
rmSync(home, { recursive: true, force: true });
|
|
25
|
+
mkdirSync(home, { recursive: true });
|
|
26
|
+
});
|
|
27
|
+
after(() => {
|
|
28
|
+
closeDb();
|
|
29
|
+
rmSync(home, { recursive: true, force: true });
|
|
30
|
+
if (previousCrtrHome === undefined)
|
|
31
|
+
delete process.env['CRTR_HOME'];
|
|
32
|
+
else
|
|
33
|
+
process.env['CRTR_HOME'] = previousCrtrHome;
|
|
34
|
+
});
|
|
35
|
+
function node(id, overrides = {}) {
|
|
36
|
+
return {
|
|
37
|
+
node_id: id,
|
|
38
|
+
name: id,
|
|
39
|
+
created: '2026-08-17T00:00:00.000Z',
|
|
40
|
+
cwd: home,
|
|
41
|
+
kind: 'developer',
|
|
42
|
+
mode: 'base',
|
|
43
|
+
lifecycle: 'terminal',
|
|
44
|
+
status: 'active',
|
|
45
|
+
parent: null,
|
|
46
|
+
profile_id: 'activity-profile-7f3a9c01',
|
|
47
|
+
launch: { model: 'anthropic/original', extensions: [], env: {} },
|
|
48
|
+
...overrides,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function list(query) {
|
|
52
|
+
const result = handleList({
|
|
53
|
+
method: 'GET',
|
|
54
|
+
path: '/v1/nodes',
|
|
55
|
+
params: {},
|
|
56
|
+
query: new URLSearchParams(query),
|
|
57
|
+
body: null,
|
|
58
|
+
});
|
|
59
|
+
assert.equal(result.status, 200);
|
|
60
|
+
return result.body;
|
|
61
|
+
}
|
|
62
|
+
function report(nodeId, stamp, tier, body) {
|
|
63
|
+
const filename = `${stamp}-${tier}.md`;
|
|
64
|
+
writeFileSync(join(reportsDir(nodeId), filename), `---\nkind: ${tier}\nts: ${stamp}\n---\n${body}\n`);
|
|
65
|
+
return filename;
|
|
66
|
+
}
|
|
67
|
+
test('list activity query composes profile, lifecycle, topology filters and report enrichment', () => {
|
|
68
|
+
const finalName = '20260817T100000-final.md';
|
|
69
|
+
createNode(node('root', {
|
|
70
|
+
status: 'done',
|
|
71
|
+
final_report: finalName,
|
|
72
|
+
finalized_at: '2026-08-17T10:00:00.000Z',
|
|
73
|
+
}));
|
|
74
|
+
report('root', '20260817T090000', 'update', 'Started the task');
|
|
75
|
+
report('root', '20260817T100000', 'final', 'Finished the task');
|
|
76
|
+
report('root', '20260817T110000', 'update', 'Still monitoring the task');
|
|
77
|
+
createNode(node('bridge', { kind: 'human', parent: 'root' }));
|
|
78
|
+
const page = join(home, 'pending-page.tsx');
|
|
79
|
+
writeFileSync(page, `export default function PendingPage() {
|
|
80
|
+
return <Page title="Need a decision"><UserText id="answer" initialText="" /></Page>;
|
|
81
|
+
}
|
|
82
|
+
`);
|
|
83
|
+
const ticket = submitPage({
|
|
84
|
+
dir: ticketDir('bridge'),
|
|
85
|
+
sourceFile: page,
|
|
86
|
+
delivery: { placement: 'inline', inbox: true, reply: true },
|
|
87
|
+
});
|
|
88
|
+
bindTicketReplyRoute(ticket.dir, 'bridge');
|
|
89
|
+
invalidateTicketCounts();
|
|
90
|
+
createNode(node('child', { parent: 'root' }));
|
|
91
|
+
createNode(node('resident', { lifecycle: 'resident' }));
|
|
92
|
+
createNode(node('other-profile', { profile_id: 'other-profile-7f3a9c01' }));
|
|
93
|
+
const roots = list('profile_prefix=activity-profile-&lifecycle=terminal&top_level=true&include=activity');
|
|
94
|
+
assert.deepEqual(roots.map((row) => row.node_id), ['root']);
|
|
95
|
+
assert.equal(roots[0].activity?.final_report?.body, 'Finished the task');
|
|
96
|
+
assert.equal(roots[0].activity?.latest_report?.body, 'Still monitoring the task');
|
|
97
|
+
assert.equal(roots[0].activity?.pending_human_count, 1);
|
|
98
|
+
const children = list('profile_id=activity-profile-7f3a9c01&parent=root');
|
|
99
|
+
assert.deepEqual(children.map((row) => row.node_id), ['bridge', 'child']);
|
|
100
|
+
assert.equal(children[1].activity, undefined);
|
|
101
|
+
});
|
|
@@ -169,6 +169,36 @@ test('A4a: WS attach welcome.snapshot is byte-identical to a direct ViewSocketCl
|
|
|
169
169
|
assert.equal(JSON.stringify(wsWelcome.snapshot), JSON.stringify(direct.welcome.snapshot), 'WS welcome.snapshot is byte-identical to the direct ViewSocketClient snapshot');
|
|
170
170
|
direct.close();
|
|
171
171
|
});
|
|
172
|
+
test('A4a regression: reconnecting an observer keeps the live broker instance', async () => {
|
|
173
|
+
const root = h.spawnRoot('a4 reconnect root');
|
|
174
|
+
const id = (await client.createNode({ kind: 'developer', parent: root, prompt: 'a4 reconnect worker' })).node_id;
|
|
175
|
+
await h.awaitBoot(id);
|
|
176
|
+
const pid = h.node(id)?.pi_pid;
|
|
177
|
+
const boots = h.bootCount(id);
|
|
178
|
+
const attachOnce = () => new Promise((resolveAttach, rejectAttach) => {
|
|
179
|
+
const ws = new WebSocket(`ws+unix://${sockPath}:/v1/nodes/${id}/attach`);
|
|
180
|
+
const timer = setTimeout(() => {
|
|
181
|
+
ws.terminate();
|
|
182
|
+
rejectAttach(new Error('timed out waiting for observer welcome'));
|
|
183
|
+
}, 15_000);
|
|
184
|
+
ws.on('open', () => ws.send(JSON.stringify({ type: 'hello', role: 'observer', client_id: `a4-reconnect-${Math.random()}` })));
|
|
185
|
+
ws.on('message', (data) => {
|
|
186
|
+
if (JSON.parse(data.toString('utf8'))['type'] !== 'welcome')
|
|
187
|
+
return;
|
|
188
|
+
clearTimeout(timer);
|
|
189
|
+
ws.close();
|
|
190
|
+
resolveAttach();
|
|
191
|
+
});
|
|
192
|
+
ws.on('error', (error) => {
|
|
193
|
+
clearTimeout(timer);
|
|
194
|
+
rejectAttach(error);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
await attachOnce();
|
|
198
|
+
await attachOnce();
|
|
199
|
+
assert.equal(h.bootCount(id), boots, 'observer reconnect did not relaunch the broker');
|
|
200
|
+
assert.equal(h.node(id)?.pi_pid, pid, 'the in-flight broker instance was retained');
|
|
201
|
+
});
|
|
172
202
|
test('A4b: ?revive=0 on a dormant node refuses 409 without launching a broker', async () => {
|
|
173
203
|
const root = h.spawnRoot('a4b root');
|
|
174
204
|
// A dormant row: no live broker, no pi_pid, view.sock absent.
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import type { IncomingMessage } from 'node:http';
|
|
2
2
|
import type { Duplex } from 'node:stream';
|
|
3
|
+
import { type WebSocket } from 'ws';
|
|
4
|
+
/** Keep an otherwise quiet observer connection alive through common 60s proxy
|
|
5
|
+
* idle limits. A viewer only observes a broker, so a missed heartbeat may close
|
|
6
|
+
* this socket but must never affect the broker or its in-flight work. */
|
|
7
|
+
export declare const REMOTE_ATTACH_HEARTBEAT_MS = 25000;
|
|
8
|
+
export declare function startRemoteAttachHeartbeat(ws: WebSocket, intervalMs?: number): () => void;
|
|
3
9
|
/** Handle the HTTP upgrade for `GET /v1/nodes/{id}/attach`. A-9 matches the
|
|
4
10
|
* route, extracts `{id}`, and calls this with the raw upgrade triplet; this
|
|
5
11
|
* owns the WS handshake (its own noServer `wss` above) AND the spec §5.3
|