@agentpulselabs/cli-plugin-pulse 1.0.0 → 1.1.3
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 +55 -0
- package/package.json +7 -4
- package/src/clients/hub-bridge.mjs +99 -0
- package/src/clients/hub.mjs +324 -0
- package/src/commands/pulse/cutover.js +63 -0
- package/src/commands/pulse/doctor.js +1 -0
- package/src/commands/pulse/import.js +62 -0
- package/src/commands/pulse/log-write.js +26 -0
- package/src/commands/pulse/parity.js +20 -0
- package/src/commands/pulse/shadow.js +19 -0
- package/src/daemon/main.js +20 -12
- package/src/hub/core/auth.mjs +80 -0
- package/src/hub/core/changes.mjs +38 -0
- package/src/hub/core/conversations.mjs +121 -0
- package/src/hub/core/db.mjs +263 -0
- package/src/hub/core/edgeIds.mjs +9 -0
- package/src/hub/core/hub.mjs +20 -0
- package/src/hub/core/ids.mjs +39 -0
- package/src/hub/core/leases.mjs +55 -0
- package/src/hub/core/messaging.mjs +322 -0
- package/src/hub/core/registry.mjs +281 -0
- package/src/hub/core/ticks.mjs +58 -0
- package/src/hub/core/wake.mjs +125 -0
- package/src/hub/home.mjs +16 -0
- package/src/hub/hooks/hub-bridge.mjs +167 -0
- package/src/hub/keepawake/agent.mjs +96 -0
- package/src/hub/lib/brief.mjs +133 -0
- package/src/hub/lib/channels.mjs +268 -0
- package/src/hub/lib/chat.mjs +163 -0
- package/src/hub/lib/closure.mjs +92 -0
- package/src/hub/lib/collect-worker.mjs +20 -0
- package/src/hub/lib/collect.mjs +342 -0
- package/src/hub/lib/deliveries.mjs +222 -0
- package/src/hub/lib/git.mjs +93 -0
- package/src/hub/lib/me.mjs +123 -0
- package/src/hub/lib/personas.mjs +143 -0
- package/src/hub/lib/status.mjs +147 -0
- package/src/hub/lib/store.mjs +106 -0
- package/src/hub/lib/titles.mjs +29 -0
- package/src/hub/lib/transcript-feed.mjs +72 -0
- package/src/hub/lib/transcripts.mjs +364 -0
- package/src/hub/lib/writing.mjs +18 -0
- package/src/hub/projections/deliveries.mjs +31 -0
- package/src/hub/projections/graph.mjs +149 -0
- package/src/hub/runners/drivers.mjs +162 -0
- package/src/hub/workers/collector.mjs +128 -0
- package/src/hub/workers/manager.mjs +366 -0
- package/src/hub/workers/runner.mjs +145 -0
- package/src/lib/application.js +82 -34
- package/src/lib/channelScanner.js +99 -0
- package/src/lib/clientInstaller.js +111 -0
- package/src/lib/cloudFirstCommands.js +293 -0
- package/src/lib/contract.sha256 +1 -1
- package/src/lib/cutover.js +411 -0
- package/src/lib/cutoverRequest.js +485 -0
- package/src/lib/cutoverWiring.js +140 -0
- package/src/lib/daemonLock.js +2 -0
- package/src/lib/daemonLoop.js +5 -2
- package/src/lib/daemonProcess.js +57 -6
- package/src/lib/daemonRuntime.js +8 -0
- package/src/lib/doctorWarnings.js +53 -0
- package/src/lib/errors.js +14 -0
- package/src/lib/hubDbReader.js +132 -0
- package/src/lib/hubMapping.js +108 -0
- package/src/lib/hubScan.js +611 -0
- package/src/lib/hubSnapshot.js +64 -0
- package/src/lib/mirrorContract.js +1 -1
- package/src/lib/mirrorService.js +8 -1
- package/src/lib/mirrorSource.js +13 -38
- package/src/lib/nodeFloor.js +19 -0
- package/src/lib/outboxFlusher.js +75 -0
- package/src/lib/pairingLifecycle.js +1 -1
- package/src/lib/reconcile.js +316 -0
- package/src/lib/reconcileView.js +129 -0
- package/src/lib/runnerStore.js +418 -0
- package/src/lib/serviceManager.js +64 -1
- package/src/lib/shadowService.js +435 -0
- package/src/lib/sqlite.js +28 -0
- package/src/lib/tokenStore.js +43 -4
- package/src/lib/transport.js +53 -5
- package/src/lib/transportContract.js +3 -0
- package/src/lib/uploadRedactor.js +102 -0
- package/src/runner/applier.js +297 -0
- package/src/runner/bridge.js +289 -0
- package/src/runner/busIdentity.js +499 -0
- package/src/runner/capture.js +103 -0
- package/src/runner/captureMapping.js +21 -0
- package/src/runner/channelDiscipline.js +425 -0
- package/src/runner/deliveryStates.js +220 -0
- package/src/runner/facade.js +876 -0
- package/src/runner/hubCapture.js +160 -0
- package/src/runner/lease.js +46 -0
- package/src/runner/main.js +35 -0
- package/src/runner/render.js +91 -0
- package/src/runner/runnerChild.js +57 -0
- package/src/runner/runnerHub.js +104 -0
- package/src/runner/runnerProcess.js +124 -0
- package/src/runner/runnerSchema.js +51 -0
- package/src/runner/runnerService.js +149 -0
- package/src/runner/spool.js +86 -0
- package/src/runner/spoolInto.js +22 -0
- package/src/runner/statusPage.js +30 -0
|
@@ -0,0 +1,876 @@
|
|
|
1
|
+
// The local hub API on 127.0.0.1:8890, served by the Pulse runner (design §7, D11). A port of agents-hub
|
|
2
|
+
// server/http.mjs at 6b2fb0b: the same routes, guards, auth and SSE over the same ported core, with these changes:
|
|
3
|
+
// - every write runs in one capture transaction, so the hub rows and their outbox commands commit together, and it
|
|
4
|
+
// commits only while its client is still connected and inside its budget (H2); the answer goes out right after;
|
|
5
|
+
// - `/` redirects to Pulse (an offline status page when the cloud is out of reach); the hub UI is retired;
|
|
6
|
+
// - new routes: POST /hook (handleHook, verbatim), /api/legacy/* (the old hub.mjs subcommands), /runner/health and
|
|
7
|
+
// /runner/metrics;
|
|
8
|
+
// - the manager, the wake sweep and the scheduled nudge run only while this runner holds the user's manager lease;
|
|
9
|
+
// - Pulse 1.1.1: one bus identity per session (busIdentity.js: aliases bound by the hook, mail routed to the session,
|
|
10
|
+
// `GET /bus/who`) and honest delivery states (deliveryStates.js: `delivery` in /bus/publish and /bus/messages,
|
|
11
|
+
// `GET /bus/status`, the manager's FYIs read or skipped, undeliverable notices).
|
|
12
|
+
import http from 'node:http';
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import os from 'node:os';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { Worker } from 'node:worker_threads';
|
|
17
|
+
import { authenticate, bindPrincipal, setAuthCookie } from '../hub/core/auth.mjs';
|
|
18
|
+
import { changeTracker } from '../hub/core/changes.mjs';
|
|
19
|
+
import { withTx } from '../hub/core/db.mjs';
|
|
20
|
+
import { ADDRESS, MESSAGE_ID, ONLINE_MS } from '../hub/core/ids.mjs';
|
|
21
|
+
import { STOPPED_MS, requestWake, scheduledNudge, sweepWakes } from '../hub/core/wake.mjs';
|
|
22
|
+
import { isBusy } from '../hub/core/ticks.mjs';
|
|
23
|
+
import { handleHook } from '../hub/hooks/hub-bridge.mjs';
|
|
24
|
+
import { defaultDrivers, diagnoseClaude } from '../hub/runners/drivers.mjs';
|
|
25
|
+
import { createRunner } from '../hub/workers/runner.mjs';
|
|
26
|
+
import { readTaskBrief } from '../hub/lib/brief.mjs';
|
|
27
|
+
import { decisionPost, parsePosts } from '../hub/lib/channels.mjs';
|
|
28
|
+
import { STEP_IDS, auditPost, movePost, overrideHolds, overrideKey, overrideText, statusRequest, stepHolds, withOverride, workMoved } from '../hub/lib/status.mjs';
|
|
29
|
+
import { askDetail, channelParticipant, hasChatInbox, listChannels, participantPost, participantThread, postToProject, projectMessages, taskMessages } from '../hub/lib/chat.mjs';
|
|
30
|
+
import { findAgentQuery } from '../hub/lib/transcripts.mjs';
|
|
31
|
+
import { watchTranscripts } from '../hub/lib/transcript-feed.mjs';
|
|
32
|
+
import { HUB, acceptDelivery, keepAwakeStatus, readSettings, validSettings, writeSettings } from '../hub/lib/store.mjs';
|
|
33
|
+
import { createManager } from '../hub/workers/manager.mjs';
|
|
34
|
+
import { NOBODY, aliasesFor } from '../hub/lib/me.mjs';
|
|
35
|
+
import { personaUsernames, voiceFor } from '../hub/lib/personas.mjs';
|
|
36
|
+
import { projectDeliveries } from '../hub/projections/deliveries.mjs';
|
|
37
|
+
import { projectGraph } from '../hub/projections/graph.mjs';
|
|
38
|
+
import { execFileBounded } from '../lib/subprocess.js';
|
|
39
|
+
import { createBusIdentity, routedMessaging } from './busIdentity.js';
|
|
40
|
+
import { createChannelDiscipline } from './channelDiscipline.js';
|
|
41
|
+
import { createDeliveryStates } from './deliveryStates.js';
|
|
42
|
+
import { statusPage } from './statusPage.js';
|
|
43
|
+
|
|
44
|
+
const WORKSPACE = path.resolve(HUB, '..');
|
|
45
|
+
const CHANNEL_ROOTS = ['AgentPulseApps', 'Google_Cloud'].map((d) => path.join(WORKSPACE, d));
|
|
46
|
+
const HOURS = [1, 3, 6, 12, 24, 72, 168];
|
|
47
|
+
const CACHE_MS = 4000;
|
|
48
|
+
const CHANGE_DEBOUNCE_MS = 600;
|
|
49
|
+
/** Steady writes (heartbeats every few hundred ms) keep restarting the debounce; a change still goes out this soon. */
|
|
50
|
+
const CHANGE_MAX_WAIT_MS = 2000;
|
|
51
|
+
/** A graph read re-registers the user at most this often: often enough to stay online, rarely enough not to wake the change feed on every read. */
|
|
52
|
+
const USER_SEEN_MS = ONLINE_MS / 3;
|
|
53
|
+
const CHAT_CHANNELS_MS = 48 * 3600e3;
|
|
54
|
+
const CHAT_HISTORY_MS = 7 * 24 * 3600e3;
|
|
55
|
+
const MAX_BODY_BYTES = 20e3;
|
|
56
|
+
/** The hook client sends only the fields handleHook reads; a long agent answer still fits. */
|
|
57
|
+
const HOOK_BODY_BYTES = 64e3;
|
|
58
|
+
/** The hook client gives up after 2 s; the runner rolls back past this, so it never commits mail nobody saw. */
|
|
59
|
+
export const HOOK_BUDGET_MS = 1500;
|
|
60
|
+
/** Other writes: well inside the 8 s the CLI client waits. */
|
|
61
|
+
export const WRITE_BUDGET_MS = 5000;
|
|
62
|
+
const LISTEN_HEARTBEAT_MS = 5000;
|
|
63
|
+
const LOCAL_HOST = /^(127\.0\.0\.1|localhost)(:\d+)?$/;
|
|
64
|
+
const LOCAL_ORIGIN = /^http:\/\/(127\.0\.0\.1|localhost)(:\d+)?$/;
|
|
65
|
+
const NO_FRAME = { 'x-frame-options': 'DENY', 'content-security-policy': "frame-ancestors 'none'", 'x-content-type-options': 'nosniff' };
|
|
66
|
+
|
|
67
|
+
const sendJson = (res, body, code = 200, extra = {}) => {
|
|
68
|
+
res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store', ...NO_FRAME, ...extra });
|
|
69
|
+
res.end(JSON.stringify(body));
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
function trustedWrite(req) {
|
|
73
|
+
const origin = req.headers.origin;
|
|
74
|
+
if (origin && !LOCAL_ORIGIN.test(origin)) return false;
|
|
75
|
+
const site = req.headers['sec-fetch-site'];
|
|
76
|
+
if (site && site !== 'same-origin' && site !== 'none') return false;
|
|
77
|
+
return String(req.headers['content-type'] || '').startsWith('application/json');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function readBody(req, res, done, maxBytes = MAX_BODY_BYTES) {
|
|
81
|
+
req.setEncoding('utf8');
|
|
82
|
+
let raw = '';
|
|
83
|
+
let bytes = 0;
|
|
84
|
+
let over = false;
|
|
85
|
+
req.on('data', (c) => {
|
|
86
|
+
if (over) return;
|
|
87
|
+
bytes += Buffer.byteLength(c);
|
|
88
|
+
if (bytes <= maxBytes) { raw += c; return; }
|
|
89
|
+
over = true;
|
|
90
|
+
sendJson(res, { error: 'message too large' }, 413, { connection: 'close' });
|
|
91
|
+
req.pause();
|
|
92
|
+
});
|
|
93
|
+
req.on('end', () => {
|
|
94
|
+
if (over) return;
|
|
95
|
+
let body;
|
|
96
|
+
try { body = JSON.parse(raw || '{}'); } catch { sendJson(res, { error: 'bad json' }, 400); return; }
|
|
97
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) { sendJson(res, { error: 'bad json' }, 400); return; }
|
|
98
|
+
try { done(body); } catch (e) { console.error(`write failed: ${e && e.stack ? e.stack : e}`); if (!res.headersSent) sendJson(res, { error: 'server error' }, 500); }
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** A write that may not commit: its client left, or it ran past its budget. Nothing it did is kept. */
|
|
103
|
+
class Refused extends Error {}
|
|
104
|
+
|
|
105
|
+
/** The Cursor session a `bus listen`, `ack` or `reply` ran in (the CLI sends CURSOR_CONVERSATION_ID), or null. */
|
|
106
|
+
const callerSession = (body) => (ADDRESS.test(String(body.session || '')) ? body.session : null);
|
|
107
|
+
|
|
108
|
+
/** A listen or ack as an alias a live session holds, from anyone but that session: refused before it takes anything. */
|
|
109
|
+
const heldAnswer = (alias, held) => ({
|
|
110
|
+
error: 'held', holder: held.session, idleMin: held.idleMin,
|
|
111
|
+
message: `${alias} is held by live session ${held.session}, which gets its mail. Only that session can listen or reply as ${alias}.`,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
/** Pulse 1.1.2 (E1): mail to an address the bus doesn't know is refused, with the closest ones it does. */
|
|
115
|
+
const unknownAddress = (address, didYouMean) => ({
|
|
116
|
+
error: 'E_UNKNOWN_ADDRESS', address, didYouMean,
|
|
117
|
+
message: `agent/${address} is not a known session id or bound alias, so nobody could ever read this.${didYouMean.length ? ` Did you mean ${didYouMean.map((d) => `agent/${d.address}`).join(', ')}?` : ''} A session is known once it has run a tool; a nickname needs \`bus register --as <nickname>\` from inside that session. If it is a session or alias on another machine, retry in a minute: its presence takes about 30 s to reach this one.`,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
/** The outbox is at its cap (§6.2): the write is refused, said so, and never spooled (507). */
|
|
121
|
+
const backlogged = (e) => e && e.code === 'pulse_backlog';
|
|
122
|
+
const BACKLOG = { error: 'pulse_backlog' };
|
|
123
|
+
|
|
124
|
+
/** The request stream itself is destroyed once its body is read; what matters is the socket the answer goes back on. */
|
|
125
|
+
const connected = (req, res) => {
|
|
126
|
+
const socket = res.socket || req.socket;
|
|
127
|
+
return !!socket && !socket.destroyed && !res.writableEnded && !res.closed;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/** Capture over a plain hub (tests that serve a bare openHub): the same transaction and guard, no outbox. */
|
|
131
|
+
export function plainCapture(db) {
|
|
132
|
+
return {
|
|
133
|
+
tx: (fn, { guard = null, label = 'write' } = {}) => withTx(db, () => { const out = fn(); if (guard) guard(); return out; }, label),
|
|
134
|
+
applying: (fn) => fn(),
|
|
135
|
+
pump: () => 0,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const BRIEF_CACHE_MS = 30e3;
|
|
140
|
+
const briefCache = new Map();
|
|
141
|
+
|
|
142
|
+
export function channelFileFor(key, roots = CHANNEL_ROOTS) {
|
|
143
|
+
const m = String(key || '').match(/^([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)$/);
|
|
144
|
+
if (!m || m[1].startsWith('.') || m[2].startsWith('.')) return null;
|
|
145
|
+
for (const root of roots) {
|
|
146
|
+
const file = path.join(root, m[1], '.agents', m[2], 'channel.md');
|
|
147
|
+
try {
|
|
148
|
+
const st = fs.lstatSync(file);
|
|
149
|
+
if (!st.isFile()) continue;
|
|
150
|
+
const realRoot = fs.realpathSync(root);
|
|
151
|
+
if (fs.realpathSync(file) !== path.join(realRoot, m[1], '.agents', m[2], 'channel.md')) continue;
|
|
152
|
+
return file;
|
|
153
|
+
} catch { /* not here */ }
|
|
154
|
+
}
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function taskBriefFor(key, roots) {
|
|
159
|
+
const hit = briefCache.get(key);
|
|
160
|
+
if (hit && Date.now() - hit.at < BRIEF_CACHE_MS) return hit.body;
|
|
161
|
+
const file = channelFileFor(key, roots);
|
|
162
|
+
if (!file) return null;
|
|
163
|
+
const fromFiles = readTaskBrief(file);
|
|
164
|
+
const thin = !fromFiles || !fromFiles.sections.some((s) => s.body && s.body.trim());
|
|
165
|
+
const body = thin ? (readTaskBrief(file, { query: findAgentQuery(key) }) || fromFiles || { title: null, source: null, sections: [] }) : fromFiles;
|
|
166
|
+
if (briefCache.size > 200) briefCache.clear();
|
|
167
|
+
briefCache.set(key, { at: Date.now(), body });
|
|
168
|
+
return body;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** How many posts a channel holds now: the position of the one just appended. */
|
|
172
|
+
const postsIn = (file) => parsePosts(fs.readFileSync(file, 'utf8'), Date.now()).length;
|
|
173
|
+
|
|
174
|
+
/** Append to a channel file checked by channelFileFor; refuses if the path was swapped between the check and the open. */
|
|
175
|
+
export function appendNoFollow(file, text) {
|
|
176
|
+
const fd = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_NOFOLLOW);
|
|
177
|
+
try {
|
|
178
|
+
const opened = fs.fstatSync(fd);
|
|
179
|
+
const real = fs.statSync(fs.realpathSync(file));
|
|
180
|
+
if (!opened.isFile() || opened.ino !== real.ino || opened.dev !== real.dev) throw new Error(`channel file moved: ${file}`);
|
|
181
|
+
fs.writeSync(fd, text);
|
|
182
|
+
} finally { fs.closeSync(fd); }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function collectWorker() {
|
|
186
|
+
const worker = new Worker(new URL('../hub/lib/collect-worker.mjs', import.meta.url));
|
|
187
|
+
const waiting = new Map();
|
|
188
|
+
let seq = 0;
|
|
189
|
+
worker.on('message', ({ id, body, error }) => {
|
|
190
|
+
const w = waiting.get(id);
|
|
191
|
+
waiting.delete(id);
|
|
192
|
+
if (w) { if (error) w.reject(new Error(error)); else w.resolve(body); }
|
|
193
|
+
});
|
|
194
|
+
worker.on('error', (e) => { console.error(`collector crashed: ${e && e.stack ? e.stack : e}`); process.exit(1); });
|
|
195
|
+
return (hours, managerView, aliases, { discoveryEpoch } = {}) => {
|
|
196
|
+
const id = ++seq;
|
|
197
|
+
const running = new Promise((resolve, reject) => waiting.set(id, { resolve, reject }));
|
|
198
|
+
worker.postMessage({ id, hours, managerView, aliases, discoveryEpoch });
|
|
199
|
+
return running;
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function collector(run) {
|
|
204
|
+
const cache = new Map();
|
|
205
|
+
/** Bumped by clear() and reproject(): a collect started before a change must not cache its older body. */
|
|
206
|
+
let generation = 0;
|
|
207
|
+
/** Bumped by clear() only: the collector may reuse a discovery pass from the same epoch (workers/collector.mjs). */
|
|
208
|
+
let discoveryEpoch = 0;
|
|
209
|
+
return {
|
|
210
|
+
graph(hours, managerView, aliases = []) {
|
|
211
|
+
const hit = cache.get(hours);
|
|
212
|
+
if (hit && hit.body && Date.now() - hit.at < CACHE_MS) return Promise.resolve(hit.body);
|
|
213
|
+
if (hit && hit.running) return hit.running;
|
|
214
|
+
const started = generation;
|
|
215
|
+
const running = Promise.resolve(run(hours, managerView, aliases, { discoveryEpoch }))
|
|
216
|
+
.then((body) => { if (started === generation) cache.set(hours, { at: Date.now(), body }); return body; })
|
|
217
|
+
.catch((e) => { if (started === generation) cache.delete(hours); throw e; });
|
|
218
|
+
cache.set(hours, { ...(hit || {}), running });
|
|
219
|
+
return running;
|
|
220
|
+
},
|
|
221
|
+
clear() { generation++; discoveryEpoch++; cache.clear(); },
|
|
222
|
+
/** Presence moved and nothing else: refresh the bodies without a new discovery pass. */
|
|
223
|
+
reproject() { generation++; cache.clear(); },
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** A runner.db write; it becomes a `hub` change only when the database holds news (core/changes.mjs). */
|
|
228
|
+
const DB_WRITE = 'db-write';
|
|
229
|
+
|
|
230
|
+
function watchRecursive(dir, onFile, onError) {
|
|
231
|
+
return fs.watch(dir, { recursive: true }, (e, f) => { if (f) onFile(String(f)); }).on('error', onError);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function changeFeed(onChange, { stateDir, dbFile, channelRoots, transcriptsDir, hubChanged, watchDir }) {
|
|
235
|
+
const clients = new Set();
|
|
236
|
+
const pending = new Set();
|
|
237
|
+
let timer = null;
|
|
238
|
+
let firstPendingAt = null;
|
|
239
|
+
const dbNews = () => { try { return hubChanged(); } catch (e) { console.error(`change check failed: ${e.message}`); return 'hub'; } };
|
|
240
|
+
const flush = () => {
|
|
241
|
+
firstPendingAt = null;
|
|
242
|
+
if (pending.delete(DB_WRITE)) { const news = dbNews(); if (news) pending.add(news); }
|
|
243
|
+
if (pending.has('hub')) pending.delete('presence');
|
|
244
|
+
const kinds = [...pending];
|
|
245
|
+
pending.clear();
|
|
246
|
+
if (!kinds.length) return;
|
|
247
|
+
onChange(kinds);
|
|
248
|
+
const msg = `event: change\ndata: ${JSON.stringify({ kinds, at: Date.now() })}\n\n`;
|
|
249
|
+
for (const res of clients) res.write(msg);
|
|
250
|
+
};
|
|
251
|
+
const notify = (kind) => {
|
|
252
|
+
pending.add(kind);
|
|
253
|
+
clearTimeout(timer);
|
|
254
|
+
firstPendingAt ??= Date.now();
|
|
255
|
+
timer = setTimeout(flush, Math.min(CHANGE_DEBOUNCE_MS, Math.max(0, firstPendingAt + CHANGE_MAX_WAIT_MS - Date.now())));
|
|
256
|
+
};
|
|
257
|
+
const watchers = [];
|
|
258
|
+
const watch = (dir, kindOf) => {
|
|
259
|
+
const failed = (err) => console.error(`cannot watch ${dir}: ${err.message}; that part falls back to polling`);
|
|
260
|
+
try { watchers.push(watchDir(dir, (f) => { const kind = kindOf(f); if (kind) notify(kind); }, failed)); }
|
|
261
|
+
catch (err) { failed(err); }
|
|
262
|
+
};
|
|
263
|
+
const dbName = path.basename(dbFile || 'hub.db');
|
|
264
|
+
for (const root of channelRoots) watch(root, (f) => (/\.agents[\\/][^\\/]+[\\/](channel|brief)\.md$/.test(f) ? 'channels' : null));
|
|
265
|
+
watch(stateDir, (f) => (/(events\.jsonl|requests|chat[\\/]|inbox[\\/]|bus[\\/]|accepted\.json|personas\.json)/.test(f) ? 'hub' : null));
|
|
266
|
+
if (dbFile) watch(path.dirname(dbFile), (f) => (f.startsWith(dbName) ? DB_WRITE : null));
|
|
267
|
+
if (transcriptsDir) watchTranscripts(transcriptsDir, () => notify('agents'));
|
|
268
|
+
const keepAlive = setInterval(() => { for (const res of clients) res.write(': keep-alive\n\n'); }, 25e3);
|
|
269
|
+
keepAlive.unref();
|
|
270
|
+
return {
|
|
271
|
+
attach(req, res) {
|
|
272
|
+
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store', connection: 'keep-alive', ...NO_FRAME });
|
|
273
|
+
res.write(`event: hello\ndata: ${JSON.stringify({ at: Date.now() })}\n\n`);
|
|
274
|
+
clients.add(res);
|
|
275
|
+
req.on('close', () => clients.delete(res));
|
|
276
|
+
},
|
|
277
|
+
/** A write that landed here or a sync page that was applied: same as a hub.db file change before. */
|
|
278
|
+
dbWrite: () => notify(DB_WRITE),
|
|
279
|
+
notify,
|
|
280
|
+
close() { clearTimeout(timer); clearInterval(keepAlive); for (const w of watchers) w.close(); for (const res of clients) res.end(); },
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function settingsOf(hub) {
|
|
285
|
+
const fromDb = hub.registry.fact('settings', 'hub');
|
|
286
|
+
const s = fromDb ? validSettings(fromDb) : readSettings();
|
|
287
|
+
return { ...s, keepAwakeStatus: keepAwakeStatus() };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const hasTable = (db, name) => Boolean(db.prepare("SELECT 1 AS found FROM sqlite_master WHERE type = 'table' AND name = ?").get(name));
|
|
291
|
+
|
|
292
|
+
/** Without a cloud lease (tests, a bare hub) this runner is the only one, as the local hub was. */
|
|
293
|
+
const SOLE_LEASE = Object.freeze({ held: () => true, view: () => ({ holder: 'local', here: true }), onChange: () => {} });
|
|
294
|
+
|
|
295
|
+
export function serve({
|
|
296
|
+
hub, capture = plainCapture(hub.db), port = 8890, listenHost = '127.0.0.1', stateDir = path.join(HUB, 'state'), dbFile = null,
|
|
297
|
+
enableCollector = true, enableManager = true, collect = null, channelRoots = CHANNEL_ROOTS,
|
|
298
|
+
transcriptsDir = path.join(os.homedir(), '.cursor', 'projects'), watchDir = watchRecursive, identity = null,
|
|
299
|
+
lease = SOLE_LEASE, pulseUrl = null, health = () => ({ state: 'online' }), metrics = () => ({}), drivers = null,
|
|
300
|
+
hookBudgetMs = HOOK_BUDGET_MS, writeBudgetMs = WRITE_BUDGET_MS, onWrite = () => {}, managerOptions = {}, workspaceRoot = WORKSPACE,
|
|
301
|
+
discipline: disciplineOptions = {},
|
|
302
|
+
} = {}) {
|
|
303
|
+
const me = () => {
|
|
304
|
+
const m = identity ? identity.current() : NOBODY;
|
|
305
|
+
return m === NOBODY ? NOBODY : { ...m, aliases: aliasesFor(m, personaUsernames()) };
|
|
306
|
+
};
|
|
307
|
+
const graphs = enableCollector ? collector(collect || collectWorker()) : { graph: async () => ({ nodes: [], links: [], events: [], deliveries: [], agents: [], totals: {} }), clear() {}, reproject() {} };
|
|
308
|
+
const caches = { clear: () => graphs.clear() };
|
|
309
|
+
const feed = enableCollector
|
|
310
|
+
? changeFeed((kinds) => (kinds.every((k) => k === 'presence') ? graphs.reproject() : graphs.clear()), { stateDir, dbFile, channelRoots, transcriptsDir, hubChanged: changeTracker(hub.db), watchDir })
|
|
311
|
+
: { attach(req, res) { res.writeHead(200); res.end(); }, dbWrite() {}, notify() {}, close() {} };
|
|
312
|
+
if (identity) {
|
|
313
|
+
identity.listen(() => { graphs.clear(); feed.notify('hub'); });
|
|
314
|
+
identity.refresh();
|
|
315
|
+
}
|
|
316
|
+
const bus = createBusIdentity(hub);
|
|
317
|
+
const delivery = createDeliveryStates(hub, bus);
|
|
318
|
+
const { sweepMs: disciplineSweepMs = 60e3, ...thresholds } = disciplineOptions;
|
|
319
|
+
const discipline = createChannelDiscipline(hub, {
|
|
320
|
+
channelRoots, workspaceRoot, append: appendNoFollow, ...thresholds,
|
|
321
|
+
publish: (message) => hub.messaging.publish(message, hub.registry.directory()),
|
|
322
|
+
});
|
|
323
|
+
const manager = enableManager
|
|
324
|
+
? createManager({ hub, state: stateDir, onChange: () => graphs.clear(), held: () => lease.held(), fyi: (m) => delivery.managerFyi(m), ...managerOptions })
|
|
325
|
+
: { start() {}, stop() {}, view() { return { state: 'offline', online: false, pending: 0 }; } };
|
|
326
|
+
/** The manager's status, with the DONE and STATUS posts it has read (F2). */
|
|
327
|
+
const managerView = () => ({ ...manager.view(), reports: delivery.reports() });
|
|
328
|
+
const holder = `server-${process.pid}`;
|
|
329
|
+
let serverLease = { error: 'pending' };
|
|
330
|
+
for (let i = 0; i < 12; i++) {
|
|
331
|
+
serverLease = hub.leases.acquire('server', holder, { ttlMs: 30e3, force: i >= 11 });
|
|
332
|
+
if (!serverLease.error) break;
|
|
333
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 250);
|
|
334
|
+
}
|
|
335
|
+
if (serverLease.error) console.error(`another hub server holds the lease (${serverLease.holder}); taking over`);
|
|
336
|
+
const timers = [];
|
|
337
|
+
const every = (ms, fn) => { const t = setInterval(fn, ms); t.unref(); timers.push(t); };
|
|
338
|
+
if (serverLease.token) {
|
|
339
|
+
every(10e3, () => { try { hub.leases.renew('server', serverLease.token, { ttlMs: 30e3 }); } catch (e) { console.error(`server lease renewal failed: ${e.message}`); } });
|
|
340
|
+
}
|
|
341
|
+
const releaseLease = () => { if (serverLease.token) { try { hub.leases.release('server', serverLease.token); } catch { /* closing */ } } };
|
|
342
|
+
const wrote = () => { feed.dbWrite(); onWrite(); };
|
|
343
|
+
/** The manager, the wake sweep and the scheduled nudge belong to the runner holding the user's manager lease (Q9). */
|
|
344
|
+
const leaseHeld = () => lease.held();
|
|
345
|
+
if (enableManager) {
|
|
346
|
+
if (leaseHeld()) manager.start();
|
|
347
|
+
lease.onChange((held) => { if (held) manager.start(); else manager.stop(); });
|
|
348
|
+
every(60e3, () => {
|
|
349
|
+
if (!leaseHeld()) return;
|
|
350
|
+
try { if (capture.tx(() => sweepWakes(hub), { label: 'wake sweep' }).length) { caches.clear(); wrote(); } } catch (e) { console.error(`wake sweep failed: ${e.message}`); }
|
|
351
|
+
try {
|
|
352
|
+
if (capture.tx(() => delivery.settleNudges() + delivery.bounce().length, { label: 'delivery sweep' })) { caches.clear(); wrote(); }
|
|
353
|
+
} catch (e) { console.error(`delivery sweep failed: ${e.message}`); }
|
|
354
|
+
});
|
|
355
|
+
every(disciplineSweepMs, () => {
|
|
356
|
+
if (!leaseHeld()) return;
|
|
357
|
+
try {
|
|
358
|
+
if (capture.tx(() => discipline.sweep().length, { label: 'stale sweep' })) { caches.clear(); wrote(); }
|
|
359
|
+
} catch (e) { console.error(`stale sweep failed: ${e.message}`); }
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
const runner = createRunner({ hub, drivers: drivers || defaultDrivers(), workspaceRoot: WORKSPACE, onChange: () => { graphs.clear(); wrote(); } });
|
|
363
|
+
if (enableManager) runner.begin();
|
|
364
|
+
if (enableManager) every(30e3, () => {
|
|
365
|
+
if (!leaseHeld()) return;
|
|
366
|
+
try {
|
|
367
|
+
const nudge = () => scheduledNudge(hub, {
|
|
368
|
+
everyMin: settingsOf(hub).wake.nudgeEveryMin, skip: (last) => delivery.postedSince(last), released: () => delivery.settledNudgeRecipients(),
|
|
369
|
+
});
|
|
370
|
+
if (capture.tx(nudge, { label: 'scheduled nudge' })) { caches.clear(); wrote(); }
|
|
371
|
+
} catch (e) { console.error(`scheduled nudge failed: ${e.message}`); }
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
const registeredAt = hasTable(hub.db, 'pulse_registered')
|
|
375
|
+
? hub.db.prepare('INSERT INTO pulse_registered (id, at) VALUES (?, ?) ON CONFLICT (id) DO UPDATE SET at = excluded.at')
|
|
376
|
+
: null;
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* One write in one capture transaction; the answer is sent only after it committed. An abort that arrives during
|
|
380
|
+
* the synchronous transaction itself is seen only after COMMIT: that window is one SQLite transaction long.
|
|
381
|
+
*/
|
|
382
|
+
const commit = (req, res, started, budgetMs, fn) => capture.tx(fn, {
|
|
383
|
+
guard: () => {
|
|
384
|
+
if (!connected(req, res)) throw new Refused('client_gone');
|
|
385
|
+
if (Date.now() - started > budgetMs) throw new Refused('over_budget');
|
|
386
|
+
},
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
const writeRoute = (req, res, started, handler) => {
|
|
390
|
+
let answer = null;
|
|
391
|
+
const send = (body, code = 200, extra = {}) => { answer = [body, code, extra]; };
|
|
392
|
+
try {
|
|
393
|
+
commit(req, res, started, writeBudgetMs, () => handler(send));
|
|
394
|
+
} catch (e) {
|
|
395
|
+
if (backlogged(e)) { if (connected(req, res)) sendJson(res, BACKLOG, 507); return undefined; }
|
|
396
|
+
if (!(e instanceof Refused)) throw e;
|
|
397
|
+
if (connected(req, res)) sendJson(res, { error: 'hub busy, try again' }, 503);
|
|
398
|
+
return undefined;
|
|
399
|
+
}
|
|
400
|
+
wrote();
|
|
401
|
+
if (answer) sendJson(res, ...answer);
|
|
402
|
+
return undefined;
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
/** Each hook event's field names, once per event per runner: the real Cursor payload's shape, never its values. */
|
|
406
|
+
const shapesSeen = new Set();
|
|
407
|
+
const noteShape = (input) => {
|
|
408
|
+
const keys = input && input._keys;
|
|
409
|
+
const event = String(input && input.hook_event_name);
|
|
410
|
+
if (!keys || shapesSeen.has(event)) return;
|
|
411
|
+
shapesSeen.add(event);
|
|
412
|
+
try {
|
|
413
|
+
fs.appendFileSync(path.join(stateDir, 'hook-shapes.jsonl'), `${JSON.stringify({ at: Date.now(), event, keys })}\n`, { mode: 0o600 });
|
|
414
|
+
} catch { /* a diagnostic only */ }
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* One hook event, in the hook's transaction: the session's activity, any alias its Shell command registered or
|
|
419
|
+
* listened as, what its earlier hooks showed it now read, then handleHook (verbatim) over the session's whole inbox:
|
|
420
|
+
* its own address and its aliases. A binding's result is told to the session in the same answer. Pulse 1.1.2: the
|
|
421
|
+
* delivery-discipline checks (channelDiscipline.js) run on every event; a `preToolUse` answer carries only E5's
|
|
422
|
+
* permission decision, since Cursor blocks a call whose answer doesn't match that hook's schema.
|
|
423
|
+
*/
|
|
424
|
+
const answerHook = (input) => {
|
|
425
|
+
const id = input && input.conversation_id;
|
|
426
|
+
if (!input || !input.hook_event_name || !ADDRESS.test(String(id || '')) || id === 'user' || id === 'manager') return handleHook(input, hub);
|
|
427
|
+
noteShape(input);
|
|
428
|
+
const at = Date.now();
|
|
429
|
+
const previousHook = bus.sawHook(input, at);
|
|
430
|
+
const bound = bus.bindFromHook(input, at);
|
|
431
|
+
const addresses = bus.addressesOf(id);
|
|
432
|
+
delivery.readOnHook(addresses, { at, previousHook });
|
|
433
|
+
const out = handleHook(input, { ...hub, messaging: routedMessaging(hub.messaging, addresses) });
|
|
434
|
+
let checked = { deny: null, notices: [] };
|
|
435
|
+
try {
|
|
436
|
+
checked = discipline.onHook(input, at);
|
|
437
|
+
} catch (e) {
|
|
438
|
+
console.error(`discipline check failed open on ${input.hook_event_name}: ${e && e.message ? e.message : e}`);
|
|
439
|
+
}
|
|
440
|
+
if (input.hook_event_name === 'preToolUse') return checked.deny || {};
|
|
441
|
+
const notice = [bus.notices(bound, id), ...checked.notices].filter(Boolean).join('\n\n');
|
|
442
|
+
return notice ? { ...out, additional_context: [out.additional_context, notice].filter(Boolean).join('\n\n') } : out;
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
const routes = (req, res, p, url, auth, started) => {
|
|
446
|
+
if (p === '/' || p === '/index.html') {
|
|
447
|
+
setAuthCookie(res, hub.token);
|
|
448
|
+
const h = health();
|
|
449
|
+
if (pulseUrl && h.state !== 'offline') {
|
|
450
|
+
res.writeHead(302, { location: pulseUrl, 'cache-control': 'no-store', ...NO_FRAME });
|
|
451
|
+
return res.end();
|
|
452
|
+
}
|
|
453
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', ...NO_FRAME });
|
|
454
|
+
return res.end(statusPage({ health: h, pulseUrl }));
|
|
455
|
+
}
|
|
456
|
+
if (p.startsWith('/ui/') || p.startsWith('/shared/')) {
|
|
457
|
+
res.writeHead(302, { location: '/', 'cache-control': 'no-store', ...NO_FRAME });
|
|
458
|
+
return res.end();
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if (!auth) return sendJson(res, { error: 'unauthorized' }, 401);
|
|
462
|
+
|
|
463
|
+
if (p === '/runner/health' && req.method === 'GET') return sendJson(res, health());
|
|
464
|
+
if (p === '/runner/metrics' && req.method === 'GET') return sendJson(res, metrics());
|
|
465
|
+
if (p === '/api/graph') {
|
|
466
|
+
const hours = HOURS.includes(Number(url.searchParams.get('hours'))) ? Number(url.searchParams.get('hours')) : 3;
|
|
467
|
+
const seen = hub.registry.presence('user');
|
|
468
|
+
if (!seen || Date.now() - seen.last_seen >= USER_SEEN_MS) {
|
|
469
|
+
capture.tx(() => hub.registry.register({ id: 'user', role: 'USER', name: 'You', topics: ['user'], node_type: 'user' }), { label: 'graph user' });
|
|
470
|
+
wrote();
|
|
471
|
+
}
|
|
472
|
+
return graphs.graph(hours, managerView(), me().aliases).then((body) => sendJson(res, { ...body, settings: { ...settingsOf(hub), me: me() } }))
|
|
473
|
+
.catch((e) => { console.error(`collect failed: ${e.message}`); if (!res.headersSent) sendJson(res, { error: 'server error' }, 500); });
|
|
474
|
+
}
|
|
475
|
+
if (p === '/api/settings' && req.method === 'GET') return sendJson(res, { ...settingsOf(hub), me: me() });
|
|
476
|
+
if (p === '/api/manager' && req.method === 'GET') return sendJson(res, { ...managerView(), lease: lease.view() });
|
|
477
|
+
if (p === '/api/stream' && req.method === 'GET') return feed.attach(req, res);
|
|
478
|
+
if (p === '/bus/messages' && req.method === 'GET') {
|
|
479
|
+
const since = Number(url.searchParams.get('since')) || 0;
|
|
480
|
+
const limit = Number(url.searchParams.get('limit')) || 300;
|
|
481
|
+
return sendJson(res, { messages: delivery.withDelivery(hub.messaging.messages({ since, limit })) });
|
|
482
|
+
}
|
|
483
|
+
if (p === '/bus/status' && req.method === 'GET') {
|
|
484
|
+
const id = String(url.searchParams.get('id') || '');
|
|
485
|
+
if (!MESSAGE_ID.test(id)) return sendJson(res, { error: 'bad id' }, 400);
|
|
486
|
+
const found = delivery.status(id);
|
|
487
|
+
return found ? sendJson(res, found) : sendJson(res, { error: 'no such message' }, 404);
|
|
488
|
+
}
|
|
489
|
+
if (p === '/bus/who' && req.method === 'GET') {
|
|
490
|
+
return sendJson(res, bus.who({ all: url.searchParams.get('all') === '1', minutes: url.searchParams.get('minutes') }));
|
|
491
|
+
}
|
|
492
|
+
if (p === '/bus/directory' && req.method === 'GET') {
|
|
493
|
+
return sendJson(res, { nodes: hub.registry.directory(), registrations: hub.registry.registrations() });
|
|
494
|
+
}
|
|
495
|
+
if (p === '/api/task/brief' && req.method === 'GET') {
|
|
496
|
+
const key = String(url.searchParams.get('key') || '');
|
|
497
|
+
if (!/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(key)) return sendJson(res, { error: 'no such channel' }, 404);
|
|
498
|
+
const brief = taskBriefFor(key, channelRoots);
|
|
499
|
+
return brief ? sendJson(res, brief) : sendJson(res, { error: 'no such channel' }, 404);
|
|
500
|
+
}
|
|
501
|
+
if (p === '/api/runs' && req.method === 'GET') return sendJson(res, { runs: runner.list(), drivers: runner.drivers() });
|
|
502
|
+
if (p === '/api/runs/diagnose' && req.method === 'GET') {
|
|
503
|
+
diagnoseClaude({ run: execFileBounded }).then((d) => sendJson(res, d)).catch((e) => sendJson(res, { error: e.message }, 500));
|
|
504
|
+
return undefined;
|
|
505
|
+
}
|
|
506
|
+
if (p === '/api/task/ask' && req.method === 'GET') {
|
|
507
|
+
const key = String(url.searchParams.get('key') || '');
|
|
508
|
+
const file = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(key) ? channelFileFor(key, channelRoots) : null;
|
|
509
|
+
if (!file) return sendJson(res, { error: 'no such channel' }, 404);
|
|
510
|
+
const ref = String(url.searchParams.get('ref') || '');
|
|
511
|
+
const at = Number(url.searchParams.get('at')) || null;
|
|
512
|
+
const detail = askDetail(file, { ref: /^[A-Z]?\d+$/.test(ref) ? ref : null, at, aliases: me().aliases });
|
|
513
|
+
return detail ? sendJson(res, detail) : sendJson(res, { error: 'no open ask in this channel' }, 404);
|
|
514
|
+
}
|
|
515
|
+
if (p === '/api/inbox' && req.method === 'GET') {
|
|
516
|
+
const since = Math.max(Date.now() - 24 * 3600e3, Number(url.searchParams.get('since')) || 0);
|
|
517
|
+
const messages = hub.messaging.involving('user', since)
|
|
518
|
+
.filter((m) => m.from !== 'user' && m.to.includes('user') && m.at > since)
|
|
519
|
+
.map((m) => ({ id: m.id, at: m.at, from: m.from, fromName: m.fromName, type: m.type, body: String(m.body || '').slice(0, 400), replyTo: m.replyTo }));
|
|
520
|
+
return sendJson(res, { messages, at: Date.now() });
|
|
521
|
+
}
|
|
522
|
+
if (p === '/api/chat/channels' && req.method === 'GET') {
|
|
523
|
+
const dms = hub.conversations.dmPartners();
|
|
524
|
+
return sendJson(res, { ...listChannels(channelRoots, Date.now() - CHAT_CHANNELS_MS), dms });
|
|
525
|
+
}
|
|
526
|
+
if (p === '/api/chat/messages' && req.method === 'GET') {
|
|
527
|
+
const ch = String(url.searchParams.get('ch') || '');
|
|
528
|
+
if (ch.startsWith('task:')) {
|
|
529
|
+
const file = channelFileFor(ch.slice(5), channelRoots);
|
|
530
|
+
return file ? sendJson(res, { messages: taskMessages(file) }) : sendJson(res, { error: 'no such channel' }, 404);
|
|
531
|
+
}
|
|
532
|
+
if (ch.startsWith('project:')) {
|
|
533
|
+
const messages = projectMessages(channelRoots, ch.slice(8), Date.now() - CHAT_HISTORY_MS);
|
|
534
|
+
return messages ? sendJson(res, { messages }) : sendJson(res, { error: 'no such channel' }, 404);
|
|
535
|
+
}
|
|
536
|
+
if (ch.startsWith('dm:')) {
|
|
537
|
+
const part = channelParticipant(ch.slice(3));
|
|
538
|
+
if (part) {
|
|
539
|
+
const file = channelFileFor(part.key, channelRoots);
|
|
540
|
+
return file ? sendJson(res, { messages: participantThread(file, part.role) }) : sendJson(res, { error: 'no such person' }, 404);
|
|
541
|
+
}
|
|
542
|
+
const dm = hub.conversations.thread(ch.slice(3));
|
|
543
|
+
return dm ? sendJson(res, dm) : sendJson(res, { error: 'no such person' }, 404);
|
|
544
|
+
}
|
|
545
|
+
return sendJson(res, { error: 'no such channel' }, 404);
|
|
546
|
+
}
|
|
547
|
+
if (p === '/api/deliveries' && req.method === 'GET') return sendJson(res, projectDeliveries(hub));
|
|
548
|
+
if (p === '/api/legacy/map' && req.method === 'GET') {
|
|
549
|
+
return sendJson(res, projectGraph(hub, { hours: Number(url.searchParams.get('hours')) || 6 }));
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
if ((p === '/bus/listen' || p === '/bus/take') && req.method === 'GET') {
|
|
553
|
+
return sendJson(res, { error: 'use POST' }, 405);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (req.method !== 'POST') return sendJson(res, { error: 'not found' }, 404);
|
|
557
|
+
if (p === '/hook') {
|
|
558
|
+
return readBody(req, res, (body) => {
|
|
559
|
+
let out = {};
|
|
560
|
+
try {
|
|
561
|
+
out = commit(req, res, started, hookBudgetMs, () => answerHook(body));
|
|
562
|
+
wrote();
|
|
563
|
+
} catch (e) {
|
|
564
|
+
if (!(e instanceof Refused)) console.error(`hook failed: ${e && e.message ? e.message : e}`);
|
|
565
|
+
out = {};
|
|
566
|
+
}
|
|
567
|
+
if (connected(req, res)) sendJson(res, out);
|
|
568
|
+
}, HOOK_BODY_BYTES);
|
|
569
|
+
}
|
|
570
|
+
return readBody(req, res, (body) => {
|
|
571
|
+
if (p === '/bus/listen' || p === '/bus/take') {
|
|
572
|
+
const as = String(body.as || '');
|
|
573
|
+
const bound = bindPrincipal(auth, as);
|
|
574
|
+
if (bound.error) return sendJson(res, bound, 403);
|
|
575
|
+
const box = bus.inboxFor(bound.principal, callerSession(body));
|
|
576
|
+
if (box.held) return sendJson(res, heldAnswer(bound.principal, box.held), 409);
|
|
577
|
+
const asked = Number(body.timeout);
|
|
578
|
+
const timeout = Math.max(0, Math.min(Number.isFinite(asked) ? asked : 25, 30)) * 1000;
|
|
579
|
+
const pollStarted = Date.now();
|
|
580
|
+
let beat = pollStarted;
|
|
581
|
+
const inbox = routedMessaging(hub.messaging, box.addresses);
|
|
582
|
+
// Re-polls run from a timer, outside readBody's try/catch: a busy database waits for the next poll, anything else answers 503.
|
|
583
|
+
const poll = () => {
|
|
584
|
+
if (!connected(req, res)) return undefined;
|
|
585
|
+
let messages = [];
|
|
586
|
+
try {
|
|
587
|
+
messages = commit(req, res, pollStarted, Infinity, () => {
|
|
588
|
+
if (body.heartbeat === true && Date.now() - beat >= LISTEN_HEARTBEAT_MS) {
|
|
589
|
+
hub.registry.heartbeat(bound.principal, { event: 'listen' });
|
|
590
|
+
bus.listened(bound.principal);
|
|
591
|
+
beat = Date.now();
|
|
592
|
+
}
|
|
593
|
+
const got = inbox.claim(bound.principal, { via: 'listen', limit: 20 });
|
|
594
|
+
if (got.length) inbox.markDelivered(bound.principal, got.map((m) => m.id), { via: 'listen' });
|
|
595
|
+
return got;
|
|
596
|
+
});
|
|
597
|
+
} catch (e) {
|
|
598
|
+
if (e instanceof Refused) return undefined;
|
|
599
|
+
if (backlogged(e)) return res.headersSent ? undefined : sendJson(res, BACKLOG, 507);
|
|
600
|
+
if (!isBusy(e) || Date.now() - pollStarted >= timeout) {
|
|
601
|
+
console.error(`bus listen for ${bound.principal} failed: ${e.message}`);
|
|
602
|
+
return res.headersSent ? undefined : sendJson(res, { error: 'hub busy, try again' }, 503);
|
|
603
|
+
}
|
|
604
|
+
setTimeout(poll, 250).unref();
|
|
605
|
+
return undefined;
|
|
606
|
+
}
|
|
607
|
+
if (messages.length) wrote();
|
|
608
|
+
if (messages.length || Date.now() - pollStarted >= timeout) return sendJson(res, { messages, at: Date.now() });
|
|
609
|
+
setTimeout(poll, 250).unref();
|
|
610
|
+
return undefined;
|
|
611
|
+
};
|
|
612
|
+
return poll();
|
|
613
|
+
}
|
|
614
|
+
if (p === '/api/runs') {
|
|
615
|
+
const parent = typeof body.parent === 'string' && ADDRESS.test(body.parent) ? body.parent : 'manager';
|
|
616
|
+
const stream = typeof body.stream === 'string' && /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(body.stream) ? body.stream : null;
|
|
617
|
+
runner.start({ driver: String(body.driver || ''), role: String(body.role || ''), workspace: String(body.workspace || ''), brief: typeof body.brief === 'string' ? body.brief : '',
|
|
618
|
+
stream, parent, name: typeof body.name === 'string' ? body.name.slice(0, 80) : null, mode: body.mode === 'read' ? 'read' : 'write' })
|
|
619
|
+
.then((r) => {
|
|
620
|
+
capture.pump();
|
|
621
|
+
if (r.error) { console.error(`run refused: ${r.error}`); sendJson(res, r, 400); } else { caches.clear(); wrote(); sendJson(res, r); }
|
|
622
|
+
})
|
|
623
|
+
.catch((e) => { console.error(`run start failed: ${e.message}`); sendJson(res, { error: `could not start: ${e.message.slice(0, 200)}` }, 500); });
|
|
624
|
+
return undefined;
|
|
625
|
+
}
|
|
626
|
+
return writeRoute(req, res, started, (send) => {
|
|
627
|
+
if (p === '/api/message') {
|
|
628
|
+
const to = (Array.isArray(body.to) ? body.to : [body.to]).filter((x) => typeof x === 'string' && x.length < 200).slice(0, 50);
|
|
629
|
+
if (!to.length || !['NUDGE', 'STATUS?'].includes(body.type)) return send({ error: 'invalid message' }, 400);
|
|
630
|
+
const dir = hub.registry.directory();
|
|
631
|
+
for (const id of to) {
|
|
632
|
+
hub.messaging.publish({ from: 'manager', fromName: 'Manager', topic: `agent/${id}`, type: body.type, body: String(body.text || '').slice(0, 500), needsAck: true }, dir);
|
|
633
|
+
}
|
|
634
|
+
return send({ ok: true, sent: to.length });
|
|
635
|
+
}
|
|
636
|
+
if (p === '/bus/publish') {
|
|
637
|
+
const from = String(body.from || '');
|
|
638
|
+
const bound = bindPrincipal(auth, from);
|
|
639
|
+
if (bound.error) return send(bound, 403);
|
|
640
|
+
if (auth.via === 'cookie' && from && from !== 'user') return send({ error: 'forbidden principal' }, 403);
|
|
641
|
+
if (body.supersedes != null && !MESSAGE_ID.test(String(body.supersedes))) return send({ error: 'bad supersedes' }, 400);
|
|
642
|
+
const direct = typeof body.topic === 'string' && body.topic.startsWith('agent/') ? body.topic.slice(6) : null;
|
|
643
|
+
if (direct && ADDRESS.test(direct) && !bus.known(direct)) return send(unknownAddress(direct, bus.suggest(direct)), 400);
|
|
644
|
+
const env = hub.messaging.publish({
|
|
645
|
+
id: typeof body.id === 'string' ? body.id : null, from: bound.principal, fromName: body.fromName, topic: body.topic, type: body.type || 'MESSAGE',
|
|
646
|
+
body: body.body, needsAck: body.needsAck, ttlMs: body.ttlMs, replyTo: body.replyTo,
|
|
647
|
+
...(body.supersedes != null ? { meta: { supersedes: body.supersedes } } : {}),
|
|
648
|
+
}, hub.registry.directory());
|
|
649
|
+
if (env.error) return send(env, 400);
|
|
650
|
+
caches.clear();
|
|
651
|
+
return send({ ...env, delivery: delivery.report(env) });
|
|
652
|
+
}
|
|
653
|
+
if (p === '/bus/register') {
|
|
654
|
+
const id = String(body.id || body.as || '');
|
|
655
|
+
const bound = bindPrincipal(auth, id);
|
|
656
|
+
if (bound.error) return send(bound, 403);
|
|
657
|
+
const result = hub.registry.register({ ...body, id: bound.principal });
|
|
658
|
+
if (result.error) return send(result, 400);
|
|
659
|
+
if (registeredAt) registeredAt.run(bound.principal, Date.now());
|
|
660
|
+
bus.proved(bound.principal, 'register');
|
|
661
|
+
caches.clear();
|
|
662
|
+
return send(result);
|
|
663
|
+
}
|
|
664
|
+
if (p === '/bus/ack' || p === '/bus/reply') {
|
|
665
|
+
const as = String(body.as || '');
|
|
666
|
+
const bound = bindPrincipal(auth, as);
|
|
667
|
+
if (bound.error) return send(bound, 403);
|
|
668
|
+
const box = bus.inboxFor(bound.principal, callerSession(body));
|
|
669
|
+
if (box.held) return send(heldAnswer(bound.principal, box.held), 409);
|
|
670
|
+
const inbox = routedMessaging(hub.messaging, box.addresses);
|
|
671
|
+
const reply = body.reply || body.body;
|
|
672
|
+
const sender = (typeof reply === 'string' && reply.trim()) || body.reaction ? delivery.senderOf(String(body.id || '')) : null;
|
|
673
|
+
if (sender && !bus.known(sender, { reply: true })) return send(unknownAddress(sender, bus.suggest(sender)), 400);
|
|
674
|
+
const result = inbox.ack(bound.principal, String(body.id || ''), { reply, fromName: body.fromName, reaction: body.reaction }, hub.registry.directory());
|
|
675
|
+
if (result.error) return send(result, 400);
|
|
676
|
+
caches.clear();
|
|
677
|
+
const offline = result.reply && sender ? bus.reach(sender) : null;
|
|
678
|
+
return send(offline && offline.reason === 'offline' ? { ...result, delivery: [{ to: sender, ...offline }] } : result);
|
|
679
|
+
}
|
|
680
|
+
if (p === '/api/runs/stop') {
|
|
681
|
+
const r = runner.stop(String(body.id || ''));
|
|
682
|
+
return r.error ? send(r, 404) : send(r);
|
|
683
|
+
}
|
|
684
|
+
if (p === '/api/decision') {
|
|
685
|
+
const file = typeof body.key === 'string' ? channelFileFor(body.key, channelRoots) : null;
|
|
686
|
+
const text = String(body.text || '').trim().slice(0, 4000);
|
|
687
|
+
if (!text) return send({ error: 'empty decision' }, 400);
|
|
688
|
+
if (!file) return send({ error: 'no such channel' }, 404);
|
|
689
|
+
const kind = body.kind === 'CHANGES REQUESTED' ? body.kind : 'DECISION';
|
|
690
|
+
appendNoFollow(file, decisionPost(text, { ref: typeof body.ref === 'string' ? body.ref : null, kind }));
|
|
691
|
+
hub.registry.appendEvent({ kind, actor: 'user', payload: { from: 'user', to: 'manager', type: kind, text: text.slice(0, 200), channel: body.key } });
|
|
692
|
+
caches.clear();
|
|
693
|
+
return send({ ok: true });
|
|
694
|
+
}
|
|
695
|
+
if (p === '/api/accept') {
|
|
696
|
+
if (typeof body.delivery !== 'string' || body.delivery.length > 100) return send({ error: 'invalid delivery' }, 400);
|
|
697
|
+
hub.registry.setFact('accepted', body.delivery, { at: Date.now() });
|
|
698
|
+
// The board's deliveries read acceptances from state/accepted.json (store.readAccepted), not from hub.db.
|
|
699
|
+
acceptDelivery(body.delivery);
|
|
700
|
+
hub.registry.appendEvent({ kind: 'ACCEPTED', actor: 'user', payload: { from: 'user', to: 'manager', type: 'ACCEPTED', ref: body.delivery, text: String(body.note || '').slice(0, 500) } });
|
|
701
|
+
caches.clear();
|
|
702
|
+
return send({ ok: true });
|
|
703
|
+
}
|
|
704
|
+
if (p === '/api/status') {
|
|
705
|
+
const want = statusRequest(body);
|
|
706
|
+
if (want.error) return send({ error: want.error }, 400);
|
|
707
|
+
const { target } = want;
|
|
708
|
+
if (target.kind === 'stream') {
|
|
709
|
+
const stream = hub.registry.fact('stream', target.id);
|
|
710
|
+
if (!stream) return send({ error: 'no such stream' }, 404);
|
|
711
|
+
if (stream.done) return send({ error: 'a finished stream does not move' }, 409);
|
|
712
|
+
const owner = hub.registry.facts('delivery').find((d) => (d.streams || []).some((s) => s.key === target.id));
|
|
713
|
+
const closed = owner && withOverride(owner, hub.registry.fact('status_override', `delivery:${owner.id}`), { kind: 'delivery', ...workMoved('delivery', owner) }).status === 'done';
|
|
714
|
+
if (closed) return send({ error: 'its delivery is closed, so its cards do not move' }, 409);
|
|
715
|
+
const key = overrideKey(target);
|
|
716
|
+
const prev = hub.registry.fact('status_override', key);
|
|
717
|
+
const holding = stepHolds(prev, stream);
|
|
718
|
+
const derived = STEP_IDS[stream.step];
|
|
719
|
+
if (holding ? prev.step === want.step : derived === want.step) {
|
|
720
|
+
if (prev && !holding) hub.registry.dropFact('status_override', key);
|
|
721
|
+
return send({ ok: true, unchanged: true, target, step: want.step, posted: [] });
|
|
722
|
+
}
|
|
723
|
+
const file = channelFileFor(target.id, channelRoots);
|
|
724
|
+
if (!file) return send({ error: 'this stream has no channel to note the move in' }, 409);
|
|
725
|
+
const at = Date.now();
|
|
726
|
+
const cleared = want.step === derived;
|
|
727
|
+
appendNoFollow(file, movePost(want, { at: new Date(at) }));
|
|
728
|
+
if (cleared) hub.registry.dropFact('status_override', key);
|
|
729
|
+
else hub.registry.setFact('status_override', key, { step: want.step, fromStep: stream.step, by: me() === NOBODY ? 'user' : me().name, at, seq: postsIn(file) });
|
|
730
|
+
hub.registry.appendEvent({ kind: 'STEP SET', actor: 'user', payload: { from: 'user', to: 'manager', type: 'STEP SET', ref: key, text: `moved to ${want.step}${cleared ? ' (its own step)' : ''}` } });
|
|
731
|
+
caches.clear();
|
|
732
|
+
feed.notify('hub');
|
|
733
|
+
return send({ ok: true, target, step: want.step, cleared, posted: [target.id] });
|
|
734
|
+
}
|
|
735
|
+
const row = hub.registry.fact(target.kind === 'delivery' ? 'delivery' : 'agent-row', target.id);
|
|
736
|
+
if (!row) return send({ error: `no such ${target.kind}` }, 404);
|
|
737
|
+
const prev = hub.registry.fact('status_override', overrideKey(target));
|
|
738
|
+
if (overrideHolds(prev, workMoved(target.kind, row)) && prev.status === want.status && (prev.reason || null) === want.reason && (prev.to || null) === want.to) {
|
|
739
|
+
return send({ ok: true, unchanged: true, target, status: want.status, posted: [] });
|
|
740
|
+
}
|
|
741
|
+
const at = Date.now();
|
|
742
|
+
const override = { status: want.status, reason: want.reason, to: want.to, by: me() === NOBODY ? 'user' : me().name, at };
|
|
743
|
+
const open = target.kind === 'delivery'
|
|
744
|
+
? (row.streams || []).filter((s) => !s.done).map((s) => s.key)
|
|
745
|
+
: [row.streamKey].filter((k) => k && !(hub.registry.fact('stream', k) || {}).done);
|
|
746
|
+
const who = target.kind === 'agent' ? (row.persona && row.persona.name ? `${row.label} (${row.persona.name})` : row.label) : null;
|
|
747
|
+
const post = auditPost(override, { at: new Date(at), who });
|
|
748
|
+
const posted = [];
|
|
749
|
+
const seqs = {};
|
|
750
|
+
for (const key of open) {
|
|
751
|
+
const file = channelFileFor(key, channelRoots);
|
|
752
|
+
if (file) { appendNoFollow(file, post); posted.push(key); seqs[key] = postsIn(file); }
|
|
753
|
+
}
|
|
754
|
+
hub.registry.setFact('status_override', overrideKey(target), { ...override, seqs });
|
|
755
|
+
hub.registry.appendEvent({ kind: 'STATUS SET', actor: 'user', payload: { from: 'user', to: 'manager', type: 'STATUS SET', ref: overrideKey(target), text: overrideText(override) } });
|
|
756
|
+
caches.clear();
|
|
757
|
+
feed.notify('hub');
|
|
758
|
+
return send({ ok: true, target, status: want.status, posted });
|
|
759
|
+
}
|
|
760
|
+
if (p === '/api/chat/post') {
|
|
761
|
+
const ch = String(body.ch || '');
|
|
762
|
+
const text = String(body.text || '').trim().slice(0, 4000);
|
|
763
|
+
const refuse = (error, code = 400) => { console.error(`chat post refused (${ch.split(':')[0] || 'none'}): ${error}`); return send({ error }, code); };
|
|
764
|
+
if (!text) return refuse('empty message');
|
|
765
|
+
if (auth.via === 'cookie' && body.from && body.from !== 'user') return send({ error: 'forbidden principal' }, 403);
|
|
766
|
+
if (ch.startsWith('task:')) {
|
|
767
|
+
const file = channelFileFor(ch.slice(5), channelRoots);
|
|
768
|
+
if (!file) return refuse('no such channel', 404);
|
|
769
|
+
appendNoFollow(file, decisionPost(text, { kind: 'MESSAGE' }));
|
|
770
|
+
caches.clear();
|
|
771
|
+
return send({ ok: true });
|
|
772
|
+
}
|
|
773
|
+
if (ch.startsWith('project:') && postToProject(ch.slice(8), 'USER', text)) return send({ ok: true });
|
|
774
|
+
if (ch.startsWith('dm:')) {
|
|
775
|
+
const recipient = ch.slice(3);
|
|
776
|
+
const part = channelParticipant(recipient);
|
|
777
|
+
if (part) {
|
|
778
|
+
const file = channelFileFor(part.key, channelRoots);
|
|
779
|
+
if (!file) return refuse('no such person');
|
|
780
|
+
appendNoFollow(file, participantPost(part.role, text));
|
|
781
|
+
hub.registry.appendEvent({ kind: 'MESSAGE', actor: 'user', payload: { from: 'user', to: recipient, type: 'MESSAGE', text: text.slice(0, 200), channel: part.key } });
|
|
782
|
+
caches.clear();
|
|
783
|
+
return send({ ok: true, delivered: true, via: 'channel', channel: part.key });
|
|
784
|
+
}
|
|
785
|
+
if (!ADDRESS.test(recipient)) return refuse(recipient === 'hub' ? 'the hub itself takes no direct messages' : 'this person has no inbox the hub can reach');
|
|
786
|
+
const file = typeof body.key === 'string' ? channelFileFor(body.key, channelRoots) : null;
|
|
787
|
+
const channel = file ? body.key.split('/').pop() : null;
|
|
788
|
+
const role = typeof body.role === 'string' && /^[A-Z][A-Z0-9-]{0,39}$/.test(body.role) ? body.role : null;
|
|
789
|
+
const name = body.wake && typeof body.wake === 'object' && typeof body.wake.name === 'string' ? body.wake.name.slice(0, 80) : null;
|
|
790
|
+
const env = hub.messaging.publish({
|
|
791
|
+
from: 'user', fromName: 'You', topic: recipient === 'manager' ? 'manager' : `agent/${recipient}`,
|
|
792
|
+
type: 'MESSAGE', body: text, needsAck: true,
|
|
793
|
+
meta: { voice: hasChatInbox(recipient) ? voiceFor(recipient, role) : null, channel },
|
|
794
|
+
}, hub.registry.directory());
|
|
795
|
+
if (env.error || !env.to.length) return refuse(env.error || 'no such person');
|
|
796
|
+
const seen = hub.registry.presence(recipient);
|
|
797
|
+
const running = seen && Date.now() - seen.last_seen < STOPPED_MS && seen.last_event !== 'stop';
|
|
798
|
+
const wake = running ? null : requestWake(hub, recipient, { name });
|
|
799
|
+
caches.clear();
|
|
800
|
+
return send({ ok: true, messageId: env.id, queued: true, wake: wake ? wake.parent : null });
|
|
801
|
+
}
|
|
802
|
+
return refuse('no such channel');
|
|
803
|
+
}
|
|
804
|
+
if (p === '/api/settings') {
|
|
805
|
+
const next = writeSettings(body);
|
|
806
|
+
hub.registry.setFact('settings', 'hub', next);
|
|
807
|
+
return send({ ...next, keepAwakeStatus: keepAwakeStatus(), me: me() });
|
|
808
|
+
}
|
|
809
|
+
if (p === '/api/legacy/request') {
|
|
810
|
+
const id = `r-${Date.now().toString(36)}`;
|
|
811
|
+
const reqBody = {
|
|
812
|
+
id, from: [String(body.from || 'user')], kind: String(body.kind ?? 'platform'),
|
|
813
|
+
title: String(body.title ?? ''), files: (body.files ?? '').split(',').filter(Boolean),
|
|
814
|
+
status: 'queued', at: Date.now(),
|
|
815
|
+
};
|
|
816
|
+
hub.registry.setFact('request', id, reqBody);
|
|
817
|
+
hub.registry.appendEvent({ kind: 'REQUEST', actor: reqBody.from[0], payload: { ...reqBody, type: 'REQUEST', text: `${reqBody.kind}: ${reqBody.title}` } });
|
|
818
|
+
return send(reqBody);
|
|
819
|
+
}
|
|
820
|
+
if (p === '/api/legacy/post') {
|
|
821
|
+
const event = { from: body.from || 'user', to: body.to ?? '*', type: body.type ?? 'NOTE', text: body.text ?? '', at: Date.now() };
|
|
822
|
+
hub.registry.appendEvent({ kind: event.type, actor: event.from, payload: event });
|
|
823
|
+
return send(event);
|
|
824
|
+
}
|
|
825
|
+
if (p === '/api/legacy/wait') {
|
|
826
|
+
const forId = String(body.for || '');
|
|
827
|
+
const hit = hub.messaging.claim(forId, { via: 'wait', limit: 1 });
|
|
828
|
+
if (hit.length) {
|
|
829
|
+
hub.messaging.markDelivered(forId, hit.map((m) => m.id), { via: 'wait' });
|
|
830
|
+
return send({ message: hit[0] });
|
|
831
|
+
}
|
|
832
|
+
const ev = hub.registry.eventsSince(Number(body.since) || 0, 20).find((e) => e.to === forId || e.to === '*');
|
|
833
|
+
return send(ev ? { event: ev } : {});
|
|
834
|
+
}
|
|
835
|
+
return send({ error: 'not found' }, 404);
|
|
836
|
+
});
|
|
837
|
+
});
|
|
838
|
+
};
|
|
839
|
+
|
|
840
|
+
const server = http.createServer((req, res) => {
|
|
841
|
+
const started = Date.now();
|
|
842
|
+
try {
|
|
843
|
+
if (!LOCAL_HOST.test(req.headers.host || '')) return sendJson(res, { error: 'unknown host' }, 421);
|
|
844
|
+
if (req.method !== 'GET' && req.method !== 'POST') return sendJson(res, { error: 'method not allowed' }, 405);
|
|
845
|
+
if (req.method === 'POST' && !trustedWrite(req)) {
|
|
846
|
+
console.error(`write refused: ${req.url.split('?')[0]} origin=${req.headers.origin || '-'} site=${req.headers['sec-fetch-site'] || '-'} type=${req.headers['content-type'] || '-'}`);
|
|
847
|
+
return sendJson(res, { error: 'forbidden' }, 403);
|
|
848
|
+
}
|
|
849
|
+
let url; let p;
|
|
850
|
+
try { url = new URL(req.url, 'http://127.0.0.1'); p = decodeURIComponent(url.pathname); } catch { return sendJson(res, { error: 'bad path' }, 400); }
|
|
851
|
+
if (p.includes('\0')) return sendJson(res, { error: 'bad path' }, 400);
|
|
852
|
+
const auth = authenticate(req, hub.token, { asHint: url.searchParams.get('as') });
|
|
853
|
+
const publicPath = p === '/' || p === '/index.html' || p.startsWith('/ui/') || p.startsWith('/shared/');
|
|
854
|
+
if (!publicPath && !auth) {
|
|
855
|
+
if (req.method === 'POST') console.error(`write refused: ${p} not signed in (cookie ${req.headers.cookie && req.headers.cookie.includes('hub_auth=') ? 'stale' : 'missing'})`);
|
|
856
|
+
return sendJson(res, { error: 'unauthorized' }, 401);
|
|
857
|
+
}
|
|
858
|
+
const stale = health().staleSeconds;
|
|
859
|
+
if (Number.isFinite(stale) && stale > 35) res.setHeader('x-pulse-stale', String(Math.round(stale)));
|
|
860
|
+
return routes(req, res, p, url, auth, started);
|
|
861
|
+
} catch (e) {
|
|
862
|
+
console.error(`request failed: ${req.method} ${req.url}: ${e && e.stack ? e.stack : e}`);
|
|
863
|
+
if (!res.headersSent) sendJson(res, { error: 'server error' }, 500);
|
|
864
|
+
else res.end();
|
|
865
|
+
return undefined;
|
|
866
|
+
}
|
|
867
|
+
});
|
|
868
|
+
server.on('close', () => {
|
|
869
|
+
feed.close();
|
|
870
|
+
for (const t of timers) clearInterval(t);
|
|
871
|
+
manager.stop();
|
|
872
|
+
releaseLease();
|
|
873
|
+
});
|
|
874
|
+
server.listen(port, listenHost);
|
|
875
|
+
return { server, manager, graphs, runner, feed };
|
|
876
|
+
}
|