agent-state-machine 2.0.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-state-machine",
3
- "version": "2.0.0",
3
+ "version": "2.0.1",
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, '..', 'lib', 'ui', 'index.html');
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