orchestrating 0.4.0 → 0.4.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.
Files changed (2) hide show
  1. package/bin/orch +87 -26
  2. package/package.json +1 -1
package/bin/orch CHANGED
@@ -57,12 +57,17 @@ function clearAuth() {
57
57
  const SUPABASE_URL = process.env.SUPABASE_URL || "https://vhyqfzdskgrmnxrdaqql.supabase.co";
58
58
  const SUPABASE_ANON_KEY = process.env.SUPABASE_ANON_KEY || "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InZoeXFmemRza2dybW54cmRhcXFsIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzE1NjU5NTYsImV4cCI6MjA4NzE0MTk1Nn0.hWAceFshHoeTlnwThM08enNWG-ZeXICl3uyi-AmIJEk";
59
59
 
60
+ // Returns one of:
61
+ // { ok: true, token } — refreshed successfully
62
+ // { ok: false, permanent: true } — refresh token invalid/revoked; re-auth required
63
+ // { ok: false, permanent: false } — transient failure (network/5xx); safe to retry with backoff
60
64
  async function refreshAuthToken() {
61
65
  const auth = loadStoredAuth();
62
- if (!auth || !auth.refresh_token) return null;
66
+ if (!auth || !auth.refresh_token) return { ok: false, permanent: true };
63
67
 
68
+ let res;
64
69
  try {
65
- const res = await fetch(`${SUPABASE_URL}/auth/v1/token?grant_type=refresh_token`, {
70
+ res = await fetch(`${SUPABASE_URL}/auth/v1/token?grant_type=refresh_token`, {
66
71
  method: "POST",
67
72
  headers: {
68
73
  "Content-Type": "application/json",
@@ -70,20 +75,30 @@ async function refreshAuthToken() {
70
75
  },
71
76
  body: JSON.stringify({ refresh_token: auth.refresh_token }),
72
77
  });
78
+ } catch {
79
+ // Network error / server unreachable — transient
80
+ return { ok: false, permanent: false };
81
+ }
73
82
 
74
- if (!res.ok) return null;
75
-
76
- const data = await res.json();
77
- if (data.access_token) {
83
+ if (res.ok) {
84
+ let data;
85
+ try { data = await res.json(); } catch { return { ok: false, permanent: false }; }
86
+ if (data && data.access_token) {
78
87
  saveAuth({
79
88
  access_token: data.access_token,
80
89
  refresh_token: data.refresh_token || auth.refresh_token,
81
90
  expires_at: data.expires_at || 0,
82
91
  });
83
- return data.access_token;
92
+ return { ok: true, token: data.access_token };
84
93
  }
85
- } catch {}
86
- return null;
94
+ return { ok: false, permanent: true };
95
+ }
96
+
97
+ // 400/401/403 → refresh token rejected (permanent). 408/429/5xx/522 → transient.
98
+ if (res.status === 400 || res.status === 401 || res.status === 403) {
99
+ return { ok: false, permanent: true };
100
+ }
101
+ return { ok: false, permanent: false };
87
102
  }
88
103
 
89
104
  function getAuthToken() {
@@ -480,12 +495,16 @@ async function handleDaemon(projectsDir) {
480
495
  process.exit(1);
481
496
  }
482
497
  if (isTokenExpired()) {
483
- const refreshed = await refreshAuthToken();
484
- if (refreshed) {
485
- token = refreshed;
486
- } else {
498
+ const r = await refreshAuthToken();
499
+ if (r.ok) {
500
+ token = r.token;
501
+ } else if (r.permanent) {
487
502
  console.error(`${RED}Session expired. Run 'orch login' to re-authenticate.${RESET}`);
488
503
  process.exit(1);
504
+ } else {
505
+ // Auth server unreachable at startup — keep the (stale) token and let the
506
+ // reconnect loop retry with backoff rather than crash-loop under launchd.
507
+ console.error(`${RED}Could not reach auth server — will keep retrying in the background.${RESET}`);
489
508
  }
490
509
  }
491
510
 
@@ -497,11 +516,17 @@ async function handleDaemon(projectsDir) {
497
516
  const PING_INTERVAL_MS = 30_000;
498
517
  const PONG_TIMEOUT_MS = 10_000;
499
518
  const HEARTBEAT_TIMEOUT_MS = 65_000; // expect server heartbeat every 30s, allow 2 missed + margin
519
+ const RECONNECT_BASE_SEC = 2; // initial reconnect delay
520
+ const RECONNECT_MAX_SEC = 300; // cap backoff at 5 min so a down server isn't hammered
521
+ const REAUTH_POLL_SEC = 300; // while paused for re-auth, re-check the token every 5 min
500
522
  let ws = null;
501
523
  let pingTimer = null;
502
524
  let pongTimer = null;
503
525
  let reconnectTimer = null;
504
526
  let heartbeatTimer = null;
527
+ let reauthTimer = null;
528
+ let reconnectAttempts = 0; // grows on consecutive failures; reset on a successful connect
529
+ let needsReauth = false; // true once the refresh token is rejected; pauses the reconnect loop
505
530
 
506
531
  // Scan project directories
507
532
  const homeDir = os.homedir();
@@ -536,25 +561,59 @@ async function handleDaemon(projectsDir) {
536
561
  }, HEARTBEAT_TIMEOUT_MS);
537
562
  }
538
563
 
539
- function scheduleReconnect(delaySec) {
540
- if (reconnecting) return;
564
+ function scheduleReconnect(minSec) {
565
+ if (reconnecting || needsReauth) return;
541
566
  reconnecting = true;
567
+ // Exponential backoff with full jitter, capped — bounds the request rate when the
568
+ // server or auth endpoint is down (a fixed delay meant relentless retries forever).
569
+ const exp = Math.min(RECONNECT_MAX_SEC, RECONNECT_BASE_SEC * 2 ** reconnectAttempts);
570
+ const ceil = Math.max(minSec, exp);
571
+ const delay = Math.round(ceil / 2 + Math.random() * (ceil / 2));
572
+ reconnectAttempts++;
542
573
  if (reconnectTimer) clearTimeout(reconnectTimer);
543
574
  reconnectTimer = setTimeout(() => {
544
575
  reconnecting = false;
545
576
  connect();
546
- }, delaySec * 1000);
577
+ }, delay * 1000);
578
+ }
579
+
580
+ // Refresh token was rejected. Stop reconnecting (no point hammering the server) and
581
+ // wait for the user to run `orch login`. Staying alive — rather than exiting — avoids a
582
+ // launchd/systemd KeepAlive respawn loop that would just re-trigger the request flood.
583
+ function enterReauthWait() {
584
+ if (needsReauth) return;
585
+ needsReauth = true;
586
+ if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
587
+ process.stderr.write(`${RED}${PREFIX} Session invalid — run 'orch login' to re-authenticate.${RESET}\n`);
588
+ process.stderr.write(`${DIM}${PREFIX} Paused; will resume automatically once re-authenticated.${RESET}\n`);
589
+ const check = () => {
590
+ const auth = loadStoredAuth();
591
+ if (auth && auth.access_token && !isTokenExpired()) {
592
+ needsReauth = false;
593
+ reconnectAttempts = 0;
594
+ token = auth.access_token;
595
+ process.stderr.write(`${GREEN}${PREFIX} Re-authenticated — reconnecting${RESET}\n`);
596
+ connect();
597
+ } else {
598
+ reauthTimer = setTimeout(check, REAUTH_POLL_SEC * 1000);
599
+ }
600
+ };
601
+ reauthTimer = setTimeout(check, REAUTH_POLL_SEC * 1000);
547
602
  }
548
603
 
549
604
  async function connect() {
605
+ if (needsReauth) return; // paused until the user re-authenticates
550
606
  // Refresh token if needed before connecting
551
607
  if (isTokenExpired()) {
552
- const refreshed = await refreshAuthToken();
553
- if (refreshed) {
554
- token = refreshed;
608
+ const r = await refreshAuthToken();
609
+ if (r.ok) {
610
+ token = r.token;
555
611
  process.stderr.write(`${DIM}${PREFIX} Token refreshed${RESET}\n`);
612
+ } else if (r.permanent) {
613
+ enterReauthWait();
614
+ return;
556
615
  } else {
557
- process.stderr.write(`${RED}${PREFIX} Token refresh failed — retrying in 10s${RESET}\n`);
616
+ process.stderr.write(`${RED}${PREFIX} Token refresh failed (auth server unreachable) backing off${RESET}\n`);
558
617
  scheduleReconnect(10);
559
618
  return;
560
619
  }
@@ -568,6 +627,7 @@ async function handleDaemon(projectsDir) {
568
627
  ws = sock;
569
628
 
570
629
  sock.on("open", () => {
630
+ reconnectAttempts = 0; // healthy connection — reset backoff
571
631
  sock.send(JSON.stringify({
572
632
  type: "register_daemon",
573
633
  token,
@@ -610,14 +670,14 @@ async function handleDaemon(projectsDir) {
610
670
  process.stderr.write(`${RED}${PREFIX} Server: ${msg.error}${RESET}\n`);
611
671
  if (/unauthorized|auth|token/i.test(msg.error || "")) {
612
672
  // Don't reconnect from here — let the close handler do it after refreshing
613
- refreshAuthToken().then((refreshed) => {
614
- if (refreshed) {
615
- token = refreshed;
673
+ refreshAuthToken().then((r) => {
674
+ if (r.ok) {
675
+ token = r.token;
616
676
  process.stderr.write(`${DIM}${PREFIX} Token refreshed — will reconnect${RESET}\n`);
617
- } else {
618
- process.stderr.write(`${RED}${PREFIX} Auth failed. Run 'orch login'.${RESET}\n`);
619
- process.exit(1);
677
+ } else if (r.permanent) {
678
+ enterReauthWait();
620
679
  }
680
+ // transient: leave it — the close handler's backoff retry will try again
621
681
  });
622
682
  }
623
683
  return;
@@ -769,6 +829,7 @@ async function handleDaemon(projectsDir) {
769
829
  if (pongTimer) clearTimeout(pongTimer);
770
830
  if (heartbeatTimer) clearTimeout(heartbeatTimer);
771
831
  if (reconnectTimer) clearTimeout(reconnectTimer);
832
+ if (reauthTimer) clearTimeout(reauthTimer);
772
833
  if (ws) ws.close();
773
834
  process.exit(0);
774
835
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orchestrating",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Stream terminal sessions to the orchestrat.ing dashboard",
5
5
  "type": "module",
6
6
  "bin": {