@north-light/crouter 0.3.269 → 0.3.270
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/broker.d.ts +11 -2
- package/dist/api/dto/broker.js +2 -2
- package/dist/api/dto/nodes.d.ts +4 -0
- package/dist/clients/attach/viewer.js +406 -406
- package/dist/commands/pkg/plugin-inspect.js +2 -2
- package/dist/commands/pkg/plugin-manage.js +3 -3
- package/dist/commands/sys/doctor.js +4 -3
- package/dist/core/__tests__/human-deliver.test.js +1 -1
- package/dist/core/__tests__/integration/lifecycle-hooks.test.d.ts +1 -0
- package/dist/core/__tests__/integration/lifecycle-hooks.test.js +182 -0
- package/dist/core/__tests__/integration/revive.test.js +15 -15
- package/dist/core/__tests__/push-final-guard.test.js +1 -1
- package/dist/core/__tests__/revive-capacity.test.js +20 -20
- package/dist/core/__tests__/revive-parked-fresh.test.js +14 -14
- package/dist/core/__tests__/seam/broker-cap-freeze.test.js +2 -2
- package/dist/core/__tests__/seam/dormancy-release.test.js +7 -7
- package/dist/core/__tests__/seam/held-deferred-human-prompt.test.js +1 -1
- package/dist/core/__tests__/seam/yield-refresh-transaction.test.js +2 -2
- package/dist/core/command-hooks/discovery.d.ts +27 -1
- package/dist/core/command-hooks/discovery.js +52 -0
- package/dist/core/command-hooks/index.d.ts +4 -3
- package/dist/core/command-hooks/index.js +2 -1
- package/dist/core/command-hooks/lifecycle-catalog.d.ts +3 -0
- package/dist/core/command-hooks/lifecycle-catalog.js +4 -0
- package/dist/core/command-hooks/report.d.ts +11 -2
- package/dist/core/command-hooks/report.js +10 -1
- package/dist/core/command-hooks/schema.d.ts +16 -1
- package/dist/core/command-hooks/schema.js +128 -40
- package/dist/core/command-hooks/transport/exec-lifecycle.d.ts +12 -0
- package/dist/core/command-hooks/transport/exec-lifecycle.js +152 -0
- package/dist/core/human/feedback-companion.js +1 -1
- package/dist/core/review/realize.js +1 -1
- package/dist/core/runtime/broker/engine-drive.d.ts +14 -3
- package/dist/core/runtime/broker/engine-drive.js +29 -1
- package/dist/core/runtime/broker/frame-client.js +3 -4
- package/dist/core/runtime/broker/rebind.js +7 -0
- package/dist/core/runtime/broker-protocol.d.ts +11 -2
- package/dist/core/runtime/broker.d.ts +1 -1
- package/dist/core/runtime/broker.js +13 -3
- package/dist/core/runtime/node-read.d.ts +2 -0
- package/dist/core/runtime/node-read.js +18 -9
- package/dist/core/runtime/revive-all.d.ts +1 -1
- package/dist/core/runtime/revive-all.js +2 -2
- package/dist/core/runtime/revive.d.ts +2 -2
- package/dist/core/runtime/revive.js +49 -13
- package/dist/core/runtime/session-visibility.d.ts +10 -14
- package/dist/core/runtime/session-visibility.js +43 -30
- package/dist/core/runtime/stamp/channel.d.ts +10 -6
- package/dist/core/runtime/stamp/channel.js +16 -7
- package/dist/core/runtime/stamp/protocol.d.ts +4 -0
- package/dist/core/runtime/turn-visibility.d.ts +10 -0
- package/dist/core/runtime/turn-visibility.js +31 -0
- package/dist/daemon/api/__tests__/profile-launch-gates.test.js +2 -2
- package/dist/daemon/api/bridge.js +1 -1
- package/dist/daemon/api/handlers/attach.js +4 -4
- package/dist/daemon/api/handlers/bash-jobs.js +1 -1
- package/dist/daemon/api/handlers/messages.js +2 -2
- package/dist/daemon/api/handlers/nodes.js +7 -4
- package/dist/daemon/cron/sinks.js +3 -3
- package/dist/daemon/fleet.js +1 -1
- package/dist/daemon/messaging/node-message.js +1 -1
- package/dist/daemon/profile-delete.js +1 -1
- package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.js +6 -6
- package/dist/hook-authoring.d.ts +31 -1
- package/dist/hook-authoring.js +42 -5
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -0
- package/dist/pi-extensions/canvas-stamp.js +3 -3
- package/dist/shared/generated-context.d.ts +1 -2
- package/dist/shared/generated-context.js +3 -4
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
|
@@ -79,7 +79,7 @@ test('idle-release settles its real watcher before exit; daemon revives exactly
|
|
|
79
79
|
},
|
|
80
80
|
},
|
|
81
81
|
});
|
|
82
|
-
reviveNode(manager, { resume: true, capacity: 'freeze' });
|
|
82
|
+
await reviveNode(manager, { resume: true, capacity: 'freeze' });
|
|
83
83
|
await h.awaitBoot(manager);
|
|
84
84
|
// A live managed child makes this unfocused terminal manager legitimately
|
|
85
85
|
// awaiting, so its real stophook takes the idle-release branch.
|
|
@@ -172,7 +172,7 @@ test('an unattended resident with no live obligation completes on the daemon clo
|
|
|
172
172
|
},
|
|
173
173
|
});
|
|
174
174
|
subscribe(root, nodeId, true);
|
|
175
|
-
reviveNode(nodeId, { resume: true, capacity: 'freeze' });
|
|
175
|
+
await reviveNode(nodeId, { resume: true, capacity: 'freeze' });
|
|
176
176
|
const boot = await h.awaitBoot(nodeId);
|
|
177
177
|
assert.equal(h.fleet.has(nodeId), true, 'the revived broker is fleet-owned');
|
|
178
178
|
const seenAt = 10_000;
|
|
@@ -223,7 +223,7 @@ test('a non-attending observer does not prevent unattended resident parking', {
|
|
|
223
223
|
intent: 'idle-release',
|
|
224
224
|
});
|
|
225
225
|
updateNode(nodeId, { launch: { extensions: [], tools: [], systemPrompt: '', env: {} } });
|
|
226
|
-
reviveNode(nodeId, { resume: true, capacity: 'freeze' });
|
|
226
|
+
await reviveNode(nodeId, { resume: true, capacity: 'freeze' });
|
|
227
227
|
const boot = await h.awaitBoot(nodeId);
|
|
228
228
|
await attach.attach(nodeId, 'observer', 'system-observer-test', { attends: false });
|
|
229
229
|
assert.equal(JSON.parse(readFileSync(join(h.home, 'nodes', nodeId, 'job', 'attach.json'), 'utf8')).viewers, 0, 'a non-attending hello is not persisted as human attendance');
|
|
@@ -268,7 +268,7 @@ test('a connected broker viewer keeps an obligation-free resident live', { timeo
|
|
|
268
268
|
env: {},
|
|
269
269
|
},
|
|
270
270
|
});
|
|
271
|
-
reviveNode(nodeId, { resume: true, capacity: 'freeze' });
|
|
271
|
+
await reviveNode(nodeId, { resume: true, capacity: 'freeze' });
|
|
272
272
|
const boot = await h.awaitBoot(nodeId);
|
|
273
273
|
// awaitBoot returns on the boot proof, which is written BEFORE the broker's
|
|
274
274
|
// own `persistAttachState()` lays down the truthful zero state. Writing our
|
|
@@ -311,7 +311,7 @@ test('the unattended park clock cancels an obligation-free terminal node and spa
|
|
|
311
311
|
try {
|
|
312
312
|
const boot = async (id) => {
|
|
313
313
|
updateNode(id, { launch: { extensions: [], tools: [], systemPrompt: '', env: {} } });
|
|
314
|
-
reviveNode(id, { resume: true, capacity: 'freeze' });
|
|
314
|
+
await reviveNode(id, { resume: true, capacity: 'freeze' });
|
|
315
315
|
return (await h.awaitBoot(id)).pid;
|
|
316
316
|
};
|
|
317
317
|
const settled = h.fabricateBrokerNode({ id: 'terminal-no-work', parent: root, status: 'idle', intent: 'idle-release' });
|
|
@@ -378,7 +378,7 @@ test('a resident managed child parks without finalizing while a terminal child i
|
|
|
378
378
|
subscribe(terminalManager, terminal, true);
|
|
379
379
|
const boot = async (id) => {
|
|
380
380
|
updateNode(id, { launch: { extensions: [], tools: [], systemPrompt: '', env: {} } });
|
|
381
|
-
reviveNode(id, { resume: true, capacity: 'freeze' });
|
|
381
|
+
await reviveNode(id, { resume: true, capacity: 'freeze' });
|
|
382
382
|
return (await h.awaitBoot(id)).pid;
|
|
383
383
|
};
|
|
384
384
|
const residentPid = await boot(resident);
|
|
@@ -439,7 +439,7 @@ test('an unattended resident awaiting a live child remains wakeable', { timeout:
|
|
|
439
439
|
env: {},
|
|
440
440
|
},
|
|
441
441
|
});
|
|
442
|
-
reviveNode(nodeId, { resume: true, capacity: 'freeze' });
|
|
442
|
+
await reviveNode(nodeId, { resume: true, capacity: 'freeze' });
|
|
443
443
|
const boot = await h.awaitBoot(nodeId);
|
|
444
444
|
const seenAt = 20_000;
|
|
445
445
|
await h.tick(seenAt);
|
|
@@ -42,7 +42,7 @@ test('a Jiti-loaded watcher prepends its held deferred batch to a native broker
|
|
|
42
42
|
},
|
|
43
43
|
},
|
|
44
44
|
});
|
|
45
|
-
reviveNode(nodeId, { resume: true, capacity: 'freeze' });
|
|
45
|
+
await reviveNode(nodeId, { resume: true, capacity: 'freeze' });
|
|
46
46
|
await h.awaitBoot(nodeId);
|
|
47
47
|
const entry = appendInbox(nodeId, {
|
|
48
48
|
from: 'child',
|
|
@@ -73,11 +73,11 @@ test('yield-message survives one pre-session_start retry and clears on session_s
|
|
|
73
73
|
// the retry still roots a fresh cycle carrying the note, never reopening the
|
|
74
74
|
// old session. Driving reviveNode directly is exactly what focus's revive path
|
|
75
75
|
// calls, minus a tmux pane this contract does not involve.
|
|
76
|
-
const focusRevive = reviveNode(id, { resume: true, capacity: 'freeze' });
|
|
76
|
+
const focusRevive = await reviveNode(id, { resume: true, capacity: 'freeze' });
|
|
77
77
|
const focusPid = focusRevive.launch?.pid;
|
|
78
78
|
assert.ok(focusPid != null, 'the competing focus invocation launches the replacement broker');
|
|
79
79
|
assert.equal(focusRevive.resumed, false, 'refresh intent overrides this invocation\'s strict-resume request');
|
|
80
|
-
assert.equal(h.fleet.get(id)?.pid, focusPid, 'the focus invocation
|
|
80
|
+
assert.equal(h.fleet.get(id)?.pid, focusPid, 'the focus invocation owns the authoritative fleet slot');
|
|
81
81
|
// Registering the manual launch cancels the exit policy's deferred recovery.
|
|
82
82
|
// Its pid must therefore be the successful observed boot, proving these
|
|
83
83
|
// assertions belong to the contender above rather than an already-queued boot.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { InstalledPlugin } from '../../types.js';
|
|
2
2
|
import type { CoreHookCatalog } from './catalog.js';
|
|
3
|
-
import type { HookManifestIssue, HookPhase, ValidatedHookManifest } from './schema.js';
|
|
3
|
+
import type { HookManifestIssue, HookPhase, LifecycleHookPhase, ValidatedHookManifest } from './schema.js';
|
|
4
|
+
import type { LifecycleEvent } from './lifecycle-catalog.js';
|
|
4
5
|
export type HookDiscoveryIssueCode = HookManifestIssue['code'] | 'command_hook_target_invalid' | 'command_hook_collision';
|
|
5
6
|
export interface HookDiscoveryIssue {
|
|
6
7
|
code: HookDiscoveryIssueCode;
|
|
@@ -38,6 +39,27 @@ export interface EffectiveHookPlan {
|
|
|
38
39
|
after: readonly EffectiveHook[];
|
|
39
40
|
replacements: readonly EffectiveHook[];
|
|
40
41
|
}
|
|
42
|
+
/** One lifecycle declaration resolved to the local executable that receives it. */
|
|
43
|
+
export interface EffectiveLifecycleHook {
|
|
44
|
+
executable: string;
|
|
45
|
+
plugin: HookPluginAttribution;
|
|
46
|
+
event: LifecycleEvent;
|
|
47
|
+
phase: LifecycleHookPhase;
|
|
48
|
+
op: string;
|
|
49
|
+
description: string;
|
|
50
|
+
effects: readonly string[];
|
|
51
|
+
/** Index in this plugin's lifecycle declaration array. */
|
|
52
|
+
declarationIndex: number;
|
|
53
|
+
/** Stable discovery order: scope, lexical plugin name, declaration index. */
|
|
54
|
+
order: number;
|
|
55
|
+
}
|
|
56
|
+
export interface LifecycleHookRegistry {
|
|
57
|
+
hooks: readonly EffectiveLifecycleHook[];
|
|
58
|
+
plans: ReadonlyMap<LifecycleEvent, readonly EffectiveLifecycleHook[]>;
|
|
59
|
+
validations: readonly HookPluginValidation[];
|
|
60
|
+
issues: readonly HookDiscoveryIssue[];
|
|
61
|
+
valid: boolean;
|
|
62
|
+
}
|
|
41
63
|
export interface HookPluginValidation {
|
|
42
64
|
plugin: InstalledPlugin;
|
|
43
65
|
manifestPath?: string;
|
|
@@ -74,6 +96,10 @@ export interface HookPluginCandidate {
|
|
|
74
96
|
* manifest files to validate them but never executes a plugin executable.
|
|
75
97
|
*/
|
|
76
98
|
export declare function compileHookRegistry(catalog: CoreHookCatalog, plugins: readonly InstalledPlugin[]): HookRegistry;
|
|
99
|
+
/** Compiles lifecycle declarations from resolved plugins without loading the command tree. */
|
|
100
|
+
export declare function compileLifecycleHookRegistry(plugins: readonly InstalledPlugin[]): LifecycleHookRegistry;
|
|
101
|
+
/** Discovers lifecycle hooks for one node's resolved cwd and profile. */
|
|
102
|
+
export declare function discoverLifecycleHookRegistry(startDir: string, profileId?: string | null): LifecycleHookRegistry;
|
|
77
103
|
/** Discovers and compiles the invocation-local effective registry. */
|
|
78
104
|
export declare function discoverHookRegistry(catalog: CoreHookCatalog, startDir?: string, profileId?: string | null): HookRegistry;
|
|
79
105
|
/** Compile the exact effective set a source candidate would create before its
|
|
@@ -153,6 +153,58 @@ export function compileHookRegistry(catalog, plugins) {
|
|
|
153
153
|
valid: issues.length === 0,
|
|
154
154
|
});
|
|
155
155
|
}
|
|
156
|
+
/** Compiles lifecycle declarations from resolved plugins without loading the command tree. */
|
|
157
|
+
export function compileLifecycleHookRegistry(plugins) {
|
|
158
|
+
const validations = [];
|
|
159
|
+
const hooks = [];
|
|
160
|
+
const issues = [];
|
|
161
|
+
let order = 0;
|
|
162
|
+
for (const plugin of plugins) {
|
|
163
|
+
const artifact = validatePluginHookArtifact(plugin.root, plugin.manifest);
|
|
164
|
+
const pluginIssues = artifactIssues(plugin, artifact.issues);
|
|
165
|
+
let compiled = [];
|
|
166
|
+
if (pluginIssues.length === 0 && artifact.manifest !== undefined && artifact.executablePath !== undefined) {
|
|
167
|
+
const lifecycle = artifact.manifest.schemaVersion === 2 ? artifact.manifest.lifecycle : [];
|
|
168
|
+
compiled = lifecycle.map((hook, declarationIndex) => Object.freeze({
|
|
169
|
+
executable: artifact.executablePath,
|
|
170
|
+
plugin: Object.freeze({ name: plugin.name, scope: plugin.scope, root: plugin.root }),
|
|
171
|
+
event: hook.event,
|
|
172
|
+
phase: hook.phase,
|
|
173
|
+
op: hook.op,
|
|
174
|
+
description: hook.description,
|
|
175
|
+
effects: Object.freeze([...hook.effects]),
|
|
176
|
+
declarationIndex,
|
|
177
|
+
order: order++,
|
|
178
|
+
}));
|
|
179
|
+
hooks.push(...compiled);
|
|
180
|
+
}
|
|
181
|
+
validations.push(Object.freeze({
|
|
182
|
+
plugin,
|
|
183
|
+
...(artifact.manifestPath === undefined ? {} : { manifestPath: artifact.manifestPath }),
|
|
184
|
+
...(artifact.executablePath === undefined ? {} : { executablePath: artifact.executablePath }),
|
|
185
|
+
...(artifact.manifest === undefined ? {} : { manifest: artifact.manifest }),
|
|
186
|
+
hooks: Object.freeze([]),
|
|
187
|
+
issues: Object.freeze(pluginIssues),
|
|
188
|
+
}));
|
|
189
|
+
issues.push(...pluginIssues);
|
|
190
|
+
}
|
|
191
|
+
const plans = new Map();
|
|
192
|
+
for (const hook of hooks) {
|
|
193
|
+
const entries = plans.get(hook.event) ?? [];
|
|
194
|
+
plans.set(hook.event, Object.freeze([...entries, hook]));
|
|
195
|
+
}
|
|
196
|
+
return Object.freeze({
|
|
197
|
+
hooks: Object.freeze(hooks),
|
|
198
|
+
plans,
|
|
199
|
+
validations: Object.freeze(validations),
|
|
200
|
+
issues: Object.freeze(issues),
|
|
201
|
+
valid: issues.length === 0,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
/** Discovers lifecycle hooks for one node's resolved cwd and profile. */
|
|
205
|
+
export function discoverLifecycleHookRegistry(startDir, profileId) {
|
|
206
|
+
return compileLifecycleHookRegistry(effectiveHookPlugins(startDir, profileId));
|
|
207
|
+
}
|
|
156
208
|
/** Discovers and compiles the invocation-local effective registry. */
|
|
157
209
|
export function discoverHookRegistry(catalog, startDir = process.cwd(), profileId) {
|
|
158
210
|
return compileHookRegistry(catalog, effectiveHookPlugins(startDir, profileId));
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { createCoreHookCatalog, type CoreHookCatalog, type CoreHookTarget } from './catalog.js';
|
|
2
2
|
export { projectEffectiveLeafHelp } from './help.js';
|
|
3
|
-
export { compileHookRegistry, discoverCandidateHookRegistry, discoverHookRegistry, discoverHookRegistryInScopes, effectiveHookPlugins, hookDiscoveryScopes, type EffectiveHook, type EffectiveHookPlan, type HookDiscoveryIssue, type HookPluginAttribution, type HookPluginValidation, type HookRegistry, type HookScope, } from './discovery.js';
|
|
3
|
+
export { compileHookRegistry, compileLifecycleHookRegistry, discoverCandidateHookRegistry, discoverHookRegistry, discoverHookRegistryInScopes, discoverLifecycleHookRegistry, effectiveHookPlugins, hookDiscoveryScopes, type EffectiveHook, type EffectiveHookPlan, type EffectiveLifecycleHook, type HookDiscoveryIssue, type HookPluginAttribution, type HookPluginValidation, type HookRegistry, type HookScope, type LifecycleHookRegistry, } from './discovery.js';
|
|
4
4
|
export { validatePluginHookArtifact, type HookArtifactValidation } from './artifact.js';
|
|
5
|
-
export { hasDeclaredHooks, hookReport, HOOK_TRUST_WARNING, type HookDeclarationReport, type HookReport } from './report.js';
|
|
6
|
-
export { validateHookManifest, type DeclaredHook, type HookManifestIssue, type HookManifestValidation, type HookPhase, type ValidatedHookManifest, } from './schema.js';
|
|
5
|
+
export { hasDeclaredHooks, hookReport, HOOK_TRUST_WARNING, type HookDeclarationReport, type HookReport, type LifecycleHookDeclarationReport } from './report.js';
|
|
6
|
+
export { validateHookManifest, type DeclaredHook, type DeclaredLifecycleHook, type HookManifestIssue, type HookManifestValidation, type HookPhase, type LifecycleHookPhase, type ValidatedHookManifest, } from './schema.js';
|
|
7
|
+
export { LIFECYCLE_EVENTS, isLifecycleEvent, type LifecycleEvent } from './lifecycle-catalog.js';
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { createCoreHookCatalog } from './catalog.js';
|
|
2
2
|
export { projectEffectiveLeafHelp } from './help.js';
|
|
3
|
-
export { compileHookRegistry, discoverCandidateHookRegistry, discoverHookRegistry, discoverHookRegistryInScopes, effectiveHookPlugins, hookDiscoveryScopes, } from './discovery.js';
|
|
3
|
+
export { compileHookRegistry, compileLifecycleHookRegistry, discoverCandidateHookRegistry, discoverHookRegistry, discoverHookRegistryInScopes, discoverLifecycleHookRegistry, effectiveHookPlugins, hookDiscoveryScopes, } from './discovery.js';
|
|
4
4
|
export { validatePluginHookArtifact } from './artifact.js';
|
|
5
5
|
export { hasDeclaredHooks, hookReport, HOOK_TRUST_WARNING } from './report.js';
|
|
6
6
|
export { validateHookManifest, } from './schema.js';
|
|
7
|
+
export { LIFECYCLE_EVENTS, isLifecycleEvent } from './lifecycle-catalog.js';
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { PluginManifest } from '../../types.js';
|
|
2
2
|
import type { HookDiscoveryIssue, HookPluginValidation } from './discovery.js';
|
|
3
|
-
import type { DeclaredHook } from './schema.js';
|
|
4
|
-
export declare const HOOK_TRUST_WARNING = "
|
|
3
|
+
import type { DeclaredHook, DeclaredLifecycleHook } from './schema.js';
|
|
4
|
+
export declare const HOOK_TRUST_WARNING = "Command hooks receive normalized inputs and may block or replace declared targets. Lifecycle hooks run unprompted at every declared event before node launch. Both run local executables with runtime authority and may transmit received data.";
|
|
5
5
|
export interface HookDeclarationReport {
|
|
6
6
|
target: string;
|
|
7
7
|
phase: DeclaredHook['phase'];
|
|
@@ -9,6 +9,13 @@ export interface HookDeclarationReport {
|
|
|
9
9
|
description: string;
|
|
10
10
|
effects: readonly string[];
|
|
11
11
|
}
|
|
12
|
+
export interface LifecycleHookDeclarationReport {
|
|
13
|
+
event: DeclaredLifecycleHook['event'];
|
|
14
|
+
phase: DeclaredLifecycleHook['phase'];
|
|
15
|
+
op: string;
|
|
16
|
+
description: string;
|
|
17
|
+
effects: readonly string[];
|
|
18
|
+
}
|
|
12
19
|
/** One static hook artifact report shared by plugin inspection and lifecycle
|
|
13
20
|
* output. It only reads declarations and filesystem metadata; it never runs
|
|
14
21
|
* the hook executable. */
|
|
@@ -16,6 +23,8 @@ export interface HookReport {
|
|
|
16
23
|
manifestPath?: string;
|
|
17
24
|
executablePath?: string;
|
|
18
25
|
declarations: readonly HookDeclarationReport[];
|
|
26
|
+
/** Present only for schemaVersion 2 manifests. */
|
|
27
|
+
lifecycle?: readonly LifecycleHookDeclarationReport[];
|
|
19
28
|
issues: readonly HookDiscoveryIssue[];
|
|
20
29
|
trust: typeof HOOK_TRUST_WARNING;
|
|
21
30
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export const HOOK_TRUST_WARNING = '
|
|
1
|
+
export const HOOK_TRUST_WARNING = 'Command hooks receive normalized inputs and may block or replace declared targets. Lifecycle hooks run unprompted at every declared event before node launch. Both run local executables with runtime authority and may transmit received data.';
|
|
2
2
|
export function hasDeclaredHooks(manifest) {
|
|
3
3
|
return manifest.hooks !== undefined || manifest.hookExecutable !== undefined;
|
|
4
4
|
}
|
|
@@ -13,6 +13,15 @@ export function hookReport(validation, issues = validation.issues) {
|
|
|
13
13
|
description: hook.description,
|
|
14
14
|
effects: Object.freeze([...hook.effects]),
|
|
15
15
|
}))),
|
|
16
|
+
...(validation.manifest?.schemaVersion === 2 ? {
|
|
17
|
+
lifecycle: Object.freeze(validation.manifest.lifecycle.map((hook) => Object.freeze({
|
|
18
|
+
event: hook.event,
|
|
19
|
+
phase: hook.phase,
|
|
20
|
+
op: hook.op,
|
|
21
|
+
description: hook.description,
|
|
22
|
+
effects: Object.freeze([...hook.effects]),
|
|
23
|
+
}))),
|
|
24
|
+
} : {}),
|
|
16
25
|
issues: Object.freeze([...issues]),
|
|
17
26
|
trust: HOOK_TRUST_WARNING,
|
|
18
27
|
});
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { type LifecycleEvent } from './lifecycle-catalog.js';
|
|
1
2
|
export type HookPhase = 'before' | 'after' | 'replace';
|
|
3
|
+
export type LifecycleHookPhase = 'on';
|
|
2
4
|
export interface DeclaredHook {
|
|
3
5
|
target: string;
|
|
4
6
|
phase: HookPhase;
|
|
@@ -6,10 +8,23 @@ export interface DeclaredHook {
|
|
|
6
8
|
description: string;
|
|
7
9
|
effects: string[];
|
|
8
10
|
}
|
|
9
|
-
export interface
|
|
11
|
+
export interface DeclaredLifecycleHook {
|
|
12
|
+
event: LifecycleEvent;
|
|
13
|
+
phase: LifecycleHookPhase;
|
|
14
|
+
op: string;
|
|
15
|
+
description: string;
|
|
16
|
+
effects: string[];
|
|
17
|
+
}
|
|
18
|
+
export interface ValidatedV1HookManifest {
|
|
10
19
|
schemaVersion: 1;
|
|
11
20
|
hooks: DeclaredHook[];
|
|
12
21
|
}
|
|
22
|
+
export interface ValidatedV2HookManifest {
|
|
23
|
+
schemaVersion: 2;
|
|
24
|
+
hooks: DeclaredHook[];
|
|
25
|
+
lifecycle: DeclaredLifecycleHook[];
|
|
26
|
+
}
|
|
27
|
+
export type ValidatedHookManifest = ValidatedV1HookManifest | ValidatedV2HookManifest;
|
|
13
28
|
export type HookIssueCode = 'hook_manifest_unreadable' | 'hook_manifest_invalid' | 'hook_schema_version' | 'hook_path_unsafe' | 'hook_not_executable';
|
|
14
29
|
export interface HookManifestIssue {
|
|
15
30
|
code: HookIssueCode;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isRecord } from '../../shared/predicates.js';
|
|
2
|
+
import { LIFECYCLE_EVENTS, isLifecycleEvent } from './lifecycle-catalog.js';
|
|
2
3
|
function typeName(value) {
|
|
3
4
|
if (value === null)
|
|
4
5
|
return 'null';
|
|
@@ -6,6 +7,98 @@ function typeName(value) {
|
|
|
6
7
|
return 'array';
|
|
7
8
|
return typeof value;
|
|
8
9
|
}
|
|
10
|
+
function validateCommandDeclaration(value, index, issue) {
|
|
11
|
+
const path = `hooks[${index}]`;
|
|
12
|
+
if (!isRecord(value)) {
|
|
13
|
+
issue('hook_manifest_invalid', 'hook must be an object', typeName(value), '{ target, phase, op, description, effects }', 'Fix the hook declaration.', path);
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
const keys = Object.keys(value).filter((key) => !['target', 'phase', 'op', 'description', 'effects'].includes(key));
|
|
17
|
+
if (keys.length > 0) {
|
|
18
|
+
issue('hook_manifest_invalid', 'unknown hook keys', keys.join(', '), 'only: target, phase, op, description, effects', 'Remove the unknown keys.', path);
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
for (const field of ['target', 'op', 'description']) {
|
|
22
|
+
if (typeof value[field] !== 'string' || value[field].length === 0) {
|
|
23
|
+
issue('hook_manifest_invalid', `${field} must be a non-empty string`, typeName(value[field]), 'a non-empty string', `Set ${field}.`, `${path}.${field}`);
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (value['phase'] !== 'before' && value['phase'] !== 'after' && value['phase'] !== 'replace') {
|
|
28
|
+
issue('hook_manifest_invalid', 'phase must be before|after|replace', String(value['phase']), 'before | after | replace', 'Set phase to a supported hook phase.', `${path}.phase`);
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
if (!isEffects(value['effects'])) {
|
|
32
|
+
issue('hook_manifest_invalid', 'effects must be a non-empty string array', typeName(value['effects']), 'a non-empty array of non-empty strings', 'Declare every persistent effect.', `${path}.effects`);
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
target: value['target'],
|
|
37
|
+
phase: value['phase'],
|
|
38
|
+
op: value['op'],
|
|
39
|
+
description: value['description'],
|
|
40
|
+
effects: value['effects'],
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function validateLifecycleDeclaration(value, index, issue) {
|
|
44
|
+
const path = `lifecycle[${index}]`;
|
|
45
|
+
if (!isRecord(value)) {
|
|
46
|
+
issue('hook_manifest_invalid', 'lifecycle hook must be an object', typeName(value), '{ event, phase, op, description, effects }', 'Fix the lifecycle declaration.', path);
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
const keys = Object.keys(value).filter((key) => !['event', 'phase', 'op', 'description', 'effects'].includes(key));
|
|
50
|
+
if (keys.length > 0) {
|
|
51
|
+
issue('hook_manifest_invalid', 'unknown lifecycle hook keys', keys.join(', '), 'only: event, phase, op, description, effects', 'Remove the unknown keys.', path);
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
for (const field of ['event', 'op', 'description']) {
|
|
55
|
+
if (typeof value[field] !== 'string' || value[field].length === 0) {
|
|
56
|
+
issue('hook_manifest_invalid', `${field} must be a non-empty string`, typeName(value[field]), 'a non-empty string', `Set ${field}.`, `${path}.${field}`);
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (!isLifecycleEvent(value['event'])) {
|
|
61
|
+
issue('hook_manifest_invalid', 'event must name a supported lifecycle event', String(value['event']), LIFECYCLE_EVENTS.join(' | '), `Set event to one of: ${LIFECYCLE_EVENTS.join(', ')}.`, `${path}.event`);
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
if (value['phase'] !== 'on') {
|
|
65
|
+
issue('hook_manifest_invalid', 'phase must be on', String(value['phase']), 'on', 'Set phase to on.', `${path}.phase`);
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
if (!isEffects(value['effects'])) {
|
|
69
|
+
issue('hook_manifest_invalid', 'effects must be a non-empty string array', typeName(value['effects']), 'a non-empty array of non-empty strings', 'Declare every persistent effect.', `${path}.effects`);
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
event: value['event'],
|
|
74
|
+
phase: 'on',
|
|
75
|
+
op: value['op'],
|
|
76
|
+
description: value['description'],
|
|
77
|
+
effects: value['effects'],
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
function isEffects(value) {
|
|
81
|
+
return Array.isArray(value) && value.length > 0 && value.every((effect) => typeof effect === 'string' && effect.length > 0);
|
|
82
|
+
}
|
|
83
|
+
function validateV1(raw, issue) {
|
|
84
|
+
const unknown = Object.keys(raw).filter((key) => key !== 'schemaVersion' && key !== 'hooks');
|
|
85
|
+
if (unknown.length > 0) {
|
|
86
|
+
issue('hook_manifest_invalid', 'unknown top-level keys', unknown.join(', '), 'only: schemaVersion, hooks', 'Remove the unknown keys.');
|
|
87
|
+
return { issues: [] };
|
|
88
|
+
}
|
|
89
|
+
if (!Array.isArray(raw['hooks']) || raw['hooks'].length === 0) {
|
|
90
|
+
issue('hook_manifest_invalid', 'hooks must be a non-empty array', typeName(raw['hooks']), 'a non-empty array of hook declarations', 'Declare at least one hook.', 'hooks');
|
|
91
|
+
return { issues: [] };
|
|
92
|
+
}
|
|
93
|
+
const hooks = [];
|
|
94
|
+
for (let index = 0; index < raw['hooks'].length; index++) {
|
|
95
|
+
const hook = validateCommandDeclaration(raw['hooks'][index], index, issue);
|
|
96
|
+
if (hook === undefined)
|
|
97
|
+
return { issues: [] };
|
|
98
|
+
hooks.push(hook);
|
|
99
|
+
}
|
|
100
|
+
return { manifest: { schemaVersion: 1, hooks }, issues: [] };
|
|
101
|
+
}
|
|
9
102
|
/** Validates the declarative hooks.json payload without filesystem access. */
|
|
10
103
|
export function validateHookManifest(raw) {
|
|
11
104
|
const issues = [];
|
|
@@ -16,53 +109,48 @@ export function validateHookManifest(raw) {
|
|
|
16
109
|
issue('hook_manifest_invalid', 'manifest must be an object', typeName(raw), '{ schemaVersion, hooks }', 'Provide a valid hooks.json manifest.');
|
|
17
110
|
return { issues };
|
|
18
111
|
}
|
|
19
|
-
|
|
112
|
+
if (raw['schemaVersion'] === 1) {
|
|
113
|
+
const validation = validateV1(raw, issue);
|
|
114
|
+
return { ...(validation.manifest === undefined ? {} : { manifest: validation.manifest }), issues };
|
|
115
|
+
}
|
|
116
|
+
if (raw['schemaVersion'] !== 2) {
|
|
117
|
+
issue('hook_schema_version', 'schemaVersion must be exactly 1 or 2', String(raw['schemaVersion']), '1 | 2', 'Update the manifest schema version to 1 or 2.', 'schemaVersion');
|
|
118
|
+
return { issues };
|
|
119
|
+
}
|
|
120
|
+
const unknown = Object.keys(raw).filter((key) => key !== 'schemaVersion' && key !== 'hooks' && key !== 'lifecycle');
|
|
20
121
|
if (unknown.length > 0) {
|
|
21
|
-
issue('hook_manifest_invalid', 'unknown top-level keys', unknown.join(', '), 'only: schemaVersion, hooks', 'Remove the unknown keys.');
|
|
122
|
+
issue('hook_manifest_invalid', 'unknown top-level keys', unknown.join(', '), 'only: schemaVersion, hooks, lifecycle', 'Remove the unknown keys.');
|
|
22
123
|
return { issues };
|
|
23
124
|
}
|
|
24
|
-
|
|
25
|
-
|
|
125
|
+
const rawHooks = raw['hooks'];
|
|
126
|
+
const rawLifecycle = raw['lifecycle'];
|
|
127
|
+
if (rawHooks !== undefined && !Array.isArray(rawHooks)) {
|
|
128
|
+
issue('hook_manifest_invalid', 'hooks must be an array when present', typeName(rawHooks), 'an array of hook declarations', 'Fix hooks.', 'hooks');
|
|
26
129
|
return { issues };
|
|
27
130
|
}
|
|
28
|
-
if (!Array.isArray(
|
|
29
|
-
issue('hook_manifest_invalid', '
|
|
131
|
+
if (rawLifecycle !== undefined && !Array.isArray(rawLifecycle)) {
|
|
132
|
+
issue('hook_manifest_invalid', 'lifecycle must be an array when present', typeName(rawLifecycle), 'an array of lifecycle hook declarations', 'Fix lifecycle.', 'lifecycle');
|
|
30
133
|
return { issues };
|
|
31
134
|
}
|
|
32
|
-
const hooks = [];
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
if (
|
|
42
|
-
issue('hook_manifest_invalid', 'unknown hook keys', keys.join(', '), 'only: target, phase, op, description, effects', 'Remove the unknown keys.', path);
|
|
43
|
-
return { issues };
|
|
44
|
-
}
|
|
45
|
-
for (const field of ['target', 'op', 'description']) {
|
|
46
|
-
if (typeof value[field] !== 'string' || value[field].length === 0) {
|
|
47
|
-
issue('hook_manifest_invalid', `${field} must be a non-empty string`, typeName(value[field]), 'a non-empty string', `Set ${field}.`, `${path}.${field}`);
|
|
48
|
-
return { issues };
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
if (value['phase'] !== 'before' && value['phase'] !== 'after' && value['phase'] !== 'replace') {
|
|
52
|
-
issue('hook_manifest_invalid', 'phase must be before|after|replace', String(value['phase']), 'before | after | replace', 'Set phase to a supported hook phase.', `${path}.phase`);
|
|
135
|
+
const hooks = rawHooks ?? [];
|
|
136
|
+
const lifecycle = rawLifecycle ?? [];
|
|
137
|
+
if (hooks.length === 0 && lifecycle.length === 0) {
|
|
138
|
+
issue('hook_manifest_invalid', 'hooks or lifecycle must be a non-empty array', 'both arrays were empty or absent', 'at least one command or lifecycle hook declaration', 'Declare at least one hook.', 'hooks');
|
|
139
|
+
return { issues };
|
|
140
|
+
}
|
|
141
|
+
const declaredHooks = [];
|
|
142
|
+
for (let index = 0; index < hooks.length; index++) {
|
|
143
|
+
const hook = validateCommandDeclaration(hooks[index], index, issue);
|
|
144
|
+
if (hook === undefined)
|
|
53
145
|
return { issues };
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
146
|
+
declaredHooks.push(hook);
|
|
147
|
+
}
|
|
148
|
+
const declaredLifecycle = [];
|
|
149
|
+
for (let index = 0; index < lifecycle.length; index++) {
|
|
150
|
+
const hook = validateLifecycleDeclaration(lifecycle[index], index, issue);
|
|
151
|
+
if (hook === undefined)
|
|
57
152
|
return { issues };
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
phase: value['phase'],
|
|
62
|
-
op: value['op'],
|
|
63
|
-
description: value['description'],
|
|
64
|
-
effects: value['effects'],
|
|
65
|
-
});
|
|
66
|
-
}
|
|
67
|
-
return { manifest: { schemaVersion: 1, hooks }, issues };
|
|
153
|
+
declaredLifecycle.push(hook);
|
|
154
|
+
}
|
|
155
|
+
return { manifest: { schemaVersion: 2, hooks: declaredHooks, lifecycle: declaredLifecycle }, issues };
|
|
68
156
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { LifecycleHookRequest } from '../../../hook-authoring.js';
|
|
2
|
+
import type { EffectiveLifecycleHook } from '../discovery.js';
|
|
3
|
+
export declare const LIFECYCLE_HOOK_TIMEOUT_MS = 5000;
|
|
4
|
+
export declare const LIFECYCLE_EVENT_BUDGET_MS = 10000;
|
|
5
|
+
export interface LifecycleHookInvocation {
|
|
6
|
+
node: LifecycleHookRequest['node'];
|
|
7
|
+
runtime: LifecycleHookRequest['runtime'];
|
|
8
|
+
}
|
|
9
|
+
/** Runs one lifecycle hook. The node launch remains authoritative on every failure. */
|
|
10
|
+
export declare function invokeLifecycleHook(hook: EffectiveLifecycleHook, invocation: LifecycleHookInvocation, timeoutMs: number): Promise<void>;
|
|
11
|
+
/** Executes lifecycle hooks in discovery order, within one bounded start-event budget. */
|
|
12
|
+
export declare function invokeLifecycleHooks(hooks: readonly EffectiveLifecycleHook[], invocation: LifecycleHookInvocation): Promise<void>;
|