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,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 {
@@ -4,7 +4,9 @@ import { MachineRail, Sidebar } from "./AppNavigation";
4
4
  import { Workspace } from "./AppWorkspace";
5
5
  import type { ContrastPreference, ConversationMode, FontPreference, FontSizePreference, ShellMode, SurfaceId, ThemePreference } from "./app-model";
6
6
  import { DEFAULT_ACCENT_HUE, DEFAULT_CONTRAST, DEFAULT_FONT, DEFAULT_FONT_SIZE, fileRootBySurface, findSurface, findSurfaceSectionLabel, fontFamilyForPreference, fontSizeForPreference, getSystemTheme, isContrastPreference, isFontPreference, isFontSizePreference } from "./app-model";
7
+ import { DesktopStatusBar } from "./DesktopStatusBar";
7
8
  import { type NavigationHistory, nextHistoryIndex, useNavigationHistoryKeyboard } from "./navigation-history";
9
+ import { ScreenshotCaptureNotificationHost } from "./ScreenshotCaptureNotification";
8
10
  import { fetchStatus, mockedStatus } from "./status-client";
9
11
 
10
12
  interface WebKitBridgeWindow extends Window {
@@ -214,6 +216,7 @@ export function App(): ReactElement {
214
216
  return (
215
217
  <main className={machineRailVisible ? "app-shell" : "app-shell machine-rail-collapsed"}>
216
218
  <div className="desktop-titlebar-tagline" aria-hidden="true">Your data protected. Your systems managed. Your creations published.</div>
219
+ <ScreenshotCaptureNotificationHost />
217
220
  {machineRailVisible ? <MachineRail machine={status.data.machine} /> : null}
218
221
  <Sidebar
219
222
  activeSurface={activeSurface}
@@ -423,29 +426,3 @@ function readStoredBoolean(storage: ReadableAppearanceStorage | undefined, key:
423
426
 
424
427
  return fallback;
425
428
  }
426
-
427
- function DesktopStatusBar({ status }: { status: GuiStatusData }): ReactElement {
428
- const version = status.update.installed_version !== "unknown" ? status.update.installed_version : status.aidevops_version;
429
- const versionLabel = version === "unknown" || version.startsWith("v") ? version : `v${version}`;
430
- const needsUpdate = status.setup_targets.filter((target) => target.needs_update).length + status.ai_apps.filter((app) => app.needs_update).length;
431
- const oauthAccounts = status.oauth_pool.providers.reduce((total, provider) => total + provider.total, 0);
432
- const localRepoCount = status.local_repos.total || status.local_repos.repos.length;
433
- const remoteRepoCount = status.repos.total || status.repos.repos.length;
434
- const statusLabel = status.update.restart_required ? "Restart required" : "Ready";
435
- const vaultLabel = status.vault?.unlocked ? "Vault unlocked" : `Vault ${status.vault?.status ?? "unknown"}`;
436
-
437
- return (
438
- <div className="desktop-status-bar" role="status">
439
- <span className={status.update.restart_required ? "status-dot warn" : "status-dot"} aria-hidden="true" />
440
- <strong>{statusLabel}</strong>
441
- <span>Read-only local GUI</span>
442
- <span>{versionLabel}</span>
443
- <span>{localRepoCount} local repos</span>
444
- <span>{remoteRepoCount} remote repos</span>
445
- <span>{status.secrets.length} secrets</span>
446
- <span>{vaultLabel}</span>
447
- <span>{oauthAccounts} provider accounts</span>
448
- <span>{needsUpdate} need update</span>
449
- </div>
450
- );
451
- }
@@ -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
  }
@@ -0,0 +1,28 @@
1
+ import type { GuiStatusData } from "@aidevops/gui-shared";
2
+ import type { ReactElement } from "react";
3
+
4
+ export function DesktopStatusBar({ status }: { status: GuiStatusData }): ReactElement {
5
+ const version = status.update.installed_version !== "unknown" ? status.update.installed_version : status.aidevops_version;
6
+ const versionLabel = version === "unknown" || version.startsWith("v") ? version : `v${version}`;
7
+ const needsUpdate = status.setup_targets.filter((target) => target.needs_update).length + status.ai_apps.filter((app) => app.needs_update).length;
8
+ const oauthAccounts = status.oauth_pool.providers.reduce((total, provider) => total + provider.total, 0);
9
+ const localRepoCount = status.local_repos.total || status.local_repos.repos.length;
10
+ const remoteRepoCount = status.repos.total || status.repos.repos.length;
11
+ const statusLabel = status.update.restart_required ? "Restart required" : "Ready";
12
+ const vaultLabel = status.vault?.unlocked ? "Vault unlocked" : `Vault ${status.vault?.status ?? "unknown"}`;
13
+
14
+ return (
15
+ <div className="desktop-status-bar" role="status">
16
+ <span className={status.update.restart_required ? "status-dot warn" : "status-dot"} aria-hidden="true" />
17
+ <strong>{statusLabel}</strong>
18
+ <span>Read-only local GUI</span>
19
+ <span>{versionLabel}</span>
20
+ <span>{localRepoCount} local repos</span>
21
+ <span>{remoteRepoCount} remote repos</span>
22
+ <span>{status.secrets.length} secrets</span>
23
+ <span>{vaultLabel}</span>
24
+ <span>{oauthAccounts} provider accounts</span>
25
+ <span>{needsUpdate} need update</span>
26
+ </div>
27
+ );
28
+ }
@@ -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 ?? "unknown"} ${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
  }
@@ -1,5 +1,6 @@
1
- import { type CSSProperties, type ReactElement, useState } from "react";
2
- import type { GuiPulseWorkerActivityEvent, GuiPulseWorkerChartSeries, GuiStatusData } from "../../gui-shared/src";
1
+ import { type CSSProperties, type Dispatch, type ReactElement, type SetStateAction, useEffect, useState } from "react";
2
+ import type { GuiPulseWorkerActionJobSummary, GuiPulseWorkerActionSummary, GuiPulseWorkerActivityEvent, GuiPulseWorkerChartSeries, GuiResponseEnvelope, GuiStatusData } from "../../gui-shared/src";
3
+ import { AppActionTerminal } from "./AppActionTerminal";
3
4
  import { text } from "./app-model";
4
5
 
5
6
  type PulseEvent = GuiPulseWorkerActivityEvent;
@@ -10,10 +11,32 @@ const quickFilters = ["Needs attention", "Stalled workers", "Failed terminal che
10
11
  export function PulseWorkersSurface({ status }: { status: GuiStatusData }): ReactElement {
11
12
  const pulse = status.pulse_workers;
12
13
  const [selectedEventId, setSelectedEventId] = useState(pulse.events[0]?.id ?? "");
14
+ const [selectedJobId, setSelectedJobId] = useState<string | null>(null);
15
+ const [jobs, setJobs] = useState<Record<string, GuiPulseWorkerActionJobSummary>>({});
16
+ const [dismissedJobIds, setDismissedJobIds] = useState<Set<string>>(() => new Set());
13
17
  const selectedEvent = pulse.events.find((event) => event.id === selectedEventId) ?? pulse.events[0];
18
+ const selectedJob = selectedJobId === null ? null : jobs[selectedJobId] ?? null;
14
19
  const filterGroups = buildFilterGroups(pulse);
15
20
  const chartSeries = pulse.charts.slice(0, 4);
16
21
  const sampleSize = pulse.kpis.reduce((total, kpi) => total + (kpi.sample_size ?? 0), 0);
22
+ const runningJobIdsKey = Object.values(jobs).filter((job) => job.status === "running").map((job) => job.id).sort().join("|");
23
+
24
+ useEffect(() => {
25
+ const runningJobIds = runningJobIdsKey.split("|").filter(Boolean);
26
+ if (runningJobIds.length === 0) {
27
+ return undefined;
28
+ }
29
+
30
+ const refreshRunningJobs = () => {
31
+ for (const jobId of runningJobIds) {
32
+ void refreshPulseWorkerJob(jobId, setJobs);
33
+ }
34
+ };
35
+ refreshRunningJobs();
36
+ const timer = window.setInterval(refreshRunningJobs, 1_500);
37
+
38
+ return () => window.clearInterval(timer);
39
+ }, [runningJobIdsKey]);
17
40
 
18
41
  return (
19
42
  <section className="pulse-workers-surface" aria-label={text.workers}>
@@ -49,12 +72,27 @@ export function PulseWorkersSurface({ status }: { status: GuiStatusData }): Reac
49
72
  <p className="eyebrow">Exceptions first</p>
50
73
  <h3>Needs attention</h3>
51
74
  </div>
52
- <span className="count-pill">{pulse.attention.length} findings · planned actions disabled</span>
75
+ <span className="count-pill">{pulse.attention.length} findings · allowlisted actions only</span>
53
76
  </div>
54
77
  <ul>
55
78
  {pulse.attention.map((item) => <li className={`pulse-attention-${item.severity}`} key={item.id}><strong>{item.title}</strong>: {item.detail}</li>)}
56
79
  </ul>
57
- <button disabled title="Action routes need audited worker control APIs" type="button">Create systemic fix (planned)</button>
80
+ <div className="pulse-insight-list" aria-label="Grouped systemic findings">
81
+ {pulse.insights.map((finding) => (
82
+ <article className={`pulse-insight-card pulse-attention-${finding.severity}`} key={finding.id}>
83
+ <p className="eyebrow">{humanize(finding.kind)} · {finding.confidence} confidence · n={finding.sample_size}</p>
84
+ <h4>{finding.title}</h4>
85
+ <p>{finding.detail}</p>
86
+ <dl className="pulse-detail-grid compact-detail-grid">
87
+ <div><dt>Likely cause</dt><dd>{finding.likely_cause}</dd></div>
88
+ <div><dt>Evidence</dt><dd>{finding.evidence_refs.join(" · ") || "metadata evidence pending"}</dd></div>
89
+ <div><dt>Comparison</dt><dd>{finding.period_label} · {finding.scope_label} · {finding.comparison_label}</dd></div>
90
+ <div><dt>Systemic fix</dt><dd>{finding.recommendation}</dd></div>
91
+ </dl>
92
+ </article>
93
+ ))}
94
+ </div>
95
+ <button disabled={!actionById(pulse.actions, "create_systemic_fix")?.enabled} onClick={() => void runPulseWorkerAction(actionById(pulse.actions, "create_systemic_fix"), setJobs, setSelectedJobId, setDismissedJobIds)} title="Create a worker-ready systemic-fix task from selected evidence" type="button">Create systemic fix task</button>
58
96
  </article>
59
97
 
60
98
  <section className="pulse-chart-grid" aria-label="Pulse trend charts">
@@ -120,23 +158,66 @@ export function PulseWorkersSurface({ status }: { status: GuiStatusData }): Reac
120
158
  </button>
121
159
  ))}
122
160
  </div>
123
- <p className="notice compact-notice">Mobile activity cards replace the dense table on small screens. Detail drawer becomes a full-screen sheet on small screens, and terminal panel becomes full-screen later.</p>
161
+ <p className="notice compact-notice">Mobile activity cards replace the dense table on small screens. Detail drawer becomes a full-screen sheet on small screens, and terminal panel becomes a full-screen panel/sheet for command output.</p>
124
162
  </section>
125
163
 
126
164
  <section className="pulse-layout" aria-label="Drilldown and planned actions">
127
165
  <PulseDrilldownPanel event={selectedEvent} />
128
- <article className="planned-card pulse-actions-panel">
129
- <p className="eyebrow">Planned controls</p>
130
- <h3>Actions stay disabled until audited routes land</h3>
131
- <button disabled title="Terminal output needs a read-only command-output adapter" type="button">Open terminal output (planned)</button>
132
- <button disabled title="Dispatch needs worker control and trust-boundary APIs" type="button">Redispatch worker (planned)</button>
133
- <button disabled title="Persistence needs a write-action manifest and audit trail" type="button">Save systemic fix (planned)</button>
166
+ <article className="planned-card pulse-actions-panel" aria-label="Pulse and Workers action buttons">
167
+ <p className="eyebrow">Allowlisted controls</p>
168
+ <h3>Safe actions with terminal output</h3>
169
+ <p className="notice compact-notice">Read-only diagnostics are separated from write actions. Destructive controls such as stopping workers, closing PRs, cleanup, and rebase/update branch remain deferred.</p>
170
+ <div className="pulse-action-list">
171
+ {pulse.actions.map((action) => (
172
+ <button disabled={!action.enabled} key={action.id} onClick={() => void runPulseWorkerAction(action, setJobs, setSelectedJobId, setDismissedJobIds)} title={action.scope_copy} type="button">
173
+ <strong>{action.label}</strong>
174
+ <span>{action.classification} · confirmation {action.confirmation} · {action.target_ref}</span>
175
+ </button>
176
+ ))}
177
+ </div>
178
+ <dl className="pulse-detail-grid pulse-action-confirmations">
179
+ {pulse.actions.map((action) => <div key={`${action.id}-copy`}><dt>{action.label}</dt><dd>{action.scope_copy} {action.expected_effect} Command preview: {action.command_preview}. Audit/source ref: {action.audit_ref}.</dd></div>)}
180
+ </dl>
181
+ <p className="notice compact-notice">Create systemic fix task generates worker-ready context from the selected event: files/patterns when known, evidence refs, verification commands, and protected-data notes before wrapper dispatch.</p>
182
+ {selectedJob === null || dismissedJobIds.has(selectedJob.id) ? null : <AppActionTerminal job={selectedJob} label={`Pulse & Workers ${selectedJob.action}`} onDismiss={(jobId) => setDismissedJobIds((current) => new Set([...current, jobId]))} />}
134
183
  </article>
135
184
  </section>
136
185
  </section>
137
186
  );
138
187
  }
