@sleep2agi/commhub-server 0.9.0-preview.44 → 0.9.0-preview.45
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/admin-networks-http.test.ts +5 -1
- package/src/api-host-supervisors-fallback.test.ts +5 -1
- package/src/auth-validate.test.ts +5 -1
- package/src/dashboard-slash-routing-http.test.ts +5 -1
- package/src/db.ts +31 -0
- package/src/external-schedule-edits-http.test.ts +5 -1
- package/src/external-schedule-edits-timestamps.test.ts +64 -0
- package/src/external-schedule-edits.ts +17 -4
- package/src/health-redaction.test.ts +5 -1
- package/src/hub-timestamp.test.ts +38 -0
- package/src/hub-timestamp.ts +27 -0
- package/src/network-name-http.test.ts +5 -1
- package/src/node-attrs.test.ts +5 -1
- package/src/node-id-alias.test.ts +41 -0
- package/src/observer-avatar-http.test.ts +5 -1
- package/src/rest-explicit-columns-http.test.ts +5 -1
- package/src/rules-file-transport.test.ts +235 -0
- package/src/scheduled-tasks-http.test.ts +5 -1
- package/src/server.ts +8 -3
- package/src/side-thread-http-integration.test.ts +5 -1
- package/src/skillhub-http.test.ts +5 -1
- package/src/task-auth-origin-http.test.ts +5 -1
- package/src/task-consumption.test.ts +1 -1
- package/src/task-diagnostic-http.test.ts +5 -1
- package/src/task-lifecycle-watcher.test.ts +5 -1
- package/src/task-network-resolution-http.test.ts +5 -1
- package/src/tasks-pagination-http.test.ts +5 -1
- package/src/tools.ts +282 -13
- package/src/uploads-http.test.ts +5 -1
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.45",
|
|
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",
|
|
@@ -25,6 +25,10 @@ async function listNetworks(token: string): Promise<any[]> {
|
|
|
25
25
|
return body.networks;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
// 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
|
|
29
|
+
// 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
|
|
30
|
+
// (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
|
|
31
|
+
// 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
|
|
28
32
|
beforeAll(async () => {
|
|
29
33
|
const { db } = await import("./db.js");
|
|
30
34
|
const { createNetworkTokenForNode, register } = await import("./auth.js");
|
|
@@ -51,7 +55,7 @@ beforeAll(async () => {
|
|
|
51
55
|
const mod = await import("./server.js");
|
|
52
56
|
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
53
57
|
base = `http://127.0.0.1:${server.port}`;
|
|
54
|
-
});
|
|
58
|
+
}, 30_000);
|
|
55
59
|
|
|
56
60
|
afterAll(() => {
|
|
57
61
|
try { server?.stop(true); } catch {}
|
|
@@ -38,6 +38,10 @@ let soloNetworkId = "";
|
|
|
38
38
|
let multiNetworkA = "", multiNetworkB = "";
|
|
39
39
|
let soloDaemonAlias = "";
|
|
40
40
|
|
|
41
|
+
// 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
|
|
42
|
+
// 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
|
|
43
|
+
// (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
|
|
44
|
+
// 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
|
|
41
45
|
beforeAll(async () => {
|
|
42
46
|
process.env.HOST = "127.0.0.1";
|
|
43
47
|
|
|
@@ -116,7 +120,7 @@ beforeAll(async () => {
|
|
|
116
120
|
server = bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
117
121
|
BASE = `http://127.0.0.1:${server.port}`;
|
|
118
122
|
await new Promise((r) => setTimeout(r, 100));
|
|
119
|
-
});
|
|
123
|
+
}, 30_000);
|
|
120
124
|
|
|
121
125
|
// Insert a minimal `nodes` row + `api_tokens` row so the daemon shows up
|
|
122
126
|
// in /api/host-supervisors' JOIN-then-role-filter query (index.ts
|
|
@@ -10,13 +10,17 @@ import { register } from "./auth.js";
|
|
|
10
10
|
|
|
11
11
|
// First user gets RELAXED rules (admin bootstrap allows 4-char "anethub" etc.)
|
|
12
12
|
// To test full-strength rules, seed a dummy admin first.
|
|
13
|
+
// 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
|
|
14
|
+
// 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
|
|
15
|
+
// (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
|
|
16
|
+
// 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
|
|
13
17
|
beforeAll(() => {
|
|
14
18
|
// Idempotent: if DB file already has users (from a previous run on the
|
|
15
19
|
// same temp path), this errors with "username already taken" → that's
|
|
16
20
|
// fine, the goal is just "ensure at least one user exists so subsequent
|
|
17
21
|
// registers hit the strict path".
|
|
18
22
|
register("_seed_admin", "BootstrapPw1", undefined, "seed");
|
|
19
|
-
});
|
|
23
|
+
}, 30_000);
|
|
20
24
|
|
|
21
25
|
describe("register — username rules", () => {
|
|
22
26
|
it("rejects empty username", () => {
|
|
@@ -18,6 +18,10 @@ let nodeToken = "";
|
|
|
18
18
|
let networkId = "";
|
|
19
19
|
let requestSequence = 0;
|
|
20
20
|
|
|
21
|
+
// 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
|
|
22
|
+
// 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
|
|
23
|
+
// (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
|
|
24
|
+
// 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
|
|
21
25
|
beforeAll(async () => {
|
|
22
26
|
process.env.COMMHUB_DB = process.env.COMMHUB_DB || join(PRIVATE_DB_DIR, "hub.db");
|
|
23
27
|
const username = `dashboard_slash_${Date.now()}`;
|
|
@@ -42,7 +46,7 @@ beforeAll(async () => {
|
|
|
42
46
|
const mod: any = await import("./server.js");
|
|
43
47
|
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
44
48
|
base = `http://127.0.0.1:${server.port}`;
|
|
45
|
-
});
|
|
49
|
+
}, 30_000);
|
|
46
50
|
|
|
47
51
|
afterAll(() => {
|
|
48
52
|
try { server?.stop?.(true); } catch {}
|
package/src/db.ts
CHANGED
|
@@ -540,6 +540,37 @@ try {
|
|
|
540
540
|
console.warn(`[commhub] partial unique index uniq_ncu_node_inflight skipped: ${e?.message || e}`);
|
|
541
541
|
}
|
|
542
542
|
|
|
543
|
+
// ── app#225: 节点规则文件(AGENTS.md / CLAUDE.md)远程读写请求 ──
|
|
544
|
+
//
|
|
545
|
+
// 形状照抄 node_config_updates(上面):客户端落一行 + 门铃,节点拉走做完回报,
|
|
546
|
+
// 客户端轮询结果。network_id 同样在插入时从 nodes 反规范化。
|
|
547
|
+
//
|
|
548
|
+
// 🔴 这张表**故意没有路径列**:文件名由节点按自己的 RUNTIME 决定
|
|
549
|
+
// (claude → CLAUDE.md,其余 → AGENTS.md),目录固定是节点进程的 cwd。
|
|
550
|
+
// hub 侧只存 op + 内容,客户端连文件名都指定不了(#225 验收第 5 条)。
|
|
551
|
+
// file_name 是节点**回报**的实际文件名,只用于显示。
|
|
552
|
+
db.exec(`
|
|
553
|
+
CREATE TABLE IF NOT EXISTS node_rules_requests (
|
|
554
|
+
request_id TEXT PRIMARY KEY,
|
|
555
|
+
node_id TEXT NOT NULL,
|
|
556
|
+
network_id TEXT NOT NULL,
|
|
557
|
+
op TEXT NOT NULL,
|
|
558
|
+
content TEXT,
|
|
559
|
+
status TEXT NOT NULL,
|
|
560
|
+
file_name TEXT,
|
|
561
|
+
file_exists INTEGER,
|
|
562
|
+
result_content TEXT,
|
|
563
|
+
error TEXT,
|
|
564
|
+
created_at INTEGER NOT NULL,
|
|
565
|
+
created_by_token TEXT NOT NULL,
|
|
566
|
+
pulled_at INTEGER,
|
|
567
|
+
acked_at INTEGER
|
|
568
|
+
);
|
|
569
|
+
|
|
570
|
+
CREATE INDEX IF NOT EXISTS idx_nrr_node_status ON node_rules_requests(node_id, status);
|
|
571
|
+
CREATE INDEX IF NOT EXISTS idx_nrr_network ON node_rules_requests(network_id);
|
|
572
|
+
`);
|
|
573
|
+
|
|
543
574
|
// ── V3: networks table ──
|
|
544
575
|
db.exec(`
|
|
545
576
|
CREATE TABLE IF NOT EXISTS networks (
|
|
@@ -51,6 +51,10 @@ function snapshot(revision: number, extra: Record<string, unknown> = {}) {
|
|
|
51
51
|
});
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
// 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
|
|
55
|
+
// 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
|
|
56
|
+
// (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
|
|
57
|
+
// 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
|
|
54
58
|
beforeAll(async () => {
|
|
55
59
|
const admin = register(`b4_admin_${stamp}`, "B4Admin123!", undefined, "B4 Admin");
|
|
56
60
|
const owner = register(`b4_owner_${stamp}`, "B4Owner123!", undefined, "B4 Owner");
|
|
@@ -83,7 +87,7 @@ beforeAll(async () => {
|
|
|
83
87
|
const mod: any = await import("./server.js");
|
|
84
88
|
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
85
89
|
base = `http://127.0.0.1:${server.port}`;
|
|
86
|
-
});
|
|
90
|
+
}, 30_000);
|
|
87
91
|
|
|
88
92
|
afterAll(() => {
|
|
89
93
|
try { server?.stop?.(true); } catch {}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { parseHubTimestamp } from "./hub-timestamp";
|
|
3
|
+
|
|
4
|
+
// #1650 —— external-schedule-edits 的 publicEdit() 原先对四个时间列直接
|
|
5
|
+
// `new Date(row.x).toISOString()`。这些列是 SQLite `datetime('now')` 家族:
|
|
6
|
+
// **UTC 但不带时区标记**,而 JS 对「有时间、无偏移」的串按**本机时区**解析,
|
|
7
|
+
// 且不报错 ⇒ 发给 API 消费方的是一个错的时刻。
|
|
8
|
+
|
|
9
|
+
function hubIso(raw: unknown): string | null {
|
|
10
|
+
const ms = parseHubTimestamp(raw);
|
|
11
|
+
return ms === null ? null : new Date(ms).toISOString();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("#1650 hub 时间戳按 UTC 解析", () => {
|
|
15
|
+
it("🔴 无时区标记的串按 UTC 解析,不按本机时区", () => {
|
|
16
|
+
expect(hubIso("2026-08-30 21:04:11")).toBe("2026-08-30T21:04:11.000Z");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("🔴 反向见证:旧写法在非 UTC 机器上会给出不同的结果", () => {
|
|
20
|
+
const old = new Date("2026-08-30 21:04:11").toISOString();
|
|
21
|
+
const now = hubIso("2026-08-30 21:04:11")!;
|
|
22
|
+
// 只有当本机恰好是 UTC 时两者才相同;此处断言的是「新写法等于 UTC 语义」,
|
|
23
|
+
// 而 old 的值取决于运行机器 —— 所以只在两者不同时才有对比意义。
|
|
24
|
+
const offsetMin = new Date("2026-08-30T21:04:11Z").getTimezoneOffset();
|
|
25
|
+
if (offsetMin !== 0) expect(old).not.toBe(now);
|
|
26
|
+
expect(now).toBe("2026-08-30T21:04:11.000Z");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("带 Z 的串保持原意", () => {
|
|
30
|
+
expect(hubIso("2026-08-30T21:04:11Z")).toBe("2026-08-30T21:04:11.000Z");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("🔴 垃圾串返回 null,而不是抛 RangeError(旧写法会把接口打成 500)", () => {
|
|
34
|
+
expect(hubIso("not-a-date")).toBeNull();
|
|
35
|
+
expect(hubIso(null)).toBeNull();
|
|
36
|
+
expect(hubIso(undefined)).toBeNull();
|
|
37
|
+
expect(() => new Date("not-a-date").toISOString()).toThrow();
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// 🔴 上面四条测的是 helper 的语义。真正要钉住的是**那个文件用了它**——
|
|
42
|
+
// 否则 helper 再对,publicEdit 里改回 `new Date(row.x)` 也照样全绿。
|
|
43
|
+
import { readFileSync } from "node:fs";
|
|
44
|
+
import { join } from "node:path";
|
|
45
|
+
|
|
46
|
+
describe("#1650 publicEdit 必须走 hubIso,不能有裸 new Date(row.*)", () => {
|
|
47
|
+
const SRC = readFileSync(join(import.meta.dir, "external-schedule-edits.ts"), "utf-8");
|
|
48
|
+
const CODE = SRC.split("\n").filter(l => !/^\s*(\/\/|\*|\/\*)/.test(l)).join("\n");
|
|
49
|
+
|
|
50
|
+
it("🔴 源码里不再有裸 new Date(row.…)", () => {
|
|
51
|
+
expect(/new Date\(row\./.test(CODE)).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("四个时间列都走 hubIso", () => {
|
|
55
|
+
for (const f of ["expires_at", "created_at", "delivered_at", "acked_at"]) {
|
|
56
|
+
expect(CODE).toContain(`${f}: hubIso(row.${f})`);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("hubIso 复用 parseHubTimestamp,没有第二套判据", () => {
|
|
61
|
+
expect(CODE).toContain('from "./hub-timestamp"');
|
|
62
|
+
expect(CODE).toContain("parseHubTimestamp(raw)");
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -2,6 +2,7 @@ import { db } from "./db.js";
|
|
|
2
2
|
import { getUserNetworkRole } from "./auth.js";
|
|
3
3
|
import { pushEvent } from "./push.js";
|
|
4
4
|
import { parseExternalSchedulePatch, type ExternalSchedulePatch } from "./shared/external-schedule-contract.js";
|
|
5
|
+
import { parseHubTimestamp } from "./hub-timestamp";
|
|
5
6
|
|
|
6
7
|
type Auth = {
|
|
7
8
|
userId: string;
|
|
@@ -109,6 +110,18 @@ function reportedSchedule(node: NodeRow, scheduleId: string): Record<string, unk
|
|
|
109
110
|
}
|
|
110
111
|
}
|
|
111
112
|
|
|
113
|
+
// 🔴 #1650 —— 这些列是 SQLite `datetime('now')` 家族:**UTC 但不带时区标记**。
|
|
114
|
+
// `new Date("2026-08-30 21:04:11")` 会按**本机时区**解析(ES 规范:带时间且无偏移
|
|
115
|
+
// 的形式视为本地时间),而且不报错 —— 于是发给 API 消费方的是一个错的时刻,
|
|
116
|
+
// 误差就是这台机器的 offset。
|
|
117
|
+
// 另外原写法在字段是垃圾串时 `new Date(x).toISOString()` 会**抛 RangeError**,
|
|
118
|
+
// 足以把这个接口打成 500;`parseHubTimestamp` 解析失败返回 null,这里也返回 null。
|
|
119
|
+
// 复用 server.ts:1010 已在用的那个 helper,不自造第二套判据。
|
|
120
|
+
function hubIso(raw: unknown): string | null {
|
|
121
|
+
const ms = parseHubTimestamp(raw);
|
|
122
|
+
return ms === null ? null : new Date(ms).toISOString();
|
|
123
|
+
}
|
|
124
|
+
|
|
112
125
|
function publicEdit(row: EditRow): Record<string, unknown> {
|
|
113
126
|
let patch: ExternalSchedulePatch = {};
|
|
114
127
|
try { patch = JSON.parse(row.patch_json); } catch {}
|
|
@@ -119,10 +132,10 @@ function publicEdit(row: EditRow): Record<string, unknown> {
|
|
|
119
132
|
base_revision: row.base_revision,
|
|
120
133
|
patch,
|
|
121
134
|
status: row.status,
|
|
122
|
-
expires_at:
|
|
123
|
-
created_at:
|
|
124
|
-
delivered_at:
|
|
125
|
-
acked_at:
|
|
135
|
+
expires_at: hubIso(row.expires_at),
|
|
136
|
+
created_at: hubIso(row.created_at),
|
|
137
|
+
delivered_at: hubIso(row.delivered_at),
|
|
138
|
+
acked_at: hubIso(row.acked_at),
|
|
126
139
|
result_revision: row.result_revision,
|
|
127
140
|
error_code: row.error_code,
|
|
128
141
|
};
|
|
@@ -30,6 +30,10 @@ let userNetworkId = "";
|
|
|
30
30
|
let userName = "";
|
|
31
31
|
let adminToken = "";
|
|
32
32
|
|
|
33
|
+
// 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
|
|
34
|
+
// 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
|
|
35
|
+
// (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
|
|
36
|
+
// 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
|
|
33
37
|
beforeAll(async () => {
|
|
34
38
|
process.env.COMMHUB_DB = process.env.COMMHUB_DB || SERVER_DB;
|
|
35
39
|
process.env.HOST = "127.0.0.1";
|
|
@@ -72,7 +76,7 @@ beforeAll(async () => {
|
|
|
72
76
|
if (res.status !== 200) throw new Error("SSE subscribe failed: " + res.status);
|
|
73
77
|
sseReader = res.body!.getReader();
|
|
74
78
|
await sseReader.read(); // consume the connected frame → registration done
|
|
75
|
-
});
|
|
79
|
+
}, 30_000);
|
|
76
80
|
|
|
77
81
|
afterAll(() => {
|
|
78
82
|
try { sseReader?.cancel(); } catch {}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { parseHubTimestamp } from "./hub-timestamp";
|
|
3
|
+
|
|
4
|
+
const RAW = "2026-08-30 21:11:24"; // sessions.last_seen_at 的真实取值
|
|
5
|
+
const TRUE_UTC = Date.UTC(2026, 7, 30, 21, 11, 24);
|
|
6
|
+
|
|
7
|
+
describe("parseHubTimestamp (#1650)", () => {
|
|
8
|
+
test("🔴 非 UTC 时区下也必须解出同一个 UTC 时刻", () => {
|
|
9
|
+
// `bun test` 把测试进程时区固定成 UTC(getTimezoneOffset() === 0)。
|
|
10
|
+
// 在 UTC 下,「补 Z」和「不补 Z」解析出**同一个数** —— 这个函数存在的那个
|
|
11
|
+
// 缺陷在测试进程里**结构上不可见**。所以这一条必须开 TZ≠UTC 的子进程。
|
|
12
|
+
// 实测:去掉补 Z,本文件其余各条**全绿**。
|
|
13
|
+
const child = `import { parseHubTimestamp } from ${JSON.stringify(import.meta.dir + "/hub-timestamp.ts")};
|
|
14
|
+
process.stdout.write(new Date().getTimezoneOffset() + "," + parseHubTimestamp(${JSON.stringify(RAW)}));`;
|
|
15
|
+
const r = Bun.spawnSync(["bun", "-e", child], { env: { ...process.env, TZ: "Asia/Shanghai" } });
|
|
16
|
+
const [offset, parsed] = r.stdout.toString().trim().split(",");
|
|
17
|
+
// 🔴 先断言控制生效:子进程真的不在 UTC。否则 TZ 被忽略时这条又变成空测试。
|
|
18
|
+
expect(Number(offset)).not.toBe(0);
|
|
19
|
+
expect(parsed).toBe(String(TRUE_UTC));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("已带 Z / ±HH:MM 的串不再追加时区", () => {
|
|
23
|
+
expect(parseHubTimestamp("2026-08-30T21:11:24Z")).toBe(TRUE_UTC);
|
|
24
|
+
expect(parseHubTimestamp("2026-08-30T22:11:24+01:00")).toBe(TRUE_UTC);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("拿不到就返回 null,不返回一个看起来合理的数", () => {
|
|
28
|
+
for (const bad of [undefined, null, "", " ", 0, 1788124284000, {}, [], "2026-08-30", "垃圾"]) {
|
|
29
|
+
expect(parseHubTimestamp(bad as unknown)).toBeNull();
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("🔴 INTEGER 列(epoch 毫秒)不归它管 —— 传数字必须返回 null,而不是悄悄接受", () => {
|
|
34
|
+
// external_schedule_edits.* 是 INTEGER。如果有人把本函数用在那儿,
|
|
35
|
+
// 应当立刻拿到 null 而不是一个错的时刻。
|
|
36
|
+
expect(parseHubTimestamp(TRUE_UTC)).toBeNull();
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #1650 — hub 的 TEXT 时间戳列是 SQLite `datetime('now')` 写的,
|
|
3
|
+
* 形如 `2026-08-30 21:11:24`:**UTC,但不带任何时区标记**。
|
|
4
|
+
*
|
|
5
|
+
* 🔴 `new Date(raw)` / `Date.parse(raw)` 对这种串按**本地时间**解析(ES 规范:
|
|
6
|
+
* 带时间的形式无偏移时视为本地时间),误差恰好等于主机时区偏移,**而且不报错**:
|
|
7
|
+
*
|
|
8
|
+
* TZ=UTC 误差 0h
|
|
9
|
+
* TZ=Asia/Shanghai 误差 -8h (看起来更陈旧)
|
|
10
|
+
* TZ=America/Los_Angeles 误差 +7h (看起来更新鲜 ← 朝好的一侧错)
|
|
11
|
+
*
|
|
12
|
+
* 🔴 **只对 TEXT 列用它。** 同一个字段名在不同表里可能是不同类型:
|
|
13
|
+
* sessions.last_seen_at / licenses.expires_at → TEXT(要用本函数)
|
|
14
|
+
* external_schedule_edits.expires_at 等 4 列 → INTEGER(epoch 毫秒,直接 new Date 就对)
|
|
15
|
+
* 按字段名判会判错 —— 见 #1650 里的更正。
|
|
16
|
+
*
|
|
17
|
+
* 与 agent-network 侧的同名函数是**有意的重复**:server 包不依赖 agent-network,
|
|
18
|
+
* 仓里也没有共享 lib。两边各自带测试。
|
|
19
|
+
*/
|
|
20
|
+
export function parseHubTimestamp(raw: unknown): number | null {
|
|
21
|
+
if (typeof raw !== "string") return null;
|
|
22
|
+
const t = raw.trim();
|
|
23
|
+
if (!/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}/.test(t)) return null;
|
|
24
|
+
const hasZone = /([Zz]|[+-]\d{2}:?\d{2})$/.test(t);
|
|
25
|
+
const ms = Date.parse(t.replace(" ", "T") + (hasZone ? "" : "Z"));
|
|
26
|
+
return Number.isFinite(ms) ? ms : null;
|
|
27
|
+
}
|
|
@@ -13,6 +13,10 @@ let nodeToken = "";
|
|
|
13
13
|
let networkId = "";
|
|
14
14
|
let username = "";
|
|
15
15
|
|
|
16
|
+
// 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
|
|
17
|
+
// 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
|
|
18
|
+
// (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
|
|
19
|
+
// 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
|
|
16
20
|
beforeAll(async () => {
|
|
17
21
|
process.env.COMMHUB_DB = process.env.COMMHUB_DB || join(PRIVATE_DB_DIR, "hub.db");
|
|
18
22
|
username = `network_name_${Date.now()}_${process.pid}`;
|
|
@@ -30,7 +34,7 @@ beforeAll(async () => {
|
|
|
30
34
|
const mod: any = await import("./server.js");
|
|
31
35
|
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
32
36
|
base = `http://127.0.0.1:${server.port}`;
|
|
33
|
-
});
|
|
37
|
+
}, 30_000);
|
|
34
38
|
|
|
35
39
|
afterAll(() => {
|
|
36
40
|
try { server?.stop?.(true); } catch {}
|
package/src/node-attrs.test.ts
CHANGED
|
@@ -45,6 +45,10 @@ const LEGACY_ALIAS = "attrs-legacy-node";
|
|
|
45
45
|
let token = "";
|
|
46
46
|
let server: { stop: (force?: boolean) => void } | null = null;
|
|
47
47
|
|
|
48
|
+
// 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
|
|
49
|
+
// 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
|
|
50
|
+
// (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
|
|
51
|
+
// 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
|
|
48
52
|
beforeAll(async () => {
|
|
49
53
|
db.run(`INSERT INTO users (user_id, username, password_hash, role) VALUES (?1, 'attrs-user', 'x', 'user')`, [USER]);
|
|
50
54
|
db.run(`INSERT INTO networks (network_id, network_name, owner_id) VALUES (?1, 'attrs-net', ?2)`, [NET, USER]);
|
|
@@ -58,7 +62,7 @@ beforeAll(async () => {
|
|
|
58
62
|
token = issueUserToken(USER, "attrs-test").token;
|
|
59
63
|
server = bootServer({ port: PORT, hostname: "127.0.0.1" }) as never;
|
|
60
64
|
await new Promise((r) => setTimeout(r, 120));
|
|
61
|
-
});
|
|
65
|
+
}, 30_000);
|
|
62
66
|
|
|
63
67
|
afterAll(() => {
|
|
64
68
|
try { server?.stop(true); } catch { /* already down */ }
|
|
@@ -145,3 +145,44 @@ describe("#1281 real handler path — stop_node routes both names to the same pl
|
|
|
145
145
|
expect(r.error).toBe("node_id_conflict");
|
|
146
146
|
});
|
|
147
147
|
});
|
|
148
|
+
|
|
149
|
+
describe("app#196 —— 「解析不到 daemon」的两种情况必须给不同的话", () => {
|
|
150
|
+
// 🔴 生产实测(2026-08-28):218 个节点里 207 个是 `n_…`(有人直接
|
|
151
|
+
// `anet node start` 起的),它们**根本没有 daemon**。原先这两种情况
|
|
152
|
+
// 打印同一句 `pass daemon_node_id explicitly` —— 对那 207 个来说,
|
|
153
|
+
// **这句建议的前提是假的**,照着做只会走进死路。
|
|
154
|
+
const HAND_STARTED = "n_73687f17"; // 手工起的节点,真实形状
|
|
155
|
+
const DAEMON_CHILD = "node_test_1281"; // daemon 建的子节点
|
|
156
|
+
|
|
157
|
+
test("手工起的节点 → not_daemon_managed,并且**不**建议传 daemon_node_id", async () => {
|
|
158
|
+
const r = await call("stop_node", { node_id: HAND_STARTED });
|
|
159
|
+
expect(r.ok).toBe(false);
|
|
160
|
+
expect(r.error).toBe("not_daemon_managed");
|
|
161
|
+
// 🔴 这一条是本组的核心:旧文案的那句建议**不能**再出现在这条路径上。
|
|
162
|
+
expect(r.message).not.toContain("pass daemon_node_id explicitly");
|
|
163
|
+
// 而且要告诉他真正能走的那条路。
|
|
164
|
+
expect(r.message).toContain("anet node stop");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("daemon 建的子节点(无 create-request 行) → 仍是 daemon_not_resolvable", async () => {
|
|
168
|
+
const r = await call("stop_node", { node_id: DAEMON_CHILD });
|
|
169
|
+
expect(r.ok).toBe(false);
|
|
170
|
+
expect(r.error).toBe("daemon_not_resolvable");
|
|
171
|
+
expect(r.message).toContain("pass daemon_node_id explicitly");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("delete_node 走同一条判别(两个调用点不能只改一个)", async () => {
|
|
175
|
+
const r = await call("delete_node", { node_id: HAND_STARTED, confirm_alias: "x" });
|
|
176
|
+
expect(r.ok).toBe(false);
|
|
177
|
+
expect(r.error).toBe("not_daemon_managed");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// 🔴 反向见证:两种情况必须**真的不同**。若有人把判别改成恒定一支,
|
|
181
|
+
// 上面三条里至少一条会红 —— 但那还不够明显,所以这里直接断言两者不等。
|
|
182
|
+
test("两种情况的 error 与 message 都不相同", async () => {
|
|
183
|
+
const a = await call("stop_node", { node_id: HAND_STARTED });
|
|
184
|
+
const b = await call("stop_node", { node_id: DAEMON_CHILD });
|
|
185
|
+
expect(a.error).not.toBe(b.error);
|
|
186
|
+
expect(a.message).not.toBe(b.message);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
@@ -36,6 +36,10 @@ let outsiderUserId = "";
|
|
|
36
36
|
const TARGET_ALIAS = "obs-target-agent";
|
|
37
37
|
const AVATAR_NODE_ID = "node_avatar_test_1";
|
|
38
38
|
|
|
39
|
+
// 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
|
|
40
|
+
// 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
|
|
41
|
+
// (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
|
|
42
|
+
// 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
|
|
39
43
|
beforeAll(async () => {
|
|
40
44
|
// Kept for per-file runs launched without an external COMMHUB_DB (the
|
|
41
45
|
// documented contract still is to set it externally; see db-adapter.ts).
|
|
@@ -88,7 +92,7 @@ beforeAll(async () => {
|
|
|
88
92
|
const mod: any = await import("./server.js");
|
|
89
93
|
privateServer = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
90
94
|
BASE = `http://127.0.0.1:${privateServer.port}`;
|
|
91
|
-
});
|
|
95
|
+
}, 30_000);
|
|
92
96
|
|
|
93
97
|
afterAll(() => {
|
|
94
98
|
try { privateServer?.stop?.(true); } catch {}
|
|
@@ -82,6 +82,10 @@ async function api(path: string): Promise<any> {
|
|
|
82
82
|
return response.json();
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
// 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
|
|
86
|
+
// 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
|
|
87
|
+
// (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
|
|
88
|
+
// 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
|
|
85
89
|
beforeAll(async () => {
|
|
86
90
|
({ db } = await import("./db.js"));
|
|
87
91
|
const { register } = await import("./auth.js");
|
|
@@ -135,7 +139,7 @@ beforeAll(async () => {
|
|
|
135
139
|
const mod = await import("./server.js");
|
|
136
140
|
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
137
141
|
base = `http://127.0.0.1:${server.port}`;
|
|
138
|
-
});
|
|
142
|
+
}, 30_000);
|
|
139
143
|
|
|
140
144
|
afterAll(() => {
|
|
141
145
|
try { server?.stop(true); } catch {}
|