@north-light/crouter 0.3.179 → 0.3.180

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.
@@ -1,12 +1,86 @@
1
- // Ticket-inbox listing for the attached terminal viewer.
1
+ // Ticket inbox handlers — crtrd `/v1/human/inbox` (Northlight crouter-inbox
2
+ // v1, inbox-contract.md §A). This public ticket-serving resource enumerates,
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.
2
10
  import { createHash } from 'node:crypto';
3
- import { realpathSync } from 'node:fs';
11
+ import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs';
12
+ import { basename, resolve } from 'node:path';
13
+ 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';
4
17
  import { ticketsRoot } from '../../../core/human/root.js';
5
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';
6
24
  import { filterTerminalReviewTickets } from '../../../core/review/ticket-filter.js';
25
+ const DEFAULT_CANCEL_REASON = 'Canceled from the inbox.';
26
+ const CANCEL_REASON_MAX = 1000;
27
+ const TICKET_ID_PATTERN = /^[a-f0-9]{64}$/;
28
+ // ===========================================================================
29
+ // Opaque ticket id resolution
30
+ // ===========================================================================
31
+ /** Opaque ids hash the canonical derived tickets root and ticket basename. */
7
32
  function ticketHash(canonicalRoot, ticketBasename) {
8
33
  return createHash('sha256').update(`${canonicalRoot}\0${ticketBasename}`, 'utf8').digest('hex');
9
34
  }
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
+ function resolveTicket(ticketId) {
38
+ let canonicalRoot;
39
+ try {
40
+ canonicalRoot = realpathSync(ticketsRoot());
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ let entries;
46
+ try {
47
+ entries = readdirSync(canonicalRoot);
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ for (const entry of entries) {
53
+ if (ticketHash(canonicalRoot, entry) !== ticketId)
54
+ continue;
55
+ const candidate = resolve(canonicalRoot, entry);
56
+ let canonicalDir;
57
+ try {
58
+ if (!statSync(candidate).isDirectory())
59
+ continue;
60
+ canonicalDir = realpathSync(candidate);
61
+ }
62
+ catch {
63
+ continue;
64
+ }
65
+ if (resolve(canonicalDir, '..') !== canonicalRoot || basename(canonicalDir) !== entry)
66
+ continue;
67
+ if (existsSync(deckPath(canonicalDir)))
68
+ return { nodeId: entry, dir: canonicalDir, kind: 'deck' };
69
+ if (existsSync(reviewPath(canonicalDir)))
70
+ return { nodeId: entry, dir: canonicalDir, kind: 'review' };
71
+ }
72
+ return null;
73
+ }
74
+ function requireTicketId(params) {
75
+ const ticketId = params['ticket_id'];
76
+ if (ticketId === undefined || !TICKET_ID_PATTERN.test(ticketId)) {
77
+ throw new ApiError(400, 'invalid_request', 'invalid ticket id');
78
+ }
79
+ return ticketId;
80
+ }
81
+ // ===========================================================================
82
+ // Projections — humanloop canonical shapes → wire DTOs
83
+ // ===========================================================================
10
84
  function toDeckSourceDTO(source) {
11
85
  const dto = {};
12
86
  if (source.sessionName !== undefined)
@@ -17,6 +91,39 @@ function toDeckSourceDTO(source) {
17
91
  dto.blockedSince = source.blockedSince;
18
92
  return dto;
19
93
  }
94
+ function toOptionDTO(option) {
95
+ const dto = { id: option.id, label: option.label };
96
+ if (option.description !== undefined)
97
+ dto.description = option.description;
98
+ return dto;
99
+ }
100
+ function toInteractionDTO(interaction) {
101
+ const dto = {
102
+ id: interaction.id,
103
+ title: interaction.title,
104
+ subtitle: interaction.subtitle,
105
+ options: interaction.options.map(toOptionDTO),
106
+ };
107
+ if (interaction.body !== undefined)
108
+ dto.body = interaction.body;
109
+ if (interaction.multiSelect !== undefined)
110
+ dto.multiSelect = interaction.multiSelect;
111
+ if (interaction.allowFreetext !== undefined)
112
+ dto.allowFreetext = interaction.allowFreetext;
113
+ if (interaction.freetextLabel !== undefined)
114
+ dto.freetextLabel = interaction.freetextLabel;
115
+ if (interaction.kind !== undefined)
116
+ dto.kind = interaction.kind;
117
+ if (interaction.preAnswered !== undefined)
118
+ dto.preAnswered = interaction.preAnswered;
119
+ return dto;
120
+ }
121
+ function toDeckDTO(deck) {
122
+ const dto = { title: deck.title, interactions: deck.interactions.map(toInteractionDTO) };
123
+ if (deck.source !== undefined)
124
+ dto.source = toDeckSourceDTO(deck.source);
125
+ return dto;
126
+ }
20
127
  function toTicketSummaryDTO(item) {
21
128
  const ticket_id = ticketHash(realpathSync(ticketsRoot()), item.id);
22
129
  const source = toDeckSourceDTO(item.source);
@@ -43,14 +150,246 @@ function toTicketSummaryDTO(item) {
43
150
  dto.interaction_kind = item.interactionKind;
44
151
  return dto;
45
152
  }
153
+ // ===========================================================================
154
+ // Request-body validation — structural only. Semantic response↔deck
155
+ // validation (option ids, single/multi-select shape, freetext permission,
156
+ // option comments, required notify acknowledgement) belongs to the ticket
157
+ // store via the daemon finish path; a structural violation here is
158
+ // `invalid_request`, a semantic rejection there is `invalid_ticket_response`.
159
+ // ===========================================================================
160
+ function isPlainObject(value) {
161
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
162
+ }
163
+ function requireNoExtraKeys(obj, allowed) {
164
+ for (const key of Object.keys(obj)) {
165
+ if (!allowed.includes(key))
166
+ throw new ApiError(400, 'invalid_request', `unexpected field: ${key}`);
167
+ }
168
+ }
169
+ const RESPONSE_FIELDS = ['id', 'selectedOptionId', 'selectedOptionIds', 'freetext', 'optionComments'];
170
+ function validateInteractionResponse(raw) {
171
+ if (!isPlainObject(raw))
172
+ throw new ApiError(400, 'invalid_request', 'each response must be an object');
173
+ requireNoExtraKeys(raw, RESPONSE_FIELDS);
174
+ const { id, selectedOptionId, selectedOptionIds, freetext, optionComments } = raw;
175
+ if (typeof id !== 'string' || id === '') {
176
+ throw new ApiError(400, 'invalid_request', 'response.id must be a nonempty string');
177
+ }
178
+ const response = { id };
179
+ if (selectedOptionId !== undefined) {
180
+ if (typeof selectedOptionId !== 'string') {
181
+ throw new ApiError(400, 'invalid_request', 'response.selectedOptionId must be a string');
182
+ }
183
+ response.selectedOptionId = selectedOptionId;
184
+ }
185
+ if (selectedOptionIds !== undefined) {
186
+ if (!Array.isArray(selectedOptionIds) || !selectedOptionIds.every((v) => typeof v === 'string')) {
187
+ throw new ApiError(400, 'invalid_request', 'response.selectedOptionIds must be a string array');
188
+ }
189
+ response.selectedOptionIds = selectedOptionIds;
190
+ }
191
+ if (freetext !== undefined) {
192
+ if (typeof freetext !== 'string')
193
+ throw new ApiError(400, 'invalid_request', 'response.freetext must be a string');
194
+ response.freetext = freetext;
195
+ }
196
+ if (optionComments !== undefined) {
197
+ if (!isPlainObject(optionComments) || !Object.values(optionComments).every((v) => typeof v === 'string')) {
198
+ throw new ApiError(400, 'invalid_request', 'response.optionComments must be a string map');
199
+ }
200
+ response.optionComments = optionComments;
201
+ }
202
+ return response;
203
+ }
204
+ function validateRespondRequest(raw) {
205
+ if (!isPlainObject(raw))
206
+ throw new ApiError(400, 'invalid_request', 'respond body is required');
207
+ requireNoExtraKeys(raw, ['responses']);
208
+ if (!Array.isArray(raw['responses']))
209
+ throw new ApiError(400, 'invalid_request', '`responses` must be an array');
210
+ return { responses: raw['responses'].map(validateInteractionResponse) };
211
+ }
212
+ function validateCancelRequest(raw) {
213
+ if (raw === undefined)
214
+ return {};
215
+ if (!isPlainObject(raw))
216
+ throw new ApiError(400, 'invalid_request', 'cancel body must be an object');
217
+ requireNoExtraKeys(raw, ['reason']);
218
+ if (raw['reason'] === undefined)
219
+ return {};
220
+ const reason = raw['reason'];
221
+ if (typeof reason !== 'string')
222
+ throw new ApiError(400, 'invalid_request', '`reason` must be a string');
223
+ if (reason.trim() === '' || reason.length > CANCEL_REASON_MAX) {
224
+ throw new ApiError(400, 'invalid_request', '`reason` must be nonempty after trim and at most 1000 characters');
225
+ }
226
+ return { reason };
227
+ }
228
+ // ===========================================================================
229
+ // GET /v1/human/inbox — enumerate
230
+ // ===========================================================================
46
231
  function handleList() {
47
232
  const items = filterTerminalReviewTickets(scanInbox());
233
+ // `blocked_since` is an RFC 3339 timestamp that may carry a non-`Z` offset;
234
+ // two offsets are not lexically comparable (`+14:00` can sort after `Z`
235
+ // while naming an earlier instant), so sort by parsed instant and use the
236
+ // ticket id only to break an exact tie.
48
237
  const tickets = items.map(toTicketSummaryDTO).sort((a, b) => {
49
238
  const byTime = Date.parse(b.blocked_since) - Date.parse(a.blocked_since);
50
239
  return byTime !== 0 ? byTime : a.ticket_id.localeCompare(b.ticket_id);
51
240
  });
52
241
  return { status: 200, body: { tickets } };
53
242
  }
243
+ // ===========================================================================
244
+ // GET /v1/human/inbox/:ticket_id — read a pending deck
245
+ // ===========================================================================
246
+ function handleGetDeck(ctx) {
247
+ const ticketId = requireTicketId(ctx.params);
248
+ const resolved = resolveTicket(ticketId);
249
+ if (resolved === null)
250
+ throw new ApiError(404, 'ticket_not_found', 'no matching ticket');
251
+ if (resolved.kind === 'review') {
252
+ throw new ApiError(409, 'ticket_kind_unsupported', 'review tickets have no v1 read operation');
253
+ }
254
+ if (readTicketResult(resolved.dir) !== null) {
255
+ throw new ApiError(409, 'ticket_already_resolved', 'ticket was already resolved');
256
+ }
257
+ // A malformed stored ticket is omitted from enumeration by `scanInbox`; if
258
+ // independently reached during a race (or a ticket is mutated on disk after
259
+ // enumeration), `parseDeck` throws a plain Error whose message can carry a
260
+ // `bodyPath` value or another host filesystem path (e.g. "bodyPath '/etc/
261
+ // passwd' escapes the deck directory"). That message must never reach the
262
+ // wire, so it is never allowed to fall through to the router's generic
263
+ // mapper unsanitized — humanloop validation itself is never weakened to
264
+ // serve unvalidated content, only its failure message is sanitized.
265
+ let deck;
266
+ try {
267
+ deck = parseDeck(deckPath(resolved.dir));
268
+ }
269
+ catch {
270
+ throw new ApiError(500, 'internal', 'the ticket could not be read');
271
+ }
272
+ return { status: 200, body: { ticket_id: ticketId, kind: 'deck', deck: toDeckDTO(deck) } };
273
+ }
274
+ // ===========================================================================
275
+ // POST /v1/human/inbox/:ticket_id/respond — submit
276
+ // ===========================================================================
277
+ // The exhaustive set of messages the ticket store's response validator throws
278
+ // for a genuine response-shape
279
+ // rejection — duplicate/unknown interaction id, invalid single/multi-select
280
+ // shape, disallowed freetext, invalid option comments, or a missing required
281
+ // notify acknowledgement. None of these ever carry a filesystem path (only
282
+ // client-supplied interaction ids), so their message is safe to forward
283
+ // verbatim as `invalid_ticket_response`. Any other failure — a malformed
284
+ // stored deck, a lock/dispatch/filesystem error — is not a response-shape
285
+ // problem and must not be mislabeled or leak its cause; it is sanitized to
286
+ // `500 internal`.
287
+ const RESPONSE_VALIDATION_MESSAGE_PATTERNS = [
288
+ /^response does not match a unique deck interaction: /,
289
+ /^invalid single-select response for /,
290
+ /^invalid multi-select response for /,
291
+ /^freetext is not allowed for /,
292
+ /^invalid option comments for /,
293
+ /^notification requires an explicit acknowledgement: /,
294
+ ];
295
+ function isResponseValidationError(err) {
296
+ return err instanceof Error && RESPONSE_VALIDATION_MESSAGE_PATTERNS.some((pattern) => pattern.test(err.message));
297
+ }
298
+ function coerceSingleSelect(responses, dir) {
299
+ const deck = readJsonOrNull(deckPath(dir));
300
+ if (deck === null)
301
+ return responses;
302
+ const multiSelect = new Map(deck.interactions.map((interaction) => [interaction.id, interaction.multiSelect === true]));
303
+ return responses.map((response) => {
304
+ if (multiSelect.get(response.id) === true || response.selectedOptionId !== undefined || response.selectedOptionIds?.length !== 1)
305
+ return response;
306
+ const { selectedOptionIds: _selectedOptionIds, ...rest } = response;
307
+ return { ...rest, selectedOptionId: response.selectedOptionIds[0] };
308
+ });
309
+ }
310
+ async function handleRespond(ctx) {
311
+ const ticketId = requireTicketId(ctx.params);
312
+ const request = validateRespondRequest(ctx.body);
313
+ const resolved = resolveTicket(ticketId);
314
+ if (resolved === null)
315
+ throw new ApiError(404, 'ticket_not_found', 'no matching ticket');
316
+ if (resolved.kind === 'review') {
317
+ throw new ApiError(409, 'ticket_kind_unsupported', 'review tickets submit through POST /v1/human/reviews/:id/submit');
318
+ }
319
+ if (readTicketResult(resolved.dir) !== null) {
320
+ throw new ApiError(409, 'ticket_already_resolved', 'ticket was already resolved');
321
+ }
322
+ try {
323
+ const result = await resolveDeckTicket(resolved.nodeId, coerceSingleSelect(request.responses, resolved.dir));
324
+ if (result.kind !== 'deck')
325
+ throw new ApiError(409, 'ticket_already_resolved', 'ticket was already resolved');
326
+ return { status: 200, body: result };
327
+ }
328
+ catch (error) {
329
+ // The daemon's terminal errors are public protocol outcomes: preserve both
330
+ // their code and message so Northlight can distinguish conflict from a
331
+ // recorded-but-undelivered answer.
332
+ if (error instanceof ApiError)
333
+ throw error;
334
+ if (isResponseValidationError(error)) {
335
+ throw new ApiError(400, 'invalid_ticket_response', error.message);
336
+ }
337
+ throw new ApiError(500, 'internal', 'the ticket could not be finalized');
338
+ }
339
+ }
340
+ // ===========================================================================
341
+ // POST /v1/human/inbox/:ticket_id/cancel — cancel
342
+ // ===========================================================================
343
+ async function handleCancel(ctx) {
344
+ const ticketId = requireTicketId(ctx.params);
345
+ const request = validateCancelRequest(ctx.body);
346
+ const resolved = resolveTicket(ticketId);
347
+ if (resolved === null)
348
+ throw new ApiError(404, 'ticket_not_found', 'no matching ticket');
349
+ try {
350
+ if (resolved.kind === 'review') {
351
+ const review = getReviewByBridge(resolved.nodeId);
352
+ if (review !== null) {
353
+ if (review.state === 'approved') {
354
+ throw new ApiError(409, 'ticket_already_resolved', 'ticket was already resolved');
355
+ }
356
+ const finished = await cancelReview(review.review_id, {
357
+ reason: request.reason ?? DEFAULT_CANCEL_REASON,
358
+ actor: 'human',
359
+ });
360
+ if (finished.outcome === 'already_settled') {
361
+ throw new ApiError(409, 'ticket_already_resolved', 'ticket was already resolved');
362
+ }
363
+ if (finished.ticket_result?.kind !== 'canceled') {
364
+ throw new Error('canonical review cancellation did not produce a ticket result');
365
+ }
366
+ return { status: 200, body: finished.ticket_result };
367
+ }
368
+ }
369
+ const result = await cancelHumanTicket(resolved.nodeId, {
370
+ reason: request.reason ?? DEFAULT_CANCEL_REASON,
371
+ actor: 'human',
372
+ });
373
+ if (result.kind !== 'canceled')
374
+ throw new ApiError(409, 'ticket_already_resolved', 'ticket was already resolved');
375
+ return { status: 200, body: result };
376
+ }
377
+ catch (error) {
378
+ if (error instanceof ApiError)
379
+ throw error;
380
+ // Review errors are canonical lifecycle vocabulary, not ticket protocol.
381
+ // A concurrent terminal winner is the same public ticket conflict.
382
+ if (error instanceof ReviewOperationError) {
383
+ throw new ApiError(409, 'ticket_already_resolved', 'ticket was already resolved');
384
+ }
385
+ // A lock/filesystem/delivery failure is not a request-shape problem; do
386
+ // not leak its cause through the generic router mapper.
387
+ throw new ApiError(500, 'internal', 'the ticket could not be canceled');
388
+ }
389
+ }
54
390
  export const inboxRoutes = [
55
391
  { method: 'GET', pattern: '/v1/human/inbox', handler: handleList },
392
+ { method: 'GET', pattern: '/v1/human/inbox/:ticket_id', handler: handleGetDeck },
393
+ { method: 'POST', pattern: '/v1/human/inbox/:ticket_id/respond', handler: handleRespond },
394
+ { method: 'POST', pattern: '/v1/human/inbox/:ticket_id/cancel', handler: handleCancel },
56
395
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.179",
3
+ "version": "0.3.180",
4
4
  "description": "crtr — agent runtime with memory, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/runtime.lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.179",
3
+ "version": "0.3.180",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@north-light/crouter",
9
- "version": "0.3.179",
9
+ "version": "0.3.180",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT",
12
12
  "dependencies": {