agent-state-machine 1.4.2 → 2.0.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 +2 -6
- package/bin/cli.js +4 -19
- package/lib/ui/index.html +339 -114
- package/package.json +1 -1
- package/vercel-server/local-server.js +30 -329
- package/vercel-server/public/index.html +17 -5
- package/lib/ui/server.js +0 -150
package/README.md
CHANGED
|
@@ -45,7 +45,7 @@ Requirements: Node.js >= 16.
|
|
|
45
45
|
state-machine --setup <workflow-name>
|
|
46
46
|
state-machine run <workflow-name>
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
|
|
49
49
|
state-machine history <workflow-name> [limit]
|
|
50
50
|
state-machine reset <workflow-name> (clears memory/state)
|
|
51
51
|
state-machine reset-hard <workflow-name> (clears everything: history/interactions/memory)
|
|
@@ -298,11 +298,7 @@ export const config = {
|
|
|
298
298
|
};
|
|
299
299
|
```
|
|
300
300
|
|
|
301
|
-
The runtime captures the fully-built prompt in `state/history.jsonl`, viewable in the browser with live updates via
|
|
302
|
-
|
|
303
|
-
```bash
|
|
304
|
-
state-machine follow <workflow-name>
|
|
305
|
-
```
|
|
301
|
+
The runtime captures the fully-built prompt in `state/history.jsonl`, viewable in the browser with live updates when running with the `--local` flag or via the remote URL.
|
|
306
302
|
|
|
307
303
|
---
|
|
308
304
|
|
package/bin/cli.js
CHANGED
|
@@ -5,7 +5,7 @@ import fs from 'fs';
|
|
|
5
5
|
import { pathToFileURL, fileURLToPath } from 'url';
|
|
6
6
|
import { WorkflowRuntime } from '../lib/index.js';
|
|
7
7
|
import { setup } from '../lib/setup.js';
|
|
8
|
-
|
|
8
|
+
|
|
9
9
|
import { startLocalServer } from '../vercel-server/local-server.js';
|
|
10
10
|
|
|
11
11
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -34,8 +34,8 @@ Agent State Machine CLI (Native JS Workflows Only) v${getVersion()}
|
|
|
34
34
|
Usage:
|
|
35
35
|
state-machine --setup <workflow-name> Create a new workflow project
|
|
36
36
|
state-machine run <workflow-name> Run a workflow (remote follow enabled by default)
|
|
37
|
-
state-machine run <workflow-name>
|
|
38
|
-
|
|
37
|
+
state-machine run <workflow-name> -l Run with local server (localhost:3000)
|
|
38
|
+
|
|
39
39
|
state-machine status [workflow-name] Show current state (or list all)
|
|
40
40
|
state-machine history <workflow-name> [limit] Show execution history logs
|
|
41
41
|
state-machine reset <workflow-name> Reset workflow state (clears memory/state)
|
|
@@ -269,22 +269,7 @@ async function main() {
|
|
|
269
269
|
}
|
|
270
270
|
break;
|
|
271
271
|
|
|
272
|
-
|
|
273
|
-
if (!workflowName) {
|
|
274
|
-
console.error('Error: Workflow name required');
|
|
275
|
-
console.error('Usage: state-machine follow <workflow-name>');
|
|
276
|
-
process.exit(1);
|
|
277
|
-
}
|
|
278
|
-
{
|
|
279
|
-
const workflowDir = resolveWorkflowDir(workflowName);
|
|
280
|
-
if (!fs.existsSync(workflowDir)) {
|
|
281
|
-
console.error(`Error: Workflow '${workflowName}' not found`);
|
|
282
|
-
process.exit(1);
|
|
283
|
-
}
|
|
284
|
-
startServer(workflowDir);
|
|
285
|
-
// Do not exit, server needs to stay alive
|
|
286
|
-
}
|
|
287
|
-
break;
|
|
272
|
+
|
|
288
273
|
|
|
289
274
|
case 'reset':
|
|
290
275
|
if (!workflowName) {
|
package/lib/ui/index.html
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<head>
|
|
5
5
|
<meta charset="UTF-8">
|
|
6
6
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
7
|
-
<title>
|
|
7
|
+
<title>{{WORKFLOW_NAME}} - Remote Follow</title>
|
|
8
8
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
9
9
|
<script>
|
|
10
10
|
tailwind.config = {
|
|
@@ -38,14 +38,36 @@
|
|
|
38
38
|
.dark ::-webkit-scrollbar-thumb:hover {
|
|
39
39
|
background: #3f3f46;
|
|
40
40
|
}
|
|
41
|
+
|
|
42
|
+
@keyframes pulse {
|
|
43
|
+
|
|
44
|
+
0%,
|
|
45
|
+
100% {
|
|
46
|
+
opacity: 1;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
50% {
|
|
50
|
+
opacity: .7;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.animate-pulse-slow {
|
|
55
|
+
animation: pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
|
56
|
+
}
|
|
41
57
|
</style>
|
|
42
58
|
</head>
|
|
43
59
|
|
|
44
60
|
<body>
|
|
45
61
|
<div id="root"></div>
|
|
46
62
|
|
|
63
|
+
<script>
|
|
64
|
+
// Placeholders replaced by server
|
|
65
|
+
window.SESSION_TOKEN = '{{SESSION_TOKEN}}';
|
|
66
|
+
window.WORKFLOW_NAME_TEMPLATE = '{{WORKFLOW_NAME}}';
|
|
67
|
+
</script>
|
|
68
|
+
|
|
47
69
|
<script type="text/babel">
|
|
48
|
-
const { useState, useEffect } = React;
|
|
70
|
+
const { useState, useEffect, useRef } = React;
|
|
49
71
|
|
|
50
72
|
// Icons
|
|
51
73
|
const SunIcon = () => (
|
|
@@ -72,6 +94,25 @@
|
|
|
72
94
|
</svg>
|
|
73
95
|
);
|
|
74
96
|
|
|
97
|
+
function StatusBadge({ status }) {
|
|
98
|
+
const colors = {
|
|
99
|
+
connected: 'bg-green-500',
|
|
100
|
+
disconnected: 'bg-red-500',
|
|
101
|
+
connecting: 'bg-yellow-500 animate-pulse-slow',
|
|
102
|
+
};
|
|
103
|
+
const labels = {
|
|
104
|
+
connected: 'Live',
|
|
105
|
+
disconnected: 'Offline',
|
|
106
|
+
connecting: 'Connecting...',
|
|
107
|
+
};
|
|
108
|
+
return (
|
|
109
|
+
<div className="flex items-center gap-2">
|
|
110
|
+
<div className={`w-2 h-2 rounded-full ${colors[status] || colors.disconnected}`}></div>
|
|
111
|
+
<span className="text-[10px] uppercase tracking-wider text-zinc-500 font-bold">{labels[status] || status}</span>
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
75
116
|
function CopyButton({ text, className }) {
|
|
76
117
|
const [copied, setCopied] = useState(false);
|
|
77
118
|
|
|
@@ -101,8 +142,7 @@
|
|
|
101
142
|
const rawContent = isObject ? JSON.stringify(data, null, 2) : String(data);
|
|
102
143
|
const lineBreak = '';
|
|
103
144
|
|
|
104
|
-
//
|
|
105
|
-
// Headers render in CAPS, BOLD, and BLUE.
|
|
145
|
+
// "clean" view loops through ALL top-level keys.
|
|
106
146
|
let cleanParts = null;
|
|
107
147
|
if (isObject) {
|
|
108
148
|
const keys = Object.keys(data);
|
|
@@ -117,7 +157,7 @@
|
|
|
117
157
|
<div className="text-[11px] font-extrabold uppercase tracking-wider text-blue-600 dark:text-blue-400 mb-2">
|
|
118
158
|
{k}
|
|
119
159
|
</div>
|
|
120
|
-
<div className="whitespace-pre-wrap">
|
|
160
|
+
<div className="whitespace-pre-wrap leading-relaxed">
|
|
121
161
|
{prettyVal}
|
|
122
162
|
</div>
|
|
123
163
|
</div>
|
|
@@ -126,9 +166,7 @@
|
|
|
126
166
|
}
|
|
127
167
|
}
|
|
128
168
|
|
|
129
|
-
|
|
130
|
-
const rawContentUnescaped = rawContent; // keep raw exact
|
|
131
|
-
|
|
169
|
+
const rawContentUnescaped = rawContent;
|
|
132
170
|
const hasToggle = isObject || rawContent.includes('\\n');
|
|
133
171
|
|
|
134
172
|
if (onTop) {
|
|
@@ -193,107 +231,272 @@
|
|
|
193
231
|
);
|
|
194
232
|
}
|
|
195
233
|
|
|
234
|
+
function InteractionForm({ interaction, onSubmit, disabled }) {
|
|
235
|
+
const [response, setResponse] = useState('');
|
|
236
|
+
const [submitting, setSubmitting] = useState(false);
|
|
237
|
+
|
|
238
|
+
const handleSubmit = async (e) => {
|
|
239
|
+
e.preventDefault();
|
|
240
|
+
if (!response.trim() || submitting) return;
|
|
241
|
+
setSubmitting(true);
|
|
242
|
+
try {
|
|
243
|
+
await onSubmit(interaction.slug, interaction.targetKey, response.trim());
|
|
244
|
+
setResponse('');
|
|
245
|
+
} finally {
|
|
246
|
+
setSubmitting(false);
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
return (
|
|
251
|
+
<div className="bg-yellow-100/50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-700/50 rounded-2xl p-6 mb-8 transition-all hover:border-yellow-300 dark:hover:border-yellow-600/50">
|
|
252
|
+
<div className="text-[10px] font-black uppercase tracking-[0.2em] text-yellow-600 dark:text-yellow-500 mb-3 flex items-center gap-2">
|
|
253
|
+
<span className="w-2 h-2 rounded-full bg-yellow-500 animate-pulse"></span>
|
|
254
|
+
Input Required
|
|
255
|
+
</div>
|
|
256
|
+
<div className="text-sm text-yellow-900 dark:text-yellow-100/90 mb-5 whitespace-pre-wrap leading-relaxed italic">
|
|
257
|
+
{interaction.question || 'Please provide your input.'}
|
|
258
|
+
</div>
|
|
259
|
+
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
|
260
|
+
<textarea
|
|
261
|
+
value={response}
|
|
262
|
+
onChange={(e) => setResponse(e.target.value)}
|
|
263
|
+
className="w-full p-4 bg-white dark:bg-zinc-800 border border-yellow-200 dark:border-zinc-700 rounded-xl text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 focus:outline-none focus:ring-1 focus:ring-yellow-500 transition-all min-h-[100px]"
|
|
264
|
+
placeholder="Type your response here..."
|
|
265
|
+
disabled={submitting || disabled}
|
|
266
|
+
/>
|
|
267
|
+
<div className="flex justify-end items-center gap-4">
|
|
268
|
+
{disabled && <span className="text-[10px] uppercase font-bold text-red-500 tracking-wider">CLI Offline</span>}
|
|
269
|
+
<button
|
|
270
|
+
type="submit"
|
|
271
|
+
disabled={submitting || disabled || !response.trim()}
|
|
272
|
+
className="px-6 py-2.5 bg-yellow-500 hover:bg-yellow-600 text-white font-bold text-xs uppercase tracking-[0.15em] rounded-lg disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-sm"
|
|
273
|
+
>
|
|
274
|
+
{submitting ? 'Submitting...' : 'Submit Response'}
|
|
275
|
+
</button>
|
|
276
|
+
</div>
|
|
277
|
+
</form>
|
|
278
|
+
</div>
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
196
282
|
function App() {
|
|
197
283
|
const [history, setHistory] = useState([]);
|
|
198
284
|
const [loading, setLoading] = useState(true);
|
|
199
285
|
const [error, setError] = useState(null);
|
|
200
|
-
const [
|
|
286
|
+
const [status, setStatus] = useState('connecting');
|
|
287
|
+
const [workflowName, setWorkflowName] = useState(window.WORKFLOW_NAME_TEMPLATE || '');
|
|
201
288
|
const [theme, setTheme] = useState('dark');
|
|
202
|
-
const [
|
|
289
|
+
const [sortNewest, setSortNewest] = useState(true);
|
|
290
|
+
const [pendingInteraction, setPendingInteraction] = useState(null);
|
|
291
|
+
|
|
292
|
+
const token = window.SESSION_TOKEN && window.SESSION_TOKEN !== '{{' + 'SESSION_TOKEN' + '}}' ? window.SESSION_TOKEN : null;
|
|
293
|
+
|
|
294
|
+
// Build API URLs
|
|
295
|
+
const historyUrl = token ? `/api/history/${token}` : '/api/history';
|
|
296
|
+
const eventsUrl = token ? `/api/events/${token}` : '/api/events';
|
|
297
|
+
const submitUrl = token ? `/api/submit/${token}` : '/api/submit';
|
|
203
298
|
|
|
299
|
+
// Detect pending interactions
|
|
204
300
|
useEffect(() => {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
301
|
+
if (history.length === 0) {
|
|
302
|
+
setPendingInteraction(null);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const resolvedSlugs = new Set();
|
|
307
|
+
let pending = null;
|
|
308
|
+
|
|
309
|
+
for (const entry of history) {
|
|
310
|
+
const isResolution = entry.event === 'INTERACTION_RESOLVED' ||
|
|
311
|
+
entry.event === 'PROMPT_ANSWERED' ||
|
|
312
|
+
entry.event === 'INTERACTION_SUBMITTED';
|
|
313
|
+
const isRequest = entry.event === 'INTERACTION_REQUESTED' ||
|
|
314
|
+
entry.event === 'PROMPT_REQUESTED';
|
|
315
|
+
|
|
316
|
+
if (isResolution && entry.slug) {
|
|
317
|
+
resolvedSlugs.add(entry.slug);
|
|
318
|
+
}
|
|
218
319
|
|
|
219
|
-
|
|
320
|
+
if (isRequest && entry.slug && !resolvedSlugs.has(entry.slug) && !pending) {
|
|
321
|
+
pending = {
|
|
322
|
+
slug: entry.slug,
|
|
323
|
+
targetKey: entry.targetKey || `_interaction_${entry.slug}`,
|
|
324
|
+
question: entry.question,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
setPendingInteraction(pending);
|
|
330
|
+
}, [history]);
|
|
331
|
+
|
|
332
|
+
const fetchData = async () => {
|
|
333
|
+
try {
|
|
334
|
+
const res = await fetch(historyUrl);
|
|
335
|
+
const data = await res.json();
|
|
336
|
+
if (data.entries) setHistory(data.entries);
|
|
337
|
+
if (data.workflowName) setWorkflowName(data.workflowName);
|
|
338
|
+
|
|
339
|
+
// In remote mode, the API also tells us connectivity
|
|
340
|
+
if (token && data.cliConnected !== undefined) {
|
|
341
|
+
setStatus(data.cliConnected ? 'connected' : 'disconnected');
|
|
342
|
+
} else if (!token) {
|
|
343
|
+
// Local mode is always "connected" if the page loads
|
|
344
|
+
setStatus('connected');
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
setLoading(false);
|
|
348
|
+
return true;
|
|
349
|
+
} catch (err) {
|
|
350
|
+
setError(err.message);
|
|
351
|
+
setLoading(false);
|
|
352
|
+
return false;
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
useEffect(() => {
|
|
220
357
|
fetchData();
|
|
221
358
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
359
|
+
let eventSource = null;
|
|
360
|
+
let reconnectTimeout = null;
|
|
361
|
+
let pollInterval = null;
|
|
362
|
+
let reconnectAttempts = 0;
|
|
363
|
+
|
|
364
|
+
const connect = () => {
|
|
365
|
+
if (eventSource) eventSource.close();
|
|
366
|
+
eventSource = new EventSource(eventsUrl);
|
|
367
|
+
|
|
368
|
+
eventSource.onopen = () => {
|
|
369
|
+
setStatus('connected');
|
|
370
|
+
reconnectAttempts = 0;
|
|
226
371
|
fetchData();
|
|
227
|
-
}
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
eventSource.onerror = () => {
|
|
375
|
+
setStatus('disconnected');
|
|
376
|
+
eventSource.close();
|
|
377
|
+
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 10000);
|
|
378
|
+
reconnectAttempts++;
|
|
379
|
+
reconnectTimeout = setTimeout(connect, delay);
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
eventSource.onmessage = (e) => {
|
|
383
|
+
try {
|
|
384
|
+
if (e.data === 'update') {
|
|
385
|
+
fetchData();
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const data = JSON.parse(e.data);
|
|
390
|
+
switch (data.type) {
|
|
391
|
+
case 'status':
|
|
392
|
+
setStatus(data.cliConnected ? 'connected' : 'disconnected');
|
|
393
|
+
if (data.workflowName) setWorkflowName(data.workflowName);
|
|
394
|
+
break;
|
|
395
|
+
case 'history': setHistory(data.entries || []); break;
|
|
396
|
+
case 'event':
|
|
397
|
+
setHistory(prev => {
|
|
398
|
+
if (data.event === 'INTERACTION_SUBMITTED' && data.slug) {
|
|
399
|
+
const hasDupe = prev.some(e =>
|
|
400
|
+
e.event === 'INTERACTION_SUBMITTED' && e.slug === data.slug
|
|
401
|
+
);
|
|
402
|
+
if (hasDupe) return prev;
|
|
403
|
+
}
|
|
404
|
+
return [data, ...prev];
|
|
405
|
+
});
|
|
406
|
+
break;
|
|
407
|
+
case 'cli_connected':
|
|
408
|
+
case 'cli_reconnected': setStatus('connected'); break;
|
|
409
|
+
case 'cli_disconnected': setStatus('disconnected'); break;
|
|
410
|
+
}
|
|
411
|
+
} catch (err) { /* Not JSON or update ping */ }
|
|
412
|
+
};
|
|
228
413
|
};
|
|
229
414
|
|
|
415
|
+
connect();
|
|
416
|
+
pollInterval = setInterval(fetchData, 10000);
|
|
417
|
+
|
|
230
418
|
return () => {
|
|
231
|
-
eventSource.close();
|
|
419
|
+
if (eventSource) eventSource.close();
|
|
420
|
+
if (reconnectTimeout) clearTimeout(reconnectTimeout);
|
|
421
|
+
if (pollInterval) clearInterval(pollInterval);
|
|
232
422
|
};
|
|
233
423
|
}, []);
|
|
234
424
|
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
425
|
+
const handleSubmit = async (slug, targetKey, response) => {
|
|
426
|
+
const optimisticEvent = {
|
|
427
|
+
timestamp: new Date().toISOString(),
|
|
428
|
+
event: 'INTERACTION_SUBMITTED',
|
|
429
|
+
slug,
|
|
430
|
+
targetKey,
|
|
431
|
+
answer: response.substring(0, 200) + (response.length > 200 ? '...' : ''),
|
|
432
|
+
source: 'remote',
|
|
433
|
+
};
|
|
434
|
+
setHistory(prev => [optimisticEvent, ...prev]);
|
|
238
435
|
|
|
239
|
-
|
|
240
|
-
|
|
436
|
+
try {
|
|
437
|
+
const res = await fetch(submitUrl, {
|
|
438
|
+
method: 'POST',
|
|
439
|
+
headers: { 'Content-Type': 'application/json' },
|
|
440
|
+
body: JSON.stringify({ slug, targetKey, response }),
|
|
441
|
+
});
|
|
442
|
+
if (!res.ok) {
|
|
443
|
+
setHistory(prev => prev.filter(e => e !== optimisticEvent));
|
|
444
|
+
const error = await res.json();
|
|
445
|
+
throw new Error(error.error || 'Failed to submit');
|
|
446
|
+
}
|
|
447
|
+
setTimeout(fetchData, 1000);
|
|
448
|
+
} catch (err) {
|
|
449
|
+
setHistory(prev => prev.filter(e => e !== optimisticEvent));
|
|
450
|
+
alert(err.message);
|
|
451
|
+
}
|
|
241
452
|
};
|
|
242
453
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
<div className="flex items-center justify-center h-screen bg-gray-50 dark:bg-black text-gray-500 dark:text-zinc-500">
|
|
246
|
-
Loading history...
|
|
247
|
-
</div>
|
|
248
|
-
</div>
|
|
249
|
-
);
|
|
454
|
+
const toggleTheme = () => setTheme(prev => prev === 'dark' ? 'light' : 'dark');
|
|
455
|
+
const toggleSort = () => setSortNewest(prev => !prev);
|
|
250
456
|
|
|
251
|
-
if (
|
|
457
|
+
if (loading && !history.length) return (
|
|
252
458
|
<div className={theme}>
|
|
253
|
-
<div className="flex items-center justify-center h-screen bg-gray-50 dark:bg-black text-
|
|
254
|
-
|
|
459
|
+
<div className="flex items-center justify-center h-screen bg-gray-50 dark:bg-black text-zinc-500 uppercase tracking-widest text-[10px] font-black">
|
|
460
|
+
Opening Terminal...
|
|
255
461
|
</div>
|
|
256
462
|
</div>
|
|
257
463
|
);
|
|
258
464
|
|
|
259
|
-
|
|
260
|
-
// We only filter out nulls or malformed entries if any
|
|
261
|
-
let visibleEvents = history;
|
|
262
|
-
|
|
263
|
-
// Apply Sort
|
|
264
|
-
// History from API is "Newest First" (index 0 is latest)
|
|
265
|
-
if (sortOrder === 'oldest') {
|
|
266
|
-
visibleEvents = [...visibleEvents].reverse();
|
|
267
|
-
}
|
|
268
|
-
|
|
465
|
+
let visibleEvents = sortNewest ? history : [...history].reverse();
|
|
269
466
|
const formatTime = (ts) => new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
270
467
|
|
|
271
468
|
return (
|
|
272
469
|
<div className={theme}>
|
|
273
|
-
<div className="min-h-screen bg-gray-50 dark:bg-black transition-colors duration-
|
|
274
|
-
<div className="max-w-
|
|
470
|
+
<div className="min-h-screen bg-gray-50 dark:bg-black transition-colors duration-500">
|
|
471
|
+
<div className="max-w-4xl mx-auto min-h-screen flex flex-col p-6">
|
|
275
472
|
|
|
276
473
|
{/* Sticky Header */}
|
|
277
|
-
<header className="sticky top-0 z-50 py-
|
|
278
|
-
<div
|
|
279
|
-
<h1 className="text-xl font-
|
|
280
|
-
|
|
474
|
+
<header className="sticky top-0 z-50 py-6 bg-gray-50/90 dark:bg-black/90 backdrop-blur-md border-b border-gray-200 dark:border-zinc-800 flex items-center justify-between mb-8 transition-all">
|
|
475
|
+
<div>
|
|
476
|
+
<h1 className="text-xl font-black text-gray-800 dark:text-zinc-100 transition-colors uppercase tracking-tight">
|
|
477
|
+
{workflowName || 'Workflow'}
|
|
478
|
+
</h1>
|
|
479
|
+
<div className="flex items-center gap-3 mt-1.5">
|
|
480
|
+
<div className="text-zinc-400 dark:text-zinc-600 text-[10px] font-black uppercase tracking-widest">
|
|
481
|
+
{token ? 'Remote Follow' : 'Local Terminal'}
|
|
482
|
+
</div>
|
|
483
|
+
<div className="w-1 h-1 rounded-full bg-zinc-300 dark:bg-zinc-800"></div>
|
|
484
|
+
<StatusBadge status={status} />
|
|
485
|
+
</div>
|
|
281
486
|
</div>
|
|
282
|
-
<div className="flex items-center space-x-
|
|
487
|
+
<div className="flex items-center space-x-3">
|
|
283
488
|
<button
|
|
284
489
|
onClick={toggleSort}
|
|
285
|
-
className="p-2 rounded-
|
|
286
|
-
title={
|
|
490
|
+
className="p-2.5 rounded-xl bg-gray-200 dark:bg-zinc-900 text-gray-600 dark:text-zinc-400 hover:text-blue-500 transition-all border border-transparent hover:border-gray-300 dark:hover:border-zinc-700"
|
|
491
|
+
title={sortNewest ? "Sort: Newest First" : "Sort: Oldest First"}
|
|
287
492
|
>
|
|
288
|
-
{
|
|
289
|
-
<
|
|
290
|
-
|
|
291
|
-
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4h13M3 8h9m-9 4h6m4 0l4-4m0 0l4 4m-4-4v12" transform="scale(1, -1) translate(0, -24)" /></svg>
|
|
292
|
-
}
|
|
493
|
+
<svg xmlns="http://www.w3.org/2000/svg" className={`h-5 w-5 transition-transform ${!sortNewest ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
494
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4h13M3 8h9m-9 4h6m4 0l4-4m0 0l4 4m-4-4v12" />
|
|
495
|
+
</svg>
|
|
293
496
|
</button>
|
|
294
497
|
<button
|
|
295
498
|
onClick={toggleTheme}
|
|
296
|
-
className="p-2 rounded-
|
|
499
|
+
className="p-2.5 rounded-xl bg-gray-200 dark:bg-zinc-900 text-gray-600 dark:text-zinc-400 hover:text-blue-500 transition-all border border-transparent hover:border-gray-300 dark:hover:border-zinc-700"
|
|
297
500
|
title="Toggle Theme"
|
|
298
501
|
>
|
|
299
502
|
{theme === 'dark' ? <SunIcon /> : <MoonIcon />}
|
|
@@ -301,10 +504,30 @@
|
|
|
301
504
|
</div>
|
|
302
505
|
</header>
|
|
303
506
|
|
|
507
|
+
{/* Pending Interaction at Top */}
|
|
508
|
+
{pendingInteraction && (
|
|
509
|
+
<InteractionForm
|
|
510
|
+
interaction={pendingInteraction}
|
|
511
|
+
onSubmit={handleSubmit}
|
|
512
|
+
disabled={status !== 'connected'}
|
|
513
|
+
/>
|
|
514
|
+
)}
|
|
515
|
+
|
|
516
|
+
{/* Disconnected Warning */}
|
|
517
|
+
{status === 'disconnected' && !pendingInteraction && (
|
|
518
|
+
<div className="bg-red-50 dark:bg-red-900/10 border border-red-200 dark:border-red-900/40 rounded-2xl p-4 mb-8 text-center">
|
|
519
|
+
<div className="text-[10px] font-black uppercase tracking-widest text-red-600 dark:text-red-500">
|
|
520
|
+
Terminal Connection Lost • Retrying...
|
|
521
|
+
</div>
|
|
522
|
+
</div>
|
|
523
|
+
)}
|
|
524
|
+
|
|
304
525
|
{/* Content */}
|
|
305
|
-
<div className="flex-1 space-y-
|
|
526
|
+
<div className="flex-1 space-y-12">
|
|
306
527
|
{visibleEvents.length === 0 && (
|
|
307
|
-
<div className="text-center text-
|
|
528
|
+
<div className="text-center text-zinc-400 dark:text-zinc-700 py-20 uppercase text-[10px] font-bold tracking-[0.3em]">
|
|
529
|
+
Waiting for events...
|
|
530
|
+
</div>
|
|
308
531
|
)}
|
|
309
532
|
|
|
310
533
|
{visibleEvents.map((item, idx) => {
|
|
@@ -312,19 +535,21 @@
|
|
|
312
535
|
if (item.event.startsWith('WORKFLOW_')) {
|
|
313
536
|
const colorMap = {
|
|
314
537
|
'WORKFLOW_STARTED': 'text-green-500 dark:text-green-400',
|
|
315
|
-
'WORKFLOW_COMPLETED': 'text-blue-
|
|
538
|
+
'WORKFLOW_COMPLETED': 'text-blue-510 dark:text-blue-400',
|
|
316
539
|
'WORKFLOW_FAILED': 'text-red-500 dark:text-red-400',
|
|
317
540
|
'WORKFLOW_RESET': 'text-yellow-500 dark:text-yellow-400'
|
|
318
541
|
};
|
|
319
542
|
return (
|
|
320
543
|
<div key={idx} className="flex flex-col items-center py-4">
|
|
321
|
-
<div className="flex items-center space-x-
|
|
322
|
-
<div className="h-px w-
|
|
323
|
-
<span className={colorMap[item.event]
|
|
324
|
-
|
|
325
|
-
|
|
544
|
+
<div className="flex items-center space-x-4 text-[10px] uppercase tracking-[0.25em] font-black text-zinc-300 dark:text-zinc-800">
|
|
545
|
+
<div className="h-px w-10 bg-current opacity-20"></div>
|
|
546
|
+
<span className={`${colorMap[item.event] || 'text-zinc-500'} dark:opacity-80`}>
|
|
547
|
+
{item.event.replace('WORKFLOW_', '')}
|
|
548
|
+
</span>
|
|
549
|
+
<span className="text-zinc-400 dark:text-zinc-700 font-medium tracking-normal">{formatTime(item.timestamp)}</span>
|
|
550
|
+
<div className="h-px w-10 bg-current opacity-20"></div>
|
|
326
551
|
</div>
|
|
327
|
-
{item.error && <div className="mt-2 text-red-500 text-xs font-mono">{item.error}</div>}
|
|
552
|
+
{item.error && <div className="mt-2 text-red-500 text-xs font-mono max-w-md text-center">{item.error}</div>}
|
|
328
553
|
</div>
|
|
329
554
|
);
|
|
330
555
|
}
|
|
@@ -333,9 +558,9 @@
|
|
|
333
558
|
if (item.event === 'AGENT_STARTED') {
|
|
334
559
|
return (
|
|
335
560
|
<div key={idx} className="flex justify-start">
|
|
336
|
-
<div className="text-[10px] text-zinc-400 dark:text-zinc-600 uppercase tracking-widest flex items-center space-x-2">
|
|
561
|
+
<div className="text-[10px] text-zinc-400 dark:text-zinc-600 uppercase tracking-widest font-bold flex items-center space-x-2">
|
|
337
562
|
<span className="w-1.5 h-1.5 rounded-full bg-blue-500/50 animate-pulse"></span>
|
|
338
|
-
<span>Agent <span className="text-zinc-
|
|
563
|
+
<span>Agent <span className="text-zinc-800 dark:text-zinc-300">{item.agent}</span> started</span>
|
|
339
564
|
<span>•</span>
|
|
340
565
|
<span>{formatTime(item.timestamp)}</span>
|
|
341
566
|
</div>
|
|
@@ -347,9 +572,9 @@
|
|
|
347
572
|
if (item.event === 'AGENT_FAILED') {
|
|
348
573
|
return (
|
|
349
574
|
<div key={idx} className="flex justify-center">
|
|
350
|
-
<div className="bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-900/50 rounded-
|
|
351
|
-
<div className="font-
|
|
352
|
-
<div>{item.error}</div>
|
|
575
|
+
<div className="bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-900/50 rounded-2xl px-6 py-4 text-red-600 dark:text-red-400 text-xs font-mono w-full max-w-2xl shadow-sm">
|
|
576
|
+
<div className="font-black mb-2 uppercase tracking-tight">AGENT FAILED: {item.agent}</div>
|
|
577
|
+
<div className="leading-relaxed opacity-80">{item.error}</div>
|
|
353
578
|
</div>
|
|
354
579
|
</div>
|
|
355
580
|
);
|
|
@@ -357,14 +582,14 @@
|
|
|
357
582
|
|
|
358
583
|
// 4. Interaction / Prompt Requested
|
|
359
584
|
if (item.event === 'INTERACTION_REQUESTED' || item.event === 'PROMPT_REQUESTED') {
|
|
360
|
-
const isPrompt = item.event === 'PROMPT_REQUESTED';
|
|
361
585
|
return (
|
|
362
|
-
<div key={idx} className="flex justify-center">
|
|
363
|
-
<div className="bg-zinc-100 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 border-dashed rounded-
|
|
364
|
-
<div className="text-[10px] text-zinc-400 dark:text-zinc-600 uppercase font-
|
|
365
|
-
|
|
586
|
+
<div key={idx} className="flex justify-center animate-in fade-in slide-in-from-bottom-2">
|
|
587
|
+
<div className="bg-zinc-100 dark:bg-zinc-900/50 border border-zinc-200 dark:border-zinc-800 border-dashed rounded-2xl px-8 py-6 text-center max-w-md w-full">
|
|
588
|
+
<div className="text-[10px] text-zinc-400 dark:text-zinc-600 uppercase font-black tracking-[0.2em] mb-2 flex items-center justify-center gap-2">
|
|
589
|
+
<span className="w-1 h-1 rounded-full bg-yellow-500"></span>
|
|
590
|
+
Intervention Required
|
|
366
591
|
</div>
|
|
367
|
-
<div className="text-xs text-zinc-600 dark:text-zinc-400 italic">
|
|
592
|
+
<div className="text-xs text-zinc-600 dark:text-zinc-400 italic font-medium leading-relaxed">
|
|
368
593
|
{item.question ? `"${item.question}"` : `Waiting for response to "${item.slug}"...`}
|
|
369
594
|
</div>
|
|
370
595
|
</div>
|
|
@@ -373,14 +598,15 @@
|
|
|
373
598
|
}
|
|
374
599
|
|
|
375
600
|
// 5. Prompt Answered
|
|
376
|
-
if (item.event === 'PROMPT_ANSWERED') {
|
|
601
|
+
if (item.event === 'PROMPT_ANSWERED' || item.event === 'INTERACTION_SUBMITTED') {
|
|
602
|
+
const isManual = item.source === 'remote';
|
|
377
603
|
return (
|
|
378
604
|
<div key={idx} className="flex justify-center">
|
|
379
|
-
<div className="bg-green-50 dark:bg-green-950/
|
|
380
|
-
<div className="text-[
|
|
381
|
-
User Answered
|
|
605
|
+
<div className="bg-green-50/50 dark:bg-green-950/10 border border-green-200/50 dark:border-green-900/30 rounded-2xl px-6 py-3 text-center max-w-md w-full">
|
|
606
|
+
<div className="text-[9px] text-green-600 dark:text-green-500 uppercase font-black tracking-widest mb-1">
|
|
607
|
+
{isManual ? 'Resolved via Browser' : 'User Answered'}
|
|
382
608
|
</div>
|
|
383
|
-
<div className="text-xs text-green-
|
|
609
|
+
<div className="text-xs text-green-800 dark:text-green-300 italic font-bold">
|
|
384
610
|
"{item.answer}"
|
|
385
611
|
</div>
|
|
386
612
|
</div>
|
|
@@ -388,20 +614,19 @@
|
|
|
388
614
|
);
|
|
389
615
|
}
|
|
390
616
|
|
|
391
|
-
// 6. Agent Completed / Interaction Resolved
|
|
617
|
+
// 6. Agent Completed / Interaction Resolved
|
|
392
618
|
if (item.event === 'AGENT_COMPLETED' || item.event === 'INTERACTION_RESOLVED') {
|
|
393
619
|
return (
|
|
394
|
-
<div key={idx} className="flex flex-col space-y-
|
|
620
|
+
<div key={idx} className="flex flex-col space-y-6">
|
|
395
621
|
{/* Header Line */}
|
|
396
|
-
<div className="flex items-center justify-center space-x-
|
|
397
|
-
<
|
|
398
|
-
<span
|
|
399
|
-
<span>
|
|
400
|
-
<
|
|
401
|
-
<span>{formatTime(item.timestamp)}</span>
|
|
622
|
+
<div className="flex items-center justify-center space-x-3 text-[10px] text-zinc-300 dark:text-zinc-800 uppercase tracking-widest font-black">
|
|
623
|
+
<div className="h-px flex-1 bg-current opacity-20"></div>
|
|
624
|
+
<span className="text-zinc-500 dark:text-zinc-400">{item.agent || item.slug}</span>
|
|
625
|
+
<span className="text-zinc-400 dark:text-zinc-700 font-medium">DONE • {formatTime(item.timestamp)}</span>
|
|
626
|
+
<div className="h-px flex-1 bg-current opacity-20"></div>
|
|
402
627
|
</div>
|
|
403
628
|
|
|
404
|
-
{/* Output (Response) -
|
|
629
|
+
{/* Output (Response) - ON TOP */}
|
|
405
630
|
{(item.output || item.result) && (
|
|
406
631
|
<JsonView
|
|
407
632
|
data={item.output || item.result}
|
|
@@ -410,13 +635,13 @@
|
|
|
410
635
|
/>
|
|
411
636
|
)}
|
|
412
637
|
|
|
413
|
-
{/* Prompt (Input) -
|
|
638
|
+
{/* Prompt (Input) - ON BOTTOM */}
|
|
414
639
|
{item.prompt && (
|
|
415
640
|
<div className="flex justify-start w-full group">
|
|
416
|
-
<div className="max-w-[85%] bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-2xl rounded-tl-none shadow-sm p-
|
|
417
|
-
<div className="flex justify-between items-center mb-
|
|
418
|
-
<div className="text-[9px] font-black text-zinc-300 dark:text-zinc-700 uppercase tracking-[0.
|
|
419
|
-
<div className="absolute top-
|
|
641
|
+
<div className="max-w-[85%] bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-2xl rounded-tl-none shadow-sm p-8 transition-all hover:border-zinc-300 dark:hover:border-zinc-700 relative">
|
|
642
|
+
<div className="flex justify-between items-center mb-6">
|
|
643
|
+
<div className="text-[9px] font-black text-zinc-300 dark:text-zinc-700 uppercase tracking-[0.3em]">Prompt / Input</div>
|
|
644
|
+
<div className="absolute top-6 right-6 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
420
645
|
<CopyButton text={item.prompt} className="text-gray-400 hover:text-gray-600 dark:text-zinc-600 dark:hover:text-zinc-400" />
|
|
421
646
|
</div>
|
|
422
647
|
</div>
|
|
@@ -430,12 +655,12 @@
|
|
|
430
655
|
);
|
|
431
656
|
}
|
|
432
657
|
|
|
433
|
-
// 7. CATCH-ALL
|
|
658
|
+
// 7. CATCH-ALL
|
|
434
659
|
return (
|
|
435
660
|
<div key={idx} className="flex justify-center px-4">
|
|
436
661
|
<JsonView
|
|
437
662
|
data={JSON.parse(JSON.stringify(item, (key, value) => {
|
|
438
|
-
if (key === 'event' || key === 'timestamp') return undefined;
|
|
663
|
+
if (key === 'event' || key === 'timestamp') return undefined;
|
|
439
664
|
return value;
|
|
440
665
|
}))}
|
|
441
666
|
label={item.event}
|
|
@@ -446,8 +671,8 @@
|
|
|
446
671
|
})}
|
|
447
672
|
</div>
|
|
448
673
|
|
|
449
|
-
<footer className="mt-
|
|
450
|
-
|
|
674
|
+
<footer className="mt-32 mb-12 text-center text-zinc-300 dark:text-zinc-800 text-[10px] font-black uppercase tracking-[0.4em] transition-colors">
|
|
675
|
+
SUPAMACHINE • Terminal v1.4
|
|
451
676
|
</footer>
|
|
452
677
|
</div>
|
|
453
678
|
</div>
|
|
@@ -460,4 +685,4 @@
|
|
|
460
685
|
</script>
|
|
461
686
|
</body>
|
|
462
687
|
|
|
463
|
-
</html>
|
|
688
|
+
</html>
|
package/package.json
CHANGED
|
@@ -342,6 +342,35 @@ async function handleSubmitPost(req, res, token) {
|
|
|
342
342
|
/**
|
|
343
343
|
* Serve session UI
|
|
344
344
|
*/
|
|
345
|
+
const MASTER_TEMPLATE_PATH = path.join(__dirname, '..', 'lib', 'ui', 'index.html');
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Get session HTML by reading the master template from lib/ui/index.html
|
|
349
|
+
*/
|
|
350
|
+
function getSessionHTML(token, workflowName) {
|
|
351
|
+
try {
|
|
352
|
+
const template = fs.readFileSync(MASTER_TEMPLATE_PATH, 'utf8');
|
|
353
|
+
return template
|
|
354
|
+
.replace(/\{\{SESSION_TOKEN\}\}/g, token)
|
|
355
|
+
.replace(/\{\{WORKFLOW_NAME\}\}/g, workflowName || 'Workflow');
|
|
356
|
+
} catch (err) {
|
|
357
|
+
console.error('Error loading master template:', err);
|
|
358
|
+
return `
|
|
359
|
+
<!DOCTYPE html>
|
|
360
|
+
<html>
|
|
361
|
+
<head><title>Error</title></head>
|
|
362
|
+
<body style="font-family: system-ui; max-width: 600px; margin: 100px auto; text-align: center;">
|
|
363
|
+
<h1>Error loading UI template</h1>
|
|
364
|
+
<p>${err.message}</p>
|
|
365
|
+
<p>Make sure <code>lib/ui/index.html</code> exists.</p>
|
|
366
|
+
</body>
|
|
367
|
+
</html>
|
|
368
|
+
`;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
|
|
345
374
|
function serveSessionUI(res, token) {
|
|
346
375
|
const session = getSession(token);
|
|
347
376
|
|
|
@@ -359,340 +388,12 @@ function serveSessionUI(res, token) {
|
|
|
359
388
|
`);
|
|
360
389
|
}
|
|
361
390
|
|
|
362
|
-
// Read the session UI template from api/session/[token].js and extract HTML
|
|
363
|
-
// For simplicity, serve a standalone version
|
|
364
391
|
const html = getSessionHTML(token, session.workflowName);
|
|
365
392
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
366
393
|
res.end(html);
|
|
367
394
|
}
|
|
368
395
|
|
|
369
|
-
|
|
370
|
-
* Get session HTML (inline version for local dev)
|
|
371
|
-
*/
|
|
372
|
-
function getSessionHTML(token, workflowName) {
|
|
373
|
-
return `<!DOCTYPE html>
|
|
374
|
-
<html lang="en">
|
|
375
|
-
<head>
|
|
376
|
-
<meta charset="UTF-8">
|
|
377
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
378
|
-
<title>${workflowName} - Remote Follow</title>
|
|
379
|
-
<script src="https://cdn.tailwindcss.com"></script>
|
|
380
|
-
<script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
|
381
|
-
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
|
382
|
-
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
|
383
|
-
<style>
|
|
384
|
-
.animate-pulse-slow { animation: pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite; }
|
|
385
|
-
</style>
|
|
386
|
-
</head>
|
|
387
|
-
<body class="bg-zinc-950 text-zinc-100 min-h-screen">
|
|
388
|
-
<div id="root"></div>
|
|
389
|
-
<script>
|
|
390
|
-
window.SESSION_TOKEN = '${token}';
|
|
391
|
-
window.WORKFLOW_NAME = '${workflowName}';
|
|
392
|
-
</script>
|
|
393
|
-
<script type="text/babel">
|
|
394
|
-
const { useState, useEffect, useRef } = React;
|
|
395
|
-
|
|
396
|
-
function StatusBadge({ status }) {
|
|
397
|
-
const colors = {
|
|
398
|
-
connected: 'bg-green-500',
|
|
399
|
-
disconnected: 'bg-red-500',
|
|
400
|
-
connecting: 'bg-yellow-500 animate-pulse-slow',
|
|
401
|
-
};
|
|
402
|
-
const labels = {
|
|
403
|
-
connected: 'Live',
|
|
404
|
-
disconnected: 'CLI Offline',
|
|
405
|
-
connecting: 'Connecting...',
|
|
406
|
-
};
|
|
407
|
-
return (
|
|
408
|
-
<div className="flex items-center gap-2">
|
|
409
|
-
<div className={\`w-2 h-2 rounded-full \${colors[status] || colors.disconnected}\`}></div>
|
|
410
|
-
<span className="text-xs uppercase tracking-wider text-zinc-400">{labels[status] || status}</span>
|
|
411
|
-
</div>
|
|
412
|
-
);
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
function CopyButton({ text }) {
|
|
416
|
-
const [copied, setCopied] = useState(false);
|
|
417
|
-
const handleCopy = async () => {
|
|
418
|
-
await navigator.clipboard.writeText(text);
|
|
419
|
-
setCopied(true);
|
|
420
|
-
setTimeout(() => setCopied(false), 2000);
|
|
421
|
-
};
|
|
422
|
-
return (
|
|
423
|
-
<button onClick={handleCopy} className="px-2 py-1 text-xs bg-zinc-700 hover:bg-zinc-600 rounded">
|
|
424
|
-
{copied ? 'Copied!' : 'Copy'}
|
|
425
|
-
</button>
|
|
426
|
-
);
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
function JsonView({ data, label }) {
|
|
430
|
-
const [isRaw, setIsRaw] = useState(false);
|
|
431
|
-
const jsonStr = JSON.stringify(data, null, 2);
|
|
432
|
-
return (
|
|
433
|
-
<div className="bg-zinc-800 rounded-lg overflow-hidden">
|
|
434
|
-
<div className="flex justify-between items-center px-3 py-2 bg-zinc-700">
|
|
435
|
-
<span className="text-xs font-medium text-zinc-300">{label}</span>
|
|
436
|
-
<div className="flex gap-2">
|
|
437
|
-
<button onClick={() => setIsRaw(!isRaw)} className="text-xs text-zinc-400 hover:text-zinc-200">
|
|
438
|
-
{isRaw ? 'Clean' : 'Raw'}
|
|
439
|
-
</button>
|
|
440
|
-
<CopyButton text={jsonStr} />
|
|
441
|
-
</div>
|
|
442
|
-
</div>
|
|
443
|
-
<pre className="p-3 text-xs overflow-auto max-h-96 text-zinc-300">{jsonStr}</pre>
|
|
444
|
-
</div>
|
|
445
|
-
);
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
function InteractionForm({ interaction, onSubmit, disabled }) {
|
|
449
|
-
const [response, setResponse] = useState('');
|
|
450
|
-
const [submitting, setSubmitting] = useState(false);
|
|
451
|
-
|
|
452
|
-
const handleSubmit = async (e) => {
|
|
453
|
-
e.preventDefault();
|
|
454
|
-
if (!response.trim() || submitting) return;
|
|
455
|
-
setSubmitting(true);
|
|
456
|
-
try {
|
|
457
|
-
await onSubmit(interaction.slug, interaction.targetKey, response.trim());
|
|
458
|
-
setResponse('');
|
|
459
|
-
} finally {
|
|
460
|
-
setSubmitting(false);
|
|
461
|
-
}
|
|
462
|
-
};
|
|
463
|
-
|
|
464
|
-
return (
|
|
465
|
-
<div className="bg-yellow-900/20 border border-yellow-700/50 rounded-lg p-6 mb-6">
|
|
466
|
-
<div className="text-sm font-bold text-yellow-200 mb-2">Input Required</div>
|
|
467
|
-
<div className="text-sm text-yellow-100/80 mb-4 whitespace-pre-wrap">
|
|
468
|
-
{interaction.question || 'Please provide your input.'}
|
|
469
|
-
</div>
|
|
470
|
-
<form onSubmit={handleSubmit}>
|
|
471
|
-
<textarea
|
|
472
|
-
value={response}
|
|
473
|
-
onChange={(e) => setResponse(e.target.value)}
|
|
474
|
-
className="w-full p-3 bg-zinc-800 border border-zinc-700 rounded-lg text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-yellow-500"
|
|
475
|
-
rows={4}
|
|
476
|
-
placeholder="Enter your response..."
|
|
477
|
-
disabled={submitting || disabled}
|
|
478
|
-
/>
|
|
479
|
-
<div className="flex justify-end mt-3 gap-2">
|
|
480
|
-
{disabled && <span className="text-sm text-red-400">CLI is offline</span>}
|
|
481
|
-
<button
|
|
482
|
-
type="submit"
|
|
483
|
-
disabled={submitting || disabled || !response.trim()}
|
|
484
|
-
className="px-4 py-2 bg-yellow-600 hover:bg-yellow-500 text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
|
485
|
-
>
|
|
486
|
-
{submitting ? 'Submitting...' : 'Submit'}
|
|
487
|
-
</button>
|
|
488
|
-
</div>
|
|
489
|
-
</form>
|
|
490
|
-
</div>
|
|
491
|
-
);
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
function EventCard({ entry }) {
|
|
495
|
-
const eventColors = {
|
|
496
|
-
WORKFLOW_STARTED: 'border-blue-500',
|
|
497
|
-
WORKFLOW_COMPLETED: 'border-green-500',
|
|
498
|
-
WORKFLOW_FAILED: 'border-red-500',
|
|
499
|
-
AGENT_STARTED: 'border-blue-400',
|
|
500
|
-
AGENT_COMPLETED: 'border-green-400',
|
|
501
|
-
AGENT_FAILED: 'border-red-400',
|
|
502
|
-
PROMPT_REQUESTED: 'border-yellow-500',
|
|
503
|
-
PROMPT_ANSWERED: 'border-yellow-400',
|
|
504
|
-
INTERACTION_REQUESTED: 'border-yellow-500',
|
|
505
|
-
INTERACTION_RESOLVED: 'border-yellow-400',
|
|
506
|
-
INTERACTION_SUBMITTED: 'border-yellow-300',
|
|
507
|
-
};
|
|
508
|
-
const borderColor = eventColors[entry.event] || 'border-zinc-600';
|
|
509
|
-
const time = entry.timestamp ? new Date(entry.timestamp).toLocaleTimeString() : '';
|
|
510
|
-
|
|
511
|
-
return (
|
|
512
|
-
<div className={\`border-l-2 \${borderColor} pl-4 py-3\`}>
|
|
513
|
-
<div className="flex justify-between items-start mb-2">
|
|
514
|
-
<span className="font-medium text-sm">{entry.event}</span>
|
|
515
|
-
<span className="text-xs text-zinc-500">{time}</span>
|
|
516
|
-
</div>
|
|
517
|
-
{entry.agent && <div className="text-xs text-zinc-400 mb-1">Agent: <span className="text-zinc-300">{entry.agent}</span></div>}
|
|
518
|
-
{entry.slug && <div className="text-xs text-zinc-400 mb-1">Slug: <span className="text-zinc-300">{entry.slug}</span></div>}
|
|
519
|
-
{entry.question && (
|
|
520
|
-
<div className="text-xs text-zinc-400 mt-2">
|
|
521
|
-
<div className="text-zinc-500 mb-1">Question:</div>
|
|
522
|
-
<div className="text-zinc-300 whitespace-pre-wrap">{entry.question}</div>
|
|
523
|
-
</div>
|
|
524
|
-
)}
|
|
525
|
-
{entry.answer && (
|
|
526
|
-
<div className="text-xs text-zinc-400 mt-2">
|
|
527
|
-
<div className="text-zinc-500 mb-1">Answer:</div>
|
|
528
|
-
<div className="text-zinc-300">{entry.answer}</div>
|
|
529
|
-
</div>
|
|
530
|
-
)}
|
|
531
|
-
{entry.error && <div className="text-xs text-red-400 mt-2">{entry.error}</div>}
|
|
532
|
-
{entry.output && <div className="mt-2"><JsonView data={entry.output} label="Output" /></div>}
|
|
533
|
-
{entry.prompt && (
|
|
534
|
-
<details className="mt-2">
|
|
535
|
-
<summary className="text-xs text-zinc-500 cursor-pointer hover:text-zinc-300">Show Prompt</summary>
|
|
536
|
-
<pre className="mt-2 p-2 bg-zinc-800 rounded text-xs text-zinc-400 overflow-auto max-h-48">
|
|
537
|
-
{typeof entry.prompt === 'string' ? entry.prompt : JSON.stringify(entry.prompt, null, 2)}
|
|
538
|
-
</pre>
|
|
539
|
-
</details>
|
|
540
|
-
)}
|
|
541
|
-
</div>
|
|
542
|
-
);
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
function App() {
|
|
546
|
-
const [history, setHistory] = useState([]);
|
|
547
|
-
const [status, setStatus] = useState('connecting');
|
|
548
|
-
const [pendingInteraction, setPendingInteraction] = useState(null);
|
|
549
|
-
const [sortNewest, setSortNewest] = useState(true);
|
|
550
|
-
|
|
551
|
-
// Detect pending interactions - scan history for unresolved requests
|
|
552
|
-
useEffect(() => {
|
|
553
|
-
if (history.length === 0) {
|
|
554
|
-
setPendingInteraction(null);
|
|
555
|
-
return;
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
// Build set of resolved slugs (scan from newest to oldest)
|
|
559
|
-
const resolvedSlugs = new Set();
|
|
560
|
-
let pending = null;
|
|
561
|
-
|
|
562
|
-
for (const entry of history) {
|
|
563
|
-
const isResolution = entry.event === 'INTERACTION_RESOLVED' ||
|
|
564
|
-
entry.event === 'PROMPT_ANSWERED' ||
|
|
565
|
-
entry.event === 'INTERACTION_SUBMITTED';
|
|
566
|
-
const isRequest = entry.event === 'INTERACTION_REQUESTED' ||
|
|
567
|
-
entry.event === 'PROMPT_REQUESTED';
|
|
568
|
-
|
|
569
|
-
if (isResolution && entry.slug) {
|
|
570
|
-
resolvedSlugs.add(entry.slug);
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
// Find the most recent unresolved request
|
|
574
|
-
if (isRequest && entry.slug && !resolvedSlugs.has(entry.slug) && !pending) {
|
|
575
|
-
pending = {
|
|
576
|
-
slug: entry.slug,
|
|
577
|
-
targetKey: entry.targetKey || \`_interaction_\${entry.slug}\`,
|
|
578
|
-
question: entry.question,
|
|
579
|
-
};
|
|
580
|
-
}
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
setPendingInteraction(pending);
|
|
584
|
-
}, [history]);
|
|
585
|
-
|
|
586
|
-
useEffect(() => {
|
|
587
|
-
const token = window.SESSION_TOKEN;
|
|
588
|
-
fetch(\`/api/history/\${token}\`)
|
|
589
|
-
.then(res => res.json())
|
|
590
|
-
.then(data => {
|
|
591
|
-
if (data.entries) setHistory(data.entries);
|
|
592
|
-
setStatus(data.cliConnected ? 'connected' : 'disconnected');
|
|
593
|
-
})
|
|
594
|
-
.catch(() => setStatus('disconnected'));
|
|
595
|
-
|
|
596
|
-
const eventSource = new EventSource(\`/api/events/\${token}\`);
|
|
597
|
-
eventSource.onopen = () => setStatus('connected');
|
|
598
|
-
eventSource.onerror = () => setStatus('disconnected');
|
|
599
|
-
eventSource.onmessage = (e) => {
|
|
600
|
-
try {
|
|
601
|
-
const data = JSON.parse(e.data);
|
|
602
|
-
switch (data.type) {
|
|
603
|
-
case 'status': setStatus(data.cliConnected ? 'connected' : 'disconnected'); break;
|
|
604
|
-
case 'history': setHistory(data.entries || []); break;
|
|
605
|
-
case 'event':
|
|
606
|
-
// Skip duplicate INTERACTION_SUBMITTED events (from optimistic updates)
|
|
607
|
-
setHistory(prev => {
|
|
608
|
-
if (data.event === 'INTERACTION_SUBMITTED' && data.slug) {
|
|
609
|
-
const hasDupe = prev.some(e =>
|
|
610
|
-
e.event === 'INTERACTION_SUBMITTED' && e.slug === data.slug
|
|
611
|
-
);
|
|
612
|
-
if (hasDupe) return prev;
|
|
613
|
-
}
|
|
614
|
-
return [data, ...prev];
|
|
615
|
-
});
|
|
616
|
-
break;
|
|
617
|
-
case 'cli_connected':
|
|
618
|
-
case 'cli_reconnected': setStatus('connected'); break;
|
|
619
|
-
case 'cli_disconnected': setStatus('disconnected'); break;
|
|
620
|
-
}
|
|
621
|
-
} catch (err) { console.error(err); }
|
|
622
|
-
};
|
|
623
|
-
return () => eventSource.close();
|
|
624
|
-
}, []);
|
|
625
|
-
|
|
626
|
-
const handleSubmit = async (slug, targetKey, response) => {
|
|
627
|
-
// Optimistic update - add event immediately to hide form
|
|
628
|
-
const optimisticEvent = {
|
|
629
|
-
timestamp: new Date().toISOString(),
|
|
630
|
-
event: 'INTERACTION_SUBMITTED',
|
|
631
|
-
slug,
|
|
632
|
-
targetKey,
|
|
633
|
-
answer: response.substring(0, 200) + (response.length > 200 ? '...' : ''),
|
|
634
|
-
source: 'remote',
|
|
635
|
-
};
|
|
636
|
-
setHistory(prev => [optimisticEvent, ...prev]);
|
|
637
|
-
|
|
638
|
-
const res = await fetch(\`/api/submit/\${window.SESSION_TOKEN}\`, {
|
|
639
|
-
method: 'POST',
|
|
640
|
-
headers: { 'Content-Type': 'application/json' },
|
|
641
|
-
body: JSON.stringify({ slug, targetKey, response }),
|
|
642
|
-
});
|
|
643
|
-
if (!res.ok) {
|
|
644
|
-
// Rollback optimistic update on error
|
|
645
|
-
setHistory(prev => prev.filter(e => e !== optimisticEvent));
|
|
646
|
-
const error = await res.json();
|
|
647
|
-
throw new Error(error.error || 'Failed to submit');
|
|
648
|
-
}
|
|
649
|
-
};
|
|
650
|
-
|
|
651
|
-
const sortedHistory = sortNewest ? history : [...history].reverse();
|
|
652
|
-
|
|
653
|
-
return (
|
|
654
|
-
<div className="max-w-4xl mx-auto p-6">
|
|
655
|
-
<div className="sticky top-0 bg-zinc-950/95 backdrop-blur py-4 mb-6 border-b border-zinc-800">
|
|
656
|
-
<div className="flex justify-between items-center">
|
|
657
|
-
<div>
|
|
658
|
-
<h1 className="text-xl font-bold text-zinc-100">{window.WORKFLOW_NAME || 'Workflow'}</h1>
|
|
659
|
-
<div className="text-xs text-zinc-500 mt-1">Remote Follow (Local Dev)</div>
|
|
660
|
-
</div>
|
|
661
|
-
<div className="flex items-center gap-4">
|
|
662
|
-
<button onClick={() => setSortNewest(!sortNewest)} className="text-xs text-zinc-400 hover:text-zinc-200">
|
|
663
|
-
{sortNewest ? 'Newest First' : 'Oldest First'}
|
|
664
|
-
</button>
|
|
665
|
-
<StatusBadge status={status} />
|
|
666
|
-
</div>
|
|
667
|
-
</div>
|
|
668
|
-
</div>
|
|
669
|
-
|
|
670
|
-
{pendingInteraction && (
|
|
671
|
-
<InteractionForm interaction={pendingInteraction} onSubmit={handleSubmit} disabled={status !== 'connected'} />
|
|
672
|
-
)}
|
|
673
|
-
|
|
674
|
-
{status === 'disconnected' && !pendingInteraction && (
|
|
675
|
-
<div className="bg-red-900/20 border border-red-700/50 rounded-lg p-4 mb-6">
|
|
676
|
-
<div className="text-sm text-red-200">CLI is disconnected. Waiting for reconnection...</div>
|
|
677
|
-
</div>
|
|
678
|
-
)}
|
|
679
|
-
|
|
680
|
-
<div className="space-y-2">
|
|
681
|
-
{sortedHistory.length === 0 ? (
|
|
682
|
-
<div className="text-center text-zinc-500 py-12">No events yet. Waiting for workflow activity...</div>
|
|
683
|
-
) : (
|
|
684
|
-
sortedHistory.map((entry, i) => <EventCard key={\`\${entry.timestamp}-\${i}\`} entry={entry} />)
|
|
685
|
-
)}
|
|
686
|
-
</div>
|
|
687
|
-
</div>
|
|
688
|
-
);
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
|
|
692
|
-
</script>
|
|
693
|
-
</body>
|
|
694
|
-
</html>`;
|
|
695
|
-
}
|
|
396
|
+
// getSessionHTML was moved up and updated to read from MASTER_TEMPLATE_PATH
|
|
696
397
|
|
|
697
398
|
/**
|
|
698
399
|
* Serve static files
|
|
@@ -17,28 +17,40 @@
|
|
|
17
17
|
<ol class="space-y-4 text-zinc-300">
|
|
18
18
|
<li class="flex gap-3">
|
|
19
19
|
<span class="text-zinc-500 font-mono">1.</span>
|
|
20
|
-
<span>
|
|
20
|
+
<span>Install package:
|
|
21
|
+
<br><code class="bg-zinc-800 px-2 py-0.5 rounded">npm i -D agent-state-machine</code></span>
|
|
22
|
+
</li>
|
|
23
|
+
|
|
24
|
+
<li class="flex gap-3">
|
|
25
|
+
<span class="text-zinc-500 font-mono">2.</span>
|
|
26
|
+
<span>Initialize your workflow:
|
|
27
|
+
<br><code class="bg-zinc-800 px-2 py-0.5 rounded">npx state-machine --setup my-workflow</code></span>
|
|
28
|
+
</li>
|
|
29
|
+
|
|
30
|
+
<li class="flex gap-3">
|
|
31
|
+
<span class="text-zinc-500 font-mono">3.</span>
|
|
32
|
+
<span>Run your workflow:</span>
|
|
21
33
|
</li>
|
|
22
34
|
<li class="pl-6">
|
|
23
35
|
<code class="bg-zinc-800 px-3 py-2 rounded block text-sm">
|
|
24
|
-
state-machine run my-workflow
|
|
36
|
+
npx state-machine run my-workflow
|
|
25
37
|
</code>
|
|
26
38
|
</li>
|
|
27
39
|
|
|
28
40
|
<li class="flex gap-3 mt-4">
|
|
29
|
-
<span class="text-zinc-500 font-mono">
|
|
41
|
+
<span class="text-zinc-500 font-mono">4.</span>
|
|
30
42
|
<span>The CLI will print a unique URL. Share it with anyone who needs to follow along or interact with the workflow.</span>
|
|
31
43
|
</li>
|
|
32
44
|
|
|
33
45
|
<li class="flex gap-3 mt-4">
|
|
34
|
-
<span class="text-zinc-500 font-mono">
|
|
46
|
+
<span class="text-zinc-500 font-mono">5.</span>
|
|
35
47
|
<span>Open the URL in a browser to see live workflow events and submit interaction responses.</span>
|
|
36
48
|
</li>
|
|
37
49
|
</ol>
|
|
38
50
|
</div>
|
|
39
51
|
|
|
40
52
|
<p class="text-zinc-500 text-sm mt-8">
|
|
41
|
-
Learn more at <a href="https://github.com/
|
|
53
|
+
Learn more at <a href="https://github.com/superbasicman/state-machine" target="_blank" class="text-cyan-400 hover:underline">GitHub</a>
|
|
42
54
|
</p>
|
|
43
55
|
</div>
|
|
44
56
|
</body>
|
package/lib/ui/server.js
DELETED
|
@@ -1,150 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* File: /lib/ui/server.js
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import http from 'http';
|
|
6
|
-
import fs from 'fs';
|
|
7
|
-
import path from 'path';
|
|
8
|
-
import { fileURLToPath } from 'url';
|
|
9
|
-
|
|
10
|
-
const __filename = fileURLToPath(import.meta.url);
|
|
11
|
-
const __dirname = path.dirname(__filename);
|
|
12
|
-
|
|
13
|
-
export function startServer(workflowDir, initialPort = 3000) {
|
|
14
|
-
const clients = new Set();
|
|
15
|
-
const stateDir = path.join(workflowDir, 'state');
|
|
16
|
-
|
|
17
|
-
// Watch for changes in the state directory
|
|
18
|
-
// We debounce slightly to avoid sending multiple events for a single write burst
|
|
19
|
-
let debounceTimer;
|
|
20
|
-
const broadcastUpdate = () => {
|
|
21
|
-
if (debounceTimer) clearTimeout(debounceTimer);
|
|
22
|
-
debounceTimer = setTimeout(() => {
|
|
23
|
-
const msg = 'data: update\n\n';
|
|
24
|
-
for (const client of clients) {
|
|
25
|
-
try {
|
|
26
|
-
client.write(msg);
|
|
27
|
-
} catch (e) {
|
|
28
|
-
clients.delete(client);
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
}, 100);
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
try {
|
|
35
|
-
if (fs.existsSync(stateDir)) {
|
|
36
|
-
fs.watch(stateDir, (eventType, filename) => {
|
|
37
|
-
if (filename && (filename === 'history.jsonl' || filename.startsWith('history'))) {
|
|
38
|
-
broadcastUpdate();
|
|
39
|
-
}
|
|
40
|
-
});
|
|
41
|
-
} else {
|
|
42
|
-
console.warn('Warning: State directory does not exist yet. Live updates might not work until it is created.');
|
|
43
|
-
}
|
|
44
|
-
} catch (err) {
|
|
45
|
-
console.warn('Warning: Failed to setup file watcher:', err.message);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// Request Handler
|
|
49
|
-
const requestHandler = (req, res) => {
|
|
50
|
-
// Serve the main HTML page
|
|
51
|
-
if (req.url === '/' || req.url === '/index.html') {
|
|
52
|
-
const htmlPath = path.join(__dirname, 'index.html');
|
|
53
|
-
fs.readFile(htmlPath, (err, content) => {
|
|
54
|
-
if (err) {
|
|
55
|
-
res.writeHead(500);
|
|
56
|
-
res.end('Error loading UI');
|
|
57
|
-
return;
|
|
58
|
-
}
|
|
59
|
-
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
60
|
-
res.end(content);
|
|
61
|
-
});
|
|
62
|
-
return;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// Server-Sent Events endpoint
|
|
66
|
-
if (req.url === '/api/events') {
|
|
67
|
-
res.writeHead(200, {
|
|
68
|
-
'Content-Type': 'text/event-stream',
|
|
69
|
-
'Cache-Control': 'no-cache',
|
|
70
|
-
'Connection': 'keep-alive',
|
|
71
|
-
});
|
|
72
|
-
res.write('retry: 10000\n\n');
|
|
73
|
-
|
|
74
|
-
clients.add(res);
|
|
75
|
-
|
|
76
|
-
req.on('close', () => {
|
|
77
|
-
clients.delete(res);
|
|
78
|
-
});
|
|
79
|
-
return;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// Serve API
|
|
83
|
-
if (req.url === '/api/history') {
|
|
84
|
-
const historyFile = path.join(stateDir, 'history.jsonl');
|
|
85
|
-
|
|
86
|
-
if (!fs.existsSync(historyFile)) {
|
|
87
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
88
|
-
res.end(JSON.stringify({
|
|
89
|
-
workflowName: path.basename(workflowDir),
|
|
90
|
-
entries: []
|
|
91
|
-
}));
|
|
92
|
-
return;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
try {
|
|
96
|
-
const fileContent = fs.readFileSync(historyFile, 'utf-8');
|
|
97
|
-
const lines = fileContent.trim().split('\n');
|
|
98
|
-
const entries = lines
|
|
99
|
-
.map(line => {
|
|
100
|
-
try { return JSON.parse(line); } catch { return null; }
|
|
101
|
-
})
|
|
102
|
-
.filter(Boolean);
|
|
103
|
-
|
|
104
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
105
|
-
res.end(JSON.stringify({
|
|
106
|
-
workflowName: path.basename(workflowDir),
|
|
107
|
-
entries
|
|
108
|
-
}));
|
|
109
|
-
} catch (err) {
|
|
110
|
-
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
111
|
-
res.end(JSON.stringify({ error: err.message }));
|
|
112
|
-
}
|
|
113
|
-
return;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// 404
|
|
117
|
-
res.writeHead(404);
|
|
118
|
-
res.end('Not found');
|
|
119
|
-
};
|
|
120
|
-
|
|
121
|
-
// Port hunting logic
|
|
122
|
-
let port = initialPort;
|
|
123
|
-
const maxPort = initialPort + 100; // Try up to 100 ports
|
|
124
|
-
|
|
125
|
-
const attemptServer = () => {
|
|
126
|
-
const server = http.createServer(requestHandler);
|
|
127
|
-
|
|
128
|
-
server.on('error', (e) => {
|
|
129
|
-
if (e.code === 'EADDRINUSE') {
|
|
130
|
-
if (port < maxPort) {
|
|
131
|
-
console.log(`Port ${port} is in use, trying ${port + 1}...`);
|
|
132
|
-
port++;
|
|
133
|
-
attemptServer();
|
|
134
|
-
} else {
|
|
135
|
-
console.error(`Error: Could not find an open port between ${initialPort} and ${maxPort}.`);
|
|
136
|
-
}
|
|
137
|
-
} else {
|
|
138
|
-
console.error('Server error:', e);
|
|
139
|
-
}
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
server.listen(port, () => {
|
|
143
|
-
console.log(`\n> Follow UI running at http://localhost:${port}`);
|
|
144
|
-
console.log(`> Viewing history for: ${workflowDir}`);
|
|
145
|
-
console.log(`> Press Ctrl+C to stop`);
|
|
146
|
-
});
|
|
147
|
-
};
|
|
148
|
-
|
|
149
|
-
attemptServer();
|
|
150
|
-
}
|