aidevops 3.29.39 → 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.
- package/README.md +1 -1
- package/VERSION +1 -1
- package/aidevops.sh +1 -1
- package/package.json +1 -1
- package/packages/gui-api/src/app.ts +119 -4
- package/packages/gui-api/src/status-adapter.ts +5 -0
- package/packages/gui-api/src/status-pulse-workers-attention.ts +24 -0
- package/packages/gui-api/src/status-pulse-workers-classifiers.ts +99 -0
- package/packages/gui-api/src/status-pulse-workers-values.ts +59 -0
- package/packages/gui-api/src/status-pulse-workers.ts +335 -0
- package/packages/gui-shared/src/contracts.ts +76 -3
- package/packages/gui-shared/src/fixtures.ts +11 -0
- package/packages/gui-shared/src/tambo.ts +43 -13
- package/packages/gui-web/src/AppActionTerminal.tsx +12 -5
- package/packages/gui-web/src/AppWorkspace.tsx +2 -109
- package/packages/gui-web/src/GenUiCards.tsx +5 -4
- package/packages/gui-web/src/PulseWorkersSurface.tsx +352 -0
- package/packages/gui-web/src/app-model.ts +19 -1
- package/packages/gui-web/src/status-client.ts +2 -0
- package/packages/gui-web/src/styles.css +181 -13
- package/setup.sh +1 -1
|
@@ -10,6 +10,7 @@ import { CommsConversationSurface } from "./CommsConversationSurface";
|
|
|
10
10
|
import { FileExplorerSurface } from "./FileExplorerSurface";
|
|
11
11
|
import { TamboConversationPart } from "./GenUiCards";
|
|
12
12
|
import { AppsSurface, EditableInventorySurface, InstallationSurface } from "./InventorySurfaces";
|
|
13
|
+
import { PulseWorkersSurface } from "./PulseWorkersSurface";
|
|
13
14
|
import { AiProvidersSurface, LocalReposSurface, LockedVaultGate, OverviewSurface, PlannedSurface, ProjectsSurface, SecuritySurface, VaultSurface } from "./StatusSurfaces";
|
|
14
15
|
import { isVaultSurfaceLocked, vaultCollectionForSurface } from "./VaultBadges";
|
|
15
16
|
import { applyCommandPaletteSelection, useHeaderMenuState } from "./workspace-header-state";
|
|
@@ -292,7 +293,7 @@ function AiSessionsSurface({ selectedRepoIndex, selectedSessionId, status }: { s
|
|
|
292
293
|
<ToolStatusCard />
|
|
293
294
|
<TamboConversationPart
|
|
294
295
|
part={{ id: "ai-session-tambo-preview", message_id: "ai-session-preview", kind: "tambo_component", ordinal: 1, text: null, payload_json: { component: "RepoHealthCard", tenant_ref: "local", session_ref: selectedSession?.id_ref ?? "ai-session-preview", read_only: true, props: { repo: selectedRepo?.name ?? "local workspace", status: "ready for Tambo cards", open_prs: 0, failing_checks: 0, notes: ["Server-proxied provider metadata", "Read-only schemas first"] } }, file_ref: null, source_ref: "tambo:ai-session-preview" }}
|
|
295
|
-
scope={{ tenant_ref: "local", workspace_ref: "aidevops", repo_ref: selectedRepo?.
|
|
296
|
+
scope={{ tenant_ref: "local", workspace_ref: "aidevops", repo_ref: selectedRepo?.path_ref ?? null }}
|
|
296
297
|
/>
|
|
297
298
|
</div>
|
|
298
299
|
<form className="chat-composer ai-composer" aria-label="AI prompt composer" data-tour="ai-composer">
|
|
@@ -437,114 +438,6 @@ function SurfaceContent({ activeItem, activeSurface, fileRoot, openSurface, stat
|
|
|
437
438
|
return staticSurfaces[activeSurface] ?? null;
|
|
438
439
|
}
|
|
439
440
|
|
|
440
|
-
const pulseFilters = ["Repo", "Event type", "Outcome", "Resource", "Provider / model", "Issue origin", "Author", "Author association"] as const;
|
|
441
|
-
|
|
442
|
-
function PulseWorkersSurface({ status }: { status: GuiStatusData }): ReactElement {
|
|
443
|
-
const pulse = status.pulse_workers;
|
|
444
|
-
|
|
445
|
-
return (
|
|
446
|
-
<section className="pulse-workers-surface" aria-label={text.workers}>
|
|
447
|
-
<div className="planned-card pulse-hero">
|
|
448
|
-
<p className="eyebrow">Observability shell · fixture-backed · read-only</p>
|
|
449
|
-
<h2>{text.workers}</h2>
|
|
450
|
-
<p>{text.workersIntro}</p>
|
|
451
|
-
<section className="pulse-scope-strip" aria-label="Pulse scope and time range">
|
|
452
|
-
<span><strong>Period</strong> {pulse.period_label} · {pulse.scope_label}</span>
|
|
453
|
-
<span><strong>Comparison</strong> {pulse.comparison_label}</span>
|
|
454
|
-
<span><strong>Sample</strong> {pulse.events.length} canonical events · {pulse.kpis.reduce((total, kpi) => total + (kpi.sample_size ?? 0), 0)} samples</span>
|
|
455
|
-
<span><strong>Trust boundary</strong> Metadata/status only; protected payloads excluded</span>
|
|
456
|
-
</section>
|
|
457
|
-
</div>
|
|
458
|
-
<section className="pulse-kpi-grid" aria-label="Pulse health summary">
|
|
459
|
-
{pulse.kpis.map((kpi) => (
|
|
460
|
-
<article className="metric-card pulse-kpi-card" key={kpi.id}>
|
|
461
|
-
<span>{kpi.label} · {kpi.period_label} · {kpi.scope_label}</span>
|
|
462
|
-
<strong>{kpi.value}</strong>
|
|
463
|
-
<p>{kpi.detail} {kpi.comparison_label}</p>
|
|
464
|
-
</article>
|
|
465
|
-
))}
|
|
466
|
-
</section>
|
|
467
|
-
<section className="pulse-layout" aria-label="Pulse observability hierarchy">
|
|
468
|
-
<article className="planned-card pulse-attention-panel">
|
|
469
|
-
<div className="split-heading">
|
|
470
|
-
<div>
|
|
471
|
-
<p className="eyebrow">Exceptions first</p>
|
|
472
|
-
<h3>Needs attention</h3>
|
|
473
|
-
</div>
|
|
474
|
-
<span className="count-pill">planned actions disabled</span>
|
|
475
|
-
</div>
|
|
476
|
-
<ul>
|
|
477
|
-
{pulse.attention.map((item) => <li key={item.id}>{item.title}: {item.detail}</li>)}
|
|
478
|
-
</ul>
|
|
479
|
-
<button disabled title="Action routes need audited worker control APIs" type="button">Create systemic fix (planned)</button>
|
|
480
|
-
</article>
|
|
481
|
-
<article className="planned-card pulse-chart-panel">
|
|
482
|
-
<p className="eyebrow">Trends</p>
|
|
483
|
-
<h3>Health trend placeholder</h3>
|
|
484
|
-
<div className="pulse-chart-placeholder" aria-label="Chart placeholder showing health, queue, token, cost, API, and CI capacity trends" role="img">
|
|
485
|
-
<span>health</span><span>queue</span><span>tokens</span><span>cost</span><span>api</span><span>ci</span>
|
|
486
|
-
</div>
|
|
487
|
-
<p>Charts derive from {pulse.charts.map((chart) => chart.points[0]?.period).filter(Boolean).join("/")} fixture buckets across the canonical event stream: Pulse, workers, commands, CI, issues, PRs, reviews, and outcomes.</p>
|
|
488
|
-
</article>
|
|
489
|
-
</section>
|
|
490
|
-
<section className="planned-card pulse-activity-panel" aria-label="Unified activity stream">
|
|
491
|
-
<div className="split-heading">
|
|
492
|
-
<div>
|
|
493
|
-
<p className="eyebrow">Canonical stream</p>
|
|
494
|
-
<h3>Unified activity</h3>
|
|
495
|
-
</div>
|
|
496
|
-
<section className="pulse-filter-row" aria-label="Quick filters">
|
|
497
|
-
{pulseFilters.map((filter) => <button disabled key={filter} title={`${filter} filter is planned`} type="button">{filter}</button>)}
|
|
498
|
-
</section>
|
|
499
|
-
</div>
|
|
500
|
-
<table className="pulse-activity-table" aria-label="Pulse and worker events">
|
|
501
|
-
<thead>
|
|
502
|
-
<tr className="pulse-activity-row pulse-activity-header">
|
|
503
|
-
<th scope="col">When</th><th scope="col">Event</th><th scope="col">Scope</th><th scope="col">Outcome</th><th scope="col">Resource</th><th scope="col">Origin / actor</th>
|
|
504
|
-
</tr>
|
|
505
|
-
</thead>
|
|
506
|
-
<tbody>
|
|
507
|
-
{pulse.events.map((row) => (
|
|
508
|
-
<tr className="pulse-activity-row" key={row.id}>
|
|
509
|
-
<td data-label="When">{row.occurred_at.slice(11, 16)}</td>
|
|
510
|
-
<td data-label="Event">{row.title}</td>
|
|
511
|
-
<td data-label="Scope">{[row.repo_ref, row.issue_ref ?? row.pull_request_ref].filter(Boolean).join(" ")}</td>
|
|
512
|
-
<td data-label="Outcome">{row.outcome.replaceAll("_", " ")}</td>
|
|
513
|
-
<td data-label="Resource">{resourceSummary(row)}</td>
|
|
514
|
-
<td data-label="Origin / actor">{row.issue_origin.replaceAll("_", "-")} · {row.author_association}</td>
|
|
515
|
-
</tr>
|
|
516
|
-
))}
|
|
517
|
-
</tbody>
|
|
518
|
-
</table>
|
|
519
|
-
<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>
|
|
520
|
-
</section>
|
|
521
|
-
<section className="pulse-layout" aria-label="Drilldown and planned actions">
|
|
522
|
-
<article className="planned-card pulse-drilldown-panel">
|
|
523
|
-
<p className="eyebrow">Drilldown placeholder</p>
|
|
524
|
-
<h3>What happened, when, why, how, who acted, and what resources were available</h3>
|
|
525
|
-
<p>Selected event details will connect timeline evidence, issue/PR/review context, provider/model/tokens/time/cost metadata, GitHub/API allowance, queue/concurrency, CI capacity, and local resource snapshots without exposing secrets.</p>
|
|
526
|
-
</article>
|
|
527
|
-
<article className="planned-card pulse-actions-panel">
|
|
528
|
-
<p className="eyebrow">Planned controls</p>
|
|
529
|
-
<h3>Actions stay disabled until audited routes land</h3>
|
|
530
|
-
<button disabled title="Terminal output needs a read-only command-output adapter" type="button">Open terminal output (planned)</button>
|
|
531
|
-
<button disabled title="Dispatch needs worker control and trust-boundary APIs" type="button">Redispatch worker (planned)</button>
|
|
532
|
-
<button disabled title="Persistence needs a write-action manifest and audit trail" type="button">Save systemic fix (planned)</button>
|
|
533
|
-
</article>
|
|
534
|
-
</section>
|
|
535
|
-
</section>
|
|
536
|
-
);
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
function resourceSummary(row: GuiStatusData["pulse_workers"]["events"][number]): string {
|
|
540
|
-
const model = row.usage?.model_ref?.replace("model:", "");
|
|
541
|
-
const providerLabel = row.usage?.provider === "openai" ? "OpenAI" : row.usage?.provider === "anthropic" ? "Anthropic" : row.usage?.provider;
|
|
542
|
-
const provider = row.usage === null ? row.resources[0]?.available_label ?? "metadata only" : `${providerLabel ?? "Provider"} · ${model ?? "model metadata pending"}`;
|
|
543
|
-
const tokens = row.usage === null ? "" : ` · ${row.usage.total_tokens.toLocaleString()} tokens`;
|
|
544
|
-
|
|
545
|
-
return `${provider}${tokens}`;
|
|
546
|
-
}
|
|
547
|
-
|
|
548
441
|
function HelpSurface(): ReactElement {
|
|
549
442
|
const shortcuts = [
|
|
550
443
|
["#", "Channels"],
|
|
@@ -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>
|
|
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}
|
|
19
|
+
<section className="genui-card" data-genui-component={validation.component ?? "unknown"} aria-label={`${validation.component} ${text.devOpsCard}`}>
|
|
19
20
|
<header>
|
|
20
|
-
<p className="eyebrow">
|
|
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">
|
|
27
|
+
{validation.component === "ApprovalPromptCard" ? <p className="genui-deferred">{text.approvalExecutionDeferred}</p> : null}
|
|
27
28
|
</section>
|
|
28
29
|
);
|
|
29
30
|
}
|
|
@@ -0,0 +1,352 @@
|
|
|
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";
|
|
4
|
+
import { text } from "./app-model";
|
|
5
|
+
|
|
6
|
+
type PulseEvent = GuiPulseWorkerActivityEvent;
|
|
7
|
+
type PulseFilterGroup = { label: string; values: string[] };
|
|
8
|
+
|
|
9
|
+
const quickFilters = ["Needs attention", "Stalled workers", "Failed terminal checks", "Expensive runs", "Third-party issues", "No verification"];
|
|
10
|
+
|
|
11
|
+
export function PulseWorkersSurface({ status }: { status: GuiStatusData }): ReactElement {
|
|
12
|
+
const pulse = status.pulse_workers;
|
|
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());
|
|
17
|
+
const selectedEvent = pulse.events.find((event) => event.id === selectedEventId) ?? pulse.events[0];
|
|
18
|
+
const selectedJob = selectedJobId === null ? null : jobs[selectedJobId] ?? null;
|
|
19
|
+
const filterGroups = buildFilterGroups(pulse);
|
|
20
|
+
const chartSeries = pulse.charts.slice(0, 4);
|
|
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]);
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<section className="pulse-workers-surface" aria-label={text.workers}>
|
|
43
|
+
<div className="planned-card pulse-hero">
|
|
44
|
+
<p className="eyebrow">Data-driven observability · shared pulse_workers status · read-only</p>
|
|
45
|
+
<h2>{text.workers}</h2>
|
|
46
|
+
<p>{text.workersIntro}</p>
|
|
47
|
+
<section className="pulse-scope-strip" aria-label="Pulse scope and time range">
|
|
48
|
+
<span><strong>Period</strong> {pulse.period_label} · {periodChoices(pulse.selected_period)}</span>
|
|
49
|
+
<span><strong>Repo scope</strong> {pulse.scope_label}</span>
|
|
50
|
+
<span><strong>Issue origin</strong> {filterSummary(pulse.filters.issue_origins)}</span>
|
|
51
|
+
<span><strong>Provider/model scope</strong> {filterSummary(pulse.filters.providers)}</span>
|
|
52
|
+
<span><strong>Comparison</strong> {pulse.comparison_label}</span>
|
|
53
|
+
<span><strong>Sample</strong> {pulse.events.length} canonical events · {sampleSize} samples</span>
|
|
54
|
+
<span><strong>Trust boundary</strong> Metadata/status only; protected payloads excluded</span>
|
|
55
|
+
</section>
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
<section className="pulse-kpi-grid" aria-label="Pulse health summary">
|
|
59
|
+
{pulse.kpis.map((kpi) => (
|
|
60
|
+
<article className={`metric-card pulse-kpi-card pulse-status-${kpi.status}`} key={kpi.id}>
|
|
61
|
+
<span>{kpi.label} · {kpi.period_label} · {kpi.scope_label}</span>
|
|
62
|
+
<strong>{kpi.value}</strong>
|
|
63
|
+
<p>{kpi.detail} {kpi.comparison_label}{kpi.sample_size === undefined ? "" : ` · n=${kpi.sample_size}`}</p>
|
|
64
|
+
</article>
|
|
65
|
+
))}
|
|
66
|
+
</section>
|
|
67
|
+
|
|
68
|
+
<section className="pulse-layout pulse-overview-layout" aria-label="Pulse observability hierarchy">
|
|
69
|
+
<article className="planned-card pulse-attention-panel">
|
|
70
|
+
<div className="split-heading">
|
|
71
|
+
<div>
|
|
72
|
+
<p className="eyebrow">Exceptions first</p>
|
|
73
|
+
<h3>Needs attention</h3>
|
|
74
|
+
</div>
|
|
75
|
+
<span className="count-pill">{pulse.attention.length} findings · allowlisted actions only</span>
|
|
76
|
+
</div>
|
|
77
|
+
<ul>
|
|
78
|
+
{pulse.attention.map((item) => <li className={`pulse-attention-${item.severity}`} key={item.id}><strong>{item.title}</strong>: {item.detail}</li>)}
|
|
79
|
+
</ul>
|
|
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>
|
|
96
|
+
</article>
|
|
97
|
+
|
|
98
|
+
<section className="pulse-chart-grid" aria-label="Pulse trend charts">
|
|
99
|
+
{chartSeries.map((chart) => <PulseChartPanel chart={chart} key={chart.id} />)}
|
|
100
|
+
</section>
|
|
101
|
+
</section>
|
|
102
|
+
|
|
103
|
+
<section className="planned-card pulse-filter-panel" aria-label="Pulse filters">
|
|
104
|
+
<div className="split-heading">
|
|
105
|
+
<div>
|
|
106
|
+
<p className="eyebrow">Filter controls</p>
|
|
107
|
+
<h3>Scope the canonical event stream</h3>
|
|
108
|
+
</div>
|
|
109
|
+
<span className="count-pill">disabled preview controls</span>
|
|
110
|
+
</div>
|
|
111
|
+
<div className="pulse-filter-groups">
|
|
112
|
+
{filterGroups.map((group) => (
|
|
113
|
+
<fieldset className="pulse-filter-group" key={group.label}>
|
|
114
|
+
<legend>{group.label}</legend>
|
|
115
|
+
<div className="pulse-filter-row">
|
|
116
|
+
{group.values.map((value) => <button disabled key={`${group.label}-${value}`} title={`${group.label}: ${value} filter is planned`} type="button">{value}</button>)}
|
|
117
|
+
</div>
|
|
118
|
+
</fieldset>
|
|
119
|
+
))}
|
|
120
|
+
</div>
|
|
121
|
+
</section>
|
|
122
|
+
|
|
123
|
+
<section className="planned-card pulse-activity-panel" aria-label="Unified activity stream">
|
|
124
|
+
<div className="split-heading">
|
|
125
|
+
<div>
|
|
126
|
+
<p className="eyebrow">Canonical stream</p>
|
|
127
|
+
<h3>Unified activity</h3>
|
|
128
|
+
</div>
|
|
129
|
+
<section className="pulse-filter-row pulse-quick-filter-row" aria-label="Quick filters">
|
|
130
|
+
{quickFilters.map((filter) => <button disabled key={filter} title={`${filter} quick filter is planned`} type="button">{filter}</button>)}
|
|
131
|
+
</section>
|
|
132
|
+
</div>
|
|
133
|
+
<table className="pulse-activity-table" aria-label="Pulse and worker events">
|
|
134
|
+
<thead>
|
|
135
|
+
<tr className="pulse-activity-row pulse-activity-header">
|
|
136
|
+
<th scope="col">When</th><th scope="col">Event</th><th scope="col">Scope</th><th scope="col">Outcome</th><th scope="col">Resource</th><th scope="col">Origin / actor</th>
|
|
137
|
+
</tr>
|
|
138
|
+
</thead>
|
|
139
|
+
<tbody>
|
|
140
|
+
{pulse.events.map((row) => (
|
|
141
|
+
<tr className={row.id === selectedEvent?.id ? "pulse-activity-row selected" : "pulse-activity-row"} key={row.id}>
|
|
142
|
+
<td data-label="When"><button className="pulse-row-select" onClick={() => setSelectedEventId(row.id)} type="button">{row.occurred_at.slice(11, 16)}</button></td>
|
|
143
|
+
<td data-label="Event"><strong>{row.title}</strong><span>{row.summary}</span></td>
|
|
144
|
+
<td data-label="Scope">{[row.repo_ref, row.issue_ref ?? row.pull_request_ref, row.worker_session_ref].filter(Boolean).join(" · ")}</td>
|
|
145
|
+
<td data-label="Outcome">{humanize(row.outcome)} · {humanize(row.status)}</td>
|
|
146
|
+
<td data-label="Resource">{resourceSummary(row)}</td>
|
|
147
|
+
<td data-label="Origin / actor">{humanize(row.issue_origin, "-")} · {row.author_association} · {row.actor_ref ?? "actor pending"}</td>
|
|
148
|
+
</tr>
|
|
149
|
+
))}
|
|
150
|
+
</tbody>
|
|
151
|
+
</table>
|
|
152
|
+
<div className="pulse-card-stream" aria-label="Mobile activity cards">
|
|
153
|
+
{pulse.events.map((row) => (
|
|
154
|
+
<button className={row.id === selectedEvent?.id ? "pulse-event-card selected" : "pulse-event-card"} key={row.id} onClick={() => setSelectedEventId(row.id)} type="button">
|
|
155
|
+
<span>{row.occurred_at.slice(11, 16)} · {humanize(row.severity)}</span>
|
|
156
|
+
<strong>{row.title}</strong>
|
|
157
|
+
<small>{humanize(row.outcome)} · {resourceSummary(row)}</small>
|
|
158
|
+
</button>
|
|
159
|
+
))}
|
|
160
|
+
</div>
|
|
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>
|
|
162
|
+
</section>
|
|
163
|
+
|
|
164
|
+
<section className="pulse-layout" aria-label="Drilldown and planned actions">
|
|
165
|
+
<PulseDrilldownPanel event={selectedEvent} />
|
|
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]))} />}
|
|
183
|
+
</article>
|
|
184
|
+
</section>
|
|
185
|
+
</section>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
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
|
+
|
|
221
|
+
function PulseChartPanel({ chart }: { chart: GuiPulseWorkerChartSeries }): ReactElement {
|
|
222
|
+
const maxValue = Math.max(...chart.points.map((point) => point.value), 1);
|
|
223
|
+
|
|
224
|
+
return (
|
|
225
|
+
<article className="planned-card pulse-chart-panel">
|
|
226
|
+
<p className="eyebrow">Trends · day/week/month/year</p>
|
|
227
|
+
<h3>{chart.label}</h3>
|
|
228
|
+
<div className="pulse-chart-placeholder" aria-label={`${chart.label} chart for day week month year buckets`} role="img">
|
|
229
|
+
{chart.points.map((point) => <span key={`${chart.id}-${point.period}`} style={{ "--bar-height": `${Math.max(18, Math.round((point.value / maxValue) * 100))}%` } as CSSProperties}>{point.period}</span>)}
|
|
230
|
+
</div>
|
|
231
|
+
<p>{chart.unit} · {chart.points.map((point) => `${point.period_label}: ${point.value}`).join(" · ")}</p>
|
|
232
|
+
</article>
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function PulseDrilldownPanel({ event }: { event: PulseEvent | undefined }): ReactElement {
|
|
237
|
+
if (event === undefined) {
|
|
238
|
+
return (
|
|
239
|
+
<article className="planned-card pulse-drilldown-panel pulse-detail-drawer" aria-label="Pulse event detail drawer">
|
|
240
|
+
<p className="eyebrow">Drilldown drawer</p>
|
|
241
|
+
<h3>No activity selected</h3>
|
|
242
|
+
<p>Selected event details will connect timeline evidence, issue/PR/review context, usage, resources, failure analysis, suggested systemic fixes, and planned actions without exposing secrets.</p>
|
|
243
|
+
</article>
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return (
|
|
248
|
+
<article className="planned-card pulse-drilldown-panel pulse-detail-drawer" aria-label="Pulse event detail drawer">
|
|
249
|
+
<p className="eyebrow">Drilldown drawer · selected row</p>
|
|
250
|
+
<h3>{event.title}</h3>
|
|
251
|
+
<dl className="pulse-detail-grid">
|
|
252
|
+
<div><dt>What</dt><dd>{event.summary}</dd></div>
|
|
253
|
+
<div><dt>When</dt><dd>{event.occurred_at} · {durationLabel(event.duration_ms)}</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>
|
|
255
|
+
<div><dt>How</dt><dd>{[event.type, event.status, event.outcome].map((value) => humanize(value)).join(" · ")}</dd></div>
|
|
256
|
+
<div><dt>Who</dt><dd>{event.actor_ref ?? "actor pending"} · {humanize(event.issue_origin, "-")} · {event.author_association}</dd></div>
|
|
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>
|
|
258
|
+
<div><dt>Usage and cost</dt><dd>{usageSummary(event)}</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>
|
|
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>
|
|
263
|
+
</dl>
|
|
264
|
+
</article>
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function relatedFindings(event: PulseEvent): string[] {
|
|
269
|
+
return event.drilldown_sections?.filter((section) => section.label.toLowerCase().includes("systemic") || section.label.toLowerCase().includes("verification")).map((section) => `${section.label}: ${section.body}`) ?? [];
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function buildFilterGroups(pulse: GuiStatusData["pulse_workers"]): PulseFilterGroup[] {
|
|
273
|
+
return [
|
|
274
|
+
{ label: "Repo", values: optionLabels(pulse.filters.repos) },
|
|
275
|
+
{ label: "Event type", values: optionLabels(pulse.filters.event_types) },
|
|
276
|
+
{ label: "Outcome", values: optionLabels(pulse.filters.outcomes) },
|
|
277
|
+
{ label: "Status", values: unique(pulse.events.map((event) => humanize(event.status))) },
|
|
278
|
+
{ label: "Severity", values: unique(pulse.events.map((event) => humanize(event.severity))) },
|
|
279
|
+
{ label: "Resource", values: optionLabels(pulse.filters.resources) },
|
|
280
|
+
{ label: "Provider / model", values: optionLabels(pulse.filters.providers) },
|
|
281
|
+
{ label: "Model", values: unique(pulse.events.map((event) => event.usage?.model_ref?.replace("model:", "") ?? "model pending")) },
|
|
282
|
+
{ label: "Issue origin", values: optionLabels(pulse.filters.issue_origins) },
|
|
283
|
+
{ label: "Author", values: optionLabels(pulse.filters.authors) },
|
|
284
|
+
{ label: "Author association", values: optionLabels(pulse.filters.author_associations) },
|
|
285
|
+
{ label: "Cost range", values: unique(pulse.events.map((event) => event.usage?.estimated_cost_ref ?? "no cost metadata")) },
|
|
286
|
+
{ label: "Duration range", values: unique(pulse.events.map((event) => durationBucket(event.duration_ms))) },
|
|
287
|
+
];
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function optionLabels(options: Array<{ label: string; count: number }> | null | undefined): string[] {
|
|
291
|
+
return options?.map((option) => `${option.label} (${option.count})`) ?? [];
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function unique(values: string[]): string[] {
|
|
295
|
+
return Array.from(new Set(values.filter(Boolean)));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function filterSummary(options: Array<{ label: string; count: number }> | null | undefined): string {
|
|
299
|
+
return optionLabels(options).slice(0, 3).join(" · ") || "all";
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function periodChoices(selected: string): string {
|
|
303
|
+
return ["day", "week", "month", "year"].map((period) => period === selected ? `${period} selected` : period).join(" / ");
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function resourceSummary(row: PulseEvent): string {
|
|
307
|
+
const model = row.usage?.model_ref?.replace("model:", "");
|
|
308
|
+
const providerLabel = row.usage?.provider === "openai" ? "OpenAI" : row.usage?.provider === "anthropic" ? "Anthropic" : row.usage?.provider;
|
|
309
|
+
const provider = row.usage === null ? row.resources?.[0]?.available_label ?? "metadata only" : `${providerLabel ?? "Provider"} · ${model ?? "model metadata pending"}`;
|
|
310
|
+
const tokens = row.usage === null ? "" : ` · ${row.usage.total_tokens.toLocaleString()} tokens`;
|
|
311
|
+
const cost = row.usage?.estimated_cost_ref === null || row.usage?.estimated_cost_ref === undefined ? "" : ` · ${row.usage.estimated_cost_ref}`;
|
|
312
|
+
|
|
313
|
+
return `${provider}${tokens}${cost}`;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function usageSummary(event: PulseEvent): string {
|
|
317
|
+
if (event.usage === null) {
|
|
318
|
+
return "Usage metadata pending or not applicable.";
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return `${event.usage.provider} · ${event.usage.model_ref ?? "model pending"} · ${event.usage.total_tokens.toLocaleString()} tokens · ${event.usage.estimated_cost_ref ?? "cost pending"} · ${durationLabel(event.usage.wall_time_ms)}`;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function durationBucket(durationMs: number | null): string {
|
|
325
|
+
if (durationMs === null) {
|
|
326
|
+
return "duration pending";
|
|
327
|
+
}
|
|
328
|
+
if (durationMs < 300000) {
|
|
329
|
+
return "under 5m";
|
|
330
|
+
}
|
|
331
|
+
if (durationMs < 1800000) {
|
|
332
|
+
return "5m-30m";
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return "30m+";
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function durationLabel(durationMs: number | null): string {
|
|
339
|
+
if (durationMs === null) {
|
|
340
|
+
return "duration pending";
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return `${Math.round(durationMs / 60000)}m`;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function humanize(value: string | null | undefined, replacement = " "): string {
|
|
347
|
+
if (!value) {
|
|
348
|
+
return "";
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return value.replaceAll("_", replacement);
|
|
352
|
+
}
|
|
@@ -148,6 +148,13 @@ export interface InventorySurfaceConfig {
|
|
|
148
148
|
title: string;
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
export interface ChatPrimitiveStackDecision {
|
|
152
|
+
adopt: string[];
|
|
153
|
+
defer: string[];
|
|
154
|
+
foundation: string;
|
|
155
|
+
owner: "local_vite_shell";
|
|
156
|
+
}
|
|
157
|
+
|
|
151
158
|
export type SurfaceRecordCounts = Partial<Record<SurfaceId, number>>;
|
|
152
159
|
|
|
153
160
|
export const text = {
|
|
@@ -278,6 +285,8 @@ export const text = {
|
|
|
278
285
|
workersIntro: "Pulse & Workers brings worker sessions, event streams, outcomes, resources, and systemic fixes into one observability console so attention goes to exceptions, bottlenecks, and reusable improvements instead of raw noise.",
|
|
279
286
|
theme: "Theme",
|
|
280
287
|
appearance: "Appearance",
|
|
288
|
+
approvalExecutionDeferred: "Approval execution is deferred until audited approval tooling exists.",
|
|
289
|
+
devOpsCard: "DevOps card",
|
|
281
290
|
hue: "Hue",
|
|
282
291
|
reset: "Reset",
|
|
283
292
|
showBorders: "Show borders",
|
|
@@ -286,6 +295,8 @@ export const text = {
|
|
|
286
295
|
fontSize: "Font size",
|
|
287
296
|
truncated: "Preview truncated for safety.",
|
|
288
297
|
update: "Update",
|
|
298
|
+
tamboGenUiReadOnly: "Tambo GenUI · read-only",
|
|
299
|
+
unsupportedDevOpsCard: "Unsupported DevOps card",
|
|
289
300
|
vault: "Vault",
|
|
290
301
|
vaultAudit: "Audit Logs",
|
|
291
302
|
vaultBackups: "Backups & Recovery",
|
|
@@ -315,6 +326,13 @@ export const DEFAULT_CONTRAST: ContrastPreference = "low";
|
|
|
315
326
|
export const DEFAULT_FONT: FontPreference = "Inter";
|
|
316
327
|
export const DEFAULT_FONT_SIZE: FontSizePreference = "xs";
|
|
317
328
|
|
|
329
|
+
export const chatPrimitiveStackDecision: ChatPrimitiveStackDecision = {
|
|
330
|
+
owner: "local_vite_shell",
|
|
331
|
+
foundation: "Turbostarter AI patterns supply persistence, AI SDK transport, model selection, attachments, tool/reasoning panels, sharing, and session actions behind audited routes.",
|
|
332
|
+
adopt: ["MessageScroller", "Message", "Bubble", "Attachment", "Marker"],
|
|
333
|
+
defer: ["Tambo ordinary chat persistence", "Tambo channel transport", "React Joyride as chat infrastructure", "Next.js route ownership"],
|
|
334
|
+
};
|
|
335
|
+
|
|
318
336
|
export const contrastOptions: ContrastOption[] = [
|
|
319
337
|
{ value: "low", label: "Low" },
|
|
320
338
|
{ value: "medium", label: "Medium" },
|
|
@@ -368,7 +386,7 @@ export const navGroups: SurfaceNavGroup[] = [
|
|
|
368
386
|
mode: "devops",
|
|
369
387
|
items: [
|
|
370
388
|
plannedNavItem("aiSessions", text.aiSessions, "AI chat and OpenCode sessions", "terminal"),
|
|
371
|
-
|
|
389
|
+
{ id: "workers", label: text.workers, description: "Pulse, worker sessions, outcomes, and resources", icon: "activity" },
|
|
372
390
|
{ id: "git", label: text.localRepos, description: "~/Git explorer", icon: "folder" },
|
|
373
391
|
plannedNavItem("repos", text.repos, "Unified local and remote repo context", "git"),
|
|
374
392
|
{ id: "projects", label: text.projects, description: "repos.json summary", icon: "git" },
|
|
@@ -104,9 +104,11 @@ function normalizeStatusEnvelope(envelope: GuiResponseEnvelope<Partial<GuiStatus
|
|
|
104
104
|
...data.pulse_workers,
|
|
105
105
|
kpis: data.pulse_workers?.kpis ?? statusFixture.pulse_workers.kpis,
|
|
106
106
|
attention: data.pulse_workers?.attention ?? statusFixture.pulse_workers.attention,
|
|
107
|
+
insights: data.pulse_workers?.insights ?? statusFixture.pulse_workers.insights,
|
|
107
108
|
filters: { ...statusFixture.pulse_workers.filters, ...data.pulse_workers?.filters },
|
|
108
109
|
charts: data.pulse_workers?.charts ?? statusFixture.pulse_workers.charts,
|
|
109
110
|
events: data.pulse_workers?.events ?? statusFixture.pulse_workers.events,
|
|
111
|
+
actions: data.pulse_workers?.actions ?? statusFixture.pulse_workers.actions,
|
|
110
112
|
},
|
|
111
113
|
capabilities: data.capabilities ?? statusFixture.capabilities,
|
|
112
114
|
secrets: data.secrets ?? statusFixture.secrets,
|