@jpcasabiancai/cayenne 0.3.4 → 0.3.5

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/dist/cli.js +235 -43
  2. package/package.json +6 -2
package/dist/cli.js CHANGED
@@ -27,11 +27,11 @@ var CLOUD_SUPABASE_URL, CLOUD_SUPABASE_ANON_KEY, CLOUD_APP_ORIGIN;
27
27
  var init_cloud = __esm({
28
28
  "src/cloud.ts"() {
29
29
  "use strict";
30
- CLOUD_SUPABASE_URL = "";
30
+ CLOUD_SUPABASE_URL = "https://ndqrkqxwkzxgnhkvcunr.supabase.co";
31
31
  CLOUD_SUPABASE_ANON_KEY = assertPublishable(
32
- ""
32
+ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im5kcXJrcXh3a3p4Z25oa3ZjdW5yIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODg3MTMyNzYsImV4cCI6MjEwNDI4OTI3Nn0.Dlsxw91E1QYZ4eoRDjP80Rf4feY77Z-T-fiIwUDY2k0"
33
33
  );
34
- CLOUD_APP_ORIGIN = "";
34
+ CLOUD_APP_ORIGIN = "https://cayenne-pink.vercel.app";
35
35
  }
36
36
  });
37
37
 
@@ -1646,11 +1646,13 @@ var init_lock = __esm({
1646
1646
  });
1647
1647
 
1648
1648
  // ../../packages/db/src/client.ts
