@sleep2agi/commhub-server 0.9.0-preview.13 → 0.9.0-preview.130
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/README.md +130 -10
- package/bin/commhub.ts +99 -11
- package/package.json +7 -7
- package/src/ack-create-request-transport.test.ts +182 -0
- package/src/ack-create-request.test.ts +50 -7
- package/src/admin-delete-network-http.test.ts +228 -0
- package/src/admin-networks-http.test.ts +88 -0
- package/src/adoption-candidates-http.test.ts +137 -0
- package/src/adoption-candidates.ts +158 -0
- package/src/agent-access-batch.test.ts +210 -0
- package/src/agent-access.ts +487 -0
- package/src/agent-acl-groups-http.test.ts +247 -0
- package/src/agent-acl-http.test.ts +659 -0
- package/src/agent-acl-slice3-http.test.ts +267 -0
- package/src/agent-groups-perf.test.ts +223 -0
- package/src/agent-notice.ts +48 -0
- package/src/agent-team-whoami-http.test.ts +98 -0
- package/src/agent-teams-http.test.ts +323 -0
- package/src/agent-teams.ts +244 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +42 -0
- package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
- package/src/api-host-supervisors-fallback.test.ts +271 -0
- package/src/api-host-supervisors-runtime-readiness.test.ts +195 -0
- package/src/api-nodes-shape.test.ts +26 -10
- package/src/auth-password-change-cli-tokens-http.test.ts +294 -0
- package/src/auth-sessions-http.test.ts +250 -0
- package/src/auth-validate.test.ts +5 -1
- package/src/auth-weak-password-login.test.ts +176 -0
- package/src/auth.ts +598 -49
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/binding-generation-http.test.ts +121 -0
- package/src/blocked-exit-on-reply.test.ts +122 -0
- package/src/boot-side-effect.test.ts +124 -0
- package/src/clone-node.test.ts +248 -0
- package/src/clone-node.ts +289 -0
- package/src/codex-config-flags.ts +77 -0
- package/src/codex-fork-contract.ts +23 -0
- package/src/codex-fork-migration.test.ts +38 -0
- package/src/commhub-bin-args.test.ts +85 -0
- package/src/config-apply-sec1.test.ts +107 -0
- package/src/config-apply-validate.test.ts +134 -1
- package/src/config-apply-validate.ts +111 -5
- package/src/create-node-blocked-gate.test.ts +356 -0
- package/src/create-node-network-id.test.ts +79 -0
- package/src/create-node-tool-schema.test.ts +48 -0
- package/src/create-node-unicode-name.test.ts +120 -0
- package/src/create-node-validate.test.ts +193 -7
- package/src/create-node-validate.ts +227 -15
- package/src/create-node-workdir.test.ts +289 -0
- package/src/create-node.ts +77 -23
- package/src/daemon-provider-snapshots.test.ts +421 -0
- package/src/daemon-provider-snapshots.ts +327 -0
- package/src/daemon-token-owner-http.test.ts +236 -0
- package/src/dashboard-slash-routing-http.test.ts +107 -0
- package/src/db-adapter-guard.test.ts +148 -0
- package/src/db-adapter-translate.test.ts +128 -0
- package/src/db-adapter.ts +323 -87
- package/src/db-timestamp.test.ts +38 -0
- package/src/db-timestamp.ts +15 -0
- package/src/db.ts +930 -35
- package/src/delivered-without-push.test.ts +140 -0
- package/src/department-groups-http.test.ts +209 -0
- package/src/department-groups-sync-http.test.ts +328 -0
- package/src/department-groups.ts +434 -0
- package/src/department-heads-http.test.ts +330 -0
- package/src/department-heads.ts +226 -0
- package/src/departments-http.test.ts +185 -0
- package/src/departments.ts +260 -0
- package/src/desktop-message-seam.test.ts +150 -0
- package/src/desktop-message.test.ts +368 -0
- package/src/dm-file-scope-http.test.ts +178 -0
- package/src/ext-token-shared.test.ts +60 -0
- package/src/ext-validation-regression.test.ts +131 -0
- package/src/external-schedule-edits-http.test.ts +321 -0
- package/src/external-schedule-edits-timestamps.test.ts +64 -0
- package/src/external-schedule-edits.ts +327 -0
- package/src/file-download-authz-dev-open.test.ts +135 -0
- package/src/file-download-authz.test.ts +404 -0
- package/src/file-download-date-bucket.test.ts +436 -0
- package/src/file-network-scope-dev-open.test.ts +97 -0
- package/src/file-network-scope.test.ts +732 -0
- package/src/group-membership-events-http.test.ts +264 -0
- package/src/group-messages-http.test.ts +482 -0
- package/src/group-messages.ts +297 -0
- package/src/health-redaction.test.ts +642 -0
- package/src/http-gzip.test.ts +94 -0
- package/src/http-gzip.ts +112 -0
- package/src/hub-provider-config.test.ts +139 -0
- package/src/hub-provider-config.ts +105 -0
- package/src/hub-timestamp.test.ts +38 -0
- package/src/hub-timestamp.ts +27 -0
- package/src/human-dm.ts +215 -0
- package/src/inbox-caller.ts +49 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2362
- package/src/lifecycle-guard.ts +1 -1
- package/src/mcp-context-cost-http.test.ts +123 -0
- package/src/mcp-projects-events-http.test.ts +162 -0
- package/src/mcp-route-label-http.test.ts +120 -0
- package/src/mcp-write-network-resolution.test.ts +432 -0
- package/src/member-audit-network-id-http.test.ts +93 -0
- package/src/member-presence-http.test.ts +210 -0
- package/src/messages-ack-by-agent-http.test.ts +142 -0
- package/src/migrate-sqlite-to-pg.test.ts +47 -0
- package/src/migrate-sqlite-to-pg.ts +263 -0
- package/src/model-auth-notify-http.test.ts +181 -0
- package/src/model-auth-notify.ts +116 -0
- package/src/network-name-http.test.ts +75 -0
- package/src/network-scope.ts +276 -0
- package/src/network-token-membership-http.test.ts +139 -0
- package/src/node-alias-control.ts +12 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +217 -0
- package/src/node-caller-identity-http.test.ts +781 -0
- package/src/node-create-request-status-http.test.ts +105 -0
- package/src/node-daemon-bindings.test.ts +132 -0
- package/src/node-daemon-bindings.ts +130 -0
- package/src/node-files-transport.test.ts +344 -0
- package/src/node-health-codex-login.test.ts +120 -0
- package/src/node-health-dispatch-http.test.ts +279 -0
- package/src/node-health-guard.ts +105 -0
- package/src/node-health-passthrough.test.ts +115 -0
- package/src/node-health-store.ts +101 -0
- package/src/node-id-alias.test.ts +190 -0
- package/src/node-identity-conflict-http.test.ts +269 -0
- package/src/node-identity-conflict.ts +142 -0
- package/src/node-identity-mismatch.test.ts +74 -0
- package/src/node-lifecycle-controllable.test.ts +64 -0
- package/src/node-lifecycle-controllable.ts +53 -0
- package/src/node-lifecycle-read-http.test.ts +245 -0
- package/src/node-lifecycle-read.ts +121 -0
- package/src/node-logs-transport.test.ts +263 -0
- package/src/node-permissions-http.test.ts +276 -0
- package/src/node-permissions.ts +285 -0
- package/src/node-request-retention.test.ts +281 -0
- package/src/node-request-retention.ts +120 -0
- package/src/node-skills-transport.test.ts +209 -0
- package/src/node-token-ownership.ts +51 -0
- package/src/node-token-privilege-http.test.ts +158 -0
- package/src/observer-avatar-http.test.ts +431 -0
- package/src/observer-push.test.ts +252 -0
- package/src/opencode-v2-catalog.test.ts +268 -0
- package/src/opencode-v2-catalog.ts +352 -0
- package/src/package-version-consistency.test.ts +25 -0
- package/src/peer-reply-atomic.test.ts +455 -0
- package/src/peer-reply-capability.test.ts +70 -0
- package/src/pg-worker.ts +87 -0
- package/src/project-dir-warn-clear-http.test.ts +132 -0
- package/src/project-viewer-can-http.test.ts +114 -0
- package/src/push.ts +429 -10
- package/src/redact-tokens.test.ts +65 -0
- package/src/redact-tokens.ts +40 -0
- package/src/rename.ts +22 -2
- package/src/reply-target-derivation.test.ts +96 -0
- package/src/report-status-handover-capabilities-http.test.ts +124 -0
- package/src/report-status-host-schema.test.ts +235 -0
- package/src/report-status-resume-id-handover.test.ts +150 -0
- package/src/require-explicit-test-db.ts +9 -0
- package/src/requirement-due-reminders-http.test.ts +620 -0
- package/src/requirement-due-reminders.ts +416 -0
- package/src/requirement-events-http.test.ts +268 -0
- package/src/requirement-events.test.ts +45 -0
- package/src/requirement-events.ts +144 -0
- package/src/requirement-notify.ts +165 -0
- package/src/requirement-participant-notify-http.test.ts +309 -0
- package/src/requirement-tag-ops-http.test.ts +266 -0
- package/src/requirement-tags.ts +89 -0
- package/src/requirements-abandoned-http.test.ts +236 -0
- package/src/requirements-comment-http.test.ts +167 -0
- package/src/requirements-completed-at-migration.test.ts +70 -0
- package/src/requirements-errors-mcp-http.test.ts +164 -0
- package/src/requirements-errors.ts +143 -0
- package/src/requirements-etag-http.test.ts +91 -0
- package/src/requirements-http.test.ts +709 -0
- package/src/requirements-last-event-http.test.ts +349 -0
- package/src/requirements-list-mcp-slim-http.test.ts +155 -0
- package/src/requirements-list-slim-http.test.ts +319 -0
- package/src/requirements-mcp.test.ts +163 -0
- package/src/requirements-migrate.ts +259 -0
- package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
- package/src/requirements-people-http.test.ts +114 -0
- package/src/requirements-people-mcp-http.test.ts +225 -0
- package/src/requirements-people.ts +127 -0
- package/src/requirements-priority-lowest.test.ts +114 -0
- package/src/requirements-search-http.test.ts +182 -0
- package/src/requirements-search.test.ts +32 -0
- package/src/requirements-search.ts +145 -0
- package/src/requirements-seq-http.test.ts +193 -0
- package/src/requirements-seq-migration.test.ts +63 -0
- package/src/requirements-start-date.test.ts +125 -0
- package/src/requirements-stats-http.test.ts +288 -0
- package/src/requirements-stats.ts +167 -0
- package/src/requirements-status-participants-http.test.ts +222 -0
- package/src/requirements-upgrade-e2e.test.ts +126 -0
- package/src/requirements-upgrade-migration.test.ts +98 -0
- package/src/requirements.ts +1806 -0
- package/src/resolve-port.test.ts +57 -0
- package/src/resolve-port.ts +44 -0
- package/src/response-charset.test.ts +130 -0
- package/src/rest-explicit-columns-http.test.ts +222 -0
- package/src/rest-identity.test.ts +129 -0
- package/src/rest-identity.ts +125 -0
- package/src/rest-projections.ts +68 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/restricted-files.ts +76 -0
- package/src/retention.ts +15 -3
- package/src/route-callers-http.test.ts +120 -0
- package/src/route-timing.test.ts +211 -0
- package/src/route-timing.ts +258 -0
- package/src/rules-file-duplicate-alias.test.ts +234 -0
- package/src/rules-file-session-target.test.ts +207 -0
- package/src/rules-file-transport.test.ts +237 -0
- package/src/runtime-label.test.ts +22 -0
- package/src/runtime-label.ts +21 -0
- package/src/runtime-readiness.ts +90 -0
- package/src/schedule-agent-mcp-http.test.ts +381 -0
- package/src/schedule-agent.ts +192 -0
- package/src/scheduled-doorbell-nodeless-session.test.ts +134 -0
- package/src/scheduled-failures-http.test.ts +233 -0
- package/src/scheduled-failures.ts +179 -0
- package/src/scheduled-offline.test.ts +131 -0
- package/src/scheduled-offline.ts +66 -0
- package/src/scheduled-reply-unread-http.test.ts +212 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-skip-blocking-task.test.ts +120 -0
- package/src/scheduled-stuck-http.test.ts +238 -0
- package/src/scheduled-stuck.ts +153 -0
- package/src/scheduled-tasks-http.test.ts +638 -0
- package/src/scheduled-tasks.ts +766 -0
- package/src/scoped-secrets.ts +124 -0
- package/src/send-dedup-request-id.test.ts +229 -0
- package/src/send-reply-agent-warning.test.ts +202 -0
- package/src/send-reply-alias-optional.test.ts +131 -0
- package/src/send-reply-attachments.test.ts +458 -0
- package/src/send_dedup.test.ts +54 -0
- package/src/send_dedup.ts +52 -6
- package/src/serve-error.test.ts +38 -0
- package/src/serve-error.ts +27 -0
- package/src/server.ts +5257 -0
- package/src/session-task-on-dispatch.ts +14 -0
- package/src/session-token-ownership-http.test.ts +122 -0
- package/src/shared/codex-inventory.ts +41 -0
- package/src/shared/daemon-provider-login.ts +31 -0
- package/src/shared/daemon-provider-snapshot.ts +52 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/shared/node-name.test.ts +62 -0
- package/src/shared/node-name.ts +161 -0
- package/src/shared/provider-application.ts +29 -0
- package/src/shared/provider-collection.ts +14 -0
- package/src/shared/provider-initialization.ts +8 -0
- package/src/shared/reserved-env-drift.test.ts +10 -0
- package/src/shared/scoped-secret-contract.ts +5 -0
- package/src/shared/task-content-limit-drift.test.ts +28 -0
- package/src/shared/task-content-limit.ts +25 -0
- package/src/side-thread-command-transport.test.ts +312 -0
- package/src/side-thread-command-transport.ts +318 -0
- package/src/side-thread-http-integration.test.ts +233 -0
- package/src/side-thread-http.ts +504 -0
- package/src/side-thread-production.test.ts +72 -0
- package/src/side-thread-production.ts +118 -0
- package/src/side-thread.test.ts +1067 -0
- package/src/side-thread.ts +2499 -0
- package/src/skillhub-http.test.ts +108 -0
- package/src/sqlite-close-checkpoint.test.ts +78 -0
- package/src/start-node.test.ts +264 -0
- package/src/status-alias-filter-http.test.ts +106 -0
- package/src/status-alias-resolver-http.test.ts +163 -0
- package/src/status-alias-resolver.ts +19 -0
- package/src/status-node-id-filter-http.test.ts +161 -0
- package/src/status-read-cache-http.test.ts +229 -0
- package/src/status-read-cache.ts +122 -0
- package/src/stop-delete-node.test.ts +509 -1
- package/src/task-access-http.test.ts +402 -0
- package/src/task-access.ts +420 -0
- package/src/task-auth-origin-http.test.ts +91 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +482 -0
- package/src/task-diagnostic-http.test.ts +149 -0
- package/src/task-diagnostic.test.ts +115 -0
- package/src/task-diagnostic.ts +118 -0
- package/src/task-expiry-notice-http.test.ts +287 -0
- package/src/task-expiry-notice.ts +315 -0
- package/src/task-idempotency-mcp.test.ts +142 -0
- package/src/task-idempotency.test.ts +60 -0
- package/src/task-idempotency.ts +75 -0
- package/src/task-late-replies.ts +54 -0
- package/src/task-late-reply-http.test.ts +152 -0
- package/src/task-lifecycle-watcher.test.ts +204 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +141 -0
- package/src/task-orphan-http.test.ts +287 -0
- package/src/task-orphan.ts +119 -0
- package/src/task-queue-ahead-http.test.ts +309 -0
- package/src/task-queue-ahead-scale.test.ts +236 -0
- package/src/task-queue-ahead.ts +186 -0
- package/src/task-rejection-log-http.test.ts +160 -0
- package/src/task-rejection-log.ts +49 -0
- package/src/task-stale-open-http.test.ts +290 -0
- package/src/task-stale-open.ts +103 -0
- package/src/tasks-pagination-http.test.ts +141 -0
- package/src/tool-audience-http.test.ts +220 -0
- package/src/tool-audience.ts +118 -0
- package/src/tools-list-json-schema.test.ts +67 -0
- package/src/tools.ts +3927 -323
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +97 -15
- package/src/uploads.ts +103 -6
- package/src/user-avatar-http.test.ts +74 -0
- package/src/user-avatar-migration.test.ts +25 -0
- package/src/user-inbox-by-agent-http.test.ts +161 -0
- package/src/user-inbox-netid-notnull.test.ts +122 -0
- package/src/user-inbox-read-http.test.ts +223 -0
- package/src/user-inbox-schema.test.ts +54 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// #473(#469 问题 2)—— Agent 查人 + 人员字段按名字写。
|
|
2
|
+
//
|
|
3
|
+
// 钉住:
|
|
4
|
+
// 1. MCP requirements_people:紧凑行(user_id / username / display_name / role / department / agents),
|
|
5
|
+
// 没主人的 Agent 单列;q 按用户名 / 显示名 / 部门 / Agent 别名筛;50 人一页、offset 翻页;一页有字节上限。
|
|
6
|
+
// 2. 严格参数:不认识的参数 → -32602,列出能用的参数。
|
|
7
|
+
// 3. requirements_create / update / upsert 的 owner / agent_owner / participants 收 {kind:'user', username} 与
|
|
8
|
+
// {kind:'node', alias},在任务所在的网络里解析,存下来仍是 {kind, id}。
|
|
9
|
+
// 4. 别名对上多个 Agent → person_ambiguous;找不到 → person_not_in_network;都带 field 和指向 requirements_people 的 hint。
|
|
10
|
+
// 5. 绝不跨网络:别的网络的用户名 / 别名都是「找不到」。受限成员看不见的 Agent:通讯录里没有,按别名写也是「找不到」。
|
|
11
|
+
// 6. REST GET /api/requirements/people 的形状不变。
|
|
12
|
+
//
|
|
13
|
+
// 跑法:cd server && COMMHUB_DB=/tmp/x.db bun test src/requirements-people-mcp-http.test.ts
|
|
14
|
+
// PG:COMMHUB_TEST_PG_URL=… COMMHUB_PG_EXPERIMENTAL=1(tests/test2123-hub-postgres-ladder 里注册)
|
|
15
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
16
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
17
|
+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
18
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
19
|
+
import { addNetworkMember, register } from "./auth.js";
|
|
20
|
+
import { db } from "./db.js";
|
|
21
|
+
import { createDepartment, setMemberDepartment } from "./departments.js";
|
|
22
|
+
import { registerTools } from "./tools.js";
|
|
23
|
+
|
|
24
|
+
const activeDbPath = process.env.COMMHUB_DB ?? (process.env.COMMHUB_TEST_PG_URL ? "postgres" : undefined);
|
|
25
|
+
if (!activeDbPath) throw new Error("requirements-people-mcp requires COMMHUB_DB (or COMMHUB_TEST_PG_URL) before module import");
|
|
26
|
+
|
|
27
|
+
const stamp = Date.now();
|
|
28
|
+
const PW = "PeopleLookup123!";
|
|
29
|
+
/** 一页 50 人(每人带部门、1 个 Agent)的字节上限。实测约 12.6 KB(≈ 250 B / 人);上限 16 KB。 */
|
|
30
|
+
const PAGE_MAX_BYTES = 16_000;
|
|
31
|
+
const EXTRA_MEMBERS = 60;
|
|
32
|
+
|
|
33
|
+
let server: any;
|
|
34
|
+
let base = "";
|
|
35
|
+
let netA = "", netB = "";
|
|
36
|
+
let boss: { id: string; token: string; username: string };
|
|
37
|
+
let alice: { id: string; username: string };
|
|
38
|
+
let bob: { id: string; username: string };
|
|
39
|
+
let carol: { id: string; username: string };
|
|
40
|
+
let ruth: { id: string; username: string };
|
|
41
|
+
let deptId = "";
|
|
42
|
+
const node = (suffix: string) => `n_pl_${suffix}_${stamp}`;
|
|
43
|
+
const ALIAS = { alice: `alice-bot-${stamp}`, dup: `dup-bot-${stamp}`, free: `free-bot-${stamp}`, far: `far-bot-${stamp}` };
|
|
44
|
+
|
|
45
|
+
type Mcp = { call: (name: string, args: Record<string, unknown>) => Promise<any>; close: () => Promise<void> };
|
|
46
|
+
async function connect(userId: string, username: string): Promise<Mcp> {
|
|
47
|
+
const s = new McpServer({ name: "people", version: "1" });
|
|
48
|
+
registerTools(s, undefined, null, userId, username, false, "tok_user");
|
|
49
|
+
const client = new Client({ name: "people-client", version: "1" });
|
|
50
|
+
const [ct, st] = InMemoryTransport.createLinkedPair();
|
|
51
|
+
await s.connect(st);
|
|
52
|
+
await client.connect(ct);
|
|
53
|
+
return {
|
|
54
|
+
call: async (name, args) => {
|
|
55
|
+
const r: any = await client.callTool({ name, arguments: args });
|
|
56
|
+
const text = String(r.content?.[0]?.text ?? "");
|
|
57
|
+
if (r.isError) return { mcpError: true, text };
|
|
58
|
+
return { ...JSON.parse(text), _bytes: Buffer.byteLength(text, "utf8") };
|
|
59
|
+
},
|
|
60
|
+
close: async () => { await client.close(); await s.close(); },
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
let asBoss: Mcp, asRuth: Mcp;
|
|
64
|
+
|
|
65
|
+
function mk(name: string) {
|
|
66
|
+
const r = register(`${name}_${stamp}`, PW, undefined, name === "alice" ? "爱丽丝" : undefined);
|
|
67
|
+
expect(r.ok).toBe(true);
|
|
68
|
+
return { id: r.user!.user_id, token: r.token!, username: r.user!.username, net: r.network_id! };
|
|
69
|
+
}
|
|
70
|
+
const insertNode = (id: string, alias: string, net: string, owner: string | null) =>
|
|
71
|
+
db.run("INSERT INTO nodes (node_id, node_name, alias, network_id, owner_user_id) VALUES (?1, ?2, ?2, ?3, ?4)", [id, alias, net, owner]);
|
|
72
|
+
|
|
73
|
+
beforeAll(async () => {
|
|
74
|
+
const b = mk("boss"); boss = { id: b.id, token: b.token, username: b.username }; netA = b.net;
|
|
75
|
+
const a = mk("alice"); alice = { id: a.id, username: a.username };
|
|
76
|
+
const o = mk("bob"); bob = { id: o.id, username: o.username };
|
|
77
|
+
const c = mk("carol"); carol = { id: c.id, username: c.username }; netB = c.net;
|
|
78
|
+
const r = mk("ruth"); ruth = { id: r.id, username: r.username };
|
|
79
|
+
expect(addNetworkMember(netA, alice.id, "member", boss.id, { agentAccess: "all" }).ok).toBe(true);
|
|
80
|
+
expect(addNetworkMember(netA, bob.id, "member", boss.id, { agentAccess: "all" }).ok).toBe(true);
|
|
81
|
+
// ruth:受限成员(只看授权的 Agent),一个都没授权
|
|
82
|
+
expect(addNetworkMember(netA, ruth.id, "member", boss.id, { agentAccess: "granted" }).ok).toBe(true);
|
|
83
|
+
const dept = createDepartment(netA, boss.id, { name: `研发部${stamp % 1000}` });
|
|
84
|
+
expect(dept.ok).toBe(true);
|
|
85
|
+
deptId = (dept as any).department.id;
|
|
86
|
+
expect(setMemberDepartment(netA, alice.id, deptId).ok).toBe(true);
|
|
87
|
+
insertNode(node("alice"), ALIAS.alice, netA, alice.id);
|
|
88
|
+
insertNode(node("dup1"), ALIAS.dup, netA, null);
|
|
89
|
+
insertNode(node("dup2"), ALIAS.dup, netA, null);
|
|
90
|
+
insertNode(node("free"), ALIAS.free, netA, null);
|
|
91
|
+
insertNode(node("far"), ALIAS.far, netB, carol.id);
|
|
92
|
+
for (let i = 0; i < EXTRA_MEMBERS; i++) {
|
|
93
|
+
const m = register(`pl_m${String(i).padStart(2, "0")}_${stamp}`, PW, undefined, `成员${i}`);
|
|
94
|
+
expect(addNetworkMember(netA, m.user!.user_id, "member", boss.id, { agentAccess: "all" }).ok).toBe(true);
|
|
95
|
+
setMemberDepartment(netA, m.user!.user_id, deptId);
|
|
96
|
+
insertNode(node(`m${i}`), `m${i}-bot-${stamp}`, netA, m.user!.user_id);
|
|
97
|
+
}
|
|
98
|
+
const mod: any = await import("./server.js");
|
|
99
|
+
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
100
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
101
|
+
asBoss = await connect(boss.id, boss.username);
|
|
102
|
+
asRuth = await connect(ruth.id, ruth.username);
|
|
103
|
+
}, 120_000);
|
|
104
|
+
afterAll(async () => { await asBoss?.close(); await asRuth?.close(); server?.stop?.(true); });
|
|
105
|
+
|
|
106
|
+
describe("#473 requirements_people", () => {
|
|
107
|
+
test("compact rows: username, display_name, role, department, owned agents; unowned agents listed once", async () => {
|
|
108
|
+
const r = await asBoss.call("requirements_people", { network_id: netA, q: "alice" });
|
|
109
|
+
expect(r.ok).toBe(true);
|
|
110
|
+
expect(r.people).toEqual([{
|
|
111
|
+
user_id: alice.id, username: alice.username, display_name: "爱丽丝", role: "member",
|
|
112
|
+
department: { id: deptId, name: `研发部${stamp % 1000}` }, agents: [{ node_id: node("alice"), alias: ALIAS.alice }],
|
|
113
|
+
}]);
|
|
114
|
+
const all = await asBoss.call("requirements_people", { network_id: netA, q: String(stamp) });
|
|
115
|
+
expect(all.agents_without_owner.map((a: any) => a.alias).sort()).toEqual([ALIAS.dup, ALIAS.dup, ALIAS.free].sort());
|
|
116
|
+
const bossRow = (await asBoss.call("requirements_people", { network_id: netA, q: boss.username })).people[0];
|
|
117
|
+
expect(bossRow.role).toBe("owner");
|
|
118
|
+
expect(bossRow.display_name).toBe("");
|
|
119
|
+
expect(bossRow.department).toBeNull();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("q matches username, display name, department and Agent alias", async () => {
|
|
123
|
+
expect((await asBoss.call("requirements_people", { network_id: netA, q: "爱丽" })).people.map((p: any) => p.user_id)).toEqual([alice.id]);
|
|
124
|
+
expect((await asBoss.call("requirements_people", { network_id: netA, q: ALIAS.alice.toUpperCase() })).people.map((p: any) => p.user_id)).toEqual([alice.id]);
|
|
125
|
+
const dept = await asBoss.call("requirements_people", { network_id: netA, q: `研发部${stamp % 1000}`, limit: 200 });
|
|
126
|
+
expect(dept.total).toBe(EXTRA_MEMBERS + 1);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("default page = 50 people under the byte bound; offset pages the rest", async () => {
|
|
130
|
+
const p1 = await asBoss.call("requirements_people", { network_id: netA });
|
|
131
|
+
expect(p1.people.length).toBe(50);
|
|
132
|
+
expect(p1.has_more).toBe(true);
|
|
133
|
+
console.log(`people page of 50: ${p1._bytes} B`);
|
|
134
|
+
expect(p1._bytes).toBeLessThan(PAGE_MAX_BYTES);
|
|
135
|
+
const p2 = await asBoss.call("requirements_people", { network_id: netA, offset: p1.next_offset });
|
|
136
|
+
expect(p2.people.length).toBe(p1.total - 50);
|
|
137
|
+
expect(p2.agents_without_owner).toBeUndefined();
|
|
138
|
+
expect(new Set([...p1.people, ...p2.people].map((p: any) => p.user_id)).size).toBe(p1.total);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("strict params: unknown key → -32602 listing the valid ones", async () => {
|
|
142
|
+
const r = await asBoss.call("requirements_people", { network_id: netA, name: "alice" });
|
|
143
|
+
expect(r.mcpError).toBe(true);
|
|
144
|
+
expect(r.text).toContain("-32602");
|
|
145
|
+
expect(r.text).toContain("unknown parameter(s): name; valid parameters: network_id, q, limit, offset");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("another network is not readable; a restricted member sees no ungranted Agents", async () => {
|
|
149
|
+
const other = await asBoss.call("requirements_people", { network_id: netB });
|
|
150
|
+
expect(other.ok).toBe(false);
|
|
151
|
+
const r = await asRuth.call("requirements_people", { network_id: netA, q: String(stamp), limit: 200 });
|
|
152
|
+
expect(r.people.every((p: any) => p.agents.length === 0)).toBe(true);
|
|
153
|
+
expect(r.agents_without_owner).toEqual([]);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("#473 person fields by name", () => {
|
|
158
|
+
test("create / update / upsert accept username and alias; stored as ids in this network", async () => {
|
|
159
|
+
const made = await asBoss.call("requirements_create", {
|
|
160
|
+
network_id: netA, name: `by name ${stamp}`,
|
|
161
|
+
owner: { kind: "user", username: alice.username },
|
|
162
|
+
agent_owner: { kind: "node", alias: ALIAS.alice },
|
|
163
|
+
participants: [{ kind: "user", username: bob.username }, { kind: "node", alias: ALIAS.free }],
|
|
164
|
+
});
|
|
165
|
+
expect(made.ok).toBe(true);
|
|
166
|
+
const req = made.requirement;
|
|
167
|
+
expect(req.owner).toEqual({ kind: "user", id: alice.id });
|
|
168
|
+
expect(req.agent_owner).toEqual({ kind: "node", id: node("alice") });
|
|
169
|
+
expect(req.participants).toEqual([{ kind: "user", id: bob.id }, { kind: "node", id: node("free") }]);
|
|
170
|
+
|
|
171
|
+
const upd = await asBoss.call("requirements_update", { id: req.id, network_id: netA, owner: { kind: "user", username: bob.username } });
|
|
172
|
+
expect(upd.requirement.owner).toEqual({ kind: "user", id: bob.id });
|
|
173
|
+
|
|
174
|
+
const ups = await asBoss.call("requirements_upsert_by_external_ref", {
|
|
175
|
+
network_id: netA, external_ref: `test:people#${stamp}`, name: "upserted", agent_owner: { kind: "node", alias: ALIAS.alice },
|
|
176
|
+
});
|
|
177
|
+
expect(ups.requirement.agent_owner).toEqual({ kind: "node", id: node("alice") });
|
|
178
|
+
// id 照旧可用,且优先于名字
|
|
179
|
+
const byId = await asBoss.call("requirements_update", { id: req.id, network_id: netA, owner: { kind: "user", id: alice.id, username: bob.username } });
|
|
180
|
+
expect(byId.requirement.owner).toEqual({ kind: "user", id: alice.id });
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("ambiguous alias → person_ambiguous with field and a requirements_people hint", async () => {
|
|
184
|
+
const r = await asBoss.call("requirements_create", { network_id: netA, name: "amb", agent_owner: { kind: "node", alias: ALIAS.dup } });
|
|
185
|
+
expect(r.ok).toBe(false);
|
|
186
|
+
expect(r.error).toBe("person_ambiguous");
|
|
187
|
+
expect(r.field).toBe("agent_owner");
|
|
188
|
+
expect(r.hint).toContain("requirements_people");
|
|
189
|
+
expect(r.hint).toContain("node_id");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("unknown name → person_not_in_network naming the field and requirements_people", async () => {
|
|
193
|
+
const r = await asBoss.call("requirements_create", { network_id: netA, name: "nope", participants: [{ kind: "user", username: `ghost_${stamp}` }] });
|
|
194
|
+
expect(r.error).toBe("person_not_in_network");
|
|
195
|
+
expect(r.field).toBe("participants");
|
|
196
|
+
expect(r.hint).toContain("requirements_people");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("never across networks: another network's username / alias is not found", async () => {
|
|
200
|
+
const u = await asBoss.call("requirements_create", { network_id: netA, name: "x", owner: { kind: "user", username: carol.username } });
|
|
201
|
+
expect(u.error).toBe("person_not_in_network");
|
|
202
|
+
const n = await asBoss.call("requirements_create", { network_id: netA, name: "x", agent_owner: { kind: "node", alias: ALIAS.far } });
|
|
203
|
+
expect(n.error).toBe("person_not_in_network");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("wrong-kind or missing identifier → invalid_person", async () => {
|
|
207
|
+
for (const bad of [{ kind: "user", alias: ALIAS.alice }, { kind: "node", username: alice.username }, { kind: "user" }]) {
|
|
208
|
+
const r = await asBoss.call("requirements_create", { network_id: netA, name: "bad", participants: [bad] });
|
|
209
|
+
expect(r.error).toBe("invalid_person");
|
|
210
|
+
expect(r.hint).toContain("requirements_people");
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("a restricted member cannot resolve an Agent alias they are not granted", async () => {
|
|
215
|
+
const r = await asRuth.call("requirements_create", { network_id: netA, name: "hidden", agent_owner: { kind: "node", alias: ALIAS.alice } });
|
|
216
|
+
expect(r.error).toBe("person_not_in_network");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("REST GET /api/requirements/people keeps its shape", async () => {
|
|
220
|
+
const res = await fetch(`${base}/api/requirements/people?network_id=${netA}`, { headers: { Authorization: `Bearer ${boss.token}` } });
|
|
221
|
+
const body = await res.json() as any;
|
|
222
|
+
expect(res.status).toBe(200);
|
|
223
|
+
for (const p of body.people) expect(Object.keys(p).sort()).toEqual(["display_name", "id", "kind", "name", "networkId"]);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// #473(#469 问题 2)—— Agent 查人。
|
|
2
|
+
//
|
|
3
|
+
// 现场:Agent 想把任务派给「张三」或者某个节点,手里只有用户名 / 别名,而任务的人员字段只收 id;
|
|
4
|
+
// GET /api/requirements/people 只给显示名,没有用户名、角色、部门,也看不出哪个 Agent 是谁的。
|
|
5
|
+
// 这里给 MCP requirements_people 一份紧凑的通讯录:每个成员一行(user_id、username、display_name、网络角色、
|
|
6
|
+
// 部门),带上他名下的 Agent(node_id、alias);没有主人(或主人不在本网络)的 Agent 单列。
|
|
7
|
+
// 只在一个网络里查,受限成员看不见的 Agent 一律不出现(与 /people 同一个 hidden 判据)。
|
|
8
|
+
//
|
|
9
|
+
// 人员字段按名字写(owner {kind:'user', username} / agent_owner {kind:'node', alias})的解析在
|
|
10
|
+
// requirements.ts personRef():同样只在任务所在的那个网络里找,看不见的节点等同不存在。
|
|
11
|
+
|
|
12
|
+
import { db } from "./db.js";
|
|
13
|
+
import "./departments.js"; // network_departments 表 + network_members.department_id 列(导入即建,只增)
|
|
14
|
+
|
|
15
|
+
type HiddenNode = ((nodeId: string) => boolean) | null;
|
|
16
|
+
|
|
17
|
+
export const PEOPLE_DEFAULT_LIMIT = 50;
|
|
18
|
+
export const PEOPLE_MAX_LIMIT = 200;
|
|
19
|
+
|
|
20
|
+
export type DirectoryAgent = { node_id: string; alias: string };
|
|
21
|
+
export type DirectoryPerson = {
|
|
22
|
+
user_id: string;
|
|
23
|
+
username: string;
|
|
24
|
+
/** 没设(或等于用户名)为 ""(与 /people 同一规则)。 */
|
|
25
|
+
display_name: string;
|
|
26
|
+
role: string;
|
|
27
|
+
department: { id: string; name: string } | null;
|
|
28
|
+
agents: DirectoryAgent[];
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** q / limit / offset 解析;不合法 → 错误码(400)。 */
|
|
32
|
+
export function parsePeopleQuery(params: URLSearchParams): { q: string; limit: number; offset: number } | { error: string } {
|
|
33
|
+
const q = (params.get("q") ?? "").trim();
|
|
34
|
+
if (q.length > 100) return { error: "invalid_q" };
|
|
35
|
+
let limit = PEOPLE_DEFAULT_LIMIT;
|
|
36
|
+
const rawLimit = params.get("limit");
|
|
37
|
+
if (rawLimit !== null) {
|
|
38
|
+
if (!/^\d{1,4}$/.test(rawLimit)) return { error: "invalid_limit" };
|
|
39
|
+
limit = Number(rawLimit);
|
|
40
|
+
if (limit < 1 || limit > PEOPLE_MAX_LIMIT) return { error: "invalid_limit" };
|
|
41
|
+
}
|
|
42
|
+
let offset = 0;
|
|
43
|
+
const rawOffset = params.get("offset");
|
|
44
|
+
if (rawOffset !== null) {
|
|
45
|
+
if (!/^\d{1,6}$/.test(rawOffset)) return { error: "invalid_offset" };
|
|
46
|
+
offset = Number(rawOffset);
|
|
47
|
+
}
|
|
48
|
+
return { q, limit, offset };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 一个网络的通讯录。q = 不区分大小写的子串,匹配用户名 / 显示名 / 部门名 / 名下 Agent 的别名;
|
|
53
|
+
* 成员按 username 排序、offset 翻页;没主人的 Agent 只在第一页给(按 q 过滤)。
|
|
54
|
+
*/
|
|
55
|
+
export function peopleDirectory(networkId: string, hidden: HiddenNode, query: { q: string; limit: number; offset: number }) {
|
|
56
|
+
const members = db.all<{ user_id: string; username: string; display_name: string | null; role: string | null; department_id: string | null; department_name: string | null }>(
|
|
57
|
+
`SELECT u.user_id, u.username, u.display_name, m.role, d.department_id, d.name AS department_name
|
|
58
|
+
FROM network_members m
|
|
59
|
+
JOIN users u ON u.user_id = m.user_id
|
|
60
|
+
LEFT JOIN network_departments d ON d.network_id = m.network_id AND d.department_id = m.department_id
|
|
61
|
+
WHERE m.network_id = ?1
|
|
62
|
+
ORDER BY u.username, u.user_id`,
|
|
63
|
+
networkId,
|
|
64
|
+
);
|
|
65
|
+
const nodes = db.all<{ node_id: string; alias: string | null; node_name: string; owner_user_id: string | null }>(
|
|
66
|
+
"SELECT node_id, alias, node_name, owner_user_id FROM nodes WHERE network_id = ?1 ORDER BY COALESCE(alias, node_name), node_id",
|
|
67
|
+
networkId,
|
|
68
|
+
).filter(n => !hidden?.(n.node_id));
|
|
69
|
+
const memberIds = new Set(members.map(m => m.user_id));
|
|
70
|
+
const byOwner = new Map<string, DirectoryAgent[]>();
|
|
71
|
+
const unowned: DirectoryAgent[] = [];
|
|
72
|
+
for (const n of nodes) {
|
|
73
|
+
const agent = { node_id: n.node_id, alias: n.alias || n.node_name };
|
|
74
|
+
if (n.owner_user_id && memberIds.has(n.owner_user_id)) {
|
|
75
|
+
const list = byOwner.get(n.owner_user_id) ?? [];
|
|
76
|
+
list.push(agent);
|
|
77
|
+
byOwner.set(n.owner_user_id, list);
|
|
78
|
+
} else unowned.push(agent);
|
|
79
|
+
}
|
|
80
|
+
const needle = query.q.toLowerCase();
|
|
81
|
+
const hit = (s: string | null | undefined) => !needle || (s ?? "").toLowerCase().includes(needle);
|
|
82
|
+
const people: DirectoryPerson[] = [];
|
|
83
|
+
for (const m of members) {
|
|
84
|
+
const displayName = !m.display_name || m.display_name === m.username ? "" : m.display_name;
|
|
85
|
+
const agents = byOwner.get(m.user_id) ?? [];
|
|
86
|
+
if (!(hit(m.username) || (displayName && hit(displayName)) || (m.department_name && hit(m.department_name)) || agents.some(a => hit(a.alias)))) continue;
|
|
87
|
+
people.push({
|
|
88
|
+
user_id: m.user_id,
|
|
89
|
+
username: m.username,
|
|
90
|
+
display_name: displayName,
|
|
91
|
+
role: m.role ?? "member",
|
|
92
|
+
department: m.department_id && m.department_name ? { id: m.department_id, name: m.department_name } : null,
|
|
93
|
+
agents,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
const page = people.slice(query.offset, query.offset + query.limit);
|
|
97
|
+
const hasMore = query.offset + query.limit < people.length;
|
|
98
|
+
return {
|
|
99
|
+
ok: true,
|
|
100
|
+
network_id: networkId,
|
|
101
|
+
total: people.length,
|
|
102
|
+
people: page,
|
|
103
|
+
...(query.offset === 0 ? { agents_without_owner: unowned.filter(a => hit(a.alias)) } : {}),
|
|
104
|
+
has_more: hasMore,
|
|
105
|
+
next_offset: hasMore ? query.offset + query.limit : null,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* 按名字找人(personRef 用):只在 networkId 里找,hidden 的节点等同不存在。
|
|
111
|
+
* 返回 id;找不到 → person_not_in_network;同一个别名对上多个节点 → person_ambiguous。
|
|
112
|
+
*/
|
|
113
|
+
export function resolvePersonName(kind: "user" | "node", name: string, networkId: string, hidden: HiddenNode): string {
|
|
114
|
+
if (kind === "user") {
|
|
115
|
+
const row = db.get<{ user_id: string }>(
|
|
116
|
+
"SELECT u.user_id FROM network_members m JOIN users u ON u.user_id = m.user_id WHERE m.network_id = ?1 AND u.username = ?2",
|
|
117
|
+
networkId, name,
|
|
118
|
+
);
|
|
119
|
+
if (!row) throw new Error("person_not_in_network");
|
|
120
|
+
return row.user_id;
|
|
121
|
+
}
|
|
122
|
+
const rows = db.all<{ node_id: string }>("SELECT node_id FROM nodes WHERE network_id = ?1 AND alias = ?2", networkId, name)
|
|
123
|
+
.filter(r => !hidden?.(r.node_id));
|
|
124
|
+
if (rows.length === 0) throw new Error("person_not_in_network");
|
|
125
|
+
if (rows.length > 1) throw new Error("person_ambiguous");
|
|
126
|
+
return rows[0].node_id;
|
|
127
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// P0–P3 优先级:lowest(P3 极低)。生产库的 requirements 表建于旧 db.ts,带 CHECK(priority IN ('high', 'normal', 'low')),
|
|
2
|
+
// 光放开校验集合写进去也会被 SQLite 拒掉。这里先用旧建表语句建库、塞行,再走真实启动路径(db.ts + server.ts),
|
|
3
|
+
// 看:表重建一次、存量行 / 索引原样、lowest 在 POST / PATCH / upsert 都能写、坏值仍被拒、再启动不再重建。
|
|
4
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
5
|
+
import { Database } from "bun:sqlite";
|
|
6
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
|
|
10
|
+
const dir = mkdtempSync(join(tmpdir(), "anet-req-priority-"));
|
|
11
|
+
const file = join(dir, "hub.db");
|
|
12
|
+
process.env.COMMHUB_DB = file;
|
|
13
|
+
|
|
14
|
+
const legacy = new Database(file);
|
|
15
|
+
legacy.exec(`
|
|
16
|
+
CREATE TABLE IF NOT EXISTS requirements (
|
|
17
|
+
requirement_id TEXT PRIMARY KEY,
|
|
18
|
+
network_id TEXT NOT NULL,
|
|
19
|
+
title TEXT NOT NULL,
|
|
20
|
+
column_name TEXT NOT NULL DEFAULT 'pool' CHECK(column_name IN ('pool', 'doing', 'done')),
|
|
21
|
+
priority TEXT NOT NULL DEFAULT 'normal' CHECK(priority IN ('high', 'normal', 'low')),
|
|
22
|
+
due_on TEXT,
|
|
23
|
+
assignee TEXT,
|
|
24
|
+
client_id TEXT,
|
|
25
|
+
issues_json TEXT,
|
|
26
|
+
created_by TEXT,
|
|
27
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
28
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
29
|
+
);
|
|
30
|
+
CREATE INDEX IF NOT EXISTS idx_requirements_network ON requirements(network_id, created_at DESC);
|
|
31
|
+
`);
|
|
32
|
+
legacy.exec("ALTER TABLE requirements ADD COLUMN tags_json TEXT NOT NULL DEFAULT '[]'");
|
|
33
|
+
legacy.exec("ALTER TABLE requirements ADD COLUMN description TEXT");
|
|
34
|
+
const seeded = [["r_high", "high"], ["r_normal", "normal"], ["r_low", "low"]] as const;
|
|
35
|
+
for (const [id, p] of seeded) {
|
|
36
|
+
legacy.run("INSERT INTO requirements (requirement_id, network_id, title, priority, client_id, description, tags_json) VALUES (?1, 'net_legacy', ?2, ?3, ?4, ?5, '[\"旧\"]')", [id, `卡 ${id}`, p, `c_${id}`, `desc ${id}`]);
|
|
37
|
+
}
|
|
38
|
+
// 旧库确实拒 lowest:这就是光改 PRIORITIES 不够的原因
|
|
39
|
+
expect(() => legacy.run("INSERT INTO requirements (requirement_id, network_id, title, priority) VALUES ('x', 'n', 't', 'lowest')")).toThrow(/CHECK/);
|
|
40
|
+
legacy.close();
|
|
41
|
+
|
|
42
|
+
let base = "";
|
|
43
|
+
let token = "";
|
|
44
|
+
let server: { port: number; stop?: (force?: boolean) => void };
|
|
45
|
+
let db: any;
|
|
46
|
+
|
|
47
|
+
async function api(path: string, init?: RequestInit) {
|
|
48
|
+
const res = await fetch(`${base}${path}`, { ...init, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" } });
|
|
49
|
+
return { status: res.status, body: await res.json() as any };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
beforeAll(async () => {
|
|
53
|
+
({ db } = await import("./db.js"));
|
|
54
|
+
const { register } = await import("./auth.js");
|
|
55
|
+
const owner = register(`prio_owner_${Date.now()}`, "PriorityOwner123!", undefined, "seed");
|
|
56
|
+
token = owner.token!;
|
|
57
|
+
const mod: any = await import("./server.js");
|
|
58
|
+
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
59
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
60
|
+
}, 30_000);
|
|
61
|
+
|
|
62
|
+
afterAll(() => {
|
|
63
|
+
try { server?.stop?.(true); } catch {}
|
|
64
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("P3 lowest priority", () => {
|
|
68
|
+
test("startup rebuilds the legacy CHECK once and keeps every row and index", async () => {
|
|
69
|
+
const sql = db.get("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'requirements'").sql as string;
|
|
70
|
+
expect(sql).toContain("'lowest'");
|
|
71
|
+
expect(sql).toContain("CHECK(column_name IN ('pool', 'doing', 'done', 'abandoned'))");
|
|
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
|
+
expect(rows.map(r => [r.requirement_id, r.priority])).toEqual([["r_high", "high"], ["r_low", "low"], ["r_normal", "normal"]]);
|
|
74
|
+
for (const r of rows) {
|
|
75
|
+
expect(r.title).toBe(`卡 ${r.requirement_id}`);
|
|
76
|
+
expect(r.client_id).toBe(`c_${r.requirement_id}`);
|
|
77
|
+
expect(r.description).toBe(`desc ${r.requirement_id}`);
|
|
78
|
+
expect(r.tags_json).toBe('["旧"]');
|
|
79
|
+
}
|
|
80
|
+
const indexes = (db.all("PRAGMA index_list(requirements)") as { name: string; unique: number }[]).map(i => i.name);
|
|
81
|
+
for (const name of ["idx_requirements_network", "idx_requirements_client", "idx_requirements_external_ref", "idx_requirements_network_updated", "idx_requirements_parent"]) expect(indexes).toContain(name);
|
|
82
|
+
expect(db.get("SELECT name FROM sqlite_master WHERE name = 'requirements_migrated'")).toBeNull();
|
|
83
|
+
// 坏值仍被数据库拒
|
|
84
|
+
expect(() => db.run("INSERT INTO requirements (requirement_id, network_id, title, priority) VALUES ('bad', 'n', 't', 'urgent')")).toThrow(/CHECK/);
|
|
85
|
+
// 下次启动:已经放开,不再重建
|
|
86
|
+
const { migrateRequirementPriorityCheck } = await import("./requirements-migrate.js");
|
|
87
|
+
expect(migrateRequirementPriorityCheck(db).rebuilt).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("lowest is accepted on create, PATCH and upsert; unknown values stay 400", async () => {
|
|
91
|
+
const created = await api("/api/requirements", { method: "POST", body: JSON.stringify({ name: "极低的活", priority: "lowest" }) });
|
|
92
|
+
expect(created.status).toBe(201);
|
|
93
|
+
expect(created.body.requirement.priority).toBe("lowest");
|
|
94
|
+
const id = created.body.requirement.id;
|
|
95
|
+
const patched = await api(`/api/requirements/${id}`, { method: "PATCH", body: JSON.stringify({ priority: "low" }) });
|
|
96
|
+
expect(patched.body.requirement.priority).toBe("low");
|
|
97
|
+
const back = await api(`/api/requirements/${id}`, { method: "PATCH", body: JSON.stringify({ priority: "lowest" }) });
|
|
98
|
+
expect(back.status).toBe(200);
|
|
99
|
+
expect(back.body.requirement.priority).toBe("lowest");
|
|
100
|
+
const up = await api("/api/requirements/upsert", { method: "POST", body: JSON.stringify({ name: "同步来的", external_ref: "github:t/p#1", priority: "lowest" }) });
|
|
101
|
+
expect(up.status).toBe(201);
|
|
102
|
+
expect(up.body.requirement.priority).toBe("lowest");
|
|
103
|
+
const upAgain = await api("/api/requirements/upsert", { method: "POST", body: JSON.stringify({ external_ref: "github:t/p#1", priority: "high" }) });
|
|
104
|
+
expect(upAgain.body.requirement.priority).toBe("high");
|
|
105
|
+
for (const [path, method] of [["/api/requirements", "POST"], [`/api/requirements/${id}`, "PATCH"]] as const) {
|
|
106
|
+
const bad = await api(path, { method, body: JSON.stringify({ name: "x", priority: "urgent" }) });
|
|
107
|
+
expect(bad.status).toBe(400);
|
|
108
|
+
expect(bad.body.error).toBe("invalid_priority");
|
|
109
|
+
}
|
|
110
|
+
const listed = await api("/api/requirements");
|
|
111
|
+
expect(listed.body.capabilities).toContain("priority_lowest");
|
|
112
|
+
expect(listed.body.requirements.find((r: any) => r.id === id).priority).toBe("lowest");
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// GET /api/requirements 的 q= 服务端搜索与 limit / cursor 分页(requirements-search.ts)。一次性 hub:临时 DB、端口 0。
|
|
2
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
3
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
const dir = mkdtempSync(join(tmpdir(), "anet-req-search-"));
|
|
8
|
+
process.env.COMMHUB_DB = join(dir, "hub.db");
|
|
9
|
+
|
|
10
|
+
let server: { port: number; stop?: (force?: boolean) => void };
|
|
11
|
+
let base = "";
|
|
12
|
+
let token = "";
|
|
13
|
+
let otherToken = "";
|
|
14
|
+
let net = "";
|
|
15
|
+
let db: any;
|
|
16
|
+
|
|
17
|
+
async function api(tok: string, path: string, init?: RequestInit) {
|
|
18
|
+
const res = await fetch(`${base}${path}`, { ...init, headers: { Authorization: `Bearer ${tok}`, "Content-Type": "application/json", ...(init?.headers || {}) } });
|
|
19
|
+
return { status: res.status, body: await res.json() as any };
|
|
20
|
+
}
|
|
21
|
+
const list = (query: string, tok = token) => api(tok, `/api/requirements?network_id=${net}${query ? `&${query}` : ""}`);
|
|
22
|
+
const ids = async (query: string, tok = token) => {
|
|
23
|
+
const r = await list(query, tok);
|
|
24
|
+
expect(r.status).toBe(200);
|
|
25
|
+
return (r.body.requirements as any[]).map(x => x.name);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
beforeAll(async () => {
|
|
29
|
+
({ db } = await import("./db.js"));
|
|
30
|
+
const { register } = await import("./auth.js");
|
|
31
|
+
const owner = register(`search_owner_${Date.now()}`, "SearchOwner123!", undefined, "seed");
|
|
32
|
+
token = owner.token!;
|
|
33
|
+
net = owner.network_id!;
|
|
34
|
+
const other = register(`search_other_${Date.now()}`, "SearchOther123!", undefined, "seed");
|
|
35
|
+
otherToken = other.token!;
|
|
36
|
+
const mod: any = await import("./server.js");
|
|
37
|
+
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
38
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
39
|
+
db.run("INSERT INTO nodes(node_id,node_name,alias,display_name,network_id) VALUES (?1,?2,?2,?3,?4)", ["n_search_a", "portal-cow", "示例-门户牛", net]);
|
|
40
|
+
const project = await api(token, "/api/requirements/projects", { method: "POST", body: JSON.stringify({ name: "示例门户项目", network_id: net }) });
|
|
41
|
+
const pid = project.body.project.id;
|
|
42
|
+
const mk = (name: string, extra: Record<string, unknown> = {}) => api(token, "/api/requirements", { method: "POST", body: JSON.stringify({ name, network_id: net, ...extra }) });
|
|
43
|
+
await mk("企业组织树权限设置");
|
|
44
|
+
await mk("Portal Token 管理 | 估算成本卡布局", { agent_owner: { kind: "node", id: "n_search_a" }, tags: ["前端"] });
|
|
45
|
+
await mk("Space 网络策略 | 历史 receipt 误报", { description: "看  和 [链接文字](https://example.com/x),重启导致 binding 丢失" });
|
|
46
|
+
await mk("子项目的卡", { project_id: pid });
|
|
47
|
+
const arch = await mk("已归档的组织树旧方案");
|
|
48
|
+
await api(token, `/api/requirements/${arch.body.requirement.id}`, { method: "PATCH", body: JSON.stringify({ archived: true }) });
|
|
49
|
+
await api(otherToken, "/api/requirements", { method: "POST", body: JSON.stringify({ name: "别的网络的组织树" }) });
|
|
50
|
+
}, 30_000);
|
|
51
|
+
|
|
52
|
+
afterAll(() => {
|
|
53
|
+
try { server?.stop?.(true); } catch {}
|
|
54
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("q= server-side search (same semantics as the app's task search)", () => {
|
|
58
|
+
test("title substring, CJK, case-insensitive, full-width input", async () => {
|
|
59
|
+
expect(await ids("q=" + encodeURIComponent("组织树"))).toEqual(["企业组织树权限设置"]);
|
|
60
|
+
expect(await ids("q=portal")).toEqual(["Portal Token 管理 | 估算成本卡布局"]);
|
|
61
|
+
expect(await ids("q=" + encodeURIComponent("PORTAL"))).toEqual(["Portal Token 管理 | 估算成本卡布局"]);
|
|
62
|
+
});
|
|
63
|
+
test("agent owner display name, tags, project name, description text (not URLs)", async () => {
|
|
64
|
+
expect(await ids("q=" + encodeURIComponent("门户牛"))).toEqual(["Portal Token 管理 | 估算成本卡布局"]);
|
|
65
|
+
expect(await ids("q=" + encodeURIComponent("前端"))).toEqual(["Portal Token 管理 | 估算成本卡布局"]);
|
|
66
|
+
expect(await ids("q=" + encodeURIComponent("示例门户项目"))).toEqual(["子项目的卡"]);
|
|
67
|
+
expect(await ids("q=binding")).toEqual(["Space 网络策略 | 历史 receipt 误报"]);
|
|
68
|
+
expect(await ids("q=" + encodeURIComponent("链接文字"))).toEqual(["Space 网络策略 | 历史 receipt 误报"]);
|
|
69
|
+
expect(await ids("q=abc123")).toEqual([]);
|
|
70
|
+
expect(await ids("q=example.com")).toEqual([]);
|
|
71
|
+
});
|
|
72
|
+
test("space-separated terms are ANDed across fields", async () => {
|
|
73
|
+
expect(await ids("q=" + encodeURIComponent("成本 门户牛"))).toEqual(["Portal Token 管理 | 估算成本卡布局"]);
|
|
74
|
+
expect(await ids("q=" + encodeURIComponent("成本 receipt"))).toEqual([]);
|
|
75
|
+
});
|
|
76
|
+
test("archived only with archived=true / include_archived=1; other networks never", async () => {
|
|
77
|
+
expect(await ids("q=" + encodeURIComponent("组织树"))).toEqual(["企业组织树权限设置"]);
|
|
78
|
+
expect(await ids("archived=true&q=" + encodeURIComponent("组织树"))).toEqual(["已归档的组织树旧方案"]);
|
|
79
|
+
expect((await ids("include_archived=1&q=" + encodeURIComponent("组织树"))).sort()).toEqual(["企业组织树权限设置", "已归档的组织树旧方案"].sort());
|
|
80
|
+
});
|
|
81
|
+
test("combines with the other filters", async () => {
|
|
82
|
+
expect(await ids("status=done&q=portal")).toEqual([]);
|
|
83
|
+
expect(await ids("agent_owner=node:n_search_a&q=" + encodeURIComponent("成本"))).toEqual(["Portal Token 管理 | 估算成本卡布局"]);
|
|
84
|
+
});
|
|
85
|
+
test("empty / whitespace q = no search; too long q = 400", async () => {
|
|
86
|
+
expect((await ids("q=")).length).toBe(4);
|
|
87
|
+
expect((await ids("q=%20%20")).length).toBe(4);
|
|
88
|
+
const r = await list("q=" + "x".repeat(201));
|
|
89
|
+
expect(r.status).toBe(400);
|
|
90
|
+
expect(r.body.error).toBe("invalid_q");
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("q= also matches task ids: #N / N on seq (#2139), full id and 8+ char prefix", () => {
|
|
95
|
+
const idOf = async (name: string) => (await list("limit=1000&include_archived=1")).body.requirements.find((r: any) => r.name === name).id as string;
|
|
96
|
+
test("full id and id prefix (with or without req_), not shorter prefixes", async () => {
|
|
97
|
+
const id = await idOf("企业组织树权限设置");
|
|
98
|
+
const bare = id.replace(/^req_/, "");
|
|
99
|
+
expect(await ids("q=" + encodeURIComponent(id))).toEqual(["企业组织树权限设置"]);
|
|
100
|
+
expect(await ids("q=" + encodeURIComponent(id.toUpperCase()))).toEqual(["企业组织树权限设置"]);
|
|
101
|
+
expect(await ids("q=" + encodeURIComponent(bare.slice(0, 10)))).toEqual(["企业组织树权限设置"]);
|
|
102
|
+
expect(await ids("q=" + encodeURIComponent(`req_${bare.slice(0, 9)}`))).toEqual(["企业组织树权限设置"]);
|
|
103
|
+
// 7 位以下的前缀不算 ID(否则输入几个字母就搜出一堆)
|
|
104
|
+
expect(await ids("q=" + encodeURIComponent(`req_${bare.slice(0, 5)}`))).toEqual([]);
|
|
105
|
+
});
|
|
106
|
+
test("#N / full-width #N / padded #N match the task's seq (#2139); #N plus text is plain text; filters still apply", async () => {
|
|
107
|
+
const target = await idOf("Space 网络策略 | 历史 receipt 误报");
|
|
108
|
+
const n = db.get("SELECT seq FROM requirements WHERE requirement_id = ?1", target).seq as number;
|
|
109
|
+
expect(typeof n).toBe("number");
|
|
110
|
+
expect(await ids(`q=%23${n}`)).toEqual(["Space 网络策略 | 历史 receipt 误报"]);
|
|
111
|
+
const fullWidth = `#${String(n).replace(/\d/g, d => String.fromCharCode(0xff10 + Number(d)))}`;
|
|
112
|
+
expect(await ids("q=" + encodeURIComponent(fullWidth))).toEqual(["Space 网络策略 | 历史 receipt 误报"]);
|
|
113
|
+
expect(await ids("q=" + encodeURIComponent(` #${n} `))).toEqual(["Space 网络策略 | 历史 receipt 误报"]);
|
|
114
|
+
// 「#N receipt」整句不是 ID,只按文字「且」:没有卡同时含「#N」和「receipt」
|
|
115
|
+
expect(await ids("q=" + encodeURIComponent(`#${n} receipt`))).toEqual([]);
|
|
116
|
+
// ID 命中也受筛选管
|
|
117
|
+
expect(await ids(`status=done&q=%23${n}`)).toEqual([]);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("paging: limit / cursor / has_more / next_cursor", () => {
|
|
122
|
+
test("no params: legacy shape plus has_more=false, next_cursor=null, capabilities advertise search + paging", async () => {
|
|
123
|
+
const r = await list("");
|
|
124
|
+
expect(r.body.requirements.length).toBe(4);
|
|
125
|
+
expect(r.body.has_more).toBe(false);
|
|
126
|
+
expect(r.body.next_cursor).toBeNull();
|
|
127
|
+
expect(r.body.capabilities).toContain("search");
|
|
128
|
+
expect(r.body.capabilities).toContain("paging");
|
|
129
|
+
});
|
|
130
|
+
test("walking pages returns every row exactly once, newest first, including same-timestamp ties", async () => {
|
|
131
|
+
// 25 张,其中 10 张同一个 created_at(翻页边界落在并列上也不能漏 / 重)
|
|
132
|
+
const same = new Date(Date.now() + 60_000).toISOString();
|
|
133
|
+
for (let i = 0; i < 25; i++) {
|
|
134
|
+
const c = await api(token, "/api/requirements", { method: "POST", body: JSON.stringify({ name: `页-${String(i).padStart(2, "0")}`, network_id: net }) });
|
|
135
|
+
if (i >= 10 && i < 20) db.run("UPDATE requirements SET created_at = ?1 WHERE requirement_id = ?2", [same, c.body.requirement.id]);
|
|
136
|
+
}
|
|
137
|
+
const full = await ids("limit=1000");
|
|
138
|
+
expect(full.length).toBe(29);
|
|
139
|
+
const seen: string[] = [];
|
|
140
|
+
let cursor = "";
|
|
141
|
+
let pages = 0;
|
|
142
|
+
for (;;) {
|
|
143
|
+
const r = await list(`limit=7${cursor ? `&cursor=${cursor}` : ""}`);
|
|
144
|
+
expect(r.status).toBe(200);
|
|
145
|
+
seen.push(...r.body.requirements.map((x: any) => x.name));
|
|
146
|
+
pages++;
|
|
147
|
+
if (!r.body.has_more) { expect(r.body.next_cursor).toBeNull(); break; }
|
|
148
|
+
expect(typeof r.body.next_cursor).toBe("string");
|
|
149
|
+
cursor = r.body.next_cursor;
|
|
150
|
+
expect(pages).toBeLessThan(10);
|
|
151
|
+
}
|
|
152
|
+
expect(pages).toBe(5);
|
|
153
|
+
expect(seen).toEqual(full);
|
|
154
|
+
expect(new Set(seen).size).toBe(29);
|
|
155
|
+
});
|
|
156
|
+
test("q= pages too", async () => {
|
|
157
|
+
const first = await list("limit=4&q=" + encodeURIComponent("页-"));
|
|
158
|
+
expect(first.body.requirements.length).toBe(4);
|
|
159
|
+
expect(first.body.has_more).toBe(true);
|
|
160
|
+
const rest = await list(`limit=100&q=${encodeURIComponent("页-")}&cursor=${first.body.next_cursor}`);
|
|
161
|
+
expect(rest.body.requirements.length).toBe(21);
|
|
162
|
+
expect(rest.body.has_more).toBe(false);
|
|
163
|
+
});
|
|
164
|
+
test("default limit stays 500 (legacy clients see the same cap)", async () => {
|
|
165
|
+
const { DEFAULT_LIST_LIMIT } = await import("./requirements-search.js");
|
|
166
|
+
expect(DEFAULT_LIST_LIMIT).toBe(500);
|
|
167
|
+
});
|
|
168
|
+
test("bad limit / cursor → 400", async () => {
|
|
169
|
+
for (const q of ["limit=0", "limit=1001", "limit=abc", "limit=-1", "cursor=%%%", "cursor=bm9wZQ"]) {
|
|
170
|
+
const r = await list(q);
|
|
171
|
+
expect(r.status).toBe(400);
|
|
172
|
+
expect(["invalid_limit", "invalid_cursor"]).toContain(r.body.error);
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
test("ETag still works with the new fields", async () => {
|
|
176
|
+
const first = await fetch(`${base}/api/requirements?network_id=${net}&limit=5`, { headers: { Authorization: `Bearer ${token}` } });
|
|
177
|
+
const etag = first.headers.get("etag")!;
|
|
178
|
+
await first.text();
|
|
179
|
+
const again = await fetch(`${base}/api/requirements?network_id=${net}&limit=5`, { headers: { Authorization: `Bearer ${token}`, "If-None-Match": etag } });
|
|
180
|
+
expect(again.status).toBe(304);
|
|
181
|
+
});
|
|
182
|
+
});
|