@north-light/crouter 0.3.231 → 0.3.233

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 (187) 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-memory/internal/memory-loading.md +4 -3
  20. package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +6 -1
  21. package/dist/clients/attach/render/diagram.js +13 -5
  22. package/dist/clients/attach/render/page-block.d.ts +0 -1
  23. package/dist/clients/attach/render/page-block.js +4 -57
  24. package/dist/clients/attach/viewer.js +570 -563
  25. package/dist/clients/inbox/__tests__/integration/inbox-controller.test.js +9 -0
  26. package/dist/clients/inbox/__tests__/integration/mount-panel.test.js +62 -1
  27. package/dist/clients/inbox/controller.d.ts +10 -0
  28. package/dist/clients/inbox/controller.js +56 -12
  29. package/dist/clients/inbox/tui/input.js +38 -10
  30. package/dist/clients/inbox/tui/page-body.d.ts +10 -0
  31. package/dist/clients/inbox/tui/page-body.js +66 -0
  32. package/dist/clients/inbox/tui/panel.js +6 -4
  33. package/dist/clients/inbox/tui/render.js +89 -17
  34. package/dist/clients/inbox/tui/types.d.ts +4 -4
  35. package/dist/commands/__tests__/human.test.js +18 -3
  36. package/dist/commands/__tests__/node-message.test.js +3 -3
  37. package/dist/commands/api-client.js +1 -7
  38. package/dist/commands/canvas-config.js +6 -14
  39. package/dist/commands/canvas-use.js +4 -6
  40. package/dist/commands/cron.js +16 -22
  41. package/dist/commands/human/prompts.d.ts +1 -1
  42. package/dist/commands/human/prompts.js +118 -112
  43. package/dist/commands/human/request.js +13 -14
  44. package/dist/commands/human/review.js +3 -4
  45. package/dist/commands/human/shared.d.ts +6 -0
  46. package/dist/commands/human/shared.js +43 -5
  47. package/dist/commands/human.js +1 -1
  48. package/dist/commands/memory/delete.js +4 -6
  49. package/dist/commands/memory/edit.js +0 -4
  50. package/dist/commands/memory/move.js +3 -5
  51. package/dist/commands/memory/shared.d.ts +1 -1
  52. package/dist/commands/memory/shared.js +11 -7
  53. package/dist/commands/memory/write.js +60 -29
  54. package/dist/commands/memory.js +1 -1
  55. package/dist/commands/node/bash.js +6 -9
  56. package/dist/commands/node/create.js +89 -22
  57. package/dist/commands/node/inspect.js +3 -3
  58. package/dist/commands/node/lifecycle.js +30 -27
  59. package/dist/commands/node/message.js +24 -45
  60. package/dist/commands/node/subscription.js +6 -15
  61. package/dist/commands/node/wait.js +2 -3
  62. package/dist/commands/node-lifecycle-revive.js +1 -12
  63. package/dist/commands/pkg/browse/actions.js +2 -3
  64. package/dist/commands/pkg/market-manage.js +2 -5
  65. package/dist/commands/pkg/plugin-manage.js +7 -8
  66. package/dist/commands/profile/default.js +5 -5
  67. package/dist/commands/profile/delete.js +1 -1
  68. package/dist/commands/profile/env.js +9 -15
  69. package/dist/commands/profile/kind.js +3 -7
  70. package/dist/commands/profile/meta.js +3 -5
  71. package/dist/commands/profile/new.js +0 -6
  72. package/dist/commands/profile/pause.js +4 -8
  73. package/dist/commands/profile/project.js +5 -9
  74. package/dist/commands/profile/rename.js +3 -7
  75. package/dist/commands/profile/show.js +3 -3
  76. package/dist/commands/profile.js +4 -3
  77. package/dist/commands/surface-tmux-spread.js +1 -3
  78. package/dist/commands/sys/config.js +3 -4
  79. package/dist/commands/sys/support/prepare.js +8 -4
  80. package/dist/commands/sys/support/submit.js +2 -3
  81. package/dist/commands/sys/sync-deps.js +1 -9
  82. package/dist/commands/sys/sync-project-guidance.js +1 -7
  83. package/dist/commands/sys/sync-skills.js +1 -11
  84. package/dist/core/__tests__/cron-node-sink-parked-root.test.d.ts +1 -0
  85. package/dist/core/__tests__/cron-node-sink-parked-root.test.js +147 -0
  86. package/dist/core/__tests__/history-inbox.test.js +11 -1
  87. package/dist/core/__tests__/human-deliver.test.js +2 -1
  88. package/dist/core/__tests__/integration/command-plugins.test.js +0 -1
  89. package/dist/core/__tests__/integration/deferred-no-wake.test.js +0 -1
  90. package/dist/core/__tests__/lifecycle.test.js +30 -2
  91. package/dist/core/__tests__/revive-parked-fresh.test.d.ts +1 -0
  92. package/dist/core/__tests__/revive-parked-fresh.test.js +109 -0
  93. package/dist/core/__tests__/seam/dormancy-release.test.js +32 -5
  94. package/dist/core/canvas/attention.d.ts +2 -0
  95. package/dist/core/canvas/attention.js +25 -18
  96. package/dist/core/canvas/extensions.d.ts +1 -1
  97. package/dist/core/canvas/extensions.js +7 -1
  98. package/dist/core/canvas/history.js +20 -2
  99. package/dist/core/canvas/types.d.ts +1 -1
  100. package/dist/core/command.js +33 -8
  101. package/dist/core/help.d.ts +28 -2
  102. package/dist/core/help.js +46 -10
  103. package/dist/core/human/__tests__/page-html-markdown.test.d.ts +1 -0
  104. package/dist/core/human/__tests__/page-html-markdown.test.js +48 -0
  105. package/dist/core/human/component-docs.js +4 -4
  106. package/dist/core/human/page-html-markdown.d.ts +8 -0
  107. package/dist/core/human/page-html-markdown.js +260 -0
  108. package/dist/core/memory/lint.d.ts +15 -0
  109. package/dist/core/memory/lint.js +150 -90
  110. package/dist/core/profiles/__tests__/fuzzy-match.test.d.ts +1 -0
  111. package/dist/core/profiles/__tests__/fuzzy-match.test.js +51 -0
  112. package/dist/core/profiles/fuzzy-match.d.ts +19 -0
  113. package/dist/core/profiles/fuzzy-match.js +92 -0
  114. package/dist/core/profiles/manifest.d.ts +14 -7
  115. package/dist/core/profiles/manifest.js +62 -12
  116. package/dist/core/profiles/select.d.ts +3 -1
  117. package/dist/core/profiles/select.js +5 -3
  118. package/dist/core/profiles/state-block.js +4 -3
  119. package/dist/core/runtime/boot-root.d.ts +3 -2
  120. package/dist/core/runtime/canvas-extensions.d.ts +7 -1
  121. package/dist/core/runtime/canvas-extensions.js +8 -1
  122. package/dist/core/runtime/lifecycle.d.ts +11 -2
  123. package/dist/core/runtime/lifecycle.js +15 -2
  124. package/dist/core/runtime/model-selection.d.ts +4 -0
  125. package/dist/core/runtime/model-selection.js +5 -0
  126. package/dist/core/runtime/nodes.js +5 -0
  127. package/dist/core/runtime/reopen.d.ts +6 -0
  128. package/dist/core/runtime/reopen.js +12 -1
  129. package/dist/core/runtime/revive.d.ts +6 -0
  130. package/dist/core/runtime/revive.js +22 -2
  131. package/dist/core/runtime/spawn.d.ts +5 -2
  132. package/dist/core/runtime/spawn.js +18 -32
  133. package/dist/core/runtime/structured-output.d.ts +6 -0
  134. package/dist/core/runtime/structured-output.js +6 -0
  135. package/dist/core/substrate/__tests__/surface-match-command.test.d.ts +1 -0
  136. package/dist/core/substrate/__tests__/surface-match-command.test.js +89 -0
  137. package/dist/core/substrate/__tests__/surface-match-pre-command.test.d.ts +1 -0
  138. package/dist/core/substrate/__tests__/surface-match-pre-command.test.js +92 -0
  139. package/dist/core/substrate/frontmatter-validation.js +1 -1
  140. package/dist/core/substrate/injected-store.d.ts +6 -0
  141. package/dist/core/substrate/injected-store.js +24 -0
  142. package/dist/core/substrate/on-read.d.ts +17 -1
  143. package/dist/core/substrate/on-read.js +38 -3
  144. package/dist/core/substrate/schema.d.ts +3 -3
  145. package/dist/core/substrate/schema.js +4 -4
  146. package/dist/core/substrate/surface-match.d.ts +19 -0
  147. package/dist/core/substrate/surface-match.js +224 -12
  148. package/dist/core/termrender/version.d.ts +1 -1
  149. package/dist/core/termrender/version.js +1 -1
  150. package/dist/core/user-settings.js +1 -1
  151. package/dist/daemon/api/__tests__/broker-settle-park.test.d.ts +1 -0
  152. package/dist/daemon/api/__tests__/broker-settle-park.test.js +102 -0
  153. package/dist/daemon/api/__tests__/canvas-snapshot-fields.test.d.ts +1 -0
  154. package/dist/daemon/api/__tests__/canvas-snapshot-fields.test.js +188 -0
  155. package/dist/daemon/api/__tests__/node-create-description.test.d.ts +1 -0
  156. package/dist/daemon/api/__tests__/node-create-description.test.js +83 -0
  157. package/dist/daemon/api/__tests__/profile-metadata-route.test.d.ts +1 -0
  158. package/dist/daemon/api/__tests__/profile-metadata-route.test.js +92 -0
  159. package/dist/daemon/api/__tests__/reopen-delivery.test.d.ts +1 -0
  160. package/dist/daemon/api/__tests__/reopen-delivery.test.js +173 -0
  161. package/dist/daemon/api/handlers/broker-ops.js +21 -0
  162. package/dist/daemon/api/handlers/canvas.js +10 -0
  163. package/dist/daemon/api/handlers/messages.js +25 -16
  164. package/dist/daemon/api/handlers/nodes.js +5 -0
  165. package/dist/daemon/api/handlers/profiles.js +22 -1
  166. package/dist/daemon/cron-run.js +19 -1
  167. package/dist/daemon/manage.d.ts +16 -1
  168. package/dist/daemon/manage.js +20 -1
  169. package/dist/daemon/park-pending.d.ts +13 -0
  170. package/dist/daemon/park-pending.js +42 -0
  171. package/dist/daemon/reconcilers/broker-supervision.d.ts +13 -0
  172. package/dist/daemon/reconcilers/broker-supervision.js +139 -21
  173. package/dist/daemon/reconcilers/live-obligation.d.ts +10 -4
  174. package/dist/daemon/reconcilers/live-obligation.js +7 -3
  175. package/dist/daemon/reconcilers/storage-maintenance.d.ts +0 -4
  176. package/dist/daemon/reconcilers/storage-maintenance.js +1 -33
  177. package/dist/pi-extensions/__tests__/pre-command-gate.test.d.ts +1 -0
  178. package/dist/pi-extensions/__tests__/pre-command-gate.test.js +220 -0
  179. package/dist/pi-extensions/canvas-doc-substrate.d.ts +14 -0
  180. package/dist/pi-extensions/canvas-doc-substrate.js +75 -2
  181. package/dist/pi-extensions/canvas-prompt-scrub.d.ts +13 -0
  182. package/dist/pi-extensions/canvas-prompt-scrub.js +53 -0
  183. package/dist/shared/generated-context.d.ts +7 -0
  184. package/dist/shared/generated-context.js +11 -0
  185. package/package.json +4 -4
  186. package/runtime.lock.json +2 -2
  187. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/strip-skills-docs.ts +0 -47
