querysub 0.500.0 → 0.501.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": "querysub",
3
- "version": "0.500.0",
3
+ "version": "0.501.0",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "note1": "note on node-forge fork, see https://github.com/digitalbazaar/forge/issues/744 for details",
@@ -24,7 +24,7 @@
24
24
  "mc": "yarn typenode ./src/diagnostics/logs/IndexedLogs/MCPIndexedLogsEntry.ts --cwd D:/repos/qs-cyoa/",
25
25
  "mcp2": "yarn typenode ./src/diagnostics/debugger/mcp-server.ts",
26
26
  "mc2": "yarn typenode ./src/diagnostics/debugger/mcp-server.ts --cwd D:/repos/qs-cyoa/",
27
- "autofixer": "yarn typenode ./src/diagnostics/logs/errorTickets/autoFixerEntry.ts",
27
+ "autofix": "yarn typenode ./src/diagnostics/logs/errorTickets/autoFixerEntry.ts",
28
28
  "ssh-a-claude": "ssh root@a.querysubtest.com"
29
29
  },
30
30
  "bin": {
@@ -49,7 +49,7 @@
49
49
  "audit-imports": "./bin/audit-imports.js",
50
50
  "audit-disk-values": "./bin/audit-disk-values.js",
51
51
  "mcp-indexed-logs": "./bin/mcp-indexed-logs.js",
52
- "autofixer": "./bin/autofixer.js"
52
+ "autofix": "./bin/autofix.js"
53
53
  },
