@sema-agent/server 7.120.0 → 7.122.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/MIGRATION.md +173 -0
- package/README.md +1 -1
- package/README.zh-CN.md +3 -1
- package/USAGE.md +97 -29
- package/deploy/sema-up/chart/templates/server-v4pro.yaml +2 -2
- package/deploy/sema-up/chart/templates/server.yaml +2 -2
- package/deploy/sema-up/stack/sema-stack-cluster.yaml +1 -1
- package/dist/approval-reconciler.d.ts +8 -0
- package/dist/approval-reconciler.js +72 -52
- package/dist/auto-mode-face.d.ts +9 -2
- package/dist/auto-mode-face.js +5 -3
- package/dist/bench/s1/live-deps.js +1 -0
- package/dist/boot/config-center.d.ts +5 -7
- package/dist/boot/config-center.js +33 -9
- package/dist/boot/engine-lease.d.ts +1 -1
- package/dist/boot/leader.js +1 -0
- package/dist/boot/reapers.d.ts +3 -0
- package/dist/boot/reapers.js +2 -1
- package/dist/boot/resolve-spec.js +2 -4
- package/dist/boot/shutdown.d.ts +9 -10
- package/dist/boot/shutdown.js +59 -16
- package/dist/boot/stage-08-reapers.js +2 -2
- package/dist/boot/stage-09-leader.d.ts +2 -9
- package/dist/boot/stage-10-http-server.d.ts +1 -9
- package/dist/boot/stage-10-http-server.js +31 -8
- package/dist/capabilities/composite-members.d.ts +1 -1
- package/dist/capabilities/composite-members.js +1 -1
- package/dist/capabilities/memory-notice.d.ts +1 -1
- package/dist/config-catalog.js +12 -6
- package/dist/config-center/center-request.d.ts +13 -1
- package/dist/config-center/center-request.js +25 -10
- package/dist/config-types.d.ts +44 -5
- package/dist/config-types.js +6 -0
- package/dist/config.d.ts +35 -6
- package/dist/config.js +113 -33
- package/dist/fleet/drain-entry.d.ts +112 -0
- package/dist/fleet/drain-entry.js +38 -0
- package/dist/fleet/engine-signal.d.ts +144 -0
- package/dist/fleet/engine-signal.js +60 -0
- package/dist/fleet/serving-lease.d.ts +47 -0
- package/dist/fleet/serving-lease.js +233 -0
- package/dist/fleet-client.d.ts +35 -19
- package/dist/fleet-client.js +49 -25
- package/dist/governance-overrides.d.ts +7 -2
- package/dist/http/admission.js +7 -5
- package/dist/http/approval-sweeps.js +5 -7
- package/dist/http/checkpoint-ctx.d.ts +60 -11
- package/dist/http/checkpoint-ctx.js +23 -9
- package/dist/http/dispatch.js +33 -16
- package/dist/http/health-self-report.d.ts +60 -0
- package/dist/http/health-self-report.js +53 -0
- package/dist/http/principal-gate.d.ts +8 -1
- package/dist/http/principal-gate.js +1 -0
- package/dist/http/readiness.d.ts +25 -5
- package/dist/http/readiness.js +31 -6
- package/dist/http/resume-legs.js +9 -9
- package/dist/http/route-ctx.d.ts +25 -7
- package/dist/http/route-table.d.ts +6 -0
- package/dist/http/route-table.js +8 -1
- package/dist/http/routes/a2a-serve.d.ts +1 -1
- package/dist/http/routes/a2a-serve.js +5 -5
- package/dist/http/routes/admin-drain.d.ts +15 -31
- package/dist/http/routes/admin-drain.js +28 -10
- package/dist/http/routes/approvals-assistant.d.ts +4 -3
- package/dist/http/routes/approvals-assistant.js +9 -9
- package/dist/http/routes/capabilities.js +7 -5
- package/dist/http/routes/devices.js +3 -2
- package/dist/http/routes/fleet.d.ts +1 -0
- package/dist/http/routes/fleet.js +1 -1
- package/dist/http/routes/images.d.ts +1 -0
- package/dist/http/routes/images.js +1 -1
- package/dist/http/routes/leader.d.ts +1 -1
- package/dist/http/routes/leader.js +7 -1
- package/dist/http/routes/memory-consolidation.d.ts +1 -1
- package/dist/http/routes/memory-consolidation.js +1 -1
- package/dist/http/routes/notify-wake.d.ts +1 -1
- package/dist/http/routes/notify-wake.js +1 -1
- package/dist/http/routes/rules.js +4 -3
- package/dist/http/routes/runs.d.ts +4 -2
- package/dist/http/routes/runs.js +7 -6
- package/dist/http/routes/sessions.d.ts +1 -0
- package/dist/http/routes/sessions.js +1 -1
- package/dist/http/routes/side-query.d.ts +1 -1
- package/dist/http/routes/side-query.js +1 -1
- package/dist/http/routes/tasks.d.ts +2 -2
- package/dist/http/routes/tasks.js +3 -3
- package/dist/http/routes/trace-usage.d.ts +1 -0
- package/dist/http/routes/trace-usage.js +1 -1
- package/dist/http/routes/workflows.d.ts +1 -0
- package/dist/http/routes/workflows.js +1 -1
- package/dist/http/send.d.ts +13 -0
- package/dist/http/send.js +9 -0
- package/dist/http/server.d.ts +10 -21
- package/dist/http/server.js +20 -1
- package/dist/http/stored-body-replay.d.ts +27 -0
- package/dist/http/stored-body-replay.js +23 -0
- package/dist/http/wire-types.d.ts +7 -1
- package/dist/leader/leader.d.ts +22 -1
- package/dist/leader/leader.js +4 -1
- package/dist/leader/repair-wire.d.ts +1 -1
- package/dist/leader/repair-wire.js +2 -2
- package/dist/leader/run-entry.d.ts +27 -0
- package/dist/leader/run-entry.js +29 -0
- package/dist/leader/wire.d.ts +10 -0
- package/dist/leader/wire.js +15 -10
- package/dist/model-reach-scope.d.ts +15 -3
- package/dist/model-reach-scope.js +3 -2
- package/dist/model-route-url.d.ts +13 -20
- package/dist/model-route-url.js +28 -24
- package/dist/model-unreachable-notice.d.ts +2 -1
- package/dist/model-unreachable-notice.js +1 -0
- package/dist/observability/fail-open.d.ts +35 -2
- package/dist/observability/fail-open.js +20 -0
- package/dist/plugins/approval-ask-store-file.d.ts +1 -0
- package/dist/plugins/approval-ask-store-file.js +3 -0
- package/dist/plugins/approval-ask-store-memory.d.ts +1 -0
- package/dist/plugins/approval-ask-store-memory.js +6 -0
- package/dist/plugins/approval-ask-store-sql.d.ts +12 -0
- package/dist/plugins/approval-ask-store-sql.js +4 -0
- package/dist/plugins/one-time-migrations.js +14 -1
- package/dist/plugins/park-commit-watch.d.ts +19 -5
- package/dist/plugins/park-commit-watch.js +24 -17
- package/dist/plugins/permission-rule-store-sql.d.ts +2 -1
- package/dist/plugins/remote-env-device.js +0 -1
- package/dist/request-key-closure.js +1 -1
- package/dist/router/route-orchestration.d.ts +4 -2
- package/dist/router/route-orchestration.js +2 -1
- package/dist/rules-consent.d.ts +6 -2
- package/dist/rules-consent.js +3 -0
- package/dist/run-local.js +3 -1
- package/dist/runs.js +1 -1
- package/dist/runtime-governance.d.ts +44 -32
- package/dist/runtime-governance.js +23 -9
- package/dist/sighup-idle.d.ts +7 -1
- package/dist/sighup-idle.js +4 -4
- package/dist/task-settings.d.ts +37 -14
- package/dist/task-settings.js +20 -20
- package/dist/tool-approval.d.ts +13 -0
- package/dist/tool-approval.js +17 -2
- package/dist/trace/core-keyset-guard.d.ts +2 -2
- package/dist/trace/redact.d.ts +3 -3
- package/dist/trace/self-minted-wire.d.ts +64 -0
- package/dist/trace/self-minted-wire.js +30 -0
- package/dist/unattended-permission-mode.d.ts +17 -13
- package/dist/unattended-permission-mode.js +3 -1
- package/dist/url-shape.d.ts +31 -0
- package/dist/url-shape.js +41 -0
- package/package.json +3 -3
- package/scripts/drain-check-local.mjs +1 -1
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { IDENTITY_SOURCES } from "./principal-gate.js";
|
|
2
|
+
import { operatorLane, sessionList } from "../capability-facts.js";
|
|
3
|
+
import { authBridgeIssuerOf } from "../auth-bridge.js";
|
|
4
|
+
import { centerLaneConfigured } from "../config-center/center-request.js";
|
|
5
|
+
import { buildDisplaySafeUrl } from "../trace/redact.js";
|
|
6
|
+
export const IDENTITY_ATTACH_WORDS = ["available", "unavailable"];
|
|
7
|
+
export const OPERATOR_PRESENCE_WORDS = ["present", "absent"];
|
|
8
|
+
export const SESSIONS_VISIBLE_VALUES = [true, false, "unknown"];
|
|
9
|
+
function carriesCallerIdentity(kind) {
|
|
10
|
+
switch (kind) {
|
|
11
|
+
case "service_token":
|
|
12
|
+
return false;
|
|
13
|
+
case "sso":
|
|
14
|
+
case "direct_door_jwt":
|
|
15
|
+
return true;
|
|
16
|
+
default: {
|
|
17
|
+
const unknownKind = kind;
|
|
18
|
+
throw new Error(`health self-report: unknown identity source ${String(unknownKind)}`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function identitySourceWired(kind, a) {
|
|
23
|
+
switch (kind) {
|
|
24
|
+
case "service_token":
|
|
25
|
+
return a.credentialGateArmed;
|
|
26
|
+
case "sso":
|
|
27
|
+
return a.bridgeWired;
|
|
28
|
+
case "direct_door_jwt":
|
|
29
|
+
return a.directDoorActive;
|
|
30
|
+
default: {
|
|
31
|
+
const unknownKind = kind;
|
|
32
|
+
throw new Error(`health self-report: unknown identity source ${String(unknownKind)}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export function buildHealthSelfReport(deps, credentialGateArmed) {
|
|
37
|
+
const { config } = deps;
|
|
38
|
+
const bridgeWired = deps.registryJwtVerifier !== undefined;
|
|
39
|
+
const kinds = IDENTITY_SOURCES.filter((k) => identitySourceWired(k, { credentialGateArmed, bridgeWired, directDoorActive: config.directDoorActive }));
|
|
40
|
+
const issuer = authBridgeIssuerOf(config)?.issuer;
|
|
41
|
+
const webBaseUrl = config.configCenter?.webBaseUrl;
|
|
42
|
+
const sessionsVisible = centerLaneConfigured(config) && config.fleetAdvertiseAddress !== undefined && bridgeWired && sessionList(deps).available ? (config.requirePrincipal ? true : "unknown") : false;
|
|
43
|
+
return {
|
|
44
|
+
auth: { required: credentialGateArmed, kinds, identityAttach: kinds.some(carriesCallerIdentity) ? "available" : "unavailable" },
|
|
45
|
+
operator: operatorLane(deps).available ? "present" : "absent",
|
|
46
|
+
center: {
|
|
47
|
+
...(issuer !== undefined ? { issuer: buildDisplaySafeUrl(issuer) } : {}),
|
|
48
|
+
...(webBaseUrl !== undefined ? { webBaseUrl: buildDisplaySafeUrl(webBaseUrl) } : {}),
|
|
49
|
+
sessionsVisible,
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
//# sourceMappingURL=health-self-report.js.map
|
|
@@ -13,13 +13,20 @@ export declare function systemFor(req: IncomingMessage, config: ServiceConfig):
|
|
|
13
13
|
export declare function bearerPresentedButUnverified(req: IncomingMessage, config: ServiceConfig): boolean;
|
|
14
14
|
/** loopback 三形(IPv4 / IPv6 / IPv4-mapped)—— 一处定义,diagnostics 门与任何「本机自用」判据同吃。缺席 ⇒ false(fail-closed)。 */
|
|
15
15
|
export declare function isLoopbackAddress(addr: string | undefined): boolean;
|
|
16
|
+
/**
|
|
17
|
+
* 验过的身份来源的**闭集**(序 = `/health.auth.kinds` 的出现序)。{@link verifiedIdentitySource} 的回值就是这张表的词;
|
|
18
|
+
* `/health` 的自报位(`http/health-self-report.ts`)读同一张表,答「这台部署的装配**能**回哪几个词」—— 加一个来源 = 加一个词,
|
|
19
|
+
* 两边各自的穷举 `switch` 不接就编译红。
|
|
20
|
+
*/
|
|
21
|
+
export declare const IDENTITY_SOURCES: readonly ["service_token", "sso", "direct_door_jwt"];
|
|
22
|
+
export type IdentitySource = (typeof IDENTITY_SOURCES)[number];
|
|
16
23
|
/**
|
|
17
24
|
* 这次请求的身份是不是**密码学验过**的(而不是调用方自述的 header):`sso` = registry JWT(入口预认证的签名真源)/
|
|
18
25
|
* `direct_door_jwt` = direct-door principal JWT 验过 / `service_token` = 出示了单一或 per-system service token。
|
|
19
26
|
* 缺席 = 只有自述头或什么都没有。一处判定(7.63.0 codex [high]+[medium]):off-box 读 operator 面的门用它——名单只在
|
|
20
27
|
* 有可验身份时才是身份,零凭据下它只是谁都能写的字符串;而 JWT 身份不依赖静态 token 的在场。
|
|
21
28
|
*/
|
|
22
|
-
export declare function verifiedIdentitySource(req: IncomingMessage, config: ServiceConfig):
|
|
29
|
+
export declare function verifiedIdentitySource(req: IncomingMessage, config: ServiceConfig): IdentitySource | undefined;
|
|
23
30
|
export declare function gatedPrincipal(req: IncomingMessage, config: ServiceConfig): string | undefined;
|
|
24
31
|
/**
|
|
25
32
|
* 「**出示了凭证但验不过** ≠ **没出示**」在 **direct-door principal JWT** 面上的形 —— 本仓
|
|
@@ -37,6 +37,7 @@ export function bearerPresentedButUnverified(req, config) {
|
|
|
37
37
|
export function isLoopbackAddress(addr) {
|
|
38
38
|
return addr === "127.0.0.1" || addr === "::1" || addr === "::ffff:127.0.0.1";
|
|
39
39
|
}
|
|
40
|
+
export const IDENTITY_SOURCES = ["service_token", "sso", "direct_door_jwt"];
|
|
40
41
|
export function verifiedIdentitySource(req, config) {
|
|
41
42
|
if (ssoVerifiedPrincipal(req))
|
|
42
43
|
return "sso";
|
package/dist/http/readiness.d.ts
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
* 模块形:纯叶(只值 import `send.ts`)—— `fleet-client.ts` / `boot/` 都会读它,不许长出回环。
|
|
23
23
|
*/
|
|
24
24
|
import type { ServerResponse } from "node:http";
|
|
25
|
+
import type { ServingLeaseView } from "../fleet/engine-signal.js";
|
|
25
26
|
/**
|
|
26
27
|
* 就绪原因闭集,**数组序 = 优先序**:`/health.readyReason` 取首条的句、计费门取首条的拒绝码。
|
|
27
28
|
* draining 在前 —— 一台正在下线的副本「去找替补」(Retry-After 15)比「稍后重试本机」(5)更对。
|
|
@@ -61,6 +62,26 @@ export declare function healthReadinessKeys(v: ReadinessVerdict): Record<string,
|
|
|
61
62
|
/** `/readyz` 两形:200 `{ready:true}` / 503 `{ready:false, reasons}` + `Retry-After: 5`。体只这两键(不带身份字段:
|
|
62
63
|
* 就绪探针给 k8s readinessProbe / Traefik / LB 摘流用,不是 `/health` 的替身;`drainReason` 等运维自由文本也不上这一面)。 */
|
|
63
64
|
export declare function sendReadyz(res: ServerResponse, v: ReadinessVerdict): void;
|
|
65
|
+
/**
|
|
66
|
+
* 编排器片 2 件 2 —— 计费面请求的**种类**(闭集;路由声明表 `billable` 列的取值,`http/route-ctx.ts`):
|
|
67
|
+
* · `submit` —— 起一轮**新**工作(新任务 / 新 run / leader / side-query / wake / 记忆整理 / A2A `message/send`);
|
|
68
|
+
* · `hitl-answer` —— 对一张已经在等人的卡作答(耐久 `/decide`、计划审阅、PARKED 停驻卡的赎回);
|
|
69
|
+
* · `resume` —— 续跑一条已经存在的 run(助手 resume、子代 resume)。
|
|
70
|
+
*/
|
|
71
|
+
export declare const BILLABLE_KINDS: readonly ["submit", "hitl-answer", "resume"];
|
|
72
|
+
export type BillableKind = (typeof BILLABLE_KINDS)[number];
|
|
73
|
+
/**
|
|
74
|
+
* 计费面的拒因(**数组序 = 优先序**):就绪原因({@link READINESS_REASONS})∪ `lease_expired`(服务租约过期且中心不可达满 `T_max`)。
|
|
75
|
+
* `lease_expired` 刻意不是就绪原因:它不翻 `/readyz`(中心不可达不是本实例的病,摘它出路由只会把取消 / HITL / 读面一起摘掉)。
|
|
76
|
+
*/
|
|
77
|
+
export declare const ADMISSION_REFUSALS: readonly ["draining", "model_roster_pending", "lease_expired"];
|
|
78
|
+
export type AdmissionRefusal = (typeof ADMISSION_REFUSALS)[number];
|
|
79
|
+
/**
|
|
80
|
+
* 计费面请求的**唯一**准入判(dispatch 的计费门、a2a `message/send`、PARKED 赎回席同调):按 {@link ADMISSION_REFUSALS} 序找第一条
|
|
81
|
+
* 成立且按 {@link ADMISSION_TABLE} 拒这一种的拒因;没有 ⇒ `undefined`(放行)。入参是**判决**而不是 deps:就绪判由调用点经唯一谓词
|
|
82
|
+
* {@link readinessOf} 现读(模块边界上那一只 —— 谓词单源钉注入它,任何读点自己读 `drainState` 都会当场红),租约读数取 `deps.servingLease`。
|
|
83
|
+
*/
|
|
84
|
+
export declare function admissionRefusalOf(verdict: ReadinessVerdict, lease: ServingLeaseView | undefined, kind: BillableKind): BillableRefusal | undefined;
|
|
64
85
|
/** 计费提交门的拒绝(首条 reason 定码)。`retryAfterSec` 由发送形(`sendBillableRefusal`)镜像成头;交回
|
|
65
86
|
* `{status, body}` 的赎回席只取体(它的路由刻意不带头,见 `approval-sweeps.ts` 该臂注)。 */
|
|
66
87
|
export interface BillableRefusal {
|
|
@@ -69,17 +90,16 @@ export interface BillableRefusal {
|
|
|
69
90
|
readonly error: string;
|
|
70
91
|
readonly errorCode: string;
|
|
71
92
|
readonly message: string;
|
|
72
|
-
readonly reason?: string;
|
|
73
93
|
};
|
|
74
|
-
readonly retryAfterSec: 15 | 5;
|
|
94
|
+
readonly retryAfterSec: 15 | 5 | 60;
|
|
75
95
|
}
|
|
76
96
|
/**
|
|
77
97
|
* 计费提交门的拒绝体 —— **唯一铸点**(`error` 字面 `"draining"` / `"model_roster_pending"` 与 `message` 是冻结的
|
|
78
98
|
* wire 契约:SDK / 壳的 pre-stream 重试按它们判型;冻结在 `test/api-error-text-freeze.test.ts` 的
|
|
79
|
-
* `FROZEN_RESULT_BODY` 与 `test/api-notice-text-freeze.test.ts` §2)
|
|
80
|
-
*
|
|
99
|
+
* `FROZEN_RESULT_BODY` 与 `test/api-notice-text-freeze.test.ts` §2)。编排器片 2:排空体的 `reason` 键随
|
|
100
|
+
* 停机因由声明口一起退役(排空口的体改成契约 `DrainRequest`,没有因由字段);`lease_expired` 一臂新增。
|
|
81
101
|
*/
|
|
82
|
-
export declare function billableRefusalOf(
|
|
102
|
+
export declare function billableRefusalOf(reason: AdmissionRefusal): BillableRefusal;
|
|
83
103
|
/** 发送形:`Retry-After` + 体(`dispatch.ts` 的 billable 门与 a2a `message/send` 同调)。走 `sendError` —— errorCode
|
|
84
104
|
* 是它的必填位置参(对抗复审 #3:修前这里是 `sendJson(res, r.status, r.body)`,与 leader 口的豁免行逐字同文,
|
|
85
105
|
* 在 error-code-key-gate 上借了别人的豁免过门);体键序与修前逐字同(`error, errorCode, message, reason?`)。 */
|
package/dist/http/readiness.js
CHANGED
|
@@ -33,16 +33,41 @@ export function sendReadyz(res, v) {
|
|
|
33
33
|
res.setHeader("retry-after", "5");
|
|
34
34
|
sendJson(res, 503, { ready: false, reasons: [...v.reasons] });
|
|
35
35
|
}
|
|
36
|
-
export
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
export const BILLABLE_KINDS = ["submit", "hitl-answer", "resume"];
|
|
37
|
+
export const ADMISSION_REFUSALS = [...READINESS_REASONS, "lease_expired"];
|
|
38
|
+
const ADMISSION_TABLE = {
|
|
39
|
+
draining: { submit: true, "hitl-answer": false, resume: false },
|
|
40
|
+
model_roster_pending: { submit: true, "hitl-answer": true, resume: true },
|
|
41
|
+
lease_expired: { submit: true, "hitl-answer": false, resume: false },
|
|
42
|
+
};
|
|
43
|
+
export function admissionRefusalOf(verdict, lease, kind) {
|
|
44
|
+
const holdsNow = (r) => {
|
|
45
|
+
switch (r) {
|
|
46
|
+
case "draining":
|
|
47
|
+
case "model_roster_pending":
|
|
48
|
+
return !verdict.ready && verdict.reasons.includes(r);
|
|
49
|
+
case "lease_expired":
|
|
50
|
+
return lease?.state === "expired" && lease.pastOutageMax;
|
|
51
|
+
default: {
|
|
52
|
+
const unhandled = r;
|
|
53
|
+
throw new Error(`admission refusal without a predicate arm: ${String(unhandled)}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
const refusal = ADMISSION_REFUSALS.find((r) => holdsNow(r) && ADMISSION_TABLE[r][kind]);
|
|
58
|
+
return refusal === undefined ? undefined : billableRefusalOf(refusal);
|
|
59
|
+
}
|
|
60
|
+
export function billableRefusalOf(reason) {
|
|
61
|
+
switch (reason) {
|
|
39
62
|
case "draining":
|
|
40
|
-
return { status: 503, body: { error: "draining", errorCode: "draining", message: "this instance is draining for shutdown/upgrade — retry against the replacement instance"
|
|
63
|
+
return { status: 503, body: { error: "draining", errorCode: "draining", message: "this instance is draining for shutdown/upgrade — retry against the replacement instance" }, retryAfterSec: 15 };
|
|
41
64
|
case "model_roster_pending":
|
|
42
65
|
return { status: 503, body: { error: "model_roster_pending", errorCode: "state.model_roster_pending", message: "this worker has no model yet (waiting for the first effective-config pull to land the roster) — retry shortly" }, retryAfterSec: 5 };
|
|
66
|
+
case "lease_expired":
|
|
67
|
+
return { status: 503, body: { error: "lease_expired", errorCode: "state.lease_expired", message: "this instance has not renewed its serving lease with the fleet center for longer than its outage limit — it takes no new work; cancel, approvals, resume and reads stay open here — retry new work against another instance" }, retryAfterSec: 60 };
|
|
43
68
|
default: {
|
|
44
|
-
const unhandled =
|
|
45
|
-
throw new Error(`
|
|
69
|
+
const unhandled = reason;
|
|
70
|
+
throw new Error(`admission refusal without a billable refusal: ${String(unhandled)}`);
|
|
46
71
|
}
|
|
47
72
|
}
|
|
48
73
|
}
|
package/dist/http/resume-legs.js
CHANGED
|
@@ -29,7 +29,7 @@ import { reasoningDeltaBytes, reasoningSegmentEventId } from "../trace/model-seg
|
|
|
29
29
|
import { errorText } from "../observability/err-text.js";
|
|
30
30
|
import { recordFailOpen } from "../observability/fail-open.js";
|
|
31
31
|
import { verifyRoundsFromBody } from "./verify-rounds.js";
|
|
32
|
-
import { storedBodyPermissionMode } from "../task-settings.js";
|
|
32
|
+
import { planReviewDecisionExitsPlan, storedBodyPermissionMode } from "../task-settings.js";
|
|
33
33
|
import { acceptedResumingReceipt, buildHostLegOutcome } from "./route-ctx.js";
|
|
34
34
|
import { mintAndDriveDurableRun } from "./durable-run-leg.js";
|
|
35
35
|
import { createSessionsLocal } from "./routes/sessions.js";
|
|
@@ -43,7 +43,8 @@ import { commitSessionInheritEnv } from "./session-inherit-env.js";
|
|
|
43
43
|
import { ASK_USER_QUESTION_TOOL_NAME } from "../approval-content-kind.js";
|
|
44
44
|
import { deploymentListAsks } from "../approval.js";
|
|
45
45
|
import { readResumeCtx, resumeCtxCorruptAnswer } from "./resume-ctx-read.js";
|
|
46
|
-
import { resumeBlockedByPolicyAnswer, storedBodyReplayAnswer, storedBodyReplayLogFields,
|
|
46
|
+
import { resumeBlockedByPolicyAnswer, storedBodyReplayAnswer, storedBodyReplayLogFields, withPermissionModeRewritten } from "./checkpoint-ctx.js";
|
|
47
|
+
import { storedBodyReplayRefusal } from "./stored-body-replay.js";
|
|
47
48
|
import { httpErrorCode } from "./send.js";
|
|
48
49
|
function isCheckpointReopenedFailure(r) {
|
|
49
50
|
const code = failureCodeOf(r.terminal);
|
|
@@ -422,7 +423,7 @@ export function createResumeLegs(ctx) {
|
|
|
422
423
|
return storedBodyReplayAnswer(refusal);
|
|
423
424
|
};
|
|
424
425
|
const resolveResumeSpecOrBlocked = async (ctx, req, auth) => {
|
|
425
|
-
const replay = storedBodyReplayRefusal(ctx);
|
|
426
|
+
const replay = storedBodyReplayRefusal(ctx, deps.config.autonomy);
|
|
426
427
|
if (replay !== undefined)
|
|
427
428
|
return { ok: false, answer: refuseStoredBodyReplay(auth.sessionId, replay) };
|
|
428
429
|
try {
|
|
@@ -582,7 +583,7 @@ export function createResumeLegs(ctx) {
|
|
|
582
583
|
let unregisterEngineNotice;
|
|
583
584
|
try {
|
|
584
585
|
const instructionLeg = createInstructionSelectionLeg();
|
|
585
|
-
result = await instructionLeg.run(sessionId, () => withModelReachScope(sessionId, () => withPrincipal(principal, async () => {
|
|
586
|
+
result = await instructionLeg.run(sessionId, () => withModelReachScope({ sessionId, taskId }, () => withPrincipal(principal, async () => {
|
|
586
587
|
const resumeNotifiedKeys = new NotifiedKeys();
|
|
587
588
|
if (deps.runStore && taskId) {
|
|
588
589
|
const rs0 = deps.runStore;
|
|
@@ -1352,7 +1353,7 @@ export function createResumeLegs(ctx) {
|
|
|
1352
1353
|
return refuse(resumeCtxCorruptAnswer(ctxRead.where));
|
|
1353
1354
|
if (ctxRead.kind === "absent")
|
|
1354
1355
|
return refuse({ status: 409, body: { error: "resume context missing — cannot rebuild task config", errorCode: "conflict.resume_context_unavailable" } });
|
|
1355
|
-
const replay = storedBodyReplayRefusal(ctxRead.ctx);
|
|
1356
|
+
const replay = storedBodyReplayRefusal(ctxRead.ctx, deps.config.autonomy);
|
|
1356
1357
|
if (replay !== undefined)
|
|
1357
1358
|
return refuse(refuseStoredBodyReplay(sessionId, replay));
|
|
1358
1359
|
return { ok: true, prepared: { sessionId, token, cp, ctx: ctxRead.ctx } };
|
|
@@ -1364,11 +1365,10 @@ export function createResumeLegs(ctx) {
|
|
|
1364
1365
|
const auth = { sessionId: cp.sessionId, principal: decodeCheckpointScope(cp.scope), memoryScope: ctx.memoryScope };
|
|
1365
1366
|
const effMode = storedBodyPermissionMode(ctx.body);
|
|
1366
1367
|
if (permissionModeAfter !== undefined && effMode !== "plan") {
|
|
1367
|
-
throw new Error("drivePlanReview: permissionModeAfter was passed for a task
|
|
1368
|
+
throw new Error("drivePlanReview: permissionModeAfter was passed for a task whose stored permission mode is not \"plan\" — the caller must refuse this before any state changes (see the plan_review route's pre-nonce gate)");
|
|
1368
1369
|
}
|
|
1369
|
-
const approvedMode = decision
|
|
1370
|
-
const
|
|
1371
|
-
const rebuildCtx = { ...ctx, body: resumeBody };
|
|
1370
|
+
const approvedMode = planReviewDecisionExitsPlan(decision) && effMode === "plan" ? (permissionModeAfter ?? "default") : undefined;
|
|
1371
|
+
const rebuildCtx = approvedMode === undefined ? ctx : withPermissionModeRewritten(ctx, { ...ctx.body, permissionMode: approvedMode });
|
|
1372
1372
|
const resolved = await resolveResumeSpecOrBlocked(rebuildCtx, req, auth);
|
|
1373
1373
|
if (!resolved.ok)
|
|
1374
1374
|
return resolved.answer;
|
package/dist/http/route-ctx.d.ts
CHANGED
|
@@ -168,6 +168,14 @@ export interface CheckpointCtx {
|
|
|
168
168
|
* 写点只有 `buildCheckpointCtx`(同模块;新的持久化写);续跑腿的写回传原 ctx / 其展开 ⇒ 记号随行。体里没有 `allow` 的行不写它。
|
|
169
169
|
*/
|
|
170
170
|
permissionsAllowReading?: import("./checkpoint-ctx.js").PermissionsAllowReading;
|
|
171
|
+
/**
|
|
172
|
+
* 体里**模式词的读法记号**(闭集只有当前一词 `"entry_word"`):在场 = 这一行体的顶层 `permissionMode` 是这条 run 模式词唯一的源(提交入口盖过运维
|
|
173
|
+
* `AUTONOMY=plan` 的地板;`plan_review` 写回改它),续跑照它读。**服务端写、不来自请求体**。缺席(7.121.x 及以前写下的行 —— 那时运维的 plan 由治理层每段重钳)
|
|
174
|
+
* 而本部署此刻有入口地板、体的词被地板钳会变 ⇒ 续跑腿响亮拒、行字节不动(规则与删除条件的属主 = `http/checkpoint-ctx.ts` 顶注第三问)。
|
|
175
|
+
* 写点两个(写模式词的人盖记号,同模块):`buildCheckpointCtx`(每一次新的持久化写都写)与 `withPermissionModeRewritten`(`plan_review` 改词的写回);
|
|
176
|
+
* 不改词的续跑腿写回传原 ctx ⇒ 记号随行。
|
|
177
|
+
*/
|
|
178
|
+
permissionModeReading?: import("./checkpoint-ctx.js").PermissionModeReading;
|
|
171
179
|
}
|
|
172
180
|
/** B4①(命名化):`driveResumeIntoRunLog` 的入参形(resume 家族共用)——原为 9 成员内联匿名对象。 */
|
|
173
181
|
export interface DriveResumeArgs {
|
|
@@ -448,8 +456,8 @@ export interface RouteLegs {
|
|
|
448
456
|
drivePlanReview(prepared: PreparedPlanReview, decision: "approve" | "edit" | "reject", editedPlan: string | undefined, reason: string | undefined, req: IncomingMessage | undefined,
|
|
449
457
|
/** [ref] 件 S-1:透传给 {@link DriveResumeArgs.acceptEarly}(200 受理语义)。HTTP `/plan_review` 腿传 `true`。 */
|
|
450
458
|
acceptEarly?: boolean,
|
|
451
|
-
/** S-433:批准之后这条会话继续用的 permission mode(闭集 `PLAN_APPROVE_PERMISSION_MODES_AFTER
|
|
452
|
-
* 上有意义,缺席 ⇒ `"default"`)。语义与判据的唯一属主 = `resume-legs.ts` 的同名形参顶注。 */
|
|
459
|
+
/** S-433:批准之后这条会话继续用的 permission mode(闭集 `PLAN_APPROVE_PERMISSION_MODES_AFTER`;只在把计划带出 plan 的决定
|
|
460
|
+
* —— `approve` / `edit` —— 上有意义,缺席 ⇒ `"default"`)。语义与判据的唯一属主 = `resume-legs.ts` 的同名形参顶注。 */
|
|
453
461
|
permissionModeAfter?: PlanApprovePermissionModeAfter): Promise<{
|
|
454
462
|
status: number;
|
|
455
463
|
body: object;
|
|
@@ -569,10 +577,12 @@ export type RouteMethod = "GET" | "POST" | "PUT" | "DELETE";
|
|
|
569
577
|
* `satisfies` 让它与 {@link RouteMethod} 双向闭合:漏一个动词 / 多一个都是编译红。 */
|
|
570
578
|
export declare const ROUTE_METHOD_ORDER: readonly ["GET", "POST", "PUT", "DELETE"];
|
|
571
579
|
export interface RouteDeclFacts {
|
|
572
|
-
/** 「这条 url **烧模型**」—— drain / model-roster-pending / 无 service-token
|
|
573
|
-
* 没有这一列 = 不烧模型(
|
|
574
|
-
* 少一道门 ——
|
|
575
|
-
|
|
580
|
+
/** 「这条 url **烧模型**」—— drain / model-roster-pending / 租约过期 / 无 service-token 四道 503 的共同判别。
|
|
581
|
+
* 没有这一列 = 不烧模型(零默认值:要它在场必须显式写出来,漏写的方向是**少收一笔账单**而不是
|
|
582
|
+
* 少一道门 —— 反了才是灾难,所以门读的是在场这一侧)。
|
|
583
|
+
* 编排器片 2 件 2:值 = 这一种计费请求的**种类**(`submit` 新工作 / `hitl-answer` 对等人的卡作答 / `resume` 续跑;闭集属主
|
|
584
|
+
* `http/readiness.ts` 的 `BILLABLE_KINDS`)—— 排空与租约过期只拒 `submit`(放行闭集表 `ADMISSION_TABLE`),无凭证门不看种类。 */
|
|
585
|
+
readonly billable?: import("./readiness.js").BillableKind;
|
|
576
586
|
/** 「本条 url 在**没有任何 service credential** 的部署形下,这几个方法必须 fail-closed」。
|
|
577
587
|
* 判据(两个合取项)= 「授权的唯一输入是 principal 头」∧「持久改写,或爆炸半径跨租户的治理读」;
|
|
578
588
|
* 逐口论证在各 `routes/*.ts` 的头注里,声明行只记结论。 */
|
|
@@ -584,7 +594,7 @@ export interface RouteDeclFacts {
|
|
|
584
594
|
* 503 照旧罩着(它判的是「这台机能不能收写」,与方法无关,且必须在解析请求体之前)。 */
|
|
585
595
|
readonly methodDispatched?: true;
|
|
586
596
|
}
|
|
587
|
-
/**
|
|
597
|
+
/** **可分派行**多出来的列(S-146 第三刀):身份 + 它真认的方法(+ S-1281 的订阅类)。纯门行一列都没有(见下)。 */
|
|
588
598
|
interface RouteDeclIdentity {
|
|
589
599
|
/** **全表唯一**的行名(kebab 闭集词)。这是声明行的身份:域 handler 用
|
|
590
600
|
* `switch (match.decl.id)` 闭集分派(漏一口 = 编译红),所以它必须稳定、唯一、且与路径解耦
|
|
@@ -599,6 +609,14 @@ interface RouteDeclIdentity {
|
|
|
599
609
|
* ② 2 条「一条路径住着两只域」的跨域行按方法拆成两行各归各域(`/v1/tasks` = trace-usage 的 GET +
|
|
600
610
|
* tasks 的 POST;`/v1/memory/export` = memory-policy 的 GET + memory-bundle 的 POST)。 */
|
|
601
611
|
readonly methods: readonly RouteMethod[];
|
|
612
|
+
/**
|
|
613
|
+
* 「这条 GET 开的是一条**只读订阅**」—— 一条 SSE 读面(推送队列 / 账本 tail),它看着别人干活,自己不是活(S-1281)。
|
|
614
|
+
* 在场 ⇒ 分派器不把它记进在飞(`drainState.inflight()`:排空等的、`/health` 报的、SIGHUP 空闲窗与附着租约读的那一只),只记进附着
|
|
615
|
+
* (`attachedStreams()`:壳还挂在这只引擎上);排空进入那一拍(以及硬停起点)由分派器发一帧 `draining` 终帧后关掉它
|
|
616
|
+
* (`drainState.endSubscriptions`)。闭集 = 八条 GET SSE 读面,逐行理由见各行注;**唯一**一条「在飞腿的流」`POST /v1/tasks/stream`
|
|
617
|
+
* 不带它 —— 那条流就是 sync 腿本身(关流 = 断连语义),照旧算在飞。漏标一条订阅的方向是**多算在飞**(排空多等),不是少等。
|
|
618
|
+
*/
|
|
619
|
+
readonly subscription?: true;
|
|
602
620
|
}
|
|
603
621
|
/**
|
|
604
622
|
* **可分派行**:一条真路由。匹配式二选一 —— `path`(精确;标签**就是** path 自身,所以没有 label 列,
|
|
@@ -54,6 +54,7 @@
|
|
|
54
54
|
import type { FlatServiceDeps } from "./server.js";
|
|
55
55
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
56
56
|
import type { RouteDecl, RouteDispatchDecl, RouteMatch, RouteCtx, RouteMethod } from "./route-ctx.js";
|
|
57
|
+
import type { BillableKind } from "./readiness.js";
|
|
57
58
|
/** 一只域 handler 相对**全局服务凭据门**的位置。门前 = 未鉴权可达(每一只都要单独论证),门后 = 先过凭据。 */
|
|
58
59
|
/**
|
|
59
60
|
* 一行路由在全局服务凭据门的哪一侧、吃哪道门(闭集,三员;S-672 起由两员拆成三员):
|
|
@@ -133,11 +134,16 @@ export declare function matchDecl(d: RouteDispatchDecl, url: string): Readonly<R
|
|
|
133
134
|
export declare function routeLabel(_method: string, url: string): string;
|
|
134
135
|
/** POST endpoints that trigger BILLABLE work — the fail-closed auth guard must cover ALL of them. */
|
|
135
136
|
export declare function isBillableSubmitPath(url: string): boolean;
|
|
137
|
+
/** 编排器片 2 件 2:这条 url 是哪一**种**计费请求(`undefined` = 不在计费面)。声明序第一条命中的行定种类(今天每条 url 至多命中一行)。 */
|
|
138
|
+
export declare function billableKindOf(url: string): BillableKind | undefined;
|
|
136
139
|
/** [ref]([ref] 件5)—— **改写门**:不烧模型、但在没有任何 service credential 的部署形下必须
|
|
137
140
|
* 与 billable / bake 两族一样 fail-closed 的写门。与 `isBillableSubmitPath` **刻意分开**:这几扇门
|
|
138
141
|
* `billable=false`(零模型工作),塞进那张名单会让它们连带吃 drain 与 roster-pending 两道 503 ——
|
|
139
142
|
* 而收编与规则读写在排空期/roster 未落时做完全无害。 */
|
|
140
143
|
export declare function isCredentialGatedRewrite(method: string, url: string): boolean;
|
|
144
|
+
/** S-1281:这次请求是不是一条**只读订阅**(`RouteDeclFacts.subscription` 那一列;方法也要对上 —— 订阅行恒只认 GET)。
|
|
145
|
+
* 分派器在 admission 那一拍(第一个 await 之前)同步判它:订阅不进在飞计数,只进附着集与订阅集。 */
|
|
146
|
+
export declare function isSubscriptionRequest(method: string, url: string): boolean;
|
|
141
147
|
/** 见 `RouteDeclFacts.methodDispatched` 的头注(单 URL 多方法端点)。 */
|
|
142
148
|
export declare function isMethodDispatchedSubmitPath(url: string): boolean;
|
|
143
149
|
/** 一次分派查找的结果:命中的行与它的参数,外加**这条路径上所有行**的方法并集(405 的 `Allow`)。 */
|
package/dist/http/route-table.js
CHANGED
|
@@ -109,8 +109,9 @@ export function matchDecl(d, url) {
|
|
|
109
109
|
const NO_PARAMS = Object.freeze({});
|
|
110
110
|
const LABEL_BY_PATH = new Map(ROUTE_DECLARATIONS.flatMap((d) => ("path" in d ? [[d.path, d.path]] : [])));
|
|
111
111
|
const SHAPED_LABEL_DECLS = ROUTE_DECLARATIONS.filter((d) => !("path" in d) && typeof d.label === "string");
|
|
112
|
-
const BILLABLE_DECLS = ROUTE_DECLARATIONS.filter((d) => d.billable
|
|
112
|
+
const BILLABLE_DECLS = ROUTE_DECLARATIONS.filter((d) => d.billable !== undefined);
|
|
113
113
|
const METHOD_DISPATCHED_DECLS = ROUTE_DECLARATIONS.filter((d) => d.methodDispatched === true);
|
|
114
|
+
const SUBSCRIPTION_DECLS = ROUTE_DECLARATIONS.filter(isDispatchDecl).filter((d) => d.subscription === true);
|
|
114
115
|
const CREDENTIAL_GATED_BY_METHOD = (() => {
|
|
115
116
|
const m = new Map();
|
|
116
117
|
for (const d of ROUTE_DECLARATIONS)
|
|
@@ -135,10 +136,16 @@ export function routeLabel(_method, url) {
|
|
|
135
136
|
export function isBillableSubmitPath(url) {
|
|
136
137
|
return BILLABLE_DECLS.some((d) => declMatches(d, url));
|
|
137
138
|
}
|
|
139
|
+
export function billableKindOf(url) {
|
|
140
|
+
return BILLABLE_DECLS.find((d) => declMatches(d, url))?.billable;
|
|
141
|
+
}
|
|
138
142
|
export function isCredentialGatedRewrite(method, url) {
|
|
139
143
|
const rows = CREDENTIAL_GATED_BY_METHOD.get(method.toUpperCase());
|
|
140
144
|
return rows !== undefined && rows.some((d) => declMatches(d, url));
|
|
141
145
|
}
|
|
146
|
+
export function isSubscriptionRequest(method, url) {
|
|
147
|
+
return SUBSCRIPTION_DECLS.some((d) => d.methods.includes(method) && matchDecl(d, url) !== null);
|
|
148
|
+
}
|
|
142
149
|
export function isMethodDispatchedSubmitPath(url) {
|
|
143
150
|
return METHOD_DISPATCHED_DECLS.some((d) => declMatches(d, url));
|
|
144
151
|
}
|
|
@@ -79,7 +79,7 @@ export type A2aServeRouteId = RouteIdsOf<typeof A2A_SERVE_ROUTES>;
|
|
|
79
79
|
export declare const A2A_RPC_ROUTES: readonly [{
|
|
80
80
|
readonly id: "a2a-rpc";
|
|
81
81
|
readonly path: "/v1/a2a";
|
|
82
|
-
readonly billable:
|
|
82
|
+
readonly billable: "submit";
|
|
83
83
|
readonly methodDispatched: true;
|
|
84
84
|
readonly methods: readonly ["POST"];
|
|
85
85
|
}];
|
|
@@ -5,7 +5,7 @@ import { isParkedRunStatus, isTerminalRunStatus } from "../../plugins/store-cont
|
|
|
5
5
|
import { HttpError } from "../../security.js";
|
|
6
6
|
import { scopedIdempotencyKey } from "../idempotency.js";
|
|
7
7
|
import { sendJson, sendError } from "../send.js";
|
|
8
|
-
import {
|
|
8
|
+
import { admissionRefusalOf, readinessOf, sendBillableRefusal } from "../readiness.js";
|
|
9
9
|
import { gatedPrincipal, sendPrincipalRefusal } from "../principal-gate.js";
|
|
10
10
|
import { assertNever } from "../route-ctx.js";
|
|
11
11
|
import * as facts from "../../capability-facts.js";
|
|
@@ -168,10 +168,10 @@ function rpcErrorFromTypedFailure(err, skillId) {
|
|
|
168
168
|
}
|
|
169
169
|
function submitUnavailable(res, ctx) {
|
|
170
170
|
const { deps } = ctx;
|
|
171
|
-
const
|
|
172
|
-
if (
|
|
171
|
+
const refusal = admissionRefusalOf(readinessOf(deps), deps.servingLease?.(), "submit");
|
|
172
|
+
if (refusal === undefined)
|
|
173
173
|
return false;
|
|
174
|
-
sendBillableRefusal(res,
|
|
174
|
+
sendBillableRefusal(res, refusal);
|
|
175
175
|
return true;
|
|
176
176
|
}
|
|
177
177
|
function readBlocking(params) {
|
|
@@ -392,6 +392,6 @@ export const A2A_SERVE_ROUTES = [
|
|
|
392
392
|
{ id: "a2a-agent-card", path: "/.well-known/agent-card.json", methods: ["GET"] },
|
|
393
393
|
];
|
|
394
394
|
export const A2A_RPC_ROUTES = [
|
|
395
|
-
{ id: "a2a-rpc", path: "/v1/a2a", billable:
|
|
395
|
+
{ id: "a2a-rpc", path: "/v1/a2a", billable: "submit", methodDispatched: true, methods: ["POST"] },
|
|
396
396
|
];
|
|
397
397
|
//# sourceMappingURL=a2a-serve.js.map
|
|
@@ -1,40 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* 编排壳在发停机信号(SIGTERM)**之前**先调本口显式声明停机因由;server 记入 `drainState.reason`,
|
|
4
|
-
* draining 期间由 `/health`(`drainReason`)与 503 draining 体(`reason`)透出。未声明 = 两面键缺席
|
|
5
|
-
* (additive,不编数)——升级/缩容/驱逐在消费端因此可判别,而不是一律读成「实例没了」。
|
|
2
|
+
* `POST /v1/admin/drain` —— 排空口(编排器长远稿片 2 件 2;契约体 = settings-schema 8.1.0 `DrainRequest`,回执 = `DrainReceipt`)。
|
|
6
3
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* probe / LB / 编排器带不了 token,`drainReason` 放这儿的全部理由就是给它们看);② 值的内容完全由
|
|
22
|
-
* operator 自选,且窗口只在 draining 期;③ 同一免鉴权体上早已有 `dataRoot` 绝对路径 / `pid` / `port` /
|
|
23
|
-
* `instanceId` / `configHash` / `storeProbe`,单独收紧本键不改变那面的暴露量级。
|
|
24
|
-
* (那条另立的兄弟件 —— `storeProbe.error` 把**原始异常 message 未截断**塞出去、DSN 片段可随之外泄 ——
|
|
25
|
-
* 已在 [ref] 修掉:免鉴权面上它改成闭集词,原文只走日志轴。两件的判据不同故不合并:本键的值由 operator
|
|
26
|
-
* 自选且窗口只在 draining 期,`storeProbe.error` 的值则来自不受控的驱动异常。)
|
|
27
|
-
* ⇒ 真正的缺口是**告诫缺席**:此前路由头注、400 文案、CHANGELOG、wire 契约四处都只说「透出」,没有一处
|
|
28
|
-
* 说「其中一面免鉴权」,操作员要拼出「我写的字是公开的」得自己合并两处文档去推理。本批把它写进**写口
|
|
29
|
-
* 现场**(400 文案 + 本注 + USAGE 端点条目),因为那是操作员唯一会读到的地方。
|
|
30
|
-
* 词表化(把自由文本换成受限枚举)= 行为变更,**不在本批**:它会让「rolling upgrade to 7.36.0」这类真正
|
|
31
|
-
* 有用的因由说不出来,取舍要属主拍。
|
|
4
|
+
* 一只入口的 HTTP 车道(Docker 车道 / 中心直调 / 人手形):体经契约读器 `readDrainRequest`(一处判形),身份四元经
|
|
5
|
+
* `fleet/drain-entry.ts` 的 `drainIdentityMismatches`(一处判身份;本引擎的 scope = `config.fleetScope`,env `FLEET_SCOPE` 的唯一读数),
|
|
6
|
+
* 进入排空经 `drainState.enter`(收尾链装的唯一入口)。
|
|
7
|
+
* · 门序 = operator 面的既有口径(`routes/retention-ops.ts` 逐字):身份 → 授权 → 验型;billable=false,**刻意不吃排空 503**
|
|
8
|
+
* (排空中的重放必须能拿到 `already_draining` 回执,吃了就自锁)。
|
|
9
|
+
* · 判序:体坏 ⇒ 400 `request.field_invalid`(零状态变化)→ 没有收尾链(嵌入宿主 / 测试夹具)⇒ 501 `capability.drain_state_required`
|
|
10
|
+
* → 身份不符 ⇒ 409 `conflict.fleet_identity`(零状态变化;拒句点名不符的字段,不回显值)→ 入口:已在排空 / 硬停已在进行 ⇒ 200 回执
|
|
11
|
+
* (这一发的期限不会被采用);这一发会进入排空而 `deadlineAt` 离进入那一拍超过 `FLEET_LEASE_OUTAGE_MAX_MS`(`config.fleetLeaseOutageMaxMs`,
|
|
12
|
+
* 本口随入参声明的准入上界,`fleet/drain-entry.ts` 规则 2)⇒ 入口答拒、本口 400 `request.field_invalid`(零状态变化;拒句点名字段、上界的
|
|
13
|
+
* 席名与值,不回显请求带的期限)。
|
|
14
|
+
* · 首次 ⇒ 200 回执 `state:"draining"`;同一 `actionId`(或别的动作)再来 ⇒ 200 `already_draining`,回显第一次的 `since` / `deadlineAt`,不重置。
|
|
15
|
+
* · 人手形:`actionId` 自取(推荐 `manual:<uuid>`),身份照样四元比对 —— 非 fleet 成员不带 `expectedGeneration`(缺席对缺席);
|
|
16
|
+
* `instanceId` 在场才比(从本机 `/health` 读);不带 `scope` = 点名缺省 scope(多租引擎要带它自己的 `FLEET_SCOPE`)。
|
|
17
|
+
* 修前形(7.35.0 起的 `{reason}` 停机因由声明:只记一句话、不进排空)随契约体退役 —— 零别名;那一形的请求体读不成 `DrainRequest` ⇒ 400。
|
|
32
18
|
*/
|
|
33
19
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
34
20
|
import type { RouteCtx, RouteMatch, RouteIdsOf } from "../route-ctx.js";
|
|
35
21
|
export declare const ADMIN_DRAIN_PATH = "/v1/admin/drain";
|
|
36
|
-
/** 上限与 approvals 的 reason 族同量级(消费面同为人读短句;非同一常量——两面语义无关,独立演化)。 */
|
|
37
|
-
export declare const MAX_DRAIN_REASON_CHARS = 256;
|
|
38
22
|
export declare function handleAdminDrain(req: IncomingMessage, res: ServerResponse, match: RouteMatch<AdminDrainRouteId>, ctx: RouteCtx): Promise<void>;
|
|
39
23
|
export declare const ADMIN_DRAIN_ROUTES: readonly [{
|
|
40
24
|
readonly id: "admin-drain";
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import { readDrainRequest } from "@sema-agent/settings-schema/fleet";
|
|
1
2
|
import { sendJson, sendError } from "../send.js";
|
|
2
3
|
import { gatedPrincipal, explicitOperatorOk, sendOperatorPrincipalsRequired, sendPrincipalRefusal } from "../principal-gate.js";
|
|
3
4
|
import * as facts from "../../capability-facts.js";
|
|
5
|
+
import { fleetScopeOf } from "../../config-types.js";
|
|
6
|
+
import { drainIdentityMismatches } from "../../fleet/drain-entry.js";
|
|
4
7
|
import { assertNever } from "../route-ctx.js";
|
|
5
8
|
export const ADMIN_DRAIN_PATH = "/v1/admin/drain";
|
|
6
|
-
export const MAX_DRAIN_REASON_CHARS = 256;
|
|
7
9
|
const OPERATOR_ONLY_MESSAGE = "drain declaration is operator-only: the caller principal is not in OPERATOR_PRINCIPALS";
|
|
8
10
|
export async function handleAdminDrain(req, res, match, ctx) {
|
|
9
11
|
const { deps } = ctx;
|
|
@@ -22,19 +24,35 @@ export async function handleAdminDrain(req, res, match, ctx) {
|
|
|
22
24
|
sendError(res, 403, "auth.operator_only", OPERATOR_ONLY_MESSAGE);
|
|
23
25
|
return;
|
|
24
26
|
}
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
sendError(res, 400, "request.field_invalid", `the drain reason is surfaced verbatim on the UNAUTHENTICATED /health endpoint (and in 503 draining bodies), so anyone who can reach this port can read it — do not put ticket ids, hostnames, or internal identifiers in it. body must be { reason: string }: a non-empty string of at most ${MAX_DRAIN_REASON_CHARS} characters`);
|
|
27
|
+
const request = readDrainRequest(await ctx.helpers.readJson(req));
|
|
28
|
+
if (request === undefined) {
|
|
29
|
+
sendError(res, 400, "request.field_invalid", "the drain request must be { actionId, worker, deadlineAt, scope?, expectedGeneration?, instanceId?, term? }: actionId a non-empty idempotency key, worker this engine's worker name, deadlineAt epoch milliseconds, expectedGeneration a canonical decimal string (\"7\") — nothing was changed");
|
|
29
30
|
return;
|
|
30
31
|
}
|
|
31
|
-
|
|
32
|
-
|
|
32
|
+
const enter = deps.drainState?.enter;
|
|
33
|
+
if (enter === undefined) {
|
|
34
|
+
sendError(res, 501, "capability.drain_state_required", "this deployment has no drain entry wired (embedded in another process?) — there is no drain for this request to enter");
|
|
33
35
|
return;
|
|
34
36
|
}
|
|
35
|
-
deps.
|
|
36
|
-
|
|
37
|
-
|
|
37
|
+
const mismatched = drainIdentityMismatches(request, { scope: deps.config.fleetScope, worker: deps.config.configCenter?.worker, generation: deps.config.fleetGeneration, instanceId: deps.instanceId });
|
|
38
|
+
if (mismatched.length > 0) {
|
|
39
|
+
sendError(res, 409, "conflict.fleet_identity", `the drain request names a different instance than this engine (mismatched: ${mismatched.join(", ")}) — nothing was changed; compare with this engine's GET /health (worker comes from SEMA_REGISTRY_WORKER, generation from FLEET_GENERATION, scope from FLEET_SCOPE — unset, and a request without scope, mean "${fleetScopeOf(undefined)}")`);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const answer = enter({ actionId: request.actionId, deadlineAt: request.deadlineAt, deadlineBoundMs: deps.config.fleetLeaseOutageMaxMs });
|
|
43
|
+
switch (answer.state) {
|
|
44
|
+
case "deadline_beyond_bound":
|
|
45
|
+
sendError(res, 400, "request.field_invalid", `the drain request's deadlineAt is further from now than FLEET_LEASE_OUTAGE_MAX_MS (${answer.boundMs} ms) on this engine's clock — the longest this engine stays up without serving; send a nearer deadline — nothing was changed`);
|
|
46
|
+
return;
|
|
47
|
+
case "draining":
|
|
48
|
+
case "already_draining":
|
|
49
|
+
sendJson(res, 200, answer);
|
|
50
|
+
return;
|
|
51
|
+
default: {
|
|
52
|
+
const unhandled = answer;
|
|
53
|
+
throw new Error(`drain entry: unknown answer ${JSON.stringify(unhandled)}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
38
56
|
}
|
|
39
57
|
default:
|
|
40
58
|
return assertNever(match.decl.id);
|
|
@@ -151,6 +151,7 @@ export declare const APPROVALS_ASSISTANT_ROUTES: readonly [{
|
|
|
151
151
|
readonly id: "approvals-stream";
|
|
152
152
|
readonly path: "/v1/approvals/stream";
|
|
153
153
|
readonly methods: readonly ["GET"];
|
|
154
|
+
readonly subscription: true;
|
|
154
155
|
}, {
|
|
155
156
|
readonly id: "assistant-inbox";
|
|
156
157
|
readonly path: "/v1/assistant/inbox";
|
|
@@ -183,17 +184,17 @@ export declare const APPROVALS_ASSISTANT_ROUTES: readonly [{
|
|
|
183
184
|
readonly id: "assistant-task-resume";
|
|
184
185
|
readonly pattern: RegExp;
|
|
185
186
|
readonly label: "/v1/assistant/tasks/:id/resume";
|
|
186
|
-
readonly billable:
|
|
187
|
+
readonly billable: "resume";
|
|
187
188
|
readonly methods: readonly ["POST"];
|
|
188
189
|
}, {
|
|
189
190
|
readonly id: "assistant-task-plan-review";
|
|
190
191
|
readonly pattern: RegExp;
|
|
191
192
|
readonly label: "/v1/assistant/tasks/:id/plan_review";
|
|
192
|
-
readonly billable:
|
|
193
|
+
readonly billable: "hitl-answer";
|
|
193
194
|
readonly methods: readonly ["POST"];
|
|
194
195
|
}, {
|
|
195
196
|
readonly prefix: "/v1/approvals";
|
|
196
|
-
readonly billable:
|
|
197
|
+
readonly billable: "hitl-answer";
|
|
197
198
|
}];
|
|
198
199
|
/** 本域可分派行的 `id` 闭集 —— handler 的 `switch` 按它判穷尽(漏一口 = 编译红)。 */
|
|
199
200
|
export type ApprovalsAssistantRouteId = RouteIdsOf<typeof APPROVALS_ASSISTANT_ROUTES>;
|