@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.
- package/dist/{chunk-MM44DN7Y.js → chunk-M2V4EFJY.js} +1897 -154
- package/dist/chunk-M2V4EFJY.js.map +1 -0
- package/dist/dashboard.js +2 -2
- package/dist/dashboard.js.map +1 -1
- package/dist/index.d.ts +505 -1
- package/dist/index.js +19 -1
- package/dist/index.js.map +1 -1
- package/dist/standalone.js +21 -7
- package/dist/standalone.js.map +1 -1
- package/package.json +1 -1
- package/dist/chunk-MM44DN7Y.js.map +0 -1
|
@@ -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:
|
|
852
|
-
key:
|
|
853
|
-
...config.caPath ? { ca:
|
|
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(
|
|
932
|
-
|
|
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,
|
|
1969
|
+
`[RuntimeScope] Port ${port} in use, trying ${nextPort}...`
|
|
979
1970
|
);
|
|
980
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
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
|
|
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:
|
|
2412
|
+
defaultPort: 6767,
|
|
1277
2413
|
bufferSize: 1e4,
|
|
1278
|
-
httpPort:
|
|
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 ??
|
|
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
|
|
2429
|
+
return join3(this.baseDir, "projects", "_invalid");
|
|
1294
2430
|
}
|
|
1295
|
-
return
|
|
2431
|
+
return join3(this.baseDir, "projects", safe);
|
|
1296
2432
|
}
|
|
1297
2433
|
getProjectDbPath(projectName) {
|
|
1298
|
-
return
|
|
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(
|
|
1304
|
-
const configPath =
|
|
1305
|
-
if (!
|
|
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 =
|
|
1313
|
-
if (!
|
|
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 =
|
|
1327
|
-
if (!
|
|
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(
|
|
2467
|
+
this.writeJson(join3(this.baseDir, "config.json"), config);
|
|
1332
2468
|
}
|
|
1333
2469
|
getProjectConfig(projectName) {
|
|
1334
|
-
const configPath =
|
|
1335
|
-
if (!
|
|
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(
|
|
2475
|
+
this.writeJson(join3(this.getProjectDir(projectName), "config.json"), config);
|
|
1340
2476
|
}
|
|
1341
2477
|
getInfrastructureConfig(projectName) {
|
|
1342
|
-
const jsonPath =
|
|
1343
|
-
if (
|
|
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 =
|
|
1348
|
-
if (
|
|
2483
|
+
const yamlPath = join3(this.getProjectDir(projectName), "infrastructure.yaml");
|
|
2484
|
+
if (existsSync3(yamlPath)) {
|
|
1349
2485
|
try {
|
|
1350
|
-
const content =
|
|
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 =
|
|
1360
|
-
if (!
|
|
1361
|
-
return
|
|
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 =
|
|
1366
|
-
if (!
|
|
1367
|
-
return
|
|
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
|
|
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 =
|
|
1423
|
-
if (
|
|
1424
|
-
const content =
|
|
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 (!
|
|
1458
|
-
|
|
2593
|
+
if (!existsSync3(dir)) {
|
|
2594
|
+
mkdirSync3(dir, { recursive: true });
|
|
1459
2595
|
}
|
|
1460
2596
|
}
|
|
1461
2597
|
readJson(path) {
|
|
1462
|
-
const content =
|
|
2598
|
+
const content = readFileSync3(path, "utf-8");
|
|
1463
2599
|
return JSON.parse(content);
|
|
1464
2600
|
}
|
|
1465
2601
|
writeJson(path, data) {
|
|
1466
|
-
|
|
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
|
|
1504
|
-
import { join as
|
|
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 =
|
|
1520
|
-
if (!
|
|
2655
|
+
const configPath = join4(projectDir, ".runtimescope", "config.json");
|
|
2656
|
+
if (!existsSync4(configPath)) return null;
|
|
1521
2657
|
try {
|
|
1522
|
-
const content =
|
|
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 =
|
|
1530
|
-
if (!
|
|
1531
|
-
|
|
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 ?? "
|
|
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 =
|
|
1563
|
-
if (!
|
|
1564
|
-
|
|
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 =
|
|
1587
|
-
if (
|
|
1588
|
-
const config = JSON.parse(
|
|
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
|
|
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:
|
|
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
|
|
1789
|
-
import { join as
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
2120
|
-
import { join as
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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 =
|
|
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 =
|
|
3811
|
+
const memoryDir = join6(homedir2(), ".claude", "projects", project.claudeProjectKey, "memory");
|
|
2489
3812
|
await mkdir(memoryDir, { recursive: true });
|
|
2490
|
-
await writeFile(
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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:
|
|
3111
|
-
project: claudeProjectKey ?
|
|
3112
|
-
local: projectPath ?
|
|
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 (
|
|
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 =
|
|
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", (
|
|
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
|
|
4699
|
+
projectId
|
|
3304
4700
|
});
|
|
3305
4701
|
this.json(res, { data: events, count: events.length });
|
|
3306
4702
|
});
|
|
3307
|
-
this.routes.set("GET /api/events/console", (
|
|
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
|
|
4711
|
+
projectId
|
|
3314
4712
|
});
|
|
3315
4713
|
this.json(res, { data: events, count: events.length });
|
|
3316
4714
|
});
|
|
3317
|
-
this.routes.set("GET /api/events/state", (
|
|
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
|
|
4722
|
+
projectId
|
|
3323
4723
|
});
|
|
3324
4724
|
this.json(res, { data: events, count: events.length });
|
|
3325
4725
|
});
|
|
3326
|
-
this.routes.set("GET /api/events/renders", (
|
|
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
|
|
4733
|
+
projectId
|
|
3332
4734
|
});
|
|
3333
4735
|
this.json(res, { data: events, count: events.length });
|
|
3334
4736
|
});
|
|
3335
|
-
this.routes.set("GET /api/events/performance", (
|
|
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
|
|
4744
|
+
projectId
|
|
3341
4745
|
});
|
|
3342
4746
|
this.json(res, { data: events, count: events.length });
|
|
3343
4747
|
});
|
|
3344
|
-
this.routes.set("GET /api/events/database", (
|
|
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
|
|
4757
|
+
projectId
|
|
3352
4758
|
});
|
|
3353
4759
|
this.json(res, { data: events, count: events.length });
|
|
3354
4760
|
});
|
|
3355
|
-
this.routes.set("GET /api/events/timeline", (
|
|
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
|
|
4769
|
+
projectId
|
|
3362
4770
|
});
|
|
3363
4771
|
this.json(res, { data: events, count: events.length });
|
|
3364
4772
|
});
|
|
3365
|
-
this.routes.set("GET /api/events/custom", (
|
|
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
|
|
4780
|
+
projectId
|
|
3371
4781
|
});
|
|
3372
4782
|
this.json(res, { data: events, count: events.length });
|
|
3373
4783
|
});
|
|
3374
|
-
this.routes.set("GET /api/events/ui", (
|
|
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
|
|
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 =
|
|
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 (
|
|
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 ?? "
|
|
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
|
-
|
|
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}:${
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
5067
|
-
import { existsSync as
|
|
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 =
|
|
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 =
|
|
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(
|
|
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(
|
|
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 (
|
|
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
|
|
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 =
|
|
6880
|
+
const candidate = join7(prefix, segment);
|
|
5147
6881
|
if (count === remaining.length) {
|
|
5148
|
-
if (
|
|
6882
|
+
if (existsSync7(candidate)) return candidate;
|
|
5149
6883
|
} else {
|
|
5150
6884
|
try {
|
|
5151
|
-
if (
|
|
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 ??
|
|
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 =
|
|
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 =
|
|
7054
|
+
const projectDir = join7(this.claudeBaseDir, "projects", project.claudeProjectKey);
|
|
5321
7055
|
let sessionsIndexed = 0;
|
|
5322
7056
|
try {
|
|
5323
|
-
const indexPath =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
7174
|
+
const projectDir = join7(this.claudeBaseDir, "projects", claudeKey);
|
|
5441
7175
|
try {
|
|
5442
7176
|
let indexEntries = null;
|
|
5443
7177
|
try {
|
|
5444
|
-
const indexPath =
|
|
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 =
|
|
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-
|
|
7411
|
+
//# sourceMappingURL=chunk-M2V4EFJY.js.map
|