@tablation/crew 0.5.0 → 0.6.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 (3) hide show
  1. package/README.md +1 -1
  2. package/dist/cli.js +104 -13
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -207,7 +207,7 @@ crew ports [route] which checkout owns which ports, and what is up
207
207
  crew reap [route] kill servers left behind by removed worktrees
208
208
  crew drop [route] NNN remove a merged ticket's worktree and branch
209
209
  crew sync [route] fast-forward the checkout and its worktrees from the remote
210
- crew pause|resume [route] [R] pause everything, or one role
210
+ crew pause|resume [route] [R|release] pause everything, one role, or releases only (passengers keep running)
211
211
  crew log [route] tail the log
212
212
  crew inbox [--member NAME] your tickets across every workspace
213
213
  crew connect WS[/PROJECT] resolve a workspace/project's ids into the state tree
package/dist/cli.js CHANGED
@@ -578,7 +578,8 @@ var SHIP_KEYS = /* @__PURE__ */ new Set([
578
578
  "relayHost",
579
579
  "relayPort",
580
580
  "relayHttpPort",
581
- "relayPublicDomain"
581
+ "relayPublicDomain",
582
+ "passengerImage"
582
583
  ]);
583
584
  var ROUTE_KEYS = /* @__PURE__ */ new Set([
584
585
  "route",
@@ -912,7 +913,8 @@ Create the first of those:
912
913
  // 2222 matches the relay's own RELAY_SSH_PORT default (crew-relay README).
913
914
  relayPort: shipRaw.relayPort !== void 0 ? Number(shipRaw.relayPort) : 2222,
914
915
  relayHttpPort: shipRaw.relayHttpPort !== void 0 ? Number(shipRaw.relayHttpPort) : 443,
915
- relayPublicDomain: shipRaw.relayPublicDomain ? String(shipRaw.relayPublicDomain) : void 0
916
+ relayPublicDomain: shipRaw.relayPublicDomain ? String(shipRaw.relayPublicDomain) : void 0,
917
+ passengerImage: shipRaw.passengerImage ? String(shipRaw.passengerImage) : void 0
916
918
  },
917
919
  routes,
918
920
  crewHome,
@@ -1217,6 +1219,31 @@ var State = class _State {
1217
1219
  resume(role) {
1218
1220
  rmSync(role ? this.path(`.role-paused-${role}`) : this.path(".crew-paused"), { force: true });
1219
1221
  }
1222
+ /**
1223
+ * The release phase (merge, deploy, release) has its own sentinel, one
1224
+ * level below the whole-crew pause: `crew pause release` stops shipping
1225
+ * while agents keep working.
1226
+ */
1227
+ isReleasePaused() {
1228
+ return existsSync3(this.path(".release-paused"));
1229
+ }
1230
+ setReleasePaused(paused) {
1231
+ if (paused) writeFileSync(this.path(".release-paused"), "");
1232
+ else rmSync(this.path(".release-paused"), { force: true });
1233
+ }
1234
+ /**
1235
+ * Why a release cycle must not run right now, or null when it may. Every
1236
+ * release entry point checks this first — the release timer, `crew release`,
1237
+ * `crew merge`, `crew deploy` and the inline release a `run` cycle used to
1238
+ * do — so a paused ship cannot merge, deploy, or file release alerts.
1239
+ * A release already in flight when the pause lands finishes; only new
1240
+ * cycles are refused.
1241
+ */
1242
+ releaseSkipReason() {
1243
+ if (this.isPaused()) return "the crew is paused";
1244
+ if (this.isReleasePaused()) return "releases are paused";
1245
+ return null;
1246
+ }
1220
1247
  /**
1221
1248
  * Take an exclusive lock, or report who holds it.
1222
1249
  *
@@ -7775,7 +7802,21 @@ function dockerAvailable(exec = realExec) {
7775
7802
  return false;
7776
7803
  }
7777
7804
  }
7778
- var IMAGE_TAG = "crew-passenger-mcp:latest";
7805
+ var PASSENGER_IMAGE_REPO = "tablation/crew-passenger-mcp";
7806
+ function passengerImageRef(cfg) {
7807
+ return process.env.CREW_PASSENGER_IMAGE || cfg.ship.passengerImage || `${PASSENGER_IMAGE_REPO}:${crewVersion(cfg.crewHome)}`;
7808
+ }
7809
+ function imagePresent(image, exec = realExec) {
7810
+ try {
7811
+ exec("docker", ["image", "inspect", image]);
7812
+ return true;
7813
+ } catch {
7814
+ return false;
7815
+ }
7816
+ }
7817
+ function ensureImage(image, exec = realExec) {
7818
+ if (!imagePresent(image, exec)) exec("docker", ["pull", image]);
7819
+ }
7779
7820
  function containerNameFor(workspaceId) {
7780
7821
  return `crew-passenger-${workspaceId}`;
7781
7822
  }
@@ -7783,12 +7824,13 @@ function portFor(workspaceId, base = 28765, span = 1e3) {
7783
7824
  const digest = createHash4("sha256").update(workspaceId).digest();
7784
7825
  return base + digest.readUInt32BE(0) % span;
7785
7826
  }
7786
- function mountsDigest(mounts) {
7827
+ function mountsDigest(mounts, image) {
7787
7828
  const sorted2 = [...mounts].sort((a, b) => a.containerPath.localeCompare(b.containerPath));
7788
- return createHash4("sha256").update(JSON.stringify(sorted2)).digest("hex").slice(0, 16);
7829
+ return createHash4("sha256").update(JSON.stringify({ mounts: sorted2, image })).digest("hex").slice(0, 16);
7789
7830
  }
7790
7831
  function planContainers(cfg) {
7791
7832
  const byWorkspace = /* @__PURE__ */ new Map();
7833
+ const image = passengerImageRef(cfg);
7792
7834
  for (const route of cfg.routes) {
7793
7835
  if (!route.enabled || !route.hostPassengers || !route.resolved?.workspaceId) continue;
7794
7836
  const workspaceId = route.resolved.workspaceId;
@@ -7804,8 +7846,9 @@ function planContainers(cfg) {
7804
7846
  workspaceId,
7805
7847
  containerName: containerNameFor(workspaceId),
7806
7848
  port: portFor(workspaceId),
7849
+ image,
7807
7850
  mounts: mountList,
7808
- mountsHash: mountsDigest(mountList)
7851
+ mountsHash: mountsDigest(mountList, image)
7809
7852
  };
7810
7853
  });
7811
7854
  }
