@tablation/crew 0.6.0 → 0.7.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.
- package/README.md +1 -1
- package/dist/cli.js +196 -27
- package/package.json +1 -1
- package/prompts/default/personas/common.md +5 -0
- package/prompts/dev-qa/personas/common.md +5 -0
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
|
|
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
|
@@ -824,6 +824,7 @@ function parseResolved(raw, m, where) {
|
|
|
824
824
|
operator: m.req(raw.operator, `${where}.resolved.operator`),
|
|
825
825
|
holds: holds2,
|
|
826
826
|
contract: raw.contract ?? void 0,
|
|
827
|
+
...typeof raw.releaseSeat === "string" && raw.releaseSeat ? { releaseSeat: raw.releaseSeat } : {},
|
|
827
828
|
...Object.keys(agentPersonas).length > 0 ? { agentPersonas } : {},
|
|
828
829
|
...Object.keys(agentSkills).length > 0 ? { agentSkills } : {}
|
|
829
830
|
};
|
|
@@ -1083,6 +1084,10 @@ async function hydrateApiKeys(routes, getStore = getSessionStore) {
|
|
|
1083
1084
|
}
|
|
1084
1085
|
}
|
|
1085
1086
|
}
|
|
1087
|
+
function releaseSeat(rt) {
|
|
1088
|
+
const r = rt.resolved;
|
|
1089
|
+
return r?.releaseSeat ?? r?.seats.dev ?? r?.seats.qa ?? "";
|
|
1090
|
+
}
|
|
1086
1091
|
function configuredMembers(rt) {
|
|
1087
1092
|
const r = rt.resolved;
|
|
1088
1093
|
if (!r) throw new ConfigError(`route "${rt.route}" has no resolved ids \u2014 run \`crew connect\``);
|
|
@@ -1219,6 +1224,31 @@ var State = class _State {
|
|
|
1219
1224
|
resume(role) {
|
|
1220
1225
|
rmSync(role ? this.path(`.role-paused-${role}`) : this.path(".crew-paused"), { force: true });
|
|
1221
1226
|
}
|
|
1227
|
+
/**
|
|
1228
|
+
* The release phase (merge, deploy, release) has its own sentinel, one
|
|
1229
|
+
* level below the whole-crew pause: `crew pause release` stops shipping
|
|
1230
|
+
* while agents keep working.
|
|
1231
|
+
*/
|
|
1232
|
+
isReleasePaused() {
|
|
1233
|
+
return existsSync3(this.path(".release-paused"));
|
|
1234
|
+
}
|
|
1235
|
+
setReleasePaused(paused) {
|
|
1236
|
+
if (paused) writeFileSync(this.path(".release-paused"), "");
|
|
1237
|
+
else rmSync(this.path(".release-paused"), { force: true });
|
|
1238
|
+
}
|
|
1239
|
+
/**
|
|
1240
|
+
* Why a release cycle must not run right now, or null when it may. Every
|
|
1241
|
+
* release entry point checks this first — the release timer, `crew release`,
|
|
1242
|
+
* `crew merge`, `crew deploy` and the inline release a `run` cycle used to
|
|
1243
|
+
* do — so a paused ship cannot merge, deploy, or file release alerts.
|
|
1244
|
+
* A release already in flight when the pause lands finishes; only new
|
|
1245
|
+
* cycles are refused.
|
|
1246
|
+
*/
|
|
1247
|
+
releaseSkipReason() {
|
|
1248
|
+
if (this.isPaused()) return "the crew is paused";
|
|
1249
|
+
if (this.isReleasePaused()) return "releases are paused";
|
|
1250
|
+
return null;
|
|
1251
|
+
}
|
|
1222
1252
|
/**
|
|
1223
1253
|
* Take an exclusive lock, or report who holds it.
|
|
1224
1254
|
*
|
|
@@ -1650,7 +1680,9 @@ var DEFAULT_CONTRACT = {
|
|
|
1650
1680
|
updatedAt: "updated_at",
|
|
1651
1681
|
needsDesign: "needs_design",
|
|
1652
1682
|
reportType: "report_type",
|
|
1653
|
-
epic: "epic_id"
|
|
1683
|
+
epic: "epic_id",
|
|
1684
|
+
author: "filed_by_id",
|
|
1685
|
+
ship: "ship_id"
|
|
1654
1686
|
},
|
|
1655
1687
|
comments: {
|
|
1656
1688
|
parent: "ticket_id",
|
|
@@ -1658,7 +1690,8 @@ var DEFAULT_CONTRACT = {
|
|
|
1658
1690
|
author: "team_member_id",
|
|
1659
1691
|
kind: "kind",
|
|
1660
1692
|
eventKind: "event",
|
|
1661
|
-
createdAt: "created_at"
|
|
1693
|
+
createdAt: "created_at",
|
|
1694
|
+
ship: "ship_id"
|
|
1662
1695
|
},
|
|
1663
1696
|
statuses: {
|
|
1664
1697
|
open: ["new", "accepted", "blocked", "in_progress", "needs_info", "fixed", "qa", "verified"],
|
|
@@ -2446,6 +2479,12 @@ var Tracker = class {
|
|
|
2446
2479
|
client;
|
|
2447
2480
|
models;
|
|
2448
2481
|
userAgent;
|
|
2482
|
+
shipName;
|
|
2483
|
+
/**
|
|
2484
|
+
* The once-per-process authorship probe (CREW-1371), memoised as a promise
|
|
2485
|
+
* so concurrent first writes share one round of field-metadata reads.
|
|
2486
|
+
*/
|
|
2487
|
+
authorship;
|
|
2449
2488
|
stateDir;
|
|
2450
2489
|
constructor(route, ship) {
|
|
2451
2490
|
this.route = route;
|
|
@@ -2457,6 +2496,7 @@ var Tracker = class {
|
|
|
2457
2496
|
this.models = route.resolved.models;
|
|
2458
2497
|
this.contract = resolveContract(route.contract);
|
|
2459
2498
|
this.userAgent = ship.userAgent;
|
|
2499
|
+
this.shipName = ship.name;
|
|
2460
2500
|
this.stateDir = ship.stateDir;
|
|
2461
2501
|
this.client = new TablationClient({
|
|
2462
2502
|
baseUrl: `${route.baseUrl}/api`,
|
|
@@ -2809,13 +2849,60 @@ var Tracker = class {
|
|
|
2809
2849
|
* someone else" wake signal.
|
|
2810
2850
|
*/
|
|
2811
2851
|
async postEvent(ticketId, body, memberId) {
|
|
2852
|
+
const auth = await this.authorshipProbe();
|
|
2812
2853
|
await this.client.records.create(this.models.comments, {
|
|
2813
2854
|
ticket_id: ticketId,
|
|
2814
2855
|
body,
|
|
2815
2856
|
team_member_id: memberId,
|
|
2816
|
-
kind: "event"
|
|
2857
|
+
kind: "event",
|
|
2858
|
+
...auth.commentShip && auth.shipId ? { [this.contract.comments.ship]: auth.shipId } : {}
|
|
2817
2859
|
});
|
|
2818
2860
|
}
|
|
2861
|
+
/**
|
|
2862
|
+
* The authorship probe (CREW-1371), read once per process: which optional
|
|
2863
|
+
* `filed_by_id`/`ship_id` columns exist on Issues and Comments, and this
|
|
2864
|
+
* ship's own Ships row id. Memoised as a promise so concurrent first writes
|
|
2865
|
+
* share one round of metadata reads. A table whose metadata cannot be read
|
|
2866
|
+
* reads as "column absent", the same way `connect.ts`'s `owner_id` probe does.
|
|
2867
|
+
*/
|
|
2868
|
+
authorshipProbe() {
|
|
2869
|
+
this.authorship ??= this.probeAuthorship();
|
|
2870
|
+
return this.authorship;
|
|
2871
|
+
}
|
|
2872
|
+
async probeAuthorship() {
|
|
2873
|
+
const [issueFields, commentFields, shipRow] = await Promise.all([
|
|
2874
|
+
this.columnNames(this.models.issues),
|
|
2875
|
+
this.columnNames(this.models.comments),
|
|
2876
|
+
// No name (a caller that only beats or patches, never stamps) means no row to find.
|
|
2877
|
+
this.shipName ? this.myShipRow(this.shipName) : Promise.resolve(void 0)
|
|
2878
|
+
]);
|
|
2879
|
+
return {
|
|
2880
|
+
issueAuthor: issueFields.has(this.contract.columns.author),
|
|
2881
|
+
issueShip: issueFields.has(this.contract.columns.ship),
|
|
2882
|
+
commentShip: commentFields.has(this.contract.comments.ship),
|
|
2883
|
+
shipId: shipRow?.id ?? null
|
|
2884
|
+
};
|
|
2885
|
+
}
|
|
2886
|
+
async columnNames(modelId) {
|
|
2887
|
+
try {
|
|
2888
|
+
const model = await this.client.dataModels.get(modelId);
|
|
2889
|
+
return new Set(model.fields.map((f) => f.columnName));
|
|
2890
|
+
} catch {
|
|
2891
|
+
return /* @__PURE__ */ new Set();
|
|
2892
|
+
}
|
|
2893
|
+
}
|
|
2894
|
+
/**
|
|
2895
|
+
* The authorship columns this workspace is missing, as `table.column` names
|
|
2896
|
+
* for `crew doctor` to report (CREW-1371). Empty on a fully updated install.
|
|
2897
|
+
*/
|
|
2898
|
+
async missingAuthorshipColumns() {
|
|
2899
|
+
const auth = await this.authorshipProbe();
|
|
2900
|
+
const missing = [];
|
|
2901
|
+
if (!auth.issueAuthor) missing.push(`Issues.${this.contract.columns.author}`);
|
|
2902
|
+
if (!auth.issueShip) missing.push(`Issues.${this.contract.columns.ship}`);
|
|
2903
|
+
if (!auth.commentShip) missing.push(`Comments.${this.contract.comments.ship}`);
|
|
2904
|
+
return missing;
|
|
2905
|
+
}
|
|
2819
2906
|
/**
|
|
2820
2907
|
* The public `/v/<slug>/r/<token>` link for one ticket (ISSUE-928) —
|
|
2821
2908
|
* fetch-or-create via `POST /views/:viewId/record-links`, so a repeated
|
|
@@ -2857,8 +2944,13 @@ var Tracker = class {
|
|
|
2857
2944
|
* not among them (`postEvent` above hardcodes its own fields the same
|
|
2858
2945
|
* way).
|
|
2859
2946
|
*/
|
|
2860
|
-
async fileTicket(fields) {
|
|
2861
|
-
|
|
2947
|
+
async fileTicket(fields, memberId) {
|
|
2948
|
+
const auth = await this.authorshipProbe();
|
|
2949
|
+
return this.client.records.create(this.models.issues, {
|
|
2950
|
+
...fields,
|
|
2951
|
+
...auth.issueAuthor && memberId ? { [this.contract.columns.author]: memberId } : {},
|
|
2952
|
+
...auth.issueShip && auth.shipId ? { [this.contract.columns.ship]: auth.shipId } : {}
|
|
2953
|
+
});
|
|
2862
2954
|
}
|
|
2863
2955
|
};
|
|
2864
2956
|
|
|
@@ -2908,7 +3000,8 @@ function rosterMarkdown(roster, meId) {
|
|
|
2908
3000
|
`You are **${selfName}**. Address your shipmates by name in ticket`,
|
|
2909
3001
|
"comments the way you would a colleague; add the role in parentheses only",
|
|
2910
3002
|
"where a reader would otherwise not know which seat you mean. Your own",
|
|
2911
|
-
"row id is what you write to `assignee_id` and `team_member_id
|
|
3003
|
+
"row id is what you write to `assignee_id` and `team_member_id`, and to",
|
|
3004
|
+
"`filed_by_id` on a ticket you file (where the Environment section names it).",
|
|
2912
3005
|
"",
|
|
2913
3006
|
"| seat | name | Crew row id |",
|
|
2914
3007
|
"|---|---|---|"
|
|
@@ -3365,20 +3458,36 @@ function attentionTransitions(previous, current) {
|
|
|
3365
3458
|
function displayKey2(t) {
|
|
3366
3459
|
return t.issue_tag ?? t.issue_id ?? "?";
|
|
3367
3460
|
}
|
|
3368
|
-
function author(c,
|
|
3461
|
+
function author(c, i) {
|
|
3462
|
+
const { me, roster } = i;
|
|
3369
3463
|
if (c.team_member_id === me) return "you";
|
|
3370
3464
|
if (c.team_member_id === null || c.team_member_id === void 0) {
|
|
3371
3465
|
const byIdentity = memberByIdentity(roster, c.created_by_id);
|
|
3372
3466
|
if (byIdentity) return byIdentity.id === me ? "you" : crewLabel(byIdentity) ?? byIdentity.name;
|
|
3373
3467
|
return `${c.reporter_name ?? "anon"} (no identity)`;
|
|
3374
3468
|
}
|
|
3375
|
-
return crewLabel(roster.get(c.team_member_id)) ?? "someone off this ship";
|
|
3469
|
+
return (crewLabel(roster.get(c.team_member_id)) ?? "someone off this ship") + shipSuffix(c, i);
|
|
3470
|
+
}
|
|
3471
|
+
function shipSuffix(c, i) {
|
|
3472
|
+
if (!c.ship_id || c.ship_id === i.myShipId) return "";
|
|
3473
|
+
const name = i.ships?.find((s) => s.id === c.ship_id)?.name;
|
|
3474
|
+
return name ? ` (${name})` : "";
|
|
3475
|
+
}
|
|
3476
|
+
function filedBy(t, i) {
|
|
3477
|
+
const member = (t.filed_by_id ? i.roster.get(t.filed_by_id) : void 0) ?? memberByIdentity(i.roster, t.created_by_id);
|
|
3478
|
+
if (member) {
|
|
3479
|
+
if (member.id === i.me) return "you";
|
|
3480
|
+
const label = crewLabel(member) ?? member.name;
|
|
3481
|
+
const ship = t.ship_id && t.ship_id !== i.myShipId ? i.ships?.find((s) => s.id === t.ship_id)?.name : void 0;
|
|
3482
|
+
return ship ? `${label} (${ship})` : label;
|
|
3483
|
+
}
|
|
3484
|
+
return t.reporter_name ? `${t.reporter_name} (unverified)` : "\u2014";
|
|
3376
3485
|
}
|
|
3377
3486
|
function lastComment(i, ticketId) {
|
|
3378
3487
|
const forTicket = i.comments.filter((c) => c.ticket_id === ticketId).sort((a, b) => a.created_at < b.created_at ? -1 : a.created_at > b.created_at ? 1 : 0);
|
|
3379
3488
|
const last = forTicket[forTicket.length - 1];
|
|
3380
3489
|
if (!last) return "\u2014";
|
|
3381
|
-
return `${last.created_at.slice(0, 16)}Z ${author(last, i
|
|
3490
|
+
return `${last.created_at.slice(0, 16)}Z ${author(last, i)}`;
|
|
3382
3491
|
}
|
|
3383
3492
|
function newFromOthers(i, ticketId) {
|
|
3384
3493
|
return i.comments.filter(
|
|
@@ -3410,14 +3519,14 @@ ${sorted(ts).map(row).join("\n")}
|
|
|
3410
3519
|
`;
|
|
3411
3520
|
}
|
|
3412
3521
|
function buildingDigest(i) {
|
|
3413
|
-
const header = "| ticket | repo | branch | worktree | status | assignee | sev | pri | eff | updated | last comment | new since last poll |\n
|
|
3522
|
+
const header = "| ticket | repo | branch | worktree | status | assignee | filed by | sev | pri | eff | updated | last comment | new since last poll |\n|---|---|---|---|---|---|---|---|---|---|---|---|---|";
|
|
3414
3523
|
const row = (t) => {
|
|
3415
3524
|
const n = newFromOthers(i, t.id);
|
|
3416
3525
|
const branch = i.branchFor ? i.branchFor(t) : "";
|
|
3417
3526
|
const worktree = i.worktreeFor ? i.worktreeFor(t) : "";
|
|
3418
3527
|
const dir = i.dirFor ? i.dirFor(t) : null;
|
|
3419
3528
|
const repo = i.dirFor ? dir ?? "**NO CHECKOUT**" : "";
|
|
3420
|
-
return `| ${displayKey2(t)} | ${repo} | ${branch} | ${worktree} | ${t.status} | ${who(t, i)} | ${t.severity ?? "\u2014"} | ${t.priority ?? "\u2014"} | p${effectivePriority(t)} | ${t.updated_at.slice(0, 16)}Z | ${lastComment(i, t.id)} | ${n > 0 ? `**${n} new**` : "\u2014"} |`;
|
|
3529
|
+
return `| ${displayKey2(t)} | ${repo} | ${branch} | ${worktree} | ${t.status} | ${who(t, i)} | ${filedBy(t, i)} | ${t.severity ?? "\u2014"} | ${t.priority ?? "\u2014"} | p${effectivePriority(t)} | ${t.updated_at.slice(0, 16)}Z | ${lastComment(i, t.id)} | ${n > 0 ? `**${n} new**` : "\u2014"} |`;
|
|
3421
3530
|
};
|
|
3422
3531
|
const blockedRow = (t) => `| ${displayKey2(t)} | ${t.status} | ${who(t, i)} | p${effectivePriority(t)} | ${blockers(t, i)} |`;
|
|
3423
3532
|
const blockedTable = (ts) => ts.length === 0 ? "\n_None._\n" : `
|
|
@@ -3449,7 +3558,7 @@ ${sorted(ts).map(blockedRow).join("\n")}
|
|
|
3449
3558
|
]);
|
|
3450
3559
|
}
|
|
3451
3560
|
function qaDigest(i) {
|
|
3452
|
-
const header = "| ticket | repo | status | built by | assignee | sev | pri | eff | branch | worktree | updated | last comment | new since last poll |\n
|
|
3561
|
+
const header = "| ticket | repo | status | built by | assignee | filed by | sev | pri | eff | branch | worktree | updated | last comment | new since last poll |\n|---|---|---|---|---|---|---|---|---|---|---|---|---|---|";
|
|
3453
3562
|
const branchCell = (t) => {
|
|
3454
3563
|
if (i.dirFor && i.dirFor(t) === null) return "\u2014";
|
|
3455
3564
|
if (!i.existingBranchFor) return "";
|
|
@@ -3464,7 +3573,7 @@ function qaDigest(i) {
|
|
|
3464
3573
|
const n = newFromOthers(i, t.id);
|
|
3465
3574
|
const dir = i.dirFor ? i.dirFor(t) : null;
|
|
3466
3575
|
const repo = i.dirFor ? dir ?? "**NO CHECKOUT**" : "";
|
|
3467
|
-
return `| ${displayKey2(t)} | ${repo} | ${t.status} | ${builtBy(t)} | ${who(t, i)} | ${t.severity ?? "\u2014"} | ${t.priority ?? "\u2014"} | p${effectivePriority(t)} | ${branchCell(t)} | ${worktreeCell(t)} | ${t.updated_at.slice(0, 16)}Z | ${lastComment(i, t.id)} | ${n > 0 ? `**${n} new**` : "\u2014"} |`;
|
|
3576
|
+
return `| ${displayKey2(t)} | ${repo} | ${t.status} | ${builtBy(t)} | ${who(t, i)} | ${filedBy(t, i)} | ${t.severity ?? "\u2014"} | ${t.priority ?? "\u2014"} | p${effectivePriority(t)} | ${branchCell(t)} | ${worktreeCell(t)} | ${t.updated_at.slice(0, 16)}Z | ${lastComment(i, t.id)} | ${n > 0 ? `**${n} new**` : "\u2014"} |`;
|
|
3468
3577
|
};
|
|
3469
3578
|
return stream([
|
|
3470
3579
|
"## Current queue \u2014 built for you by the poll\n",
|
|
@@ -3927,11 +4036,12 @@ async function decideCycle(o) {
|
|
|
3927
4036
|
const { route, ship, state, emit } = o;
|
|
3928
4037
|
const tracker = new Tracker(route, ship);
|
|
3929
4038
|
emit.enter("poll");
|
|
3930
|
-
const [tickets, comments, crewRows, epicRows] = await Promise.all([
|
|
4039
|
+
const [tickets, comments, crewRows, epicRows, ships] = await Promise.all([
|
|
3931
4040
|
tracker.openTickets(),
|
|
3932
4041
|
tracker.comments(200),
|
|
3933
4042
|
tracker.crewRows(),
|
|
3934
|
-
tracker.epicRows()
|
|
4043
|
+
tracker.epicRows(),
|
|
4044
|
+
tracker.shipRows()
|
|
3935
4045
|
]);
|
|
3936
4046
|
emit.emit(`${tickets.length} open ticket(s), ${comments.length} comment(s)`, {
|
|
3937
4047
|
data: { tickets: tickets.length, comments: comments.length }
|
|
@@ -4044,6 +4154,7 @@ async function decideCycle(o) {
|
|
|
4044
4154
|
tickets,
|
|
4045
4155
|
comments,
|
|
4046
4156
|
roster,
|
|
4157
|
+
ships,
|
|
4047
4158
|
blocked,
|
|
4048
4159
|
info,
|
|
4049
4160
|
sweep,
|
|
@@ -4122,6 +4233,10 @@ function writeDigest(o, d, role, stateDir) {
|
|
|
4122
4233
|
comments: d.comments,
|
|
4123
4234
|
me,
|
|
4124
4235
|
roster: d.roster,
|
|
4236
|
+
// CREW-1371: a comment from another ship is labelled with that ship's
|
|
4237
|
+
// name, so two ships under one API key are distinguishable on the queue.
|
|
4238
|
+
ships: d.ships,
|
|
4239
|
+
myShipId: d.ships.find((s) => (s.name ?? "").trim() === o.ship.name.trim())?.id ?? null,
|
|
4125
4240
|
watermark: d.watermark,
|
|
4126
4241
|
blocked: d.blocked,
|
|
4127
4242
|
blockerInfo: d.info
|
|
@@ -4397,6 +4512,14 @@ import { createHash as createHash2 } from "crypto";
|
|
|
4397
4512
|
// src/environment.ts
|
|
4398
4513
|
import { basename as basename4 } from "path";
|
|
4399
4514
|
var API_KEY_VAR = "CREW_API_KEY";
|
|
4515
|
+
function authorshipBullets(a, contract) {
|
|
4516
|
+
if (!a) return [];
|
|
4517
|
+
return [
|
|
4518
|
+
...a.issueAuthor ? [`- \`${contract.columns.author}\`: your own Crew row id, from "Your crew" above \u2014 who filed it`] : [],
|
|
4519
|
+
...a.issueShip && a.shipId ? [`- \`${contract.columns.ship}\`: \`${a.shipId}\` \u2014 this ship, where it was filed from`] : [],
|
|
4520
|
+
...a.commentShip && a.shipId ? [`- \`${contract.comments.ship}\` on every comment you post: \`${a.shipId}\` \u2014 this ship`] : []
|
|
4521
|
+
];
|
|
4522
|
+
}
|
|
4400
4523
|
var HOOK_PURPOSE = [
|
|
4401
4524
|
["setup", "make a fresh worktree usable \u2014 dependencies, generated clients"],
|
|
4402
4525
|
["isolate", "point this worktree at state of its own; prints KEY=value overrides to export"],
|
|
@@ -4603,6 +4726,7 @@ function renderEnvironment(i) {
|
|
|
4603
4726
|
...only?.id ? [`- \`${contract.columns.repo}\`: \`${only.id}\` (this repository)`] : repos.filter((r) => r.id).map(
|
|
4604
4727
|
(r) => `- \`${contract.columns.repo}\`: \`${r.id}\` \u2014 when the new ticket belongs to ${r.name}`
|
|
4605
4728
|
),
|
|
4729
|
+
...authorshipBullets(i.authorship, contract),
|
|
4606
4730
|
"",
|
|
4607
4731
|
...i.sourceTicket ? [
|
|
4608
4732
|
`Name ${i.sourceTicket} \u2014 the ticket you were working when you noticed this \u2014 in the new`,
|
|
@@ -6103,7 +6227,7 @@ Still failing: ${n.detail ?? n.headline}`, memberId);
|
|
|
6103
6227
|
report_type: "bug",
|
|
6104
6228
|
severity: "s2",
|
|
6105
6229
|
reporter_name: "Crew release"
|
|
6106
|
-
});
|
|
6230
|
+
}, memberId);
|
|
6107
6231
|
const label = created.issue_id ? displayKey(created) : created.id;
|
|
6108
6232
|
log.warn(`release failure \u2014 filed ${label}`, { step: "release" });
|
|
6109
6233
|
return { kind: "filed", issueId: label };
|
|
@@ -7133,7 +7257,19 @@ async function discover(o) {
|
|
|
7133
7257
|
}
|
|
7134
7258
|
const pairId = seatFor(scoped, "pair") || (shipRowId ? seatFor(unclaimed, "pair") : void 0);
|
|
7135
7259
|
if (pairId) out.seats.pair = pairId;
|
|
7136
|
-
|
|
7260
|
+
if (shipRowId) {
|
|
7261
|
+
const releaseId = seatFor(scoped, "release");
|
|
7262
|
+
if (releaseId) out.releaseSeat = releaseId;
|
|
7263
|
+
else {
|
|
7264
|
+
const created = await post2(o, `/data-models/${out.models.crew}/records`, {
|
|
7265
|
+
name: "Release agent",
|
|
7266
|
+
ship_id: shipRowId
|
|
7267
|
+
});
|
|
7268
|
+
out.releaseSeat = created.id;
|
|
7269
|
+
provisioning.push("created Crew row for the release seat");
|
|
7270
|
+
}
|
|
7271
|
+
}
|
|
7272
|
+
const seatIds = /* @__PURE__ */ new Set([...Object.values(out.seats), ...out.releaseSeat ? [out.releaseSeat] : []]);
|
|
7137
7273
|
out.holds = rows.filter((r) => !seatIds.has(r.id)).map((r) => ({ id: r.id, name: r.name ?? "" }));
|
|
7138
7274
|
if (out.holds.length === 0) {
|
|
7139
7275
|
problems.push("no non-seat Crew rows \u2014 which row is the operator?");
|
|
@@ -8525,6 +8661,7 @@ var SpawnBreaker = class {
|
|
|
8525
8661
|
}
|
|
8526
8662
|
};
|
|
8527
8663
|
async function runOnePass(o) {
|
|
8664
|
+
if (o.state.isPaused()) return { pending: [], started: [], skipped: [] };
|
|
8528
8665
|
await hydrateApiKeys([o.route], o.getSessionStore);
|
|
8529
8666
|
const emit = o.newEmitter();
|
|
8530
8667
|
const decide = o.decide ?? decideCycle;
|
|
@@ -8706,7 +8843,8 @@ async function runRoleAgent(role, decision, ctx) {
|
|
|
8706
8843
|
}
|
|
8707
8844
|
}
|
|
8708
8845
|
async function buildEnvironment(route, ship, ticket) {
|
|
8709
|
-
const
|
|
8846
|
+
const tracker = new Tracker(route, ship);
|
|
8847
|
+
const contract = tracker.contract;
|
|
8710
8848
|
const repos = reposOf(route).map((t) => {
|
|
8711
8849
|
const id = repoIdForName(route, t.name);
|
|
8712
8850
|
const o = route.repoOverrides[t.name];
|
|
@@ -8721,7 +8859,8 @@ async function buildEnvironment(route, ship, ticket) {
|
|
|
8721
8859
|
}, t.dir)
|
|
8722
8860
|
};
|
|
8723
8861
|
});
|
|
8724
|
-
|
|
8862
|
+
const authorship = await tracker.authorshipProbe().catch(() => null);
|
|
8863
|
+
return renderEnvironment({ route, userAgent: ship.userAgent, repos, contract, sourceTicket: ticket, authorship });
|
|
8725
8864
|
}
|
|
8726
8865
|
|
|
8727
8866
|
// src/cli.ts
|
|
@@ -8852,7 +8991,8 @@ function usage() {
|
|
|
8852
8991
|
crew rotate-passenger-url [route] force a Host Passengers tunnel to reconnect with a fresh public URL
|
|
8853
8992
|
crew unassign [route] NNN hand back a session's ticket \u2014 clears assignee, next cycle picks it up
|
|
8854
8993
|
crew sync [route] fast-forward the checkout and its worktrees from the remote
|
|
8855
|
-
crew pause|resume [route] [R] pause everything, or
|
|
8994
|
+
crew pause|resume [route] [R|release] pause everything, one role, or releases only (merge/deploy/release; passengers keeps running).
|
|
8995
|
+
resume with no argument clears only the whole-crew pause; a role or release pause needs its own resume
|
|
8856
8996
|
crew log [route] tail the log
|
|
8857
8997
|
crew inbox [--member NAME] your tickets across every workspace (or a colleague's)
|
|
8858
8998
|
crew connect resolve a workspace's ids into a crew.yaml block
|
|
@@ -8953,6 +9093,11 @@ if (command === "passenger-sync-daemon") {
|
|
|
8953
9093
|
urlFor: (slug) => publicUrlFor(slug, relayHost, cfg.ship.relayHttpPort, cfg.ship.relayPublicDomain),
|
|
8954
9094
|
warn: (m) => emit.warn(m, { step: "passengers" })
|
|
8955
9095
|
};
|
|
9096
|
+
}, releaseRefused = function() {
|
|
9097
|
+
const why = state.releaseSkipReason();
|
|
9098
|
+
if (!why) return false;
|
|
9099
|
+
emit.emit(`${why} \u2014 release skipped`, { step: "release" });
|
|
9100
|
+
return true;
|
|
8956
9101
|
}, reportUnplaceable = function(c, unplaceable, verified, emit2) {
|
|
8957
9102
|
if (unplaceableReported.has(c.route)) return;
|
|
8958
9103
|
unplaceableReported.add(c.route);
|
|
@@ -8961,7 +9106,7 @@ if (command === "passenger-sync-daemon") {
|
|
|
8961
9106
|
else emit2.emit(note.message, { step: "release" });
|
|
8962
9107
|
}
|
|
8963
9108
|
};
|
|
8964
|
-
requireArmed2 = requireArmed, parseTicketNumber2 = parseTicketNumber, passengerApiKeys2 = passengerApiKeys, endpointApiFor2 = endpointApiFor, endpointReconcileDeps2 = endpointReconcileDeps, reportUnplaceable2 = reportUnplaceable;
|
|
9109
|
+
requireArmed2 = requireArmed, parseTicketNumber2 = parseTicketNumber, passengerApiKeys2 = passengerApiKeys, endpointApiFor2 = endpointApiFor, endpointReconcileDeps2 = endpointReconcileDeps, releaseRefused2 = releaseRefused, reportUnplaceable2 = reportUnplaceable;
|
|
8965
9110
|
let cfg;
|
|
8966
9111
|
let firstRun;
|
|
8967
9112
|
try {
|
|
@@ -9003,7 +9148,7 @@ if (command === "passenger-sync-daemon") {
|
|
|
9003
9148
|
const releaseFleetWide = ["merge", "deploy", "release"].includes(command) && flag("fleet");
|
|
9004
9149
|
const DAEMON_SUBCOMMANDS = /* @__PURE__ */ new Set(["start", "stop", "status", "restart"]);
|
|
9005
9150
|
const named = command === "daemon" && DAEMON_SUBCOMMANDS.has(positional[1] ?? "") ? positional[2] : positional[1];
|
|
9006
|
-
const fleetWide = command === "inbox" || command === "connect" || command === "agents" || command === "skills" || command === "logbook" || command === "repos" || releaseFleetWide || FLEET_CAPABLE.has(command) && !named && cfg.routes.length > 1;
|
|
9151
|
+
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;
|
|
9007
9152
|
let route;
|
|
9008
9153
|
try {
|
|
9009
9154
|
route = fleetWide ? cfg.routes[0] : findRoute(cfg, named);
|
|
@@ -9093,6 +9238,7 @@ if (command === "passenger-sync-daemon") {
|
|
|
9093
9238
|
}
|
|
9094
9239
|
}
|
|
9095
9240
|
async function releaseFleet(opts = {}) {
|
|
9241
|
+
if (releaseRefused()) return;
|
|
9096
9242
|
const summaries = [];
|
|
9097
9243
|
if (!dryRun) state.releaseHeartbeat().touch();
|
|
9098
9244
|
for (const c of cfg.routes) {
|
|
@@ -9103,6 +9249,7 @@ if (command === "passenger-sync-daemon") {
|
|
|
9103
9249
|
if (!dryRun) state.releaseHeartbeat().touch();
|
|
9104
9250
|
}
|
|
9105
9251
|
async function releaseTargets(opts = {}) {
|
|
9252
|
+
if (releaseRefused()) return;
|
|
9106
9253
|
const only = value("repo");
|
|
9107
9254
|
const targets = reposOf(route);
|
|
9108
9255
|
const chosen = only ? targets.filter((t) => t.name === only) : targets;
|
|
@@ -9302,13 +9449,15 @@ ${changed2.map((c) => ` - ${c}
|
|
|
9302
9449
|
return (await resolvedRepos(c)).map((r) => `${r.name} requires ${r.config.platform}`).join(", ");
|
|
9303
9450
|
}
|
|
9304
9451
|
async function environmentFor(c, ticket) {
|
|
9305
|
-
const
|
|
9452
|
+
const tracker = new Tracker(c, cfg.ship);
|
|
9453
|
+
const contract = tracker.contract;
|
|
9306
9454
|
return renderEnvironment({
|
|
9307
9455
|
route: c,
|
|
9308
9456
|
userAgent: cfg.ship.userAgent,
|
|
9309
9457
|
repos: await resolvedRepos(c),
|
|
9310
9458
|
contract,
|
|
9311
|
-
sourceTicket: ticket
|
|
9459
|
+
sourceTicket: ticket,
|
|
9460
|
+
authorship: await tracker.authorshipProbe().catch(() => null)
|
|
9312
9461
|
});
|
|
9313
9462
|
}
|
|
9314
9463
|
const unplaceableReported = /* @__PURE__ */ new Set();
|
|
@@ -9410,7 +9559,7 @@ ${changed2.map((c) => ` - ${c}
|
|
|
9410
9559
|
try {
|
|
9411
9560
|
const g = outcome.gateRed;
|
|
9412
9561
|
const comments = await tracker.comments();
|
|
9413
|
-
const seat = c
|
|
9562
|
+
const seat = releaseSeat(c);
|
|
9414
9563
|
for (const t of gateRedTickets(outcome.merged, outcome.decision.merges)) {
|
|
9415
9564
|
if (alreadyReported(t, g, comments)) continue;
|
|
9416
9565
|
await tracker.postEvent(t.id, gateRedComment(g), seat);
|
|
@@ -9428,7 +9577,7 @@ ${changed2.map((c) => ` - ${c}
|
|
|
9428
9577
|
if (news) {
|
|
9429
9578
|
const notified = await notify(c, cfg.ship, news, remit, dryRun);
|
|
9430
9579
|
if (!notified && news.level === "fail") {
|
|
9431
|
-
const memberId = c
|
|
9580
|
+
const memberId = releaseSeat(c);
|
|
9432
9581
|
await applyFailureAlert(tracker, news, scope, all, memberId, remit, dryRun).catch((e) => {
|
|
9433
9582
|
remit.warn(`could not file/update a failure ticket: ${e.message}`, { step: "release" });
|
|
9434
9583
|
});
|
|
@@ -9436,7 +9585,7 @@ ${changed2.map((c) => ` - ${c}
|
|
|
9436
9585
|
}
|
|
9437
9586
|
if (outcome.conflicts?.length || outcome.unbuildable?.length) {
|
|
9438
9587
|
const comments = await tracker.comments();
|
|
9439
|
-
const seat = c
|
|
9588
|
+
const seat = releaseSeat(c);
|
|
9440
9589
|
for (const f of outcome.conflicts ?? []) {
|
|
9441
9590
|
try {
|
|
9442
9591
|
const bounce = planConflictBounce(
|
|
@@ -10582,6 +10731,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
|
|
|
10582
10731
|
models: found.models,
|
|
10583
10732
|
seats: found.seats,
|
|
10584
10733
|
holds: found.holds.map((h) => ({ id: h.id, role: h.name })),
|
|
10734
|
+
...found.releaseSeat ? { releaseSeat: found.releaseSeat } : {},
|
|
10585
10735
|
...operator ? { operator } : {},
|
|
10586
10736
|
...contract ? { contract } : {},
|
|
10587
10737
|
...reviewedStatuses.length > 0 ? { reviewedStatuses } : {},
|
|
@@ -11022,6 +11172,12 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
|
|
|
11022
11172
|
}
|
|
11023
11173
|
case "pause":
|
|
11024
11174
|
case "resume": {
|
|
11175
|
+
if (positional[1] === "release" || positional[2] === "release") {
|
|
11176
|
+
state.setReleasePaused(command === "pause");
|
|
11177
|
+
process.stdout.write(`${command}d releases
|
|
11178
|
+
`);
|
|
11179
|
+
break;
|
|
11180
|
+
}
|
|
11025
11181
|
const role = positional[2] ?? (["dev", "design", "qa"].includes(positional[1] ?? "") ? positional[1] : void 0);
|
|
11026
11182
|
if (command === "pause") state.pause(role);
|
|
11027
11183
|
else state.resume(role);
|
|
@@ -11113,6 +11269,9 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
|
|
|
11113
11269
|
},
|
|
11114
11270
|
crew: {
|
|
11115
11271
|
paused: state.isPaused(),
|
|
11272
|
+
// The release timer honors the whole-crew pause too, so this is
|
|
11273
|
+
// true whenever releases are stopped for either reason.
|
|
11274
|
+
releasePaused: state.releaseSkipReason() !== null,
|
|
11116
11275
|
roles: ROLE_NAMES.map((r) => ({ name: r, label: ROLE_LABEL[r], paused: state.isRolePaused(r) })),
|
|
11117
11276
|
seats: seats2
|
|
11118
11277
|
},
|
|
@@ -11156,6 +11315,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
|
|
|
11156
11315
|
route: ${route.route} -> ${route.dir}
|
|
11157
11316
|
enabled: ${route.enabled}
|
|
11158
11317
|
crew: ${state.isPaused() ? "paused" : "active"}
|
|
11318
|
+
release: ${state.releaseSkipReason() ? "paused" : "active"}
|
|
11159
11319
|
watermark: ${state.watermark()}
|
|
11160
11320
|
`
|
|
11161
11321
|
);
|
|
@@ -11220,6 +11380,14 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
|
|
|
11220
11380
|
`);
|
|
11221
11381
|
}
|
|
11222
11382
|
}
|
|
11383
|
+
const missingAuthorship = await tracker.missingAuthorshipColumns();
|
|
11384
|
+
process.stdout.write(
|
|
11385
|
+
missingAuthorship.length === 0 ? "authorship: filed_by_id, ship_id present\n" : `authorship: missing ${missingAuthorship.join(", ")} \u2014 update the Issues template
|
|
11386
|
+
`
|
|
11387
|
+
);
|
|
11388
|
+
process.stdout.write(
|
|
11389
|
+
route.resolved?.releaseSeat ? "release seat: this ship has its own Release Crew row\n" : "release seat: none \u2014 release comments are credited to the dev seat; run `crew connect` once the Crew table has this ship's row\n"
|
|
11390
|
+
);
|
|
11223
11391
|
const [ships, crewRows] = await Promise.all([tracker.shipRows(), tracker.crewRows()]);
|
|
11224
11392
|
if (ships.length === 0) {
|
|
11225
11393
|
process.stdout.write("ship record: this workspace has no Ships table (fine)\n");
|
|
@@ -11357,4 +11525,5 @@ var parseTicketNumber2;
|
|
|
11357
11525
|
var passengerApiKeys2;
|
|
11358
11526
|
var endpointApiFor2;
|
|
11359
11527
|
var endpointReconcileDeps2;
|
|
11528
|
+
var releaseRefused2;
|
|
11360
11529
|
var reportUnplaceable2;
|
package/package.json
CHANGED
|
@@ -619,6 +619,11 @@ filed without them has nowhere to route to — it sits unclaimed by any
|
|
|
619
619
|
per-repo queue until a person notices and fixes it by hand, however
|
|
620
620
|
urgent its priority.
|
|
621
621
|
|
|
622
|
+
If that block also lists `filed_by_id` (your own Crew row) or `ship_id` (the
|
|
623
|
+
ship you are running on), set those too, and `ship_id` on every comment you
|
|
624
|
+
post. They record who filed the ticket and from where. The block lists a column
|
|
625
|
+
only when this workspace has it, so a column it does not name is not yours to set.
|
|
626
|
+
|
|
622
627
|
Name the ticket you were working when you noticed it in the new ticket's
|
|
623
628
|
description. Only put it in the new ticket's `blocked by` column if your
|
|
624
629
|
own ticket genuinely cannot proceed without the new one being fixed
|
|
@@ -600,6 +600,11 @@ filed without them has nowhere to route to — it sits unclaimed by any
|
|
|
600
600
|
per-repo queue until a person notices and fixes it by hand, however
|
|
601
601
|
urgent its priority.
|
|
602
602
|
|
|
603
|
+
If that block also lists `filed_by_id` (your own Crew row) or `ship_id` (the
|
|
604
|
+
ship you are running on), set those too, and `ship_id` on every comment you
|
|
605
|
+
post. They record who filed the ticket and from where. The block lists a column
|
|
606
|
+
only when this workspace has it, so a column it does not name is not yours to set.
|
|
607
|
+
|
|
603
608
|
Name the ticket you were working when you noticed it in the new ticket's
|
|
604
609
|
description. Only put it in the new ticket's `blocked by` column if your
|
|
605
610
|
own ticket genuinely cannot proceed without the new one being fixed
|