agent-state-machine 2.0.4 → 2.0.5

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.
@@ -164,6 +164,18 @@ export class RemoteClient {
164
164
  });
165
165
  }
166
166
 
167
+ /**
168
+ * Sync history to remote (for manual edits to history.jsonl)
169
+ * @param {Array} history - Array of history entries
170
+ */
171
+ async sendHistorySync(history = []) {
172
+ await this.send({
173
+ type: 'history_sync',
174
+ sessionToken: this.sessionToken,
175
+ history,
176
+ });
177
+ }
178
+
167
179
  /**
168
180
  * Send an event to the server
169
181
  * @param {object} event - Event object with timestamp, event type, etc.
@@ -164,6 +164,9 @@ export class WorkflowRuntime {
164
164
 
165
165
  const line = JSON.stringify(entry) + '\n';
166
166
 
167
+ // Track when we're writing to avoid triggering the file watcher
168
+ this._lastHistoryWrite = Date.now();
169
+
167
170
  // Prepend to file (read existing, write new + existing)
168
171
  let existing = '';
169
172
  if (fs.existsSync(this.historyFile)) {
@@ -539,16 +542,63 @@ export class WorkflowRuntime {
539
542
  this.remoteEnabled = true;
540
543
  this.remoteUrl = this.remoteClient.getRemoteUrl();
541
544
 
545
+ // Watch history.jsonl for manual edits and sync to remote
546
+ this.startHistoryWatcher();
547
+
542
548
  console.log(`\n${C.cyan}${C.bold}Remote follow enabled${C.reset}`);
543
549
  console.log(` ${C.dim}URL:${C.reset} ${this.remoteUrl}\n`);
544
550
 
545
551
  return this.remoteUrl;
546
552
  }
547
553
 
554
+ /**
555
+ * Start watching history.jsonl for manual edits
556
+ */
557
+ startHistoryWatcher() {
558
+ if (this.historyWatcher) return;
559
+
560
+ // Debounce to avoid multiple syncs for rapid changes
561
+ let debounceTimer = null;
562
+
563
+ try {
564
+ this.historyWatcher = fs.watch(this.historyFile, (eventType) => {
565
+ if (eventType !== 'change') return;
566
+
567
+ // Debounce: wait 300ms after last change before syncing
568
+ if (debounceTimer) clearTimeout(debounceTimer);
569
+
570
+ debounceTimer = setTimeout(async () => {
571
+ // Don't sync if we just wrote to the file ourselves (within 500ms)
572
+ if (this._lastHistoryWrite && Date.now() - this._lastHistoryWrite < 500) return;
573
+
574
+ if (this.remoteClient && this.remoteEnabled) {
575
+ const history = this.loadHistory();
576
+ await this.remoteClient.sendHistorySync(history);
577
+ console.log(`${C.dim}Remote: History synced from file${C.reset}`);
578
+ }
579
+ }, 300);
580
+ });
581
+ } catch (err) {
582
+ // File might not exist yet, that's ok
583
+ }
584
+ }
585
+
586
+ /**
587
+ * Stop watching history.jsonl
588
+ */
589
+ stopHistoryWatcher() {
590
+ if (this.historyWatcher) {
591
+ this.historyWatcher.close();
592
+ this.historyWatcher = null;
593
+ }
594
+ }
595
+
548
596
  /**
549
597
  * Disable remote follow mode and disconnect
550
598
  */
