@north-light/crouter 0.3.281 → 0.3.283

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 (188) hide show
  1. package/dist/api/client.d.ts +19 -3
  2. package/dist/api/client.js +34 -0
  3. package/dist/api/dto/broker-ops.d.ts +11 -0
  4. package/dist/api/dto/broker.d.ts +3 -8
  5. package/dist/api/dto/common.d.ts +1 -1
  6. package/dist/api/dto/messages.d.ts +2 -2
  7. package/dist/api/dto/node-outcomes.d.ts +80 -0
  8. package/dist/api/dto/node-outcomes.js +2 -0
  9. package/dist/api/dto/nodes.d.ts +9 -0
  10. package/dist/api/dto/profiles.d.ts +5 -0
  11. package/dist/api/dto/reports.d.ts +45 -0
  12. package/dist/api/index.d.ts +1 -0
  13. package/dist/api/index.js +1 -0
  14. package/dist/api/plugin-manifest-schema.d.ts +208 -0
  15. package/dist/api/plugin-manifest-schema.js +23 -0
  16. package/dist/api/routes.d.ts +7 -0
  17. package/dist/api/routes.js +7 -0
  18. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +24 -3
  19. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +31 -4
  20. package/dist/cli.js +8 -2
  21. package/dist/clients/attach/overlays/graph.d.ts +5 -0
  22. package/dist/clients/attach/overlays/graph.js +65 -47
  23. package/dist/clients/attach/overlays/pickers.js +8 -9
  24. package/dist/clients/attach/render/card-presentation.js +1 -1
  25. package/dist/clients/attach/viewer.js +1286 -1260
  26. package/dist/commands/api-client.js +22 -23
  27. package/dist/commands/node/create.js +38 -3
  28. package/dist/commands/node/inspect.js +3 -7
  29. package/dist/commands/node/message.js +3 -3
  30. package/dist/commands/node/outcome.d.ts +2 -0
  31. package/dist/commands/node/outcome.js +134 -0
  32. package/dist/commands/node.js +2 -1
  33. package/dist/commands/pkg/browse/catalog.js +1 -1
  34. package/dist/commands/pkg/plugin-manage.d.ts +46 -1
  35. package/dist/commands/pkg/plugin-manage.js +170 -20
  36. package/dist/commands/profile/pause.js +17 -14
  37. package/dist/commands/push.js +102 -1
  38. package/dist/commands/sys/__tests__/setup-core.test.js +4 -1
  39. package/dist/commands/sys/panels/broker-limits-panel.js +7 -1
  40. package/dist/commands/sys/panels/models-panel.js +2 -2
  41. package/dist/commands/sys/settings-shell.d.ts +1 -1
  42. package/dist/commands/sys/settings-shell.js +6 -0
  43. package/dist/commands/sys/settings.js +3 -3
  44. package/dist/core/__tests__/fixtures/fake-engine.d.ts +2 -0
  45. package/dist/core/__tests__/fixtures/fake-engine.js +42 -4
  46. package/dist/core/__tests__/helpers/harness.d.ts +1 -0
  47. package/dist/core/__tests__/helpers/harness.js +9 -0
  48. package/dist/core/__tests__/integration/plugin-revalidate.test.d.ts +1 -0
  49. package/dist/core/__tests__/integration/plugin-revalidate.test.js +355 -0
  50. package/dist/core/__tests__/integration/profile-pause.test.d.ts +1 -0
  51. package/dist/core/__tests__/integration/profile-pause.test.js +115 -0
  52. package/dist/core/__tests__/node-outcome.test.d.ts +1 -0
  53. package/dist/core/__tests__/node-outcome.test.js +184 -0
  54. package/dist/core/__tests__/revive-parked-fresh.test.js +13 -20
  55. package/dist/core/__tests__/seam/broker-provider-retry.test.js +6 -2
  56. package/dist/core/__tests__/seam/dormancy-release.test.js +3 -3
  57. package/dist/core/__tests__/seam/node-outcome-delivery.test.d.ts +1 -0
  58. package/dist/core/__tests__/seam/node-outcome-delivery.test.js +153 -0
  59. package/dist/core/bash-jobs.d.ts +6 -0
  60. package/dist/core/bash-jobs.js +22 -0
  61. package/dist/core/canvas/canvas.d.ts +41 -3
  62. package/dist/core/canvas/canvas.js +153 -39
  63. package/dist/core/canvas/crons.js +2 -1
  64. package/dist/core/canvas/index.d.ts +1 -0
  65. package/dist/core/canvas/index.js +1 -0
  66. package/dist/core/canvas/migrations.js +47 -0
  67. package/dist/core/canvas/node-outcome-deliveries.d.ts +80 -0
  68. package/dist/core/canvas/node-outcome-deliveries.js +163 -0
  69. package/dist/core/canvas/pid.d.ts +7 -0
  70. package/dist/core/canvas/pid.js +17 -0
  71. package/dist/core/canvas/render-source.js +2 -2
  72. package/dist/core/canvas/types.d.ts +42 -1
  73. package/dist/core/canvas/types.js +2 -1
  74. package/dist/core/command-manifests/registry.d.ts +3 -1
  75. package/dist/core/command-manifests/schema.d.ts +2 -75
  76. package/dist/core/command-manifests/schema.js +5 -0
  77. package/dist/core/command-plugins/compose.js +1 -1
  78. package/dist/core/command-plugins/revalidate.d.ts +18 -0
  79. package/dist/core/command-plugins/revalidate.js +152 -0
  80. package/dist/core/command-plugins/transport/http-fetch.d.ts +29 -4
  81. package/dist/core/command-plugins/transport/http-fetch.js +18 -8
  82. package/dist/core/command-plugins/transport/http-invoke.js +7 -0
  83. package/dist/core/command.d.ts +8 -0
  84. package/dist/core/command.js +19 -3
  85. package/dist/core/config.d.ts +4 -1
  86. package/dist/core/config.js +25 -1
  87. package/dist/core/exclusive-lock.d.ts +12 -0
  88. package/dist/core/exclusive-lock.js +29 -0
  89. package/dist/core/feed/feed.d.ts +8 -0
  90. package/dist/core/feed/feed.js +25 -4
  91. package/dist/core/feed/inbox.d.ts +5 -0
  92. package/dist/core/feed/inbox.js +13 -0
  93. package/dist/core/installed-plugins.js +31 -0
  94. package/dist/core/keybindings/catalog.d.ts +1 -1
  95. package/dist/core/keybindings/catalog.js +3 -0
  96. package/dist/core/manifest-recovery.d.ts +15 -0
  97. package/dist/core/manifest-recovery.js +72 -0
  98. package/dist/core/manifest-stale.d.ts +18 -0
  99. package/dist/core/manifest-stale.js +39 -0
  100. package/dist/core/plugin-swap-lock.d.ts +9 -0
  101. package/dist/core/plugin-swap-lock.js +31 -0
  102. package/dist/core/profiles/manifest.d.ts +3 -1
  103. package/dist/core/profiles/manifest.js +6 -1
  104. package/dist/core/runtime/broker/daemon-ops.d.ts +3 -1
  105. package/dist/core/runtime/broker/daemon-ops.js +6 -0
  106. package/dist/core/runtime/broker/engine-drive.d.ts +2 -3
  107. package/dist/core/runtime/broker/engine-drive.js +74 -27
  108. package/dist/core/runtime/broker/frame-dispatch.js +1 -0
  109. package/dist/core/runtime/broker/read-ops.js +14 -0
  110. package/dist/core/runtime/broker/rebind.js +0 -7
  111. package/dist/core/runtime/broker-protocol.d.ts +1 -6
  112. package/dist/core/runtime/fleet.d.ts +7 -0
  113. package/dist/core/runtime/headless-pi.d.ts +13 -0
  114. package/dist/core/runtime/headless-pi.js +10 -2
  115. package/dist/core/runtime/lifecycle.d.ts +6 -1
  116. package/dist/core/runtime/lifecycle.js +50 -1
  117. package/dist/core/runtime/nodes.d.ts +5 -1
  118. package/dist/core/runtime/nodes.js +2 -1
  119. package/dist/core/runtime/outcome-document.d.ts +70 -0
  120. package/dist/core/runtime/outcome-document.js +97 -0
  121. package/dist/core/runtime/revive.js +16 -21
  122. package/dist/core/runtime/spawn.d.ts +5 -1
  123. package/dist/core/runtime/spawn.js +18 -7
  124. package/dist/core/runtime/stamp/channel.d.ts +3 -5
  125. package/dist/core/runtime/stamp/channel.js +3 -10
  126. package/dist/core/runtime/stop-guard.js +2 -2
  127. package/dist/core/runtime/structured-output.d.ts +24 -3
  128. package/dist/core/runtime/structured-output.js +42 -3
  129. package/dist/core/runtime/tmux-bindings.js +4 -0
  130. package/dist/core/runtime/warm-pool.d.ts +5 -2
  131. package/dist/core/runtime/warm-pool.js +5 -5
  132. package/dist/core/subscription-state.js +7 -5
  133. package/dist/core/substrate/render.js +10 -10
  134. package/dist/core/user-settings.d.ts +18 -1
  135. package/dist/core/user-settings.js +37 -0
  136. package/dist/daemon/__tests__/node-deadline-lane.test.d.ts +1 -0
  137. package/dist/daemon/__tests__/node-deadline-lane.test.js +76 -0
  138. package/dist/daemon/__tests__/node-outcome-birth-invariants.test.d.ts +1 -0
  139. package/dist/daemon/__tests__/node-outcome-birth-invariants.test.js +122 -0
  140. package/dist/daemon/__tests__/node-outcome-delivery.test.d.ts +1 -0
  141. package/dist/daemon/__tests__/node-outcome-delivery.test.js +86 -0
  142. package/dist/daemon/api/__tests__/broker-settle-park.test.js +86 -18
  143. package/dist/daemon/api/__tests__/profile-launch-gates.test.js +10 -1
  144. package/dist/daemon/api/__tests__/seam/node-outcome-api.test.d.ts +1 -0
  145. package/dist/daemon/api/__tests__/seam/node-outcome-api.test.js +159 -0
  146. package/dist/daemon/api/__tests__/submit-result.test.d.ts +1 -0
  147. package/dist/daemon/api/__tests__/submit-result.test.js +214 -0
  148. package/dist/daemon/api/handlers/broker-ops.js +53 -24
  149. package/dist/daemon/api/handlers/canvas.js +2 -2
  150. package/dist/daemon/api/handlers/messages.js +12 -2
  151. package/dist/daemon/api/handlers/node-outcomes.d.ts +2 -0
  152. package/dist/daemon/api/handlers/node-outcomes.js +136 -0
  153. package/dist/daemon/api/handlers/nodes.js +67 -1
  154. package/dist/daemon/api/handlers/profiles.js +53 -1
  155. package/dist/daemon/api/handlers/reports.js +197 -33
  156. package/dist/daemon/api/map.d.ts +7 -2
  157. package/dist/daemon/api/map.js +78 -4
  158. package/dist/daemon/api/server.js +2 -0
  159. package/dist/daemon/crtrd.js +12 -0
  160. package/dist/daemon/fleet.d.ts +4 -8
  161. package/dist/daemon/fleet.js +48 -9
  162. package/dist/daemon/messaging/node-message.js +2 -1
  163. package/dist/daemon/node-outcome/deliver-outcome.d.ts +14 -0
  164. package/dist/daemon/node-outcome/deliver-outcome.js +155 -0
  165. package/dist/daemon/park-activity.d.ts +9 -0
  166. package/dist/daemon/park-activity.js +30 -0
  167. package/dist/daemon/park-pending.d.ts +10 -5
  168. package/dist/daemon/park-pending.js +18 -8
  169. package/dist/daemon/reconcilers/bash-deadline.js +6 -0
  170. package/dist/daemon/reconcilers/broker-supervision.d.ts +3 -7
  171. package/dist/daemon/reconcilers/broker-supervision.js +29 -33
  172. package/dist/daemon/reconcilers/node-deadline.d.ts +5 -0
  173. package/dist/daemon/reconcilers/node-deadline.js +82 -0
  174. package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.js +9 -1
  175. package/dist/daemon/reconcilers/node-lifecycle/tick.js +12 -3
  176. package/dist/daemon/reconcilers/node-outcome-delivery-lane.d.ts +10 -0
  177. package/dist/daemon/reconcilers/node-outcome-delivery-lane.js +41 -0
  178. package/dist/pi-extensions/__tests__/canvas-structured-output.test.js +66 -66
  179. package/dist/pi-extensions/canvas-structured-output.d.ts +9 -5
  180. package/dist/pi-extensions/canvas-structured-output.js +23 -297
  181. package/dist/shared/env.d.ts +3 -0
  182. package/dist/shared/env.js +6 -0
  183. package/dist/shared/generated-context.d.ts +3 -9
  184. package/dist/shared/generated-context.js +8 -16
  185. package/dist/types.d.ts +19 -0
  186. package/dist/types.js +1 -0
  187. package/package.json +9 -8
  188. package/runtime.lock.json +227 -383
