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 +1 -1
- package/VERSION +1 -1
- package/aidevops.sh +1 -1
- package/package.json +1 -1
- package/packages/gui-api/src/app.ts +154 -8
- 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-desktop/scripts/install-macos-app.sh +217 -2
- 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/App.tsx +3 -26
- package/packages/gui-web/src/AppActionTerminal.tsx +12 -5
- package/packages/gui-web/src/DesktopStatusBar.tsx +28 -0
- package/packages/gui-web/src/GenUiCards.tsx +5 -4
- package/packages/gui-web/src/PulseWorkersSurface.tsx +112 -19
- package/packages/gui-web/src/ScreenshotCaptureNotification.tsx +51 -0
- 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 +98 -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.
|
|
1
|
+
3.30.0
|
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
|
+
|
|
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(
|
|
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
|
|
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
|
+
}
|