@sleep2agi/commhub-server 0.9.0-preview.27 → 0.9.0-preview.29
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/bin/commhub.ts +4 -0
- package/package.json +4 -4
- package/src/ack-create-request-transport.test.ts +159 -0
- package/src/ack-create-request.test.ts +5 -5
- package/src/admin-networks-http.test.ts +84 -0
- package/src/api-host-supervisors-fallback.test.ts +1 -6
- package/src/auth.ts +88 -14
- package/src/dashboard-slash-routing-http.test.ts +103 -0
- package/src/db-adapter-guard.test.ts +85 -0
- package/src/db-adapter.ts +64 -23
- package/src/db.ts +279 -32
- package/src/ext-token-shared.test.ts +60 -0
- package/src/ext-validation-regression.test.ts +10 -17
- package/src/external-schedule-edits-http.test.ts +316 -0
- package/src/external-schedule-edits.ts +314 -0
- package/src/file-download-authz-dev-open.test.ts +1 -1
- package/src/file-download-authz.test.ts +21 -1
- package/src/file-download-date-bucket.test.ts +1 -1
- package/src/file-network-scope-dev-open.test.ts +1 -1
- package/src/file-network-scope.test.ts +1 -1
- package/src/health-redaction.test.ts +12 -0
- package/src/index.ts +6 -2
- package/src/network-name-http.test.ts +71 -0
- package/src/network-scope.ts +20 -0
- package/src/node-attrs.test.ts +1 -1
- package/src/peer-reply-atomic.test.ts +455 -0
- package/src/peer-reply-capability.test.ts +70 -0
- package/src/require-explicit-test-db.ts +9 -0
- package/src/rest-explicit-columns-http.test.ts +149 -0
- package/src/rest-identity.test.ts +120 -0
- package/src/rest-identity.ts +93 -0
- package/src/rest-projections.ts +67 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-tasks-http.test.ts +460 -0
- package/src/scheduled-tasks.ts +538 -0
- package/src/send-reply-agent-warning.test.ts +38 -38
- package/src/server.ts +315 -60
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/skillhub-http.test.ts +88 -0
- package/src/task-auth-origin-http.test.ts +87 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +329 -0
- package/src/task-diagnostic-http.test.ts +145 -0
- package/src/task-diagnostic.test.ts +82 -0
- package/src/task-diagnostic.ts +89 -0
- package/src/task-idempotency-mcp.test.ts +137 -0
- package/src/task-idempotency.test.ts +60 -0
- package/src/task-idempotency.ts +75 -0
- package/src/task-lifecycle-watcher.test.ts +160 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +137 -0
- package/src/tasks-pagination-http.test.ts +137 -0
- package/src/tools.ts +635 -107
- package/src/uploads-http.test.ts +1 -3
- package/src/uploads.ts +14 -3
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
assertSafeTestDatabaseEnv,
|
|
4
|
+
resolveDatabaseTarget,
|
|
5
|
+
} from "./db-adapter";
|
|
6
|
+
|
|
7
|
+
const BASE_ENV: NodeJS.ProcessEnv = { HOME: "/nonexistent" };
|
|
8
|
+
|
|
9
|
+
describe("#435 inherited DATABASE_URL guard", () => {
|
|
10
|
+
for (const databaseUrl of [
|
|
11
|
+
"postgres://user:pw@prod.example:5432/commhub",
|
|
12
|
+
"postgresql://user:pw@prod.example:5432/commhub",
|
|
13
|
+
"sqlite:///tmp/not-an-escape",
|
|
14
|
+
]) {
|
|
15
|
+
test(`NODE_ENV=test rejects ${databaseUrl.split(":", 1)[0]} DATABASE_URL`, () => {
|
|
16
|
+
expect(() => assertSafeTestDatabaseEnv({
|
|
17
|
+
...BASE_ENV,
|
|
18
|
+
NODE_ENV: "test",
|
|
19
|
+
DATABASE_URL: databaseUrl,
|
|
20
|
+
})).toThrow(/REFUSING to honor inherited DATABASE_URL/);
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
test("test without DATABASE_URL leaves the existing SQLite guard in charge", () => {
|
|
25
|
+
expect(() => assertSafeTestDatabaseEnv({ ...BASE_ENV, NODE_ENV: "test" })).not.toThrow();
|
|
26
|
+
expect(() => resolveDatabaseTarget({ ...BASE_ENV, NODE_ENV: "test" }))
|
|
27
|
+
.toThrow(/REFUSING to open the default SQLite database/);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("DATABASE_URL refusal wins even when COMMHUB_DB is also set", () => {
|
|
31
|
+
expect(() => resolveDatabaseTarget({
|
|
32
|
+
...BASE_ENV,
|
|
33
|
+
NODE_ENV: "test",
|
|
34
|
+
DATABASE_URL: "postgres://user:pw@prod.example:5432/commhub",
|
|
35
|
+
COMMHUB_DB: "/tmp/isolated.db",
|
|
36
|
+
})).toThrow(/REFUSING to honor inherited DATABASE_URL/);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("production postgres branch remains unchanged without constructing it", () => {
|
|
40
|
+
const url = "postgres://user:pw@prod.example:5432/commhub";
|
|
41
|
+
expect(resolveDatabaseTarget({
|
|
42
|
+
...BASE_ENV,
|
|
43
|
+
NODE_ENV: "production",
|
|
44
|
+
DATABASE_URL: url,
|
|
45
|
+
})).toEqual({ kind: "postgres", url });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("unset NODE_ENV still selects postgres", () => {
|
|
49
|
+
const url = "postgresql://user:pw@prod.example:5432/commhub";
|
|
50
|
+
expect(resolveDatabaseTarget({ ...BASE_ENV, DATABASE_URL: url }))
|
|
51
|
+
.toEqual({ kind: "postgres", url });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("explicit SQLite targets remain unchanged in test and production", () => {
|
|
55
|
+
for (const NODE_ENV of ["test", "production"]) {
|
|
56
|
+
expect(resolveDatabaseTarget({
|
|
57
|
+
...BASE_ENV,
|
|
58
|
+
NODE_ENV,
|
|
59
|
+
COMMHUB_DB: "/tmp/isolated.db",
|
|
60
|
+
})).toEqual({ kind: "sqlite", path: "/tmp/isolated.db" });
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("non-server callers cannot silently fall through to the production default SQLite path", () => {
|
|
65
|
+
for (const NODE_ENV of [undefined, "development", "production"]) {
|
|
66
|
+
expect(() => resolveDatabaseTarget({ ...BASE_ENV, NODE_ENV }))
|
|
67
|
+
.toThrow(/explicit COMMHUB_DB|COMMHUB_SERVER=1/);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("the explicit server boot capability permits the canonical default SQLite path", () => {
|
|
72
|
+
expect(resolveDatabaseTarget({
|
|
73
|
+
...BASE_ENV,
|
|
74
|
+
NODE_ENV: "production",
|
|
75
|
+
COMMHUB_SERVER: "1",
|
|
76
|
+
})).toEqual({ kind: "sqlite", path: "/nonexistent/.commhub/commhub.db" });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("lookalike server capability values fail closed", () => {
|
|
80
|
+
for (const COMMHUB_SERVER of ["", "true", "yes", "01", " 1"] ) {
|
|
81
|
+
expect(() => resolveDatabaseTarget({ ...BASE_ENV, COMMHUB_SERVER }))
|
|
82
|
+
.toThrow(/COMMHUB_SERVER=1/);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
});
|
package/src/db-adapter.ts
CHANGED
|
@@ -212,10 +212,28 @@ export class PgAdapter implements DbAdapter {
|
|
|
212
212
|
// Factory
|
|
213
213
|
// ════════════════════════════════════════════
|
|
214
214
|
|
|
215
|
+
export type DbTarget =
|
|
216
|
+
| { kind: "postgres"; url: string }
|
|
217
|
+
| { kind: "sqlite"; path: string };
|
|
218
|
+
|
|
215
219
|
/**
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
220
|
+
* Refuse every inherited DATABASE_URL under the Bun test environment before
|
|
221
|
+
* adapter selection, logging, DNS, or construction. A test that intentionally
|
|
222
|
+
* exercises PostgreSQL needs a separately reviewed isolated harness; accepting
|
|
223
|
+
* a shell/CI DATABASE_URL here could connect a unit test to production.
|
|
224
|
+
*/
|
|
225
|
+
export function assertSafeTestDatabaseEnv(env: NodeJS.ProcessEnv = process.env): void {
|
|
226
|
+
if (env.NODE_ENV === "test" && env.DATABASE_URL) {
|
|
227
|
+
throw new Error(
|
|
228
|
+
"[commhub] REFUSING to honor inherited DATABASE_URL under NODE_ENV=test.\n" +
|
|
229
|
+
" Tests must unset DATABASE_URL and set COMMHUB_DB to an isolated path.\n" +
|
|
230
|
+
" This guard is fail-closed and has no opt-in bypass."
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Pure target selection after the inherited-DATABASE_URL safety gate.
|
|
219
237
|
*
|
|
220
238
|
* Test-default-prod GUARD: when `NODE_ENV === "test"` (set automatically by
|
|
221
239
|
* `bun test`) AND `COMMHUB_DB` is unset, we REFUSE to fall through to the
|
|
@@ -227,27 +245,20 @@ export class PgAdapter implements DbAdapter {
|
|
|
227
245
|
* pre-existing history of test pollution in the same DB). The guard makes
|
|
228
246
|
* the failure mode loud + actionable instead of silent + destructive.
|
|
229
247
|
*
|
|
230
|
-
* The
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
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.
|
|
248
|
+
* The production default path is a capability, not a generic fallback:
|
|
249
|
+
* only canonical server entrypoints set COMMHUB_SERVER=1 before importing
|
|
250
|
+
* the database graph. Tests, scripts, and `bun -e` probes must name an
|
|
251
|
+
* explicit COMMHUB_DB (or a reviewed PostgreSQL DATABASE_URL), so forgetting
|
|
252
|
+
* one fails before mkdir/open/write instead of touching the live Hub.
|
|
238
253
|
*/
|
|
239
|
-
|
|
240
|
-
const dbUrl =
|
|
254
|
+
function resolveDatabaseTargetAfterGuard(env: NodeJS.ProcessEnv): DbTarget {
|
|
255
|
+
const dbUrl = env.DATABASE_URL;
|
|
241
256
|
if (dbUrl && (dbUrl.startsWith("postgres://") || dbUrl.startsWith("postgresql://"))) {
|
|
242
|
-
|
|
243
|
-
return new PgAdapter(dbUrl);
|
|
257
|
+
return { kind: "postgres", url: dbUrl };
|
|
244
258
|
}
|
|
245
|
-
// Default: SQLite
|
|
246
|
-
const { mkdirSync } = require("fs");
|
|
247
|
-
const { dirname } = require("path");
|
|
248
259
|
|
|
249
260
|
// Test-default-prod GUARD (see docblock above).
|
|
250
|
-
if (
|
|
261
|
+
if (env.NODE_ENV === "test" && !env.COMMHUB_DB) {
|
|
251
262
|
throw new Error(
|
|
252
263
|
"[commhub] REFUSING to open the default SQLite database under NODE_ENV=test.\n" +
|
|
253
264
|
" Tests must explicitly set COMMHUB_DB to a throwaway path so they don't\n" +
|
|
@@ -257,10 +268,40 @@ export function createAdapter(): DbAdapter {
|
|
|
257
268
|
);
|
|
258
269
|
}
|
|
259
270
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
271
|
+
if (!env.COMMHUB_DB && env.COMMHUB_SERVER !== "1") {
|
|
272
|
+
throw new Error(
|
|
273
|
+
"[commhub] REFUSING to open the default production SQLite database from a non-server entrypoint.\n" +
|
|
274
|
+
" Scripts and probes must set an explicit COMMHUB_DB path. The canonical\n" +
|
|
275
|
+
" Hub entrypoints set COMMHUB_SERVER=1 before loading the database."
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return { kind: "sqlite", path: env.COMMHUB_DB || `${env.HOME}/.commhub/commhub.db` };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Pure public resolver used by tests without constructing an adapter. */
|
|
283
|
+
export function resolveDatabaseTarget(env: NodeJS.ProcessEnv = process.env): DbTarget {
|
|
284
|
+
assertSafeTestDatabaseEnv(env);
|
|
285
|
+
return resolveDatabaseTargetAfterGuard(env);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Create the appropriate adapter based on environment.
|
|
290
|
+
* The inherited-DATABASE_URL guard is deliberately the first executable line.
|
|
291
|
+
*/
|
|
292
|
+
export function createAdapter(): DbAdapter {
|
|
293
|
+
assertSafeTestDatabaseEnv(process.env);
|
|
294
|
+
const target = resolveDatabaseTargetAfterGuard(process.env);
|
|
295
|
+
if (target.kind === "postgres") {
|
|
296
|
+
console.log("[commhub] database: PostgreSQL");
|
|
297
|
+
return new PgAdapter(target.url);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const { mkdirSync } = require("fs");
|
|
301
|
+
const { dirname } = require("path");
|
|
302
|
+
mkdirSync(dirname(target.path), { recursive: true });
|
|
303
|
+
console.log(`[commhub] database: ${target.path}`);
|
|
304
|
+
const rawDb = new Database(target.path);
|
|
264
305
|
rawDb.exec("PRAGMA journal_mode=WAL");
|
|
265
306
|
rawDb.exec("PRAGMA busy_timeout=5000");
|
|
266
307
|
return new SQLiteAdapter(rawDb);
|
package/src/db.ts
CHANGED
|
@@ -89,6 +89,8 @@ for (const col of [
|
|
|
89
89
|
{ name: "process_cpu_pct", def: "REAL" },
|
|
90
90
|
{ name: "process_uptime_seconds", def: "REAL" },
|
|
91
91
|
{ name: "process_in_flight_count", def: "INTEGER" },
|
|
92
|
+
{ name: "external_schedules", def: "TEXT" },
|
|
93
|
+
{ name: "peer_reply_inbox_capable", def: "INTEGER NOT NULL DEFAULT 0" },
|
|
92
94
|
]) {
|
|
93
95
|
try { db.exec(`ALTER TABLE sessions ADD COLUMN ${col.name} ${col.def}`); } catch {}
|
|
94
96
|
}
|
|
@@ -162,6 +164,11 @@ for (const col of [
|
|
|
162
164
|
{ name: "scope", def: "TEXT DEFAULT 'single'" },
|
|
163
165
|
{ name: "meta_json", def: "TEXT" },
|
|
164
166
|
{ name: "node_id", def: "TEXT" },
|
|
167
|
+
// #520 — explicit logical task identity. Initial deliveries historically
|
|
168
|
+
// used inbox.id == tasks.task_id, while retry/reassign generate a fresh
|
|
169
|
+
// inbox row id. Keep transport-row identity and logical-task identity
|
|
170
|
+
// separate so runtime evidence/replies survive those redeliveries.
|
|
171
|
+
{ name: "task_id", def: "TEXT" },
|
|
165
172
|
]) {
|
|
166
173
|
try { db.exec(`ALTER TABLE inbox ADD COLUMN ${col.name} ${col.def}`); } catch {}
|
|
167
174
|
}
|
|
@@ -191,6 +198,8 @@ db.exec(`
|
|
|
191
198
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
192
199
|
delivered_at TEXT,
|
|
193
200
|
started_at TEXT,
|
|
201
|
+
runtime_submitted_at TEXT,
|
|
202
|
+
consumed_at TEXT,
|
|
194
203
|
completed_at TEXT,
|
|
195
204
|
expires_at TEXT
|
|
196
205
|
);
|
|
@@ -237,6 +246,8 @@ db.exec(`
|
|
|
237
246
|
task_id TEXT NOT NULL,
|
|
238
247
|
from_status TEXT,
|
|
239
248
|
to_status TEXT NOT NULL,
|
|
249
|
+
event_type TEXT,
|
|
250
|
+
event_key TEXT,
|
|
240
251
|
actor TEXT NOT NULL DEFAULT 'system',
|
|
241
252
|
detail TEXT,
|
|
242
253
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
@@ -301,6 +312,16 @@ try {
|
|
|
301
312
|
if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
|
|
302
313
|
}
|
|
303
314
|
|
|
315
|
+
// RFC-036 / B4 — immutable per-node owner. New nodes receive this value in
|
|
316
|
+
// the same transaction that mints their node token. Heartbeats may verify the
|
|
317
|
+
// binding but must never first-claim or replace it. Legacy NULL rows remain
|
|
318
|
+
// read-only for external-schedule writes.
|
|
319
|
+
try {
|
|
320
|
+
db.exec(`ALTER TABLE nodes ADD COLUMN owner_user_id TEXT`);
|
|
321
|
+
} catch (e: any) {
|
|
322
|
+
if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
|
|
323
|
+
}
|
|
324
|
+
|
|
304
325
|
// RFC-026 §9 / #338 PR2 — daemon self-declare fields promoted from
|
|
305
326
|
// config_snapshot to first-class indexable columns. The hub
|
|
306
327
|
// `list_host_supervisors` MCP tool reads these directly without parsing
|
|
@@ -495,6 +516,7 @@ for (const ddl of [
|
|
|
495
516
|
"ALTER TABLE api_tokens ADD COLUMN request_id TEXT",
|
|
496
517
|
"ALTER TABLE api_tokens ADD COLUMN revoked_at TEXT",
|
|
497
518
|
"ALTER TABLE api_tokens ADD COLUMN role TEXT",
|
|
519
|
+
"ALTER TABLE api_tokens ADD COLUMN bound_node_id TEXT",
|
|
498
520
|
]) {
|
|
499
521
|
try { db.exec(ddl); }
|
|
500
522
|
catch (e: any) {
|
|
@@ -503,6 +525,8 @@ for (const ddl of [
|
|
|
503
525
|
}
|
|
504
526
|
try { db.exec(`CREATE INDEX IF NOT EXISTS idx_tokens_request ON api_tokens(request_id)`); }
|
|
505
527
|
catch (e: any) { /* index may already exist */ void e; }
|
|
528
|
+
try { db.exec(`CREATE INDEX IF NOT EXISTS idx_tokens_bound_node ON api_tokens(bound_node_id)`); }
|
|
529
|
+
catch (e: any) { void e; }
|
|
506
530
|
|
|
507
531
|
// RFC-026 v4 §2.5 + §4.4 — node_create_requests. Metadata only;
|
|
508
532
|
// env_blob NEVER lives in this table (F1 mint-stream-evict — secret
|
|
@@ -634,6 +658,37 @@ db.exec(`
|
|
|
634
658
|
CREATE INDEX IF NOT EXISTS idx_audit_network ON audit_log(network_id);
|
|
635
659
|
`);
|
|
636
660
|
|
|
661
|
+
// SkillHub — immutable, network-scoped skill submissions. A (network, slug,
|
|
662
|
+
// version) tuple is write-once: retries with the same content are idempotent,
|
|
663
|
+
// while different content must use a new version. Node submissions remain
|
|
664
|
+
// pending until an owner/admin explicitly publishes them.
|
|
665
|
+
db.exec(`
|
|
666
|
+
CREATE TABLE IF NOT EXISTS skillhub_skills (
|
|
667
|
+
skill_id TEXT PRIMARY KEY,
|
|
668
|
+
network_id TEXT NOT NULL,
|
|
669
|
+
slug TEXT NOT NULL,
|
|
670
|
+
name TEXT NOT NULL,
|
|
671
|
+
description TEXT NOT NULL DEFAULT '',
|
|
672
|
+
version TEXT NOT NULL,
|
|
673
|
+
content TEXT NOT NULL,
|
|
674
|
+
content_hash TEXT NOT NULL,
|
|
675
|
+
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'published', 'rejected', 'archived')),
|
|
676
|
+
source_type TEXT NOT NULL CHECK(source_type IN ('node', 'user')),
|
|
677
|
+
source_alias TEXT,
|
|
678
|
+
created_by_user TEXT,
|
|
679
|
+
reviewed_by_user TEXT,
|
|
680
|
+
review_note TEXT,
|
|
681
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
682
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
683
|
+
reviewed_at TEXT,
|
|
684
|
+
UNIQUE(network_id, slug, version)
|
|
685
|
+
);
|
|
686
|
+
CREATE INDEX IF NOT EXISTS idx_skillhub_network_status
|
|
687
|
+
ON skillhub_skills(network_id, status, updated_at DESC);
|
|
688
|
+
CREATE INDEX IF NOT EXISTS idx_skillhub_network_slug
|
|
689
|
+
ON skillhub_skills(network_id, slug, version);
|
|
690
|
+
`);
|
|
691
|
+
|
|
637
692
|
// ── V3: licenses table ──
|
|
638
693
|
db.exec(`
|
|
639
694
|
CREATE TABLE IF NOT EXISTS licenses (
|
|
@@ -744,6 +799,14 @@ for (const table of ["sessions", "nodes", "tasks", "inbox", "task_events", "comp
|
|
|
744
799
|
try { db.exec(`ALTER TABLE ${table} ADD COLUMN network_id TEXT`); } catch {}
|
|
745
800
|
}
|
|
746
801
|
|
|
802
|
+
// #167 — stable lifecycle event names plus an internal idempotency key.
|
|
803
|
+
// Ordinary status transitions keep event_key NULL, so retries and repeated
|
|
804
|
+
// transitions remain auditable. Watcher warnings set a stable key; the unique
|
|
805
|
+
// index makes every (task, threshold) write-once across patrol rounds/workers.
|
|
806
|
+
try { db.exec("ALTER TABLE task_events ADD COLUMN event_type TEXT"); } catch {}
|
|
807
|
+
try { db.exec("ALTER TABLE task_events ADD COLUMN event_key TEXT"); } catch {}
|
|
808
|
+
try { db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_task_events_task_key ON task_events(task_id, event_key)"); } catch {}
|
|
809
|
+
|
|
747
810
|
// PR-1 (#146): durable node identity on historical inbox/tasks rows. Some
|
|
748
811
|
// existing databases created `tasks` before from_node_id/to_node_id were in the
|
|
749
812
|
// CREATE TABLE statement, so keep these ALTERs explicit and idempotent.
|
|
@@ -1030,6 +1093,102 @@ try { db.exec("CREATE INDEX IF NOT EXISTS idx_completions_network ON completions
|
|
|
1030
1093
|
// the reply up the chain via parent_task_id.
|
|
1031
1094
|
try { db.exec("ALTER TABLE tasks ADD COLUMN parent_task_id TEXT"); } catch {}
|
|
1032
1095
|
try { db.exec("ALTER TABLE tasks ADD COLUMN meta_json TEXT"); } catch {}
|
|
1096
|
+
// #520 — two monotonic evidence levels for this exact task:
|
|
1097
|
+
// runtime_submitted_at means agent-node handed the body to the vendor runtime;
|
|
1098
|
+
// consumed_at requires an attributable turn-start/first-activity signal.
|
|
1099
|
+
// Both stay separate from delivered_at (enqueue), acked (process receipt), and
|
|
1100
|
+
// started_at (legacy report_status/content matching). Old binaries ignore them.
|
|
1101
|
+
try { db.exec("ALTER TABLE tasks ADD COLUMN runtime_submitted_at TEXT"); } catch {}
|
|
1102
|
+
try { db.exec("ALTER TABLE tasks ADD COLUMN consumed_at TEXT"); } catch {}
|
|
1103
|
+
|
|
1104
|
+
// ── Hub scheduled tasks ──────────────────────────────────────────────
|
|
1105
|
+
// Scheduling is a Hub concern: clients manage these rows, while agents only
|
|
1106
|
+
// receive the ordinary inbox/tasks rows produced for each occurrence. Keep
|
|
1107
|
+
// the occurrence table separate so retries, skips and failures remain
|
|
1108
|
+
// auditable without overloading the task lifecycle.
|
|
1109
|
+
db.exec(`
|
|
1110
|
+
CREATE TABLE IF NOT EXISTS scheduled_tasks (
|
|
1111
|
+
schedule_id TEXT PRIMARY KEY,
|
|
1112
|
+
network_id TEXT NOT NULL,
|
|
1113
|
+
created_by TEXT,
|
|
1114
|
+
name TEXT NOT NULL,
|
|
1115
|
+
target_node_id TEXT NOT NULL,
|
|
1116
|
+
target_alias TEXT NOT NULL,
|
|
1117
|
+
task_content TEXT NOT NULL,
|
|
1118
|
+
priority TEXT NOT NULL DEFAULT 'normal',
|
|
1119
|
+
schedule_type TEXT NOT NULL,
|
|
1120
|
+
schedule_json TEXT NOT NULL,
|
|
1121
|
+
timezone TEXT NOT NULL DEFAULT 'UTC',
|
|
1122
|
+
overlap_policy TEXT NOT NULL DEFAULT 'skip',
|
|
1123
|
+
misfire_policy TEXT NOT NULL DEFAULT 'catch_up_once',
|
|
1124
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
1125
|
+
next_run_at TEXT,
|
|
1126
|
+
last_run_at TEXT,
|
|
1127
|
+
revision INTEGER NOT NULL DEFAULT 1,
|
|
1128
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
1129
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
1130
|
+
);
|
|
1131
|
+
|
|
1132
|
+
CREATE INDEX IF NOT EXISTS idx_scheduled_tasks_due
|
|
1133
|
+
ON scheduled_tasks(status, next_run_at);
|
|
1134
|
+
CREATE INDEX IF NOT EXISTS idx_scheduled_tasks_network
|
|
1135
|
+
ON scheduled_tasks(network_id, updated_at DESC);
|
|
1136
|
+
CREATE INDEX IF NOT EXISTS idx_scheduled_tasks_target
|
|
1137
|
+
ON scheduled_tasks(network_id, target_node_id);
|
|
1138
|
+
|
|
1139
|
+
CREATE TABLE IF NOT EXISTS scheduled_task_runs (
|
|
1140
|
+
run_id TEXT PRIMARY KEY,
|
|
1141
|
+
schedule_id TEXT NOT NULL,
|
|
1142
|
+
network_id TEXT NOT NULL,
|
|
1143
|
+
scheduled_for TEXT NOT NULL,
|
|
1144
|
+
task_id TEXT,
|
|
1145
|
+
status TEXT NOT NULL,
|
|
1146
|
+
error_code TEXT,
|
|
1147
|
+
error_message TEXT,
|
|
1148
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
1149
|
+
completed_at TEXT,
|
|
1150
|
+
UNIQUE(schedule_id, scheduled_for)
|
|
1151
|
+
);
|
|
1152
|
+
|
|
1153
|
+
CREATE INDEX IF NOT EXISTS idx_scheduled_runs_schedule
|
|
1154
|
+
ON scheduled_task_runs(schedule_id, created_at DESC);
|
|
1155
|
+
CREATE INDEX IF NOT EXISTS idx_scheduled_runs_network
|
|
1156
|
+
ON scheduled_task_runs(network_id, created_at DESC);
|
|
1157
|
+
`);
|
|
1158
|
+
try { db.exec("ALTER TABLE scheduled_tasks ADD COLUMN misfire_policy TEXT NOT NULL DEFAULT 'catch_up_once'"); } catch {}
|
|
1159
|
+
|
|
1160
|
+
// RFC-036 / B4 — owner-authorized edits of node-host managed schedules.
|
|
1161
|
+
// This is intentionally separate from Hub scheduled_tasks: these rows are
|
|
1162
|
+
// one-shot control intents consumed by the exact token-bound node. The partial
|
|
1163
|
+
// unique index makes the single-flight promise survive multiple Hub workers.
|
|
1164
|
+
db.exec(`
|
|
1165
|
+
CREATE TABLE IF NOT EXISTS external_schedule_edits (
|
|
1166
|
+
intent_id TEXT PRIMARY KEY,
|
|
1167
|
+
network_id TEXT NOT NULL,
|
|
1168
|
+
node_id TEXT NOT NULL,
|
|
1169
|
+
schedule_id TEXT NOT NULL,
|
|
1170
|
+
base_revision INTEGER NOT NULL,
|
|
1171
|
+
patch_json TEXT NOT NULL,
|
|
1172
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
1173
|
+
expires_at INTEGER NOT NULL,
|
|
1174
|
+
created_at INTEGER NOT NULL,
|
|
1175
|
+
delivered_at INTEGER,
|
|
1176
|
+
acked_at INTEGER,
|
|
1177
|
+
created_by_user TEXT NOT NULL,
|
|
1178
|
+
created_by_token TEXT NOT NULL,
|
|
1179
|
+
consumed_by_token TEXT,
|
|
1180
|
+
result_revision INTEGER,
|
|
1181
|
+
error_code TEXT
|
|
1182
|
+
);
|
|
1183
|
+
|
|
1184
|
+
CREATE INDEX IF NOT EXISTS idx_external_schedule_edits_node
|
|
1185
|
+
ON external_schedule_edits(network_id, node_id, status, created_at);
|
|
1186
|
+
CREATE INDEX IF NOT EXISTS idx_external_schedule_edits_owner
|
|
1187
|
+
ON external_schedule_edits(created_by_user, created_at DESC);
|
|
1188
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_external_schedule_edits_singleflight
|
|
1189
|
+
ON external_schedule_edits(node_id, schedule_id)
|
|
1190
|
+
WHERE status IN ('pending', 'delivered');
|
|
1191
|
+
`);
|
|
1033
1192
|
try { db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_task_id)"); } catch {}
|
|
1034
1193
|
|
|
1035
1194
|
// Helpers
|
|
@@ -1338,38 +1497,40 @@ export function chainReplyToParent(
|
|
|
1338
1497
|
const marker = `\n\n[via ${childAlias} 子任务结果]\n${currentReply}`;
|
|
1339
1498
|
const newResult = parent.result ? parent.result + marker : `[via ${childAlias} 子任务结果]\n${currentReply}`;
|
|
1340
1499
|
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
);
|
|
1350
|
-
logTaskEvent(parent.task_id, parent.status, replyStatus, "auto-chain", `from ${childAlias}`);
|
|
1351
|
-
} else {
|
|
1352
|
-
db.run(
|
|
1353
|
-
"UPDATE tasks SET result = ?1, completed_at = datetime('now') WHERE task_id = ?2",
|
|
1354
|
-
[newResult.slice(0, 8000), parent.task_id]
|
|
1355
|
-
);
|
|
1356
|
-
logTaskEvent(parent.task_id, parent.status, parent.status, "auto-chain-append", `from ${childAlias}`);
|
|
1357
|
-
}
|
|
1358
|
-
|
|
1359
|
-
if (parent.from_name && parent.from_name !== "hub" && parent.from_name !== "api") {
|
|
1360
|
-
try {
|
|
1361
|
-
const notifyId = `chain_${crypto.randomUUID().replace(/-/g, "").slice(0, 16)}`;
|
|
1362
|
-
const notifyNode = db.get<{ node_id: string | null }>(
|
|
1363
|
-
"SELECT node_id FROM sessions WHERE alias = ?1 AND COALESCE(network_id, 'default') = COALESCE(?2, 'default') ORDER BY updated_at DESC LIMIT 1",
|
|
1364
|
-
[parent.from_name, parent.network_id ?? null]
|
|
1500
|
+
db.transaction(() => {
|
|
1501
|
+
// Bump parent status to replied if still open. The task transition and
|
|
1502
|
+
// its scheduler-run mirror share this transaction, so a crash cannot
|
|
1503
|
+
// leave one terminal while the other remains delivered.
|
|
1504
|
+
if (parent.status === "delivered" || parent.status === "acked" || parent.status === "running" || parent.status === "created") {
|
|
1505
|
+
db.run(
|
|
1506
|
+
"UPDATE tasks SET status = ?1, result = ?2, completed_at = datetime('now') WHERE task_id = ?3",
|
|
1507
|
+
[replyStatus, newResult.slice(0, 8000), parent.task_id]
|
|
1365
1508
|
);
|
|
1509
|
+
syncScheduledRunForTask(parent.task_id, parent.network_id);
|
|
1510
|
+
logTaskEvent(parent.task_id, parent.status, replyStatus, "auto-chain", `from ${childAlias}`);
|
|
1511
|
+
} else {
|
|
1366
1512
|
db.run(
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
[notifyId, parent.from_name, notifyNode?.node_id ?? null, `[${childAlias} 子任务完成]\n${currentReply.slice(0, 4000)}`, parent.to_name, parent.task_id, parent.network_id ?? null]
|
|
1513
|
+
"UPDATE tasks SET result = ?1, completed_at = datetime('now') WHERE task_id = ?2",
|
|
1514
|
+
[newResult.slice(0, 8000), parent.task_id]
|
|
1370
1515
|
);
|
|
1371
|
-
|
|
1372
|
-
|
|
1516
|
+
logTaskEvent(parent.task_id, parent.status, parent.status, "auto-chain-append", `from ${childAlias}`);
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
if (parent.from_name && parent.from_name !== "hub" && parent.from_name !== "api") {
|
|
1520
|
+
try {
|
|
1521
|
+
const notifyId = `chain_${crypto.randomUUID().replace(/-/g, "").slice(0, 16)}`;
|
|
1522
|
+
const notifyNode = db.get<{ node_id: string | null }>(
|
|
1523
|
+
"SELECT node_id FROM sessions WHERE alias = ?1 AND COALESCE(network_id, 'default') = COALESCE(?2, 'default') ORDER BY updated_at DESC LIMIT 1",
|
|
1524
|
+
[parent.from_name, parent.network_id ?? null]
|
|
1525
|
+
);
|
|
1526
|
+
db.run(
|
|
1527
|
+
`INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, in_reply_to, requires_response, network_id)
|
|
1528
|
+
VALUES (?1, ?2, ?3, 'reply', 'normal', ?4, ?5, ?6, 'none', ?7)`,
|
|
1529
|
+
[notifyId, parent.from_name, notifyNode?.node_id ?? null, `[${childAlias} 子任务完成]\n${currentReply.slice(0, 4000)}`, parent.to_name, parent.task_id, parent.network_id ?? null]
|
|
1530
|
+
);
|
|
1531
|
+
} catch {}
|
|
1532
|
+
}
|
|
1533
|
+
});
|
|
1373
1534
|
|
|
1374
1535
|
// This iteration actually wrote a parent row (and possibly an
|
|
1375
1536
|
// inbox notification). Record that so the caller can gate its
|
|
@@ -1383,12 +1544,98 @@ export function chainReplyToParent(
|
|
|
1383
1544
|
return { chained };
|
|
1384
1545
|
}
|
|
1385
1546
|
|
|
1547
|
+
const SCHEDULED_RUN_TERMINAL_STATUSES = new Set(["replied", "failed", "cancelled", "expired"]);
|
|
1548
|
+
|
|
1549
|
+
type ScheduledTaskLifecycleRow = {
|
|
1550
|
+
task_id: string;
|
|
1551
|
+
network_id: string | null;
|
|
1552
|
+
status: string;
|
|
1553
|
+
completed_at: string | null;
|
|
1554
|
+
};
|
|
1555
|
+
|
|
1556
|
+
type ScheduledTaskBinding = { run_id: string; schedule_id: string };
|
|
1557
|
+
|
|
1558
|
+
/**
|
|
1559
|
+
* Mirror an exact scheduler-created task's lifecycle into its run row.
|
|
1560
|
+
*
|
|
1561
|
+
* The status is always read back from `tasks`; callers cannot supply one.
|
|
1562
|
+
* The scheduler's storage binding is the four-part tuple
|
|
1563
|
+
* (run_id, schedule_id, task_id, network_id). It is read from
|
|
1564
|
+
* `scheduled_task_runs`, not caller-controlled task metadata (which may also
|
|
1565
|
+
* legitimately be replaced by reply attachment metadata).
|
|
1566
|
+
*
|
|
1567
|
+
* Terminal states close the run. `retry_task` calls this after resetting the
|
|
1568
|
+
* same logical task_id to delivered, which deliberately reopens that run so
|
|
1569
|
+
* the history reflects the current retry rather than the first failed attempt.
|
|
1570
|
+
*/
|
|
1571
|
+
export function syncScheduledRunForTask(taskId: string, expectedNetworkId?: string | null): { matched: boolean; status?: string } {
|
|
1572
|
+
const task = db.get<ScheduledTaskLifecycleRow>(
|
|
1573
|
+
`SELECT task_id, network_id, status, completed_at
|
|
1574
|
+
FROM tasks
|
|
1575
|
+
WHERE task_id = ?1
|
|
1576
|
+
AND (?2 IS NULL OR network_id = ?2)`,
|
|
1577
|
+
taskId,
|
|
1578
|
+
expectedNetworkId ?? null,
|
|
1579
|
+
);
|
|
1580
|
+
if (!task?.network_id) return { matched: false };
|
|
1581
|
+
const bindings = db.all<ScheduledTaskBinding>(
|
|
1582
|
+
`SELECT run_id, schedule_id FROM scheduled_task_runs
|
|
1583
|
+
WHERE task_id = ?1 AND network_id = ?2`,
|
|
1584
|
+
task.task_id,
|
|
1585
|
+
task.network_id,
|
|
1586
|
+
);
|
|
1587
|
+
// One task belongs to at most one scheduled occurrence. Fail closed if a
|
|
1588
|
+
// corrupt/legacy database violates that invariant rather than fanning one
|
|
1589
|
+
// lifecycle transition into multiple runs.
|
|
1590
|
+
if (bindings.length !== 1) return { matched: false };
|
|
1591
|
+
const binding = bindings[0];
|
|
1592
|
+
|
|
1593
|
+
if (SCHEDULED_RUN_TERMINAL_STATUSES.has(task.status)) {
|
|
1594
|
+
const errorCode = task.status === "replied" ? null : `task_${task.status}`;
|
|
1595
|
+
const updated = db.run(
|
|
1596
|
+
`UPDATE scheduled_task_runs
|
|
1597
|
+
SET status = ?1,
|
|
1598
|
+
error_code = ?2,
|
|
1599
|
+
error_message = NULL,
|
|
1600
|
+
completed_at = COALESCE(?3, datetime('now'))
|
|
1601
|
+
WHERE run_id = ?4 AND task_id = ?5 AND network_id = ?6 AND schedule_id = ?7`,
|
|
1602
|
+
[task.status, errorCode, task.completed_at, binding.run_id, task.task_id, task.network_id, binding.schedule_id],
|
|
1603
|
+
);
|
|
1604
|
+
return { matched: updated.changes === 1, status: task.status };
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
if (task.status === "delivered") {
|
|
1608
|
+
const updated = db.run(
|
|
1609
|
+
`UPDATE scheduled_task_runs
|
|
1610
|
+
SET status = 'delivered', error_code = NULL, error_message = NULL, completed_at = NULL
|
|
1611
|
+
WHERE run_id = ?1 AND task_id = ?2 AND network_id = ?3 AND schedule_id = ?4`,
|
|
1612
|
+
[binding.run_id, task.task_id, task.network_id, binding.schedule_id],
|
|
1613
|
+
);
|
|
1614
|
+
return { matched: updated.changes === 1, status: task.status };
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
return { matched: false };
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
function taskEventTypeForStatus(toStatus: string): string {
|
|
1621
|
+
switch (toStatus) {
|
|
1622
|
+
case "delivered": return "task.send.delivered";
|
|
1623
|
+
case "acked": return "task.ack";
|
|
1624
|
+
case "running": return "task.started";
|
|
1625
|
+
case "replied": return "task.replied";
|
|
1626
|
+
case "expired": return "task.expired";
|
|
1627
|
+
case "failed":
|
|
1628
|
+
case "cancelled": return "task.failed";
|
|
1629
|
+
default: return `task.status.${toStatus}`;
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1386
1633
|
export function logTaskEvent(taskId: string, fromStatus: string | null, toStatus: string, actor: string, detail?: string) {
|
|
1387
1634
|
try {
|
|
1388
1635
|
db.run(
|
|
1389
|
-
`INSERT INTO task_events (task_id, from_status, to_status, actor, detail, network_id)
|
|
1390
|
-
VALUES (?1, ?2, ?3, ?4, ?5, (SELECT network_id FROM tasks WHERE task_id = ?1))`,
|
|
1391
|
-
[taskId, fromStatus, toStatus, actor, detail ?? null]
|
|
1636
|
+
`INSERT INTO task_events (task_id, from_status, to_status, event_type, actor, detail, network_id)
|
|
1637
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, (SELECT network_id FROM tasks WHERE task_id = ?1))`,
|
|
1638
|
+
[taskId, fromStatus, toStatus, taskEventTypeForStatus(toStatus), actor, detail ?? null]
|
|
1392
1639
|
);
|
|
1393
1640
|
} catch {}
|
|
1394
1641
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
buildStoragePath,
|
|
4
|
+
pathForExistingBlob,
|
|
5
|
+
validateIndexEntry,
|
|
6
|
+
} from "./uploads.js";
|
|
7
|
+
|
|
8
|
+
const fileId = "0123456789abcdef0123456789abcdef";
|
|
9
|
+
const dateBucket = "2026-08-09";
|
|
10
|
+
const uploadsRoot = "/srv/uploads";
|
|
11
|
+
|
|
12
|
+
const malicious = [
|
|
13
|
+
"../x",
|
|
14
|
+
"/abs/x",
|
|
15
|
+
".png/../../y",
|
|
16
|
+
".p ng",
|
|
17
|
+
".p\0ng",
|
|
18
|
+
".\\..\\x",
|
|
19
|
+
"." + "a".repeat(17),
|
|
20
|
+
".tar.gz",
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
const legal = ["", ".png", ".PNG", ".mp4", "." + "a".repeat(16)];
|
|
24
|
+
|
|
25
|
+
describe("#527 shared extension-token invariant", () => {
|
|
26
|
+
test("new-upload path rejects every malformed token and accepts the shared legal set", () => {
|
|
27
|
+
for (const ext of malicious) {
|
|
28
|
+
expect(() => buildStoragePath(fileId, ext, { uploadsRoot })).toThrow(/ext .* is invalid/);
|
|
29
|
+
}
|
|
30
|
+
for (const ext of legal) {
|
|
31
|
+
expect(buildStoragePath(fileId, ext, { uploadsRoot }).ext).toBe(ext);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("existing-blob path rejects every malformed token and accepts the shared legal set", () => {
|
|
36
|
+
for (const ext of malicious) {
|
|
37
|
+
expect(() => pathForExistingBlob(dateBucket, fileId, ext, { uploadsRoot })).toThrow(/ext .* is invalid/);
|
|
38
|
+
}
|
|
39
|
+
for (const ext of legal) {
|
|
40
|
+
expect(pathForExistingBlob(dateBucket, fileId, ext, { uploadsRoot }).ext).toBe(ext);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("stored-index gate rejects every malformed token and accepts the shared legal set", () => {
|
|
45
|
+
for (const ext of malicious) {
|
|
46
|
+
expect(validateIndexEntry({ file_id: fileId, date_bucket: dateBucket, ext, size: 0 })).toBe(false);
|
|
47
|
+
}
|
|
48
|
+
for (const ext of legal) {
|
|
49
|
+
expect(validateIndexEntry({ file_id: fileId, date_bucket: dateBucket, ext, size: 0 })).toBe(true);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("all three boundaries fail closed on non-string runtime values", () => {
|
|
54
|
+
for (const ext of [undefined, null, 7, {}, []]) {
|
|
55
|
+
expect(() => buildStoragePath(fileId, ext as never, { uploadsRoot })).toThrow(/ext .* is invalid/);
|
|
56
|
+
expect(() => pathForExistingBlob(dateBucket, fileId, ext as never, { uploadsRoot })).toThrow(/ext .* is invalid/);
|
|
57
|
+
expect(validateIndexEntry({ file_id: fileId, date_bucket: dateBucket, ext, size: 0 })).toBe(false);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
});
|