@tea-agent/loop-agent 0.22.0 → 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/AGENTS.md +42 -108
- package/CHANGELOG.md +85 -0
- package/README.md +8 -5
- package/bin/agent-worker.js +0 -0
- package/dist/application/context-usage/skill-resolution-stats.js +263 -0
- package/dist/application/dag/generate-task-dag.js +17 -3
- package/dist/cli/command-definitions.js +8 -7
- package/dist/cli/program.js +17 -15
- package/dist/commands/doctor.js +269 -18
- package/dist/commands/init.js +101 -86
- package/dist/commands/stats.js +40 -11
- package/dist/executors/shell-executor.js +20 -7
- package/dist/shared/operator/capabilities.js +486 -3
- package/dist/worker/console/app-data.js +6 -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/instruction-skills.js +217 -0
- package/dist/worker/console/chat/interview-adapter.js +136 -0
- package/dist/worker/console/chat/model-resolver.js +106 -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 +1143 -0
- 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 +67 -0
- package/dist/worker/console/chat/routes.js +1646 -0
- 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 +437 -0
- package/dist/worker/console/chat/shortcuts.js +15 -0
- package/dist/worker/console/chat/tool-adapter.js +125 -0
- package/dist/worker/console/chat/tools.js +195 -0
- 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 +68 -1
- 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/worker/feature/profile-schema.js +1 -1
- 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 +71 -22
- package/dist/workflows/dag/node-execution.js +38 -1
- package/dist/workflows/dag/output-protocol.js +89 -0
- package/dist/workflows/dag/prompt.js +35 -1
- package/dist/workflows/dag/recovery-recommendation.js +45 -0
- package/dist/workflows/dag/report.js +28 -1
- package/dist/workflows/dag/rerun-task.js +1 -1
- package/dist/workflows/dag/scheduler.js +9 -0
- package/dist/workflows/dag/types.js +12 -0
- package/dist/workflows/dag/validate.js +55 -0
- package/docs/README.md +73 -156
- package/docs/architecture/README.md +7 -6
- package/docs/architecture/dag-execution.md +2 -2
- package/docs/architecture/evolution.md +16 -14
- package/docs/architecture/system-overview.md +1 -1
- package/docs/architecture/worker-and-feature.md +3 -3
- package/docs/governance/README.md +15 -0
- package/docs/{harness-methodology-debugging.md → governance/harness-methodology-debugging.md} +27 -3
- package/docs/init-surface.manifest.json +22 -4
- package/docs/operations/README.md +12 -0
- package/docs/{local-development-environment.md → operations/local-development-environment.md} +1 -1
- package/docs/skills/vetted-skill-registry.md +23 -3
- package/docs/templates/README.md +55 -0
- package/docs/templates/backend-test-dag.json +2 -2
- package/docs/templates/evaluation/agents-map-slim-v1.candidate.json +9 -0
- package/docs/templates/evaluation/agents-map-slim-v1.md +87 -0
- package/docs/templates/evaluation/agents-map-verbose-v0.candidate.json +9 -0
- package/docs/templates/evaluation/agents-map-verbose-v0.md +153 -0
- package/docs/templates/hybrid-dag.json +1 -1
- package/docs/templates/progress-log.md +9 -2
- package/harness.json +4 -4
- package/package.json +5 -5
- package/scripts/kb-bootstrap-init-skeleton.sh +2 -2
- package/skills/agent-worker/SKILL.md +1 -1
- package/skills/grill-with-docs/SKILL.md +44 -52
- package/skills/grill-with-docs/adr-format.md +37 -26
- package/skills/grill-with-docs/context-format.md +18 -26
- package/skills/loop-agent/SKILL.md +28 -112
- package/skills/loop-agent/references/command-reference.md +9 -3
- package/skills/loop-agent/references/harness-policy.md +3 -3
- package/skills/loop-agent/references/hybrid-dag.md +1 -1
- package/skills/loop-agent/references/orchestrator-and-interventions.md +1 -1
- package/skills/loop-agent/references/task-workflow.md +2 -0
- package/skills/systematic-debugging/SKILL.md +20 -4
- package/skills/test-driven-development/SKILL.md +10 -3
- package/dist/worker/console/static/assets/index-CUDke82y.js +0 -18
- package/dist/worker/console/static/assets/index-wSEksVSO.css +0 -1
- /package/docs/{harness-methodology-tdd.md → governance/harness-methodology-tdd.md} +0 -0
- /package/docs/{harness-methodology-verification.md → governance/harness-methodology-verification.md} +0 -0
|
@@ -0,0 +1,1646 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operator Chat — HTTP route handlers (plan W7 / W8 backend).
|
|
3
|
+
*
|
|
4
|
+
* Routes (all under /api/operator/v1/chat):
|
|
5
|
+
* POST /sessions — create a new Chat session
|
|
6
|
+
* GET /sessions/:id — fetch session metadata + messages
|
|
7
|
+
* POST /sessions/:id/prompt — SSE stream: send a prompt, stream events
|
|
8
|
+
* GET /sessions/:id/events — SSE stream: (re)connect to live events
|
|
9
|
+
* GET /capabilities — Chat tool surface + instruction skills
|
|
10
|
+
* DELETE /sessions/:id — dispose a Chat session
|
|
11
|
+
*
|
|
12
|
+
* Mutation routes go through the Console mutation gate (boot token + Origin).
|
|
13
|
+
* Read routes (GET capabilities / sessions) are open to loopback.
|
|
14
|
+
*/
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
16
|
+
import { OPERATOR_CHAT_ALLOWED_TOOLS, OPERATOR_CHAT_DENIED_TOOLS, OPERATOR_CHAT_DENIED_OPERATOR_ACTIONS, } from "./tools.js";
|
|
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 { 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";
|
|
34
|
+
import { sendJson } from "../routes.js";
|
|
35
|
+
import { openSseResponse, parseLastEventId, writeSseEvent, } from "../operation-sse.js";
|
|
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
|
+
}
|
|
42
|
+
res.write(`event: ${event.kind}\n`);
|
|
43
|
+
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
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
|
+
}
|
|
92
|
+
async function readJsonBody(req, maxBytes = 2 * 1024 * 1024) {
|
|
93
|
+
const chunks = [];
|
|
94
|
+
let total = 0;
|
|
95
|
+
for await (const chunk of req) {
|
|
96
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
97
|
+
total += buf.length;
|
|
98
|
+
if (total > maxBytes) {
|
|
99
|
+
throw new Error(`request body exceeds ${maxBytes} bytes`);
|
|
100
|
+
}
|
|
101
|
+
chunks.push(buf);
|
|
102
|
+
}
|
|
103
|
+
if (chunks.length === 0)
|
|
104
|
+
return {};
|
|
105
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
106
|
+
if (!raw.trim())
|
|
107
|
+
return {};
|
|
108
|
+
return JSON.parse(raw);
|
|
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
|
+
}
|
|
140
|
+
function gateMutation(req, deps) {
|
|
141
|
+
const failure = evaluateMutationGate(req, {
|
|
142
|
+
bootToken: deps.bootToken.value,
|
|
143
|
+
confirmationToken: deps.bootToken.confirmationToken,
|
|
144
|
+
consoleOrigin: deps.getConsoleOrigin(),
|
|
145
|
+
allowNonLoopbackAccess: deps.allowNonLoopbackAccess,
|
|
146
|
+
});
|
|
147
|
+
if (failure) {
|
|
148
|
+
return {
|
|
149
|
+
ok: false,
|
|
150
|
+
status: failure.status,
|
|
151
|
+
code: failure.code,
|
|
152
|
+
message: failure.message,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
return { ok: true };
|
|
156
|
+
}
|
|
157
|
+
export async function handleChatCapabilities(_req, res, deps) {
|
|
158
|
+
const toolSchemas = buildModelCallableToolSchemas();
|
|
159
|
+
const skills = await loadOperatorChatInstructionSkills(deps.skillsDir);
|
|
160
|
+
sendJson(res, 200, {
|
|
161
|
+
ok: true,
|
|
162
|
+
schemaVersion: 1,
|
|
163
|
+
allowedTools: OPERATOR_CHAT_ALLOWED_TOOLS,
|
|
164
|
+
deniedTools: OPERATOR_CHAT_DENIED_TOOLS,
|
|
165
|
+
deniedOperatorActions: OPERATOR_CHAT_DENIED_OPERATOR_ACTIONS,
|
|
166
|
+
tools: toolSchemas,
|
|
167
|
+
instructionSkills: {
|
|
168
|
+
allowed: OPERATOR_CHAT_ALLOWED_INSTRUCTION_SKILLS,
|
|
169
|
+
loaded: skills.loaded.map((s) => ({
|
|
170
|
+
name: s.name,
|
|
171
|
+
description: s.description,
|
|
172
|
+
charCount: s.charCount,
|
|
173
|
+
})),
|
|
174
|
+
failures: skills.failures,
|
|
175
|
+
skipped: skills.skipped,
|
|
176
|
+
promptFragmentCharCount: composeInstructionSkillsPrompt(skills.loaded).length,
|
|
177
|
+
},
|
|
178
|
+
// M0-A: bare bash is removed; safe-read/safe-grep enforce the repo and
|
|
179
|
+
// secret boundaries instead.
|
|
180
|
+
hasBash: false,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
export async function handleCreateChatSession(req, res, deps) {
|
|
184
|
+
const gate = gateMutation(req, deps);
|
|
185
|
+
if (!gate.ok) {
|
|
186
|
+
sendJson(res, gate.status, {
|
|
187
|
+
ok: false,
|
|
188
|
+
error: { code: gate.code, message: gate.message },
|
|
189
|
+
});
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
let body;
|
|
193
|
+
try {
|
|
194
|
+
body = await readJsonBody(req);
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
sendJson(res, 400, {
|
|
198
|
+
ok: false,
|
|
199
|
+
error: {
|
|
200
|
+
code: "INVALID_INPUT",
|
|
201
|
+
message: error instanceof Error ? error.message : String(error),
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
return;
|
|
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
|
+
}
|
|
210
|
+
const provider = typeof body.modelProvider === "string" ? body.modelProvider : undefined;
|
|
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
|
+
}
|
|
217
|
+
try {
|
|
218
|
+
deps.runtime.setDisabledInstructionSkills(await readSkillPreferences(deps.appData));
|
|
219
|
+
const snapshot = snapshotInput === undefined ? undefined : projectCompactSnapshot(snapshotInput);
|
|
220
|
+
const handle = await deps.runtime.createSession({
|
|
221
|
+
...(provider && modelId ? { model: { provider, modelId } } : {}),
|
|
222
|
+
...(snapshot ? { systemPromptSuffix: `Read-only initial Operator context snapshot; this is not runtime rollback.\n${snapshot.summary}` } : {}),
|
|
223
|
+
});
|
|
224
|
+
await deps.store.create({
|
|
225
|
+
sessionId: handle.sessionId,
|
|
226
|
+
sessionFile: handle.sessionFile,
|
|
227
|
+
modelProvider: handle.model?.provider,
|
|
228
|
+
modelId: handle.model?.modelId,
|
|
229
|
+
});
|
|
230
|
+
if (snapshot)
|
|
231
|
+
deps.events.append(handle.sessionId, `${handle.sessionId}:snapshot`, { kind: "compact", data: snapshot });
|
|
232
|
+
sendJson(res, 201, {
|
|
233
|
+
ok: true,
|
|
234
|
+
sessionId: handle.sessionId,
|
|
235
|
+
sessionFile: handle.sessionFile,
|
|
236
|
+
model: handle.model,
|
|
237
|
+
activeTools: handle.activeTools,
|
|
238
|
+
hasBash: false,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
catch (error) {
|
|
242
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
243
|
+
sendJson(res, 500, {
|
|
244
|
+
ok: false,
|
|
245
|
+
error: { code: "CHAT_SESSION_CREATE_FAILED", message },
|
|
246
|
+
});
|
|
247
|
+
}
|
|
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
|
+
}
|
|
324
|
+
export async function handleGetChatSession(_req, res, deps, sessionId) {
|
|
325
|
+
const record = await deps.store.get(sessionId);
|
|
326
|
+
if (!record) {
|
|
327
|
+
sendJson(res, 404, {
|
|
328
|
+
ok: false,
|
|
329
|
+
error: { code: "NOT_FOUND", message: `chat session not found: ${sessionId}` },
|
|
330
|
+
});
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
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
|
+
});
|
|
420
|
+
}
|
|
421
|
+
export async function handleChatPrompt(req, res, deps, sessionId) {
|
|
422
|
+
const gate = gateMutation(req, deps);
|
|
423
|
+
if (!gate.ok) {
|
|
424
|
+
sendJson(res, gate.status, {
|
|
425
|
+
ok: false,
|
|
426
|
+
error: { code: gate.code, message: gate.message },
|
|
427
|
+
});
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
let body;
|
|
431
|
+
try {
|
|
432
|
+
body = await readJsonBody(req);
|
|
433
|
+
}
|
|
434
|
+
catch (error) {
|
|
435
|
+
sendJson(res, 400, {
|
|
436
|
+
ok: false,
|
|
437
|
+
error: {
|
|
438
|
+
code: "INVALID_INPUT",
|
|
439
|
+
message: error instanceof Error ? error.message : String(error),
|
|
440
|
+
},
|
|
441
|
+
});
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
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
|
+
}
|
|
453
|
+
if (!text) {
|
|
454
|
+
sendJson(res, 400, {
|
|
455
|
+
ok: false,
|
|
456
|
+
error: { code: "INVALID_INPUT", message: "text is required" },
|
|
457
|
+
});
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
if (!deps.runtime.hasSession(sessionId)) {
|
|
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
|
+
: {}),
|
|
476
|
+
});
|
|
477
|
+
if (!reopen.ok) {
|
|
478
|
+
sendJson(res, 409, { ok: false, error: { code: reopen.code, message: reopen.message } });
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
const actionContext = deps.runtime.actionContext;
|
|
483
|
+
if (!actionContext) {
|
|
484
|
+
sendJson(res, 503, {
|
|
485
|
+
ok: false,
|
|
486
|
+
error: {
|
|
487
|
+
code: "CHAT_NO_ACTION_CONTEXT",
|
|
488
|
+
message: "Chat runtime has no operator action context wired",
|
|
489
|
+
},
|
|
490
|
+
});
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
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.
|
|
511
|
+
openSseResponse(res);
|
|
512
|
+
const ac = new AbortController();
|
|
513
|
+
let closed = false;
|
|
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;
|
|
520
|
+
closed = true;
|
|
521
|
+
abortedByClient = true;
|
|
522
|
+
ac.abort();
|
|
523
|
+
});
|
|
524
|
+
// Lazily import to avoid circular module load.
|
|
525
|
+
const { runChatTurn } = await import("./session-store.js");
|
|
526
|
+
try {
|
|
527
|
+
const outcome = await runChatTurn({
|
|
528
|
+
runtime: deps.runtime,
|
|
529
|
+
store: deps.store,
|
|
530
|
+
actionContext,
|
|
531
|
+
sessionId,
|
|
532
|
+
text,
|
|
533
|
+
images,
|
|
534
|
+
signal: ac.signal,
|
|
535
|
+
onOperationAccepted: (accepted) => deps.operationLinker.link({
|
|
536
|
+
sessionId,
|
|
537
|
+
turnId,
|
|
538
|
+
toolCallId: accepted.toolCallId,
|
|
539
|
+
operationId: accepted.operationId,
|
|
540
|
+
}),
|
|
541
|
+
onEvent: (event) => {
|
|
542
|
+
if (closed)
|
|
543
|
+
return;
|
|
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
|
+
}
|
|
555
|
+
// heartbeat is a keep-alive: write it as an SSE comment frame (":\n\n")
|
|
556
|
+
// which proxies/browsers treat as traffic but clients ignore as data.
|
|
557
|
+
// It is NOT persisted (no real state change).
|
|
558
|
+
if (event.type === "heartbeat") {
|
|
559
|
+
if (!res.writableEnded)
|
|
560
|
+
res.write(": heartbeat\n\n");
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
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);
|
|
569
|
+
},
|
|
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
|
+
}
|
|
580
|
+
}
|
|
581
|
+
catch (error) {
|
|
582
|
+
// runChatTurn should not reject (it catches runtime.prompt failures
|
|
583
|
+
// internally and returns {ok:false}), but a pre-prompt persistence failure
|
|
584
|
+
// (e.g. store.appendMessage for the user message) CAN throw after the SSE
|
|
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
|
+
});
|
|
592
|
+
if (!closed) {
|
|
593
|
+
const persisted = deps.events.append(sessionId, turnId, {
|
|
594
|
+
kind: "error",
|
|
595
|
+
data: { message },
|
|
596
|
+
});
|
|
597
|
+
if (!res.writableEnded)
|
|
598
|
+
writeChatSse(res, persisted);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
finally {
|
|
602
|
+
// Guarantee the SSE stream is terminated regardless of outcome so the
|
|
603
|
+
// connection never hangs (regression: previously a rejected promise left
|
|
604
|
+
// res open and the client waited indefinitely).
|
|
605
|
+
if (!closed && !res.writableEnded) {
|
|
606
|
+
res.end();
|
|
607
|
+
}
|
|
608
|
+
}
|
|
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
|
+
}
|
|
800
|
+
export async function handleDeleteChatSession(req, res, deps, sessionId) {
|
|
801
|
+
const gate = gateMutation(req, deps);
|
|
802
|
+
if (!gate.ok) {
|
|
803
|
+
sendJson(res, gate.status, {
|
|
804
|
+
ok: false,
|
|
805
|
+
error: { code: gate.code, message: gate.message },
|
|
806
|
+
});
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
deps.runtime.dispose(sessionId);
|
|
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
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
/**
|
|
1431
|
+
* Dispatch a chat HTTP request. Returns true if handled.
|
|
1432
|
+
* Mounted under /api/operator/v1/chat/*.
|
|
1433
|
+
*/
|
|
1434
|
+
export async function handleChatRequest(req, res, deps, pathname) {
|
|
1435
|
+
if (!deps)
|
|
1436
|
+
return false;
|
|
1437
|
+
if (!pathname.startsWith("/api/operator/v1/chat/") && !pathname.startsWith("/api/operator/v1/pi/"))
|
|
1438
|
+
return false;
|
|
1439
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
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
|
+
}
|
|
1474
|
+
if (method === "GET" && pathname === "/api/operator/v1/chat/capabilities") {
|
|
1475
|
+
await handleChatCapabilities(req, res, deps);
|
|
1476
|
+
return true;
|
|
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
|
+
}
|
|
1599
|
+
const sessionPrompt = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/prompt$/);
|
|
1600
|
+
if (method === "POST" && sessionPrompt) {
|
|
1601
|
+
await handleChatPrompt(req, res, deps, decodeURIComponent(sessionPrompt[1]));
|
|
1602
|
+
return true;
|
|
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
|
+
}
|
|
1609
|
+
const sessionEvents = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/events$/);
|
|
1610
|
+
if (method === "GET" && sessionEvents) {
|
|
1611
|
+
await handleChatEventsStream(req, res, deps, decodeURIComponent(sessionEvents[1]));
|
|
1612
|
+
return true;
|
|
1613
|
+
}
|
|
1614
|
+
const sessionMatch = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)$/);
|
|
1615
|
+
if (sessionMatch) {
|
|
1616
|
+
const id = decodeURIComponent(sessionMatch[1]);
|
|
1617
|
+
if (method === "GET") {
|
|
1618
|
+
await handleGetChatSession(req, res, deps, id);
|
|
1619
|
+
return true;
|
|
1620
|
+
}
|
|
1621
|
+
if (method === "PATCH") {
|
|
1622
|
+
await handlePatchChatSession(req, res, deps, id);
|
|
1623
|
+
return true;
|
|
1624
|
+
}
|
|
1625
|
+
if (method === "DELETE") {
|
|
1626
|
+
await handleDeleteChatSession(req, res, deps, id);
|
|
1627
|
+
return true;
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
if (method === "POST" && pathname === "/api/operator/v1/chat/sessions") {
|
|
1631
|
+
await handleCreateChatSession(req, res, deps);
|
|
1632
|
+
return true;
|
|
1633
|
+
}
|
|
1634
|
+
if (isMutationMethod(method)) {
|
|
1635
|
+
// Already gated above for known routes; unknown mutation routes 404.
|
|
1636
|
+
sendJson(res, 404, {
|
|
1637
|
+
ok: false,
|
|
1638
|
+
error: { code: "NOT_FOUND", message: `unknown chat route: ${pathname}` },
|
|
1639
|
+
});
|
|
1640
|
+
return true;
|
|
1641
|
+
}
|
|
1642
|
+
sendJson(res, 404, { ok: false, error: { code: "NOT_FOUND", message: `unknown chat route: ${pathname}` } });
|
|
1643
|
+
return true;
|
|
1644
|
+
}
|
|
1645
|
+
// Re-export for route wiring; writeSseEvent kept for parity with operation-sse.
|
|
1646
|
+
void writeSseEvent;
|