@north-light/crouter 0.3.311 → 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 (51) hide show
  1. package/dist/clients/attach/viewer.js +394 -394
  2. package/dist/core/__tests__/context-intro.test.js +4 -4
  3. package/dist/core/__tests__/human-deliver.test.js +18 -18
  4. package/dist/core/__tests__/integration/human-deliver-e2e.test.js +5 -5
  5. package/dist/core/__tests__/integration/lifecycle-hooks.test.js +299 -1
  6. package/dist/core/__tests__/integration/spawn-root.test.js +11 -11
  7. package/dist/core/__tests__/on-read-crouter-home-fence.test.js +2 -2
  8. package/dist/core/__tests__/on-read-dedup-resume.test.js +6 -6
  9. package/dist/core/__tests__/on-read-nested-store.test.js +6 -6
  10. package/dist/core/__tests__/profile-project-memory-delivery.test.js +31 -31
  11. package/dist/core/__tests__/spawn-worktree-id-conflict.test.js +3 -3
  12. package/dist/core/__tests__/warm-claim-preference-snapshot.test.js +1 -1
  13. package/dist/core/command-hooks/artifact.d.ts +6 -0
  14. package/dist/core/command-hooks/artifact.js +22 -6
  15. package/dist/core/command-hooks/discovery.d.ts +2 -0
  16. package/dist/core/command-hooks/discovery.js +2 -0
  17. package/dist/core/command-hooks/lifecycle-catalog.d.ts +20 -2
  18. package/dist/core/command-hooks/lifecycle-catalog.js +7 -2
  19. package/dist/core/command-hooks/lifecycle-create.d.ts +4 -0
  20. package/dist/core/command-hooks/lifecycle-create.js +51 -0
  21. package/dist/core/command-hooks/schema.d.ts +2 -2
  22. package/dist/core/command-hooks/schema.js +5 -4
  23. package/dist/core/command-hooks/transport/exec-invoke.js +3 -1
  24. package/dist/core/command-hooks/transport/exec-lifecycle.d.ts +18 -8
  25. package/dist/core/command-hooks/transport/exec-lifecycle.js +89 -30
  26. package/dist/core/errors.d.ts +1 -0
  27. package/dist/core/errors.js +4 -0
  28. package/dist/core/human/feedback-companion.js +1 -1
  29. package/dist/core/review/companion.d.ts +1 -1
  30. package/dist/core/review/companion.js +2 -2
  31. package/dist/core/review/realize.js +6 -6
  32. package/dist/core/runtime/nodes.d.ts +5 -1
  33. package/dist/core/runtime/nodes.js +26 -11
  34. package/dist/core/runtime/recycle.js +16 -12
  35. package/dist/core/runtime/reset.js +4 -1
  36. package/dist/core/runtime/revive.js +13 -12
  37. package/dist/core/runtime/spawn.d.ts +4 -1
  38. package/dist/core/runtime/spawn.js +30 -3
  39. package/dist/core/runtime/warm-pool.d.ts +2 -2
  40. package/dist/core/runtime/warm-pool.js +23 -3
  41. package/dist/daemon/__tests__/node-outcome-birth-invariants.test.js +2 -2
  42. package/dist/daemon/api/__tests__/profile-launch-gates.test.js +2 -2
  43. package/dist/daemon/api/__tests__/seam/profile-delete.test.js +2 -2
  44. package/dist/daemon/api/handlers/human.js +5 -2
  45. package/dist/daemon/api/handlers/nodes.d.ts +1 -0
  46. package/dist/daemon/api/handlers/nodes.js +34 -29
  47. package/dist/hook-authoring.d.ts +28 -2
  48. package/dist/hook-authoring.js +53 -1
  49. package/dist/index.d.ts +1 -1
  50. package/package.json +1 -1
  51. package/runtime.lock.json +5 -5
