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
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
type GuiPulseResourceKind,
|
|
5
|
+
type GuiPulseResourceSnapshot,
|
|
6
|
+
type GuiPulseSystemicFinding,
|
|
7
|
+
type GuiPulseWorkerActionSummary,
|
|
8
|
+
type GuiPulseWorkerActivityEvent,
|
|
9
|
+
type GuiPulseWorkerChartSeries,
|
|
10
|
+
type GuiPulseWorkerKpiCard,
|
|
11
|
+
type GuiPulseWorkerSummary,
|
|
12
|
+
type GuiPulseWorkerUsageSnapshot,
|
|
13
|
+
type GuiPulsePeriodBucket,
|
|
14
|
+
pulseWorkersFixture,
|
|
15
|
+
} from "../../gui-shared/src";
|
|
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";
|
|
20
|
+
|
|
21
|
+
export interface PulseWorkersAdapterOptions {
|
|
22
|
+
metricsPath?: string;
|
|
23
|
+
pulseStatsPath?: string;
|
|
24
|
+
resourceMetricsPath?: string;
|
|
25
|
+
tokenReportsRoot?: string;
|
|
26
|
+
oauthPoolPath?: string;
|
|
27
|
+
observedAt?: string;
|
|
28
|
+
nowMs?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface SourceState {
|
|
32
|
+
path_ref: string;
|
|
33
|
+
health: "present" | "missing" | "invalid";
|
|
34
|
+
observed_at: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const DAY_MS = 86_400_000;
|
|
38
|
+
const WEEK_MS = 7 * DAY_MS;
|
|
39
|
+
const DEFAULT_SCOPE = "local aidevops telemetry";
|
|
40
|
+
const DEFAULT_METRICS_PATH_REF = "~/.aidevops/logs/headless-runtime-metrics.jsonl";
|
|
41
|
+
const DEFAULT_PULSE_STATS_PATH_REF = "~/.aidevops/logs/pulse-stats.json";
|
|
42
|
+
const DEFAULT_RESOURCE_METRICS_PATH_REF = "~/.aidevops/logs/resource-metrics.jsonl";
|
|
43
|
+
const DEFAULT_TOKEN_REPORTS_ROOT_REF = "~/.aidevops/_reports/token-use";
|
|
44
|
+
const PULSE_WORKER_ACTIONS: GuiPulseWorkerActionSummary[] = pulseWorkersFixture.actions;
|
|
45
|
+
|
|
46
|
+
export function readPulseWorkersSummary(options: PulseWorkersAdapterOptions = {}): { summary: GuiPulseWorkerSummary; source_path_refs: string[] } {
|
|
47
|
+
const nowMs = options.nowMs ?? Date.parse(options.observedAt ?? new Date().toISOString());
|
|
48
|
+
const observedAt = new Date(Number.isFinite(nowMs) ? nowMs : Date.now()).toISOString();
|
|
49
|
+
const metricsPath = options.metricsPath ?? expandHome(DEFAULT_METRICS_PATH_REF);
|
|
50
|
+
const pulseStatsPath = options.pulseStatsPath ?? expandHome(DEFAULT_PULSE_STATS_PATH_REF);
|
|
51
|
+
const resourceMetricsPath = options.resourceMetricsPath ?? expandHome(DEFAULT_RESOURCE_METRICS_PATH_REF);
|
|
52
|
+
const tokenReportsRoot = options.tokenReportsRoot ?? expandHome(DEFAULT_TOKEN_REPORTS_ROOT_REF);
|
|
53
|
+
const oauthPoolPath = options.oauthPoolPath ?? expandHome("~/.aidevops/oauth-pool.json");
|
|
54
|
+
|
|
55
|
+
const metrics = readJsonLines(metricsPath);
|
|
56
|
+
const resources = readJsonLines(resourceMetricsPath);
|
|
57
|
+
const tokenReports = readTokenReports(tokenReportsRoot);
|
|
58
|
+
const pulseStats = readJsonObject(pulseStatsPath);
|
|
59
|
+
const oauthPool = readJsonObject(oauthPoolPath);
|
|
60
|
+
const sourceStates: SourceState[] = [
|
|
61
|
+
stateFor(metricsPath, metrics.health, observedAt),
|
|
62
|
+
stateFor(pulseStatsPath, pulseStats.health, observedAt),
|
|
63
|
+
stateFor(resourceMetricsPath, resources.health, observedAt),
|
|
64
|
+
stateFor(tokenReportsRoot, tokenReports.health, observedAt),
|
|
65
|
+
stateFor(oauthPoolPath, oauthPool.health, observedAt),
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
const dayMetrics = filterWindow(metrics.records, nowMs, DAY_MS);
|
|
69
|
+
const previousDayMetrics = filterWindowBetween(metrics.records, nowMs - (2 * DAY_MS), nowMs - DAY_MS);
|
|
70
|
+
const weekMetrics = filterWindow(metrics.records, nowMs, WEEK_MS);
|
|
71
|
+
const pulseCounters = extractPulseCounters(pulseStats.value, nowMs);
|
|
72
|
+
const resourceSnapshots = resourceMetricsToSnapshots(resources.records, observedAt);
|
|
73
|
+
const usageSamples = collectUsageSamples([...dayMetrics, ...tokenReports.records]);
|
|
74
|
+
const events = buildEvents(dayMetrics, resourceSnapshots, usageSamples, observedAt);
|
|
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);
|
|
77
|
+
|
|
78
|
+
const summary: GuiPulseWorkerSummary = {
|
|
79
|
+
value_policy: "metadata_only_no_prompt_payloads_no_secrets",
|
|
80
|
+
selected_period: "day",
|
|
81
|
+
period_label: "Last 24h",
|
|
82
|
+
scope_label: DEFAULT_SCOPE,
|
|
83
|
+
comparison_label: "Prior local telemetry window",
|
|
84
|
+
updated_at: observedAt,
|
|
85
|
+
kpis: buildKpis(dayMetrics, weekMetrics, pulseCounters, usageSamples, resourceSnapshots),
|
|
86
|
+
attention: warnings,
|
|
87
|
+
insights,
|
|
88
|
+
filters: buildFilters(events),
|
|
89
|
+
charts: buildCharts(metrics.records, pulseCounters, nowMs),
|
|
90
|
+
events,
|
|
91
|
+
actions: PULSE_WORKER_ACTIONS,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
return { summary, source_path_refs: sourceStates.map((source) => source.path_ref) };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function readJsonLines(pathName: string): { health: "present" | "missing" | "invalid"; records: MetricRecord[] } {
|
|
98
|
+
if (!existsSync(pathName)) {
|
|
99
|
+
return { health: "missing", records: [] };
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
const records = readFileSync(pathName, "utf8")
|
|
103
|
+
.split(/\r?\n/)
|
|
104
|
+
.map((line) => line.trim())
|
|
105
|
+
.filter(Boolean)
|
|
106
|
+
.map((line) => JSON.parse(line))
|
|
107
|
+
.filter(isRecord);
|
|
108
|
+
return { health: "present", records };
|
|
109
|
+
} catch {
|
|
110
|
+
return { health: "invalid", records: [] };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function readTokenReports(root: string): { health: "present" | "missing" | "invalid"; records: MetricRecord[] } {
|
|
115
|
+
if (!existsSync(root)) {
|
|
116
|
+
return { health: "missing", records: [] };
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
const records = readdirSync(root, { withFileTypes: true })
|
|
120
|
+
.filter((entry) => entry.isDirectory())
|
|
121
|
+
.map((entry) => join(root, entry.name, "report.json"))
|
|
122
|
+
.filter((pathName) => existsSync(pathName))
|
|
123
|
+
.slice(-25)
|
|
124
|
+
.map((pathName) => JSON.parse(readFileSync(pathName, "utf8")))
|
|
125
|
+
.filter(isRecord);
|
|
126
|
+
return { health: "present", records };
|
|
127
|
+
} catch {
|
|
128
|
+
return { health: "invalid", records: [] };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function stateFor(pathName: string, health: SourceState["health"], observedAt: string): SourceState {
|
|
133
|
+
return { path_ref: collapseHome(pathName), health, observed_at: observedAt };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function filterWindow(records: MetricRecord[], nowMs: number, windowMs: number): MetricRecord[] {
|
|
137
|
+
const cutoff = nowMs - windowMs;
|
|
138
|
+
return records.filter((record) => {
|
|
139
|
+
const time = recordTimeMs(record);
|
|
140
|
+
return time !== null && time >= cutoff && time <= nowMs;
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
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
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function extractPulseCounters(value: Record<string, unknown>, nowMs: number): Record<string, number> {
|
|
152
|
+
const counters = isRecord(value.counters) ? value.counters : {};
|
|
153
|
+
return Object.fromEntries(Object.entries(counters).map(([name, entries]) => [name, countRecentTimestamps(entries, nowMs, DAY_MS)]));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function countRecentTimestamps(value: unknown, nowMs: number, windowMs: number): number {
|
|
157
|
+
if (!Array.isArray(value)) {
|
|
158
|
+
return 0;
|
|
159
|
+
}
|
|
160
|
+
const cutoff = nowMs - windowMs;
|
|
161
|
+
return value.filter((entry) => {
|
|
162
|
+
const ms = typeof entry === "number" ? (entry > 9_999_999_999 ? entry : entry * 1000) : Date.parse(String(entry));
|
|
163
|
+
return Number.isFinite(ms) && ms >= cutoff && ms <= nowMs;
|
|
164
|
+
}).length;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function buildKpis(dayMetrics: MetricRecord[], weekMetrics: MetricRecord[], counters: Record<string, number>, usage: GuiPulseWorkerUsageSnapshot[], resources: GuiPulseResourceSnapshot[]): GuiPulseWorkerKpiCard[] {
|
|
168
|
+
const total = dayMetrics.length;
|
|
169
|
+
const healthy = dayMetrics.filter((record) => isHealthyOutcome(outcomeFromRecord(record))).length;
|
|
170
|
+
const rate = total === 0 ? 0 : Math.round((healthy / total) * 100);
|
|
171
|
+
const totalTokens = usage.reduce((sum, item) => sum + item.total_tokens, 0);
|
|
172
|
+
const costRefs = usage.map((item) => item.estimated_cost_ref).filter((item): item is string => item !== null);
|
|
173
|
+
const attentionCount = Object.values(counters).reduce((sum, count) => sum + count, 0) + resources.filter((resource) => resource.pressure !== "low" && resource.pressure !== "unknown").length;
|
|
174
|
+
|
|
175
|
+
return [
|
|
176
|
+
{ id: "worker-outcomes-24h", label: "Healthy worker outcomes", value: total === 0 ? "unknown" : `${rate}%`, period_label: "Last 24h", scope_label: DEFAULT_SCOPE, comparison_label: "computed from local worker metrics", sample_size: total, status: total === 0 ? "attention" : rate >= 80 ? "healthy" : "attention", detail: total === 0 ? "No local worker outcome records were available for the last 24h." : `${healthy} of ${total} local worker sessions ended merged, closed, completed, or deferred.` },
|
|
177
|
+
{ id: "attention-signals", label: "Attention signals", value: String(attentionCount), period_label: "Last 24h", scope_label: DEFAULT_SCOPE, comparison_label: "pulse counters plus resource pressure", sample_size: Object.keys(counters).length + resources.length, status: attentionCount > 0 ? "attention" : "healthy", detail: "Counts canonical Pulse counters and observed resource pressure without treating missing telemetry as failure." },
|
|
178
|
+
{ id: "token-cost", label: "Token and cost sample", value: totalTokens > 0 ? `${totalTokens.toLocaleString()} tokens` : "unknown", period_label: "Last 24h", scope_label: "provider/model metadata", comparison_label: costRefs.length > 0 ? "cost refs observed" : "cost unavailable", sample_size: usage.length, status: usage.length > 0 ? "healthy" : "attention", detail: "Token usage is derived from metadata fields only; prompts, command output, credential paths, and message payloads are excluded." },
|
|
179
|
+
{ id: "systemic-fixes", label: "Systemic fixes observed", value: String(weekMetrics.filter((record) => outcomeFromRecord(record) === "created_followup").length), period_label: "Trailing 7d", scope_label: DEFAULT_SCOPE, comparison_label: "worker outcome metadata", sample_size: weekMetrics.length, status: "completed", detail: "Follow-up/systemic-fix outcomes are counted only when canonical local metrics record them." },
|
|
180
|
+
];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function buildEvents(records: MetricRecord[], resources: GuiPulseResourceSnapshot[], usage: GuiPulseWorkerUsageSnapshot[], observedAt: string): GuiPulseWorkerActivityEvent[] {
|
|
184
|
+
return records.slice(-25).sort((left, right) => (recordTimeMs(right) ?? 0) - (recordTimeMs(left) ?? 0)).map((record, index) => {
|
|
185
|
+
const outcome = outcomeFromRecord(record);
|
|
186
|
+
const status = statusFromOutcome(outcome);
|
|
187
|
+
const issue = stringOrNumber(record.issue ?? record.issue_number);
|
|
188
|
+
const pr = stringOrNumber(record.pr ?? record.pr_number ?? record.pull_request);
|
|
189
|
+
const repo = stringField(record, "repo") ?? null;
|
|
190
|
+
return {
|
|
191
|
+
id: `event:worker:${issue ?? index}:${recordTimeMs(record) ?? index}`,
|
|
192
|
+
type: "worker_session",
|
|
193
|
+
status,
|
|
194
|
+
outcome,
|
|
195
|
+
severity: severityFromStatus(status),
|
|
196
|
+
occurred_at: recordTimeMs(record) === null ? observedAt : new Date(recordTimeMs(record) as number).toISOString(),
|
|
197
|
+
title: "Worker session",
|
|
198
|
+
summary: summaryFromRecord(record, outcome),
|
|
199
|
+
pulse_run_ref: stringField(record, "pulse_run_ref") ?? null,
|
|
200
|
+
worker_session_ref: stringField(record, "session_key") ?? stringField(record, "worker_session_ref") ?? null,
|
|
201
|
+
issue_ref: issue === null ? null : `#${issue.replace(/^#/, "")}`,
|
|
202
|
+
pull_request_ref: pr === null ? null : `#${pr.replace(/^#/, "")}`,
|
|
203
|
+
command_job_ref: stringField(record, "command_job_ref") ?? null,
|
|
204
|
+
repo_ref: repo,
|
|
205
|
+
actor_ref: stringField(record, "actor") ?? "worker:auto-dispatch",
|
|
206
|
+
issue_origin: issueOriginFromRecord(record),
|
|
207
|
+
author_association: authorAssociationFromRecord(record),
|
|
208
|
+
duration_ms: durationMsFromRecord(record),
|
|
209
|
+
usage: usageFromRecord(record) ?? usage[index] ?? null,
|
|
210
|
+
resources,
|
|
211
|
+
drilldown_sections: [
|
|
212
|
+
{ label: "Evidence", body: "Derived from local worker outcome/resource metadata; prompt text and command payloads are not exposed." },
|
|
213
|
+
{ label: "Outcome", body: outcome },
|
|
214
|
+
{ label: "Verification", body: verificationSummary(record) },
|
|
215
|
+
{ label: "Systemic fix", body: systemicFixRecommendation(record, outcome) },
|
|
216
|
+
],
|
|
217
|
+
};
|
|
218
|
+
});
|
|
219
|
+
}
|
|
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
|
+
|
|
295
|
+
function buildFilters(events: GuiPulseWorkerActivityEvent[]): GuiPulseWorkerSummary["filters"] {
|
|
296
|
+
return {
|
|
297
|
+
repos: countOptions(events.map((event) => event.repo_ref).filter(Boolean) as string[], "repo:"),
|
|
298
|
+
event_types: countOptions(events.map((event) => event.type)),
|
|
299
|
+
outcomes: countOptions(events.map((event) => event.outcome)),
|
|
300
|
+
resources: countOptions(events.flatMap((event) => event.resources.map((resource) => resource.kind))),
|
|
301
|
+
providers: countOptions(events.map((event) => event.usage === null ? null : `${event.usage.provider}:${event.usage.model_ref ?? "unknown"}`).filter(Boolean) as string[]),
|
|
302
|
+
issue_origins: countOptions(events.map((event) => event.issue_origin)),
|
|
303
|
+
authors: countOptions(events.map((event) => event.actor_ref).filter(Boolean) as string[], "actor:"),
|
|
304
|
+
author_associations: countOptions(events.map((event) => event.author_association)),
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function buildCharts(records: MetricRecord[], counters: Record<string, number>, nowMs: number): GuiPulseWorkerChartSeries[] {
|
|
309
|
+
const specs: Array<[GuiPulsePeriodBucket, number, string]> = [["day", DAY_MS, "Last 24h"], ["week", WEEK_MS, "Trailing 7d"], ["month", 30 * DAY_MS, "Trailing 30d"], ["year", 365 * DAY_MS, "Trailing 365d"]];
|
|
310
|
+
const counterTotal = Object.values(counters).reduce((sum, count) => sum + count, 0);
|
|
311
|
+
return [{ id: "worker-events", label: "Worker events", unit: "count", points: specs.map(([period, windowMs, label]) => ({ period, period_label: label, scope_label: DEFAULT_SCOPE, bucket_start: new Date(nowMs - windowMs).toISOString(), bucket_end: new Date(nowMs).toISOString(), value: filterWindow(records, nowMs, windowMs).length + (period === "day" ? counterTotal : 0) })) }];
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function resourceMetricsToSnapshots(records: MetricRecord[], observedAt: string): GuiPulseResourceSnapshot[] {
|
|
315
|
+
return records.slice(-5).map((record) => {
|
|
316
|
+
const rssKb = numberValue(record.rss_kb ?? record.peak_rss_kb);
|
|
317
|
+
return { kind: "memory" as GuiPulseResourceKind, label: stringField(record, "role") ?? "Process memory", available_label: rssKb === null ? "unknown" : `${Math.round(rssKb / 1024)} MB RSS`, pressure: rssKb === null ? "unknown" : rssKb > 2_000_000 ? "high" : rssKb > 750_000 ? "medium" : "low", observed_at: recordTimeMs(record) === null ? observedAt : new Date(recordTimeMs(record) as number).toISOString(), reset_at: null };
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function collectUsageSamples(records: MetricRecord[]): GuiPulseWorkerUsageSnapshot[] {
|
|
322
|
+
return records.map(usageFromRecord).filter((usage): usage is GuiPulseWorkerUsageSnapshot => usage !== null);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function usageFromRecord(record: MetricRecord): GuiPulseWorkerUsageSnapshot | null {
|
|
326
|
+
const input = numberValue(record.input_tokens ?? record.prompt_tokens);
|
|
327
|
+
const output = numberValue(record.output_tokens ?? record.completion_tokens);
|
|
328
|
+
const total = numberValue(record.total_tokens) ?? (input ?? 0) + (output ?? 0);
|
|
329
|
+
if (total <= 0 && input === null && output === null) {
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
const provider = providerFromString(stringField(record, "provider") ?? stringField(record, "provider_id"));
|
|
333
|
+
const model = stringField(record, "model") ?? stringField(record, "model_ref") ?? null;
|
|
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 };
|
|
335
|
+
}
|
|
@@ -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 = {
|
|
@@ -160,7 +160,7 @@ export const TAMBO_PROVIDER_CONFIG: GuiTamboProviderConfig = {
|
|
|
160
160
|
|
|
161
161
|
export function validateTamboComponentPayload(payload: unknown, scope: GuiConversationScope): GuiTamboValidationResult {
|
|
162
162
|
const envelope = validateTamboPayloadEnvelope(payload, scope);
|
|
163
|
-
if (
|
|
163
|
+
if (envelope.ok === false) {
|
|
164
164
|
return envelope.result;
|
|
165
165
|
}
|
|
166
166
|
|
|
@@ -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 =
|
|
203
|
+
const schema = resolveTamboComponentSchema(payload.component);
|
|
184
204
|
if (schema === undefined) {
|
|
185
|
-
return { ok: false, result: invalid(null, {},
|
|
205
|
+
return { ok: false, result: invalid(null, {}, componentLookupError(payload.component)) };
|
|
186
206
|
}
|
|
187
207
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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=
|
|
18
|
-
<header><strong>{
|
|
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
|
|
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
|
}
|