@north-light/crouter 0.3.231 → 0.3.232

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 (173) hide show
  1. package/dist/api/client.d.ts +3 -1
  2. package/dist/api/client.js +4 -0
  3. package/dist/api/dto/canvas.d.ts +10 -0
  4. package/dist/api/dto/common.d.ts +1 -1
  5. package/dist/api/dto/health.d.ts +2 -1
  6. package/dist/api/dto/lifecycle.d.ts +3 -4
  7. package/dist/api/dto/messages.d.ts +5 -4
  8. package/dist/api/dto/nodes.d.ts +2 -0
  9. package/dist/api/dto/profiles.d.ts +5 -0
  10. package/dist/api/routes.d.ts +1 -0
  11. package/dist/api/routes.js +1 -0
  12. package/dist/builtin-memory/00-runtime-base/00-authoring.md +8 -0
  13. package/dist/builtin-memory/00-runtime-base/01-escalation.md +1 -1
  14. package/dist/builtin-memory/01-spine/00-has-manager.md +1 -1
  15. package/dist/builtin-memory/02-turn-lifecycle/00-ending-a-turn.md +5 -0
  16. package/dist/builtin-memory/02-turn-lifecycle/02-resident.md +5 -3
  17. package/dist/builtin-memory/04-orchestration-kernel.md +2 -2
  18. package/dist/builtin-memory/insights/capture.md +3 -2
  19. package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +6 -1
  20. package/dist/clients/attach/render/diagram.js +13 -5
  21. package/dist/clients/attach/render/page-block.d.ts +0 -1
  22. package/dist/clients/attach/render/page-block.js +4 -57
  23. package/dist/clients/attach/viewer.js +570 -563
  24. package/dist/clients/inbox/__tests__/integration/inbox-controller.test.js +9 -0
  25. package/dist/clients/inbox/__tests__/integration/mount-panel.test.js +62 -1
  26. package/dist/clients/inbox/controller.d.ts +10 -0
  27. package/dist/clients/inbox/controller.js +56 -12
  28. package/dist/clients/inbox/tui/input.js +38 -10
  29. package/dist/clients/inbox/tui/page-body.d.ts +10 -0
  30. package/dist/clients/inbox/tui/page-body.js +66 -0
  31. package/dist/clients/inbox/tui/panel.js +6 -4
  32. package/dist/clients/inbox/tui/render.js +89 -17
  33. package/dist/clients/inbox/tui/types.d.ts +4 -4
  34. package/dist/commands/__tests__/human.test.js +18 -3
  35. package/dist/commands/__tests__/node-message.test.js +3 -3
  36. package/dist/commands/api-client.js +1 -7
  37. package/dist/commands/canvas-config.js +6 -14
  38. package/dist/commands/canvas-use.js +4 -6
  39. package/dist/commands/cron.js +16 -22
  40. package/dist/commands/human/prompts.d.ts +1 -1
  41. package/dist/commands/human/prompts.js +118 -112
  42. package/dist/commands/human/request.js +13 -14
  43. package/dist/commands/human/review.js +3 -4
  44. package/dist/commands/human/shared.d.ts +6 -0
  45. package/dist/commands/human/shared.js +43 -5
  46. package/dist/commands/human.js +1 -1
  47. package/dist/commands/memory/delete.js +4 -6
  48. package/dist/commands/memory/edit.js +0 -4
  49. package/dist/commands/memory/move.js +3 -5
  50. package/dist/commands/memory/shared.d.ts +1 -1
  51. package/dist/commands/memory/shared.js +9 -5
  52. package/dist/commands/memory/write.js +60 -29
  53. package/dist/commands/memory.js +1 -1
  54. package/dist/commands/node/bash.js +6 -9
  55. package/dist/commands/node/create.js +89 -22
  56. package/dist/commands/node/inspect.js +3 -3
  57. package/dist/commands/node/lifecycle.js +30 -27
  58. package/dist/commands/node/message.js +24 -45
  59. package/dist/commands/node/subscription.js +6 -15
  60. package/dist/commands/node/wait.js +2 -3
  61. package/dist/commands/node-lifecycle-revive.js +1 -12
  62. package/dist/commands/pkg/browse/actions.js +2 -3
  63. package/dist/commands/pkg/market-manage.js +2 -5
  64. package/dist/commands/pkg/plugin-manage.js +7 -8
  65. package/dist/commands/profile/default.js +5 -5
  66. package/dist/commands/profile/delete.js +1 -1
  67. package/dist/commands/profile/env.js +9 -15
  68. package/dist/commands/profile/kind.js +3 -7
  69. package/dist/commands/profile/meta.js +3 -5
  70. package/dist/commands/profile/new.js +0 -6
  71. package/dist/commands/profile/pause.js +4 -8
  72. package/dist/commands/profile/project.js +5 -9
  73. package/dist/commands/profile/rename.js +3 -7
  74. package/dist/commands/profile/show.js +3 -3
  75. package/dist/commands/profile.js +4 -3
  76. package/dist/commands/surface-tmux-spread.js +1 -3
  77. package/dist/commands/sys/config.js +3 -4
  78. package/dist/commands/sys/support/prepare.js +8 -4
  79. package/dist/commands/sys/support/submit.js +2 -3
  80. package/dist/commands/sys/sync-deps.js +1 -9
  81. package/dist/commands/sys/sync-project-guidance.js +1 -7
  82. package/dist/commands/sys/sync-skills.js +1 -11
  83. package/dist/core/__tests__/cron-node-sink-parked-root.test.d.ts +1 -0
  84. package/dist/core/__tests__/cron-node-sink-parked-root.test.js +147 -0
  85. package/dist/core/__tests__/history-inbox.test.js +11 -1
  86. package/dist/core/__tests__/human-deliver.test.js +2 -1
  87. package/dist/core/__tests__/integration/command-plugins.test.js +0 -1
  88. package/dist/core/__tests__/integration/deferred-no-wake.test.js +0 -1
  89. package/dist/core/__tests__/lifecycle.test.js +30 -2
  90. package/dist/core/__tests__/revive-parked-fresh.test.d.ts +1 -0
  91. package/dist/core/__tests__/revive-parked-fresh.test.js +109 -0
  92. package/dist/core/__tests__/seam/dormancy-release.test.js +32 -5
  93. package/dist/core/canvas/attention.d.ts +2 -0
  94. package/dist/core/canvas/attention.js +25 -18
  95. package/dist/core/canvas/extensions.d.ts +1 -1
  96. package/dist/core/canvas/extensions.js +7 -1
  97. package/dist/core/canvas/history.js +20 -2
  98. package/dist/core/canvas/types.d.ts +1 -1
  99. package/dist/core/command.js +33 -8
  100. package/dist/core/help.d.ts +28 -2
  101. package/dist/core/help.js +46 -10
  102. package/dist/core/human/__tests__/page-html-markdown.test.d.ts +1 -0
  103. package/dist/core/human/__tests__/page-html-markdown.test.js +48 -0
  104. package/dist/core/human/component-docs.js +4 -4
  105. package/dist/core/human/page-html-markdown.d.ts +8 -0
  106. package/dist/core/human/page-html-markdown.js +260 -0
  107. package/dist/core/memory/lint.d.ts +15 -0
  108. package/dist/core/memory/lint.js +150 -90
  109. package/dist/core/profiles/__tests__/fuzzy-match.test.d.ts +1 -0
  110. package/dist/core/profiles/__tests__/fuzzy-match.test.js +51 -0
  111. package/dist/core/profiles/fuzzy-match.d.ts +19 -0
  112. package/dist/core/profiles/fuzzy-match.js +92 -0
  113. package/dist/core/profiles/manifest.d.ts +14 -7
  114. package/dist/core/profiles/manifest.js +62 -12
  115. package/dist/core/profiles/select.d.ts +3 -1
  116. package/dist/core/profiles/select.js +5 -3
  117. package/dist/core/profiles/state-block.js +4 -3
  118. package/dist/core/runtime/boot-root.d.ts +3 -2
  119. package/dist/core/runtime/canvas-extensions.d.ts +7 -1
  120. package/dist/core/runtime/canvas-extensions.js +8 -1
  121. package/dist/core/runtime/lifecycle.d.ts +11 -2
  122. package/dist/core/runtime/lifecycle.js +15 -2
  123. package/dist/core/runtime/model-selection.d.ts +4 -0
  124. package/dist/core/runtime/model-selection.js +5 -0
  125. package/dist/core/runtime/nodes.js +5 -0
  126. package/dist/core/runtime/reopen.d.ts +6 -0
  127. package/dist/core/runtime/reopen.js +12 -1
  128. package/dist/core/runtime/revive.d.ts +6 -0
  129. package/dist/core/runtime/revive.js +22 -2
  130. package/dist/core/runtime/spawn.d.ts +5 -2
  131. package/dist/core/runtime/spawn.js +18 -32
  132. package/dist/core/runtime/structured-output.d.ts +6 -0
  133. package/dist/core/runtime/structured-output.js +6 -0
  134. package/dist/core/substrate/__tests__/surface-match-command.test.d.ts +1 -0
  135. package/dist/core/substrate/__tests__/surface-match-command.test.js +89 -0
  136. package/dist/core/substrate/on-read.js +2 -1
  137. package/dist/core/substrate/surface-match.js +164 -12
  138. package/dist/core/termrender/version.d.ts +1 -1
  139. package/dist/core/termrender/version.js +1 -1
  140. package/dist/core/user-settings.js +1 -1
  141. package/dist/daemon/api/__tests__/broker-settle-park.test.d.ts +1 -0
  142. package/dist/daemon/api/__tests__/broker-settle-park.test.js +102 -0
  143. package/dist/daemon/api/__tests__/canvas-snapshot-fields.test.d.ts +1 -0
  144. package/dist/daemon/api/__tests__/canvas-snapshot-fields.test.js +188 -0
  145. package/dist/daemon/api/__tests__/node-create-description.test.d.ts +1 -0
  146. package/dist/daemon/api/__tests__/node-create-description.test.js +83 -0
  147. package/dist/daemon/api/__tests__/profile-metadata-route.test.d.ts +1 -0
  148. package/dist/daemon/api/__tests__/profile-metadata-route.test.js +92 -0
  149. package/dist/daemon/api/__tests__/reopen-delivery.test.d.ts +1 -0
  150. package/dist/daemon/api/__tests__/reopen-delivery.test.js +173 -0
  151. package/dist/daemon/api/handlers/broker-ops.js +21 -0
  152. package/dist/daemon/api/handlers/canvas.js +10 -0
  153. package/dist/daemon/api/handlers/messages.js +25 -16
  154. package/dist/daemon/api/handlers/nodes.js +5 -0
  155. package/dist/daemon/api/handlers/profiles.js +22 -1
  156. package/dist/daemon/cron-run.js +19 -1
  157. package/dist/daemon/manage.d.ts +16 -1
  158. package/dist/daemon/manage.js +20 -1
  159. package/dist/daemon/park-pending.d.ts +13 -0
  160. package/dist/daemon/park-pending.js +42 -0
  161. package/dist/daemon/reconcilers/broker-supervision.d.ts +13 -0
  162. package/dist/daemon/reconcilers/broker-supervision.js +139 -21
  163. package/dist/daemon/reconcilers/live-obligation.d.ts +10 -4
  164. package/dist/daemon/reconcilers/live-obligation.js +7 -3
  165. package/dist/daemon/reconcilers/storage-maintenance.d.ts +0 -4
  166. package/dist/daemon/reconcilers/storage-maintenance.js +1 -33
  167. package/dist/pi-extensions/canvas-prompt-scrub.d.ts +13 -0
  168. package/dist/pi-extensions/canvas-prompt-scrub.js +53 -0
  169. package/dist/shared/generated-context.d.ts +7 -0
  170. package/dist/shared/generated-context.js +11 -0
  171. package/package.json +4 -4
  172. package/runtime.lock.json +2 -2
  173. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/strip-skills-docs.ts +0 -47
