@north-light/crouter 0.3.192 → 0.3.193

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 (167) hide show
  1. package/dist/api/client.d.ts +31 -10
  2. package/dist/api/client.js +83 -24
  3. package/dist/api/dto/bash-jobs.d.ts +22 -0
  4. package/dist/api/dto/bash-jobs.js +9 -0
  5. package/dist/api/dto/human.d.ts +3 -4
  6. package/dist/api/dto/inbox.d.ts +106 -65
  7. package/dist/api/dto/inbox.js +2 -8
  8. package/dist/api/index.d.ts +1 -0
  9. package/dist/api/index.js +1 -0
  10. package/dist/api/routes.d.ts +7 -0
  11. package/dist/api/routes.js +7 -0
  12. package/dist/builtin-memory/internal/storage-tiers.md +1 -1
  13. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/serial/provider-rotation.test.ts +101 -3
  14. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.d.ts +6 -2
  15. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +68 -14
  16. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +87 -13
  17. package/dist/clients/attach/__tests__/chat-view-snapshot-ordering.test.js +2 -2
  18. package/dist/clients/attach/__tests__/group-activity.test.js +2 -2
  19. package/dist/clients/attach/chrome/canvas-panels.d.ts +1 -1
  20. package/dist/clients/attach/chrome/canvas-panels.js +1 -1
  21. package/dist/clients/attach/input/overlay-owner.js +1 -1
  22. package/dist/clients/attach/overlays/auth.js +34 -8
  23. package/dist/clients/attach/overlays/pickers.d.ts +5 -8
  24. package/dist/clients/attach/overlays/pickers.js +37 -43
  25. package/dist/clients/attach/viewer.js +643 -620
  26. package/dist/clients/inbox/__tests__/serial/inbox-controller.test.js +38 -128
  27. package/dist/clients/inbox/__tests__/serial/mount-panel.test.js +72 -148
  28. package/dist/clients/inbox/controller.d.ts +2 -3
  29. package/dist/clients/inbox/controller.js +41 -42
  30. package/dist/clients/inbox/page-adapter.d.ts +33 -0
  31. package/dist/clients/inbox/page-adapter.js +54 -0
  32. package/dist/clients/inbox/resolve.d.ts +2 -2
  33. package/dist/clients/inbox/resolve.js +1 -1
  34. package/dist/clients/inbox/review-visibility.d.ts +1 -1
  35. package/dist/clients/inbox/review-visibility.js +1 -1
  36. package/dist/clients/inbox/tui/ansi.d.ts +1 -1
  37. package/dist/clients/inbox/tui/ansi.js +2 -2
  38. package/dist/clients/inbox/tui/input.d.ts +1 -3
  39. package/dist/clients/inbox/tui/input.js +242 -553
  40. package/dist/clients/inbox/tui/panel.d.ts +3 -1
  41. package/dist/clients/inbox/tui/panel.js +59 -187
  42. package/dist/clients/inbox/tui/render.d.ts +0 -25
  43. package/dist/clients/inbox/tui/render.js +165 -416
  44. package/dist/clients/inbox/tui/slots.d.ts +32 -0
  45. package/dist/clients/inbox/tui/slots.js +156 -0
  46. package/dist/clients/inbox/tui/types.d.ts +25 -13
  47. package/dist/clients/inbox/tui.js +5 -3
  48. package/dist/commands/attention.js +2 -2
  49. package/dist/commands/human/doc.js +66 -20
  50. package/dist/commands/human/prompts.d.ts +3 -3
  51. package/dist/commands/human/prompts.js +158 -112
  52. package/dist/commands/human/queue.d.ts +0 -1
  53. package/dist/commands/human/queue.js +34 -94
  54. package/dist/commands/human/shared.d.ts +10 -4
  55. package/dist/commands/human/shared.js +62 -13
  56. package/dist/commands/human.js +8 -4
  57. package/dist/commands/memory/read.js +13 -5
  58. package/dist/commands/node/bash.js +15 -28
  59. package/dist/commands/surface/node/focus.js +1 -1
  60. package/dist/core/__tests__/human-deliver.test.js +40 -46
  61. package/dist/core/__tests__/seam/broker-attach-stream.test.js +7 -1
  62. package/dist/core/__tests__/serial/human-deliver-e2e.test.js +40 -31
  63. package/dist/core/__tests__/serial/spawn-root.test.js +3 -1
  64. package/dist/core/__tests__/spawn-worktree-id-conflict.test.js +3 -1
  65. package/dist/core/auth-interaction.d.ts +2 -2
  66. package/dist/core/bash-jobs.d.ts +32 -0
  67. package/dist/core/bash-jobs.js +72 -1
  68. package/dist/core/canvas/__tests__/attention.test.js +16 -10
  69. package/dist/core/canvas/__tests__/render-remote.test.js +9 -3
  70. package/dist/core/canvas/attention.js +1 -1
  71. package/dist/core/canvas/browse/app.js +1 -1
  72. package/dist/core/canvas/nav-model.d.ts +1 -1
  73. package/dist/core/canvas/nav-model.js +1 -1
  74. package/dist/core/canvas/remote-canvas-source.js +1 -1
  75. package/dist/core/config.js +4 -1
  76. package/dist/core/human/__tests__/page-render.test.d.ts +1 -0
  77. package/dist/core/human/__tests__/page-render.test.js +49 -0
  78. package/dist/core/human/__tests__/page-tickets.test.d.ts +1 -0
  79. package/dist/core/human/__tests__/page-tickets.test.js +112 -0
  80. package/dist/core/human/__tests__/page.test.d.ts +1 -0
  81. package/dist/core/human/__tests__/page.test.js +139 -0
  82. package/dist/core/human/__tests__/serial/inbox-core.test.js +42 -32
  83. package/dist/core/human/claim.js +2 -2
  84. package/dist/core/human/convention.d.ts +4 -4
  85. package/dist/core/human/convention.js +6 -13
  86. package/dist/core/human/markdown-html.d.ts +8 -0
  87. package/dist/core/human/markdown-html.js +581 -0
  88. package/dist/core/human/page-catalog.d.ts +6 -0
  89. package/dist/core/human/page-catalog.js +43 -0
  90. package/dist/core/human/page-render.d.ts +3 -0
  91. package/dist/core/human/page-render.js +131 -0
  92. package/dist/core/human/page-schema.d.ts +312 -0
  93. package/dist/core/human/page-schema.js +291 -0
  94. package/dist/core/human/page-synth.d.ts +9 -0
  95. package/dist/core/human/page-synth.js +17 -0
  96. package/dist/core/human/page.d.ts +22 -0
  97. package/dist/core/human/page.js +427 -0
  98. package/dist/core/human/review-schema.d.ts +19 -0
  99. package/dist/core/human/review-schema.js +34 -0
  100. package/dist/core/human/scan.d.ts +12 -3
  101. package/dist/core/human/scan.js +69 -26
  102. package/dist/core/human/summary.d.ts +5 -6
  103. package/dist/core/human/summary.js +54 -37
  104. package/dist/core/human/tickets.d.ts +61 -11
  105. package/dist/core/human/tickets.js +100 -57
  106. package/dist/core/human/types.d.ts +21 -66
  107. package/dist/core/human/types.js +1 -5
  108. package/dist/core/preview-registry.js +0 -1
  109. package/dist/core/review/ticket-filter.d.ts +1 -1
  110. package/dist/core/review/ticket-filter.js +2 -8
  111. package/dist/core/runtime/broker/read-ops.js +3 -0
  112. package/dist/core/runtime/broker.js +19 -10
  113. package/dist/core/runtime/package-health.js +1 -1
  114. package/dist/core/termrender/termrender.d.ts +1 -1
  115. package/dist/core/termrender/termrender.js +2 -2
  116. package/dist/core/tui/page-host.d.ts +1 -1
  117. package/dist/core/tui/page-host.js +2 -2
  118. package/dist/core/user-settings.d.ts +19 -0
  119. package/dist/core/user-settings.js +22 -0
  120. package/dist/daemon/api/handlers/bash-jobs.d.ts +2 -0
  121. package/dist/daemon/api/handlers/bash-jobs.js +89 -0
  122. package/dist/daemon/api/handlers/human.js +16 -14
  123. package/dist/daemon/api/handlers/inbox.js +311 -227
  124. package/dist/daemon/api/router.d.ts +5 -1
  125. package/dist/daemon/api/router.js +5 -0
  126. package/dist/daemon/api/server.js +2 -0
  127. package/dist/daemon/cron-run.js +23 -10
  128. package/dist/daemon/human/finish.d.ts +8 -3
  129. package/dist/daemon/human/finish.js +35 -15
  130. package/dist/pages/bundle.css +1 -0
  131. package/dist/pages/bundle.js +1347 -0
  132. package/dist/pages/comments.d.ts +34 -0
  133. package/dist/pages/comments.js +72 -0
  134. package/dist/pages/elements/cards.d.ts +17 -0
  135. package/dist/pages/elements/cards.js +785 -0
  136. package/dist/pages/elements/chart.d.ts +1 -0
  137. package/dist/pages/elements/chart.js +648 -0
  138. package/dist/pages/elements/options.d.ts +21 -0
  139. package/dist/pages/elements/options.js +604 -0
  140. package/dist/pages/elements/pages.d.ts +24 -0
  141. package/dist/pages/elements/pages.js +355 -0
  142. package/dist/pages/elements/slot.d.ts +21 -0
  143. package/dist/pages/elements/slot.js +105 -0
  144. package/dist/pages/elements/table.d.ts +19 -0
  145. package/dist/pages/elements/table.js +906 -0
  146. package/dist/pages/elements/text.d.ts +25 -0
  147. package/dist/pages/elements/text.js +951 -0
  148. package/dist/pages/entry.d.ts +8 -0
  149. package/dist/pages/entry.js +8 -0
  150. package/dist/pages/host.d.ts +132 -0
  151. package/dist/pages/host.js +149 -0
  152. package/dist/pages/register.d.ts +2 -0
  153. package/dist/pages/register.js +3 -0
  154. package/dist/pages/slot-config.d.ts +36 -0
  155. package/dist/pages/slot-config.js +50 -0
  156. package/dist/pages/types.d.ts +98 -0
  157. package/dist/pages/types.js +23 -0
  158. package/dist/types.d.ts +4 -0
  159. package/dist/types.js +2 -0
  160. package/package.json +8 -8
  161. package/runtime.lock.json +156 -101
  162. package/dist/clients/inbox/deck-adapter.d.ts +0 -29
  163. package/dist/clients/inbox/deck-adapter.js +0 -63
  164. package/dist/core/human/deck-factories.d.ts +0 -8
  165. package/dist/core/human/deck-factories.js +0 -15
  166. package/dist/core/human/deck-schema.d.ts +0 -70
  167. package/dist/core/human/deck-schema.js +0 -92
