@runtimescope/collector 0.9.3 → 0.10.1

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 = {}) {
932
- const port = options.port ?? 9090;
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 = {}) {
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);
@@ -1090,7 +2163,15 @@ var CollectorServer = class {
1090
2163
  switch (msg.type) {
1091
2164
  case "handshake": {
1092
2165
  const payload = msg.payload;
1093
- if (this.authManager?.isEnabled()) {
2166
+ let workspaceFromKey = null;
2167
+ if (payload.authToken && this.pmStore?.getWorkspaceByApiKey) {
2168
+ try {
2169
+ const ws2 = this.pmStore.getWorkspaceByApiKey(payload.authToken);
2170
+ if (ws2) workspaceFromKey = { id: ws2.id, slug: ws2.slug };
2171
+ } catch {
2172
+ }
2173
+ }
2174
+ if (this.authManager?.isEnabled() && !workspaceFromKey) {
1094
2175
  if (!this.authManager.isAuthorized(payload.authToken)) {
1095
2176
  try {
1096
2177
  ws.send(JSON.stringify({
@@ -1103,15 +2184,27 @@ var CollectorServer = class {
1103
2184
  ws.close(4001, "Authentication failed");
1104
2185
  return;
1105
2186
  }
1106
- this.pendingHandshakes.delete(ws);
1107
2187
  }
2188
+ this.pendingHandshakes.delete(ws);
1108
2189
  const projectName = payload.appName;
1109
2190
  const projectId = payload.projectId ?? (this.projectManager ? resolveProjectId(this.projectManager, projectName, this.pmStore) : void 0);
1110
2191
  this.clients.set(ws, {
1111
2192
  sessionId: payload.sessionId,
1112
2193
  projectName,
1113
- projectId
2194
+ projectId,
2195
+ workspaceId: workspaceFromKey?.id
1114
2196
  });
2197
+ if (workspaceFromKey && projectId && this.pmStore?.listProjects && this.pmStore.setProjectWorkspace) {
2198
+ try {
2199
+ const existing = this.pmStore.listProjects().find(
2200
+ (p) => p.runtimeProjectId === projectId
2201
+ );
2202
+ if (existing && !existing.workspaceId) {
2203
+ this.pmStore.setProjectWorkspace(existing.id, workspaceFromKey.id);
2204
+ }
2205
+ } catch {
2206
+ }
2207
+ }
1115
2208
  const sqliteStore = this.ensureSqliteStore(projectName);
1116
2209
  if (sqliteStore) {
1117
2210
  const sessionInfo = {
@@ -1121,7 +2214,8 @@ var CollectorServer = class {
1121
2214
  connectedAt: msg.timestamp,
1122
2215
  sdkVersion: payload.sdkVersion,
1123
2216
  eventCount: 0,
1124
- isConnected: true
2217
+ isConnected: true,
2218
+ projectId
1125
2219
  };
1126
2220
  sqliteStore.saveSession(sessionInfo);
1127
2221
  }
@@ -1150,12 +2244,38 @@ var CollectorServer = class {
1150
2244
  if (this.pendingHandshakes.has(ws)) return;
1151
2245
  const clientInfo = this.clients.get(ws);
1152
2246
  const payload = msg.payload;
1153
- if (Array.isArray(payload.events)) {
1154
- for (const event of payload.events) {
1155
- if (clientInfo && !this.rateLimiter.allow(clientInfo.sessionId)) {
1156
- break;
1157
- }
1158
- 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);
1159
2279
  }
1160
2280
  }
1161
2281
  break;
@@ -1252,6 +2372,21 @@ var CollectorServer = class {
1252
2372
  }
1253
2373
  }
1254
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();
1255
2390
  for (const [name, sqliteStore] of this.sqliteStores) {
1256
2391
  try {
1257
2392
  sqliteStore.close();
@@ -1265,23 +2400,24 @@ var CollectorServer = class {
1265
2400
  this.wss = null;
1266
2401
  console.error("[RuntimeScope] Collector stopped");
1267
2402
  }
2403
+ this.ready = false;
1268
2404
  }
1269
2405
  };
1270
2406
 
1271
2407
  // src/project-manager.ts
1272
- import { mkdirSync, readFileSync as readFileSync2, writeFileSync, existsSync as existsSync2, readdirSync } from "fs";
1273
- 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";
1274
2410
  import { homedir } from "os";
1275
2411
  var DEFAULT_GLOBAL_CONFIG = {
1276
- defaultPort: 9090,
2412
+ defaultPort: 6767,
1277
2413
  bufferSize: 1e4,
1278
- httpPort: 9091
2414
+ httpPort: 6768
1279
2415
  };
1280
2416
  var ProjectManager = class {
1281
2417
  baseDir;
1282
2418
  appProjectIndex = /* @__PURE__ */ new Map();
1283
2419
  constructor(baseDir) {
1284
- this.baseDir = baseDir ?? join(homedir(), ".runtimescope");
2420
+ this.baseDir = baseDir ?? join3(homedir(), ".runtimescope");
1285
2421
  }
1286
2422
  get rootDir() {
1287
2423
  return this.baseDir;
@@ -1290,27 +2426,27 @@ var ProjectManager = class {
1290
2426
  getProjectDir(projectName) {
1291
2427
  const safe = projectName.replace(/[^a-zA-Z0-9_.-]/g, "_");
1292
2428
  if (!safe || safe === "." || safe === "..") {
1293
- return join(this.baseDir, "projects", "_invalid");
2429
+ return join3(this.baseDir, "projects", "_invalid");
1294
2430
  }
1295
- return join(this.baseDir, "projects", safe);
2431
+ return join3(this.baseDir, "projects", safe);
1296
2432
  }
1297
2433
  getProjectDbPath(projectName) {
1298
- return join(this.getProjectDir(projectName), "events.db");
2434
+ return join3(this.getProjectDir(projectName), "events.db");
1299
2435
  }
1300
2436
  // --- Lifecycle (idempotent) ---
1301
2437
  ensureGlobalDir() {
1302
2438
  this.mkdirp(this.baseDir);
1303
- this.mkdirp(join(this.baseDir, "projects"));
1304
- const configPath = join(this.baseDir, "config.json");
1305
- if (!existsSync2(configPath)) {
2439
+ this.mkdirp(join3(this.baseDir, "projects"));
2440
+ const configPath = join3(this.baseDir, "config.json");
2441
+ if (!existsSync3(configPath)) {
1306
2442
  this.writeJson(configPath, DEFAULT_GLOBAL_CONFIG);
1307
2443
  }
1308
2444
  }
1309
2445
  ensureProjectDir(projectName) {
1310
2446
  const projectDir = this.getProjectDir(projectName);
1311
2447
  this.mkdirp(projectDir);
1312
- const configPath = join(projectDir, "config.json");
1313
- if (!existsSync2(configPath)) {
2448
+ const configPath = join3(projectDir, "config.json");
2449
+ if (!existsSync3(configPath)) {
1314
2450
  const config = {
1315
2451
  name: projectName,
1316
2452
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -1323,31 +2459,31 @@ var ProjectManager = class {
1323
2459
  }
1324
2460
  // --- Config ---
1325
2461
  getGlobalConfig() {
1326
- const configPath = join(this.baseDir, "config.json");
1327
- if (!existsSync2(configPath)) return { ...DEFAULT_GLOBAL_CONFIG };
2462
+ const configPath = join3(this.baseDir, "config.json");
2463
+ if (!existsSync3(configPath)) return { ...DEFAULT_GLOBAL_CONFIG };
1328
2464
  return { ...DEFAULT_GLOBAL_CONFIG, ...this.readJson(configPath) };
1329
2465
  }
1330
2466
  saveGlobalConfig(config) {
1331
- this.writeJson(join(this.baseDir, "config.json"), config);
2467
+ this.writeJson(join3(this.baseDir, "config.json"), config);
1332
2468
  }
1333
2469
  getProjectConfig(projectName) {
1334
- const configPath = join(this.getProjectDir(projectName), "config.json");
1335
- if (!existsSync2(configPath)) return null;
2470
+ const configPath = join3(this.getProjectDir(projectName), "config.json");
2471
+ if (!existsSync3(configPath)) return null;
1336
2472
  return this.readJson(configPath);
1337
2473
  }
1338
2474
  saveProjectConfig(projectName, config) {
1339
- this.writeJson(join(this.getProjectDir(projectName), "config.json"), config);
2475
+ this.writeJson(join3(this.getProjectDir(projectName), "config.json"), config);
1340
2476
  }
1341
2477
  getInfrastructureConfig(projectName) {
1342
- const jsonPath = join(this.getProjectDir(projectName), "infrastructure.json");
1343
- if (existsSync2(jsonPath)) {
2478
+ const jsonPath = join3(this.getProjectDir(projectName), "infrastructure.json");
2479
+ if (existsSync3(jsonPath)) {
1344
2480
  const config = this.readJson(jsonPath);
1345
2481
  return this.resolveConfigEnvVars(config);
1346
2482
  }
1347
- const yamlPath = join(this.getProjectDir(projectName), "infrastructure.yaml");
1348
- if (existsSync2(yamlPath)) {
2483
+ const yamlPath = join3(this.getProjectDir(projectName), "infrastructure.yaml");
2484
+ if (existsSync3(yamlPath)) {
1349
2485
  try {
1350
- const content = readFileSync2(yamlPath, "utf-8");
2486
+ const content = readFileSync3(yamlPath, "utf-8");
1351
2487
  return this.resolveConfigEnvVars(this.parseSimpleYaml(content));
1352
2488
  } catch {
1353
2489
  return null;
@@ -1356,18 +2492,18 @@ var ProjectManager = class {
1356
2492
  return null;
1357
2493
  }
1358
2494
  getClaudeInstructions(projectName) {
1359
- const filePath = join(this.getProjectDir(projectName), "claude-instructions.md");
1360
- if (!existsSync2(filePath)) return null;
1361
- 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");
1362
2498
  }
1363
2499
  // --- Discovery ---
1364
2500
  listProjects() {
1365
- const projectsDir = join(this.baseDir, "projects");
1366
- if (!existsSync2(projectsDir)) return [];
1367
- 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);
1368
2504
  }
1369
2505
  projectExists(projectName) {
1370
- return existsSync2(this.getProjectDir(projectName));
2506
+ return existsSync3(this.getProjectDir(projectName));
1371
2507
  }
1372
2508
  // --- Project ID helpers ---
1373
2509
  /** Look up the stored projectId for an appName. Returns null if none set. */
@@ -1419,9 +2555,9 @@ var ProjectManager = class {
1419
2555
  for (const p of pmStore.listProjects()) {
1420
2556
  if (p.path) {
1421
2557
  try {
1422
- const configPath = join(p.path, ".runtimescope", "config.json");
1423
- if (existsSync2(configPath)) {
1424
- 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");
1425
2561
  const config = JSON.parse(content);
1426
2562
  if (config.projectId) {
1427
2563
  if (config.appName) {
@@ -1454,16 +2590,16 @@ var ProjectManager = class {
1454
2590
  }
1455
2591
  // --- Private helpers ---
1456
2592
  mkdirp(dir) {
1457
- if (!existsSync2(dir)) {
1458
- mkdirSync(dir, { recursive: true });
2593
+ if (!existsSync3(dir)) {
2594
+ mkdirSync3(dir, { recursive: true });
1459
2595
  }
1460
2596
  }
1461
2597
  readJson(path) {
1462
- const content = readFileSync2(path, "utf-8");
2598
+ const content = readFileSync3(path, "utf-8");
1463
2599
  return JSON.parse(content);
1464
2600
  }
1465
2601
  writeJson(path, data) {
1466
- writeFileSync(path, JSON.stringify(data, null, 2) + "\n", "utf-8");
2602
+ writeFileSync2(path, JSON.stringify(data, null, 2) + "\n", "utf-8");
1467
2603
  }
1468
2604
  resolveConfigEnvVars(config) {
1469
2605
  const resolve2 = (obj) => {
@@ -1500,8 +2636,8 @@ var ProjectManager = class {
1500
2636
  };
1501
2637
 
1502
2638
  // src/project-config.ts
1503
- import { readFileSync as readFileSync3, existsSync as existsSync3, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2 } from "fs";
1504
- 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";
1505
2641
  var DEFAULT_CAPTURE = {
1506
2642
  network: true,
1507
2643
  console: true,
@@ -1516,19 +2652,19 @@ var DEFAULT_CAPTURE = {
1516
2652
  stackTraces: false
1517
2653
  };
1518
2654
  function readProjectConfig(projectDir) {
1519
- const configPath = join2(projectDir, ".runtimescope", "config.json");
1520
- if (!existsSync3(configPath)) return null;
2655
+ const configPath = join4(projectDir, ".runtimescope", "config.json");
2656
+ if (!existsSync4(configPath)) return null;
1521
2657
  try {
1522
- const content = readFileSync3(configPath, "utf-8");
2658
+ const content = readFileSync4(configPath, "utf-8");
1523
2659
  return JSON.parse(content);
1524
2660
  } catch {
1525
2661
  return null;
1526
2662
  }
1527
2663
  }
1528
2664
  function writeProjectConfig(projectDir, config) {
1529
- const dir = join2(projectDir, ".runtimescope");
1530
- if (!existsSync3(dir)) mkdirSync2(dir, { recursive: true });
1531
- 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");
1532
2668
  }
1533
2669
  function scaffoldProjectConfig(projectDir, opts) {
1534
2670
  const existing = readProjectConfig(projectDir);
@@ -1547,7 +2683,7 @@ function scaffoldProjectConfig(projectDir, opts) {
1547
2683
  return existing;
1548
2684
  }
1549
2685
  const projectId = generateProjectId();
1550
- const httpPort = process.env.RUNTIMESCOPE_HTTP_PORT ?? "9091";
2686
+ const httpPort = process.env.RUNTIMESCOPE_HTTP_PORT ?? "6768";
1551
2687
  const dsn = `runtimescope://${projectId}@localhost:${httpPort}/${opts.appName}`;
1552
2688
  const config = {
1553
2689
  projectId,
@@ -1559,9 +2695,9 @@ function scaffoldProjectConfig(projectDir, opts) {
1559
2695
  category: opts.category
1560
2696
  };
1561
2697
  writeProjectConfig(projectDir, config);
1562
- const gitignorePath = join2(projectDir, ".runtimescope", ".gitignore");
1563
- if (!existsSync3(gitignorePath)) {
1564
- 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");
1565
2701
  }
1566
2702
  return config;
1567
2703
  }
@@ -1583,9 +2719,9 @@ function migrateProjectIds(projectManager, pmStore) {
1583
2719
  }
1584
2720
  let canonicalId = null;
1585
2721
  try {
1586
- const configPath = join2(project.path, ".runtimescope", "config.json");
1587
- if (existsSync3(configPath)) {
1588
- 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"));
1589
2725
  if (config.projectId) canonicalId = config.projectId;
1590
2726
  }
1591
2727
  } catch {
@@ -1623,7 +2759,7 @@ function migrateProjectIds(projectManager, pmStore) {
1623
2759
  }
1624
2760
 
1625
2761
  // src/auth.ts
1626
- import { randomBytes as randomBytes2, timingSafeEqual } from "crypto";
2762
+ import { randomBytes as randomBytes3, timingSafeEqual } from "crypto";
1627
2763
  var AuthManager = class {
1628
2764
  keys = /* @__PURE__ */ new Map();
1629
2765
  enabled;
@@ -1670,7 +2806,7 @@ var AuthManager = class {
1670
2806
  };
1671
2807
  function generateApiKey(label, project) {
1672
2808
  return {
1673
- key: randomBytes2(32).toString("hex"),
2809
+ key: randomBytes3(32).toString("hex"),
1674
2810
  label,
1675
2811
  project,
1676
2812
  createdAt: Date.now()
@@ -1785,8 +2921,8 @@ var Redactor = class {
1785
2921
 
1786
2922
  // src/platform.ts
1787
2923
  import { execFileSync, execSync } from "child_process";
1788
- import { readlinkSync, readdirSync as readdirSync2 } from "fs";
1789
- import { join as join3 } from "path";
2924
+ import { readlinkSync, readdirSync as readdirSync3 } from "fs";
2925
+ import { join as join5 } from "path";
1790
2926
  var IS_WIN = process.platform === "win32";
1791
2927
  var IS_LINUX = process.platform === "linux";
1792
2928
  function runFile(cmd, args, timeoutMs = 5e3) {
@@ -1901,11 +3037,11 @@ function findPidsInDir_linux(dir) {
1901
3037
  if (lsofResult.length > 0) return lsofResult;
1902
3038
  try {
1903
3039
  const pids = [];
1904
- for (const entry of readdirSync2("/proc")) {
3040
+ for (const entry of readdirSync3("/proc")) {
1905
3041
  const pid = parseInt(entry, 10);
1906
3042
  if (isNaN(pid) || pid <= 1) continue;
1907
3043
  try {
1908
- const cwd = readlinkSync(join3("/proc", entry, "cwd"));
3044
+ const cwd = readlinkSync(join5("/proc", entry, "cwd"));
1909
3045
  if (cwd.startsWith(dir)) pids.push(pid);
1910
3046
  } catch {
1911
3047
  }
@@ -2109,15 +3245,15 @@ var SessionManager = class {
2109
3245
  // src/http-server.ts
2110
3246
  import { createServer } from "http";
2111
3247
  import { createServer as createHttpsServer2 } from "https";
2112
- import { readFileSync as readFileSync4, existsSync as existsSync5 } from "fs";
2113
- import { resolve, dirname } from "path";
3248
+ import { readFileSync as readFileSync5, existsSync as existsSync6 } from "fs";
3249
+ import { resolve, dirname as dirname2 } from "path";
2114
3250
  import { fileURLToPath } from "url";
2115
3251
  import { WebSocketServer as WebSocketServer2 } from "ws";
2116
3252
 
2117
3253
  // src/pm/pm-routes.ts
2118
3254
  import { readdir, readFile, writeFile, unlink, mkdir } from "fs/promises";
2119
- import { existsSync as existsSync4 } from "fs";
2120
- import { join as join4 } from "path";
3255
+ import { existsSync as existsSync5 } from "fs";
3256
+ import { join as join6 } from "path";
2121
3257
  import { homedir as homedir2 } from "os";
2122
3258
  import { spawn, execFileSync as execFileSync2 } from "child_process";
2123
3259
  var LOG_RING_SIZE = 500;
@@ -2127,6 +3263,19 @@ function pushLog(mp, stream, line) {
2127
3263
  if (mp.logs.length >= LOG_RING_SIZE) mp.logs.shift();
2128
3264
  mp.logs.push(entry);
2129
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
+ }
2130
3279
  function createPmRouter(pmStore, discovery, helpers, broadcastDevServer) {
2131
3280
  const routes = [];
2132
3281
  function route(method, pattern, handler) {
@@ -2156,7 +3305,11 @@ function createPmRouter(pmStore, discovery, helpers, broadcastDevServer) {
2156
3305
  helpers.json(res, { ...project, stats });
2157
3306
  return;
2158
3307
  }
2159
- const projects = pmStore.listProjects();
3308
+ let projects = pmStore.listProjects();
3309
+ const workspaceId = params.get("workspace_id");
3310
+ if (workspaceId) {
3311
+ projects = projects.filter((p) => p.workspaceId === workspaceId);
3312
+ }
2160
3313
  helpers.json(res, { data: projects, count: projects.length });
2161
3314
  });
2162
3315
  route("GET", "/api/pm/projects/export-csv", (_req, res, params) => {
@@ -2264,6 +3417,176 @@ function createPmRouter(pmStore, discovery, helpers, broadcastDevServer) {
2264
3417
  pmStore.deleteProject(id);
2265
3418
  helpers.json(res, { ok: true, deleted: project.name });
2266
3419
  });
3420
+ route("PUT", "/api/pm/projects/:id/workspace", async (req, res, params) => {
3421
+ const id = params.get("id");
3422
+ const body = await helpers.readBody(req, 4096);
3423
+ if (!body) {
3424
+ helpers.json(res, { error: "Missing body" }, 400);
3425
+ return;
3426
+ }
3427
+ let parsed;
3428
+ try {
3429
+ parsed = JSON.parse(body);
3430
+ } catch {
3431
+ helpers.json(res, { error: "Invalid JSON" }, 400);
3432
+ return;
3433
+ }
3434
+ if (!parsed.workspace_id) {
3435
+ helpers.json(res, { error: "Missing workspace_id" }, 400);
3436
+ return;
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
+ }
3452
+ try {
3453
+ pmStore.setProjectWorkspace(id, parsed.workspace_id);
3454
+ helpers.json(res, { ok: true });
3455
+ } catch (err) {
3456
+ helpers.json(res, { error: err.message }, 400);
3457
+ }
3458
+ });
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 });
3468
+ });
3469
+ route("POST", "/api/pm/workspaces", async (req, res) => {
3470
+ if (!requireAdmin(helpers, req, res)) return;
3471
+ const body = await helpers.readBody(req, 4096);
3472
+ if (!body) {
3473
+ helpers.json(res, { error: "Missing body" }, 400);
3474
+ return;
3475
+ }
3476
+ let parsed;
3477
+ try {
3478
+ parsed = JSON.parse(body);
3479
+ } catch {
3480
+ helpers.json(res, { error: "Invalid JSON" }, 400);
3481
+ return;
3482
+ }
3483
+ if (!parsed.name || typeof parsed.name !== "string") {
3484
+ helpers.json(res, { error: "Missing name" }, 400);
3485
+ return;
3486
+ }
3487
+ try {
3488
+ const ws = pmStore.createWorkspace({ name: parsed.name, slug: parsed.slug, description: parsed.description });
3489
+ helpers.json(res, ws, 201);
3490
+ } catch (err) {
3491
+ helpers.json(res, { error: err.message }, 400);
3492
+ }
3493
+ });
3494
+ route("GET", "/api/pm/workspaces/:id", (req, res, params) => {
3495
+ const id = params.get("id");
3496
+ if (!requireWorkspaceAccess(helpers, req, res, id)) return;
3497
+ const ws = pmStore.getWorkspace(id);
3498
+ if (!ws) {
3499
+ helpers.json(res, { error: "Workspace not found" }, 404);
3500
+ return;
3501
+ }
3502
+ helpers.json(res, ws);
3503
+ });
3504
+ route("PUT", "/api/pm/workspaces/:id", async (req, res, params) => {
3505
+ const id = params.get("id");
3506
+ if (!requireWorkspaceAccess(helpers, req, res, id)) return;
3507
+ if (!pmStore.getWorkspace(id)) {
3508
+ helpers.json(res, { error: "Workspace not found" }, 404);
3509
+ return;
3510
+ }
3511
+ const body = await helpers.readBody(req, 4096);
3512
+ if (!body) {
3513
+ helpers.json(res, { error: "Missing body" }, 400);
3514
+ return;
3515
+ }
3516
+ let parsed;
3517
+ try {
3518
+ parsed = JSON.parse(body);
3519
+ } catch {
3520
+ helpers.json(res, { error: "Invalid JSON" }, 400);
3521
+ return;
3522
+ }
3523
+ try {
3524
+ pmStore.updateWorkspace(id, parsed);
3525
+ helpers.json(res, pmStore.getWorkspace(id));
3526
+ } catch (err) {
3527
+ helpers.json(res, { error: err.message }, 400);
3528
+ }
3529
+ });
3530
+ route("DELETE", "/api/pm/workspaces/:id", (req, res, params) => {
3531
+ if (!requireAdmin(helpers, req, res)) return;
3532
+ const id = params.get("id");
3533
+ try {
3534
+ pmStore.deleteWorkspace(id);
3535
+ helpers.json(res, { ok: true });
3536
+ } catch (err) {
3537
+ helpers.json(res, { error: err.message }, 400);
3538
+ }
3539
+ });
3540
+ route("GET", "/api/pm/workspaces/:id/api-keys", (req, res, params) => {
3541
+ const id = params.get("id");
3542
+ if (!requireWorkspaceAccess(helpers, req, res, id)) return;
3543
+ if (!pmStore.getWorkspace(id)) {
3544
+ helpers.json(res, { error: "Workspace not found" }, 404);
3545
+ return;
3546
+ }
3547
+ helpers.json(res, { data: pmStore.listApiKeys(id) });
3548
+ });
3549
+ route("POST", "/api/pm/workspaces/:id/api-keys", async (req, res, params) => {
3550
+ const id = params.get("id");
3551
+ if (!requireWorkspaceAccess(helpers, req, res, id)) return;
3552
+ if (!pmStore.getWorkspace(id)) {
3553
+ helpers.json(res, { error: "Workspace not found" }, 404);
3554
+ return;
3555
+ }
3556
+ const body = await helpers.readBody(req, 4096);
3557
+ if (!body) {
3558
+ helpers.json(res, { error: "Missing body" }, 400);
3559
+ return;
3560
+ }
3561
+ let parsed;
3562
+ try {
3563
+ parsed = JSON.parse(body);
3564
+ } catch {
3565
+ helpers.json(res, { error: "Invalid JSON" }, 400);
3566
+ return;
3567
+ }
3568
+ if (!parsed.label) {
3569
+ helpers.json(res, { error: "Missing label" }, 400);
3570
+ return;
3571
+ }
3572
+ try {
3573
+ const key = pmStore.createApiKey(id, parsed.label, parsed.expires_at);
3574
+ helpers.json(res, key, 201);
3575
+ } catch (err) {
3576
+ helpers.json(res, { error: err.message }, 400);
3577
+ }
3578
+ });
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);
3588
+ helpers.json(res, { ok: true });
3589
+ });
2267
3590
  route("GET", "/api/pm/tasks", (_req, res, params) => {
2268
3591
  const projectId = params.get("project_id") ?? void 0;
2269
3592
  const status = params.get("status") ?? void 0;
@@ -2439,13 +3762,13 @@ function createPmRouter(pmStore, discovery, helpers, broadcastDevServer) {
2439
3762
  helpers.json(res, { data: [], count: 0 });
2440
3763
  return;
2441
3764
  }
2442
- const memoryDir = join4(homedir2(), ".claude", "projects", project.claudeProjectKey, "memory");
3765
+ const memoryDir = join6(homedir2(), ".claude", "projects", project.claudeProjectKey, "memory");
2443
3766
  try {
2444
3767
  const files = await readdir(memoryDir);
2445
3768
  const mdFiles = files.filter((f) => f.endsWith(".md"));
2446
3769
  const result = await Promise.all(
2447
3770
  mdFiles.map(async (filename) => {
2448
- const content = await readFile(join4(memoryDir, filename), "utf-8");
3771
+ const content = await readFile(join6(memoryDir, filename), "utf-8");
2449
3772
  return { filename, content, sizeBytes: Buffer.byteLength(content) };
2450
3773
  })
2451
3774
  );
@@ -2462,7 +3785,7 @@ function createPmRouter(pmStore, discovery, helpers, broadcastDevServer) {
2462
3785
  helpers.json(res, { error: "Project not found" }, 404);
2463
3786
  return;
2464
3787
  }
2465
- const filePath = join4(homedir2(), ".claude", "projects", project.claudeProjectKey, "memory", filename);
3788
+ const filePath = join6(homedir2(), ".claude", "projects", project.claudeProjectKey, "memory", filename);
2466
3789
  try {
2467
3790
  const content = await readFile(filePath, "utf-8");
2468
3791
  helpers.json(res, { filename, content, sizeBytes: Buffer.byteLength(content) });
@@ -2485,9 +3808,9 @@ function createPmRouter(pmStore, discovery, helpers, broadcastDevServer) {
2485
3808
  }
2486
3809
  try {
2487
3810
  const { content } = JSON.parse(body);
2488
- const memoryDir = join4(homedir2(), ".claude", "projects", project.claudeProjectKey, "memory");
3811
+ const memoryDir = join6(homedir2(), ".claude", "projects", project.claudeProjectKey, "memory");
2489
3812
  await mkdir(memoryDir, { recursive: true });
2490
- await writeFile(join4(memoryDir, filename), content, "utf-8");
3813
+ await writeFile(join6(memoryDir, filename), content, "utf-8");
2491
3814
  helpers.json(res, { ok: true });
2492
3815
  } catch (err) {
2493
3816
  helpers.json(res, { error: err.message }, 500);
@@ -2501,7 +3824,7 @@ function createPmRouter(pmStore, discovery, helpers, broadcastDevServer) {
2501
3824
  helpers.json(res, { error: "Project not found" }, 404);
2502
3825
  return;
2503
3826
  }
2504
- const filePath = join4(homedir2(), ".claude", "projects", project.claudeProjectKey, "memory", filename);
3827
+ const filePath = join6(homedir2(), ".claude", "projects", project.claudeProjectKey, "memory", filename);
2505
3828
  try {
2506
3829
  await unlink(filePath);
2507
3830
  helpers.json(res, { ok: true });
@@ -2561,7 +3884,7 @@ function createPmRouter(pmStore, discovery, helpers, broadcastDevServer) {
2561
3884
  const { content } = JSON.parse(body);
2562
3885
  const paths = getRulesPaths(project.claudeProjectKey, project.path);
2563
3886
  const filePath = paths[scope];
2564
- const dir = join4(filePath, "..");
3887
+ const dir = join6(filePath, "..");
2565
3888
  await mkdir(dir, { recursive: true });
2566
3889
  await writeFile(filePath, content, "utf-8");
2567
3890
  helpers.json(res, { ok: true });
@@ -2581,7 +3904,7 @@ function createPmRouter(pmStore, discovery, helpers, broadcastDevServer) {
2581
3904
  return;
2582
3905
  }
2583
3906
  try {
2584
- const pkgPath = join4(project.path, "package.json");
3907
+ const pkgPath = join6(project.path, "package.json");
2585
3908
  const pkg = JSON.parse(await readFile(pkgPath, "utf-8"));
2586
3909
  const scripts = pkg.scripts ?? {};
2587
3910
  const recommended = ["dev", "start", "serve"].find((s) => s in scripts) ?? null;
@@ -3107,9 +4430,9 @@ function sanitizeFilename(name) {
3107
4430
  function getRulesPaths(claudeProjectKey, projectPath) {
3108
4431
  const home = homedir2();
3109
4432
  return {
3110
- global: join4(home, ".claude", "CLAUDE.md"),
3111
- project: claudeProjectKey ? join4(home, ".claude", "projects", claudeProjectKey, "CLAUDE.md") : join4(projectPath ?? "", ".claude", "CLAUDE.md"),
3112
- 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")
3113
4436
  };
3114
4437
  }
3115
4438
  function execGit(args, cwd) {
@@ -3154,7 +4477,7 @@ function parseGitStatus(porcelain) {
3154
4477
  }
3155
4478
  async function readRuleFile(filePath) {
3156
4479
  try {
3157
- if (existsSync4(filePath)) {
4480
+ if (existsSync5(filePath)) {
3158
4481
  const content = await readFile(filePath, "utf-8");
3159
4482
  return { path: filePath, content, exists: true };
3160
4483
  }
@@ -3164,6 +4487,16 @@ async function readRuleFile(filePath) {
3164
4487
  }
3165
4488
 
3166
4489
  // src/http-server.ts
4490
+ var COLLECTOR_VERSION = (() => {
4491
+ try {
4492
+ const here = dirname2(fileURLToPath(import.meta.url));
4493
+ const pkgJson = readFileSync5(resolve(here, "..", "package.json"), "utf-8");
4494
+ const pkg = JSON.parse(pkgJson);
4495
+ return pkg.version ?? "unknown";
4496
+ } catch {
4497
+ return "unknown";
4498
+ }
4499
+ })();
3167
4500
  var HttpServer = class {
3168
4501
  server = null;
3169
4502
  wss = null;
@@ -3177,11 +4510,15 @@ var HttpServer = class {
3177
4510
  routes = /* @__PURE__ */ new Map();
3178
4511
  pmRouter = null;
3179
4512
  sdkBundlePath = null;
3180
- activePort = 9091;
4513
+ activePort = 6768;
3181
4514
  startedAt = Date.now();
3182
4515
  connectedSessionsGetter = null;
3183
4516
  pmStore = null;
3184
4517
  projectManager = null;
4518
+ isReadyGetter = null;
4519
+ snapshotFn = null;
4520
+ lastSnapshotAt = 0;
4521
+ renderMetricsFn = null;
3185
4522
  constructor(store, processMonitor, options) {
3186
4523
  this.store = store;
3187
4524
  this.processMonitor = processMonitor ?? null;
@@ -3191,11 +4528,15 @@ var HttpServer = class {
3191
4528
  this.connectedSessionsGetter = options?.getConnectedSessions ?? null;
3192
4529
  this.pmStore = options?.pmStore ?? null;
3193
4530
  this.projectManager = options?.projectManager ?? null;
4531
+ this.isReadyGetter = options?.isReady ?? null;
4532
+ this.snapshotFn = options?.createSnapshot ?? null;
4533
+ this.renderMetricsFn = options?.renderMetrics ?? null;
3194
4534
  this.registerRoutes();
3195
4535
  if (options?.pmStore && options?.discovery) {
3196
4536
  this.pmRouter = createPmRouter(options.pmStore, options.discovery, {
3197
4537
  json: (res, data, status) => this.json(res, data, status),
3198
- 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 }
3199
4540
  }, (msg) => this.broadcastDevServer(msg));
3200
4541
  }
3201
4542
  }
@@ -3203,12 +4544,65 @@ var HttpServer = class {
3203
4544
  this.routes.set("GET /api/health", (_req, res) => {
3204
4545
  this.json(res, {
3205
4546
  status: "ok",
4547
+ version: COLLECTOR_VERSION,
3206
4548
  timestamp: Date.now(),
3207
4549
  uptime: Math.floor((Date.now() - this.startedAt) / 1e3),
3208
4550
  sessions: this.store.getSessionInfo().filter((s) => s.isConnected).length,
3209
4551
  authEnabled: this.authManager?.isEnabled() ?? false
3210
4552
  });
3211
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
+ });
3212
4606
  this.routes.set("GET /api/sessions", (_req, res) => {
3213
4607
  const sessions = this.store.getSessionInfo();
3214
4608
  this.json(res, { data: sessions, count: sessions.length });
@@ -3294,90 +4688,108 @@ var HttpServer = class {
3294
4688
  const ports = this.processMonitor.getPortUsage(port);
3295
4689
  this.json(res, { data: ports, count: ports.length });
3296
4690
  });
3297
- 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;
3298
4694
  const events = this.store.getNetworkRequests({
3299
4695
  sinceSeconds: numParam(params, "since_seconds"),
3300
4696
  urlPattern: params.get("url_pattern") ?? void 0,
3301
4697
  method: params.get("method") ?? void 0,
3302
4698
  sessionId: params.get("session_id") ?? void 0,
3303
- projectId: params.get("project_id") ?? void 0
4699
+ projectId
3304
4700
  });
3305
4701
  this.json(res, { data: events, count: events.length });
3306
4702
  });
3307
- 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;
3308
4706
  const events = this.store.getConsoleMessages({
3309
4707
  sinceSeconds: numParam(params, "since_seconds"),
3310
4708
  level: params.get("level") ?? void 0,
3311
4709
  search: params.get("search") ?? void 0,
3312
4710
  sessionId: params.get("session_id") ?? void 0,
3313
- projectId: params.get("project_id") ?? void 0
4711
+ projectId
3314
4712
  });
3315
4713
  this.json(res, { data: events, count: events.length });
3316
4714
  });
3317
- 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;
3318
4718
  const events = this.store.getStateEvents({
3319
4719
  sinceSeconds: numParam(params, "since_seconds"),
3320
4720
  storeId: params.get("store_id") ?? void 0,
3321
4721
  sessionId: params.get("session_id") ?? void 0,
3322
- projectId: params.get("project_id") ?? void 0
4722
+ projectId
3323
4723
  });
3324
4724
  this.json(res, { data: events, count: events.length });
3325
4725
  });
3326
- 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;
3327
4729
  const events = this.store.getRenderEvents({
3328
4730
  sinceSeconds: numParam(params, "since_seconds"),
3329
4731
  componentName: params.get("component") ?? void 0,
3330
4732
  sessionId: params.get("session_id") ?? void 0,
3331
- projectId: params.get("project_id") ?? void 0
4733
+ projectId
3332
4734
  });
3333
4735
  this.json(res, { data: events, count: events.length });
3334
4736
  });
3335
- 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;
3336
4740
  const events = this.store.getPerformanceMetrics({
3337
4741
  sinceSeconds: numParam(params, "since_seconds"),
3338
4742
  metricName: params.get("metric") ?? void 0,
3339
4743
  sessionId: params.get("session_id") ?? void 0,
3340
- projectId: params.get("project_id") ?? void 0
4744
+ projectId
3341
4745
  });
3342
4746
  this.json(res, { data: events, count: events.length });
3343
4747
  });
3344
- 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;
3345
4751
  const events = this.store.getDatabaseEvents({
3346
4752
  sinceSeconds: numParam(params, "since_seconds"),
3347
4753
  table: params.get("table") ?? void 0,
3348
4754
  minDurationMs: numParam(params, "min_duration_ms"),
3349
4755
  search: params.get("search") ?? void 0,
3350
4756
  sessionId: params.get("session_id") ?? void 0,
3351
- projectId: params.get("project_id") ?? void 0
4757
+ projectId
3352
4758
  });
3353
4759
  this.json(res, { data: events, count: events.length });
3354
4760
  });
3355
- 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;
3356
4764
  const eventTypes = params.get("event_types")?.split(",") ?? void 0;
3357
4765
  const events = this.store.getEventTimeline({
3358
4766
  sinceSeconds: numParam(params, "since_seconds"),
3359
4767
  eventTypes,
3360
4768
  sessionId: params.get("session_id") ?? void 0,
3361
- projectId: params.get("project_id") ?? void 0
4769
+ projectId
3362
4770
  });
3363
4771
  this.json(res, { data: events, count: events.length });
3364
4772
  });
3365
- 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;
3366
4776
  const events = this.store.getCustomEvents({
3367
4777
  name: params.get("name") ?? void 0,
3368
4778
  sinceSeconds: numParam(params, "since_seconds"),
3369
4779
  sessionId: params.get("session_id") ?? void 0,
3370
- projectId: params.get("project_id") ?? void 0
4780
+ projectId
3371
4781
  });
3372
4782
  this.json(res, { data: events, count: events.length });
3373
4783
  });
3374
- 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;
3375
4787
  const action = params.get("action");
3376
4788
  const events = this.store.getUIInteractions({
3377
4789
  action: action ?? void 0,
3378
4790
  sinceSeconds: numParam(params, "since_seconds"),
3379
4791
  sessionId: params.get("session_id") ?? void 0,
3380
- projectId: params.get("project_id") ?? void 0
4792
+ projectId
3381
4793
  });
3382
4794
  this.json(res, { data: events, count: events.length });
3383
4795
  });
@@ -3426,6 +4838,21 @@ var HttpServer = class {
3426
4838
  } catch {
3427
4839
  }
3428
4840
  }
4841
+ if (this.pmStore) {
4842
+ try {
4843
+ const token = AuthManager.extractBearer(req.headers.authorization);
4844
+ if (token) {
4845
+ const ws = this.pmStore.getWorkspaceByApiKey(token);
4846
+ if (ws && projectId) {
4847
+ const existing = this.pmStore.listProjects().find((p) => p.runtimeProjectId === projectId);
4848
+ if (existing && !existing.workspaceId) {
4849
+ this.pmStore.setProjectWorkspace(existing.id, ws.id);
4850
+ }
4851
+ }
4852
+ }
4853
+ } catch {
4854
+ }
4855
+ }
3429
4856
  }
3430
4857
  const VALID_EVENT_TYPES = /* @__PURE__ */ new Set([
3431
4858
  "network",
@@ -3476,7 +4903,7 @@ var HttpServer = class {
3476
4903
  */
3477
4904
  resolveSdkPath() {
3478
4905
  if (this.sdkBundlePath) return this.sdkBundlePath;
3479
- const __dir = dirname(fileURLToPath(import.meta.url));
4906
+ const __dir = dirname2(fileURLToPath(import.meta.url));
3480
4907
  const candidates = [
3481
4908
  resolve(__dir, "../../sdk/dist/index.global.js"),
3482
4909
  // monorepo: packages/collector/dist -> packages/sdk/dist
@@ -3484,15 +4911,57 @@ var HttpServer = class {
3484
4911
  // npm installed
3485
4912
  ];
3486
4913
  for (const p of candidates) {
3487
- if (existsSync5(p)) {
4914
+ if (existsSync6(p)) {
3488
4915
  this.sdkBundlePath = p;
3489
4916
  return p;
3490
4917
  }
3491
4918
  }
3492
4919
  return null;
3493
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
+ }
3494
4963
  async start(options = {}) {
3495
- const basePort = options.port ?? parseInt(process.env.RUNTIMESCOPE_HTTP_PORT ?? "9091", 10);
4964
+ const basePort = options.port ?? parseInt(process.env.RUNTIMESCOPE_HTTP_PORT ?? "6768", 10);
3496
4965
  const host = options.host ?? "127.0.0.1";
3497
4966
  const tls = options.tls;
3498
4967
  const maxRetries = 5;
@@ -3533,10 +5002,12 @@ var HttpServer = class {
3533
5002
  this.store.onEvent(this.eventListener);
3534
5003
  server.on("listening", () => {
3535
5004
  this.server = server;
3536
- 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;
3537
5008
  this.startedAt = Date.now();
3538
5009
  const proto = tls ? "https" : "http";
3539
- console.error(`[RuntimeScope] HTTP API listening on ${proto}://${host}:${port}`);
5010
+ console.error(`[RuntimeScope] HTTP API listening on ${proto}://${host}:${boundPort}`);
3540
5011
  resolve2();
3541
5012
  });
3542
5013
  server.on("error", (err) => {
@@ -3621,18 +5092,29 @@ var HttpServer = class {
3621
5092
  res.end();
3622
5093
  return;
3623
5094
  }
3624
- const isPublic = url.pathname === "/api/health" || url.pathname === "/runtimescope.js" || url.pathname === "/snippet";
3625
- 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) {
3626
5103
  const token = AuthManager.extractBearer(req.headers.authorization);
3627
- if (!this.authManager.isAuthorized(token)) {
5104
+ const isGlobal = !!(token && this.authManager?.validate(token));
5105
+ const workspace = token ? this.pmStore?.getWorkspaceByApiKey(token) : null;
5106
+ if (!isGlobal && !workspace) {
3628
5107
  this.json(res, { error: "Unauthorized", code: "AUTH_FAILED" }, 401);
3629
5108
  return;
3630
5109
  }
5110
+ caller.isAdmin = isGlobal;
5111
+ caller.workspaceId = workspace?.id ?? null;
3631
5112
  }
5113
+ req._rsCaller = caller;
3632
5114
  if (req.method === "GET" && url.pathname === "/runtimescope.js") {
3633
5115
  const sdkPath = this.resolveSdkPath();
3634
5116
  if (sdkPath) {
3635
- const bundle = readFileSync4(sdkPath, "utf-8");
5117
+ const bundle = readFileSync5(sdkPath, "utf-8");
3636
5118
  res.writeHead(200, {
3637
5119
  "Content-Type": "application/javascript",
3638
5120
  "Cache-Control": "no-cache"
@@ -3756,6 +5238,7 @@ function numParam(params, key) {
3756
5238
 
3757
5239
  // src/pm/pm-store.ts
3758
5240
  import Database from "better-sqlite3";
5241
+ import { randomBytes as randomBytes4, createHash as createHash2, timingSafeEqual as timingSafeEqual2 } from "crypto";
3759
5242
  var PmStore = class {
3760
5243
  db;
3761
5244
  constructor(options) {
@@ -3888,6 +5371,30 @@ var PmStore = class {
3888
5371
  deleted_at INTEGER NOT NULL
3889
5372
  );
3890
5373
  CREATE INDEX IF NOT EXISTS idx_deleted_path ON pm_deleted_projects(path);
5374
+
5375
+ -- Multi-tenant workspaces (Phase 1) --
5376
+ CREATE TABLE IF NOT EXISTS pm_workspaces (
5377
+ id TEXT PRIMARY KEY,
5378
+ name TEXT NOT NULL,
5379
+ slug TEXT UNIQUE NOT NULL,
5380
+ description TEXT,
5381
+ is_default INTEGER NOT NULL DEFAULT 0,
5382
+ created_at INTEGER NOT NULL,
5383
+ updated_at INTEGER NOT NULL
5384
+ );
5385
+ CREATE INDEX IF NOT EXISTS idx_workspaces_slug ON pm_workspaces(slug);
5386
+
5387
+ CREATE TABLE IF NOT EXISTS pm_api_keys (
5388
+ key TEXT PRIMARY KEY,
5389
+ workspace_id TEXT NOT NULL,
5390
+ label TEXT NOT NULL,
5391
+ created_at INTEGER NOT NULL,
5392
+ last_used_at INTEGER,
5393
+ expires_at INTEGER,
5394
+ revoked_at INTEGER,
5395
+ FOREIGN KEY (workspace_id) REFERENCES pm_workspaces(id) ON DELETE CASCADE
5396
+ );
5397
+ CREATE INDEX IF NOT EXISTS idx_api_keys_workspace ON pm_api_keys(workspace_id);
3891
5398
  `);
3892
5399
  }
3893
5400
  runMigrations() {
@@ -3907,18 +5414,65 @@ var PmStore = class {
3907
5414
  this.db.exec("ALTER TABLE pm_projects ADD COLUMN runtime_project_id TEXT DEFAULT NULL");
3908
5415
  } catch {
3909
5416
  }
5417
+ try {
5418
+ this.db.exec("ALTER TABLE pm_projects ADD COLUMN workspace_id TEXT DEFAULT NULL");
5419
+ } catch {
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
+ }
5438
+ this.ensureDefaultWorkspace();
5439
+ }
5440
+ /**
5441
+ * Ensure a default "personal" workspace exists and every project has a
5442
+ * workspace_id. Runs on every startup — idempotent.
5443
+ */
5444
+ ensureDefaultWorkspace() {
5445
+ const existing = this.db.prepare("SELECT id FROM pm_workspaces WHERE is_default = 1").get();
5446
+ let defaultId;
5447
+ if (existing) {
5448
+ defaultId = existing.id;
5449
+ } else {
5450
+ defaultId = generateWorkspaceId();
5451
+ const now = Date.now();
5452
+ this.db.prepare(
5453
+ `INSERT INTO pm_workspaces (id, name, slug, description, is_default, created_at, updated_at)
5454
+ VALUES (?, ?, ?, ?, 1, ?, ?)`
5455
+ ).run(defaultId, "Personal", "personal", "Your personal workspace", now, now);
5456
+ }
5457
+ this.db.prepare("UPDATE pm_projects SET workspace_id = ? WHERE workspace_id IS NULL").run(defaultId);
3910
5458
  }
3911
5459
  // ============================================================
3912
5460
  // Projects
3913
5461
  // ============================================================
3914
5462
  upsertProject(project) {
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
+ }
3915
5468
  this.db.prepare(`
3916
- INSERT INTO pm_projects (id, name, path, claude_project_key, runtimescope_project,
5469
+ INSERT INTO pm_projects (id, workspace_id, name, path, claude_project_key, runtimescope_project,
3917
5470
  phase, management_authorized, probable_to_complete, project_status,
3918
5471
  category, sdk_installed, runtime_apps,
3919
5472
  created_at, updated_at, metadata)
3920
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
5473
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
3921
5474
  ON CONFLICT(id) DO UPDATE SET
5475
+ workspace_id = COALESCE(pm_projects.workspace_id, excluded.workspace_id),
3922
5476
  name = excluded.name,
3923
5477
  path = COALESCE(excluded.path, pm_projects.path),
3924
5478
  claude_project_key = COALESCE(excluded.claude_project_key, pm_projects.claude_project_key),
@@ -3929,6 +5483,7 @@ var PmStore = class {
3929
5483
  metadata = COALESCE(excluded.metadata, pm_projects.metadata)
3930
5484
  `).run(
3931
5485
  project.id,
5486
+ resolvedWorkspaceId,
3932
5487
  project.name,
3933
5488
  project.path ?? null,
3934
5489
  project.claudeProjectKey ?? null,
@@ -4064,9 +5619,156 @@ var PmStore = class {
4064
5619
  const rows = this.db.prepare("SELECT DISTINCT category FROM pm_projects WHERE category IS NOT NULL ORDER BY category ASC").all();
4065
5620
  return rows.map((r) => r.category);
4066
5621
  }
5622
+ // ============================================================
5623
+ // Workspaces (multi-tenant)
5624
+ // ============================================================
5625
+ listWorkspaces() {
5626
+ const rows = this.db.prepare("SELECT * FROM pm_workspaces ORDER BY is_default DESC, name ASC").all();
5627
+ return rows.map(mapWorkspaceRow);
5628
+ }
5629
+ getWorkspace(id) {
5630
+ const row = this.db.prepare("SELECT * FROM pm_workspaces WHERE id = ?").get(id);
5631
+ return row ? mapWorkspaceRow(row) : null;
5632
+ }
5633
+ getWorkspaceBySlug(slug) {
5634
+ const row = this.db.prepare("SELECT * FROM pm_workspaces WHERE slug = ?").get(slug);
5635
+ return row ? mapWorkspaceRow(row) : null;
5636
+ }
5637
+ getDefaultWorkspace() {
5638
+ const row = this.db.prepare("SELECT * FROM pm_workspaces WHERE is_default = 1 LIMIT 1").get();
5639
+ if (!row) {
5640
+ throw new Error("Default workspace missing \u2014 ensureDefaultWorkspace() must run first");
5641
+ }
5642
+ return mapWorkspaceRow(row);
5643
+ }
5644
+ createWorkspace(input) {
5645
+ const id = generateWorkspaceId();
5646
+ const slug = (input.slug ?? input.name).toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
5647
+ if (!slug) {
5648
+ throw new Error("Workspace slug cannot be empty");
5649
+ }
5650
+ if (this.getWorkspaceBySlug(slug)) {
5651
+ throw new Error(`Workspace with slug "${slug}" already exists`);
5652
+ }
5653
+ const now = Date.now();
5654
+ this.db.prepare(
5655
+ `INSERT INTO pm_workspaces (id, name, slug, description, is_default, created_at, updated_at)
5656
+ VALUES (?, ?, ?, ?, 0, ?, ?)`
5657
+ ).run(id, input.name, slug, input.description ?? null, now, now);
5658
+ return { id, name: input.name, slug, description: input.description, createdAt: now, updatedAt: now, isDefault: false };
5659
+ }
5660
+ updateWorkspace(id, updates) {
5661
+ const sets = [];
5662
+ const params = [];
5663
+ if (updates.name !== void 0) {
5664
+ sets.push("name = ?");
5665
+ params.push(updates.name);
5666
+ }
5667
+ if (updates.slug !== void 0) {
5668
+ sets.push("slug = ?");
5669
+ params.push(updates.slug);
5670
+ }
5671
+ if (updates.description !== void 0) {
5672
+ sets.push("description = ?");
5673
+ params.push(updates.description);
5674
+ }
5675
+ if (!sets.length) return;
5676
+ sets.push("updated_at = ?");
5677
+ params.push(Date.now());
5678
+ params.push(id);
5679
+ this.db.prepare(`UPDATE pm_workspaces SET ${sets.join(", ")} WHERE id = ?`).run(...params);
5680
+ }
5681
+ deleteWorkspace(id) {
5682
+ const ws = this.getWorkspace(id);
5683
+ if (!ws) return;
5684
+ if (ws.isDefault) {
5685
+ throw new Error("Cannot delete the default workspace");
5686
+ }
5687
+ const def = this.getDefaultWorkspace();
5688
+ this.db.prepare("UPDATE pm_projects SET workspace_id = ? WHERE workspace_id = ?").run(def.id, id);
5689
+ this.db.prepare("DELETE FROM pm_api_keys WHERE workspace_id = ?").run(id);
5690
+ this.db.prepare("DELETE FROM pm_workspaces WHERE id = ?").run(id);
5691
+ }
5692
+ /** Move a project between workspaces. */
5693
+ setProjectWorkspace(projectId, workspaceId) {
5694
+ const ws = this.getWorkspace(workspaceId);
5695
+ if (!ws) throw new Error(`Workspace ${workspaceId} does not exist`);
5696
+ this.db.prepare("UPDATE pm_projects SET workspace_id = ?, updated_at = ? WHERE id = ?").run(workspaceId, Date.now(), projectId);
5697
+ }
5698
+ // ============================================================
5699
+ // API Keys (workspace-scoped)
5700
+ // ============================================================
5701
+ createApiKey(workspaceId, label, expiresAt) {
5702
+ const ws = this.getWorkspace(workspaceId);
5703
+ if (!ws) throw new Error(`Workspace ${workspaceId} does not exist`);
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);
5708
+ const now = Date.now();
5709
+ this.db.prepare(
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 };
5714
+ }
5715
+ listApiKeys(workspaceId) {
5716
+ const rows = this.db.prepare(
5717
+ `SELECT * FROM pm_api_keys WHERE workspace_id = ? AND revoked_at IS NULL ORDER BY created_at DESC`
5718
+ ).all(workspaceId);
5719
+ return rows.map(mapApiKeyRow);
5720
+ }
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;
5740
+ }
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);
5755
+ const row = this.db.prepare(
5756
+ `SELECT w.* FROM pm_api_keys k
5757
+ JOIN pm_workspaces w ON w.id = k.workspace_id
5758
+ WHERE k.key = ? AND k.revoked_at IS NULL
5759
+ AND (k.expires_at IS NULL OR k.expires_at > ?)`
5760
+ ).get(hash, Date.now());
5761
+ if (!row) return null;
5762
+ try {
5763
+ this.db.prepare("UPDATE pm_api_keys SET last_used_at = ? WHERE key = ?").run(Date.now(), hash);
5764
+ } catch {
5765
+ }
5766
+ return mapWorkspaceRow(row);
5767
+ }
4067
5768
  mapProjectRow(row) {
4068
5769
  return {
4069
5770
  id: row.id,
5771
+ workspaceId: row.workspace_id ?? void 0,
4070
5772
  name: row.name,
4071
5773
  path: row.path ?? void 0,
4072
5774
  claudeProjectKey: row.claude_project_key ?? void 0,
@@ -4857,6 +6559,38 @@ var PmStore = class {
4857
6559
  this.db.close();
4858
6560
  }
4859
6561
  };
6562
+ function hashApiKey(raw) {
6563
+ return createHash2("sha256").update(raw, "utf8").digest("hex");
6564
+ }
6565
+ function generateWorkspaceId() {
6566
+ return `ws_${randomBytes4(8).toString("hex")}`;
6567
+ }
6568
+ function mapWorkspaceRow(row) {
6569
+ return {
6570
+ id: row.id,
6571
+ name: row.name,
6572
+ slug: row.slug,
6573
+ description: row.description ?? void 0,
6574
+ isDefault: row.is_default === 1,
6575
+ createdAt: row.created_at,
6576
+ updatedAt: row.updated_at
6577
+ };
6578
+ }
6579
+ function mapApiKeyRow(row) {
6580
+ return {
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 ?? "",
6586
+ workspaceId: row.workspace_id,
6587
+ label: row.label,
6588
+ createdAt: row.created_at,
6589
+ lastUsedAt: row.last_used_at ?? void 0,
6590
+ expiresAt: row.expires_at ?? void 0,
6591
+ revokedAt: row.revoked_at ?? void 0
6592
+ };
6593
+ }
4860
6594
 
4861
6595
  // src/pm/session-parser.ts
4862
6596
  import { createReadStream } from "fs";
@@ -5063,13 +6797,13 @@ async function parseSessionJsonl(jsonlPath, sessionId, projectId) {
5063
6797
 
5064
6798
  // src/pm/project-discovery.ts
5065
6799
  import { readdir as readdir2, readFile as readFile2, stat as stat2 } from "fs/promises";
5066
- import { join as join5, basename as basename2 } from "path";
5067
- import { existsSync as existsSync6 } from "fs";
6800
+ import { join as join7, basename as basename2 } from "path";
6801
+ import { existsSync as existsSync7 } from "fs";
5068
6802
  import { homedir as homedir3 } from "os";
5069
6803
  var LOG_PREFIX = "[RuntimeScope PM]";
5070
6804
  async function detectSdkInstalled(projectPath) {
5071
6805
  try {
5072
- const pkgPath = join5(projectPath, "package.json");
6806
+ const pkgPath = join7(projectPath, "package.json");
5073
6807
  const pkg = JSON.parse(await readFile2(pkgPath, "utf-8"));
5074
6808
  const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
5075
6809
  if ("@runtimescope/sdk" in allDeps || "@runtimescope/server-sdk" in allDeps) {
@@ -5079,13 +6813,13 @@ async function detectSdkInstalled(projectPath) {
5079
6813
  const workspaces = Array.isArray(pkg.workspaces) ? pkg.workspaces : pkg.workspaces.packages ?? [];
5080
6814
  for (const ws of workspaces) {
5081
6815
  const wsBase = ws.replace(/\/?\*$/, "");
5082
- const wsDir = join5(projectPath, wsBase);
6816
+ const wsDir = join7(projectPath, wsBase);
5083
6817
  try {
5084
6818
  const entries = await readdir2(wsDir, { withFileTypes: true });
5085
6819
  for (const entry of entries) {
5086
6820
  if (!entry.isDirectory()) continue;
5087
6821
  try {
5088
- 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"));
5089
6823
  const wsDeps = { ...wsPkg.dependencies, ...wsPkg.devDependencies };
5090
6824
  if ("@runtimescope/sdk" in wsDeps || "@runtimescope/server-sdk" in wsDeps) {
5091
6825
  return true;
@@ -5100,7 +6834,7 @@ async function detectSdkInstalled(projectPath) {
5100
6834
  } catch {
5101
6835
  }
5102
6836
  try {
5103
- await stat2(join5(projectPath, "node_modules", "@runtimescope"));
6837
+ await stat2(join7(projectPath, "node_modules", "@runtimescope"));
5104
6838
  return true;
5105
6839
  } catch {
5106
6840
  return false;
@@ -5131,7 +6865,7 @@ function slugifyPath(fsPath) {
5131
6865
  }
5132
6866
  function decodeClaudeKey(key) {
5133
6867
  const naive = "/" + key.slice(1).replace(/-/g, "/");
5134
- if (existsSync6(naive)) return naive;
6868
+ if (existsSync7(naive)) return naive;
5135
6869
  const parts = key.slice(1).split("-");
5136
6870
  return resolvePathSegments(parts);
5137
6871
  }
@@ -5139,16 +6873,16 @@ function resolvePathSegments(parts) {
5139
6873
  if (parts.length === 0) return null;
5140
6874
  function tryResolve(prefix, remaining) {
5141
6875
  if (remaining.length === 0) {
5142
- return existsSync6(prefix) ? prefix : null;
6876
+ return existsSync7(prefix) ? prefix : null;
5143
6877
  }
5144
6878
  for (let count = remaining.length; count >= 1; count--) {
5145
6879
  const segment = remaining.slice(0, count).join("-");
5146
- const candidate = join5(prefix, segment);
6880
+ const candidate = join7(prefix, segment);
5147
6881
  if (count === remaining.length) {
5148
- if (existsSync6(candidate)) return candidate;
6882
+ if (existsSync7(candidate)) return candidate;
5149
6883
  } else {
5150
6884
  try {
5151
- if (existsSync6(candidate)) {
6885
+ if (existsSync7(candidate)) {
5152
6886
  const result = tryResolve(candidate, remaining.slice(count));
5153
6887
  if (result) return result;
5154
6888
  }
@@ -5171,7 +6905,7 @@ var ProjectDiscovery = class {
5171
6905
  constructor(pmStore, projectManager, claudeBaseDir) {
5172
6906
  this.pmStore = pmStore;
5173
6907
  this.projectManager = projectManager;
5174
- this.claudeBaseDir = claudeBaseDir ?? join5(homedir3(), ".claude");
6908
+ this.claudeBaseDir = claudeBaseDir ?? join7(homedir3(), ".claude");
5175
6909
  }
5176
6910
  claudeBaseDir;
5177
6911
  /**
@@ -5204,7 +6938,7 @@ var ProjectDiscovery = class {
5204
6938
  sessionsUpdated: 0,
5205
6939
  errors: []
5206
6940
  };
5207
- const projectsDir = join5(this.claudeBaseDir, "projects");
6941
+ const projectsDir = join7(this.claudeBaseDir, "projects");
5208
6942
  try {
5209
6943
  await stat2(projectsDir);
5210
6944
  } catch {
@@ -5317,10 +7051,10 @@ var ProjectDiscovery = class {
5317
7051
  if (!project.claudeProjectKey) {
5318
7052
  return 0;
5319
7053
  }
5320
- const projectDir = join5(this.claudeBaseDir, "projects", project.claudeProjectKey);
7054
+ const projectDir = join7(this.claudeBaseDir, "projects", project.claudeProjectKey);
5321
7055
  let sessionsIndexed = 0;
5322
7056
  try {
5323
- const indexPath = join5(projectDir, "sessions-index.json");
7057
+ const indexPath = join7(projectDir, "sessions-index.json");
5324
7058
  let indexEntries = null;
5325
7059
  try {
5326
7060
  const indexContent = await readFile2(indexPath, "utf-8");
@@ -5333,7 +7067,7 @@ var ProjectDiscovery = class {
5333
7067
  for (const jsonlFile of jsonlFiles) {
5334
7068
  try {
5335
7069
  const sessionId = jsonlFile.replace(".jsonl", "");
5336
- const jsonlPath = join5(projectDir, jsonlFile);
7070
+ const jsonlPath = join7(projectDir, jsonlFile);
5337
7071
  const fileStat = await stat2(jsonlPath);
5338
7072
  const fileSize = fileStat.size;
5339
7073
  const existingSession = await this.pmStore.getSession(sessionId);
@@ -5368,7 +7102,7 @@ var ProjectDiscovery = class {
5368
7102
  * Process a single Claude project directory key.
5369
7103
  */
5370
7104
  async processClaudeProject(key, result) {
5371
- const projectDir = join5(this.claudeBaseDir, "projects", key);
7105
+ const projectDir = join7(this.claudeBaseDir, "projects", key);
5372
7106
  let fsPath = decodeClaudeKey(key);
5373
7107
  if (!fsPath) {
5374
7108
  fsPath = await this.resolvePathFromIndex(projectDir);
@@ -5422,7 +7156,7 @@ var ProjectDiscovery = class {
5422
7156
  */
5423
7157
  async resolvePathFromIndex(projectDir) {
5424
7158
  try {
5425
- const indexPath = join5(projectDir, "sessions-index.json");
7159
+ const indexPath = join7(projectDir, "sessions-index.json");
5426
7160
  const content = await readFile2(indexPath, "utf-8");
5427
7161
  const index = JSON.parse(content);
5428
7162
  const entry = index.entries?.find((e) => e.projectPath);
@@ -5437,11 +7171,11 @@ var ProjectDiscovery = class {
5437
7171
  */
5438
7172
  async indexSessionsForClaudeProject(projectId, claudeKey) {
5439
7173
  const counts = { discovered: 0, updated: 0 };
5440
- const projectDir = join5(this.claudeBaseDir, "projects", claudeKey);
7174
+ const projectDir = join7(this.claudeBaseDir, "projects", claudeKey);
5441
7175
  try {
5442
7176
  let indexEntries = null;
5443
7177
  try {
5444
- const indexPath = join5(projectDir, "sessions-index.json");
7178
+ const indexPath = join7(projectDir, "sessions-index.json");
5445
7179
  const indexContent = await readFile2(indexPath, "utf-8");
5446
7180
  const index = JSON.parse(indexContent);
5447
7181
  indexEntries = index.entries ?? [];
@@ -5452,7 +7186,7 @@ var ProjectDiscovery = class {
5452
7186
  for (const jsonlFile of jsonlFiles) {
5453
7187
  try {
5454
7188
  const sessionId = jsonlFile.replace(".jsonl", "");
5455
- const jsonlPath = join5(projectDir, jsonlFile);
7189
+ const jsonlPath = join7(projectDir, jsonlFile);
5456
7190
  const fileStat = await stat2(jsonlPath);
5457
7191
  const fileSize = fileStat.size;
5458
7192
  const existingSession = await this.pmStore.getSession(sessionId);
@@ -5637,6 +7371,15 @@ export {
5637
7371
  getOrCreateProjectId,
5638
7372
  SqliteStore,
5639
7373
  isSqliteAvailable,
7374
+ Wal,
7375
+ Counter,
7376
+ Gauge,
7377
+ MetricsRegistry,
7378
+ OtelExporter,
7379
+ traceIdFromSession,
7380
+ randomSpanId,
7381
+ parseOtelHeaders,
7382
+ otelOptionsFromEnv,
5640
7383
  SessionRateLimiter,
5641
7384
  loadTlsOptions,
5642
7385
  resolveTlsConfig,
@@ -5665,4 +7408,4 @@ export {
5665
7408
  parseSessionJsonl,
5666
7409
  ProjectDiscovery
5667
7410
  };
5668
- //# sourceMappingURL=chunk-MM44DN7Y.js.map
7411
+ //# sourceMappingURL=chunk-M2V4EFJY.js.map