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.
- 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 +154 -8
- 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 +92 -133
- package/packages/gui-desktop/scripts/install-macos-app.sh +217 -2
- 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 +42 -12
- package/packages/gui-web/src/App.tsx +3 -26
- package/packages/gui-web/src/AppActionTerminal.tsx +12 -5
- package/packages/gui-web/src/DesktopStatusBar.tsx +28 -0
- package/packages/gui-web/src/GenUiCards.tsx +5 -4
- package/packages/gui-web/src/PulseWorkersSurface.tsx +112 -19
- package/packages/gui-web/src/ScreenshotCaptureNotification.tsx +51 -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 +98 -11
- package/setup.sh +1 -1
|
@@ -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 =
|
|
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 {
|
|
@@ -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
|
-
|
|
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
|
}
|
|
@@ -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>
|
|
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 ?? "unknown"} ${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
|
}
|
|
@@ -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 ·
|
|
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
|
-
<
|
|
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
|
|
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">
|
|
130
|
-
<h3>
|
|
131
|
-
<
|
|
132
|
-
<
|
|
133
|
-
|
|
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
|
|
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
|
|
179
|
-
<div><dt>Suggested systemic fix</dt><dd>{event.drilldown_sections
|
|
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
|
|
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
|
}
|