@portel/photon 1.32.2 → 1.32.4

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.
Files changed (52) hide show
  1. package/README.md +8 -4
  2. package/dist/auto-ui/beam.d.ts.map +1 -1
  3. package/dist/auto-ui/beam.js +56 -14
  4. package/dist/auto-ui/beam.js.map +1 -1
  5. package/dist/auto-ui/bridge/index.js +1 -1
  6. package/dist/auto-ui/streamable-http-transport.d.ts +5 -0
  7. package/dist/auto-ui/streamable-http-transport.d.ts.map +1 -1
  8. package/dist/auto-ui/streamable-http-transport.js +452 -175
  9. package/dist/auto-ui/streamable-http-transport.js.map +1 -1
  10. package/dist/auto-ui/types.d.ts +34 -0
  11. package/dist/auto-ui/types.d.ts.map +1 -1
  12. package/dist/auto-ui/types.js +57 -0
  13. package/dist/auto-ui/types.js.map +1 -1
  14. package/dist/beam.bundle.js +2492 -1442
  15. package/dist/beam.bundle.js.map +4 -4
  16. package/dist/claude-code-plugin.js +9 -3
  17. package/dist/claude-code-plugin.js.map +1 -1
  18. package/dist/cli/commands/beam.d.ts.map +1 -1
  19. package/dist/cli/commands/beam.js +5 -0
  20. package/dist/cli/commands/beam.js.map +1 -1
  21. package/dist/context.d.ts.map +1 -1
  22. package/dist/context.js +12 -6
  23. package/dist/context.js.map +1 -1
  24. package/dist/daemon/client.d.ts.map +1 -1
  25. package/dist/daemon/client.js +187 -489
  26. package/dist/daemon/client.js.map +1 -1
  27. package/dist/daemon/manager.d.ts +2 -1
  28. package/dist/daemon/manager.d.ts.map +1 -1
  29. package/dist/daemon/manager.js +57 -29
  30. package/dist/daemon/manager.js.map +1 -1
  31. package/dist/daemon/server.js +120 -31
  32. package/dist/daemon/server.js.map +1 -1
  33. package/dist/loader.d.ts.map +1 -1
  34. package/dist/loader.js +19 -8
  35. package/dist/loader.js.map +1 -1
  36. package/dist/photons/marketplace.photon.d.ts.map +1 -1
  37. package/dist/photons/marketplace.photon.js +34 -7
  38. package/dist/photons/marketplace.photon.js.map +1 -1
  39. package/dist/photons/marketplace.photon.ts +35 -7
  40. package/dist/server.d.ts.map +1 -1
  41. package/dist/server.js +40 -6
  42. package/dist/server.js.map +1 -1
  43. package/dist/types/server-types.d.ts +2 -0
  44. package/dist/types/server-types.d.ts.map +1 -1
  45. package/dist/version-notify.d.ts +5 -0
  46. package/dist/version-notify.d.ts.map +1 -1
  47. package/dist/version-notify.js +57 -7
  48. package/dist/version-notify.js.map +1 -1
  49. package/dist/watcher.d.ts.map +1 -1
  50. package/dist/watcher.js +8 -3
  51. package/dist/watcher.js.map +1 -1
  52. package/package.json +89 -73
@@ -18,6 +18,12 @@ import { ProgressRenderer } from '@portel/photon-core';
18
18
  const SESSION_ID = process.env.PHOTON_SESSION_ID || `cli-${process.pid}-${crypto.randomBytes(4).toString('hex')}`;
19
19
  const logger = createLogger({ component: 'daemon-client', minimal: true });
20
20
  const cliProgress = new ProgressRenderer();
21
+ const DEFAULT_DAEMON_READY_TIMEOUT_MS = 30_000;
22
+ const DEFAULT_DAEMON_COMMAND_RETRIES = 3;
23
+ function getDaemonReadyTimeoutMs() {
24
+ const parsed = Number(process.env.PHOTON_DAEMON_READY_TIMEOUT_MS);
25
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_DAEMON_READY_TIMEOUT_MS;
26
+ }
21
27
  /**
22
28
  * Render a generator emit yield in the CLI (mirrors loader's createOutputHandler)
23
29
  */
@@ -154,16 +160,19 @@ function connectToDaemon(socketPath) {
154
160
  process.nextTick(() => dummy.emit('error', err));
155
161
  return dummy;
156
162
  }