@@ -1,26 +1,18 @@
1
1
  // Ticket inbox handlers — crtrd `/v1/human/inbox` (Northlight crouter-inbox
2
2
  // v1, inbox-contract.md §A). This public ticket-serving resource enumerates,
3
3
  // reads, responds to, and cancels tickets while preserving its product DTOs.
4
- //
5
- // Every ticket is addressed on the wire by an opaque, stable id: lowercase
6
- // SHA-256 hex of `canonicalRoot + "\0" + ticketBasename`. The wire never exposes
7
- // a ticket directory, review file/output, claim owner/heartbeat, or other host
8
- // path or process metadata. The daemon's shared ticket finish path owns claims,
9
- // terminal publication, and bridge delivery.
10
4
  import { createHash } from 'node:crypto';
11
- import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs';
12
- import { basename, resolve } from 'node:path';
5
+ import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs';
6
+ import { basename, resolve, join } from 'node:path';
13
7
  import { ApiError } from '../../../api/index.js';
14
- import { readJsonOrNull } from '../../../core/fs-utils.js';
15
- import { deckPath, reviewPath } from '../../../core/human/convention.js';
16
- import { parseDeck } from '../../../core/human/deck-schema.js';
8
+ import { assetRoot } from '../../../core/asset-root.js';
9
+ import { pageManifestPath, reviewPath } from '../../../core/human/convention.js';
10
+ import { renderPageHtml } from '../../../core/human/page-render.js';
17
11
  import { ticketsRoot } from '../../../core/human/root.js';
