aidevops 3.29.40 → 3.30.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/README.md CHANGED
@@ -58,7 +58,7 @@ The result: an AI operations platform that manages projects across every busines
58
58
  [![Copyright](https://img.shields.io/badge/Copyright-Marcus%20Quinn%202025--2026-blue.svg)](https://github.com/marcusquinn)
59
59
 
60
60
  <!-- Release & Version Info -->
61
- [![Version](https://img.shields.io/badge/Version-3.29.40-blue.svg)](https://github.com/marcusquinn/aidevops/releases)
61
+ [![Version](https://img.shields.io/badge/Version-3.30.0-blue.svg)](https://github.com/marcusquinn/aidevops/releases)
62
62
  [![npm version](https://img.shields.io/npm/v/aidevops)](https://www.npmjs.com/package/aidevops)
63
63
  [![Homebrew](https://img.shields.io/badge/homebrew-marcusquinn%2Ftap-orange)](https://github.com/marcusquinn/homebrew-tap)
64
64
  [![GitHub repository](https://img.shields.io/badge/github-repository-181717.svg?logo=github)](https://github.com/marcusquinn/aidevops)
package/VERSION CHANGED
@@ -1 +1 @@
1
- 3.29.40
1
+ 3.30.0
package/aidevops.sh CHANGED
@@ -5,7 +5,7 @@
5
5
  # AI DevOps Framework CLI
6
6
  # Usage: aidevops <command> [options]
7
7
  #
8
- # Version: 3.29.40
8
+ # Version: 3.30.0
9
9
 
10
10
  set -euo pipefail
11
11
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aidevops",
3
- "version": "3.29.40",
3
+ "version": "3.30.0",
4
4
  "description": "AI DevOps Framework - AI-assisted development workflows, code quality, and deployment automation",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -1,7 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import * as readline from "node:readline";
3
3
  import { Hono } from "hono";
4
- import { APP_ACTION_ROUTE_MANIFEST, APP_ACTION_STATUS_ROUTE_MANIFEST, BANNED_ROUTE_PATTERNS, createEnvelope, FILE_EXPLORER_ROUTE_MANIFEST, type GuiAppActionId, type GuiAppActionJobSummary, STATUS_ROUTE_MANIFEST, TAMBO_PROVIDER_CONFIG, TAMBO_PROXY_ROUTE_MANIFEST, VAULT_STATUS_ROUTE_MANIFEST } from "../../gui-shared/src";
4
+ import { APP_ACTION_ROUTE_MANIFEST, APP_ACTION_STATUS_ROUTE_MANIFEST, BANNED_ROUTE_PATTERNS, createEnvelope, FILE_EXPLORER_ROUTE_MANIFEST, type GuiAppActionId, type GuiAppActionJobSummary, type GuiPulseWorkerActionId, type GuiPulseWorkerActionJobSummary, PULSE_WORKERS_ACTION_ROUTE_MANIFEST, PULSE_WORKERS_ACTION_STATUS_ROUTE_MANIFEST, STATUS_ROUTE_MANIFEST, TAMBO_PROVIDER_CONFIG, TAMBO_PROXY_ROUTE_MANIFEST, VAULT_STATUS_ROUTE_MANIFEST } from "../../gui-shared/src";
5
5
  import { readFileExplorer } from "./file-adapter";
6
6
  import { readStatus, readVaultStatus } from "./status-adapter";
7
7
 
@@ -34,6 +34,15 @@ const appActionCommands: Record<string, Partial<Record<GuiAppActionId, string[]>
34
34
 
35
35
  const appActionJobs = new Map<string, GuiAppActionJobSummary>();
36
36
 
37
+ const pulseWorkerActionCommands: Record<GuiPulseWorkerActionId, { command: string[]; audit_ref: string; target_ref: string }> = {
38
+ diagnose: { command: ["aidevops", "pulse", "diagnose", "--gui", "--metadata-only"], audit_ref: "gui:pulse-workers:diagnose", target_ref: "selected event" },
39
+ run_pulse: { command: ["aidevops", "pulse", "run", "--scope", "gui"], audit_ref: "gui:pulse-workers:run-pulse", target_ref: "current filtered scope" },
40
+ open_logs: { command: ["aidevops", "pulse", "logs", "--metadata-only"], audit_ref: "gui:pulse-workers:open-logs", target_ref: "selected event evidence refs" },
41
+ create_systemic_fix: { command: ["aidevops", "task", "create", "--from-pulse", "--worker-ready"], audit_ref: "gui:pulse-workers:create-systemic-fix", target_ref: "selected event systemic-fix context" },
42
+ };
43
+
44
+ const pulseWorkerActionJobs = new Map<string, GuiPulseWorkerActionJobSummary>();
45
+
37
46
  export function createGuiApiApp() {
38
47
  const app = new Hono();
39
48
 
@@ -54,8 +63,8 @@ export function createGuiApiApp() {
54
63
  });
55
64
 
56
65
  app.get(TAMBO_PROXY_ROUTE_MANIFEST.route, (context) => {
57
- const tenantRef = context.req.query("tenant_ref") ?? "local";
58
- const workspaceRef = context.req.query("workspace_ref") ?? "aidevops";
66
+ const tenantRef = threadScopeRef(context.req.query("tenant_ref") ?? "local");
67
+ const workspaceRef = threadScopeRef(context.req.query("workspace_ref") ?? "aidevops");
59
68
  const sessionRef = context.req.query("session_ref") ?? "conversation:local";
60
69
 
61
70
  return context.json(createEnvelope({
@@ -111,6 +120,47 @@ export function createGuiApiApp() {
111
120
  }));
112
121
  });
113
122
 
123
+ app.post(PULSE_WORKERS_ACTION_ROUTE_MANIFEST.route, (context) => {
124
+ const action = context.req.param("action") as GuiPulseWorkerActionId;
125
+
126
+ if (!Object.hasOwn(pulseWorkerActionCommands, action)) {
127
+ return context.json(createEnvelope({
128
+ operation_id: PULSE_WORKERS_ACTION_ROUTE_MANIFEST.operation_id,
129
+ source: { surface: "pulse_workers", authority: "allowlisted local command runner", path_refs: [] },
130
+ data: rejectedPulseWorkerJob(action, "No allowlisted command for this Pulse & Workers action."),
131
+ errors: ["action_not_allowlisted"],
132
+ }), 400);
133
+ }
134
+
135
+ const actionCommand = pulseWorkerActionCommands[action];
136
+ const job = startPulseWorkerActionJob(action, actionCommand.command, actionCommand.target_ref, actionCommand.audit_ref);
137
+ return context.json(createEnvelope({
138
+ operation_id: PULSE_WORKERS_ACTION_ROUTE_MANIFEST.operation_id,
139
+ source: { surface: "pulse_workers", authority: "allowlisted local command runner", path_refs: [".agents/scripts", "packages/gui-api/src/app.ts"] },
140
+ data: job,
141
+ warnings: ["Command runs locally in the background through an explicit allowlist. Output is redacted and retained in memory for this GUI API process only."],
142
+ }), 202);
143
+ });
144
+
145
+ app.get(PULSE_WORKERS_ACTION_STATUS_ROUTE_MANIFEST.route, (context) => {
146
+ const jobId = context.req.param("jobId");
147
+ const job = pulseWorkerActionJobs.get(jobId);
148
+ if (job === undefined) {
149
+ return context.json(createEnvelope({
150
+ operation_id: PULSE_WORKERS_ACTION_STATUS_ROUTE_MANIFEST.operation_id,
151
+ source: { surface: "pulse_workers", authority: "background job store", path_refs: [] },
152
+ data: rejectedPulseWorkerJob("diagnose", "Unknown Pulse & Workers job."),
153
+ errors: ["unknown_job"],
154
+ }), 404);
155
+ }
156
+
157
+ return context.json(createEnvelope({
158
+ operation_id: PULSE_WORKERS_ACTION_STATUS_ROUTE_MANIFEST.operation_id,
159
+ source: { surface: "pulse_workers", authority: "background job store", path_refs: [] },
160
+ data: job,
161
+ }));
162
+ });
163
+
114
164
  app.get(FILE_EXPLORER_ROUTE_MANIFEST.route, (context) => {
115
165
  const root = context.req.param("root");
116
166
  const path = context.req.query("path") ?? "";
@@ -149,6 +199,7 @@ export function createGuiApiApp() {
149
199
 
150
200
  function startAppActionJob(appId: string, action: GuiAppActionId, command: string[]): GuiAppActionJobSummary {
151
201
  const now = new Date().toISOString();
202
+ const redactLine = createOutputLineRedactor();
152
203
  const job: GuiAppActionJobSummary = {
153
204
  id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
154
205
  app_id: appId,
@@ -169,11 +220,11 @@ function startAppActionJob(appId: string, action: GuiAppActionId, command: strin
169
220
  });
170
221
  if (child.stdout !== null) {
171
222
  const stdoutLines = readline.createInterface({ input: child.stdout, terminal: false });
172
- stdoutLines.on("line", (line) => appendJobLine(job, line));
223
+ stdoutLines.on("line", (line) => appendJobLine(job, line, redactLine));
173
224
  }
174
225
  if (child.stderr !== null) {
175
226
  const stderrLines = readline.createInterface({ input: child.stderr, terminal: false });
176
- stderrLines.on("line", (line) => appendJobLine(job, line));
227
+ stderrLines.on("line", (line) => appendJobLine(job, line, redactLine));
177
228
  }
178
229
  child.on("error", (error) => {
179
230
  appendJobLine(job, error.message);
@@ -190,18 +241,104 @@ function startAppActionJob(appId: string, action: GuiAppActionId, command: strin
190
241
  return job;
191
242
  }
192
243
 
193
- function appendJobLine(job: GuiAppActionJobSummary, line: string): void {
244
+ function appendJobLine(job: GuiAppActionJobSummary, line: string, redactLine: (line: string) => string = redactOutputLine): void {
245
+ if (line.length === 0) {
246
+ return;
247
+ }
248
+ job.output.push(redactLine(line));
249
+ if (job.output.length > 400) {
250
+ job.output.splice(1, job.output.length - 400);
251
+ }
252
+ }
253
+
254
+ function startPulseWorkerActionJob(action: GuiPulseWorkerActionId, command: string[], targetRef: string, auditRef: string): GuiPulseWorkerActionJobSummary {
255
+ const now = new Date().toISOString();
256
+ const redactLine = createOutputLineRedactor();
257
+ const job: GuiPulseWorkerActionJobSummary = {
258
+ id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
259
+ action,
260
+ target_ref: targetRef,
261
+ status: "running",
262
+ command_preview: command.join(" "),
263
+ started_at: now,
264
+ finished_at: null,
265
+ exit_code: null,
266
+ output: [`$ ${command.join(" ")}`, `audit_ref=${auditRef}`],
267
+ audit_ref: auditRef,
268
+ };
269
+ pulseWorkerActionJobs.set(job.id, job);
270
+ startBackgroundProcess(command, (line) => appendPulseWorkerJobLine(job, line, redactLine), (code) => {
271
+ job.status = code === 0 ? "completed" : "failed";
272
+ job.finished_at = new Date().toISOString();
273
+ job.exit_code = code;
274
+ });
275
+
276
+ return job;
277
+ }
278
+
279
+ function appendPulseWorkerJobLine(job: GuiPulseWorkerActionJobSummary, line: string, redactLine: (line: string) => string = redactOutputLine): void {
194
280
  if (line.length === 0) {
195
281
  return;
196
282
  }
197
- job.output.push(redactOutputLine(line));
283
+ job.output.push(redactLine(line));
198
284
  if (job.output.length > 400) {
199
285
  job.output.splice(1, job.output.length - 400);
200
286
  }
201
287
  }
202
288
 
289
+ function startBackgroundProcess(command: string[], appendLine: (line: string) => void, finish: (code: number) => void): void {
290
+ let finished = false;
291
+ const safeFinish = (code: number): void => {
292
+ if (finished) {
293
+ return;
294
+ }
295
+ finished = true;
296
+ finish(code);
297
+ };
298
+
299
+ const child = spawn(command[0], command.slice(1), {
300
+ cwd: process.cwd(),
301
+ env: { ...process.env, AIDEVOPS_NON_INTERACTIVE: "true", CLICOLOR_FORCE: "1", FORCE_COLOR: "1", TERM: process.env.TERM ?? "xterm-256color" },
302
+ stdio: ["ignore", "pipe", "pipe"],
303
+ });
304
+ if (child.stdout !== null) {
305
+ readline.createInterface({ input: child.stdout, terminal: false }).on("line", appendLine);
306
+ }
307
+ if (child.stderr !== null) {
308
+ readline.createInterface({ input: child.stderr, terminal: false }).on("line", appendLine);
309
+ }
310
+ child.on("error", (error) => {
311
+ appendLine(error.message);
312
+ safeFinish(127);
313
+ });
314
+ child.on("close", (code) => safeFinish(code ?? 1));
315
+ }
316
+
317
+ export function createOutputLineRedactor(): (line: string) => string {
318
+ let inPrivateKeyBlock = false;
319
+
320
+ return (line: string): string => {
321
+ if (/-----BEGIN [A-Z ]+ PRIVATE KEY-----/.test(line)) {
322
+ inPrivateKeyBlock = !/-----END [A-Z ]+ PRIVATE KEY-----/.test(line);
323
+ return "[redacted private key]";
324
+ }
325
+
326
+ if (inPrivateKeyBlock) {
327
+ if (/-----END [A-Z ]+ PRIVATE KEY-----/.test(line)) {
328
+ inPrivateKeyBlock = false;
329
+ }
330
+ return "[redacted private key]";
331
+ }
332
+
333
+ return redactOutputLine(line);
334
+ };
335
+ }
336
+
203
337
  function redactOutputLine(line: string): string {
204
- return line.replace(/(token|secret|password|authorization)=\S+/gi, "$1=[redacted]");
338
+ return line
339
+ .replace(/(token|secret|password|authorization|api[_-]?key|sessionid)=\S+/gi, "$1=[redacted]")
340
+ .replace(/Bearer\s+\S+/gi, "Bearer [redacted]")
341
+ .replace(/-----BEGIN [A-Z ]+ PRIVATE KEY-----[\s\S]*?-----END [A-Z ]+ PRIVATE KEY-----/g, "[redacted private key]");
205
342
  }
206
343
 
207
344
  function rejectedJob(appId: string, action: GuiAppActionId, message: string): GuiAppActionJobSummary {
@@ -209,4 +346,13 @@ function rejectedJob(appId: string, action: GuiAppActionId, message: string): Gu
209
346
  return { id: "rejected", app_id: appId, action, status: "rejected", command_preview: "not run", started_at: now, finished_at: now, exit_code: null, output: [message] };
210
347
  }
211
348
 
349
+ function rejectedPulseWorkerJob(action: GuiPulseWorkerActionId, message: string): GuiPulseWorkerActionJobSummary {
350
+ const now = new Date().toISOString();
351
+ return { id: "rejected", action, target_ref: "none", status: "rejected", command_preview: "not run", started_at: now, finished_at: now, exit_code: null, output: [message], audit_ref: "gui:pulse-workers:rejected" };
352
+ }
353
+
212
354
  export const app = createGuiApiApp();
355
+
356
+ function threadScopeRef(value: string): string {
357
+ return value.replace(/:/g, "");
358
+ }
@@ -0,0 +1,24 @@
1
+ import type { GuiPulseResourceSnapshot, GuiPulseWorkerSummary } from "../../gui-shared/src";
2
+ import { hasAvailableProvider } from "./status-pulse-workers-classifiers";
3
+ import { slug } from "./status-pulse-workers-values";
4
+
5
+ interface SourceState {
6
+ path_ref: string;
7
+ health: "present" | "missing" | "invalid";
8
+ observed_at: string;
9
+ }
10
+
11
+ export function buildAttention(sources: SourceState[], counters: Record<string, number>, resources: GuiPulseResourceSnapshot[], oauthPool: Record<string, unknown>): GuiPulseWorkerSummary["attention"] {
12
+ const missing = sources.filter((source) => source.health !== "present");
13
+ const attention: GuiPulseWorkerSummary["attention"] = missing.map((source) => ({ id: `source-${source.health}-${slug(source.path_ref)}`, severity: source.health === "invalid" ? "warning" : "info", title: `Telemetry source ${source.health}`, detail: `${source.path_ref} was ${source.health}; the GUI used safe empty summaries for that source.`, event_ref: null }));
14
+ for (const [name, count] of Object.entries(counters).filter(([, count]) => count > 0)) {
15
+ attention.push({ id: `pulse-counter-${slug(name)}`, severity: "warning", title: `Pulse counter active: ${name}`, detail: `${count} events observed in the selected local period.`, event_ref: null });
16
+ }
17
+ for (const resource of resources.filter((item) => item.pressure === "medium" || item.pressure === "high")) {
18
+ attention.push({ id: `resource-${slug(resource.kind)}-${slug(resource.label)}`, severity: resource.pressure === "high" ? "critical" : "warning", title: `${resource.label} pressure ${resource.pressure}`, detail: resource.available_label, event_ref: null });
19
+ }
20
+ if (!hasAvailableProvider(oauthPool)) {
21
+ attention.push({ id: "provider-availability-unknown", severity: "info", title: "Provider availability unknown", detail: "OAuth pool metadata did not expose an available provider account count; the GUI marks provider capacity as unknown.", event_ref: null });
22
+ }
23
+ return attention.slice(0, 12);
24
+ }
@@ -0,0 +1,99 @@
1
+ import {
2
+ type GuiPulseAuthorAssociation,
3
+ type GuiPulseIssueOrigin,
4
+ type GuiPulseProviderId,
5
+ type GuiPulseWorkerOutcome,
6
+ type GuiPulseWorkerSeverity,
7
+ type GuiPulseWorkerStatus,
8
+ } from "../../gui-shared/src";
9
+
10
+ type MetricRecord = Record<string, unknown>;
11
+
12
+ const KNOWN_WORKER_OUTCOMES = new Set<GuiPulseWorkerOutcome>(["merged", "closed", "in_progress", "needs_maintainer_review", "blocked", "failed", "deferred", "created_followup"]);
13
+ const HEALTHY_WORKER_OUTCOMES = new Set<GuiPulseWorkerOutcome>(["merged", "closed", "deferred", "created_followup"]);
14
+ const OUTCOME_MATCHERS: Array<{ pattern: RegExp; outcome: GuiPulseWorkerOutcome }> = [
15
+ { pattern: /success|complete/, outcome: "merged" },
16
+ { pattern: /follow/, outcome: "created_followup" },
17
+ { pattern: /defer/, outcome: "deferred" },
18
+ { pattern: /block/, outcome: "blocked" },
19
+ { pattern: /fail|kill/, outcome: "failed" },
20
+ ];
21
+ const KNOWN_ISSUE_ORIGINS = new Set<GuiPulseIssueOrigin>(["aidevops_created", "self_created", "maintainer_created", "origin_interactive", "third_party", "imported", "unknown"]);
22
+ const ISSUE_ORIGIN_MATCHERS: Array<{ pattern: RegExp; origin: GuiPulseIssueOrigin }> = [
23
+ { pattern: /interactive/, origin: "origin_interactive" },
24
+ { pattern: /third|community/, origin: "third_party" },
25
+ { pattern: /import/, origin: "imported" },
26
+ { pattern: /self|user/, origin: "self_created" },
27
+ { pattern: /maintainer/, origin: "maintainer_created" },
28
+ { pattern: /aidevops/, origin: "aidevops_created" },
29
+ ];
30
+ const STATUS_BY_OUTCOME: Partial<Record<GuiPulseWorkerOutcome, GuiPulseWorkerStatus>> = {
31
+ failed: "failed",
32
+ blocked: "blocked",
33
+ needs_maintainer_review: "blocked",
34
+ in_progress: "running",
35
+ deferred: "deferred",
36
+ };
37
+ const SEVERITY_BY_STATUS: Partial<Record<GuiPulseWorkerStatus, GuiPulseWorkerSeverity>> = {
38
+ failed: "critical",
39
+ blocked: "critical",
40
+ attention: "warning",
41
+ deferred: "warning",
42
+ completed: "success",
43
+ healthy: "success",
44
+ };
45
+ const KNOWN_AUTHOR_ASSOCIATIONS = new Set<GuiPulseAuthorAssociation>(["OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR", "FIRST_TIMER", "FIRST_TIME_CONTRIBUTOR", "NONE", "UNKNOWN"]);
46
+ const KNOWN_PROVIDER_IDS = new Set<GuiPulseProviderId>(["anthropic", "openai", "cursor", "google", "local"]);
47
+
48
+ export function outcomeFromRecord(record: MetricRecord): GuiPulseWorkerOutcome {
49
+ const raw = String(record.outcome ?? record.result ?? record.status ?? "in_progress").toLowerCase();
50
+ if (KNOWN_WORKER_OUTCOMES.has(raw as GuiPulseWorkerOutcome)) {
51
+ return raw as GuiPulseWorkerOutcome;
52
+ }
53
+ return OUTCOME_MATCHERS.find((matcher) => matcher.pattern.test(raw))?.outcome ?? "in_progress";
54
+ }
55
+
56
+ export function statusFromOutcome(outcome: GuiPulseWorkerOutcome): GuiPulseWorkerStatus {
57
+ return STATUS_BY_OUTCOME[outcome] ?? "completed";
58
+ }
59
+
60
+ export function severityFromStatus(status: GuiPulseWorkerStatus): GuiPulseWorkerSeverity {
61
+ return SEVERITY_BY_STATUS[status] ?? "info";
62
+ }
63
+
64
+ export function isHealthyOutcome(outcome: GuiPulseWorkerOutcome): boolean {
65
+ return HEALTHY_WORKER_OUTCOMES.has(outcome);
66
+ }
67
+
68
+ export function issueOriginFromRecord(record: MetricRecord): GuiPulseIssueOrigin {
69
+ const raw = String(record.issue_origin ?? record.origin ?? "unknown").toLowerCase();
70
+ if (KNOWN_ISSUE_ORIGINS.has(raw as GuiPulseIssueOrigin)) return raw as GuiPulseIssueOrigin;
71
+ return ISSUE_ORIGIN_MATCHERS.find((matcher) => matcher.pattern.test(raw))?.origin ?? "unknown";
72
+ }
73
+
74
+ export function authorAssociationFromRecord(record: MetricRecord): GuiPulseAuthorAssociation {
75
+ const raw = String(record.author_association ?? "UNKNOWN").toUpperCase();
76
+ if (KNOWN_AUTHOR_ASSOCIATIONS.has(raw as GuiPulseAuthorAssociation)) return raw as GuiPulseAuthorAssociation;
77
+ return "UNKNOWN";
78
+ }
79
+
80
+ export function hasAvailableProvider(record: MetricRecord): boolean {
81
+ const providers = Array.isArray(record.providers) ? record.providers : [];
82
+ return providers.some((provider) => providerAvailability(provider) > 0);
83
+ }
84
+
85
+ function providerAvailability(provider: unknown): number {
86
+ const record = providerRecord(provider);
87
+ const available = record?.available;
88
+ return typeof available === "number" && Number.isFinite(available) ? available : 0;
89
+ }
90
+
91
+ function providerRecord(value: unknown): MetricRecord | null {
92
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return null;
93
+ return value as MetricRecord;
94
+ }
95
+
96
+ export function providerFromString(value: string | undefined): GuiPulseProviderId {
97
+ if (KNOWN_PROVIDER_IDS.has(value as GuiPulseProviderId)) return value as GuiPulseProviderId;
98
+ return "unknown";
99
+ }
@@ -0,0 +1,59 @@
1
+ import type { GuiPulseWorkerOutcome } from "../../gui-shared/src";
2
+
3
+ export type MetricRecord = Record<string, unknown>;
4
+
5
+ const TIME_FIELDS = ["ts", "timestamp", "started_at", "finished_at", "created_at", "updated_at"];
6
+
7
+ export function recordTimeMs(record: MetricRecord): number | null {
8
+ for (const key of TIME_FIELDS) {
9
+ const parsed = timeValueMs(record[key]);
10
+ if (parsed !== null) return parsed;
11
+ }
12
+ return null;
13
+ }
14
+
15
+ export function durationMsFromRecord(record: MetricRecord): number | null {
16
+ const duration = numberValue(record.duration_ms ?? record.wall_time_ms);
17
+ if (duration !== null) return duration;
18
+ const elapsed = numberValue(record.elapsed_s);
19
+ return elapsed === null ? null : elapsed * 1000;
20
+ }
21
+
22
+ export function summaryFromRecord(record: MetricRecord, outcome: GuiPulseWorkerOutcome): string {
23
+ const issue = stringOrNumber(record.issue ?? record.issue_number);
24
+ return issue === null ? `Worker outcome: ${outcome}.` : `Worker outcome for #${issue.replace(/^#/, "")}: ${outcome}.`;
25
+ }
26
+
27
+ export function countOptions(values: string[], prefix = ""): Array<{ id: string; label: string; count: number }> {
28
+ const counts = new Map<string, number>();
29
+ for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1);
30
+ return [...counts.entries()].map(([label, count]) => ({ id: `${prefix}${slug(label)}`, label, count })).sort((a, b) => b.count - a.count || a.label.localeCompare(b.label));
31
+ }
32
+
33
+ export function costRef(record: MetricRecord): string | null {
34
+ const cost = record.estimated_cost_ref ?? record.cost_ref;
35
+ if (typeof cost === "string" && cost.length > 0 && !cost.includes("/")) return cost;
36
+ const amount = numberValue(record.estimated_cost_usd ?? record.cost_usd);
37
+ return amount === null ? null : `$${amount.toFixed(2)} estimated`;
38
+ }
39
+
40
+ export function stringOrNumber(value: unknown): string | null {
41
+ if (typeof value === "string" && value.length > 0) return value;
42
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
43
+ return null;
44
+ }
45
+
46
+ export function numberValue(value: unknown): number | null {
47
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
48
+ }
49
+
50
+ export function slug(value: string): string {
51
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "unknown";
52
+ }
53
+
54
+ function timeValueMs(value: unknown): number | null {
55
+ if (typeof value === "number" && Number.isFinite(value)) return value > 9_999_999_999 ? value : value * 1000;
56
+ if (typeof value !== "string" || value.length === 0) return null;
57
+ const parsed = Date.parse(value);
58
+ return Number.isFinite(parsed) ? parsed : null;
59
+ }