aidevops 3.29.40 → 3.29.42
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 +1 -1
- package/VERSION +1 -1
- package/aidevops.sh +1 -1
- package/package.json +1 -1
- package/packages/gui-api/src/app.ts +119 -4
- package/packages/gui-api/src/status-pulse-workers-attention.ts +24 -0
- package/packages/gui-api/src/status-pulse-workers-classifiers.ts +99 -0
- package/packages/gui-api/src/status-pulse-workers-values.ts +59 -0
- package/packages/gui-api/src/status-pulse-workers.ts +92 -133
- package/packages/gui-shared/src/contracts.ts +76 -3
- package/packages/gui-shared/src/fixtures.ts +11 -0
- package/packages/gui-shared/src/tambo.ts +42 -12
- package/packages/gui-web/src/AppActionTerminal.tsx +12 -5
- package/packages/gui-web/src/GenUiCards.tsx +5 -4
- package/packages/gui-web/src/PulseWorkersSurface.tsx +109 -19
- package/packages/gui-web/src/app-model.ts +19 -1
- package/packages/gui-web/src/status-client.ts +2 -0
- package/packages/gui-web/src/styles.css +36 -11
- package/setup.sh +1 -1
package/README.md
CHANGED
|
@@ -58,7 +58,7 @@ The result: an AI operations platform that manages projects across every busines
|
|
|
58
58
|
[](https://github.com/marcusquinn)
|
|
59
59
|
|
|
60
60
|
<!-- Release & Version Info -->
|
|
61
|
-
[](https://github.com/marcusquinn/aidevops/releases)
|
|
62
62
|
[](https://www.npmjs.com/package/aidevops)
|
|
63
63
|
[](https://github.com/marcusquinn/homebrew-tap)
|
|
64
64
|
[](https://github.com/marcusquinn/aidevops)
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
3.29.
|
|
1
|
+
3.29.42
|
package/aidevops.sh
CHANGED
package/package.json
CHANGED
|
@@ -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
|
+
const actionCommand = pulseWorkerActionCommands[action];
|
|
126
|
+
|
|
127
|
+
if (actionCommand === undefined) {
|
|
128
|
+
return context.json(createEnvelope({
|
|
129
|
+
operation_id: PULSE_WORKERS_ACTION_ROUTE_MANIFEST.operation_id,
|
|
130
|
+
source: { surface: "pulse_workers", authority: "allowlisted local command runner", path_refs: [] },
|
|
131
|
+
data: rejectedPulseWorkerJob(action, "No allowlisted command for this Pulse & Workers action."),
|
|
132
|
+
errors: ["action_not_allowlisted"],
|
|
133
|
+
}), 400);
|
|
134
|
+
}
|
|
135
|
+
|
|
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") ?? "";
|
|
@@ -200,8 +250,64 @@ function appendJobLine(job: GuiAppActionJobSummary, line: string): void {
|
|
|
200
250
|
}
|
|
201
251
|
}
|
|
202
252
|
|
|
253
|
+
function startPulseWorkerActionJob(action: GuiPulseWorkerActionId, command: string[], targetRef: string, auditRef: string): GuiPulseWorkerActionJobSummary {
|
|
254
|
+
const now = new Date().toISOString();
|
|
255
|
+
const job: GuiPulseWorkerActionJobSummary = {
|
|
256
|
+
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
257
|
+
action,
|
|
258
|
+
target_ref: targetRef,
|
|
259
|
+
status: "running",
|
|
260
|
+
command_preview: command.join(" "),
|
|
261
|
+
started_at: now,
|
|
262
|
+
finished_at: null,
|
|
263
|
+
exit_code: null,
|
|
264
|
+
output: [`$ ${command.join(" ")}`, `audit_ref=${auditRef}`],
|
|
265
|
+
audit_ref: auditRef,
|
|
266
|
+
};
|
|
267
|
+
pulseWorkerActionJobs.set(job.id, job);
|
|
268
|
+
startBackgroundProcess(command, (line) => appendPulseWorkerJobLine(job, line), (code) => {
|
|
269
|
+
job.status = code === 0 ? "completed" : "failed";
|
|
270
|
+
job.finished_at = new Date().toISOString();
|
|
271
|
+
job.exit_code = code;
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
return job;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function appendPulseWorkerJobLine(job: GuiPulseWorkerActionJobSummary, line: string): void {
|
|
278
|
+
if (line.length === 0) {
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
job.output.push(redactOutputLine(line));
|
|
282
|
+
if (job.output.length > 400) {
|
|
283
|
+
job.output.splice(1, job.output.length - 400);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function startBackgroundProcess(command: string[], appendLine: (line: string) => void, finish: (code: number) => void): void {
|
|
288
|
+
const child = spawn(command[0], command.slice(1), {
|
|
289
|
+
cwd: process.cwd(),
|
|
290
|
+
env: { ...process.env, AIDEVOPS_NON_INTERACTIVE: "true", CLICOLOR_FORCE: "1", FORCE_COLOR: "1", TERM: process.env.TERM ?? "xterm-256color" },
|
|
291
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
292
|
+
});
|
|
293
|
+
if (child.stdout !== null) {
|
|
294
|
+
readline.createInterface({ input: child.stdout, terminal: false }).on("line", appendLine);
|
|
295
|
+
}
|
|
296
|
+
if (child.stderr !== null) {
|
|
297
|
+
readline.createInterface({ input: child.stderr, terminal: false }).on("line", appendLine);
|
|
298
|
+
}
|
|
299
|
+
child.on("error", (error) => {
|
|
300
|
+
appendLine(error.message);
|
|
301
|
+
finish(127);
|
|
302
|
+
});
|
|
303
|
+
child.on("close", (code) => finish(code ?? 1));
|
|
304
|
+
}
|
|
305
|
+
|
|
203
306
|
function redactOutputLine(line: string): string {
|
|
204
|
-
return line
|
|
307
|
+
return line
|
|
308
|
+
.replace(/(token|secret|password|authorization|api[_-]?key|sessionid)=\S+/gi, "$1=[redacted]")
|
|
309
|
+
.replace(/Bearer\s+\S+/gi, "Bearer [redacted]")
|
|
310
|
+
.replace(/-----BEGIN [A-Z ]+ PRIVATE KEY-----[\s\S]*?-----END [A-Z ]+ PRIVATE KEY-----/g, "[redacted private key]");
|
|
205
311
|
}
|
|
206
312
|
|
|
207
313
|
function rejectedJob(appId: string, action: GuiAppActionId, message: string): GuiAppActionJobSummary {
|
|
@@ -209,4 +315,13 @@ function rejectedJob(appId: string, action: GuiAppActionId, message: string): Gu
|
|
|
209
315
|
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
316
|
}
|
|
211
317
|
|
|
318
|
+
function rejectedPulseWorkerJob(action: GuiPulseWorkerActionId, message: string): GuiPulseWorkerActionJobSummary {
|
|
319
|
+
const now = new Date().toISOString();
|
|
320
|
+
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" };
|
|
321
|
+
}
|
|
322
|
+
|
|
212
323
|
export const app = createGuiApiApp();
|
|
324
|
+
|
|
325
|
+
function threadScopeRef(value: string): string {
|
|
326
|
+
return value.replace(/:/g, "");
|
|
327
|
+
}
|
|
@@ -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
|
+
}
|