@sleep2agi/commhub-server 0.9.0-preview.0 → 0.9.0-preview.100
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 +92 -11
- package/package.json +8 -7
- package/src/ack-create-request-transport.test.ts +159 -0
- package/src/ack-create-request.test.ts +331 -0
- package/src/admin-delete-network-http.test.ts +228 -0
- package/src/admin-networks-http.test.ts +88 -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 +520 -0
- package/src/agent-acl-slice3-http.test.ts +267 -0
- package/src/agent-groups-perf.test.ts +190 -0
- package/src/agent-notice.ts +48 -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-nodes-shape.test.ts +188 -0
- package/src/auth-kdf-migration.test.ts +378 -0
- package/src/auth-sessions-http.test.ts +250 -0
- package/src/auth-tokens.test.ts +18 -9
- package/src/auth-validate.test.ts +5 -1
- package/src/auth.ts +554 -38
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/blocked-exit-on-reply.test.ts +122 -0
- package/src/boot-side-effect.test.ts +124 -0
- package/src/commhub-bin-args.test.ts +85 -0
- package/src/config-apply-sec1.test.ts +830 -0
- package/src/config-apply-validate.test.ts +352 -0
- package/src/config-apply-validate.ts +249 -0
- package/src/create-node-blocked-gate.test.ts +356 -0
- package/src/create-node-tool-schema.test.ts +39 -0
- package/src/create-node-validate.test.ts +275 -0
- package/src/create-node-validate.ts +288 -0
- package/src/create-node-workdir.test.ts +255 -0
- package/src/create-node.test.ts +354 -0
- package/src/create-node.ts +349 -0
- package/src/cross-tenant-injection.test.ts +110 -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 +1268 -42
- 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 +329 -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-timestamp.test.ts +38 -0
- package/src/hub-timestamp.ts +27 -0
- package/src/human-dm.ts +215 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2128
- package/src/lifecycle-guard.ts +50 -0
- package/src/list-host-supervisors.test.ts +314 -0
- package/src/mcp-context-cost-http.test.ts +119 -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 +429 -0
- package/src/member-audit-network-id-http.test.ts +93 -0
- package/src/member-presence-http.test.ts +206 -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 +224 -0
- package/src/network-token-membership-http.test.ts +139 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +217 -0
- package/src/node-create-request-status-http.test.ts +92 -0
- package/src/node-files-transport.test.ts +344 -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 +72 -0
- package/src/node-id-alias.test.ts +188 -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 +51 -0
- package/src/node-logs-transport.test.ts +263 -0
- package/src/node-permissions-http.test.ts +276 -0
- package/src/node-permissions.ts +242 -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-privilege-http.test.ts +149 -0
- package/src/observer-avatar-http.test.ts +431 -0
- package/src/observer-push.test.ts +252 -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/probe-validate.test.ts +221 -0
- package/src/probe-validate.ts +104 -0
- package/src/probe.test.ts +80 -0
- package/src/probe.ts +200 -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 +7 -2
- package/src/reply-target-derivation.test.ts +96 -0
- package/src/report-status-host-schema.test.ts +211 -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 +390 -0
- package/src/requirement-due-reminders.ts +310 -0
- package/src/requirement-events-http.test.ts +257 -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-comment-http.test.ts +165 -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 +344 -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 +225 -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 +164 -0
- package/src/requirements-status-participants-http.test.ts +220 -0
- package/src/requirements-upgrade-e2e.test.ts +126 -0
- package/src/requirements-upgrade-migration.test.ts +98 -0
- package/src/requirements.ts +1761 -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 +100 -0
- package/src/rest-projections.ts +68 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/restricted-files.ts +73 -0
- package/src/retention.test.ts +450 -0
- package/src/retention.ts +281 -0
- 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/scheduled-doorbell-nodeless-session.test.ts +130 -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 +641 -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 +5038 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
- package/src/shared/probe-host-allowlist.ts +128 -0
- package/src/shared/reserved-env-drift.test.ts +43 -0
- package/src/shared/reserved-env.test.ts +35 -0
- package/src/shared/reserved-env.ts +31 -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/stale-sweeper.test.ts +181 -0
- package/src/stale-sweeper.ts +114 -0
- package/src/start-node.test.ts +109 -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 +1164 -0
- package/src/task-access-http.test.ts +379 -0
- package/src/task-access.ts +413 -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 +313 -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-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-queue-ahead-http.test.ts +309 -0
- package/src/task-queue-ahead-scale.test.ts +211 -0
- package/src/task-queue-ahead.ts +186 -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 +195 -0
- package/src/tool-audience.ts +109 -0
- package/src/tools-list-json-schema.test.ts +67 -0
- package/src/tools.ts +5426 -275
- package/src/update-provider.test.ts +234 -0
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +97 -15
- package/src/uploads.test.ts +66 -0
- package/src/uploads.ts +138 -5
- 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
- package/src/vault.test.ts +200 -0
- package/src/vault.ts +156 -0
package/src/db.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { createAdapter, type DbAdapter } from "./db-adapter";
|
|
1
|
+
import { createAdapter, type DbAdapter } from "./db-adapter"; import { withStatementHook } from "./status-read-cache"; // #431 写 sessions 的语句让 /api/status 缓存失效
|
|
2
2
|
|
|
3
|
-
export const db: DbAdapter = createAdapter();
|
|
3
|
+
export const db: DbAdapter = withStatementHook(createAdapter());
|
|
4
4
|
|
|
5
5
|
// Schema
|
|
6
6
|
db.exec(`
|
|
@@ -53,6 +53,38 @@ db.exec(`
|
|
|
53
53
|
CREATE INDEX IF NOT EXISTS idx_inbox_pending
|
|
54
54
|
ON inbox(session_name, acked) WHERE acked = 0;
|
|
55
55
|
|
|
56
|
+
-- #1459 ① — agent→用户的 desktop message 持久化收件箱。
|
|
57
|
+
-- inbox 是 session_name(alias) 寻址的;desktop message 目标是 user_id,不能复用
|
|
58
|
+
-- inbox(否则 /api/messages+inbox_count+SSE gate 一票 alias 域查询会隐性开始返回
|
|
59
|
+
-- 用户消息)。单独一张 user_id 寻址表,镜像 inbox 的 acked/count 语义。
|
|
60
|
+
-- message_id 即主键(dm_<uuid>,与 inbox「消息 id 即主键」同形)→ 写入用
|
|
61
|
+
-- INSERT OR IGNORE,send_desktop_message 重试天然幂等,不会插出重复消息行。
|
|
62
|
+
-- 索引留到 P3:读取查询形状定了再按真实 WHERE 加(SDK马 review:没查询就加索引是猜;
|
|
63
|
+
-- 部分索引 acked=0 若「近期」读含已 ack 行则用不上)。P1 只落表。
|
|
64
|
+
CREATE TABLE IF NOT EXISTS user_inbox (
|
|
65
|
+
message_id TEXT PRIMARY KEY,
|
|
66
|
+
network_id TEXT NOT NULL,
|
|
67
|
+
user_id TEXT NOT NULL,
|
|
68
|
+
from_session TEXT,
|
|
69
|
+
kind TEXT NOT NULL DEFAULT 'info',
|
|
70
|
+
title TEXT,
|
|
71
|
+
content TEXT NOT NULL,
|
|
72
|
+
severity TEXT NOT NULL DEFAULT 'info',
|
|
73
|
+
meta_json TEXT,
|
|
74
|
+
acked INTEGER NOT NULL DEFAULT 0,
|
|
75
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
76
|
+
acked_at TEXT
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
-- #1459 ① P3 —— 现在有真实查询了才建索引(P1 刻意没建:没有查询就加索引是猜)。
|
|
80
|
+
-- 覆盖回读分支的两条:
|
|
81
|
+
-- 列表 SELECT ... WHERE user_id = ? [AND acked = 0] ORDER BY created_at DESC
|
|
82
|
+
-- 未读数 SELECT COUNT(*) WHERE user_id = ? AND acked = 0
|
|
83
|
+
-- 不是部分索引(不写 WHERE acked = 0):列表在 unacked=0 时要读**含已 ack 的**
|
|
84
|
+
-- 近期消息,部分索引那时用不上。
|
|
85
|
+
CREATE INDEX IF NOT EXISTS idx_user_inbox_user_acked
|
|
86
|
+
ON user_inbox(user_id, acked, created_at);
|
|
87
|
+
|
|
56
88
|
CREATE TABLE IF NOT EXISTS completions (
|
|
57
89
|
id TEXT PRIMARY KEY,
|
|
58
90
|
session_name TEXT NOT NULL,
|
|
@@ -89,6 +121,21 @@ for (const col of [
|
|
|
89
121
|
{ name: "process_cpu_pct", def: "REAL" },
|
|
90
122
|
{ name: "process_uptime_seconds", def: "REAL" },
|
|
91
123
|
{ name: "process_in_flight_count", def: "INTEGER" },
|
|
124
|
+
{ name: "external_schedules", def: "TEXT" },
|
|
125
|
+
{ name: "peer_reply_inbox_capable", def: "INTEGER NOT NULL DEFAULT 0" },
|
|
126
|
+
// app#225 follow-up — a session (incl. claude-code sessions with no `nodes` row)
|
|
127
|
+
// that answers the rules-file doorbell. Sticky: set by a bound node token, never
|
|
128
|
+
// cleared by reports that omit it (agent-node status-only reports do).
|
|
129
|
+
{ name: "rules_file_capable", def: "INTEGER NOT NULL DEFAULT 0" },
|
|
130
|
+
// Node skills view — the same session answers skills_list / skill_read on the
|
|
131
|
+
// rules-file doorbell (node-skills.ts). Sticky, bound-token only, like above.
|
|
132
|
+
{ name: "skills_capable", def: "INTEGER NOT NULL DEFAULT 0" },
|
|
133
|
+
// Project folder view — the same session answers files_list / file_read on the
|
|
134
|
+
// rules-file doorbell (node-files.ts). Sticky, bound-token only, like above.
|
|
135
|
+
{ name: "files_capable", def: "INTEGER NOT NULL DEFAULT 0" },
|
|
136
|
+
// Node run-log view — the same session answers logs_tail on the rules-file
|
|
137
|
+
// doorbell (node-logs.ts). Sticky, bound-token only, like above.
|
|
138
|
+
{ name: "logs_capable", def: "INTEGER NOT NULL DEFAULT 0" },
|
|
92
139
|
]) {
|
|
93
140
|
try { db.exec(`ALTER TABLE sessions ADD COLUMN ${col.name} ${col.def}`); } catch {}
|
|
94
141
|
}
|
|
@@ -117,12 +164,43 @@ db.exec(`
|
|
|
117
164
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
118
165
|
);
|
|
119
166
|
|
|
120
|
-
CREATE INDEX IF NOT EXISTS idx_agent_telemetry_host_time
|
|
121
|
-
ON agent_telemetry(network_id, hostname, ip, created_at);
|
|
122
167
|
CREATE INDEX IF NOT EXISTS idx_agent_telemetry_alias_time
|
|
123
168
|
ON agent_telemetry(network_id, alias, created_at);
|
|
124
169
|
`);
|
|
125
170
|
|
|
171
|
+
// Round-2/4 review ② — server-health index split.
|
|
172
|
+
//
|
|
173
|
+
// The /api/server-health/:host query (index.ts) filters
|
|
174
|
+
// WHERE (hostname = ?1 OR ip = ?2) AND created_at >= ?3
|
|
175
|
+
// The old combined index (network_id, hostname, ip, created_at) only
|
|
176
|
+
// covers an equality on hostname AND ip (not OR), so SQLite couldn't
|
|
177
|
+
// use it for the disjunction and fell back to a full scan of
|
|
178
|
+
// agent_telemetry on every server-health page load. At a 30s heartbeat
|
|
179
|
+
// × N agents, the table grows fast and the scan dominates.
|
|
180
|
+
//
|
|
181
|
+
// Split into two narrow indexes so SQLite's OR optimizer can run an
|
|
182
|
+
// index UNION on (hostname-path ∪ ip-path), each path covered by an
|
|
183
|
+
// index that also lets the created_at range be range-scanned.
|
|
184
|
+
//
|
|
185
|
+
// **Honest trade-off** (corrected after reviewer flag): secondary
|
|
186
|
+
// index count goes from 2 (host_time + alias_time) to 3 (hostname_time
|
|
187
|
+
// + ip_time + alias_time). Every agent_telemetry insert now maintains
|
|
188
|
+
// ONE MORE B-tree, so per-insert write-amp is +50%. The trade-off is
|
|
189
|
+
// deliberate: write-amp is bounded by the heartbeat cadence (30s ×
|
|
190
|
+
// N agents) and is small in absolute terms, while the read-path win
|
|
191
|
+
// is going from O(n) full-scan-per-dashboard-poll to O(log n) two-
|
|
192
|
+
// branch index union. For the public-facing hub's expected load
|
|
193
|
+
// shape (many readers, few writers), that's a clear net gain.
|
|
194
|
+
try { db.exec("DROP INDEX IF EXISTS idx_agent_telemetry_host_time"); } catch {}
|
|
195
|
+
try {
|
|
196
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_agent_telemetry_hostname_time
|
|
197
|
+
ON agent_telemetry(network_id, hostname, created_at)`);
|
|
198
|
+
} catch {}
|
|
199
|
+
try {
|
|
200
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_agent_telemetry_ip_time
|
|
201
|
+
ON agent_telemetry(network_id, ip, created_at)`);
|
|
202
|
+
} catch {}
|
|
203
|
+
|
|
126
204
|
// inbox: add in_reply_to, requires_response, expires_at, scope, node identity
|
|
127
205
|
for (const col of [
|
|
128
206
|
{ name: "in_reply_to", def: "TEXT" },
|
|
@@ -131,6 +209,11 @@ for (const col of [
|
|
|
131
209
|
{ name: "scope", def: "TEXT DEFAULT 'single'" },
|
|
132
210
|
{ name: "meta_json", def: "TEXT" },
|
|
133
211
|
{ name: "node_id", def: "TEXT" },
|
|
212
|
+
// #520 — explicit logical task identity. Initial deliveries historically
|
|
213
|
+
// used inbox.id == tasks.task_id, while retry/reassign generate a fresh
|
|
214
|
+
// inbox row id. Keep transport-row identity and logical-task identity
|
|
215
|
+
// separate so runtime evidence/replies survive those redeliveries.
|
|
216
|
+
{ name: "task_id", def: "TEXT" },
|
|
134
217
|
]) {
|
|
135
218
|
try { db.exec(`ALTER TABLE inbox ADD COLUMN ${col.name} ${col.def}`); } catch {}
|
|
136
219
|
}
|
|
@@ -160,6 +243,10 @@ db.exec(`
|
|
|
160
243
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
161
244
|
delivered_at TEXT,
|
|
162
245
|
started_at TEXT,
|
|
246
|
+
runtime_submitted_at TEXT,
|
|
247
|
+
consumed_at TEXT,
|
|
248
|
+
thread_id TEXT,
|
|
249
|
+
turn_id TEXT,
|
|
163
250
|
completed_at TEXT,
|
|
164
251
|
expires_at TEXT
|
|
165
252
|
);
|
|
@@ -206,6 +293,8 @@ db.exec(`
|
|
|
206
293
|
task_id TEXT NOT NULL,
|
|
207
294
|
from_status TEXT,
|
|
208
295
|
to_status TEXT NOT NULL,
|
|
296
|
+
event_type TEXT,
|
|
297
|
+
event_key TEXT,
|
|
209
298
|
actor TEXT NOT NULL DEFAULT 'system',
|
|
210
299
|
detail TEXT,
|
|
211
300
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
@@ -246,6 +335,265 @@ try {
|
|
|
246
335
|
if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
|
|
247
336
|
}
|
|
248
337
|
|
|
338
|
+
// RFC-024 (2026-06-28): node config-apply foundation.
|
|
339
|
+
//
|
|
340
|
+
// `nodes` table gains two columns:
|
|
341
|
+
// - `config_revision`: monotonically-incrementing per-node revision
|
|
342
|
+
// that gates write conflicts (dashboard sends base_revision, hub
|
|
343
|
+
// rejects 409 if it doesn't match current). Promoted by hub when
|
|
344
|
+
// the node ACKs `applied` from a successful apply.
|
|
345
|
+
// - `config_snapshot`: masked JSON of the node's current effective
|
|
346
|
+
// config, posted by the node via report_status. Dashboard reads
|
|
347
|
+
// this for the GET snapshot path (never touches per-node files).
|
|
348
|
+
//
|
|
349
|
+
// Both ALTERs wrapped in try/catch for the same reason as
|
|
350
|
+
// must_change_password above (no IF NOT EXISTS on SQLite < 3.35).
|
|
351
|
+
try {
|
|
352
|
+
db.exec(`ALTER TABLE nodes ADD COLUMN config_revision INTEGER DEFAULT 0`);
|
|
353
|
+
} catch (e: any) {
|
|
354
|
+
if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
|
|
355
|
+
}
|
|
356
|
+
try {
|
|
357
|
+
db.exec(`ALTER TABLE nodes ADD COLUMN config_snapshot TEXT`);
|
|
358
|
+
} catch (e: any) {
|
|
359
|
+
if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// RFC-036 / B4 — immutable per-node owner. New nodes receive this value in
|
|
363
|
+
// the same transaction that mints their node token. Heartbeats may verify the
|
|
364
|
+
// binding but must never first-claim or replace it. Legacy NULL rows remain
|
|
365
|
+
// read-only for external-schedule writes.
|
|
366
|
+
try {
|
|
367
|
+
db.exec(`ALTER TABLE nodes ADD COLUMN owner_user_id TEXT`);
|
|
368
|
+
} catch (e: any) {
|
|
369
|
+
if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// RFC-026 §9 / #338 PR2 — daemon self-declare fields promoted from
|
|
373
|
+
// config_snapshot to first-class indexable columns. The hub
|
|
374
|
+
// `list_host_supervisors` MCP tool reads these directly without parsing
|
|
375
|
+
// the snapshot JSON on every list call.
|
|
376
|
+
//
|
|
377
|
+
// Both fields are JSON arrays of strings:
|
|
378
|
+
// runtimes_supported e.g. ["claude-agent-sdk","codex-sdk","grok-build-acp"]
|
|
379
|
+
// allowed_secret_keys e.g. ["ANTHROPIC_API_KEY","OPENAI_API_KEY"]
|
|
380
|
+
//
|
|
381
|
+
// Stored as TEXT (JSON-encoded) — SQLite has no native array type and
|
|
382
|
+
// the wrapper code is small. Both nullable for pre-PR2 daemons; the
|
|
383
|
+
// list endpoint falls back to [] when null.
|
|
384
|
+
try {
|
|
385
|
+
db.exec(`ALTER TABLE nodes ADD COLUMN runtimes_supported TEXT`);
|
|
386
|
+
} catch (e: any) {
|
|
387
|
+
if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
|
|
388
|
+
}
|
|
389
|
+
try {
|
|
390
|
+
db.exec(`ALTER TABLE nodes ADD COLUMN allowed_secret_keys TEXT`);
|
|
391
|
+
} catch (e: any) {
|
|
392
|
+
if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// RFC-027 §2.3 — state machine for stop/delete (D5). 'active' default
|
|
396
|
+
// matches pre-RFC behavior for every existing row. Transitions:
|
|
397
|
+
// active → stopping → stopped → (back to active via restart_node)
|
|
398
|
+
// active → deleting → (row gone)
|
|
399
|
+
// stopped → deleting → (row gone)
|
|
400
|
+
// Used as the inbox-enqueue gate per §2.3 race-free invariant:
|
|
401
|
+
// pushEvent / INSERT INTO inbox must refuse a non-active target so the
|
|
402
|
+
// SIGTERM-in-flight window never gets a new task.
|
|
403
|
+
try {
|
|
404
|
+
db.exec(`ALTER TABLE nodes ADD COLUMN lifecycle_state TEXT DEFAULT 'active'`);
|
|
405
|
+
} catch (e: any) {
|
|
406
|
+
if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// #462 — user-set custom avatar (nullable http/https image URL). Pure
|
|
410
|
+
// display attribute: deliberately NOT part of the RFC-024 config-apply
|
|
411
|
+
// pipeline (nothing for agent-node to consume, no revision/ack). Written
|
|
412
|
+
// only via PUT /api/nodes/:ref/avatar which validates the URL shape.
|
|
413
|
+
try {
|
|
414
|
+
db.exec(`ALTER TABLE nodes ADD COLUMN avatar_url TEXT`);
|
|
415
|
+
} catch (e: any) {
|
|
416
|
+
if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Node DISPLAY attributes (Vincent 目标第 3 条 — dashboard/app 多维度编辑).
|
|
420
|
+
// Same posture as avatar_url above: hub-side presentation only, NOT part of
|
|
421
|
+
// the RFC-024 config-apply pipeline (agent-node consumes none of them, so a
|
|
422
|
+
// doorbell/ack round-trip would be meaningless and could wedge the config
|
|
423
|
+
// revision). Written only via PUT /api/nodes/:ref/attrs.
|
|
424
|
+
//
|
|
425
|
+
// display_name — human label shown in the UI. Distinct from `alias`,
|
|
426
|
+
// which is the ROUTING key (messages address the alias)
|
|
427
|
+
// and from `node_name`, which is an addressing key
|
|
428
|
+
// (`WHERE node_id OR node_name OR alias`). Renaming
|
|
429
|
+
// either of those must go through the rename 2PC
|
|
430
|
+
// (/api/node-rename/*), never through an attrs write.
|
|
431
|
+
// team, tags — organisation metadata; tags stored as JSON array text.
|
|
432
|
+
// attrs_revision — optimistic-lock counter for these display fields ONLY.
|
|
433
|
+
// Deliberately separate from config_revision so that
|
|
434
|
+
// editing a tag never bumps the node's *config* revision
|
|
435
|
+
// (which would perturb the config ack protocol).
|
|
436
|
+
//
|
|
437
|
+
// All nullable / defaulted so the ~100 rows already in flight keep reading.
|
|
438
|
+
for (const ddl of [
|
|
439
|
+
`ALTER TABLE nodes ADD COLUMN display_name TEXT`,
|
|
440
|
+
`ALTER TABLE nodes ADD COLUMN team TEXT`,
|
|
441
|
+
`ALTER TABLE nodes ADD COLUMN tags TEXT`,
|
|
442
|
+
`ALTER TABLE nodes ADD COLUMN attrs_revision INTEGER NOT NULL DEFAULT 0`,
|
|
443
|
+
]) {
|
|
444
|
+
try {
|
|
445
|
+
db.exec(ddl);
|
|
446
|
+
} catch (e: any) {
|
|
447
|
+
if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// RFC-027 §2 — stop/delete request envelope. Mirrors
|
|
452
|
+
// node_create_requests structure so the daemon-side pull/ack pattern
|
|
453
|
+
// is symmetric with create_node. action='stop'|'delete' picks which
|
|
454
|
+
// branch (delete adds backup_path + revokes ntok on ack).
|
|
455
|
+
db.exec(`
|
|
456
|
+
CREATE TABLE IF NOT EXISTS node_stop_requests (
|
|
457
|
+
request_id TEXT PRIMARY KEY,
|
|
458
|
+
network_id TEXT NOT NULL,
|
|
459
|
+
daemon_node_id TEXT NOT NULL,
|
|
460
|
+
child_node_id TEXT NOT NULL,
|
|
461
|
+
child_alias TEXT NOT NULL,
|
|
462
|
+
child_token_id TEXT,
|
|
463
|
+
action TEXT NOT NULL CHECK (action IN ('stop', 'delete')),
|
|
464
|
+
delete_config INTEGER NOT NULL DEFAULT 1,
|
|
465
|
+
grace_seconds INTEGER NOT NULL DEFAULT 10,
|
|
466
|
+
force INTEGER NOT NULL DEFAULT 0,
|
|
467
|
+
in_flight_at_dispatch INTEGER NOT NULL DEFAULT 0,
|
|
468
|
+
created_by_token TEXT NOT NULL,
|
|
469
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
470
|
+
error TEXT,
|
|
471
|
+
backup_path TEXT,
|
|
472
|
+
exit_signal TEXT,
|
|
473
|
+
created_at INTEGER NOT NULL,
|
|
474
|
+
delivered_at INTEGER,
|
|
475
|
+
acked_at INTEGER
|
|
476
|
+
)
|
|
477
|
+
`);
|
|
478
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_stop_req_daemon ON node_stop_requests(daemon_node_id, status)`);
|
|
479
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_stop_req_child ON node_stop_requests(child_node_id)`);
|
|
480
|
+
// #1448 finding-5 — 存 delete 目标 child 的 token_id,让 ack 精确按 token_id 撤 ntok
|
|
481
|
+
// (而非按 name='node:<alias>' 广撤 → 同 alias 在 delete 窗口内重建会误杀新 token)。
|
|
482
|
+
// 幂等迁移:已有库补列,旧行 child_token_id 为 NULL、ack 时 fallback 回按 name 撤。
|
|
483
|
+
try { db.exec(`ALTER TABLE node_stop_requests ADD COLUMN child_token_id TEXT`); } catch { /* 已存在 */ }
|
|
484
|
+
|
|
485
|
+
// Daemon-mediated start request. Kept separate from node_stop_requests:
|
|
486
|
+
// the deployed stop table has a CHECK(action IN ('stop','delete')) and
|
|
487
|
+
// rebuilding that table in-place would be a risky migration for an additive
|
|
488
|
+
// lifecycle operation.
|
|
489
|
+
db.exec(`
|
|
490
|
+
CREATE TABLE IF NOT EXISTS node_start_requests (
|
|
491
|
+
request_id TEXT PRIMARY KEY,
|
|
492
|
+
network_id TEXT NOT NULL,
|
|
493
|
+
daemon_node_id TEXT NOT NULL,
|
|
494
|
+
child_node_id TEXT NOT NULL,
|
|
495
|
+
child_alias TEXT NOT NULL,
|
|
496
|
+
created_by_token TEXT NOT NULL,
|
|
497
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
498
|
+
error TEXT,
|
|
499
|
+
child_pid INTEGER,
|
|
500
|
+
created_at INTEGER NOT NULL,
|
|
501
|
+
delivered_at INTEGER,
|
|
502
|
+
acked_at INTEGER
|
|
503
|
+
)
|
|
504
|
+
`);
|
|
505
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_start_req_daemon ON node_start_requests(daemon_node_id, status)`);
|
|
506
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_start_req_child ON node_start_requests(child_node_id)`);
|
|
507
|
+
try {
|
|
508
|
+
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS uniq_start_req_child_inflight
|
|
509
|
+
ON node_start_requests(child_node_id)
|
|
510
|
+
WHERE status IN ('pending', 'delivered')`);
|
|
511
|
+
} catch (e: any) {
|
|
512
|
+
if (!/unique constraint/i.test(e?.message || "")) throw e;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// `node_config_updates` — pending + history. One row per dashboard write.
|
|
516
|
+
// At most one row per node may be in a non-terminal state at a time
|
|
517
|
+
// (single-flight enforced at the tool layer; this table just stores).
|
|
518
|
+
//
|
|
519
|
+
// network_id is denormalized from nodes(network_id) at insert time so
|
|
520
|
+
// queries don't need to JOIN through nodes every time + so a node
|
|
521
|
+
// deletion doesn't orphan the history (audit retention).
|
|
522
|
+
db.exec(`
|
|
523
|
+
CREATE TABLE IF NOT EXISTS node_config_updates (
|
|
524
|
+
update_id TEXT PRIMARY KEY,
|
|
525
|
+
node_id TEXT NOT NULL,
|
|
526
|
+
network_id TEXT NOT NULL,
|
|
527
|
+
patch_json TEXT NOT NULL,
|
|
528
|
+
apply_mode TEXT NOT NULL,
|
|
529
|
+
base_revision INTEGER NOT NULL,
|
|
530
|
+
status TEXT NOT NULL,
|
|
531
|
+
error TEXT,
|
|
532
|
+
created_at INTEGER NOT NULL,
|
|
533
|
+
created_by_token TEXT NOT NULL,
|
|
534
|
+
acked_at INTEGER,
|
|
535
|
+
new_revision INTEGER
|
|
536
|
+
);
|
|
537
|
+
|
|
538
|
+
CREATE INDEX IF NOT EXISTS idx_ncu_node_status ON node_config_updates(node_id, status);
|
|
539
|
+
CREATE INDEX IF NOT EXISTS idx_ncu_network ON node_config_updates(network_id);
|
|
540
|
+
`);
|
|
541
|
+
|
|
542
|
+
// F-C (CHANGE_REQ): partial unique index on non-terminal rows. The
|
|
543
|
+
// single-flight check in tools.ts is an app-level check-then-INSERT —
|
|
544
|
+
// safe under single-process Bun, but two hub workers could race and
|
|
545
|
+
// double-insert. This index makes that race impossible at the DB
|
|
546
|
+
// layer regardless of process count. Wrapped in try/catch because
|
|
547
|
+
// SQLite < 3.8.0 doesn't support partial indexes (rare on modern
|
|
548
|
+
// systems; if missing, app-level check is the fallback).
|
|
549
|
+
try {
|
|
550
|
+
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS uniq_ncu_node_inflight ON node_config_updates(node_id) WHERE status IN ('pending', 'restarting')`);
|
|
551
|
+
} catch (e: any) {
|
|
552
|
+
// Older SQLite — fall back to app-level single-flight only.
|
|
553
|
+
console.warn(`[commhub] partial unique index uniq_ncu_node_inflight skipped: ${e?.message || e}`);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// ── app#225: 节点规则文件(AGENTS.md / CLAUDE.md)远程读写请求 ──
|
|
557
|
+
//
|
|
558
|
+
// 形状照抄 node_config_updates(上面):客户端落一行 + 门铃,节点拉走做完回报,
|
|
559
|
+
// 客户端轮询结果。network_id 同样在插入时从 nodes 反规范化。
|
|
560
|
+
//
|
|
561
|
+
// 🔴 这张表**故意没有路径列**:文件名由节点按自己的 RUNTIME 决定
|
|
562
|
+
// (claude → CLAUDE.md,其余 → AGENTS.md),目录固定是节点进程的 cwd。
|
|
563
|
+
// hub 侧只存 op + 内容,客户端连文件名都指定不了(#225 验收第 5 条)。
|
|
564
|
+
// file_name 是节点**回报**的实际文件名,只用于显示。
|
|
565
|
+
db.exec(`
|
|
566
|
+
CREATE TABLE IF NOT EXISTS node_rules_requests (
|
|
567
|
+
request_id TEXT PRIMARY KEY,
|
|
568
|
+
node_id TEXT NOT NULL,
|
|
569
|
+
network_id TEXT NOT NULL,
|
|
570
|
+
op TEXT NOT NULL,
|
|
571
|
+
content TEXT,
|
|
572
|
+
status TEXT NOT NULL,
|
|
573
|
+
file_name TEXT,
|
|
574
|
+
file_exists INTEGER,
|
|
575
|
+
result_content TEXT,
|
|
576
|
+
error TEXT,
|
|
577
|
+
created_at INTEGER NOT NULL,
|
|
578
|
+
created_by_token TEXT NOT NULL,
|
|
579
|
+
pulled_at INTEGER,
|
|
580
|
+
acked_at INTEGER
|
|
581
|
+
);
|
|
582
|
+
|
|
583
|
+
CREATE INDEX IF NOT EXISTS idx_nrr_node_status ON node_rules_requests(node_id, status);
|
|
584
|
+
CREATE INDEX IF NOT EXISTS idx_nrr_network ON node_rules_requests(network_id);
|
|
585
|
+
`);
|
|
586
|
+
// Content retention (node-request-retention.ts): file bytes in content /
|
|
587
|
+
// result_content are purged after the first terminal read (+grace) or a TTL;
|
|
588
|
+
// these two columns record when, so the row itself survives as audit metadata.
|
|
589
|
+
for (const col of ["first_read_at INTEGER", "content_purged_at INTEGER"]) {
|
|
590
|
+
try { db.exec(`ALTER TABLE node_rules_requests ADD COLUMN ${col}`); } catch {}
|
|
591
|
+
}
|
|
592
|
+
db.exec(`
|
|
593
|
+
CREATE INDEX IF NOT EXISTS idx_nrr_created ON node_rules_requests(created_at);
|
|
594
|
+
CREATE INDEX IF NOT EXISTS idx_nrr_first_read ON node_rules_requests(first_read_at);
|
|
595
|
+
`);
|
|
596
|
+
|
|
249
597
|
// ── V3: networks table ──
|
|
250
598
|
db.exec(`
|
|
251
599
|
CREATE TABLE IF NOT EXISTS networks (
|
|
@@ -280,6 +628,287 @@ db.exec(`
|
|
|
280
628
|
CREATE INDEX IF NOT EXISTS idx_tokens_user ON api_tokens(user_id);
|
|
281
629
|
`);
|
|
282
630
|
|
|
631
|
+
// RFC-026 v4 §4.4.8 C4 — api_tokens metadata so sweeper can revoke
|
|
632
|
+
// orphan child-ntoks without ever touching plaintext tokens. ALTER in
|
|
633
|
+
// try/catch (idempotent for already-migrated DBs).
|
|
634
|
+
// request_id — links a child token to the create_node request that
|
|
635
|
+
// minted it; sweeper joins on this
|
|
636
|
+
// revoked_at — explicit revoke; resolveToken treats non-NULL as
|
|
637
|
+
// invalid even before expiry
|
|
638
|
+
for (const ddl of [
|
|
639
|
+
"ALTER TABLE api_tokens ADD COLUMN request_id TEXT",
|
|
640
|
+
"ALTER TABLE api_tokens ADD COLUMN revoked_at TEXT",
|
|
641
|
+
"ALTER TABLE api_tokens ADD COLUMN role TEXT",
|
|
642
|
+
"ALTER TABLE api_tokens ADD COLUMN bound_node_id TEXT",
|
|
643
|
+
// 登录会话的设备标识(GET /api/auth/sessions 显示用):客户端自报的 client_label + 登录请求的 User-Agent。
|
|
644
|
+
"ALTER TABLE api_tokens ADD COLUMN client_label TEXT",
|
|
645
|
+
"ALTER TABLE api_tokens ADD COLUMN user_agent TEXT",
|
|
646
|
+
]) {
|
|
647
|
+
try { db.exec(ddl); }
|
|
648
|
+
catch (e: any) {
|
|
649
|
+
if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
try { db.exec(`CREATE INDEX IF NOT EXISTS idx_tokens_request ON api_tokens(request_id)`); }
|
|
653
|
+
catch (e: any) { /* index may already exist */ void e; }
|
|
654
|
+
try { db.exec(`CREATE INDEX IF NOT EXISTS idx_tokens_bound_node ON api_tokens(bound_node_id)`); }
|
|
655
|
+
catch (e: any) { void e; }
|
|
656
|
+
|
|
657
|
+
// RFC-026 v4 §2.5 + §4.4 — node_create_requests. Metadata only;
|
|
658
|
+
// env_blob NEVER lives in this table (F1 mint-stream-evict — secret
|
|
659
|
+
// blob is in-memory Map keyed by request_id, drained on daemon get,
|
|
660
|
+
// TTL 60s GC).
|
|
661
|
+
//
|
|
662
|
+
// env_keys is a JSON array of secret KEY NAMES only (for audit). The
|
|
663
|
+
// actual values flow through pendingEnvBlobs Map and never touch
|
|
664
|
+
// disk.
|
|
665
|
+
db.exec(`
|
|
666
|
+
CREATE TABLE IF NOT EXISTS node_create_requests (
|
|
667
|
+
request_id TEXT PRIMARY KEY,
|
|
668
|
+
daemon_node_id TEXT NOT NULL,
|
|
669
|
+
child_name TEXT NOT NULL,
|
|
670
|
+
network_id TEXT NOT NULL,
|
|
671
|
+
runtime TEXT NOT NULL,
|
|
672
|
+
model TEXT,
|
|
673
|
+
flags_json TEXT NOT NULL,
|
|
674
|
+
env_keys TEXT NOT NULL,
|
|
675
|
+
status TEXT NOT NULL,
|
|
676
|
+
error TEXT,
|
|
677
|
+
child_token_id TEXT,
|
|
678
|
+
created_at INTEGER NOT NULL,
|
|
679
|
+
created_by_token TEXT NOT NULL,
|
|
680
|
+
delivered_at INTEGER,
|
|
681
|
+
acked_at INTEGER,
|
|
682
|
+
child_node_id TEXT
|
|
683
|
+
);
|
|
684
|
+
|
|
685
|
+
CREATE INDEX IF NOT EXISTS idx_ncr_daemon_status ON node_create_requests(daemon_node_id, status);
|
|
686
|
+
CREATE INDEX IF NOT EXISTS idx_ncr_network ON node_create_requests(network_id);
|
|
687
|
+
CREATE INDEX IF NOT EXISTS idx_ncr_child_name ON node_create_requests(child_name);
|
|
688
|
+
`);
|
|
689
|
+
function migrateNodeCreateRequestsModelNullable() {
|
|
690
|
+
if (db.dialect === "postgres") {
|
|
691
|
+
try { db.exec("ALTER TABLE node_create_requests ALTER COLUMN model DROP NOT NULL"); } catch {}
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
let needsRebuild = false;
|
|
696
|
+
try {
|
|
697
|
+
const createSql = db.get<{ sql: string }>("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'node_create_requests'");
|
|
698
|
+
needsRebuild = !!createSql?.sql && /\bmodel\s+TEXT\s+NOT\s+NULL\b/i.test(createSql.sql);
|
|
699
|
+
} catch {}
|
|
700
|
+
if (!needsRebuild) return;
|
|
701
|
+
|
|
702
|
+
db.transaction(() => {
|
|
703
|
+
db.exec("DROP TABLE IF EXISTS node_create_requests_migrated");
|
|
704
|
+
db.exec(`
|
|
705
|
+
CREATE TABLE node_create_requests_migrated (
|
|
706
|
+
request_id TEXT PRIMARY KEY,
|
|
707
|
+
daemon_node_id TEXT NOT NULL,
|
|
708
|
+
child_name TEXT NOT NULL,
|
|
709
|
+
network_id TEXT NOT NULL,
|
|
710
|
+
runtime TEXT NOT NULL,
|
|
711
|
+
model TEXT,
|
|
712
|
+
flags_json TEXT NOT NULL,
|
|
713
|
+
env_keys TEXT NOT NULL,
|
|
714
|
+
status TEXT NOT NULL,
|
|
715
|
+
error TEXT,
|
|
716
|
+
child_token_id TEXT,
|
|
717
|
+
created_at INTEGER NOT NULL,
|
|
718
|
+
created_by_token TEXT NOT NULL,
|
|
719
|
+
delivered_at INTEGER,
|
|
720
|
+
acked_at INTEGER,
|
|
721
|
+
child_node_id TEXT
|
|
722
|
+
)
|
|
723
|
+
`);
|
|
724
|
+
db.exec(`
|
|
725
|
+
INSERT INTO node_create_requests_migrated (
|
|
726
|
+
request_id, daemon_node_id, child_name, network_id, runtime, model,
|
|
727
|
+
flags_json, env_keys, status, error, child_token_id, created_at,
|
|
728
|
+
created_by_token, delivered_at, acked_at, child_node_id
|
|
729
|
+
)
|
|
730
|
+
SELECT
|
|
731
|
+
request_id, daemon_node_id, child_name, network_id, runtime, model,
|
|
732
|
+
flags_json, env_keys, status, error, child_token_id, created_at,
|
|
733
|
+
created_by_token, delivered_at, acked_at, child_node_id
|
|
734
|
+
FROM node_create_requests
|
|
735
|
+
`);
|
|
736
|
+
db.exec("DROP TABLE node_create_requests");
|
|
737
|
+
db.exec("ALTER TABLE node_create_requests_migrated RENAME TO node_create_requests");
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
migrateNodeCreateRequestsModelNullable();
|
|
741
|
+
|
|
742
|
+
// app「新建节点」工作目录 —— 请求带的 node_spec.workdir(可空;空 = 落 daemon cwd 的老行为)。
|
|
743
|
+
// 🔴 必须排在上面那个重建表迁移**之后**:它按显式列清单拷贝,排在前面的话,老库(model NOT NULL)
|
|
744
|
+
// 第一次启动时这一列会被重建吞掉。
|
|
745
|
+
try {
|
|
746
|
+
db.exec(`ALTER TABLE node_create_requests ADD COLUMN workdir TEXT`);
|
|
747
|
+
} catch (e: any) {
|
|
748
|
+
if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// #1493 — 把 user_inbox.network_id 升到 **schema 级 NOT NULL**(belt-and-suspenders,
|
|
752
|
+
// 叠在 send_desktop_message 的代码级三闸之上:canWrite / `!effectiveNetId` return /
|
|
753
|
+
// getUserNetworkRole,tools.ts)。「不产生 network_id=NULL 孤儿」现是代码级保证 + #1492
|
|
754
|
+
// 三道测试;schema 级约束让**任何**未来绕过那三闸写 NULL 的回归在 INSERT 处直接被
|
|
755
|
+
// 数据库拒(而非产出 scoped-unreadable 的静默孤儿)。
|
|
756
|
+
//
|
|
757
|
+
// SQLite 不能 `ALTER COLUMN ADD NOT NULL` → 建新表→copy→drop→rename(与本文件既有
|
|
758
|
+
// recreate-table 迁移同范式);新库的 CREATE 已直接 NOT NULL,迁移检测到就跳过(幂等)。
|
|
759
|
+
function migrateUserInboxNetworkIdNotNull() {
|
|
760
|
+
if (db.dialect === "postgres") {
|
|
761
|
+
try { db.exec("ALTER TABLE user_inbox ALTER COLUMN network_id SET NOT NULL"); } catch {}
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
let createSql: string | undefined;
|
|
765
|
+
try {
|
|
766
|
+
createSql = db.get<{ sql: string }>("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'user_inbox'")?.sql;
|
|
767
|
+
} catch { return; }
|
|
768
|
+
if (!createSql) return; // 表不存在:上面的 CREATE 已建成 NOT NULL 新表
|
|
769
|
+
if (/network_id\s+TEXT\s+NOT\s+NULL/i.test(createSql)) return; // 已是 NOT NULL → 幂等跳过
|
|
770
|
+
|
|
771
|
+
// 🔴 迁移前守卫:存量若有 network_id IS NULL 行(按代码级三闸理应 0)→ **大声 warn +
|
|
772
|
+
// 跳过迁移**(列暂保持可空),hub 照常启动。fail-safe,不 throw(SDK马 review #1516):
|
|
773
|
+
// · 这条 NOT NULL 是 belt-and-suspenders,代码级三闸(#1492 三测钉着)已挡新 NULL;
|
|
774
|
+
// 为一条冗余保险带让**整个舰队的 hub** boot 期停摆,代价与收益不成比例;
|
|
775
|
+
// · 生产这个 COUNT 没人量过,而 #1493 第一条原则就是"没量过的数不要动手"——只要生产
|
|
776
|
+
// 有一行历史 NULL,throw 就把升级变成停机;
|
|
777
|
+
// · fail-closed 的真正目的(不盲目清空、不 COALESCE 猜网络)用 warn+skip 一样达到。
|
|
778
|
+
// ⚠ 校正:本文件既有迁移(migrateNodeCreateRequestsModelNullable 内部 catch{}+return;
|
|
779
|
+
// migrateSessionsNetworkAliasUnique 全函数 throw 0 次)都**不**在 boot 期打死 hub——
|
|
780
|
+
// "顶层无 try/catch"是真前提,但"所以 throw 符合惯例"是没验的推论(SDK马 量出),撤回。
|
|
781
|
+
// 孤儿行仍有明确运维信号,三闸仍生效;等人工量过、清干净,下次启动幂等判据自动完成迁移。
|
|
782
|
+
const nullCount = db.get<{ n: number }>("SELECT COUNT(*) AS n FROM user_inbox WHERE network_id IS NULL")?.n ?? 0;
|
|
783
|
+
if (nullCount > 0) {
|
|
784
|
+
console.error(
|
|
785
|
+
`[migrate #1493] user_inbox 有 ${nullCount} 行 network_id IS NULL —— 与 send_desktop_message ` +
|
|
786
|
+
`的代码级三闸矛盾,可能存在更早的写路径漏洞;已**跳过** NOT NULL 迁移(列暂保持可空),` +
|
|
787
|
+
`hub 正常启动。请人工排查这些孤儿行来源、清理后下次启动会自动完成迁移(不要盲目清空)。`,
|
|
788
|
+
);
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
db.transaction(() => {
|
|
793
|
+
db.exec("DROP TABLE IF EXISTS user_inbox_migrated");
|
|
794
|
+
db.exec(`
|
|
795
|
+
CREATE TABLE user_inbox_migrated (
|
|
796
|
+
message_id TEXT PRIMARY KEY,
|
|
797
|
+
network_id TEXT NOT NULL,
|
|
798
|
+
user_id TEXT NOT NULL,
|
|
799
|
+
from_session TEXT,
|
|
800
|
+
kind TEXT NOT NULL DEFAULT 'info',
|
|
801
|
+
title TEXT,
|
|
802
|
+
content TEXT NOT NULL,
|
|
803
|
+
severity TEXT NOT NULL DEFAULT 'info',
|
|
804
|
+
meta_json TEXT,
|
|
805
|
+
acked INTEGER NOT NULL DEFAULT 0,
|
|
806
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
807
|
+
acked_at TEXT
|
|
808
|
+
)
|
|
809
|
+
`);
|
|
810
|
+
db.exec(`
|
|
811
|
+
INSERT INTO user_inbox_migrated
|
|
812
|
+
(message_id, network_id, user_id, from_session, kind, title, content, severity, meta_json, acked, created_at, acked_at)
|
|
813
|
+
SELECT
|
|
814
|
+
message_id, network_id, user_id, from_session, kind, title, content, severity, meta_json, acked, created_at, acked_at
|
|
815
|
+
FROM user_inbox
|
|
816
|
+
`);
|
|
817
|
+
db.exec("DROP TABLE user_inbox");
|
|
818
|
+
db.exec("ALTER TABLE user_inbox_migrated RENAME TO user_inbox");
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
migrateUserInboxNetworkIdNotNull();
|
|
822
|
+
// 迁移会 drop 掉旧表上的 idx_user_inbox_user_acked → 重建(IF NOT EXISTS;未迁移时 no-op)。
|
|
823
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_user_inbox_user_acked ON user_inbox(user_id, acked, created_at);`);
|
|
824
|
+
// 多用户:人与人私信。sender_user_id 由 Hub 按发信人的**用户令牌**写入(不是客户端填的 from_session),
|
|
825
|
+
// 会话记录按它双向取;Agent / 节点令牌发来的桌面消息这一列为 NULL。
|
|
826
|
+
try { db.exec("ALTER TABLE user_inbox ADD COLUMN sender_user_id TEXT"); } catch {}
|
|
827
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_user_inbox_dm ON user_inbox(network_id, user_id, sender_user_id, created_at);`);
|
|
828
|
+
|
|
829
|
+
db.exec(`
|
|
830
|
+
CREATE INDEX IF NOT EXISTS idx_ncr_daemon_status ON node_create_requests(daemon_node_id, status);
|
|
831
|
+
CREATE INDEX IF NOT EXISTS idx_ncr_network ON node_create_requests(network_id);
|
|
832
|
+
CREATE INDEX IF NOT EXISTS idx_ncr_child_name ON node_create_requests(child_name);
|
|
833
|
+
`);
|
|
834
|
+
try {
|
|
835
|
+
// Single-flight per (daemon, child_name) — prevents racing dashboard
|
|
836
|
+
// create on same name. Mirrors RFC-024 uniq_ncu_node_inflight.
|
|
837
|
+
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS uniq_ncr_inflight ON node_create_requests(daemon_node_id, child_name) WHERE status IN ('pending', 'delivered')`);
|
|
838
|
+
} catch (e: any) {
|
|
839
|
+
console.warn(`[commhub] partial unique index uniq_ncr_inflight skipped: ${e?.message || e}`);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// RFC-028 P1 v4 §2.2 — providers + provider_models + network_secrets +
|
|
843
|
+
// probe_results. All 4 tables idempotent CREATE TABLE IF NOT EXISTS. F2
|
|
844
|
+
// lazy gate: tables created on hub boot regardless; vault master key
|
|
845
|
+
// (ANET_HUB_SECRET_VAULT_KEY env) only required when these tables get
|
|
846
|
+
// their first non-empty row (lazy in vault.ts). Empty tables = hub
|
|
847
|
+
// boots fine without env (critical for existing prod hub升级 不 brick).
|
|
848
|
+
db.exec(`
|
|
849
|
+
CREATE TABLE IF NOT EXISTS providers (
|
|
850
|
+
provider_id TEXT PRIMARY KEY,
|
|
851
|
+
network_id TEXT NOT NULL,
|
|
852
|
+
name TEXT NOT NULL,
|
|
853
|
+
vendor TEXT NOT NULL,
|
|
854
|
+
base_url TEXT NOT NULL,
|
|
855
|
+
secret_key_ref TEXT NOT NULL,
|
|
856
|
+
created_at INTEGER NOT NULL,
|
|
857
|
+
created_by TEXT NOT NULL,
|
|
858
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
859
|
+
UNIQUE(network_id, name)
|
|
860
|
+
);
|
|
861
|
+
CREATE INDEX IF NOT EXISTS idx_providers_network ON providers(network_id);
|
|
862
|
+
`);
|
|
863
|
+
db.exec(`
|
|
864
|
+
CREATE TABLE IF NOT EXISTS provider_models (
|
|
865
|
+
model_id TEXT PRIMARY KEY,
|
|
866
|
+
provider_id TEXT NOT NULL,
|
|
867
|
+
model_name TEXT NOT NULL,
|
|
868
|
+
display_name TEXT,
|
|
869
|
+
context_window INTEGER,
|
|
870
|
+
supports_vision INTEGER NOT NULL DEFAULT 0,
|
|
871
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
872
|
+
created_at INTEGER NOT NULL,
|
|
873
|
+
UNIQUE(provider_id, model_name)
|
|
874
|
+
);
|
|
875
|
+
CREATE INDEX IF NOT EXISTS idx_models_provider ON provider_models(provider_id);
|
|
876
|
+
`);
|
|
877
|
+
// network_secrets — encrypted-at-rest vault. ciphertext + iv + tag are
|
|
878
|
+
// AES-GCM output; plaintext NEVER touches this table. master key from
|
|
879
|
+
// ANET_HUB_SECRET_VAULT_KEY env (lazy gate in vault.ts).
|
|
880
|
+
db.exec(`
|
|
881
|
+
CREATE TABLE IF NOT EXISTS network_secrets (
|
|
882
|
+
network_id TEXT NOT NULL,
|
|
883
|
+
key TEXT NOT NULL,
|
|
884
|
+
ciphertext BLOB NOT NULL,
|
|
885
|
+
iv BLOB NOT NULL,
|
|
886
|
+
tag BLOB NOT NULL,
|
|
887
|
+
created_at INTEGER NOT NULL,
|
|
888
|
+
updated_at INTEGER NOT NULL,
|
|
889
|
+
PRIMARY KEY (network_id, key)
|
|
890
|
+
);
|
|
891
|
+
`);
|
|
892
|
+
// probe_results — connectivity matrix history. v3 R3 lock: error_label
|
|
893
|
+
// is HUB-DERIVED ONLY from (status, raw_status_code) — daemon CANNOT
|
|
894
|
+
// submit arbitrary error string. status enum mirrors ProbeAckPayload.
|
|
895
|
+
db.exec(`
|
|
896
|
+
CREATE TABLE IF NOT EXISTS probe_results (
|
|
897
|
+
probe_id TEXT PRIMARY KEY,
|
|
898
|
+
provider_id TEXT NOT NULL,
|
|
899
|
+
model_name TEXT NOT NULL,
|
|
900
|
+
daemon_node_id TEXT NOT NULL,
|
|
901
|
+
network_id TEXT NOT NULL,
|
|
902
|
+
status TEXT NOT NULL,
|
|
903
|
+
latency_ms INTEGER,
|
|
904
|
+
error_label TEXT,
|
|
905
|
+
probed_at INTEGER NOT NULL,
|
|
906
|
+
probed_by_user TEXT,
|
|
907
|
+
raw_status_code INTEGER
|
|
908
|
+
);
|
|
909
|
+
CREATE INDEX IF NOT EXISTS idx_probe_matrix ON probe_results(network_id, provider_id, model_name, daemon_node_id, probed_at DESC);
|
|
910
|
+
`);
|
|
911
|
+
|
|
283
912
|
// ── V3: audit_log table ──
|
|
284
913
|
db.exec(`
|
|
285
914
|
CREATE TABLE IF NOT EXISTS audit_log (
|
|
@@ -300,6 +929,64 @@ db.exec(`
|
|
|
300
929
|
CREATE INDEX IF NOT EXISTS idx_audit_network ON audit_log(network_id);
|
|
301
930
|
`);
|
|
302
931
|
|
|
932
|
+
// SkillHub — immutable, network-scoped skill submissions. A (network, slug,
|
|
933
|
+
// version) tuple is write-once: retries with the same content are idempotent,
|
|
934
|
+
// while different content must use a new version. Node submissions remain
|
|
935
|
+
// pending until an owner/admin explicitly publishes them.
|
|
936
|
+
db.exec(`
|
|
937
|
+
CREATE TABLE IF NOT EXISTS skillhub_skills (
|
|
938
|
+
skill_id TEXT PRIMARY KEY,
|
|
939
|
+
network_id TEXT NOT NULL,
|
|
940
|
+
slug TEXT NOT NULL,
|
|
941
|
+
name TEXT NOT NULL,
|
|
942
|
+
description TEXT NOT NULL DEFAULT '',
|
|
943
|
+
version TEXT NOT NULL,
|
|
944
|
+
content TEXT NOT NULL,
|
|
945
|
+
content_hash TEXT NOT NULL,
|
|
946
|
+
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'published', 'rejected', 'archived')),
|
|
947
|
+
source_type TEXT NOT NULL CHECK(source_type IN ('node', 'user')),
|
|
948
|
+
source_alias TEXT,
|
|
949
|
+
created_by_user TEXT,
|
|
950
|
+
reviewed_by_user TEXT,
|
|
951
|
+
review_note TEXT,
|
|
952
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
953
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
954
|
+
reviewed_at TEXT,
|
|
955
|
+
UNIQUE(network_id, slug, version)
|
|
956
|
+
);
|
|
957
|
+
CREATE INDEX IF NOT EXISTS idx_skillhub_network_status
|
|
958
|
+
ON skillhub_skills(network_id, status, updated_at DESC);
|
|
959
|
+
CREATE INDEX IF NOT EXISTS idx_skillhub_network_slug
|
|
960
|
+
ON skillhub_skills(network_id, slug, version);
|
|
961
|
+
`);
|
|
962
|
+
|
|
963
|
+
// 需求池。长期卡片,按 network 隔离。不是 tasks。
|
|
964
|
+
db.exec(`
|
|
965
|
+
CREATE TABLE IF NOT EXISTS requirements (
|
|
966
|
+
requirement_id TEXT PRIMARY KEY,
|
|
967
|
+
network_id TEXT NOT NULL,
|
|
968
|
+
title TEXT NOT NULL,
|
|
969
|
+
column_name TEXT NOT NULL DEFAULT 'pool' CHECK(column_name IN ('pool', 'doing', 'done')),
|
|
970
|
+
priority TEXT NOT NULL DEFAULT 'normal' CHECK(priority IN ('high', 'normal', 'low', 'lowest')),
|
|
971
|
+
due_on TEXT,
|
|
972
|
+
assignee TEXT,
|
|
973
|
+
client_id TEXT,
|
|
974
|
+
issues_json TEXT,
|
|
975
|
+
created_by TEXT,
|
|
976
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
977
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
978
|
+
);
|
|
979
|
+
CREATE INDEX IF NOT EXISTS idx_requirements_network
|
|
980
|
+
ON requirements(network_id, created_at DESC);
|
|
981
|
+
`);
|
|
982
|
+
try { db.exec("ALTER TABLE requirements ADD COLUMN client_id TEXT"); } catch {}
|
|
983
|
+
try { db.exec("ALTER TABLE requirements ADD COLUMN issues_json TEXT"); } catch {}
|
|
984
|
+
try { db.exec("ALTER TABLE requirements ADD COLUMN tags_json TEXT NOT NULL DEFAULT '[]'"); } catch (e: any) { if (!/duplicate column|already exists/i.test(e?.message || "")) throw e; }
|
|
985
|
+
db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_requirements_client ON requirements(network_id, client_id) WHERE client_id IS NOT NULL AND client_id != ''");
|
|
986
|
+
for (const column of ["owner_json TEXT", "participants_json TEXT NOT NULL DEFAULT '[]'", "agent_owner_json TEXT", "description TEXT", "checklist_json TEXT", "project_id TEXT", "external_ref TEXT", "external_url TEXT", "archived INTEGER NOT NULL DEFAULT 0", "created_by_json TEXT", "updated_by_json TEXT", "parent_id TEXT", "start_on TEXT"]) {
|
|
987
|
+
try { db.exec(`ALTER TABLE requirements ADD COLUMN ${column}`); } catch (e: any) { if (!/duplicate column|already exists/i.test(e?.message || "")) throw e; }
|
|
988
|
+
}
|
|
989
|
+
|
|
303
990
|
// ── V3: licenses table ──
|
|
304
991
|
db.exec(`
|
|
305
992
|
CREATE TABLE IF NOT EXISTS licenses (
|
|
@@ -356,6 +1043,68 @@ db.exec(`
|
|
|
356
1043
|
);
|
|
357
1044
|
`);
|
|
358
1045
|
|
|
1046
|
+
// ── 多用户账号与 Agent 权限:成员可见哪些 Agent ──
|
|
1047
|
+
// network_members.agent_access:
|
|
1048
|
+
// 'all' —— 旧语义:能看到并联系网络里所有 Agent。ALTER 的 DEFAULT 让**升级前已有的**成员行
|
|
1049
|
+
// 全部落在这里,生产上已有成员的可见范围不因升级而变。
|
|
1050
|
+
// 'granted' —— 只看到 network_member_agent_grants 里给了的 Agent。此后**新加入**的
|
|
1051
|
+
// member/viewer(管理员建号、POST members、邀请码)都以它入库,即默认零权限。
|
|
1052
|
+
// owner/admin 角色与 Hub 管理员不看这一列(永远全部可见),判定集中在 agent-access.ts。
|
|
1053
|
+
try { db.exec("ALTER TABLE network_members ADD COLUMN agent_access TEXT NOT NULL DEFAULT 'all'"); } catch {}
|
|
1054
|
+
// node_id 优先(稳定);alias 只给没有 node_id 的旧会话用。两者恰有其一。
|
|
1055
|
+
db.exec(`
|
|
1056
|
+
CREATE TABLE IF NOT EXISTS network_member_agent_grants (
|
|
1057
|
+
network_id TEXT NOT NULL,
|
|
1058
|
+
user_id TEXT NOT NULL,
|
|
1059
|
+
node_id TEXT,
|
|
1060
|
+
alias TEXT,
|
|
1061
|
+
can_message INTEGER NOT NULL DEFAULT 1,
|
|
1062
|
+
created_by TEXT,
|
|
1063
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
1064
|
+
CHECK ((node_id IS NULL) <> (alias IS NULL))
|
|
1065
|
+
);
|
|
1066
|
+
|
|
1067
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_grants_node ON network_member_agent_grants(network_id, user_id, node_id) WHERE node_id IS NOT NULL;
|
|
1068
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_grants_alias ON network_member_agent_grants(network_id, user_id, alias) WHERE alias IS NOT NULL;
|
|
1069
|
+
`);
|
|
1070
|
+
|
|
1071
|
+
// ── Agent 分组(RFC-038 §8):管理员自由定义的组,授权可以给到组,组成员动态生效 ──
|
|
1072
|
+
// 组授权单独一张表(不给 network_member_agent_grants 加列):那张表的 CHECK 改不了,
|
|
1073
|
+
// 而且旧 app 的 PUT 是整体替换 —— 同表的话,旧 app 一保存就把组授权清掉。
|
|
1074
|
+
db.exec(`
|
|
1075
|
+
CREATE TABLE IF NOT EXISTS agent_groups (
|
|
1076
|
+
group_id TEXT PRIMARY KEY,
|
|
1077
|
+
network_id TEXT NOT NULL,
|
|
1078
|
+
name TEXT NOT NULL,
|
|
1079
|
+
description TEXT,
|
|
1080
|
+
created_by TEXT,
|
|
1081
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
1082
|
+
updated_at TEXT
|
|
1083
|
+
);
|
|
1084
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_groups_name ON agent_groups(network_id, name);
|
|
1085
|
+
|
|
1086
|
+
CREATE TABLE IF NOT EXISTS agent_group_members (
|
|
1087
|
+
group_id TEXT NOT NULL,
|
|
1088
|
+
node_id TEXT NOT NULL,
|
|
1089
|
+
added_by TEXT,
|
|
1090
|
+
added_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
1091
|
+
PRIMARY KEY (group_id, node_id)
|
|
1092
|
+
);
|
|
1093
|
+
|
|
1094
|
+
CREATE TABLE IF NOT EXISTS network_member_group_grants (
|
|
1095
|
+
network_id TEXT NOT NULL,
|
|
1096
|
+
user_id TEXT NOT NULL,
|
|
1097
|
+
group_id TEXT NOT NULL,
|
|
1098
|
+
can_message INTEGER NOT NULL DEFAULT 1,
|
|
1099
|
+
created_by TEXT,
|
|
1100
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
1101
|
+
PRIMARY KEY (network_id, user_id, group_id)
|
|
1102
|
+
);
|
|
1103
|
+
-- 改组成员 / 删组时按组找被授权成员(usersGrantedGroup、granted_user_count):主键以 network_id 开头用不上。
|
|
1104
|
+
-- 展开查询本身走各表主键,见 agent-groups-perf.test.ts 的 EXPLAIN QUERY PLAN。
|
|
1105
|
+
CREATE INDEX IF NOT EXISTS idx_group_grants_group ON network_member_group_grants(group_id);
|
|
1106
|
+
`);
|
|
1107
|
+
|
|
359
1108
|
// ── #84: node rename — rename_txn table (RFC-010 §4) ──
|
|
360
1109
|
// Single isolated table holding the rename 2PC transaction state. It doubles
|
|
361
1110
|
// as the alias_rename_log (RFC §4 risk #5): the audit log of completed renames
|
|
@@ -410,6 +1159,14 @@ for (const table of ["sessions", "nodes", "tasks", "inbox", "task_events", "comp
|
|
|
410
1159
|
try { db.exec(`ALTER TABLE ${table} ADD COLUMN network_id TEXT`); } catch {}
|
|
411
1160
|
}
|
|
412
1161
|
|
|
1162
|
+
// #167 — stable lifecycle event names plus an internal idempotency key.
|
|
1163
|
+
// Ordinary status transitions keep event_key NULL, so retries and repeated
|
|
1164
|
+
// transitions remain auditable. Watcher warnings set a stable key; the unique
|
|
1165
|
+
// index makes every (task, threshold) write-once across patrol rounds/workers.
|
|
1166
|
+
try { db.exec("ALTER TABLE task_events ADD COLUMN event_type TEXT"); } catch {}
|
|
1167
|
+
try { db.exec("ALTER TABLE task_events ADD COLUMN event_key TEXT"); } catch {}
|
|
1168
|
+
try { db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_task_events_task_key ON task_events(task_id, event_key)"); } catch {}
|
|
1169
|
+
|
|
413
1170
|
// PR-1 (#146): durable node identity on historical inbox/tasks rows. Some
|
|
414
1171
|
// existing databases created `tasks` before from_node_id/to_node_id were in the
|
|
415
1172
|
// CREATE TABLE statement, so keep these ALTERs explicit and idempotent.
|
|
@@ -685,6 +1442,12 @@ migrateSessionsNetworkAliasUnique();
|
|
|
685
1442
|
try { db.exec("CREATE INDEX IF NOT EXISTS idx_sessions_network ON sessions(network_id)"); } catch {}
|
|
686
1443
|
try { db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_network_alias_unique ON sessions(network_id, alias)"); } catch {}
|
|
687
1444
|
try { db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_network ON tasks(network_id)"); } catch {}
|
|
1445
|
+
// #500 — covering index for the full /api/status queue_depth GROUP BY (task-queue-ahead.ts queueDepthQuery): the 24 h
|
|
1446
|
+
// created_at range plus every column that query reads, so it never touches the (wide) task rows. Here, not in the
|
|
1447
|
+
// CREATE TABLE block: tasks.network_id only exists after the ALTER above. On a read-only copy of the production DB
|
|
1448
|
+
// (57k tasks, 1.9k in the last 24 h) the statement went from ~3 ms (idx_tasks_created + one row lookup per task) to
|
|
1449
|
+
// ~0.3 ms; building it there took ~170 ms once. Plain composite index: the same DDL runs on PostgreSQL.
|
|
1450
|
+
try { db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_created_queue ON tasks(created_at, status, network_id, to_name)"); } catch {}
|
|
688
1451
|
try { db.exec("CREATE INDEX IF NOT EXISTS idx_nodes_network ON nodes(network_id)"); } catch {}
|
|
689
1452
|
try { db.exec("CREATE INDEX IF NOT EXISTS idx_inbox_network ON inbox(network_id)"); } catch {}
|
|
690
1453
|
try { db.exec("CREATE INDEX IF NOT EXISTS idx_task_events_network ON task_events(network_id)"); } catch {}
|
|
@@ -696,6 +1459,176 @@ try { db.exec("CREATE INDEX IF NOT EXISTS idx_completions_network ON completions
|
|
|
696
1459
|
// the reply up the chain via parent_task_id.
|
|
697
1460
|
try { db.exec("ALTER TABLE tasks ADD COLUMN parent_task_id TEXT"); } catch {}
|
|
698
1461
|
try { db.exec("ALTER TABLE tasks ADD COLUMN meta_json TEXT"); } catch {}
|
|
1462
|
+
// #520 — two monotonic evidence levels for this exact task:
|
|
1463
|
+
// runtime_submitted_at means agent-node handed the body to the vendor runtime;
|
|
1464
|
+
// consumed_at requires an attributable turn-start/first-activity signal.
|
|
1465
|
+
// Both stay separate from delivered_at (enqueue), acked (process receipt), and
|
|
1466
|
+
// started_at (legacy report_status/content matching). Old binaries ignore them.
|
|
1467
|
+
try { db.exec("ALTER TABLE tasks ADD COLUMN runtime_submitted_at TEXT"); } catch {}
|
|
1468
|
+
try { db.exec("ALTER TABLE tasks ADD COLUMN consumed_at TEXT"); } catch {}
|
|
1469
|
+
// Exact vendor-runtime boundary for SideThread-capable clients. These values
|
|
1470
|
+
// are written only by the token-bound target node after attributable runtime
|
|
1471
|
+
// evidence; the Hub never derives them from task text or caller input.
|
|
1472
|
+
try { db.exec("ALTER TABLE tasks ADD COLUMN thread_id TEXT"); } catch {}
|
|
1473
|
+
try { db.exec("ALTER TABLE tasks ADD COLUMN turn_id TEXT"); } catch {}
|
|
1474
|
+
|
|
1475
|
+
// #1181 durable outbound terminal cursor. A task's created_at cannot be used
|
|
1476
|
+
// as a recovery watermark because an old task may become terminal after newer
|
|
1477
|
+
// tasks. Record terminal transitions in their own monotonically increasing
|
|
1478
|
+
// journal instead. The journal is append-only for a logical task; retry keeps
|
|
1479
|
+
// the existing task identity and therefore the same idempotency domain.
|
|
1480
|
+
db.exec(`
|
|
1481
|
+
CREATE TABLE IF NOT EXISTS task_terminal_events (
|
|
1482
|
+
terminal_seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1483
|
+
task_id TEXT NOT NULL UNIQUE,
|
|
1484
|
+
network_id TEXT,
|
|
1485
|
+
from_node_id TEXT,
|
|
1486
|
+
completed_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
1487
|
+
);
|
|
1488
|
+
CREATE INDEX IF NOT EXISTS idx_task_terminal_events_sender_seq
|
|
1489
|
+
ON task_terminal_events(network_id, from_node_id, terminal_seq);
|
|
1490
|
+
`);
|
|
1491
|
+
if (db.dialect === "sqlite") {
|
|
1492
|
+
db.exec(`
|
|
1493
|
+
CREATE TRIGGER IF NOT EXISTS tasks_terminal_event_insert
|
|
1494
|
+
AFTER INSERT ON tasks
|
|
1495
|
+
WHEN NEW.status IN ('replied', 'failed', 'cancelled', 'expired')
|
|
1496
|
+
BEGIN
|
|
1497
|
+
INSERT OR IGNORE INTO task_terminal_events (task_id, network_id, from_node_id, completed_at)
|
|
1498
|
+
VALUES (NEW.task_id, NEW.network_id, NEW.from_node_id, COALESCE(NEW.completed_at, datetime('now')));
|
|
1499
|
+
END;
|
|
1500
|
+
CREATE TRIGGER IF NOT EXISTS tasks_terminal_event_update
|
|
1501
|
+
AFTER UPDATE OF status ON tasks
|
|
1502
|
+
WHEN NEW.status IN ('replied', 'failed', 'cancelled', 'expired')
|
|
1503
|
+
AND OLD.status NOT IN ('replied', 'failed', 'cancelled', 'expired')
|
|
1504
|
+
BEGIN
|
|
1505
|
+
INSERT OR IGNORE INTO task_terminal_events (task_id, network_id, from_node_id, completed_at)
|
|
1506
|
+
VALUES (NEW.task_id, NEW.network_id, NEW.from_node_id, COALESCE(NEW.completed_at, datetime('now')));
|
|
1507
|
+
END;
|
|
1508
|
+
`);
|
|
1509
|
+
db.run(`INSERT OR IGNORE INTO task_terminal_events (task_id, network_id, from_node_id, completed_at)
|
|
1510
|
+
SELECT task_id, network_id, from_node_id, COALESCE(completed_at, created_at)
|
|
1511
|
+
FROM tasks
|
|
1512
|
+
WHERE status IN ('replied', 'failed', 'cancelled', 'expired')
|
|
1513
|
+
ORDER BY COALESCE(completed_at, created_at), task_id`);
|
|
1514
|
+
} else {
|
|
1515
|
+
db.exec(`
|
|
1516
|
+
CREATE OR REPLACE FUNCTION record_task_terminal_event() RETURNS trigger AS $$
|
|
1517
|
+
BEGIN
|
|
1518
|
+
IF NEW.status IN ('replied', 'failed', 'cancelled', 'expired')
|
|
1519
|
+
AND (TG_OP = 'INSERT' OR OLD.status NOT IN ('replied', 'failed', 'cancelled', 'expired')) THEN
|
|
1520
|
+
INSERT INTO task_terminal_events (task_id, network_id, from_node_id, completed_at)
|
|
1521
|
+
VALUES (NEW.task_id, NEW.network_id, NEW.from_node_id, COALESCE(NEW.completed_at, datetime('now')))
|
|
1522
|
+
ON CONFLICT (task_id) DO NOTHING;
|
|
1523
|
+
END IF;
|
|
1524
|
+
RETURN NEW;
|
|
1525
|
+
END;
|
|
1526
|
+
$$ LANGUAGE plpgsql;
|
|
1527
|
+
DROP TRIGGER IF EXISTS tasks_terminal_event_insert ON tasks;
|
|
1528
|
+
DROP TRIGGER IF EXISTS tasks_terminal_event_update ON tasks;
|
|
1529
|
+
CREATE TRIGGER tasks_terminal_event_insert AFTER INSERT ON tasks
|
|
1530
|
+
FOR EACH ROW EXECUTE FUNCTION record_task_terminal_event();
|
|
1531
|
+
CREATE TRIGGER tasks_terminal_event_update AFTER UPDATE OF status ON tasks
|
|
1532
|
+
FOR EACH ROW EXECUTE FUNCTION record_task_terminal_event();
|
|
1533
|
+
INSERT INTO task_terminal_events (task_id, network_id, from_node_id, completed_at)
|
|
1534
|
+
SELECT task_id, network_id, from_node_id, COALESCE(completed_at, created_at)
|
|
1535
|
+
FROM tasks WHERE status IN ('replied', 'failed', 'cancelled', 'expired')
|
|
1536
|
+
ON CONFLICT (task_id) DO NOTHING;
|
|
1537
|
+
`);
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
// ── Hub scheduled tasks ──────────────────────────────────────────────
|
|
1541
|
+
// Scheduling is a Hub concern: clients manage these rows, while agents only
|
|
1542
|
+
// receive the ordinary inbox/tasks rows produced for each occurrence. Keep
|
|
1543
|
+
// the occurrence table separate so retries, skips and failures remain
|
|
1544
|
+
// auditable without overloading the task lifecycle.
|
|
1545
|
+
db.exec(`
|
|
1546
|
+
CREATE TABLE IF NOT EXISTS scheduled_tasks (
|
|
1547
|
+
schedule_id TEXT PRIMARY KEY,
|
|
1548
|
+
network_id TEXT NOT NULL,
|
|
1549
|
+
created_by TEXT,
|
|
1550
|
+
name TEXT NOT NULL,
|
|
1551
|
+
target_node_id TEXT NOT NULL,
|
|
1552
|
+
target_alias TEXT NOT NULL,
|
|
1553
|
+
task_content TEXT NOT NULL,
|
|
1554
|
+
priority TEXT NOT NULL DEFAULT 'normal',
|
|
1555
|
+
schedule_type TEXT NOT NULL,
|
|
1556
|
+
schedule_json TEXT NOT NULL,
|
|
1557
|
+
timezone TEXT NOT NULL DEFAULT 'UTC',
|
|
1558
|
+
overlap_policy TEXT NOT NULL DEFAULT 'skip',
|
|
1559
|
+
misfire_policy TEXT NOT NULL DEFAULT 'catch_up_once',
|
|
1560
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
1561
|
+
next_run_at TEXT,
|
|
1562
|
+
last_run_at TEXT,
|
|
1563
|
+
revision INTEGER NOT NULL DEFAULT 1,
|
|
1564
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
1565
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
1566
|
+
);
|
|
1567
|
+
|
|
1568
|
+
CREATE INDEX IF NOT EXISTS idx_scheduled_tasks_due
|
|
1569
|
+
ON scheduled_tasks(status, next_run_at);
|
|
1570
|
+
CREATE INDEX IF NOT EXISTS idx_scheduled_tasks_network
|
|
1571
|
+
ON scheduled_tasks(network_id, updated_at DESC);
|
|
1572
|
+
CREATE INDEX IF NOT EXISTS idx_scheduled_tasks_target
|
|
1573
|
+
ON scheduled_tasks(network_id, target_node_id);
|
|
1574
|
+
|
|
1575
|
+
CREATE TABLE IF NOT EXISTS scheduled_task_runs (
|
|
1576
|
+
run_id TEXT PRIMARY KEY,
|
|
1577
|
+
schedule_id TEXT NOT NULL,
|
|
1578
|
+
network_id TEXT NOT NULL,
|
|
1579
|
+
scheduled_for TEXT NOT NULL,
|
|
1580
|
+
task_id TEXT,
|
|
1581
|
+
status TEXT NOT NULL,
|
|
1582
|
+
error_code TEXT,
|
|
1583
|
+
error_message TEXT,
|
|
1584
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
1585
|
+
completed_at TEXT,
|
|
1586
|
+
UNIQUE(schedule_id, scheduled_for)
|
|
1587
|
+
);
|
|
1588
|
+
|
|
1589
|
+
CREATE INDEX IF NOT EXISTS idx_scheduled_runs_schedule
|
|
1590
|
+
ON scheduled_task_runs(schedule_id, created_at DESC);
|
|
1591
|
+
CREATE INDEX IF NOT EXISTS idx_scheduled_runs_network
|
|
1592
|
+
ON scheduled_task_runs(network_id, created_at DESC);
|
|
1593
|
+
`);
|
|
1594
|
+
try { db.exec("ALTER TABLE scheduled_tasks ADD COLUMN misfire_policy TEXT NOT NULL DEFAULT 'catch_up_once'"); } catch {}
|
|
1595
|
+
// Skipped runs (overlap_policy=skip) name the open task that blocked them and
|
|
1596
|
+
// whether the node had ever picked it up: not_received | in_progress.
|
|
1597
|
+
try { db.exec("ALTER TABLE scheduled_task_runs ADD COLUMN blocked_by_task_id TEXT"); } catch {}
|
|
1598
|
+
try { db.exec("ALTER TABLE scheduled_task_runs ADD COLUMN blocked_by_state TEXT"); } catch {}
|
|
1599
|
+
|
|
1600
|
+
// RFC-036 / B4 — owner-authorized edits of node-host managed schedules.
|
|
1601
|
+
// This is intentionally separate from Hub scheduled_tasks: these rows are
|
|
1602
|
+
// one-shot control intents consumed by the exact token-bound node. The partial
|
|
1603
|
+
// unique index makes the single-flight promise survive multiple Hub workers.
|
|
1604
|
+
db.exec(`
|
|
1605
|
+
CREATE TABLE IF NOT EXISTS external_schedule_edits (
|
|
1606
|
+
intent_id TEXT PRIMARY KEY,
|
|
1607
|
+
network_id TEXT NOT NULL,
|
|
1608
|
+
node_id TEXT NOT NULL,
|
|
1609
|
+
schedule_id TEXT NOT NULL,
|
|
1610
|
+
base_revision INTEGER NOT NULL,
|
|
1611
|
+
patch_json TEXT NOT NULL,
|
|
1612
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
1613
|
+
expires_at INTEGER NOT NULL,
|
|
1614
|
+
created_at INTEGER NOT NULL,
|
|
1615
|
+
delivered_at INTEGER,
|
|
1616
|
+
acked_at INTEGER,
|
|
1617
|
+
created_by_user TEXT NOT NULL,
|
|
1618
|
+
created_by_token TEXT NOT NULL,
|
|
1619
|
+
consumed_by_token TEXT,
|
|
1620
|
+
result_revision INTEGER,
|
|
1621
|
+
error_code TEXT
|
|
1622
|
+
);
|
|
1623
|
+
|
|
1624
|
+
CREATE INDEX IF NOT EXISTS idx_external_schedule_edits_node
|
|
1625
|
+
ON external_schedule_edits(network_id, node_id, status, created_at);
|
|
1626
|
+
CREATE INDEX IF NOT EXISTS idx_external_schedule_edits_owner
|
|
1627
|
+
ON external_schedule_edits(created_by_user, created_at DESC);
|
|
1628
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_external_schedule_edits_singleflight
|
|
1629
|
+
ON external_schedule_edits(node_id, schedule_id)
|
|
1630
|
+
WHERE status IN ('pending', 'delivered');
|
|
1631
|
+
`);
|
|
699
1632
|
try { db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_task_id)"); } catch {}
|
|
700
1633
|
|
|
701
1634
|
// Helpers
|
|
@@ -707,8 +1640,179 @@ export function generateId(prefix: string): string {
|
|
|
707
1640
|
return `${prefix}_${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
|
|
708
1641
|
}
|
|
709
1642
|
|
|
710
|
-
|
|
711
|
-
|
|
1643
|
+
// ──────────────────────────────────────────────────────────────────
|
|
1644
|
+
// Round-6 A1 — Password hashing: salted scrypt with lazy migration
|
|
1645
|
+
//
|
|
1646
|
+
// Format: `scrypt$<N>$<salt-b64>$<hash-b64>`
|
|
1647
|
+
// N = log2(cost). E.g. 14 → 2^14 = 16384 (~50ms on modern hw).
|
|
1648
|
+
// salt-b64 = base64 of 16 random bytes.
|
|
1649
|
+
// hash-b64 = base64 of 64-byte scrypt output.
|
|
1650
|
+
//
|
|
1651
|
+
// Why scrypt: Node built-in crypto.scryptSync — zero new deps.
|
|
1652
|
+
// Memory-hard, ASIC-resistant. Better than bcrypt under modern GPU
|
|
1653
|
+
// attack. argon2id would be marginally better but pulls a native
|
|
1654
|
+
// dep (argon2 npm package) we don't want on the hub.
|
|
1655
|
+
//
|
|
1656
|
+
// Why "$" delimiter: legacy hashes are bare hex (0-9a-f only). Any
|
|
1657
|
+
// "$" in stored hash means new format. Trivial detect with zero
|
|
1658
|
+
// false-positives.
|
|
1659
|
+
//
|
|
1660
|
+
// Lazy migration:
|
|
1661
|
+
// - register/bootstrap/changePassword/resetUserPassword always
|
|
1662
|
+
// write the new format.
|
|
1663
|
+
// - login + changePassword (old-verify) accept BOTH formats.
|
|
1664
|
+
// - on successful login against a legacy hash, the caller rehashes
|
|
1665
|
+
// in place. Zero downtime, no forced password change.
|
|
1666
|
+
//
|
|
1667
|
+
// See feedback_assume_unit_before_threshold.md: explicit-set
|
|
1668
|
+
// crypto parameters; don't rely on library defaults.
|
|
1669
|
+
// ──────────────────────────────────────────────────────────────────
|
|
1670
|
+
|
|
1671
|
+
import { scryptSync, randomBytes, timingSafeEqual } from "node:crypto";
|
|
1672
|
+
|
|
1673
|
+
// Default cost parameter (N = 2^14 = 16384). Tunable via env for
|
|
1674
|
+
// test suites that don't want to spend ~50ms per scrypt call.
|
|
1675
|
+
// N=10 → 2^10 = 1024 iter, ~5ms (test-only)
|
|
1676
|
+
// N=14 → 2^14 = 16384 iter, ~50ms (production)
|
|
1677
|
+
// N=15 → 2^15 = 32768 iter, ~100ms (if hw improves and ops want headroom)
|
|
1678
|
+
const DEFAULT_SCRYPT_N = 14;
|
|
1679
|
+
const SCRYPT_R = 8;
|
|
1680
|
+
const SCRYPT_P = 1;
|
|
1681
|
+
const SCRYPT_KEYLEN = 64;
|
|
1682
|
+
const SCRYPT_SALT_BYTES = 16;
|
|
1683
|
+
// Bun's scrypt defaults maxmem to ~32 MiB which is too tight for
|
|
1684
|
+
// N ≥ 14 (128 * r * 2^N = ~16 MiB at r=8, N=14; default has no
|
|
1685
|
+
// headroom for the internal buffers). Set explicitly so a higher-N
|
|
1686
|
+
// upgrade later doesn't suddenly throw at runtime.
|
|
1687
|
+
const SCRYPT_MAXMEM = 128 * 1024 * 1024; // 128 MiB
|
|
1688
|
+
|
|
1689
|
+
function getScryptN(): number {
|
|
1690
|
+
const raw = process.env.COMMHUB_SCRYPT_N;
|
|
1691
|
+
if (!raw) return DEFAULT_SCRYPT_N;
|
|
1692
|
+
const n = parseInt(raw, 10);
|
|
1693
|
+
// Bound: 8 (way-too-weak, only for unit tests) — 20 (DoS risk).
|
|
1694
|
+
if (!Number.isFinite(n) || n < 8 || n > 20) return DEFAULT_SCRYPT_N;
|
|
1695
|
+
return n;
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
export function hashPassword(plain: string): string {
|
|
1699
|
+
const N = getScryptN();
|
|
1700
|
+
const salt = randomBytes(SCRYPT_SALT_BYTES);
|
|
1701
|
+
const hash = scryptSync(plain, salt, SCRYPT_KEYLEN, {
|
|
1702
|
+
N: 1 << N,
|
|
1703
|
+
r: SCRYPT_R,
|
|
1704
|
+
p: SCRYPT_P,
|
|
1705
|
+
maxmem: SCRYPT_MAXMEM,
|
|
1706
|
+
});
|
|
1707
|
+
return `scrypt$${N}$${salt.toString("base64")}$${hash.toString("base64")}`;
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
/**
|
|
1711
|
+
* Verify a plaintext password against the stored hash. Accepts BOTH
|
|
1712
|
+
* the new scrypt format and the legacy bare-hex SHA-256 format
|
|
1713
|
+
* (back-compat for pre-A1 deployments).
|
|
1714
|
+
*
|
|
1715
|
+
* Returns:
|
|
1716
|
+
* ok — true iff the password matches.
|
|
1717
|
+
* needsRehash — true iff the stored hash was legacy format AND the
|
|
1718
|
+
* password matched. The caller MUST rehash and write
|
|
1719
|
+
* the new format back to disk so the legacy hash
|
|
1720
|
+
* stops existing. Returned false for already-new
|
|
1721
|
+
* hashes (no rehash needed even on N upgrade —
|
|
1722
|
+
* follow-up if/when we bump default N).
|
|
1723
|
+
*/
|
|
1724
|
+
export function verifyPassword(plain: string, stored: string): { ok: boolean; needsRehash: boolean } {
|
|
1725
|
+
// Format detection: new format always contains "$". Legacy bare
|
|
1726
|
+
// sha256 hex never does (0-9a-f only).
|
|
1727
|
+
if (stored.includes("$")) {
|
|
1728
|
+
const parts = stored.split("$");
|
|
1729
|
+
if (parts.length !== 4 || parts[0] !== "scrypt") {
|
|
1730
|
+
// Unknown new-style scheme — be conservative, reject.
|
|
1731
|
+
return { ok: false, needsRehash: false };
|
|
1732
|
+
}
|
|
1733
|
+
const N = parseInt(parts[1], 10);
|
|
1734
|
+
if (!Number.isFinite(N) || N < 8 || N > 20) return { ok: false, needsRehash: false };
|
|
1735
|
+
let saltBuf: Buffer;
|
|
1736
|
+
let expectedBuf: Buffer;
|
|
1737
|
+
try {
|
|
1738
|
+
saltBuf = Buffer.from(parts[2], "base64");
|
|
1739
|
+
expectedBuf = Buffer.from(parts[3], "base64");
|
|
1740
|
+
} catch {
|
|
1741
|
+
return { ok: false, needsRehash: false };
|
|
1742
|
+
}
|
|
1743
|
+
if (expectedBuf.length === 0) return { ok: false, needsRehash: false };
|
|
1744
|
+
let actualBuf: Buffer;
|
|
1745
|
+
try {
|
|
1746
|
+
actualBuf = scryptSync(plain, saltBuf, expectedBuf.length, {
|
|
1747
|
+
N: 1 << N,
|
|
1748
|
+
r: SCRYPT_R,
|
|
1749
|
+
p: SCRYPT_P,
|
|
1750
|
+
maxmem: SCRYPT_MAXMEM,
|
|
1751
|
+
});
|
|
1752
|
+
} catch {
|
|
1753
|
+
return { ok: false, needsRehash: false };
|
|
1754
|
+
}
|
|
1755
|
+
// timingSafeEqual requires equal-length buffers; we sized actual
|
|
1756
|
+
// to expected.length above so this is safe.
|
|
1757
|
+
const ok = timingSafeEqual(actualBuf, expectedBuf);
|
|
1758
|
+
return { ok, needsRehash: false };
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
// Legacy bare-hex SHA-256 format (pre-A1).
|
|
1762
|
+
const legacy = new Bun.CryptoHasher("sha256").update(`anet:${plain}`).digest("hex");
|
|
1763
|
+
// Use timingSafeEqual on byte buffers (hex strings would be
|
|
1764
|
+
// compared via JS === which is variable-time on first-differing
|
|
1765
|
+
// char — the timing leak is tiny for fixed-format hex but pin the
|
|
1766
|
+
// habit). Both are exactly 64 hex chars when sha256 produced.
|
|
1767
|
+
if (stored.length !== legacy.length) {
|
|
1768
|
+
// Length mismatch is itself indistinguishable from a parse miss
|
|
1769
|
+
// — but we still pay the scrypt to keep this branch's timing
|
|
1770
|
+
// peer to the verify branches below.
|
|
1771
|
+
burnEqualTimeScrypt(plain);
|
|
1772
|
+
return { ok: false, needsRehash: false };
|
|
1773
|
+
}
|
|
1774
|
+
let ok = false;
|
|
1775
|
+
try {
|
|
1776
|
+
ok = timingSafeEqual(Buffer.from(stored, "hex"), Buffer.from(legacy, "hex"));
|
|
1777
|
+
} catch {
|
|
1778
|
+
burnEqualTimeScrypt(plain);
|
|
1779
|
+
return { ok: false, needsRehash: false };
|
|
1780
|
+
}
|
|
1781
|
+
// Timing-oracle close (round-6 round-2 — independent reviewer flag):
|
|
1782
|
+
// legacy-OK path callers (auth.ts:login) do `hashPassword(password)`
|
|
1783
|
+
// immediately after to rehash → one scrypt cost on top of
|
|
1784
|
+
// this function's return. ~50ms.
|
|
1785
|
+
// legacy-WRONG path callers reject and return → ZERO additional cost.
|
|
1786
|
+
//
|
|
1787
|
+
// That asymmetry leaks "is this user on the legacy hash" via wall-
|
|
1788
|
+
// clock difference (sub-ms wrong vs ~50ms ok). To close it, burn an
|
|
1789
|
+
// equal-time scrypt on the wrong-password branch too. Now both
|
|
1790
|
+
// legacy paths cost SHA-256 + one scrypt, matching new-format paths
|
|
1791
|
+
// and the missing-user dummy-scrypt path.
|
|
1792
|
+
if (!ok) {
|
|
1793
|
+
burnEqualTimeScrypt(plain);
|
|
1794
|
+
}
|
|
1795
|
+
return { ok, needsRehash: ok };
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
// Round-6 round-2 hardening — throwaway scrypt to equalize timing on
|
|
1799
|
+
// failure paths. Burns the same wall-clock cost as a real
|
|
1800
|
+
// hashPassword() call. Result discarded; salt is a fresh random so
|
|
1801
|
+
// the work is real (not optimised away). Cost ~N=14 → 50ms,
|
|
1802
|
+
// COMMHUB_SCRYPT_N controlled like the real path.
|
|
1803
|
+
function burnEqualTimeScrypt(plain: string): void {
|
|
1804
|
+
try {
|
|
1805
|
+
const N = getScryptN();
|
|
1806
|
+
scryptSync(plain, randomBytes(SCRYPT_SALT_BYTES), SCRYPT_KEYLEN, {
|
|
1807
|
+
N: 1 << N,
|
|
1808
|
+
r: SCRYPT_R,
|
|
1809
|
+
p: SCRYPT_P,
|
|
1810
|
+
maxmem: SCRYPT_MAXMEM,
|
|
1811
|
+
});
|
|
1812
|
+
} catch {
|
|
1813
|
+
// Even on failure to scrypt (shouldn't happen), don't surface —
|
|
1814
|
+
// this is a timing pacer, not a correctness path.
|
|
1815
|
+
}
|
|
712
1816
|
}
|
|
713
1817
|
|
|
714
1818
|
export function hashToken(token: string): string {
|
|
@@ -762,17 +1866,39 @@ export function logAudit(userId: string | null, username: string | null, action:
|
|
|
762
1866
|
* (e.g. server-internal bookkeeping that doesn't have a tenant
|
|
763
1867
|
* context) can omit `callerNetId`, but every MCP tool path MUST
|
|
764
1868
|
* supply it.
|
|
1869
|
+
*
|
|
1870
|
+
* Returns `{ chained, stoppedReason? }` so the caller can gate its
|
|
1871
|
+
* follow-up SSE push on whether the chain actually wrote (round-2
|
|
1872
|
+
* fix per #275 follow-up, 通信牛 catch): even when the WRITE was
|
|
1873
|
+
* refused, callers were still running `SELECT parent.from_name;
|
|
1874
|
+
* pushEvent(parent.from_name, { parent_task_id }, callerNetId)` —
|
|
1875
|
+
* leaking the foreign parent's task_id into the caller's network
|
|
1876
|
+
* via the SSE payload. Now the caller checks `result.chained` before
|
|
1877
|
+
* pushing the chained_reply event.
|
|
765
1878
|
*/
|
|
1879
|
+
export type ChainReplyResult = {
|
|
1880
|
+
/** True iff this call actually wrote into at least one parent row.
|
|
1881
|
+
* When false, callers MUST NOT push SSE events for parent
|
|
1882
|
+
* listeners — the chain would otherwise leak the foreign parent's
|
|
1883
|
+
* identifiers into the caller's network. */
|
|
1884
|
+
chained: boolean;
|
|
1885
|
+
/** Optional reason the chain stopped short. Today the only enforced
|
|
1886
|
+
* reason is `cross_network`; future reasons (max-depth, parent-
|
|
1887
|
+
* archived, etc.) can land here without breaking callers. */
|
|
1888
|
+
stoppedReason?: "cross_network";
|
|
1889
|
+
};
|
|
1890
|
+
|
|
766
1891
|
export function chainReplyToParent(
|
|
767
1892
|
childTaskId: string,
|
|
768
1893
|
replyText: string,
|
|
769
1894
|
replyStatus: "replied" | "failed" | "cancelled" = "replied",
|
|
770
1895
|
maxDepth = 5,
|
|
771
1896
|
callerNetId?: string | null,
|
|
772
|
-
):
|
|
1897
|
+
): ChainReplyResult {
|
|
773
1898
|
let currentChildId: string | null = childTaskId;
|
|
774
1899
|
let currentReply = replyText;
|
|
775
1900
|
let depth = 0;
|
|
1901
|
+
let chained = false;
|
|
776
1902
|
// Normalize so undefined ("don't enforce") stays distinct from
|
|
777
1903
|
// null ("explicit default network"). Caller passes undefined to
|
|
778
1904
|
// opt out of the cross-tenant check; null to require the parent
|
|
@@ -788,12 +1914,12 @@ export function chainReplyToParent(
|
|
|
788
1914
|
"SELECT parent_task_id, to_name, from_name, content FROM tasks WHERE task_id = ?1",
|
|
789
1915
|
currentChildId
|
|
790
1916
|
);
|
|
791
|
-
if (!child?.parent_task_id) return;
|
|
1917
|
+
if (!child?.parent_task_id) return { chained };
|
|
792
1918
|
const parent: ParentRow | null = db.get<ParentRow>(
|
|
793
1919
|
"SELECT task_id, from_name, to_name, status, result, network_id, parent_task_id FROM tasks WHERE task_id = ?1",
|
|
794
1920
|
child.parent_task_id
|
|
795
1921
|
);
|
|
796
|
-
if (!parent) return;
|
|
1922
|
+
if (!parent) return { chained };
|
|
797
1923
|
|
|
798
1924
|
// round5 F2 fix: refuse to write into a parent that lives in a
|
|
799
1925
|
// different network than the caller. Stops chain-reply being a
|
|
@@ -803,7 +1929,7 @@ export function chainReplyToParent(
|
|
|
803
1929
|
const parentNet = parent.network_id ?? null;
|
|
804
1930
|
if (parentNet !== callerNorm) {
|
|
805
1931
|
console.log(`[commhub] 🚫 chainReplyToParent cross-network blocked: parent=${parent.task_id.slice(0, 8)} parent-net=${parentNet ?? "null"} caller-net=${callerNorm ?? "null"}`);
|
|
806
|
-
return;
|
|
1932
|
+
return { chained, stoppedReason: "cross_network" };
|
|
807
1933
|
}
|
|
808
1934
|
}
|
|
809
1935
|
|
|
@@ -811,51 +1937,151 @@ export function chainReplyToParent(
|
|
|
811
1937
|
const marker = `\n\n[via ${childAlias} 子任务结果]\n${currentReply}`;
|
|
812
1938
|
const newResult = parent.result ? parent.result + marker : `[via ${childAlias} 子任务结果]\n${currentReply}`;
|
|
813
1939
|
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
);
|
|
823
|
-
logTaskEvent(parent.task_id, parent.status, replyStatus, "auto-chain", `from ${childAlias}`);
|
|
824
|
-
} else {
|
|
825
|
-
db.run(
|
|
826
|
-
"UPDATE tasks SET result = ?1, completed_at = datetime('now') WHERE task_id = ?2",
|
|
827
|
-
[newResult.slice(0, 8000), parent.task_id]
|
|
828
|
-
);
|
|
829
|
-
logTaskEvent(parent.task_id, parent.status, parent.status, "auto-chain-append", `from ${childAlias}`);
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
if (parent.from_name && parent.from_name !== "hub" && parent.from_name !== "api") {
|
|
833
|
-
try {
|
|
834
|
-
const notifyId = `chain_${crypto.randomUUID().replace(/-/g, "").slice(0, 16)}`;
|
|
835
|
-
const notifyNode = db.get<{ node_id: string | null }>(
|
|
836
|
-
"SELECT node_id FROM sessions WHERE alias = ?1 AND COALESCE(network_id, 'default') = COALESCE(?2, 'default') ORDER BY updated_at DESC LIMIT 1",
|
|
837
|
-
[parent.from_name, parent.network_id ?? null]
|
|
1940
|
+
db.transaction(() => {
|
|
1941
|
+
// Bump parent status to replied if still open. The task transition and
|
|
1942
|
+
// its scheduler-run mirror share this transaction, so a crash cannot
|
|
1943
|
+
// leave one terminal while the other remains delivered.
|
|
1944
|
+
if (parent.status === "delivered" || parent.status === "acked" || parent.status === "running" || parent.status === "created") {
|
|
1945
|
+
db.run(
|
|
1946
|
+
"UPDATE tasks SET status = ?1, result = ?2, completed_at = datetime('now') WHERE task_id = ?3",
|
|
1947
|
+
[replyStatus, newResult.slice(0, 8000), parent.task_id]
|
|
838
1948
|
);
|
|
1949
|
+
syncScheduledRunForTask(parent.task_id, parent.network_id);
|
|
1950
|
+
logTaskEvent(parent.task_id, parent.status, replyStatus, "auto-chain", `from ${childAlias}`);
|
|
1951
|
+
} else {
|
|
839
1952
|
db.run(
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
[notifyId, parent.from_name, notifyNode?.node_id ?? null, `[${childAlias} 子任务完成]\n${currentReply.slice(0, 4000)}`, parent.to_name, parent.task_id, parent.network_id ?? null]
|
|
1953
|
+
"UPDATE tasks SET result = ?1, completed_at = datetime('now') WHERE task_id = ?2",
|
|
1954
|
+
[newResult.slice(0, 8000), parent.task_id]
|
|
843
1955
|
);
|
|
844
|
-
|
|
845
|
-
|
|
1956
|
+
logTaskEvent(parent.task_id, parent.status, parent.status, "auto-chain-append", `from ${childAlias}`);
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
if (parent.from_name && parent.from_name !== "hub" && parent.from_name !== "api") {
|
|
1960
|
+
try {
|
|
1961
|
+
const notifyId = `chain_${crypto.randomUUID().replace(/-/g, "").slice(0, 16)}`;
|
|
1962
|
+
const notifyNode = db.get<{ node_id: string | null }>(
|
|
1963
|
+
"SELECT node_id FROM sessions WHERE alias = ?1 AND COALESCE(network_id, 'default') = COALESCE(?2, 'default') ORDER BY updated_at DESC LIMIT 1",
|
|
1964
|
+
[parent.from_name, parent.network_id ?? null]
|
|
1965
|
+
);
|
|
1966
|
+
db.run(
|
|
1967
|
+
`INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, in_reply_to, requires_response, network_id)
|
|
1968
|
+
VALUES (?1, ?2, ?3, 'reply', 'normal', ?4, ?5, ?6, 'none', ?7)`,
|
|
1969
|
+
[notifyId, parent.from_name, notifyNode?.node_id ?? null, `[${childAlias} 子任务完成]\n${currentReply.slice(0, 4000)}`, parent.to_name, parent.task_id, parent.network_id ?? null]
|
|
1970
|
+
);
|
|
1971
|
+
} catch {}
|
|
1972
|
+
}
|
|
1973
|
+
});
|
|
1974
|
+
|
|
1975
|
+
// This iteration actually wrote a parent row (and possibly an
|
|
1976
|
+
// inbox notification). Record that so the caller can gate its
|
|
1977
|
+
// SSE push on a real chain.
|
|
1978
|
+
chained = true;
|
|
846
1979
|
|
|
847
1980
|
// Recurse up the chain.
|
|
848
1981
|
currentChildId = parent.task_id;
|
|
849
1982
|
currentReply = newResult;
|
|
850
1983
|
}
|
|
1984
|
+
return { chained };
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
const SCHEDULED_RUN_TERMINAL_STATUSES = new Set(["replied", "failed", "cancelled", "expired"]);
|
|
1988
|
+
|
|
1989
|
+
type ScheduledTaskLifecycleRow = {
|
|
1990
|
+
task_id: string;
|
|
1991
|
+
network_id: string | null;
|
|
1992
|
+
status: string;
|
|
1993
|
+
completed_at: string | null;
|
|
1994
|
+
};
|
|
1995
|
+
|
|
1996
|
+
type ScheduledTaskBinding = { run_id: string; schedule_id: string };
|
|
1997
|
+
|
|
1998
|
+
/**
|
|
1999
|
+
* Mirror an exact scheduler-created task's lifecycle into its run row.
|
|
2000
|
+
*
|
|
2001
|
+
* The status is always read back from `tasks`; callers cannot supply one.
|
|
2002
|
+
* The scheduler's storage binding is the four-part tuple
|
|
2003
|
+
* (run_id, schedule_id, task_id, network_id). It is read from
|
|
2004
|
+
* `scheduled_task_runs`, not caller-controlled task metadata (which may also
|
|
2005
|
+
* legitimately be replaced by reply attachment metadata).
|
|
2006
|
+
*
|
|
2007
|
+
* Terminal states close the run. `retry_task` calls this after resetting the
|
|
2008
|
+
* same logical task_id to delivered, which deliberately reopens that run so
|
|
2009
|
+
* the history reflects the current retry rather than the first failed attempt.
|
|
2010
|
+
*/
|
|
2011
|
+
export function syncScheduledRunForTask(taskId: string, expectedNetworkId?: string | null): { matched: boolean; status?: string } {
|
|
2012
|
+
const task = db.get<ScheduledTaskLifecycleRow>(
|
|
2013
|
+
`SELECT task_id, network_id, status, completed_at
|
|
2014
|
+
FROM tasks
|
|
2015
|
+
WHERE task_id = ?1
|
|
2016
|
+
AND (CAST(?2 AS TEXT) IS NULL OR network_id = ?2)`,
|
|
2017
|
+
taskId,
|
|
2018
|
+
expectedNetworkId ?? null,
|
|
2019
|
+
);
|
|
2020
|
+
if (!task?.network_id) return { matched: false };
|
|
2021
|
+
const bindings = db.all<ScheduledTaskBinding>(
|
|
2022
|
+
`SELECT run_id, schedule_id FROM scheduled_task_runs
|
|
2023
|
+
WHERE task_id = ?1 AND network_id = ?2`,
|
|
2024
|
+
task.task_id,
|
|
2025
|
+
task.network_id,
|
|
2026
|
+
);
|
|
2027
|
+
// One task belongs to at most one scheduled occurrence. Fail closed if a
|
|
2028
|
+
// corrupt/legacy database violates that invariant rather than fanning one
|
|
2029
|
+
// lifecycle transition into multiple runs.
|
|
2030
|
+
if (bindings.length !== 1) return { matched: false };
|
|
2031
|
+
const binding = bindings[0];
|
|
2032
|
+
|
|
2033
|
+
if (SCHEDULED_RUN_TERMINAL_STATUSES.has(task.status)) {
|
|
2034
|
+
const errorCode = task.status === "replied" ? null : `task_${task.status}`;
|
|
2035
|
+
const updated = db.run(
|
|
2036
|
+
`UPDATE scheduled_task_runs
|
|
2037
|
+
SET status = ?1,
|
|
2038
|
+
error_code = ?2,
|
|
2039
|
+
error_message = NULL,
|
|
2040
|
+
completed_at = COALESCE(?3, datetime('now'))
|
|
2041
|
+
WHERE run_id = ?4 AND task_id = ?5 AND network_id = ?6 AND schedule_id = ?7`,
|
|
2042
|
+
[task.status, errorCode, task.completed_at, binding.run_id, task.task_id, task.network_id, binding.schedule_id],
|
|
2043
|
+
);
|
|
2044
|
+
return { matched: updated.changes === 1, status: task.status };
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
if (task.status === "delivered") {
|
|
2048
|
+
const updated = db.run(
|
|
2049
|
+
`UPDATE scheduled_task_runs
|
|
2050
|
+
SET status = 'delivered', error_code = NULL, error_message = NULL, completed_at = NULL
|
|
2051
|
+
WHERE run_id = ?1 AND task_id = ?2 AND network_id = ?3 AND schedule_id = ?4`,
|
|
2052
|
+
[binding.run_id, task.task_id, task.network_id, binding.schedule_id],
|
|
2053
|
+
);
|
|
2054
|
+
return { matched: updated.changes === 1, status: task.status };
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
return { matched: false };
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
function taskEventTypeForStatus(toStatus: string): string {
|
|
2061
|
+
switch (toStatus) {
|
|
2062
|
+
case "delivered": return "task.send.delivered";
|
|
2063
|
+
case "acked": return "task.ack";
|
|
2064
|
+
case "running": return "task.started";
|
|
2065
|
+
case "replied": return "task.replied";
|
|
2066
|
+
case "expired": return "task.expired";
|
|
2067
|
+
case "failed":
|
|
2068
|
+
case "cancelled": return "task.failed";
|
|
2069
|
+
default: return `task.status.${toStatus}`;
|
|
2070
|
+
}
|
|
851
2071
|
}
|
|
852
2072
|
|
|
853
2073
|
export function logTaskEvent(taskId: string, fromStatus: string | null, toStatus: string, actor: string, detail?: string) {
|
|
854
2074
|
try {
|
|
855
2075
|
db.run(
|
|
856
|
-
`INSERT INTO task_events (task_id, from_status, to_status, actor, detail, network_id)
|
|
857
|
-
VALUES (?1, ?2, ?3, ?4, ?5, (SELECT network_id FROM tasks WHERE task_id = ?1))`,
|
|
858
|
-
[taskId, fromStatus, toStatus, actor, detail ?? null]
|
|
2076
|
+
`INSERT INTO task_events (task_id, from_status, to_status, event_type, actor, detail, network_id)
|
|
2077
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, (SELECT network_id FROM tasks WHERE task_id = ?1))`,
|
|
2078
|
+
[taskId, fromStatus, toStatus, taskEventTypeForStatus(toStatus), actor, detail ?? null]
|
|
859
2079
|
);
|
|
860
2080
|
} catch {}
|
|
861
2081
|
}
|
|
2082
|
+
|
|
2083
|
+
// RFC-039 F3: queries break created_at ties with SQLite's implicit rowid
|
|
2084
|
+
// (insertion order). PostgreSQL has none, so give these tables a real one.
|
|
2085
|
+
if (db.dialect === "postgres") {
|
|
2086
|
+
for (const table of ["nodes", "user_inbox"]) db.exec(`ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS rowid BIGSERIAL`);
|
|
2087
|
+
}
|