@tablation/crew 0.99.0 → 0.99.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 (2) hide show
  1. package/dist/cli.js +91 -3
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -83,6 +83,25 @@ function routePeriscopeProblems(o) {
83
83
  function previewHostFinding(message) {
84
84
  return /does not resolve/.test(message) ? { severity: "attention", kind: "preview_host_unresolvable", message } : { severity: "blocker", kind: "preview_host", message };
85
85
  }
86
+ var refId = (v) => typeof v === "string" ? v : v && typeof v === "object" && typeof v.id === "string" ? v.id : void 0;
87
+ function duplicateLivePeriscopes(rows, tagOf, shipName2, route2) {
88
+ const byTicket = /* @__PURE__ */ new Map();
89
+ for (const r of rows) {
90
+ const t = refId(r.issue_id);
91
+ if (t) byTicket.set(t, [...byTicket.get(t) ?? [], r]);
92
+ }
93
+ const out = [];
94
+ for (const [t, rs] of byTicket) {
95
+ if (rs.length < 2) continue;
96
+ out.push({
97
+ severity: "attention",
98
+ kind: "periscope_duplicate_live",
99
+ ...route2 ? { route: route2 } : {},
100
+ message: `${tagOf(t)} has ${rs.length} live Periscopes rows (${rs.map((r) => `${r.id} on ${shipName2(r.ship_id)}${r.url ? ` ${r.url}` : ""}`).join("; ")}) \u2014 the periscope_url rollup assumes one; end all but one with \`crew periscope down\``
101
+ });
102
+ }
103
+ return out;
104
+ }
86
105
 
87
106
  // src/periscope-state.ts
88
107
  import { existsSync as existsSync2, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "fs";
@@ -7672,6 +7691,30 @@ var Tracker = class {
7672
7691
  limit: 200
7673
7692
  });
7674
7693
  }
7694
+ /**
7695
+ * Every `live` Periscopes row on any ship (CREW-2871), for the one-per-issue
7696
+ * guard and doctor. Empty without the table.
7697
+ */
7698
+ async livePeriscopesAllShips() {
7699
+ const modelId = this.route.resolved?.periscopesModelId;
7700
+ if (!modelId) return [];
7701
+ return this.client.records.list(modelId, {
7702
+ filters: encodeFilters([{ columnName: "state", operator: "EQ", value: "live" }]),
7703
+ limit: 500
7704
+ });
7705
+ }
7706
+ /** The `live` Periscopes rows of one ticket, on any ship (CREW-2871). */
7707
+ async livePeriscopesForTicket(ticketRecordId) {
7708
+ const modelId = this.route.resolved?.periscopesModelId;
7709
+ if (!modelId) return [];
7710
+ return this.client.records.list(modelId, {
7711
+ filters: encodeFilters([
7712
+ { columnName: "issue_id", operator: "EQ", value: ticketRecordId },
7713
+ { columnName: "state", operator: "EQ", value: "live" }
7714
+ ]),
7715
+ limit: 50
7716
+ });
7717
+ }
7675
7718
  /** Patches one Periscopes row (EPIC-077). */
