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 +3 -3
- package/src/5-diagnostics/Table.tsx +1 -1
- package/src/diagnostics/logs/errorNotifications2/ErrorNotificationPage.tsx +46 -25
- package/src/diagnostics/logs/errorNotifications2/ErrorWarning.tsx +57 -17
- package/src/diagnostics/logs/errorTickets/TicketPage.tsx +156 -49
- package/src/diagnostics/logs/errorTickets/autoFixer.ts +210 -25
- package/src/diagnostics/logs/errorTickets/ticketTypes.ts +7 -2
- package/src/diagnostics/logs/errorTickets/tickets.ts +71 -1
- /package/bin/{autofixer.js → autofix.js} +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "querysub",
|
|
3
|
-
"version": "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
|
-
"
|
|
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
|
-
"
|
|
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")
|
|
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,
|
|
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(
|
|
27
|
+
let id = await createTicketForError(datumSnapshot, pattern);
|
|
26
28
|
Querysub.commit(() => {
|
|
27
|
-
|
|
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
|
-
<
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
<
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
18
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
return <div className={css.hbox(
|
|
24
|
-
|
|
25
|
-
|
|
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 {
|
|
14
|
-
import {
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
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).
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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 {
|
|
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 *
|
|
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,
|
|
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 {
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
538
|
-
|
|
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
|
-
|
|
709
|
+
emit(line);
|
|
548
710
|
}
|
|
549
711
|
});
|
|
550
712
|
stream.on("end", () => {
|
|
551
713
|
if (pending) {
|
|
552
|
-
|
|
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
|
-
|
|
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
|
|
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, "
|
|
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
|
|
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
|