@tablation/crew 0.2.0 → 0.3.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/dist/cli.js
CHANGED
|
@@ -2746,7 +2746,7 @@ function qaDigest(i) {
|
|
|
2746
2746
|
"## Current queue \u2014 built for you by the poll\n",
|
|
2747
2747
|
'\nAlready filtered to your lane: every ticket at `qa` ("Verification" \u2014\nyours, unfinished) or `fixed` (nobody has checked it yet), from both\nbuilding lanes. Ticket bodies are deliberately omitted: fetch the full\nrecord and the comments of the one ticket you actually pick up. **Do not\nre-fetch the whole tracker.** This digest comes from the same API call the\npoll just made, moments ago.\n',
|
|
2748
2748
|
'\n"new since last poll" counts comments from someone other than you since\nthe poll watermark \u2014 the same signal that woke this run.\n',
|
|
2749
|
-
"\n**`repo` is the checkout the ticket's work happens in** \u2014 an area spans\nseveral repositories, so the worktree you verify in sits beside THAT\ndirectory, not beside whichever one this session started in. A ticket\nmarked **NO CHECKOUT** is not yours: this ship has no clone of its\nrepository, and another ship may serve it.\n\n**`branch` is that repository's own branch for the ticket**, found there\nrather than derived. **MISSING** means the branch is gone
|
|
2749
|
+
"\n**`repo` is the checkout the ticket's work happens in** \u2014 an area spans\nseveral repositories, so the worktree you verify in sits beside THAT\ndirectory, not beside whichever one this session started in. A ticket\nmarked **NO CHECKOUT** is not yours: this ship has no clone of its\nrepository, and another ship may serve it.\n\n**`branch` is that repository's own branch for the ticket**, found there\nrather than derived. **`<name> (on origin only)`** means another ship built it\nand pushed it: it is testable \u2014 cut a worktree from the remote branch\n(`git worktree add <worktree> <name>`). **MISSING** means the branch is gone\nlocally AND on the remote, so there is nothing left to verify \u2014 say so on the ticket. A `\u2014` means the branch\ncould not be looked for at all, because the repo has no checkout here.\n\n**`worktree` is where that branch should be checked out**, `<repo>/../<worktree>`\n\u2014 rendered the same way the dev/design digest computes it, so you land in\nthe same directory the building lane used.\n",
|
|
2750
2750
|
"\n### Still in verification \u2014 yours, unfinished (take these first)\n",
|
|
2751
2751
|
table(i.tickets.filter((t) => t.status === "qa"), i, header, row),
|
|
2752
2752
|
"\n### Awaiting verification, in pick order\n",
|
|
@@ -2813,6 +2813,13 @@ function branches(cwd, pattern) {
|
|
|
2813
2813
|
return out ? out.split("\n").filter(Boolean) : [];
|
|
2814
2814
|
}
|
|
2815
2815
|
function branchForIssue(cwd, issueKey, templates = { name: "issue-{number}" }, render2 = (t) => t.replace(/\{number\}/g, issueKey.replace(/^\D+/, ""))) {
|
|
2816
|
+
for (const c of branchCandidates(issueKey, templates, render2)) {
|
|
2817
|
+
const found = branches(cwd, c);
|
|
2818
|
+
if (found.length) return found[0];
|
|
2819
|
+
}
|
|
2820
|
+
return null;
|
|
2821
|
+
}
|
|
2822
|
+
function branchCandidates(issueKey, templates, render2) {
|
|
2816
2823
|
const num = issueKey.replace(/^\D+/, "");
|
|
2817
2824
|
const unpadded = String(Number.parseInt(num, 10));
|
|
2818
2825
|
const candidates = [];
|
|
@@ -2827,12 +2834,31 @@ function branchForIssue(cwd, issueKey, templates = { name: "issue-{number}" }, r
|
|
|
2827
2834
|
}
|
|
2828
2835
|
candidates.push(`issue-${num}`);
|
|
2829
2836
|
if (num !== unpadded) candidates.push(`issue-${unpadded}`);
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2837
|
+
return [...new Set(candidates)];
|
|
2838
|
+
}
|
|
2839
|
+
function remoteBranchForIssue(cwd, remote, issueKey, templates = { name: "issue-{number}" }, render2 = (t) => t.replace(/\{number\}/g, issueKey.replace(/^\D+/, ""))) {
|
|
2840
|
+
const prefix = `refs/remotes/${remote}/`;
|
|
2841
|
+
for (const c of branchCandidates(issueKey, templates, render2)) {
|
|
2842
|
+
const out = gitOk(cwd, ["for-each-ref", "--format=%(refname)", `${prefix}${c}`]);
|
|
2843
|
+
const hit = out?.split("\n").filter((r) => r.startsWith(prefix) && r !== `${prefix}HEAD`)[0];
|
|
2844
|
+
if (hit) return hit.slice(prefix.length);
|
|
2833
2845
|
}
|
|
2834
2846
|
return null;
|
|
2835
2847
|
}
|
|
2848
|
+
function materializeRemoteBranch(cwd, remote, branch) {
|
|
2849
|
+
if (!remoteBranchExists(cwd, remote, branch)) {
|
|
2850
|
+
return gitOk(cwd, ["rev-parse", "-q", "--verify", `refs/heads/${branch}`]) !== null;
|
|
2851
|
+
}
|
|
2852
|
+
if (gitOk(cwd, ["rev-parse", "-q", "--verify", `refs/heads/${branch}`]) === null) {
|
|
2853
|
+
return gitOk(cwd, ["branch", "--track", branch, `${remote}/${branch}`]) !== null;
|
|
2854
|
+
}
|
|
2855
|
+
gitOk(cwd, ["fetch", "--quiet", remote, `${branch}:${branch}`]);
|
|
2856
|
+
return true;
|
|
2857
|
+
}
|
|
2858
|
+
function deleteRemoteBranch(cwd, remote, branch) {
|
|
2859
|
+
if (!remoteBranchExists(cwd, remote, branch)) return true;
|
|
2860
|
+
return gitOk(cwd, ["push", remote, "--delete", branch]) !== null;
|
|
2861
|
+
}
|
|
2836
2862
|
function worktreeForNumber(cwd, num) {
|
|
2837
2863
|
const re = new RegExp(`(?:^|-)${num}$`);
|
|
2838
2864
|
return worktrees(cwd).find((w) => re.test(basename3(w.path))) ?? null;
|
|
@@ -3138,11 +3164,32 @@ function worktreeForTicket(dir, cfg, t, role) {
|
|
|
3138
3164
|
const number = t.issue_id.replace(/^\D+/, "");
|
|
3139
3165
|
return effectiveWorktreeDirName(cfg, dir, branchName, number);
|
|
3140
3166
|
}
|
|
3141
|
-
function
|
|
3167
|
+
function branchTemplates(cfg, t) {
|
|
3142
3168
|
const { prefix } = ticketBranchContext(t);
|
|
3143
3169
|
const name = effectiveBranchTemplate(cfg, prefix);
|
|
3144
3170
|
const push = cfg.provenance["branch.push"] === "default" ? name : cfg.branch.push;
|
|
3145
|
-
return
|
|
3171
|
+
return { name, push };
|
|
3172
|
+
}
|
|
3173
|
+
function existingBranchForTicket(dir, cfg, t, role) {
|
|
3174
|
+
return branchForIssue(dir, t.issue_id, branchTemplates(cfg, t), branchRenderer(t, role));
|
|
3175
|
+
}
|
|
3176
|
+
function locateBranchForTicket(dir, cfg, t, role, fetchedDirs) {
|
|
3177
|
+
const local = existingBranchForTicket(dir, cfg, t, role);
|
|
3178
|
+
if (local) return { branch: local, where: "local" };
|
|
3179
|
+
const remote = cfg.branch.remote;
|
|
3180
|
+
if (!remoteConfigured(dir, remote)) return null;
|
|
3181
|
+
if (fetchedDirs && !fetchedDirs.has(dir)) {
|
|
3182
|
+
fetchedDirs.add(dir);
|
|
3183
|
+
fetchRemote(dir, remote);
|
|
3184
|
+
}
|
|
3185
|
+
const found = remoteBranchForIssue(dir, remote, t.issue_id, branchTemplates(cfg, t), branchRenderer(t, role));
|
|
3186
|
+
return found ? { branch: found, where: "remote" } : null;
|
|
3187
|
+
}
|
|
3188
|
+
function materializeBranchForTicket(dir, cfg, t, role) {
|
|
3189
|
+
const loc = locateBranchForTicket(dir, cfg, t, role);
|
|
3190
|
+
if (!loc) return null;
|
|
3191
|
+
materializeRemoteBranch(dir, cfg.branch.remote, loc.branch);
|
|
3192
|
+
return loc.branch;
|
|
3146
3193
|
}
|
|
3147
3194
|
|
|
3148
3195
|
// src/poll.ts
|
|
@@ -3312,6 +3359,7 @@ function writeDigest(o, d, role, stateDir) {
|
|
|
3312
3359
|
repoCache.set(dir, cfg);
|
|
3313
3360
|
return cfg;
|
|
3314
3361
|
};
|
|
3362
|
+
const fetchedDirs = /* @__PURE__ */ new Set();
|
|
3315
3363
|
const dirFor = (t) => dirForRepo(o.route, t.repo_id);
|
|
3316
3364
|
const render2 = (t) => branchRenderer(t, role);
|
|
3317
3365
|
const input = {
|
|
@@ -3336,6 +3384,10 @@ function writeDigest(o, d, role, stateDir) {
|
|
|
3336
3384
|
existingBranchFor: (t) => {
|
|
3337
3385
|
const dir = dirFor(t);
|
|
3338
3386
|
if (!dir) return null;
|
|
3387
|
+
if (role === "qa") {
|
|
3388
|
+
const loc = locateBranchForTicket(dir, repoFor(dir), t, role, fetchedDirs);
|
|
3389
|
+
return loc ? loc.where === "remote" ? `${loc.branch} (on ${repoFor(dir).branch.remote} only)` : loc.branch : null;
|
|
3390
|
+
}
|
|
3339
3391
|
return existingBranchForTicket(dir, repoFor(dir), t, role);
|
|
3340
3392
|
},
|
|
3341
3393
|
tickets: role === "triage" ? d.tickets.filter((t) => t.assignee_id === o.route.resolved?.seats.triage) : sliceFor(d.tickets, role),
|
|
@@ -3510,9 +3562,17 @@ async function applyConflictBounce(writer, b, contract, memberId, log, dryRun) {
|
|
|
3510
3562
|
|
|
3511
3563
|
// src/stranded-verified.ts
|
|
3512
3564
|
var STRANDED_MARKER = "<!-- crew:stranded-verified -->";
|
|
3565
|
+
var STRANDED_ESCALATE_AFTER_MS = 30 * 60 * 1e3;
|
|
3513
3566
|
var priorStrandedFlags = (ticket, comments) => comments.filter((c) => c.ticket_id === ticket.id && (c.body ?? "").includes(STRANDED_MARKER)).length;
|
|
3514
3567
|
function planStrandedVerified(candidate, base, remote, comments) {
|
|
3515
|
-
|
|
3568
|
+
const stamps = comments.filter((c) => c.ticket_id === candidate.ticket.id && (c.body ?? "").includes(STRANDED_MARKER)).map((c) => Date.parse(c.created_at)).filter((n) => !Number.isNaN(n));
|
|
3569
|
+
return {
|
|
3570
|
+
candidate,
|
|
3571
|
+
base,
|
|
3572
|
+
remote,
|
|
3573
|
+
priorFlags: priorStrandedFlags(candidate.ticket, comments),
|
|
3574
|
+
lastFlaggedAt: stamps.length ? Math.max(...stamps) : void 0
|
|
3575
|
+
};
|
|
3516
3576
|
}
|
|
3517
3577
|
function flagComment(s) {
|
|
3518
3578
|
const key = s.candidate.ticket.issue_id;
|
|
@@ -3528,12 +3588,16 @@ function escalateComment2(s) {
|
|
|
3528
3588
|
return `${STRANDED_MARKER}
|
|
3529
3589
|
**Still nothing to merge \u2014 stopping rather than repeating.**
|
|
3530
3590
|
|
|
3531
|
-
\`${key}\` was flagged as stranded at \`verified\` on an earlier cycle and still has no branch on this ship and no commit on \`${s.base}\` naming its key (searched \`${s.remote}/${s.base}\`). This needs a person: either the work landed under a commit that doesn't name the key and this ticket can simply close, or the branch is gone and the work needs redoing.
|
|
3591
|
+
\`${key}\` was flagged as stranded at \`verified\` on an earlier cycle and still has no branch on this ship or on \`${s.remote}\` and no commit on \`${s.base}\` naming its key (searched \`${s.remote}/${s.base}\`). This needs a person: either the work landed under a commit that doesn't name the key and this ticket can simply close, or the branch is gone and the work needs redoing.
|
|
3532
3592
|
|
|
3533
3593
|
The crew will not close this on its own.`;
|
|
3534
3594
|
}
|
|
3535
|
-
async function applyStrandedVerified(writer, s, contract, operatorId, memberId, log, dryRun) {
|
|
3595
|
+
async function applyStrandedVerified(writer, s, contract, operatorId, memberId, log, dryRun, now = Date.now()) {
|
|
3536
3596
|
const t = { ticket: s.candidate.ticket.issue_id, step: "merge" };
|
|
3597
|
+
if (s.priorFlags > 0 && s.lastFlaggedAt !== void 0 && now - s.lastFlaggedAt < STRANDED_ESCALATE_AFTER_MS) {
|
|
3598
|
+
log.emit("flagged as stranded recently \u2014 not escalating yet", t);
|
|
3599
|
+
return { kind: "waiting" };
|
|
3600
|
+
}
|
|
3537
3601
|
if (s.priorFlags > 0) {
|
|
3538
3602
|
if (dryRun) {
|
|
3539
3603
|
log.emit(`would escalate to ${contract.statuses.needsHuman} \u2014 flagged before`, t);
|
|
@@ -5313,7 +5377,7 @@ function refreshBase(o) {
|
|
|
5313
5377
|
return { ok: true };
|
|
5314
5378
|
case "fetch-failed":
|
|
5315
5379
|
o.emit.warn(`${r.detail} \u2014 releasing from what this ship already has`);
|
|
5316
|
-
return { ok: true };
|
|
5380
|
+
return { ok: true, stale: true };
|
|
5317
5381
|
case "would-fast-forward":
|
|
5318
5382
|
case "fast-forwarded":
|
|
5319
5383
|
o.emit.emit(r.detail);
|
|
@@ -5356,7 +5420,10 @@ async function runRelease(o) {
|
|
|
5356
5420
|
const decision = decideRelease(o.cwd, o.tickets, o.contract, {
|
|
5357
5421
|
tagPattern,
|
|
5358
5422
|
base: o.repo.branch.base,
|
|
5359
|
-
|
|
5423
|
+
// CREW-1364: a branch another ship built and pushed at `fixed` exists
|
|
5424
|
+
// here only on the remote. Materialize it so the squash below has a ref.
|
|
5425
|
+
// A dry run only looks.
|
|
5426
|
+
branchFor: (t) => o.dryRun ? locateBranchForTicket(o.cwd, o.repo, t)?.branch ?? null : materializeBranchForTicket(o.cwd, o.repo, t)
|
|
5360
5427
|
});
|
|
5361
5428
|
if (!fresh.ok) {
|
|
5362
5429
|
o.emit.error(scoped(`refusing to release \u2014 ${fresh.why}; a person has to reconcile the two`));
|
|
@@ -5405,7 +5472,9 @@ async function runRelease(o) {
|
|
|
5405
5472
|
);
|
|
5406
5473
|
} else {
|
|
5407
5474
|
o.emit.emit("verified but has no branch and nothing on the base names it \u2014 nothing to merge", { ticket: c.ticket.issue_id });
|
|
5408
|
-
|
|
5475
|
+
if (fresh.ok && fresh.stale) {
|
|
5476
|
+
o.emit.warn("not flagging as stranded \u2014 the remote could not be fetched, so the branch may exist there", { ticket: c.ticket.issue_id });
|
|
5477
|
+
} else unbuildable.push(c);
|
|
5409
5478
|
}
|
|
5410
5479
|
continue;
|
|
5411
5480
|
}
|
|
@@ -7199,6 +7268,7 @@ function schedulerInstalled(plan, exists, crontab) {
|
|
|
7199
7268
|
}
|
|
7200
7269
|
|
|
7201
7270
|
// src/worktree-sweep.ts
|
|
7271
|
+
import { existsSync as existsSync14 } from "fs";
|
|
7202
7272
|
import { basename as basename6 } from "path";
|
|
7203
7273
|
function planWorktreeSweep(target, tickets, contract) {
|
|
7204
7274
|
const terminal = new Set(contract.statuses.resolved.filter((s) => s !== contract.statuses.verified));
|
|
@@ -7218,10 +7288,24 @@ function planWorktreeSweep(target, tickets, contract) {
|
|
|
7218
7288
|
}
|
|
7219
7289
|
return actions;
|
|
7220
7290
|
}
|
|
7221
|
-
async function applyWorktreeSweep(cwd, actions, dryRun, log) {
|
|
7291
|
+
async function applyWorktreeSweep(cwd, actions, dryRun, log, remote = "origin") {
|
|
7222
7292
|
let removed = 0;
|
|
7223
7293
|
let keptBranches = 0;
|
|
7224
7294
|
for (const a of actions) {
|
|
7295
|
+
if (!dryRun && existsSync14(a.path)) {
|
|
7296
|
+
let dirty = false;
|
|
7297
|
+
try {
|
|
7298
|
+
dirty = status(a.path).length > 0;
|
|
7299
|
+
} catch {
|
|
7300
|
+
}
|
|
7301
|
+
if (dirty) {
|
|
7302
|
+
log.warn(
|
|
7303
|
+
`not removing worktree ${a.path} for ${a.ticket.issue_id} (${a.ticket.status}): it has uncommitted changes`,
|
|
7304
|
+
{ ticket: a.ticket.issue_id, step: "worktree" }
|
|
7305
|
+
);
|
|
7306
|
+
continue;
|
|
7307
|
+
}
|
|
7308
|
+
}
|
|
7225
7309
|
const n = Number(a.ticket.issue_id.replace(/^\D+/, ""));
|
|
7226
7310
|
const pids = pidsInWorktree(a.path, n);
|
|
7227
7311
|
if (pids.length) {
|
|
@@ -7241,7 +7325,12 @@ async function applyWorktreeSweep(cwd, actions, dryRun, log) {
|
|
|
7241
7325
|
continue;
|
|
7242
7326
|
}
|
|
7243
7327
|
gitOk(cwd, ["worktree", "remove", "--force", a.path]);
|
|
7244
|
-
if (!a.keepBranch && a.branch)
|
|
7328
|
+
if (!a.keepBranch && a.branch) {
|
|
7329
|
+
gitOk(cwd, ["branch", "-D", a.branch]);
|
|
7330
|
+
if (remoteConfigured(cwd, remote) && !deleteRemoteBranch(cwd, remote, a.branch)) {
|
|
7331
|
+
log.warn(`could not delete ${remote}/${a.branch} \u2014 leaving it for the next sweep`, { ticket: a.ticket.issue_id, step: "worktree" });
|
|
7332
|
+
}
|
|
7333
|
+
}
|
|
7245
7334
|
log.emit(
|
|
7246
7335
|
`removed worktree for ${a.ticket.issue_id} (${a.ticket.status}); ${branchNote}`,
|
|
7247
7336
|
{ ticket: a.ticket.issue_id, step: "worktree" }
|
|
@@ -7250,14 +7339,53 @@ async function applyWorktreeSweep(cwd, actions, dryRun, log) {
|
|
|
7250
7339
|
}
|
|
7251
7340
|
return { removed, keptBranches };
|
|
7252
7341
|
}
|
|
7342
|
+
function planRemoteBranchCleanup(cwd, remote, base, tickets, contract) {
|
|
7343
|
+
if (!remoteConfigured(cwd, remote)) return [];
|
|
7344
|
+
const stems = /* @__PURE__ */ new Set();
|
|
7345
|
+
for (const t of tickets) {
|
|
7346
|
+
if (t.status !== contract.statuses.deployed) continue;
|
|
7347
|
+
const num = t.issue_id.replace(/^\D+/, "");
|
|
7348
|
+
const unpadded = String(Number.parseInt(num, 10));
|
|
7349
|
+
const prefixes = ["issue", t.project_issue_prefix?.toLowerCase()].filter((p) => !!p);
|
|
7350
|
+
for (const p of prefixes) {
|
|
7351
|
+
stems.add(`${p}-${num}`);
|
|
7352
|
+
stems.add(`${p}-${unpadded}`);
|
|
7353
|
+
}
|
|
7354
|
+
}
|
|
7355
|
+
if (stems.size === 0) return [];
|
|
7356
|
+
const prefix = `refs/remotes/${remote}/`;
|
|
7357
|
+
const refs = (gitOk(cwd, ["for-each-ref", "--format=%(refname)", prefix]) ?? "").split("\n").filter(Boolean);
|
|
7358
|
+
const out = [];
|
|
7359
|
+
for (const ref of refs) {
|
|
7360
|
+
const name = ref.slice(prefix.length);
|
|
7361
|
+
if (name === "HEAD" || name === base) continue;
|
|
7362
|
+
const m = /^(.+?-\d+)(?:-.*)?$/.exec(name);
|
|
7363
|
+
if (m && stems.has(m[1])) out.push(name);
|
|
7364
|
+
}
|
|
7365
|
+
return out;
|
|
7366
|
+
}
|
|
7367
|
+
function applyRemoteBranchCleanup(cwd, remote, branches2, dryRun, log) {
|
|
7368
|
+
let deleted = 0;
|
|
7369
|
+
for (const b of branches2) {
|
|
7370
|
+
if (dryRun) {
|
|
7371
|
+
log.emit(`would delete ${remote}/${b} \u2014 its ticket is deployed`, { step: "worktree" });
|
|
7372
|
+
continue;
|
|
7373
|
+
}
|
|
7374
|
+
if (deleteRemoteBranch(cwd, remote, b)) {
|
|
7375
|
+
deleted++;
|
|
7376
|
+
log.emit(`deleted ${remote}/${b} \u2014 its ticket is deployed`, { step: "worktree" });
|
|
7377
|
+
} else log.warn(`could not delete ${remote}/${b} \u2014 leaving it for the next sweep`, { step: "worktree" });
|
|
7378
|
+
}
|
|
7379
|
+
return deleted;
|
|
7380
|
+
}
|
|
7253
7381
|
|
|
7254
7382
|
// src/stream-sweep.ts
|
|
7255
|
-
import { existsSync as
|
|
7383
|
+
import { existsSync as existsSync15, readdirSync as readdirSync2, rmSync as rmSync2, statSync as statSync4 } from "fs";
|
|
7256
7384
|
import { join as join13 } from "path";
|
|
7257
7385
|
var DAY_MS = 24 * 60 * 60 * 1e3;
|
|
7258
7386
|
function planStreamSweep(stateDir, retentionDays, now = Date.now()) {
|
|
7259
7387
|
const dir = join13(stateDir, "streams");
|
|
7260
|
-
if (!
|
|
7388
|
+
if (!existsSync15(dir)) return [];
|
|
7261
7389
|
const cutoff = now - retentionDays * DAY_MS;
|
|
7262
7390
|
return readdirSync2(dir).map((name) => join13(dir, name)).filter((path) => {
|
|
7263
7391
|
try {
|
|
@@ -7286,13 +7414,13 @@ function applyStreamSweep(paths, dryRun, log) {
|
|
|
7286
7414
|
}
|
|
7287
7415
|
|
|
7288
7416
|
// src/cli.ts
|
|
7289
|
-
import { readFileSync as readFileSync12, existsSync as
|
|
7417
|
+
import { readFileSync as readFileSync12, existsSync as existsSync19, mkdirSync as mkdirSync10, writeFileSync as writeFileSync9, unlinkSync as unlinkSync5, copyFileSync, renameSync } from "fs";
|
|
7290
7418
|
import { createInterface } from "readline/promises";
|
|
7291
7419
|
import { dirname as dirOf, resolve as resolvePath } from "path";
|
|
7292
7420
|
|
|
7293
7421
|
// src/ssh-keys.ts
|
|
7294
7422
|
import { execFileSync as execFileSync3 } from "child_process";
|
|
7295
|
-
import { existsSync as
|
|
7423
|
+
import { existsSync as existsSync16, mkdirSync as mkdirSync7, readFileSync as readFileSync9, chmodSync } from "fs";
|
|
7296
7424
|
import { dirname as dirname8, join as join14 } from "path";
|
|
7297
7425
|
var SshKeyError = class extends Error {
|
|
7298
7426
|
};
|
|
@@ -7302,7 +7430,7 @@ function shipSshKeyPathFor(stateDir) {
|
|
|
7302
7430
|
function ensureShipSshKeypair(stateDir) {
|
|
7303
7431
|
const privateKeyPath = shipSshKeyPathFor(stateDir);
|
|
7304
7432
|
const publicKeyPath = `${privateKeyPath}.pub`;
|
|
7305
|
-
if (!
|
|
7433
|
+
if (!existsSync16(privateKeyPath)) {
|
|
7306
7434
|
mkdirSync7(dirname8(privateKeyPath), { recursive: true });
|
|
7307
7435
|
try {
|
|
7308
7436
|
execFileSync3("ssh-keygen", [
|
|
@@ -7323,7 +7451,7 @@ function ensureShipSshKeypair(stateDir) {
|
|
|
7323
7451
|
}
|
|
7324
7452
|
chmodSync(privateKeyPath, 384);
|
|
7325
7453
|
}
|
|
7326
|
-
if (!
|
|
7454
|
+
if (!existsSync16(publicKeyPath)) {
|
|
7327
7455
|
throw new SshKeyError(`${privateKeyPath} exists but ${publicKeyPath} does not \u2014 regenerate or restore it`);
|
|
7328
7456
|
}
|
|
7329
7457
|
const publicKey = readFileSync9(publicKeyPath, "utf8").trim();
|
|
@@ -7516,7 +7644,7 @@ function syncAllPassengerCheckouts(cfg) {
|
|
|
7516
7644
|
|
|
7517
7645
|
// src/tunnel.ts
|
|
7518
7646
|
import { spawn as spawn3 } from "child_process";
|
|
7519
|
-
import { existsSync as
|
|
7647
|
+
import { existsSync as existsSync17, mkdirSync as mkdirSync8, readFileSync as readFileSync10, writeFileSync as writeFileSync7, unlinkSync as unlinkSync3, readdirSync as readdirSync3 } from "fs";
|
|
7520
7648
|
import { randomInt } from "crypto";
|
|
7521
7649
|
import { join as join15 } from "path";
|
|
7522
7650
|
var DEFAULT_SPAWN_OPTIONS = { stdio: "ignore", detached: true };
|
|
@@ -7589,7 +7717,7 @@ function tunnelStatePath(stateDir, workspaceId) {
|
|
|
7589
7717
|
}
|
|
7590
7718
|
function readPersistedTunnel(stateDir, workspaceId) {
|
|
7591
7719
|
const path = tunnelStatePath(stateDir, workspaceId);
|
|
7592
|
-
if (!
|
|
7720
|
+
if (!existsSync17(path)) return void 0;
|
|
7593
7721
|
try {
|
|
7594
7722
|
return JSON.parse(readFileSync10(path, "utf8"));
|
|
7595
7723
|
} catch {
|
|
@@ -7697,7 +7825,7 @@ function syncPassengerTunnels(plans, relayHost, relayPort, privateKeyPath, state
|
|
|
7697
7825
|
}
|
|
7698
7826
|
function readAllPersistedWorkspaceIds(stateDir) {
|
|
7699
7827
|
const dir = join15(stateDir, "passengers");
|
|
7700
|
-
if (!
|
|
7828
|
+
if (!existsSync17(dir)) return [];
|
|
7701
7829
|
return readdirSync3(dir).filter((f) => f.endsWith(".json") && !f.endsWith("-sync.json")).map((f) => f.slice(0, -".json".length));
|
|
7702
7830
|
}
|
|
7703
7831
|
|
|
@@ -7818,7 +7946,7 @@ async function unregisterEndpoint(workspaceId, info, d) {
|
|
|
7818
7946
|
import { createServer as createHttpServer } from "http";
|
|
7819
7947
|
import { spawn as spawn4 } from "child_process";
|
|
7820
7948
|
import { randomBytes } from "crypto";
|
|
7821
|
-
import { existsSync as
|
|
7949
|
+
import { existsSync as existsSync18, mkdirSync as mkdirSync9, readFileSync as readFileSync11, writeFileSync as writeFileSync8, unlinkSync as unlinkSync4, readdirSync as readdirSync4 } from "fs";
|
|
7822
7950
|
import { join as join16 } from "path";
|
|
7823
7951
|
|
|
7824
7952
|
// src/secret-compare.ts
|
|
@@ -7912,7 +8040,7 @@ function syncDaemonStatePath(stateDir, workspaceId) {
|
|
|
7912
8040
|
}
|
|
7913
8041
|
function readPersistedSyncDaemon(stateDir, workspaceId) {
|
|
7914
8042
|
const path = syncDaemonStatePath(stateDir, workspaceId);
|
|
7915
|
-
if (!
|
|
8043
|
+
if (!existsSync18(path)) return void 0;
|
|
7916
8044
|
try {
|
|
7917
8045
|
return JSON.parse(readFileSync11(path, "utf8"));
|
|
7918
8046
|
} catch {
|
|
@@ -7943,7 +8071,7 @@ function waitForExit(pid, alive, timeoutMs = 5e3) {
|
|
|
7943
8071
|
}
|
|
7944
8072
|
function readAllPersistedSyncWorkspaceIds(stateDir) {
|
|
7945
8073
|
const dir = join16(stateDir, "passengers");
|
|
7946
|
-
if (!
|
|
8074
|
+
if (!existsSync18(dir)) return [];
|
|
7947
8075
|
const suffix = "-sync.json";
|
|
7948
8076
|
return readdirSync4(dir).filter((f) => f.endsWith(suffix)).map((f) => f.slice(0, -suffix.length));
|
|
7949
8077
|
}
|
|
@@ -8655,7 +8783,7 @@ if (command === "passenger-sync-daemon") {
|
|
|
8655
8783
|
if (host === "linux" && hasSystemd) {
|
|
8656
8784
|
const label = labelFor(CREW_HOME, "run");
|
|
8657
8785
|
const staleTimer = join18(homedir4(), ".config", "systemd", "user", `${label}.timer`);
|
|
8658
|
-
if (
|
|
8786
|
+
if (existsSync19(staleTimer)) {
|
|
8659
8787
|
process.stdout.write(
|
|
8660
8788
|
`removing stale run timer from before the persistent-service cutover: ${staleTimer}${dryRun ? " (dry run)" : ""}
|
|
8661
8789
|
`
|
|
@@ -8680,7 +8808,7 @@ if (command === "passenger-sync-daemon") {
|
|
|
8680
8808
|
const tracker = new Tracker(c, cfg.ship);
|
|
8681
8809
|
problems.push(...validateContract(tracker.contract).map((p) => `contract: ${p}`));
|
|
8682
8810
|
for (const r of await resolvedRepos(c)) {
|
|
8683
|
-
if (!
|
|
8811
|
+
if (!existsSync19(r.dir)) problems.push(`repo ${r.name}: ${r.dir} is missing on disk`);
|
|
8684
8812
|
if (!satisfies(host, r.config.platform)) problems.push(`repo ${r.name}: requires ${r.config.platform}, this host is ${host}`);
|
|
8685
8813
|
problems.push(...validateEffective(r.config).map((p) => `repo ${r.name}: ${p}`));
|
|
8686
8814
|
}
|
|
@@ -8698,7 +8826,7 @@ if (command === "passenger-sync-daemon") {
|
|
|
8698
8826
|
}
|
|
8699
8827
|
async function doctorFix(o) {
|
|
8700
8828
|
const routes = await Promise.all(routesInScope(cfg.routes, named).map((c) => gatherHealth({ route: c.route, enabled: c.enabled, hasRepos: reposOf(c).length > 0 }, () => routeProblems(c))));
|
|
8701
|
-
const unitExists = (p) =>
|
|
8829
|
+
const unitExists = (p) => existsSync19(p);
|
|
8702
8830
|
const crontab = o.host === "linux" ? spawnSync2("crontab", ["-l"], { encoding: "utf8" }).stdout ?? "" : "";
|
|
8703
8831
|
const schedulerAbsent = o.host !== "windows" ? !schedulerInstalled(planInstall(cfg.ship, CREW_HOME, o.host, detectSystemd(), "run"), unitExists, crontab) : true;
|
|
8704
8832
|
const plan = planFix({ routes, schedulerAbsent, host: o.host });
|
|
@@ -8918,9 +9046,11 @@ ${changed2.map((c) => ` - ${c}
|
|
|
8918
9046
|
const actions = planWorktreeSweep(target, scoped, tracker.contract);
|
|
8919
9047
|
if (actions.length) {
|
|
8920
9048
|
remit.enter("worktree");
|
|
8921
|
-
const r = await applyWorktreeSweep(target.dir, actions, dryRun, remit);
|
|
9049
|
+
const r = await applyWorktreeSweep(target.dir, actions, dryRun, remit, repo.branch.remote);
|
|
8922
9050
|
remit.emit(`swept ${r.removed} worktree(s), kept ${r.keptBranches} branch(es)`, { step: "worktree" });
|
|
8923
9051
|
}
|
|
9052
|
+
const stale = planRemoteBranchCleanup(target.dir, repo.branch.remote, repo.branch.base, scoped, tracker.contract);
|
|
9053
|
+
if (stale.length) applyRemoteBranchCleanup(target.dir, repo.branch.remote, stale, dryRun, remit);
|
|
8924
9054
|
} catch (e) {
|
|
8925
9055
|
remit.warn(`worktree sweep failed: ${e.message}`, { step: "worktree" });
|
|
8926
9056
|
}
|
|
@@ -9574,7 +9704,7 @@ ${changed2.map((c) => ` - ${c}
|
|
|
9574
9704
|
`);
|
|
9575
9705
|
if (!dryRun) {
|
|
9576
9706
|
const resolvedPath = resolvedPathFor(cfg.ship.stateDir, target.route);
|
|
9577
|
-
const raw =
|
|
9707
|
+
const raw = existsSync19(resolvedPath) ? JSON.parse(readFileSync12(resolvedPath, "utf8")) : {};
|
|
9578
9708
|
raw.agentPersonas = personaResult.agentPersonas;
|
|
9579
9709
|
raw.agentSkills = skillResult.agentSkills;
|
|
9580
9710
|
mkdirSync10(dirOf(resolvedPath), { recursive: true });
|
|
@@ -9623,7 +9753,7 @@ ${changed2.map((c) => ` - ${c}
|
|
|
9623
9753
|
`);
|
|
9624
9754
|
if (!dryRun) {
|
|
9625
9755
|
const resolvedPath = resolvedPathFor(cfg.ship.stateDir, target.route);
|
|
9626
|
-
const raw =
|
|
9756
|
+
const raw = existsSync19(resolvedPath) ? JSON.parse(readFileSync12(resolvedPath, "utf8")) : {};
|
|
9627
9757
|
raw.agentSkills = result.agentSkills;
|
|
9628
9758
|
mkdirSync10(dirOf(resolvedPath), { recursive: true });
|
|
9629
9759
|
writeFileSync9(resolvedPath, `${JSON.stringify(raw, null, 2)}
|
|
@@ -9646,7 +9776,7 @@ ${changed2.map((c) => ` - ${c}
|
|
|
9646
9776
|
if (sub === "list" && !routeName) usage2();
|
|
9647
9777
|
if (sub === "add") {
|
|
9648
9778
|
try {
|
|
9649
|
-
const a = parseAddArgs(positional.slice(2), routeNamesOf(configText), (p) =>
|
|
9779
|
+
const a = parseAddArgs(positional.slice(2), routeNamesOf(configText), (p) => existsSync19(resolvePath(p.replace(/^~(?=\/|$)/, homedir4()))));
|
|
9650
9780
|
routeName = a.route;
|
|
9651
9781
|
addPath = a.path;
|
|
9652
9782
|
addName = a.name;
|
|
@@ -9661,7 +9791,7 @@ ${changed2.map((c) => ` - ${c}
|
|
|
9661
9791
|
}
|
|
9662
9792
|
const resolvedFile = resolvedPathFor(cfg.ship.stateDir, routeName);
|
|
9663
9793
|
let resolvedRemotes;
|
|
9664
|
-
if (
|
|
9794
|
+
if (existsSync19(resolvedFile)) {
|
|
9665
9795
|
try {
|
|
9666
9796
|
resolvedRemotes = JSON.parse(readFileSync12(resolvedFile, "utf8"));
|
|
9667
9797
|
} catch {
|
|
@@ -9678,14 +9808,14 @@ ${changed2.map((c) => ` - ${c}
|
|
|
9678
9808
|
for (const r of repos) {
|
|
9679
9809
|
const origin2 = originOf(r.dir);
|
|
9680
9810
|
const row = matchRepoName(origin2, resolvedRemotes);
|
|
9681
|
-
const note = !
|
|
9811
|
+
const note = !existsSync19(r.dir) ? "missing on disk" : row ? `Repos row: ${row}` : `no Repos row matches ${normalizeRemote(origin2) ?? "this checkout (no origin remote)"}`;
|
|
9682
9812
|
process.stdout.write(`${r.name} ${r.dir} ${note}
|
|
9683
9813
|
`);
|
|
9684
9814
|
}
|
|
9685
9815
|
break;
|
|
9686
9816
|
}
|
|
9687
9817
|
const target = resolvePath(addPath.replace(/^~(?=\/|$)/, homedir4()));
|
|
9688
|
-
const top =
|
|
9818
|
+
const top = existsSync19(target) ? gitOk(target, ["rev-parse", "--show-toplevel"]) : null;
|
|
9689
9819
|
if (!top) throw new ReposError(`${target} is not a git checkout`);
|
|
9690
9820
|
const origin = originOf(top);
|
|
9691
9821
|
const plan = planRepoAdd({
|
|
@@ -9733,7 +9863,7 @@ ${changed2.map((c) => ` - ${c}
|
|
|
9733
9863
|
const created = await rowTracker.createRepoRow(rowPayload);
|
|
9734
9864
|
process.stdout.write(`created Repos row ${String(rowPayload.name)} (${String(rowPayload.remote)}) ${created.id}
|
|
9735
9865
|
`);
|
|
9736
|
-
if (
|
|
9866
|
+
if (existsSync19(resolvedFile)) {
|
|
9737
9867
|
const raw = JSON.parse(readFileSync12(resolvedFile, "utf8"));
|
|
9738
9868
|
raw.repoNames = { ...raw.repoNames, [created.id]: String(rowPayload.name) };
|
|
9739
9869
|
raw.repoRemotes = { ...raw.repoRemotes, [created.id]: String(rowPayload.remote) };
|
|
@@ -9966,7 +10096,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
|
|
|
9966
10096
|
operator = await pickOperator(found.holds);
|
|
9967
10097
|
}
|
|
9968
10098
|
let previous;
|
|
9969
|
-
const hadPreviousFile =
|
|
10099
|
+
const hadPreviousFile = existsSync19(resolvedPath);
|
|
9970
10100
|
if (hadPreviousFile) {
|
|
9971
10101
|
try {
|
|
9972
10102
|
previous = JSON.parse(readFileSync12(resolvedPath, "utf8"));
|
|
@@ -10222,9 +10352,14 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
|
|
|
10222
10352
|
const { byRepo } = ticketsByRepo(route, terminal);
|
|
10223
10353
|
for (const r of await resolvedRepos(route)) {
|
|
10224
10354
|
const actions = planWorktreeSweep(r, byRepo.get(r.name) ?? [], tracker.contract);
|
|
10355
|
+
const stale = planRemoteBranchCleanup(r.dir, r.config.branch.remote, r.config.branch.base, byRepo.get(r.name) ?? [], tracker.contract);
|
|
10356
|
+
if (stale.length) {
|
|
10357
|
+
anything = true;
|
|
10358
|
+
applyRemoteBranchCleanup(r.dir, r.config.branch.remote, stale, dryRun, emit);
|
|
10359
|
+
}
|
|
10225
10360
|
if (!actions.length) continue;
|
|
10226
10361
|
anything = true;
|
|
10227
|
-
const res = await applyWorktreeSweep(r.dir, actions, dryRun, emit);
|
|
10362
|
+
const res = await applyWorktreeSweep(r.dir, actions, dryRun, emit, r.config.branch.remote);
|
|
10228
10363
|
emit.emit(
|
|
10229
10364
|
`swept ${res.removed} worktree(s) for ${r.name}, kept ${res.keptBranches} branch(es)`,
|
|
10230
10365
|
{ step: "worktree" }
|
|
@@ -10280,8 +10415,17 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
|
|
|
10280
10415
|
break;
|
|
10281
10416
|
}
|
|
10282
10417
|
gitOk(target.dir, ["worktree", "remove", "--force", wt]);
|
|
10283
|
-
if (branch)
|
|
10284
|
-
|
|
10418
|
+
if (branch) {
|
|
10419
|
+
gitOk(target.dir, ["branch", "-D", branch]);
|
|
10420
|
+
const { remote, base } = target.config.branch;
|
|
10421
|
+
if (remoteConfigured(target.dir, remote) && remoteBranchExists(target.dir, remote, branch)) {
|
|
10422
|
+
if (findKeyInRange(target.dir, `ISSUE-${n}`, `${remote}/${base}`)) {
|
|
10423
|
+
emit.emit(deleteRemoteBranch(target.dir, remote, branch) ? `deleted ${remote}/${branch}` : `could not delete ${remote}/${branch}`);
|
|
10424
|
+
} else {
|
|
10425
|
+
emit.warn(`${remote}/${branch} kept \u2014 nothing on ${remote}/${base} names ISSUE-${n} yet, so it may be the only copy`);
|
|
10426
|
+
}
|
|
10427
|
+
}
|
|
10428
|
+
} else emit.warn(`no branch found for ISSUE-${n} in ${target.name} \u2014 worktree removed, nothing to delete`);
|
|
10285
10429
|
emit.emit(`dropped ISSUE-${n} from ${target.name}`);
|
|
10286
10430
|
break;
|
|
10287
10431
|
}
|
package/package.json
CHANGED
|
@@ -191,6 +191,12 @@ since your last comment: an answer to a question, new direction, or a
|
|
|
191
191
|
comment is what clears you to act again, not the flag itself. Clearing
|
|
192
192
|
`needs_planning`/`needs_review` is always the operator's move, never
|
|
193
193
|
yours, whatever you find when you resume.
|
|
194
|
+
- **Before resuming in an existing worktree, run `crew sync`** (it fetches and
|
|
195
|
+
fast-forwards every worktree branch that tracks a remote, never merging or
|
|
196
|
+
rebasing) — another ship may have pushed to the branch since you left it.
|
|
197
|
+
If it reports your branch as diverged (local commits not on the remote AND
|
|
198
|
+
the remote has moved), stop: comment on the ticket with what it printed and
|
|
199
|
+
set `needs_info`; do not merge or rebase it yourself.
|
|
194
200
|
- A ticket QA has bounced back to you comes in as `in_progress`,
|
|
195
201
|
reassigned to your row, with a comment saying what still fails. Treat
|
|
196
202
|
that exactly like new direction from a hold: read the comment, fix what
|
|
@@ -520,6 +526,15 @@ if `report_type` is either of those.
|
|
|
520
526
|
part).
|
|
521
527
|
9. Set the ticket's `status` to `fixed` once you have verified it yourself,
|
|
522
528
|
and **clear `assignee_id`** — that pair is the hand-off to the QA lane.
|
|
529
|
+
**Push the branch first, in the same step:**
|
|
530
|
+
`git push --set-upstream <remote> <branch>` from the worktree (the remote is
|
|
531
|
+
`origin` unless the repo's `.crew.yaml` says otherwise; a repo with no remote
|
|
532
|
+
configured has nothing to push to, so say so in your comment and skip it).
|
|
533
|
+
Another ship may be the one that verifies and releases this ticket, and a
|
|
534
|
+
branch that never left this machine looks MISSING to it. A bounce back to
|
|
535
|
+
`in_progress` and the next `fixed` push again — a plain fast-forward, never
|
|
536
|
+
`--force`; if the push is rejected, someone else moved the branch, so stop
|
|
537
|
+
and say so on the ticket instead of forcing it.
|
|
523
538
|
Leave the worktree and branch exactly where they are, unmerged: QA boots
|
|
524
539
|
*your worktree* on its derived ports to test the fix, so removing it
|
|
525
540
|
would leave QA nothing to test. Your last progress comment is what QA
|
|
@@ -50,6 +50,12 @@ human review, so "probably fine" is not a pass.
|
|
|
50
50
|
Does the change actually do what the comment claims? Does it handle the
|
|
51
51
|
empty/error/permission path, or only the happy one? Does it touch
|
|
52
52
|
anything the ticket never mentioned?
|
|
53
|
+
**A branch on the remote is testable.** The builder may be on another ship:
|
|
54
|
+
the digest's `branch` column then reads `<name> (on origin only)` and no
|
|
55
|
+
worktree exists here yet. Cut one from the remote branch —
|
|
56
|
+
`git worktree add <the digest's worktree> <name>` from the ticket's repo
|
|
57
|
+
checkout (git creates the local tracking branch) — and carry on. Only
|
|
58
|
+
**MISSING** (gone locally and on the remote) means there is nothing to test.
|
|
53
59
|
5. **Run it.** The builder left its worktree in place for you: `cd` into it,
|
|
54
60
|
`nvm use` if the repository pins a version, and `eval` the **`ports`** hook
|
|
55
61
|
for this worktree's own ports (anything already listening on them is a dead
|
|
@@ -172,6 +172,12 @@ since your last comment: an answer to a question, new direction, or a
|
|
|
172
172
|
to leave such tickets alone, which — combined with the poll not waking
|
|
173
173
|
for them — left ISSUE-049 parked for hours mid-implementation with no
|
|
174
174
|
question outstanding, restartable only by someone commenting on it.
|
|
175
|
+
- **Before resuming in an existing worktree, run `crew sync`** (it fetches and
|
|
176
|
+
fast-forwards every worktree branch that tracks a remote, never merging or
|
|
177
|
+
rebasing) — another ship may have pushed to the branch since you left it.
|
|
178
|
+
If it reports your branch as diverged (local commits not on the remote AND
|
|
179
|
+
the remote has moved), stop: comment on the ticket with what it printed and
|
|
180
|
+
set `needs_info`; do not merge or rebase it yourself.
|
|
175
181
|
- A ticket QA has bounced back to you comes in as `in_progress`,
|
|
176
182
|
reassigned to your row, with a comment saying what still fails. Treat
|
|
177
183
|
that exactly like new direction from a hold: read the comment, fix what
|
|
@@ -501,6 +507,15 @@ if `report_type` is either of those.
|
|
|
501
507
|
part).
|
|
502
508
|
9. Set the ticket's `status` to `fixed` once you have verified it yourself,
|
|
503
509
|
and **clear `assignee_id`** — that pair is the hand-off to the QA lane.
|
|
510
|
+
**Push the branch first, in the same step:**
|
|
511
|
+
`git push --set-upstream <remote> <branch>` from the worktree (the remote is
|
|
512
|
+
`origin` unless the repo's `.crew.yaml` says otherwise; a repo with no remote
|
|
513
|
+
configured has nothing to push to, so say so in your comment and skip it).
|
|
514
|
+
Another ship may be the one that verifies and releases this ticket, and a
|
|
515
|
+
branch that never left this machine looks MISSING to it. A bounce back to
|
|
516
|
+
`in_progress` and the next `fixed` push again — a plain fast-forward, never
|
|
517
|
+
`--force`; if the push is rejected, someone else moved the branch, so stop
|
|
518
|
+
and say so on the ticket instead of forcing it.
|
|
504
519
|
Leave the worktree and branch exactly where they are, unmerged: QA boots
|
|
505
520
|
*your worktree* on its derived ports to test the fix, so removing it
|
|
506
521
|
would leave QA nothing to test. Your last progress comment is what QA
|