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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-ekka-desktop-app",
3
- "version": "0.4.7",
3
+ "version": "0.4.9",
4
4
  "description": "Create an EKKA desktop app with built-in demo backend. No setup required.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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: 'absolute',
38
- bottom: '100%',
39
- left: '50%',
40
- transform: 'translateX(-50%)',
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: 1000,
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={() => setIsVisible(true)}
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
  }