@@ -7861,7 +7904,7 @@ function startContainer(plan, tablationApiBaseUrl, sync, exec = realExec) {
7861
7904
  );
7862
7905
  }
7863
7906
  for (const m of plan.mounts) args.push("-v", `${m.hostPath}:${m.containerPath}:ro`);
7864
- args.push(IMAGE_TAG);
7907
+ args.push(plan.image);
7865
7908
  exec("docker", args);
7866
7909
  }
7867
7910
  function syncPassengerContainers(cfg, tablationApiBaseUrl, exec = realExec, syncEndpoints = /* @__PURE__ */ new Map()) {
@@ -7872,19 +7915,27 @@ function syncPassengerContainers(cfg, tablationApiBaseUrl, exec = realExec, sync
7872
7915
  const started = [];
7873
7916
  const recreated = [];
7874
7917
  const stopped = [];
7918
+ const ensured = /* @__PURE__ */ new Set();
7919
+ const start = (plan, sync) => {
7920
+ if (!ensured.has(plan.image)) {
7921
+ ensureImage(plan.image, exec);
7922
+ ensured.add(plan.image);
7923
+ }
7924
+ startContainer(plan, tablationApiBaseUrl, sync, exec);
7925
+ };
7875
7926
  for (const plan of plans) {
7876
7927
  const sync = syncEndpoints.get(plan.workspaceId);
7877
7928
  const current = existingByName.get(plan.containerName);
7878
7929
  if (!current) {
7879
- startContainer(plan, tablationApiBaseUrl, sync, exec);
7930
+ start(plan, sync);
7880
7931
  started.push(plan.containerName);
7881
7932
  } else if (!current.running) {
7882
7933
  stopContainer(plan.containerName, exec);
7883
- startContainer(plan, tablationApiBaseUrl, sync, exec);
7934
+ start(plan, sync);
7884
7935
  started.push(plan.containerName);
7885
7936
  } else if (currentMountsHash(plan.containerName, exec) !== plan.mountsHash) {
7886
7937
  stopContainer(plan.containerName, exec);
7887
- startContainer(plan, tablationApiBaseUrl, sync, exec);
7938
+ start(plan, sync);
7888
7939
  recreated.push(plan.containerName);
7889
7940
  }
7890
7941
  }
@@ -8499,6 +8550,7 @@ var SpawnBreaker = class {
8499
8550
  }
8500
8551
  };
8501
8552
  async function runOnePass(o) {
8553
+ if (o.state.isPaused()) return { pending: [], started: [], skipped: [] };
8502
8554
  await hydrateApiKeys([o.route], o.getSessionStore);
8503
8555
  const emit = o.newEmitter();
8504
8556
  const decide = o.decide ?? decideCycle;
@@ -8826,7 +8878,8 @@ function usage() {
8826
8878
  crew rotate-passenger-url [route] force a Host Passengers tunnel to reconnect with a fresh public URL
8827
8879
  crew unassign [route] NNN hand back a session's ticket \u2014 clears assignee, next cycle picks it up
8828
8880
  crew sync [route] fast-forward the checkout and its worktrees from the remote
8829
- crew pause|resume [route] [R] pause everything, or one role
8881
+ crew pause|resume [route] [R|release] pause everything, one role, or releases only (merge/deploy/release; passengers keeps running).
8882
+ resume with no argument clears only the whole-crew pause; a role or release pause needs its own resume
8830
8883
  crew log [route] tail the log
8831
8884
  crew inbox [--member NAME] your tickets across every workspace (or a colleague's)
8832
8885
  crew connect resolve a workspace's ids into a crew.yaml block
@@ -8927,6 +8980,11 @@ if (command === "passenger-sync-daemon") {
8927
8980
  urlFor: (slug) => publicUrlFor(slug, relayHost, cfg.ship.relayHttpPort, cfg.ship.relayPublicDomain),
8928
8981
  warn: (m) => emit.warn(m, { step: "passengers" })
8929
8982
  };
8983
+ }, releaseRefused = function() {
8984
+ const why = state.releaseSkipReason();
8985
+ if (!why) return false;
8986
+ emit.emit(`${why} \u2014 release skipped`, { step: "release" });
8987
+ return true;
8930
8988
  }, reportUnplaceable = function(c, unplaceable, verified, emit2) {
8931
8989
  if (unplaceableReported.has(c.route)) return;
8932
8990
  unplaceableReported.add(c.route);
@@ -8935,7 +8993,7 @@ if (command === "passenger-sync-daemon") {
8935
8993
  else emit2.emit(note.message, { step: "release" });
8936
8994
  }
8937
8995
  };
8938
- requireArmed2 = requireArmed, parseTicketNumber2 = parseTicketNumber, passengerApiKeys2 = passengerApiKeys, endpointApiFor2 = endpointApiFor, endpointReconcileDeps2 = endpointReconcileDeps, reportUnplaceable2 = reportUnplaceable;
8996
+ requireArmed2 = requireArmed, parseTicketNumber2 = parseTicketNumber, passengerApiKeys2 = passengerApiKeys, endpointApiFor2 = endpointApiFor, endpointReconcileDeps2 = endpointReconcileDeps, releaseRefused2 = releaseRefused, reportUnplaceable2 = reportUnplaceable;
8939
8997
  let cfg;
8940
8998
  let firstRun;
8941
8999
  try {
@@ -8977,7 +9035,7 @@ if (command === "passenger-sync-daemon") {
8977
9035
  const releaseFleetWide = ["merge", "deploy", "release"].includes(command) && flag("fleet");
8978
9036
  const DAEMON_SUBCOMMANDS = /* @__PURE__ */ new Set(["start", "stop", "status", "restart"]);
8979
9037
  const named = command === "daemon" && DAEMON_SUBCOMMANDS.has(positional[1] ?? "") ? positional[2] : positional[1];
8980
- const fleetWide = command === "inbox" || command === "connect" || command === "agents" || command === "skills" || command === "logbook" || command === "repos" || releaseFleetWide || FLEET_CAPABLE.has(command) && !named && cfg.routes.length > 1;
9038
+ const fleetWide = command === "inbox" || command === "connect" || command === "agents" || command === "skills" || command === "logbook" || command === "repos" || releaseFleetWide || command === "pause" || command === "resume" || FLEET_CAPABLE.has(command) && !named && cfg.routes.length > 1;
8981
9039
  let route;
8982
9040
  try {
8983
9041
  route = fleetWide ? cfg.routes[0] : findRoute(cfg, named);
@@ -9067,6 +9125,7 @@ if (command === "passenger-sync-daemon") {
9067
9125
  }
9068
9126
  }
9069
9127
  async function releaseFleet(opts = {}) {
9128
+ if (releaseRefused()) return;
9070
9129
  const summaries = [];
9071
9130
  if (!dryRun) state.releaseHeartbeat().touch();
9072
9131
  for (const c of cfg.routes) {
@@ -9077,6 +9136,7 @@ if (command === "passenger-sync-daemon") {
9077
9136
  if (!dryRun) state.releaseHeartbeat().touch();
9078
9137
  }
9079
9138
  async function releaseTargets(opts = {}) {
9139
+ if (releaseRefused()) return;
9080
9140
  const only = value("repo");
9081
9141
  const targets = reposOf(route);
9082
9142
  const chosen = only ? targets.filter((t) => t.name === only) : targets;
@@ -9129,6 +9189,23 @@ if (command === "passenger-sync-daemon") {
9129
9189
  process.stderr.write("crew install: no scheduler support yet for Windows (Task Scheduler is planned, not built)\n");
9130
9190
  process.exit(2);
9131
9191
  }
9192
+ if (!dryRun && cfg.routes.some((r) => r.enabled && r.hostPassengers) && dockerAvailable()) {
9193
+ const image = passengerImageRef(cfg);
9194
+ if (imagePresent(image)) {
9195
+ process.stdout.write(`Host Passengers image ${image} is already present
9196
+ `);
9197
+ } else {
9198
+ process.stdout.write(`pulling Host Passengers image ${image}
9199
+ `);
9200
+ try {
9201
+ ensureImage(image);
9202
+ } catch (e) {
9203
+ process.stderr.write(`crew install: could not pull Host Passengers image ${image}: ${e.message}
9204
+ `);
9205
+ process.exitCode = 1;
9206
+ }
9207
+ }
9208
+ }
9132
9209
  const hasSystemd = detectSystemd();
9133
9210
  for (const job of ["run", "release", "passengers"]) {
9134
9211
  const plan = planInstall(cfg.ship, CREW_HOME, host, hasSystemd, job);
@@ -10979,6 +11056,12 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
10979
11056
  }
10980
11057
  case "pause":
10981
11058
  case "resume": {
11059
+ if (positional[1] === "release" || positional[2] === "release") {
11060
+ state.setReleasePaused(command === "pause");
11061
+ process.stdout.write(`${command}d releases
11062
+ `);
11063
+ break;
11064
+ }
10982
11065
  const role = positional[2] ?? (["dev", "design", "qa"].includes(positional[1] ?? "") ? positional[1] : void 0);
10983
11066
  if (command === "pause") state.pause(role);
10984
11067
  else state.resume(role);
@@ -11070,6 +11153,9 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
11070
11153
  },
11071
11154
  crew: {
11072
11155
  paused: state.isPaused(),
11156
+ // The release timer honors the whole-crew pause too, so this is
11157
+ // true whenever releases are stopped for either reason.
11158
+ releasePaused: state.releaseSkipReason() !== null,
11073
11159
  roles: ROLE_NAMES.map((r) => ({ name: r, label: ROLE_LABEL[r], paused: state.isRolePaused(r) })),
11074
11160
  seats: seats2
11075
11161
  },
@@ -11113,6 +11199,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
11113
11199
  route: ${route.route} -> ${route.dir}
11114
11200
  enabled: ${route.enabled}
11115
11201
  crew: ${state.isPaused() ? "paused" : "active"}
11202
+ release: ${state.releaseSkipReason() ? "paused" : "active"}
11116
11203
  watermark: ${state.watermark()}
11117
11204
  `
11118
11205
  );
@@ -11253,10 +11340,13 @@ git: ${gitPath ? `OK (${gitPath})` : "NOT FOUND on the scheduler P
11253
11340
  } else {
11254
11341
  const dockerOk = dockerAvailable();
11255
11342
  const keygenOk = sshKeygenAvailable();
11343
+ const image = passengerImageRef(cfg);
11344
+ const imageOk = dockerOk && imagePresent(image);
11256
11345
  process.stdout.write(
11257
11346
  `host passengers: ON for ${passengerRoutes.map((r) => r.route).join(", ")}
11258
11347
  docker: ${dockerOk ? "OK" : "NOT AVAILABLE"}
11259
11348
  ssh-keygen: ${keygenOk ? "OK" : "NOT AVAILABLE"}
11349
+ image: ${image} (${imageOk ? "present" : "missing"})
11260
11350
  relay host: ${cfg.ship.relayHost ? `${cfg.ship.relayHost}:${cfg.ship.relayPort}` : "not set (ship.relayHost) \u2014 containers will run with no tunnel"}
11261
11351
  `
11262
11352
  );
@@ -11311,4 +11401,5 @@ var parseTicketNumber2;
11311
11401
  var passengerApiKeys2;
11312
11402
  var endpointApiFor2;
11313
11403
  var endpointReconcileDeps2;
11404
+ var releaseRefused2;
11314
11405
  var reportUnplaceable2;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
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",