@tablation/crew 0.13.2 → 0.14.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 +159 -8
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -86,6 +86,7 @@ var BRANCH_KEYS = /* @__PURE__ */ new Set(["base", "name", "push", "remote"]);
86
86
  var PLACEHOLDER = /\{(key|number|slug|role|prefix|tag)\}/g;
87
87
  var RELEASE_KEYS = /* @__PURE__ */ new Set([
88
88
  "mode",
89
+ "ship",
89
90
  "ci",
90
91
  "verify",
91
92
  "versioning",
@@ -202,6 +203,16 @@ function parseRepoConfig(text, file) {
202
203
  if (!MODES.includes(mode)) {
203
204
  throw new RepoConfigError(`${file}: release.mode must be one of ${MODES.join("|")} (got "${mode}")`);
204
205
  }
206
+ let ship = null;
207
+ if (raw.release?.ship !== void 0 && raw.release?.ship !== null) {
208
+ ship = String(raw.release.ship).trim();
209
+ if (ship === "") throw new RepoConfigError(`${file}: release.ship is empty`);
210
+ if (mode !== "local" && mode !== "integrate") {
211
+ throw new RepoConfigError(
212
+ `${file}: release.ship only applies to release.mode "local" or "integrate" (got "${mode}") \u2014 CI-mode repos keep multi-ship merging, protected by the board lock and the fast-forward-only push`
213
+ );
214
+ }
215
+ }
205
216
  const provider = String(raw.release?.ci?.provider ?? "none");
206
217
  if (!PROVIDERS.includes(provider)) {
207
218
  throw new RepoConfigError(
@@ -301,6 +312,7 @@ function parseRepoConfig(text, file) {
301
312
  labels,
302
313
  release: {
303
314
  mode,
315
+ ship,
304
316
  ci: { provider, ref: raw.release?.ci?.ref ? String(raw.release.ci.ref) : void 0 },
305
317
  verify,
306
318
  versioning,
@@ -451,6 +463,9 @@ function resolveRepoConfig(repo, ship, dir) {
451
463
  labels,
452
464
  release: {
453
465
  mode,
466
+ // Repo-only, like `platform`: which ship releases a repo is a fact the
467
+ // repo declares (CREW-1384), never something a ship's own fallback sets.
468
+ ship: repo?.release.ship ?? null,
454
469
  ci: {
455
470
  provider,
456
471
  ref: pick("release.ci.ref", repo?.release.ci.ref, ship?.release?.ci?.ref, void 0)
@@ -522,6 +537,11 @@ function validateEffective(cfg) {
522
537
  if (cfg.release.mode === "local" && !cfg.hooks.deploy) {
523
538
  problems.push('release.mode "local" needs a deploy hook');
524
539
  }
540
+ if (cfg.release.ship && cfg.release.mode !== "local" && cfg.release.mode !== "integrate") {
541
+ problems.push(
542
+ `release.ship "${cfg.release.ship}" only applies to release.mode "local" or "integrate" (this ship's settings make it "${cfg.release.mode}") \u2014 CI-mode repos keep multi-ship merging`
543
+ );
544
+ }
525
545
  if (cfg.release.mode === "external" && !cfg.hooks.merged) {
526
546
  problems.push(
527
547
  'release.mode "external" without a merged hook \u2014 the crew hands work off and then has no reliable way to learn whether it landed'
@@ -2639,14 +2659,16 @@ var SHELL_WORDS = /* @__PURE__ */ new Set([
2639
2659
 
2640
2660
  // src/board-lock.ts
2641
2661
  import { StaleWriteError } from "@tablation/client";
2662
+ function releaseLockScope(route, repo) {
2663
+ return `${route}/${repo}`;
2664
+ }
2642
2665
  async function acquireBoardLock(client2, modelId, scope, holderLabel, ttlMs) {
2643
2666
  if (!modelId) return { ok: true, release: async () => {
2644
2667
  } };
2645
2668
  const filters = JSON.stringify([{ columnName: "scope", operator: "EQ", value: scope }]);
2646
2669
  const rows = await client2.list(modelId, { filters, limit: 1 });
2647
2670
  const row = rows[0];
2648
- if (!row) return { ok: true, release: async () => {
2649
- } };
2671
+ if (!row) return { ok: false, reason: "missing" };
2650
2672
  const age = Date.now() - new Date(row.updated_at).getTime();
2651
2673
  const free = !row.holder || row.holder === holderLabel || age > ttlMs;
2652
2674
  if (!free) return { ok: false, heldBy: row.holder ?? void 0, reason: "held" };
@@ -2667,6 +2689,39 @@ async function acquireBoardLock(client2, modelId, scope, holderLabel, ttlMs) {
2667
2689
  }
2668
2690
  };
2669
2691
  }
2692
+ async function lockRowState(client2, modelId, scope) {
2693
+ const rows = await client2.list(modelId, { filters: scopeFilter(scope), limit: 1 });
2694
+ const row = rows[0];
2695
+ if (!row) return { exists: false };
2696
+ return { exists: true, ...row.holder ? { holder: row.holder, since: row.updated_at } : {} };
2697
+ }
2698
+ var scopeFilter = (scope) => JSON.stringify([{ columnName: "scope", operator: "EQ", value: scope }]);
2699
+ async function ensureLockRows(client2, modelId, scopes) {
2700
+ const created = [];
2701
+ for (const scope of scopes) {
2702
+ const filters = scopeFilter(scope);
2703
+ if ((await client2.list(modelId, { filters, limit: 1 })).length > 0) continue;
2704
+ let mine;
2705
+ try {
2706
+ mine = await client2.create(modelId, { scope, holder: "" });
2707
+ } catch (e) {
2708
+ const status2 = e.status;
2709
+ if (status2 === 409 || status2 === 400) {
2710
+ if ((await client2.list(modelId, { filters, limit: 1 })).length > 0) continue;
2711
+ }
2712
+ throw e;
2713
+ }
2714
+ const all = await client2.list(modelId, { filters, limit: 20 });
2715
+ const keeper = [...all].sort((a, b) => (a.created_at ?? "").localeCompare(b.created_at ?? "") || a.id.localeCompare(b.id))[0];
2716
+ if (keeper && keeper.id !== mine.id) {
2717
+ await client2.remove(modelId, mine.id).catch(() => {
2718
+ });
2719
+ continue;
2720
+ }
2721
+ created.push(scope);
2722
+ }
2723
+ return created;
2724
+ }
2670
2725
 
2671
2726
  // src/tracker.ts
2672
2727
  function ticketBranchContext(t) {
@@ -3036,13 +3091,18 @@ var Tracker = class {
3036
3091
  /**
3037
3092
  * Claim a board-visible lock for `scope` (ISSUE-394) — see board-lock.ts
3038
3093
  * for the CAS mechanics. Resolves to `{ ok: true }` immediately, with a
3039
- * no-op release, when this workspace has no Locks table or no row
3040
- * provisioned for `scope`: a route that has not adopted board
3041
- * locking keeps behaving exactly as it always has.
3094
+ * no-op release, only when this workspace has no Locks table at all. A
3095
+ * Locks table with no row for `scope` is `{ ok: false, reason: 'missing' }`
3096
+ * (CREW-1384): `crew connect` provisions the row.
3042
3097
  */
3043
3098
  acquireBoardLock(scope, holderLabel, ttlMs) {
3044
3099
  return acquireBoardLock(this.client.records, this.route.resolved?.locksModelId, scope, holderLabel, ttlMs);
3045
3100
  }
3101
+ /** The Locks row for `scope` — present? held by whom? Undefined when this workspace has no Locks table. */
3102
+ async lockState(scope) {
3103
+ const model = this.route.resolved?.locksModelId;
3104
+ return model ? lockRowState(this.client.records, model, scope) : void 0;
3105
+ }
3046
3106
  /**
3047
3107
  * Reports one crew member's live status (ISSUE-325) — "Working" for the
3048
3108
  * duration of an agent run, "Idle" once it ends.
@@ -6673,6 +6733,24 @@ Still failing: ${n.detail ?? n.headline}`, memberId);
6673
6733
  // src/cli.ts
6674
6734
  import { StaleWriteError as StaleWriteError8 } from "@tablation/client";
6675
6735
 
6736
+ // src/release-ship.ts
6737
+ var DAY_MS = 24 * 60 * 60 * 1e3;
6738
+ function releaserFor(release, shipName2) {
6739
+ const named = release.ship;
6740
+ if (!named || release.mode !== "local" && release.mode !== "integrate") return { releases: true, releaser: named ?? null };
6741
+ return { releases: named.trim() === shipName2.trim(), releaser: named };
6742
+ }
6743
+ function nonReleaserNote(repo, releaser) {
6744
+ return `release for ${repo} runs on ${releaser}`;
6745
+ }
6746
+ function missingReleaserWarning(repo, release, ships, now = Date.now()) {
6747
+ if (release.ship || release.mode !== "local" && release.mode !== "integrate") return null;
6748
+ const live = ships.filter((s) => s.last_seen && now - Date.parse(s.last_seen) <= DAY_MS);
6749
+ if (live.length < 2) return null;
6750
+ const names = live.map((s) => s.name ?? "?").join(", ");
6751
+ return `${repo}: release.mode "${release.mode}" with no release.ship, and ${live.length} ships seen in the last day (${names}) \u2014 every one of them will try to release it. Set release.ship in .crew.yaml.`;
6752
+ }
6753
+
6676
6754
  // src/watch.ts
6677
6755
  import { watch as watchFile, existsSync as existsSync10, statSync as statSync5, openSync as openSync2, readSync, closeSync as closeSync2 } from "fs";
6678
6756
  var E = "\x1B[";
@@ -7455,6 +7533,35 @@ async function patch(o, path, body) {
7455
7533
  if (!res.ok) throw new ConnectHttpError(res.status, path, res.statusText);
7456
7534
  return await res.json();
7457
7535
  }
7536
+ async function del(o, path) {
7537
+ const res = await fetch(`${o.baseUrl.replace(/\/+$/, "")}/api${path}`, {
7538
+ method: "DELETE",
7539
+ headers: {
7540
+ Authorization: `Bearer ${o.apiKey}`,
7541
+ "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0"
7542
+ }
7543
+ });
7544
+ if (!res.ok) throw new ConnectHttpError(res.status, path, res.statusText);
7545
+ }
7546
+ async function provisionReleaseLocks(o, locksModelId, route, repoNames) {
7547
+ const scopes = [...new Set(repoNames)].sort().map((r) => releaseLockScope(route, r));
7548
+ if (scopes.length === 0) return [];
7549
+ const client2 = {
7550
+ // The Locks table is a handful of rows, so read it whole and match in
7551
+ // memory rather than depend on the HTTP filter syntax.
7552
+ list: async (model, params) => {
7553
+ const want = JSON.parse(params?.filters ?? "[]")[0]?.value;
7554
+ const rows = await get(
7555
+ o,
7556
+ `/data-models/${model}/records?limit=500`
7557
+ );
7558
+ return rows.filter((r) => r.scope === want).slice(0, params?.limit ?? rows.length);
7559
+ },
7560
+ create: (model, body) => post2(o, `/data-models/${model}/records`, body),
7561
+ remove: (model, id) => del(o, `/data-models/${model}/records/${id}`)
7562
+ };
7563
+ return ensureLockRows(client2, locksModelId, scopes);
7564
+ }
7458
7565
  var ISSUES_TEMPLATE_IDENTIFIER = "crew.issues";
7459
7566
  async function listLibraryTemplates(o) {
7460
7567
  return get(o, "/library-templates");
@@ -8325,11 +8432,11 @@ function applyRemoteBranchCleanup(cwd, remote, branches2, dryRun, log) {
8325
8432
  // src/stream-sweep.ts
8326
8433
  import { existsSync as existsSync15, readdirSync as readdirSync3, rmSync as rmSync3, statSync as statSync6 } from "fs";
8327
8434
  import { join as join14 } from "path";
8328
- var DAY_MS = 24 * 60 * 60 * 1e3;
8435
+ var DAY_MS2 = 24 * 60 * 60 * 1e3;
8329
8436
  function planStreamSweep(stateDir, retentionDays, now = Date.now()) {
8330
8437
  const dir = join14(stateDir, "streams");
8331
8438
  if (!existsSync15(dir)) return [];
8332
- const cutoff = now - retentionDays * DAY_MS;
8439
+ const cutoff = now - retentionDays * DAY_MS2;
8333
8440
  return readdirSync3(dir).map((name) => join14(dir, name)).filter((path) => {
8334
8441
  try {
8335
8442
  return statSync6(path).mtimeMs < cutoff;
@@ -10017,9 +10124,20 @@ ${changed2.map((c) => ` - ${c}
10017
10124
  shipName: cfg.ship.name
10018
10125
  });
10019
10126
  }
10127
+ async function releaserLine(tracker, routeName, repo, release) {
10128
+ const who2 = release.ship ? `released by ${release.ship}` : "any ship may release";
10129
+ let lock2;
10130
+ try {
10131
+ const st = await tracker.lockState(releaseLockScope(routeName, repo));
10132
+ lock2 = st === void 0 ? "no Locks table" : !st.exists ? "lock row MISSING (run crew connect)" : st.holder ? `lock held by ${st.holder}` : "lock row free";
10133
+ } catch (e) {
10134
+ lock2 = `lock unreadable (${e.message})`;
10135
+ }
10136
+ return `${who2}; ${lock2}`;
10137
+ }
10020
10138
  const unplaceableReported = /* @__PURE__ */ new Set();
10021
10139
  async function releasePhase(c, target, opts = {}) {
10022
- const scope = `${c.route}/${target.name}`;
10140
+ const scope = releaseLockScope(c.route, target.name);
10023
10141
  const remit = emit.forRoute(c.route);
10024
10142
  const relLock = dryRun ? void 0 : state.acquire(`release-${scope}`);
10025
10143
  if (relLock && !relLock.ok) {
@@ -10044,11 +10162,22 @@ ${changed2.map((c) => ` - ${c}
10044
10162
  remit.emit(`${scope}: skipping \u2014 ${detail}`, { step: "release" });
10045
10163
  return { scope, tests: "skipped", outcome: "skipped", detail };
10046
10164
  }
10165
+ const who2 = releaserFor(repo.release, cfg.ship.name);
10166
+ if (!who2.releases) {
10167
+ const note = nonReleaserNote(target.name, who2.releaser);
10168
+ remit.emit(note, { step: "release" });
10169
+ return { scope, tests: "skipped", outcome: "skipped", detail: note };
10170
+ }
10047
10171
  if (!dryRun && !c.enabled) return { scope, tests: "skipped", outcome: "skipped", detail: "route not enabled" };
10048
10172
  const tracker = new Tracker(c, cfg.ship);
10049
10173
  if (!dryRun) {
10050
10174
  const holderLabel = `${cfg.ship.name}:${process.pid}`;
10051
10175
  const got = await tracker.acquireBoardLock(scope, holderLabel, RELEASE_LOCK_TTL_MS);
10176
+ if (!got.ok && got.reason === "missing") {
10177
+ const detail = `no Locks row for ${scope} \u2014 run \`crew connect ${c.route}\` to provision it`;
10178
+ remit.warn(`release for ${scope} refused: ${detail}`, { step: "release" });
10179
+ return { scope, tests: "skipped", outcome: "skipped", detail };
10180
+ }
10052
10181
  if (!got.ok) {
10053
10182
  const why = got.reason === "held" ? `held by ${got.heldBy ?? "another ship"}` : "claimed by another ship mid-check";
10054
10183
  remit.emit(`release for ${scope} is ${why} on the board \u2014 skipping`, { step: "release" });
@@ -11278,6 +11407,18 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
11278
11407
  }
11279
11408
  const route2 = `${found.workspaceSlug}/${found.projectSlug ?? found.projectId}`;
11280
11409
  const resolvedPath = resolvedPathFor(cfg.ship.stateDir, route2);
11410
+ if (found.locksModelId && !dryRun) {
11411
+ const configured = cfg.routes.find((r) => r.route === route2);
11412
+ const repoNames = [...Object.values(found.repoNames ?? {}), ...configured ? reposOf(configured).map((t) => t.name) : []].filter((n) => !!n);
11413
+ try {
11414
+ const made = await provisionReleaseLocks(authOpts, found.locksModelId, route2, repoNames);
11415
+ for (const sc of made) process.stderr.write(`created release lock row ${sc}
11416
+ `);
11417
+ } catch (e) {
11418
+ process.stderr.write(`(could not provision release lock rows \u2014 ${e.message})
11419
+ `);
11420
+ }
11421
+ }
11281
11422
  let operator = found.operator;
11282
11423
  if (!operator && found.holds.length > 0 && process.stdin.isTTY && process.stdout.isTTY) {
11283
11424
  operator = await pickOperator(found.holds);
@@ -11949,6 +12090,8 @@ watermark: ${state.watermark()}
11949
12090
  for (const r of await resolvedRepos(route)) {
11950
12091
  const bh = baseHealth(r);
11951
12092
  if (bh.unsafe) process.stdout.write(`base: ${r.name} \u2014 UNSAFE, no worktree cuts or releases: ${bh.detail}
12093
+ `);
12094
+ process.stdout.write(`releaser: ${r.name} \u2014 ${await releaserLine(statusTracker, route.route, r.name, r.config.release)}
11952
12095
  `);
11953
12096
  }
11954
12097
  if (waiting) {
@@ -12009,6 +12152,14 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
12009
12152
  `);
12010
12153
  if (bh.unsafe) process.exitCode = 1;
12011
12154
  }
12155
+ if (config.release.ship) {
12156
+ process.stdout.write(` release.ship ${config.release.ship}${config.release.ship === cfg.ship.name ? " (this ship)" : ""}
12157
+ `);
12158
+ } else {
12159
+ const warn = missingReleaserWarning(r.name, config.release, await tracker.shipRows().catch(() => []));
12160
+ if (warn) process.stdout.write(` WARNING ${warn}
12161
+ `);
12162
+ }
12012
12163
  }
12013
12164
  const missingAuthorship = await tracker.missingAuthorshipColumns();
12014
12165
  process.stdout.write(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.13.2",
3
+ "version": "0.14.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",