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