querysub 0.502.0 → 0.504.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 +1 -1
- package/src/3-path-functions/pathFunctionLoader.ts +17 -1
- package/src/deployManager/machineApplyMainCode.ts +1 -1
- package/src/deployManager/machineController.ts +1 -1
- package/src/diagnostics/logs/errorTickets/TicketPage.tsx +65 -0
- package/src/diagnostics/logs/errorTickets/autoFixer.ts +101 -22
package/package.json
CHANGED
|
@@ -177,7 +177,7 @@ let moduleResolver = async (spec: {
|
|
|
177
177
|
await executeCommand("git", ["reset", "--hard", spec.gitRef], { cwd: repoPath });
|
|
178
178
|
|
|
179
179
|
// Yarn install
|
|
180
|
-
await
|
|
180
|
+
await yarnInstallWithHealing(repoPath);
|
|
181
181
|
|
|
182
182
|
// Delete querysub, and replace it with a symlink. Otherwise the synchronization code
|
|
183
183
|
// will run again, and a lot of setup code will run again, etc, and nothing will work correctly.
|
|
@@ -462,6 +462,22 @@ const hotreloadIfChanged = batchFunction({ delay: 100, name: "hotreloadIfChanged
|
|
|
462
462
|
}
|
|
463
463
|
});
|
|
464
464
|
|
|
465
|
+
// A corrupt entry in the shared global yarn cache (e.g. "Extracting tar content ... the file appears to be
|
|
466
|
+
// corrupt") fails every install identically, because re-cloning the repo folder never touches the global
|
|
467
|
+
// cache. Detect that, purge the cache with `yarn cache clean`, and retry once so the loader heals itself.
|
|
468
|
+
async function yarnInstallWithHealing(repoPath: string): Promise<void> {
|
|
469
|
+
try {
|
|
470
|
+
await executeCommand("yarn", ["install", "--mutex", "network"], { cwd: repoPath });
|
|
471
|
+
return;
|
|
472
|
+
} catch (e) {
|
|
473
|
+
let errorText = String((e as Error)?.stack || e);
|
|
474
|
+
if (!/appears to be corrupt|Extracting tar content/.test(errorText)) throw e;
|
|
475
|
+
console.log(red(`yarn install failed with a corrupt yarn cache, cleaning cache and retrying:\n${errorText}`));
|
|
476
|
+
await executeCommand("yarn", ["cache", "clean"]);
|
|
477
|
+
}
|
|
478
|
+
await executeCommand("yarn", ["install", "--mutex", "network"], { cwd: repoPath });
|
|
479
|
+
}
|
|
480
|
+
|
|
465
481
|
async function which(command: string): Promise<string> {
|
|
466
482
|
let whichOrWhere = os.platform() === "win32" ? "where" : "which";
|
|
467
483
|
let path = child_process.execSync(`${whichOrWhere} ${command}`).toString().trim().replaceAll("\r", "").split("\n")[0].trim().replaceAll("\\", "/");
|
|
@@ -743,7 +743,7 @@ const resyncServicesBase = runInSerial(measureWrap(async function resyncServices
|
|
|
743
743
|
let nodeModulesMissing = !await fsExistsAsync(gitFolder + "node_modules");
|
|
744
744
|
if (afterGitRef !== prevGitRef || nodeModulesMissing) {
|
|
745
745
|
console.log(green(`Yarn installing for ${magenta(screenName)} (ref ${prevGitRef} -> ${afterGitRef}, nodeModulesMissing=${nodeModulesMissing})`));
|
|
746
|
-
await runPromise(`yarn install`, { cwd: gitFolder });
|
|
746
|
+
await runPromise(`yarn install --mutex network`, { cwd: gitFolder });
|
|
747
747
|
}
|
|
748
748
|
}
|
|
749
749
|
let parameterPath = folder + "/parameters.json";
|
|
@@ -136,7 +136,7 @@ class MachineControllerBase {
|
|
|
136
136
|
gitFolder,
|
|
137
137
|
gitRef: config.gitRef,
|
|
138
138
|
});
|
|
139
|
-
await runPromise("yarn install", { cwd: gitFolder });
|
|
139
|
+
await runPromise("yarn install --mutex network", { cwd: gitFolder });
|
|
140
140
|
await runPromise("bash machine-startup.sh", { cwd: os.homedir(), detach: true });
|
|
141
141
|
}
|
|
142
142
|
|
|
@@ -9,6 +9,7 @@ import { Querysub } from "../../../4-querysub/Querysub";
|
|
|
9
9
|
import { URLParam } from "../../../library-components/URLParam";
|
|
10
10
|
import { ATag } from "../../../library-components/ATag";
|
|
11
11
|
import { Button } from "../../../library-components/Button";
|
|
12
|
+
import { InputLabel } from "../../../library-components/InputLabel";
|
|
12
13
|
import { LogDatum } from "../diskLogger";
|
|
13
14
|
import { managementPageURL, showingManagementURL } from "../../managementPages";
|
|
14
15
|
import { TicketsController, watchTickets } from "./tickets";
|
|
@@ -183,6 +184,11 @@ export class TicketPage extends qreact.Component {
|
|
|
183
184
|
}
|
|
184
185
|
|
|
185
186
|
class TicketList extends qreact.Component {
|
|
187
|
+
state = t.state({
|
|
188
|
+
newTicketTitle: t.atomic<string>(""),
|
|
189
|
+
newTicketText: t.atomic<string>(""),
|
|
190
|
+
});
|
|
191
|
+
|
|
186
192
|
render() {
|
|
187
193
|
let tickets = getController().getTickets();
|
|
188
194
|
if (!tickets) {
|
|
@@ -216,6 +222,65 @@ class TicketList extends qreact.Component {
|
|
|
216
222
|
<code className={css.pad2(8, 4).hsl(220, 15, 15).colorhsl(120, 60, 70).fontSize(13).borderRadius(3)}>yarn autofix</code>
|
|
217
223
|
<span>to automatically investigate and fix open tickets.</span>
|
|
218
224
|
</div>
|
|
225
|
+
<div className={css.vbox(8).pad2(12).bord2(210, 50, 60).hsl(210, 50, 96).fillWidth}>
|
|
226
|
+
<strong>Create Custom Ticket</strong>
|
|
227
|
+
<InputLabel
|
|
228
|
+
label="Title"
|
|
229
|
+
className={css.width(600)}
|
|
230
|
+
value={this.state.newTicketTitle}
|
|
231
|
+
onChangeValue={(value) => {
|
|
232
|
+
this.state.newTicketTitle = value;
|
|
233
|
+
}}
|
|
234
|
+
/>
|
|
235
|
+
<textarea
|
|
236
|
+
className={css.minHeight(COMMENT_TEXTAREA_COLLAPSED_HEIGHT).minHeight(COMMENT_TEXTAREA_MIN_HEIGHT, "focus").fillWidth.pad2(8).fontSize(14).resize("vertical")}
|
|
237
|
+
value={this.state.newTicketText}
|
|
238
|
+
onInput={e => {
|
|
239
|
+
this.state.newTicketText = (e.target as HTMLTextAreaElement).value;
|
|
240
|
+
}}
|
|
241
|
+
/>
|
|
242
|
+
<div className={css.hbox(8)}>
|
|
243
|
+
<Button
|
|
244
|
+
hue={210}
|
|
245
|
+
onClick={() => {
|
|
246
|
+
let title = this.state.newTicketTitle.trim();
|
|
247
|
+
let text = this.state.newTicketText.trim();
|
|
248
|
+
if (!title) return;
|
|
249
|
+
Querysub.onCommitFinished(async () => {
|
|
250
|
+
let now = Date.now();
|
|
251
|
+
let ticket: Ticket = {
|
|
252
|
+
id: nextId(),
|
|
253
|
+
title: title.slice(0, TITLE_MAX_LENGTH),
|
|
254
|
+
state: "investigation",
|
|
255
|
+
createdTime: now,
|
|
256
|
+
lastUpdatedTime: now,
|
|
257
|
+
errorDatum: {
|
|
258
|
+
time: now,
|
|
259
|
+
__LOG_TYPE: "custom",
|
|
260
|
+
param0: title,
|
|
261
|
+
},
|
|
262
|
+
comments: !text && [] || [{
|
|
263
|
+
id: nextId(),
|
|
264
|
+
time: now,
|
|
265
|
+
author: "user",
|
|
266
|
+
kind: "text" as const,
|
|
267
|
+
text,
|
|
268
|
+
}],
|
|
269
|
+
};
|
|
270
|
+
await getController().createTicket.promise(ticket);
|
|
271
|
+
resetTicketData();
|
|
272
|
+
Querysub.commit(() => {
|
|
273
|
+
this.state.newTicketTitle = "";
|
|
274
|
+
this.state.newTicketText = "";
|
|
275
|
+
goToTicket(ticket.id);
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
}}
|
|
279
|
+
>
|
|
280
|
+
Create Ticket
|
|
281
|
+
</Button>
|
|
282
|
+
</div>
|
|
283
|
+
</div>
|
|
219
284
|
{sorted.length === 0 && <div>No tickets. Create one from the Error Notifications page with the "Ticket" button.</div>}
|
|
220
285
|
<div className={css.vbox(8).fillWidth.flexGrow(1).minHeight(0).overflowAuto}>
|
|
221
286
|
{sorted.map(ticket => (
|
|
@@ -161,39 +161,61 @@ async function registerWithTicketService() {
|
|
|
161
161
|
}
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
let
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
164
|
+
// Relative paths may belong to either the application repo or the querysub repo (a sibling / dependency) — file paths are unique across the two, so whichever one actually has the file wins.
|
|
165
|
+
function resolvePatchFilePath(file: string): { fullPath: string | undefined; candidates: string[] } {
|
|
166
|
+
let candidates = Array.from(new Set([
|
|
167
|
+
path.resolve(process.cwd(), file),
|
|
168
|
+
path.resolve(QUERYSUB_ROOT, file),
|
|
169
|
+
]));
|
|
170
|
+
for (let candidate of candidates) {
|
|
171
|
+
if (fs.existsSync(candidate)) {
|
|
172
|
+
return { fullPath: candidate, candidates };
|
|
173
|
+
}
|
|
173
174
|
}
|
|
175
|
+
return { fullPath: undefined, candidates };
|
|
176
|
+
}
|
|
174
177
|
|
|
175
|
-
|
|
178
|
+
// Resolves and validates every file entry without writing anything (so a patch is all-or-nothing), returning the writes to perform. Throws with a detailed message when the patch cannot apply cleanly — also used at addPatch time so the AI is told about mistakes immediately.
|
|
179
|
+
function preparePatchWrites(patchFiles: TicketPatchFile[]): { fullPath: string; newContents: string }[] {
|
|
176
180
|
let writes: { fullPath: string; newContents: string }[] = [];
|
|
177
|
-
for (let patchFile of
|
|
178
|
-
let fullPath =
|
|
181
|
+
for (let patchFile of patchFiles) {
|
|
182
|
+
let { fullPath, candidates } = resolvePatchFilePath(patchFile.file);
|
|
179
183
|
if (!patchFile.oldText) {
|
|
180
|
-
if (
|
|
181
|
-
throw new Error(`Patch wants to create ${patchFile.file}, but it already exists and is not empty`);
|
|
184
|
+
if (fullPath && fs.readFileSync(fullPath, "utf8").trim()) {
|
|
185
|
+
throw new Error(`Patch wants to create ${patchFile.file}, but it already exists at ${fullPath} and is not empty`);
|
|
182
186
|
}
|
|
183
|
-
writes.push({ fullPath, newContents: patchFile.newText });
|
|
187
|
+
writes.push({ fullPath: fullPath ?? candidates[0], newContents: patchFile.newText });
|
|
184
188
|
continue;
|
|
185
189
|
}
|
|
186
|
-
if (!
|
|
187
|
-
throw new Error(`Patch targets ${patchFile.file}, which does not exist
|
|
190
|
+
if (!fullPath) {
|
|
191
|
+
throw new Error(`Patch targets ${patchFile.file}, which does not exist. Tried: ${candidates.join(", ")}`);
|
|
188
192
|
}
|
|
189
193
|
let contents = fs.readFileSync(fullPath, "utf8");
|
|
190
194
|
let index = contents.indexOf(patchFile.oldText);
|
|
191
195
|
if (index === -1) {
|
|
192
|
-
throw new Error(`Patch oldText not found in ${
|
|
196
|
+
throw new Error(`Patch oldText not found in ${fullPath}. oldText must be copied EXACTLY from the current file contents (check whitespace/indentation).`);
|
|
197
|
+
}
|
|
198
|
+
if (contents.indexOf(patchFile.oldText, index + 1) !== -1) {
|
|
199
|
+
throw new Error(`Patch oldText appears more than once in ${fullPath}, so the replacement is ambiguous. Include more surrounding context to make it unique.`);
|
|
193
200
|
}
|
|
194
201
|
let newContents = contents.slice(0, index) + patchFile.newText + contents.slice(index + patchFile.oldText.length);
|
|
195
202
|
writes.push({ fullPath, newContents });
|
|
196
203
|
}
|
|
204
|
+
return writes;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function applyPatch(ticketId: string, commentId: string): Promise<void> {
|
|
208
|
+
let service = await ticketService();
|
|
209
|
+
let ticket = await service.getTicket(ticketId);
|
|
210
|
+
if (!ticket) {
|
|
211
|
+
throw new Error(`Ticket ${ticketId} not found`);
|
|
212
|
+
}
|
|
213
|
+
let comment = ticket.comments.find(c => c.id === commentId);
|
|
214
|
+
if (!comment || comment.kind !== "patch" || !comment.patchFiles) {
|
|
215
|
+
throw new Error(`Comment ${commentId} is not a patch comment on ticket ${ticketId}`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
let writes = preparePatchWrites(comment.patchFiles);
|
|
197
219
|
for (let write of writes) {
|
|
198
220
|
fs.mkdirSync(path.dirname(write.fullPath), { recursive: true });
|
|
199
221
|
fs.writeFileSync(write.fullPath, write.newContents);
|
|
@@ -258,7 +280,7 @@ Query syntax (case-insensitive substring match by default):
|
|
|
258
280
|
},
|
|
259
281
|
{
|
|
260
282
|
name: "addPatch",
|
|
261
|
-
description: `Propose a code patch on the ticket. The patch is NOT applied automatically — a human reviews it and applies or rejects it. Each file entry replaces the
|
|
283
|
+
description: `Propose a code patch on the ticket. The patch is NOT applied automatically — a human reviews it and applies or rejects it. Each file entry replaces the exact occurrence of oldText with newText. oldText must be copied exactly from the current file contents and must be unique within the file. An empty oldText creates a new file with newText as its contents. The patch is validated against the current files when you submit it — if validation fails you get an error explaining why and the patch is not added; fix it and resubmit.`,
|
|
262
284
|
inputSchema: {
|
|
263
285
|
type: "object",
|
|
264
286
|
properties: {
|
|
@@ -359,6 +381,11 @@ async function callTool(toolName: string, args: Record<string, unknown>): Promis
|
|
|
359
381
|
throw new Error(`Each patch file entry requires file, oldText, and newText`);
|
|
360
382
|
}
|
|
361
383
|
}
|
|
384
|
+
try {
|
|
385
|
+
preparePatchWrites(files);
|
|
386
|
+
} catch (e) {
|
|
387
|
+
throw new Error(`Patch validation failed — the patch was NOT added. Fix the problem and call addPatch again. ${(e as Error).message}`);
|
|
388
|
+
}
|
|
362
389
|
await addTicketComment(ticketId, {
|
|
363
390
|
kind: "patch",
|
|
364
391
|
text,
|
|
@@ -532,7 +559,7 @@ PHASE 2 — FIX. Only after you have added your diagnosis comment, decide on the
|
|
|
532
559
|
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.
|
|
533
560
|
2. Actually fixing the broken code.
|
|
534
561
|
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.
|
|
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()})
|
|
562
|
+
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()}) first, then against the querysub repository (${QUERYSUB_ROOT}) — whichever contains the file. Absolute paths also work. Keep patches minimal and follow the style of the surrounding code.
|
|
536
563
|
|
|
537
564
|
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.
|
|
538
565
|
|
|
@@ -558,6 +585,21 @@ const QUERYSUB_ROOT = path.resolve(__dirname, "../../../..");
|
|
|
558
585
|
// 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
586
|
let claudeTurnEndedHandler: (() => void) | undefined = undefined;
|
|
560
587
|
|
|
588
|
+
// Built-in tools (Read/Glob/Grep) don't go through our MCP server, so we record them from the stream instead: tool_use blocks are stashed here until their tool_result arrives in a "user" event. Our own mcp__autofixer__ tools are excluded — callTool already records those.
|
|
589
|
+
let pendingStreamToolCalls = new Map<string, { name: string; input: string; startTime: number }>();
|
|
590
|
+
|
|
591
|
+
function toolResultToText(content: unknown): string {
|
|
592
|
+
if (typeof content === "string") return content;
|
|
593
|
+
if (Array.isArray(content)) {
|
|
594
|
+
return content.map(block =>
|
|
595
|
+
block && typeof block === "object" && (block as { type?: string; text?: string }).type === "text"
|
|
596
|
+
? String((block as { text?: string }).text ?? "")
|
|
597
|
+
: JSON.stringify(block)
|
|
598
|
+
).join("\n");
|
|
599
|
+
}
|
|
600
|
+
return JSON.stringify(content);
|
|
601
|
+
}
|
|
602
|
+
|
|
561
603
|
type ClaudeUsage = {
|
|
562
604
|
input_tokens?: number;
|
|
563
605
|
cache_creation_input_tokens?: number;
|
|
@@ -594,6 +636,13 @@ function handleClaudeStreamLine(line: string): boolean {
|
|
|
594
636
|
console.log(`[claude] ${String(block.text).slice(0, CLAUDE_LOG_PREVIEW_CHARS)}`);
|
|
595
637
|
} else if (block.type === "tool_use") {
|
|
596
638
|
console.log(`[claude] tool_use: ${blue(String(block.name))} ${JSON.stringify(block.input ?? {}).slice(0, CLAUDE_LOG_PREVIEW_CHARS)}`);
|
|
639
|
+
if (block.id && !String(block.name).startsWith("mcp__autofixer__")) {
|
|
640
|
+
pendingStreamToolCalls.set(String(block.id), {
|
|
641
|
+
name: String(block.name),
|
|
642
|
+
input: JSON.stringify(block.input ?? {}),
|
|
643
|
+
startTime: Date.now(),
|
|
644
|
+
});
|
|
645
|
+
}
|
|
597
646
|
}
|
|
598
647
|
}
|
|
599
648
|
return true;
|
|
@@ -607,8 +656,37 @@ function handleClaudeStreamLine(line: string): boolean {
|
|
|
607
656
|
claudeTurnEndedHandler?.();
|
|
608
657
|
return true;
|
|
609
658
|
}
|
|
610
|
-
if (event.type === "
|
|
611
|
-
//
|
|
659
|
+
if (event.type === "user") {
|
|
660
|
+
// Tool results for built-in tools — record them in the ticket like our own tool calls.
|
|
661
|
+
for (let block of event.message?.content ?? []) {
|
|
662
|
+
if (block?.type !== "tool_result" || !block.tool_use_id) continue;
|
|
663
|
+
let pending = pendingStreamToolCalls.get(String(block.tool_use_id));
|
|
664
|
+
if (!pending) continue;
|
|
665
|
+
pendingStreamToolCalls.delete(String(block.tool_use_id));
|
|
666
|
+
let ticketId = currentTicketId;
|
|
667
|
+
if (!ticketId) continue;
|
|
668
|
+
let outputText = toolResultToText(block.content);
|
|
669
|
+
if (block.is_error) {
|
|
670
|
+
outputText = `ERROR: ${outputText}`;
|
|
671
|
+
}
|
|
672
|
+
if (outputText.length > MAX_TOOL_OUTPUT_STORED) {
|
|
673
|
+
outputText = outputText.slice(0, MAX_TOOL_OUTPUT_STORED) + `... (truncated, ${outputText.length} chars total)`;
|
|
674
|
+
}
|
|
675
|
+
addTicketComment(ticketId, {
|
|
676
|
+
kind: "tool-call",
|
|
677
|
+
text: "",
|
|
678
|
+
toolName: pending.name,
|
|
679
|
+
toolInput: pending.input,
|
|
680
|
+
toolOutput: outputText,
|
|
681
|
+
toolDurationMs: Date.now() - pending.startTime,
|
|
682
|
+
}).catch(e => {
|
|
683
|
+
console.error(`Failed to record tool call in ticket ${ticketId}:`, (e as Error).stack ?? e);
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
return true;
|
|
687
|
+
}
|
|
688
|
+
if (event.type === "system") {
|
|
689
|
+
// Init/config noise.
|
|
612
690
|
return true;
|
|
613
691
|
}
|
|
614
692
|
return false;
|
|
@@ -745,6 +823,7 @@ async function runInvestigation(ticket: Ticket): Promise<void> {
|
|
|
745
823
|
currentTicketTitle = ticket.title;
|
|
746
824
|
currentTicketStartTime = Date.now();
|
|
747
825
|
runTokens = emptyTokens();
|
|
826
|
+
pendingStreamToolCalls.clear();
|
|
748
827
|
stateChangedDuringRun = false;
|
|
749
828
|
patchAddedDuringRun = false;
|
|
750
829
|
try {
|