bugpatrol 0.2.1 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bugpatrol",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "AI agents that explore your app, find bugs, and fix them.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
package/src/ui/app.js CHANGED
@@ -44,7 +44,8 @@ try {
44
44
  }
45
45
  const graphCamera = { box: null, key: '' };
46
46
  let showBackLinks = false;
47
- let renderedReviews = '';
47
+ let renderedState = '';
48
+ let renderedPage = '';
48
49
 
49
50
  // ---------------------------------------------------------------- data
50
51
 
@@ -62,6 +63,12 @@ async function getJson(url) {
62
63
 
63
64
  const artifact = (path) => `/api/artifact?path=${encodeURIComponent(path)}`;
64
65
 
66
+ async function fetchText(path) {
67
+ const response = await fetch(artifact(path));
68
+ if (!response.ok) throw new Error(String(response.status));
69
+ return response.text();
70
+ }
71
+
65
72
  async function refresh({ keepSelection = true } = {}) {
66
73
  const snapshot = await getJson('/api/state');
67
74
  state.runs = snapshot.runs;
@@ -134,13 +141,13 @@ async function refresh({ keepSelection = true } = {}) {
134
141
  state.selectedReview = reviews[0]?.pr.number ?? null;
135
142
  }
136
143
  const detail = state.selectedReview ? await getJson(`/api/reviews/${state.selectedReview}`) : null;
137
- // A rebuilt page restarts a playing video, so skip the render when the review did not change.
138
- const key = JSON.stringify([reviews, detail]);
139
- if (key === renderedReviews && view.querySelector('.review-page')) return;
140
- renderedReviews = key;
141
144
  state.reviews = reviews;
142
145
  state.reviewDetail = detail;
143
146
  }
147
+ // A rebuilt page closes what the reader opened and restarts a playing video: skip it when nothing changed.
148
+ const key = JSON.stringify(state);
149
+ if (key === renderedState && view.childElementCount) return;
150
+ renderedState = key;
144
151
  render();
145
152
  }
146
153
 
@@ -162,6 +169,18 @@ function render() {
162
169
  button.classList.toggle('active', button.dataset.view === state.view);
163
170
  if (button.dataset.view === 'checks') button.hidden = !hasChecks;
164
171
  }
172
+ // The same page, drawn again with new data, keeps what the reader opened and where they were.
173
+ const page = JSON.stringify([
174
+ state.view,
175
+ state.selectedIssueId,
176
+ state.selectedSessionId,
177
+ state.selectedReview,
178
+ state.selectedRunId,
179
+ state.selectedScreenId,
180
+ ]);
181
+ const opened = page === renderedPage ? detailsOpen() : undefined;
182
+ const scroll = window.scrollY;
183
+ renderedPage = page;
165
184
  view.innerHTML = '';
166
185
  if (state.view === 'checks') {
167
186
  const banner = renderLiveBanner();
@@ -178,6 +197,25 @@ function render() {
178
197
  checks: renderRuns,
179
198
  };
180
199
  view.append(renderers[state.view]());
200
+ if (opened) {
201
+ for (const [key, details] of detailsByKey()) if (opened.has(key)) details.open = opened.get(key);
202
+ window.scrollTo(0, scroll);
203
+ }
204
+ }
205
+
206
+ /** Each <details> on the page by its summary and its place among the ones with the same summary. */
207
+ function detailsByKey() {
208
+ const seen = new Map();
209
+ return [...view.querySelectorAll('details')].map((details) => {
210
+ const summary = details.querySelector('summary')?.textContent ?? '';
211
+ const nth = (seen.get(summary) ?? 0) + 1;
212
+ seen.set(summary, nth);
213
+ return [`${summary}#${nth}`, details];
214
+ });
215
+ }
216
+
217
+ function detailsOpen() {
218
+ return new Map(detailsByKey().map(([key, details]) => [key, details.open]));
181
219
  }
182
220
 
