@north-light/crouter 0.3.192 → 0.3.194
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.
- package/dist/api/client.d.ts +31 -10
- package/dist/api/client.js +83 -24
- package/dist/api/dto/bash-jobs.d.ts +24 -0
- package/dist/api/dto/bash-jobs.js +9 -0
- package/dist/api/dto/human.d.ts +3 -4
- package/dist/api/dto/inbox.d.ts +106 -65
- package/dist/api/dto/inbox.js +2 -8
- package/dist/api/index.d.ts +1 -0
- package/dist/api/index.js +1 -0
- package/dist/api/routes.d.ts +7 -0
- package/dist/api/routes.js +7 -0
- package/dist/builtin-memory/02-lifecycle/01-resident.md +1 -1
- package/dist/builtin-memory/internal/examples/imessage-assistant.md +1 -1
- package/dist/builtin-memory/internal/nodes-and-canvas.md +2 -2
- package/dist/builtin-memory/internal/storage-tiers.md +1 -1
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/serial/provider-rotation.test.ts +101 -3
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.d.ts +6 -2
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +68 -14
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +87 -13
- package/dist/clients/attach/__tests__/chat-view-snapshot-ordering.test.js +2 -2
- package/dist/clients/attach/__tests__/group-activity.test.js +2 -2
- package/dist/clients/attach/chrome/canvas-panels.d.ts +1 -1
- package/dist/clients/attach/chrome/canvas-panels.js +1 -1
- package/dist/clients/attach/input/overlay-owner.js +1 -1
- package/dist/clients/attach/overlays/auth.js +34 -8
- package/dist/clients/attach/overlays/pickers.d.ts +5 -8
- package/dist/clients/attach/overlays/pickers.js +37 -43
- package/dist/clients/attach/viewer.js +643 -620
- package/dist/clients/inbox/__tests__/serial/inbox-controller.test.js +38 -128
- package/dist/clients/inbox/__tests__/serial/mount-panel.test.js +72 -148
- package/dist/clients/inbox/controller.d.ts +2 -3
- package/dist/clients/inbox/controller.js +41 -42
- package/dist/clients/inbox/page-adapter.d.ts +33 -0
- package/dist/clients/inbox/page-adapter.js +54 -0
- package/dist/clients/inbox/resolve.d.ts +2 -2
- package/dist/clients/inbox/resolve.js +1 -1
- package/dist/clients/inbox/review-visibility.d.ts +1 -1
- package/dist/clients/inbox/review-visibility.js +1 -1
- package/dist/clients/inbox/tui/ansi.d.ts +1 -1
- package/dist/clients/inbox/tui/ansi.js +2 -2
- package/dist/clients/inbox/tui/input.d.ts +1 -3
- package/dist/clients/inbox/tui/input.js +242 -553
- package/dist/clients/inbox/tui/panel.d.ts +3 -1
- package/dist/clients/inbox/tui/panel.js +59 -187
- package/dist/clients/inbox/tui/render.d.ts +0 -25
- package/dist/clients/inbox/tui/render.js +165 -416
- package/dist/clients/inbox/tui/slots.d.ts +32 -0
- package/dist/clients/inbox/tui/slots.js +156 -0
- package/dist/clients/inbox/tui/types.d.ts +25 -13
- package/dist/clients/inbox/tui.js +5 -3
- package/dist/commands/attention.js +2 -2
- package/dist/commands/human/doc.js +66 -20
- package/dist/commands/human/prompts.d.ts +3 -3
- package/dist/commands/human/prompts.js +158 -112
- package/dist/commands/human/queue.d.ts +0 -1
- package/dist/commands/human/queue.js +34 -94
- package/dist/commands/human/shared.d.ts +10 -4
- package/dist/commands/human/shared.js +62 -13
- package/dist/commands/human.js +8 -4
- package/dist/commands/memory/read.js +13 -5
- package/dist/commands/node/bash.js +18 -30
- package/dist/commands/node/lifecycle.js +2 -2
- package/dist/commands/surface/node/focus.js +1 -1
- package/dist/core/__tests__/human-deliver.test.js +40 -46
- package/dist/core/__tests__/seam/broker-attach-stream.test.js +7 -1
- package/dist/core/__tests__/seam/dormancy-release.test.js +64 -10
- package/dist/core/__tests__/serial/human-deliver-e2e.test.js +40 -31
- package/dist/core/__tests__/serial/spawn-root.test.js +3 -1
- package/dist/core/__tests__/spawn-worktree-id-conflict.test.js +3 -1
- package/dist/core/auth-interaction.d.ts +2 -2
- package/dist/core/bash-jobs.d.ts +43 -0
- package/dist/core/bash-jobs.js +96 -2
- package/dist/core/canvas/__tests__/attention.test.js +16 -10
- package/dist/core/canvas/__tests__/render-remote.test.js +9 -3
- package/dist/core/canvas/attention.js +1 -1
- package/dist/core/canvas/browse/app.js +1 -1
- package/dist/core/canvas/nav-model.d.ts +1 -1
- package/dist/core/canvas/nav-model.js +1 -1
- package/dist/core/canvas/remote-canvas-source.js +1 -1
- package/dist/core/canvas/types.d.ts +2 -1
- package/dist/core/config.js +4 -1
- package/dist/core/human/__tests__/page-render.test.d.ts +1 -0
- package/dist/core/human/__tests__/page-render.test.js +49 -0
- package/dist/core/human/__tests__/page-tickets.test.d.ts +1 -0
- package/dist/core/human/__tests__/page-tickets.test.js +112 -0
- package/dist/core/human/__tests__/page.test.d.ts +1 -0
- package/dist/core/human/__tests__/page.test.js +139 -0
- package/dist/core/human/__tests__/serial/inbox-core.test.js +42 -32
- package/dist/core/human/claim.js +2 -2
- package/dist/core/human/convention.d.ts +4 -4
- package/dist/core/human/convention.js +6 -13
- package/dist/core/human/markdown-html.d.ts +8 -0
- package/dist/core/human/markdown-html.js +581 -0
- package/dist/core/human/page-catalog.d.ts +6 -0
- package/dist/core/human/page-catalog.js +43 -0
- package/dist/core/human/page-render.d.ts +3 -0
- package/dist/core/human/page-render.js +131 -0
- package/dist/core/human/page-schema.d.ts +312 -0
- package/dist/core/human/page-schema.js +291 -0
- package/dist/core/human/page-synth.d.ts +9 -0
- package/dist/core/human/page-synth.js +17 -0
- package/dist/core/human/page.d.ts +22 -0
- package/dist/core/human/page.js +427 -0
- package/dist/core/human/review-schema.d.ts +19 -0
- package/dist/core/human/review-schema.js +34 -0
- package/dist/core/human/scan.d.ts +12 -3
- package/dist/core/human/scan.js +69 -26
- package/dist/core/human/summary.d.ts +5 -6
- package/dist/core/human/summary.js +54 -37
- package/dist/core/human/tickets.d.ts +61 -11
- package/dist/core/human/tickets.js +100 -57
- package/dist/core/human/types.d.ts +21 -66
- package/dist/core/human/types.js +1 -5
- package/dist/core/inspector/model.d.ts +5 -0
- package/dist/core/inspector/model.js +5 -0
- package/dist/core/inspector/text.js +2 -2
- package/dist/core/inspector/tui.js +10 -3
- package/dist/core/keybindings/catalog.d.ts +1 -1
- package/dist/core/keybindings/catalog.js +4 -2
- package/dist/core/preview-registry.js +0 -1
- package/dist/core/review/ticket-filter.d.ts +1 -1
- package/dist/core/review/ticket-filter.js +2 -8
- package/dist/core/runtime/broker/read-ops.js +3 -0
- package/dist/core/runtime/broker.js +19 -10
- package/dist/core/runtime/package-health.js +1 -1
- package/dist/core/runtime/persona.js +2 -1
- package/dist/core/runtime/tmux-bindings.js +8 -1
- package/dist/core/termrender/termrender.d.ts +1 -1
- package/dist/core/termrender/termrender.js +2 -2
- package/dist/core/termrender/version.d.ts +1 -1
- package/dist/core/termrender/version.js +1 -1
- package/dist/core/tui/page-host.d.ts +1 -1
- package/dist/core/tui/page-host.js +2 -2
- package/dist/core/user-settings.d.ts +19 -0
- package/dist/core/user-settings.js +22 -0
- package/dist/daemon/api/handlers/bash-jobs.d.ts +2 -0
- package/dist/daemon/api/handlers/bash-jobs.js +90 -0
- package/dist/daemon/api/handlers/human.js +16 -14
- package/dist/daemon/api/handlers/inbox.js +311 -227
- package/dist/daemon/api/router.d.ts +5 -1
- package/dist/daemon/api/router.js +5 -0
- package/dist/daemon/api/server.js +2 -0
- package/dist/daemon/cron-run.js +23 -10
- package/dist/daemon/human/finish.d.ts +8 -3
- package/dist/daemon/human/finish.js +35 -15
- package/dist/daemon/reconcilers/broker-supervision.js +7 -1
- package/dist/daemon/reconcilers/dormant-inbox.js +8 -0
- package/dist/daemon/reconcilers/live-obligation.d.ts +2 -0
- package/dist/daemon/reconcilers/live-obligation.js +11 -0
- package/dist/pages/bundle.css +1 -0
- package/dist/pages/bundle.js +1347 -0
- package/dist/pages/comments.d.ts +34 -0
- package/dist/pages/comments.js +72 -0
- package/dist/pages/elements/cards.d.ts +17 -0
- package/dist/pages/elements/cards.js +785 -0
- package/dist/pages/elements/chart.d.ts +1 -0
- package/dist/pages/elements/chart.js +648 -0
- package/dist/pages/elements/options.d.ts +21 -0
- package/dist/pages/elements/options.js +604 -0
- package/dist/pages/elements/pages.d.ts +24 -0
- package/dist/pages/elements/pages.js +355 -0
- package/dist/pages/elements/slot.d.ts +21 -0
- package/dist/pages/elements/slot.js +105 -0
- package/dist/pages/elements/table.d.ts +19 -0
- package/dist/pages/elements/table.js +906 -0
- package/dist/pages/elements/text.d.ts +25 -0
- package/dist/pages/elements/text.js +951 -0
- package/dist/pages/entry.d.ts +8 -0
- package/dist/pages/entry.js +8 -0
- package/dist/pages/host.d.ts +132 -0
- package/dist/pages/host.js +149 -0
- package/dist/pages/register.d.ts +2 -0
- package/dist/pages/register.js +3 -0
- package/dist/pages/slot-config.d.ts +36 -0
- package/dist/pages/slot-config.js +50 -0
- package/dist/pages/types.d.ts +98 -0
- package/dist/pages/types.js +23 -0
- package/dist/pi-extensions/canvas-bash-valve.d.ts +4 -2
- package/dist/pi-extensions/canvas-bash-valve.js +25 -7
- package/dist/types.d.ts +4 -0
- package/dist/types.js +2 -0
- package/package.json +8 -8
- package/runtime.lock.json +156 -101
- package/scripts/install-runtime.mjs +172 -5
- package/dist/clients/inbox/deck-adapter.d.ts +0 -29
- package/dist/clients/inbox/deck-adapter.js +0 -63
- package/dist/core/human/deck-factories.d.ts +0 -8
- package/dist/core/human/deck-factories.js +0 -15
- package/dist/core/human/deck-schema.d.ts +0 -70
- 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 {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
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 {
|
|
21
|
-
import {
|
|
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(
|
|
68
|
-
return { nodeId: entry, dir: canonicalDir, kind: '
|
|
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
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
options: interaction.options.map(toOptionDTO),
|
|
75
|
+
kind: slot.kind,
|
|
76
|
+
step: slot.step,
|
|
77
|
+
config: slot.config,
|
|
111
78
|
};
|
|
112
|
-
if (
|
|
113
|
-
dto.
|
|
114
|
-
if (
|
|
115
|
-
dto.
|
|
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
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
title:
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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 (
|
|
155
|
-
dto.
|
|
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 (!
|
|
214
|
-
throw new ApiError(400, 'invalid_request', '`responses` must be an
|
|
215
|
-
return { responses: raw['responses']
|
|
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
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
|
206
|
+
// GET /v1/human/inbox/history?node_id=<id> — page ticket history
|
|
250
207
|
// ===========================================================================
|
|
251
|
-
function
|
|
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
|
|
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
|
-
|
|
263
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
281
|
+
throw new ApiError(409, 'ticket_kind_unsupported', 'review tickets submit through their own path');
|
|
323
282
|
}
|
|
324
|
-
|
|
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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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 (
|
|
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
|
|
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
|
];
|