1649
- import { createClient } from "@supabase/supabase-js";
1650
- function createRunnerClient(url, anonKey, storage) {
1649
+ import {
1650
+ createClient
1651
+ } from "@supabase/supabase-js";
1652
+ function createRunnerClient(url, anonKey, storage, transport) {
1651
1653
  return createClient(url, anonKey, {
1652
1654
  auth: { storage, persistSession: true, autoRefreshToken: true, detectSessionInUrl: false },
1653
- realtime: { params: { eventsPerSecond: 50 } }
1655
+ realtime: { params: { eventsPerSecond: 50 }, transport }
1654
1656
  });
1655
1657
  }
1656
1658
  var init_client = __esm({
@@ -1667,17 +1669,38 @@ var init_src2 = __esm({
1667
1669
  }
1668
1670
  });
1669
1671
 
1672
+ // src/realtime-transport.ts
1673
+ import { WebSocket } from "ws";
1674
+ function realtimeTransport(handshakeTimeoutMs = REALTIME_HANDSHAKE_TIMEOUT_MS) {
1675
+ return class RealtimeTransport2 extends WebSocket {
1676
+ static handshakeTimeoutMs = handshakeTimeoutMs;
1677
+ constructor(address, protocols) {
1678
+ super(address, protocols, { handshakeTimeout: RealtimeTransport2.handshakeTimeoutMs });
1679
+ }
1680
+ };
1681
+ }
1682
+ var REALTIME_HANDSHAKE_TIMEOUT_MS, RealtimeTransport;
1683
+ var init_realtime_transport = __esm({
1684
+ "src/realtime-transport.ts"() {
1685
+ "use strict";
1686
+ REALTIME_HANDSHAKE_TIMEOUT_MS = 1e4;
1687
+ RealtimeTransport = realtimeTransport();
1688
+ }
1689
+ });
1690
+
1670
1691
  // src/supabase.ts
1671
1692
  import { join as join7 } from "path";
1672
1693
  function makeClient(cfg, dir = cayenneDir()) {
1673
1694
  const storage = new FileSessionStorage(join7(dir, "credentials.json"));
1674
- return createRunnerClient(cfg.supabaseUrl, cfg.supabaseAnonKey, storage);
1695
+ const transport = RealtimeTransport;
1696
+ return createRunnerClient(cfg.supabaseUrl, cfg.supabaseAnonKey, storage, transport);
1675
1697
  }
1676
1698
  var init_supabase = __esm({
1677
1699
  "src/supabase.ts"() {
1678
1700
  "use strict";
1679
1701
  init_src2();
1680
1702
  init_config();
1703
+ init_realtime_transport();
1681
1704
  }
1682
1705
  });
1683
1706
 
@@ -1978,12 +2001,17 @@ var init_dispatcher = __esm({
1978
2001
 
1979
2002
  // src/commands/source.ts
1980
2003
  import { LRUCache } from "lru-cache";
1981
- var STOPPING, byCreatedAt, GIT_READ_KINDS, GIT_WORKTREE_KINDS, laneOf, CommandSource;
2004
+ var STOPPING, REJOIN_MIN_MS, REJOIN_MAX_MS, SOCKET_RESET_MS, LOST, messageOf, byCreatedAt, GIT_READ_KINDS, GIT_WORKTREE_KINDS, laneOf, CommandSource;
1982
2005
  var init_source = __esm({
1983
2006
  "src/commands/source.ts"() {
1984
2007
  "use strict";
1985
2008
  init_src();
1986
2009
  STOPPING = "runner stopping";
2010
+ REJOIN_MIN_MS = 1e3;
2011
+ REJOIN_MAX_MS = 3e4;
2012
+ SOCKET_RESET_MS = 6e4;
2013
+ LOST = /* @__PURE__ */ new Set(["CHANNEL_ERROR", "TIMED_OUT", "CLOSED"]);
2014
+ messageOf = (err) => err instanceof Error ? err.message : String(err);
1987
2015
  byCreatedAt = (a, b) => Date.parse(a.created_at) - Date.parse(b.created_at) || a.id.localeCompare(b.id);
1988
2016
  GIT_READ_KINDS = /* @__PURE__ */ new Set(["git.status", "git.diff", "git.branches"]);
1989
2017
  GIT_WORKTREE_KINDS = /* @__PURE__ */ new Set([
@@ -2018,32 +2046,175 @@ var init_source = __esm({
2018
2046
  /** Rows between `enqueue` and their settlement: an `offer` for one of these registers hooks only. */
2019
2047
  inflight = /* @__PURE__ */ new Set();
2020
2048
  queues = /* @__PURE__ */ new Map();
2049
+ /** The live `runner:{id}` instance; only its statuses count (see `onStatus`). */
2021
2050
  channel = null;
2051
+ /** Every instance this source gave up on: the client can hand one back if its removal did not take. */
2052
+ abandoned = /* @__PURE__ */ new WeakSet();
2053
+ /** Removals of abandoned instances; never rejects. A rejoin waits for it before reusing the topic. */
2054
+ removal = Promise.resolve();
2055
+ rejoinTimer = null;
2056
+ /** Rejoin attempts scheduled since the last `SUBSCRIBED`: the backoff step. */
2057
+ attempts = 0;
2058
+ /** `now()` at the first attempt that found the socket down, or at its last reset; null while up. */
2059
+ socketDownSince = null;
2022
2060
  staleMs;
2023
2061
  now;
2024
2062
  stopped = false;
2025
2063
  /** The in-flight `drain()`, so `stop()` can wait for work it never started itself. */
2026
2064
  draining = null;
2065
+ /** A `drain()` arrived while one was in flight: that one reads once more before it ends. */
2066
+ again = false;
2027
2067
  /** Broadcasts held back while a drain is in flight; see `drain()` for why. */
2028
2068
  pending = [];
2069
+ /** Join `runner:{id}` at once; no connectedness check, since `subscribe` connects the socket. */
2029
2070
  async start() {
2030
- this.channel = this.db.channel(runnerTopic(this.runnerId), { config: { private: true } }).on("broadcast", { event: "command" }, ({ payload }) => {
2031
- const record = payload?.record;
2032
- const parsed = CommandRow.safeParse(record);
2033
- if (!parsed.success) {
2034
- this.log.warn({ issues: parsed.error.issues }, "ignoring malformed command broadcast");
2071
+ this.join(this.runnerChannel());
2072
+ }
2073
+ /**
2074
+ * The one place a `runner:{id}` instance is asked for, first join and rejoins alike. Private:
2075
+ * Realtime authorises the join against RLS, which is what keeps this topic this runner's.
2076
+ */
2077
+ runnerChannel() {
2078
+ return this.db.channel(runnerTopic(this.runnerId), { config: { private: true } });
2079
+ }
2080
+ /** Make `ch` the live instance, then give it the one broadcast and status handler every instance gets. */
2081
+ join(ch) {
2082
+ this.channel = ch;
2083
+ ch.on("broadcast", { event: "command" }, ({ payload }) => this.onBroadcast(payload)).subscribe(
2084
+ (status, err) => this.onStatus(ch, status, err)
2085
+ );
2086
+ }
2087
+ onBroadcast(payload) {
2088
+ const record = payload?.record;
2089
+ const parsed = CommandRow.safeParse(record);
2090
+ if (!parsed.success) {
2091
+ this.log.warn({ issues: parsed.error.issues }, "ignoring malformed command broadcast");
2092
+ return;
2093
+ }
2094
+ if (parsed.data.runner_id !== this.runnerId) {
2095
+ this.log.warn({ id: parsed.data.id }, "ignoring command for another runner");
2096
+ return;
2097
+ }
2098
+ if (this.draining) this.pending.push(parsed.data);
2099
+ else this.enqueue(parsed.data);
2100
+ }
2101
+ /**
2102
+ * The subscribe callback of every instance this source joined (BACKLOG P-29, protocol §8 step
2103
+ * 5). Nothing else rejoins a lost channel: after a three-hour outage the client's own rejoin never
2104
+ * ran, because its socket never reopened. So a lost instance is abandoned and a fresh one joined
2105
+ * after a backoff; every `SUBSCRIBED` drains, since broadcasts sent while the channel was down
2106
+ * are gone and their rows sit `queued`.
2107
+ *
2108
+ * Only the live instance counts. An abandoned one still reports: its own removal fires `CLOSED`
2109
+ * in-line, and handling that as a loss would remove the instance again, whose leave fires
2110
+ * `CLOSED` again, and so on: removals nested inside removals.
2111
+ */
2112
+ onStatus(ch, status, err) {
2113
+ if (ch !== this.channel) {
2114
+ this.log.debug({ status }, "ignoring the status of a runner channel no longer in use");
2115
+ return;
2116
+ }
2117
+ this.log.info({ status, err: err?.message }, "runner channel status");
2118
+ if (status === "SUBSCRIBED") {
2119
+ this.attempts = 0;
2120
+ this.socketDownSince = null;
2121
+ void this.drain();
2122
+ } else if (LOST.has(status)) {
2123
+ this.abandon(ch);
2124
+ this.scheduleRejoin(status);
2125
+ }
2126
+ }
2127
+ /** Stop listening to `ch` (null first, so its removal's `CLOSED` is ignored), then remove it. */
2128
+ abandon(ch) {
2129
+ if (this.channel === ch) this.channel = null;
2130
+ this.abandoned.add(ch);
2131
+ const left = (async () => this.db.removeChannel(ch))().then(
2132
+ (result) => {
2133
+ if (result === "ok" || result === "timed out") return true;
2134
+ this.log.warn({ result }, "leaving the runner channel failed");
2135
+ return false;
2136
+ },
2137
+ (err) => {
2138
+ this.log.warn({ err: messageOf(err) }, "leaving the runner channel failed");
2139
+ return false;
2140
+ }
2141
+ );
2142
+ this.removal = Promise.all([this.removal, left]).then(() => {
2143
+ });
2144
+ return left;
2145
+ }
2146
+ /**
2147
+ * At most one pending attempt, `min(1 s * 2^n, 30 s)` away, n = attempts since the last
2148
+ * `SUBSCRIBED`. `reason` says why: the lost channel's status, or `SOCKET_DOWN`, `NOT_REMOVED`,
2149
+ * `JOIN_FAILED` for an attempt that could not join. It is logged as `reason`, not `status`, so
2150
+ * a search for the client's statuses never meets one of these.
2151
+ */
2152
+ scheduleRejoin(reason) {
2153
+ if (this.stopped || this.rejoinTimer) return;
2154
+ const delayMs = Math.min(REJOIN_MIN_MS * 2 ** this.attempts, REJOIN_MAX_MS);
2155
+ this.attempts += 1;
2156
+ this.log.warn({ reason, attempt: this.attempts, delayMs }, "runner channel lost; rejoining");
2157
+ this.rejoinTimer = setTimeout(() => {
2158
+ this.rejoinTimer = null;
2159
+ void this.rejoin();
2160
+ }, delayMs);
2161
+ }
2162
+ /**
2163
+ * One attempt. The client hands back the instance it holds for a topic and drops a topic's
2164
+ * entry when any instance of it closes, so the old removal must have finished before a fresh
2165
+ * instance is created. A socket that is down gets a `connect()` and no join: it buffers every
2166
+ * push while down and flushes them all on reopen, so a join per attempt through a long outage
2167
+ * would arrive as a burst of joins and leaves.
2168
+ */
2169
+ async rejoin() {
2170
+ await this.removal;
2171
+ if (this.stopped) return;
2172
+ try {
2173
+ if (!this.db.realtime.isConnected()) {
2174
+ await this.reconnectSocket();
2175
+ this.scheduleRejoin("SOCKET_DOWN");
2035
2176
  return;
2036
2177
  }
2037
- if (parsed.data.runner_id !== this.runnerId) {
2038
- this.log.warn({ id: parsed.data.id }, "ignoring command for another runner");
2178
+ this.socketDownSince = null;
2179
+ const ch = this.runnerChannel();
2180
+ if (this.abandoned.has(ch)) {
2181
+ this.log.warn({ attempt: this.attempts }, "runner channel still held after leaving it");
2182
+ this.abandon(ch);
2183
+ this.scheduleRejoin("NOT_REMOVED");
2039
2184
  return;
2040
2185
  }
2041
- if (this.draining) this.pending.push(parsed.data);
2042
- else this.enqueue(parsed.data);
2043
- }).subscribe((status, err) => {
2044
- this.log.info({ status, err: err?.message }, "runner channel status");
2045
- if (status === "SUBSCRIBED") void this.drain();
2046
- });
2186
+ this.join(ch);
2187
+ } catch (err) {
2188
+ this.log.warn(
2189
+ { attempt: this.attempts, err: messageOf(err) },
2190
+ "runner channel rejoin failed"
2191
+ );
2192
+ if (this.channel) this.abandon(this.channel);
2193
+ this.scheduleRejoin("JOIN_FAILED");
2194
+ }
2195
+ }
2196
+ /**
2197
+ * Ask a down socket to connect. Once attempts have found it down for `SOCKET_RESET_MS`, tear it
2198
+ * down first: `connect()` returns early while the client still holds a conn, and a conn whose
2199
+ * handshake failed without a close (Node's own WebSocket, BACKLOG P-29) is held forever. The
2200
+ * `ws` transport should never leave one; this clears it whatever the transport. In an ordinary
2201
+ * outage it costs nothing, since phoenix's reconnect loop carries on from this `connect()`.
2202
+ */
2203
+ async reconnectSocket() {
2204
+ const now = this.now();
2205
+ this.socketDownSince ??= now;
2206
+ const downMs = now - this.socketDownSince;
2207
+ if (downMs >= SOCKET_RESET_MS) {
2208
+ this.log.warn({ downMs }, "realtime socket still down; resetting it");
2209
+ this.socketDownSince = now;
2210
+ try {
2211
+ await this.db.realtime.disconnect();
2212
+ } catch (err) {
2213
+ this.log.warn({ err: messageOf(err) }, "realtime socket reset failed");
2214
+ }
2215
+ if (this.stopped) return;
2216
+ }
2217
+ this.db.realtime.connect();
2047
2218
  }
2048
2219
  /**
2049
2220
  * Pick up rows queued while the runner was offline, oldest first.
@@ -2051,15 +2222,37 @@ var init_source = __esm({
2051
2222
  * Broadcasts that land while the select is in flight are buffered and merged in here rather
2052
2223
  * than enqueued as they arrive: every drained row predates them, and protocol §2.1 requires
2053
2224
  * one session's commands to execute in `created_at` order.
2225
+ *
2226
+ * One drain at a time: a call while one is in flight (a rejoin's `SUBSCRIBED`) asks it for one
2227
+ * more read and waits for it. A second, overlapping select would release the buffer while the
2228
+ * first was still reading older rows. Rows and buffered broadcasts of every read are merged once,
2229
+ * at the end, because a row only the later read finds can predate a broadcast held earlier.
2054
2230
  */
2055
- async drain() {
2056
- if (this.stopped) return;
2057
- let release = () => {
2058
- };
2059
- this.draining = new Promise((res) => {
2060
- release = res;
2061
- });
2231
+ drain() {
2232
+ if (this.stopped) return Promise.resolve();
2233
+ if (this.draining) {
2234
+ this.again = true;
2235
+ return this.draining;
2236
+ }
2237
+ this.draining = this.drainAll();
2238
+ return this.draining;
2239
+ }
2240
+ async drainAll() {
2062
2241
  const rows = [];
2242
+ try {
2243
+ do {
2244
+ this.again = false;
2245
+ await this.readQueued(rows);
2246
+ } while (this.again && !this.stopped);
2247
+ } finally {
2248
+ const merged = [...rows, ...this.pending].sort(byCreatedAt);
2249
+ this.pending = [];
2250
+ this.draining = null;
2251
+ for (const row of merged) this.enqueue(row);
2252
+ }
2253
+ }
2254
+ /** One read of this runner's `queued` rows, appended to `rows`. Never rejects. */
2255
+ async readQueued(rows) {
2063
2256
  try {
2064
2257
  const { data, error } = await this.db.from("runner_commands").select("*").eq("runner_id", this.runnerId).eq("status", "queued").order("created_at", { ascending: true });
2065
2258
  if (error) this.log.error({ err: error.message }, "drain failed");
@@ -2069,17 +2262,16 @@ var init_source = __esm({
2069
2262
  }
2070
2263
  } catch (err) {
2071
2264
  this.log.error({ err }, "drain failed");
2072
- } finally {
2073
- const merged = [...rows, ...this.pending].sort(byCreatedAt);
2074
- this.pending = [];
2075
- this.draining = null;
2076
- for (const row of merged) this.enqueue(row);
2077
- release();
2078
2265
  }
2079
2266
  }
2080
2267
  async stop() {
2081
2268
  this.stopped = true;
2082
- if (this.channel) await this.db.removeChannel(this.channel);
2269
+ if (this.rejoinTimer) clearTimeout(this.rejoinTimer);
2270
+ this.rejoinTimer = null;
2271
+ const live = this.channel;
2272
+ const left = live ? this.abandon(live) : null;
2273
+ await this.removal;
2274
+ if (left && await left) this.log.info({ status: "CLOSED" }, "runner channel status");
2083
2275
  this.pending = [];
2084
2276
  await this.draining;
2085
2277
  while (this.queues.size > 0) await Promise.allSettled([...this.queues.values()]);
@@ -8733,7 +8925,7 @@ function createTerminalChannels({
8733
8925
  log.warn({ terminalId, type: m.type, result }, "terminal frame not sent");
8734
8926
  },
8735
8927
  (err) => {
8736
- log.warn({ terminalId, type: m.type, err: messageOf(err) }, "terminal frame not sent");
8928
+ log.warn({ terminalId, type: m.type, err: messageOf2(err) }, "terminal frame not sent");
8737
8929
  }
8738
8930
  ).finally(() => {
8739
8931
  entry.pending.delete(sent);
@@ -8773,7 +8965,7 @@ function createTerminalChannels({
8773
8965
  try {
8774
8966
  manager.receive(terminalId, payload, "relay");
8775
8967
  } catch (err) {
8776
- log.warn({ terminalId, err: messageOf(err) }, "terminal message refused");
8968
+ log.warn({ terminalId, err: messageOf2(err) }, "terminal message refused");
8777
8969
  }
8778
8970
  });
8779
8971
  joined.set(terminalId, { channel, pending: /* @__PURE__ */ new Set() });
@@ -8804,12 +8996,12 @@ function createTerminalChannels({
8804
8996
  }
8805
8997
  }
8806
8998
  }
8807
- var messageOf;
8999
+ var messageOf2;
8808
9000
  var init_channels = __esm({
8809
9001
  "src/terminal/channels.ts"() {
8810
9002
  "use strict";
8811
9003
  init_src();
8812
- messageOf = (err) => err instanceof Error ? err.message : String(err);
9004
+ messageOf2 = (err) => err instanceof Error ? err.message : String(err);
8813
9005
  }
8814
9006
  });
8815
9007
 
@@ -9746,7 +9938,7 @@ function createTerminalService({
9746
9938
  await closeRow(terminalId);
9747
9939
  await channels.detach(terminalId);
9748
9940
  })().catch((err) => {
9749
- log.warn({ terminalId, err: messageOf2(err) }, "terminal exit cleanup failed");
9941
+ log.warn({ terminalId, err: messageOf3(err) }, "terminal exit cleanup failed");
9750
9942
  }).finally(() => {
9751
9943
  exiting.delete(done);
9752
9944
  });
@@ -9777,7 +9969,7 @@ function createTerminalService({
9777
9969
  if (!manager.has(terminalId) && !cleanedWhileOpening.has(terminalId)) {
9778
9970
  live.delete(terminalId);
9779
9971
  await channels.detach(terminalId).catch((detachErr) => {
9780
- log.warn({ terminalId, err: messageOf2(detachErr) }, "terminal channel detach failed");
9972
+ log.warn({ terminalId, err: messageOf3(detachErr) }, "terminal channel detach failed");
9781
9973
  });
9782
9974
  await closeRow(terminalId);
9783
9975
  }
@@ -9838,13 +10030,13 @@ function createTerminalService({
9838
10030
  }
9839
10031
  };
9840
10032
  }
9841
- var messageOf2;
10033
+ var messageOf3;
9842
10034
  var init_service3 = __esm({
9843
10035
  "src/terminal/service.ts"() {
9844
10036
  "use strict";
9845
10037
  init_manager2();
9846
10038
  init_refusals();
9847
- messageOf2 = (err) => err instanceof Error ? err.message : String(err);
10039
+ messageOf3 = (err) => err instanceof Error ? err.message : String(err);
9848
10040
  }
9849
10041
  });
9850
10042
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jpcasabiancai/cayenne",
3
- "version": "0.3.4",
3
+ "version": "0.3.5",
4
4
  "files": [
5
5
  "dist/cli.js"
6
6
  ],
@@ -24,7 +24,11 @@
24
24
  "dev": "tsx src/cli.ts start",
25
25
  "build": "tsup",
26
26
  "postbuild": "node dist/cli.js --version",
27
- "prepublishOnly": "pnpm build",
27
+ "release:guard:pre": "tsx src/release/cli.ts --pre",
28
+ "release:guard:post": "tsx src/release/cli.ts --post",
29
+ "release:build": "pnpm release:guard:pre && CAYENNE_REQUIRE_CLOUD=1 pnpm build && pnpm release:guard:post",
30
+ "release:pack": "pnpm release:build && npm pack",
31
+ "prepublishOnly": "pnpm release:build",
28
32
  "typecheck": "tsc -p tsconfig.json",
29
33
  "test": "vitest run"
30
34
  },