183
221
  function el(tag, props = {}, children = []) {
@@ -212,7 +250,7 @@ function renderLiveBanner() {
212
250
  const total = live.plannedCaptures || done;
213
251
  return el('div', { class: 'runbanner' }, [
214
252
  el('span', { class: 'spinner' }),
215
- el('span', { text: `Run in progress — ${done}/${total} captures` }),
253
+ el('span', { text: `Run in progress: ${done}/${total} captures` }),
216
254
  live.currentStep ? el('span', { class: 'muted', text: live.currentStep }) : null,
217
255
  ]);
218
256
  }
@@ -329,7 +367,7 @@ function renderDetail() {
329
367
  ...trace.suppressed.map((s) =>
330
368
  el('div', { class: 'finding' }, [
331
369
  el('div', { text: `${s.ruleId} on ${s.screenId}` }),
332
- el('div', { class: 'why', text: `"${s.reason}" — ${s.decidedBy}` }),
370
+ el('div', { class: 'why', text: `"${s.reason}": ${s.decidedBy}` }),
333
371
  ]),
334
372
  ),
335
373
  ]),
@@ -462,7 +500,7 @@ function renderReasoning(screen) {
462
500
  'Pixel diff',
463
501
  d.identical
464
502
  ? `identical (${d.engine}, ${Math.round(d.durationMs)}ms)`
465
- : `${d.changedPixels} px across ${d.regionCount} region(s) — ` +
503
+ : `${d.changedPixels} px across ${d.regionCount} region(s): ` +
466
504
  `${(d.changedFraction * 100).toFixed(3)}% of compared area (${d.engine})`,
467
505
  );
468
506
  add('Masked', `${(d.maskedFraction * 100).toFixed(1)}% of the screen, ` + `${d.maskedRegionCount} region(s)`);
@@ -485,7 +523,7 @@ function renderReasoning(screen) {
485
523
  const d = screen.decision;
486
524
  add(
487
525
  'Decision layer',
488
- d.decider === 'none' ? `not consulted — ${d.skippedReason}` : `${d.decider}, $${(d.costUsd ?? 0).toFixed(6)}`,
526
+ d.decider === 'none' ? `not consulted: ${d.skippedReason}` : `${d.decider}, $${(d.costUsd ?? 0).toFixed(6)}`,
489
527
  );
490
528
  if (d.stateChars) add('State digest', `${d.stateChars} chars (hash ${String(d.stateHash ?? '').slice(0, 12)})`);
491
529
  for (const [key, answer] of Object.entries(d.answers ?? {})) {
@@ -532,7 +570,7 @@ function roleIcon(role) {
532
570
  }
533
571
 
534
572
  function relativeTime(value) {
535
- if (!value) return '—';
573
+ if (!value) return '-';
536
574
  const seconds = Math.max(0, Math.round((Date.now() - new Date(value).getTime()) / 1000));
537
575
  if (!Number.isFinite(seconds)) return value;
538
576
  if (seconds < 60) return 'just now';
@@ -590,10 +628,10 @@ function renderUsage(usage) {
590
628
  el('td', { text: row.model }),
591
629
  el('td', { text: String(row.sessions) }),
592
630
  el('td', { text: count(row.tokens.input) }),
593
- el('td', { text: row.tokens.cacheRead ? count(row.tokens.cacheRead) : '—' }),
631
+ el('td', { text: row.tokens.cacheRead ? count(row.tokens.cacheRead) : '-' }),
594
632
  el('td', { text: count(row.tokens.output) }),
595
633
  el('td', { text: count(Math.round((row.tokens.input + row.tokens.output) / Math.max(1, row.sessions))) }),
596
- el('td', { text: row.tokens.listCostUsd ? money(row.tokens.listCostUsd) : '—' }),
634
+ el('td', { text: row.tokens.listCostUsd ? money(row.tokens.listCostUsd) : '-' }),
597
635
  ]),
598
636
  ),
599
637
  ])