@@ -99,11 +99,11 @@ test('revive refuses a paused profile before launching a broker', async () => {
99
99
  // (fork, cron spawn, warm-pool mint, `/new` relaunch, recycle) actually hits,
100
100
  // because they all reach the canvas through spawnNode and none of them call
101
101
  // handleCreate.
102
- test('the birth gate refuses a paused profile for a daemon-side spawn', () => {
102
+ test('the birth gate refuses a paused profile for a daemon-side spawn', async () => {
103
103
  const profile = createProfile('paused spawn', [{ path: cwd, memory: 'content' }]);
104
104
  pauseProfile(profile.profileId);
105
105
  const beforeRows = listNodes().length;
106
- assert.throws(() => spawnNode({ kind: 'general', cwd, parent: null, profile_id: profile.profileId }), (error) => {
106
+ await assert.rejects(() => spawnNode({ kind: 'general', cwd, parent: null, profile_id: profile.profileId }), (error) => {
107
107
  assert.ok(error instanceof CrtrError);
108
108
  assert.equal(error.code, 'usage');
109
109
  assert.match(error.details?.['next'], new RegExp(`crtr profile resume ${profile.profileId}`));
@@ -247,13 +247,13 @@ test('forced profile deletion owns the complete daemon lifecycle', { timeout: 90
247
247
  assert.equal(readFileSync(hardReview.sourceFile, 'utf8'), 'source\n', 'the reviewed source document is never removed');
248
248
  assert.equal(getDefaultProfileId(hardPin), null);
249
249
  await assert.rejects(client.getProfile(hardProfile.id), (error) => error instanceof ApiError && error.status === 404);
250
- assert.throws(() => spawnNode({
250
+ await assert.rejects(() => spawnNode({
251
251
  kind: 'developer',
252
252
  cwd: process.cwd(),
253
253
  parent: null,
254
254
  profile_id: hardProfile.id,
255
255
  nodeId: 'hard-stale-birth-deadbeef',
256
- }), /profile not found/, 'the final synchronous birth gate rejects a profile resolved before a completed hard deletion');
256
+ }), /profile not found/, 'the final birth gate rejects a profile resolved before a completed hard deletion');
257
257
  assert.equal(h.node('hard-stale-birth-deadbeef'), null);
258
258
  // Detach preserves nodes, topology, ticket/review state, and conversations;
259
259
  // matching brokers restart with no profile identity and matching crons become
@@ -10,6 +10,7 @@ import { readTicketResult, validatePageTicketResponses } from '../../../core/hum
10
10
  import { getReviewByBridge } from '../../../core/review/store.js';
11
11
  import { ReviewOperationError } from '../../../core/review/types.js';
12
12
  import { spawnNode } from '../../../core/runtime/nodes.js';
13
+ import { CrtrError } from '../../../core/errors.js';
13
14
  import { cancelHumanTicket, resolvePageTicket } from '../../human/finish.js';
14
15
  import { cancelReview } from '../../review/finish.js';
15
16
  function requireInteractionTicketId(ctx) {
@@ -20,7 +21,7 @@ function requireInteractionTicketId(ctx) {
20
21
  return ticketId;
21
22
  }
22
23
  // POST /v1/human/bridge — create a terminal bridge without a broker
23
- function handleCreateBridge(ctx) {
24
+ async function handleCreateBridge(ctx) {
24
25
  const req = ctx.body;
25
26
  if (req === undefined || typeof req !== 'object') {
26
27
  throw new ApiError(400, 'invalid_request', 'bridge body is required');
@@ -37,10 +38,12 @@ function handleCreateBridge(ctx) {
37
38
  throw new ApiError(400, 'invalid_request', 'bridge `parent` must be a node id or null');
38
39
  }
39
40
  try {
40
- const node = spawnNode({ kind: 'human', parent, creator: parent, cwd, name, lifecycle: 'terminal' });
41
+ const node = await spawnNode({ kind: 'human', parent, creator: parent, cwd, name, lifecycle: 'terminal' });
41
42
  return { status: 200, body: { node_id: node.node_id, name: node.name } };
42
43
  }
43
44
  catch (error) {
45
+ if (error instanceof CrtrError && error.code === 'node_create_refused')
46
+ throw error;
44
47
  throw new ApiError(400, 'invalid_request', String(error));
45
48
  }
46
49
  }
@@ -6,6 +6,7 @@ interface NodeHandlerDeps {
6
6
  export { resolveCreateKind } from '../../../core/runtime/launch-target.js';
7
7
  export declare function handleCreate(ctx: RequestContext, deps?: NodeHandlerDeps): Promise<HandlerResult>;
8
8
  export declare function handleList(ctx: RequestContext): HandlerResult;
9
+ export declare function handleFork(ctx: RequestContext): Promise<HandlerResult>;
9
10
  export declare function handlePromote(ctx: RequestContext, deps?: NodeHandlerDeps): Promise<HandlerResult>;
10
11
  export declare function handleYield(ctx: RequestContext, deps?: NodeHandlerDeps): Promise<HandlerResult>;
11
12
  export declare function handleConfig(ctx: RequestContext, deps?: NodeHandlerDeps): Promise<HandlerResult>;
@@ -13,7 +13,7 @@ import { appendSituationalContext, formatSituationalProse } from '../../../core/
13
13
  import { closeNode } from '../../../core/runtime/close.js';
14
14
  import { reviveNode } from '../../../core/runtime/revive.js';
15
15
  import { nowIso } from '../../../core/fs-utils.js';
16
- import { assertLaunchModelRegistered, forkNode, spawnChild } from '../../../core/runtime/spawn.js';
16
+ import { assertLaunchModelRegistered, forkNode, spawnChildPrepared } from '../../../core/runtime/spawn.js';
17
17
  import { childrenOf, getNode, listNodes, subscribersOf, subscriptionsOf, setMessageWait, updateNode } from '../../../core/canvas/canvas.js';
18
18
  import { subtreeIds } from '../../../core/canvas/nav-model.js';
19
19
  import { ticketCountsForNodes } from '../../../core/canvas/attention.js';
@@ -298,32 +298,39 @@ export async function handleCreate(ctx, deps = {}) {
298
298
  const resolvedReq = { ...req, kind };
299
299
  const resolvedOutcomeDelivery = outcomeDelivery === undefined ? undefined : resolveOutcomeDelivery(outcomeDelivery, cwd);
300
300
  await assertLaunchModelRegistered(requestFromLaunch(launch), { cwd, profileId }, deps.modelRegistry);
301
- // --- Warm path: hand out an already-booted spare instead of paying a full
302
- // engine boot. A miss is not a failure — it falls through to the cold
303
- // spawn below and mints the spare that serves the NEXT create. ---
304
- const warm = warmRequestFor(resolvedReq, cwd, profileId);
305
- if (warm !== null) {
306
- const claimed = await claimWarmNode(warm, {
307
- ...(deadline === undefined ? {} : { deadlineAt: deadline }),
308
- ...(resolvedOutcomeDelivery === undefined ? {} : { outcomeDelivery: resolvedOutcomeDelivery }),
309
- });
310
- refillWarmPool(warm);
311
- if (claimed !== null) {
312
- if (schema !== null)
313
- writeOutputSchema(claimed, 'terminal', schema);
314
- return { status: 201, body: detailOf(claimed) };
301
+ let warmRefill = null;
302
+ {
303
+ // --- Warm path: hand out an already-booted spare instead of paying a full
304
+ // engine boot. A miss is not a failure — it falls through to the cold
305
+ // spawn below and mints the spare that serves the NEXT create. ---
306
+ const warm = warmRequestFor(resolvedReq, cwd, profileId);
307
+ if (warm !== null) {
308
+ const claimed = await claimWarmNode(warm, {
309
+ ...(deadline === undefined ? {} : { deadlineAt: deadline }),
310
+ ...(resolvedOutcomeDelivery === undefined ? {} : { outcomeDelivery: resolvedOutcomeDelivery }),
311
+ });
312
+ if (claimed !== null) {
313
+ refillWarmPool(warm);
314
+ if (schema !== null)
315
+ writeOutputSchema(claimed, 'terminal', schema);
316
+ return { status: 201, body: detailOf(claimed) };
317
+ }
318
+ warmRefill = warm;
315
319
  }
320
+ const recipe = buildRecipe(resolvedReq, parent, cwd);
321
+ if (deadline !== undefined)
322
+ recipe.deadlineAt = deadline;
323
+ if (resolvedOutcomeDelivery !== undefined)
324
+ recipe.outcomeDelivery = resolvedOutcomeDelivery;
325
+ recipe.readiness = req.root === true ? 'await' : 'launch';
326
+ const result = await spawnChildPrepared(recipe, undefined, () => {
327
+ if (warmRefill !== null)
328
+ refillWarmPool(warmRefill);
329
+ });
330
+ if (schema !== null)
331
+ writeOutputSchema(result.node.node_id, 'terminal', schema);
332
+ return { status: 201, body: detailOf(result.node.node_id) };
316
333
  }
317
- const recipe = buildRecipe(resolvedReq, parent, cwd);
318
- if (deadline !== undefined)
319
- recipe.deadlineAt = deadline;
320
- if (resolvedOutcomeDelivery !== undefined)
321
- recipe.outcomeDelivery = resolvedOutcomeDelivery;
322
- recipe.readiness = req.root === true ? 'await' : 'launch';
323
- const result = await spawnChild(recipe);
324
- if (schema !== null)
325
- writeOutputSchema(result.node.node_id, 'terminal', schema);
326
- return { status: 201, body: detailOf(result.node.node_id) };
327
334
  }
328
335
  // GET /v1/nodes — list (filters) ; GET /v1/nodes/{id} — detail
329
336
  function singleQueryValue(ctx, name) {
@@ -557,10 +564,8 @@ function handleArtifacts(ctx) {
557
564
  return { status: 200, body };
558
565
  }
559
566
  // Lifecycle verbs
560
- async function handleFork(ctx) {
561
- const id = ctx.params['id'];
562
- requireMeta(id);
563
- const result = await forkNode(id);
567
+ export async function handleFork(ctx) {
568
+ const result = await forkNode(ctx.params['id']);
564
569
  return { status: 201, body: detailOf(result.node.node_id) };
565
570
  }
566
571
  async function handleRevive(ctx) {
@@ -23,6 +23,32 @@ export interface LifecycleHookRuntime {
23
23
  canvasHome: string;
24
24
  profile: string | null;
25
25
  }
26
+ /** `node:create` runs before every node birth, immediately before a cold row is written or a warm spare is claimed. Return `ok:false` to refuse the birth. There is no node payload because no node exists yet. */
27
+ export interface NodeCreateHookRequest {
28
+ protocolVersion: 1;
29
+ op: string;
30
+ event: 'node:create';
31
+ phase: 'before';
32
+ operationId: string;
33
+ create: {
34
+ kind: string;
35
+ mode: string;
36
+ cwd: string;
37
+ profile: string | null;
38
+ root: boolean;
39
+ parentId: string | null;
40
+ requestingNodeId: string | null;
41
+ lifecycle: 'resident' | 'terminal';
42
+ replaces: string | null;
43
+ };
44
+ runtime: {
45
+ canvasHome: string;
46
+ profile: string | null;
47
+ };
48
+ context: {
49
+ cwd: string;
50
+ };
51
+ }
26
52
  interface HookRequestBase {
27
53
  protocolVersion: 1;
28
54
  op: string;
@@ -43,7 +69,7 @@ export interface AfterHookRequest extends HookRequestBase {
43
69
  export interface ReplaceHookRequest extends HookRequestBase {
44
70
  phase: 'replace';
45
71
  }
46
- /** Lifecycle handlers run on every matching event. `node:start` handlers must be idempotent because they fire for birth and every revive. */
72
+ /** `node:start` handlers run for birth and every revive, so they must be idempotent. `node:close` runs after close. */
47
73
  export interface LifecycleHookRequest {
48
74
  protocolVersion: 1;
49
75
  op: string;
@@ -56,7 +82,7 @@ export interface LifecycleHookRequest {
56
82
  cwd: string;
57
83
  };
58
84
  }
59
- export type HookRequest = BeforeHookRequest | AfterHookRequest | ReplaceHookRequest | LifecycleHookRequest;
85
+ export type HookRequest = BeforeHookRequest | AfterHookRequest | ReplaceHookRequest | NodeCreateHookRequest | LifecycleHookRequest;
60
86
  export interface HookSuccess {
61
87
  protocolVersion: 1;
62
88
  ok: true;
@@ -246,6 +246,28 @@ function validateRequest(value) {
246
246
  if (typeof value['operationId'] !== 'string' || !/^[0-9a-f]{32}$/.test(value['operationId'])) {
247
247
  return invalid('operationId must be a 32-character lowercase hexadecimal string');
248
248
  }
249
+ if (value['event'] === 'node:create') {
250
+ if (!hasOnlyKeys(value, ['protocolVersion', 'op', 'event', 'phase', 'operationId', 'create', 'runtime', 'context']))
251
+ return invalid('node:create request contains unknown fields');
252
+ if (value['phase'] !== 'before')
253
+ return invalid('node:create phase must be before');
254
+ if (!isNodeCreate(value['create']))
255
+ return invalid('node:create request create contains invalid fields');
256
+ if (!isCreateRuntime(value['runtime']))
257
+ return invalid('node:create request runtime contains invalid fields');
258
+ if (!isLifecycleContext(value['context']))
259
+ return invalid('node:create request context must contain cwd');
260
+ return valid({
261
+ protocolVersion: 1,
262
+ op: value['op'],
263
+ event: 'node:create',
264
+ phase: 'before',
265
+ operationId: value['operationId'],
266
+ create: value['create'],
267
+ runtime: value['runtime'],
268
+ context: value['context'],
269
+ });
270
+ }
249
271
  if (value['phase'] === 'on') {
250
272
  if (!hasOnlyKeys(value, ['protocolVersion', 'op', 'event', 'phase', 'operationId', 'node', 'runtime', 'context']))
251
273
  return invalid('lifecycle request contains unknown fields');
@@ -295,10 +317,21 @@ function validateRequest(value) {
295
317
  return invalid('phase must be before, after, or replace');
296
318
  if (Object.hasOwn(value, 'result'))
297
319
  return invalid('only after requests may contain result');
320
+ if (value['phase'] === 'before') {
321
+ return valid({
322
+ protocolVersion: 1,
323
+ op: value['op'],
324
+ phase: 'before',
325
+ command: value['command'],
326
+ operationId: value['operationId'],
327
+ input: value['input'],
328
+ context: value['context'],
329
+ });
330
+ }
298
331
  return valid({
299
332
  protocolVersion: 1,
300
333
  op: value['op'],
301
- phase: value['phase'],
334
+ phase: 'replace',
302
335
  command: value['command'],
303
336
  operationId: value['operationId'],
304
337
  input: value['input'],
@@ -342,6 +375,25 @@ function isContext(value) {
342
375
  function isLifecycleContext(value) {
343
376
  return isRecord(value) && hasOnlyKeys(value, ['cwd']) && typeof value['cwd'] === 'string';
344
377
  }
378
+ function isNodeCreate(value) {
379
+ return isRecord(value)
380
+ && hasOnlyKeys(value, ['kind', 'mode', 'cwd', 'profile', 'root', 'parentId', 'requestingNodeId', 'lifecycle', 'replaces'])
381
+ && typeof value['kind'] === 'string'
382
+ && typeof value['mode'] === 'string'
383
+ && typeof value['cwd'] === 'string'
384
+ && (typeof value['profile'] === 'string' || value['profile'] === null)
385
+ && typeof value['root'] === 'boolean'
386
+ && (typeof value['parentId'] === 'string' || value['parentId'] === null)
387
+ && (typeof value['requestingNodeId'] === 'string' || value['requestingNodeId'] === null)
388
+ && (value['lifecycle'] === 'resident' || value['lifecycle'] === 'terminal')
389
+ && (typeof value['replaces'] === 'string' || value['replaces'] === null);
390
+ }
391
+ function isCreateRuntime(value) {
392
+ return isRecord(value)
393
+ && hasOnlyKeys(value, ['canvasHome', 'profile'])
394
+ && typeof value['canvasHome'] === 'string'
395
+ && (typeof value['profile'] === 'string' || value['profile'] === null);
396
+ }
345
397
  function isLifecycleNode(value) {
346
398
  return isRecord(value)
347
399
  && hasOnlyKeys(value, ['id', 'name', 'kind', 'mode', 'lifecycle', 'cwd', 'nodeDir', 'contextDir', 'jobDir', 'reportsDir'])
package/dist/index.d.ts CHANGED
@@ -23,7 +23,7 @@ export { nowIso } from './core/fs-utils.js';
23
23
  export { validateOperationId, buildEnvelope, operationIdContext, errorClassCodes, serializeEventRecord, errorClassFromError, } from './core/events/index.js';
24
24
  export type { BuildEnvelopeInput, ErrorClass, ErrorClassCode, ErrorClassHints, EventComponent, EventEnvelope, EventError, EventField, EventLevel, EventOutcome, OperationId, OperationIdContext, RetryDisposition, } from './core/events/index.js';
25
25
  export { forwardHook, hookError, runHookExecutable, runLocalHookProcess } from './hook-authoring.js';
26
- export type { AfterHookRequest, BeforeHookRequest, ForwardHookOptions, HookContext, HookError, HookFailure, HookHandler, HookHandlers, HookJson, HookPhase, HookRequest, HookResponse, HookSuccess, LocalHookProcessOptions, LocalHookProcessResult, LocalHookProcessSuccess, ReplaceHookRequest, RunHookExecutableOptions, } from './hook-authoring.js';
26
+ export type { AfterHookRequest, BeforeHookRequest, ForwardHookOptions, HookContext, HookError, HookFailure, HookHandler, HookHandlers, HookJson, HookPhase, HookRequest, HookResponse, HookSuccess, LocalHookProcessOptions, LocalHookProcessResult, LocalHookProcessSuccess, LifecycleHookNode, LifecycleHookRequest, LifecycleHookRuntime, NodeCreateHookRequest, ReplaceHookRequest, RunHookExecutableOptions, } from './hook-authoring.js';
27
27
  export { validateCommandManifest } from './core/command-manifests/manifest.js';
28
28
  export type { CommandManifestValidation, ValidatedCommandManifest } from './core/command-manifests/manifest.js';
29
29
  export type { CommandManifestIssue, TransportKind } from './core/command-manifests/schema.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.311",
3
+ "version": "0.3.312",
4
4
  "description": "crtr — agent runtime with memory, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/runtime.lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.311",
3
+ "version": "0.3.312",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@north-light/crouter",
9
- "version": "0.3.311",
9
+ "version": "0.3.312",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT",
12
12
  "workspaces": [
@@ -5206,17 +5206,17 @@
5206
5206
  },
5207
5207
  "packages/crouter-api": {
5208
5208
  "name": "@north-light/crouter-api",
5209
- "version": "0.3.311",
5209
+ "version": "0.3.312",
5210
5210
  "license": "UNLICENSED"
5211
5211
  },
5212
5212
  "packages/crouter-env-docker": {
5213
5213
  "name": "@north-light/crouter-env-docker",
5214
- "version": "0.3.311",
5214
+ "version": "0.3.312",
5215
5215
  "license": "UNLICENSED"
5216
5216
  },
5217
5217
  "packages/crouter-sdk": {
5218
5218
  "name": "@north-light/crouter-sdk",
5219
- "version": "0.3.311",
5219
+ "version": "0.3.312",
5220
5220
  "license": "UNLICENSED",
5221
5221
  "dependencies": {
5222
5222
  "@north-light/crouter-api": "^0.3.295"