querysub 0.443.0 → 0.446.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.
@@ -2,7 +2,16 @@
2
2
  "permissions": {
3
3
  "allow": [
4
4
  "Bash(Get-ChildItem *)",
5
- "mcp__querysub-logs__searchIndexes"
5
+ "mcp__querysub-logs__searchIndexes",
6
+ "mcp__node-debugger__listNodes",
7
+ "mcp__node-debugger__attach",
8
+ "mcp__node-debugger__evaluate",
9
+ "mcp__node-debugger__attachNode",
10
+ "mcp__node-debugger__getSource",
11
+ "mcp__node-debugger__logpoint",
12
+ "Bash(claude mcp *)",
13
+ "mcp__querysub-logs__search",
14
+ "Bash(yarn type *)"
6
15
  ]
7
16
  }
8
17
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "querysub",
3
- "version": "0.443.0",
3
+ "version": "0.446.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",
@@ -21,7 +21,9 @@
21
21
  "audit-imports": "yarn typenode ./src/diagnostics/auditImportViolations.ts",
22
22
  "test": "yarn typenode ./test.ts",
23
23
  "mcp": "yarn typenode ./src/diagnostics/logs/IndexedLogs/MCPIndexedLogsEntry.ts",
24
- "mc": "yarn typenode ./src/diagnostics/logs/IndexedLogs/MCPIndexedLogsEntry.ts --cwd D:/repos/qs-cyoa/"
24
+ "mc": "yarn typenode ./src/diagnostics/logs/IndexedLogs/MCPIndexedLogsEntry.ts --cwd D:/repos/qs-cyoa/",
25
+ "mcp2": "yarn typenode ./src/diagnostics/debugger/mcp-server.ts",
26
+ "mc2": "yarn typenode ./src/diagnostics/debugger/mcp-server.ts --cwd D:/repos/qs-cyoa/"
25
27
  },
26
28
  "bin": {
27
29
  "deploy": "./bin/deploy.js",
@@ -396,7 +396,11 @@ export class ArchivesBackblaze {
396
396
  public?: boolean;
397
397
  immutable?: boolean;
398
398
  cacheTime?: number;
399
- }) { }
399
+ allowedOrigins?: string[];
400
+ }) {
401
+ // Get the api, to setup cors
402
+ void this.getBucketAPI();
403
+ }
400
404
 
401
405
  private bucketName = this.config.bucketName.replaceAll(/[^\w\d]/g, "-");
402
406
  private bucketId = "";
