@north-light/crouter 0.3.306 → 0.3.308
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/plugin-manifest-schema.d.ts +2 -1
- package/dist/builtin-memory/internal/plugins.md +1 -1
- package/dist/builtin-pi-packages/pi-crtr-extensions/package-lock.json +635 -163
- package/dist/commands/cron.js +9 -7
- package/dist/core/__tests__/integration/command-plugins.test.js +44 -4
- package/dist/core/__tests__/integration/lifecycle-hooks.test.js +5 -0
- package/dist/core/__tests__/relaunch-root.test.js +2 -2
- package/dist/core/command-hooks/lifecycle-catalog.d.ts +1 -1
- package/dist/core/command-hooks/lifecycle-catalog.js +1 -1
- package/dist/core/command-hooks/transport/exec-lifecycle.d.ts +1 -1
- package/dist/core/command-hooks/transport/exec-lifecycle.js +1 -1
- package/dist/core/command-manifests/schema.js +2 -1
- package/dist/core/command-plugins/discovery.js +36 -1
- package/dist/core/command.d.ts +2 -1
- package/dist/core/human/component-docs.js +3 -1
- package/dist/core/runtime/close.d.ts +2 -0
- package/dist/core/runtime/close.js +51 -9
- package/dist/core/runtime/host.d.ts +13 -5
- package/dist/core/runtime/host.js +144 -177
- package/dist/core/runtime/placement.d.ts +1 -1
- package/dist/core/runtime/placement.js +3 -2
- package/dist/daemon/api/handlers/nodes.js +3 -0
- package/dist/daemon/control.d.ts +2 -0
- package/dist/daemon/crtrd.js +1 -1
- package/dist/hook-authoring.d.ts +2 -2
- package/dist/hook-authoring.js +2 -2
- package/package.json +1 -1
- package/runtime.lock.json +5 -5
package/dist/commands/cron.js
CHANGED
|
@@ -132,7 +132,7 @@ const addLeaf = defineLeaf({
|
|
|
132
132
|
{ kind: 'stdin', name: 'command', required: true, constraint: "Arbitrary bash the daemon runs at each fire. Pipe it from a single-quoted heredoc (`<<'EOF'`) to preserve literal bytes. Runs via `bash -c` in this cwd with $CRTR_CRON_ID, $CRTR_CRON_NAME, and $CRTR_RUN_ID in the env ($CRTR_ANCHOR too when anchored). The scheduler does not interpret it as a node action; a bash predicate can decide whether this fire acts, not arm a native predicate trigger. A run that must branch on canvas state should call `crtr --json <cmd>` and read the leaf's declared outputs, rather than parsing the prose output." },
|
|
133
133
|
{ kind: 'flag', name: 'name', type: 'string', required: true, constraint: 'Agent-legible label — how the cron shows in `cron list`.' },
|
|
134
134
|
{ kind: 'flag', name: 'at', type: 'string', required: false, constraint: 'One-shot: run the stored bash once at <when> — a duration ("90s","1h30m"), a zoned ISO ("2026-06-07T09:00:00Z"), or a bare ISO ("2026-06-07T09:00", interpreted in --tz else host zone). Exactly one of --at / --every is required. The row is deleted after its run unless the run\'s disposition pauses it.' },
|
|
135
|
-
{ kind: 'flag', name: 'every', type: 'string', required: false, constraint: 'Recurring: run the stored bash at each cadence — a fixed interval ("6h" — first fire one interval from now), a 5-field cron ("0 9 * * *"), or an @alias ("@daily"). Each fire is another bash run. Minimum cadence 60s. Exactly one of --at / --every is required.
|
|
135
|
+
{ kind: 'flag', name: 'every', type: 'string', required: false, constraint: 'Recurring: run the stored bash at each cadence — a fixed interval ("6h" — first fire one interval from now), a 5-field cron ("0 9 * * *"), or an @alias ("@daily"). Each fire is another bash run. Minimum cadence 60s. Exactly one of --at / --every is required. Use this for a bash condition that only the local filesystem knows: if it exits 75, the next cadence re-checks it. A host platform can also re-check it early by calling the daemon\'s `POST /v1/crons/poke` endpoint.' },
|
|
136
136
|
{ kind: 'flag', name: 'tz', type: 'string', required: false, constraint: 'IANA zone (e.g. "America/New_York") for a bare-ISO --at or a calendar --every. Defaults to the host zone.' },
|
|
137
137
|
{ kind: 'flag', name: 'on-output', type: 'enum', choices: ['silent', 'on-failure', 'always', 'on-change'], required: false, constraint: 'What each run does with its output. Defaults to on-change for a node:<id> sink; otherwise on-failure. silent: record in the run log, tell nobody — never escalate. on-failure: no success delivery; a failing run pauses the cron and spawns a node to deal with it. always: deliver stdout every run to --sink at --tier. on-change: deliver stdout only when it differs from the previous run\'s — the shape polling wants.' },
|
|
138
138
|
{ kind: 'flag', name: 'sink', type: 'string', required: false, constraint: 'Where always/on-change stdout deliveries go: "node:<id>" (an existing node\'s inbox — must exist now; gone/finalized at fire time is a failure), "spawn:<kind>" (each delivered output creates a self-finishing node with stdout as kickoff — a managed child while its creator exists, otherwise a terminal parentless root), or "human" (the humanloop inbox). Required by always/on-change, rejected otherwise.' },
|
|
@@ -282,7 +282,7 @@ const listLeaf = defineLeaf({
|
|
|
282
282
|
summary: 'every cron in scope, next-fire order',
|
|
283
283
|
params: [],
|
|
284
284
|
output: [
|
|
285
|
-
{ name: 'crons', type: 'object[]', required: true, constraint: 'One row per cron: cron_id, name, fire_at (next fire, UTC), recur (cadence display), state (active|paused), held (true
|
|
285
|
+
{ name: 'crons', type: 'object[]', required: true, constraint: 'One row per cron: cron_id, name, fire_at (next fire, UTC), recur (cadence display), state (active|paused), held (true after an exit-75 gate; a recurring row re-checks at fire_at or when a host platform calls `POST /v1/crons/poke`, while a one-shot needs that call or `crtr cron run <cron-id>`), on_output, sink, expires_at, last_run (the most recent settled run: finished, exit_code, delivered — null if it never ran).' },
|
|
286
286
|
],
|
|
287
287
|
outputKind: 'object',
|
|
288
288
|
effects: ['None. Read-only.'],
|
|
@@ -325,7 +325,9 @@ const listLeaf = defineLeaf({
|
|
|
325
325
|
? ' [PAUSED (held) — resume or cancel]'
|
|
326
326
|
: ' [PAUSED — resume or cancel]'
|
|
327
327
|
: c.held
|
|
328
|
-
?
|
|
328
|
+
? c.recur === 'none'
|
|
329
|
+
? ` [HELD — last run exited 75; run manually with \`crtr cron run ${c.cron_id}\`, or wait for a host platform to call \`POST /v1/crons/poke\`]`
|
|
330
|
+
: ' [HELD — last run exited 75; re-checks at the next cadence or when a host platform calls `POST /v1/crons/poke`]'
|
|
329
331
|
: '';
|
|
330
332
|
return `- ${c.name} (${c.cron_id}) — ${bits.join(', ')}${marker}`;
|
|
331
333
|
});
|
|
@@ -366,10 +368,10 @@ const showLeaf = defineLeaf({
|
|
|
366
368
|
const scheduleLine = !c.held
|
|
367
369
|
? `- schedule: ${schedule}${tzPart} — next fire ${c.fire_at}`
|
|
368
370
|
: c.recur !== null
|
|
369
|
-
? `- schedule: ${schedule}${tzPart} —
|
|
371
|
+
? `- schedule: ${schedule}${tzPart} — next re-check ${c.fire_at} (held — also re-checks when a host platform calls \`POST /v1/crons/poke\`; last run exited 75)`
|
|
370
372
|
: c.expires_at !== null
|
|
371
|
-
? `- schedule: ${schedule}${tzPart} —
|
|
372
|
-
: `- schedule: ${schedule}${tzPart} —
|
|
373
|
+
? `- schedule: ${schedule}${tzPart} — no scheduled re-check — expires ${c.expires_at} (held — run manually with \`crtr cron run ${c.cron_id}\`, or wait for a host platform to call \`POST /v1/crons/poke\`; deleted unfired at expiry)`
|
|
374
|
+
: `- schedule: ${schedule}${tzPart} — no scheduled re-check (held — run manually with \`crtr cron run ${c.cron_id}\`, or wait for a host platform to call \`POST /v1/crons/poke\`)`;
|
|
373
375
|
const stateTag = c.state === 'paused' ? (c.held ? ' — PAUSED (held)' : ' — PAUSED') : c.held ? ' — HELD' : '';
|
|
374
376
|
const lines = [
|
|
375
377
|
`# ${c.name} (${c.cron_id})${stateTag}`,
|
|
@@ -525,7 +527,7 @@ export function registerCron() {
|
|
|
525
527
|
help: {
|
|
526
528
|
name: 'cron',
|
|
527
529
|
summary: 'scheduled bash commands, daemon-run',
|
|
528
|
-
model: 'A cron stores and daemon-runs arbitrary bash at its clock. Use ordinary bash for ordinary work; choose node:<id> for stdout as inbox information to an existing node (its disposition defaults to on-change), an existing node\'s lifecycle fresh-revive action in the bash for a clean re-check without inbox output, or spawn:<kind> to route each output-producing fire to a fresh self-finishing node (a managed child while its creator exists, otherwise a terminal parentless root). Per-fire fresh agent work belongs on the existing spawn:<kind> sink, not an independent resident-root birth. A gate is bash, at the top of the command. Exit 0 after deciding not to act and the occurrence is simply spent — right when the recurrence is your polling cadence. Exit 75
|
|
530
|
+
model: 'A cron stores and daemon-runs arbitrary bash at its clock. Use ordinary bash for ordinary work; choose node:<id> for stdout as inbox information to an existing node (its disposition defaults to on-change), an existing node\'s lifecycle fresh-revive action in the bash for a clean re-check without inbox output, or spawn:<kind> to route each output-producing fire to a fresh self-finishing node (a managed child while its creator exists, otherwise a terminal parentless root). Per-fire fresh agent work belongs on the existing spawn:<kind> sink, not an independent resident-root birth. A gate is bash, at the top of the command. Exit 0 after deciding not to act and the occurrence is simply spent — right when the recurrence is your polling cadence. Exit 75 holds the row. A held recurring row re-checks at its next natural slot, or when a host platform calls the daemon\'s `POST /v1/crons/poke` endpoint. A held one-shot has no scheduled re-check: it stays held until that endpoint is called, or you execute its bash explicitly with `crtr cron run <cron-id>` (which does not unhold or reschedule the row). A local filesystem condition does not call the daemon, so use --every for that condition or run the cron explicitly after it changes. Any other nonzero exit is a real failure and escalates per --on-output. Keep gate errors nonzero so a broken probe is loud instead of silently held. Beyond the exit-code contract, cron has no native predicate-trigger semantics. --at runs once and deletes the row unless the run\'s disposition pauses it; --every runs each cadence. Output disposition (--on-output) records silently, escalates failures, or routes stdout to its --sink. Anchor a cron to delete it with a node; --cancel-on-wake instead deletes it when the anchor wakes. An unanchored recurring cron ends through --expires, explicit cancellation, or self-cancellation from its bash. Every run lands in a bounded per-cron run log (`cron show`); cwd, env, and profile are snapshotted at arm time, and --scope controls who lists and cancels it.',
|
|
529
531
|
},
|
|
530
532
|
children: [addLeaf, listLeaf, showLeaf, runLeaf, pauseLeaf, resumeLeaf, cancelLeaf],
|
|
531
533
|
});
|
|
@@ -153,7 +153,7 @@ process.stdin.on('end', () => {
|
|
|
153
153
|
process.stdout.write(JSON.stringify({ protocolVersion: 1, ok: true, result: { value: request.input.thing } }));
|
|
154
154
|
});
|
|
155
155
|
`;
|
|
156
|
-
function passthroughCommandsJson() {
|
|
156
|
+
function passthroughCommandsJson(bin = 'capture') {
|
|
157
157
|
return {
|
|
158
158
|
schemaVersion: 1,
|
|
159
159
|
mounts: [{
|
|
@@ -169,7 +169,7 @@ function passthroughCommandsJson() {
|
|
|
169
169
|
whenToUse: 'driving a browser through the Capture CLI',
|
|
170
170
|
},
|
|
171
171
|
summary: 'browser automation through Capture',
|
|
172
|
-
passthrough: { bin
|
|
172
|
+
passthrough: { bin, installHint: 'Install @crouton-kit/capture globally.' },
|
|
173
173
|
children: [],
|
|
174
174
|
},
|
|
175
175
|
}],
|
|
@@ -417,17 +417,17 @@ describe('protocol integration', () => {
|
|
|
417
417
|
});
|
|
418
418
|
}
|
|
419
419
|
});
|
|
420
|
+
const CLI = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..', 'dist', 'cli.js');
|
|
420
421
|
// 4b. True end-to-end CLI invocation (argv → parseArgv → adapter → renderer)
|
|
421
422
|
describe('end-to-end CLI invocation', () => {
|
|
422
423
|
// dist/cli.js is up 4 from src/core/__tests__/integration/. Requires a prior
|
|
423
424
|
// `npm run build` (the harness/CI builds before tests). Drives the WHOLE seam: the
|
|
424
425
|
// declared positional `app-id` must arrive as `appId` in the request, and
|
|
425
426
|
// the rendered + --json outputs come through the real dispatcher.
|
|
426
|
-
const cli = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..', 'dist', 'cli.js');
|
|
427
427
|
const runCrtr = (args) => {
|
|
428
428
|
installPlugin(userRoot, 'deploy-tools');
|
|
429
429
|
resetScopeCache();
|
|
430
|
-
return execFileSync(process.execPath, [
|
|
430
|
+
return execFileSync(process.execPath, [CLI, ...args], {
|
|
431
431
|
cwd: emptyStart,
|
|
432
432
|
env: { ...process.env, HOME: home, CRTR_FRONT_DOOR: '' },
|
|
433
433
|
encoding: 'utf8',
|
|
@@ -445,6 +445,38 @@ describe('end-to-end CLI invocation', () => {
|
|
|
445
445
|
assert.equal(obj.status, 'running');
|
|
446
446
|
});
|
|
447
447
|
});
|
|
448
|
+
// Passthrough paths resolve inside their plugin during discovery. Bare names are
|
|
449
|
+
// deliberately left for the invoking process's PATH.
|
|
450
|
+
describe('passthrough binary resolution', () => {
|
|
451
|
+
function runPassthrough(args, env = process.env) {
|
|
452
|
+
return execFileSync(process.execPath, [CLI, ...args], {
|
|
453
|
+
cwd: emptyStart,
|
|
454
|
+
env: { ...env, HOME: home, CRTR_FRONT_DOOR: '' },
|
|
455
|
+
encoding: 'utf8',
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
test('a plugin-relative bin runs the plugin program', () => {
|
|
459
|
+
const root = installPlugin(userRoot, 'passthrough-fixture', { manifest: passthroughCommandsJson('bin/passthrough.js'), executable: null });
|
|
460
|
+
const program = join(root, 'bin', 'passthrough.js');
|
|
461
|
+
writeFileSync(program, '#!/bin/sh\nprintf "plugin program: %s\\n" "$*"\n');
|
|
462
|
+
chmodSync(program, 0o755);
|
|
463
|
+
const validation = validatePluginCommands(listInstalledPluginsInRoot('user', userRoot).find((plugin) => plugin.name === 'passthrough-fixture'), RESERVED);
|
|
464
|
+
assert.equal(validation.issues.length, 0, JSON.stringify(validation.issues));
|
|
465
|
+
assert.equal(validation.contributions[0].node.passthrough?.bin, realpathSync(program));
|
|
466
|
+
assert.equal(runPassthrough(['capture', 'one', '--two']).trim(), 'plugin program: one --two');
|
|
467
|
+
});
|
|
468
|
+
test('a bare bin runs the program from PATH', () => {
|
|
469
|
+
installPlugin(userRoot, 'passthrough-fixture', { manifest: passthroughCommandsJson('capture-fixture'), executable: null });
|
|
470
|
+
const pathDir = mintDir('crtr-cmdplugin-path-');
|
|
471
|
+
const program = join(pathDir, 'capture-fixture');
|
|
472
|
+
writeFileSync(program, '#!/bin/sh\nprintf "PATH program: %s\\n" "$*"\n');
|
|
473
|
+
chmodSync(program, 0o755);
|
|
474
|
+
const validation = validatePluginCommands(listInstalledPluginsInRoot('user', userRoot).find((plugin) => plugin.name === 'passthrough-fixture'), RESERVED);
|
|
475
|
+
assert.equal(validation.issues.length, 0, JSON.stringify(validation.issues));
|
|
476
|
+
assert.equal(validation.contributions[0].node.passthrough?.bin, 'capture-fixture');
|
|
477
|
+
assert.equal(runPassthrough(['capture', 'three'], { ...process.env, PATH: `${pathDir}:${process.env['PATH'] ?? ''}` }).trim(), 'PATH program: three');
|
|
478
|
+
});
|
|
479
|
+
});
|
|
448
480
|
// Extensible plugin command branches — repository fragments attach to a marked
|
|
449
481
|
// top-level branch as one native forest and stay entirely inside the existing
|
|
450
482
|
// parser/help/exec contract.
|
|
@@ -794,6 +826,14 @@ describe('typed validation issues', () => {
|
|
|
794
826
|
const v = validate(commandsJson(), { executable: '../../../../bin/sh' });
|
|
795
827
|
assert.ok(v.issues.some((i) => i.code === 'command_path_unsafe'));
|
|
796
828
|
});
|
|
829
|
+
test('passthrough path escaping the plugin root → command_path_unsafe', () => {
|
|
830
|
+
const v = validate(passthroughCommandsJson('../../../../bin/sh'), { executable: null });
|
|
831
|
+
assert.ok(v.issues.some((i) => i.code === 'command_path_unsafe'));
|
|
832
|
+
});
|
|
833
|
+
test('non-executable passthrough path → command_not_executable', () => {
|
|
834
|
+
const v = validate(passthroughCommandsJson('bin/cmd.js'), { executable: null, execBit: false });
|
|
835
|
+
assert.ok(v.issues.some((i) => i.code === 'command_not_executable'));
|
|
836
|
+
});
|
|
797
837
|
test('a top-level leaf (no rootEntry) → command_node_invalid', () => {
|
|
798
838
|
const m = {
|
|
799
839
|
schemaVersion: 1,
|
|
@@ -171,6 +171,11 @@ test('hooks.json v1 remains command-only while v2 accepts a lifecycle-only decla
|
|
|
171
171
|
hooks: [],
|
|
172
172
|
lifecycle: [{ event: 'node:start', phase: 'on', op: 'prepare', description: 'prepare', effects: ['creates a directory'] }],
|
|
173
173
|
});
|
|
174
|
+
const close = validateHookManifest({
|
|
175
|
+
schemaVersion: 2,
|
|
176
|
+
lifecycle: [{ event: 'node:close', phase: 'on', op: 'cleanup', description: 'cleanup', effects: ['removes a directory'] }],
|
|
177
|
+
});
|
|
178
|
+
assert.deepEqual(close.issues, []);
|
|
174
179
|
for (const manifest of [
|
|
175
180
|
{ schemaVersion: 2, hooks: [], lifecycle: [] },
|
|
176
181
|
{ schemaVersion: 2, lifecycle: [{ event: 'node:stop', phase: 'on', op: 'prepare', description: 'prepare', effects: ['creates a directory'] }] },
|
|
@@ -61,7 +61,7 @@ function makeDeps(pid) {
|
|
|
61
61
|
},
|
|
62
62
|
waitForViewSocket: () => true,
|
|
63
63
|
respawnViewer: () => true,
|
|
64
|
-
teardownBroker: () => { },
|
|
64
|
+
teardownBroker: async () => ({ confirmed: true }),
|
|
65
65
|
};
|
|
66
66
|
return { deps, launched };
|
|
67
67
|
}
|
|
@@ -151,7 +151,7 @@ test('relaunchRoot at capacity refuses visibly and settles the half-born row bef
|
|
|
151
151
|
},
|
|
152
152
|
waitForViewSocket: () => true,
|
|
153
153
|
respawnViewer: () => true,
|
|
154
|
-
teardownBroker: () => { },
|
|
154
|
+
teardownBroker: async () => ({ confirmed: true }),
|
|
155
155
|
};
|
|
156
156
|
await assert.rejects(() => relaunchRoot('root', deps), (error) => {
|
|
157
157
|
const details = error.details ?? {};
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export declare const LIFECYCLE_EVENTS: readonly ["node:start"];
|
|
1
|
+
export declare const LIFECYCLE_EVENTS: readonly ["node:start", "node:close"];
|
|
2
2
|
export type LifecycleEvent = typeof LIFECYCLE_EVENTS[number];
|
|
3
3
|
export declare function isLifecycleEvent(value: string): value is LifecycleEvent;
|
|
@@ -8,5 +8,5 @@ export interface LifecycleHookInvocation {
|
|
|
8
8
|
}
|
|
9
9
|
/** Runs one lifecycle hook. The node launch remains authoritative on every failure. */
|
|
10
10
|
export declare function invokeLifecycleHook(hook: EffectiveLifecycleHook, invocation: LifecycleHookInvocation, timeoutMs: number): Promise<void>;
|
|
11
|
-
/** Executes lifecycle hooks in discovery order, within one bounded
|
|
11
|
+
/** Executes lifecycle hooks in discovery order, within one bounded event budget. */
|
|
12
12
|
export declare function invokeLifecycleHooks(hooks: readonly EffectiveLifecycleHook[], invocation: LifecycleHookInvocation): Promise<void>;
|
|
@@ -34,7 +34,7 @@ export async function invokeLifecycleHook(hook, invocation, timeoutMs) {
|
|
|
34
34
|
diag(`crtr: ${label(hook)} failed: ${describeError(error)}`);
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
|
-
/** Executes lifecycle hooks in discovery order, within one bounded
|
|
37
|
+
/** Executes lifecycle hooks in discovery order, within one bounded event budget. */
|
|
38
38
|
export async function invokeLifecycleHooks(hooks, invocation) {
|
|
39
39
|
const deadline = Date.now() + LIFECYCLE_EVENT_BUDGET_MS;
|
|
40
40
|
for (const hook of hooks) {
|
|
@@ -436,7 +436,8 @@ function validateBranch(raw, path, topLevel, transport, issue, options) {
|
|
|
436
436
|
...(passthrough !== undefined ? { passthrough } : {}), children: validated,
|
|
437
437
|
};
|
|
438
438
|
}
|
|
439
|
-
/** `passthrough: { bin, installHint }` —
|
|
439
|
+
/** `passthrough: { bin, installHint }` — `bin` is a bare PATH command or a
|
|
440
|
+
* plugin-root-relative executable path; both fields are required non-empty strings. */
|
|
440
441
|
function validatePassthrough(raw, path, issue) {
|
|
441
442
|
if (!isRecord(raw)) {
|
|
442
443
|
issue('command_node_invalid', 'passthrough must be an object', typeName(raw), 'an object { bin, installHint }', 'Fix or remove passthrough.', path);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { realpathSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
-
import { resolve, sep } from 'node:path';
|
|
2
|
+
import { isAbsolute, resolve, sep } from 'node:path';
|
|
3
3
|
import { listInstalledPlugins, listInstalledPluginsInRoot } from '../resolver.js';
|
|
4
4
|
import { projectScopeRoots } from '../scope.js';
|
|
5
5
|
import { validateCommandManifest } from '../command-manifests/manifest.js';
|
|
@@ -82,6 +82,38 @@ function validateExecExecutable(plugin, transport, manifest, issues) {
|
|
|
82
82
|
}
|
|
83
83
|
return { kind: 'exec', executable };
|
|
84
84
|
}
|
|
85
|
+
function namesPath(bin) {
|
|
86
|
+
return isAbsolute(bin) || bin.startsWith('.') || bin.includes('/') || bin.includes('\\');
|
|
87
|
+
}
|
|
88
|
+
/** Resolve plugin-owned passthrough programs during discovery. Bare program
|
|
89
|
+
* names intentionally remain untouched so spawn resolves them through PATH. */
|
|
90
|
+
function resolvePassthroughBinaries(plugin, manifest, issues) {
|
|
91
|
+
let valid = true;
|
|
92
|
+
const visit = (node) => {
|
|
93
|
+
if (node.kind !== 'branch')
|
|
94
|
+
return;
|
|
95
|
+
const passthrough = node.passthrough;
|
|
96
|
+
if (passthrough !== undefined && namesPath(passthrough.bin)) {
|
|
97
|
+
const executable = safePath(plugin.root, passthrough.bin);
|
|
98
|
+
if (executable === null) {
|
|
99
|
+
issues.push({ code: 'command_path_unsafe', plugin: plugin.name, message: 'passthrough binary escapes the plugin root or is not a regular file', received: passthrough.bin, expected: 'a relative path to a regular file inside the plugin root', next: 'Fix passthrough.bin in commands.json.', path: 'passthrough.bin' });
|
|
100
|
+
valid = false;
|
|
101
|
+
}
|
|
102
|
+
else if ((statSync(executable).mode & 0o111) === 0) {
|
|
103
|
+
issues.push({ code: 'command_not_executable', plugin: plugin.name, message: 'passthrough binary lacks the POSIX exec bit', received: passthrough.bin, expected: 'a file with an executable permission bit', next: 'chmod +x the plugin executable, then update commands.json.', path: 'passthrough.bin' });
|
|
104
|
+
valid = false;
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
node.passthrough = { ...passthrough, bin: executable };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
for (const child of node.children)
|
|
111
|
+
visit(child);
|
|
112
|
+
};
|
|
113
|
+
for (const root of manifest.roots)
|
|
114
|
+
visit(root);
|
|
115
|
+
return valid;
|
|
116
|
+
}
|
|
85
117
|
export function validatePluginCommands(plugin, reservedNames = new Set(), coreCommandPaths) {
|
|
86
118
|
const issues = [];
|
|
87
119
|
const commands = plugin.manifest.commands;
|
|
@@ -131,6 +163,9 @@ export function validatePluginCommands(plugin, reservedNames = new Set(), coreCo
|
|
|
131
163
|
: transport;
|
|
132
164
|
if (resolvedTransport === undefined)
|
|
133
165
|
return { plugin, manifestPath, transport, contributions: [], issues };
|
|
166
|
+
if (resolvedTransport.kind === 'exec' && !resolvePassthroughBinaries(plugin, validation.manifest, issues)) {
|
|
167
|
+
return { plugin, manifestPath, transport: resolvedTransport, contributions: [], issues };
|
|
168
|
+
}
|
|
134
169
|
const roots = validation.manifest.roots;
|
|
135
170
|
if (!roots.every((node) => node.kind === 'branch')) {
|
|
136
171
|
throw new Error('plugin manifest validation returned a top-level leaf');
|
package/dist/core/command.d.ts
CHANGED
|
@@ -82,7 +82,8 @@ export interface BranchDef {
|
|
|
82
82
|
/** Opt this branch out of the tree model entirely: every token after this
|
|
83
83
|
* branch's name is forwarded VERBATIM (raw argv, not the `--json`-filtered
|
|
84
84
|
* tokens) to an external binary via spawn, with stdio inherited and the
|
|
85
|
-
* child's exit code propagated.
|
|
85
|
+
* child's exit code propagated. Plugin-owned paths have already resolved to
|
|
86
|
+
* absolute executable paths; bare program names resolve through PATH. A deliberate, documented exception for
|
|
86
87
|
* wrapping an external CLI whose own schema crtr cannot and must not
|
|
87
88
|
* duplicate (the official marketplace's `capture` plugin mounts `crtr
|
|
88
89
|
* capture` this way) — not a general escape hatch. A
|
|
@@ -9,6 +9,7 @@ const SLOT_RULES = `Component contract
|
|
|
9
9
|
- \`id\` is required, must match \`^[A-Za-z0-9_-]{1,64}$\`, and must be unique across the page.
|
|
10
10
|
- When a page has two or more response-bearing components, every one needs a nonempty \`label\`.
|
|
11
11
|
- Config objects and every documented nested object reject unknown fields.
|
|
12
|
+
- A quoted JSX attribute does not interpret escape sequences, so \`body="one.\\n\\ntwo."\` reaches the reader as one run-on paragraph with a literal backslash-n in the middle of it. Write any markdown body that has a line or paragraph break as a braced string — \`body={"one.\\n\\ntwo."}\` — where the escape is real.
|
|
12
13
|
- The page is rendered once at submit to build the ticket manifest, so every prop reaches it whatever its shape — mapped data, a helper component, a value from \`useState\`. Event handlers are dropped, because a manifest is JSON. The user's answer lands in \`response.json\` keyed by this \`id\`.`;
|
|
13
14
|
const COMMENT_SHAPE = `Comments are \`{id:string (nonempty), text:string, anchor:Anchor}\` and qualify the answer itself. \`Anchor\` is \`{kind:"option",optionId:string}\` or \`{kind:"row",rowId:string}\`; each component accepts only the anchors named next. The reader writes one against a specific option or row in whatever host they read in — in the terminal inbox, \`c\` on the selected one — so every option and row needs a label that reads on its own. Feedback on the page's own prose is a separate channel, offered only by hosts that render the page, and never part of a response.`;
|
|
14
15
|
const DISPLAY_RULES = `Display-only: it is not recorded in the page manifest and contributes nothing to the response. Every display component also takes \`className\` for Tailwind utilities, and standard shadcn props otherwise.`;
|
|
@@ -92,7 +93,8 @@ Response rules
|
|
|
92
93
|
|
|
93
94
|
Example
|
|
94
95
|
\`\`\`jsx
|
|
95
|
-
<UserQuestion id="lane" label="Release lane"
|
|
96
|
+
<UserQuestion id="lane" label="Release lane" mode="single" allowFreetext
|
|
97
|
+
body={"The audit closes tomorrow.\\n\\nBoth release gates are green as of this morning."}
|
|
96
98
|
freetextLabel="Something else"
|
|
97
99
|
options={[
|
|
98
100
|
{ id: 'now', label: 'Ship now', description: 'Both gates are green.', recommended: true },
|
|
@@ -33,4 +33,6 @@ export interface CloseNodeResult {
|
|
|
33
33
|
export declare function closeNode(rootId: string, opts?: {
|
|
34
34
|
rootEvent?: 'cancel' | 'finish';
|
|
35
35
|
callerPid?: number;
|
|
36
|
+
/** Daemon-owned close work that must drain before this daemon exits. */
|
|
37
|
+
registerDetached?: (work: Promise<void>) => void;
|
|
36
38
|
}): CloseNodeResult;
|
|
@@ -36,6 +36,10 @@ import { cancelCronsOnWake, getNode, subscriptionsOf, subscribersOf, } from '../
|
|
|
36
36
|
import { transition } from './lifecycle.js';
|
|
37
37
|
import { requestBrokerTeardown } from './host.js';
|
|
38
38
|
import { tearDownNode, reapIfEmpty } from './placement.js';
|
|
39
|
+
import { crtrHome, contextDir, jobDir, nodeDir, reportsDir } from '../canvas/paths.js';
|
|
40
|
+
import { discoverLifecycleHookRegistry } from '../command-hooks/discovery.js';
|
|
41
|
+
import { invokeLifecycleHooks } from '../command-hooks/transport/exec-lifecycle.js';
|
|
42
|
+
import { emitEvent } from '../events/emit.js';
|
|
39
43
|
import { appendInbox } from '../feed/inbox.js';
|
|
40
44
|
import { appendPassive } from '../feed/passive.js';
|
|
41
45
|
/** Fan a lifecycle or birth notice to every given subscriber. Active subscribers
|
|
@@ -173,12 +177,50 @@ export function closeNode(rootId, opts = {}) {
|
|
|
173
177
|
// Surviving managers captured BEFORE any teardown — a reap (below) deletes
|
|
174
178
|
// this node's edges, so the step-4 fan-out must read them up front.
|
|
175
179
|
const survivors = subscribersOf(id).filter((s) => !closing.has(s.node_id));
|
|
180
|
+
// Capture the target-scoped hook plan and payload before an empty node's
|
|
181
|
+
// reap can remove its row and directories. The executable still runs from
|
|
182
|
+
// its plugin root; the payload paths can therefore name a reaped directory.
|
|
183
|
+
let afterTeardown;
|
|
184
|
+
try {
|
|
185
|
+
const hooks = discoverLifecycleHookRegistry(m.cwd, m.profile_id).plans.get('node:close') ?? [];
|
|
186
|
+
if (hooks.length > 0) {
|
|
187
|
+
const invocation = {
|
|
188
|
+
node: {
|
|
189
|
+
id,
|
|
190
|
+
name: m.name,
|
|
191
|
+
kind: m.kind,
|
|
192
|
+
mode: m.mode,
|
|
193
|
+
lifecycle: m.lifecycle,
|
|
194
|
+
cwd: m.cwd,
|
|
195
|
+
nodeDir: nodeDir(id),
|
|
196
|
+
contextDir: contextDir(id),
|
|
197
|
+
jobDir: jobDir(id),
|
|
198
|
+
reportsDir: reportsDir(id),
|
|
199
|
+
},
|
|
200
|
+
runtime: { isBirth: false, canvasHome: crtrHome(), profile: m.profile_id ?? null },
|
|
201
|
+
};
|
|
202
|
+
afterTeardown = () => invokeLifecycleHooks(hooks, invocation);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
emitEvent({ level: 'error', event: 'node.close_hook.discovery_failed', node_id: id, error });
|
|
207
|
+
}
|
|
208
|
+
// Capture the process snapshot while the row exists, then execute after
|
|
209
|
+
// the response settles. A failed teardown skips the lifecycle hook rather
|
|
210
|
+
// than claiming cleanup was safe.
|
|
211
|
+
const teardown = requestBrokerTeardown(id, id === rootId ? { excludePid: opts.callerPid } : {}, afterTeardown);
|
|
212
|
+
try {
|
|
213
|
+
opts.registerDetached?.(teardown.then(() => { }));
|
|
214
|
+
}
|
|
215
|
+
catch (error) {
|
|
216
|
+
emitEvent({ level: 'error', event: 'node.close_hook.detached_registration_failed', node_id: id, error });
|
|
217
|
+
}
|
|
176
218
|
// 0) A node with no substantive assistant output is a useless shell — don't
|
|
177
219
|
// park it as a canceled husk; reap it outright (engine + viewer + row +
|
|
178
|
-
// dir).
|
|
179
|
-
// skip the cancel transition + resume notice (the node
|
|
180
|
-
// fan the "child gone" wake out to
|
|
181
|
-
if (!reapIfEmpty(id)) {
|
|
220
|
+
// dir). Its teardown was queued above from the preserved snapshot; when
|
|
221
|
+
// reaping fires we skip the cancel transition + resume notice (the node
|
|
222
|
+
// is gone) but still fan the "child gone" wake out to survivors below.
|
|
223
|
+
if (!reapIfEmpty(id, undefined, false)) {
|
|
182
224
|
// 1) Terminal status set BEFORE the window dies (daemon race). The root may
|
|
183
225
|
// finish to `done` (a deliberate "mark complete" close-out); every
|
|
184
226
|
// descendant, and the root by default, `cancel`s. finish is legal only
|
|
@@ -199,9 +241,9 @@ export function closeNode(rootId, opts = {}) {
|
|
|
199
241
|
else {
|
|
200
242
|
transition(id, 'cancel', { reason: 'closed' });
|
|
201
243
|
}
|
|
202
|
-
// 2)
|
|
203
|
-
//
|
|
204
|
-
|
|
244
|
+
// 2) The daemon-owned teardown was queued above after capturing this
|
|
245
|
+
// node's process tree. The root caller's shell was excluded from its
|
|
246
|
+
// own broker tree before the response can settle.
|
|
205
247
|
tearDownNode(id);
|
|
206
248
|
// 3) Leave the resume notice AFTER the watcher is gone, so it survives.
|
|
207
249
|
appendInbox(id, {
|
|
@@ -226,8 +268,8 @@ export function closeNode(rootId, opts = {}) {
|
|
|
226
268
|
fanDoctrineWake(id, survivors, `Child closed — ${m.name} (${id}) was closed from the canvas and is no longer running.`, { reason: 'child-closed', child: id });
|
|
227
269
|
closed.push(id);
|
|
228
270
|
}
|
|
229
|
-
catch {
|
|
230
|
-
|
|
271
|
+
catch (error) {
|
|
272
|
+
emitEvent({ level: 'error', event: 'node.close.failed', node_id: id, error });
|
|
231
273
|
}
|
|
232
274
|
}
|
|
233
275
|
return { root: rootId, closed, spared };
|
|
@@ -25,6 +25,13 @@ export interface HostHandle {
|
|
|
25
25
|
export interface TeardownOptions {
|
|
26
26
|
excludePid?: number;
|
|
27
27
|
}
|
|
28
|
+
/** The only completion signal a caller may use for post-teardown work. A false
|
|
29
|
+
* result means the original engine tree could not be confirmed dead, so no
|
|
30
|
+
* cleanup may claim it was safe to remove resources it owned. */
|
|
31
|
+
export interface TeardownResult {
|
|
32
|
+
confirmed: boolean;
|
|
33
|
+
error?: Error;
|
|
34
|
+
}
|
|
28
35
|
export interface Host {
|
|
29
36
|
/** Bring a node's ENGINE into existence from its launch recipe; return a
|
|
30
37
|
* supervisable handle. */
|
|
@@ -33,8 +40,9 @@ export interface Host {
|
|
|
33
40
|
* both call sites — the revive guard (passes the id) and the daemon (passes a
|
|
34
41
|
* row) — share one selector. For the broker this IS isPidAlive(pi_pid). */
|
|
35
42
|
isAlive(node: string | NodeRow): boolean;
|
|
36
|
-
/** Tear the engine down (close/cancel teardown).
|
|
37
|
-
|
|
43
|
+
/** Tear the engine down (close/cancel teardown). Resolves only after the
|
|
44
|
+
* fixed process snapshot is confirmed dead, or with a diagnostic failure. */
|
|
45
|
+
teardown(nodeId: string, opts?: TeardownOptions): Promise<TeardownResult>;
|
|
38
46
|
/** Deliver an OS signal to the engine container — present so the daemon never
|
|
39
47
|
* reaches around the abstraction. */
|
|
40
48
|
signal(nodeId: string, sig: NodeJS.Signals): void;
|
|
@@ -43,7 +51,7 @@ export interface Host {
|
|
|
43
51
|
* the fast-fail seam for known module/path errors: a bad engine resolve or a
|
|
44
52
|
* missing plain session path throws here, before spawn(). */
|
|
45
53
|
export declare function preflightBrokerLaunch(nodeId: string, inv: PiInvocation, cwd: string): void;
|
|
46
|
-
/** Queue broker teardown
|
|
47
|
-
*
|
|
48
|
-
export declare function requestBrokerTeardown(nodeId: string, opts?: TeardownOptions):
|
|
54
|
+
/** Queue broker teardown after the close response settles. Completion includes
|
|
55
|
+
* any post-teardown work, which lets the daemon drain it during shutdown. */
|
|
56
|
+
export declare function requestBrokerTeardown(nodeId: string, opts?: TeardownOptions, after?: () => Promise<void>): Promise<TeardownResult>;
|
|
49
57
|
export declare const headlessBrokerHost: Host;
|