@tablation/crew 0.16.0 → 0.18.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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
|
-
import { resolve as resolve4, dirname as
|
|
4
|
+
import { resolve as resolve4, dirname as dirname11, join as join20, basename as basename7 } from "path";
|
|
5
5
|
import { fileURLToPath } from "url";
|
|
6
6
|
import { hostname as hostname2, homedir as homedir5 } from "os";
|
|
7
7
|
import { spawnSync as spawnSync3 } from "child_process";
|
|
@@ -1796,7 +1796,9 @@ var DEFAULT_CONTRACT = {
|
|
|
1796
1796
|
epic: "epic_id",
|
|
1797
1797
|
author: "filed_by_id",
|
|
1798
1798
|
ship: "ship_id",
|
|
1799
|
-
heldBy: "held_by_ship_id"
|
|
1799
|
+
heldBy: "held_by_ship_id",
|
|
1800
|
+
verifiedSha: "verified_sha",
|
|
1801
|
+
prRef: "pr_ref"
|
|
1800
1802
|
},
|
|
1801
1803
|
comments: {
|
|
1802
1804
|
parent: "ticket_id",
|
|
@@ -2782,7 +2784,8 @@ async function detectClosure(o) {
|
|
|
2782
2784
|
const code = await o.mergedHook({
|
|
2783
2785
|
CREW_TICKET: o.key,
|
|
2784
2786
|
CREW_BRANCH: o.pushedBranch,
|
|
2785
|
-
CREW_BASE: base
|
|
2787
|
+
CREW_BASE: base,
|
|
2788
|
+
...o.pr ? { CREW_PR: o.pr } : {}
|
|
2786
2789
|
});
|
|
2787
2790
|
if (code === 0) {
|
|
2788
2791
|
const mergedAt = findKeyOnBase(o.cwd, [o.key, ...aliases], remote, base, o.since) ?? void 0;
|
|
@@ -2952,8 +2955,8 @@ var CHECKOUT_LOCK_STALE_MS = 5 * 60 * 1e3;
|
|
|
2952
2955
|
function checkoutLockPath(dir) {
|
|
2953
2956
|
return `${dir}.sync.lock`;
|
|
2954
2957
|
}
|
|
2955
|
-
function sleepSync(
|
|
2956
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0,
|
|
2958
|
+
function sleepSync(ms2) {
|
|
2959
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms2);
|
|
2957
2960
|
}
|
|
2958
2961
|
function acquireCheckoutLock(dir, timeoutMs = 3e4) {
|
|
2959
2962
|
const lockPath = checkoutLockPath(dir);
|
|
@@ -3046,6 +3049,29 @@ function baseAttention(repo, outcome, remote, base, nowIso = (/* @__PURE__ */ ne
|
|
|
3046
3049
|
if (outcome.action === "level" || outcome.action === "fast-forwarded") return { clear: baseUnsafeKey(repo) };
|
|
3047
3050
|
return null;
|
|
3048
3051
|
}
|
|
3052
|
+
function repoStopFor(items, route) {
|
|
3053
|
+
const stops = new Map(items.filter((i) => i.kind === "base_unsafe").map((i) => [i.key, i.message]));
|
|
3054
|
+
if (stops.size === 0) return () => null;
|
|
3055
|
+
return (t) => {
|
|
3056
|
+
const name = repoTargetFor(route, t.repo_id)?.name;
|
|
3057
|
+
return name ? stops.get(baseUnsafeKey(name)) ?? null : null;
|
|
3058
|
+
};
|
|
3059
|
+
}
|
|
3060
|
+
function recheckBaseStops(state, route) {
|
|
3061
|
+
const open = state.shipAttention().previous().filter((i) => i.kind === "base_unsafe");
|
|
3062
|
+
const cleared = [];
|
|
3063
|
+
for (const item of open) {
|
|
3064
|
+
const target = reposOf(route).find((t) => baseUnsafeKey(t.name) === item.key);
|
|
3065
|
+
if (!target) continue;
|
|
3066
|
+
const cfg = resolveRepoConfig(loadRepoConfig(target.dir), void 0, target.dir);
|
|
3067
|
+
const outcome = refreshBaseBranch(target.dir, cfg.branch.remote, cfg.branch.base, true);
|
|
3068
|
+
if (outcome.action === "level" || outcome.action === "would-fast-forward") {
|
|
3069
|
+
clearShipAttention(state, item.key);
|
|
3070
|
+
cleared.push(target.name);
|
|
3071
|
+
}
|
|
3072
|
+
}
|
|
3073
|
+
return cleared;
|
|
3074
|
+
}
|
|
3049
3075
|
function openShipAttention(state, nowMs = Date.now()) {
|
|
3050
3076
|
const stale = staleRelease(state.releaseHeartbeat().at(), nowMs);
|
|
3051
3077
|
const stored = state.shipAttention().previous();
|
|
@@ -3122,7 +3148,7 @@ function releaseLockScope(route, repo) {
|
|
|
3122
3148
|
}
|
|
3123
3149
|
async function acquireBoardLock(client2, modelId, scope, holderLabel, ttlMs) {
|
|
3124
3150
|
if (!modelId) return { ok: true, release: async () => {
|
|
3125
|
-
} };
|
|
3151
|
+
}, exclusion: "off" };
|
|
3126
3152
|
const filters = JSON.stringify([{ columnName: "scope", operator: "EQ", value: scope }]);
|
|
3127
3153
|
const rows = await client2.list(modelId, { filters, limit: 1 });
|
|
3128
3154
|
const row = rows[0];
|
|
@@ -3139,6 +3165,7 @@ async function acquireBoardLock(client2, modelId, scope, holderLabel, ttlMs) {
|
|
|
3139
3165
|
}
|
|
3140
3166
|
return {
|
|
3141
3167
|
ok: true,
|
|
3168
|
+
exclusion: "taken",
|
|
3142
3169
|
release: async () => {
|
|
3143
3170
|
try {
|
|
3144
3171
|
await client2.update(modelId, row.id, { holder: "" }, claimed.updated_at);
|
|
@@ -3605,6 +3632,18 @@ var Tracker = class {
|
|
|
3605
3632
|
shipId: auth.shipId
|
|
3606
3633
|
};
|
|
3607
3634
|
}
|
|
3635
|
+
/**
|
|
3636
|
+
* Which of the optional review columns (`verified_sha`, `pr_ref`) the Issues
|
|
3637
|
+
* table carries. A table without them has not adopted the review hand-off:
|
|
3638
|
+
* the crew then pushes and moves the ticket without recording either.
|
|
3639
|
+
*/
|
|
3640
|
+
async reviewColumns() {
|
|
3641
|
+
const fields = await this.columnNames(this.models.issues);
|
|
3642
|
+
return {
|
|
3643
|
+
verifiedSha: fields.has(this.contract.columns.verifiedSha),
|
|
3644
|
+
prRef: fields.has(this.contract.columns.prRef)
|
|
3645
|
+
};
|
|
3646
|
+
}
|
|
3608
3647
|
/**
|
|
3609
3648
|
* The authorship probe (CREW-1371), read once per process: which optional
|
|
3610
3649
|
* `filed_by_id`/`ship_id` columns exist on Issues and Comments, and this
|
|
@@ -4005,6 +4044,10 @@ function withHoldCheck(affinity, i) {
|
|
|
4005
4044
|
const a = affinityContext(i);
|
|
4006
4045
|
return a ? { ...affinity, isHeldElsewhere: (t) => heldByOtherShip(t, a), isLiveHoldElsewhere: (t) => heldByLiveOtherShip(t, a) } : affinity;
|
|
4007
4046
|
}
|
|
4047
|
+
function notInStoppedRepo(slice, i) {
|
|
4048
|
+
const stop = i.repoStop;
|
|
4049
|
+
return stop ? slice.filter((t) => stop(t) === null) : slice;
|
|
4050
|
+
}
|
|
4008
4051
|
function notHeldByOtherShip(slice, i) {
|
|
4009
4052
|
if (!i.ships) return slice;
|
|
4010
4053
|
const a = { myShipId: i.ships.myShipId, ships: i.ships.rows, contract: i.contract ?? DEFAULT_CONTRACT, now: i.ships.now };
|
|
@@ -4013,7 +4056,7 @@ function notHeldByOtherShip(slice, i) {
|
|
|
4013
4056
|
function buildingRoleHasWork(role, i) {
|
|
4014
4057
|
const me = i.seats[role];
|
|
4015
4058
|
if (!me) return { hasWork: false, reason: "this ship does not crew that role" };
|
|
4016
|
-
const mine = notHeldByOtherShip(sliceFor(i.tickets, role), i);
|
|
4059
|
+
const mine = notInStoppedRepo(notHeldByOtherShip(sliceFor(i.tickets, role), i), i);
|
|
4017
4060
|
const startable = mine.filter(
|
|
4018
4061
|
(t) => (t.status === "accepted" || t.status === "blocked") && !i.blocked.has(t.id) && !isHeld(t, i.holds)
|
|
4019
4062
|
);
|
|
@@ -4063,7 +4106,7 @@ function roleCandidates(role, i) {
|
|
|
4063
4106
|
if (role === "pair") return [];
|
|
4064
4107
|
const me = i.seats[role];
|
|
4065
4108
|
if (!me) return [];
|
|
4066
|
-
return notHeldByOtherShip(sliceFor(i.tickets, role), i).filter(
|
|
4109
|
+
return notInStoppedRepo(notHeldByOtherShip(sliceFor(i.tickets, role), i), i).filter(
|
|
4067
4110
|
(t) => ((t.status === "accepted" || t.status === "blocked") && !i.blocked.has(t.id) || t.status === "in_progress" && (t.assignee_id === me || !t.assignee_id)) && !isHeld(t, i.holds)
|
|
4068
4111
|
);
|
|
4069
4112
|
}
|
|
@@ -4235,6 +4278,16 @@ function attentionTransitions(previous, current) {
|
|
|
4235
4278
|
}
|
|
4236
4279
|
return out;
|
|
4237
4280
|
}
|
|
4281
|
+
function operatorTodo(tickets, contract, operatorId) {
|
|
4282
|
+
const out = [];
|
|
4283
|
+
for (const ticket of tickets) {
|
|
4284
|
+
const reasons = attentionReasons(ticket, contract).filter(
|
|
4285
|
+
(r) => r === "needs_planning" || r === "needs_info" && ticket.assignee_id === operatorId
|
|
4286
|
+
);
|
|
4287
|
+
if (reasons.length > 0) out.push({ ticket, reasons });
|
|
4288
|
+
}
|
|
4289
|
+
return out;
|
|
4290
|
+
}
|
|
4238
4291
|
|
|
4239
4292
|
// src/digest.ts
|
|
4240
4293
|
function displayKey2(t) {
|
|
@@ -4328,9 +4381,21 @@ ${sorted(ts).map(blockedRow).join("\n")}
|
|
|
4328
4381
|
const step1 = i.tickets.filter(
|
|
4329
4382
|
(t) => t.status === "in_progress" && (t.assignee_id === i.me || !t.assignee_id) || t.status === "needs_info"
|
|
4330
4383
|
);
|
|
4384
|
+
const stopOf = (t) => i.repoStop ? i.repoStop(t) : null;
|
|
4331
4385
|
const step2 = i.tickets.filter(
|
|
4332
|
-
(t) => (t.status === "accepted" || t.status === "blocked") && !isBlocked(t)
|
|
4386
|
+
(t) => (t.status === "accepted" || t.status === "blocked") && !isBlocked(t) && stopOf(t) === null
|
|
4333
4387
|
);
|
|
4388
|
+
const held = i.tickets.filter((t) => (t.status === "accepted" || t.status === "blocked") && !isBlocked(t) && stopOf(t) !== null);
|
|
4389
|
+
const heldTable = held.length === 0 ? "" : `
|
|
4390
|
+
### Held repos \u2014 this ship cannot start these right now
|
|
4391
|
+
|
|
4392
|
+
Not yours to start this run: the ship's checkout of their repo is stopped. It
|
|
4393
|
+
resumes by itself once the checkout is level; no one needs to change the ticket.
|
|
4394
|
+
|
|
4395
|
+
| ticket | why |
|
|
4396
|
+
|---|---|
|
|
4397
|
+
${sorted(held).map((t) => `| ${displayKey2(t)} | ${stopOf(t)} |`).join("\n")}
|
|
4398
|
+
`;
|
|
4334
4399
|
return stream([
|
|
4335
4400
|
"## Current queue \u2014 built for you by the poll\n",
|
|
4336
4401
|
"\nAlready filtered to your lane, and already ordered by the Step 2 rule.\nTicket bodies are deliberately omitted: fetch the full record of only the\nticket you actually pick up. **Do not re-fetch the whole tracker.** This\ndigest comes from the same API call the poll just made, moments ago.\n",
|
|
@@ -4344,7 +4409,8 @@ ${sorted(ts).map(blockedRow).join("\n")}
|
|
|
4344
4409
|
table(step2, i, header, row),
|
|
4345
4410
|
"\n### Blocked \u2014 waiting on a dependency, not yours to start\n",
|
|
4346
4411
|
'\nListed rather than hidden so "nothing to do" stays distinguishable from\n"everything is parked". The loop parks and restores these itself, on every\npoll: an approved ticket whose `Blocked by` entries are unresolved moves to\n`blocked`, and back to `accepted` once the last one resolves. **Do not pick\none up, do not set or clear `blocked` by hand, and do not "unblock" one by\nediting its `Blocked by` field** \u2014 if a blocker looks wrong, say so in your\nrun summary. A blocker counts as resolved at `verified`, `closed_deployed`,\n`closed_wont_fix` or `closed_duplicate`; `fixed` is still an unmerged branch\nawaiting QA, so it does not count.\n',
|
|
4347
|
-
blockedTable(i.tickets.filter(isBlocked))
|
|
4412
|
+
blockedTable(i.tickets.filter(isBlocked)),
|
|
4413
|
+
...heldTable ? [heldTable] : []
|
|
4348
4414
|
]);
|
|
4349
4415
|
}
|
|
4350
4416
|
function qaDigest(i) {
|
|
@@ -4394,6 +4460,9 @@ function branchTemplates(cfg, t) {
|
|
|
4394
4460
|
const push = cfg.provenance["branch.push"] === "default" ? name : cfg.branch.push;
|
|
4395
4461
|
return { name, push };
|
|
4396
4462
|
}
|
|
4463
|
+
function pushedBranchForTicket(cfg, t, role) {
|
|
4464
|
+
return branchRenderer(t, role)(branchTemplates(cfg, t).push);
|
|
4465
|
+
}
|
|
4397
4466
|
function existingBranchForTicket(dir, cfg, t, role) {
|
|
4398
4467
|
return branchForIssue(dir, t.issue_id, branchTemplates(cfg, t), branchRenderer(t, role));
|
|
4399
4468
|
}
|
|
@@ -4419,6 +4488,102 @@ function materializeBranchForTicket(dir, cfg, t, role) {
|
|
|
4419
4488
|
// src/poll.ts
|
|
4420
4489
|
import { writeFileSync as writeFileSync3 } from "fs";
|
|
4421
4490
|
import { join as join6 } from "path";
|
|
4491
|
+
|
|
4492
|
+
// src/stalled.ts
|
|
4493
|
+
var STALLED_AFTER_CYCLES = 30;
|
|
4494
|
+
var CYCLE_MS = 120 * 1e3;
|
|
4495
|
+
var STALLED_AFTER_MS = STALLED_AFTER_CYCLES * CYCLE_MS;
|
|
4496
|
+
var STALLED_MARKER = "<!-- crew:stalled -->";
|
|
4497
|
+
var stalledKey = (issueId) => `stalled:${issueId}`;
|
|
4498
|
+
var ms = (iso) => {
|
|
4499
|
+
const n = iso ? Date.parse(iso) : NaN;
|
|
4500
|
+
return Number.isFinite(n) ? n : 0;
|
|
4501
|
+
};
|
|
4502
|
+
function planStalled(tickets, comments, ships, contract, holds2 = /* @__PURE__ */ new Set(), now = Date.now()) {
|
|
4503
|
+
const watched = /* @__PURE__ */ new Set([contract.statuses.building, contract.statuses.handoff, contract.statuses.verifying]);
|
|
4504
|
+
const byTicket = /* @__PURE__ */ new Map();
|
|
4505
|
+
for (const c of comments) {
|
|
4506
|
+
const list = byTicket.get(c.ticket_id);
|
|
4507
|
+
if (list) list.push(c);
|
|
4508
|
+
else byTicket.set(c.ticket_id, [c]);
|
|
4509
|
+
}
|
|
4510
|
+
const out = [];
|
|
4511
|
+
for (const t of tickets) {
|
|
4512
|
+
if (!watched.has(t.status)) continue;
|
|
4513
|
+
if (t.assignee_id && holds2.has(t.assignee_id)) continue;
|
|
4514
|
+
const mine = byTicket.get(t.id) ?? [];
|
|
4515
|
+
const lastMarker = Math.max(0, ...mine.filter((c) => (c.body ?? "").includes(STALLED_MARKER)).map((c) => ms(c.created_at)));
|
|
4516
|
+
const lastActivityAt = Math.max(
|
|
4517
|
+
ms(t.updated_at),
|
|
4518
|
+
...mine.filter((c) => !(c.body ?? "").includes(STALLED_MARKER)).map((c) => ms(c.created_at))
|
|
4519
|
+
);
|
|
4520
|
+
if (now - lastActivityAt < STALLED_AFTER_MS) continue;
|
|
4521
|
+
if (ships.some((s) => s.engaged && s.engaged_ticket_id === t.id)) continue;
|
|
4522
|
+
out.push({
|
|
4523
|
+
ticket: t,
|
|
4524
|
+
holderShipId: ticketHolderShipId(t, contract),
|
|
4525
|
+
lastActivityAt,
|
|
4526
|
+
announced: lastMarker > lastActivityAt
|
|
4527
|
+
});
|
|
4528
|
+
}
|
|
4529
|
+
return out;
|
|
4530
|
+
}
|
|
4531
|
+
function stalledComment(s, ships, now = Date.now()) {
|
|
4532
|
+
const mins = Math.round((now - s.lastActivityAt) / 6e4);
|
|
4533
|
+
const who2 = s.holderShipId ? `Held by ${shipName(ships, s.holderShipId)}, which is not engaged on it. That ship has an attention item open; the ticket is otherwise left as it is, and resumes the moment the ship picks it back up.` : `No ship holds it and none is engaged on it. It is waiting for the next ship that polls its lane; its status and assignee are left as they are.`;
|
|
4534
|
+
return `${STALLED_MARKER}
|
|
4535
|
+
**Stalled \u2014 no activity on \`${s.ticket.issue_id}\` for ${mins} minutes (\`${s.ticket.status}\`).**
|
|
4536
|
+
|
|
4537
|
+
${who2}`;
|
|
4538
|
+
}
|
|
4539
|
+
async function applyStalled(writer, stalled, ships, memberId, myShipId, log, dryRun, now = Date.now()) {
|
|
4540
|
+
const r = { announced: 0, failed: 0, attention: [] };
|
|
4541
|
+
for (const s of stalled) {
|
|
4542
|
+
const x = { ticket: s.ticket.issue_id, step: "sweep" };
|
|
4543
|
+
if (s.holderShipId && s.holderShipId === myShipId) {
|
|
4544
|
+
r.attention.push({
|
|
4545
|
+
kind: "stalled",
|
|
4546
|
+
key: stalledKey(s.ticket.issue_id),
|
|
4547
|
+
message: `${s.ticket.issue_id} is held by this ship but has been quiet for ${Math.round((now - s.lastActivityAt) / 6e4)} minutes with nothing engaged on it`,
|
|
4548
|
+
since: new Date(s.lastActivityAt + STALLED_AFTER_MS).toISOString()
|
|
4549
|
+
});
|
|
4550
|
+
}
|
|
4551
|
+
if (s.announced) continue;
|
|
4552
|
+
if (dryRun) {
|
|
4553
|
+
log.emit("would flag as stalled", x);
|
|
4554
|
+
continue;
|
|
4555
|
+
}
|
|
4556
|
+
try {
|
|
4557
|
+
await writer.postEvent(s.ticket.id, stalledComment(s, ships, now), memberId);
|
|
4558
|
+
r.announced++;
|
|
4559
|
+
log.warn(`stalled at ${s.ticket.status} \u2014 ${s.holderShipId ? "flagged on the holder" : "commented, no holder"}`, x);
|
|
4560
|
+
} catch (e) {
|
|
4561
|
+
r.failed++;
|
|
4562
|
+
log.warn(`could not flag as stalled: ${e.message}`, x);
|
|
4563
|
+
}
|
|
4564
|
+
}
|
|
4565
|
+
return r;
|
|
4566
|
+
}
|
|
4567
|
+
async function runStalledSweep(o, stalled) {
|
|
4568
|
+
const r = await applyStalled(
|
|
4569
|
+
o.tracker,
|
|
4570
|
+
stalled,
|
|
4571
|
+
o.ships,
|
|
4572
|
+
o.memberId,
|
|
4573
|
+
o.myShipId,
|
|
4574
|
+
o.emit,
|
|
4575
|
+
o.dryRun ?? false
|
|
4576
|
+
);
|
|
4577
|
+
if (o.dryRun) return r;
|
|
4578
|
+
const keep = new Set(r.attention.map((i) => i.key));
|
|
4579
|
+
for (const item of r.attention) await raiseShipAttention({ state: o.state, emit: o.emit, route: o.route, ship: o.ship }, item);
|
|
4580
|
+
for (const open of o.state.shipAttention().previous()) {
|
|
4581
|
+
if (open.kind === "stalled" && !keep.has(open.key)) clearShipAttention(o.state, open.key);
|
|
4582
|
+
}
|
|
4583
|
+
return r;
|
|
4584
|
+
}
|
|
4585
|
+
|
|
4586
|
+
// src/poll.ts
|
|
4422
4587
|
async function decideCycle(o) {
|
|
4423
4588
|
const { route, ship, state, emit } = o;
|
|
4424
4589
|
const tracker = new Tracker(route, ship);
|
|
@@ -4467,6 +4632,7 @@ async function decideCycle(o) {
|
|
|
4467
4632
|
}
|
|
4468
4633
|
const sweep = planSweep(tickets, info, blocked);
|
|
4469
4634
|
const holdReleases = planHoldReleases(tickets, tracker.contract);
|
|
4635
|
+
const stalled = planStalled(tickets, comments, ships, tracker.contract, holds2);
|
|
4470
4636
|
const blockedIssues = tickets.filter((t) => blocked.has(t.id)).map((t) => t.issue_id);
|
|
4471
4637
|
emit.emit(`${blockedIssues.length} ticket(s) parked as blocked`, { data: { blocked: blockedIssues } });
|
|
4472
4638
|
const qaHeld = tickets.filter((t) => t.status === tracker.contract.statuses.verifying).map((t) => t.issue_id);
|
|
@@ -4526,7 +4692,11 @@ async function decideCycle(o) {
|
|
|
4526
4692
|
}
|
|
4527
4693
|
emit.enter("select");
|
|
4528
4694
|
const watermark = state.watermark();
|
|
4695
|
+
for (const repo of recheckBaseStops(state, route)) {
|
|
4696
|
+
emit.emit(`${repo}: primary checkout is level again \u2014 building lanes resume for that repo`, { step: "select" });
|
|
4697
|
+
}
|
|
4529
4698
|
const selectionInput = {
|
|
4699
|
+
repoStop: repoStopFor(openShipAttention(state), route),
|
|
4530
4700
|
tickets,
|
|
4531
4701
|
comments,
|
|
4532
4702
|
watermark,
|
|
@@ -4549,6 +4719,7 @@ async function decideCycle(o) {
|
|
|
4549
4719
|
sweep,
|
|
4550
4720
|
epicSync,
|
|
4551
4721
|
holdReleases,
|
|
4722
|
+
stalled,
|
|
4552
4723
|
stranded,
|
|
4553
4724
|
selection,
|
|
4554
4725
|
actionable,
|
|
@@ -4593,6 +4764,7 @@ function writeDigest(o, d, role, stateDir) {
|
|
|
4593
4764
|
const render2 = (t) => branchRenderer(t, role);
|
|
4594
4765
|
const input = {
|
|
4595
4766
|
dirFor,
|
|
4767
|
+
repoStop: repoStopFor(openShipAttention(o.state), o.route),
|
|
4596
4768
|
branchFor: (t) => {
|
|
4597
4769
|
const cfg = repoFor(dirFor(t) ?? o.route.dir);
|
|
4598
4770
|
const { prefix } = ticketBranchContext(t);
|
|
@@ -5617,10 +5789,10 @@ function spawnAgent(plan, emit) {
|
|
|
5617
5789
|
}));
|
|
5618
5790
|
} catch {
|
|
5619
5791
|
}
|
|
5620
|
-
const
|
|
5792
|
+
const ms2 = Date.now() - started;
|
|
5621
5793
|
emit.emit(`agent run finished (exit ${code ?? 1})`, {
|
|
5622
5794
|
data: {
|
|
5623
|
-
ms,
|
|
5795
|
+
ms: ms2,
|
|
5624
5796
|
code: code ?? 1,
|
|
5625
5797
|
...result?.sessionId ? { sessionId: result.sessionId } : {},
|
|
5626
5798
|
...result?.numTurns !== void 0 ? { numTurns: result.numTurns } : {},
|
|
@@ -5648,7 +5820,7 @@ function spawnAgent(plan, emit) {
|
|
|
5648
5820
|
emit.warn(`could not report agent log: ${e.message}`, { step: "agent" });
|
|
5649
5821
|
}
|
|
5650
5822
|
}
|
|
5651
|
-
resolve5({ code: code ?? 1, ms });
|
|
5823
|
+
resolve5({ code: code ?? 1, ms: ms2 });
|
|
5652
5824
|
});
|
|
5653
5825
|
child.stdin.on("error", (err) => {
|
|
5654
5826
|
emit.warn(`agent closed stdin before the prompt was delivered: ${err.message}`, { step: "agent" });
|
|
@@ -5657,6 +5829,208 @@ function spawnAgent(plan, emit) {
|
|
|
5657
5829
|
});
|
|
5658
5830
|
}
|
|
5659
5831
|
|
|
5832
|
+
// src/run-worktree.ts
|
|
5833
|
+
import { existsSync as existsSync9 } from "fs";
|
|
5834
|
+
import { dirname as dirname6, join as join9 } from "path";
|
|
5835
|
+
|
|
5836
|
+
// src/ports.ts
|
|
5837
|
+
import { execFileSync as execFileSync2 } from "child_process";
|
|
5838
|
+
import { existsSync as existsSync8 } from "fs";
|
|
5839
|
+
import { join as join8 } from "path";
|
|
5840
|
+
var run = (cmd, args) => {
|
|
5841
|
+
try {
|
|
5842
|
+
return execFileSync2(cmd, args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
5843
|
+
} catch {
|
|
5844
|
+
return "";
|
|
5845
|
+
}
|
|
5846
|
+
};
|
|
5847
|
+
function listeners() {
|
|
5848
|
+
const out = [];
|
|
5849
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5850
|
+
const add = (pid, port) => {
|
|
5851
|
+
const key = `${pid}:${port}`;
|
|
5852
|
+
if (!Number.isFinite(pid) || !Number.isFinite(port) || seen.has(key)) return;
|
|
5853
|
+
seen.add(key);
|
|
5854
|
+
out.push({ pid, port });
|
|
5855
|
+
};
|
|
5856
|
+
const lsof = run("lsof", ["-nP", "-iTCP", "-sTCP:LISTEN"]);
|
|
5857
|
+
if (lsof) {
|
|
5858
|
+
for (const line of lsof.split("\n").slice(1)) {
|
|
5859
|
+
const cols = line.split(/\s+/);
|
|
5860
|
+
const addr = cols[8];
|
|
5861
|
+
if (!addr) continue;
|
|
5862
|
+
add(Number(cols[1]), Number(addr.slice(addr.lastIndexOf(":") + 1)));
|
|
5863
|
+
}
|
|
5864
|
+
return out;
|
|
5865
|
+
}
|
|
5866
|
+
const ss = run("ss", ["-ltnpH"]);
|
|
5867
|
+
for (const line of ss.split("\n")) {
|
|
5868
|
+
const local = line.trim().split(/\s+/)[3];
|
|
5869
|
+
const pid = /pid=(\d+)/.exec(line)?.[1];
|
|
5870
|
+
if (!local || !pid) continue;
|
|
5871
|
+
add(Number(pid), Number(local.slice(local.lastIndexOf(":") + 1)));
|
|
5872
|
+
}
|
|
5873
|
+
return out;
|
|
5874
|
+
}
|
|
5875
|
+
var pidsOnPort = (port) => listeners().filter((l) => l.port === port).map((l) => l.pid);
|
|
5876
|
+
var DEFAULT_PORTS = { backendBase: 3e4, frontendBase: 4e4, span: 1e3 };
|
|
5877
|
+
function ticketForPort(port, s = DEFAULT_PORTS) {
|
|
5878
|
+
if (port >= s.backendBase && port < s.backendBase + s.span) return { n: port - s.backendBase, role: "backend" };
|
|
5879
|
+
if (port >= s.frontendBase && port < s.frontendBase + s.span) return { n: port - s.frontendBase, role: "frontend" };
|
|
5880
|
+
return null;
|
|
5881
|
+
}
|
|
5882
|
+
function worktreeExists(parent, prefix, n) {
|
|
5883
|
+
for (const name of [`${prefix}${n}`, `${prefix}${String(n).padStart(3, "0")}`]) {
|
|
5884
|
+
if (existsSync8(join8(parent, name, ".git"))) return true;
|
|
5885
|
+
}
|
|
5886
|
+
return false;
|
|
5887
|
+
}
|
|
5888
|
+
var worktreeExistsIn = (where, n) => where.some((w) => worktreeExists(w.parent, w.prefix, n));
|
|
5889
|
+
function findOrphansIn(where, s = DEFAULT_PORTS) {
|
|
5890
|
+
const orphans = [];
|
|
5891
|
+
for (const l of listeners()) {
|
|
5892
|
+
const t = ticketForPort(l.port, s);
|
|
5893
|
+
if (!t) continue;
|
|
5894
|
+
if (worktreeExistsIn(where, t.n)) continue;
|
|
5895
|
+
orphans.push({ ...l, ...t });
|
|
5896
|
+
}
|
|
5897
|
+
return orphans;
|
|
5898
|
+
}
|
|
5899
|
+
async function killGently(pids, graceMs = 2e3) {
|
|
5900
|
+
for (const pid of pids) {
|
|
5901
|
+
try {
|
|
5902
|
+
process.kill(pid, "SIGTERM");
|
|
5903
|
+
} catch {
|
|
5904
|
+
}
|
|
5905
|
+
}
|
|
5906
|
+
if (pids.length === 0) return;
|
|
5907
|
+
await new Promise((r) => setTimeout(r, graceMs));
|
|
5908
|
+
for (const pid of pids) {
|
|
5909
|
+
try {
|
|
5910
|
+
process.kill(pid, "SIGKILL");
|
|
5911
|
+
} catch {
|
|
5912
|
+
}
|
|
5913
|
+
}
|
|
5914
|
+
}
|
|
5915
|
+
function pidsInWorktree(path, n, s = DEFAULT_PORTS) {
|
|
5916
|
+
const pids = /* @__PURE__ */ new Set();
|
|
5917
|
+
for (const port of [s.backendBase + n, s.frontendBase + n]) for (const p of pidsOnPort(port)) pids.add(p);
|
|
5918
|
+
const matched = run("pgrep", ["-f", `${path}/`]);
|
|
5919
|
+
for (const line of matched.split("\n")) {
|
|
5920
|
+
const pid = Number(line.trim());
|
|
5921
|
+
if (Number.isFinite(pid) && pid > 0 && pid !== process.pid) pids.add(pid);
|
|
5922
|
+
}
|
|
5923
|
+
return [...pids];
|
|
5924
|
+
}
|
|
5925
|
+
|
|
5926
|
+
// src/run-worktree.ts
|
|
5927
|
+
var describeRunWorktreePlan = (p) => {
|
|
5928
|
+
switch (p.action) {
|
|
5929
|
+
case "cut-remote":
|
|
5930
|
+
return `cut ${p.path} from the remote branch ${p.branch}`;
|
|
5931
|
+
case "cut-fresh":
|
|
5932
|
+
return `cut ${p.path} fresh from ${p.base} (no branch ${p.branch} anywhere yet)`;
|
|
5933
|
+
case "reset":
|
|
5934
|
+
return `fast-forwarded ${p.path} to the remote tip of ${p.branch}`;
|
|
5935
|
+
case "reuse":
|
|
5936
|
+
return `reusing ${p.path} (${p.branch} is already level with the remote)`;
|
|
5937
|
+
case "recut":
|
|
5938
|
+
return `${p.branch} is gone from the remote and ${p.path} held no work of its own \u2014 re-cut it fresh from ${p.base}`;
|
|
5939
|
+
case "leave-dirty":
|
|
5940
|
+
return `left ${p.path} alone: it has uncommitted changes (someone may be editing it by hand)`;
|
|
5941
|
+
case "leave-unpushed":
|
|
5942
|
+
return `left ${p.path} alone: ${p.branch} has commits that are not on the remote`;
|
|
5943
|
+
}
|
|
5944
|
+
};
|
|
5945
|
+
var planIsUsable = (p) => p.action !== "leave-dirty" && p.action !== "leave-unpushed";
|
|
5946
|
+
function newBranchName(cfg, t, role) {
|
|
5947
|
+
const { prefix } = ticketBranchContext(t);
|
|
5948
|
+
return branchRenderer(t, role)(effectiveBranchTemplate(cfg, prefix));
|
|
5949
|
+
}
|
|
5950
|
+
function planRunWorktree(dir, cfg, t, role) {
|
|
5951
|
+
const { remote, base } = cfg.branch;
|
|
5952
|
+
const hasRemote = remoteConfigured(dir, remote);
|
|
5953
|
+
if (hasRemote) fetchRemote(dir, remote);
|
|
5954
|
+
const num = t.issue_id.replace(/^\D+/, "");
|
|
5955
|
+
const existing = worktreeForNumber(dir, num);
|
|
5956
|
+
const located = locateBranchForTicket(dir, cfg, t, role);
|
|
5957
|
+
const baseRef = hasRemote ? `${remote}/${base}` : base;
|
|
5958
|
+
if (existing && !existing.prunable && existsSync9(existing.path)) {
|
|
5959
|
+
const branch = existing.branch;
|
|
5960
|
+
if (status(existing.path).length > 0) return { action: "leave-dirty", path: existing.path, branch };
|
|
5961
|
+
if (!branch) return { action: "leave-unpushed", path: existing.path, branch: "(detached HEAD)" };
|
|
5962
|
+
if (!hasRemote) return { action: "reuse", path: existing.path, branch };
|
|
5963
|
+
if (remoteBranchExists(dir, remote, branch)) {
|
|
5964
|
+
const ahead = countCommits(existing.path, `${remote}/${branch}..HEAD`);
|
|
5965
|
+
const behind = countCommits(existing.path, `HEAD..${remote}/${branch}`);
|
|
5966
|
+
if (ahead > 0) return { action: "leave-unpushed", path: existing.path, branch };
|
|
5967
|
+
return behind > 0 ? { action: "reset", path: existing.path, branch } : { action: "reuse", path: existing.path, branch };
|
|
5968
|
+
}
|
|
5969
|
+
if (countCommits(existing.path, `${baseRef}..HEAD`) > 0) return { action: "leave-unpushed", path: existing.path, branch };
|
|
5970
|
+
return { action: "recut", path: existing.path, branch, base: baseRef };
|
|
5971
|
+
}
|
|
5972
|
+
const path = join9(dirname6(dir), worktreeForTicket(dir, cfg, t, role));
|
|
5973
|
+
if (located?.where === "remote" || located && hasRemote && remoteBranchExists(dir, remote, located.branch)) {
|
|
5974
|
+
return { action: "cut-remote", path, branch: located.branch };
|
|
5975
|
+
}
|
|
5976
|
+
if (located) {
|
|
5977
|
+
return { action: "reuse", path, branch: located.branch };
|
|
5978
|
+
}
|
|
5979
|
+
return { action: "cut-fresh", path, branch: newBranchName(cfg, t, role), base: baseRef };
|
|
5980
|
+
}
|
|
5981
|
+
function applyRunWorktree(dir, cfg, plan) {
|
|
5982
|
+
const { remote } = cfg.branch;
|
|
5983
|
+
switch (plan.action) {
|
|
5984
|
+
case "leave-dirty":
|
|
5985
|
+
case "leave-unpushed":
|
|
5986
|
+
return null;
|
|
5987
|
+
case "reuse":
|
|
5988
|
+
if (!worktrees(dir).some((w) => w.path === plan.path)) git(dir, ["worktree", "add", plan.path, plan.branch]);
|
|
5989
|
+
return plan.path;
|
|
5990
|
+
case "reset":
|
|
5991
|
+
git(plan.path, ["merge", "--ff-only", `${remote}/${plan.branch}`]);
|
|
5992
|
+
return plan.path;
|
|
5993
|
+
case "cut-remote": {
|
|
5994
|
+
const local = gitOk(dir, ["rev-parse", "-q", "--verify", `refs/heads/${plan.branch}`]) !== null;
|
|
5995
|
+
if (local) {
|
|
5996
|
+
git(dir, ["worktree", "add", plan.path, plan.branch]);
|
|
5997
|
+
gitOk(plan.path, ["merge", "--ff-only", `${remote}/${plan.branch}`]);
|
|
5998
|
+
} else {
|
|
5999
|
+
git(dir, ["worktree", "add", "--track", "-b", plan.branch, plan.path, `${remote}/${plan.branch}`]);
|
|
6000
|
+
}
|
|
6001
|
+
return plan.path;
|
|
6002
|
+
}
|
|
6003
|
+
case "recut":
|
|
6004
|
+
git(dir, ["worktree", "remove", "--force", plan.path]);
|
|
6005
|
+
gitOk(dir, ["branch", "-D", plan.branch]);
|
|
6006
|
+
git(dir, ["worktree", "add", "-b", plan.branch, plan.path, plan.base]);
|
|
6007
|
+
return plan.path;
|
|
6008
|
+
case "cut-fresh":
|
|
6009
|
+
git(dir, ["worktree", "add", "-b", plan.branch, plan.path, plan.base]);
|
|
6010
|
+
return plan.path;
|
|
6011
|
+
}
|
|
6012
|
+
}
|
|
6013
|
+
function worktreeHoldsUnpushed(path, remote) {
|
|
6014
|
+
const out = gitOk(path, ["for-each-ref", "--contains", "HEAD", "--count=1", "--format=%(refname)", `refs/remotes/${remote}/`]);
|
|
6015
|
+
return out === null || out.trim() === "";
|
|
6016
|
+
}
|
|
6017
|
+
async function finishRunWorktree(dir, cfg, branch) {
|
|
6018
|
+
const wt = worktrees(dir).find((w) => w.branch === branch && !w.prunable && w.path !== dir);
|
|
6019
|
+
if (!wt || !existsSync9(wt.path)) return { kind: "none" };
|
|
6020
|
+
if (cfg.hooks.handoff) return { kind: "kept-handoff", path: wt.path };
|
|
6021
|
+
if (status(wt.path).length > 0) return { kind: "kept-dirty", path: wt.path };
|
|
6022
|
+
if (remoteConfigured(dir, cfg.branch.remote) && worktreeHoldsUnpushed(wt.path, cfg.branch.remote)) {
|
|
6023
|
+
return { kind: "kept-unpushed", path: wt.path };
|
|
6024
|
+
}
|
|
6025
|
+
const n = Number(wt.path.match(/(\d+)$/)?.[1] ?? NaN);
|
|
6026
|
+
if (Number.isFinite(n)) {
|
|
6027
|
+
const pids = pidsInWorktree(wt.path, n);
|
|
6028
|
+
if (pids.length) await killGently(pids);
|
|
6029
|
+
}
|
|
6030
|
+
gitOk(dir, ["worktree", "remove", wt.path]);
|
|
6031
|
+
return existsSync9(wt.path) ? { kind: "kept-dirty", path: wt.path } : { kind: "removed", path: wt.path };
|
|
6032
|
+
}
|
|
6033
|
+
|
|
5660
6034
|
// src/handoff-guard.ts
|
|
5661
6035
|
var HANDOFF_BOUNCED_MARKER = "<!-- crew:handoff-bounced -->";
|
|
5662
6036
|
var pushedEventBody = (sha, remote, branch) => `pushed ${sha} to ${remote}/${branch}`;
|
|
@@ -5747,7 +6121,7 @@ async function enforceHandoff(i) {
|
|
|
5747
6121
|
}
|
|
5748
6122
|
}
|
|
5749
6123
|
async function guardRunHandoff(route, ship, tracker, role, workingId, log) {
|
|
5750
|
-
if (role !== "dev" && role !== "design"
|
|
6124
|
+
if (!workingId || role !== "dev" && role !== "design" && role !== "qa") return null;
|
|
5751
6125
|
const seatId = route.resolved?.seats[role];
|
|
5752
6126
|
if (!seatId) return null;
|
|
5753
6127
|
try {
|
|
@@ -5760,8 +6134,12 @@ async function guardRunHandoff(route, ship, tracker, role, workingId, log) {
|
|
|
5760
6134
|
labels: { ...route.labels, ...o?.labels },
|
|
5761
6135
|
branch: { ...route.branch, ...o?.branch }
|
|
5762
6136
|
}, target.dir);
|
|
6137
|
+
if (role === "qa") {
|
|
6138
|
+
await retireRunWorktree(tracker, target.dir, cfg, t, role, seatId, ship.name, log);
|
|
6139
|
+
return null;
|
|
6140
|
+
}
|
|
5763
6141
|
const affinity = await tracker.claimAffinity();
|
|
5764
|
-
|
|
6142
|
+
const outcome = await enforceHandoff({
|
|
5765
6143
|
writer: tracker,
|
|
5766
6144
|
contract: tracker.contract,
|
|
5767
6145
|
ticketId: workingId,
|
|
@@ -5773,20 +6151,38 @@ async function guardRunHandoff(route, ship, tracker, role, workingId, log) {
|
|
|
5773
6151
|
hold: affinity ? { column: affinity.column, shipId: affinity.shipId } : void 0,
|
|
5774
6152
|
log
|
|
5775
6153
|
});
|
|
6154
|
+
if (outcome.kind === "pushed") await retireRunWorktree(tracker, target.dir, cfg, t, role, seatId, ship.name, log);
|
|
6155
|
+
return outcome;
|
|
5776
6156
|
} catch (e) {
|
|
5777
6157
|
log.warn(`could not check the hand-off: ${e.message}`, { step: "handoff" });
|
|
5778
6158
|
return null;
|
|
5779
6159
|
}
|
|
5780
6160
|
}
|
|
6161
|
+
async function retireRunWorktree(tracker, dir, cfg, t, role, seatId, shipName2, log) {
|
|
6162
|
+
try {
|
|
6163
|
+
const branch = existingBranchForTicket(dir, cfg, t, role);
|
|
6164
|
+
if (!branch) return;
|
|
6165
|
+
const o = await finishRunWorktree(dir, cfg, branch);
|
|
6166
|
+
const at = { ticket: t.issue_id, step: "handoff" };
|
|
6167
|
+
if (o.kind === "removed") log.emit(`${t.issue_id}: removed the run's worktree ${o.path} \u2014 the branch is on ${cfg.branch.remote}`, at);
|
|
6168
|
+
else if (o.kind === "kept-handoff") {
|
|
6169
|
+
log.emit(`${t.issue_id}: kept worktree ${o.path} \u2014 this repo's handoff hook serves from it`, at);
|
|
6170
|
+
await tracker.postEvent(t.id, `worktree kept on ship ${shipName2} at \`${o.path}\`: the repo's \`handoff\` hook serves from it. QA on another ship cuts its own from \`${cfg.branch.remote}/${branch}\` instead.`, seatId);
|
|
6171
|
+
} else if (o.kind === "kept-dirty") log.warn(`${t.issue_id}: left worktree ${o.path} \u2014 it has uncommitted changes`, at);
|
|
6172
|
+
else if (o.kind === "kept-unpushed") log.warn(`${t.issue_id}: left worktree ${o.path} \u2014 its branch has commits that are not on ${cfg.branch.remote}`, at);
|
|
6173
|
+
} catch (e) {
|
|
6174
|
+
log.warn(`could not retire the run's worktree: ${e.message}`, { step: "handoff" });
|
|
6175
|
+
}
|
|
6176
|
+
}
|
|
5781
6177
|
|
|
5782
6178
|
// src/connect-wizard.ts
|
|
5783
|
-
import { delimiter, dirname as
|
|
5784
|
-
import { accessSync as accessSync2, constants as constants2, existsSync as
|
|
6179
|
+
import { delimiter, dirname as dirname7, join as join10 } from "path";
|
|
6180
|
+
import { accessSync as accessSync2, constants as constants2, existsSync as existsSync10, mkdirSync as mkdirSync6, writeFileSync as writeFileSync5 } from "fs";
|
|
5785
6181
|
import { stringify } from "yaml";
|
|
5786
6182
|
var HOST_PASSENGERS_DOCS_URL = "https://crew.tablation.dev/host-passengers";
|
|
5787
6183
|
function shouldRunWizard(opts) {
|
|
5788
6184
|
if (!opts.isTTY) return false;
|
|
5789
|
-
const exists = opts.configExists ??
|
|
6185
|
+
const exists = opts.configExists ?? existsSync10;
|
|
5790
6186
|
return !configSearchPath(opts.crewHome).some((p) => exists(p));
|
|
5791
6187
|
}
|
|
5792
6188
|
function firstRunConfigPath() {
|
|
@@ -5803,7 +6199,7 @@ function defaultIsExecutable(p) {
|
|
|
5803
6199
|
function findOnPath2(name, pathEnv, isExecutable = defaultIsExecutable) {
|
|
5804
6200
|
for (const dir of pathEnv.split(delimiter)) {
|
|
5805
6201
|
if (!dir) continue;
|
|
5806
|
-
const candidate =
|
|
6202
|
+
const candidate = join10(dir, name);
|
|
5807
6203
|
if (isExecutable(candidate)) return candidate;
|
|
5808
6204
|
}
|
|
5809
6205
|
return void 0;
|
|
@@ -5829,9 +6225,9 @@ async function runWizard(p, env) {
|
|
|
5829
6225
|
if (entered && isExec(entered)) agentBin = entered;
|
|
5830
6226
|
else p.say(entered ? `${entered} is not an executable file.` : "A path is required.");
|
|
5831
6227
|
}
|
|
5832
|
-
const nodeDir =
|
|
6228
|
+
const nodeDir = dirname7(env.nodePath);
|
|
5833
6229
|
const pnpmPath = findOnPath2("pnpm", env.pathEnv, isExec);
|
|
5834
|
-
const baseDirs = [nodeDir, pnpmPath ?
|
|
6230
|
+
const baseDirs = [nodeDir, pnpmPath ? dirname7(pnpmPath) : void 0];
|
|
5835
6231
|
p.say(
|
|
5836
6232
|
`
|
|
5837
6233
|
Host Passengers lets this machine run the agent sessions that other people's
|
|
@@ -5842,7 +6238,7 @@ Tablation workspaces ask for, in a container. More: ${HOST_PASSENGERS_DOCS_URL}`
|
|
|
5842
6238
|
p.say("Docker is not available here, so nothing will be hosted until it is. Your answer is recorded anyway.");
|
|
5843
6239
|
}
|
|
5844
6240
|
const dockerPath = hostPassengers ? findOnPath2("docker", env.pathEnv, isExec) : void 0;
|
|
5845
|
-
const extraPath = deriveExtraPath([...baseDirs, dockerPath ?
|
|
6241
|
+
const extraPath = deriveExtraPath([...baseDirs, dockerPath ? dirname7(dockerPath) : void 0]);
|
|
5846
6242
|
if (extraPath) {
|
|
5847
6243
|
p.say(`Hooks will get ${extraPath} on their PATH (where node, pnpm${dockerPath ? " and docker" : ""} live).`);
|
|
5848
6244
|
}
|
|
@@ -5864,7 +6260,7 @@ routes:
|
|
|
5864
6260
|
${routeBlock}`;
|
|
5865
6261
|
}
|
|
5866
6262
|
function writeNewConfig(path, text) {
|
|
5867
|
-
mkdirSync6(
|
|
6263
|
+
mkdirSync6(dirname7(path), { recursive: true });
|
|
5868
6264
|
try {
|
|
5869
6265
|
writeFileSync5(path, text, { mode: 384, flag: "wx" });
|
|
5870
6266
|
return true;
|
|
@@ -5898,8 +6294,8 @@ Wrote ${o.configPath}.
|
|
|
5898
6294
|
}
|
|
5899
6295
|
|
|
5900
6296
|
// src/release-run.ts
|
|
5901
|
-
import { readFileSync as readFileSync7, writeFileSync as writeFileSync6, existsSync as
|
|
5902
|
-
import { dirname as
|
|
6297
|
+
import { readFileSync as readFileSync7, writeFileSync as writeFileSync6, existsSync as existsSync11, copyFileSync, mkdirSync as mkdirSync7, mkdtempSync, rmSync as rmSync2 } from "fs";
|
|
6298
|
+
import { dirname as dirname8, join as join11 } from "path";
|
|
5903
6299
|
import { tmpdir as tmpdir2 } from "os";
|
|
5904
6300
|
|
|
5905
6301
|
// src/release.ts
|
|
@@ -6159,6 +6555,139 @@ async function applyExternalClosures(tracker, closures, contract, emit, dryRun,
|
|
|
6159
6555
|
return stamped;
|
|
6160
6556
|
}
|
|
6161
6557
|
|
|
6558
|
+
// src/review.ts
|
|
6559
|
+
function reviewApplies(repo, contract) {
|
|
6560
|
+
return repo.release.mode === "external" && !!contract.statuses.reviewing;
|
|
6561
|
+
}
|
|
6562
|
+
function reviewProblems(repo, contract) {
|
|
6563
|
+
if (!reviewApplies(repo, contract)) return [];
|
|
6564
|
+
const problems = [];
|
|
6565
|
+
const template = repo.branch.push ?? repo.branch.name;
|
|
6566
|
+
if (!/\{(key|number|tag)\}/.test(template)) {
|
|
6567
|
+
problems.push(
|
|
6568
|
+
`branch.push "${template}" names none of {key}, {number} or {tag} \u2014 with release.mode "external" and a reviewing status, the pushed branch is the only link back to its ticket`
|
|
6569
|
+
);
|
|
6570
|
+
}
|
|
6571
|
+
return problems;
|
|
6572
|
+
}
|
|
6573
|
+
function parsePrRef(output) {
|
|
6574
|
+
const lines = output.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
6575
|
+
if (lines.length === 1) return lines[0].slice(0, 500);
|
|
6576
|
+
for (const l of [...lines].reverse()) {
|
|
6577
|
+
if (/^https?:\/\/\S+$/.test(l) || /^#?\d+$/.test(l)) return l;
|
|
6578
|
+
}
|
|
6579
|
+
return void 0;
|
|
6580
|
+
}
|
|
6581
|
+
function columnValue(t, column) {
|
|
6582
|
+
const v = t[column];
|
|
6583
|
+
return typeof v === "string" ? v.trim() : "";
|
|
6584
|
+
}
|
|
6585
|
+
function movedPast(r, t, branch, pushedAs) {
|
|
6586
|
+
if (!r.columns.verifiedSha) return null;
|
|
6587
|
+
const verified = columnValue(t, r.contract.columns.verifiedSha);
|
|
6588
|
+
if (!verified) return null;
|
|
6589
|
+
const head = resolve2(r.cwd, branch);
|
|
6590
|
+
const remoteHead = resolve2(r.cwd, `${r.repo.branch.remote}/${pushedAs}`);
|
|
6591
|
+
if (remoteHead && !isAncestor(r.cwd, remoteHead, verified) && remoteHead !== head) {
|
|
6592
|
+
return { ticket: t, verified, head: remoteHead };
|
|
6593
|
+
}
|
|
6594
|
+
if (head && head !== verified && !isAncestor(r.cwd, head, verified)) {
|
|
6595
|
+
return { ticket: t, verified, head };
|
|
6596
|
+
}
|
|
6597
|
+
return null;
|
|
6598
|
+
}
|
|
6599
|
+
async function runReview(r, verified, reviewing) {
|
|
6600
|
+
const out = { handoffs: [], requeues: [] };
|
|
6601
|
+
const remote = r.repo.branch.remote;
|
|
6602
|
+
for (const { ticket, branch } of reviewing) {
|
|
6603
|
+
const moved = movedPast(r, ticket, branch, pushedBranchForTicket(r.repo, ticket));
|
|
6604
|
+
if (moved) out.requeues.push(moved);
|
|
6605
|
+
}
|
|
6606
|
+
for (const { ticket, branch } of verified) {
|
|
6607
|
+
const pushedAs = pushedBranchForTicket(r.repo, ticket);
|
|
6608
|
+
const moved = movedPast(r, ticket, branch, pushedAs);
|
|
6609
|
+
if (moved) {
|
|
6610
|
+
out.requeues.push(moved);
|
|
6611
|
+
continue;
|
|
6612
|
+
}
|
|
6613
|
+
const head = resolve2(r.cwd, branch);
|
|
6614
|
+
if (!head) continue;
|
|
6615
|
+
if (r.dryRun) {
|
|
6616
|
+
r.emit.emit(`would push ${branch} to ${remote}/${pushedAs} for review`, { ticket: ticket.issue_id });
|
|
6617
|
+
continue;
|
|
6618
|
+
}
|
|
6619
|
+
try {
|
|
6620
|
+
pushBranch(r.cwd, remote, branch, pushedAs);
|
|
6621
|
+
} catch (e) {
|
|
6622
|
+
r.emit.error(
|
|
6623
|
+
`could not push ${branch} to ${remote}/${pushedAs} for review \u2014 ${e.message.split("\n")[0]}; left at verified, retried next cycle`,
|
|
6624
|
+
{ ticket: ticket.issue_id }
|
|
6625
|
+
);
|
|
6626
|
+
continue;
|
|
6627
|
+
}
|
|
6628
|
+
r.emit.emit(`pushed ${branch} to ${remote}/${pushedAs} for review`, { ticket: ticket.issue_id });
|
|
6629
|
+
const handoff = { ticket, pushedAs, head };
|
|
6630
|
+
const existing = r.columns.prRef ? columnValue(ticket, r.contract.columns.prRef) : "";
|
|
6631
|
+
if (existing) {
|
|
6632
|
+
handoff.prRef = existing;
|
|
6633
|
+
} else {
|
|
6634
|
+
const res = await r.runPrHook({
|
|
6635
|
+
CREW_TICKET: ticket.issue_id,
|
|
6636
|
+
CREW_BRANCH: pushedAs,
|
|
6637
|
+
CREW_BASE: r.repo.branch.base,
|
|
6638
|
+
CREW_TITLE: `${displayKey(ticket)}: ${ticket.title ?? ""}`.trim()
|
|
6639
|
+
});
|
|
6640
|
+
if (res && res.code !== 0) handoff.prFailure = { code: res.code, tail: lastLines(res.output, 5).trim() };
|
|
6641
|
+
else if (res) handoff.prRef = parsePrRef(res.output);
|
|
6642
|
+
}
|
|
6643
|
+
out.handoffs.push(handoff);
|
|
6644
|
+
}
|
|
6645
|
+
return out;
|
|
6646
|
+
}
|
|
6647
|
+
async function applyReview(tracker, contract, outcome, remote, emit, seat, columns, dryRun) {
|
|
6648
|
+
const reviewing = contract.statuses.reviewing;
|
|
6649
|
+
for (const h of outcome.handoffs) {
|
|
6650
|
+
if (!reviewing) break;
|
|
6651
|
+
const t = h.ticket;
|
|
6652
|
+
const lines = [`Pushed \`${h.pushedAs}\` (${h.head.slice(0, 8)}) to \`${remote}\` for review; moved to ${reviewing}.`];
|
|
6653
|
+
if (h.prRef) lines.push(`Pull request: ${h.prRef}`);
|
|
6654
|
+
if (h.prFailure) {
|
|
6655
|
+
lines.push(
|
|
6656
|
+
`\`hooks.pr\` exited ${h.prFailure.code}, so no pull request was recorded \u2014 the branch is pushed, open one by hand.
|
|
6657
|
+
|
|
6658
|
+
\`\`\`
|
|
6659
|
+
${h.prFailure.tail}
|
|
6660
|
+
\`\`\``
|
|
6661
|
+
);
|
|
6662
|
+
}
|
|
6663
|
+
try {
|
|
6664
|
+
await tracker.updateTicket(t.id, {
|
|
6665
|
+
[contract.columns.status]: reviewing,
|
|
6666
|
+
...h.prRef && columns.prRef ? { [contract.columns.prRef]: h.prRef } : {}
|
|
6667
|
+
}, t.updated_at);
|
|
6668
|
+
await tracker.postEvent(t.id, lines.join("\n\n"), seat);
|
|
6669
|
+
emit.emit(`moved to ${reviewing}`, { ticket: t.issue_id });
|
|
6670
|
+
} catch (e) {
|
|
6671
|
+
emit.warn(`could not move to ${reviewing}: ${e.message}`, { ticket: t.issue_id });
|
|
6672
|
+
}
|
|
6673
|
+
}
|
|
6674
|
+
for (const q of outcome.requeues) {
|
|
6675
|
+
const t = q.ticket;
|
|
6676
|
+
const body = `The branch is no longer what was verified: \`verified_sha\` is ${q.verified.slice(0, 8)}, the branch is now at ${q.head.slice(0, 8)}. Returned to ${contract.statuses.handoff} for QA to check what changed.`;
|
|
6677
|
+
if (dryRun) {
|
|
6678
|
+
emit.emit(`would return to ${contract.statuses.handoff} \u2014 branch moved past ${q.verified.slice(0, 8)}`, { ticket: t.issue_id });
|
|
6679
|
+
continue;
|
|
6680
|
+
}
|
|
6681
|
+
try {
|
|
6682
|
+
await tracker.updateTicket(t.id, { [contract.columns.status]: contract.statuses.handoff }, t.updated_at);
|
|
6683
|
+
await tracker.postEvent(t.id, body, seat);
|
|
6684
|
+
emit.emit(`returned to ${contract.statuses.handoff} \u2014 branch moved past ${q.verified.slice(0, 8)}`, { ticket: t.issue_id });
|
|
6685
|
+
} catch (e) {
|
|
6686
|
+
emit.warn(`could not return to ${contract.statuses.handoff}: ${e.message}`, { ticket: t.issue_id });
|
|
6687
|
+
}
|
|
6688
|
+
}
|
|
6689
|
+
}
|
|
6690
|
+
|
|
6162
6691
|
// src/release-run.ts
|
|
6163
6692
|
function emitReleaseSummary(emit, summaries) {
|
|
6164
6693
|
if (summaries.length <= 1) return;
|
|
@@ -6258,11 +6787,15 @@ async function detectExternalClosures(o, candidates) {
|
|
|
6258
6787
|
const out = [];
|
|
6259
6788
|
for (const c of candidates) {
|
|
6260
6789
|
if (!c.branch) continue;
|
|
6790
|
+
const prRef = c.ticket[o.contract.columns.prRef];
|
|
6261
6791
|
const closure = await detectClosure({
|
|
6262
6792
|
cwd: o.cwd,
|
|
6263
6793
|
key: c.ticket.issue_id,
|
|
6264
6794
|
aliases: referenceKeys(c.ticket).slice(1),
|
|
6265
|
-
|
|
6795
|
+
// The name the remote knows it by — `branch.push`, which is not the
|
|
6796
|
+
// local branch's when the repo sets one.
|
|
6797
|
+
pushedBranch: pushedBranchForTicket(o.repo, c.ticket),
|
|
6798
|
+
pr: typeof prRef === "string" && prRef.trim() ? prRef.trim() : void 0,
|
|
6266
6799
|
remote: o.repo.branch.remote,
|
|
6267
6800
|
base: o.repo.branch.base,
|
|
6268
6801
|
mergedHook
|
|
@@ -6272,6 +6805,29 @@ async function detectExternalClosures(o, candidates) {
|
|
|
6272
6805
|
}
|
|
6273
6806
|
return out;
|
|
6274
6807
|
}
|
|
6808
|
+
async function reviewHandoff(o, closures, verifiedCandidates, reviewing) {
|
|
6809
|
+
if (!reviewApplies(o.repo, o.contract)) return void 0;
|
|
6810
|
+
const problems = reviewProblems(o.repo, o.contract);
|
|
6811
|
+
if (problems.length) {
|
|
6812
|
+
for (const p of problems) o.emit.error(`${o.scope ?? "release"}: ${p}`);
|
|
6813
|
+
return void 0;
|
|
6814
|
+
}
|
|
6815
|
+
const open = new Set(closures.filter((c) => c.closure.state !== "merged").map((c) => c.ticket.id));
|
|
6816
|
+
const verified = verifiedCandidates.filter((c) => !!c.branch && open.has(c.ticket.id)).map((c) => ({ ticket: c.ticket, branch: c.branch }));
|
|
6817
|
+
const outcome = await runReview({
|
|
6818
|
+
cwd: o.cwd,
|
|
6819
|
+
repo: o.repo,
|
|
6820
|
+
contract: o.contract,
|
|
6821
|
+
emit: o.emit,
|
|
6822
|
+
dryRun: o.dryRun,
|
|
6823
|
+
columns: o.reviewColumns ?? { verifiedSha: false, prRef: false },
|
|
6824
|
+
runPrHook: async (env) => {
|
|
6825
|
+
const r = await hook(o, "pr", env);
|
|
6826
|
+
return r ? { code: r.code, output: r.output } : null;
|
|
6827
|
+
}
|
|
6828
|
+
}, verified, reviewing.filter((r) => open.has(r.ticket.id)));
|
|
6829
|
+
return outcome.handoffs.length || outcome.requeues.length ? outcome : void 0;
|
|
6830
|
+
}
|
|
6275
6831
|
async function confirmExternalReleased(o, closures) {
|
|
6276
6832
|
if (!o.repo.hooks.released) return closures;
|
|
6277
6833
|
const landed = closures.filter((c) => c.closure.state === "merged" && c.closure.mergedAt);
|
|
@@ -6362,9 +6918,9 @@ async function bump(o, size) {
|
|
|
6362
6918
|
}
|
|
6363
6919
|
return printed;
|
|
6364
6920
|
}
|
|
6365
|
-
const files = o.repo.release.versionFiles.map((f) =>
|
|
6921
|
+
const files = o.repo.release.versionFiles.map((f) => join11(o.cwd, f));
|
|
6366
6922
|
const first = files[0];
|
|
6367
|
-
if (!
|
|
6923
|
+
if (!existsSync11(first)) {
|
|
6368
6924
|
o.emit.error(`no ${o.repo.release.versionFiles[0]} to read a version from`);
|
|
6369
6925
|
return null;
|
|
6370
6926
|
}
|
|
@@ -6394,13 +6950,13 @@ async function bump(o, size) {
|
|
|
6394
6950
|
function writeChangelog(o, version, entries) {
|
|
6395
6951
|
const file = o.repo.release.changelog;
|
|
6396
6952
|
if (!file || entries.length === 0) return;
|
|
6397
|
-
const path =
|
|
6953
|
+
const path = join11(o.cwd, file);
|
|
6398
6954
|
const section = renderChangelogSection(version, (/* @__PURE__ */ new Date()).toISOString().slice(0, 10), entries);
|
|
6399
6955
|
if (o.dryRun) {
|
|
6400
6956
|
o.emit.emit(`would prepend ${entries.length} entr(ies) to ${file}`);
|
|
6401
6957
|
return;
|
|
6402
6958
|
}
|
|
6403
|
-
const existing =
|
|
6959
|
+
const existing = existsSync11(path) ? readFileSync7(path, "utf8") : "# Changelog\n";
|
|
6404
6960
|
writeFileSync6(path, insertChangelogSection(existing, section));
|
|
6405
6961
|
}
|
|
6406
6962
|
async function confirm(o, expected) {
|
|
@@ -6482,8 +7038,8 @@ async function confirmLastRelease(o, decision) {
|
|
|
6482
7038
|
function openReleaseCheckout(o) {
|
|
6483
7039
|
const { remote, base } = o.repo.branch;
|
|
6484
7040
|
const branch = `crew/release-${Date.now()}`;
|
|
6485
|
-
const root = mkdtempSync(
|
|
6486
|
-
const dir =
|
|
7041
|
+
const root = mkdtempSync(join11(tmpdir2(), "crew-release-"));
|
|
7042
|
+
const dir = join11(root, "checkout");
|
|
6487
7043
|
const close = () => {
|
|
6488
7044
|
gitOk(o.cwd, ["worktree", "remove", "--force", dir]);
|
|
6489
7045
|
rmSync2(root, { recursive: true, force: true });
|
|
@@ -6497,10 +7053,10 @@ function openReleaseCheckout(o) {
|
|
|
6497
7053
|
throw e;
|
|
6498
7054
|
}
|
|
6499
7055
|
for (const f of o.repo.worktrees.copy) {
|
|
6500
|
-
const src =
|
|
6501
|
-
if (!
|
|
6502
|
-
mkdirSync7(
|
|
6503
|
-
copyFileSync(src,
|
|
7056
|
+
const src = join11(o.cwd, f);
|
|
7057
|
+
if (!existsSync11(src)) continue;
|
|
7058
|
+
mkdirSync7(dirname8(join11(dir, f)), { recursive: true });
|
|
7059
|
+
copyFileSync(src, join11(dir, f));
|
|
6504
7060
|
}
|
|
6505
7061
|
return { dir, branch, close };
|
|
6506
7062
|
}
|
|
@@ -6546,9 +7102,18 @@ async function runRelease(o) {
|
|
|
6546
7102
|
}
|
|
6547
7103
|
if (o.repo.release.mode === "external") {
|
|
6548
7104
|
o.emit.emit("release.mode is external \u2014 the crew hands work off and does not release");
|
|
6549
|
-
const
|
|
7105
|
+
const reviewingStatus = o.contract.statuses.reviewing;
|
|
7106
|
+
const reviewing = [];
|
|
7107
|
+
if (reviewingStatus) {
|
|
7108
|
+
for (const t of o.tickets.filter((x) => x.status === reviewingStatus)) {
|
|
7109
|
+
const branch = locateBranchForTicket(o.cwd, o.repo, t)?.branch;
|
|
7110
|
+
if (branch) reviewing.push({ ticket: t, branch });
|
|
7111
|
+
}
|
|
7112
|
+
}
|
|
7113
|
+
const landed = await detectExternalClosures(o, [...decision.merges, ...reviewing]);
|
|
6550
7114
|
const externalClosures = await confirmExternalReleased(o, landed);
|
|
6551
|
-
|
|
7115
|
+
const review = await reviewHandoff(o, externalClosures, decision.merges, reviewing);
|
|
7116
|
+
return { merged: [], deployed: false, stopped: "external", decision, externalClosures, review };
|
|
6552
7117
|
}
|
|
6553
7118
|
const { remote, base } = o.repo.branch;
|
|
6554
7119
|
if (o.dryRun || o.mergeOnly || !remoteBranchExists(o.cwd, remote, base)) {
|
|
@@ -6903,7 +7468,7 @@ function missingReleaserWarning(repo, release, ships, now = Date.now()) {
|
|
|
6903
7468
|
}
|
|
6904
7469
|
|
|
6905
7470
|
// src/watch.ts
|
|
6906
|
-
import { watch as watchFile, existsSync as
|
|
7471
|
+
import { watch as watchFile, existsSync as existsSync12, statSync as statSync5, openSync as openSync2, readSync, closeSync as closeSync2 } from "fs";
|
|
6907
7472
|
var E = "\x1B[";
|
|
6908
7473
|
var dim = (s) => `${E}2m${s}${E}0m`;
|
|
6909
7474
|
var bold = (s) => `${E}1m${s}${E}0m`;
|
|
@@ -7004,7 +7569,7 @@ function renderHeaders(states, now = Date.now()) {
|
|
|
7004
7569
|
return states.map((s) => renderHeader(s, now)).join("");
|
|
7005
7570
|
}
|
|
7006
7571
|
function readFrom(file, from) {
|
|
7007
|
-
if (!
|
|
7572
|
+
if (!existsSync12(file)) return { events: [], offset: from };
|
|
7008
7573
|
const size = statSync5(file).size;
|
|
7009
7574
|
if (size < from) return readFrom(file, 0);
|
|
7010
7575
|
if (size === from) return { events: [], offset: from };
|
|
@@ -7083,96 +7648,6 @@ function startWatch(o) {
|
|
|
7083
7648
|
return stop;
|
|
7084
7649
|
}
|
|
7085
7650
|
|
|
7086
|
-
// src/ports.ts
|
|
7087
|
-
import { execFileSync as execFileSync2 } from "child_process";
|
|
7088
|
-
import { existsSync as existsSync11 } from "fs";
|
|
7089
|
-
import { join as join10 } from "path";
|
|
7090
|
-
var run = (cmd, args) => {
|
|
7091
|
-
try {
|
|
7092
|
-
return execFileSync2(cmd, args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
7093
|
-
} catch {
|
|
7094
|
-
return "";
|
|
7095
|
-
}
|
|
7096
|
-
};
|
|
7097
|
-
function listeners() {
|
|
7098
|
-
const out = [];
|
|
7099
|
-
const seen = /* @__PURE__ */ new Set();
|
|
7100
|
-
const add = (pid, port) => {
|
|
7101
|
-
const key = `${pid}:${port}`;
|
|
7102
|
-
if (!Number.isFinite(pid) || !Number.isFinite(port) || seen.has(key)) return;
|
|
7103
|
-
seen.add(key);
|
|
7104
|
-
out.push({ pid, port });
|
|
7105
|
-
};
|
|
7106
|
-
const lsof = run("lsof", ["-nP", "-iTCP", "-sTCP:LISTEN"]);
|
|
7107
|
-
if (lsof) {
|
|
7108
|
-
for (const line of lsof.split("\n").slice(1)) {
|
|
7109
|
-
const cols = line.split(/\s+/);
|
|
7110
|
-
const addr = cols[8];
|
|
7111
|
-
if (!addr) continue;
|
|
7112
|
-
add(Number(cols[1]), Number(addr.slice(addr.lastIndexOf(":") + 1)));
|
|
7113
|
-
}
|
|
7114
|
-
return out;
|
|
7115
|
-
}
|
|
7116
|
-
const ss = run("ss", ["-ltnpH"]);
|
|
7117
|
-
for (const line of ss.split("\n")) {
|
|
7118
|
-
const local = line.trim().split(/\s+/)[3];
|
|
7119
|
-
const pid = /pid=(\d+)/.exec(line)?.[1];
|
|
7120
|
-
if (!local || !pid) continue;
|
|
7121
|
-
add(Number(pid), Number(local.slice(local.lastIndexOf(":") + 1)));
|
|
7122
|
-
}
|
|
7123
|
-
return out;
|
|
7124
|
-
}
|
|
7125
|
-
var pidsOnPort = (port) => listeners().filter((l) => l.port === port).map((l) => l.pid);
|
|
7126
|
-
var DEFAULT_PORTS = { backendBase: 3e4, frontendBase: 4e4, span: 1e3 };
|
|
7127
|
-
function ticketForPort(port, s = DEFAULT_PORTS) {
|
|
7128
|
-
if (port >= s.backendBase && port < s.backendBase + s.span) return { n: port - s.backendBase, role: "backend" };
|
|
7129
|
-
if (port >= s.frontendBase && port < s.frontendBase + s.span) return { n: port - s.frontendBase, role: "frontend" };
|
|
7130
|
-
return null;
|
|
7131
|
-
}
|
|
7132
|
-
function worktreeExists(parent, prefix, n) {
|
|
7133
|
-
for (const name of [`${prefix}${n}`, `${prefix}${String(n).padStart(3, "0")}`]) {
|
|
7134
|
-
if (existsSync11(join10(parent, name, ".git"))) return true;
|
|
7135
|
-
}
|
|
7136
|
-
return false;
|
|
7137
|
-
}
|
|
7138
|
-
var worktreeExistsIn = (where, n) => where.some((w) => worktreeExists(w.parent, w.prefix, n));
|
|
7139
|
-
function findOrphansIn(where, s = DEFAULT_PORTS) {
|
|
7140
|
-
const orphans = [];
|
|
7141
|
-
for (const l of listeners()) {
|
|
7142
|
-
const t = ticketForPort(l.port, s);
|
|
7143
|
-
if (!t) continue;
|
|
7144
|
-
if (worktreeExistsIn(where, t.n)) continue;
|
|
7145
|
-
orphans.push({ ...l, ...t });
|
|
7146
|
-
}
|
|
7147
|
-
return orphans;
|
|
7148
|
-
}
|
|
7149
|
-
async function killGently(pids, graceMs = 2e3) {
|
|
7150
|
-
for (const pid of pids) {
|
|
7151
|
-
try {
|
|
7152
|
-
process.kill(pid, "SIGTERM");
|
|
7153
|
-
} catch {
|
|
7154
|
-
}
|
|
7155
|
-
}
|
|
7156
|
-
if (pids.length === 0) return;
|
|
7157
|
-
await new Promise((r) => setTimeout(r, graceMs));
|
|
7158
|
-
for (const pid of pids) {
|
|
7159
|
-
try {
|
|
7160
|
-
process.kill(pid, "SIGKILL");
|
|
7161
|
-
} catch {
|
|
7162
|
-
}
|
|
7163
|
-
}
|
|
7164
|
-
}
|
|
7165
|
-
function pidsInWorktree(path, n, s = DEFAULT_PORTS) {
|
|
7166
|
-
const pids = /* @__PURE__ */ new Set();
|
|
7167
|
-
for (const port of [s.backendBase + n, s.frontendBase + n]) for (const p of pidsOnPort(port)) pids.add(p);
|
|
7168
|
-
const matched = run("pgrep", ["-f", `${path}/`]);
|
|
7169
|
-
for (const line of matched.split("\n")) {
|
|
7170
|
-
const pid = Number(line.trim());
|
|
7171
|
-
if (Number.isFinite(pid) && pid > 0 && pid !== process.pid) pids.add(pid);
|
|
7172
|
-
}
|
|
7173
|
-
return [...pids];
|
|
7174
|
-
}
|
|
7175
|
-
|
|
7176
7651
|
// src/inbox.ts
|
|
7177
7652
|
function matchMembers(rows, query) {
|
|
7178
7653
|
const q = query.trim().toLowerCase();
|
|
@@ -7298,8 +7773,8 @@ async function decideFleet(o) {
|
|
|
7298
7773
|
}
|
|
7299
7774
|
var LOUD_STREAK = 5;
|
|
7300
7775
|
function since(iso, now = Date.now()) {
|
|
7301
|
-
const
|
|
7302
|
-
const mins = Math.round(
|
|
7776
|
+
const ms2 = Math.max(0, now - Date.parse(iso));
|
|
7777
|
+
const mins = Math.round(ms2 / 6e4);
|
|
7303
7778
|
if (mins < 60) return `${mins}m`;
|
|
7304
7779
|
const hours = Math.round(mins / 60);
|
|
7305
7780
|
if (hours < 48) return `${hours}h`;
|
|
@@ -7334,8 +7809,8 @@ function nextRoles(decision, ran) {
|
|
|
7334
7809
|
}
|
|
7335
7810
|
|
|
7336
7811
|
// src/agents.ts
|
|
7337
|
-
import { readFileSync as readFileSync8, existsSync as
|
|
7338
|
-
import { join as
|
|
7812
|
+
import { readFileSync as readFileSync8, existsSync as existsSync13 } from "fs";
|
|
7813
|
+
import { join as join12 } from "path";
|
|
7339
7814
|
import { TablationClient as TablationClient2, StaleWriteError as StaleWriteError6, TablationApiError } from "@tablation/client";
|
|
7340
7815
|
var PERSONA_NAME = {
|
|
7341
7816
|
dev: "Developer",
|
|
@@ -7349,11 +7824,11 @@ var CREW_AGENT_COLUMN = "agent_id";
|
|
|
7349
7824
|
var AgentsSyncError = class extends Error {
|
|
7350
7825
|
};
|
|
7351
7826
|
function personaDefaultPrompt(promptsDir, role) {
|
|
7352
|
-
const briefPath =
|
|
7353
|
-
if (!
|
|
7827
|
+
const briefPath = join12(promptsDir, "personas", `lane-${role}.md`);
|
|
7828
|
+
if (!existsSync13(briefPath)) throw new AgentsSyncError(`no brief at ${briefPath}`);
|
|
7354
7829
|
if (role === "pair") return readFileSync8(briefPath, "utf8");
|
|
7355
|
-
const commonPath =
|
|
7356
|
-
if (!
|
|
7830
|
+
const commonPath = join12(promptsDir, "personas", "common.md");
|
|
7831
|
+
if (!existsSync13(commonPath)) throw new AgentsSyncError(`no shared policy at ${commonPath}`);
|
|
7357
7832
|
return `${readFileSync8(commonPath, "utf8")}${readFileSync8(briefPath, "utf8")}`;
|
|
7358
7833
|
}
|
|
7359
7834
|
async function resolveAgentId(route, role, opts) {
|
|
@@ -8020,8 +8495,8 @@ import {
|
|
|
8020
8495
|
} from "@tablation/client";
|
|
8021
8496
|
|
|
8022
8497
|
// src/skills.ts
|
|
8023
|
-
import { readFileSync as readFileSync9, readdirSync as readdirSync2, existsSync as
|
|
8024
|
-
import { join as
|
|
8498
|
+
import { readFileSync as readFileSync9, readdirSync as readdirSync2, existsSync as existsSync14 } from "fs";
|
|
8499
|
+
import { join as join13 } from "path";
|
|
8025
8500
|
import { parse as parseYaml } from "yaml";
|
|
8026
8501
|
import { TablationClient as TablationClient3, StaleWriteError as StaleWriteError7, TablationApiError as TablationApiError2 } from "@tablation/client";
|
|
8027
8502
|
var SkillSyncError = class extends Error {
|
|
@@ -8040,9 +8515,9 @@ function parseSkillFile(path) {
|
|
|
8040
8515
|
return { name: meta.name, description: meta.description, prompt: body, file: path };
|
|
8041
8516
|
}
|
|
8042
8517
|
function listLocalSkills(promptsDir) {
|
|
8043
|
-
const dir =
|
|
8044
|
-
if (!
|
|
8045
|
-
return readdirSync2(dir).filter((f) => f.endsWith(".md")).map((f) => parseSkillFile(
|
|
8518
|
+
const dir = join13(promptsDir, "skills");
|
|
8519
|
+
if (!existsSync14(dir)) return [];
|
|
8520
|
+
return readdirSync2(dir).filter((f) => f.endsWith(".md")).map((f) => parseSkillFile(join13(dir, f)));
|
|
8046
8521
|
}
|
|
8047
8522
|
async function syncSkills(route, opts) {
|
|
8048
8523
|
if (!route.resolved) {
|
|
@@ -8369,7 +8844,7 @@ function resolveRepoProject(projects, opt, routeAreaId) {
|
|
|
8369
8844
|
import { spawnSync as spawnSync2 } from "child_process";
|
|
8370
8845
|
import { readFileSync as readFileSync10 } from "fs";
|
|
8371
8846
|
import { homedir as homedir4 } from "os";
|
|
8372
|
-
import { join as
|
|
8847
|
+
import { join as join14 } from "path";
|
|
8373
8848
|
var MCP_SERVER_NAME = "tablation";
|
|
8374
8849
|
var redact = (s, key) => key ? s.split(key).join("sk_***") : s;
|
|
8375
8850
|
function mcpUrlFor(baseUrl) {
|
|
@@ -8382,7 +8857,7 @@ function runClaude(bin, args) {
|
|
|
8382
8857
|
}
|
|
8383
8858
|
function userScopeEntry(home) {
|
|
8384
8859
|
try {
|
|
8385
|
-
const j = JSON.parse(readFileSync10(
|
|
8860
|
+
const j = JSON.parse(readFileSync10(join14(home, ".claude.json"), "utf8"));
|
|
8386
8861
|
return j.mcpServers?.[MCP_SERVER_NAME];
|
|
8387
8862
|
} catch {
|
|
8388
8863
|
return void 0;
|
|
@@ -8469,22 +8944,26 @@ function schedulerInstalled(plan, exists, crontab) {
|
|
|
8469
8944
|
}
|
|
8470
8945
|
|
|
8471
8946
|
// src/worktree-sweep.ts
|
|
8472
|
-
import { existsSync as
|
|
8947
|
+
import { existsSync as existsSync15 } from "fs";
|
|
8473
8948
|
import { basename as basename6 } from "path";
|
|
8474
8949
|
function planWorktreeSweep(target, tickets, contract) {
|
|
8475
8950
|
const terminal = new Set(contract.statuses.resolved.filter((s) => s !== contract.statuses.verified));
|
|
8951
|
+
const verified = contract.statuses.verified;
|
|
8476
8952
|
const byNumber = new Map(tickets.map((t) => [t.issue_id.replace(/^\D+/, ""), t]));
|
|
8477
8953
|
const actions = [];
|
|
8478
8954
|
for (const w of worktrees(target.dir)) {
|
|
8479
8955
|
const m = /(?:^|-)(\d+)$/.exec(basename6(w.path));
|
|
8480
8956
|
if (!m) continue;
|
|
8481
8957
|
const ticket = byNumber.get(m[1]);
|
|
8482
|
-
if (!ticket
|
|
8958
|
+
if (!ticket) continue;
|
|
8959
|
+
const isVerified = ticket.status === verified;
|
|
8960
|
+
if (!isVerified && !terminal.has(ticket.status)) continue;
|
|
8483
8961
|
actions.push({
|
|
8484
8962
|
ticket,
|
|
8485
8963
|
path: w.path,
|
|
8486
8964
|
branch: w.branch,
|
|
8487
|
-
keepBranch: ticket.status !== contract.statuses.deployed
|
|
8965
|
+
keepBranch: ticket.status !== contract.statuses.deployed,
|
|
8966
|
+
...isVerified ? { requirePushed: true } : {}
|
|
8488
8967
|
});
|
|
8489
8968
|
}
|
|
8490
8969
|
return actions;
|
|
@@ -8493,7 +8972,7 @@ async function applyWorktreeSweep(cwd, actions, dryRun, log, remote = "origin")
|
|
|
8493
8972
|
let removed = 0;
|
|
8494
8973
|
let keptBranches = 0;
|
|
8495
8974
|
for (const a of actions) {
|
|
8496
|
-
if (!dryRun &&
|
|
8975
|
+
if (!dryRun && existsSync15(a.path)) {
|
|
8497
8976
|
let dirty = false;
|
|
8498
8977
|
try {
|
|
8499
8978
|
dirty = status(a.path).length > 0;
|
|
@@ -8507,6 +8986,13 @@ async function applyWorktreeSweep(cwd, actions, dryRun, log, remote = "origin")
|
|
|
8507
8986
|
continue;
|
|
8508
8987
|
}
|
|
8509
8988
|
}
|
|
8989
|
+
if (!dryRun && a.requirePushed && a.branch && existsSync15(a.path) && remoteConfigured(cwd, remote) && worktreeHoldsUnpushed(a.path, remote)) {
|
|
8990
|
+
log.warn(
|
|
8991
|
+
`not removing worktree ${a.path} for ${a.ticket.issue_id} (${a.ticket.status}): ${a.branch} has commits that are not on ${remote}`,
|
|
8992
|
+
{ ticket: a.ticket.issue_id, step: "worktree" }
|
|
8993
|
+
);
|
|
8994
|
+
continue;
|
|
8995
|
+
}
|
|
8510
8996
|
const n = Number(a.ticket.issue_id.replace(/^\D+/, ""));
|
|
8511
8997
|
const pids = pidsInWorktree(a.path, n);
|
|
8512
8998
|
if (pids.length) {
|
|
@@ -8581,14 +9067,14 @@ function applyRemoteBranchCleanup(cwd, remote, branches2, dryRun, log) {
|
|
|
8581
9067
|
}
|
|
8582
9068
|
|
|
8583
9069
|
// src/stream-sweep.ts
|
|
8584
|
-
import { existsSync as
|
|
8585
|
-
import { join as
|
|
9070
|
+
import { existsSync as existsSync16, readdirSync as readdirSync3, rmSync as rmSync3, statSync as statSync6 } from "fs";
|
|
9071
|
+
import { join as join15 } from "path";
|
|
8586
9072
|
var DAY_MS2 = 24 * 60 * 60 * 1e3;
|
|
8587
9073
|
function planStreamSweep(stateDir, retentionDays, now = Date.now()) {
|
|
8588
|
-
const dir =
|
|
8589
|
-
if (!
|
|
9074
|
+
const dir = join15(stateDir, "streams");
|
|
9075
|
+
if (!existsSync16(dir)) return [];
|
|
8590
9076
|
const cutoff = now - retentionDays * DAY_MS2;
|
|
8591
|
-
return readdirSync3(dir).map((name) =>
|
|
9077
|
+
return readdirSync3(dir).map((name) => join15(dir, name)).filter((path) => {
|
|
8592
9078
|
try {
|
|
8593
9079
|
return statSync6(path).mtimeMs < cutoff;
|
|
8594
9080
|
} catch {
|
|
@@ -8615,24 +9101,24 @@ function applyStreamSweep(paths, dryRun, log) {
|
|
|
8615
9101
|
}
|
|
8616
9102
|
|
|
8617
9103
|
// src/cli.ts
|
|
8618
|
-
import { readFileSync as readFileSync14, existsSync as
|
|
9104
|
+
import { readFileSync as readFileSync14, existsSync as existsSync20, mkdirSync as mkdirSync11, writeFileSync as writeFileSync9, unlinkSync as unlinkSync5, copyFileSync as copyFileSync2, renameSync } from "fs";
|
|
8619
9105
|
import { createInterface } from "readline/promises";
|
|
8620
9106
|
import { dirname as dirOf, resolve as resolvePath } from "path";
|
|
8621
9107
|
|
|
8622
9108
|
// src/ssh-keys.ts
|
|
8623
9109
|
import { execFileSync as execFileSync3 } from "child_process";
|
|
8624
|
-
import { existsSync as
|
|
8625
|
-
import { dirname as
|
|
9110
|
+
import { existsSync as existsSync17, mkdirSync as mkdirSync8, readFileSync as readFileSync11, chmodSync } from "fs";
|
|
9111
|
+
import { dirname as dirname10, join as join16 } from "path";
|
|
8626
9112
|
var SshKeyError = class extends Error {
|
|
8627
9113
|
};
|
|
8628
9114
|
function shipSshKeyPathFor(stateDir) {
|
|
8629
|
-
return
|
|
9115
|
+
return join16(stateDir, "ssh", "id_ed25519");
|
|
8630
9116
|
}
|
|
8631
9117
|
function ensureShipSshKeypair(stateDir) {
|
|
8632
9118
|
const privateKeyPath = shipSshKeyPathFor(stateDir);
|
|
8633
9119
|
const publicKeyPath = `${privateKeyPath}.pub`;
|
|
8634
|
-
if (!
|
|
8635
|
-
mkdirSync8(
|
|
9120
|
+
if (!existsSync17(privateKeyPath)) {
|
|
9121
|
+
mkdirSync8(dirname10(privateKeyPath), { recursive: true });
|
|
8636
9122
|
try {
|
|
8637
9123
|
execFileSync3("ssh-keygen", [
|
|
8638
9124
|
"-t",
|
|
@@ -8652,7 +9138,7 @@ function ensureShipSshKeypair(stateDir) {
|
|
|
8652
9138
|
}
|
|
8653
9139
|
chmodSync(privateKeyPath, 384);
|
|
8654
9140
|
}
|
|
8655
|
-
if (!
|
|
9141
|
+
if (!existsSync17(publicKeyPath)) {
|
|
8656
9142
|
throw new SshKeyError(`${privateKeyPath} exists but ${publicKeyPath} does not \u2014 regenerate or restore it`);
|
|
8657
9143
|
}
|
|
8658
9144
|
const publicKey = readFileSync11(publicKeyPath, "utf8").trim();
|
|
@@ -8869,9 +9355,9 @@ function syncAllPassengerCheckouts(cfg) {
|
|
|
8869
9355
|
|
|
8870
9356
|
// src/tunnel.ts
|
|
8871
9357
|
import { spawn as spawn3 } from "child_process";
|
|
8872
|
-
import { existsSync as
|
|
9358
|
+
import { existsSync as existsSync18, mkdirSync as mkdirSync9, readFileSync as readFileSync12, writeFileSync as writeFileSync7, unlinkSync as unlinkSync3, readdirSync as readdirSync4 } from "fs";
|
|
8873
9359
|
import { randomInt } from "crypto";
|
|
8874
|
-
import { join as
|
|
9360
|
+
import { join as join17 } from "path";
|
|
8875
9361
|
var DEFAULT_SPAWN_OPTIONS = { stdio: "ignore", detached: true };
|
|
8876
9362
|
var defaultSpawn = (cmd, args) => spawn3(cmd, args, DEFAULT_SPAWN_OPTIONS);
|
|
8877
9363
|
var SLUG_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
@@ -8938,11 +9424,11 @@ function startTunnel(opts) {
|
|
|
8938
9424
|
};
|
|
8939
9425
|
}
|
|
8940
9426
|
function tunnelStatePath(stateDir, workspaceId) {
|
|
8941
|
-
return
|
|
9427
|
+
return join17(stateDir, "passengers", `${workspaceId}.json`);
|
|
8942
9428
|
}
|
|
8943
9429
|
function readPersistedTunnel(stateDir, workspaceId) {
|
|
8944
9430
|
const path = tunnelStatePath(stateDir, workspaceId);
|
|
8945
|
-
if (!
|
|
9431
|
+
if (!existsSync18(path)) return void 0;
|
|
8946
9432
|
try {
|
|
8947
9433
|
return JSON.parse(readFileSync12(path, "utf8"));
|
|
8948
9434
|
} catch {
|
|
@@ -8951,7 +9437,7 @@ function readPersistedTunnel(stateDir, workspaceId) {
|
|
|
8951
9437
|
}
|
|
8952
9438
|
function writePersistedTunnel(stateDir, state) {
|
|
8953
9439
|
const path = tunnelStatePath(stateDir, state.workspaceId);
|
|
8954
|
-
mkdirSync9(
|
|
9440
|
+
mkdirSync9(join17(stateDir, "passengers"), { recursive: true });
|
|
8955
9441
|
writeFileSync7(path, `${JSON.stringify(state, null, 2)}
|
|
8956
9442
|
`);
|
|
8957
9443
|
}
|
|
@@ -9049,8 +9535,8 @@ function syncPassengerTunnels(plans, relayHost, relayPort, privateKeyPath, state
|
|
|
9049
9535
|
}
|
|
9050
9536
|
}
|
|
9051
9537
|
function readAllPersistedWorkspaceIds(stateDir) {
|
|
9052
|
-
const dir =
|
|
9053
|
-
if (!
|
|
9538
|
+
const dir = join17(stateDir, "passengers");
|
|
9539
|
+
if (!existsSync18(dir)) return [];
|
|
9054
9540
|
return readdirSync4(dir).filter((f) => f.endsWith(".json") && !f.endsWith("-sync.json")).map((f) => f.slice(0, -".json".length));
|
|
9055
9541
|
}
|
|
9056
9542
|
|
|
@@ -9171,8 +9657,8 @@ async function unregisterEndpoint(workspaceId, info, d) {
|
|
|
9171
9657
|
import { createServer as createHttpServer } from "http";
|
|
9172
9658
|
import { spawn as spawn4 } from "child_process";
|
|
9173
9659
|
import { randomBytes } from "crypto";
|
|
9174
|
-
import { existsSync as
|
|
9175
|
-
import { join as
|
|
9660
|
+
import { existsSync as existsSync19, mkdirSync as mkdirSync10, readFileSync as readFileSync13, writeFileSync as writeFileSync8, unlinkSync as unlinkSync4, readdirSync as readdirSync5 } from "fs";
|
|
9661
|
+
import { join as join18 } from "path";
|
|
9176
9662
|
|
|
9177
9663
|
// src/secret-compare.ts
|
|
9178
9664
|
import { timingSafeEqual } from "crypto";
|
|
@@ -9261,11 +9747,11 @@ var defaultSpawn2 = (cmd, args, env) => {
|
|
|
9261
9747
|
return child;
|
|
9262
9748
|
};
|
|
9263
9749
|
function syncDaemonStatePath(stateDir, workspaceId) {
|
|
9264
|
-
return
|
|
9750
|
+
return join18(stateDir, "passengers", `${workspaceId}-sync.json`);
|
|
9265
9751
|
}
|
|
9266
9752
|
function readPersistedSyncDaemon(stateDir, workspaceId) {
|
|
9267
9753
|
const path = syncDaemonStatePath(stateDir, workspaceId);
|
|
9268
|
-
if (!
|
|
9754
|
+
if (!existsSync19(path)) return void 0;
|
|
9269
9755
|
try {
|
|
9270
9756
|
return JSON.parse(readFileSync13(path, "utf8"));
|
|
9271
9757
|
} catch {
|
|
@@ -9274,7 +9760,7 @@ function readPersistedSyncDaemon(stateDir, workspaceId) {
|
|
|
9274
9760
|
}
|
|
9275
9761
|
function writePersistedSyncDaemon(stateDir, state) {
|
|
9276
9762
|
const path = syncDaemonStatePath(stateDir, state.workspaceId);
|
|
9277
|
-
mkdirSync10(
|
|
9763
|
+
mkdirSync10(join18(stateDir, "passengers"), { recursive: true });
|
|
9278
9764
|
writeFileSync8(path, `${JSON.stringify(state, null, 2)}
|
|
9279
9765
|
`);
|
|
9280
9766
|
}
|
|
@@ -9285,8 +9771,8 @@ function removePersistedSyncDaemon(stateDir, workspaceId) {
|
|
|
9285
9771
|
} catch {
|
|
9286
9772
|
}
|
|
9287
9773
|
}
|
|
9288
|
-
function sleepSync2(
|
|
9289
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0,
|
|
9774
|
+
function sleepSync2(ms2) {
|
|
9775
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms2);
|
|
9290
9776
|
}
|
|
9291
9777
|
function waitForExit(pid, alive, timeoutMs = 5e3) {
|
|
9292
9778
|
const deadline = Date.now() + timeoutMs;
|
|
@@ -9295,8 +9781,8 @@ function waitForExit(pid, alive, timeoutMs = 5e3) {
|
|
|
9295
9781
|
}
|
|
9296
9782
|
}
|
|
9297
9783
|
function readAllPersistedSyncWorkspaceIds(stateDir) {
|
|
9298
|
-
const dir =
|
|
9299
|
-
if (!
|
|
9784
|
+
const dir = join18(stateDir, "passengers");
|
|
9785
|
+
if (!existsSync19(dir)) return [];
|
|
9300
9786
|
const suffix = "-sync.json";
|
|
9301
9787
|
return readdirSync5(dir).filter((f) => f.endsWith(suffix)).map((f) => f.slice(0, -suffix.length));
|
|
9302
9788
|
}
|
|
@@ -9354,10 +9840,10 @@ function syncPassengerSyncDaemons(plans, cfg, stateDir, deps = {}) {
|
|
|
9354
9840
|
|
|
9355
9841
|
// src/daemon.ts
|
|
9356
9842
|
import { readdirSync as readdirSync6, statSync as statSync7 } from "fs";
|
|
9357
|
-
import { join as
|
|
9843
|
+
import { join as join19 } from "path";
|
|
9358
9844
|
var DEFAULT_FLOOR_MS = 5e3;
|
|
9359
9845
|
var DEFAULT_CEILING_MS = INTERVAL_SECONDS * 1e3;
|
|
9360
|
-
var defaultSleep = (
|
|
9846
|
+
var defaultSleep = (ms2) => new Promise((resolve5) => setTimeout(resolve5, ms2));
|
|
9361
9847
|
async function runDaemonLoop(o) {
|
|
9362
9848
|
const floor = o.floorMs ?? DEFAULT_FLOOR_MS;
|
|
9363
9849
|
const ceiling = o.ceilingMs ?? DEFAULT_CEILING_MS;
|
|
@@ -9393,7 +9879,7 @@ function latestMtime(path) {
|
|
|
9393
9879
|
if (!stat.isDirectory()) return 0;
|
|
9394
9880
|
let latest = stat.mtimeMs;
|
|
9395
9881
|
for (const entry of readdirSync6(path, { withFileTypes: true })) {
|
|
9396
|
-
latest = Math.max(latest, latestMtime(
|
|
9882
|
+
latest = Math.max(latest, latestMtime(join19(path, entry.name)));
|
|
9397
9883
|
}
|
|
9398
9884
|
return latest;
|
|
9399
9885
|
}
|
|
@@ -9460,6 +9946,24 @@ async function runOnePass(o) {
|
|
|
9460
9946
|
const tracker = new Tracker(o.route, o.ship);
|
|
9461
9947
|
await applyHoldReleases(tracker, decision.holdReleases, tracker.contract, emit);
|
|
9462
9948
|
}
|
|
9949
|
+
{
|
|
9950
|
+
const tracker = new Tracker(o.route, o.ship);
|
|
9951
|
+
const memberId = o.route.resolved?.seats.qa ?? o.route.resolved?.seats.dev;
|
|
9952
|
+
try {
|
|
9953
|
+
if (memberId) await runStalledSweep({
|
|
9954
|
+
tracker,
|
|
9955
|
+
state: o.state,
|
|
9956
|
+
emit,
|
|
9957
|
+
route: o.route,
|
|
9958
|
+
ship: o.ship,
|
|
9959
|
+
ships: decision.ships,
|
|
9960
|
+
memberId,
|
|
9961
|
+
myShipId: shipIdByName(decision.ships, o.ship.name)
|
|
9962
|
+
}, decision.stalled ?? []);
|
|
9963
|
+
} catch (e) {
|
|
9964
|
+
emit.warn(`could not sweep stalled tickets: ${e.message}`, { step: "sweep" });
|
|
9965
|
+
}
|
|
9966
|
+
}
|
|
9463
9967
|
const order = [...decision.selection.pending].sort(
|
|
9464
9968
|
(a, b) => (decision.selection.ranks[a] ?? Number.POSITIVE_INFINITY) - (decision.selection.ranks[b] ?? Number.POSITIVE_INFINITY)
|
|
9465
9969
|
);
|
|
@@ -9659,7 +10163,7 @@ async function buildEnvironment(route, ship, ticket) {
|
|
|
9659
10163
|
}
|
|
9660
10164
|
|
|
9661
10165
|
// src/cli.ts
|
|
9662
|
-
var CREW_HOME = isCompiledBinary(import.meta.url) ?
|
|
10166
|
+
var CREW_HOME = isCompiledBinary(import.meta.url) ? dirname11(process.execPath) : resolve4(dirname11(fileURLToPath(import.meta.url)), "..");
|
|
9663
10167
|
var RELEASE_LOCK_TTL_MS = 20 * 60 * 1e3;
|
|
9664
10168
|
var CYCLE_SECONDS2 = 120;
|
|
9665
10169
|
var STALE_CYCLES = 3;
|
|
@@ -9784,6 +10288,7 @@ function usage() {
|
|
|
9784
10288
|
crew reap [route] kill orphaned servers, drop worktrees for closed tickets
|
|
9785
10289
|
crew drop [route] NNN remove a merged ticket's worktree and branch
|
|
9786
10290
|
crew rotate-passenger-url [route] force a Host Passengers tunnel to reconnect with a fresh public URL
|
|
10291
|
+
crew worktree [route] NNN cut (or refresh) this run's worktree for a ticket from the remote; prints its path
|
|
9787
10292
|
crew unassign [route] NNN hand back a session's ticket \u2014 clears assignee, next cycle picks it up
|
|
9788
10293
|
crew sync [route] fast-forward the checkout and its worktrees from the remote
|
|
9789
10294
|
crew pause|resume [route] [R|release] pause everything, one role, or releases only (merge/deploy/release; passengers keeps running).
|
|
@@ -10148,8 +10653,8 @@ if (command === "passenger-sync-daemon") {
|
|
|
10148
10653
|
}
|
|
10149
10654
|
if (host === "linux" && hasSystemd) {
|
|
10150
10655
|
const label = labelFor(CREW_HOME, "run");
|
|
10151
|
-
const staleTimer =
|
|
10152
|
-
if (
|
|
10656
|
+
const staleTimer = join20(homedir5(), ".config", "systemd", "user", `${label}.timer`);
|
|
10657
|
+
if (existsSync20(staleTimer)) {
|
|
10153
10658
|
process.stdout.write(
|
|
10154
10659
|
`removing stale run timer from before the persistent-service cutover: ${staleTimer}${dryRun ? " (dry run)" : ""}
|
|
10155
10660
|
`
|
|
@@ -10175,7 +10680,7 @@ if (command === "passenger-sync-daemon") {
|
|
|
10175
10680
|
const tracker = new Tracker(c, cfg.ship);
|
|
10176
10681
|
problems.push(...validateContract(tracker.contract).map((p) => `contract: ${p}`));
|
|
10177
10682
|
for (const r of await resolvedRepos(c)) {
|
|
10178
|
-
if (!
|
|
10683
|
+
if (!existsSync20(r.dir)) problems.push(`repo ${r.name}: ${r.dir} is missing on disk`);
|
|
10179
10684
|
if (!satisfies(host, r.config.platform)) problems.push(`repo ${r.name}: requires ${r.config.platform}, this host is ${host}`);
|
|
10180
10685
|
problems.push(...validateEffective(r.config).map((p) => `repo ${r.name}: ${p}`));
|
|
10181
10686
|
}
|
|
@@ -10193,7 +10698,7 @@ if (command === "passenger-sync-daemon") {
|
|
|
10193
10698
|
}
|
|
10194
10699
|
async function doctorFix(o) {
|
|
10195
10700
|
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))));
|
|
10196
|
-
const unitExists = (p) =>
|
|
10701
|
+
const unitExists = (p) => existsSync20(p);
|
|
10197
10702
|
const crontab = o.host === "linux" ? spawnSync3("crontab", ["-l"], { encoding: "utf8" }).stdout ?? "" : "";
|
|
10198
10703
|
const schedulerAbsent = o.host !== "windows" ? !schedulerInstalled(planInstall(cfg.ship, CREW_HOME, o.host, detectSystemd(), "run"), unitExists, crontab) : true;
|
|
10199
10704
|
const plan = planFix({ routes, schedulerAbsent, host: o.host });
|
|
@@ -10336,6 +10841,10 @@ ${changed2.map((c) => ` - ${c}
|
|
|
10336
10841
|
return { scope, tests: "skipped", outcome: "skipped", detail: `release ${why} on the board` };
|
|
10337
10842
|
}
|
|
10338
10843
|
boardLock = got;
|
|
10844
|
+
remit.emit(
|
|
10845
|
+
got.exclusion === "taken" ? `took board lock ${scope}` : `this board has no Locks table \u2014 cross-ship exclusion for ${scope} is OFF`,
|
|
10846
|
+
{ step: "release" }
|
|
10847
|
+
);
|
|
10339
10848
|
}
|
|
10340
10849
|
const all = await tracker.openTickets();
|
|
10341
10850
|
const { byRepo, unplaceable } = ticketsByRepo(c, all);
|
|
@@ -10349,6 +10858,7 @@ ${changed2.map((c) => ` - ${c}
|
|
|
10349
10858
|
emit: remit,
|
|
10350
10859
|
scope,
|
|
10351
10860
|
state: state.release(scope),
|
|
10861
|
+
reviewColumns: repo.release.mode === "external" ? await tracker.reviewColumns() : void 0,
|
|
10352
10862
|
dryRun,
|
|
10353
10863
|
skipTests: flag("skip-tests"),
|
|
10354
10864
|
shell: cfg.ship.shell,
|
|
@@ -10411,6 +10921,18 @@ ${changed2.map((c) => ` - ${c}
|
|
|
10411
10921
|
if (outcome.externalClosures?.length) {
|
|
10412
10922
|
await applyExternalClosures(tracker, outcome.externalClosures, tracker.contract, remit, dryRun, target.dir);
|
|
10413
10923
|
}
|
|
10924
|
+
if (outcome.review) {
|
|
10925
|
+
await applyReview(
|
|
10926
|
+
tracker,
|
|
10927
|
+
tracker.contract,
|
|
10928
|
+
outcome.review,
|
|
10929
|
+
repo.branch.remote,
|
|
10930
|
+
remit,
|
|
10931
|
+
releaseSeat(c),
|
|
10932
|
+
await tracker.reviewColumns(),
|
|
10933
|
+
dryRun
|
|
10934
|
+
);
|
|
10935
|
+
}
|
|
10414
10936
|
const news = describeRelease(outcome, scope);
|
|
10415
10937
|
if (news) {
|
|
10416
10938
|
const notified = await notify(c, cfg.ship, news, remit, dryRun);
|
|
@@ -10467,8 +10989,9 @@ ${changed2.map((c) => ` - ${c}
|
|
|
10467
10989
|
}
|
|
10468
10990
|
try {
|
|
10469
10991
|
const terminal = await tracker.terminalTickets();
|
|
10992
|
+
const verifiedNow = (await tracker.openTickets()).filter((t) => t.status === tracker.contract.statuses.verified);
|
|
10470
10993
|
const scoped = ticketsByRepo(c, terminal).byRepo.get(target.name) ?? [];
|
|
10471
|
-
const actions = planWorktreeSweep(target, scoped, tracker.contract);
|
|
10994
|
+
const actions = planWorktreeSweep(target, [...scoped, ...ticketsByRepo(c, verifiedNow).byRepo.get(target.name) ?? []], tracker.contract);
|
|
10472
10995
|
if (actions.length) {
|
|
10473
10996
|
remit.enter("worktree");
|
|
10474
10997
|
const r = await applyWorktreeSweep(target.dir, actions, dryRun, remit, repo.branch.remote);
|
|
@@ -11026,7 +11549,7 @@ ${changed2.map((c) => ` - ${c}
|
|
|
11026
11549
|
process.exit(1);
|
|
11027
11550
|
}
|
|
11028
11551
|
requireArmed("run the daemon loop");
|
|
11029
|
-
const watchPaths = isCompiledBinary(import.meta.url) ? [process.execPath] : [
|
|
11552
|
+
const watchPaths = isCompiledBinary(import.meta.url) ? [process.execPath] : [join20(CREW_HOME, "src"), join20(CREW_HOME, "dist"), join20(CREW_HOME, "bin")];
|
|
11030
11553
|
const spawnBreaker = new SpawnBreaker();
|
|
11031
11554
|
const stopReason = await runDaemonLoop({
|
|
11032
11555
|
runPass: () => runOnePass({
|
|
@@ -11174,7 +11697,7 @@ ${changed2.map((c) => ` - ${c}
|
|
|
11174
11697
|
`);
|
|
11175
11698
|
if (!dryRun) {
|
|
11176
11699
|
const resolvedPath = resolvedPathFor(cfg.ship.stateDir, target.route);
|
|
11177
|
-
const raw =
|
|
11700
|
+
const raw = existsSync20(resolvedPath) ? JSON.parse(readFileSync14(resolvedPath, "utf8")) : {};
|
|
11178
11701
|
raw.agentPersonas = personaResult.agentPersonas;
|
|
11179
11702
|
raw.agentSkills = skillResult.agentSkills;
|
|
11180
11703
|
mkdirSync11(dirOf(resolvedPath), { recursive: true });
|
|
@@ -11223,7 +11746,7 @@ ${changed2.map((c) => ` - ${c}
|
|
|
11223
11746
|
`);
|
|
11224
11747
|
if (!dryRun) {
|
|
11225
11748
|
const resolvedPath = resolvedPathFor(cfg.ship.stateDir, target.route);
|
|
11226
|
-
const raw =
|
|
11749
|
+
const raw = existsSync20(resolvedPath) ? JSON.parse(readFileSync14(resolvedPath, "utf8")) : {};
|
|
11227
11750
|
raw.agentSkills = result.agentSkills;
|
|
11228
11751
|
mkdirSync11(dirOf(resolvedPath), { recursive: true });
|
|
11229
11752
|
writeFileSync9(resolvedPath, `${JSON.stringify(raw, null, 2)}
|
|
@@ -11246,7 +11769,7 @@ ${changed2.map((c) => ` - ${c}
|
|
|
11246
11769
|
if (sub === "list" && !routeName) usage2();
|
|
11247
11770
|
if (sub === "add") {
|
|
11248
11771
|
try {
|
|
11249
|
-
const a = parseAddArgs(positional.slice(2), routeNamesOf(configText), (p) =>
|
|
11772
|
+
const a = parseAddArgs(positional.slice(2), routeNamesOf(configText), (p) => existsSync20(resolvePath(p.replace(/^~(?=\/|$)/, homedir5()))));
|
|
11250
11773
|
routeName = a.route;
|
|
11251
11774
|
addPath = a.path;
|
|
11252
11775
|
addName = a.name;
|
|
@@ -11261,7 +11784,7 @@ ${changed2.map((c) => ` - ${c}
|
|
|
11261
11784
|
}
|
|
11262
11785
|
const resolvedFile = resolvedPathFor(cfg.ship.stateDir, routeName);
|
|
11263
11786
|
let resolvedRemotes;
|
|
11264
|
-
if (
|
|
11787
|
+
if (existsSync20(resolvedFile)) {
|
|
11265
11788
|
try {
|
|
11266
11789
|
resolvedRemotes = JSON.parse(readFileSync14(resolvedFile, "utf8"));
|
|
11267
11790
|
} catch {
|
|
@@ -11269,7 +11792,7 @@ ${changed2.map((c) => ` - ${c}
|
|
|
11269
11792
|
}
|
|
11270
11793
|
const originOf = (dir) => gitOk(dir, ["remote", "get-url", "origin"]) ?? void 0;
|
|
11271
11794
|
const text = configText;
|
|
11272
|
-
const base =
|
|
11795
|
+
const base = dirname11(cfg.configFile);
|
|
11273
11796
|
try {
|
|
11274
11797
|
if (sub === "list") {
|
|
11275
11798
|
const repos = configuredRepos(text, routeName, base);
|
|
@@ -11278,14 +11801,14 @@ ${changed2.map((c) => ` - ${c}
|
|
|
11278
11801
|
for (const r of repos) {
|
|
11279
11802
|
const origin2 = originOf(r.dir);
|
|
11280
11803
|
const row = matchRepoName(origin2, resolvedRemotes);
|
|
11281
|
-
const note = !
|
|
11804
|
+
const note = !existsSync20(r.dir) ? "missing on disk" : row ? `Repos row: ${row}` : `no Repos row matches ${normalizeRemote(origin2) ?? "this checkout (no origin remote)"}`;
|
|
11282
11805
|
process.stdout.write(`${r.name} ${r.dir} ${note}
|
|
11283
11806
|
`);
|
|
11284
11807
|
}
|
|
11285
11808
|
break;
|
|
11286
11809
|
}
|
|
11287
11810
|
const target = resolvePath(addPath.replace(/^~(?=\/|$)/, homedir5()));
|
|
11288
|
-
const top =
|
|
11811
|
+
const top = existsSync20(target) ? gitOk(target, ["rev-parse", "--show-toplevel"]) : null;
|
|
11289
11812
|
if (!top) throw new ReposError(`${target} is not a git checkout`);
|
|
11290
11813
|
const origin = originOf(top);
|
|
11291
11814
|
const plan = planRepoAdd({
|
|
@@ -11333,7 +11856,7 @@ ${changed2.map((c) => ` - ${c}
|
|
|
11333
11856
|
const created = await rowTracker.createRepoRow(rowPayload);
|
|
11334
11857
|
process.stdout.write(`created Repos row ${String(rowPayload.name)} (${String(rowPayload.remote)}) ${created.id}
|
|
11335
11858
|
`);
|
|
11336
|
-
if (
|
|
11859
|
+
if (existsSync20(resolvedFile)) {
|
|
11337
11860
|
const raw = JSON.parse(readFileSync14(resolvedFile, "utf8"));
|
|
11338
11861
|
raw.repoNames = { ...raw.repoNames, [created.id]: String(rowPayload.name) };
|
|
11339
11862
|
raw.repoRemotes = { ...raw.repoRemotes, [created.id]: String(rowPayload.remote) };
|
|
@@ -11578,7 +12101,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
|
|
|
11578
12101
|
operator = await pickOperator(found.holds);
|
|
11579
12102
|
}
|
|
11580
12103
|
let previous;
|
|
11581
|
-
const hadPreviousFile =
|
|
12104
|
+
const hadPreviousFile = existsSync20(resolvedPath);
|
|
11582
12105
|
if (hadPreviousFile) {
|
|
11583
12106
|
try {
|
|
11584
12107
|
previous = JSON.parse(readFileSync14(resolvedPath, "utf8"));
|
|
@@ -11642,7 +12165,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
|
|
|
11642
12165
|
process.stderr.write(`(dry run) would write resolved ids to ${resolvedPath}
|
|
11643
12166
|
`);
|
|
11644
12167
|
} else {
|
|
11645
|
-
mkdirSync11(
|
|
12168
|
+
mkdirSync11(dirname11(resolvedPath), { recursive: true });
|
|
11646
12169
|
if (hadPreviousFile) copyFileSync2(resolvedPath, `${resolvedPath}.bak`);
|
|
11647
12170
|
const tmpPath = `${resolvedPath}.tmp${process.pid}`;
|
|
11648
12171
|
writeFileSync9(tmpPath, `${JSON.stringify(resolved, null, 2)}
|
|
@@ -11659,7 +12182,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
|
|
|
11659
12182
|
process.stderr.write(`(dry run) would write the minted API key to ${apiKeyFile}
|
|
11660
12183
|
`);
|
|
11661
12184
|
} else {
|
|
11662
|
-
mkdirSync11(
|
|
12185
|
+
mkdirSync11(dirname11(apiKeyFile), { recursive: true });
|
|
11663
12186
|
writeFileSync9(apiKeyFile, `${apiKeyVar}=${apiKey}
|
|
11664
12187
|
`, { mode: 384 });
|
|
11665
12188
|
process.stderr.write(`API key written to ${apiKeyFile}
|
|
@@ -11669,7 +12192,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
|
|
|
11669
12192
|
const keyPersisted = keychainBacked || apiKeyFile !== void 0;
|
|
11670
12193
|
const wizard = firstRun;
|
|
11671
12194
|
const writeFirstRun = wizard !== void 0 && !dryRun && keyPersisted;
|
|
11672
|
-
const routeDir = value("dir") ?? (writeFirstRun ?
|
|
12195
|
+
const routeDir = value("dir") ?? (writeFirstRun ? join20(DEFAULT_REPOS_BASE_PATH, found.workspaceSlug) : void 0);
|
|
11673
12196
|
const routeBlock = renderConnection(
|
|
11674
12197
|
found,
|
|
11675
12198
|
route2,
|
|
@@ -11845,8 +12368,12 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
|
|
|
11845
12368
|
const tracker = new Tracker(route, cfg.ship);
|
|
11846
12369
|
const terminal = await tracker.terminalTickets();
|
|
11847
12370
|
const { byRepo } = ticketsByRepo(route, terminal);
|
|
12371
|
+
const verifiedByRepo = ticketsByRepo(
|
|
12372
|
+
route,
|
|
12373
|
+
(await tracker.openTickets()).filter((t) => t.status === tracker.contract.statuses.verified)
|
|
12374
|
+
).byRepo;
|
|
11848
12375
|
for (const r of await resolvedRepos(route)) {
|
|
11849
|
-
const actions = planWorktreeSweep(r, byRepo.get(r.name) ?? [], tracker.contract);
|
|
12376
|
+
const actions = planWorktreeSweep(r, [...byRepo.get(r.name) ?? [], ...verifiedByRepo.get(r.name) ?? []], tracker.contract);
|
|
11850
12377
|
const stale = planRemoteBranchCleanup(r.dir, r.config.branch.remote, r.config.branch.base, byRepo.get(r.name) ?? [], tracker.contract);
|
|
11851
12378
|
if (stale.length) {
|
|
11852
12379
|
anything = true;
|
|
@@ -11984,6 +12511,40 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
|
|
|
11984
12511
|
emit.emit(`rotated the passenger tunnel for ${route.route} \u2014 it reconnects under a fresh slug and the registered endpoint follows on the next passengers cycle`);
|
|
11985
12512
|
break;
|
|
11986
12513
|
}
|
|
12514
|
+
case "worktree": {
|
|
12515
|
+
emit.enter("worktree");
|
|
12516
|
+
const rawWt = positional[2] ?? positional[1];
|
|
12517
|
+
const nWt = rawWt ? parseTicketNumber(rawWt) : null;
|
|
12518
|
+
if (!nWt) {
|
|
12519
|
+
process.stderr.write("worktree: need a ticket number (326, ISSUE-326, or TABL-326)\n");
|
|
12520
|
+
process.exit(2);
|
|
12521
|
+
}
|
|
12522
|
+
const trackerWt = new Tracker(route, cfg.ship);
|
|
12523
|
+
const cWt = trackerWt.contract;
|
|
12524
|
+
const ticketWt = (await trackerWt.openTickets()).find((t) => t[cWt.columns.key] === `ISSUE-${nWt}`);
|
|
12525
|
+
if (!ticketWt) {
|
|
12526
|
+
process.stderr.write(`worktree: no open ticket ISSUE-${nWt} on ${route.route}
|
|
12527
|
+
`);
|
|
12528
|
+
process.exit(2);
|
|
12529
|
+
}
|
|
12530
|
+
const repoWt = (await resolvedRepos(route)).find((r) => r.id === ticketWt.repo_id) ?? (await resolvedRepos(route))[0];
|
|
12531
|
+
if (!repoWt) {
|
|
12532
|
+
process.stderr.write("worktree: this route has no repository\n");
|
|
12533
|
+
process.exit(2);
|
|
12534
|
+
}
|
|
12535
|
+
const roleWt = positional.find((p) => p === "dev" || p === "design" || p === "qa");
|
|
12536
|
+
const planWt = planRunWorktree(repoWt.dir, repoWt.config, ticketWt, roleWt);
|
|
12537
|
+
if (dryRun) {
|
|
12538
|
+
emit.emit(`would ${describeRunWorktreePlan(planWt)}`);
|
|
12539
|
+
break;
|
|
12540
|
+
}
|
|
12541
|
+
const pathWt = applyRunWorktree(repoWt.dir, repoWt.config, planWt);
|
|
12542
|
+
emit.emit(describeRunWorktreePlan(planWt));
|
|
12543
|
+
if (pathWt) process.stdout.write(`${pathWt}
|
|
12544
|
+
`);
|
|
12545
|
+
if (!planIsUsable(planWt)) process.exitCode = 1;
|
|
12546
|
+
break;
|
|
12547
|
+
}
|
|
11987
12548
|
case "unassign": {
|
|
11988
12549
|
emit.enter("select");
|
|
11989
12550
|
const rawUnassign = positional[2] ?? positional[1];
|
|
@@ -12152,6 +12713,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
|
|
|
12152
12713
|
const engagedTicketIds = shipRows.map((s) => s.engaged_ticket_id).filter((id) => !!id);
|
|
12153
12714
|
const engagedTickets = engagedTicketIds.length ? await statusTracker.ticketsByIds(engagedTicketIds) : [];
|
|
12154
12715
|
const issueLabelById = new Map(engagedTickets.map((t) => [t.id, t.issue_id]));
|
|
12716
|
+
const todo = await statusTracker.openTickets().then((open) => operatorTodo(open, statusTracker.contract, route.resolved?.operator ?? "")).catch(() => []);
|
|
12155
12717
|
if (flag("json")) {
|
|
12156
12718
|
const repos = await resolvedRepos(route);
|
|
12157
12719
|
const seatIds = route.resolved?.seats ?? {};
|
|
@@ -12188,6 +12750,8 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
|
|
|
12188
12750
|
// derived `release_stale`. `version` stays 1.
|
|
12189
12751
|
attention: openShipAttention(state)
|
|
12190
12752
|
},
|
|
12753
|
+
// Additive (CREW-1402): what the operator owes. `version` stays 1.
|
|
12754
|
+
todo: todo.map((i) => ({ ticket: displayKey(i.ticket), title: i.ticket.title ?? null, reasons: i.reasons })),
|
|
12191
12755
|
crew: {
|
|
12192
12756
|
paused: state.isPaused(),
|
|
12193
12757
|
// The release timer honors the whole-crew pause too, so this is
|
|
@@ -12234,6 +12798,15 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
|
|
|
12234
12798
|
`);
|
|
12235
12799
|
break;
|
|
12236
12800
|
}
|
|
12801
|
+
const owed = [
|
|
12802
|
+
...todo.map((i) => `${displayKey(i.ticket)} \u2014 ${i.reasons.join(", ")}${i.ticket.title ? ` \u2014 ${i.ticket.title}` : ""}`),
|
|
12803
|
+
...openShipAttention(state).map((i) => `ship ${cfg.ship.name}: ${i.kind} \u2014 ${i.message}`),
|
|
12804
|
+
...shipRows.filter((s) => s.name !== cfg.ship.name).flatMap((s) => (s.attention ?? []).map((i) => `ship ${s.name}: ${i.kind} \u2014 ${i.message}`))
|
|
12805
|
+
];
|
|
12806
|
+
if (owed.length) process.stdout.write(`to do (${owed.length}):
|
|
12807
|
+
${owed.map((l) => ` ${l}
|
|
12808
|
+
`).join("")}
|
|
12809
|
+
`);
|
|
12237
12810
|
process.stdout.write(
|
|
12238
12811
|
`ship: ${cfg.ship.name} (${cfg.ship.platform})
|
|
12239
12812
|
route: ${route.route} -> ${route.dir}
|