@tablation/crew 0.79.0 → 0.79.2

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 +89 -34
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -11388,14 +11388,14 @@ function dedupeChangelogEntries(entries, ticket) {
11388
11388
  return out;
11389
11389
  }
11390
11390
  var BUMP_LINE = /^Bump:[ \t]*(major|minor|patch)\b/gim;
11391
- function planMerge(cwd, tickets, contract, lastReleased, branchFor = (t) => branchForIssue(cwd, t.issue_id), base = "main", blockerInfo) {
11391
+ function planMerge(cwd, tickets, contract, lastReleased, branchFor = (t) => branchForIssue(cwd, t.issue_id), base = "main", blockerInfo, ref = "HEAD") {
11392
11392
  const held = new Set(heldByBlockers(tickets, contract.statuses.verified, blockerInfo).map((h) => h.ticket.id));
11393
11393
  const verified = tickets.filter((t) => t.status === contract.statuses.verified && !held.has(t.id)).sort((a, b) => Number.parseInt(a.issue_id.replace(/^\D+/, ""), 10) - Number.parseInt(b.issue_id.replace(/^\D+/, ""), 10));
11394
11394
  return verified.map((ticket) => {
11395
11395
  const branch = branchFor(ticket);
11396
11396
  if (!branch) {
11397
11397
  const keys = referenceKeys(ticket);
11398
- const unreleased = lastReleased ? findKeyInRange(cwd, keys, `${lastReleased}..HEAD`) : null;
11398
+ const unreleased = lastReleased ? findKeyInRange(cwd, keys, `${lastReleased}..${ref}`) : null;
11399
11399
  if (unreleased) {
11400
11400
  return {
11401
11401
  ticket,
@@ -11407,7 +11407,7 @@ function planMerge(cwd, tickets, contract, lastReleased, branchFor = (t) => bran
11407
11407
  skipReason: "already-merged"
11408
11408
  };
11409
11409
  }
11410
- const anywhere = findKeyInRange(cwd, keys, "HEAD");
11410
+ const anywhere = findKeyInRange(cwd, keys, ref);
11411
11411
  if (anywhere) {
11412
11412
  return {
11413
11413
  ticket,
@@ -11504,21 +11504,22 @@ ${section}`;
11504
11504
  return [...lines.slice(0, anchor), section, ...lines.slice(anchor)].join("\n");
11505
11505
  }
11506
11506
  var RELEASE_COMMIT_SUBJECT = /^Release \S+$/;
11507
- function subjectsSince(cwd, sha) {
11508
- if (!isAncestor(cwd, sha, "HEAD")) return null;
11509
- return (gitOk(cwd, ["log", "--format=%h %s", `${sha}..HEAD`]) ?? "").split("\n").filter(Boolean);
11507
+ function subjectsSince(cwd, sha, ref = "HEAD") {
11508
+ if (!isAncestor(cwd, sha, ref)) return null;
11509
+ return (gitOk(cwd, ["log", "--format=%h %s", `${sha}..${ref}`]) ?? "").split("\n").filter(Boolean);
11510
11510
  }
11511
11511
  var isReleaseCommitLine = (line) => RELEASE_COMMIT_SUBJECT.test(line.replace(/^\S+ /, ""));
11512
11512
  function decideRelease(cwd, tickets, contract, opts = {}) {
11513
11513
  const pattern = opts.tagPattern ?? "v*";
11514
11514
  const base = opts.base ?? "main";
11515
- const block = checkGuards(cwd, base);
11516
- const lastTag = latestReleaseTag(cwd, pattern, "HEAD");
11515
+ const ref = opts.ref ?? "HEAD";
11516
+ const block = opts.guard === false ? null : checkGuards(cwd, base);
11517
+ const lastTag = latestReleaseTag(cwd, pattern, ref);
11517
11518
  const lastReleased = lastTag ? tagCommit(cwd, lastTag) : null;
11518
- const head = resolve2(cwd, "HEAD") ?? "";
11519
- const merges = block ? [] : planMerge(cwd, tickets, contract, lastReleased, opts.branchFor ?? ((t) => branchForIssue(cwd, t.issue_id)), base, opts.blockerInfo);
11520
- const unreleasedCommits = lastReleased ? countCommits(cwd, `${lastReleased}..HEAD`) : 0;
11521
- const unreleasedWork = lastReleased ? (gitOk(cwd, ["log", "--format=%s", `${lastReleased}..HEAD`]) ?? "").split("\n").filter((l) => l && !RELEASE_COMMIT_SUBJECT.test(l)).length : 0;
11519
+ const head = resolve2(cwd, ref) ?? "";
11520
+ const merges = block ? [] : planMerge(cwd, tickets, contract, lastReleased, opts.branchFor ?? ((t) => branchForIssue(cwd, t.issue_id)), opts.ref ?? base, opts.blockerInfo, ref);
11521
+ const unreleasedCommits = lastReleased ? countCommits(cwd, `${lastReleased}..${ref}`) : 0;
11522
+ const unreleasedWork = lastReleased ? (gitOk(cwd, ["log", "--format=%s", `${lastReleased}..${ref}`]) ?? "").split("\n").filter((l) => l && !RELEASE_COMMIT_SUBJECT.test(l)).length : 0;
11522
11523
  return {
11523
11524
  block,
11524
11525
  merges,
@@ -12233,10 +12234,14 @@ async function runReleaseCycle(o, movedPast2, localRefSkips) {
12233
12234
  }
12234
12235
  if (drop.size) releasable = releasable.filter((t) => !drop.has(t.id));
12235
12236
  }
12237
+ const { remote, base } = o.repo.branch;
12238
+ const inPlace = o.dryRun || !!o.mergeOnly || !remoteBranchExists(o.cwd, remote, base);
12239
+ const remoteRef = `${remote}/${base}`;
12236
12240
  const decision = decideRelease(o.cwd, releasable, o.contract, {
12237
12241
  tagPattern,
12238
- base: o.repo.branch.base,
12242
+ base,
12239
12243
  blockerInfo: o.blockerInfo,
12244
+ ...inPlace ? {} : { ref: remoteRef, guard: false },
12240
12245
  // CREW-1364: a branch another ship built and pushed at `fixed` exists
12241
12246
  // here only on the remote. Materialize it so the squash below has a ref.
12242
12247
  // A dry run only looks.
@@ -12265,7 +12270,7 @@ async function runReleaseCycle(o, movedPast2, localRefSkips) {
12265
12270
  o.state?.clearBlocked();
12266
12271
  const failedAt = o.state?.deployFailedSha();
12267
12272
  if (!o.force && failedAt) {
12268
- const since2 = failedAt === decision.head ? [] : subjectsSince(o.cwd, failedAt);
12273
+ const since2 = failedAt === decision.head ? [] : subjectsSince(o.cwd, failedAt, inPlace ? "HEAD" : remoteRef);
12269
12274
  const real = since2?.filter((l) => !isReleaseCommitLine(l)) ?? null;
12270
12275
  if (real && real.length === 0) {
12271
12276
  o.emit.warn(
@@ -12292,10 +12297,13 @@ async function runReleaseCycle(o, movedPast2, localRefSkips) {
12292
12297
  const review = await reviewHandoff(o, externalClosures, decision.merges, reviewing);
12293
12298
  return { merged: [], deployed: false, stopped: "external", decision, externalClosures, review };
12294
12299
  }
12295
- const { remote, base } = o.repo.branch;
12296
- if (o.dryRun || o.mergeOnly || !remoteBranchExists(o.cwd, remote, base)) {
12300
+ if (inPlace) {
12297
12301
  return mergeAndRelease(o, decision, fresh);
12298
12302
  }
12303
+ const primary = checkGuards(o.cwd, base);
12304
+ if (primary) {
12305
+ o.emit.emit(scoped(`primary checkout not fast-forwarded: ${primary.detail}; its dist/ is stale until rebuilt`), { step: "release" });
12306
+ }
12299
12307
  let checkout;
12300
12308
  const stranded = recoverStrandedReleaseCheckouts(o.cwd);
12301
12309
  if (stranded.length > 0) o.emit.warn(scoped(`dropped release checkout(s) stranded by a crash: ${stranded.join(", ")}`));
@@ -13139,10 +13147,19 @@ async function canMintAgentKeys(route, o = {}) {
13139
13147
 
13140
13148
  // src/seat-read-probe.ts
13141
13149
  var SEAT_NO_READ_REMEDY = "the project has no default role for agent keys \u2014 update the Issues app's template, then re-mint with `crew agents sync --revoke-seat-keys`, or assign the Crew seat role to the agent members";
13142
- async function probeIssuesRead(route, apiKey, o = {}) {
13143
- const resolved = route.resolved;
13144
- if (!resolved?.models?.issues) return { state: "unknown", detail: "route has no resolved Issues table" };
13145
- const url = `${route.baseUrl.replace(/\/+$/, "")}/api/workspaces/${resolved.workspaceId}/data-models/${resolved.models.issues}/records?limit=1`;
13150
+ function probeTables(route) {
13151
+ const m = route.resolved?.models;
13152
+ if (!m) return [];
13153
+ const r = route.resolved;
13154
+ return [
13155
+ { name: "Issues", modelId: m.issues },
13156
+ { name: "Comments", modelId: m.comments },
13157
+ { name: "Crew", modelId: m.crew },
13158
+ { name: "Ships", modelId: r?.shipsModelId ?? "" }
13159
+ ].filter((t) => !!t.modelId);
13160
+ }
13161
+ async function probeTableRead(route, modelId, apiKey, o = {}) {
13162
+ const url = `${route.baseUrl.replace(/\/+$/, "")}/api/data-models/${modelId}/records?limit=1`;
13146
13163
  try {
13147
13164
  const res = await (o.fetchImpl ?? fetch)(url, {
13148
13165
  headers: { Authorization: `Bearer ${apiKey}`, "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0" }
@@ -13160,12 +13177,46 @@ async function probeSeatReads(route, o = {}) {
13160
13177
  for (const role of roles) {
13161
13178
  const id = role === "release" ? await resolveReleaseKey(route, o.getStore) : await resolveSeatKey(route, role, o.getStore);
13162
13179
  if (id.kind !== "agent") continue;
13163
- out.push({ role, ...await probeIssuesRead(route, id.apiKey, o) });
13180
+ const tables = [];
13181
+ for (const t of probeTables(route)) tables.push({ table: t.name, ...await probeTableRead(route, t.modelId, id.apiKey, o) });
13182
+ const bad = tables.find((t) => t.state === "forbidden") ?? tables.find((t) => t.state === "unknown");
13183
+ out.push({ role, state: bad?.state ?? (tables.length ? "ok" : "unknown"), detail: bad?.detail ?? (tables.length ? void 0 : "route has no resolved tables"), tables });
13164
13184
  }
13165
13185
  return out;
13166
13186
  }
13167
- function describeSeatForbidden(role) {
13168
- return `seat read: ERROR ${role} agent key cannot read Issues (403) \u2014 ${SEAT_NO_READ_REMEDY}`;
13187
+ function forbiddenDetail(e, route) {
13188
+ const msg = e instanceof Error ? e.message : String(e);
13189
+ const m = /^([A-Z]+) (\S+) -> 403/.exec(msg);
13190
+ const verb = /No (READ|WRITE) access/i.exec(msg)?.[1]?.toUpperCase();
13191
+ const modelId = /\/data-models\/([\w-]+)/i.exec(m?.[2] ?? "")?.[1];
13192
+ const r = route?.resolved;
13193
+ const known = [
13194
+ [r?.models?.issues, "Issues"],
13195
+ [r?.models?.comments, "Comments"],
13196
+ [r?.models?.crew, "Crew"],
13197
+ [r?.shipsModelId, "Ships"],
13198
+ [r?.locksModelId, "Locks"],
13199
+ [r?.epicsModelId, "Epics"],
13200
+ [r?.reposModelId, "Repos"]
13201
+ ];
13202
+ const table2 = modelId ? known.find(([id]) => id && id === modelId)?.[1] ?? `table ${modelId}` : void 0;
13203
+ return { method: m?.[1], path: m?.[2], table: table2, verb };
13204
+ }
13205
+ function remedyFor(d, roleName = "the agent role") {
13206
+ if (d.table && d.verb && !(d.table === "Issues" && d.verb === "READ")) {
13207
+ return `grant ${d.verb} on ${d.table} to ${roleName}`;
13208
+ }
13209
+ return SEAT_NO_READ_REMEDY;
13210
+ }
13211
+ function describeSeatForbidden(role, tables = []) {
13212
+ const bad = tables.filter((t) => t.state === "forbidden").map((t) => t.table);
13213
+ const what = bad.length ? bad.join(", ") : "Issues";
13214
+ const remedy = bad.length && !(bad.length === 1 && bad[0] === "Issues") ? bad.map((t) => remedyFor({ table: t, verb: "READ" })).join("; ") : SEAT_NO_READ_REMEDY;
13215
+ return `seat read: ERROR ${role} agent key cannot read ${what} (403) \u2014 ${remedy}`;
13216
+ }
13217
+ function describeSeatTables(role, tables) {
13218
+ const cells = tables.map((t) => `${t.table} ${t.state === "ok" ? "ok" : t.state === "forbidden" ? "403" : `error (${t.detail ?? "?"})`}`);
13219
+ return `seat read: ${role}: ${cells.join(", ")}`;
13169
13220
  }
13170
13221
  function isForbiddenError(e) {
13171
13222
  const status2 = e?.status;
@@ -13173,16 +13224,18 @@ function isForbiddenError(e) {
13173
13224
  return /\b403\b/.test(e instanceof Error ? e.message : String(e));
13174
13225
  }
13175
13226
  var SEAT_FORBIDDEN_KEY = "seat_forbidden:release";
13176
- function seatForbiddenItem(now = /* @__PURE__ */ new Date()) {
13227
+ function seatForbiddenItem(now = /* @__PURE__ */ new Date(), e, route) {
13228
+ const d = e === void 0 ? {} : forbiddenDetail(e, route);
13229
+ const what = d.method && d.table ? `${d.method} ${d.table}${d.verb ? ` -> 403 No ${d.verb} access` : " -> 403"}` : "cannot read the Issues table (403)";
13177
13230
  return {
13178
13231
  kind: "seat_forbidden",
13179
13232
  key: SEAT_FORBIDDEN_KEY,
13180
- message: `release seat cannot read the Issues table (403) \u2014 ${SEAT_NO_READ_REMEDY}`,
13233
+ message: `release seat: ${what} \u2014 ${remedyFor(d)}`,
13181
13234
  since: now.toISOString()
13182
13235
  };
13183
13236
  }
13184
13237
  function seatForbiddenRepoLine(scope) {
13185
- return `${scope}: skipped \u2014 release seat cannot read Issues (see attention)`;
13238
+ return `${scope}: skipped \u2014 release seat was refused by the tracker (see attention)`;
13186
13239
  }
13187
13240
 
13188
13241
  // src/cli.ts
@@ -17952,9 +18005,11 @@ ${changed2.map((c) => ` - ${c}
17952
18005
  preModes
17953
18006
  );
17954
18007
  reportUnplaceable(c.route, preUnplaceable, tracker.contract.statuses.verified, remit);
18008
+ const remoteBase = remoteBranchExists(target.dir, repo.branch.remote, repo.branch.base);
17955
18009
  const d = decideRelease(target.dir, pre, tracker.contract, {
17956
18010
  tagPattern: repo.release.tagPattern ?? "v*",
17957
- base: repo.branch.base
18011
+ base: repo.branch.base,
18012
+ ...remoteBase ? { ref: `${repo.branch.remote}/${repo.branch.base}`, guard: false } : {}
17958
18013
  });
17959
18014
  const unstamped = planReleasedBranches(
17960
18015
  target.dir,
@@ -18253,7 +18308,7 @@ ${changed2.map((c) => ` - ${c}
18253
18308
  return summary;
18254
18309
  } catch (e) {
18255
18310
  if (isForbiddenError(e)) {
18256
- await raiseShipAttention({ state, emit: remit, route: c, ship: cfg.ship }, seatForbiddenItem());
18311
+ await raiseShipAttention({ state, emit: remit, route: c, ship: cfg.ship }, seatForbiddenItem(/* @__PURE__ */ new Date(), e, c));
18257
18312
  const line = seatForbiddenRepoLine(scope);
18258
18313
  remit.warn(line, { step: "release" });
18259
18314
  return { scope, tests: "skipped", outcome: "error", detail: line };
@@ -19014,7 +19069,7 @@ ${changed2.map((c) => ` - ${c}
19014
19069
  if (!dryRun) {
19015
19070
  for (const p of await probeSeatReads(target, { userAgent: cfg.ship.userAgent })) {
19016
19071
  if (p.state === "forbidden") {
19017
- process.stdout.write(`${describeSeatForbidden(p.role)}
19072
+ process.stdout.write(`${describeSeatForbidden(p.role, p.tables)}
19018
19073
  `);
19019
19074
  process.exitCode = 1;
19020
19075
  }
@@ -19997,7 +20052,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
19997
20052
  for (const o of mintOutcomes) process.stderr.write(`${describeMintOutcome(o)}
19998
20053
  `);
19999
20054
  for (const p of await probeSeatReads({ ...existingRoute, resolved: { ...existingRoute.resolved, seats: found.seats } }, { userAgent: cfg.ship.userAgent })) {
20000
- if (p.state === "forbidden") process.stderr.write(`${describeSeatForbidden(p.role)}
20055
+ if (p.state === "forbidden") process.stderr.write(`${describeSeatForbidden(p.role, p.tables)}
20001
20056
  `);
20002
20057
  }
20003
20058
  } catch (e) {
@@ -20176,7 +20231,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
20176
20231
  for (const m of minted) process.stderr.write(` ${describeMintOutcome(m)}
20177
20232
  `);
20178
20233
  for (const p of await probeSeatReads(syncRoute, { userAgent: cfg.ship.userAgent })) {
20179
- if (p.state === "forbidden") process.stderr.write(` ${describeSeatForbidden(p.role)}
20234
+ if (p.state === "forbidden") process.stderr.write(` ${describeSeatForbidden(p.role, p.tables)}
20180
20235
  `);
20181
20236
  }
20182
20237
  if (run2.readOnly) {
@@ -21303,7 +21358,7 @@ watermark: ${state.watermark()}
21303
21358
  if (!flag("offline")) {
21304
21359
  for (const p of await probeSeatReads(route, { userAgent: cfg.ship.userAgent })) {
21305
21360
  if (p.state === "forbidden") {
21306
- problems.push({ severity: "blocker", kind: "seat_cannot_read_issues", route: route.route, message: `${p.role} agent key cannot read Issues (403)`, remedy: SEAT_NO_READ_REMEDY });
21361
+ problems.push({ severity: "blocker", kind: "seat_cannot_read_issues", route: route.route, message: `${p.role} agent key refused on ${p.tables.filter((t) => t.state === "forbidden").map((t) => t.table).join(", ")} (403)`, remedy: describeSeatForbidden(p.role, p.tables).replace(/^.*?— /, "") });
21307
21362
  } else if (p.state === "ok") passed++;
21308
21363
  }
21309
21364
  }
@@ -21449,11 +21504,11 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
21449
21504
  if (!flag("offline")) {
21450
21505
  for (const p of await probeSeatReads(roleRoute, { userAgent: cfg.ship.userAgent })) {
21451
21506
  if (p.state === "forbidden") {
21452
- process.stdout.write(`${describeSeatForbidden(p.role)}
21507
+ process.stdout.write(`${describeSeatForbidden(p.role, p.tables)}
21453
21508
  `);
21454
21509
  process.exitCode = 1;
21455
21510
  } else {
21456
- process.stdout.write(p.state === "ok" ? `seat read: ${p.role} can read Issues
21511
+ process.stdout.write(p.state === "ok" ? `${describeSeatTables(p.role, p.tables)}
21457
21512
  ` : `seat read: ${p.role} could not be checked (${p.detail})
21458
21513
  `);
21459
21514
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.79.0",
3
+ "version": "0.79.2",
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",