@portel/photon 1.32.1 → 1.32.3

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 +35 -4
  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 +402 -171
  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 +165 -478
  26. package/dist/daemon/client.js.map +1 -1
  27. package/dist/daemon/manager.d.ts +1 -1
  28. package/dist/daemon/manager.d.ts.map +1 -1
  29. package/dist/daemon/manager.js +18 -19
  30. package/dist/daemon/manager.js.map +1 -1
  31. package/dist/daemon/server.js +61 -23
  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) => {
@@ -551,7 +588,7 @@ export async function subscribeChannel(photonName, channel, handler, options) {
551
588
  return;
552
589
  try {
553
590
  // Try reconnecting to existing daemon first; only start if it's down
554
- await ensureDaemon();
591
+ await ensureDaemonReady();
555
592
  await connect();
556
593
  reconnectAttempts = 0;
557
594
  logger.debug(`Reconnected subscription for ${channel}`);
@@ -573,48 +610,12 @@ export async function subscribeChannel(photonName, channel, handler, options) {
573
610
  * Publish a message to a channel on a daemon
574
611
  */
575
612
  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
- });
613
+ await sendSimpleDaemonRequest({
614
+ type: 'publish',
615
+ photonName,
616
+ channel,
617
+ message,
618
+ workingDir,
618
619
  });
619
620
  }
620
621
  /**
@@ -622,485 +623,152 @@ export async function publishToChannel(photonName, channel, message, workingDir)
622
623
  * Returns true if lock acquired, false if already held
623
624
  */
624
625
  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
- });
626
+ const result = await sendSimpleDaemonRequest({
627
+ type: 'lock',
628
+ photonName,
629
+ sessionId: SESSION_ID,
630
+ lockName,
631
+ lockTimeout: timeout,
632
+ workingDir,
668
633
  });
634
+ return result.acquired;
669
635
  }
670
636
  /**
671
637
  * Release a distributed lock
672
638
  * Returns true if lock released, false if not held by this session
673
639
  */
674
640
  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
- });
641
+ const result = await sendSimpleDaemonRequest({
642
+ type: 'unlock',
643
+ photonName,
644
+ sessionId: SESSION_ID,
645
+ lockName,
646
+ workingDir,
717
647
  });
648
+ return result.released;
718
649
  }
719
650
  /**
720
651
  * List all active locks
721
652
  */
722
653
  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
- });
654
+ const result = await sendSimpleDaemonRequest({
655
+ type: 'list_locks',
656
+ photonName,
762
657
  });
658
+ return result.locks || [];
763
659
  }
764
660
  /**
765
661
  * Assign a lock to a specific caller (identity-aware)
766
662
  * Unlike acquireLock which uses the session ID, this sets an explicit holder.
767
663
  */
768
664
  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
- });
665
+ const result = await sendSimpleDaemonRequest({
666
+ type: 'assign_lock',
667
+ photonName,
668
+ sessionId: SESSION_ID,
669
+ lockName,
670
+ lockHolder: holder,
671
+ lockTimeout: timeout,
672
+ workingDir,
813
673
  });
674
+ return result.acquired;
814
675
  }
815
676
  /**
816
677
  * Transfer a lock from one holder to another
817
678
  */
818
679
  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
- });
680
+ const result = await sendSimpleDaemonRequest({
681
+ type: 'transfer_lock',
682
+ photonName,
683
+ sessionId: SESSION_ID,
684
+ lockName,
685
+ lockHolder: fromHolder,
686
+ lockTransferTo: toHolder,
687
+ lockTimeout: timeout,
688
+ workingDir,
864
689
  });
690
+ return result.transferred;
865
691
  }
866
692
  /**
867
693
  * Release a lock held by a specific caller (identity-aware)
868
694
  */
869
695
  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
- });
696
+ const result = await sendSimpleDaemonRequest({
697
+ type: 'unlock',
698
+ photonName,
699
+ sessionId: SESSION_ID,
700
+ lockName,
701
+ lockHolder: holder,
702
+ workingDir,
913
703
  });