139
188
 
189
+ async function refreshPulseWorkerJob(jobId: string, setJobs: Dispatch<SetStateAction<Record<string, GuiPulseWorkerActionJobSummary>>>): Promise<void> {
190
+ const response = await fetch(`/api/pulse-workers/jobs/${encodeURIComponent(jobId)}`);
191
+ if (!response.ok) {
192
+ return;
193
+ }
194
+ const envelope = await response.json() as GuiResponseEnvelope<GuiPulseWorkerActionJobSummary>;
195
+ setJobs((current) => ({ ...current, [envelope.data.id]: envelope.data }));
196
+ }
197
+
198
+ async function runPulseWorkerAction(action: GuiPulseWorkerActionSummary | undefined, setJobs: Dispatch<SetStateAction<Record<string, GuiPulseWorkerActionJobSummary>>>, setSelectedJobId: Dispatch<SetStateAction<string | null>>, setDismissedJobIds: Dispatch<SetStateAction<Set<string>>>): Promise<void> {
199
+ if (action === undefined || !action.enabled) {
200
+ return;
201
+ }
202
+ if (action.confirmation === "required" && typeof window !== "undefined" && !window.confirm(`${action.label}\n\nScope: ${action.scope_copy}\nExpected effect: ${action.expected_effect}\nCommand: ${action.command_preview}`)) {
203
+ return;
204
+ }
205
+
206
+ const response = await fetch(`/api/pulse-workers/actions/${encodeURIComponent(action.id)}`, { method: "POST" });
207
+ const envelope = await response.json() as GuiResponseEnvelope<GuiPulseWorkerActionJobSummary>;
208
+ setDismissedJobIds((current) => {
209
+ const next = new Set(current);
210
+ next.delete(envelope.data.id);
211
+ return next;
212
+ });
213
+ setJobs((current) => ({ ...current, [envelope.data.id]: envelope.data }));
214
+ setSelectedJobId(envelope.data.id);
215
+ }
216
+
217
+ function actionById(actions: GuiPulseWorkerActionSummary[], id: GuiPulseWorkerActionSummary["id"]): GuiPulseWorkerActionSummary | undefined {
218
+ return actions.find((action) => action.id === id);
219
+ }
220
+
140
221
  function PulseChartPanel({ chart }: { chart: GuiPulseWorkerChartSeries }): ReactElement {
141
222
  const maxValue = Math.max(...chart.points.map((point) => point.value), 1);
142
223
 
@@ -170,19 +251,27 @@ function PulseDrilldownPanel({ event }: { event: PulseEvent | undefined }): Reac
170
251
  <dl className="pulse-detail-grid">
171
252
  <div><dt>What</dt><dd>{event.summary}</dd></div>
172
253
  <div><dt>When</dt><dd>{event.occurred_at} · {durationLabel(event.duration_ms)}</dd></div>
173
- <div><dt>Why</dt><dd>{event.drilldown_sections.find((section) => section.label.toLowerCase().includes("failure"))?.body ?? event.drilldown_sections[0]?.body ?? "No failure analysis recorded."}</dd></div>
254
+ <div><dt>Why</dt><dd>{event.drilldown_sections?.find((section) => section.label.toLowerCase().includes("failure"))?.body ?? event.drilldown_sections?.[0]?.body ?? "No failure analysis recorded."}</dd></div>
174
255
  <div><dt>How</dt><dd>{[event.type, event.status, event.outcome].map((value) => humanize(value)).join(" · ")}</dd></div>
175
256
  <div><dt>Who</dt><dd>{event.actor_ref ?? "actor pending"} · {humanize(event.issue_origin, "-")} · {event.author_association}</dd></div>
176
257
  <div><dt>Issue / PR / session refs</dt><dd>{[event.repo_ref, event.issue_ref, event.pull_request_ref, event.worker_session_ref, event.command_job_ref, event.pulse_run_ref].filter(Boolean).join(" · ")}</dd></div>
177
258
  <div><dt>Usage and cost</dt><dd>{usageSummary(event)}</dd></div>
178
- <div><dt>Resources</dt><dd>{event.resources.map((resource) => `${resource.label}: ${resource.available_label} (${resource.pressure})`).join(" · ") || "Resource metadata pending"}</dd></div>
179
- <div><dt>Suggested systemic fix</dt><dd>{event.drilldown_sections.find((section) => section.label.toLowerCase().includes("fix"))?.body ?? "Create a worker-ready follow-up when repeated blindspots appear."}</dd></div>
259
+ <div><dt>Resources</dt><dd>{event.resources?.map((resource) => `${resource.label}: ${resource.available_label} (${resource.pressure})`).join(" · ") || "Resource metadata pending"}</dd></div>
260
+ <div><dt>Suggested systemic fix</dt><dd>{event.drilldown_sections?.find((section) => section.label.toLowerCase().includes("fix"))?.body ?? "Create a worker-ready follow-up when repeated blindspots appear."}</dd></div>
261
+ <div><dt>Related systemic findings</dt><dd>{relatedFindings(event).join(" · ") || "No grouped finding attached to this event."}</dd></div>
180
262
  <div><dt>Planned actions</dt><dd>Open terminal output, redispatch worker, save systemic fix, and attach timeline evidence remain disabled until Phase 5 action routes land.</dd></div>
181
263
  </dl>
182
264
  </article>
183
265
  );
