@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,32 @@
|
|
|
1
|
+
// requirements-search.ts 的纯函数:任务 ID 匹配(与 App 的 task-short-id.ts matchesTaskId 同一规则)。
|
|
2
|
+
import { describe, expect, test } from "bun:test";
|
|
3
|
+
import { matchesTaskId, parseListQuery, taskIdQuery } from "./requirements-search.js";
|
|
4
|
+
|
|
5
|
+
const row = (id: string, seq: number | null) => ({ requirement_id: id, seq });
|
|
6
|
+
|
|
7
|
+
describe("matchesTaskId", () => {
|
|
8
|
+
test("#N / N match seq exactly (#7 does not hit #70)", () => {
|
|
9
|
+
expect(matchesTaskId(row("req_a", 7), "#7")).toBe(true);
|
|
10
|
+
expect(matchesTaskId(row("req_a", 7), "7")).toBe(true);
|
|
11
|
+
expect(matchesTaskId(row("req_a", 70), "#7")).toBe(false);
|
|
12
|
+
expect(matchesTaskId(row("req_a", null), "#7")).toBe(false);
|
|
13
|
+
});
|
|
14
|
+
test("full id and 8+ char prefix, with or without req_; shorter prefixes do not count", () => {
|
|
15
|
+
const id = "req_0a1b2c3d-4e5f-6789-abcd-ef0123456789";
|
|
16
|
+
expect(matchesTaskId(row(id, 1), id)).toBe(true);
|
|
17
|
+
expect(matchesTaskId(row(id, 1), "0a1b2c3d")).toBe(true);
|
|
18
|
+
expect(matchesTaskId(row(id, 1), "req_0a1b2c3d-4")).toBe(true);
|
|
19
|
+
expect(matchesTaskId(row(id, 1), "0a1b2c3")).toBe(false);
|
|
20
|
+
expect(matchesTaskId(row(id, 1), "")).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
test("taskIdQuery: trim, NFKC (full-width #7 → #7), lowercase", () => {
|
|
23
|
+
expect(taskIdQuery(" #7 ")).toBe("#7");
|
|
24
|
+
expect(taskIdQuery("REQ_0A1B")).toBe("req_0a1b");
|
|
25
|
+
});
|
|
26
|
+
test("parseListQuery: idQuery only when there are terms", () => {
|
|
27
|
+
const q = (s: string) => parseListQuery(new URLSearchParams(s)) as any;
|
|
28
|
+
expect(q("q=%EF%BC%83%EF%BC%97").idQuery).toBe("#7");
|
|
29
|
+
expect(q("q=%20%20").idQuery).toBe("");
|
|
30
|
+
expect(q("").idQuery).toBe("");
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// GET /api/requirements 的服务端搜索(q=)与分页(limit / cursor)。纯函数,requirements.ts 接线。
|
|
2
|
+
//
|
|
3
|
+
// 为什么要:列表一直是 `ORDER BY created_at DESC LIMIT 500`,App 的任务搜索只在读到的行里搜 ——
|
|
4
|
+
// 生产一个网络已经 ~400 张卡,过了 500 最老的那些就搜不到、也翻不到。
|
|
5
|
+
//
|
|
6
|
+
// 语义与 App 的任务搜索(agent-network-app src/task-search.ts)一致,两边搜同一个词得到同一批卡:
|
|
7
|
+
// · 字段:标题、描述(去掉 markdown 图片 / 链接的地址,链接文字保留)、负责人 / 负责 Agent / 参与人的显示名、
|
|
8
|
+
// 旧 assignee 文本、项目名、标签
|
|
9
|
+
// · NFKC(全角 → 半角)+ 小写;中文按子串;空白隔开的词是「且」,每个词可以落在不同字段,但不跨字段拼
|
|
10
|
+
// · 调用者看不见的节点(受限成员)在卡片上本来就被隐去,它的名字也不参与匹配 —— 否则 q= 就成了
|
|
11
|
+
// 「这张卡是不是某个没授权给我的 Agent 负责」的探测器
|
|
12
|
+
//
|
|
13
|
+
// 分页:按 (created_at DESC, requirement_id DESC) 排,cursor = 上一页最后一行的这两个值(不透明的 base64url)。
|
|
14
|
+
// 不带 limit / cursor / q 的旧请求:同样的 500 行、同样的顺序,响应只多两个字段 has_more / next_cursor。
|
|
15
|
+
|
|
16
|
+
export const DEFAULT_LIST_LIMIT = 500;
|
|
17
|
+
export const MAX_LIST_LIMIT = 1000;
|
|
18
|
+
export const MAX_QUERY_LENGTH = 200;
|
|
19
|
+
|
|
20
|
+
const fold = (s: string): string => s.normalize("NFKC").toLocaleLowerCase();
|
|
21
|
+
|
|
22
|
+
/** 搜索词:归一后按空白切开,去重、去空。 */
|
|
23
|
+
export function searchTerms(q: string): string[] {
|
|
24
|
+
return [...new Set(fold(q).split(/\s+/).filter(Boolean))];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 描述是 markdown:图片 / 链接的地址不参与搜索,链接文字保留;html 标签去掉。 */
|
|
28
|
+
export function descriptionText(md: string | null | undefined): string {
|
|
29
|
+
if (!md) return "";
|
|
30
|
+
return md
|
|
31
|
+
.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
|
|
32
|
+
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
|
|
33
|
+
.replace(/<[^>]+>/g, " ");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type Ref = { kind?: unknown; id?: unknown } | null | undefined;
|
|
37
|
+
export type SearchableRow = {
|
|
38
|
+
name: string;
|
|
39
|
+
description: string;
|
|
40
|
+
assignee: string;
|
|
41
|
+
tags: readonly string[];
|
|
42
|
+
project_id: string | null;
|
|
43
|
+
owner: Ref;
|
|
44
|
+
agent_owner: Ref;
|
|
45
|
+
participants: readonly Ref[] | unknown;
|
|
46
|
+
};
|
|
47
|
+
export type NameMaps = {
|
|
48
|
+
/** `user:<id>` / `node:<id>` → 显示名(看不见的节点不在里面)。 */
|
|
49
|
+
people: ReadonlyMap<string, string>;
|
|
50
|
+
/** project_id → 项目名。 */
|
|
51
|
+
projects: ReadonlyMap<string, string>;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const refName = (ref: Ref, maps: NameMaps): string => {
|
|
55
|
+
if (!ref || typeof ref !== "object" || typeof ref.id !== "string" || (ref.kind !== "user" && ref.kind !== "node")) return "";
|
|
56
|
+
return maps.people.get(`${ref.kind}:${ref.id}`) ?? "";
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** 一张卡能被搜到的文字,字段之间用换行隔开(一个词不会跨两个字段拼出来)。 */
|
|
60
|
+
export function searchText(row: SearchableRow, maps: NameMaps): string {
|
|
61
|
+
const participants = Array.isArray(row.participants) ? row.participants as Ref[] : [];
|
|
62
|
+
return fold([
|
|
63
|
+
row.name,
|
|
64
|
+
descriptionText(row.description),
|
|
65
|
+
refName(row.owner, maps),
|
|
66
|
+
refName(row.agent_owner, maps),
|
|
67
|
+
...participants.map(r => refName(r, maps)),
|
|
68
|
+
row.assignee,
|
|
69
|
+
row.project_id ? maps.projects.get(row.project_id) ?? "" : "",
|
|
70
|
+
...row.tags,
|
|
71
|
+
].filter(Boolean).join("\n"));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function matchesTerms(row: SearchableRow, terms: readonly string[], maps: NameMaps): boolean {
|
|
75
|
+
if (!terms.length) return true;
|
|
76
|
+
const hay = searchText(row, maps);
|
|
77
|
+
return terms.every(t => hay.includes(t));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export type Cursor = { createdAt: string; id: string };
|
|
81
|
+
|
|
82
|
+
export function encodeCursor(c: Cursor): string {
|
|
83
|
+
return Buffer.from(JSON.stringify([c.createdAt, c.id]), "utf8").toString("base64url");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function decodeCursor(raw: string): Cursor | null {
|
|
87
|
+
if (!raw || raw.length > 600 || !/^[A-Za-z0-9_-]+$/.test(raw)) return null;
|
|
88
|
+
try {
|
|
89
|
+
const v = JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
|
|
90
|
+
if (!Array.isArray(v) || v.length !== 2 || typeof v[0] !== "string" || typeof v[1] !== "string" || !v[0] || !v[1]) return null;
|
|
91
|
+
return { createdAt: v[0], id: v[1] };
|
|
92
|
+
} catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export type ListQuery = { limit: number; cursor: Cursor | null; terms: string[]; /** 整句当任务 ID 去对(taskIdQuery);空 = 没有搜索。 */ idQuery: string; /** #471 —— 只要带这个标签的卡(精确、区分大小写);null = 不按标签筛。 */ tag: string | null };
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 任务 ID(同 App 的 task-short-id.ts matchesTaskId):「#42」「42」精确对短号 seq;完整 id,或 8 位以上的
|
|
101
|
+
* id 前缀(带不带 req_ 都行)对主键。整句当一个 ID —— 和文字匹配是「或」,不拆成「ID 且文字」。
|
|
102
|
+
* 全角「#42」先 NFKC 归一(ID 本身都是 ASCII)。
|
|
103
|
+
*/
|
|
104
|
+
export const taskIdQuery = (q: string): string => q.trim().normalize("NFKC").toLowerCase();
|
|
105
|
+
export const seqOfQuery = (idQuery: string): number | null => {
|
|
106
|
+
const m = /^#?(\d{1,15})$/.exec(idQuery);
|
|
107
|
+
return m ? Number(m[1]) : null;
|
|
108
|
+
};
|
|
109
|
+
export function matchesTaskId(row: { requirement_id: string; seq?: number | null }, idQuery: string): boolean {
|
|
110
|
+
if (!idQuery) return false;
|
|
111
|
+
const n = seqOfQuery(idQuery);
|
|
112
|
+
if (n !== null) return typeof row.seq === "number" && row.seq === n;
|
|
113
|
+
const id = row.requirement_id.toLowerCase();
|
|
114
|
+
if (idQuery === id) return true;
|
|
115
|
+
const bare = idQuery.replace(/^req_/, "");
|
|
116
|
+
return bare.length >= 8 && id.replace(/^req_/, "").startsWith(bare);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** 解析 limit / cursor / q。不合法 → 错误码(400)。都没带 = 旧行为(500 行、第一页、不搜)。 */
|
|
120
|
+
export function parseListQuery(params: URLSearchParams): ListQuery | { error: string } {
|
|
121
|
+
let limit = DEFAULT_LIST_LIMIT;
|
|
122
|
+
const rawLimit = params.get("limit");
|
|
123
|
+
if (rawLimit !== null) {
|
|
124
|
+
if (!/^\d{1,5}$/.test(rawLimit)) return { error: "invalid_limit" };
|
|
125
|
+
limit = Number(rawLimit);
|
|
126
|
+
if (limit < 1 || limit > MAX_LIST_LIMIT) return { error: "invalid_limit" };
|
|
127
|
+
}
|
|
128
|
+
let cursor: Cursor | null = null;
|
|
129
|
+
const rawCursor = params.get("cursor");
|
|
130
|
+
if (rawCursor !== null && rawCursor !== "") {
|
|
131
|
+
cursor = decodeCursor(rawCursor);
|
|
132
|
+
if (!cursor) return { error: "invalid_cursor" };
|
|
133
|
+
}
|
|
134
|
+
const q = params.get("q") ?? "";
|
|
135
|
+
if (q.length > MAX_QUERY_LENGTH) return { error: "invalid_q" };
|
|
136
|
+
const terms = searchTerms(q);
|
|
137
|
+
// #471 —— tag=:和卡片标签同一套规范(去首尾空白、1–20 个字符、不含控制字符);不带 = 旧行为。
|
|
138
|
+
const rawTag = params.get("tag");
|
|
139
|
+
let tag: string | null = null;
|
|
140
|
+
if (rawTag !== null) {
|
|
141
|
+
tag = rawTag.trim();
|
|
142
|
+
if (!tag || [...tag].length > 20 || /[\u0000-\u001f\u007f]/u.test(tag)) return { error: "invalid_tag" };
|
|
143
|
+
}
|
|
144
|
+
return { limit, cursor, terms, idQuery: terms.length ? taskIdQuery(q) : "", tag };
|
|
145
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// 任务短号 seq:新建领号(并发不撞)、按网络各自编号、按 #N 查 / 改、删除和归档不回收号、MCP 工具也收 #N。
|
|
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-seq-"));
|
|
8
|
+
process.env.COMMHUB_DB = join(dir, "hub.db");
|
|
9
|
+
|
|
10
|
+
let server: { port: number; stop?: (force?: boolean) => void };
|
|
11
|
+
let base = "";
|
|
12
|
+
let ownerToken = "", otherToken = "", nodeToken = "";
|
|
13
|
+
let ownerNet = "", otherNet = "", ownerId = "", otherId = "";
|
|
14
|
+
|
|
15
|
+
async function api(token: string, path: string, init?: RequestInit) {
|
|
16
|
+
const res = await fetch(`${base}${path}`, { ...init, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...(init?.headers || {}) } });
|
|
17
|
+
return { status: res.status, body: await res.json() as any };
|
|
18
|
+
}
|
|
19
|
+
const create = (token: string, body: Record<string, unknown>, query = "") => api(token, `/api/requirements${query}`, { method: "POST", body: JSON.stringify(body) });
|
|
20
|
+
|
|
21
|
+
beforeAll(async () => {
|
|
22
|
+
const { register, createNetworkTokenForNode } = await import("./auth.js");
|
|
23
|
+
// 第一个注册的用户是 Hub 管理员、作用域是全部网络;让它先占掉,下面两个是普通用户(各自一个网络)。
|
|
24
|
+
register(`seq_admin_${Date.now()}`, "SeqAdmin123!x", undefined, "seed");
|
|
25
|
+
const o = register(`seq_owner_${Date.now()}`, "SeqOwner123!x", undefined, "seed");
|
|
26
|
+
const x = register(`seq_other_${Date.now()}`, "SeqOther123!x", undefined, "seed");
|
|
27
|
+
ownerToken = o.token!; ownerNet = o.network_id!; ownerId = o.user!.user_id;
|
|
28
|
+
otherToken = x.token!; otherNet = x.network_id!; otherId = x.user!.user_id;
|
|
29
|
+
nodeToken = createNetworkTokenForNode(ownerId, ownerNet, "seq-node").token!;
|
|
30
|
+
const mod: any = await import("./server.js");
|
|
31
|
+
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
32
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
33
|
+
}, 30_000);
|
|
34
|
+
|
|
35
|
+
afterAll(() => {
|
|
36
|
+
try { server?.stop?.(true); } catch {}
|
|
37
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("requirement short numbers (#N)", () => {
|
|
41
|
+
test("capability is advertised and create returns seq", async () => {
|
|
42
|
+
const list = await api(ownerToken, "/api/requirements");
|
|
43
|
+
expect(list.body.capabilities).toContain("requirement_seq");
|
|
44
|
+
const first = await create(ownerToken, { name: "第一张" });
|
|
45
|
+
expect(first.status).toBe(201);
|
|
46
|
+
expect(first.body.requirement.seq).toBe(1);
|
|
47
|
+
expect(first.body.requirement.id).toMatch(/^req_/);
|
|
48
|
+
const got = await api(ownerToken, `/api/requirements/${first.body.requirement.id}`);
|
|
49
|
+
expect(got.body.requirement.seq).toBe(1);
|
|
50
|
+
expect((await api(ownerToken, "/api/requirements")).body.requirements[0].seq).toBe(1);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("concurrent creates get distinct, gap-free seqs", async () => {
|
|
54
|
+
const before = (await create(ownerToken, { name: "before" })).body.requirement.seq as number;
|
|
55
|
+
const results = await Promise.all(Array.from({ length: 40 }, (_, i) => create(i % 2 ? nodeToken : ownerToken, { name: `并发 ${i}` })));
|
|
56
|
+
expect(results.every(r => r.status === 201)).toBe(true);
|
|
57
|
+
const seqs = results.map(r => r.body.requirement.seq as number).sort((a, b) => a - b);
|
|
58
|
+
expect(new Set(seqs).size).toBe(40);
|
|
59
|
+
expect(seqs).toEqual(Array.from({ length: 40 }, (_, i) => before + 1 + i));
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("seq is per network", async () => {
|
|
63
|
+
const a = await create(otherToken, { name: "别的网络第一张" });
|
|
64
|
+
expect(a.body.requirement.seq).toBe(1);
|
|
65
|
+
const b = await create(otherToken, { name: "别的网络第二张" });
|
|
66
|
+
expect(b.body.requirement.seq).toBe(2);
|
|
67
|
+
// 同一个 #1 在两个网络各指各的卡
|
|
68
|
+
expect((await api(ownerToken, "/api/requirements/%231")).body.requirement.name).toBe("第一张");
|
|
69
|
+
expect((await api(otherToken, "/api/requirements/%231")).body.requirement.name).toBe("别的网络第一张");
|
|
70
|
+
// 别的网络的号查不到对方的卡
|
|
71
|
+
expect((await api(otherToken, "/api/requirements/%2310")).status).toBe(404);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("lookup and patch by #N; list filter ?seq=; malformed forms rejected", async () => {
|
|
75
|
+
const c = (await create(ownerToken, { name: "按号查", checklist: [{ id: "a", text: "一" }] })).body.requirement;
|
|
76
|
+
const ref = `%23${c.seq}`;
|
|
77
|
+
const got = await api(ownerToken, `/api/requirements/${ref}`);
|
|
78
|
+
expect(got.status).toBe(200);
|
|
79
|
+
expect(got.body.requirement.id).toBe(c.id);
|
|
80
|
+
const patched = await api(nodeToken, `/api/requirements/${ref}`, { method: "PATCH", body: JSON.stringify({ column: "doing" }) });
|
|
81
|
+
expect(patched.status).toBe(200);
|
|
82
|
+
expect(patched.body.requirement.column).toBe("doing");
|
|
83
|
+
expect(patched.body.requirement.seq).toBe(c.seq); // 改卡不改号
|
|
84
|
+
const ticked = await api(ownerToken, `/api/requirements/${ref}/checklist/a`, { method: "PATCH", body: JSON.stringify({ done: true }) });
|
|
85
|
+
expect(ticked.body.requirement.checklist[0].done).toBe(true);
|
|
86
|
+
const listed = await api(ownerToken, `/api/requirements?seq=${c.seq}`);
|
|
87
|
+
expect(listed.body.requirements.map((r: any) => r.id)).toEqual([c.id]);
|
|
88
|
+
expect((await api(ownerToken, "/api/requirements?seq=abc")).status).toBe(400);
|
|
89
|
+
expect((await api(ownerToken, "/api/requirements?seq=0")).status).toBe(400);
|
|
90
|
+
// 旧 id 照常;裸数字、「#」「#0」「#-1」都不是短号,按 id 查 → 404
|
|
91
|
+
expect((await api(ownerToken, `/api/requirements/${c.id}`)).body.requirement.seq).toBe(c.seq);
|
|
92
|
+
for (const bad of [String(c.seq), "%23", "%230", "%23-1", `%23${c.seq}x`]) expect((await api(ownerToken, `/api/requirements/${bad}`)).status).toBe(404);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("deleting or archiving never frees a number", async () => {
|
|
96
|
+
const top = (await create(ownerToken, { name: "最大号" })).body.requirement;
|
|
97
|
+
expect((await api(ownerToken, `/api/requirements/%23${top.seq}`, { method: "DELETE" })).status).toBe(200);
|
|
98
|
+
expect((await api(ownerToken, `/api/requirements/%23${top.seq}`)).status).toBe(404);
|
|
99
|
+
const next = (await create(ownerToken, { name: "删之后" })).body.requirement;
|
|
100
|
+
expect(next.seq).toBe(top.seq + 1);
|
|
101
|
+
await api(ownerToken, `/api/requirements/${next.id}`, { method: "PATCH", body: JSON.stringify({ archived: true }) });
|
|
102
|
+
expect((await create(ownerToken, { name: "归档之后" })).body.requirement.seq).toBe(next.seq + 1);
|
|
103
|
+
// 归档的卡按号照样能查到
|
|
104
|
+
expect((await api(ownerToken, `/api/requirements/%23${next.seq}`)).body.requirement.archived).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("a create that loses on client_id / external_ref does not burn a number", async () => {
|
|
108
|
+
const a = (await create(ownerToken, { name: "幂等", client_id: "seq-client-1" })).body.requirement;
|
|
109
|
+
const again = await create(ownerToken, { name: "幂等", client_id: "seq-client-1" });
|
|
110
|
+
expect(again.body.requirement.seq).toBe(a.seq);
|
|
111
|
+
await create(ownerToken, { name: "外部", external_ref: "github:acme/seq#1" });
|
|
112
|
+
expect((await create(ownerToken, { name: "外部", external_ref: "github:acme/seq#1" })).status).toBe(409);
|
|
113
|
+
const after = (await create(ownerToken, { name: "之后" })).body.requirement;
|
|
114
|
+
expect(after.seq).toBe(a.seq + 2);
|
|
115
|
+
// upsert 新建也领号,改的时候号不变
|
|
116
|
+
const up = await api(ownerToken, "/api/requirements/upsert", { method: "POST", body: JSON.stringify({ name: "同步", external_ref: "github:acme/seq#2" }) });
|
|
117
|
+
expect(up.body.requirement.seq).toBe(after.seq + 1);
|
|
118
|
+
const up2 = await api(ownerToken, "/api/requirements/upsert", { method: "POST", body: JSON.stringify({ name: "同步改名", external_ref: "github:acme/seq#2" }) });
|
|
119
|
+
expect(up2.body.requirement.seq).toBe(after.seq + 1);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("a user whose scope spans two networks gets 409 ambiguous_seq for #N, and network_id resolves it", async () => {
|
|
123
|
+
const { db } = await import("./db.js");
|
|
124
|
+
const { addNetworkMember } = await import("./auth.js");
|
|
125
|
+
// 看全网的成员(RFC-038 §9 之前的语义):这里测的是跨网络 #N 歧义,不是任务范围。
|
|
126
|
+
addNetworkMember(otherNet, ownerId, "member", otherId, { taskAccess: "all" });
|
|
127
|
+
const both = await api(ownerToken, "/api/requirements/%231");
|
|
128
|
+
expect(both.status).toBe(409);
|
|
129
|
+
expect(both.body.error).toBe("ambiguous_seq");
|
|
130
|
+
expect(both.body.networks.sort()).toEqual([ownerNet, otherNet].sort());
|
|
131
|
+
expect(both.body.message).toContain("pass network_id");
|
|
132
|
+
// MCP 上用户令牌(跨两个网络)同样:清楚的错误 + 带 network_id 就能查到
|
|
133
|
+
{
|
|
134
|
+
const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
|
|
135
|
+
const { InMemoryTransport } = await import("@modelcontextprotocol/sdk/inMemory.js");
|
|
136
|
+
const { McpServer } = await import("@modelcontextprotocol/sdk/server/mcp.js");
|
|
137
|
+
const { registerTools } = await import("./tools.js");
|
|
138
|
+
const mcp = new McpServer({ name: "seq-mcp-user", version: "1" });
|
|
139
|
+
registerTools(mcp, undefined, null, ownerId, "seq-owner", false, "tok_user");
|
|
140
|
+
const client = new Client({ name: "seq-mcp-user-client", version: "1" });
|
|
141
|
+
const [ct, st] = InMemoryTransport.createLinkedPair();
|
|
142
|
+
await mcp.connect(st); await client.connect(ct);
|
|
143
|
+
const call = async (name: string, args: Record<string, unknown>) => JSON.parse(((await client.callTool({ name, arguments: args })) as any).content[0].text);
|
|
144
|
+
try {
|
|
145
|
+
const amb = await call("requirements_get", { id: "#1" });
|
|
146
|
+
expect(amb.ok).toBe(false);
|
|
147
|
+
expect(amb.error).toBe("ambiguous_seq");
|
|
148
|
+
expect(amb.status).toBe(409);
|
|
149
|
+
expect(amb.message).toContain("pass network_id");
|
|
150
|
+
expect((await call("requirements_update", { id: "#1", priority: "low" })).error).toBe("ambiguous_seq");
|
|
151
|
+
expect((await call("requirements_get", { id: "#1", network_id: otherNet })).requirement.name).toBe("别的网络第一张");
|
|
152
|
+
expect((await call("requirements_get", { id: "#1", network_id: ownerNet })).requirement.name).toBe("第一张");
|
|
153
|
+
} finally { await client.close(); await mcp.close(); }
|
|
154
|
+
}
|
|
155
|
+
// 旧 id 不受影响
|
|
156
|
+
const firstId = (await api(ownerToken, `/api/requirements/%231?network_id=${ownerNet}`)).body.requirement.id;
|
|
157
|
+
expect((await api(ownerToken, `/api/requirements/${firstId}`)).status).toBe(200);
|
|
158
|
+
const scoped = await api(ownerToken, `/api/requirements/%231?network_id=${otherNet}`);
|
|
159
|
+
expect(scoped.status).toBe(200);
|
|
160
|
+
expect(scoped.body.requirement.name).toBe("别的网络第一张");
|
|
161
|
+
db.run("DELETE FROM network_members WHERE network_id = ?1 AND user_id = ?2", [otherNet, ownerId]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("MCP tools accept #N and expose seq", async () => {
|
|
165
|
+
const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
|
|
166
|
+
const { InMemoryTransport } = await import("@modelcontextprotocol/sdk/inMemory.js");
|
|
167
|
+
const { McpServer } = await import("@modelcontextprotocol/sdk/server/mcp.js");
|
|
168
|
+
const { registerTools } = await import("./tools.js");
|
|
169
|
+
const { db } = await import("./db.js");
|
|
170
|
+
const tokenId = db.get<{ token_id: string }>("SELECT token_id FROM api_tokens WHERE network_id = ?1 AND name LIKE 'node:%' LIMIT 1", ownerNet)?.token_id ?? null;
|
|
171
|
+
const mcp = new McpServer({ name: "seq-mcp", version: "1" });
|
|
172
|
+
registerTools(mcp, undefined, ownerNet, ownerId, "seq-node", true, tokenId);
|
|
173
|
+
const client = new Client({ name: "seq-mcp-client", version: "1" });
|
|
174
|
+
const [ct, st] = InMemoryTransport.createLinkedPair();
|
|
175
|
+
await mcp.connect(st); await client.connect(ct);
|
|
176
|
+
const call = async (name: string, args: Record<string, unknown>) => {
|
|
177
|
+
const r: any = await client.callTool({ name, arguments: args });
|
|
178
|
+
return r.isError ? { mcpError: true } : JSON.parse(r.content[0].text);
|
|
179
|
+
};
|
|
180
|
+
try {
|
|
181
|
+
const made = await call("requirements_create", { name: "MCP 建的", checklist: [{ id: "k", text: "一" }] });
|
|
182
|
+
expect(Number.isInteger(made.requirement.seq)).toBe(true);
|
|
183
|
+
const n = `#${made.requirement.seq}`;
|
|
184
|
+
expect((await call("requirements_get", { id: n })).requirement.id).toBe(made.requirement.id);
|
|
185
|
+
expect((await call("requirements_update", { id: n, priority: "high" })).requirement.priority).toBe("high");
|
|
186
|
+
expect((await call("requirements_checklist_toggle", { id: n, item_id: "k", done: true })).requirement.checklist[0].done).toBe(true);
|
|
187
|
+
const listed = await call("requirements_list", { seq: made.requirement.seq });
|
|
188
|
+
expect(listed.requirements.map((r: any) => r.id)).toEqual([made.requirement.id]);
|
|
189
|
+
expect((await call("requirements_list", { seq: 0 })).mcpError).toBe(true);
|
|
190
|
+
expect((await call("requirements_get", { id: "#1" })).requirement.name).toBe("第一张"); // 节点令牌只看自己网络的 #1
|
|
191
|
+
} finally { await client.close(); await mcp.close(); }
|
|
192
|
+
});
|
|
193
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// 任务短号 seq 的启动迁移:用没有 seq 列的旧表建库、塞行,再走真实启动路径(import db.ts + requirements.ts)。
|
|
2
|
+
// 补号按网络各自从 1 开始,按 created_at(新旧两种时间格式混在一起)排,同一时刻按 requirement_id。
|
|
3
|
+
import { afterAll, expect, test } from "bun:test";
|
|
4
|
+
import { Database } from "bun:sqlite";
|
|
5
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
|
|
9
|
+
const dir = mkdtempSync(join(tmpdir(), "anet-req-seq-migrate-"));
|
|
10
|
+
const file = join(dir, "hub.db");
|
|
11
|
+
process.env.COMMHUB_DB = file;
|
|
12
|
+
|
|
13
|
+
const legacy = new Database(file);
|
|
14
|
+
legacy.exec(`CREATE TABLE requirements (
|
|
15
|
+
requirement_id TEXT PRIMARY KEY, network_id TEXT NOT NULL, title TEXT NOT NULL,
|
|
16
|
+
column_name TEXT NOT NULL DEFAULT 'pool', priority TEXT NOT NULL DEFAULT 'normal', due_on TEXT, assignee TEXT,
|
|
17
|
+
client_id TEXT, issues_json TEXT, created_by TEXT,
|
|
18
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
19
|
+
owner_json TEXT, participants_json TEXT NOT NULL DEFAULT '[]')`);
|
|
20
|
+
// 插入顺序故意打乱;期望的号写在第三列。
|
|
21
|
+
const rows: [string, string, string, number][] = [
|
|
22
|
+
["req_c", "net_a", "2026-09-02T00:00:00.000Z", 5],
|
|
23
|
+
["req_a", "net_a", "2026-09-01 08:00:00", 1], // 旧默认格式(UTC,空格分隔)
|
|
24
|
+
["req_z", "net_b", "2026-08-01T00:00:00.000Z", 1],
|
|
25
|
+
["req_b2", "net_a", "2026-09-01T09:00:00.000Z", 3], // 与 req_b1 同一时刻 → 按 id
|
|
26
|
+
["req_b1", "net_a", "2026-09-01T09:00:00.000Z", 2],
|
|
27
|
+
["req_y", "net_b", "2026-08-02 00:00:00", 2],
|
|
28
|
+
["req_d", "net_a", "2026-09-01 23:59:59", 4], // 旧格式,晚于 09:00 的 ISO、早于 09-02
|
|
29
|
+
];
|
|
30
|
+
for (const [id, net, at] of rows) legacy.run("INSERT INTO requirements (requirement_id, network_id, title, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?4)", [id, net, `卡 ${id}`, at]);
|
|
31
|
+
legacy.close();
|
|
32
|
+
|
|
33
|
+
afterAll(() => { try { rmSync(dir, { recursive: true, force: true }); } catch {} });
|
|
34
|
+
|
|
35
|
+
test("startup backfills seq per network in created_at order, then id; counters and unique index follow", async () => {
|
|
36
|
+
const { db } = await import("./db.js");
|
|
37
|
+
await import("./requirements.js"); // 真实启动路径
|
|
38
|
+
const got = Object.fromEntries((db.all("SELECT requirement_id, network_id, seq FROM requirements") as any[]).map(r => [r.requirement_id, r.seq]));
|
|
39
|
+
// net_a 按时间:req_a 09-01 08:00 → req_b1 / req_b2 09-01 09:00(按 id)→ req_d 09-01 23:59:59 → req_c 09-02
|
|
40
|
+
expect(got).toEqual({ req_a: 1, req_b1: 2, req_b2: 3, req_d: 4, req_c: 5, req_z: 1, req_y: 2 });
|
|
41
|
+
// 按字符串排会把「2026-09-01 23:59:59」排到所有 ISO 的 09-01 前面 —— 证明排序真的按时刻,不按字面
|
|
42
|
+
expect("2026-09-01 23:59:59" < "2026-09-01T09:00:00.000Z").toBe(true);
|
|
43
|
+
const counters = Object.fromEntries((db.all("SELECT network_id, last_seq FROM requirement_seq_counters") as any[]).map(r => [r.network_id, r.last_seq]));
|
|
44
|
+
expect(counters).toEqual({ net_a: 5, net_b: 2 });
|
|
45
|
+
const idx = (db.all("PRAGMA index_list(requirements)") as { name: string; unique: number }[]).find(i => i.name === "idx_requirements_network_seq");
|
|
46
|
+
expect(idx?.unique).toBe(1);
|
|
47
|
+
expect(() => db.run("UPDATE requirements SET seq = 1 WHERE requirement_id = 'req_c'")).toThrow();
|
|
48
|
+
|
|
49
|
+
// 重复执行:已有号的行一个不动;回滚到旧 Hub 期间建的卡(seq 为空)接在后面补号
|
|
50
|
+
const { ensureRequirementSeq, nextRequirementSeq } = await import("./requirements-migrate.js");
|
|
51
|
+
expect(ensureRequirementSeq(db).backfilled).toBe(0);
|
|
52
|
+
db.run("INSERT INTO requirements (requirement_id, network_id, title, created_at, updated_at) VALUES ('req_old_hub', 'net_a', 'x', '2020-01-01T00:00:00Z', '2020-01-01T00:00:00Z')");
|
|
53
|
+
expect(ensureRequirementSeq(db).backfilled).toBe(1);
|
|
54
|
+
expect((db.get("SELECT seq FROM requirements WHERE requirement_id = 'req_old_hub'") as any).seq).toBe(6);
|
|
55
|
+
expect(got.req_a).toBe((db.get("SELECT seq FROM requirements WHERE requirement_id = 'req_a'") as any).seq);
|
|
56
|
+
// 删掉最大号的卡,下一个号也不回收
|
|
57
|
+
db.run("DELETE FROM requirements WHERE requirement_id = 'req_old_hub'");
|
|
58
|
+
ensureRequirementSeq(db);
|
|
59
|
+
expect(nextRequirementSeq(db, "net_a")).toBe(7);
|
|
60
|
+
// 从没建过卡的网络从 1 开始
|
|
61
|
+
expect(nextRequirementSeq(db, "net_new")).toBe(1);
|
|
62
|
+
expect(nextRequirementSeq(db, "net_new")).toBe(2);
|
|
63
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// 需求的可选开始字段(start,存 start_on):App 甘特图用。
|
|
2
|
+
// 先用「没有 start_on 列」的旧库形状建库、塞行,再走真实启动路径(db.ts + server.ts),看:
|
|
3
|
+
// 列被加上、存量行读出来 start = "" 且别的列原样;POST / PATCH / upsert 都能写;只 PATCH due 不动 start 和描述;
|
|
4
|
+
// 旧客户端(不带 start)的 PATCH 不清掉已有的 start;坏值 400 invalid_start;capabilities 带 start_date。
|
|
5
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
6
|
+
import { Database } from "bun:sqlite";
|
|
7
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
|
|
11
|
+
const dir = mkdtempSync(join(tmpdir(), "anet-req-start-"));
|
|
12
|
+
const file = join(dir, "hub.db");
|
|
13
|
+
process.env.COMMHUB_DB = file;
|
|
14
|
+
|
|
15
|
+
const legacy = new Database(file);
|
|
16
|
+
legacy.exec(`
|
|
17
|
+
CREATE TABLE IF NOT EXISTS requirements (
|
|
18
|
+
requirement_id TEXT PRIMARY KEY,
|
|
19
|
+
network_id TEXT NOT NULL,
|
|
20
|
+
title TEXT NOT NULL,
|
|
21
|
+
column_name TEXT NOT NULL DEFAULT 'pool' CHECK(column_name IN ('pool', 'doing', 'done')),
|
|
22
|
+
priority TEXT NOT NULL DEFAULT 'normal' CHECK(priority IN ('high', 'normal', 'low', 'lowest')),
|
|
23
|
+
due_on TEXT,
|
|
24
|
+
assignee TEXT,
|
|
25
|
+
client_id TEXT,
|
|
26
|
+
issues_json TEXT,
|
|
27
|
+
created_by TEXT,
|
|
28
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
29
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
30
|
+
);
|
|
31
|
+
`);
|
|
32
|
+
legacy.exec("ALTER TABLE requirements ADD COLUMN description TEXT");
|
|
33
|
+
legacy.run("INSERT INTO requirements (requirement_id, network_id, title, due_on, description) VALUES ('r_old', 'net_legacy', '旧卡', '2026-10-01', '旧描述')");
|
|
34
|
+
expect(legacy.query("SELECT name FROM pragma_table_info('requirements') WHERE name = 'start_on'").all()).toEqual([]);
|
|
35
|
+
legacy.close();
|
|
36
|
+
|
|
37
|
+
let base = "";
|
|
38
|
+
let token = "";
|
|
39
|
+
let server: { port: number; stop?: (force?: boolean) => void };
|
|
40
|
+
let db: any;
|
|
41
|
+
|
|
42
|
+
async function api(path: string, init?: RequestInit) {
|
|
43
|
+
const res = await fetch(`${base}${path}`, { ...init, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" } });
|
|
44
|
+
return { status: res.status, body: await res.json() as any };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
beforeAll(async () => {
|
|
48
|
+
({ db } = await import("./db.js"));
|
|
49
|
+
const { register } = await import("./auth.js");
|
|
50
|
+
const owner = register(`start_owner_${Date.now()}`, "StartOwner123!", undefined, "seed");
|
|
51
|
+
token = owner.token!;
|
|
52
|
+
const mod: any = await import("./server.js");
|
|
53
|
+
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
54
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
55
|
+
}, 30_000);
|
|
56
|
+
|
|
57
|
+
afterAll(() => {
|
|
58
|
+
try { server?.stop?.(true); } catch {}
|
|
59
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("optional requirement start date", () => {
|
|
63
|
+
test("startup adds start_on to an old table; existing rows keep every value and read start = ''", () => {
|
|
64
|
+
const cols = (db.all("PRAGMA table_info(requirements)") as { name: string }[]).map(c => c.name);
|
|
65
|
+
expect(cols).toContain("start_on");
|
|
66
|
+
const row = db.get("SELECT title, due_on, description, start_on FROM requirements WHERE requirement_id = 'r_old'") as any;
|
|
67
|
+
expect(row).toEqual({ title: "旧卡", due_on: "2026-10-01", description: "旧描述", start_on: null });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("create / PATCH / upsert write start; due-only PATCH and start-less PATCH keep it", async () => {
|
|
71
|
+
const created = await api("/api/requirements", { method: "POST", body: JSON.stringify({ name: "有开始的", start: "2026-09-20", due: "2026-10-01", description: "别动我" }) });
|
|
72
|
+
expect(created.status).toBe(201);
|
|
73
|
+
expect(created.body.requirement.start).toBe("2026-09-20");
|
|
74
|
+
const id = created.body.requirement.id;
|
|
75
|
+
|
|
76
|
+
// 甘特图拖动只发 due:start 和描述原样
|
|
77
|
+
const dueOnly = await api(`/api/requirements/${id}`, { method: "PATCH", body: JSON.stringify({ due: "2026-10-05" }) });
|
|
78
|
+
expect(dueOnly.status).toBe(200);
|
|
79
|
+
expect(dueOnly.body.requirement.due).toBe("2026-10-05");
|
|
80
|
+
expect(dueOnly.body.requirement.start).toBe("2026-09-20");
|
|
81
|
+
expect(dueOnly.body.requirement.description).toBe("别动我");
|
|
82
|
+
|
|
83
|
+
// 旧客户端不认识 start:它的 PATCH 不带这个字段,不能清掉
|
|
84
|
+
const old = await api(`/api/requirements/${id}`, { method: "PATCH", body: JSON.stringify({ name: "改名了" }) });
|
|
85
|
+
expect(old.body.requirement.start).toBe("2026-09-20");
|
|
86
|
+
|
|
87
|
+
const instant = await api(`/api/requirements/${id}`, { method: "PATCH", body: JSON.stringify({ start: "2026-09-21T09:30:00+08:00" }) });
|
|
88
|
+
expect(instant.body.requirement.start).toBe("2026-09-21T01:30:00Z");
|
|
89
|
+
const cleared = await api(`/api/requirements/${id}`, { method: "PATCH", body: JSON.stringify({ start: "" }) });
|
|
90
|
+
expect(cleared.status).toBe(200);
|
|
91
|
+
expect(cleared.body.requirement.start).toBe("");
|
|
92
|
+
// 只带 start 的 PATCH 不是 empty_patch
|
|
93
|
+
const onlyStart = await api(`/api/requirements/${id}`, { method: "PATCH", body: JSON.stringify({ start: "2026-09-22" }) });
|
|
94
|
+
expect(onlyStart.status).toBe(200);
|
|
95
|
+
|
|
96
|
+
const up = await api("/api/requirements/upsert", { method: "POST", body: JSON.stringify({ name: "同步来的", external_ref: "github:t/p#9", start: "2026-09-01" }) });
|
|
97
|
+
expect(up.status).toBe(201);
|
|
98
|
+
expect(up.body.requirement.start).toBe("2026-09-01");
|
|
99
|
+
const upAgain = await api("/api/requirements/upsert", { method: "POST", body: JSON.stringify({ external_ref: "github:t/p#9", start: "2026-09-02" }) });
|
|
100
|
+
expect(upAgain.body.requirement.start).toBe("2026-09-02");
|
|
101
|
+
|
|
102
|
+
const noStart = await api("/api/requirements", { method: "POST", body: JSON.stringify({ name: "没开始字段" }) });
|
|
103
|
+
expect(noStart.body.requirement.start).toBe("");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("bad start values are 400 invalid_start and change nothing", async () => {
|
|
107
|
+
const created = await api("/api/requirements", { method: "POST", body: JSON.stringify({ name: "坏值", start: "2026-09-20" }) });
|
|
108
|
+
const id = created.body.requirement.id;
|
|
109
|
+
for (const bad of ["2026-02-30", "tomorrow", "2026-09-20T10:00:00", 20260920]) {
|
|
110
|
+
const post = await api("/api/requirements", { method: "POST", body: JSON.stringify({ name: "x", start: bad }) });
|
|
111
|
+
const patch = await api(`/api/requirements/${id}`, { method: "PATCH", body: JSON.stringify({ start: bad }) });
|
|
112
|
+
expect(patch.status).toBe(400);
|
|
113
|
+
expect(patch.body.error).toBe("invalid_start");
|
|
114
|
+
if (typeof bad === "string") {
|
|
115
|
+
expect(post.status).toBe(400);
|
|
116
|
+
expect(post.body.error).toBe("invalid_start");
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const listed = await api("/api/requirements");
|
|
120
|
+
expect(listed.body.requirements.find((r: any) => r.id === id).start).toBe("2026-09-20");
|
|
121
|
+
expect(listed.body.capabilities).toContain("start_date");
|
|
122
|
+
// 旧卡在列表里 start = ""(不是缺字段:新 App 按 capabilities 判断支持与否)
|
|
123
|
+
expect(listed.body.requirements.every((r: any) => typeof r.start === "string")).toBe(true);
|
|
124
|
+
});
|
|
125
|
+
});
|