@tea-agent/loop-agent 0.23.1 → 0.24.0
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/CHANGELOG.md +18 -1
- package/README.md +1 -1
- package/bin/agent-worker.js +0 -0
- package/dist/executors/shell-executor.js +20 -7
- package/dist/shared/operator/capabilities.js +475 -2
- package/dist/worker/console/app-data.js +2 -0
- package/dist/worker/console/chat/artifact-card.js +23 -0
- package/dist/worker/console/chat/chat-event-store.js +495 -0
- package/dist/worker/console/chat/chat-ui-policy.js +25 -0
- package/dist/worker/console/chat/composer-draft-store.js +45 -0
- package/dist/worker/console/chat/context-panel.js +54 -0
- package/dist/worker/console/chat/contract-apply-receipt-store.js +174 -0
- package/dist/worker/console/chat/explore-tools.js +299 -0
- package/dist/worker/console/chat/human-gate-card.js +37 -0
- package/dist/worker/console/chat/interview-adapter.js +136 -0
- package/dist/worker/console/chat/operation-card.js +23 -0
- package/dist/worker/console/chat/pi-console-config.js +158 -0
- package/dist/worker/console/chat/pi-runtime.js +581 -43
- package/dist/worker/console/chat/repo-browser.js +140 -0
- package/dist/worker/console/chat/repo-walk.js +116 -0
- package/dist/worker/console/chat/resource-loader.js +18 -17
- package/dist/worker/console/chat/routes.js +1354 -65
- package/dist/worker/console/chat/runtime-context.js +24 -0
- package/dist/worker/console/chat/runtime-selection.js +37 -0
- package/dist/worker/console/chat/session-store.js +210 -11
- package/dist/worker/console/chat/shortcuts.js +15 -0
- package/dist/worker/console/chat/tool-adapter.js +81 -194
- package/dist/worker/console/chat/tools.js +72 -48
- package/dist/worker/console/chat/usage.js +37 -0
- package/dist/worker/console/chat/workspace-landing.js +56 -0
- package/dist/worker/console/dag-confirmation.js +42 -8
- package/dist/worker/console/human-gate-token.js +130 -0
- package/dist/worker/console/mutation-gate-receipt-store.js +184 -0
- package/dist/worker/console/operation-runner.js +6 -2
- package/dist/worker/console/operation-sse.js +26 -0
- package/dist/worker/console/operator-actions.js +420 -7
- package/dist/worker/console/server.js +14 -2
- package/dist/worker/console/static/assets/index-BTbrEHnO.css +1 -0
- package/dist/worker/console/static/assets/index-D9qLevoP.js +27 -0
- package/dist/worker/console/static/index.html +2 -2
- package/dist/workflows/dag/backend-test-markdown-workflow.js +9 -5
- package/dist/workflows/dag/backend-test-result-contract.js +229 -0
- package/dist/workflows/dag/frontend-lint-baseline.js +4 -4
- package/dist/workflows/dag/init-hybrid.js +2 -1
- package/docs/README.md +1 -1
- package/docs/architecture/README.md +5 -5
- package/docs/architecture/evolution.md +4 -4
- package/docs/architecture/worker-and-feature.md +1 -1
- package/docs/templates/backend-test-dag.json +2 -2
- package/harness.json +1 -1
- package/package.json +1 -1
- package/dist/worker/console/static/assets/index-DVl7Jxt5.js +0 -25
- package/dist/worker/console/static/assets/index-lVcIr9Ju.css +0 -1
|
@@ -12,16 +12,83 @@
|
|
|
12
12
|
* Mutation routes go through the Console mutation gate (boot token + Origin).
|
|
13
13
|
* Read routes (GET capabilities / sessions) are open to loopback.
|
|
14
14
|
*/
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
15
16
|
import { OPERATOR_CHAT_ALLOWED_TOOLS, OPERATOR_CHAT_DENIED_TOOLS, OPERATOR_CHAT_DENIED_OPERATOR_ACTIONS, } from "./tools.js";
|
|
16
|
-
import { OPERATOR_CHAT_ALLOWED_INSTRUCTION_SKILLS, composeInstructionSkillsPrompt, loadOperatorChatInstructionSkills, } from "./instruction-skills.js";
|
|
17
|
-
import {
|
|
17
|
+
import { OPERATOR_CHAT_ALLOWED_INSTRUCTION_SKILLS, OPERATOR_CHAT_DENIED_INSTRUCTION_SKILLS, OPERATOR_CHAT_WORKFLOW_CONDITIONAL_SKILLS, composeInstructionSkillsPrompt, loadOperatorChatInstructionSkills, } from "./instruction-skills.js";
|
|
18
|
+
import { buildModelCallableToolSchemas } from "./tool-adapter.js";
|
|
19
|
+
import { projectChatToolEventPayload, projectCompactSnapshot, } from "./chat-event-store.js";
|
|
20
|
+
import { THINKING_LEVELS } from "./pi-runtime.js";
|
|
21
|
+
import { ComposerDraftStore } from "./composer-draft-store.js";
|
|
22
|
+
import { walkRepoFiles } from "./repo-walk.js";
|
|
23
|
+
import { isSensitivePath, scrubSecrets } from "./explore-tools.js";
|
|
24
|
+
import { listRepoDirectory, readRepoPreview, RepoBrowserError, } from "./repo-browser.js";
|
|
25
|
+
import { projectRuntimeContext } from "./runtime-context.js";
|
|
26
|
+
import { patchModelsConfig, readModelsConfig, readPackageInventory, readSkillPreferences, writeSkillPreferences, } from "./pi-console-config.js";
|
|
27
|
+
import { ChatInterviewAdapter, projectChatInterviewState } from "./interview-adapter.js";
|
|
18
28
|
import { evaluateMutationGate, isMutationMethod, } from "../security.js";
|
|
29
|
+
import { dispatchOperatorAction } from "../operator-actions.js";
|
|
30
|
+
import { makeHumanGateCard } from "./human-gate-card.js";
|
|
31
|
+
import { ContractApplyReceiptStore } from "./contract-apply-receipt-store.js";
|
|
32
|
+
import { projectTaskContext } from "./context-panel.js";
|
|
33
|
+
import { contractApplyPayloadHash, issueHumanGateToken, verifyHumanGateToken } from "../human-gate-token.js";
|
|
19
34
|
import { sendJson } from "../routes.js";
|
|
20
|
-
import { openSseResponse, writeSseEvent, } from "../operation-sse.js";
|
|
35
|
+
import { openSseResponse, parseLastEventId, writeSseEvent, } from "../operation-sse.js";
|
|
21
36
|
function writeChatSse(res, event) {
|
|
37
|
+
// Persisted Chat events carry their own eventId (<sessionId>:<seq>); write
|
|
38
|
+
// it as the SSE `id:` frame so the browser tracks Last-Event-ID for reconnect.
|
|
39
|
+
if ("seq" in event && event.eventId) {
|
|
40
|
+
res.write(`id: ${event.eventId}\n`);
|
|
41
|
+
}
|
|
22
42
|
res.write(`event: ${event.kind}\n`);
|
|
23
43
|
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
24
44
|
}
|
|
45
|
+
/** Map a runtime ChatTurnEvent to the persisted event-store partial.
|
|
46
|
+
*
|
|
47
|
+
* Returns undefined for heartbeat (not persisted — keep-alive only). */
|
|
48
|
+
function turnEventToStorePartial(event) {
|
|
49
|
+
switch (event.type) {
|
|
50
|
+
case "agent_start":
|
|
51
|
+
return { kind: "agent_start", data: {} };
|
|
52
|
+
case "message_update":
|
|
53
|
+
return { kind: "message_update", data: { text: String(event.text ?? "") } };
|
|
54
|
+
case "message_end":
|
|
55
|
+
return { kind: "message_end", data: { text: String(event.text ?? "") } };
|
|
56
|
+
case "usage":
|
|
57
|
+
return { kind: "usage", data: (event.usage ?? {}) };
|
|
58
|
+
case "tool_call":
|
|
59
|
+
return {
|
|
60
|
+
kind: "tool_call",
|
|
61
|
+
data: projectChatToolEventPayload({
|
|
62
|
+
kind: "tool_call",
|
|
63
|
+
toolCallId: String(event.toolCallId ?? ""),
|
|
64
|
+
toolName: String(event.toolName ?? ""),
|
|
65
|
+
value: event.args,
|
|
66
|
+
}),
|
|
67
|
+
};
|
|
68
|
+
case "tool_result":
|
|
69
|
+
return {
|
|
70
|
+
kind: "tool_result",
|
|
71
|
+
data: projectChatToolEventPayload({
|
|
72
|
+
kind: "tool_result",
|
|
73
|
+
toolCallId: String(event.toolCallId ?? ""),
|
|
74
|
+
toolName: String(event.toolName ?? ""),
|
|
75
|
+
value: event.result,
|
|
76
|
+
isError: Boolean(event.isError),
|
|
77
|
+
}),
|
|
78
|
+
};
|
|
79
|
+
case "agent_end":
|
|
80
|
+
return {
|
|
81
|
+
kind: "agent_end",
|
|
82
|
+
data: { willRetry: Boolean(event.willRetry) },
|
|
83
|
+
};
|
|
84
|
+
case "agent_settled":
|
|
85
|
+
return { kind: "agent_settled", data: {} };
|
|
86
|
+
case "error":
|
|
87
|
+
return { kind: "error", data: { message: String(event.message ?? "") } };
|
|
88
|
+
default:
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
25
92
|
async function readJsonBody(req, maxBytes = 2 * 1024 * 1024) {
|
|
26
93
|
const chunks = [];
|
|
27
94
|
let total = 0;
|
|
@@ -40,6 +107,36 @@ async function readJsonBody(req, maxBytes = 2 * 1024 * 1024) {
|
|
|
40
107
|
return {};
|
|
41
108
|
return JSON.parse(raw);
|
|
42
109
|
}
|
|
110
|
+
const IMAGE_MIME_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
|
|
111
|
+
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
112
|
+
const MAX_TOTAL_IMAGE_BYTES = 20 * 1024 * 1024;
|
|
113
|
+
const MAX_IMAGES = 4;
|
|
114
|
+
export function parseChatImages(value) {
|
|
115
|
+
if (value === undefined)
|
|
116
|
+
return [];
|
|
117
|
+
if (!Array.isArray(value) || value.length > MAX_IMAGES)
|
|
118
|
+
throw new Error(`images must be an array with at most ${MAX_IMAGES} items`);
|
|
119
|
+
let total = 0;
|
|
120
|
+
return value.map((item, index) => {
|
|
121
|
+
if (!item || typeof item !== "object")
|
|
122
|
+
throw new Error(`images[${index}] is invalid`);
|
|
123
|
+
const record = item;
|
|
124
|
+
if (record.type !== "image" || typeof record.mimeType !== "string" || !IMAGE_MIME_TYPES.has(record.mimeType))
|
|
125
|
+
throw new Error(`images[${index}] must be a supported raster image`);
|
|
126
|
+
if (typeof record.data !== "string" || !record.data || !/^[A-Za-z0-9+/]+={0,2}$/.test(record.data) || record.data.length % 4 !== 0)
|
|
127
|
+
throw new Error(`images[${index}] data must be valid base64`);
|
|
128
|
+
const bytes = Buffer.from(record.data, "base64");
|
|
129
|
+
const textProbe = bytes.subarray(0, Math.min(bytes.length, 64 * 1024)).toString("utf8");
|
|
130
|
+
if (scrubSecrets(textProbe).redactions.length > 0)
|
|
131
|
+
throw new Error(`images[${index}] contains secret-shaped content`);
|
|
132
|
+
if (bytes.length > MAX_IMAGE_BYTES)
|
|
133
|
+
throw new Error(`images[${index}] exceeds ${MAX_IMAGE_BYTES} bytes`);
|
|
134
|
+
total += bytes.length;
|
|
135
|
+
if (total > MAX_TOTAL_IMAGE_BYTES)
|
|
136
|
+
throw new Error(`images exceed ${MAX_TOTAL_IMAGE_BYTES} total bytes`);
|
|
137
|
+
return { type: "image", mimeType: record.mimeType, data: bytes.toString("base64") };
|
|
138
|
+
});
|
|
139
|
+
}
|
|
43
140
|
function gateMutation(req, deps) {
|
|
44
141
|
const failure = evaluateMutationGate(req, {
|
|
45
142
|
bootToken: deps.bootToken.value,
|
|
@@ -58,7 +155,7 @@ function gateMutation(req, deps) {
|
|
|
58
155
|
return { ok: true };
|
|
59
156
|
}
|
|
60
157
|
export async function handleChatCapabilities(_req, res, deps) {
|
|
61
|
-
const toolSchemas =
|
|
158
|
+
const toolSchemas = buildModelCallableToolSchemas();
|
|
62
159
|
const skills = await loadOperatorChatInstructionSkills(deps.skillsDir);
|
|
63
160
|
sendJson(res, 200, {
|
|
64
161
|
ok: true,
|
|
@@ -78,9 +175,9 @@ export async function handleChatCapabilities(_req, res, deps) {
|
|
|
78
175
|
skipped: skills.skipped,
|
|
79
176
|
promptFragmentCharCount: composeInstructionSkillsPrompt(skills.loaded).length,
|
|
80
177
|
},
|
|
81
|
-
// bash is
|
|
82
|
-
//
|
|
83
|
-
hasBash:
|
|
178
|
+
// M0-A: bare bash is removed; safe-read/safe-grep enforce the repo and
|
|
179
|
+
// secret boundaries instead.
|
|
180
|
+
hasBash: false,
|
|
84
181
|
});
|
|
85
182
|
}
|
|
86
183
|
export async function handleCreateChatSession(req, res, deps) {
|
|
@@ -106,24 +203,39 @@ export async function handleCreateChatSession(req, res, deps) {
|
|
|
106
203
|
});
|
|
107
204
|
return;
|
|
108
205
|
}
|
|
206
|
+
if ("clientRequestId" in body || "requestIndex" in body) {
|
|
207
|
+
sendJson(res, 400, { ok: false, error: { code: "INVALID_INPUT", message: "operation idempotency ownership cannot be copied" } });
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
109
210
|
const provider = typeof body.modelProvider === "string" ? body.modelProvider : undefined;
|
|
110
211
|
const modelId = typeof body.modelId === "string" ? body.modelId : undefined;
|
|
212
|
+
const snapshotInput = body.snapshot;
|
|
213
|
+
if (snapshotInput !== undefined && (!snapshotInput || typeof snapshotInput !== "object" || snapshotInput.schemaVersion !== 1 || typeof snapshotInput.summary !== "string" || !snapshotInput.summary)) {
|
|
214
|
+
sendJson(res, 400, { ok: false, error: { code: "INVALID_SNAPSHOT", message: "valid OperatorContextSnapshotV1 is required" } });
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
111
217
|
try {
|
|
218
|
+
deps.runtime.setDisabledInstructionSkills(await readSkillPreferences(deps.appData));
|
|
219
|
+
const snapshot = snapshotInput === undefined ? undefined : projectCompactSnapshot(snapshotInput);
|
|
112
220
|
const handle = await deps.runtime.createSession({
|
|
113
221
|
...(provider && modelId ? { model: { provider, modelId } } : {}),
|
|
222
|
+
...(snapshot ? { systemPromptSuffix: `Read-only initial Operator context snapshot; this is not runtime rollback.\n${snapshot.summary}` } : {}),
|
|
114
223
|
});
|
|
115
224
|
await deps.store.create({
|
|
116
225
|
sessionId: handle.sessionId,
|
|
226
|
+
sessionFile: handle.sessionFile,
|
|
117
227
|
modelProvider: handle.model?.provider,
|
|
118
228
|
modelId: handle.model?.modelId,
|
|
119
229
|
});
|
|
230
|
+
if (snapshot)
|
|
231
|
+
deps.events.append(handle.sessionId, `${handle.sessionId}:snapshot`, { kind: "compact", data: snapshot });
|
|
120
232
|
sendJson(res, 201, {
|
|
121
233
|
ok: true,
|
|
122
234
|
sessionId: handle.sessionId,
|
|
123
235
|
sessionFile: handle.sessionFile,
|
|
124
236
|
model: handle.model,
|
|
125
237
|
activeTools: handle.activeTools,
|
|
126
|
-
hasBash:
|
|
238
|
+
hasBash: false,
|
|
127
239
|
});
|
|
128
240
|
}
|
|
129
241
|
catch (error) {
|
|
@@ -134,6 +246,81 @@ export async function handleCreateChatSession(req, res, deps) {
|
|
|
134
246
|
});
|
|
135
247
|
}
|
|
136
248
|
}
|
|
249
|
+
export async function handleForkChatSession(req, res, deps, sourceSessionId) {
|
|
250
|
+
const gate = gateMutation(req, deps);
|
|
251
|
+
if (!gate.ok) {
|
|
252
|
+
sendJson(res, gate.status, { ok: false, error: { code: gate.code, message: gate.message } });
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (deps.events.getActiveTurn(sourceSessionId)) {
|
|
256
|
+
sendJson(res, 409, { ok: false, error: { code: "TURN_ACTIVE", message: "cannot fork an active turn" } });
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const source = await deps.store.get(sourceSessionId);
|
|
260
|
+
if (!source) {
|
|
261
|
+
sendJson(res, 404, { ok: false, error: { code: "NOT_FOUND", message: `chat session not found: ${sourceSessionId}` } });
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
try {
|
|
265
|
+
const handle = await deps.runtime.forkSession({ sourceSessionFile: source.sessionFile ?? "", ...(source.modelProvider && source.modelId ? { model: { provider: source.modelProvider, modelId: source.modelId } } : {}) });
|
|
266
|
+
await deps.store.forkFrom({ sourceSessionId, targetSessionId: handle.sessionId, targetSessionFile: handle.sessionFile });
|
|
267
|
+
const copied = deps.events.forkContextRefs({ sourceSessionId, targetSessionId: handle.sessionId, targetTurnId: `${handle.sessionId}:fork` });
|
|
268
|
+
await deps.operationLinker.recoverSession(handle.sessionId);
|
|
269
|
+
sendJson(res, 201, { ok: true, sessionId: handle.sessionId, forkedFrom: sourceSessionId, copiedContextRefs: copied.copiedCount, warnings: ["已发生的 task/DAG/Worker mutation 不会撤销或重放。", "operation idempotency / clientRequestId ownership 不会复制到新会话。"] });
|
|
270
|
+
}
|
|
271
|
+
catch (error) {
|
|
272
|
+
sendJson(res, 409, { ok: false, error: { code: "SESSION_FORK_FAILED", message: error instanceof Error ? error.message : String(error) } });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
export async function handleBranchChatSession(req, res, deps, sessionId) {
|
|
276
|
+
const gate = gateMutation(req, deps);
|
|
277
|
+
if (!gate.ok) {
|
|
278
|
+
sendJson(res, gate.status, { ok: false, error: { code: gate.code, message: gate.message } });
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (deps.events.getActiveTurn(sessionId)) {
|
|
282
|
+
sendJson(res, 409, { ok: false, error: { code: "TURN_ACTIVE", message: "cannot branch an active turn" } });
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
const record = await deps.store.get(sessionId);
|
|
286
|
+
if (!record) {
|
|
287
|
+
sendJson(res, 404, { ok: false, error: { code: "NOT_FOUND", message: `chat session not found: ${sessionId}` } });
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
try {
|
|
291
|
+
if (!deps.runtime.hasSession(sessionId)) {
|
|
292
|
+
const reopened = await deps.runtime.reopenSession({ sessionId, sessionFile: record.sessionFile });
|
|
293
|
+
if (!reopened.ok)
|
|
294
|
+
throw new Error(reopened.message);
|
|
295
|
+
}
|
|
296
|
+
const body = await readJsonBody(req);
|
|
297
|
+
if ("clientRequestId" in body || "requestIndex" in body)
|
|
298
|
+
throw new Error("operation idempotency ownership cannot be copied");
|
|
299
|
+
const current = deps.runtime.getBranchContext(sessionId);
|
|
300
|
+
const branchFromEntryId = typeof body.branchFromEntryId === "string" ? body.branchFromEntryId : current.leafId;
|
|
301
|
+
if (!branchFromEntryId)
|
|
302
|
+
throw new Error("branchFromEntryId is required");
|
|
303
|
+
const result = await deps.runtime.branchSession({ sessionId, branchFromEntryId, ...(typeof body.summary === "string" && body.summary ? { summary: body.summary } : {}) });
|
|
304
|
+
sendJson(res, 200, { ok: true, ...result, note: "仅影响后续 LLM 上下文,未持久化第二历史" });
|
|
305
|
+
}
|
|
306
|
+
catch (error) {
|
|
307
|
+
sendJson(res, 400, { ok: false, error: { code: "SESSION_BRANCH_FAILED", message: error instanceof Error ? error.message : String(error) } });
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
export async function handleSwitchMainlineChatSession(req, res, deps, sessionId) {
|
|
311
|
+
const gate = gateMutation(req, deps);
|
|
312
|
+
if (!gate.ok) {
|
|
313
|
+
sendJson(res, gate.status, { ok: false, error: { code: gate.code, message: gate.message } });
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
try {
|
|
317
|
+
deps.runtime.switchToMainline(sessionId);
|
|
318
|
+
sendJson(res, 200, { ok: true, sessionId, active: "mainline" });
|
|
319
|
+
}
|
|
320
|
+
catch (error) {
|
|
321
|
+
sendJson(res, 409, { ok: false, error: { code: "MAINLINE_SWITCH_FAILED", message: error instanceof Error ? error.message : String(error) } });
|
|
322
|
+
}
|
|
323
|
+
}
|
|
137
324
|
export async function handleGetChatSession(_req, res, deps, sessionId) {
|
|
138
325
|
const record = await deps.store.get(sessionId);
|
|
139
326
|
if (!record) {
|
|
@@ -143,7 +330,93 @@ export async function handleGetChatSession(_req, res, deps, sessionId) {
|
|
|
143
330
|
});
|
|
144
331
|
return;
|
|
145
332
|
}
|
|
146
|
-
|
|
333
|
+
await deps.operationLinker.recoverSession(sessionId);
|
|
334
|
+
const composerDraft = await new ComposerDraftStore(deps.appData).get(sessionId);
|
|
335
|
+
const snapshot = deps.events.snapshot(sessionId);
|
|
336
|
+
const lastEvent = snapshot.at(-1);
|
|
337
|
+
sendJson(res, 200, {
|
|
338
|
+
ok: true,
|
|
339
|
+
session: {
|
|
340
|
+
...record,
|
|
341
|
+
composerDraft,
|
|
342
|
+
activeTurnId: deps.events.getActiveTurn(sessionId)?.turnId,
|
|
343
|
+
},
|
|
344
|
+
hasBash: false,
|
|
345
|
+
lastEventId: lastEvent?.eventId ?? null,
|
|
346
|
+
lastEventSeq: lastEvent?.seq ?? 0,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
export async function handleListChatSessions(_req, res, deps) {
|
|
350
|
+
const sessions = await deps.store.list({ includeArchived: true });
|
|
351
|
+
sendJson(res, 200, {
|
|
352
|
+
ok: true,
|
|
353
|
+
schemaVersion: 2,
|
|
354
|
+
sessions: sessions.map((session) => ({
|
|
355
|
+
...session,
|
|
356
|
+
activeTurnId: deps.events.getActiveTurn(session.sessionId)?.turnId,
|
|
357
|
+
})),
|
|
358
|
+
hasBash: false,
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
export async function handlePatchChatSession(req, res, deps, sessionId) {
|
|
362
|
+
const gate = gateMutation(req, deps);
|
|
363
|
+
if (!gate.ok) {
|
|
364
|
+
sendJson(res, gate.status, { ok: false, error: { code: gate.code, message: gate.message } });
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
try {
|
|
368
|
+
const body = await readJsonBody(req);
|
|
369
|
+
let record;
|
|
370
|
+
if (typeof body.title === "string")
|
|
371
|
+
record = await deps.store.rename(sessionId, body.title);
|
|
372
|
+
else if (body.state === "active" || body.state === "archived") {
|
|
373
|
+
record = await deps.store.setState(sessionId, body.state);
|
|
374
|
+
}
|
|
375
|
+
else {
|
|
376
|
+
sendJson(res, 400, { ok: false, error: { code: "INVALID_INPUT", message: "title or state(active|archived) is required" } });
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
sendJson(res, 200, { ok: true, session: record });
|
|
380
|
+
}
|
|
381
|
+
catch (error) {
|
|
382
|
+
sendJson(res, 404, { ok: false, error: { code: "NOT_FOUND", message: error instanceof Error ? error.message : String(error) } });
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
export async function handleReopenChatSession(req, res, deps, sessionId) {
|
|
386
|
+
const gate = gateMutation(req, deps);
|
|
387
|
+
if (!gate.ok) {
|
|
388
|
+
sendJson(res, gate.status, { ok: false, error: { code: gate.code, message: gate.message } });
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
const record = await deps.store.get(sessionId);
|
|
392
|
+
if (!record) {
|
|
393
|
+
sendJson(res, 404, { ok: false, error: { code: "NOT_FOUND", message: `chat session not found: ${sessionId}` } });
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
if (deps.runtime.hasSession(sessionId)) {
|
|
397
|
+
sendJson(res, 200, { ok: true, sessionId, reopened: false, alreadyActive: true, hasBash: false });
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
const result = await deps.runtime.reopenSession({
|
|
401
|
+
sessionId,
|
|
402
|
+
sessionFile: record.sessionFile,
|
|
403
|
+
...(record.modelProvider && record.modelId
|
|
404
|
+
? { model: { provider: record.modelProvider, modelId: record.modelId } }
|
|
405
|
+
: {}),
|
|
406
|
+
});
|
|
407
|
+
if (!result.ok) {
|
|
408
|
+
sendJson(res, 409, { ok: false, error: { code: result.code, message: result.message } });
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
sendJson(res, 200, {
|
|
412
|
+
ok: true,
|
|
413
|
+
sessionId,
|
|
414
|
+
reopened: true,
|
|
415
|
+
sessionFile: result.handle.sessionFile,
|
|
416
|
+
model: result.handle.model,
|
|
417
|
+
activeTools: result.handle.activeTools,
|
|
418
|
+
hasBash: false,
|
|
419
|
+
});
|
|
147
420
|
}
|
|
148
421
|
export async function handleChatPrompt(req, res, deps, sessionId) {
|
|
149
422
|
const gate = gateMutation(req, deps);
|
|
@@ -169,6 +442,14 @@ export async function handleChatPrompt(req, res, deps, sessionId) {
|
|
|
169
442
|
return;
|
|
170
443
|
}
|
|
171
444
|
const text = typeof body.text === "string" ? body.text.trim() : "";
|
|
445
|
+
let images;
|
|
446
|
+
try {
|
|
447
|
+
images = parseChatImages(body.images);
|
|
448
|
+
}
|
|
449
|
+
catch (error) {
|
|
450
|
+
sendJson(res, 400, { ok: false, error: { code: "INVALID_ATTACHMENT", message: error instanceof Error ? error.message : String(error) } });
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
172
453
|
if (!text) {
|
|
173
454
|
sendJson(res, 400, {
|
|
174
455
|
ok: false,
|
|
@@ -177,11 +458,26 @@ export async function handleChatPrompt(req, res, deps, sessionId) {
|
|
|
177
458
|
return;
|
|
178
459
|
}
|
|
179
460
|
if (!deps.runtime.hasSession(sessionId)) {
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
461
|
+
// M1-S04/S05: lazily reopen a persisted session that is no longer in
|
|
462
|
+
// memory (Console restart / browser refresh). Fail-closed when there is
|
|
463
|
+
// no durable record or no Pi session file — we never silently create a
|
|
464
|
+
// fresh session under the same id, which would lose prior context.
|
|
465
|
+
const record = await deps.store.get(sessionId);
|
|
466
|
+
if (!record) {
|
|
467
|
+
sendJson(res, 404, { ok: false, error: { code: "NOT_FOUND", message: `chat session not found: ${sessionId}` } });
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
const reopen = await deps.runtime.reopenSession({
|
|
471
|
+
sessionId,
|
|
472
|
+
sessionFile: record.sessionFile,
|
|
473
|
+
...(record.modelProvider && record.modelId
|
|
474
|
+
? { model: { provider: record.modelProvider, modelId: record.modelId } }
|
|
475
|
+
: {}),
|
|
183
476
|
});
|
|
184
|
-
|
|
477
|
+
if (!reopen.ok) {
|
|
478
|
+
sendJson(res, 409, { ok: false, error: { code: reopen.code, message: reopen.message } });
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
185
481
|
}
|
|
186
482
|
const actionContext = deps.runtime.actionContext;
|
|
187
483
|
if (!actionContext) {
|
|
@@ -194,86 +490,112 @@ export async function handleChatPrompt(req, res, deps, sessionId) {
|
|
|
194
490
|
});
|
|
195
491
|
return;
|
|
196
492
|
}
|
|
197
|
-
//
|
|
493
|
+
// Acquire the per-session active-turn lease (T01/T16). The compatibility
|
|
494
|
+
// prompt SSE route uses the same durable Turn resource as POST /turns.
|
|
495
|
+
const createdTurn = deps.events.createTurn(sessionId);
|
|
496
|
+
if (!createdTurn.ok) {
|
|
497
|
+
sendJson(res, 409, {
|
|
498
|
+
ok: false,
|
|
499
|
+
error: {
|
|
500
|
+
code: "TURN_ACTIVE",
|
|
501
|
+
message: `chat turn already active: ${createdTurn.activeTurn.turnId}`,
|
|
502
|
+
},
|
|
503
|
+
});
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
const turnId = createdTurn.turn.turnId;
|
|
507
|
+
deps.events.setTurnState(sessionId, turnId, "running");
|
|
508
|
+
// Stream events via SSE. Each event is FIRST persisted to the chat event
|
|
509
|
+
// ring (so a reconnect via GET /events can resume by Last-Event-ID), THEN
|
|
510
|
+
// framed with its eventId as the SSE `id:` line.
|
|
198
511
|
openSseResponse(res);
|
|
199
|
-
// Emit an immediate agent_start so the client shows a streaming state
|
|
200
|
-
// before the SDK emits its own agent_start (which we drop below to avoid
|
|
201
|
-
// a duplicate frame — P1.4).
|
|
202
|
-
writeChatSse(res, { kind: "agent_start", sessionId });
|
|
203
512
|
const ac = new AbortController();
|
|
204
513
|
let closed = false;
|
|
205
|
-
|
|
514
|
+
let abortedByClient = false;
|
|
515
|
+
// Prefer response close: after the request body is fully read, browser abort
|
|
516
|
+
// of an SSE response typically emits res.close, not req.close.
|
|
517
|
+
res.once("close", () => {
|
|
518
|
+
if (res.writableEnded)
|
|
519
|
+
return;
|
|
206
520
|
closed = true;
|
|
521
|
+
abortedByClient = true;
|
|
207
522
|
ac.abort();
|
|
208
523
|
});
|
|
209
524
|
// Lazily import to avoid circular module load.
|
|
210
525
|
const { runChatTurn } = await import("./session-store.js");
|
|
211
526
|
try {
|
|
212
|
-
await runChatTurn({
|
|
527
|
+
const outcome = await runChatTurn({
|
|
213
528
|
runtime: deps.runtime,
|
|
214
529
|
store: deps.store,
|
|
215
530
|
actionContext,
|
|
216
531
|
sessionId,
|
|
217
532
|
text,
|
|
533
|
+
images,
|
|
218
534
|
signal: ac.signal,
|
|
535
|
+
onOperationAccepted: (accepted) => deps.operationLinker.link({
|
|
536
|
+
sessionId,
|
|
537
|
+
turnId,
|
|
538
|
+
toolCallId: accepted.toolCallId,
|
|
539
|
+
operationId: accepted.operationId,
|
|
540
|
+
}),
|
|
219
541
|
onEvent: (event) => {
|
|
220
542
|
if (closed)
|
|
221
543
|
return;
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
544
|
+
if (event.type === "tool_result" && event.toolName === "prepareDagConfirmation" && !event.isError) {
|
|
545
|
+
const result = event.result;
|
|
546
|
+
const confirmationId = result?.confirmation?.confirmationId;
|
|
547
|
+
if (confirmationId)
|
|
548
|
+
void ctxConfirmationCard(deps, sessionId, turnId, confirmationId);
|
|
549
|
+
}
|
|
550
|
+
if (event.type === "tool_result" && event.toolName === "prepareMutationGate" && !event.isError) {
|
|
551
|
+
const result = event.result;
|
|
552
|
+
if (result?.receiptId)
|
|
553
|
+
void ctxMutationGateCard(deps, sessionId, result.receiptId);
|
|
554
|
+
}
|
|
226
555
|
// heartbeat is a keep-alive: write it as an SSE comment frame (":\n\n")
|
|
227
556
|
// which proxies/browsers treat as traffic but clients ignore as data.
|
|
557
|
+
// It is NOT persisted (no real state change).
|
|
228
558
|
if (event.type === "heartbeat") {
|
|
229
559
|
if (!res.writableEnded)
|
|
230
560
|
res.write(": heartbeat\n\n");
|
|
231
561
|
return;
|
|
232
562
|
}
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
args: event.args,
|
|
240
|
-
}
|
|
241
|
-
: event.type === "tool_result"
|
|
242
|
-
? {
|
|
243
|
-
kind: "tool_result",
|
|
244
|
-
sessionId,
|
|
245
|
-
toolCallId: event.toolCallId,
|
|
246
|
-
toolName: event.toolName,
|
|
247
|
-
result: event.result,
|
|
248
|
-
isError: event.isError,
|
|
249
|
-
}
|
|
250
|
-
: event.type === "error"
|
|
251
|
-
? { kind: "error", sessionId, message: event.message }
|
|
252
|
-
: event.type === "agent_end"
|
|
253
|
-
? { kind: "agent_end", sessionId, willRetry: event.willRetry }
|
|
254
|
-
: event.type === "agent_settled"
|
|
255
|
-
? { kind: "agent_settled", sessionId }
|
|
256
|
-
: {
|
|
257
|
-
kind: event.type,
|
|
258
|
-
sessionId,
|
|
259
|
-
text: "text" in event ? event.text : "",
|
|
260
|
-
};
|
|
261
|
-
writeChatSse(res, sse);
|
|
563
|
+
const partial = turnEventToStorePartial(event);
|
|
564
|
+
if (!partial)
|
|
565
|
+
return;
|
|
566
|
+
const persisted = deps.events.append(sessionId, turnId, partial);
|
|
567
|
+
if (!res.writableEnded)
|
|
568
|
+
writeChatSse(res, persisted);
|
|
262
569
|
},
|
|
263
570
|
});
|
|
571
|
+
if (abortedByClient || ac.signal.aborted) {
|
|
572
|
+
deps.events.setTurnState(sessionId, turnId, "aborted", {
|
|
573
|
+
code: "CLIENT_ABORTED",
|
|
574
|
+
message: "client disconnected before turn settled",
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
else {
|
|
578
|
+
deps.events.setTurnState(sessionId, turnId, outcome.ok ? "settled" : "failed", outcome.error);
|
|
579
|
+
}
|
|
264
580
|
}
|
|
265
581
|
catch (error) {
|
|
266
582
|
// runChatTurn should not reject (it catches runtime.prompt failures
|
|
267
583
|
// internally and returns {ok:false}), but a pre-prompt persistence failure
|
|
268
584
|
// (e.g. store.appendMessage for the user message) CAN throw after the SSE
|
|
269
|
-
// stream is already open.
|
|
270
|
-
// sees the failure
|
|
585
|
+
// stream is already open. Persist + emit an error event so a reconnecting
|
|
586
|
+
// client also sees the failure (not just the original requester).
|
|
587
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
588
|
+
deps.events.setTurnState(sessionId, turnId, abortedByClient || ac.signal.aborted ? "aborted" : "failed", {
|
|
589
|
+
code: abortedByClient || ac.signal.aborted ? "CLIENT_ABORTED" : "CHAT_TURN_FAILED",
|
|
590
|
+
message,
|
|
591
|
+
});
|
|
271
592
|
if (!closed) {
|
|
272
|
-
|
|
593
|
+
const persisted = deps.events.append(sessionId, turnId, {
|
|
273
594
|
kind: "error",
|
|
274
|
-
|
|
275
|
-
message: error instanceof Error ? error.message : String(error),
|
|
595
|
+
data: { message },
|
|
276
596
|
});
|
|
597
|
+
if (!res.writableEnded)
|
|
598
|
+
writeChatSse(res, persisted);
|
|
277
599
|
}
|
|
278
600
|
}
|
|
279
601
|
finally {
|
|
@@ -285,6 +607,196 @@ export async function handleChatPrompt(req, res, deps, sessionId) {
|
|
|
285
607
|
}
|
|
286
608
|
}
|
|
287
609
|
}
|
|
610
|
+
/**
|
|
611
|
+
* Resource-oriented turn start (M1-T02/T15): acknowledge quickly, while the
|
|
612
|
+
* Pi prompt continues independently of this HTTP response and publishes to the
|
|
613
|
+
* persisted event ring consumed by GET /events.
|
|
614
|
+
*/
|
|
615
|
+
export async function handleCreateChatTurn(req, res, deps, sessionId) {
|
|
616
|
+
const gate = gateMutation(req, deps);
|
|
617
|
+
if (!gate.ok) {
|
|
618
|
+
sendJson(res, gate.status, { ok: false, error: { code: gate.code, message: gate.message } });
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
let body;
|
|
622
|
+
try {
|
|
623
|
+
body = await readJsonBody(req);
|
|
624
|
+
}
|
|
625
|
+
catch (error) {
|
|
626
|
+
sendJson(res, 400, { ok: false, error: { code: "INVALID_INPUT", message: error instanceof Error ? error.message : String(error) } });
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
const text = typeof body.text === "string" ? body.text.trim() : "";
|
|
630
|
+
let images;
|
|
631
|
+
try {
|
|
632
|
+
images = parseChatImages(body.images);
|
|
633
|
+
}
|
|
634
|
+
catch (error) {
|
|
635
|
+
sendJson(res, 400, { ok: false, error: { code: "INVALID_ATTACHMENT", message: error instanceof Error ? error.message : String(error) } });
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
if (!text) {
|
|
639
|
+
sendJson(res, 400, { ok: false, error: { code: "INVALID_INPUT", message: "text is required" } });
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
if (!deps.runtime.hasSession(sessionId)) {
|
|
643
|
+
const record = await deps.store.get(sessionId);
|
|
644
|
+
if (!record) {
|
|
645
|
+
sendJson(res, 404, { ok: false, error: { code: "NOT_FOUND", message: `chat session not found: ${sessionId}` } });
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
const reopen = await deps.runtime.reopenSession({
|
|
649
|
+
sessionId,
|
|
650
|
+
sessionFile: record.sessionFile,
|
|
651
|
+
...(record.modelProvider && record.modelId
|
|
652
|
+
? { model: { provider: record.modelProvider, modelId: record.modelId } }
|
|
653
|
+
: {}),
|
|
654
|
+
});
|
|
655
|
+
if (!reopen.ok) {
|
|
656
|
+
sendJson(res, 409, { ok: false, error: { code: reopen.code, message: reopen.message } });
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
const actionContext = deps.runtime.actionContext;
|
|
661
|
+
if (!actionContext) {
|
|
662
|
+
sendJson(res, 503, { ok: false, error: { code: "CHAT_NO_ACTION_CONTEXT", message: "Chat runtime has no operator action context wired" } });
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
const created = deps.events.createTurn(sessionId);
|
|
666
|
+
if (!created.ok) {
|
|
667
|
+
sendJson(res, 409, {
|
|
668
|
+
ok: false,
|
|
669
|
+
error: { code: "TURN_ACTIVE", message: `chat turn already active: ${created.activeTurn.turnId}` },
|
|
670
|
+
});
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
const turn = created.turn;
|
|
674
|
+
deps.events.setTurnState(sessionId, turn.turnId, "running");
|
|
675
|
+
sendJson(res, 202, { ok: true, turnId: turn.turnId, ordinal: turn.ordinal, state: "running" });
|
|
676
|
+
// Deliberately detached from the request lifecycle: closing the POST
|
|
677
|
+
// response cannot abort the prompt; clients follow GET /events instead.
|
|
678
|
+
void (async () => {
|
|
679
|
+
const { runChatTurn } = await import("./session-store.js");
|
|
680
|
+
try {
|
|
681
|
+
const outcome = await runChatTurn({
|
|
682
|
+
runtime: deps.runtime,
|
|
683
|
+
store: deps.store,
|
|
684
|
+
actionContext,
|
|
685
|
+
sessionId,
|
|
686
|
+
text,
|
|
687
|
+
images,
|
|
688
|
+
onOperationAccepted: (accepted) => deps.operationLinker.link({
|
|
689
|
+
sessionId,
|
|
690
|
+
turnId: turn.turnId,
|
|
691
|
+
toolCallId: accepted.toolCallId,
|
|
692
|
+
operationId: accepted.operationId,
|
|
693
|
+
}),
|
|
694
|
+
onEvent: (event) => {
|
|
695
|
+
if (event.type === "heartbeat")
|
|
696
|
+
return;
|
|
697
|
+
const partial = turnEventToStorePartial(event);
|
|
698
|
+
if (partial)
|
|
699
|
+
deps.events.append(sessionId, turn.turnId, partial);
|
|
700
|
+
},
|
|
701
|
+
});
|
|
702
|
+
deps.events.setTurnState(sessionId, turn.turnId, outcome.ok ? "settled" : "failed", outcome.error);
|
|
703
|
+
}
|
|
704
|
+
catch (error) {
|
|
705
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
706
|
+
deps.events.append(sessionId, turn.turnId, { kind: "error", data: { message } });
|
|
707
|
+
deps.events.setTurnState(sessionId, turn.turnId, "failed", {
|
|
708
|
+
code: "CHAT_TURN_FAILED",
|
|
709
|
+
message,
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
})();
|
|
713
|
+
}
|
|
714
|
+
export async function handleGetChatTurn(_req, res, deps, sessionId, turnId) {
|
|
715
|
+
const turn = deps.events.getTurn(sessionId, turnId);
|
|
716
|
+
if (!turn) {
|
|
717
|
+
sendJson(res, 404, { ok: false, error: { code: "NOT_FOUND", message: `chat turn not found: ${turnId}` } });
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
sendJson(res, 200, { ok: true, turn });
|
|
721
|
+
}
|
|
722
|
+
export async function handleChatEventsStream(req, res, deps, sessionId) {
|
|
723
|
+
// Read routes (capabilities/sessions GET) are open to loopback; the events
|
|
724
|
+
// stream is a READ and does NOT require the mutation gate, but we still need
|
|
725
|
+
// the boot cookie so only the Console browser can poll a live session.
|
|
726
|
+
const record = await deps.store.get(sessionId);
|
|
727
|
+
if (!record) {
|
|
728
|
+
sendJson(res, 404, { ok: false, error: { code: "NOT_FOUND", message: `chat session not found: ${sessionId}` } });
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
await deps.operationLinker.recoverSession(sessionId);
|
|
732
|
+
openSseResponse(res);
|
|
733
|
+
// Resume from the client's last seen eventId (Last-Event-ID header, T04).
|
|
734
|
+
const lastEventIdHeader = req.headers["last-event-id"];
|
|
735
|
+
const afterSeq = parseLastEventId(Array.isArray(lastEventIdHeader) ? lastEventIdHeader[0] : lastEventIdHeader);
|
|
736
|
+
const replay = deps.events.listFrom(sessionId, afterSeq);
|
|
737
|
+
let lastSeq = afterSeq;
|
|
738
|
+
if ("error" in replay) {
|
|
739
|
+
// Cursor aged out of the bounded ring (T07 fence): tell the client to
|
|
740
|
+
// reconcile from a fresh snapshot rather than render a gap as if current.
|
|
741
|
+
writeChatSse(res, {
|
|
742
|
+
kind: "reconcile",
|
|
743
|
+
sessionId,
|
|
744
|
+
reason: "cursor_expired",
|
|
745
|
+
minSeq: replay.minSeq,
|
|
746
|
+
latestTurnId: deps.events.latestTurnId(sessionId),
|
|
747
|
+
});
|
|
748
|
+
// Fall back to full retained snapshot so the client can re-render.
|
|
749
|
+
for (const ev of deps.events.snapshot(sessionId)) {
|
|
750
|
+
writeChatSse(res, ev);
|
|
751
|
+
lastSeq = Math.max(lastSeq, ev.seq);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
else {
|
|
755
|
+
for (const ev of replay.events) {
|
|
756
|
+
writeChatSse(res, ev);
|
|
757
|
+
lastSeq = Math.max(lastSeq, ev.seq);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
let closed = false;
|
|
761
|
+
res.once("close", () => {
|
|
762
|
+
closed = true;
|
|
763
|
+
});
|
|
764
|
+
// Follow live appends for a short window so a reconnect that lands while a
|
|
765
|
+
// turn is still streaming continues to receive new events (T05). If nothing
|
|
766
|
+
// arrives within the follow window we close the stream idempotently; the
|
|
767
|
+
// client re-opens GET /events and resumes from its tracked Last-Event-ID.
|
|
768
|
+
const FOLLOW_WINDOW_MS = 25_000;
|
|
769
|
+
const IDLE_CLOSE_MS = 12_000;
|
|
770
|
+
const unsub = deps.events.subscribe(sessionId, (ev) => {
|
|
771
|
+
if (closed || res.writableEnded)
|
|
772
|
+
return;
|
|
773
|
+
if (ev.seq <= lastSeq)
|
|
774
|
+
return; // already sent during replay
|
|
775
|
+
writeChatSse(res, ev);
|
|
776
|
+
lastSeq = ev.seq;
|
|
777
|
+
});
|
|
778
|
+
const startedAt = Date.now();
|
|
779
|
+
const tick = setInterval(() => {
|
|
780
|
+
if (closed || res.writableEnded) {
|
|
781
|
+
clearInterval(tick);
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
const elapsed = Date.now() - startedAt;
|
|
785
|
+
// Emit a keep-alive comment so proxies/browsers don't drop the idle stream.
|
|
786
|
+
res.write(": keepalive\n\n");
|
|
787
|
+
if (elapsed >= FOLLOW_WINDOW_MS) {
|
|
788
|
+
clearInterval(tick);
|
|
789
|
+
unsub();
|
|
790
|
+
if (!res.writableEnded)
|
|
791
|
+
res.end();
|
|
792
|
+
}
|
|
793
|
+
}, IDLE_CLOSE_MS);
|
|
794
|
+
// If the client disconnects before the window elapses, clean up.
|
|
795
|
+
res.once("close", () => {
|
|
796
|
+
clearInterval(tick);
|
|
797
|
+
unsub();
|
|
798
|
+
});
|
|
799
|
+
}
|
|
288
800
|
export async function handleDeleteChatSession(req, res, deps, sessionId) {
|
|
289
801
|
const gate = gateMutation(req, deps);
|
|
290
802
|
if (!gate.ok) {
|
|
@@ -295,7 +807,625 @@ export async function handleDeleteChatSession(req, res, deps, sessionId) {
|
|
|
295
807
|
return;
|
|
296
808
|
}
|
|
297
809
|
deps.runtime.dispose(sessionId);
|
|
298
|
-
|
|
810
|
+
deps.operationLinker.clearSession(sessionId);
|
|
811
|
+
deps.events.clear(sessionId);
|
|
812
|
+
const removed = await deps.store.remove(sessionId);
|
|
813
|
+
await new ComposerDraftStore(deps.appData).remove(sessionId);
|
|
814
|
+
if (!removed) {
|
|
815
|
+
sendJson(res, 404, { ok: false, error: { code: "NOT_FOUND", message: `chat session not found: ${sessionId}` } });
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
sendJson(res, 200, { ok: true, sessionId, disposed: true, removed: true });
|
|
819
|
+
}
|
|
820
|
+
export async function handleGetTaskContext(_res, res, deps, sessionId) {
|
|
821
|
+
if (!await requireChatSession(res, deps, sessionId))
|
|
822
|
+
return;
|
|
823
|
+
const events = deps.events.snapshot(sessionId);
|
|
824
|
+
const context = projectTaskContext(events);
|
|
825
|
+
const latest = events.at(-1);
|
|
826
|
+
sendJson(res, 200, {
|
|
827
|
+
ok: true,
|
|
828
|
+
context,
|
|
829
|
+
lastSeq: latest?.seq ?? 0,
|
|
830
|
+
lastEventId: latest?.eventId,
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
async function handleComposerDraft(req, res, deps, sessionId) {
|
|
834
|
+
const gate = gateMutation(req, deps);
|
|
835
|
+
if (!gate.ok) {
|
|
836
|
+
sendJson(res, gate.status, { ok: false, error: { code: gate.code, message: gate.message } });
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
if (!await requireChatSession(res, deps, sessionId))
|
|
840
|
+
return;
|
|
841
|
+
try {
|
|
842
|
+
const body = await readJsonBody(req, 70 * 1024);
|
|
843
|
+
const text = typeof body.text === "string" ? body.text : "";
|
|
844
|
+
await new ComposerDraftStore(deps.appData).save(sessionId, text);
|
|
845
|
+
sendJson(res, 200, { ok: true });
|
|
846
|
+
}
|
|
847
|
+
catch (error) {
|
|
848
|
+
sendJson(res, 400, { ok: false, error: { code: "INVALID_INPUT", message: error instanceof Error ? error.message : String(error) } });
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
async function handleRepoBrowser(res, deps, kind, rawPath) {
|
|
852
|
+
try {
|
|
853
|
+
const data = kind === "tree"
|
|
854
|
+
? await listRepoDirectory(deps.appData.repoRoot, rawPath)
|
|
855
|
+
: await readRepoPreview(deps.appData.repoRoot, rawPath);
|
|
856
|
+
sendJson(res, 200, { ok: true, data });
|
|
857
|
+
}
|
|
858
|
+
catch (error) {
|
|
859
|
+
const failure = error instanceof RepoBrowserError
|
|
860
|
+
? error
|
|
861
|
+
: new RepoBrowserError(500, "READ_FAILED", error instanceof Error ? error.message : String(error));
|
|
862
|
+
sendJson(res, failure.status, { ok: false, error: { code: failure.code, message: failure.message } });
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
async function handlePiConfig(req, res, deps, resource) {
|
|
866
|
+
try {
|
|
867
|
+
if (resource === "models") {
|
|
868
|
+
const agentDir = await deps.runtime.getAgentDir();
|
|
869
|
+
if (req.method === "GET")
|
|
870
|
+
sendJson(res, 200, { ok: true, data: await readModelsConfig(agentDir) });
|
|
871
|
+
else {
|
|
872
|
+
const gate = gateMutation(req, deps);
|
|
873
|
+
if (!gate.ok) {
|
|
874
|
+
sendJson(res, gate.status, { ok: false, error: { code: gate.code, message: gate.message } });
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
const body = await readJsonBody(req);
|
|
878
|
+
sendJson(res, 200, { ok: true, data: await patchModelsConfig(agentDir, body) });
|
|
879
|
+
}
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
if (resource === "packages") {
|
|
883
|
+
sendJson(res, 200, { ok: true, data: { packages: await readPackageInventory(await deps.runtime.getAgentDir(), deps.appData.repoRoot), mutationSupported: false } });
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
if (req.method === "GET") {
|
|
887
|
+
const disabledNames = await readSkillPreferences(deps.appData);
|
|
888
|
+
const loaded = await loadOperatorChatInstructionSkills(deps.skillsDir);
|
|
889
|
+
sendJson(res, 200, { ok: true, data: {
|
|
890
|
+
allowed: OPERATOR_CHAT_ALLOWED_INSTRUCTION_SKILLS.map((name) => {
|
|
891
|
+
const skill = loaded.loaded.find((item) => item.name === name);
|
|
892
|
+
return {
|
|
893
|
+
name,
|
|
894
|
+
description: skill?.description ?? "",
|
|
895
|
+
contentHash: skill
|
|
896
|
+
? createHash("sha256").update(skill.bodyText).digest("hex")
|
|
897
|
+
: undefined,
|
|
898
|
+
};
|
|
899
|
+
}),
|
|
900
|
+
conditional: OPERATOR_CHAT_WORKFLOW_CONDITIONAL_SKILLS,
|
|
901
|
+
denied: OPERATOR_CHAT_DENIED_INSTRUCTION_SKILLS,
|
|
902
|
+
disabledNames,
|
|
903
|
+
} });
|
|
904
|
+
}
|
|
905
|
+
else {
|
|
906
|
+
const gate = gateMutation(req, deps);
|
|
907
|
+
if (!gate.ok) {
|
|
908
|
+
sendJson(res, gate.status, { ok: false, error: { code: gate.code, message: gate.message } });
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
const body = await readJsonBody(req);
|
|
912
|
+
const disabledNames = await writeSkillPreferences(deps.appData, Array.isArray(body.disabledNames) ? body.disabledNames.filter((name) => typeof name === "string") : []);
|
|
913
|
+
deps.runtime.setDisabledInstructionSkills(disabledNames);
|
|
914
|
+
sendJson(res, 200, { ok: true, data: { disabledNames, appliesTo: "new-sessions" } });
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
catch (error) {
|
|
918
|
+
const failure = error;
|
|
919
|
+
sendJson(res, failure.status ?? 400, { ok: false, error: { code: failure.code ?? "INVALID_INPUT", message: failure.message } });
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
async function handleChatFiles(res, deps, sessionId, query) {
|
|
923
|
+
if (!await requireChatSession(res, deps, sessionId))
|
|
924
|
+
return;
|
|
925
|
+
const q = query.trim().toLowerCase();
|
|
926
|
+
const files = (await walkRepoFiles(deps.appData.repoRoot, { skipSensitive: true, maxFiles: 5000 }))
|
|
927
|
+
.map((entry) => entry.repoRelative).filter((file) => !isSensitivePath(file) && (!q || file.toLowerCase().includes(q)))
|
|
928
|
+
.sort((a, b) => a.localeCompare(b)).slice(0, 50).map((path) => ({ path }));
|
|
929
|
+
sendJson(res, 200, { ok: true, files });
|
|
930
|
+
}
|
|
931
|
+
async function handleRuntimeContext(res, deps, sessionId) {
|
|
932
|
+
const record = await deps.store.get(sessionId);
|
|
933
|
+
if (!record) {
|
|
934
|
+
sendJson(res, 404, { ok: false });
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
const skills = await loadOperatorChatInstructionSkills(deps.skillsDir);
|
|
938
|
+
const selection = deps.runtime.getRuntimeSelection(sessionId);
|
|
939
|
+
sendJson(res, 200, { ok: true, context: projectRuntimeContext({ systemPrompt: "Operator Chat isolated system prompt", skills: skills.loaded, model: selection.model ?? (record.modelProvider && record.modelId ? { provider: record.modelProvider, modelId: record.modelId } : undefined), thinkingLevel: selection.thinkingLevel ?? record.thinkingLevel, activeTools: selection.activeTools }) });
|
|
940
|
+
}
|
|
941
|
+
async function handleModels(req, res, deps, sessionId) {
|
|
942
|
+
if (!await requireChatSession(res, deps, sessionId))
|
|
943
|
+
return;
|
|
944
|
+
try {
|
|
945
|
+
if ((req.method ?? "GET").toUpperCase() === "GET") {
|
|
946
|
+
sendJson(res, 200, { ok: true, models: await deps.runtime.listModels(sessionId), thinkingLevels: THINKING_LEVELS });
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
const gate = gateMutation(req, deps);
|
|
950
|
+
if (!gate.ok) {
|
|
951
|
+
sendJson(res, gate.status, { ok: false, error: { code: gate.code, message: gate.message } });
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
const body = await readJsonBody(req);
|
|
955
|
+
let updated;
|
|
956
|
+
if (typeof body.provider === "string" && typeof body.modelId === "string") {
|
|
957
|
+
const model = await deps.runtime.applyModel(sessionId, { provider: body.provider, modelId: body.modelId });
|
|
958
|
+
updated = await deps.store.setRuntimeSelection(sessionId, { modelProvider: model.provider, modelId: model.modelId });
|
|
959
|
+
}
|
|
960
|
+
else if (typeof body.thinkingLevel === "string" && THINKING_LEVELS.includes(body.thinkingLevel)) {
|
|
961
|
+
const thinkingLevel = deps.runtime.applyThinkingLevel(sessionId, body.thinkingLevel);
|
|
962
|
+
updated = await deps.store.setRuntimeSelection(sessionId, { thinkingLevel });
|
|
963
|
+
}
|
|
964
|
+
else {
|
|
965
|
+
sendJson(res, 400, { ok: false, error: { code: "INVALID_INPUT", message: "provider/modelId or thinkingLevel is required" } });
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
sendJson(res, 200, { ok: true, session: updated });
|
|
969
|
+
}
|
|
970
|
+
catch (error) {
|
|
971
|
+
sendJson(res, 409, { ok: false, error: { code: "MODEL_SWITCH_FAILED", message: error instanceof Error ? error.message : String(error) } });
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
async function requireChatSession(res, deps, sessionId) {
|
|
975
|
+
if (await deps.store.get(sessionId))
|
|
976
|
+
return true;
|
|
977
|
+
sendJson(res, 404, { ok: false, error: { code: "NOT_FOUND", message: `chat session not found: ${sessionId}` } });
|
|
978
|
+
return false;
|
|
979
|
+
}
|
|
980
|
+
async function handleLinkWorkspaceOperation(req, res, deps, sessionId) {
|
|
981
|
+
const gate = gateMutation(req, deps);
|
|
982
|
+
if (!gate.ok) {
|
|
983
|
+
sendJson(res, gate.status, { ok: false, error: { code: gate.code, message: gate.message } });
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
if (!await requireChatSession(res, deps, sessionId))
|
|
987
|
+
return;
|
|
988
|
+
try {
|
|
989
|
+
const body = await readJsonBody(req);
|
|
990
|
+
const operationId = typeof body.operationId === "string" ? body.operationId.trim() : "";
|
|
991
|
+
if (!operationId) {
|
|
992
|
+
sendJson(res, 400, { ok: false, error: { code: "INVALID_INPUT", message: "operationId is required" } });
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
const operations = deps.runtime.actionContext?.operations;
|
|
996
|
+
if (!operations || !await operations.get(operationId)) {
|
|
997
|
+
sendJson(res, 404, { ok: false, error: { code: "NOT_FOUND", message: `operation not found: ${operationId}` } });
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
await deps.operationLinker.link({ sessionId, turnId: deps.events.latestTurnId(sessionId) ?? "workspace", toolCallId: `workspace-${operationId}`, operationId });
|
|
1001
|
+
sendJson(res, 200, { ok: true });
|
|
1002
|
+
}
|
|
1003
|
+
catch (error) {
|
|
1004
|
+
sendJson(res, 400, { ok: false, error: { code: "INVALID_INPUT", message: error instanceof Error ? error.message : String(error) } });
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
async function handleStartInterview(req, res, deps, sessionId) {
|
|
1008
|
+
const gate = gateMutation(req, deps);
|
|
1009
|
+
if (!gate.ok) {
|
|
1010
|
+
sendJson(res, gate.status, { ok: false, error: { code: gate.code, message: gate.message } });
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
if (!await requireChatSession(res, deps, sessionId))
|
|
1014
|
+
return;
|
|
1015
|
+
try {
|
|
1016
|
+
const body = await readJsonBody(req);
|
|
1017
|
+
const taskId = typeof body.taskId === "string" ? body.taskId.trim() : "";
|
|
1018
|
+
if (!taskId) {
|
|
1019
|
+
sendJson(res, 400, { ok: false, error: { code: "INVALID_INPUT", message: "taskId is required" } });
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
const adapter = new ChatInterviewAdapter(deps.appData, deps.events);
|
|
1023
|
+
const state = await adapter.start({ operatorSessionId: sessionId, taskId, ...(typeof body.title === "string" ? { title: body.title } : {}), ...(body.initialDraft && typeof body.initialDraft === "object" ? { initialDraft: body.initialDraft } : {}) });
|
|
1024
|
+
sendJson(res, 201, { ok: true, ...projectChatInterviewState(state) });
|
|
1025
|
+
}
|
|
1026
|
+
catch (error) {
|
|
1027
|
+
sendJson(res, 400, { ok: false, error: { code: "INTERVIEW_START_FAILED", message: error instanceof Error ? error.message : String(error) } });
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
async function handleAnswerInterview(req, res, deps, sessionId, interviewSessionId) {
|
|
1031
|
+
const gate = gateMutation(req, deps);
|
|
1032
|
+
if (!gate.ok) {
|
|
1033
|
+
sendJson(res, gate.status, { ok: false, error: { code: gate.code, message: gate.message } });
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
if (!await requireChatSession(res, deps, sessionId))
|
|
1037
|
+
return;
|
|
1038
|
+
try {
|
|
1039
|
+
const body = await readJsonBody(req);
|
|
1040
|
+
const questionId = typeof body.questionId === "string" ? body.questionId : "";
|
|
1041
|
+
const response = typeof body.response === "string" ? body.response : "";
|
|
1042
|
+
if (!questionId || !response) {
|
|
1043
|
+
sendJson(res, 400, { ok: false, error: { code: "INVALID_INPUT", message: "questionId and response are required" } });
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
const state = await new ChatInterviewAdapter(deps.appData, deps.events).answer({ operatorSessionId: sessionId, interviewSessionId, questionId, response, ...(typeof body.text === "string" ? { text: body.text } : {}) });
|
|
1047
|
+
sendJson(res, 200, { ok: true, ...projectChatInterviewState(state) });
|
|
1048
|
+
}
|
|
1049
|
+
catch (error) {
|
|
1050
|
+
sendJson(res, 400, { ok: false, error: { code: "INTERVIEW_ANSWER_FAILED", message: error instanceof Error ? error.message : String(error) } });
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
async function handleGetInterviewFact(res, deps, sessionId, interviewSessionId, fact) {
|
|
1054
|
+
if (!await requireChatSession(res, deps, sessionId))
|
|
1055
|
+
return;
|
|
1056
|
+
const state = await new ChatInterviewAdapter(deps.appData, deps.events).get(interviewSessionId);
|
|
1057
|
+
if (!state || state.interview.operatorSessionId !== sessionId) {
|
|
1058
|
+
sendJson(res, 404, { ok: false, error: { code: "NOT_FOUND", message: `interview session not found: ${interviewSessionId}` } });
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
const projected = projectChatInterviewState(state);
|
|
1062
|
+
sendJson(res, 200, { ok: true, [fact]: projected[fact] });
|
|
1063
|
+
}
|
|
1064
|
+
async function ctxConfirmationCard(deps, sessionId, turnId, confirmationId) {
|
|
1065
|
+
const confirmation = await deps.runtime.actionContext?.confirmations.get(confirmationId);
|
|
1066
|
+
if (!confirmation)
|
|
1067
|
+
return;
|
|
1068
|
+
const taskId = String(confirmation.taskContractBinding.taskId ?? "");
|
|
1069
|
+
appendHumanGateCard(deps, sessionId, makeHumanGateCard({
|
|
1070
|
+
cardId: `dag-${confirmationId}`,
|
|
1071
|
+
gateType: "dag-run",
|
|
1072
|
+
state: confirmation.state === "confirmed" ? "confirmed" : "prepared",
|
|
1073
|
+
taskId,
|
|
1074
|
+
confirmationId,
|
|
1075
|
+
expiresAt: confirmation.expiresAt,
|
|
1076
|
+
inspectHref: `/inspect/?taskId=${encodeURIComponent(taskId)}#/`,
|
|
1077
|
+
humanGateToken: confirmation.humanGateToken,
|
|
1078
|
+
displayOnlyChallenges: ["source", "writer-scope", "verification", "human-gates"],
|
|
1079
|
+
contractRevision: confirmation.taskContractBinding.revision,
|
|
1080
|
+
}));
|
|
1081
|
+
void turnId;
|
|
1082
|
+
}
|
|
1083
|
+
async function ctxMutationGateCard(deps, sessionId, receiptId) {
|
|
1084
|
+
const { MutationGateReceiptStore } = await import("../mutation-gate-receipt-store.js");
|
|
1085
|
+
const store = new MutationGateReceiptStore(deps.appData);
|
|
1086
|
+
const receipt = await store.get(receiptId);
|
|
1087
|
+
if (!receipt)
|
|
1088
|
+
return;
|
|
1089
|
+
appendHumanGateCard(deps, sessionId, makeHumanGateCard({
|
|
1090
|
+
cardId: `mutation-${receipt.receiptId}`,
|
|
1091
|
+
gateType: "mutation",
|
|
1092
|
+
state: "prepared",
|
|
1093
|
+
taskId: receipt.action,
|
|
1094
|
+
receiptId: receipt.receiptId,
|
|
1095
|
+
expiresAt: receipt.expiresAt,
|
|
1096
|
+
humanGateToken: receipt.humanGateToken,
|
|
1097
|
+
inspectHref: `/inspect/#/`,
|
|
1098
|
+
}));
|
|
1099
|
+
}
|
|
1100
|
+
function actionResultBody(result) {
|
|
1101
|
+
const body = result.body;
|
|
1102
|
+
return (body.result && typeof body.result === "object" ? body.result : body);
|
|
1103
|
+
}
|
|
1104
|
+
function appendHumanGateCard(deps, sessionId, card) {
|
|
1105
|
+
deps.events.append(sessionId, deps.events.latestTurnId(sessionId) ?? "human-gate", {
|
|
1106
|
+
kind: "human-gate-card",
|
|
1107
|
+
data: card,
|
|
1108
|
+
});
|
|
1109
|
+
}
|
|
1110
|
+
async function liveContractBinding(ctx, taskId) {
|
|
1111
|
+
const draft = await ctx.drafts.get(taskId);
|
|
1112
|
+
if (!draft)
|
|
1113
|
+
throw new Error(`draft not found: ${taskId}`);
|
|
1114
|
+
const shown = await dispatchOperatorAction(ctx, { action: "contractShow", actionParams: { taskId } });
|
|
1115
|
+
if (shown.kind === "error")
|
|
1116
|
+
throw new Error("contractShow failed");
|
|
1117
|
+
const result = actionResultBody(shown);
|
|
1118
|
+
const state = result.state;
|
|
1119
|
+
const revision = state?.ref?.revision ?? 0;
|
|
1120
|
+
const expectedObservedHash = state?.observedCanonicalHash ?? "0".repeat(64);
|
|
1121
|
+
return { taskId, revision, canonicalHash: state?.ref?.canonicalHash ?? expectedObservedHash, draftSha256: draft.draftSha256, expectedObservedHash };
|
|
1122
|
+
}
|
|
1123
|
+
async function handleContractApplyHumanGate(req, res, deps, sessionId, step) {
|
|
1124
|
+
const gate = gateMutation(req, deps);
|
|
1125
|
+
if (!gate.ok) {
|
|
1126
|
+
sendJson(res, gate.status, { ok: false, error: { code: gate.code, message: gate.message } });
|
|
1127
|
+
return;
|
|
1128
|
+
}
|
|
1129
|
+
if (!await requireChatSession(res, deps, sessionId))
|
|
1130
|
+
return;
|
|
1131
|
+
const ctx = deps.runtime.actionContext;
|
|
1132
|
+
if (!ctx) {
|
|
1133
|
+
sendJson(res, 503, { ok: false, error: { code: "CHAT_NO_ACTION_CONTEXT", message: "Chat runtime has no operator action context wired" } });
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
try {
|
|
1137
|
+
const body = await readJsonBody(req);
|
|
1138
|
+
const receipts = new ContractApplyReceiptStore(deps.appData);
|
|
1139
|
+
if (step === "prepare") {
|
|
1140
|
+
const taskId = typeof body.taskId === "string" ? body.taskId.trim() : "";
|
|
1141
|
+
if (!taskId)
|
|
1142
|
+
throw new Error("taskId is required");
|
|
1143
|
+
const binding = await liveContractBinding(ctx, taskId);
|
|
1144
|
+
const expiresAt = new Date(Date.now() + 30 * 60_000).toISOString();
|
|
1145
|
+
const receiptId = `apply_${crypto.randomUUID()}`;
|
|
1146
|
+
const humanGateToken = issueHumanGateToken({ confirmationId: receiptId, payloadHash: contractApplyPayloadHash(binding), expiresAt }, deps.bootToken.confirmationToken);
|
|
1147
|
+
const receipt = await receipts.prepare({ receiptId, operatorSessionId: sessionId, ...binding, expiresAt, humanGateToken });
|
|
1148
|
+
const card = makeHumanGateCard({ cardId: `contract-${receipt.receiptId}`, gateType: "contract-apply", state: "prepared", taskId, receiptId: receipt.receiptId, expiresAt, humanGateToken, contractRevision: binding.revision, contractDiffSummary: `revision ${binding.revision}; draft ${binding.draftSha256.slice(0, 12)}; observed ${binding.expectedObservedHash.slice(0, 12)}`, inspectHref: `/inspect/?taskId=${encodeURIComponent(taskId)}#/` });
|
|
1149
|
+
appendHumanGateCard(deps, sessionId, card);
|
|
1150
|
+
sendJson(res, 201, { ok: true, card });
|
|
1151
|
+
return;
|
|
1152
|
+
}
|
|
1153
|
+
const receiptId = typeof body.receiptId === "string" ? body.receiptId : "";
|
|
1154
|
+
const receipt = await receipts.get(receiptId);
|
|
1155
|
+
if (!receipt || receipt.operatorSessionId !== sessionId) {
|
|
1156
|
+
sendJson(res, 404, { ok: false, error: { code: "NOT_FOUND", message: "contract apply receipt not found" } });
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
const current = await liveContractBinding(ctx, receipt.taskId);
|
|
1160
|
+
const check = verifyHumanGateToken(body.humanGateToken, { confirmationId: receipt.receiptId, payloadHash: contractApplyPayloadHash(current) }, deps.bootToken.confirmationToken);
|
|
1161
|
+
if (!check.ok) {
|
|
1162
|
+
sendJson(res, 403, { ok: false, error: { code: check.code, message: check.message } });
|
|
1163
|
+
return;
|
|
1164
|
+
}
|
|
1165
|
+
const reserved = await receipts.tryReserve(receiptId);
|
|
1166
|
+
if (!reserved.ok) {
|
|
1167
|
+
sendJson(res, reserved.code === "NOT_FOUND" ? 404 : reserved.code === "EXPIRED" ? 410 : 409, {
|
|
1168
|
+
ok: false,
|
|
1169
|
+
error: { code: reserved.code, message: reserved.message },
|
|
1170
|
+
});
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
1173
|
+
if (!reserved.reserved) {
|
|
1174
|
+
const operationId = reserved.receipt.operationId;
|
|
1175
|
+
const card = makeHumanGateCard({
|
|
1176
|
+
cardId: `contract-${receiptId}`,
|
|
1177
|
+
gateType: "contract-apply",
|
|
1178
|
+
state: "dispatched",
|
|
1179
|
+
taskId: receipt.taskId,
|
|
1180
|
+
receiptId,
|
|
1181
|
+
expiresAt: receipt.expiresAt,
|
|
1182
|
+
contractRevision: receipt.revision,
|
|
1183
|
+
operationId,
|
|
1184
|
+
inspectHref: operationId
|
|
1185
|
+
? `/inspect/?operationId=${encodeURIComponent(operationId)}#/`
|
|
1186
|
+
: `/inspect/?taskId=${encodeURIComponent(receipt.taskId)}#/`,
|
|
1187
|
+
});
|
|
1188
|
+
appendHumanGateCard(deps, sessionId, card);
|
|
1189
|
+
sendJson(res, 202, {
|
|
1190
|
+
ok: true,
|
|
1191
|
+
card,
|
|
1192
|
+
result: operationId
|
|
1193
|
+
? { operationId, state: "accepted", action: "contractApply", clientRequestId: `chat-apply-${receiptId}` }
|
|
1194
|
+
: { state: reserved.reason },
|
|
1195
|
+
});
|
|
1196
|
+
return;
|
|
1197
|
+
}
|
|
1198
|
+
const clientRequestId = typeof body.clientRequestId === "string" && body.clientRequestId.trim()
|
|
1199
|
+
? body.clientRequestId.trim()
|
|
1200
|
+
: `chat-apply-${receiptId}`;
|
|
1201
|
+
const result = await dispatchOperatorAction(ctx, {
|
|
1202
|
+
action: "contractApply",
|
|
1203
|
+
actionParams: {
|
|
1204
|
+
taskId: receipt.taskId,
|
|
1205
|
+
expectedRevision: receipt.revision,
|
|
1206
|
+
expectedObservedHash: receipt.expectedObservedHash,
|
|
1207
|
+
},
|
|
1208
|
+
clientRequestId,
|
|
1209
|
+
});
|
|
1210
|
+
if (result.kind === "error") {
|
|
1211
|
+
await receipts.releaseToPrepared(receiptId);
|
|
1212
|
+
sendJson(res, result.status, result.body);
|
|
1213
|
+
return;
|
|
1214
|
+
}
|
|
1215
|
+
const operationId = result.kind === "accepted" ? result.body.operationId : undefined;
|
|
1216
|
+
await receipts.markDispatched(receiptId, operationId);
|
|
1217
|
+
const card = makeHumanGateCard({ cardId: `contract-${receiptId}`, gateType: "contract-apply", state: "dispatched", taskId: receipt.taskId, receiptId, expiresAt: receipt.expiresAt, contractRevision: receipt.revision, operationId, inspectHref: operationId ? `/inspect/?operationId=${encodeURIComponent(operationId)}#/` : `/inspect/?taskId=${encodeURIComponent(receipt.taskId)}#/` });
|
|
1218
|
+
appendHumanGateCard(deps, sessionId, card);
|
|
1219
|
+
if (operationId)
|
|
1220
|
+
await deps.operationLinker.link({ sessionId, turnId: deps.events.latestTurnId(sessionId) ?? "human-gate", toolCallId: "human-contract-apply", operationId });
|
|
1221
|
+
sendJson(res, result.status, { ok: true, card, result: actionResultBody(result) });
|
|
1222
|
+
}
|
|
1223
|
+
catch (error) {
|
|
1224
|
+
sendJson(res, 400, { ok: false, error: { code: "HUMAN_GATE_FAILED", message: error instanceof Error ? error.message : String(error) } });
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
async function handleDagHumanGate(req, res, deps, sessionId, step) {
|
|
1228
|
+
const gate = gateMutation(req, deps);
|
|
1229
|
+
if (!gate.ok) {
|
|
1230
|
+
sendJson(res, gate.status, { ok: false, error: { code: gate.code, message: gate.message } });
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
if (!await requireChatSession(res, deps, sessionId))
|
|
1234
|
+
return;
|
|
1235
|
+
const ctx = deps.runtime.actionContext;
|
|
1236
|
+
if (!ctx) {
|
|
1237
|
+
sendJson(res, 503, { ok: false, error: { code: "CHAT_NO_ACTION_CONTEXT", message: "Chat runtime has no operator action context wired" } });
|
|
1238
|
+
return;
|
|
1239
|
+
}
|
|
1240
|
+
try {
|
|
1241
|
+
const body = await readJsonBody(req);
|
|
1242
|
+
const action = step === "prepare" ? "prepareDagConfirmation" : step === "confirm" ? "confirmDagConfirmation" : "runDag";
|
|
1243
|
+
const result = await dispatchOperatorAction(ctx, {
|
|
1244
|
+
action,
|
|
1245
|
+
actionParams: body,
|
|
1246
|
+
clientRequestId: typeof body.clientRequestId === "string" ? body.clientRequestId : `chat-${sessionId}-${Date.now()}`,
|
|
1247
|
+
});
|
|
1248
|
+
if (result.kind === "error") {
|
|
1249
|
+
sendJson(res, result.status, result.body);
|
|
1250
|
+
return;
|
|
1251
|
+
}
|
|
1252
|
+
const projected = actionResultBody(result);
|
|
1253
|
+
const confirmation = (projected.confirmation ?? await ctx.confirmations.get(String(body.confirmationId ?? "")));
|
|
1254
|
+
const confirmationId = String(confirmation?.confirmationId ?? body.confirmationId ?? "");
|
|
1255
|
+
const taskBinding = confirmation?.taskContractBinding;
|
|
1256
|
+
const taskId = String(taskBinding?.taskId ?? body.taskId ?? "");
|
|
1257
|
+
const state = step === "prepare" ? "prepared" : step === "confirm" ? "confirmed" : "dispatched";
|
|
1258
|
+
const operationId = result.kind === "accepted" ? result.body.operationId : undefined;
|
|
1259
|
+
const dagText = typeof body.dagText === "string" ? body.dagText : typeof body.dagJson === "string" ? body.dagJson : "";
|
|
1260
|
+
let dagSpine;
|
|
1261
|
+
try {
|
|
1262
|
+
const parsed = JSON.parse(dagText);
|
|
1263
|
+
dagSpine = parsed.nodes?.map((node) => String(node.id ?? "")).filter(Boolean);
|
|
1264
|
+
}
|
|
1265
|
+
catch { /* display summary is optional */ }
|
|
1266
|
+
const card = makeHumanGateCard({
|
|
1267
|
+
cardId: `dag-${confirmationId}`,
|
|
1268
|
+
gateType: "dag-run",
|
|
1269
|
+
state,
|
|
1270
|
+
taskId,
|
|
1271
|
+
confirmationId,
|
|
1272
|
+
expiresAt: String(confirmation?.expiresAt ?? new Date().toISOString()),
|
|
1273
|
+
inspectHref: operationId ? `/inspect/?operationId=${encodeURIComponent(operationId)}#/` : `/inspect/?taskId=${encodeURIComponent(taskId)}#/`,
|
|
1274
|
+
...(confirmation?.humanGateToken ? { humanGateToken: confirmation.humanGateToken } : {}),
|
|
1275
|
+
displayOnlyChallenges: Array.isArray(projected.requiredChallenges) ? projected.requiredChallenges.map(String) : undefined,
|
|
1276
|
+
contractRevision: typeof taskBinding?.revision === "number" ? taskBinding.revision : undefined,
|
|
1277
|
+
dagSpine,
|
|
1278
|
+
operationId,
|
|
1279
|
+
});
|
|
1280
|
+
appendHumanGateCard(deps, sessionId, card);
|
|
1281
|
+
if (operationId)
|
|
1282
|
+
await deps.operationLinker.link({ sessionId, turnId: deps.events.latestTurnId(sessionId) ?? "human-gate", toolCallId: `human-${step}`, operationId });
|
|
1283
|
+
sendJson(res, result.status, { ok: true, card, result: projected });
|
|
1284
|
+
}
|
|
1285
|
+
catch (error) {
|
|
1286
|
+
sendJson(res, 400, { ok: false, error: { code: "HUMAN_GATE_FAILED", message: error instanceof Error ? error.message : String(error) } });
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
async function handleMutationHumanGate(req, res, deps, sessionId) {
|
|
1290
|
+
const gate = gateMutation(req, deps);
|
|
1291
|
+
if (!gate.ok) {
|
|
1292
|
+
sendJson(res, gate.status, { ok: false, error: { code: gate.code, message: gate.message } });
|
|
1293
|
+
return;
|
|
1294
|
+
}
|
|
1295
|
+
if (!(await requireChatSession(res, deps, sessionId)))
|
|
1296
|
+
return;
|
|
1297
|
+
const ctx = deps.runtime.actionContext;
|
|
1298
|
+
if (!ctx) {
|
|
1299
|
+
sendJson(res, 503, {
|
|
1300
|
+
ok: false,
|
|
1301
|
+
error: {
|
|
1302
|
+
code: "CHAT_NO_ACTION_CONTEXT",
|
|
1303
|
+
message: "Chat runtime has no operator action context wired",
|
|
1304
|
+
},
|
|
1305
|
+
});
|
|
1306
|
+
return;
|
|
1307
|
+
}
|
|
1308
|
+
try {
|
|
1309
|
+
const body = await readJsonBody(req);
|
|
1310
|
+
const receiptId = typeof body.receiptId === "string" ? body.receiptId : "";
|
|
1311
|
+
const { MutationGateReceiptStore } = await import("../mutation-gate-receipt-store.js");
|
|
1312
|
+
const store = new MutationGateReceiptStore(deps.appData);
|
|
1313
|
+
const receipt = await store.get(receiptId);
|
|
1314
|
+
if (!receipt || receipt.operatorSessionId !== ctx.operatorSessionId) {
|
|
1315
|
+
sendJson(res, 404, {
|
|
1316
|
+
ok: false,
|
|
1317
|
+
error: { code: "NOT_FOUND", message: "mutation gate receipt not found" },
|
|
1318
|
+
});
|
|
1319
|
+
return;
|
|
1320
|
+
}
|
|
1321
|
+
const result = await dispatchOperatorAction(ctx, {
|
|
1322
|
+
action: receipt.action,
|
|
1323
|
+
actionParams: {
|
|
1324
|
+
...receipt.actionParams,
|
|
1325
|
+
confirmationId: receipt.receiptId,
|
|
1326
|
+
humanGateToken: body.humanGateToken ?? receipt.humanGateToken,
|
|
1327
|
+
},
|
|
1328
|
+
clientRequestId: `chat-mutation-${receipt.receiptId}`,
|
|
1329
|
+
});
|
|
1330
|
+
if (result.kind === "error") {
|
|
1331
|
+
sendJson(res, result.status, result.body);
|
|
1332
|
+
return;
|
|
1333
|
+
}
|
|
1334
|
+
const operationId = result.kind === "accepted" ? result.body.operationId : undefined;
|
|
1335
|
+
const card = makeHumanGateCard({
|
|
1336
|
+
cardId: `mutation-${receiptId}`,
|
|
1337
|
+
gateType: "mutation",
|
|
1338
|
+
state: "dispatched",
|
|
1339
|
+
taskId: receipt.action,
|
|
1340
|
+
receiptId,
|
|
1341
|
+
expiresAt: receipt.expiresAt,
|
|
1342
|
+
operationId,
|
|
1343
|
+
inspectHref: operationId
|
|
1344
|
+
? `/inspect/?operationId=${encodeURIComponent(operationId)}#/`
|
|
1345
|
+
: `/inspect/#/`,
|
|
1346
|
+
});
|
|
1347
|
+
appendHumanGateCard(deps, sessionId, card);
|
|
1348
|
+
if (operationId) {
|
|
1349
|
+
await deps.operationLinker.link({
|
|
1350
|
+
sessionId,
|
|
1351
|
+
turnId: deps.events.latestTurnId(sessionId) ?? "human-gate",
|
|
1352
|
+
toolCallId: "human-mutation-apply",
|
|
1353
|
+
operationId,
|
|
1354
|
+
});
|
|
1355
|
+
}
|
|
1356
|
+
sendJson(res, result.status, { ok: true, card, result: actionResultBody(result) });
|
|
1357
|
+
}
|
|
1358
|
+
catch (error) {
|
|
1359
|
+
sendJson(res, 400, {
|
|
1360
|
+
ok: false,
|
|
1361
|
+
error: {
|
|
1362
|
+
code: "HUMAN_GATE_FAILED",
|
|
1363
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1364
|
+
},
|
|
1365
|
+
});
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
async function handleConfirmTaskKind(req, res, deps, sessionId, interviewSessionId) {
|
|
1369
|
+
const gate = gateMutation(req, deps);
|
|
1370
|
+
if (!gate.ok) {
|
|
1371
|
+
sendJson(res, gate.status, { ok: false, error: { code: gate.code, message: gate.message } });
|
|
1372
|
+
return;
|
|
1373
|
+
}
|
|
1374
|
+
if (!await requireChatSession(res, deps, sessionId))
|
|
1375
|
+
return;
|
|
1376
|
+
try {
|
|
1377
|
+
const body = await readJsonBody(req);
|
|
1378
|
+
if (typeof body.taskKind !== "string" || !body.taskKind.trim()) {
|
|
1379
|
+
sendJson(res, 400, { ok: false, error: { code: "INVALID_INPUT", message: "taskKind is required" } });
|
|
1380
|
+
return;
|
|
1381
|
+
}
|
|
1382
|
+
const state = await new ChatInterviewAdapter(deps.appData, deps.events).confirmTaskKind({ operatorSessionId: sessionId, interviewSessionId, taskKind: body.taskKind });
|
|
1383
|
+
sendJson(res, 200, { ok: true, ...projectChatInterviewState(state) });
|
|
1384
|
+
}
|
|
1385
|
+
catch (error) {
|
|
1386
|
+
sendJson(res, 400, { ok: false, error: { code: "TASK_KIND_CONFIRM_FAILED", message: error instanceof Error ? error.message : String(error) } });
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
export async function handleChatCompact(req, res, deps, sessionId) {
|
|
1390
|
+
const gate = gateMutation(req, deps);
|
|
1391
|
+
if (!gate.ok) {
|
|
1392
|
+
sendJson(res, gate.status, { ok: false, error: { code: gate.code, message: gate.message } });
|
|
1393
|
+
return;
|
|
1394
|
+
}
|
|
1395
|
+
if (deps.events.getActiveTurn(sessionId)) {
|
|
1396
|
+
sendJson(res, 409, { ok: false, error: { code: "TURN_ACTIVE", message: "cannot compact an active turn" } });
|
|
1397
|
+
return;
|
|
1398
|
+
}
|
|
1399
|
+
const record = await deps.store.get(sessionId);
|
|
1400
|
+
if (!record) {
|
|
1401
|
+
sendJson(res, 404, { ok: false, error: { code: "NOT_FOUND", message: `chat session not found: ${sessionId}` } });
|
|
1402
|
+
return;
|
|
1403
|
+
}
|
|
1404
|
+
if (!deps.runtime.hasSession(sessionId)) {
|
|
1405
|
+
const reopened = await deps.runtime.reopenSession({ sessionId, sessionFile: record.sessionFile, ...(record.modelProvider && record.modelId ? { model: { provider: record.modelProvider, modelId: record.modelId } } : {}) });
|
|
1406
|
+
if (!reopened.ok) {
|
|
1407
|
+
sendJson(res, 409, { ok: false, error: { code: reopened.code, message: reopened.message } });
|
|
1408
|
+
return;
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
try {
|
|
1412
|
+
const snapshot = await deps.runtime.compact(sessionId);
|
|
1413
|
+
const event = deps.events.append(sessionId, deps.events.latestTurnId(sessionId) ?? `${sessionId}:compact`, { kind: "compact", data: snapshot });
|
|
1414
|
+
sendJson(res, 200, { ok: true, snapshot, eventId: event.eventId });
|
|
1415
|
+
}
|
|
1416
|
+
catch (error) {
|
|
1417
|
+
sendJson(res, 409, { ok: false, error: { code: "COMPACTION_FAILED", message: error instanceof Error ? error.message : String(error) } });
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
export async function handleChatMessages(res, deps, sessionId, url) {
|
|
1421
|
+
try {
|
|
1422
|
+
const limit = Number(url.searchParams.get("limit") ?? 30);
|
|
1423
|
+
const page = await deps.store.listMessages(sessionId, { limit: Number.isFinite(limit) ? limit : 30, beforeId: url.searchParams.get("beforeId") ?? undefined });
|
|
1424
|
+
sendJson(res, 200, { ok: true, ...page });
|
|
1425
|
+
}
|
|
1426
|
+
catch (error) {
|
|
1427
|
+
sendJson(res, 404, { ok: false, error: { code: "NOT_FOUND", message: error instanceof Error ? error.message : String(error) } });
|
|
1428
|
+
}
|
|
299
1429
|
}
|
|
300
1430
|
/**
|
|
301
1431
|
* Dispatch a chat HTTP request. Returns true if handled.
|
|
@@ -304,26 +1434,181 @@ export async function handleDeleteChatSession(req, res, deps, sessionId) {
|
|
|
304
1434
|
export async function handleChatRequest(req, res, deps, pathname) {
|
|
305
1435
|
if (!deps)
|
|
306
1436
|
return false;
|
|
307
|
-
if (!pathname.startsWith("/api/operator/v1/chat/"))
|
|
1437
|
+
if (!pathname.startsWith("/api/operator/v1/chat/") && !pathname.startsWith("/api/operator/v1/pi/"))
|
|
308
1438
|
return false;
|
|
309
1439
|
const method = (req.method ?? "GET").toUpperCase();
|
|
310
1440
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
1441
|
+
if (pathname === "/api/operator/v1/pi/models-config/test" && method === "POST") {
|
|
1442
|
+
const gate = gateMutation(req, deps);
|
|
1443
|
+
if (!gate.ok) {
|
|
1444
|
+
sendJson(res, gate.status, { ok: false, error: { code: gate.code, message: gate.message } });
|
|
1445
|
+
return true;
|
|
1446
|
+
}
|
|
1447
|
+
try {
|
|
1448
|
+
const body = await readJsonBody(req);
|
|
1449
|
+
if (typeof body.provider !== "string" || typeof body.modelId !== "string")
|
|
1450
|
+
throw new Error("provider and modelId are required");
|
|
1451
|
+
sendJson(res, 200, { ok: true, data: await deps.runtime.testConfiguredModel({ provider: body.provider, modelId: body.modelId }) });
|
|
1452
|
+
}
|
|
1453
|
+
catch (error) {
|
|
1454
|
+
sendJson(res, 400, { ok: false, error: { code: "MODEL_TEST_FAILED", message: error instanceof Error ? error.message : String(error) } });
|
|
1455
|
+
}
|
|
1456
|
+
return true;
|
|
1457
|
+
}
|
|
1458
|
+
if (pathname === "/api/operator/v1/pi/models-config" && (method === "GET" || method === "PATCH")) {
|
|
1459
|
+
await handlePiConfig(req, res, deps, "models");
|
|
1460
|
+
return true;
|
|
1461
|
+
}
|
|
1462
|
+
if (pathname === "/api/operator/v1/pi/packages" && method === "GET") {
|
|
1463
|
+
await handlePiConfig(req, res, deps, "packages");
|
|
1464
|
+
return true;
|
|
1465
|
+
}
|
|
1466
|
+
if (pathname === "/api/operator/v1/chat/instruction-skills" && method === "GET") {
|
|
1467
|
+
await handlePiConfig(req, res, deps, "skills");
|
|
1468
|
+
return true;
|
|
1469
|
+
}
|
|
1470
|
+
if (pathname === "/api/operator/v1/chat/instruction-skills/preferences" && method === "PUT") {
|
|
1471
|
+
await handlePiConfig(req, res, deps, "skills");
|
|
1472
|
+
return true;
|
|
1473
|
+
}
|
|
311
1474
|
if (method === "GET" && pathname === "/api/operator/v1/chat/capabilities") {
|
|
312
1475
|
await handleChatCapabilities(req, res, deps);
|
|
313
1476
|
return true;
|
|
314
1477
|
}
|
|
1478
|
+
if (method === "GET" && pathname === "/api/operator/v1/chat/repo/tree") {
|
|
1479
|
+
await handleRepoBrowser(res, deps, "tree", url.searchParams.get("path") ?? "");
|
|
1480
|
+
return true;
|
|
1481
|
+
}
|
|
1482
|
+
if (method === "GET" && pathname === "/api/operator/v1/chat/repo/file") {
|
|
1483
|
+
const filePath = url.searchParams.get("path");
|
|
1484
|
+
if (!filePath)
|
|
1485
|
+
sendJson(res, 400, { ok: false, error: { code: "INVALID_PATH", message: "path is required" } });
|
|
1486
|
+
else
|
|
1487
|
+
await handleRepoBrowser(res, deps, "file", filePath);
|
|
1488
|
+
return true;
|
|
1489
|
+
}
|
|
1490
|
+
if (method === "GET" && pathname === "/api/operator/v1/chat/sessions") {
|
|
1491
|
+
await handleListChatSessions(req, res, deps);
|
|
1492
|
+
return true;
|
|
1493
|
+
}
|
|
1494
|
+
const interviewStart = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/interviews$/);
|
|
1495
|
+
if (method === "POST" && interviewStart) {
|
|
1496
|
+
await handleStartInterview(req, res, deps, decodeURIComponent(interviewStart[1]));
|
|
1497
|
+
return true;
|
|
1498
|
+
}
|
|
1499
|
+
const interviewAnswer = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/interviews\/([^/]+)\/answers$/);
|
|
1500
|
+
if (method === "POST" && interviewAnswer) {
|
|
1501
|
+
await handleAnswerInterview(req, res, deps, decodeURIComponent(interviewAnswer[1]), decodeURIComponent(interviewAnswer[2]));
|
|
1502
|
+
return true;
|
|
1503
|
+
}
|
|
1504
|
+
const interviewFact = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/interviews\/([^/]+)\/(draft|assessment)$/);
|
|
1505
|
+
if (method === "GET" && interviewFact) {
|
|
1506
|
+
await handleGetInterviewFact(res, deps, decodeURIComponent(interviewFact[1]), decodeURIComponent(interviewFact[2]), interviewFact[3]);
|
|
1507
|
+
return true;
|
|
1508
|
+
}
|
|
1509
|
+
const taskKindConfirm = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/interviews\/([^/]+)\/task-kind\/confirm$/);
|
|
1510
|
+
if (method === "POST" && taskKindConfirm) {
|
|
1511
|
+
await handleConfirmTaskKind(req, res, deps, decodeURIComponent(taskKindConfirm[1]), decodeURIComponent(taskKindConfirm[2]));
|
|
1512
|
+
return true;
|
|
1513
|
+
}
|
|
1514
|
+
const contractHumanGate = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/human-gate\/contract-apply(?:\/(prepare))?$/);
|
|
1515
|
+
if (method === "POST" && contractHumanGate) {
|
|
1516
|
+
await handleContractApplyHumanGate(req, res, deps, decodeURIComponent(contractHumanGate[1]), contractHumanGate[2] ? "prepare" : "apply");
|
|
1517
|
+
return true;
|
|
1518
|
+
}
|
|
1519
|
+
const dagHumanGate = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/human-gate\/dag-(prepare|confirm|run)$/);
|
|
1520
|
+
if (method === "POST" && dagHumanGate) {
|
|
1521
|
+
await handleDagHumanGate(req, res, deps, decodeURIComponent(dagHumanGate[1]), dagHumanGate[2]);
|
|
1522
|
+
return true;
|
|
1523
|
+
}
|
|
1524
|
+
const mutationHumanGate = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/human-gate\/mutation-apply$/);
|
|
1525
|
+
if (method === "POST" && mutationHumanGate) {
|
|
1526
|
+
await handleMutationHumanGate(req, res, deps, decodeURIComponent(mutationHumanGate[1]));
|
|
1527
|
+
return true;
|
|
1528
|
+
}
|
|
1529
|
+
const m4Route = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/(draft|files|runtime-context|models|model)$/);
|
|
1530
|
+
if (m4Route) {
|
|
1531
|
+
const id = decodeURIComponent(m4Route[1]);
|
|
1532
|
+
const route = m4Route[2];
|
|
1533
|
+
if (route === "draft" && method === "PUT") {
|
|
1534
|
+
await handleComposerDraft(req, res, deps, id);
|
|
1535
|
+
return true;
|
|
1536
|
+
}
|
|
1537
|
+
if (route === "files" && method === "GET") {
|
|
1538
|
+
await handleChatFiles(res, deps, id, url.searchParams.get("q") ?? "");
|
|
1539
|
+
return true;
|
|
1540
|
+
}
|
|
1541
|
+
if (route === "runtime-context" && method === "GET") {
|
|
1542
|
+
await handleRuntimeContext(res, deps, id);
|
|
1543
|
+
return true;
|
|
1544
|
+
}
|
|
1545
|
+
if (route === "models" && method === "GET") {
|
|
1546
|
+
await handleModels(req, res, deps, id);
|
|
1547
|
+
return true;
|
|
1548
|
+
}
|
|
1549
|
+
if (route === "model" && method === "POST") {
|
|
1550
|
+
await handleModels(req, res, deps, id);
|
|
1551
|
+
return true;
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
const sessionMainline = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/branch\/mainline$/);
|
|
1555
|
+
if (method === "POST" && sessionMainline) {
|
|
1556
|
+
await handleSwitchMainlineChatSession(req, res, deps, decodeURIComponent(sessionMainline[1]));
|
|
1557
|
+
return true;
|
|
1558
|
+
}
|
|
1559
|
+
const sessionFork = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/fork$/);
|
|
1560
|
+
if (method === "POST" && sessionFork) {
|
|
1561
|
+
await handleForkChatSession(req, res, deps, decodeURIComponent(sessionFork[1]));
|
|
1562
|
+
return true;
|
|
1563
|
+
}
|
|
1564
|
+
const sessionBranch = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/branch$/);
|
|
1565
|
+
if (method === "POST" && sessionBranch) {
|
|
1566
|
+
await handleBranchChatSession(req, res, deps, decodeURIComponent(sessionBranch[1]));
|
|
1567
|
+
return true;
|
|
1568
|
+
}
|
|
1569
|
+
const sessionReopen = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/reopen$/);
|
|
1570
|
+
if (method === "POST" && sessionReopen) {
|
|
1571
|
+
await handleReopenChatSession(req, res, deps, decodeURIComponent(sessionReopen[1]));
|
|
1572
|
+
return true;
|
|
1573
|
+
}
|
|
1574
|
+
const sessionTurn = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/turns$/);
|
|
1575
|
+
if (method === "POST" && sessionTurn) {
|
|
1576
|
+
await handleCreateChatTurn(req, res, deps, decodeURIComponent(sessionTurn[1]));
|
|
1577
|
+
return true;
|
|
1578
|
+
}
|
|
1579
|
+
const sessionTurnDetail = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/turns\/([^/]+)$/);
|
|
1580
|
+
if (method === "GET" && sessionTurnDetail) {
|
|
1581
|
+
await handleGetChatTurn(req, res, deps, decodeURIComponent(sessionTurnDetail[1]), decodeURIComponent(sessionTurnDetail[2]));
|
|
1582
|
+
return true;
|
|
1583
|
+
}
|
|
1584
|
+
const operationLink = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/operation-links$/);
|
|
1585
|
+
if (method === "POST" && operationLink) {
|
|
1586
|
+
await handleLinkWorkspaceOperation(req, res, deps, decodeURIComponent(operationLink[1]));
|
|
1587
|
+
return true;
|
|
1588
|
+
}
|
|
1589
|
+
const sessionCompact = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/compact$/);
|
|
1590
|
+
if (method === "POST" && sessionCompact) {
|
|
1591
|
+
await handleChatCompact(req, res, deps, decodeURIComponent(sessionCompact[1]));
|
|
1592
|
+
return true;
|
|
1593
|
+
}
|
|
1594
|
+
const sessionMessages = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/messages$/);
|
|
1595
|
+
if (method === "GET" && sessionMessages) {
|
|
1596
|
+
await handleChatMessages(res, deps, decodeURIComponent(sessionMessages[1]), url);
|
|
1597
|
+
return true;
|
|
1598
|
+
}
|
|
315
1599
|
const sessionPrompt = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/prompt$/);
|
|
316
1600
|
if (method === "POST" && sessionPrompt) {
|
|
317
1601
|
await handleChatPrompt(req, res, deps, decodeURIComponent(sessionPrompt[1]));
|
|
318
1602
|
return true;
|
|
319
1603
|
}
|
|
1604
|
+
const sessionContext = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/context$/);
|
|
1605
|
+
if (method === "GET" && sessionContext) {
|
|
1606
|
+
await handleGetTaskContext(req, res, deps, decodeURIComponent(sessionContext[1]));
|
|
1607
|
+
return true;
|
|
1608
|
+
}
|
|
320
1609
|
const sessionEvents = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/events$/);
|
|
321
1610
|
if (method === "GET" && sessionEvents) {
|
|
322
|
-
|
|
323
|
-
// events stream is reserved for degraded-state recovery (V13); for now
|
|
324
|
-
// we return the persisted session snapshot so a reconnecting client can
|
|
325
|
-
// re-render history.
|
|
326
|
-
await handleGetChatSession(req, res, deps, decodeURIComponent(sessionEvents[1]));
|
|
1611
|
+
await handleChatEventsStream(req, res, deps, decodeURIComponent(sessionEvents[1]));
|
|
327
1612
|
return true;
|
|
328
1613
|
}
|
|
329
1614
|
const sessionMatch = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)$/);
|
|
@@ -333,6 +1618,10 @@ export async function handleChatRequest(req, res, deps, pathname) {
|
|
|
333
1618
|
await handleGetChatSession(req, res, deps, id);
|
|
334
1619
|
return true;
|
|
335
1620
|
}
|
|
1621
|
+
if (method === "PATCH") {
|
|
1622
|
+
await handlePatchChatSession(req, res, deps, id);
|
|
1623
|
+
return true;
|
|
1624
|
+
}
|
|
336
1625
|
if (method === "DELETE") {
|
|
337
1626
|
await handleDeleteChatSession(req, res, deps, id);
|
|
338
1627
|
return true;
|