create-ekka-desktop-app 0.4.7 → 0.4.9
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/package.json +1 -1
- package/template/src/demo/DemoApp.tsx +2 -0
- package/template/src/demo/components/InfoTooltip.tsx +20 -8
- package/template/src/demo/layout/Sidebar.tsx +11 -1
- package/template/src/demo/pages/ExecutionPlansPage.tsx +455 -0
- package/template/src-tauri/crates/ekka-desktop-core/src/main.rs +199 -0
- package/template/src-tauri/src/commands.rs +8 -0
package/package.json
CHANGED
|
@@ -20,6 +20,7 @@ import { AuditLogPage } from './pages/AuditLogPage';
|
|
|
20
20
|
import { PathPermissionsPage } from './pages/PathPermissionsPage';
|
|
21
21
|
import { VaultPage } from './pages/VaultPage';
|
|
22
22
|
import { RunnerPage } from './pages/RunnerPage';
|
|
23
|
+
import { ExecutionPlansPage } from './pages/ExecutionPlansPage';
|
|
23
24
|
import { LoginPage } from './pages/LoginPage';
|
|
24
25
|
import { HomeSetupPage } from './pages/HomeSetupPage';
|
|
25
26
|
import { SetupWizard } from './components/SetupWizard';
|
|
@@ -251,6 +252,7 @@ export function DemoApp(): ReactElement {
|
|
|
251
252
|
{selectedPage === 'path-permissions' && <PathPermissionsPage darkMode={darkMode} />}
|
|
252
253
|
{selectedPage === 'vault' && <VaultPage darkMode={darkMode} />}
|
|
253
254
|
{selectedPage === 'runner' && <RunnerPage darkMode={darkMode} />}
|
|
255
|
+
{selectedPage === 'execution-plans' && <ExecutionPlansPage darkMode={darkMode} />}
|
|
254
256
|
{selectedPage === 'audit-log' && <AuditLogPage darkMode={darkMode} />}
|
|
255
257
|
{selectedPage === 'system' && <SystemPage darkMode={darkMode} />}
|
|
256
258
|
</Shell>
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Info Tooltip Component
|
|
3
3
|
* Shows an info icon that displays tooltip text on hover.
|
|
4
|
+
* Uses position: fixed to avoid being clipped by parent overflow.
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
|
-
import { useState, type CSSProperties, type ReactElement } from 'react';
|
|
7
|
+
import { useState, useRef, type CSSProperties, type ReactElement } from 'react';
|
|
7
8
|
|
|
8
9
|
interface InfoTooltipProps {
|
|
9
10
|
text: string;
|
|
@@ -12,6 +13,8 @@ interface InfoTooltipProps {
|
|
|
12
13
|
|
|
13
14
|
export function InfoTooltip({ text, darkMode = false }: InfoTooltipProps): ReactElement {
|
|
14
15
|
const [isVisible, setIsVisible] = useState(false);
|
|
16
|
+
const [pos, setPos] = useState({ top: 0, left: 0 });
|
|
17
|
+
const iconRef = useRef<HTMLSpanElement>(null);
|
|
15
18
|
|
|
16
19
|
const colors = {
|
|
17
20
|
icon: darkMode ? '#98989d' : '#86868b',
|
|
@@ -20,6 +23,14 @@ export function InfoTooltip({ text, darkMode = false }: InfoTooltipProps): React
|
|
|
20
23
|
tooltipText: '#ffffff',
|
|
21
24
|
};
|
|
22
25
|
|
|
26
|
+
function handleEnter() {
|
|
27
|
+
if (iconRef.current) {
|
|
28
|
+
const rect = iconRef.current.getBoundingClientRect();
|
|
29
|
+
setPos({ top: rect.top - 8, left: rect.left + rect.width / 2 });
|
|
30
|
+
}
|
|
31
|
+
setIsVisible(true);
|
|
32
|
+
}
|
|
33
|
+
|
|
23
34
|
const styles: Record<string, CSSProperties> = {
|
|
24
35
|
container: {
|
|
25
36
|
position: 'relative',
|
|
@@ -34,11 +45,10 @@ export function InfoTooltip({ text, darkMode = false }: InfoTooltipProps): React
|
|
|
34
45
|
transition: 'color 0.15s ease',
|
|
35
46
|
},
|
|
36
47
|
tooltip: {
|
|
37
|
-
position: '
|
|
38
|
-
|
|
39
|
-
left:
|
|
40
|
-
transform: '
|
|
41
|
-
marginBottom: '8px',
|
|
48
|
+
position: 'fixed',
|
|
49
|
+
top: pos.top,
|
|
50
|
+
left: pos.left,
|
|
51
|
+
transform: 'translate(-50%, -100%)',
|
|
42
52
|
padding: '8px 12px',
|
|
43
53
|
background: colors.tooltipBg,
|
|
44
54
|
color: colors.tooltipText,
|
|
@@ -48,18 +58,20 @@ export function InfoTooltip({ text, darkMode = false }: InfoTooltipProps): React
|
|
|
48
58
|
whiteSpace: 'normal',
|
|
49
59
|
maxWidth: '320px',
|
|
50
60
|
width: 'max-content',
|
|
51
|
-
zIndex:
|
|
61
|
+
zIndex: 10000,
|
|
52
62
|
opacity: isVisible ? 1 : 0,
|
|
53
63
|
visibility: isVisible ? 'visible' : 'hidden',
|
|
54
64
|
transition: 'opacity 0.15s ease, visibility 0.15s ease',
|
|
55
65
|
pointerEvents: 'none',
|
|
66
|
+
boxShadow: '0 2px 8px rgba(0,0,0,0.25)',
|
|
56
67
|
},
|
|
57
68
|
};
|
|
58
69
|
|
|
59
70
|
return (
|
|
60
71
|
<span
|
|
72
|
+
ref={iconRef}
|
|
61
73
|
style={styles.container}
|
|
62
|
-
onMouseEnter={
|
|
74
|
+
onMouseEnter={handleEnter}
|
|
63
75
|
onMouseLeave={() => setIsVisible(false)}
|
|
64
76
|
>
|
|
65
77
|
<svg style={styles.icon} viewBox="0 0 16 16" fill="none">
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { type CSSProperties, type ReactElement } from 'react';
|
|
7
7
|
|
|
8
|
-
export type Page = 'audit-log' | 'path-permissions' | 'vault' | 'runner' | 'system';
|
|
8
|
+
export type Page = 'audit-log' | 'path-permissions' | 'vault' | 'runner' | 'execution-plans' | 'system';
|
|
9
9
|
|
|
10
10
|
interface SidebarProps {
|
|
11
11
|
selectedPage: Page;
|
|
@@ -126,6 +126,7 @@ export function Sidebar({ selectedPage, onNavigate, darkMode }: SidebarProps): R
|
|
|
126
126
|
<NavButton page="path-permissions" label="Path Permissions" icon={<PathIcon />} />
|
|
127
127
|
<NavButton page="vault" label="Vault" icon={<VaultIcon />} />
|
|
128
128
|
<NavButton page="runner" label="Runner" icon={<RunnerIcon />} />
|
|
129
|
+
<NavButton page="execution-plans" label="Execution Plans" icon={<ExecutionIcon />} />
|
|
129
130
|
<NavButton page="audit-log" label="Audit Log" icon={<AuditIcon />} />
|
|
130
131
|
</nav>
|
|
131
132
|
|
|
@@ -180,3 +181,12 @@ function RunnerIcon(): ReactElement {
|
|
|
180
181
|
</svg>
|
|
181
182
|
);
|
|
182
183
|
}
|
|
184
|
+
|
|
185
|
+
function ExecutionIcon(): ReactElement {
|
|
186
|
+
return (
|
|
187
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" style={{ opacity: 0.7 }}>
|
|
188
|
+
<path d="M6 3.5a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1-.5-.5zm0 4a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1-.5-.5zm0 4a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1-.5-.5z" />
|
|
189
|
+
<path d="M2.5 3l2 1.5L2.5 6V3zm0 4l2 1.5L2.5 10V7zm0 4l2 1.5-2 1.5V11z" />
|
|
190
|
+
</svg>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Execution Plans Page
|
|
3
|
+
*
|
|
4
|
+
* Generic execution plan runner demo:
|
|
5
|
+
* - Select a plan from dropdown
|
|
6
|
+
* - Edit input JSON
|
|
7
|
+
* - Execute (start a run)
|
|
8
|
+
* - View runs grid + run details/events
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { useState, useEffect, useCallback, useRef, type CSSProperties, type ReactElement } from 'react';
|
|
12
|
+
import { _internal, makeRequest } from '../../ekka/internal';
|
|
13
|
+
import { InfoTooltip } from '../components';
|
|
14
|
+
|
|
15
|
+
// =============================================================================
|
|
16
|
+
// API HELPERS (use internal request wrapper, no direct fetch)
|
|
17
|
+
// =============================================================================
|
|
18
|
+
|
|
19
|
+
interface Plan {
|
|
20
|
+
id: string;
|
|
21
|
+
plan_code: string;
|
|
22
|
+
display_name: string;
|
|
23
|
+
classification?: string;
|
|
24
|
+
description?: string;
|
|
25
|
+
input_schema?: { required?: string[]; properties?: Record<string, unknown> };
|
|
26
|
+
steps?: unknown[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface Run {
|
|
30
|
+
id: string;
|
|
31
|
+
plan_id: string;
|
|
32
|
+
status: string;
|
|
33
|
+
inputs?: Record<string, unknown>;
|
|
34
|
+
outputs?: Record<string, unknown>;
|
|
35
|
+
error?: string;
|
|
36
|
+
created_at: string;
|
|
37
|
+
updated_at?: string;
|
|
38
|
+
started_at?: string;
|
|
39
|
+
completed_at?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface RunEvent {
|
|
43
|
+
id: string;
|
|
44
|
+
run_id: string;
|
|
45
|
+
event_type: string;
|
|
46
|
+
step_key?: string;
|
|
47
|
+
payload?: unknown;
|
|
48
|
+
created_at: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function request<T>(op: string, payload: unknown = {}): Promise<T> {
|
|
52
|
+
const req = makeRequest(op, payload);
|
|
53
|
+
const resp = await _internal.request(req);
|
|
54
|
+
if (!resp.ok) {
|
|
55
|
+
throw new Error(resp.error?.message || `${op} failed`);
|
|
56
|
+
}
|
|
57
|
+
return resp.result as T;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function listPlans(): Promise<Plan[]> {
|
|
61
|
+
const result = await request<{ data?: Plan[]; plans?: Plan[] }>('execution.plans.list', { limit: 100 });
|
|
62
|
+
return result.data || result.plans || (Array.isArray(result) ? result as unknown as Plan[] : []);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function getPlan(id: string): Promise<Plan> {
|
|
66
|
+
return request<Plan>('execution.plans.get', { id });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function listRuns(planId: string): Promise<Run[]> {
|
|
70
|
+
const result = await request<{ data?: Run[]; runs?: Run[] }>('execution.plans.runs.list', { planId, limit: 25 });
|
|
71
|
+
return result.data || result.runs || (Array.isArray(result) ? result as unknown as Run[] : []);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function startRun(plan_id: string, inputs: Record<string, unknown>): Promise<Run> {
|
|
75
|
+
return request<Run>('execution.runs.start', { plan_id, inputs });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function getRun(runId: string): Promise<Run> {
|
|
79
|
+
return request<Run>('execution.runs.get', { runId });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function getRunEvents(runId: string): Promise<RunEvent[]> {
|
|
83
|
+
const result = await request<{ data?: RunEvent[]; events?: RunEvent[] }>('execution.runs.events', { runId });
|
|
84
|
+
return result.data || result.events || (Array.isArray(result) ? result as unknown as RunEvent[] : []);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// =============================================================================
|
|
88
|
+
// PAGE COMPONENT
|
|
89
|
+
// =============================================================================
|
|
90
|
+
|
|
91
|
+
interface ExecutionPlansPageProps {
|
|
92
|
+
darkMode: boolean;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function ExecutionPlansPage({ darkMode }: ExecutionPlansPageProps): ReactElement {
|
|
96
|
+
const [plans, setPlans] = useState<Plan[]>([]);
|
|
97
|
+
const [selectedPlanId, setSelectedPlanId] = useState<string>('');
|
|
98
|
+
const [selectedPlan, setSelectedPlan] = useState<Plan | null>(null);
|
|
99
|
+
const [inputJson, setInputJson] = useState<string>('{}');
|
|
100
|
+
const [jsonError, setJsonError] = useState<string | null>(null);
|
|
101
|
+
const [runs, setRuns] = useState<Run[]>([]);
|
|
102
|
+
const [selectedRun, setSelectedRun] = useState<Run | null>(null);
|
|
103
|
+
const [runEvents, setRunEvents] = useState<RunEvent[]>([]);
|
|
104
|
+
const [loading, setLoading] = useState(false);
|
|
105
|
+
const [executing, setExecuting] = useState(false);
|
|
106
|
+
const [error, setError] = useState<string | null>(null);
|
|
107
|
+
const didLoadRef = useRef(false);
|
|
108
|
+
|
|
109
|
+
// Colors
|
|
110
|
+
const colors = {
|
|
111
|
+
bg: darkMode ? '#1c1c1e' : '#ffffff',
|
|
112
|
+
cardBg: darkMode ? '#2c2c2e' : '#fafafa',
|
|
113
|
+
border: darkMode ? '#3a3a3c' : '#e5e5e5',
|
|
114
|
+
text: darkMode ? '#ffffff' : '#1d1d1f',
|
|
115
|
+
textMuted: darkMode ? '#98989d' : '#6e6e73',
|
|
116
|
+
blue: darkMode ? '#0a84ff' : '#007aff',
|
|
117
|
+
green: darkMode ? '#30d158' : '#34c759',
|
|
118
|
+
red: darkMode ? '#ff453a' : '#ff3b30',
|
|
119
|
+
yellow: darkMode ? '#ffd60a' : '#ff9f0a',
|
|
120
|
+
inputBg: darkMode ? '#1c1c1e' : '#ffffff',
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const styles: Record<string, CSSProperties> = {
|
|
124
|
+
container: { padding: '24px 32px', maxWidth: '1200px', color: colors.text },
|
|
125
|
+
header: { marginBottom: '24px' },
|
|
126
|
+
title: { fontSize: '24px', fontWeight: 700, letterSpacing: '-0.02em', margin: 0 },
|
|
127
|
+
subtitle: { fontSize: '13px', color: colors.textMuted, marginTop: '4px' },
|
|
128
|
+
error: { padding: '10px 14px', background: darkMode ? '#3c1618' : '#fef2f2', border: `1px solid ${darkMode ? '#7f1d1d' : '#fecaca'}`, borderRadius: '6px', fontSize: '13px', color: darkMode ? '#fca5a5' : '#991b1b', marginBottom: '16px' },
|
|
129
|
+
card: { background: colors.cardBg, border: `1px solid ${colors.border}`, borderRadius: '8px', padding: '16px', marginBottom: '16px' },
|
|
130
|
+
label: { fontSize: '12px', fontWeight: 600, color: colors.textMuted, textTransform: 'uppercase', letterSpacing: '0.04em', marginBottom: '6px', display: 'block' },
|
|
131
|
+
select: { width: '100%', padding: '8px 12px', fontSize: '13px', borderRadius: '6px', border: `1px solid ${colors.border}`, background: colors.inputBg, color: colors.text, outline: 'none' },
|
|
132
|
+
textarea: { width: '100%', minHeight: '120px', padding: '10px 12px', fontSize: '12px', fontFamily: 'SF Mono, Menlo, monospace', borderRadius: '6px', border: `1px solid ${jsonError ? colors.red : colors.border}`, background: colors.inputBg, color: colors.text, outline: 'none', resize: 'vertical' as const, lineHeight: 1.5 },
|
|
133
|
+
button: { padding: '8px 20px', fontSize: '13px', fontWeight: 500, borderRadius: '6px', border: 'none', cursor: 'pointer', transition: 'opacity 0.15s' },
|
|
134
|
+
buttonPrimary: { background: colors.blue, color: '#ffffff' },
|
|
135
|
+
buttonDisabled: { opacity: 0.5, cursor: 'not-allowed' },
|
|
136
|
+
grid: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' },
|
|
137
|
+
table: { width: '100%', borderCollapse: 'collapse' as const, fontSize: '12px' },
|
|
138
|
+
th: { textAlign: 'left' as const, padding: '8px 10px', borderBottom: `1px solid ${colors.border}`, fontWeight: 600, color: colors.textMuted, fontSize: '11px', textTransform: 'uppercase' as const, letterSpacing: '0.04em' },
|
|
139
|
+
td: { padding: '8px 10px', borderBottom: `1px solid ${colors.border}`, verticalAlign: 'top' as const },
|
|
140
|
+
mono: { fontFamily: 'SF Mono, Menlo, monospace', fontSize: '11px' },
|
|
141
|
+
badge: { display: 'inline-block', padding: '2px 8px', borderRadius: '4px', fontSize: '11px', fontWeight: 500 },
|
|
142
|
+
sectionTitle: { fontSize: '14px', fontWeight: 600, marginBottom: '12px', display: 'flex', alignItems: 'center', gap: '6px' },
|
|
143
|
+
meta: { fontSize: '12px', color: colors.textMuted, marginTop: '8px' },
|
|
144
|
+
pre: { background: darkMode ? '#1c1c1e' : '#f5f5f7', border: `1px solid ${colors.border}`, borderRadius: '6px', padding: '12px', fontSize: '11px', fontFamily: 'SF Mono, Menlo, monospace', overflow: 'auto', maxHeight: '300px', whiteSpace: 'pre-wrap' as const, wordBreak: 'break-all' as const },
|
|
145
|
+
link: { color: colors.blue, cursor: 'pointer', background: 'none', border: 'none', fontSize: '12px', fontFamily: 'SF Mono, Menlo, monospace', padding: 0 },
|
|
146
|
+
row: { display: 'flex', gap: '12px', alignItems: 'flex-end', marginBottom: '12px' },
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
// Load plans on mount
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
if (didLoadRef.current) return;
|
|
152
|
+
didLoadRef.current = true;
|
|
153
|
+
setLoading(true);
|
|
154
|
+
listPlans()
|
|
155
|
+
.then((p) => {
|
|
156
|
+
setPlans(p);
|
|
157
|
+
// Restore last selected plan from localStorage
|
|
158
|
+
const lastPlan = localStorage.getItem('ekka_exec_plan_id');
|
|
159
|
+
if (lastPlan && p.some((x) => x.id === lastPlan)) {
|
|
160
|
+
setSelectedPlanId(lastPlan);
|
|
161
|
+
}
|
|
162
|
+
})
|
|
163
|
+
.catch((e) => setError(e.message))
|
|
164
|
+
.finally(() => setLoading(false));
|
|
165
|
+
}, []);
|
|
166
|
+
|
|
167
|
+
// When plan selection changes, load plan details + runs
|
|
168
|
+
useEffect(() => {
|
|
169
|
+
if (!selectedPlanId) {
|
|
170
|
+
setSelectedPlan(null);
|
|
171
|
+
setRuns([]);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
localStorage.setItem('ekka_exec_plan_id', selectedPlanId);
|
|
175
|
+
setSelectedPlan(null);
|
|
176
|
+
setSelectedRun(null);
|
|
177
|
+
setRunEvents([]);
|
|
178
|
+
|
|
179
|
+
getPlan(selectedPlanId)
|
|
180
|
+
.then((p) => {
|
|
181
|
+
setSelectedPlan(p);
|
|
182
|
+
// Prefill input JSON from schema
|
|
183
|
+
const defaults = buildDefaultInputs(p);
|
|
184
|
+
const lastInputs = localStorage.getItem(`ekka_exec_inputs_${selectedPlanId}`);
|
|
185
|
+
setInputJson(lastInputs || JSON.stringify(defaults, null, 2));
|
|
186
|
+
})
|
|
187
|
+
.catch((e) => setError(e.message));
|
|
188
|
+
|
|
189
|
+
loadRuns(selectedPlanId);
|
|
190
|
+
}, [selectedPlanId]);
|
|
191
|
+
|
|
192
|
+
const loadRuns = useCallback((planId: string) => {
|
|
193
|
+
listRuns(planId)
|
|
194
|
+
.then(setRuns)
|
|
195
|
+
.catch(() => setRuns([]));
|
|
196
|
+
}, []);
|
|
197
|
+
|
|
198
|
+
// Validate JSON on change
|
|
199
|
+
useEffect(() => {
|
|
200
|
+
try {
|
|
201
|
+
JSON.parse(inputJson);
|
|
202
|
+
setJsonError(null);
|
|
203
|
+
} catch (e) {
|
|
204
|
+
setJsonError((e as Error).message);
|
|
205
|
+
}
|
|
206
|
+
}, [inputJson]);
|
|
207
|
+
|
|
208
|
+
// Execute
|
|
209
|
+
async function handleExecute() {
|
|
210
|
+
if (!selectedPlanId || jsonError) return;
|
|
211
|
+
setExecuting(true);
|
|
212
|
+
setError(null);
|
|
213
|
+
try {
|
|
214
|
+
const inputs = JSON.parse(inputJson);
|
|
215
|
+
localStorage.setItem(`ekka_exec_inputs_${selectedPlanId}`, inputJson);
|
|
216
|
+
await startRun(selectedPlanId, inputs);
|
|
217
|
+
loadRuns(selectedPlanId);
|
|
218
|
+
} catch (e) {
|
|
219
|
+
setError((e as Error).message);
|
|
220
|
+
} finally {
|
|
221
|
+
setExecuting(false);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// View run details
|
|
226
|
+
async function handleViewRun(runId: string) {
|
|
227
|
+
try {
|
|
228
|
+
const [run, events] = await Promise.all([getRun(runId), getRunEvents(runId)]);
|
|
229
|
+
setSelectedRun(run);
|
|
230
|
+
setRunEvents(events);
|
|
231
|
+
} catch (e) {
|
|
232
|
+
setError((e as Error).message);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function statusColor(status: string): string {
|
|
237
|
+
if (status === 'completed' || status === 'succeeded') return colors.green;
|
|
238
|
+
if (status === 'failed' || status === 'error') return colors.red;
|
|
239
|
+
if (status === 'running' || status === 'in_progress') return colors.blue;
|
|
240
|
+
if (status === 'pending' || status === 'queued') return colors.yellow;
|
|
241
|
+
return colors.textMuted;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function shortId(id: string): string {
|
|
245
|
+
return id.length > 12 ? id.slice(0, 8) + '...' : id;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function timeAgo(ts: string): string {
|
|
249
|
+
const diff = Date.now() - new Date(ts).getTime();
|
|
250
|
+
const secs = Math.floor(diff / 1000);
|
|
251
|
+
if (secs < 60) return `${secs}s ago`;
|
|
252
|
+
const mins = Math.floor(secs / 60);
|
|
253
|
+
if (mins < 60) return `${mins}m ago`;
|
|
254
|
+
const hrs = Math.floor(mins / 60);
|
|
255
|
+
return `${hrs}h ago`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return (
|
|
259
|
+
<div style={styles.container}>
|
|
260
|
+
<header style={styles.header}>
|
|
261
|
+
<h1 style={styles.title}>Execution Plans <InfoTooltip text="Select a plan, configure inputs, and execute. Each run is tracked with status updates and an event timeline." darkMode={darkMode} /></h1>
|
|
262
|
+
<p style={styles.subtitle}>Run execution plans against the engine and inspect results.</p>
|
|
263
|
+
</header>
|
|
264
|
+
|
|
265
|
+
{error && <div style={styles.error}>{error} <button onClick={() => setError(null)} style={{ ...styles.link, marginLeft: '8px' }}>dismiss</button></div>}
|
|
266
|
+
|
|
267
|
+
{/* Plan Selector */}
|
|
268
|
+
<div style={styles.card}>
|
|
269
|
+
<span style={styles.label}>Select Plan</span>
|
|
270
|
+
<select
|
|
271
|
+
style={styles.select}
|
|
272
|
+
value={selectedPlanId}
|
|
273
|
+
onChange={(e) => setSelectedPlanId(e.target.value)}
|
|
274
|
+
disabled={loading}
|
|
275
|
+
>
|
|
276
|
+
<option value="">{loading ? 'Loading plans...' : '— Select an execution plan —'}</option>
|
|
277
|
+
{plans.map((p) => (
|
|
278
|
+
<option key={p.id} value={p.id}>
|
|
279
|
+
{p.display_name || p.plan_code} [{p.classification || 'general'}]
|
|
280
|
+
</option>
|
|
281
|
+
))}
|
|
282
|
+
</select>
|
|
283
|
+
{selectedPlan && (
|
|
284
|
+
<div style={styles.meta}>
|
|
285
|
+
<strong>{selectedPlan.display_name}</strong>
|
|
286
|
+
{selectedPlan.description && <span> — {selectedPlan.description}</span>}
|
|
287
|
+
{selectedPlan.steps && <span> ({(selectedPlan.steps as unknown[]).length} steps)</span>}
|
|
288
|
+
</div>
|
|
289
|
+
)}
|
|
290
|
+
</div>
|
|
291
|
+
|
|
292
|
+
{/* Input JSON + Execute */}
|
|
293
|
+
{selectedPlanId && (
|
|
294
|
+
<div style={styles.card}>
|
|
295
|
+
<span style={styles.label}>Input JSON</span>
|
|
296
|
+
<textarea
|
|
297
|
+
style={styles.textarea}
|
|
298
|
+
value={inputJson}
|
|
299
|
+
onChange={(e) => setInputJson(e.target.value)}
|
|
300
|
+
spellCheck={false}
|
|
301
|
+
/>
|
|
302
|
+
{jsonError && <div style={{ fontSize: '11px', color: colors.red, marginTop: '4px' }}>{jsonError}</div>}
|
|
303
|
+
<div style={{ marginTop: '12px', display: 'flex', gap: '8px' }}>
|
|
304
|
+
<button
|
|
305
|
+
style={{ ...styles.button, ...styles.buttonPrimary, ...(executing || !!jsonError ? styles.buttonDisabled : {}) }}
|
|
306
|
+
onClick={handleExecute}
|
|
307
|
+
disabled={executing || !!jsonError}
|
|
308
|
+
>
|
|
309
|
+
{executing ? 'Starting...' : 'Execute'}
|
|
310
|
+
</button>
|
|
311
|
+
<button
|
|
312
|
+
style={{ ...styles.button, border: `1px solid ${colors.border}`, background: 'transparent', color: colors.text }}
|
|
313
|
+
onClick={() => loadRuns(selectedPlanId)}
|
|
314
|
+
>
|
|
315
|
+
Refresh Runs
|
|
316
|
+
</button>
|
|
317
|
+
</div>
|
|
318
|
+
</div>
|
|
319
|
+
)}
|
|
320
|
+
|
|
321
|
+
{/* Runs Grid */}
|
|
322
|
+
{selectedPlanId && runs.length > 0 && (
|
|
323
|
+
<div style={styles.card}>
|
|
324
|
+
<div style={styles.sectionTitle}>Runs ({runs.length})</div>
|
|
325
|
+
<table style={styles.table}>
|
|
326
|
+
<thead>
|
|
327
|
+
<tr>
|
|
328
|
+
<th style={styles.th}>Run ID</th>
|
|
329
|
+
<th style={styles.th}>Status</th>
|
|
330
|
+
<th style={styles.th}>Created</th>
|
|
331
|
+
<th style={styles.th}>Actions</th>
|
|
332
|
+
</tr>
|
|
333
|
+
</thead>
|
|
334
|
+
<tbody>
|
|
335
|
+
{runs.map((run) => (
|
|
336
|
+
<tr key={run.id}>
|
|
337
|
+
<td style={{ ...styles.td, ...styles.mono }}>{shortId(run.id)}</td>
|
|
338
|
+
<td style={styles.td}>
|
|
339
|
+
<span style={{ ...styles.badge, background: `${statusColor(run.status)}20`, color: statusColor(run.status) }}>
|
|
340
|
+
{run.status}
|
|
341
|
+
</span>
|
|
342
|
+
</td>
|
|
343
|
+
<td style={{ ...styles.td, color: colors.textMuted }}>{timeAgo(run.created_at)}</td>
|
|
344
|
+
<td style={styles.td}>
|
|
345
|
+
<button style={styles.link} onClick={() => handleViewRun(run.id)}>View</button>
|
|
346
|
+
</td>
|
|
347
|
+
</tr>
|
|
348
|
+
))}
|
|
349
|
+
</tbody>
|
|
350
|
+
</table>
|
|
351
|
+
</div>
|
|
352
|
+
)}
|
|
353
|
+
|
|
354
|
+
{selectedPlanId && runs.length === 0 && !loading && (
|
|
355
|
+
<div style={{ ...styles.card, color: colors.textMuted, textAlign: 'center', padding: '32px' }}>
|
|
356
|
+
No runs yet. Click Execute to start one.
|
|
357
|
+
</div>
|
|
358
|
+
)}
|
|
359
|
+
|
|
360
|
+
{/* Run Detail Panel */}
|
|
361
|
+
{selectedRun && (
|
|
362
|
+
<div style={styles.card}>
|
|
363
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
|
|
364
|
+
<div style={styles.sectionTitle}>
|
|
365
|
+
Run Detail
|
|
366
|
+
<span style={{ ...styles.badge, background: `${statusColor(selectedRun.status)}20`, color: statusColor(selectedRun.status) }}>
|
|
367
|
+
{selectedRun.status}
|
|
368
|
+
</span>
|
|
369
|
+
</div>
|
|
370
|
+
<button style={styles.link} onClick={() => { setSelectedRun(null); setRunEvents([]); }}>Close</button>
|
|
371
|
+
</div>
|
|
372
|
+
|
|
373
|
+
<div style={{ ...styles.grid, marginBottom: '16px' }}>
|
|
374
|
+
<div>
|
|
375
|
+
<span style={styles.label}>Run ID</span>
|
|
376
|
+
<div style={styles.mono}>{selectedRun.id}</div>
|
|
377
|
+
</div>
|
|
378
|
+
<div>
|
|
379
|
+
<span style={styles.label}>Created</span>
|
|
380
|
+
<div style={{ fontSize: '12px' }}>{new Date(selectedRun.created_at).toLocaleString()}</div>
|
|
381
|
+
</div>
|
|
382
|
+
</div>
|
|
383
|
+
|
|
384
|
+
{selectedRun.error && (
|
|
385
|
+
<div style={{ ...styles.error, marginBottom: '12px' }}>
|
|
386
|
+
<strong>Error:</strong> {selectedRun.error}
|
|
387
|
+
</div>
|
|
388
|
+
)}
|
|
389
|
+
|
|
390
|
+
{selectedRun.outputs && Object.keys(selectedRun.outputs).length > 0 && (
|
|
391
|
+
<>
|
|
392
|
+
<span style={styles.label}>Outputs</span>
|
|
393
|
+
<pre style={styles.pre}>{JSON.stringify(selectedRun.outputs, null, 2)}</pre>
|
|
394
|
+
</>
|
|
395
|
+
)}
|
|
396
|
+
|
|
397
|
+
{/* Events Timeline */}
|
|
398
|
+
<div style={{ marginTop: '16px' }}>
|
|
399
|
+
<div style={styles.sectionTitle}>
|
|
400
|
+
Events ({runEvents.length})
|
|
401
|
+
<button style={styles.link} onClick={() => handleViewRun(selectedRun.id)}>Refresh</button>
|
|
402
|
+
</div>
|
|
403
|
+
{runEvents.length === 0 ? (
|
|
404
|
+
<div style={{ color: colors.textMuted, fontSize: '12px' }}>No events yet.</div>
|
|
405
|
+
) : (
|
|
406
|
+
<table style={styles.table}>
|
|
407
|
+
<thead>
|
|
408
|
+
<tr>
|
|
409
|
+
<th style={styles.th}>Time</th>
|
|
410
|
+
<th style={styles.th}>Type</th>
|
|
411
|
+
<th style={styles.th}>Step</th>
|
|
412
|
+
<th style={styles.th}>Detail</th>
|
|
413
|
+
</tr>
|
|
414
|
+
</thead>
|
|
415
|
+
<tbody>
|
|
416
|
+
{runEvents.map((ev) => (
|
|
417
|
+
<tr key={ev.id}>
|
|
418
|
+
<td style={{ ...styles.td, ...styles.mono, whiteSpace: 'nowrap' }}>
|
|
419
|
+
{new Date(ev.created_at).toLocaleTimeString()}
|
|
420
|
+
</td>
|
|
421
|
+
<td style={{ ...styles.td, ...styles.mono }}>{ev.event_type}</td>
|
|
422
|
+
<td style={{ ...styles.td, ...styles.mono, color: colors.textMuted }}>{ev.step_key || '—'}</td>
|
|
423
|
+
<td style={{ ...styles.td, fontSize: '11px', maxWidth: '300px', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
|
424
|
+
{ev.payload ? (typeof ev.payload === 'string' ? ev.payload : JSON.stringify(ev.payload).slice(0, 120)) : '—'}
|
|
425
|
+
</td>
|
|
426
|
+
</tr>
|
|
427
|
+
))}
|
|
428
|
+
</tbody>
|
|
429
|
+
</table>
|
|
430
|
+
)}
|
|
431
|
+
</div>
|
|
432
|
+
</div>
|
|
433
|
+
)}
|
|
434
|
+
</div>
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// =============================================================================
|
|
439
|
+
// HELPERS
|
|
440
|
+
// =============================================================================
|
|
441
|
+
|
|
442
|
+
function buildDefaultInputs(plan: Plan): Record<string, unknown> {
|
|
443
|
+
if (plan.input_schema?.properties) {
|
|
444
|
+
const defaults: Record<string, unknown> = {};
|
|
445
|
+
const required = new Set(plan.input_schema.required || []);
|
|
446
|
+
for (const [key, schema] of Object.entries(plan.input_schema.properties)) {
|
|
447
|
+
const s = schema as Record<string, unknown>;
|
|
448
|
+
if (required.has(key) || Object.keys(plan.input_schema.properties).length <= 5) {
|
|
449
|
+
defaults[key] = s.default !== undefined ? s.default : (s.type === 'number' ? 0 : s.type === 'boolean' ? false : '');
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
return defaults;
|
|
453
|
+
}
|
|
454
|
+
return {};
|
|
455
|
+
}
|
|
@@ -119,6 +119,12 @@ fn dispatch(req: &Request) -> Response {
|
|
|
119
119
|
"nodeSession.ensureIdentity" => handle_ensure_node_identity(&req.id, &req.payload),
|
|
120
120
|
"runtime.info" => handle_runtime_info(&req.id, &req.payload),
|
|
121
121
|
"home.status" => handle_home_status(&req.id, &req.payload),
|
|
122
|
+
"execution.plans.list" => handle_execution_proxy_get(&req.id, &req.payload, "/auth/admin/execution/plans", "execution.plans.list", &["limit", "offset", "search"]),
|
|
123
|
+
"execution.plans.get" => handle_execution_proxy_get_by_id(&req.id, &req.payload, "id", "/auth/admin/execution/plans", "execution.plans.get"),
|
|
124
|
+
"execution.plans.runs.list" => handle_execution_plan_runs_list(&req.id, &req.payload),
|
|
125
|
+
"execution.runs.get" => handle_execution_proxy_get_by_id(&req.id, &req.payload, "runId", "/engine/admin/execution/runs", "execution.runs.get"),
|
|
126
|
+
"execution.runs.events" => handle_execution_runs_events(&req.id, &req.payload),
|
|
127
|
+
"execution.runs.start" => handle_execution_runs_start(&req.id, &req.payload),
|
|
122
128
|
"debug.isDevMode" => handle_is_dev_mode(&req.id),
|
|
123
129
|
_ => Response::err(
|
|
124
130
|
req.id.clone(),
|
|
@@ -1129,6 +1135,199 @@ fn handle_workflow_runs_get(id: &str, payload: &Value) -> Response {
|
|
|
1129
1135
|
}
|
|
1130
1136
|
}
|
|
1131
1137
|
|
|
1138
|
+
// =============================================================================
|
|
1139
|
+
// Execution Plans (engine API proxies)
|
|
1140
|
+
// =============================================================================
|
|
1141
|
+
|
|
1142
|
+
/// Reusable: authenticated GET proxy with optional query params.
|
|
1143
|
+
/// `query_keys` names payload fields to forward as ?key=value.
|
|
1144
|
+
fn handle_execution_proxy_get(
|
|
1145
|
+
id: &str,
|
|
1146
|
+
payload: &Value,
|
|
1147
|
+
path: &str,
|
|
1148
|
+
action: &str,
|
|
1149
|
+
query_keys: &[&str],
|
|
1150
|
+
) -> Response {
|
|
1151
|
+
let engine_url = config::engine_url();
|
|
1152
|
+
let token = match get_cached_or_fresh_token(engine_url) {
|
|
1153
|
+
Ok(t) => t,
|
|
1154
|
+
Err(e) => return Response::err(id.to_string(), "NOT_AUTHENTICATED", &e),
|
|
1155
|
+
};
|
|
1156
|
+
let client = match reqwest::blocking::Client::builder()
|
|
1157
|
+
.timeout(std::time::Duration::from_secs(30))
|
|
1158
|
+
.build()
|
|
1159
|
+
{
|
|
1160
|
+
Ok(c) => c,
|
|
1161
|
+
Err(e) => return Response::err(id.to_string(), "HTTP_CLIENT_ERROR", &e.to_string()),
|
|
1162
|
+
};
|
|
1163
|
+
|
|
1164
|
+
let mut url = format!("{}{}", engine_url, path);
|
|
1165
|
+
|
|
1166
|
+
// Build query string from payload fields
|
|
1167
|
+
let mut qs_parts: Vec<String> = Vec::new();
|
|
1168
|
+
for key in query_keys {
|
|
1169
|
+
if let Some(val) = payload.get(*key) {
|
|
1170
|
+
if let Some(s) = val.as_str() {
|
|
1171
|
+
qs_parts.push(format!("{}={}", key, s));
|
|
1172
|
+
} else if let Some(n) = val.as_i64() {
|
|
1173
|
+
qs_parts.push(format!("{}={}", key, n));
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
if !qs_parts.is_empty() {
|
|
1178
|
+
url = format!("{}?{}", url, qs_parts.join("&"));
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
let request_id = uuid::Uuid::new_v4().to_string();
|
|
1182
|
+
let send = |bearer: &str| -> Result<reqwest::blocking::Response, reqwest::Error> {
|
|
1183
|
+
client
|
|
1184
|
+
.get(&url)
|
|
1185
|
+
.header("Content-Type", "application/json")
|
|
1186
|
+
.header("Authorization", format!("Bearer {}", bearer))
|
|
1187
|
+
.header("X-EKKA-PROOF-TYPE", "jwt")
|
|
1188
|
+
.header("X-REQUEST-ID", &request_id)
|
|
1189
|
+
.header("X-EKKA-CORRELATION-ID", &request_id)
|
|
1190
|
+
.header("X-EKKA-MODULE", "desktop")
|
|
1191
|
+
.header("X-EKKA-ACTION", action)
|
|
1192
|
+
.header("X-EKKA-CLIENT", config::app_slug())
|
|
1193
|
+
.header("X-EKKA-CLIENT-VERSION", "0.2.0")
|
|
1194
|
+
.send()
|
|
1195
|
+
};
|
|
1196
|
+
|
|
1197
|
+
let resp = match send(&token.token) {
|
|
1198
|
+
Ok(r) => r,
|
|
1199
|
+
Err(e) => return Response::err(id.to_string(), "REQUEST_FAILED", &e.to_string()),
|
|
1200
|
+
};
|
|
1201
|
+
|
|
1202
|
+
if resp.status().as_u16() == 401 {
|
|
1203
|
+
clear_auth_cache();
|
|
1204
|
+
let retry_token = match get_cached_or_fresh_token(engine_url) {
|
|
1205
|
+
Ok(t) => t,
|
|
1206
|
+
Err(e) => return Response::err(id.to_string(), "NOT_AUTHENTICATED", &e),
|
|
1207
|
+
};
|
|
1208
|
+
let retry_resp = match send(&retry_token.token) {
|
|
1209
|
+
Ok(r) => r,
|
|
1210
|
+
Err(e) => return Response::err(id.to_string(), "REQUEST_FAILED", &e.to_string()),
|
|
1211
|
+
};
|
|
1212
|
+
return parse_proxy_response(id, retry_resp);
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
parse_proxy_response(id, resp)
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
/// Reusable: authenticated GET proxy for /path/{id}
|
|
1219
|
+
fn handle_execution_proxy_get_by_id(
|
|
1220
|
+
id: &str,
|
|
1221
|
+
payload: &Value,
|
|
1222
|
+
id_field: &str,
|
|
1223
|
+
base_path: &str,
|
|
1224
|
+
action: &str,
|
|
1225
|
+
) -> Response {
|
|
1226
|
+
let resource_id = match payload.get(id_field).and_then(|v| v.as_str()) {
|
|
1227
|
+
Some(v) => v,
|
|
1228
|
+
None => return Response::err(id.to_string(), "INVALID_PAYLOAD", &format!("'{}' is required", id_field)),
|
|
1229
|
+
};
|
|
1230
|
+
let path = format!("{}/{}", base_path, resource_id);
|
|
1231
|
+
handle_execution_proxy_get(id, payload, &path, action, &[])
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
/// execution.plans.runs.list — GET /auth/admin/execution/plans/{planId}/runs
|
|
1235
|
+
fn handle_execution_plan_runs_list(id: &str, payload: &Value) -> Response {
|
|
1236
|
+
let plan_id = match payload.get("planId").and_then(|v| v.as_str()) {
|
|
1237
|
+
Some(v) => v,
|
|
1238
|
+
None => return Response::err(id.to_string(), "INVALID_PAYLOAD", "'planId' is required"),
|
|
1239
|
+
};
|
|
1240
|
+
let path = format!("/auth/admin/execution/plans/{}/runs", plan_id);
|
|
1241
|
+
handle_execution_proxy_get(id, payload, &path, "execution.plans.runs.list", &["limit", "offset"])
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
/// execution.runs.events — GET /engine/admin/execution/runs/{runId}/events
|
|
1245
|
+
fn handle_execution_runs_events(id: &str, payload: &Value) -> Response {
|
|
1246
|
+
let run_id = match payload.get("runId").and_then(|v| v.as_str()) {
|
|
1247
|
+
Some(v) => v,
|
|
1248
|
+
None => return Response::err(id.to_string(), "INVALID_PAYLOAD", "'runId' is required"),
|
|
1249
|
+
};
|
|
1250
|
+
let path = format!("/engine/admin/execution/runs/{}/events", run_id);
|
|
1251
|
+
handle_execution_proxy_get(id, payload, &path, "execution.runs.events", &[])
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
/// execution.runs.start — POST /engine/execution/runs
|
|
1255
|
+
fn handle_execution_runs_start(id: &str, payload: &Value) -> Response {
|
|
1256
|
+
let engine_url = config::engine_url();
|
|
1257
|
+
let token = match get_cached_or_fresh_token(engine_url) {
|
|
1258
|
+
Ok(t) => t,
|
|
1259
|
+
Err(e) => return Response::err(id.to_string(), "NOT_AUTHENTICATED", &e),
|
|
1260
|
+
};
|
|
1261
|
+
let client = match reqwest::blocking::Client::builder()
|
|
1262
|
+
.timeout(std::time::Duration::from_secs(60))
|
|
1263
|
+
.build()
|
|
1264
|
+
{
|
|
1265
|
+
Ok(c) => c,
|
|
1266
|
+
Err(e) => return Response::err(id.to_string(), "HTTP_CLIENT_ERROR", &e.to_string()),
|
|
1267
|
+
};
|
|
1268
|
+
|
|
1269
|
+
let request_id = uuid::Uuid::new_v4().to_string();
|
|
1270
|
+
let body = serde_json::json!({
|
|
1271
|
+
"plan_id": payload.get("plan_id"),
|
|
1272
|
+
"inputs": payload.get("inputs").unwrap_or(&serde_json::json!({})),
|
|
1273
|
+
});
|
|
1274
|
+
|
|
1275
|
+
let send = |bearer: &str| -> Result<reqwest::blocking::Response, reqwest::Error> {
|
|
1276
|
+
client
|
|
1277
|
+
.post(format!("{}/engine/execution/runs", engine_url))
|
|
1278
|
+
.header("Content-Type", "application/json")
|
|
1279
|
+
.header("Authorization", format!("Bearer {}", bearer))
|
|
1280
|
+
.header("X-EKKA-PROOF-TYPE", "jwt")
|
|
1281
|
+
.header("X-REQUEST-ID", &request_id)
|
|
1282
|
+
.header("X-EKKA-CORRELATION-ID", &request_id)
|
|
1283
|
+
.header("X-EKKA-MODULE", "desktop")
|
|
1284
|
+
.header("X-EKKA-ACTION", "execution.runs.start")
|
|
1285
|
+
.header("X-EKKA-CLIENT", config::app_slug())
|
|
1286
|
+
.header("X-EKKA-CLIENT-VERSION", "0.2.0")
|
|
1287
|
+
.json(&body)
|
|
1288
|
+
.send()
|
|
1289
|
+
};
|
|
1290
|
+
|
|
1291
|
+
let resp = match send(&token.token) {
|
|
1292
|
+
Ok(r) => r,
|
|
1293
|
+
Err(e) => return Response::err(id.to_string(), "REQUEST_FAILED", &e.to_string()),
|
|
1294
|
+
};
|
|
1295
|
+
|
|
1296
|
+
if resp.status().as_u16() == 401 {
|
|
1297
|
+
clear_auth_cache();
|
|
1298
|
+
let retry_token = match get_cached_or_fresh_token(engine_url) {
|
|
1299
|
+
Ok(t) => t,
|
|
1300
|
+
Err(e) => return Response::err(id.to_string(), "NOT_AUTHENTICATED", &e),
|
|
1301
|
+
};
|
|
1302
|
+
let retry_resp = match send(&retry_token.token) {
|
|
1303
|
+
Ok(r) => r,
|
|
1304
|
+
Err(e) => return Response::err(id.to_string(), "REQUEST_FAILED", &e.to_string()),
|
|
1305
|
+
};
|
|
1306
|
+
return parse_proxy_response(id, retry_resp);
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
parse_proxy_response(id, resp)
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
/// Parse an engine HTTP response into a JSON-RPC Response (reused by all execution proxies)
|
|
1313
|
+
fn parse_proxy_response(id: &str, resp: reqwest::blocking::Response) -> Response {
|
|
1314
|
+
let status = resp.status();
|
|
1315
|
+
if status.is_success() {
|
|
1316
|
+
match resp.json::<serde_json::Value>() {
|
|
1317
|
+
Ok(data) => Response::ok(id.to_string(), data),
|
|
1318
|
+
Err(e) => Response::err(id.to_string(), "PARSE_ERROR", &e.to_string()),
|
|
1319
|
+
}
|
|
1320
|
+
} else {
|
|
1321
|
+
let status_code = status.as_u16();
|
|
1322
|
+
let body = resp.text().unwrap_or_default();
|
|
1323
|
+
let msg = serde_json::from_str::<Value>(&body)
|
|
1324
|
+
.ok()
|
|
1325
|
+
.and_then(|v| v.get("message").or(v.get("error")).and_then(|m| m.as_str()).map(|s| s.to_string()))
|
|
1326
|
+
.unwrap_or_else(|| format!("HTTP {}", status_code));
|
|
1327
|
+
Response::err(id.to_string(), "EXECUTION_PROXY_FAILED", &format!("HTTP {}: {}", status_code, msg))
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1132
1331
|
// =============================================================================
|
|
1133
1332
|
// Debug (stateless)
|
|
1134
1333
|
// =============================================================================
|
|
@@ -319,6 +319,14 @@ pub fn engine_request(req: EngineRequest, state: State<EngineState>, app_handle:
|
|
|
319
319
|
handlers::vault::handle_audit_list(&req.payload, &state)
|
|
320
320
|
}
|
|
321
321
|
|
|
322
|
+
// Execution Plans (proxied to Desktop Core → engine API)
|
|
323
|
+
"execution.plans.list" => state.core_process.request("execution.plans.list", &req.payload),
|
|
324
|
+
"execution.plans.get" => state.core_process.request("execution.plans.get", &req.payload),
|
|
325
|
+
"execution.plans.runs.list" => state.core_process.request("execution.plans.runs.list", &req.payload),
|
|
326
|
+
"execution.runs.get" => state.core_process.request("execution.runs.get", &req.payload),
|
|
327
|
+
"execution.runs.events" => state.core_process.request("execution.runs.events", &req.payload),
|
|
328
|
+
"execution.runs.start" => state.core_process.request("execution.runs.start", &req.payload),
|
|
329
|
+
|
|
322
330
|
// Unknown
|
|
323
331
|
_ => EngineResponse::err("INVALID_OP", &format!("Unknown operation: {}", req.op)),
|
|
324
332
|
}
|