querysub 0.497.0 → 0.499.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/bin/autofixer.js +6 -0
- package/package.json +4 -2
- package/src/diagnostics/logs/errorNotifications2/ErrorNotificationPage.tsx +37 -0
- package/src/diagnostics/logs/errorNotifications2/errorNotifications.ts +2 -0
- package/src/diagnostics/logs/errorTickets/TicketPage.tsx +518 -0
- package/src/diagnostics/logs/errorTickets/autoFixer.ts +634 -0
- package/src/diagnostics/logs/errorTickets/autoFixerController.ts +41 -0
- package/src/diagnostics/logs/errorTickets/autoFixerEntry.ts +24 -0
- package/src/diagnostics/logs/errorTickets/ticketTypes.ts +47 -0
- package/src/diagnostics/logs/errorTickets/tickets.ts +264 -0
- package/src/diagnostics/managementPages.tsx +6 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module.hotreload = false;
|
|
2
|
+
|
|
3
|
+
// Allow callers to point this process at a different querysub project root (so we use the right keys/certs/local data) before any other imports run.
|
|
4
|
+
{
|
|
5
|
+
let argv = process.argv;
|
|
6
|
+
let cwdIdx = argv.indexOf("--cwd");
|
|
7
|
+
if (cwdIdx >= 0 && argv[cwdIdx + 1]) {
|
|
8
|
+
process.chdir(argv[cwdIdx + 1]);
|
|
9
|
+
argv.splice(cwdIdx, 2);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
import "../../../inject";
|
|
14
|
+
|
|
15
|
+
import { logErrors } from "../../../errors";
|
|
16
|
+
import { Querysub } from "../../../4-querysub/Querysub";
|
|
17
|
+
import { runAutoFixer } from "./autoFixer";
|
|
18
|
+
|
|
19
|
+
logErrors(main());
|
|
20
|
+
|
|
21
|
+
async function main() {
|
|
22
|
+
await Querysub.hostService("AutoFixer");
|
|
23
|
+
await runAutoFixer();
|
|
24
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { LogDatum } from "../diskLogger";
|
|
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"];
|
|
5
|
+
|
|
6
|
+
export type TicketPatchStatus = "pending" | "applied" | "rejected";
|
|
7
|
+
|
|
8
|
+
export type TicketPatchFile = {
|
|
9
|
+
file: string;
|
|
10
|
+
// Empty oldText means "create the file with newText" (or fully overwrite an empty file).
|
|
11
|
+
oldText: string;
|
|
12
|
+
newText: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type TicketCommentKind = "text" | "patch" | "tool-call";
|
|
16
|
+
|
|
17
|
+
export type TicketComment = {
|
|
18
|
+
id: string;
|
|
19
|
+
time: number;
|
|
20
|
+
author: string;
|
|
21
|
+
kind: TicketCommentKind;
|
|
22
|
+
text: string;
|
|
23
|
+
|
|
24
|
+
patchFiles?: TicketPatchFile[];
|
|
25
|
+
patchStatus?: TicketPatchStatus;
|
|
26
|
+
patchStatusTime?: number;
|
|
27
|
+
|
|
28
|
+
toolName?: string;
|
|
29
|
+
toolInput?: string;
|
|
30
|
+
toolOutput?: string;
|
|
31
|
+
toolDurationMs?: number;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type Ticket = {
|
|
35
|
+
id: string;
|
|
36
|
+
title: string;
|
|
37
|
+
state: TicketState;
|
|
38
|
+
createdTime: number;
|
|
39
|
+
lastUpdatedTime: number;
|
|
40
|
+
|
|
41
|
+
// The full log datum the ticket was created from, so every field in the log is preserved in the ticket.
|
|
42
|
+
errorDatum: LogDatum;
|
|
43
|
+
// The suppression pattern that matched the error, when the ticket was created from a suppression entry.
|
|
44
|
+
suppressionPattern?: string;
|
|
45
|
+
|
|
46
|
+
comments: TicketComment[];
|
|
47
|
+
};
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { lazy } from "socket-function/src/caching";
|
|
2
|
+
import { batchFunction, runInfinitePollCallAtStart } from "socket-function/src/batching";
|
|
3
|
+
import { timeInMinute } from "socket-function/src/misc";
|
|
4
|
+
import { SocketFunction } from "socket-function/SocketFunction";
|
|
5
|
+
import { nestArchives } from "../../../-a-archives/archives";
|
|
6
|
+
import { getArchivesBackblaze } from "../../../-a-archives/archivesBackBlaze";
|
|
7
|
+
import { archiveJSONT } from "../../../-a-archives/archivesJSONT";
|
|
8
|
+
import { getDomain, isPublic } from "../../../config";
|
|
9
|
+
import { getControllerNodeId } from "../../../-g-core-values/NodeCapabilities";
|
|
10
|
+
import { assertIsManagementUser } from "../../managementPages";
|
|
11
|
+
import { getSyncedController } from "../../../library-components/SyncedController";
|
|
12
|
+
import { AutoFixerControllerBase } from "./autoFixerController";
|
|
13
|
+
import { Ticket, TicketComment, TicketPatchStatus, TicketState } from "./ticketTypes";
|
|
14
|
+
|
|
15
|
+
const TICKET_CACHE_POLL_INTERVAL = timeInMinute * 5;
|
|
16
|
+
|
|
17
|
+
export const ticketsArchive = archiveJSONT<Ticket>(() => nestArchives("logs/error-tickets/", getArchivesBackblaze(getDomain())));
|
|
18
|
+
|
|
19
|
+
let ticketCache = new Map<string, Ticket>();
|
|
20
|
+
let ensureWatching = lazy(async () => {
|
|
21
|
+
await runInfinitePollCallAtStart(TICKET_CACHE_POLL_INTERVAL, async () => {
|
|
22
|
+
let tickets = await ticketsArchive.values();
|
|
23
|
+
ticketCache = new Map(tickets.map(ticket => [ticket.id, ticket]));
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
async function getTicketCache(): Promise<Map<string, Ticket>> {
|
|
27
|
+
await ensureWatching();
|
|
28
|
+
return ticketCache;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function mutateTicket(id: string, mutate: (ticket: Ticket) => void): Promise<Ticket> {
|
|
32
|
+
let cache = await getTicketCache();
|
|
33
|
+
let ticket = cache.get(id);
|
|
34
|
+
if (!ticket) {
|
|
35
|
+
ticket = await ticketsArchive.get(id);
|
|
36
|
+
if (ticket) {
|
|
37
|
+
cache.set(id, ticket);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (!ticket) {
|
|
41
|
+
throw new Error(`Ticket ${id} not found`);
|
|
42
|
+
}
|
|
43
|
+
let updated: Ticket = JSON.parse(JSON.stringify(ticket)) as Ticket;
|
|
44
|
+
mutate(updated);
|
|
45
|
+
updated.lastUpdatedTime = Date.now();
|
|
46
|
+
cache.set(id, updated);
|
|
47
|
+
await ticketsArchive.set(id, updated);
|
|
48
|
+
TicketService.triggerTicketsChanged(id);
|
|
49
|
+
return updated;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
class TicketService {
|
|
53
|
+
public async getTickets(): Promise<Ticket[]> {
|
|
54
|
+
let cache = await getTicketCache();
|
|
55
|
+
return Array.from(cache.values());
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
public async getTicket(id: string): Promise<Ticket | undefined> {
|
|
59
|
+
let cache = await getTicketCache();
|
|
60
|
+
let ticket = cache.get(id);
|
|
61
|
+
if (ticket) return ticket;
|
|
62
|
+
ticket = await ticketsArchive.get(id);
|
|
63
|
+
if (ticket) {
|
|
64
|
+
cache.set(id, ticket);
|
|
65
|
+
}
|
|
66
|
+
return ticket;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
public async createTicket(ticket: Ticket): Promise<void> {
|
|
70
|
+
let cache = await getTicketCache();
|
|
71
|
+
cache.set(ticket.id, ticket);
|
|
72
|
+
await ticketsArchive.set(ticket.id, ticket);
|
|
73
|
+
TicketService.triggerTicketsChanged(ticket.id);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
public async deleteTicket(id: string): Promise<void> {
|
|
77
|
+
let cache = await getTicketCache();
|
|
78
|
+
cache.delete(id);
|
|
79
|
+
await ticketsArchive.delete(id);
|
|
80
|
+
TicketService.triggerTicketsChanged(id);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
public async setTicketState(id: string, state: TicketState): Promise<void> {
|
|
84
|
+
await mutateTicket(id, ticket => {
|
|
85
|
+
ticket.state = state;
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
public async addComment(id: string, comment: TicketComment): Promise<void> {
|
|
90
|
+
await mutateTicket(id, ticket => {
|
|
91
|
+
ticket.comments.push(comment);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
public async updateCommentText(id: string, commentId: string, text: string): Promise<void> {
|
|
96
|
+
await mutateTicket(id, ticket => {
|
|
97
|
+
let comment = ticket.comments.find(c => c.id === commentId);
|
|
98
|
+
if (!comment) {
|
|
99
|
+
throw new Error(`Comment ${commentId} not found in ticket ${id}`);
|
|
100
|
+
}
|
|
101
|
+
comment.text = text;
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
public async deleteComment(id: string, commentId: string): Promise<void> {
|
|
106
|
+
await mutateTicket(id, ticket => {
|
|
107
|
+
ticket.comments = ticket.comments.filter(c => c.id !== commentId);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
public async setPatchStatus(id: string, commentId: string, status: TicketPatchStatus): Promise<void> {
|
|
112
|
+
await mutateTicket(id, ticket => {
|
|
113
|
+
let comment = ticket.comments.find(c => c.id === commentId);
|
|
114
|
+
if (!comment) {
|
|
115
|
+
throw new Error(`Comment ${commentId} not found in ticket ${id}`);
|
|
116
|
+
}
|
|
117
|
+
if (comment.kind !== "patch") {
|
|
118
|
+
throw new Error(`Comment ${commentId} is not a patch comment`);
|
|
119
|
+
}
|
|
120
|
+
comment.patchStatus = status;
|
|
121
|
+
comment.patchStatusTime = Date.now();
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Routes to a registered autofixer node, which does the filesystem work. On success we record the applied status.
|
|
126
|
+
public async applyPatch(id: string, commentId: string): Promise<void> {
|
|
127
|
+
let errors: string[] = [];
|
|
128
|
+
let applied = false;
|
|
129
|
+
for (let nodeId of Array.from(TicketService.autoFixerNodes)) {
|
|
130
|
+
try {
|
|
131
|
+
await AutoFixerControllerBase.nodes[nodeId].applyPatch(id, commentId);
|
|
132
|
+
applied = true;
|
|
133
|
+
break;
|
|
134
|
+
} catch (e) {
|
|
135
|
+
errors.push((e as Error).stack ?? String(e));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (!applied) {
|
|
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.`);
|
|
141
|
+
}
|
|
142
|
+
throw new Error(`Failed to apply patch on all connected autofixer nodes:\n${errors.join("\n")}`);
|
|
143
|
+
}
|
|
144
|
+
await this.setPatchStatus(id, commentId, "applied");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private static autoFixerNodes = new Set<string>();
|
|
148
|
+
public async registerAutoFixerSERVICE(): Promise<void> {
|
|
149
|
+
let caller = SocketFunction.getCaller();
|
|
150
|
+
TicketService.autoFixerNodes.add(caller.nodeId);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
public static triggerTicketsChanged = batchFunction({ delay: 100 }, (ticketIds: string[]) => {
|
|
154
|
+
let uniqueIds = Array.from(new Set(ticketIds));
|
|
155
|
+
for (let nodeId of TicketService.autoFixerNodes) {
|
|
156
|
+
void (async () => {
|
|
157
|
+
try {
|
|
158
|
+
await AutoFixerControllerBase.nodes[nodeId].onTicketsChanged(uniqueIds);
|
|
159
|
+
} catch {
|
|
160
|
+
TicketService.autoFixerNodes.delete(nodeId);
|
|
161
|
+
}
|
|
162
|
+
})();
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export const TicketServiceBase = SocketFunction.register(
|
|
168
|
+
"TicketService-019c9cae-8333-7708-a4e7-500f5fc23180",
|
|
169
|
+
new TicketService(),
|
|
170
|
+
() => ({
|
|
171
|
+
getTickets: {},
|
|
172
|
+
getTicket: {},
|
|
173
|
+
createTicket: {},
|
|
174
|
+
deleteTicket: {},
|
|
175
|
+
setTicketState: {},
|
|
176
|
+
addComment: {},
|
|
177
|
+
updateCommentText: {},
|
|
178
|
+
deleteComment: {},
|
|
179
|
+
setPatchStatus: {},
|
|
180
|
+
applyPatch: {},
|
|
181
|
+
registerAutoFixerSERVICE: {},
|
|
182
|
+
}),
|
|
183
|
+
() => ({
|
|
184
|
+
hooks: [assertIsManagementUser],
|
|
185
|
+
}),
|
|
186
|
+
{
|
|
187
|
+
noAutoExpose: true,
|
|
188
|
+
}
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
export function exposeTicketService() {
|
|
192
|
+
SocketFunction.expose(TicketServiceBase);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function getTicketServiceNode(): Promise<string> {
|
|
196
|
+
let controllerNodeId = await getControllerNodeId(TicketServiceBase, !isPublic());
|
|
197
|
+
if (!controllerNodeId) {
|
|
198
|
+
throw new Error(`Could not find node exposing controller TicketServiceBase. Is \`yarn error-watch\` running?`);
|
|
199
|
+
}
|
|
200
|
+
return controllerNodeId;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
class TicketData {
|
|
204
|
+
public async getTickets(): Promise<Ticket[]> {
|
|
205
|
+
return await TicketServiceBase.nodes[await getTicketServiceNode()].getTickets();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
public async getTicket(id: string): Promise<Ticket | undefined> {
|
|
209
|
+
return await TicketServiceBase.nodes[await getTicketServiceNode()].getTicket(id);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
public async createTicket(ticket: Ticket): Promise<void> {
|
|
213
|
+
await TicketServiceBase.nodes[await getTicketServiceNode()].createTicket(ticket);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
public async deleteTicket(id: string): Promise<void> {
|
|
217
|
+
await TicketServiceBase.nodes[await getTicketServiceNode()].deleteTicket(id);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
public async setTicketState(id: string, state: TicketState): Promise<void> {
|
|
221
|
+
await TicketServiceBase.nodes[await getTicketServiceNode()].setTicketState(id, state);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
public async addComment(id: string, comment: TicketComment): Promise<void> {
|
|
225
|
+
await TicketServiceBase.nodes[await getTicketServiceNode()].addComment(id, comment);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
public async updateCommentText(id: string, commentId: string, text: string): Promise<void> {
|
|
229
|
+
await TicketServiceBase.nodes[await getTicketServiceNode()].updateCommentText(id, commentId, text);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
public async deleteComment(id: string, commentId: string): Promise<void> {
|
|
233
|
+
await TicketServiceBase.nodes[await getTicketServiceNode()].deleteComment(id, commentId);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
public async setPatchStatus(id: string, commentId: string, status: TicketPatchStatus): Promise<void> {
|
|
237
|
+
await TicketServiceBase.nodes[await getTicketServiceNode()].setPatchStatus(id, commentId, status);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
public async applyPatch(id: string, commentId: string): Promise<void> {
|
|
241
|
+
await TicketServiceBase.nodes[await getTicketServiceNode()].applyPatch(id, commentId);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export const TicketDataBase = SocketFunction.register(
|
|
246
|
+
"TicketData-019c9cae-8333-7708-a4e7-500f5fc23181",
|
|
247
|
+
new TicketData(),
|
|
248
|
+
() => ({
|
|
249
|
+
getTickets: {},
|
|
250
|
+
getTicket: {},
|
|
251
|
+
createTicket: {},
|
|
252
|
+
deleteTicket: {},
|
|
253
|
+
setTicketState: {},
|
|
254
|
+
addComment: {},
|
|
255
|
+
updateCommentText: {},
|
|
256
|
+
deleteComment: {},
|
|
257
|
+
setPatchStatus: {},
|
|
258
|
+
applyPatch: {},
|
|
259
|
+
}),
|
|
260
|
+
() => ({
|
|
261
|
+
hooks: [assertIsManagementUser],
|
|
262
|
+
})
|
|
263
|
+
);
|
|
264
|
+
export const TicketsController = getSyncedController(TicketDataBase);
|
|
@@ -152,6 +152,12 @@ export async function registerManagementPages2(config: {
|
|
|
152
152
|
controllerName: "",
|
|
153
153
|
getModule: () => import("./logs/errorNotifications2/ErrorNotificationPage"),
|
|
154
154
|
});
|
|
155
|
+
inputPages.push({
|
|
156
|
+
title: "Tickets",
|
|
157
|
+
componentName: "TicketPage",
|
|
158
|
+
controllerName: "",
|
|
159
|
+
getModule: () => import("./logs/errorTickets/TicketPage"),
|
|
160
|
+
});
|
|
155
161
|
// NOTE: Even if we never use this, it is needed if a server or site is bootstrapping. However, they should probably include this in their own header dynamically as well.
|
|
156
162
|
inputPages.push({
|
|
157
163
|
title: "Security",
|