@tablation/crew 0.96.0 → 0.97.0

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 +146 -41
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -7278,6 +7278,18 @@ var Tracker = class {
7278
7278
  if (!row) return;
7279
7279
  await this.client.records.update(this.route.resolved.shipsModelId, row.id, patch2);
7280
7280
  }
7281
+ /**
7282
+ * Writes (or, with `null`, clears) this ship's `relay_url` (CREW-2803). A
7283
+ * no-op when there is no row for this ship or the Ships table has not taken
7284
+ * the column (probed by the key's absence from the row), so the tunnel
7285
+ * still runs on an install that predates it.
7286
+ */
7287
+ async updateRelayUrl(shipName2, url) {
7288
+ const row = await this.myShipRow(shipName2);
7289
+ if (!row || !("relay_url" in row)) return;
7290
+ if ((row.relay_url ?? null) === url) return;
7291
+ await this.client.records.update(this.route.resolved.shipsModelId, row.id, { relay_url: url });
7292
+ }
7281
7293
  /** The other half of `beatEngaged` — clears engagement back to idle. */
7282
7294
  async beatIdle(shipName2) {
7283
7295
  const row = await this.myShipRow(shipName2);
@@ -18145,6 +18157,26 @@ function sshKeygenAvailable() {
18145
18157
  }
18146
18158
  }
18147
18159
 
18160
+ // src/relay-probe.ts
18161
+ async function probeRelayControl(relayUrl, apiKey, userAgent, fetchFn = fetch, timeoutMs = 1e4) {
18162
+ const started = Date.now();
18163
+ try {
18164
+ const res = await fetchFn(`${relayUrl.replace(/\/+$/, "")}/control/status`, {
18165
+ headers: { Authorization: `Bearer ${apiKey}`, "User-Agent": userAgent },
18166
+ signal: AbortSignal.timeout(timeoutMs)
18167
+ });
18168
+ const latencyMs = Date.now() - started;
18169
+ await res.arrayBuffer().catch(() => void 0);
18170
+ if (res.ok) return { ok: true, status: res.status, latencyMs };
18171
+ return { ok: false, status: res.status, error: `HTTP ${res.status}` };
18172
+ } catch (e) {
18173
+ return { ok: false, error: e.message };
18174
+ }
18175
+ }
18176
+ function describeRelayProbe(url, p) {
18177
+ return p.ok ? `relay control: OK ${url}/control/status (${p.status}, ${p.latencyMs} ms)` : `relay control: FAILED ${url}/control/status \u2014 ${p.error}`;
18178
+ }
18179
+
18148
18180
  // src/passenger-containers.ts
18149
18181
  import { execFileSync as execFileSync9 } from "child_process";
18150
18182
  import { createHash as createHash7 } from "crypto";
@@ -18362,15 +18394,21 @@ function generateTunnelSlug() {
18362
18394
  for (let i = 0; i < SLUG_LENGTH; i++) out += SLUG_ALPHABET[randomInt(SLUG_ALPHABET.length)];
18363
18395
  return out;
18364
18396
  }
