@north-light/crouter 0.3.221 → 0.3.222
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 +21 -1
- package/dist/api/client.js +34 -0
- package/dist/api/dto/chat-inventory.d.ts +13 -0
- package/dist/api/dto/human-requests.d.ts +88 -0
- package/dist/api/dto/human-requests.js +4 -0
- package/dist/api/dto/human.d.ts +3 -0
- package/dist/api/dto/reviews.d.ts +2 -0
- 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 +10 -0
- package/dist/builtin-memory/00-runtime-base/00-authoring.md +31 -0
- package/dist/builtin-memory/00-runtime-base/01-escalation.md +14 -0
- package/dist/builtin-memory/{insights/listen.md → 00-runtime-base/02-insight-capture.md} +1 -0
- package/dist/builtin-memory/02-turn-lifecycle/00-ending-a-turn.md +27 -0
- package/dist/builtin-memory/{02-lifecycle/01-resident.md → 02-turn-lifecycle/02-resident.md} +5 -0
- package/dist/builtin-memory/04-base-worker.md +4 -8
- package/dist/builtin-memory/04-orchestration-kernel.md +1 -1
- package/dist/builtin-memory/05-kinds/advisor/01-orchestrator.md +1 -0
- package/dist/builtin-memory/05-kinds/advisor/advice-contract.md +1 -0
- package/dist/builtin-memory/05-kinds/design/00-base.md +2 -1
- package/dist/builtin-memory/05-kinds/design/01-orchestrator.md +2 -1
- package/dist/builtin-memory/05-kinds/design/design-contract.md +19 -0
- package/dist/builtin-memory/05-kinds/developer/00-base.md +1 -0
- package/dist/builtin-memory/05-kinds/developer/01-orchestrator.md +1 -0
- package/dist/builtin-memory/05-kinds/explore/00-base.md +1 -0
- package/dist/builtin-memory/05-kinds/explore/01-orchestrator.md +1 -0
- package/dist/builtin-memory/05-kinds/general/00-base.md +1 -0
- package/dist/builtin-memory/05-kinds/plan/00-base.md +2 -1
- package/dist/builtin-memory/05-kinds/plan/01-orchestrator.md +2 -1
- package/dist/builtin-memory/05-kinds/plan/plan-contract.md +28 -0
- package/dist/builtin-memory/05-kinds/plan/reviewers/architecture-fit.md +1 -0
- package/dist/builtin-memory/05-kinds/plan/reviewers/code-smells.md +1 -0
- package/dist/builtin-memory/05-kinds/plan/reviewers/lens-contract.md +1 -0
- package/dist/builtin-memory/05-kinds/plan/reviewers/pattern-consistency.md +1 -0
- package/dist/builtin-memory/05-kinds/plan/reviewers/requirements-coverage.md +1 -0
- package/dist/builtin-memory/05-kinds/plan/reviewers/security.md +1 -0
- package/dist/builtin-memory/05-kinds/review/00-base.md +1 -0
- package/dist/builtin-memory/05-kinds/review/01-orchestrator.md +1 -0
- package/dist/builtin-memory/05-kinds/review/companion/00-base.md +1 -0
- package/dist/builtin-memory/05-kinds/review/security-findings.md +1 -0
- package/dist/builtin-memory/05-kinds/spec/00-base.md +4 -3
- package/dist/builtin-memory/05-kinds/spec/01-orchestrator.md +1 -0
- package/dist/builtin-memory/05-kinds/spec/requirements.md +1 -0
- package/dist/builtin-memory/design/guide.md +35 -0
- package/dist/builtin-memory/design/roadmap.md +21 -0
- package/dist/builtin-memory/insights/capture.md +1 -1
- package/dist/builtin-memory/internal/plugins.md +10 -1
- package/dist/builtin-memory/internal/storage-tiers.md +1 -1
- package/dist/builtin-memory/plan/roadmap.md +6 -28
- package/dist/builtin-memory/spec/guide.md +19 -8
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/memory-slash-commands.ts +28 -15
- package/dist/clients/attach/render/markdown-source.js +106 -1
- package/dist/clients/attach/session/file-links.d.ts +13 -4
- package/dist/clients/attach/session/file-links.js +54 -58
- package/dist/clients/attach/viewer.js +525 -523
- package/dist/clients/inbox/controller.js +1 -1
- package/dist/clients/inbox/resolve.d.ts +1 -0
- package/dist/clients/inbox/review/review-client.js +3 -1
- package/dist/commands/__tests__/human.test.js +2 -2
- package/dist/commands/human/request.d.ts +2 -0
- package/dist/commands/human/request.js +281 -0
- package/dist/commands/human.js +5 -2
- package/dist/commands/sys/config.js +2 -2
- package/dist/commands/sys/doctor.js +54 -2
- package/dist/core/__tests__/broker-extension-canvas-db-boundary.test.js +7 -4
- package/dist/core/__tests__/fixtures/memory-slash-live-probe.d.ts +1 -0
- package/dist/core/__tests__/fixtures/memory-slash-live-probe.js +71 -0
- package/dist/core/__tests__/human-action-delivery.test.d.ts +1 -0
- package/dist/core/__tests__/human-action-delivery.test.js +140 -0
- package/dist/core/__tests__/human-actions.test.d.ts +1 -0
- package/dist/core/__tests__/human-actions.test.js +116 -0
- package/dist/core/__tests__/inline-memory-refs.test.js +1 -1
- package/dist/core/__tests__/profile-project-memory-delivery.test.js +1 -1
- package/dist/core/__tests__/prospective-inventory-capability-parity.test.d.ts +1 -0
- package/dist/core/__tests__/prospective-inventory-capability-parity.test.js +91 -0
- package/dist/core/__tests__/seam/memory-slash-node-relative-inventory.test.d.ts +1 -0
- package/dist/core/__tests__/seam/memory-slash-node-relative-inventory.test.js +127 -0
- package/dist/core/__tests__/seam/prospective-inventory-stdout.test.d.ts +1 -0
- package/dist/core/__tests__/seam/prospective-inventory-stdout.test.js +31 -0
- package/dist/core/canvas/db.js +23 -0
- package/dist/core/canvas/human-deliveries.d.ts +53 -0
- package/dist/core/canvas/human-deliveries.js +75 -0
- package/dist/core/config.d.ts +13 -1
- package/dist/core/config.js +51 -1
- package/dist/core/feed/inbox.d.ts +6 -0
- package/dist/core/feed/inbox.js +9 -1
- package/dist/core/human/action-binding.d.ts +21 -0
- package/dist/core/human/action-binding.js +40 -0
- package/dist/core/human/completion.d.ts +38 -0
- package/dist/core/human/completion.js +27 -0
- package/dist/core/human/convention.d.ts +2 -0
- package/dist/core/human/convention.js +2 -0
- package/dist/core/human/tickets.d.ts +25 -6
- package/dist/core/human/tickets.js +19 -13
- package/dist/core/human/types.d.ts +5 -0
- package/dist/core/human-actions.d.ts +25 -0
- package/dist/core/human-actions.js +101 -0
- package/dist/core/memory-resolver.js +1 -1
- package/dist/core/profiles/select.d.ts +2 -0
- package/dist/core/profiles/select.js +21 -4
- package/dist/core/runtime/broker/frame-dispatch.js +2 -5
- package/dist/core/runtime/broker-inventory.d.ts +1 -2
- package/dist/core/runtime/broker-inventory.js +2 -77
- package/dist/core/runtime/broker-persona-guidance.js +1 -1
- package/dist/core/runtime/broker.js +4 -4
- package/dist/core/runtime/chat-inventory-rows.d.ts +8 -0
- package/dist/core/runtime/chat-inventory-rows.js +105 -0
- package/dist/core/runtime/command-surface.d.ts +8 -3
- package/dist/core/runtime/command-surface.js +42 -6
- package/dist/core/runtime/launch-target.d.ts +25 -0
- package/dist/core/runtime/launch-target.js +54 -0
- package/dist/core/runtime/persona.js +3 -3
- package/dist/core/runtime/prospective-inventory-cli.d.ts +1 -0
- package/dist/core/runtime/prospective-inventory-cli.js +61 -0
- package/dist/core/runtime/prospective-inventory.d.ts +10 -0
- package/dist/core/runtime/prospective-inventory.js +88 -0
- package/dist/core/runtime/spawn.d.ts +3 -1
- package/dist/core/runtime/spawn.js +5 -3
- package/dist/core/substrate/on-read.js +17 -28
- package/dist/core/substrate/render-node.d.ts +3 -2
- package/dist/core/substrate/render-node.js +3 -2
- package/dist/core/substrate/render.js +51 -19
- package/dist/core/substrate/schema.d.ts +5 -1
- package/dist/core/substrate/schema.js +4 -4
- package/dist/core/user-settings.d.ts +4 -0
- package/dist/core/user-settings.js +1 -0
- package/dist/daemon/api/__tests__/profile-launch-gates.test.js +52 -3
- package/dist/daemon/api/handlers/human-requests.d.ts +2 -0
- package/dist/daemon/api/handlers/human-requests.js +409 -0
- package/dist/daemon/api/handlers/human.js +3 -0
- package/dist/daemon/api/handlers/inbox.js +3 -0
- package/dist/daemon/api/handlers/nodes.d.ts +1 -3
- package/dist/daemon/api/handlers/nodes.js +11 -46
- package/dist/daemon/api/handlers/prospective-chat-inventory.d.ts +2 -0
- package/dist/daemon/api/handlers/prospective-chat-inventory.js +59 -0
- package/dist/daemon/api/handlers/reviews.js +10 -2
- package/dist/daemon/api/server.js +4 -0
- package/dist/daemon/crtrd.js +6 -0
- package/dist/daemon/human/deliver-action.d.ts +16 -0
- package/dist/daemon/human/deliver-action.js +168 -0
- package/dist/daemon/human/finish.d.ts +8 -5
- package/dist/daemon/human/finish.js +45 -6
- package/dist/daemon/human/sweep.js +4 -1
- package/dist/daemon/reconcilers/human-delivery-lane.d.ts +10 -0
- package/dist/daemon/reconcilers/human-delivery-lane.js +41 -0
- package/dist/daemon/review/finish.d.ts +8 -3
- package/dist/daemon/review/finish.js +19 -1
- package/dist/types.d.ts +8 -0
- package/dist/types.js +1 -0
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
- package/dist/builtin-memory/00-runtime-base.md +0 -55
- package/dist/builtin-memory/design.md +0 -55
- /package/dist/builtin-memory/{02-lifecycle/00-terminal.md → 02-turn-lifecycle/01-terminal.md} +0 -0
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
// Durable programmatic human requests — crtrd `/v1/human/requests`. A request
|
|
2
|
+
// is an ordinary page ticket with an optional frozen completion action, so it
|
|
3
|
+
// is the same record the inbox lists, answers, and dismisses. Creation is the
|
|
4
|
+
// only surface that writes the action binding; every settlement path funnels
|
|
5
|
+
// through the shared first-writer-wins store.
|
|
6
|
+
import { randomBytes } from 'node:crypto';
|
|
7
|
+
import { statSync } from 'node:fs';
|
|
8
|
+
import { basename, isAbsolute } from 'node:path';
|
|
9
|
+
import { ApiError } from '../../../api/index.js';
|
|
10
|
+
import { readActionDelivery } from '../../../core/canvas/human-deliveries.js';
|
|
11
|
+
import { resolvePageComponents } from '../../../core/config.js';
|
|
12
|
+
import { resolveHumanAction } from '../../../core/human-actions.js';
|
|
13
|
+
import { HUMAN_ACTION_NAME_PATTERN, publishTicketActionBinding, readTicketActionBinding, } from '../../../core/human/action-binding.js';
|
|
14
|
+
import { completionEventFor } from '../../../core/human/completion.js';
|
|
15
|
+
import { parsePage } from '../../../core/human/page.js';
|
|
16
|
+
import { opaqueInboxTicketId, ticketDir } from '../../../core/human/root.js';
|
|
17
|
+
import { preparePageSource, publishPage, readPageTicket, readTicketResult, validatePageTicketResponses, } from '../../../core/human/tickets.js';
|
|
18
|
+
import { cancelHumanTicket, resolvePageTicket } from '../../human/finish.js';
|
|
19
|
+
import { resolveTicket, requireTicketId } from './inbox.js';
|
|
20
|
+
const REASON_MAX = 1000;
|
|
21
|
+
const ACTOR_MAX = 200;
|
|
22
|
+
const DEFAULT_DELIVERY = { placement: 'inline', inbox: true, reply: false };
|
|
23
|
+
const SOURCE_FIELDS = ['sessionName', 'askedBy', 'emittedAt', 'profileName', 'nodeId'];
|
|
24
|
+
// ===========================================================================
|
|
25
|
+
// Body validation
|
|
26
|
+
// ===========================================================================
|
|
27
|
+
function isPlainObject(value) {
|
|
28
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
29
|
+
}
|
|
30
|
+
function requireNoExtraKeys(obj, allowed, where) {
|
|
31
|
+
for (const key of Object.keys(obj)) {
|
|
32
|
+
if (!allowed.includes(key))
|
|
33
|
+
throw new ApiError(400, 'invalid_request', `unexpected field in ${where}: ${key}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function invalid(message) {
|
|
37
|
+
return new ApiError(400, 'invalid_request', message);
|
|
38
|
+
}
|
|
39
|
+
function validatePage(raw) {
|
|
40
|
+
if (!isPlainObject(raw))
|
|
41
|
+
throw invalid('`page` must be an object');
|
|
42
|
+
requireNoExtraKeys(raw, ['dialect', 'source'], '`page`');
|
|
43
|
+
const dialect = raw['dialect'];
|
|
44
|
+
if (dialect !== 'jsx' && dialect !== 'html')
|
|
45
|
+
throw invalid('`page.dialect` must be "jsx" or "html"');
|
|
46
|
+
const source = raw['source'];
|
|
47
|
+
if (typeof source !== 'string' || source.trim() === '')
|
|
48
|
+
throw invalid('`page.source` must be the authored page text');
|
|
49
|
+
return { dialect, source };
|
|
50
|
+
}
|
|
51
|
+
/** Creation pins a programmatic request to the inbox with no bridge to reply to. */
|
|
52
|
+
function validateCreateDelivery(raw) {
|
|
53
|
+
if (raw === undefined)
|
|
54
|
+
return DEFAULT_DELIVERY;
|
|
55
|
+
if (!isPlainObject(raw))
|
|
56
|
+
throw invalid('`delivery` must be an object');
|
|
57
|
+
requireNoExtraKeys(raw, ['placement', 'inbox', 'reply'], '`delivery`');
|
|
58
|
+
const placement = validatePlacement(raw['placement'], DEFAULT_DELIVERY.placement);
|
|
59
|
+
const inbox = raw['inbox'] ?? true;
|
|
60
|
+
const reply = raw['reply'] ?? false;
|
|
61
|
+
if (inbox !== true)
|
|
62
|
+
throw invalid('`delivery.inbox` must be true: a programmatic request must stay answerable');
|
|
63
|
+
if (reply !== false)
|
|
64
|
+
throw invalid('`delivery.reply` must be false: a programmatic request has no agent bridge to reply to');
|
|
65
|
+
return { placement, inbox: true, reply: false };
|
|
66
|
+
}
|
|
67
|
+
/** Replace re-presents a pending page; where the ticket is routed is fixed at
|
|
68
|
+
* creation, so only placement is replaceable. */
|
|
69
|
+
function validateReplaceDelivery(raw, prior) {
|
|
70
|
+
if (raw === undefined)
|
|
71
|
+
return prior;
|
|
72
|
+
if (!isPlainObject(raw))
|
|
73
|
+
throw invalid('`delivery` must be an object');
|
|
74
|
+
requireNoExtraKeys(raw, ['placement'], '`delivery`');
|
|
75
|
+
return { ...prior, placement: validatePlacement(raw['placement'], prior.placement) };
|
|
76
|
+
}
|
|
77
|
+
function validatePlacement(raw, fallback) {
|
|
78
|
+
const placement = raw ?? fallback;
|
|
79
|
+
if (placement !== 'inline' && placement !== 'panel')
|
|
80
|
+
throw invalid('`delivery.placement` must be "inline" or "panel"');
|
|
81
|
+
return placement;
|
|
82
|
+
}
|
|
83
|
+
function validateSource(raw) {
|
|
84
|
+
if (raw === undefined)
|
|
85
|
+
return undefined;
|
|
86
|
+
if (!isPlainObject(raw))
|
|
87
|
+
throw invalid('`source` must be an object');
|
|
88
|
+
requireNoExtraKeys(raw, SOURCE_FIELDS, '`source`');
|
|
89
|
+
const source = {};
|
|
90
|
+
for (const field of SOURCE_FIELDS) {
|
|
91
|
+
const value = raw[field];
|
|
92
|
+
if (value === undefined)
|
|
93
|
+
continue;
|
|
94
|
+
if (typeof value !== 'string' || value === '')
|
|
95
|
+
throw invalid(`\`source.${field}\` must be a nonempty string`);
|
|
96
|
+
source[field] = value;
|
|
97
|
+
}
|
|
98
|
+
return source;
|
|
99
|
+
}
|
|
100
|
+
function validateCreatorCwd(raw) {
|
|
101
|
+
if (typeof raw !== 'string' || raw === '')
|
|
102
|
+
throw invalid('`creator_cwd` is required');
|
|
103
|
+
if (!isAbsolute(raw))
|
|
104
|
+
throw invalid('`creator_cwd` must be an absolute path');
|
|
105
|
+
try {
|
|
106
|
+
if (!statSync(raw).isDirectory())
|
|
107
|
+
throw invalid(`\`creator_cwd\` is not a directory: ${raw}`);
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
if (error instanceof ApiError)
|
|
111
|
+
throw error;
|
|
112
|
+
throw invalid(`\`creator_cwd\` does not exist: ${raw}`);
|
|
113
|
+
}
|
|
114
|
+
return raw;
|
|
115
|
+
}
|
|
116
|
+
function validateActionSelection(raw) {
|
|
117
|
+
if (raw === undefined)
|
|
118
|
+
return undefined;
|
|
119
|
+
if (!isPlainObject(raw))
|
|
120
|
+
throw invalid('`action` must be an object');
|
|
121
|
+
requireNoExtraKeys(raw, ['name', 'payload'], '`action`');
|
|
122
|
+
const name = raw['name'];
|
|
123
|
+
if (typeof name !== 'string' || !HUMAN_ACTION_NAME_PATTERN.test(name)) {
|
|
124
|
+
throw invalid('`action.name` must be a declared action name');
|
|
125
|
+
}
|
|
126
|
+
// The completion document always carries `action.payload`, so an omitted one
|
|
127
|
+
// is normalized here rather than serialized away downstream.
|
|
128
|
+
return { name, payload: raw['payload'] ?? null };
|
|
129
|
+
}
|
|
130
|
+
function validateCreateRequest(raw) {
|
|
131
|
+
if (!isPlainObject(raw))
|
|
132
|
+
throw invalid('request body is required');
|
|
133
|
+
requireNoExtraKeys(raw, ['page', 'delivery', 'source', 'creator_cwd', 'action'], 'the request body');
|
|
134
|
+
const source = validateSource(raw['source']);
|
|
135
|
+
const action = validateActionSelection(raw['action']);
|
|
136
|
+
return {
|
|
137
|
+
page: validatePage(raw['page']),
|
|
138
|
+
delivery: validateCreateDelivery(raw['delivery']),
|
|
139
|
+
...(source === undefined ? {} : { source }),
|
|
140
|
+
creatorCwd: validateCreatorCwd(raw['creator_cwd']),
|
|
141
|
+
...(action === undefined ? {} : { action }),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
function validateReplaceRequest(raw, priorDelivery) {
|
|
145
|
+
if (!isPlainObject(raw))
|
|
146
|
+
throw invalid('request body is required');
|
|
147
|
+
requireNoExtraKeys(raw, ['page', 'delivery'], 'the request body');
|
|
148
|
+
return { page: validatePage(raw['page']), delivery: validateReplaceDelivery(raw['delivery'], priorDelivery) };
|
|
149
|
+
}
|
|
150
|
+
function optionalBoundedString(raw, field, max) {
|
|
151
|
+
if (raw === undefined)
|
|
152
|
+
return undefined;
|
|
153
|
+
if (typeof raw !== 'string')
|
|
154
|
+
throw invalid(`\`${field}\` must be a string`);
|
|
155
|
+
if (raw.trim() === '' || raw.length > max)
|
|
156
|
+
throw invalid(`\`${field}\` must be nonempty after trim and at most ${max} characters`);
|
|
157
|
+
return raw;
|
|
158
|
+
}
|
|
159
|
+
function validateRespondRequest(raw) {
|
|
160
|
+
if (!isPlainObject(raw))
|
|
161
|
+
throw invalid('request body is required');
|
|
162
|
+
requireNoExtraKeys(raw, ['responses', 'actor'], 'the request body');
|
|
163
|
+
if (!isPlainObject(raw['responses']))
|
|
164
|
+
throw invalid('`responses` must be an object');
|
|
165
|
+
const actor = optionalBoundedString(raw['actor'], 'actor', ACTOR_MAX);
|
|
166
|
+
return { responses: raw['responses'], ...(actor === undefined ? {} : { actor }) };
|
|
167
|
+
}
|
|
168
|
+
function validateSettleRequest(raw) {
|
|
169
|
+
if (raw === undefined)
|
|
170
|
+
return {};
|
|
171
|
+
if (!isPlainObject(raw))
|
|
172
|
+
throw invalid('request body must be an object');
|
|
173
|
+
requireNoExtraKeys(raw, ['reason', 'actor'], 'the request body');
|
|
174
|
+
const reason = optionalBoundedString(raw['reason'], 'reason', REASON_MAX);
|
|
175
|
+
const actor = optionalBoundedString(raw['actor'], 'actor', ACTOR_MAX);
|
|
176
|
+
return { ...(reason === undefined ? {} : { reason }), ...(actor === undefined ? {} : { actor }) };
|
|
177
|
+
}
|
|
178
|
+
function resolveRequest(ctx) {
|
|
179
|
+
const requestId = requireTicketId({ ticket_id: ctx.params['request_id'] ?? '' });
|
|
180
|
+
const resolved = resolveTicket(requestId);
|
|
181
|
+
if (resolved === null || resolved.kind !== 'page')
|
|
182
|
+
throw new ApiError(404, 'not_found', 'no request with that id');
|
|
183
|
+
let manifest;
|
|
184
|
+
try {
|
|
185
|
+
manifest = parsePage(resolved.dir);
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
throw new ApiError(500, 'internal', 'the request could not be read');
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
requestId,
|
|
192
|
+
ticketId: resolved.ticketId,
|
|
193
|
+
dir: resolved.dir,
|
|
194
|
+
manifest,
|
|
195
|
+
result: readTicketResult(resolved.dir),
|
|
196
|
+
binding: readTicketActionBinding(resolved.dir),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
function requestState(result) {
|
|
200
|
+
if (result === null)
|
|
201
|
+
return 'pending';
|
|
202
|
+
if (result.kind === 'canceled')
|
|
203
|
+
return completionEventFor(result);
|
|
204
|
+
return 'answered';
|
|
205
|
+
}
|
|
206
|
+
function deliveryDetail(row) {
|
|
207
|
+
if (row === null)
|
|
208
|
+
return { state: 'none', attempt: 0 };
|
|
209
|
+
const detail = { state: row.state, attempt: row.attempt };
|
|
210
|
+
// A next attempt is only meaningful for a pending retry; the enqueue instant
|
|
211
|
+
// of an untried delivery says nothing a client can act on.
|
|
212
|
+
if (row.state === 'pending' && row.lastFailure !== null)
|
|
213
|
+
detail.next_attempt_at = new Date(row.nextAttemptAt).toISOString();
|
|
214
|
+
if (row.acceptedAt !== null)
|
|
215
|
+
detail.accepted_at = new Date(row.acceptedAt).toISOString();
|
|
216
|
+
if (row.permanentFailedAt !== null)
|
|
217
|
+
detail.permanent_failed_at = new Date(row.permanentFailedAt).toISOString();
|
|
218
|
+
if (row.lastFailure !== null) {
|
|
219
|
+
const failure = row.lastFailure;
|
|
220
|
+
detail.last_failure = {
|
|
221
|
+
kind: failure.kind,
|
|
222
|
+
...(failure.exitCode === undefined ? {} : { exit_code: failure.exitCode }),
|
|
223
|
+
...(failure.signal === undefined ? {} : { signal: failure.signal }),
|
|
224
|
+
...(failure.message === undefined ? {} : { message: failure.message }),
|
|
225
|
+
...(failure.stderr === undefined ? {} : { stderr: failure.stderr }),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
return detail;
|
|
229
|
+
}
|
|
230
|
+
function requestToDTO(request) {
|
|
231
|
+
const { manifest, result, binding } = request;
|
|
232
|
+
const emittedAt = manifest.source?.emittedAt;
|
|
233
|
+
if (emittedAt === undefined)
|
|
234
|
+
throw new ApiError(500, 'internal', 'the request carries no emitted time');
|
|
235
|
+
const state = requestState(result);
|
|
236
|
+
const dto = {
|
|
237
|
+
request_id: request.requestId,
|
|
238
|
+
state,
|
|
239
|
+
title: manifest.title,
|
|
240
|
+
source: manifest.source ?? {},
|
|
241
|
+
emitted_at: emittedAt,
|
|
242
|
+
};
|
|
243
|
+
if (manifest.subtitle !== undefined)
|
|
244
|
+
dto.subtitle = manifest.subtitle;
|
|
245
|
+
if (result !== null) {
|
|
246
|
+
dto.settled_at = result.kind === 'canceled' ? result.canceledAt : result.completedAt;
|
|
247
|
+
// Never fabricated for a dismissal or a withdrawal: only an answer has responses.
|
|
248
|
+
if (state === 'answered' && result.kind === 'page')
|
|
249
|
+
dto.responses = result.responses;
|
|
250
|
+
if (result.kind === 'canceled' && result.reason !== undefined)
|
|
251
|
+
dto.reason = result.reason;
|
|
252
|
+
const actor = result.kind === 'review' ? undefined : result.actor;
|
|
253
|
+
if (actor !== undefined)
|
|
254
|
+
dto.actor = actor;
|
|
255
|
+
}
|
|
256
|
+
if (binding !== null) {
|
|
257
|
+
dto.action = { name: binding.name, payload: binding.payload };
|
|
258
|
+
dto.delivery = deliveryDetail(readActionDelivery(request.requestId));
|
|
259
|
+
}
|
|
260
|
+
return dto;
|
|
261
|
+
}
|
|
262
|
+
// ===========================================================================
|
|
263
|
+
// POST /v1/human/requests — create
|
|
264
|
+
// ===========================================================================
|
|
265
|
+
/** Authoring failure is the caller's malformed page, never a server fault. */
|
|
266
|
+
function prepareOrReject(dialect, document, source, delivery) {
|
|
267
|
+
try {
|
|
268
|
+
return preparePageSource({ dialect, document, source, delivery, productKinds: resolvePageComponents() });
|
|
269
|
+
}
|
|
270
|
+
catch (error) {
|
|
271
|
+
throw invalid(error instanceof Error ? error.message : 'the page could not be prepared');
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
function handleCreate(ctx) {
|
|
275
|
+
const request = validateCreateRequest(ctx.body);
|
|
276
|
+
// Action resolution precedes every write: a rejected action must leave no
|
|
277
|
+
// inbox row for a human to answer.
|
|
278
|
+
let resolvedAction;
|
|
279
|
+
if (request.action !== undefined) {
|
|
280
|
+
const resolution = resolveHumanAction(request.action.name, request.creatorCwd);
|
|
281
|
+
if ('kind' in resolution) {
|
|
282
|
+
const detail = resolution.kind === 'unknown_action'
|
|
283
|
+
? `no action named ${resolution.name} is declared from ${request.creatorCwd}`
|
|
284
|
+
: `${resolution.name} is declared in ${resolution.configOrigin} but unusable: ${resolution.reason}`;
|
|
285
|
+
throw new ApiError(400, resolution.kind, detail);
|
|
286
|
+
}
|
|
287
|
+
resolvedAction = resolution;
|
|
288
|
+
}
|
|
289
|
+
const prepared = prepareOrReject(request.page.dialect, request.page.source, request.source, request.delivery);
|
|
290
|
+
const dir = ticketDir(`page-${randomBytes(8).toString('hex')}`);
|
|
291
|
+
// The binding is written before the page exists, so a visible request is
|
|
292
|
+
// never momentarily unbound and `replace` can never reach the approved effect.
|
|
293
|
+
if (request.action !== undefined && resolvedAction !== undefined) {
|
|
294
|
+
publishTicketActionBinding(dir, {
|
|
295
|
+
schema: 'crtr.human-action-binding/v1',
|
|
296
|
+
name: request.action.name,
|
|
297
|
+
payload: request.action.payload,
|
|
298
|
+
creatorCwd: request.creatorCwd,
|
|
299
|
+
configOrigin: resolvedAction.configOrigin,
|
|
300
|
+
argv: resolvedAction.argv,
|
|
301
|
+
cwd: resolvedAction.cwd,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
const published = publishPage(dir, prepared);
|
|
305
|
+
return {
|
|
306
|
+
status: 201,
|
|
307
|
+
body: {
|
|
308
|
+
request_id: opaqueInboxTicketId(basename(published.dir)),
|
|
309
|
+
state: 'pending',
|
|
310
|
+
...(request.action === undefined ? {} : { action: { name: request.action.name } }),
|
|
311
|
+
delivery_state: 'none',
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
// ===========================================================================
|
|
316
|
+
// GET /v1/human/requests/:request_id — read
|
|
317
|
+
// ===========================================================================
|
|
318
|
+
function handleGet(ctx) {
|
|
319
|
+
return { status: 200, body: requestToDTO(resolveRequest(ctx)) };
|
|
320
|
+
}
|
|
321
|
+
// ===========================================================================
|
|
322
|
+
// POST /v1/human/requests/:request_id/replace — revise the pending page
|
|
323
|
+
// ===========================================================================
|
|
324
|
+
function handleReplace(ctx) {
|
|
325
|
+
const request = resolveRequest(ctx);
|
|
326
|
+
if (request.result !== null)
|
|
327
|
+
throw new ApiError(409, 'already_settled', 'the request already has a terminal result');
|
|
328
|
+
const body = validateReplaceRequest(ctx.body, request.manifest.delivery);
|
|
329
|
+
// Identity and provenance are the creation's, verbatim — a revision changes
|
|
330
|
+
// only what is on screen, never who asked or when.
|
|
331
|
+
const prepared = prepareOrReject(body.page.dialect, body.page.source, request.manifest.source, body.delivery);
|
|
332
|
+
publishPage(request.dir, prepared);
|
|
333
|
+
return { status: 200, body: requestToDTO({ ...request, manifest: prepared.manifest }) };
|
|
334
|
+
}
|
|
335
|
+
// ===========================================================================
|
|
336
|
+
// Settlement — respond, dismiss, cancel
|
|
337
|
+
// ===========================================================================
|
|
338
|
+
/** The settlement funnels speak the inbox's error vocabulary; requests speak
|
|
339
|
+
* the wire contract's. This is the one translation. */
|
|
340
|
+
function settlementError(error) {
|
|
341
|
+
if (error instanceof ApiError) {
|
|
342
|
+
if (error.code === 'ticket_already_resolved')
|
|
343
|
+
return new ApiError(409, 'already_settled', 'the request already has a terminal result');
|
|
344
|
+
if (error.code === 'page_has_no_response_lifecycle')
|
|
345
|
+
return new ApiError(409, 'not_settleable', 'this request carries no response lifecycle');
|
|
346
|
+
return error;
|
|
347
|
+
}
|
|
348
|
+
if (error instanceof Error && error.message === 'page_has_no_response_lifecycle') {
|
|
349
|
+
return new ApiError(409, 'not_settleable', 'this request carries no response lifecycle');
|
|
350
|
+
}
|
|
351
|
+
return new ApiError(500, 'internal', 'the request could not be settled');
|
|
352
|
+
}
|
|
353
|
+
function requireSettleable(request) {
|
|
354
|
+
if (request.result !== null)
|
|
355
|
+
throw new ApiError(409, 'already_settled', 'the request already has a terminal result');
|
|
356
|
+
if (readPageTicket(request.dir).state === 'passive') {
|
|
357
|
+
throw new ApiError(409, 'not_settleable', 'this request carries no response lifecycle');
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
async function handleRespond(ctx) {
|
|
361
|
+
const request = resolveRequest(ctx);
|
|
362
|
+
const body = validateRespondRequest(ctx.body);
|
|
363
|
+
requireSettleable(request);
|
|
364
|
+
try {
|
|
365
|
+
validatePageTicketResponses(request.dir, body.responses);
|
|
366
|
+
}
|
|
367
|
+
catch (error) {
|
|
368
|
+
// `requireSettleable` already refused a page with no response lifecycle, so
|
|
369
|
+
// anything left here is the caller's response map.
|
|
370
|
+
throw invalid(error instanceof Error ? error.message : 'the response map is invalid');
|
|
371
|
+
}
|
|
372
|
+
let result;
|
|
373
|
+
try {
|
|
374
|
+
result = await resolvePageTicket(request.ticketId, body.responses, body.actor === undefined ? {} : { actor: body.actor });
|
|
375
|
+
}
|
|
376
|
+
catch (error) {
|
|
377
|
+
throw settlementError(error);
|
|
378
|
+
}
|
|
379
|
+
return { status: 200, body: requestToDTO({ ...request, result, binding: request.binding }) };
|
|
380
|
+
}
|
|
381
|
+
async function settle(ctx, disposition) {
|
|
382
|
+
const request = resolveRequest(ctx);
|
|
383
|
+
const body = validateSettleRequest(ctx.body);
|
|
384
|
+
requireSettleable(request);
|
|
385
|
+
let result;
|
|
386
|
+
try {
|
|
387
|
+
result = await cancelHumanTicket(request.ticketId, { ...body, disposition });
|
|
388
|
+
}
|
|
389
|
+
catch (error) {
|
|
390
|
+
throw settlementError(error);
|
|
391
|
+
}
|
|
392
|
+
return { status: 200, body: requestToDTO({ ...request, result }) };
|
|
393
|
+
}
|
|
394
|
+
/** The recipient surface deliberately closing the request without answering. */
|
|
395
|
+
function handleDismiss(ctx) {
|
|
396
|
+
return settle(ctx, 'dismissed');
|
|
397
|
+
}
|
|
398
|
+
/** The requester withdrawing its own request. */
|
|
399
|
+
function handleCancel(ctx) {
|
|
400
|
+
return settle(ctx, 'canceled');
|
|
401
|
+
}
|
|
402
|
+
export const humanRequestRoutes = [
|
|
403
|
+
{ method: 'POST', pattern: '/v1/human/requests', handler: handleCreate },
|
|
404
|
+
{ method: 'GET', pattern: '/v1/human/requests/:request_id', handler: handleGet },
|
|
405
|
+
{ method: 'POST', pattern: '/v1/human/requests/:request_id/replace', handler: handleReplace },
|
|
406
|
+
{ method: 'POST', pattern: '/v1/human/requests/:request_id/respond', handler: handleRespond },
|
|
407
|
+
{ method: 'POST', pattern: '/v1/human/requests/:request_id/dismiss', handler: handleDismiss },
|
|
408
|
+
{ method: 'POST', pattern: '/v1/human/requests/:request_id/cancel', handler: handleCancel },
|
|
409
|
+
];
|
|
@@ -108,6 +108,9 @@ async function handleCancel(ctx) {
|
|
|
108
108
|
if (request !== undefined && (typeof request !== 'object' || request === null)) {
|
|
109
109
|
throw new ApiError(400, 'invalid_request', 'ticket cancel body must be an object');
|
|
110
110
|
}
|
|
111
|
+
if (request?.disposition !== undefined && request.disposition !== 'canceled' && request.disposition !== 'dismissed') {
|
|
112
|
+
throw new ApiError(400, 'invalid_request', '`disposition` must be "canceled" or "dismissed"');
|
|
113
|
+
}
|
|
111
114
|
const dir = ticketDir(ticketId);
|
|
112
115
|
const review = getReviewByBridge(ticketId);
|
|
113
116
|
try {
|
|
@@ -364,6 +364,9 @@ async function handleCancel(ctx) {
|
|
|
364
364
|
const result = await cancelHumanTicket(resolved.ticketId, {
|
|
365
365
|
reason: request.reason ?? DEFAULT_CANCEL_REASON,
|
|
366
366
|
actor: 'human',
|
|
367
|
+
// This is the recipient surface: closing a request here is a dismissal,
|
|
368
|
+
// never the requester withdrawing it.
|
|
369
|
+
disposition: 'dismissed',
|
|
367
370
|
});
|
|
368
371
|
if (result.kind !== 'canceled')
|
|
369
372
|
throw new ApiError(409, 'ticket_already_resolved', 'ticket was already resolved');
|
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
import { type ModelRegistryLike } from '../../../core/model-routes.js';
|
|
2
|
-
import { assertProfileActive } from '../../../core/profiles/manifest.js';
|
|
3
2
|
import type { HandlerResult, RequestContext, RouteTable } from '../router.js';
|
|
4
3
|
interface NodeHandlerDeps {
|
|
5
4
|
modelRegistry?: ModelRegistryLike;
|
|
6
5
|
}
|
|
7
|
-
export
|
|
6
|
+
export { resolveCreateKind } from '../../../core/runtime/launch-target.js';
|
|
8
7
|
export declare function handleCreate(ctx: RequestContext, deps?: NodeHandlerDeps): Promise<HandlerResult>;
|
|
9
8
|
export declare function handleList(ctx: RequestContext): HandlerResult;
|
|
10
9
|
export declare function handlePromote(ctx: RequestContext, deps?: NodeHandlerDeps): Promise<HandlerResult>;
|
|
11
10
|
export declare function handleYield(ctx: RequestContext, deps?: NodeHandlerDeps): Promise<HandlerResult>;
|
|
12
11
|
export declare function handleConfig(ctx: RequestContext, deps?: NodeHandlerDeps): Promise<HandlerResult>;
|
|
13
12
|
export declare const nodeRoutes: RouteTable;
|
|
14
|
-
export {};
|
|
@@ -7,10 +7,10 @@
|
|
|
7
7
|
//
|
|
8
8
|
// `{id}` is the target node.
|
|
9
9
|
import { readdirSync } from 'node:fs';
|
|
10
|
-
import { basename
|
|
10
|
+
import { basename } from 'node:path';
|
|
11
11
|
import { appendSituationalContext, closeNode, getNode, listNodes, nowIso, reviveNode, } from '../../../index.js';
|
|
12
12
|
import { formatSituationalProse } from '../../../core/runtime/situational-context.js';
|
|
13
|
-
import { assertLaunchModelRegistered, forkNode,
|
|
13
|
+
import { assertLaunchModelRegistered, forkNode, spawnChild } from '../../../core/runtime/spawn.js';
|
|
14
14
|
import { childrenOf, subscribersOf, subscriptionsOf, setMessageWait, updateNode } from '../../../core/canvas/canvas.js';
|
|
15
15
|
import { subtreeIds } from '../../../core/canvas/nav-model.js';
|
|
16
16
|
import { ticketCountsForNodes } from '../../../core/canvas/attention.js';
|
|
@@ -32,7 +32,7 @@ import { BrokerUnreachableError } from '../../../core/runtime/broker-request.js'
|
|
|
32
32
|
import { isBrokerLive, persistDormantModel, setModelLive } from '../../../core/runtime/model-swap.js';
|
|
33
33
|
import { writeOutputSchema } from '../../../core/runtime/structured-output.js';
|
|
34
34
|
import { claimWarmNode, refillWarmPool, warmPoolEnabled } from '../../../core/runtime/warm-pool.js';
|
|
35
|
-
import {
|
|
35
|
+
import { resolveLaunchTarget } from '../../../core/runtime/launch-target.js';
|
|
36
36
|
import { fullName } from '../../../core/canvas/labels.js';
|
|
37
37
|
import { reportsForNode } from './reports.js';
|
|
38
38
|
import { getCron } from '../../../core/canvas/crons.js';
|
|
@@ -99,28 +99,6 @@ function parseCreateBody(body) {
|
|
|
99
99
|
}
|
|
100
100
|
return b;
|
|
101
101
|
}
|
|
102
|
-
/** The directory a new node is pinned to. An explicit `pin_cwd` wins; else a
|
|
103
|
-
* managed child inherits its spawner's cwd (which is what keeps a worktree
|
|
104
|
-
* node's whole subtree inside that worktree); else the launch's own `cwd`, the
|
|
105
|
-
* directory the create came from; else the profile's home.
|
|
106
|
-
*
|
|
107
|
-
* The launch directory beating the profile home is what lets one profile cover
|
|
108
|
-
* many working directories — a root started inside a planted instance runs in
|
|
109
|
-
* that instance while still taking its identity, purview, and memory from the
|
|
110
|
-
* profile covering it. The profile supplies a home only for a create that named
|
|
111
|
-
* no directory at all. */
|
|
112
|
-
function resolveNodeCwd(req, parent, profileId, contextCwd) {
|
|
113
|
-
if (req.pin_cwd !== undefined && req.pin_cwd !== '')
|
|
114
|
-
return resolve(req.pin_cwd);
|
|
115
|
-
if (parent != null) {
|
|
116
|
-
const spawner = getNode(parent);
|
|
117
|
-
if (spawner !== null)
|
|
118
|
-
return spawner.cwd;
|
|
119
|
-
}
|
|
120
|
-
if (req.cwd !== undefined && req.cwd !== '')
|
|
121
|
-
return resolve(req.cwd);
|
|
122
|
-
return profileHome(profileId) ?? contextCwd;
|
|
123
|
-
}
|
|
124
102
|
/** Build the immediate spawn recipe. */
|
|
125
103
|
function buildRecipe(req, parent, cwd) {
|
|
126
104
|
const recipe = {
|
|
@@ -185,9 +163,7 @@ function warmRequestFor(req, cwd, profileId) {
|
|
|
185
163
|
name: req.name ?? null,
|
|
186
164
|
};
|
|
187
165
|
}
|
|
188
|
-
export
|
|
189
|
-
return kind ?? profile?.manifest.default_kind ?? 'general';
|
|
190
|
-
}
|
|
166
|
+
export { resolveCreateKind } from '../../../core/runtime/launch-target.js';
|
|
191
167
|
function requestFromLaunch(launch) {
|
|
192
168
|
const intent = launch.modelIntent === undefined
|
|
193
169
|
? undefined
|
|
@@ -229,20 +205,8 @@ export async function handleCreate(ctx, deps = {}) {
|
|
|
229
205
|
// Resolve and validate the exact launch recipe before the warm pool can claim
|
|
230
206
|
// a spare (or the cold path can create a worktree/node). The registry belongs
|
|
231
207
|
// to this daemon, never to the possibly-remote CLI caller.
|
|
232
|
-
const
|
|
233
|
-
const profileId = await resolveProfileId(req.profile, parent ?? null, contextCwd);
|
|
234
|
-
const profile = assertProfileActive(profileId);
|
|
235
|
-
const kind = resolveCreateKind(req.kind, profile);
|
|
208
|
+
const { profileId, kind, cwd, launch } = await resolveLaunchTarget(req, { validateKind: assertKindServer });
|
|
236
209
|
const resolvedReq = { ...req, kind };
|
|
237
|
-
const cwd = resolveNodeCwd(resolvedReq, parent, profileId, contextCwd);
|
|
238
|
-
await assertKindServer(kind, cwd, profileId);
|
|
239
|
-
const launch = (await buildLaunchSpecAsync(kind, (resolvedReq.mode ?? 'base'), {
|
|
240
|
-
lifecycle: req.root === true ? (req.root_lifecycle ?? 'resident') : 'terminal',
|
|
241
|
-
hasManager: req.root !== true,
|
|
242
|
-
...(req.model !== undefined ? { model: req.model } : {}),
|
|
243
|
-
cwd,
|
|
244
|
-
profileId,
|
|
245
|
-
})).launch;
|
|
246
210
|
await assertLaunchModelRegistered(requestFromLaunch(launch), { cwd, profileId }, deps.modelRegistry);
|
|
247
211
|
// --- Warm path: hand out an already-booted spare instead of paying a full
|
|
248
212
|
// engine boot. A miss is not a failure — it falls through to the cold
|
|
@@ -680,7 +644,7 @@ export async function handleConfig(ctx, deps = {}) {
|
|
|
680
644
|
// --- kind / mode / lifecycle: reshape via canonical primitive (CLI parity). ---
|
|
681
645
|
if (patch.kind !== undefined || patch.mode !== undefined || patch.lifecycle !== undefined) {
|
|
682
646
|
if (patch.kind !== undefined)
|
|
683
|
-
await assertKindServer(patch.kind
|
|
647
|
+
await assertKindServer(patch.kind);
|
|
684
648
|
const newKind = patch.kind ?? meta.kind;
|
|
685
649
|
const newMode = (patch.mode ?? meta.mode);
|
|
686
650
|
const newLifecycle = patch.lifecycle ?? meta.lifecycle;
|
|
@@ -712,10 +676,11 @@ export async function handleConfig(ctx, deps = {}) {
|
|
|
712
676
|
// to receive it, so it is a documented Stage-A no-op here.
|
|
713
677
|
return { status: 200, body: detailOf(id) };
|
|
714
678
|
}
|
|
715
|
-
/**
|
|
716
|
-
*
|
|
717
|
-
|
|
718
|
-
|
|
679
|
+
/** POST /v1/nodes and PATCH /v1/nodes/:id/config validate kinds in the daemon's
|
|
680
|
+
* ambient configuration; prospective reads validate their resolved target. The
|
|
681
|
+
* read is subprocess-isolated — crtrd must never resolve config on its own loop. */
|
|
682
|
+
async function assertKindServer(kind) {
|
|
683
|
+
const kinds = Object.keys((await readMergedLaunchConfigAsync(process.cwd(), process.env['CRTR_PROFILE_ID'] || null)).kinds);
|
|
719
684
|
if (!kinds.includes(kind)) {
|
|
720
685
|
throw usage(`unknown kind: ${kind}`, { received: kind, next: `Valid kinds: ${kinds.sort().join(', ')}.` });
|
|
721
686
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { ApiError } from '../../../api/errors.js';
|
|
2
|
+
import { usage } from '../../../core/errors.js';
|
|
3
|
+
import { resolveLaunchTarget } from '../../../core/runtime/launch-target.js';
|
|
4
|
+
import { runProspectiveInventory } from '../../../core/runtime/prospective-inventory.js';
|
|
5
|
+
const inFlight = new Map();
|
|
6
|
+
function singleQueryValue(ctx, name) {
|
|
7
|
+
const values = ctx.query.getAll(name);
|
|
8
|
+
if (values.length > 1)
|
|
9
|
+
throw usage(`${name} must appear at most once`);
|
|
10
|
+
return values[0];
|
|
11
|
+
}
|
|
12
|
+
async function handleGet(ctx) {
|
|
13
|
+
for (const [name] of ctx.query) {
|
|
14
|
+
if (name !== 'profile' && name !== 'cwd' && name !== 'kind')
|
|
15
|
+
throw usage(`unknown prospective inventory query parameter: ${name}`);
|
|
16
|
+
}
|
|
17
|
+
const profile = singleQueryValue(ctx, 'profile');
|
|
18
|
+
const cwd = singleQueryValue(ctx, 'cwd');
|
|
19
|
+
const kind = singleQueryValue(ctx, 'kind');
|
|
20
|
+
if (kind !== undefined && kind.trim() === '')
|
|
21
|
+
throw usage('kind must be a non-empty string when provided');
|
|
22
|
+
const target = await resolveLaunchTarget({
|
|
23
|
+
root: true,
|
|
24
|
+
...(profile !== undefined ? { profile } : {}),
|
|
25
|
+
...(cwd !== undefined ? { cwd } : {}),
|
|
26
|
+
...(kind !== undefined ? { kind } : {}),
|
|
27
|
+
}, { readOnlyProfileSelection: true });
|
|
28
|
+
const key = JSON.stringify([target.profileId, target.cwd, target.kind]);
|
|
29
|
+
let pending = inFlight.get(key);
|
|
30
|
+
if (pending === undefined) {
|
|
31
|
+
pending = runProspectiveInventory({
|
|
32
|
+
cwd: target.cwd,
|
|
33
|
+
profileId: target.profileId,
|
|
34
|
+
extensions: target.launch.extensions,
|
|
35
|
+
});
|
|
36
|
+
inFlight.set(key, pending);
|
|
37
|
+
void pending.finally(() => {
|
|
38
|
+
if (inFlight.get(key) === pending)
|
|
39
|
+
inFlight.delete(key);
|
|
40
|
+
}).catch(() => { });
|
|
41
|
+
}
|
|
42
|
+
let rows;
|
|
43
|
+
try {
|
|
44
|
+
rows = await pending;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
throw new ApiError(503, 'preflight_failed', 'prospective chat inventory preflight failed');
|
|
48
|
+
}
|
|
49
|
+
const body = {
|
|
50
|
+
profile_id: target.profileId,
|
|
51
|
+
cwd: target.cwd,
|
|
52
|
+
commands: rows.commands,
|
|
53
|
+
memory_refs: rows.memory_refs,
|
|
54
|
+
};
|
|
55
|
+
return { status: 200, body };
|
|
56
|
+
}
|
|
57
|
+
export const prospectiveChatInventoryRoutes = [
|
|
58
|
+
{ method: 'GET', pattern: '/v1/prospective-chat-inventory', handler: handleGet },
|
|
59
|
+
];
|
|
@@ -14,7 +14,7 @@ import { toFeedbackResultDTO, toReviewDTO } from '../map.js';
|
|
|
14
14
|
import { invalidRequest as rejectRequest, objectBody, optionalString, requireParam, requiredString, } from './validate.js';
|
|
15
15
|
const REVIEW_STATES = new Set(['open', 'approved', 'canceled']);
|
|
16
16
|
const CREATE_FIELDS = ['origin_kind', 'origin_node_id', 'file', 'idempotency_key', 'subtitle', 'output_path'];
|
|
17
|
-
const CANCEL_FIELDS = ['reason', 'actor'];
|
|
17
|
+
const CANCEL_FIELDS = ['reason', 'actor', 'disposition'];
|
|
18
18
|
function invalidRequest(violations) {
|
|
19
19
|
rejectRequest('review request', violations);
|
|
20
20
|
}
|
|
@@ -131,7 +131,15 @@ function parseCancel(ctx, violations) {
|
|
|
131
131
|
const body = ctx.body === undefined ? {} : objectBody(ctx.body, CANCEL_FIELDS, violations);
|
|
132
132
|
const reason = optionalString(body, 'reason', violations);
|
|
133
133
|
const actor = optionalString(body, 'actor', violations);
|
|
134
|
-
|
|
134
|
+
const disposition = optionalString(body, 'disposition', violations);
|
|
135
|
+
if (disposition !== undefined && disposition !== 'canceled' && disposition !== 'dismissed') {
|
|
136
|
+
violations.push({ field: 'disposition', problem: 'must be "canceled" or "dismissed"', received: disposition });
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
...(reason === undefined ? {} : { reason }),
|
|
140
|
+
...(actor === undefined ? {} : { actor }),
|
|
141
|
+
...(disposition === 'canceled' || disposition === 'dismissed' ? { disposition } : {}),
|
|
142
|
+
};
|
|
135
143
|
}
|
|
136
144
|
function requireEmptyBody(ctx, violations) {
|
|
137
145
|
objectBody(ctx.body, [], violations);
|