@@ -102,21 +102,173 @@ export function matchesMemoryReadEntry(entry, routingAnchor, subject, name) {
102
102
  return false;
103
103
  return entry.match.some((g) => memoryReadGlobMatches(g, routingAnchor, name));
104
104
  }
105
- /** A command glob is a STRING glob over the whole command line, not a path
106
- * glob: `*` crosses every character (including `/` — command lines carry
107
- * paths), `?` matches one. Translated to a regex rather than fed to
108
- * `matchesGlob`, whose `*` stops at path separators. */
109
- function commandGlobMatches(glob, command) {
110
- const rx = glob
105
+ function splitCommandSegments(command) {
106
+ const segments = [];
107
+ let current = '';
108
+ let quote = null;
109
+ let escaped = false;
110
+ const finish = () => {
111
+ segments.push(current);
112
+ current = '';
113
+ };
114
+ for (let i = 0; i < command.length; i += 1) {
115
+ const ch = command[i];
116
+ if (escaped) {
117
+ current += ch;
118
+ escaped = false;
119
+ continue;
120
+ }
121
+ if (ch === '\\' && quote !== "'") {
122
+ current += ch;
123
+ escaped = true;
124
+ continue;
125
+ }
126
+ if (quote !== null) {
127
+ current += ch;
128
+ if (ch === quote)
129
+ quote = null;
130
+ continue;
131
+ }
132
+ if (ch === "'" || ch === '"') {
133
+ current += ch;
134
+ quote = ch;
135
+ continue;
136
+ }
137
+ if (ch === '\n' || ch === ';') {
138
+ finish();
139
+ continue;
140
+ }
141
+ if ((ch === '&' || ch === '|') && command[i + 1] === ch) {
142
+ finish();
143
+ i += 1;
144
+ continue;
145
+ }
146
+ if (ch === '|') {
147
+ finish();
148
+ continue;
149
+ }
150
+ current += ch;
151
+ }
152
+ finish();
153
+ return segments;
154
+ }
155
+ function tokenizeCommandSegment(segment) {
156
+ const tokens = [];
157
+ let text = '';
158
+ let opaque = false;
159
+ let started = false;
160
+ let quote = null;
161
+ let escaped = false;
162
+ const finish = () => {
163
+ if (started)
164
+ tokens.push({ text, opaque });
165
+ text = '';
166
+ opaque = false;
167
+ started = false;
168
+ };
169
+ for (let i = 0; i < segment.length; i += 1) {
170
+ const ch = segment[i];
171
+ if (escaped) {
172
+ text += ch;
173
+ started = true;
174
+ escaped = false;
175
+ continue;
176
+ }
177
+ if (ch === '\\' && quote !== "'") {
178
+ escaped = true;
179
+ started = true;
180
+ continue;
181
+ }
182
+ if (quote !== null) {
183
+ started = true;
184
+ if (ch === quote) {
185
+ quote = null;
186
+ }
187
+ else {
188
+ text += ch;
189
+ }
190
+ continue;
191
+ }
192
+ if (ch === "'" || ch === '"') {
193
+ quote = ch;
194
+ opaque = true;
195
+ started = true;
196
+ continue;
197
+ }
198
+ if (/\s/.test(ch)) {
199
+ finish();
200
+ continue;
201
+ }
202
+ text += ch;
203
+ started = true;
204
+ }
205
+ if (escaped)
206
+ text += '\\';
207
+ finish();
208
+ return tokens;
209
+ }
210
+ function commandSegments(command) {
211
+ return splitCommandSegments(command)
212
+ .map(tokenizeCommandSegment)
213
+ .map((tokens) => {
214
+ let first = 0;
215
+ while (first < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[first].text))
216
+ first += 1;
217
+ return tokens.slice(first);
218
+ })
219
+ .filter((tokens) => tokens.length > 0);
220
+ }
221
+ function commandGlobPattern(glob) {
222
+ const words = glob.trim().split(/\s+/);
223
+ if (words.length === 1 && words[0] === '')
224
+ return null;
225
+ const pattern = [];
226
+ for (const word of words) {
227
+ const parts = word.split('*');
228
+ for (let i = 0; i < parts.length; i += 1) {
229
+ if (parts[i] !== '')
230
+ pattern.push({ kind: 'literal', text: parts[i] });
231
+ if (i < parts.length - 1)
232
+ pattern.push({ kind: 'any' });
233
+ }
234
+ }
235
+ return pattern;
236
+ }
237
+ function tokenMatches(pattern, token) {
238
+ const rx = pattern
111
239
  .split('')
112
- .map((ch) => (ch === '*' ? '[\\s\\S]*' : ch === '?' ? '[\\s\\S]' : ch.replace(/[.+^${}()|[\]\\]/g, '\\$&')))
240
+ .map((ch) => (ch === '?' ? '[\\s\\S]' : ch.replace(/[.+^${}()|[\]\\]/g, '\\$&')))
113
241
  .join('');
114
- try {
115
- return new RegExp(`^${rx}$`).test(command);
116
- }
117
- catch {
242
+ return new RegExp(`^${rx}$`).test(token);
243
+ }
244
+ function segmentMatches(pattern, tokens) {
245
+ const memo = new Map();
246
+ const match = (patternIndex, tokenIndex) => {
247
+ const key = `${patternIndex}:${tokenIndex}`;
248
+ const cached = memo.get(key);
249
+ if (cached !== undefined)
250
+ return cached;
251
+ let result;
252
+ if (patternIndex === pattern.length) {
253
+ result = tokenIndex === tokens.length;
254
+ }
255
+ else if (pattern[patternIndex].kind === 'any') {
256
+ result = match(patternIndex + 1, tokenIndex) || (tokenIndex < tokens.length && match(patternIndex, tokenIndex + 1));
257
+ }
258
+ else {
259
+ const token = tokens[tokenIndex];
260
+ result = token !== undefined && !token.opaque && tokenMatches(pattern[patternIndex].text, token.text) && match(patternIndex + 1, tokenIndex + 1);
261
+ }
262
+ memo.set(key, result);
263
+ return result;
264
+ };
265
+ return match(0, 0);
266
+ }
267
+ function commandGlobMatches(glob, command) {
268
+ const pattern = commandGlobPattern(glob);
269
+ if (pattern === null)
118
270
  return false;
119
- }
271
+ return commandSegments(command).some((tokens) => segmentMatches(pattern, tokens));
120
272
  }
