@oh-my-pi/pi-coding-agent 13.14.0 → 13.15.2
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/CHANGELOG.md +140 -0
- package/package.json +10 -8
- package/src/autoresearch/command-initialize.md +34 -0
- package/src/autoresearch/command-resume.md +17 -0
- package/src/autoresearch/contract.ts +332 -0
- package/src/autoresearch/dashboard.ts +447 -0
- package/src/autoresearch/git.ts +243 -0
- package/src/autoresearch/helpers.ts +458 -0
- package/src/autoresearch/index.ts +693 -0
- package/src/autoresearch/prompt.md +227 -0
- package/src/autoresearch/resume-message.md +16 -0
- package/src/autoresearch/state.ts +386 -0
- package/src/autoresearch/tools/init-experiment.ts +310 -0
- package/src/autoresearch/tools/log-experiment.ts +833 -0
- package/src/autoresearch/tools/run-experiment.ts +640 -0
- package/src/autoresearch/types.ts +218 -0
- package/src/cli/args.ts +8 -2
- package/src/cli/initial-message.ts +58 -0
- package/src/config/keybindings.ts +417 -212
- package/src/config/model-registry.ts +1 -0
- package/src/config/model-resolver.ts +57 -9
- package/src/config/settings-schema.ts +38 -10
- package/src/config/settings.ts +1 -4
- package/src/exec/bash-executor.ts +7 -5
- package/src/export/html/template.css +43 -13
- package/src/export/html/template.generated.ts +1 -1
- package/src/export/html/template.html +1 -0
- package/src/export/html/template.js +107 -0
- package/src/extensibility/extensions/types.ts +31 -8
- package/src/internal-urls/docs-index.generated.ts +1 -1
- package/src/lsp/index.ts +1 -1
- package/src/main.ts +44 -44
- package/src/mcp/oauth-discovery.ts +1 -1
- package/src/modes/acp/acp-agent.ts +957 -0
- package/src/modes/acp/acp-event-mapper.ts +531 -0
- package/src/modes/acp/acp-mode.ts +13 -0
- package/src/modes/acp/index.ts +2 -0
- package/src/modes/components/agent-dashboard.ts +5 -4
- package/src/modes/components/bash-execution.ts +40 -11
- package/src/modes/components/custom-editor.ts +47 -47
- package/src/modes/components/extensions/extension-dashboard.ts +2 -1
- package/src/modes/components/history-search.ts +2 -1
- package/src/modes/components/hook-editor.ts +2 -1
- package/src/modes/components/hook-input.ts +8 -7
- package/src/modes/components/hook-selector.ts +15 -10
- package/src/modes/components/keybinding-hints.ts +9 -9
- package/src/modes/components/login-dialog.ts +3 -3
- package/src/modes/components/mcp-add-wizard.ts +2 -1
- package/src/modes/components/model-selector.ts +14 -3
- package/src/modes/components/oauth-selector.ts +2 -1
- package/src/modes/components/python-execution.ts +2 -3
- package/src/modes/components/session-selector.ts +2 -1
- package/src/modes/components/settings-selector.ts +2 -1
- package/src/modes/components/status-line-segment-editor.ts +2 -1
- package/src/modes/components/tool-execution.ts +4 -5
- package/src/modes/components/tree-selector.ts +3 -2
- package/src/modes/components/user-message-selector.ts +3 -8
- package/src/modes/components/user-message.ts +16 -0
- package/src/modes/controllers/command-controller.ts +0 -2
- package/src/modes/controllers/extension-ui-controller.ts +89 -4
- package/src/modes/controllers/input-controller.ts +29 -23
- package/src/modes/controllers/mcp-command-controller.ts +1 -1
- package/src/modes/index.ts +1 -0
- package/src/modes/interactive-mode.ts +17 -5
- package/src/modes/print-mode.ts +1 -1
- package/src/modes/prompt-action-autocomplete.ts +7 -7
- package/src/modes/rpc/rpc-mode.ts +7 -2
- package/src/modes/rpc/rpc-types.ts +1 -0
- package/src/modes/theme/theme.ts +53 -44
- package/src/modes/types.ts +9 -2
- package/src/modes/utils/hotkeys-markdown.ts +19 -19
- package/src/modes/utils/keybinding-matchers.ts +21 -0
- package/src/modes/utils/ui-helpers.ts +1 -1
- package/src/patch/hashline.ts +139 -127
- package/src/patch/index.ts +77 -59
- package/src/patch/shared.ts +19 -11
- package/src/prompts/tools/hashline.md +43 -116
- package/src/sdk.ts +34 -17
- package/src/session/agent-session.ts +123 -30
- package/src/session/session-manager.ts +32 -31
- package/src/session/streaming-output.ts +87 -37
- package/src/tools/ask.ts +56 -30
- package/src/tools/bash-interactive.ts +2 -6
- package/src/tools/bash-interceptor.ts +1 -39
- package/src/tools/bash-skill-urls.ts +1 -1
- package/src/tools/browser.ts +1 -1
- package/src/tools/gemini-image.ts +1 -1
- package/src/tools/python.ts +2 -2
- package/src/tools/resolve.ts +1 -1
- package/src/utils/child-process.ts +88 -0
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { isEnoent } from "@oh-my-pi/pi-utils";
|
|
4
|
+
import { parseCommandArgs } from "../utils/command-args";
|
|
5
|
+
import type {
|
|
6
|
+
ASIData,
|
|
7
|
+
ASIValue,
|
|
8
|
+
AutoresearchConfig,
|
|
9
|
+
MetricDirection,
|
|
10
|
+
NumericMetricMap,
|
|
11
|
+
PendingRunSummary,
|
|
12
|
+
} from "./types";
|
|
13
|
+
|
|
14
|
+
export const METRIC_LINE_PREFIX = "METRIC";
|
|
15
|
+
export const ASI_LINE_PREFIX = "ASI";
|
|
16
|
+
export const EXPERIMENT_MAX_LINES = 10;
|
|
17
|
+
export const EXPERIMENT_MAX_BYTES = 4 * 1024;
|
|
18
|
+
export const AUTORESEARCH_COMMITTABLE_FILES = [
|
|
19
|
+
"autoresearch.md",
|
|
20
|
+
"autoresearch.program.md",
|
|
21
|
+
"autoresearch.sh",
|
|
22
|
+
"autoresearch.checks.sh",
|
|
23
|
+
"autoresearch.ideas.md",
|
|
24
|
+
] as const;
|
|
25
|
+
export const AUTORESEARCH_LOCAL_STATE_FILES = ["autoresearch.jsonl"] as const;
|
|
26
|
+
export const AUTORESEARCH_LOCAL_STATE_DIRECTORIES = [".autoresearch"] as const;
|
|
27
|
+
|
|
28
|
+
const DENIED_KEY_NAMES = new Set(["__proto__", "constructor", "prototype"]);
|
|
29
|
+
|
|
30
|
+
export function parseMetricLines(output: string): Map<string, number> {
|
|
31
|
+
const metrics = new Map<string, number>();
|
|
32
|
+
const regex = new RegExp(`^${METRIC_LINE_PREFIX}\\s+([\\w.µ-]+)=(\\S+)\\s*$`, "gm");
|
|
33
|
+
let match = regex.exec(output);
|
|
34
|
+
while (match !== null) {
|
|
35
|
+
const name = match[1];
|
|
36
|
+
if (!DENIED_KEY_NAMES.has(name)) {
|
|
37
|
+
const value = Number(match[2]);
|
|
38
|
+
if (Number.isFinite(value)) {
|
|
39
|
+
metrics.set(name, value);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
match = regex.exec(output);
|
|
43
|
+
}
|
|
44
|
+
return metrics;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function parseAsiLines(output: string): ASIData | null {
|
|
48
|
+
const asi: ASIData = {};
|
|
49
|
+
const regex = new RegExp(`^${ASI_LINE_PREFIX}\\s+([\\w.-]+)=(.+)\\s*$`, "gm");
|
|
50
|
+
let match = regex.exec(output);
|
|
51
|
+
while (match !== null) {
|
|
52
|
+
const key = match[1];
|
|
53
|
+
if (!DENIED_KEY_NAMES.has(key)) {
|
|
54
|
+
asi[key] = parseAsiValue(match[2]);
|
|
55
|
+
}
|
|
56
|
+
match = regex.exec(output);
|
|
57
|
+
}
|
|
58
|
+
return Object.keys(asi).length > 0 ? asi : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function parseAsiValue(raw: string): ASIValue {
|
|
62
|
+
const value = raw.trim();
|
|
63
|
+
if (value === "true") return true;
|
|
64
|
+
if (value === "false") return false;
|
|
65
|
+
if (value === "null") return null;
|
|
66
|
+
if (/^-?\d+(?:\.\d+)?$/.test(value)) {
|
|
67
|
+
const numberValue = Number(value);
|
|
68
|
+
if (Number.isFinite(numberValue)) return numberValue;
|
|
69
|
+
}
|
|
70
|
+
if (value.startsWith("{") || value.startsWith("[") || value.startsWith('"')) {
|
|
71
|
+
try {
|
|
72
|
+
const parsed = JSON.parse(value) as ASIValue;
|
|
73
|
+
return parsed;
|
|
74
|
+
} catch {
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return value;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function mergeAsi(base: ASIData | null, override: ASIData | undefined): ASIData | undefined {
|
|
82
|
+
if (!base && !override) return undefined;
|
|
83
|
+
return {
|
|
84
|
+
...(base ?? {}),
|
|
85
|
+
...(override ?? {}),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function commas(value: number): string {
|
|
90
|
+
const sign = value < 0 ? "-" : "";
|
|
91
|
+
const digits = String(Math.trunc(Math.abs(value)));
|
|
92
|
+
const groups: string[] = [];
|
|
93
|
+
for (let index = digits.length; index > 0; index -= 3) {
|
|
94
|
+
groups.unshift(digits.slice(Math.max(0, index - 3), index));
|
|
95
|
+
}
|
|
96
|
+
return sign + groups.join(",");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function fmtNum(value: number, decimals: number = 0): string {
|
|
100
|
+
if (decimals <= 0) return commas(Math.round(value));
|
|
101
|
+
const absolute = Math.abs(value);
|
|
102
|
+
const whole = Math.floor(absolute);
|
|
103
|
+
const fraction = (absolute - whole).toFixed(decimals).slice(1);
|
|
104
|
+
return `${value < 0 ? "-" : ""}${commas(whole)}${fraction}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function formatNum(value: number | null, unit: string): string {
|
|
108
|
+
if (value === null) return "-";
|
|
109
|
+
if (Number.isInteger(value)) return `${fmtNum(value)}${unit}`;
|
|
110
|
+
return `${fmtNum(value, 2)}${unit}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function formatElapsed(milliseconds: number): string {
|
|
114
|
+
const totalSeconds = Math.floor(milliseconds / 1000);
|
|
115
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
116
|
+
const seconds = totalSeconds % 60;
|
|
117
|
+
if (minutes > 0) {
|
|
118
|
+
return `${minutes}m ${String(seconds).padStart(2, "0")}s`;
|
|
119
|
+
}
|
|
120
|
+
return `${seconds}s`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function getAutoresearchRunDirectory(workDir: string, runNumber: number): string {
|
|
124
|
+
return path.join(workDir, ".autoresearch", "runs", String(runNumber).padStart(4, "0"));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function getNextAutoresearchRunNumber(workDir: string, lastRunNumber: number | null): number {
|
|
128
|
+
const runsDirectory = path.join(workDir, ".autoresearch", "runs");
|
|
129
|
+
let maxRunNumber = lastRunNumber ?? 0;
|
|
130
|
+
try {
|
|
131
|
+
for (const entry of fs.readdirSync(runsDirectory, { withFileTypes: true })) {
|
|
132
|
+
if (!entry.isDirectory()) continue;
|
|
133
|
+
const runNumber = Number.parseInt(entry.name, 10);
|
|
134
|
+
if (Number.isFinite(runNumber)) {
|
|
135
|
+
maxRunNumber = Math.max(maxRunNumber, runNumber);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
} catch (error) {
|
|
139
|
+
if (!isEnoent(error)) {
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return maxRunNumber + 1;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function normalizeAutoresearchPath(relativePath: string): string {
|
|
147
|
+
const normalized = relativePath.replaceAll("\\", "/").trim();
|
|
148
|
+
if (normalized === "." || normalized === "./") return ".";
|
|
149
|
+
return normalized.replace(/^\.\/+/, "").replace(/\/+$/, "");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function isAutoresearchCommittableFile(relativePath: string): boolean {
|
|
153
|
+
const normalized = normalizeAutoresearchPath(relativePath);
|
|
154
|
+
return AUTORESEARCH_COMMITTABLE_FILES.some(candidate => candidate === normalized);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function isAutoresearchLocalStatePath(relativePath: string): boolean {
|
|
158
|
+
const normalized = normalizeAutoresearchPath(relativePath);
|
|
159
|
+
if (AUTORESEARCH_LOCAL_STATE_FILES.some(candidate => candidate === normalized)) {
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
return AUTORESEARCH_LOCAL_STATE_DIRECTORIES.some(candidate => {
|
|
163
|
+
const normalizedCandidate = normalizeAutoresearchPath(candidate);
|
|
164
|
+
return normalized === normalizedCandidate || normalized.startsWith(`${normalizedCandidate}/`);
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function killTree(pid: number, signal: NodeJS.Signals | number = "SIGTERM"): void {
|
|
169
|
+
try {
|
|
170
|
+
process.kill(-pid, signal);
|
|
171
|
+
} catch {
|
|
172
|
+
try {
|
|
173
|
+
process.kill(pid, signal);
|
|
174
|
+
} catch {
|
|
175
|
+
// Process already exited.
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function isAutoresearchShCommand(command: string): boolean {
|
|
181
|
+
let normalized = command.trim();
|
|
182
|
+
normalized = normalized.replace(/^(?:\w+=\S*\s+)+/, "");
|
|
183
|
+
|
|
184
|
+
let previous = "";
|
|
185
|
+
while (previous !== normalized) {
|
|
186
|
+
previous = normalized;
|
|
187
|
+
normalized = normalized.replace(/^(?:env|time|nice|nohup)(?:\s+-\S+(?:\s+\d+)?)?\s+/, "");
|
|
188
|
+
}
|
|
189
|
+
if (/[;&|<>]/.test(normalized)) {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const tokens = parseCommandArgs(normalized);
|
|
194
|
+
if (tokens.length === 0) return false;
|
|
195
|
+
|
|
196
|
+
let index = 0;
|
|
197
|
+
if (tokens[index] === "bash" || tokens[index] === "sh") {
|
|
198
|
+
index += 1;
|
|
199
|
+
while (index < tokens.length && tokens[index]?.startsWith("-")) {
|
|
200
|
+
if (tokens[index]?.includes("c")) {
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
index += 1;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const scriptToken = tokens[index];
|
|
208
|
+
if (!scriptToken || !/^(?:\.\/|\/[\w/.-]*\/)?autoresearch\.sh$/.test(scriptToken)) {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
for (const token of tokens.slice(index + 1)) {
|
|
213
|
+
if (token === "&&" || token === "||" || token === ";" || token === "|" || token === ">" || token === "<") {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function isBetter(current: number, best: number, direction: MetricDirection): boolean {
|
|
222
|
+
return direction === "lower" ? current < best : current > best;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function inferMetricUnitFromName(name: string): string {
|
|
226
|
+
if (name.endsWith("µs") || name.endsWith("_µs")) return "µs";
|
|
227
|
+
if (name.endsWith("ms") || name.endsWith("_ms")) return "ms";
|
|
228
|
+
if (name.endsWith("_s") || name.endsWith("_sec") || name.endsWith("_secs")) return "s";
|
|
229
|
+
if (name.endsWith("_kb") || name.endsWith("kb")) return "kb";
|
|
230
|
+
if (name.endsWith("_mb") || name.endsWith("mb")) return "mb";
|
|
231
|
+
return "";
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export async function readPendingRunSummary(
|
|
235
|
+
workDir: string,
|
|
236
|
+
loggedRunNumbers: ReadonlySet<number> = new Set<number>(),
|
|
237
|
+
): Promise<PendingRunSummary | null> {
|
|
238
|
+
const runsDir = path.join(workDir, ".autoresearch", "runs");
|
|
239
|
+
let entries: fs.Dirent[];
|
|
240
|
+
try {
|
|
241
|
+
entries = await fs.promises.readdir(runsDir, { withFileTypes: true });
|
|
242
|
+
} catch (error) {
|
|
243
|
+
if (isEnoent(error)) return null;
|
|
244
|
+
throw error;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const runDirectories = entries
|
|
248
|
+
.filter(entry => entry.isDirectory())
|
|
249
|
+
.map(entry => entry.name)
|
|
250
|
+
.sort((left, right) => right.localeCompare(left));
|
|
251
|
+
|
|
252
|
+
for (const directoryName of runDirectories) {
|
|
253
|
+
const runDirectory = path.join(runsDir, directoryName);
|
|
254
|
+
const runJsonPath = path.join(runDirectory, "run.json");
|
|
255
|
+
let parsed: unknown;
|
|
256
|
+
try {
|
|
257
|
+
parsed = await Bun.file(runJsonPath).json();
|
|
258
|
+
} catch (error) {
|
|
259
|
+
if (isEnoent(error)) continue;
|
|
260
|
+
throw error;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const pendingRun = parsePendingRunSummary(parsed, runDirectory, directoryName, loggedRunNumbers);
|
|
264
|
+
if (pendingRun) {
|
|
265
|
+
return pendingRun;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function readConfig(cwd: string): AutoresearchConfig {
|
|
273
|
+
const configPath = path.join(cwd, "autoresearch.config.json");
|
|
274
|
+
try {
|
|
275
|
+
const raw = fs.readFileSync(configPath, "utf8");
|
|
276
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
277
|
+
if (typeof parsed !== "object" || parsed === null) return {};
|
|
278
|
+
const candidate = parsed as { maxIterations?: unknown; workingDir?: unknown };
|
|
279
|
+
const config: AutoresearchConfig = {};
|
|
280
|
+
if (typeof candidate.maxIterations === "number" && Number.isFinite(candidate.maxIterations)) {
|
|
281
|
+
config.maxIterations = candidate.maxIterations;
|
|
282
|
+
}
|
|
283
|
+
if (typeof candidate.workingDir === "string" && candidate.workingDir.trim().length > 0) {
|
|
284
|
+
config.workingDir = candidate.workingDir;
|
|
285
|
+
}
|
|
286
|
+
return config;
|
|
287
|
+
} catch (error) {
|
|
288
|
+
if (isEnoent(error)) return {};
|
|
289
|
+
return {};
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function readMaxExperiments(cwd: string): number | null {
|
|
294
|
+
const value = readConfig(cwd).maxIterations;
|
|
295
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return null;
|
|
296
|
+
return Math.floor(value);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function resolveWorkDir(cwd: string): string {
|
|
300
|
+
const configured = readConfig(cwd).workingDir;
|
|
301
|
+
if (!configured) return cwd;
|
|
302
|
+
return path.isAbsolute(configured) ? configured : path.resolve(cwd, configured);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function validateWorkDir(cwd: string): string | null {
|
|
306
|
+
const workDir = resolveWorkDir(cwd);
|
|
307
|
+
try {
|
|
308
|
+
const stat = fs.statSync(workDir);
|
|
309
|
+
if (!stat.isDirectory()) {
|
|
310
|
+
return `workingDir ${workDir} is not a directory.`;
|
|
311
|
+
}
|
|
312
|
+
return null;
|
|
313
|
+
} catch (error) {
|
|
314
|
+
if (isEnoent(error)) {
|
|
315
|
+
return `workingDir ${workDir} does not exist.`;
|
|
316
|
+
}
|
|
317
|
+
return `workingDir ${workDir} is unavailable.`;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function parsePendingRunSummary(
|
|
322
|
+
value: unknown,
|
|
323
|
+
runDirectory: string,
|
|
324
|
+
directoryName: string,
|
|
325
|
+
loggedRunNumbers: ReadonlySet<number>,
|
|
326
|
+
): PendingRunSummary | null {
|
|
327
|
+
if (typeof value !== "object" || value === null) return null;
|
|
328
|
+
const candidate = value as {
|
|
329
|
+
checks?: { durationSeconds?: unknown; passed?: unknown; timedOut?: unknown };
|
|
330
|
+
completedAt?: unknown;
|
|
331
|
+
command?: unknown;
|
|
332
|
+
durationSeconds?: unknown;
|
|
333
|
+
exitCode?: unknown;
|
|
334
|
+
loggedAt?: unknown;
|
|
335
|
+
parsedAsi?: unknown;
|
|
336
|
+
parsedMetrics?: unknown;
|
|
337
|
+
parsedPrimary?: unknown;
|
|
338
|
+
runNumber?: unknown;
|
|
339
|
+
status?: unknown;
|
|
340
|
+
timedOut?: unknown;
|
|
341
|
+
};
|
|
342
|
+
if (candidate.loggedAt !== undefined || candidate.status !== undefined) {
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const command = typeof candidate.command === "string" ? candidate.command : "";
|
|
347
|
+
const runNumber =
|
|
348
|
+
typeof candidate.runNumber === "number" && Number.isFinite(candidate.runNumber)
|
|
349
|
+
? candidate.runNumber
|
|
350
|
+
: parseInt(directoryName, 10);
|
|
351
|
+
if (!Number.isFinite(runNumber)) return null;
|
|
352
|
+
if (loggedRunNumbers.has(runNumber)) return null;
|
|
353
|
+
|
|
354
|
+
const hasCompletedMetadata =
|
|
355
|
+
typeof candidate.completedAt === "string" ||
|
|
356
|
+
candidate.exitCode !== undefined ||
|
|
357
|
+
candidate.timedOut !== undefined ||
|
|
358
|
+
candidate.durationSeconds !== undefined ||
|
|
359
|
+
candidate.checks !== undefined ||
|
|
360
|
+
candidate.parsedPrimary !== undefined ||
|
|
361
|
+
candidate.parsedMetrics !== undefined ||
|
|
362
|
+
candidate.parsedAsi !== undefined;
|
|
363
|
+
if (!hasCompletedMetadata) {
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const checksPass =
|
|
368
|
+
typeof candidate.checks?.passed === "boolean"
|
|
369
|
+
? candidate.checks.passed
|
|
370
|
+
: typeof candidate.checks?.timedOut === "boolean" && candidate.checks.timedOut
|
|
371
|
+
? false
|
|
372
|
+
: null;
|
|
373
|
+
const exitCode =
|
|
374
|
+
typeof candidate.exitCode === "number" && Number.isFinite(candidate.exitCode) ? candidate.exitCode : null;
|
|
375
|
+
const timedOut = candidate.timedOut === true;
|
|
376
|
+
const durationSeconds =
|
|
377
|
+
typeof candidate.durationSeconds === "number" && Number.isFinite(candidate.durationSeconds)
|
|
378
|
+
? candidate.durationSeconds
|
|
379
|
+
: null;
|
|
380
|
+
const parsedPrimary =
|
|
381
|
+
typeof candidate.parsedPrimary === "number" && Number.isFinite(candidate.parsedPrimary)
|
|
382
|
+
? candidate.parsedPrimary
|
|
383
|
+
: null;
|
|
384
|
+
const parsedAsi = cloneAsiData(candidate.parsedAsi);
|
|
385
|
+
const parsedMetrics = cloneNumericMetricMap(candidate.parsedMetrics);
|
|
386
|
+
const checksDurationSeconds =
|
|
387
|
+
typeof candidate.checks?.durationSeconds === "number" && Number.isFinite(candidate.checks.durationSeconds)
|
|
388
|
+
? candidate.checks.durationSeconds
|
|
389
|
+
: null;
|
|
390
|
+
const checksTimedOut = candidate.checks?.timedOut === true;
|
|
391
|
+
|
|
392
|
+
return {
|
|
393
|
+
checksDurationSeconds,
|
|
394
|
+
checksPass,
|
|
395
|
+
checksTimedOut,
|
|
396
|
+
command,
|
|
397
|
+
durationSeconds,
|
|
398
|
+
parsedAsi,
|
|
399
|
+
parsedMetrics,
|
|
400
|
+
parsedPrimary,
|
|
401
|
+
passed: exitCode === 0 && !timedOut && checksPass !== false,
|
|
402
|
+
runDirectory,
|
|
403
|
+
runNumber,
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function cloneNumericMetricMap(value: unknown): NumericMetricMap | null {
|
|
408
|
+
if (typeof value !== "object" || value === null) return null;
|
|
409
|
+
const metrics = value as { [key: string]: unknown };
|
|
410
|
+
const clone: NumericMetricMap = {};
|
|
411
|
+
for (const [key, entryValue] of Object.entries(metrics)) {
|
|
412
|
+
if (DENIED_KEY_NAMES.has(key)) continue;
|
|
413
|
+
if (typeof entryValue === "number" && Number.isFinite(entryValue)) {
|
|
414
|
+
clone[key] = entryValue;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
return Object.keys(clone).length > 0 ? clone : null;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function cloneAsiData(value: unknown): ASIData | null {
|
|
421
|
+
if (typeof value !== "object" || value === null) return null;
|
|
422
|
+
const candidate = value as { [key: string]: unknown };
|
|
423
|
+
const clone: ASIData = {};
|
|
424
|
+
for (const [key, entryValue] of Object.entries(candidate)) {
|
|
425
|
+
if (DENIED_KEY_NAMES.has(key)) continue;
|
|
426
|
+
const sanitized = clonePendingAsiValue(entryValue);
|
|
427
|
+
if (sanitized !== undefined) {
|
|
428
|
+
clone[key] = sanitized;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return Object.keys(clone).length > 0 ? clone : null;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function clonePendingAsiValue(value: unknown): ASIValue | undefined {
|
|
435
|
+
if (value === null) return null;
|
|
436
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
|
437
|
+
return value;
|
|
438
|
+
}
|
|
439
|
+
if (Array.isArray(value)) {
|
|
440
|
+
const items = value
|
|
441
|
+
.map(entry => clonePendingAsiValue(entry))
|
|
442
|
+
.filter((entry): entry is NonNullable<typeof entry> => entry !== undefined);
|
|
443
|
+
return items;
|
|
444
|
+
}
|
|
445
|
+
if (typeof value === "object") {
|
|
446
|
+
const candidate = value as { [key: string]: unknown };
|
|
447
|
+
const clone: { [key: string]: ASIValue } = {};
|
|
448
|
+
for (const [key, entryValue] of Object.entries(candidate)) {
|
|
449
|
+
if (DENIED_KEY_NAMES.has(key)) continue;
|
|
450
|
+
const sanitized = clonePendingAsiValue(entryValue);
|
|
451
|
+
if (sanitized !== undefined) {
|
|
452
|
+
clone[key] = sanitized;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
return clone;
|
|
456
|
+
}
|
|
457
|
+
return undefined;
|
|
458
|
+
}
|