@@ -826,7 +864,7 @@ function visibleIssues() {
826
864
  function renderIssueDetail() {
827
865
  const detail = state.issueDetail;
828
866
  if (!detail) return el('section', { class: 'card panel empty', text: 'Select an issue.' });
829
- const { issue, fix, candidates } = detail;
867
+ const { issue, fix, candidates, gaveUpAfter, attemptDiffs = {} } = detail;
830
868
  const evidence = issue.evidence ?? {};
831
869
  const shots = [
832
870
  ['Screenshot', evidence.screenshot],
@@ -888,21 +926,26 @@ function renderIssueDetail() {
888
926
  fix
889
927
  ? el('section', { class: 'fix' }, [
890
928
  el('div', { class: 'section-title' }, [el('h2', { text: 'Proposed fix' }), fixBadge(fix)]),
929
+ gaveUpAfter
930
+ ? el('p', { class: 'gave-up', text: `The fixer stopped after ${gaveUpAfter} fix attempts.` })
931
+ : null,
891
932
  markdown(fix.summary || 'No summary yet.'),
892
- ...(fix.retests?.length
893
- ? [
894
- retestBlock(fix.retests.at(-1), issue),
895
- fix.retests.length > 1
896
- ? el('details', { class: 'earlier-retests' }, [
897
- el('summary', { text: `Earlier attempts (${fix.retests.length - 1})` }),
898
- ...fix.retests
899
- .slice(0, -1)
900
- .reverse()
901
- .map((retest) => retestBlock(retest, issue)),
902
- ])
903
- : null,
904
- ]
905
- : []),
933
+ ...(fix.attempts?.length
934
+ ? []
935
+ : fix.retests?.length
936
+ ? [
937
+ retestBlock(fix.retests.at(-1), issue),
938
+ fix.retests.length > 1
939
+ ? el('details', { class: 'earlier-retests' }, [
940
+ el('summary', { text: `Earlier retests (${fix.retests.length - 1})` }),
941
+ ...fix.retests
942
+ .slice(0, -1)
943
+ .reverse()
944
+ .map((retest) => retestBlock(retest, issue)),
945
+ ])
946
+ : null,
947
+ ]
948
+ : []),
906
949
  title('Code change'),
907
950
  fix.diffStat ? el('pre', { text: fix.diffStat }) : null,
908
951
  // Collapsed: the verdict and the screenshots come first; the diff is
@@ -919,6 +962,9 @@ function renderIssueDetail() {
919
962
  ? el('span', { text: `Worktree removed ${new Date(fix.worktreeRemovedAt).toLocaleString()}` })
920
963
  : el('code', { text: fix.worktree }),
921
964
  ]),
965
+ ...(fix.attempts?.length
966
+ ? [title('Fix attempts', 'oldest first'), attemptTimeline(fix, issue, attemptDiffs)]
967
+ : []),
922
968
  ])
923
969
  : null,
924
970
  ]);
@@ -1015,6 +1061,78 @@ function fixBadge(fix) {
1015
1061
  return el('span', { class: `badge ${kind}`, text: label });
1016
1062
  }
1017
1063
 
1064
+ const ATTEMPT_KINDS = { first: 'First fix', rerun: 'Rerun', refix: 'Refix', ci: 'CI fix' };
1065
+ const ATTEMPT_OUTCOMES = {
1066
+ proposed: ['Proposed', 'pass'],
1067
+ 'no-change': ['No change', 'warn'],
1068
+ declined: ['Declined', 'warn'],
1069
+ 'verify-failed': ['Verify failed', 'fail'],
1070
+ error: ['Error', 'fail'],
1071
+ timeout: ['Timed out', 'fail'],
1072
+ abandoned: ['Abandoned', 'fail'],
1073
+ };
1074
+
1075
+ /** Each fix attempt, oldest first, with the retests that judged it. */
1076
+ function attemptTimeline(fix, issue, attemptDiffs) {
1077
+ const last = fix.attempts.at(-1).n;
1078
+ const judged = (n) =>
1079
+ (fix.retests ?? []).filter((retest) =>
1080
+ // A retest from before attempts were recorded, or one naming an unknown attempt, judged the last one.
1081
+ fix.attempts.some((attempt) => attempt.n === retest.fixAttempt) ? retest.fixAttempt === n : n === last,
1082
+ );
1083
+ return el(
1084
+ 'ol',
1085
+ { class: 'attempt-timeline' },
1086
+ fix.attempts.map((attempt) => {
1087
+ const [outcome, kind] = attempt.outcome
1088
+ ? (ATTEMPT_OUTCOMES[attempt.outcome] ?? [attempt.outcome, 'info'])
1089
+ : ['Running…', 'info'];
1090
+ return el('li', { class: 'attempt' }, [
1091
+ el('div', { class: 'detail-heading' }, [
1092
+ el('h3', { text: `Attempt ${attempt.n}: ${ATTEMPT_KINDS[attempt.kind] ?? attempt.kind}` }),
1093
+ el('span', { class: `badge ${kind}`, text: outcome }),
1094
+ ]),
1095
+ el('div', { class: 'kv' }, [
1096
+ el('span', { text: relativeTime(attempt.startedAt) }),
1097
+ attempt.costUsd !== undefined ? el('span', { text: money(attempt.costUsd) }) : null,
1098
+ ]),
1099
+ attempt.reason ? markdown(attempt.reason) : null,
1100
+ attempt.diffStat ? el('pre', { text: attempt.diffStat }) : null,
1101
+ attemptDiffs[attempt.n] ? lazyDiff(attemptDiffs[attempt.n]) : null,
1102
+ attempt.verifyOutput
1103
+ ? el('details', { class: 'diff-details' }, [
1104
+ el('summary', { text: 'Show the verify output' }),
1105
+ el('pre', { text: attempt.verifyOutput }),
1106
+ ])
1107
+ : null,
1108
+ ...judged(attempt.n).map((retest) => retestBlock(retest, issue)),
1109
+ ]);
1110
+ }),
1111
+ );
1112
+ }
1113
+
1114
+ /** A diff file that loads the first time the reader opens it. */
1115
+ function lazyDiff(path) {
1116
+ const body = el('div', { class: 'muted', text: 'Loading…' });
1117
+ let loaded = false;
1118
+ return el(
1119
+ 'details',
1120
+ {
1121
+ class: 'diff-details',
1122
+ ontoggle: (event) => {
1123
+ if (!event.target.open || loaded) return;
1124
+ loaded = true;
1125
+ fetchText(path)
1126
+ .then((diff) => body.replaceWith(diffBlock(diff)))
1127
+ .catch((error) => {
1128
+ body.textContent = `Could not read the diff: ${error.message}`;
1129
+ });
1130
+ },
1131
+ },
1132
+ [el('summary', { text: 'Show the diff' }), body],
1133
+ );
1134
+ }
1135
+
1018
1136
  function retestBlock(retest, issue) {
1019
1137
  const before = retest.before ?? issue.evidence?.screenshot;
1020
1138
  const pair = (shot) =>
@@ -1884,8 +2002,7 @@ function castPlayer(path) {
1884
2002
  }, at * 1000);
1885
2003
  });
