@sleep2agi/commhub-server 0.9.0-preview.7 → 0.9.0-preview.71

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.
Files changed (170) hide show
  1. package/README.md +130 -10
  2. package/bin/commhub.ts +10 -2
  3. package/package.json +5 -5
  4. package/src/ack-create-request-transport.test.ts +159 -0
  5. package/src/ack-create-request.test.ts +331 -0
  6. package/src/admin-delete-network-http.test.ts +177 -0
  7. package/src/admin-networks-http.test.ts +88 -0
  8. package/src/agent-access.ts +461 -0
  9. package/src/agent-acl-groups-http.test.ts +247 -0
  10. package/src/agent-acl-http.test.ts +510 -0
  11. package/src/agent-acl-slice3-http.test.ts +267 -0
  12. package/src/agent-groups-perf.test.ts +167 -0
  13. package/src/alias-filter.test.ts +72 -0
  14. package/src/alias-filter.ts +32 -0
  15. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  16. package/src/api-host-supervisors-fallback.test.ts +271 -0
  17. package/src/api-nodes-shape.test.ts +26 -10
  18. package/src/auth-sessions-http.test.ts +250 -0
  19. package/src/auth-validate.test.ts +5 -1
  20. package/src/auth.ts +423 -29
  21. package/src/avatar-validate.test.ts +163 -0
  22. package/src/avatar-validate.ts +103 -0
  23. package/src/blocked-exit-on-reply.test.ts +122 -0
  24. package/src/boot-side-effect.test.ts +124 -0
  25. package/src/config-apply-sec1.test.ts +107 -0
  26. package/src/config-apply-validate.test.ts +92 -0
  27. package/src/config-apply-validate.ts +81 -3
  28. package/src/create-node-blocked-gate.test.ts +356 -0
  29. package/src/create-node-tool-schema.test.ts +39 -0
  30. package/src/create-node-validate.test.ts +89 -1
  31. package/src/create-node-validate.ts +89 -7
  32. package/src/create-node-workdir.test.ts +255 -0
  33. package/src/create-node.ts +51 -1
  34. package/src/dashboard-slash-routing-http.test.ts +107 -0
  35. package/src/db-adapter-guard.test.ts +85 -0
  36. package/src/db-adapter-translate.test.ts +112 -0
  37. package/src/db-adapter.ts +246 -83
  38. package/src/db-timestamp.test.ts +38 -0
  39. package/src/db-timestamp.ts +15 -0
  40. package/src/db.ts +799 -33
  41. package/src/delivered-without-push.test.ts +140 -0
  42. package/src/desktop-message-seam.test.ts +150 -0
  43. package/src/desktop-message.test.ts +368 -0
  44. package/src/ext-token-shared.test.ts +60 -0
  45. package/src/ext-validation-regression.test.ts +131 -0
  46. package/src/external-schedule-edits-http.test.ts +321 -0
  47. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  48. package/src/external-schedule-edits.ts +327 -0
  49. package/src/file-download-authz-dev-open.test.ts +135 -0
  50. package/src/file-download-authz.test.ts +404 -0
  51. package/src/file-download-date-bucket.test.ts +436 -0
  52. package/src/file-network-scope-dev-open.test.ts +97 -0
  53. package/src/file-network-scope.test.ts +732 -0
  54. package/src/health-redaction.test.ts +642 -0
  55. package/src/http-gzip.test.ts +73 -0
  56. package/src/http-gzip.ts +60 -0
  57. package/src/hub-timestamp.test.ts +38 -0
  58. package/src/hub-timestamp.ts +27 -0
  59. package/src/human-dm.ts +149 -0
  60. package/src/inbox-count-sse.test.ts +310 -0
  61. package/src/inbox-count.ts +29 -0
  62. package/src/index.ts +15 -2331
  63. package/src/lifecycle-guard.ts +50 -0
  64. package/src/mcp-write-network-resolution.test.ts +429 -0
  65. package/src/messages-ack-by-agent-http.test.ts +142 -0
  66. package/src/network-name-http.test.ts +75 -0
  67. package/src/network-scope.ts +224 -0
  68. package/src/node-attrs-validate.ts +88 -0
  69. package/src/node-attrs.test.ts +217 -0
  70. package/src/node-create-request-status-http.test.ts +92 -0
  71. package/src/node-files-transport.test.ts +344 -0
  72. package/src/node-id-alias.test.ts +188 -0
  73. package/src/node-lifecycle-controllable.test.ts +64 -0
  74. package/src/node-lifecycle-controllable.ts +51 -0
  75. package/src/node-logs-transport.test.ts +263 -0
  76. package/src/node-request-retention.test.ts +281 -0
  77. package/src/node-request-retention.ts +120 -0
  78. package/src/node-skills-transport.test.ts +209 -0
  79. package/src/node-token-privilege-http.test.ts +149 -0
  80. package/src/observer-avatar-http.test.ts +429 -0
  81. package/src/observer-push.test.ts +252 -0
  82. package/src/package-version-consistency.test.ts +25 -0
  83. package/src/peer-reply-atomic.test.ts +455 -0
  84. package/src/peer-reply-capability.test.ts +70 -0
  85. package/src/pg-worker.ts +87 -0
  86. package/src/push.ts +204 -7
  87. package/src/redact-tokens.test.ts +65 -0
  88. package/src/redact-tokens.ts +40 -0
  89. package/src/rename.ts +7 -2
  90. package/src/reply-target-derivation.test.ts +96 -0
  91. package/src/report-status-host-schema.test.ts +211 -0
  92. package/src/report-status-resume-id-handover.test.ts +150 -0
  93. package/src/require-explicit-test-db.ts +9 -0
  94. package/src/requirement-tags.ts +16 -0
  95. package/src/requirements-etag-http.test.ts +91 -0
  96. package/src/requirements-http.test.ts +708 -0
  97. package/src/requirements-mcp.test.ts +142 -0
  98. package/src/requirements-migrate.ts +83 -0
  99. package/src/requirements-priority-lowest.test.ts +114 -0
  100. package/src/requirements-start-date.test.ts +125 -0
  101. package/src/requirements-upgrade-e2e.test.ts +126 -0
  102. package/src/requirements-upgrade-migration.test.ts +85 -0
  103. package/src/requirements.ts +899 -0
  104. package/src/resolve-port.test.ts +57 -0
  105. package/src/resolve-port.ts +44 -0
  106. package/src/response-charset.test.ts +130 -0
  107. package/src/rest-explicit-columns-http.test.ts +216 -0
  108. package/src/rest-identity.test.ts +129 -0
  109. package/src/rest-identity.ts +100 -0
  110. package/src/rest-projections.ts +68 -0
  111. package/src/rest-write-network-resolution.test.ts +133 -0
  112. package/src/restricted-files.ts +73 -0
  113. package/src/retention.ts +10 -1
  114. package/src/rules-file-duplicate-alias.test.ts +234 -0
  115. package/src/rules-file-session-target.test.ts +207 -0
  116. package/src/rules-file-transport.test.ts +237 -0
  117. package/src/runtime-label.test.ts +22 -0
  118. package/src/runtime-label.ts +21 -0
  119. package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
  120. package/src/scheduled-run-terminal.test.ts +268 -0
  121. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  122. package/src/scheduled-tasks-http.test.ts +625 -0
  123. package/src/scheduled-tasks.ts +597 -0
  124. package/src/send-dedup-request-id.test.ts +229 -0
  125. package/src/send-reply-agent-warning.test.ts +202 -0
  126. package/src/send-reply-alias-optional.test.ts +131 -0
  127. package/src/send-reply-attachments.test.ts +458 -0
  128. package/src/send_dedup.test.ts +54 -0
  129. package/src/send_dedup.ts +52 -6
  130. package/src/serve-error.test.ts +38 -0
  131. package/src/serve-error.ts +27 -0
  132. package/src/server.ts +4385 -0
  133. package/src/shared/external-schedule-contract.ts +60 -0
  134. package/src/side-thread-command-transport.test.ts +302 -0
  135. package/src/side-thread-command-transport.ts +318 -0
  136. package/src/side-thread-http-integration.test.ts +233 -0
  137. package/src/side-thread-http.ts +504 -0
  138. package/src/side-thread-production.test.ts +72 -0
  139. package/src/side-thread-production.ts +118 -0
  140. package/src/side-thread.test.ts +1067 -0
  141. package/src/side-thread.ts +2495 -0
  142. package/src/skillhub-http.test.ts +92 -0
  143. package/src/start-node.test.ts +109 -0
  144. package/src/status-alias-filter-http.test.ts +106 -0
  145. package/src/stop-delete-node.test.ts +1164 -0
  146. package/src/task-auth-origin-http.test.ts +91 -0
  147. package/src/task-auth-origin.test.ts +24 -0
  148. package/src/task-auth-origin.ts +7 -0
  149. package/src/task-consumption.test.ts +482 -0
  150. package/src/task-diagnostic-http.test.ts +149 -0
  151. package/src/task-diagnostic.test.ts +115 -0
  152. package/src/task-diagnostic.ts +118 -0
  153. package/src/task-idempotency-mcp.test.ts +142 -0
  154. package/src/task-idempotency.test.ts +60 -0
  155. package/src/task-idempotency.ts +75 -0
  156. package/src/task-lifecycle-watcher.test.ts +204 -0
  157. package/src/task-lifecycle-watcher.ts +49 -0
  158. package/src/task-network-resolution-http.test.ts +141 -0
  159. package/src/tasks-pagination-http.test.ts +141 -0
  160. package/src/tools-list-json-schema.test.ts +66 -0
  161. package/src/tools.ts +3446 -257
  162. package/src/update-provider.test.ts +65 -0
  163. package/src/upload-early-reject-guard.test.ts +155 -0
  164. package/src/uploads-http.test.ts +97 -15
  165. package/src/uploads.test.ts +66 -0
  166. package/src/uploads.ts +130 -5
  167. package/src/user-inbox-by-agent-http.test.ts +161 -0
  168. package/src/user-inbox-netid-notnull.test.ts +122 -0
  169. package/src/user-inbox-read-http.test.ts +223 -0
  170. package/src/user-inbox-schema.test.ts +54 -0
