@seanxdo/superview 0.1.13
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/README.md +193 -0
- package/README.zh-CN.md +193 -0
- package/core/contextReplay.ts +388 -0
- package/core/cost.ts +125 -0
- package/core/hash.ts +5 -0
- package/core/history.ts +96 -0
- package/core/id.ts +6 -0
- package/core/normalizer.ts +720 -0
- package/core/parser.ts +53 -0
- package/core/redactor.ts +49 -0
- package/core/replay.ts +55 -0
- package/core/timeline.ts +350 -0
- package/core/types.ts +460 -0
- package/dist/ui/assets/index-BUbbOxsU.js +18 -0
- package/dist/ui/assets/index-DafedT5l.css +1 -0
- package/dist/ui/index.html +13 -0
- package/package.json +72 -0
- package/runtime-node/adapters/claude-code.ts +205 -0
- package/runtime-node/adapters/codex.ts +24 -0
- package/runtime-node/adapters/index.ts +18 -0
- package/runtime-node/adapters/opencode.ts +193 -0
- package/runtime-node/adapters/shared.ts +113 -0
- package/runtime-node/cli-ingest.ts +7 -0
- package/runtime-node/cli-start.js +15 -0
- package/runtime-node/cli-start.ts +9 -0
- package/runtime-node/dev-server.ts +6 -0
- package/runtime-node/git-provider.ts +102 -0
- package/runtime-node/history.ts +9 -0
- package/runtime-node/ingest-worker.ts +32 -0
- package/runtime-node/ingest.ts +362 -0
- package/runtime-node/prod-server.ts +24 -0
- package/runtime-node/scanner.ts +13 -0
- package/runtime-node/server.ts +183 -0
- package/storage/database.ts +1016 -0
- package/storage/paths.ts +20 -0
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { AgentLogAdapter, AgentLogSource, AgentProvider, AgentSourceConfig, CodexHistoryPrompt, GitCommitRecord, IngestJob, NormalizedBundle } from "../core/types";
|
|
7
|
+
import { SuperViewDatabase } from "../storage/database";
|
|
8
|
+
import { resolveCodexHome } from "../storage/paths";
|
|
9
|
+
import { adapterForProvider, defaultAdapters } from "./adapters";
|
|
10
|
+
import { getCommits, getRepoRoot } from "./git-provider";
|
|
11
|
+
import { parseCodexHistoryJsonlFile } from "./history";
|
|
12
|
+
|
|
13
|
+
export const INGEST_PROCESSOR_VERSION = "2026-06-14-project-by-provider-v1";
|
|
14
|
+
|
|
15
|
+
export interface IngestStartResult {
|
|
16
|
+
job: IngestJob;
|
|
17
|
+
alreadyRunning: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface IngestStartOptions {
|
|
21
|
+
codexHome?: string;
|
|
22
|
+
sources?: AgentSourceConfig[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface IngestCandidate {
|
|
26
|
+
source: AgentLogSource;
|
|
27
|
+
adapter: AgentLogAdapter;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class IngestService {
|
|
31
|
+
private workersByJobId = new Map<string, ChildProcess>();
|
|
32
|
+
|
|
33
|
+
constructor(private db: SuperViewDatabase) {}
|
|
34
|
+
|
|
35
|
+
start(options: IngestStartOptions | string = {}): IngestStartResult {
|
|
36
|
+
const activeJob = this.db.getActiveIngestJob();
|
|
37
|
+
if (activeJob) {
|
|
38
|
+
return { job: activeJob, alreadyRunning: true };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const ingestOptions = typeof options === "string" ? { codexHome: options } : options;
|
|
42
|
+
const now = new Date().toISOString();
|
|
43
|
+
const job: IngestJob = {
|
|
44
|
+
id: randomUUID(),
|
|
45
|
+
status: "queued",
|
|
46
|
+
phase: "queued",
|
|
47
|
+
startedAt: now,
|
|
48
|
+
finishedAt: null,
|
|
49
|
+
totalFiles: 0,
|
|
50
|
+
processedFiles: 0,
|
|
51
|
+
totalEvents: 0,
|
|
52
|
+
errors: [],
|
|
53
|
+
skippedFiles: 0,
|
|
54
|
+
candidateFiles: 0,
|
|
55
|
+
changedFiles: 0,
|
|
56
|
+
processedBytes: 0,
|
|
57
|
+
totalBytes: 0,
|
|
58
|
+
currentFile: null,
|
|
59
|
+
workerPid: null,
|
|
60
|
+
processorVersion: INGEST_PROCESSOR_VERSION
|
|
61
|
+
};
|
|
62
|
+
this.db.upsertJob(job);
|
|
63
|
+
const worker = this.spawnWorker(job.id, ingestOptions);
|
|
64
|
+
if (worker.pid) {
|
|
65
|
+
job.workerPid = worker.pid;
|
|
66
|
+
this.db.upsertJob(job);
|
|
67
|
+
}
|
|
68
|
+
return { job, alreadyRunning: false };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
getJob(jobId: string) {
|
|
72
|
+
return this.db.getJob(jobId);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private spawnWorker(jobId: string, options: IngestStartOptions) {
|
|
76
|
+
const { command, args } = buildWorkerCommand(jobId, options);
|
|
77
|
+
const worker = spawn(command, args, {
|
|
78
|
+
cwd: process.cwd(),
|
|
79
|
+
env: { ...process.env },
|
|
80
|
+
stdio: ["ignore", "ignore", "pipe"]
|
|
81
|
+
});
|
|
82
|
+
this.workersByJobId.set(jobId, worker);
|
|
83
|
+
|
|
84
|
+
let stderr = "";
|
|
85
|
+
worker.stderr?.on("data", (chunk: Buffer) => {
|
|
86
|
+
stderr += chunk.toString("utf8");
|
|
87
|
+
stderr = stderr.slice(-4000);
|
|
88
|
+
});
|
|
89
|
+
worker.on("error", (error) => {
|
|
90
|
+
this.workersByJobId.delete(jobId);
|
|
91
|
+
this.failActiveJob(jobId, `Ingest worker failed to start: ${error.message}`);
|
|
92
|
+
});
|
|
93
|
+
worker.on("exit", (code, signal) => {
|
|
94
|
+
this.workersByJobId.delete(jobId);
|
|
95
|
+
if (code === 0) return;
|
|
96
|
+
const reason = signal ? `signal ${signal}` : `exit code ${code ?? "unknown"}`;
|
|
97
|
+
const detail = stderr.trim() ? `${reason}: ${stderr.trim()}` : reason;
|
|
98
|
+
this.failActiveJob(jobId, `Ingest worker exited with ${detail}`);
|
|
99
|
+
});
|
|
100
|
+
return worker;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private failActiveJob(jobId: string, message: string) {
|
|
104
|
+
const job = this.db.getJob(jobId);
|
|
105
|
+
if (!job || (job.status !== "queued" && job.status !== "running")) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
job.status = "failed";
|
|
109
|
+
job.phase = "failed";
|
|
110
|
+
job.finishedAt = new Date().toISOString();
|
|
111
|
+
job.currentFile = null;
|
|
112
|
+
job.errors.push(message);
|
|
113
|
+
this.db.upsertJob(job);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function runIngestJob(db: SuperViewDatabase, jobId: string, ingestOptions: IngestStartOptions | string = {}, options: { workerPid?: number | null } = {}) {
|
|
118
|
+
const job = db.getJob(jobId);
|
|
119
|
+
if (!job) {
|
|
120
|
+
throw new Error(`Ingest job ${jobId} not found`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
job.status = "running";
|
|
125
|
+
job.phase = "scanning";
|
|
126
|
+
job.workerPid = options.workerPid ?? job.workerPid ?? null;
|
|
127
|
+
job.processorVersion = INGEST_PROCESSOR_VERSION;
|
|
128
|
+
db.upsertJob(job);
|
|
129
|
+
|
|
130
|
+
const normalizedOptions = typeof ingestOptions === "string" ? { codexHome: ingestOptions } : ingestOptions;
|
|
131
|
+
const adapterConfigs = resolveAdapterConfigs(normalizedOptions);
|
|
132
|
+
const sources = await scanAgentSources(adapterConfigs);
|
|
133
|
+
job.phase = "diffing";
|
|
134
|
+
job.totalFiles = sources.length;
|
|
135
|
+
job.candidateFiles = sources.length;
|
|
136
|
+
db.upsertJob(job);
|
|
137
|
+
const currentSourceIds = new Set(sources.map((candidate) => candidate.source.id));
|
|
138
|
+
db.pruneMissingIngestedFiles(providersEligibleForPrune(adapterConfigs, sources), currentSourceIds);
|
|
139
|
+
|
|
140
|
+
const candidates: IngestCandidate[] = [];
|
|
141
|
+
let skippedFiles = 0;
|
|
142
|
+
let skippedBytes = 0;
|
|
143
|
+
let totalBytes = 0;
|
|
144
|
+
|
|
145
|
+
for (const candidate of sources) {
|
|
146
|
+
totalBytes += candidate.source.sizeBytes;
|
|
147
|
+
const previous = db.getIngestedFile(candidate.source.id);
|
|
148
|
+
if (previous && previous.mtimeMs === candidate.source.mtimeMs && previous.sizeBytes === candidate.source.sizeBytes && previous.processorVersion === INGEST_PROCESSOR_VERSION) {
|
|
149
|
+
skippedFiles += 1;
|
|
150
|
+
skippedBytes += candidate.source.sizeBytes;
|
|
151
|
+
} else {
|
|
152
|
+
candidates.push(candidate);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
job.skippedFiles = skippedFiles;
|
|
157
|
+
job.changedFiles = candidates.length;
|
|
158
|
+
job.processedFiles = skippedFiles;
|
|
159
|
+
job.processedBytes = skippedBytes;
|
|
160
|
+
job.totalBytes = totalBytes;
|
|
161
|
+
db.upsertJob(job);
|
|
162
|
+
|
|
163
|
+
const codexRoot = adapterConfigs.find((config) => config.provider === "codex")?.root ?? normalizedOptions.codexHome;
|
|
164
|
+
const historyBySessionId = candidates.length > 0 ? await loadHistoryForJob(db, job, codexRoot) : new Map<string, CodexHistoryPrompt[]>();
|
|
165
|
+
const repoRootsByCwd = new Map<string, string | null>();
|
|
166
|
+
const commitsByRepoRoot = new Map<string, GitCommitRecord[]>();
|
|
167
|
+
|
|
168
|
+
let projectCount = 0;
|
|
169
|
+
let sessionCount = 0;
|
|
170
|
+
|
|
171
|
+
for (const candidate of candidates) {
|
|
172
|
+
job.phase = "parsing";
|
|
173
|
+
job.currentFile = candidate.source.path;
|
|
174
|
+
db.upsertJob(job);
|
|
175
|
+
await maybeDelayForTests();
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
const bundle = await parseCandidateWithRepoRoot(candidate, repoRootsByCwd);
|
|
179
|
+
|
|
180
|
+
job.phase = "writing";
|
|
181
|
+
db.upsertJob(job);
|
|
182
|
+
if (bundle) {
|
|
183
|
+
bundle.historyPrompts = normalizeHistoryPrompts(historyBySessionId.get(bundle.session.externalSessionId) ?? historyBySessionId.get(bundle.session.id) ?? [], bundle.session.id);
|
|
184
|
+
bundle.gitCommits = bundle.project.repoRoot ? await cachedCommits(commitsByRepoRoot, bundle.project.repoRoot, bundle.session.startedAt, bundle.session.endedAt) : [];
|
|
185
|
+
db.upsertBundle(bundle);
|
|
186
|
+
db.upsertIngestedFile({
|
|
187
|
+
path: candidate.source.id,
|
|
188
|
+
mtimeMs: candidate.source.mtimeMs,
|
|
189
|
+
sizeBytes: candidate.source.sizeBytes,
|
|
190
|
+
sha256: null,
|
|
191
|
+
sessionId: bundle.session.id,
|
|
192
|
+
processorVersion: INGEST_PROCESSOR_VERSION,
|
|
193
|
+
processedAt: new Date().toISOString()
|
|
194
|
+
});
|
|
195
|
+
projectCount += 1;
|
|
196
|
+
sessionCount += 1;
|
|
197
|
+
job.totalEvents += bundle.events.length;
|
|
198
|
+
} else {
|
|
199
|
+
db.upsertIngestedFile({
|
|
200
|
+
path: candidate.source.id,
|
|
201
|
+
mtimeMs: candidate.source.mtimeMs,
|
|
202
|
+
sizeBytes: candidate.source.sizeBytes,
|
|
203
|
+
sha256: null,
|
|
204
|
+
sessionId: null,
|
|
205
|
+
processorVersion: INGEST_PROCESSOR_VERSION,
|
|
206
|
+
processedAt: new Date().toISOString()
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
} catch (error) {
|
|
210
|
+
job.errors.push(`${candidate.source.path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
job.processedFiles += 1;
|
|
214
|
+
job.processedBytes = (job.processedBytes ?? 0) + candidate.source.sizeBytes;
|
|
215
|
+
job.currentFile = null;
|
|
216
|
+
db.upsertJob(job);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
job.status = "completed";
|
|
220
|
+
job.phase = "completed";
|
|
221
|
+
job.finishedAt = new Date().toISOString();
|
|
222
|
+
job.currentFile = null;
|
|
223
|
+
db.upsertJob(job);
|
|
224
|
+
return { projects: projectCount, sessions: sessionCount, events: job.totalEvents };
|
|
225
|
+
} catch (error) {
|
|
226
|
+
job.status = "failed";
|
|
227
|
+
job.phase = "failed";
|
|
228
|
+
job.finishedAt = new Date().toISOString();
|
|
229
|
+
job.currentFile = null;
|
|
230
|
+
job.errors.push(error instanceof Error ? error.message : String(error));
|
|
231
|
+
db.upsertJob(job);
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function normalizeHistoryPrompts(prompts: CodexHistoryPrompt[], sessionId: string): CodexHistoryPrompt[] {
|
|
237
|
+
return prompts.map((prompt) => ({ ...prompt, sessionId }));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function resolveAdapterConfigs(options: IngestStartOptions): AgentSourceConfig[] {
|
|
241
|
+
if (options.sources && options.sources.length > 0) {
|
|
242
|
+
return options.sources;
|
|
243
|
+
}
|
|
244
|
+
if (options.codexHome) {
|
|
245
|
+
return [{ provider: "codex", root: options.codexHome }];
|
|
246
|
+
}
|
|
247
|
+
return defaultAdapters().map((adapter) => ({ provider: adapter.provider }));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function scanAgentSources(configs: AgentSourceConfig[]): Promise<IngestCandidate[]> {
|
|
251
|
+
const candidates: IngestCandidate[] = [];
|
|
252
|
+
for (const config of configs) {
|
|
253
|
+
const adapter = adapterForProvider(config.provider);
|
|
254
|
+
const sources = await adapter.scan(config);
|
|
255
|
+
candidates.push(...sources.map((source) => ({ source, adapter })));
|
|
256
|
+
}
|
|
257
|
+
return candidates;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function providersEligibleForPrune(configs: AgentSourceConfig[], candidates: IngestCandidate[]): AgentProvider[] {
|
|
261
|
+
const providersWithCurrentSources = new Set(candidates.map((candidate) => candidate.source.provider));
|
|
262
|
+
return Array.from(
|
|
263
|
+
new Set(
|
|
264
|
+
configs
|
|
265
|
+
.filter((config) => Boolean(config.root ?? config.path) || providersWithCurrentSources.has(config.provider))
|
|
266
|
+
.map((config) => config.provider)
|
|
267
|
+
)
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function parseCandidateWithRepoRoot(candidate: IngestCandidate, repoRootsByCwd: Map<string, string | null>): Promise<NormalizedBundle | null> {
|
|
272
|
+
const initial = await candidate.adapter.parseSource(candidate.source);
|
|
273
|
+
if (!initial) return null;
|
|
274
|
+
const repoRoot = await cachedRepoRoot(repoRootsByCwd, initial.session.cwd);
|
|
275
|
+
if (!repoRoot) return initial;
|
|
276
|
+
if (initial.project.repoRoot === repoRoot) return initial;
|
|
277
|
+
return candidate.adapter.parseSource(candidate.source, { repoRoot });
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function loadHistoryBySessionId(codexHome?: string) {
|
|
281
|
+
try {
|
|
282
|
+
const sourcePath = `${codexHome ?? resolveCodexHome()}/history.jsonl`;
|
|
283
|
+
return (await parseCodexHistoryJsonlFile(sourcePath)).bySessionId;
|
|
284
|
+
} catch {
|
|
285
|
+
return new Map();
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function loadHistoryForJob(db: SuperViewDatabase, job: IngestJob, codexHome?: string) {
|
|
290
|
+
job.phase = "loading_history";
|
|
291
|
+
db.upsertJob(job);
|
|
292
|
+
return loadHistoryBySessionId(codexHome);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function cachedRepoRoot(cache: Map<string, string | null>, cwd: string) {
|
|
296
|
+
if (cache.has(cwd)) return cache.get(cwd) ?? null;
|
|
297
|
+
const repoRoot = await getRepoRoot(cwd);
|
|
298
|
+
cache.set(cwd, repoRoot);
|
|
299
|
+
return repoRoot;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async function cachedCommits(cache: Map<string, GitCommitRecord[]>, repoRoot: string, from?: string | null, to?: string | null) {
|
|
303
|
+
let commits = cache.get(repoRoot);
|
|
304
|
+
if (!commits) {
|
|
305
|
+
commits = await getCommits(repoRoot);
|
|
306
|
+
cache.set(repoRoot, commits);
|
|
307
|
+
}
|
|
308
|
+
return commits.filter((commit) => isCommitInWindow(commit, from, to));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function isCommitInWindow(commit: GitCommitRecord, from?: string | null, to?: string | null) {
|
|
312
|
+
const commitMs = Date.parse(commit.timestamp);
|
|
313
|
+
if (!Number.isFinite(commitMs)) return true;
|
|
314
|
+
const fromMs = from ? Date.parse(from) : null;
|
|
315
|
+
const toMs = to ? Date.parse(to) : null;
|
|
316
|
+
if (fromMs !== null && Number.isFinite(fromMs) && commitMs < fromMs) return false;
|
|
317
|
+
if (toMs !== null && Number.isFinite(toMs) && commitMs > toMs) return false;
|
|
318
|
+
return true;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function resolveTsxCli() {
|
|
322
|
+
// Try package-local node_modules first (works when installed globally)
|
|
323
|
+
const pkgTsx = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "node_modules", "tsx", "dist", "cli.mjs");
|
|
324
|
+
if (existsSync(pkgTsx)) return pkgTsx;
|
|
325
|
+
// Fall back to CWD (dev mode)
|
|
326
|
+
const cwdTsx = path.resolve(process.cwd(), "node_modules", "tsx", "dist", "cli.mjs");
|
|
327
|
+
if (existsSync(cwdTsx)) return cwdTsx;
|
|
328
|
+
// Last resort: .bin/tsx
|
|
329
|
+
return path.resolve(process.cwd(), "node_modules", ".bin", "tsx");
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function buildWorkerCommand(jobId: string, options: IngestStartOptions) {
|
|
333
|
+
const workerPath = workerPathFromImportMeta();
|
|
334
|
+
const tsxCli = resolveTsxCli();
|
|
335
|
+
const encodedOptions = Buffer.from(JSON.stringify(options), "utf8").toString("base64url");
|
|
336
|
+
const args = [workerPath, jobId, encodedOptions];
|
|
337
|
+
return { command: process.execPath, args: [tsxCli, ...args] };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function workerPathFromImportMeta() {
|
|
341
|
+
const workerUrl = new URL("./ingest-worker.ts", import.meta.url);
|
|
342
|
+
if (workerUrl.protocol === "file:") {
|
|
343
|
+
return fileURLToPath(workerUrl);
|
|
344
|
+
}
|
|
345
|
+
return path.resolve(process.cwd(), "runtime-node", "ingest-worker.ts");
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function maybeDelayForTests() {
|
|
349
|
+
const delayMs = Number(process.env.SUPERVIEW_TEST_INGEST_FILE_DELAY_MS ?? 0);
|
|
350
|
+
if (Number.isFinite(delayMs) && delayMs > 0) {
|
|
351
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export function parseIngestOptions(value: string | undefined): IngestStartOptions {
|
|
356
|
+
if (!value) return {};
|
|
357
|
+
try {
|
|
358
|
+
return JSON.parse(Buffer.from(value, "base64url").toString("utf8")) as IngestStartOptions;
|
|
359
|
+
} catch {
|
|
360
|
+
return { codexHome: value };
|
|
361
|
+
}
|
|
362
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import express from "express";
|
|
4
|
+
import { createServer } from "./server.js";
|
|
5
|
+
|
|
6
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const uiDist = path.resolve(__dirname, "..", "dist", "ui");
|
|
8
|
+
|
|
9
|
+
const app = createServer();
|
|
10
|
+
|
|
11
|
+
app.use(express.static(uiDist, { index: "index.html" }));
|
|
12
|
+
|
|
13
|
+
// SPA fallback — serve index.html for any non-API, non-file route
|
|
14
|
+
app.use((req, res, next) => {
|
|
15
|
+
if (req.path.startsWith("/api/") || req.path.includes(".")) return next();
|
|
16
|
+
res.sendFile(path.join(uiDist, "index.html"));
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export function startProdServer(port?: number) {
|
|
20
|
+
const p = port ?? Number(process.env.SUPERVIEW_PORT ?? 5174);
|
|
21
|
+
return app.listen(p, "0.0.0.0", () => {
|
|
22
|
+
console.log(`SuperView running at http://0.0.0.0:${p}`);
|
|
23
|
+
});
|
|
24
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import fg from "fast-glob";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { resolveCodexHome } from "../storage/paths";
|
|
4
|
+
|
|
5
|
+
export async function scanRolloutFiles(codexHome = resolveCodexHome()): Promise<string[]> {
|
|
6
|
+
const sessionsDir = path.join(codexHome, "sessions");
|
|
7
|
+
return fg("**/rollout-*.jsonl", {
|
|
8
|
+
cwd: sessionsDir,
|
|
9
|
+
absolute: true,
|
|
10
|
+
onlyFiles: true,
|
|
11
|
+
suppressErrors: true
|
|
12
|
+
});
|
|
13
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
import { AgentProvider, AgentSourceConfig, TimelineLane, TimelineQuery } from "../core/types";
|
|
3
|
+
import { buildContextReplay } from "../core/contextReplay";
|
|
4
|
+
import { SuperViewDatabase } from "../storage/database";
|
|
5
|
+
import { IngestService } from "./ingest";
|
|
6
|
+
|
|
7
|
+
export function createServer() {
|
|
8
|
+
const db = new SuperViewDatabase();
|
|
9
|
+
const ingest = new IngestService(db);
|
|
10
|
+
const app = express();
|
|
11
|
+
app.use(express.json());
|
|
12
|
+
|
|
13
|
+
app.get("/api/health", (_req, res) => {
|
|
14
|
+
res.json({ ok: true });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
app.post("/api/ingest", (req, res) => {
|
|
18
|
+
const result = ingest.start(parseIngestBody(req.body));
|
|
19
|
+
res.status(202).json({ jobId: result.job.id, alreadyRunning: result.alreadyRunning, job: result.job });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
app.get("/api/ingest/jobs/:id", (req, res) => {
|
|
23
|
+
const job = ingest.getJob(req.params.id);
|
|
24
|
+
if (!job) {
|
|
25
|
+
res.status(404).json({ error: "job not found" });
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
res.json(job);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
app.get("/api/projects", (_req, res) => {
|
|
32
|
+
const projectRecords = db.listProjects();
|
|
33
|
+
const projectIds = projectRecords.map((project) => project.id);
|
|
34
|
+
const tokenUsageByProject = db.getProjectTokenUsageByProjectIds(projectIds);
|
|
35
|
+
const sessionsByProject = db.listSessionsByProjectIds(projectIds);
|
|
36
|
+
const projects = projectRecords.map((project) => ({
|
|
37
|
+
...project,
|
|
38
|
+
tokenUsage: tokenUsageByProject.get(project.id) ?? { input: 0, output: 0, reasoning: 0, cachedInput: 0, total: 0 },
|
|
39
|
+
sessions: sessionsByProject.get(project.id) ?? []
|
|
40
|
+
}));
|
|
41
|
+
res.json({ projects });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
app.get("/api/projects/:id/timeline", (req, res) => {
|
|
45
|
+
const query = parseTimelineQuery(req.query);
|
|
46
|
+
const timeline = db.getTimeline(req.params.id, query);
|
|
47
|
+
if (!timeline) {
|
|
48
|
+
res.status(404).json({ error: "project not found" });
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
res.json(timeline);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
app.get("/api/projects/:id/token-usage/daily", (req, res) => {
|
|
55
|
+
const usage = db.getProjectDailyTokenUsage(req.params.id);
|
|
56
|
+
if (!usage) {
|
|
57
|
+
res.status(404).json({ error: "project not found" });
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
res.json(usage);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
app.get("/api/events/:id/evidence", (req, res) => {
|
|
64
|
+
const evidence = db.getEventEvidence(req.params.id);
|
|
65
|
+
if (!evidence) {
|
|
66
|
+
res.status(404).json({ error: "event not found" });
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
res.json({
|
|
70
|
+
event: evidence.event,
|
|
71
|
+
artifacts: evidence.artifacts,
|
|
72
|
+
rawEvent: evidence.rawEvent
|
|
73
|
+
? {
|
|
74
|
+
id: evidence.rawEvent.id,
|
|
75
|
+
sessionId: evidence.rawEvent.sessionId,
|
|
76
|
+
lineNo: evidence.rawEvent.lineNo,
|
|
77
|
+
timestamp: evidence.rawEvent.timestamp,
|
|
78
|
+
type: evidence.rawEvent.type,
|
|
79
|
+
sourcePath: evidence.rawEvent.sourcePath,
|
|
80
|
+
sha256: evidence.rawEvent.sha256,
|
|
81
|
+
redactedPayload: safeJsonParse(evidence.rawEvent.redactedPayloadJson)
|
|
82
|
+
}
|
|
83
|
+
: null
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
app.get("/api/task-journeys/:id", (req, res) => {
|
|
88
|
+
const projectId = firstQueryValue(req.query.projectId);
|
|
89
|
+
const detail = db.getTaskJourneyDetail(req.params.id, projectId);
|
|
90
|
+
if (!detail) {
|
|
91
|
+
res.status(404).json({ error: "task journey not found" });
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
res.json(detail);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
app.get("/api/task-journeys/:id/context-replay", (req, res) => {
|
|
98
|
+
const projectId = firstQueryValue(req.query.projectId);
|
|
99
|
+
const detail = db.getTaskJourneyDetail(req.params.id, projectId);
|
|
100
|
+
if (!detail) {
|
|
101
|
+
res.status(404).json({ error: "task journey not found" });
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const evidenceByEventId = db.getEventEvidenceByEventIds(detail.events.map((event) => event.id));
|
|
105
|
+
const historyPrompts = db.listHistoryPromptsForSession(detail.journey.sessionId);
|
|
106
|
+
res.json(buildContextReplay({ detail, evidenceByEventId, historyPrompts }));
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
app.get("/api/runs/:id", (req, res) => {
|
|
110
|
+
const replay = db.getRunReplay(req.params.id);
|
|
111
|
+
if (!replay) {
|
|
112
|
+
res.status(404).json({ error: "run not found" });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
res.json(replay);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
app.post("/api/reset", (_req, res) => {
|
|
119
|
+
db.reset();
|
|
120
|
+
res.json({ ok: true });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
return app;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const AGENT_PROVIDERS: AgentProvider[] = ["codex", "claude-code", "opencode"];
|
|
127
|
+
|
|
128
|
+
function parseIngestBody(body: unknown) {
|
|
129
|
+
if (!body || typeof body !== "object") return {};
|
|
130
|
+
const record = body as Record<string, unknown>;
|
|
131
|
+
const sources = Array.isArray(record.sources) ? record.sources.map(parseAgentSourceConfig).filter((source): source is AgentSourceConfig => Boolean(source)) : undefined;
|
|
132
|
+
return {
|
|
133
|
+
codexHome: typeof record.codexHome === "string" ? record.codexHome : undefined,
|
|
134
|
+
sources
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function parseAgentSourceConfig(value: unknown): AgentSourceConfig | null {
|
|
139
|
+
if (!value || typeof value !== "object") return null;
|
|
140
|
+
const record = value as Record<string, unknown>;
|
|
141
|
+
const provider = typeof record.provider === "string" && AGENT_PROVIDERS.includes(record.provider as AgentProvider) ? (record.provider as AgentProvider) : null;
|
|
142
|
+
if (!provider) return null;
|
|
143
|
+
return {
|
|
144
|
+
provider,
|
|
145
|
+
root: typeof record.root === "string" ? record.root : undefined,
|
|
146
|
+
path: typeof record.path === "string" ? record.path : undefined,
|
|
147
|
+
mode: record.mode === "cli-export" || record.mode === "files" ? record.mode : undefined
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const TIMELINE_LANES: TimelineLane[] = ["Product", "Architecture", "Code", "Agent Runs", "Verification", "Risks"];
|
|
152
|
+
|
|
153
|
+
function parseTimelineQuery(query: Record<string, unknown>): TimelineQuery {
|
|
154
|
+
const parsed: TimelineQuery = {};
|
|
155
|
+
const limit = firstQueryValue(query.limit);
|
|
156
|
+
const offset = firstQueryValue(query.offset);
|
|
157
|
+
const lane = firstQueryValue(query.lane);
|
|
158
|
+
const since = firstQueryValue(query.since);
|
|
159
|
+
const until = firstQueryValue(query.until);
|
|
160
|
+
|
|
161
|
+
if (limit !== undefined) parsed.limit = Number(limit);
|
|
162
|
+
if (offset !== undefined) parsed.offset = Number(offset);
|
|
163
|
+
if (lane && TIMELINE_LANES.includes(lane as TimelineLane)) parsed.lane = lane as TimelineLane;
|
|
164
|
+
if (since) parsed.since = since;
|
|
165
|
+
if (until) parsed.until = until;
|
|
166
|
+
return parsed;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function firstQueryValue(value: unknown): string | undefined {
|
|
170
|
+
if (Array.isArray(value)) {
|
|
171
|
+
return typeof value[0] === "string" ? value[0] : undefined;
|
|
172
|
+
}
|
|
173
|
+
return typeof value === "string" ? value : undefined;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function safeJsonParse(value: string): unknown {
|
|
177
|
+
try {
|
|
178
|
+
return JSON.parse(value);
|
|
179
|
+
} catch {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|