@@ -37,6 +37,10 @@ import { activeFaultForDisplay } from '../../../core/canvas/render-source.js';
37
37
  import { isBrokerLive, persistDormantModel, setModelLive } from '../../../core/runtime/model-swap.js';
38
38
  import { boundFleet } from '../../../core/runtime/fleet.js';
39
39
  import { writeOutputSchema } from '../../../core/runtime/structured-output.js';
40
+ import { OUTCOME_PAYLOAD_MAX_BYTES } from '../../../core/canvas/types.js';
41
+ import { HUMAN_ACTION_NAME_PATTERN } from '../../../core/human/action-binding.js';
42
+ import { resolveHumanAction } from '../../../core/human-actions.js';
43
+ import { parseDurationMs } from '../../../core/wake.js';
40
44
  import { claimWarmNode, refillWarmPool, warmPoolEnabled } from '../../../core/runtime/warm-pool.js';
41
45
  import { resolveLaunchTarget } from '../../../core/runtime/launch-target.js';
42
46
  import { fullName } from '../../../core/canvas/labels.js';
@@ -115,6 +119,58 @@ function parseCreateBody(body) {
115
119
  }
116
120
  return b;
117
121
  }
122
+ function parseDeadline(raw) {
123
+ if (raw === undefined)
124
+ return undefined;
125
+ const durationMs = parseDurationMs(raw);
126
+ const deadlineAt = durationMs === null ? null : new Date(Date.now() + durationMs);
127
+ if (durationMs === null || !Number.isFinite(durationMs) || durationMs <= 0 || deadlineAt === null || !Number.isFinite(deadlineAt.getTime())) {
128
+ throw new ApiError(422, 'invalid_deadline', '`deadline` must be a positive representable duration');
129
+ }
130
+ return deadlineAt.toISOString();
131
+ }
132
+ function parseOutcomeDelivery(raw) {
133
+ if (raw === undefined)
134
+ return undefined;
135
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
136
+ throw new ApiError(400, 'invalid_request', '`outcome_delivery` must be an object');
137
+ }
138
+ if (typeof raw.action !== 'string' || !HUMAN_ACTION_NAME_PATTERN.test(raw.action)) {
139
+ throw new ApiError(400, 'invalid_request', '`outcome_delivery.action` must be a declared action name');
140
+ }
141
+ if (raw.payload !== undefined) {
142
+ let serialized;
143
+ try {
144
+ serialized = JSON.stringify(raw.payload);
145
+ }
146
+ catch {
147
+ throw new ApiError(400, 'invalid_request', '`outcome_delivery.payload` must be JSON-serializable');
148
+ }
149
+ if (serialized === undefined) {
150
+ throw new ApiError(400, 'invalid_request', '`outcome_delivery.payload` must be JSON-serializable');
151
+ }
152
+ if (Buffer.byteLength(serialized, 'utf8') > OUTCOME_PAYLOAD_MAX_BYTES) {
153
+ throw new ApiError(413, 'payload_too_large', `serialized payload exceeds ${OUTCOME_PAYLOAD_MAX_BYTES} bytes`);
154
+ }
155
+ }
156
+ return raw;
157
+ }
158
+ function resolveOutcomeDelivery(request, cwd) {
159
+ const resolution = resolveHumanAction(request.action, cwd);
160
+ if (!('kind' in resolution)) {
161
+ return {
162
+ actionName: request.action,
163
+ argv: resolution.argv,
164
+ cwd: resolution.cwd,
165
+ configOrigin: resolution.configOrigin,
166
+ ...(request.payload === undefined ? {} : { payload: request.payload }),
167
+ };
168
+ }
169
+ if (resolution.kind === 'unknown_action') {
170
+ throw new ApiError(422, 'unknown_action', `no action named ${request.action} is declared from ${cwd}`);
171
+ }
172
+ throw new ApiError(422, 'invalid_action', `${request.action} is declared in ${resolution.configOrigin} but unusable: ${resolution.reason}`, { reason: resolution.reason, configOrigin: resolution.configOrigin });
173
+ }
118
174
  /** Build the immediate spawn recipe. */