54
54
  "dependencies": {
55
55
  "@types/fs-ext": "^2.0.3",
@@ -216,7 +216,7 @@ function renderTrimmed(config: {
216
216
 
217
217
  return {
218
218
  outerAttributes: {
219
- class: css.opacity(0.5, "hover").button,
219
+ class: css.opacity(0.5, "hover"),
220
220
  onClick: () => {
221
221
  let close = showModal({
222
222
  content: <FullscreenModal onCancel={() => {
@@ -18,19 +18,45 @@ import { startTimeParam, endTimeParam } from "../TimeRangeSelector";
18
18
  import { timeInHour } from "socket-function/src/misc";
19
19
  import { ATag, URLOverride } from "../../../library-components/ATag";
20
20
  import { Button } from "../../../library-components/Button";
21
- import { createTicketForError, ticketIdURL } from "../errorTickets/TicketPage";
21
+ import { createTicketForError, findTicketForPattern, goToTicket } from "../errorTickets/TicketPage";
22
22
 
23
23
  function openTicket(datum: LogDatum, pattern: string | undefined) {
24
+ // datum may be synchronized state (props/manager state), which cannot be read inside the async callback — snapshot it while we're still in the synchronized event context.
25
+ let datumSnapshot: LogDatum = JSON.parse(JSON.stringify(datum));
24
26
  Querysub.onCommitFinished(async () => {
25
- let id = await createTicketForError(datum, pattern);
27
+ let id = await createTicketForError(datumSnapshot, pattern);
26
28
  Querysub.commit(() => {
27
- showingManagementURL.value = true;
28
- managementPageURL.value = "TicketPage";
29
- ticketIdURL.value = id;
29
+ goToTicket(id);
30
30
  });
31
31
  });
32
32
  }
33
33
 
34
+ // Renders "Ticket" (create + navigate) or, when a ticket already exists for this suppression pattern, "Go to Ticket" — duplicate tickets for the same pattern are pointless.
35
+ function TicketButton(props: {
36
+ getDatum: () => LogDatum;
37
+ pattern: string | undefined;
38
+ stopClickPropagation?: boolean;
39
+ }) {
40
+ let existingTicket = findTicketForPattern(props.pattern);
41
+ return <Button
42
+ hue={210}
43
+ onClick={(e) => {
44
+ if (props.stopClickPropagation) {
45
+ e.preventDefault();
46
+ e.stopPropagation();
47
+ }
48
+ if (existingTicket) {
49
+ goToTicket(existingTicket.id);
50
+ } else {
51
+ openTicket(props.getDatum(), props.pattern);
52
+ }
53
+ }}
54
+ >
55
+ {existingTicket && `Go to Ticket (${existingTicket.state})` || "Ticket"}
56
+ </Button>;
57
+ }
58
+ qreact.inline(TicketButton);
59
+
34
60
  let getMatcher = cacheLimited(100, (pattern: string) =>
35
61
  createMatchesPattern(Buffer.from(pattern), false)
36
62
  );
@@ -166,18 +192,11 @@ export class LogDatumRenderer extends qreact.Component<{
166
192
  >
167
193
  Not a Bug
168
194
  </Button>
169
- <Button
170
- hue={210}
171
- onClick={(e) => {
172
- if (this.props.inlineMode) {
173
- e.preventDefault();
174
- e.stopPropagation();
175
- }
176
- openTicket(datum, timedOutMatches[0].pattern);
177
- }}
178
- >
179
- Ticket
180
- </Button>
195
+ <TicketButton
196
+ getDatum={() => datum}
197
+ pattern={timedOutMatches[0].pattern}
198
+ stopClickPropagation={this.props.inlineMode}
199
+ />
181
200
  </div>
182
201
  )}
183
202
 
@@ -436,19 +455,21 @@ class SuppressionItem extends qreact.Component<{
436
455
  >
437
456
  Not a Bug
438
457
  </Button>
439
- <Button
440
- hue={210}
441
- onClick={() => {
442
- let datum = this.props.matchData?.examples[0] || {
458
+ <TicketButton
459
+ getDatum={() => {
460
+ let example = this.props.matchData?.examples[0];
461
+ if (example) return example;
462
+ // No example recorded for this suppression — find any live error matching the pattern, so the ticket stores a real, full log entry instead of a synthetic stub.
463
+ let matcher = getMatcher(suppression.pattern);
464
+ let fallback = this.manager.state.unmatchedErrors.find(e => matcher(Buffer.from(JSON.stringify(e))));
465
+ return fallback || {
443
466
  time: Date.now(),
444
467
  __LOG_TYPE: "error",
445
468
  param0: suppression.pattern,
446
469
  };
447
- openTicket(datum, suppression.pattern);
448
470
  }}
449
- >
450
- Ticket
451
- </Button>
471
+ pattern={suppression.pattern}
472
+ />
452
473
  <Button
453
474
  hue={30}
454
475
  onClick={() => {
@@ -1,34 +1,74 @@
1
+ import preact from "preact";
1
2
  import { qreact } from "../../../4-dom/qreact";
2
- import { t } from "../../../2-proxy/schema2";
3
3
  import { css } from "typesafecss";
4
+ import { SocketFunction } from "socket-function/SocketFunction";
4
5
  import { ATag } from "../../../library-components/ATag";
5
6
  import { managementPageURL, showingManagementURL } from "../../managementPages";
6
7
  import { LogDatumRenderer } from "./ErrorNotificationPage";
7
8
  import { getErrorNotificationsManager } from "./errorWatcher";
9
+ import { TicketsController } from "../errorTickets/tickets";
10
+ import { STATE_HUES, ticketFilterURL } from "../errorTickets/TicketPage";
11
+ import { isTicketFinished, TicketState, TICKET_STATES } from "../errorTickets/ticketTypes";
8
12
 
9
13
  export class ErrorWarning extends qreact.Component {
10
14
  manager = getErrorNotificationsManager();
11
15
 
12
- render() {
13
- if (this.manager.state.isLoading) {
14
- return undefined;
16
+ renderTicketCounts() {
17
+ let tickets = TicketsController(SocketFunction.browserNodeId()).getTickets();
18
+ if (!tickets) return undefined;
19
+ let unfinished = tickets.filter(x => !isTicketFinished(x.state));
20
+ if (unfinished.length === 0) return undefined;
21
+
22
+ let counts = new Map<TicketState, number>();
23
+ for (let ticket of unfinished) {
24
+ counts.set(ticket.state, (counts.get(ticket.state) ?? 0) + 1);
15
25
  }
16
26
 
17
- if (this.manager.state.unmatchedErrors.length === 0) {
18
- return undefined;
27
+ return <div className={css.hbox(4).flexShrink0}>
28
+ {TICKET_STATES.filter(state => counts.has(state)).map(state => {
29
+ let hue = STATE_HUES[state];
30
+ return <ATag
31
+ key={state}
32
+ className={
33
+ css.pad2(6, 2).hsl(hue, 60, 85).bord2(hue, 60, 60)
34
+ .colorhsl(hue, 80, 25).fontSize(12).boldStyle.whiteSpace("nowrap")
35
+ .textDecoration("none")
36
+ }
37
+ values={[
38
+ showingManagementURL.getOverride(true),
39
+ managementPageURL.getOverride("TicketPage"),
40
+ ticketFilterURL.getOverride("unfinished"),
41
+ ]}
42
+ >
43
+ {counts.get(state)} {state}
44
+ </ATag>;
45
+ })}
46
+ </div>;
47
+ }
48
+
49
+ render() {
50
+ let ticketCounts = this.renderTicketCounts();
51
+
52
+ let errorWarning: preact.ComponentChild = undefined;
53
+ if (!this.manager.state.isLoading && this.manager.state.unmatchedErrors.length > 0) {
54
+ let firstError = this.manager.state.unmatchedErrors[0];
55
+ errorWarning = <div className={css.hbox(4).alignItems("start").hsl(0, 0, 90).bord2(0, 0, 85).pad2(4, 2).hslcolor(0, 0, 0)}>
56
+ <ATag className={css.paddingTop(5).flexShrink0} values={[
57
+ showingManagementURL.getOverride(true),
58
+ managementPageURL.getOverride("ErrorNotificationPage"),
59
+ ]}>
60
+ Suppress
61
+ </ATag>
62
+ <div className={css.paddingTop(5)}>|</div>
63
+ <LogDatumRenderer datum={firstError} inlineMode />
64
+ </div>;
19
65
  }
20
66
 
21
- let firstError = this.manager.state.unmatchedErrors[0];
22
-
23
- return <div className={css.hbox(4).alignItems("start").hsl(0, 0, 90).bord2(0, 0, 85).pad2(4, 2).hslcolor(0, 0, 0)}>
24
- <ATag className={css.paddingTop(5).flexShrink0} values={[
25
- showingManagementURL.getOverride(true),
26
- managementPageURL.getOverride("ErrorNotificationPage"),
27
- ]}>
28
- Suppress
29
- </ATag>
30
- <div className={css.paddingTop(5)}>|</div>
31
- <LogDatumRenderer datum={firstError} inlineMode />
67
+ if (!ticketCounts && !errorWarning) return undefined;
68
+
69
+ return <div className={css.hbox(8).alignItems("start")}>
70
+ {ticketCounts}
71
+ {errorWarning}
32
72
  </div>;
33
73
  }
34
74
  }
@@ -10,18 +10,24 @@ import { URLParam } from "../../../library-components/URLParam";
10
10
  import { ATag } from "../../../library-components/ATag";
11
11
  import { Button } from "../../../library-components/Button";
12
12
  import { LogDatum } from "../diskLogger";
13
- import { TicketsController } from "./tickets";
14
- import { Ticket, TicketComment, TicketPatchFile, TicketState, TICKET_STATES } from "./ticketTypes";
13
+ import { managementPageURL, showingManagementURL } from "../../managementPages";
14
+ import { TicketsController, watchTickets } from "./tickets";
15
+ import { isTicketFinished, Ticket, TicketComment, TicketPatchFile, TicketState, TICKET_STATES } from "./ticketTypes";
15
16
 
16
17
  export const ticketIdURL = new URLParam("ticketid", "");
18
+ // "unfinished" hides tickets in a final state (fixed / not-a-bug).
19
+ export const ticketFilterURL = new URLParam("ticketfilter", "");
17
20
 
18
21
  const TITLE_MAX_LENGTH = 200;
22
+ const COMMENT_TEXTAREA_MIN_HEIGHT = 250;
23
+ const COMMENT_TEXTAREA_COLLAPSED_HEIGHT = 40;
19
24
 
20
- const STATE_HUES: Record<TicketState, number> = {
25
+ export const STATE_HUES: Record<TicketState, number> = {
21
26
  "investigation": 45,
22
27
  "code-change": 210,
23
28
  "fixed": 120,
24
29
  "not-a-bug": 280,
30
+ "confused": 330,
25
31
  "timed-out": 0,
26
32
  };
27
33
 
@@ -35,6 +41,28 @@ function resetTicketData() {
35
41
  controller.getTicket.resetAll();
36
42
  }
37
43
 
44
+ // Must be called in a synchronized context (e.g. an event callback or Querysub.commit).
45
+ export function goToTicket(ticketId: string) {
46
+ showingManagementURL.value = true;
47
+ managementPageURL.value = "TicketPage";
48
+ ticketIdURL.value = ticketId;
49
+ }
50
+
51
+ // Reactive: returns undefined while the ticket list is still loading. When several tickets share the pattern, returns the most recently updated one.
52
+ export function findTicketForPattern(pattern: string | undefined): Ticket | undefined {
53
+ if (!pattern) return undefined;
54
+ let tickets = getController().getTickets();
55
+ if (!tickets) return undefined;
56
+ let best: Ticket | undefined = undefined;
57
+ for (let ticket of tickets) {
58
+ if (ticket.suppressionPattern !== pattern) continue;
59
+ if (!best || ticket.lastUpdatedTime > best.lastUpdatedTime) {
60
+ best = ticket;
61
+ }
62
+ }
63
+ return best;
64
+ }
65
+
38
66
  export async function createTicketForError(datum: LogDatum, suppressionPattern?: string): Promise<string> {
39
67
  let now = Date.now();
40
68
  let title = (datum.param0 && String(datum.param0) || "(no message)").slice(0, TITLE_MAX_LENGTH);
@@ -78,23 +106,22 @@ class HighlightedCode extends qreact.Component<{
78
106
  });
79
107
 
80
108
  componentDidMount() {
109
+ // Props are synchronized state, so read them here and not after an await.
110
+ let code = this.props.code;
111
+ let file = this.props.file;
81
112
  void (async () => {
82
- try {
83
- await ensureHljsLoaded();
84
- let hljs = (window as any).hljs;
85
- let extension = this.props.file?.split(".").pop() || "";
86
- let html: string;
87
- if (extension && hljs.getLanguage(extension)) {
88
- html = hljs.highlight(this.props.code, { language: extension }).value;
89
- } else {
90
- html = hljs.highlightAuto(this.props.code).value;
91
- }
92
- Querysub.commit(() => {
93
- this.state.html = html;
94
- });
95
- } catch (e) {
96
- console.error(`Failed to highlight code:`, (e as Error).stack ?? e);
113
+ await ensureHljsLoaded();
114
+ let hljs = (window as any).hljs;
115
+ let extension = file?.split(".").pop() || "";
116
+ let html: string;
117
+ if (extension && hljs.getLanguage(extension)) {
118
+ html = hljs.highlight(code, { language: extension }).value;
119
+ } else {
120
+ html = hljs.highlightAuto(code).value;
97
121
  }
122
+ Querysub.commit(() => {
123
+ this.state.html = html;
124
+ });
98
125
  })();
99
126
  }
100
127
 
@@ -130,6 +157,23 @@ function StateBadge(props: { state: TicketState }) {
130
157
  qreact.inline(StateBadge);
131
158
 
132
159
  export class TicketPage extends qreact.Component {
160
+ unwatch: (() => void) | undefined = undefined;
161
+
162
+ componentDidMount() {
163
+ void watchTickets(() => {
164
+ // refresh (not reset) so the current data stays on screen while the refetch happens.
165
+ let controller = getController();
166
+ controller.getTickets.refreshAll();
167
+ controller.getTicket.refreshAll();
168
+ }).then(unwatch => {
169
+ this.unwatch = unwatch;
170
+ });
171
+ }
172
+
173
+ componentWillUnmount() {
174
+ this.unwatch?.();
175
+ }
176
+
133
177
  render() {
134
178
  if (ticketIdURL.value) {
135
179
  return <TicketDetail ticketId={ticketIdURL.value} />;
@@ -148,12 +192,16 @@ class TicketList extends qreact.Component {
148
192
  </div>;
149
193
  }
150
194
 
151
- let sorted = [...tickets];
195
+ let showUnfinishedOnly = ticketFilterURL.value === "unfinished";
196
+ let sorted = showUnfinishedOnly && tickets.filter(x => !isTicketFinished(x.state)) || [...tickets];
152
197
  sort(sorted, x => -x.lastUpdatedTime);
153
198
 
154
- return <div className={css.vbox(16).pad2(16).fillWidth}>
199
+ return <div className={css.vbox(16).pad2(16).fillBoth.minHeight(0)}>
155
200
  <div className={css.hbox(16)}>
156
- <h2>Tickets ({sorted.length})</h2>
201
+ <h2>Tickets ({sorted.length}{showUnfinishedOnly && " unfinished" || ""})</h2>
202
+ <ATag values={[ticketFilterURL.getOverride(showUnfinishedOnly && "" || "unfinished")]}>
203
+ {showUnfinishedOnly && "Show All" || "Show Unfinished Only"}
204
+ </ATag>
157
205
  <Button
158
206
  hue={200}
159
207
  onClick={() => {
@@ -163,13 +211,18 @@ class TicketList extends qreact.Component {
163
211
  Refresh
164
212
  </Button>
165
213
  </div>
214
+ <div className={css.hbox(8).alignItems("center")}>
215
+ <span>Run</span>
216
+ <code className={css.pad2(8, 4).hsl(220, 15, 15).colorhsl(120, 60, 70).fontSize(13).borderRadius(3)}>yarn autofix</code>
217
+ <span>to automatically investigate and fix open tickets.</span>
218
+ </div>
166
219
  {sorted.length === 0 && <div>No tickets. Create one from the Error Notifications page with the "Ticket" button.</div>}
167
- <div className={css.vbox(8)}>
220
+ <div className={css.vbox(8).fillWidth.flexGrow(1).minHeight(0).overflowAuto}>
168
221
  {sorted.map(ticket => (
169
222
  <ATag
170
223
  key={ticket.id}
171
224
  values={[ticketIdURL.getOverride(ticket.id)]}
172
- className={css.hbox(16).pad2(12).bord2(200, 30, 70).hsl(0, 0, 96).alignItems("center").textDecoration("none")}
225
+ className={css.hbox(16).pad2(12).bord2(200, 30, 70).hsl(0, 0, 96).alignItems("center").textDecoration("none").fillWidth}
173
226
  >
174
227
  <StateBadge state={ticket.state} />
175
228
  <span className={css.ellipsis.flexFillWidth.colorhsl(0, 0, 10).boldStyle}>{ticket.title}</span>
@@ -206,8 +259,7 @@ class TicketDetail extends qreact.Component<{ ticketId: string }> {
206
259
 
207
260
  let sortedComments = [...ticket.comments];
208
261
  sort(sortedComments, x => x.time);
209
-
210
- return <div className={css.vbox(16).pad2(16).fillWidth.maxWidth("100%")}>
262
+ return <div className={css.vbox(16).pad2(16).fillBoth.maxWidth("100%").minHeight(0)}>
211
263
  <div className={css.hbox(16).alignItems("center")}>
212
264
  <ATag values={[ticketIdURL.getOverride("")]}>← All Tickets</ATag>
213
265
  <StateBadge state={ticket.state} />
@@ -222,8 +274,9 @@ class TicketDetail extends qreact.Component<{ ticketId: string }> {
222
274
  hue={STATE_HUES[state]}
223
275
  disabled={state === ticket!.state}
224
276
  onClick={() => {
277
+ let ticketId = this.props.ticketId;
225
278
  Querysub.onCommitFinished(async () => {
226
- await getController().setTicketState.promise(this.props.ticketId, state);
279
+ await getController().setTicketState.promise(ticketId, state);
227
280
  resetTicketData();
228
281
  });
229
282
  }}
@@ -235,8 +288,9 @@ class TicketDetail extends qreact.Component<{ ticketId: string }> {
235
288
  hue={0}
236
289
  onClick={() => {
237
290
  if (!confirm(`Delete this ticket "${ticket!.title}"?`)) return;
291
+ let ticketId = this.props.ticketId;
238
292
  Querysub.onCommitFinished(async () => {
239
- await getController().deleteTicket.promise(this.props.ticketId);
293
+ await getController().deleteTicket.promise(ticketId);
240
294
  resetTicketData();
241
295
  Querysub.commit(() => {
242
296
  ticketIdURL.value = "";
@@ -248,7 +302,7 @@ class TicketDetail extends qreact.Component<{ ticketId: string }> {
248
302
  </Button>
249
303
  </div>
250
304
 
251
- <div className={css.vbox(8).pad2(12).bord2(0, 50, 70).hsl(0, 30, 96)}>
305
+ <div className={css.vbox(8).pad2(12).bord2(0, 50, 70).hsl(0, 30, 96).fillWidth}>
252
306
  <div
253
307
  className={css.hbox(8).button}
254
308
  onClick={() => {
@@ -266,7 +320,7 @@ class TicketDetail extends qreact.Component<{ ticketId: string }> {
266
320
  </div>
267
321
  )}
268
322
  {this.state.showingErrorInfo && (
269
- <div className={css.vbox(4).pad2(8).hsl(0, 0, 98).bord2(0, 0, 85)}>
323
+ <div className={css.vbox(4).pad2(8).hsl(0, 0, 98).bord2(0, 0, 85).fillWidth}>
270
324
  {Object.entries(ticket.errorDatum).map(([key, value]) => (
271
325
  <div key={key} className={css.hbox(8)}>
272
326
  <strong className={css.minWidth(120)}>{key}:</strong>
@@ -277,17 +331,17 @@ class TicketDetail extends qreact.Component<{ ticketId: string }> {
277
331
  )}
278
332
  </div>
279
333
 
280
- <div className={css.vbox(12)}>
334
+ <div className={css.vbox(12).fillWidth.flexGrow(1).minHeight(0).overflowAuto}>
281
335
  <h3>Comments ({sortedComments.length})</h3>
282
336
  {sortedComments.map(comment => (
283
337
  <TicketCommentItem key={comment.id} ticketId={this.props.ticketId} comment={comment} />
284
338
  ))}
285
339
  </div>
286
340
 
287
- <div className={css.vbox(8).pad2(12).bord2(120, 50, 60).hsl(120, 50, 96)}>
341
+ <div className={css.vbox(8).pad2(12).bord2(120, 50, 60).hsl(120, 50, 96).fillWidth}>
288
342
  <strong>Add Comment</strong>
289
343
  <textarea
290
- className={css.minHeight(80).fillWidth.pad2(8)}
344
+ className={css.minHeight(COMMENT_TEXTAREA_COLLAPSED_HEIGHT).minHeight(COMMENT_TEXTAREA_MIN_HEIGHT, "focus").fillWidth.pad2(8).fontSize(14).resize("vertical")}
291
345
  value={this.state.newCommentText}
292
346
  onInput={e => {
293
347
  this.state.newCommentText = (e.target as HTMLTextAreaElement).value;
@@ -299,6 +353,7 @@ class TicketDetail extends qreact.Component<{ ticketId: string }> {
299
353
  onClick={() => {
300
354
  let text = this.state.newCommentText.trim();
301
355
  if (!text) return;
356
+ let ticketId = this.props.ticketId;
302
357
  Querysub.onCommitFinished(async () => {
303
358
  let comment: TicketComment = {
304
359
  id: nextId(),
@@ -307,7 +362,7 @@ class TicketDetail extends qreact.Component<{ ticketId: string }> {
307
362
  kind: "text",
308
363
  text,
309
364
  };
310
- await getController().addComment.promise(this.props.ticketId, comment);
365
+ await getController().addComment.promise(ticketId, comment);
311
366
  resetTicketData();
312
367
  Querysub.commit(() => {
313
368
  this.state.newCommentText = "";
@@ -323,6 +378,45 @@ class TicketDetail extends qreact.Component<{ ticketId: string }> {
323
378
  }
324
379
  }
325
380
 
381
+ // Pulls the interesting parts out of a searchLogs tool-call comment so they can be shown inline. startTime/endTime may be epoch ms or strings — either way the Date constructor parses them.
382
+ function parseSearchLogsInfo(comment: TicketComment): {
383
+ rangeText: string;
384
+ resultsText: string;
385
+ machine: string;
386
+ query: string;
387
+ } | undefined {
388
+ if (comment.kind !== "tool-call" || comment.toolName !== "searchLogs") return undefined;
389
+ try {
390
+ let input = JSON.parse(comment.toolInput || "{}") as Record<string, unknown>;
391
+
392
+ function formatBound(value: unknown): string {
393
+ let time = new Date(value as string | number).getTime();
394
+ if (Number.isFinite(time)) return formatDateTime(time);
395
+ return String(value);
396
+ }
397
+ let rangeText = `${formatBound(input.startTime)} → ${formatBound(input.endTime)}`;
398
+
399
+ let resultsText = "error";
400
+ try {
401
+ let output = JSON.parse(comment.toolOutput || "") as { results?: unknown[]; limitHit?: boolean };
402
+ if (Array.isArray(output.results)) {
403
+ resultsText = `${output.results.length}${output.limitHit && "+" || ""} results`;
404
+ }
405
+ } catch {
406
+ // Output was an error message, not JSON.
407
+ }
408
+
409
+ return {
410
+ rangeText,
411
+ resultsText,
412
+ machine: String(input.machine ?? ""),
413
+ query: String(input.query ?? ""),
414
+ };
415
+ } catch {
416
+ return undefined;
417
+ }
418
+ }
419
+
326
420
  class TicketCommentItem extends qreact.Component<{
327
421
  ticketId: string;
328
422
  comment: TicketComment;
@@ -340,8 +434,9 @@ class TicketCommentItem extends qreact.Component<{
340
434
  if (comment.kind === "tool-call") {
341
435
  hue = 0;
342
436
  }
437
+ let searchInfo = parseSearchLogsInfo(comment);
343
438
 
344
- return <div className={css.vbox(8).pad2(12).bord2(hue, 30, 70).hsl(hue, 20, 97)}>
439
+ return <div className={css.vbox(8).pad2(12).bord2(hue, 30, 70).hsl(hue, 20, 97).fillWidth}>
345
440
  <div className={css.hbox(12).alignItems("center")}>
346
441
  <strong>{comment.author}</strong>
347
442
  <span className={css.colorhsl(0, 0, 40).fontSize(12)}>{formatDateTime(comment.time)}</span>
@@ -369,18 +464,28 @@ class TicketCommentItem extends qreact.Component<{
369
464
  flavor="small"
370
465
  onClick={() => {
371
466
  if (!confirm(`Delete this comment?`)) return;
467
+ let ticketId = this.props.ticketId;
468
+ let commentId = comment.id;
372
469
  Querysub.onCommitFinished(async () => {
373
- await getController().deleteComment.promise(this.props.ticketId, comment.id);
470
+ await getController().deleteComment.promise(ticketId, commentId);
374
471
  resetTicketData();
375
472
  });
376
473
  }}
377
474
  >
378
475
  Delete
379
476
  </Button>
477
+ {searchInfo && (
478
+ <>
479
+ <span className={css.fontSize(12).whiteSpace("nowrap")}>{searchInfo.rangeText}</span>
480
+ <span className={css.fontSize(12).whiteSpace("nowrap").boldStyle}>{searchInfo.resultsText}</span>
481
+ <span className={css.fontSize(12).whiteSpace("nowrap").colorhsl(0, 0, 40)}>{searchInfo.machine}</span>
482
+ <span className={css.fontSize(12).ellipsis.colorhsl(210, 60, 35)}>{searchInfo.query}</span>
483
+ </>
484
+ )}
380
485
  </div>
381
486
 
382
487
  {comment.kind === "tool-call" && (
383
- <div className={css.vbox(8)}>
488
+ <div className={css.vbox(8).fillWidth}>
384
489
  <div
385
490
  className={css.hbox(8).button}
386
491
  onClick={() => {
@@ -395,7 +500,7 @@ class TicketCommentItem extends qreact.Component<{
395
500
  <span className={css.ellipsis.colorhsl(0, 0, 40).fontSize(12)}>{comment.toolInput}</span>
396
501
  </div>
397
502
  {this.state.expandedTool && (
398
- <div className={css.vbox(8)}>
503
+ <div className={css.vbox(8).fillWidth}>
399
504
  <strong>Input</strong>
400
505
  <pre className={css.pad2(8).hsl(0, 0, 92).whiteSpace("pre-wrap").overflowWrap("break-word").margin(0).fontSize(12).maxHeight(200).overflowAuto}>{comment.toolInput}</pre>
401
506
  <strong>Output</strong>
@@ -410,9 +515,9 @@ class TicketCommentItem extends qreact.Component<{
410
515
  )}
411
516
 
412
517
  {this.state.editing && (
413
- <div className={css.vbox(8)}>
518
+ <div className={css.vbox(8).fillWidth}>
414
519
  <textarea
415
- className={css.minHeight(80).fillWidth.pad2(8)}
520
+ className={css.minHeight(COMMENT_TEXTAREA_MIN_HEIGHT).fillWidth.pad2(8).fontSize(14).resize("vertical")}
416
521
  value={this.state.editText}
417
522
  onInput={e => {
418
523
  this.state.editText = (e.target as HTMLTextAreaElement).value;
@@ -423,8 +528,10 @@ class TicketCommentItem extends qreact.Component<{
423
528
  hue={120}
424
529
  onClick={() => {
425
530
  let text = this.state.editText.trim();
531
+ let ticketId = this.props.ticketId;
532
+ let commentId = comment.id;
426
533
  Querysub.onCommitFinished(async () => {
427
- await getController().updateCommentText.promise(this.props.ticketId, comment.id, text);
534
+ await getController().updateCommentText.promise(ticketId, commentId, text);
428
535
  resetTicketData();
429
536
  Querysub.commit(() => {
430
537
  this.state.editing = false;
@@ -446,7 +553,7 @@ class TicketCommentItem extends qreact.Component<{
446
553
  )}
447
554
 
448
555
  {comment.kind === "patch" && comment.patchFiles && (
449
- <div className={css.vbox(12)}>
556
+ <div className={css.vbox(12).fillWidth}>
450
557
  {comment.patchFiles.map((patchFile, idx) => (
451
558
  <PatchFileView key={idx} patchFile={patchFile} />
452
559
  ))}
@@ -456,12 +563,10 @@ class TicketCommentItem extends qreact.Component<{
456
563
  <Button
457
564
  hue={120}
458
565
  onClick={() => {
566
+ let ticketId = this.props.ticketId;
567
+ let commentId = comment.id;
459
568
  Querysub.onCommitFinished(async () => {
460
- try {
461
- await getController().applyPatch.promise(this.props.ticketId, comment.id);
462
- } catch (e) {
463
- alert(`Failed to apply patch: ${(e as Error).message}`);
464
- }
569
+ await getController().applyPatch.promise(ticketId, commentId);
465
570
  resetTicketData();
466
571
  });
467
572
  }}
@@ -471,8 +576,10 @@ class TicketCommentItem extends qreact.Component<{
471
576
  <Button
472
577
  hue={0}
473
578
  onClick={() => {
579
+ let ticketId = this.props.ticketId;
580
+ let commentId = comment.id;
474
581
  Querysub.onCommitFinished(async () => {
475
- await getController().setPatchStatus.promise(this.props.ticketId, comment.id, "rejected");
582
+ await getController().setPatchStatus.promise(ticketId, commentId, "rejected");
476
583
  resetTicketData();
477
584
  });
478
585
  }}
@@ -501,10 +608,10 @@ class TicketCommentItem extends qreact.Component<{
501
608
  class PatchFileView extends qreact.Component<{ patchFile: TicketPatchFile }> {
502
609
  render() {
503
610
  let patchFile = this.props.patchFile;
504
- return <div className={css.vbox(4)}>
611
+ return <div className={css.vbox(4).fillWidth}>
505
612
  <strong className={css.fontSize(13)}>{patchFile.file}</strong>
506
613
  {patchFile.oldText && (
507
- <div className={css.vbox(2)}>
614
+ <div className={css.vbox(2).fillWidth}>
508
615
  <span className={css.colorhsl(0, 70, 40).fontSize(12).boldStyle}>- Old</span>
509
616
  <HighlightedCode code={patchFile.oldText} file={patchFile.file} />
510
617
  </div>
@@ -5,9 +5,11 @@ import * as path from "path";
5
5
  import { spawn, ChildProcess } from "child_process";
6
6
  import { nextId, timeInHour, timeInMinute } from "socket-function/src/misc";
7
7
  import { runInfinitePollCallAtStart, runInSerial } from "socket-function/src/batching";
8
- import { formatTime } from "socket-function/src/formatting/format";
8
+ import { formatNumber, formatTime } from "socket-function/src/formatting/format";
9
+ import { blue, green } from "socket-function/src/formatting/logColors";
9
10
  import { SocketFunction } from "socket-function/SocketFunction";
10
- import { isPublic } from "../../../config";
11
+ import { decodeNodeId } from "sliftutils/misc/https/certs";
12
+ import { getDomain, isPublic } from "../../../config";
11
13
  import { timeoutToUndefinedSilent } from "../../../errors";
12
14
  import { getAllNodeIds } from "../../../-f-node-discovery/NodeDiscovery";
13
15
  import { getControllerNodeId, NodeCapabilitiesController } from "../../../-g-core-values/NodeCapabilities";
@@ -16,7 +18,7 @@ import { AutoFixerControllerBase, setAutoFixerHandlers } from "./autoFixerContro
16
18
  import { TicketServiceBase } from "./tickets";
17
19
  import { Ticket, TicketComment, TicketPatchFile, TicketState } from "./ticketTypes";
18
20
 
19
- const INVESTIGATION_TIMEOUT = timeInMinute * 20;
21
+ const INVESTIGATION_TIMEOUT = timeInMinute * 60;
20
22
  // "Files modified" check on tickets we already know are open.
21
23
  const OPEN_TICKET_POLL_INTERVAL = timeInMinute * 30;
22
24
  // Full listing, to discover tickets we've never seen (in case a change notification was missed).
@@ -24,6 +26,7 @@ const ALL_TICKETS_POLL_INTERVAL = timeInHour;
24
26
  // Re-registering is cheap and the service loses its watcher set when it restarts.
25
27
  const REGISTER_POLL_INTERVAL = timeInMinute * 5;
26
28
  const NODE_INFO_TIMEOUT_MS = 5000;
29
+ const STATUS_LOG_INTERVAL = timeInMinute;
27
30
  const TOOL_SERVER_PORT = 4517;
28
31
  const MAX_TOOL_OUTPUT_STORED = 20_000;
29
32
  const MAX_PRIOR_COMMENTS_PROMPT_CHARS = 30_000;
@@ -53,6 +56,44 @@ async function ticketService() {
53
56
 
54
57
  // The ticket the current claude run is bound to. Runs are serialized, so a module-level value is safe.
55
58
  let currentTicketId: string | undefined = undefined;
59
+ let currentTicketTitle: string | undefined = undefined;
60
+ let currentTicketStartTime = 0;
61
+
62
+ // inputTokens is uncached input only — cache reads/writes are tracked separately, otherwise cache reads massively inflate the input number.
63
+ type TokenUsage = {
64
+ inputTokens: number;
65
+ outputTokens: number;
66
+ cacheReadTokens: number;
67
+ cacheWriteTokens: number;
68
+ };
69
+ function emptyTokens(): TokenUsage {
70
+ return { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
71
+ }
72
+ function addTokens(into: TokenUsage, from: TokenUsage) {
73
+ into.inputTokens += from.inputTokens;
74
+ into.outputTokens += from.outputTokens;
75
+ into.cacheReadTokens += from.cacheReadTokens;
76
+ into.cacheWriteTokens += from.cacheWriteTokens;
77
+ }
78
+ let runTokens = emptyTokens();
79
+ let totalTokens = emptyTokens();
80
+
81
+ function formatTokens(tokens: TokenUsage) {
82
+ return `${formatNumber(tokens.inputTokens)} in / ${formatNumber(tokens.outputTokens)} out (cache: ${formatNumber(tokens.cacheReadTokens)} read, ${formatNumber(tokens.cacheWriteTokens)} write)`;
83
+ }
84
+
85
+ function logStatus() {
86
+ let parts: string[] = [];
87
+ parts.push(`${knownOpenTicketIds.size} open ticket(s) to fix`);
88
+ if (currentTicketId) {
89
+ parts.push(`working on "${currentTicketTitle}" (${currentTicketId}) for ${formatTime(Date.now() - currentTicketStartTime)}`);
90
+ parts.push(`run tokens: ${formatTokens(runTokens)}`);
91
+ } else {
92
+ parts.push(`idle`);
93
+ }
94
+ parts.push(`total tokens: ${formatTokens(totalTokens)}`);
95
+ console.log(`AutoFixer status: ${parts.join(" | ")}`);
96
+ }
56
97
  // Set when the AI calls setTicketState during a run, so we know not to auto-transition afterwards.
57
98
  let stateChangedDuringRun = false;
58
99
  // Set when the AI adds a patch during a run.
@@ -188,7 +229,7 @@ Query syntax (case-insensitive substring match by default):
188
229
  type: "object",
189
230
  properties: {
190
231
  query: { type: "string" },
191
- machine: { type: "string", description: "machineId, or \"local\" for the machine running this server" },
232
+ machine: { type: "string", description: "machineId (get one from listNodes, or from the error's __machineId field), or \"local\" for the machine running this server. A nodeId is also accepted and converted to its machineId." },
192
233
  startTime: { type: ["number", "string"], description: "epoch ms or any string Date can parse (no-tz strings are local time)" },
193
234
  endTime: { type: ["number", "string"], description: "epoch ms or any string Date can parse; must be at least 1 minute in the past" },
194
235
  direction: { type: "string", enum: ["fromStart", "fromEnd"] },
@@ -201,7 +242,7 @@ Query syntax (case-insensitive substring match by default):
201
242
  },
202
243
  {
203
244
  name: "listNodes",
204
- description: `List every node in the Querysub cluster, with each node's process entry point. Returns an array of { nodeId, entryPoint }.`,
245
+ description: `List every node in the Querysub cluster, with each node's process entry point. Returns an array of { nodeId, machineId, entryPoint }. Use the machineId (NOT the nodeId) as the \`machine\` parameter of searchLogs.`,
205
246
  inputSchema: { type: "object", properties: {} },
206
247
  },
207
248
  {
@@ -240,11 +281,11 @@ Query syntax (case-insensitive substring match by default):
240
281
  },
241
282
  {
242
283
  name: "setTicketState",
243
- description: `Set the state of the ticket you are investigating. Use "code-change" once you have proposed patches, or "not-a-bug" if the error should simply be ignored.`,
284
+ description: `Set the state of the ticket you are investigating. Use "code-change" once you have proposed patches, "not-a-bug" if the error should simply be ignored, or "confused" if you genuinely cannot figure out what is going on.`,
244
285
  inputSchema: {
245
286
  type: "object",
246
287
  properties: {
247
- state: { type: "string", enum: ["code-change", "not-a-bug"] },
288
+ state: { type: "string", enum: ["code-change", "not-a-bug", "confused"] },
248
289
  },
249
290
  required: ["state"],
250
291
  },
@@ -274,11 +315,23 @@ async function getNodeInfos() {
274
315
  return Promise.all(
275
316
  nodes.map(async nodeId => {
276
317
  let metadata = await timeoutToUndefinedSilent(NODE_INFO_TIMEOUT_MS, NodeCapabilitiesController.nodes[nodeId].getMetadata());
277
- return { nodeId, entryPoint: metadata?.entryPoint };
318
+ return {
319
+ nodeId,
320
+ machineId: decodeNodeId(nodeId, getDomain(), "allowMissingThreadId")?.machineId,
321
+ entryPoint: metadata?.entryPoint,
322
+ };
278
323
  }),
279
324
  );
280
325
  }
281
326
 
327
+ // The search only matches files by machineId — a nodeId (or anything else) silently matches nothing, so convert nodeIds for the AI.
328
+ function normalizeMachineParam(machine: string): string {
329
+ if (machine === "local") return machine;
330
+ let decoded = decodeNodeId(machine, getDomain(), "allowMissingThreadId");
331
+ if (decoded?.machineId) return decoded.machineId;
332
+ return machine;
333
+ }
334
+
282
335
  let mcpLogs = new MCPIndexedLogs();
283
336
 
284
337
  async function callTool(toolName: string, args: Record<string, unknown>): Promise<unknown> {
@@ -321,13 +374,15 @@ async function callTool(toolName: string, args: Record<string, unknown>): Promis
321
374
  let errorText: string | undefined = undefined;
322
375
  try {
323
376
  if (toolName === "searchLogs") {
324
- result = await mcpLogs.search(args as Parameters<MCPIndexedLogs["search"]>[0]);
377
+ let searchArgs = { ...args } as Parameters<MCPIndexedLogs["search"]>[0];
378
+ searchArgs.machine = normalizeMachineParam(String(searchArgs.machine ?? ""));
379
+ result = await mcpLogs.search(searchArgs);
325
380
  } else if (toolName === "listNodes") {
326
381
  result = await getNodeInfos();
327
382
  } else if (toolName === "setTicketState") {
328
383
  let state = String(args.state ?? "") as TicketState;
329
- if (state !== "code-change" && state !== "not-a-bug") {
330
- throw new Error(`setTicketState only allows "code-change" or "not-a-bug"`);
384
+ if (state !== "code-change" && state !== "not-a-bug" && state !== "confused") {
385
+ throw new Error(`setTicketState only allows "code-change", "not-a-bug", or "confused"`);
331
386
  }
332
387
  let service = await ticketService();
333
388
  await service.setTicketState(ticketId, state);
@@ -465,7 +520,7 @@ function buildPrompt(ticket: Ticket): string {
465
520
 
466
521
  return `You are an automated bug investigator ("autofixer") working on a ticket created from a production error.
467
522
 
468
- The repository is at ${process.cwd()}. Use Read/Glob/Grep to read the code, and the mcp__autofixer__searchLogs / mcp__autofixer__listNodes tools to search the production logs.
523
+ The application repository is at ${process.cwd()}, and it is built on the querysub framework, whose repository is at ${QUERYSUB_ROOT}. Your job is to fix bugs in BOTH repositories — the bug may be in the application or in querysub itself, so read and patch whichever one the problem actually lives in. Use Read/Glob/Grep to read the code, and the mcp__autofixer__searchLogs / mcp__autofixer__listNodes tools to search the production logs.
469
524
 
470
525
  Work in two phases:
471
526
 
@@ -477,9 +532,9 @@ PHASE 2 — FIX. Only after you have added your diagnosis comment, decide on the
477
532
  1. Downgrading logging: when the "error" is not actually an error, patch the logging call site to downgrade it from an error to a warning or a plain log, so it stops being reported.
478
533
  2. Actually fixing the broken code.
479
534
  3. Gathering more information: if you could NOT determine the root cause from the available logs, propose patches that ADD logging statements to the relevant code paths so the next investigation has the information it needs.
480
- Each patch file entry is { file, oldText, newText }: oldText must be copied exactly from the current file contents and should be unique within the file; it is replaced with newText. An empty oldText creates a new file. Keep patches minimal and follow the style of the surrounding code.
535
+ Each patch file entry is { file, oldText, newText }: oldText must be copied exactly from the current file contents and should be unique within the file; it is replaced with newText. An empty oldText creates a new file. Relative file paths are resolved against the application repository (${process.cwd()}); for files in the querysub repository use absolute paths. Keep patches minimal and follow the style of the surrounding code.
481
536
 
482
- When you are done, call mcp__autofixer__setTicketState with "code-change" if you proposed patches, or "not-a-bug" if the error should simply be ignored without any code change. Do NOT edit files directly — only propose changes through mcp__autofixer__addPatch.
537
+ When you are done, call mcp__autofixer__setTicketState with "code-change" if you proposed patches, or "not-a-bug" if the error should simply be ignored without any code change. If you are confused and just cannot figure out what is going on — the logs and code don't add up, and you can't even propose useful additional logging — add a comment explaining what you tried and what confused you, then call mcp__autofixer__setTicketState with "confused". Do NOT edit files directly — only propose changes through mcp__autofixer__addPatch.
483
538
 
484
539
  ==== TICKET ====
485
540
  Title: ${ticket.title}
@@ -489,10 +544,76 @@ ${ticket.suppressionPattern && `Matched suppression pattern: ${ticket.suppressio
489
544
  ${JSON.stringify(ticket.errorDatum, undefined, 2)}
490
545
 
491
546
  ==== COMMENTS SO FAR (chronological) ====
547
+ This is the ticket's full history: your own comments, patches, and tool calls from prior runs, plus comments from the human user. Comments authored by "user" are from the human — read them carefully and treat them as direct instructions and corrections. They take precedence over the generic instructions above and over your own prior conclusions.
548
+
492
549
  ${priorCommentsText || "(none)"}
493
550
  `;
494
551
  }
495
552
 
553
+ const CLAUDE_LOG_PREVIEW_CHARS = 300;
554
+
555
+ // This file lives at <querysub>/src/diagnostics/logs/errorTickets, so the framework repo root is four levels up — correct whether querysub is a sibling repo or inside node_modules.
556
+ const QUERYSUB_ROOT = path.resolve(__dirname, "../../../..");
557
+
558
+ // Set for the duration of a claude run; called whenever a turn finishes (a stream-json "result" event), so the run loop can decide to forward new user comments or end the session.
559
+ let claudeTurnEndedHandler: (() => void) | undefined = undefined;
560
+
561
+ type ClaudeUsage = {
562
+ input_tokens?: number;
563
+ cache_creation_input_tokens?: number;
564
+ cache_read_input_tokens?: number;
565
+ output_tokens?: number;
566
+ };
567
+
568
+ function usageToTokens(usage: ClaudeUsage): TokenUsage {
569
+ return {
570
+ inputTokens: usage.input_tokens ?? 0,
571
+ outputTokens: usage.output_tokens ?? 0,
572
+ cacheReadTokens: usage.cache_read_input_tokens ?? 0,
573
+ cacheWriteTokens: usage.cache_creation_input_tokens ?? 0,
574
+ };
575
+ }
576
+
577
+ // Parses one stream-json line from claude. Returns true when the line was understood (so the raw JSON isn't echoed).
578
+ function handleClaudeStreamLine(line: string): boolean {
579
+ let event: any;
580
+ try {
581
+ event = JSON.parse(line);
582
+ } catch {
583
+ return false;
584
+ }
585
+ if (!event || typeof event.type !== "string") return false;
586
+
587
+ if (event.type === "assistant") {
588
+ let usage = event.message?.usage as ClaudeUsage | undefined;
589
+ if (usage) {
590
+ addTokens(runTokens, usageToTokens(usage));
591
+ }
592
+ for (let block of event.message?.content ?? []) {
593
+ if (block.type === "text" && block.text) {
594
+ console.log(`[claude] ${String(block.text).slice(0, CLAUDE_LOG_PREVIEW_CHARS)}`);
595
+ } else if (block.type === "tool_use") {
596
+ console.log(`[claude] tool_use: ${blue(String(block.name))} ${JSON.stringify(block.input ?? {}).slice(0, CLAUDE_LOG_PREVIEW_CHARS)}`);
597
+ }
598
+ }
599
+ return true;
600
+ }
601
+ if (event.type === "result") {
602
+ // The result's usage is the authoritative total for the run, replacing our per-message accumulation.
603
+ if (event.usage) {
604
+ runTokens = usageToTokens(event.usage as ClaudeUsage);
605
+ }
606
+ console.log(`[claude] result (${event.subtype ?? "?"}): tokens ${formatTokens(runTokens)}${event.total_cost_usd !== undefined && `, cost $${event.total_cost_usd}` || ""}`);
607
+ claudeTurnEndedHandler?.();
608
+ return true;
609
+ }
610
+ if (event.type === "system" || event.type === "user") {
611
+ // Init/config noise and tool results (which we already record in the ticket).
612
+ return true;
613
+ }
614
+ return false;
615
+ }
616
+
496
617
  function quoteArgForShell(arg: string): string {
497
618
  if (!/[\s"^&|<>()%!;'$`\\]/.test(arg)) return arg;
498
619
  return `"${arg.replace(/"/g, "\\\"")}"`;
@@ -507,7 +628,7 @@ function killChildTree(child: ChildProcess) {
507
628
  }
508
629
  }
509
630
 
510
- async function runClaude(prompt: string): Promise<{ timedOut: boolean; exitCode: number | undefined }> {
631
+ async function runClaude(prompt: string, takeNewUserComments: () => Promise<string[]>): Promise<{ timedOut: boolean; exitCode: number | undefined }> {
511
632
  let mcpConfigPath = path.join(os.tmpdir(), `autofixer-mcp-${process.pid}.json`);
512
633
  fs.writeFileSync(mcpConfigPath, JSON.stringify({
513
634
  mcpServers: {
@@ -520,6 +641,10 @@ async function runClaude(prompt: string): Promise<{ timedOut: boolean; exitCode:
520
641
 
521
642
  let args = [
522
643
  "-p",
644
+ // stream-json output (which requires --verbose in -p mode) gives us per-message token usage for status logging. stream-json input keeps stdin open so we can inject new user comments as user messages between turns.
645
+ "--output-format", "stream-json",
646
+ "--input-format", "stream-json",
647
+ "--verbose",
523
648
  "--mcp-config", mcpConfigPath,
524
649
  "--strict-mcp-config",
525
650
  "--allowedTools", CLAUDE_ALLOWED_TOOLS.join(","),
@@ -534,26 +659,63 @@ async function runClaude(prompt: string): Promise<{ timedOut: boolean; exitCode:
534
659
  child = spawn("claude", args, { stdio: ["pipe", "pipe", "pipe"] });
535
660
  }
536
661
 
537
- child.stdin!.write(prompt);
538
- child.stdin!.end();
662
+ function writeUserMessage(text: string) {
663
+ if (child.exitCode !== null || child.killed) return;
664
+ try {
665
+ child.stdin!.write(JSON.stringify({
666
+ type: "user",
667
+ message: { role: "user", content: [{ type: "text", text }] },
668
+ }) + "\n");
669
+ } catch (e) {
670
+ console.error(`Failed to write user message to claude:`, (e as Error).stack ?? e);
671
+ }
672
+ }
673
+ writeUserMessage(prompt);
674
+
675
+ // After each turn: forward any user comments added to the ticket during the turn as a new user message, otherwise end the session.
676
+ claudeTurnEndedHandler = () => {
677
+ void (async () => {
678
+ let newComments: string[] = [];
679
+ try {
680
+ newComments = await takeNewUserComments();
681
+ } catch (e) {
682
+ console.error(`Failed to check for new user comments:`, (e as Error).stack ?? e);
683
+ }
684
+ if (newComments.length > 0) {
685
+ console.log(green(`Forwarding ${newComments.length} new user comment(s) to the running investigation`));
686
+ writeUserMessage(`The user added the following comment(s) to the ticket while you were working. Take them into account and continue (re-run phases as needed, and remember to set the ticket state when you are done):\n\n${newComments.join("\n\n---\n\n")}`);
687
+ return;
688
+ }
689
+ try {
690
+ child.stdin!.end();
691
+ } catch {
692
+ // Process already exited.
693
+ }
694
+ })();
695
+ };
539
696
 
540
- function forwardLines(stream: NodeJS.ReadableStream, prefix: string) {
697
+ function forwardLines(stream: NodeJS.ReadableStream, prefix: string, onLine?: (line: string) => boolean) {
541
698
  let pending = "";
699
+ function emit(line: string) {
700
+ if (!line.trim()) return;
701
+ if (onLine && onLine(line)) return;
702
+ console.log(`${prefix} ${line}`);
703
+ }
542
704
  stream.on("data", (chunk: Buffer) => {
543
705
  pending += chunk.toString("utf8");
544
706
  let lines = pending.split("\n");
545
707
  pending = lines.pop() ?? "";
546
708
  for (let line of lines) {
547
- console.log(`${prefix} ${line}`);
709
+ emit(line);
548
710
  }
549
711
  });
550
712
  stream.on("end", () => {
551
713
  if (pending) {
552
- console.log(`${prefix} ${pending}`);
714
+ emit(pending);
553
715
  }
554
716
  });
555
717
  }
556
- forwardLines(child.stdout!, "[claude]");
718
+ forwardLines(child.stdout!, "[claude]", line => handleClaudeStreamLine(line));
557
719
  forwardLines(child.stderr!, "[claude:err]");
558
720
 
559
721
  let timedOut = false;
@@ -570,15 +732,19 @@ async function runClaude(prompt: string): Promise<{ timedOut: boolean; exitCode:
570
732
  });
571
733
  child.on("exit", code => resolve(code ?? undefined));
572
734
  });
735
+ claudeTurnEndedHandler = undefined;
573
736
  clearTimeout(timeout);
574
737
  return { timedOut, exitCode };
575
738
  }
576
739
 
577
740
  async function runInvestigation(ticket: Ticket): Promise<void> {
578
- console.log(`AutoFixer starting investigation of ticket ${ticket.id}: ${ticket.title}`);
741
+ console.log(green(`AutoFixer starting investigation of ticket ${ticket.id}: ${ticket.title}`));
579
742
  let service = await ticketService();
580
743
 
581
744
  currentTicketId = ticket.id;
745
+ currentTicketTitle = ticket.title;
746
+ currentTicketStartTime = Date.now();
747
+ runTokens = emptyTokens();
582
748
  stateChangedDuringRun = false;
583
749
  patchAddedDuringRun = false;
584
750
  try {
@@ -587,7 +753,23 @@ async function runInvestigation(ticket: Ticket): Promise<void> {
587
753
  text: `Starting automated investigation (timeout ${formatTime(INVESTIGATION_TIMEOUT)}).`,
588
754
  });
589
755
 
590
- let { timedOut, exitCode } = await runClaude(buildPrompt(ticket));
756
+ // Any text comment that appears on the ticket during the run and wasn't written by us is a user comment — forwarded into the claude session between turns.
757
+ let seenCommentIds = new Set(ticket.comments.map(c => c.id));
758
+ async function takeNewUserComments(): Promise<string[]> {
759
+ let updated = await service.getTicket(ticket.id);
760
+ if (!updated) return [];
761
+ let newComments = updated.comments.filter(c =>
762
+ c.kind === "text"
763
+ && c.author !== AUTOFIXER_AUTHOR
764
+ && !seenCommentIds.has(c.id)
765
+ );
766
+ for (let comment of updated.comments) {
767
+ seenCommentIds.add(comment.id);
768
+ }
769
+ return newComments.map(c => c.text);
770
+ }
771
+
772
+ let { timedOut, exitCode } = await runClaude(buildPrompt(ticket), takeNewUserComments);
591
773
 
592
774
  if (timedOut) {
593
775
  await addTicketComment(ticket.id, {
@@ -612,12 +794,14 @@ async function runInvestigation(ticket: Ticket): Promise<void> {
612
794
  } else {
613
795
  await addTicketComment(ticket.id, {
614
796
  kind: "text",
615
- text: `Automated investigation ended (claude exit code ${exitCode}) without a diagnosis, patches, or a state change. Marking the ticket timed-out so it is not retried automatically — set it back to investigation to retry.`,
797
+ text: `Automated investigation ended (claude exit code ${exitCode}) without a diagnosis, patches, or a state change. Marking the ticket confused so it is not retried automatically — set it back to investigation to retry.`,
616
798
  });
617
- await service.setTicketState(ticket.id, "timed-out");
799
+ await service.setTicketState(ticket.id, "confused");
618
800
  }
619
801
  } finally {
802
+ addTokens(totalTokens, runTokens);
620
803
  currentTicketId = undefined;
804
+ currentTicketTitle = undefined;
621
805
  }
622
806
  }
623
807
 
@@ -629,6 +813,7 @@ export async function runAutoFixer(): Promise<void> {
629
813
  void runInfinitePollCallAtStart(REGISTER_POLL_INTERVAL, registerWithTicketService);
630
814
  void runInfinitePollCallAtStart(ALL_TICKETS_POLL_INTERVAL, pollAllTickets);
631
815
  void runInfinitePollCallAtStart(OPEN_TICKET_POLL_INTERVAL, pollOpenTickets);
816
+ void runInfinitePollCallAtStart(STATUS_LOG_INTERVAL, logStatus);
632
817
 
633
818
  console.log(`AutoFixer running. Watching for tickets in the investigation state (cwd: ${process.cwd()}).`);
634
819
  }
@@ -1,7 +1,12 @@
1
1
  import { LogDatum } from "../diskLogger";
2
2
 
3
- export type TicketState = "investigation" | "code-change" | "fixed" | "not-a-bug" | "timed-out";
4
- export const TICKET_STATES: TicketState[] = ["investigation", "code-change", "fixed", "not-a-bug", "timed-out"];
3
+ export type TicketState = "investigation" | "code-change" | "fixed" | "not-a-bug" | "confused" | "timed-out";
4
+ export const TICKET_STATES: TicketState[] = ["investigation", "code-change", "fixed", "not-a-bug", "confused", "timed-out"];
5
+
6
+ export const TICKET_FINAL_STATES: TicketState[] = ["fixed", "not-a-bug"];
7
+ export function isTicketFinished(state: TicketState): boolean {
8
+ return TICKET_FINAL_STATES.includes(state);
9
+ }
5
10
 
6
11
  export type TicketPatchStatus = "pending" | "applied" | "rejected";
7
12
 
@@ -137,7 +137,7 @@ class TicketService {
137
137
  }
138
138
  if (!applied) {
139
139
  if (TicketService.autoFixerNodes.size === 0) {
140
- throw new Error(`No autofixer is connected, so the patch cannot be applied. Run \`yarn autofixer\` in the repository the patch targets.`);
140
+ throw new Error(`No autofixer is connected, so the patch cannot be applied. Run \`yarn autofix\` in the repository the patch targets.`);
141
141
  }
142
142
  throw new Error(`Failed to apply patch on all connected autofixer nodes:\n${errors.join("\n")}`);
143
143
  }
@@ -150,6 +150,12 @@ class TicketService {
150
150
  TicketService.autoFixerNodes.add(caller.nodeId);
151
151
  }
152
152
 
153
+ private static watchersSERVICE = new Set<string>();
154
+ public async watchTicketsSERVICE(): Promise<void> {
155
+ let caller = SocketFunction.getCaller();
156
+ TicketService.watchersSERVICE.add(caller.nodeId);
157
+ }
158
+
153
159
  public static triggerTicketsChanged = batchFunction({ delay: 100 }, (ticketIds: string[]) => {
154
160
  let uniqueIds = Array.from(new Set(ticketIds));
155
161
  for (let nodeId of TicketService.autoFixerNodes) {
@@ -161,6 +167,15 @@ class TicketService {
161
167
  }
162
168
  })();
163
169
  }
170
+ for (let nodeId of TicketService.watchersSERVICE) {
171
+ void (async () => {
172
+ try {
173
+ await TicketDataBase.nodes[nodeId].receiveTicketsChangedHTTP(uniqueIds);
174
+ } catch {
175
+ TicketService.watchersSERVICE.delete(nodeId);
176
+ }
177
+ })();
178
+ }
164
179
  });
165
180
  }
166
181
 
@@ -179,6 +194,7 @@ export const TicketServiceBase = SocketFunction.register(
179
194
  setPatchStatus: {},
180
195
  applyPatch: {},
181
196
  registerAutoFixerSERVICE: {},
197
+ watchTicketsSERVICE: {},
182
198
  }),
183
199
  () => ({
184
200
  hooks: [assertIsManagementUser],
@@ -240,6 +256,53 @@ class TicketData {
240
256
  public async applyPatch(id: string, commentId: string): Promise<void> {
241
257
  await TicketServiceBase.nodes[await getTicketServiceNode()].applyPatch(id, commentId);
242
258
  }
259
+
260
+ // Live updates: browser → this HTTP node → ticket service node, with change notifications flowing back down the same two hops.
261
+ private static browserCallbacks = new Set<(ticketIds: string[]) => void>();
262
+ public static async watchTickets(callback: (ticketIds: string[]) => void) {
263
+ TicketData.browserCallbacks.add(callback);
264
+ await TicketData.ensureWatchingBrowser();
265
+ return () => {
266
+ TicketData.browserCallbacks.delete(callback);
267
+ };
268
+ }
269
+
270
+ private static ensureWatchingBrowser = lazy(async () => {
271
+ await TicketDataBase.nodes[SocketFunction.getBrowserNodeId()].watchTicketsHTTP();
272
+ });
273
+
274
+ public async receiveTicketsChangedBrowser(ticketIds: string[]) {
275
+ for (let callback of TicketData.browserCallbacks) {
276
+ callback(ticketIds);
277
+ }
278
+ }
279
+
280
+ private watchersHTTP = new Set<string>();
281
+ public async watchTicketsHTTP() {
282
+ let caller = SocketFunction.getCaller();
283
+ this.watchersHTTP.add(caller.nodeId);
284
+ await TicketData.ensureWatchingService();
285
+ }
286
+
287
+ private static ensureWatchingService = lazy(async () => {
288
+ let controllerNodeId = await getTicketServiceNode();
289
+ SocketFunction.onNextDisconnect(controllerNodeId, () => {
290
+ TicketData.ensureWatchingService.reset();
291
+ }, "iKnowThatServerNodeIdsMayReconnect_andIHandleReconnections");
292
+ await TicketServiceBase.nodes[controllerNodeId].watchTicketsSERVICE();
293
+ });
294
+
295
+ public async receiveTicketsChangedHTTP(ticketIds: string[]) {
296
+ for (let nodeId of this.watchersHTTP) {
297
+ void (async () => {
298
+ try {
299
+ await TicketDataBase.nodes[nodeId].receiveTicketsChangedBrowser(ticketIds);
300
+ } catch {
301
+ this.watchersHTTP.delete(nodeId);
302
+ }
303
+ })();
304
+ }
305
+ }
243
306
  }
244
307
 
245
308
  export const TicketDataBase = SocketFunction.register(
@@ -256,9 +319,16 @@ export const TicketDataBase = SocketFunction.register(
256
319
  deleteComment: {},
257
320
  setPatchStatus: {},
258
321
  applyPatch: {},
322
+ watchTicketsHTTP: {},
323
+ receiveTicketsChangedHTTP: {},
324
+ receiveTicketsChangedBrowser: {},
259
325
  }),
260
326
  () => ({
261
327
  hooks: [assertIsManagementUser],
262
328
  })
263
329
  );
264
330
  export const TicketsController = getSyncedController(TicketDataBase);
331
+
332
+ export function watchTickets(callback: (ticketIds: string[]) => void) {
333
+ return TicketData.watchTickets(callback);
334
+ }
File without changes