@oh-my-pi/omp-stats 18.2.3 → 18.2.5
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/CHANGELOG.md +6 -0
- package/dist/types/shared-types.d.ts +6 -0
- package/dist/types/trace.d.ts +9 -3
- package/package.json +4 -4
- package/src/db.ts +3 -0
- package/src/server.ts +25 -3
- package/src/shared-types.ts +6 -0
- package/src/trace.ts +122 -1
package/CHANGELOG.md
CHANGED
|
@@ -549,6 +549,12 @@ export interface SessionTrace {
|
|
|
549
549
|
endedAt: number;
|
|
550
550
|
/** Root transcript mtime; doubles as the ETag. */
|
|
551
551
|
mtimeMs: number;
|
|
552
|
+
/**
|
|
553
|
+
* Conditional-request fingerprint `"<rootMs>:<childHighMs>"` (see
|
|
554
|
+
* traceFingerprintForEtag): the server ETag is built from this, so a
|
|
555
|
+
* subagent-only append changes the ETag even when the root is untouched.
|
|
556
|
+
*/
|
|
557
|
+
etag: string;
|
|
552
558
|
/** DFS order, main first. */
|
|
553
559
|
tracks: TraceTrack[];
|
|
554
560
|
summary: TraceSummary;
|
package/dist/types/trace.d.ts
CHANGED
|
@@ -19,10 +19,16 @@ export declare class TracePathError extends Error {
|
|
|
19
19
|
*/
|
|
20
20
|
export declare const TRACE_ETAG_VERSION = 3;
|
|
21
21
|
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
22
|
+
* Freshness fingerprint for the trace ETag pre-check: root mtime plus the
|
|
23
|
+
* child-transcript set fingerprint (readdir+stat walk, no parse). Returns
|
|
24
|
+
* undefined when the path is rejected or missing (the caller falls through
|
|
25
|
+
* to the full build, which maps those to 400/404).
|
|
25
26
|
*/
|
|
27
|
+
export declare function traceFingerprintForEtag(fileParam: string): Promise<string | undefined>;
|
|
28
|
+
export declare function traceMemoForTests(): {
|
|
29
|
+
file: string;
|
|
30
|
+
mtimeMs: number;
|
|
31
|
+
} | undefined;
|
|
26
32
|
export declare function buildSessionTrace(fileParam: string): Promise<SessionTrace>;
|
|
27
33
|
/** Fetch one full journal entry by id from a transcript, for the span drawer. */
|
|
28
34
|
export declare function getTraceEntry(fileParam: string, entryId: string): Promise<SessionEntry | null>;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/omp-stats",
|
|
4
|
-
"version": "18.2.
|
|
4
|
+
"version": "18.2.5",
|
|
5
5
|
"description": "Local observability dashboard for pi AI usage statistics",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Stencil Labs, Inc.",
|
|
@@ -39,9 +39,9 @@
|
|
|
39
39
|
"fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@oh-my-pi/pi-ai": "18.2.
|
|
43
|
-
"@oh-my-pi/pi-catalog": "18.2.
|
|
44
|
-
"@oh-my-pi/pi-utils": "18.2.
|
|
42
|
+
"@oh-my-pi/pi-ai": "18.2.5",
|
|
43
|
+
"@oh-my-pi/pi-catalog": "18.2.5",
|
|
44
|
+
"@oh-my-pi/pi-utils": "18.2.5",
|
|
45
45
|
"@tailwindcss/node": "^4.3.2",
|
|
46
46
|
"chart.js": "^4.5.1",
|
|
47
47
|
"lucide-react": "^1.24.0",
|
package/src/db.ts
CHANGED
|
@@ -215,6 +215,7 @@ export async function initDb(): Promise<Database> {
|
|
|
215
215
|
);
|
|
216
216
|
|
|
217
217
|
CREATE INDEX IF NOT EXISTS idx_messages_timestamp ON messages(timestamp);
|
|
218
|
+
CREATE INDEX IF NOT EXISTS idx_messages_entry_timestamp ON messages(entry_id, timestamp);
|
|
218
219
|
CREATE INDEX IF NOT EXISTS idx_messages_model ON messages(model);
|
|
219
220
|
CREATE INDEX IF NOT EXISTS idx_messages_folder ON messages(folder);
|
|
220
221
|
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_file);
|
|
@@ -249,6 +250,7 @@ export async function initDb(): Promise<Database> {
|
|
|
249
250
|
);
|
|
250
251
|
|
|
251
252
|
CREATE INDEX IF NOT EXISTS idx_user_messages_timestamp ON user_messages(timestamp);
|
|
253
|
+
CREATE INDEX IF NOT EXISTS idx_user_messages_entry_timestamp ON user_messages(entry_id, timestamp);
|
|
252
254
|
CREATE INDEX IF NOT EXISTS idx_user_messages_timestamp_model ON user_messages(timestamp, model, provider);
|
|
253
255
|
|
|
254
256
|
CREATE TABLE IF NOT EXISTS tool_calls (
|
|
@@ -270,6 +272,7 @@ export async function initDb(): Promise<Database> {
|
|
|
270
272
|
);
|
|
271
273
|
|
|
272
274
|
CREATE INDEX IF NOT EXISTS idx_tool_calls_timestamp ON tool_calls(timestamp);
|
|
275
|
+
CREATE INDEX IF NOT EXISTS idx_tool_calls_entry_timestamp ON tool_calls(entry_id, timestamp);
|
|
273
276
|
CREATE INDEX IF NOT EXISTS idx_tool_calls_tool_timestamp ON tool_calls(tool_name, timestamp);
|
|
274
277
|
|
|
275
278
|
CREATE TABLE IF NOT EXISTS meta (
|
package/src/server.ts
CHANGED
|
@@ -30,7 +30,14 @@ import {
|
|
|
30
30
|
STATS_DASHBOARD_HOSTNAME_HEADER,
|
|
31
31
|
STATS_DASHBOARD_SECURITY_VERSION,
|
|
32
32
|
} from "./port-conflict";
|
|
33
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
buildSessionTrace,
|
|
35
|
+
getTraceEntry,
|
|
36
|
+
listSessionSummaries,
|
|
37
|
+
TRACE_ETAG_VERSION,
|
|
38
|
+
traceFingerprintForEtag,
|
|
39
|
+
TracePathError,
|
|
40
|
+
} from "./trace";
|
|
34
41
|
|
|
35
42
|
const EMBEDDED_CLIENT_ARCHIVE = decodeEmbeddedClientArchive(embeddedClientArchiveTxt);
|
|
36
43
|
|
|
@@ -294,9 +301,24 @@ export async function handleApi(req: Request): Promise<Response> {
|
|
|
294
301
|
const file = url.searchParams.get("file");
|
|
295
302
|
if (!file) return Response.json({ error: "file required" }, { status: 400 });
|
|
296
303
|
try {
|
|
304
|
+
// ETag-first: compare the client's etag against the
|
|
305
|
+
// root+child fingerprint WITHOUT building the trace. A matching
|
|
306
|
+
// (unchanged) poll returns 304 after stats only; only a changed
|
|
307
|
+
// tree pays the full re-read/re-parse/rebuild in
|
|
308
|
+
// buildSessionTrace (itself memoized for non-conditional polls).
|
|
309
|
+
// The fingerprint covers child transcripts, so a subagent-only
|
|
310
|
+
// append changes the ETag and never 304s stale.
|
|
311
|
+
const clientEtag = req.headers.get("if-none-match");
|
|
312
|
+
if (clientEtag) {
|
|
313
|
+
const fingerprint = await traceFingerprintForEtag(file);
|
|
314
|
+
if (fingerprint !== undefined) {
|
|
315
|
+
const etag = `"${TRACE_ETAG_VERSION}:${fingerprint}"`;
|
|
316
|
+
if (clientEtag === etag) return new Response(null, { status: 304 });
|
|
317
|
+
}
|
|
318
|
+
}
|
|
297
319
|
const trace = await buildSessionTrace(file);
|
|
298
|
-
const etag = `"${TRACE_ETAG_VERSION}:${trace.
|
|
299
|
-
if (
|
|
320
|
+
const etag = `"${TRACE_ETAG_VERSION}:${trace.etag}"`;
|
|
321
|
+
if (clientEtag === etag) return new Response(null, { status: 304 });
|
|
300
322
|
return Response.json(trace, { headers: { ETag: etag } });
|
|
301
323
|
} catch (err) {
|
|
302
324
|
if (err instanceof TracePathError) return Response.json({ error: err.message }, { status: 400 });
|
package/src/shared-types.ts
CHANGED
|
@@ -586,6 +586,12 @@ export interface SessionTrace {
|
|
|
586
586
|
endedAt: number;
|
|
587
587
|
/** Root transcript mtime; doubles as the ETag. */
|
|
588
588
|
mtimeMs: number;
|
|
589
|
+
/**
|
|
590
|
+
* Conditional-request fingerprint `"<rootMs>:<childHighMs>"` (see
|
|
591
|
+
* traceFingerprintForEtag): the server ETag is built from this, so a
|
|
592
|
+
* subagent-only append changes the ETag even when the root is untouched.
|
|
593
|
+
*/
|
|
594
|
+
etag: string;
|
|
589
595
|
/** DFS order, main first. */
|
|
590
596
|
tracks: TraceTrack[];
|
|
591
597
|
summary: TraceSummary;
|
package/src/trace.ts
CHANGED
|
@@ -741,9 +741,92 @@ async function buildTrackTree(
|
|
|
741
741
|
* Throws {@link TracePathError} for paths outside the sessions root; ENOENT
|
|
742
742
|
* passes through for the caller's 404 mapping.
|
|
743
743
|
*/
|
|
744
|
+
// Single-entry memo: the dashboard polls one open trace every 15s. Keyed on
|
|
745
|
+
// the root file's mtime PLUS a fingerprint of the child-transcript SET under
|
|
746
|
+
// the root's artifacts tree (one cheap readdir+stat walk, no parse). A
|
|
747
|
+
// high-water mark alone is blind to deletions and to older-mtime
|
|
748
|
+
// replacements, so the fingerprint encodes every child's (name, mtimeMs)
|
|
749
|
+
// pair: any append, delete, rename, or content swap changes the key and the
|
|
750
|
+
// memo misses. Membership diffs are bounded (depth + file count caps) so a
|
|
751
|
+
// pathological artifacts tree cannot blow the key.
|
|
752
|
+
interface TraceMemoEntry {
|
|
753
|
+
mtimeMs: number;
|
|
754
|
+
childFingerprint: string;
|
|
755
|
+
trace: SessionTrace;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
let traceMemo: { file: string; entry: TraceMemoEntry } | undefined;
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* Fingerprint of the child-transcript set: per-directory file COUNT plus
|
|
762
|
+
* sorted `name@mtimeMs` pairs, mirroring buildTrackTree's discovery (stats
|
|
763
|
+
* only, no parse). The count is load-bearing: without it, children past a
|
|
764
|
+
* truncation cap are invisible and appends to them never invalidate the key.
|
|
765
|
+
* Best-effort: a raced gc deletion is recorded as `name@gone` (which itself
|
|
766
|
+
* changes the fingerprint and misses the memo — the safe direction).
|
|
767
|
+
*/
|
|
768
|
+
async function childTranscriptsFingerprint(rootFile: string, depth = 0): Promise<string> {
|
|
769
|
+
if (depth > MAX_TRACK_DEPTH) return "";
|
|
770
|
+
let names: string[] = [];
|
|
771
|
+
try {
|
|
772
|
+
names = await fs.readdir(transcriptStem(rootFile));
|
|
773
|
+
} catch {
|
|
774
|
+
return "";
|
|
775
|
+
}
|
|
776
|
+
names.sort();
|
|
777
|
+
const transcriptNames = names.filter(name => name.endsWith(".jsonl") || name.endsWith(".jsonl.gz"));
|
|
778
|
+
const parts: string[] = [`n=${transcriptNames.length}`];
|
|
779
|
+
for (const name of transcriptNames) {
|
|
780
|
+
const childFile = path.join(transcriptStem(rootFile), name);
|
|
781
|
+
try {
|
|
782
|
+
const stat = await fs.stat(childFile);
|
|
783
|
+
parts.push(`${name}@${stat.mtimeMs}`);
|
|
784
|
+
// Recurse like the builder (nested subagents), without parsing.
|
|
785
|
+
const nested = await childTranscriptsFingerprint(childFile, depth + 1);
|
|
786
|
+
if (nested) parts.push(`${name}/{${nested}}`);
|
|
787
|
+
} catch {
|
|
788
|
+
parts.push(`${name}@gone`);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
return parts.join(",");
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* Freshness fingerprint for the trace ETag pre-check: root mtime plus the
|
|
796
|
+
* child-transcript set fingerprint (readdir+stat walk, no parse). Returns
|
|
797
|
+
* undefined when the path is rejected or missing (the caller falls through
|
|
798
|
+
* to the full build, which maps those to 400/404).
|
|
799
|
+
*/
|
|
800
|
+
export async function traceFingerprintForEtag(fileParam: string): Promise<string | undefined> {
|
|
801
|
+
try {
|
|
802
|
+
const resolved = resolveSessionPath(fileParam);
|
|
803
|
+
const rootMs = (await fs.stat(resolved)).mtimeMs;
|
|
804
|
+
const childFp = await childTranscriptsFingerprint(resolved);
|
|
805
|
+
return `${rootMs}:${childFp}`;
|
|
806
|
+
} catch {
|
|
807
|
+
return undefined;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
export function traceMemoForTests(): { file: string; mtimeMs: number } | undefined {
|
|
812
|
+
if (!traceMemo) return undefined;
|
|
813
|
+
return { file: traceMemo.file, mtimeMs: traceMemo.entry.mtimeMs };
|
|
814
|
+
}
|
|
815
|
+
|
|
744
816
|
export async function buildSessionTrace(fileParam: string): Promise<SessionTrace> {
|
|
745
817
|
const resolved = resolveSessionPath(fileParam);
|
|
746
818
|
const rootStat = await fs.stat(resolved);
|
|
819
|
+
const memo = traceMemo?.file === resolved ? traceMemo.entry : undefined;
|
|
820
|
+
// Pre-build child fingerprint on EVERY path (hit check and rebuild
|
|
821
|
+
// alike): the post-build race guard compares against this, so a child
|
|
822
|
+
// append landing mid-parse is detected even on first builds and
|
|
823
|
+
// memo-less rebuilds, where no prior fingerprint exists to compare.
|
|
824
|
+
const preChildFingerprint = await childTranscriptsFingerprint(resolved);
|
|
825
|
+
if (memo && memo.mtimeMs === rootStat.mtimeMs) {
|
|
826
|
+
// Equality, not <=: a LOWER mark (delete/older-mtime replace) is
|
|
827
|
+
// also stale — only an identical child set may reuse the trace.
|
|
828
|
+
if (preChildFingerprint === memo.childFingerprint) return memo.trace;
|
|
829
|
+
}
|
|
747
830
|
|
|
748
831
|
const tracks: TraceTrack[] = [];
|
|
749
832
|
const scans: TrackScan[] = [];
|
|
@@ -823,16 +906,39 @@ export async function buildSessionTrace(fileParam: string): Promise<SessionTrace
|
|
|
823
906
|
toolStats: [...toolStats.values()].sort((a, b) => b.totalMs - a.totalMs),
|
|
824
907
|
};
|
|
825
908
|
|
|
826
|
-
|
|
909
|
+
// Re-collect the fingerprint AFTER the parse: a child append landing
|
|
910
|
+
// between the pre-build check and now would otherwise be baked into
|
|
911
|
+
// neither the trace nor the key.
|
|
912
|
+
const childFingerprint = await childTranscriptsFingerprint(resolved);
|
|
913
|
+
// Root rewritten mid-build: same no-cache rule as the child race below.
|
|
914
|
+
const rootChanged = (await fs.stat(resolved).catch(() => null))?.mtimeMs !== rootStat.mtimeMs;
|
|
915
|
+
const raced = childFingerprint !== preChildFingerprint;
|
|
916
|
+
// Raced builds keep the PRE-build fingerprint as their ETag: the parsed
|
|
917
|
+
// bytes predate the racy write, so claiming the post-write state would
|
|
918
|
+
// 304 stale forever when that write was the child's last. The pre-build
|
|
919
|
+
// key guarantees the next conditional poll misses (filesystem moved on)
|
|
920
|
+
// and rebuilds — at most one stale response per race, never an
|
|
921
|
+
// indefinitely cached one. Unraced builds use the post-build key, which
|
|
922
|
+
// equals the pre-build key by construction.
|
|
923
|
+
const etagFingerprint = raced || rootChanged ? preChildFingerprint : childFingerprint;
|
|
924
|
+
const trace: SessionTrace = {
|
|
827
925
|
file: resolved,
|
|
828
926
|
title: rootScan.title,
|
|
829
927
|
cwd: rootScan.cwd,
|
|
830
928
|
startedAt: start,
|
|
831
929
|
endedAt: end,
|
|
832
930
|
mtimeMs: rootStat.mtimeMs,
|
|
931
|
+
etag: `${rootStat.mtimeMs}:${etagFingerprint}`,
|
|
833
932
|
tracks,
|
|
834
933
|
summary,
|
|
835
934
|
};
|
|
935
|
+
if (!raced && !rootChanged) {
|
|
936
|
+
traceMemo = {
|
|
937
|
+
file: resolved,
|
|
938
|
+
entry: { mtimeMs: rootStat.mtimeMs, childFingerprint, trace },
|
|
939
|
+
};
|
|
940
|
+
}
|
|
941
|
+
return trace;
|
|
836
942
|
}
|
|
837
943
|
|
|
838
944
|
/** Fetch one full journal entry by id from a transcript, for the span drawer. */
|
|
@@ -945,7 +1051,21 @@ function basenameTimestamp(base: string): number | undefined {
|
|
|
945
1051
|
* Traces list include sessions the sync pass hasn't indexed yet (most
|
|
946
1052
|
* importantly the currently running one).
|
|
947
1053
|
*/
|
|
1054
|
+
// Short-TTL memo for the disk sweep behind /api/sessions (30s poll): the
|
|
1055
|
+
// session list changes only on create/edit, so readdir+stat of every file per
|
|
1056
|
+
// poll is pure syscall churn. TTL is deliberately short so a just-created
|
|
1057
|
+
// session appears within seconds.
|
|
1058
|
+
let diskRootsMemo:
|
|
1059
|
+
| { atMs: number; limit: number; roots: Array<{ file: string; mtimeMs: number; startedAt: number }> }
|
|
1060
|
+
| undefined;
|
|
1061
|
+
const DISK_ROOTS_TTL_MS = 5_000;
|
|
1062
|
+
|
|
948
1063
|
async function scanDiskRoots(limit: number): Promise<Array<{ file: string; mtimeMs: number; startedAt: number }>> {
|
|
1064
|
+
const now = Date.now();
|
|
1065
|
+
const memo = diskRootsMemo;
|
|
1066
|
+
if (memo && memo.limit >= limit && now - memo.atMs < DISK_ROOTS_TTL_MS) {
|
|
1067
|
+
return memo.roots.slice(0, limit);
|
|
1068
|
+
}
|
|
949
1069
|
const sessionsDir = getSessionsDir();
|
|
950
1070
|
let projects: string[] = [];
|
|
951
1071
|
try {
|
|
@@ -978,6 +1098,7 @@ async function scanDiskRoots(limit: number): Promise<Array<{ file: string; mtime
|
|
|
978
1098
|
}),
|
|
979
1099
|
);
|
|
980
1100
|
roots.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
1101
|
+
if (roots.length <= 1000) diskRootsMemo = { atMs: Date.now(), limit, roots };
|
|
981
1102
|
return roots.slice(0, limit);
|
|
982
1103
|
}
|
|
983
1104
|
|