18365
- function bindAddrFor(o) {
18366
- const base = `workspace-${o.workspaceId}-slug-${o.slug}-key-${o.apiKey}`;
18367
- return o.mcpSecret ? `${base}-secret-${o.mcpSecret}` : base;
18397
+ function bindAddrFor(o, path) {
18398
+ const base = `workspace-${o.workspaceId}-slug-${o.slug}${path ? `-path-${path}` : ""}-key-${o.apiKey}`;
18399
+ return o.mcpSecret && !path ? `${base}-secret-${o.mcpSecret}` : base;
18368
18400
  }
18369
- function publicUrlFor(slug, relayHost, relayHttpPort, relayPublicDomain) {
18401
+ function relayBaseUrlFor(slug, relayHost, relayHttpPort, relayPublicDomain) {
18370
18402
  const domain = relayPublicDomain ?? relayHost.replace(/^.*@/, "");
18371
- return `https://${slug}.${domain}${relayHttpPort === 443 ? "" : `:${relayHttpPort}`}/mcp`;
18403
+ return `https://${slug}.${domain}${relayHttpPort === 443 ? "" : `:${relayHttpPort}`}`;
18404
+ }
18405
+ function publicUrlFor(slug, relayHost, relayHttpPort, relayPublicDomain) {
18406
+ return `${relayBaseUrlFor(slug, relayHost, relayHttpPort, relayPublicDomain)}/mcp`;
18372
18407
  }
18373
18408
  function sshArgsFor(o) {
18409
+ const forwards = [];
18410
+ if (o.localPort !== void 0) forwards.push("-R", `${bindAddrFor(o)}:0:127.0.0.1:${o.localPort}`);
18411
+ if (o.controlSocket) forwards.push("-R", `${bindAddrFor(o, "control")}:0:${o.controlSocket}`);
18374
18412
  return [
18375
18413
  "-N",
18376
18414
  "-o",
@@ -18385,8 +18423,7 @@ function sshArgsFor(o) {
18385
18423
  o.privateKeyPath,
18386
18424
  "-p",
18387
18425
  String(o.relayPort),
18388
- "-R",
18389
- `${bindAddrFor(o)}:0:127.0.0.1:${o.localPort}`,
18426
+ ...forwards,
18390
18427
  o.relayHost
18391
18428
  ];
18392
18429
  }
@@ -18457,16 +18494,19 @@ function isPidAlive(pid) {
18457
18494
  return false;
18458
18495
  }
18459
18496
  }
18497
+ var CONTROL_ONLY_HASH = "control-only";
18460
18498
  function syncPassengerTunnels(plans, relayHost, relayPort, privateKeyPath, stateDir, mcpSecrets = /* @__PURE__ */ new Map(), deps = {}) {
18461
18499
  const alive = deps.isPidAlive ?? isPidAlive;
18462
18500
  const plannedIds = new Set(plans.map((p) => p.workspaceId));
18463
18501
  for (const plan of plans) {
18464
18502
  const existing = readPersistedTunnel(stateDir, plan.workspaceId);
18503
+ const mountsHash = plan.mountsHash ?? CONTROL_ONLY_HASH;
18504
+ const controlUnchanged = existing?.controlSocket === deps.controlSocket;
18465
18505
  const currentSecret = mcpSecrets.get(plan.workspaceId);
18466
18506
  const secretUnchanged = (existing?.mcpSecret ?? void 0) === (currentSecret ?? void 0);
18467
18507
  const slug = existing?.slug ?? generateTunnelSlug();
18468
18508
  const slugUnchanged = existing?.liveSlug === slug;
18469
- if (existing && alive(existing.pid) && existing.mountsHash === plan.mountsHash && secretUnchanged && slugUnchanged) {
18509
+ if (existing && alive(existing.pid) && existing.mountsHash === mountsHash && secretUnchanged && slugUnchanged && controlUnchanged) {
18470
18510
  if (existing.status === "connecting") {
18471
18511
  const settled = { ...existing, status: "connected" };
18472
18512
  writePersistedTunnel(stateDir, settled);
@@ -18493,6 +18533,7 @@ function syncPassengerTunnels(plans, relayHost, relayPort, privateKeyPath, state
18493
18533
  slug,
18494
18534
  apiKey,
18495
18535
  localPort: plan.port,
18536
+ controlSocket: deps.controlSocket,
18496
18537
  privateKeyPath,
18497
18538
  mcpSecret: currentSecret,
18498
18539
  spawnFn: deps.spawnFn,
@@ -18502,9 +18543,10 @@ function syncPassengerTunnels(plans, relayHost, relayPort, privateKeyPath, state
18502
18543
  });
18503
18544
  const started = {
18504
18545
  pid: tunnel.pid ?? -1,
18505
- containerName: plan.containerName,
18546
+ containerName: plan.containerName ?? "",
18506
18547
  workspaceId: plan.workspaceId,
18507
- mountsHash: plan.mountsHash,
18548
+ mountsHash,
18549
+ controlSocket: deps.controlSocket,
18508
18550
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
18509
18551
  status: settledStatus,
18510
18552
  mcpSecret: currentSecret,
@@ -20484,6 +20526,11 @@ if (command === "passenger-sync-daemon") {
20484
20526
  }, parseTicketNumber = function(input) {
20485
20527
  const m = /^(?:[A-Za-z]+-)?(\d+)$/.exec(input.trim());
20486
20528
  return m ? m[1] : null;
20529
+ }, routeRelayUrl = function(route3) {
20530
+ const ws = route3.resolved?.workspaceId;
20531
+ const slug = ws ? readPersistedTunnel(cfg.ship.stateDir, ws)?.slug : void 0;
20532
+ if (!cfg.ship.relayHost || !slug) return null;
20533
+ return relayBaseUrlFor(slug, cfg.ship.relayHost, cfg.ship.relayHttpPort, cfg.ship.relayPublicDomain);
20487
20534
  }, passengerApiKeys = function(routes) {
20488
20535
  const keys = /* @__PURE__ */ new Map();
20489
20536
  for (const r of routes) {
@@ -20531,7 +20578,7 @@ if (command === "passenger-sync-daemon") {
20531
20578
  const unsafe = baseBranchUnsafe(out);
20532
20579
  return { action: out.action, unsafe, detail: unsafe ? describeUnsafeBase(out, remote, base) : out.detail };
20533
20580
  };
20534
- requireArmed2 = requireArmed, parseTicketNumber2 = parseTicketNumber, passengerApiKeys2 = passengerApiKeys, endpointApiFor2 = endpointApiFor, endpointReconcileDeps2 = endpointReconcileDeps, releaseRefused2 = releaseRefused, doctorMachineId2 = doctorMachineId, baseHealth2 = baseHealth;
20581
+ requireArmed2 = requireArmed, parseTicketNumber2 = parseTicketNumber, routeRelayUrl2 = routeRelayUrl, passengerApiKeys2 = passengerApiKeys, endpointApiFor2 = endpointApiFor, endpointReconcileDeps2 = endpointReconcileDeps, releaseRefused2 = releaseRefused, doctorMachineId2 = doctorMachineId, baseHealth2 = baseHealth;
20535
20582
  let cfg;
20536
20583
  let firstRun;
20537
20584
  let firstRunFromFlags = false;
@@ -20664,7 +20711,10 @@ if (command === "passenger-sync-daemon") {
20664
20711
  async function syncHostPassengers() {
20665
20712
  if (dryRun) return;
20666
20713
  const passengerRoutes = cfg.routes.filter((r) => r.enabled && r.hostPassengers);
20667
- if (passengerRoutes.length === 0) return;
20714
+ if (passengerRoutes.length === 0) {
20715
+ await syncShipTunnels([], /* @__PURE__ */ new Map(), []);
20716
+ return;
20717
+ }
20668
20718
  if (!dockerAvailable()) {
20669
20719
  const pathProblem = dockerPathProblem(cfg.ship, COMMON_DOCKER_DIRS);
20670
20720
  emit.warn(
@@ -20706,37 +20756,64 @@ if (command === "passenger-sync-daemon") {
20706
20756
  { step: "passengers", data: { ...result } }
20707
20757
  );
20708
20758
  }
20709
- if (cfg.ship.relayHost) {
20710
- try {
20711
- const { privateKeyPath } = ensureShipSshKeypair(cfg.ship.stateDir);
20712
- const plans = planContainers(cfg);
20713
- const mcpSecrets = new Map([...syncEndpoints].map(([workspaceId, e]) => [workspaceId, e.secret]));
20714
- const relayHost = cfg.ship.relayHost;
20715
- const reconcile = endpointReconcileDeps(passengerRoutes, relayHost);
20716
- const pending = [];
20717
- syncPassengerTunnels(plans, relayHost, cfg.ship.relayPort, privateKeyPath, cfg.ship.stateDir, mcpSecrets, {
20718
- apiKeys: passengerApiKeys(passengerRoutes),
20719
- onStatus: (workspaceId, status2, info) => {
20720
- const owningRoute = passengerRoutes.find((r) => r.resolved?.workspaceId === workspaceId);
20721
- if (!owningRoute) return;
20722
- pending.push(
20723
- new Tracker(owningRoute, cfg.ship).updateTunnelState(cfg.ship.name, { tunnel_status: status2 }).catch((e) => {
20724
- emit.warn(`could not write tunnel_status: ${e.message}`, { step: "passengers" });
20725
- })
20726
- );
20727
- if (status2 === "disconnected") pending.push(unregisterEndpoint(workspaceId, info, reconcile));
20728
- }
20729
- });
20730
- for (const plan of plans) pending.push(reconcileEndpoint(plan.workspaceId, reconcile));
20731
- await Promise.all(pending);
20732
- } catch (e) {
20733
- emit.warn(`passenger tunnel sync failed: ${e.message}`, { step: "passengers" });
20734
- }
20735
- }
20759
+ await syncShipTunnels(planContainers(cfg), new Map([...syncEndpoints].map(([workspaceId, e]) => [workspaceId, e.secret])), passengerRoutes);
20736
20760
  } catch (e) {
20737
20761
  emit.warn(`passenger container sync failed: ${e.message}`, { step: "passengers" });
20738
20762
  }
20739
20763
  }
20764
+ async function syncShipTunnels(containerPlans, mcpSecrets, passengerRoutes) {
20765
+ if (!cfg.ship.relayHost) return;
20766
+ const relayHost = cfg.ship.relayHost;
20767
+ try {
20768
+ const { privateKeyPath } = ensureShipSshKeypair(cfg.ship.stateDir);
20769
+ const controlRoutes = cfg.routes.filter((r) => r.enabled && r.resolved?.workspaceId);
20770
+ const planned = new Set(containerPlans.map((p) => p.workspaceId));
20771
+ const plans = [...containerPlans];
20772
+ for (const r of controlRoutes) {
20773
+ const ws = r.resolved.workspaceId;
20774
+ if (planned.has(ws)) continue;
20775
+ planned.add(ws);
20776
+ plans.push({ workspaceId: ws });
20777
+ }
20778
+ const apiRoutes = controlRoutes;
20779
+ const reconcile = endpointReconcileDeps(passengerRoutes, relayHost);
20780
+ const pending = [];
20781
+ syncPassengerTunnels(plans, relayHost, cfg.ship.relayPort, privateKeyPath, cfg.ship.stateDir, mcpSecrets, {
20782
+ apiKeys: passengerApiKeys(apiRoutes),
20783
+ controlSocket: controlSocketFor(cfg.ship.stateDir),
20784
+ onStatus: (workspaceId, status2, info) => {
20785
+ const owningRoute = apiRoutes.find((r) => r.resolved?.workspaceId === workspaceId);
20786
+ if (!owningRoute) return;
20787
+ pending.push(
20788
+ new Tracker(owningRoute, cfg.ship).updateTunnelState(cfg.ship.name, { tunnel_status: status2 }).catch((e) => {
20789
+ emit.warn(`could not write tunnel_status: ${e.message}`, { step: "passengers" });
20790
+ })
20791
+ );
20792
+ if (status2 === "disconnected") pending.push(unregisterEndpoint(workspaceId, info, reconcile));
20793
+ }
20794
+ });
20795
+ for (const plan of containerPlans) pending.push(reconcileEndpoint(plan.workspaceId, reconcile));
20796
+ for (const plan of plans) pending.push(reconcileRelayUrl(plan.workspaceId, apiRoutes));
20797
+ await Promise.all(pending);
20798
+ } catch (e) {
20799
+ emit.warn(`ship tunnel sync failed: ${e.message}`, { step: "passengers" });
20800
+ }
20801
+ }
20802
+ async function reconcileRelayUrl(workspaceId, routes) {
20803
+ if (!cfg.ship.relayHost) return;
20804
+ const persisted = readPersistedTunnel(cfg.ship.stateDir, workspaceId);
20805
+ if (!persisted?.slug || persisted.status !== "connected") return;
20806
+ const url = relayBaseUrlFor(persisted.slug, cfg.ship.relayHost, cfg.ship.relayHttpPort, cfg.ship.relayPublicDomain);
20807
+ if (persisted.relayUrl === url) return;
20808
+ const route3 = routes.find((r) => r.resolved?.workspaceId === workspaceId);
20809
+ if (!route3) return;
20810
+ try {
20811
+ await new Tracker(route3, cfg.ship).updateRelayUrl(cfg.ship.name, url);
20812
+ updatePersistedTunnel(cfg.ship.stateDir, workspaceId, { relayUrl: url });
20813
+ } catch (e) {
20814
+ emit.warn(`could not write relay_url: ${e.message}`, { step: "passengers" });
20815
+ }
20816
+ }
20740
20817
  async function releaseFleet(opts = {}) {
20741
20818
  if (releaseRefused()) return;
20742
20819
  precheckTickets.clear();
@@ -23753,6 +23830,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
23753
23830
  const rotatePending = [];
23754
23831
  syncPassengerTunnels(plans, cfg.ship.relayHost, cfg.ship.relayPort, privateKeyPath, cfg.ship.stateDir, /* @__PURE__ */ new Map(), {
23755
23832
  apiKeys: passengerApiKeys([route2]),
23833
+ controlSocket: controlSocketFor(cfg.ship.stateDir),
23756
23834
  onStatus: (wsId, status2, info) => {
23757
23835
  rotatePending.push(
23758
23836
  new Tracker(route2, cfg.ship).updateTunnelState(cfg.ship.name, { tunnel_status: status2 }).catch((e) => {
@@ -24329,6 +24407,14 @@ ${where.join("\n")}`);
24329
24407
  const url = mcpUrlFor(value("base-url") ?? cfg.routes[0]?.baseUrl ?? DEFAULT_BASE_URL);
24330
24408
  const disconnectHarnesses = /* @__PURE__ */ new Set([...shipPair(cfg.ship), ...shipToolchains(cfg.ship).map((t) => t.harness).filter(isPairHarness)]);
24331
24409
  const removePairEntries = (mcpUrl, dry) => [...disconnectHarnesses].map((h) => pairInstallerFor(h).removePair({ url: mcpUrl, bin: cfg.ship.agent.bin, dryRun: dry }));
24410
+ if (!dryRun) {
24411
+ for (const r of cfg.routes.filter((x) => x.resolved?.shipsModelId)) {
24412
+ await new Tracker(r, cfg.ship).updateRelayUrl(cfg.ship.name, null).catch((e) => {
24413
+ process.stderr.write(`could not clear relay_url for ${r.route}: ${e.message}
24414
+ `);
24415
+ });
24416
+ }
24417
+ }
24332
24418
  if (!flag("reset")) {
24333
24419
  for (const line of removePairEntries(url, dryRun)) process.stderr.write(`${line}
24334
24420
  `);
@@ -24763,7 +24849,9 @@ ${where.join("\n")}`);
24763
24849
  attention: openShipAttention(state),
24764
24850
  // Additive (CREW-2495): this ship's Ships-row name and whether it has drifted from `name`.
24765
24851
  rowName: nameSync.rowName ?? null,
24766
- nameDrift: nameSync.drift
24852
+ nameDrift: nameSync.drift,
24853
+ // Additive (CREW-2803): the relay URL this route's tunnel is reachable at, or null.
24854
+ relayUrl: routeRelayUrl(route2)
24767
24855
  },
24768
24856
  // Additive (CREW-1402): what the operator owes. `version` stays 1.
24769
24857
  todo: todo.map((i) => ({ ticket: displayKey(i.ticket), title: i.ticket.title ?? null, reasons: i.reasons })),
@@ -24853,7 +24941,8 @@ ${owed.map((l) => ` ${l}
24853
24941
  route: ${route2.route} -> ${route2.dir}
24854
24942
  enabled: ${route2.enabled}
24855
24943
  periscopes: ${periscopeSummary(cfg)}
24856
- ` + periscopes.map((p) => `periscope: ${p.ticket} ${p.repo} ${p.url} ${p.state}${p.serving ? "" : " (not serving)"} expires ${p.expiresAt}
24944
+ ` + (routeRelayUrl(route2) ? `relay: ${routeRelayUrl(route2)}
24945
+ ` : "") + periscopes.map((p) => `periscope: ${p.ticket} ${p.repo} ${p.url} ${p.state}${p.serving ? "" : " (not serving)"} expires ${p.expiresAt}
24857
24946
  `).join("") + `crew: ${state.isPaused() ? "paused" : "active"}
24858
24947
  release: ${state.releaseSkipReason() ? "paused" : "active"}
24859
24948
  watermark: ${state.watermark()}
@@ -25445,6 +25534,21 @@ ${foreign.join("\n")}
25445
25534
  );
25446
25535
  }
25447
25536
  }
25537
+ if (cfg.ship.relayHost && !flag("offline")) {
25538
+ const relayUrl = routeRelayUrl(route2);
25539
+ if (!relayUrl) {
25540
+ process.stdout.write("relay control: no tunnel URL yet (the first `crew passengers` cycle opens it)\n");
25541
+ } else {
25542
+ let key;
25543
+ try {
25544
+ key = resolveApiKey(route2);
25545
+ } catch {
25546
+ key = void 0;
25547
+ }
25548
+ process.stdout.write(`${key ? describeRelayProbe(relayUrl, await probeRelayControl(relayUrl, key, cfg.ship.userAgent)) : "relay control: could not check (no API key for this route)"}
25549
+ `);
25550
+ }
25551
+ }
25448
25552
  for (const l of doctorSelftestLines(readSelftest(cfg.ship.stateDir), shipToolchains(cfg.ship).map((t) => t.name))) {
25449
25553
  process.stdout.write(`${l}
25450
25554
  `);
@@ -25466,6 +25570,7 @@ ${foreign.join("\n")}
25466
25570
  }
25467
25571
  var requireArmed2;
25468
25572
  var parseTicketNumber2;
25573
+ var routeRelayUrl2;
25469
25574
  var passengerApiKeys2;
25470
25575
  var endpointApiFor2;
25471
25576
  var endpointReconcileDeps2;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.96.0",
3
+ "version": "0.97.0",
4
4
  "description": "A standing team of headless agents that picks work off a Tablation board, does it on a machine you control, and reports back.",
5
5
  "type": "module",
6
6
  "license": "MIT",