551
599
  async disableRemote() {
600
+ this.stopHistoryWatcher();
601
+
552
602
  if (this.remoteClient) {
553
603
  await this.remoteClient.disconnect();
554
604
  this.remoteClient = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-state-machine",
3
- "version": "2.0.4",
3
+ "version": "2.0.5",
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",
@@ -2,14 +2,14 @@
2
2
  * File: /vercel-server/api/events/[token].js
3
3
  *
4
4
  * SSE endpoint for browser connections
5
- * Streams history and real-time events to connected browsers
5
+ * Streams events to connected browsers from a single source of truth (events list)
6
6
  */
7
7
 
8
8
  import {
9
9
  getSession,
10
- getHistory,
11
- redis,
12
- KEYS,
10
+ getEvents,
11
+ getEventsLength,
12
+ getEventsRange,
13
13
  refreshSession,
14
14
  } from '../../lib/redis.js';
15
15
 
@@ -48,32 +48,15 @@ export default async function handler(req, res) {
48
48
  workflowName: session.workflowName,
49
49
  })}\n\n`);
50
50
 
51
- // Send existing history
52
- const history = await getHistory(token);
51
+ // Send all existing events (single source of truth)
52
+ const events = await getEvents(token);
53
53
  res.write(`data: ${JSON.stringify({
54
54
  type: 'history',
55
- entries: history,
55
+ entries: events,
56
56
  })}\n\n`);
57
57
 
58
- // Poll for new events
59
- const eventsListKey = `${KEYS.events(token)}:list`;
60
- let lastEventIndex = 0;
61
-
62
- // If history is empty, seed from the event list to catch early events.
63
- const currentLength = await redis.llen(eventsListKey);
64
- // if (history.length === 0 && currentLength > 0) {
65
- // const existingEvents = await redis.lrange(eventsListKey, 0, -1);
66
- // const entries = existingEvents
67
- // .map((event) => (typeof event === 'object' ? event : JSON.parse(event)))
68
- // .filter(Boolean);
69
- // res.write(`data: ${JSON.stringify({
70
- // type: 'history',
71
- // entries,
72
- // })}\n\n`);
73
- // lastEventIndex = currentLength;
74
- // } else {
75
- // lastEventIndex = currentLength;
76
- // }
58
+ // Track current position for polling new events
59
+ let lastEventIndex = await getEventsLength(token);
77
60
 
78
61
  const pollInterval = setInterval(async () => {
79
62
  try {
@@ -81,15 +64,19 @@ export default async function handler(req, res) {
81
64
  await refreshSession(token);
82
65
 
83
66
  // Check for new events
84
- const newLength = await redis.llen(eventsListKey);
67
+ const newLength = await getEventsLength(token);
85
68
 
86
69
  if (newLength > lastEventIndex) {
87
- // Get new events (newest first)
88
- const newEvents = await redis.lrange(eventsListKey, 0, newLength - lastEventIndex - 1);
89
-
90
- for (const event of newEvents.reverse()) {
91
- const eventData = typeof event === 'object' ? event : JSON.parse(event);
92
- res.write(`data: ${JSON.stringify(eventData)}\n\n`);
70
+ // Get new events (they're prepended, so newest are at the start)
71
+ const newCount = newLength - lastEventIndex;
72
+ const newEvents = await getEventsRange(token, 0, newCount - 1);
73
+
74
+ // Send in chronological order (oldest first of the new batch)
75
+ for (const eventData of newEvents.reverse()) {
76
+ res.write(`data: ${JSON.stringify({
77
+ type: 'event',
78
+ ...eventData,
79
+ })}\n\n`);
93
80
  }
94
81
 
95
82
  lastEventIndex = newLength;
@@ -106,14 +93,13 @@ export default async function handler(req, res) {
106
93
  } catch (err) {
107
94
  console.error('Error polling events:', err);
108
95
  }
109
- }, 2000);
96
+ }, 1000); // Poll every 1 second for faster updates
110
97
 
111
98
  // Clean up on client disconnect
112
99
  req.on('close', () => {
113
100
  clearInterval(pollInterval);
114
101
  });
115
102
 
116
- // For Vercel, we need to keep the connection alive but also respect function timeout
117
103
  // Send keepalive pings
118
104
  const keepaliveInterval = setInterval(() => {
119
105
  res.write(': keepalive\n\n');
@@ -12,9 +12,8 @@ import {
12
12
  getSession,
13
13
  updateSession,
14
14
  setCLIConnected,
15
- addHistoryEvent,
16
- addHistoryEvents,
17
- publishEvent,
15
+ addEvent,
16
+ setEvents,
18
17
  redis,
19
18
  KEYS,
20
19
  } from '../../lib/redis.js';
@@ -59,19 +58,8 @@ async function handlePost(req, res) {
59
58
  // Create session
60
59
  await createSession(sessionToken, { workflowName, cliConnected: true });
61
60
 
62
- // Replace any existing history and event stream with the provided snapshot
63
- await redis.del(KEYS.history(sessionToken), `${KEYS.events(sessionToken)}:list`);
64
-
65
- // Store initial history
66
- if (history && history.length > 0) {
67
- await addHistoryEvents(sessionToken, history);
68
- }
69
-
70
- // Notify browsers
71
- await publishEvent(sessionToken, {
72
- type: 'cli_connected',
73
- workflowName,
74
- });
61
+ // Replace events with the provided history snapshot (single source of truth)
62
+ await setEvents(sessionToken, history || []);
75
63
 
76
64
  return res.status(200).json({ success: true });
77
65
  }
@@ -82,9 +70,10 @@ async function handlePost(req, res) {
82
70
  // Update session as connected
83
71
  await setCLIConnected(sessionToken, true);
84
72
 
85
- // Notify browsers
86
- await publishEvent(sessionToken, {
87
- type: 'cli_reconnected',
73
+ // Add reconnect event to events list
74
+ await addEvent(sessionToken, {
75
+ timestamp: new Date().toISOString(),
76
+ event: 'CLI_RECONNECTED',
88
77
  workflowName,
89
78
  });
90
79
 
@@ -104,14 +93,8 @@ async function handlePost(req, res) {
104
93
  delete historyEvent.sessionToken;
105
94
  delete historyEvent.type;
106
95
 
107
- // Add to history
108
- await addHistoryEvent(sessionToken, historyEvent);
109
-
110
- // Notify browsers
111
- await publishEvent(sessionToken, {
112
- type: 'event',
113
- ...historyEvent,
114
- });
96
+ // Add to events list (single source of truth)
97
+ await addEvent(sessionToken, historyEvent);
115
98
 
116
99
  return res.status(200).json({ success: true });
117
100
  }
@@ -122,15 +105,25 @@ async function handlePost(req, res) {
122
105
  // Mark CLI as disconnected
123
106
  await setCLIConnected(sessionToken, false);
124
107
 
125
- // Notify browsers
126
- await publishEvent(sessionToken, {
127
- type: 'cli_disconnected',
108
+ // Add disconnect event to events list
109
+ await addEvent(sessionToken, {
110
+ timestamp: new Date().toISOString(),
111
+ event: 'CLI_DISCONNECTED',
128
112
  reason,
129
113
  });
130
114
 
131
115
  return res.status(200).json({ success: true });
132
116
  }
133
117
 
118
+ case 'history_sync': {
119
+ const { history } = body;
120
+
121
+ // Replace events with synced history (for manual edits to history.jsonl)
122
+ await setEvents(sessionToken, history || []);
123
+
124
+ return res.status(200).json({ success: true });
125
+ }
126
+
134
127
  default:
135
128
  return res.status(400).json({ error: `Unknown message type: ${type}` });
136
129
  }