aidevops 3.29.40 → 3.29.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
- }
@@ -1,6 +1,6 @@
1
1
  export type GuiRouteClassification = "read" | "write" | "destructive";
2
2
 
3
- export type GuiOperationId = "setup.status.read" | "capabilities.read" | "filesystem.read" | "vault.status.read" | "apps.action.run" | "apps.action.status" | "tambo.session.read";
3
+ export type GuiOperationId = "setup.status.read" | "capabilities.read" | "filesystem.read" | "vault.status.read" | "apps.action.run" | "apps.action.status" | "pulse_workers.action.run" | "pulse_workers.action.status" | "tambo.session.read";
4
4
 
5
5
  export type GuiFileRootId = "agents" | "config" | "localSetup" | "git";
6
6
 
@@ -106,6 +106,34 @@ export interface GuiAppActionJobSummary {
106
106
  output: string[];
107
107
  }
108
108
 
109
+ export type GuiPulseWorkerActionId = "diagnose" | "run_pulse" | "open_logs" | "create_systemic_fix";
110
+
111
+ export interface GuiPulseWorkerActionSummary {
112
+ id: GuiPulseWorkerActionId;
113
+ label: string;
114
+ enabled: boolean;
115
+ classification: GuiRouteClassification;
116
+ confirmation: "none" | "recommended" | "required";
117
+ command_preview: string;
118
+ target_ref: string;
119
+ scope_copy: string;
120
+ expected_effect: string;
121
+ audit_ref: string;
122
+ }
123
+
124
+ export interface GuiPulseWorkerActionJobSummary {
125
+ id: string;
126
+ action: GuiPulseWorkerActionId;
127
+ target_ref: string;
128
+ status: "running" | "completed" | "failed" | "rejected";
129
+ command_preview: string;
130
+ started_at: string;
131
+ finished_at: string | null;
132
+ exit_code: number | null;
133
+ output: string[];
134
+ audit_ref: string;
135
+ }
136
+
109
137
  export interface GuiSecretReference {
110
138
  name: string;
111
139
  status: "configured" | "missing" | "unchecked";
@@ -239,9 +267,9 @@ export type GuiPulseWorkerOutcome = "merged" | "closed" | "in_progress" | "needs
239
267
 
240
268
  export type GuiPulseWorkerSeverity = "info" | "success" | "warning" | "critical";
241
269
 
242
- export type GuiPulseIssueOrigin = "aidevops_created" | "maintainer_created" | "origin_interactive" | "third_party" | "unknown";
270
+ export type GuiPulseIssueOrigin = "aidevops_created" | "self_created" | "maintainer_created" | "origin_interactive" | "third_party" | "imported" | "unknown";
243
271
 
244
- export type GuiPulseAuthorAssociation = "OWNER" | "MEMBER" | "COLLABORATOR" | "CONTRIBUTOR" | "FIRST_TIME_CONTRIBUTOR" | "NONE" | "UNKNOWN";
272
+ export type GuiPulseAuthorAssociation = "OWNER" | "MEMBER" | "COLLABORATOR" | "CONTRIBUTOR" | "FIRST_TIMER" | "FIRST_TIME_CONTRIBUTOR" | "NONE" | "UNKNOWN";
245
273
 
246
274
  export type GuiPulseResourceKind = "provider_model" | "github_api" | "ci_capacity" | "queue" | "cpu" | "memory" | "disk" | "network";
247
275
 
@@ -294,6 +322,27 @@ export interface GuiPulseWorkerChartSeries {
294
322
  points: GuiPulseWorkerChartPoint[];
295
323
  }
296
324
 
325
+ export type GuiPulseInsightKind = "third_party_waiting" | "high_retry_cost" | "repeated_failure" | "cost_spike" | "weak_verification" | "resource_pressure" | "slow_bottleneck";
326
+
327
+ export interface GuiPulseSystemicFinding {
328
+ id: string;
329
+ kind: GuiPulseInsightKind;
330
+ severity: GuiPulseWorkerSeverity;
331
+ title: string;
332
+ detail: string;
333
+ likely_cause: string;
334
+ evidence_refs: string[];
335
+ event_refs: string[];
336
+ confidence: "low" | "medium" | "high";
337
+ recommendation: string;
338
+ metric_label: string;
339
+ period_label: string;
340
+ scope_label: string;
341
+ comparison_label: string;
342
+ sample_size: number;
343
+ primary_action: GuiPulseWorkerActionId;
344
+ }
345
+
297
346
  export interface GuiPulseWorkerActivityEvent {
298
347
  id: string;
299
348
  type: GuiPulseWorkerEventType;
@@ -333,6 +382,7 @@ export interface GuiPulseWorkerSummary {
333
382
  updated_at: string;
334
383
  kpis: GuiPulseWorkerKpiCard[];
335
384
  attention: Array<{ id: string; severity: GuiPulseWorkerSeverity; title: string; detail: string; event_ref: string | null }>;
385
+ insights: GuiPulseSystemicFinding[];
336
386
  filters: {
337
387
  repos: GuiPulseWorkerFilterOption[];
338
388
  event_types: GuiPulseWorkerFilterOption[];
@@ -345,6 +395,7 @@ export interface GuiPulseWorkerSummary {
345
395
  };
346
396
  charts: GuiPulseWorkerChartSeries[];
347
397
  events: GuiPulseWorkerActivityEvent[];
398
+ actions: GuiPulseWorkerActionSummary[];
348
399
  }
349
400
 
350
401
  export interface GuiConversationMessage {
@@ -655,6 +706,28 @@ export const APP_ACTION_STATUS_ROUTE_MANIFEST: GuiRouteManifest = {
655
706
  redactions: ["secret_values", "credential_paths", "private_key_material"],
656
707
  };
657
708
 
709
+ export const PULSE_WORKERS_ACTION_ROUTE_MANIFEST: GuiRouteManifest = {
710
+ route: "/api/pulse-workers/actions/:action",
711
+ method: "POST",
712
+ operation_id: "pulse_workers.action.run",
713
+ classification: "write",
714
+ source_surface: "pulse_workers",
715
+ adapter: "pulseWorkerActions.startPulseWorkerAction",
716
+ command_pattern: ["allowlisted", "aidevops", "pulse/worker-diagnostics", "background-job"],
717
+ redactions: ["secret_values", "credential_paths", "private_key_material", "github_tokens", "authorization_headers", "session_cookies"],
718
+ };
719
+
720
+ export const PULSE_WORKERS_ACTION_STATUS_ROUTE_MANIFEST: GuiRouteManifest = {
721
+ route: "/api/pulse-workers/jobs/:jobId",
722
+ method: "GET",
723
+ operation_id: "pulse_workers.action.status",
724
+ classification: "read",
725
+ source_surface: "pulse_workers",
726
+ adapter: "pulseWorkerActions.readPulseWorkerActionJob",
727
+ command_pattern: ["background-job", "metadata-and-redacted-output-only"],
728
+ redactions: ["secret_values", "credential_paths", "private_key_material", "github_tokens", "authorization_headers", "session_cookies"],
729
+ };
730
+
658
731
  export const GUI_FILE_ROOTS: readonly GuiFileRootDefinition[] = [
659
732
  {
660
733
  id: "agents",
@@ -27,6 +27,11 @@ export const pulseWorkersFixture: GuiPulseWorkerSummary = {
27
27
  { id: "stale-loop", severity: "warning", title: "1 worker loop matched stale-symptom diagnostics", detail: "Pulse should suggest a systemic diagnostics task instead of another retry.", event_ref: null },
28
28
  { id: "github-api", severity: "critical", title: "GitHub API allowance below dispatch comfort threshold", detail: "REST budget remains available; GraphQL-heavy list operations should pause.", event_ref: "event:community-25876" },
29
29
  ],
30
+ insights: [
31
+ { id: "insight-community-waiting", kind: "third_party_waiting", severity: "warning", title: "Third-party issues waiting", detail: "1 community-origin issue needs triage before dispatch.", likely_cause: "Community reports require collaborator trust-boundary handling and may need maintainer decisions.", evidence_refs: ["marcusquinn/aidevops", "#25876"], event_refs: ["event:community-25876"], confidence: "high", recommendation: "Create a systemic triage task with first-response thresholds and non-collaborator safety checks.", metric_label: "community waiting count", period_label: pulsePeriod, scope_label: pulseScope, comparison_label: "previous 24h baseline", sample_size: 1, primary_action: "create_systemic_fix" },
32
+ { id: "insight-weak-verification", kind: "weak_verification", severity: "warning", title: "No-verification outcomes", detail: "1 event lacks strong verification metadata.", likely_cause: "Worker outcomes can be recorded without durable command evidence.", evidence_refs: ["worker:25912:gpt-5.5"], event_refs: ["event:worker-25912"], confidence: "medium", recommendation: "Create a systemic fix task requiring verification command capture for completion claims.", metric_label: "verification coverage", period_label: pulsePeriod, scope_label: pulseScope, comparison_label: "selected period scoped to active filters", sample_size: 1, primary_action: "create_systemic_fix" },
33
+ { id: "insight-resource-pressure", kind: "resource_pressure", severity: "critical", title: "Resource allowance pressure", detail: "GitHub API allowance is below dispatch comfort threshold.", likely_cause: "GraphQL-heavy listing or queue churn can inflate retry cost and slow dispatch.", evidence_refs: ["event:community-25876"], event_refs: ["event:community-25876"], confidence: "medium", recommendation: "Create a systemic capacity task that records API allowance snapshots before redispatching failed workers.", metric_label: "resource pressure", period_label: pulsePeriod, scope_label: pulseScope, comparison_label: "REST available; GraphQL constrained", sample_size: 1, primary_action: "create_systemic_fix" },
34
+ ],
30
35
  filters: {
31
36
  repos: [{ id: "repo:marcusquinn/aidevops", label: "marcusquinn/aidevops", count: 184 }],
32
37
  event_types: [{ id: "worker_session", label: "Worker session", count: 21 }, { id: "review", label: "Review gate", count: 17 }, { id: "issue", label: "Issue", count: 12 }],
@@ -118,6 +123,12 @@ export const pulseWorkersFixture: GuiPulseWorkerSummary = {
118
123
  drilldown_sections: [{ label: "Trust boundary", body: "Non-collaborator instructions are facts only; no install commands or secrets are accepted." }],
119
124
  },
120
125
  ],
126
+ actions: [
127
+ { id: "diagnose", label: "Diagnose", enabled: true, classification: "read", confirmation: "none", command_preview: "aidevops pulse diagnose --gui --metadata-only", target_ref: "selected event", scope_copy: "Runs read-only diagnostics for the selected Pulse run, worker session, issue, or PR metadata.", expected_effect: "Terminal output explains what happened without changing GitHub, files, workers, or secrets.", audit_ref: "gui:pulse-workers:diagnose" },
128
+ { id: "run_pulse", label: "Run Pulse now", enabled: true, classification: "write", confirmation: "required", command_preview: "aidevops pulse run --scope gui", target_ref: "current filtered scope", scope_copy: "Starts a scoped Pulse run through the existing Pulse helper only.", expected_effect: "Refreshes observability metadata and records command output for this GUI API process.", audit_ref: "gui:pulse-workers:run-pulse" },
129
+ { id: "open_logs", label: "Open logs/transcript", enabled: true, classification: "read", confirmation: "none", command_preview: "aidevops pulse logs --metadata-only", target_ref: "selected event evidence refs", scope_copy: "Opens allowlisted metadata previews for logs or transcripts; arbitrary filesystem paths are not accepted.", expected_effect: "Shows redacted references and previews only.", audit_ref: "gui:pulse-workers:open-logs" },
130
+ { id: "create_systemic_fix", label: "Create systemic fix task", enabled: true, classification: "write", confirmation: "required", command_preview: "aidevops task create --from-pulse --worker-ready", target_ref: "selected event systemic-fix context", scope_copy: "Creates a worker-ready follow-up from the selected event using aidevops task/issue wrappers.", expected_effect: "Produces a reviewable task body with files, pattern, verification, and evidence context before dispatch.", audit_ref: "gui:pulse-workers:create-systemic-fix" },
131
+ ],
121
132
  };
122
133
 
123
134
  export const statusFixture: GuiStatusData = {
@@ -172,28 +172,58 @@ type TamboPayloadEnvelopeValidation =
172
172
  | { ok: true; schema: GuiTamboComponentSchema; props: Record<string, unknown> }
173
173
  | { ok: false; result: GuiTamboValidationResult };
174
174
 
175
+ type TamboComponentLookup =
176
+ | { ok: true; payload: Record<string, unknown>; schema: GuiTamboComponentSchema }
177
+ | { ok: false; result: GuiTamboValidationResult };
178
+
175
179
  function validateTamboPayloadEnvelope(payload: unknown, scope: GuiConversationScope): TamboPayloadEnvelopeValidation {
180
+ const lookup = resolveTamboComponentLookup(payload);
181
+ if (lookup.ok === false) {
182
+ return lookup;
183
+ }
184
+
185
+ const envelopeError = validateScopedTamboEnvelope(lookup.payload, scope);
186
+ if (envelopeError !== null) {
187
+ return { ok: false, result: invalid(lookup.schema.name, {}, envelopeError) };
188
+ }
189
+
190
+ const props = resolveTamboComponentProps(lookup.schema, lookup.payload.props);
191
+ if (props.ok === false) {
192
+ return props;
193
+ }
194
+
195
+ return { ok: true, schema: lookup.schema, props: props.value };
196
+ }
197
+
198
+ function resolveTamboComponentLookup(payload: unknown): TamboComponentLookup {
176
199
  if (!isRecord(payload)) {
177
200
  return { ok: false, result: invalid(null, {}, "payload_not_object") };
178
201
  }
179
- if (typeof payload.component !== "string") {
180
- return { ok: false, result: invalid(null, {}, "component_missing") };
181
- }
182
202
 
183
- const schema = findTamboComponentSchema(payload.component);
203
+ const schema = resolveTamboComponentSchema(payload.component);
184
204
  if (schema === undefined) {
185
- return { ok: false, result: invalid(null, {}, "component_not_registered") };
205
+ return { ok: false, result: invalid(null, {}, componentLookupError(payload.component)) };
186
206
  }
187
207
 
188
- const envelopeError = validateScopedTamboEnvelope(payload, scope);
189
- if (envelopeError !== null) {
190
- return { ok: false, result: invalid(schema.name, {}, envelopeError) };
191
- }
192
- if (!isRecord(payload.props)) {
208
+ return { ok: true, payload, schema };
209
+ }
210
+
211
+ function resolveTamboComponentSchema(component: unknown): GuiTamboComponentSchema | undefined {
212
+ return typeof component === "string" ? findTamboComponentSchema(component) : undefined;
213
+ }
214
+
215
+ function componentLookupError(component: unknown): string {
216
+ return typeof component === "string" ? "component_not_registered" : "component_missing";
217
+ }
218
+
219
+ function resolveTamboComponentProps(
220
+ schema: GuiTamboComponentSchema,
221
+ props: unknown,
222
+ ): { ok: true; value: Record<string, unknown> } | { ok: false; result: GuiTamboValidationResult } {
223
+ if (!isRecord(props)) {
193
224
  return { ok: false, result: invalid(schema.name, {}, "props_not_object") };
194
225
  }
195
-
196
- return { ok: true, schema, props: payload.props };
226
+ return { ok: true, value: props };
197
227
  }
198
228
 
199
229
  function findTamboComponentSchema(component: string): GuiTamboComponentSchema | undefined {
@@ -1,11 +1,14 @@
1
- import type { GuiAppActionJobSummary } from "@aidevops/gui-shared";
1
+ import type { GuiAppActionJobSummary, GuiPulseWorkerActionJobSummary } from "@aidevops/gui-shared";
2
2
  import { type ReactElement, type ReactNode, useEffect, useRef } from "react";
3
3
  import { FiX } from "react-icons/fi";
4
4
 
5
5
  const ANSI_PATTERN = /\x1b\[([0-9;]*)m/g;
6
6
 
7
- export function AppActionTerminal({ job, onDismiss }: { job: GuiAppActionJobSummary; onDismiss: (jobId: string) => void }): ReactElement {
7
+ type TerminalJob = GuiAppActionJobSummary | GuiPulseWorkerActionJobSummary;
8
+
9
+ export function AppActionTerminal({ job, label, onDismiss }: { job: TerminalJob; label?: string; onDismiss: (jobId: string) => void }): ReactElement {
8
10
  const outputRef = useRef<HTMLPreElement | null>(null);
11
+ const title = label ?? terminalTitle(job);
9
12
 
10
13
  useEffect(() => {
11
14
  if (outputRef.current !== null) {
@@ -14,14 +17,18 @@ export function AppActionTerminal({ job, onDismiss }: { job: GuiAppActionJobSumm
14
17
  }, [job.output.length]);
15
18
 
16
19
  return (
17
- <section className="app-terminal" id={`app-job-${job.id}`} aria-label="App action terminal output">
18
- <header><strong>{job.app_id} {job.action}</strong><span>{terminalStatusLabel(job)}</span><button aria-label={`Dismiss ${job.app_id} ${job.action} terminal output`} className="terminal-close-button" onClick={() => onDismiss(job.id)} title="Dismiss terminal output" type="button"><FiX aria-hidden="true" /></button></header>
20
+ <section className="app-terminal" id={`app-job-${job.id}`} aria-label={`${title} terminal output`}>
21
+ <header><strong>{title}</strong><span>{terminalStatusLabel(job)}</span><button aria-label={`Dismiss ${title} terminal output`} className="terminal-close-button" onClick={() => onDismiss(job.id)} title="Dismiss terminal output" type="button"><FiX aria-hidden="true" /></button></header>
19
22
  <pre ref={outputRef}>{renderTerminalOutput(job.output)}</pre>
20
23
  </section>
21
24
  );
22
25
  }
23
26
 
24
- function terminalStatusLabel(job: GuiAppActionJobSummary): string {
27
+ function terminalTitle(job: TerminalJob): string {
28
+ return "app_id" in job ? `${job.app_id} ${job.action}` : `Pulse & Workers ${job.action}`;
29
+ }
30
+
31
+ function terminalStatusLabel(job: TerminalJob): string {
25
32
  if (job.status === "running") {
26
33
  return "running";
27
34
  }
@@ -1,10 +1,11 @@
1
1
  import { validateTamboComponentPayload, type GuiConversationMessagePart, type GuiConversationScope, type GuiTamboValidationResult } from "../../gui-shared/src";
2
+ import { text } from "./app-model";
2
3
 
3
4
  export function TamboConversationPart({ part, scope }: { part: GuiConversationMessagePart; scope: GuiConversationScope }) {
4
5
  const validation = validateTamboComponentPayload(part.payload_json, scope);
5
6
 
6
7
  if (!validation.ok) {
7
- return <div className="genui-card genui-card-invalid" data-genui-component={validation.component ?? "unknown"} role="note"><strong>Unsupported DevOps card</strong><small>{validation.errors.join(", ")}</small></div>;
8
+ return <div className="genui-card genui-card-invalid" data-genui-component={validation.component ?? "unknown"} role="note"><strong>{text.unsupportedDevOpsCard}</strong><small>{validation.errors.join(", ")}</small></div>;
8
9
  }
9
10
 
10
11
  return <DevOpsCard validation={validation} />;
@@ -15,15 +16,15 @@ function DevOpsCard({ validation }: { validation: GuiTamboValidationResult }) {
15
16
  const entries = Object.entries(props).filter(([key]) => key !== "title" && key !== "disabled");
16
17
 
17
18
  return (
18
- <section className="genui-card" data-genui-component={validation.component ?? "unknown"} aria-label={`${validation.component} DevOps card`}>
19
+ <section className="genui-card" data-genui-component={validation.component ?? "unknown"} aria-label={`${validation.component} ${text.devOpsCard}`}>
19
20
  <header>
20
- <p className="eyebrow">Tambo GenUI · read-only</p>
21
+ <p className="eyebrow">{text.tamboGenUiReadOnly}</p>
21
22
  <h3>{stringProp(props.title) ?? validation.component}</h3>
22
23
  </header>
23
24
  <dl>
24
25
  {entries.map(([key, value]) => <CardProperty key={key} name={key} value={value} />)}
25
26
  </dl>
26
- {validation.component === "ApprovalPromptCard" ? <p className="genui-deferred">Approval execution is deferred until audited approval tooling exists.</p> : null}
27
+ {validation.component === "ApprovalPromptCard" ? <p className="genui-deferred">{text.approvalExecutionDeferred}</p> : null}
27
28
  </section>
28
29
  );
29
30
  }