@runtimescope/collector 0.10.0 → 0.10.2

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.
@@ -76,6 +76,57 @@ var EventStore = class {
76
76
  this.sqliteStore = store;
77
77
  this.currentProject = project;
78
78
  }
79
+ /**
80
+ * Pre-load recent events from a SqliteStore into the in-memory ring buffer.
81
+ * Used at collector startup to make MCP tools immediately useful after a
82
+ * restart — without this, the buffer is empty until the SDK reconnects and
83
+ * generates new traffic.
84
+ *
85
+ * Also rehydrates the session→projectId map from SqliteStore's `sessions`
86
+ * table. Without this, queries filtered by `project_id` after a crash
87
+ * return nothing because the in-memory map is empty: events are tagged
88
+ * by sessionId, and matchesProjectId() looks up the session record to
89
+ * find the project. (Pre-fix: only sessions whose `session` event happens
90
+ * to be in the warmed window got rehydrated, which is rare in long runs.)
91
+ *
92
+ * Does NOT propagate to SqliteStore (we just read FROM it). Ring-buffer
93
+ * eviction handles overflow naturally if multiple projects are warmed.
94
+ */
95
+ warmFromSqlite(sqliteStore, project, limit) {
96
+ for (const stored of sqliteStore.getStoredSessions(project)) {
97
+ if (this.sessions.has(stored.sessionId)) continue;
98
+ this.sessions.set(stored.sessionId, {
99
+ sessionId: stored.sessionId,
100
+ appName: stored.appName,
101
+ connectedAt: stored.connectedAt,
102
+ sdkVersion: stored.sdkVersion,
103
+ eventCount: 0,
104
+ // recompute from warmed events below
105
+ isConnected: false,
106
+ projectId: stored.projectId
107
+ });
108
+ }
109
+ const events = sqliteStore.getRecentEvents(project, limit);
110
+ for (const event of events) {
111
+ if (event.eventType === "session") {
112
+ const se = event;
113
+ if (!this.sessions.has(se.sessionId)) {
114
+ this.sessions.set(se.sessionId, {
115
+ sessionId: se.sessionId,
116
+ appName: se.appName,
117
+ connectedAt: se.connectedAt,
118
+ sdkVersion: se.sdkVersion,
119
+ eventCount: 0,
120
+ isConnected: false,
121
+ projectId: se.projectId
122
+ });
123
+ }
124
+ }
125
+ const session = this.sessions.get(event.sessionId);
126
+ if (session) session.eventCount++;
127
+ this.buffer.push(event);
128
+ }
129
+ }
79
130
  onEvent(callback) {
80
131
  this.onEventCallbacks.push(callback);
81
132
  }
