@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.
Files changed (68) hide show
  1. package/dist/builtin-pi-packages/pi-crtr-extensions/package-lock.json +3 -3
  2. package/dist/clients/attach/viewer.js +389 -389
  3. package/dist/commands/__tests__/integration/dashboard-snapshot.test.d.ts +1 -0
  4. package/dist/commands/__tests__/integration/dashboard-snapshot.test.js +212 -0
  5. package/dist/commands/api-client.d.ts +1 -0
  6. package/dist/commands/api-client.js +4 -2
  7. package/dist/commands/canvas-roster.js +24 -17
  8. package/dist/commands/dashboard.js +23 -16
  9. package/dist/core/__tests__/context-intro.test.js +4 -4
  10. package/dist/core/__tests__/cron-timeout-descendants.test.d.ts +1 -0
  11. package/dist/core/__tests__/cron-timeout-descendants.test.js +102 -0
  12. package/dist/core/__tests__/human-deliver.test.js +18 -18
  13. package/dist/core/__tests__/integration/human-deliver-e2e.test.js +5 -5
  14. package/dist/core/__tests__/integration/lifecycle-hooks.test.js +299 -1
  15. package/dist/core/__tests__/integration/spawn-root.test.js +11 -11
  16. package/dist/core/__tests__/on-read-crouter-home-fence.test.js +2 -2
  17. package/dist/core/__tests__/on-read-dedup-resume.test.js +6 -6
  18. package/dist/core/__tests__/on-read-nested-store.test.js +6 -6
  19. package/dist/core/__tests__/profile-project-memory-delivery.test.js +31 -31
  20. package/dist/core/__tests__/spawn-worktree-id-conflict.test.js +3 -3
  21. package/dist/core/__tests__/warm-claim-preference-snapshot.test.js +1 -1
  22. package/dist/core/canvas/__tests__/remote-canvas-source.test.js +24 -11
  23. package/dist/core/canvas/remote-canvas-source.d.ts +4 -0
  24. package/dist/core/canvas/remote-canvas-source.js +58 -6
  25. package/dist/core/canvas/render-source.d.ts +4 -4
  26. package/dist/core/canvas/render-source.js +49 -48
  27. package/dist/core/canvas/source.d.ts +2 -0
  28. package/dist/core/canvas/source.js +4 -1
  29. package/dist/core/command-hooks/artifact.d.ts +6 -0
  30. package/dist/core/command-hooks/artifact.js +22 -6
  31. package/dist/core/command-hooks/discovery.d.ts +2 -0
  32. package/dist/core/command-hooks/discovery.js +2 -0
  33. package/dist/core/command-hooks/lifecycle-catalog.d.ts +20 -2
  34. package/dist/core/command-hooks/lifecycle-catalog.js +7 -2
  35. package/dist/core/command-hooks/lifecycle-create.d.ts +4 -0
  36. package/dist/core/command-hooks/lifecycle-create.js +51 -0
  37. package/dist/core/command-hooks/schema.d.ts +2 -2
  38. package/dist/core/command-hooks/schema.js +5 -4
  39. package/dist/core/command-hooks/transport/exec-invoke.js +3 -1
  40. package/dist/core/command-hooks/transport/exec-lifecycle.d.ts +18 -8
  41. package/dist/core/command-hooks/transport/exec-lifecycle.js +89 -30
  42. package/dist/core/errors.d.ts +1 -0
  43. package/dist/core/errors.js +4 -0
  44. package/dist/core/human/feedback-companion.js +1 -1
  45. package/dist/core/review/companion.d.ts +1 -1
  46. package/dist/core/review/companion.js +2 -2
  47. package/dist/core/review/realize.js +6 -6
  48. package/dist/core/runtime/nodes.d.ts +5 -1
  49. package/dist/core/runtime/nodes.js +26 -11
  50. package/dist/core/runtime/recycle.js +16 -12
  51. package/dist/core/runtime/reset.js +4 -1
  52. package/dist/core/runtime/revive.js +13 -12
  53. package/dist/core/runtime/spawn.d.ts +4 -1
  54. package/dist/core/runtime/spawn.js +30 -3
  55. package/dist/core/runtime/warm-pool.d.ts +2 -2
  56. package/dist/core/runtime/warm-pool.js +23 -3
  57. package/dist/daemon/__tests__/node-outcome-birth-invariants.test.js +2 -2
  58. package/dist/daemon/api/__tests__/profile-launch-gates.test.js +2 -2
  59. package/dist/daemon/api/__tests__/seam/profile-delete.test.js +2 -2
  60. package/dist/daemon/api/handlers/human.js +5 -2
  61. package/dist/daemon/api/handlers/nodes.d.ts +1 -0
  62. package/dist/daemon/api/handlers/nodes.js +34 -29
  63. package/dist/daemon/cron-run.js +49 -15
  64. package/dist/hook-authoring.d.ts +28 -2
  65. package/dist/hook-authoring.js +53 -1
  66. package/dist/index.d.ts +1 -1
  67. package/package.json +1 -1
  68. 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 LIFECYCLE_EVENTS: readonly ["node:start", "node:close"];
2
- export type LifecycleEvent = typeof LIFECYCLE_EVENTS[number];
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 LIFECYCLE_EVENTS = ['node:start', 'node:close'];
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 LIFECYCLE_EVENTS.includes(value);
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
- if (value['phase'] !== 'on') {
65
- issue('hook_manifest_invalid', 'phase must be on', String(value['phase']), 'on', 'Set phase to on.', `${path}.phase`);
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: 'on',
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
- return { ...base, phase: spec.phase };
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 declare const LIFECYCLE_HOOK_TIMEOUT_MS = 5000;
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
- /** 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 event budget. */
12
- export declare function invokeLifecycleHooks(hooks: readonly EffectiveLifecycleHook[], invocation: LifecycleHookInvocation): Promise<void>;
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
- export const LIFECYCLE_HOOK_TIMEOUT_MS = 5_000;
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 request = {
13
- protocolVersion: 1,
14
- op: hook.op,
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, within one bounded event budget. */
19
+ /** Executes non-blocking lifecycle hooks in discovery order within the event's catalogued budget. */
38
20
  export async function invokeLifecycleHooks(hooks, invocation) {
39
- const deadline = Date.now() + LIFECYCLE_EVENT_BUDGET_MS;
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
- // A timed-out hook must be reaped before launch can proceed. Reserve the
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 ${LIFECYCLE_EVENT_BUDGET_MS}ms aggregate budget; skipped ${label(hook)}`);
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
  }
@@ -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;
@@ -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) {
@@ -65,7 +65,7 @@ export async function realizeFeedbackCompanion(args) {
65
65
  cwd: origin.cwd,
66
66
  profileId: origin.profile_id ?? null,
67
67
  });
68
- const node = spawnNode({
68
+ const node = await spawnNode({
69
69
  kind: origin.kind,
70
70
  mode: origin.mode,
71
71
  lifecycle: 'resident',
@@ -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
- * RACE SAFETY: this function and the `createNode()` call that follows it in
136
- * `spawnNode` run with no `await` between them, and `spawnNode` itself is a
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 ? validatedExplicitNodeId(opts.nodeId) : newNodeId();
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
- createNode(meta, opts.outcomeDelivery === undefined ? undefined : { outcomeDelivery: opts.outcomeDelivery });
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.