@sleep2agi/commhub-server 0.9.0-preview.157 → 0.9.0-preview.159
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/package.json +1 -1
- package/src/api-nodes-shape.test.ts +95 -4
- package/src/node-attrs.test.ts +41 -0
- package/src/opencode-catalog-report-http.test.ts +139 -0
- package/src/push.test.ts +20 -10
- package/src/push.ts +39 -5
- package/src/scheduled-requirement-wake-http.test.ts +7 -1
- package/src/server.ts +27 -2
- package/src/tools.ts +17 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.9.0-preview.
|
|
3
|
+
"version": "0.9.0-preview.159",
|
|
4
4
|
"description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and MCP tools (17 collaboration-core + node/provider ops tools; authoritative list: docs-site/docs/api/mcp-tools.md).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -36,10 +36,25 @@ afterAll(() => {
|
|
|
36
36
|
// the dashboard / external clients see.
|
|
37
37
|
function mapRow(r: Record<string, unknown>): Record<string, unknown> {
|
|
38
38
|
let role: string | null = null;
|
|
39
|
+
// #2293 —— 与 handler 同一套读法:快照可能是文本或对象;解析失败/形状不对 = 这一格没有。
|
|
40
|
+
let opencodeCatalog: string[] | null = null;
|
|
41
|
+
let opencodeGeneration: "v1" | "v2" | null = null;
|
|
42
|
+
let opencodeMode: string | null = null;
|
|
43
|
+
let opencodeServer: string | null = null;
|
|
39
44
|
const snap = r.config_snapshot;
|
|
40
45
|
if (snap) {
|
|
41
|
-
|
|
42
|
-
|
|
46
|
+
let parsed: any = null;
|
|
47
|
+
try { parsed = typeof snap === "string" ? JSON.parse(snap) : snap; }
|
|
48
|
+
catch { parsed = null; }
|
|
49
|
+
role = parsed?.role ?? null;
|
|
50
|
+
const raw = parsed?.opencode_catalog;
|
|
51
|
+
if (Array.isArray(raw) && raw.every((m: unknown) => typeof m === "string" && m.length <= 400)) {
|
|
52
|
+
opencodeCatalog = raw.slice(0, 400);
|
|
53
|
+
}
|
|
54
|
+
const flags = parsed?.flags;
|
|
55
|
+
if (flags?.opencodeGeneration === "v1" || flags?.opencodeGeneration === "v2") opencodeGeneration = flags.opencodeGeneration;
|
|
56
|
+
if (typeof flags?.opencodeMode === "string" && flags.opencodeMode.length <= 32) opencodeMode = flags.opencodeMode;
|
|
57
|
+
if (typeof parsed?.opencode_server === "string" && parsed.opencode_server.length <= 200) opencodeServer = parsed.opencode_server;
|
|
43
58
|
}
|
|
44
59
|
const { config_snapshot, ...rest } = r;
|
|
45
60
|
return {
|
|
@@ -47,6 +62,10 @@ function mapRow(r: Record<string, unknown>): Record<string, unknown> {
|
|
|
47
62
|
tags: parseStoredTags(r.tags),
|
|
48
63
|
attrs_revision: Number(r.attrs_revision ?? 0),
|
|
49
64
|
role,
|
|
65
|
+
opencode_catalog: opencodeCatalog,
|
|
66
|
+
opencode_generation: opencodeGeneration,
|
|
67
|
+
opencode_mode: opencodeMode,
|
|
68
|
+
opencode_server: opencodeServer,
|
|
50
69
|
};
|
|
51
70
|
}
|
|
52
71
|
|
|
@@ -83,7 +102,7 @@ describe("RFC-024 regression — GET /api/nodes response shape (no SELECT *)", (
|
|
|
83
102
|
// Expected fields present (13 = 12 contract + new role)
|
|
84
103
|
for (const k of ["node_id", "node_name", "alias", "runtime", "model",
|
|
85
104
|
"config_path", "channels", "server", "hostname",
|
|
86
|
-
"network_id", "created_at", "updated_at", "role"]) {
|
|
105
|
+
"network_id", "created_at", "updated_at", "role", "opencode_catalog"]) {
|
|
87
106
|
expect(row).toHaveProperty(k);
|
|
88
107
|
}
|
|
89
108
|
// Internal fields NOT present
|
|
@@ -100,7 +119,8 @@ describe("RFC-024 regression — GET /api/nodes response shape (no SELECT *)", (
|
|
|
100
119
|
expect(Object.keys(row).sort()).toEqual([
|
|
101
120
|
"alias", "attrs_revision", "channels", "config_path", "created_at",
|
|
102
121
|
"display_name", "hostname", "model", "network_id", "node_id",
|
|
103
|
-
"node_name", "
|
|
122
|
+
"node_name", "opencode_catalog", "opencode_generation", "opencode_mode",
|
|
123
|
+
"opencode_server", "role", "runtime", "server", "tags", "team",
|
|
104
124
|
"updated_at",
|
|
105
125
|
]);
|
|
106
126
|
});
|
|
@@ -186,3 +206,74 @@ describe("RFC-024 regression — GET /api/nodes response shape (no SELECT *)", (
|
|
|
186
206
|
expect(cfgRow?.config_snapshot).toContain("claude-opus");
|
|
187
207
|
});
|
|
188
208
|
});
|
|
209
|
+
|
|
210
|
+
// #2293(admin 2026-10-25)—— 节点自报的模型清单必须能到 /api/nodes:
|
|
211
|
+
// 「没有 daemon 绑定」的节点,客户端就是靠这一格给建议列表(否则只能手填)。
|
|
212
|
+
describe("#2293 opencode_catalog projection", () => {
|
|
213
|
+
const insert = (snapshot: unknown) => db.run(
|
|
214
|
+
`INSERT OR REPLACE INTO nodes (node_id, node_name, alias, network_id, config_snapshot)
|
|
215
|
+
VALUES (?1, ?2, ?3, ?4, ?5)`,
|
|
216
|
+
[TEST_NODE, "test-shape", "test-shape", TEST_NETWORK, typeof snapshot === "string" ? snapshot : JSON.stringify(snapshot)],
|
|
217
|
+
);
|
|
218
|
+
const read = () => mapRow(db.get<Record<string, unknown>>(
|
|
219
|
+
`SELECT node_id, node_name, alias, network_id, config_snapshot FROM nodes WHERE node_id = ?1`,
|
|
220
|
+
TEST_NODE,
|
|
221
|
+
)!);
|
|
222
|
+
|
|
223
|
+
test("valid list reaches the row, order kept", () => {
|
|
224
|
+
insert({ opencode_catalog: ["deepseek/deepseek-flash", "opencode/mimo-v2.6-flash-free"] });
|
|
225
|
+
expect(read().opencode_catalog).toEqual(["deepseek/deepseek-flash", "opencode/mimo-v2.6-flash-free"]);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("absent / malformed / wrong shape → null, row still served", () => {
|
|
229
|
+
insert({ model: "x" });
|
|
230
|
+
expect(read().opencode_catalog).toBeNull();
|
|
231
|
+
insert("not-json{");
|
|
232
|
+
expect(read().opencode_catalog).toBeNull();
|
|
233
|
+
// 混了非字符串 → 整格作废(不猜、不部分展示)
|
|
234
|
+
insert({ opencode_catalog: ["a/b", 7] });
|
|
235
|
+
expect(read().opencode_catalog).toBeNull();
|
|
236
|
+
// 空列表是合法但无意义 → 照原样给出(客户端自己判断要不要建列表)
|
|
237
|
+
insert({ opencode_catalog: [] });
|
|
238
|
+
expect(read().opencode_catalog).toEqual([]);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// #2293(PR #1092 在真机上拿不到值的原因)—— /api/nodes 不带整个 config_snapshot,
|
|
243
|
+
// 但「Runtime · V2」「OpenCode Server」两行只要三个标量:代际、模式、loopback 端点。
|
|
244
|
+
describe("#2293 opencode_* scalars", () => {
|
|
245
|
+
test("generation / mode / server come from the snapshot, nothing else leaks", () => {
|
|
246
|
+
db.run(
|
|
247
|
+
`INSERT OR REPLACE INTO nodes (node_id, node_name, alias, network_id, config_snapshot)
|
|
248
|
+
VALUES (?1, ?2, ?3, ?4, ?5)`,
|
|
249
|
+
[TEST_NODE, "test-shape", "test-shape", TEST_NETWORK, JSON.stringify({
|
|
250
|
+
flags: { opencodeGeneration: "v2", opencodeMode: "copresence", secretOther: "must-not-leak" },
|
|
251
|
+
opencode_server: "http://127.0.0.1:4096",
|
|
252
|
+
})],
|
|
253
|
+
);
|
|
254
|
+
const row = mapRow(db.get<Record<string, unknown>>(
|
|
255
|
+
`SELECT node_id, node_name, alias, network_id, config_snapshot FROM nodes WHERE node_id = ?1`,
|
|
256
|
+
TEST_NODE,
|
|
257
|
+
)!);
|
|
258
|
+
expect(row.opencode_generation).toBe("v2");
|
|
259
|
+
expect(row.opencode_mode).toBe("copresence");
|
|
260
|
+
expect(row.opencode_server).toBe("http://127.0.0.1:4096");
|
|
261
|
+
expect(row).not.toHaveProperty("config_snapshot");
|
|
262
|
+
expect(JSON.stringify(row)).not.toContain("must-not-leak");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("unknown generation / absent fields → null (old nodes keep rendering)", () => {
|
|
266
|
+
db.run(
|
|
267
|
+
`INSERT OR REPLACE INTO nodes (node_id, node_name, alias, network_id, config_snapshot)
|
|
268
|
+
VALUES (?1, ?2, ?3, ?4, ?5)`,
|
|
269
|
+
[TEST_NODE, "test-shape", "test-shape", TEST_NETWORK, JSON.stringify({ flags: { opencodeGeneration: "v9" } })],
|
|
270
|
+
);
|
|
271
|
+
const row = mapRow(db.get<Record<string, unknown>>(
|
|
272
|
+
`SELECT node_id, node_name, alias, network_id, config_snapshot FROM nodes WHERE node_id = ?1`,
|
|
273
|
+
TEST_NODE,
|
|
274
|
+
)!);
|
|
275
|
+
expect(row.opencode_generation).toBeNull();
|
|
276
|
+
expect(row.opencode_mode).toBeNull();
|
|
277
|
+
expect(row.opencode_server).toBeNull();
|
|
278
|
+
});
|
|
279
|
+
});
|
package/src/node-attrs.test.ts
CHANGED
|
@@ -215,3 +215,44 @@ describe("node display attrs — auth + scope", () => {
|
|
|
215
215
|
expect(r.status).toBe(404);
|
|
216
216
|
});
|
|
217
217
|
});
|
|
218
|
+
|
|
219
|
+
// #2293(admin 2026-10-25)—— 走**真正**的 HTTP,不镜像 handler:
|
|
220
|
+
// 客户端「Runtime · V2」「OpenCode Server」和「模型可选列表」要的三样东西
|
|
221
|
+
// (代际 / 模式 / loopback 端点)与「这台机器上报的模型 id 清单」都在 /api/nodes 上,
|
|
222
|
+
// 而整个 config_snapshot 仍然不外泄(终端 #312 的约定)。
|
|
223
|
+
describe("#2293 opencode bits on GET /api/nodes (real HTTP)", () => {
|
|
224
|
+
const OC_NODE = "node_attrs_opencode";
|
|
225
|
+
const OC_ALIAS = "attrs-opencode-node";
|
|
226
|
+
|
|
227
|
+
test("generation / mode / server / catalog are projected; config_snapshot is not", async () => {
|
|
228
|
+
db.run(`DELETE FROM nodes WHERE node_id = ?1`, [OC_NODE]);
|
|
229
|
+
db.run(
|
|
230
|
+
`INSERT INTO nodes (node_id, node_name, alias, runtime, model, network_id, config_snapshot)
|
|
231
|
+
VALUES (?1, 'attrs-oc', ?2, 'opencode-sdk', 'deepseek/deepseek-flash', ?3, ?4)`,
|
|
232
|
+
[OC_NODE, OC_ALIAS, NET, JSON.stringify({
|
|
233
|
+
model: "deepseek/deepseek-flash",
|
|
234
|
+
flags: { opencodeGeneration: "v2", opencodeMode: "copresence", daemonSideSecret: "must-not-leak" },
|
|
235
|
+
opencode_server: "http://127.0.0.1:4096",
|
|
236
|
+
opencode_catalog: ["deepseek/deepseek-flash", "opencode/mimo-v2.6-flash-free"],
|
|
237
|
+
})],
|
|
238
|
+
);
|
|
239
|
+
const row = (await listNodes()).find((r) => r.node_id === OC_NODE)!;
|
|
240
|
+
expect(row).toBeDefined();
|
|
241
|
+
expect(row.opencode_generation).toBe("v2");
|
|
242
|
+
expect(row.opencode_mode).toBe("copresence");
|
|
243
|
+
expect(row.opencode_server).toBe("http://127.0.0.1:4096");
|
|
244
|
+
expect(row.opencode_catalog).toEqual(["deepseek/deepseek-flash", "opencode/mimo-v2.6-flash-free"]);
|
|
245
|
+
// 整个快照仍然不出现,且 flags 里的其它键不外泄。
|
|
246
|
+
expect(row).not.toHaveProperty("config_snapshot");
|
|
247
|
+
expect(JSON.stringify(row)).not.toContain("must-not-leak");
|
|
248
|
+
db.run(`DELETE FROM nodes WHERE node_id = ?1`, [OC_NODE]);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("a node that never reported them reads as null / [] (old node, no invented values)", async () => {
|
|
252
|
+
const row = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!;
|
|
253
|
+
expect(row.opencode_generation ?? null).toBeNull();
|
|
254
|
+
expect(row.opencode_mode ?? null).toBeNull();
|
|
255
|
+
expect(row.opencode_server ?? null).toBeNull();
|
|
256
|
+
expect(row.opencode_catalog ?? null).toBeNull();
|
|
257
|
+
});
|
|
258
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// #2293(admin 2026-10-25)—— 从**节点的真实上报**一路走到 `/api/nodes` 的投影。
|
|
2
|
+
//
|
|
3
|
+
// 为什么这条要单独写:`api-nodes-shape.test.ts` 是**镜像 handler** 的(文件里自己声明会漂),
|
|
4
|
+
// `node-attrs.test.ts` 是直接往库里塞一行快照。两者都没有走 `report_status` 的 zod schema ——
|
|
5
|
+
// 而 schema 恰恰是「节点发了、Hub 上却什么都没有」最容易出事的地方(#1545:往非 strict 对象里
|
|
6
|
+
// 加键会被静默丢弃)。这里用真 node 令牌 + 真 MCP `tools/call` + 真 `GET /api/nodes`。
|
|
7
|
+
//
|
|
8
|
+
// 断言三件事:
|
|
9
|
+
// 1. `opencode_catalog` / `opencode_server` / flags 里的代际·模式,经 schema 后**落到库里**;
|
|
10
|
+
// 2. `/api/nodes` 把它们投影出来,且**整个 config_snapshot 不外泄**;
|
|
11
|
+
// 3. 形状不对(嵌对象、混非字符串)时**心跳照收**,只是这一格没有 —— 不许拒整份上报。
|
|
12
|
+
|
|
13
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
14
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { createNetworkTokenForNode, register } from "./auth.js";
|
|
18
|
+
import { db } from "./db.js";
|
|
19
|
+
|
|
20
|
+
const DIR = mkdtempSync(join(tmpdir(), "anet-catalog-report-"));
|
|
21
|
+
process.env.COMMHUB_DB ||= join(DIR, "commhub.db");
|
|
22
|
+
|
|
23
|
+
const stamp = `${process.pid}_${Date.now()}`;
|
|
24
|
+
const NET_ALIAS = `catalog-${stamp}`;
|
|
25
|
+
let hub: any;
|
|
26
|
+
let base = "";
|
|
27
|
+
let adminToken = "";
|
|
28
|
+
let nodeToken = "";
|
|
29
|
+
let networkId = "";
|
|
30
|
+
let nodeId = "";
|
|
31
|
+
|
|
32
|
+
async function send(token: string, method: string, path: string, body?: unknown) {
|
|
33
|
+
const r = await fetch(`${base}${path}`, {
|
|
34
|
+
method,
|
|
35
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
36
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
37
|
+
});
|
|
38
|
+
const text = await r.text();
|
|
39
|
+
let json: any = null;
|
|
40
|
+
try { json = text ? JSON.parse(text) : null; } catch { /* MCP may answer as SSE */ }
|
|
41
|
+
return { status: r.status, body: json, text };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 真 MCP `tools/call report_status`,和 agent-node 心跳走同一条入参路径。 */
|
|
45
|
+
async function reportStatus(snapshot: Record<string, unknown>) {
|
|
46
|
+
const r = await fetch(`${base}/mcp`, {
|
|
47
|
+
method: "POST",
|
|
48
|
+
headers: { Authorization: `Bearer ${nodeToken}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream" },
|
|
49
|
+
body: JSON.stringify({
|
|
50
|
+
jsonrpc: "2.0", id: 1, method: "tools/call",
|
|
51
|
+
params: {
|
|
52
|
+
name: "report_status",
|
|
53
|
+
arguments: {
|
|
54
|
+
resume_id: `r_catalog_${stamp}`, alias: NET_ALIAS, status: "idle", node_id: nodeId,
|
|
55
|
+
runtime: "opencode-sdk",
|
|
56
|
+
config_snapshot: { model: "deepseek/deepseek-flash", ...snapshot },
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
}),
|
|
60
|
+
});
|
|
61
|
+
await r.text();
|
|
62
|
+
return r.status;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function listNodes() {
|
|
66
|
+
const r = await send(adminToken, "GET", `/api/nodes?network_id=${networkId}`);
|
|
67
|
+
expect(r.status).toBe(200);
|
|
68
|
+
return (r.body?.nodes ?? []) as Array<Record<string, unknown>>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
beforeAll(async () => {
|
|
72
|
+
const admin = register(`catalog_admin_${stamp}`, "CatalogAdmin123!", undefined, "Catalog Admin");
|
|
73
|
+
expect(admin.ok).toBe(true);
|
|
74
|
+
adminToken = admin.token!;
|
|
75
|
+
networkId = admin.network_id!;
|
|
76
|
+
const ownerId = admin.user!.user_id;
|
|
77
|
+
nodeId = `n_catalog_${stamp}`;
|
|
78
|
+
const minted = createNetworkTokenForNode(ownerId, networkId, NET_ALIAS, nodeId);
|
|
79
|
+
expect(minted.ok).toBe(true);
|
|
80
|
+
nodeToken = minted.token!;
|
|
81
|
+
const mod: any = await import("./server.js");
|
|
82
|
+
hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
83
|
+
base = `http://127.0.0.1:${hub.port}`;
|
|
84
|
+
}, 30_000);
|
|
85
|
+
|
|
86
|
+
afterAll(() => {
|
|
87
|
+
try { hub?.stop?.(true); } catch { /* already down */ }
|
|
88
|
+
try { rmSync(DIR, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("#2293 node-reported OpenCode catalog reaches GET /api/nodes", () => {
|
|
92
|
+
test("a real report_status lands the catalog and the scalars on the node row", async () => {
|
|
93
|
+
expect(await reportStatus({
|
|
94
|
+
flags: { opencodeGeneration: "v2", opencodeMode: "copresence", daemonSideSecret: "must-not-leak" },
|
|
95
|
+
opencode_catalog: ["deepseek/deepseek-flash", "opencode/mimo-v2.6-flash-free"],
|
|
96
|
+
opencode_server: "http://127.0.0.1:4096",
|
|
97
|
+
})).toBe(200);
|
|
98
|
+
|
|
99
|
+
const stored = db.get<{ config_snapshot: string }>("SELECT config_snapshot FROM nodes WHERE node_id = ?1", nodeId)!;
|
|
100
|
+
expect(JSON.parse(stored.config_snapshot).opencode_catalog).toEqual([
|
|
101
|
+
"deepseek/deepseek-flash", "opencode/mimo-v2.6-flash-free",
|
|
102
|
+
]);
|
|
103
|
+
|
|
104
|
+
const row = (await listNodes()).find(r => r.node_id === nodeId)!;
|
|
105
|
+
expect(row).toBeDefined();
|
|
106
|
+
expect(row.opencode_catalog).toEqual(["deepseek/deepseek-flash", "opencode/mimo-v2.6-flash-free"]);
|
|
107
|
+
expect(row.opencode_generation).toBe("v2");
|
|
108
|
+
expect(row.opencode_mode).toBe("copresence");
|
|
109
|
+
expect(row.opencode_server).toBe("http://127.0.0.1:4096");
|
|
110
|
+
// 整个快照仍然不出现在这一行里;flags 中的其它键也不外泄。
|
|
111
|
+
expect(row).not.toHaveProperty("config_snapshot");
|
|
112
|
+
expect(JSON.stringify(row)).not.toContain("must-not-leak");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("a malformed catalog drops that field only — the heartbeat is still accepted", async () => {
|
|
116
|
+
expect(await reportStatus({
|
|
117
|
+
flags: { opencodeGeneration: "v2" },
|
|
118
|
+
// 嵌对象 + 混入数字:形状不对,schema 的 `.catch(undefined)` 应当只丢这一格。
|
|
119
|
+
opencode_catalog: { models: ["deepseek/deepseek-flash"] },
|
|
120
|
+
opencode_server: 12345,
|
|
121
|
+
})).toBe(200);
|
|
122
|
+
|
|
123
|
+
const row = (await listNodes()).find(r => r.node_id === nodeId)!;
|
|
124
|
+
expect(row).toBeDefined();
|
|
125
|
+
expect(row.opencode_catalog).toBeNull();
|
|
126
|
+
expect(row.opencode_server).toBeNull();
|
|
127
|
+
// 代际这一格仍然照常投影(没被同一条错误连带丢掉)。
|
|
128
|
+
expect(row.opencode_generation).toBe("v2");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("a node that never reported the fields reads as null on every one of them", async () => {
|
|
132
|
+
const other = (await listNodes()).find(r => r.node_id !== nodeId);
|
|
133
|
+
if (!other) return; // 该网络里只有一个节点时无需断言(不是失败)
|
|
134
|
+
expect(other.opencode_catalog ?? null).toBeNull();
|
|
135
|
+
expect(other.opencode_generation ?? null).toBeNull();
|
|
136
|
+
expect(other.opencode_mode ?? null).toBeNull();
|
|
137
|
+
expect(other.opencode_server ?? null).toBeNull();
|
|
138
|
+
});
|
|
139
|
+
});
|
package/src/push.test.ts
CHANGED
|
@@ -351,26 +351,36 @@ describe("real ReadableStream — byte-level backpressure (round-2 fix)", () =>
|
|
|
351
351
|
await res.body!.cancel();
|
|
352
352
|
});
|
|
353
353
|
|
|
354
|
-
test("non-reading consumer
|
|
354
|
+
test("non-reading consumer stops growing at COALESCE_AT and is reaped by the stuck timeout", async () => {
|
|
355
|
+
// #2363 —— 语义变更:落后超过 COALESCE_AT_BYTES 后**跳过非关键事件**,不再一路排队到
|
|
356
|
+
// 1 MB 硬顶。这样 150 条慢订阅者不会把内存放大到 连接数 × 1 MB(实测 2 GB 尖峰)。
|
|
357
|
+
// 兜底仍然存在:硬顶对这一帧的**关键帧**生效;长期不读的由 STUCK_CLOSE_MS 回收。
|
|
358
|
+
const coalesce = __SSE_INTERNALS_FOR_TEST.COALESCE_AT_BYTES;
|
|
355
359
|
const ceiling = __SSE_INTERNALS_FOR_TEST.MAX_QUEUE_BACKPRESSURE_BYTES;
|
|
356
360
|
const res = createSSEStream("byte-cap-probe", "net-bcp");
|
|
357
361
|
const key = "net-bcp:byte-cap-probe";
|
|
358
362
|
const c = __SSE_INTERNALS_FOR_TEST.clientsMap.get(key)![0];
|
|
359
363
|
|
|
360
|
-
|
|
361
|
-
// With chunk-count strategy this would NOT close (each event is 1
|
|
362
|
-
// chunk; desiredSize would be at -N for N events, never -1M).
|
|
363
|
-
// With byte strategy this SHOULD close once accumulated bytes
|
|
364
|
-
// exceed ceiling.
|
|
365
|
-
const chunkSize = 100_000; // 100KB per push
|
|
366
|
-
const needed = Math.ceil(ceiling / chunkSize) + 2; // overshoot a bit
|
|
364
|
+
const chunkSize = 100_000; // 100KB per push, larger than COALESCE_AT
|
|
367
365
|
const big = "x".repeat(chunkSize);
|
|
368
|
-
|
|
366
|
+
const before = __SSE_INTERNALS_FOR_TEST.degradedDropCount();
|
|
367
|
+
for (let i = 0; i < Math.ceil(ceiling / chunkSize) + 2; i++) {
|
|
369
368
|
if (c.closed) break;
|
|
370
369
|
pushEvent("byte-cap-probe", { type: "big", payload: big }, "net-bcp");
|
|
371
370
|
}
|
|
371
|
+
// 关键断言 1:**没有被立刻关闭**(跳过的语义是“不排队”,不是“断开”)
|
|
372
|
+
expect(c.closed).toBe(false);
|
|
373
|
+
// 关键断言 2:队列没有一路涨到硬顶 —— 落后量被 COALESCE_AT 挡住
|
|
374
|
+
const desired = c.controller.desiredSize ?? 0;
|
|
375
|
+
expect(-desired).toBeLessThan(ceiling);
|
|
376
|
+
expect(-desired).toBeLessThanOrEqual(coalesce + chunkSize * 2);
|
|
377
|
+
// 关键断言 3:跳过被**计数**(不静默)
|
|
378
|
+
expect(__SSE_INTERNALS_FOR_TEST.degradedDropCount()).toBeGreaterThan(before);
|
|
379
|
+
|
|
380
|
+
// 关键断言 4:长期不读仍会被收回(沿用原有 stuck 回收)
|
|
381
|
+
c.stuckSince = Date.now() - (__SSE_INTERNALS_FOR_TEST.STUCK_CLOSE_MS + 1);
|
|
382
|
+
__SSE_INTERNALS_FOR_TEST.tryEnqueueBytes(c, new Uint8Array(1), { critical: true });
|
|
372
383
|
expect(c.closed).toBe(true);
|
|
373
|
-
expect(__SSE_INTERNALS_FOR_TEST.clientsMap.has(key)).toBe(false);
|
|
374
384
|
|
|
375
385
|
try { await res.body!.cancel(); } catch { /* already closed */ }
|
|
376
386
|
});
|
package/src/push.ts
CHANGED
|
@@ -59,6 +59,24 @@ function envNum(k: string, fallback: number): number {
|
|
|
59
59
|
* half-open consumer that has stopped reading entirely. */
|
|
60
60
|
const MAX_QUEUE_BACKPRESSURE_BYTES = envNum("ANET_SSE_MAX_QUEUE_BYTES", 1_000_000);
|
|
61
61
|
|
|
62
|
+
/** #2363 —— 订阅者“已经落后多少字节”就不再给它非关键事件。
|
|
63
|
+
*
|
|
64
|
+
* 背景(2026-10-26 实测):150 条慢订阅者 + 30 msg/s 广播,RSS 峰值可到 2 GB;
|
|
65
|
+
* 每条连接的上限只是 1 MB,但 300 条 × 1 MB 就是 GB 级。与其把上限一路压到底,
|
|
66
|
+
* 更合理的是:**明显落后的连接直接跳过非关键事件**(keepalive 与 connected 必须保留,
|
|
67
|
+
* 否则 liveness 探测和重连握手会失效),并计数 `sse_dropped_degraded`。
|
|
68
|
+
*
|
|
69
|
+
* 这不是“丢消息”的新语义:SSE 本来就是 best-effort,客户端重连后会拉一次快照;
|
|
70
|
+
* 真正的问题是把“慢消费者”变成“Hub 的内存账”。 */
|
|
71
|
+
const COALESCE_AT_BYTES = envNum("ANET_SSE_COALESCE_AT_BYTES", 65_536);
|
|
72
|
+
|
|
73
|
+
/** #2363 —— 所有广播共用同一个 TextEncoder:编码结果只读、不跨事件复用同一 Uint8Array。 */
|
|
74
|
+
const SHARED_ENCODER = new TextEncoder();
|
|
75
|
+
|
|
76
|
+
/** 被跳过的事件计数(进程内累计,供 /health 与测试读取)。 */
|
|
77
|
+
let droppedDegraded = 0;
|
|
78
|
+
export function sseDegradedDropCount(): number { return droppedDegraded; }
|
|
79
|
+
|
|
62
80
|
/** How long `desiredSize` can stay negative (i.e. consumer behind HWM)
|
|
63
81
|
* before we declare the client dead. 60s by default — past 2× the
|
|
64
82
|
* keepalive interval, so a live-but-slow consumer wouldn't trip it. */
|
|
@@ -200,7 +218,7 @@ function closeClient(client: SSEClient, reason: string): void {
|
|
|
200
218
|
/** Best-effort enqueue with backpressure guard. Returns:
|
|
201
219
|
* - "ok" → bytes accepted, queue healthy
|
|
202
220
|
* - "dead" → client closed; caller must remove from map */
|
|
203
|
-
function tryEnqueueBytes(client: SSEClient, bytes: Uint8Array): "ok" | "dead" {
|
|
221
|
+
function tryEnqueueBytes(client: SSEClient, bytes: Uint8Array, opts: { critical?: boolean } = {}): "ok" | "dead" {
|
|
204
222
|
if (client.closed) return "dead";
|
|
205
223
|
|
|
206
224
|
const ctrl = client.controller;
|
|
@@ -219,6 +237,14 @@ function tryEnqueueBytes(client: SSEClient, bytes: Uint8Array): "ok" | "dead" {
|
|
|
219
237
|
return "dead";
|
|
220
238
|
}
|
|
221
239
|
|
|
240
|
+
// #2363 —— 落后超过 COALESCE_AT_BYTES 且不是关键帧:跳过,不排队、也不关连接
|
|
241
|
+
// (上限之上的仍然按硬顶关闭,见上一段)。这样“慢消费者”不再把内存账单放大到
|
|
242
|
+
// 连接数 × 单连接上限,同时保留原有“彻底读不动就断开回收”的语义。
|
|
243
|
+
if (!opts.critical && desired < -COALESCE_AT_BYTES) {
|
|
244
|
+
droppedDegraded += 1;
|
|
245
|
+
return "ok";
|
|
246
|
+
}
|
|
247
|
+
|
|
222
248
|
// Track stuck-since-when for the soft timeout below.
|
|
223
249
|
if (desired < 0) {
|
|
224
250
|
if (client.stuckSince === null) {
|
|
@@ -459,6 +485,7 @@ function createStreamForKey(
|
|
|
459
485
|
tryEnqueueBytes(
|
|
460
486
|
client,
|
|
461
487
|
encoder.encode(`data: ${JSON.stringify(initialEvent)}\n\n`),
|
|
488
|
+
{ critical: true },
|
|
462
489
|
);
|
|
463
490
|
|
|
464
491
|
// Periodic keepalive doubles as a half-open probe — if the
|
|
@@ -469,7 +496,7 @@ function createStreamForKey(
|
|
|
469
496
|
if (client.keepaliveTimer) clearInterval(client.keepaliveTimer);
|
|
470
497
|
return;
|
|
471
498
|
}
|
|
472
|
-
const result = tryEnqueueBytes(client, encoder.encode(`: keepalive\n\n`));
|
|
499
|
+
const result = tryEnqueueBytes(client, encoder.encode(`: keepalive\n\n`), { critical: true });
|
|
473
500
|
if (result === "ok" && isUserStreamClient(client)) touchUserSeen(client.subscriberUserId!);
|
|
474
501
|
if (result === "dead") {
|
|
475
502
|
// closeClient already cleared the timer; just make sure we
|
|
@@ -580,6 +607,9 @@ export function pushEvent(sessionName: string, event: Record<string, unknown>, n
|
|
|
580
607
|
if (!arr || arr.length === 0) return;
|
|
581
608
|
|
|
582
609
|
const data = `data: ${JSON.stringify(event)}\n\n`;
|
|
610
|
+
// #2363 —— 一次编码,所有订阅者共用同一份字节:150 条连接 × 每秒几十条事件时,
|
|
611
|
+
// 每连接重新 encode 一次会产生大量短命 Uint8Array(实测 150×30/s 时 RSS 峰值 2 GB)。
|
|
612
|
+
const bytes = SHARED_ENCODER.encode(data);
|
|
583
613
|
let needPrune = false;
|
|
584
614
|
|
|
585
615
|
for (const c of arr) {
|
|
@@ -587,7 +617,7 @@ export function pushEvent(sessionName: string, event: Record<string, unknown>, n
|
|
|
587
617
|
needPrune = true;
|
|
588
618
|
continue;
|
|
589
619
|
}
|
|
590
|
-
const result = tryEnqueueBytes(c,
|
|
620
|
+
const result = tryEnqueueBytes(c, bytes);
|
|
591
621
|
if (result === "dead") needPrune = true;
|
|
592
622
|
}
|
|
593
623
|
|
|
@@ -614,6 +644,7 @@ export function pushNetworkObserverEvent(
|
|
|
614
644
|
if (!arr || arr.length === 0) return;
|
|
615
645
|
|
|
616
646
|
const data = `data: ${JSON.stringify({ ...event, network_id: networkId, scope: "network" })}\n\n`;
|
|
647
|
+
const bytes = SHARED_ENCODER.encode(data); // #2363:一次编码,所有 observer 共用
|
|
617
648
|
let needPrune = false;
|
|
618
649
|
|
|
619
650
|
for (const c of arr) {
|
|
@@ -626,7 +657,7 @@ export function pushNetworkObserverEvent(
|
|
|
626
657
|
try { pass = c.eventFilter(event); } catch { pass = false; }
|
|
627
658
|
if (!pass) continue;
|
|
628
659
|
}
|
|
629
|
-
const result = tryEnqueueBytes(c,
|
|
660
|
+
const result = tryEnqueueBytes(c, bytes);
|
|
630
661
|
if (result === "dead") needPrune = true;
|
|
631
662
|
}
|
|
632
663
|
|
|
@@ -661,6 +692,7 @@ export function pushUserEvent(
|
|
|
661
692
|
if (!arr || arr.length === 0) return;
|
|
662
693
|
|
|
663
694
|
const data = `data: ${JSON.stringify({ ...event, network_id: networkId, user_id: userId, scope: "user" })}\n\n`;
|
|
695
|
+
const bytes = SHARED_ENCODER.encode(data); // #2363 同 pushEvent:一次编码共用
|
|
664
696
|
let needPrune = false;
|
|
665
697
|
|
|
666
698
|
for (const c of arr) {
|
|
@@ -668,7 +700,7 @@ export function pushUserEvent(
|
|
|
668
700
|
needPrune = true;
|
|
669
701
|
continue;
|
|
670
702
|
}
|
|
671
|
-
const result = tryEnqueueBytes(c,
|
|
703
|
+
const result = tryEnqueueBytes(c, bytes);
|
|
672
704
|
if (result === "dead") needPrune = true;
|
|
673
705
|
}
|
|
674
706
|
|
|
@@ -797,6 +829,8 @@ export const __SSE_INTERNALS_FOR_TEST = {
|
|
|
797
829
|
sweepLiveness,
|
|
798
830
|
closeClient,
|
|
799
831
|
MAX_QUEUE_BACKPRESSURE_BYTES,
|
|
832
|
+
COALESCE_AT_BYTES,
|
|
833
|
+
degradedDropCount: sseDegradedDropCount,
|
|
800
834
|
STUCK_CLOSE_MS,
|
|
801
835
|
KEEPALIVE_MS,
|
|
802
836
|
LIVENESS_SWEEP_MS,
|
|
@@ -268,6 +268,12 @@ describe("#1279 authoritative scoped requirement-event wake over HTTP/MCP", () =
|
|
|
268
268
|
expect({exit:code,stderr:w.stderr().trim().slice(0,800)}).toEqual({exit:0,stderr:""});
|
|
269
269
|
results.push(JSON.parse(output.trim().split("\n").at(-1)!).wake);}
|
|
270
270
|
expect(results.sort()).toEqual([false,true]);expect(state(scheduleId).revision).toBe(before.revision+1);expect(poll(scheduleId)).toBe(false);
|
|
271
|
-
|
|
271
|
+
// #2198:显式预算。这条用例要起 2 个 Node 子进程、各自打开同一个磁盘 SQLite,
|
|
272
|
+
// 再用 /tmp 上的 gate 文件对齐起跑。默认 5s 在机器繁忙时会被打满,而**超时与断言
|
|
273
|
+
// 失败在报告里长得一样**(都只是一行 (fail)):实测 8 路并发 + 3× CPU 超订时,
|
|
274
|
+
// 11 轮里有 2 轮红,但每次红的都是 `timed out after 5000ms`,而 trues 恒为 1、
|
|
275
|
+
// revision 恒只 +1 —— 语义一直是对的,是预算不够。
|
|
276
|
+
// 这里只放宽**时间**,不放宽任何断言(仍然 [false,true]、仍然 revision+1、仍然 poll false)。
|
|
277
|
+
}, 30_000);
|
|
272
278
|
|
|
273
279
|
});
|
package/src/server.ts
CHANGED
|
@@ -5250,10 +5250,31 @@ return Bun.serve({
|
|
|
5250
5250
|
const management = lifecycleUser ? lifecycleProjections(restScope) : null;
|
|
5251
5251
|
const rows = rawRows.map(r => {
|
|
5252
5252
|
let role: string | null = null;
|
|
5253
|
+
// #2293(admin 2026-10-25)—— 节点自报的 OpenCode V2 模型 id 清单(`provider/model` 字符串数组)。
|
|
5254
|
+
// 客户端在**没有 daemon 绑定**时用它当建议列表。读法同 role:快照可能是文本或对象;
|
|
5255
|
+
// 解析失败/形状不对 = 这一格没有(null),不影响整行。
|
|
5256
|
+
let opencodeCatalog: string[] | null = null;
|
|
5257
|
+
let opencodeGeneration: "v1" | "v2" | null = null;
|
|
5258
|
+
let opencodeMode: string | null = null;
|
|
5259
|
+
let opencodeServer: string | null = null;
|
|
5253
5260
|
const snap = r.config_snapshot;
|
|
5254
5261
|
if (snap) {
|
|
5255
|
-
|
|
5256
|
-
|
|
5262
|
+
let parsed: any = null;
|
|
5263
|
+
try { parsed = typeof snap === "string" ? JSON.parse(snap) : snap; }
|
|
5264
|
+
catch { parsed = null; }
|
|
5265
|
+
role = parsed?.role ?? null;
|
|
5266
|
+
const raw = parsed?.opencode_catalog;
|
|
5267
|
+
if (Array.isArray(raw) && raw.every((m: unknown) => typeof m === "string" && m.length <= 400)) {
|
|
5268
|
+
opencodeCatalog = raw.slice(0, 400);
|
|
5269
|
+
}
|
|
5270
|
+
// #2293 —— 客户端「Runtime · V2」和「OpenCode Server」两行要的正是快照里这三格标量。
|
|
5271
|
+
// PR #1092 之前从 `node.config_snapshot` 读,但这个端点**刻意不带整个快照**
|
|
5272
|
+
// (见上方 #312 注释)⇒ 真机上两行是空的/整行不出现。这里只放三个标量出来,
|
|
5273
|
+
// 仍不暴露 flags 里的其它内容,更不暴露任何凭据。
|
|
5274
|
+
const flags = parsed?.flags;
|
|
5275
|
+
if (flags?.opencodeGeneration === "v1" || flags?.opencodeGeneration === "v2") opencodeGeneration = flags.opencodeGeneration;
|
|
5276
|
+
if (typeof flags?.opencodeMode === "string" && flags.opencodeMode.length <= 32) opencodeMode = flags.opencodeMode;
|
|
5277
|
+
if (typeof parsed?.opencode_server === "string" && parsed.opencode_server.length <= 200) opencodeServer = parsed.opencode_server;
|
|
5257
5278
|
}
|
|
5258
5279
|
const { config_snapshot, owner_user_id, ...rest } = r;
|
|
5259
5280
|
// `tags` is stored as JSON text; hand the dashboard a real array so
|
|
@@ -5269,6 +5290,10 @@ return Bun.serve({
|
|
|
5269
5290
|
tags: parseStoredTags(r.tags),
|
|
5270
5291
|
attrs_revision: Number(r.attrs_revision ?? 0),
|
|
5271
5292
|
role,
|
|
5293
|
+
opencode_catalog: opencodeCatalog,
|
|
5294
|
+
opencode_generation: opencodeGeneration,
|
|
5295
|
+
opencode_mode: opencodeMode,
|
|
5296
|
+
opencode_server: opencodeServer,
|
|
5272
5297
|
lifecycle_controllable,
|
|
5273
5298
|
lifecycle_daemon_node_id: controllable.get(r.node_id) ?? null,
|
|
5274
5299
|
...(management ? (management.get(r.node_id) ?? { managed: "none", adoption: null }) : {}),
|
package/src/tools.ts
CHANGED
|
@@ -921,7 +921,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
921
921
|
revision: z.number().int().min(0).optional(),
|
|
922
922
|
}).strict()).max(64),
|
|
923
923
|
error: z.enum(["invalid_manifest", "unsafe_manifest", "read_failed"]).optional(),
|
|
924
|
-
}).strict().optional().describe("
|
|
924
|
+
}).strict().optional().describe("Node-reported schedules; never includes host paths or commands"),
|
|
925
925
|
// RFC-024 B6 — masked snapshot of the node's effective config
|
|
926
926
|
// (model + 6 dashboard-editable flags). Secrets ARE NOT in this
|
|
927
927
|
// shape (env._envRef stays on host); the dashboard reads this
|
|
@@ -934,6 +934,16 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
934
934
|
model: z.string().max(200).optional().nullable(),
|
|
935
935
|
flags: z.record(z.string(), z.unknown()).optional(),
|
|
936
936
|
config_revision: z.number().int().min(0).optional(),
|
|
937
|
+
// #2293(admin 2026-10-25)—— 节点自己上报的 OpenCode V2 模型 id 清单
|
|
938
|
+
// (`provider/model`)。没有 daemon 绑定的节点靠这一格给客户端可选列表,
|
|
939
|
+
// 否则 admin 只能手填。只收 id:名字/设置/headers 可能带凭据,一律不收。
|
|
940
|
+
// 排成**一个字符串数组**而不是对象,是为了不抬 tools/list 的字节上限
|
|
941
|
+
// (约定见 tool-audience-http.test.ts:加字段先砍描述或合并,别改那三个数)。
|
|
942
|
+
// `.catch(undefined)`:形状不对只丢这一格,绝不拒掉整份心跳。
|
|
943
|
+
opencode_catalog: z.array(z.string()).max(400).optional().catch(undefined),
|
|
944
|
+
// #2293 —— 这台机器上 OpenCode serve 的 loopback 端点(`http://127.0.0.1:PORT`)。
|
|
945
|
+
// 客户端「OpenCode Server」行此前只能写「端口未上报」。只收 loopback URL,不收密码。
|
|
946
|
+
opencode_server: z.string().max(200).optional().catch(undefined),
|
|
937
947
|
config_update_capable: z.boolean().optional(),
|
|
938
948
|
peer_reply_inbox_capable: z.literal(true).optional(),
|
|
939
949
|
side_thread_capability: z.object({
|
|
@@ -2002,8 +2012,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2002
2012
|
from_session: z.string().max(200).optional(),
|
|
2003
2013
|
ttl_seconds: z.number().min(1).max(86400).optional().describe("Task TTL in seconds (default: 3600)"),
|
|
2004
2014
|
network_id: z.string().max(200).optional().describe("Network scope"),
|
|
2005
|
-
parent_task_id: z.string().max(200).optional().describe("Parent task this dispatch is on behalf of.
|
|
2006
|
-
meta: z.any().optional().describe("Optional
|
|
2015
|
+
parent_task_id: z.string().max(200).optional().describe("Parent task this dispatch is on behalf of. The child's reply is auto-chained to the parent's originator, so the user sees the result even if the intermediate session ends."),
|
|
2016
|
+
meta: z.any().optional().describe("Optional task metadata, e.g. { attachments: [{ type, path, url, mime, name, size }] }."),
|
|
2007
2017
|
force: z.boolean().optional().describe("dispatch even if the target is degraded (user tokens only)"),
|
|
2008
2018
|
},
|
|
2009
2019
|
async ({ alias, task, priority, context, from_session: _fromIn, ttl_seconds, network_id: netId, parent_task_id: parentIn, meta, force }) => {
|
|
@@ -2367,7 +2377,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2367
2377
|
// it always sent alias=undefined → -32602 "expected string, received
|
|
2368
2378
|
// undefined at alias". The target was already knowable from the task,
|
|
2369
2379
|
// so requiring the caller to pass it was a redundant-param trap.
|
|
2370
|
-
alias: z.string().min(1).max(200).optional().describe("Target
|
|
2380
|
+
alias: z.string().min(1).max(200).optional().describe("Target alias — optional; defaults to in_reply_to's sender"),
|
|
2371
2381
|
text: z.string().min(1).max(10000).describe("Reply content"),
|
|
2372
2382
|
in_reply_to: z.string().max(200).optional().describe("Original task/message ID"),
|
|
2373
2383
|
status: z.enum(["replied", "failed", "cancelled"]).optional().default("replied").describe("Task outcome"),
|
|
@@ -2378,7 +2388,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2378
2388
|
// `attachments` before this schema entry existed would see `ok:true`
|
|
2379
2389
|
// and never learn the attachments were dropped. Validated by
|
|
2380
2390
|
// validateAttachments (uploads.ts) — the same helper the REST path uses.
|
|
2381
|
-
attachments: z.any().optional().describe("Attachments, like send_task's
|
|
2391
|
+
attachments: z.any().optional().describe("Attachments, like send_task's: [{type:'file', file_id, name?, mime?, size?}]."),
|
|
2382
2392
|
// #507 — optional structured metadata (parity with send_task L837). If
|
|
2383
2393
|
// both `attachments` (top-level) and `meta.attachments` are supplied,
|
|
2384
2394
|
// top-level wins (same rule as REST /api/task L2101).
|
|
@@ -4111,7 +4121,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4111
4121
|
lines: z.number().int().min(1).max(LOGS_MAX_LINES).optional().describe("How many of the newest matching lines (default 500, max 2000)."),
|
|
4112
4122
|
level: z.enum(["info", "warn", "error"]).optional().describe("Only lines of exactly this level."),
|
|
4113
4123
|
grep: z.string().min(1).max(LOGS_GREP_MAX).optional().describe("Case-insensitive substring, matched after redaction."),
|
|
4114
|
-
since_ts: z.number().int().min(0).optional().describe("Only lines at
|
|
4124
|
+
since_ts: z.number().int().min(0).optional().describe("Only lines at/after this epoch-ms timestamp (follow mode; client de-dupes by line key)."),
|
|
4115
4125
|
network_id: z.string().max(200).optional(),
|
|
4116
4126
|
},
|
|
4117
4127
|
async ({ node_id, child_node_id, alias, lines, level, grep, since_ts, network_id }) =>
|
|
@@ -6776,7 +6786,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
6776
6786
|
name: z.string().min(1).max(80).optional(),
|
|
6777
6787
|
priority: z.enum(["high", "normal", "low", "lowest"]).optional().describe("high = P0 最高, normal = P1 普通, low = P2 低, lowest = P3 极低"),
|
|
6778
6788
|
column: z.enum(["pool", "doing", "done", "abandoned"]).optional(),
|
|
6779
|
-
due: z.string().max(40).optional().describe("YYYY-MM-DD (all day) or ISO 8601
|
|
6789
|
+
due: z.string().max(40).optional().describe("YYYY-MM-DD (all day) or ISO 8601 (stored as UTC seconds); \"\" clears"),
|
|
6780
6790
|
start: z.string().max(40).optional().describe("start date (Gantt): same shapes as due; \"\" clears"),
|
|
6781
6791
|
description: z.string().max(20_000).optional().describe("markdown"),
|
|
6782
6792
|
checklist: z.array(reqChecklistItem).max(100).optional().describe("replaces the whole list"),
|