@north-light/crouter 0.3.310 → 0.3.312
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/builtin-pi-packages/pi-crtr-extensions/package-lock.json +3 -3
- package/dist/clients/attach/viewer.js +389 -389
- package/dist/commands/__tests__/integration/dashboard-snapshot.test.d.ts +1 -0
- package/dist/commands/__tests__/integration/dashboard-snapshot.test.js +212 -0
- package/dist/commands/api-client.d.ts +1 -0
- package/dist/commands/api-client.js +4 -2
- package/dist/commands/canvas-roster.js +24 -17
- package/dist/commands/dashboard.js +23 -16
- package/dist/core/__tests__/context-intro.test.js +4 -4
- package/dist/core/__tests__/cron-timeout-descendants.test.d.ts +1 -0
- package/dist/core/__tests__/cron-timeout-descendants.test.js +102 -0
- package/dist/core/__tests__/human-deliver.test.js +18 -18
- package/dist/core/__tests__/integration/human-deliver-e2e.test.js +5 -5
- package/dist/core/__tests__/integration/lifecycle-hooks.test.js +299 -1
- package/dist/core/__tests__/integration/spawn-root.test.js +11 -11
- package/dist/core/__tests__/on-read-crouter-home-fence.test.js +2 -2
- package/dist/core/__tests__/on-read-dedup-resume.test.js +6 -6
- package/dist/core/__tests__/on-read-nested-store.test.js +6 -6
- package/dist/core/__tests__/profile-project-memory-delivery.test.js +31 -31
- package/dist/core/__tests__/spawn-worktree-id-conflict.test.js +3 -3
- package/dist/core/__tests__/warm-claim-preference-snapshot.test.js +1 -1
- package/dist/core/canvas/__tests__/remote-canvas-source.test.js +24 -11
- package/dist/core/canvas/remote-canvas-source.d.ts +4 -0
- package/dist/core/canvas/remote-canvas-source.js +58 -6
- package/dist/core/canvas/render-source.d.ts +4 -4
- package/dist/core/canvas/render-source.js +49 -48
- package/dist/core/canvas/source.d.ts +2 -0
- package/dist/core/canvas/source.js +4 -1
- package/dist/core/command-hooks/artifact.d.ts +6 -0
- package/dist/core/command-hooks/artifact.js +22 -6
- package/dist/core/command-hooks/discovery.d.ts +2 -0
- package/dist/core/command-hooks/discovery.js +2 -0
- package/dist/core/command-hooks/lifecycle-catalog.d.ts +20 -2
- package/dist/core/command-hooks/lifecycle-catalog.js +7 -2
- package/dist/core/command-hooks/lifecycle-create.d.ts +4 -0
- package/dist/core/command-hooks/lifecycle-create.js +51 -0
- package/dist/core/command-hooks/schema.d.ts +2 -2
- package/dist/core/command-hooks/schema.js +5 -4
- package/dist/core/command-hooks/transport/exec-invoke.js +3 -1
- package/dist/core/command-hooks/transport/exec-lifecycle.d.ts +18 -8
- package/dist/core/command-hooks/transport/exec-lifecycle.js +89 -30
- package/dist/core/errors.d.ts +1 -0
- package/dist/core/errors.js +4 -0
- package/dist/core/human/feedback-companion.js +1 -1
- package/dist/core/review/companion.d.ts +1 -1
- package/dist/core/review/companion.js +2 -2
- package/dist/core/review/realize.js +6 -6
- package/dist/core/runtime/nodes.d.ts +5 -1
- package/dist/core/runtime/nodes.js +26 -11
- package/dist/core/runtime/recycle.js +16 -12
- package/dist/core/runtime/reset.js +4 -1
- package/dist/core/runtime/revive.js +13 -12
- package/dist/core/runtime/spawn.d.ts +4 -1
- package/dist/core/runtime/spawn.js +30 -3
- package/dist/core/runtime/warm-pool.d.ts +2 -2
- package/dist/core/runtime/warm-pool.js +23 -3
- package/dist/daemon/__tests__/node-outcome-birth-invariants.test.js +2 -2
- package/dist/daemon/api/__tests__/profile-launch-gates.test.js +2 -2
- package/dist/daemon/api/__tests__/seam/profile-delete.test.js +2 -2
- package/dist/daemon/api/handlers/human.js +5 -2
- package/dist/daemon/api/handlers/nodes.d.ts +1 -0
- package/dist/daemon/api/handlers/nodes.js +34 -29
- package/dist/daemon/cron-run.js +49 -15
- package/dist/hook-authoring.d.ts +28 -2
- package/dist/hook-authoring.js +53 -1
- package/dist/index.d.ts +1 -1
- package/package.json +1 -1
- package/runtime.lock.json +5 -5
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
import { readFileSync, realpathSync, statSync } from 'node:fs';
|
|
2
2
|
import { isAbsolute, resolve, sep } from 'node:path';
|
|
3
3
|
import { validateHookManifest } from './schema.js';
|
|
4
|
+
function lifecycleCandidates(raw) {
|
|
5
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw))
|
|
6
|
+
return [];
|
|
7
|
+
const lifecycle = raw['lifecycle'];
|
|
8
|
+
if (!Array.isArray(lifecycle))
|
|
9
|
+
return [];
|
|
10
|
+
return lifecycle.flatMap((value, index) => {
|
|
11
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
12
|
+
return [];
|
|
13
|
+
const declaration = value;
|
|
14
|
+
return typeof declaration['event'] === 'string'
|
|
15
|
+
? [{ event: declaration['event'], op: typeof declaration['op'] === 'string' ? declaration['op'] : '<invalid>', index }]
|
|
16
|
+
: [];
|
|
17
|
+
});
|
|
18
|
+
}
|
|
4
19
|
function safeFilePath(root, pointer) {
|
|
5
20
|
if (isAbsolute(pointer))
|
|
6
21
|
return null;
|
|
@@ -29,10 +44,10 @@ export function validatePluginHookArtifact(root, manifest) {
|
|
|
29
44
|
expected: 'both hooks and hookExecutable',
|
|
30
45
|
next: hasHooks ? 'Add plugin.json.hookExecutable or remove hooks.' : 'Add plugin.json.hooks or remove hookExecutable.',
|
|
31
46
|
});
|
|
32
|
-
return { issues };
|
|
47
|
+
return { lifecycleCandidates: [], issues };
|
|
33
48
|
}
|
|
34
49
|
if (!hasHooks)
|
|
35
|
-
return { issues };
|
|
50
|
+
return { lifecycleCandidates: [], issues };
|
|
36
51
|
if (typeof hooks !== 'string' || hooks.length === 0) {
|
|
37
52
|
issues.push({
|
|
38
53
|
code: 'hook_manifest_invalid',
|
|
@@ -54,7 +69,7 @@ export function validatePluginHookArtifact(root, manifest) {
|
|
|
54
69
|
});
|
|
55
70
|
}
|
|
56
71
|
if (issues.length > 0)
|
|
57
|
-
return { issues };
|
|
72
|
+
return { lifecycleCandidates: [], issues };
|
|
58
73
|
const hooksPointer = hooks;
|
|
59
74
|
const executablePointer = executable;
|
|
60
75
|
const manifestPath = safeFilePath(root, hooksPointer);
|
|
@@ -90,7 +105,7 @@ export function validatePluginHookArtifact(root, manifest) {
|
|
|
90
105
|
});
|
|
91
106
|
}
|
|
92
107
|
if (manifestPath === null)
|
|
93
|
-
return { ...(executablePath === null ? {} : { executablePath }), issues };
|
|
108
|
+
return { lifecycleCandidates: [], ...(executablePath === null ? {} : { executablePath }), issues };
|
|
94
109
|
let text;
|
|
95
110
|
try {
|
|
96
111
|
text = readFileSync(manifestPath, 'utf8');
|
|
@@ -103,7 +118,7 @@ export function validatePluginHookArtifact(root, manifest) {
|
|
|
103
118
|
expected: 'a readable JSON file',
|
|
104
119
|
next: 'Reinstall or update the plugin.',
|
|
105
120
|
});
|
|
106
|
-
return { manifestPath, ...(executablePath === null ? {} : { executablePath }), issues };
|
|
121
|
+
return { manifestPath, lifecycleCandidates: [], ...(executablePath === null ? {} : { executablePath }), issues };
|
|
107
122
|
}
|
|
108
123
|
let raw;
|
|
109
124
|
try {
|
|
@@ -117,11 +132,12 @@ export function validatePluginHookArtifact(root, manifest) {
|
|
|
117
132
|
expected: 'a JSON object',
|
|
118
133
|
next: 'Regenerate hooks.json.',
|
|
119
134
|
});
|
|
120
|
-
return { manifestPath, ...(executablePath === null ? {} : { executablePath }), issues };
|
|
135
|
+
return { manifestPath, lifecycleCandidates: [], ...(executablePath === null ? {} : { executablePath }), issues };
|
|
121
136
|
}
|
|
122
137
|
const validation = validateHookManifest(raw);
|
|
123
138
|
return {
|
|
124
139
|
manifestPath,
|
|
140
|
+
lifecycleCandidates: lifecycleCandidates(raw),
|
|
125
141
|
...(executablePath === null ? {} : { executablePath }),
|
|
126
142
|
...(validation.manifest === undefined ? {} : { manifest: validation.manifest }),
|
|
127
143
|
issues: [...issues, ...validation.issues],
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { InstalledPlugin } from '../../types.js';
|
|
2
|
+
import { type LifecycleDeclarationCandidate } from './artifact.js';
|
|
2
3
|
import type { CoreHookCatalog } from './catalog.js';
|
|
3
4
|
import type { HookManifestIssue, HookPhase, LifecycleHookPhase, ValidatedHookManifest } from './schema.js';
|
|
4
5
|
import type { LifecycleEvent } from './lifecycle-catalog.js';
|
|
@@ -65,6 +66,7 @@ export interface HookPluginValidation {
|
|
|
65
66
|
manifestPath?: string;
|
|
66
67
|
executablePath?: string;
|
|
67
68
|
manifest?: ValidatedHookManifest;
|
|
69
|
+
lifecycleCandidates: readonly LifecycleDeclarationCandidate[];
|
|
68
70
|
hooks: readonly EffectiveHook[];
|
|
69
71
|
issues: readonly HookDiscoveryIssue[];
|
|
70
72
|
}
|
|
@@ -109,6 +109,7 @@ export function compileHookRegistry(catalog, plugins) {
|
|
|
109
109
|
...(artifact.manifestPath === undefined ? {} : { manifestPath: artifact.manifestPath }),
|
|
110
110
|
...(artifact.executablePath === undefined ? {} : { executablePath: artifact.executablePath }),
|
|
111
111
|
...(artifact.manifest === undefined ? {} : { manifest: artifact.manifest }),
|
|
112
|
+
lifecycleCandidates: Object.freeze([...artifact.lifecycleCandidates]),
|
|
112
113
|
hooks: Object.freeze(compiled),
|
|
113
114
|
issues: Object.freeze(pluginIssues),
|
|
114
115
|
}));
|
|
@@ -183,6 +184,7 @@ export function compileLifecycleHookRegistry(plugins) {
|
|
|
183
184
|
...(artifact.manifestPath === undefined ? {} : { manifestPath: artifact.manifestPath }),
|
|
184
185
|
...(artifact.executablePath === undefined ? {} : { executablePath: artifact.executablePath }),
|
|
185
186
|
...(artifact.manifest === undefined ? {} : { manifest: artifact.manifest }),
|
|
187
|
+
lifecycleCandidates: Object.freeze([...artifact.lifecycleCandidates]),
|
|
186
188
|
hooks: Object.freeze([]),
|
|
187
189
|
issues: Object.freeze(pluginIssues),
|
|
188
190
|
}));
|
|
@@ -1,3 +1,21 @@
|
|
|
1
|
-
export declare const
|
|
2
|
-
|
|
1
|
+
export declare const LIFECYCLE_CATALOG: {
|
|
2
|
+
readonly 'node:create': {
|
|
3
|
+
readonly phase: "before";
|
|
4
|
+
readonly hookTimeoutMs: 5000;
|
|
5
|
+
readonly eventBudgetMs: 10000;
|
|
6
|
+
};
|
|
7
|
+
readonly 'node:start': {
|
|
8
|
+
readonly phase: "on";
|
|
9
|
+
readonly hookTimeoutMs: 5000;
|
|
10
|
+
readonly eventBudgetMs: 10000;
|
|
11
|
+
};
|
|
12
|
+
readonly 'node:close': {
|
|
13
|
+
readonly phase: "on";
|
|
14
|
+
readonly hookTimeoutMs: 120000;
|
|
15
|
+
readonly eventBudgetMs: 180000;
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
export type LifecycleEvent = keyof typeof LIFECYCLE_CATALOG;
|
|
19
|
+
export type LifecycleHookPhase = (typeof LIFECYCLE_CATALOG)[LifecycleEvent]['phase'];
|
|
20
|
+
export declare const LIFECYCLE_EVENTS: readonly ("node:create" | "node:start" | "node:close")[];
|
|
3
21
|
export declare function isLifecycleEvent(value: string): value is LifecycleEvent;
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
export const
|
|
1
|
+
export const LIFECYCLE_CATALOG = {
|
|
2
|
+
'node:create': { phase: 'before', hookTimeoutMs: 5_000, eventBudgetMs: 10_000 },
|
|
3
|
+
'node:start': { phase: 'on', hookTimeoutMs: 5_000, eventBudgetMs: 10_000 },
|
|
4
|
+
'node:close': { phase: 'on', hookTimeoutMs: 120_000, eventBudgetMs: 180_000 },
|
|
5
|
+
};
|
|
6
|
+
export const LIFECYCLE_EVENTS = Object.freeze(Object.keys(LIFECYCLE_CATALOG));
|
|
2
7
|
export function isLifecycleEvent(value) {
|
|
3
|
-
return
|
|
8
|
+
return Object.hasOwn(LIFECYCLE_CATALOG, value);
|
|
4
9
|
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { NodeCreateHookRequest } from '../../hook-authoring.js';
|
|
2
|
+
export type NodeCreateRequest = NodeCreateHookRequest['create'];
|
|
3
|
+
/** Admit one node creation and return the release that must run after its row exists. */
|
|
4
|
+
export declare function admitNodeCreate(create: NodeCreateRequest): Promise<() => void>;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { crtrHome } from '../canvas/paths.js';
|
|
2
|
+
import { nodeCreateRefused } from '../errors.js';
|
|
3
|
+
import { discoverLifecycleHookRegistry } from './discovery.js';
|
|
4
|
+
import { invokeBlockingLifecycleHooks } from './transport/exec-lifecycle.js';
|
|
5
|
+
let nodeCreateGate = Promise.resolve();
|
|
6
|
+
async function enterNodeCreateGate() {
|
|
7
|
+
let resolve;
|
|
8
|
+
const previous = nodeCreateGate;
|
|
9
|
+
nodeCreateGate = new Promise((release) => { resolve = release; });
|
|
10
|
+
await previous;
|
|
11
|
+
let released = false;
|
|
12
|
+
return () => {
|
|
13
|
+
if (released)
|
|
14
|
+
return;
|
|
15
|
+
released = true;
|
|
16
|
+
resolve();
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
function refusedCreateHookDiscovery(cwd, profileId) {
|
|
20
|
+
const registry = discoverLifecycleHookRegistry(cwd, profileId);
|
|
21
|
+
const plan = registry.plans.get('node:create') ?? [];
|
|
22
|
+
const failed = registry.validations.flatMap((validation) => validation.lifecycleCandidates
|
|
23
|
+
.filter((candidate) => candidate.event === 'node:create' && !plan.some((hook) => hook.plugin.name === validation.plugin.name && hook.declarationIndex === candidate.index))
|
|
24
|
+
.map((candidate) => ({
|
|
25
|
+
validation,
|
|
26
|
+
candidate,
|
|
27
|
+
issue: validation.issues.find((issue) => issue.path === `lifecycle[${candidate.index}]` || issue.path?.startsWith(`lifecycle[${candidate.index}].`)) ?? validation.issues[0],
|
|
28
|
+
})))[0];
|
|
29
|
+
if (failed === undefined)
|
|
30
|
+
return registry;
|
|
31
|
+
const { validation, candidate, issue } = failed;
|
|
32
|
+
throw nodeCreateRefused(`plugin "${validation.plugin.name}" lifecycle hook "${candidate.op}" for node:create could not be discovered: ${issue.message}`, `Refused by plugin ${validation.plugin.name}, op ${candidate.op}.`);
|
|
33
|
+
}
|
|
34
|
+
/** Admit one node creation and return the release that must run after its row exists. */
|
|
35
|
+
export async function admitNodeCreate(create) {
|
|
36
|
+
const release = await enterNodeCreateGate();
|
|
37
|
+
try {
|
|
38
|
+
const registry = refusedCreateHookDiscovery(create.cwd, create.profile);
|
|
39
|
+
const refusal = await invokeBlockingLifecycleHooks(registry.plans.get('node:create') ?? [], {
|
|
40
|
+
create,
|
|
41
|
+
runtime: { canvasHome: crtrHome(), profile: create.profile },
|
|
42
|
+
});
|
|
43
|
+
if (refusal !== null)
|
|
44
|
+
throw nodeCreateRefused(refusal.message, refusal.next);
|
|
45
|
+
return release;
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
release();
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { type LifecycleEvent } from './lifecycle-catalog.js';
|
|
1
|
+
import { type LifecycleEvent, type LifecycleHookPhase } from './lifecycle-catalog.js';
|
|
2
|
+
export type { LifecycleHookPhase } from './lifecycle-catalog.js';
|
|
2
3
|
export type HookPhase = 'before' | 'after' | 'replace';
|
|
3
|
-
export type LifecycleHookPhase = 'on';
|
|
4
4
|
export interface DeclaredHook {
|
|
5
5
|
target: string;
|
|
6
6
|
phase: HookPhase;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { isRecord } from '../../shared/predicates.js';
|
|
2
|
-
import { LIFECYCLE_EVENTS, isLifecycleEvent } from './lifecycle-catalog.js';
|
|
2
|
+
import { LIFECYCLE_CATALOG, LIFECYCLE_EVENTS, isLifecycleEvent } from './lifecycle-catalog.js';
|
|
3
3
|
function typeName(value) {
|
|
4
4
|
if (value === null)
|
|
5
5
|
return 'null';
|
|
@@ -61,8 +61,9 @@ function validateLifecycleDeclaration(value, index, issue) {
|
|
|
61
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
62
|
return undefined;
|
|
63
63
|
}
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
const phase = LIFECYCLE_CATALOG[value['event']].phase;
|
|
65
|
+
if (value['phase'] !== phase) {
|
|
66
|
+
issue('hook_manifest_invalid', `phase must be ${phase}`, String(value['phase']), phase, `Set phase to ${phase}.`, `${path}.phase`);
|
|
66
67
|
return undefined;
|
|
67
68
|
}
|
|
68
69
|
if (!isEffects(value['effects'])) {
|
|
@@ -71,7 +72,7 @@ function validateLifecycleDeclaration(value, index, issue) {
|
|
|
71
72
|
}
|
|
72
73
|
return {
|
|
73
74
|
event: value['event'],
|
|
74
|
-
phase
|
|
75
|
+
phase,
|
|
75
76
|
op: value['op'],
|
|
76
77
|
description: value['description'],
|
|
77
78
|
effects: value['effects'],
|
|
@@ -170,7 +170,9 @@ function buildRequest(spec, invocation, cwd) {
|
|
|
170
170
|
if (invocation.result !== undefined) {
|
|
171
171
|
throw protocolError(spec, invocation, `${spec.phase} hook invocation included a result`, 'result only for after hooks');
|
|
172
172
|
}
|
|
173
|
-
|
|
173
|
+
if (spec.phase === 'before')
|
|
174
|
+
return { ...base, phase: 'before' };
|
|
175
|
+
return { ...base, phase: 'replace' };
|
|
174
176
|
}
|
|
175
177
|
function parseEnvelope(spec, invocation, stdout) {
|
|
176
178
|
let value;
|
|
@@ -1,12 +1,22 @@
|
|
|
1
|
-
import type { LifecycleHookRequest } from '../../../hook-authoring.js';
|
|
1
|
+
import type { LifecycleHookRequest, NodeCreateHookRequest } from '../../../hook-authoring.js';
|
|
2
2
|
import type { EffectiveLifecycleHook } from '../discovery.js';
|
|
3
|
-
export
|
|
4
|
-
export declare const LIFECYCLE_EVENT_BUDGET_MS = 10000;
|
|
5
|
-
export interface LifecycleHookInvocation {
|
|
3
|
+
export interface NodeLifecycleHookInvocation {
|
|
6
4
|
node: LifecycleHookRequest['node'];
|
|
7
5
|
runtime: LifecycleHookRequest['runtime'];
|
|
8
6
|
}
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
7
|
+
export interface NodeCreateHookInvocation {
|
|
8
|
+
create: NodeCreateHookRequest['create'];
|
|
9
|
+
runtime: NodeCreateHookRequest['runtime'];
|
|
10
|
+
}
|
|
11
|
+
export interface BlockingLifecycleHookRefusal {
|
|
12
|
+
hook: EffectiveLifecycleHook;
|
|
13
|
+
message: string;
|
|
14
|
+
next: string;
|
|
15
|
+
}
|
|
16
|
+
/** Runs one lifecycle hook. `node:start` and `node:close` remain best-effort. */
|
|
17
|
+
export declare function invokeLifecycleHook(hook: EffectiveLifecycleHook, invocation: NodeLifecycleHookInvocation, timeoutMs: number): Promise<void>;
|
|
18
|
+
/** Executes non-blocking lifecycle hooks in discovery order within the event's catalogued budget. */
|
|
19
|
+
export declare function invokeLifecycleHooks(hooks: readonly EffectiveLifecycleHook[], invocation: NodeLifecycleHookInvocation): Promise<void>;
|
|
20
|
+
/** Runs blocking `node:create` hooks. Every protocol or process failure refuses the create. */
|
|
21
|
+
export declare function invokeBlockingLifecycleHooks(hooks: readonly EffectiveLifecycleHook[], invocation: NodeCreateHookInvocation): Promise<BlockingLifecycleHookRefusal | null>;
|
|
22
|
+
export declare function lifecycleHookAttribution(hook: EffectiveLifecycleHook): string;
|
|
@@ -3,53 +3,109 @@ import { spawn } from 'node:child_process';
|
|
|
3
3
|
import { PROCESS_GROUP_KILL_GRACE_MS, posixProcessGroupSpawnOptions, terminateProcessGroup } from '../../../hook-process.js';
|
|
4
4
|
import { isRecord } from '../../../shared/predicates.js';
|
|
5
5
|
import { diag } from '../../io.js';
|
|
6
|
+
import { LIFECYCLE_CATALOG } from '../lifecycle-catalog.js';
|
|
6
7
|
const MAX_STDOUT = 10 * 1024 * 1024;
|
|
7
|
-
|
|
8
|
-
export const LIFECYCLE_EVENT_BUDGET_MS = 10_000;
|
|
9
|
-
/** Runs one lifecycle hook. The node launch remains authoritative on every failure. */
|
|
8
|
+
/** Runs one lifecycle hook. `node:start` and `node:close` remain best-effort. */
|
|
10
9
|
export async function invokeLifecycleHook(hook, invocation, timeoutMs) {
|
|
11
10
|
try {
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
event: hook.event,
|
|
16
|
-
phase: hook.phase,
|
|
17
|
-
operationId: randomBytes(16).toString('hex'),
|
|
18
|
-
node: invocation.node,
|
|
19
|
-
runtime: invocation.runtime,
|
|
20
|
-
context: { cwd: invocation.node.cwd },
|
|
21
|
-
};
|
|
22
|
-
const result = await runLifecycleProcess(hook, JSON.stringify(request), timeoutMs);
|
|
23
|
-
if (result.signal !== null)
|
|
24
|
-
throw new Error(`executable was killed by signal ${result.signal}`);
|
|
25
|
-
const envelope = parseEnvelope(result.stdout);
|
|
26
|
-
if (envelope.ok) {
|
|
27
|
-
if (result.status !== 0)
|
|
28
|
-
diag(`crtr: ${label(hook)} returned ok:true but exited ${result.status} (honoring the envelope)`);
|
|
29
|
-
return;
|
|
30
|
-
}
|
|
31
|
-
diag(`crtr: ${label(hook)} failed: ${envelope.error.code}: ${envelope.error.message}`);
|
|
11
|
+
const envelope = await executeLifecycleHook(hook, invocation, timeoutMs);
|
|
12
|
+
if (!envelope.ok)
|
|
13
|
+
diag(`crtr: ${label(hook)} failed: ${envelope.error.code}: ${envelope.error.message}`);
|
|
32
14
|
}
|
|
33
15
|
catch (error) {
|
|
34
16
|
diag(`crtr: ${label(hook)} failed: ${describeError(error)}`);
|
|
35
17
|
}
|
|
36
18
|
}
|
|
37
|
-
/** Executes lifecycle hooks in discovery order
|
|
19
|
+
/** Executes non-blocking lifecycle hooks in discovery order within the event's catalogued budget. */
|
|
38
20
|
export async function invokeLifecycleHooks(hooks, invocation) {
|
|
39
|
-
|
|
21
|
+
if (hooks.length === 0)
|
|
22
|
+
return;
|
|
23
|
+
const { hookTimeoutMs, eventBudgetMs } = LIFECYCLE_CATALOG[hooks[0].event];
|
|
24
|
+
const deadline = Date.now() + eventBudgetMs;
|
|
40
25
|
for (const hook of hooks) {
|
|
41
26
|
const remaining = deadline - Date.now();
|
|
42
|
-
|
|
43
|
-
// process-group termination grace inside the event budget rather than
|
|
44
|
-
// letting the final cleanup extend the node-start critical path.
|
|
45
|
-
const timeoutMs = Math.min(LIFECYCLE_HOOK_TIMEOUT_MS, remaining - PROCESS_GROUP_KILL_GRACE_MS);
|
|
27
|
+
const timeoutMs = Math.min(hookTimeoutMs, remaining - PROCESS_GROUP_KILL_GRACE_MS);
|
|
46
28
|
if (timeoutMs <= 0) {
|
|
47
|
-
diag(`crtr: lifecycle hook event ${hook.event} exceeded its ${
|
|
29
|
+
diag(`crtr: lifecycle hook event ${hook.event} exceeded its ${eventBudgetMs}ms aggregate budget; skipped ${label(hook)}`);
|
|
48
30
|
return;
|
|
49
31
|
}
|
|
50
32
|
await invokeLifecycleHook(hook, invocation, timeoutMs);
|
|
51
33
|
}
|
|
52
34
|
}
|
|
35
|
+
/** Runs blocking `node:create` hooks. Every protocol or process failure refuses the create. */
|
|
36
|
+
export async function invokeBlockingLifecycleHooks(hooks, invocation) {
|
|
37
|
+
if (hooks.length === 0)
|
|
38
|
+
return null;
|
|
39
|
+
const { hookTimeoutMs, eventBudgetMs } = LIFECYCLE_CATALOG['node:create'];
|
|
40
|
+
const deadline = Date.now() + eventBudgetMs;
|
|
41
|
+
for (const hook of hooks) {
|
|
42
|
+
const remaining = deadline - Date.now();
|
|
43
|
+
const timeoutMs = Math.min(hookTimeoutMs, remaining - PROCESS_GROUP_KILL_GRACE_MS);
|
|
44
|
+
if (timeoutMs <= 0) {
|
|
45
|
+
return refusal(hook, `lifecycle hook event node:create exceeded its ${eventBudgetMs}ms aggregate budget`);
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
const envelope = await executeLifecycleHook(hook, invocation, timeoutMs);
|
|
49
|
+
if (!envelope.ok)
|
|
50
|
+
return {
|
|
51
|
+
hook,
|
|
52
|
+
message: envelope.error.message,
|
|
53
|
+
next: envelope.error.next ?? attribution(hook),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
return refusal(hook, describeError(error));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
function refusal(hook, cause) {
|
|
63
|
+
return {
|
|
64
|
+
hook,
|
|
65
|
+
message: `${label(hook)} failed: ${cause}`,
|
|
66
|
+
next: attribution(hook),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
export function lifecycleHookAttribution(hook) {
|
|
70
|
+
return attribution(hook);
|
|
71
|
+
}
|
|
72
|
+
async function executeLifecycleHook(hook, invocation, timeoutMs) {
|
|
73
|
+
const request = hook.event === 'node:create'
|
|
74
|
+
? createRequest(hook, invocation)
|
|
75
|
+
: nodeRequest(hook, invocation);
|
|
76
|
+
const result = await runLifecycleProcess(hook, JSON.stringify(request), timeoutMs);
|
|
77
|
+
if (result.signal !== null)
|
|
78
|
+
throw new Error(`executable was killed by signal ${result.signal}`);
|
|
79
|
+
const envelope = parseEnvelope(result.stdout);
|
|
80
|
+
if (envelope.ok && result.status !== 0) {
|
|
81
|
+
diag(`crtr: ${label(hook)} returned ok:true but exited ${result.status} (honoring the envelope)`);
|
|
82
|
+
}
|
|
83
|
+
return envelope;
|
|
84
|
+
}
|
|
85
|
+
function nodeRequest(hook, invocation) {
|
|
86
|
+
return {
|
|
87
|
+
protocolVersion: 1,
|
|
88
|
+
op: hook.op,
|
|
89
|
+
event: hook.event,
|
|
90
|
+
phase: hook.phase,
|
|
91
|
+
operationId: randomBytes(16).toString('hex'),
|
|
92
|
+
node: invocation.node,
|
|
93
|
+
runtime: invocation.runtime,
|
|
94
|
+
context: { cwd: invocation.node.cwd },
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
function createRequest(hook, invocation) {
|
|
98
|
+
return {
|
|
99
|
+
protocolVersion: 1,
|
|
100
|
+
op: hook.op,
|
|
101
|
+
event: 'node:create',
|
|
102
|
+
phase: 'before',
|
|
103
|
+
operationId: randomBytes(16).toString('hex'),
|
|
104
|
+
create: invocation.create,
|
|
105
|
+
runtime: invocation.runtime,
|
|
106
|
+
context: { cwd: invocation.create.cwd },
|
|
107
|
+
};
|
|
108
|
+
}
|
|
53
109
|
async function runLifecycleProcess(hook, input, timeoutMs) {
|
|
54
110
|
let child;
|
|
55
111
|
try {
|
|
@@ -147,6 +203,9 @@ function exactKeys(value, required, optional = []) {
|
|
|
147
203
|
function label(hook) {
|
|
148
204
|
return `plugin "${hook.plugin.name}" lifecycle hook "${hook.op}" for ${hook.event}`;
|
|
149
205
|
}
|
|
206
|
+
function attribution(hook) {
|
|
207
|
+
return `Refused by plugin ${hook.plugin.name}, op ${hook.op}.`;
|
|
208
|
+
}
|
|
150
209
|
function describeError(error) {
|
|
151
210
|
return error instanceof Error ? error.message : String(error);
|
|
152
211
|
}
|
package/dist/core/errors.d.ts
CHANGED
|
@@ -22,6 +22,7 @@ export declare function general(message: string, details?: Record<string, unknow
|
|
|
22
22
|
* `apiErrorToCliError` (tier 2) reconstructs the exact code, message, and
|
|
23
23
|
* actionable `next` instead of degrading to a generic `invalid_request`. */
|
|
24
24
|
export declare function brokerLaunchFailed(message: string, next: string): CrtrError;
|
|
25
|
+
export declare function nodeCreateRefused(message: string, next: string): CrtrError;
|
|
25
26
|
/** Thrown by stub handlers for leaves not yet wired in P3+.
|
|
26
27
|
* code='not_implemented', exitCode=GENERAL, next names the node. */
|
|
27
28
|
export declare function notImplemented(node: string): CrtrError;
|
package/dist/core/errors.js
CHANGED
|
@@ -41,6 +41,10 @@ export function brokerLaunchFailed(message, next) {
|
|
|
41
41
|
const code = 'broker_launch_failed';
|
|
42
42
|
return new CrtrError(code, message, ExitCode.GENERAL, { error: code, message, next });
|
|
43
43
|
}
|
|
44
|
+
export function nodeCreateRefused(message, next) {
|
|
45
|
+
const code = 'node_create_refused';
|
|
46
|
+
return new CrtrError(code, message, ExitCode.GENERAL, { error: code, message, next });
|
|
47
|
+
}
|
|
44
48
|
/** Thrown by stub handlers for leaves not yet wired in P3+.
|
|
45
49
|
* code='not_implemented', exitCode=GENERAL, next names the node. */
|
|
46
50
|
export function notImplemented(node) {
|
|
@@ -27,7 +27,7 @@ export declare function captureOrigin(originNodeId: string, deps?: {
|
|
|
27
27
|
/** Materialize the immutable review fork, reusing only an already-valid branch. */
|
|
28
28
|
export declare function materializeReviewBranch(reviewId: string, capture: OriginCapture): string;
|
|
29
29
|
/** Create the ordinary, promptless companion node. Realization owns its launch. */
|
|
30
|
-
export declare function spawnReviewCompanion(spec: ReviewCompanionSpec): void
|
|
30
|
+
export declare function spawnReviewCompanion(spec: ReviewCompanionSpec): Promise<void>;
|
|
31
31
|
/** A companion is bound only when its session coordinates and visible boundary are durable. */
|
|
32
32
|
export declare function isReviewCompanionBound(nodeId: string): boolean;
|
|
33
33
|
/** Wait for the durable companion bind without owning companion liveness. */
|
|
@@ -141,7 +141,7 @@ export function materializeReviewBranch(reviewId, capture) {
|
|
|
141
141
|
return branchFile;
|
|
142
142
|
}
|
|
143
143
|
/** Create the ordinary, promptless companion node. Realization owns its launch. */
|
|
144
|
-
export function spawnReviewCompanion(spec) {
|
|
144
|
+
export async function spawnReviewCompanion(spec) {
|
|
145
145
|
if (!isAbsolute(spec.branchFile) || !isAbsolute(spec.targetFile)) {
|
|
146
146
|
throw new Error('review companion requires absolute branch and target paths');
|
|
147
147
|
}
|
|
@@ -156,7 +156,7 @@ export function spawnReviewCompanion(spec) {
|
|
|
156
156
|
cwd: spec.cwd,
|
|
157
157
|
profileId: spec.profileId,
|
|
158
158
|
});
|
|
159
|
-
const node = spawnNode({
|
|
159
|
+
const node = await spawnNode({
|
|
160
160
|
nodeId: spec.nodeId,
|
|
161
161
|
kind: 'review/companion',
|
|
162
162
|
mode: 'base',
|
|
@@ -57,12 +57,12 @@ function requireOrigin(review) {
|
|
|
57
57
|
throw originMissing(review.origin_node_id);
|
|
58
58
|
return origin;
|
|
59
59
|
}
|
|
60
|
-
function spawnCompanion(review) {
|
|
60
|
+
async function spawnCompanion(review) {
|
|
61
61
|
if (getNode(review.companion_node_id) !== null)
|
|
62
62
|
return;
|
|
63
63
|
const origin = requireOrigin(review);
|
|
64
64
|
try {
|
|
65
|
-
spawnReviewCompanion({
|
|
65
|
+
await spawnReviewCompanion({
|
|
66
66
|
nodeId: review.companion_node_id,
|
|
67
67
|
reviewId: review.review_id,
|
|
68
68
|
originNodeId: review.origin_node_id,
|
|
@@ -94,13 +94,13 @@ function ensureBridgeSubscription(review, bridgeNodeId) {
|
|
|
94
94
|
throw bridgeUnsubscribed(review, bridgeNodeId);
|
|
95
95
|
}
|
|
96
96
|
/** R2: create/bind the ticket bridge and prove its active origin subscription. */
|
|
97
|
-
function realizeBridge(review) {
|
|
97
|
+
async function realizeBridge(review) {
|
|
98
98
|
if (review.origin_kind !== 'ticket')
|
|
99
99
|
return review;
|
|
100
100
|
let current = review;
|
|
101
101
|
if (current.bridge_node_id === null) {
|
|
102
102
|
const origin = requireOrigin(current);
|
|
103
|
-
const bridge = spawnNode({
|
|
103
|
+
const bridge = await spawnNode({
|
|
104
104
|
kind: 'human',
|
|
105
105
|
lifecycle: 'terminal',
|
|
106
106
|
parent: current.origin_node_id,
|
|
@@ -160,9 +160,9 @@ export async function realizeReview(reviewId) {
|
|
|
160
160
|
throw new ReviewOperationError('branch_lost', 'review branch was lost before realization', { review_id: review.review_id });
|
|
161
161
|
}
|
|
162
162
|
// R1: creation consumes only the persisted coordinates and preallocated id.
|
|
163
|
-
spawnCompanion(review);
|
|
163
|
+
await spawnCompanion(review);
|
|
164
164
|
// R2: ticket bridge birth and route assertion precede all public exposure.
|
|
165
|
-
review = realizeBridge(requireReview(reviewId));
|
|
165
|
+
review = await realizeBridge(requireReview(reviewId));
|
|
166
166
|
if (review.state !== 'binding')
|
|
167
167
|
return review;
|
|
168
168
|
// R3: only realization owns this initial launch/retry.
|
|
@@ -101,6 +101,10 @@ export interface SpawnNodeOpts {
|
|
|
101
101
|
deadlineAt?: string;
|
|
102
102
|
/** Delivery target persisted with the row before any broker can launch. */
|
|
103
103
|
outcomeDelivery?: OutcomeDeliveryBinding;
|
|
104
|
+
/** Runs after the create hook admitted the birth but before its row is written. */
|
|
105
|
+
beforeCreate?: () => void | Promise<void>;
|
|
106
|
+
/** Root id replaced by this birth (`/new` or recycle); null for all other births. */
|
|
107
|
+
replaces?: string | null;
|
|
104
108
|
/** Override the generated id — either an internally pre-allocated id
|
|
105
109
|
* (worktree naming; always valid+fresh) or an external caller's explicit id
|
|
106
110
|
* (`--node-id`), which is format-validated
|
|
@@ -112,4 +116,4 @@ export interface SpawnNodeOpts {
|
|
|
112
116
|
* For a child (parent given): the parent auto-subscribes ACTIVE to the child
|
|
113
117
|
* (so it's woken when the child finishes), and a spawned_by audit edge is
|
|
114
118
|
* recorded. For a root (no parent): no edges, resident by default. */
|
|
115
|
-
export declare function spawnNode(opts: SpawnNodeOpts): NodeMeta
|
|
119
|
+
export declare function spawnNode(opts: SpawnNodeOpts): Promise<NodeMeta>;
|
|
@@ -21,6 +21,7 @@ import { resolveInstallId } from '../canvas/install-id.js';
|
|
|
21
21
|
import { canvasDbPath } from '../canvas/paths.js';
|
|
22
22
|
import { usage } from '../errors.js';
|
|
23
23
|
import { assertProfileAvailableForBirth } from '../profiles/deletion-reservation.js';
|
|
24
|
+
import { admitNodeCreate } from '../command-hooks/lifecycle-create.js';
|
|
24
25
|
import { fanDoctrineWake } from './close.js';
|
|
25
26
|
import { BIRTH_ANNOUNCEMENT_MARKER, birthAnnouncementBody } from '../../shared/birth-announcement.js';
|
|
26
27
|
// crtrd sets this once at boot (setInstallId) and it wins unconditionally —
|
|
@@ -131,14 +132,9 @@ export function preflightNodeId(nodeId) {
|
|
|
131
132
|
* satisfy the format unconditionally and are never a duplicate (they embed a
|
|
132
133
|
* fresh timestamp+random suffix), so this is only ever exercised by an
|
|
133
134
|
* explicit `opts.nodeId` from a real caller (`--node-id` / API `node_id`).
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
* `
|
|
137
|
-
* synchronous function — so on the single-threaded daemon process, the
|
|
138
|
-
* existence-check-then-create is atomic relative to any other concurrent
|
|
139
|
-
* request. Two concurrent creates at the same id resolve to exactly one
|
|
140
|
-
* winner (whichever's synchronous `spawnNode()` call runs first) and one
|
|
141
|
-
* `NodeIdConflictError` for the loser. */
|
|
135
|
+
* It runs inside the create critical section immediately before `createNode`,
|
|
136
|
+
* so concurrent explicit-id births resolve to one winner and one
|
|
137
|
+
* `NodeIdConflictError`. */
|
|
142
138
|
function validatedExplicitNodeId(nodeId) {
|
|
143
139
|
validateNodeIdFormat(nodeId);
|
|
144
140
|
if (getNode(nodeId) !== null)
|
|
@@ -225,8 +221,8 @@ export function nodeEnv(meta) {
|
|
|
225
221
|
* For a child (parent given): the parent auto-subscribes ACTIVE to the child
|
|
226
222
|
* (so it's woken when the child finishes), and a spawned_by audit edge is
|
|
227
223
|
* recorded. For a root (no parent): no edges, resident by default. */
|
|
228
|
-
export function spawnNode(opts) {
|
|
229
|
-
const nodeId = opts.nodeId !== undefined ?
|
|
224
|
+
export async function spawnNode(opts) {
|
|
225
|
+
const nodeId = opts.nodeId !== undefined ? (validateNodeIdFormat(opts.nodeId), opts.nodeId) : newNodeId();
|
|
230
226
|
const parent = opts.parent ?? null;
|
|
231
227
|
const isRoot = parent === null;
|
|
232
228
|
// Provenance is independent of the spine: a root has no parent but still
|
|
@@ -279,7 +275,26 @@ export function spawnNode(opts) {
|
|
|
279
275
|
throw new Error(`cannot spawn from unknown creator node: ${creator}`);
|
|
280
276
|
}
|
|
281
277
|
assertProfileAvailableForBirth(meta.profile_id ?? null);
|
|
282
|
-
|
|
278
|
+
const releaseCreateGate = await admitNodeCreate({
|
|
279
|
+
kind: meta.kind,
|
|
280
|
+
mode: meta.mode,
|
|
281
|
+
cwd: meta.cwd,
|
|
282
|
+
profile: meta.profile_id ?? null,
|
|
283
|
+
root: isRoot,
|
|
284
|
+
parentId: parent,
|
|
285
|
+
requestingNodeId: creator,
|
|
286
|
+
lifecycle,
|
|
287
|
+
replaces: opts.replaces ?? null,
|
|
288
|
+
});
|
|
289
|
+
try {
|
|
290
|
+
if (opts.nodeId !== undefined)
|
|
291
|
+
validatedExplicitNodeId(opts.nodeId);
|
|
292
|
+
await opts.beforeCreate?.();
|
|
293
|
+
createNode(meta, opts.outcomeDelivery === undefined ? undefined : { outcomeDelivery: opts.outcomeDelivery });
|
|
294
|
+
}
|
|
295
|
+
finally {
|
|
296
|
+
releaseCreateGate();
|
|
297
|
+
}
|
|
283
298
|
// Create the node-local memory directory so substrate docs can be written
|
|
284
299
|
// directly into it without a separate mkdir. Skip for ephemeral human-bridge
|
|
285
300
|
// rows (kind 'human') — they are not agent nodes.
|