@@ -425,7 +429,7 @@ export class ArchivesBackblaze {
425
429
  // ALWAYS set access control, as we can make urls for private buckets with getDownloadAuthorization
426
430
  let desiredCorsRules = [{
427
431
  corsRuleName: "allowAll",
428
- allowedOrigins: ["https"],
432
+ allowedOrigins: this.config.allowedOrigins ?? ["https"],
429
433
  allowedOperations: ["b2_download_file_by_id", "b2_download_file_by_name"],
430
434
  allowedHeaders: ["range"],
431
435
  exposeHeaders: ["x-bz-content-sha1"],
@@ -1024,7 +1028,8 @@ export const getArchivesBackblazePublicImmutable = cache((domain: string) => {
1024
1028
  return new ArchivesBackblaze({
1025
1029
  bucketName: domain + "-public-immutable",
1026
1030
  public: true,
1027
- immutable: true
1031
+ immutable: true,
1032
+ allowedOrigins: [`https://${domain}`, `https://127-0-0-1.${domain}:7007`],
1028
1033
  });
1029
1034
  });
1030
1035
 
@@ -1036,5 +1041,6 @@ export const getArchivesBackblazePublic = cache((domain: string) => {
1036
1041
  bucketName: domain + "-public",
1037
1042
  public: true,
1038
1043
  cacheTime: timeInMinute,
1044
+ allowedOrigins: [`https://${domain}`, `https://127-0-0-1.${domain}:7007`],
1039
1045
  });
1040
1046
  });
@@ -36,10 +36,11 @@ export type AuthoritySpec = {
36
36
  // If the path.startsWith(prefix), but prefix !== path, then we hash getPathIndex(path, hashIndex)
37
37
  // - For now, let's just never add overlapping prefixes.
38
38
  prefixes: PrefixMatcher[];
39
+ // - Make sure to set this if you just want the prefix values, otherwise you will get all that that prefixes don't match (the prefixes exclude prefix match but where route does not match).
39
40
  excludeDefault?: boolean;
40
41
  };
41
42
 
42
-
43
+ // If you want to match just a simple path, use parsePrefixMatcher. It won't match ===, so get the parent if you want that (and then filter the result).
43
44
  export type PrefixMatcher = {
44
45
  prefix: string;
45
46
  additionalMatches: {
@@ -59,7 +60,6 @@ function matchesPrefix(matcher: PrefixMatcher, path: string): boolean {
59
60
  return false;
60
61
  }
61
62
  }
62
- if (getPathIndex(path, matcher.childKeyIndex) === undefined) return false;
63
63
  return true;
64
64
  }
65
65
  // Checks if they're asking for the exact prefix, which would mean that the children would be the direct children of this prefix matcher.
@@ -451,7 +451,7 @@ export class PathValueArchives {
451
451
 
452
452
  private static valuePathCountCache: { time: number; count: Promise<number> } | undefined;
453
453
  public static async getValuePathCount(): Promise<number> {
454
- const ONE_HOUR = 60 * 60 * 1000;
454
+ const ONE_HOUR = 15 * 60 * 1000;
455
455
  let cache = PathValueArchives.valuePathCountCache;
456
456
  if (cache && Date.now() - cache.time < ONE_HOUR) {
457
457
  return cache.count;
@@ -1492,12 +1492,9 @@ export class PathValueProxyWatcher {
1492
1492
  }
1493
1493
  watcher.countSinceLastFullSync++;
1494
1494
  if (watcher.countSinceLastFullSync > 10) {
1495
- require("debugbreak")(2);
1496
- debugger;
1497
1495
  console.warn(`Watcher ${watcher.debugName} has been unsynced for ${watcher.countSinceLastFullSync} times. This is fine, but maybe optimize it. Why is it cascading?`, { lastUnsyncedAccesses: watcher.lastUnsyncedAccesses, lastUnsyncedParentAccesses: watcher.lastUnsyncedParentAccesses }, watcher.options.watchFunction);
1498
1496
  }
1499
1497
  if (watcher.countSinceLastFullSync > 500) {
1500
- debugger;
1501
1498
  // NOTE: Using forceEqualWrites will also fix this a lot of the time, such as when
1502
1499
  // a write contains random numbers or dates.
1503
1500
  let errorMessage = `Too many attempts (${watcher.countSinceLastFullSync}) to sync with different values. If you are reading in a loop, make sure to read all the values, instead of aborting the loop if a value is not synced. ALSO, make sure you don't access paths with Math.random() or Date.now(). This will prevent the sync loop from ever stabilizing.`;
@@ -88,5 +88,5 @@ export function waitIfReceivedIncompleteTransaction(watcher: SyncWatcher) {
88
88
  // This really nicely both blocks it from finishing because of the waiting for the promise and also triggers it automatically when the delay finishes. HOWEVER, In practice, the promise should never be required to trigger it. It should trigger when we receive the missing parts of the transaction, which we will be, of course, watching as they're in the path values that we're accessing. The timeout is just in case something goes wrong and we incorrectly think we should receive the values, but we won't. That way, eventually, we do commit the value.
89
89
  // ALSO! Plus the hash I think only stores like 48 bits. So the chance of collision is somewhat high, especially if we're accessing thousands of paths. So sometimes this will trigger even though we're not missing any part just because we had a collision between the path hashes.
90
90
  proxyWatcher.triggerOnPromiseFinish(promise, { waitReason: "Missing transaction part" });
91
- console.error(`(NOT an error, convert this to a warning after we finish testing). Waiting for missing transaction part ${newestTime.path} which was written at time ${newestTime.time} (now is ${now}). We have parts of this transaction, but we are missing this specific path.`);
91
+ console.warn(`(NOT an error, convert this to a warning after we finish testing). Waiting for missing transaction part ${newestTime.path} which was written at time ${newestTime.time} (now is ${now}). We have parts of this transaction, but we are missing this specific path.`);
92
92
  }
@@ -16,6 +16,61 @@ export async function getGitUncommitted(gitDir = "."): Promise<string[]> {
16
16
  return (await runPromise(`git status --porcelain`, { cwd: gitDir })).split("\n").map(x => x.trim()).filter(x => x);
17
17
  }
18
18
 
19
+ // Cap the diff we hand to the client / AI, so a huge accidental change doesn't
20
+ // blow up the request (or the AI's context window).
21
+ const MAX_DIFF_CHARS = 200_000;
22
+ // Per-file cap for untracked files, so a single large new file doesn't dominate.
23
+ const MAX_UNTRACKED_FILE_CHARS = 20_000;
24
+
25
+ /** Returns a unified-diff-style summary of all uncommitted changes (tracked
26
+ * modifications plus the contents of untracked files), suitable for feeding
27
+ * to an AI for summarization. Output is capped at MAX_DIFF_CHARS. */
28
+ export async function getGitDiff(gitDir = "."): Promise<string> {
29
+ let sections: string[] = [];
30
+
31
+ // Tracked modifications (staged + unstaged) relative to the last commit.
32
+ let trackedDiff = await runPromise(`git diff HEAD`, { cwd: gitDir, quiet: true });
33
+ if (trackedDiff.trim()) {
34
+ sections.push(trackedDiff);
35
+ }
36
+
37
+ // Untracked files don't show up in `git diff HEAD`, so include their
38
+ // contents as all-addition hunks.
39
+ let untrackedFiles = (await runPromise(`git ls-files --others --exclude-standard`, { cwd: gitDir, quiet: true }))
40
+ .split("\n").map(x => x.trim()).filter(x => x);
41
+ for (let file of untrackedFiles) {
42
+ let content: string;
43
+ try {
44
+ content = await fs.promises.readFile(path.join(gitDir, file), "utf8");
45
+ } catch (e: any) {
46
+ sections.push(`--- /dev/null\n+++ b/${file}\n(could not read file: ${e.stack ?? e})`);
47
+ continue;
48
+ }
49
+ let isBinary = false;
50
+ for (let i = 0; i < content.length; i++) {
51
+ if (content.charCodeAt(i) === 0) {
52
+ isBinary = true;
53
+ break;
54
+ }
55
+ }
56
+ if (isBinary) {
57
+ sections.push(`--- /dev/null\n+++ b/${file}\n(binary file, ${content.length} bytes)`);
58
+ continue;
59
+ }
60
+ if (content.length > MAX_UNTRACKED_FILE_CHARS) {
61
+ content = content.slice(0, MAX_UNTRACKED_FILE_CHARS) + `\n(... file truncated)`;
62
+ }
63
+ let addedLines = content.split("\n").map(line => "+" + line).join("\n");
64
+ sections.push(`--- /dev/null\n+++ b/${file} (new file)\n${addedLines}`);
65
+ }
66
+
67
+ let diff = sections.join("\n\n");
68
+ if (diff.length > MAX_DIFF_CHARS) {
69
+ diff = diff.slice(0, MAX_DIFF_CHARS) + `\n\n(... diff truncated, ${diff.length - MAX_DIFF_CHARS} more characters)`;
70
+ }
71
+ return diff;
72
+ }
73
+
19
74
  export async function getLatestRefOnUpstreamBranch(gitDir = ".") {
20
75
  await runPromise(`git fetch`, { cwd: gitDir });
21
76
  return (await runPromise(`git rev-parse @{upstream}`, { cwd: gitDir })).trim();
@@ -29,7 +84,7 @@ export async function commitAndPush(config: {
29
84
  const tempFile = path.join(os.tmpdir(), `commit-message-${Date.now()}-${Math.random().toString(36).substr(2, 9)}.txt`);
30
85
 
31
86
  try {
32
- await fs.promises.writeFile(tempFile, config.message, 'utf8');
87
+ await fs.promises.writeFile(tempFile, config.message, "utf8");
33
88
  await runPromise(`git add --all`, { cwd: config.gitDir });
34
89
  await runPromise(`git commit -F "${tempFile}"`, { cwd: config.gitDir });
35
90
  await runPromise(`git push`, { cwd: config.gitDir });
@@ -237,6 +237,8 @@ export function anyPredictionsPending() {
237
237
  }
238
238
  export const debug_anyPredictionsPending = anyPredictionsPending;
239
239
  export async function waitUntilAllPredictionsFinish() {
240
+ // HACK: Commit an empty function to allow predictions to queue. Now great, and it only works if the predictions can run right away, but... fixes a lot of bugs, most of the time.
241
+ await Querysub.commitAsync(() => { });
240
242
  while (anyPredictionsPending()) {
241
243
  try {
242
244
  await Promise.allSettled(Array.from(pendingPredictedCalls.values()).map(obj => obj.obj.promise));
@@ -131,7 +131,7 @@ function predictCallBase(config: {
131
131
  let result = pathValueSerializer.getPathValue(resultObj) as FunctionResult | undefined;
132
132
  if (!result) return;
133
133
  if (result.lastInternalLoopCount === -1) return;
134
- cleanupPrediction();
134
+ void cleanupPrediction();
135
135
  actualValueFinished.resolve();
136
136
  for (let callback of onPredictionFinishedCallbacks) {
137
137
  callback({ callId: call.CallId, result, functionId: call.FunctionId });
@@ -328,7 +328,7 @@ function predictCallBase(config: {
328
328
  }
329
329
 
330
330
  setTimeout(cleanupPrediction, Querysub.PREDICTION_MAX_LIFESPAN);
331
- function cleanupPrediction() {
331
+ async function cleanupPrediction() {
332
332
  if (didCancel) return;
333
333
 
334
334
  // ALWAYS reject the prediction, in case the function runner server is down.
@@ -338,7 +338,10 @@ function predictCallBase(config: {
338
338
  // which create a memory leak.
339
339
  rejectPrediction();
340
340
 
341
+
341
342
  if (Querysub.AUDIT_PREDICTIONS && predictions) {
343
+ // Wait to receive all parts of the prediction
344
+ await delay(10_000);
342
345
  const afterTime = { time: call.runAtTime.time, version: Number.MAX_SAFE_INTEGER, creatorId: 0 };
343
346
  // Clone predictions, to strip symbols
344
347
  let predictionsCopy = cborEncoder().decode(cborEncoder().encode(predictions)) as typeof predictions;
@@ -0,0 +1,274 @@
1
+ import preact from "preact";
2
+ import { SocketFunction } from "socket-function/SocketFunction";
3
+ import { qreact } from "../../4-dom/qreact";
4
+ import { css } from "../../4-dom/css";
5
+ import { MachineServiceController } from "../machineSchema";
6
+ import { Querysub } from "../../4-querysub/QuerysubController";
7
+ import { InputLabel } from "../../library-components/InputLabel";
8
+ import { DropdownSelector } from "../../library-components/DropdownSelector";
9
+ import { closeAllModals } from "../../5-diagnostics/Modal";
10
+ import { callOpenRouterModel, OpenRouterEffort } from "../../diagnostics/logs/errorNotifications2/openRouterHelper";
11
+ import { formatNumber } from "socket-function/src/formatting/format";
12
+ import { t } from "../../2-proxy/schema2";
13
+
14
+ module.hotreload = true;
15
+
16
+ // Models offered for AI commit-message summarization. These are exact OpenRouter
17
+ // model ids and are passed through to the API verbatim. The first is the default.
18
+ export const SUGGEST_MODELS = [
19
+ "anthropic/claude-sonnet-4.6",
20
+ "google/gemini-3.1-flash-lite",
21
+ "qwen/qwen3.5-9b",
22
+ "~google/gemini-flash-latest",
23
+ "x-ai/grok-4.1-fast",
24
+ "nousresearch/hermes-3-llama-3.1-405b",
25
+ "google/gemini-3.1-pro-preview",
26
+ "anthropic/claude-opus-4.7",
27
+ ];
28
+
29
+ const EFFORT_LEVELS: OpenRouterEffort[] = ["none", "low", "medium", "high"];
30
+
31
+ // localStorage keys for persisting the user's OpenRouter preferences.
32
+ const STORAGE_API_KEY = "commitSummary.openRouterApiKey";
33
+ const STORAGE_MODEL = "commitSummary.model";
34
+ const STORAGE_EFFORT = "commitSummary.effort";
35
+
36
+ const DEFAULT_EFFORT: OpenRouterEffort = "low";
37
+
38
+ const COMMIT_PROMPT = `You are an expert software engineer writing a git commit message.
39
+ Summarize the following code changes into a clear, concise commit message.
40
+
41
+ Rules:
42
+ - The first line is a short summary in the imperative mood, max 72 characters.
43
+ - If useful, add bullet points describing notable changes.
44
+ - Output ONLY the raw commit message. No markdown code fences, no preamble, no quotes.
45
+
46
+ Changes (git diff):
47
+ {{DIFF}}`;
48
+
49
+ const DEFAULT_MODEL = SUGGEST_MODELS[0];
50
+
51
+ function loadStored(key: string, fallback: string): string {
52
+ if (typeof localStorage === "undefined") return fallback;
53
+ return localStorage.getItem(key) ?? fallback;
54
+ }
55
+ function saveStored(key: string, value: string) {
56
+ if (typeof localStorage === "undefined") return;
57
+ localStorage.setItem(key, value);
58
+ }
59
+
60
+ /** Strips a surrounding markdown code fence, in case the model ignores the instruction. */
61
+ function cleanCommitMessage(text: string): string {
62
+ text = text.trim();
63
+ let fenceMatch = text.match(/^```[^\n]*\n([\s\S]*?)\n```$/);
64
+ if (fenceMatch) {
65
+ text = fenceMatch[1].trim();
66
+ }
67
+ return text;
68
+ }
69
+
70
+ const rowButtonStyle = css.pad2(12, 8).button.bord2(0, 0, 20).fontWeight("bold");
71
+
72
+ export class CommitModal extends qreact.Component<{
73
+ /** True for the querysub repo, false for the main service repo. */
74
+ isQuerysub?: boolean;
75
+ /** The `git status --porcelain` lines, shown as a file list. */
76
+ changes: string[];
77
+ /** Performs the actual commit + push (and publish, for querysub). */
78
+ onCommit: (message: string) => Promise<void>;
79
+ }> {
80
+ state = t.state({
81
+ message: t.string,
82
+ apiKey: t.string(loadStored(STORAGE_API_KEY, "")),
83
+ model: t.string(loadStored(STORAGE_MODEL, DEFAULT_MODEL)),
84
+ effort: t.atomic(loadStored(STORAGE_EFFORT, DEFAULT_EFFORT) as OpenRouterEffort),
85
+ diff: t.string,
86
+ diffLoading: t.boolean(true),
87
+ summarizing: t.boolean(false),
88
+ committing: t.boolean(false),
89
+ error: t.string,
90
+ lastCost: t.number,
91
+ });
92
+
93
+ componentDidMount() {
94
+ Querysub.onCommitFinished(() => {
95
+ void this.loadDiff();
96
+ });
97
+ }
98
+
99
+ private async loadDiff() {
100
+ try {
101
+ let isQuerysub = Querysub.localRead(() => this.props.isQuerysub);
102
+ let controller = MachineServiceController(SocketFunction.browserNodeId());
103
+ let diff = await controller.getGitDiff.promise({ useQuerysub: isQuerysub });
104
+ Querysub.localCommit(() => {
105
+ this.state.diff = diff;
106
+ this.state.diffLoading = false;
107
+ });
108
+ } catch (e: any) {
109
+ Querysub.localCommit(() => {
110
+ this.state.error = `Failed to load changes: ${e.stack ?? e}`;
111
+ this.state.diffLoading = false;
112
+ });
113
+ }
114
+ }
115
+
116
+ private async doSummarize() {
117
+ let { apiKey, model, effort, diff } = Querysub.localRead(() => ({
118
+ apiKey: this.state.apiKey,
119
+ model: this.state.model,
120
+ effort: this.state.effort,
121
+ diff: this.state.diff,
122
+ }));
123
+ if (!apiKey.trim()) {
124
+ Querysub.localCommit(() => { this.state.error = "Enter an OpenRouter API key first."; });
125
+ return;
126
+ }
127
+ if (!diff) {
128
+ Querysub.localCommit(() => { this.state.error = "No changes available to summarize."; });
129
+ return;
130
+ }
131
+ Querysub.localCommit(() => {
132
+ this.state.summarizing = true;
133
+ this.state.error = "";
134
+ });
135
+ try {
136
+ let result = await callOpenRouterModel({
137
+ apiKey,
138
+ model,
139
+ effort,
140
+ prompt: COMMIT_PROMPT.replace("{{DIFF}}", diff),
141
+ onProgress: partial => {
142
+ Querysub.localCommit(() => { this.state.message = partial; });
143
+ },
144
+ });
145
+ Querysub.localCommit(() => {
146
+ this.state.message = cleanCommitMessage(result.text);
147
+ this.state.lastCost = result.cost ?? 0;
148
+ });
149
+ } catch (e: any) {
150
+ Querysub.localCommit(() => { this.state.error = `Summarize failed: ${e.stack ?? e}`; });
151
+ } finally {
152
+ Querysub.localCommit(() => { this.state.summarizing = false; });
153
+ }
154
+ }
155
+
156
+ private async doCommit() {
157
+ let message = Querysub.localRead(() => this.state.message).trim();
158
+ if (!message) {
159
+ Querysub.localCommit(() => { this.state.error = "Commit message is empty."; });
160
+ return;
161
+ }
162
+ Querysub.localCommit(() => {
163
+ this.state.committing = true;
164
+ this.state.error = "";
165
+ });
166
+ try {
167
+ await this.props.onCommit(message);
168
+ closeAllModals();
169
+ } catch (e: any) {
170
+ Querysub.localCommit(() => {
171
+ this.state.error = `Commit failed: ${e.stack ?? e}`;
172
+ this.state.committing = false;
173
+ });
174
+ }
175
+ }
176
+
177
+ render() {
178
+ let { diff, diffLoading, summarizing, committing } = this.state;
179
+ let diffBytes = diff ? new TextEncoder().encode(diff).length : 0;
180
+
181
+ let summarizeLabel: string;
182
+ if (diffLoading) {
183
+ summarizeLabel = "Summarize (loading changes…)";
184
+ } else if (summarizing) {
185
+ summarizeLabel = "Summarizing…";
186
+ } else {
187
+ summarizeLabel = `Summarize ${diffBytes.toLocaleString()} bytes of changes`;
188
+ }
189
+
190
+ return <div className={css.vbox(12).minWidth(620)}>
191
+ <InputLabel
192
+ label="Commit message (shift+enter/shift+tab to commit, escape to cancel)"
193
+ value={this.state.message}
194
+ textarea
195
+ fillWidth
196
+ focusOnMount
197
+ hot
198
+ noEnterKeyCommit
199
+ alwaysUseLatestValueWhenFocused
200
+ style={{ height: 300 }}
201
+ onChangeValue={value => { this.state.message = value; }}
202
+ onKeyDown={e => {
203
+ if (e.key === "Enter" && e.shiftKey || e.key === "Tab" && e.shiftKey) {
204
+ e.preventDefault();
205
+ void this.doCommit();
206
+ }
207
+ }}
208
+ />
209
+
210
+ <div className={css.hbox(10).flexWrap("wrap").alignItems("flex-end")}>
211
+ <button
212
+ className={rowButtonStyle.hsl(45, 80, 85)}
213
+ disabled={diffLoading || summarizing}
214
+ onClick={() => void this.doSummarize()}
215
+ >
216
+ 🤖 {summarizeLabel}
217
+ </button>
218
+ <DropdownSelector
219
+ title="Model"
220
+ value={this.state.model}
221
+ onChange={value => {
222
+ this.state.model = value;
223
+ saveStored(STORAGE_MODEL, value);
224
+ }}
225
+ options={SUGGEST_MODELS.map(m => ({ value: m, label: m }))}
226
+ />
227
+ <DropdownSelector
228
+ title="Thinking effort"
229
+ value={this.state.effort}
230
+ onChange={value => {
231
+ this.state.effort = value;
232
+ saveStored(STORAGE_EFFORT, value);
233
+ }}
234
+ options={EFFORT_LEVELS.map(level => ({ value: level, label: level }))}
235
+ />
236
+ {this.state.lastCost && <span className={css.colorhsl(0, 0, 45).fontSize(12)}>
237
+ {formatNumber(1 / this.state.lastCost)} / USD
238
+ </span>}
239
+ </div>
240
+
241
+ <InputLabel
242
+ label="OpenRouter API Key"
243
+ value={this.state.apiKey}
244
+ type="password"
245
+ fillWidth
246
+ hot
247
+ onChangeValue={value => {
248
+ this.state.apiKey = value;
249
+ saveStored(STORAGE_API_KEY, value);
250
+ }}
251
+ />
252
+
253
+ {this.state.error && <div className={
254
+ css.pad2(10).bord2(0, 80, 50).hsl(0, 80, 95).colorhsl(0, 80, 30).whiteSpace("pre-wrap")
255
+ }>
256
+ ⚠️ {this.state.error}
257
+ </div>}
258
+
259
+ <div className={css.hbox(10)}>
260
+ <button
261
+ className={rowButtonStyle.hsl(120, 70, 85)}
262
+ disabled={committing}
263
+ onClick={() => void this.doCommit()}
264
+ >
265
+ {committing ? "Committing…" : "⬆️ Commit & Push"}
266
+ </button>
267
+ </div>
268
+
269
+ <div className={css.vbox(5)}>
270
+ {this.props.changes.map(change => <div>{change}</div>)}
271
+ </div>
272
+ </div>;
273
+ }
274
+ }
@@ -2,13 +2,12 @@ import { SocketFunction } from "socket-function/SocketFunction";
2
2
  import { qreact } from "../../4-dom/qreact";
3
3
  import { MachineServiceController, MachineInfo, ServiceConfig } from "../machineSchema";
4
4
  import { showFullscreenModal } from "../../5-diagnostics/FullscreenModal";
5
- import { InputLabel } from "../../library-components/InputLabel";
6
5
  import { css } from "../../4-dom/css";
7
6
  import { formatTime } from "socket-function/src/formatting/format";
8
7
  import { formatDateJSX } from "../../misc/formatJSX";
9
8
  import { MachineController } from "../machineController";
10
- import { closeAllModals } from "../../5-diagnostics/Modal";
11
9
  import { unique } from "../../misc";
10
+ import { CommitModal } from "./CommitModal";
12
11
 
13
12
  module.hotreload = true;
14
13
 
@@ -56,32 +55,13 @@ export class UpdateButtons extends qreact.Component<{
56
55
  title={gitInfo?.querysubUncommitted.join("\n")}
57
56
  onClick={() => {
58
57
  showFullscreenModal({
59
- content: <div>
60
- <InputLabel
61
- label="Commit message (shift+enter/shift+tab to commit, escape to cancel)"
62
- value={""}
63
- textarea
64
- fillWidth
65
- focusOnMount
66
- noEnterKeyCommit
67
- onKeyDown={async e => {
68
- let value = e.currentTarget.value;
69
- if (!value) return;
70
- if (e.key === "Enter" && e.shiftKey || e.key === "Tab" && e.shiftKey) {
71
- e.preventDefault();
72
- await controller.commitPushAndPublishQuerysub.promise(value);
73
- closeAllModals();
74
- }
75
- }}
76
- />
77
- <div className={css.vbox(5)}>
78
- {gitInfo?.querysubUncommitted.map(change => {
79
- return <div>
80
- {change}
81
- </div>;
82
- })}
83
- </div>
84
- </div>
58
+ content: <CommitModal
59
+ isQuerysub
60
+ changes={gitInfo?.querysubUncommitted ?? []}
61
+ onCommit={async message => {
62
+ await controller.commitPushAndPublishQuerysub.promise(message);
63
+ }}
64
+ />
85
65
  });
86
66
  }}
87
67
  >
@@ -99,32 +79,12 @@ export class UpdateButtons extends qreact.Component<{
99
79
  title={gitInfo?.uncommitted.join("\n")}
100
80
  onClick={() => {
101
81
  showFullscreenModal({
102
- content: <div>
103
- <InputLabel
104
- label="Commit message (shift+enter/shift+tab to commit, escape to cancel)"
105
- value={""}
106
- textarea
107
- fillWidth
108
- focusOnMount
109
- noEnterKeyCommit
110
- onKeyDown={async e => {
111
- let value = e.currentTarget.value;
112
- if (!value) return;
113
- if (e.key === "Enter" && e.shiftKey || e.key === "Tab" && e.shiftKey) {
114
- e.preventDefault();
115
- await controller.commitPushService.promise(value);
116
- closeAllModals();
117
- }
118
- }}
119
- />
120
- <div className={css.vbox(5)}>
121
- {gitInfo?.uncommitted.map(change => {
122
- return <div>
123
- {change}
124
- </div>;
125
- })}
126
- </div>
127
- </div>
82
+ content: <CommitModal
83
+ changes={gitInfo?.uncommitted ?? []}
84
+ onCommit={async message => {
85
+ await controller.commitPushService.promise(message);
86
+ }}
87
+ />
128
88
  });
129
89
  }}
130
90
  >
@@ -26,7 +26,12 @@ import { PromiseObj } from "../promise";
26
26
  import path from "path";
27
27
  import { fsExistsAsync } from "../fs";
28
28
 
29
- const PIPE_FILE_LINE_LIMIT = 10_000;
29
+ // The deployed-service stdout log (pipe.txt) is a rolling buffer: once it grows
30
+ // to PIPE_FILE_LINE_LIMIT lines it is truncated down to PIPE_FILE_LINE_KEEP. The
31
+ // file size is checked every PIPE_FILE_LINE_KEEP lines, so the file stays within
32
+ // [PIPE_FILE_LINE_KEEP, PIPE_FILE_LINE_LIMIT] lines.
33
+ const PIPE_FILE_LINE_LIMIT = 2_000;
34
+ const PIPE_FILE_LINE_KEEP = 1_000;
30
35
 
31
36
 
32
37
  const getMemoryInfo = measureWrap(async function getMemoryInfo(): Promise<{ value: number; max: number } | undefined> {
@@ -551,16 +556,16 @@ while IFS= read -r line; do
551
556
  echo "$line" >> "${pipeFile}"
552
557
  ((line_count++))
553
558
 
554
- # Check line count every ${PIPE_FILE_LINE_LIMIT} lines to avoid too much overhead
555
- if (( line_count % ${PIPE_FILE_LINE_LIMIT} == 0 )); then
559
+ # Check line count every ${PIPE_FILE_LINE_KEEP} lines to avoid too much overhead
560
+ if (( line_count % ${PIPE_FILE_LINE_KEEP} == 0 )); then
556
561
  if [ -f "${pipeFile}" ]; then
557
562
  # Count total lines in file
558
563
  total_lines=$(wc -l < "${pipeFile}")
559
- if [ "$total_lines" -gt ${PIPE_FILE_LINE_LIMIT} ]; then
564
+ if [ "$total_lines" -ge ${PIPE_FILE_LINE_LIMIT} ]; then
560
565
  # Wait 2 seconds to give the watcher time to read pending content
561
566
  sleep 2
562
- # Keep only the last ${PIPE_FILE_LINE_LIMIT} lines when file gets too many lines
563
- tail -n ${Math.floor(PIPE_FILE_LINE_LIMIT / 2)} "${pipeFile}" > "${pipeFile}.tmp" && mv "${pipeFile}.tmp" "${pipeFile}"
567
+ # Keep only the last ${PIPE_FILE_LINE_KEEP} lines when file gets too many lines
568
+ tail -n ${PIPE_FILE_LINE_KEEP} "${pipeFile}" > "${pipeFile}.tmp" && mv "${pipeFile}.tmp" "${pipeFile}"
564
569
  # AND, give it time to read the truncation
565
570
  sleep 2
566
571
  fi