querysub 0.503.0 → 0.505.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "querysub",
3
- "version": "0.503.0",
3
+ "version": "0.505.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",
@@ -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 executeCommand("yarn", ["install", "--mutex", "network"], { cwd: repoPath });
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("\\", "/");
@@ -7,16 +7,18 @@ import { nextId, sort } from "socket-function/src/misc";
7
7
  import { formatDateTime, formatTime } from "socket-function/src/formatting/format";
8
8
  import { Querysub } from "../../../4-querysub/Querysub";
9
9
  import { URLParam } from "../../../library-components/URLParam";
10
+ import { mainResets } from "../../../library-components/urlResetGroups";
10
11
  import { ATag } from "../../../library-components/ATag";
11
12
  import { Button } from "../../../library-components/Button";
13
+ import { InputLabel } from "../../../library-components/InputLabel";
12
14
  import { LogDatum } from "../diskLogger";
13
15
  import { managementPageURL, showingManagementURL } from "../../managementPages";
14
16
  import { TicketsController, watchTickets } from "./tickets";
15
17
  import { isTicketFinished, Ticket, TicketComment, TicketPatchFile, TicketState, TICKET_STATES } from "./ticketTypes";
16
18
 
17
19
  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", "");
20
+ // "unfinished" hides tickets in a final state (fixed / not-a-bug). Resets when the page changes, so the filter doesn't confusingly stick around.
21
+ export const ticketFilterURL = new URLParam("ticketfilter", "", { reset: [mainResets] });
20
22
 
21
23
  const TITLE_MAX_LENGTH = 200;
22
24
  const COMMENT_TEXTAREA_MIN_HEIGHT = 250;
@@ -183,6 +185,11 @@ export class TicketPage extends qreact.Component {
183
185
  }
184
186
 
