@sleep2agi/commhub-server 0.9.0-preview.112 → 0.9.0-preview.113
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/auth-password-change-cli-tokens-http.test.ts +294 -0
- package/src/auth.ts +85 -14
- package/src/db.ts +15 -1
- package/src/requirement-due-reminders.ts +3 -3
- package/src/requirement-notify.ts +1 -1
- package/src/requirements-abandoned-http.test.ts +236 -0
- package/src/requirements-errors.ts +4 -4
- package/src/requirements-http.test.ts +1 -1
- package/src/requirements-list-slim-http.test.ts +2 -2
- package/src/requirements-migrate.ts +34 -0
- package/src/requirements-priority-lowest.test.ts +1 -1
- package/src/requirements-stats.ts +4 -1
- package/src/requirements.ts +59 -10
- package/src/server.ts +9 -5
- package/src/tools.ts +8 -8
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.113",
|
|
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",
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
// #711 改密码只让浏览器 / app 的登录会话下线,命令行和脚本令牌默认保留。
|
|
2
|
+
//
|
|
3
|
+
// main 上 POST /api/auth/password 跑的是 `DELETE FROM api_tokens WHERE user_id = ? AND network_id IS NULL
|
|
4
|
+
// AND token_id != ?` —— 每台机器上 `anet login` 拿到的令牌、POST /api/auth/tokens 建的具名脚本令牌,
|
|
5
|
+
// 跟浏览器会话一起被删光。安全复审定的形状:默认不变,保留是显式选项。这里每条都钉住:
|
|
6
|
+
// · 默认(旧 app、dashboard 代理不带字段):撤销全部不绑网络的令牌 —— 客户端自报的 cli 也活不下来;
|
|
7
|
+
// · keep_cli_tokens=true(anet passwd 默认带):只撤其他 kind='login' 会话,保留的 cli 令牌列在响应里、不含令牌值;
|
|
8
|
+
// · 当前会话(换发后的新令牌)永远可用,而且沿用当前令牌的种类;
|
|
9
|
+
// · 节点 / 网络令牌永远不动;
|
|
10
|
+
// · 启动迁移按 client_label / scope 回填存量令牌的 kind(夹具库,跑两遍幂等)。
|
|
11
|
+
// SQLite 由 test798 自动收进;PostgreSQL 由 tests/test2123-hub-postgres-ladder 的 run_pg_tests_rc 跑同一个文件。
|
|
12
|
+
|
|
13
|
+
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
|
14
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
15
|
+
import { join } from "path";
|
|
16
|
+
import { tmpdir } from "os";
|
|
17
|
+
import { spawnSync } from "child_process";
|
|
18
|
+
import { register, createNetworkTokenForNode, createToken, changePassword } from "./auth.js";
|
|
19
|
+
import { db, hashToken } from "./db.js";
|
|
20
|
+
|
|
21
|
+
const DIR = mkdtempSync(join(tmpdir(), "anet-pwchange-kind-"));
|
|
22
|
+
let BASE = "";
|
|
23
|
+
let hub: any = null;
|
|
24
|
+
const PW = "PwChangeKindPassw0rd!";
|
|
25
|
+
const PW2 = "PwChangeKindPassw0rd!2";
|
|
26
|
+
const CLI_LABEL = "anet 2.5.0 · test-box · login";
|
|
27
|
+
|
|
28
|
+
async function call(token: string, method: string, path: string, body?: unknown) {
|
|
29
|
+
const res = await fetch(`${BASE}${path}`, {
|
|
30
|
+
method,
|
|
31
|
+
headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(body === undefined ? {} : { "Content-Type": "application/json" }) },
|
|
32
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
33
|
+
});
|
|
34
|
+
return { status: res.status, body: await res.json().catch(() => null) as any };
|
|
35
|
+
}
|
|
36
|
+
const works = async (token: string) => (await call(token, "GET", "/api/auth/me")).status === 200;
|
|
37
|
+
const kindOf = (token: string) => db.get<any>("SELECT kind FROM api_tokens WHERE token_hash = ?1", hashToken(token))?.kind ?? null;
|
|
38
|
+
|
|
39
|
+
type Fleet = {
|
|
40
|
+
user: string; userId: string; net: string;
|
|
41
|
+
app: string; appB: string; regToken: string;
|
|
42
|
+
cliByLabel: string; cliByKind: string; apiToken: string;
|
|
43
|
+
nodeToken: string; netToken: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
let seq = 0;
|
|
47
|
+
async function fleet(): Promise<Fleet> {
|
|
48
|
+
const user = `pwk_${Date.now()}_${++seq}`;
|
|
49
|
+
const reg = register(user, PW);
|
|
50
|
+
expect(reg.ok).toBe(true);
|
|
51
|
+
const login = async (extra: Record<string, unknown>) => {
|
|
52
|
+
const r = await call("", "POST", "/api/auth/login", { username: user, password: PW, ...extra });
|
|
53
|
+
expect(r.status).toBe(200);
|
|
54
|
+
return r.body.token as string;
|
|
55
|
+
};
|
|
56
|
+
const node = createNetworkTokenForNode(reg.user!.user_id, reg.network_id!, "pwk-node", `node_pwk_${seq}`);
|
|
57
|
+
expect(node.ok).toBe(true);
|
|
58
|
+
const api = createToken(reg.user!.user_id, "ci-script");
|
|
59
|
+
expect(api.ok).toBe(true);
|
|
60
|
+
const netTok = createToken(reg.user!.user_id, "net-script", reg.network_id!);
|
|
61
|
+
expect(netTok.ok).toBe(true);
|
|
62
|
+
return {
|
|
63
|
+
user, userId: reg.user!.user_id, net: reg.network_id!,
|
|
64
|
+
regToken: reg.token!,
|
|
65
|
+
app: await login({ client_label: "Desktop app" }),
|
|
66
|
+
appB: await login({}),
|
|
67
|
+
cliByLabel: await login({ client_label: CLI_LABEL }),
|
|
68
|
+
cliByKind: await login({ client_kind: "cli" }),
|
|
69
|
+
apiToken: api.token!,
|
|
70
|
+
nodeToken: node.token!,
|
|
71
|
+
netToken: netTok.token!,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
beforeAll(async () => {
|
|
76
|
+
process.env.COMMHUB_DB ||= join(DIR, "hub.db");
|
|
77
|
+
process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
|
|
78
|
+
process.env.HOST = "127.0.0.1";
|
|
79
|
+
process.env.COMMHUB_LOGIN_IP_MAX = "1000";
|
|
80
|
+
const mod: any = await import("./server.js");
|
|
81
|
+
hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
82
|
+
BASE = `http://127.0.0.1:${hub.port}`;
|
|
83
|
+
}, 30_000);
|
|
84
|
+
|
|
85
|
+
afterAll(() => {
|
|
86
|
+
try { hub?.stop?.(true); } catch {}
|
|
87
|
+
try { rmSync(DIR, { recursive: true, force: true }); } catch {}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("new tokens are classified at issue time", () => {
|
|
91
|
+
test("browser/app logins are 'login'; anet-labelled, client_kind=cli and named API tokens are 'cli'; node tokens unclassified", async () => {
|
|
92
|
+
const f = await fleet();
|
|
93
|
+
expect(kindOf(f.regToken)).toBe("login");
|
|
94
|
+
expect(kindOf(f.app)).toBe("login");
|
|
95
|
+
expect(kindOf(f.appB)).toBe("login");
|
|
96
|
+
expect(kindOf(f.cliByLabel)).toBe("cli");
|
|
97
|
+
expect(kindOf(f.cliByKind)).toBe("cli");
|
|
98
|
+
expect(kindOf(f.apiToken)).toBe("cli");
|
|
99
|
+
expect(kindOf(f.nodeToken)).toBe(null);
|
|
100
|
+
expect(kindOf(f.netToken)).toBe(null);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// 响应里出现任何令牌值都算泄漏:换发给调用方的那一条(body.token)除外,它本来就要交给调用方。
|
|
105
|
+
function assertNoTokenValues(body: any, f: Fleet) {
|
|
106
|
+
const { token: _rotated, ...rest } = body;
|
|
107
|
+
const text = JSON.stringify(rest);
|
|
108
|
+
for (const t of [f.regToken, f.app, f.appB, f.cliByLabel, f.cliByKind, f.apiToken, f.nodeToken, f.netToken]) {
|
|
109
|
+
expect(text.includes(t)).toBe(false);
|
|
110
|
+
}
|
|
111
|
+
expect(/\b(utok|atok|ntok)_[A-Za-z0-9]{8,}/.test(text)).toBe(false);
|
|
112
|
+
expect(text.includes("token_hash")).toBe(false);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
describe("POST /api/auth/password (default: old apps, dashboard proxy — no field)", () => {
|
|
116
|
+
test("every other non-network token is revoked (pre-#711 behaviour); node tokens and the rotated current session survive", async () => {
|
|
117
|
+
const f = await fleet();
|
|
118
|
+
const r = await call(f.app, "POST", "/api/auth/password", { old_password: PW, new_password: PW2 });
|
|
119
|
+
expect(r.status).toBe(200);
|
|
120
|
+
expect(r.body.ok).toBe(true);
|
|
121
|
+
expect(r.body.revoked_login).toBe(2); // reg + appB
|
|
122
|
+
expect(r.body.revoked_cli).toBe(3); // cliByLabel + cliByKind + apiToken
|
|
123
|
+
expect(r.body.revoked).toBe(5);
|
|
124
|
+
expect(r.body.kept_cli_tokens).toBeUndefined();
|
|
125
|
+
for (const t of [f.regToken, f.appB, f.cliByLabel, f.cliByKind, f.apiToken]) expect(await works(t)).toBe(false);
|
|
126
|
+
expect(await works(f.nodeToken)).toBe(true);
|
|
127
|
+
expect(await works(f.netToken)).toBe(true);
|
|
128
|
+
expect(await works(r.body.token)).toBe(true);
|
|
129
|
+
expect(kindOf(r.body.token)).toBe("login");
|
|
130
|
+
expect(await works(f.app)).toBe(false);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("a client-declared cli token (client_kind / anet label) does NOT survive a default password change", async () => {
|
|
134
|
+
const f = await fleet();
|
|
135
|
+
expect(kindOf(f.cliByKind)).toBe("cli");
|
|
136
|
+
const r = await call(f.app, "POST", "/api/auth/password", { old_password: PW, new_password: PW2 });
|
|
137
|
+
expect(r.status).toBe(200);
|
|
138
|
+
expect(await works(f.cliByKind)).toBe(false);
|
|
139
|
+
expect(await works(f.cliByLabel)).toBe(false);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("a non-boolean keep_cli_tokens is not an opt-in; the removed revoke_cli_tokens field does nothing", async () => {
|
|
143
|
+
const f = await fleet();
|
|
144
|
+
const r = await call(f.app, "POST", "/api/auth/password", { old_password: PW, new_password: PW2, keep_cli_tokens: "true", revoke_cli_tokens: false });
|
|
145
|
+
expect(r.status).toBe(200);
|
|
146
|
+
expect(r.body.revoked_cli).toBe(3);
|
|
147
|
+
expect(await works(f.cliByLabel)).toBe(false);
|
|
148
|
+
expect(await works(f.apiToken)).toBe(false);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("POST /api/auth/password with keep_cli_tokens=true (anet passwd)", () => {
|
|
153
|
+
test("only other login sessions are revoked; CLI/script tokens are kept and listed without token values", async () => {
|
|
154
|
+
const f = await fleet();
|
|
155
|
+
const r = await call(f.cliByLabel, "POST", "/api/auth/password", { old_password: PW, new_password: PW2, keep_cli_tokens: true });
|
|
156
|
+
expect(r.status).toBe(200);
|
|
157
|
+
expect(r.body.revoked_login).toBe(3); // reg + app + appB
|
|
158
|
+
expect(r.body.revoked_cli).toBe(0);
|
|
159
|
+
expect(r.body.revoked).toBe(3);
|
|
160
|
+
for (const t of [f.regToken, f.app, f.appB]) expect(await works(t)).toBe(false);
|
|
161
|
+
expect(await works(f.cliByKind)).toBe(true);
|
|
162
|
+
expect(await works(f.apiToken)).toBe(true);
|
|
163
|
+
expect(await works(f.nodeToken)).toBe(true);
|
|
164
|
+
expect(await works(f.netToken)).toBe(true);
|
|
165
|
+
// 当前会话:换发的新令牌可用,沿用 cli
|
|
166
|
+
expect(await works(r.body.token)).toBe(true);
|
|
167
|
+
expect(kindOf(r.body.token)).toBe("cli");
|
|
168
|
+
// 保留清单:不含当前这条(它已被换掉),只含另外两条 cli;字段齐全、无令牌值
|
|
169
|
+
const kept = r.body.kept_cli_tokens as any[];
|
|
170
|
+
const idOf = (t: string) => db.get<any>("SELECT token_id FROM api_tokens WHERE token_hash = ?1", hashToken(t)).token_id;
|
|
171
|
+
expect(kept.map((k) => k.token_id).sort()).toEqual([idOf(f.cliByKind), idOf(f.apiToken)].sort());
|
|
172
|
+
for (const k of kept) {
|
|
173
|
+
expect(Object.keys(k).sort()).toEqual(["client_label", "created_at", "last_used_at", "name", "token_id"]);
|
|
174
|
+
expect(k.token_id).toMatch(/^tok_[0-9a-f]{12}$/);
|
|
175
|
+
}
|
|
176
|
+
expect(kept.find((k) => k.token_id === idOf(f.apiToken)).name).toBe("ci-script");
|
|
177
|
+
assertNoTokenValues(r.body, f);
|
|
178
|
+
// 可以逐条撤销
|
|
179
|
+
expect((await call(r.body.token, "DELETE", `/api/auth/tokens/${idOf(f.apiToken)}`)).status).toBe(200);
|
|
180
|
+
expect(await works(f.apiToken)).toBe(false);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("default response carries no token values either", async () => {
|
|
184
|
+
const f = await fleet();
|
|
185
|
+
const r = await call(f.app, "POST", "/api/auth/password", { old_password: PW, new_password: PW2 });
|
|
186
|
+
expect(r.status).toBe(200);
|
|
187
|
+
assertNoTokenValues(r.body, f);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe("changePassword() keeps the current token row in both modes", () => {
|
|
192
|
+
test("no opts at all = default: CLI tokens revoked, current kept", async () => {
|
|
193
|
+
const f = await fleet();
|
|
194
|
+
const cur = db.get<any>("SELECT token_id FROM api_tokens WHERE token_hash = ?1", hashToken(f.app)).token_id;
|
|
195
|
+
const r = changePassword(f.userId, PW, PW2, cur);
|
|
196
|
+
expect(r.ok).toBe(true);
|
|
197
|
+
expect(r.revoked_cli).toBe(3);
|
|
198
|
+
expect(r.kept_cli_tokens).toBeUndefined();
|
|
199
|
+
expect(await works(f.app)).toBe(true);
|
|
200
|
+
expect(await works(f.cliByKind)).toBe(false);
|
|
201
|
+
expect(await works(f.apiToken)).toBe(false);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
for (const keepCliTokens of [false, true]) {
|
|
205
|
+
test(`keepCliTokens=${keepCliTokens}: current login token kept`, async () => {
|
|
206
|
+
const f = await fleet();
|
|
207
|
+
const cur = db.get<any>("SELECT token_id FROM api_tokens WHERE token_hash = ?1", hashToken(f.app)).token_id;
|
|
208
|
+
const r = changePassword(f.userId, PW, PW2, cur, { keepCliTokens });
|
|
209
|
+
expect(r.ok).toBe(true);
|
|
210
|
+
expect(await works(f.app)).toBe(true);
|
|
211
|
+
expect(await works(f.appB)).toBe(false);
|
|
212
|
+
expect(await works(f.cliByLabel)).toBe(keepCliTokens);
|
|
213
|
+
expect(await works(f.nodeToken)).toBe(true);
|
|
214
|
+
});
|
|
215
|
+
test(`keepCliTokens=${keepCliTokens}: current CLI token kept`, async () => {
|
|
216
|
+
const f = await fleet();
|
|
217
|
+
const cur = db.get<any>("SELECT token_id FROM api_tokens WHERE token_hash = ?1", hashToken(f.cliByKind)).token_id;
|
|
218
|
+
const r = changePassword(f.userId, PW, PW2, cur, { keepCliTokens });
|
|
219
|
+
expect(r.ok).toBe(true);
|
|
220
|
+
expect(await works(f.cliByKind)).toBe(true);
|
|
221
|
+
expect(await works(f.app)).toBe(false);
|
|
222
|
+
expect(await works(f.apiToken)).toBe(keepCliTokens);
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe("startup migration backfills api_tokens.kind on a fixture DB", () => {
|
|
228
|
+
// 把 kind 列拿掉 = 回到 #711 之前的表结构;塞存量行;再起一个进程加载 db.ts(= Hub 启动迁移)。
|
|
229
|
+
function bootMigration() {
|
|
230
|
+
const r = spawnSync(process.execPath, ["-e", `await import(${JSON.stringify(join(import.meta.dir, "db.ts"))}); process.exit(0);`], {
|
|
231
|
+
cwd: join(import.meta.dir, ".."),
|
|
232
|
+
env: { ...process.env, NODE_ENV: "test" },
|
|
233
|
+
encoding: "utf8",
|
|
234
|
+
timeout: 60_000,
|
|
235
|
+
});
|
|
236
|
+
if (r.status !== 0) console.log(String(r.stderr || "").slice(-2000));
|
|
237
|
+
return r.status;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
test("anet-labelled and named API tokens → cli, everything else → login, node tokens untouched; idempotent", async () => {
|
|
241
|
+
const f = await fleet();
|
|
242
|
+
db.exec("ALTER TABLE api_tokens DROP COLUMN kind");
|
|
243
|
+
const ins = (id: string, net: string | null, scope: string, label: string | null) =>
|
|
244
|
+
db.run(
|
|
245
|
+
"INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, client_label, node_identity_epoch) VALUES (?1, ?2, ?3, ?4, 'fixture', ?5, ?6, 1)",
|
|
246
|
+
[id, hashToken(`fixture-${id}`), f.userId, net, scope, label],
|
|
247
|
+
);
|
|
248
|
+
const id = (s: string) => `tok_fx_${seq}_${s}`;
|
|
249
|
+
ins(id("cli_label"), null, "user", "anet 2.4.0 · old-box · login");
|
|
250
|
+
ins(id("cli_demo"), null, "user", "anet dev · unknown-host · demo sci-team");
|
|
251
|
+
ins(id("browser"), null, "user", "Chrome on macOS");
|
|
252
|
+
ins(id("nolabel"), null, "user", null);
|
|
253
|
+
ins(id("lookalike"), null, "user", "planet-anet dashboard");
|
|
254
|
+
ins(id("upper"), null, "user", "ANET 2.4.0 · box · login");
|
|
255
|
+
ins(id("padded"), null, "user", " anet 2.4.0 · box · login");
|
|
256
|
+
ins(id("named"), null, "full", null);
|
|
257
|
+
ins(id("node"), f.net, "network", null);
|
|
258
|
+
ins(id("netfull"), f.net, "full", null);
|
|
259
|
+
|
|
260
|
+
expect(bootMigration()).toBe(0);
|
|
261
|
+
const k = (s: string) => db.get<any>("SELECT kind FROM api_tokens WHERE token_id = ?1", id(s))?.kind ?? null;
|
|
262
|
+
expect(k("cli_label")).toBe("cli");
|
|
263
|
+
expect(k("cli_demo")).toBe("cli");
|
|
264
|
+
expect(k("browser")).toBe("login");
|
|
265
|
+
expect(k("nolabel")).toBe("login");
|
|
266
|
+
expect(k("lookalike")).toBe("login");
|
|
267
|
+
expect(k("upper")).toBe("login"); // 区分大小写:SQLite 的 LIKE 会把它当 cli,PG 不会 —— 两边必须一致
|
|
268
|
+
expect(k("padded")).toBe("cli"); // 与运行时一样先 trim
|
|
269
|
+
expect(k("named")).toBe("cli");
|
|
270
|
+
expect(k("node")).toBe(null);
|
|
271
|
+
expect(k("netfull")).toBe(null);
|
|
272
|
+
// fleet() 的 live 令牌也被回填(列重建后原值丢了,按同一规则算回来)
|
|
273
|
+
expect(kindOf(f.cliByLabel)).toBe("cli");
|
|
274
|
+
expect(kindOf(f.apiToken)).toBe("cli");
|
|
275
|
+
expect(kindOf(f.app)).toBe("login");
|
|
276
|
+
|
|
277
|
+
// 幂等:已有值不被改写(手动改成 login 的 anet 行保持 login),再跑一次结果不变
|
|
278
|
+
db.run("UPDATE api_tokens SET kind = 'login' WHERE token_id = ?1", [id("cli_label")]);
|
|
279
|
+
expect(bootMigration()).toBe(0);
|
|
280
|
+
expect(k("cli_label")).toBe("login");
|
|
281
|
+
expect(k("named")).toBe("cli");
|
|
282
|
+
expect(k("browser")).toBe("login");
|
|
283
|
+
|
|
284
|
+
// 迁移后的库上,keep_cli_tokens 保留回填成 cli 的存量令牌
|
|
285
|
+
const cur = db.get<any>("SELECT token_id FROM api_tokens WHERE token_hash = ?1", hashToken(f.app)).token_id;
|
|
286
|
+
const r = changePassword(f.userId, PW, PW2, cur, { keepCliTokens: true });
|
|
287
|
+
expect(r.ok).toBe(true);
|
|
288
|
+
expect(k("named")).toBe("cli");
|
|
289
|
+
expect(k("cli_demo")).toBe("cli");
|
|
290
|
+
expect(k("browser")).toBe(null); // 被撤销(行已删除)
|
|
291
|
+
expect(k("node")).toBe(null);
|
|
292
|
+
expect(Number(db.get<any>("SELECT COUNT(*) AS n FROM api_tokens WHERE token_id = ?1", id("node")).n)).toBe(1);
|
|
293
|
+
}, 120_000);
|
|
294
|
+
});
|
package/src/auth.ts
CHANGED
|
@@ -137,8 +137,8 @@ export function register(username: string, password: string, email?: string, dis
|
|
|
137
137
|
const userToken = generateUserToken();
|
|
138
138
|
const userTokenId = generateId("tok");
|
|
139
139
|
db.run(
|
|
140
|
-
"INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, client_label, user_agent, node_identity_epoch) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 1)",
|
|
141
|
-
[userTokenId, hashToken(userToken), userId, null, "user-login", "user", cleanClientField(opts.client?.label, 64), cleanClientField(opts.client?.userAgent, 256)]
|
|
140
|
+
"INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, client_label, user_agent, kind, node_identity_epoch) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 1)",
|
|
141
|
+
[userTokenId, hashToken(userToken), userId, null, "user-login", "user", cleanClientField(opts.client?.label, 64), cleanClientField(opts.client?.userAgent, 256), userTokenKindFor(opts.client)]
|
|
142
142
|
);
|
|
143
143
|
|
|
144
144
|
// Network token (ntok_) — bound to default network, for agent-node
|
|
@@ -217,8 +217,8 @@ export function login(username: string, password: string, client: SessionClientI
|
|
|
217
217
|
const userToken = generateUserToken();
|
|
218
218
|
const tokenId = generateId("tok");
|
|
219
219
|
db.run(
|
|
220
|
-
"INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, client_label, user_agent, node_identity_epoch) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 1)",
|
|
221
|
-
[tokenId, hashToken(userToken), user.user_id, null, "user-login", "user", cleanClientField(client.label, 64), cleanClientField(client.userAgent, 256)]
|
|
220
|
+
"INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, client_label, user_agent, kind, node_identity_epoch) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 1)",
|
|
221
|
+
[tokenId, hashToken(userToken), user.user_id, null, "user-login", "user", cleanClientField(client.label, 64), cleanClientField(client.userAgent, 256), userTokenKindFor(client)]
|
|
222
222
|
);
|
|
223
223
|
|
|
224
224
|
// Find default network
|
|
@@ -321,7 +321,28 @@ const SESSION_TOKEN_SQL = "scope = 'user' AND network_id IS NULL";
|
|
|
321
321
|
// 以小时为粒度对一个以天计的闲置窗口没有影响。
|
|
322
322
|
const LAST_USED_WRITE_INTERVAL_SECONDS = 3600;
|
|
323
323
|
|
|
324
|
-
export type SessionClientInfo = { label?: unknown; userAgent?: unknown };
|
|
324
|
+
export type SessionClientInfo = { label?: unknown; userAgent?: unknown; kind?: unknown };
|
|
325
|
+
|
|
326
|
+
// #711 不绑网络的用户令牌的种类(api_tokens.kind):
|
|
327
|
+
// 'login' — 浏览器 / app 的登录会话;
|
|
328
|
+
// 'cli' — anet 命令行登录、POST /api/auth/tokens 建的具名脚本令牌。
|
|
329
|
+
// 种类由客户端自报(client_kind / client_label),**只用于展示和「显式保留」那条路**:
|
|
330
|
+
// 改密码默认仍撤销全部不绑网络的令牌(当前这条除外),调用方显式 keep_cli_tokens=true 才保留 'cli'。
|
|
331
|
+
// 所以一个自称 cli 的令牌永远不能靠自报在默认改密码里活下来。
|
|
332
|
+
// 节点 / 网络令牌(network_id 非空)不分类,改密码永远不碰。
|
|
333
|
+
export type UserTokenKind = "login" | "cli";
|
|
334
|
+
|
|
335
|
+
/** 登录 / 注册请求签发的令牌算哪一类:客户端显式报 kind='cli',或 client_label 是 anet 自报的「anet <版本> · …」。 */
|
|
336
|
+
/** anet 自报的 client_label:去掉首尾空白后,区分大小写地以「anet 」开头(启动回填用同一条规则,见 db.ts)。 */
|
|
337
|
+
export function isAnetClientLabel(label: string): boolean {
|
|
338
|
+
return label.trim().startsWith("anet ");
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export function userTokenKindFor(client: SessionClientInfo = {}): UserTokenKind {
|
|
342
|
+
if (client.kind === "cli") return "cli";
|
|
343
|
+
if (typeof client.label === "string" && isAnetClientLabel(client.label)) return "cli";
|
|
344
|
+
return "login";
|
|
345
|
+
}
|
|
325
346
|
|
|
326
347
|
function cleanClientField(value: unknown, max: number): string | null {
|
|
327
348
|
if (typeof value !== "string") return null;
|
|
@@ -508,9 +529,10 @@ export function createToken(userId: string, name: string, networkId?: string): {
|
|
|
508
529
|
}
|
|
509
530
|
const token = generateToken();
|
|
510
531
|
const tokenId = generateId("tok");
|
|
532
|
+
// #711 不绑网络的具名令牌是给脚本 / 命令行长期用的:kind='cli'(改密码时可显式 keep_cli_tokens 保留)。
|
|
511
533
|
db.run(
|
|
512
|
-
"INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, node_identity_epoch) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1)",
|
|
513
|
-
[tokenId, hashToken(token), userId, networkId || null, name, "full"]
|
|
534
|
+
"INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, kind, node_identity_epoch) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 1)",
|
|
535
|
+
[tokenId, hashToken(token), userId, networkId || null, name, "full", networkId ? null : "cli"]
|
|
514
536
|
);
|
|
515
537
|
return { ok: true, token, token_id: tokenId };
|
|
516
538
|
}
|
|
@@ -520,16 +542,55 @@ export function revokeToken(userId: string, tokenId: string): { ok: boolean; err
|
|
|
520
542
|
return result.changes > 0 ? { ok: true } : { ok: false, error: "token not found" };
|
|
521
543
|
}
|
|
522
544
|
|
|
523
|
-
export function issueUserToken(
|
|
545
|
+
export function issueUserToken(
|
|
546
|
+
userId: string,
|
|
547
|
+
name = "user-login",
|
|
548
|
+
opts: { kind?: UserTokenKind; clientLabel?: string | null; userAgent?: string | null } = {},
|
|
549
|
+
): { token: string; token_id: string } {
|
|
524
550
|
const token = generateUserToken();
|
|
525
551
|
const tokenId = generateId("tok");
|
|
526
552
|
db.run(
|
|
527
|
-
"INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, node_identity_epoch) VALUES (?1, ?2, ?3, NULL, ?4, 'user', 1)",
|
|
528
|
-
[tokenId, hashToken(token), userId, name]
|
|
553
|
+
"INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, client_label, user_agent, kind, node_identity_epoch) VALUES (?1, ?2, ?3, NULL, ?4, 'user', ?5, ?6, ?7, 1)",
|
|
554
|
+
[tokenId, hashToken(token), userId, name, opts.clientLabel ?? null, opts.userAgent ?? null, opts.kind === "cli" ? "cli" : "login"]
|
|
529
555
|
);
|
|
530
556
|
return { token, token_id: tokenId };
|
|
531
557
|
}
|
|
532
558
|
|
|
559
|
+
/** 一条令牌的种类和设备标识(改密码换发新令牌时沿用,命令行的令牌换完还是命令行的)。 */
|
|
560
|
+
export function getUserTokenMeta(userId: string, tokenId: string): { kind: UserTokenKind; client_label: string | null; user_agent: string | null } | null {
|
|
561
|
+
const row = db.get<any>("SELECT kind, client_label, user_agent FROM api_tokens WHERE token_id = ?1 AND user_id = ?2 AND network_id IS NULL", tokenId, userId);
|
|
562
|
+
if (!row) return null;
|
|
563
|
+
return { kind: row.kind === "cli" ? "cli" : "login", client_label: row.client_label ?? null, user_agent: row.user_agent ?? null };
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// #711 改密码撤销哪些令牌。kind 为 NULL 的(启动回填之后由旧版 Hub 签出的)按 'login' 算。
|
|
567
|
+
const CLI_KIND_SQL = "kind = 'cli'";
|
|
568
|
+
const NOT_CLI_KIND_SQL = "COALESCE(kind, 'login') <> 'cli'";
|
|
569
|
+
|
|
570
|
+
/** 改密码后保留下来的命令行 / 脚本令牌(给 UI 列出来、让用户逐条撤销)。**永远不含令牌值。** */
|
|
571
|
+
export type KeptCliToken = { token_id: string; name: string; client_label: string | null; created_at: string; last_used_at: string | null };
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* 改密码后的令牌撤销。默认(keepCli=false)= 改密码前的行为:撤销全部不绑网络的令牌;
|
|
575
|
+
* keepCli=true 时保留 kind='cli' 的,并把它们列出来。当前这条(exceptTokenId)和节点 / 网络令牌永远不动。
|
|
576
|
+
*/
|
|
577
|
+
export function revokeTokensAfterPasswordChange(userId: string, exceptTokenId: string | null | undefined, keepCli: boolean): { login: number; cli: number; kept: KeptCliToken[] } {
|
|
578
|
+
const except = exceptTokenId ? " AND token_id != ?2" : "";
|
|
579
|
+
const params = exceptTokenId ? [userId, exceptTokenId] : [userId];
|
|
580
|
+
const login = db.run(`DELETE FROM api_tokens WHERE user_id = ?1 AND network_id IS NULL AND ${NOT_CLI_KIND_SQL}${except}`, params).changes;
|
|
581
|
+
if (!keepCli) {
|
|
582
|
+
const cli = db.run(`DELETE FROM api_tokens WHERE user_id = ?1 AND network_id IS NULL${except}`, params).changes;
|
|
583
|
+
return { login, cli, kept: [] };
|
|
584
|
+
}
|
|
585
|
+
const kept = db.all<any>(
|
|
586
|
+
`SELECT token_id, name, client_label, created_at, last_used_at FROM api_tokens
|
|
587
|
+
WHERE user_id = ?1 AND network_id IS NULL AND ${CLI_KIND_SQL}${except}
|
|
588
|
+
ORDER BY created_at DESC`,
|
|
589
|
+
...params,
|
|
590
|
+
).map((r) => ({ token_id: r.token_id, name: r.name, client_label: r.client_label ?? null, created_at: r.created_at, last_used_at: r.last_used_at ?? null }));
|
|
591
|
+
return { login, cli: 0, kept };
|
|
592
|
+
}
|
|
593
|
+
|
|
533
594
|
export function revokeOtherUserTokens(userId: string, exceptTokenId?: string | null): number {
|
|
534
595
|
const result = exceptTokenId
|
|
535
596
|
? db.run("DELETE FROM api_tokens WHERE user_id = ?1 AND network_id IS NULL AND token_id != ?2", [userId, exceptTokenId])
|
|
@@ -544,6 +605,7 @@ export type LoginSession = {
|
|
|
544
605
|
last_used_at: string | null;
|
|
545
606
|
client_label: string | null;
|
|
546
607
|
user_agent: string | null;
|
|
608
|
+
kind: UserTokenKind;
|
|
547
609
|
is_current: boolean;
|
|
548
610
|
};
|
|
549
611
|
|
|
@@ -551,7 +613,7 @@ export type LoginSession = {
|
|
|
551
613
|
export function listSessions(userId: string, currentTokenId: string | null): LoginSession[] {
|
|
552
614
|
const cutoff = idleCutoffOffset();
|
|
553
615
|
const rows = db.all<any>(
|
|
554
|
-
`SELECT token_id, name, created_at, last_used_at, client_label, user_agent FROM api_tokens
|
|
616
|
+
`SELECT token_id, name, created_at, last_used_at, client_label, user_agent, kind FROM api_tokens
|
|
555
617
|
WHERE user_id = ?1 AND ${SESSION_TOKEN_SQL} AND revoked_at IS NULL${cutoff ? `
|
|
556
618
|
AND COALESCE(last_used_at, created_at) >= datetime('now', ?2)` : ""}
|
|
557
619
|
ORDER BY COALESCE(last_used_at, created_at) DESC, created_at DESC`,
|
|
@@ -563,6 +625,7 @@ export function listSessions(userId: string, currentTokenId: string | null): Log
|
|
|
563
625
|
last_used_at: r.last_used_at ?? null,
|
|
564
626
|
client_label: r.client_label ?? null,
|
|
565
627
|
user_agent: r.user_agent ?? null,
|
|
628
|
+
kind: r.kind === "cli" ? "cli" : "login",
|
|
566
629
|
is_current: !!currentTokenId && r.token_id === currentTokenId,
|
|
567
630
|
}));
|
|
568
631
|
}
|
|
@@ -578,7 +641,14 @@ export function revokeOtherSessions(userId: string, currentTokenId: string): num
|
|
|
578
641
|
return db.run(`DELETE FROM api_tokens WHERE user_id = ?1 AND ${SESSION_TOKEN_SQL} AND token_id != ?2`, [userId, currentTokenId]).changes;
|
|
579
642
|
}
|
|
580
643
|
|
|
581
|
-
export
|
|
644
|
+
export type ChangePasswordResult = { ok: boolean; error?: string; revoked?: number; revoked_login?: number; revoked_cli?: number; kept_cli_tokens?: KeptCliToken[] };
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* 改密码。默认撤销该用户全部不绑网络的令牌(改密码前的行为,旧 app 依赖它)。#711:opts.keepCliTokens=true
|
|
648
|
+
* 时保留 kind='cli' 的(anet 命令行登录、具名脚本令牌),并在 kept_cli_tokens 里列出。当前会话和节点令牌永远保留。
|
|
649
|
+
* revoked = 两类合计(旧字段,兼容旧客户端)。
|
|
650
|
+
*/
|
|
651
|
+
export function changePassword(userId: string, oldPassword: string, newPassword: string, currentTokenId?: string | null, opts: { keepCliTokens?: boolean } = {}): ChangePasswordResult {
|
|
582
652
|
const passwordError = validatePasswordStrength(newPassword, "new password");
|
|
583
653
|
if (passwordError) return { ok: false, error: passwordError };
|
|
584
654
|
const user = db.get<any>("SELECT password_hash FROM users WHERE user_id = ?1", userId);
|
|
@@ -593,8 +663,9 @@ export function changePassword(userId: string, oldPassword: string, newPassword:
|
|
|
593
663
|
// nudge goes away on next login. SET to 0 explicitly (rather than skip)
|
|
594
664
|
// so a future flag flip can't drift the state.
|
|
595
665
|
db.run("UPDATE users SET password_hash = ?1, must_change_password = 0, updated_at = datetime('now') WHERE user_id = ?2", [hashPassword(newPassword), userId]);
|
|
596
|
-
const
|
|
597
|
-
|
|
666
|
+
const keep = opts.keepCliTokens === true;
|
|
667
|
+
const r = revokeTokensAfterPasswordChange(userId, currentTokenId, keep);
|
|
668
|
+
return { ok: true, revoked: r.login + r.cli, revoked_login: r.login, revoked_cli: r.cli, ...(keep ? { kept_cli_tokens: r.kept } : {}) };
|
|
598
669
|
}
|
|
599
670
|
|
|
600
671
|
/**
|
package/src/db.ts
CHANGED
|
@@ -646,12 +646,26 @@ for (const ddl of [
|
|
|
646
646
|
// 登录会话的设备标识(GET /api/auth/sessions 显示用):客户端自报的 client_label + 登录请求的 User-Agent。
|
|
647
647
|
"ALTER TABLE api_tokens ADD COLUMN client_label TEXT",
|
|
648
648
|
"ALTER TABLE api_tokens ADD COLUMN user_agent TEXT",
|
|
649
|
+
// #711 不绑网络的用户令牌分两类:'login' = 浏览器 / app 的登录会话;'cli' = anet 命令行登录、
|
|
650
|
+
// POST /api/auth/tokens 建的具名脚本令牌。只用于展示和 keep_cli_tokens 那条显式保留路径;节点 / 网络令牌(network_id 非空)不填。
|
|
651
|
+
"ALTER TABLE api_tokens ADD COLUMN kind TEXT",
|
|
649
652
|
]) {
|
|
650
653
|
try { db.exec(ddl); }
|
|
651
654
|
catch (e: any) {
|
|
652
655
|
if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
|
|
653
656
|
}
|
|
654
657
|
}
|
|
658
|
+
// #711 回填 kind:只碰 kind 还是 NULL 的非网络令牌(幂等;回滚到旧 Hub 期间新签的行,下次启动照样补上)。
|
|
659
|
+
// 能明确认出是 anet 命令行的才算 'cli':scope='full'(POST /api/auth/tokens 建的具名令牌),
|
|
660
|
+
// 或 client_label 去掉首尾空格后以「anet 」开头(anet 自报的「anet <版本> · <主机> · <子命令>」)。其余一律 'login'。
|
|
661
|
+
// 用 SUBSTR + = 而不是 LIKE:SQLite 的 LIKE 不分大小写、PG 分,两边结果会不一样;= 两边都区分大小写,
|
|
662
|
+
// 与运行时 isAnetClientLabel(auth.ts)同一条规则。
|
|
663
|
+
db.run(
|
|
664
|
+
`UPDATE api_tokens SET kind = CASE
|
|
665
|
+
WHEN scope = 'full' OR SUBSTR(TRIM(client_label), 1, 5) = 'anet ' THEN 'cli'
|
|
666
|
+
ELSE 'login' END
|
|
667
|
+
WHERE network_id IS NULL AND kind IS NULL`,
|
|
668
|
+
);
|
|
655
669
|
try { db.exec(`CREATE INDEX IF NOT EXISTS idx_tokens_request ON api_tokens(request_id)`); }
|
|
656
670
|
catch (e: any) { /* index may already exist */ void e; }
|
|
657
671
|
try { db.exec(`CREATE INDEX IF NOT EXISTS idx_tokens_bound_node ON api_tokens(bound_node_id)`); }
|
|
@@ -969,7 +983,7 @@ db.exec(`
|
|
|
969
983
|
requirement_id TEXT PRIMARY KEY,
|
|
970
984
|
network_id TEXT NOT NULL,
|
|
971
985
|
title TEXT NOT NULL,
|
|
972
|
-
column_name TEXT NOT NULL DEFAULT 'pool' CHECK(column_name IN ('pool', 'doing', 'done')),
|
|
986
|
+
column_name TEXT NOT NULL DEFAULT 'pool' CHECK(column_name IN ('pool', 'doing', 'done', 'abandoned')),
|
|
973
987
|
priority TEXT NOT NULL DEFAULT 'normal' CHECK(priority IN ('high', 'normal', 'low', 'lowest')),
|
|
974
988
|
due_on TEXT,
|
|
975
989
|
assignee TEXT,
|
|
@@ -227,7 +227,7 @@ export function dueReminderFor(due: string, nowMs: number, tz: string, maxOverdu
|
|
|
227
227
|
export function overdueSql(params: unknown[], nowMs: number, tz: string): string {
|
|
228
228
|
const today = localDate(nowMs, tz);
|
|
229
229
|
const nowIso = new Date(nowMs).toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
230
|
-
return `(due_on IS NOT NULL AND column_name
|
|
230
|
+
return `(due_on IS NOT NULL AND column_name NOT IN ('done', 'abandoned') AND ((length(due_on) = 10 AND due_on < ?${params.push(today)}) OR (length(due_on) > 10 AND due_on <= ?${params.push(nowIso)})))`;
|
|
231
231
|
}
|
|
232
232
|
/** 没逾期、且在 tz 的「今天 + days」那天结束之前到期(0 = 今天内)。 */
|
|
233
233
|
export function dueWithinSql(params: unknown[], days: number, nowMs: number, tz: string): string {
|
|
@@ -235,7 +235,7 @@ export function dueWithinSql(params: unknown[], days: number, nowMs: number, tz:
|
|
|
235
235
|
const lastDay = addDays(today, days);
|
|
236
236
|
const endIso = new Date(localMidnightMs(addDays(lastDay, 1), tz)).toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
237
237
|
const nowIso = new Date(nowMs).toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
238
|
-
return `(due_on IS NOT NULL AND column_name
|
|
238
|
+
return `(due_on IS NOT NULL AND column_name NOT IN ('done', 'abandoned') AND ((length(due_on) = 10 AND due_on >= ?${params.push(today)} AND due_on <= ?${params.push(lastDay)}) OR (length(due_on) > 10 AND due_on > ?${params.push(nowIso)} AND due_on < ?${params.push(endIso)})))`;
|
|
239
239
|
}
|
|
240
240
|
|
|
241
241
|
// ── 扫描 ───────────────────────────────────────────────────
|
|
@@ -332,7 +332,7 @@ export function runDueReminders(opts: { now?: number } = {}): DueReminderSent[]
|
|
|
332
332
|
const rows = db.all<DueRow>(
|
|
333
333
|
`SELECT requirement_id, network_id, seq, title, due_on, owner_json, participants_json, agent_owner_json
|
|
334
334
|
FROM requirements
|
|
335
|
-
WHERE due_on IS NOT NULL AND due_on >= ?1 AND due_on < ?2 AND column_name
|
|
335
|
+
WHERE due_on IS NOT NULL AND due_on >= ?1 AND due_on < ?2 AND column_name NOT IN ('done', 'abandoned') AND COALESCE(archived, 0) = 0${netFilter}${ownerFilter}`,
|
|
336
336
|
...params,
|
|
337
337
|
);
|
|
338
338
|
const out: DueReminderSent[] = [];
|
|
@@ -12,7 +12,7 @@ import { isParticipant } from "./task-access.js";
|
|
|
12
12
|
import { rewriteUnreadNoticeDm, sendNoticeDm } from "./human-dm.js";
|
|
13
13
|
|
|
14
14
|
export const COALESCE_WINDOW_MS = 60_000;
|
|
15
|
-
const COLUMN_LABEL: Record<string, string> = { pool: "需求池", doing: "进行中", done: "完成" };
|
|
15
|
+
const COLUMN_LABEL: Record<string, string> = { pool: "需求池", doing: "进行中", done: "完成", abandoned: "废弃" };
|
|
16
16
|
const MAX_LINES = 20;
|
|
17
17
|
const MAX_ITEM_CHARS = 40;
|
|
18
18
|
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// 任务状态「废弃」(abandoned)—— HTTP 集成测试(真实 Bun.serve,私有端口,临时库)。
|
|
2
|
+
// test2123 在真实 PostgreSQL 上原样再跑一遍(COMMHUB_TEST_PG_URL)。
|
|
3
|
+
//
|
|
4
|
+
// 废弃是关闭态(同 done):不算开着、不逾期、不发到期提醒、不记完成时间;改进 / 改出都记动态;能改回 pool / doing。
|
|
5
|
+
// 旧 App(desktop ≤ 0.2.220)把不认识的状态当 pool —— 所以没声明 `X-Anet-Accept-Columns: abandoned`
|
|
6
|
+
// (或 `accept_columns=abandoned`)的调用者看到的是 done;MCP 总是声明,看到真值。
|
|
7
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
8
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
const DIR = mkdtempSync(join(tmpdir(), "anet-req-abandoned-"));
|
|
13
|
+
process.env.COMMHUB_DB ||= join(DIR, "hub.db");
|
|
14
|
+
process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
|
|
15
|
+
process.env.HOST = "127.0.0.1";
|
|
16
|
+
for (const k of ["COMMHUB_DUE_REMINDER_TZ", "COMMHUB_DUE_OVERDUE_MAX_DAYS", "COMMHUB_DUE_REMINDER_NETWORKS", "COMMHUB_DUE_REMINDERS", "COMMHUB_DUE_REMINDERS_NETWORKS", "COMMHUB_DUE_REMINDERS_EXCLUDE_NETWORKS", "COMMHUB_DUE_REMINDERS_OWNERS"]) delete process.env[k];
|
|
17
|
+
const PW = "AbandonedPassw0rd!x";
|
|
18
|
+
const T0 = Date.UTC(2026, 9, 10, 2, 0, 0);
|
|
19
|
+
const DAY = 86_400_000;
|
|
20
|
+
const AWARE = { "X-Anet-Accept-Columns": "abandoned" };
|
|
21
|
+
|
|
22
|
+
let BASE = "";
|
|
23
|
+
let hub: any = null;
|
|
24
|
+
let NET = "";
|
|
25
|
+
let TOKEN = "";
|
|
26
|
+
let OWNER = "";
|
|
27
|
+
let db: any;
|
|
28
|
+
let due: typeof import("./requirement-due-reminders.js");
|
|
29
|
+
|
|
30
|
+
type R = { status: number; body: any };
|
|
31
|
+
async function send(method: string, path: string, payload?: unknown, headers: Record<string, string> = {}): Promise<R> {
|
|
32
|
+
const res = await fetch(`${BASE}${path}`, {
|
|
33
|
+
method,
|
|
34
|
+
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", ...headers },
|
|
35
|
+
body: payload === undefined ? undefined : JSON.stringify(payload),
|
|
36
|
+
}, 60_000);
|
|
37
|
+
const text = await res.text();
|
|
38
|
+
let body: any = null;
|
|
39
|
+
try { body = JSON.parse(text); } catch {}
|
|
40
|
+
return { status: res.status, body };
|
|
41
|
+
}
|
|
42
|
+
async function mcp(name: string, args: Record<string, unknown>): Promise<any> {
|
|
43
|
+
const res = await fetch(`${BASE}/mcp`, {
|
|
44
|
+
method: "POST",
|
|
45
|
+
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" },
|
|
46
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
|
|
47
|
+
}, 60_000);
|
|
48
|
+
const raw = await res.text();
|
|
49
|
+
const lines = raw.split("\n").filter(x => x.startsWith("data:"));
|
|
50
|
+
const payload = lines.length ? JSON.parse(lines.at(-1)!.slice(5).trim()) : JSON.parse(raw);
|
|
51
|
+
return JSON.parse(payload.result.content[0].text);
|
|
52
|
+
}
|
|
53
|
+
async function card(name: string, extra: Record<string, unknown> = {}): Promise<string> {
|
|
54
|
+
const r = await send("POST", "/api/requirements", { network_id: NET, name, owner: { kind: "user", id: OWNER }, ...extra });
|
|
55
|
+
expect(r.status).toBe(201);
|
|
56
|
+
return r.body.requirement.id as string;
|
|
57
|
+
}
|
|
58
|
+
const get = (id: string, headers: Record<string, string> = {}) => send("GET", `/api/requirements/${id}?network_id=${NET}`, undefined, headers);
|
|
59
|
+
const patch = (id: string, body: Record<string, unknown>, headers: Record<string, string> = {}) => send("PATCH", `/api/requirements/${id}?network_id=${NET}`, body, headers);
|
|
60
|
+
const list = (qs = "", headers: Record<string, string> = {}) => send("GET", `/api/requirements?network_id=${NET}${qs}`, undefined, headers);
|
|
61
|
+
const ids = (r: R) => (r.body.requirements as any[]).map(x => x.id);
|
|
62
|
+
const columnIn = (r: R, id: string) => (r.body.requirements as any[]).find(x => x.id === id)?.column;
|
|
63
|
+
const sentFor = (out: Array<{ requirement_id: string; kind: string }>, id: string) => out.filter(x => x.requirement_id === id).map(x => x.kind);
|
|
64
|
+
const columnEvents = async (id: string) => ((await send("GET", `/api/requirements/events?network_id=${NET}&requirement_id=${id}`, undefined, AWARE)).body.events as any[])
|
|
65
|
+
.filter(e => e.kind === "changed" && e.field === "column").map(e => [e.old, e.new]).reverse();
|
|
66
|
+
|
|
67
|
+
beforeAll(async () => {
|
|
68
|
+
({ db } = await import("./db.js"));
|
|
69
|
+
const { register } = await import("./auth.js");
|
|
70
|
+
const r = register(`ab_owner_${Date.now()}`, PW);
|
|
71
|
+
expect(r.ok).toBe(true);
|
|
72
|
+
TOKEN = r.token!;
|
|
73
|
+
OWNER = r.user!.user_id;
|
|
74
|
+
NET = r.network_id!;
|
|
75
|
+
due = await import("./requirement-due-reminders.js");
|
|
76
|
+
const mod: any = await import("./server.js");
|
|
77
|
+
hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
78
|
+
BASE = `http://127.0.0.1:${hub.port}`;
|
|
79
|
+
due.runDueReminders({ now: T0 - 30 * DAY }); // 到期提醒基线(同 requirement-due-reminders-http.test.ts)
|
|
80
|
+
}, 30_000);
|
|
81
|
+
|
|
82
|
+
afterAll(() => {
|
|
83
|
+
try { hub?.stop?.(true); } catch {}
|
|
84
|
+
try { rmSync(DIR, { recursive: true, force: true }); } catch {}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("abandoned column", () => {
|
|
88
|
+
test("schema accepts abandoned; the legacy CHECK migration is idempotent (SQLite rebuild / PG constraint swap)", async () => {
|
|
89
|
+
const { migrateRequirementColumnCheck } = await import("./requirements-migrate.js");
|
|
90
|
+
if (db.dialect === "postgres") {
|
|
91
|
+
// 模拟旧 PG 库:换回旧约束,迁移后 abandoned 能写,再跑一次照样不报错。
|
|
92
|
+
db.exec("ALTER TABLE requirements DROP CONSTRAINT IF EXISTS requirements_column_name_check");
|
|
93
|
+
db.exec("ALTER TABLE requirements ADD CONSTRAINT requirements_column_name_check CHECK(column_name IN ('pool', 'doing', 'done'))");
|
|
94
|
+
expect(migrateRequirementColumnCheck(db).rebuilt).toBe(true);
|
|
95
|
+
// 已经认识 abandoned:不再动表(不是每次启动都删了重加)
|
|
96
|
+
expect(migrateRequirementColumnCheck(db).rebuilt).toBe(false);
|
|
97
|
+
// 加约束失败要响亮地失败,而且是一条语句:旧约束原样留着,不会落到「没约束」
|
|
98
|
+
db.exec("ALTER TABLE requirements DROP CONSTRAINT IF EXISTS requirements_column_name_check");
|
|
99
|
+
db.exec("ALTER TABLE requirements ADD CONSTRAINT requirements_column_name_check CHECK(column_name IN ('pool', 'doing', 'done', 'bogus'))");
|
|
100
|
+
db.run("INSERT INTO requirements (requirement_id, network_id, title, column_name) VALUES ('req_ab_bogus', ?1, 'bogus', 'bogus')", [NET]);
|
|
101
|
+
expect(() => migrateRequirementColumnCheck(db)).toThrow();
|
|
102
|
+
const def = (db.get("SELECT pg_get_constraintdef(c.oid) AS def FROM pg_constraint c JOIN pg_class t ON t.oid = c.conrelid WHERE t.relname = 'requirements' AND c.conname = 'requirements_column_name_check'") as any)?.def ?? "";
|
|
103
|
+
expect(def).toContain("'bogus'");
|
|
104
|
+
db.run("DELETE FROM requirements WHERE requirement_id = 'req_ab_bogus'");
|
|
105
|
+
expect(migrateRequirementColumnCheck(db).rebuilt).toBe(true);
|
|
106
|
+
} else {
|
|
107
|
+
expect(migrateRequirementColumnCheck(db).rebuilt).toBe(false);
|
|
108
|
+
}
|
|
109
|
+
const id = await card("ab-schema");
|
|
110
|
+
expect((await patch(id, { column: "abandoned" }, AWARE)).status).toBe(200);
|
|
111
|
+
expect((await patch(id, { column: "cancelled" }, AWARE)).body.error).toBe("invalid_column");
|
|
112
|
+
expect((await list()).body.capabilities).toContain("column_abandoned");
|
|
113
|
+
}, 60_000);
|
|
114
|
+
|
|
115
|
+
test("set and restore: events recorded each way, no completedAt, restorable to pool and doing", async () => {
|
|
116
|
+
const id = await card("ab-set");
|
|
117
|
+
const a = await patch(id, { column: "abandoned" }, AWARE);
|
|
118
|
+
expect(a.status).toBe(200);
|
|
119
|
+
expect(a.body.requirement.column).toBe("abandoned");
|
|
120
|
+
expect(a.body.requirement.completedAt).toBeNull();
|
|
121
|
+
expect((await patch(id, { column: "doing" }, AWARE)).body.requirement.column).toBe("doing");
|
|
122
|
+
expect((await patch(id, { column: "abandoned" }, AWARE)).body.requirement.column).toBe("abandoned");
|
|
123
|
+
expect((await patch(id, { column: "pool" }, AWARE)).body.requirement.column).toBe("pool");
|
|
124
|
+
expect(await columnEvents(id)).toEqual([["pool", "abandoned"], ["abandoned", "doing"], ["doing", "abandoned"], ["abandoned", "pool"]]);
|
|
125
|
+
// 直接建在废弃列(带声明)也行
|
|
126
|
+
const created = await send("POST", "/api/requirements", { network_id: NET, name: "ab-born", column: "abandoned" }, AWARE);
|
|
127
|
+
expect(created.body.requirement.column).toBe("abandoned");
|
|
128
|
+
}, 60_000);
|
|
129
|
+
|
|
130
|
+
test("closed like done: not overdue, no due reminder, not counted as open in stats; restoring reopens it", async () => {
|
|
131
|
+
// id:到期提醒用假时钟 T0(10-10),到期 10-07 = 逾期 3 天;old:列表的 overdue 筛选用真时钟,到期给一个早就过了的日子。
|
|
132
|
+
const id = await card("ab-overdue", { due: "2026-10-07", column: "doing" });
|
|
133
|
+
const old = await card("ab-overdue-list", { due: "2020-01-01", column: "doing" });
|
|
134
|
+
expect(ids(await list("&overdue=1"))).toContain(old);
|
|
135
|
+
await patch(id, { column: "abandoned" }, AWARE);
|
|
136
|
+
await patch(old, { column: "abandoned" }, AWARE);
|
|
137
|
+
expect(ids(await list("&overdue=1"))).not.toContain(old);
|
|
138
|
+
expect(ids(await list("&overdue=0"))).toContain(old);
|
|
139
|
+
expect(sentFor(due.runDueReminders({ now: T0 }), id)).toEqual([]);
|
|
140
|
+
const stats = (await send("GET", `/api/requirements/stats?network_id=${NET}`)).body.totals;
|
|
141
|
+
const raw = db.all("SELECT column_name FROM requirements WHERE network_id = ?1 AND COALESCE(archived, 0) = 0", NET) as any[];
|
|
142
|
+
const n = (c: string) => raw.filter(r => r.column_name === c).length;
|
|
143
|
+
expect(stats.doing).toBe(n("doing"));
|
|
144
|
+
expect(stats.pool).toBe(n("pool"));
|
|
145
|
+
expect(stats.abandoned).toBe(n("abandoned"));
|
|
146
|
+
expect(n("abandoned")).toBeGreaterThan(0);
|
|
147
|
+
// 子任务进度不把废弃的子任务算进去
|
|
148
|
+
const parent = await card("ab-parent");
|
|
149
|
+
const kid = await card("ab-kid", { parent_id: parent });
|
|
150
|
+
await card("ab-kid2", { parent_id: parent });
|
|
151
|
+
await patch(kid, { column: "abandoned" }, AWARE);
|
|
152
|
+
// 声明了:废弃的子需求不算进度;没声明(旧 App 把它当 done 列出来):算进总数、算作完成,和它看到的列表对得上
|
|
153
|
+
expect((await get(parent, AWARE)).body.requirement.children).toEqual({ total: 1, done: 0 });
|
|
154
|
+
expect((await get(parent)).body.requirement.children).toEqual({ total: 2, done: 1 });
|
|
155
|
+
expect(columnIn(await list("&parent_id=" + parent), kid)).toBe("done");
|
|
156
|
+
// 改回 doing:又逾期、又提醒
|
|
157
|
+
await patch(id, { column: "doing" }, AWARE);
|
|
158
|
+
await patch(old, { column: "doing" }, AWARE);
|
|
159
|
+
expect(ids(await list("&overdue=1"))).toContain(old);
|
|
160
|
+
expect(sentFor(due.runDueReminders({ now: T0 }), id)).toEqual(["overdue"]);
|
|
161
|
+
}, 60_000);
|
|
162
|
+
|
|
163
|
+
test("old clients (no declaration) see abandoned as done; their writes cannot un-abandon it by echo", async () => {
|
|
164
|
+
const id = await card("ab-legacy");
|
|
165
|
+
await patch(id, { column: "abandoned" }, AWARE);
|
|
166
|
+
expect((await get(id)).body.requirement.column).toBe("done");
|
|
167
|
+
expect((await get(id, AWARE)).body.requirement.column).toBe("abandoned");
|
|
168
|
+
expect((await send("GET", `/api/requirements/${id}?network_id=${NET}&accept_columns=abandoned`)).body.requirement.column).toBe("abandoned");
|
|
169
|
+
expect(columnIn(await list(), id)).toBe("done");
|
|
170
|
+
expect(columnIn(await list("&view=summary"), id)).toBe("done");
|
|
171
|
+
expect(columnIn(await list("", AWARE), id)).toBe("abandoned");
|
|
172
|
+
// 列表缓存按声明分开:同一个查询串,先旧后新,不串值
|
|
173
|
+
expect(columnIn(await list(), id)).toBe("done");
|
|
174
|
+
expect(columnIn(await list("", AWARE), id)).toBe("abandoned");
|
|
175
|
+
// 旧客户端按 done 筛:带上废弃的(它眼里就是 done)
|
|
176
|
+
expect(ids(await list("&status=done"))).toContain(id);
|
|
177
|
+
expect(ids(await list("&status=done", AWARE))).not.toContain(id);
|
|
178
|
+
expect(ids(await list("&status=abandoned", AWARE))).toContain(id);
|
|
179
|
+
// 旧客户端改别的字段 / 回传它看到的 done:仍是废弃;响应里仍是 done
|
|
180
|
+
const p1 = await patch(id, { priority: "high" });
|
|
181
|
+
expect(p1.body.requirement.column).toBe("done");
|
|
182
|
+
const p2 = await patch(id, { column: "done" });
|
|
183
|
+
expect(p2.body.requirement.column).toBe("done");
|
|
184
|
+
expect((await get(id, AWARE)).body.requirement.column).toBe("abandoned");
|
|
185
|
+
// 旧客户端明确改回 pool:照办
|
|
186
|
+
expect((await patch(id, { column: "pool" })).body.requirement.column).toBe("pool");
|
|
187
|
+
// 新客户端从 abandoned 改成 done:真的完成
|
|
188
|
+
await patch(id, { column: "abandoned" }, AWARE);
|
|
189
|
+
const done = await patch(id, { column: "done" }, AWARE);
|
|
190
|
+
expect(done.body.requirement.column).toBe("done");
|
|
191
|
+
expect(done.body.requirement.completedAt).not.toBeNull();
|
|
192
|
+
}, 60_000);
|
|
193
|
+
|
|
194
|
+
test("events and last_event: undeclared callers see the projected column; done ↔ abandoned disappears for them", async () => {
|
|
195
|
+
const id = await card("ab-events");
|
|
196
|
+
await patch(id, { column: "abandoned" }, AWARE);
|
|
197
|
+
await patch(id, { column: "done" }, AWARE);
|
|
198
|
+
await patch(id, { column: "abandoned" }, AWARE);
|
|
199
|
+
const evs = async (headers: Record<string, string> = {}) => ((await send("GET", `/api/requirements/events?network_id=${NET}&requirement_id=${id}`, undefined, headers)).body.events as any[])
|
|
200
|
+
.filter(e => e.kind === "changed" && e.field === "column").map(e => [e.old, e.new]).reverse();
|
|
201
|
+
expect(await evs(AWARE)).toEqual([["pool", "abandoned"], ["abandoned", "done"], ["done", "abandoned"]]);
|
|
202
|
+
expect(await evs()).toEqual([["pool", "done"]]);
|
|
203
|
+
const legacyRaw = JSON.stringify((await send("GET", `/api/requirements/events?network_id=${NET}`)).body.events);
|
|
204
|
+
expect(legacyRaw).not.toContain("abandoned");
|
|
205
|
+
// 每行的 last_event:最新一条是 done → abandoned,旧客户端那里投影后前后一样 → 不给字段 / 摘要
|
|
206
|
+
const legacyRow = (await list()).body.requirements.find((r: any) => r.id === id);
|
|
207
|
+
expect(legacyRow.last_event.field).toBeNull();
|
|
208
|
+
expect(legacyRow.last_event.summary).toBeUndefined();
|
|
209
|
+
const awareRow = (await list("", AWARE)).body.requirements.find((r: any) => r.id === id);
|
|
210
|
+
expect(awareRow.last_event).toMatchObject({ field: "column", summary: "done → abandoned" });
|
|
211
|
+
await patch(id, { column: "doing" }, AWARE);
|
|
212
|
+
const back = (await list()).body.requirements.find((r: any) => r.id === id);
|
|
213
|
+
expect(back.last_event).toMatchObject({ field: "column", summary: "done → doing" });
|
|
214
|
+
expect(JSON.stringify((await list()).body.requirements)).not.toContain("abandoned");
|
|
215
|
+
}, 60_000);
|
|
216
|
+
|
|
217
|
+
test("checklist toggle response is projected for undeclared callers", async () => {
|
|
218
|
+
const id = await card("ab-check", { checklist: [{ id: "c1", text: "one" }] });
|
|
219
|
+
await patch(id, { column: "abandoned" }, AWARE);
|
|
220
|
+
const legacy = await send("PATCH", `/api/requirements/${id}/checklist/c1?network_id=${NET}`, { done: true });
|
|
221
|
+
expect(legacy.status).toBe(200);
|
|
222
|
+
expect(legacy.body.requirement.column).toBe("done");
|
|
223
|
+
const aware = await send("PATCH", `/api/requirements/${id}/checklist/c1?network_id=${NET}`, { done: false }, AWARE);
|
|
224
|
+
expect(aware.body.requirement.column).toBe("abandoned");
|
|
225
|
+
}, 60_000);
|
|
226
|
+
|
|
227
|
+
test("MCP declares support: requirements_update / get / list carry the real value", async () => {
|
|
228
|
+
const id = await card("ab-mcp");
|
|
229
|
+
const up = await mcp("requirements_update", { id, network_id: NET, status: "abandoned" });
|
|
230
|
+
expect(up.requirement.column).toBe("abandoned");
|
|
231
|
+
expect((await mcp("requirements_get", { id, network_id: NET })).requirement.column).toBe("abandoned");
|
|
232
|
+
const l = await mcp("requirements_list", { network_id: NET, status: "abandoned" });
|
|
233
|
+
expect(l.requirements.map((x: any) => x.id)).toContain(id);
|
|
234
|
+
expect((await mcp("requirements_update", { id, network_id: NET, column: "doing" })).requirement.column).toBe("doing");
|
|
235
|
+
}, 60_000);
|
|
236
|
+
});
|
|
@@ -54,7 +54,7 @@ const ENTRIES: Record<string, Entry> = {
|
|
|
54
54
|
participants_conflict: { field: 'participants', message: 'participants (replace the whole list) cannot be combined with participants_add / participants_remove.', hint: 'Use participants_add / participants_remove to change a few people, or participants alone to replace the list.' },
|
|
55
55
|
participants_add_remove_overlap: { field: 'participants_remove', message: 'The same person is in both participants_add and participants_remove.', hint: 'Send each person in only one of the two lists.' },
|
|
56
56
|
too_many_participants: { field: 'participants_add', message: 'A task can have at most 100 participants.', hint: 'Remove some with participants_remove first.' },
|
|
57
|
-
status_conflicts_with_column: { field: 'status', message: 'status is an alias of column, and the two values sent differ.', hint: 'Send only one of them (pool, doing or
|
|
57
|
+
status_conflicts_with_column: { field: 'status', message: 'status is an alias of column, and the two values sent differ.', hint: 'Send only one of them (pool, doing, done or abandoned).' },
|
|
58
58
|
invalid_assignee: { field: 'assignee', message: 'assignee must be a string (legacy free-text field).', hint: 'Prefer owner / agent_owner.' },
|
|
59
59
|
// ── 项目 ──
|
|
60
60
|
invalid_project: { field: 'project_id', message: 'project_id must be a project id string or null.', hint: 'Call projects_list to get project ids (proj_…); null removes the task from its project.' },
|
|
@@ -77,14 +77,14 @@ const ENTRIES: Record<string, Entry> = {
|
|
|
77
77
|
empty_patch: (ctx) => {
|
|
78
78
|
const sent = ctx.sent ?? [];
|
|
79
79
|
const writable = ctx.writable?.length ? ctx.writable.join(', ') : 'name, column, priority, due, start, description, checklist, tags, owner, agent_owner, participants, project_id, parent_id, external_ref, external_url, archived';
|
|
80
|
-
const statusHint = sent.includes('status') ? 'To move a task between pool / doing / done, use column (status is the name of the list filter). ' : '';
|
|
80
|
+
const statusHint = sent.includes('status') ? 'To move a task between pool / doing / done / abandoned, use column (status is the name of the list filter). ' : '';
|
|
81
81
|
return { message: sent.length ? `None of the keys sent (${sent.join(', ')}) is a writable field.` : 'The patch changes nothing.', hint: `${statusHint}Writable fields: ${writable}.` };
|
|
82
82
|
},
|
|
83
83
|
invalid_json: { message: 'The request body must be a JSON object.' },
|
|
84
84
|
invalid_name: { field: 'name', message: 'name must be 1–80 characters.' },
|
|
85
85
|
invalid_description: { field: 'description', message: 'description must be a string (markdown) of at most 20 000 characters.' },
|
|
86
|
-
invalid_column: { field: 'column', message: 'column must be pool, doing or
|
|
87
|
-
invalid_status: { field: 'status', message: 'status (list filter) must be pool, doing or
|
|
86
|
+
invalid_column: { field: 'column', message: 'column must be pool, doing, done or abandoned.' },
|
|
87
|
+
invalid_status: { field: 'status', message: 'status (list filter) must be pool, doing, done or abandoned.' },
|
|
88
88
|
invalid_priority: { field: 'priority', message: 'priority must be high (P0), normal (P1), low (P2) or lowest (P3).' },
|
|
89
89
|
invalid_due: { field: 'due', message: 'due is not a date this hub understands.', hint: `Use ${DUE_FORMATS}.` },
|
|
90
90
|
invalid_start: { field: 'start', message: 'start is not a date this hub understands.', hint: `Use ${DUE_FORMATS}.` },
|
|
@@ -373,7 +373,7 @@ describe("requirements stay on the hub", () => {
|
|
|
373
373
|
expect((await api(ownerToken, path, { method: "PATCH", body: JSON.stringify({ due: "" }) })).body.requirement.due).toBe("");
|
|
374
374
|
const listed = await api(ownerToken, "/api/requirements");
|
|
375
375
|
expect(listed.body.capabilities).toContain("due_datetime");
|
|
376
|
-
expect(listed.body.capabilities).toEqual(["agent_owner", "description", "checklist", "projects", "due_datetime", "external_ref", "archived", "agent_api", "sub_requirements", "tags", "priority_lowest", "start_date", "requirement_seq", "search", "paging", "completed_at", "stats", "tag_ops", "list_summary", "changes", "events", "last_event"]);
|
|
376
|
+
expect(listed.body.capabilities).toEqual(["agent_owner", "description", "checklist", "projects", "due_datetime", "external_ref", "archived", "agent_api", "sub_requirements", "tags", "priority_lowest", "start_date", "requirement_seq", "search", "paging", "completed_at", "stats", "tag_ops", "list_summary", "changes", "events", "last_event", "column_abandoned"]);
|
|
377
377
|
});
|
|
378
378
|
|
|
379
379
|
test("sub-requirements: parent_id in the same network, no cycles, ≤ 5 levels, child counts, filters, delete detaches children", async () => {
|
|
@@ -125,8 +125,8 @@ describe("view=summary", () => {
|
|
|
125
125
|
const one = await send(admin.token, "GET", `/api/requirements/${card.id}?network_id=${NET}`);
|
|
126
126
|
expect(one.body.requirement.description).toBe(f.description);
|
|
127
127
|
// 比的是 summary 省掉的那部分(正文 / 子任务):两边一样多的 last_event(#506,每行约 150 B)不算,
|
|
128
|
-
// 否则这张只有几行的小表上,固定开销会把比例拉过一半。
|
|
129
|
-
const bare = (body: any) => JSON.stringify({ ...body, requirements: body.requirements.map(({ last_event: _l, ...r }: any) => r) }).length;
|
|
128
|
+
// 否则这张只有几行的小表上,固定开销会把比例拉过一半。capabilities 同理:两边一样、每加一个能力就长一截。
|
|
129
|
+
const bare = (body: any) => JSON.stringify({ ...body, capabilities: [], requirements: body.requirements.map(({ last_event: _l, ...r }: any) => r) }).length;
|
|
130
130
|
expect(summary.body.requirements.every((r: any) => "last_event" in r)).toBe(true);
|
|
131
131
|
expect(bare(summary.body)).toBeLessThan(bare(full.body) / 2);
|
|
132
132
|
});
|
|
@@ -119,6 +119,40 @@ export function migrateRequirementPriorityCheck(database: DbAdapter): { rebuilt:
|
|
|
119
119
|
return { rebuilt: true };
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
/**
|
|
123
|
+
* 状态加 abandoned(废弃)。同 migrateRequirementPriorityCheck:旧库的 CHECK(column_name IN ('pool', 'doing', 'done'))
|
|
124
|
+
* SQLite 不能就地改,只重建一次;PostgreSQL 换掉默认名的列约束。已经放开的库什么都不做。存量值不改。
|
|
125
|
+
*/
|
|
126
|
+
const OLD_COLUMN_CHECK = /CHECK\s*\(\s*column_name\s+IN\s*\(\s*'pool'\s*,\s*'doing'\s*,\s*'done'\s*\)\s*\)/i;
|
|
127
|
+
export const COLUMN_CHECK = "CHECK(column_name IN ('pool', 'doing', 'done', 'abandoned'))";
|
|
128
|
+
export function migrateRequirementColumnCheck(database: DbAdapter): { rebuilt: boolean } {
|
|
129
|
+
if (database.dialect === "postgres") {
|
|
130
|
+
// 只在约束还不认识 abandoned(或不存在)时动;一条 ALTER TABLE 里先删后加 = 一个语句,原子,
|
|
131
|
+
// 失败(比如存量里有不合法的值)整条回滚、旧约束原样留着,并且直接抛出 —— 不吞错,启动就该停下来。
|
|
132
|
+
const def = database.get<{ def: string }>(
|
|
133
|
+
"SELECT pg_get_constraintdef(c.oid) AS def FROM pg_constraint c JOIN pg_class t ON t.oid = c.conrelid WHERE t.relname = 'requirements' AND c.conname = 'requirements_column_name_check'",
|
|
134
|
+
)?.def ?? "";
|
|
135
|
+
if (def.includes("'abandoned'")) return { rebuilt: false };
|
|
136
|
+
database.exec(`ALTER TABLE requirements DROP CONSTRAINT IF EXISTS requirements_column_name_check, ADD CONSTRAINT requirements_column_name_check ${COLUMN_CHECK}`);
|
|
137
|
+
return { rebuilt: true };
|
|
138
|
+
}
|
|
139
|
+
const table = database.get<{ sql: string }>("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'requirements'");
|
|
140
|
+
if (!table?.sql || !OLD_COLUMN_CHECK.test(table.sql)) return { rebuilt: false };
|
|
141
|
+
const indexes = database.all<{ sql: string }>("SELECT sql FROM sqlite_master WHERE type = 'index' AND tbl_name = 'requirements' AND sql IS NOT NULL");
|
|
142
|
+
const createSql = table.sql
|
|
143
|
+
.replace(/^CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?requirements["`]?/i, "CREATE TABLE requirements_migrated")
|
|
144
|
+
.replace(OLD_COLUMN_CHECK, COLUMN_CHECK);
|
|
145
|
+
database.transaction(() => {
|
|
146
|
+
database.exec("DROP TABLE IF EXISTS requirements_migrated");
|
|
147
|
+
database.exec(createSql);
|
|
148
|
+
database.exec("INSERT INTO requirements_migrated SELECT * FROM requirements");
|
|
149
|
+
database.exec("DROP TABLE requirements");
|
|
150
|
+
database.exec("ALTER TABLE requirements_migrated RENAME TO requirements");
|
|
151
|
+
for (const index of indexes) database.exec(index.sql);
|
|
152
|
+
});
|
|
153
|
+
return { rebuilt: true };
|
|
154
|
+
}
|
|
155
|
+
|
|
122
156
|
/**
|
|
123
157
|
* 任务短号 seq:每个网络自己的 #1、#2…,界面显示和按 #N 查都用它。主键 requirement_id 不变。
|
|
124
158
|
* - 加可空列 seq;没有号的旧行按 created_at(再按 requirement_id)补号,接在该网络已有的最大号后面。
|
|
@@ -68,7 +68,7 @@ describe("P3 lowest priority", () => {
|
|
|
68
68
|
test("startup rebuilds the legacy CHECK once and keeps every row and index", async () => {
|
|
69
69
|
const sql = db.get("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'requirements'").sql as string;
|
|
70
70
|
expect(sql).toContain("'lowest'");
|
|
71
|
-
expect(sql).toContain("CHECK(column_name IN ('pool', 'doing', 'done'))");
|
|
71
|
+
expect(sql).toContain("CHECK(column_name IN ('pool', 'doing', 'done', 'abandoned'))");
|
|
72
72
|
const rows = db.all("SELECT requirement_id, network_id, title, priority, client_id, description, tags_json FROM requirements WHERE network_id = 'net_legacy' ORDER BY requirement_id") as any[];
|
|
73
73
|
expect(rows.map(r => [r.requirement_id, r.priority])).toEqual([["r_high", "high"], ["r_low", "low"], ["r_normal", "normal"]]);
|
|
74
74
|
for (const r of rows) {
|
|
@@ -93,7 +93,7 @@ export function aggregateStats(rows: StatsRow[], q: StatsQuery, hidden: ((nodeId
|
|
|
93
93
|
const sparkDays = new Set(days.slice(-STATS_SPARK_DAYS));
|
|
94
94
|
const daily = new Map(days.map(d => [d, 0]));
|
|
95
95
|
|
|
96
|
-
let done = 0, approx = 0, created = 0, createdDone = 0, doing = 0, pool = 0, unattributed = 0;
|
|
96
|
+
let done = 0, approx = 0, created = 0, createdDone = 0, doing = 0, pool = 0, abandoned = 0, unattributed = 0;
|
|
97
97
|
const byProject = new Map<string | null, number>();
|
|
98
98
|
const byCompleter = new Map<string, { ref: Ref; n: number; spark: Map<string, number> }>();
|
|
99
99
|
const recent: { row: StatsRow; ms: number; by: Ref | null }[] = [];
|
|
@@ -102,6 +102,7 @@ export function aggregateStats(rows: StatsRow[], q: StatsQuery, hidden: ((nodeId
|
|
|
102
102
|
// 当前还开着的卡(不看时间范围;归档的不算「进行中」)。
|
|
103
103
|
if (!row.archived && row.column_name === "doing") doing++;
|
|
104
104
|
if (!row.archived && row.column_name === "pool") pool++;
|
|
105
|
+
if (!row.archived && row.column_name === "abandoned") abandoned++;
|
|
105
106
|
const completedMs = row.column_name === "done" ? msOf(row.completed_at) : null;
|
|
106
107
|
const createdMs = msOf(row.created_at);
|
|
107
108
|
if (inRange(createdMs)) {
|
|
@@ -143,6 +144,8 @@ export function aggregateStats(rows: StatsRow[], q: StatsQuery, hidden: ((nodeId
|
|
|
143
144
|
completion_rate: created ? createdDone / created : null,
|
|
144
145
|
doing,
|
|
145
146
|
pool,
|
|
147
|
+
// 废弃(关闭,不算开着也不算完成)。旧 App 忽略这个字段。
|
|
148
|
+
abandoned,
|
|
146
149
|
},
|
|
147
150
|
daily: days.map(date => ({ date, n: daily.get(date) ?? 0 })),
|
|
148
151
|
by_project: [...byProject].map(([project_id, n]) => ({ project_id, n })).sort((a, b) => b.n - a.n || String(a.project_id).localeCompare(String(b.project_id))),
|
package/src/requirements.ts
CHANGED
|
@@ -20,11 +20,12 @@ import { notifyLeaderDeleted, notifyParticipantChange } from "./requirement-noti
|
|
|
20
20
|
import { departmentCardsFor, departmentSubtree } from "./department-heads.js";
|
|
21
21
|
import { dueReminderTimezone, dueWithinSql, ensureDueReminders, overdueSql } from "./requirement-due-reminders.js";
|
|
22
22
|
import { diffRequirement, ensureRequirementEvents, eventPublic, recordRequirementEvents, type EventRow } from "./requirement-events.js";
|
|
23
|
-
import { ensureNetworkTags, ensureRequirementCompletedAt, ensureRequirementIndexes, ensureRequirementProjects, ensureRequirementSeq, ensureRequirementTombstones, migrateRequirementAgentOwners, migrateRequirementPriorityCheck, nextRequirementSeq } from "./requirements-migrate.js";
|
|
23
|
+
import { ensureNetworkTags, ensureRequirementCompletedAt, ensureRequirementIndexes, ensureRequirementProjects, ensureRequirementSeq, ensureRequirementTombstones, migrateRequirementAgentOwners, migrateRequirementColumnCheck, migrateRequirementPriorityCheck, nextRequirementSeq } from "./requirements-migrate.js";
|
|
24
24
|
|
|
25
25
|
// 启动迁移:旧库里节点当负责人的卡,节点挪到 agent_owner(列由 db.ts 的加列循环加上)。
|
|
26
26
|
// 放在这里而不是 db.ts:db.ts 每多一行,文档里钉着的行号就漂一次。
|
|
27
27
|
migrateRequirementPriorityCheck(db);
|
|
28
|
+
migrateRequirementColumnCheck(db);
|
|
28
29
|
migrateRequirementAgentOwners(db);
|
|
29
30
|
ensureRequirementProjects(db);
|
|
30
31
|
ensureRequirementIndexes(db);
|
|
@@ -52,7 +53,36 @@ export type RequirementsRequestContext = {
|
|
|
52
53
|
strictOwner?: boolean;
|
|
53
54
|
};
|
|
54
55
|
|
|
55
|
-
const COLUMNS = new Set(["pool", "doing", "done"]);
|
|
56
|
+
const COLUMNS = new Set(["pool", "doing", "done", "abandoned"]);
|
|
57
|
+
/**
|
|
58
|
+
* 废弃(abandoned):和 done 一样是关闭态 —— 不算开着、不算逾期、不发到期提醒,不记完成时间;能改回 pool / doing。
|
|
59
|
+
* 旧客户端(desktop ≤ 0.2.220)只认 pool / doing / done,不认识的值一律当成 pool(会把废弃的卡摆回需求池、还标逾期)。
|
|
60
|
+
* 所以客户端没声明认识它时,响应里把 abandoned 投影成 done;声明方式二选一:
|
|
61
|
+
* 请求头 `X-Anet-Accept-Columns: abandoned`,或查询参数 `accept_columns=abandoned`。
|
|
62
|
+
* MCP(tools.ts)总是声明。文档:docs-site/docs/api/mcp-tools.md「需求池 / 任务看板」的 column 字段。
|
|
63
|
+
*/
|
|
64
|
+
const ACCEPT_COLUMNS_HEADER = "x-anet-accept-columns";
|
|
65
|
+
function knowsAbandoned(ctx: RequirementsRequestContext): boolean {
|
|
66
|
+
const raw = `${ctx.req.headers.get(ACCEPT_COLUMNS_HEADER) ?? ""},${ctx.url.searchParams.get("accept_columns") ?? ""}`;
|
|
67
|
+
return raw.split(",").some(v => v.trim() === "abandoned");
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* 动态的旧客户端视图:column 改动的 old / new、created 的 new.column 都投影;投影后前后一样(done ↔ abandoned)= null(不回)。
|
|
71
|
+
*/
|
|
72
|
+
function projectEvent<T extends { kind: string; field: string | null; old: unknown; new: unknown }>(ev: T, aware: boolean): T | null {
|
|
73
|
+
if (aware) return ev;
|
|
74
|
+
if (ev.kind === "created" && ev.new && typeof ev.new === "object" && typeof (ev.new as any).column === "string") {
|
|
75
|
+
return { ...ev, new: { ...(ev.new as object), column: projectColumn((ev.new as any).column, false) } };
|
|
76
|
+
}
|
|
77
|
+
if (ev.field !== "column") return ev;
|
|
78
|
+
const old = typeof ev.old === "string" ? projectColumn(ev.old, false) : ev.old;
|
|
79
|
+
const neu = typeof ev.new === "string" ? projectColumn(ev.new, false) : ev.new;
|
|
80
|
+
return old === neu ? null : { ...ev, old, new: neu };
|
|
81
|
+
}
|
|
82
|
+
/** 旧客户端看到的状态:abandoned → done,其余原样。 */
|
|
83
|
+
export function projectColumn(column: string, aware: boolean): string {
|
|
84
|
+
return column === "abandoned" && !aware ? "done" : column;
|
|
85
|
+
}
|
|
56
86
|
// 界面上 P0–P3:high=P0 最高、normal=P1 普通、low=P2 低、lowest=P3 极低。存的值不变,旧客户端照常读写前三个。
|
|
57
87
|
const PRIORITIES = new Set(["high", "normal", "low", "lowest"]);
|
|
58
88
|
const DUE = /^\d{4}-\d{2}-\d{2}$/;
|
|
@@ -73,6 +103,7 @@ type Row = {
|
|
|
73
103
|
parent_id: string | null;
|
|
74
104
|
children_total: number | null;
|
|
75
105
|
children_done: number | null;
|
|
106
|
+
children_abandoned?: number | null;
|
|
76
107
|
participants_json: string;
|
|
77
108
|
requirement_id: string;
|
|
78
109
|
network_id: string;
|
|
@@ -511,7 +542,8 @@ function toPublic(row: Row) {
|
|
|
511
542
|
updated_by: row.updated_by_json ? JSON.parse(row.updated_by_json) : null,
|
|
512
543
|
// 子需求:parent_id(可空)和父卡上的子需求进度(未归档的子需求数 / 其中完成的)。
|
|
513
544
|
parent_id: row.parent_id || null,
|
|
514
|
-
|
|
545
|
+
// 废弃的子需求不算进度(total 里减掉);旧客户端眼里它是 done,见 toPublicFor。
|
|
546
|
+
children: { total: Number(row.children_total ?? 0) - Number(row.children_abandoned ?? 0), done: Number(row.children_done ?? 0) },
|
|
515
547
|
// 完成时间:进「完成」列的时刻,移出清空(不在「完成」列 = null)。completedAtApprox = 升级前就完成的卡,
|
|
516
548
|
// 时刻是按 updated_at 补的近似值。completedBy = 谁移进「完成」的(近似值的卡为 null)。旧 App 忽略这三个字段。
|
|
517
549
|
completedAt: row.completed_at || null,
|
|
@@ -563,7 +595,14 @@ function toPublicFor(ctx: RequirementsRequestContext, row: Row) {
|
|
|
563
595
|
if (!perms) permsByCtx.set(ctx, perms = taskPermissionsResolver(taskCaller(ctx)));
|
|
564
596
|
// viewer_can:只对「只看相关任务」的调用者出现,客户端据此画只读锁、藏删除;不出现 = 与今天一样全能(旧 Hub 也不出现)。
|
|
565
597
|
const can = perms(row);
|
|
566
|
-
const
|
|
598
|
+
const base = toPublic(row);
|
|
599
|
+
if (!knowsAbandoned(ctx)) {
|
|
600
|
+
base.column = projectColumn(base.column, false);
|
|
601
|
+
// 旧客户端把废弃的子需求当 done 列出来:进度也按「算进总数、算作完成」给,和它看到的子需求列表对得上。
|
|
602
|
+
const abandoned = Number(row.children_abandoned ?? 0);
|
|
603
|
+
base.children = { total: base.children.total + abandoned, done: base.children.done + abandoned };
|
|
604
|
+
}
|
|
605
|
+
const pub = can ? { ...base, viewer_can: can } : base;
|
|
567
606
|
const hidden = hiddenNodeFilter(ctx, row.network_id);
|
|
568
607
|
if (!hidden) return pub;
|
|
569
608
|
return {
|
|
@@ -579,6 +618,7 @@ function toPublicFor(ctx: RequirementsRequestContext, row: Row) {
|
|
|
579
618
|
|
|
580
619
|
const SELECT = "requirement_id, network_id, title, column_name, priority, due_on, assignee, issues_json, tags_json, created_at, owner_json, participants_json, agent_owner_json, description, checklist_json, project_id, external_ref, external_url, archived, created_by, created_by_json, updated_by_json, updated_at, parent_id, start_on, seq, completed_at, completed_by_json, completed_at_approx, " +
|
|
581
620
|
"(SELECT COUNT(*) FROM requirements c WHERE c.parent_id = requirements.requirement_id AND COALESCE(c.archived, 0) = 0) AS children_total, " +
|
|
621
|
+
"(SELECT COUNT(*) FROM requirements c WHERE c.parent_id = requirements.requirement_id AND COALESCE(c.archived, 0) = 0 AND c.column_name = 'abandoned') AS children_abandoned, " +
|
|
582
622
|
"(SELECT COUNT(*) FROM requirements c WHERE c.parent_id = requirements.requirement_id AND COALESCE(c.archived, 0) = 0 AND c.column_name = 'done') AS children_done";
|
|
583
623
|
|
|
584
624
|
type PersonRef = { kind: 'user' | 'node'; id: string };
|
|
@@ -730,7 +770,7 @@ function ifNoneMatchHits(header: string | null, etag: string): boolean {
|
|
|
730
770
|
// list_summary:GET 认 view=summary(不带描述正文与子任务条目,见 toSummary);changes:GET 认 changes=1 + updated_since
|
|
731
771
|
// (改过的卡含归档的,加上 deleted 墓碑与 server_time,见 listChanges)。
|
|
732
772
|
// events:GET /api/requirements/events —— 字段级的改动流水(谁、何时、旧值 → 新值,requirement-events.ts)。
|
|
733
|
-
export const REQUIREMENT_CAPABILITIES = ["agent_owner", "description", "checklist", "projects", "due_datetime", "external_ref", "archived", "agent_api", "sub_requirements", "tags", "priority_lowest", "start_date", "requirement_seq", "search", "paging", "completed_at", "stats", "tag_ops", "list_summary", "changes", "events", "last_event"] as const;
|
|
773
|
+
export const REQUIREMENT_CAPABILITIES = ["agent_owner", "description", "checklist", "projects", "due_datetime", "external_ref", "archived", "agent_api", "sub_requirements", "tags", "priority_lowest", "start_date", "requirement_seq", "search", "paging", "completed_at", "stats", "tag_ops", "list_summary", "changes", "events", "last_event", "column_abandoned"] as const;
|
|
734
774
|
|
|
735
775
|
// ── 子需求 ──
|
|
736
776
|
// parent_id:同一网络里的另一张卡;不能成环;最多 5 层(顶层是第 1 层)。删父卡 = 子卡保留、parent_id 置空。
|
|
@@ -1017,7 +1057,10 @@ function patchRequirement(ctx: RequirementsRequestContext, row: Row, body: Recor
|
|
|
1017
1057
|
archived = body.archived ? 1 : 0;
|
|
1018
1058
|
}
|
|
1019
1059
|
const updatedAt = new Date().toISOString();
|
|
1020
|
-
|
|
1060
|
+
// 旧客户端眼里废弃的卡是 done:它回传 column=done 不是「改成完成」,保持废弃。
|
|
1061
|
+
const column = !has("column") ? row.column_name
|
|
1062
|
+
: row.column_name === "abandoned" && body.column === "done" && !knowsAbandoned(ctx) ? "abandoned"
|
|
1063
|
+
: String(body.column);
|
|
1021
1064
|
const actor = JSON.stringify(actorOf(ctx));
|
|
1022
1065
|
// 完成时间:移进「完成」= 此刻 + 这次的操作者;移出 = 清空;留在「完成」(done → done、改别的字段)= 原样不动。
|
|
1023
1066
|
const completed = column !== "done" ? { at: null, by: null, approx: 0 }
|
|
@@ -1083,7 +1126,9 @@ function listFilters(url: URL, sql: string, params: unknown[], ctx?: Requirement
|
|
|
1083
1126
|
const status = q.get("status");
|
|
1084
1127
|
if (status !== null) {
|
|
1085
1128
|
if (!COLUMNS.has(status)) return jsonError("invalid_status", 400);
|
|
1086
|
-
|
|
1129
|
+
// 旧客户端把废弃的卡看成 done:它按 done 筛时也要带上废弃的,否则同一张卡在列表里有、筛出来没有。
|
|
1130
|
+
if (status === "done" && ctx && !knowsAbandoned(ctx)) sql += " AND column_name IN ('done', 'abandoned')";
|
|
1131
|
+
else sql += ` AND column_name = ?${params.push(status)}`;
|
|
1087
1132
|
}
|
|
1088
1133
|
const project = q.get("project_id");
|
|
1089
1134
|
if (project !== null) sql += project === "none" ? " AND project_id IS NULL" : ` AND project_id = ?${params.push(project)}`;
|
|
@@ -1319,7 +1364,9 @@ function lastEventsFor(ctx: RequirementsRequestContext, rows: readonly Row[]): M
|
|
|
1319
1364
|
...ids,
|
|
1320
1365
|
);
|
|
1321
1366
|
if (!events.length) return out;
|
|
1322
|
-
const
|
|
1367
|
+
const aware = knowsAbandoned(ctx);
|
|
1368
|
+
// 投影后成了 done → done 的那条:当作没有可展示的字段(同下面隐去节点的处理)。
|
|
1369
|
+
const pubs = events.map(e => { const ev = eventPublic(e); return projectEvent(ev, aware) ?? { ...ev, field: null, old: null, new: null }; });
|
|
1323
1370
|
const userIds = new Set<string>(), nodeIds = new Set<string>();
|
|
1324
1371
|
for (const ev of pubs) if (isRef(ev.actor)) (ev.actor.kind === "user" ? userIds : nodeIds).add(ev.actor.id);
|
|
1325
1372
|
// 名字规则同 GET /api/requirements/people 的 name(没设显示名 → 用户名 / alias / node_name)。
|
|
@@ -1384,6 +1431,7 @@ function listEvents(ctx: RequirementsRequestContext): Response {
|
|
|
1384
1431
|
const serverTime = new Date().toISOString();
|
|
1385
1432
|
const caller = taskCaller(ctx);
|
|
1386
1433
|
const hidden = hiddenNodeFilter(ctx, networkId);
|
|
1434
|
+
const aware = knowsAbandoned(ctx);
|
|
1387
1435
|
const seen = new Map<string, boolean>();
|
|
1388
1436
|
const canSee = (id: string): boolean => {
|
|
1389
1437
|
if (!caller) return true;
|
|
@@ -1399,7 +1447,8 @@ function listEvents(ctx: RequirementsRequestContext): Response {
|
|
|
1399
1447
|
};
|
|
1400
1448
|
const shown = (row: EventRow) => {
|
|
1401
1449
|
if (!canSee(row.requirement_id)) return null;
|
|
1402
|
-
const ev = eventPublic(row);
|
|
1450
|
+
const ev = projectEvent(eventPublic(row), aware);
|
|
1451
|
+
if (!ev) return null;
|
|
1403
1452
|
if (!hidden) return ev;
|
|
1404
1453
|
const mask = (ref: unknown) => (isHiddenRef(ref, hidden) ? null : ref);
|
|
1405
1454
|
const masked = { ...ev, actor: mask(ev.actor) };
|
|
@@ -1479,7 +1528,7 @@ function listCacheAcl(ctx: RequirementsRequestContext): string | null {
|
|
|
1479
1528
|
|
|
1480
1529
|
function listCacheKey(ctx: RequirementsRequestContext): string {
|
|
1481
1530
|
const sorted = [...ctx.url.searchParams.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
1482
|
-
return JSON.stringify({ u: ctx.auth?.userId ?? null, n: ctx.auth?.networkId ?? null, a: ctx.isAdmin, t: ctx.isNodeToken, s: ctx.scope, q: sorted });
|
|
1531
|
+
return JSON.stringify({ u: ctx.auth?.userId ?? null, n: ctx.auth?.networkId ?? null, a: ctx.isAdmin, t: ctx.isNodeToken, s: ctx.scope, q: sorted, c: knowsAbandoned(ctx) });
|
|
1483
1532
|
}
|
|
1484
1533
|
|
|
1485
1534
|
/** Test-only. */
|
package/src/server.ts
CHANGED
|
@@ -39,7 +39,7 @@ import { broadcastVerdict, dispatchVerdict, humanOnlyVerdict, NODE_PERMISSION_MO
|
|
|
39
39
|
import { validateAvatarUrl } from "./avatar-validate.js";
|
|
40
40
|
import { narrowTags, parseStoredTags, validateScalarAttr } from "./node-attrs-validate.js";
|
|
41
41
|
import { daemonDefaultWorkdirRoot } from "./create-node-validate.js";
|
|
42
|
-
import { register, login, resolveToken, tokenRejection, isExpiredSessionToken, sessionIdleDays, listSessions, revokeSession, revokeOtherSessions, getUserNetworks, getUserAllNetworks, createNetwork, deleteNetwork, adminDeleteEmptyNetwork, renameNetwork, changePassword, issueUserToken, listTokens, createToken, revokeToken, getNetworkMembers, getNetworkHumans, getUserNetworkRole, addNetworkMember, adminCreateUser, listUsersWithMemberships, updateMemberRole, removeNetworkMember, createInvite, joinByInvite, createNetworkTokenForNode, type AuthUser } from "./auth.js";
|
|
42
|
+
import { register, login, resolveToken, tokenRejection, isExpiredSessionToken, sessionIdleDays, listSessions, revokeSession, revokeOtherSessions, getUserNetworks, getUserAllNetworks, createNetwork, deleteNetwork, adminDeleteEmptyNetwork, renameNetwork, changePassword, issueUserToken, getUserTokenMeta, listTokens, createToken, revokeToken, getNetworkMembers, getNetworkHumans, getUserNetworkRole, addNetworkMember, adminCreateUser, listUsersWithMemberships, updateMemberRole, removeNetworkMember, createInvite, joinByInvite, createNetworkTokenForNode, type AuthUser } from "./auth.js";
|
|
43
43
|
import { abortRename, cleanupCommittedRenameSessions, commitRename, prepareRename, resolveCanonicalAlias } from "./rename.js";
|
|
44
44
|
import { sharedSendDedup, buildDuplicateSendPayload } from "./send_dedup.js";
|
|
45
45
|
import { clientRequestIdFromMeta, idempotentTaskId, idempotentTaskMatches, type StoredIdempotentTask } from "./task-idempotency.js";
|
|
@@ -1376,7 +1376,7 @@ return Bun.serve({
|
|
|
1376
1376
|
}
|
|
1377
1377
|
try {
|
|
1378
1378
|
const body = await req.json() as any;
|
|
1379
|
-
const result = register(body.username, body.password, body.email, body.display_name, { client: { label: body.client_label, userAgent: req.headers.get("user-agent") } });
|
|
1379
|
+
const result = register(body.username, body.password, body.email, body.display_name, { client: { label: body.client_label, userAgent: req.headers.get("user-agent"), kind: body.client_kind } });
|
|
1380
1380
|
if (result.ok) logAudit(result.user!.user_id, body.username, "register", "user", result.user!.user_id);
|
|
1381
1381
|
return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
|
|
1382
1382
|
} catch (e: any) {
|
|
@@ -1404,7 +1404,7 @@ return Bun.serve({
|
|
|
1404
1404
|
{ status: 429, headers: { "Retry-After": String(Math.ceil((lock.retryAfterMs ?? 1000) / 1000)) } }
|
|
1405
1405
|
));
|
|
1406
1406
|
}
|
|
1407
|
-
const result = login(body.username, body.password, { label: body.client_label, userAgent: req.headers.get("user-agent") });
|
|
1407
|
+
const result = login(body.username, body.password, { label: body.client_label, userAgent: req.headers.get("user-agent"), kind: body.client_kind });
|
|
1408
1408
|
if (result.ok) {
|
|
1409
1409
|
sharedLoginFailureLockout.recordSuccess(body.username);
|
|
1410
1410
|
logAudit(result.user!.user_id, body.username, "login", "user", result.user!.user_id);
|
|
@@ -1473,9 +1473,13 @@ return Bun.serve({
|
|
|
1473
1473
|
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1474
1474
|
try {
|
|
1475
1475
|
const body = await req.json() as any;
|
|
1476
|
-
|
|
1476
|
+
// #711 不带 keep_cli_tokens 的请求(旧 app、dashboard 代理)= 改密码前的行为:撤销全部不绑网络的令牌。
|
|
1477
|
+
// keep_cli_tokens 必须是布尔 true 才保留命令行 / 脚本令牌。
|
|
1478
|
+
const meta = resolved.tokenId ? getUserTokenMeta(resolved.user.user_id, resolved.tokenId) : null;
|
|
1479
|
+
const result = changePassword(resolved.user.user_id, body.old_password, body.new_password, resolved.tokenId, { keepCliTokens: body.keep_cli_tokens === true });
|
|
1477
1480
|
if (result.ok) {
|
|
1478
|
-
|
|
1481
|
+
// 换发的新令牌沿用当前这条的种类和设备标识:anet passwd 换完仍是命令行令牌。
|
|
1482
|
+
const issued = issueUserToken(resolved.user.user_id, "password-change", { kind: meta?.kind, clientLabel: meta?.client_label, userAgent: meta?.user_agent });
|
|
1479
1483
|
if (resolved.tokenId) revokeToken(resolved.user.user_id, resolved.tokenId);
|
|
1480
1484
|
logAudit(resolved.user.user_id, resolved.user.username, "password_changed", "user", resolved.user.user_id);
|
|
1481
1485
|
return withCors(req, Response.json({ ...result, token: issued.token, token_id: issued.token_id }));
|
package/src/tools.ts
CHANGED
|
@@ -3684,7 +3684,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3684
3684
|
|
|
3685
3685
|
server.tool(
|
|
3686
3686
|
"list_node_skills",
|
|
3687
|
-
"Ask a node to list the skills its runtime loads (name, scope project|user|system, display path, frontmatter description). Read-only; no path argument. Poll get_rules_file_result — content is JSON {skills:[…]}.",
|
|
3687
|
+
"Ask a node to list the skills its runtime loads (name, scope project|user|system, display path, frontmatter description; team skills also include origin \"team\"). Read-only; no path argument. Poll get_rules_file_result — content is JSON {skills:[…], roots, warnings}.",
|
|
3688
3688
|
{
|
|
3689
3689
|
...NODE_ID_ALIAS_FIELDS,
|
|
3690
3690
|
alias: z.string().min(1).max(200).optional().describe("Alias instead of node_id (a node, or a session reporting skills_capable)."),
|
|
@@ -3695,7 +3695,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3695
3695
|
|
|
3696
3696
|
server.tool(
|
|
3697
3697
|
"read_node_skill",
|
|
3698
|
-
"Ask a node for one skill's SKILL.md by name. Read-only; the node resolves the name under its own runtime's skills roots — no path argument. Poll get_rules_file_result — content is JSON {name, scope, path_rel, description, content}.",
|
|
3698
|
+
"Ask a node for one skill's SKILL.md by name. Read-only; the node resolves the name under its own runtime's skills roots — no path argument. Poll get_rules_file_result — content is JSON {name, scope, path_rel, description, content, origin?}. origin is \"team\" only for a team skill.",
|
|
3699
3699
|
{
|
|
3700
3700
|
...NODE_ID_ALIAS_FIELDS,
|
|
3701
3701
|
alias: z.string().min(1).max(200).optional().describe("Alias instead of node_id (a node, or a session reporting skills_capable)."),
|
|
@@ -5993,7 +5993,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
5993
5993
|
const scope = resolveRestNetworkScope(enforceNetworkId ? null : (clientNetId ?? null), mcpAuthCtx, false);
|
|
5994
5994
|
if (scope.denied) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "access_denied", message: scope.denied }) }] };
|
|
5995
5995
|
const url = new URL(`http://mcp.internal${path}`);
|
|
5996
|
-
const req = new Request(url, { method, headers: { "content-type": "application/json" }, body: body ? JSON.stringify(body) : undefined });
|
|
5996
|
+
const req = new Request(url, { method, headers: { "content-type": "application/json", "x-anet-accept-columns": "abandoned" }, body: body ? JSON.stringify(body) : undefined });
|
|
5997
5997
|
const res = await handleRequirementsRequest({
|
|
5998
5998
|
req, url,
|
|
5999
5999
|
auth: mcpAuthCtx ? { ...mcpAuthCtx, username: "", tokenId: callerTokenId ?? null, tokenName: callerTokenIsNetwork && callerAlias ? `node:${callerAlias}` : null } : null,
|
|
@@ -6021,7 +6021,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
6021
6021
|
tags: z.array(z.string()).max(10).optional().describe("Task labels; each 1–20 Unicode characters. Omit to preserve, [] to clear."),
|
|
6022
6022
|
name: z.string().min(1).max(80).optional(),
|
|
6023
6023
|
priority: z.enum(["high", "normal", "low", "lowest"]).optional().describe("high = P0 最高, normal = P1 普通, low = P2 低, lowest = P3 极低"),
|
|
6024
|
-
column: z.enum(["pool", "doing", "done"]).optional(),
|
|
6024
|
+
column: z.enum(["pool", "doing", "done", "abandoned"]).optional(),
|
|
6025
6025
|
due: z.string().max(40).optional().describe("YYYY-MM-DD (all day) or ISO 8601 with Z / ±HH:MM (stored as UTC seconds); \"\" clears"),
|
|
6026
6026
|
start: z.string().max(40).optional().describe("start date (Gantt): same shapes as due; \"\" clears"),
|
|
6027
6027
|
description: z.string().max(20_000).optional().describe("markdown"),
|
|
@@ -6037,7 +6037,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
6037
6037
|
const REQ_WRITE_KEYS = [...Object.keys(reqFields), "external_ref", "archived"];
|
|
6038
6038
|
// #475 —— status 是 column 的别名(列表的筛选参数就叫 status,Agent 写任务时也常这么写)。只在 MCP 这层换名,REST 不变;
|
|
6039
6039
|
// 两个都给且不同 → 400 status_conflicts_with_column,不替调用方挑一个。
|
|
6040
|
-
const reqStatus = z.enum(["pool", "doing", "done"]).optional().describe("alias of column; must match it if both are sent");
|
|
6040
|
+
const reqStatus = z.enum(["pool", "doing", "done", "abandoned"]).optional().describe("alias of column; must match it if both are sent");
|
|
6041
6041
|
const withColumn = (args: Record<string, unknown>, call: (args: Record<string, unknown>) => Promise<{ content: { type: "text"; text: string }[] }>) => {
|
|
6042
6042
|
if (args.status === undefined) return call(args);
|
|
6043
6043
|
if (args.column !== undefined && args.column !== args.status) return Promise.resolve({ content: [{ type: "text" as const, text: JSON.stringify({ ...errorBody("status_conflicts_with_column"), status: 400 }) }] });
|
|
@@ -6053,7 +6053,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
6053
6053
|
const reqListShape = {
|
|
6054
6054
|
network_id: z.string().max(200).optional(),
|
|
6055
6055
|
seq: z.number().int().positive().optional().describe("short number (#N) of one task in this network"),
|
|
6056
|
-
status: z.enum(["pool", "doing", "done"]).optional(),
|
|
6056
|
+
status: z.enum(["pool", "doing", "done", "abandoned"]).optional(),
|
|
6057
6057
|
project_id: z.string().max(200).optional(),
|
|
6058
6058
|
owner: z.string().max(210).optional(),
|
|
6059
6059
|
agent_owner: z.string().max(210).optional(),
|
|
@@ -6134,14 +6134,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
6134
6134
|
|
|
6135
6135
|
server.tool(
|
|
6136
6136
|
"requirements_create",
|
|
6137
|
-
"Create a requirement task. name is required. column (or its alias status) = pool / doing / done. A duplicate external_ref in the network returns 409 external_ref_exists with existing_id — use requirements_upsert_by_external_ref for syncing.",
|
|
6137
|
+
"Create a requirement task. name is required. column (or its alias status) = pool / doing / done / abandoned. A duplicate external_ref in the network returns 409 external_ref_exists with existing_id — use requirements_upsert_by_external_ref for syncing.",
|
|
6138
6138
|
{ network_id: z.string().max(200).optional(), ...reqFields, status: reqStatus, name: z.string().min(1).max(80), external_ref: z.string().max(200).optional() },
|
|
6139
6139
|
async (args) => withColumn(args, a => requirementsCall("POST", "/api/requirements", args.network_id, pick(a, REQ_WRITE_KEYS))),
|
|
6140
6140
|
);
|
|
6141
6141
|
|
|
6142
6142
|
server.tool(
|
|
6143
6143
|
"requirements_update",
|
|
6144
|
-
"Patch a requirement task (id or \"#N\"); omitted fields keep their value. column (or its alias status) moves it between pool / doing / done. participants REPLACES the whole list; to add or remove people without dropping the others use participants_add / participants_remove. archived=true hides it from the default list (agents cannot delete).",
|
|
6144
|
+
"Patch a requirement task (id or \"#N\"); omitted fields keep their value. column (or its alias status) moves it between pool / doing / done / abandoned. participants REPLACES the whole list; to add or remove people without dropping the others use participants_add / participants_remove. archived=true hides it from the default list (agents cannot delete).",
|
|
6145
6145
|
{ id: z.string().min(1).max(200).describe(REQ_ID_DESC), network_id: z.string().max(200).optional(), ...reqFields, status: reqStatus, participants_add: reqPeopleDelta("add"), participants_remove: reqPeopleDelta("remove"), external_ref: z.string().max(200).nullable().optional(), archived: z.boolean().optional() },
|
|
6146
6146
|
async (args) => withColumn(args, a => requirementsCall("PATCH", `/api/requirements/${encodeURIComponent(args.id)}`, args.network_id, pick(a, [...REQ_WRITE_KEYS, "participants_add", "participants_remove"]))),
|
|
6147
6147
|
);
|