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
|
@@ -1,23 +1,22 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import {
|
|
4
|
-
type GuiPulseAuthorAssociation,
|
|
5
|
-
type GuiPulseIssueOrigin,
|
|
6
|
-
type GuiPulseProviderId,
|
|
7
4
|
type GuiPulseResourceKind,
|
|
8
5
|
type GuiPulseResourceSnapshot,
|
|
6
|
+
type GuiPulseSystemicFinding,
|
|
7
|
+
type GuiPulseWorkerActionSummary,
|
|
9
8
|
type GuiPulseWorkerActivityEvent,
|
|
10
9
|
type GuiPulseWorkerChartSeries,
|
|
11
10
|
type GuiPulseWorkerKpiCard,
|
|
12
|
-
type GuiPulseWorkerOutcome,
|
|
13
|
-
type GuiPulseWorkerSeverity,
|
|
14
|
-
type GuiPulseWorkerStatus,
|
|
15
11
|
type GuiPulseWorkerSummary,
|
|
16
12
|
type GuiPulseWorkerUsageSnapshot,
|
|
17
13
|
type GuiPulsePeriodBucket,
|
|
18
14
|
pulseWorkersFixture,
|
|
19
15
|
} from "../../gui-shared/src";
|
|
20
16
|
import { collapseHome, expandHome, formatEpochField, isRecord, readJsonObject, stringField } from "./status-adapter-utils";
|
|
17
|
+
import { buildAttention } from "./status-pulse-workers-attention";
|
|
18
|
+
import { authorAssociationFromRecord, isHealthyOutcome, issueOriginFromRecord, outcomeFromRecord, providerFromString, severityFromStatus, statusFromOutcome } from "./status-pulse-workers-classifiers";
|
|
19
|
+
import { costRef, countOptions, durationMsFromRecord, type MetricRecord, numberValue, recordTimeMs, stringOrNumber, summaryFromRecord } from "./status-pulse-workers-values";
|
|
21
20
|
|
|
22
21
|
export interface PulseWorkersAdapterOptions {
|
|
23
22
|
metricsPath?: string;
|
|
@@ -35,8 +34,6 @@ interface SourceState {
|
|
|
35
34
|
observed_at: string;
|
|
36
35
|
}
|
|
37
36
|
|
|
38
|
-
type MetricRecord = Record<string, unknown>;
|
|
39
|
-
|
|
40
37
|
const DAY_MS = 86_400_000;
|
|
41
38
|
const WEEK_MS = 7 * DAY_MS;
|
|
42
39
|
const DEFAULT_SCOPE = "local aidevops telemetry";
|
|
@@ -44,11 +41,7 @@ const DEFAULT_METRICS_PATH_REF = "~/.aidevops/logs/headless-runtime-metrics.json
|
|
|
44
41
|
const DEFAULT_PULSE_STATS_PATH_REF = "~/.aidevops/logs/pulse-stats.json";
|
|
45
42
|
const DEFAULT_RESOURCE_METRICS_PATH_REF = "~/.aidevops/logs/resource-metrics.jsonl";
|
|
46
43
|
const DEFAULT_TOKEN_REPORTS_ROOT_REF = "~/.aidevops/_reports/token-use";
|
|
47
|
-
const
|
|
48
|
-
const WORKER_OUTCOME_KEYWORDS: Array<[string, GuiPulseWorkerOutcome]> = [["success", "merged"], ["complete", "merged"], ["follow", "created_followup"], ["defer", "deferred"], ["block", "blocked"], ["fail", "failed"], ["kill", "failed"]];
|
|
49
|
-
const DIRECT_ISSUE_ORIGINS = new Set<string>(["aidevops_created", "maintainer_created", "origin_interactive", "third_party", "unknown"]);
|
|
50
|
-
const ISSUE_ORIGIN_KEYWORDS: Array<[string, GuiPulseIssueOrigin]> = [["interactive", "origin_interactive"], ["third", "third_party"], ["community", "third_party"], ["maintainer", "maintainer_created"], ["aidevops", "aidevops_created"]];
|
|
51
|
-
const KNOWN_PROVIDER_IDS: readonly GuiPulseProviderId[] = ["anthropic", "openai", "cursor", "google", "local"];
|
|
44
|
+
const PULSE_WORKER_ACTIONS: GuiPulseWorkerActionSummary[] = pulseWorkersFixture.actions;
|
|
52
45
|
|
|
53
46
|
export function readPulseWorkersSummary(options: PulseWorkersAdapterOptions = {}): { summary: GuiPulseWorkerSummary; source_path_refs: string[] } {
|
|
54
47
|
const nowMs = options.nowMs ?? Date.parse(options.observedAt ?? new Date().toISOString());
|
|
@@ -73,12 +66,14 @@ export function readPulseWorkersSummary(options: PulseWorkersAdapterOptions = {}
|
|
|
73
66
|
];
|
|
74
67
|
|
|
75
68
|
const dayMetrics = filterWindow(metrics.records, nowMs, DAY_MS);
|
|
69
|
+
const previousDayMetrics = filterWindowBetween(metrics.records, nowMs - (2 * DAY_MS), nowMs - DAY_MS);
|
|
76
70
|
const weekMetrics = filterWindow(metrics.records, nowMs, WEEK_MS);
|
|
77
71
|
const pulseCounters = extractPulseCounters(pulseStats.value, nowMs);
|
|
78
72
|
const resourceSnapshots = resourceMetricsToSnapshots(resources.records, observedAt);
|
|
79
73
|
const usageSamples = collectUsageSamples([...dayMetrics, ...tokenReports.records]);
|
|
80
74
|
const events = buildEvents(dayMetrics, resourceSnapshots, usageSamples, observedAt);
|
|
81
|
-
const
|
|
75
|
+
const insights = buildInsights(events, dayMetrics, previousDayMetrics, resourceSnapshots);
|
|
76
|
+
const warnings = [...buildAttention(sourceStates, pulseCounters, resourceSnapshots, oauthPool.value), ...insights.map((finding) => ({ id: finding.id, severity: finding.severity, title: finding.title, detail: `${finding.detail} Recommended fix: ${finding.recommendation}`, event_ref: finding.event_refs[0] ?? null }))].slice(0, 12);
|
|
82
77
|
|
|
83
78
|
const summary: GuiPulseWorkerSummary = {
|
|
84
79
|
value_policy: "metadata_only_no_prompt_payloads_no_secrets",
|
|
@@ -89,9 +84,11 @@ export function readPulseWorkersSummary(options: PulseWorkersAdapterOptions = {}
|
|
|
89
84
|
updated_at: observedAt,
|
|
90
85
|
kpis: buildKpis(dayMetrics, weekMetrics, pulseCounters, usageSamples, resourceSnapshots),
|
|
91
86
|
attention: warnings,
|
|
87
|
+
insights,
|
|
92
88
|
filters: buildFilters(events),
|
|
93
89
|
charts: buildCharts(metrics.records, pulseCounters, nowMs),
|
|
94
90
|
events,
|
|
91
|
+
actions: PULSE_WORKER_ACTIONS,
|
|
95
92
|
};
|
|
96
93
|
|
|
97
94
|
return { summary, source_path_refs: sourceStates.map((source) => source.path_ref) };
|
|
@@ -144,20 +141,11 @@ function filterWindow(records: MetricRecord[], nowMs: number, windowMs: number):
|
|
|
144
141
|
});
|
|
145
142
|
}
|
|
146
143
|
|
|
147
|
-
function
|
|
148
|
-
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
}
|
|
153
|
-
if (typeof value === "string" && value.length > 0) {
|
|
154
|
-
const parsed = Date.parse(value);
|
|
155
|
-
if (Number.isFinite(parsed)) {
|
|
156
|
-
return parsed;
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
return null;
|
|
144
|
+
function filterWindowBetween(records: MetricRecord[], startMs: number, endMs: number): MetricRecord[] {
|
|
145
|
+
return records.filter((record) => {
|
|
146
|
+
const time = recordTimeMs(record);
|
|
147
|
+
return time !== null && time >= startMs && time < endMs;
|
|
148
|
+
});
|
|
161
149
|
}
|
|
162
150
|
|
|
163
151
|
function extractPulseCounters(value: Record<string, unknown>, nowMs: number): Record<string, number> {
|
|
@@ -192,21 +180,6 @@ function buildKpis(dayMetrics: MetricRecord[], weekMetrics: MetricRecord[], coun
|
|
|
192
180
|
];
|
|
193
181
|
}
|
|
194
182
|
|
|
195
|
-
function buildAttention(sources: SourceState[], counters: Record<string, number>, resources: GuiPulseResourceSnapshot[], oauthPool: Record<string, unknown>): GuiPulseWorkerSummary["attention"] {
|
|
196
|
-
const missing = sources.filter((source) => source.health !== "present");
|
|
197
|
-
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 }));
|
|
198
|
-
for (const [name, count] of Object.entries(counters).filter(([, count]) => count > 0)) {
|
|
199
|
-
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 });
|
|
200
|
-
}
|
|
201
|
-
for (const resource of resources.filter((item) => item.pressure === "medium" || item.pressure === "high")) {
|
|
202
|
-
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 });
|
|
203
|
-
}
|
|
204
|
-
if (!hasAvailableProvider(oauthPool)) {
|
|
205
|
-
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 });
|
|
206
|
-
}
|
|
207
|
-
return attention.slice(0, 12);
|
|
208
|
-
}
|
|
209
|
-
|
|
210
183
|
function buildEvents(records: MetricRecord[], resources: GuiPulseResourceSnapshot[], usage: GuiPulseWorkerUsageSnapshot[], observedAt: string): GuiPulseWorkerActivityEvent[] {
|
|
211
184
|
return records.slice(-25).sort((left, right) => (recordTimeMs(right) ?? 0) - (recordTimeMs(left) ?? 0)).map((record, index) => {
|
|
212
185
|
const outcome = outcomeFromRecord(record);
|
|
@@ -238,11 +211,87 @@ function buildEvents(records: MetricRecord[], resources: GuiPulseResourceSnapsho
|
|
|
238
211
|
drilldown_sections: [
|
|
239
212
|
{ label: "Evidence", body: "Derived from local worker outcome/resource metadata; prompt text and command payloads are not exposed." },
|
|
240
213
|
{ label: "Outcome", body: outcome },
|
|
214
|
+
{ label: "Verification", body: verificationSummary(record) },
|
|
215
|
+
{ label: "Systemic fix", body: systemicFixRecommendation(record, outcome) },
|
|
241
216
|
],
|
|
242
217
|
};
|
|
243
218
|
});
|
|
244
219
|
}
|
|
245
220
|
|
|
221
|
+
function buildInsights(events: GuiPulseWorkerActivityEvent[], dayMetrics: MetricRecord[], previousDayMetrics: MetricRecord[], resources: GuiPulseResourceSnapshot[]): GuiPulseSystemicFinding[] {
|
|
222
|
+
const findings: GuiPulseSystemicFinding[] = [];
|
|
223
|
+
const thirdPartyWaiting = events.filter((event) => event.issue_origin === "third_party" && (event.outcome === "needs_maintainer_review" || event.outcome === "blocked" || event.outcome === "in_progress"));
|
|
224
|
+
if (thirdPartyWaiting.length > 0) {
|
|
225
|
+
findings.push(finding("third-party-waiting", "third_party_waiting", "warning", "Third-party issues waiting", `${thirdPartyWaiting.length} community-origin issue events need triage or resolution in the selected period.`, "Community reports are separated from aidevops-created task handling and may need a maintainer decision before dispatch.", thirdPartyWaiting, "Create a systemic triage task that preserves non-collaborator trust boundaries and adds first-response thresholds.", "community waiting count", thirdPartyWaiting.length, "high"));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const failedOrBlocked = events.filter((event) => event.outcome === "failed" || event.outcome === "blocked");
|
|
229
|
+
for (const [key, group] of groupEvents(failedOrBlocked, (event) => [event.repo_ref ?? "repo:unknown", event.usage?.provider ?? "provider:unknown", event.resources[0]?.kind ?? "resource:unknown"].join("|"))) {
|
|
230
|
+
if (group.length > 1) {
|
|
231
|
+
findings.push(finding(`repeated-${key}`, "repeated_failure", "critical", "Repeated failure pattern", `${group.length} failed or blocked runs share repo/provider/resource context.`, "A repeated worker blindspot is likely being retried as individual failures instead of being converted into a shared guardrail or helper fix.", group, "Create a systemic fix task with grouped evidence, affected repo/provider/resource, and a focused verification command.", key.replaceAll("|", " · "), group.length, "medium"));
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const weakVerification = events.filter((event) => event.drilldown_sections.some((section) => section.label === "Verification" && /missing|weak|not recorded/i.test(section.body)));
|
|
236
|
+
if (weakVerification.length > 0) {
|
|
237
|
+
findings.push(finding("weak-verification", "weak_verification", "warning", "No-verification outcomes", `${weakVerification.length} events lack strong verification metadata.`, "Workers may be completing or blocking without durable command evidence in the telemetry stream.", weakVerification, "Create a systemic fix task to require verification command capture in worker outcome records.", "verification coverage", weakVerification.length, "medium"));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const pressureEvents = events.filter((event) => event.resources.some((resource) => resource.pressure === "medium" || resource.pressure === "high"));
|
|
241
|
+
if (pressureEvents.length > 0 || resources.some((resource) => resource.pressure === "medium" || resource.pressure === "high")) {
|
|
242
|
+
findings.push(finding("resource-pressure", "resource_pressure", resources.some((resource) => resource.pressure === "high") ? "critical" : "warning", "Resource allowance pressure", `${pressureEvents.length} events overlapped medium/high resource pressure; ${resources.length} resource samples are in scope.`, "Provider quota, GitHub API, queue, CI, or local resource pressure can inflate retry cost and slow dispatch-to-merge cycles.", pressureEvents, "Create a systemic capacity task that records allowance snapshots before redispatching failed workers.", "resource pressure", Math.max(pressureEvents.length, resources.length), "medium"));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const currentCost = sumCost(dayMetrics);
|
|
246
|
+
const previousCost = sumCost(previousDayMetrics);
|
|
247
|
+
const failedCostEvents = events.filter((event) => (event.outcome === "failed" || event.outcome === "blocked") && event.usage?.estimated_cost_ref != null);
|
|
248
|
+
if (currentCost > 0 && previousCost > 0 && currentCost >= previousCost * 1.5) {
|
|
249
|
+
findings.push(finding("cost-spike", "cost_spike", "warning", "Cost spike versus previous period", `Estimated selected-period cost is ${currentCost.toFixed(2)} versus ${previousCost.toFixed(2)} in the previous equivalent window.`, "Retry loops or expensive models may be consuming allowance faster than successful outcomes justify.", events, "Create a systemic cost-control task that ties model/provider selection to outcome quality and retry count.", "estimated USD", dayMetrics.length, "medium", `previous equivalent period: ${previousCost.toFixed(2)}`));
|
|
250
|
+
} else if (failedCostEvents.length > 0) {
|
|
251
|
+
findings.push(finding("failed-run-cost", "high_retry_cost", "warning", "Failed-run cost attached to outcomes", `${failedCostEvents.length} failed or blocked events include token/cost metadata.`, "Cost is being spent on outcomes that did not merge, close, defer, or create follow-up work.", failedCostEvents, "Create a systemic retry-budget task that escalates or changes strategy before another costly rerun.", "failed-run cost samples", failedCostEvents.length, "medium"));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const slowEvents = events.filter((event) => (event.duration_ms ?? 0) >= 1_800_000);
|
|
255
|
+
if (slowEvents.length > 0) {
|
|
256
|
+
findings.push(finding("slow-bottlenecks", "slow_bottleneck", "warning", "Slow dispatch-to-outcome bottlenecks", `${slowEvents.length} events exceeded 30 minutes.`, "Long-running dispatch, review, or merge gates can hide queue/concurrency or CI runner bottlenecks.", slowEvents, "Create a systemic bottleneck task that separates dispatch-to-PR and PR-to-merge timing evidence.", "duration >=30m", slowEvents.length, "medium"));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return findings.slice(0, 8);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function finding(id: string, kind: GuiPulseSystemicFinding["kind"], severity: GuiPulseSystemicFinding["severity"], title: string, detail: string, likelyCause: string, events: GuiPulseWorkerActivityEvent[], recommendation: string, metricLabel: string, sampleSize: number, confidence: GuiPulseSystemicFinding["confidence"], comparisonLabel = "selected period scoped to active filters"): GuiPulseSystemicFinding {
|
|
263
|
+
return { id: `insight-${id.replace(/[^a-z0-9-]+/gi, "-").toLowerCase()}`, kind, severity, title, detail, likely_cause: likelyCause, evidence_refs: evidenceRefs(events), event_refs: events.map((event) => event.id).slice(0, 6), confidence, recommendation, metric_label: metricLabel, period_label: "Last 24h", scope_label: DEFAULT_SCOPE, comparison_label: comparisonLabel, sample_size: sampleSize, primary_action: "create_systemic_fix" };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function groupEvents(events: GuiPulseWorkerActivityEvent[], keyFor: (event: GuiPulseWorkerActivityEvent) => string): Map<string, GuiPulseWorkerActivityEvent[]> {
|
|
267
|
+
const groups = new Map<string, GuiPulseWorkerActivityEvent[]>();
|
|
268
|
+
for (const event of events) {
|
|
269
|
+
const key = keyFor(event);
|
|
270
|
+
groups.set(key, [...(groups.get(key) ?? []), event]);
|
|
271
|
+
}
|
|
272
|
+
return groups;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function evidenceRefs(events: GuiPulseWorkerActivityEvent[]): string[] {
|
|
276
|
+
return events.flatMap((event) => [event.repo_ref, event.issue_ref, event.pull_request_ref, event.worker_session_ref, event.command_job_ref].filter(Boolean) as string[]).slice(0, 8);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function verificationSummary(record: MetricRecord): string {
|
|
280
|
+
const value = stringField(record, "verification") ?? stringField(record, "testing") ?? stringField(record, "verification_status");
|
|
281
|
+
if (typeof value === "string" && value.length > 0) return value;
|
|
282
|
+
return "Verification missing or weak in local telemetry; outcome should carry command evidence before completion claims.";
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function systemicFixRecommendation(record: MetricRecord, outcome: string): string {
|
|
286
|
+
if (/missing|weak|none/i.test(verificationSummary(record))) return "Require worker outcome telemetry to include verification commands and results.";
|
|
287
|
+
if (outcome === "failed" || outcome === "blocked") return "Group repeated failure evidence and create a worker-ready helper or validator fix.";
|
|
288
|
+
return "Create follow-up only when the same blindspot repeats across events in the selected period.";
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function sumCost(records: MetricRecord[]): number {
|
|
292
|
+
return records.reduce((sum, record) => sum + (numberValue(record.estimated_cost_usd ?? record.cost_usd) ?? 0), 0);
|
|
293
|
+
}
|
|
294
|
+
|
|
246
295
|
function buildFilters(events: GuiPulseWorkerActivityEvent[]): GuiPulseWorkerSummary["filters"] {
|
|
247
296
|
return {
|
|
248
297
|
repos: countOptions(events.map((event) => event.repo_ref).filter(Boolean) as string[], "repo:"),
|
|
@@ -284,93 +333,3 @@ function usageFromRecord(record: MetricRecord): GuiPulseWorkerUsageSnapshot | nu
|
|
|
284
333
|
const model = stringField(record, "model") ?? stringField(record, "model_ref") ?? null;
|
|
285
334
|
return { provider, provider_ref: `provider:${provider}`, model_ref: model, input_tokens: input ?? 0, output_tokens: output ?? 0, cached_tokens: numberValue(record.cached_tokens) ?? 0, total_tokens: total, cost_ref: costRef(record), estimated_cost_ref: costRef(record), wall_time_ms: numberValue(record.wall_time_ms ?? record.duration_ms) ?? 0 };
|
|
286
335
|
}
|
|
287
|
-
|
|
288
|
-
function outcomeFromRecord(record: MetricRecord): GuiPulseWorkerOutcome {
|
|
289
|
-
const raw = String(record.outcome ?? record.result ?? record.status ?? "in_progress").toLowerCase();
|
|
290
|
-
if (DIRECT_WORKER_OUTCOMES.has(raw)) {
|
|
291
|
-
return raw as GuiPulseWorkerOutcome;
|
|
292
|
-
}
|
|
293
|
-
const match = WORKER_OUTCOME_KEYWORDS.find(([keyword]) => raw.includes(keyword));
|
|
294
|
-
return match?.[1] ?? "in_progress";
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
function statusFromOutcome(outcome: GuiPulseWorkerOutcome): GuiPulseWorkerStatus {
|
|
298
|
-
if (outcome === "failed") return "failed";
|
|
299
|
-
if (outcome === "blocked" || outcome === "needs_maintainer_review") return "blocked";
|
|
300
|
-
if (outcome === "in_progress") return "running";
|
|
301
|
-
if (outcome === "deferred") return "deferred";
|
|
302
|
-
return "completed";
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
function severityFromStatus(status: GuiPulseWorkerStatus): GuiPulseWorkerSeverity {
|
|
306
|
-
if (status === "failed" || status === "blocked") return "critical";
|
|
307
|
-
if (status === "attention" || status === "deferred") return "warning";
|
|
308
|
-
if (status === "completed" || status === "healthy") return "success";
|
|
309
|
-
return "info";
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
function isHealthyOutcome(outcome: GuiPulseWorkerOutcome): boolean {
|
|
313
|
-
return ["merged", "closed", "deferred", "created_followup"].includes(outcome);
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
function issueOriginFromRecord(record: MetricRecord): GuiPulseIssueOrigin {
|
|
317
|
-
const raw = String(record.issue_origin ?? record.origin ?? "unknown").toLowerCase();
|
|
318
|
-
if (DIRECT_ISSUE_ORIGINS.has(raw)) return raw as GuiPulseIssueOrigin;
|
|
319
|
-
const match = ISSUE_ORIGIN_KEYWORDS.find(([keyword]) => raw.includes(keyword));
|
|
320
|
-
return match?.[1] ?? "unknown";
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
function authorAssociationFromRecord(record: MetricRecord): GuiPulseAuthorAssociation {
|
|
324
|
-
const raw = String(record.author_association ?? "UNKNOWN").toUpperCase();
|
|
325
|
-
if (["OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR", "FIRST_TIME_CONTRIBUTOR", "NONE", "UNKNOWN"].includes(raw)) return raw as GuiPulseAuthorAssociation;
|
|
326
|
-
return "UNKNOWN";
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
function durationMsFromRecord(record: MetricRecord): number | null {
|
|
330
|
-
const duration = numberValue(record.duration_ms ?? record.wall_time_ms);
|
|
331
|
-
if (duration !== null) return duration;
|
|
332
|
-
const elapsed = numberValue(record.elapsed_s);
|
|
333
|
-
return elapsed === null ? null : elapsed * 1000;
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
function summaryFromRecord(record: MetricRecord, outcome: GuiPulseWorkerOutcome): string {
|
|
337
|
-
const issue = stringOrNumber(record.issue ?? record.issue_number);
|
|
338
|
-
return issue === null ? `Worker outcome: ${outcome}.` : `Worker outcome for #${issue.replace(/^#/, "")}: ${outcome}.`;
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
function countOptions(values: string[], prefix = ""): Array<{ id: string; label: string; count: number }> {
|
|
342
|
-
const counts = new Map<string, number>();
|
|
343
|
-
for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1);
|
|
344
|
-
return [...counts.entries()].map(([label, count]) => ({ id: `${prefix}${slug(label)}`, label, count })).sort((a, b) => b.count - a.count || a.label.localeCompare(b.label));
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
function hasAvailableProvider(value: Record<string, unknown>): boolean {
|
|
348
|
-
const providers = Array.isArray(value.providers) ? value.providers : [];
|
|
349
|
-
return providers.some((provider) => isRecord(provider) && (numberValue(provider.available) ?? 0) > 0);
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
function costRef(record: MetricRecord): string | null {
|
|
353
|
-
const cost = record.estimated_cost_ref ?? record.cost_ref;
|
|
354
|
-
if (typeof cost === "string" && cost.length > 0 && !cost.includes("/")) return cost;
|
|
355
|
-
const amount = numberValue(record.estimated_cost_usd ?? record.cost_usd);
|
|
356
|
-
return amount === null ? null : `$${amount.toFixed(2)} estimated`;
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
function providerFromString(value: string | undefined): GuiPulseProviderId {
|
|
360
|
-
if (KNOWN_PROVIDER_IDS.includes(value as GuiPulseProviderId)) return value as GuiPulseProviderId;
|
|
361
|
-
return "unknown";
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
function stringOrNumber(value: unknown): string | null {
|
|
365
|
-
if (typeof value === "string" && value.length > 0) return value;
|
|
366
|
-
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
|
367
|
-
return null;
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
function numberValue(value: unknown): number | null {
|
|
371
|
-
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
function slug(value: string): string {
|
|
375
|
-
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "unknown";
|
|
376
|
-
}
|
|
@@ -341,6 +341,7 @@ final class DraggableTitlebarView: NSView {
|
|
|
341
341
|
final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKUIDelegate, WKScriptMessageHandler, NSMenuItemValidation, NSWindowDelegate {
|
|
342
342
|
private var window: NSWindow!
|
|
343
343
|
private var aboutWindow: NSWindow?
|
|
344
|
+
private var titlebarCameraButton: NSButton?
|
|
344
345
|
private var titlebarToggleButton: NSButton?
|
|
345
346
|
private var webView: WKWebView!
|
|
346
347
|
private let apiPort = ProcessInfo.processInfo.environment["AIDEVOPS_GUI_API_PORT"] ?? "8787"
|
|
@@ -408,9 +409,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate,
|
|
|
408
409
|
return
|
|
409
410
|
}
|
|
410
411
|
|
|
412
|
+
if revealFileURL(url) {
|
|
413
|
+
return
|
|
414
|
+
}
|
|
415
|
+
|
|
411
416
|
_ = openExternalURL(url)
|
|
412
417
|
}
|
|
413
418
|
|
|
419
|
+
private func revealFileURL(_ url: URL) -> Bool {
|
|
420
|
+
guard url.isFileURL else {
|
|
421
|
+
return false
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
NSWorkspace.shared.activateFileViewerSelecting([url])
|
|
425
|
+
return true
|
|
426
|
+
}
|
|
427
|
+
|
|
414
428
|
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
|
415
429
|
guard let url = navigationAction.request.url else {
|
|
416
430
|
decisionHandler(.allow)
|
|
@@ -552,7 +566,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate,
|
|
|
552
566
|
return webView?.canGoBack ?? false
|
|
553
567
|
case #selector(goForward(_:)):
|
|
554
568
|
return webView?.canGoForward ?? false
|
|
555
|
-
case #selector(reloadPage(_:)), #selector(stopLoading(_:)), #selector(resetZoom(_:)), #selector(zoomIn(_:)), #selector(zoomOut(_:)):
|
|
569
|
+
case #selector(reloadPage(_:)), #selector(stopLoading(_:)), #selector(resetZoom(_:)), #selector(zoomIn(_:)), #selector(zoomOut(_:)), #selector(captureAppScreenshot(_:)), #selector(capturePageScreenshot(_:)):
|
|
556
570
|
return webView != nil
|
|
557
571
|
default:
|
|
558
572
|
return true
|
|
@@ -779,11 +793,30 @@ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate,
|
|
|
779
793
|
|
|
780
794
|
overlay.addSubview(toggleButton)
|
|
781
795
|
titlebarToggleButton = toggleButton
|
|
796
|
+
|
|
797
|
+
let cameraButton = NSButton(frame: .zero)
|
|
798
|
+
cameraButton.action = #selector(showScreenshotMenu(_:))
|
|
799
|
+
cameraButton.bezelStyle = .regularSquare
|
|
800
|
+
cameraButton.contentTintColor = defaultButtonAccent
|
|
801
|
+
cameraButton.image = cameraImage()
|
|
802
|
+
cameraButton.imagePosition = .imageOnly
|
|
803
|
+
cameraButton.isBordered = false
|
|
804
|
+
cameraButton.setButtonType(.momentaryChange)
|
|
805
|
+
cameraButton.target = self
|
|
806
|
+
cameraButton.toolTip = "Capture an aidevops screenshot"
|
|
807
|
+
cameraButton.translatesAutoresizingMaskIntoConstraints = false
|
|
808
|
+
|
|
809
|
+
overlay.addSubview(cameraButton)
|
|
810
|
+
titlebarCameraButton = cameraButton
|
|
782
811
|
NSLayoutConstraint.activate([
|
|
783
812
|
toggleButton.leadingAnchor.constraint(equalTo: overlay.leadingAnchor, constant: 92),
|
|
784
813
|
toggleButton.centerYAnchor.constraint(equalTo: overlay.centerYAnchor, constant: 3.5),
|
|
785
814
|
toggleButton.heightAnchor.constraint(equalToConstant: 22),
|
|
786
|
-
toggleButton.widthAnchor.constraint(equalToConstant: 22)
|
|
815
|
+
toggleButton.widthAnchor.constraint(equalToConstant: 22),
|
|
816
|
+
cameraButton.trailingAnchor.constraint(equalTo: overlay.trailingAnchor, constant: -92),
|
|
817
|
+
cameraButton.centerYAnchor.constraint(equalTo: overlay.centerYAnchor, constant: 3.5),
|
|
818
|
+
cameraButton.heightAnchor.constraint(equalToConstant: 22),
|
|
819
|
+
cameraButton.widthAnchor.constraint(equalToConstant: 22)
|
|
787
820
|
])
|
|
788
821
|
|
|
789
822
|
return overlay
|
|
@@ -791,6 +824,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate,
|
|
|
791
824
|
|
|
792
825
|
private func updateTitlebarAccent(hue: CGFloat) {
|
|
793
826
|
let normalizedHue = min(359, max(0, hue))
|
|
827
|
+
titlebarCameraButton?.contentTintColor = accentColor(hueDegrees: normalizedHue, alpha: 0.9)
|
|
794
828
|
titlebarToggleButton?.contentTintColor = accentColor(hueDegrees: normalizedHue, alpha: 0.9)
|
|
795
829
|
}
|
|
796
830
|
|
|
@@ -866,10 +900,191 @@ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate,
|
|
|
866
900
|
return image
|
|
867
901
|
}
|
|
868
902
|
|
|
903
|
+
private func cameraImage() -> NSImage {
|
|
904
|
+
let image = NSImage(size: NSSize(width: 20, height: 20), flipped: false) { _ in
|
|
905
|
+
let body = NSBezierPath(roundedRect: NSRect(x: 3, y: 6, width: 14, height: 10), xRadius: 2.7, yRadius: 2.7)
|
|
906
|
+
body.lineWidth = 2
|
|
907
|
+
NSColor.labelColor.setStroke()
|
|
908
|
+
body.stroke()
|
|
909
|
+
|
|
910
|
+
let prism = NSBezierPath(roundedRect: NSRect(x: 6, y: 14, width: 5, height: 2.3), xRadius: 1, yRadius: 1)
|
|
911
|
+
prism.lineWidth = 1.8
|
|
912
|
+
prism.stroke()
|
|
913
|
+
|
|
914
|
+
let lens = NSBezierPath(ovalIn: NSRect(x: 7.1, y: 8.1, width: 5.8, height: 5.8))
|
|
915
|
+
lens.lineWidth = 1.8
|
|
916
|
+
lens.stroke()
|
|
917
|
+
|
|
918
|
+
return true
|
|
919
|
+
}
|
|
920
|
+
image.isTemplate = true
|
|
921
|
+
return image
|
|
922
|
+
}
|
|
923
|
+
|
|
869
924
|
@objc private func toggleMachineRail(_ sender: Any?) {
|
|
870
925
|
webView.evaluateJavaScript("window.dispatchEvent(new CustomEvent('aidevops:toggle-machine-rail')); true;", completionHandler: nil)
|
|
871
926
|
}
|
|
872
927
|
|
|
928
|
+
@objc private func showScreenshotMenu(_ sender: Any?) {
|
|
929
|
+
guard let button = sender as? NSButton else {
|
|
930
|
+
return
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
let menu = NSMenu(title: "Screenshots")
|
|
934
|
+
menu.addItem(menuItem("Screenshot App", action: #selector(captureAppScreenshot(_:)), target: self))
|
|
935
|
+
menu.addItem(menuItem("Screenshot Page", action: #selector(capturePageScreenshot(_:)), target: self))
|
|
936
|
+
menu.popUp(positioning: nil, at: NSPoint(x: 0, y: button.bounds.height + 3), in: button)
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
@objc private func captureAppScreenshot(_ sender: Any?) {
|
|
940
|
+
guard let contentView = window.contentView else {
|
|
941
|
+
return
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
let bounds = contentView.bounds
|
|
945
|
+
guard let representation = contentView.bitmapImageRepForCachingDisplay(in: bounds) else {
|
|
946
|
+
return
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
contentView.cacheDisplay(in: bounds, to: representation)
|
|
950
|
+
let image = NSImage(size: bounds.size)
|
|
951
|
+
image.addRepresentation(representation)
|
|
952
|
+
saveScreenshot(image: image, nameComponent: "window")
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
@objc private func capturePageScreenshot(_ sender: Any?) {
|
|
956
|
+
let script = """
|
|
957
|
+
(() => {
|
|
958
|
+
const el = document.querySelector('.workspace-scroll');
|
|
959
|
+
const title = document.querySelector('.header-title h1')?.textContent || document.title || 'page';
|
|
960
|
+
if (!el) return null;
|
|
961
|
+
const rect = el.getBoundingClientRect();
|
|
962
|
+
return { x: rect.left, y: rect.top, width: rect.width, height: rect.height, scrollHeight: el.scrollHeight, title, originalScrollTop: el.scrollTop };
|
|
963
|
+
})();
|
|
964
|
+
"""
|
|
965
|
+
|
|
966
|
+
webView.evaluateJavaScript(script) { [weak self] result, _ in
|
|
967
|
+
guard let self = self, let info = result as? [String: Any] else {
|
|
968
|
+
return
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
self.captureScrollableWorkspace(info: info)
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
private func captureScrollableWorkspace(info: [String: Any]) {
|
|
976
|
+
guard let x = info["x"] as? NSNumber,
|
|
977
|
+
let y = info["y"] as? NSNumber,
|
|
978
|
+
let width = info["width"] as? NSNumber,
|
|
979
|
+
let height = info["height"] as? NSNumber,
|
|
980
|
+
let scrollHeight = info["scrollHeight"] as? NSNumber else {
|
|
981
|
+
return
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
let viewportHeight = max(1, CGFloat(height.doubleValue))
|
|
985
|
+
let contentHeight = max(viewportHeight, CGFloat(scrollHeight.doubleValue))
|
|
986
|
+
let contentWidth = max(1, CGFloat(width.doubleValue))
|
|
987
|
+
let captureRect = CGRect(x: CGFloat(x.doubleValue), y: CGFloat(y.doubleValue), width: contentWidth, height: viewportHeight)
|
|
988
|
+
let pageName = sanitizeFilenameComponent((info["title"] as? String) ?? "page")
|
|
989
|
+
let originalScrollTop = CGFloat((info["originalScrollTop"] as? NSNumber)?.doubleValue ?? 0)
|
|
990
|
+
let composite = NSImage(size: NSSize(width: contentWidth, height: contentHeight))
|
|
991
|
+
captureWorkspaceChunk(rect: captureRect, contentHeight: contentHeight, viewportHeight: viewportHeight, originalScrollTop: originalScrollTop, targetOffset: 0, pageName: pageName, composite: composite, capturedOffsets: Set<Int>())
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
private func captureWorkspaceChunk(rect: CGRect, contentHeight: CGFloat, viewportHeight: CGFloat, originalScrollTop: CGFloat, targetOffset: CGFloat, pageName: String, composite: NSImage, capturedOffsets: Set<Int>) {
|
|
995
|
+
let scrollScript = """
|
|
996
|
+
(() => {
|
|
997
|
+
const el = document.querySelector('.workspace-scroll');
|
|
998
|
+
if (!el) return null;
|
|
999
|
+
el.scrollTop = \(Int(targetOffset));
|
|
1000
|
+
return el.scrollTop;
|
|
1001
|
+
})();
|
|
1002
|
+
"""
|
|
1003
|
+
|
|
1004
|
+
webView.evaluateJavaScript(scrollScript) { [weak self] result, _ in
|
|
1005
|
+
guard let self = self else {
|
|
1006
|
+
return
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
let actualOffset = CGFloat((result as? NSNumber)?.doubleValue ?? Double(targetOffset))
|
|
1010
|
+
let offsetKey = Int(actualOffset.rounded())
|
|
1011
|
+
var nextCapturedOffsets = capturedOffsets
|
|
1012
|
+
if capturedOffsets.contains(offsetKey) {
|
|
1013
|
+
self.restoreWorkspaceScroll(originalScrollTop)
|
|
1014
|
+
self.saveScreenshot(image: composite, nameComponent: pageName)
|
|
1015
|
+
return
|
|
1016
|
+
}
|
|
1017
|
+
nextCapturedOffsets.insert(offsetKey)
|
|
1018
|
+
|
|
1019
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) {
|
|
1020
|
+
let configuration = WKSnapshotConfiguration()
|
|
1021
|
+
configuration.rect = rect
|
|
1022
|
+
self.webView.takeSnapshot(with: configuration) { snapshot, _ in
|
|
1023
|
+
if let snapshot = snapshot {
|
|
1024
|
+
composite.lockFocus()
|
|
1025
|
+
snapshot.draw(in: NSRect(x: 0, y: contentHeight - actualOffset - viewportHeight, width: rect.width, height: viewportHeight), from: .zero, operation: .copy, fraction: 1)
|
|
1026
|
+
composite.unlockFocus()
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
if actualOffset + viewportHeight >= contentHeight - 1 {
|
|
1030
|
+
self.restoreWorkspaceScroll(originalScrollTop)
|
|
1031
|
+
self.saveScreenshot(image: composite, nameComponent: pageName)
|
|
1032
|
+
} else {
|
|
1033
|
+
let nextOffset = min(contentHeight - viewportHeight, actualOffset + viewportHeight)
|
|
1034
|
+
self.captureWorkspaceChunk(rect: rect, contentHeight: contentHeight, viewportHeight: viewportHeight, originalScrollTop: originalScrollTop, targetOffset: nextOffset, pageName: pageName, composite: composite, capturedOffsets: nextCapturedOffsets)
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
private func restoreWorkspaceScroll(_ scrollTop: CGFloat) {
|
|
1042
|
+
webView.evaluateJavaScript("document.querySelector('.workspace-scroll')?.scrollTo({ top: \(Int(scrollTop)), behavior: 'instant' }); true;", completionHandler: nil)
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
private func saveScreenshot(image: NSImage, nameComponent: String) {
|
|
1046
|
+
guard let tiff = image.tiffRepresentation,
|
|
1047
|
+
let bitmap = NSBitmapImageRep(data: tiff),
|
|
1048
|
+
let data = bitmap.representation(using: .png, properties: [:]) else {
|
|
1049
|
+
return
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
let directory = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Screenshots", isDirectory: true)
|
|
1053
|
+
do {
|
|
1054
|
+
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
1055
|
+
let fileURL = directory.appendingPathComponent("aidevops-app-screenshot-\(sanitizeFilenameComponent(nameComponent))-\(screenshotTimestamp()).png")
|
|
1056
|
+
try data.write(to: fileURL, options: .atomic)
|
|
1057
|
+
NSPasteboard.general.clearContents()
|
|
1058
|
+
NSPasteboard.general.setString(fileURL.path, forType: .string)
|
|
1059
|
+
notifyScreenshotCaptured(fileURL: fileURL)
|
|
1060
|
+
} catch {
|
|
1061
|
+
NSSound.beep()
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
private func screenshotTimestamp() -> String {
|
|
1066
|
+
let formatter = DateFormatter()
|
|
1067
|
+
formatter.dateFormat = "yyMMdd-HHmmss"
|
|
1068
|
+
return formatter.string(from: Date())
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
private func sanitizeFilenameComponent(_ value: String) -> String {
|
|
1072
|
+
let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_"))
|
|
1073
|
+
let mapped = value.lowercased().unicodeScalars.map { allowed.contains($0) ? String($0) : "-" }.joined()
|
|
1074
|
+
let collapsed = mapped.split(separator: "-").joined(separator: "-")
|
|
1075
|
+
return collapsed.isEmpty ? "page" : collapsed
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
private func notifyScreenshotCaptured(fileURL: URL) {
|
|
1079
|
+
guard let payloadData = try? JSONSerialization.data(withJSONObject: ["path": fileURL.path, "url": fileURL.absoluteString], options: []),
|
|
1080
|
+
let payload = String(data: payloadData, encoding: .utf8) else {
|
|
1081
|
+
return
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
let script = "window.dispatchEvent(new CustomEvent('aidevops:screenshot-captured', { detail: \(payload) })); true;"
|
|
1085
|
+
webView.evaluateJavaScript(script, completionHandler: nil)
|
|
1086
|
+
}
|
|
1087
|
+
|
|
873
1088
|
private func saveMainWindowFrame() {
|
|
874
1089
|
guard window != nil else {
|
|
875
1090
|
return
|