@cydm/happy-elves 0.1.0-beta.39 → 0.1.0-beta.40

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.
@@ -176,7 +176,13 @@ export function createExternalAgentRuntimeProvider(options) {
176
176
  }));
177
177
  }
178
178
  function clientForAgent(agent) {
179
- return clientsByAgent.get(agent);
179
+ const client = clientsByAgent.get(agent);
180
+ if (!client)
181
+ return undefined;
182
+ if (client.isAvailable() && client.listAgents().some((profile) => profile.id === agent))
183
+ return client;
184
+ clientsByAgent.delete(agent);
185
+ return undefined;
180
186
  }
181
187
  function clientForHandle(handle) {
182
188
  return handle.externalProviderId ? clients.find((client) => client.id === handle.externalProviderId) : undefined;
@@ -248,7 +254,7 @@ export function createExternalAgentRuntimeProvider(options) {
248
254
  id: "fallback",
249
255
  load: (cursor, pageLimit) => filterHistoricalSessionPage(fallbackHistoricalSessionPage({ ...input, cursor, limit: pageLimit }), input.cwdMode),
250
256
  },
251
- ...clients.map((client) => ({
257
+ ...clients.filter((client) => client.isAvailable()).map((client) => ({
252
258
  id: `external:${client.id}`,
253
259
  load: (cursor, pageLimit) => filterHistoricalSessionPage(client.request("listHistoricalSessions", { ...input, cursor, limit: pageLimit }).catch(emptyPageUnlessCursorStale), input.cwdMode),
254
260
  })),
@@ -470,6 +476,9 @@ class ExternalProviderClient {
470
476
  listAgents() {
471
477
  return this.agents;
472
478
  }
479
+ isAvailable() {
480
+ return this.ready && !!this.child && !this.child.killed && !this.exited;
481
+ }
473
482
  diagnostics() {
474
483
  const processAvailable = !!this.child && !this.child.killed && !this.exited;
475
484
  return {
@@ -648,6 +657,7 @@ class ExternalProviderClient {
648
657
  this.ready = false;
649
658
  this.exited = true;
650
659
  this.lastError = error.message;
660
+ this.agents = [];
651
661
  for (const [id, pending] of this.pending) {
652
662
  clearTimeout(pending.timer);
653
663
  pending.reject(error);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cydm/happy-elves-daemon",
3
- "version": "0.1.0-beta.39",
3
+ "version": "0.1.0-beta.40",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }
@@ -116,6 +116,26 @@ export function initDb(db) {
116
116
  FOREIGN KEY (machine_id) REFERENCES machines(id) ON DELETE CASCADE
117
117
  );
118
118
 
119
+ CREATE TABLE IF NOT EXISTS pending_session_head_advances (
120
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
121
+ account_id TEXT NOT NULL,
122
+ session_id TEXT NOT NULL,
123
+ machine_id TEXT NOT NULL,
124
+ message_id TEXT,
125
+ current_head TEXT NOT NULL,
126
+ last_turn_id TEXT NOT NULL,
127
+ basis_turn_id TEXT NOT NULL,
128
+ basis_event_message_id TEXT NOT NULL,
129
+ basis_turn_seq INTEGER NOT NULL,
130
+ basis_occurred_at INTEGER,
131
+ encrypted_metadata TEXT,
132
+ created_at INTEGER NOT NULL,
133
+ updated_at INTEGER NOT NULL,
134
+ FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE,
135
+ FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE,
136
+ FOREIGN KEY (machine_id) REFERENCES machines(id) ON DELETE CASCADE
137
+ );
138
+
119
139
  CREATE TABLE IF NOT EXISTS command_results (
120
140
  account_id TEXT NOT NULL,
121
141
  request_id TEXT NOT NULL,
@@ -131,6 +151,8 @@ export function initDb(db) {
131
151
  CREATE INDEX IF NOT EXISTS idx_events_session ON session_events(session_id, id);
132
152
  CREATE INDEX IF NOT EXISTS idx_turn_aliases_runtime
133
153
  ON session_turn_aliases(account_id, session_id, runtime_turn_id);
154
+ CREATE INDEX IF NOT EXISTS idx_pending_head_session
155
+ ON pending_session_head_advances(account_id, session_id, machine_id);
134
156
  CREATE INDEX IF NOT EXISTS idx_command_results_created ON command_results(created_at);
135
157
  CREATE INDEX IF NOT EXISTS idx_controller_invites_account ON controller_invites(account_id, created_at DESC);
136
158
  `);
@@ -157,6 +179,9 @@ export function initDb(db) {
157
179
  CREATE UNIQUE INDEX IF NOT EXISTS idx_events_machine_message
158
180
  ON session_events(account_id, machine_id, message_id)
159
181
  WHERE message_id IS NOT NULL;
182
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_pending_head_machine_message
183
+ ON pending_session_head_advances(account_id, machine_id, message_id)
184
+ WHERE message_id IS NOT NULL;
160
185
  `);
161
186
  repairHistoricalEventTimestamps(db);
162
187
  }
@@ -142,6 +142,7 @@ export function handleMachineMessage(context, connection, message) {
142
142
  acknowledgeMachineMessage();
143
143
  return;
144
144
  }
145
+ clearPendingSessionHeadAdvances(db, connection.accountId, message.sessionId, connection.machineId);
145
146
  const row = db.prepare("SELECT * FROM sessions WHERE id = ?").get(message.sessionId);
146
147
  if (row)
147
148
  broadcastControllers(connection.accountId, { type: "server:session", session: sessionSnapshot(row) });
@@ -272,6 +273,7 @@ export function handleMachineMessage(context, connection, message) {
272
273
  WHERE account_id = ? AND session_id = ? AND machine_id = ?`).run(connection.accountId, message.sessionId, connection.machineId);
273
274
  db.prepare(`DELETE FROM session_turn_aliases
274
275
  WHERE account_id = ? AND session_id = ? AND machine_id = ?`).run(connection.accountId, message.sessionId, connection.machineId);
276
+ clearPendingSessionHeadAdvances(db, connection.accountId, message.sessionId, connection.machineId);
275
277
  for (const event of message.events) {
276
278
  const result = db
277
279
  .prepare(`INSERT INTO session_events
@@ -323,7 +325,10 @@ export function handleMachineMessage(context, connection, message) {
323
325
  db.exec("ROLLBACK");
324
326
  throw error;
325
327
  }
326
- const row = db.prepare("SELECT * FROM sessions WHERE id = ?").get(message.sessionId);
328
+ const pendingSessionRow = inserted.length > 0
329
+ ? applyPendingSessionHeadAdvances(db, connection.accountId, message.sessionId, connection.machineId, now())
330
+ : undefined;
331
+ const row = pendingSessionRow ?? db.prepare("SELECT * FROM sessions WHERE id = ?").get(message.sessionId);
327
332
  if (row) {
328
333
  const session = sessionSnapshot(row);
329
334
  broadcastControllers(connection.accountId, {
@@ -387,7 +392,8 @@ export function handleMachineMessage(context, connection, message) {
387
392
  db.exec("ROLLBACK");
388
393
  throw error;
389
394
  }
390
- const row = db.prepare("SELECT * FROM sessions WHERE id = ?").get(message.sessionId);
395
+ const pendingSessionRow = applyPendingSessionHeadAdvances(db, connection.accountId, message.sessionId, connection.machineId, now());
396
+ const row = pendingSessionRow ?? db.prepare("SELECT * FROM sessions WHERE id = ?").get(message.sessionId);
391
397
  if (row) {
392
398
  const session = sessionSnapshot(row);
393
399
  broadcastControllers(connection.accountId, {
@@ -403,10 +409,19 @@ export function handleMachineMessage(context, connection, message) {
403
409
  return;
404
410
  }
405
411
  if (message.type === "machine:sessionHeadAdvanced") {
406
- const sessionRow = db
407
- .prepare("SELECT * FROM sessions WHERE id = ? AND account_id = ? AND machine_id = ?")
408
- .get(message.sessionId, connection.accountId, connection.machineId);
409
- if (!sessionRow) {
412
+ const ts = now();
413
+ const input = {
414
+ accountId: connection.accountId,
415
+ sessionId: message.sessionId,
416
+ machineId: connection.machineId,
417
+ messageId,
418
+ currentHead: message.currentHead,
419
+ lastTurnId: message.lastTurnId,
420
+ basis: message.basis,
421
+ ...(message.encryptedMetadata ? { encryptedMetadata: message.encryptedMetadata } : {}),
422
+ };
423
+ const result = applySessionHeadAdvance(db, input, ts);
424
+ if (result.status === "session-not-found") {
410
425
  broadcastError(connection.accountId, {
411
426
  type: "server:error",
412
427
  code: "SESSION_NOT_FOUND",
@@ -415,16 +430,17 @@ export function handleMachineMessage(context, connection, message) {
415
430
  acknowledgeMachineMessage();
416
431
  return;
417
432
  }
418
- const basisEvent = resolveHeadAdvanceBasisEvent(db, connection.accountId, message.sessionId, connection.machineId, message.basis);
419
- if (!basisEvent) {
433
+ if (result.status === "missing") {
434
+ storePendingSessionHeadAdvance(db, input, ts);
420
435
  broadcastError(connection.accountId, {
421
436
  type: "server:error",
422
437
  code: "SESSION_HEAD_BASIS_MISSING",
423
- message: "Head advance basis event is not available yet",
438
+ message: "Head advance basis event is not available yet; update will be applied when the event arrives",
424
439
  });
440
+ acknowledgeMachineMessage();
425
441
  return;
426
442
  }
427
- if (basisEvent.turn_id !== message.basis.turnId || basisEvent.turn_seq !== message.basis.turnSeq) {
443
+ if (result.status === "mismatch") {
428
444
  broadcastError(connection.accountId, {
429
445
  type: "server:error",
430
446
  code: "SESSION_HEAD_BASIS_MISMATCH",
@@ -433,25 +449,7 @@ export function handleMachineMessage(context, connection, message) {
433
449
  acknowledgeMachineMessage();
434
450
  return;
435
451
  }
436
- const nextOrder = logicalEventOrder(basisEvent);
437
- const currentOrder = sessionHeadBasisOrder(db, sessionRow);
438
- const rewriteFenced = typeof sessionRow.head_basis_message_id === "string" &&
439
- sessionRow.head_basis_message_id.startsWith(REWRITE_HEAD_BASIS_PREFIX);
440
- const shouldAdvance = !rewriteFenced && compareHeadBasisOrder(nextOrder, currentOrder) > 0;
441
- const ts = now();
442
- if (shouldAdvance) {
443
- db.prepare(`UPDATE sessions
444
- SET current_head = ?,
445
- last_turn_id = ?,
446
- head_basis_event_id = ?,
447
- head_basis_message_id = ?,
448
- head_basis_logical_time = ?,
449
- head_basis_turn_seq = ?,
450
- encrypted_metadata = COALESCE(?, encrypted_metadata),
451
- updated_at = ?
452
- WHERE id = ? AND account_id = ? AND machine_id = ?`).run(message.currentHead, message.lastTurnId, basisEvent.id, basisEvent.message_id, nextOrder.time, nextOrder.seq, message.encryptedMetadata ? JSON.stringify(message.encryptedMetadata) : null, ts, message.sessionId, connection.accountId, connection.machineId);
453
- }
454
- const row = db.prepare("SELECT * FROM sessions WHERE id = ?").get(message.sessionId);
452
+ const row = result.session ?? db.prepare("SELECT * FROM sessions WHERE id = ?").get(message.sessionId);
455
453
  if (row)
456
454
  broadcastControllers(connection.accountId, { type: "server:session", session: sessionSnapshot(row) });
457
455
  acknowledgeMachineMessage();
@@ -483,7 +481,10 @@ export function handleMachineMessage(context, connection, message) {
483
481
  occurredAt: message.occurredAt,
484
482
  payload: message.payload,
485
483
  });
484
+ const pendingSessionRow = applyPendingSessionHeadAdvances(db, connection.accountId, message.sessionId, connection.machineId, now());
486
485
  broadcastControllers(connection.accountId, { type: "server:event", event });
486
+ if (pendingSessionRow)
487
+ broadcastControllers(connection.accountId, { type: "server:session", session: sessionSnapshot(pendingSessionRow) });
487
488
  acknowledgeMachineMessage();
488
489
  return;
489
490
  }
@@ -582,6 +583,94 @@ export function handleMachineMessage(context, connection, message) {
582
583
  function machineMessageId(message) {
583
584
  return "messageId" in message && typeof message.messageId === "string" ? message.messageId : undefined;
584
585
  }
586
+ function storePendingSessionHeadAdvance(db, input, ts) {
587
+ db.prepare(`INSERT INTO pending_session_head_advances
588
+ (account_id, session_id, machine_id, message_id, current_head, last_turn_id,
589
+ basis_turn_id, basis_event_message_id, basis_turn_seq, basis_occurred_at,
590
+ encrypted_metadata, created_at, updated_at)
591
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
592
+ ON CONFLICT(account_id, machine_id, message_id) WHERE message_id IS NOT NULL
593
+ DO UPDATE SET
594
+ current_head = excluded.current_head,
595
+ last_turn_id = excluded.last_turn_id,
596
+ basis_turn_id = excluded.basis_turn_id,
597
+ basis_event_message_id = excluded.basis_event_message_id,
598
+ basis_turn_seq = excluded.basis_turn_seq,
599
+ basis_occurred_at = excluded.basis_occurred_at,
600
+ encrypted_metadata = excluded.encrypted_metadata,
601
+ updated_at = excluded.updated_at`).run(input.accountId, input.sessionId, input.machineId, input.messageId ?? null, input.currentHead, input.lastTurnId, input.basis.turnId, input.basis.eventMessageId, input.basis.turnSeq, input.basis.occurredAt ?? null, input.encryptedMetadata ? JSON.stringify(input.encryptedMetadata) : null, ts, ts);
602
+ }
603
+ function clearPendingSessionHeadAdvances(db, accountId, sessionId, machineId) {
604
+ db.prepare(`DELETE FROM pending_session_head_advances
605
+ WHERE account_id = ? AND session_id = ? AND machine_id = ?`).run(accountId, sessionId, machineId);
606
+ }
607
+ function applyPendingSessionHeadAdvances(db, accountId, sessionId, machineId, ts) {
608
+ const pending = db
609
+ .prepare(`SELECT * FROM pending_session_head_advances
610
+ WHERE account_id = ? AND session_id = ? AND machine_id = ?
611
+ ORDER BY created_at, id`)
612
+ .all(accountId, sessionId, machineId);
613
+ let latestSession;
614
+ for (const row of pending) {
615
+ const result = applySessionHeadAdvance(db, pendingRowToHeadAdvanceInput(row), ts);
616
+ if (result.status === "missing")
617
+ continue;
618
+ db.prepare("DELETE FROM pending_session_head_advances WHERE id = ?").run(row.id);
619
+ latestSession = result.session ?? latestSession;
620
+ }
621
+ return latestSession;
622
+ }
623
+ function pendingRowToHeadAdvanceInput(row) {
624
+ return {
625
+ accountId: row.account_id,
626
+ sessionId: row.session_id,
627
+ machineId: row.machine_id,
628
+ messageId: row.message_id ?? undefined,
629
+ currentHead: row.current_head,
630
+ lastTurnId: row.last_turn_id,
631
+ basis: {
632
+ turnId: row.basis_turn_id,
633
+ eventMessageId: row.basis_event_message_id,
634
+ turnSeq: row.basis_turn_seq,
635
+ ...(row.basis_occurred_at !== null ? { occurredAt: row.basis_occurred_at } : {}),
636
+ },
637
+ ...(row.encrypted_metadata ? { encryptedMetadata: JSON.parse(row.encrypted_metadata) } : {}),
638
+ };
639
+ }
640
+ function applySessionHeadAdvance(db, input, ts) {
641
+ const sessionRow = db
642
+ .prepare("SELECT * FROM sessions WHERE id = ? AND account_id = ? AND machine_id = ?")
643
+ .get(input.sessionId, input.accountId, input.machineId);
644
+ if (!sessionRow)
645
+ return { status: "session-not-found" };
646
+ const basisEvent = resolveHeadAdvanceBasisEvent(db, input.accountId, input.sessionId, input.machineId, input.basis);
647
+ if (!basisEvent)
648
+ return { status: "missing", session: sessionRow };
649
+ if (basisEvent.turn_id !== input.basis.turnId || basisEvent.turn_seq !== input.basis.turnSeq) {
650
+ return { status: "mismatch", session: sessionRow };
651
+ }
652
+ const nextOrder = logicalEventOrder(basisEvent);
653
+ const currentOrder = sessionHeadBasisOrder(db, sessionRow);
654
+ const rewriteFenced = typeof sessionRow.head_basis_message_id === "string" &&
655
+ sessionRow.head_basis_message_id.startsWith(REWRITE_HEAD_BASIS_PREFIX);
656
+ const comparison = compareHeadBasisOrder(nextOrder, currentOrder);
657
+ const currentHeadIsBasisEvent = sessionRow.current_head === `event:${basisEvent.id}`;
658
+ const shouldAdvance = !rewriteFenced && (comparison > 0 || (comparison === 0 && currentHeadIsBasisEvent));
659
+ if (!shouldAdvance)
660
+ return { status: "noop", session: sessionRow };
661
+ db.prepare(`UPDATE sessions
662
+ SET current_head = ?,
663
+ last_turn_id = ?,
664
+ head_basis_event_id = ?,
665
+ head_basis_message_id = ?,
666
+ head_basis_logical_time = ?,
667
+ head_basis_turn_seq = ?,
668
+ encrypted_metadata = COALESCE(?, encrypted_metadata),
669
+ updated_at = ?
670
+ WHERE id = ? AND account_id = ? AND machine_id = ?`).run(input.currentHead, input.lastTurnId, basisEvent.id, basisEvent.message_id, nextOrder.time, nextOrder.seq, input.encryptedMetadata ? JSON.stringify(input.encryptedMetadata) : null, ts, input.sessionId, input.accountId, input.machineId);
671
+ const session = db.prepare("SELECT * FROM sessions WHERE id = ?").get(input.sessionId);
672
+ return { status: "applied", session };
673
+ }
585
674
  function compareHeadBasisOrder(nextOrder, currentOrder) {
586
675
  if (!currentOrder)
587
676
  return 1;
@@ -63,6 +63,22 @@ export type EventRow = {
63
63
  created_at: number;
64
64
  occurred_at: number | null;
65
65
  };
66
+ export type PendingSessionHeadAdvanceRow = {
67
+ id: number;
68
+ account_id: string;
69
+ session_id: string;
70
+ machine_id: string;
71
+ message_id: string | null;
72
+ current_head: string;
73
+ last_turn_id: string;
74
+ basis_turn_id: string;
75
+ basis_event_message_id: string;
76
+ basis_turn_seq: number;
77
+ basis_occurred_at: number | null;
78
+ encrypted_metadata: string | null;
79
+ created_at: number;
80
+ updated_at: number;
81
+ };
66
82
  export type CommandResultRow = {
67
83
  account_id: string;
68
84
  request_id: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cydm/happy-elves",
3
- "version": "0.1.0-beta.39",
3
+ "version": "0.1.0-beta.40",
4
4
  "description": "Remote controller for local coding agents with hosted or self-hosted relay support.",
5
5
  "type": "module",
6
6
  "bin": {