agent-state-machine 2.0.0 → 2.0.2
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/bin/cli.js +185 -4
- package/lib/remote/client.js +2 -1
- package/lib/runtime/runtime.js +4 -1
- package/package.json +4 -2
- package/vercel-server/api/events/[token].js +119 -0
- package/vercel-server/api/history/[token].js +51 -0
- package/vercel-server/api/session/[token].js +49 -0
- package/vercel-server/api/submit/[token].js +94 -0
- package/vercel-server/api/ws/cli.js +186 -0
- package/vercel-server/local-server.js +1 -1
- /package/{lib → vercel-server}/ui/index.html +0 -0
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 { generateSessionToken } from '../lib/remote/client.js';
|
|
8
9
|
|
|
9
10
|
import { startLocalServer } from '../vercel-server/local-server.js';
|
|
10
11
|
|
|
@@ -35,6 +36,9 @@ Usage:
|
|
|
35
36
|
state-machine --setup <workflow-name> Create a new workflow project
|
|
36
37
|
state-machine run <workflow-name> Run a workflow (remote follow enabled by default)
|
|
37
38
|
state-machine run <workflow-name> -l Run with local server (localhost:3000)
|
|
39
|
+
state-machine run <workflow-name> -n Generate a new remote follow path
|
|
40
|
+
state-machine run <workflow-name> -reset Reset workflow state before running
|
|
41
|
+
state-machine run <workflow-name> -reset-hard Hard reset workflow before running
|
|
38
42
|
|
|
39
43
|
state-machine status [workflow-name] Show current state (or list all)
|
|
40
44
|
state-machine history <workflow-name> [limit] Show execution history logs
|
|
@@ -46,6 +50,9 @@ Usage:
|
|
|
46
50
|
Options:
|
|
47
51
|
--setup, -s Initialize a new workflow with directory structure
|
|
48
52
|
--local, -l Use local server instead of remote (starts on localhost:3000)
|
|
53
|
+
--new, -n Generate a new remote follow path
|
|
54
|
+
-reset Reset workflow state before running
|
|
55
|
+
-reset-hard Hard reset workflow before running
|
|
49
56
|
--help, -h Show help
|
|
50
57
|
--version, -v Show version
|
|
51
58
|
|
|
@@ -80,6 +87,155 @@ function resolveWorkflowEntry(workflowDir) {
|
|
|
80
87
|
return null;
|
|
81
88
|
}
|
|
82
89
|
|
|
90
|
+
function findConfigObjectRange(source) {
|
|
91
|
+
const match = source.match(/export\s+const\s+config\s*=/);
|
|
92
|
+
if (!match) return null;
|
|
93
|
+
const startSearch = match.index + match[0].length;
|
|
94
|
+
const braceStart = source.indexOf('{', startSearch);
|
|
95
|
+
if (braceStart === -1) return null;
|
|
96
|
+
|
|
97
|
+
let depth = 0;
|
|
98
|
+
let inString = null;
|
|
99
|
+
let inLineComment = false;
|
|
100
|
+
let inBlockComment = false;
|
|
101
|
+
let escape = false;
|
|
102
|
+
|
|
103
|
+
for (let i = braceStart; i < source.length; i += 1) {
|
|
104
|
+
const ch = source[i];
|
|
105
|
+
const next = source[i + 1];
|
|
106
|
+
|
|
107
|
+
if (inLineComment) {
|
|
108
|
+
if (ch === '\n') inLineComment = false;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (inBlockComment) {
|
|
113
|
+
if (ch === '*' && next === '/') {
|
|
114
|
+
inBlockComment = false;
|
|
115
|
+
i += 1;
|
|
116
|
+
}
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (inString) {
|
|
121
|
+
if (escape) {
|
|
122
|
+
escape = false;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (ch === '\\') {
|
|
126
|
+
escape = true;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (ch === inString) {
|
|
130
|
+
inString = null;
|
|
131
|
+
}
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (ch === '"' || ch === '\'' || ch === '`') {
|
|
136
|
+
inString = ch;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (ch === '/' && next === '/') {
|
|
141
|
+
inLineComment = true;
|
|
142
|
+
i += 1;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (ch === '/' && next === '*') {
|
|
147
|
+
inBlockComment = true;
|
|
148
|
+
i += 1;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (ch === '{') {
|
|
153
|
+
depth += 1;
|
|
154
|
+
} else if (ch === '}') {
|
|
155
|
+
depth -= 1;
|
|
156
|
+
if (depth === 0) {
|
|
157
|
+
return { start: braceStart, end: i };
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function readRemotePathFromWorkflow(workflowFile) {
|
|
166
|
+
const source = fs.readFileSync(workflowFile, 'utf-8');
|
|
167
|
+
const range = findConfigObjectRange(source);
|
|
168
|
+
if (!range) return null;
|
|
169
|
+
const configSource = source.slice(range.start, range.end + 1);
|
|
170
|
+
const match = configSource.match(/\bremotePath\s*:\s*(['"`])([^'"`]+)\1/);
|
|
171
|
+
return match ? match[2] : null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function writeRemotePathToWorkflow(workflowFile, remotePath) {
|
|
175
|
+
const source = fs.readFileSync(workflowFile, 'utf-8');
|
|
176
|
+
const range = findConfigObjectRange(source);
|
|
177
|
+
const remoteLine = `remotePath: "${remotePath}"`;
|
|
178
|
+
|
|
179
|
+
if (!range) {
|
|
180
|
+
const hasConfigExport = /export\s+const\s+config\s*=/.test(source);
|
|
181
|
+
if (hasConfigExport) {
|
|
182
|
+
throw new Error('Workflow config export is not an object literal; add remotePath manually.');
|
|
183
|
+
}
|
|
184
|
+
const trimmed = source.replace(/\s*$/, '');
|
|
185
|
+
const appended = `${trimmed}\n\nexport const config = {\n ${remoteLine}\n};\n`;
|
|
186
|
+
fs.writeFileSync(workflowFile, appended);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const configSource = source.slice(range.start, range.end + 1);
|
|
191
|
+
const remoteRegex = /\bremotePath\s*:\s*(['"`])([^'"`]*?)\1/;
|
|
192
|
+
let updatedConfigSource;
|
|
193
|
+
|
|
194
|
+
if (remoteRegex.test(configSource)) {
|
|
195
|
+
updatedConfigSource = configSource.replace(remoteRegex, remoteLine);
|
|
196
|
+
} else {
|
|
197
|
+
const inner = configSource.slice(1, -1);
|
|
198
|
+
const indentMatch = inner.match(/\n([ \t]+)\S/);
|
|
199
|
+
const indent = indentMatch ? indentMatch[1] : ' ';
|
|
200
|
+
const trimmedInner = inner.replace(/\s*$/, '');
|
|
201
|
+
const hasContent = trimmedInner.trim().length > 0;
|
|
202
|
+
let updatedInner = trimmedInner;
|
|
203
|
+
|
|
204
|
+
if (hasContent) {
|
|
205
|
+
for (let i = updatedInner.length - 1; i >= 0; i -= 1) {
|
|
206
|
+
const ch = updatedInner[i];
|
|
207
|
+
if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') continue;
|
|
208
|
+
if (ch !== ',') {
|
|
209
|
+
updatedInner += ',';
|
|
210
|
+
}
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const needsNewline = updatedInner && !updatedInner.endsWith('\n');
|
|
216
|
+
const insert = `${indent}${remoteLine},\n`;
|
|
217
|
+
const newInner = hasContent
|
|
218
|
+
? `${updatedInner}${needsNewline ? '\n' : ''}${insert}`
|
|
219
|
+
: `\n${insert}`;
|
|
220
|
+
updatedConfigSource = `{${newInner}}`;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const updatedSource =
|
|
224
|
+
source.slice(0, range.start) +
|
|
225
|
+
updatedConfigSource +
|
|
226
|
+
source.slice(range.end + 1);
|
|
227
|
+
fs.writeFileSync(workflowFile, updatedSource);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function ensureRemotePath(workflowFile, { forceNew = false } = {}) {
|
|
231
|
+
const existing = readRemotePathFromWorkflow(workflowFile);
|
|
232
|
+
if (existing && !forceNew) return existing;
|
|
233
|
+
|
|
234
|
+
const remotePath = generateSessionToken();
|
|
235
|
+
writeRemotePathToWorkflow(workflowFile, remotePath);
|
|
236
|
+
return remotePath;
|
|
237
|
+
}
|
|
238
|
+
|
|
83
239
|
function readState(workflowDir) {
|
|
84
240
|
const stateFile = path.join(workflowDir, 'state', 'current.json');
|
|
85
241
|
if (!fs.existsSync(stateFile)) return null;
|
|
@@ -145,7 +301,16 @@ function listWorkflows() {
|
|
|
145
301
|
console.log('');
|
|
146
302
|
}
|
|
147
303
|
|
|
148
|
-
async function runOrResume(
|
|
304
|
+
async function runOrResume(
|
|
305
|
+
workflowName,
|
|
306
|
+
{
|
|
307
|
+
remoteEnabled = false,
|
|
308
|
+
useLocalServer = false,
|
|
309
|
+
forceNewRemotePath = false,
|
|
310
|
+
preReset = false,
|
|
311
|
+
preResetHard = false
|
|
312
|
+
} = {}
|
|
313
|
+
) {
|
|
149
314
|
const workflowDir = resolveWorkflowDir(workflowName);
|
|
150
315
|
|
|
151
316
|
if (!fs.existsSync(workflowDir)) {
|
|
@@ -161,6 +326,12 @@ async function runOrResume(workflowName, { remoteEnabled = false, useLocalServer
|
|
|
161
326
|
}
|
|
162
327
|
|
|
163
328
|
const runtime = new WorkflowRuntime(workflowDir);
|
|
329
|
+
if (preResetHard) {
|
|
330
|
+
runtime.resetHard();
|
|
331
|
+
} else if (preReset) {
|
|
332
|
+
runtime.reset();
|
|
333
|
+
}
|
|
334
|
+
|
|
164
335
|
const workflowUrl = pathToFileURL(entry).href;
|
|
165
336
|
|
|
166
337
|
let localServer = null;
|
|
@@ -183,7 +354,8 @@ async function runOrResume(workflowName, { remoteEnabled = false, useLocalServer
|
|
|
183
354
|
|
|
184
355
|
// Enable remote follow mode if we have a URL
|
|
185
356
|
if (remoteUrl) {
|
|
186
|
-
|
|
357
|
+
const sessionToken = ensureRemotePath(entry, { forceNew: forceNewRemotePath });
|
|
358
|
+
await runtime.enableRemote(remoteUrl, { sessionToken });
|
|
187
359
|
}
|
|
188
360
|
|
|
189
361
|
try {
|
|
@@ -227,15 +399,24 @@ async function main() {
|
|
|
227
399
|
case 'run':
|
|
228
400
|
if (!workflowName) {
|
|
229
401
|
console.error('Error: Workflow name required');
|
|
230
|
-
console.error(`Usage: state-machine ${command} <workflow-name> [--local]`);
|
|
402
|
+
console.error(`Usage: state-machine ${command} <workflow-name> [--local] [--new] [-reset] [-reset-hard]`);
|
|
231
403
|
process.exit(1);
|
|
232
404
|
}
|
|
233
405
|
{
|
|
234
406
|
// Remote is enabled by default, --local uses local server instead
|
|
235
407
|
const useLocalServer = args.includes('--local') || args.includes('-l');
|
|
408
|
+
const forceNewRemotePath = args.includes('--new') || args.includes('-n');
|
|
409
|
+
const preReset = args.includes('-reset');
|
|
410
|
+
const preResetHard = args.includes('-reset-hard');
|
|
236
411
|
const remoteEnabled = !useLocalServer; // Use Vercel if not local
|
|
237
412
|
try {
|
|
238
|
-
await runOrResume(workflowName, {
|
|
413
|
+
await runOrResume(workflowName, {
|
|
414
|
+
remoteEnabled,
|
|
415
|
+
useLocalServer,
|
|
416
|
+
forceNewRemotePath,
|
|
417
|
+
preReset,
|
|
418
|
+
preResetHard
|
|
419
|
+
});
|
|
239
420
|
} catch (err) {
|
|
240
421
|
console.error('Error:', err.message || String(err));
|
|
241
422
|
process.exit(1);
|
package/lib/remote/client.js
CHANGED
|
@@ -80,6 +80,7 @@ export class RemoteClient {
|
|
|
80
80
|
* @param {string} options.workflowName - Name of the workflow
|
|
81
81
|
* @param {function} options.onInteractionResponse - Callback when interaction response received
|
|
82
82
|
* @param {function} [options.onStatusChange] - Callback when connection status changes
|
|
83
|
+
* @param {string} [options.sessionToken] - Optional session token to reuse
|
|
83
84
|
*/
|
|
84
85
|
constructor(options) {
|
|
85
86
|
this.serverUrl = options.serverUrl.replace(/\/$/, ''); // Remove trailing slash
|
|
@@ -87,7 +88,7 @@ export class RemoteClient {
|
|
|
87
88
|
this.onInteractionResponse = options.onInteractionResponse;
|
|
88
89
|
this.onStatusChange = options.onStatusChange || (() => {});
|
|
89
90
|
|
|
90
|
-
this.sessionToken = generateSessionToken();
|
|
91
|
+
this.sessionToken = options.sessionToken || generateSessionToken();
|
|
91
92
|
this.connected = false;
|
|
92
93
|
this.polling = false;
|
|
93
94
|
this.pollAbortController = null;
|
package/lib/runtime/runtime.js
CHANGED
|
@@ -507,12 +507,15 @@ export class WorkflowRuntime {
|
|
|
507
507
|
/**
|
|
508
508
|
* Enable remote follow mode
|
|
509
509
|
* @param {string} serverUrl - Base URL of the remote server
|
|
510
|
+
* @param {object} [options]
|
|
511
|
+
* @param {string} [options.sessionToken] - Optional session token to reuse
|
|
510
512
|
* @returns {Promise<string>} The remote URL for browser access
|
|
511
513
|
*/
|
|
512
|
-
async enableRemote(serverUrl) {
|
|
514
|
+
async enableRemote(serverUrl, options = {}) {
|
|
513
515
|
this.remoteClient = new RemoteClient({
|
|
514
516
|
serverUrl,
|
|
515
517
|
workflowName: this.workflowName,
|
|
518
|
+
sessionToken: options.sessionToken,
|
|
516
519
|
onInteractionResponse: (slug, targetKey, response) => {
|
|
517
520
|
this.handleRemoteInteraction(slug, targetKey, response);
|
|
518
521
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-state-machine",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A workflow orchestrator for running agents and scripts in sequence with state management",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -29,6 +29,8 @@
|
|
|
29
29
|
"bin",
|
|
30
30
|
"lib",
|
|
31
31
|
"vercel-server/local-server.js",
|
|
32
|
-
"vercel-server/public"
|
|
32
|
+
"vercel-server/public",
|
|
33
|
+
"vercel-server/ui",
|
|
34
|
+
"vercel-server/api"
|
|
33
35
|
]
|
|
34
36
|
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File: /vercel-server/api/events/[token].js
|
|
3
|
+
*
|
|
4
|
+
* SSE endpoint for browser connections
|
|
5
|
+
* Streams history and real-time events to connected browsers
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
getSession,
|
|
10
|
+
getHistory,
|
|
11
|
+
redis,
|
|
12
|
+
KEYS,
|
|
13
|
+
refreshSession,
|
|
14
|
+
} from '../../lib/redis.js';
|
|
15
|
+
|
|
16
|
+
export const config = {
|
|
17
|
+
maxDuration: 60, // Maximum 60 seconds for SSE
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export default async function handler(req, res) {
|
|
21
|
+
const { token } = req.query;
|
|
22
|
+
|
|
23
|
+
if (!token) {
|
|
24
|
+
return res.status(400).json({ error: 'Missing token parameter' });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Validate session
|
|
28
|
+
const session = await getSession(token);
|
|
29
|
+
if (!session) {
|
|
30
|
+
return res.status(404).json({ error: 'Session not found or expired' });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Set up SSE headers
|
|
34
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
35
|
+
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
|
36
|
+
res.setHeader('Connection', 'keep-alive');
|
|
37
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
38
|
+
|
|
39
|
+
// Send retry interval
|
|
40
|
+
res.write('retry: 3000\n\n');
|
|
41
|
+
|
|
42
|
+
// Send initial data
|
|
43
|
+
try {
|
|
44
|
+
// Send connection status
|
|
45
|
+
res.write(`data: ${JSON.stringify({
|
|
46
|
+
type: 'status',
|
|
47
|
+
cliConnected: session.cliConnected,
|
|
48
|
+
workflowName: session.workflowName,
|
|
49
|
+
})}\n\n`);
|
|
50
|
+
|
|
51
|
+
// Send existing history
|
|
52
|
+
const history = await getHistory(token);
|
|
53
|
+
res.write(`data: ${JSON.stringify({
|
|
54
|
+
type: 'history',
|
|
55
|
+
entries: history,
|
|
56
|
+
})}\n\n`);
|
|
57
|
+
|
|
58
|
+
// Poll for new events
|
|
59
|
+
const eventsListKey = `${KEYS.events(token)}:list`;
|
|
60
|
+
let lastEventIndex = 0;
|
|
61
|
+
|
|
62
|
+
// Get current list length to start from
|
|
63
|
+
const currentLength = await redis.llen(eventsListKey);
|
|
64
|
+
lastEventIndex = currentLength;
|
|
65
|
+
|
|
66
|
+
const pollInterval = setInterval(async () => {
|
|
67
|
+
try {
|
|
68
|
+
// Refresh session TTL
|
|
69
|
+
await refreshSession(token);
|
|
70
|
+
|
|
71
|
+
// Check for new events
|
|
72
|
+
const newLength = await redis.llen(eventsListKey);
|
|
73
|
+
|
|
74
|
+
if (newLength > lastEventIndex) {
|
|
75
|
+
// Get new events (newest first)
|
|
76
|
+
const newEvents = await redis.lrange(eventsListKey, 0, newLength - lastEventIndex - 1);
|
|
77
|
+
|
|
78
|
+
for (const event of newEvents.reverse()) {
|
|
79
|
+
const eventData = typeof event === 'object' ? event : JSON.parse(event);
|
|
80
|
+
res.write(`data: ${JSON.stringify(eventData)}\n\n`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
lastEventIndex = newLength;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Check CLI status
|
|
87
|
+
const updatedSession = await getSession(token);
|
|
88
|
+
if (updatedSession && updatedSession.cliConnected !== session.cliConnected) {
|
|
89
|
+
session.cliConnected = updatedSession.cliConnected;
|
|
90
|
+
res.write(`data: ${JSON.stringify({
|
|
91
|
+
type: updatedSession.cliConnected ? 'cli_reconnected' : 'cli_disconnected',
|
|
92
|
+
})}\n\n`);
|
|
93
|
+
}
|
|
94
|
+
} catch (err) {
|
|
95
|
+
console.error('Error polling events:', err);
|
|
96
|
+
}
|
|
97
|
+
}, 2000);
|
|
98
|
+
|
|
99
|
+
// Clean up on client disconnect
|
|
100
|
+
req.on('close', () => {
|
|
101
|
+
clearInterval(pollInterval);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// For Vercel, we need to keep the connection alive but also respect function timeout
|
|
105
|
+
// Send keepalive pings
|
|
106
|
+
const keepaliveInterval = setInterval(() => {
|
|
107
|
+
res.write(': keepalive\n\n');
|
|
108
|
+
}, 15000);
|
|
109
|
+
|
|
110
|
+
req.on('close', () => {
|
|
111
|
+
clearInterval(keepaliveInterval);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
} catch (err) {
|
|
115
|
+
console.error('SSE error:', err);
|
|
116
|
+
res.write(`data: ${JSON.stringify({ type: 'error', message: err.message })}\n\n`);
|
|
117
|
+
res.end();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File: /vercel-server/api/history/[token].js
|
|
3
|
+
*
|
|
4
|
+
* REST endpoint to get session history
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { getSession, getHistory, refreshSession } from '../../lib/redis.js';
|
|
8
|
+
|
|
9
|
+
export default async function handler(req, res) {
|
|
10
|
+
// Enable CORS
|
|
11
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
12
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
|
13
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
14
|
+
|
|
15
|
+
if (req.method === 'OPTIONS') {
|
|
16
|
+
return res.status(200).end();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (req.method !== 'GET') {
|
|
20
|
+
return res.status(405).json({ error: 'Method not allowed' });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const { token } = req.query;
|
|
24
|
+
|
|
25
|
+
if (!token) {
|
|
26
|
+
return res.status(400).json({ error: 'Missing token parameter' });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
// Validate session
|
|
31
|
+
const session = await getSession(token);
|
|
32
|
+
if (!session) {
|
|
33
|
+
return res.status(404).json({ error: 'Session not found or expired' });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Refresh session TTL
|
|
37
|
+
await refreshSession(token);
|
|
38
|
+
|
|
39
|
+
// Get history
|
|
40
|
+
const entries = await getHistory(token);
|
|
41
|
+
|
|
42
|
+
return res.status(200).json({
|
|
43
|
+
workflowName: session.workflowName,
|
|
44
|
+
cliConnected: session.cliConnected,
|
|
45
|
+
entries,
|
|
46
|
+
});
|
|
47
|
+
} catch (err) {
|
|
48
|
+
console.error('Error getting history:', err);
|
|
49
|
+
return res.status(500).json({ error: err.message });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// /vercel-server/api/session/[token].js
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { readFile } from 'fs/promises';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
|
|
6
|
+
import { getSession } from '../../lib/redis.js';
|
|
7
|
+
|
|
8
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
9
|
+
const __dirname = path.dirname(__filename);
|
|
10
|
+
|
|
11
|
+
let cachedTemplate = null;
|
|
12
|
+
async function getTemplate() {
|
|
13
|
+
if (cachedTemplate) return cachedTemplate;
|
|
14
|
+
// Point to the unified template in vercel-server/ui/index.html
|
|
15
|
+
const templatePath = path.join(__dirname, '..', '..', 'ui', 'index.html');
|
|
16
|
+
cachedTemplate = await readFile(templatePath, 'utf8');
|
|
17
|
+
return cachedTemplate;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export default async function handler(req, res) {
|
|
21
|
+
const { token } = req.query;
|
|
22
|
+
|
|
23
|
+
if (!token) return res.status(400).send('Missing session token');
|
|
24
|
+
|
|
25
|
+
const session = await getSession(token);
|
|
26
|
+
if (!session) {
|
|
27
|
+
return res.status(404).send(`<!DOCTYPE html>
|
|
28
|
+
<html>
|
|
29
|
+
<head>
|
|
30
|
+
<title>Session Not Found</title>
|
|
31
|
+
<style>body { font-family: system-ui; max-width: 600px; margin: 100px auto; text-align: center; }</style>
|
|
32
|
+
</head>
|
|
33
|
+
<body>
|
|
34
|
+
<h1>Session Not Found</h1>
|
|
35
|
+
<p>This session has expired or does not exist.</p>
|
|
36
|
+
<p>Sessions expire 30 minutes after the last activity.</p>
|
|
37
|
+
</body>
|
|
38
|
+
</html>`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const template = await getTemplate();
|
|
42
|
+
|
|
43
|
+
const html = template
|
|
44
|
+
.replace(/\{\{SESSION_TOKEN\}\}/g, token)
|
|
45
|
+
.replace(/\{\{WORKFLOW_NAME\}\}/g, session.workflowName || 'Workflow');
|
|
46
|
+
|
|
47
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
48
|
+
res.send(html);
|
|
49
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File: /vercel-server/api/submit/[token].js
|
|
3
|
+
*
|
|
4
|
+
* POST endpoint for browser interaction submissions
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
getSession,
|
|
9
|
+
addHistoryEvent,
|
|
10
|
+
publishEvent,
|
|
11
|
+
redis,
|
|
12
|
+
KEYS,
|
|
13
|
+
} from '../../lib/redis.js';
|
|
14
|
+
|
|
15
|
+
export default async function handler(req, res) {
|
|
16
|
+
// Enable CORS
|
|
17
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
18
|
+
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
|
|
19
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
20
|
+
|
|
21
|
+
if (req.method === 'OPTIONS') {
|
|
22
|
+
return res.status(200).end();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (req.method !== 'POST') {
|
|
26
|
+
return res.status(405).json({ error: 'Method not allowed' });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const { token } = req.query;
|
|
30
|
+
|
|
31
|
+
if (!token) {
|
|
32
|
+
return res.status(400).json({ error: 'Missing token parameter' });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
// Validate session
|
|
37
|
+
const session = await getSession(token);
|
|
38
|
+
if (!session) {
|
|
39
|
+
return res.status(404).json({ error: 'Session not found or expired' });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Check if CLI is connected
|
|
43
|
+
if (!session.cliConnected) {
|
|
44
|
+
return res.status(503).json({ error: 'CLI is disconnected. Cannot submit interaction.' });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Parse body
|
|
48
|
+
const body = typeof req.body === 'string' ? JSON.parse(req.body) : req.body;
|
|
49
|
+
const { slug, targetKey, response } = body;
|
|
50
|
+
|
|
51
|
+
if (!slug || !response) {
|
|
52
|
+
return res.status(400).json({ error: 'Missing required fields: slug, response' });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Validate response size (max 1MB)
|
|
56
|
+
if (response.length > 1024 * 1024) {
|
|
57
|
+
return res.status(413).json({ error: 'Response too large (max 1MB)' });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Push to pending interactions list (for CLI to poll)
|
|
61
|
+
const pendingKey = `${KEYS.interactions(token)}:pending`;
|
|
62
|
+
await redis.rpush(pendingKey, JSON.stringify({
|
|
63
|
+
slug,
|
|
64
|
+
targetKey: targetKey || `_interaction_${slug}`,
|
|
65
|
+
response,
|
|
66
|
+
}));
|
|
67
|
+
|
|
68
|
+
// Set TTL on pending list
|
|
69
|
+
await redis.expire(pendingKey, 300); // 5 minutes
|
|
70
|
+
|
|
71
|
+
// Log event to history (include answer preview)
|
|
72
|
+
const event = {
|
|
73
|
+
timestamp: new Date().toISOString(),
|
|
74
|
+
event: 'INTERACTION_SUBMITTED',
|
|
75
|
+
slug,
|
|
76
|
+
targetKey: targetKey || `_interaction_${slug}`,
|
|
77
|
+
answer: response.substring(0, 200) + (response.length > 200 ? '...' : ''),
|
|
78
|
+
source: 'remote',
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
await addHistoryEvent(token, event);
|
|
82
|
+
|
|
83
|
+
// Notify other browsers
|
|
84
|
+
await publishEvent(token, {
|
|
85
|
+
type: 'event',
|
|
86
|
+
...event,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
return res.status(200).json({ success: true });
|
|
90
|
+
} catch (err) {
|
|
91
|
+
console.error('Error submitting interaction:', err);
|
|
92
|
+
return res.status(500).json({ error: err.message });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File: /vercel-server/api/ws/cli.js
|
|
3
|
+
*
|
|
4
|
+
* HTTP-based endpoint for CLI communication (replaces WebSocket for serverless)
|
|
5
|
+
*
|
|
6
|
+
* POST: Receive messages from CLI (session_init, event, session_end)
|
|
7
|
+
* GET: Long-poll for interaction responses
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
createSession,
|
|
12
|
+
getSession,
|
|
13
|
+
updateSession,
|
|
14
|
+
setCLIConnected,
|
|
15
|
+
addHistoryEvent,
|
|
16
|
+
addHistoryEvents,
|
|
17
|
+
publishEvent,
|
|
18
|
+
redis,
|
|
19
|
+
KEYS,
|
|
20
|
+
} from '../../lib/redis.js';
|
|
21
|
+
|
|
22
|
+
export default async function handler(req, res) {
|
|
23
|
+
// Enable CORS
|
|
24
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
25
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
26
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
27
|
+
|
|
28
|
+
if (req.method === 'OPTIONS') {
|
|
29
|
+
return res.status(200).end();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (req.method === 'POST') {
|
|
33
|
+
return handlePost(req, res);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (req.method === 'GET') {
|
|
37
|
+
return handleGet(req, res);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return res.status(405).json({ error: 'Method not allowed' });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Handle POST requests from CLI
|
|
45
|
+
*/
|
|
46
|
+
async function handlePost(req, res) {
|
|
47
|
+
const body = typeof req.body === 'string' ? JSON.parse(req.body) : req.body;
|
|
48
|
+
const { type, sessionToken } = body;
|
|
49
|
+
|
|
50
|
+
if (!sessionToken) {
|
|
51
|
+
return res.status(400).json({ error: 'Missing sessionToken' });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
switch (type) {
|
|
56
|
+
case 'session_init': {
|
|
57
|
+
const { workflowName, history } = body;
|
|
58
|
+
|
|
59
|
+
// Create session
|
|
60
|
+
await createSession(sessionToken, { workflowName, cliConnected: true });
|
|
61
|
+
|
|
62
|
+
// Store initial history
|
|
63
|
+
if (history && history.length > 0) {
|
|
64
|
+
await addHistoryEvents(sessionToken, history);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Notify browsers
|
|
68
|
+
await publishEvent(sessionToken, {
|
|
69
|
+
type: 'cli_connected',
|
|
70
|
+
workflowName,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
return res.status(200).json({ success: true });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
case 'session_reconnect': {
|
|
77
|
+
const { workflowName } = body;
|
|
78
|
+
|
|
79
|
+
// Update session as connected
|
|
80
|
+
await setCLIConnected(sessionToken, true);
|
|
81
|
+
|
|
82
|
+
// Notify browsers
|
|
83
|
+
await publishEvent(sessionToken, {
|
|
84
|
+
type: 'cli_reconnected',
|
|
85
|
+
workflowName,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
return res.status(200).json({ success: true });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
case 'event': {
|
|
92
|
+
const { timestamp, event, ...eventData } = body;
|
|
93
|
+
|
|
94
|
+
const historyEvent = {
|
|
95
|
+
timestamp: timestamp || new Date().toISOString(),
|
|
96
|
+
event,
|
|
97
|
+
...eventData,
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// Remove sessionToken and type from event data
|
|
101
|
+
delete historyEvent.sessionToken;
|
|
102
|
+
delete historyEvent.type;
|
|
103
|
+
|
|
104
|
+
// Add to history
|
|
105
|
+
await addHistoryEvent(sessionToken, historyEvent);
|
|
106
|
+
|
|
107
|
+
// Notify browsers
|
|
108
|
+
await publishEvent(sessionToken, {
|
|
109
|
+
type: 'event',
|
|
110
|
+
...historyEvent,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
return res.status(200).json({ success: true });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
case 'session_end': {
|
|
117
|
+
const { reason } = body;
|
|
118
|
+
|
|
119
|
+
// Mark CLI as disconnected
|
|
120
|
+
await setCLIConnected(sessionToken, false);
|
|
121
|
+
|
|
122
|
+
// Notify browsers
|
|
123
|
+
await publishEvent(sessionToken, {
|
|
124
|
+
type: 'cli_disconnected',
|
|
125
|
+
reason,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
return res.status(200).json({ success: true });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
default:
|
|
132
|
+
return res.status(400).json({ error: `Unknown message type: ${type}` });
|
|
133
|
+
}
|
|
134
|
+
} catch (err) {
|
|
135
|
+
console.error('Error handling CLI message:', err);
|
|
136
|
+
return res.status(500).json({ error: err.message });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Handle GET requests - long-poll for interaction responses
|
|
142
|
+
*/
|
|
143
|
+
async function handleGet(req, res) {
|
|
144
|
+
const { token, timeout = '30000' } = req.query;
|
|
145
|
+
|
|
146
|
+
if (!token) {
|
|
147
|
+
return res.status(400).json({ error: 'Missing token parameter' });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const session = await getSession(token);
|
|
151
|
+
if (!session) {
|
|
152
|
+
return res.status(404).json({ error: 'Session not found' });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const timeoutMs = Math.min(parseInt(timeout, 10), 55000); // Max 55s for Vercel
|
|
156
|
+
const channel = KEYS.interactions(token);
|
|
157
|
+
|
|
158
|
+
// Check for pending interactions using a list
|
|
159
|
+
const pendingKey = `${channel}:pending`;
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
// Try to get a pending interaction
|
|
163
|
+
const startTime = Date.now();
|
|
164
|
+
|
|
165
|
+
while (Date.now() - startTime < timeoutMs) {
|
|
166
|
+
const pending = await redis.lpop(pendingKey);
|
|
167
|
+
|
|
168
|
+
if (pending) {
|
|
169
|
+
const data = typeof pending === 'object' ? pending : JSON.parse(pending);
|
|
170
|
+
return res.status(200).json({
|
|
171
|
+
type: 'interaction_response',
|
|
172
|
+
...data,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Wait before checking again
|
|
177
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Timeout - no interaction received
|
|
181
|
+
return res.status(204).end();
|
|
182
|
+
} catch (err) {
|
|
183
|
+
console.error('Error polling for interactions:', err);
|
|
184
|
+
return res.status(500).json({ error: err.message });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
@@ -342,7 +342,7 @@ async function handleSubmitPost(req, res, token) {
|
|
|
342
342
|
/**
|
|
343
343
|
* Serve session UI
|
|
344
344
|
*/
|
|
345
|
-
const MASTER_TEMPLATE_PATH = path.join(__dirname, '
|
|
345
|
+
const MASTER_TEMPLATE_PATH = path.join(__dirname, 'ui', 'index.html');
|
|
346
346
|
|
|
347
347
|
/**
|
|
348
348
|
* Get session HTML by reading the master template from lib/ui/index.html
|
|
File without changes
|