157
- try {
158
- return net.createConnection(socketPath);
159
- }
160
- catch (syncErr) {
161
- // TOCTOU: file vanished between existsSync and createConnection.
162
- // Emit asynchronously for the same reason as above.
163
- const dummy = new net.Socket();
164
- process.nextTick(() => dummy.emit('error', syncErr));
165
- return dummy;
166
- }
163
+ const socket = new net.Socket();
164
+ // Give callers one tick to attach error/data/connect handlers before
165
+ // Bun can report ENOENT/ECONNREFUSED for a socket that vanished after
166
+ // the existsSync guard.
167
+ process.nextTick(() => {
168
+ try {
169
+ socket.connect(socketPath);
170
+ }
171
+ catch (syncErr) {
172
+ socket.emit('error', syncErr);
173
+ }
174
+ });
175
+ return socket;
167
176
  }
168
177
  /**
169
178
  * connectToDaemon variant for the best-effort health-check helpers
@@ -206,7 +215,7 @@ const RETRYABLE_METHODS = new Set(['_instances', '_use', '_settings', '_runs', '
206
215
  * Called after ensureDaemon() so sendCommand retries land on a ready socket,
207
216
  * not on a socket that exists on disk but the daemon hasn't bound yet.
208
217
  */
209
- async function waitForDaemon(timeoutMs = 10_000) {
218
+ async function waitForDaemon(timeoutMs = getDaemonReadyTimeoutMs()) {
210
219
  const deadline = Date.now() + timeoutMs;
211
220
  let delay = 100;
212
221
  while (Date.now() < deadline) {
@@ -215,7 +224,15 @@ async function waitForDaemon(timeoutMs = 10_000) {
215
224
  await new Promise((r) => setTimeout(r, delay));
216
225
  delay = Math.min(delay * 2, 1000);
217
226
  }
218
- throw new Error('Daemon did not become ready within timeout');
227
+ throw new Error(`Daemon did not become ready within ${Math.round(timeoutMs / 1000)}s`);
228
+ }
229
+ async function ensureDaemonReady() {
230
+ await ensureDaemon();
231
+ await waitForDaemon();
232
+ }
233
+ async function waitBeforeDaemonRetry(attempt) {
234
+ const delay = Math.min(100 * Math.pow(2, attempt), 1_000);
235
+ await new Promise((resolve) => setTimeout(resolve, delay));
219
236
  }
220
237
  /**
221
238
  * Send command to daemon with auto-restart on connection failure.
@@ -223,17 +240,18 @@ async function waitForDaemon(timeoutMs = 10_000) {
223
240
  * errors for read-only methods (retry without restart).
224
241
  */
225
242
  export async function sendCommand(photonName, method, args, options) {
226
- const maxRetries = options?.maxRetries ?? 1;
243
+ const maxRetries = options?.maxRetries ?? DEFAULT_DAEMON_COMMAND_RETRIES;
227
244
  const isRetryable = RETRYABLE_METHODS.has(method);
228
245
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
229
246
  try {
247
+ await ensureDaemonReady();
230
248
  return await sendCommandDirect(photonName, method, args, options?.photonPath, options?.sessionId, options?.instanceName, options?.workingDir, options?.targetInstance, options?.clientType);
231
249
  }
232
250
  catch (error) {
233
251
  if (isDaemonConnectionError(error) && attempt < maxRetries) {
234
252
  logger.info(`Daemon unreachable (${photonName}/${method}), waiting for daemon...`);
235
- await ensureDaemon();
236
- await waitForDaemon();
253
+ await waitBeforeDaemonRetry(attempt);
254
+ await ensureDaemonReady();
237
255
  continue;
238
256
  }
239
257
  // Retry transient errors for read-only methods only
@@ -252,6 +270,24 @@ export async function sendCommand(photonName, method, args, options) {
252
270
  * Called by Beam after hot-reload so the daemon's instance matches.
253
271
  */
254
272
  export async function reloadDaemonPhoton(photonName, photonPath, workingDir) {
273
+ const maxRetries = DEFAULT_DAEMON_COMMAND_RETRIES;
274
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
275
+ try {
276
+ await ensureDaemonReady();
277
+ return await reloadDaemonPhotonDirect(photonName, photonPath, workingDir);
278
+ }
279
+ catch (error) {
280
+ if (isDaemonConnectionError(error) && attempt < maxRetries) {
281
+ logger.info(`Daemon unreachable during reload (${photonName}), waiting for daemon...`);
282
+ await waitBeforeDaemonRetry(attempt);
283
+ await ensureDaemonReady();
284
+ continue;
285
+ }
286
+ throw error;
287
+ }
288
+ }
289
+ }
290
+ async function reloadDaemonPhotonDirect(photonName, photonPath, workingDir) {
255
291
  const socketPath = getGlobalSocketPath();
256
292
  return new Promise((resolve, reject) => {
257
293
  const client = connectToDaemon(socketPath);
@@ -413,7 +449,8 @@ async function sendCommandDirect(photonName, method, args, photonPath, sessionId
413
449
  export async function subscribeChannel(photonName, channel, handler, options) {
414
450
  let cancelled = false;
415
451
  let lastSeenEventId = options?.lastEventId;
416
- const connect = () => {
452
+ const connect = async () => {
453
+ await ensureDaemonReady();
417
454
  const socketPath = getGlobalSocketPath();
418
455
  const subscribeId = `sub_${Date.now()}_${Math.random().toString(36).slice(2)}`;
419
456
  return new Promise((resolve, reject) => {
@@ -511,6 +548,8 @@ export async function subscribeChannel(photonName, channel, handler, options) {
511
548
  }
512
549
  }
513
550
  else if (options?.reconnect && !cancelled) {
551
+ if (!client.destroyed)
552
+ client.destroy();
514
553
  scheduleReconnect();
515
554
  }
516
555
  });
@@ -527,6 +566,8 @@ export async function subscribeChannel(photonName, channel, handler, options) {
527
566
  }
528
567
  }
529
568
  else if (options?.reconnect && !cancelled) {
569
+ if (!client.destroyed)
570
+ client.destroy();
530
571
  scheduleReconnect();
531
572
  }
532
573
  });
@@ -551,7 +592,7 @@ export async function subscribeChannel(photonName, channel, handler, options) {
551
592
  return;
552
593
  try {
553
594
  // Try reconnecting to existing daemon first; only start if it's down
554
- await ensureDaemon();
595
+ await ensureDaemonReady();
555
596
  await connect();
556
597
  reconnectAttempts = 0;
557
598
  logger.debug(`Reconnected subscription for ${channel}`);
@@ -573,48 +614,12 @@ export async function subscribeChannel(photonName, channel, handler, options) {
573
614
  * Publish a message to a channel on a daemon
574
615
  */
575
616
  export async function publishToChannel(photonName, channel, message, workingDir) {
576
- const socketPath = getGlobalSocketPath();
577
- const requestId = `pub_${Date.now()}_${Math.random().toString(36).slice(2)}`;
578
- return new Promise((resolve, reject) => {
579
- const client = connectToDaemon(socketPath);
580
- const timeout = setTimeout(() => {
581
- client.destroy();
582
- reject(new Error('Publish timeout'));
583
- }, 5000);
584
- client.on('connect', () => {
585
- const request = {
586
- type: 'publish',
587
- id: requestId,
588
- photonName,
589
- channel,
590
- message,
591
- workingDir,
592
- };
593
- client.write(JSON.stringify(request) + '\n');
594
- });
595
- client.on('data', (chunk) => {
596
- try {
597
- const response = JSON.parse(chunk.toString().trim());
598
- if (response.id === requestId) {
599
- clearTimeout(timeout);
600
- client.destroy();
601
- if (response.type === 'result') {
602
- resolve();
603
- }
604
- else {
605
- reject(new Error(response.error || 'Publish failed'));
606
- }
607
- }
608
- }
609
- catch (e) {
610
- logger.warn('Failed to parse daemon response', { error: getErrorMessage(e) });
611
- }
612
- });
613
- client.on('error', (error) => {
614
- clearTimeout(timeout);
615
- client.destroy();
616
- reject(new Error(`Connection error: ${getErrorMessage(error)}`));
617
- });
617
+ await sendSimpleDaemonRequest({
618
+ type: 'publish',
619
+ photonName,
620
+ channel,
621
+ message,
622
+ workingDir,
618
623
  });
619
624
  }
620
625
  /**
@@ -622,485 +627,152 @@ export async function publishToChannel(photonName, channel, message, workingDir)
622
627
  * Returns true if lock acquired, false if already held
623
628
  */
624
629
  export async function acquireLock(photonName, lockName, timeout, workingDir) {
625
- const socketPath = getGlobalSocketPath();
626
- const requestId = `lock_${Date.now()}_${Math.random().toString(36).slice(2)}`;
627
- return new Promise((resolve, reject) => {
628
- const client = connectToDaemon(socketPath);
629
- const requestTimeout = setTimeout(() => {
630
- client.destroy();
631
- reject(new Error('Lock request timeout'));
632
- }, 10000);
633
- client.on('connect', () => {
634
- const request = {
635
- type: 'lock',
636
- id: requestId,
637
- photonName,
638
- sessionId: SESSION_ID,
639
- lockName,
640
- lockTimeout: timeout,
641
- workingDir,
642
- };
643
- client.write(JSON.stringify(request) + '\n');
644
- });
645
- client.on('data', (chunk) => {
646
- try {
647
- const response = JSON.parse(chunk.toString().trim());
648
- if (response.id === requestId) {
649
- clearTimeout(requestTimeout);
650
- client.destroy();
651
- if (response.type === 'result') {
652
- resolve(response.data.acquired);
653
- }
654
- else {
655
- reject(new Error(response.error || 'Lock failed'));
656
- }
657
- }
658
- }
659
- catch (e) {
660
- logger.warn('Failed to parse daemon response', { error: getErrorMessage(e) });
661
- }
662
- });
663
- client.on('error', (error) => {
664
- clearTimeout(requestTimeout);
665
- client.destroy();
666
- reject(new Error(`Connection error: ${getErrorMessage(error)}`));
667
- });
630
+ const result = await sendSimpleDaemonRequest({
631
+ type: 'lock',
632
+ photonName,
633
+ sessionId: SESSION_ID,
634
+ lockName,
635
+ lockTimeout: timeout,
636
+ workingDir,
668
637
  });
638
+ return result.acquired;
669
639
  }
670
640
  /**
671
641
  * Release a distributed lock
672
642
  * Returns true if lock released, false if not held by this session
673
643
  */
674
644
  export async function releaseLock(photonName, lockName, workingDir) {
675
- const socketPath = getGlobalSocketPath();
676
- const requestId = `unlock_${Date.now()}_${Math.random().toString(36).slice(2)}`;
677
- return new Promise((resolve, reject) => {
678
- const client = connectToDaemon(socketPath);
679
- const timeout = setTimeout(() => {
680
- client.destroy();
681
- reject(new Error('Unlock request timeout'));
682
- }, 5000);
683
- client.on('connect', () => {
684
- const request = {
685
- type: 'unlock',
686
- id: requestId,
687
- photonName,
688
- sessionId: SESSION_ID,
689
- lockName,
690
- workingDir,
691
- };
692
- client.write(JSON.stringify(request) + '\n');
693
- });
694
- client.on('data', (chunk) => {
695
- try {
696
- const response = JSON.parse(chunk.toString().trim());
697
- if (response.id === requestId) {
698
- clearTimeout(timeout);
699
- client.destroy();
700
- if (response.type === 'result') {
701
- resolve(response.data.released);
702
- }
703
- else {
704
- reject(new Error(response.error || 'Unlock failed'));
705
- }
706
- }
707
- }
708
- catch (e) {
709
- logger.warn('Failed to parse daemon response', { error: getErrorMessage(e) });
710
- }
711
- });
712
- client.on('error', (error) => {
713
- clearTimeout(timeout);
714
- client.destroy();
715
- reject(new Error(`Connection error: ${getErrorMessage(error)}`));
716
- });
645
+ const result = await sendSimpleDaemonRequest({
646
+ type: 'unlock',
647
+ photonName,
648
+ sessionId: SESSION_ID,
649
+ lockName,
650
+ workingDir,
717
651
  });
652
+ return result.released;
718
653
  }
719
654
  /**
720
655
  * List all active locks
721
656
  */
722
657
  export async function listLocks(photonName) {
723
- const socketPath = getGlobalSocketPath();
724
- const requestId = `listlocks_${Date.now()}_${Math.random().toString(36).slice(2)}`;
725
- return new Promise((resolve, reject) => {
726
- const client = connectToDaemon(socketPath);
727
- const timeout = setTimeout(() => {
728
- client.destroy();
729
- reject(new Error('List locks request timeout'));
730
- }, 5000);
731
- client.on('connect', () => {
732
- const request = {
733
- type: 'list_locks',
734
- id: requestId,
735
- photonName,
736
- };
737
- client.write(JSON.stringify(request) + '\n');
738
- });
739
- client.on('data', (chunk) => {
740
- try {
741
- const response = JSON.parse(chunk.toString().trim());
742
- if (response.id === requestId) {
743
- clearTimeout(timeout);
744
- client.destroy();
745
- if (response.type === 'result') {
746
- resolve(response.data.locks || []);
747
- }
748
- else {
749
- reject(new Error(response.error || 'List locks failed'));
750
- }
751
- }
752
- }
753
- catch (e) {
754
- logger.warn('Failed to parse daemon response', { error: getErrorMessage(e) });
755
- }
756
- });
757
- client.on('error', (error) => {
758
- clearTimeout(timeout);
759
- client.destroy();
760
- reject(new Error(`Connection error: ${getErrorMessage(error)}`));
761
- });
658
+ const result = await sendSimpleDaemonRequest({
659
+ type: 'list_locks',
660
+ photonName,
762
661
  });
662
+ return result.locks || [];
763
663
  }
764
664
  /**
765
665
  * Assign a lock to a specific caller (identity-aware)
766
666
  * Unlike acquireLock which uses the session ID, this sets an explicit holder.
767
667
  */
768
668
  export async function assignLock(photonName, lockName, holder, timeout, workingDir) {
769
- const socketPath = getGlobalSocketPath();
770
- const requestId = `assignlock_${Date.now()}_${Math.random().toString(36).slice(2)}`;
771
- return new Promise((resolve, reject) => {
772
- const client = connectToDaemon(socketPath);
773
- const requestTimeout = setTimeout(() => {
774
- client.destroy();
775
- reject(new Error('Assign lock request timeout'));
776
- }, 10000);
777
- client.on('connect', () => {
778
- const request = {
779
- type: 'assign_lock',
780
- id: requestId,
781
- photonName,
782
- sessionId: SESSION_ID,
783
- lockName,
784
- lockHolder: holder,
785
- lockTimeout: timeout,
786
- workingDir,
787
- };
788
- client.write(JSON.stringify(request) + '\n');
789
- });
790
- client.on('data', (chunk) => {
791
- try {
792
- const response = JSON.parse(chunk.toString().trim());
793
- if (response.id === requestId) {
794
- clearTimeout(requestTimeout);
795
- client.destroy();
796
- if (response.type === 'result') {
797
- resolve(response.data.acquired);
798
- }
799
- else {
800
- reject(new Error(response.error || 'Assign lock failed'));
801
- }
802
- }
803
- }
804
- catch (e) {
805
- logger.warn('Failed to parse daemon response', { error: getErrorMessage(e) });
806
- }
807
- });
808
- client.on('error', (error) => {
809
- clearTimeout(requestTimeout);
810
- client.destroy();
811
- reject(new Error(`Connection error: ${getErrorMessage(error)}`));
812
- });
669
+ const result = await sendSimpleDaemonRequest({
670
+ type: 'assign_lock',
671
+ photonName,
672
+ sessionId: SESSION_ID,
673
+ lockName,
674
+ lockHolder: holder,
675
+ lockTimeout: timeout,
676
+ workingDir,
813
677
  });
678
+ return result.acquired;
814
679
  }
815
680
  /**
816
681
  * Transfer a lock from one holder to another
817
682
  */
818
683
  export async function transferLock(photonName, lockName, fromHolder, toHolder, timeout, workingDir) {
819
- const socketPath = getGlobalSocketPath();
820
- const requestId = `transferlock_${Date.now()}_${Math.random().toString(36).slice(2)}`;
821
- return new Promise((resolve, reject) => {
822
- const client = connectToDaemon(socketPath);
823
- const requestTimeout = setTimeout(() => {
824
- client.destroy();
825
- reject(new Error('Transfer lock request timeout'));
826
- }, 10000);
827
- client.on('connect', () => {
828
- const request = {
829
- type: 'transfer_lock',
830
- id: requestId,
831
- photonName,
832
- sessionId: SESSION_ID,
833
- lockName,
834
- lockHolder: fromHolder,
835
- lockTransferTo: toHolder,
836
- lockTimeout: timeout,
837
- workingDir,
838
- };
839
- client.write(JSON.stringify(request) + '\n');
840
- });
841
- client.on('data', (chunk) => {
842
- try {
843
- const response = JSON.parse(chunk.toString().trim());
844
- if (response.id === requestId) {
845
- clearTimeout(requestTimeout);
846
- client.destroy();
847
- if (response.type === 'result') {
848
- resolve(response.data.transferred);
849
- }
850
- else {
851
- reject(new Error(response.error || 'Transfer lock failed'));
852
- }
853
- }
854
- }
855
- catch (e) {
856
- logger.warn('Failed to parse daemon response', { error: getErrorMessage(e) });
857
- }
858
- });
859
- client.on('error', (error) => {
860
- clearTimeout(requestTimeout);
861
- client.destroy();
862
- reject(new Error(`Connection error: ${getErrorMessage(error)}`));
863
- });
684
+ const result = await sendSimpleDaemonRequest({
685
+ type: 'transfer_lock',
686
+ photonName,
687
+ sessionId: SESSION_ID,
688
+ lockName,
689
+ lockHolder: fromHolder,
690
+ lockTransferTo: toHolder,
691
+ lockTimeout: timeout,
692
+ workingDir,
864
693
  });
694
+ return result.transferred;
865
695
  }
866
696
  /**
867
697
  * Release a lock held by a specific caller (identity-aware)
868
698
  */
869
699
  export async function releaseIdentityLock(photonName, lockName, holder, workingDir) {
870
- const socketPath = getGlobalSocketPath();
871
- const requestId = `releaselock_${Date.now()}_${Math.random().toString(36).slice(2)}`;
872
- return new Promise((resolve, reject) => {
873
- const client = connectToDaemon(socketPath);
874
- const requestTimeout = setTimeout(() => {
875
- client.destroy();
876
- reject(new Error('Release lock request timeout'));
877
- }, 5000);
878
- client.on('connect', () => {
879
- const request = {
880
- type: 'unlock',
881
- id: requestId,
882
- photonName,
883
- sessionId: SESSION_ID,
884
- lockName,
885
- lockHolder: holder,
886
- workingDir,
887
- };
888
- client.write(JSON.stringify(request) + '\n');
889
- });
890
- client.on('data', (chunk) => {
891
- try {
892
- const response = JSON.parse(chunk.toString().trim());
893
- if (response.id === requestId) {
894
- clearTimeout(requestTimeout);
895
- client.destroy();
896
- if (response.type === 'result') {
897
- resolve(response.data.released);
898
- }
899
- else {
900
- reject(new Error(response.error || 'Release lock failed'));
901
- }
902
- }
903
- }
904
- catch (e) {
905
- logger.warn('Failed to parse daemon response', { error: getErrorMessage(e) });
906
- }
907
- });
908
- client.on('error', (error) => {
909
- clearTimeout(requestTimeout);
910
- client.destroy();
911
- reject(new Error(`Connection error: ${getErrorMessage(error)}`));
912
- });
700
+ const result = await sendSimpleDaemonRequest({
701
+ type: 'unlock',
702
+ photonName,
703
+ sessionId: SESSION_ID,
704
+ lockName,
705
+ lockHolder: holder,
706
+ workingDir,
913
707
  });
708
+ return result.released;
914
709
  }
915
710
  /**
916
711
  * Query who holds a specific lock
917
712
  */
918
713
  export async function queryLock(photonName, lockName) {
919
- const socketPath = getGlobalSocketPath();
920
- const requestId = `querylock_${Date.now()}_${Math.random().toString(36).slice(2)}`;
921
- return new Promise((resolve, reject) => {
922
- const client = connectToDaemon(socketPath);
923
- const requestTimeout = setTimeout(() => {
924
- client.destroy();
925
- reject(new Error('Query lock request timeout'));
926
- }, 5000);
927
- client.on('connect', () => {
928
- const request = {
929
- type: 'query_lock',
930
- id: requestId,
931
- photonName,
932
- lockName,
933
- };
934
- client.write(JSON.stringify(request) + '\n');
935
- });
936
- client.on('data', (chunk) => {
937
- try {
938
- const response = JSON.parse(chunk.toString().trim());
939
- if (response.id === requestId) {
940
- clearTimeout(requestTimeout);
941
- client.destroy();
942
- if (response.type === 'result') {
943
- resolve(response.data);
944
- }
945
- else {
946
- reject(new Error(response.error || 'Query lock failed'));
947
- }
948
- }
949
- }
950
- catch (e) {
951
- logger.warn('Failed to parse daemon response', { error: getErrorMessage(e) });
952
- }
953
- });
954
- client.on('error', (error) => {
955
- clearTimeout(requestTimeout);
956
- client.destroy();
957
- reject(new Error(`Connection error: ${getErrorMessage(error)}`));
958
- });
714
+ return sendSimpleDaemonRequest({
715
+ type: 'query_lock',
716
+ photonName,
717
+ lockName,
959
718
  });
960
719
  }
961
720
  /**
962
721
  * Schedule a recurring job
963
722
  */
964
723
  export async function scheduleJob(photonName, jobId, method, cron, args) {
965
- const socketPath = getGlobalSocketPath();
966
- const requestId = `schedule_${Date.now()}_${Math.random().toString(36).slice(2)}`;
967
- return new Promise((resolve, reject) => {
968
- const client = connectToDaemon(socketPath);
969
- const timeout = setTimeout(() => {
970
- client.destroy();
971
- reject(new Error('Schedule request timeout'));
972
- }, 5000);
973
- client.on('connect', () => {
974
- const request = {
975
- type: 'schedule',
976
- id: requestId,
977
- photonName,
978
- sessionId: SESSION_ID,
979
- jobId,
980
- method,
981
- cron,
982
- args,
983
- };
984
- client.write(JSON.stringify(request) + '\n');
985
- });
986
- client.on('data', (chunk) => {
987
- try {
988
- const response = JSON.parse(chunk.toString().trim());
989
- if (response.id === requestId) {
990
- clearTimeout(timeout);
991
- client.destroy();
992
- if (response.type === 'result') {
993
- resolve(response.data);
994
- }
995
- else {
996
- reject(new Error(response.error || 'Schedule failed'));
997
- }
998
- }
999
- }
1000
- catch (e) {
1001
- logger.warn('Failed to parse daemon response', { error: getErrorMessage(e) });
1002
- }
1003
- });
1004
- client.on('error', (error) => {
1005
- clearTimeout(timeout);
1006
- client.destroy();
1007
- reject(new Error(`Connection error: ${getErrorMessage(error)}`));
1008
- });
724
+ return sendSimpleDaemonRequest({
725
+ type: 'schedule',
726
+ photonName,
727
+ sessionId: SESSION_ID,
728
+ jobId,
729
+ method,
730
+ cron,
731
+ args,
1009
732
  });
1010
733
  }
1011
734
  /**
1012
735
  * Unschedule a job
1013
736
  */
1014
737
  export async function unscheduleJob(photonName, jobId) {
1015
- const socketPath = getGlobalSocketPath();
1016
- const requestId = `unschedule_${Date.now()}_${Math.random().toString(36).slice(2)}`;
1017
- return new Promise((resolve, reject) => {
1018
- const client = connectToDaemon(socketPath);
1019
- const timeout = setTimeout(() => {
1020
- client.destroy();
1021
- reject(new Error('Unschedule request timeout'));
1022
- }, 5000);
1023
- client.on('connect', () => {
1024
- const request = {
1025
- type: 'unschedule',
1026
- id: requestId,
1027
- photonName,
1028
- jobId,
1029
- };
1030
- client.write(JSON.stringify(request) + '\n');
1031
- });
1032
- client.on('data', (chunk) => {
1033
- try {
1034
- const response = JSON.parse(chunk.toString().trim());
1035
- if (response.id === requestId) {
1036
- clearTimeout(timeout);
1037
- client.destroy();
1038
- if (response.type === 'result') {
1039
- resolve(response.data.unscheduled);
1040
- }
1041
- else {
1042
- reject(new Error(response.error || 'Unschedule failed'));
1043
- }
1044
- }
1045
- }
1046
- catch (e) {
1047
- logger.warn('Failed to parse daemon response', { error: getErrorMessage(e) });
1048
- }
1049
- });
1050
- client.on('error', (error) => {
1051
- clearTimeout(timeout);
1052
- client.destroy();
1053
- reject(new Error(`Connection error: ${getErrorMessage(error)}`));
1054
- });
738
+ const result = await sendSimpleDaemonRequest({
739
+ type: 'unschedule',
740
+ photonName,
741
+ jobId,
1055
742
  });
743
+ return result.unscheduled;
1056
744
  }
1057
745
  /**
1058
746
  * List all scheduled jobs
1059
747
  */
1060
748
  export async function listJobs(photonName) {
1061
- const socketPath = getGlobalSocketPath();
1062
- const requestId = `listjobs_${Date.now()}_${Math.random().toString(36).slice(2)}`;
1063
- return new Promise((resolve, reject) => {
1064
- const client = connectToDaemon(socketPath);
1065
- const timeout = setTimeout(() => {
1066
- client.destroy();
1067
- reject(new Error('List jobs request timeout'));
1068
- }, 5000);
1069
- client.on('connect', () => {
1070
- const request = {
1071
- type: 'list_jobs',
1072
- id: requestId,
1073
- photonName,
1074
- };
1075
- client.write(JSON.stringify(request) + '\n');
1076
- });
1077
- client.on('data', (chunk) => {
1078
- try {
1079
- const response = JSON.parse(chunk.toString().trim());
1080
- if (response.id === requestId) {
1081
- clearTimeout(timeout);
1082
- client.destroy();
1083
- if (response.type === 'result') {
1084
- resolve(response.data.jobs || []);
1085
- }
1086
- else {
1087
- reject(new Error(response.error || 'List jobs failed'));
1088
- }
1089
- }
1090
- }
1091
- catch (e) {
1092
- logger.warn('Failed to parse daemon response', { error: getErrorMessage(e) });
1093
- }
1094
- });
1095
- client.on('error', (error) => {
1096
- clearTimeout(timeout);
1097
- client.destroy();
1098
- reject(new Error(`Connection error: ${getErrorMessage(error)}`));
1099
- });
749
+ const result = await sendSimpleDaemonRequest({
750
+ type: 'list_jobs',
751
+ photonName,
1100
752
  });
753
+ return result.jobs || [];
1101
754
  }
1102
755
  /** Fire-and-forget helper for the new RPCs. Returns the parsed result.data. */
1103
756
  async function sendSimpleDaemonRequest(req) {
757
+ const maxRetries = DEFAULT_DAEMON_COMMAND_RETRIES;
758
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
759
+ try {
760
+ await ensureDaemonReady();
761
+ return await sendSimpleDaemonRequestDirect(req);
762
+ }
763
+ catch (error) {
764
+ if (isDaemonConnectionError(error) && attempt < maxRetries) {
765
+ logger.info(`Daemon unreachable during ${req.type}, waiting for daemon...`);
766
+ await waitBeforeDaemonRetry(attempt);
767
+ await ensureDaemonReady();
768
+ continue;
769
+ }
770
+ throw error;
771
+ }
772
+ }
773
+ throw new Error(`${req.type} failed`);
774
+ }
775
+ async function sendSimpleDaemonRequestDirect(req) {
1104
776
  const socketPath = getGlobalSocketPath();
1105
777
  const requestId = req.id || `${req.type}_${Date.now()}_${Math.random().toString(36).slice(2)}`;
1106
778
  return new Promise((resolve, reject) => {
@@ -1195,9 +867,22 @@ export async function pingDaemon(photonName) {
1195
867
  const requestId = `ping_${Date.now()}`;
1196
868
  return new Promise((resolve) => {
1197
869
  const client = tryConnectToDaemon(socketPath);
870
+ let done = false;
871
+ const finish = (alive) => {
872
+ if (done)
873
+ return;
874
+ done = true;
875
+ clearTimeout(timeout);
876
+ if (alive) {
877
+ client.end();
878
+ }
879
+ else {
880
+ client.destroy();
881
+ }
882
+ resolve(alive);
883
+ };
1198
884
  const timeout = setTimeout(() => {
1199
- client.destroy();
1200
- resolve(false);
885
+ finish(false);
1201
886
  }, 5000);
1202
887
  client.on('connect', () => {
1203
888
  const request = {
@@ -1211,9 +896,7 @@ export async function pingDaemon(photonName) {
1211
896
  try {
1212
897
  const response = JSON.parse(chunk.toString().trim());
1213
898
  if (response.id === requestId && response.type === 'pong') {
1214
- clearTimeout(timeout);
1215
- client.destroy();
1216
- resolve(true);
899
+ finish(true);
1217
900
  }
1218
901
  }
1219
902
  catch (error) {
@@ -1221,14 +904,10 @@ export async function pingDaemon(photonName) {
1221
904
  }
1222
905
  });
1223
906
  client.on('error', () => {
1224
- clearTimeout(timeout);
1225
- client.destroy();
1226
- resolve(false);
907
+ finish(false);
1227
908
  });
1228
909
  client.on('end', () => {
1229
- clearTimeout(timeout);
1230
- client.destroy();
1231
- resolve(false);
910
+ finish(false);
1232
911
  });
1233
912
  });
1234
913
  }
@@ -1331,6 +1010,25 @@ export async function clearInstances(photonName, workingDir) {
1331
1010
  });
1332
1011
  }
1333
1012
  export async function getEventsSince(photonName, channel, lastEventId) {
1013
+ const maxRetries = DEFAULT_DAEMON_COMMAND_RETRIES;
1014
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
1015
+ try {
1016
+ await ensureDaemonReady();
1017
+ return await getEventsSinceDirect(photonName, channel, lastEventId);
1018
+ }
1019
+ catch (error) {
1020
+ if (isDaemonConnectionError(error) && attempt < maxRetries) {
1021
+ logger.info(`Daemon unreachable during get_events_since (${channel}), waiting for daemon...`);
1022
+ await waitBeforeDaemonRetry(attempt);
1023
+ await ensureDaemonReady();
1024
+ continue;
1025
+ }
1026
+ throw error;
1027
+ }
1028
+ }
1029
+ throw new Error('get_events_since failed');
1030
+ }
1031
+ async function getEventsSinceDirect(photonName, channel, lastEventId) {
1334
1032
  const socketPath = getGlobalSocketPath();
1335
1033
  const requestId = `getevents_${Date.now()}_${Math.random().toString(36).slice(2)}`;
1336
1034
  return new Promise((resolve, reject) => {