184
266
  }
185
267
 
268
+ function relatedFindings(event: PulseEvent): string[] {
269
+ return event.drilldown_sections?.filter((section) => {
270
+ const label = section.label?.toLowerCase();
271
+ return label?.includes("systemic") || label?.includes("verification");
272
+ }).map((section) => `${section.label}: ${section.body}`) ?? [];
273
+ }
274
+
186
275
  function buildFilterGroups(pulse: GuiStatusData["pulse_workers"]): PulseFilterGroup[] {
187
276
  return [
188
277
  { label: "Repo", values: optionLabels(pulse.filters.repos) },
@@ -201,15 +290,15 @@ function buildFilterGroups(pulse: GuiStatusData["pulse_workers"]): PulseFilterGr
201
290
  ];
202
291
  }
203
292
 
204
- function optionLabels(options: Array<{ label: string; count: number }>): string[] {
205
- return options.map((option) => `${option.label} (${option.count})`);
293
+ function optionLabels(options: Array<{ label: string; count: number }> | null | undefined): string[] {
294
+ return options?.map((option) => `${option.label} (${option.count})`) ?? [];
206
295
  }
207
296
 
208
297
  function unique(values: string[]): string[] {
209
298
  return Array.from(new Set(values.filter(Boolean)));
210
299
  }
211
300
 
212
- function filterSummary(options: Array<{ label: string; count: number }>): string {
301
+ function filterSummary(options: Array<{ label: string; count: number }> | null | undefined): string {
213
302
  return optionLabels(options).slice(0, 3).join(" · ") || "all";
214
303
  }
215
304
 
@@ -220,7 +309,7 @@ function periodChoices(selected: string): string {
220
309
  function resourceSummary(row: PulseEvent): string {
221
310
  const model = row.usage?.model_ref?.replace("model:", "");
222
311
  const providerLabel = row.usage?.provider === "openai" ? "OpenAI" : row.usage?.provider === "anthropic" ? "Anthropic" : row.usage?.provider;
223
- const provider = row.usage === null ? row.resources[0]?.available_label ?? "metadata only" : `${providerLabel ?? "Provider"} · ${model ?? "model metadata pending"}`;
312
+ const provider = row.usage === null ? row.resources?.[0]?.available_label ?? "metadata only" : `${providerLabel ?? "Provider"} · ${model ?? "model metadata pending"}`;
224
313
  const tokens = row.usage === null ? "" : ` · ${row.usage.total_tokens.toLocaleString()} tokens`;
225
314
  const cost = row.usage?.estimated_cost_ref === null || row.usage?.estimated_cost_ref === undefined ? "" : ` · ${row.usage.estimated_cost_ref}`;
226
315
 
@@ -257,6 +346,10 @@ function durationLabel(durationMs: number | null): string {
257
346
  return `${Math.round(durationMs / 60000)}m`;
258
347
  }
259
348
 
260
- function humanize(value: string, replacement = " "): string {
349
+ function humanize(value: string | null | undefined, replacement = " "): string {
350
+ if (!value) {
351
+ return "";
352
+ }
353
+
261
354
  return value.replaceAll("_", replacement);
262
355
  }