18
- import { scanInbox } from '../../../core/human/scan.js';
19
- import { readTicketResult } from '../../../core/human/tickets.js';
20
- import { getReviewByBridge } from '../../../core/review/store.js';
21
- import { ReviewOperationError } from '../../../core/review/types.js';
22
- import { cancelHumanTicket, resolveDeckTicket } from '../../human/finish.js';
23
- import { cancelReview } from '../../review/finish.js';
12
+ import { scanInbox, scanPageHistory } from '../../../core/human/scan.js';
13
+ import { readPageTicket, readTicketResult, validatePageTicketResponses, writePageProgress, } from '../../../core/human/tickets.js';
14
+ import { readConfig } from '../../../core/config.js';
15
+ import { cancelHumanTicket, resolvePageTicket } from '../../human/finish.js';
24
16
  import { filterTerminalReviewTickets } from '../../../core/review/ticket-filter.js';
25
17
  const DEFAULT_CANCEL_REASON = 'Canceled from the inbox.';
26
18
  const CANCEL_REASON_MAX = 1000;
@@ -28,12 +20,9 @@ const TICKET_ID_PATTERN = /^[a-f0-9]{64}$/;
28
20
  // ===========================================================================
29
21
  // Opaque ticket id resolution
30
22
  // ===========================================================================
31
- /** Opaque ids hash the canonical derived tickets root and ticket basename. */
32
23
  function ticketHash(canonicalRoot, ticketBasename) {
33
24
  return createHash('sha256').update(`${canonicalRoot}\0${ticketBasename}`, 'utf8').digest('hex');
34
25
  }
