@north-light/crouter 0.3.234 → 0.3.236
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 +15 -3
- package/dist/clients/attach/viewer.js +398 -398
- package/dist/commands/__tests__/node-new.test.d.ts +1 -0
- package/dist/commands/__tests__/node-new.test.js +58 -0
- package/dist/commands/attention.js +1 -1
- package/dist/commands/cron.js +5 -5
- package/dist/commands/human.js +1 -1
- package/dist/commands/memory/__tests__/memory-list-and-lint-ignore.test.d.ts +1 -0
- package/dist/commands/memory/__tests__/memory-list-and-lint-ignore.test.js +96 -0
- package/dist/commands/memory/delete.js +1 -1
- package/dist/commands/memory/edit.js +6 -3
- package/dist/commands/memory/list.d.ts +4 -0
- package/dist/commands/memory/list.js +20 -14
- package/dist/commands/memory/move.js +1 -1
- package/dist/commands/memory/shared.d.ts +1 -0
- package/dist/commands/memory/shared.js +13 -1
- package/dist/commands/memory/write.js +4 -1
- package/dist/commands/node/create.js +3 -0
- package/dist/commands/node/inspect.js +11 -4
- package/dist/commands/node/lifecycle.js +2 -1
- package/dist/commands/node/message.js +8 -4
- package/dist/commands/node/subscription.js +5 -1
- package/dist/commands/node/wait.js +5 -1
- package/dist/commands/pkg/plugin-manage.js +1 -1
- package/dist/commands/pkg.js +1 -0
- package/dist/commands/profile/rename.js +1 -1
- package/dist/commands/sys/config.js +1 -0
- package/dist/commands/sys.js +1 -0
- package/dist/core/__tests__/canvas-inbox-watcher-naming.test.d.ts +1 -0
- package/dist/core/__tests__/canvas-inbox-watcher-naming.test.js +258 -0
- package/dist/core/__tests__/cli-help-contract.test.d.ts +1 -0
- package/dist/core/__tests__/cli-help-contract.test.js +150 -0
- package/dist/core/__tests__/helpers/harness.js +1 -0
- package/dist/core/__tests__/integration/command-plugins.test.js +21 -0
- package/dist/core/__tests__/seam/dormancy-release.test.js +70 -1
- package/dist/core/__tests__/watchdog-abort-arms-retry.test.js +11 -6
- package/dist/core/canvas/canvas.js +11 -1
- package/dist/core/canvas/meta-file.js +18 -27
- package/dist/core/canvas/remote-canvas-source.d.ts +8 -0
- package/dist/core/canvas/remote-canvas-source.js +9 -1
- package/dist/core/canvas/types.d.ts +2 -2
- package/dist/core/command-manifests/registry.d.ts +2 -0
- package/dist/core/command-plugins/compose.js +4 -2
- package/dist/core/command-plugins/discovery.d.ts +1 -0
- package/dist/core/command-plugins/discovery.js +1 -1
- package/dist/core/command.js +2 -2
- package/dist/core/fault-classifier.js +8 -0
- package/dist/core/pagination.d.ts +2 -0
- package/dist/core/pagination.js +2 -1
- package/dist/core/runtime/broker/fault-retry.js +6 -0
- package/dist/core/runtime/broker/rebind.js +50 -0
- package/dist/core/runtime/launch-target.js +1 -1
- package/dist/core/runtime/launch.d.ts +2 -2
- package/dist/core/runtime/launch.js +17 -4
- package/dist/core/runtime/spawn.d.ts +5 -13
- package/dist/core/runtime/spawn.js +4 -10
- package/dist/core/runtime/stop-guard.js +28 -16
- package/dist/core/substrate/frontmatter-validation.d.ts +1 -0
- package/dist/core/substrate/frontmatter-validation.js +1 -1
- package/dist/daemon/api/__tests__/error-seam.test.js +27 -0
- package/dist/daemon/api/handlers/messages.js +12 -12
- package/dist/daemon/api/handlers/nodes.js +13 -4
- package/dist/daemon/api/handlers/worktree.js +6 -22
- package/dist/daemon/api/map.d.ts +4 -2
- package/dist/daemon/api/map.js +22 -2
- package/dist/daemon/reconcilers/broker-supervision.js +1 -1
- package/dist/pi-extensions/canvas-bash-valve.js +4 -3
- package/dist/pi-extensions/canvas-inbox-watcher.js +12 -0
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
|
@@ -69,6 +69,10 @@ export const nodeSubscription = defineBranch({
|
|
|
69
69
|
name: 'subscription',
|
|
70
70
|
description: 'manage push-delivery edges between nodes',
|
|
71
71
|
whenToUse: 'you need an ongoing active or passive subscriber-to-publisher delivery edge that spawn did not create, or need to remove one',
|
|
72
|
-
help: {
|
|
72
|
+
help: {
|
|
73
|
+
name: 'node subscription',
|
|
74
|
+
summary: 'add or remove subscriber-to-publisher push-delivery edges',
|
|
75
|
+
model: 'Subscriptions point from a subscriber to a publisher and control future push delivery. Active edges wake on each push; passive edges accumulate pushes for the subscriber’s next turn. Removing either edge stops subsequent delivery without changing messages already received.',
|
|
76
|
+
},
|
|
73
77
|
children: [nodeSubscribe, nodeUnsubscribe],
|
|
74
78
|
});
|
|
@@ -154,6 +154,10 @@ export const nodeWaitBranch = defineBranch({
|
|
|
154
154
|
name: 'wait',
|
|
155
155
|
description: 'declare a durable controller or deadline wait',
|
|
156
156
|
whenToUse: 'a terminal node must dorm for one named controller, or race its inbox against a self-scheduled deadline',
|
|
157
|
-
help: {
|
|
157
|
+
help: {
|
|
158
|
+
name: 'node wait',
|
|
159
|
+
summary: 'declare durable wait state; choose the event that settles it',
|
|
160
|
+
model: 'Both waits belong to the calling terminal node and settle on one future event. A controller wait records an existing node as the wake source; a deadline wait arms an urgent one-shot cron that races any earlier inbox delivery and is canceled when that delivery wins.',
|
|
161
|
+
},
|
|
158
162
|
children: [nodeWait, nodeWaitDeadline],
|
|
159
163
|
});
|
|
@@ -791,7 +791,7 @@ export const pluginRemove = defineLeaf({
|
|
|
791
791
|
summary: 'remove a plugin and its directory from the given scope',
|
|
792
792
|
params: [
|
|
793
793
|
{ kind: 'positional', name: 'name', type: 'string', required: true, constraint: 'Plugin name to remove.' },
|
|
794
|
-
{ kind: 'flag', name: 'scope', type: 'enum', choices: ['user', 'project'], required: false, constraint: 'Default: searches
|
|
794
|
+
{ kind: 'flag', name: 'scope', type: 'enum', choices: ['user', 'project'], required: false, constraint: 'Default: searches every scope.' },
|
|
795
795
|
],
|
|
796
796
|
output: [
|
|
797
797
|
{ name: 'scopes', type: 'string[]', required: true, constraint: 'Scopes the plugin was removed from.' },
|
package/dist/commands/pkg.js
CHANGED
|
@@ -14,6 +14,7 @@ export function registerPkg() {
|
|
|
14
14
|
help: {
|
|
15
15
|
name: 'pkg',
|
|
16
16
|
summary: 'manage plugins and marketplace sources',
|
|
17
|
+
model: 'Marketplaces are configured source catalogs; plugins are installed extensions resolved from those catalogs or direct HTTP endpoints. Browsing searches the configured catalogs without changing source or install state.',
|
|
17
18
|
},
|
|
18
19
|
children: [pkgBrowseLeaf, pluginBranch, marketBranch],
|
|
19
20
|
});
|
|
@@ -3,7 +3,7 @@ import { resolveProfileOperand, renameProfile } from '../../core/profiles/manife
|
|
|
3
3
|
export const renameLeaf = defineLeaf({
|
|
4
4
|
name: 'rename',
|
|
5
5
|
description: "change a profile's display name",
|
|
6
|
-
whenToUse: "you want to
|
|
6
|
+
whenToUse: "you want the profile to appear under a new name in bearings, listings, and `profile` subject gates. The profile id (its directory) never changes.",
|
|
7
7
|
help: {
|
|
8
8
|
name: 'profile rename',
|
|
9
9
|
summary: "change a profile's manifest `name` only — the profile_id is stable and never changes",
|
|
@@ -257,6 +257,7 @@ export const configBranch = defineBranch({
|
|
|
257
257
|
help: {
|
|
258
258
|
name: 'sys config',
|
|
259
259
|
summary: 'read and write crtr configuration, including model ladders; edit humanActions maps directly in ~/.crouter/config.json or <repo>/.crouter/config.json',
|
|
260
|
+
model: 'Configuration resolves through ordered scopes. Reads can resolve the effective value, writes target one writable scope, and path inspection reveals the backing files without reading values.',
|
|
260
261
|
},
|
|
261
262
|
children: [configGet, configSet, configPath],
|
|
262
263
|
});
|
package/dist/commands/sys.js
CHANGED
|
@@ -24,6 +24,7 @@ export function registerSys() {
|
|
|
24
24
|
help: {
|
|
25
25
|
name: 'sys',
|
|
26
26
|
summary: 'crtr system configuration, settings, diagnostics, and self-management',
|
|
27
|
+
model: 'Configuration and settings own durable behavior; diagnostics and support observe it. Setup, migration, sync, and update change installed or generated state, while daemon control owns the supervisor lifecycle.',
|
|
27
28
|
},
|
|
28
29
|
children: [sysSettingsLeaf, configBranch, sysSetupLeaf, sysDoctorLeaf, sysSyspromptLeaf, promptReviewLeaf, sysFeedbackLeaf, sysLogsLeaf, supportBranch, sysUpdateLeaf, sysVersionLeaf, sysMigrateLeaf, sysSyncBranch, daemonBranch],
|
|
29
30
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// Tests for the naming/goal-capture gap on the durable-inbox delivery path.
|
|
2
|
+
//
|
|
3
|
+
// Run with: node --conditions=crtr-src --import tsx/esm --test src/core/__tests__/canvas-inbox-watcher-naming.test.ts
|
|
4
|
+
//
|
|
5
|
+
// A cold-booting child's first message falls to the durable inbox and pi's
|
|
6
|
+
// `input` event never fires for it, so canvas-goal-capture's first-message
|
|
7
|
+
// hooks (naming, goal capture) never run for it. The inbox watcher now mirrors
|
|
8
|
+
// both on its own `kind: 'human'` visible delivery. This pins that mirror:
|
|
9
|
+
// a human delivery reaches the naming pipeline and captures the goal, a
|
|
10
|
+
// card/envelope delivery touches neither, and an already-described node's
|
|
11
|
+
// naming call still fetches state (the daemon owns the compare-and-set) but
|
|
12
|
+
// never issues a commit.
|
|
13
|
+
//
|
|
14
|
+
// The naming attempt is observed at the daemon socket exactly as in
|
|
15
|
+
// canvas-goal-capture-envelope.test.ts: generateAndCommitInitialName starts by
|
|
16
|
+
// asking crtrd for the node's projection (GET .../broker/extension-state), so
|
|
17
|
+
// a request for a node id is proof the pipeline ran for it. An unnamed node's
|
|
18
|
+
// GET is answered with an error — same as that file — so the test never
|
|
19
|
+
// reaches the real headless model call; a "described" node's GET succeeds with
|
|
20
|
+
// a non-empty description, which is enough to prove the guard short-circuits
|
|
21
|
+
// before any commit (POST .../broker/generated-name) is sent.
|
|
22
|
+
import { test, before, after, afterEach } from 'node:test';
|
|
23
|
+
import assert from 'node:assert/strict';
|
|
24
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
25
|
+
import { createServer } from 'node:net';
|
|
26
|
+
import { tmpdir } from 'node:os';
|
|
27
|
+
import { join } from 'node:path';
|
|
28
|
+
import registerCanvasInboxWatcher from '../../pi-extensions/canvas-inbox-watcher.js';
|
|
29
|
+
import { createNode } from '../canvas/canvas.js';
|
|
30
|
+
import { closeDb } from '../canvas/db.js';
|
|
31
|
+
import { apiSocketPath } from '../canvas/paths.js';
|
|
32
|
+
import { appendInbox } from '../feed/inbox.js';
|
|
33
|
+
const TICK_MS = 20;
|
|
34
|
+
const DEBOUNCE_MS = 25;
|
|
35
|
+
let home;
|
|
36
|
+
let socket;
|
|
37
|
+
const getRequests = [];
|
|
38
|
+
const postRequests = [];
|
|
39
|
+
/** node_id -> description the stub answers `extension-state` with. Absent =
|
|
40
|
+
* the GET errors (mirrors canvas-goal-capture-envelope.test.ts: proof of
|
|
41
|
+
* attempt without a real model call). Present (even '') = the GET succeeds. */
|
|
42
|
+
const descriptions = new Map();
|
|
43
|
+
function node(id) {
|
|
44
|
+
return {
|
|
45
|
+
node_id: id,
|
|
46
|
+
name: id,
|
|
47
|
+
created: new Date().toISOString(),
|
|
48
|
+
cwd: '/tmp/work',
|
|
49
|
+
kind: 'general',
|
|
50
|
+
mode: 'base',
|
|
51
|
+
lifecycle: 'terminal',
|
|
52
|
+
status: 'active',
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
function initialPromptPath(id) {
|
|
56
|
+
return join(home, 'nodes', id, 'context', 'initial-prompt.md');
|
|
57
|
+
}
|
|
58
|
+
function respond(connection, status, body) {
|
|
59
|
+
const json = JSON.stringify(body);
|
|
60
|
+
connection.end(`HTTP/1.1 ${status}\r\n`
|
|
61
|
+
+ 'Content-Type: application/json\r\n'
|
|
62
|
+
+ `Content-Length: ${Buffer.byteLength(json)}\r\n`
|
|
63
|
+
+ 'Connection: close\r\n\r\n'
|
|
64
|
+
+ json);
|
|
65
|
+
}
|
|
66
|
+
function nodeIdFromPath(path) {
|
|
67
|
+
return path.split('/nodes/')[1]?.split('/')[0] ?? '';
|
|
68
|
+
}
|
|
69
|
+
/** Every node's watcher fetches `extension-state` ONCE at session_start (to
|
|
70
|
+
* seed report-node names for card rendering) regardless of what it later
|
|
71
|
+
* delivers, so a bare presence check can't tell a startup fetch from a
|
|
72
|
+
* naming-triggered one. Naming issues a SECOND, independent fetch for the
|
|
73
|
+
* same node id — count occurrences instead of checking presence. */
|
|
74
|
+
function getRequestCount(nodeId) {
|
|
75
|
+
return getRequests.filter((line) => line.includes(`/nodes/${nodeId}/`)).length;
|
|
76
|
+
}
|
|
77
|
+
function makeFakePi() {
|
|
78
|
+
const handlers = {};
|
|
79
|
+
return {
|
|
80
|
+
injected: [],
|
|
81
|
+
sentMessages: [],
|
|
82
|
+
on(e, h) { handlers[e] = h; },
|
|
83
|
+
sendUserMessage(content, options) { this.injected.push({ content, deliverAs: options?.deliverAs }); },
|
|
84
|
+
sendMessage(message) { this.sentMessages.push(message); },
|
|
85
|
+
fire(e, ev, ctx) { handlers[e]?.(ev, ctx); },
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
89
|
+
async function waitFor(predicate, timeoutMs = 3000, stepMs = 10) {
|
|
90
|
+
const deadline = Date.now() + timeoutMs;
|
|
91
|
+
while (Date.now() < deadline) {
|
|
92
|
+
if (predicate())
|
|
93
|
+
return;
|
|
94
|
+
await wait(stepMs);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const disposers = [];
|
|
98
|
+
function registerWatcher(pi) {
|
|
99
|
+
const dispose = registerCanvasInboxWatcher(pi);
|
|
100
|
+
pi.fire('session_start', { type: 'session_start' }, undefined);
|
|
101
|
+
return dispose;
|
|
102
|
+
}
|
|
103
|
+
/** Point CRTR_HOME/CRTR_NODE_ID at this node and make sure it exists in canvas.db. */
|
|
104
|
+
function freshNode(id) {
|
|
105
|
+
process.env['CRTR_NODE_ID'] = id;
|
|
106
|
+
createNode(node(id));
|
|
107
|
+
}
|
|
108
|
+
let origHome;
|
|
109
|
+
let origNode;
|
|
110
|
+
let origTick;
|
|
111
|
+
let origDebounce;
|
|
112
|
+
before(async () => {
|
|
113
|
+
origHome = process.env['CRTR_HOME'];
|
|
114
|
+
origNode = process.env['CRTR_NODE_ID'];
|
|
115
|
+
origTick = process.env['CRTR_WATCHER_TICK_MS'];
|
|
116
|
+
origDebounce = process.env['CRTR_WATCHER_DEBOUNCE_MS'];
|
|
117
|
+
home = mkdtempSync(join(tmpdir(), 'crtr-inbox-naming-'));
|
|
118
|
+
mkdirSync(join(home, 'nodes'), { recursive: true });
|
|
119
|
+
process.env['CRTR_HOME'] = home;
|
|
120
|
+
process.env['CRTR_WATCHER_TICK_MS'] = String(TICK_MS);
|
|
121
|
+
process.env['CRTR_WATCHER_DEBOUNCE_MS'] = String(DEBOUNCE_MS);
|
|
122
|
+
socket = createServer((connection) => {
|
|
123
|
+
connection.on('data', (chunk) => {
|
|
124
|
+
const raw = chunk.toString('utf8');
|
|
125
|
+
const requestLine = raw.split('\r\n')[0] ?? '';
|
|
126
|
+
const [method, path] = requestLine.split(' ');
|
|
127
|
+
if (method === 'GET' && path?.includes('/broker/extension-state')) {
|
|
128
|
+
getRequests.push(raw);
|
|
129
|
+
const nodeId = nodeIdFromPath(path);
|
|
130
|
+
if (!descriptions.has(nodeId)) {
|
|
131
|
+
respond(connection, '500 Internal Server Error', { error: { code: 'internal', message: 'stub daemon' } });
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
respond(connection, '200 OK', {
|
|
135
|
+
node: {
|
|
136
|
+
node_id: nodeId, name: nodeId, description: descriptions.get(nodeId), kind: 'general',
|
|
137
|
+
mode: 'base', lifecycle: 'terminal', status: 'active', cwd: '/tmp/work',
|
|
138
|
+
parent: null, fork_from: null, profile_id: null, intent: null, created: new Date().toISOString(),
|
|
139
|
+
},
|
|
140
|
+
warm_spare: false, ancestors: [], children: [], fork_source: null,
|
|
141
|
+
subject: { kind: 'general', mode: 'base', lifecycle: 'terminal', hasManager: false, cwd: '/tmp/work', scope: 'project', orchestration: { depth: 0 }, profile: null },
|
|
142
|
+
report_nodes: [],
|
|
143
|
+
});
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (method === 'POST' && path?.includes('/broker/generated-name')) {
|
|
147
|
+
postRequests.push(raw);
|
|
148
|
+
respond(connection, '200 OK', { applied: false });
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
// Everything else (e.g. advanceBrokerInboxCursor) — a harmless error is
|
|
152
|
+
// fine: the watcher retries cursor commits later and never blocks
|
|
153
|
+
// visible delivery on them.
|
|
154
|
+
respond(connection, '500 Internal Server Error', { error: { code: 'internal', message: 'stub daemon' } });
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
await new Promise((resolve) => socket.listen(apiSocketPath(), resolve));
|
|
158
|
+
});
|
|
159
|
+
afterEach(() => {
|
|
160
|
+
while (disposers.length > 0)
|
|
161
|
+
disposers.pop()();
|
|
162
|
+
});
|
|
163
|
+
after(async () => {
|
|
164
|
+
closeDb();
|
|
165
|
+
await new Promise((resolve) => socket.close(() => resolve()));
|
|
166
|
+
rmSync(home, { recursive: true, force: true });
|
|
167
|
+
if (origHome === undefined)
|
|
168
|
+
delete process.env['CRTR_HOME'];
|
|
169
|
+
else
|
|
170
|
+
process.env['CRTR_HOME'] = origHome;
|
|
171
|
+
if (origNode === undefined)
|
|
172
|
+
delete process.env['CRTR_NODE_ID'];
|
|
173
|
+
else
|
|
174
|
+
process.env['CRTR_NODE_ID'] = origNode;
|
|
175
|
+
if (origTick === undefined)
|
|
176
|
+
delete process.env['CRTR_WATCHER_TICK_MS'];
|
|
177
|
+
else
|
|
178
|
+
process.env['CRTR_WATCHER_TICK_MS'] = origTick;
|
|
179
|
+
if (origDebounce === undefined)
|
|
180
|
+
delete process.env['CRTR_WATCHER_DEBOUNCE_MS'];
|
|
181
|
+
else
|
|
182
|
+
process.env['CRTR_WATCHER_DEBOUNCE_MS'] = origDebounce;
|
|
183
|
+
});
|
|
184
|
+
test('an inbox-delivered human first message reaches the naming pipeline and captures the goal', async () => {
|
|
185
|
+
freshNode('inbox-human-unnamed');
|
|
186
|
+
const pi = makeFakePi();
|
|
187
|
+
disposers.push(registerWatcher(pi));
|
|
188
|
+
await wait(TICK_MS + 100);
|
|
189
|
+
appendInbox('inbox-human-unnamed', {
|
|
190
|
+
from: 'human',
|
|
191
|
+
tier: 'normal',
|
|
192
|
+
kind: 'message',
|
|
193
|
+
label: 'build me a receipt tracker',
|
|
194
|
+
data: { body: 'build me a receipt tracker' },
|
|
195
|
+
});
|
|
196
|
+
await waitFor(() => pi.injected.length >= 1);
|
|
197
|
+
assert.equal(pi.injected[0].content, 'build me a receipt tracker', 'the human body still rides the visible digest verbatim');
|
|
198
|
+
// One GET at session_start (report-node seed) plus one from the naming
|
|
199
|
+
// pipeline (generateAndCommitInitialName's own state fetch) = 2.
|
|
200
|
+
await waitFor(() => getRequestCount('inbox-human-unnamed') >= 2);
|
|
201
|
+
assert.equal(getRequestCount('inbox-human-unnamed'), 2, 'an inbox-delivered human message reaches the same naming pipeline a live message does');
|
|
202
|
+
assert.equal(readFileSync(initialPromptPath('inbox-human-unnamed'), 'utf8').trim(), 'build me a receipt tracker', 'an inbox-delivered first message is also captured as the goal, for fresh-revive recovery');
|
|
203
|
+
});
|
|
204
|
+
test('a card/envelope delivery never reaches naming or goal capture', async () => {
|
|
205
|
+
freshNode('inbox-card-only');
|
|
206
|
+
const pi = makeFakePi();
|
|
207
|
+
const dispose = registerWatcher(pi);
|
|
208
|
+
await wait(TICK_MS + 100);
|
|
209
|
+
appendInbox('inbox-card-only', { from: 'child-9', tier: 'normal', kind: 'update', label: 'phase one done' });
|
|
210
|
+
await waitFor(() => pi.injected.length >= 1, 3000);
|
|
211
|
+
assert.equal(pi.injected.length, 1, 'the card still reaches the digest as a visible send');
|
|
212
|
+
dispose(); // release ownership of this node id before a second watcher binds a new one
|
|
213
|
+
// Clock: a human message to a DIFFERENT node whose naming pipeline we know
|
|
214
|
+
// fires (proven above). Once its GET lands, the card node's window to fire
|
|
215
|
+
// its own GET has already passed — the watcher fires naming synchronously
|
|
216
|
+
// off the same delivery cycle, not on a later timer.
|
|
217
|
+
freshNode('inbox-card-clock');
|
|
218
|
+
const clockPi = makeFakePi();
|
|
219
|
+
disposers.push(registerWatcher(clockPi));
|
|
220
|
+
await wait(TICK_MS + 100);
|
|
221
|
+
appendInbox('inbox-card-clock', { from: 'human', tier: 'normal', kind: 'message', label: 'clock', data: { body: 'clock' } });
|
|
222
|
+
await waitFor(() => getRequests.some((line) => line.includes('inbox-card-clock')));
|
|
223
|
+
// Exactly the one startup GET (report-node seed) — no second, naming-triggered fetch.
|
|
224
|
+
assert.equal(getRequestCount('inbox-card-only'), 1, 'a card/envelope delivery never asks the naming pipeline to name the node');
|
|
225
|
+
assert.equal(existsSync(initialPromptPath('inbox-card-only')), false, 'a card/envelope delivery never seeds a goal file either');
|
|
226
|
+
});
|
|
227
|
+
test('an already-described node fetches state but never commits a new name', async () => {
|
|
228
|
+
descriptions.set('inbox-already-named', 'existing-description');
|
|
229
|
+
freshNode('inbox-already-named');
|
|
230
|
+
const pi = makeFakePi();
|
|
231
|
+
const dispose = registerWatcher(pi);
|
|
232
|
+
await wait(TICK_MS + 100);
|
|
233
|
+
appendInbox('inbox-already-named', {
|
|
234
|
+
from: 'human',
|
|
235
|
+
tier: 'normal',
|
|
236
|
+
kind: 'message',
|
|
237
|
+
label: 'second thing to do',
|
|
238
|
+
data: { body: 'second thing to do' },
|
|
239
|
+
});
|
|
240
|
+
await waitFor(() => pi.injected.length >= 1);
|
|
241
|
+
// One GET at session_start plus one from the naming pipeline's own state
|
|
242
|
+
// fetch: the guard is checked AFTER this fetch resolves, not before it.
|
|
243
|
+
await waitFor(() => getRequestCount('inbox-already-named') >= 2);
|
|
244
|
+
assert.equal(getRequestCount('inbox-already-named'), 2, 'the naming pipeline still asks the daemon for the current projection');
|
|
245
|
+
dispose(); // release ownership of this node id before a second watcher binds a new one
|
|
246
|
+
// Clock: a second node's naming pipeline fires synchronously off the same
|
|
247
|
+
// delivery cycle (proven in the first test above). Once its GET has landed,
|
|
248
|
+
// this node's guard — checked in the very same .then() before any commit
|
|
249
|
+
// call — has already resolved one way or the other.
|
|
250
|
+
freshNode('inbox-commit-clock');
|
|
251
|
+
const clockPi = makeFakePi();
|
|
252
|
+
disposers.push(registerWatcher(clockPi));
|
|
253
|
+
await wait(TICK_MS + 100);
|
|
254
|
+
appendInbox('inbox-commit-clock', { from: 'human', tier: 'normal', kind: 'message', label: 'clock', data: { body: 'clock' } });
|
|
255
|
+
await waitFor(() => getRequests.some((line) => line.includes('inbox-commit-clock')));
|
|
256
|
+
await wait(DEBOUNCE_MS + 100);
|
|
257
|
+
assert.equal(postRequests.some((line) => line.includes('inbox-already-named')), false, 'an already-described node is untouched: the guard short-circuits before any generated-name commit');
|
|
258
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Help-contract gate for the whole command tree.
|
|
2
|
+
// Run with: node --import tsx/esm --test src/core/__tests__/cli-help-contract.test.ts
|
|
3
|
+
//
|
|
4
|
+
// The cli-design rules that hold for every node are enforced here rather than
|
|
5
|
+
// per command, because help text is written one command at a time and drifts
|
|
6
|
+
// the same way every time: a branch ships its selection prose in the
|
|
7
|
+
// description attribute, a rubric restates the description it sits beside, or
|
|
8
|
+
// a constraint copied from a sibling leaf keeps naming a value this leaf's
|
|
9
|
+
// enum rejects. Each rule below is mechanical — a judgment call about whether
|
|
10
|
+
// a rubric actually helps an agent choose belongs to review, not to this file.
|
|
11
|
+
import { test, before, after } from 'node:test';
|
|
12
|
+
import assert from 'node:assert/strict';
|
|
13
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
14
|
+
import { tmpdir } from 'node:os';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { buildRoot } from '../../build-root.js';
|
|
17
|
+
import { resetScopeCache } from '../scope.js';
|
|
18
|
+
// buildRoot() composes external command-plugin branches from the effective
|
|
19
|
+
// enabled plugins. Pin HOME to an isolated no-plugin home: a third party's
|
|
20
|
+
// help text is not this repo's contract to enforce.
|
|
21
|
+
let home;
|
|
22
|
+
let realHome;
|
|
23
|
+
let realProfile;
|
|
24
|
+
before(() => {
|
|
25
|
+
realHome = process.env['HOME'];
|
|
26
|
+
realProfile = process.env['CRTR_PROFILE_ID'];
|
|
27
|
+
home = mkdtempSync(join(tmpdir(), 'crtr-help-contract-'));
|
|
28
|
+
delete process.env['CRTR_PROFILE_ID'];
|
|
29
|
+
process.env['HOME'] = home;
|
|
30
|
+
resetScopeCache();
|
|
31
|
+
});
|
|
32
|
+
after(() => {
|
|
33
|
+
if (realHome !== undefined)
|
|
34
|
+
process.env['HOME'] = realHome;
|
|
35
|
+
else
|
|
36
|
+
delete process.env['HOME'];
|
|
37
|
+
if (realProfile !== undefined)
|
|
38
|
+
process.env['CRTR_PROFILE_ID'] = realProfile;
|
|
39
|
+
rmSync(home, { recursive: true, force: true });
|
|
40
|
+
resetScopeCache();
|
|
41
|
+
});
|
|
42
|
+
async function everyNode() {
|
|
43
|
+
const root = await buildRoot();
|
|
44
|
+
const out = [];
|
|
45
|
+
const visit = (node, path) => {
|
|
46
|
+
out.push({ node, path });
|
|
47
|
+
if (node.kind === 'branch')
|
|
48
|
+
for (const child of node.children)
|
|
49
|
+
visit(child, `${path} ${child.name}`);
|
|
50
|
+
};
|
|
51
|
+
for (const subtree of root.subtrees)
|
|
52
|
+
visit(subtree, subtree.name);
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
function normalize(text) {
|
|
56
|
+
if (text === undefined)
|
|
57
|
+
return '';
|
|
58
|
+
return text.toLowerCase().replace(/\s+/g, ' ').replace(/[.\s]+$/, '').trim();
|
|
59
|
+
}
|
|
60
|
+
function enumFlags(leaf) {
|
|
61
|
+
return (leaf.help.params ?? []).filter((param) => param.kind === 'flag' && param.type === 'enum' && param.choices !== undefined);
|
|
62
|
+
}
|
|
63
|
+
test('every branch offering a choice carries the model prose that frames it', async () => {
|
|
64
|
+
const offenders = [];
|
|
65
|
+
for (const { node, path } of await everyNode()) {
|
|
66
|
+
if (node.kind !== 'branch')
|
|
67
|
+
continue;
|
|
68
|
+
const choices = node.children.filter((child) => child.tier !== 'hidden');
|
|
69
|
+
if (choices.length < 2)
|
|
70
|
+
continue;
|
|
71
|
+
const model = node.help.model;
|
|
72
|
+
if (model === undefined || model.trim() === '')
|
|
73
|
+
offenders.push(`${path} (${choices.length} children)`);
|
|
74
|
+
}
|
|
75
|
+
assert.deepEqual(offenders, [], `branches with 2+ visible children and no help.model:\n ${offenders.join('\n ')}`);
|
|
76
|
+
});
|
|
77
|
+
test('no rubric restates the description it is read beside', async () => {
|
|
78
|
+
const offenders = [];
|
|
79
|
+
for (const { node, path } of await everyNode()) {
|
|
80
|
+
const description = normalize(node.description ?? '');
|
|
81
|
+
const rubric = normalize(node.whenToUse ?? '');
|
|
82
|
+
if (description === '' || rubric === '')
|
|
83
|
+
continue;
|
|
84
|
+
if (rubric === description || rubric.includes(description))
|
|
85
|
+
offenders.push(`${path}: whenToUse repeats "${node.description}"`);
|
|
86
|
+
}
|
|
87
|
+
assert.deepEqual(offenders, [], `rubrics restating their own description:\n ${offenders.join('\n ')}`);
|
|
88
|
+
});
|
|
89
|
+
// A constraint may name a value its own enum rejects only while saying so —
|
|
90
|
+
// the copy-paste failure this catches states the foreign value as if it were
|
|
91
|
+
// selectable. Vocabulary is per flag NAME, so it only fires where one flag
|
|
92
|
+
// genuinely carries different choices on different leaves.
|
|
93
|
+
const NEGATION = /\b(?:reject\w*|refus\w*|invalid|unsupported|forbid\w*|not\s+accepted)\b/i;
|
|
94
|
+
test('no enum constraint names a value that enum does not accept', async () => {
|
|
95
|
+
const offenders = [];
|
|
96
|
+
for (const { node, path } of await everyNode()) {
|
|
97
|
+
if (node.kind !== 'branch')
|
|
98
|
+
continue;
|
|
99
|
+
const leaves = node.children.filter((child) => child.kind === 'leaf');
|
|
100
|
+
// Vocabulary is pooled across ONE branch's leaves, because that is where a
|
|
101
|
+
// constraint gets copied: two sibling verbs carrying the same flag with
|
|
102
|
+
// different choices. Pooling tree-wide would compare unrelated commands
|
|
103
|
+
// that merely share a flag name and flag ordinary English words that
|
|
104
|
+
// happen to be some distant enum's value.
|
|
105
|
+
const vocabulary = new Map();
|
|
106
|
+
for (const leaf of leaves) {
|
|
107
|
+
for (const flag of enumFlags(leaf)) {
|
|
108
|
+
const known = vocabulary.get(flag.name) ?? new Set();
|
|
109
|
+
for (const choice of flag.choices)
|
|
110
|
+
known.add(choice.toLowerCase());
|
|
111
|
+
vocabulary.set(flag.name, known);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
for (const leaf of leaves) {
|
|
115
|
+
for (const flag of enumFlags(leaf)) {
|
|
116
|
+
const own = new Set(flag.choices.map((choice) => choice.toLowerCase()));
|
|
117
|
+
const foreign = [...(vocabulary.get(flag.name) ?? [])].filter((value) => !own.has(value));
|
|
118
|
+
if (foreign.length === 0)
|
|
119
|
+
continue;
|
|
120
|
+
for (const sentence of flag.constraint.split(/(?<=[.;:])\s+/)) {
|
|
121
|
+
if (NEGATION.test(sentence))
|
|
122
|
+
continue;
|
|
123
|
+
const named = foreign.filter((value) => new RegExp(`\\b${value}\\b`, 'i').test(sentence));
|
|
124
|
+
if (named.length > 0)
|
|
125
|
+
offenders.push(`${path} ${leaf.name} --${flag.name}: names ${named.join(', ')} — accepts only ${flag.choices.join(', ')}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
assert.deepEqual(offenders, [], `enum constraints naming values they reject:\n ${offenders.join('\n ')}`);
|
|
131
|
+
});
|
|
132
|
+
test('every leaf declares its effects, and none contradicts a claim of none', async () => {
|
|
133
|
+
const offenders = [];
|
|
134
|
+
for (const { node, path } of await everyNode()) {
|
|
135
|
+
if (node.kind !== 'leaf')
|
|
136
|
+
continue;
|
|
137
|
+
const effects = node.help.effects;
|
|
138
|
+
if (effects.length === 0 || effects.some((effect) => effect.trim() === '')) {
|
|
139
|
+
offenders.push(`${path}: declares no effects`);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
// A leaf may describe what read-only means for it — several usefully do.
|
|
143
|
+
// What it may not do is open with "None" and then list effects underneath:
|
|
144
|
+
// an agent reading the first entry stops there.
|
|
145
|
+
if (effects.length > 1 && /^none\b/i.test(effects[0].trim())) {
|
|
146
|
+
offenders.push(`${path}: claims "${effects[0]}" and then lists ${effects.length - 1} more effect(s)`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
assert.deepEqual(offenders, [], `leaves with a broken effects declaration:\n ${offenders.join('\n ')}`);
|
|
150
|
+
});
|
|
@@ -505,6 +505,7 @@ export async function createHarness(opts = {}) {
|
|
|
505
505
|
prompt: task,
|
|
506
506
|
parent: parentId,
|
|
507
507
|
...(o.mode !== undefined ? { mode: o.mode } : {}),
|
|
508
|
+
...(o.lifecycle !== undefined ? { rootLifecycle: o.lifecycle } : {}),
|
|
508
509
|
...(o.model !== undefined ? { model: o.model } : {}),
|
|
509
510
|
});
|
|
510
511
|
await harness.awaitBoot(result.node.node_id);
|
|
@@ -641,6 +641,27 @@ process.stdout.write(JSON.stringify({ protocolVersion: 1, ok: true, result: { ap
|
|
|
641
641
|
}
|
|
642
642
|
assertNotExecuted();
|
|
643
643
|
});
|
|
644
|
+
test('a colliding exec plugin keeps its manifest path for help and execution', async () => {
|
|
645
|
+
installPlugin(userRoot, 'p-one', { exec: FIXTURE_EXEC });
|
|
646
|
+
installPlugin(userRoot, 'p-two', { exec: FIXTURE_EXEC, sourceMarketplace: 'fixture-market' });
|
|
647
|
+
resetScopeCache();
|
|
648
|
+
const snapshot = discoverCommandContributions(new Set(['sys']), emptyStart);
|
|
649
|
+
const external = composeExternalSubtrees(adaptPluginContributions(snapshot.contributions));
|
|
650
|
+
const mounted = external.find((branch) => branch.name === 'fixture-market:app');
|
|
651
|
+
assert.ok(mounted, 'the marketplace-qualified plugin branch is mounted');
|
|
652
|
+
assert.match(renderBranch(mounted.help), /<command name="fixture-market:app"/);
|
|
653
|
+
const leaf = leafOf(mounted, 'show');
|
|
654
|
+
const log = join(emptyStart, 'collision-request.json');
|
|
655
|
+
process.env['FIXTURE_REQUEST_LOG'] = log;
|
|
656
|
+
try {
|
|
657
|
+
const result = await leaf.run({ appId: 'app_123', mode: 'ok' });
|
|
658
|
+
assert.deepEqual(result, { app_id: 'app_123', status: 'running' });
|
|
659
|
+
assert.deepEqual(JSON.parse(readFileSync(log, 'utf8')).command, ['app', 'show']);
|
|
660
|
+
}
|
|
661
|
+
finally {
|
|
662
|
+
delete process.env['FIXTURE_REQUEST_LOG'];
|
|
663
|
+
}
|
|
664
|
+
});
|
|
644
665
|
});
|
|
645
666
|
// Memory-corpus gate across the plugin lifecycle — install, and enable of a
|
|
646
667
|
// package installed while disabled, both converge the candidate's memory store
|
|
@@ -22,7 +22,7 @@ import assert from 'node:assert/strict';
|
|
|
22
22
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
23
23
|
import { join } from 'node:path';
|
|
24
24
|
import { appendInbox, readCursor, readInboxSince } from '../../feed/inbox.js';
|
|
25
|
-
import { registerWarmSpare, subscribe, updateNode } from '../../canvas/index.js';
|
|
25
|
+
import { registerWarmSpare, subscribe, subscriptionsOf, updateNode } from '../../canvas/index.js';
|
|
26
26
|
import { isPidAlive } from '../../canvas/pid.js';
|
|
27
27
|
import { reviveNode } from '../../runtime/revive.js';
|
|
28
28
|
import { createHarness } from '../helpers/harness.js';
|
|
@@ -349,6 +349,75 @@ test('the unattended park clock cancels an obligation-free terminal node and spa
|
|
|
349
349
|
process.env['CRTR_TEST_UNATTENDED_PARK_MS'] = priorParkMs;
|
|
350
350
|
}
|
|
351
351
|
});
|
|
352
|
+
test('a resident managed child parks without finalizing while a terminal child is settled by the same grace sweep', { timeout: 40_000 }, async () => {
|
|
353
|
+
const priorParkMs = process.env['CRTR_TEST_UNATTENDED_PARK_MS'];
|
|
354
|
+
const priorGraceMs = process.env['CRTR_TEST_PARK_SUMMARY_GRACE_MS'];
|
|
355
|
+
process.env['CRTR_TEST_UNATTENDED_PARK_MS'] = '1500';
|
|
356
|
+
process.env['CRTR_TEST_PARK_SUMMARY_GRACE_MS'] = '1000';
|
|
357
|
+
try {
|
|
358
|
+
const resident = h.fabricateBrokerNode({
|
|
359
|
+
id: 'resident-managed-child',
|
|
360
|
+
parent: root,
|
|
361
|
+
lifecycle: 'resident',
|
|
362
|
+
status: 'idle',
|
|
363
|
+
intent: 'idle-release',
|
|
364
|
+
pi_session_id: 'resident-managed-session',
|
|
365
|
+
});
|
|
366
|
+
subscribe(root, resident, true);
|
|
367
|
+
// Keep a managed terminal child in the same sweep, but make its parent
|
|
368
|
+
// historical so no live subscriber remains to hold an unfinished report.
|
|
369
|
+
const terminalManager = h.fabricateBrokerNode({ id: 'terminal-manager', status: 'done' });
|
|
370
|
+
const terminal = h.fabricateBrokerNode({
|
|
371
|
+
id: 'terminal-managed-child',
|
|
372
|
+
parent: terminalManager,
|
|
373
|
+
lifecycle: 'terminal',
|
|
374
|
+
status: 'idle',
|
|
375
|
+
intent: 'idle-release',
|
|
376
|
+
pi_session_id: 'terminal-managed-session',
|
|
377
|
+
});
|
|
378
|
+
subscribe(terminalManager, terminal, true);
|
|
379
|
+
const boot = async (id) => {
|
|
380
|
+
updateNode(id, { launch: { extensions: [], tools: [], systemPrompt: '', env: {} } });
|
|
381
|
+
reviveNode(id, { resume: true });
|
|
382
|
+
return (await h.awaitBoot(id)).pid;
|
|
383
|
+
};
|
|
384
|
+
const residentPid = await boot(resident);
|
|
385
|
+
const terminalPid = await boot(terminal);
|
|
386
|
+
const seenAt = 50_000;
|
|
387
|
+
await h.tick(seenAt);
|
|
388
|
+
// Clock expiry arms the resident's pending park and requests its summary
|
|
389
|
+
// turn; the terminal child is cancelled outright by the same pass.
|
|
390
|
+
await h.tick(seenAt + 1500);
|
|
391
|
+
// Summary grace expiry: the degraded enactment parks without a report.
|
|
392
|
+
await h.tick(seenAt + 1500 + 1000);
|
|
393
|
+
await h.waitFor(() => h.node(resident)?.status === 'done' && h.node(resident)?.intent === 'parked' && !isPidAlive(residentPid), {
|
|
394
|
+
timeoutMs: 10_000,
|
|
395
|
+
intervalMs: 20,
|
|
396
|
+
label: 'resident managed child parks and releases its broker',
|
|
397
|
+
});
|
|
398
|
+
await h.waitFor(() => h.node(terminal)?.status === 'canceled' && !isPidAlive(terminalPid), {
|
|
399
|
+
timeoutMs: 10_000,
|
|
400
|
+
intervalMs: 20,
|
|
401
|
+
label: 'terminal managed child settles and releases its broker',
|
|
402
|
+
});
|
|
403
|
+
await h.awaitFleetExit(resident);
|
|
404
|
+
await h.awaitFleetExit(terminal);
|
|
405
|
+
assert.equal(h.node(resident)?.intent, 'parked', 'resident child carries the durable park marker, not a final');
|
|
406
|
+
assert.equal(h.node(resident)?.final_report ?? null, null, 'resident child has no forced final report');
|
|
407
|
+
assert.ok(subscriptionsOf(root).some((edge) => edge.node_id === resident && edge.active), 'resident child keeps its active parent subscription');
|
|
408
|
+
assert.equal(h.node(terminal)?.status, 'canceled', 'terminal child is settled by unattended grace without a final');
|
|
409
|
+
}
|
|
410
|
+
finally {
|
|
411
|
+
if (priorParkMs === undefined)
|
|
412
|
+
delete process.env['CRTR_TEST_UNATTENDED_PARK_MS'];
|
|
413
|
+
else
|
|
414
|
+
process.env['CRTR_TEST_UNATTENDED_PARK_MS'] = priorParkMs;
|
|
415
|
+
if (priorGraceMs === undefined)
|
|
416
|
+
delete process.env['CRTR_TEST_PARK_SUMMARY_GRACE_MS'];
|
|
417
|
+
else
|
|
418
|
+
process.env['CRTR_TEST_PARK_SUMMARY_GRACE_MS'] = priorGraceMs;
|
|
419
|
+
}
|
|
420
|
+
});
|
|
352
421
|
test('an unattended resident awaiting a live child remains wakeable', { timeout: 30_000 }, async () => {
|
|
353
422
|
const priorParkMs = process.env['CRTR_TEST_UNATTENDED_PARK_MS'];
|
|
354
423
|
process.env['CRTR_TEST_UNATTENDED_PARK_MS'] = '1500';
|