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.
@@ -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 DIRECT_WORKER_OUTCOMES = new Set<string>(["merged", "closed", "in_progress", "needs_maintainer_review", "blocked", "failed", "deferred", "created_followup"]);
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 warnings = buildAttention(sourceStates, pulseCounters, resourceSnapshots, oauthPool.value);
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 recordTimeMs(record: MetricRecord): number | null {
148
- for (const key of ["ts", "timestamp", "started_at", "finished_at", "created_at", "updated_at"]) {
149
- const value = record[key];
150
- if (typeof value === "number" && Number.isFinite(value)) {
151
- return value > 9_999_999_999 ? value : value * 1000;
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