185
187
  class TicketList extends qreact.Component {
188
+ state = t.state({
189
+ newTicketTitle: t.atomic<string>(""),
190
+ newTicketText: t.atomic<string>(""),
191
+ });
192
+
186
193
  render() {
187
194
  let tickets = getController().getTickets();
188
195
  if (!tickets) {
@@ -199,7 +206,7 @@ class TicketList extends qreact.Component {
199
206
  return <div className={css.vbox(16).pad2(16).fillBoth.minHeight(0)}>
200
207
  <div className={css.hbox(16)}>
201
208
  <h2>Tickets ({sorted.length}{showUnfinishedOnly && " unfinished" || ""})</h2>
202
- <ATag values={[ticketFilterURL.getOverride(showUnfinishedOnly && "" || "unfinished")]}>
209
+ <ATag values={[ticketFilterURL.getOverride(showUnfinishedOnly ? "" : "unfinished")]}>
203
210
  {showUnfinishedOnly && "Show All" || "Show Unfinished Only"}
204
211
  </ATag>
205
212
  <Button
@@ -216,6 +223,65 @@ class TicketList extends qreact.Component {
216
223
  <code className={css.pad2(8, 4).hsl(220, 15, 15).colorhsl(120, 60, 70).fontSize(13).borderRadius(3)}>yarn autofix</code>
217
224
  <span>to automatically investigate and fix open tickets.</span>
218
225
  </div>
226
+ <div className={css.vbox(8).pad2(12).bord2(210, 50, 60).hsl(210, 50, 96).fillWidth}>
227
+ <strong>Create Custom Ticket</strong>
228
+ <InputLabel
229
+ label="Title"
230
+ className={css.width(600)}
231
+ value={this.state.newTicketTitle}
232
+ onChangeValue={(value) => {
233
+ this.state.newTicketTitle = value;
234
+ }}
235
+ />
236
+ <textarea
237
+ className={css.minHeight(COMMENT_TEXTAREA_COLLAPSED_HEIGHT).minHeight(COMMENT_TEXTAREA_MIN_HEIGHT, "focus").fillWidth.pad2(8).fontSize(14).resize("vertical")}
238
+ value={this.state.newTicketText}
239
+ onInput={e => {
240
+ this.state.newTicketText = (e.target as HTMLTextAreaElement).value;
241
+ }}
242
+ />
243
+ <div className={css.hbox(8)}>
244
+ <Button
245
+ hue={210}
246
+ onClick={() => {
247
+ let title = this.state.newTicketTitle.trim();
248
+ let text = this.state.newTicketText.trim();
249
+ if (!title) return;
250
+ Querysub.onCommitFinished(async () => {
251
+ let now = Date.now();
252
+ let ticket: Ticket = {
253
+ id: nextId(),
254
+ title: title.slice(0, TITLE_MAX_LENGTH),
255
+ state: "investigation",
256
+ createdTime: now,
257
+ lastUpdatedTime: now,
258
+ errorDatum: {
259
+ time: now,
260
+ __LOG_TYPE: "custom",
261
+ param0: title,
262
+ },
263
+ comments: !text && [] || [{
264
+ id: nextId(),
265
+ time: now,
266
+ author: "user",
267
+ kind: "text" as const,
268
+ text,
269
+ }],
270
+ };
271
+ await getController().createTicket.promise(ticket);
272
+ resetTicketData();
273
+ Querysub.commit(() => {
274
+ this.state.newTicketTitle = "";
275
+ this.state.newTicketText = "";
276
+ goToTicket(ticket.id);
277
+ });
278
+ });
279
+ }}
280
+ >
281
+ Create Ticket
282
+ </Button>
283
+ </div>
284
+ </div>
219
285
  {sorted.length === 0 && <div>No tickets. Create one from the Error Notifications page with the "Ticket" button.</div>}
220
286
  <div className={css.vbox(8).fillWidth.flexGrow(1).minHeight(0).overflowAuto}>
221
287
  {sorted.map(ticket => (
@@ -161,34 +161,24 @@ async function registerWithTicketService() {
161
161
  }
162
162
  }
163
163
 
164
- async function applyPatch(ticketId: string, commentId: string): Promise<void> {
165
- let service = await ticketService();
166
- let ticket = await service.getTicket(ticketId);
167
- if (!ticket) {
168
- throw new Error(`Ticket ${ticketId} not found`);
169
- }
170
- let comment = ticket.comments.find(c => c.id === commentId);
171
- if (!comment || comment.kind !== "patch" || !comment.patchFiles) {
172
- throw new Error(`Comment ${commentId} is not a patch comment on ticket ${ticketId}`);
173
- }
174
-
175
- // 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.
176
- function resolvePatchFilePath(file: string): { fullPath: string | undefined; candidates: string[] } {
177
- let candidates = Array.from(new Set([
178
- path.resolve(process.cwd(), file),
179
- path.resolve(QUERYSUB_ROOT, file),
180
- ]));
181
- for (let candidate of candidates) {
182
- if (fs.existsSync(candidate)) {
183
- return { fullPath: candidate, candidates };
184
- }
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 };
185
173
  }
186
- return { fullPath: undefined, candidates };
187
174
  }
175
+ return { fullPath: undefined, candidates };
176
+ }
188
177
 
189
- // Validate everything before writing anything, so a patch is all-or-nothing.
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 }[] {
190
180
  let writes: { fullPath: string; newContents: string }[] = [];
191
- for (let patchFile of comment.patchFiles) {
181
+ for (let patchFile of patchFiles) {
192
182
  let { fullPath, candidates } = resolvePatchFilePath(patchFile.file);
193
183
  if (!patchFile.oldText) {
194
184
  if (fullPath && fs.readFileSync(fullPath, "utf8").trim()) {
@@ -203,11 +193,29 @@ async function applyPatch(ticketId: string, commentId: string): Promise<void> {
203
193
  let contents = fs.readFileSync(fullPath, "utf8");
204
194
  let index = contents.indexOf(patchFile.oldText);
205
195
  if (index === -1) {
206
- throw new Error(`Patch oldText not found in ${patchFile.file}. The file may have changed since the patch was created.`);
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.`);
207
200
  }
208
201
  let newContents = contents.slice(0, index) + patchFile.newText + contents.slice(index + patchFile.oldText.length);
209
202
  writes.push({ fullPath, newContents });
210
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);
211
219
  for (let write of writes) {
212
220
  fs.mkdirSync(path.dirname(write.fullPath), { recursive: true });
213
221
  fs.writeFileSync(write.fullPath, write.newContents);
@@ -272,7 +280,7 @@ Query syntax (case-insensitive substring match by default):
272
280
  },
273
281
  {
274
282
  name: "addPatch",
275
- 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 first exact occurrence of oldText with newText. oldText must be copied exactly from the current file contents and should be unique within the file. An empty oldText creates a new file with newText as its contents.`,
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.`,
276
284
  inputSchema: {
277
285
  type: "object",
278
286
  properties: {
@@ -373,6 +381,11 @@ async function callTool(toolName: string, args: Record<string, unknown>): Promis
373
381
  throw new Error(`Each patch file entry requires file, oldText, and newText`);
374
382
  }
375
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
+ }
376
389
  await addTicketComment(ticketId, {
377
390
  kind: "patch",
378
391
  text,
@@ -572,6 +585,21 @@ const QUERYSUB_ROOT = path.resolve(__dirname, "../../../..");
572
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.
573
586
  let claudeTurnEndedHandler: (() => void) | undefined = undefined;
574
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
+
575
603
  type ClaudeUsage = {
576
604
  input_tokens?: number;
577
605
  cache_creation_input_tokens?: number;
@@ -608,6 +636,13 @@ function handleClaudeStreamLine(line: string): boolean {
608
636
  console.log(`[claude] ${String(block.text).slice(0, CLAUDE_LOG_PREVIEW_CHARS)}`);
609
637
  } else if (block.type === "tool_use") {
610
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
+ }
611
646
  }
612
647
  }
613
648
  return true;
@@ -621,8 +656,37 @@ function handleClaudeStreamLine(line: string): boolean {
621
656
  claudeTurnEndedHandler?.();
622
657
  return true;
623
658
  }
624
- if (event.type === "system" || event.type === "user") {
625
- // Init/config noise and tool results (which we already record in the ticket).
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.
626
690
  return true;
627
691
  }
628
692
  return false;
@@ -759,6 +823,7 @@ async function runInvestigation(ticket: Ticket): Promise<void> {
759
823
  currentTicketTitle = ticket.title;
760
824
  currentTicketStartTime = Date.now();
761
825
  runTokens = emptyTokens();
826
+ pendingStreamToolCalls.clear();
762
827
  stateChangedDuringRun = false;
763
828
  patchAddedDuringRun = false;
764
829
  try {