@sleep2agi/commhub-server 0.9.0-preview.3 → 0.9.0-preview.30
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 +125 -7
- package/bin/commhub.ts +10 -2
- package/package.json +5 -5
- package/src/ack-create-request-transport.test.ts +159 -0
- package/src/ack-create-request.test.ts +331 -0
- package/src/admin-networks-http.test.ts +84 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +32 -0
- package/src/api-host-supervisors-fallback.test.ts +267 -0
- package/src/api-nodes-shape.test.ts +188 -0
- package/src/auth.ts +93 -14
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/boot-side-effect.test.ts +124 -0
- package/src/config-apply-sec1.test.ts +107 -0
- package/src/config-apply-validate.test.ts +92 -0
- package/src/config-apply-validate.ts +81 -3
- package/src/create-node.ts +48 -1
- 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 +522 -32
- 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 +316 -0
- package/src/external-schedule-edits.ts +314 -0
- package/src/file-download-authz-dev-open.test.ts +135 -0
- package/src/file-download-authz.test.ts +403 -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 +731 -0
- package/src/health-redaction.test.ts +637 -0
- package/src/index.ts +15 -2172
- package/src/lifecycle-guard.ts +50 -0
- package/src/list-host-supervisors.test.ts +314 -0
- package/src/mcp-write-network-resolution.test.ts +429 -0
- package/src/network-name-http.test.ts +71 -0
- package/src/network-scope.ts +101 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +213 -0
- package/src/observer-avatar-http.test.ts +349 -0
- package/src/observer-push.test.ts +176 -0
- package/src/peer-reply-atomic.test.ts +455 -0
- package/src/peer-reply-capability.test.ts +70 -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/push.ts +96 -7
- package/src/require-explicit-test-db.ts +9 -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 +211 -0
- package/src/rest-identity.test.ts +129 -0
- package/src/rest-identity.ts +100 -0
- package/src/rest-projections.ts +67 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/runtime-label.test.ts +22 -0
- package/src/runtime-label.ts +21 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-tasks-http.test.ts +479 -0
- package/src/scheduled-tasks.ts +549 -0
- package/src/send-reply-agent-warning.test.ts +202 -0
- package/src/send-reply-attachments.test.ts +437 -0
- package/src/server.ts +3492 -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/side-thread-command-transport.test.ts +140 -0
- package/src/side-thread-command-transport.ts +309 -0
- package/src/side-thread-http-integration.test.ts +229 -0
- package/src/side-thread-http.ts +504 -0
- package/src/side-thread.test.ts +1038 -0
- package/src/side-thread.ts +2241 -0
- package/src/skillhub-http.test.ts +88 -0
- package/src/stop-delete-node.test.ts +720 -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 +398 -0
- package/src/task-diagnostic-http.test.ts +145 -0
- package/src/task-diagnostic.test.ts +115 -0
- package/src/task-diagnostic.ts +118 -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 +200 -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 +2302 -169
- package/src/update-provider.test.ts +234 -0
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +92 -14
- package/src/uploads.test.ts +66 -0
- package/src/uploads.ts +130 -5
- package/src/vault.test.ts +200 -0
- package/src/vault.ts +156 -0
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,124 @@ 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
|
+
|
|
325
|
+
// RFC-026 §9 / #338 PR2 — daemon self-declare fields promoted from
|
|
326
|
+
// config_snapshot to first-class indexable columns. The hub
|
|
327
|
+
// `list_host_supervisors` MCP tool reads these directly without parsing
|
|
328
|
+
// the snapshot JSON on every list call.
|
|
329
|
+
//
|
|
330
|
+
// Both fields are JSON arrays of strings:
|
|
331
|
+
// runtimes_supported e.g. ["claude-agent-sdk","codex-sdk","grok-build-acp"]
|
|
332
|
+
// allowed_secret_keys e.g. ["ANTHROPIC_API_KEY","OPENAI_API_KEY"]
|
|
333
|
+
//
|
|
334
|
+
// Stored as TEXT (JSON-encoded) — SQLite has no native array type and
|
|
335
|
+
// the wrapper code is small. Both nullable for pre-PR2 daemons; the
|
|
336
|
+
// list endpoint falls back to [] when null.
|
|
337
|
+
try {
|
|
338
|
+
db.exec(`ALTER TABLE nodes ADD COLUMN runtimes_supported TEXT`);
|
|
339
|
+
} catch (e: any) {
|
|
340
|
+
if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
|
|
341
|
+
}
|
|
342
|
+
try {
|
|
343
|
+
db.exec(`ALTER TABLE nodes ADD COLUMN allowed_secret_keys TEXT`);
|
|
344
|
+
} catch (e: any) {
|
|
345
|
+
if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// RFC-027 §2.3 — state machine for stop/delete (D5). 'active' default
|
|
349
|
+
// matches pre-RFC behavior for every existing row. Transitions:
|
|
350
|
+
// active → stopping → stopped → (back to active via restart_node)
|
|
351
|
+
// active → deleting → (row gone)
|
|
352
|
+
// stopped → deleting → (row gone)
|
|
353
|
+
// Used as the inbox-enqueue gate per §2.3 race-free invariant:
|
|
354
|
+
// pushEvent / INSERT INTO inbox must refuse a non-active target so the
|
|
355
|
+
// SIGTERM-in-flight window never gets a new task.
|
|
356
|
+
try {
|
|
357
|
+
db.exec(`ALTER TABLE nodes ADD COLUMN lifecycle_state TEXT DEFAULT 'active'`);
|
|
358
|
+
} catch (e: any) {
|
|
359
|
+
if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// #462 — user-set custom avatar (nullable http/https image URL). Pure
|
|
363
|
+
// display attribute: deliberately NOT part of the RFC-024 config-apply
|
|
364
|
+
// pipeline (nothing for agent-node to consume, no revision/ack). Written
|
|
365
|
+
// only via PUT /api/nodes/:ref/avatar which validates the URL shape.
|
|
366
|
+
try {
|
|
367
|
+
db.exec(`ALTER TABLE nodes ADD COLUMN avatar_url TEXT`);
|
|
368
|
+
} catch (e: any) {
|
|
369
|
+
if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Node DISPLAY attributes (Vincent 目标第 3 条 — dashboard/app 多维度编辑).
|
|
373
|
+
// Same posture as avatar_url above: hub-side presentation only, NOT part of
|
|
374
|
+
// the RFC-024 config-apply pipeline (agent-node consumes none of them, so a
|
|
375
|
+
// doorbell/ack round-trip would be meaningless and could wedge the config
|
|
376
|
+
// revision). Written only via PUT /api/nodes/:ref/attrs.
|
|
377
|
+
//
|
|
378
|
+
// display_name — human label shown in the UI. Distinct from `alias`,
|
|
379
|
+
// which is the ROUTING key (messages address the alias)
|
|
380
|
+
// and from `node_name`, which is an addressing key
|
|
381
|
+
// (`WHERE node_id OR node_name OR alias`). Renaming
|
|
382
|
+
// either of those must go through the rename 2PC
|
|
383
|
+
// (/api/node-rename/*), never through an attrs write.
|
|
384
|
+
// team, tags — organisation metadata; tags stored as JSON array text.
|
|
385
|
+
// attrs_revision — optimistic-lock counter for these display fields ONLY.
|
|
386
|
+
// Deliberately separate from config_revision so that
|
|
387
|
+
// editing a tag never bumps the node's *config* revision
|
|
388
|
+
// (which would perturb the config ack protocol).
|
|
389
|
+
//
|
|
390
|
+
// All nullable / defaulted so the ~100 rows already in flight keep reading.
|
|
391
|
+
for (const ddl of [
|
|
392
|
+
`ALTER TABLE nodes ADD COLUMN display_name TEXT`,
|
|
393
|
+
`ALTER TABLE nodes ADD COLUMN team TEXT`,
|
|
394
|
+
`ALTER TABLE nodes ADD COLUMN tags TEXT`,
|
|
395
|
+
`ALTER TABLE nodes ADD COLUMN attrs_revision INTEGER NOT NULL DEFAULT 0`,
|
|
396
|
+
]) {
|
|
397
|
+
try {
|
|
398
|
+
db.exec(ddl);
|
|
399
|
+
} catch (e: any) {
|
|
400
|
+
if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// RFC-027 §2 — stop/delete request envelope. Mirrors
|
|
405
|
+
// node_create_requests structure so the daemon-side pull/ack pattern
|
|
406
|
+
// is symmetric with create_node. action='stop'|'delete' picks which
|
|
407
|
+
// branch (delete adds backup_path + revokes ntok on ack).
|
|
408
|
+
db.exec(`
|
|
409
|
+
CREATE TABLE IF NOT EXISTS node_stop_requests (
|
|
410
|
+
request_id TEXT PRIMARY KEY,
|
|
411
|
+
network_id TEXT NOT NULL,
|
|
412
|
+
daemon_node_id TEXT NOT NULL,
|
|
413
|
+
child_node_id TEXT NOT NULL,
|
|
414
|
+
child_alias TEXT NOT NULL,
|
|
415
|
+
action TEXT NOT NULL CHECK (action IN ('stop', 'delete')),
|
|
416
|
+
delete_config INTEGER NOT NULL DEFAULT 1,
|
|
417
|
+
grace_seconds INTEGER NOT NULL DEFAULT 10,
|
|
418
|
+
force INTEGER NOT NULL DEFAULT 0,
|
|
419
|
+
in_flight_at_dispatch INTEGER NOT NULL DEFAULT 0,
|
|
420
|
+
created_by_token TEXT NOT NULL,
|
|
421
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
422
|
+
error TEXT,
|
|
423
|
+
backup_path TEXT,
|
|
424
|
+
exit_signal TEXT,
|
|
425
|
+
created_at INTEGER NOT NULL,
|
|
426
|
+
delivered_at INTEGER,
|
|
427
|
+
acked_at INTEGER
|
|
428
|
+
)
|
|
429
|
+
`);
|
|
430
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_stop_req_daemon ON node_stop_requests(daemon_node_id, status)`);
|
|
431
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_stop_req_child ON node_stop_requests(child_node_id)`);
|
|
432
|
+
|
|
304
433
|
// `node_config_updates` — pending + history. One row per dashboard write.
|
|
305
434
|
// At most one row per node may be in a non-terminal state at a time
|
|
306
435
|
// (single-flight enforced at the tool layer; this table just stores).
|
|
@@ -387,6 +516,7 @@ for (const ddl of [
|
|
|
387
516
|
"ALTER TABLE api_tokens ADD COLUMN request_id TEXT",
|
|
388
517
|
"ALTER TABLE api_tokens ADD COLUMN revoked_at TEXT",
|
|
389
518
|
"ALTER TABLE api_tokens ADD COLUMN role TEXT",
|
|
519
|
+
"ALTER TABLE api_tokens ADD COLUMN bound_node_id TEXT",
|
|
390
520
|
]) {
|
|
391
521
|
try { db.exec(ddl); }
|
|
392
522
|
catch (e: any) {
|
|
@@ -395,6 +525,8 @@ for (const ddl of [
|
|
|
395
525
|
}
|
|
396
526
|
try { db.exec(`CREATE INDEX IF NOT EXISTS idx_tokens_request ON api_tokens(request_id)`); }
|
|
397
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; }
|
|
398
530
|
|
|
399
531
|
// RFC-026 v4 §2.5 + §4.4 — node_create_requests. Metadata only;
|
|
400
532
|
// env_blob NEVER lives in this table (F1 mint-stream-evict — secret
|
|
@@ -436,6 +568,76 @@ try {
|
|
|
436
568
|
console.warn(`[commhub] partial unique index uniq_ncr_inflight skipped: ${e?.message || e}`);
|
|
437
569
|
}
|
|
438
570
|
|
|
571
|
+
// RFC-028 P1 v4 §2.2 — providers + provider_models + network_secrets +
|
|
572
|
+
// probe_results. All 4 tables idempotent CREATE TABLE IF NOT EXISTS. F2
|
|
573
|
+
// lazy gate: tables created on hub boot regardless; vault master key
|
|
574
|
+
// (ANET_HUB_SECRET_VAULT_KEY env) only required when these tables get
|
|
575
|
+
// their first non-empty row (lazy in vault.ts). Empty tables = hub
|
|
576
|
+
// boots fine without env (critical for existing prod hub升级 不 brick).
|
|
577
|
+
db.exec(`
|
|
578
|
+
CREATE TABLE IF NOT EXISTS providers (
|
|
579
|
+
provider_id TEXT PRIMARY KEY,
|
|
580
|
+
network_id TEXT NOT NULL,
|
|
581
|
+
name TEXT NOT NULL,
|
|
582
|
+
vendor TEXT NOT NULL,
|
|
583
|
+
base_url TEXT NOT NULL,
|
|
584
|
+
secret_key_ref TEXT NOT NULL,
|
|
585
|
+
created_at INTEGER NOT NULL,
|
|
586
|
+
created_by TEXT NOT NULL,
|
|
587
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
588
|
+
UNIQUE(network_id, name)
|
|
589
|
+
);
|
|
590
|
+
CREATE INDEX IF NOT EXISTS idx_providers_network ON providers(network_id);
|
|
591
|
+
`);
|
|
592
|
+
db.exec(`
|
|
593
|
+
CREATE TABLE IF NOT EXISTS provider_models (
|
|
594
|
+
model_id TEXT PRIMARY KEY,
|
|
595
|
+
provider_id TEXT NOT NULL,
|
|
596
|
+
model_name TEXT NOT NULL,
|
|
597
|
+
display_name TEXT,
|
|
598
|
+
context_window INTEGER,
|
|
599
|
+
supports_vision INTEGER NOT NULL DEFAULT 0,
|
|
600
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
601
|
+
created_at INTEGER NOT NULL,
|
|
602
|
+
UNIQUE(provider_id, model_name)
|
|
603
|
+
);
|
|
604
|
+
CREATE INDEX IF NOT EXISTS idx_models_provider ON provider_models(provider_id);
|
|
605
|
+
`);
|
|
606
|
+
// network_secrets — encrypted-at-rest vault. ciphertext + iv + tag are
|
|
607
|
+
// AES-GCM output; plaintext NEVER touches this table. master key from
|
|
608
|
+
// ANET_HUB_SECRET_VAULT_KEY env (lazy gate in vault.ts).
|
|
609
|
+
db.exec(`
|
|
610
|
+
CREATE TABLE IF NOT EXISTS network_secrets (
|
|
611
|
+
network_id TEXT NOT NULL,
|
|
612
|
+
key TEXT NOT NULL,
|
|
613
|
+
ciphertext BLOB NOT NULL,
|
|
614
|
+
iv BLOB NOT NULL,
|
|
615
|
+
tag BLOB NOT NULL,
|
|
616
|
+
created_at INTEGER NOT NULL,
|
|
617
|
+
updated_at INTEGER NOT NULL,
|
|
618
|
+
PRIMARY KEY (network_id, key)
|
|
619
|
+
);
|
|
620
|
+
`);
|
|
621
|
+
// probe_results — connectivity matrix history. v3 R3 lock: error_label
|
|
622
|
+
// is HUB-DERIVED ONLY from (status, raw_status_code) — daemon CANNOT
|
|
623
|
+
// submit arbitrary error string. status enum mirrors ProbeAckPayload.
|
|
624
|
+
db.exec(`
|
|
625
|
+
CREATE TABLE IF NOT EXISTS probe_results (
|
|
626
|
+
probe_id TEXT PRIMARY KEY,
|
|
627
|
+
provider_id TEXT NOT NULL,
|
|
628
|
+
model_name TEXT NOT NULL,
|
|
629
|
+
daemon_node_id TEXT NOT NULL,
|
|
630
|
+
network_id TEXT NOT NULL,
|
|
631
|
+
status TEXT NOT NULL,
|
|
632
|
+
latency_ms INTEGER,
|
|
633
|
+
error_label TEXT,
|
|
634
|
+
probed_at INTEGER NOT NULL,
|
|
635
|
+
probed_by_user TEXT,
|
|
636
|
+
raw_status_code INTEGER
|
|
637
|
+
);
|
|
638
|
+
CREATE INDEX IF NOT EXISTS idx_probe_matrix ON probe_results(network_id, provider_id, model_name, daemon_node_id, probed_at DESC);
|
|
639
|
+
`);
|
|
640
|
+
|
|
439
641
|
// ── V3: audit_log table ──
|
|
440
642
|
db.exec(`
|
|
441
643
|
CREATE TABLE IF NOT EXISTS audit_log (
|
|
@@ -456,6 +658,37 @@ db.exec(`
|
|
|
456
658
|
CREATE INDEX IF NOT EXISTS idx_audit_network ON audit_log(network_id);
|
|
457
659
|
`);
|
|
458
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
|
+
|
|
459
692
|
// ── V3: licenses table ──
|
|
460
693
|
db.exec(`
|
|
461
694
|
CREATE TABLE IF NOT EXISTS licenses (
|
|
@@ -566,6 +799,14 @@ for (const table of ["sessions", "nodes", "tasks", "inbox", "task_events", "comp
|
|
|
566
799
|
try { db.exec(`ALTER TABLE ${table} ADD COLUMN network_id TEXT`); } catch {}
|
|
567
800
|
}
|
|
568
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
|
+
|
|
569
810
|
// PR-1 (#146): durable node identity on historical inbox/tasks rows. Some
|
|
570
811
|
// existing databases created `tasks` before from_node_id/to_node_id were in the
|
|
571
812
|
// CREATE TABLE statement, so keep these ALTERs explicit and idempotent.
|
|
@@ -852,6 +1093,167 @@ try { db.exec("CREATE INDEX IF NOT EXISTS idx_completions_network ON completions
|
|
|
852
1093
|
// the reply up the chain via parent_task_id.
|
|
853
1094
|
try { db.exec("ALTER TABLE tasks ADD COLUMN parent_task_id TEXT"); } catch {}
|
|
854
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
|
+
// #1181 durable outbound terminal cursor. A task's created_at cannot be used
|
|
1105
|
+
// as a recovery watermark because an old task may become terminal after newer
|
|
1106
|
+
// tasks. Record terminal transitions in their own monotonically increasing
|
|
1107
|
+
// journal instead. The journal is append-only for a logical task; retry keeps
|
|
1108
|
+
// the existing task identity and therefore the same idempotency domain.
|
|
1109
|
+
db.exec(`
|
|
1110
|
+
CREATE TABLE IF NOT EXISTS task_terminal_events (
|
|
1111
|
+
terminal_seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1112
|
+
task_id TEXT NOT NULL UNIQUE,
|
|
1113
|
+
network_id TEXT,
|
|
1114
|
+
from_node_id TEXT,
|
|
1115
|
+
completed_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
1116
|
+
);
|
|
1117
|
+
CREATE INDEX IF NOT EXISTS idx_task_terminal_events_sender_seq
|
|
1118
|
+
ON task_terminal_events(network_id, from_node_id, terminal_seq);
|
|
1119
|
+
`);
|
|
1120
|
+
if (db.dialect === "sqlite") {
|
|
1121
|
+
db.exec(`
|
|
1122
|
+
CREATE TRIGGER IF NOT EXISTS tasks_terminal_event_insert
|
|
1123
|
+
AFTER INSERT ON tasks
|
|
1124
|
+
WHEN NEW.status IN ('replied', 'failed', 'cancelled', 'expired')
|
|
1125
|
+
BEGIN
|
|
1126
|
+
INSERT OR IGNORE INTO task_terminal_events (task_id, network_id, from_node_id, completed_at)
|
|
1127
|
+
VALUES (NEW.task_id, NEW.network_id, NEW.from_node_id, COALESCE(NEW.completed_at, datetime('now')));
|
|
1128
|
+
END;
|
|
1129
|
+
CREATE TRIGGER IF NOT EXISTS tasks_terminal_event_update
|
|
1130
|
+
AFTER UPDATE OF status ON tasks
|
|
1131
|
+
WHEN NEW.status IN ('replied', 'failed', 'cancelled', 'expired')
|
|
1132
|
+
AND OLD.status NOT IN ('replied', 'failed', 'cancelled', 'expired')
|
|
1133
|
+
BEGIN
|
|
1134
|
+
INSERT OR IGNORE INTO task_terminal_events (task_id, network_id, from_node_id, completed_at)
|
|
1135
|
+
VALUES (NEW.task_id, NEW.network_id, NEW.from_node_id, COALESCE(NEW.completed_at, datetime('now')));
|
|
1136
|
+
END;
|
|
1137
|
+
`);
|
|
1138
|
+
db.run(`INSERT OR IGNORE INTO task_terminal_events (task_id, network_id, from_node_id, completed_at)
|
|
1139
|
+
SELECT task_id, network_id, from_node_id, COALESCE(completed_at, created_at)
|
|
1140
|
+
FROM tasks
|
|
1141
|
+
WHERE status IN ('replied', 'failed', 'cancelled', 'expired')
|
|
1142
|
+
ORDER BY COALESCE(completed_at, created_at), task_id`);
|
|
1143
|
+
} else {
|
|
1144
|
+
db.exec(`
|
|
1145
|
+
CREATE OR REPLACE FUNCTION record_task_terminal_event() RETURNS trigger AS $$
|
|
1146
|
+
BEGIN
|
|
1147
|
+
IF NEW.status IN ('replied', 'failed', 'cancelled', 'expired')
|
|
1148
|
+
AND (TG_OP = 'INSERT' OR OLD.status NOT IN ('replied', 'failed', 'cancelled', 'expired')) THEN
|
|
1149
|
+
INSERT INTO task_terminal_events (task_id, network_id, from_node_id, completed_at)
|
|
1150
|
+
VALUES (NEW.task_id, NEW.network_id, NEW.from_node_id, COALESCE(NEW.completed_at, NOW()))
|
|
1151
|
+
ON CONFLICT (task_id) DO NOTHING;
|
|
1152
|
+
END IF;
|
|
1153
|
+
RETURN NEW;
|
|
1154
|
+
END;
|
|
1155
|
+
$$ LANGUAGE plpgsql;
|
|
1156
|
+
DROP TRIGGER IF EXISTS tasks_terminal_event_insert ON tasks;
|
|
1157
|
+
DROP TRIGGER IF EXISTS tasks_terminal_event_update ON tasks;
|
|
1158
|
+
CREATE TRIGGER tasks_terminal_event_insert AFTER INSERT ON tasks
|
|
1159
|
+
FOR EACH ROW EXECUTE FUNCTION record_task_terminal_event();
|
|
1160
|
+
CREATE TRIGGER tasks_terminal_event_update AFTER UPDATE OF status ON tasks
|
|
1161
|
+
FOR EACH ROW EXECUTE FUNCTION record_task_terminal_event();
|
|
1162
|
+
INSERT INTO task_terminal_events (task_id, network_id, from_node_id, completed_at)
|
|
1163
|
+
SELECT task_id, network_id, from_node_id, COALESCE(completed_at, created_at)
|
|
1164
|
+
FROM tasks WHERE status IN ('replied', 'failed', 'cancelled', 'expired')
|
|
1165
|
+
ON CONFLICT (task_id) DO NOTHING;
|
|
1166
|
+
`);
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
// ── Hub scheduled tasks ──────────────────────────────────────────────
|
|
1170
|
+
// Scheduling is a Hub concern: clients manage these rows, while agents only
|
|
1171
|
+
// receive the ordinary inbox/tasks rows produced for each occurrence. Keep
|
|
1172
|
+
// the occurrence table separate so retries, skips and failures remain
|
|
1173
|
+
// auditable without overloading the task lifecycle.
|
|
1174
|
+
db.exec(`
|
|
1175
|
+
CREATE TABLE IF NOT EXISTS scheduled_tasks (
|
|
1176
|
+
schedule_id TEXT PRIMARY KEY,
|
|
1177
|
+
network_id TEXT NOT NULL,
|
|
1178
|
+
created_by TEXT,
|
|
1179
|
+
name TEXT NOT NULL,
|
|
1180
|
+
target_node_id TEXT NOT NULL,
|
|
1181
|
+
target_alias TEXT NOT NULL,
|
|
1182
|
+
task_content TEXT NOT NULL,
|
|
1183
|
+
priority TEXT NOT NULL DEFAULT 'normal',
|
|
1184
|
+
schedule_type TEXT NOT NULL,
|
|
1185
|
+
schedule_json TEXT NOT NULL,
|
|
1186
|
+
timezone TEXT NOT NULL DEFAULT 'UTC',
|
|
1187
|
+
overlap_policy TEXT NOT NULL DEFAULT 'skip',
|
|
1188
|
+
misfire_policy TEXT NOT NULL DEFAULT 'catch_up_once',
|
|
1189
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
1190
|
+
next_run_at TEXT,
|
|
1191
|
+
last_run_at TEXT,
|
|
1192
|
+
revision INTEGER NOT NULL DEFAULT 1,
|
|
1193
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
1194
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
1195
|
+
);
|
|
1196
|
+
|
|
1197
|
+
CREATE INDEX IF NOT EXISTS idx_scheduled_tasks_due
|
|
1198
|
+
ON scheduled_tasks(status, next_run_at);
|
|
1199
|
+
CREATE INDEX IF NOT EXISTS idx_scheduled_tasks_network
|
|
1200
|
+
ON scheduled_tasks(network_id, updated_at DESC);
|
|
1201
|
+
CREATE INDEX IF NOT EXISTS idx_scheduled_tasks_target
|
|
1202
|
+
ON scheduled_tasks(network_id, target_node_id);
|
|
1203
|
+
|
|
1204
|
+
CREATE TABLE IF NOT EXISTS scheduled_task_runs (
|
|
1205
|
+
run_id TEXT PRIMARY KEY,
|
|
1206
|
+
schedule_id TEXT NOT NULL,
|
|
1207
|
+
network_id TEXT NOT NULL,
|
|
1208
|
+
scheduled_for TEXT NOT NULL,
|
|
1209
|
+
task_id TEXT,
|
|
1210
|
+
status TEXT NOT NULL,
|
|
1211
|
+
error_code TEXT,
|
|
1212
|
+
error_message TEXT,
|
|
1213
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
1214
|
+
completed_at TEXT,
|
|
1215
|
+
UNIQUE(schedule_id, scheduled_for)
|
|
1216
|
+
);
|
|
1217
|
+
|
|
1218
|
+
CREATE INDEX IF NOT EXISTS idx_scheduled_runs_schedule
|
|
1219
|
+
ON scheduled_task_runs(schedule_id, created_at DESC);
|
|
1220
|
+
CREATE INDEX IF NOT EXISTS idx_scheduled_runs_network
|
|
1221
|
+
ON scheduled_task_runs(network_id, created_at DESC);
|
|
1222
|
+
`);
|
|
1223
|
+
try { db.exec("ALTER TABLE scheduled_tasks ADD COLUMN misfire_policy TEXT NOT NULL DEFAULT 'catch_up_once'"); } catch {}
|
|
1224
|
+
|
|
1225
|
+
// RFC-036 / B4 — owner-authorized edits of node-host managed schedules.
|
|
1226
|
+
// This is intentionally separate from Hub scheduled_tasks: these rows are
|
|
1227
|
+
// one-shot control intents consumed by the exact token-bound node. The partial
|
|
1228
|
+
// unique index makes the single-flight promise survive multiple Hub workers.
|
|
1229
|
+
db.exec(`
|
|
1230
|
+
CREATE TABLE IF NOT EXISTS external_schedule_edits (
|
|
1231
|
+
intent_id TEXT PRIMARY KEY,
|
|
1232
|
+
network_id TEXT NOT NULL,
|
|
1233
|
+
node_id TEXT NOT NULL,
|
|
1234
|
+
schedule_id TEXT NOT NULL,
|
|
1235
|
+
base_revision INTEGER NOT NULL,
|
|
1236
|
+
patch_json TEXT NOT NULL,
|
|
1237
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
1238
|
+
expires_at INTEGER NOT NULL,
|
|
1239
|
+
created_at INTEGER NOT NULL,
|
|
1240
|
+
delivered_at INTEGER,
|
|
1241
|
+
acked_at INTEGER,
|
|
1242
|
+
created_by_user TEXT NOT NULL,
|
|
1243
|
+
created_by_token TEXT NOT NULL,
|
|
1244
|
+
consumed_by_token TEXT,
|
|
1245
|
+
result_revision INTEGER,
|
|
1246
|
+
error_code TEXT
|
|
1247
|
+
);
|
|
1248
|
+
|
|
1249
|
+
CREATE INDEX IF NOT EXISTS idx_external_schedule_edits_node
|
|
1250
|
+
ON external_schedule_edits(network_id, node_id, status, created_at);
|
|
1251
|
+
CREATE INDEX IF NOT EXISTS idx_external_schedule_edits_owner
|
|
1252
|
+
ON external_schedule_edits(created_by_user, created_at DESC);
|
|
1253
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_external_schedule_edits_singleflight
|
|
1254
|
+
ON external_schedule_edits(node_id, schedule_id)
|
|
1255
|
+
WHERE status IN ('pending', 'delivered');
|
|
1256
|
+
`);
|
|
855
1257
|
try { db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_task_id)"); } catch {}
|
|
856
1258
|
|
|
857
1259
|
// Helpers
|
|
@@ -1160,38 +1562,40 @@ export function chainReplyToParent(
|
|
|
1160
1562
|
const marker = `\n\n[via ${childAlias} 子任务结果]\n${currentReply}`;
|
|
1161
1563
|
const newResult = parent.result ? parent.result + marker : `[via ${childAlias} 子任务结果]\n${currentReply}`;
|
|
1162
1564
|
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
);
|
|
1172
|
-
logTaskEvent(parent.task_id, parent.status, replyStatus, "auto-chain", `from ${childAlias}`);
|
|
1173
|
-
} else {
|
|
1174
|
-
db.run(
|
|
1175
|
-
"UPDATE tasks SET result = ?1, completed_at = datetime('now') WHERE task_id = ?2",
|
|
1176
|
-
[newResult.slice(0, 8000), parent.task_id]
|
|
1177
|
-
);
|
|
1178
|
-
logTaskEvent(parent.task_id, parent.status, parent.status, "auto-chain-append", `from ${childAlias}`);
|
|
1179
|
-
}
|
|
1180
|
-
|
|
1181
|
-
if (parent.from_name && parent.from_name !== "hub" && parent.from_name !== "api") {
|
|
1182
|
-
try {
|
|
1183
|
-
const notifyId = `chain_${crypto.randomUUID().replace(/-/g, "").slice(0, 16)}`;
|
|
1184
|
-
const notifyNode = db.get<{ node_id: string | null }>(
|
|
1185
|
-
"SELECT node_id FROM sessions WHERE alias = ?1 AND COALESCE(network_id, 'default') = COALESCE(?2, 'default') ORDER BY updated_at DESC LIMIT 1",
|
|
1186
|
-
[parent.from_name, parent.network_id ?? null]
|
|
1565
|
+
db.transaction(() => {
|
|
1566
|
+
// Bump parent status to replied if still open. The task transition and
|
|
1567
|
+
// its scheduler-run mirror share this transaction, so a crash cannot
|
|
1568
|
+
// leave one terminal while the other remains delivered.
|
|
1569
|
+
if (parent.status === "delivered" || parent.status === "acked" || parent.status === "running" || parent.status === "created") {
|
|
1570
|
+
db.run(
|
|
1571
|
+
"UPDATE tasks SET status = ?1, result = ?2, completed_at = datetime('now') WHERE task_id = ?3",
|
|
1572
|
+
[replyStatus, newResult.slice(0, 8000), parent.task_id]
|
|
1187
1573
|
);
|
|
1574
|
+
syncScheduledRunForTask(parent.task_id, parent.network_id);
|
|
1575
|
+
logTaskEvent(parent.task_id, parent.status, replyStatus, "auto-chain", `from ${childAlias}`);
|
|
1576
|
+
} else {
|
|
1188
1577
|
db.run(
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
[notifyId, parent.from_name, notifyNode?.node_id ?? null, `[${childAlias} 子任务完成]\n${currentReply.slice(0, 4000)}`, parent.to_name, parent.task_id, parent.network_id ?? null]
|
|
1578
|
+
"UPDATE tasks SET result = ?1, completed_at = datetime('now') WHERE task_id = ?2",
|
|
1579
|
+
[newResult.slice(0, 8000), parent.task_id]
|
|
1192
1580
|
);
|
|
1193
|
-
|
|
1194
|
-
|
|
1581
|
+
logTaskEvent(parent.task_id, parent.status, parent.status, "auto-chain-append", `from ${childAlias}`);
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
if (parent.from_name && parent.from_name !== "hub" && parent.from_name !== "api") {
|
|
1585
|
+
try {
|
|
1586
|
+
const notifyId = `chain_${crypto.randomUUID().replace(/-/g, "").slice(0, 16)}`;
|
|
1587
|
+
const notifyNode = db.get<{ node_id: string | null }>(
|
|
1588
|
+
"SELECT node_id FROM sessions WHERE alias = ?1 AND COALESCE(network_id, 'default') = COALESCE(?2, 'default') ORDER BY updated_at DESC LIMIT 1",
|
|
1589
|
+
[parent.from_name, parent.network_id ?? null]
|
|
1590
|
+
);
|
|
1591
|
+
db.run(
|
|
1592
|
+
`INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, in_reply_to, requires_response, network_id)
|
|
1593
|
+
VALUES (?1, ?2, ?3, 'reply', 'normal', ?4, ?5, ?6, 'none', ?7)`,
|
|
1594
|
+
[notifyId, parent.from_name, notifyNode?.node_id ?? null, `[${childAlias} 子任务完成]\n${currentReply.slice(0, 4000)}`, parent.to_name, parent.task_id, parent.network_id ?? null]
|
|
1595
|
+
);
|
|
1596
|
+
} catch {}
|
|
1597
|
+
}
|
|
1598
|
+
});
|
|
1195
1599
|
|
|
1196
1600
|
// This iteration actually wrote a parent row (and possibly an
|
|
1197
1601
|
// inbox notification). Record that so the caller can gate its
|
|
@@ -1205,12 +1609,98 @@ export function chainReplyToParent(
|
|
|
1205
1609
|
return { chained };
|
|
1206
1610
|
}
|
|
1207
1611
|
|
|
1612
|
+
const SCHEDULED_RUN_TERMINAL_STATUSES = new Set(["replied", "failed", "cancelled", "expired"]);
|
|
1613
|
+
|
|
1614
|
+
type ScheduledTaskLifecycleRow = {
|
|
1615
|
+
task_id: string;
|
|
1616
|
+
network_id: string | null;
|
|
1617
|
+
status: string;
|
|
1618
|
+
completed_at: string | null;
|
|
1619
|
+
};
|
|
1620
|
+
|
|
1621
|
+
type ScheduledTaskBinding = { run_id: string; schedule_id: string };
|
|
1622
|
+
|
|
1623
|
+
/**
|
|
1624
|
+
* Mirror an exact scheduler-created task's lifecycle into its run row.
|
|
1625
|
+
*
|
|
1626
|
+
* The status is always read back from `tasks`; callers cannot supply one.
|
|
1627
|
+
* The scheduler's storage binding is the four-part tuple
|
|
1628
|
+
* (run_id, schedule_id, task_id, network_id). It is read from
|
|
1629
|
+
* `scheduled_task_runs`, not caller-controlled task metadata (which may also
|
|
1630
|
+
* legitimately be replaced by reply attachment metadata).
|
|
1631
|
+
*
|
|
1632
|
+
* Terminal states close the run. `retry_task` calls this after resetting the
|
|
1633
|
+
* same logical task_id to delivered, which deliberately reopens that run so
|
|
1634
|
+
* the history reflects the current retry rather than the first failed attempt.
|
|
1635
|
+
*/
|
|
1636
|
+
export function syncScheduledRunForTask(taskId: string, expectedNetworkId?: string | null): { matched: boolean; status?: string } {
|
|
1637
|
+
const task = db.get<ScheduledTaskLifecycleRow>(
|
|
1638
|
+
`SELECT task_id, network_id, status, completed_at
|
|
1639
|
+
FROM tasks
|
|
1640
|
+
WHERE task_id = ?1
|
|
1641
|
+
AND (?2 IS NULL OR network_id = ?2)`,
|
|
1642
|
+
taskId,
|
|
1643
|
+
expectedNetworkId ?? null,
|
|
1644
|
+
);
|
|
1645
|
+
if (!task?.network_id) return { matched: false };
|
|
1646
|
+
const bindings = db.all<ScheduledTaskBinding>(
|
|
1647
|
+
`SELECT run_id, schedule_id FROM scheduled_task_runs
|
|
1648
|
+
WHERE task_id = ?1 AND network_id = ?2`,
|
|
1649
|
+
task.task_id,
|
|
1650
|
+
task.network_id,
|
|
1651
|
+
);
|
|
1652
|
+
// One task belongs to at most one scheduled occurrence. Fail closed if a
|
|
1653
|
+
// corrupt/legacy database violates that invariant rather than fanning one
|
|
1654
|
+
// lifecycle transition into multiple runs.
|
|
1655
|
+
if (bindings.length !== 1) return { matched: false };
|
|
1656
|
+
const binding = bindings[0];
|
|
1657
|
+
|
|
1658
|
+
if (SCHEDULED_RUN_TERMINAL_STATUSES.has(task.status)) {
|
|
1659
|
+
const errorCode = task.status === "replied" ? null : `task_${task.status}`;
|
|
1660
|
+
const updated = db.run(
|
|
1661
|
+
`UPDATE scheduled_task_runs
|
|
1662
|
+
SET status = ?1,
|
|
1663
|
+
error_code = ?2,
|
|
1664
|
+
error_message = NULL,
|
|
1665
|
+
completed_at = COALESCE(?3, datetime('now'))
|
|
1666
|
+
WHERE run_id = ?4 AND task_id = ?5 AND network_id = ?6 AND schedule_id = ?7`,
|
|
1667
|
+
[task.status, errorCode, task.completed_at, binding.run_id, task.task_id, task.network_id, binding.schedule_id],
|
|
1668
|
+
);
|
|
1669
|
+
return { matched: updated.changes === 1, status: task.status };
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
if (task.status === "delivered") {
|
|
1673
|
+
const updated = db.run(
|
|
1674
|
+
`UPDATE scheduled_task_runs
|
|
1675
|
+
SET status = 'delivered', error_code = NULL, error_message = NULL, completed_at = NULL
|
|
1676
|
+
WHERE run_id = ?1 AND task_id = ?2 AND network_id = ?3 AND schedule_id = ?4`,
|
|
1677
|
+
[binding.run_id, task.task_id, task.network_id, binding.schedule_id],
|
|
1678
|
+
);
|
|
1679
|
+
return { matched: updated.changes === 1, status: task.status };
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
return { matched: false };
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
function taskEventTypeForStatus(toStatus: string): string {
|
|
1686
|
+
switch (toStatus) {
|
|
1687
|
+
case "delivered": return "task.send.delivered";
|
|
1688
|
+
case "acked": return "task.ack";
|
|
1689
|
+
case "running": return "task.started";
|
|
1690
|
+
case "replied": return "task.replied";
|
|
1691
|
+
case "expired": return "task.expired";
|
|
1692
|
+
case "failed":
|
|
1693
|
+
case "cancelled": return "task.failed";
|
|
1694
|
+
default: return `task.status.${toStatus}`;
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1208
1698
|
export function logTaskEvent(taskId: string, fromStatus: string | null, toStatus: string, actor: string, detail?: string) {
|
|
1209
1699
|
try {
|
|
1210
1700
|
db.run(
|
|
1211
|
-
`INSERT INTO task_events (task_id, from_status, to_status, actor, detail, network_id)
|
|
1212
|
-
VALUES (?1, ?2, ?3, ?4, ?5, (SELECT network_id FROM tasks WHERE task_id = ?1))`,
|
|
1213
|
-
[taskId, fromStatus, toStatus, actor, detail ?? null]
|
|
1701
|
+
`INSERT INTO task_events (task_id, from_status, to_status, event_type, actor, detail, network_id)
|
|
1702
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, (SELECT network_id FROM tasks WHERE task_id = ?1))`,
|
|
1703
|
+
[taskId, fromStatus, toStatus, taskEventTypeForStatus(toStatus), actor, detail ?? null]
|
|
1214
1704
|
);
|
|
1215
1705
|
} catch {}
|
|
1216
1706
|
}
|