@@ -144,6 +195,19 @@ var EventStore = class {
144
195
  getSessionInfo() {
145
196
  return Array.from(this.sessions.values());
146
197
  }
198
+ /**
199
+ * Fast O(sessions) event count for a specific projectId. Callers that just
200
+ * want to know "have any events arrived for project X yet?" should use this
201
+ * instead of `getAllEvents(..., projectId).length`, which allocates and
202
+ * filters the entire 10K-event ring buffer on every call.
203
+ */
204
+ eventCountForProject(projectId) {
205
+ let total = 0;
206
+ for (const session of this.sessions.values()) {
207
+ if (session.projectId === projectId) total += session.eventCount;
208
+ }
209
+ return total;
210
+ }
147
211
  markDisconnected(sessionId) {
148
212
  const s = this.sessions.get(sessionId);
149
213
  if (s) s.isConnected = false;
@@ -381,7 +445,7 @@ function resolveProjectId(projectManager, appName, pmStore) {
381
445
  }
382
446
 
383
447
  // src/sqlite-store.ts
384
- import { renameSync, existsSync } from "fs";
448
+ import { renameSync, existsSync, statSync } from "fs";
385
449
  import { createRequire } from "module";
386
450
  var DatabaseConstructor;
387
451
  function getDatabase() {
@@ -459,8 +523,8 @@ var SqliteStore = class _SqliteStore {
459
523
  this.insertSessionStmt = db.prepare(`
460
524
  INSERT OR REPLACE INTO sessions (
461
525
  session_id, project, app_name, connected_at, sdk_version,
462
- event_count, is_connected, build_meta
463
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
526
+ event_count, is_connected, build_meta, project_id
527
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
464
528
  `);
465
529
  this.updateSessionDisconnectedStmt = db.prepare(`
466
530
  UPDATE sessions SET is_connected = 0, disconnected_at = ? WHERE session_id = ?
@@ -512,6 +576,14 @@ var SqliteStore = class _SqliteStore {
512
576
  CREATE INDEX IF NOT EXISTS idx_snapshots_session ON session_snapshots(session_id);
513
577
  CREATE INDEX IF NOT EXISTS idx_snapshots_project ON session_snapshots(project, created_at);
514
578
  `);
579
+ try {
580
+ d.exec("ALTER TABLE sessions ADD COLUMN project_id TEXT");
581
+ } catch {
582
+ }
583
+ try {
584
+ d.exec("CREATE INDEX IF NOT EXISTS idx_sessions_project_id ON sessions(project_id)");
585
+ } catch {
586
+ }
515
587
  this.migrateSessionMetrics(d);
516
588
  }
517
589
  // --- Write Operations ---
@@ -554,9 +626,37 @@ var SqliteStore = class _SqliteStore {
554
626
  info.sdkVersion,
555
627
  info.eventCount,
556
628
  info.isConnected ? 1 : 0,
557
- info.buildMeta ? JSON.stringify(info.buildMeta) : null
629
+ info.buildMeta ? JSON.stringify(info.buildMeta) : null,
630
+ info.projectId ?? null
558
631
  );
559
632
  }
633
+ /**
634
+ * Read every session record stored for a project. Used by the in-memory
635
+ * EventStore at startup to rehydrate the session→projectId map after a
636
+ * crash, so post-recovery `?project_id=...` queries return the right rows.
637
+ * Each row is returned as a `SessionInfo` shape compatible with EventStore.
638
+ */
639
+ getStoredSessions(project) {
640
+ const rows = this.db.prepare(
641
+ `SELECT session_id, project, app_name, connected_at, disconnected_at,
642
+ sdk_version, event_count, is_connected, build_meta, project_id
643
+ FROM sessions WHERE project = ?`
644
+ ).all(project);
645
+ return rows.map((r) => ({
646
+ sessionId: r.session_id,
647
+ project: r.project,
648
+ appName: r.app_name,
649
+ connectedAt: r.connected_at,
650
+ sdkVersion: r.sdk_version,
651
+ eventCount: r.event_count,
652
+ // Recovered sessions are NEVER live — only an active WS reconnect
653
+ // flips this back to true. The persisted `is_connected = 1` only
654
+ // means "was connected when last written"; if we crashed, it's stale.
655
+ isConnected: false,
656
+ buildMeta: r.build_meta ? JSON.parse(r.build_meta) : void 0,
657
+ projectId: r.project_id ?? void 0
658
+ }));
659
+ }
560
660
  updateSessionDisconnected(sessionId, disconnectedAt) {
561
661
  this.updateSessionDisconnectedStmt.run(disconnectedAt, sessionId);
562
662
  }
@@ -582,6 +682,17 @@ var SqliteStore = class _SqliteStore {
582
682
  }
583
683
  }
584
684
  // --- Read Operations ---
685
+ /**
686
+ * Return the most recent `limit` events for a project, in chronological
687
+ * order (oldest-first). Used at startup to warm the in-memory ring buffer
688
+ * so MCP tools see recent activity immediately after a collector restart.
689
+ */
690
+ getRecentEvents(project, limit) {
691
+ const rows = this.db.prepare(
692
+ `SELECT data FROM events WHERE project = ? ORDER BY timestamp DESC LIMIT ?`
693
+ ).all(project, limit);
694
+ return rows.reverse().map((row) => JSON.parse(row.data));
695
+ }
585
696
  getEvents(filter) {
586
697
  const conditions = [];
587
698
  const params = [];
@@ -726,6 +837,24 @@ var SqliteStore = class _SqliteStore {
726
837
  vacuum() {
727
838
  this.db.exec("VACUUM");
728
839
  }
840
+ /**
841
+ * Atomically copy the live database to `targetPath` using SQLite's
842
+ * `VACUUM INTO`. The destination is a self-contained, defragmented copy of
843
+ * the DB at the moment the statement runs — no readers are blocked beyond
844
+ * the duration of the copy, and the WAL/journal contents are folded in.
845
+ * The target file must not already exist; SQLite refuses to overwrite.
846
+ *
847
+ * Returns the size in bytes of the resulting file.
848
+ */
849
+ snapshotTo(targetPath) {
850
+ this.flush();
851
+ this.db.prepare("VACUUM INTO ?").run(targetPath);
852
+ try {
853
+ return statSync(targetPath).size;
854
+ } catch {
855
+ return 0;
856
+ }
857
+ }
729
858
  close() {
730
859
  if (this.flushTimer) {
731
860
  clearInterval(this.flushTimer);
@@ -756,6 +885,672 @@ function isSqliteAvailable() {
756
885
  return _available;
757
886
  }
758
887
 
888
+ // src/wal.ts
889
+ import {
890
+ closeSync,
891
+ existsSync as existsSync2,
892
+ fsyncSync,
893
+ mkdirSync,
894
+ openSync,
895
+ readFileSync,
896
+ readdirSync,
897
+ renameSync as renameSync2,
898
+ statSync as statSync2,
899
+ unlinkSync,
900
+ writeSync
901
+ } from "fs";
902
+ import { join } from "path";
903
+ var Wal = class {
904
+ dir;
905
+ rotateSize;
906
+ fd = null;
907
+ activeSize = 0;
908
+ seq = 0;
909
+ closed = false;
910
+ constructor(options) {
911
+ this.dir = options.dir;
912
+ this.rotateSize = options.rotateSizeBytes ?? 8 * 1024 * 1024;
913
+ mkdirSync(this.dir, { recursive: true });
914
+ this.openActive();
915
+ }
916
+ openActive() {
917
+ const path = this.activePath();
918
+ this.fd = openSync(path, "a");
919
+ try {
920
+ this.activeSize = statSync2(path).size;
921
+ } catch {
922
+ this.activeSize = 0;
923
+ }
924
+ }
925
+ activePath() {
926
+ return join(this.dir, "active.jsonl");
927
+ }
928
+ /**
929
+ * Buffer events into the active file. Not durable yet — must be followed by
930
+ * `commit()` before the caller treats the events as persisted.
931
+ */
932
+ append(events) {
933
+ if (this.closed || this.fd === null || events.length === 0) return;
934
+ const lines = [];
935
+ for (const ev of events) {
936
+ this.seq++;
937
+ const entry = { seq: this.seq, event: ev };
938
+ lines.push(JSON.stringify(entry));
939
+ }
940
+ const payload = Buffer.from(lines.join("\n") + "\n", "utf8");
941
+ writeSync(this.fd, payload);
942
+ this.activeSize += payload.length;
943
+ }
944
+ /** fsync the active file. Durability contract: returns only after bytes are on stable storage. */
945
+ commit() {
946
+ if (this.closed || this.fd === null) return;
947
+ try {
948
+ fsyncSync(this.fd);
949
+ } catch {
950
+ }
951
+ }
952
+ /** True if the active file has exceeded the rotate threshold. */
953
+ shouldRotate() {
954
+ return this.activeSize >= this.rotateSize;
955
+ }
956
+ /**
957
+ * Rotate the active file: fsync, close, rename to `sealed-<ts>-<seq>.jsonl`,
958
+ * then open a fresh active file. Returns the sealed path so the caller can
959
+ * schedule deletion after confirming persistence elsewhere.
960
+ */
961
+ rotate() {
962
+ if (this.closed || this.fd === null) return null;
963
+ this.commit();
964
+ closeSync(this.fd);
965
+ this.fd = null;
966
+ const active = this.activePath();
967
+ if (!existsSync2(active)) {
968
+ this.openActive();
969
+ return null;
970
+ }
971
+ const sealed = join(this.dir, `sealed-${Date.now()}-${this.seq}.jsonl`);
972
+ renameSync2(active, sealed);
973
+ this.activeSize = 0;
974
+ this.openActive();
975
+ return sealed;
976
+ }
977
+ /** Remove a sealed file once its events are durable in the downstream store. */
978
+ static deleteSealed(path) {
979
+ try {
980
+ unlinkSync(path);
981
+ } catch {
982
+ }
983
+ }
984
+ /** List sealed files in this WAL's directory, sorted oldest-first. */
985
+ listSealed() {
986
+ try {
987
+ return readdirSync(this.dir).filter((f) => f.startsWith("sealed-") && f.endsWith(".jsonl")).sort().map((f) => join(this.dir, f));
988
+ } catch {
989
+ return [];
990
+ }
991
+ }
992
+ /**
993
+ * Parse a WAL file back into its events, skipping any corrupt or truncated
994
+ * trailing line. A crash mid-append can leave a partial line; the parser
995
+ * tolerates it because fsync had never completed for that line, which means
996
+ * the event was never treated as durable.
997
+ */
998
+ static readFile(path) {
999
+ let raw;
1000
+ try {
1001
+ raw = readFileSync(path, "utf8");
1002
+ } catch {
1003
+ return [];
1004
+ }
1005
+ if (!raw) return [];
1006
+ const events = [];
1007
+ for (const line of raw.split("\n")) {
1008
+ if (!line.trim()) continue;
1009
+ try {
1010
+ const parsed = JSON.parse(line);
1011
+ if (parsed && parsed.event) events.push(parsed.event);
1012
+ } catch {
1013
+ break;
1014
+ }
1015
+ }
1016
+ return events;
1017
+ }
1018
+ /**
1019
+ * List the WAL files that need recovery for a project: any sealed files
1020
+ * plus the active file if it has content. Returned in ingestion order
1021
+ * (sealed oldest-first, then active last).
1022
+ */
1023
+ static listRecoveryFiles(dir) {
1024
+ if (!existsSync2(dir)) return [];
1025
+ let entries;
1026
+ try {
1027
+ entries = readdirSync(dir);
1028
+ } catch {
1029
+ return [];
1030
+ }
1031
+ const sealed = entries.filter((f) => f.startsWith("sealed-") && f.endsWith(".jsonl")).sort();
1032
+ const files = sealed.map((f) => join(dir, f));
1033
+ const active = join(dir, "active.jsonl");
1034
+ try {
1035
+ const s = statSync2(active);
1036
+ if (s.size > 0) files.push(active);
1037
+ } catch {
1038
+ }
1039
+ return files;
1040
+ }
1041
+ close() {
1042
+ if (this.closed || this.fd === null) return;
1043
+ this.commit();
1044
+ try {
1045
+ closeSync(this.fd);
1046
+ } catch {
1047
+ }
1048
+ this.fd = null;
1049
+ this.closed = true;
1050
+ }
1051
+ /**
1052
+ * Copy the active file (post-fsync) and any sealed files into `targetDir`.
1053
+ * Returns the total bytes copied. Used by snapshot endpoints — pairing the
1054
+ * SQLite snapshot with the WAL means a restore captures any events that
1055
+ * hadn't yet been drained into SQLite at snapshot time.
1056
+ */
1057
+ snapshotTo(targetDir) {
1058
+ this.commit();
1059
+ mkdirSync(targetDir, { recursive: true });
1060
+ let total = 0;
1061
+ const copyOne = (src, dstName) => {
1062
+ try {
1063
+ const data = readFileSync(src);
1064
+ const dst = join(targetDir, dstName);
1065
+ const fd = openSync(dst, "wx");
1066
+ try {
1067
+ writeSync(fd, data);
1068
+ } finally {
1069
+ closeSync(fd);
1070
+ }
1071
+ total += data.length;
1072
+ } catch {
1073
+ }
1074
+ };
1075
+ const active = this.activePath();
1076
+ try {
1077
+ if (statSync2(active).size > 0) copyOne(active, "active.jsonl");
1078
+ } catch {
1079
+ }
1080
+ for (const sealed of this.listSealed()) {
1081
+ copyOne(sealed, sealed.split("/").pop() ?? "sealed.jsonl");
1082
+ }
1083
+ return total;
1084
+ }
1085
+ };
1086
+
1087
+ // src/metrics.ts
1088
+ var Metric = class {
1089
+ constructor(name, help, labelNames = []) {
1090
+ this.name = name;
1091
+ this.help = help;
1092
+ this.labelNames = labelNames;
1093
+ if (!/^[a-zA-Z_:][a-zA-Z0-9_:]*$/.test(name)) {
1094
+ throw new Error(`Invalid metric name "${name}" \u2014 must match Prometheus naming rules`);
1095
+ }
1096
+ }
1097
+ };
1098
+ var Counter = class extends Metric {
1099
+ values = /* @__PURE__ */ new Map();
1100
+ type() {
1101
+ return "counter";
1102
+ }
1103
+ inc(value = 1, labels = {}) {
1104
+ if (!Number.isFinite(value) || value < 0) return;
1105
+ const key = labelKey(labels, this.labelNames);
1106
+ const existing = this.values.get(key);
1107
+ if (existing) {
1108
+ existing.value += value;
1109
+ } else {
1110
+ this.values.set(key, { labels: orderLabels(labels, this.labelNames), value });
1111
+ }
1112
+ }
1113
+ reset() {
1114
+ this.values.clear();
1115
+ }
1116
+ collect() {
1117
+ return Array.from(this.values.values());
1118
+ }
1119
+ };
1120
+ var Gauge = class extends Metric {
1121
+ values = /* @__PURE__ */ new Map();
1122
+ collectFn = null;
1123
+ type() {
1124
+ return "gauge";
1125
+ }
1126
+ /**
1127
+ * Set a fixed value for the given label set. Per the Prometheus spec, gauges
1128
+ * may take any numeric value including +Inf / -Inf / NaN — the renderer
1129
+ * handles formatting. Only actually-not-a-number inputs (`undefined`, etc.)
1130
+ * are silently dropped.
1131
+ */
1132
+ set(value, labels = {}) {
1133
+ if (typeof value !== "number") return;
1134
+ const key = labelKey(labels, this.labelNames);
1135
+ this.values.set(key, { labels: orderLabels(labels, this.labelNames), value });
1136
+ }
1137
+ /**
1138
+ * Compute the gauge dynamically at scrape time. Useful for things like
1139
+ * "currently-connected sessions" where caching a stale value is wrong.
1140
+ * Mutually exclusive with `set()` — last writer wins.
1141
+ */
1142
+ setCollect(fn) {
1143
+ this.collectFn = () => {
1144
+ const result = fn();
1145
+ if (typeof result === "number") return [{ labels: {}, value: result }];
1146
+ return result;
1147
+ };
1148
+ }
1149
+ collect() {
1150
+ if (this.collectFn) return this.collectFn();
1151
+ return Array.from(this.values.values());
1152
+ }
1153
+ };
1154
+ var MetricsRegistry = class {
1155
+ metrics = [];
1156
+ counter(name, help, labelNames = []) {
1157
+ const c = new Counter(name, help, labelNames);
1158
+ this.metrics.push(c);
1159
+ return c;
1160
+ }
1161
+ gauge(name, help, labelNames = []) {
1162
+ const g = new Gauge(name, help, labelNames);
1163
+ this.metrics.push(g);
1164
+ return g;
1165
+ }
1166
+ /** Serialize every registered metric in Prometheus exposition format. */
1167
+ render() {
1168
+ const out = [];
1169
+ for (const metric of this.metrics) {
1170
+ out.push(`# HELP ${metric.name} ${escapeHelp(metric.help)}`);
1171
+ out.push(`# TYPE ${metric.name} ${metric.type()}`);
1172
+ for (const sample of metric.collect()) {
1173
+ out.push(formatSample(metric.name, sample));
1174
+ }
1175
+ }
1176
+ return out.join("\n") + "\n";
1177
+ }
1178
+ };
1179
+ function labelKey(labels, expected) {
1180
+ const ordered = orderLabels(labels, expected);
1181
+ const pairs = [];
1182
+ for (const name of Object.keys(ordered).sort()) {
1183
+ pairs.push(`${name}=${ordered[name]}`);
1184
+ }
1185
+ return pairs.join("|");
1186
+ }
1187
+ function orderLabels(labels, expected) {
1188
+ const out = {};
1189
+ for (const name of expected) {
1190
+ out[name] = labels[name] ?? "";
1191
+ }
1192
+ return out;
1193
+ }
1194
+ function formatSample(name, sample) {
1195
+ const labelPart = renderLabels(sample.labels);
1196
+ return `${name}${labelPart} ${formatNumber(sample.value)}`;
1197
+ }
1198
+ function renderLabels(labels) {
1199
+ const keys = Object.keys(labels);
1200
+ if (keys.length === 0) return "";
1201
+ const parts = [];
1202
+ for (const k of keys.sort()) {
1203
+ parts.push(`${k}="${escapeLabelValue(labels[k])}"`);
1204
+ }
1205
+ return `{${parts.join(",")}}`;
1206
+ }
1207
+ function escapeLabelValue(v) {
1208
+ return v.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n");
1209
+ }
1210
+ function escapeHelp(v) {
1211
+ return v.replace(/\\/g, "\\\\").replace(/\n/g, "\\n");
1212
+ }
1213
+ function formatNumber(n) {
1214
+ if (Number.isNaN(n)) return "NaN";
1215
+ if (n === Infinity) return "+Inf";
1216
+ if (n === -Infinity) return "-Inf";
1217
+ if (Number.isInteger(n)) return String(n);
1218
+ return n.toString();
1219
+ }
1220
+
1221
+ // src/otel-exporter.ts
1222
+ import { createHash, randomBytes as randomBytes2 } from "crypto";
1223
+ var SpanKind = {
1224
+ INTERNAL: 1,
1225
+ SERVER: 2,
1226
+ CLIENT: 3
1227
+ };
1228
+ var StatusCode = {
1229
+ UNSET: 0,
1230
+ OK: 1,
1231
+ ERROR: 2
1232
+ };
1233
+ var OtelExporter = class {
1234
+ spans = [];
1235
+ logs = [];
1236
+ metrics = [];
1237
+ flushTimer = null;
1238
+ closed = false;
1239
+ lastFailureLog = 0;
1240
+ endpoint;
1241
+ serviceName;
1242
+ serviceNamespace;
1243
+ headers;
1244
+ maxBatchSize;
1245
+ constructor(options) {
1246
+ if (!options.endpoint) throw new Error("OtelExporter requires an endpoint");
1247
+ this.endpoint = options.endpoint.replace(/\/$/, "");
1248
+ this.serviceName = options.serviceName ?? "runtimescope-collector";
1249
+ this.serviceNamespace = options.serviceNamespace;
1250
+ this.headers = options.headers ?? {};
1251
+ this.maxBatchSize = options.maxBatchSize ?? 1e3;
1252
+ const interval = options.flushIntervalMs ?? 1e4;
1253
+ this.flushTimer = setInterval(() => {
1254
+ void this.flush().catch(() => {
1255
+ });
1256
+ }, interval);
1257
+ this.flushTimer.unref?.();
1258
+ }
1259
+ /** Convert a RuntimeScope event to its OTel signal(s) and buffer. */
1260
+ ingest(event) {
1261
+ if (this.closed) return;
1262
+ switch (event.eventType) {
1263
+ case "network":
1264
+ this.spans.push(this.networkToSpan(event));
1265
+ break;
1266
+ case "database":
1267
+ this.spans.push(this.databaseToSpan(event));
1268
+ break;
1269
+ case "render":
1270
+ this.spans.push(...this.renderToSpans(event));
1271
+ break;
1272
+ case "console":
1273
+ this.logs.push(this.consoleToLog(event));
1274
+ break;
1275
+ case "performance": {
1276
+ const m = this.performanceToMetric(event);
1277
+ if (m) this.metrics.push(m);
1278
+ break;
1279
+ }
1280
+ default:
1281
+ break;
1282
+ }
1283
+ if (this.spans.length + this.logs.length + this.metrics.length >= this.maxBatchSize) {
1284
+ void this.flush().catch(() => {
1285
+ });
1286
+ }
1287
+ }
1288
+ /** POST any buffered signals to the OTLP endpoint. */
1289
+ async flush() {
1290
+ if (this.closed) return;
1291
+ const spans = this.spans;
1292
+ const logs = this.logs;
1293
+ const metrics = this.metrics;
1294
+ this.spans = [];
1295
+ this.logs = [];
1296
+ this.metrics = [];
1297
+ const tasks = [];
1298
+ if (spans.length) tasks.push(this.send("/v1/traces", { resourceSpans: this.wrapSpans(spans) }));
1299
+ if (logs.length) tasks.push(this.send("/v1/logs", { resourceLogs: this.wrapLogs(logs) }));
1300
+ if (metrics.length) tasks.push(this.send("/v1/metrics", { resourceMetrics: this.wrapMetrics(metrics) }));
1301
+ await Promise.all(tasks);
1302
+ }
1303
+ async close() {
1304
+ if (this.closed) return;
1305
+ this.closed = true;
1306
+ if (this.flushTimer) {
1307
+ clearInterval(this.flushTimer);
1308
+ this.flushTimer = null;
1309
+ }
1310
+ await this.flush();
1311
+ }
1312
+ // ------------------------------------------------------------------------
1313
+ // Event-to-signal converters
1314
+ // ------------------------------------------------------------------------
1315
+ networkToSpan(e) {
1316
+ const start = (e.timestamp - (e.duration ?? 0)) * 1e6;
1317
+ const end = e.timestamp * 1e6;
1318
+ const isError = e.status >= 400 || e.status === 0;
1319
+ return {
1320
+ traceId: traceIdFromSession(e.sessionId),
1321
+ spanId: randomSpanId(),
1322
+ name: `${e.method} ${stripQuery(e.url)}`,
1323
+ kind: SpanKind.CLIENT,
1324
+ startTimeUnixNano: String(start),
1325
+ endTimeUnixNano: String(end),
1326
+ attributes: [
1327
+ attrStr("http.request.method", e.method),
1328
+ attrStr("url.full", e.url),
1329
+ attrInt("http.response.status_code", e.status),
1330
+ attrInt("http.request.body.size", e.requestBodySize ?? 0),
1331
+ attrInt("http.response.body.size", e.responseBodySize ?? 0),
1332
+ attrStr("runtimescope.session_id", e.sessionId)
1333
+ ],
1334
+ status: {
1335
+ code: isError ? StatusCode.ERROR : StatusCode.OK,
1336
+ message: isError ? `HTTP ${e.status}` : void 0
1337
+ }
1338
+ };
1339
+ }
1340
+ databaseToSpan(e) {
1341
+ const start = (e.timestamp - (e.duration ?? 0)) * 1e6;
1342
+ const end = e.timestamp * 1e6;
1343
+ const primaryTable = e.tablesAccessed?.[0] ?? "";
1344
+ return {
1345
+ traceId: traceIdFromSession(e.sessionId),
1346
+ spanId: randomSpanId(),
1347
+ name: `${(e.operation ?? "query").toUpperCase()} ${primaryTable}`.trim(),
1348
+ kind: SpanKind.CLIENT,
1349
+ startTimeUnixNano: String(start),
1350
+ endTimeUnixNano: String(end),
1351
+ attributes: [
1352
+ attrStr("db.system", e.source ?? "unknown"),
1353
+ attrStr("db.operation", e.operation ?? ""),
1354
+ attrStr("db.statement", e.query ?? ""),
1355
+ attrStr("db.sql.table", primaryTable),
1356
+ attrInt("db.response.returned_rows", e.rowsReturned ?? 0),
1357
+ attrStr("runtimescope.session_id", e.sessionId)
1358
+ ],
1359
+ status: { code: e.error ? StatusCode.ERROR : StatusCode.OK, message: e.error }
1360
+ };
1361
+ }
1362
+ /**
1363
+ * Render events bundle multiple component profiles in one event. We emit
1364
+ * one span per profile so the OTel UI can show each component's render
1365
+ * timing independently — sharing the same trace via the sessionId.
1366
+ */
1367
+ renderToSpans(e) {
1368
+ const traceId = traceIdFromSession(e.sessionId);
1369
+ const profiles = e.profiles ?? [];
1370
+ const out = [];
1371
+ for (const p of profiles) {
1372
+ const dur = p.totalDuration ?? 0;
1373
+ const end = e.timestamp * 1e6;
1374
+ const start = end - Math.round(dur * 1e6);
1375
+ out.push({
1376
+ traceId,
1377
+ spanId: randomSpanId(),
1378
+ name: `render ${p.componentName}`,
1379
+ kind: SpanKind.INTERNAL,
1380
+ startTimeUnixNano: String(start),
1381
+ endTimeUnixNano: String(end),
1382
+ attributes: [
1383
+ attrStr("react.component", p.componentName),
1384
+ attrStr("react.phase", p.lastRenderPhase ?? ""),
1385
+ attrInt("react.render_count", p.renderCount ?? 0),
1386
+ attrStr("react.last_render_cause", p.lastRenderCause ?? ""),
1387
+ attrStr("runtimescope.session_id", e.sessionId)
1388
+ ],
1389
+ status: { code: p.suspicious ? StatusCode.ERROR : StatusCode.UNSET }
1390
+ });
1391
+ }
1392
+ return out;
1393
+ }
1394
+ /**
1395
+ * `console` events with `level: 'error'` carry stack traces; we surface them
1396
+ * via OTel's exception attributes so log-search backends can show them
1397
+ * inline. Lower-severity console events become plain log records.
1398
+ */
1399
+ consoleToLog(e) {
1400
+ const attrs = [attrStr("runtimescope.session_id", e.sessionId)];
1401
+ if (e.source) attrs.push(attrStr("runtimescope.source", e.source));
1402
+ if (e.level === "error" && e.stackTrace) {
1403
+ attrs.push(attrStr("exception.message", e.message ?? ""));
1404
+ attrs.push(attrStr("exception.stacktrace", e.stackTrace));
1405
+ }
1406
+ return {
1407
+ timeUnixNano: String(e.timestamp * 1e6),
1408
+ severityNumber: severityFromConsole(e.level),
1409
+ severityText: e.level,
1410
+ body: { stringValue: e.message ?? "" },
1411
+ attributes: attrs,
1412
+ traceId: traceIdFromSession(e.sessionId)
1413
+ };
1414
+ }
1415
+ performanceToMetric(e) {
1416
+ if (!e.metricName || typeof e.value !== "number") return null;
1417
+ const isWebVital = WEB_VITALS.has(e.metricName);
1418
+ const name = isWebVital ? `runtimescope.web_vitals.${e.metricName.toLowerCase()}` : `runtimescope.server.${e.metricName.toLowerCase()}`;
1419
+ return {
1420
+ name,
1421
+ unit: e.unit ?? webVitalUnit(e.metricName),
1422
+ gauge: {
1423
+ dataPoints: [
1424
+ {
1425
+ asDouble: e.value,
1426
+ timeUnixNano: String(e.timestamp * 1e6),
1427
+ attributes: [
1428
+ attrStr("rating", e.rating ?? "unknown"),
1429
+ attrStr("runtimescope.session_id", e.sessionId)
1430
+ ]
1431
+ }
1432
+ ]
1433
+ }
1434
+ };
1435
+ }
1436
+ // ------------------------------------------------------------------------
1437
+ // OTLP envelope wrapping + transport
1438
+ // ------------------------------------------------------------------------
1439
+ resourceAttributes() {
1440
+ const attrs = [
1441
+ attrStr("service.name", this.serviceName),
1442
+ attrStr("telemetry.sdk.name", "runtimescope"),
1443
+ attrStr("telemetry.sdk.language", "nodejs")
1444
+ ];
1445
+ if (this.serviceNamespace) attrs.push(attrStr("service.namespace", this.serviceNamespace));
1446
+ return attrs;
1447
+ }
1448
+ wrapSpans(spans) {
1449
+ return [
1450
+ {
1451
+ resource: { attributes: this.resourceAttributes() },
1452
+ scopeSpans: [{ scope: { name: "runtimescope" }, spans }]
1453
+ }
1454
+ ];
1455
+ }
1456
+ wrapLogs(logs) {
1457
+ return [
1458
+ {
1459
+ resource: { attributes: this.resourceAttributes() },
1460
+ scopeLogs: [{ scope: { name: "runtimescope" }, logRecords: logs }]
1461
+ }
1462
+ ];
1463
+ }
1464
+ wrapMetrics(metrics) {
1465
+ return [
1466
+ {
1467
+ resource: { attributes: this.resourceAttributes() },
1468
+ scopeMetrics: [{ scope: { name: "runtimescope" }, metrics }]
1469
+ }
1470
+ ];
1471
+ }
1472
+ async send(path, body) {
1473
+ try {
1474
+ const res = await fetch(this.endpoint + path, {
1475
+ method: "POST",
1476
+ headers: {
1477
+ "content-type": "application/json",
1478
+ ...this.headers
1479
+ },
1480
+ body: JSON.stringify(body)
1481
+ });
1482
+ if (!res.ok) {
1483
+ this.logFailure(`OTel POST ${path} \u2192 ${res.status} ${res.statusText}`);
1484
+ }
1485
+ } catch (err) {
1486
+ this.logFailure(`OTel POST ${path} failed: ${err.message}`);
1487
+ }
1488
+ }
1489
+ /** Rate-limit failure logs to once every 60s — don't spam stderr. */
1490
+ logFailure(msg) {
1491
+ const now = Date.now();
1492
+ if (now - this.lastFailureLog < 6e4) return;
1493
+ this.lastFailureLog = now;
1494
+ console.error(`[RuntimeScope] ${msg}`);
1495
+ }
1496
+ };
1497
+ function traceIdFromSession(sessionId) {
1498
+ return createHash("sha256").update(sessionId).digest("hex").slice(0, 32);
1499
+ }
1500
+ function randomSpanId() {
1501
+ return randomBytes2(8).toString("hex");
1502
+ }
1503
+ function attrStr(key, value) {
1504
+ return { key, value: { stringValue: value } };
1505
+ }
1506
+ function attrInt(key, value) {
1507
+ return { key, value: { intValue: String(Math.trunc(value)) } };
1508
+ }
1509
+ function severityFromConsole(level) {
1510
+ switch (level) {
1511
+ case "debug":
1512
+ return 5;
1513
+ case "log":
1514
+ case "info":
1515
+ return 9;
1516
+ case "warn":
1517
+ return 13;
1518
+ case "error":
1519
+ return 17;
1520
+ default:
1521
+ return 9;
1522
+ }
1523
+ }
1524
+ var WEB_VITALS = /* @__PURE__ */ new Set(["LCP", "FCP", "CLS", "TTFB", "FID", "INP"]);
1525
+ function webVitalUnit(name) {
1526
+ return name.toUpperCase() === "CLS" ? "1" : "ms";
1527
+ }
1528
+ function stripQuery(url) {
1529
+ const i = url.indexOf("?");
1530
+ return i >= 0 ? url.slice(0, i) : url;
1531
+ }
1532
+ function parseOtelHeaders(raw) {
1533
+ if (!raw) return {};
1534
+ const out = {};
1535
+ for (const pair of raw.split(",")) {
1536
+ const eq = pair.indexOf("=");
1537
+ if (eq <= 0) continue;
1538
+ const k = pair.slice(0, eq).trim();
1539
+ const v = pair.slice(eq + 1).trim();
1540
+ if (k) out[k] = v;
1541
+ }
1542
+ return out;
1543
+ }
1544
+ function otelOptionsFromEnv() {
1545
+ const endpoint = process.env.RUNTIMESCOPE_OTEL_ENDPOINT;
1546
+ if (!endpoint) return null;
1547
+ return {
1548
+ endpoint,
1549
+ serviceName: process.env.RUNTIMESCOPE_OTEL_SERVICE_NAME,
1550
+ headers: parseOtelHeaders(process.env.RUNTIMESCOPE_OTEL_HEADERS)
1551
+ };
1552
+ }
1553
+
759
1554
  // src/rate-limiter.ts
760
1555
  var SessionRateLimiter = class {
761
1556
  windows = /* @__PURE__ */ new Map();
@@ -845,12 +1640,12 @@ var SessionRateLimiter = class {
845
1640
  };
846
1641
 
847
1642
  // src/tls.ts
848
- import { readFileSync } from "fs";
1643
+ import { readFileSync as readFileSync2 } from "fs";
849
1644
  function loadTlsOptions(config) {
850
1645
  return {
851
- cert: readFileSync(config.certPath, "utf-8"),
852
- key: readFileSync(config.keyPath, "utf-8"),
853
- ...config.caPath ? { ca: readFileSync(config.caPath, "utf-8") } : {}
1646
+ cert: readFileSync2(config.certPath, "utf-8"),
1647
+ key: readFileSync2(config.keyPath, "utf-8"),
1648
+ ...config.caPath ? { ca: readFileSync2(config.caPath, "utf-8") } : {}
854
1649
  };
855
1650
  }
856
1651
  function resolveTlsConfig() {
@@ -867,6 +1662,8 @@ function resolveTlsConfig() {
867
1662
  // src/server.ts
868
1663
  import { createServer as createHttpsServer } from "https";
869
1664
  import { WebSocketServer } from "ws";
1665
+ import { dirname, join as join2 } from "path";
1666
+ import { mkdirSync as mkdirSync2, writeFileSync } from "fs";
870
1667
  var CollectorServer = class {
871
1668
  wss = null;
872
1669
  store;
@@ -877,6 +1674,12 @@ var CollectorServer = class {
877
1674
  pendingHandshakes = /* @__PURE__ */ new Set();
878
1675
  pendingCommands = /* @__PURE__ */ new Map();
879
1676
  sqliteStores = /* @__PURE__ */ new Map();
1677
+ wals = /* @__PURE__ */ new Map();
1678
+ ready = false;
1679
+ metrics = new MetricsRegistry();
1680
+ startedAt = Date.now();
1681
+ counters;
1682
+ otelExporter = null;
880
1683
  connectCallbacks = [];
881
1684
  disconnectCallbacks = [];
882
1685
  pruneTimer = null;
@@ -895,6 +1698,70 @@ var CollectorServer = class {
895
1698
  if (this.rateLimiter.isEnabled()) {
896
1699
  this.pruneTimer = setInterval(() => this.rateLimiter.prune(), 6e4);
897
1700
  }
1701
+ this.counters = {
1702
+ eventsTotal: this.metrics.counter(
1703
+ "runtimescope_events_total",
1704
+ "Total events accepted by the collector since start.",
1705
+ ["type"]
1706
+ ),
1707
+ eventsDropped: this.metrics.counter(
1708
+ "runtimescope_events_dropped_total",
1709
+ "Total events dropped before reaching the in-memory store.",
1710
+ ["reason"]
1711
+ ),
1712
+ wsDisconnects: this.metrics.counter(
1713
+ "runtimescope_ws_disconnects_total",
1714
+ "WebSocket disconnects (clean + abnormal) since start.",
1715
+ ["cause"]
1716
+ )
1717
+ };
1718
+ this.store.onEvent((event) => {
1719
+ this.counters.eventsTotal.inc(1, { type: event.eventType });
1720
+ });
1721
+ const uptime = this.metrics.gauge(
1722
+ "runtimescope_collector_uptime_seconds",
1723
+ "Seconds since the collector process started."
1724
+ );
1725
+ uptime.setCollect(() => Math.floor((Date.now() - this.startedAt) / 1e3));
1726
+ const sessionsConnected = this.metrics.gauge(
1727
+ "runtimescope_sessions_connected",
1728
+ "SDK sessions currently connected via WebSocket."
1729
+ );
1730
+ sessionsConnected.setCollect(
1731
+ () => this.store.getSessionInfo().filter((s) => s.isConnected).length
1732
+ );
1733
+ const bufferSize = this.metrics.gauge(
1734
+ "runtimescope_buffer_size",
1735
+ "Events currently held in the in-memory ring buffer."
1736
+ );
1737
+ bufferSize.setCollect(() => this.store.eventCount);
1738
+ const projectsGauge = this.metrics.gauge(
1739
+ "runtimescope_projects",
1740
+ "Distinct projects (apps) the collector has seen."
1741
+ );
1742
+ projectsGauge.setCollect(() => this.projectManager?.listProjects().length ?? 0);
1743
+ const workspacesGauge = this.metrics.gauge(
1744
+ "runtimescope_workspaces",
1745
+ "Workspaces (multi-tenant containers) registered in PmStore."
1746
+ );
1747
+ workspacesGauge.setCollect(() => {
1748
+ const pm = this.pmStore;
1749
+ return pm?.listWorkspaces ? pm.listWorkspaces().length : 0;
1750
+ });
1751
+ const otelOptions = options.otel ?? otelOptionsFromEnv();
1752
+ if (otelOptions) {
1753
+ this.otelExporter = new OtelExporter(otelOptions);
1754
+ this.store.onEvent((event) => {
1755
+ this.otelExporter?.ingest(event);
1756
+ });
1757
+ console.error(
1758
+ `[RuntimeScope] OpenTelemetry export enabled \u2192 ${otelOptions.endpoint}`
1759
+ );
1760
+ }
1761
+ }
1762
+ /** Public access to the metrics registry — HttpServer renders this at /metrics. */
1763
+ getMetricsRegistry() {
1764
+ return this.metrics;
898
1765
  }
899
1766
  getStore() {
900
1767
  return this.store;
@@ -928,14 +1795,137 @@ var CollectorServer = class {
928
1795
  onDisconnect(cb) {
929
1796
  this.disconnectCallbacks.push(cb);
930
1797
  }
931
- start(options = {}) {
1798
+ /** True after start() finishes recovery. False during startup or after stop(). */
1799
+ isReady() {
1800
+ return this.ready;
1801
+ }
1802
+ /**
1803
+ * Snapshot every project's SQLite DB and WAL into a fresh directory under
1804
+ * `<runtimescope-root>/snapshots/<ISO>/`. Atomic via SQLite's `VACUUM INTO`;
1805
+ * non-blocking for ongoing event ingestion (the live DB keeps accepting
1806
+ * writes during the copy).
1807
+ *
1808
+ * Returns metadata for the admin endpoint to serialize.
1809
+ */
1810
+ createSnapshot() {
1811
+ if (!this.projectManager) {
1812
+ throw new Error("Cannot snapshot \u2014 no projectManager configured");
1813
+ }
1814
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
1815
+ const root = join2(this.projectManager.rootDir, "snapshots", timestamp);
1816
+ mkdirSync2(root, { recursive: true });
1817
+ const projects = [];
1818
+ let totalBytes = 0;
1819
+ for (const projectName of this.projectManager.listProjects()) {
1820
+ const projectDir = join2(root, projectName);
1821
+ mkdirSync2(projectDir, { recursive: true });
1822
+ let sqliteBytes = 0;
1823
+ let eventCount = 0;
1824
+ const sqliteStore = this.sqliteStores.get(projectName);
1825
+ if (sqliteStore) {
1826
+ const sqlitePath = join2(projectDir, "events.db");
1827
+ try {
1828
+ sqliteBytes = sqliteStore.snapshotTo(sqlitePath);
1829
+ eventCount = sqliteStore.getEventCount({ project: projectName });
1830
+ } catch (err) {
1831
+ console.error(
1832
+ `[RuntimeScope] Snapshot of "${projectName}" SQLite failed:`,
1833
+ err.message
1834
+ );
1835
+ }
1836
+ }
1837
+ let walBytes = 0;
1838
+ const wal = this.wals.get(projectName);
1839
+ if (wal) {
1840
+ try {
1841
+ walBytes = wal.snapshotTo(join2(projectDir, "wal"));
1842
+ } catch (err) {
1843
+ console.error(
1844
+ `[RuntimeScope] Snapshot of "${projectName}" WAL failed:`,
1845
+ err.message
1846
+ );
1847
+ }
1848
+ }
1849
+ projects.push({ name: projectName, sqliteBytes, walBytes, eventCount });
1850
+ totalBytes += sqliteBytes + walBytes;
1851
+ }
1852
+ const manifest = {
1853
+ timestamp,
1854
+ createdAt: Date.now(),
1855
+ collectorVersion: process.env.npm_package_version ?? "0.0.0",
1856
+ projects,
1857
+ totalBytes
1858
+ };
1859
+ writeFileSync(join2(root, "manifest.json"), JSON.stringify(manifest, null, 2));
1860
+ return { path: root, timestamp, projects, totalBytes };
1861
+ }
1862
+ async start(options = {}) {
932
1863
  const port = options.port ?? 6767;
933
1864
  const host = options.host ?? "127.0.0.1";
934
1865
  const maxRetries = options.maxRetries ?? 5;
935
1866
  const retryDelayMs = options.retryDelayMs ?? 1e3;
936
1867
  const tls = options.tls ?? this.tlsConfig;
1868
+ try {
1869
+ this.runStartupRecovery();
1870
+ } catch (err) {
1871
+ console.error("[RuntimeScope] Startup recovery failed (non-fatal):", err.message);
1872
+ }
1873
+ this.ready = true;
937
1874
  return this.tryStart(port, host, maxRetries, retryDelayMs, tls);
938
1875
  }
1876
+ /**
1877
+ * On collector startup, for each known project:
1878
+ * 1. Replay any sealed/active WAL files into SqliteStore (mirror of the
1879
+ * lazy recovery in `ensureWal`, but proactive — handles the case where
1880
+ * a crashed project never reconnects).
1881
+ * 2. Warm the in-memory ring buffer with recent events from SqliteStore so
1882
+ * MCP tools see history immediately, not just events from the next
1883
+ * session that connects.
1884
+ *
1885
+ * Runs synchronously — better-sqlite3 is sync — so callers can `await
1886
+ * collector.start()` and trust the buffer is hot when it returns.
1887
+ */
1888
+ runStartupRecovery() {
1889
+ if (!this.projectManager) return;
1890
+ const projects = this.projectManager.listProjects();
1891
+ if (projects.length === 0) return;
1892
+ let walReplayed = 0;
1893
+ let warmed = 0;
1894
+ for (const project of projects) {
1895
+ const dir = this.walDirFor(project);
1896
+ if (dir) {
1897
+ const files = Wal.listRecoveryFiles(dir);
1898
+ if (files.length > 0) {
1899
+ const sqliteStore2 = this.ensureSqliteStore(project);
1900
+ if (sqliteStore2) {
1901
+ for (const file of files) {
1902
+ const events = Wal.readFile(file);
1903
+ for (const ev of events) {
1904
+ try {
1905
+ sqliteStore2.addEvent(ev, project);
1906
+ } catch {
1907
+ }
1908
+ }
1909
+ walReplayed += events.length;
1910
+ }
1911
+ sqliteStore2.flush();
1912
+ for (const file of files) Wal.deleteSealed(file);
1913
+ }
1914
+ }
1915
+ }
1916
+ const sqliteStore = this.ensureSqliteStore(project);
1917
+ if (sqliteStore) {
1918
+ const before = this.store.eventCount;
1919
+ this.store.warmFromSqlite(sqliteStore, project, 1e3);
1920
+ warmed += this.store.eventCount - before;
1921
+ }
1922
+ }
1923
+ if (walReplayed > 0 || warmed > 0) {
1924
+ console.error(
1925
+ `[RuntimeScope] Recovery: ${walReplayed} WAL events replayed, ${warmed} events warmed into ring buffer.`
1926
+ );
1927
+ }
1928
+ }
939
1929
  tryStart(port, host, retriesLeft, retryDelayMs, tls) {
940
1930
  return new Promise((resolve2, reject) => {
941
1931
  let wss;
@@ -974,12 +1964,11 @@ var CollectorServer = class {
974
1964
  }
975
1965
  handleStartError(err, port, host, retriesLeft, retryDelayMs, tls, resolve2, reject) {
976
1966
  if (err.code === "EADDRINUSE" && retriesLeft > 0) {
1967
+ const nextPort = port + 1;
977
1968
  console.error(
978
- `[RuntimeScope] Port ${port} in use, retrying in ${retryDelayMs}ms (${retriesLeft} attempts left)...`
1969
+ `[RuntimeScope] Port ${port} in use, trying ${nextPort}...`
979
1970
  );
980
- setTimeout(() => {
981
- this.tryStart(port, host, retriesLeft - 1, retryDelayMs, tls).then(resolve2).catch(reject);
982
- }, retryDelayMs);
1971
+ this.tryStart(nextPort, host, retriesLeft - 1, retryDelayMs, tls).then(resolve2).catch(reject);
983
1972
  } else {
984
1973
  console.error("[RuntimeScope] WebSocket server error:", err.message);
985
1974
  reject(err);
@@ -1007,6 +1996,88 @@ var CollectorServer = class {
1007
1996
  }
1008
1997
  return sqliteStore;
1009
1998
  }
1999
+ walDirFor(projectName) {
2000
+ if (!this.projectManager) return null;
2001
+ try {
2002
+ this.projectManager.ensureProjectDir(projectName);
2003
+ const dbPath = this.projectManager.getProjectDbPath(projectName);
2004
+ return join2(dirname(dbPath), "wal");
2005
+ } catch {
2006
+ return null;
2007
+ }
2008
+ }
2009
+ /**
2010
+ * Open (or return) the WAL for a project. Every event ingested for this
2011
+ * project is first appended + fsync'd here before being pushed to the ring
2012
+ * buffer and SqliteStore, so a crash between receipt and SqliteStore flush
2013
+ * doesn't lose acknowledged events.
2014
+ */
2015
+ ensureWal(projectName) {
2016
+ if (!this.projectManager) return null;
2017
+ let wal = this.wals.get(projectName);
2018
+ if (wal) return wal;
2019
+ const dir = this.walDirFor(projectName);
2020
+ if (!dir) return null;
2021
+ try {
2022
+ this.recoverWalForProject(projectName, dir);
2023
+ wal = new Wal({ dir });
2024
+ this.wals.set(projectName, wal);
2025
+ return wal;
2026
+ } catch (err) {
2027
+ console.error(
2028
+ `[RuntimeScope] Failed to open WAL for "${projectName}":`,
2029
+ err.message
2030
+ );
2031
+ return null;
2032
+ }
2033
+ }
2034
+ /**
2035
+ * Replay any sealed or non-empty active WAL files into SqliteStore, then
2036
+ * delete them. Called lazily the first time a project's WAL is opened — if
2037
+ * the prior collector crashed mid-batch, those events survive in the WAL
2038
+ * and we'd otherwise leave them stranded on disk forever.
2039
+ */
2040
+ recoverWalForProject(projectName, dir) {
2041
+ const files = Wal.listRecoveryFiles(dir);
2042
+ if (files.length === 0) return;
2043
+ const sqliteStore = this.ensureSqliteStore(projectName);
2044
+ let replayed = 0;
2045
+ for (const file of files) {
2046
+ const events = Wal.readFile(file);
2047
+ if (events.length === 0) {
2048
+ Wal.deleteSealed(file);
2049
+ continue;
2050
+ }
2051
+ if (sqliteStore) {
2052
+ for (const ev of events) {
2053
+ try {
2054
+ sqliteStore.addEvent(ev, projectName);
2055
+ } catch {
2056
+ }
2057
+ }
2058
+ replayed += events.length;
2059
+ }
2060
+ }
2061
+ if (sqliteStore && replayed > 0) {
2062
+ sqliteStore.flush();
2063
+ for (const file of files) Wal.deleteSealed(file);
2064
+ console.error(
2065
+ `[RuntimeScope] WAL recovery: replayed ${replayed} events for "${projectName}"`
2066
+ );
2067
+ }
2068
+ }
2069
+ /**
2070
+ * Rotate the project's WAL, flush SqliteStore so the rotated file's events
2071
+ * are persisted, then delete the sealed file. Called when the active file
2072
+ * has grown past its rotate threshold.
2073
+ */
2074
+ checkpointWal(projectName, wal) {
2075
+ const sealed = wal.rotate();
2076
+ if (!sealed) return;
2077
+ const sqliteStore = this.sqliteStores.get(projectName);
2078
+ sqliteStore?.flush();
2079
+ setTimeout(() => Wal.deleteSealed(sealed), 5e3).unref();
2080
+ }
1010
2081
  /** Catch runtime errors on the WSS so an unhandled error doesn't crash the process */
1011
2082
  setupPersistentErrorHandler(wss) {
1012
2083
  wss.on("error", (err) => {
@@ -1063,7 +2134,9 @@ var CollectorServer = class {
1063
2134
  console.error("[RuntimeScope] Malformed WebSocket message, ignoring");
1064
2135
  }
1065
2136
  });
1066
- ws.on("close", () => {
2137
+ ws.on("close", (code) => {
2138
+ const cause = code === 1e3 || code === 1001 ? "clean" : "abnormal";
2139
+ this.counters.wsDisconnects.inc(1, { cause });
1067
2140
  const clientInfo = this.clients.get(ws);
1068
2141
  if (clientInfo) {
1069
2142
  this.store.markDisconnected(clientInfo.sessionId);
@@ -1141,7 +2214,8 @@ var CollectorServer = class {
1141
2214
  connectedAt: msg.timestamp,
1142
2215
  sdkVersion: payload.sdkVersion,
1143
2216
  eventCount: 0,
1144
- isConnected: true
2217
+ isConnected: true,
2218
+ projectId
1145
2219
  };
1146
2220
  sqliteStore.saveSession(sessionInfo);
1147
2221
  }
@@ -1170,12 +2244,38 @@ var CollectorServer = class {
1170
2244
  if (this.pendingHandshakes.has(ws)) return;
1171
2245
  const clientInfo = this.clients.get(ws);
1172
2246
  const payload = msg.payload;
1173
- if (Array.isArray(payload.events)) {
1174
- for (const event of payload.events) {
1175
- if (clientInfo && !this.rateLimiter.allow(clientInfo.sessionId)) {
1176
- break;
1177
- }
1178
- this.store.addEvent(event);
2247
+ if (!Array.isArray(payload.events)) break;
2248
+ const accepted = [];
2249
+ let rateLimited = 0;
2250
+ for (const event of payload.events) {
2251
+ if (clientInfo && !this.rateLimiter.allow(clientInfo.sessionId)) {
2252
+ rateLimited = payload.events.length - accepted.length;
2253
+ break;
2254
+ }
2255
+ accepted.push(event);
2256
+ }
2257
+ if (rateLimited > 0) {
2258
+ this.counters.eventsDropped.inc(rateLimited, { reason: "rate_limit" });
2259
+ }
2260
+ if (accepted.length === 0) break;
2261
+ const wal = clientInfo?.projectName ? this.ensureWal(clientInfo.projectName) : null;
2262
+ if (wal) {
2263
+ try {
2264
+ wal.append(accepted);
2265
+ wal.commit();
2266
+ } catch (err) {
2267
+ console.error("[RuntimeScope] WAL append/commit failed:", err.message);
2268
+ this.counters.eventsDropped.inc(accepted.length, { reason: "wal_backpressure" });
2269
+ }
2270
+ }
2271
+ for (const event of accepted) {
2272
+ this.store.addEvent(event);
2273
+ }
2274
+ if (wal?.shouldRotate() && clientInfo?.projectName) {
2275
+ try {
2276
+ this.checkpointWal(clientInfo.projectName, wal);
2277
+ } catch (err) {
2278
+ console.error("[RuntimeScope] WAL checkpoint failed:", err.message);
1179
2279
  }
1180
2280
  }
1181
2281
  break;
@@ -1272,6 +2372,21 @@ var CollectorServer = class {
1272
2372
  }
1273
2373
  }
1274
2374
  }
2375
+ if (this.otelExporter) {
2376
+ try {
2377
+ void this.otelExporter.close();
2378
+ } catch {
2379
+ }
2380
+ this.otelExporter = null;
2381
+ }
2382
+ for (const [name, wal] of this.wals) {
2383
+ try {
2384
+ wal.close();
2385
+ } catch {
2386
+ console.error(`[RuntimeScope] WAL close error for "${name}" (non-fatal)`);
2387
+ }
2388
+ }
2389
+ this.wals.clear();
1275
2390
  for (const [name, sqliteStore] of this.sqliteStores) {
1276
2391
  try {
1277
2392
  sqliteStore.close();
@@ -1285,12 +2400,13 @@ var CollectorServer = class {
1285
2400
  this.wss = null;
1286
2401
  console.error("[RuntimeScope] Collector stopped");
1287
2402
  }
2403
+ this.ready = false;
1288
2404
  }
1289
2405
  };
1290
2406
 
1291
2407
  // src/project-manager.ts
1292
- import { mkdirSync, readFileSync as readFileSync2, writeFileSync, existsSync as existsSync2, readdirSync } from "fs";
1293
- import { join } from "path";
2408
+ import { mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync2, existsSync as existsSync3, readdirSync as readdirSync2 } from "fs";
2409
+ import { join as join3 } from "path";
1294
2410
  import { homedir } from "os";
1295
2411
  var DEFAULT_GLOBAL_CONFIG = {
1296
2412
  defaultPort: 6767,
@@ -1301,7 +2417,7 @@ var ProjectManager = class {
1301
2417
  baseDir;
1302
2418
  appProjectIndex = /* @__PURE__ */ new Map();
1303
2419
  constructor(baseDir) {
1304
- this.baseDir = baseDir ?? join(homedir(), ".runtimescope");
2420
+ this.baseDir = baseDir ?? join3(homedir(), ".runtimescope");
1305
2421
  }
1306
2422
  get rootDir() {
1307
2423
  return this.baseDir;
@@ -1310,27 +2426,27 @@ var ProjectManager = class {
1310
2426
  getProjectDir(projectName) {
1311
2427
  const safe = projectName.replace(/[^a-zA-Z0-9_.-]/g, "_");
1312
2428
  if (!safe || safe === "." || safe === "..") {
1313
- return join(this.baseDir, "projects", "_invalid");
2429
+ return join3(this.baseDir, "projects", "_invalid");
1314
2430
  }
1315
- return join(this.baseDir, "projects", safe);
2431
+ return join3(this.baseDir, "projects", safe);
1316
2432
  }
1317
2433
  getProjectDbPath(projectName) {
1318
- return join(this.getProjectDir(projectName), "events.db");
2434
+ return join3(this.getProjectDir(projectName), "events.db");
1319
2435
  }
1320
2436
  // --- Lifecycle (idempotent) ---
1321
2437
  ensureGlobalDir() {
1322
2438
  this.mkdirp(this.baseDir);
1323
- this.mkdirp(join(this.baseDir, "projects"));
1324
- const configPath = join(this.baseDir, "config.json");
1325
- if (!existsSync2(configPath)) {
2439
+ this.mkdirp(join3(this.baseDir, "projects"));
2440
+ const configPath = join3(this.baseDir, "config.json");
2441
+ if (!existsSync3(configPath)) {
1326
2442
  this.writeJson(configPath, DEFAULT_GLOBAL_CONFIG);
1327
2443
  }
1328
2444
  }
1329
2445
  ensureProjectDir(projectName) {
1330
2446
  const projectDir = this.getProjectDir(projectName);
1331
2447
  this.mkdirp(projectDir);
1332
- const configPath = join(projectDir, "config.json");
1333
- if (!existsSync2(configPath)) {
2448
+ const configPath = join3(projectDir, "config.json");
2449
+ if (!existsSync3(configPath)) {
1334
2450
  const config = {
1335
2451
  name: projectName,
1336
2452
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -1343,31 +2459,31 @@ var ProjectManager = class {
1343
2459
  }
1344
2460
  // --- Config ---
1345
2461
  getGlobalConfig() {
1346
- const configPath = join(this.baseDir, "config.json");
1347
- if (!existsSync2(configPath)) return { ...DEFAULT_GLOBAL_CONFIG };
2462
+ const configPath = join3(this.baseDir, "config.json");
2463
+ if (!existsSync3(configPath)) return { ...DEFAULT_GLOBAL_CONFIG };
1348
2464
  return { ...DEFAULT_GLOBAL_CONFIG, ...this.readJson(configPath) };
1349
2465
  }
1350
2466
  saveGlobalConfig(config) {
1351
- this.writeJson(join(this.baseDir, "config.json"), config);
2467
+ this.writeJson(join3(this.baseDir, "config.json"), config);
1352
2468
  }
1353
2469
  getProjectConfig(projectName) {
1354
- const configPath = join(this.getProjectDir(projectName), "config.json");
1355
- if (!existsSync2(configPath)) return null;
2470
+ const configPath = join3(this.getProjectDir(projectName), "config.json");
2471
+ if (!existsSync3(configPath)) return null;
1356
2472
  return this.readJson(configPath);
1357
2473
  }
1358
2474
  saveProjectConfig(projectName, config) {
1359
- this.writeJson(join(this.getProjectDir(projectName), "config.json"), config);
2475
+ this.writeJson(join3(this.getProjectDir(projectName), "config.json"), config);
1360
2476
  }
1361
2477
  getInfrastructureConfig(projectName) {
1362
- const jsonPath = join(this.getProjectDir(projectName), "infrastructure.json");
1363
- if (existsSync2(jsonPath)) {
2478
+ const jsonPath = join3(this.getProjectDir(projectName), "infrastructure.json");
2479
+ if (existsSync3(jsonPath)) {
1364
2480
  const config = this.readJson(jsonPath);
1365
2481
  return this.resolveConfigEnvVars(config);
1366
2482
  }
1367
- const yamlPath = join(this.getProjectDir(projectName), "infrastructure.yaml");
1368
- if (existsSync2(yamlPath)) {
2483
+ const yamlPath = join3(this.getProjectDir(projectName), "infrastructure.yaml");
2484
+ if (existsSync3(yamlPath)) {
1369
2485
  try {
1370
- const content = readFileSync2(yamlPath, "utf-8");
2486
+ const content = readFileSync3(yamlPath, "utf-8");
1371
2487
  return this.resolveConfigEnvVars(this.parseSimpleYaml(content));
1372
2488
  } catch {
1373
2489
  return null;
@@ -1376,18 +2492,18 @@ var ProjectManager = class {
1376
2492
  return null;
1377
2493
  }
1378
2494
  getClaudeInstructions(projectName) {
1379
- const filePath = join(this.getProjectDir(projectName), "claude-instructions.md");
1380
- if (!existsSync2(filePath)) return null;
1381
- return readFileSync2(filePath, "utf-8");
2495
+ const filePath = join3(this.getProjectDir(projectName), "claude-instructions.md");
2496
+ if (!existsSync3(filePath)) return null;
2497
+ return readFileSync3(filePath, "utf-8");
1382
2498
  }
1383
2499
  // --- Discovery ---
1384
2500
  listProjects() {
1385
- const projectsDir = join(this.baseDir, "projects");
1386
- if (!existsSync2(projectsDir)) return [];
1387
- return readdirSync(projectsDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
2501
+ const projectsDir = join3(this.baseDir, "projects");
2502
+ if (!existsSync3(projectsDir)) return [];
2503
+ return readdirSync2(projectsDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
1388
2504
  }
1389
2505
  projectExists(projectName) {
1390
- return existsSync2(this.getProjectDir(projectName));
2506
+ return existsSync3(this.getProjectDir(projectName));
1391
2507
  }
1392
2508
  // --- Project ID helpers ---
1393
2509
  /** Look up the stored projectId for an appName. Returns null if none set. */
@@ -1439,9 +2555,9 @@ var ProjectManager = class {
1439
2555
  for (const p of pmStore.listProjects()) {
1440
2556
  if (p.path) {
1441
2557
  try {
1442
- const configPath = join(p.path, ".runtimescope", "config.json");
1443
- if (existsSync2(configPath)) {
1444
- const content = readFileSync2(configPath, "utf-8");
2558
+ const configPath = join3(p.path, ".runtimescope", "config.json");
2559
+ if (existsSync3(configPath)) {
2560
+ const content = readFileSync3(configPath, "utf-8");
1445
2561
  const config = JSON.parse(content);
1446
2562
  if (config.projectId) {
1447
2563
  if (config.appName) {
@@ -1474,16 +2590,16 @@ var ProjectManager = class {
1474
2590
  }
1475
2591
  // --- Private helpers ---
1476
2592
  mkdirp(dir) {
1477
- if (!existsSync2(dir)) {
1478
- mkdirSync(dir, { recursive: true });
2593
+ if (!existsSync3(dir)) {
2594
+ mkdirSync3(dir, { recursive: true });
1479
2595
  }
1480
2596
  }
1481
2597
  readJson(path) {
1482
- const content = readFileSync2(path, "utf-8");
2598
+ const content = readFileSync3(path, "utf-8");
1483
2599
  return JSON.parse(content);
1484
2600
  }
1485
2601
  writeJson(path, data) {
1486
- writeFileSync(path, JSON.stringify(data, null, 2) + "\n", "utf-8");
2602
+ writeFileSync2(path, JSON.stringify(data, null, 2) + "\n", "utf-8");
1487
2603
  }
1488
2604
  resolveConfigEnvVars(config) {
1489
2605
  const resolve2 = (obj) => {
@@ -1520,8 +2636,8 @@ var ProjectManager = class {
1520
2636
  };
1521
2637
 
1522
2638
  // src/project-config.ts
1523
- import { readFileSync as readFileSync3, existsSync as existsSync3, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2 } from "fs";
1524
- import { join as join2 } from "path";
2639
+ import { readFileSync as readFileSync4, existsSync as existsSync4, writeFileSync as writeFileSync3, mkdirSync as mkdirSync4 } from "fs";
2640
+ import { join as join4 } from "path";
1525
2641
  var DEFAULT_CAPTURE = {
1526
2642
  network: true,
1527
2643
  console: true,
@@ -1536,19 +2652,19 @@ var DEFAULT_CAPTURE = {
1536
2652
  stackTraces: false
1537
2653
  };
1538
2654
  function readProjectConfig(projectDir) {
1539
- const configPath = join2(projectDir, ".runtimescope", "config.json");
1540
- if (!existsSync3(configPath)) return null;
2655
+ const configPath = join4(projectDir, ".runtimescope", "config.json");
2656
+ if (!existsSync4(configPath)) return null;
1541
2657
  try {
1542
- const content = readFileSync3(configPath, "utf-8");
2658
+ const content = readFileSync4(configPath, "utf-8");
1543
2659
  return JSON.parse(content);
1544
2660
  } catch {
1545
2661
  return null;
1546
2662
  }
1547
2663
  }
1548
2664
  function writeProjectConfig(projectDir, config) {
1549
- const dir = join2(projectDir, ".runtimescope");
1550
- if (!existsSync3(dir)) mkdirSync2(dir, { recursive: true });
1551
- writeFileSync2(join2(dir, "config.json"), JSON.stringify(config, null, 2) + "\n", "utf-8");
2665
+ const dir = join4(projectDir, ".runtimescope");
2666
+ if (!existsSync4(dir)) mkdirSync4(dir, { recursive: true });
2667
+ writeFileSync3(join4(dir, "config.json"), JSON.stringify(config, null, 2) + "\n", "utf-8");
1552
2668
  }
1553
2669
  function scaffoldProjectConfig(projectDir, opts) {
1554
2670
  const existing = readProjectConfig(projectDir);
@@ -1579,9 +2695,9 @@ function scaffoldProjectConfig(projectDir, opts) {
1579
2695
  category: opts.category
1580
2696
  };
1581
2697
  writeProjectConfig(projectDir, config);
1582
- const gitignorePath = join2(projectDir, ".runtimescope", ".gitignore");
1583
- if (!existsSync3(gitignorePath)) {
1584
- writeFileSync2(gitignorePath, "# Keep config.json committed, ignore local state\n*.log\n*.db\n.env\n", "utf-8");
2698
+ const gitignorePath = join4(projectDir, ".runtimescope", ".gitignore");
2699
+ if (!existsSync4(gitignorePath)) {
2700
+ writeFileSync3(gitignorePath, "# Keep config.json committed, ignore local state\n*.log\n*.db\n.env\n", "utf-8");
1585
2701
  }
1586
2702
  return config;
1587
2703
  }
@@ -1603,9 +2719,9 @@ function migrateProjectIds(projectManager, pmStore) {
1603
2719
  }
1604
2720
  let canonicalId = null;
1605
2721
  try {
1606
- const configPath = join2(project.path, ".runtimescope", "config.json");
1607
- if (existsSync3(configPath)) {
1608
- const config = JSON.parse(readFileSync3(configPath, "utf-8"));
2722
+ const configPath = join4(project.path, ".runtimescope", "config.json");
2723
+ if (existsSync4(configPath)) {
2724
+ const config = JSON.parse(readFileSync4(configPath, "utf-8"));
1609
2725
  if (config.projectId) canonicalId = config.projectId;
1610
2726
  }
1611
2727
  } catch {
@@ -1643,7 +2759,7 @@ function migrateProjectIds(projectManager, pmStore) {
1643
2759
  }
1644
2760
 
1645
2761
  // src/auth.ts
1646
- import { randomBytes as randomBytes2, timingSafeEqual } from "crypto";
2762
+ import { randomBytes as randomBytes3, timingSafeEqual } from "crypto";
1647
2763
  var AuthManager = class {
1648
2764
  keys = /* @__PURE__ */ new Map();
1649
2765
  enabled;
@@ -1690,7 +2806,7 @@ var AuthManager = class {
1690
2806
  };
1691
2807
  function generateApiKey(label, project) {
1692
2808
  return {
1693
- key: randomBytes2(32).toString("hex"),
2809
+ key: randomBytes3(32).toString("hex"),
1694
2810
  label,
1695
2811
  project,
1696
2812
  createdAt: Date.now()
@@ -1805,8 +2921,8 @@ var Redactor = class {
1805
2921
 
1806
2922
  // src/platform.ts
1807
2923
  import { execFileSync, execSync } from "child_process";
1808
- import { readlinkSync, readdirSync as readdirSync2 } from "fs";
1809
- import { join as join3 } from "path";
2924
+ import { readlinkSync, readdirSync as readdirSync3 } from "fs";
2925
+ import { join as join5 } from "path";
1810
2926
  var IS_WIN = process.platform === "win32";
1811
2927
  var IS_LINUX = process.platform === "linux";
1812
2928
  function runFile(cmd, args, timeoutMs = 5e3) {
@@ -1921,11 +3037,11 @@ function findPidsInDir_linux(dir) {
1921
3037
  if (lsofResult.length > 0) return lsofResult;
1922
3038
  try {
1923
3039
  const pids = [];
1924
- for (const entry of readdirSync2("/proc")) {
3040
+ for (const entry of readdirSync3("/proc")) {
1925
3041
  const pid = parseInt(entry, 10);
1926
3042
  if (isNaN(pid) || pid <= 1) continue;
1927
3043
  try {
1928
- const cwd = readlinkSync(join3("/proc", entry, "cwd"));
3044
+ const cwd = readlinkSync(join5("/proc", entry, "cwd"));
1929
3045
  if (cwd.startsWith(dir)) pids.push(pid);
1930
3046
  } catch {
1931
3047
  }
@@ -2129,15 +3245,15 @@ var SessionManager = class {
2129
3245
  // src/http-server.ts
2130
3246
  import { createServer } from "http";
2131
3247
  import { createServer as createHttpsServer2 } from "https";
2132
- import { readFileSync as readFileSync4, existsSync as existsSync5 } from "fs";
2133
- import { resolve, dirname } from "path";
3248
+ import { readFileSync as readFileSync5, existsSync as existsSync6 } from "fs";
3249
+ import { resolve, dirname as dirname2 } from "path";
2134
3250
  import { fileURLToPath } from "url";
2135
3251
  import { WebSocketServer as WebSocketServer2 } from "ws";
2136
3252
 
2137
3253
  // src/pm/pm-routes.ts
2138
3254
  import { readdir, readFile, writeFile, unlink, mkdir } from "fs/promises";
2139
- import { existsSync as existsSync4 } from "fs";
2140
- import { join as join4 } from "path";
3255
+ import { existsSync as existsSync5 } from "fs";
3256
+ import { join as join6 } from "path";
2141
3257
  import { homedir as homedir2 } from "os";
2142
3258
  import { spawn, execFileSync as execFileSync2 } from "child_process";
2143
3259
  var LOG_RING_SIZE = 500;
@@ -2147,6 +3263,19 @@ function pushLog(mp, stream, line) {
2147
3263
  if (mp.logs.length >= LOG_RING_SIZE) mp.logs.shift();
2148
3264
  mp.logs.push(entry);
2149
3265
  }
3266
+ function requireWorkspaceAccess(helpers, req, res, targetWorkspaceId) {
3267
+ const caller = helpers.resolveCaller(req);
3268
+ if (caller.isAdmin) return true;
3269
+ if (caller.workspaceId && caller.workspaceId === targetWorkspaceId) return true;
3270
+ helpers.json(res, { error: "Forbidden: caller not authorized for this workspace" }, 403);
3271
+ return false;
3272
+ }
3273
+ function requireAdmin(helpers, req, res) {
3274
+ const caller = helpers.resolveCaller(req);
3275
+ if (caller.isAdmin) return true;
3276
+ helpers.json(res, { error: "Forbidden: admin required" }, 403);
3277
+ return false;
3278
+ }
2150
3279
  function createPmRouter(pmStore, discovery, helpers, broadcastDevServer) {
2151
3280
  const routes = [];
2152
3281
  function route(method, pattern, handler) {
@@ -2306,6 +3435,20 @@ function createPmRouter(pmStore, discovery, helpers, broadcastDevServer) {
2306
3435
  helpers.json(res, { error: "Missing workspace_id" }, 400);
2307
3436
  return;
2308
3437
  }
3438
+ const project = pmStore.getProject(id);
3439
+ if (!project) {
3440
+ helpers.json(res, { error: "Project not found" }, 404);
3441
+ return;
3442
+ }
3443
+ const caller = helpers.resolveCaller(req);
3444
+ if (!caller.isAdmin) {
3445
+ const sourceOk = !!project.workspaceId && project.workspaceId === caller.workspaceId;
3446
+ const destOk = parsed.workspace_id === caller.workspaceId;
3447
+ if (!sourceOk || !destOk) {
3448
+ helpers.json(res, { error: "Forbidden: caller must own both source and destination workspace" }, 403);
3449
+ return;
3450
+ }
3451
+ }
2309
3452
  try {
2310
3453
  pmStore.setProjectWorkspace(id, parsed.workspace_id);
2311
3454
  helpers.json(res, { ok: true });
@@ -2313,10 +3456,18 @@ function createPmRouter(pmStore, discovery, helpers, broadcastDevServer) {
2313
3456
  helpers.json(res, { error: err.message }, 400);
2314
3457
  }
2315
3458
  });
2316
- route("GET", "/api/pm/workspaces", (_req, res) => {
2317
- helpers.json(res, { data: pmStore.listWorkspaces() });
3459
+ route("GET", "/api/pm/workspaces", (req, res) => {
3460
+ const caller = helpers.resolveCaller(req);
3461
+ const all = pmStore.listWorkspaces();
3462
+ if (caller.isAdmin) {
3463
+ helpers.json(res, { data: all });
3464
+ return;
3465
+ }
3466
+ const filtered = caller.workspaceId ? all.filter((w) => w.id === caller.workspaceId) : [];
3467
+ helpers.json(res, { data: filtered });
2318
3468
  });
2319
3469
  route("POST", "/api/pm/workspaces", async (req, res) => {
3470
+ if (!requireAdmin(helpers, req, res)) return;
2320
3471
  const body = await helpers.readBody(req, 4096);
2321
3472
  if (!body) {
2322
3473
  helpers.json(res, { error: "Missing body" }, 400);
@@ -2340,8 +3491,9 @@ function createPmRouter(pmStore, discovery, helpers, broadcastDevServer) {
2340
3491
  helpers.json(res, { error: err.message }, 400);
2341
3492
  }
2342
3493
  });
2343
- route("GET", "/api/pm/workspaces/:id", (_req, res, params) => {
3494
+ route("GET", "/api/pm/workspaces/:id", (req, res, params) => {
2344
3495
  const id = params.get("id");
3496
+ if (!requireWorkspaceAccess(helpers, req, res, id)) return;
2345
3497
  const ws = pmStore.getWorkspace(id);
2346
3498
  if (!ws) {
2347
3499
  helpers.json(res, { error: "Workspace not found" }, 404);
@@ -2351,6 +3503,7 @@ function createPmRouter(pmStore, discovery, helpers, broadcastDevServer) {
2351
3503
  });
2352
3504
  route("PUT", "/api/pm/workspaces/:id", async (req, res, params) => {
2353
3505
  const id = params.get("id");
3506
+ if (!requireWorkspaceAccess(helpers, req, res, id)) return;
2354
3507
  if (!pmStore.getWorkspace(id)) {
2355
3508
  helpers.json(res, { error: "Workspace not found" }, 404);
2356
3509
  return;
@@ -2374,7 +3527,8 @@ function createPmRouter(pmStore, discovery, helpers, broadcastDevServer) {
2374
3527
  helpers.json(res, { error: err.message }, 400);
2375
3528
  }
2376
3529
  });
2377
- route("DELETE", "/api/pm/workspaces/:id", (_req, res, params) => {
3530
+ route("DELETE", "/api/pm/workspaces/:id", (req, res, params) => {
3531
+ if (!requireAdmin(helpers, req, res)) return;
2378
3532
  const id = params.get("id");
2379
3533
  try {
2380
3534
  pmStore.deleteWorkspace(id);
@@ -2383,8 +3537,9 @@ function createPmRouter(pmStore, discovery, helpers, broadcastDevServer) {
2383
3537
  helpers.json(res, { error: err.message }, 400);
2384
3538
  }
2385
3539
  });
2386
- route("GET", "/api/pm/workspaces/:id/api-keys", (_req, res, params) => {
3540
+ route("GET", "/api/pm/workspaces/:id/api-keys", (req, res, params) => {
2387
3541
  const id = params.get("id");
3542
+ if (!requireWorkspaceAccess(helpers, req, res, id)) return;
2388
3543
  if (!pmStore.getWorkspace(id)) {
2389
3544
  helpers.json(res, { error: "Workspace not found" }, 404);
2390
3545
  return;
@@ -2393,6 +3548,7 @@ function createPmRouter(pmStore, discovery, helpers, broadcastDevServer) {
2393
3548
  });
2394
3549
  route("POST", "/api/pm/workspaces/:id/api-keys", async (req, res, params) => {
2395
3550
  const id = params.get("id");
3551
+ if (!requireWorkspaceAccess(helpers, req, res, id)) return;
2396
3552
  if (!pmStore.getWorkspace(id)) {
2397
3553
  helpers.json(res, { error: "Workspace not found" }, 404);
2398
3554
  return;
@@ -2420,8 +3576,15 @@ function createPmRouter(pmStore, discovery, helpers, broadcastDevServer) {
2420
3576
  helpers.json(res, { error: err.message }, 400);
2421
3577
  }
2422
3578
  });
2423
- route("DELETE", "/api/pm/api-keys/:key", (_req, res, params) => {
2424
- pmStore.revokeApiKey(params.get("key"));
3579
+ route("DELETE", "/api/pm/api-keys/:prefix", (req, res, params) => {
3580
+ const prefix = params.get("prefix");
3581
+ const key = pmStore.findApiKeyByPrefix(prefix);
3582
+ if (!key) {
3583
+ helpers.json(res, { error: "Key not found" }, 404);
3584
+ return;
3585
+ }
3586
+ if (!requireWorkspaceAccess(helpers, req, res, key.workspaceId)) return;
3587
+ pmStore.revokeApiKey(prefix);
2425
3588
  helpers.json(res, { ok: true });
2426
3589
  });
2427
3590
  route("GET", "/api/pm/tasks", (_req, res, params) => {
@@ -2599,13 +3762,13 @@ function createPmRouter(pmStore, discovery, helpers, broadcastDevServer) {
2599
3762
  helpers.json(res, { data: [], count: 0 });
2600
3763
  return;
2601
3764
  }
2602
- const memoryDir = join4(homedir2(), ".claude", "projects", project.claudeProjectKey, "memory");
3765
+ const memoryDir = join6(homedir2(), ".claude", "projects", project.claudeProjectKey, "memory");
2603
3766
  try {
2604
3767
  const files = await readdir(memoryDir);
2605
3768
  const mdFiles = files.filter((f) => f.endsWith(".md"));
2606
3769
  const result = await Promise.all(
2607
3770
  mdFiles.map(async (filename) => {
2608
- const content = await readFile(join4(memoryDir, filename), "utf-8");
3771
+ const content = await readFile(join6(memoryDir, filename), "utf-8");
2609
3772
  return { filename, content, sizeBytes: Buffer.byteLength(content) };
2610
3773
  })
2611
3774
  );
@@ -2622,7 +3785,7 @@ function createPmRouter(pmStore, discovery, helpers, broadcastDevServer) {
2622
3785
  helpers.json(res, { error: "Project not found" }, 404);
2623
3786
  return;
2624
3787
  }
2625
- const filePath = join4(homedir2(), ".claude", "projects", project.claudeProjectKey, "memory", filename);
3788
+ const filePath = join6(homedir2(), ".claude", "projects", project.claudeProjectKey, "memory", filename);
2626
3789
  try {
2627
3790
  const content = await readFile(filePath, "utf-8");
2628
3791
  helpers.json(res, { filename, content, sizeBytes: Buffer.byteLength(content) });
@@ -2645,9 +3808,9 @@ function createPmRouter(pmStore, discovery, helpers, broadcastDevServer) {
2645
3808
  }
2646
3809
  try {
2647
3810
  const { content } = JSON.parse(body);
2648
- const memoryDir = join4(homedir2(), ".claude", "projects", project.claudeProjectKey, "memory");
3811
+ const memoryDir = join6(homedir2(), ".claude", "projects", project.claudeProjectKey, "memory");
2649
3812
  await mkdir(memoryDir, { recursive: true });
2650
- await writeFile(join4(memoryDir, filename), content, "utf-8");
3813
+ await writeFile(join6(memoryDir, filename), content, "utf-8");
2651
3814
  helpers.json(res, { ok: true });
2652
3815
  } catch (err) {
2653
3816
  helpers.json(res, { error: err.message }, 500);
@@ -2661,7 +3824,7 @@ function createPmRouter(pmStore, discovery, helpers, broadcastDevServer) {
2661
3824
  helpers.json(res, { error: "Project not found" }, 404);
2662
3825
  return;
2663
3826
  }
2664
- const filePath = join4(homedir2(), ".claude", "projects", project.claudeProjectKey, "memory", filename);
3827
+ const filePath = join6(homedir2(), ".claude", "projects", project.claudeProjectKey, "memory", filename);
2665
3828
  try {
2666
3829
  await unlink(filePath);
2667
3830
  helpers.json(res, { ok: true });
@@ -2721,7 +3884,7 @@ function createPmRouter(pmStore, discovery, helpers, broadcastDevServer) {
2721
3884
  const { content } = JSON.parse(body);
2722
3885
  const paths = getRulesPaths(project.claudeProjectKey, project.path);
2723
3886
  const filePath = paths[scope];
2724
- const dir = join4(filePath, "..");
3887
+ const dir = join6(filePath, "..");
2725
3888
  await mkdir(dir, { recursive: true });
2726
3889
  await writeFile(filePath, content, "utf-8");
2727
3890
  helpers.json(res, { ok: true });
@@ -2741,7 +3904,7 @@ function createPmRouter(pmStore, discovery, helpers, broadcastDevServer) {
2741
3904
  return;
2742
3905
  }
2743
3906
  try {
2744
- const pkgPath = join4(project.path, "package.json");
3907
+ const pkgPath = join6(project.path, "package.json");
2745
3908
  const pkg = JSON.parse(await readFile(pkgPath, "utf-8"));
2746
3909
  const scripts = pkg.scripts ?? {};
2747
3910
  const recommended = ["dev", "start", "serve"].find((s) => s in scripts) ?? null;
@@ -3267,9 +4430,9 @@ function sanitizeFilename(name) {
3267
4430
  function getRulesPaths(claudeProjectKey, projectPath) {
3268
4431
  const home = homedir2();
3269
4432
  return {
3270
- global: join4(home, ".claude", "CLAUDE.md"),
3271
- project: claudeProjectKey ? join4(home, ".claude", "projects", claudeProjectKey, "CLAUDE.md") : join4(projectPath ?? "", ".claude", "CLAUDE.md"),
3272
- local: projectPath ? join4(projectPath, "CLAUDE.md") : join4(home, "CLAUDE.md")
4433
+ global: join6(home, ".claude", "CLAUDE.md"),
4434
+ project: claudeProjectKey ? join6(home, ".claude", "projects", claudeProjectKey, "CLAUDE.md") : join6(projectPath ?? "", ".claude", "CLAUDE.md"),
4435
+ local: projectPath ? join6(projectPath, "CLAUDE.md") : join6(home, "CLAUDE.md")
3273
4436
  };
3274
4437
  }
3275
4438
  function execGit(args, cwd) {
@@ -3314,7 +4477,7 @@ function parseGitStatus(porcelain) {
3314
4477
  }
3315
4478
  async function readRuleFile(filePath) {
3316
4479
  try {
3317
- if (existsSync4(filePath)) {
4480
+ if (existsSync5(filePath)) {
3318
4481
  const content = await readFile(filePath, "utf-8");
3319
4482
  return { path: filePath, content, exists: true };
3320
4483
  }
@@ -3326,8 +4489,8 @@ async function readRuleFile(filePath) {
3326
4489
  // src/http-server.ts
3327
4490
  var COLLECTOR_VERSION = (() => {
3328
4491
  try {
3329
- const here = dirname(fileURLToPath(import.meta.url));
3330
- const pkgJson = readFileSync4(resolve(here, "..", "package.json"), "utf-8");
4492
+ const here = dirname2(fileURLToPath(import.meta.url));
4493
+ const pkgJson = readFileSync5(resolve(here, "..", "package.json"), "utf-8");
3331
4494
  const pkg = JSON.parse(pkgJson);
3332
4495
  return pkg.version ?? "unknown";
3333
4496
  } catch {
@@ -3352,6 +4515,10 @@ var HttpServer = class {
3352
4515
  connectedSessionsGetter = null;
3353
4516
  pmStore = null;
3354
4517
  projectManager = null;
4518
+ isReadyGetter = null;
4519
+ snapshotFn = null;
4520
+ lastSnapshotAt = 0;
4521
+ renderMetricsFn = null;
3355
4522
  constructor(store, processMonitor, options) {
3356
4523
  this.store = store;
3357
4524
  this.processMonitor = processMonitor ?? null;
@@ -3361,11 +4528,15 @@ var HttpServer = class {
3361
4528
  this.connectedSessionsGetter = options?.getConnectedSessions ?? null;
3362
4529
  this.pmStore = options?.pmStore ?? null;
3363
4530
  this.projectManager = options?.projectManager ?? null;
4531
+ this.isReadyGetter = options?.isReady ?? null;
4532
+ this.snapshotFn = options?.createSnapshot ?? null;
4533
+ this.renderMetricsFn = options?.renderMetrics ?? null;
3364
4534
  this.registerRoutes();
3365
4535
  if (options?.pmStore && options?.discovery) {
3366
4536
  this.pmRouter = createPmRouter(options.pmStore, options.discovery, {
3367
4537
  json: (res, data, status) => this.json(res, data, status),
3368
- readBody: (req, maxBytes) => this.readBody(req, maxBytes)
4538
+ readBody: (req, maxBytes) => this.readBody(req, maxBytes),
4539
+ resolveCaller: (req) => req._rsCaller ?? { isAdmin: !this.authManager?.isEnabled(), workspaceId: null }
3369
4540
  }, (msg) => this.broadcastDevServer(msg));
3370
4541
  }
3371
4542
  }
@@ -3380,6 +4551,58 @@ var HttpServer = class {
3380
4551
  authEnabled: this.authManager?.isEnabled() ?? false
3381
4552
  });
3382
4553
  });
4554
+ this.routes.set("GET /readyz", (_req, res) => {
4555
+ const ready = this.isReadyGetter ? this.isReadyGetter() : true;
4556
+ if (ready) {
4557
+ this.json(res, { status: "ready", timestamp: Date.now() });
4558
+ } else {
4559
+ this.json(res, { status: "starting", timestamp: Date.now() }, 503);
4560
+ }
4561
+ });
4562
+ this.routes.set("GET /metrics", (_req, res) => {
4563
+ if (process.env.RUNTIMESCOPE_DISABLE_METRICS === "1") {
4564
+ res.writeHead(404, { "Content-Type": "text/plain" });
4565
+ res.end("Metrics disabled (RUNTIMESCOPE_DISABLE_METRICS=1).\n");
4566
+ return;
4567
+ }
4568
+ const body = this.renderMetricsFn ? this.renderMetricsFn() : "";
4569
+ res.writeHead(200, { "Content-Type": "text/plain; version=0.0.4; charset=utf-8" });
4570
+ res.end(body);
4571
+ });
4572
+ this.routes.set("POST /api/v1/admin/snapshot", (req, res) => {
4573
+ if (!this.snapshotFn) {
4574
+ this.json(res, { error: "Snapshot is not available on this collector" }, 501);
4575
+ return;
4576
+ }
4577
+ const caller = req._rsCaller ?? {
4578
+ isAdmin: !this.authManager?.isEnabled(),
4579
+ workspaceId: null
4580
+ };
4581
+ if (!caller.isAdmin) {
4582
+ this.json(res, { error: "Forbidden: snapshot requires admin" }, 403);
4583
+ return;
4584
+ }
4585
+ const now = Date.now();
4586
+ const sinceLast = now - this.lastSnapshotAt;
4587
+ const COOLDOWN_MS = 6e4;
4588
+ if (sinceLast < COOLDOWN_MS) {
4589
+ const retryAfter = Math.ceil((COOLDOWN_MS - sinceLast) / 1e3);
4590
+ res.setHeader("Retry-After", String(retryAfter));
4591
+ this.json(
4592
+ res,
4593
+ { error: "Snapshot rate-limited", retryAfterSeconds: retryAfter },
4594
+ 429
4595
+ );
4596
+ return;
4597
+ }
4598
+ this.lastSnapshotAt = now;
4599
+ try {
4600
+ const result = this.snapshotFn();
4601
+ this.json(res, result, 201);
4602
+ } catch (err) {
4603
+ this.json(res, { error: err.message }, 500);
4604
+ }
4605
+ });
3383
4606
  this.routes.set("GET /api/sessions", (_req, res) => {
3384
4607
  const sessions = this.store.getSessionInfo();
3385
4608
  this.json(res, { data: sessions, count: sessions.length });
@@ -3465,90 +4688,108 @@ var HttpServer = class {
3465
4688
  const ports = this.processMonitor.getPortUsage(port);
3466
4689
  this.json(res, { data: ports, count: ports.length });
3467
4690
  });
3468
- this.routes.set("GET /api/events/network", (_req, res, params) => {
4691
+ this.routes.set("GET /api/events/network", (req, res, params) => {
4692
+ const projectId = this.authorizeProjectIdParam(req, res, params);
4693
+ if (projectId === false) return;
3469
4694
  const events = this.store.getNetworkRequests({
3470
4695
  sinceSeconds: numParam(params, "since_seconds"),
3471
4696
  urlPattern: params.get("url_pattern") ?? void 0,
3472
4697
  method: params.get("method") ?? void 0,
3473
4698
  sessionId: params.get("session_id") ?? void 0,
3474
- projectId: params.get("project_id") ?? void 0
4699
+ projectId
3475
4700
  });
3476
4701
  this.json(res, { data: events, count: events.length });
3477
4702
  });
3478
- this.routes.set("GET /api/events/console", (_req, res, params) => {
4703
+ this.routes.set("GET /api/events/console", (req, res, params) => {
4704
+ const projectId = this.authorizeProjectIdParam(req, res, params);
4705
+ if (projectId === false) return;
3479
4706
  const events = this.store.getConsoleMessages({
3480
4707
  sinceSeconds: numParam(params, "since_seconds"),
3481
4708
  level: params.get("level") ?? void 0,
3482
4709
  search: params.get("search") ?? void 0,
3483
4710
  sessionId: params.get("session_id") ?? void 0,
3484
- projectId: params.get("project_id") ?? void 0
4711
+ projectId
3485
4712
  });
3486
4713
  this.json(res, { data: events, count: events.length });
3487
4714
  });
3488
- this.routes.set("GET /api/events/state", (_req, res, params) => {
4715
+ this.routes.set("GET /api/events/state", (req, res, params) => {
4716
+ const projectId = this.authorizeProjectIdParam(req, res, params);
4717
+ if (projectId === false) return;
3489
4718
  const events = this.store.getStateEvents({
3490
4719
  sinceSeconds: numParam(params, "since_seconds"),
3491
4720
  storeId: params.get("store_id") ?? void 0,
3492
4721
  sessionId: params.get("session_id") ?? void 0,
3493
- projectId: params.get("project_id") ?? void 0
4722
+ projectId
3494
4723
  });
3495
4724
  this.json(res, { data: events, count: events.length });
3496
4725
  });
3497
- this.routes.set("GET /api/events/renders", (_req, res, params) => {
4726
+ this.routes.set("GET /api/events/renders", (req, res, params) => {
4727
+ const projectId = this.authorizeProjectIdParam(req, res, params);
4728
+ if (projectId === false) return;
3498
4729
  const events = this.store.getRenderEvents({
3499
4730
  sinceSeconds: numParam(params, "since_seconds"),
3500
4731
  componentName: params.get("component") ?? void 0,
3501
4732
  sessionId: params.get("session_id") ?? void 0,
3502
- projectId: params.get("project_id") ?? void 0
4733
+ projectId
3503
4734
  });
3504
4735
  this.json(res, { data: events, count: events.length });
3505
4736
  });
3506
- this.routes.set("GET /api/events/performance", (_req, res, params) => {
4737
+ this.routes.set("GET /api/events/performance", (req, res, params) => {
4738
+ const projectId = this.authorizeProjectIdParam(req, res, params);
4739
+ if (projectId === false) return;
3507
4740
  const events = this.store.getPerformanceMetrics({
3508
4741
  sinceSeconds: numParam(params, "since_seconds"),
3509
4742
  metricName: params.get("metric") ?? void 0,
3510
4743
  sessionId: params.get("session_id") ?? void 0,
3511
- projectId: params.get("project_id") ?? void 0
4744
+ projectId
3512
4745
  });
3513
4746
  this.json(res, { data: events, count: events.length });
3514
4747
  });
3515
- this.routes.set("GET /api/events/database", (_req, res, params) => {
4748
+ this.routes.set("GET /api/events/database", (req, res, params) => {
4749
+ const projectId = this.authorizeProjectIdParam(req, res, params);
4750
+ if (projectId === false) return;
3516
4751
  const events = this.store.getDatabaseEvents({
3517
4752
  sinceSeconds: numParam(params, "since_seconds"),
3518
4753
  table: params.get("table") ?? void 0,
3519
4754
  minDurationMs: numParam(params, "min_duration_ms"),
3520
4755
  search: params.get("search") ?? void 0,
3521
4756
  sessionId: params.get("session_id") ?? void 0,
3522
- projectId: params.get("project_id") ?? void 0
4757
+ projectId
3523
4758
  });
3524
4759
  this.json(res, { data: events, count: events.length });
3525
4760
  });
3526
- this.routes.set("GET /api/events/timeline", (_req, res, params) => {
4761
+ this.routes.set("GET /api/events/timeline", (req, res, params) => {
4762
+ const projectId = this.authorizeProjectIdParam(req, res, params);
4763
+ if (projectId === false) return;
3527
4764
  const eventTypes = params.get("event_types")?.split(",") ?? void 0;
3528
4765
  const events = this.store.getEventTimeline({
3529
4766
  sinceSeconds: numParam(params, "since_seconds"),
3530
4767
  eventTypes,
3531
4768
  sessionId: params.get("session_id") ?? void 0,
3532
- projectId: params.get("project_id") ?? void 0
4769
+ projectId
3533
4770
  });
3534
4771
  this.json(res, { data: events, count: events.length });
3535
4772
  });
3536
- this.routes.set("GET /api/events/custom", (_req, res, params) => {
4773
+ this.routes.set("GET /api/events/custom", (req, res, params) => {
4774
+ const projectId = this.authorizeProjectIdParam(req, res, params);
4775
+ if (projectId === false) return;
3537
4776
  const events = this.store.getCustomEvents({
3538
4777
  name: params.get("name") ?? void 0,
3539
4778
  sinceSeconds: numParam(params, "since_seconds"),
3540
4779
  sessionId: params.get("session_id") ?? void 0,
3541
- projectId: params.get("project_id") ?? void 0
4780
+ projectId
3542
4781
  });
3543
4782
  this.json(res, { data: events, count: events.length });
3544
4783
  });
3545
- this.routes.set("GET /api/events/ui", (_req, res, params) => {
4784
+ this.routes.set("GET /api/events/ui", (req, res, params) => {
4785
+ const projectId = this.authorizeProjectIdParam(req, res, params);
4786
+ if (projectId === false) return;
3546
4787
  const action = params.get("action");
3547
4788
  const events = this.store.getUIInteractions({
3548
4789
  action: action ?? void 0,
3549
4790
  sinceSeconds: numParam(params, "since_seconds"),
3550
4791
  sessionId: params.get("session_id") ?? void 0,
3551
- projectId: params.get("project_id") ?? void 0
4792
+ projectId
3552
4793
  });
3553
4794
  this.json(res, { data: events, count: events.length });
3554
4795
  });
@@ -3662,7 +4903,7 @@ var HttpServer = class {
3662
4903
  */
3663
4904
  resolveSdkPath() {
3664
4905
  if (this.sdkBundlePath) return this.sdkBundlePath;
3665
- const __dir = dirname(fileURLToPath(import.meta.url));
4906
+ const __dir = dirname2(fileURLToPath(import.meta.url));
3666
4907
  const candidates = [
3667
4908
  resolve(__dir, "../../sdk/dist/index.global.js"),
3668
4909
  // monorepo: packages/collector/dist -> packages/sdk/dist
@@ -3670,13 +4911,55 @@ var HttpServer = class {
3670
4911
  // npm installed
3671
4912
  ];
3672
4913
  for (const p of candidates) {
3673
- if (existsSync5(p)) {
4914
+ if (existsSync6(p)) {
3674
4915
  this.sdkBundlePath = p;
3675
4916
  return p;
3676
4917
  }
3677
4918
  }
3678
4919
  return null;
3679
4920
  }
4921
+ getPort() {
4922
+ return this.activePort;
4923
+ }
4924
+ /**
4925
+ * Validate the `project_id` query parameter against the caller's workspace.
4926
+ *
4927
+ * Returns:
4928
+ * - `string` — the caller is authorized; pass to store.
4929
+ * - `undefined` — caller is admin and didn't specify a project_id (all projects allowed).
4930
+ * - `false` — not authorized (a 400 or 403 response has already been written); the handler must return immediately.
4931
+ *
4932
+ * Callers with a workspace-scoped token MUST provide `project_id`, and it
4933
+ * must resolve to a PM project in the caller's workspace. Runtime projectIds
4934
+ * without a PM record (never registered via setup_project) fall through to
4935
+ * admin-only; workspace-scoped callers get 403.
4936
+ */
4937
+ authorizeProjectIdParam(req, res, params) {
4938
+ const caller = req._rsCaller ?? {
4939
+ isAdmin: !this.authManager?.isEnabled(),
4940
+ workspaceId: null
4941
+ };
4942
+ const projectId = params.get("project_id") ?? void 0;
4943
+ if (caller.isAdmin) return projectId;
4944
+ if (!projectId) {
4945
+ this.json(
4946
+ res,
4947
+ { error: "project_id query param is required for workspace-scoped callers" },
4948
+ 400
4949
+ );
4950
+ return false;
4951
+ }
4952
+ const projectWorkspaceId = this.pmStore?.getWorkspaceIdByRuntimeProjectId(projectId) ?? null;
4953
+ if (!projectWorkspaceId) {
4954
+ this.json(res, { error: "Forbidden: project is not registered with any workspace" }, 403);
4955
+ return false;
4956
+ }
4957
+ if (projectWorkspaceId !== caller.workspaceId) {
4958
+ this.json(res, { error: "Forbidden: project belongs to a different workspace" }, 403);
4959
+ return false;
4960
+ }
4961
+ return projectId;
4962
+ }
3680
4963
  async start(options = {}) {
3681
4964
  const basePort = options.port ?? parseInt(process.env.RUNTIMESCOPE_HTTP_PORT ?? "6768", 10);
3682
4965
  const host = options.host ?? "127.0.0.1";
@@ -3719,10 +5002,12 @@ var HttpServer = class {
3719
5002
  this.store.onEvent(this.eventListener);
3720
5003
  server.on("listening", () => {
3721
5004
  this.server = server;
3722
- this.activePort = port;
5005
+ const addr = server.address();
5006
+ const boundPort = addr && typeof addr === "object" && typeof addr.port === "number" ? addr.port : port;
5007
+ this.activePort = boundPort;
3723
5008
  this.startedAt = Date.now();
3724
5009
  const proto = tls ? "https" : "http";
3725
- console.error(`[RuntimeScope] HTTP API listening on ${proto}://${host}:${port}`);
5010
+ console.error(`[RuntimeScope] HTTP API listening on ${proto}://${host}:${boundPort}`);
3726
5011
  resolve2();
3727
5012
  });
3728
5013
  server.on("error", (err) => {
@@ -3807,20 +5092,29 @@ var HttpServer = class {
3807
5092
  res.end();
3808
5093
  return;
3809
5094
  }
3810
- const isPublic = url.pathname === "/api/health" || url.pathname === "/runtimescope.js" || url.pathname === "/snippet";
3811
- if (!isPublic && this.authManager?.isEnabled()) {
5095
+ const isPublic = url.pathname === "/api/health" || url.pathname === "/readyz" || url.pathname === "/metrics" || url.pathname === "/runtimescope.js" || url.pathname === "/snippet";
5096
+ const workspaceKeysExist = !!this.pmStore?.hasActiveApiKeys?.();
5097
+ const authActive = !!this.authManager?.isEnabled() || workspaceKeysExist;
5098
+ const caller = {
5099
+ isAdmin: !authActive,
5100
+ workspaceId: null
5101
+ };
5102
+ if (!isPublic && authActive) {
3812
5103
  const token = AuthManager.extractBearer(req.headers.authorization);
3813
- const isGlobal = this.authManager.isAuthorized(token);
3814
- const isWorkspaceToken = !!(token && this.pmStore?.getWorkspaceByApiKey(token));
3815
- if (!isGlobal && !isWorkspaceToken) {
5104
+ const isGlobal = !!(token && this.authManager?.validate(token));
5105
+ const workspace = token ? this.pmStore?.getWorkspaceByApiKey(token) : null;
5106
+ if (!isGlobal && !workspace) {
3816
5107
  this.json(res, { error: "Unauthorized", code: "AUTH_FAILED" }, 401);
3817
5108
  return;
3818
5109
  }
5110
+ caller.isAdmin = isGlobal;
5111
+ caller.workspaceId = workspace?.id ?? null;
3819
5112
  }
5113
+ req._rsCaller = caller;
3820
5114
  if (req.method === "GET" && url.pathname === "/runtimescope.js") {
3821
5115
  const sdkPath = this.resolveSdkPath();
3822
5116
  if (sdkPath) {
3823
- const bundle = readFileSync4(sdkPath, "utf-8");
5117
+ const bundle = readFileSync5(sdkPath, "utf-8");
3824
5118
  res.writeHead(200, {
3825
5119
  "Content-Type": "application/javascript",
3826
5120
  "Cache-Control": "no-cache"
@@ -3944,7 +5238,7 @@ function numParam(params, key) {
3944
5238
 
3945
5239
  // src/pm/pm-store.ts
3946
5240
  import Database from "better-sqlite3";
3947
- import { randomBytes as randomBytes3 } from "crypto";
5241
+ import { randomBytes as randomBytes4, createHash as createHash2, timingSafeEqual as timingSafeEqual2 } from "crypto";
3948
5242
  var PmStore = class {
3949
5243
  db;
3950
5244
  constructor(options) {
@@ -4124,6 +5418,23 @@ var PmStore = class {
4124
5418
  this.db.exec("ALTER TABLE pm_projects ADD COLUMN workspace_id TEXT DEFAULT NULL");
4125
5419
  } catch {
4126
5420
  }
5421
+ let apiKeyColumnsAdded = false;
5422
+ try {
5423
+ this.db.exec("ALTER TABLE pm_api_keys ADD COLUMN key_prefix TEXT");
5424
+ apiKeyColumnsAdded = true;
5425
+ } catch {
5426
+ }
5427
+ try {
5428
+ this.db.exec("ALTER TABLE pm_api_keys ADD COLUMN key_last4 TEXT");
5429
+ } catch {
5430
+ }
5431
+ try {
5432
+ this.db.exec("CREATE INDEX IF NOT EXISTS idx_api_keys_prefix ON pm_api_keys(key_prefix)");
5433
+ } catch {
5434
+ }
5435
+ if (apiKeyColumnsAdded) {
5436
+ this.db.prepare("UPDATE pm_api_keys SET revoked_at = ? WHERE revoked_at IS NULL AND key_prefix IS NULL").run(Date.now());
5437
+ }
4127
5438
  this.ensureDefaultWorkspace();
4128
5439
  }
4129
5440
  /**
@@ -4149,8 +5460,11 @@ var PmStore = class {
4149
5460
  // Projects
4150
5461
  // ============================================================
4151
5462
  upsertProject(project) {
4152
- const workspaceId = project.workspaceId ?? this.db.prepare("SELECT id FROM pm_workspaces WHERE is_default = 1 LIMIT 1").get();
4153
- const resolvedWorkspaceId = typeof workspaceId === "string" ? workspaceId : workspaceId?.id ?? null;
5463
+ let resolvedWorkspaceId = project.workspaceId ?? null;
5464
+ if (!resolvedWorkspaceId) {
5465
+ const row = this.db.prepare("SELECT id FROM pm_workspaces WHERE is_default = 1 LIMIT 1").get();
5466
+ resolvedWorkspaceId = row?.id ?? null;
5467
+ }
4154
5468
  this.db.prepare(`
4155
5469
  INSERT INTO pm_projects (id, workspace_id, name, path, claude_project_key, runtimescope_project,
4156
5470
  phase, management_authorized, probable_to_complete, project_status,
@@ -4387,13 +5701,16 @@ var PmStore = class {
4387
5701
  createApiKey(workspaceId, label, expiresAt) {
4388
5702
  const ws = this.getWorkspace(workspaceId);
4389
5703
  if (!ws) throw new Error(`Workspace ${workspaceId} does not exist`);
4390
- const key = `tk_${randomBytes3(24).toString("hex")}`;
5704
+ const raw = `tk_${randomBytes4(24).toString("hex")}`;
5705
+ const hash = hashApiKey(raw);
5706
+ const prefix = raw.slice(0, 11);
5707
+ const last4 = raw.slice(-4);
4391
5708
  const now = Date.now();
4392
5709
  this.db.prepare(
4393
- `INSERT INTO pm_api_keys (key, workspace_id, label, created_at, expires_at)
4394
- VALUES (?, ?, ?, ?, ?)`
4395
- ).run(key, workspaceId, label, now, expiresAt ?? null);
4396
- return { key, workspaceId, label, createdAt: now, expiresAt };
5710
+ `INSERT INTO pm_api_keys (key, workspace_id, label, created_at, expires_at, key_prefix, key_last4)
5711
+ VALUES (?, ?, ?, ?, ?, ?, ?)`
5712
+ ).run(hash, workspaceId, label, now, expiresAt ?? null, prefix, last4);
5713
+ return { key: raw, keyPrefix: prefix, keyLast4: last4, workspaceId, label, createdAt: now, expiresAt };
4397
5714
  }
4398
5715
  listApiKeys(workspaceId) {
4399
5716
  const rows = this.db.prepare(
@@ -4401,19 +5718,49 @@ var PmStore = class {
4401
5718
  ).all(workspaceId);
4402
5719
  return rows.map(mapApiKeyRow);
4403
5720
  }
4404
- revokeApiKey(key) {
4405
- this.db.prepare("UPDATE pm_api_keys SET revoked_at = ? WHERE key = ?").run(Date.now(), key);
5721
+ /** Revoke by the public prefix (what the dashboard shows), not the raw secret. */
5722
+ revokeApiKey(prefix) {
5723
+ this.db.prepare("UPDATE pm_api_keys SET revoked_at = ? WHERE key_prefix = ?").run(Date.now(), prefix);
5724
+ }
5725
+ /** Look up an API key's workspace by its public prefix. Used for per-workspace authorization on the revoke route. */
5726
+ findApiKeyByPrefix(prefix) {
5727
+ const row = this.db.prepare(`SELECT * FROM pm_api_keys WHERE key_prefix = ? AND revoked_at IS NULL LIMIT 1`).get(prefix);
5728
+ return row ? mapApiKeyRow(row) : null;
5729
+ }
5730
+ /**
5731
+ * True if any non-revoked workspace API key exists. The HTTP auth gate uses
5732
+ * this to enforce auth whenever workspace keys are in play, even when the
5733
+ * global AuthManager has no keys configured. Without this check, a user who
5734
+ * creates a workspace key expecting it to gate access gets no auth at all
5735
+ * (the H5 bypass).
5736
+ */
5737
+ hasActiveApiKeys() {
5738
+ const row = this.db.prepare("SELECT 1 AS n FROM pm_api_keys WHERE revoked_at IS NULL LIMIT 1").get();
5739
+ return !!row;
4406
5740
  }
4407
- getWorkspaceByApiKey(key) {
5741
+ /**
5742
+ * Given a runtime projectId (proj_xxx), return the workspaceId it belongs to,
5743
+ * or null if the projectId isn't registered with any PM project. Used to
5744
+ * enforce per-workspace isolation on event-read routes — the caller can only
5745
+ * query events from runtime projects owned by their workspace.
5746
+ */
5747
+ getWorkspaceIdByRuntimeProjectId(runtimeProjectId) {
5748
+ if (!runtimeProjectId) return null;
5749
+ const row = this.db.prepare("SELECT workspace_id FROM pm_projects WHERE runtime_project_id = ? LIMIT 1").get(runtimeProjectId);
5750
+ return row?.workspace_id ?? null;
5751
+ }
5752
+ getWorkspaceByApiKey(rawKey) {
5753
+ if (!rawKey || typeof rawKey !== "string") return null;
5754
+ const hash = hashApiKey(rawKey);
4408
5755
  const row = this.db.prepare(
4409
5756
  `SELECT w.* FROM pm_api_keys k
4410
5757
  JOIN pm_workspaces w ON w.id = k.workspace_id
4411
5758
  WHERE k.key = ? AND k.revoked_at IS NULL
4412
5759
  AND (k.expires_at IS NULL OR k.expires_at > ?)`
4413
- ).get(key, Date.now());
5760
+ ).get(hash, Date.now());
4414
5761
  if (!row) return null;
4415
5762
  try {
4416
- this.db.prepare("UPDATE pm_api_keys SET last_used_at = ? WHERE key = ?").run(Date.now(), key);
5763
+ this.db.prepare("UPDATE pm_api_keys SET last_used_at = ? WHERE key = ?").run(Date.now(), hash);
4417
5764
  } catch {
4418
5765
  }
4419
5766
  return mapWorkspaceRow(row);
@@ -5212,8 +6559,11 @@ var PmStore = class {
5212
6559
  this.db.close();
5213
6560
  }
5214
6561
  };
6562
+ function hashApiKey(raw) {
6563
+ return createHash2("sha256").update(raw, "utf8").digest("hex");
6564
+ }
5215
6565
  function generateWorkspaceId() {
5216
- return `ws_${randomBytes3(8).toString("hex")}`;
6566
+ return `ws_${randomBytes4(8).toString("hex")}`;
5217
6567
  }
5218
6568
  function mapWorkspaceRow(row) {
5219
6569
  return {
@@ -5228,7 +6578,11 @@ function mapWorkspaceRow(row) {
5228
6578
  }
5229
6579
  function mapApiKeyRow(row) {
5230
6580
  return {
5231
- key: row.key,
6581
+ // Never return the stored value (it's the hash) — list responses expose
6582
+ // only prefix + last4 for display. The raw token appears once, at create.
6583
+ key: "",
6584
+ keyPrefix: row.key_prefix ?? "",
6585
+ keyLast4: row.key_last4 ?? "",
5232
6586
  workspaceId: row.workspace_id,
5233
6587
  label: row.label,
5234
6588
  createdAt: row.created_at,
@@ -5443,13 +6797,13 @@ async function parseSessionJsonl(jsonlPath, sessionId, projectId) {
5443
6797
 
5444
6798
  // src/pm/project-discovery.ts
5445
6799
  import { readdir as readdir2, readFile as readFile2, stat as stat2 } from "fs/promises";
5446
- import { join as join5, basename as basename2 } from "path";
5447
- import { existsSync as existsSync6 } from "fs";
6800
+ import { join as join7, basename as basename2 } from "path";
6801
+ import { existsSync as existsSync7 } from "fs";
5448
6802
  import { homedir as homedir3 } from "os";
5449
6803
  var LOG_PREFIX = "[RuntimeScope PM]";
5450
6804
  async function detectSdkInstalled(projectPath) {
5451
6805
  try {
5452
- const pkgPath = join5(projectPath, "package.json");
6806
+ const pkgPath = join7(projectPath, "package.json");
5453
6807
  const pkg = JSON.parse(await readFile2(pkgPath, "utf-8"));
5454
6808
  const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
5455
6809
  if ("@runtimescope/sdk" in allDeps || "@runtimescope/server-sdk" in allDeps) {
@@ -5459,13 +6813,13 @@ async function detectSdkInstalled(projectPath) {
5459
6813
  const workspaces = Array.isArray(pkg.workspaces) ? pkg.workspaces : pkg.workspaces.packages ?? [];
5460
6814
  for (const ws of workspaces) {
5461
6815
  const wsBase = ws.replace(/\/?\*$/, "");
5462
- const wsDir = join5(projectPath, wsBase);
6816
+ const wsDir = join7(projectPath, wsBase);
5463
6817
  try {
5464
6818
  const entries = await readdir2(wsDir, { withFileTypes: true });
5465
6819
  for (const entry of entries) {
5466
6820
  if (!entry.isDirectory()) continue;
5467
6821
  try {
5468
- const wsPkg = JSON.parse(await readFile2(join5(wsDir, entry.name, "package.json"), "utf-8"));
6822
+ const wsPkg = JSON.parse(await readFile2(join7(wsDir, entry.name, "package.json"), "utf-8"));
5469
6823
  const wsDeps = { ...wsPkg.dependencies, ...wsPkg.devDependencies };
5470
6824
  if ("@runtimescope/sdk" in wsDeps || "@runtimescope/server-sdk" in wsDeps) {
5471
6825
  return true;
@@ -5480,7 +6834,7 @@ async function detectSdkInstalled(projectPath) {
5480
6834
  } catch {
5481
6835
  }
5482
6836
  try {
5483
- await stat2(join5(projectPath, "node_modules", "@runtimescope"));
6837
+ await stat2(join7(projectPath, "node_modules", "@runtimescope"));
5484
6838
  return true;
5485
6839
  } catch {
5486
6840
  return false;
@@ -5511,7 +6865,7 @@ function slugifyPath(fsPath) {
5511
6865
  }
5512
6866
  function decodeClaudeKey(key) {
5513
6867
  const naive = "/" + key.slice(1).replace(/-/g, "/");
5514
- if (existsSync6(naive)) return naive;
6868
+ if (existsSync7(naive)) return naive;
5515
6869
  const parts = key.slice(1).split("-");
5516
6870
  return resolvePathSegments(parts);
5517
6871
  }
@@ -5519,16 +6873,16 @@ function resolvePathSegments(parts) {
5519
6873
  if (parts.length === 0) return null;
5520
6874
  function tryResolve(prefix, remaining) {
5521
6875
  if (remaining.length === 0) {
5522
- return existsSync6(prefix) ? prefix : null;
6876
+ return existsSync7(prefix) ? prefix : null;
5523
6877
  }
5524
6878
  for (let count = remaining.length; count >= 1; count--) {
5525
6879
  const segment = remaining.slice(0, count).join("-");
5526
- const candidate = join5(prefix, segment);
6880
+ const candidate = join7(prefix, segment);
5527
6881
  if (count === remaining.length) {
5528
- if (existsSync6(candidate)) return candidate;
6882
+ if (existsSync7(candidate)) return candidate;
5529
6883
  } else {
5530
6884
  try {
5531
- if (existsSync6(candidate)) {
6885
+ if (existsSync7(candidate)) {
5532
6886
  const result = tryResolve(candidate, remaining.slice(count));
5533
6887
  if (result) return result;
5534
6888
  }
@@ -5551,7 +6905,7 @@ var ProjectDiscovery = class {
5551
6905
  constructor(pmStore, projectManager, claudeBaseDir) {
5552
6906
  this.pmStore = pmStore;
5553
6907
  this.projectManager = projectManager;
5554
- this.claudeBaseDir = claudeBaseDir ?? join5(homedir3(), ".claude");
6908
+ this.claudeBaseDir = claudeBaseDir ?? join7(homedir3(), ".claude");
5555
6909
  }
5556
6910
  claudeBaseDir;
5557
6911
  /**
@@ -5584,7 +6938,7 @@ var ProjectDiscovery = class {
5584
6938
  sessionsUpdated: 0,
5585
6939
  errors: []
5586
6940
  };
5587
- const projectsDir = join5(this.claudeBaseDir, "projects");
6941
+ const projectsDir = join7(this.claudeBaseDir, "projects");
5588
6942
  try {
5589
6943
  await stat2(projectsDir);
5590
6944
  } catch {
@@ -5697,10 +7051,10 @@ var ProjectDiscovery = class {
5697
7051
  if (!project.claudeProjectKey) {
5698
7052
  return 0;
5699
7053
  }
5700
- const projectDir = join5(this.claudeBaseDir, "projects", project.claudeProjectKey);
7054
+ const projectDir = join7(this.claudeBaseDir, "projects", project.claudeProjectKey);
5701
7055
  let sessionsIndexed = 0;
5702
7056
  try {
5703
- const indexPath = join5(projectDir, "sessions-index.json");
7057
+ const indexPath = join7(projectDir, "sessions-index.json");
5704
7058
  let indexEntries = null;
5705
7059
  try {
5706
7060
  const indexContent = await readFile2(indexPath, "utf-8");
@@ -5713,7 +7067,7 @@ var ProjectDiscovery = class {
5713
7067
  for (const jsonlFile of jsonlFiles) {
5714
7068
  try {
5715
7069
  const sessionId = jsonlFile.replace(".jsonl", "");
5716
- const jsonlPath = join5(projectDir, jsonlFile);
7070
+ const jsonlPath = join7(projectDir, jsonlFile);
5717
7071
  const fileStat = await stat2(jsonlPath);
5718
7072
  const fileSize = fileStat.size;
5719
7073
  const existingSession = await this.pmStore.getSession(sessionId);
@@ -5748,7 +7102,7 @@ var ProjectDiscovery = class {
5748
7102
  * Process a single Claude project directory key.
5749
7103
  */
5750
7104
  async processClaudeProject(key, result) {
5751
- const projectDir = join5(this.claudeBaseDir, "projects", key);
7105
+ const projectDir = join7(this.claudeBaseDir, "projects", key);
5752
7106
  let fsPath = decodeClaudeKey(key);
5753
7107
  if (!fsPath) {
5754
7108
  fsPath = await this.resolvePathFromIndex(projectDir);
@@ -5802,7 +7156,7 @@ var ProjectDiscovery = class {
5802
7156
  */
5803
7157
  async resolvePathFromIndex(projectDir) {
5804
7158
  try {
5805
- const indexPath = join5(projectDir, "sessions-index.json");
7159
+ const indexPath = join7(projectDir, "sessions-index.json");
5806
7160
  const content = await readFile2(indexPath, "utf-8");
5807
7161
  const index = JSON.parse(content);
5808
7162
  const entry = index.entries?.find((e) => e.projectPath);
@@ -5817,11 +7171,11 @@ var ProjectDiscovery = class {
5817
7171
  */
5818
7172
  async indexSessionsForClaudeProject(projectId, claudeKey) {
5819
7173
  const counts = { discovered: 0, updated: 0 };
5820
- const projectDir = join5(this.claudeBaseDir, "projects", claudeKey);
7174
+ const projectDir = join7(this.claudeBaseDir, "projects", claudeKey);
5821
7175
  try {
5822
7176
  let indexEntries = null;
5823
7177
  try {
5824
- const indexPath = join5(projectDir, "sessions-index.json");
7178
+ const indexPath = join7(projectDir, "sessions-index.json");
5825
7179
  const indexContent = await readFile2(indexPath, "utf-8");
5826
7180
  const index = JSON.parse(indexContent);
5827
7181
  indexEntries = index.entries ?? [];
@@ -5832,7 +7186,7 @@ var ProjectDiscovery = class {
5832
7186
  for (const jsonlFile of jsonlFiles) {
5833
7187
  try {
5834
7188
  const sessionId = jsonlFile.replace(".jsonl", "");
5835
- const jsonlPath = join5(projectDir, jsonlFile);
7189
+ const jsonlPath = join7(projectDir, jsonlFile);
5836
7190
  const fileStat = await stat2(jsonlPath);
5837
7191
  const fileSize = fileStat.size;
5838
7192
  const existingSession = await this.pmStore.getSession(sessionId);
@@ -6017,6 +7371,15 @@ export {
6017
7371
  getOrCreateProjectId,
6018
7372
  SqliteStore,
6019
7373
  isSqliteAvailable,
7374
+ Wal,
7375
+ Counter,
7376
+ Gauge,
7377
+ MetricsRegistry,
7378
+ OtelExporter,
7379
+ traceIdFromSession,
7380
+ randomSpanId,
7381
+ parseOtelHeaders,
7382
+ otelOptionsFromEnv,
6020
7383
  SessionRateLimiter,
6021
7384
  loadTlsOptions,
6022
7385
  resolveTlsConfig,
@@ -6045,4 +7408,4 @@ export {
6045
7408
  parseSessionJsonl,
6046
7409
  ProjectDiscovery
6047
7410
  };
6048
- //# sourceMappingURL=chunk-WWFIEANS.js.map
7411
+ //# sourceMappingURL=chunk-M2V4EFJY.js.map