@kal-elsam/kairo-runtime 0.9.0 → 0.10.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 +14 -8
- package/package.json +1 -1
- package/scripts/cockpit-smoke.mjs +3 -3
- package/scripts/ux-prototype-tty.mjs +9 -0
- package/src/global/control-plane-proposals.js +2 -1
- package/src/global/control-plane-snapshot.js +1 -1
- package/src/global/ink/cockpit/primitives.js +47 -35
- package/src/global/ink/cockpit-changes.js +2 -2
- package/src/global/ink/cockpit-control-center.js +12 -64
- package/src/global/ink/cockpit-controller.js +41 -5
- package/src/global/ink/cockpit-enter.js +1 -0
- package/src/global/ink/cockpit-models.js +14 -7
- package/src/global/ink/cockpit-palette.js +18 -7
- package/src/global/ink/cockpit-recovery.js +9 -6
- package/src/global/ink/cockpit-usage.js +111 -0
- package/src/global/ink/cockpit-views.js +69 -97
- package/src/global/ink/orchestrator-app.js +32 -0
- package/src/global/ink/setup-app.js +55 -72
- package/src/global/ink/setup-state.js +16 -0
- package/src/global/ink/theme.js +27 -0
- package/src/global/ink/ux/live-activity.js +194 -0
- package/src/global/ink/ux/live-alerts.js +159 -0
- package/src/global/ink/ux/live-governance.js +195 -0
- package/src/global/ink/ux/live-orchestration.js +188 -0
- package/src/global/ink/ux/live-overview.js +125 -0
- package/src/global/ink/ux/live-settings.js +189 -0
- package/src/global/ink/ux/live-setup.js +160 -0
- package/src/global/ink/ux/live-usage.js +51 -0
- package/src/global/ink/ux/semantic.js +84 -0
- package/src/global/ink/ux/task-flow-app.js +85 -0
- package/src/global/ink/ux/task-flow.js +173 -0
- package/src/global/orchestrator.js +37 -20
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/** Live semantic Activity/Recovery. Callout=status · Confirm=action · footer=keys · snapshots=focus. */
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { Box, Text } from "ink";
|
|
4
|
+
import { COCKPIT_COLORS } from "../theme.js";
|
|
5
|
+
import { formatConfirmPath } from "../cockpit-path-label.js";
|
|
6
|
+
import {
|
|
7
|
+
RECOVERY_PHASE, listRecoverySnapshots, formatWhen, formatResult, shortName
|
|
8
|
+
} from "../cockpit-recovery.js";
|
|
9
|
+
import { LAYOUT_MODES } from "../layout.js";
|
|
10
|
+
import { ActionList, Callout, Confirm, Details } from "./semantic.js";
|
|
11
|
+
import { detailsPathLimit } from "./live-governance.js";
|
|
12
|
+
|
|
13
|
+
export function activityContentLimits(layoutMode = LAYOUT_MODES.COMPACT) {
|
|
14
|
+
return layoutMode === LAYOUT_MODES.WIDE
|
|
15
|
+
? { events: 4, snapshots: 8 }
|
|
16
|
+
: { events: 3, snapshots: 3 };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function activitySnapshotLimit(layoutMode = LAYOUT_MODES.COMPACT) {
|
|
20
|
+
return activityContentLimits(layoutMode).snapshots;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Visible window over full list; keeps `index` focused without truncating navigation. */
|
|
24
|
+
export function windowSlice(items = [], index = 0, limit = 3) {
|
|
25
|
+
const total = items.length;
|
|
26
|
+
if (total === 0) return { items: [], selectedIndex: -1, start: 0 };
|
|
27
|
+
const size = Math.max(1, Math.min(limit, total));
|
|
28
|
+
const safeIndex = Math.min(Math.max(0, index), total - 1);
|
|
29
|
+
const start = Math.min(Math.max(0, safeIndex - Math.floor((size - 1) / 2)), total - size);
|
|
30
|
+
return { items: items.slice(start, start + size), selectedIndex: safeIndex - start, start };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function collectRecentItems(snapshot, dashboard, eventLimit) {
|
|
34
|
+
const items = [];
|
|
35
|
+
for (const event of (snapshot?.history?.events ?? []).slice(0, 3)) {
|
|
36
|
+
items.push({
|
|
37
|
+
id: `e-${items.length}`,
|
|
38
|
+
label: `${formatWhen(event.timestamp)} · ${event.command ?? event.type ?? "event"} · ${formatResult(event.action)}`
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
for (const run of (dashboard?.recentRuns ?? []).slice(0, 2)) {
|
|
42
|
+
items.push({
|
|
43
|
+
id: `r-${items.length}`,
|
|
44
|
+
label: `${formatWhen(run.updatedAt ?? run.endedAt ?? run.startedAt)} · ${run.agentId ?? "agent"} · ${formatResult(run.state)}`
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
return items.slice(0, eventLimit);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function phaseTone(phase) {
|
|
51
|
+
if (phase === RECOVERY_PHASE.FAILED) return "danger";
|
|
52
|
+
if (phase === RECOVERY_PHASE.COMPLETED) return "ready";
|
|
53
|
+
if (phase === RECOVERY_PHASE.CONFIRMING || phase === RECOVERY_PHASE.PREVIEWING || phase === RECOVERY_PHASE.APPLYING) {
|
|
54
|
+
return "warn";
|
|
55
|
+
}
|
|
56
|
+
return "info";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function phaseTitle(phase, snapCount, eventCount) {
|
|
60
|
+
if (phase === RECOVERY_PHASE.PREVIEWING) return "Previewing restore";
|
|
61
|
+
if (phase === RECOVERY_PHASE.CONFIRMING) return "Confirm restore";
|
|
62
|
+
if (phase === RECOVERY_PHASE.APPLYING) return "Restoring";
|
|
63
|
+
if (phase === RECOVERY_PHASE.COMPLETED) return "Restore complete";
|
|
64
|
+
if (phase === RECOVERY_PHASE.FAILED) return "Restore failed";
|
|
65
|
+
return `${snapCount} snapshot(s) · ${eventCount} recent`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function calloutBody(phase, recoveryAction) {
|
|
69
|
+
const msg = recoveryAction?.message ?? "";
|
|
70
|
+
if (/Y restore|N\/Esc/i.test(msg)) return "";
|
|
71
|
+
if (phase === RECOVERY_PHASE.FAILED) {
|
|
72
|
+
return msg || (recoveryAction?.error ? `Error · ${recoveryAction.error}` : "");
|
|
73
|
+
}
|
|
74
|
+
return phase === RECOVERY_PHASE.IDLE ? msg : "";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function buildDetailsLines(preview, receipt, showPaths, homeDir, pathLimit) {
|
|
78
|
+
if (!showPaths) return [];
|
|
79
|
+
const files = preview?.files ?? [];
|
|
80
|
+
if (files.length > 0) {
|
|
81
|
+
const lines = files.slice(0, pathLimit).map((f) => formatConfirmPath(f.displayPath ?? f.path, homeDir));
|
|
82
|
+
if (files.length > pathLimit) lines.push(`… ${files.length - pathLimit} more`);
|
|
83
|
+
return lines;
|
|
84
|
+
}
|
|
85
|
+
if (receipt?.safetyBackup) return ["Safety backup retained"];
|
|
86
|
+
if (receipt) return [`Result · ${receipt.action ?? "rollback"} · ${receipt.restored?.length ?? 0} restored`];
|
|
87
|
+
return ["No path evidence in this preview."];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function adaptActivityModel({
|
|
91
|
+
snapshot = null, recoveryAction = null, dashboard = null, listIndex = 0,
|
|
92
|
+
homeDir = null, detailsOpen = false, layoutMode = LAYOUT_MODES.COMPACT
|
|
93
|
+
} = {}) {
|
|
94
|
+
const phase = recoveryAction?.phase ?? RECOVERY_PHASE.IDLE;
|
|
95
|
+
const limits = activityContentLimits(layoutMode);
|
|
96
|
+
const allSnapshots = listRecoverySnapshots(snapshot);
|
|
97
|
+
const windowed = windowSlice(allSnapshots, listIndex, limits.snapshots);
|
|
98
|
+
const preview = recoveryAction?.preview ?? null;
|
|
99
|
+
const receipt = recoveryAction?.receipt ?? null;
|
|
100
|
+
const hasPreview = Boolean(preview);
|
|
101
|
+
const confirming = phase === RECOVERY_PHASE.CONFIRMING;
|
|
102
|
+
const working = phase === RECOVERY_PHASE.PREVIEWING || phase === RECOVERY_PHASE.APPLYING;
|
|
103
|
+
const showPaths = (detailsOpen && hasPreview) || confirming;
|
|
104
|
+
const recentItems = collectRecentItems(snapshot, dashboard, limits.events);
|
|
105
|
+
const recent = recentItems.length > 0
|
|
106
|
+
? recentItems
|
|
107
|
+
: [{ id: "empty-recent", label: "No recent agent activity." }];
|
|
108
|
+
const snapshotItems = windowed.items.length === 0
|
|
109
|
+
? [{ id: "empty-snap", label: "No global snapshots yet." }]
|
|
110
|
+
: windowed.items.map((entry, index) => ({
|
|
111
|
+
id: entry.name ?? `s-${windowed.start + index}`,
|
|
112
|
+
label: `${shortName(entry.name)} · ${entry.fileCount ?? "?"} files`
|
|
113
|
+
}));
|
|
114
|
+
const focused = allSnapshots[Math.min(Math.max(0, listIndex), Math.max(0, allSnapshots.length - 1))] ?? null;
|
|
115
|
+
const fileCount = preview?.files?.length ?? 0;
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
title: "Activity",
|
|
119
|
+
phase,
|
|
120
|
+
hasPreview,
|
|
121
|
+
callout: {
|
|
122
|
+
tone: phaseTone(phase),
|
|
123
|
+
title: phaseTitle(phase, allSnapshots.length, recentItems.length),
|
|
124
|
+
body: calloutBody(phase, recoveryAction)
|
|
125
|
+
},
|
|
126
|
+
primary: confirming || working ? null : {
|
|
127
|
+
label: hasPreview
|
|
128
|
+
? `Restore preview · ${fileCount} file(s)`
|
|
129
|
+
: (allSnapshots.length > 0 ? "Select a snapshot to preview" : "No snapshots to restore"),
|
|
130
|
+
detail: receipt
|
|
131
|
+
? `Result · ${receipt.action ?? "rollback"} · ${receipt.restored?.length ?? 0} restored`
|
|
132
|
+
: null
|
|
133
|
+
},
|
|
134
|
+
confirm: confirming ? {
|
|
135
|
+
summary: fileCount > 0
|
|
136
|
+
? `Restore ${fileCount} file(s) from ${shortName(preview?.snapshot)}.`
|
|
137
|
+
: "Restore confirmed snapshot preview.",
|
|
138
|
+
primaryLabel: "Restore"
|
|
139
|
+
} : null,
|
|
140
|
+
recent,
|
|
141
|
+
snapshots: snapshotItems,
|
|
142
|
+
selectedIndex: windowed.items.length === 0 ? -1 : windowed.selectedIndex,
|
|
143
|
+
focusedSnapshot: focused?.name ?? null,
|
|
144
|
+
snapshotTotal: allSnapshots.length,
|
|
145
|
+
details: buildDetailsLines(preview, receipt, showPaths, homeDir, detailsPathLimit(layoutMode)),
|
|
146
|
+
detailsOpen: showPaths,
|
|
147
|
+
showDetails: hasPreview || confirming || Boolean(receipt)
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function SemanticActivityPanel({
|
|
152
|
+
snapshot = null, recoveryAction = null, dashboard = null, listIndex = 0,
|
|
153
|
+
homeDir = null, detailsOpen = false, layoutMode = LAYOUT_MODES.COMPACT,
|
|
154
|
+
contentFocused = false, colorEnabled = true, unicode = true
|
|
155
|
+
}) {
|
|
156
|
+
const view = adaptActivityModel({
|
|
157
|
+
snapshot, recoveryAction, dashboard, listIndex, homeDir, detailsOpen, layoutMode
|
|
158
|
+
});
|
|
159
|
+
return React.createElement(Box, { flexDirection: "column" },
|
|
160
|
+
React.createElement(Text, {
|
|
161
|
+
bold: true, color: colorEnabled ? COCKPIT_COLORS.secondary : undefined
|
|
162
|
+
}, view.title),
|
|
163
|
+
React.createElement(Callout, {
|
|
164
|
+
tone: view.callout.tone, title: view.callout.title,
|
|
165
|
+
body: view.callout.body || undefined, colorEnabled, compact: true
|
|
166
|
+
}),
|
|
167
|
+
view.confirm
|
|
168
|
+
? React.createElement(Confirm, {
|
|
169
|
+
summary: view.confirm.summary, primaryLabel: view.confirm.primaryLabel,
|
|
170
|
+
focused: false, colorEnabled, mark: " "
|
|
171
|
+
})
|
|
172
|
+
: view.primary
|
|
173
|
+
? React.createElement(Text, { bold: true }, ` ${view.primary.label}`)
|
|
174
|
+
: null,
|
|
175
|
+
view.primary?.detail && !view.confirm
|
|
176
|
+
? React.createElement(Text, null, view.primary.detail) : null,
|
|
177
|
+
React.createElement(Text, { bold: true }, "Recent"),
|
|
178
|
+
React.createElement(ActionList, {
|
|
179
|
+
items: view.recent, selectedIndex: -1, focused: false, colorEnabled, unicode
|
|
180
|
+
}),
|
|
181
|
+
React.createElement(Text, { bold: true }, "Snapshots"),
|
|
182
|
+
React.createElement(ActionList, {
|
|
183
|
+
items: view.snapshots, selectedIndex: view.selectedIndex,
|
|
184
|
+
focused: contentFocused, colorEnabled, unicode
|
|
185
|
+
}),
|
|
186
|
+
view.showDetails
|
|
187
|
+
? React.createElement(Details, {
|
|
188
|
+
open: view.detailsOpen, summary: "Details",
|
|
189
|
+
lines: view.details.length > 0 ? view.details : ["No path evidence in this preview."],
|
|
190
|
+
colorEnabled, focused: false, mark: " "
|
|
191
|
+
})
|
|
192
|
+
: null
|
|
193
|
+
);
|
|
194
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live semantic Alerts inbox.
|
|
3
|
+
* ActionList owns the only focus mark; windowSlice keeps full open domain navigable.
|
|
4
|
+
* Enter resolve / D dismiss unchanged — adapter exposes focusedId aligned with selectAlertFromList.
|
|
5
|
+
*/
|
|
6
|
+
import React from "react";
|
|
7
|
+
import { Box, Text } from "ink";
|
|
8
|
+
import { COCKPIT_COLORS } from "../theme.js";
|
|
9
|
+
import { LAYOUT_MODES } from "../layout.js";
|
|
10
|
+
import { ALERT_STATES } from "../../runtime/alerts/alert-types.js";
|
|
11
|
+
import { formatAlertsHeadline, selectAlertFromList } from "../cockpit-alerts.js";
|
|
12
|
+
import { ActionList, Callout } from "./semantic.js";
|
|
13
|
+
import { windowSlice } from "./live-activity.js";
|
|
14
|
+
|
|
15
|
+
export function alertsListLimit(layoutMode = LAYOUT_MODES.COMPACT) {
|
|
16
|
+
return layoutMode === LAYOUT_MODES.WIDE ? 8 : 3;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function formatAlertWhen(alert) {
|
|
20
|
+
return String(alert.createdAt ?? "").slice(0, 16).replace("T", " ") || "unknown time";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function alertLabel(alert) {
|
|
24
|
+
return `${alert.severity} · ${alert.title} · ${formatAlertWhen(alert)}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function openAlerts(alerts) {
|
|
28
|
+
if (!Array.isArray(alerts)) return [];
|
|
29
|
+
return alerts.filter((alert) => alert.state === ALERT_STATES.OPEN);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function unavailablePack() {
|
|
33
|
+
return {
|
|
34
|
+
items: [{ id: "unavailable", label: "Could not read the alert store." }],
|
|
35
|
+
selectedIndex: -1,
|
|
36
|
+
focusedId: null,
|
|
37
|
+
total: 0,
|
|
38
|
+
start: 0,
|
|
39
|
+
isEmpty: true,
|
|
40
|
+
isUnavailable: true
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function emptyPack() {
|
|
45
|
+
return {
|
|
46
|
+
items: [{ id: "empty", label: "No pending alerts." }],
|
|
47
|
+
selectedIndex: -1,
|
|
48
|
+
focusedId: null,
|
|
49
|
+
total: 0,
|
|
50
|
+
start: 0,
|
|
51
|
+
isEmpty: true,
|
|
52
|
+
isUnavailable: false
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function populatedPack(open, listIndex, limit) {
|
|
57
|
+
const windowed = windowSlice(open, listIndex, limit);
|
|
58
|
+
const safe = Math.min(Math.max(0, listIndex), open.length - 1);
|
|
59
|
+
const focused = open[safe] ?? null;
|
|
60
|
+
return {
|
|
61
|
+
items: windowed.items.map((alert, i) => ({
|
|
62
|
+
id: alert.alertId ?? `alert-${windowed.start + i}`,
|
|
63
|
+
label: alertLabel(alert)
|
|
64
|
+
})),
|
|
65
|
+
selectedIndex: windowed.selectedIndex,
|
|
66
|
+
focusedId: focused?.alertId ?? null,
|
|
67
|
+
total: open.length,
|
|
68
|
+
start: windowed.start,
|
|
69
|
+
isEmpty: false,
|
|
70
|
+
isUnavailable: false
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Pure adapter: unavailable · empty · pending inbox. */
|
|
75
|
+
export function adaptAlertsModel({
|
|
76
|
+
alerts = null,
|
|
77
|
+
listIndex = 0,
|
|
78
|
+
layoutMode = LAYOUT_MODES.COMPACT
|
|
79
|
+
} = {}) {
|
|
80
|
+
const limit = alertsListLimit(layoutMode);
|
|
81
|
+
const headline = formatAlertsHeadline(alerts);
|
|
82
|
+
let list;
|
|
83
|
+
|
|
84
|
+
if (alerts == null) {
|
|
85
|
+
list = unavailablePack();
|
|
86
|
+
} else {
|
|
87
|
+
const open = openAlerts(alerts);
|
|
88
|
+
list = open.length === 0
|
|
89
|
+
? emptyPack()
|
|
90
|
+
: populatedPack(open, listIndex, limit);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const selected = selectAlertFromList(alerts, listIndex);
|
|
94
|
+
if (!list.isEmpty && selected?.alertId) {
|
|
95
|
+
list.focusedId = selected.alertId;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const callout = list.isUnavailable
|
|
99
|
+
? {
|
|
100
|
+
tone: "danger",
|
|
101
|
+
title: headline.headline,
|
|
102
|
+
body: "Esc back · / Alerts"
|
|
103
|
+
}
|
|
104
|
+
: list.isEmpty
|
|
105
|
+
? {
|
|
106
|
+
tone: "info",
|
|
107
|
+
title: headline.headline,
|
|
108
|
+
body: "Esc back · / Alerts"
|
|
109
|
+
}
|
|
110
|
+
: {
|
|
111
|
+
tone: "warn",
|
|
112
|
+
title: headline.headline,
|
|
113
|
+
body: "Enter resolves · D dismisses · Esc back"
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
title: "Alerts",
|
|
118
|
+
callout,
|
|
119
|
+
items: list.items,
|
|
120
|
+
selectedIndex: list.selectedIndex,
|
|
121
|
+
focusedId: list.focusedId,
|
|
122
|
+
total: list.total,
|
|
123
|
+
start: list.start,
|
|
124
|
+
isEmpty: list.isEmpty,
|
|
125
|
+
isUnavailable: list.isUnavailable,
|
|
126
|
+
listLimit: limit
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function SemanticAlertsPanel({
|
|
131
|
+
alerts = null,
|
|
132
|
+
listIndex = 0,
|
|
133
|
+
layoutMode = LAYOUT_MODES.COMPACT,
|
|
134
|
+
contentFocused = false,
|
|
135
|
+
colorEnabled = true,
|
|
136
|
+
unicode = true
|
|
137
|
+
}) {
|
|
138
|
+
const model = adaptAlertsModel({ alerts, listIndex, layoutMode });
|
|
139
|
+
const listFocused = contentFocused && !model.isEmpty && !model.isUnavailable;
|
|
140
|
+
return React.createElement(Box, { flexDirection: "column" },
|
|
141
|
+
React.createElement(Text, {
|
|
142
|
+
bold: true, color: colorEnabled ? COCKPIT_COLORS.secondary : undefined
|
|
143
|
+
}, model.title),
|
|
144
|
+
React.createElement(Callout, {
|
|
145
|
+
tone: model.callout.tone,
|
|
146
|
+
title: model.callout.title,
|
|
147
|
+
body: model.callout.body || undefined,
|
|
148
|
+
colorEnabled,
|
|
149
|
+
compact: true
|
|
150
|
+
}),
|
|
151
|
+
React.createElement(ActionList, {
|
|
152
|
+
items: model.items,
|
|
153
|
+
selectedIndex: model.selectedIndex,
|
|
154
|
+
focused: listFocused,
|
|
155
|
+
colorEnabled,
|
|
156
|
+
unicode
|
|
157
|
+
})
|
|
158
|
+
);
|
|
159
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/** Live semantic Governance. Ownership: Callout=status · Confirm/primary=action · footer=keys. */
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { Box, Text } from "ink";
|
|
4
|
+
import { COCKPIT_COLORS } from "../theme.js";
|
|
5
|
+
import { formatConfirmPath } from "../cockpit-path-label.js";
|
|
6
|
+
import { CHANGES_PHASE } from "../cockpit-changes.js";
|
|
7
|
+
import { LAYOUT_MODES } from "../layout.js";
|
|
8
|
+
import { ActionList, Callout, Confirm, Details } from "./semantic.js";
|
|
9
|
+
import { mapHealthTone } from "./live-overview.js";
|
|
10
|
+
|
|
11
|
+
function healthLabel(kind) {
|
|
12
|
+
return String(kind ?? "unknown").replaceAll("_", " ");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function plannedChanges(snapshot, changesAction) {
|
|
16
|
+
const preview = changesAction?.preview;
|
|
17
|
+
const diff = snapshot?.diff;
|
|
18
|
+
if (preview?.hasChanges) return preview.changes ?? [];
|
|
19
|
+
if (diff?.hasChanges && !preview) return diff.changes ?? [];
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function phaseTone(phase, healthKind) {
|
|
24
|
+
if (phase === CHANGES_PHASE.FAILED) return "danger";
|
|
25
|
+
if (phase === CHANGES_PHASE.COMPLETED) return "ready";
|
|
26
|
+
if (phase === CHANGES_PHASE.CONFIRMING || phase === CHANGES_PHASE.PREVIEWING || phase === CHANGES_PHASE.APPLYING) {
|
|
27
|
+
return "warn";
|
|
28
|
+
}
|
|
29
|
+
return mapHealthTone(healthKind);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function phaseTitle(phase, snapshot, changesAction) {
|
|
33
|
+
if (phase === CHANGES_PHASE.PREVIEWING) return "Previewing";
|
|
34
|
+
if (phase === CHANGES_PHASE.CONFIRMING) return "Confirm apply";
|
|
35
|
+
if (phase === CHANGES_PHASE.APPLYING) return "Applying";
|
|
36
|
+
if (phase === CHANGES_PHASE.COMPLETED) return "Apply complete";
|
|
37
|
+
if (phase === CHANGES_PHASE.FAILED) {
|
|
38
|
+
return changesAction?.error === "setup-required" ? "Setup required" : "Governance failed";
|
|
39
|
+
}
|
|
40
|
+
const pending = snapshot?.diff?.hasChanges
|
|
41
|
+
? (snapshot.diff.changeCount ?? snapshot.diff.changes?.length ?? 0)
|
|
42
|
+
: 0;
|
|
43
|
+
return `${healthLabel(snapshot?.health)}${pending > 0 ? ` · ${pending} pending` : " · drift clean"}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Compact/minimal: 3 paths; wide: 12. */
|
|
47
|
+
export function detailsPathLimit(layoutMode = LAYOUT_MODES.COMPACT) {
|
|
48
|
+
return layoutMode === LAYOUT_MODES.WIDE ? 12 : 3;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function calloutBody(phase, changesAction) {
|
|
52
|
+
if (phase !== CHANGES_PHASE.FAILED) return "";
|
|
53
|
+
if (changesAction?.error === "setup-required") return "Not configured — open Overview and run setup.";
|
|
54
|
+
if (changesAction?.error) return `Error · ${changesAction.error}`;
|
|
55
|
+
return changesAction?.message ?? "";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function buildDetailsLines(planned, showPaths, changesAction, homeDir, pathLimit) {
|
|
59
|
+
if (!showPaths) return [];
|
|
60
|
+
if (planned.length > 0) {
|
|
61
|
+
const lines = planned.slice(0, pathLimit).map((c) =>
|
|
62
|
+
`${c.action ?? c.kind} · ${formatConfirmPath(c.target, homeDir)}`
|
|
63
|
+
);
|
|
64
|
+
if (planned.length > pathLimit) lines.push(`… ${planned.length - pathLimit} more`);
|
|
65
|
+
return lines;
|
|
66
|
+
}
|
|
67
|
+
const receipt = changesAction?.receipt;
|
|
68
|
+
if (receipt?.checksBefore && receipt?.checksAfter) {
|
|
69
|
+
return [`Checks · before ok=${receipt.checksBefore.ok} → after ok=${receipt.checksAfter.ok}`];
|
|
70
|
+
}
|
|
71
|
+
return ["No path evidence on this scan."];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Pure adapter: paths only in detailsLines (confirming or Details open). */
|
|
75
|
+
export function adaptGovernanceModel({
|
|
76
|
+
snapshot = null,
|
|
77
|
+
changesAction = null,
|
|
78
|
+
homeDir = null,
|
|
79
|
+
detailsOpen = false,
|
|
80
|
+
layoutMode = LAYOUT_MODES.COMPACT
|
|
81
|
+
} = {}) {
|
|
82
|
+
const phase = changesAction?.phase ?? CHANGES_PHASE.IDLE;
|
|
83
|
+
const coverage = snapshot?.coverage ?? {};
|
|
84
|
+
const cta = snapshot?.cta;
|
|
85
|
+
const planned = plannedChanges(snapshot, changesAction);
|
|
86
|
+
const showPaths = detailsOpen || phase === CHANGES_PHASE.CONFIRMING;
|
|
87
|
+
const confirming = phase === CHANGES_PHASE.CONFIRMING;
|
|
88
|
+
const working = phase === CHANGES_PHASE.PREVIEWING || phase === CHANGES_PHASE.APPLYING;
|
|
89
|
+
const actionable = phase === CHANGES_PHASE.IDLE
|
|
90
|
+
|| phase === CHANGES_PHASE.COMPLETED
|
|
91
|
+
|| phase === CHANGES_PHASE.FAILED;
|
|
92
|
+
|
|
93
|
+
const metrics = [
|
|
94
|
+
{
|
|
95
|
+
id: "coverage",
|
|
96
|
+
label: `Coverage · ${coverage.governedAgents ?? 0}/${coverage.detectedAgents ?? 0} agents · ${coverage.components ?? 0} components`
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
id: "planned",
|
|
100
|
+
label: planned.length > 0
|
|
101
|
+
? `Planned · ${planned.length} change(s)`
|
|
102
|
+
: (snapshot?.diff?.summary ?? "No pending governance changes.")
|
|
103
|
+
}
|
|
104
|
+
];
|
|
105
|
+
if (changesAction?.receipt) {
|
|
106
|
+
const r = changesAction.receipt;
|
|
107
|
+
metrics.push({
|
|
108
|
+
id: "receipt",
|
|
109
|
+
label: `Result · ${r.action}${r.partial ? " (partial)" : ""}${r.backups?.length ? ` · ${r.backups.length} backup(s)` : ""}`
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
title: "Governance",
|
|
115
|
+
phase,
|
|
116
|
+
callout: {
|
|
117
|
+
tone: phaseTone(phase, snapshot?.health),
|
|
118
|
+
title: phaseTitle(phase, snapshot, changesAction),
|
|
119
|
+
body: calloutBody(phase, changesAction)
|
|
120
|
+
},
|
|
121
|
+
primary: confirming || working
|
|
122
|
+
? null
|
|
123
|
+
: {
|
|
124
|
+
label: cta?.title ?? "Review governance when ready",
|
|
125
|
+
detail: actionable ? (cta?.detail ?? null) : null
|
|
126
|
+
},
|
|
127
|
+
confirm: confirming
|
|
128
|
+
? {
|
|
129
|
+
summary: planned.length > 0
|
|
130
|
+
? `Apply ${planned.length} planned change(s).`
|
|
131
|
+
: "Apply confirmed governance preview.",
|
|
132
|
+
primaryLabel: "Apply"
|
|
133
|
+
}
|
|
134
|
+
: null,
|
|
135
|
+
metrics,
|
|
136
|
+
details: buildDetailsLines(planned, showPaths, changesAction, homeDir, detailsPathLimit(layoutMode)),
|
|
137
|
+
detailsOpen: showPaths
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function SemanticGovernancePanel({
|
|
142
|
+
snapshot = null,
|
|
143
|
+
changesAction = null,
|
|
144
|
+
homeDir = null,
|
|
145
|
+
detailsOpen = false,
|
|
146
|
+
layoutMode = LAYOUT_MODES.COMPACT,
|
|
147
|
+
colorEnabled = true,
|
|
148
|
+
unicode = true
|
|
149
|
+
}) {
|
|
150
|
+
const view = adaptGovernanceModel({
|
|
151
|
+
snapshot, changesAction, homeDir, detailsOpen, layoutMode
|
|
152
|
+
});
|
|
153
|
+
return React.createElement(Box, { flexDirection: "column" },
|
|
154
|
+
React.createElement(Text, {
|
|
155
|
+
bold: true,
|
|
156
|
+
color: colorEnabled ? COCKPIT_COLORS.secondary : undefined
|
|
157
|
+
}, view.title),
|
|
158
|
+
React.createElement(Callout, {
|
|
159
|
+
tone: view.callout.tone,
|
|
160
|
+
title: view.callout.title,
|
|
161
|
+
body: view.callout.body || undefined,
|
|
162
|
+
colorEnabled,
|
|
163
|
+
compact: true
|
|
164
|
+
}),
|
|
165
|
+
view.confirm
|
|
166
|
+
? React.createElement(Confirm, {
|
|
167
|
+
summary: view.confirm.summary,
|
|
168
|
+
primaryLabel: view.confirm.primaryLabel,
|
|
169
|
+
focused: false,
|
|
170
|
+
colorEnabled,
|
|
171
|
+
mark: " "
|
|
172
|
+
})
|
|
173
|
+
: view.primary
|
|
174
|
+
? React.createElement(Box, { flexDirection: "column" },
|
|
175
|
+
React.createElement(Text, { bold: true }, ` ${view.primary.label}`),
|
|
176
|
+
view.primary.detail ? React.createElement(Text, null, view.primary.detail) : null
|
|
177
|
+
)
|
|
178
|
+
: null,
|
|
179
|
+
React.createElement(ActionList, {
|
|
180
|
+
items: view.metrics,
|
|
181
|
+
selectedIndex: -1,
|
|
182
|
+
focused: false,
|
|
183
|
+
colorEnabled,
|
|
184
|
+
unicode
|
|
185
|
+
}),
|
|
186
|
+
React.createElement(Details, {
|
|
187
|
+
open: view.detailsOpen,
|
|
188
|
+
summary: "Details",
|
|
189
|
+
lines: view.details.length > 0 ? view.details : ["No path evidence on this scan."],
|
|
190
|
+
colorEnabled,
|
|
191
|
+
focused: false,
|
|
192
|
+
mark: " "
|
|
193
|
+
})
|
|
194
|
+
);
|
|
195
|
+
}
|