agent-state-machine 1.0.3 → 1.2.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 +11 -10
- package/bin/cli.js +24 -7
- package/lib/llm.js +44 -52
- package/lib/runtime/agent.js +10 -3
- package/lib/runtime/runtime.js +0 -6
- package/lib/setup.js +14 -9
- package/lib/ui/index.html +324 -0
- package/lib/ui/server.js +150 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -44,10 +44,11 @@ Requirements: Node.js >= 16.
|
|
|
44
44
|
```bash
|
|
45
45
|
state-machine --setup <workflow-name>
|
|
46
46
|
state-machine run <workflow-name>
|
|
47
|
-
|
|
48
|
-
state-machine
|
|
47
|
+
|
|
48
|
+
state-machine follow <workflow-name> (view prompt trace history in browser with live updates)
|
|
49
49
|
state-machine history <workflow-name> [limit]
|
|
50
|
-
state-machine reset <workflow-name>
|
|
50
|
+
state-machine reset <workflow-name> (clears memory/state)
|
|
51
|
+
state-machine reset-hard <workflow-name> (clears everything: history/interactions/memory)
|
|
51
52
|
```
|
|
52
53
|
|
|
53
54
|
Workflows live in:
|
|
@@ -58,7 +59,7 @@ workflows/<name>/
|
|
|
58
59
|
├── package.json # Sets "type": "module" for this workflow folder
|
|
59
60
|
├── agents/ # Custom agents (.js/.mjs/.cjs or .md)
|
|
60
61
|
├── interactions/ # Human-in-the-loop files (auto-created)
|
|
61
|
-
├── state/ # current.json, history.jsonl
|
|
62
|
+
├── state/ # current.json, history.jsonl
|
|
62
63
|
└── steering/ # global.md + config.json
|
|
63
64
|
```
|
|
64
65
|
|
|
@@ -130,13 +131,13 @@ export default async function() {
|
|
|
130
131
|
}
|
|
131
132
|
```
|
|
132
133
|
|
|
133
|
-
###
|
|
134
|
+
### Resuming workflows
|
|
134
135
|
|
|
135
|
-
`
|
|
136
|
+
`state-machine run` restarts your workflow from the top, loading the persisted state.
|
|
136
137
|
|
|
137
138
|
If the workflow needs human input, it will **block inline** in the terminal. You’ll be told which `interactions/<slug>.md` file to edit; after you fill it in, press `y` in the same terminal session to continue.
|
|
138
139
|
|
|
139
|
-
If the process is interrupted, running `state-machine
|
|
140
|
+
If the process is interrupted, running `state-machine run <workflow-name>` again will continue execution (assuming your workflow uses `memory` to skip completed steps).
|
|
140
141
|
|
|
141
142
|
---
|
|
142
143
|
|
|
@@ -292,10 +293,10 @@ export const config = {
|
|
|
292
293
|
};
|
|
293
294
|
```
|
|
294
295
|
|
|
295
|
-
The runtime
|
|
296
|
+
The runtime captures the fully-built prompt in `state/history.jsonl`, viewable in the browser with live updates via:
|
|
296
297
|
|
|
297
|
-
```
|
|
298
|
-
|
|
298
|
+
```bash
|
|
299
|
+
state-machine follow <workflow-name>
|
|
299
300
|
```
|
|
300
301
|
|
|
301
302
|
---
|
package/bin/cli.js
CHANGED
|
@@ -5,6 +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
|
+
import { startServer } from '../lib/ui/server.js';
|
|
8
9
|
|
|
9
10
|
const __filename = fileURLToPath(import.meta.url);
|
|
10
11
|
const __dirname = path.dirname(__filename);
|
|
@@ -28,12 +29,12 @@ Agent State Machine CLI (Native JS Workflows Only) v${getVersion()}
|
|
|
28
29
|
|
|
29
30
|
Usage:
|
|
30
31
|
state-machine --setup <workflow-name> Create a new workflow project
|
|
31
|
-
state-machine run <workflow-name> Run a workflow
|
|
32
|
-
state-machine
|
|
32
|
+
state-machine run <workflow-name> Run a workflow (loads existing state)
|
|
33
|
+
state-machine follow <workflow-name> View prompt trace history in browser with live updates
|
|
33
34
|
state-machine status [workflow-name] Show current state (or list all)
|
|
34
|
-
state-machine history <workflow-name> [limit] Show execution history
|
|
35
|
-
state-machine reset <workflow-name> Reset workflow state
|
|
36
|
-
state-machine reset-hard <workflow-name> Hard reset (
|
|
35
|
+
state-machine history <workflow-name> [limit] Show execution history logs
|
|
36
|
+
state-machine reset <workflow-name> Reset workflow state (clears memory/state)
|
|
37
|
+
state-machine reset-hard <workflow-name> Hard reset (clears everything: history/interactions/memory)
|
|
37
38
|
state-machine list List all workflows
|
|
38
39
|
state-machine help Show this help
|
|
39
40
|
|
|
@@ -48,7 +49,7 @@ Workflow Structure:
|
|
|
48
49
|
├── package.json # Sets "type": "module" for this workflow folder
|
|
49
50
|
├── agents/ # Custom agents (.js/.mjs/.cjs or .md)
|
|
50
51
|
├── interactions/ # Human-in-the-loop files (auto-created)
|
|
51
|
-
├── state/ # current.json, history.jsonl
|
|
52
|
+
├── state/ # current.json, history.jsonl
|
|
52
53
|
└── steering/ # global.md + config.json
|
|
53
54
|
`);
|
|
54
55
|
}
|
|
@@ -182,7 +183,6 @@ async function main() {
|
|
|
182
183
|
|
|
183
184
|
switch (command) {
|
|
184
185
|
case 'run':
|
|
185
|
-
case 'resume':
|
|
186
186
|
if (!workflowName) {
|
|
187
187
|
console.error('Error: Workflow name required');
|
|
188
188
|
console.error(`Usage: state-machine ${command} <workflow-name>`);
|
|
@@ -222,6 +222,23 @@ async function main() {
|
|
|
222
222
|
}
|
|
223
223
|
break;
|
|
224
224
|
|
|
225
|
+
case 'follow':
|
|
226
|
+
if (!workflowName) {
|
|
227
|
+
console.error('Error: Workflow name required');
|
|
228
|
+
console.error('Usage: state-machine follow <workflow-name>');
|
|
229
|
+
process.exit(1);
|
|
230
|
+
}
|
|
231
|
+
{
|
|
232
|
+
const workflowDir = resolveWorkflowDir(workflowName);
|
|
233
|
+
if (!fs.existsSync(workflowDir)) {
|
|
234
|
+
console.error(`Error: Workflow '${workflowName}' not found`);
|
|
235
|
+
process.exit(1);
|
|
236
|
+
}
|
|
237
|
+
startServer(workflowDir);
|
|
238
|
+
// Do not exit, server needs to stay alive
|
|
239
|
+
}
|
|
240
|
+
break;
|
|
241
|
+
|
|
225
242
|
case 'reset':
|
|
226
243
|
if (!workflowName) {
|
|
227
244
|
console.error('Error: Workflow name required');
|
package/lib/llm.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import fs from 'fs';
|
|
6
6
|
import path from 'path';
|
|
7
|
+
import os from 'os';
|
|
7
8
|
import { spawn, execSync } from 'child_process';
|
|
8
9
|
import { createRequire } from 'module';
|
|
9
10
|
|
|
@@ -38,21 +39,6 @@ export function detectAvailableCLIs() {
|
|
|
38
39
|
return available;
|
|
39
40
|
}
|
|
40
41
|
|
|
41
|
-
/**
|
|
42
|
-
* Write the generated prompt file
|
|
43
|
-
*/
|
|
44
|
-
export function writeGeneratedPrompt(workflowDir, content) {
|
|
45
|
-
const promptDir = path.join(workflowDir, 'state');
|
|
46
|
-
const promptFile = path.join(promptDir, 'generated-prompt.md');
|
|
47
|
-
|
|
48
|
-
if (!fs.existsSync(promptDir)) {
|
|
49
|
-
fs.mkdirSync(promptDir, { recursive: true });
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
fs.writeFileSync(promptFile, content);
|
|
53
|
-
return promptFile;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
42
|
/**
|
|
57
43
|
* Build the full prompt with steering and context
|
|
58
44
|
*/
|
|
@@ -96,17 +82,18 @@ export function buildPrompt(context, options) {
|
|
|
96
82
|
|
|
97
83
|
/**
|
|
98
84
|
* Execute CLI command and return response
|
|
85
|
+
* Uses Stdin for supported tools, and temporary files for generic tools.
|
|
99
86
|
*/
|
|
100
|
-
async function executeCLI(command,
|
|
87
|
+
async function executeCLI(command, promptText, options = {}, apiKeys = {}) {
|
|
101
88
|
return new Promise((resolve, reject) => {
|
|
102
89
|
// Parse command to extract base command and args
|
|
103
|
-
// Note: naive split; if you need quoted args, consider a shell-args parser.
|
|
104
90
|
const parts = command.split(' ');
|
|
105
91
|
const baseCmd = parts[0];
|
|
106
92
|
const baseArgs = parts.slice(1);
|
|
107
93
|
|
|
108
94
|
// Build full args
|
|
109
95
|
const args = [...baseArgs];
|
|
96
|
+
let tempPromptFile = null;
|
|
110
97
|
|
|
111
98
|
const ensureCodexExec = () => {
|
|
112
99
|
const CODEX_SUBCOMMANDS = new Set([
|
|
@@ -140,7 +127,7 @@ async function executeCLI(command, promptFile, options = {}, apiKeys = {}) {
|
|
|
140
127
|
'-o', '--output-last-message'
|
|
141
128
|
]);
|
|
142
129
|
|
|
143
|
-
// Insert `exec` after any leading global options
|
|
130
|
+
// Insert `exec` after any leading global options
|
|
144
131
|
let i = 0;
|
|
145
132
|
while (i < args.length) {
|
|
146
133
|
const token = args[i];
|
|
@@ -150,7 +137,6 @@ async function executeCLI(command, promptFile, options = {}, apiKeys = {}) {
|
|
|
150
137
|
i += 2;
|
|
151
138
|
continue;
|
|
152
139
|
}
|
|
153
|
-
|
|
154
140
|
i += 1;
|
|
155
141
|
}
|
|
156
142
|
|
|
@@ -158,41 +144,38 @@ async function executeCLI(command, promptFile, options = {}, apiKeys = {}) {
|
|
|
158
144
|
if (firstNonOption && CODEX_SUBCOMMANDS.has(firstNonOption)) {
|
|
159
145
|
return;
|
|
160
146
|
}
|
|
161
|
-
|
|
162
147
|
args.splice(i, 0, 'exec');
|
|
163
148
|
};
|
|
164
149
|
|
|
165
|
-
//
|
|
150
|
+
// Configure args based on the tool
|
|
151
|
+
const isStandardCLI = (baseCmd === 'claude' || baseCmd === 'gemini' || baseCmd === 'codex');
|
|
152
|
+
|
|
166
153
|
if (baseCmd === 'claude') {
|
|
167
|
-
|
|
168
|
-
args.push('--print'); // Print response only
|
|
154
|
+
args.push('--print');
|
|
169
155
|
args.push('--permission-mode', 'acceptEdits');
|
|
170
|
-
//
|
|
156
|
+
// Input via stdin
|
|
171
157
|
} else if (baseCmd === 'gemini') {
|
|
172
|
-
// Gemini CLI
|
|
173
158
|
args.push('--approval-mode', 'auto_edit');
|
|
174
|
-
//
|
|
159
|
+
// Input via stdin
|
|
175
160
|
} else if (baseCmd === 'codex') {
|
|
176
|
-
// Codex CLI defaults to an interactive TUI, which requires a TTY.
|
|
177
|
-
// Force non-interactive mode via `codex exec`, and feed PROMPT via stdin ("-").
|
|
178
161
|
ensureCodexExec();
|
|
179
|
-
|
|
180
|
-
// Write only the final message to a file to avoid parsing extra output.
|
|
181
162
|
const lastMessageFile = path.join(
|
|
182
|
-
|
|
163
|
+
os.tmpdir(),
|
|
183
164
|
`codex-last-message-${process.pid}-${Date.now()}.txt`
|
|
184
165
|
);
|
|
185
166
|
args.push('--output-last-message', lastMessageFile);
|
|
186
|
-
|
|
187
|
-
args.push('-');
|
|
167
|
+
args.push('-'); // Explicitly read from stdin
|
|
188
168
|
} else {
|
|
189
|
-
// Generic:
|
|
190
|
-
|
|
169
|
+
// Generic CLI: Fallback to temp file if not a known stdin consumer
|
|
170
|
+
// We assume generic tools might expect a filename as an argument.
|
|
171
|
+
const uniqueId = Date.now().toString(36) + Math.random().toString(36).substr(2, 5);
|
|
172
|
+
tempPromptFile = path.join(os.tmpdir(), `asm-prompt-${uniqueId}.md`);
|
|
173
|
+
fs.writeFileSync(tempPromptFile, promptText);
|
|
174
|
+
args.push(tempPromptFile);
|
|
191
175
|
}
|
|
192
176
|
|
|
193
177
|
console.log(` [LLM] Running: ${baseCmd} ${args.join(' ')}`);
|
|
194
178
|
|
|
195
|
-
// Prepare environment variables with API keys if provided
|
|
196
179
|
const env = { ...process.env };
|
|
197
180
|
if (apiKeys.gemini) env.GEMINI_API_KEY = apiKeys.gemini;
|
|
198
181
|
if (apiKeys.anthropic) env.ANTHROPIC_API_KEY = apiKeys.anthropic;
|
|
@@ -203,10 +186,12 @@ async function executeCLI(command, promptFile, options = {}, apiKeys = {}) {
|
|
|
203
186
|
env: env
|
|
204
187
|
});
|
|
205
188
|
|
|
206
|
-
//
|
|
207
|
-
if (
|
|
208
|
-
|
|
189
|
+
// Write prompt to stdin if it's a standard tool or we decided to use stdin
|
|
190
|
+
if (isStandardCLI) {
|
|
191
|
+
child.stdin.write(promptText);
|
|
192
|
+
child.stdin.end();
|
|
209
193
|
} else {
|
|
194
|
+
// For generic tools using temp files, just close stdin
|
|
210
195
|
child.stdin.end();
|
|
211
196
|
}
|
|
212
197
|
|
|
@@ -222,6 +207,11 @@ async function executeCLI(command, promptFile, options = {}, apiKeys = {}) {
|
|
|
222
207
|
});
|
|
223
208
|
|
|
224
209
|
child.on('close', (code) => {
|
|
210
|
+
// Cleanup temp file if used
|
|
211
|
+
if (tempPromptFile && fs.existsSync(tempPromptFile)) {
|
|
212
|
+
try { fs.unlinkSync(tempPromptFile); } catch {}
|
|
213
|
+
}
|
|
214
|
+
|
|
225
215
|
if (code === 0) {
|
|
226
216
|
if (baseCmd === 'codex') {
|
|
227
217
|
const outputFlagIndex = args.findIndex(a => a === '--output-last-message' || a === '-o');
|
|
@@ -247,6 +237,10 @@ async function executeCLI(command, promptFile, options = {}, apiKeys = {}) {
|
|
|
247
237
|
});
|
|
248
238
|
|
|
249
239
|
child.on('error', (err) => {
|
|
240
|
+
// Cleanup temp file if used
|
|
241
|
+
if (tempPromptFile && fs.existsSync(tempPromptFile)) {
|
|
242
|
+
try { fs.unlinkSync(tempPromptFile); } catch {}
|
|
243
|
+
}
|
|
250
244
|
reject(new Error(`Failed to execute CLI: ${err.message}`));
|
|
251
245
|
});
|
|
252
246
|
});
|
|
@@ -259,7 +253,6 @@ async function executeAPI(provider, model, prompt, apiKey, options = {}) {
|
|
|
259
253
|
console.log(` [LLM] Calling API: ${provider}/${model}`);
|
|
260
254
|
|
|
261
255
|
if (provider === 'anthropic') {
|
|
262
|
-
// Dynamic import to avoid requiring the package if not used
|
|
263
256
|
let Anthropic;
|
|
264
257
|
try {
|
|
265
258
|
Anthropic = require('@anthropic-ai/sdk');
|
|
@@ -341,8 +334,9 @@ export async function llm(context, options) {
|
|
|
341
334
|
const config = context._config || {};
|
|
342
335
|
const models = config.models || {};
|
|
343
336
|
const apiKeys = config.apiKeys || {};
|
|
344
|
-
|
|
345
|
-
|
|
337
|
+
|
|
338
|
+
// No longer needed to write to prompts/ directory here
|
|
339
|
+
|
|
346
340
|
// Look up the model command/config
|
|
347
341
|
const modelConfig = models[options.model];
|
|
348
342
|
|
|
@@ -356,13 +350,9 @@ export async function llm(context, options) {
|
|
|
356
350
|
// Build the full prompt
|
|
357
351
|
const fullPrompt = buildPrompt(context, options);
|
|
358
352
|
|
|
359
|
-
// Write to generated-prompt.md
|
|
360
|
-
const promptFile = writeGeneratedPrompt(workflowDir, fullPrompt);
|
|
361
|
-
console.log(` [LLM] Prompt written to: ${promptFile}`);
|
|
362
|
-
|
|
363
353
|
// Check if it's an API call or CLI
|
|
354
|
+
let result;
|
|
364
355
|
if (modelConfig.startsWith('api:')) {
|
|
365
|
-
// Format: api:provider:model
|
|
366
356
|
const parts = modelConfig.split(':');
|
|
367
357
|
const provider = parts[1];
|
|
368
358
|
const model = parts.slice(2).join(':');
|
|
@@ -375,11 +365,13 @@ export async function llm(context, options) {
|
|
|
375
365
|
);
|
|
376
366
|
}
|
|
377
367
|
|
|
378
|
-
|
|
368
|
+
result = await executeAPI(provider, model, fullPrompt, apiKey, options);
|
|
369
|
+
} else {
|
|
370
|
+
// CLI execution - pass fullPrompt string directly
|
|
371
|
+
result = await executeCLI(modelConfig, fullPrompt, options, apiKeys);
|
|
379
372
|
}
|
|
380
373
|
|
|
381
|
-
|
|
382
|
-
return executeCLI(modelConfig, promptFile, options, apiKeys);
|
|
374
|
+
return { ...result, fullPrompt };
|
|
383
375
|
}
|
|
384
376
|
|
|
385
377
|
/**
|
|
@@ -446,7 +438,7 @@ export function parseJSON(text) {
|
|
|
446
438
|
} catch {}
|
|
447
439
|
}
|
|
448
440
|
|
|
449
|
-
const arrayMatch = text.match(/\[[\s\S]*\]/);
|
|
441
|
+
const arrayMatch = text.match(/\{[\[\s\S]*\]\}/);
|
|
450
442
|
if (arrayMatch) {
|
|
451
443
|
try {
|
|
452
444
|
return JSON.parse(arrayMatch[0]);
|
|
@@ -469,4 +461,4 @@ export async function llmJSON(context, options) {
|
|
|
469
461
|
...response,
|
|
470
462
|
data: parseJSON(response.text)
|
|
471
463
|
};
|
|
472
|
-
}
|
|
464
|
+
}
|
package/lib/runtime/agent.js
CHANGED
|
@@ -34,11 +34,18 @@ export async function agent(name, params = {}) {
|
|
|
34
34
|
try {
|
|
35
35
|
const result = await executeAgent(runtime, name, params);
|
|
36
36
|
|
|
37
|
+
let prompt = undefined;
|
|
38
|
+
if (result && typeof result === 'object' && result._debug_prompt) {
|
|
39
|
+
prompt = result._debug_prompt;
|
|
40
|
+
delete result._debug_prompt;
|
|
41
|
+
}
|
|
42
|
+
|
|
37
43
|
console.log(` [Agent: ${name}] Completed`);
|
|
38
44
|
runtime.prependHistory({
|
|
39
45
|
event: 'AGENT_COMPLETED',
|
|
40
46
|
agent: name,
|
|
41
|
-
output: result
|
|
47
|
+
output: result,
|
|
48
|
+
prompt: prompt
|
|
42
49
|
});
|
|
43
50
|
|
|
44
51
|
return result;
|
|
@@ -243,11 +250,11 @@ async function executeMDAgent(runtime, agentPath, name, params) {
|
|
|
243
250
|
});
|
|
244
251
|
|
|
245
252
|
// Return the user's response as the agent result
|
|
246
|
-
return { [outputKey]: userResponse };
|
|
253
|
+
return { [outputKey]: userResponse, _debug_prompt: response.fullPrompt };
|
|
247
254
|
}
|
|
248
255
|
|
|
249
256
|
// Return result object
|
|
250
|
-
return { [outputKey]: output };
|
|
257
|
+
return { [outputKey]: output, _debug_prompt: response.fullPrompt };
|
|
251
258
|
}
|
|
252
259
|
|
|
253
260
|
/**
|
package/lib/runtime/runtime.js
CHANGED
|
@@ -38,7 +38,6 @@ export class WorkflowRuntime {
|
|
|
38
38
|
this.agentsDir = path.join(workflowDir, 'agents');
|
|
39
39
|
this.interactionsDir = path.join(workflowDir, 'interactions');
|
|
40
40
|
this.steeringDir = path.join(workflowDir, 'steering');
|
|
41
|
-
this.generatedPromptFile = path.join(this.stateDir, 'generated-prompt.md');
|
|
42
41
|
this.historyFile = path.join(this.stateDir, 'history.jsonl');
|
|
43
42
|
this.stateFile = path.join(this.stateDir, 'current.json');
|
|
44
43
|
|
|
@@ -361,11 +360,6 @@ export class WorkflowRuntime {
|
|
|
361
360
|
fs.unlinkSync(this.historyFile);
|
|
362
361
|
}
|
|
363
362
|
|
|
364
|
-
// 2. Delete generated-prompt.md file
|
|
365
|
-
if (fs.existsSync(this.generatedPromptFile)) {
|
|
366
|
-
fs.unlinkSync(this.generatedPromptFile);
|
|
367
|
-
}
|
|
368
|
-
|
|
369
363
|
// 3. Clear interactions directory
|
|
370
364
|
if (fs.existsSync(this.interactionsDir)) {
|
|
371
365
|
fs.rmSync(this.interactionsDir, { recursive: true, force: true });
|
package/lib/setup.js
CHANGED
|
@@ -173,7 +173,7 @@ Once you have it create a yoda-greeting.md file in root dir with the greeting.
|
|
|
173
173
|
You are a fast, direct worker. Do NOT investigate the codebase or read files unless strictly necessary. Perform the requested action immediately using the provided context. Avoid "thinking" steps or creating plans if the task is simple.
|
|
174
174
|
`;
|
|
175
175
|
|
|
176
|
-
const yodaNameCollectorAgent = `---
|
|
176
|
+
const yodaNameCollectorAgent = `---
|
|
177
177
|
model: low
|
|
178
178
|
output: name
|
|
179
179
|
---
|
|
@@ -288,22 +288,17 @@ ${workflowName}/
|
|
|
288
288
|
├── package.json # Sets "type": "module" for this workflow folder
|
|
289
289
|
├── agents/ # Custom agents (.js/.mjs/.cjs or .md)
|
|
290
290
|
├── interactions/ # Human-in-the-loop inputs (created at runtime)
|
|
291
|
-
├── state/ # Runtime state (current.json, history.jsonl)
|
|
291
|
+
├── state/ # Runtime state (current.json, history.jsonl, prompts/)
|
|
292
292
|
└── steering/ # Steering configuration
|
|
293
293
|
\\\`\\\`\\\`
|
|
294
294
|
|
|
295
295
|
## Usage
|
|
296
296
|
|
|
297
|
-
Run the workflow:
|
|
297
|
+
Run the workflow (or resume if interrupted):
|
|
298
298
|
\\\`\\\`\\\`bash
|
|
299
299
|
state-machine run ${workflowName}
|
|
300
300
|
\\\`\\\`\\\`
|
|
301
301
|
|
|
302
|
-
Resume a paused workflow:
|
|
303
|
-
\\\`\\\`\\\`bash
|
|
304
|
-
state-machine resume ${workflowName}
|
|
305
|
-
\\\`\\\`\\\`
|
|
306
|
-
|
|
307
302
|
Check status:
|
|
308
303
|
\\\`\\\`\\\`bash
|
|
309
304
|
state-machine status ${workflowName}
|
|
@@ -314,11 +309,21 @@ View history:
|
|
|
314
309
|
state-machine history ${workflowName}
|
|
315
310
|
\\\`\\\`\\\`
|
|
316
311
|
|
|
317
|
-
|
|
312
|
+
View trace logs in browser with live updates:
|
|
313
|
+
\\\`\\\`\\\`bash
|
|
314
|
+
state-machine follow ${workflowName}
|
|
315
|
+
\\\`\\\`\\\`
|
|
316
|
+
|
|
317
|
+
Reset state (clears memory/state):
|
|
318
318
|
\\\`\\\`\\\`bash
|
|
319
319
|
state-machine reset ${workflowName}
|
|
320
320
|
\\\`\\\`\\\`
|
|
321
321
|
|
|
322
|
+
Hard reset (clears everything: history/interactions/memory):
|
|
323
|
+
\\\`\\\`\\\`bash
|
|
324
|
+
state-machine reset-hard ${workflowName}
|
|
325
|
+
\\\`\\\`\\\`
|
|
326
|
+
|
|
322
327
|
## Writing Workflows
|
|
323
328
|
|
|
324
329
|
Edit \`workflow.js\` - write normal async JavaScript:
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Workflow Prompts Viewer</title>
|
|
7
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
8
|
+
<script>
|
|
9
|
+
tailwind.config = {
|
|
10
|
+
darkMode: 'class',
|
|
11
|
+
}
|
|
12
|
+
</script>
|
|
13
|
+
<script src="https://unpkg.com/react@18/umd/react.development.js" crossorigin></script>
|
|
14
|
+
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js" crossorigin></script>
|
|
15
|
+
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
|
16
|
+
<style>
|
|
17
|
+
.markdown-body { white-space: pre-wrap; font-family: monospace; }
|
|
18
|
+
/* Scrollbar styles for dark mode */
|
|
19
|
+
.dark ::-webkit-scrollbar { width: 10px; height: 10px; }
|
|
20
|
+
.dark ::-webkit-scrollbar-track { background: #000000; }
|
|
21
|
+
.dark ::-webkit-scrollbar-thumb { background: #27272a; border-radius: 5px; }
|
|
22
|
+
.dark ::-webkit-scrollbar-thumb:hover { background: #3f3f46; }
|
|
23
|
+
</style>
|
|
24
|
+
</head>
|
|
25
|
+
<body>
|
|
26
|
+
<div id="root"></div>
|
|
27
|
+
|
|
28
|
+
<script type="text/babel">
|
|
29
|
+
const { useState, useEffect } = React;
|
|
30
|
+
|
|
31
|
+
// Icons
|
|
32
|
+
const SunIcon = () => (
|
|
33
|
+
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
34
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
|
35
|
+
</svg>
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const MoonIcon = () => (
|
|
39
|
+
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
40
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
|
41
|
+
</svg>
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
const CopyIcon = () => (
|
|
45
|
+
<svg xmlns="http://www.w3.org/2000/svg" className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
46
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
|
47
|
+
</svg>
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const CheckIcon = () => (
|
|
51
|
+
<svg xmlns="http://www.w3.org/2000/svg" className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
52
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
53
|
+
</svg>
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
function CopyButton({ text, className }) {
|
|
57
|
+
const [copied, setCopied] = useState(false);
|
|
58
|
+
|
|
59
|
+
const handleCopy = () => {
|
|
60
|
+
const content = typeof text === 'object' ? JSON.stringify(text, null, 2) : text;
|
|
61
|
+
navigator.clipboard.writeText(content);
|
|
62
|
+
setCopied(true);
|
|
63
|
+
setTimeout(() => setCopied(false), 2000);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<button
|
|
68
|
+
onClick={handleCopy}
|
|
69
|
+
className={`flex items-center space-x-1 text-[9px] uppercase tracking-wider transition-colors hover:text-blue-500 focus:outline-none ${className}`}
|
|
70
|
+
title="Copy to clipboard"
|
|
71
|
+
>
|
|
72
|
+
{copied ? <CheckIcon /> : <CopyIcon />}
|
|
73
|
+
<span>{copied ? 'Copied' : 'Copy'}</span>
|
|
74
|
+
</button>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function App() {
|
|
79
|
+
const [history, setHistory] = useState([]);
|
|
80
|
+
const [loading, setLoading] = useState(true);
|
|
81
|
+
const [error, setError] = useState(null);
|
|
82
|
+
const [workflowName, setWorkflowName] = useState('');
|
|
83
|
+
const [theme, setTheme] = useState('dark');
|
|
84
|
+
const [sortOrder, setSortOrder] = useState('newest'); // 'newest' | 'oldest'
|
|
85
|
+
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
const fetchData = () => {
|
|
88
|
+
fetch('/api/history')
|
|
89
|
+
.then(res => res.json())
|
|
90
|
+
.then(data => {
|
|
91
|
+
setHistory(data.entries);
|
|
92
|
+
setWorkflowName(data.workflowName);
|
|
93
|
+
setLoading(false);
|
|
94
|
+
})
|
|
95
|
+
.catch(err => {
|
|
96
|
+
setError(err.message);
|
|
97
|
+
setLoading(false);
|
|
98
|
+
});
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// Initial fetch
|
|
102
|
+
fetchData();
|
|
103
|
+
|
|
104
|
+
// Setup SSE
|
|
105
|
+
const eventSource = new EventSource('/api/events');
|
|
106
|
+
eventSource.onmessage = (event) => {
|
|
107
|
+
if (event.data === 'update') {
|
|
108
|
+
fetchData();
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
return () => {
|
|
113
|
+
eventSource.close();
|
|
114
|
+
};
|
|
115
|
+
}, []);
|
|
116
|
+
|
|
117
|
+
const toggleTheme = () => {
|
|
118
|
+
setTheme(prev => prev === 'dark' ? 'light' : 'dark');
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const toggleSort = () => {
|
|
122
|
+
setSortOrder(prev => prev === 'newest' ? 'oldest' : 'newest');
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
if (loading) return (
|
|
126
|
+
<div className={theme}>
|
|
127
|
+
<div className="flex items-center justify-center h-screen bg-gray-50 dark:bg-black text-gray-500 dark:text-zinc-500">
|
|
128
|
+
Loading history...
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
if (error) return (
|
|
134
|
+
<div className={theme}>
|
|
135
|
+
<div className="flex items-center justify-center h-screen bg-gray-50 dark:bg-black text-red-500">
|
|
136
|
+
Error: {error}
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
// Filter for events we want to display
|
|
142
|
+
let visibleEvents = history.filter(item =>
|
|
143
|
+
[
|
|
144
|
+
'WORKFLOW_STARTED', 'WORKFLOW_COMPLETED', 'WORKFLOW_FAILED', 'WORKFLOW_RESET',
|
|
145
|
+
'AGENT_STARTED', 'AGENT_COMPLETED', 'AGENT_FAILED',
|
|
146
|
+
'INTERACTION_REQUESTED', 'INTERACTION_RESOLVED'
|
|
147
|
+
].includes(item.event)
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
// Apply Sort
|
|
151
|
+
// History from API is "Newest First" (index 0 is latest)
|
|
152
|
+
if (sortOrder === 'oldest') {
|
|
153
|
+
visibleEvents = [...visibleEvents].reverse();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const formatTime = (ts) => new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
157
|
+
|
|
158
|
+
return (
|
|
159
|
+
<div className={theme}>
|
|
160
|
+
<div className="min-h-screen bg-gray-50 dark:bg-black transition-colors duration-200">
|
|
161
|
+
<div className="max-w-5xl mx-auto min-h-screen flex flex-col">
|
|
162
|
+
|
|
163
|
+
{/* Sticky Header */}
|
|
164
|
+
<header className="sticky top-0 z-50 py-4 px-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-colors">
|
|
165
|
+
<div className="flex-1">
|
|
166
|
+
<h1 className="text-xl font-bold text-gray-800 dark:text-zinc-100 transition-colors uppercase tracking-tight">{workflowName}</h1>
|
|
167
|
+
<p className="text-gray-500 dark:text-zinc-500 text-xs mt-0.5">Runtime History & Prompt Logs</p>
|
|
168
|
+
</div>
|
|
169
|
+
<div className="flex items-center space-x-2">
|
|
170
|
+
<button
|
|
171
|
+
onClick={toggleSort}
|
|
172
|
+
className="p-2 rounded-full bg-gray-200 dark:bg-zinc-900 text-gray-800 dark:text-zinc-200 hover:bg-gray-300 dark:hover:bg-zinc-800 transition-colors"
|
|
173
|
+
title={sortOrder === 'newest' ? "Sort: Newest First" : "Sort: Oldest First"}
|
|
174
|
+
>
|
|
175
|
+
{sortOrder === 'newest' ?
|
|
176
|
+
<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" /></svg>
|
|
177
|
+
:
|
|
178
|
+
<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>
|
|
179
|
+
}
|
|
180
|
+
</button>
|
|
181
|
+
<button
|
|
182
|
+
onClick={toggleTheme}
|
|
183
|
+
className="p-2 rounded-full bg-gray-200 dark:bg-zinc-900 text-gray-800 dark:text-zinc-200 hover:bg-gray-300 dark:hover:bg-zinc-800 transition-colors"
|
|
184
|
+
title="Toggle Theme"
|
|
185
|
+
>
|
|
186
|
+
{theme === 'dark' ? <SunIcon /> : <MoonIcon />}
|
|
187
|
+
</button>
|
|
188
|
+
</div>
|
|
189
|
+
</header>
|
|
190
|
+
|
|
191
|
+
{/* Content */}
|
|
192
|
+
<div className="flex-1 space-y-8 px-6">
|
|
193
|
+
{visibleEvents.length === 0 && (
|
|
194
|
+
<div className="text-center text-gray-400 py-10">No execution history found.</div>
|
|
195
|
+
)}
|
|
196
|
+
|
|
197
|
+
{visibleEvents.map((item, idx) => {
|
|
198
|
+
// 1. Lifecycle Events
|
|
199
|
+
if (item.event.startsWith('WORKFLOW_')) {
|
|
200
|
+
const colorMap = {
|
|
201
|
+
'WORKFLOW_STARTED': 'text-green-500 dark:text-green-400',
|
|
202
|
+
'WORKFLOW_COMPLETED': 'text-blue-500 dark:text-blue-400',
|
|
203
|
+
'WORKFLOW_FAILED': 'text-red-500 dark:text-red-400',
|
|
204
|
+
'WORKFLOW_RESET': 'text-yellow-500 dark:text-yellow-400'
|
|
205
|
+
};
|
|
206
|
+
return (
|
|
207
|
+
<div key={idx} className="flex flex-col items-center py-4">
|
|
208
|
+
<div className="flex items-center space-x-3 text-[10px] uppercase tracking-[0.2em] font-bold text-zinc-400 dark:text-zinc-600">
|
|
209
|
+
<div className="h-px w-8 bg-zinc-200 dark:bg-zinc-800"></div>
|
|
210
|
+
<span className={colorMap[item.event]}>{item.event.replace('_', ' ')}</span>
|
|
211
|
+
<span>{formatTime(item.timestamp)}</span>
|
|
212
|
+
<div className="h-px w-8 bg-zinc-200 dark:bg-zinc-800"></div>
|
|
213
|
+
</div>
|
|
214
|
+
{item.error && <div className="mt-2 text-red-500 text-xs font-mono">{item.error}</div>}
|
|
215
|
+
</div>
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// 2. Agent Started
|
|
220
|
+
if (item.event === 'AGENT_STARTED') {
|
|
221
|
+
return (
|
|
222
|
+
<div key={idx} className="flex justify-start">
|
|
223
|
+
<div className="text-[10px] text-zinc-400 dark:text-zinc-600 uppercase tracking-widest flex items-center space-x-2">
|
|
224
|
+
<span className="w-1.5 h-1.5 rounded-full bg-blue-500/50 animate-pulse"></span>
|
|
225
|
+
<span>Agent <span className="text-zinc-600 dark:text-zinc-400">{item.agent}</span> started</span>
|
|
226
|
+
<span>•</span>
|
|
227
|
+
<span>{formatTime(item.timestamp)}</span>
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// 3. Agent Failed
|
|
234
|
+
if (item.event === 'AGENT_FAILED') {
|
|
235
|
+
return (
|
|
236
|
+
<div key={idx} className="flex justify-center">
|
|
237
|
+
<div className="bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-900/50 rounded-lg px-4 py-2 text-red-600 dark:text-red-400 text-xs font-mono w-full max-w-2xl">
|
|
238
|
+
<div className="font-bold mb-1 underline">AGENT FAILED: {item.agent}</div>
|
|
239
|
+
<div>{item.error}</div>
|
|
240
|
+
</div>
|
|
241
|
+
</div>
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// 4. Interaction Requested
|
|
246
|
+
if (item.event === 'INTERACTION_REQUESTED') {
|
|
247
|
+
return (
|
|
248
|
+
<div key={idx} className="flex justify-center">
|
|
249
|
+
<div className="bg-zinc-100 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 border-dashed rounded-lg px-6 py-4 text-center max-w-md w-full">
|
|
250
|
+
<div className="text-[10px] text-zinc-400 dark:text-zinc-600 uppercase font-bold tracking-widest mb-1">Human Intervention Needed</div>
|
|
251
|
+
<div className="text-xs text-zinc-600 dark:text-zinc-400 italic">Waiting for response to "{item.slug}"...</div>
|
|
252
|
+
</div>
|
|
253
|
+
</div>
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// 5. Agent Completed / Interaction Resolved (The Bubbles)
|
|
258
|
+
if (item.event === 'AGENT_COMPLETED' || item.event === 'INTERACTION_RESOLVED') {
|
|
259
|
+
return (
|
|
260
|
+
<div key={idx} className="flex flex-col space-y-4">
|
|
261
|
+
{/* Header Line */}
|
|
262
|
+
<div className="flex items-center justify-center space-x-2 text-[10px] text-zinc-400 dark:text-zinc-600 uppercase tracking-widest">
|
|
263
|
+
<span className="font-black text-zinc-500 dark:text-zinc-400">{item.agent || item.slug}</span>
|
|
264
|
+
<span>•</span>
|
|
265
|
+
<span>COMPLETED</span>
|
|
266
|
+
<span>•</span>
|
|
267
|
+
<span>{formatTime(item.timestamp)}</span>
|
|
268
|
+
</div>
|
|
269
|
+
|
|
270
|
+
{/* Output (Response) - NOW ON TOP */}
|
|
271
|
+
{(item.output || item.result) && (
|
|
272
|
+
<div className="flex justify-end w-full group">
|
|
273
|
+
<div className="max-w-[85%] bg-blue-50 dark:bg-blue-950/20 border border-blue-100 dark:border-blue-900/40 rounded-2xl rounded-tr-none shadow-sm p-6 transition-all hover:border-blue-200 dark:hover:border-blue-800 relative">
|
|
274
|
+
<div className="flex justify-between items-center mb-3">
|
|
275
|
+
<div className="text-[9px] font-black text-blue-300 dark:text-blue-800/60 uppercase tracking-[0.2em] text-right w-full">Output / Response</div>
|
|
276
|
+
<div className="absolute top-4 right-4 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
277
|
+
<CopyButton text={item.output || item.result} className="text-blue-400 hover:text-blue-600 dark:text-blue-600 dark:hover:text-blue-400" />
|
|
278
|
+
</div>
|
|
279
|
+
</div>
|
|
280
|
+
<div className="markdown-body text-gray-800 dark:text-zinc-200 text-sm overflow-x-auto leading-relaxed">
|
|
281
|
+
{typeof item.output === 'object' ? JSON.stringify(item.output, null, 2) : (item.output || item.result)}
|
|
282
|
+
</div>
|
|
283
|
+
</div>
|
|
284
|
+
</div>
|
|
285
|
+
)}
|
|
286
|
+
|
|
287
|
+
{/* Prompt (Input) - NOW ON BOTTOM */}
|
|
288
|
+
{item.prompt && (
|
|
289
|
+
<div className="flex justify-start w-full group">
|
|
290
|
+
<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-6 transition-all hover:border-zinc-300 dark:hover:border-zinc-700 relative">
|
|
291
|
+
<div className="flex justify-between items-center mb-3">
|
|
292
|
+
<div className="text-[9px] font-black text-zinc-300 dark:text-zinc-700 uppercase tracking-[0.2em]">Prompt / Input</div>
|
|
293
|
+
<div className="absolute top-4 right-4 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
294
|
+
<CopyButton text={item.prompt} className="text-gray-400 hover:text-gray-600 dark:text-zinc-600 dark:hover:text-zinc-400" />
|
|
295
|
+
</div>
|
|
296
|
+
</div>
|
|
297
|
+
<div className="markdown-body text-gray-800 dark:text-zinc-300 text-sm overflow-x-auto leading-relaxed">
|
|
298
|
+
{item.prompt}
|
|
299
|
+
</div>
|
|
300
|
+
</div>
|
|
301
|
+
</div>
|
|
302
|
+
)}
|
|
303
|
+
</div>
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return null;
|
|
308
|
+
})}
|
|
309
|
+
</div>
|
|
310
|
+
|
|
311
|
+
<footer className="mt-20 mb-8 text-center text-zinc-400 dark:text-zinc-800 text-[10px] uppercase tracking-[0.3em] transition-colors">
|
|
312
|
+
Agent State Machine • Debug Terminal
|
|
313
|
+
</footer>
|
|
314
|
+
</div>
|
|
315
|
+
</div>
|
|
316
|
+
</div>
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const root = ReactDOM.createRoot(document.getElementById('root'));
|
|
321
|
+
root.render(<App />);
|
|
322
|
+
</script>
|
|
323
|
+
</body>
|
|
324
|
+
</html>
|
package/lib/ui/server.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
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
|
+
}
|