121
273
  /** Does a `command` entry fit the executed command string? */
122
274
  export function matchesCommandEntry(entry, subject, command) {
@@ -124,6 +276,53 @@ export function matchesCommandEntry(entry, subject, command) {
124
276
  return false;
125
277
  return entry.match.some((g) => commandGlobMatches(g, command));
126
278
  }
279
+ const HEREDOC_OPEN = /<<(-?)\s*(["']?)([A-Za-z_][A-Za-z0-9_]*)\2/g;
280
+ /** Remove heredoc BODIES (the stdin data between `<<DELIM` and its closing
281
+ * `DELIM` line) from a command before it is matched. The opening line is kept
282
+ * — `crtr push final <<'EOF'` is still a real `push final` invocation — but the
283
+ * body lines, which are data fed on stdin and never executed as commands, are
284
+ * dropped. Without this a doc-writing command whose body merely MENTIONS a
285
+ * matched invocation would have its whole call held. Post-execution `command`
286
+ * delivery does not need this (a spurious late injection costs a paragraph);
287
+ * a `pre-command` block costs the agent a turn, so it does. */
288
+ export function stripHeredocs(command) {
289
+ const lines = command.split('\n');
290
+ const out = [];
291
+ let i = 0;
292
+ while (i < lines.length) {
293
+ const line = lines[i];
294
+ out.push(line);
295
+ i += 1;
296
+ // Every heredoc opened on this line, left to right; their bodies stack and
297
+ // are consumed in that order.
298
+ const delims = [];
299
+ HEREDOC_OPEN.lastIndex = 0;
300
+ let m;
301
+ while ((m = HEREDOC_OPEN.exec(line)) !== null)
302
+ delims.push({ name: m[3], dash: m[1] === '-' });
303
+ for (const d of delims) {
304
+ while (i < lines.length) {
305
+ // `<<-` strips leading TABS from the body and the closing delimiter.
306
+ const probe = d.dash ? lines[i].replace(/^\t+/, '') : lines[i];
307
+ i += 1;
308
+ if (probe === d.name)
309
+ break; // closing delimiter line — drop it, stop
310
+ }
311
+ }
312
+ }
313
+ return out.join('\n');
314
+ }
315
+ /** Does a `pre-command` entry fit a command that is ABOUT to run? The same
316
+ * glob machinery `command` uses — one matcher, one semantics — applied to the
317
+ * command with its heredoc bodies stripped. Subshell parentheses, backticks,
318
+ * and path-prefixed binaries stay unhandled: this is a guardrail against the
319
+ * faithful-but-uninformed action, not an enforcement boundary. */
320
+ export function matchesPreCommandEntry(entry, subject, command) {
321
+ if (entry.on !== 'pre-command' || entry.match === undefined || !surfaceEntryGatePasses(entry, subject))
322
+ return false;
323
+ const stripped = stripHeredocs(command);
324
+ return entry.match.some((g) => commandGlobMatches(g, stripped));
325
+ }
127
326
  // ---------------------------------------------------------------------------
128
327
  // Per-doc rung folds — a doc's delivery rung for one event occurrence is the
129
328
  // highest `at` over its matching entries of that event, `none` when none
@@ -179,3 +378,16 @@ export function commandDeliveryRung(doc, subject, command) {
179
378
  }
180
379
  return r;
181
380
  }
381
+ /** The doc's delivery rung for a command about to run — the highest `at`
382
+ * over the doc's matching `pre-command` entries, folded the same way as every
383
+ * other event. */
384
+ export function preCommandDeliveryRung(doc, subject, command) {
385
+ let r = 'none';
386
+ for (const e of doc.surfaces) {
387
+ if (!matchesPreCommandEntry(e, subject, command))
388
+ continue;
389
+ if (rungRank(e.at) > rungRank(r))
390
+ r = e.at;
391
+ }
392
+ return r;
393
+ }
@@ -1 +1 @@
1
- export declare const TERMRENDER_VERSION = "4.12.10";
1
+ export declare const TERMRENDER_VERSION = "4.12.11";
@@ -1 +1 @@
1
- export const TERMRENDER_VERSION = '4.12.10';
1
+ export const TERMRENDER_VERSION = '4.12.11';
@@ -197,7 +197,7 @@ export const CONFIG_SETTINGS_COVERAGE = {
197
197
  setting: {
198
198
  key: 'page_surface',
199
199
  label: 'Page human surface',
200
- description: 'Prompt agents to use JSX pages by default for structured or interactive content shown to you.',
200
+ description: 'This host reads pages in a browser rather than a terminal. Agents are told the page module runs as written, HTML pages are offered, and document review and live display leave the command tree.',
201
201
  valueConstraint: 'true|false',
202
202
  applies: 'Applies when human command help is assembled.',
203
203
  group: 'display',
@@ -0,0 +1,102 @@
1
+ // Run with: node --conditions=crtr-src --import tsx/esm --test src/daemon/api/__tests__/broker-settle-park.test.ts
2
+ //
3
+ // The park clock hands terminalization to broker settlement: it arms a pending
4
+ // park, delivers one summary prompt to the live engine, and this handler
5
+ // decides what the ending turn meant. The defect surface is the re-evaluation —
6
+ // the verdict the clock reached 15 minutes ago must not be enacted when the
7
+ // summary turn itself created live work (a human ask, new wake-worthy mail, a
8
+ // pushed final), and the pending park must be consumed exactly once regardless.
9
+ import { after, before, beforeEach, test } from 'node:test';
10
+ import assert from 'node:assert/strict';
11
+ import { mkdtempSync, rmSync } from 'node:fs';
12
+ import { createNode, getNode } from '../../../core/canvas/canvas.js';
13
+ import { closeDb } from '../../../core/canvas/db.js';
14
+ import { appendInbox } from '../../../core/feed/inbox.js';
15
+ import { beginPendingPark, isPendingPark } from '../../park-pending.js';
16
+ import { brokerOperationRoutes } from '../handlers/broker-ops.js';
17
+ let home = '';
18
+ let originalHome;
19
+ const settleRoute = brokerOperationRoutes.find((route) => route.method === 'POST' && route.pattern === '/v1/nodes/:id/broker/settle');
20
+ before(() => {
21
+ originalHome = process.env['CRTR_HOME'];
22
+ home = mkdtempSync('/tmp/crtr-settle-park-');
23
+ process.env['CRTR_HOME'] = home;
24
+ });
25
+ beforeEach(() => {
26
+ closeDb();
27
+ rmSync(home, { recursive: true, force: true });
28
+ });
29
+ after(() => {
30
+ closeDb();
31
+ rmSync(home, { recursive: true, force: true });
32
+ if (originalHome === undefined)
33
+ delete process.env['CRTR_HOME'];
34
+ else
35
+ process.env['CRTR_HOME'] = originalHome;
36
+ });
37
+ /** A live resident mid-parking-turn: the clock armed the park and the summary
38
+ * turn is what is now settling. */
39
+ function residentMidParkingTurn(id) {
40
+ const meta = {
41
+ node_id: id,
42
+ name: id,
43
+ created: new Date().toISOString(),
44
+ cwd: home,
45
+ kind: 'general',
46
+ mode: 'base',
47
+ lifecycle: 'resident',
48
+ status: 'active',
49
+ };
50
+ createNode(meta);
51
+ beginPendingPark(id, Date.now());
52
+ }
53
+ async function settle(id, over = {}) {
54
+ const ctx = {
55
+ method: 'POST',
56
+ path: `/v1/nodes/${id}/broker/settle`,
57
+ params: { id },
58
+ query: new URLSearchParams(),
59
+ body: {
60
+ stopReason: 'stop',
61
+ backgroundJobsRunning: false,
62
+ pushedFinal: false,
63
+ requestedHumanReply: false,
64
+ ...over,
65
+ },
66
+ };
67
+ return (await settleRoute.handler(ctx)).body;
68
+ }
69
+ test('a summary turn that ends with nothing new parks the node: done + intent=parked, no latch', async () => {
70
+ residentMidParkingTurn('summarized');
71
+ const directive = await settle('summarized');
72
+ assert.deepEqual(directive, { action: 'shutdown' });
73
+ const parked = getNode('summarized');
74
+ assert.equal(parked.status, 'done');
75
+ assert.equal(parked.intent, 'parked');
76
+ assert.equal(parked.final_report ?? null, null, 'a park never sets the finalization latch');
77
+ assert.equal(isPendingPark('summarized'), false, 'the pending park is consumed');
78
+ });
79
+ test('a human ask raised during the parking turn keeps the node live instead of parking it', async () => {
80
+ residentMidParkingTurn('asked');
81
+ const directive = await settle('asked', { requestedHumanReply: true });
82
+ assert.deepEqual(directive, { action: 'stay_dormant' });
83
+ assert.equal(getNode('asked').status, 'active', 'the node stays live awaiting its answer');
84
+ assert.equal(getNode('asked').intent ?? null, null);
85
+ assert.equal(isPendingPark('asked'), false, 'the park is discarded, not held for the next settle');
86
+ });
87
+ test('wake-worthy mail arriving during the parking turn flips the verdict back to release', async () => {
88
+ residentMidParkingTurn('mailed');
89
+ appendInbox('mailed', { from: null, tier: 'normal', kind: 'message', label: 'x', data: { body: 'work' } });
90
+ const directive = await settle('mailed');
91
+ assert.equal(directive.action, 'stay_dormant');
92
+ assert.equal(getNode('mailed').status, 'active', 're-evaluation saw the new obligation and did not park');
93
+ assert.equal(getNode('mailed').intent ?? null, null);
94
+ assert.equal(isPendingPark('mailed'), false);
95
+ });
96
+ test('a final pushed during the parking turn finishes the node rather than parking it', async () => {
97
+ residentMidParkingTurn('finished');
98
+ const directive = await settle('finished', { pushedFinal: true });
99
+ assert.deepEqual(directive, { action: 'shutdown' });
100
+ assert.equal(getNode('finished').intent ?? null, null, 'a pushed final is not a park');
101
+ assert.equal(isPendingPark('finished'), false);
102
+ });
@@ -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
+ });