@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
package/src/server.ts
CHANGED
|
@@ -2,10 +2,10 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
2
2
|
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
3
3
|
import { z } from "zod/v4";
|
|
4
4
|
import { registerTools } from "./tools.js";
|
|
5
|
-
import { db, logTaskEvent, logAudit } from "./db.js";
|
|
5
|
+
import { db, logTaskEvent, logAudit, syncScheduledRunForTask } from "./db.js";
|
|
6
6
|
import { createSSEStream, createNetworkObserverStream, pushEvent, pushNetworkObserverEvent, getSSEStats, PRINTABLE_OBSERVER_KEY_PREFIX } from "./push.js";
|
|
7
7
|
import { assertNodeActive } from "./lifecycle-guard.js";
|
|
8
|
-
import { addNetworkScope, canRestWriteNetwork, getUserNetworkIds, resolveRestNetworkScope, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
|
|
8
|
+
import { addNetworkScope, canRestWriteNetwork, getUserNetworkIds, resolveRestNetworkScope, resolveRestWriteNetworkId, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
|
|
9
9
|
import { validateAvatarUrl } from "./avatar-validate.js";
|
|
10
10
|
import { narrowTags, parseStoredTags, validateScalarAttr } from "./node-attrs-validate.js";
|
|
11
11
|
import { register, login, resolveToken, getUserNetworks, getUserAllNetworks, createNetwork, deleteNetwork, renameNetwork, changePassword, issueUserToken, listTokens, createToken, revokeToken, getNetworkMembers, getUserNetworkRole, addNetworkMember, updateMemberRole, removeNetworkMember, createInvite, joinByInvite, createNetworkTokenForNode, type AuthUser } from "./auth.js";
|
|
@@ -29,10 +29,24 @@ import {
|
|
|
29
29
|
validateAttachments,
|
|
30
30
|
validateIndexEntry,
|
|
31
31
|
} from "./uploads.js";
|
|
32
|
-
import { mkdirSync, writeFileSync, readFileSync, existsSync, statSync } from "fs";
|
|
32
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync, statSync, unlinkSync, renameSync } from "fs";
|
|
33
33
|
import { dirname as pathDirname } from "path";
|
|
34
34
|
import { startRetentionSweeper } from "./retention.js";
|
|
35
35
|
import { startStaleSessionSweeper } from "./stale-sweeper.js";
|
|
36
|
+
import {
|
|
37
|
+
AUDIT_LOG_REST_SELECT,
|
|
38
|
+
COMPLETION_REST_SELECT,
|
|
39
|
+
NETWORK_REST_SELECT,
|
|
40
|
+
SESSION_REST_SELECT,
|
|
41
|
+
TASK_EVENT_REST_SELECT,
|
|
42
|
+
TASK_REST_SELECT,
|
|
43
|
+
} from "./rest-projections.js";
|
|
44
|
+
import { resolveRestFromSession } from "./rest-identity.js";
|
|
45
|
+
import { stampTaskAuthOrigin, type TaskAuthOrigin } from "./task-auth-origin.js";
|
|
46
|
+
import { diagnoseTask } from "./task-diagnostic.js";
|
|
47
|
+
import { assertScheduledTaskBackendSupported, handleScheduledTaskRequest, startScheduledTaskScheduler } from "./scheduled-tasks.js";
|
|
48
|
+
import { handleExternalScheduleEditRequest } from "./external-schedule-edits.js";
|
|
49
|
+
import { recordDeliveredStaleEvents } from "./task-lifecycle-watcher.js";
|
|
36
50
|
|
|
37
51
|
const PORT = Number(process.env.PORT) || 9200;
|
|
38
52
|
const HOST = process.env.HOST || "127.0.0.1";
|
|
@@ -124,19 +138,22 @@ function createServer(clientIP?: string, enforceNetworkId?: string | null, enfor
|
|
|
124
138
|
}
|
|
125
139
|
|
|
126
140
|
// ── Auth helper ─────────────────────────────────────
|
|
127
|
-
|
|
141
|
+
type RequestTokenOptions = { allowQueryToken?: boolean };
|
|
142
|
+
|
|
143
|
+
function requestToken(req: Request, options: RequestTokenOptions = {}): string {
|
|
128
144
|
const header = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
145
|
+
if (options.allowQueryToken === false) return header || "";
|
|
129
146
|
const url = new URL(req.url);
|
|
130
147
|
return header || url.searchParams.get("token") || "";
|
|
131
148
|
}
|
|
132
149
|
|
|
133
|
-
function isLegacyAuthToken(req: Request): boolean {
|
|
134
|
-
const token = requestToken(req);
|
|
150
|
+
function isLegacyAuthToken(req: Request, options: RequestTokenOptions = {}): boolean {
|
|
151
|
+
const token = requestToken(req, options);
|
|
135
152
|
return !!AUTH_TOKEN && token === AUTH_TOKEN;
|
|
136
153
|
}
|
|
137
154
|
|
|
138
|
-
function requireAuth(req: Request): Response | null {
|
|
139
|
-
const token = requestToken(req);
|
|
155
|
+
function requireAuth(req: Request, options: RequestTokenOptions = {}): Response | null {
|
|
156
|
+
const token = requestToken(req, options);
|
|
140
157
|
|
|
141
158
|
// V3: check api_tokens first
|
|
142
159
|
if (token) {
|
|
@@ -209,8 +226,8 @@ function requireTmuxAccess(req: Request, server?: any): Response | null {
|
|
|
209
226
|
}
|
|
210
227
|
|
|
211
228
|
// Extract user + network + token-binding identity from request token.
|
|
212
|
-
function resolveRequestAuth(req: Request): { userId: string; networkId: string | null; username: string; tokenName: string | null; tokenId: string | null } | null {
|
|
213
|
-
const token = requestToken(req);
|
|
229
|
+
function resolveRequestAuth(req: Request, options: RequestTokenOptions = {}): { userId: string; networkId: string | null; username: string; tokenName: string | null; tokenId: string | null } | null {
|
|
230
|
+
const token = requestToken(req, options);
|
|
214
231
|
if (!token) return null;
|
|
215
232
|
const resolved = resolveToken(token);
|
|
216
233
|
if (!resolved) return null;
|
|
@@ -229,14 +246,14 @@ export type Principal =
|
|
|
229
246
|
| { kind: "ntok"; userId: string; boundNetworkId: string }
|
|
230
247
|
| { kind: "admin-utok"; userId: string; username: string };
|
|
231
248
|
|
|
232
|
-
export function resolvePrincipal(req: Request): Principal {
|
|
233
|
-
const authCtx = resolveRequestAuth(req);
|
|
249
|
+
export function resolvePrincipal(req: Request, options: RequestTokenOptions = {}): Principal {
|
|
250
|
+
const authCtx = resolveRequestAuth(req, options);
|
|
234
251
|
if (!authCtx) {
|
|
235
|
-
if (isLegacyAuthToken(req)) return { kind: "legacy-master" };
|
|
252
|
+
if (isLegacyAuthToken(req, options)) return { kind: "legacy-master" };
|
|
236
253
|
if (DEV_OPEN) return { kind: "dev-open-anon" };
|
|
237
254
|
return { kind: "anonymous" };
|
|
238
255
|
}
|
|
239
|
-
const token = requestToken(req);
|
|
256
|
+
const token = requestToken(req, options);
|
|
240
257
|
const resolved = token ? resolveToken(token) : null;
|
|
241
258
|
const isAdmin = !!resolved && resolved.user.role === "admin";
|
|
242
259
|
// Bound network wins over admin classification: a ntok_ issued BY an
|
|
@@ -366,6 +383,26 @@ function parseSqliteTime(value: string | null | undefined): number {
|
|
|
366
383
|
return Number.isFinite(ts) ? ts : 0;
|
|
367
384
|
}
|
|
368
385
|
|
|
386
|
+
function normalizeTaskBeforeCursor(value: string): string | null {
|
|
387
|
+
const cursor = value.trim();
|
|
388
|
+
if (!cursor) return null;
|
|
389
|
+
const sqliteShape = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/;
|
|
390
|
+
if (sqliteShape.test(cursor)) {
|
|
391
|
+
const parsed = new Date(`${cursor.replace(" ", "T")}Z`);
|
|
392
|
+
if (!Number.isFinite(parsed.getTime()) || sqliteTime(parsed) !== cursor) return null;
|
|
393
|
+
return cursor;
|
|
394
|
+
}
|
|
395
|
+
const timestamp = Date.parse(cursor);
|
|
396
|
+
if (!Number.isFinite(timestamp)) return null;
|
|
397
|
+
return sqliteTime(new Date(timestamp));
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function normalizeTaskBeforeTaskId(value: string): string | null {
|
|
401
|
+
const taskId = value.trim();
|
|
402
|
+
if (!taskId || taskId.length > 256 || /[\u0000-\u001f\u007f]/.test(taskId)) return null;
|
|
403
|
+
return taskId;
|
|
404
|
+
}
|
|
405
|
+
|
|
369
406
|
function cpuPct(load: number | null | undefined, cores: number | null | undefined): number | null {
|
|
370
407
|
if (typeof load !== "number" || typeof cores !== "number" || cores <= 0) return null;
|
|
371
408
|
return Math.round((load / cores) * 1000) / 10;
|
|
@@ -491,6 +528,17 @@ function resolveRestNodeIdForAlias(alias: string, networkId: string | null): str
|
|
|
491
528
|
return target.state === "not_found" ? null : (target.session?.node_id ?? null);
|
|
492
529
|
}
|
|
493
530
|
|
|
531
|
+
function withNetworkNameAlias<T extends { network_name?: unknown }>(row: T): T & { name: string | null } {
|
|
532
|
+
return {
|
|
533
|
+
...row,
|
|
534
|
+
// `network_name` is the canonical V3 storage/API field and remains for
|
|
535
|
+
// existing CLI/Dashboard clients. `name` is the documented REST-friendly
|
|
536
|
+
// compatibility alias; never let it drift or serialize as null when the
|
|
537
|
+
// canonical NOT NULL field is present.
|
|
538
|
+
name: typeof row.network_name === "string" ? row.network_name : null,
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
|
|
494
542
|
// ── REST input schema ───────────────────────────────
|
|
495
543
|
const TaskSchema = z.object({
|
|
496
544
|
alias: z.string().min(1).max(200),
|
|
@@ -585,23 +633,45 @@ const wsTmuxIntervals = new Map<object, ReturnType<typeof setInterval>>();
|
|
|
585
633
|
// Registered in startHub() (#476): this WRITES the DB, so it must never
|
|
586
634
|
// run as an import side effect — a tool/test importing this module with
|
|
587
635
|
// COMMHUB_DB unset would patrol the production database.
|
|
588
|
-
function patrolExpiredTasks(): void {
|
|
636
|
+
export function patrolExpiredTasks(): void {
|
|
589
637
|
try {
|
|
590
|
-
const
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
638
|
+
const expired = db.transaction(() => {
|
|
639
|
+
const due = db.all<{ task_id: string; network_id: string | null }>(
|
|
640
|
+
`SELECT task_id, network_id FROM tasks
|
|
641
|
+
WHERE expires_at IS NOT NULL AND expires_at < datetime('now')
|
|
642
|
+
AND status IN ('created', 'delivered')`,
|
|
643
|
+
);
|
|
644
|
+
const changed: Array<{ task_id: string; network_id: string | null }> = [];
|
|
645
|
+
for (const task of due) {
|
|
646
|
+
const result = db.run(
|
|
647
|
+
`UPDATE tasks SET status = 'expired', completed_at = datetime('now')
|
|
648
|
+
WHERE task_id = ?1 AND status IN ('created', 'delivered')`,
|
|
649
|
+
[task.task_id],
|
|
650
|
+
);
|
|
651
|
+
if (result.changes !== 1) continue;
|
|
652
|
+
syncScheduledRunForTask(task.task_id, task.network_id);
|
|
653
|
+
changed.push(task);
|
|
654
|
+
}
|
|
655
|
+
return changed;
|
|
656
|
+
});
|
|
657
|
+
if (expired.length > 0) {
|
|
658
|
+
console.log(`[patrol] expired ${expired.length} stale task(s)`);
|
|
659
|
+
for (const task of expired) logTaskEvent(task.task_id, null, "expired", "patrol");
|
|
601
660
|
}
|
|
602
661
|
} catch {}
|
|
603
662
|
}
|
|
604
663
|
|
|
664
|
+
function patrolDeliveredStaleTasks(): void {
|
|
665
|
+
try {
|
|
666
|
+
const result = recordDeliveredStaleEvents();
|
|
667
|
+
if (result.inserted > 0) {
|
|
668
|
+
console.warn(`[patrol] recorded ${result.inserted} delivered-stale task warning(s)`);
|
|
669
|
+
}
|
|
670
|
+
} catch (error: any) {
|
|
671
|
+
console.error(`[patrol] delivered-stale watcher failed: ${error?.message || error}`);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
605
675
|
// #434 — test-safety seam, same signature as PR #438 so the two branches
|
|
606
676
|
// merge cleanly. Wraps the sole Bun.serve config in a factory so
|
|
607
677
|
// integration tests can request an OS-assigned ephemeral port
|
|
@@ -814,7 +884,9 @@ return Bun.serve({
|
|
|
814
884
|
|
|
815
885
|
// ── V3: License endpoints ──
|
|
816
886
|
if (url.pathname === "/api/license" && req.method === "GET") {
|
|
817
|
-
const license = db.get<any>(
|
|
887
|
+
const license = db.get<any>(
|
|
888
|
+
"SELECT type, expires_at, max_agents, max_networks, max_tasks_day FROM licenses ORDER BY created_at LIMIT 1",
|
|
889
|
+
);
|
|
818
890
|
if (!license) return withCors(req, Response.json({ ok: true, status: "no_license" }));
|
|
819
891
|
const now = new Date().toISOString().replace("T", " ").slice(0, 19);
|
|
820
892
|
const expired = license.expires_at && license.expires_at < now;
|
|
@@ -966,7 +1038,7 @@ return Bun.serve({
|
|
|
966
1038
|
try {
|
|
967
1039
|
const body = await req.json() as any;
|
|
968
1040
|
if (!body.network_id || !body.node_name) return withCors(req, Response.json({ ok: false, error: "network_id and node_name required" }, { status: 400 }));
|
|
969
|
-
const result = createNetworkTokenForNode(resolved.user.user_id, body.network_id, body.node_name);
|
|
1041
|
+
const result = createNetworkTokenForNode(resolved.user.user_id, body.network_id, body.node_name, body.node_id);
|
|
970
1042
|
if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "node_token_created", "network", body.network_id, body.node_name);
|
|
971
1043
|
return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
|
|
972
1044
|
} catch (e: any) {
|
|
@@ -1072,11 +1144,26 @@ return Bun.serve({
|
|
|
1072
1144
|
// V3.13: ntok_ can only see its bound network; utok_ sees all member networks
|
|
1073
1145
|
if (resolved.networkId) {
|
|
1074
1146
|
// ntok_ — only return the bound network
|
|
1075
|
-
const net = db.get<any>(
|
|
1076
|
-
return withCors(req, Response.json({ ok: true, networks: net ? [net] : [] }));
|
|
1077
|
-
}
|
|
1078
|
-
|
|
1079
|
-
|
|
1147
|
+
const net = db.get<any>(`SELECT ${NETWORK_REST_SELECT} FROM networks WHERE network_id = ?1`, resolved.networkId);
|
|
1148
|
+
return withCors(req, Response.json({ ok: true, networks: net ? [withNetworkNameAlias(net)] : [] }));
|
|
1149
|
+
}
|
|
1150
|
+
// Global Hub admins have cross-network authority throughout the REST
|
|
1151
|
+
// surface, so limiting this discovery endpoint to their membership rows
|
|
1152
|
+
// made otherwise-authorized networks impossible to select (#94). Keep
|
|
1153
|
+
// ordinary utok_ callers membership-scoped; ntok_ returned above stays
|
|
1154
|
+
// bound to exactly one network.
|
|
1155
|
+
const networks = resolved.user.role === "admin"
|
|
1156
|
+
? db.all<any>(
|
|
1157
|
+
`SELECT ${NETWORK_REST_SELECT},
|
|
1158
|
+
COALESCE((SELECT nm.role FROM network_members nm
|
|
1159
|
+
WHERE nm.network_id = networks.network_id
|
|
1160
|
+
AND nm.user_id = ?1), 'admin') AS member_role
|
|
1161
|
+
FROM networks ORDER BY created_at`,
|
|
1162
|
+
resolved.user.user_id,
|
|
1163
|
+
)
|
|
1164
|
+
: getUserAllNetworks(resolved.user.user_id);
|
|
1165
|
+
const visibleNetworks = networks.map(withNetworkNameAlias);
|
|
1166
|
+
return withCors(req, Response.json({ ok: true, networks: visibleNetworks }));
|
|
1080
1167
|
}
|
|
1081
1168
|
|
|
1082
1169
|
if (url.pathname === "/api/networks" && req.method === "POST") {
|
|
@@ -1178,7 +1265,7 @@ return Bun.serve({
|
|
|
1178
1265
|
const resolved = resolveToken(token);
|
|
1179
1266
|
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1180
1267
|
const networkId = netDetailMatch[1];
|
|
1181
|
-
const network = db.get<any>(
|
|
1268
|
+
const network = db.get<any>(`SELECT ${NETWORK_REST_SELECT} FROM networks WHERE network_id = ?1`, networkId);
|
|
1182
1269
|
if (!network) return withCors(req, Response.json({ ok: false, error: "network not found" }, { status: 404 }));
|
|
1183
1270
|
// Membership check: must be a member or system admin
|
|
1184
1271
|
const viewerRole = getUserNetworkRole(resolved.user.user_id, networkId);
|
|
@@ -1329,6 +1416,14 @@ return Bun.serve({
|
|
|
1329
1416
|
multi_network: true,
|
|
1330
1417
|
license: license?.type || "none",
|
|
1331
1418
|
uptime: Math.floor(process.uptime()),
|
|
1419
|
+
// #496 — public, stable capability data. Dashboard upload proxies
|
|
1420
|
+
// need the Hub's real limits before opening a streaming request;
|
|
1421
|
+
// duplicating these constants client-side creates a silent
|
|
1422
|
+
// lower-limit drift that the Hub can never observe.
|
|
1423
|
+
limits: {
|
|
1424
|
+
max_upload_bytes: MAX_UPLOAD_BYTES,
|
|
1425
|
+
max_request_content_length: MAX_REQUEST_CONTENT_LENGTH,
|
|
1426
|
+
},
|
|
1332
1427
|
};
|
|
1333
1428
|
if (scopedSessions !== undefined) body.sse_sessions = scopedSessions;
|
|
1334
1429
|
return withCors(req, Response.json(body));
|
|
@@ -1347,6 +1442,33 @@ return Bun.serve({
|
|
|
1347
1442
|
return withCors(req, Response.json({ ok: false, error: restScope.denied }, { status: 403 }));
|
|
1348
1443
|
}
|
|
1349
1444
|
|
|
1445
|
+
// RFC-036 owner-gated host schedule edits. This handler independently
|
|
1446
|
+
// verifies exact node ownership and node-token binding; network admin is
|
|
1447
|
+
// deliberately not an override.
|
|
1448
|
+
const requestCredential = requestToken(req);
|
|
1449
|
+
const externalScheduleEditResponse = await handleExternalScheduleEditRequest({
|
|
1450
|
+
req,
|
|
1451
|
+
url,
|
|
1452
|
+
auth: restAuth,
|
|
1453
|
+
isUserToken: requestCredential.startsWith("utok_"),
|
|
1454
|
+
isNodeToken: requestCredential.startsWith("ntok_"),
|
|
1455
|
+
});
|
|
1456
|
+
if (externalScheduleEditResponse) return withCors(req, externalScheduleEditResponse);
|
|
1457
|
+
|
|
1458
|
+
// Hub-owned scheduled tasks. Dashboard and mobile are management
|
|
1459
|
+
// clients only; each occurrence is dispatched as an ordinary task by the
|
|
1460
|
+
// scheduler started in startHub(). Node tokens are rejected in the
|
|
1461
|
+
// handler so an agent cannot grant itself a persistent execution loop.
|
|
1462
|
+
const scheduledResponse = await handleScheduledTaskRequest({
|
|
1463
|
+
req,
|
|
1464
|
+
url,
|
|
1465
|
+
auth: restAuth,
|
|
1466
|
+
isAdmin,
|
|
1467
|
+
isNodeToken: requestToken(req).startsWith("ntok_"),
|
|
1468
|
+
scope: restScope,
|
|
1469
|
+
});
|
|
1470
|
+
if (scheduledResponse) return withCors(req, scheduledResponse);
|
|
1471
|
+
|
|
1350
1472
|
// ── #473 REST: SSE connection detail (ops-only) ──
|
|
1351
1473
|
// The per-key breakdown /health used to expose anonymously: keys are
|
|
1352
1474
|
// `{networkId}:{alias}` (+ `\0netobs:{networkId}` observer keys via
|
|
@@ -1385,10 +1507,10 @@ return Bun.serve({
|
|
|
1385
1507
|
const params: any[] = [];
|
|
1386
1508
|
let sql = isLight
|
|
1387
1509
|
? "SELECT alias, status, agent, task, server, updated_at, network_id FROM sessions WHERE 1=1"
|
|
1388
|
-
:
|
|
1510
|
+
: `SELECT ${SESSION_REST_SELECT} FROM sessions WHERE 1=1`;
|
|
1389
1511
|
sql = addNetworkScope(sql, params, restScope);
|
|
1390
1512
|
sql += " ORDER BY updated_at DESC";
|
|
1391
|
-
// `model` comes straight from the sessions
|
|
1513
|
+
// `model` comes straight from the explicit sessions projection; `runtime` is
|
|
1392
1514
|
// derived from the raw `agent` field. Both default to null for old nodes
|
|
1393
1515
|
// that never reported a model — the dashboard falls back to a placeholder.
|
|
1394
1516
|
const sessions = db.all(sql, ...params).map((s: any) => {
|
|
@@ -1404,8 +1526,14 @@ return Bun.serve({
|
|
|
1404
1526
|
network_id: s.network_id ?? null,
|
|
1405
1527
|
};
|
|
1406
1528
|
}
|
|
1529
|
+
const externalSchedules = (() => {
|
|
1530
|
+
if (typeof s.external_schedules !== "string") return null;
|
|
1531
|
+
try { return JSON.parse(s.external_schedules); } catch { return null; }
|
|
1532
|
+
})();
|
|
1533
|
+
const { external_schedules: _externalSchedulesJson, ...publicSession } = s;
|
|
1407
1534
|
return {
|
|
1408
|
-
...
|
|
1535
|
+
...publicSession,
|
|
1536
|
+
external_schedules: externalSchedules,
|
|
1409
1537
|
model: s.model ?? null,
|
|
1410
1538
|
runtime: normalizeRuntime(s.agent),
|
|
1411
1539
|
host: {
|
|
@@ -1522,7 +1650,7 @@ return Bun.serve({
|
|
|
1522
1650
|
if (detailKind === "agents") {
|
|
1523
1651
|
const params: any[] = [host, host];
|
|
1524
1652
|
let sql = `
|
|
1525
|
-
SELECT alias, agent, status, task, progress, model, hostname, ip,
|
|
1653
|
+
SELECT alias, agent, status, task, progress, model, hostname, ip, external_schedules,
|
|
1526
1654
|
cpu_load_1min, cpu_cores, mem_avail_gb, mem_used_gb, mem_total_gb,
|
|
1527
1655
|
disk_avail_gb, disk_used_gb, disk_total_gb,
|
|
1528
1656
|
process_rss_bytes, process_rss_mb, process_cpu_pct, process_uptime_seconds, process_in_flight_count,
|
|
@@ -1540,6 +1668,10 @@ return Bun.serve({
|
|
|
1540
1668
|
status: s.status ?? "offline",
|
|
1541
1669
|
task: s.task ?? null,
|
|
1542
1670
|
progress: s.progress ?? 0,
|
|
1671
|
+
external_schedules: (() => {
|
|
1672
|
+
if (typeof s.external_schedules !== "string") return null;
|
|
1673
|
+
try { return JSON.parse(s.external_schedules); } catch { return null; }
|
|
1674
|
+
})(),
|
|
1543
1675
|
last_seen: s.last_seen ?? null,
|
|
1544
1676
|
health: agentHealthChip(s.status, s.last_seen),
|
|
1545
1677
|
hostname: s.hostname ?? null,
|
|
@@ -1786,7 +1918,7 @@ return Bun.serve({
|
|
|
1786
1918
|
}
|
|
1787
1919
|
|
|
1788
1920
|
if (uploadNetId !== null) {
|
|
1789
|
-
const networkRow = db.get<any>("SELECT
|
|
1921
|
+
const networkRow = db.get<any>("SELECT network_id FROM networks WHERE network_id = ?1", uploadNetId);
|
|
1790
1922
|
if (!networkRow) {
|
|
1791
1923
|
if (principal.kind === "admin-utok" || principal.kind === "legacy-master") {
|
|
1792
1924
|
return earlyReject(Response.json({
|
|
@@ -1853,14 +1985,14 @@ return Bun.serve({
|
|
|
1853
1985
|
try {
|
|
1854
1986
|
mkdirSync(pathDirname(storage.absolutePath), { recursive: true });
|
|
1855
1987
|
const buf = Buffer.from(await file.arrayBuffer());
|
|
1856
|
-
|
|
1988
|
+
// #693 — write blob to .tmp then rename for crash-safe durability.
|
|
1989
|
+
const tmpPath = storage.absolutePath + ".tmp";
|
|
1990
|
+
writeFileSync(tmpPath, buf);
|
|
1857
1991
|
// Defense-in-depth: after writing, confirm the path still sits
|
|
1858
1992
|
// inside the uploads root (catches a symlink swap or root env
|
|
1859
1993
|
// poisoning across processes).
|
|
1860
|
-
if (!isPathInsideUploadsRoot(storage.absolutePath)) {
|
|
1861
|
-
|
|
1862
|
-
// Don't try to clean up — better to keep evidence on disk than
|
|
1863
|
-
// chase a symlink for cleanup.
|
|
1994
|
+
if (!isPathInsideUploadsRoot(tmpPath) || !isPathInsideUploadsRoot(storage.absolutePath)) {
|
|
1995
|
+
try { unlinkSync(tmpPath); } catch { /* best-effort */ }
|
|
1864
1996
|
console.error("[/api/upload] write escaped uploads root:", storage.absolutePath);
|
|
1865
1997
|
return withCors(req, Response.json({ ok: false, error: "internal_path_escape" }, { status: 500 }));
|
|
1866
1998
|
}
|
|
@@ -1883,6 +2015,18 @@ return Bun.serve({
|
|
|
1883
2015
|
...(uploadNetId ? { network_id: uploadNetId } : {}),
|
|
1884
2016
|
uploaded_at: new Date().toISOString(),
|
|
1885
2017
|
}, null, 2));
|
|
2018
|
+
} else {
|
|
2019
|
+
// No index path → refuse to leave an unindexed blob.
|
|
2020
|
+
try { unlinkSync(tmpPath); } catch { /* best-effort */ }
|
|
2021
|
+
return withCors(req, Response.json({ ok: false, error: "index_path_error" }, { status: 500 }));
|
|
2022
|
+
}
|
|
2023
|
+
// Index first, then publish blob via rename — if rename fails, drop index.
|
|
2024
|
+
try {
|
|
2025
|
+
renameSync(tmpPath, storage.absolutePath);
|
|
2026
|
+
} catch (renameErr: any) {
|
|
2027
|
+
try { unlinkSync(tmpPath); } catch { /* best-effort */ }
|
|
2028
|
+
try { if (idxPath) unlinkSync(idxPath); } catch { /* best-effort */ }
|
|
2029
|
+
throw renameErr;
|
|
1886
2030
|
}
|
|
1887
2031
|
} catch (e: any) {
|
|
1888
2032
|
console.error("[/api/upload] write failed:", e?.message);
|
|
@@ -1920,7 +2064,11 @@ return Bun.serve({
|
|
|
1920
2064
|
// allow-list inline, or the branches will drift.
|
|
1921
2065
|
const fileMatch = url.pathname.match(/^\/api\/files\/(.+)$/);
|
|
1922
2066
|
if (fileMatch && (req.method === "GET" || req.method === "HEAD")) {
|
|
1923
|
-
|
|
2067
|
+
// #501: file credentials must never travel in the URL. Query tokens
|
|
2068
|
+
// leak through browser history, access/proxy logs, copied links, and
|
|
2069
|
+
// Referer. SSE/EventSource compatibility keeps the global query-token
|
|
2070
|
+
// fallback for now; file GET/HEAD deliberately opt out here.
|
|
2071
|
+
const authErr = requireAuth(req, { allowQueryToken: false });
|
|
1924
2072
|
if (authErr) return withCors(req, authErr);
|
|
1925
2073
|
|
|
1926
2074
|
const fileId = decodeURIComponent(fileMatch[1]);
|
|
@@ -1951,7 +2099,7 @@ return Bun.serve({
|
|
|
1951
2099
|
// "no such file". Both branches return the same 404 shape
|
|
1952
2100
|
// for BOTH GET and HEAD (HEAD honours body-omission but the
|
|
1953
2101
|
// status code and headers are the authoritative signal).
|
|
1954
|
-
if (!authorizeFileDownload(resolvePrincipal(req), normalizeEntry(entry))) {
|
|
2102
|
+
if (!authorizeFileDownload(resolvePrincipal(req, { allowQueryToken: false }), normalizeEntry(entry))) {
|
|
1955
2103
|
return withCors(req, Response.json({ ok: false, error: "not_found" }, { status: 404 }));
|
|
1956
2104
|
}
|
|
1957
2105
|
|
|
@@ -2066,10 +2214,16 @@ return Bun.serve({
|
|
|
2066
2214
|
}
|
|
2067
2215
|
taskNetId = body.network_id;
|
|
2068
2216
|
} else {
|
|
2069
|
-
taskNetId = restAuth
|
|
2217
|
+
taskNetId = restAuth
|
|
2218
|
+
? resolveRestWriteNetworkId(restScope, restAuth, isAdmin)
|
|
2219
|
+
: null;
|
|
2070
2220
|
}
|
|
2071
2221
|
if (restAuth && !taskNetId) {
|
|
2072
|
-
return withCors(req, Response.json({
|
|
2222
|
+
return withCors(req, Response.json({
|
|
2223
|
+
ok: false,
|
|
2224
|
+
error: "network_id_required",
|
|
2225
|
+
message: "network_id is required when the user token has zero or multiple network memberships",
|
|
2226
|
+
}, { status: 400 }));
|
|
2073
2227
|
}
|
|
2074
2228
|
if (!canRestWriteNetwork(restAuth, taskNetId, isAdmin)) {
|
|
2075
2229
|
return withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
|
|
@@ -2088,7 +2242,38 @@ return Bun.serve({
|
|
|
2088
2242
|
}, { status: 404 }));
|
|
2089
2243
|
}
|
|
2090
2244
|
const id = crypto.randomUUID();
|
|
2091
|
-
|
|
2245
|
+
// Identity binding, mirroring the MCP transport (tools.ts defaultFrom /
|
|
2246
|
+
// fromIdentityMismatchReply). Both transports resolve the same
|
|
2247
|
+
// api_tokens row through resolveToken, and auth.ts already documents the
|
|
2248
|
+
// invariant: "Network-bound node tokens are an identity boundary: they
|
|
2249
|
+
// must not spoof another node via from_session."
|
|
2250
|
+
//
|
|
2251
|
+
// MCP enforced it; this REST handler did not — it took `body.from`
|
|
2252
|
+
// verbatim, so any node could POST /api/task with another node's alias
|
|
2253
|
+
// and be recorded (and, once sender labels ship, *rendered in a human's
|
|
2254
|
+
// TUI*) as that node. Same table, same token, one transport hardened and
|
|
2255
|
+
// its sibling not.
|
|
2256
|
+
//
|
|
2257
|
+
// User tokens keep the previous behaviour: the Dashboard legitimately
|
|
2258
|
+
// posts as its logged-in user, and tightening that is a separate change
|
|
2259
|
+
// with its own compatibility surface.
|
|
2260
|
+
// The decision itself lives in rest-identity.ts as a pure function so the
|
|
2261
|
+
// shipped path and the tested path are the same code.
|
|
2262
|
+
const identity = resolveRestFromSession({
|
|
2263
|
+
token: requestToken(req),
|
|
2264
|
+
tokenName: restAuth?.tokenName,
|
|
2265
|
+
requestedFrom: body.from,
|
|
2266
|
+
});
|
|
2267
|
+
if (!identity.ok) {
|
|
2268
|
+
return withCors(req, Response.json({
|
|
2269
|
+
ok: false,
|
|
2270
|
+
error: identity.error,
|
|
2271
|
+
message: identity.message,
|
|
2272
|
+
token_alias: identity.tokenAlias,
|
|
2273
|
+
requested_from_session: identity.requestedFromSession,
|
|
2274
|
+
}, { status: 403 }));
|
|
2275
|
+
}
|
|
2276
|
+
const fromSession = identity.fromSession;
|
|
2092
2277
|
const ttlSeconds = (body as any).ttl_seconds || 3600;
|
|
2093
2278
|
const fromNodeId = resolveRestNodeIdForAlias(fromSession, taskNetId);
|
|
2094
2279
|
const targetNodeId = target.session?.node_id ?? null;
|
|
@@ -2105,7 +2290,13 @@ return Bun.serve({
|
|
|
2105
2290
|
const mergedMeta = attachmentsResult.attachments.length
|
|
2106
2291
|
? { ...((body as any).meta && typeof (body as any).meta === "object" ? (body as any).meta : {}), attachments: attachmentsResult.attachments }
|
|
2107
2292
|
: (body as any).meta;
|
|
2108
|
-
const
|
|
2293
|
+
const rawToken = requestToken(req);
|
|
2294
|
+
const authOrigin: TaskAuthOrigin = rawToken.startsWith("ntok_")
|
|
2295
|
+
? "node"
|
|
2296
|
+
: restAuth
|
|
2297
|
+
? "user"
|
|
2298
|
+
: "legacy";
|
|
2299
|
+
const metaJson = normalizeMetaJson(stampTaskAuthOrigin(mergedMeta, authOrigin));
|
|
2109
2300
|
|
|
2110
2301
|
// #212 dedup guardrail. Mirrors the MCP `send_task` tool: same
|
|
2111
2302
|
// (from_session, target_alias, task) within COMMHUB_SEND_DEDUP_WINDOW_MS
|
|
@@ -2143,8 +2334,8 @@ return Bun.serve({
|
|
|
2143
2334
|
// dispatched via REST (anet demo, dashboard Dispatch button, etc.).
|
|
2144
2335
|
db.transaction(() => {
|
|
2145
2336
|
db.run(
|
|
2146
|
-
`INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, requires_response, network_id, meta_json)
|
|
2147
|
-
VALUES (?1, ?2, ?3, 'task', ?4, ?5, ?6, 'reply', ?7, ?8)`,
|
|
2337
|
+
`INSERT INTO inbox (id, task_id, session_name, node_id, type, priority, content, from_session, requires_response, network_id, meta_json)
|
|
2338
|
+
VALUES (?1, ?1, ?2, ?3, 'task', ?4, ?5, ?6, 'reply', ?7, ?8)`,
|
|
2148
2339
|
[id, targetAlias, targetNodeId, body.priority, body.task, fromSession, taskNetId, metaJson]
|
|
2149
2340
|
);
|
|
2150
2341
|
db.run(
|
|
@@ -2369,7 +2560,7 @@ return Bun.serve({
|
|
|
2369
2560
|
const limit = Math.min(Number(url.searchParams.get("limit")) || 50, 200);
|
|
2370
2561
|
const action = url.searchParams.get("action");
|
|
2371
2562
|
const userId = url.searchParams.get("user_id");
|
|
2372
|
-
let sql =
|
|
2563
|
+
let sql = `SELECT ${AUDIT_LOG_REST_SELECT} FROM audit_log WHERE 1=1`;
|
|
2373
2564
|
const params: any[] = [];
|
|
2374
2565
|
// Non-admin can only see own logs
|
|
2375
2566
|
if (resolved.user.role !== "admin") { sql += ` AND user_id = ?${params.length + 1}`; params.push(resolved.user.user_id); }
|
|
@@ -2385,7 +2576,7 @@ return Bun.serve({
|
|
|
2385
2576
|
if (url.pathname === "/api/task_events") {
|
|
2386
2577
|
const taskId = url.searchParams.get("task_id");
|
|
2387
2578
|
const limit = Math.min(Number(url.searchParams.get("limit")) || 50, 500);
|
|
2388
|
-
let sql =
|
|
2579
|
+
let sql = `SELECT ${TASK_EVENT_REST_SELECT} FROM task_events WHERE 1=1`;
|
|
2389
2580
|
const params: any[] = [];
|
|
2390
2581
|
sql = addNetworkScope(sql, params, restScope);
|
|
2391
2582
|
if (taskId) { sql += ` AND task_id = ?${params.length + 1}`; params.push(taskId); }
|
|
@@ -2400,7 +2591,7 @@ return Bun.serve({
|
|
|
2400
2591
|
if (nodeDeleteMatch && req.method === "DELETE") {
|
|
2401
2592
|
const ref = decodeURIComponent(nodeDeleteMatch[1]);
|
|
2402
2593
|
const params: any[] = [ref, ref, ref];
|
|
2403
|
-
let sql = "SELECT
|
|
2594
|
+
let sql = "SELECT node_id, node_name, alias, network_id FROM nodes WHERE (node_id = ?1 OR node_name = ?2 OR alias = ?3)";
|
|
2404
2595
|
sql = addNetworkScope(sql, params, restScope);
|
|
2405
2596
|
sql += " ORDER BY updated_at DESC LIMIT 1";
|
|
2406
2597
|
const node = db.get<any>(sql, ...params);
|
|
@@ -2823,14 +3014,45 @@ return Bun.serve({
|
|
|
2823
3014
|
if (taskPathMatch && req.method === "GET") {
|
|
2824
3015
|
const taskId = decodeURIComponent(taskPathMatch[1] ?? "");
|
|
2825
3016
|
const params: any[] = [taskId];
|
|
2826
|
-
let sql =
|
|
3017
|
+
let sql = `SELECT ${TASK_REST_SELECT} FROM tasks WHERE task_id = ?1`;
|
|
2827
3018
|
sql = addNetworkScope(sql, params, restScope);
|
|
2828
3019
|
sql += " LIMIT 1";
|
|
2829
3020
|
const task = db.get(sql, ...params);
|
|
2830
3021
|
if (!task) {
|
|
2831
3022
|
return withCors(req, Response.json({ ok: false, error: "task_not_found", task_id: taskId }, { status: 404 }));
|
|
2832
3023
|
}
|
|
2833
|
-
|
|
3024
|
+
const taskNetworkId = typeof task.network_id === "string" ? task.network_id : null;
|
|
3025
|
+
const canonicalTarget = task.to_node_id
|
|
3026
|
+
? null
|
|
3027
|
+
: resolveCanonicalAlias(taskNetworkId, String(task.to_name ?? "")).alias;
|
|
3028
|
+
const targetParams: unknown[] = [];
|
|
3029
|
+
let targetSql = "SELECT alias, status FROM sessions WHERE ";
|
|
3030
|
+
if (task.to_node_id) {
|
|
3031
|
+
targetSql += "node_id = ?1";
|
|
3032
|
+
targetParams.push(task.to_node_id);
|
|
3033
|
+
} else {
|
|
3034
|
+
targetSql += "alias = ?1";
|
|
3035
|
+
targetParams.push(canonicalTarget);
|
|
3036
|
+
}
|
|
3037
|
+
if (taskNetworkId) {
|
|
3038
|
+
targetSql += " AND network_id = ?2";
|
|
3039
|
+
targetParams.push(taskNetworkId);
|
|
3040
|
+
} else {
|
|
3041
|
+
targetSql += " AND network_id IS NULL";
|
|
3042
|
+
}
|
|
3043
|
+
targetSql += " ORDER BY updated_at DESC LIMIT 1";
|
|
3044
|
+
const targetSession = db.get<{ alias: string; status: string }>(targetSql, ...targetParams);
|
|
3045
|
+
const targetAlias = targetSession?.alias ?? canonicalTarget ?? String(task.to_name ?? "");
|
|
3046
|
+
const liveSseConnections = getSSEStats().sessions[`${taskNetworkId || "global"}:${targetAlias}`] ?? 0;
|
|
3047
|
+
const diagnostic = diagnoseTask({
|
|
3048
|
+
status: String(task.status ?? "unknown"),
|
|
3049
|
+
runtimeSubmittedAt: typeof task.runtime_submitted_at === "string" ? task.runtime_submitted_at : null,
|
|
3050
|
+
consumedAt: typeof task.consumed_at === "string" ? task.consumed_at : null,
|
|
3051
|
+
targetSessionStatus: targetSession?.status ?? null,
|
|
3052
|
+
targetSessionExists: Boolean(targetSession),
|
|
3053
|
+
liveSseConnections,
|
|
3054
|
+
});
|
|
3055
|
+
return withCors(req, Response.json({ ok: true, task, diagnostic }));
|
|
2834
3056
|
}
|
|
2835
3057
|
|
|
2836
3058
|
// ── REST: tasks table (V2) ──
|
|
@@ -2839,6 +3061,16 @@ return Bun.serve({
|
|
|
2839
3061
|
const status = url.searchParams.get("status");
|
|
2840
3062
|
const toName = url.searchParams.get("to_name");
|
|
2841
3063
|
const fromName = url.searchParams.get("from_name");
|
|
3064
|
+
const rawBefore = url.searchParams.get("before");
|
|
3065
|
+
const before = rawBefore === null ? null : normalizeTaskBeforeCursor(rawBefore);
|
|
3066
|
+
if (rawBefore !== null && before === null) {
|
|
3067
|
+
return withCors(req, Response.json({ ok: false, error: "invalid_before" }, { status: 400 }));
|
|
3068
|
+
}
|
|
3069
|
+
const rawBeforeTaskId = url.searchParams.get("before_task_id");
|
|
3070
|
+
const beforeTaskId = rawBeforeTaskId === null ? null : normalizeTaskBeforeTaskId(rawBeforeTaskId);
|
|
3071
|
+
if (rawBeforeTaskId !== null && (before === null || beforeTaskId === null)) {
|
|
3072
|
+
return withCors(req, Response.json({ ok: false, error: "invalid_before_task_id" }, { status: 400 }));
|
|
3073
|
+
}
|
|
2842
3074
|
const limit = Math.min(Number(url.searchParams.get("limit")) || 50, 200);
|
|
2843
3075
|
// #248 — opt-out of the stats GROUP-BY scan. The dashboard chat panel
|
|
2844
3076
|
// never consumes `stats`; it's a global per-status table scan that
|
|
@@ -2847,14 +3079,23 @@ return Bun.serve({
|
|
|
2847
3079
|
// {ok, tasks, count, stats} shape — backwards-compatible.
|
|
2848
3080
|
const skipStats = url.searchParams.get("skip_stats") === "1";
|
|
2849
3081
|
|
|
2850
|
-
let sql =
|
|
3082
|
+
let sql = `SELECT ${TASK_REST_SELECT} FROM tasks WHERE 1=1`;
|
|
2851
3083
|
const params: any[] = [];
|
|
2852
3084
|
sql = addNetworkScope(sql, params, restScope);
|
|
2853
3085
|
if (taskId) { sql += ` AND task_id = ?${params.length + 1}`; params.push(taskId); }
|
|
2854
3086
|
if (status) { sql += ` AND status = ?${params.length + 1}`; params.push(status); }
|
|
2855
3087
|
if (toName) { sql += ` AND to_name = ?${params.length + 1}`; params.push(toName); }
|
|
2856
3088
|
if (fromName) { sql += ` AND from_name = ?${params.length + 1}`; params.push(fromName); }
|
|
2857
|
-
|
|
3089
|
+
if (before && beforeTaskId) {
|
|
3090
|
+
const createdAtParam = params.length + 1;
|
|
3091
|
+
const taskIdParam = params.length + 2;
|
|
3092
|
+
sql += ` AND (created_at < ?${createdAtParam} OR (created_at = ?${createdAtParam} AND task_id < ?${taskIdParam}))`;
|
|
3093
|
+
params.push(before, beforeTaskId);
|
|
3094
|
+
} else if (before) {
|
|
3095
|
+
sql += ` AND created_at < ?${params.length + 1}`;
|
|
3096
|
+
params.push(before);
|
|
3097
|
+
}
|
|
3098
|
+
sql += ` ORDER BY created_at DESC, task_id DESC LIMIT ?${params.length + 1}`;
|
|
2858
3099
|
params.push(limit);
|
|
2859
3100
|
|
|
2860
3101
|
const rows = db.all(sql, ...params);
|
|
@@ -2873,7 +3114,7 @@ return Bun.serve({
|
|
|
2873
3114
|
if (url.pathname === "/api/completions") {
|
|
2874
3115
|
const since = url.searchParams.get("since") ?? new Date(Date.now() - 86400000).toISOString();
|
|
2875
3116
|
const params: any[] = [since];
|
|
2876
|
-
let sql =
|
|
3117
|
+
let sql = `SELECT ${COMPLETION_REST_SELECT} FROM completions WHERE completed_at >= ?1`;
|
|
2877
3118
|
sql = addNetworkScope(sql, params, restScope);
|
|
2878
3119
|
sql += " ORDER BY completed_at DESC LIMIT 100";
|
|
2879
3120
|
const rows = db.all(sql, ...params);
|
|
@@ -3023,6 +3264,11 @@ export function startHub(opts?: { port?: number; hostname?: string }): ReturnTyp
|
|
|
3023
3264
|
`For an additional throwaway instance use bootServer({ port: 0 }).`
|
|
3024
3265
|
);
|
|
3025
3266
|
}
|
|
3267
|
+
// The current PostgreSQL adapter opens a fresh process/connection for each
|
|
3268
|
+
// statement, so its transaction() cannot provide the occurrence claim +
|
|
3269
|
+
// task creation atomicity this scheduler requires. Refuse before opening a
|
|
3270
|
+
// listening socket instead of silently running an unsafe scheduler.
|
|
3271
|
+
assertScheduledTaskBackendSupported();
|
|
3026
3272
|
const server = bootServer(opts);
|
|
3027
3273
|
|
|
3028
3274
|
// Round-2/4 review ② — periodic retention sweep + incremental VACUUM.
|
|
@@ -3054,8 +3300,13 @@ export function startHub(opts?: { port?: number; hostname?: string }): ReturnTyp
|
|
|
3054
3300
|
? Number(process.env.COMMHUB_RATELIMIT_SWEEP_MS) : 300000;
|
|
3055
3301
|
const taskPatrolMs = Number(process.env.COMMHUB_TASK_PATROL_MS) > 0
|
|
3056
3302
|
? Number(process.env.COMMHUB_TASK_PATROL_MS) : 5 * 60 * 1000;
|
|
3303
|
+
const deliveredStalePatrolMs = Number(process.env.COMMHUB_DELIVERED_STALE_PATROL_MS) > 0
|
|
3304
|
+
? Number(process.env.COMMHUB_DELIVERED_STALE_PATROL_MS) : 5 * 1000;
|
|
3057
3305
|
const rateLimitSweepTimer = setInterval(sweepStaleRateLimits, rateLimitSweepMs);
|
|
3058
3306
|
const taskPatrolTimer = setInterval(patrolExpiredTasks, taskPatrolMs);
|
|
3307
|
+
patrolDeliveredStaleTasks();
|
|
3308
|
+
const deliveredStalePatrolTimer = setInterval(patrolDeliveredStaleTasks, deliveredStalePatrolMs);
|
|
3309
|
+
const scheduledTaskTimer = startScheduledTaskScheduler();
|
|
3059
3310
|
|
|
3060
3311
|
// The Bun.serve socket is what keeps the process alive; the periodic
|
|
3061
3312
|
// jobs must not — otherwise a test (or a future caller) that stops the
|
|
@@ -3064,6 +3315,8 @@ export function startHub(opts?: { port?: number; hostname?: string }): ReturnTyp
|
|
|
3064
3315
|
(staleSweeperTimer as any)?.unref?.();
|
|
3065
3316
|
(rateLimitSweepTimer as any)?.unref?.();
|
|
3066
3317
|
(taskPatrolTimer as any)?.unref?.();
|
|
3318
|
+
(deliveredStalePatrolTimer as any)?.unref?.();
|
|
3319
|
+
(scheduledTaskTimer as any)?.unref?.();
|
|
3067
3320
|
|
|
3068
3321
|
// ── Graceful shutdown ───────────────────────────────
|
|
3069
3322
|
function shutdown() {
|
|
@@ -3072,6 +3325,8 @@ export function startHub(opts?: { port?: number; hostname?: string }): ReturnTyp
|
|
|
3072
3325
|
clearInterval(staleSweeperTimer);
|
|
3073
3326
|
clearInterval(rateLimitSweepTimer);
|
|
3074
3327
|
clearInterval(taskPatrolTimer);
|
|
3328
|
+
clearInterval(deliveredStalePatrolTimer);
|
|
3329
|
+
clearInterval(scheduledTaskTimer);
|
|
3075
3330
|
db.close();
|
|
3076
3331
|
process.exit(0);
|
|
3077
3332
|
}
|