@migor/agentia 0.7.2 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +53 -1
- package/README.md +10 -6
- package/dist/AGENTS.md +26 -6
- package/dist/engine/loop-result.d.ts +72 -0
- package/dist/engine/loop-result.js +80 -0
- package/dist/engine/loop-result.js.map +1 -0
- package/dist/engine/loop.d.ts +3 -40
- package/dist/engine/loop.js +31 -125
- package/dist/engine/loop.js.map +1 -1
- package/dist/engine/resume-input.d.ts +26 -0
- package/dist/engine/resume-input.js +39 -0
- package/dist/engine/resume-input.js.map +1 -0
- package/dist/engine/retry.d.ts +8 -0
- package/dist/engine/retry.js +10 -0
- package/dist/engine/retry.js.map +1 -1
- package/dist/engine/run-config.d.ts +28 -0
- package/dist/engine/run-config.js +75 -0
- package/dist/engine/run-config.js.map +1 -0
- package/dist/engine/stop-reason.d.ts +27 -0
- package/dist/engine/stop-reason.js +76 -0
- package/dist/engine/stop-reason.js.map +1 -0
- package/dist/engine/text.d.ts +10 -0
- package/dist/engine/text.js +6 -0
- package/dist/engine/text.js.map +1 -0
- package/dist/engine/tool-context.d.ts +32 -0
- package/dist/engine/tool-context.js +18 -0
- package/dist/engine/tool-context.js.map +1 -0
- package/dist/engine/tool-events.d.ts +42 -0
- package/dist/engine/tool-events.js +64 -0
- package/dist/engine/tool-events.js.map +1 -0
- package/dist/engine/turn-request.d.ts +24 -0
- package/dist/engine/turn-request.js +12 -0
- package/dist/engine/turn-request.js.map +1 -0
- package/dist/engine/turn.d.ts +3 -25
- package/dist/engine/turn.js +43 -155
- package/dist/engine/turn.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/toolkit/tool.js +1 -0
- package/dist/toolkit/tool.js.map +1 -1
- package/dist/transport/approval-policy.d.ts +30 -0
- package/dist/transport/approval-policy.js +37 -0
- package/dist/transport/approval-policy.js.map +1 -0
- package/dist/transport/async.d.ts +0 -4
- package/dist/transport/async.js +40 -144
- package/dist/transport/async.js.map +1 -1
- package/dist/transport/drain-gate.d.ts +28 -0
- package/dist/transport/drain-gate.js +62 -0
- package/dist/transport/drain-gate.js.map +1 -0
- package/dist/transport/http-route.d.ts +55 -0
- package/dist/transport/http-route.js +72 -0
- package/dist/transport/http-route.js.map +1 -0
- package/dist/transport/http-shapes.d.ts +57 -0
- package/dist/transport/http-shapes.js +56 -0
- package/dist/transport/http-shapes.js.map +1 -0
- package/dist/transport/http.d.ts +5 -21
- package/dist/transport/http.js +176 -232
- package/dist/transport/http.js.map +1 -1
- package/dist/transport/resume-policy.d.ts +40 -0
- package/dist/transport/resume-policy.js +25 -0
- package/dist/transport/resume-policy.js.map +1 -0
- package/dist/transport/slot-pool.d.ts +24 -0
- package/dist/transport/slot-pool.js +43 -0
- package/dist/transport/slot-pool.js.map +1 -0
- package/dist/transport/task-waiters.d.ts +25 -0
- package/dist/transport/task-waiters.js +63 -0
- package/dist/transport/task-waiters.js.map +1 -0
- package/package.json +4 -3
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { Trace, SpanError } from '../core/trace.js';
|
|
2
|
+
import type { RunStatus } from '../core/run.js';
|
|
3
|
+
import type { AgentRunResult, AgentStopReason } from '../engine/types.js';
|
|
4
|
+
import type { RunInvocationOptions } from '../engine/spec.js';
|
|
5
|
+
import type { ApprovalDecisions } from './async.js';
|
|
6
|
+
/**
|
|
7
|
+
* Agentia —— HTTP 宿主的**出入站形状口径**(transport/http.ts 的拆分第一步)。
|
|
8
|
+
*
|
|
9
|
+
* 三条边界在这里定形,别处(`http.ts` 的路由分支)只做编排:
|
|
10
|
+
* - 入站 `POST /tasks`:body 是不是 `{ input, … }`(`toTaskSubmitBody`);
|
|
11
|
+
* - 入站 `POST /tasks/<id>/approve`:body 是不是
|
|
12
|
+
* `{ decisions: { <id>: { approved, reason? } }, decidedBy? }`(`parseApproveBody`);
|
|
13
|
+
* - 出站:run 产物 → HTTP 响应体(`toHttpBody`,JSON 与 SSE 的 `run.end` **共用同一形状**)。
|
|
14
|
+
*
|
|
15
|
+
* 三条都是**纯形状判定**:只读参数、只回值,不碰 req/res、不写响应、不抛异常
|
|
16
|
+
* (形状不合法一律回 `undefined`,由调用方选状态码 —— 400 还是别的,是路由的语义)。
|
|
17
|
+
* 判定刻意做窄且**全有或全无**:一处不合法就整条拒掉,不做「能救则救」的部分接受 ——
|
|
18
|
+
* 半接受的审批体比拒掉更危险(缺哪个 tool_use 的决定,只有调用方知道)。
|
|
19
|
+
*/
|
|
20
|
+
/** POST /run 的响应形态 */
|
|
21
|
+
export interface RunHttpResponse {
|
|
22
|
+
runId: string;
|
|
23
|
+
status: RunStatus;
|
|
24
|
+
stopReason: AgentStopReason;
|
|
25
|
+
finalText: string;
|
|
26
|
+
/** 结构化结果(R2 起应用可携带;无则为 undefined,字段在场) */
|
|
27
|
+
typed: unknown;
|
|
28
|
+
trace: Trace;
|
|
29
|
+
error: SpanError | undefined;
|
|
30
|
+
}
|
|
31
|
+
/** POST /tasks 的请求体形态 */
|
|
32
|
+
export interface TaskSubmitBody {
|
|
33
|
+
input: unknown;
|
|
34
|
+
idempotencyKey?: string;
|
|
35
|
+
options?: RunInvocationOptions;
|
|
36
|
+
}
|
|
37
|
+
/** 把 app.run 的产物收成 HTTP 响应体(JSON 与 SSE 的 run.end 共用同一形状) */
|
|
38
|
+
export declare function toHttpBody(out: {
|
|
39
|
+
run: {
|
|
40
|
+
runId: string;
|
|
41
|
+
status: RunStatus;
|
|
42
|
+
};
|
|
43
|
+
result: AgentRunResult;
|
|
44
|
+
}): RunHttpResponse;
|
|
45
|
+
/**
|
|
46
|
+
* `POST /tasks` 的 body 形状闸:必须是**纯对象**(`null` / 数组 / 标量都不算)。
|
|
47
|
+
* 返回**同一个引用**(不是副本)—— 后续 `runner.submit` 拿到的就是请求里那个对象。
|
|
48
|
+
*/
|
|
49
|
+
export declare function toTaskSubmitBody(body: unknown): TaskSubmitBody | undefined;
|
|
50
|
+
/**
|
|
51
|
+
* 解析 `POST /tasks/<id>/approve` 的 body;形状不合法返回 undefined(调用方回 400)。
|
|
52
|
+
* `decisions` 必须是纯对象,每个值是 `{ approved: boolean, reason?: string }`。
|
|
53
|
+
*/
|
|
54
|
+
export declare function parseApproveBody(body: unknown): {
|
|
55
|
+
decisions: ApprovalDecisions;
|
|
56
|
+
decidedBy?: string;
|
|
57
|
+
} | undefined;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/** 把 app.run 的产物收成 HTTP 响应体(JSON 与 SSE 的 run.end 共用同一形状) */
|
|
2
|
+
export function toHttpBody(out) {
|
|
3
|
+
return {
|
|
4
|
+
runId: out.run.runId,
|
|
5
|
+
// status 取 **run 状态机**的口径(它才知道 awaiting_approval 不是终态),
|
|
6
|
+
// 不从 stopReason 反推 —— 两者语义不同,反推会把挂起当成失败
|
|
7
|
+
status: out.run.status,
|
|
8
|
+
stopReason: out.result.stopReason,
|
|
9
|
+
finalText: out.result.finalText,
|
|
10
|
+
typed: out.result.typed,
|
|
11
|
+
trace: out.result.trace,
|
|
12
|
+
error: out.result.error,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* `POST /tasks` 的 body 形状闸:必须是**纯对象**(`null` / 数组 / 标量都不算)。
|
|
17
|
+
* 返回**同一个引用**(不是副本)—— 后续 `runner.submit` 拿到的就是请求里那个对象。
|
|
18
|
+
*/
|
|
19
|
+
export function toTaskSubmitBody(body) {
|
|
20
|
+
if (!body || typeof body !== 'object' || Array.isArray(body))
|
|
21
|
+
return undefined;
|
|
22
|
+
return body;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* 解析 `POST /tasks/<id>/approve` 的 body;形状不合法返回 undefined(调用方回 400)。
|
|
26
|
+
* `decisions` 必须是纯对象,每个值是 `{ approved: boolean, reason?: string }`。
|
|
27
|
+
*/
|
|
28
|
+
export function parseApproveBody(body) {
|
|
29
|
+
if (!body || typeof body !== 'object' || Array.isArray(body))
|
|
30
|
+
return undefined;
|
|
31
|
+
const o = body;
|
|
32
|
+
if (!o.decisions || typeof o.decisions !== 'object' || Array.isArray(o.decisions)) {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
if (o.decidedBy !== undefined && typeof o.decidedBy !== 'string')
|
|
36
|
+
return undefined;
|
|
37
|
+
const decisions = {};
|
|
38
|
+
for (const [id, d] of Object.entries(o.decisions)) {
|
|
39
|
+
if (!d || typeof d !== 'object' || Array.isArray(d))
|
|
40
|
+
return undefined;
|
|
41
|
+
const v = d;
|
|
42
|
+
if (typeof v.approved !== 'boolean')
|
|
43
|
+
return undefined;
|
|
44
|
+
if (v.reason !== undefined && typeof v.reason !== 'string')
|
|
45
|
+
return undefined;
|
|
46
|
+
decisions[id] = {
|
|
47
|
+
approved: v.approved,
|
|
48
|
+
...(v.reason !== undefined ? { reason: v.reason } : {}),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
decisions,
|
|
53
|
+
...(o.decidedBy !== undefined ? { decidedBy: o.decidedBy } : {}),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
//# sourceMappingURL=http-shapes.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"http-shapes.js","sourceRoot":"","sources":["../../src/transport/http-shapes.ts"],"names":[],"mappings":"AAwCA,4DAA4D;AAC5D,MAAM,UAAU,UAAU,CAAC,GAG1B;IACC,OAAO;QACL,KAAK,EAAE,GAAG,CAAC,GAAG,CAAC,KAAK;QACpB,wDAAwD;QACxD,wCAAwC;QACxC,MAAM,EAAE,GAAG,CAAC,GAAG,CAAC,MAAM;QACtB,UAAU,EAAE,GAAG,CAAC,MAAM,CAAC,UAAU;QACjC,SAAS,EAAE,GAAG,CAAC,MAAM,CAAC,SAAS;QAC/B,KAAK,EAAG,GAAG,CAAC,MAA8B,CAAC,KAAK;QAChD,KAAK,EAAE,GAAG,CAAC,MAAM,CAAC,KAAK;QACvB,KAAK,EAAE,GAAG,CAAC,MAAM,CAAC,KAAK;KACxB,CAAC;AACJ,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,gBAAgB,CAAC,IAAa;IAC5C,IAAI,CAAC,IAAI,IAAI,OAAO,IAAI,KAAK,QAAQ,IAAI,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC;QAAE,OAAO,SAAS,CAAC;IAC/E,OAAO,IAAsB,CAAC;AAChC,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,gBAAgB,CAC9B,IAAa;IAEb,IAAI,CAAC,IAAI,IAAI,OAAO,IAAI,KAAK,QAAQ,IAAI,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC;QAAE,OAAO,SAAS,CAAC;IAC/E,MAAM,CAAC,GAAG,IAAoD,CAAC;IAC/D,IAAI,CAAC,CAAC,CAAC,SAAS,IAAI,OAAO,CAAC,CAAC,SAAS,KAAK,QAAQ,IAAI,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,SAAS,CAAC,EAAE,CAAC;QAClF,OAAO,SAAS,CAAC;IACnB,CAAC;IACD,IAAI,CAAC,CAAC,SAAS,KAAK,SAAS,IAAI,OAAO,CAAC,CAAC,SAAS,KAAK,QAAQ;QAAE,OAAO,SAAS,CAAC;IACnF,MAAM,SAAS,GAAsB,EAAE,CAAC;IACxC,KAAK,MAAM,CAAC,EAAE,EAAE,CAAC,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,CAAC,CAAC,SAAS,CAAC,EAAE,CAAC;QAClD,IAAI,CAAC,CAAC,IAAI,OAAO,CAAC,KAAK,QAAQ,IAAI,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC;YAAE,OAAO,SAAS,CAAC;QACtE,MAAM,CAAC,GAAG,CAA6C,CAAC;QACxD,IAAI,OAAO,CAAC,CAAC,QAAQ,KAAK,SAAS;YAAE,OAAO,SAAS,CAAC;QACtD,IAAI,CAAC,CAAC,MAAM,KAAK,SAAS,IAAI,OAAO,CAAC,CAAC,MAAM,KAAK,QAAQ;YAAE,OAAO,SAAS,CAAC;QAC7E,SAAS,CAAC,EAAE,CAAC,GAAG;YACd,QAAQ,EAAE,CAAC,CAAC,QAAQ;YACpB,GAAG,CAAC,CAAC,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;SACxD,CAAC;IACJ,CAAC;IACD,OAAO;QACL,SAAS;QACT,GAAG,CAAC,CAAC,CAAC,SAAS,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,SAAS,EAAE,CAAC,CAAC,SAAmB,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KAC3E,CAAC;AACJ,CAAC"}
|
package/dist/transport/http.d.ts
CHANGED
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
-
import type { SpanError, Trace } from '../core/trace.js';
|
|
3
|
-
import type { AgentStopReason } from '../engine/types.js';
|
|
4
2
|
import { AsyncRunner } from './async.js';
|
|
5
3
|
import type { AppCallable } from './async.js';
|
|
6
|
-
import type { RunInvocationOptions } from '../engine/spec.js';
|
|
7
|
-
import type { RunStatus } from '../core/run.js';
|
|
8
4
|
/**
|
|
9
5
|
* Agentia —— HTTP 宿主(spec §6.6:换宿主不换语义,roadmap R3)。
|
|
10
6
|
*
|
|
@@ -39,23 +35,11 @@ import type { RunStatus } from '../core/run.js';
|
|
|
39
35
|
* 在飞同步 run 收尾、强制收口仍开着的 SSE 流;`GET /tasks/<id>` 停机中照常可轮询
|
|
40
36
|
* (否则调用方拿不到在飞任务的结果)。
|
|
41
37
|
*/
|
|
42
|
-
/**
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
finalText: string;
|
|
48
|
-
/** 结构化结果(R2 起应用可携带;无则为 undefined,字段在场) */
|
|
49
|
-
typed: unknown;
|
|
50
|
-
trace: Trace;
|
|
51
|
-
error: SpanError | undefined;
|
|
52
|
-
}
|
|
53
|
-
/** POST /tasks 的请求体形态 */
|
|
54
|
-
export interface TaskSubmitBody {
|
|
55
|
-
input: unknown;
|
|
56
|
-
idempotencyKey?: string;
|
|
57
|
-
options?: RunInvocationOptions;
|
|
58
|
-
}
|
|
38
|
+
/**
|
|
39
|
+
* 出入站的形状口径(响应体 / 任务提交体 / 审批体)与它们的类型住在
|
|
40
|
+
* `./http-shapes.js`;这里转出以保持既有 import 路径不变(公开面名字与位置都未变)。
|
|
41
|
+
*/
|
|
42
|
+
export type { RunHttpResponse, TaskSubmitBody } from './http-shapes.js';
|
|
59
43
|
export interface HttpHandlerOptions {
|
|
60
44
|
/** 异步任务宿主;缺省 new AsyncRunner(app)(InMemoryTaskStore) */
|
|
61
45
|
runner?: AsyncRunner;
|
package/dist/transport/http.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { parseTraceparent } from '../core/trace.js';
|
|
2
2
|
import { AsyncRunner, TaskApproveError } from './async.js';
|
|
3
|
+
import { parseApproveBody, toHttpBody, toTaskSubmitBody } from './http-shapes.js';
|
|
4
|
+
import { isPreAuthRoute, routeRequest } from './http-route.js';
|
|
3
5
|
import { sseWriter } from './sse.js';
|
|
4
6
|
import { TaskInputError, normalizeMessages } from '../engine/spec.js';
|
|
5
7
|
/**
|
|
@@ -44,18 +46,6 @@ function sendPrometheus(res, body) {
|
|
|
44
46
|
});
|
|
45
47
|
res.end(body);
|
|
46
48
|
}
|
|
47
|
-
/** 把 app.run 的产物收成 HTTP 响应体(JSON 与 SSE 的 run.end 共用同一形状) */
|
|
48
|
-
function toHttpBody(out) {
|
|
49
|
-
return {
|
|
50
|
-
runId: out.run.runId,
|
|
51
|
-
status: out.run.status,
|
|
52
|
-
stopReason: out.result.stopReason,
|
|
53
|
-
finalText: out.result.finalText,
|
|
54
|
-
typed: out.result.typed,
|
|
55
|
-
trace: out.result.trace,
|
|
56
|
-
error: out.result.error,
|
|
57
|
-
};
|
|
58
|
-
}
|
|
59
49
|
/**
|
|
60
50
|
* 读取 body 全文。两道兜底(原实现两者皆无):
|
|
61
51
|
* - **maxBytes 上限**:超限立即停止累积并 resolve 成哨兵(调用方回 413)——
|
|
@@ -212,28 +202,32 @@ export function createHttpHandler(app, opts = {}) {
|
|
|
212
202
|
const handler = async (req, res) => {
|
|
213
203
|
const method = req.method ?? 'GET';
|
|
214
204
|
const pathname = (req.url ?? '/').split('?')[0];
|
|
205
|
+
// 路由判定是**纯函数**(见 http-route.ts):这里只按判定结果去编排。
|
|
206
|
+
// 顺序陷阱写在那边的头注里,最要命的一条:免鉴权组(/healthz、/metrics)
|
|
207
|
+
// 连同它们自己的 405 都在鉴权**之前**回,其余一律**先鉴权再判方法/路径**。
|
|
208
|
+
const route = routeRequest(pathname, method, metricsProvider !== undefined);
|
|
215
209
|
try {
|
|
216
|
-
//
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
210
|
+
// 免鉴权组:健康检查(探针带不了凭据)与指标抓取(抓取端在集群内网),停机中也照回
|
|
211
|
+
// —— 探针要先能问到才有意义。它们的方法不符(405)同样在鉴权之前。
|
|
212
|
+
if (isPreAuthRoute(route)) {
|
|
213
|
+
if (route.kind === 'methodNotAllowed') {
|
|
214
|
+
methodNotAllowed(res, method, route.allowed);
|
|
220
215
|
return;
|
|
221
216
|
}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
}
|
|
230
|
-
// 指标:与 /healthz 同档(不鉴权、停机中仍可拉 —— 抓取端在集群内网)
|
|
231
|
-
if (pathname === '/metrics' && metricsProvider) {
|
|
232
|
-
if (method !== 'GET') {
|
|
233
|
-
methodNotAllowed(res, method, 'GET');
|
|
217
|
+
if (route.kind === 'healthz') {
|
|
218
|
+
sendJson(res, 200, {
|
|
219
|
+
ok: true,
|
|
220
|
+
inFlight: inFlightRuns + runner.inFlight,
|
|
221
|
+
uptimeMs: Date.now() - startedAt,
|
|
222
|
+
draining,
|
|
223
|
+
});
|
|
234
224
|
return;
|
|
235
225
|
}
|
|
236
|
-
|
|
226
|
+
// 指标:与 /healthz 同档(不鉴权、停机中仍可拉 —— 抓取端在集群内网)。
|
|
227
|
+
// `?.` 只为让类型收窄(这条分支只在配了 metrics 出口时可达),行为与原来一致。
|
|
228
|
+
sendPrometheus(res, typeof metricsProvider === 'function'
|
|
229
|
+
? metricsProvider()
|
|
230
|
+
: (metricsProvider?.render() ?? ''));
|
|
237
231
|
return;
|
|
238
232
|
}
|
|
239
233
|
// 鉴权缝:必须在读 body 之前(未通过即断开,body 一个字节都不收)
|
|
@@ -246,179 +240,163 @@ export function createHttpHandler(app, opts = {}) {
|
|
|
246
240
|
return;
|
|
247
241
|
}
|
|
248
242
|
}
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
}
|
|
254
|
-
if (draining) {
|
|
255
|
-
sendShuttingDown(req, res);
|
|
256
|
-
return;
|
|
257
|
-
}
|
|
258
|
-
const input = await parseJsonBody(req, res, maxBodyBytes);
|
|
259
|
-
if (input === PARSE_FAILED)
|
|
260
|
-
return;
|
|
261
|
-
let messages;
|
|
262
|
-
try {
|
|
263
|
-
messages = normalizeMessages(input);
|
|
264
|
-
}
|
|
265
|
-
catch (e) {
|
|
266
|
-
sendJson(res, 400, { error: errMessage(e) });
|
|
267
|
-
return;
|
|
268
|
-
}
|
|
269
|
-
// 并发闸门:body 已读完(连接可复用),只是暂时不给跑 —— 回 503 让调用方退避重试
|
|
270
|
-
if (inFlightRuns >= maxConcurrentRuns) {
|
|
271
|
-
res.setHeader('retry-after', RETRY_AFTER_SECONDS);
|
|
272
|
-
sendJson(res, 503, {
|
|
273
|
-
error: `并发 run 已达上限 ${maxConcurrentRuns},请稍后重试`,
|
|
274
|
-
});
|
|
243
|
+
// 方法与路径已在 routeRequest 里判完,往下只剩编排
|
|
244
|
+
switch (route.kind) {
|
|
245
|
+
case 'methodNotAllowed':
|
|
246
|
+
methodNotAllowed(res, method, route.allowed);
|
|
275
247
|
return;
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
if (
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
if (
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
onBackpressure: () => runAc.abort(),
|
|
248
|
+
case 'run': {
|
|
249
|
+
if (draining) {
|
|
250
|
+
sendShuttingDown(req, res);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const input = await parseJsonBody(req, res, maxBodyBytes);
|
|
254
|
+
if (input === PARSE_FAILED)
|
|
255
|
+
return;
|
|
256
|
+
let messages;
|
|
257
|
+
try {
|
|
258
|
+
messages = normalizeMessages(input);
|
|
259
|
+
}
|
|
260
|
+
catch (e) {
|
|
261
|
+
sendJson(res, 400, { error: errMessage(e) });
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
// 并发闸门:body 已读完(连接可复用),只是暂时不给跑 —— 回 503 让调用方退避重试
|
|
265
|
+
if (inFlightRuns >= maxConcurrentRuns) {
|
|
266
|
+
res.setHeader('retry-after', RETRY_AFTER_SECONDS);
|
|
267
|
+
sendJson(res, 503, {
|
|
268
|
+
error: `并发 run 已达上限 ${maxConcurrentRuns},请稍后重试`,
|
|
298
269
|
});
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
clearInterval(heartbeat);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
inFlightRuns++;
|
|
273
|
+
// 客户端中途断开 → 中止本次 run(省 token)。res 'close' 正常结束也会触发,
|
|
274
|
+
// 故以 writableEnded 区分:只有响应还没写完才算「断开」。
|
|
275
|
+
const runAc = new AbortController();
|
|
276
|
+
const onClose = () => {
|
|
277
|
+
if (!res.writableEnded)
|
|
308
278
|
runAc.abort();
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
279
|
+
};
|
|
280
|
+
res.once('close', onClose);
|
|
281
|
+
// 内容协商:`Accept: text/event-stream` → SSE 逐帧下发;否则一元 JSON(旧行为逐字不变)
|
|
282
|
+
const wantsSse = String(req.headers.accept ?? '').includes('text/event-stream');
|
|
283
|
+
// 入站链路(spec §9.2):W3C `traceparent` 头 → run 根的 links。
|
|
284
|
+
// 畸形/缺头一律静默当作「没有上游上下文」(parseTraceparent 统一判定)——
|
|
285
|
+
// 链路是观测行为,不该因为一个坏头把业务请求打成 400。
|
|
286
|
+
const traceContext = parseTraceparent(headerValue(req, 'traceparent'));
|
|
287
|
+
try {
|
|
288
|
+
if (wantsSse) {
|
|
289
|
+
const sse = sseWriter(res, {
|
|
290
|
+
maxBufferedBytes: sseMaxBufferedBytes,
|
|
291
|
+
// 下游积压超限 → 收口并中止本次 run(见 sseWriter 的背压说明)
|
|
292
|
+
onBackpressure: () => runAc.abort(),
|
|
319
293
|
});
|
|
320
|
-
sse.
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
//
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
294
|
+
const heartbeat = setInterval(() => sse.comment('ping'), 15_000);
|
|
295
|
+
heartbeat.unref?.();
|
|
296
|
+
// 登记收口函数:drain 时强制关闭(SSE 是长连,不关会把进程吊住)。
|
|
297
|
+
// 收口必须同时 abort 对应 run:sse.close() → res.end() 后 writableEnded 同步
|
|
298
|
+
// 变 true,上面的 onClose 守卫(`!writableEnded`)在 close 事件时不成立,
|
|
299
|
+
// 只 close 不 abort 会让 run 在后台继续烧 token(与背压收口路径的
|
|
300
|
+
// onBackpressure → abort 对齐;run 已正常结束时 abort 是无害 no-op)。
|
|
301
|
+
const closeSse = () => {
|
|
302
|
+
clearInterval(heartbeat);
|
|
303
|
+
runAc.abort();
|
|
304
|
+
sse.close();
|
|
305
|
+
};
|
|
306
|
+
openSse.add(closeSse);
|
|
307
|
+
try {
|
|
308
|
+
// rethrow:false —— 与 AsyncRunner 对齐:硬失败也以 run.end 下发(status/error 字段)
|
|
309
|
+
const out = await app.run(messages, {
|
|
310
|
+
rethrow: false,
|
|
311
|
+
signal: runAc.signal,
|
|
312
|
+
onText: (delta) => sse.event('text.delta', { text: delta }),
|
|
313
|
+
...(traceContext !== undefined ? { traceContext } : {}),
|
|
314
|
+
});
|
|
315
|
+
sse.event('run.end', toHttpBody(out));
|
|
316
|
+
}
|
|
317
|
+
catch (e) {
|
|
318
|
+
// 流已开(200 与头已发出)→ 只能以 error 事件收尾,不能再改 HTTP 状态码
|
|
319
|
+
sse.event('error', { message: errMessage(e) });
|
|
320
|
+
}
|
|
321
|
+
finally {
|
|
322
|
+
openSse.delete(closeSse);
|
|
323
|
+
closeSse();
|
|
324
|
+
}
|
|
325
|
+
return;
|
|
329
326
|
}
|
|
330
|
-
|
|
327
|
+
// rethrow:false —— 与 AsyncRunner 对齐:硬失败也以 status/error 字段返回 200
|
|
328
|
+
const out = await app.run(messages, {
|
|
329
|
+
rethrow: false,
|
|
330
|
+
signal: runAc.signal,
|
|
331
|
+
...(traceContext !== undefined ? { traceContext } : {}),
|
|
332
|
+
});
|
|
333
|
+
sendJson(res, 200, toHttpBody(out));
|
|
334
|
+
}
|
|
335
|
+
finally {
|
|
336
|
+
res.off('close', onClose);
|
|
337
|
+
inFlightRuns--;
|
|
331
338
|
}
|
|
332
|
-
// rethrow:false —— 与 AsyncRunner 对齐:硬失败也以 status/error 字段返回 200
|
|
333
|
-
const out = await app.run(messages, {
|
|
334
|
-
rethrow: false,
|
|
335
|
-
signal: runAc.signal,
|
|
336
|
-
...(traceContext !== undefined ? { traceContext } : {}),
|
|
337
|
-
});
|
|
338
|
-
sendJson(res, 200, toHttpBody(out));
|
|
339
|
-
}
|
|
340
|
-
finally {
|
|
341
|
-
res.off('close', onClose);
|
|
342
|
-
inFlightRuns--;
|
|
343
|
-
}
|
|
344
|
-
return;
|
|
345
|
-
}
|
|
346
|
-
if (pathname === '/tasks') {
|
|
347
|
-
if (method !== 'POST') {
|
|
348
|
-
methodNotAllowed(res, method, 'POST');
|
|
349
|
-
return;
|
|
350
|
-
}
|
|
351
|
-
// 停机中不再接单(含直接 drain 了 runner 的情况);GET /tasks/<id> 不受影响
|
|
352
|
-
if (draining || runner.isDraining) {
|
|
353
|
-
sendShuttingDown(req, res);
|
|
354
|
-
return;
|
|
355
|
-
}
|
|
356
|
-
const body = await parseJsonBody(req, res, maxBodyBytes);
|
|
357
|
-
if (body === PARSE_FAILED)
|
|
358
|
-
return;
|
|
359
|
-
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
|
360
|
-
sendJson(res, 400, { error: 'body 需为 { input, idempotencyKey?, options? }' });
|
|
361
339
|
return;
|
|
362
340
|
}
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
// 的那次 run(另一个进程 `resumePending` 接着跑)也带得上,关联不断链。
|
|
367
|
-
const submitTrace = parseTraceparent(headerValue(req, 'traceparent'));
|
|
368
|
-
let rec;
|
|
369
|
-
try {
|
|
370
|
-
rec = runner.submit(submitBody.input, {
|
|
371
|
-
...(submitBody.idempotencyKey !== undefined
|
|
372
|
-
? { idempotencyKey: submitBody.idempotencyKey }
|
|
373
|
-
: {}),
|
|
374
|
-
options: submitBody.options?.traceContext !== undefined
|
|
375
|
-
? submitBody.options
|
|
376
|
-
: {
|
|
377
|
-
...submitBody.options,
|
|
378
|
-
...(submitTrace !== undefined ? { traceContext: submitTrace } : {}),
|
|
379
|
-
},
|
|
380
|
-
source: 'http',
|
|
381
|
-
});
|
|
382
|
-
}
|
|
383
|
-
catch (e) {
|
|
384
|
-
// `submit` 是同步的:入参校验失败与 store 落库故障从同一个 catch 出去。
|
|
385
|
-
// 前者是调用方的错(400 + 原因),后者是服务端的错 —— 必须走与 500 路径同一套
|
|
386
|
-
// `exposeErrors` 策略,否则一次磁盘/Redis 故障会被报成「你参数写错了」,
|
|
387
|
-
// 并把内部错误消息原样回给调用方(500/401 路径都不这么干)。
|
|
388
|
-
if (e instanceof TaskInputError) {
|
|
389
|
-
sendJson(res, 400, { error: errMessage(e) });
|
|
390
|
-
return;
|
|
391
|
-
}
|
|
392
|
-
// 上面的停机闸门与 `submit` 之间隔着一次 `await parseJsonBody`:读 body 期间
|
|
393
|
-
// drain 可能刚开始,此时 submit 抛「正在优雅停机」。这是 503 而不是 500 ——
|
|
394
|
-
// 调用方应该退避重试,跟闸门本身回的是同一句话。
|
|
395
|
-
if (runner.isDraining) {
|
|
341
|
+
case 'submit': {
|
|
342
|
+
// 停机中不再接单(含直接 drain 了 runner 的情况);GET /tasks/<id> 不受影响
|
|
343
|
+
if (draining || runner.isDraining) {
|
|
396
344
|
sendShuttingDown(req, res);
|
|
397
345
|
return;
|
|
398
346
|
}
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
if (pathname.startsWith('/tasks/')) {
|
|
406
|
-
const rest = pathname.slice('/tasks/'.length);
|
|
407
|
-
// POST /tasks/<id>/approve(HITL):审批挂起的任务。drain 期间仍允许 ——
|
|
408
|
-
// 与 GET 轮询同理由:挂起的任务只有人能推进,停机不该连「批准」也拒掉。
|
|
409
|
-
if (rest.endsWith('/approve')) {
|
|
410
|
-
if (method !== 'POST') {
|
|
411
|
-
methodNotAllowed(res, method, 'POST');
|
|
347
|
+
const body = await parseJsonBody(req, res, maxBodyBytes);
|
|
348
|
+
if (body === PARSE_FAILED)
|
|
349
|
+
return;
|
|
350
|
+
const submitBody = toTaskSubmitBody(body);
|
|
351
|
+
if (!submitBody) {
|
|
352
|
+
sendJson(res, 400, { error: 'body 需为 { input, idempotencyKey?, options? }' });
|
|
412
353
|
return;
|
|
413
354
|
}
|
|
414
|
-
|
|
355
|
+
// 入站链路(spec §9.2):body 显式给的 `options.traceContext` 优先,否则取
|
|
356
|
+
// `traceparent` 头。它随 `spec.options` 落进 TaskRecord —— 所以**跨进程续跑**
|
|
357
|
+
// 的那次 run(另一个进程 `resumePending` 接着跑)也带得上,关联不断链。
|
|
358
|
+
const submitTrace = parseTraceparent(headerValue(req, 'traceparent'));
|
|
359
|
+
let rec;
|
|
415
360
|
try {
|
|
416
|
-
|
|
361
|
+
rec = runner.submit(submitBody.input, {
|
|
362
|
+
...(submitBody.idempotencyKey !== undefined
|
|
363
|
+
? { idempotencyKey: submitBody.idempotencyKey }
|
|
364
|
+
: {}),
|
|
365
|
+
options: submitBody.options?.traceContext !== undefined
|
|
366
|
+
? submitBody.options
|
|
367
|
+
: {
|
|
368
|
+
...submitBody.options,
|
|
369
|
+
...(submitTrace !== undefined ? { traceContext: submitTrace } : {}),
|
|
370
|
+
},
|
|
371
|
+
source: 'http',
|
|
372
|
+
});
|
|
417
373
|
}
|
|
418
|
-
catch {
|
|
419
|
-
|
|
374
|
+
catch (e) {
|
|
375
|
+
// `submit` 是同步的:入参校验失败与 store 落库故障从同一个 catch 出去。
|
|
376
|
+
// 前者是调用方的错(400 + 原因),后者是服务端的错 —— 必须走与 500 路径同一套
|
|
377
|
+
// `exposeErrors` 策略,否则一次磁盘/Redis 故障会被报成「你参数写错了」,
|
|
378
|
+
// 并把内部错误消息原样回给调用方(500/401 路径都不这么干)。
|
|
379
|
+
if (e instanceof TaskInputError) {
|
|
380
|
+
sendJson(res, 400, { error: errMessage(e) });
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
// 上面的停机闸门与 `submit` 之间隔着一次 `await parseJsonBody`:读 body 期间
|
|
384
|
+
// drain 可能刚开始,此时 submit 抛「正在优雅停机」。这是 503 而不是 500 ——
|
|
385
|
+
// 调用方应该退避重试,跟闸门本身回的是同一句话。
|
|
386
|
+
if (runner.isDraining) {
|
|
387
|
+
sendShuttingDown(req, res);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
sendInternalError(res, e);
|
|
420
391
|
return;
|
|
421
392
|
}
|
|
393
|
+
sendJson(res, 202, rec);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
// POST /tasks/<id>/approve(HITL):审批挂起的任务。drain 期间仍允许 ——
|
|
397
|
+
// 与 GET 轮询同理由:挂起的任务只有人能推进,停机不该连「批准」也拒掉。
|
|
398
|
+
case 'approve': {
|
|
399
|
+
const taskId = route.taskId;
|
|
422
400
|
const body = await parseJsonBody(req, res, maxBodyBytes);
|
|
423
401
|
if (body === PARSE_FAILED)
|
|
424
402
|
return;
|
|
@@ -443,28 +421,26 @@ export function createHttpHandler(app, opts = {}) {
|
|
|
443
421
|
}
|
|
444
422
|
return;
|
|
445
423
|
}
|
|
446
|
-
|
|
447
|
-
|
|
424
|
+
case 'poll': {
|
|
425
|
+
const taskId = route.taskId;
|
|
426
|
+
const rec = await runner.poll(taskId); // MaybePromise:异步 store 下必须 await
|
|
427
|
+
if (!rec) {
|
|
428
|
+
sendJson(res, 404, { error: `task 不存在: ${taskId}` });
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
sendJson(res, 200, rec);
|
|
448
432
|
return;
|
|
449
433
|
}
|
|
450
|
-
|
|
451
|
-
try {
|
|
452
|
-
taskId = decodeURIComponent(rest);
|
|
453
|
-
}
|
|
454
|
-
catch {
|
|
434
|
+
case 'badTaskId':
|
|
455
435
|
// 残缺的 % 转义会抛 URIError —— 是调用方的输入问题(400),不是服务端 500
|
|
456
436
|
sendJson(res, 400, { error: 'taskId 不是合法的 URL 编码' });
|
|
457
437
|
return;
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
sendJson(res, 404, { error:
|
|
438
|
+
// healthz / metrics 已在上面的免鉴权组里 return 掉
|
|
439
|
+
case 'notFound':
|
|
440
|
+
default:
|
|
441
|
+
sendJson(res, 404, { error: `路径不存在: ${pathname}` });
|
|
462
442
|
return;
|
|
463
|
-
}
|
|
464
|
-
sendJson(res, 200, rec);
|
|
465
|
-
return;
|
|
466
443
|
}
|
|
467
|
-
sendJson(res, 404, { error: `路径不存在: ${pathname}` });
|
|
468
444
|
}
|
|
469
445
|
catch (e) {
|
|
470
446
|
sendInternalError(res, e);
|
|
@@ -474,38 +450,6 @@ export function createHttpHandler(app, opts = {}) {
|
|
|
474
450
|
return Object.assign(handler, { drain, runner });
|
|
475
451
|
}
|
|
476
452
|
const PARSE_FAILED = Symbol('parse-failed');
|
|
477
|
-
/**
|
|
478
|
-
* 解析 `POST /tasks/<id>/approve` 的 body;形状不合法返回 undefined(调用方回 400)。
|
|
479
|
-
* `decisions` 必须是纯对象,每个值是 `{ approved: boolean, reason?: string }`。
|
|
480
|
-
*/
|
|
481
|
-
function parseApproveBody(body) {
|
|
482
|
-
if (!body || typeof body !== 'object' || Array.isArray(body))
|
|
483
|
-
return undefined;
|
|
484
|
-
const o = body;
|
|
485
|
-
if (!o.decisions || typeof o.decisions !== 'object' || Array.isArray(o.decisions)) {
|
|
486
|
-
return undefined;
|
|
487
|
-
}
|
|
488
|
-
if (o.decidedBy !== undefined && typeof o.decidedBy !== 'string')
|
|
489
|
-
return undefined;
|
|
490
|
-
const decisions = {};
|
|
491
|
-
for (const [id, d] of Object.entries(o.decisions)) {
|
|
492
|
-
if (!d || typeof d !== 'object' || Array.isArray(d))
|
|
493
|
-
return undefined;
|
|
494
|
-
const v = d;
|
|
495
|
-
if (typeof v.approved !== 'boolean')
|
|
496
|
-
return undefined;
|
|
497
|
-
if (v.reason !== undefined && typeof v.reason !== 'string')
|
|
498
|
-
return undefined;
|
|
499
|
-
decisions[id] = {
|
|
500
|
-
approved: v.approved,
|
|
501
|
-
...(v.reason !== undefined ? { reason: v.reason } : {}),
|
|
502
|
-
};
|
|
503
|
-
}
|
|
504
|
-
return {
|
|
505
|
-
decisions,
|
|
506
|
-
...(o.decidedBy !== undefined ? { decidedBy: o.decidedBy } : {}),
|
|
507
|
-
};
|
|
508
|
-
}
|
|
509
453
|
/** 读取并解析 JSON body;失败时直接回错误响应并返回哨兵(连接已断则无响应可回)。 */
|
|
510
454
|
async function parseJsonBody(req, res, maxBytes) {
|
|
511
455
|
const body = await readBody(req, maxBytes);
|