@evandu/super-pi 0.1.107 → 0.1.108
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/dist/.super-pi-check-inputs.json +2 -2
- package/dist/client/.super-pi-client-sources.json +6 -6
- package/dist/client/assets/index-CnIOGNIm.js +79 -0
- package/dist/client/index.html +1 -1
- package/dist/server/content-search.d.ts +18 -8
- package/dist/server/content-search.js +226 -129
- package/dist/server/store.js +36 -10
- package/package.json +2 -1
- package/dist/client/assets/index-B1K1OA_6.js +0 -79
package/dist/client/index.html
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
<link rel="alternate icon" href="/favicon.ico" />
|
|
9
9
|
<link rel="apple-touch-icon" href="/favicon.svg" />
|
|
10
10
|
<title>Super Pi</title>
|
|
11
|
-
<script type="module" crossorigin src="/assets/index-
|
|
11
|
+
<script type="module" crossorigin src="/assets/index-CnIOGNIm.js"></script>
|
|
12
12
|
<link rel="stylesheet" crossorigin href="/assets/index-Aa8KN3IB.css">
|
|
13
13
|
</head>
|
|
14
14
|
<body>
|
|
@@ -22,11 +22,21 @@ export type ContentSearchResult = {
|
|
|
22
22
|
complete: boolean;
|
|
23
23
|
reasons: string[];
|
|
24
24
|
};
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
25
|
+
type RipgrepMatch = {
|
|
26
|
+
path: string;
|
|
27
|
+
line: number;
|
|
28
|
+
text: string;
|
|
29
|
+
};
|
|
30
|
+
type RipgrepRun = (input: {
|
|
31
|
+
rgPath: string;
|
|
32
|
+
pattern: string;
|
|
33
|
+
paths: string[];
|
|
34
|
+
signal: AbortSignal;
|
|
35
|
+
onMatch: (match: RipgrepMatch) => Promise<boolean>;
|
|
36
|
+
}) => Promise<void>;
|
|
37
|
+
export type ContentSearchOptions = {
|
|
38
|
+
resolveRipgrep?: () => Promise<string | undefined>;
|
|
39
|
+
runRipgrep?: RipgrepRun;
|
|
40
|
+
};
|
|
41
|
+
export declare function searchByContent(root: string, candidates: readonly ContentSearchCandidate[], query: string, indexStatus?: Pick<FileIndexSnapshot, "complete" | "skipped">, options?: ContentSearchOptions): Promise<ContentSearchResult>;
|
|
42
|
+
export {};
|
|
@@ -1,152 +1,249 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { open, stat } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
5
|
+
import { FilesError, createRootGuard, isTextName, looksBinary, toRelative } from "./files.js";
|
|
5
6
|
export const MAX_CONTENT_RESULTS = 500;
|
|
6
7
|
export const MAX_CONTENT_FILE_BYTES = 512 * 1024;
|
|
7
8
|
export const MAX_CONTENT_DEPTH = 12;
|
|
8
9
|
export const MAX_CONTENT_MS = 10000;
|
|
10
|
+
let sdkRipgrep;
|
|
11
|
+
/** Pi's public package export hides this helper; resolve the installed module used by createGrepTool. */
|
|
12
|
+
async function resolveSdkRipgrep() {
|
|
13
|
+
sdkRipgrep ??= (async () => {
|
|
14
|
+
const entry = fileURLToPath(await import.meta.resolve("@earendil-works/pi-coding-agent"));
|
|
15
|
+
const manager = await import(pathToFileURL(join(dirname(entry), "utils", "tools-manager.js")).href);
|
|
16
|
+
return manager.getToolPath("rg") ?? manager.ensureTool("rg");
|
|
17
|
+
})();
|
|
18
|
+
return sdkRipgrep;
|
|
19
|
+
}
|
|
20
|
+
function normalizeLine(text) { return text.endsWith("\r\n") ? text.slice(0, -2) : text.endsWith("\n") ? text.slice(0, -1) : text; }
|
|
9
21
|
/**
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* The SDK owns ripgrep and its match/byte/line truncation; we expose those limits, never hide them.
|
|
22
|
+
* One rg process for all indexed files. createGrepTool is deliberately not used: its path accepts only
|
|
23
|
+
* one directory/file and glob cannot express this arbitrary index set, so it would walk a second tree or
|
|
24
|
+
* spawn once per file. Matching still uses the SDK's exact ensureTool/getToolPath-selected ripgrep.
|
|
25
|
+
* The installed rg has no --files-from flag, so verified absolute candidates are passed as paths.
|
|
15
26
|
*/
|
|
16
|
-
|
|
17
|
-
|
|
27
|
+
const runRipgrep = ({ rgPath, pattern, paths, signal, onMatch }) => new Promise((resolve, reject) => {
|
|
28
|
+
const child = spawn(rgPath, ["--json", "--line-number", "--color=never", "--hidden", "--no-ignore", "--text", "--fixed-strings", "--", pattern, ...paths], { stdio: ["ignore", "pipe", "pipe"], shell: false, windowsHide: true });
|
|
29
|
+
let stdout = "";
|
|
30
|
+
let stderr = "";
|
|
31
|
+
let chain = Promise.resolve(true);
|
|
32
|
+
let stopped = false;
|
|
33
|
+
const stop = () => { if (!stopped) {
|
|
34
|
+
stopped = true;
|
|
35
|
+
child.kill();
|
|
36
|
+
} };
|
|
37
|
+
const onAbort = () => stop();
|
|
38
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
39
|
+
child.stdout.setEncoding("utf8");
|
|
40
|
+
child.stdout.on("data", (chunk) => {
|
|
41
|
+
stdout += chunk;
|
|
42
|
+
let newline;
|
|
43
|
+
while ((newline = stdout.indexOf("\n")) >= 0) {
|
|
44
|
+
const row = stdout.slice(0, newline);
|
|
45
|
+
stdout = stdout.slice(newline + 1);
|
|
46
|
+
chain = chain.then(async (continueSearch) => {
|
|
47
|
+
if (!continueSearch)
|
|
48
|
+
return false;
|
|
49
|
+
let event;
|
|
50
|
+
try {
|
|
51
|
+
event = JSON.parse(row);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
if (event.type !== "match")
|
|
57
|
+
return true;
|
|
58
|
+
const path = event.data?.path?.text;
|
|
59
|
+
const line = event.data?.line_number;
|
|
60
|
+
const text = event.data?.lines?.text;
|
|
61
|
+
if (!path || !Number.isSafeInteger(line))
|
|
62
|
+
return true;
|
|
63
|
+
const keepGoing = await onMatch({ path, line: line, text: typeof text === "string" ? normalizeLine(text) : "" });
|
|
64
|
+
if (!keepGoing)
|
|
65
|
+
stop();
|
|
66
|
+
return keepGoing;
|
|
67
|
+
});
|
|
68
|
+
chain.catch(() => { });
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
child.stderr.setEncoding("utf8");
|
|
72
|
+
child.stderr.on("data", (chunk) => { stderr += chunk; });
|
|
73
|
+
child.on("error", (error) => { signal.removeEventListener("abort", onAbort); reject(new Error(`Failed to run ripgrep: ${error.message}`)); });
|
|
74
|
+
child.on("close", (code) => {
|
|
75
|
+
signal.removeEventListener("abort", onAbort);
|
|
76
|
+
void chain.then(() => {
|
|
77
|
+
if (signal.aborted || stopped || code === 0 || code === 1)
|
|
78
|
+
resolve();
|
|
79
|
+
else
|
|
80
|
+
reject(new Error(`ripgrep failed (${code}): ${stderr.trim() || "unknown error"}`));
|
|
81
|
+
}, reject);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
async function checkedTextFile(absolute) {
|
|
85
|
+
try {
|
|
86
|
+
const info = await stat(absolute);
|
|
87
|
+
if (!info.isFile())
|
|
88
|
+
return "skip";
|
|
89
|
+
if (info.size > MAX_CONTENT_FILE_BYTES)
|
|
90
|
+
return "size";
|
|
91
|
+
const handle = await open(absolute, "r");
|
|
92
|
+
try {
|
|
93
|
+
const buffer = Buffer.alloc(MAX_CONTENT_FILE_BYTES + 1);
|
|
94
|
+
let length = 0;
|
|
95
|
+
while (length < buffer.length) {
|
|
96
|
+
const read = await handle.read(buffer, length, buffer.length - length, null);
|
|
97
|
+
if (!read.bytesRead)
|
|
98
|
+
break;
|
|
99
|
+
length += read.bytesRead;
|
|
100
|
+
}
|
|
101
|
+
if (length > MAX_CONTENT_FILE_BYTES)
|
|
102
|
+
return "size";
|
|
103
|
+
const bytes = buffer.subarray(0, length);
|
|
104
|
+
if (looksBinary(bytes) || bytes.includes(0))
|
|
105
|
+
return "skip";
|
|
106
|
+
new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
107
|
+
return "text";
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
await handle.close();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return "skip";
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
async function checkedFileSize(absolute) {
|
|
118
|
+
try {
|
|
119
|
+
const info = await stat(absolute);
|
|
120
|
+
return !info.isFile() ? "skip" : info.size > MAX_CONTENT_FILE_BYTES ? "size" : "ok";
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return "skip";
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function needsContainmentCheck(path) {
|
|
127
|
+
return /(^|[\\/])\.\.([\\/]|$)|^[\\/]|^[A-Za-z]:/.test(path);
|
|
128
|
+
}
|
|
129
|
+
async function mapConcurrently(items, limit, fn) {
|
|
130
|
+
const output = new Array(items.length);
|
|
131
|
+
let next = 0;
|
|
132
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
133
|
+
for (;;) {
|
|
134
|
+
const index = next++;
|
|
135
|
+
if (index >= items.length)
|
|
136
|
+
return;
|
|
137
|
+
output[index] = await fn(items[index]);
|
|
138
|
+
}
|
|
139
|
+
}));
|
|
140
|
+
return output;
|
|
141
|
+
}
|
|
142
|
+
export async function searchByContent(root, candidates, query, indexStatus = { complete: true, skipped: [] }, options = {}) {
|
|
143
|
+
const guard = createRootGuard(root);
|
|
144
|
+
await guard("");
|
|
18
145
|
const hits = [];
|
|
19
146
|
const skipped = new Set(indexStatus.skipped);
|
|
20
147
|
const reasons = new Set(indexStatus.complete ? [] : ["index"]);
|
|
21
148
|
const needle = query.trim();
|
|
22
149
|
if (!needle)
|
|
23
150
|
return { hits, truncated: false, skipped: [...skipped], complete: indexStatus.complete, reasons: [...reasons] };
|
|
24
|
-
const tool = createGrepTool(root);
|
|
25
151
|
const controller = new AbortController();
|
|
26
152
|
const timer = setTimeout(() => controller.abort(), MAX_CONTENT_MS);
|
|
153
|
+
const paths = [];
|
|
154
|
+
const candidateByPath = new Map();
|
|
155
|
+
const eligible = [];
|
|
156
|
+
for (const candidate of candidates) {
|
|
157
|
+
if (controller.signal.aborted) {
|
|
158
|
+
reasons.add("time");
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
if (candidate.kind !== "file")
|
|
162
|
+
continue;
|
|
163
|
+
if (candidate.path.replace(/\\/g, "/").split("/").length - 1 > MAX_CONTENT_DEPTH) {
|
|
164
|
+
skipped.add(candidate.path);
|
|
165
|
+
reasons.add("depth");
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (candidate.text === false || !isTextName(candidate.path)) {
|
|
169
|
+
skipped.add(candidate.path);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (candidate.size !== null && candidate.size > MAX_CONTENT_FILE_BYTES) {
|
|
173
|
+
skipped.add(candidate.path);
|
|
174
|
+
reasons.add("size");
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
// Index paths are normalized and symlink-free. Reject any shape that could escape before creating an
|
|
178
|
+
// rg argument; all output still takes the full shared containment path below before it is published.
|
|
179
|
+
if (needsContainmentCheck(candidate.path)) {
|
|
180
|
+
await guard(candidate.path);
|
|
181
|
+
}
|
|
182
|
+
eligible.push(candidate);
|
|
183
|
+
}
|
|
184
|
+
// Do bounded metadata checks concurrently. rg's binary heuristic cannot prove strict UTF-8, so matching
|
|
185
|
+
// files get the full byte check below; output passes the shared containment guard before publication.
|
|
186
|
+
const checkedCandidates = await mapConcurrently(eligible, 96, async (candidate) => ({ candidate, absolute: join(root, candidate.path), classification: await checkedFileSize(join(root, candidate.path)) }));
|
|
187
|
+
for (const { candidate, absolute, classification } of checkedCandidates) {
|
|
188
|
+
if (controller.signal.aborted) {
|
|
189
|
+
reasons.add("time");
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
if (classification !== "ok") {
|
|
193
|
+
skipped.add(candidate.path);
|
|
194
|
+
if (classification === "size")
|
|
195
|
+
reasons.add("size");
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
paths.push(absolute);
|
|
199
|
+
candidateByPath.set(candidate.path, candidate);
|
|
200
|
+
}
|
|
27
201
|
try {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
catch (error) {
|
|
41
|
-
const code = error.code;
|
|
42
|
-
if (code === "outside_workspace" || code === "bad_path" || code === "workspace_missing")
|
|
43
|
-
throw error;
|
|
44
|
-
skipped.add(candidate.path);
|
|
45
|
-
reasons.add("unreadable");
|
|
46
|
-
continue;
|
|
47
|
-
}
|
|
48
|
-
if (candidate.path.replace(/\\/g, "/").split("/").length - 1 > MAX_CONTENT_DEPTH) {
|
|
49
|
-
skipped.add(candidate.path);
|
|
50
|
-
reasons.add("depth");
|
|
51
|
-
continue;
|
|
52
|
-
}
|
|
53
|
-
if (candidate.text === false || !isTextName(candidate.path)) {
|
|
54
|
-
skipped.add(candidate.path);
|
|
55
|
-
continue;
|
|
56
|
-
}
|
|
57
|
-
let bytes;
|
|
58
|
-
try {
|
|
59
|
-
// Read at most cap+1 bytes even if a file grows after stat; never allocate its untrusted size.
|
|
60
|
-
const handle = await open(absolute, "r");
|
|
202
|
+
if (controller.signal.aborted)
|
|
203
|
+
return { hits, truncated: true, skipped: [...skipped], complete: indexStatus.complete, reasons: [...reasons] };
|
|
204
|
+
const rgPath = await (options.resolveRipgrep ?? resolveSdkRipgrep)();
|
|
205
|
+
if (!rgPath)
|
|
206
|
+
throw new FilesError(503, "内容搜索不可用(ripgrep 不可用)", "ripgrep_unavailable");
|
|
207
|
+
await (options.runRipgrep ?? runRipgrep)({ rgPath, pattern: needle, paths, signal: controller.signal, onMatch: async (match) => {
|
|
208
|
+
if (controller.signal.aborted) {
|
|
209
|
+
reasons.add("time");
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
let path;
|
|
213
|
+
let absolute;
|
|
61
214
|
try {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
skipped.add(candidate.path);
|
|
65
|
-
reasons.add("unreadable");
|
|
66
|
-
continue;
|
|
67
|
-
}
|
|
68
|
-
if (info.size > MAX_CONTENT_FILE_BYTES) {
|
|
69
|
-
skipped.add(candidate.path);
|
|
70
|
-
reasons.add("size");
|
|
71
|
-
continue;
|
|
72
|
-
}
|
|
73
|
-
const buffer = Buffer.alloc(MAX_CONTENT_FILE_BYTES + 1);
|
|
74
|
-
let length = 0;
|
|
75
|
-
while (length < buffer.length) {
|
|
76
|
-
const read = await handle.read(buffer, length, buffer.length - length, null);
|
|
77
|
-
if (!read.bytesRead)
|
|
78
|
-
break;
|
|
79
|
-
length += read.bytesRead;
|
|
80
|
-
}
|
|
81
|
-
if (length > MAX_CONTENT_FILE_BYTES) {
|
|
82
|
-
skipped.add(candidate.path);
|
|
83
|
-
reasons.add("size");
|
|
84
|
-
continue;
|
|
85
|
-
}
|
|
86
|
-
bytes = buffer.subarray(0, length);
|
|
215
|
+
path = toRelative(root, match.path);
|
|
216
|
+
absolute = await guard(path);
|
|
87
217
|
}
|
|
88
|
-
|
|
89
|
-
|
|
218
|
+
catch (error) {
|
|
219
|
+
const code = error.code;
|
|
220
|
+
if (code === "outside_workspace" || code === "bad_path" || code === "workspace_missing")
|
|
221
|
+
throw error;
|
|
222
|
+
skipped.add(match.path);
|
|
223
|
+
reasons.add("unreadable");
|
|
224
|
+
return true;
|
|
90
225
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
continue;
|
|
96
|
-
}
|
|
97
|
-
if (looksBinary(bytes) || bytes.includes(0)) {
|
|
98
|
-
skipped.add(candidate.path);
|
|
99
|
-
continue;
|
|
100
|
-
}
|
|
101
|
-
try {
|
|
102
|
-
new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
103
|
-
}
|
|
104
|
-
catch {
|
|
105
|
-
skipped.add(candidate.path);
|
|
106
|
-
continue;
|
|
107
|
-
}
|
|
108
|
-
let result;
|
|
109
|
-
try {
|
|
110
|
-
result = await tool.execute("content-search", {
|
|
111
|
-
pattern: needle, path: absolute, literal: true, limit: MAX_CONTENT_RESULTS - hits.length,
|
|
112
|
-
}, controller.signal);
|
|
113
|
-
}
|
|
114
|
-
catch (error) {
|
|
115
|
-
if (controller.signal.aborted) {
|
|
116
|
-
reasons.add("time");
|
|
117
|
-
break;
|
|
226
|
+
if (!candidateByPath.has(path)) {
|
|
227
|
+
skipped.add(path);
|
|
228
|
+
reasons.add("unreadable");
|
|
229
|
+
return true;
|
|
118
230
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
if (block.type !== "text")
|
|
126
|
-
continue;
|
|
127
|
-
for (const row of block.text.split("\n")) {
|
|
128
|
-
if (!row.startsWith(prefix))
|
|
129
|
-
continue;
|
|
130
|
-
const match = /^(\d+): (.*)$/.exec(row.slice(prefix.length));
|
|
131
|
-
if (!match)
|
|
132
|
-
continue;
|
|
133
|
-
rows.push({ path: candidate.path, name: basename(absolute), line: Number(match[1]), text: match[2] });
|
|
231
|
+
const classification = await checkedTextFile(absolute);
|
|
232
|
+
if (classification !== "text") {
|
|
233
|
+
skipped.add(path);
|
|
234
|
+
if (classification === "size")
|
|
235
|
+
reasons.add("size");
|
|
236
|
+
return true;
|
|
134
237
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
reasons.add("line");
|
|
145
|
-
if (details?.matchLimitReached || hits.length >= MAX_CONTENT_RESULTS) {
|
|
146
|
-
reasons.add("results");
|
|
147
|
-
break;
|
|
148
|
-
}
|
|
149
|
-
}
|
|
238
|
+
hits.push({ path, name: basename(absolute), line: match.line, text: match.text });
|
|
239
|
+
if (hits.length >= MAX_CONTENT_RESULTS) {
|
|
240
|
+
reasons.add("results");
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
return true;
|
|
244
|
+
} });
|
|
245
|
+
if (controller.signal.aborted)
|
|
246
|
+
reasons.add("time");
|
|
150
247
|
}
|
|
151
248
|
finally {
|
|
152
249
|
clearTimeout(timer);
|
package/dist/server/store.js
CHANGED
|
@@ -508,28 +508,48 @@ function reconcileTodos(state) {
|
|
|
508
508
|
*
|
|
509
509
|
* If a task originated from a tool call in a conversation, its tool call block is marked as failed
|
|
510
510
|
* so the chat transcript accurately reflects that the task was interrupted by server restart.
|
|
511
|
+
*
|
|
512
|
+
* WHY ONLY THE LINKED ONES ARE SWEPT, and not every entry in the list.
|
|
513
|
+
*
|
|
514
|
+
* A task is created by exactly one route: the `/api/exec` endpoint, and that endpoint REQUIRES a
|
|
515
|
+
* `conversationId` (it is what the capability check and the tool-call anchor are resolved against).
|
|
516
|
+
* So every task this server ever creates is linked to a conversation's tool call, and every one of
|
|
517
|
+
* those is swept here - the zombie problem is fully covered.
|
|
518
|
+
*
|
|
519
|
+
* A task with NO conversation anchor is a shape this server cannot produce. The sweep cannot account
|
|
520
|
+
* for it either: there is no tool call to mark failed, so there is nothing to tell the reader that
|
|
521
|
+
* the task died. Deleting it would remove a record without replacing it with an explanation, which is
|
|
522
|
+
* the one outcome worse than a stale entry. So those are LEFT ALONE, and this function says so in its
|
|
523
|
+
* return value rather than silently keeping them.
|
|
511
524
|
*/
|
|
512
525
|
function reconcileBackgroundTasks(state) {
|
|
513
526
|
const tasks = Array.isArray(state.backgroundTasks) ? state.backgroundTasks : [];
|
|
514
527
|
state.backgroundTasks = [];
|
|
515
528
|
if (!tasks.length)
|
|
516
|
-
return { orphaned: [], changed: false };
|
|
529
|
+
return { orphaned: [], changed: false, unlinked: [] };
|
|
517
530
|
const orphaned = [];
|
|
531
|
+
const unlinked = [];
|
|
518
532
|
for (const task of tasks) {
|
|
519
533
|
if (!task)
|
|
520
534
|
continue;
|
|
535
|
+
// The three fields are destructured rather than tested through a boolean, because a boolean does not
|
|
536
|
+
// narrow their types and `toolCalls[undefined]` is not an index.
|
|
537
|
+
const { conversationId, originMessageId, originCallRow } = task;
|
|
538
|
+
if (!conversationId || originMessageId === undefined || originCallRow === undefined) {
|
|
539
|
+
unlinked.push(task);
|
|
540
|
+
state.backgroundTasks.push(task);
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
521
543
|
orphaned.push(task);
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
call.backgroundTask.output = "任务随服务重启被终止";
|
|
529
|
-
}
|
|
544
|
+
const conv = state.conversations?.find((c) => c?.id === conversationId);
|
|
545
|
+
const msg = conv?.messages?.find((m) => m?.id === originMessageId);
|
|
546
|
+
const call = msg?.toolCalls?.[originCallRow];
|
|
547
|
+
if (call?.backgroundTask && call.backgroundTask.status === "running") {
|
|
548
|
+
call.backgroundTask.status = "failed";
|
|
549
|
+
call.backgroundTask.output = "任务随服务重启被终止";
|
|
530
550
|
}
|
|
531
551
|
}
|
|
532
|
-
return { orphaned, changed:
|
|
552
|
+
return { orphaned, changed: orphaned.length > 0, unlinked };
|
|
533
553
|
}
|
|
534
554
|
export class Store {
|
|
535
555
|
repository;
|
|
@@ -579,6 +599,12 @@ export class Store {
|
|
|
579
599
|
console.warn(`Super Pi: workflow ${plan.id} was still marked running when the last server stopped - it is now stopped, because no step can still be executing.`);
|
|
580
600
|
for (const task of orphanedTasks.orphaned)
|
|
581
601
|
console.warn(`Super Pi: background task ${task.id} (${task.title}) was left in flight when the last server stopped - it has been cleaned up.`);
|
|
602
|
+
// The unlinked ones are said out loud rather than swept silently. They cannot come from this
|
|
603
|
+
// server - `/api/exec` requires a conversationId - so they are a hand-written or legacy entry,
|
|
604
|
+
// and the reader is told they were left alone instead of wondering why one task vanished and
|
|
605
|
+
// another did not.
|
|
606
|
+
for (const task of orphanedTasks.unlinked)
|
|
607
|
+
console.warn(`Super Pi: background task ${task.id} (${task.title}) has no conversation to be traced back to - it was left in place, because there is no tool call to mark as interrupted.`);
|
|
582
608
|
// Announced for the same reason: a conversation that failed while nobody was watching is invisible
|
|
583
609
|
// otherwise, and the user who was watching a spinner when the server died deserves to learn that
|
|
584
610
|
// their run is over rather than guessing from the fact that it stopped moving.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@evandu/super-pi",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.108",
|
|
4
4
|
"description": "Web control plane and companion for Pi",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -94,6 +94,7 @@
|
|
|
94
94
|
"scripts/task-tool.test.mjs",
|
|
95
95
|
"scripts/tool-register-env.test.mjs",
|
|
96
96
|
"scripts/bg-task-zombie.test.mjs",
|
|
97
|
+
"scripts/scoped-sidebar-tabs.test.mjs",
|
|
97
98
|
"scripts/fork-server.test.mjs",
|
|
98
99
|
"scripts/styles-no-dangling.test.mjs",
|
|
99
100
|
"scripts/theme-no-hardcoded-colours.test.mjs",
|