704
+ return result.released;
914
705
  }
915
706
  /**
916
707
  * Query who holds a specific lock
917
708
  */
918
709
  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
- });
710
+ return sendSimpleDaemonRequest({
711
+ type: 'query_lock',
712
+ photonName,
713
+ lockName,
959
714
  });
960
715
  }
961
716
  /**
962
717
  * Schedule a recurring job
963
718
  */
964
719
  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
- });
720
+ return sendSimpleDaemonRequest({
721
+ type: 'schedule',
722
+ photonName,
723
+ sessionId: SESSION_ID,
724
+ jobId,
725
+ method,
726
+ cron,
727
+ args,
1009
728
  });
1010
729
  }
1011
730
  /**
1012
731
  * Unschedule a job
1013
732
  */
1014
733
  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
- });
734
+ const result = await sendSimpleDaemonRequest({
735
+ type: 'unschedule',
736
+ photonName,
737
+ jobId,
1055
738
  });
739
+ return result.unscheduled;
1056
740
  }
1057
741
  /**
1058
742
  * List all scheduled jobs
1059
743
  */
1060
744
  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
- });
745
+ const result = await sendSimpleDaemonRequest({
746
+ type: 'list_jobs',
747
+ photonName,
1100
748
  });
749
+ return result.jobs || [];
1101
750
  }
1102
751
  /** Fire-and-forget helper for the new RPCs. Returns the parsed result.data. */
1103
752
  async function sendSimpleDaemonRequest(req) {
753
+ const maxRetries = DEFAULT_DAEMON_COMMAND_RETRIES;
754
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
755
+ try {
756
+ await ensureDaemonReady();
757
+ return await sendSimpleDaemonRequestDirect(req);
758
+ }
759
+ catch (error) {
760
+ if (isDaemonConnectionError(error) && attempt < maxRetries) {
761
+ logger.info(`Daemon unreachable during ${req.type}, waiting for daemon...`);
762
+ await waitBeforeDaemonRetry(attempt);
763
+ await ensureDaemonReady();
764
+ continue;
765
+ }
766
+ throw error;
767
+ }
768
+ }
769
+ throw new Error(`${req.type} failed`);
770
+ }
771
+ async function sendSimpleDaemonRequestDirect(req) {
1104
772
  const socketPath = getGlobalSocketPath();
1105
773
  const requestId = req.id || `${req.type}_${Date.now()}_${Math.random().toString(36).slice(2)}`;
1106
774
  return new Promise((resolve, reject) => {
@@ -1331,6 +999,25 @@ export async function clearInstances(photonName, workingDir) {
1331
999
  });
1332
1000
  }
1333
1001
  export async function getEventsSince(photonName, channel, lastEventId) {
1002
+ const maxRetries = DEFAULT_DAEMON_COMMAND_RETRIES;
1003
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
1004
+ try {
1005
+ await ensureDaemonReady();
1006
+ return await getEventsSinceDirect(photonName, channel, lastEventId);
1007
+ }
1008
+ catch (error) {
1009
+ if (isDaemonConnectionError(error) && attempt < maxRetries) {
1010
+ logger.info(`Daemon unreachable during get_events_since (${channel}), waiting for daemon...`);
1011
+ await waitBeforeDaemonRetry(attempt);
1012
+ await ensureDaemonReady();
1013
+ continue;
1014
+ }
1015
+ throw error;
1016
+ }
1017
+ }
1018
+ throw new Error('get_events_since failed');
1019
+ }
1020
+ async function getEventsSinceDirect(photonName, channel, lastEventId) {
1334
1021
  const socketPath = getGlobalSocketPath();
1335
1022
  const requestId = `getevents_${Date.now()}_${Math.random().toString(36).slice(2)}`;
1336
1023
  return new Promise((resolve, reject) => {