reprobe 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/PLAYBOOK.md +109 -0
- package/README.md +119 -2
- package/bin/reprobe.mjs +6 -0
- package/dist/agent.d.ts +13 -0
- package/dist/agent.js +123 -0
- package/dist/case.d.ts +11 -0
- package/dist/case.js +98 -0
- package/dist/cli.d.ts +9 -0
- package/dist/cli.js +280 -0
- package/dist/config.d.ts +100 -0
- package/dist/config.js +149 -0
- package/dist/detectors.d.ts +8 -0
- package/dist/detectors.js +136 -0
- package/dist/diagnosis.d.ts +22 -0
- package/dist/diagnosis.js +31 -0
- package/dist/doctor.d.ts +6 -0
- package/dist/doctor.js +113 -0
- package/dist/exec.d.ts +19 -0
- package/dist/exec.js +56 -0
- package/dist/gather.d.ts +7 -0
- package/dist/gather.js +183 -0
- package/dist/init.d.ts +12 -0
- package/dist/init.js +111 -0
- package/dist/llm.d.ts +18 -0
- package/dist/llm.js +71 -0
- package/dist/redact.d.ts +3 -0
- package/dist/redact.js +98 -0
- package/dist/report.d.ts +7 -0
- package/dist/report.js +145 -0
- package/dist/sources/langfuse.d.ts +36 -0
- package/dist/sources/langfuse.js +147 -0
- package/dist/sources/langsmith.d.ts +9 -0
- package/dist/sources/langsmith.js +130 -0
- package/dist/sources/logs.d.ts +40 -0
- package/dist/sources/logs.js +324 -0
- package/dist/sources/postgres.d.ts +22 -0
- package/dist/sources/postgres.js +209 -0
- package/dist/sources/sentry.d.ts +24 -0
- package/dist/sources/sentry.js +123 -0
- package/dist/types.d.ts +160 -0
- package/dist/types.js +1 -0
- package/dist/verify.d.ts +27 -0
- package/dist/verify.js +358 -0
- package/package.json +57 -4
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { run } from "../exec.js";
|
|
2
|
+
import { config } from "../config.js";
|
|
3
|
+
import { redactText } from "../redact.js";
|
|
4
|
+
const DAY_MS = 86_400_000;
|
|
5
|
+
const AUTH_HINT = 'sentry-cli is not logged in: run `sentry-cli login`';
|
|
6
|
+
/** Sentry is reached only through sentry-cli, which holds its own credentials. */
|
|
7
|
+
export async function sentryAuthState(runner = run) {
|
|
8
|
+
if (!config.sources.sentry)
|
|
9
|
+
return { source: 'sentry', state: 'skipped', detail: 'no sentry source in the config' };
|
|
10
|
+
const r = await runner('sentry-cli', ['info'], { timeoutMs: 15_000 });
|
|
11
|
+
if (r.code === 127)
|
|
12
|
+
return { source: 'sentry', state: 'missing-credentials', detail: 'sentry-cli not installed' };
|
|
13
|
+
if (r.code !== 0 || r.stdout.includes('Method: Unauthorized')) {
|
|
14
|
+
return { source: 'sentry', state: 'missing-credentials', detail: AUTH_HINT };
|
|
15
|
+
}
|
|
16
|
+
return { source: 'sentry', state: 'ok', detail: 'sentry-cli authenticated' };
|
|
17
|
+
}
|
|
18
|
+
const SCOPE_HINT = 'the sentry-cli token cannot read issues (an org CI token has only org:ci). Create a personal token with event:read, project:read and org:read, then `sentry-cli login --auth-token <token>`';
|
|
19
|
+
/** Logged in is not enough: CI tokens authenticate but get 403 on issues. */
|
|
20
|
+
export async function sentryReadState(runner = run) {
|
|
21
|
+
if (config.sources.sentry && !config.sources.sentry.projects?.length) {
|
|
22
|
+
return { source: 'sentry', state: 'error', detail: 'no Sentry projects configured' };
|
|
23
|
+
}
|
|
24
|
+
const auth = await sentryAuthState(runner);
|
|
25
|
+
if (auth.state !== 'ok')
|
|
26
|
+
return auth;
|
|
27
|
+
const { org, projects } = config.sources.sentry;
|
|
28
|
+
const r = await runner('sentry-cli', ['issues', 'list', '-o', org, '-p', projects[0], '--max-rows', '1'], { timeoutMs: 30_000 });
|
|
29
|
+
if (r.code === 0)
|
|
30
|
+
return { source: 'sentry', state: 'ok', detail: `can read issues in ${org}/${projects[0]}` };
|
|
31
|
+
const err = `${r.stderr}\n${r.stdout}`;
|
|
32
|
+
if (/403|permission/i.test(err))
|
|
33
|
+
return { source: 'sentry', state: 'missing-credentials', detail: SCOPE_HINT };
|
|
34
|
+
return { source: 'sentry', state: 'error', detail: redactText(err.split('\n').find((l) => l.trim()) ?? `exit ${r.code}`).slice(0, 200) };
|
|
35
|
+
}
|
|
36
|
+
/** Parses the ASCII table from `sentry-cli issues list`. Columns are mapped by header name. */
|
|
37
|
+
export function parseIssueTable(text, project) {
|
|
38
|
+
const rows = text
|
|
39
|
+
.split('\n')
|
|
40
|
+
.map((l) => l.trim())
|
|
41
|
+
.filter((l) => l.startsWith('|'))
|
|
42
|
+
.map((l) => l.split('|').slice(1, -1).map((c) => c.trim()));
|
|
43
|
+
const headerIdx = rows.findIndex((cells) => cells.some((c) => c.toLowerCase() === 'issue id' || c.toLowerCase() === 'short id'));
|
|
44
|
+
if (headerIdx < 0)
|
|
45
|
+
return [];
|
|
46
|
+
const header = rows[headerIdx].map((c) => c.toLowerCase());
|
|
47
|
+
const col = (name) => header.indexOf(name);
|
|
48
|
+
const idx = {
|
|
49
|
+
id: col('issue id'),
|
|
50
|
+
shortId: col('short id'),
|
|
51
|
+
title: col('title'),
|
|
52
|
+
status: col('status'),
|
|
53
|
+
lastSeen: col('last seen'),
|
|
54
|
+
};
|
|
55
|
+
const cell = (cells, i) => (i < 0 ? '' : (cells[i] ?? ''));
|
|
56
|
+
return rows
|
|
57
|
+
.slice(headerIdx + 1)
|
|
58
|
+
.filter((cells) => cells.some((c) => c !== ''))
|
|
59
|
+
.map((cells) => ({
|
|
60
|
+
id: cell(cells, idx.id),
|
|
61
|
+
shortId: cell(cells, idx.shortId),
|
|
62
|
+
title: cell(cells, idx.title),
|
|
63
|
+
lastSeen: cell(cells, idx.lastSeen),
|
|
64
|
+
status: cell(cells, idx.status),
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
67
|
+
export async function gatherSentry(opts) {
|
|
68
|
+
const runner = opts.runner ?? run;
|
|
69
|
+
if (config.sources.sentry && !config.sources.sentry.projects?.length) {
|
|
70
|
+
return { status: { source: 'sentry', state: 'error', detail: 'no Sentry projects configured' }, evidence: [] };
|
|
71
|
+
}
|
|
72
|
+
const auth = await sentryAuthState(runner);
|
|
73
|
+
if (auth.state !== 'ok')
|
|
74
|
+
return { status: auth, evidence: [] };
|
|
75
|
+
const { org: ORG, projects: PROJECTS, url } = config.sources.sentry;
|
|
76
|
+
const baseUrl = url ? `${url.replace(/\/+$/, '')}/organizations/${encodeURIComponent(ORG)}` : `https://${ORG}.sentry.io`;
|
|
77
|
+
// Issues active around the incident, not whatever is unresolved today.
|
|
78
|
+
const cutoff = Date.parse(opts.window.from) - DAY_MS;
|
|
79
|
+
const iso = (ms) => new Date(ms).toISOString();
|
|
80
|
+
const query = opts.issue || `${opts.userId ? `user.id:${opts.userId} ` : ''}firstSeen:<=${iso(Date.parse(opts.window.to))} lastSeen:>=${iso(cutoff)}`;
|
|
81
|
+
const evidence = [];
|
|
82
|
+
const failures = [];
|
|
83
|
+
let successfulProjects = 0;
|
|
84
|
+
for (const project of PROJECTS) {
|
|
85
|
+
const r = await runner('sentry-cli', ['issues', 'list', '-o', ORG, '-p', project, '--query', query, '--max-rows', '50'], { timeoutMs: 30_000 });
|
|
86
|
+
if (r.code !== 0) {
|
|
87
|
+
failures.push(/403|permission/i.test(r.stderr) ? SCOPE_HINT : redactText((r.stderr.split('\n')[0] ?? '').trim()) || `exit ${r.code}`);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
successfulProjects++;
|
|
91
|
+
for (const row of parseIssueTable(r.stdout, project)) {
|
|
92
|
+
const ms = parseLastSeen(row.lastSeen);
|
|
93
|
+
if (!opts.issue && (ms === null || ms < cutoff))
|
|
94
|
+
continue;
|
|
95
|
+
// Prefix fallback ids so redaction cannot mistake them for phone numbers.
|
|
96
|
+
const ref = row.shortId || `issue-${row.id}`;
|
|
97
|
+
evidence.push({
|
|
98
|
+
id: `sentry:${project}:${ref}`,
|
|
99
|
+
source: 'sentry',
|
|
100
|
+
at: ms === null ? null : new Date(ms).toISOString(),
|
|
101
|
+
kind: 'sentry.issue',
|
|
102
|
+
summary: `${ref} ${row.title} (${row.status})`,
|
|
103
|
+
data: row,
|
|
104
|
+
// A numeric id is not a short-id search, and a numeric URL would be redacted again.
|
|
105
|
+
...(row.shortId ? { link: `${baseUrl}/issues/?query=${encodeURIComponent(row.shortId)}` } : {}),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (successfulProjects === 0) {
|
|
110
|
+
return { status: { source: 'sentry', state: failures[0] === SCOPE_HINT ? 'missing-credentials' : 'error', detail: failures[0] ?? 'sentry-cli failed' }, evidence: [] };
|
|
111
|
+
}
|
|
112
|
+
if (evidence.length === 0) {
|
|
113
|
+
return { status: { source: 'sentry', state: failures.length ? 'error' : 'empty', detail: failures.length ? `some project reads failed: ${failures.join('; ')}` : 'no matching issues' }, evidence };
|
|
114
|
+
}
|
|
115
|
+
return { status: { source: 'sentry', state: 'ok', detail: `${evidence.length} issues${failures.length ? `; some project reads failed: ${failures.join('; ')}` : ''}` }, evidence };
|
|
116
|
+
}
|
|
117
|
+
/** "2026-07-28 08:44:48.123 UTC" -> epoch ms, or null when it does not parse. */
|
|
118
|
+
function parseLastSeen(s) {
|
|
119
|
+
if (!s)
|
|
120
|
+
return null;
|
|
121
|
+
const ms = Date.parse(s.replace(' UTC', 'Z').replace(' ', 'T'));
|
|
122
|
+
return Number.isNaN(ms) ? null : ms;
|
|
123
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import type { Config } from './config.ts';
|
|
2
|
+
import type { Runner } from './exec.ts';
|
|
3
|
+
import type { ObservationAnnotator } from './sources/langfuse.ts';
|
|
4
|
+
import type { Pg } from './sources/postgres.ts';
|
|
5
|
+
/** Built-in sources are 'postgres', 'langfuse', 'langsmith', 'sentry', 'logs', 'archive', 'query'; packs add their own. */
|
|
6
|
+
export type SourceName = string;
|
|
7
|
+
export type SourceState = 'ok' | 'empty' | 'missing-credentials' | 'unreachable' | 'expired' | 'not-found' | 'skipped' | 'error';
|
|
8
|
+
export interface SourceStatus {
|
|
9
|
+
source: SourceName;
|
|
10
|
+
state: SourceState;
|
|
11
|
+
detail: string;
|
|
12
|
+
}
|
|
13
|
+
/** One fact from one source. `id` is stable across runs so reports and citations diff cleanly. */
|
|
14
|
+
export interface Evidence {
|
|
15
|
+
id: string;
|
|
16
|
+
source: SourceName;
|
|
17
|
+
at: string | null;
|
|
18
|
+
kind: string;
|
|
19
|
+
summary: string;
|
|
20
|
+
data: unknown;
|
|
21
|
+
link?: string;
|
|
22
|
+
}
|
|
23
|
+
export interface Window {
|
|
24
|
+
from: string;
|
|
25
|
+
to: string;
|
|
26
|
+
}
|
|
27
|
+
export interface Input {
|
|
28
|
+
complaint: string;
|
|
29
|
+
trace?: string;
|
|
30
|
+
user?: string;
|
|
31
|
+
chat?: string;
|
|
32
|
+
sentry?: string;
|
|
33
|
+
reporter?: string;
|
|
34
|
+
at?: string;
|
|
35
|
+
sinceMs: number;
|
|
36
|
+
}
|
|
37
|
+
export interface Subject {
|
|
38
|
+
userId?: string;
|
|
39
|
+
label?: string;
|
|
40
|
+
how?: string;
|
|
41
|
+
sessionIds: string[];
|
|
42
|
+
traceIds: string[];
|
|
43
|
+
}
|
|
44
|
+
export interface ReplayFile {
|
|
45
|
+
/** Path relative to the repo root. */
|
|
46
|
+
path: string;
|
|
47
|
+
content: string;
|
|
48
|
+
mode: 'create' | 'append';
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* A focused test that encodes the invariant the bug broke. It must print `<marker>:START` before its
|
|
52
|
+
* assertions and exactly one of `<marker>:PASS` / `<marker>:FAIL` after, so a run that crashed before
|
|
53
|
+
* reaching the assertion is classified BROKEN instead of FAIL.
|
|
54
|
+
*/
|
|
55
|
+
export interface Replay {
|
|
56
|
+
description: string;
|
|
57
|
+
files: ReplayFile[];
|
|
58
|
+
/** Directory relative to the repo root that the command runs in. */
|
|
59
|
+
cwd: string;
|
|
60
|
+
/** argv, no shell. command[0] must be a known test runner or listed in verify.allow. */
|
|
61
|
+
command: string[];
|
|
62
|
+
/** Files that must exist at the target commit for the replay to apply. */
|
|
63
|
+
requires: string[];
|
|
64
|
+
/** REPROBE_REPLAY:<id> */
|
|
65
|
+
marker: string;
|
|
66
|
+
/** Free-form tag a pack can use to pick its worktree preparation (e.g. 'server-jest', 'dart-test'). */
|
|
67
|
+
kind?: string;
|
|
68
|
+
}
|
|
69
|
+
export type Confidence = 'high' | 'medium' | 'low';
|
|
70
|
+
export interface Finding {
|
|
71
|
+
signature: string;
|
|
72
|
+
title: string;
|
|
73
|
+
confidence: Confidence;
|
|
74
|
+
rootCause: string;
|
|
75
|
+
evidence: string[];
|
|
76
|
+
unknowns: string[];
|
|
77
|
+
replay?: Replay;
|
|
78
|
+
}
|
|
79
|
+
export interface Gathered {
|
|
80
|
+
input: Input;
|
|
81
|
+
window: Window;
|
|
82
|
+
subject: Subject;
|
|
83
|
+
sources: SourceStatus[];
|
|
84
|
+
evidence: Evidence[];
|
|
85
|
+
}
|
|
86
|
+
/** What the complaint says, independent of any product. Packs add product topics on top. */
|
|
87
|
+
export interface Intent {
|
|
88
|
+
build: string | null;
|
|
89
|
+
aboutOther: boolean;
|
|
90
|
+
retries: number | null;
|
|
91
|
+
words: string;
|
|
92
|
+
}
|
|
93
|
+
export interface GatherCtx {
|
|
94
|
+
config: Config;
|
|
95
|
+
input: Input;
|
|
96
|
+
intent: Intent;
|
|
97
|
+
window: Window;
|
|
98
|
+
subject: Subject;
|
|
99
|
+
sources: SourceStatus[];
|
|
100
|
+
evidence: Evidence[];
|
|
101
|
+
/** The read-only Postgres client, when configured and verified read-only. */
|
|
102
|
+
pg: Pg | null;
|
|
103
|
+
/** Extra ids (user ids, session ids) that make a log line relevant. */
|
|
104
|
+
logIds: string[];
|
|
105
|
+
log: (s: string) => void;
|
|
106
|
+
run: Runner;
|
|
107
|
+
}
|
|
108
|
+
export interface DetectCtx {
|
|
109
|
+
g: Gathered;
|
|
110
|
+
intent: Intent;
|
|
111
|
+
caseId: string;
|
|
112
|
+
/** Evidence sorted by time, then id. */
|
|
113
|
+
ev: Evidence[];
|
|
114
|
+
}
|
|
115
|
+
export type Detector = (c: DetectCtx) => Finding[];
|
|
116
|
+
export interface PrepareResult {
|
|
117
|
+
/** Replaces the replay command (e.g. a toolchain picked for the commit date). */
|
|
118
|
+
command?: string[];
|
|
119
|
+
notes?: string[];
|
|
120
|
+
/** A reason the commit cannot run the replay; the result is BROKEN with this detail. */
|
|
121
|
+
error?: string;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* A pack teaches reprobe one product: how to find the user, which rows matter, which failure
|
|
125
|
+
* signatures it has, and how to build an old commit. Every hook is optional.
|
|
126
|
+
* Packs import only types from reprobe; everything they call at runtime arrives in the ctx.
|
|
127
|
+
*/
|
|
128
|
+
export interface Pack {
|
|
129
|
+
name: string;
|
|
130
|
+
/** Before logs are read: resolve who the complaint is about, or a candidate set (push ids to ctx.logIds). */
|
|
131
|
+
resolveSubject?(ctx: GatherCtx): Promise<void>;
|
|
132
|
+
/** After logs are read: narrow candidates using log evidence, then read product rows. */
|
|
133
|
+
gather?(ctx: GatherCtx): Promise<void>;
|
|
134
|
+
/** Product facts read off each Langfuse observation (flags for detectors, words for the summary). */
|
|
135
|
+
observation?: ObservationAnnotator;
|
|
136
|
+
detectors?: Detector[];
|
|
137
|
+
/** Attach a replay to a core finding (e.g. a masked 4xx on a route the pack knows how to test). */
|
|
138
|
+
replayFor?(finding: Finding, ctx: DetectCtx): Replay | undefined;
|
|
139
|
+
/** Runs in the worktree after the replay files are written and setup steps ran. */
|
|
140
|
+
prepareWorktree?(wt: string, replay: Replay, sha: string, tools: WorktreeTools): Promise<PrepareResult>;
|
|
141
|
+
doctor?(ctx: {
|
|
142
|
+
config: Config;
|
|
143
|
+
pg: Pg | null;
|
|
144
|
+
run: Runner;
|
|
145
|
+
}): Promise<Check[]>;
|
|
146
|
+
}
|
|
147
|
+
export interface WorktreeTools {
|
|
148
|
+
run: Runner;
|
|
149
|
+
git(args: string[], cwd?: string): ReturnType<Runner>;
|
|
150
|
+
/** Resolve a relative path inside a root, refusing symlinks and escapes. */
|
|
151
|
+
safePath(root: string, rel: string): string;
|
|
152
|
+
log(s: string): void;
|
|
153
|
+
}
|
|
154
|
+
export interface Check {
|
|
155
|
+
name: string;
|
|
156
|
+
ok: boolean;
|
|
157
|
+
detail: string;
|
|
158
|
+
fix?: string;
|
|
159
|
+
required?: boolean;
|
|
160
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/verify.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type Runner } from './exec.ts';
|
|
2
|
+
import type { Pack, Replay } from './types.ts';
|
|
3
|
+
export type Outcome = 'pass' | 'fail' | 'broken' | 'not-applicable';
|
|
4
|
+
export interface VerifyResult {
|
|
5
|
+
commit: string;
|
|
6
|
+
sha: string;
|
|
7
|
+
outcome: Outcome;
|
|
8
|
+
detail: string;
|
|
9
|
+
logFile: string;
|
|
10
|
+
}
|
|
11
|
+
/** Validate paths serialized in evidence before they can escape a worktree. */
|
|
12
|
+
export declare function assertSafeRelativePath(path: string, label: string): string;
|
|
13
|
+
export declare function assertSafeCheckoutPath(root: string, path: string): string;
|
|
14
|
+
/** Reject paths that can redirect a cached worktree into the user's source checkout. */
|
|
15
|
+
export declare function assertSafeWorktreeLocation(repoPath: string, cachePath: string, worktreePath: string): void;
|
|
16
|
+
/** Test runners a replay may start. A config can add more under verify.allow. */
|
|
17
|
+
export declare const DEFAULT_RUNNERS: string[];
|
|
18
|
+
export declare function assertSafeReplay(replay: Replay, allow?: string[]): void;
|
|
19
|
+
/** A run that never reached its assertion is BROKEN, not FAIL: only a failing assertion proves the bug. */
|
|
20
|
+
export declare function classify(code: number, output: string, marker?: string | null): {
|
|
21
|
+
outcome: Outcome;
|
|
22
|
+
detail: string;
|
|
23
|
+
};
|
|
24
|
+
/** A step for `server` runs only for replays whose cwd is inside `server`; a step for `.` always runs. */
|
|
25
|
+
export declare const stepApplies: (stepDir: string, replayCwd: string) => boolean;
|
|
26
|
+
export declare function verifyReplay(caseDir: string, replay: Replay, commits: string[], pack?: Pack | null, log?: (s: string) => void, runner?: Runner): Promise<VerifyResult[]>;
|
|
27
|
+
export declare function cleanWorktrees(runner?: Runner): Promise<string[]>;
|