119
175
  function buildRecipe(req, parent, cwd) {
120
176
  const recipe = {
@@ -212,6 +268,8 @@ async function assertNodeModelChangeRegistered(meta, target, registry) {
212
268
  }
213
269
  export async function handleCreate(ctx, deps = {}) {
214
270
  const req = parseCreateBody(ctx.body);
271
+ const deadline = parseDeadline(req.deadline);
272
+ const outcomeDelivery = parseOutcomeDelivery(req.outcome_delivery);
215
273
  // `no_kickoff` needs no dedicated recipe field: an empty prompt IS the
216
274
  // no-kickoff mechanism (spawnChild runs no launch turn on prompt===''), and
217
275
  // buildRecipe already defaults `prompt` to ''. The CLI keeps every no_kickoff
@@ -237,13 +295,17 @@ export async function handleCreate(ctx, deps = {}) {
237
295
  // to this daemon, never to the possibly-remote CLI caller.
238
296
  const { profileId, kind, cwd, launch } = await resolveLaunchTarget(req, { validateKind: assertKindServer });
239
297
  const resolvedReq = { ...req, kind };
298
+ const resolvedOutcomeDelivery = outcomeDelivery === undefined ? undefined : resolveOutcomeDelivery(outcomeDelivery, cwd);
240
299
  await assertLaunchModelRegistered(requestFromLaunch(launch), { cwd, profileId }, deps.modelRegistry);
241
300
  // --- Warm path: hand out an already-booted spare instead of paying a full
242
301
  // engine boot. A miss is not a failure — it falls through to the cold
243
302
  // spawn below and mints the spare that serves the NEXT create. ---
244
303
  const warm = warmRequestFor(resolvedReq, cwd, profileId);
245
304
  if (warm !== null) {
246
- const claimed = await claimWarmNode(warm);
305
+ const claimed = await claimWarmNode(warm, {
306
+ ...(deadline === undefined ? {} : { deadlineAt: deadline }),
307
+ ...(resolvedOutcomeDelivery === undefined ? {} : { outcomeDelivery: resolvedOutcomeDelivery }),
308
+ });
247
309
  refillWarmPool(warm);
248
310
  if (claimed !== null) {
249
311
  if (schema !== null)
@@ -252,6 +314,10 @@ export async function handleCreate(ctx, deps = {}) {
252
314
  }
253
315
  }
254
316
  const recipe = buildRecipe(resolvedReq, parent, cwd);
317
+ if (deadline !== undefined)
318
+ recipe.deadlineAt = deadline;
319
+ if (resolvedOutcomeDelivery !== undefined)
320
+ recipe.outcomeDelivery = resolvedOutcomeDelivery;
255
321
  recipe.readiness = req.root === true ? 'await' : 'launch';
256
322
  const result = await spawnChild(recipe);
257
323
  if (schema !== null)
@@ -7,8 +7,13 @@
7
7
  // GET /v1/profiles/:name resolve one profile by name or id
8
8
  // DELETE /v1/profiles/:name required-force cascade or detach
9
9
  import { assertInstalledKind } from '../../../core/config.js';
10
+ import { listNodesByProfile, shiftProfileNodeDeadlines } from '../../../core/canvas/canvas.js';
11
+ import { shiftAutoBackgroundBashDeadlines } from '../../../core/bash-jobs.js';
12
+ import { contextDir } from '../../../core/canvas/paths.js';
13
+ import { boundFleet } from '../../../core/runtime/fleet.js';
14
+ import { recordedPidLiveness } from '../../../core/canvas/pid.js';
10
15
  import { usage } from '../../../core/errors.js';
11
- import { assertProfileProjects, createProfile, ensureRootProfile, listProfiles, loadProfileManifest, updateProfileMetadata, } from '../../../core/profiles/manifest.js';
16
+ import { assertProfileProjects, createProfile, ensureRootProfile, listProfiles, loadProfileManifest, pauseProfile, resumeProfile, updateProfileMetadata, } from '../../../core/profiles/manifest.js';
12
17
  import { requestProfileDeletion } from '../../profile-delete.js';
13
18
  import { toProfileDTO } from '../map.js';
14
19
  const ROOT_PROFILE_NAME = 'root';
@@ -62,6 +67,51 @@ function handleSetMetadata(ctx) {
62
67
  const updated = updateProfileMetadata(profile.profileId, set, unset);
63
68
  return { status: 200, body: toProfileDTO(updated) };
64
69
  }
70
+ /** `POST /v1/profiles/:name/pause` — make the profile inert, then directly
71
+ * SIGTERM its live brokers. This deliberately avoids host teardown: the
72
+ * broker's SIGTERM path hands foreground bash jobs to their detached
73
+ * supervisors, while shutdown would abort and remove them. */
74
+ function handlePause(ctx) {
75
+ const profile = loadProfileManifest(ctx.params['name']);
76
+ const paused = pauseProfile(profile.profileId);
77
+ const nodes = listNodesByProfile(profile.profileId).filter((node) => node.status === 'active' || node.status === 'idle');
78
+ if (nodes.length === 0)
79
+ return { status: 200, body: { profile_id: profile.profileId, paused_at: paused.manifest.paused_at } };
80
+ const fleet = boundFleet();
81
+ for (const node of nodes) {
82
+ const live = fleet.get(node.node_id);
83
+ if (live !== undefined) {
84
+ fleet.interruptForProfilePause(node.node_id, live.pid);
85
+ continue;
86
+ }
87
+ if (node.pi_pid != null && recordedPidLiveness(node.pi_pid, node.pi_pid_identity) === 'alive') {
88
+ fleet.interruptForProfilePause(node.node_id, node.pi_pid);
89
+ }
90
+ }
91
+ return { status: 200, body: { profile_id: profile.profileId, paused_at: paused.manifest.paused_at } };
92
+ }
93
+ /** `POST /v1/profiles/:name/resume` — restore admission and credit active
94
+ * profile deadlines for the interval during which the profile was inert. The
95
+ * lifecycle tick owns broker recovery after the pause interruption settles. */
96
+ function handleResume(ctx) {
97
+ const profile = loadProfileManifest(ctx.params['name']);
98
+ const resumed = resumeProfile(profile.profileId);
99
+ if (resumed.pausedAt !== null) {
100
+ const pausedAt = Date.parse(resumed.pausedAt);
101
+ if (!Number.isNaN(pausedAt)) {
102
+ const pausedMs = Math.max(0, Date.now() - pausedAt);
103
+ shiftProfileNodeDeadlines(profile.profileId, pausedMs);
104
+ // Background bash claim windows are the same kind of budget as a node
105
+ // deadline: time the node was allowed to act, not wall-clock time.
106
+ for (const node of listNodesByProfile(profile.profileId)) {
107
+ if (node.status === 'dead')
108
+ continue;
109
+ shiftAutoBackgroundBashDeadlines(contextDir(node.node_id), pausedMs);
110
+ }
111
+ }
112
+ }
113
+ return { status: 200, body: { profile_id: profile.profileId, paused_at: resumed.manifest.paused_at } };
114
+ }
65
115
  /** `GET /v1/profiles` — every profile with a readable manifest. */
66
116
  function handleList() {
67
117
  return { status: 200, body: listProfiles().map(toProfileDTO) };
@@ -81,6 +131,8 @@ async function handleDelete(ctx) {
81
131
  }
82
132
  export const profileRoutes = [
83
133
  { method: 'PUT', pattern: '/v1/profiles/:name', handler: handleEnsure },
134
+ { method: 'POST', pattern: '/v1/profiles/:name/pause', handler: handlePause },
135
+ { method: 'POST', pattern: '/v1/profiles/:name/resume', handler: handleResume },
84
136
  { method: 'PATCH', pattern: '/v1/profiles/:name/metadata', handler: handleSetMetadata },
85
137
  { method: 'DELETE', pattern: '/v1/profiles/:name', handler: handleDelete },
86
138
  { method: 'GET', pattern: '/v1/profiles', handler: handleList },
@@ -2,7 +2,13 @@
2
2
  // push route — `tier` is the report kind and update-only `delivery_tier` sets
3
3
  // subscriber delivery urgency; `final` drives the server-side lifecycle
4
4
  // transition inside feed `push()` (the sanctioned path). `GET
5
- // /nodes/{id}/reports` lists the node's stored report files.
5
+ // /nodes/{id}/reports` lists the node's stored report files. `POST
6
+ // /nodes/{id}/result` submits a structured result against the node's pending
7
+ // output schema (`crtr push result`): validate → write context/result.json →
8
+ // push the mode-matched report → clear the schema — or declines it
9
+ // (`--decline --code [--retryable]`): write context/declined.json → push the
10
+ // same report shape carrying a `failure`/`declined` outcome whose detail holds
11
+ // the reason, code, and retryable flag → clear the schema.
6
12
  //
7
13
  // `{id}` is the REPORTING node (client-side = CRTR_NODE_ID).
8
14
  import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
@@ -15,6 +21,7 @@ import { notFound, usage } from '../../../core/errors.js';
15
21
  import { InputError } from '../../../core/io.js';
16
22
  import { parseFrontmatterGeneric } from '../../../core/frontmatter.js';
17
23
  import { toReportResultDTO } from '../map.js';
24
+ import { outputSchemaPath, readOutputRequest, removeOutputSchema, validateStructuredResult, writeOutputDeclined, writeOutputResult, } from '../../../core/runtime/structured-output.js';
18
25
  function requireNode(id) {
19
26
  if (getNode(id) === null)
20
27
  throw notFound(`unknown node: ${id}`, { received: id });
@@ -45,46 +52,202 @@ async function handlePush(ctx) {
45
52
  const id = ctx.params['id'];
46
53
  requireNode(id);
47
54
  const req = parsePushBody(ctx.body);
55
+ // A valid pending structured-output request means this node owes a
56
+ // schema-checked result — a plain `push final` would finish it with
57
+ // context/result.json never written (the stop guard honors `pushedFinal`
58
+ // before its structured-output check, so it cannot catch this). An INVALID
59
+ // request file deliberately does NOT block: the stop guard's prescribed
60
+ // manual-cleanup escape (`push final` after removing the corrupt file, or
61
+ // despite it) must stay reachable.
62
+ if (req.tier === 'final' && readOutputRequest(id).state === 'valid') {
63
+ throw new InputError({
64
+ error: 'structured_result_pending',
65
+ message: 'This node owes a structured result — a plain `push final` would finish it without recording one.',
66
+ next: 'Submit the result with `crtr push result` instead; it validates against the pending schema, records it, and pushes the final report for you. If the schema cannot be honestly satisfied, `crtr push result --decline "<reason>" --code <token>` records the decline and finishes the node.',
67
+ });
68
+ }
48
69
  // `final` flips the node to done inside push() (commitCanonicalFinal). Capture
49
70
  // the pre-push status so the DTO can report the transition (spec §7.4).
50
71
  const before = req.tier === 'final' ? getNode(id)?.status : undefined;
51
- // `final` with an OPEN managed worktree, BEFORE the push/transition (spec
52
- // §6.2 — worktree fs ops are server-side). A provably-clean, zero-commit
53
- // worktree is auto-dropped so a read-only or already-landed node
54
- // can finish without an explicit `node worktree close`; a clean worktree
55
- // whose exact tip is already on origin can also finish while retaining its
56
- // branch and checkout. Any work that is neither locally landed nor remotely
57
- // retained instead blocks with the parity `open_managed_worktree` error,
58
- // round-tripped via CrtrError.details so the CLI renders the same InputError
59
- // (next-hint included) it surfaced before B-5 re-homed this to the server.
60
- let autoDroppedPath;
61
- if (req.tier === 'final' && hasOpenManagedWorktree(getNode(id))) {
62
- const dropped = autoDropCleanManagedWorktree(id);
63
- if (dropped !== null) {
64
- autoDroppedPath = dropped.worktree_path;
72
+ const autoDroppedPath = req.tier === 'final' ? await guardFinalWorktree(id) : undefined;
73
+ const result = await pushOrRethrow(id, req);
74
+ const transitioned = req.tier === 'final' && before !== undefined
75
+ ? { from: before, to: getNode(id)?.status ?? 'done' }
76
+ : undefined;
77
+ return { status: 200, body: toReportResultDTO(result, transitioned, autoDroppedPath) };
78
+ }
79
+ /** The `final`-path managed-worktree guard, BEFORE the push/transition (spec
80
+ * §6.2 — worktree fs ops are server-side). A provably-clean, zero-commit
81
+ * worktree is auto-dropped so a read-only or already-landed node
82
+ * can finish without an explicit `node worktree close`; a clean worktree
83
+ * whose exact tip is already on origin can also finish while retaining its
84
+ * branch and checkout. Any work that is neither locally landed nor remotely
85
+ * retained instead blocks with the parity `open_managed_worktree` error,
86
+ * round-tripped via CrtrError.details so the CLI renders the same InputError
87
+ * (next-hint included) it surfaced before B-5 re-homed this to the server.
88
+ * Returns the auto-dropped checkout path, or undefined when nothing dropped. */
89
+ async function guardFinalWorktree(id) {
90
+ if (!hasOpenManagedWorktree(getNode(id)))
91
+ return undefined;
92
+ const dropped = autoDropCleanManagedWorktree(id);
93
+ if (dropped !== null)
94
+ return dropped.worktree_path;
95
+ if (await branchIsPushedAtCurrentTip(getNode(id).managed_worktree))
96
+ return undefined;
97
+ // Thrown as InputError so `toErrorBody` round-trips the FULL structured
98
+ // payload (code + message + next) into `ApiError.details`, and the CLI's
99
+ // shared `apiErrorToCliError` reconstructs it byte-for-byte — no
100
+ // command-local mapper needed (plan C-5).
101
+ throw new InputError({
102
+ error: 'open_managed_worktree',
103
+ message: 'This node still owns an open managed worktree — `push final` is blocked until it is closed.',
104
+ next: (() => {
105
+ const wt = getNode(id)?.managed_worktree;
106
+ return wt !== null && wt !== undefined && wt.state === 'open' && !existsSync(wt.path)
107
+ ? missingWorktreeRecoveryHint(wt)
108
+ : 'Commit the remaining work in the worktree, push its branch to origin at its current tip, then rerun `crtr push final`; or run `crtr node worktree close` to land it locally.';
109
+ })(),
110
+ });
111
+ }
112
+ /** Cap on the fenced-JSON echo inside the submit report body. The full result
113
+ * is always on disk at result_path; the report only needs enough to orient. */
114
+ const REPORT_JSON_ECHO_LIMIT = 2000;
115
+ function submitReportBody(resultPath, value) {
116
+ const json = JSON.stringify(value, null, 2);
117
+ const fenced = json.length > REPORT_JSON_ECHO_LIMIT
118
+ ? `${json.slice(0, REPORT_JSON_ECHO_LIMIT)}\n… (truncated — the full result is at the result path above)`
119
+ : json;
120
+ return `Structured output submitted.\n\nResult path: ${resultPath}\n\n\`\`\`json\n${fenced}\n\`\`\`\n`;
121
+ }
122
+ function declineReportBody(declinedPath, declined) {
123
+ return `Structured result declined.\n\nReason: ${declined.reason}\nCode: ${declined.code}\nRetryable: ${declined.retryable ? 'yes' : 'no'}\n\nDeclined marker: ${declinedPath}\n`;
124
+ }
125
+ /** A decline reason is one sentence for a human or a consumer to read, not a payload. */
126
+ const DECLINE_REASON_MAX_CHARS = 1024;
127
+ /** A decline code is one token the requester classifies on, not a message. */
128
+ const DECLINE_CODE_MAX_CHARS = 64;
129
+ /** Narrow the submit body to exactly one of `{ value }` or `{ decline, code, retryable }`. */
130
+ function parseSubmitBody(body) {
131
+ if (typeof body !== 'object' || body === null) {
132
+ throw usage('a result submission requires a JSON body { value } or { decline, code, retryable }');
133
+ }
134
+ const hasValue = Object.hasOwn(body, 'value');
135
+ const hasDecline = Object.hasOwn(body, 'decline');
136
+ if (hasValue && hasDecline)
137
+ throw usage('a result submission carries either { value } or { decline, code, retryable }, not both');
138
+ if (hasDecline) {
139
+ const { decline, code, retryable } = body;
140
+ if (typeof decline !== 'string' || decline.trim() === '') {
141
+ throw new InputError({
142
+ error: 'missing_decline_reason',
143
+ message: 'A decline needs a reason.',
144
+ next: 'Rerun with `--decline "<one sentence on why the schema cannot be honestly satisfied>" --code <token>`.',
145
+ });
65
146
  }
66
- else if (!(await branchIsPushedAtCurrentTip(getNode(id).managed_worktree))) {
67
- // Thrown as InputError so `toErrorBody` round-trips the FULL structured
68
- // payload (code + message + next) into `ApiError.details`, and the CLI's
69
- // shared `apiErrorToCliError` reconstructs it byte-for-byte — no
70
- // command-local mapper needed (plan C-5).
147
+ if (decline.length > DECLINE_REASON_MAX_CHARS) {
71
148
  throw new InputError({
72
- error: 'open_managed_worktree',
73
- message: 'This node still owns an open managed worktree — `push final` is blocked until it is closed.',
74
- next: (() => {
75
- const wt = getNode(id)?.managed_worktree;
76
- return wt !== null && wt !== undefined && wt.state === 'open' && !existsSync(wt.path)
77
- ? missingWorktreeRecoveryHint(wt)
78
- : 'Commit the remaining work in the worktree, push its branch to origin at its current tip, then rerun `crtr push final`; or run `crtr node worktree close` to land it locally.';
79
- })(),
149
+ error: 'decline_reason_too_long',
150
+ message: `A decline reason is at most ${DECLINE_REASON_MAX_CHARS} characters (got ${decline.length}).`,
151
+ next: 'Keep the reason to one plain sentence; put any detail in an artifact and name its path.',
80
152
  });
81
153
  }
154
+ if (typeof code !== 'string' || code.trim() === '' || /\s/.test(code.trim()) || code.trim().length > DECLINE_CODE_MAX_CHARS) {
155
+ throw new InputError({
156
+ error: 'invalid_decline_code',
157
+ message: `A decline needs a code: one whitespace-free token of at most ${DECLINE_CODE_MAX_CHARS} characters naming the decline class.`,
158
+ next: 'Rerun with `--code <token>` — the token your requester documented for this case.',
159
+ });
160
+ }
161
+ if (typeof retryable !== 'boolean') {
162
+ throw usage('a decline carries `retryable` as a boolean');
163
+ }
164
+ return { declined: { reason: decline.trim(), code: code.trim(), retryable } };
82
165
  }
83
- const result = await pushOrRethrow(id, req);
84
- const transitioned = req.tier === 'final' && before !== undefined
166
+ if (!hasValue) {
167
+ // `value` must be PRESENT — but any JSON value, including null, is a legal
168
+ // result to validate, so presence is checked rather than truthiness.
169
+ throw usage('a result submission requires a JSON body { value } or { decline, code, retryable }');
170
+ }
171
+ return { value: body.value };
172
+ }
173
+ /** `POST /v1/nodes/{id}/result` — submit a structured result (`crtr push
174
+ * result`). Validate against the pending schema, write context/result.json
175
+ * atomically, push the mode-matched report (final for terminal, update for
176
+ * oneoff), then clear the schema. Any failure before the schema removal leaves
177
+ * the request in place, so the submit stays retryable. */
178
+ async function handleSubmitResult(ctx) {
179
+ const id = ctx.params['id'];
180
+ requireNode(id);
181
+ const submitted = parseSubmitBody(ctx.body);
182
+ const state = readOutputRequest(id);
183
+ if (state.state === 'absent') {
184
+ throw new InputError({
185
+ error: 'no_output_request',
186
+ message: 'No structured output is currently requested of this node.',
187
+ next: 'Nothing to submit here — continue working, or finish with `crtr push final`.',
188
+ });
189
+ }
190
+ if (state.state === 'invalid') {
191
+ throw new InputError({
192
+ error: 'invalid_output_request',
193
+ message: `The structured-output request file is invalid: ${state.error}`,
194
+ next: `Have the requester re-issue the request, or remove ${outputSchemaPath(id)} and finish with \`crtr push final\`.`,
195
+ });
196
+ }
197
+ const { mode, schema } = state.request;
198
+ let validated;
199
+ if ('value' in submitted) {
200
+ try {
201
+ validated = await validateStructuredResult(schema, submitted.value);
202
+ }
203
+ catch (err) {
204
+ // Schema retained — the node fixes the listed fields and resubmits.
205
+ throw new InputError({
206
+ error: 'result_schema_mismatch',
207
+ message: `the submitted result does not match the requested schema: ${err.message}`,
208
+ next: 'Fix the listed fields and rerun `crtr push result`; if the schema cannot be honestly satisfied, rerun with `--decline "<reason>" --code <token>`.',
209
+ });
210
+ }
211
+ }
212
+ const before = mode === 'terminal' ? getNode(id)?.status : undefined;
213
+ const autoDroppedPath = mode === 'terminal' ? await guardFinalWorktree(id) : undefined;
214
+ let resultPath;
215
+ let reportBody;
216
+ let outcome;
217
+ if ('declined' in submitted) {
218
+ const { declined } = submitted;
219
+ resultPath = writeOutputDeclined(id, { mode, ...declined, at: new Date().toISOString() });
220
+ reportBody = declineReportBody(resultPath, declined);
221
+ // A one-off decline is an update report, not the canonical final: no outcome to record.
222
+ if (mode === 'terminal') {
223
+ const detail = { schema: 'crtr.node-outcome-detail/v1', message: declined.reason, declined };
224
+ outcome = { kind: 'failure', reason: 'declined', detailJson: JSON.stringify(detail) };
225
+ }
226
+ }
227
+ else {
228
+ resultPath = writeOutputResult(id, validated);
229
+ reportBody = submitReportBody(resultPath, validated);
230
+ }
231
+ const pushed = await pushOrRethrow(id, {
232
+ tier: mode === 'terminal' ? 'final' : 'update',
233
+ body: reportBody,
234
+ }, outcome);
235
+ removeOutputSchema(id);
236
+ const transitioned = mode === 'terminal' && before !== undefined
85
237
  ? { from: before, to: getNode(id)?.status ?? 'done' }
86
238
  : undefined;
87
- return { status: 200, body: toReportResultDTO(result, transitioned, autoDroppedPath) };
239
+ const body = {
240
+ mode,
241
+ result_path: resultPath,
242
+ report_path: pushed.reportPath,
243
+ notified: [...pushed.deliveredTo],
244
+ ...('declined' in submitted ? { declined: submitted.declined } : {}),
245
+ ...(transitioned === undefined ? {} : { transitioned }),
246
+ ...(autoDroppedPath === undefined
247
+ ? {}
248
+ : { worktree_auto_dropped: true, worktree_auto_dropped_path: autoDroppedPath }),
249
+ };
250
+ return { status: 200, body };
88
251
  }
89
252
  /** `push()` but with the finalization guards translated to the same structured
90
253
  * `InputError` the pre-API `crtr push` verb surfaced. Feed throws a bare
@@ -95,9 +258,9 @@ async function handlePush(ctx) {
95
258
  * survives (a bare 409 would otherwise collapse to the generic `conflict`
96
259
  * code). `unknown_node` is unreachable here (`requireNode` guards above), so
97
260
  * it falls through as the generic 404. */
98
- async function pushOrRethrow(id, req) {
261
+ async function pushOrRethrow(id, req, outcome) {
99
262
  try {
100
- return await push(id, { kind: req.tier, body: req.body, ...(req.delivery_tier === undefined ? {} : { deliveryTier: req.delivery_tier }) });
263
+ return await push(id, { kind: req.tier, body: req.body, ...(req.delivery_tier === undefined ? {} : { deliveryTier: req.delivery_tier }), ...(outcome === undefined ? {} : { outcome }) });
101
264
  }
102
265
  catch (err) {
103
266
  if (err instanceof FinalizationError && err.code === 'already_finalized') {
@@ -164,4 +327,5 @@ function handleList(ctx) {
164
327
  export const reportRoutes = [
165
328
  { method: 'POST', pattern: '/v1/nodes/:id/reports', handler: handlePush },
166
329
  { method: 'GET', pattern: '/v1/nodes/:id/reports', handler: handleList },
330
+ { method: 'POST', pattern: '/v1/nodes/:id/result', handler: handleSubmitResult },
167
331
  ];
@@ -1,4 +1,5 @@
1
- import type { ManagedWorktree, NodeMeta, NodeRow, SubscriptionRef } from '../../core/canvas/types.js';
1
+ import type { ManagedWorktree, NodeMeta, NodeRow, NodeRuntime, SubscriptionRef } from '../../core/canvas/types.js';
2
+ import type { OutcomeDeliveryRecord } from '../../core/canvas/node-outcome-deliveries.js';
2
3
  import type { ReviewComment, ReviewCommentEvent, ReviewErrorCode, ReviewRecord, ReviewResult } from '../../core/review/types.js';
3
4
  import type { Cron, CronRunRecord } from '../../core/canvas/crons.js';
4
5
  import type { ProfileEntry } from '../../core/profiles/manifest.js';
@@ -6,7 +7,11 @@ import type { ReviveResult } from '../../core/runtime/revive.js';
6
7
  import type { CloseNodeResult } from '../../core/runtime/close.js';
7
8
  import { type PushResult } from '../../core/feed/feed.js';
8
9
  import type { PageFeedback, PageFeedbackComment } from '../../core/human/feedback.js';
9
- import type { CloseResultDTO, CronDTO, CronRunDTO, ErrorBody, NodeEdgesDTO, NodePathsDTO, NodeStatusDTO, NodeSummaryDTO, NodeDetailDTO, NodeWorktreeDTO, ProfileDTO, ReviewCommentDTO, ReviewCommentEventDTO, ReviewDTO, FeedbackResultDTO, PageFeedbackCommentDTO, PageFeedbackDTO, PushReportResultDTO, ReviveResultDTO, SubscriptionDTO } from '../../api/index.js';
10
+ import type { CloseResultDTO, CronDTO, CronRunDTO, ErrorBody, NodeEdgesDTO, NodeOutcomeDTO, NodePathsDTO, NodeStatusDTO, OutcomeDeliveryDTO, NodeSummaryDTO, NodeDetailDTO, NodeWorktreeDTO, ProfileDTO, ReviewCommentDTO, ReviewCommentEventDTO, ReviewDTO, FeedbackResultDTO, PageFeedbackCommentDTO, PageFeedbackDTO, PushReportResultDTO, ReviveResultDTO, SubscriptionDTO } from '../../api/index.js';
11
+ /** Project a latched outcome, or null while this node remains unsettled. */
12
+ export declare function toNodeOutcomeDTO(node: Pick<NodeMeta, 'node_id' | 'final_report'> & NodeRuntime): NodeOutcomeDTO | null;
13
+ /** Project a durable delivery row without exposing its execution coordinates or stderr tail. */
14
+ export declare function toOutcomeDeliveryDTO(row: OutcomeDeliveryRecord): OutcomeDeliveryDTO;
10
15
  /** Project the queryable row (what `listNodes` yields) into a summary DTO. */
11
16
  export declare function toNodeSummaryDTO(row: NodeRow): NodeSummaryDTO;
12
17
  /** The subscription/child edges a detail projection needs. The handler gathers
@@ -4,13 +4,15 @@
4
4
  // dependency-light wire contract. `src/api/*` stays pure (Node built-ins only);
5
5
  // all projection lives here, server-side.
6
6
  //
7
- // Every function here is a pure projection (no I/O, no db reach) except the
8
- // path builders, which call `core/canvas/paths.ts` — themselves pure string
9
- // constructors. The DTO field sets these project into were pinned by A-1 as
7
+ // Every function here is a pure projection (no db reach) except the path
8
+ // builders and the optional structured-result sidecar read needed by a settled
9
+ // outcome. The DTO field sets these project into were pinned by A-1 as
10
10
  // reasonable projections; this module is what makes them concrete, so any DTO
11
11
  // field that projection proves wrong is corrected here at the source.
12
+ import { existsSync, readFileSync } from 'node:fs';
13
+ import { outputResultPath } from '../../core/runtime/structured-output.js';
12
14
  import { ReviewOperationError as ReviewOperationErrorClass } from '../../core/review/types.js';
13
- import { sanitizeProfileMetadata } from '../../core/profiles/manifest.js';
15
+ import { isProfilePaused, sanitizeProfileMetadata } from '../../core/profiles/manifest.js';
14
16
  import { contextDir, inboxPath, nodeDir, nodeMetaPath, reportsDir, reviewResultPath, transcriptPath, viewSocketPath, } from '../../core/canvas/paths.js';
15
17
  import { FinalizationError } from '../../core/feed/feed.js';
16
18
  import { NodeIdConflictError } from '../../core/runtime/nodes.js';
@@ -23,6 +25,72 @@ import { sinkDisplay } from '../cron-sink.js';
23
25
  import { responsePath } from '../../core/human/convention.js';
24
26
  import { ticketDir } from '../../core/human/root.js';
25
27
  // Node projections
28
+ /** Read the optional machine result without letting a missing or malformed sidecar obscure the durable outcome row. */
29
+ function readStructuredResult(nodeId) {
30
+ const path = outputResultPath(nodeId);
31
+ try {
32
+ return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : null;
33
+ }
34
+ catch {
35
+ return null;
36
+ }
37
+ }
38
+ /** Project a latched outcome, or null while this node remains unsettled. */
39
+ export function toNodeOutcomeDTO(node) {
40
+ if (node.outcome_at === null || node.outcome_at === undefined || node.outcome_kind === null || node.outcome_kind === undefined || node.outcome_reason === null || node.outcome_reason === undefined) {
41
+ return null;
42
+ }
43
+ let detail = null;
44
+ if (node.outcome_detail_json !== null && node.outcome_detail_json !== undefined) {
45
+ try {
46
+ detail = JSON.parse(node.outcome_detail_json);
47
+ }
48
+ catch {
49
+ detail = null;
50
+ }
51
+ }
52
+ // A declined structured result is a `failure` that still carries a canonical final report.
53
+ const finalReport = node.final_report ?? null;
54
+ const base = {
55
+ node_id: node.node_id,
56
+ revision: node.outcome_revision ?? 1,
57
+ settled_at: node.outcome_at,
58
+ final_report: finalReport,
59
+ final_report_path: finalReport === null ? null : `${reportsDir(node.node_id)}/${finalReport}`,
60
+ structured_result: node.outcome_kind === 'result' ? readStructuredResult(node.node_id) : null,
61
+ detail,
62
+ };
63
+ if (node.outcome_kind === 'result')
64
+ return { ...base, kind: 'result', reason: node.outcome_reason, declined: null };
65
+ if (node.outcome_reason === 'declined') {
66
+ // The decline lane always records its detail; a declined row without one is corrupt, not a legacy shape.
67
+ if (detail?.declined === undefined)
68
+ throw new Error(`node ${node.node_id}: outcome reason 'declined' without a declined detail`);
69
+ return { ...base, kind: 'failure', reason: 'declined', declined: detail.declined };
70
+ }
71
+ return { ...base, kind: 'failure', reason: node.outcome_reason, declined: null };
72
+ }
73
+ /** Project a durable delivery row without exposing its execution coordinates or stderr tail. */
74
+ export function toOutcomeDeliveryDTO(row) {
75
+ const failure = row.lastFailure;
76
+ return {
77
+ node_id: row.nodeId,
78
+ state: row.state,
79
+ action: row.actionName,
80
+ attempt: row.attempt,
81
+ next_attempt_at: row.nextAttemptAt === 0 ? null : new Date(row.nextAttemptAt).toISOString(),
82
+ accepted_at: row.acceptedAt === null ? null : new Date(row.acceptedAt).toISOString(),
83
+ permanent_failed_at: row.permanentFailedAt === null ? null : new Date(row.permanentFailedAt).toISOString(),
84
+ last_failure: failure === null ? null : {
85
+ kind: failure.kind,
86
+ ...(failure.exitCode === undefined ? {} : { exit_code: failure.exitCode }),
87
+ ...(failure.signal === undefined ? {} : { signal: failure.signal }),
88
+ ...(failure.message === undefined ? {} : { message: failure.message }),
89
+ },
90
+ created_at: new Date(row.createdAt).toISOString(),
91
+ updated_at: new Date(row.updatedAt).toISOString(),
92
+ };
93
+ }
26
94
  /** Project the queryable row (what `listNodes` yields) into a summary DTO. */
27
95
  export function toNodeSummaryDTO(row) {
28
96
  return {
@@ -37,6 +105,7 @@ export function toNodeSummaryDTO(row) {
37
105
  cwd: row.cwd,
38
106
  host_kind: row.host_kind,
39
107
  profile_id: row.profile_id,
108
+ profile_paused: isProfilePaused(row.profile_id),
40
109
  parent: row.parent,
41
110
  created: row.created,
42
111
  intent: row.intent,
@@ -50,6 +119,8 @@ export function toNodeSummaryDTO(row) {
50
119
  pane: row.pane,
51
120
  final_report: row.final_report ?? null,
52
121
  finalized_at: row.finalized_at ?? null,
122
+ deadline_at: row.deadline_at ?? null,
123
+ outcome: toNodeOutcomeDTO(row),
53
124
  };
54
125
  }
55
126
  /** Project a node's spine + subscription edges (absorbs `managers`/`paths`). */
@@ -109,6 +180,7 @@ export function toNodeDetailDTO(meta, edges) {
109
180
  cwd: meta.cwd,
110
181
  host_kind: meta.host_kind ?? null,
111
182
  profile_id: meta.profile_id ?? null,
183
+ profile_paused: isProfilePaused(meta.profile_id),
112
184
  parent: meta.parent ?? null,
113
185
  created: meta.created,
114
186
  intent: (meta.intent ?? null),
@@ -122,6 +194,8 @@ export function toNodeDetailDTO(meta, edges) {
122
194
  pane: meta.pane ?? null,
123
195
  final_report: meta.final_report ?? null,
124
196
  finalized_at: meta.finalized_at ?? null,
197
+ deadline_at: meta.deadline_at ?? null,
198
+ outcome: toNodeOutcomeDTO(meta),
125
199
  creator: meta.creator ?? null,
126
200
  edges: toNodeEdgesDTO(meta, edges),
127
201
  paths: toNodePathsDTO(meta.node_id),
@@ -38,6 +38,7 @@ import { messageRoutes } from './handlers/messages.js';
38
38
  import { modelAuthRoutes } from './handlers/modelauth.js';
39
39
  import { modelConfigRoutes } from './handlers/model-config.js';
40
40
  import { nodeRoutes } from './handlers/nodes.js';
41
+ import { nodeOutcomeRoutes } from './handlers/node-outcomes.js';
41
42
  import { profileRoutes } from './handlers/profiles.js';
42
43
  import { prospectiveChatInventoryRoutes } from './handlers/prospective-chat-inventory.js';
43
44
  import { reportRoutes } from './handlers/reports.js';
@@ -54,6 +55,7 @@ function buildRouter() {
54
55
  .registerAll(healthRoutes)
55
56
  .registerAll(daemonRoutes)
56
57
  .registerAll(nodeRoutes)
58
+ .registerAll(nodeOutcomeRoutes)
57
59
  .registerAll(bashJobRoutes)
58
60
  .registerAll(brokerOperationRoutes)
59
61
  .registerAll(worktreeRoutes)