7676
7719
  async updatePeriscope(rowId, fields) {
7677
7720
  const modelId = this.route.resolved?.periscopesModelId;
@@ -7696,12 +7739,12 @@ var Tracker = class {
7696
7739
  const conds = [{ columnName: "ship_id", operator: "EQ", value: shipId }];
7697
7740
  if (states?.length) conds.push({ columnName: "state", operator: "IN", value: states });
7698
7741
  const rows = await this.client.records.list(modelId, { filters: encodeFilters(conds), limit: 200 });
7699
- const refId = (v) => typeof v === "string" ? v : v && typeof v === "object" && typeof v.id === "string" ? v.id : void 0;
7700
- const ids = [...new Set(rows.map((r) => refId(r.issue_id)).filter((x) => !!x))];
7742
+ const refId2 = (v) => typeof v === "string" ? v : v && typeof v === "object" && typeof v.id === "string" ? v.id : void 0;
7743
+ const ids = [...new Set(rows.map((r) => refId2(r.issue_id)).filter((x) => !!x))];
7701
7744
  const tickets = new Map((await this.ticketsByIds(ids)).map((t) => [t.id, t]));
7702
7745
  const out = [];
7703
7746
  for (const row of rows) {
7704
- const t = tickets.get(refId(row.issue_id) ?? "");
7747
+ const t = tickets.get(refId2(row.issue_id) ?? "");
7705
7748
  if (t) out.push({ row, ticket: t });
7706
7749
  }
7707
7750
  return out.sort((a, b) => String(b.row.started_at ?? "").localeCompare(String(a.row.started_at ?? "")));
@@ -17710,6 +17753,13 @@ import { existsSync as existsSync35, mkdirSync as mkdirSync21, readFileSync as r
17710
17753
  import { join as join37 } from "path";
17711
17754
  var PeriscopeError = class extends Error {
17712
17755
  };
17756
+ var LivePeriscopeError = class extends PeriscopeError {
17757
+ data;
17758
+ constructor(tag, number, live) {
17759
+ super(`${tag} already has a live periscope on ${live.ship}: ${live.url} (worktree ${live.worktree}) \u2014 run \`crew periscope down ${number} --keep|--discard\` on ${live.ship}, or resume it there with \`crew periscope up --resume ${number}\``);
17760
+ this.data = { refused: "live_periscope_exists", ...live };
17761
+ }
17762
+ };
17713
17763
  var priorDir = (stateDir) => join37(stateDir, "periscope-priors");
17714
17764
  var priorPath = (stateDir, ticketRecordId) => join37(priorDir(stateDir), `${ticketRecordId}.json`);
17715
17765
  function removePeriscopePrior(stateDir, ticketRecordId) {
@@ -17776,6 +17826,10 @@ function periscopeCaps(l) {
17776
17826
  idleMinutes: l.periscopeIdleMinutes ?? DEFAULT_PERISCOPE_IDLE_MINUTES2
17777
17827
  };
17778
17828
  }
17829
+ async function assertNoLivePeriscope(e, t, number) {
17830
+ const live = (await e.liveRows(t.id))[0];
17831
+ if (live) throw new LivePeriscopeError(t.tag, number, live);
17832
+ }
17779
17833
  async function existingRefusal(e, t) {
17780
17834
  if (t.closed) return `${t.tag} is ${t.status}; a closed ticket cannot be periscoped`;
17781
17835
  if (e.repoId && t.repoId !== e.repoId) return `${t.tag} belongs to a different repository than ${e.repoName}`;
@@ -17834,6 +17888,7 @@ async function runPeriscopeUp(d, args) {
17834
17888
  if (!ex) throw new PeriscopeError("--issue is not supported here");
17835
17889
  prior = await ex.load(args.issue);
17836
17890
  if (!prior) throw new PeriscopeError(`no ticket numbered ${args.issue} on this route`);
17891
+ await assertNoLivePeriscope(ex, prior, args.issue);
17837
17892
  const refused = await existingRefusal(ex, prior);
17838
17893
  if (refused) throw new PeriscopeError(refused);
17839
17894
  const unsafe = d.baseProblem();
@@ -20311,6 +20366,25 @@ var CREW_HOME = isCompiledBinary(import.meta.url) ? dirname22(process.execPath)
20311
20366
  var RELEASE_LOCK_TTL_MS = 20 * 60 * 1e3;
20312
20367
  var CYCLE_SECONDS2 = 120;
20313
20368
  var STALE_CYCLES = 3;
20369
+ async function duplicateLivePeriscopeProblems(route2, ship) {
20370
+ try {
20371
+ const tr = new Tracker(route2, ship);
20372
+ const rows = await tr.livePeriscopesAllShips();
20373
+ const refOf = (v) => typeof v === "string" ? v : v?.id;
20374
+ const counts = /* @__PURE__ */ new Map();
20375
+ for (const r of rows) {
20376
+ const t = refOf(r.issue_id);
20377
+ if (t) counts.set(t, (counts.get(t) ?? 0) + 1);
20378
+ }
20379
+ const dupIds = [...counts].filter(([, n]) => n > 1).map(([t]) => t);
20380
+ if (dupIds.length === 0) return [];
20381
+ const tags = new Map((await tr.ticketsByIds(dupIds)).map((t) => [t.id, t.issue_tag ?? t.issue_id]));
20382
+ const ships = new Map((await tr.shipRows()).map((x) => [x.id, x.name ?? x.id]));
20383
+ return duplicateLivePeriscopes(rows, (id) => tags.get(id) ?? id, (id) => id ? ships.get(id) ?? id : "an unknown ship", route2.route);
20384
+ } catch {
20385
+ return [];
20386
+ }
20387
+ }
20314
20388
  function renderShipLine(s, issueLabelById) {
20315
20389
  const name = s.name ?? "(unnamed)";
20316
20390
  if (!s.last_seen) return `${name.padEnd(20)} never seen`;
@@ -24371,6 +24445,10 @@ ${where.join("\n")}`);
24371
24445
  const row = crewP.find((r) => r.id === assigneeId);
24372
24446
  return row?.agent_id ? row.name ?? assigneeId : void 0;
24373
24447
  },
24448
+ liveRows: async (ticketRecordId) => {
24449
+ const [rows, ships] = [await trackerP.livePeriscopesForTicket(ticketRecordId), await trackerP.shipRows()];
24450
+ return rows.map((r) => ({ ship: ships.find((x) => x.id === r.ship_id)?.name ?? r.ship_id ?? "an unknown ship", url: r.url ?? "", worktree: r.worktree }));
24451
+ },
24374
24452
  hasLiveRun: (ticketRecordId) => liveRuns(cfg.ship.stateDir).some((r) => r.ticketRecordId === ticketRecordId),
24375
24453
  hold: async (id) => {
24376
24454
  const auth = await trackerP.authorshipProbe();
@@ -24394,6 +24472,7 @@ ${where.join("\n")}`);
24394
24472
  if (a.issue) {
24395
24473
  const t = await deps.existing.load(a.issue);
24396
24474
  if (!t) throw new PeriscopeError(`no ticket numbered ${a.issue} on this route`);
24475
+ await assertNoLivePeriscope(deps.existing, t, a.issue);
24397
24476
  const refused = await existingRefusal(deps.existing, t);
24398
24477
  if (refused) throw new PeriscopeError(refused);
24399
24478
  say("dry-run", `would hold ${t.tag} (status ${t.status} stays) for ${pairId}, cut or reuse its worktree for ${a.repo}, run setup${a.isolate ? ", isolate" : ""}, ports, handoff and preview on ${deps.host}, and record a Periscopes row`);
@@ -24417,6 +24496,8 @@ ${where.join("\n")}`);
24417
24496
  } catch (e) {
24418
24497
  if (e instanceof PeriscopeError || e instanceof ConfigError) {
24419
24498
  process.stderr.write(`crew periscope: ${e.message}
24499
+ `);
24500
+ if (e instanceof LivePeriscopeError && jsonP) process.stdout.write(`${JSON.stringify(e.data)}
24420
24501
  `);
24421
24502
  process.exit(1);
24422
24503
  }
@@ -25206,6 +25287,7 @@ watermark: ${state.watermark()}
25206
25287
  const { settled } = await settleRepos(reposOf(route2), (t) => t.name, (t) => resolveOneRepo(route2, t));
25207
25288
  const tablePresent = flag("offline") ? void 0 : (await new Tracker(route2, cfg.ship).periscopesStatus().catch(() => ({ present: true }))).present;
25208
25289
  problems.push(...routePeriscopeProblems({ route: route2.route, hosts: true, repos: settled.map((r) => ({ name: r.name, hasPreviewHook: !!r.config.hooks.preview })), tablePresent }));
25290
+ if (!flag("offline")) problems.push(...await duplicateLivePeriscopeProblems(route2, cfg.ship));
25209
25291
  }
25210
25292
  problems.push(...attentionProblems(openShipAttention(state), problems));
25211
25293
  if (problems.some((p) => p.severity === "blocker")) process.exitCode = 1;
@@ -25351,6 +25433,12 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
25351
25433
  process.stdout.write(`periscopes: ${f.severity.toUpperCase()} ${f.message}
25352
25434
  `);
25353
25435
  }
25436
+ if (!flag("offline")) {
25437
+ for (const f of await duplicateLivePeriscopeProblems(route2, cfg.ship)) {
25438
+ process.stdout.write(`periscopes: ${f.severity.toUpperCase()} ${f.message}
25439
+ `);
25440
+ }
25441
+ }
25354
25442
  }
25355
25443
  process.stdout.write(
25356
25444
  !periscopes.present ? "periscopes: Periscopes table absent \u2014 this workspace cannot host periscopes\n" : periscopes.missing.length === 0 ? `periscopes: Periscopes table present (${periscopes.modelId})
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.99.0",
3
+ "version": "0.99.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",