@tablation/crew 0.17.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/cli.js +122 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -206,6 +206,7 @@ crew doctor [route] [--fix] preflight; --fix enables clean routes, offers cre
|
|
|
206
206
|
crew ports [route] which checkout owns which ports, and what is up
|
|
207
207
|
crew reap [route] kill servers left behind by removed worktrees
|
|
208
208
|
crew drop [route] NNN remove a merged ticket's worktree and branch
|
|
209
|
+
crew worktree [route] NNN cut (or refresh) this run's worktree for a ticket from the remote; prints its path
|
|
209
210
|
crew sync [route] fast-forward the checkout and its worktrees from the remote
|
|
210
211
|
crew pause|resume [route] [R|release] pause everything, one role, or releases only (passengers keep running)
|
|
211
212
|
crew log [route] tail the log
|
package/dist/cli.js
CHANGED
|
@@ -2575,6 +2575,9 @@ function detectSystemd() {
|
|
|
2575
2575
|
return !res.error;
|
|
2576
2576
|
}
|
|
2577
2577
|
|
|
2578
|
+
// src/ship-attention.ts
|
|
2579
|
+
import { statfsSync } from "fs";
|
|
2580
|
+
|
|
2578
2581
|
// src/git.ts
|
|
2579
2582
|
import { execFileSync } from "child_process";
|
|
2580
2583
|
import {
|
|
@@ -3049,14 +3052,94 @@ function baseAttention(repo, outcome, remote, base, nowIso = (/* @__PURE__ */ ne
|
|
|
3049
3052
|
if (outcome.action === "level" || outcome.action === "fast-forwarded") return { clear: baseUnsafeKey(repo) };
|
|
3050
3053
|
return null;
|
|
3051
3054
|
}
|
|
3055
|
+
var diskLowKey = (repo) => `disk_low:${repo}`;
|
|
3056
|
+
var LOW_DISK_BYTES = 2 * 1024 ** 3;
|
|
3057
|
+
var gib = (bytes) => (bytes / 1024 ** 3).toFixed(1);
|
|
3058
|
+
function diskAttention(repo, freeBytes, thresholdBytes = LOW_DISK_BYTES, nowIso = (/* @__PURE__ */ new Date()).toISOString()) {
|
|
3059
|
+
if (freeBytes >= thresholdBytes) return { clear: diskLowKey(repo) };
|
|
3060
|
+
return {
|
|
3061
|
+
raise: {
|
|
3062
|
+
kind: "disk_low",
|
|
3063
|
+
key: diskLowKey(repo),
|
|
3064
|
+
since: nowIso,
|
|
3065
|
+
message: `${repo}: only ${gib(freeBytes)} GiB free beside the checkout (need ${gib(thresholdBytes)}) \u2014 no worktree can be set up until space is freed. Resumes automatically once there is room`
|
|
3066
|
+
}
|
|
3067
|
+
};
|
|
3068
|
+
}
|
|
3069
|
+
function freeBytesAt(dir) {
|
|
3070
|
+
try {
|
|
3071
|
+
const s = statfsSync(dir);
|
|
3072
|
+
return Number(s.bavail) * Number(s.bsize);
|
|
3073
|
+
} catch {
|
|
3074
|
+
return null;
|
|
3075
|
+
}
|
|
3076
|
+
}
|
|
3077
|
+
async function checkRepoDisks(o, freeAt = freeBytesAt, thresholdBytes = LOW_DISK_BYTES) {
|
|
3078
|
+
for (const target of reposOf(o.route)) {
|
|
3079
|
+
const free = freeAt(target.dir);
|
|
3080
|
+
if (free === null) continue;
|
|
3081
|
+
const att = diskAttention(target.name, free, thresholdBytes);
|
|
3082
|
+
if ("raise" in att) await raiseShipAttention(o, att.raise);
|
|
3083
|
+
else clearShipAttention(o.state, att.clear);
|
|
3084
|
+
}
|
|
3085
|
+
}
|
|
3086
|
+
function parseHookMissing(key, routeName) {
|
|
3087
|
+
const prefix = `hook_missing:${routeName}/`;
|
|
3088
|
+
if (!key.startsWith(prefix)) return null;
|
|
3089
|
+
const rest = key.slice(prefix.length);
|
|
3090
|
+
const slash = rest.lastIndexOf("/");
|
|
3091
|
+
if (slash <= 0) return null;
|
|
3092
|
+
return { repo: rest.slice(0, slash), hook: rest.slice(slash + 1) };
|
|
3093
|
+
}
|
|
3052
3094
|
function repoStopFor(items, route) {
|
|
3053
|
-
const stops =
|
|
3095
|
+
const stops = /* @__PURE__ */ new Map();
|
|
3096
|
+
for (const i of items) {
|
|
3097
|
+
let repo = null;
|
|
3098
|
+
if (i.kind === "base_unsafe") repo = i.key.slice("base_unsafe:".length);
|
|
3099
|
+
else if (i.kind === "disk_low") repo = i.key.slice("disk_low:".length);
|
|
3100
|
+
else if (i.kind === "hook_missing") {
|
|
3101
|
+
const h = parseHookMissing(i.key, route.route);
|
|
3102
|
+
if (h?.hook === "setup") repo = h.repo;
|
|
3103
|
+
}
|
|
3104
|
+
if (repo && !stops.has(repo)) stops.set(repo, i.message);
|
|
3105
|
+
}
|
|
3054
3106
|
if (stops.size === 0) return () => null;
|
|
3055
3107
|
return (t) => {
|
|
3056
3108
|
const name = repoTargetFor(route, t.repo_id)?.name;
|
|
3057
|
-
return name ? stops.get(
|
|
3109
|
+
return name ? stops.get(name) ?? null : null;
|
|
3058
3110
|
};
|
|
3059
3111
|
}
|
|
3112
|
+
function recheckEnvironmentStops(state, route, ship, deps = {}) {
|
|
3113
|
+
const onPath = deps.onPath ?? findOnPath;
|
|
3114
|
+
const dockerProblem = deps.dockerProblem ?? dockerPathProblem;
|
|
3115
|
+
const cleared = [];
|
|
3116
|
+
for (const item of state.shipAttention().previous()) {
|
|
3117
|
+
if (item.kind === "docker_missing") {
|
|
3118
|
+
if (!dockerProblem(ship)) {
|
|
3119
|
+
clearShipAttention(state, item.key);
|
|
3120
|
+
cleared.push("docker");
|
|
3121
|
+
}
|
|
3122
|
+
continue;
|
|
3123
|
+
}
|
|
3124
|
+
if (item.kind !== "hook_missing") continue;
|
|
3125
|
+
const h = parseHookMissing(item.key, route.route);
|
|
3126
|
+
const target = h && reposOf(route).find((t) => t.name === h.repo);
|
|
3127
|
+
if (!h || !target) continue;
|
|
3128
|
+
let cfg;
|
|
3129
|
+
try {
|
|
3130
|
+
cfg = resolveRepoConfig(loadRepoConfig(target.dir), void 0, target.dir);
|
|
3131
|
+
} catch {
|
|
3132
|
+
continue;
|
|
3133
|
+
}
|
|
3134
|
+
const script = cfg.hooks[h.hook];
|
|
3135
|
+
const cmd = script ? hookCommand(script) : null;
|
|
3136
|
+
if (!cmd || onPath(cmd, pathFor(ship))) {
|
|
3137
|
+
clearShipAttention(state, item.key);
|
|
3138
|
+
cleared.push(`${h.repo}/${h.hook}`);
|
|
3139
|
+
}
|
|
3140
|
+
}
|
|
3141
|
+
return cleared;
|
|
3142
|
+
}
|
|
3060
3143
|
function recheckBaseStops(state, route) {
|
|
3061
3144
|
const open = state.shipAttention().previous().filter((i) => i.kind === "base_unsafe");
|
|
3062
3145
|
const cleared = [];
|
|
@@ -4695,6 +4778,10 @@ async function decideCycle(o) {
|
|
|
4695
4778
|
for (const repo of recheckBaseStops(state, route)) {
|
|
4696
4779
|
emit.emit(`${repo}: primary checkout is level again \u2014 building lanes resume for that repo`, { step: "select" });
|
|
4697
4780
|
}
|
|
4781
|
+
for (const what of recheckEnvironmentStops(state, route, ship)) {
|
|
4782
|
+
emit.emit(`${what}: no longer missing from the scheduler PATH \u2014 stop cleared`, { step: "select" });
|
|
4783
|
+
}
|
|
4784
|
+
await checkRepoDisks({ state, emit, route, ship });
|
|
4698
4785
|
const selectionInput = {
|
|
4699
4786
|
repoStop: repoStopFor(openShipAttention(state), route),
|
|
4700
4787
|
tickets,
|
|
@@ -6010,11 +6097,18 @@ function applyRunWorktree(dir, cfg, plan) {
|
|
|
6010
6097
|
return plan.path;
|
|
6011
6098
|
}
|
|
6012
6099
|
}
|
|
6100
|
+
function worktreeHoldsUnpushed(path, remote) {
|
|
6101
|
+
const out = gitOk(path, ["for-each-ref", "--contains", "HEAD", "--count=1", "--format=%(refname)", `refs/remotes/${remote}/`]);
|
|
6102
|
+
return out === null || out.trim() === "";
|
|
6103
|
+
}
|
|
6013
6104
|
async function finishRunWorktree(dir, cfg, branch) {
|
|
6014
6105
|
const wt = worktrees(dir).find((w) => w.branch === branch && !w.prunable && w.path !== dir);
|
|
6015
6106
|
if (!wt || !existsSync9(wt.path)) return { kind: "none" };
|
|
6016
6107
|
if (cfg.hooks.handoff) return { kind: "kept-handoff", path: wt.path };
|
|
6017
6108
|
if (status(wt.path).length > 0) return { kind: "kept-dirty", path: wt.path };
|
|
6109
|
+
if (remoteConfigured(dir, cfg.branch.remote) && worktreeHoldsUnpushed(wt.path, cfg.branch.remote)) {
|
|
6110
|
+
return { kind: "kept-unpushed", path: wt.path };
|
|
6111
|
+
}
|
|
6018
6112
|
const n = Number(wt.path.match(/(\d+)$/)?.[1] ?? NaN);
|
|
6019
6113
|
if (Number.isFinite(n)) {
|
|
6020
6114
|
const pids = pidsInWorktree(wt.path, n);
|
|
@@ -6114,7 +6208,7 @@ async function enforceHandoff(i) {
|
|
|
6114
6208
|
}
|
|
6115
6209
|
}
|
|
6116
6210
|
async function guardRunHandoff(route, ship, tracker, role, workingId, log) {
|
|
6117
|
-
if (role !== "dev" && role !== "design"
|
|
6211
|
+
if (!workingId || role !== "dev" && role !== "design" && role !== "qa") return null;
|
|
6118
6212
|
const seatId = route.resolved?.seats[role];
|
|
6119
6213
|
if (!seatId) return null;
|
|
6120
6214
|
try {
|
|
@@ -6127,6 +6221,10 @@ async function guardRunHandoff(route, ship, tracker, role, workingId, log) {
|
|
|
6127
6221
|
labels: { ...route.labels, ...o?.labels },
|
|
6128
6222
|
branch: { ...route.branch, ...o?.branch }
|
|
6129
6223
|
}, target.dir);
|
|
6224
|
+
if (role === "qa") {
|
|
6225
|
+
await retireRunWorktree(tracker, target.dir, cfg, t, role, seatId, ship.name, log);
|
|
6226
|
+
return null;
|
|
6227
|
+
}
|
|
6130
6228
|
const affinity = await tracker.claimAffinity();
|
|
6131
6229
|
const outcome = await enforceHandoff({
|
|
6132
6230
|
writer: tracker,
|
|
@@ -6158,6 +6256,7 @@ async function retireRunWorktree(tracker, dir, cfg, t, role, seatId, shipName2,
|
|
|
6158
6256
|
log.emit(`${t.issue_id}: kept worktree ${o.path} \u2014 this repo's handoff hook serves from it`, at);
|
|
6159
6257
|
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);
|
|
6160
6258
|
} else if (o.kind === "kept-dirty") log.warn(`${t.issue_id}: left worktree ${o.path} \u2014 it has uncommitted changes`, at);
|
|
6259
|
+
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);
|
|
6161
6260
|
} catch (e) {
|
|
6162
6261
|
log.warn(`could not retire the run's worktree: ${e.message}`, { step: "handoff" });
|
|
6163
6262
|
}
|
|
@@ -8936,18 +9035,22 @@ import { existsSync as existsSync15 } from "fs";
|
|
|
8936
9035
|
import { basename as basename6 } from "path";
|
|
8937
9036
|
function planWorktreeSweep(target, tickets, contract) {
|
|
8938
9037
|
const terminal = new Set(contract.statuses.resolved.filter((s) => s !== contract.statuses.verified));
|
|
9038
|
+
const verified = contract.statuses.verified;
|
|
8939
9039
|
const byNumber = new Map(tickets.map((t) => [t.issue_id.replace(/^\D+/, ""), t]));
|
|
8940
9040
|
const actions = [];
|
|
8941
9041
|
for (const w of worktrees(target.dir)) {
|
|
8942
9042
|
const m = /(?:^|-)(\d+)$/.exec(basename6(w.path));
|
|
8943
9043
|
if (!m) continue;
|
|
8944
9044
|
const ticket = byNumber.get(m[1]);
|
|
8945
|
-
if (!ticket
|
|
9045
|
+
if (!ticket) continue;
|
|
9046
|
+
const isVerified = ticket.status === verified;
|
|
9047
|
+
if (!isVerified && !terminal.has(ticket.status)) continue;
|
|
8946
9048
|
actions.push({
|
|
8947
9049
|
ticket,
|
|
8948
9050
|
path: w.path,
|
|
8949
9051
|
branch: w.branch,
|
|
8950
|
-
keepBranch: ticket.status !== contract.statuses.deployed
|
|
9052
|
+
keepBranch: ticket.status !== contract.statuses.deployed,
|
|
9053
|
+
...isVerified ? { requirePushed: true } : {}
|
|
8951
9054
|
});
|
|
8952
9055
|
}
|
|
8953
9056
|
return actions;
|
|
@@ -8970,6 +9073,13 @@ async function applyWorktreeSweep(cwd, actions, dryRun, log, remote = "origin")
|
|
|
8970
9073
|
continue;
|
|
8971
9074
|
}
|
|
8972
9075
|
}
|
|
9076
|
+
if (!dryRun && a.requirePushed && a.branch && existsSync15(a.path) && remoteConfigured(cwd, remote) && worktreeHoldsUnpushed(a.path, remote)) {
|
|
9077
|
+
log.warn(
|
|
9078
|
+
`not removing worktree ${a.path} for ${a.ticket.issue_id} (${a.ticket.status}): ${a.branch} has commits that are not on ${remote}`,
|
|
9079
|
+
{ ticket: a.ticket.issue_id, step: "worktree" }
|
|
9080
|
+
);
|
|
9081
|
+
continue;
|
|
9082
|
+
}
|
|
8973
9083
|
const n = Number(a.ticket.issue_id.replace(/^\D+/, ""));
|
|
8974
9084
|
const pids = pidsInWorktree(a.path, n);
|
|
8975
9085
|
if (pids.length) {
|
|
@@ -10966,8 +11076,9 @@ ${changed2.map((c) => ` - ${c}
|
|
|
10966
11076
|
}
|
|
10967
11077
|
try {
|
|
10968
11078
|
const terminal = await tracker.terminalTickets();
|
|
11079
|
+
const verifiedNow = (await tracker.openTickets()).filter((t) => t.status === tracker.contract.statuses.verified);
|
|
10969
11080
|
const scoped = ticketsByRepo(c, terminal).byRepo.get(target.name) ?? [];
|
|
10970
|
-
const actions = planWorktreeSweep(target, scoped, tracker.contract);
|
|
11081
|
+
const actions = planWorktreeSweep(target, [...scoped, ...ticketsByRepo(c, verifiedNow).byRepo.get(target.name) ?? []], tracker.contract);
|
|
10971
11082
|
if (actions.length) {
|
|
10972
11083
|
remit.enter("worktree");
|
|
10973
11084
|
const r = await applyWorktreeSweep(target.dir, actions, dryRun, remit, repo.branch.remote);
|
|
@@ -12344,8 +12455,12 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
|
|
|
12344
12455
|
const tracker = new Tracker(route, cfg.ship);
|
|
12345
12456
|
const terminal = await tracker.terminalTickets();
|
|
12346
12457
|
const { byRepo } = ticketsByRepo(route, terminal);
|
|
12458
|
+
const verifiedByRepo = ticketsByRepo(
|
|
12459
|
+
route,
|
|
12460
|
+
(await tracker.openTickets()).filter((t) => t.status === tracker.contract.statuses.verified)
|
|
12461
|
+
).byRepo;
|
|
12347
12462
|
for (const r of await resolvedRepos(route)) {
|
|
12348
|
-
const actions = planWorktreeSweep(r, byRepo.get(r.name) ?? [], tracker.contract);
|
|
12463
|
+
const actions = planWorktreeSweep(r, [...byRepo.get(r.name) ?? [], ...verifiedByRepo.get(r.name) ?? []], tracker.contract);
|
|
12349
12464
|
const stale = planRemoteBranchCleanup(r.dir, r.config.branch.remote, r.config.branch.base, byRepo.get(r.name) ?? [], tracker.contract);
|
|
12350
12465
|
if (stale.length) {
|
|
12351
12466
|
anything = true;
|
package/package.json
CHANGED