35
- /** Resolve an opaque id against the single tickets root. Includes terminal
36
- * tickets so a stale client receives the stable already-resolved response. */
37
26
  function resolveTicket(ticketId) {
38
27
  let canonicalRoot;
39
28
  try {
@@ -64,8 +53,8 @@ function resolveTicket(ticketId) {
64
53
  }
65
54
  if (resolve(canonicalDir, '..') !== canonicalRoot || basename(canonicalDir) !== entry)
66
55
  continue;
67
- if (existsSync(deckPath(canonicalDir)))
68
- return { nodeId: entry, dir: canonicalDir, kind: 'deck' };
56
+ if (existsSync(pageManifestPath(canonicalDir)))
57
+ return { nodeId: entry, dir: canonicalDir, kind: 'page' };
69
58
  if (existsSync(reviewPath(canonicalDir)))
70
59
  return { nodeId: entry, dir: canonicalDir, kind: 'review' };
71
60
  }
@@ -81,87 +70,45 @@ function requireTicketId(params) {
81
70
  // ===========================================================================
82
71
  // Projections — humanloop canonical shapes → wire DTOs
83
72
  // ===========================================================================
84
- function toDeckSourceDTO(source) {
85
- const dto = {};
86
- if (source.sessionName !== undefined)
87
- dto.sessionName = source.sessionName;
88
- if (source.askedBy !== undefined)
89
- dto.askedBy = source.askedBy;
90
- if (source.blockedSince !== undefined)
91
- dto.blockedSince = source.blockedSince;
92
- // The originating canvas node id — machine attribution a consumer uses to
93
- // relate a ticket to the node that raised it. A node id is opaque canvas
94
- // identity, not a host path, so it is safe on this wire.
95
- if (source.nodeId !== undefined)
96
- dto.nodeId = source.nodeId;
97
- return dto;
98
- }
99
- function toOptionDTO(option) {
100
- const dto = { id: option.id, label: option.label };
101
- if (option.description !== undefined)
102
- dto.description = option.description;
103
- return dto;
104
- }
105
- function toInteractionDTO(interaction) {
73
+ function pageSlotToDTO(slot) {
106
74
  const dto = {
107
- id: interaction.id,
108
- title: interaction.title,
109
- subtitle: interaction.subtitle,
110
- options: interaction.options.map(toOptionDTO),
75
+ kind: slot.kind,
76
+ step: slot.step,
77
+ config: slot.config,
111
78
  };
112
- if (interaction.body !== undefined)
113
- dto.body = interaction.body;
114
- if (interaction.multiSelect !== undefined)
115
- dto.multiSelect = interaction.multiSelect;
116
- if (interaction.allowFreetext !== undefined)
117
- dto.allowFreetext = interaction.allowFreetext;
118
- if (interaction.freetextLabel !== undefined)
119
- dto.freetextLabel = interaction.freetextLabel;
120
- if (interaction.kind !== undefined)
121
- dto.kind = interaction.kind;
122
- if (interaction.preAnswered !== undefined)
123
- dto.preAnswered = interaction.preAnswered;
79
+ if (slot.id !== undefined)
80
+ dto.id = slot.id;
81
+ if (slot.unvalidated === true)
82
+ dto.unvalidated = true;
124
83
  return dto;
125
84
  }
126
- function toDeckDTO(deck) {
127
- const dto = { title: deck.title, interactions: deck.interactions.map(toInteractionDTO) };
128
- if (deck.source !== undefined)
129
- dto.source = toDeckSourceDTO(deck.source);
130
- return dto;
131
- }
132
- function toTicketSummaryDTO(item) {
133
- const ticket_id = ticketHash(realpathSync(ticketsRoot()), item.id);
134
- const source = toDeckSourceDTO(item.source);
135
- if (item.kind === 'review') {
136
- const dto = {
137
- ticket_id,
138
- kind: 'review',
139
- title: item.title,
140
- subtitle: item.subtitle,
141
- blocked_since: item.blockedSince,
142
- source,
143
- };
144
- return dto;
145
- }
85
+ function pageManifestToDTO(manifest) {
146
86
  const dto = {
147
- ticket_id,
148
- kind: 'deck',
149
- title: item.title,
150
- subtitle: item.subtitle,
151
- blocked_since: item.blockedSince,
152
- source,
87
+ schema: 'crtr.page/v1',
88
+ dialect: manifest.dialect,
89
+ title: manifest.title,
90
+ placement: manifest.placement,
91
+ document: manifest.document,
92
+ steps: manifest.steps,
93
+ slots: manifest.slots.map(pageSlotToDTO),
153
94
  };
154
- if (item.interactionKind !== undefined)
155
- dto.interaction_kind = item.interactionKind;
95
+ if (manifest.subtitle !== undefined)
96
+ dto.subtitle = manifest.subtitle;
97
+ if (manifest.source !== undefined) {
98
+ const source = {};
99
+ if (manifest.source.sessionName !== undefined)
100
+ source.sessionName = manifest.source.sessionName;
101
+ if (manifest.source.askedBy !== undefined)
102
+ source.askedBy = manifest.source.askedBy;
103
+ if (manifest.source.blockedSince !== undefined)
104
+ source.blockedSince = manifest.source.blockedSince;
105
+ if (manifest.source.nodeId !== undefined)
106
+ source.nodeId = manifest.source.nodeId;
107
+ if (Object.keys(source).length > 0)
108
+ dto.source = source;
109
+ }
156
110
  return dto;
157
111
  }
158
- // ===========================================================================
159
- // Request-body validation — structural only. Semantic response↔deck
160
- // validation (option ids, single/multi-select shape, freetext permission,
161
- // option comments, required notify acknowledgement) belongs to the ticket
162
- // store via the daemon finish path; a structural violation here is
163
- // `invalid_request`, a semantic rejection there is `invalid_ticket_response`.
164
- // ===========================================================================
165
112
  function isPlainObject(value) {
166
113
  return typeof value === 'object' && value !== null && !Array.isArray(value);
167
114
  }
@@ -171,48 +118,21 @@ function requireNoExtraKeys(obj, allowed) {
171
118
  throw new ApiError(400, 'invalid_request', `unexpected field: ${key}`);
172
119
  }
173
120
  }
174
- const RESPONSE_FIELDS = ['id', 'selectedOptionId', 'selectedOptionIds', 'freetext', 'optionComments'];
175
- function validateInteractionResponse(raw) {
176
- if (!isPlainObject(raw))
177
- throw new ApiError(400, 'invalid_request', 'each response must be an object');
178
- requireNoExtraKeys(raw, RESPONSE_FIELDS);
179
- const { id, selectedOptionId, selectedOptionIds, freetext, optionComments } = raw;
180
- if (typeof id !== 'string' || id === '') {
181
- throw new ApiError(400, 'invalid_request', 'response.id must be a nonempty string');
182
- }
183
- const response = { id };
184
- if (selectedOptionId !== undefined) {
185
- if (typeof selectedOptionId !== 'string') {
186
- throw new ApiError(400, 'invalid_request', 'response.selectedOptionId must be a string');
187
- }
188
- response.selectedOptionId = selectedOptionId;
189
- }
190
- if (selectedOptionIds !== undefined) {
191
- if (!Array.isArray(selectedOptionIds) || !selectedOptionIds.every((v) => typeof v === 'string')) {
192
- throw new ApiError(400, 'invalid_request', 'response.selectedOptionIds must be a string array');
193
- }
194
- response.selectedOptionIds = selectedOptionIds;
195
- }
196
- if (freetext !== undefined) {
197
- if (typeof freetext !== 'string')
198
- throw new ApiError(400, 'invalid_request', 'response.freetext must be a string');
199
- response.freetext = freetext;
200
- }
201
- if (optionComments !== undefined) {
202
- if (!isPlainObject(optionComments) || !Object.values(optionComments).every((v) => typeof v === 'string')) {
203
- throw new ApiError(400, 'invalid_request', 'response.optionComments must be a string map');
204
- }
205
- response.optionComments = optionComments;
206
- }
207
- return response;
208
- }
209
121
  function validateRespondRequest(raw) {
210
122
  if (!isPlainObject(raw))
211
123
  throw new ApiError(400, 'invalid_request', 'respond body is required');
212
124
  requireNoExtraKeys(raw, ['responses']);
213
- if (!Array.isArray(raw['responses']))
214
- throw new ApiError(400, 'invalid_request', '`responses` must be an array');
215
- return { responses: raw['responses'].map(validateInteractionResponse) };
125
+ if (!isPlainObject(raw['responses']))
126
+ throw new ApiError(400, 'invalid_request', '`responses` must be an object');
127
+ return { responses: raw['responses'] };
128
+ }
129
+ function validateProgressRequest(raw) {
130
+ if (!isPlainObject(raw))
131
+ throw new ApiError(400, 'invalid_request', 'progress body is required');
132
+ requireNoExtraKeys(raw, ['responses']);
133
+ if (!isPlainObject(raw['responses']))
134
+ throw new ApiError(400, 'invalid_request', '`responses` must be an object');
135
+ return { responses: raw['responses'] };
216
136
  }
217
137
  function validateCancelRequest(raw) {
218
138
  if (raw === undefined)
@@ -230,88 +150,127 @@ function validateCancelRequest(raw) {
230
150
  }
231
151
  return { reason };
232
152
  }
153
+ // Error patterns matching slot-addressed validation messages from page-schema.ts
154
+ const SLOT_VALIDATION_PATTERNS = [
155
+ /^slot ".*?" /,
156
+ /^response names unknown or non-response-bearing slot id/,
157
+ /^final page response is missing response-bearing slot id/,
158
+ ];
159
+ function isSlotValidationError(err) {
160
+ return err instanceof Error && SLOT_VALIDATION_PATTERNS.some((pattern) => pattern.test(err.message));
161
+ }
233
162
  // ===========================================================================
234
163
  // GET /v1/human/inbox — enumerate
235
164
  // ===========================================================================
236
165
  function handleList() {
237
- const items = filterTerminalReviewTickets(scanInbox());
238
- // `blocked_since` is an RFC 3339 timestamp that may carry a non-`Z` offset;
239
- // two offsets are not lexically comparable (`+14:00` can sort after `Z`
240
- // while naming an earlier instant), so sort by parsed instant and use the
241
- // ticket id only to break an exact tie.
242
- const tickets = items.map(toTicketSummaryDTO).sort((a, b) => {
166
+ const productKinds = readConfig('user').page_components ?? [];
167
+ const allItems = scanInbox(productKinds);
168
+ const filtered = filterTerminalReviewTickets(allItems);
169
+ const tickets = filtered.map((item) => {
170
+ if (item.kind === 'page') {
171
+ const ticketId = createHash('sha256').update(`${realpathSync(ticketsRoot())}\0${item.id}`, 'utf8').digest('hex');
172
+ const dto = {
173
+ ticket_id: ticketId,
174
+ kind: 'page',
175
+ title: item.title,
176
+ placement: item.placement,
177
+ dialect: item.dialect,
178
+ steps: item.steps,
179
+ blocked_since: item.blockedSince,
180
+ source: item.source,
181
+ slot_kinds: item.slotKinds,
182
+ awaits_response: item.awaitsResponse,
183
+ };
184
+ if (item.subtitle !== '')
185
+ dto.subtitle = item.subtitle;
186
+ return dto;
187
+ }
188
+ else if (item.kind === 'review') {
189
+ return {
190
+ ticket_id: createHash('sha256').update(`${realpathSync(ticketsRoot())}\0${item.id}`, 'utf8').digest('hex'),
191
+ kind: 'review',
192
+ title: item.title,
193
+ subtitle: item.subtitle,
194
+ blocked_since: item.blockedSince,
195
+ source: item.source,
196
+ };
197
+ }
198
+ throw new Error(`unknown ticket kind: ${item.kind}`);
199
+ }).sort((a, b) => {
243
200
  const byTime = Date.parse(b.blocked_since) - Date.parse(a.blocked_since);
244
201
  return byTime !== 0 ? byTime : a.ticket_id.localeCompare(b.ticket_id);
245
202
  });
246
203
  return { status: 200, body: { tickets } };
247
204
  }
248
205
  // ===========================================================================
249
- // GET /v1/human/inbox/:ticket_id — read a pending deck
206
+ // GET /v1/human/inbox/history?node_id=<id> — page ticket history
250
207
  // ===========================================================================
251
- function handleGetDeck(ctx) {
208
+ function handleHistory(ctx) {
209
+ const nodeId = ctx.query.get('node_id');
210
+ if (!nodeId || typeof nodeId !== 'string') {
211
+ throw new ApiError(400, 'invalid_request', 'history requires a node_id query param');
212
+ }
213
+ const productKinds = readConfig('user').page_components ?? [];
214
+ const history = scanPageHistory(nodeId, productKinds);
215
+ const canonicalRoot = realpathSync(ticketsRoot());
216
+ const tickets = history.map((item) => {
217
+ const ticketId = createHash('sha256').update(`${canonicalRoot}\0${item.id}`, 'utf8').digest('hex');
218
+ const dto = {
219
+ ticket_id: ticketId,
220
+ kind: 'page',
221
+ title: item.title,
222
+ placement: item.placement,
223
+ dialect: item.dialect,
224
+ steps: item.steps,
225
+ blocked_since: item.blockedSince,
226
+ source: item.source,
227
+ slot_kinds: item.slotKinds,
228
+ awaits_response: item.awaitsResponse,
229
+ state: item.state,
230
+ };
231
+ if (item.subtitle !== '')
232
+ dto.subtitle = item.subtitle;
233
+ return dto;
234
+ });
235
+ return { status: 200, body: { tickets } };
236
+ }
237
+ // ===========================================================================
238
+ // GET /v1/human/inbox/:ticket_id — read a page ticket
239
+ // ===========================================================================
240
+ function handleGetPage(ctx) {
252
241
  const ticketId = requireTicketId(ctx.params);
253
242
  const resolved = resolveTicket(ticketId);
254
243
  if (resolved === null)
255
244
  throw new ApiError(404, 'ticket_not_found', 'no matching ticket');
256
245
  if (resolved.kind === 'review') {
257
- throw new ApiError(409, 'ticket_kind_unsupported', 'review tickets have no v1 read operation');
258
- }
259
- if (readTicketResult(resolved.dir) !== null) {
260
- throw new ApiError(409, 'ticket_already_resolved', 'ticket was already resolved');
246
+ throw new ApiError(409, 'ticket_kind_unsupported', 'review tickets have no page read operation');
261
247
  }
262
- // A malformed stored ticket is omitted from enumeration by `scanInbox`; if
263
- // independently reached during a race (or a ticket is mutated on disk after
264
- // enumeration), `parseDeck` throws a plain Error whose message can carry a
265
- // `bodyPath` value or another host filesystem path (e.g. "bodyPath '/etc/
266
- // passwd' escapes the deck directory"). That message must never reach the
267
- // wire, so it is never allowed to fall through to the router's generic
268
- // mapper unsanitized — humanloop validation itself is never weakened to
269
- // serve unvalidated content, only its failure message is sanitized.
270
- let deck;
248
+ const productKinds = readConfig('user').page_components ?? [];
249
+ let ticket;
271
250
  try {
272
- deck = parseDeck(deckPath(resolved.dir));
251
+ ticket = readPageTicket(resolved.dir, productKinds);
273
252
  }
274
- catch {
253
+ catch (err) {
275
254
  throw new ApiError(500, 'internal', 'the ticket could not be read');
276
255
  }
277
- return { status: 200, body: { ticket_id: ticketId, kind: 'deck', deck: toDeckDTO(deck) } };
256
+ const progress = ticket.progress;
257
+ const progressDTO = progress === null ? null : { responses: progress };
258
+ return {
259
+ status: 200,
260
+ body: {
261
+ ticket_id: ticketId,
262
+ kind: 'page',
263
+ state: ticket.state,
264
+ page: pageManifestToDTO(ticket.manifest),
265
+ document: ticket.document,
266
+ document_media_type: ticket.manifest.dialect === 'md' ? 'text/markdown' : 'text/html',
267
+ progress: progressDTO,
268
+ },
269
+ };
278
270
  }
279
271
  // ===========================================================================
280
272
  // POST /v1/human/inbox/:ticket_id/respond — submit
281
273
  // ===========================================================================
282
- // The exhaustive set of messages the ticket store's response validator throws
283
- // for a genuine response-shape
284
- // rejection — duplicate/unknown interaction id, invalid single/multi-select
285
- // shape, disallowed freetext, invalid option comments, or a missing required
286
- // notify acknowledgement. None of these ever carry a filesystem path (only
287
- // client-supplied interaction ids), so their message is safe to forward
288
- // verbatim as `invalid_ticket_response`. Any other failure — a malformed
289
- // stored deck, a lock/dispatch/filesystem error — is not a response-shape
290
- // problem and must not be mislabeled or leak its cause; it is sanitized to
291
- // `500 internal`.
292
- const RESPONSE_VALIDATION_MESSAGE_PATTERNS = [
293
- /^response does not match a unique deck interaction: /,
294
- /^invalid single-select response for /,
295
- /^invalid multi-select response for /,
296
- /^freetext is not allowed for /,
297
- /^invalid option comments for /,
298
- /^notification requires an explicit acknowledgement: /,
299
- ];
300
- function isResponseValidationError(err) {
301
- return err instanceof Error && RESPONSE_VALIDATION_MESSAGE_PATTERNS.some((pattern) => pattern.test(err.message));
302
- }
303
- function coerceSingleSelect(responses, dir) {
304
- const deck = readJsonOrNull(deckPath(dir));
305
- if (deck === null)
306
- return responses;
307
- const multiSelect = new Map(deck.interactions.map((interaction) => [interaction.id, interaction.multiSelect === true]));
308
- return responses.map((response) => {
309
- if (multiSelect.get(response.id) === true || response.selectedOptionId !== undefined || response.selectedOptionIds?.length !== 1)
310
- return response;
311
- const { selectedOptionIds: _selectedOptionIds, ...rest } = response;
312
- return { ...rest, selectedOptionId: response.selectedOptionIds[0] };
313
- });
314
- }
315
274
  async function handleRespond(ctx) {
316
275
  const ticketId = requireTicketId(ctx.params);
317
276
  const request = validateRespondRequest(ctx.body);
@@ -319,30 +278,71 @@ async function handleRespond(ctx) {
319
278
  if (resolved === null)
320
279
  throw new ApiError(404, 'ticket_not_found', 'no matching ticket');
321
280
  if (resolved.kind === 'review') {
322
- throw new ApiError(409, 'ticket_kind_unsupported', 'review tickets submit through POST /v1/human/reviews/:id/submit');
281
+ throw new ApiError(409, 'ticket_kind_unsupported', 'review tickets submit through their own path');
323
282
  }
324
- if (readTicketResult(resolved.dir) !== null) {
283
+ const result = readTicketResult(resolved.dir);
284
+ if (result !== null && result.kind !== 'canceled') {
325
285
  throw new ApiError(409, 'ticket_already_resolved', 'ticket was already resolved');
326
286
  }
287
+ if (result?.kind === 'canceled') {
288
+ throw new ApiError(409, 'ticket_canceled', 'canceled tickets cannot be answered');
289
+ }
290
+ const productKinds = readConfig('user').page_components ?? [];
327
291
  try {
328
- const result = await resolveDeckTicket(resolved.nodeId, coerceSingleSelect(request.responses, resolved.dir));
329
- if (result.kind !== 'deck')
330
- throw new ApiError(409, 'ticket_already_resolved', 'ticket was already resolved');
331
- return { status: 200, body: result };
292
+ validatePageTicketResponses(resolved.dir, request.responses, productKinds);
293
+ }
294
+ catch (error) {
295
+ if (isSlotValidationError(error)) {
296
+ throw new ApiError(400, 'invalid_ticket_response', error.message);
297
+ }
298
+ throw new ApiError(500, 'internal', 'validation failed');
299
+ }
300
+ try {
301
+ // The shared completion path: it publishes the result and wakes the asking node.
302
+ const completed = await resolvePageTicket(resolved.nodeId, request.responses, productKinds);
303
+ if (completed.kind !== 'page') {
304
+ throw new ApiError(409, 'ticket_kind_mismatch', 'unexpected result kind');
305
+ }
306
+ return { status: 200, body: completed };
332
307
  }
333
308
  catch (error) {
334
- // The daemon's terminal errors are public protocol outcomes: preserve both
335
- // their code and message so Northlight can distinguish conflict from a
336
- // recorded-but-undelivered answer.
337
309
  if (error instanceof ApiError)
338
310
  throw error;
339
- if (isResponseValidationError(error)) {
311
+ if (isSlotValidationError(error)) {
340
312
  throw new ApiError(400, 'invalid_ticket_response', error.message);
341
313
  }
342
314
  throw new ApiError(500, 'internal', 'the ticket could not be finalized');
343
315
  }
344
316
  }
345
317
  // ===========================================================================
318
+ // POST /v1/human/inbox/:ticket_id/progress — autosave
319
+ // ===========================================================================
320
+ function handleProgress(ctx) {
321
+ const ticketId = requireTicketId(ctx.params);
322
+ const request = validateProgressRequest(ctx.body);
323
+ const resolved = resolveTicket(ticketId);
324
+ if (resolved === null)
325
+ throw new ApiError(404, 'ticket_not_found', 'no matching ticket');
326
+ if (resolved.kind === 'review') {
327
+ throw new ApiError(409, 'ticket_kind_unsupported', 'review tickets do not support progress autosave');
328
+ }
329
+ const result = readTicketResult(resolved.dir);
330
+ if (result !== null && result.kind !== 'canceled') {
331
+ throw new ApiError(409, 'ticket_already_resolved', 'ticket was already resolved');
332
+ }
333
+ const productKinds = readConfig('user').page_components ?? [];
334
+ try {
335
+ writePageProgress(resolved.dir, request.responses, productKinds);
336
+ return { status: 200, body: {} };
337
+ }
338
+ catch (error) {
339
+ if (isSlotValidationError(error)) {
340
+ throw new ApiError(400, 'invalid_ticket_response', error.message);
341
+ }
342
+ throw new ApiError(500, 'internal', 'progress could not be saved');
343
+ }
344
+ }
345
+ // ===========================================================================
346
346
  // POST /v1/human/inbox/:ticket_id/cancel — cancel
347
347
  // ===========================================================================
348
348
  async function handleCancel(ctx) {
@@ -351,26 +351,10 @@ async function handleCancel(ctx) {
351
351
  const resolved = resolveTicket(ticketId);
352
352
  if (resolved === null)
353
353
  throw new ApiError(404, 'ticket_not_found', 'no matching ticket');
354
+ if (resolved.kind === 'review') {
355
+ throw new ApiError(409, 'ticket_kind_unsupported', 'review tickets have their own cancel path');
356
+ }
354
357
  try {
355
- if (resolved.kind === 'review') {
356
- const review = getReviewByBridge(resolved.nodeId);
357
- if (review !== null) {
358
- if (review.state === 'approved') {
359
- throw new ApiError(409, 'ticket_already_resolved', 'ticket was already resolved');
360
- }
361
- const finished = await cancelReview(review.review_id, {
362
- reason: request.reason ?? DEFAULT_CANCEL_REASON,
363
- actor: 'human',
364
- });
365
- if (finished.outcome === 'already_settled') {
366
- throw new ApiError(409, 'ticket_already_resolved', 'ticket was already resolved');
367
- }
368
- if (finished.ticket_result?.kind !== 'canceled') {
369
- throw new Error('canonical review cancellation did not produce a ticket result');
370
- }
371
- return { status: 200, body: finished.ticket_result };
372
- }
373
- }
374
358
  const result = await cancelHumanTicket(resolved.nodeId, {
375
359
  reason: request.reason ?? DEFAULT_CANCEL_REASON,
376
360
  actor: 'human',
@@ -382,19 +366,119 @@ async function handleCancel(ctx) {
382
366
  catch (error) {
383
367
  if (error instanceof ApiError)
384
368
  throw error;
385
- // Review errors are canonical lifecycle vocabulary, not ticket protocol.
386
- // A concurrent terminal winner is the same public ticket conflict.
387
- if (error instanceof ReviewOperationError) {
388
- throw new ApiError(409, 'ticket_already_resolved', 'ticket was already resolved');
389
- }
390
- // A lock/filesystem/delivery failure is not a request-shape problem; do
391
- // not leak its cause through the generic router mapper.
392
369
  throw new ApiError(500, 'internal', 'the ticket could not be canceled');
393
370
  }
394
371
  }
372
+ // ===========================================================================
373
+ // GET /v1/human/inbox/:ticket_id/response — published response
374
+ // ===========================================================================
375
+ function handleGetResponse(ctx) {
376
+ const ticketId = requireTicketId(ctx.params);
377
+ const resolved = resolveTicket(ticketId);
378
+ if (resolved === null)
379
+ throw new ApiError(404, 'ticket_not_found', 'no matching ticket');
380
+ if (resolved.kind === 'review') {
381
+ throw new ApiError(409, 'ticket_kind_unsupported', 'review tickets fetch responses through their own path');
382
+ }
383
+ const result = readTicketResult(resolved.dir);
384
+ if (result === null) {
385
+ throw new ApiError(404, 'ticket_pending', 'ticket has no response yet');
386
+ }
387
+ if (result.kind === 'canceled') {
388
+ throw new ApiError(404, 'ticket_canceled', 'canceled tickets have no response');
389
+ }
390
+ if (result.kind !== 'page') {
391
+ throw new ApiError(409, 'ticket_kind_mismatch', 'unexpected result kind');
392
+ }
393
+ return { status: 200, body: result };
394
+ }
395
+ // ===========================================================================
396
+ // GET /v1/human/inbox/:ticket_id/render — the page's HTML payload
397
+ // ===========================================================================
398
+ function handleRenderPage(ctx) {
399
+ const ticketId = requireTicketId(ctx.params);
400
+ const resolved = resolveTicket(ticketId);
401
+ if (resolved === null)
402
+ throw new ApiError(404, 'ticket_not_found', 'no matching ticket');
403
+ if (resolved.kind === 'review') {
404
+ throw new ApiError(409, 'ticket_kind_unsupported', 'review tickets have no page render operation');
405
+ }
406
+ const productKinds = readConfig('user').page_components ?? [];
407
+ // Addressable for pending, resolved, and canceled tickets alike — the same
408
+ // read rule as the detail route: a page stays readable after it resolves.
409
+ let ticket;
410
+ try {
411
+ ticket = readPageTicket(resolved.dir, productKinds);
412
+ }
413
+ catch {
414
+ throw new ApiError(500, 'internal', 'the ticket could not be read');
415
+ }
416
+ try {
417
+ return { status: 200, body: { html: renderPageHtml(ticket.manifest, ticket.document, productKinds) } };
418
+ }
419
+ catch {
420
+ throw new ApiError(500, 'internal', 'the page could not be rendered');
421
+ }
422
+ }
423
+ // ===========================================================================
424
+ // GET /v1/human/pages/bundle.js|.css — the page runtime assets
425
+ // ===========================================================================
426
+ const BUNDLE_MEDIA_TYPES = {
427
+ 'bundle.js': 'text/javascript; charset=utf-8',
428
+ 'bundle.css': 'text/css; charset=utf-8',
429
+ };
430
+ const bundleCache = new Map();
431
+ /** Read one built bundle asset, reusing the cached bytes while the file on disk
432
+ * is unchanged. A runtime generation swap changes mtime, so the next request
433
+ * re-reads and re-hashes. */
434
+ function readBundleAsset(name) {
435
+ let path;
436
+ let stat;
437
+ try {
438
+ path = join(assetRoot(), 'pages', name);
439
+ stat = statSync(path);
440
+ }
441
+ catch {
442
+ throw new ApiError(500, 'page_bundle_unavailable', `${name} is not present in this runtime generation; the page runtime is built by \`npm run build\``);
443
+ }
444
+ const cached = bundleCache.get(name);
445
+ if (cached !== undefined && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size)
446
+ return cached;
447
+ const bytes = readFileSync(path);
448
+ const asset = {
449
+ mtimeMs: stat.mtimeMs,
450
+ size: stat.size,
451
+ bytes,
452
+ etag: `"${createHash('sha256').update(bytes).digest('hex').slice(0, 32)}"`,
453
+ };
454
+ bundleCache.set(name, asset);
455
+ return asset;
456
+ }
457
+ function etagMatches(header, etag) {
458
+ if (header === undefined)
459
+ return false;
460
+ return header.split(',').map((candidate) => candidate.trim()).some((candidate) => candidate === '*' || candidate === etag || candidate === `W/${etag}`);
461
+ }
462
+ function handleBundleAsset(name) {
463
+ return (ctx) => {
464
+ const asset = readBundleAsset(name);
465
+ const headers = { etag: asset.etag, 'cache-control': 'no-cache' };
466
+ const ifNoneMatch = ctx.req.headers['if-none-match'];
467
+ if (etagMatches(Array.isArray(ifNoneMatch) ? ifNoneMatch.join(',') : ifNoneMatch, asset.etag)) {
468
+ return { status: 304, headers };
469
+ }
470
+ return { status: 200, rawBody: asset.bytes, headers: { ...headers, 'content-type': BUNDLE_MEDIA_TYPES[name] } };
471
+ };
472
+ }
395
473
  export const inboxRoutes = [
474
+ { method: 'GET', pattern: '/v1/human/pages/bundle.js', handler: handleBundleAsset('bundle.js') },
475
+ { method: 'GET', pattern: '/v1/human/pages/bundle.css', handler: handleBundleAsset('bundle.css') },
396
476
  { method: 'GET', pattern: '/v1/human/inbox', handler: handleList },
397
- { method: 'GET', pattern: '/v1/human/inbox/:ticket_id', handler: handleGetDeck },
477
+ { method: 'GET', pattern: '/v1/human/inbox/history', handler: handleHistory },
478
+ { method: 'GET', pattern: '/v1/human/inbox/:ticket_id', handler: handleGetPage },
479
+ { method: 'GET', pattern: '/v1/human/inbox/:ticket_id/render', handler: handleRenderPage },
480
+ { method: 'GET', pattern: '/v1/human/inbox/:ticket_id/response', handler: handleGetResponse },
398
481
  { method: 'POST', pattern: '/v1/human/inbox/:ticket_id/respond', handler: handleRespond },
482
+ { method: 'POST', pattern: '/v1/human/inbox/:ticket_id/progress', handler: handleProgress },
399
483
  { method: 'POST', pattern: '/v1/human/inbox/:ticket_id/cancel', handler: handleCancel },
400
484
  ];