@migor/agentia 0.9.4 → 0.9.5
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 +688 -4
- package/README.md +1 -1
- package/dist/AGENTS.md +313 -33
- package/dist/container/container.js +15 -0
- package/dist/container/container.js.map +1 -1
- package/dist/core/limits.d.ts +93 -13
- package/dist/core/limits.js +103 -13
- package/dist/core/limits.js.map +1 -1
- package/dist/core/line-framing.d.ts +36 -0
- package/dist/core/line-framing.js +39 -0
- package/dist/core/line-framing.js.map +1 -0
- package/dist/core/message.d.ts +11 -0
- package/dist/core/run.d.ts +68 -4
- package/dist/core/run.js +27 -1
- package/dist/core/run.js.map +1 -1
- package/dist/core/schema.js +59 -8
- package/dist/core/schema.js.map +1 -1
- package/dist/core/timeout.d.ts +43 -1
- package/dist/core/timeout.js +76 -3
- package/dist/core/timeout.js.map +1 -1
- package/dist/core/tool.d.ts +25 -1
- package/dist/core/trace.d.ts +40 -1
- package/dist/core/trace.js +26 -0
- package/dist/core/trace.js.map +1 -1
- package/dist/engine/defer.d.ts +49 -0
- package/dist/engine/defer.js +72 -0
- package/dist/engine/defer.js.map +1 -0
- package/dist/engine/forwarded.d.ts +14 -5
- package/dist/engine/forwarded.js +4 -1
- package/dist/engine/forwarded.js.map +1 -1
- package/dist/engine/loop-result.d.ts +65 -7
- package/dist/engine/loop-result.js +24 -5
- package/dist/engine/loop-result.js.map +1 -1
- package/dist/engine/loop.d.ts +2 -0
- package/dist/engine/loop.js +157 -17
- package/dist/engine/loop.js.map +1 -1
- package/dist/engine/mcp-server-http.d.ts +2 -0
- package/dist/engine/mcp-server-http.js +215 -0
- package/dist/engine/mcp-server-http.js.map +1 -0
- package/dist/engine/mcp-server-stdio.d.ts +15 -0
- package/dist/engine/mcp-server-stdio.js +106 -0
- package/dist/engine/mcp-server-stdio.js.map +1 -0
- package/dist/engine/mcp-server.d.ts +134 -0
- package/dist/engine/mcp-server.js +232 -0
- package/dist/engine/mcp-server.js.map +1 -0
- package/dist/engine/menu-drift.d.ts +34 -0
- package/dist/engine/menu-drift.js +81 -0
- package/dist/engine/menu-drift.js.map +1 -0
- package/dist/engine/policy.d.ts +2 -1
- package/dist/engine/policy.js +7 -1
- package/dist/engine/policy.js.map +1 -1
- package/dist/engine/replay.d.ts +2 -1
- package/dist/engine/replay.js +3 -2
- package/dist/engine/replay.js.map +1 -1
- package/dist/engine/resume-input.d.ts +10 -0
- package/dist/engine/resume-input.js +11 -0
- package/dist/engine/resume-input.js.map +1 -1
- package/dist/engine/run-config.d.ts +26 -2
- package/dist/engine/run-config.js +60 -0
- package/dist/engine/run-config.js.map +1 -1
- package/dist/engine/spec.d.ts +33 -4
- package/dist/engine/spec.js.map +1 -1
- package/dist/engine/tool-context.d.ts +10 -5
- package/dist/engine/tool-context.js +5 -1
- package/dist/engine/tool-context.js.map +1 -1
- package/dist/engine/tracer.d.ts +43 -5
- package/dist/engine/tracer.js +73 -10
- package/dist/engine/tracer.js.map +1 -1
- package/dist/engine/trimming.js +28 -2
- package/dist/engine/trimming.js.map +1 -1
- package/dist/engine/turn.d.ts +60 -3
- package/dist/engine/turn.js +180 -71
- package/dist/engine/turn.js.map +1 -1
- package/dist/engine/types.d.ts +89 -3
- package/dist/engine/types.js +38 -2
- package/dist/engine/types.js.map +1 -1
- package/dist/eval/export.d.ts +48 -0
- package/dist/eval/export.js +146 -0
- package/dist/eval/export.js.map +1 -0
- package/dist/eval/harvest.js +7 -2
- package/dist/eval/harvest.js.map +1 -1
- package/dist/index.d.ts +8 -5
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/integrations/adapter-options.d.ts +22 -0
- package/dist/integrations/adapter-options.js +51 -0
- package/dist/integrations/adapter-options.js.map +1 -1
- package/dist/integrations/anthropic.d.ts +4 -2
- package/dist/integrations/anthropic.js +27 -27
- package/dist/integrations/anthropic.js.map +1 -1
- package/dist/integrations/file-sink.d.ts +20 -0
- package/dist/integrations/file-sink.js +27 -0
- package/dist/integrations/file-sink.js.map +1 -0
- package/dist/integrations/mcp-http.d.ts +3 -3
- package/dist/integrations/mcp-http.js +1 -1
- package/dist/integrations/mcp-http.js.map +1 -1
- package/dist/integrations/mcp-protocol.d.ts +115 -0
- package/dist/integrations/mcp-protocol.js +69 -0
- package/dist/integrations/mcp-protocol.js.map +1 -0
- package/dist/integrations/mcp-stdio.d.ts +3 -3
- package/dist/integrations/mcp-stdio.js +17 -3
- package/dist/integrations/mcp-stdio.js.map +1 -1
- package/dist/integrations/mcp.d.ts +6 -95
- package/dist/integrations/mcp.js +24 -66
- package/dist/integrations/mcp.js.map +1 -1
- package/dist/integrations/metrics-otlp.js +52 -7
- package/dist/integrations/metrics-otlp.js.map +1 -1
- package/dist/integrations/metrics-render.d.ts +15 -0
- package/dist/integrations/metrics-render.js +79 -2
- package/dist/integrations/metrics-render.js.map +1 -1
- package/dist/integrations/metrics-state.d.ts +99 -1
- package/dist/integrations/metrics-state.js +128 -4
- package/dist/integrations/metrics-state.js.map +1 -1
- package/dist/integrations/metrics.d.ts +62 -6
- package/dist/integrations/metrics.js +49 -8
- package/dist/integrations/metrics.js.map +1 -1
- package/dist/integrations/openai.js +9 -31
- package/dist/integrations/openai.js.map +1 -1
- package/dist/integrations/otlp.d.ts +3 -3
- package/dist/integrations/report.d.ts +7 -0
- package/dist/integrations/report.js +20 -1
- package/dist/integrations/report.js.map +1 -1
- package/dist/runtime/memory.d.ts +61 -1
- package/dist/runtime/memory.js +47 -7
- package/dist/runtime/memory.js.map +1 -1
- package/dist/runtime/run.d.ts +12 -2
- package/dist/runtime/run.js +112 -15
- package/dist/runtime/run.js.map +1 -1
- package/dist/store/fsStore.js +10 -4
- package/dist/store/fsStore.js.map +1 -1
- package/dist/store/record.d.ts +41 -0
- package/dist/store/record.js +54 -0
- package/dist/store/record.js.map +1 -0
- package/dist/store/redisStore.d.ts +3 -1
- package/dist/store/redisStore.js +21 -7
- package/dist/store/redisStore.js.map +1 -1
- package/dist/store/sqliteStore.d.ts +23 -0
- package/dist/store/sqliteStore.js +103 -8
- package/dist/store/sqliteStore.js.map +1 -1
- package/dist/store/store.d.ts +65 -2
- package/dist/store/store.js +5 -2
- package/dist/store/store.js.map +1 -1
- package/dist/toolkit/capability-cycles.d.ts +50 -0
- package/dist/toolkit/capability-cycles.js +88 -0
- package/dist/toolkit/capability-cycles.js.map +1 -0
- package/dist/toolkit/capability-slice.d.ts +86 -0
- package/dist/toolkit/capability-slice.js +98 -0
- package/dist/toolkit/capability-slice.js.map +1 -0
- package/dist/toolkit/module.d.ts +7 -1
- package/dist/toolkit/module.js +65 -32
- package/dist/toolkit/module.js.map +1 -1
- package/dist/transport/approval-policy.d.ts +1 -1
- package/dist/transport/approval-policy.js +8 -4
- package/dist/transport/approval-policy.js.map +1 -1
- package/dist/transport/async.d.ts +197 -18
- package/dist/transport/async.js +871 -85
- package/dist/transport/async.js.map +1 -1
- package/dist/transport/drain-gate.d.ts +6 -2
- package/dist/transport/drain-gate.js +14 -2
- package/dist/transport/drain-gate.js.map +1 -1
- package/dist/transport/http-endpoints.d.ts +151 -0
- package/dist/transport/http-endpoints.js +480 -0
- package/dist/transport/http-endpoints.js.map +1 -0
- package/dist/transport/http-io.d.ts +66 -0
- package/dist/transport/http-io.js +145 -0
- package/dist/transport/http-io.js.map +1 -0
- package/dist/transport/http-route.d.ts +10 -3
- package/dist/transport/http-route.js +18 -3
- package/dist/transport/http-route.js.map +1 -1
- package/dist/transport/http-shapes.d.ts +11 -0
- package/dist/transport/http-shapes.js +31 -1
- package/dist/transport/http-shapes.js.map +1 -1
- package/dist/transport/http.d.ts +22 -35
- package/dist/transport/http.js +98 -476
- package/dist/transport/http.js.map +1 -1
- package/dist/transport/owner-id.d.ts +29 -0
- package/dist/transport/owner-id.js +46 -0
- package/dist/transport/owner-id.js.map +1 -0
- package/dist/transport/owner-liveness.d.ts +8 -0
- package/dist/transport/owner-liveness.js +56 -0
- package/dist/transport/owner-liveness.js.map +1 -0
- package/dist/transport/resume-policy.d.ts +48 -10
- package/dist/transport/resume-policy.js +25 -10
- package/dist/transport/resume-policy.js.map +1 -1
- package/dist/transport/scheduler.js +9 -23
- package/dist/transport/scheduler.js.map +1 -1
- package/dist/transport/slot-pool.d.ts +9 -1
- package/dist/transport/slot-pool.js +9 -1
- package/dist/transport/slot-pool.js.map +1 -1
- package/dist/transport/task-events.d.ts +34 -3
- package/dist/transport/task-events.js +86 -5
- package/dist/transport/task-events.js.map +1 -1
- package/dist/transport/wake-policy.d.ts +50 -0
- package/dist/transport/wake-policy.js +43 -0
- package/dist/transport/wake-policy.js.map +1 -0
- package/package.json +6 -4
package/dist/transport/http.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { AsyncRunner } from './async.js';
|
|
2
|
+
import { handleRoute } from './http-endpoints.js';
|
|
3
|
+
import { errMessage, sendInternalError, sendJson } from './http-io.js';
|
|
4
4
|
import { isPreAuthRoute, routeRequest } from './http-route.js';
|
|
5
|
-
import { sseWriter } from './sse.js';
|
|
6
|
-
import { TaskInputError, normalizeMessages } from '../engine/spec.js';
|
|
7
5
|
import { zeroClauseOf } from '../core/limits.js';
|
|
8
6
|
/**
|
|
9
7
|
* 鉴权钩子可抛出的异常:显式携带 HTTP 状态与响应体。
|
|
10
8
|
* 钩子抛别的错误一律按 401 处理(设计 F4:与「工具抛错即 is_error」的既有风格一致)。
|
|
9
|
+
*
|
|
10
|
+
* 它**刻意留在本文件**(而不是随应答原语去 `http-io.ts`):`HttpException` 是**宿主契约**
|
|
11
|
+
* 的一部分(公开导出、鉴权钩子的抛出类型),而 `http-io.ts` 是零内部依赖的原语层 ——
|
|
12
|
+
* 搬过去会让原语层反向依赖宿主,只为了让 `sendUnauthorized` 能 `instanceof` 一下。
|
|
11
13
|
*/
|
|
12
14
|
export class HttpException extends Error {
|
|
13
15
|
status;
|
|
@@ -23,86 +25,6 @@ export class HttpException extends Error {
|
|
|
23
25
|
const DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
|
|
24
26
|
/** POST /run 并发上限缺省值:32 —— 够单机跑满,又不至于让上游被单宿主打爆 */
|
|
25
27
|
const DEFAULT_MAX_CONCURRENT_RUNS = 32;
|
|
26
|
-
/** 503 建议重试间隔(秒):同步 run 通常秒级,给 1s 足够错峰 */
|
|
27
|
-
const RETRY_AFTER_SECONDS = '1';
|
|
28
|
-
function sendJson(res, status, body) {
|
|
29
|
-
const payload = JSON.stringify(body);
|
|
30
|
-
res.writeHead(status, {
|
|
31
|
-
'content-type': 'application/json; charset=utf-8',
|
|
32
|
-
'content-length': Buffer.byteLength(payload),
|
|
33
|
-
});
|
|
34
|
-
res.end(payload);
|
|
35
|
-
}
|
|
36
|
-
/** 405 统一响应(方法不符):文案与其余错误一致用中文,并带 Allow 头。 */
|
|
37
|
-
function methodNotAllowed(res, method, allowed) {
|
|
38
|
-
res.setHeader('allow', allowed);
|
|
39
|
-
sendJson(res, 405, { error: `方法 ${method} 不被允许,请用 ${allowed}` });
|
|
40
|
-
}
|
|
41
|
-
/** Prometheus 文本响应(G4):抓取端按 text/plain; version=0.0.4 解析 */
|
|
42
|
-
function sendPrometheus(res, body) {
|
|
43
|
-
res.writeHead(200, {
|
|
44
|
-
'content-type': 'text/plain; version=0.0.4; charset=utf-8',
|
|
45
|
-
'content-length': Buffer.byteLength(body),
|
|
46
|
-
'cache-control': 'no-store',
|
|
47
|
-
});
|
|
48
|
-
res.end(body);
|
|
49
|
-
}
|
|
50
|
-
/**
|
|
51
|
-
* 读取 body 全文。两道兜底(原实现两者皆无):
|
|
52
|
-
* - **maxBytes 上限**:超限立即停止累积并 resolve 成哨兵(调用方回 413)——
|
|
53
|
-
* 否则一个未鉴权的大 body 就能把宿主内存打满;
|
|
54
|
-
* - **close 兜底**:客户端中途断开时既不会有 `end` 也不会有 `error`(Node 发 `aborted`/`close`),
|
|
55
|
-
* Promise 永不 settle → 每个半截请求漏一个 handler 与一份 buffer。
|
|
56
|
-
*/
|
|
57
|
-
function readBody(req, maxBytes) {
|
|
58
|
-
return new Promise((resolve) => {
|
|
59
|
-
const chunks = [];
|
|
60
|
-
let size = 0;
|
|
61
|
-
let done = false;
|
|
62
|
-
const cleanup = () => {
|
|
63
|
-
req.off('data', onData);
|
|
64
|
-
req.off('end', onEnd);
|
|
65
|
-
req.off('error', onAbort);
|
|
66
|
-
req.off('close', onAbort);
|
|
67
|
-
};
|
|
68
|
-
const finish = (r) => {
|
|
69
|
-
if (done)
|
|
70
|
-
return; // end 之后仍会来一次 close
|
|
71
|
-
done = true;
|
|
72
|
-
cleanup();
|
|
73
|
-
resolve(r);
|
|
74
|
-
};
|
|
75
|
-
const onData = (c) => {
|
|
76
|
-
size += c.length;
|
|
77
|
-
if (size > maxBytes) {
|
|
78
|
-
finish({ ok: false, reason: 'too-large' });
|
|
79
|
-
return;
|
|
80
|
-
}
|
|
81
|
-
chunks.push(c);
|
|
82
|
-
};
|
|
83
|
-
const onEnd = () => finish({ ok: true, raw: Buffer.concat(chunks).toString('utf8') });
|
|
84
|
-
const onAbort = () => finish({ ok: false, reason: 'aborted' });
|
|
85
|
-
req.on('data', onData);
|
|
86
|
-
req.on('end', onEnd);
|
|
87
|
-
req.on('error', onAbort);
|
|
88
|
-
req.on('close', onAbort);
|
|
89
|
-
});
|
|
90
|
-
}
|
|
91
|
-
function errMessage(e) {
|
|
92
|
-
return e instanceof Error ? e.message : String(e);
|
|
93
|
-
}
|
|
94
|
-
/**
|
|
95
|
-
* 轮询等待条件成立,直到 deadline(`Infinity` = 一直等)。返回是否等到了。
|
|
96
|
-
* 用于 drain 里等同步 run 收尾 —— 事件驱动没有「在飞数变 0」的回调,轮询足够。
|
|
97
|
-
*/
|
|
98
|
-
async function waitUntil(cond, deadline) {
|
|
99
|
-
while (!cond()) {
|
|
100
|
-
if (Date.now() >= deadline)
|
|
101
|
-
return false;
|
|
102
|
-
await new Promise((r) => setTimeout(r, 5));
|
|
103
|
-
}
|
|
104
|
-
return true;
|
|
105
|
-
}
|
|
106
28
|
/**
|
|
107
29
|
* 鉴权未通过(B1)。`HttpException` 按其 status/body 回;其它错误回 401 通用文案,
|
|
108
30
|
* 原文只进日志(除非 exposeErrors)。
|
|
@@ -110,6 +32,9 @@ async function waitUntil(cond, deadline) {
|
|
|
110
32
|
* **连接处理**:这里在读到 body 之前就回了响应,请求体没被消费 —— 连接不能复用,
|
|
111
33
|
* 否则残留字节会被当成下一个请求(与 413 同理)。故 `req.complete` 为假时显式
|
|
112
34
|
* `connection: close`,让客户端尽早停止上传(这也正是「省资源」的落点)。
|
|
35
|
+
*
|
|
36
|
+
* 留在这里而不是随其余应答原语去 `http-io.ts`:它是**唯一** `instanceof HttpException`
|
|
37
|
+
* 的落点,而 `HttpException` 是宿主契约(见其类注释)。
|
|
113
38
|
*/
|
|
114
39
|
function sendUnauthorized(req, res, e, exposeErrors) {
|
|
115
40
|
// 在读到 body 之前就回了响应:请求体没被消费 → 连接不能复用
|
|
@@ -127,21 +52,17 @@ function sendUnauthorized(req, res, e, exposeErrors) {
|
|
|
127
52
|
console.error('[agentia:http] 鉴权钩子异常:', e);
|
|
128
53
|
sendJson(res, 401, { error: '未通过鉴权' });
|
|
129
54
|
}
|
|
130
|
-
/** 503:停机中不再接单(在读 body 之前就拒,省一次传输) */
|
|
131
|
-
function sendShuttingDown(req, res) {
|
|
132
|
-
// 同 sendUnauthorized:body 未消费 → 连接不可复用
|
|
133
|
-
if (!req.complete)
|
|
134
|
-
res.setHeader('connection', 'close');
|
|
135
|
-
res.setHeader('retry-after', RETRY_AFTER_SECONDS);
|
|
136
|
-
sendJson(res, 503, { error: '服务正在优雅停机,不再接受新任务' });
|
|
137
|
-
}
|
|
138
55
|
/**
|
|
139
|
-
*
|
|
140
|
-
*
|
|
56
|
+
* 轮询等待条件成立,直到 deadline(`Infinity` = 一直等)。返回是否等到了。
|
|
57
|
+
* 用于 drain 里等同步 run 收尾 —— 事件驱动没有「在飞数变 0」的回调,轮询足够。
|
|
141
58
|
*/
|
|
142
|
-
function
|
|
143
|
-
|
|
144
|
-
|
|
59
|
+
async function waitUntil(cond, deadline) {
|
|
60
|
+
while (!cond()) {
|
|
61
|
+
if (Date.now() >= deadline)
|
|
62
|
+
return false;
|
|
63
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
64
|
+
}
|
|
65
|
+
return true;
|
|
145
66
|
}
|
|
146
67
|
export function createHttpHandler(app, opts = {}) {
|
|
147
68
|
const runner = opts.runner ?? new AsyncRunner(app);
|
|
@@ -162,29 +83,45 @@ export function createHttpHandler(app, opts = {}) {
|
|
|
162
83
|
// NaN 会让 `inFlightRuns >= maxConcurrentRuns` 恒 false(闸门静默失效);
|
|
163
84
|
// 0 / 负数则全部 503 —— 都是配置错误,宁可在构造期响亮失败。
|
|
164
85
|
// Infinity 合法(`Infinity > 0` 成立):无上限(见选项注释)。
|
|
165
|
-
throw new Error(`maxConcurrentRuns
|
|
86
|
+
throw new Error(`maxConcurrentRuns 必须为正数(${zeroClauseOf('HttpHandlerOptions.maxConcurrentRuns')}),收到 ${opts.maxConcurrentRuns}`);
|
|
166
87
|
}
|
|
167
88
|
const exposeErrors = opts.exposeErrors ?? false;
|
|
168
89
|
const sseMaxBufferedBytes = opts.sseMaxBufferedBytes;
|
|
169
90
|
const authenticate = opts.authenticate;
|
|
170
|
-
const
|
|
91
|
+
const metrics = opts.metrics;
|
|
171
92
|
const startedAt = Date.now();
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
93
|
+
/**
|
|
94
|
+
* 宿主级可变状态:**一处创建、按引用共享**(HTTP 路径上的每一方拿到的都是这一个对象)。
|
|
95
|
+
* 拆成对象而不是三个闭包变量,是为了让它能交给 `http-endpoints.ts` 的端点表 ——
|
|
96
|
+
* 而「只此一份」这条不变量同时被 `drain()`(读在飞数、置停机态、收 SSE)与
|
|
97
|
+
* `/healthz`(读同一份)依赖:复制即失效。见 `HttpState`。
|
|
98
|
+
*/
|
|
99
|
+
const state = {
|
|
100
|
+
inFlightRuns: 0,
|
|
101
|
+
draining: false,
|
|
102
|
+
openSse: new Set(),
|
|
103
|
+
};
|
|
104
|
+
/**
|
|
105
|
+
* 强制收口全部仍开着的 SSE 流,返回**切掉了几条在飞的 run**
|
|
106
|
+
* (只关旁观者的长连不计 —— 判据在 `OpenSseHandle.cutsRun` 上,别在这里重新推导)。
|
|
107
|
+
*/
|
|
108
|
+
const closeStreams = () => {
|
|
109
|
+
let cutRuns = 0;
|
|
110
|
+
for (const s of [...state.openSse]) {
|
|
111
|
+
if (s.cutsRun)
|
|
112
|
+
cutRuns++;
|
|
113
|
+
s.close();
|
|
180
114
|
}
|
|
181
|
-
|
|
182
|
-
console.error('[agentia:http] 请求处理异常:', e);
|
|
183
|
-
sendJson(res, 500, { error: '内部错误' });
|
|
115
|
+
return cutRuns;
|
|
184
116
|
};
|
|
185
|
-
/**
|
|
117
|
+
/**
|
|
118
|
+
* 优雅停机(B2):拒新单 → 等异步任务与在飞同步 run → 强制收口 SSE。
|
|
119
|
+
*
|
|
120
|
+
* ⚠️ `timeoutMs` 缺省 0 = **对在飞工作**一直等;**活连接(SSE)不享受无限窗口** ——
|
|
121
|
+
* 没有 deadline 时先把它们收口再等,否则等待没有终点(见下面那段顺序陷阱的注)。
|
|
122
|
+
*/
|
|
186
123
|
const drain = async (drainOpts = {}) => {
|
|
187
|
-
draining = true; // 先拒新单,再等存量
|
|
124
|
+
state.draining = true; // 先拒新单,再等存量
|
|
188
125
|
const timeoutMs = drainOpts.timeoutMs ?? 0;
|
|
189
126
|
const deadline = timeoutMs > 0 ? Date.now() + timeoutMs : Number.POSITIVE_INFINITY;
|
|
190
127
|
// 异步任务:排在 waitQueue 里的也一并等(它们的记录在 store 里,超时未跑完则下次启动续跑)。
|
|
@@ -202,14 +139,28 @@ export function createHttpHandler(app, opts = {}) {
|
|
|
202
139
|
const left = deadline - Date.now();
|
|
203
140
|
tasksDrained = left > 0 ? await runner.drain({ timeoutMs: left }) : false;
|
|
204
141
|
}
|
|
205
|
-
// 同步 /run(含 SSE 流)也在 inFlightRuns 里计数 —— 同样给到 deadline
|
|
206
|
-
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
142
|
+
// 同步 /run(含 SSE 流)也在 inFlightRuns 里计数 —— 同样给到 deadline。
|
|
143
|
+
//
|
|
144
|
+
// ⚠️ 顺序陷阱(2026-09-28 修):SSE 的**强制收口必须先于这次等待**地做出判断。
|
|
145
|
+
// 此前收口排在等待**之后** ⇒ 若某条 SSE 的 run 自己不结束(客户端不断线 + 长跑),
|
|
146
|
+
// `waitUntil(..., Infinity)`(`timeoutMs` 缺省 0 = 一直等)永不返回,而**唯一**能结束
|
|
147
|
+
// 它的机制(收口 = 关流 + abort 对应 run)排在等待后面 ⇒ 死锁:`drain()` 既不返回也不报
|
|
148
|
+
// false,SIGTERM 的容器只能在宽限期后被强杀(正是本函数上面那段注释要避免的事)。
|
|
149
|
+
//
|
|
150
|
+
// 为什么只在「无 deadline」时提前:**「一直等」承诺的是「等在飞工作自己收尾」**,而活连接
|
|
151
|
+
// (SSE)没有「自己收尾」的义务,不该给它无限窗口。有 deadline 时保持既有优雅:先给流到
|
|
152
|
+
// deadline 的窗口自己收尾,到点再硬收(那条路径行为逐字不变)。
|
|
153
|
+
const unbounded = deadline === Number.POSITIVE_INFINITY;
|
|
154
|
+
const cutEarly = unbounded ? closeStreams() : 0;
|
|
155
|
+
const runsDrained = state.inFlightRuns === 0 || (await waitUntil(() => state.inFlightRuns === 0, deadline));
|
|
156
|
+
// 收口:仍挂着的 SSE 流强制关闭。closeSse 会同时 abort 对应 run(stopReason='aborted')
|
|
157
|
+
// ——只 close 不 abort 的话,res.end() 让 writableEnded 同步变 true,onClose 守卫永不触发,
|
|
158
|
+
// run 会在后台继续烧 token(实测复现)。
|
|
159
|
+
closeStreams();
|
|
160
|
+
// ⚠️ 返回值如实分两种收口(`OpenSseHandle.cutsRun`):**切过在飞的 run** ⇒ 不算排空干净
|
|
161
|
+
// (工作是被 abort 收尾的,回 true 等于把「切了」说成「等干净了」);只关掉旁观者的长连
|
|
162
|
+
// (`/tasks/<id>/stream`)⇒ 不损失任何工作,不参与这个判断(记成 false 是另一种说错话)。
|
|
163
|
+
return tasksDrained && runsDrained && cutEarly === 0;
|
|
213
164
|
};
|
|
214
165
|
const handler = async (req, res) => {
|
|
215
166
|
const method = req.method ?? 'GET';
|
|
@@ -217,33 +168,30 @@ export function createHttpHandler(app, opts = {}) {
|
|
|
217
168
|
// 路由判定是**纯函数**(见 http-route.ts):这里只按判定结果去编排。
|
|
218
169
|
// 顺序陷阱写在那边的头注里,最要命的一条:免鉴权组(/healthz、/metrics)
|
|
219
170
|
// 连同它们自己的 405 都在鉴权**之前**回,其余一律**先鉴权再判方法/路径**。
|
|
220
|
-
const route = routeRequest(pathname, method,
|
|
171
|
+
const route = routeRequest(pathname, method, metrics !== undefined);
|
|
172
|
+
// 一个请求一份 ctx:`state` 按引用共享,其余是构造期已解析好的值(不再有 `?? 缺省`)
|
|
173
|
+
const ctx = {
|
|
174
|
+
req,
|
|
175
|
+
res,
|
|
176
|
+
pathname,
|
|
177
|
+
method,
|
|
178
|
+
route,
|
|
179
|
+
app,
|
|
180
|
+
runner,
|
|
181
|
+
state,
|
|
182
|
+
maxBodyBytes,
|
|
183
|
+
maxConcurrentRuns,
|
|
184
|
+
sseMaxBufferedBytes,
|
|
185
|
+
exposeErrors,
|
|
186
|
+
startedAt,
|
|
187
|
+
metrics,
|
|
188
|
+
};
|
|
221
189
|
try {
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
return;
|
|
228
|
-
}
|
|
229
|
-
if (route.kind === 'healthz') {
|
|
230
|
-
sendJson(res, 200, {
|
|
231
|
-
ok: true,
|
|
232
|
-
inFlight: inFlightRuns + runner.inFlight,
|
|
233
|
-
uptimeMs: Date.now() - startedAt,
|
|
234
|
-
draining,
|
|
235
|
-
});
|
|
236
|
-
return;
|
|
237
|
-
}
|
|
238
|
-
// 指标:与 /healthz 同档(不鉴权、停机中仍可拉 —— 抓取端在集群内网)。
|
|
239
|
-
// `?.` 只为让类型收窄(这条分支只在配了 metrics 出口时可达),行为与原来一致。
|
|
240
|
-
sendPrometheus(res, typeof metricsProvider === 'function'
|
|
241
|
-
? metricsProvider()
|
|
242
|
-
: (metricsProvider?.render() ?? ''));
|
|
243
|
-
return;
|
|
244
|
-
}
|
|
245
|
-
// 鉴权缝:必须在读 body 之前(未通过即断开,body 一个字节都不收)
|
|
246
|
-
if (authenticate) {
|
|
190
|
+
// 鉴权缝:必须在读 body 之前(未通过即断开,body 一个字节都不收)。
|
|
191
|
+
// 写成**否定条件**(「不是免鉴权组才过闸」)而不是「先 if (免鉴权组) { 一大段应答 }」:
|
|
192
|
+
// 这样 `handler` 里只剩「谁被豁免」这一处判据可读,而被豁免者**回什么**在端点表里 ——
|
|
193
|
+
// 豁免名单只有一份真源(`http-route.ts` 的 `isPreAuthRoute`)。
|
|
194
|
+
if (!isPreAuthRoute(route) && authenticate) {
|
|
247
195
|
try {
|
|
248
196
|
await authenticate(req);
|
|
249
197
|
}
|
|
@@ -252,340 +200,14 @@ export function createHttpHandler(app, opts = {}) {
|
|
|
252
200
|
return;
|
|
253
201
|
}
|
|
254
202
|
}
|
|
255
|
-
//
|
|
256
|
-
|
|
257
|
-
case 'methodNotAllowed':
|
|
258
|
-
methodNotAllowed(res, method, route.allowed);
|
|
259
|
-
return;
|
|
260
|
-
case 'run': {
|
|
261
|
-
if (draining) {
|
|
262
|
-
sendShuttingDown(req, res);
|
|
263
|
-
return;
|
|
264
|
-
}
|
|
265
|
-
const input = await parseJsonBody(req, res, maxBodyBytes);
|
|
266
|
-
if (input === PARSE_FAILED)
|
|
267
|
-
return;
|
|
268
|
-
let messages;
|
|
269
|
-
try {
|
|
270
|
-
messages = normalizeMessages(input);
|
|
271
|
-
}
|
|
272
|
-
catch (e) {
|
|
273
|
-
sendJson(res, 400, { error: errMessage(e) });
|
|
274
|
-
return;
|
|
275
|
-
}
|
|
276
|
-
// 并发闸门:body 已读完(连接可复用),只是暂时不给跑 —— 回 503 让调用方退避重试
|
|
277
|
-
if (inFlightRuns >= maxConcurrentRuns) {
|
|
278
|
-
res.setHeader('retry-after', RETRY_AFTER_SECONDS);
|
|
279
|
-
sendJson(res, 503, {
|
|
280
|
-
error: `并发 run 已达上限 ${maxConcurrentRuns},请稍后重试`,
|
|
281
|
-
});
|
|
282
|
-
return;
|
|
283
|
-
}
|
|
284
|
-
inFlightRuns++;
|
|
285
|
-
// 客户端中途断开 → 中止本次 run(省 token)。res 'close' 正常结束也会触发,
|
|
286
|
-
// 故以 writableEnded 区分:只有响应还没写完才算「断开」。
|
|
287
|
-
const runAc = new AbortController();
|
|
288
|
-
const onClose = () => {
|
|
289
|
-
if (!res.writableEnded)
|
|
290
|
-
runAc.abort();
|
|
291
|
-
};
|
|
292
|
-
res.once('close', onClose);
|
|
293
|
-
// 内容协商:`Accept: text/event-stream` → SSE 逐帧下发;否则一元 JSON(旧行为逐字不变)
|
|
294
|
-
const wantsSse = String(req.headers.accept ?? '').includes('text/event-stream');
|
|
295
|
-
// 入站链路(spec §9.2):W3C `traceparent` 头 → run 根的 links。
|
|
296
|
-
// 畸形/缺头一律静默当作「没有上游上下文」(parseTraceparent 统一判定)——
|
|
297
|
-
// 链路是观测行为,不该因为一个坏头把业务请求打成 400。
|
|
298
|
-
const traceContext = parseTraceparent(headerValue(req, 'traceparent'));
|
|
299
|
-
try {
|
|
300
|
-
if (wantsSse) {
|
|
301
|
-
const sse = sseWriter(res, {
|
|
302
|
-
maxBufferedBytes: sseMaxBufferedBytes,
|
|
303
|
-
// 下游积压超限 → 收口并中止本次 run(见 sseWriter 的背压说明)
|
|
304
|
-
onBackpressure: () => runAc.abort(),
|
|
305
|
-
});
|
|
306
|
-
const heartbeat = setInterval(() => sse.comment('ping'), 15_000);
|
|
307
|
-
heartbeat.unref?.();
|
|
308
|
-
// 登记收口函数:drain 时强制关闭(SSE 是长连,不关会把进程吊住)。
|
|
309
|
-
// 收口必须同时 abort 对应 run:sse.close() → res.end() 后 writableEnded 同步
|
|
310
|
-
// 变 true,上面的 onClose 守卫(`!writableEnded`)在 close 事件时不成立,
|
|
311
|
-
// 只 close 不 abort 会让 run 在后台继续烧 token(与背压收口路径的
|
|
312
|
-
// onBackpressure → abort 对齐;run 已正常结束时 abort 是无害 no-op)。
|
|
313
|
-
const closeSse = () => {
|
|
314
|
-
clearInterval(heartbeat);
|
|
315
|
-
runAc.abort();
|
|
316
|
-
sse.close();
|
|
317
|
-
};
|
|
318
|
-
openSse.add(closeSse);
|
|
319
|
-
try {
|
|
320
|
-
// rethrow:false —— 与 AsyncRunner 对齐:硬失败也以 run.end 下发(status/error 字段)
|
|
321
|
-
const out = await app.run(messages, {
|
|
322
|
-
rethrow: false,
|
|
323
|
-
signal: runAc.signal,
|
|
324
|
-
onText: (delta) => sse.event('text.delta', { text: delta }),
|
|
325
|
-
// 增量记账出口(F3 当初判「架构代价大、后置」的那半):既有三帧
|
|
326
|
-
//(text.delta / run.end / error)逐字不变,这里只**追加**一族帧 ——
|
|
327
|
-
// 不认识 `trace.event` 的老客户端行为零变化。
|
|
328
|
-
// 帧名取一族(body 里的 type 区分)而不是每类型一帧:将来加新事件类型时,
|
|
329
|
-
// 老客户端只是漏掉一种 type,而不是漏掉一种**帧名**(后者更隐蔽)。
|
|
330
|
-
onTraceEvent: (e) => sse.event('trace.event', e),
|
|
331
|
-
...(traceContext !== undefined ? { traceContext } : {}),
|
|
332
|
-
});
|
|
333
|
-
sse.event('run.end', toHttpBody(out));
|
|
334
|
-
}
|
|
335
|
-
catch (e) {
|
|
336
|
-
// 流已开(200 与头已发出)→ 只能以 error 事件收尾,不能再改 HTTP 状态码
|
|
337
|
-
sse.event('error', { message: errMessage(e) });
|
|
338
|
-
}
|
|
339
|
-
finally {
|
|
340
|
-
openSse.delete(closeSse);
|
|
341
|
-
closeSse();
|
|
342
|
-
}
|
|
343
|
-
return;
|
|
344
|
-
}
|
|
345
|
-
// rethrow:false —— 与 AsyncRunner 对齐:硬失败也以 status/error 字段返回 200
|
|
346
|
-
const out = await app.run(messages, {
|
|
347
|
-
rethrow: false,
|
|
348
|
-
signal: runAc.signal,
|
|
349
|
-
...(traceContext !== undefined ? { traceContext } : {}),
|
|
350
|
-
});
|
|
351
|
-
sendJson(res, 200, toHttpBody(out));
|
|
352
|
-
}
|
|
353
|
-
finally {
|
|
354
|
-
res.off('close', onClose);
|
|
355
|
-
inFlightRuns--;
|
|
356
|
-
}
|
|
357
|
-
return;
|
|
358
|
-
}
|
|
359
|
-
case 'submit': {
|
|
360
|
-
// 停机中不再接单(含直接 drain 了 runner 的情况);GET /tasks/<id> 不受影响
|
|
361
|
-
if (draining || runner.isDraining) {
|
|
362
|
-
sendShuttingDown(req, res);
|
|
363
|
-
return;
|
|
364
|
-
}
|
|
365
|
-
const body = await parseJsonBody(req, res, maxBodyBytes);
|
|
366
|
-
if (body === PARSE_FAILED)
|
|
367
|
-
return;
|
|
368
|
-
const submitBody = toTaskSubmitBody(body);
|
|
369
|
-
if (!submitBody) {
|
|
370
|
-
sendJson(res, 400, { error: 'body 需为 { input, idempotencyKey?, options? }' });
|
|
371
|
-
return;
|
|
372
|
-
}
|
|
373
|
-
// 入站链路(spec §9.2):body 显式给的 `options.traceContext` 优先,否则取
|
|
374
|
-
// `traceparent` 头。它随 `spec.options` 落进 TaskRecord —— 所以**跨进程续跑**
|
|
375
|
-
// 的那次 run(另一个进程 `resumePending` 接着跑)也带得上,关联不断链。
|
|
376
|
-
const submitTrace = parseTraceparent(headerValue(req, 'traceparent'));
|
|
377
|
-
let rec;
|
|
378
|
-
try {
|
|
379
|
-
rec = runner.submit(submitBody.input, {
|
|
380
|
-
...(submitBody.idempotencyKey !== undefined
|
|
381
|
-
? { idempotencyKey: submitBody.idempotencyKey }
|
|
382
|
-
: {}),
|
|
383
|
-
options: submitBody.options?.traceContext !== undefined
|
|
384
|
-
? submitBody.options
|
|
385
|
-
: {
|
|
386
|
-
...submitBody.options,
|
|
387
|
-
...(submitTrace !== undefined ? { traceContext: submitTrace } : {}),
|
|
388
|
-
},
|
|
389
|
-
source: 'http',
|
|
390
|
-
});
|
|
391
|
-
}
|
|
392
|
-
catch (e) {
|
|
393
|
-
// `submit` 是同步的:入参校验失败与 store 落库故障从同一个 catch 出去。
|
|
394
|
-
// 前者是调用方的错(400 + 原因),后者是服务端的错 —— 必须走与 500 路径同一套
|
|
395
|
-
// `exposeErrors` 策略,否则一次磁盘/Redis 故障会被报成「你参数写错了」,
|
|
396
|
-
// 并把内部错误消息原样回给调用方(500/401 路径都不这么干)。
|
|
397
|
-
if (e instanceof TaskInputError) {
|
|
398
|
-
sendJson(res, 400, { error: errMessage(e) });
|
|
399
|
-
return;
|
|
400
|
-
}
|
|
401
|
-
// 上面的停机闸门与 `submit` 之间隔着一次 `await parseJsonBody`:读 body 期间
|
|
402
|
-
// drain 可能刚开始,此时 submit 抛「正在优雅停机」。这是 503 而不是 500 ——
|
|
403
|
-
// 调用方应该退避重试,跟闸门本身回的是同一句话。
|
|
404
|
-
if (runner.isDraining) {
|
|
405
|
-
sendShuttingDown(req, res);
|
|
406
|
-
return;
|
|
407
|
-
}
|
|
408
|
-
sendInternalError(res, e);
|
|
409
|
-
return;
|
|
410
|
-
}
|
|
411
|
-
sendJson(res, 202, rec);
|
|
412
|
-
return;
|
|
413
|
-
}
|
|
414
|
-
// POST /tasks/<id>/approve(HITL):审批挂起的任务。drain 期间仍允许 ——
|
|
415
|
-
// 与 GET 轮询同理由:挂起的任务只有人能推进,停机不该连「批准」也拒掉。
|
|
416
|
-
case 'approve': {
|
|
417
|
-
const taskId = route.taskId;
|
|
418
|
-
const body = await parseJsonBody(req, res, maxBodyBytes);
|
|
419
|
-
if (body === PARSE_FAILED)
|
|
420
|
-
return;
|
|
421
|
-
const parsed = parseApproveBody(body);
|
|
422
|
-
if (!parsed) {
|
|
423
|
-
sendJson(res, 400, {
|
|
424
|
-
error: 'body 需为 { decisions: { <tool_use_id>: { approved: boolean, reason?: string } }, decidedBy?: string }',
|
|
425
|
-
});
|
|
426
|
-
return;
|
|
427
|
-
}
|
|
428
|
-
try {
|
|
429
|
-
const rec = await runner.approve(taskId, parsed.decisions, parsed.decidedBy !== undefined ? { decidedBy: parsed.decidedBy } : {});
|
|
430
|
-
sendJson(res, 200, rec);
|
|
431
|
-
}
|
|
432
|
-
catch (e) {
|
|
433
|
-
// 404(任务不存在)/ 409(状态不对)是调用方语义;其余按内部错误处理
|
|
434
|
-
if (e instanceof TaskApproveError) {
|
|
435
|
-
sendJson(res, e.status, { error: errMessage(e) });
|
|
436
|
-
return;
|
|
437
|
-
}
|
|
438
|
-
sendInternalError(res, e);
|
|
439
|
-
}
|
|
440
|
-
return;
|
|
441
|
-
}
|
|
442
|
-
case 'poll': {
|
|
443
|
-
const taskId = route.taskId;
|
|
444
|
-
const rec = await runner.poll(taskId); // MaybePromise:异步 store 下必须 await
|
|
445
|
-
if (!rec) {
|
|
446
|
-
sendJson(res, 404, { error: `task 不存在: ${taskId}` });
|
|
447
|
-
return;
|
|
448
|
-
}
|
|
449
|
-
sendJson(res, 200, rec);
|
|
450
|
-
return;
|
|
451
|
-
}
|
|
452
|
-
case 'taskStream': {
|
|
453
|
-
const taskId = route.taskId;
|
|
454
|
-
// 先查一次记录:任务不存在要回 **404**,而 SSE 一旦写出响应头状态码就定死 200 了
|
|
455
|
-
//(见 sse.ts 的语义约束),所以「存不存在」必须在建流之前问。
|
|
456
|
-
const known = await runner.poll(taskId);
|
|
457
|
-
if (!known) {
|
|
458
|
-
sendJson(res, 404, { error: `task 不存在: ${taskId}` });
|
|
459
|
-
return;
|
|
460
|
-
}
|
|
461
|
-
// 续订锚点:SSE 标准头 `Last-Event-ID` 优先,其次 `?from=`(非 EventSource 的客户端用)
|
|
462
|
-
const fromRaw = headerValue(req, 'last-event-id') ??
|
|
463
|
-
new URL(req.url ?? '/', 'http://localhost').searchParams.get('from');
|
|
464
|
-
const from = fromRaw === null || fromRaw === undefined ? undefined : Number(fromRaw);
|
|
465
|
-
let unsubscribe = () => { };
|
|
466
|
-
let finished = false;
|
|
467
|
-
let heartbeat;
|
|
468
|
-
const closeStream = () => {
|
|
469
|
-
if (finished)
|
|
470
|
-
return;
|
|
471
|
-
finished = true;
|
|
472
|
-
if (heartbeat)
|
|
473
|
-
clearInterval(heartbeat);
|
|
474
|
-
unsubscribe();
|
|
475
|
-
openSse.delete(closeStream);
|
|
476
|
-
sse.close();
|
|
477
|
-
};
|
|
478
|
-
const sse = sseWriter(res, {
|
|
479
|
-
maxBufferedBytes: sseMaxBufferedBytes,
|
|
480
|
-
// ⚠️ 背压**不 abort 任务**:这条流的读者是**旁观者**,任务不该因为看的人慢而变慢。
|
|
481
|
-
//(对比 /run 的 SSE:那里的下游就是 run 的所有者,背压等于「别继续烧 token 了」。)
|
|
482
|
-
onBackpressure: () => closeStream(),
|
|
483
|
-
});
|
|
484
|
-
heartbeat = setInterval(() => sse.comment('ping'), 15_000);
|
|
485
|
-
heartbeat.unref?.();
|
|
486
|
-
// 登记收口函数:drain 时强制关闭(SSE 是长连,不关会把进程吊住)
|
|
487
|
-
openSse.add(closeStream);
|
|
488
|
-
// 客户端断开 ⇒ 退订(不退订订阅者会一直挂在 runner 的表里)
|
|
489
|
-
res.once('close', () => {
|
|
490
|
-
if (!res.writableEnded)
|
|
491
|
-
closeStream();
|
|
492
|
-
});
|
|
493
|
-
const write = (frame) => {
|
|
494
|
-
if (finished)
|
|
495
|
-
return;
|
|
496
|
-
switch (frame.type) {
|
|
497
|
-
case 'trace':
|
|
498
|
-
// 帧名一族 + SSE `id:` 给流序号 ⇒ 断线重连带 Last-Event-ID 就能续上
|
|
499
|
-
sse.event('trace.event', frame.event, String(frame.index));
|
|
500
|
-
return;
|
|
501
|
-
case 'truncated':
|
|
502
|
-
sse.event('stream.truncated', { droppedBefore: frame.droppedBefore });
|
|
503
|
-
return;
|
|
504
|
-
case 'unavailable':
|
|
505
|
-
sse.event('stream.unavailable', { reason: frame.reason });
|
|
506
|
-
return;
|
|
507
|
-
case 'closed':
|
|
508
|
-
// 流级收尾(任务未终态,见 TaskStreamFrame):帧先发出去再关连接 ——
|
|
509
|
-
// 客户端据此知道「没有更多帧了,去轮询」,而不是对着断线猜
|
|
510
|
-
sse.event('stream.closed', { reason: frame.reason });
|
|
511
|
-
closeStream();
|
|
512
|
-
return;
|
|
513
|
-
case 'end':
|
|
514
|
-
sse.event('task.end', frame.record);
|
|
515
|
-
closeStream();
|
|
516
|
-
return;
|
|
517
|
-
default: {
|
|
518
|
-
// 编译期穷尽性断言:`TaskStreamFrame` 新增成员时这里会编译失败,
|
|
519
|
-
// 而不是被静默丢掉 —— 静默丢帧的客户端只会对着流干等,最后靠超时猜。
|
|
520
|
-
const _never = frame;
|
|
521
|
-
void _never;
|
|
522
|
-
}
|
|
523
|
-
}
|
|
524
|
-
};
|
|
525
|
-
try {
|
|
526
|
-
const off = await runner.streamTask(taskId, write, from !== undefined ? { from } : {});
|
|
527
|
-
// 重放阶段就可能已收口(终态任务):那时 finished 为真,别再登记退订
|
|
528
|
-
if (finished)
|
|
529
|
-
off();
|
|
530
|
-
else
|
|
531
|
-
unsubscribe = off;
|
|
532
|
-
}
|
|
533
|
-
catch (e) {
|
|
534
|
-
if (e instanceof TaskStreamError) {
|
|
535
|
-
// 理论上不可达(上面已查过存在性),但保留这条:状态码还没写出去就还能回 404
|
|
536
|
-
if (!res.headersSent) {
|
|
537
|
-
sendJson(res, e.status, { error: errMessage(e) });
|
|
538
|
-
}
|
|
539
|
-
else {
|
|
540
|
-
sse.event('error', { message: errMessage(e) });
|
|
541
|
-
closeStream();
|
|
542
|
-
}
|
|
543
|
-
return;
|
|
544
|
-
}
|
|
545
|
-
sse.event('error', { message: errMessage(e) });
|
|
546
|
-
closeStream();
|
|
547
|
-
}
|
|
548
|
-
return;
|
|
549
|
-
}
|
|
550
|
-
case 'badTaskId':
|
|
551
|
-
// 残缺的 % 转义会抛 URIError —— 是调用方的输入问题(400),不是服务端 500
|
|
552
|
-
sendJson(res, 400, { error: 'taskId 不是合法的 URL 编码' });
|
|
553
|
-
return;
|
|
554
|
-
// healthz / metrics 已在上面的免鉴权组里 return 掉,落到这里的只剩 'notFound'。
|
|
555
|
-
// 编译期穷尽性断言:`HttpRoute` 新增 kind 时这里会编译失败 —— 而不是被静默吞成 404。
|
|
556
|
-
default: {
|
|
557
|
-
const residual = route.kind;
|
|
558
|
-
void residual;
|
|
559
|
-
sendJson(res, 404, { error: `路径不存在: ${pathname}` });
|
|
560
|
-
return;
|
|
561
|
-
}
|
|
562
|
-
}
|
|
203
|
+
// 至此路由已定、鉴权已过 —— 剩下的全是「走到这条路上做什么」,在 http-endpoints.ts
|
|
204
|
+
await handleRoute(route, ctx);
|
|
563
205
|
}
|
|
564
206
|
catch (e) {
|
|
565
|
-
sendInternalError(res, e);
|
|
207
|
+
sendInternalError(res, e, exposeErrors);
|
|
566
208
|
}
|
|
567
209
|
};
|
|
568
210
|
// 把停机控制面挂在 handler 上(不破坏 (req, res) 的调用形状)
|
|
569
211
|
return Object.assign(handler, { drain, runner });
|
|
570
212
|
}
|
|
571
|
-
const PARSE_FAILED = Symbol('parse-failed');
|
|
572
|
-
/** 读取并解析 JSON body;失败时直接回错误响应并返回哨兵(连接已断则无响应可回)。 */
|
|
573
|
-
async function parseJsonBody(req, res, maxBytes) {
|
|
574
|
-
const body = await readBody(req, maxBytes);
|
|
575
|
-
if (!body.ok) {
|
|
576
|
-
if (body.reason === 'too-large') {
|
|
577
|
-
// body 未读完就回响应:连接不能复用,否则残留字节会被当成下一个请求
|
|
578
|
-
res.setHeader('connection', 'close');
|
|
579
|
-
sendJson(res, 413, { error: `请求 body 超过上限 ${maxBytes} 字节` });
|
|
580
|
-
}
|
|
581
|
-
return PARSE_FAILED; // aborted:客户端已走,写了也没人收
|
|
582
|
-
}
|
|
583
|
-
try {
|
|
584
|
-
return body.raw ? JSON.parse(body.raw) : undefined;
|
|
585
|
-
}
|
|
586
|
-
catch {
|
|
587
|
-
sendJson(res, 400, { error: '请求 body 不是合法 JSON' });
|
|
588
|
-
return PARSE_FAILED;
|
|
589
|
-
}
|
|
590
|
-
}
|
|
591
213
|
//# sourceMappingURL=http.js.map
|