@@ -0,0 +1,188 @@
1
+ import { after, before, test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { createServer } from 'node:net';
4
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { closeDb } from '../../../core/canvas/db.js';
7
+ import { createNode, getNode } from '../../../core/canvas/canvas.js';
8
+ import { contextDir, jobDir, viewSocketPath } from '../../../core/canvas/paths.js';
9
+ import { recordFault } from '../../../core/runtime/fault.js';
10
+ import { headlessBrokerHost } from '../../../core/runtime/host.js';
11
+ import { handleCreate } from '../handlers/nodes.js';
12
+ import { canvasRoutes } from '../handlers/canvas.js';
13
+ let home = '';
14
+ const previousHome = process.env['CRTR_HOME'];
15
+ const brokers = [];
16
+ const launchedBrokers = new Map();
17
+ let originalLaunch;
18
+ before(() => {
19
+ home = mkdtempSync('/tmp/crtr-snapshot-fields-');
20
+ process.env['CRTR_HOME'] = home;
21
+ originalLaunch = headlessBrokerHost.launch;
22
+ headlessBrokerHost.launch = (nodeId) => {
23
+ const broker = createServer();
24
+ brokers.push(broker);
25
+ launchedBrokers.set(nodeId, broker);
26
+ broker.listen(viewSocketPath(nodeId));
27
+ return { pid: process.pid, exited: new Promise(() => { }) };
28
+ };
29
+ });
30
+ after(async () => {
31
+ headlessBrokerHost.launch = originalLaunch;
32
+ await Promise.all(brokers.map((broker) => new Promise((resolve) => {
33
+ if (broker.listening)
34
+ broker.close(() => resolve());
35
+ else
36
+ resolve();
37
+ })));
38
+ closeDb();
39
+ rmSync(home, { recursive: true, force: true });
40
+ if (previousHome === undefined)
41
+ delete process.env['CRTR_HOME'];
42
+ else
43
+ process.env['CRTR_HOME'] = previousHome;
44
+ });
45
+ function meta(nodeId, overrides = {}) {
46
+ return {
47
+ node_id: nodeId,
48
+ name: nodeId,
49
+ created: new Date().toISOString(),
50
+ cwd: home,
51
+ kind: 'general',
52
+ mode: 'base',
53
+ lifecycle: 'resident',
54
+ status: 'idle',
55
+ host_kind: null,
56
+ profile_id: null,
57
+ parent: null,
58
+ ...overrides,
59
+ };
60
+ }
61
+ function request(method, path, body) {
62
+ return {
63
+ method,
64
+ path,
65
+ params: {},
66
+ query: new URLSearchParams(),
67
+ body,
68
+ };
69
+ }
70
+ function snapshotRoute() {
71
+ const route = canvasRoutes.find((entry) => entry.method === 'GET' && entry.pattern === '/v1/canvas/snapshot');
72
+ assert.ok(route, 'snapshot route exists');
73
+ return route;
74
+ }
75
+ /** A live broker is the pid + view socket pair used by isBrokerLive(). */
76
+ async function listenFor(nodeId) {
77
+ const broker = createServer();
78
+ brokers.push(broker);
79
+ await new Promise((resolve, reject) => {
80
+ broker.once('error', reject);
81
+ broker.listen(viewSocketPath(nodeId), resolve);
82
+ });
83
+ return broker;
84
+ }
85
+ function activeJob(nodeId) {
86
+ const id = `${Date.now()}-snapshot-test`;
87
+ const dir = join(contextDir(nodeId), 'jobs', id);
88
+ mkdirSync(dir, { recursive: true });
89
+ writeFileSync(join(dir, 'cmd.sh'), 'printf live');
90
+ writeFileSync(join(dir, 'job.bg'), '');
91
+ }
92
+ test('one canvas snapshot carries title, brief, jobs, busy, latch, fault, and controller facts', async () => {
93
+ // Use the public create request for the brief: A2's birth contract is the
94
+ // source of the description that A3 must expose without a follow-up read.
95
+ const created = await handleCreate(request('POST', '/v1/nodes', {
96
+ kind: 'general',
97
+ root: true,
98
+ profile: null,
99
+ cwd: home,
100
+ prompt: '',
101
+ name: 'Live outcome',
102
+ description: 'Review the live outcome',
103
+ }));
104
+ const liveJobId = created.body.node_id;
105
+ activeJob(liveJobId);
106
+ const busyId = 'snapshot-busy';
107
+ createNode(meta(busyId, {
108
+ name: 'Busy outcome',
109
+ parent: liveJobId,
110
+ host_kind: 'broker',
111
+ pi_pid: process.pid,
112
+ }));
113
+ writeFileSync(join(jobDir(busyId), 'busy'), '');
114
+ const finalId = 'snapshot-final';
115
+ const finalizedAt = new Date().toISOString();
116
+ createNode(meta(finalId, {
117
+ name: 'Final outcome',
118
+ parent: liveJobId,
119
+ lifecycle: 'terminal',
120
+ status: 'done',
121
+ final_report: 'final.md',
122
+ finalized_at: finalizedAt,
123
+ }));
124
+ const faultId = 'snapshot-fault';
125
+ createNode(meta(faultId, {
126
+ name: 'Fault outcome',
127
+ parent: liveJobId,
128
+ status: 'dead',
129
+ }));
130
+ recordFault(faultId, {
131
+ link: 'viewer↔broker',
132
+ op: 'snapshot-test',
133
+ kind: 'connection',
134
+ retry: { disposition: 'manual', by: 'client' },
135
+ message: 'forced snapshot fault',
136
+ });
137
+ const controllerId = 'snapshot-controller';
138
+ createNode(meta(controllerId, { name: 'Controller', parent: liveJobId }));
139
+ const waitingId = 'snapshot-waiting';
140
+ createNode(meta(waitingId, {
141
+ name: 'Waiting outcome',
142
+ parent: liveJobId,
143
+ waiting_for: controllerId,
144
+ }));
145
+ const liveBroker = launchedBrokers.get(liveJobId);
146
+ assert.ok(liveBroker, 'created node has a live broker');
147
+ const busyBroker = await listenFor(busyId);
148
+ try {
149
+ const result = await snapshotRoute().handler(request('GET', '/v1/canvas/snapshot'));
150
+ const body = result.body;
151
+ const byId = new Map(body.nodes.map((node) => [node['node_id'], node]));
152
+ const live = byId.get(liveJobId);
153
+ assert.ok(live);
154
+ assert.equal(live['title'], 'Live outcome');
155
+ assert.equal(live['description'], 'Review the live outcome');
156
+ assert.equal(live['name'], 'Live outcome Review the live outcome');
157
+ assert.equal(live['jobs_active'], 1);
158
+ assert.equal(live['streaming'], false);
159
+ assert.equal(live['waiting_for'], null);
160
+ const busy = byId.get(busyId);
161
+ assert.ok(busy);
162
+ assert.equal(busy['title'], 'Busy outcome');
163
+ assert.equal(busy['jobs_active'], 0);
164
+ assert.equal(busy['streaming'], true);
165
+ const final = byId.get(finalId);
166
+ assert.ok(final);
167
+ assert.equal(final['title'], 'Final outcome');
168
+ assert.equal(final['final_report'], 'final.md');
169
+ assert.equal(final['finalized_at'], finalizedAt);
170
+ const fault = byId.get(faultId);
171
+ assert.ok(fault);
172
+ const hanging = fault['hanging'];
173
+ assert.equal(hanging.link, 'viewer↔broker');
174
+ assert.equal(hanging.op, 'snapshot-test');
175
+ assert.equal(hanging.kind, 'connection');
176
+ assert.deepEqual(hanging.retry, { disposition: 'manual', by: 'client' });
177
+ assert.equal(hanging.message, 'forced snapshot fault');
178
+ const waiting = byId.get(waitingId);
179
+ assert.ok(waiting);
180
+ assert.equal(waiting['title'], 'Waiting outcome');
181
+ assert.equal(waiting['waiting_for'], controllerId);
182
+ assert.equal(waiting['jobs_active'], 0);
183
+ }
184
+ finally {
185
+ await Promise.all([liveBroker, busyBroker].map((broker) => new Promise((resolve) => broker.close(() => resolve()))));
186
+ }
187
+ assert.ok(getNode(liveJobId));
188
+ });
@@ -0,0 +1,83 @@
1
+ import { after, before, test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { createServer } from 'node:net';
4
+ import { mkdtempSync, rmSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { closeDb } from '../../../core/canvas/db.js';
7
+ import { getNode } from '../../../core/canvas/canvas.js';
8
+ import { nodeDir } from '../../../core/canvas/paths.js';
9
+ import { headlessBrokerHost } from '../../../core/runtime/host.js';
10
+ import { handleCreate, nodeRoutes } from '../handlers/nodes.js';
11
+ import { brokerOperationRoutes } from '../handlers/broker-ops.js';
12
+ let home = '';
13
+ let originalHome;
14
+ let originalLaunch;
15
+ const brokers = [];
16
+ before(() => {
17
+ originalHome = process.env['CRTR_HOME'];
18
+ // macOS Unix-domain sockets have a short path limit; keep the canvas home short.
19
+ home = mkdtempSync('/tmp/crtr-node-description-');
20
+ process.env['CRTR_HOME'] = home;
21
+ originalLaunch = headlessBrokerHost.launch;
22
+ headlessBrokerHost.launch = (nodeId) => {
23
+ const broker = createServer();
24
+ brokers.push(broker);
25
+ broker.listen(join(nodeDir(nodeId), 'view.sock'));
26
+ return { pid: process.pid, exited: new Promise(() => { }) };
27
+ };
28
+ });
29
+ after(async () => {
30
+ headlessBrokerHost.launch = originalLaunch;
31
+ await Promise.all(brokers.map((broker) => new Promise((resolve) => {
32
+ if (broker.listening)
33
+ broker.close(() => resolve());
34
+ else
35
+ resolve();
36
+ })));
37
+ closeDb();
38
+ rmSync(home, { recursive: true, force: true });
39
+ if (originalHome === undefined)
40
+ delete process.env['CRTR_HOME'];
41
+ else
42
+ process.env['CRTR_HOME'] = originalHome;
43
+ });
44
+ function context(method, path, id, body) {
45
+ return {
46
+ method,
47
+ path,
48
+ params: id === undefined ? {} : { id },
49
+ query: new URLSearchParams(),
50
+ body,
51
+ };
52
+ }
53
+ test('node create stores caller description and automatic naming leaves it and name unchanged', async () => {
54
+ const created = await handleCreate(context('POST', '/v1/nodes', undefined, {
55
+ kind: 'general',
56
+ root: true,
57
+ profile: null,
58
+ cwd: home,
59
+ prompt: '',
60
+ name: 'Review worker',
61
+ description: 'Review the worker conversation',
62
+ }));
63
+ assert.equal(created.status, 201);
64
+ const nodeId = created.body.node_id;
65
+ const detailRoute = nodeRoutes.find((route) => route.method === 'GET' && route.pattern === '/v1/nodes/:id');
66
+ assert.ok(detailRoute);
67
+ const detail = await detailRoute.handler(context('GET', `/v1/nodes/${nodeId}`, nodeId));
68
+ assert.equal(detail.body.name, 'Review worker');
69
+ assert.equal(detail.body.description, 'Review the worker conversation');
70
+ const generatedNameRoute = brokerOperationRoutes.find((route) => route.method === 'POST' && route.pattern === '/v1/nodes/:id/broker/generated-name');
71
+ assert.ok(generatedNameRoute);
72
+ const generated = await generatedNameRoute.handler(context('POST', `/v1/nodes/${nodeId}/broker/generated-name`, nodeId, {
73
+ kind: 'initial',
74
+ description: 'Generated replacement',
75
+ icon: '',
76
+ }));
77
+ assert.deepEqual(generated.body, { applied: false });
78
+ assert.equal(getNode(nodeId)?.name, 'Review worker');
79
+ assert.equal(getNode(nodeId)?.description, 'Review the worker conversation');
80
+ const fetched = await detailRoute.handler(context('GET', `/v1/nodes/${nodeId}`, nodeId));
81
+ assert.equal(fetched.body.name, 'Review worker');
82
+ assert.equal(fetched.body.description, 'Review the worker conversation');
83
+ });
@@ -0,0 +1,92 @@
1
+ import { after, afterEach, beforeEach, test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, rmSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { routes } from '../../../api/index.js';
7
+ import { CrtrError } from '../../../core/errors.js';
8
+ import { closeDb } from '../../../core/canvas/db.js';
9
+ import { createProfile, loadProfileManifest } from '../../../core/profiles/manifest.js';
10
+ import { resetScopeCache } from '../../../core/scope.js';
11
+ import { profileRoutes } from '../handlers/profiles.js';
12
+ const previousCrtrHome = process.env['CRTR_HOME'];
13
+ const previousHome = process.env.HOME;
14
+ let home = '';
15
+ beforeEach(() => {
16
+ closeDb();
17
+ home = mkdtempSync(join(tmpdir(), 'crtr-profile-metadata-home-'));
18
+ process.env.HOME = home;
19
+ process.env['CRTR_HOME'] = join(home, 'canvas');
20
+ resetScopeCache();
21
+ });
22
+ afterEach(() => {
23
+ closeDb();
24
+ rmSync(home, { recursive: true, force: true });
25
+ resetScopeCache();
26
+ });
27
+ after(() => {
28
+ if (previousHome === undefined)
29
+ delete process.env.HOME;
30
+ else
31
+ process.env.HOME = previousHome;
32
+ if (previousCrtrHome === undefined)
33
+ delete process.env['CRTR_HOME'];
34
+ else
35
+ process.env['CRTR_HOME'] = previousCrtrHome;
36
+ resetScopeCache();
37
+ });
38
+ function route(method, pattern) {
39
+ const found = profileRoutes.find((entry) => entry.method === method && entry.pattern === pattern);
40
+ assert.ok(found, `missing ${method} ${pattern} route`);
41
+ return found;
42
+ }
43
+ function context(params, body) {
44
+ return {
45
+ method: 'PATCH',
46
+ path: routes.profileMetadata(params['name']),
47
+ params,
48
+ query: new URLSearchParams(),
49
+ body,
50
+ };
51
+ }
52
+ async function invoke(entry, ctx) {
53
+ return entry.handler(ctx);
54
+ }
55
+ test('PATCH metadata updates an existing profile and GET returns the opaque value', async () => {
56
+ const profile = createProfile('worker profile');
57
+ assert.deepEqual(loadProfileManifest(profile.profileId).manifest.metadata, undefined);
58
+ const patch = route('PATCH', '/v1/profiles/:name/metadata');
59
+ const patched = await invoke(patch, context({ name: profile.manifest.name }, {
60
+ set: { northlight_default_node_id: 'nlw-applet-42' },
61
+ }));
62
+ assert.equal(patched.status, 200);
63
+ assert.deepEqual(patched.body.metadata, {
64
+ northlight_default_node_id: 'nlw-applet-42',
65
+ });
66
+ const get = route('GET', '/v1/profiles/:name');
67
+ const read = await invoke(get, context({ name: profile.manifest.name }, undefined));
68
+ assert.deepEqual(read.body.metadata, {
69
+ northlight_default_node_id: 'nlw-applet-42',
70
+ });
71
+ });
72
+ test('PATCH metadata rejects an unknown profile and invalid metadata keys', async () => {
73
+ const patch = route('PATCH', '/v1/profiles/:name/metadata');
74
+ await assert.rejects(() => invoke(patch, context({ name: 'missing-profile' }, { set: { role: 'worker' } })), (error) => error instanceof CrtrError && error.code === 'not_found');
75
+ const profile = createProfile('metadata validation');
76
+ await assert.rejects(() => invoke(patch, context({ name: profile.manifest.name }, { set: { 'not a key': 'value' } })), (error) => error instanceof CrtrError && error.code === 'usage');
77
+ assert.deepEqual(loadProfileManifest(profile.profileId).manifest.metadata, undefined);
78
+ });
79
+ test('PUT remains create-only after a metadata PATCH', async () => {
80
+ const profile = createProfile('create-only profile');
81
+ const patch = route('PATCH', '/v1/profiles/:name/metadata');
82
+ await invoke(patch, context({ name: profile.manifest.name }, { set: { role: 'worker' } }));
83
+ const ensure = route('PUT', '/v1/profiles/:name');
84
+ const result = await invoke(ensure, {
85
+ ...context({ name: profile.manifest.name }, { metadata: { role: 'changed' } }),
86
+ method: 'PUT',
87
+ path: routes.profile(profile.manifest.name),
88
+ });
89
+ assert.deepEqual(result.body.metadata, {
90
+ role: 'worker',
91
+ });
92
+ });
@@ -0,0 +1,173 @@
1
+ import { after, before, beforeEach, test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, rmSync } from 'node:fs';
4
+ import { createServer } from 'node:net';
5
+ import { createNode, getNode, openDb } from '../../../core/canvas/index.js';
6
+ import { closeDb } from '../../../core/canvas/db.js';
7
+ import { viewSocketPath } from '../../../core/canvas/paths.js';
8
+ import { readInboxSince } from '../../../core/feed/inbox.js';
9
+ import { headlessBrokerHost } from '../../../core/runtime/host.js';
10
+ import { messageRoutes } from '../handlers/messages.js';
11
+ let home;
12
+ const priorHome = process.env['CRTR_HOME'];
13
+ before(() => {
14
+ // macOS limits Unix-socket paths to 104 bytes; the system temp directory's
15
+ // per-user prefix is already most of that budget.
16
+ home = mkdtempSync('/tmp/crtr-reopen-');
17
+ process.env['CRTR_HOME'] = home;
18
+ });
19
+ beforeEach(() => {
20
+ closeDb();
21
+ rmSync(home, { recursive: true, force: true });
22
+ });
23
+ after(() => {
24
+ closeDb();
25
+ rmSync(home, { recursive: true, force: true });
26
+ if (priorHome === undefined)
27
+ delete process.env['CRTR_HOME'];
28
+ else
29
+ process.env['CRTR_HOME'] = priorHome;
30
+ });
31
+ function node(id, lifecycle, status, piPid) {
32
+ return {
33
+ node_id: id,
34
+ name: id,
35
+ created: new Date().toISOString(),
36
+ cwd: '/tmp',
37
+ kind: 'general',
38
+ mode: 'base',
39
+ lifecycle,
40
+ status,
41
+ ...(piPid !== undefined ? { pi_pid: piPid } : {}),
42
+ };
43
+ }
44
+ function finalize(id) {
45
+ openDb()
46
+ .prepare("UPDATE nodes SET final_report = ?, finalized_at = ? WHERE node_id = ?")
47
+ .run('final.md', new Date().toISOString(), id);
48
+ }
49
+ async function sendMessage(id, body) {
50
+ const ctx = {
51
+ method: 'POST',
52
+ path: `/v1/nodes/${id}/messages`,
53
+ params: { id },
54
+ query: new URLSearchParams(),
55
+ body,
56
+ };
57
+ return messageRoutes[0].handler(ctx);
58
+ }
59
+ async function startLiveBroker(id, failDelivery = false) {
60
+ let observed = null;
61
+ const server = createServer((socket) => {
62
+ let pending = '';
63
+ socket.on('data', (chunk) => {
64
+ pending += chunk.toString('utf8');
65
+ let newline = pending.indexOf('\n');
66
+ while (newline !== -1) {
67
+ const line = pending.slice(0, newline);
68
+ pending = pending.slice(newline + 1);
69
+ const frame = JSON.parse(line);
70
+ if (frame.type === 'deliver') {
71
+ observed = getNode(id);
72
+ socket.write(`${JSON.stringify({
73
+ type: 'ack',
74
+ for: 'deliver',
75
+ id: frame.id,
76
+ ok: !failDelivery,
77
+ detail: failDelivery ? 'forced delivery failure' : 'prompt',
78
+ })}\n`);
79
+ }
80
+ newline = pending.indexOf('\n');
81
+ }
82
+ });
83
+ });
84
+ await new Promise((resolve, reject) => {
85
+ server.once('error', reject);
86
+ server.listen(viewSocketPath(id), resolve);
87
+ });
88
+ return {
89
+ close: () => closeServer(server),
90
+ deliveryState: () => observed,
91
+ };
92
+ }
93
+ function closeServer(server) {
94
+ return new Promise((resolve, reject) => server.close((err) => (err === undefined ? resolve() : reject(err))));
95
+ }
96
+ function assertReopened(id) {
97
+ const meta = getNode(id);
98
+ assert.equal(meta?.lifecycle, 'resident');
99
+ assert.equal(meta?.final_report, null);
100
+ }
101
+ test('reopen makes live terminal, finalized terminal, and parked targets resident and unlatched before delivery', async () => {
102
+ const liveId = 'live-terminal';
103
+ createNode(node(liveId, 'terminal', 'active', process.pid));
104
+ const liveBroker = await startLiveBroker(liveId);
105
+ try {
106
+ const result = await sendMessage(liveId, { body: 'continue live work', reopen: true, delivery: 'interactive' });
107
+ assert.equal(result.body.delivered_via, 'engine');
108
+ assertReopened(liveId);
109
+ assert.equal(liveBroker.deliveryState()?.lifecycle, 'resident', 'engine delivery sees resident lifecycle');
110
+ assert.equal(liveBroker.deliveryState()?.final_report, null, 'engine delivery sees no final latch');
111
+ assert.equal(readInboxSince(liveId).length, 0, 'live delivery does not append an inbox entry');
112
+ }
113
+ finally {
114
+ await liveBroker.close();
115
+ }
116
+ const originalLaunch = headlessBrokerHost.launch;
117
+ headlessBrokerHost.launch = (() => ({ pid: 0x7ffffffe, exited: Promise.resolve({ code: 0, signal: null }) }));
118
+ try {
119
+ const finalizedId = 'finalized-terminal';
120
+ createNode(node(finalizedId, 'terminal', 'done'));
121
+ finalize(finalizedId);
122
+ await sendMessage(finalizedId, { body: 'new mandate', reopen: true });
123
+ assertReopened(finalizedId);
124
+ assert.equal(readInboxSince(finalizedId).length, 1, 'finalized target receives its durable message after reopening');
125
+ const parkedId = 'parked-resident';
126
+ createNode(node(parkedId, 'resident', 'done'));
127
+ await sendMessage(parkedId, { body: 'resume the parked session', reopen: true });
128
+ assertReopened(parkedId);
129
+ assert.equal(readInboxSince(parkedId).length, 1, 'parked target receives its durable message after reopening');
130
+ }
131
+ finally {
132
+ headlessBrokerHost.launch = originalLaunch;
133
+ }
134
+ });
135
+ test('a reopen commit failure delivers nothing', async () => {
136
+ const id = 'commit-failure';
137
+ createNode(node(id, 'terminal', 'done'));
138
+ finalize(id);
139
+ const finalReport = getNode(id)?.final_report;
140
+ openDb().exec(`
141
+ CREATE TRIGGER fail_reopen_latch_clear
142
+ BEFORE UPDATE OF final_report ON nodes
143
+ WHEN NEW.final_report IS NULL
144
+ BEGIN
145
+ SELECT RAISE(FAIL, 'forced reopen failure');
146
+ END;
147
+ `);
148
+ try {
149
+ await assert.rejects(() => sendMessage(id, { body: 'must not deliver', reopen: true }), /forced reopen failure/);
150
+ }
151
+ finally {
152
+ openDb().exec('DROP TRIGGER IF EXISTS fail_reopen_latch_clear');
153
+ }
154
+ assert.equal(getNode(id)?.lifecycle, 'resident', 'lifecycle commit precedes the failed latch CAS');
155
+ assert.equal(getNode(id)?.final_report, finalReport, 'a failed CAS preserves the canonical-final pointer');
156
+ assert.equal(readInboxSince(id).length, 0, 'no inbox entry precedes a failed reopen commit');
157
+ });
158
+ test('a delivery failure after reopen leaves the target resident, unlatched, and without a message', async () => {
159
+ const id = 'delivery-failure';
160
+ createNode(node(id, 'terminal', 'done', process.pid));
161
+ finalize(id);
162
+ const broker = await startLiveBroker(id, true);
163
+ try {
164
+ await assert.rejects(() => sendMessage(id, { body: 'broker rejects this', reopen: true, delivery: 'interactive' }), /forced delivery failure/);
165
+ assertReopened(id);
166
+ assert.equal(broker.deliveryState()?.lifecycle, 'resident', 'failed engine delivery happened after reopen committed');
167
+ assert.equal(broker.deliveryState()?.final_report, null, 'failed engine delivery saw the cleared latch');
168
+ assert.equal(readInboxSince(id).length, 0, 'failed engine delivery creates no durable fallback message');
169
+ }
170
+ finally {
171
+ await broker.close();
172
+ }
173
+ });
@@ -16,6 +16,8 @@ import { evaluateStop } from '../../../core/runtime/stop-guard.js';
16
16
  import { clearFault } from '../../../core/runtime/fault.js';
17
17
  import { updateModelRecipe } from '../../../core/runtime/model-swap.js';
18
18
  import { completeCompanionRequestedSubmit } from '../../review/finish.js';
19
+ import { consumePendingPark } from '../../park-pending.js';
20
+ import { parkEvent } from '../../reconcilers/live-obligation.js';
19
21
  function objectBody(body, operation) {
20
22
  if (typeof body !== 'object' || body === null || Array.isArray(body)) {
21
23
  throw usage(`${operation} requires a JSON body`);
@@ -229,6 +231,11 @@ function hasUnseenNonDeferredInbox(nodeId) {
229
231
  * boundary. This handler intentionally never invokes the broker that called it:
230
232
  * the returned directive is the only Pi-side consequence. */
231
233
  function settleBroker(nodeId, request) {
234
+ // A parking turn in flight ends HERE, whatever it ended as. Consuming
235
+ // unconditionally is the abort rule expressed once: every early return below
236
+ // — clean quit, pushed final, human ask, interrupted turn, refresh — discards
237
+ // the park and leaves the node live under a restarted clock.
238
+ const parkPending = consumePendingPark(nodeId);
232
239
  const node = getNode(nodeId);
233
240
  if (node === null) {
234
241
  // A command may have recorded its Pi-local completion marker just as an
@@ -274,6 +281,20 @@ function settleBroker(nodeId, request) {
274
281
  clearFault(nodeId);
275
282
  return { action: 'shutdown' };
276
283
  }
284
+ // The parking turn just ended. Re-evaluate the verdict the clock reached
285
+ // before this turn ran: a `crtr human send`, new wake-worthy mail, a fresh
286
+ // subscription to a child it spawned, or a pending structured-output request
287
+ // all make the node live work again, and the ordinary stop policy applies
288
+ // instead. `pushedFinal` was checked above, so a node that finished during
289
+ // its parking turn ends latched rather than parked.
290
+ if (parkPending && parkEvent(nodeId, node.lifecycle) === 'park') {
291
+ clearFault(nodeId);
292
+ transition(nodeId, 'park');
293
+ if (focusOf(nodeId) !== null)
294
+ tearDownNode(nodeId);
295
+ setPresence(nodeId, { pane: null, tmux_session: null, window: null });
296
+ return { action: 'shutdown' };
297
+ }
277
298
  const decision = evaluateStop(nodeId, signals, request.backgroundJobsRunning);
278
299
  clearFault(nodeId);
279
300
  if (decision.action === 'reprompt')
@@ -13,6 +13,9 @@ import { existsSync, readdirSync } from 'node:fs';
13
13
  import { join } from 'node:path';
14
14
  import { listNodes, nowIso, ticketsAcrossCanvas } from '../../../index.js';
15
15
  import { getNode, pruneNodes, pruneToLimit, rebuildIndex, rosterEdges, rosterNodes, subscriptionsOf } from '../../../core/canvas/canvas.js';
16
+ import { activeBackgroundBashJobs } from '../../../core/bash-jobs.js';
17
+ import { contextDir } from '../../../core/canvas/paths.js';
18
+ import { isBrokerLive } from '../../../core/runtime/model-swap.js';
16
19
  import { dashboardRowsAll, enrichRows } from '../../../core/canvas/render.js';
17
20
  import { ticketCountsForNodes } from '../../../core/canvas/attention.js';
18
21
  import { buildCorpus, resolveRef, } from '../../../core/canvas/history.js';
@@ -26,6 +29,7 @@ function handleAttention() {
26
29
  name: t.name,
27
30
  cwd: t.cwd,
28
31
  count: t.count,
32
+ subjects: t.subjects,
29
33
  })),
30
34
  };
31
35
  return { status: 200, body };
@@ -273,6 +277,8 @@ function handleSnapshot() {
273
277
  return {
274
278
  node_id: row.node_id,
275
279
  name: row.name,
280
+ title: meta?.name ?? row.name,
281
+ description: meta?.description ?? null,
276
282
  kind: row.kind,
277
283
  mode: row.mode,
278
284
  lifecycle: row.lifecycle ?? meta?.lifecycle,
@@ -288,6 +294,10 @@ function handleSnapshot() {
288
294
  ...(row.mtimeMs !== undefined ? { last_activity: new Date(row.mtimeMs).toISOString() } : {}),
289
295
  ctx_tokens: row.ctx_tokens,
290
296
  streaming: row.streaming ?? false,
297
+ jobs_active: meta !== null && isBrokerLive(meta)
298
+ ? activeBackgroundBashJobs(contextDir(row.node_id)).length
299
+ : 0,
300
+ waiting_for: meta?.waiting_for ?? null,
291
301
  hanging: (row.hanging ?? null),
292
302
  viewed: row.viewed ?? false,
293
303
  final_report: meta?.final_report ?? null,