1886
2004
  });
1887
- fetch(artifact(path))
1888
- .then((response) => (response.ok ? response.text() : Promise.reject(new Error(String(response.status)))))
2005
+ fetchText(path)
1889
2006
  .then((text) => {
1890
2007
  cast = parseCast(text);
1891
2008
  if (!cast) throw new Error('not an asciicast file');
@@ -1958,7 +2075,7 @@ const lightboxCaption = document.getElementById('lightbox-caption');
1958
2075
 
1959
2076
  function openLightbox(path, caption) {
1960
2077
  lightboxImg.src = artifact(path);
1961
- lightboxCaption.textContent = `${caption} — ${path}`;
2078
+ lightboxCaption.textContent = `${caption}: ${path}`;
1962
2079
  lightbox.hidden = false;
1963
2080
  }
1964
2081
  lightbox.addEventListener('click', () => {
package/src/ui/style.css CHANGED
@@ -957,6 +957,23 @@ h1 {
957
957
  color: var(--muted);
958
958
  font-size: 0.75rem;
959
959
  }
960
+ .attempt-timeline {
961
+ list-style: none;
962
+ margin: 0;
963
+ padding: 0;
964
+ }
965
+ .attempt {
966
+ border-left: 2px solid var(--line);
967
+ padding: 0.25rem 0 0.75rem 0.9rem;
968
+ margin-bottom: 0.5rem;
969
+ }
970
+ .attempt h3 {
971
+ margin: 0;
972
+ }
973
+ .gave-up {
974
+ color: var(--critical);
975
+ font-weight: 600;
976
+ }
960
977
  .earlier-retests summary {
961
978
  cursor: pointer;
962
979
  color: var(--muted);