@runtimescope/collector 0.10.0 → 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-WWFIEANS.js → chunk-M2V4EFJY.js} +1531 -168
- package/dist/chunk-M2V4EFJY.js.map +1 -0
- package/dist/index.d.ts +443 -3
- package/dist/index.js +19 -1
- package/dist/index.js.map +1 -1
- package/dist/standalone.js +5 -2
- package/dist/standalone.js.map +1 -1
- package/package.json +1 -1
- package/dist/chunk-WWFIEANS.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(
|
|
1798
|
+
/** True after start() finishes recovery. False during startup or after stop(). */
|
|
1799
|
+
isReady() {
|
|
1800
|
+
return this.ready;
|
|
1801
|
+
}
|
|
1802
|
+
/**
|
|
1803
|
+
* Snapshot every project's SQLite DB and WAL into a fresh directory under
|
|
1804
|
+
* `<runtimescope-root>/snapshots/<ISO>/`. Atomic via SQLite's `VACUUM INTO`;
|
|
1805
|
+
* non-blocking for ongoing event ingestion (the live DB keeps accepting
|
|
1806
|
+
* writes during the copy).
|
|
1807
|
+
*
|
|
1808
|
+
* Returns metadata for the admin endpoint to serialize.
|
|
1809
|
+
*/
|
|
1810
|
+
createSnapshot() {
|
|
1811
|
+
if (!this.projectManager) {
|
|
1812
|
+
throw new Error("Cannot snapshot \u2014 no projectManager configured");
|
|
1813
|
+
}
|
|
1814
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
1815
|
+
const root = join2(this.projectManager.rootDir, "snapshots", timestamp);
|
|
1816
|
+
mkdirSync2(root, { recursive: true });
|
|
1817
|
+
const projects = [];
|
|
1818
|
+
let totalBytes = 0;
|
|
1819
|
+
for (const projectName of this.projectManager.listProjects()) {
|
|
1820
|
+
const projectDir = join2(root, projectName);
|
|
1821
|
+
mkdirSync2(projectDir, { recursive: true });
|
|
1822
|
+
let sqliteBytes = 0;
|
|
1823
|
+
let eventCount = 0;
|
|
1824
|
+
const sqliteStore = this.sqliteStores.get(projectName);
|
|
1825
|
+
if (sqliteStore) {
|
|
1826
|
+
const sqlitePath = join2(projectDir, "events.db");
|
|
1827
|
+
try {
|
|
1828
|
+
sqliteBytes = sqliteStore.snapshotTo(sqlitePath);
|
|
1829
|
+
eventCount = sqliteStore.getEventCount({ project: projectName });
|
|
1830
|
+
} catch (err) {
|
|
1831
|
+
console.error(
|
|
1832
|
+
`[RuntimeScope] Snapshot of "${projectName}" SQLite failed:`,
|
|
1833
|
+
err.message
|
|
1834
|
+
);
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
let walBytes = 0;
|
|
1838
|
+
const wal = this.wals.get(projectName);
|
|
1839
|
+
if (wal) {
|
|
1840
|
+
try {
|
|
1841
|
+
walBytes = wal.snapshotTo(join2(projectDir, "wal"));
|
|
1842
|
+
} catch (err) {
|
|
1843
|
+
console.error(
|
|
1844
|
+
`[RuntimeScope] Snapshot of "${projectName}" WAL failed:`,
|
|
1845
|
+
err.message
|
|
1846
|
+
);
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
projects.push({ name: projectName, sqliteBytes, walBytes, eventCount });
|
|
1850
|
+
totalBytes += sqliteBytes + walBytes;
|
|
1851
|
+
}
|
|
1852
|
+
const manifest = {
|
|
1853
|
+
timestamp,
|
|
1854
|
+
createdAt: Date.now(),
|
|
1855
|
+
collectorVersion: process.env.npm_package_version ?? "0.0.0",
|
|
1856
|
+
projects,
|
|
1857
|
+
totalBytes
|
|
1858
|
+
};
|
|
1859
|
+
writeFileSync(join2(root, "manifest.json"), JSON.stringify(manifest, null, 2));
|
|
1860
|
+
return { path: root, timestamp, projects, totalBytes };
|
|
1861
|
+
}
|
|
1862
|
+
async start(options = {}) {
|
|
932
1863
|
const port = options.port ?? 6767;
|
|
933
1864
|
const host = options.host ?? "127.0.0.1";
|
|
934
1865
|
const maxRetries = options.maxRetries ?? 5;
|
|
935
1866
|
const retryDelayMs = options.retryDelayMs ?? 1e3;
|
|
936
1867
|
const tls = options.tls ?? this.tlsConfig;
|
|
1868
|
+
try {
|
|
1869
|
+
this.runStartupRecovery();
|
|
1870
|
+
} catch (err) {
|
|
1871
|
+
console.error("[RuntimeScope] Startup recovery failed (non-fatal):", err.message);
|
|
1872
|
+
}
|
|
1873
|
+
this.ready = true;
|
|
937
1874
|
return this.tryStart(port, host, maxRetries, retryDelayMs, tls);
|
|
938
1875
|
}
|
|
1876
|
+
/**
|
|
1877
|
+
* On collector startup, for each known project:
|
|
1878
|
+
* 1. Replay any sealed/active WAL files into SqliteStore (mirror of the
|
|
1879
|
+
* lazy recovery in `ensureWal`, but proactive — handles the case where
|
|
1880
|
+
* a crashed project never reconnects).
|
|
1881
|
+
* 2. Warm the in-memory ring buffer with recent events from SqliteStore so
|
|
1882
|
+
* MCP tools see history immediately, not just events from the next
|
|
1883
|
+
* session that connects.
|
|
1884
|
+
*
|
|
1885
|
+
* Runs synchronously — better-sqlite3 is sync — so callers can `await
|
|
1886
|
+
* collector.start()` and trust the buffer is hot when it returns.
|
|
1887
|
+
*/
|
|
1888
|
+
runStartupRecovery() {
|
|
1889
|
+
if (!this.projectManager) return;
|
|
1890
|
+
const projects = this.projectManager.listProjects();
|
|
1891
|
+
if (projects.length === 0) return;
|
|
1892
|
+
let walReplayed = 0;
|
|
1893
|
+
let warmed = 0;
|
|
1894
|
+
for (const project of projects) {
|
|
1895
|
+
const dir = this.walDirFor(project);
|
|
1896
|
+
if (dir) {
|
|
1897
|
+
const files = Wal.listRecoveryFiles(dir);
|
|
1898
|
+
if (files.length > 0) {
|
|
1899
|
+
const sqliteStore2 = this.ensureSqliteStore(project);
|
|
1900
|
+
if (sqliteStore2) {
|
|
1901
|
+
for (const file of files) {
|
|
1902
|
+
const events = Wal.readFile(file);
|
|
1903
|
+
for (const ev of events) {
|
|
1904
|
+
try {
|
|
1905
|
+
sqliteStore2.addEvent(ev, project);
|
|
1906
|
+
} catch {
|
|
1907
|
+
}
|
|
1908
|
+
}
|
|
1909
|
+
walReplayed += events.length;
|
|
1910
|
+
}
|
|
1911
|
+
sqliteStore2.flush();
|
|
1912
|
+
for (const file of files) Wal.deleteSealed(file);
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
const sqliteStore = this.ensureSqliteStore(project);
|
|
1917
|
+
if (sqliteStore) {
|
|
1918
|
+
const before = this.store.eventCount;
|
|
1919
|
+
this.store.warmFromSqlite(sqliteStore, project, 1e3);
|
|
1920
|
+
warmed += this.store.eventCount - before;
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
if (walReplayed > 0 || warmed > 0) {
|
|
1924
|
+
console.error(
|
|
1925
|
+
`[RuntimeScope] Recovery: ${walReplayed} WAL events replayed, ${warmed} events warmed into ring buffer.`
|
|
1926
|
+
);
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
939
1929
|
tryStart(port, host, retriesLeft, retryDelayMs, tls) {
|
|
940
1930
|
return new Promise((resolve2, reject) => {
|
|
941
1931
|
let wss;
|
|
@@ -974,12 +1964,11 @@ var CollectorServer = class {
|
|
|
974
1964
|
}
|
|
975
1965
|
handleStartError(err, port, host, retriesLeft, retryDelayMs, tls, resolve2, reject) {
|
|
976
1966
|
if (err.code === "EADDRINUSE" && retriesLeft > 0) {
|
|
1967
|
+
const nextPort = port + 1;
|
|
977
1968
|
console.error(
|
|
978
|
-
`[RuntimeScope] Port ${port} in use,
|
|
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);
|
|
@@ -1141,7 +2214,8 @@ var CollectorServer = class {
|
|
|
1141
2214
|
connectedAt: msg.timestamp,
|
|
1142
2215
|
sdkVersion: payload.sdkVersion,
|
|
1143
2216
|
eventCount: 0,
|
|
1144
|
-
isConnected: true
|
|
2217
|
+
isConnected: true,
|
|
2218
|
+
projectId
|
|
1145
2219
|
};
|
|
1146
2220
|
sqliteStore.saveSession(sessionInfo);
|
|
1147
2221
|
}
|
|
@@ -1170,12 +2244,38 @@ var CollectorServer = class {
|
|
|
1170
2244
|
if (this.pendingHandshakes.has(ws)) return;
|
|
1171
2245
|
const clientInfo = this.clients.get(ws);
|
|
1172
2246
|
const payload = msg.payload;
|
|
1173
|
-
if (Array.isArray(payload.events))
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
2247
|
+
if (!Array.isArray(payload.events)) break;
|
|
2248
|
+
const accepted = [];
|
|
2249
|
+
let rateLimited = 0;
|
|
2250
|
+
for (const event of payload.events) {
|
|
2251
|
+
if (clientInfo && !this.rateLimiter.allow(clientInfo.sessionId)) {
|
|
2252
|
+
rateLimited = payload.events.length - accepted.length;
|
|
2253
|
+
break;
|
|
2254
|
+
}
|
|
2255
|
+
accepted.push(event);
|
|
2256
|
+
}
|
|
2257
|
+
if (rateLimited > 0) {
|
|
2258
|
+
this.counters.eventsDropped.inc(rateLimited, { reason: "rate_limit" });
|
|
2259
|
+
}
|
|
2260
|
+
if (accepted.length === 0) break;
|
|
2261
|
+
const wal = clientInfo?.projectName ? this.ensureWal(clientInfo.projectName) : null;
|
|
2262
|
+
if (wal) {
|
|
2263
|
+
try {
|
|
2264
|
+
wal.append(accepted);
|
|
2265
|
+
wal.commit();
|
|
2266
|
+
} catch (err) {
|
|
2267
|
+
console.error("[RuntimeScope] WAL append/commit failed:", err.message);
|
|
2268
|
+
this.counters.eventsDropped.inc(accepted.length, { reason: "wal_backpressure" });
|
|
2269
|
+
}
|
|
2270
|
+
}
|
|
2271
|
+
for (const event of accepted) {
|
|
2272
|
+
this.store.addEvent(event);
|
|
2273
|
+
}
|
|
2274
|
+
if (wal?.shouldRotate() && clientInfo?.projectName) {
|
|
2275
|
+
try {
|
|
2276
|
+
this.checkpointWal(clientInfo.projectName, wal);
|
|
2277
|
+
} catch (err) {
|
|
2278
|
+
console.error("[RuntimeScope] WAL checkpoint failed:", err.message);
|
|
1179
2279
|
}
|
|
1180
2280
|
}
|
|
1181
2281
|
break;
|
|
@@ -1272,6 +2372,21 @@ var CollectorServer = class {
|
|
|
1272
2372
|
}
|
|
1273
2373
|
}
|
|
1274
2374
|
}
|
|
2375
|
+
if (this.otelExporter) {
|
|
2376
|
+
try {
|
|
2377
|
+
void this.otelExporter.close();
|
|
2378
|
+
} catch {
|
|
2379
|
+
}
|
|
2380
|
+
this.otelExporter = null;
|
|
2381
|
+
}
|
|
2382
|
+
for (const [name, wal] of this.wals) {
|
|
2383
|
+
try {
|
|
2384
|
+
wal.close();
|
|
2385
|
+
} catch {
|
|
2386
|
+
console.error(`[RuntimeScope] WAL close error for "${name}" (non-fatal)`);
|
|
2387
|
+
}
|
|
2388
|
+
}
|
|
2389
|
+
this.wals.clear();
|
|
1275
2390
|
for (const [name, sqliteStore] of this.sqliteStores) {
|
|
1276
2391
|
try {
|
|
1277
2392
|
sqliteStore.close();
|
|
@@ -1285,12 +2400,13 @@ var CollectorServer = class {
|
|
|
1285
2400
|
this.wss = null;
|
|
1286
2401
|
console.error("[RuntimeScope] Collector stopped");
|
|
1287
2402
|
}
|
|
2403
|
+
this.ready = false;
|
|
1288
2404
|
}
|
|
1289
2405
|
};
|
|
1290
2406
|
|
|
1291
2407
|
// src/project-manager.ts
|
|
1292
|
-
import { mkdirSync, readFileSync as
|
|
1293
|
-
import { join } from "path";
|
|
2408
|
+
import { mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync2, existsSync as existsSync3, readdirSync as readdirSync2 } from "fs";
|
|
2409
|
+
import { join as join3 } from "path";
|
|
1294
2410
|
import { homedir } from "os";
|
|
1295
2411
|
var DEFAULT_GLOBAL_CONFIG = {
|
|
1296
2412
|
defaultPort: 6767,
|
|
@@ -1301,7 +2417,7 @@ var ProjectManager = class {
|
|
|
1301
2417
|
baseDir;
|
|
1302
2418
|
appProjectIndex = /* @__PURE__ */ new Map();
|
|
1303
2419
|
constructor(baseDir) {
|
|
1304
|
-
this.baseDir = baseDir ??
|
|
2420
|
+
this.baseDir = baseDir ?? join3(homedir(), ".runtimescope");
|
|
1305
2421
|
}
|
|
1306
2422
|
get rootDir() {
|
|
1307
2423
|
return this.baseDir;
|
|
@@ -1310,27 +2426,27 @@ var ProjectManager = class {
|
|
|
1310
2426
|
getProjectDir(projectName) {
|
|
1311
2427
|
const safe = projectName.replace(/[^a-zA-Z0-9_.-]/g, "_");
|
|
1312
2428
|
if (!safe || safe === "." || safe === "..") {
|
|
1313
|
-
return
|
|
2429
|
+
return join3(this.baseDir, "projects", "_invalid");
|
|
1314
2430
|
}
|
|
1315
|
-
return
|
|
2431
|
+
return join3(this.baseDir, "projects", safe);
|
|
1316
2432
|
}
|
|
1317
2433
|
getProjectDbPath(projectName) {
|
|
1318
|
-
return
|
|
2434
|
+
return join3(this.getProjectDir(projectName), "events.db");
|
|
1319
2435
|
}
|
|
1320
2436
|
// --- Lifecycle (idempotent) ---
|
|
1321
2437
|
ensureGlobalDir() {
|
|
1322
2438
|
this.mkdirp(this.baseDir);
|
|
1323
|
-
this.mkdirp(
|
|
1324
|
-
const configPath =
|
|
1325
|
-
if (!
|
|
2439
|
+
this.mkdirp(join3(this.baseDir, "projects"));
|
|
2440
|
+
const configPath = join3(this.baseDir, "config.json");
|
|
2441
|
+
if (!existsSync3(configPath)) {
|
|
1326
2442
|
this.writeJson(configPath, DEFAULT_GLOBAL_CONFIG);
|
|
1327
2443
|
}
|
|
1328
2444
|
}
|
|
1329
2445
|
ensureProjectDir(projectName) {
|
|
1330
2446
|
const projectDir = this.getProjectDir(projectName);
|
|
1331
2447
|
this.mkdirp(projectDir);
|
|
1332
|
-
const configPath =
|
|
1333
|
-
if (!
|
|
2448
|
+
const configPath = join3(projectDir, "config.json");
|
|
2449
|
+
if (!existsSync3(configPath)) {
|
|
1334
2450
|
const config = {
|
|
1335
2451
|
name: projectName,
|
|
1336
2452
|
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
@@ -1343,31 +2459,31 @@ var ProjectManager = class {
|
|
|
1343
2459
|
}
|
|
1344
2460
|
// --- Config ---
|
|
1345
2461
|
getGlobalConfig() {
|
|
1346
|
-
const configPath =
|
|
1347
|
-
if (!
|
|
2462
|
+
const configPath = join3(this.baseDir, "config.json");
|
|
2463
|
+
if (!existsSync3(configPath)) return { ...DEFAULT_GLOBAL_CONFIG };
|
|
1348
2464
|
return { ...DEFAULT_GLOBAL_CONFIG, ...this.readJson(configPath) };
|
|
1349
2465
|
}
|
|
1350
2466
|
saveGlobalConfig(config) {
|
|
1351
|
-
this.writeJson(
|
|
2467
|
+
this.writeJson(join3(this.baseDir, "config.json"), config);
|
|
1352
2468
|
}
|
|
1353
2469
|
getProjectConfig(projectName) {
|
|
1354
|
-
const configPath =
|
|
1355
|
-
if (!
|
|
2470
|
+
const configPath = join3(this.getProjectDir(projectName), "config.json");
|
|
2471
|
+
if (!existsSync3(configPath)) return null;
|
|
1356
2472
|
return this.readJson(configPath);
|
|
1357
2473
|
}
|
|
1358
2474
|
saveProjectConfig(projectName, config) {
|
|
1359
|
-
this.writeJson(
|
|
2475
|
+
this.writeJson(join3(this.getProjectDir(projectName), "config.json"), config);
|
|
1360
2476
|
}
|
|
1361
2477
|
getInfrastructureConfig(projectName) {
|
|
1362
|
-
const jsonPath =
|
|
1363
|
-
if (
|
|
2478
|
+
const jsonPath = join3(this.getProjectDir(projectName), "infrastructure.json");
|
|
2479
|
+
if (existsSync3(jsonPath)) {
|
|
1364
2480
|
const config = this.readJson(jsonPath);
|
|
1365
2481
|
return this.resolveConfigEnvVars(config);
|
|
1366
2482
|
}
|
|
1367
|
-
const yamlPath =
|
|
1368
|
-
if (
|
|
2483
|
+
const yamlPath = join3(this.getProjectDir(projectName), "infrastructure.yaml");
|
|
2484
|
+
if (existsSync3(yamlPath)) {
|
|
1369
2485
|
try {
|
|
1370
|
-
const content =
|
|
2486
|
+
const content = readFileSync3(yamlPath, "utf-8");
|
|
1371
2487
|
return this.resolveConfigEnvVars(this.parseSimpleYaml(content));
|
|
1372
2488
|
} catch {
|
|
1373
2489
|
return null;
|
|
@@ -1376,18 +2492,18 @@ var ProjectManager = class {
|
|
|
1376
2492
|
return null;
|
|
1377
2493
|
}
|
|
1378
2494
|
getClaudeInstructions(projectName) {
|
|
1379
|
-
const filePath =
|
|
1380
|
-
if (!
|
|
1381
|
-
return
|
|
2495
|
+
const filePath = join3(this.getProjectDir(projectName), "claude-instructions.md");
|
|
2496
|
+
if (!existsSync3(filePath)) return null;
|
|
2497
|
+
return readFileSync3(filePath, "utf-8");
|
|
1382
2498
|
}
|
|
1383
2499
|
// --- Discovery ---
|
|
1384
2500
|
listProjects() {
|
|
1385
|
-
const projectsDir =
|
|
1386
|
-
if (!
|
|
1387
|
-
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);
|
|
1388
2504
|
}
|
|
1389
2505
|
projectExists(projectName) {
|
|
1390
|
-
return
|
|
2506
|
+
return existsSync3(this.getProjectDir(projectName));
|
|
1391
2507
|
}
|
|
1392
2508
|
// --- Project ID helpers ---
|
|
1393
2509
|
/** Look up the stored projectId for an appName. Returns null if none set. */
|
|
@@ -1439,9 +2555,9 @@ var ProjectManager = class {
|
|
|
1439
2555
|
for (const p of pmStore.listProjects()) {
|
|
1440
2556
|
if (p.path) {
|
|
1441
2557
|
try {
|
|
1442
|
-
const configPath =
|
|
1443
|
-
if (
|
|
1444
|
-
const content =
|
|
2558
|
+
const configPath = join3(p.path, ".runtimescope", "config.json");
|
|
2559
|
+
if (existsSync3(configPath)) {
|
|
2560
|
+
const content = readFileSync3(configPath, "utf-8");
|
|
1445
2561
|
const config = JSON.parse(content);
|
|
1446
2562
|
if (config.projectId) {
|
|
1447
2563
|
if (config.appName) {
|
|
@@ -1474,16 +2590,16 @@ var ProjectManager = class {
|
|
|
1474
2590
|
}
|
|
1475
2591
|
// --- Private helpers ---
|
|
1476
2592
|
mkdirp(dir) {
|
|
1477
|
-
if (!
|
|
1478
|
-
|
|
2593
|
+
if (!existsSync3(dir)) {
|
|
2594
|
+
mkdirSync3(dir, { recursive: true });
|
|
1479
2595
|
}
|
|
1480
2596
|
}
|
|
1481
2597
|
readJson(path) {
|
|
1482
|
-
const content =
|
|
2598
|
+
const content = readFileSync3(path, "utf-8");
|
|
1483
2599
|
return JSON.parse(content);
|
|
1484
2600
|
}
|
|
1485
2601
|
writeJson(path, data) {
|
|
1486
|
-
|
|
2602
|
+
writeFileSync2(path, JSON.stringify(data, null, 2) + "\n", "utf-8");
|
|
1487
2603
|
}
|
|
1488
2604
|
resolveConfigEnvVars(config) {
|
|
1489
2605
|
const resolve2 = (obj) => {
|
|
@@ -1520,8 +2636,8 @@ var ProjectManager = class {
|
|
|
1520
2636
|
};
|
|
1521
2637
|
|
|
1522
2638
|
// src/project-config.ts
|
|
1523
|
-
import { readFileSync as
|
|
1524
|
-
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";
|
|
1525
2641
|
var DEFAULT_CAPTURE = {
|
|
1526
2642
|
network: true,
|
|
1527
2643
|
console: true,
|
|
@@ -1536,19 +2652,19 @@ var DEFAULT_CAPTURE = {
|
|
|
1536
2652
|
stackTraces: false
|
|
1537
2653
|
};
|
|
1538
2654
|
function readProjectConfig(projectDir) {
|
|
1539
|
-
const configPath =
|
|
1540
|
-
if (!
|
|
2655
|
+
const configPath = join4(projectDir, ".runtimescope", "config.json");
|
|
2656
|
+
if (!existsSync4(configPath)) return null;
|
|
1541
2657
|
try {
|
|
1542
|
-
const content =
|
|
2658
|
+
const content = readFileSync4(configPath, "utf-8");
|
|
1543
2659
|
return JSON.parse(content);
|
|
1544
2660
|
} catch {
|
|
1545
2661
|
return null;
|
|
1546
2662
|
}
|
|
1547
2663
|
}
|
|
1548
2664
|
function writeProjectConfig(projectDir, config) {
|
|
1549
|
-
const dir =
|
|
1550
|
-
if (!
|
|
1551
|
-
|
|
2665
|
+
const dir = join4(projectDir, ".runtimescope");
|
|
2666
|
+
if (!existsSync4(dir)) mkdirSync4(dir, { recursive: true });
|
|
2667
|
+
writeFileSync3(join4(dir, "config.json"), JSON.stringify(config, null, 2) + "\n", "utf-8");
|
|
1552
2668
|
}
|
|
1553
2669
|
function scaffoldProjectConfig(projectDir, opts) {
|
|
1554
2670
|
const existing = readProjectConfig(projectDir);
|
|
@@ -1579,9 +2695,9 @@ function scaffoldProjectConfig(projectDir, opts) {
|
|
|
1579
2695
|
category: opts.category
|
|
1580
2696
|
};
|
|
1581
2697
|
writeProjectConfig(projectDir, config);
|
|
1582
|
-
const gitignorePath =
|
|
1583
|
-
if (!
|
|
1584
|
-
|
|
2698
|
+
const gitignorePath = join4(projectDir, ".runtimescope", ".gitignore");
|
|
2699
|
+
if (!existsSync4(gitignorePath)) {
|
|
2700
|
+
writeFileSync3(gitignorePath, "# Keep config.json committed, ignore local state\n*.log\n*.db\n.env\n", "utf-8");
|
|
1585
2701
|
}
|
|
1586
2702
|
return config;
|
|
1587
2703
|
}
|
|
@@ -1603,9 +2719,9 @@ function migrateProjectIds(projectManager, pmStore) {
|
|
|
1603
2719
|
}
|
|
1604
2720
|
let canonicalId = null;
|
|
1605
2721
|
try {
|
|
1606
|
-
const configPath =
|
|
1607
|
-
if (
|
|
1608
|
-
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"));
|
|
1609
2725
|
if (config.projectId) canonicalId = config.projectId;
|
|
1610
2726
|
}
|
|
1611
2727
|
} catch {
|
|
@@ -1643,7 +2759,7 @@ function migrateProjectIds(projectManager, pmStore) {
|
|
|
1643
2759
|
}
|
|
1644
2760
|
|
|
1645
2761
|
// src/auth.ts
|
|
1646
|
-
import { randomBytes as
|
|
2762
|
+
import { randomBytes as randomBytes3, timingSafeEqual } from "crypto";
|
|
1647
2763
|
var AuthManager = class {
|
|
1648
2764
|
keys = /* @__PURE__ */ new Map();
|
|
1649
2765
|
enabled;
|
|
@@ -1690,7 +2806,7 @@ var AuthManager = class {
|
|
|
1690
2806
|
};
|
|
1691
2807
|
function generateApiKey(label, project) {
|
|
1692
2808
|
return {
|
|
1693
|
-
key:
|
|
2809
|
+
key: randomBytes3(32).toString("hex"),
|
|
1694
2810
|
label,
|
|
1695
2811
|
project,
|
|
1696
2812
|
createdAt: Date.now()
|
|
@@ -1805,8 +2921,8 @@ var Redactor = class {
|
|
|
1805
2921
|
|
|
1806
2922
|
// src/platform.ts
|
|
1807
2923
|
import { execFileSync, execSync } from "child_process";
|
|
1808
|
-
import { readlinkSync, readdirSync as
|
|
1809
|
-
import { join as
|
|
2924
|
+
import { readlinkSync, readdirSync as readdirSync3 } from "fs";
|
|
2925
|
+
import { join as join5 } from "path";
|
|
1810
2926
|
var IS_WIN = process.platform === "win32";
|
|
1811
2927
|
var IS_LINUX = process.platform === "linux";
|
|
1812
2928
|
function runFile(cmd, args, timeoutMs = 5e3) {
|
|
@@ -1921,11 +3037,11 @@ function findPidsInDir_linux(dir) {
|
|
|
1921
3037
|
if (lsofResult.length > 0) return lsofResult;
|
|
1922
3038
|
try {
|
|
1923
3039
|
const pids = [];
|
|
1924
|
-
for (const entry of
|
|
3040
|
+
for (const entry of readdirSync3("/proc")) {
|
|
1925
3041
|
const pid = parseInt(entry, 10);
|
|
1926
3042
|
if (isNaN(pid) || pid <= 1) continue;
|
|
1927
3043
|
try {
|
|
1928
|
-
const cwd = readlinkSync(
|
|
3044
|
+
const cwd = readlinkSync(join5("/proc", entry, "cwd"));
|
|
1929
3045
|
if (cwd.startsWith(dir)) pids.push(pid);
|
|
1930
3046
|
} catch {
|
|
1931
3047
|
}
|
|
@@ -2129,15 +3245,15 @@ var SessionManager = class {
|
|
|
2129
3245
|
// src/http-server.ts
|
|
2130
3246
|
import { createServer } from "http";
|
|
2131
3247
|
import { createServer as createHttpsServer2 } from "https";
|
|
2132
|
-
import { readFileSync as
|
|
2133
|
-
import { resolve, dirname } from "path";
|
|
3248
|
+
import { readFileSync as readFileSync5, existsSync as existsSync6 } from "fs";
|
|
3249
|
+
import { resolve, dirname as dirname2 } from "path";
|
|
2134
3250
|
import { fileURLToPath } from "url";
|
|
2135
3251
|
import { WebSocketServer as WebSocketServer2 } from "ws";
|
|
2136
3252
|
|
|
2137
3253
|
// src/pm/pm-routes.ts
|
|
2138
3254
|
import { readdir, readFile, writeFile, unlink, mkdir } from "fs/promises";
|
|
2139
|
-
import { existsSync as
|
|
2140
|
-
import { join as
|
|
3255
|
+
import { existsSync as existsSync5 } from "fs";
|
|
3256
|
+
import { join as join6 } from "path";
|
|
2141
3257
|
import { homedir as homedir2 } from "os";
|
|
2142
3258
|
import { spawn, execFileSync as execFileSync2 } from "child_process";
|
|
2143
3259
|
var LOG_RING_SIZE = 500;
|
|
@@ -2147,6 +3263,19 @@ function pushLog(mp, stream, line) {
|
|
|
2147
3263
|
if (mp.logs.length >= LOG_RING_SIZE) mp.logs.shift();
|
|
2148
3264
|
mp.logs.push(entry);
|
|
2149
3265
|
}
|
|
3266
|
+
function requireWorkspaceAccess(helpers, req, res, targetWorkspaceId) {
|
|
3267
|
+
const caller = helpers.resolveCaller(req);
|
|
3268
|
+
if (caller.isAdmin) return true;
|
|
3269
|
+
if (caller.workspaceId && caller.workspaceId === targetWorkspaceId) return true;
|
|
3270
|
+
helpers.json(res, { error: "Forbidden: caller not authorized for this workspace" }, 403);
|
|
3271
|
+
return false;
|
|
3272
|
+
}
|
|
3273
|
+
function requireAdmin(helpers, req, res) {
|
|
3274
|
+
const caller = helpers.resolveCaller(req);
|
|
3275
|
+
if (caller.isAdmin) return true;
|
|
3276
|
+
helpers.json(res, { error: "Forbidden: admin required" }, 403);
|
|
3277
|
+
return false;
|
|
3278
|
+
}
|
|
2150
3279
|
function createPmRouter(pmStore, discovery, helpers, broadcastDevServer) {
|
|
2151
3280
|
const routes = [];
|
|
2152
3281
|
function route(method, pattern, handler) {
|
|
@@ -2306,6 +3435,20 @@ function createPmRouter(pmStore, discovery, helpers, broadcastDevServer) {
|
|
|
2306
3435
|
helpers.json(res, { error: "Missing workspace_id" }, 400);
|
|
2307
3436
|
return;
|
|
2308
3437
|
}
|
|
3438
|
+
const project = pmStore.getProject(id);
|
|
3439
|
+
if (!project) {
|
|
3440
|
+
helpers.json(res, { error: "Project not found" }, 404);
|
|
3441
|
+
return;
|
|
3442
|
+
}
|
|
3443
|
+
const caller = helpers.resolveCaller(req);
|
|
3444
|
+
if (!caller.isAdmin) {
|
|
3445
|
+
const sourceOk = !!project.workspaceId && project.workspaceId === caller.workspaceId;
|
|
3446
|
+
const destOk = parsed.workspace_id === caller.workspaceId;
|
|
3447
|
+
if (!sourceOk || !destOk) {
|
|
3448
|
+
helpers.json(res, { error: "Forbidden: caller must own both source and destination workspace" }, 403);
|
|
3449
|
+
return;
|
|
3450
|
+
}
|
|
3451
|
+
}
|
|
2309
3452
|
try {
|
|
2310
3453
|
pmStore.setProjectWorkspace(id, parsed.workspace_id);
|
|
2311
3454
|
helpers.json(res, { ok: true });
|
|
@@ -2313,10 +3456,18 @@ function createPmRouter(pmStore, discovery, helpers, broadcastDevServer) {
|
|
|
2313
3456
|
helpers.json(res, { error: err.message }, 400);
|
|
2314
3457
|
}
|
|
2315
3458
|
});
|
|
2316
|
-
route("GET", "/api/pm/workspaces", (
|
|
2317
|
-
|
|
3459
|
+
route("GET", "/api/pm/workspaces", (req, res) => {
|
|
3460
|
+
const caller = helpers.resolveCaller(req);
|
|
3461
|
+
const all = pmStore.listWorkspaces();
|
|
3462
|
+
if (caller.isAdmin) {
|
|
3463
|
+
helpers.json(res, { data: all });
|
|
3464
|
+
return;
|
|
3465
|
+
}
|
|
3466
|
+
const filtered = caller.workspaceId ? all.filter((w) => w.id === caller.workspaceId) : [];
|
|
3467
|
+
helpers.json(res, { data: filtered });
|
|
2318
3468
|
});
|
|
2319
3469
|
route("POST", "/api/pm/workspaces", async (req, res) => {
|
|
3470
|
+
if (!requireAdmin(helpers, req, res)) return;
|
|
2320
3471
|
const body = await helpers.readBody(req, 4096);
|
|
2321
3472
|
if (!body) {
|
|
2322
3473
|
helpers.json(res, { error: "Missing body" }, 400);
|
|
@@ -2340,8 +3491,9 @@ function createPmRouter(pmStore, discovery, helpers, broadcastDevServer) {
|
|
|
2340
3491
|
helpers.json(res, { error: err.message }, 400);
|
|
2341
3492
|
}
|
|
2342
3493
|
});
|
|
2343
|
-
route("GET", "/api/pm/workspaces/:id", (
|
|
3494
|
+
route("GET", "/api/pm/workspaces/:id", (req, res, params) => {
|
|
2344
3495
|
const id = params.get("id");
|
|
3496
|
+
if (!requireWorkspaceAccess(helpers, req, res, id)) return;
|
|
2345
3497
|
const ws = pmStore.getWorkspace(id);
|
|
2346
3498
|
if (!ws) {
|
|
2347
3499
|
helpers.json(res, { error: "Workspace not found" }, 404);
|
|
@@ -2351,6 +3503,7 @@ function createPmRouter(pmStore, discovery, helpers, broadcastDevServer) {
|
|
|
2351
3503
|
});
|
|
2352
3504
|
route("PUT", "/api/pm/workspaces/:id", async (req, res, params) => {
|
|
2353
3505
|
const id = params.get("id");
|
|
3506
|
+
if (!requireWorkspaceAccess(helpers, req, res, id)) return;
|
|
2354
3507
|
if (!pmStore.getWorkspace(id)) {
|
|
2355
3508
|
helpers.json(res, { error: "Workspace not found" }, 404);
|
|
2356
3509
|
return;
|
|
@@ -2374,7 +3527,8 @@ function createPmRouter(pmStore, discovery, helpers, broadcastDevServer) {
|
|
|
2374
3527
|
helpers.json(res, { error: err.message }, 400);
|
|
2375
3528
|
}
|
|
2376
3529
|
});
|
|
2377
|
-
route("DELETE", "/api/pm/workspaces/:id", (
|
|
3530
|
+
route("DELETE", "/api/pm/workspaces/:id", (req, res, params) => {
|
|
3531
|
+
if (!requireAdmin(helpers, req, res)) return;
|
|
2378
3532
|
const id = params.get("id");
|
|
2379
3533
|
try {
|
|
2380
3534
|
pmStore.deleteWorkspace(id);
|
|
@@ -2383,8 +3537,9 @@ function createPmRouter(pmStore, discovery, helpers, broadcastDevServer) {
|
|
|
2383
3537
|
helpers.json(res, { error: err.message }, 400);
|
|
2384
3538
|
}
|
|
2385
3539
|
});
|
|
2386
|
-
route("GET", "/api/pm/workspaces/:id/api-keys", (
|
|
3540
|
+
route("GET", "/api/pm/workspaces/:id/api-keys", (req, res, params) => {
|
|
2387
3541
|
const id = params.get("id");
|
|
3542
|
+
if (!requireWorkspaceAccess(helpers, req, res, id)) return;
|
|
2388
3543
|
if (!pmStore.getWorkspace(id)) {
|
|
2389
3544
|
helpers.json(res, { error: "Workspace not found" }, 404);
|
|
2390
3545
|
return;
|
|
@@ -2393,6 +3548,7 @@ function createPmRouter(pmStore, discovery, helpers, broadcastDevServer) {
|
|
|
2393
3548
|
});
|
|
2394
3549
|
route("POST", "/api/pm/workspaces/:id/api-keys", async (req, res, params) => {
|
|
2395
3550
|
const id = params.get("id");
|
|
3551
|
+
if (!requireWorkspaceAccess(helpers, req, res, id)) return;
|
|
2396
3552
|
if (!pmStore.getWorkspace(id)) {
|
|
2397
3553
|
helpers.json(res, { error: "Workspace not found" }, 404);
|
|
2398
3554
|
return;
|
|
@@ -2420,8 +3576,15 @@ function createPmRouter(pmStore, discovery, helpers, broadcastDevServer) {
|
|
|
2420
3576
|
helpers.json(res, { error: err.message }, 400);
|
|
2421
3577
|
}
|
|
2422
3578
|
});
|
|
2423
|
-
route("DELETE", "/api/pm/api-keys/:
|
|
2424
|
-
|
|
3579
|
+
route("DELETE", "/api/pm/api-keys/:prefix", (req, res, params) => {
|
|
3580
|
+
const prefix = params.get("prefix");
|
|
3581
|
+
const key = pmStore.findApiKeyByPrefix(prefix);
|
|
3582
|
+
if (!key) {
|
|
3583
|
+
helpers.json(res, { error: "Key not found" }, 404);
|
|
3584
|
+
return;
|
|
3585
|
+
}
|
|
3586
|
+
if (!requireWorkspaceAccess(helpers, req, res, key.workspaceId)) return;
|
|
3587
|
+
pmStore.revokeApiKey(prefix);
|
|
2425
3588
|
helpers.json(res, { ok: true });
|
|
2426
3589
|
});
|
|
2427
3590
|
route("GET", "/api/pm/tasks", (_req, res, params) => {
|
|
@@ -2599,13 +3762,13 @@ function createPmRouter(pmStore, discovery, helpers, broadcastDevServer) {
|
|
|
2599
3762
|
helpers.json(res, { data: [], count: 0 });
|
|
2600
3763
|
return;
|
|
2601
3764
|
}
|
|
2602
|
-
const memoryDir =
|
|
3765
|
+
const memoryDir = join6(homedir2(), ".claude", "projects", project.claudeProjectKey, "memory");
|
|
2603
3766
|
try {
|
|
2604
3767
|
const files = await readdir(memoryDir);
|
|
2605
3768
|
const mdFiles = files.filter((f) => f.endsWith(".md"));
|
|
2606
3769
|
const result = await Promise.all(
|
|
2607
3770
|
mdFiles.map(async (filename) => {
|
|
2608
|
-
const content = await readFile(
|
|
3771
|
+
const content = await readFile(join6(memoryDir, filename), "utf-8");
|
|
2609
3772
|
return { filename, content, sizeBytes: Buffer.byteLength(content) };
|
|
2610
3773
|
})
|
|
2611
3774
|
);
|
|
@@ -2622,7 +3785,7 @@ function createPmRouter(pmStore, discovery, helpers, broadcastDevServer) {
|
|
|
2622
3785
|
helpers.json(res, { error: "Project not found" }, 404);
|
|
2623
3786
|
return;
|
|
2624
3787
|
}
|
|
2625
|
-
const filePath =
|
|
3788
|
+
const filePath = join6(homedir2(), ".claude", "projects", project.claudeProjectKey, "memory", filename);
|
|
2626
3789
|
try {
|
|
2627
3790
|
const content = await readFile(filePath, "utf-8");
|
|
2628
3791
|
helpers.json(res, { filename, content, sizeBytes: Buffer.byteLength(content) });
|
|
@@ -2645,9 +3808,9 @@ function createPmRouter(pmStore, discovery, helpers, broadcastDevServer) {
|
|
|
2645
3808
|
}
|
|
2646
3809
|
try {
|
|
2647
3810
|
const { content } = JSON.parse(body);
|
|
2648
|
-
const memoryDir =
|
|
3811
|
+
const memoryDir = join6(homedir2(), ".claude", "projects", project.claudeProjectKey, "memory");
|
|
2649
3812
|
await mkdir(memoryDir, { recursive: true });
|
|
2650
|
-
await writeFile(
|
|
3813
|
+
await writeFile(join6(memoryDir, filename), content, "utf-8");
|
|
2651
3814
|
helpers.json(res, { ok: true });
|
|
2652
3815
|
} catch (err) {
|
|
2653
3816
|
helpers.json(res, { error: err.message }, 500);
|
|
@@ -2661,7 +3824,7 @@ function createPmRouter(pmStore, discovery, helpers, broadcastDevServer) {
|
|
|
2661
3824
|
helpers.json(res, { error: "Project not found" }, 404);
|
|
2662
3825
|
return;
|
|
2663
3826
|
}
|
|
2664
|
-
const filePath =
|
|
3827
|
+
const filePath = join6(homedir2(), ".claude", "projects", project.claudeProjectKey, "memory", filename);
|
|
2665
3828
|
try {
|
|
2666
3829
|
await unlink(filePath);
|
|
2667
3830
|
helpers.json(res, { ok: true });
|
|
@@ -2721,7 +3884,7 @@ function createPmRouter(pmStore, discovery, helpers, broadcastDevServer) {
|
|
|
2721
3884
|
const { content } = JSON.parse(body);
|
|
2722
3885
|
const paths = getRulesPaths(project.claudeProjectKey, project.path);
|
|
2723
3886
|
const filePath = paths[scope];
|
|
2724
|
-
const dir =
|
|
3887
|
+
const dir = join6(filePath, "..");
|
|
2725
3888
|
await mkdir(dir, { recursive: true });
|
|
2726
3889
|
await writeFile(filePath, content, "utf-8");
|
|
2727
3890
|
helpers.json(res, { ok: true });
|
|
@@ -2741,7 +3904,7 @@ function createPmRouter(pmStore, discovery, helpers, broadcastDevServer) {
|
|
|
2741
3904
|
return;
|
|
2742
3905
|
}
|
|
2743
3906
|
try {
|
|
2744
|
-
const pkgPath =
|
|
3907
|
+
const pkgPath = join6(project.path, "package.json");
|
|
2745
3908
|
const pkg = JSON.parse(await readFile(pkgPath, "utf-8"));
|
|
2746
3909
|
const scripts = pkg.scripts ?? {};
|
|
2747
3910
|
const recommended = ["dev", "start", "serve"].find((s) => s in scripts) ?? null;
|
|
@@ -3267,9 +4430,9 @@ function sanitizeFilename(name) {
|
|
|
3267
4430
|
function getRulesPaths(claudeProjectKey, projectPath) {
|
|
3268
4431
|
const home = homedir2();
|
|
3269
4432
|
return {
|
|
3270
|
-
global:
|
|
3271
|
-
project: claudeProjectKey ?
|
|
3272
|
-
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")
|
|
3273
4436
|
};
|
|
3274
4437
|
}
|
|
3275
4438
|
function execGit(args, cwd) {
|
|
@@ -3314,7 +4477,7 @@ function parseGitStatus(porcelain) {
|
|
|
3314
4477
|
}
|
|
3315
4478
|
async function readRuleFile(filePath) {
|
|
3316
4479
|
try {
|
|
3317
|
-
if (
|
|
4480
|
+
if (existsSync5(filePath)) {
|
|
3318
4481
|
const content = await readFile(filePath, "utf-8");
|
|
3319
4482
|
return { path: filePath, content, exists: true };
|
|
3320
4483
|
}
|
|
@@ -3326,8 +4489,8 @@ async function readRuleFile(filePath) {
|
|
|
3326
4489
|
// src/http-server.ts
|
|
3327
4490
|
var COLLECTOR_VERSION = (() => {
|
|
3328
4491
|
try {
|
|
3329
|
-
const here =
|
|
3330
|
-
const pkgJson =
|
|
4492
|
+
const here = dirname2(fileURLToPath(import.meta.url));
|
|
4493
|
+
const pkgJson = readFileSync5(resolve(here, "..", "package.json"), "utf-8");
|
|
3331
4494
|
const pkg = JSON.parse(pkgJson);
|
|
3332
4495
|
return pkg.version ?? "unknown";
|
|
3333
4496
|
} catch {
|
|
@@ -3352,6 +4515,10 @@ var HttpServer = class {
|
|
|
3352
4515
|
connectedSessionsGetter = null;
|
|
3353
4516
|
pmStore = null;
|
|
3354
4517
|
projectManager = null;
|
|
4518
|
+
isReadyGetter = null;
|
|
4519
|
+
snapshotFn = null;
|
|
4520
|
+
lastSnapshotAt = 0;
|
|
4521
|
+
renderMetricsFn = null;
|
|
3355
4522
|
constructor(store, processMonitor, options) {
|
|
3356
4523
|
this.store = store;
|
|
3357
4524
|
this.processMonitor = processMonitor ?? null;
|
|
@@ -3361,11 +4528,15 @@ var HttpServer = class {
|
|
|
3361
4528
|
this.connectedSessionsGetter = options?.getConnectedSessions ?? null;
|
|
3362
4529
|
this.pmStore = options?.pmStore ?? null;
|
|
3363
4530
|
this.projectManager = options?.projectManager ?? null;
|
|
4531
|
+
this.isReadyGetter = options?.isReady ?? null;
|
|
4532
|
+
this.snapshotFn = options?.createSnapshot ?? null;
|
|
4533
|
+
this.renderMetricsFn = options?.renderMetrics ?? null;
|
|
3364
4534
|
this.registerRoutes();
|
|
3365
4535
|
if (options?.pmStore && options?.discovery) {
|
|
3366
4536
|
this.pmRouter = createPmRouter(options.pmStore, options.discovery, {
|
|
3367
4537
|
json: (res, data, status) => this.json(res, data, status),
|
|
3368
|
-
readBody: (req, maxBytes) => this.readBody(req, maxBytes)
|
|
4538
|
+
readBody: (req, maxBytes) => this.readBody(req, maxBytes),
|
|
4539
|
+
resolveCaller: (req) => req._rsCaller ?? { isAdmin: !this.authManager?.isEnabled(), workspaceId: null }
|
|
3369
4540
|
}, (msg) => this.broadcastDevServer(msg));
|
|
3370
4541
|
}
|
|
3371
4542
|
}
|
|
@@ -3380,6 +4551,58 @@ var HttpServer = class {
|
|
|
3380
4551
|
authEnabled: this.authManager?.isEnabled() ?? false
|
|
3381
4552
|
});
|
|
3382
4553
|
});
|
|
4554
|
+
this.routes.set("GET /readyz", (_req, res) => {
|
|
4555
|
+
const ready = this.isReadyGetter ? this.isReadyGetter() : true;
|
|
4556
|
+
if (ready) {
|
|
4557
|
+
this.json(res, { status: "ready", timestamp: Date.now() });
|
|
4558
|
+
} else {
|
|
4559
|
+
this.json(res, { status: "starting", timestamp: Date.now() }, 503);
|
|
4560
|
+
}
|
|
4561
|
+
});
|
|
4562
|
+
this.routes.set("GET /metrics", (_req, res) => {
|
|
4563
|
+
if (process.env.RUNTIMESCOPE_DISABLE_METRICS === "1") {
|
|
4564
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
4565
|
+
res.end("Metrics disabled (RUNTIMESCOPE_DISABLE_METRICS=1).\n");
|
|
4566
|
+
return;
|
|
4567
|
+
}
|
|
4568
|
+
const body = this.renderMetricsFn ? this.renderMetricsFn() : "";
|
|
4569
|
+
res.writeHead(200, { "Content-Type": "text/plain; version=0.0.4; charset=utf-8" });
|
|
4570
|
+
res.end(body);
|
|
4571
|
+
});
|
|
4572
|
+
this.routes.set("POST /api/v1/admin/snapshot", (req, res) => {
|
|
4573
|
+
if (!this.snapshotFn) {
|
|
4574
|
+
this.json(res, { error: "Snapshot is not available on this collector" }, 501);
|
|
4575
|
+
return;
|
|
4576
|
+
}
|
|
4577
|
+
const caller = req._rsCaller ?? {
|
|
4578
|
+
isAdmin: !this.authManager?.isEnabled(),
|
|
4579
|
+
workspaceId: null
|
|
4580
|
+
};
|
|
4581
|
+
if (!caller.isAdmin) {
|
|
4582
|
+
this.json(res, { error: "Forbidden: snapshot requires admin" }, 403);
|
|
4583
|
+
return;
|
|
4584
|
+
}
|
|
4585
|
+
const now = Date.now();
|
|
4586
|
+
const sinceLast = now - this.lastSnapshotAt;
|
|
4587
|
+
const COOLDOWN_MS = 6e4;
|
|
4588
|
+
if (sinceLast < COOLDOWN_MS) {
|
|
4589
|
+
const retryAfter = Math.ceil((COOLDOWN_MS - sinceLast) / 1e3);
|
|
4590
|
+
res.setHeader("Retry-After", String(retryAfter));
|
|
4591
|
+
this.json(
|
|
4592
|
+
res,
|
|
4593
|
+
{ error: "Snapshot rate-limited", retryAfterSeconds: retryAfter },
|
|
4594
|
+
429
|
|
4595
|
+
);
|
|
4596
|
+
return;
|
|
4597
|
+
}
|
|
4598
|
+
this.lastSnapshotAt = now;
|
|
4599
|
+
try {
|
|
4600
|
+
const result = this.snapshotFn();
|
|
4601
|
+
this.json(res, result, 201);
|
|
4602
|
+
} catch (err) {
|
|
4603
|
+
this.json(res, { error: err.message }, 500);
|
|
4604
|
+
}
|
|
4605
|
+
});
|
|
3383
4606
|
this.routes.set("GET /api/sessions", (_req, res) => {
|
|
3384
4607
|
const sessions = this.store.getSessionInfo();
|
|
3385
4608
|
this.json(res, { data: sessions, count: sessions.length });
|
|
@@ -3465,90 +4688,108 @@ var HttpServer = class {
|
|
|
3465
4688
|
const ports = this.processMonitor.getPortUsage(port);
|
|
3466
4689
|
this.json(res, { data: ports, count: ports.length });
|
|
3467
4690
|
});
|
|
3468
|
-
this.routes.set("GET /api/events/network", (
|
|
4691
|
+
this.routes.set("GET /api/events/network", (req, res, params) => {
|
|
4692
|
+
const projectId = this.authorizeProjectIdParam(req, res, params);
|
|
4693
|
+
if (projectId === false) return;
|
|
3469
4694
|
const events = this.store.getNetworkRequests({
|
|
3470
4695
|
sinceSeconds: numParam(params, "since_seconds"),
|
|
3471
4696
|
urlPattern: params.get("url_pattern") ?? void 0,
|
|
3472
4697
|
method: params.get("method") ?? void 0,
|
|
3473
4698
|
sessionId: params.get("session_id") ?? void 0,
|
|
3474
|
-
projectId
|
|
4699
|
+
projectId
|
|
3475
4700
|
});
|
|
3476
4701
|
this.json(res, { data: events, count: events.length });
|
|
3477
4702
|
});
|
|
3478
|
-
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;
|
|
3479
4706
|
const events = this.store.getConsoleMessages({
|
|
3480
4707
|
sinceSeconds: numParam(params, "since_seconds"),
|
|
3481
4708
|
level: params.get("level") ?? void 0,
|
|
3482
4709
|
search: params.get("search") ?? void 0,
|
|
3483
4710
|
sessionId: params.get("session_id") ?? void 0,
|
|
3484
|
-
projectId
|
|
4711
|
+
projectId
|
|
3485
4712
|
});
|
|
3486
4713
|
this.json(res, { data: events, count: events.length });
|
|
3487
4714
|
});
|
|
3488
|
-
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;
|
|
3489
4718
|
const events = this.store.getStateEvents({
|
|
3490
4719
|
sinceSeconds: numParam(params, "since_seconds"),
|
|
3491
4720
|
storeId: params.get("store_id") ?? void 0,
|
|
3492
4721
|
sessionId: params.get("session_id") ?? void 0,
|
|
3493
|
-
projectId
|
|
4722
|
+
projectId
|
|
3494
4723
|
});
|
|
3495
4724
|
this.json(res, { data: events, count: events.length });
|
|
3496
4725
|
});
|
|
3497
|
-
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;
|
|
3498
4729
|
const events = this.store.getRenderEvents({
|
|
3499
4730
|
sinceSeconds: numParam(params, "since_seconds"),
|
|
3500
4731
|
componentName: params.get("component") ?? void 0,
|
|
3501
4732
|
sessionId: params.get("session_id") ?? void 0,
|
|
3502
|
-
projectId
|
|
4733
|
+
projectId
|
|
3503
4734
|
});
|
|
3504
4735
|
this.json(res, { data: events, count: events.length });
|
|
3505
4736
|
});
|
|
3506
|
-
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;
|
|
3507
4740
|
const events = this.store.getPerformanceMetrics({
|
|
3508
4741
|
sinceSeconds: numParam(params, "since_seconds"),
|
|
3509
4742
|
metricName: params.get("metric") ?? void 0,
|
|
3510
4743
|
sessionId: params.get("session_id") ?? void 0,
|
|
3511
|
-
projectId
|
|
4744
|
+
projectId
|
|
3512
4745
|
});
|
|
3513
4746
|
this.json(res, { data: events, count: events.length });
|
|
3514
4747
|
});
|
|
3515
|
-
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;
|
|
3516
4751
|
const events = this.store.getDatabaseEvents({
|
|
3517
4752
|
sinceSeconds: numParam(params, "since_seconds"),
|
|
3518
4753
|
table: params.get("table") ?? void 0,
|
|
3519
4754
|
minDurationMs: numParam(params, "min_duration_ms"),
|
|
3520
4755
|
search: params.get("search") ?? void 0,
|
|
3521
4756
|
sessionId: params.get("session_id") ?? void 0,
|
|
3522
|
-
projectId
|
|
4757
|
+
projectId
|
|
3523
4758
|
});
|
|
3524
4759
|
this.json(res, { data: events, count: events.length });
|
|
3525
4760
|
});
|
|
3526
|
-
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;
|
|
3527
4764
|
const eventTypes = params.get("event_types")?.split(",") ?? void 0;
|
|
3528
4765
|
const events = this.store.getEventTimeline({
|
|
3529
4766
|
sinceSeconds: numParam(params, "since_seconds"),
|
|
3530
4767
|
eventTypes,
|
|
3531
4768
|
sessionId: params.get("session_id") ?? void 0,
|
|
3532
|
-
projectId
|
|
4769
|
+
projectId
|
|
3533
4770
|
});
|
|
3534
4771
|
this.json(res, { data: events, count: events.length });
|
|
3535
4772
|
});
|
|
3536
|
-
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;
|
|
3537
4776
|
const events = this.store.getCustomEvents({
|
|
3538
4777
|
name: params.get("name") ?? void 0,
|
|
3539
4778
|
sinceSeconds: numParam(params, "since_seconds"),
|
|
3540
4779
|
sessionId: params.get("session_id") ?? void 0,
|
|
3541
|
-
projectId
|
|
4780
|
+
projectId
|
|
3542
4781
|
});
|
|
3543
4782
|
this.json(res, { data: events, count: events.length });
|
|
3544
4783
|
});
|
|
3545
|
-
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;
|
|
3546
4787
|
const action = params.get("action");
|
|
3547
4788
|
const events = this.store.getUIInteractions({
|
|
3548
4789
|
action: action ?? void 0,
|
|
3549
4790
|
sinceSeconds: numParam(params, "since_seconds"),
|
|
3550
4791
|
sessionId: params.get("session_id") ?? void 0,
|
|
3551
|
-
projectId
|
|
4792
|
+
projectId
|
|
3552
4793
|
});
|
|
3553
4794
|
this.json(res, { data: events, count: events.length });
|
|
3554
4795
|
});
|
|
@@ -3662,7 +4903,7 @@ var HttpServer = class {
|
|
|
3662
4903
|
*/
|
|
3663
4904
|
resolveSdkPath() {
|
|
3664
4905
|
if (this.sdkBundlePath) return this.sdkBundlePath;
|
|
3665
|
-
const __dir =
|
|
4906
|
+
const __dir = dirname2(fileURLToPath(import.meta.url));
|
|
3666
4907
|
const candidates = [
|
|
3667
4908
|
resolve(__dir, "../../sdk/dist/index.global.js"),
|
|
3668
4909
|
// monorepo: packages/collector/dist -> packages/sdk/dist
|
|
@@ -3670,13 +4911,55 @@ var HttpServer = class {
|
|
|
3670
4911
|
// npm installed
|
|
3671
4912
|
];
|
|
3672
4913
|
for (const p of candidates) {
|
|
3673
|
-
if (
|
|
4914
|
+
if (existsSync6(p)) {
|
|
3674
4915
|
this.sdkBundlePath = p;
|
|
3675
4916
|
return p;
|
|
3676
4917
|
}
|
|
3677
4918
|
}
|
|
3678
4919
|
return null;
|
|
3679
4920
|
}
|
|
4921
|
+
getPort() {
|
|
4922
|
+
return this.activePort;
|
|
4923
|
+
}
|
|
4924
|
+
/**
|
|
4925
|
+
* Validate the `project_id` query parameter against the caller's workspace.
|
|
4926
|
+
*
|
|
4927
|
+
* Returns:
|
|
4928
|
+
* - `string` — the caller is authorized; pass to store.
|
|
4929
|
+
* - `undefined` — caller is admin and didn't specify a project_id (all projects allowed).
|
|
4930
|
+
* - `false` — not authorized (a 400 or 403 response has already been written); the handler must return immediately.
|
|
4931
|
+
*
|
|
4932
|
+
* Callers with a workspace-scoped token MUST provide `project_id`, and it
|
|
4933
|
+
* must resolve to a PM project in the caller's workspace. Runtime projectIds
|
|
4934
|
+
* without a PM record (never registered via setup_project) fall through to
|
|
4935
|
+
* admin-only; workspace-scoped callers get 403.
|
|
4936
|
+
*/
|
|
4937
|
+
authorizeProjectIdParam(req, res, params) {
|
|
4938
|
+
const caller = req._rsCaller ?? {
|
|
4939
|
+
isAdmin: !this.authManager?.isEnabled(),
|
|
4940
|
+
workspaceId: null
|
|
4941
|
+
};
|
|
4942
|
+
const projectId = params.get("project_id") ?? void 0;
|
|
4943
|
+
if (caller.isAdmin) return projectId;
|
|
4944
|
+
if (!projectId) {
|
|
4945
|
+
this.json(
|
|
4946
|
+
res,
|
|
4947
|
+
{ error: "project_id query param is required for workspace-scoped callers" },
|
|
4948
|
+
400
|
|
4949
|
+
);
|
|
4950
|
+
return false;
|
|
4951
|
+
}
|
|
4952
|
+
const projectWorkspaceId = this.pmStore?.getWorkspaceIdByRuntimeProjectId(projectId) ?? null;
|
|
4953
|
+
if (!projectWorkspaceId) {
|
|
4954
|
+
this.json(res, { error: "Forbidden: project is not registered with any workspace" }, 403);
|
|
4955
|
+
return false;
|
|
4956
|
+
}
|
|
4957
|
+
if (projectWorkspaceId !== caller.workspaceId) {
|
|
4958
|
+
this.json(res, { error: "Forbidden: project belongs to a different workspace" }, 403);
|
|
4959
|
+
return false;
|
|
4960
|
+
}
|
|
4961
|
+
return projectId;
|
|
4962
|
+
}
|
|
3680
4963
|
async start(options = {}) {
|
|
3681
4964
|
const basePort = options.port ?? parseInt(process.env.RUNTIMESCOPE_HTTP_PORT ?? "6768", 10);
|
|
3682
4965
|
const host = options.host ?? "127.0.0.1";
|
|
@@ -3719,10 +5002,12 @@ var HttpServer = class {
|
|
|
3719
5002
|
this.store.onEvent(this.eventListener);
|
|
3720
5003
|
server.on("listening", () => {
|
|
3721
5004
|
this.server = server;
|
|
3722
|
-
|
|
5005
|
+
const addr = server.address();
|
|
5006
|
+
const boundPort = addr && typeof addr === "object" && typeof addr.port === "number" ? addr.port : port;
|
|
5007
|
+
this.activePort = boundPort;
|
|
3723
5008
|
this.startedAt = Date.now();
|
|
3724
5009
|
const proto = tls ? "https" : "http";
|
|
3725
|
-
console.error(`[RuntimeScope] HTTP API listening on ${proto}://${host}:${
|
|
5010
|
+
console.error(`[RuntimeScope] HTTP API listening on ${proto}://${host}:${boundPort}`);
|
|
3726
5011
|
resolve2();
|
|
3727
5012
|
});
|
|
3728
5013
|
server.on("error", (err) => {
|
|
@@ -3807,20 +5092,29 @@ var HttpServer = class {
|
|
|
3807
5092
|
res.end();
|
|
3808
5093
|
return;
|
|
3809
5094
|
}
|
|
3810
|
-
const isPublic = url.pathname === "/api/health" || url.pathname === "/runtimescope.js" || url.pathname === "/snippet";
|
|
3811
|
-
|
|
5095
|
+
const isPublic = url.pathname === "/api/health" || url.pathname === "/readyz" || url.pathname === "/metrics" || url.pathname === "/runtimescope.js" || url.pathname === "/snippet";
|
|
5096
|
+
const workspaceKeysExist = !!this.pmStore?.hasActiveApiKeys?.();
|
|
5097
|
+
const authActive = !!this.authManager?.isEnabled() || workspaceKeysExist;
|
|
5098
|
+
const caller = {
|
|
5099
|
+
isAdmin: !authActive,
|
|
5100
|
+
workspaceId: null
|
|
5101
|
+
};
|
|
5102
|
+
if (!isPublic && authActive) {
|
|
3812
5103
|
const token = AuthManager.extractBearer(req.headers.authorization);
|
|
3813
|
-
const isGlobal = this.authManager
|
|
3814
|
-
const
|
|
3815
|
-
if (!isGlobal && !
|
|
5104
|
+
const isGlobal = !!(token && this.authManager?.validate(token));
|
|
5105
|
+
const workspace = token ? this.pmStore?.getWorkspaceByApiKey(token) : null;
|
|
5106
|
+
if (!isGlobal && !workspace) {
|
|
3816
5107
|
this.json(res, { error: "Unauthorized", code: "AUTH_FAILED" }, 401);
|
|
3817
5108
|
return;
|
|
3818
5109
|
}
|
|
5110
|
+
caller.isAdmin = isGlobal;
|
|
5111
|
+
caller.workspaceId = workspace?.id ?? null;
|
|
3819
5112
|
}
|
|
5113
|
+
req._rsCaller = caller;
|
|
3820
5114
|
if (req.method === "GET" && url.pathname === "/runtimescope.js") {
|
|
3821
5115
|
const sdkPath = this.resolveSdkPath();
|
|
3822
5116
|
if (sdkPath) {
|
|
3823
|
-
const bundle =
|
|
5117
|
+
const bundle = readFileSync5(sdkPath, "utf-8");
|
|
3824
5118
|
res.writeHead(200, {
|
|
3825
5119
|
"Content-Type": "application/javascript",
|
|
3826
5120
|
"Cache-Control": "no-cache"
|
|
@@ -3944,7 +5238,7 @@ function numParam(params, key) {
|
|
|
3944
5238
|
|
|
3945
5239
|
// src/pm/pm-store.ts
|
|
3946
5240
|
import Database from "better-sqlite3";
|
|
3947
|
-
import { randomBytes as
|
|
5241
|
+
import { randomBytes as randomBytes4, createHash as createHash2, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
3948
5242
|
var PmStore = class {
|
|
3949
5243
|
db;
|
|
3950
5244
|
constructor(options) {
|
|
@@ -4124,6 +5418,23 @@ var PmStore = class {
|
|
|
4124
5418
|
this.db.exec("ALTER TABLE pm_projects ADD COLUMN workspace_id TEXT DEFAULT NULL");
|
|
4125
5419
|
} catch {
|
|
4126
5420
|
}
|
|
5421
|
+
let apiKeyColumnsAdded = false;
|
|
5422
|
+
try {
|
|
5423
|
+
this.db.exec("ALTER TABLE pm_api_keys ADD COLUMN key_prefix TEXT");
|
|
5424
|
+
apiKeyColumnsAdded = true;
|
|
5425
|
+
} catch {
|
|
5426
|
+
}
|
|
5427
|
+
try {
|
|
5428
|
+
this.db.exec("ALTER TABLE pm_api_keys ADD COLUMN key_last4 TEXT");
|
|
5429
|
+
} catch {
|
|
5430
|
+
}
|
|
5431
|
+
try {
|
|
5432
|
+
this.db.exec("CREATE INDEX IF NOT EXISTS idx_api_keys_prefix ON pm_api_keys(key_prefix)");
|
|
5433
|
+
} catch {
|
|
5434
|
+
}
|
|
5435
|
+
if (apiKeyColumnsAdded) {
|
|
5436
|
+
this.db.prepare("UPDATE pm_api_keys SET revoked_at = ? WHERE revoked_at IS NULL AND key_prefix IS NULL").run(Date.now());
|
|
5437
|
+
}
|
|
4127
5438
|
this.ensureDefaultWorkspace();
|
|
4128
5439
|
}
|
|
4129
5440
|
/**
|
|
@@ -4149,8 +5460,11 @@ var PmStore = class {
|
|
|
4149
5460
|
// Projects
|
|
4150
5461
|
// ============================================================
|
|
4151
5462
|
upsertProject(project) {
|
|
4152
|
-
|
|
4153
|
-
|
|
5463
|
+
let resolvedWorkspaceId = project.workspaceId ?? null;
|
|
5464
|
+
if (!resolvedWorkspaceId) {
|
|
5465
|
+
const row = this.db.prepare("SELECT id FROM pm_workspaces WHERE is_default = 1 LIMIT 1").get();
|
|
5466
|
+
resolvedWorkspaceId = row?.id ?? null;
|
|
5467
|
+
}
|
|
4154
5468
|
this.db.prepare(`
|
|
4155
5469
|
INSERT INTO pm_projects (id, workspace_id, name, path, claude_project_key, runtimescope_project,
|
|
4156
5470
|
phase, management_authorized, probable_to_complete, project_status,
|
|
@@ -4387,13 +5701,16 @@ var PmStore = class {
|
|
|
4387
5701
|
createApiKey(workspaceId, label, expiresAt) {
|
|
4388
5702
|
const ws = this.getWorkspace(workspaceId);
|
|
4389
5703
|
if (!ws) throw new Error(`Workspace ${workspaceId} does not exist`);
|
|
4390
|
-
const
|
|
5704
|
+
const raw = `tk_${randomBytes4(24).toString("hex")}`;
|
|
5705
|
+
const hash = hashApiKey(raw);
|
|
5706
|
+
const prefix = raw.slice(0, 11);
|
|
5707
|
+
const last4 = raw.slice(-4);
|
|
4391
5708
|
const now = Date.now();
|
|
4392
5709
|
this.db.prepare(
|
|
4393
|
-
`INSERT INTO pm_api_keys (key, workspace_id, label, created_at, expires_at)
|
|
4394
|
-
VALUES (?, ?, ?, ?, ?)`
|
|
4395
|
-
).run(
|
|
4396
|
-
return { key, workspaceId, label, createdAt: now, expiresAt };
|
|
5710
|
+
`INSERT INTO pm_api_keys (key, workspace_id, label, created_at, expires_at, key_prefix, key_last4)
|
|
5711
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
|
5712
|
+
).run(hash, workspaceId, label, now, expiresAt ?? null, prefix, last4);
|
|
5713
|
+
return { key: raw, keyPrefix: prefix, keyLast4: last4, workspaceId, label, createdAt: now, expiresAt };
|
|
4397
5714
|
}
|
|
4398
5715
|
listApiKeys(workspaceId) {
|
|
4399
5716
|
const rows = this.db.prepare(
|
|
@@ -4401,19 +5718,49 @@ var PmStore = class {
|
|
|
4401
5718
|
).all(workspaceId);
|
|
4402
5719
|
return rows.map(mapApiKeyRow);
|
|
4403
5720
|
}
|
|
4404
|
-
|
|
4405
|
-
|
|
5721
|
+
/** Revoke by the public prefix (what the dashboard shows), not the raw secret. */
|
|
5722
|
+
revokeApiKey(prefix) {
|
|
5723
|
+
this.db.prepare("UPDATE pm_api_keys SET revoked_at = ? WHERE key_prefix = ?").run(Date.now(), prefix);
|
|
5724
|
+
}
|
|
5725
|
+
/** Look up an API key's workspace by its public prefix. Used for per-workspace authorization on the revoke route. */
|
|
5726
|
+
findApiKeyByPrefix(prefix) {
|
|
5727
|
+
const row = this.db.prepare(`SELECT * FROM pm_api_keys WHERE key_prefix = ? AND revoked_at IS NULL LIMIT 1`).get(prefix);
|
|
5728
|
+
return row ? mapApiKeyRow(row) : null;
|
|
5729
|
+
}
|
|
5730
|
+
/**
|
|
5731
|
+
* True if any non-revoked workspace API key exists. The HTTP auth gate uses
|
|
5732
|
+
* this to enforce auth whenever workspace keys are in play, even when the
|
|
5733
|
+
* global AuthManager has no keys configured. Without this check, a user who
|
|
5734
|
+
* creates a workspace key expecting it to gate access gets no auth at all
|
|
5735
|
+
* (the H5 bypass).
|
|
5736
|
+
*/
|
|
5737
|
+
hasActiveApiKeys() {
|
|
5738
|
+
const row = this.db.prepare("SELECT 1 AS n FROM pm_api_keys WHERE revoked_at IS NULL LIMIT 1").get();
|
|
5739
|
+
return !!row;
|
|
4406
5740
|
}
|
|
4407
|
-
|
|
5741
|
+
/**
|
|
5742
|
+
* Given a runtime projectId (proj_xxx), return the workspaceId it belongs to,
|
|
5743
|
+
* or null if the projectId isn't registered with any PM project. Used to
|
|
5744
|
+
* enforce per-workspace isolation on event-read routes — the caller can only
|
|
5745
|
+
* query events from runtime projects owned by their workspace.
|
|
5746
|
+
*/
|
|
5747
|
+
getWorkspaceIdByRuntimeProjectId(runtimeProjectId) {
|
|
5748
|
+
if (!runtimeProjectId) return null;
|
|
5749
|
+
const row = this.db.prepare("SELECT workspace_id FROM pm_projects WHERE runtime_project_id = ? LIMIT 1").get(runtimeProjectId);
|
|
5750
|
+
return row?.workspace_id ?? null;
|
|
5751
|
+
}
|
|
5752
|
+
getWorkspaceByApiKey(rawKey) {
|
|
5753
|
+
if (!rawKey || typeof rawKey !== "string") return null;
|
|
5754
|
+
const hash = hashApiKey(rawKey);
|
|
4408
5755
|
const row = this.db.prepare(
|
|
4409
5756
|
`SELECT w.* FROM pm_api_keys k
|
|
4410
5757
|
JOIN pm_workspaces w ON w.id = k.workspace_id
|
|
4411
5758
|
WHERE k.key = ? AND k.revoked_at IS NULL
|
|
4412
5759
|
AND (k.expires_at IS NULL OR k.expires_at > ?)`
|
|
4413
|
-
).get(
|
|
5760
|
+
).get(hash, Date.now());
|
|
4414
5761
|
if (!row) return null;
|
|
4415
5762
|
try {
|
|
4416
|
-
this.db.prepare("UPDATE pm_api_keys SET last_used_at = ? WHERE key = ?").run(Date.now(),
|
|
5763
|
+
this.db.prepare("UPDATE pm_api_keys SET last_used_at = ? WHERE key = ?").run(Date.now(), hash);
|
|
4417
5764
|
} catch {
|
|
4418
5765
|
}
|
|
4419
5766
|
return mapWorkspaceRow(row);
|
|
@@ -5212,8 +6559,11 @@ var PmStore = class {
|
|
|
5212
6559
|
this.db.close();
|
|
5213
6560
|
}
|
|
5214
6561
|
};
|
|
6562
|
+
function hashApiKey(raw) {
|
|
6563
|
+
return createHash2("sha256").update(raw, "utf8").digest("hex");
|
|
6564
|
+
}
|
|
5215
6565
|
function generateWorkspaceId() {
|
|
5216
|
-
return `ws_${
|
|
6566
|
+
return `ws_${randomBytes4(8).toString("hex")}`;
|
|
5217
6567
|
}
|
|
5218
6568
|
function mapWorkspaceRow(row) {
|
|
5219
6569
|
return {
|
|
@@ -5228,7 +6578,11 @@ function mapWorkspaceRow(row) {
|
|
|
5228
6578
|
}
|
|
5229
6579
|
function mapApiKeyRow(row) {
|
|
5230
6580
|
return {
|
|
5231
|
-
|
|
6581
|
+
// Never return the stored value (it's the hash) — list responses expose
|
|
6582
|
+
// only prefix + last4 for display. The raw token appears once, at create.
|
|
6583
|
+
key: "",
|
|
6584
|
+
keyPrefix: row.key_prefix ?? "",
|
|
6585
|
+
keyLast4: row.key_last4 ?? "",
|
|
5232
6586
|
workspaceId: row.workspace_id,
|
|
5233
6587
|
label: row.label,
|
|
5234
6588
|
createdAt: row.created_at,
|
|
@@ -5443,13 +6797,13 @@ async function parseSessionJsonl(jsonlPath, sessionId, projectId) {
|
|
|
5443
6797
|
|
|
5444
6798
|
// src/pm/project-discovery.ts
|
|
5445
6799
|
import { readdir as readdir2, readFile as readFile2, stat as stat2 } from "fs/promises";
|
|
5446
|
-
import { join as
|
|
5447
|
-
import { existsSync as
|
|
6800
|
+
import { join as join7, basename as basename2 } from "path";
|
|
6801
|
+
import { existsSync as existsSync7 } from "fs";
|
|
5448
6802
|
import { homedir as homedir3 } from "os";
|
|
5449
6803
|
var LOG_PREFIX = "[RuntimeScope PM]";
|
|
5450
6804
|
async function detectSdkInstalled(projectPath) {
|
|
5451
6805
|
try {
|
|
5452
|
-
const pkgPath =
|
|
6806
|
+
const pkgPath = join7(projectPath, "package.json");
|
|
5453
6807
|
const pkg = JSON.parse(await readFile2(pkgPath, "utf-8"));
|
|
5454
6808
|
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
5455
6809
|
if ("@runtimescope/sdk" in allDeps || "@runtimescope/server-sdk" in allDeps) {
|
|
@@ -5459,13 +6813,13 @@ async function detectSdkInstalled(projectPath) {
|
|
|
5459
6813
|
const workspaces = Array.isArray(pkg.workspaces) ? pkg.workspaces : pkg.workspaces.packages ?? [];
|
|
5460
6814
|
for (const ws of workspaces) {
|
|
5461
6815
|
const wsBase = ws.replace(/\/?\*$/, "");
|
|
5462
|
-
const wsDir =
|
|
6816
|
+
const wsDir = join7(projectPath, wsBase);
|
|
5463
6817
|
try {
|
|
5464
6818
|
const entries = await readdir2(wsDir, { withFileTypes: true });
|
|
5465
6819
|
for (const entry of entries) {
|
|
5466
6820
|
if (!entry.isDirectory()) continue;
|
|
5467
6821
|
try {
|
|
5468
|
-
const wsPkg = JSON.parse(await readFile2(
|
|
6822
|
+
const wsPkg = JSON.parse(await readFile2(join7(wsDir, entry.name, "package.json"), "utf-8"));
|
|
5469
6823
|
const wsDeps = { ...wsPkg.dependencies, ...wsPkg.devDependencies };
|
|
5470
6824
|
if ("@runtimescope/sdk" in wsDeps || "@runtimescope/server-sdk" in wsDeps) {
|
|
5471
6825
|
return true;
|
|
@@ -5480,7 +6834,7 @@ async function detectSdkInstalled(projectPath) {
|
|
|
5480
6834
|
} catch {
|
|
5481
6835
|
}
|
|
5482
6836
|
try {
|
|
5483
|
-
await stat2(
|
|
6837
|
+
await stat2(join7(projectPath, "node_modules", "@runtimescope"));
|
|
5484
6838
|
return true;
|
|
5485
6839
|
} catch {
|
|
5486
6840
|
return false;
|
|
@@ -5511,7 +6865,7 @@ function slugifyPath(fsPath) {
|
|
|
5511
6865
|
}
|
|
5512
6866
|
function decodeClaudeKey(key) {
|
|
5513
6867
|
const naive = "/" + key.slice(1).replace(/-/g, "/");
|
|
5514
|
-
if (
|
|
6868
|
+
if (existsSync7(naive)) return naive;
|
|
5515
6869
|
const parts = key.slice(1).split("-");
|
|
5516
6870
|
return resolvePathSegments(parts);
|
|
5517
6871
|
}
|
|
@@ -5519,16 +6873,16 @@ function resolvePathSegments(parts) {
|
|
|
5519
6873
|
if (parts.length === 0) return null;
|
|
5520
6874
|
function tryResolve(prefix, remaining) {
|
|
5521
6875
|
if (remaining.length === 0) {
|
|
5522
|
-
return
|
|
6876
|
+
return existsSync7(prefix) ? prefix : null;
|
|
5523
6877
|
}
|
|
5524
6878
|
for (let count = remaining.length; count >= 1; count--) {
|
|
5525
6879
|
const segment = remaining.slice(0, count).join("-");
|
|
5526
|
-
const candidate =
|
|
6880
|
+
const candidate = join7(prefix, segment);
|
|
5527
6881
|
if (count === remaining.length) {
|
|
5528
|
-
if (
|
|
6882
|
+
if (existsSync7(candidate)) return candidate;
|
|
5529
6883
|
} else {
|
|
5530
6884
|
try {
|
|
5531
|
-
if (
|
|
6885
|
+
if (existsSync7(candidate)) {
|
|
5532
6886
|
const result = tryResolve(candidate, remaining.slice(count));
|
|
5533
6887
|
if (result) return result;
|
|
5534
6888
|
}
|
|
@@ -5551,7 +6905,7 @@ var ProjectDiscovery = class {
|
|
|
5551
6905
|
constructor(pmStore, projectManager, claudeBaseDir) {
|
|
5552
6906
|
this.pmStore = pmStore;
|
|
5553
6907
|
this.projectManager = projectManager;
|
|
5554
|
-
this.claudeBaseDir = claudeBaseDir ??
|
|
6908
|
+
this.claudeBaseDir = claudeBaseDir ?? join7(homedir3(), ".claude");
|
|
5555
6909
|
}
|
|
5556
6910
|
claudeBaseDir;
|
|
5557
6911
|
/**
|
|
@@ -5584,7 +6938,7 @@ var ProjectDiscovery = class {
|
|
|
5584
6938
|
sessionsUpdated: 0,
|
|
5585
6939
|
errors: []
|
|
5586
6940
|
};
|
|
5587
|
-
const projectsDir =
|
|
6941
|
+
const projectsDir = join7(this.claudeBaseDir, "projects");
|
|
5588
6942
|
try {
|
|
5589
6943
|
await stat2(projectsDir);
|
|
5590
6944
|
} catch {
|
|
@@ -5697,10 +7051,10 @@ var ProjectDiscovery = class {
|
|
|
5697
7051
|
if (!project.claudeProjectKey) {
|
|
5698
7052
|
return 0;
|
|
5699
7053
|
}
|
|
5700
|
-
const projectDir =
|
|
7054
|
+
const projectDir = join7(this.claudeBaseDir, "projects", project.claudeProjectKey);
|
|
5701
7055
|
let sessionsIndexed = 0;
|
|
5702
7056
|
try {
|
|
5703
|
-
const indexPath =
|
|
7057
|
+
const indexPath = join7(projectDir, "sessions-index.json");
|
|
5704
7058
|
let indexEntries = null;
|
|
5705
7059
|
try {
|
|
5706
7060
|
const indexContent = await readFile2(indexPath, "utf-8");
|
|
@@ -5713,7 +7067,7 @@ var ProjectDiscovery = class {
|
|
|
5713
7067
|
for (const jsonlFile of jsonlFiles) {
|
|
5714
7068
|
try {
|
|
5715
7069
|
const sessionId = jsonlFile.replace(".jsonl", "");
|
|
5716
|
-
const jsonlPath =
|
|
7070
|
+
const jsonlPath = join7(projectDir, jsonlFile);
|
|
5717
7071
|
const fileStat = await stat2(jsonlPath);
|
|
5718
7072
|
const fileSize = fileStat.size;
|
|
5719
7073
|
const existingSession = await this.pmStore.getSession(sessionId);
|
|
@@ -5748,7 +7102,7 @@ var ProjectDiscovery = class {
|
|
|
5748
7102
|
* Process a single Claude project directory key.
|
|
5749
7103
|
*/
|
|
5750
7104
|
async processClaudeProject(key, result) {
|
|
5751
|
-
const projectDir =
|
|
7105
|
+
const projectDir = join7(this.claudeBaseDir, "projects", key);
|
|
5752
7106
|
let fsPath = decodeClaudeKey(key);
|
|
5753
7107
|
if (!fsPath) {
|
|
5754
7108
|
fsPath = await this.resolvePathFromIndex(projectDir);
|
|
@@ -5802,7 +7156,7 @@ var ProjectDiscovery = class {
|
|
|
5802
7156
|
*/
|
|
5803
7157
|
async resolvePathFromIndex(projectDir) {
|
|
5804
7158
|
try {
|
|
5805
|
-
const indexPath =
|
|
7159
|
+
const indexPath = join7(projectDir, "sessions-index.json");
|
|
5806
7160
|
const content = await readFile2(indexPath, "utf-8");
|
|
5807
7161
|
const index = JSON.parse(content);
|
|
5808
7162
|
const entry = index.entries?.find((e) => e.projectPath);
|
|
@@ -5817,11 +7171,11 @@ var ProjectDiscovery = class {
|
|
|
5817
7171
|
*/
|
|
5818
7172
|
async indexSessionsForClaudeProject(projectId, claudeKey) {
|
|
5819
7173
|
const counts = { discovered: 0, updated: 0 };
|
|
5820
|
-
const projectDir =
|
|
7174
|
+
const projectDir = join7(this.claudeBaseDir, "projects", claudeKey);
|
|
5821
7175
|
try {
|
|
5822
7176
|
let indexEntries = null;
|
|
5823
7177
|
try {
|
|
5824
|
-
const indexPath =
|
|
7178
|
+
const indexPath = join7(projectDir, "sessions-index.json");
|
|
5825
7179
|
const indexContent = await readFile2(indexPath, "utf-8");
|
|
5826
7180
|
const index = JSON.parse(indexContent);
|
|
5827
7181
|
indexEntries = index.entries ?? [];
|
|
@@ -5832,7 +7186,7 @@ var ProjectDiscovery = class {
|
|
|
5832
7186
|
for (const jsonlFile of jsonlFiles) {
|
|
5833
7187
|
try {
|
|
5834
7188
|
const sessionId = jsonlFile.replace(".jsonl", "");
|
|
5835
|
-
const jsonlPath =
|
|
7189
|
+
const jsonlPath = join7(projectDir, jsonlFile);
|
|
5836
7190
|
const fileStat = await stat2(jsonlPath);
|
|
5837
7191
|
const fileSize = fileStat.size;
|
|
5838
7192
|
const existingSession = await this.pmStore.getSession(sessionId);
|
|
@@ -6017,6 +7371,15 @@ export {
|
|
|
6017
7371
|
getOrCreateProjectId,
|
|
6018
7372
|
SqliteStore,
|
|
6019
7373
|
isSqliteAvailable,
|
|
7374
|
+
Wal,
|
|
7375
|
+
Counter,
|
|
7376
|
+
Gauge,
|
|
7377
|
+
MetricsRegistry,
|
|
7378
|
+
OtelExporter,
|
|
7379
|
+
traceIdFromSession,
|
|
7380
|
+
randomSpanId,
|
|
7381
|
+
parseOtelHeaders,
|
|
7382
|
+
otelOptionsFromEnv,
|
|
6020
7383
|
SessionRateLimiter,
|
|
6021
7384
|
loadTlsOptions,
|
|
6022
7385
|
resolveTlsConfig,
|
|
@@ -6045,4 +7408,4 @@ export {
|
|
|
6045
7408
|
parseSessionJsonl,
|
|
6046
7409
|
ProjectDiscovery
|
|
6047
7410
|
};
|
|
6048
|
-
//# sourceMappingURL=chunk-
|
|
7411
|
+
//# sourceMappingURL=chunk-M2V4EFJY.js.map
|