@galda/cli 0.10.17 → 0.10.19

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.
@@ -8,10 +8,11 @@
8
8
  //
9
9
  // RELAY_URL=wss://relay.example/agent RELAY_AGENT_TOKEN=… node engine/relay-client.mjs
10
10
 
11
- import { readFileSync, existsSync, watch } from 'node:fs';
11
+ import { readFileSync, writeFileSync, existsSync, watch, unlinkSync } from 'node:fs';
12
12
  import { resolve, dirname, join } from 'node:path';
13
13
  import { homedir } from 'node:os';
14
14
  import { fileURLToPath } from 'node:url';
15
+ import { licenseTokenIdentity, relayReconnectDecision } from './lib.mjs';
15
16
 
16
17
  const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
17
18
  const DATA_DIR = process.env.MANAGER_HOME
@@ -39,6 +40,31 @@ let currentWs = null;
39
40
  let reconnectTimer = null;
40
41
  let lastToken = readToken();
41
42
 
43
+ // Stand-down state (see lib.mjs relayReconnectDecision). When another device
44
+ // running the SAME account takes the relay slot, the relay evicts us with 4000;
45
+ // we go stoodDown and remember whose slot we lost, so a later token re-mint of
46
+ // that same account does NOT make us re-dial and re-evict them (the infinite
47
+ // loop). currentIdentity = the account we last dialed with.
48
+ let relayState = { stoodDown: false, standingIdentity: null };
49
+ let currentIdentity = licenseTokenIdentity(readToken());
50
+
51
+ // Publish the relay connection state to a file the local server can read and
52
+ // surface at /api (P4's "in use on another device → switch here" banner). Kept
53
+ // as a tiny best-effort file because the server (:4400) and this relay-client
54
+ // are separate processes that only share DATA_DIR.
55
+ const STATUS_FILE = join(DATA_DIR, 'relay-status.json');
56
+ const TAKEOVER_FILE = join(DATA_DIR, 'relay-takeover.req');
57
+ function publishStatus(status) {
58
+ try {
59
+ writeFileSync(STATUS_FILE, JSON.stringify({
60
+ status, // 'online' | 'connecting' | 'standing-down' | 'idle'
61
+ identity: currentIdentity || null,
62
+ stoodDown: relayState.stoodDown,
63
+ at: Date.now(),
64
+ }));
65
+ } catch { /* best-effort */ }
66
+ }
67
+
42
68
  function scheduleReconnect(retryMs) {
43
69
  if (reconnectTimer) return; // exactly one pending reconnect — never stack timers
44
70
  reconnectTimer = setTimeout(() => { reconnectTimer = null; connect(Math.min(retryMs * 2, 15000)); }, retryMs);
@@ -52,15 +78,21 @@ function connect(retryMs = 1000) {
52
78
  // license.token watcher below calls connect() the instant sign-in writes the
53
79
  // token. A signed-in agent that merely drops still reconnects (token present).
54
80
  if (!DEV_EMAIL && !readToken()) return;
81
+ // Stood down: another device holds this account's slot. Do NOT dial — that is
82
+ // the re-evict that starts the mutual-kick loop. Only an explicit takeover
83
+ // (writes relay-takeover.req) or a real account switch clears this.
84
+ if (relayState.stoodDown) return;
55
85
  // Single-socket invariant: never open a second socket while one is CONNECTING
56
86
  // (0) or OPEN (1). Two live sockets with the same identity make the relay evict
57
87
  // each other (server.mjs "replaced by a newer connection") → an infinite flap.
58
88
  if (currentWs && (currentWs.readyState === 0 || currentWs.readyState === 1)) return;
59
89
  if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
90
+ currentIdentity = DEV_EMAIL ? DEV_EMAIL.trim().toLowerCase() : licenseTokenIdentity(readToken());
60
91
  const auth = DEV_EMAIL ? `devEmail=${encodeURIComponent(DEV_EMAIL)}` : `token=${encodeURIComponent(readToken())}`;
61
92
  const ws = new WebSocket(`${RELAY}?${auth}`);
62
93
  currentWs = ws;
63
- ws.onopen = () => { retryMs = 1000; console.log(`[agent] connected to relay ${RELAY}`); };
94
+ publishStatus('connecting');
95
+ ws.onopen = () => { retryMs = 1000; publishStatus('online'); console.log(`[agent] connected to relay ${RELAY}`); };
64
96
  ws.onmessage = async (ev) => {
65
97
  let m; try { m = JSON.parse(ev.data); } catch { return; }
66
98
  if (m.type === 'abort') { live.get(m.id)?.abort(); live.delete(m.id); return; }
@@ -102,45 +134,82 @@ function connect(retryMs = 1000) {
102
134
  };
103
135
  ws.onclose = (ev) => {
104
136
  if (ws !== currentWs) return; // a superseded socket (we already moved on) — don't reconnect
137
+ currentWs = null;
105
138
  // Close 4000 = the relay evicted us because a NEWER agent for the SAME account
106
- // connected (server.mjs "replaced by a newer connection"). If we reconnect we
107
- // evict THAT one the two instances mutual-kick forever, which surfaces to the
108
- // user as app.galda.app reload-looping. Stand down: the newest instance is
109
- // authoritative. (A normal network drop uses a different code and still
110
- // reconnects; sign-in via the watcher below can still retake.)
111
- if (ev && ev.code === 4000) {
112
- console.log('[agent] relay: superseded by a newer instance of this account — standing down (run only one manager per account)');
113
- currentWs = null;
139
+ // connected (server.mjs "replaced by a newer connection"). Stand down STICKILY:
140
+ // remember whose slot we lost so a later re-mint of that account's token can't
141
+ // silently re-dial and re-evict them (the app.galda.app reload "無限ループ").
142
+ // A normal network drop reconnects; an explicit takeover / real account switch
143
+ // clears the stand-down. See lib.mjs relayReconnectDecision.
144
+ const d = relayReconnectDecision(relayState, ev && ev.code === 4000
145
+ ? { type: 'evicted', identity: currentIdentity }
146
+ : { type: 'dropped' });
147
+ relayState = d.state;
148
+ if (d.reason === 'superseded') {
149
+ publishStatus('standing-down');
150
+ console.log('[agent] relay: this account is live on another device — standing down here. Use "switch to this device" to take over.');
114
151
  return;
115
152
  }
153
+ if (!d.dial) { publishStatus('standing-down'); return; }
154
+ publishStatus('connecting');
116
155
  console.log(`[agent] relay connection lost — retrying in ${retryMs}ms`);
117
156
  scheduleReconnect(retryMs);
118
157
  };
119
158
  ws.onerror = () => { /* onclose follows */ };
120
159
  }
121
160
 
122
- connect();
161
+ // Redial the relay once, immediately, after detaching the old socket (so its
162
+ // onclose is ignored: ws !== currentWs) — no racing reconnect chains.
163
+ function redialNow() {
164
+ const old = currentWs;
165
+ currentWs = null;
166
+ if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
167
+ try { old?.close(); } catch { /* already gone */ }
168
+ connect(1000);
169
+ }
170
+
171
+ // Explicit "switch to this device" (P4): the local server writes relay-takeover.req
172
+ // when the user claims this device from the "in use on another device" banner. We
173
+ // clear the stand-down and reclaim the slot on purpose.
174
+ function handleTakeover() {
175
+ try { unlinkSync(TAKEOVER_FILE); } catch { /* already consumed */ }
176
+ // Only meaningful while stood down — if we already hold (or are dialing) the
177
+ // slot there is nothing to reclaim. This also absorbs fs.watch double-firing
178
+ // the request file (macOS emits multiple events) into a single reconnect.
179
+ if (!relayState.stoodDown) return;
180
+ const d = relayReconnectDecision(relayState, { type: 'takeover' });
181
+ relayState = d.state;
182
+ if (d.dial) { console.log('[agent] taking over this account on this device (user requested)'); redialNow(); }
183
+ }
184
+
185
+ if (!process.env.RELAY_CLIENT_NO_START) {
186
+ connect();
123
187
 
124
- // Sign-in reconnect: when license.token appears/changes (the user just linked
125
- // this device), drop the current socket so connect() re-dials with the new
126
- // identity. Without this the first-run socket stays bound to nobody and the
127
- // hosted board never comes alive until the CLI is restarted. Watch the DIR (the
128
- // file may not exist yet on first run); best-effort (fs.watch unsupported the
129
- // onclose retry loop still re-reads the token on the next natural reconnect).
130
- try {
131
- watch(DATA_DIR, (_ev, f) => {
132
- if (f && f !== 'license.token') return;
133
- const t = readToken();
134
- if (t && t !== lastToken) {
188
+ // Watch DATA_DIR for two triggers (the file may not exist yet on first run;
189
+ // best-effort if fs.watch is unavailable the onclose retry still re-reads the
190
+ // token on the next natural reconnect):
191
+ // 1) license.token appears/changes sign-in or account switch. Route through
192
+ // the reducer: a re-mint of the account we already stood down from does NOT
193
+ // re-dial (kills the mutual-kick loop); a genuinely different account does.
194
+ // 2) relay-takeover.req → explicit user takeover.
195
+ try {
196
+ watch(DATA_DIR, (_ev, f) => {
197
+ if (f === 'relay-takeover.req' || (f == null && existsSync(TAKEOVER_FILE))) { handleTakeover(); return; }
198
+ if (f && f !== 'license.token') return;
199
+ const t = readToken();
200
+ if (!t || t === lastToken) return;
135
201
  lastToken = t;
202
+ const d = relayReconnectDecision(relayState, { type: 'tokenChanged', identity: licenseTokenIdentity(t) });
203
+ relayState = d.state;
204
+ if (!d.dial) {
205
+ publishStatus('standing-down');
206
+ console.log('[agent] license refreshed for the account already live on another device — staying down (use "switch to this device" to take over)');
207
+ return;
208
+ }
136
209
  console.log('[agent] signed in — reconnecting to relay with your identity');
137
- // Detach the old socket FIRST so its onclose is ignored (ws !== currentWs),
138
- // then reconnect once, immediately, with a fresh backoff — no racing chains.
139
- const old = currentWs;
140
- currentWs = null;
141
- if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
142
- try { old?.close(); } catch { /* already gone */ }
143
- connect(1000);
144
- }
145
- });
146
- } catch { /* fs.watch unavailable on this platform */ }
210
+ redialNow();
211
+ });
212
+ } catch { /* fs.watch unavailable on this platform */ }
213
+ }
214
+
215
+ export { redialNow, handleTakeover };