package/src/db-adapter.ts CHANGED
@@ -2,12 +2,14 @@
2
2
  * Database Adapter — supports SQLite and PostgreSQL
3
3
  *
4
4
  * SQLite adapter: wraps bun:sqlite (sync)
5
- * PostgreSQL adapter: wraps pg Pool (async, bridged to sync interface via blocking)
5
+ * PostgreSQL adapter: one Bun.SQL connection in a worker thread, bridged to the
6
+ * sync interface with Atomics.wait (pg-worker.ts)
6
7
  *
7
8
  * Key design: callers write SQLite-style SQL. PgAdapter auto-translates:
8
9
  * - ?1, ?2 → $1, $2
9
- * - datetime('now') → NOW()
10
- * - datetime('now', '+N seconds') → NOW() + INTERVAL 'N seconds'
10
+ * - datetime('now'[, offset]) → to_char(... AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS')
11
+ * (timestamps stay TEXT on both backends, RFC-039 §4.1)
12
+ * - BLOB → BYTEA
11
13
  * - INTEGER PRIMARY KEY AUTOINCREMENT → SERIAL PRIMARY KEY
12
14
  * - ON CONFLICT(col) DO UPDATE SET → ON CONFLICT(col) DO UPDATE SET (same syntax)
13
15
  */
@@ -39,14 +41,32 @@ export interface DbAdapter {
39
41
 
40
42
  /** Dialect identifier */
41
43
  readonly dialect: "sqlite" | "postgres";
44
+
45
+ /** True when transaction() is all-or-nothing on one connection. */
46
+ readonly atomicTransactions: boolean;
47
+
48
+ /**
49
+ * Why features that need atomic transactions (scheduler, runtime evidence,
50
+ * side-thread command outbox) must stay off on this adapter, or null when
51
+ * they may run. Gates refuse on anything but null, so an adapter that does
52
+ * not implement this also refuses.
53
+ */
54
+ readonly transactionalFeaturesRefusal: string | null;
42
55
  }
43
56
 
57
+ /** RFC-039: opt-in for PostgreSQL features whose PG tests do not exist yet (S4). */
58
+ export const PG_EXPERIMENTAL_ENV = "COMMHUB_PG_EXPERIMENTAL";
59
+
44
60
  // ════════════════════════════════════════════
45
61
  // SQLite Adapter (bun:sqlite, sync)
46
62
  // ════════════════════════════════════════════
47
63
 
48
64
  export class SQLiteAdapter implements DbAdapter {
49
65
  readonly dialect = "sqlite" as const;
66
+ readonly atomicTransactions = true;
67
+ get transactionalFeaturesRefusal(): string | null {
68
+ return this.atomicTransactions ? null : "transactions on this adapter are not atomic";
69
+ }
50
70
  constructor(private readonly rawDb: Database) {}
51
71
 
52
72
  run(sql: string, params?: any[]): QueryResult {
@@ -78,6 +98,15 @@ export class SQLiteAdapter implements DbAdapter {
78
98
  // PostgreSQL Adapter (pg Pool, sync bridge)
79
99
  // ════════════════════════════════════════════
80
100
 
101
+ /**
102
+ * PostgreSQL expression for SQLite's `datetime(...)` text: UTC, `YYYY-MM-DD HH:MM:SS`.
103
+ * Timestamps stay TEXT on both backends (RFC-039 §4.1), so a column written by
104
+ * `datetime('now')` compares, sorts and serialises the same way on each.
105
+ */
106
+ function pgUtcText(expr: string): string {
107
+ return `to_char((${expr}) AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS')`;
108
+ }
109
+
81
110
  /**
82
111
  * Translate SQLite-style SQL to PostgreSQL.
83
112
  * Called on every query — must be fast (simple regex, no parsing).
@@ -85,18 +114,17 @@ export class SQLiteAdapter implements DbAdapter {
85
114
  export function sqliteToPostgres(sql: string): string {
86
115
  let s = sql;
87
116
  // ── datetime translations (before ?N→$N to handle datetime('now', ?N)) ──
88
- // datetime('now', ?N) → NOW() + $N::INTERVAL (param contains "+3600 seconds")
117
+ // datetime('now', ?N) → UTC text of NOW() + $N (param contains "+3600 seconds").
118
+ // The CAST gives PG a type for a parameter it could not otherwise infer.
89
119
  s = s.replace(/datetime\s*\(\s*'now'\s*,\s*\?(\d+)\s*\)/gi, (_, n) => {
90
- return `NOW() + $${n}::INTERVAL`;
120
+ return pgUtcText(`NOW() + CAST($${n} AS TEXT)::INTERVAL`);
91
121
  });
92
- // datetime('now', '+N seconds') → NOW() + INTERVAL 'N seconds'
122
+ // datetime('now', '+N seconds') → UTC text of NOW() + INTERVAL 'N seconds'
93
123
  s = s.replace(/datetime\s*\(\s*'now'\s*,\s*'([^']+)'\s*\)/gi, (_, offset) => {
94
- return `NOW() + INTERVAL '${offset.replace(/^\+/, "")}'`;
124
+ return pgUtcText(`NOW() + INTERVAL '${offset.replace(/^\+/, "")}'`);
95
125
  });
96
- // datetime('now') → NOW()
97
- s = s.replace(/datetime\s*\(\s*'now'\s*\)/gi, "NOW()");
98
- // TEXT NOT NULL DEFAULT (datetime('now')) → TIMESTAMP NOT NULL DEFAULT NOW()
99
- s = s.replace(/TEXT\s+NOT\s+NULL\s+DEFAULT\s+\(NOW\(\)\)/gi, "TIMESTAMP NOT NULL DEFAULT NOW()");
126
+ // datetime('now') → UTC text of NOW()
127
+ s = s.replace(/datetime\s*\(\s*'now'\s*\)/gi, pgUtcText("NOW()"));
100
128
  // ── Parameter placeholders ──
101
129
  // ?1, ?2 → $1, $2 (positional params)
102
130
  s = s.replace(/\?(\d+)/g, (_, n) => `$${n}`);
@@ -106,67 +134,148 @@ export function sqliteToPostgres(sql: string): string {
106
134
  // ── DDL translations ──
107
135
  // INTEGER PRIMARY KEY AUTOINCREMENT → SERIAL PRIMARY KEY
108
136
  s = s.replace(/INTEGER\s+PRIMARY\s+KEY\s+AUTOINCREMENT/gi, "SERIAL PRIMARY KEY");
137
+ // BLOB → BYTEA
138
+ s = s.replace(/\bBLOB\b/gi, "BYTEA");
109
139
  return s;
110
140
  }
111
141
 
112
142
  /**
113
- * PostgreSQL adapter using a synchronous subprocess bridge.
143
+ * Split a multi-statement script on top-level `;`.
114
144
  *
115
- * TODO(P0-4): this is not transaction-safe. querySync currently starts a
116
- * fresh `node -e` process for every statement, so BEGIN/COMMIT/ROLLBACK do
117
- * not share one backend connection. PostgreSQL support is demoted behind
118
- * SQLite for now; before advertising it as production-ready, replace this
119
- * bridge with a long-lived worker or make DbAdapter async and use pg directly.
145
+ * A bare `split(";")` cut through SQL comments (db.ts has `;` inside `--`
146
+ * comments) and through plpgsql `$$ … $$` bodies, so the Hub died on its
147
+ * first schema block on PostgreSQL. This skips `--` and `/* *\/` comments
148
+ * (dropping them) and keeps quoted strings, quoted identifiers and
149
+ * dollar-quoted bodies intact.
150
+ */
151
+ export function splitSqlStatements(sql: string): string[] {
152
+ const out: string[] = [];
153
+ let cur = "";
154
+ let i = 0;
155
+ const n = sql.length;
156
+ while (i < n) {
157
+ const c = sql[i];
158
+ if (c === "-" && sql[i + 1] === "-") {
159
+ while (i < n && sql[i] !== "\n") i++;
160
+ continue;
161
+ }
162
+ if (c === "/" && sql[i + 1] === "*") {
163
+ const close = sql.indexOf("*/", i + 2);
164
+ i = close < 0 ? n : close + 2;
165
+ cur += " ";
166
+ continue;
167
+ }
168
+ if (c === "'" || c === '"') {
169
+ let j = i + 1;
170
+ while (j < n) {
171
+ if (sql[j] === c) {
172
+ if (sql[j + 1] === c) { j += 2; continue; }
173
+ break;
174
+ }
175
+ j++;
176
+ }
177
+ cur += sql.slice(i, j + 1);
178
+ i = j + 1;
179
+ continue;
180
+ }
181
+ if (c === "$") {
182
+ const tag = /^\$([A-Za-z_][A-Za-z0-9_]*)?\$/.exec(sql.slice(i));
183
+ if (tag) {
184
+ const close = sql.indexOf(tag[0], i + tag[0].length);
185
+ const stop = close < 0 ? n : close + tag[0].length;
186
+ cur += sql.slice(i, stop);
187
+ i = stop;
188
+ continue;
189
+ }
190
+ }
191
+ if (c === ";") {
192
+ if (cur.trim()) out.push(cur.trim());
193
+ cur = "";
194
+ i++;
195
+ continue;
196
+ }
197
+ cur += c;
198
+ i++;
199
+ }
200
+ if (cur.trim()) out.push(cur.trim());
201
+ return out;
202
+ }
203
+
204
+ /**
205
+ * PostgreSQL adapter: synchronous facade over one long-lived worker thread
206
+ * (RFC-039 S2b).
207
+ *
208
+ * The worker (pg-worker.ts) holds a single reserved Bun.SQL connection. Each
209
+ * call posts one request and blocks on Atomics.wait until the worker answers,
210
+ * so the DbAdapter interface stays synchronous — the same contract bun:sqlite
211
+ * gives the rest of the Hub, including "nothing else runs while a transaction
212
+ * is open". Because every statement uses the same backend connection,
213
+ * BEGIN/COMMIT/ROLLBACK are real transactions.
214
+ *
215
+ * SQLite semantics kept on purpose:
216
+ * - a failed statement inside a transaction does not poison the transaction
217
+ * (each statement inside one runs under a savepoint);
218
+ * - nested transaction() calls become savepoints;
219
+ * - booleans bind as 1/0 and undefined as NULL.
120
220
  */
121
221
  export class PgAdapter implements DbAdapter {
122
222
  readonly dialect = "postgres" as const;
123
- private connString: string;
223
+ readonly atomicTransactions = true;
224
+ /** Closed by default on PG even with real transactions: explicit opt-in only. */
225
+ get transactionalFeaturesRefusal(): string | null {
226
+ if (!this.atomicTransactions) return "transactions on this adapter are not atomic";
227
+ if (process.env[PG_EXPERIMENTAL_ENV] === "1") return null;
228
+ return `PostgreSQL support is experimental; set ${PG_EXPERIMENTAL_ENV}=1 to enable this feature on it (RFC-039)`;
229
+ }
230
+ private readonly port: MessagePort;
231
+ private readonly flag: Int32Array;
232
+ private readonly worker: import("node:worker_threads").Worker;
233
+ private readonly waitMs: number;
234
+ private txDepth = 0;
124
235
 
125
236
  constructor(connectionString: string) {
126
- this.connString = connectionString;
127
- // Validate pg is available
128
- try { require("pg"); } catch (e) {
129
- throw new Error(
130
- "PostgreSQL support requires 'pg' package. Install with: bun add pg\n" +
131
- ` Original error: ${(e as Error).message}`
132
- );
133
- }
237
+ const { Worker, MessageChannel } = require("node:worker_threads") as typeof import("node:worker_threads");
238
+ const statementTimeoutMs = Number(process.env.COMMHUB_PG_STATEMENT_TIMEOUT_MS || 30_000);
239
+ // The main thread waits a little longer than PG itself will run a statement.
240
+ this.waitMs = statementTimeoutMs > 0 ? statementTimeoutMs + 5_000 : 300_000;
241
+ const { port1, port2 } = new MessageChannel();
242
+ this.port = port1 as unknown as MessagePort;
243
+ this.flag = new Int32Array(new SharedArrayBuffer(4));
244
+ this.worker = new Worker(new URL("./pg-worker.ts", import.meta.url), {
245
+ workerData: { port: port2, flag: this.flag, url: connectionString, statementTimeoutMs },
246
+ transferList: [port2 as any],
247
+ });
248
+ this.worker.unref();
249
+ (port1 as any).unref?.();
134
250
  // Test connection on startup
135
- const test = this.querySync("SELECT 1 as ok");
251
+ const test = this.request("SELECT 1 as ok");
136
252
  if (!test.rows?.[0]?.ok) throw new Error("PostgreSQL connection test failed");
137
253
  console.log("[commhub] PostgreSQL connection verified");
138
254
  }
139
255
 
140
- private querySync(sql: string, params?: any[]): { rows: any[]; rowCount: number } {
141
- const pgSql = sqliteToPostgres(sql);
142
- // Single-query subprocess with pg Pool (connection string from env)
143
- const script = `
144
- const{Pool}=require('pg');
145
- const p=new Pool({connectionString:${JSON.stringify(this.connString)},max:1});
146
- const q=${JSON.stringify(pgSql)};
147
- const v=${JSON.stringify(params || [])};
148
- p.query(q,v).then(r=>{
149
- process.stdout.write(JSON.stringify({rows:r.rows,rowCount:r.rowCount||0}));
150
- p.end();
151
- }).catch(e=>{
152
- process.stderr.write(e.message);
153
- p.end();
154
- process.exit(1);
155
- });
156
- `;
157
- const proc = Bun.spawnSync(["node", "--no-warnings", "-e", script], {
158
- stdout: "pipe", stderr: "pipe",
159
- });
160
- if (proc.exitCode !== 0) {
161
- throw new Error(`PG: ${proc.stderr.toString().trim() || "query failed"}`);
256
+ /** Send one already-translated statement to the worker and block for the answer. */
257
+ private request(pgSql: string, params?: any[]): { rows: any[]; count: number } {
258
+ const { receiveMessageOnPort } = require("node:worker_threads") as typeof import("node:worker_threads");
259
+ const bound = (params ?? []).map(v => v === undefined ? null : typeof v === "boolean" ? (v ? 1 : 0) : v);
260
+ Atomics.store(this.flag, 0, 0);
261
+ (this.port as any).postMessage({ sql: pgSql, params: bound, guard: this.txDepth > 0 });
262
+ if (Atomics.wait(this.flag, 0, 0, this.waitMs) === "timed-out") {
263
+ throw new Error(`PG: no answer from the database worker within ${this.waitMs}ms`);
162
264
  }
163
- return JSON.parse(proc.stdout.toString().trim());
265
+ const reply = receiveMessageOnPort(this.port as any)?.message as any;
266
+ if (!reply) throw new Error("PG: database worker signalled without a reply");
267
+ if (!reply.ok) throw new Error(`PG: ${reply.error}`);
268
+ return reply;
269
+ }
270
+
271
+ private querySync(sql: string, params?: any[]): { rows: any[]; count: number } {
272
+ return this.request(sqliteToPostgres(sql), params);
164
273
  }
165
274
 
166
275
  run(sql: string, params?: any[]): QueryResult {
167
276
  if (sql.trim().toUpperCase().startsWith("PRAGMA")) return { changes: 0 };
168
277
  const result = this.querySync(sql, params);
169
- return { changes: result.rowCount };
278
+ return { changes: result.count };
170
279
  }
171
280
 
172
281
  get<T = any>(sql: string, ...params: any[]): T | null {
@@ -183,9 +292,9 @@ export class PgAdapter implements DbAdapter {
183
292
  if (sql.trim().toUpperCase().startsWith("PRAGMA")) return;
184
293
  const pgSql = sqliteToPostgres(sql);
185
294
  // Split multi-statement DDL (CREATE TABLE; CREATE INDEX; ...)
186
- const stmts = pgSql.split(";").map(s => s.trim()).filter(s => s.length > 0);
295
+ const stmts = splitSqlStatements(pgSql);
187
296
  for (const stmt of stmts) {
188
- try { this.querySync(stmt); } catch (e: any) {
297
+ try { this.request(stmt); } catch (e: any) {
189
298
  // Ignore "already exists" errors for CREATE TABLE/INDEX IF NOT EXISTS
190
299
  if (!/already exists/.test(e.message)) throw e;
191
300
  }
@@ -193,29 +302,60 @@ export class PgAdapter implements DbAdapter {
193
302
  }
194
303
 
195
304
  transaction<T>(fn: () => T): T {
196
- // Not atomic for PostgreSQL until P0-4 is implemented; see class TODO.
197
- this.querySync("BEGIN");
305
+ const depth = this.txDepth;
306
+ const savepoint = `anet_tx_${depth}`;
307
+ // Control statements run unguarded: they are what the guard is made of.
308
+ this.txDepth = 0;
309
+ try { this.request(depth === 0 ? "BEGIN" : `SAVEPOINT ${savepoint}`); } finally { this.txDepth = depth; }
310
+ this.txDepth = depth + 1;
311
+ let result: T;
198
312
  try {
199
- const result = fn();
200
- this.querySync("COMMIT");
201
- return result;
313
+ result = fn();
202
314
  } catch (e) {
203
- try { this.querySync("ROLLBACK"); } catch {}
315
+ this.txDepth = 0;
316
+ try {
317
+ if (depth === 0) this.request("ROLLBACK");
318
+ else { this.request(`ROLLBACK TO SAVEPOINT ${savepoint}`); this.request(`RELEASE SAVEPOINT ${savepoint}`); }
319
+ } catch {}
320
+ this.txDepth = depth;
204
321
  throw e;
205
322
  }
323
+ this.txDepth = 0;
324
+ try { this.request(depth === 0 ? "COMMIT" : `RELEASE SAVEPOINT ${savepoint}`); } finally { this.txDepth = depth; }
325
+ return result;
206
326
  }
207
327
 
208
- close(): void {}
328
+ close(): void {
329
+ this.worker.terminate();
330
+ }
209
331
  }
210
332
 
211
333
  // ════════════════════════════════════════════
212
334
  // Factory
213
335
  // ════════════════════════════════════════════
214
336
 
337
+ export type DbTarget =
338
+ | { kind: "postgres"; url: string }
339
+ | { kind: "sqlite"; path: string };
340
+
215
341
  /**
216
- * Create the appropriate adapter based on environment.
217
- * - DATABASE_URL starts with "postgres://" → PgAdapter
218
- * - Otherwise → SQLiteAdapter with COMMHUB_DB or default path
342
+ * Refuse every inherited DATABASE_URL under the Bun test environment before
343
+ * adapter selection, logging, DNS, or construction. A test that intentionally
344
+ * exercises PostgreSQL needs a separately reviewed isolated harness; accepting
345
+ * a shell/CI DATABASE_URL here could connect a unit test to production.
346
+ */
347
+ export function assertSafeTestDatabaseEnv(env: NodeJS.ProcessEnv = process.env): void {
348
+ if (env.NODE_ENV === "test" && env.DATABASE_URL) {
349
+ throw new Error(
350
+ "[commhub] REFUSING to honor inherited DATABASE_URL under NODE_ENV=test.\n" +
351
+ " Tests must unset DATABASE_URL and set COMMHUB_DB to an isolated path.\n" +
352
+ " This guard is fail-closed and has no opt-in bypass."
353
+ );
354
+ }
355
+ }
356
+
357
+ /**
358
+ * Pure target selection after the inherited-DATABASE_URL safety gate.
219
359
  *
220
360
  * Test-default-prod GUARD: when `NODE_ENV === "test"` (set automatically by
221
361
  * `bun test`) AND `COMMHUB_DB` is unset, we REFUSE to fall through to the
@@ -227,27 +367,20 @@ export class PgAdapter implements DbAdapter {
227
367
  * pre-existing history of test pollution in the same DB). The guard makes
228
368
  * the failure mode loud + actionable instead of silent + destructive.
229
369
  *
230
- * The guard does NOT fire when:
231
- * - COMMHUB_DB is set (the test runner's documented contract); or
232
- * - NODE_ENV is unset (production server) or any non-"test" value.
233
- *
234
- * Operators who run `bun -e` snippets that touch the DB are expected to
235
- * `COMMHUB_DB=/tmp/...` themselves — `bun -e` doesn't set NODE_ENV, so the
236
- * guard can't catch that case. The rule (never read or write the
237
- * production hub database) is documented in CLAUDE.md.
370
+ * The production default path is a capability, not a generic fallback:
371
+ * only canonical server entrypoints set COMMHUB_SERVER=1 before importing
372
+ * the database graph. Tests, scripts, and `bun -e` probes must name an
373
+ * explicit COMMHUB_DB (or a reviewed PostgreSQL DATABASE_URL), so forgetting
374
+ * one fails before mkdir/open/write instead of touching the live Hub.
238
375
  */
239
- export function createAdapter(): DbAdapter {
240
- const dbUrl = process.env.DATABASE_URL;
376
+ function resolveDatabaseTargetAfterGuard(env: NodeJS.ProcessEnv): DbTarget {
377
+ const dbUrl = env.DATABASE_URL;
241
378
  if (dbUrl && (dbUrl.startsWith("postgres://") || dbUrl.startsWith("postgresql://"))) {
242
- console.log("[commhub] database: PostgreSQL");
243
- return new PgAdapter(dbUrl);
379
+ return { kind: "postgres", url: dbUrl };
244
380
  }
245
- // Default: SQLite
246
- const { mkdirSync } = require("fs");
247
- const { dirname } = require("path");
248
381
 
249
382
  // Test-default-prod GUARD (see docblock above).
250
- if (process.env.NODE_ENV === "test" && !process.env.COMMHUB_DB) {
383
+ if (env.NODE_ENV === "test" && !env.COMMHUB_DB) {
251
384
  throw new Error(
252
385
  "[commhub] REFUSING to open the default SQLite database under NODE_ENV=test.\n" +
253
386
  " Tests must explicitly set COMMHUB_DB to a throwaway path so they don't\n" +
@@ -257,10 +390,40 @@ export function createAdapter(): DbAdapter {
257
390
  );
258
391
  }
259
392
 
260
- const dbPath = process.env.COMMHUB_DB || `${process.env.HOME}/.commhub/commhub.db`;
261
- mkdirSync(dirname(dbPath), { recursive: true });
262
- console.log(`[commhub] database: ${dbPath}`);
263
- const rawDb = new Database(dbPath);
393
+ if (!env.COMMHUB_DB && env.COMMHUB_SERVER !== "1") {
394
+ throw new Error(
395
+ "[commhub] REFUSING to open the default production SQLite database from a non-server entrypoint.\n" +
396
+ " Scripts and probes must set an explicit COMMHUB_DB path. The canonical\n" +
397
+ " Hub entrypoints set COMMHUB_SERVER=1 before loading the database."
398
+ );
399
+ }
400
+
401
+ return { kind: "sqlite", path: env.COMMHUB_DB || `${env.HOME}/.commhub/commhub.db` };
402
+ }
403
+
404
+ /** Pure public resolver used by tests without constructing an adapter. */
405
+ export function resolveDatabaseTarget(env: NodeJS.ProcessEnv = process.env): DbTarget {
406
+ assertSafeTestDatabaseEnv(env);
407
+ return resolveDatabaseTargetAfterGuard(env);
408
+ }
409
+
410
+ /**
411
+ * Create the appropriate adapter based on environment.
412
+ * The inherited-DATABASE_URL guard is deliberately the first executable line.
413
+ */
414
+ export function createAdapter(): DbAdapter {
415
+ assertSafeTestDatabaseEnv(process.env);
416
+ const target = resolveDatabaseTargetAfterGuard(process.env);
417
+ if (target.kind === "postgres") {
418
+ console.log("[commhub] database: PostgreSQL");
419
+ return new PgAdapter(target.url);
420
+ }
421
+
422
+ const { mkdirSync } = require("fs");
423
+ const { dirname } = require("path");
424
+ mkdirSync(dirname(target.path), { recursive: true });
425
+ console.log(`[commhub] database: ${target.path}`);
426
+ const rawDb = new Database(target.path);
264
427
  rawDb.exec("PRAGMA journal_mode=WAL");
265
428
  rawDb.exec("PRAGMA busy_timeout=5000");
266
429
  return new SQLiteAdapter(rawDb);
@@ -0,0 +1,38 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { parseDbTimestampMs } from "./db-timestamp.js";
3
+
4
+ const NAIVE = "2026-08-27 11:23:54";
5
+ const EXPECTED = Date.UTC(2026, 7, 27, 11, 23, 54);
6
+
7
+ describe("parseDbTimestampMs", () => {
8
+ // ⚠ 这条在 **TZ=UTC 的环境**下没有分辨力:裸 Date.parse 在 UTC 下恰好正确。
9
+ // CI runner 与容器默认就是 UTC(本仓 Docker 套件亦然),所以缺陷在常规跑法里
10
+ // 恒绿。(更正:Bun 是尊重 TZ 的,不存在"bun test 固定 UTC"——通信评审牛 #1279
11
+ // 独审③ 独立复跑证伪了我最初的说法。)真正稳定见红的门是下面那条强制子进程。
12
+ it("treats a naive SQLite timestamp as UTC", () => {
13
+ expect(parseDbTimestampMs(NAIVE)).toBe(EXPECTED);
14
+ });
15
+
16
+ // 分辨力关键:宿主是 UTC 时,裸 Date.parse 与正确写法结果相同,断言恒绿。
17
+ // 强制子进程 TZ=Asia/Shanghai,让缺陷在任何 CI 时区下都显形。
18
+ it("is unaffected by a non-UTC host timezone (child process TZ=Asia/Shanghai)", () => {
19
+ const r = Bun.spawnSync({
20
+ cmd: ["bun", "-e",
21
+ `const {parseDbTimestampMs} = await import("${import.meta.dir}/db-timestamp.ts");` +
22
+ `console.log(parseDbTimestampMs("${NAIVE}"));`],
23
+ env: { ...process.env, TZ: "Asia/Shanghai" },
24
+ });
25
+ expect(Number(r.stdout.toString().trim())).toBe(EXPECTED);
26
+ });
27
+
28
+ it("keeps explicit-offset timestamps intact", () => {
29
+ expect(parseDbTimestampMs("2026-08-27T11:23:54Z")).toBe(EXPECTED);
30
+ expect(parseDbTimestampMs("2026-08-27T19:23:54+08:00")).toBe(EXPECTED);
31
+ });
32
+
33
+ it("an online window of 5min holds for a heartbeat written 1s ago", () => {
34
+ const now = Date.now();
35
+ const iso = new Date(now - 1000).toISOString().slice(0, 19).replace("T", " ");
36
+ expect(now - parseDbTimestampMs(iso)).toBeLessThanOrEqual(5 * 60_000);
37
+ });
38
+ });
@@ -0,0 +1,15 @@
1
+ /**
2
+ * SQLite 里的时间戳列(sessions.last_seen_at、tasks.created_at 等)存的是
3
+ * **无时区标记的 UTC 串**(`YYYY-MM-DD HH:MM:SS`,由 SQLite `datetime('now')` 写入)。
4
+ *
5
+ * 裸 `Date.parse("2026-08-27 11:23:54")` 会按**宿主机本地时区**解释这个串。
6
+ * 生产机 TZ=CST+0800 时,它比真实 UTC 时刻早 8 小时——所以 `now - parsed`
7
+ * 恒为 +480 分钟,任何「最近 N 秒内算在线」的判据都会恒判离线。
8
+ * 在 TZ=UTC 的机器上两种写法结果相同,缺陷不会显形。
9
+ */
10
+ export function parseDbTimestampMs(value: string): number {
11
+ const s = String(value).trim();
12
+ // 已带时区标记(ISO 的 Z / ±HH:MM)就原样交给 Date
13
+ if (/[Zz]$|[+-]\d{2}:?\d{2}$/.test(s)) return Date.parse(s);
14
+ return Date.parse(s.replace(" ", "T") + "Z");
15
+ }