@tablation/crew 0.75.0 → 0.75.1
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 +1345 -1253
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -12505,1396 +12505,1444 @@ function formatWhoami(report) {
|
|
|
12505
12505
|
`;
|
|
12506
12506
|
}
|
|
12507
12507
|
|
|
12508
|
-
// src/
|
|
12509
|
-
import {
|
|
12510
|
-
|
|
12511
|
-
|
|
12512
|
-
|
|
12513
|
-
|
|
12514
|
-
const named = release.ship;
|
|
12515
|
-
if (!named || release.mode !== "local" && release.mode !== "integrate") return { releases: true, releaser: named ?? null };
|
|
12516
|
-
return { releases: named.trim() === shipName2.trim(), releaser: named };
|
|
12517
|
-
}
|
|
12518
|
-
function nonReleaserNote(repo, releaser) {
|
|
12519
|
-
return `release for ${repo} runs on ${releaser}`;
|
|
12520
|
-
}
|
|
12521
|
-
function missingReleaserWarning(repo, release, ships, now = Date.now()) {
|
|
12522
|
-
if (release.ship || release.mode !== "local" && release.mode !== "integrate") return null;
|
|
12523
|
-
const live = ships.filter((s) => s.last_seen && now - Date.parse(s.last_seen) <= DAY_MS);
|
|
12524
|
-
if (live.length < 2) return null;
|
|
12525
|
-
const names = live.map((s) => s.name ?? "?").join(", ");
|
|
12526
|
-
return `${repo}: release.mode "${release.mode}" with no release.ship, and ${live.length} ships seen in the last day (${names}) \u2014 every one of them will try to release it. Set release.ship in .crew.yaml.`;
|
|
12527
|
-
}
|
|
12528
|
-
|
|
12529
|
-
// src/watch.ts
|
|
12530
|
-
import { watch as watchFile, existsSync as existsSync23, statSync as statSync7, openSync as openSync4, readSync as readSync2, closeSync as closeSync4 } from "fs";
|
|
12531
|
-
var E = "\x1B[";
|
|
12532
|
-
var dim = (s) => `${E}2m${s}${E}0m`;
|
|
12533
|
-
var bold = (s) => `${E}1m${s}${E}0m`;
|
|
12534
|
-
var paintWith = (n) => (s) => `${E}${n}m${s}${E}0m`;
|
|
12535
|
-
var green = paintWith(32);
|
|
12536
|
-
var yellow = paintWith(33);
|
|
12537
|
-
var red = paintWith(31);
|
|
12538
|
-
var cyan = paintWith(36);
|
|
12539
|
-
var STEP_ORDER = ["poll", "sweep", "select", "worktree", "agent", "reconcile", "merge", "release", "idle"];
|
|
12540
|
-
var WORKING_STEPS = /* @__PURE__ */ new Set(["worktree", "agent", "reconcile", "merge", "release"]);
|
|
12541
|
-
function byRoute(events, route) {
|
|
12542
|
-
return route ? events.filter((e) => e.route === route) : events;
|
|
12508
|
+
// src/seat-keys.ts
|
|
12509
|
+
import { getSessionStore as getSessionStore2, hostFromUrl as hostFromUrl3 } from "@tablation/client";
|
|
12510
|
+
var SEAT_KEY_ROLES = ROLE_NAMES.filter((r) => r !== "pair");
|
|
12511
|
+
function keyIsAdmin(route) {
|
|
12512
|
+
const role = route.resolved?.role;
|
|
12513
|
+
return role === void 0 || role === "WORKSPACE_ADMIN" || role === "PLATFORM_ADMIN";
|
|
12543
12514
|
}
|
|
12544
|
-
|
|
12545
|
-
|
|
12546
|
-
|
|
12547
|
-
|
|
12548
|
-
|
|
12549
|
-
if (f.level === "error" && e.level !== "error") return false;
|
|
12550
|
-
return true;
|
|
12515
|
+
var MEMBER_NO_ROW = "has no linked Agents row \u2014 a workspace admin's `crew agents sync` creates the persona rows; run it here afterwards to link this ship's seats";
|
|
12516
|
+
var MEMBER_NO_KEY = 'no agent key for this seat \u2014 run `crew agents sync` (needs the "Mint agent keys" capability on your role)';
|
|
12517
|
+
var FORBIDDEN_LINE = 'seat keys: not minted \u2014 your role does not grant "Mint agent keys"; ask a workspace admin to add it to your project role (or run sync with an admin key)';
|
|
12518
|
+
function seatKeySlug(workspaceSlug, agentId) {
|
|
12519
|
+
return `${workspaceSlug}#agent:${agentId}`;
|
|
12551
12520
|
}
|
|
12552
|
-
function
|
|
12553
|
-
const
|
|
12554
|
-
|
|
12555
|
-
const pad = (n) => String(n).padStart(2, "0");
|
|
12556
|
-
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
12521
|
+
function slugFor(route, agentId) {
|
|
12522
|
+
const [workspaceSlug] = splitRoute(route.route);
|
|
12523
|
+
return seatKeySlug(workspaceSlug, agentId);
|
|
12557
12524
|
}
|
|
12558
|
-
function
|
|
12559
|
-
|
|
12560
|
-
const scope = e.role ? `${e.step}[${e.role}]` : e.step;
|
|
12561
|
-
const paint = e.level === "error" ? red : e.level === "warn" ? yellow : cyan;
|
|
12562
|
-
const route = showRoute ? `${dim(e.route)} ` : "";
|
|
12563
|
-
const ticket = e.ticket ? ` ${bold(e.ticket)}` : "";
|
|
12564
|
-
return `${t} ${route}${paint(scope.padEnd(14))}${ticket} ${e.message}`;
|
|
12525
|
+
function seatAgentId(route, role) {
|
|
12526
|
+
return route.resolved?.agentPersonas?.[role]?.agentId;
|
|
12565
12527
|
}
|
|
12566
|
-
function
|
|
12567
|
-
const
|
|
12568
|
-
|
|
12569
|
-
|
|
12570
|
-
|
|
12571
|
-
|
|
12572
|
-
|
|
12573
|
-
route:
|
|
12574
|
-
|
|
12575
|
-
|
|
12576
|
-
ticket: [...ofCycle].reverse().find((e) => e.ticket && WORKING_STEPS.has(e.step))?.ticket,
|
|
12577
|
-
startedAt: first.at,
|
|
12578
|
-
lastAt: last.at,
|
|
12579
|
-
warnings: ofCycle.filter((e) => e.level === "warn").length,
|
|
12580
|
-
errors: ofCycle.filter((e) => e.level === "error").length
|
|
12581
|
-
};
|
|
12582
|
-
for (const e of ofCycle) {
|
|
12583
|
-
if (typeof e.data?.rank === "number") state.rank = e.data.rank;
|
|
12584
|
-
if (Array.isArray(e.data?.pending)) state.pending = e.data.pending;
|
|
12585
|
-
if (Array.isArray(e.data?.blocked)) state.blockedTickets = e.data.blocked;
|
|
12586
|
-
if (Array.isArray(e.data?.qaHeld)) state.qaHeldTickets = e.data.qaHeld;
|
|
12587
|
-
if (e.step === "release" && typeof e.data?.cycles === "number") {
|
|
12588
|
-
state.releaseBlockedCycles = e.data.cycles;
|
|
12589
|
-
state.releaseBlockedReason = typeof e.data.reason === "string" ? e.data.reason : void 0;
|
|
12590
|
-
}
|
|
12528
|
+
async function resolveReleaseKey(route, getStore = getSessionStore2) {
|
|
12529
|
+
const ship = (reason) => ({ kind: "ship", apiKey: resolveApiKey(route), reason });
|
|
12530
|
+
const agentId = route.resolved?.releaseAgentId;
|
|
12531
|
+
if (!agentId) return ship(keyIsAdmin(route) ? "release seat has no linked Agents row (run `crew agents sync`)" : `release seat ${MEMBER_NO_ROW}`);
|
|
12532
|
+
try {
|
|
12533
|
+
const session = await getStore().get(hostFromUrl3(route.baseUrl), slugFor(route, agentId));
|
|
12534
|
+
if (session?.apiKey) return { kind: "agent", apiKey: session.apiKey, agentId };
|
|
12535
|
+
return ship(keyIsAdmin(route) ? "no agent key stored for the release seat (run `crew agents sync`)" : MEMBER_NO_KEY);
|
|
12536
|
+
} catch {
|
|
12537
|
+
return ship("OS keychain unavailable");
|
|
12591
12538
|
}
|
|
12592
|
-
return state;
|
|
12593
|
-
}
|
|
12594
|
-
function foldCycles(events) {
|
|
12595
|
-
const routes = [...new Set(events.map((e) => e.route))];
|
|
12596
|
-
return routes.map((r) => foldCycle(events.filter((e) => e.route === r))).filter((s) => s !== null).sort((a, b) => Date.parse(b.lastAt) - Date.parse(a.lastAt));
|
|
12597
|
-
}
|
|
12598
|
-
function renderHeader(s, now = Date.now()) {
|
|
12599
|
-
if (!s) return dim("no cycle seen yet - waiting for the crew to run\n");
|
|
12600
|
-
const elapsed = Math.max(0, Math.round((now - Date.parse(s.lastAt)) / 1e3));
|
|
12601
|
-
const progress = STEP_ORDER.map((st) => st === s.step ? bold(green(st)) : dim(st)).join(dim(" > "));
|
|
12602
|
-
const bits = [
|
|
12603
|
-
`${bold(s.route)} cycle ${s.cycle}`,
|
|
12604
|
-
s.role ? `role ${bold(s.role)}` : null,
|
|
12605
|
-
s.ticket ? `ticket ${bold(s.ticket)}` : null,
|
|
12606
|
-
s.rank !== void 0 ? dim(`rank ${s.rank}`) : null,
|
|
12607
|
-
s.pending?.length ? dim(`pending: ${s.pending.join(" ")}`) : null,
|
|
12608
|
-
s.errors ? red(`${s.errors} error(s)`) : s.warnings ? yellow(`${s.warnings} warning(s)`) : null,
|
|
12609
|
-
dim(`${elapsed}s in ${s.step}`)
|
|
12610
|
-
].filter(Boolean);
|
|
12611
|
-
const releaseLine = s.releaseBlockedCycles ? red(
|
|
12612
|
-
`release BLOCKED (${s.releaseBlockedCycles} cycle${s.releaseBlockedCycles === 1 ? "" : "s"}): ${s.releaseBlockedReason ?? "unknown reason"}`
|
|
12613
|
-
) : null;
|
|
12614
|
-
const extra = [
|
|
12615
|
-
releaseLine,
|
|
12616
|
-
s.blockedTickets?.length ? dim(`blocked: ${s.blockedTickets.join(" ")}`) : null,
|
|
12617
|
-
s.qaHeldTickets?.length ? dim(`qa holding: ${s.qaHeldTickets.join(" ")}`) : null
|
|
12618
|
-
].filter(Boolean);
|
|
12619
|
-
const extraLines = extra.length ? `${extra.join("\n")}
|
|
12620
|
-
` : "";
|
|
12621
|
-
return `${bits.join(dim(" | "))}
|
|
12622
|
-
${extraLines}${progress}
|
|
12623
|
-
${dim("-".repeat(72))}
|
|
12624
|
-
`;
|
|
12625
12539
|
}
|
|
12626
|
-
function
|
|
12627
|
-
|
|
12628
|
-
|
|
12540
|
+
async function resolveSeatKey(route, role, getStore = getSessionStore2) {
|
|
12541
|
+
const ship = (reason) => ({ kind: "ship", apiKey: resolveApiKey(route), reason });
|
|
12542
|
+
if (!SEAT_KEY_ROLES.includes(role)) return ship(`${role} always writes as the ship`);
|
|
12543
|
+
const agentId = seatAgentId(route, role);
|
|
12544
|
+
if (!agentId) return ship(keyIsAdmin(route) ? "seat has no linked Agents row (run `crew agents sync`)" : `seat ${MEMBER_NO_ROW}`);
|
|
12545
|
+
try {
|
|
12546
|
+
const session = await getStore().get(hostFromUrl3(route.baseUrl), slugFor(route, agentId));
|
|
12547
|
+
if (session?.apiKey) return { kind: "agent", apiKey: session.apiKey, agentId };
|
|
12548
|
+
return ship(keyIsAdmin(route) ? "no agent key stored for this seat (run `crew agents sync`)" : MEMBER_NO_KEY);
|
|
12549
|
+
} catch {
|
|
12550
|
+
return ship("OS keychain unavailable");
|
|
12551
|
+
}
|
|
12629
12552
|
}
|
|
12630
|
-
function
|
|
12631
|
-
|
|
12632
|
-
|
|
12633
|
-
|
|
12634
|
-
if (size === from) return { events: [], offset: from };
|
|
12635
|
-
const fd = openSync4(file, "r");
|
|
12636
|
-
const buf = Buffer.alloc(size - from);
|
|
12637
|
-
readSync2(fd, buf, 0, buf.length, from);
|
|
12638
|
-
closeSync4(fd);
|
|
12639
|
-
const text = buf.toString("utf8");
|
|
12640
|
-
const lastNewline = text.lastIndexOf("\n");
|
|
12641
|
-
if (lastNewline === -1) return { events: [], offset: from };
|
|
12642
|
-
const events = [];
|
|
12643
|
-
for (const line of text.slice(0, lastNewline).split("\n")) {
|
|
12644
|
-
if (!line.trim()) continue;
|
|
12645
|
-
try {
|
|
12646
|
-
events.push(JSON.parse(line));
|
|
12647
|
-
} catch {
|
|
12648
|
-
}
|
|
12553
|
+
function describeStoreError(e) {
|
|
12554
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
12555
|
+
if (/User interaction is not allowed/i.test(msg)) {
|
|
12556
|
+
return "keychain unavailable (User interaction is not allowed \u2014 run from a logged-in session, or `security unlock-keychain` first)";
|
|
12649
12557
|
}
|
|
12650
|
-
return
|
|
12558
|
+
return "keychain unavailable (could not write to the OS keychain)";
|
|
12651
12559
|
}
|
|
12652
|
-
function
|
|
12653
|
-
const out =
|
|
12654
|
-
|
|
12655
|
-
|
|
12656
|
-
|
|
12657
|
-
|
|
12658
|
-
|
|
12659
|
-
|
|
12660
|
-
|
|
12661
|
-
|
|
12662
|
-
for (const e of all.slice(-(o.tail ?? 20)).filter((x) => matches(x, filter))) out(`${formatLine(e, showRoute())}
|
|
12663
|
-
`);
|
|
12664
|
-
out(renderHeaders(foldCycles(byRoute(all, filter.route))));
|
|
12665
|
-
const pump = () => {
|
|
12666
|
-
const { events, offset: next } = readFrom(o.file, offset);
|
|
12667
|
-
offset = next;
|
|
12668
|
-
if (!events.length) return;
|
|
12669
|
-
all = [...all, ...events].slice(-5e3);
|
|
12670
|
-
if (paused) return;
|
|
12671
|
-
for (const e of events) if (matches(e, filter)) out(`${formatLine(e, showRoute())}
|
|
12672
|
-
`);
|
|
12673
|
-
out(renderHeaders(foldCycles(byRoute(all, filter.route))));
|
|
12674
|
-
};
|
|
12675
|
-
const timer = setInterval(pump, 1e3);
|
|
12676
|
-
let watcher;
|
|
12560
|
+
async function mintMissingSeatKeys(route, o) {
|
|
12561
|
+
const out = [];
|
|
12562
|
+
if (!route.resolved) return out;
|
|
12563
|
+
const resolved = route.resolved;
|
|
12564
|
+
const doFetch = o.fetchImpl ?? fetch;
|
|
12565
|
+
const targets = [
|
|
12566
|
+
...SEAT_KEY_ROLES.filter((r) => resolved.seats[r]).map((role) => ({ role, agentId: seatAgentId(route, role) })),
|
|
12567
|
+
...resolved.releaseSeat ? [{ role: "release", agentId: resolved.releaseAgentId }] : []
|
|
12568
|
+
];
|
|
12569
|
+
let store;
|
|
12677
12570
|
try {
|
|
12678
|
-
|
|
12679
|
-
} catch {
|
|
12571
|
+
store = (o.getStore ?? getSessionStore2)();
|
|
12572
|
+
} catch (e) {
|
|
12573
|
+
return targets.map(({ role }) => ({
|
|
12574
|
+
role,
|
|
12575
|
+
action: "failed",
|
|
12576
|
+
detail: describeStoreError(e)
|
|
12577
|
+
}));
|
|
12680
12578
|
}
|
|
12681
|
-
const
|
|
12682
|
-
const
|
|
12683
|
-
|
|
12684
|
-
|
|
12685
|
-
|
|
12686
|
-
|
|
12687
|
-
|
|
12688
|
-
|
|
12689
|
-
|
|
12690
|
-
|
|
12691
|
-
|
|
12692
|
-
|
|
12693
|
-
|
|
12694
|
-
|
|
12695
|
-
|
|
12579
|
+
const host = hostFromUrl3(route.baseUrl);
|
|
12580
|
+
const base = route.baseUrl.replace(/\/+$/, "");
|
|
12581
|
+
if (!o.dryRun) {
|
|
12582
|
+
const probeSlug = seatKeySlug(splitRoute(route.route)[0], "_probe");
|
|
12583
|
+
try {
|
|
12584
|
+
await store.set(host, probeSlug, {
|
|
12585
|
+
apiKey: "probe",
|
|
12586
|
+
shipId: "",
|
|
12587
|
+
workspaceId: resolved.workspaceId,
|
|
12588
|
+
identityId: "_probe",
|
|
12589
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
12590
|
+
});
|
|
12591
|
+
await store.clear(host, probeSlug);
|
|
12592
|
+
} catch (e) {
|
|
12593
|
+
const detail = `${describeStoreError(e)}; no keys minted`;
|
|
12594
|
+
return targets.map(({ role, agentId }) => agentId ? { role, action: "failed", detail } : { role, action: "no_agent" });
|
|
12696
12595
|
}
|
|
12697
|
-
if (s === "q" || s === "") {
|
|
12698
|
-
stop();
|
|
12699
|
-
process.exit(0);
|
|
12700
|
-
}
|
|
12701
|
-
};
|
|
12702
|
-
if (stdin.isTTY) {
|
|
12703
|
-
stdin.setRawMode(true);
|
|
12704
|
-
stdin.resume();
|
|
12705
|
-
stdin.on("data", onKey);
|
|
12706
12596
|
}
|
|
12707
|
-
|
|
12708
|
-
|
|
12709
|
-
|
|
12710
|
-
|
|
12711
|
-
|
|
12712
|
-
|
|
12713
|
-
const asEmail = q.includes("@");
|
|
12714
|
-
return rows.filter((r) => asEmail ? (r.email ?? "").toLowerCase() === q : (r.name ?? "").toLowerCase().includes(q)).map((r) => ({ id: r.id, name: r.name ?? "", email: r.email ?? "", by: asEmail ? "email" : "name" }));
|
|
12715
|
-
}
|
|
12716
|
-
async function gatherInbox(routes, ship, opts = {}) {
|
|
12717
|
-
const items = [];
|
|
12718
|
-
const errors = [];
|
|
12719
|
-
const resolved = [];
|
|
12720
|
-
await Promise.all(routes.map(async (route) => {
|
|
12597
|
+
for (const { role, agentId } of targets) {
|
|
12598
|
+
if (!agentId) {
|
|
12599
|
+
out.push({ role, action: "no_agent" });
|
|
12600
|
+
continue;
|
|
12601
|
+
}
|
|
12602
|
+
const slug = slugFor(route, agentId);
|
|
12721
12603
|
try {
|
|
12722
|
-
|
|
12723
|
-
|
|
12724
|
-
|
|
12725
|
-
let mine;
|
|
12726
|
-
if (opts.memberQuery) {
|
|
12727
|
-
const hits = matchMembers(crewRows, opts.memberQuery);
|
|
12728
|
-
resolved.push({
|
|
12729
|
-
route: route.route,
|
|
12730
|
-
names: hits.map((h) => h.name),
|
|
12731
|
-
emails: hits.map((h) => h.email).filter(Boolean),
|
|
12732
|
-
by: hits[0]?.by ?? null
|
|
12733
|
-
});
|
|
12734
|
-
mine = new Set(hits.map((h) => h.id));
|
|
12735
|
-
} else {
|
|
12736
|
-
mine = opts.memberIds ?? new Set([route.resolved?.operator].filter(Boolean));
|
|
12604
|
+
if ((await store.get(host, slug))?.apiKey) {
|
|
12605
|
+
out.push({ role, action: "already_stored" });
|
|
12606
|
+
continue;
|
|
12737
12607
|
}
|
|
12738
|
-
|
|
12739
|
-
|
|
12740
|
-
|
|
12741
|
-
|
|
12742
|
-
|
|
12743
|
-
|
|
12744
|
-
|
|
12745
|
-
|
|
12746
|
-
|
|
12747
|
-
|
|
12748
|
-
|
|
12749
|
-
|
|
12608
|
+
if (o.dryRun) {
|
|
12609
|
+
out.push({ role, action: "minted", detail: "dry run" });
|
|
12610
|
+
continue;
|
|
12611
|
+
}
|
|
12612
|
+
const res = await doFetch(`${base}/api/workspaces/${resolved.workspaceId}/agents/${agentId}/api-keys`, {
|
|
12613
|
+
method: "POST",
|
|
12614
|
+
headers: {
|
|
12615
|
+
Authorization: `Bearer ${resolveApiKey(route)}`,
|
|
12616
|
+
"Content-Type": "application/json",
|
|
12617
|
+
"User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0"
|
|
12618
|
+
},
|
|
12619
|
+
body: JSON.stringify({ name: `crew ${o.shipName ?? "ship"} ${role}` })
|
|
12620
|
+
});
|
|
12621
|
+
if (res.status === 403) {
|
|
12622
|
+
out.push({ role, action: "forbidden" });
|
|
12623
|
+
break;
|
|
12624
|
+
}
|
|
12625
|
+
if (!res.ok) {
|
|
12626
|
+
out.push({ role, action: "failed", detail: `${res.status} ${res.statusText}` });
|
|
12627
|
+
continue;
|
|
12628
|
+
}
|
|
12629
|
+
const body = await res.json();
|
|
12630
|
+
if (!body.key) {
|
|
12631
|
+
out.push({ role, action: "failed", detail: "response carried no key" });
|
|
12632
|
+
continue;
|
|
12633
|
+
}
|
|
12634
|
+
try {
|
|
12635
|
+
const session = {
|
|
12636
|
+
apiKey: body.key,
|
|
12637
|
+
shipId: o.shipName ?? "",
|
|
12638
|
+
workspaceId: resolved.workspaceId,
|
|
12639
|
+
identityId: agentId,
|
|
12640
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
12641
|
+
...body.id ? { keyId: body.id } : {}
|
|
12642
|
+
};
|
|
12643
|
+
await store.set(host, slug, session);
|
|
12644
|
+
} catch (e) {
|
|
12645
|
+
let revoked = false;
|
|
12646
|
+
if (body.id) {
|
|
12647
|
+
try {
|
|
12648
|
+
const del2 = await doFetch(`${base}/api/workspaces/${resolved.workspaceId}/agents/${agentId}/api-keys/${body.id}`, {
|
|
12649
|
+
method: "DELETE",
|
|
12650
|
+
headers: { Authorization: `Bearer ${resolveApiKey(route)}`, "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0" }
|
|
12651
|
+
});
|
|
12652
|
+
revoked = del2.ok;
|
|
12653
|
+
} catch {
|
|
12654
|
+
revoked = false;
|
|
12655
|
+
}
|
|
12656
|
+
}
|
|
12657
|
+
out.push({
|
|
12658
|
+
role,
|
|
12659
|
+
action: "failed",
|
|
12660
|
+
detail: revoked ? `minted but could not be stored (${describeStoreError(e)}); revoked` : `a key for ${role} is live on the server and not stored (${describeStoreError(e)}) \u2014 revoke it from that seat's Agents row`
|
|
12750
12661
|
});
|
|
12662
|
+
continue;
|
|
12751
12663
|
}
|
|
12664
|
+
out.push({ role, action: "minted" });
|
|
12752
12665
|
} catch (e) {
|
|
12753
|
-
|
|
12666
|
+
out.push({ role, action: "failed", detail: /Command failed|security|sk_|-w /i.test(e.message) ? describeStoreError(e) : e.message });
|
|
12754
12667
|
}
|
|
12755
|
-
}
|
|
12756
|
-
|
|
12757
|
-
const byName = resolved.filter((r) => r.by === "name" && r.names.length);
|
|
12758
|
-
const distinct = new Set(byName.flatMap((r) => r.names));
|
|
12759
|
-
const ambiguous = byName.length > 1 && distinct.size > 1;
|
|
12760
|
-
return { items, errors, resolved, ambiguous };
|
|
12668
|
+
}
|
|
12669
|
+
return out;
|
|
12761
12670
|
}
|
|
12762
|
-
function
|
|
12763
|
-
|
|
12764
|
-
|
|
12765
|
-
|
|
12766
|
-
|
|
12767
|
-
|
|
12768
|
-
|
|
12769
|
-
|
|
12770
|
-
|
|
12671
|
+
function describeMintOutcome(o) {
|
|
12672
|
+
switch (o.action) {
|
|
12673
|
+
case "minted":
|
|
12674
|
+
return `${o.role}: agent key minted${o.detail ? ` (${o.detail})` : ""}`;
|
|
12675
|
+
case "already_stored":
|
|
12676
|
+
return `${o.role}: agent key already stored`;
|
|
12677
|
+
case "no_agent":
|
|
12678
|
+
return `${o.role}: no linked Agents row \u2014 writes as the ship until \`crew agents sync\` links one`;
|
|
12679
|
+
case "forbidden":
|
|
12680
|
+
return FORBIDDEN_LINE;
|
|
12681
|
+
case "failed":
|
|
12682
|
+
return `${o.role}: could not mint an agent key (${o.detail ?? "unknown"}) \u2014 writes as the ship`;
|
|
12771
12683
|
}
|
|
12684
|
+
}
|
|
12685
|
+
async function revokeSeatKeys(route, o) {
|
|
12772
12686
|
const out = [];
|
|
12773
|
-
|
|
12774
|
-
|
|
12775
|
-
|
|
12776
|
-
|
|
12777
|
-
|
|
12778
|
-
|
|
12779
|
-
|
|
12780
|
-
|
|
12781
|
-
|
|
12782
|
-
|
|
12687
|
+
if (!route.resolved) return out;
|
|
12688
|
+
const resolved = route.resolved;
|
|
12689
|
+
const doFetch = o.fetchImpl ?? fetch;
|
|
12690
|
+
const targets = [
|
|
12691
|
+
...SEAT_KEY_ROLES.filter((r) => resolved.seats[r]).map((role) => ({ role, agentId: seatAgentId(route, role) })),
|
|
12692
|
+
...resolved.releaseSeat ? [{ role: "release", agentId: resolved.releaseAgentId }] : []
|
|
12693
|
+
];
|
|
12694
|
+
let store;
|
|
12695
|
+
try {
|
|
12696
|
+
store = (o.getStore ?? getSessionStore2)();
|
|
12697
|
+
} catch (e) {
|
|
12698
|
+
return targets.map(({ role }) => ({ role, action: "failed", detail: describeStoreError(e) }));
|
|
12783
12699
|
}
|
|
12784
|
-
|
|
12785
|
-
|
|
12786
|
-
|
|
12787
|
-
|
|
12788
|
-
|
|
12789
|
-
|
|
12790
|
-
|
|
12791
|
-
|
|
12792
|
-
return agedRankScalar(rawRank, { since: waiting.since, weight: route.weight }, now);
|
|
12793
|
-
}
|
|
12794
|
-
async function decideFleet(o) {
|
|
12795
|
-
const now = o.now ?? Date.now();
|
|
12796
|
-
const eligible = o.routes.filter((c) => o.enabledOnly === false ? true : c.enabled);
|
|
12797
|
-
const entries = await Promise.all(eligible.map(async (route) => {
|
|
12700
|
+
const host = hostFromUrl3(route.baseUrl);
|
|
12701
|
+
const base = route.baseUrl.replace(/\/+$/, "");
|
|
12702
|
+
for (const { role, agentId } of targets) {
|
|
12703
|
+
if (!agentId) {
|
|
12704
|
+
out.push({ role, action: "no_agent" });
|
|
12705
|
+
continue;
|
|
12706
|
+
}
|
|
12707
|
+
const slug = slugFor(route, agentId);
|
|
12798
12708
|
try {
|
|
12799
|
-
const
|
|
12800
|
-
|
|
12801
|
-
|
|
12802
|
-
|
|
12803
|
-
|
|
12804
|
-
|
|
12805
|
-
|
|
12806
|
-
|
|
12807
|
-
|
|
12808
|
-
|
|
12709
|
+
const session = await store.get(host, slug);
|
|
12710
|
+
if (!session?.apiKey) {
|
|
12711
|
+
out.push({ role, action: "not_stored" });
|
|
12712
|
+
continue;
|
|
12713
|
+
}
|
|
12714
|
+
if (!session.keyId) {
|
|
12715
|
+
out.push({ role, action: "failed", detail: "no key id was recorded when this key was minted \u2014 revoke it from the seat's Agents row" });
|
|
12716
|
+
continue;
|
|
12717
|
+
}
|
|
12718
|
+
if (o.dryRun) {
|
|
12719
|
+
out.push({ role, action: "revoked", detail: "dry run" });
|
|
12720
|
+
continue;
|
|
12721
|
+
}
|
|
12722
|
+
const del2 = await doFetch(`${base}/api/workspaces/${resolved.workspaceId}/agents/${agentId}/api-keys/${session.keyId}`, {
|
|
12723
|
+
method: "DELETE",
|
|
12724
|
+
headers: { Authorization: `Bearer ${resolveApiKey(route)}`, "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0" }
|
|
12725
|
+
});
|
|
12726
|
+
if (!del2.ok && del2.status !== 404) {
|
|
12727
|
+
out.push({ role, action: "failed", detail: `${del2.status} ${del2.statusText}` });
|
|
12728
|
+
continue;
|
|
12729
|
+
}
|
|
12730
|
+
await store.clear(host, slug);
|
|
12731
|
+
out.push({ role, action: "revoked", detail: del2.ok ? void 0 : "server already had no such key" });
|
|
12809
12732
|
} catch (e) {
|
|
12810
|
-
|
|
12733
|
+
out.push({ role, action: "failed", detail: /Command failed|security|sk_|-w /i.test(e.message) ? describeStoreError(e) : e.message });
|
|
12811
12734
|
}
|
|
12812
|
-
}));
|
|
12813
|
-
for (const e of entries) {
|
|
12814
|
-
if (e.error) o.emit.forRoute(e.route.route).error(`${e.route.route}: ${e.error}`, { step: "poll" });
|
|
12815
12735
|
}
|
|
12816
|
-
|
|
12817
|
-
|
|
12818
|
-
|
|
12819
|
-
|
|
12820
|
-
|
|
12821
|
-
|
|
12822
|
-
|
|
12823
|
-
|
|
12824
|
-
|
|
12825
|
-
|
|
12736
|
+
return out;
|
|
12737
|
+
}
|
|
12738
|
+
function describeRevokeOutcome(o) {
|
|
12739
|
+
switch (o.action) {
|
|
12740
|
+
case "revoked":
|
|
12741
|
+
return `${o.role}: agent key revoked and cleared from the keychain${o.detail ? ` (${o.detail})` : ""}`;
|
|
12742
|
+
case "not_stored":
|
|
12743
|
+
return `${o.role}: no agent key stored on this ship`;
|
|
12744
|
+
case "no_agent":
|
|
12745
|
+
return `${o.role}: no linked Agents row`;
|
|
12746
|
+
case "failed":
|
|
12747
|
+
return `${o.role}: could not revoke the agent key (${o.detail ?? "unknown"}) \u2014 left in the keychain`;
|
|
12826
12748
|
}
|
|
12827
|
-
return {
|
|
12828
|
-
entries,
|
|
12829
|
-
winner: best && best.role && best.decision ? { route: best.route, role: best.role, decision: best.decision } : void 0,
|
|
12830
|
-
unreachable: entries.filter((e) => e.error).map((e) => e.route.route)
|
|
12831
|
-
};
|
|
12832
12749
|
}
|
|
12833
|
-
|
|
12834
|
-
|
|
12835
|
-
|
|
12836
|
-
const
|
|
12837
|
-
if (
|
|
12838
|
-
|
|
12839
|
-
|
|
12840
|
-
|
|
12750
|
+
async function canMintAgentKeys(route, o = {}) {
|
|
12751
|
+
const resolved = route.resolved;
|
|
12752
|
+
if (!resolved) return void 0;
|
|
12753
|
+
const agentId = seatAgentId(route, "dev") ?? SEAT_KEY_ROLES.map((r) => seatAgentId(route, r)).find(Boolean);
|
|
12754
|
+
if (!agentId) return void 0;
|
|
12755
|
+
try {
|
|
12756
|
+
const res = await (o.fetchImpl ?? fetch)(`${route.baseUrl.replace(/\/+$/, "")}/api/workspaces/${resolved.workspaceId}/agents/${agentId}/api-keys`, {
|
|
12757
|
+
headers: { Authorization: `Bearer ${resolveApiKey(route)}`, "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0" }
|
|
12758
|
+
});
|
|
12759
|
+
if (res.ok) return true;
|
|
12760
|
+
return res.status === 403 ? false : void 0;
|
|
12761
|
+
} catch {
|
|
12762
|
+
return void 0;
|
|
12763
|
+
}
|
|
12841
12764
|
}
|
|
12842
|
-
|
|
12843
|
-
|
|
12844
|
-
|
|
12845
|
-
|
|
12846
|
-
|
|
12847
|
-
|
|
12848
|
-
|
|
12849
|
-
|
|
12850
|
-
|
|
12851
|
-
|
|
12852
|
-
|
|
12853
|
-
|
|
12854
|
-
|
|
12765
|
+
|
|
12766
|
+
// src/seat-read-probe.ts
|
|
12767
|
+
var SEAT_NO_READ_REMEDY = "the project has no default role for agent keys \u2014 update the Issues app's template, then re-mint with `crew agents sync --revoke-seat-keys`, or assign the Crew seat role to the agent members";
|
|
12768
|
+
async function probeIssuesRead(route, apiKey, o = {}) {
|
|
12769
|
+
const resolved = route.resolved;
|
|
12770
|
+
if (!resolved?.models?.issues) return { state: "unknown", detail: "route has no resolved Issues table" };
|
|
12771
|
+
const url = `${route.baseUrl.replace(/\/+$/, "")}/api/workspaces/${resolved.workspaceId}/data-models/${resolved.models.issues}/records?limit=1`;
|
|
12772
|
+
try {
|
|
12773
|
+
const res = await (o.fetchImpl ?? fetch)(url, {
|
|
12774
|
+
headers: { Authorization: `Bearer ${apiKey}`, "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0" }
|
|
12775
|
+
});
|
|
12776
|
+
if (res.ok) return { state: "ok" };
|
|
12777
|
+
if (res.status === 403) return { state: "forbidden" };
|
|
12778
|
+
return { state: "unknown", detail: `HTTP ${res.status}` };
|
|
12779
|
+
} catch (e) {
|
|
12780
|
+
return { state: "unknown", detail: e.message };
|
|
12781
|
+
}
|
|
12855
12782
|
}
|
|
12856
|
-
function
|
|
12783
|
+
async function probeSeatReads(route, o = {}) {
|
|
12784
|
+
const out = [];
|
|
12785
|
+
const roles = [...SEAT_KEY_ROLES.filter((r) => route.resolved?.seats[r]), ...route.resolved?.releaseSeat ? ["release"] : []];
|
|
12786
|
+
for (const role of roles) {
|
|
12787
|
+
const id = role === "release" ? await resolveReleaseKey(route, o.getStore) : await resolveSeatKey(route, role, o.getStore);
|
|
12788
|
+
if (id.kind !== "agent") continue;
|
|
12789
|
+
out.push({ role, ...await probeIssuesRead(route, id.apiKey, o) });
|
|
12790
|
+
}
|
|
12791
|
+
return out;
|
|
12792
|
+
}
|
|
12793
|
+
function describeSeatForbidden(role) {
|
|
12794
|
+
return `seat read: ERROR ${role} agent key cannot read Issues (403) \u2014 ${SEAT_NO_READ_REMEDY}`;
|
|
12795
|
+
}
|
|
12796
|
+
function isForbiddenError(e) {
|
|
12797
|
+
const status2 = e?.status;
|
|
12798
|
+
if (status2 === 403) return true;
|
|
12799
|
+
return /\b403\b/.test(e instanceof Error ? e.message : String(e));
|
|
12800
|
+
}
|
|
12801
|
+
var SEAT_FORBIDDEN_KEY = "seat_forbidden:release";
|
|
12802
|
+
function seatForbiddenItem(now = /* @__PURE__ */ new Date()) {
|
|
12857
12803
|
return {
|
|
12858
|
-
|
|
12804
|
+
kind: "seat_forbidden",
|
|
12805
|
+
key: SEAT_FORBIDDEN_KEY,
|
|
12806
|
+
message: `release seat cannot read the Issues table (403) \u2014 ${SEAT_NO_READ_REMEDY}`,
|
|
12807
|
+
since: now.toISOString()
|
|
12859
12808
|
};
|
|
12860
12809
|
}
|
|
12861
|
-
function
|
|
12862
|
-
|
|
12863
|
-
for (const [id, v] of after.state) if (before.state.get(id) !== v) return true;
|
|
12864
|
-
return false;
|
|
12865
|
-
}
|
|
12866
|
-
function nextRoles(decision, ran) {
|
|
12867
|
-
return decision.selection.pending.filter((r) => !ran.includes(r)).sort((a, b) => (decision.selection.ranks[a] ?? 0) - (decision.selection.ranks[b] ?? 0));
|
|
12810
|
+
function seatForbiddenRepoLine(scope) {
|
|
12811
|
+
return `${scope}: skipped \u2014 release seat cannot read Issues (see attention)`;
|
|
12868
12812
|
}
|
|
12869
12813
|
|
|
12870
|
-
// src/
|
|
12871
|
-
import {
|
|
12872
|
-
import { dirname as dirname14 } from "path";
|
|
12873
|
-
import { parseDocument } from "yaml";
|
|
12814
|
+
// src/cli.ts
|
|
12815
|
+
import { StaleWriteError as StaleWriteError9 } from "@tablation/client";
|
|
12874
12816
|
|
|
12875
|
-
// src/
|
|
12876
|
-
|
|
12877
|
-
|
|
12878
|
-
|
|
12879
|
-
|
|
12880
|
-
|
|
12881
|
-
return join27(stateDir, "machine-id");
|
|
12882
|
-
}
|
|
12883
|
-
var sha256 = (s) => createHash4("sha256").update(s).digest("hex");
|
|
12884
|
-
function readRawMachineId(platform2 = process.platform) {
|
|
12885
|
-
try {
|
|
12886
|
-
if (platform2 === "darwin") {
|
|
12887
|
-
const out = execFileSync4("ioreg", ["-rd1", "-c", "IOPlatformExpertDevice"], {
|
|
12888
|
-
encoding: "utf8",
|
|
12889
|
-
stdio: ["ignore", "pipe", "ignore"]
|
|
12890
|
-
});
|
|
12891
|
-
return /"IOPlatformUUID"\s*=\s*"([^"]+)"/.exec(out)?.[1];
|
|
12892
|
-
}
|
|
12893
|
-
for (const p of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) {
|
|
12894
|
-
if (existsSync24(p)) {
|
|
12895
|
-
const id = readFileSync21(p, "utf8").trim();
|
|
12896
|
-
if (id) return id;
|
|
12897
|
-
}
|
|
12898
|
-
}
|
|
12899
|
-
} catch {
|
|
12900
|
-
}
|
|
12901
|
-
return void 0;
|
|
12817
|
+
// src/release-ship.ts
|
|
12818
|
+
var DAY_MS = 24 * 60 * 60 * 1e3;
|
|
12819
|
+
function releaserFor(release, shipName2) {
|
|
12820
|
+
const named = release.ship;
|
|
12821
|
+
if (!named || release.mode !== "local" && release.mode !== "integrate") return { releases: true, releaser: named ?? null };
|
|
12822
|
+
return { releases: named.trim() === shipName2.trim(), releaser: named };
|
|
12902
12823
|
}
|
|
12903
|
-
function
|
|
12904
|
-
|
|
12905
|
-
const path = machineIdPathFor(stateDir);
|
|
12906
|
-
if (existsSync24(path)) {
|
|
12907
|
-
const cached = readFileSync21(path, "utf8").trim();
|
|
12908
|
-
if (/^[0-9a-f]{64}$/.test(cached)) return cached;
|
|
12909
|
-
}
|
|
12910
|
-
const raw = (opts.read ?? readRawMachineId)();
|
|
12911
|
-
if (!raw) return void 0;
|
|
12912
|
-
const hashed = sha256(raw);
|
|
12913
|
-
mkdirSync15(dirname13(path), { recursive: true });
|
|
12914
|
-
writeFileSync17(path, `${hashed}
|
|
12915
|
-
`, { mode: 384 });
|
|
12916
|
-
return hashed;
|
|
12824
|
+
function nonReleaserNote(repo, releaser) {
|
|
12825
|
+
return `release for ${repo} runs on ${releaser}`;
|
|
12917
12826
|
}
|
|
12918
|
-
function
|
|
12919
|
-
|
|
12920
|
-
const
|
|
12921
|
-
if (
|
|
12922
|
-
|
|
12827
|
+
function missingReleaserWarning(repo, release, ships, now = Date.now()) {
|
|
12828
|
+
if (release.ship || release.mode !== "local" && release.mode !== "integrate") return null;
|
|
12829
|
+
const live = ships.filter((s) => s.last_seen && now - Date.parse(s.last_seen) <= DAY_MS);
|
|
12830
|
+
if (live.length < 2) return null;
|
|
12831
|
+
const names = live.map((s) => s.name ?? "?").join(", ");
|
|
12832
|
+
return `${repo}: release.mode "${release.mode}" with no release.ship, and ${live.length} ships seen in the last day (${names}) \u2014 every one of them will try to release it. Set release.ship in .crew.yaml.`;
|
|
12923
12833
|
}
|
|
12924
12834
|
|
|
12925
|
-
// src/
|
|
12926
|
-
|
|
12927
|
-
|
|
12928
|
-
|
|
12929
|
-
|
|
12930
|
-
|
|
12931
|
-
|
|
12932
|
-
|
|
12933
|
-
|
|
12934
|
-
|
|
12935
|
-
|
|
12936
|
-
|
|
12937
|
-
|
|
12938
|
-
|
|
12939
|
-
if (rowMoved) return { drift: "row", action: "adopt" };
|
|
12940
|
-
return { drift: "local", action: "push" };
|
|
12835
|
+
// src/watch.ts
|
|
12836
|
+
import { watch as watchFile, existsSync as existsSync23, statSync as statSync7, openSync as openSync4, readSync as readSync2, closeSync as closeSync4 } from "fs";
|
|
12837
|
+
var E = "\x1B[";
|
|
12838
|
+
var dim = (s) => `${E}2m${s}${E}0m`;
|
|
12839
|
+
var bold = (s) => `${E}1m${s}${E}0m`;
|
|
12840
|
+
var paintWith = (n) => (s) => `${E}${n}m${s}${E}0m`;
|
|
12841
|
+
var green = paintWith(32);
|
|
12842
|
+
var yellow = paintWith(33);
|
|
12843
|
+
var red = paintWith(31);
|
|
12844
|
+
var cyan = paintWith(36);
|
|
12845
|
+
var STEP_ORDER = ["poll", "sweep", "select", "worktree", "agent", "reconcile", "merge", "release", "idle"];
|
|
12846
|
+
var WORKING_STEPS = /* @__PURE__ */ new Set(["worktree", "agent", "reconcile", "merge", "release"]);
|
|
12847
|
+
function byRoute(events, route) {
|
|
12848
|
+
return route ? events.filter((e) => e.route === route) : events;
|
|
12941
12849
|
}
|
|
12942
|
-
function
|
|
12943
|
-
|
|
12944
|
-
|
|
12850
|
+
function matches(e, f) {
|
|
12851
|
+
if (f.role && e.role !== f.role) return false;
|
|
12852
|
+
if (f.ticket && e.ticket !== f.ticket) return false;
|
|
12853
|
+
if (f.route && e.route !== f.route) return false;
|
|
12854
|
+
if (f.level === "warn" && e.level === "info") return false;
|
|
12855
|
+
if (f.level === "error" && e.level !== "error") return false;
|
|
12856
|
+
return true;
|
|
12945
12857
|
}
|
|
12946
|
-
function
|
|
12947
|
-
const
|
|
12948
|
-
|
|
12949
|
-
|
|
12858
|
+
function localTime(iso) {
|
|
12859
|
+
const d = new Date(iso);
|
|
12860
|
+
if (Number.isNaN(d.getTime())) return iso.slice(11, 19);
|
|
12861
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
12862
|
+
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
12950
12863
|
}
|
|
12951
|
-
function
|
|
12952
|
-
const
|
|
12953
|
-
|
|
12954
|
-
const
|
|
12955
|
-
|
|
12956
|
-
|
|
12957
|
-
|
|
12958
|
-
const named = rows.filter((r) => !r.machine_id && names.has((r.name ?? "").trim()));
|
|
12959
|
-
if (named.length === 1) return { kind: "row", row: named[0], by: "name" };
|
|
12960
|
-
return { kind: named.length > 1 ? "ambiguous" : "none" };
|
|
12864
|
+
function formatLine(e, showRoute = false) {
|
|
12865
|
+
const t = dim(localTime(e.at));
|
|
12866
|
+
const scope = e.role ? `${e.step}[${e.role}]` : e.step;
|
|
12867
|
+
const paint = e.level === "error" ? red : e.level === "warn" ? yellow : cyan;
|
|
12868
|
+
const route = showRoute ? `${dim(e.route)} ` : "";
|
|
12869
|
+
const ticket = e.ticket ? ` ${bold(e.ticket)}` : "";
|
|
12870
|
+
return `${t} ${route}${paint(scope.padEnd(14))}${ticket} ${e.message}`;
|
|
12961
12871
|
}
|
|
12962
|
-
|
|
12963
|
-
const
|
|
12964
|
-
if (
|
|
12965
|
-
|
|
12966
|
-
|
|
12967
|
-
|
|
12968
|
-
|
|
12969
|
-
|
|
12970
|
-
|
|
12971
|
-
|
|
12972
|
-
|
|
12973
|
-
|
|
12974
|
-
|
|
12975
|
-
|
|
12976
|
-
|
|
12977
|
-
|
|
12872
|
+
function foldCycle(events) {
|
|
12873
|
+
const last = events[events.length - 1];
|
|
12874
|
+
if (!last) return null;
|
|
12875
|
+
const ofCycle = events.filter((e) => e.cycle === last.cycle);
|
|
12876
|
+
const first = ofCycle[0];
|
|
12877
|
+
const state = {
|
|
12878
|
+
cycle: last.cycle,
|
|
12879
|
+
route: last.route,
|
|
12880
|
+
step: last.step,
|
|
12881
|
+
role: last.role,
|
|
12882
|
+
ticket: [...ofCycle].reverse().find((e) => e.ticket && WORKING_STEPS.has(e.step))?.ticket,
|
|
12883
|
+
startedAt: first.at,
|
|
12884
|
+
lastAt: last.at,
|
|
12885
|
+
warnings: ofCycle.filter((e) => e.level === "warn").length,
|
|
12886
|
+
errors: ofCycle.filter((e) => e.level === "error").length
|
|
12887
|
+
};
|
|
12888
|
+
for (const e of ofCycle) {
|
|
12889
|
+
if (typeof e.data?.rank === "number") state.rank = e.data.rank;
|
|
12890
|
+
if (Array.isArray(e.data?.pending)) state.pending = e.data.pending;
|
|
12891
|
+
if (Array.isArray(e.data?.blocked)) state.blockedTickets = e.data.blocked;
|
|
12892
|
+
if (Array.isArray(e.data?.qaHeld)) state.qaHeldTickets = e.data.qaHeld;
|
|
12893
|
+
if (e.step === "release" && typeof e.data?.cycles === "number") {
|
|
12894
|
+
state.releaseBlockedCycles = e.data.cycles;
|
|
12895
|
+
state.releaseBlockedReason = typeof e.data.reason === "string" ? e.data.reason : void 0;
|
|
12978
12896
|
}
|
|
12979
|
-
return { status: "ok", ...base, action, name: i.local };
|
|
12980
|
-
}
|
|
12981
|
-
if (action === "adopt" || action === "row-wins") return { status: "ok", ...base, action, name: rowName, from: i.local };
|
|
12982
|
-
return { status: "ok", ...base, action, name: i.local };
|
|
12983
|
-
}
|
|
12984
|
-
function readSyncedName(resolvedFile) {
|
|
12985
|
-
if (!existsSync25(resolvedFile)) return void 0;
|
|
12986
|
-
try {
|
|
12987
|
-
const v = JSON.parse(readFileSync22(resolvedFile, "utf8")).shipName;
|
|
12988
|
-
return typeof v === "string" ? v : void 0;
|
|
12989
|
-
} catch {
|
|
12990
|
-
return void 0;
|
|
12991
12897
|
}
|
|
12898
|
+
return state;
|
|
12992
12899
|
}
|
|
12993
|
-
function
|
|
12994
|
-
|
|
12995
|
-
|
|
12996
|
-
try {
|
|
12997
|
-
raw = JSON.parse(readFileSync22(resolvedFile, "utf8"));
|
|
12998
|
-
} catch {
|
|
12999
|
-
return;
|
|
13000
|
-
}
|
|
13001
|
-
if (raw.shipName === name) return;
|
|
13002
|
-
raw.shipName = name;
|
|
13003
|
-
mkdirSync16(dirname14(resolvedFile), { recursive: true });
|
|
13004
|
-
const tmp = `${resolvedFile}.tmp${process.pid}`;
|
|
13005
|
-
writeFileSync18(tmp, `${JSON.stringify(raw, null, 2)}
|
|
13006
|
-
`);
|
|
13007
|
-
renameSync2(tmp, resolvedFile);
|
|
12900
|
+
function foldCycles(events) {
|
|
12901
|
+
const routes = [...new Set(events.map((e) => e.route))];
|
|
12902
|
+
return routes.map((r) => foldCycle(events.filter((e) => e.route === r))).filter((s) => s !== null).sort((a, b) => Date.parse(b.lastAt) - Date.parse(a.lastAt));
|
|
13008
12903
|
}
|
|
13009
|
-
|
|
13010
|
-
|
|
13011
|
-
|
|
13012
|
-
|
|
13013
|
-
|
|
13014
|
-
}
|
|
13015
|
-
|
|
13016
|
-
|
|
13017
|
-
|
|
13018
|
-
|
|
13019
|
-
|
|
13020
|
-
|
|
13021
|
-
|
|
13022
|
-
const
|
|
13023
|
-
|
|
13024
|
-
|
|
13025
|
-
|
|
13026
|
-
|
|
13027
|
-
|
|
13028
|
-
|
|
13029
|
-
|
|
13030
|
-
|
|
13031
|
-
|
|
13032
|
-
|
|
13033
|
-
|
|
13034
|
-
|
|
13035
|
-
|
|
13036
|
-
|
|
13037
|
-
|
|
13038
|
-
|
|
13039
|
-
|
|
13040
|
-
|
|
13041
|
-
|
|
13042
|
-
|
|
13043
|
-
|
|
13044
|
-
|
|
13045
|
-
|
|
13046
|
-
|
|
13047
|
-
|
|
13048
|
-
|
|
12904
|
+
function renderHeader(s, now = Date.now()) {
|
|
12905
|
+
if (!s) return dim("no cycle seen yet - waiting for the crew to run\n");
|
|
12906
|
+
const elapsed = Math.max(0, Math.round((now - Date.parse(s.lastAt)) / 1e3));
|
|
12907
|
+
const progress = STEP_ORDER.map((st) => st === s.step ? bold(green(st)) : dim(st)).join(dim(" > "));
|
|
12908
|
+
const bits = [
|
|
12909
|
+
`${bold(s.route)} cycle ${s.cycle}`,
|
|
12910
|
+
s.role ? `role ${bold(s.role)}` : null,
|
|
12911
|
+
s.ticket ? `ticket ${bold(s.ticket)}` : null,
|
|
12912
|
+
s.rank !== void 0 ? dim(`rank ${s.rank}`) : null,
|
|
12913
|
+
s.pending?.length ? dim(`pending: ${s.pending.join(" ")}`) : null,
|
|
12914
|
+
s.errors ? red(`${s.errors} error(s)`) : s.warnings ? yellow(`${s.warnings} warning(s)`) : null,
|
|
12915
|
+
dim(`${elapsed}s in ${s.step}`)
|
|
12916
|
+
].filter(Boolean);
|
|
12917
|
+
const releaseLine = s.releaseBlockedCycles ? red(
|
|
12918
|
+
`release BLOCKED (${s.releaseBlockedCycles} cycle${s.releaseBlockedCycles === 1 ? "" : "s"}): ${s.releaseBlockedReason ?? "unknown reason"}`
|
|
12919
|
+
) : null;
|
|
12920
|
+
const extra = [
|
|
12921
|
+
releaseLine,
|
|
12922
|
+
s.blockedTickets?.length ? dim(`blocked: ${s.blockedTickets.join(" ")}`) : null,
|
|
12923
|
+
s.qaHeldTickets?.length ? dim(`qa holding: ${s.qaHeldTickets.join(" ")}`) : null
|
|
12924
|
+
].filter(Boolean);
|
|
12925
|
+
const extraLines = extra.length ? `${extra.join("\n")}
|
|
12926
|
+
` : "";
|
|
12927
|
+
return `${bits.join(dim(" | "))}
|
|
12928
|
+
${extraLines}${progress}
|
|
12929
|
+
${dim("-".repeat(72))}
|
|
12930
|
+
`;
|
|
12931
|
+
}
|
|
12932
|
+
function renderHeaders(states, now = Date.now()) {
|
|
12933
|
+
if (!states.length) return dim("no cycle seen yet - waiting for the crew to run\n");
|
|
12934
|
+
return states.map((s) => renderHeader(s, now)).join("");
|
|
12935
|
+
}
|
|
12936
|
+
function readFrom(file, from) {
|
|
12937
|
+
if (!existsSync23(file)) return { events: [], offset: from };
|
|
12938
|
+
const size = statSync7(file).size;
|
|
12939
|
+
if (size < from) return readFrom(file, 0);
|
|
12940
|
+
if (size === from) return { events: [], offset: from };
|
|
12941
|
+
const fd = openSync4(file, "r");
|
|
12942
|
+
const buf = Buffer.alloc(size - from);
|
|
12943
|
+
readSync2(fd, buf, 0, buf.length, from);
|
|
12944
|
+
closeSync4(fd);
|
|
12945
|
+
const text = buf.toString("utf8");
|
|
12946
|
+
const lastNewline = text.lastIndexOf("\n");
|
|
12947
|
+
if (lastNewline === -1) return { events: [], offset: from };
|
|
12948
|
+
const events = [];
|
|
12949
|
+
for (const line of text.slice(0, lastNewline).split("\n")) {
|
|
12950
|
+
if (!line.trim()) continue;
|
|
12951
|
+
try {
|
|
12952
|
+
events.push(JSON.parse(line));
|
|
12953
|
+
} catch {
|
|
13049
12954
|
}
|
|
13050
|
-
} else if (result.action === "push") {
|
|
13051
|
-
i.emit?.emit(`ship name "${result.name}" pushed to the Ships table`, {
|
|
13052
|
-
step: "poll",
|
|
13053
|
-
data: { event: "ship_renamed", route: i.route, from: result.rowName, to: result.name, source: "crew.yaml" }
|
|
13054
|
-
});
|
|
13055
12955
|
}
|
|
13056
|
-
|
|
13057
|
-
return result;
|
|
12956
|
+
return { events, offset: from + Buffer.byteLength(text.slice(0, lastNewline + 1), "utf8") };
|
|
13058
12957
|
}
|
|
13059
|
-
|
|
13060
|
-
const
|
|
13061
|
-
|
|
12958
|
+
function startWatch(o) {
|
|
12959
|
+
const out = o.out ?? ((s) => process.stdout.write(s));
|
|
12960
|
+
const filter = o.filter ?? {};
|
|
12961
|
+
let all = [];
|
|
12962
|
+
let offset = 0;
|
|
12963
|
+
let paused = false;
|
|
12964
|
+
const showRoute = () => !filter.route && new Set(all.map((e) => e.route)).size > 1;
|
|
12965
|
+
const seed = readFrom(o.file, 0);
|
|
12966
|
+
all = seed.events;
|
|
12967
|
+
offset = seed.offset;
|
|
12968
|
+
for (const e of all.slice(-(o.tail ?? 20)).filter((x) => matches(x, filter))) out(`${formatLine(e, showRoute())}
|
|
12969
|
+
`);
|
|
12970
|
+
out(renderHeaders(foldCycles(byRoute(all, filter.route))));
|
|
12971
|
+
const pump = () => {
|
|
12972
|
+
const { events, offset: next } = readFrom(o.file, offset);
|
|
12973
|
+
offset = next;
|
|
12974
|
+
if (!events.length) return;
|
|
12975
|
+
all = [...all, ...events].slice(-5e3);
|
|
12976
|
+
if (paused) return;
|
|
12977
|
+
for (const e of events) if (matches(e, filter)) out(`${formatLine(e, showRoute())}
|
|
12978
|
+
`);
|
|
12979
|
+
out(renderHeaders(foldCycles(byRoute(all, filter.route))));
|
|
12980
|
+
};
|
|
12981
|
+
const timer = setInterval(pump, 1e3);
|
|
12982
|
+
let watcher;
|
|
13062
12983
|
try {
|
|
13063
|
-
|
|
13064
|
-
const tracker = new Tracker(o.route, o.ship);
|
|
13065
|
-
return await reconcileShipName({
|
|
13066
|
-
route: o.route.route,
|
|
13067
|
-
configFile: o.configFile,
|
|
13068
|
-
stateDir: o.ship.stateDir,
|
|
13069
|
-
ship: o.ship,
|
|
13070
|
-
machineId,
|
|
13071
|
-
rows: () => tracker.shipRows(),
|
|
13072
|
-
rename: (id, name) => tracker.renameShipRow(id, name),
|
|
13073
|
-
emit: o.emit,
|
|
13074
|
-
readOnly: o.readOnly
|
|
13075
|
-
});
|
|
12984
|
+
watcher = watchFile(o.file, { persistent: false }, pump);
|
|
13076
12985
|
} catch {
|
|
13077
|
-
return fallback;
|
|
13078
|
-
}
|
|
13079
|
-
}
|
|
13080
|
-
|
|
13081
|
-
// src/connect.ts
|
|
13082
|
-
var ConnectHttpError = class extends Error {
|
|
13083
|
-
status;
|
|
13084
|
-
path;
|
|
13085
|
-
constructor(status2, path, statusText) {
|
|
13086
|
-
super(`${path}: ${status2} ${statusText}`);
|
|
13087
|
-
this.name = "ConnectHttpError";
|
|
13088
|
-
this.status = status2;
|
|
13089
|
-
this.path = path;
|
|
13090
12986
|
}
|
|
13091
|
-
|
|
13092
|
-
|
|
13093
|
-
|
|
13094
|
-
|
|
13095
|
-
|
|
13096
|
-
|
|
13097
|
-
|
|
12987
|
+
const stdin = process.stdin;
|
|
12988
|
+
const stop = () => {
|
|
12989
|
+
clearInterval(timer);
|
|
12990
|
+
watcher?.close();
|
|
12991
|
+
if (stdin.isTTY) {
|
|
12992
|
+
stdin.setRawMode(false);
|
|
12993
|
+
stdin.pause();
|
|
12994
|
+
stdin.off("data", onKey);
|
|
13098
12995
|
}
|
|
13099
|
-
}
|
|
13100
|
-
|
|
13101
|
-
|
|
13102
|
-
|
|
13103
|
-
|
|
13104
|
-
|
|
13105
|
-
|
|
13106
|
-
|
|
13107
|
-
|
|
13108
|
-
|
|
13109
|
-
"Content-Type": "application/json"
|
|
13110
|
-
},
|
|
13111
|
-
body: JSON.stringify(body)
|
|
13112
|
-
});
|
|
13113
|
-
if (!res.ok) throw new ConnectHttpError(res.status, path, res.statusText);
|
|
13114
|
-
return await res.json();
|
|
13115
|
-
}
|
|
13116
|
-
async function patch(o, path, body) {
|
|
13117
|
-
const res = await fetch(`${o.baseUrl.replace(/\/+$/, "")}/api${path}`, {
|
|
13118
|
-
method: "PATCH",
|
|
13119
|
-
headers: {
|
|
13120
|
-
Authorization: `Bearer ${o.apiKey}`,
|
|
13121
|
-
"User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0",
|
|
13122
|
-
"Content-Type": "application/json"
|
|
13123
|
-
},
|
|
13124
|
-
body: JSON.stringify(body)
|
|
13125
|
-
});
|
|
13126
|
-
if (!res.ok) throw new ConnectHttpError(res.status, path, res.statusText);
|
|
13127
|
-
return await res.json();
|
|
13128
|
-
}
|
|
13129
|
-
async function del(o, path) {
|
|
13130
|
-
const res = await fetch(`${o.baseUrl.replace(/\/+$/, "")}/api${path}`, {
|
|
13131
|
-
method: "DELETE",
|
|
13132
|
-
headers: {
|
|
13133
|
-
Authorization: `Bearer ${o.apiKey}`,
|
|
13134
|
-
"User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0"
|
|
12996
|
+
};
|
|
12997
|
+
const onKey = (k) => {
|
|
12998
|
+
const s = k.toString();
|
|
12999
|
+
if (s === " ") {
|
|
13000
|
+
paused = !paused;
|
|
13001
|
+
out(dim(paused ? "\n-- paused (space to resume) --\n" : "-- resumed --\n"));
|
|
13002
|
+
}
|
|
13003
|
+
if (s === "q" || s === "") {
|
|
13004
|
+
stop();
|
|
13005
|
+
process.exit(0);
|
|
13135
13006
|
}
|
|
13136
|
-
});
|
|
13137
|
-
if (!res.ok) throw new ConnectHttpError(res.status, path, res.statusText);
|
|
13138
|
-
}
|
|
13139
|
-
async function provisionReleaseLocks(o, locksModelId, route, repoNames) {
|
|
13140
|
-
const scopes = [...new Set(repoNames)].sort().map((r) => releaseLockScope(route, r));
|
|
13141
|
-
if (scopes.length === 0) return [];
|
|
13142
|
-
const client2 = {
|
|
13143
|
-
// The Locks table is a handful of rows, so read it whole and match in
|
|
13144
|
-
// memory rather than depend on the HTTP filter syntax.
|
|
13145
|
-
list: async (model, params) => {
|
|
13146
|
-
const want = JSON.parse(params?.filters ?? "[]")[0]?.value;
|
|
13147
|
-
const rows = await get(
|
|
13148
|
-
o,
|
|
13149
|
-
`/data-models/${model}/records?limit=500`
|
|
13150
|
-
);
|
|
13151
|
-
return rows.filter((r) => r.scope === want).slice(0, params?.limit ?? rows.length);
|
|
13152
|
-
},
|
|
13153
|
-
create: (model, body) => post2(o, `/data-models/${model}/records`, body),
|
|
13154
|
-
remove: (model, id) => del(o, `/data-models/${model}/records/${id}`)
|
|
13155
13007
|
};
|
|
13156
|
-
|
|
13008
|
+
if (stdin.isTTY) {
|
|
13009
|
+
stdin.setRawMode(true);
|
|
13010
|
+
stdin.resume();
|
|
13011
|
+
stdin.on("data", onKey);
|
|
13012
|
+
}
|
|
13013
|
+
return stop;
|
|
13157
13014
|
}
|
|
13158
|
-
|
|
13159
|
-
|
|
13160
|
-
|
|
13161
|
-
|
|
13015
|
+
|
|
13016
|
+
// src/inbox.ts
|
|
13017
|
+
function matchMembers(rows, query) {
|
|
13018
|
+
const q = query.trim().toLowerCase();
|
|
13019
|
+
const asEmail = q.includes("@");
|
|
13020
|
+
return rows.filter((r) => asEmail ? (r.email ?? "").toLowerCase() === q : (r.name ?? "").toLowerCase().includes(q)).map((r) => ({ id: r.id, name: r.name ?? "", email: r.email ?? "", by: asEmail ? "email" : "name" }));
|
|
13021
|
+
}
|
|
13022
|
+
async function gatherInbox(routes, ship, opts = {}) {
|
|
13023
|
+
const items = [];
|
|
13024
|
+
const errors = [];
|
|
13025
|
+
const resolved = [];
|
|
13026
|
+
await Promise.all(routes.map(async (route) => {
|
|
13162
13027
|
try {
|
|
13163
|
-
const
|
|
13164
|
-
|
|
13165
|
-
|
|
13166
|
-
|
|
13167
|
-
if (
|
|
13168
|
-
|
|
13169
|
-
|
|
13028
|
+
const tracker = new Tracker(route, ship);
|
|
13029
|
+
const [tickets, crewRows] = await Promise.all([tracker.openTickets(), tracker.crewRows()]);
|
|
13030
|
+
const roster = buildRoster(configuredMembers(route), crewRows);
|
|
13031
|
+
let mine;
|
|
13032
|
+
if (opts.memberQuery) {
|
|
13033
|
+
const hits = matchMembers(crewRows, opts.memberQuery);
|
|
13034
|
+
resolved.push({
|
|
13035
|
+
route: route.route,
|
|
13036
|
+
names: hits.map((h) => h.name),
|
|
13037
|
+
emails: hits.map((h) => h.email).filter(Boolean),
|
|
13038
|
+
by: hits[0]?.by ?? null
|
|
13039
|
+
});
|
|
13040
|
+
mine = new Set(hits.map((h) => h.id));
|
|
13041
|
+
} else {
|
|
13042
|
+
mine = opts.memberIds ?? new Set([route.resolved?.operator].filter(Boolean));
|
|
13043
|
+
}
|
|
13044
|
+
const c = tracker.contract;
|
|
13045
|
+
for (const t of tickets) {
|
|
13046
|
+
if (!t.assignee_id || !mine.has(t.assignee_id)) continue;
|
|
13047
|
+
if (opts.statuses && !opts.statuses.has(t.status)) continue;
|
|
13048
|
+
if (!opts.includeClosed && c.statuses.resolved.includes(t.status)) continue;
|
|
13049
|
+
items.push({
|
|
13050
|
+
route: route.route,
|
|
13051
|
+
ticket: t,
|
|
13052
|
+
assignee: crewLabel(roster.get(t.assignee_id)),
|
|
13053
|
+
rank: rankScalar(t, c),
|
|
13054
|
+
effective: effectivePriority(t, c),
|
|
13055
|
+
contract: c
|
|
13056
|
+
});
|
|
13057
|
+
}
|
|
13058
|
+
} catch (e) {
|
|
13059
|
+
errors.push({ route: route.route, error: e.message });
|
|
13170
13060
|
}
|
|
13171
13061
|
}));
|
|
13172
|
-
|
|
13173
|
-
|
|
13174
|
-
|
|
13175
|
-
|
|
13062
|
+
items.sort((a, b) => a.rank - b.rank);
|
|
13063
|
+
const byName = resolved.filter((r) => r.by === "name" && r.names.length);
|
|
13064
|
+
const distinct = new Set(byName.flatMap((r) => r.names));
|
|
13065
|
+
const ambiguous = byName.length > 1 && distinct.size > 1;
|
|
13066
|
+
return { items, errors, resolved, ambiguous };
|
|
13176
13067
|
}
|
|
13177
|
-
|
|
13178
|
-
|
|
13068
|
+
function renderInbox(items, byConnection = false, subject) {
|
|
13069
|
+
if (items.length === 0) return `nothing assigned to ${subject ?? "you"}
|
|
13070
|
+
`;
|
|
13071
|
+
const key = (i) => byConnection ? i.route : i.ticket.status;
|
|
13072
|
+
const groups = /* @__PURE__ */ new Map();
|
|
13073
|
+
for (const i of items) {
|
|
13074
|
+
const g = groups.get(key(i));
|
|
13075
|
+
if (g) g.push(i);
|
|
13076
|
+
else groups.set(key(i), [i]);
|
|
13077
|
+
}
|
|
13078
|
+
const out = [];
|
|
13079
|
+
for (const [name, group] of groups) {
|
|
13080
|
+
out.push(`${name} (${group.length})`);
|
|
13081
|
+
for (const i of group) {
|
|
13082
|
+
const t = i.ticket;
|
|
13083
|
+
const where = byConnection ? t.status : i.route;
|
|
13084
|
+
out.push(
|
|
13085
|
+
` ${displayKey(t).padEnd(10)} p${i.effective} ${(t.severity ?? "--").padEnd(3)} ${where.padEnd(14)} ${(t.title ?? "").slice(0, 62)}`
|
|
13086
|
+
);
|
|
13087
|
+
}
|
|
13088
|
+
out.push("");
|
|
13089
|
+
}
|
|
13090
|
+
return out.join("\n");
|
|
13179
13091
|
}
|
|
13180
|
-
|
|
13181
|
-
|
|
13182
|
-
|
|
13183
|
-
|
|
13184
|
-
|
|
13092
|
+
|
|
13093
|
+
// src/fleet.ts
|
|
13094
|
+
function agedConnectionRank(rawRank, role, route, decision, state, now) {
|
|
13095
|
+
if (role !== "dev" && role !== "design") return rawRank;
|
|
13096
|
+
const waiting = state.fairness(route.route).waiting();
|
|
13097
|
+
if (!waiting || waiting.ticket !== decision.actionable.top?.issue_id) return rawRank;
|
|
13098
|
+
return agedRankScalar(rawRank, { since: waiting.since, weight: route.weight }, now);
|
|
13185
13099
|
}
|
|
13186
|
-
async function
|
|
13187
|
-
const
|
|
13188
|
-
|
|
13189
|
-
|
|
13100
|
+
async function decideFleet(o) {
|
|
13101
|
+
const now = o.now ?? Date.now();
|
|
13102
|
+
const eligible = o.routes.filter((c) => o.enabledOnly === false ? true : c.enabled);
|
|
13103
|
+
const entries = await Promise.all(eligible.map(async (route) => {
|
|
13104
|
+
try {
|
|
13105
|
+
const decision = await decideCycle({ route, ship: o.ship, state: o.state, emit: o.emit.forRoute(route.route) });
|
|
13106
|
+
const sel = decision.selection;
|
|
13107
|
+
if (!sel.selected) return { route, decision, rank: NOTHING_ACTIONABLE };
|
|
13108
|
+
const rawRank = sel.ranks[sel.selected] ?? NOTHING_ACTIONABLE;
|
|
13109
|
+
return {
|
|
13110
|
+
route,
|
|
13111
|
+
decision,
|
|
13112
|
+
role: sel.selected,
|
|
13113
|
+
rank: agedConnectionRank(rawRank, sel.selected, route, decision, o.state, now)
|
|
13114
|
+
};
|
|
13115
|
+
} catch (e) {
|
|
13116
|
+
return { route, rank: NOTHING_ACTIONABLE, error: e.message };
|
|
13117
|
+
}
|
|
13118
|
+
}));
|
|
13119
|
+
for (const e of entries) {
|
|
13120
|
+
if (e.error) o.emit.forRoute(e.route.route).error(`${e.route.route}: ${e.error}`, { step: "poll" });
|
|
13121
|
+
}
|
|
13122
|
+
const contenders = entries.filter((e) => e.role && e.rank < NOTHING_ACTIONABLE);
|
|
13123
|
+
const best = contenders.reduce(
|
|
13124
|
+
(acc, e) => !acc || e.rank < acc.rank ? e : acc,
|
|
13125
|
+
void 0
|
|
13190
13126
|
);
|
|
13191
|
-
|
|
13127
|
+
for (const e of entries) {
|
|
13128
|
+
if (e.error || !e.decision) continue;
|
|
13129
|
+
const won = best?.route.route === e.route.route;
|
|
13130
|
+
o.state.fairness(e.route.route).record(e.decision.actionable.count, e.decision.actionable.top?.issue_id, won);
|
|
13131
|
+
if (!o.dryRun) o.state.attention(e.route.route).persist(e.decision.attention);
|
|
13132
|
+
}
|
|
13133
|
+
return {
|
|
13134
|
+
entries,
|
|
13135
|
+
winner: best && best.role && best.decision ? { route: best.route, role: best.role, decision: best.decision } : void 0,
|
|
13136
|
+
unreachable: entries.filter((e) => e.error).map((e) => e.route.route)
|
|
13137
|
+
};
|
|
13192
13138
|
}
|
|
13193
|
-
var
|
|
13194
|
-
function
|
|
13195
|
-
const
|
|
13196
|
-
|
|
13139
|
+
var LOUD_STREAK = 5;
|
|
13140
|
+
function since(iso, now = Date.now()) {
|
|
13141
|
+
const ms2 = Math.max(0, now - Date.parse(iso));
|
|
13142
|
+
const mins = Math.round(ms2 / 6e4);
|
|
13143
|
+
if (mins < 60) return `${mins}m`;
|
|
13144
|
+
const hours = Math.round(mins / 60);
|
|
13145
|
+
if (hours < 48) return `${hours}h`;
|
|
13146
|
+
return `${Math.round(hours / 24)}d`;
|
|
13197
13147
|
}
|
|
13198
|
-
|
|
13199
|
-
|
|
13200
|
-
|
|
13201
|
-
|
|
13148
|
+
function renderFleet(f, state) {
|
|
13149
|
+
const rows = f.entries.map((e) => {
|
|
13150
|
+
if (e.error) return ` ${e.route.route.padEnd(16)} unreachable \u2014 ${e.error}`;
|
|
13151
|
+
if (!e.role) return ` ${e.route.route.padEnd(16)} nothing pending`;
|
|
13152
|
+
const pending = e.decision?.selection.pending.join(" ") ?? "";
|
|
13153
|
+
const win = f.winner?.route.route === e.route.route ? " <- runs this cycle" : "";
|
|
13154
|
+
const streak = state?.fairness(e.route.route).streak() ?? 0;
|
|
13155
|
+
const passedOver = !win && streak >= LOUD_STREAK ? ` ! passed over ${streak} cycles running` : "";
|
|
13156
|
+
return ` ${e.route.route.padEnd(16)} ${e.role.padEnd(7)} rank ${String(e.rank).padStart(12)} pending: ${pending}${win}${passedOver}`;
|
|
13157
|
+
});
|
|
13158
|
+
if (!rows.length) return "no enabled routes\n";
|
|
13159
|
+
return `${rows.join("\n")}
|
|
13160
|
+
`;
|
|
13202
13161
|
}
|
|
13203
|
-
|
|
13204
|
-
|
|
13205
|
-
|
|
13206
|
-
const ws = await get(o, `/workspaces/${encodeURIComponent(o.workspace).replace(/%3A/gi, ":")}`);
|
|
13207
|
-
const out = {
|
|
13208
|
-
workspaceId: ws.id,
|
|
13209
|
-
workspaceSlug: ws.path ?? ws.slug,
|
|
13210
|
-
workspaceName: ws.name,
|
|
13211
|
-
models: {},
|
|
13212
|
-
seats: {},
|
|
13213
|
-
holds: [],
|
|
13214
|
-
candidates: [],
|
|
13215
|
-
problems,
|
|
13216
|
-
provisioning
|
|
13162
|
+
function snapshot(tickets) {
|
|
13163
|
+
return {
|
|
13164
|
+
state: new Map(tickets.map((t) => [t.id, `${t.status}|${t.assignee_id ?? ""}|${t.updated_at}`]))
|
|
13217
13165
|
};
|
|
13166
|
+
}
|
|
13167
|
+
function changed(before, after) {
|
|
13168
|
+
if (before.state.size !== after.state.size) return true;
|
|
13169
|
+
for (const [id, v] of after.state) if (before.state.get(id) !== v) return true;
|
|
13170
|
+
return false;
|
|
13171
|
+
}
|
|
13172
|
+
function nextRoles(decision, ran) {
|
|
13173
|
+
return decision.selection.pending.filter((r) => !ran.includes(r)).sort((a, b) => (decision.selection.ranks[a] ?? 0) - (decision.selection.ranks[b] ?? 0));
|
|
13174
|
+
}
|
|
13175
|
+
|
|
13176
|
+
// src/ship-name.ts
|
|
13177
|
+
import { existsSync as existsSync25, mkdirSync as mkdirSync16, readFileSync as readFileSync22, renameSync as renameSync2, writeFileSync as writeFileSync18 } from "fs";
|
|
13178
|
+
import { dirname as dirname14 } from "path";
|
|
13179
|
+
import { parseDocument } from "yaml";
|
|
13180
|
+
|
|
13181
|
+
// src/machine-id.ts
|
|
13182
|
+
import { execFileSync as execFileSync4 } from "child_process";
|
|
13183
|
+
import { createHash as createHash4 } from "crypto";
|
|
13184
|
+
import { existsSync as existsSync24, mkdirSync as mkdirSync15, readFileSync as readFileSync21, writeFileSync as writeFileSync17 } from "fs";
|
|
13185
|
+
import { dirname as dirname13, join as join27 } from "path";
|
|
13186
|
+
function machineIdPathFor(stateDir) {
|
|
13187
|
+
return join27(stateDir, "machine-id");
|
|
13188
|
+
}
|
|
13189
|
+
var sha256 = (s) => createHash4("sha256").update(s).digest("hex");
|
|
13190
|
+
function readRawMachineId(platform2 = process.platform) {
|
|
13218
13191
|
try {
|
|
13219
|
-
|
|
13220
|
-
|
|
13221
|
-
|
|
13222
|
-
|
|
13223
|
-
|
|
13224
|
-
|
|
13192
|
+
if (platform2 === "darwin") {
|
|
13193
|
+
const out = execFileSync4("ioreg", ["-rd1", "-c", "IOPlatformExpertDevice"], {
|
|
13194
|
+
encoding: "utf8",
|
|
13195
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
13196
|
+
});
|
|
13197
|
+
return /"IOPlatformUUID"\s*=\s*"([^"]+)"/.exec(out)?.[1];
|
|
13198
|
+
}
|
|
13199
|
+
for (const p of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) {
|
|
13200
|
+
if (existsSync24(p)) {
|
|
13201
|
+
const id = readFileSync21(p, "utf8").trim();
|
|
13202
|
+
if (id) return id;
|
|
13203
|
+
}
|
|
13204
|
+
}
|
|
13225
13205
|
} catch {
|
|
13226
13206
|
}
|
|
13227
|
-
|
|
13228
|
-
|
|
13229
|
-
|
|
13230
|
-
|
|
13231
|
-
|
|
13232
|
-
|
|
13233
|
-
|
|
13234
|
-
|
|
13235
|
-
out.projectName = p.name;
|
|
13236
|
-
} catch {
|
|
13237
|
-
const projects = await get(o, `/projects?workspaceId=${ws.id}`);
|
|
13238
|
-
const hit = projects.find((p) => eq(p.name, o.project));
|
|
13239
|
-
if (hit) {
|
|
13240
|
-
out.projectId = hit.id;
|
|
13241
|
-
out.projectSlug = hit.slug;
|
|
13242
|
-
out.projectName = hit.name;
|
|
13243
|
-
} else problems.push(`no project named or slugged "${o.project}" \u2014 have: ${projects.map((p) => p.name).join(", ") || "(none)"}`);
|
|
13244
|
-
}
|
|
13245
|
-
} else {
|
|
13246
|
-
const projects = await get(o, `/projects?workspaceId=${ws.id}`);
|
|
13247
|
-
const issuesKeys = await resolveIssuesTemplateKeys(o);
|
|
13248
|
-
const defaultCandidate = projects.find((p) => !!p.sourceTemplateId && issuesKeys.has(p.sourceTemplateId)) ?? projects.find((p) => eq(p.slug ?? "", "issues"));
|
|
13249
|
-
if (defaultCandidate && (await missingTables(o, defaultCandidate.id)).length === 0) {
|
|
13250
|
-
out.projectId = defaultCandidate.id;
|
|
13251
|
-
out.projectSlug = defaultCandidate.slug;
|
|
13252
|
-
out.projectName = defaultCandidate.name;
|
|
13253
|
-
} else {
|
|
13254
|
-
const options = await Promise.all(
|
|
13255
|
-
projects.map(async (p) => ({ id: p.id, name: p.name, slug: p.slug, missing: await missingTables(o, p.id) }))
|
|
13256
|
-
);
|
|
13257
|
-
const qualifying = options.filter((p) => p.missing.length === 0);
|
|
13258
|
-
if (qualifying.length === 1) {
|
|
13259
|
-
out.projectId = qualifying[0].id;
|
|
13260
|
-
out.projectSlug = qualifying[0].slug;
|
|
13261
|
-
out.projectName = qualifying[0].name;
|
|
13262
|
-
} else if (qualifying.length > 1) {
|
|
13263
|
-
out.projectOptions = qualifying;
|
|
13264
|
-
problems.push(
|
|
13265
|
-
`${qualifying.length} projects in this workspace all have the tables a route needs \u2014 name one: ${qualifying.map((p) => p.slug ?? p.name).join(", ")}`
|
|
13266
|
-
);
|
|
13267
|
-
} else {
|
|
13268
|
-
const isAdmin = out.role === "WORKSPACE_ADMIN" || out.role === "PLATFORM_ADMIN";
|
|
13269
|
-
out.offerTemplateInstall = isAdmin;
|
|
13270
|
-
problems.push(
|
|
13271
|
-
`no project in this workspace has the tables a route needs (${REQUIRED_TABLES.join(", ")}) \u2014 ` + (isAdmin ? "install an Issues-tracker template for this workspace (Library, in the app), then rerun." : "ask a workspace admin to set one up, then rerun.")
|
|
13272
|
-
);
|
|
13273
|
-
}
|
|
13274
|
-
}
|
|
13275
|
-
}
|
|
13276
|
-
if (!out.projectId) return out;
|
|
13277
|
-
const models = await get(o, `/data-models?projectId=${out.projectId}`);
|
|
13278
|
-
const find = (name) => models.find((m) => eq(m.name, name))?.id;
|
|
13279
|
-
for (const [key, name] of [["issues", "Issues"], ["comments", "Comments"], ["crew", "Crew"]]) {
|
|
13280
|
-
const id = find(name);
|
|
13281
|
-
if (id) out.models[key] = id;
|
|
13282
|
-
else problems.push(`no "${name}" table \u2014 have: ${models.map((m) => m.name).join(", ")}`);
|
|
13207
|
+
return void 0;
|
|
13208
|
+
}
|
|
13209
|
+
function ensureMachineId(stateDir, opts = {}) {
|
|
13210
|
+
if (opts.override?.trim()) return sha256(opts.override.trim());
|
|
13211
|
+
const path = machineIdPathFor(stateDir);
|
|
13212
|
+
if (existsSync24(path)) {
|
|
13213
|
+
const cached = readFileSync21(path, "utf8").trim();
|
|
13214
|
+
if (/^[0-9a-f]{64}$/.test(cached)) return cached;
|
|
13283
13215
|
}
|
|
13284
|
-
|
|
13285
|
-
|
|
13286
|
-
|
|
13287
|
-
|
|
13288
|
-
|
|
13289
|
-
|
|
13290
|
-
|
|
13291
|
-
|
|
13292
|
-
|
|
13293
|
-
|
|
13294
|
-
|
|
13295
|
-
|
|
13216
|
+
const raw = (opts.read ?? readRawMachineId)();
|
|
13217
|
+
if (!raw) return void 0;
|
|
13218
|
+
const hashed = sha256(raw);
|
|
13219
|
+
mkdirSync15(dirname13(path), { recursive: true });
|
|
13220
|
+
writeFileSync17(path, `${hashed}
|
|
13221
|
+
`, { mode: 384 });
|
|
13222
|
+
return hashed;
|
|
13223
|
+
}
|
|
13224
|
+
function matchShipRows(ships, shipName2, machineId) {
|
|
13225
|
+
const hasColumn = ships.some((r) => "machine_id" in r);
|
|
13226
|
+
const byId = hasColumn && machineId ? ships.filter((r) => r.machine_id === machineId) : [];
|
|
13227
|
+
if (byId.length > 0) return { rows: byId, by: "machine_id", hasColumn };
|
|
13228
|
+
return { rows: ships.filter((r) => (r.name ?? "").trim() === shipName2.trim()), by: "name", hasColumn };
|
|
13229
|
+
}
|
|
13230
|
+
|
|
13231
|
+
// src/ship-name.ts
|
|
13232
|
+
var ShipNameError = class extends Error {
|
|
13233
|
+
};
|
|
13234
|
+
function validateShipName(raw) {
|
|
13235
|
+
const name = raw.trim();
|
|
13236
|
+
if (!name) throw new ShipNameError("the ship name cannot be empty");
|
|
13237
|
+
if (/[\u0000-\u001f\u007f]/.test(name)) throw new ShipNameError("the ship name cannot contain control characters");
|
|
13238
|
+
return name;
|
|
13239
|
+
}
|
|
13240
|
+
function planNameSync(local, row, lastSynced) {
|
|
13241
|
+
if (local === row) return { drift: "none", action: "none" };
|
|
13242
|
+
const rowMoved = lastSynced === void 0 || row !== lastSynced;
|
|
13243
|
+
const localMoved = lastSynced === void 0 || local !== lastSynced;
|
|
13244
|
+
if (rowMoved && localMoved) return { drift: "both", action: "row-wins" };
|
|
13245
|
+
if (rowMoved) return { drift: "row", action: "adopt" };
|
|
13246
|
+
return { drift: "local", action: "push" };
|
|
13247
|
+
}
|
|
13248
|
+
function shipNameOfYaml(text) {
|
|
13249
|
+
const v = parseDocument(text).getIn(["ship", "name"]);
|
|
13250
|
+
return typeof v === "string" && v.trim() ? v.trim() : void 0;
|
|
13251
|
+
}
|
|
13252
|
+
function setShipNameInYaml(text, name) {
|
|
13253
|
+
const doc = parseDocument(text);
|
|
13254
|
+
doc.setIn(["ship", "name"], name);
|
|
13255
|
+
return doc.toString();
|
|
13256
|
+
}
|
|
13257
|
+
function pickShipRow(rows, machineId, byName = []) {
|
|
13258
|
+
const hasColumn = rows.some((r) => "machine_id" in r);
|
|
13259
|
+
if (!hasColumn || !machineId) return { kind: "unsupported" };
|
|
13260
|
+
const byId = rows.filter((r) => r.machine_id === machineId);
|
|
13261
|
+
if (byId.length === 1) return { kind: "row", row: byId[0], by: "machine_id" };
|
|
13262
|
+
if (byId.length > 1) return { kind: "ambiguous" };
|
|
13263
|
+
const names = new Set(byName.map((n) => n.trim()));
|
|
13264
|
+
const named = rows.filter((r) => !r.machine_id && names.has((r.name ?? "").trim()));
|
|
13265
|
+
if (named.length === 1) return { kind: "row", row: named[0], by: "name" };
|
|
13266
|
+
return { kind: named.length > 1 ? "ambiguous" : "none" };
|
|
13267
|
+
}
|
|
13268
|
+
async function syncRouteName(i) {
|
|
13269
|
+
const picked = pickShipRow(i.rows, i.machineId);
|
|
13270
|
+
if (picked.kind !== "row") {
|
|
13271
|
+
const status2 = picked.kind === "none" ? "no-row" : picked.kind;
|
|
13272
|
+
return { status: status2, drift: "none", action: "none", name: i.local };
|
|
13296
13273
|
}
|
|
13297
|
-
|
|
13274
|
+
const rowName = (picked.row.name ?? "").trim();
|
|
13275
|
+
if (!rowName) return { status: "no-row", drift: "none", action: "none", name: i.local };
|
|
13276
|
+
const { drift, action } = planNameSync(i.local, rowName, i.lastSynced);
|
|
13277
|
+
const base = { drift, rowId: picked.row.id, rowName };
|
|
13278
|
+
if (action === "push") {
|
|
13279
|
+
if (!i.rename) return { status: "ok", ...base, action, name: i.local };
|
|
13298
13280
|
try {
|
|
13299
|
-
await
|
|
13281
|
+
await i.rename(picked.row.id, i.local);
|
|
13300
13282
|
} catch (e) {
|
|
13301
|
-
|
|
13283
|
+
return { status: "refused", ...base, action: "none", name: i.local, error: e.message };
|
|
13302
13284
|
}
|
|
13285
|
+
return { status: "ok", ...base, action, name: i.local };
|
|
13303
13286
|
}
|
|
13304
|
-
|
|
13305
|
-
|
|
13306
|
-
|
|
13307
|
-
|
|
13308
|
-
|
|
13287
|
+
if (action === "adopt" || action === "row-wins") return { status: "ok", ...base, action, name: rowName, from: i.local };
|
|
13288
|
+
return { status: "ok", ...base, action, name: i.local };
|
|
13289
|
+
}
|
|
13290
|
+
function readSyncedName(resolvedFile) {
|
|
13291
|
+
if (!existsSync25(resolvedFile)) return void 0;
|
|
13309
13292
|
try {
|
|
13310
|
-
const
|
|
13311
|
-
|
|
13312
|
-
if (candidates.length === 1) {
|
|
13313
|
-
out.recordLinkViewId = candidates[0].id;
|
|
13314
|
-
} else if (candidates.length === 0) {
|
|
13315
|
-
problems.push("no record-link-only view bound to Issues \u2014 attention events will carry no link");
|
|
13316
|
-
} else {
|
|
13317
|
-
problems.push(
|
|
13318
|
-
`${candidates.length} record-link-only views bound to Issues \u2014 set resolved.recordLinkViewId by hand`
|
|
13319
|
-
);
|
|
13320
|
-
}
|
|
13293
|
+
const v = JSON.parse(readFileSync22(resolvedFile, "utf8")).shipName;
|
|
13294
|
+
return typeof v === "string" ? v : void 0;
|
|
13321
13295
|
} catch {
|
|
13296
|
+
return void 0;
|
|
13322
13297
|
}
|
|
13323
|
-
|
|
13324
|
-
|
|
13325
|
-
|
|
13326
|
-
|
|
13327
|
-
|
|
13328
|
-
|
|
13329
|
-
|
|
13330
|
-
|
|
13331
|
-
);
|
|
13332
|
-
if (Object.keys(remotes).length > 0) out.repoRemotes = remotes;
|
|
13298
|
+
}
|
|
13299
|
+
function writeSyncedName(resolvedFile, name) {
|
|
13300
|
+
if (!existsSync25(resolvedFile)) return;
|
|
13301
|
+
let raw;
|
|
13302
|
+
try {
|
|
13303
|
+
raw = JSON.parse(readFileSync22(resolvedFile, "utf8"));
|
|
13304
|
+
} catch {
|
|
13305
|
+
return;
|
|
13333
13306
|
}
|
|
13334
|
-
if (
|
|
13335
|
-
|
|
13336
|
-
|
|
13337
|
-
|
|
13338
|
-
|
|
13339
|
-
|
|
13340
|
-
|
|
13341
|
-
|
|
13342
|
-
|
|
13343
|
-
|
|
13307
|
+
if (raw.shipName === name) return;
|
|
13308
|
+
raw.shipName = name;
|
|
13309
|
+
mkdirSync16(dirname14(resolvedFile), { recursive: true });
|
|
13310
|
+
const tmp = `${resolvedFile}.tmp${process.pid}`;
|
|
13311
|
+
writeFileSync18(tmp, `${JSON.stringify(raw, null, 2)}
|
|
13312
|
+
`);
|
|
13313
|
+
renameSync2(tmp, resolvedFile);
|
|
13314
|
+
}
|
|
13315
|
+
async function reconcileShipName(i) {
|
|
13316
|
+
let local = i.ship.name;
|
|
13317
|
+
try {
|
|
13318
|
+
local = shipNameOfYaml(readFileSync22(i.configFile, "utf8")) ?? local;
|
|
13319
|
+
} catch {
|
|
13344
13320
|
}
|
|
13345
|
-
|
|
13346
|
-
|
|
13347
|
-
|
|
13348
|
-
|
|
13349
|
-
|
|
13350
|
-
|
|
13351
|
-
|
|
13352
|
-
|
|
13353
|
-
|
|
13354
|
-
|
|
13355
|
-
|
|
13356
|
-
|
|
13357
|
-
|
|
13358
|
-
|
|
13359
|
-
|
|
13360
|
-
|
|
13361
|
-
|
|
13362
|
-
|
|
13363
|
-
|
|
13364
|
-
|
|
13365
|
-
|
|
13366
|
-
|
|
13367
|
-
|
|
13368
|
-
|
|
13369
|
-
if (
|
|
13370
|
-
|
|
13371
|
-
|
|
13372
|
-
|
|
13373
|
-
|
|
13374
|
-
} else {
|
|
13375
|
-
mine = byName.filter((r) => !r.machine_id);
|
|
13376
|
-
writeMachineId = true;
|
|
13377
|
-
}
|
|
13321
|
+
const resolvedFile = resolvedPathFor(i.stateDir, i.route);
|
|
13322
|
+
const lastSynced = readSyncedName(resolvedFile);
|
|
13323
|
+
let rows = [];
|
|
13324
|
+
try {
|
|
13325
|
+
rows = await i.rows();
|
|
13326
|
+
} catch {
|
|
13327
|
+
}
|
|
13328
|
+
const result = await syncRouteName({
|
|
13329
|
+
local,
|
|
13330
|
+
lastSynced,
|
|
13331
|
+
rows,
|
|
13332
|
+
machineId: i.machineId,
|
|
13333
|
+
rename: i.readOnly ? void 0 : i.rename
|
|
13334
|
+
});
|
|
13335
|
+
if (i.readOnly || result.status === "unsupported") return result;
|
|
13336
|
+
if (result.status === "refused") {
|
|
13337
|
+
i.emit?.warn(`could not rename the Ships row to "${local}": ${result.error}`, { step: "poll", data: { event: "ship_rename_refused", route: i.route, to: local } });
|
|
13338
|
+
return result;
|
|
13339
|
+
}
|
|
13340
|
+
if (result.status !== "ok") return result;
|
|
13341
|
+
if (result.action === "adopt" || result.action === "row-wins") {
|
|
13342
|
+
const text = readFileSync22(i.configFile, "utf8");
|
|
13343
|
+
writeFileSync18(i.configFile, setShipNameInYaml(text, result.name));
|
|
13344
|
+
i.ship.name = result.name;
|
|
13345
|
+
if (result.action === "row-wins") {
|
|
13346
|
+
i.emit?.warn(
|
|
13347
|
+
`ship name changed in both crew.yaml ("${result.from}") and the Ships table ("${result.name}"); the Ships row wins`,
|
|
13348
|
+
{ step: "poll", data: { event: "ship_renamed", route: i.route, from: result.from, to: result.name, source: "table", conflict: true } }
|
|
13349
|
+
);
|
|
13378
13350
|
} else {
|
|
13379
|
-
|
|
13380
|
-
|
|
13381
|
-
|
|
13382
|
-
shipRowId = mine[0].id;
|
|
13383
|
-
out.shipRow = { id: shipRowId, name: o.ship.name, action: "matched", matchedBy };
|
|
13384
|
-
const patchBody = {};
|
|
13385
|
-
if (matchedBy === "machine_id") {
|
|
13386
|
-
const rowName = (mine[0].name ?? "").trim();
|
|
13387
|
-
const route = `${out.workspaceSlug}/${out.projectSlug ?? out.projectId}`;
|
|
13388
|
-
const plan = planNameSync(o.ship.name.trim(), rowName, o.ship.lastSyncedName?.(route));
|
|
13389
|
-
if (plan.action === "push") {
|
|
13390
|
-
patchBody.name = o.ship.name.trim();
|
|
13391
|
-
provisioning.push(`renamed Ships row "${rowName}" to "${o.ship.name.trim()}" (crew.yaml changed)`);
|
|
13392
|
-
} else if (plan.action === "adopt" || plan.action === "row-wins") {
|
|
13393
|
-
out.shipRow.name = rowName;
|
|
13394
|
-
out.shipNameAdopted = rowName;
|
|
13395
|
-
provisioning.push(`matched Ships row "${rowName}" by machine id \u2014 this host's name becomes "${rowName}"${plan.action === "row-wins" ? " (crew.yaml also changed; the Ships row wins)" : ""}`);
|
|
13396
|
-
}
|
|
13397
|
-
patchBody.last_seen = (/* @__PURE__ */ new Date()).toISOString();
|
|
13398
|
-
}
|
|
13399
|
-
if (writeMachineId) patchBody.machine_id = machineId;
|
|
13400
|
-
if ((mine[0].host_passengers ?? false) !== hostPassengers) patchBody.host_passengers = hostPassengers;
|
|
13401
|
-
if (sshPublicKey !== void 0 && (mine[0].ssh_public_key ?? "") !== sshPublicKey) {
|
|
13402
|
-
patchBody.ssh_public_key = sshPublicKey;
|
|
13403
|
-
}
|
|
13404
|
-
if (ownerId !== void 0 && (mine[0].owner_id ?? "") !== ownerId) patchBody.owner_id = ownerId;
|
|
13405
|
-
if (Object.keys(patchBody).length > 0) {
|
|
13406
|
-
await patch(o, `/data-models/${out.shipsModelId}/records/${shipRowId}`, patchBody);
|
|
13407
|
-
if ("host_passengers" in patchBody) provisioning.push(`synced host_passengers=${hostPassengers} onto Ships row "${o.ship.name}"`);
|
|
13408
|
-
if ("ssh_public_key" in patchBody) provisioning.push(`synced ssh_public_key onto Ships row "${o.ship.name}"`);
|
|
13409
|
-
if ("owner_id" in patchBody) provisioning.push(`recorded owner on Ships row "${o.ship.name}"`);
|
|
13410
|
-
}
|
|
13411
|
-
} else if (mine.length === 0) {
|
|
13412
|
-
const created = await post2(o, `/data-models/${out.shipsModelId}/records`, {
|
|
13413
|
-
name: o.ship.name,
|
|
13414
|
-
...o.ship.platform ? { platform: o.ship.platform } : {},
|
|
13415
|
-
host_passengers: hostPassengers,
|
|
13416
|
-
...sshPublicKey !== void 0 ? { ssh_public_key: sshPublicKey } : {},
|
|
13417
|
-
...ownerId !== void 0 ? { owner_id: ownerId } : {},
|
|
13418
|
-
...writeMachineId ? { machine_id: machineId } : {}
|
|
13351
|
+
i.emit?.emit(`ship renamed to "${result.name}" in the Ships table; crew.yaml updated`, {
|
|
13352
|
+
step: "poll",
|
|
13353
|
+
data: { event: "ship_renamed", route: i.route, from: result.from, to: result.name, source: "table" }
|
|
13419
13354
|
});
|
|
13420
|
-
shipRowId = created.id;
|
|
13421
|
-
out.shipRow = { id: shipRowId, name: o.ship.name, action: "created", matchedBy };
|
|
13422
|
-
provisioning.push(`created Ships row "${o.ship.name}"`);
|
|
13423
|
-
} else {
|
|
13424
|
-
problems.push(
|
|
13425
|
-
matchedBy === "machine_id" ? `${mine.length} Ships rows carry this machine's id \u2014 which one is this machine?` : `${mine.length} Ships rows named "${o.ship.name}" \u2014 which one is this machine?`
|
|
13426
|
-
);
|
|
13427
13355
|
}
|
|
13356
|
+
} else if (result.action === "push") {
|
|
13357
|
+
i.emit?.emit(`ship name "${result.name}" pushed to the Ships table`, {
|
|
13358
|
+
step: "poll",
|
|
13359
|
+
data: { event: "ship_renamed", route: i.route, from: result.rowName, to: result.name, source: "crew.yaml" }
|
|
13360
|
+
});
|
|
13428
13361
|
}
|
|
13429
|
-
|
|
13430
|
-
|
|
13362
|
+
writeSyncedName(resolvedFile, result.name);
|
|
13363
|
+
return result;
|
|
13364
|
+
}
|
|
13365
|
+
async function reconcileRouteShipName(o) {
|
|
13366
|
+
const fallback = { status: "unsupported", drift: "none", action: "none", name: o.ship.name };
|
|
13367
|
+
if (!o.route.resolved?.shipsModelId) return fallback;
|
|
13368
|
+
try {
|
|
13369
|
+
const machineId = o.machineId ?? ensureMachineId(o.ship.stateDir);
|
|
13370
|
+
const tracker = new Tracker(o.route, o.ship);
|
|
13371
|
+
return await reconcileShipName({
|
|
13372
|
+
route: o.route.route,
|
|
13373
|
+
configFile: o.configFile,
|
|
13374
|
+
stateDir: o.ship.stateDir,
|
|
13375
|
+
ship: o.ship,
|
|
13376
|
+
machineId,
|
|
13377
|
+
rows: () => tracker.shipRows(),
|
|
13378
|
+
rename: (id, name) => tracker.renameShipRow(id, name),
|
|
13379
|
+
emit: o.emit,
|
|
13380
|
+
readOnly: o.readOnly
|
|
13381
|
+
});
|
|
13382
|
+
} catch {
|
|
13383
|
+
return fallback;
|
|
13384
|
+
}
|
|
13385
|
+
}
|
|
13386
|
+
|
|
13387
|
+
// src/connect.ts
|
|
13388
|
+
var ConnectHttpError = class extends Error {
|
|
13389
|
+
status;
|
|
13390
|
+
path;
|
|
13391
|
+
constructor(status2, path, statusText) {
|
|
13392
|
+
super(`${path}: ${status2} ${statusText}`);
|
|
13393
|
+
this.name = "ConnectHttpError";
|
|
13394
|
+
this.status = status2;
|
|
13395
|
+
this.path = path;
|
|
13396
|
+
}
|
|
13397
|
+
};
|
|
13398
|
+
async function get(o, path) {
|
|
13399
|
+
const res = await fetch(`${o.baseUrl.replace(/\/+$/, "")}/api${path}`, {
|
|
13400
|
+
headers: {
|
|
13401
|
+
Authorization: `Bearer ${o.apiKey}`,
|
|
13402
|
+
// Cloudflare 403s default agents on this host.
|
|
13403
|
+
"User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0"
|
|
13404
|
+
}
|
|
13405
|
+
});
|
|
13406
|
+
if (!res.ok) throw new ConnectHttpError(res.status, path, res.statusText);
|
|
13407
|
+
return await res.json();
|
|
13408
|
+
}
|
|
13409
|
+
async function post2(o, path, body) {
|
|
13410
|
+
const res = await fetch(`${o.baseUrl.replace(/\/+$/, "")}/api${path}`, {
|
|
13411
|
+
method: "POST",
|
|
13412
|
+
headers: {
|
|
13413
|
+
Authorization: `Bearer ${o.apiKey}`,
|
|
13414
|
+
"User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0",
|
|
13415
|
+
"Content-Type": "application/json"
|
|
13416
|
+
},
|
|
13417
|
+
body: JSON.stringify(body)
|
|
13418
|
+
});
|
|
13419
|
+
if (!res.ok) throw new ConnectHttpError(res.status, path, res.statusText);
|
|
13420
|
+
return await res.json();
|
|
13421
|
+
}
|
|
13422
|
+
async function patch(o, path, body) {
|
|
13423
|
+
const res = await fetch(`${o.baseUrl.replace(/\/+$/, "")}/api${path}`, {
|
|
13424
|
+
method: "PATCH",
|
|
13425
|
+
headers: {
|
|
13426
|
+
Authorization: `Bearer ${o.apiKey}`,
|
|
13427
|
+
"User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0",
|
|
13428
|
+
"Content-Type": "application/json"
|
|
13429
|
+
},
|
|
13430
|
+
body: JSON.stringify(body)
|
|
13431
|
+
});
|
|
13432
|
+
if (!res.ok) throw new ConnectHttpError(res.status, path, res.statusText);
|
|
13433
|
+
return await res.json();
|
|
13434
|
+
}
|
|
13435
|
+
async function del(o, path) {
|
|
13436
|
+
const res = await fetch(`${o.baseUrl.replace(/\/+$/, "")}/api${path}`, {
|
|
13437
|
+
method: "DELETE",
|
|
13438
|
+
headers: {
|
|
13439
|
+
Authorization: `Bearer ${o.apiKey}`,
|
|
13440
|
+
"User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0"
|
|
13441
|
+
}
|
|
13442
|
+
});
|
|
13443
|
+
if (!res.ok) throw new ConnectHttpError(res.status, path, res.statusText);
|
|
13444
|
+
}
|
|
13445
|
+
async function provisionReleaseLocks(o, locksModelId, route, repoNames) {
|
|
13446
|
+
const scopes = [...new Set(repoNames)].sort().map((r) => releaseLockScope(route, r));
|
|
13447
|
+
if (scopes.length === 0) return [];
|
|
13448
|
+
const client2 = {
|
|
13449
|
+
// The Locks table is a handful of rows, so read it whole and match in
|
|
13450
|
+
// memory rather than depend on the HTTP filter syntax.
|
|
13451
|
+
list: async (model, params) => {
|
|
13452
|
+
const want = JSON.parse(params?.filters ?? "[]")[0]?.value;
|
|
13453
|
+
const rows = await get(
|
|
13454
|
+
o,
|
|
13455
|
+
`/data-models/${model}/records?limit=500`
|
|
13456
|
+
);
|
|
13457
|
+
return rows.filter((r) => r.scope === want).slice(0, params?.limit ?? rows.length);
|
|
13458
|
+
},
|
|
13459
|
+
create: (model, body) => post2(o, `/data-models/${model}/records`, body),
|
|
13460
|
+
remove: (model, id) => del(o, `/data-models/${model}/records/${id}`)
|
|
13461
|
+
};
|
|
13462
|
+
return ensureLockRows(client2, locksModelId, scopes);
|
|
13463
|
+
}
|
|
13464
|
+
var ISSUES_TEMPLATE_IDENTIFIERS = ["tablation.issues", "crew.issues"];
|
|
13465
|
+
async function resolveIssuesTemplateKeys(o) {
|
|
13466
|
+
const keys = new Set(ISSUES_TEMPLATE_IDENTIFIERS);
|
|
13467
|
+
await Promise.all(ISSUES_TEMPLATE_IDENTIFIERS.map(async (identifier) => {
|
|
13431
13468
|
try {
|
|
13432
|
-
const
|
|
13433
|
-
const agentRows = await get(
|
|
13469
|
+
const t = await get(
|
|
13434
13470
|
o,
|
|
13435
|
-
`/
|
|
13471
|
+
`/library-templates/by-identifier/${encodeURIComponent(identifier)}`
|
|
13436
13472
|
);
|
|
13437
|
-
|
|
13473
|
+
if (t.id) keys.add(t.id);
|
|
13474
|
+
if (t.identifier) keys.add(t.identifier);
|
|
13438
13475
|
} catch {
|
|
13439
13476
|
}
|
|
13440
|
-
}
|
|
13441
|
-
|
|
13442
|
-
|
|
13443
|
-
|
|
13444
|
-
|
|
13445
|
-
|
|
13446
|
-
|
|
13447
|
-
|
|
13448
|
-
|
|
13449
|
-
|
|
13450
|
-
|
|
13451
|
-
|
|
13452
|
-
|
|
13453
|
-
|
|
13454
|
-
|
|
13455
|
-
|
|
13456
|
-
|
|
13457
|
-
|
|
13458
|
-
|
|
13459
|
-
|
|
13460
|
-
|
|
13461
|
-
|
|
13462
|
-
|
|
13463
|
-
|
|
13464
|
-
|
|
13465
|
-
|
|
13466
|
-
|
|
13467
|
-
|
|
13468
|
-
|
|
13469
|
-
|
|
13470
|
-
|
|
13471
|
-
|
|
13472
|
-
|
|
13473
|
-
|
|
13474
|
-
|
|
13475
|
-
|
|
13476
|
-
|
|
13477
|
-
|
|
13478
|
-
|
|
13479
|
-
|
|
13480
|
-
|
|
13481
|
-
|
|
13482
|
-
|
|
13483
|
-
|
|
13484
|
-
|
|
13485
|
-
|
|
13486
|
-
|
|
13487
|
-
|
|
13488
|
-
|
|
13489
|
-
|
|
13490
|
-
|
|
13491
|
-
|
|
13492
|
-
|
|
13493
|
-
|
|
13494
|
-
|
|
13495
|
-
|
|
13496
|
-
|
|
13497
|
-
const pairId = seatFor(scoped, "pair") || (seatShipId ? seatFor(unclaimed, "pair") : void 0);
|
|
13498
|
-
if (pairId) out.seats.pair = pairId;
|
|
13499
|
-
if (shipRowId) {
|
|
13500
|
-
const releaseId = seatFor(scoped, "release");
|
|
13501
|
-
if (releaseId) out.releaseSeat = releaseId;
|
|
13502
|
-
else {
|
|
13503
|
-
const created = await post2(o, `/data-models/${out.models.crew}/records`, {
|
|
13504
|
-
name: "Release agent",
|
|
13505
|
-
ship_id: shipRowId
|
|
13506
|
-
});
|
|
13507
|
-
out.releaseSeat = created.id;
|
|
13508
|
-
provisioning.push("created Crew row for the release seat");
|
|
13509
|
-
}
|
|
13510
|
-
}
|
|
13511
|
-
const seatIds = /* @__PURE__ */ new Set([...Object.values(out.seats), ...out.releaseSeat ? [out.releaseSeat] : []]);
|
|
13512
|
-
const holdRows = rows.filter((r) => !seatIds.has(r.id));
|
|
13513
|
-
out.holds = holdRows.map((r) => ({ id: r.id, name: r.name ?? "", email: r.email, user_id: r.user_id }));
|
|
13514
|
-
out.crewModelId = out.models.crew;
|
|
13515
|
-
const people = holdRows.filter((r) => !r.agent_id && (!r.ship_id || !!r.user_id));
|
|
13516
|
-
const members = /* @__PURE__ */ new Map();
|
|
13477
|
+
}));
|
|
13478
|
+
return keys;
|
|
13479
|
+
}
|
|
13480
|
+
async function listLibraryTemplates(o) {
|
|
13481
|
+
return get(o, "/library-templates");
|
|
13482
|
+
}
|
|
13483
|
+
async function previewTemplateInstall(o, templateId, workspaceId) {
|
|
13484
|
+
return get(o, `/library-templates/${templateId}/install-preview?workspaceId=${workspaceId}`);
|
|
13485
|
+
}
|
|
13486
|
+
async function installTemplate(o, templateId, workspaceId, projectName) {
|
|
13487
|
+
return post2(o, `/library-templates/${templateId}/install`, {
|
|
13488
|
+
workspaceId,
|
|
13489
|
+
...projectName ? { projectName } : {}
|
|
13490
|
+
});
|
|
13491
|
+
}
|
|
13492
|
+
async function listWorkspaces(o) {
|
|
13493
|
+
const res = await get(
|
|
13494
|
+
o,
|
|
13495
|
+
"/auth/my-workspaces"
|
|
13496
|
+
);
|
|
13497
|
+
return res.workspaces.map((w) => ({ slug: w.path ?? w.slug, name: w.name, role: w.role }));
|
|
13498
|
+
}
|
|
13499
|
+
var eq = (a, b) => (a ?? "").trim().toLowerCase() === b.trim().toLowerCase();
|
|
13500
|
+
function seatFor(rows, role) {
|
|
13501
|
+
const r = rows.find((x) => (x.name ?? "").toLowerCase().includes(role.toLowerCase()));
|
|
13502
|
+
return r?.id;
|
|
13503
|
+
}
|
|
13504
|
+
var REQUIRED_TABLES = ["Issues"];
|
|
13505
|
+
async function missingTables(o, projectId) {
|
|
13506
|
+
const models = await get(o, `/data-models?projectId=${projectId}`);
|
|
13507
|
+
return REQUIRED_TABLES.filter((t) => !models.some((m) => eq(m.name, t)));
|
|
13508
|
+
}
|
|
13509
|
+
async function discover(o) {
|
|
13510
|
+
const problems = [];
|
|
13511
|
+
const provisioning = [];
|
|
13512
|
+
const ws = await get(o, `/workspaces/${encodeURIComponent(o.workspace).replace(/%3A/gi, ":")}`);
|
|
13513
|
+
const out = {
|
|
13514
|
+
workspaceId: ws.id,
|
|
13515
|
+
workspaceSlug: ws.path ?? ws.slug,
|
|
13516
|
+
workspaceName: ws.name,
|
|
13517
|
+
models: {},
|
|
13518
|
+
seats: {},
|
|
13519
|
+
holds: [],
|
|
13520
|
+
candidates: [],
|
|
13521
|
+
problems,
|
|
13522
|
+
provisioning
|
|
13523
|
+
};
|
|
13524
|
+
try {
|
|
13525
|
+
const me = await get(o, `/auth/me?workspaceId=${ws.id}`);
|
|
13526
|
+
out.role = me.role;
|
|
13527
|
+
out.meEmail = me.email;
|
|
13528
|
+
out.meId = me.id;
|
|
13529
|
+
out.meIdentityId = me.identityId;
|
|
13530
|
+
out.meName = me.name;
|
|
13531
|
+
} catch {
|
|
13532
|
+
}
|
|
13533
|
+
if (o.project) {
|
|
13517
13534
|
try {
|
|
13518
|
-
const
|
|
13535
|
+
const p = await get(
|
|
13519
13536
|
o,
|
|
13520
|
-
`/
|
|
13537
|
+
`/projects/${encodeURIComponent(o.project)}?workspaceId=${ws.id}`
|
|
13521
13538
|
);
|
|
13522
|
-
|
|
13523
|
-
|
|
13524
|
-
|
|
13525
|
-
}
|
|
13539
|
+
out.projectId = p.id;
|
|
13540
|
+
out.projectSlug = p.slug;
|
|
13541
|
+
out.projectName = p.name;
|
|
13526
13542
|
} catch {
|
|
13543
|
+
const projects = await get(o, `/projects?workspaceId=${ws.id}`);
|
|
13544
|
+
const hit = projects.find((p) => eq(p.name, o.project));
|
|
13545
|
+
if (hit) {
|
|
13546
|
+
out.projectId = hit.id;
|
|
13547
|
+
out.projectSlug = hit.slug;
|
|
13548
|
+
out.projectName = hit.name;
|
|
13549
|
+
} else problems.push(`no project named or slugged "${o.project}" \u2014 have: ${projects.map((p) => p.name).join(", ") || "(none)"}`);
|
|
13527
13550
|
}
|
|
13528
|
-
|
|
13529
|
-
|
|
13530
|
-
|
|
13531
|
-
|
|
13532
|
-
|
|
13533
|
-
|
|
13534
|
-
|
|
13535
|
-
|
|
13536
|
-
user_id: r.user_id,
|
|
13537
|
-
detail: r.email || (r.user_id ? members.get(r.user_id)?.email : void 0) || void 0
|
|
13538
|
-
}));
|
|
13539
|
-
const isMine = (r) => !!r.user_id && (r.user_id === out.meId || !!out.meIdentityId && members.get(r.user_id)?.identityId === out.meIdentityId);
|
|
13540
|
-
const emailOf = (r) => [r.email, r.user_id ? members.get(r.user_id)?.email : void 0].filter((e) => !!e);
|
|
13541
|
-
if (!out.meId) {
|
|
13542
|
-
if (out.candidates.length === 0) problems.push("no non-seat Crew rows \u2014 which row is the operator?");
|
|
13543
|
-
else {
|
|
13544
|
-
const byEmail = out.meEmail ? people.filter((r) => emailOf(r).some((e) => eq(e, out.meEmail))) : [];
|
|
13545
|
-
if (byEmail.length === 1) out.operator = byEmail[0].id;
|
|
13546
|
-
else if (out.candidates.length === 1) out.operator = out.candidates[0].id;
|
|
13547
|
-
}
|
|
13551
|
+
} else {
|
|
13552
|
+
const projects = await get(o, `/projects?workspaceId=${ws.id}`);
|
|
13553
|
+
const issuesKeys = await resolveIssuesTemplateKeys(o);
|
|
13554
|
+
const defaultCandidate = projects.find((p) => !!p.sourceTemplateId && issuesKeys.has(p.sourceTemplateId)) ?? projects.find((p) => eq(p.slug ?? "", "issues"));
|
|
13555
|
+
if (defaultCandidate && (await missingTables(o, defaultCandidate.id)).length === 0) {
|
|
13556
|
+
out.projectId = defaultCandidate.id;
|
|
13557
|
+
out.projectSlug = defaultCandidate.slug;
|
|
13558
|
+
out.projectName = defaultCandidate.name;
|
|
13548
13559
|
} else {
|
|
13549
|
-
const
|
|
13550
|
-
|
|
13551
|
-
|
|
13552
|
-
const
|
|
13553
|
-
|
|
13554
|
-
|
|
13555
|
-
|
|
13556
|
-
out.
|
|
13557
|
-
|
|
13558
|
-
|
|
13560
|
+
const options = await Promise.all(
|
|
13561
|
+
projects.map(async (p) => ({ id: p.id, name: p.name, slug: p.slug, missing: await missingTables(o, p.id) }))
|
|
13562
|
+
);
|
|
13563
|
+
const qualifying = options.filter((p) => p.missing.length === 0);
|
|
13564
|
+
if (qualifying.length === 1) {
|
|
13565
|
+
out.projectId = qualifying[0].id;
|
|
13566
|
+
out.projectSlug = qualifying[0].slug;
|
|
13567
|
+
out.projectName = qualifying[0].name;
|
|
13568
|
+
} else if (qualifying.length > 1) {
|
|
13569
|
+
out.projectOptions = qualifying;
|
|
13570
|
+
problems.push(
|
|
13571
|
+
`${qualifying.length} projects in this workspace all have the tables a route needs \u2014 name one: ${qualifying.map((p) => p.slug ?? p.name).join(", ")}`
|
|
13572
|
+
);
|
|
13559
13573
|
} else {
|
|
13560
|
-
|
|
13561
|
-
|
|
13562
|
-
|
|
13563
|
-
|
|
13564
|
-
|
|
13574
|
+
const isAdmin = out.role === "WORKSPACE_ADMIN" || out.role === "PLATFORM_ADMIN";
|
|
13575
|
+
out.offerTemplateInstall = isAdmin;
|
|
13576
|
+
problems.push(
|
|
13577
|
+
`no project in this workspace has the tables a route needs (${REQUIRED_TABLES.join(", ")}) \u2014 ` + (isAdmin ? "install an Issues-tracker template for this workspace (Library, in the app), then rerun." : "ask a workspace admin to set one up, then rerun.")
|
|
13578
|
+
);
|
|
13565
13579
|
}
|
|
13566
13580
|
}
|
|
13567
13581
|
}
|
|
13568
|
-
return out;
|
|
13569
|
-
}
|
|
13570
|
-
|
|
13571
|
-
const name
|
|
13572
|
-
|
|
13573
|
-
|
|
13574
|
-
|
|
13575
|
-
return;
|
|
13576
|
-
}
|
|
13577
|
-
const body = { user_id: found.meId };
|
|
13578
|
-
if (!row.email && found.meEmail) body.email = found.meEmail;
|
|
13579
|
-
try {
|
|
13580
|
-
await patch(o, `/data-models/${found.crewModelId}/records/${row.id}`, body);
|
|
13581
|
-
found.provisioning.push(`linked Crew row "${name}" to your account`);
|
|
13582
|
-
} catch (e) {
|
|
13583
|
-
if (!(e instanceof ConnectHttpError) || e.status !== 403) throw e;
|
|
13584
|
-
found.provisioning.push(
|
|
13585
|
-
`could not link your Crew row (no write access) \u2014 ask a workspace admin to set user_id on "${name}"`
|
|
13586
|
-
);
|
|
13587
|
-
}
|
|
13588
|
-
}
|
|
13589
|
-
async function createOperatorRow(o, found) {
|
|
13590
|
-
if (!found.meId || !found.crewModelId) return;
|
|
13591
|
-
const name = found.meName || found.meEmail || "Operator";
|
|
13592
|
-
if (o.dryRun) {
|
|
13593
|
-
found.provisioning.push(`would create Crew row "${name}" for you`);
|
|
13594
|
-
return;
|
|
13582
|
+
if (!out.projectId) return out;
|
|
13583
|
+
const models = await get(o, `/data-models?projectId=${out.projectId}`);
|
|
13584
|
+
const find = (name) => models.find((m) => eq(m.name, name))?.id;
|
|
13585
|
+
for (const [key, name] of [["issues", "Issues"], ["comments", "Comments"], ["crew", "Crew"]]) {
|
|
13586
|
+
const id = find(name);
|
|
13587
|
+
if (id) out.models[key] = id;
|
|
13588
|
+
else problems.push(`no "${name}" table \u2014 have: ${models.map((m) => m.name).join(", ")}`);
|
|
13595
13589
|
}
|
|
13596
|
-
|
|
13597
|
-
|
|
13598
|
-
|
|
13599
|
-
|
|
13600
|
-
|
|
13601
|
-
|
|
13602
|
-
|
|
13603
|
-
|
|
13604
|
-
|
|
13605
|
-
|
|
13606
|
-
|
|
13607
|
-
|
|
13608
|
-
if (!(e instanceof ConnectHttpError) || e.status !== 403) throw e;
|
|
13609
|
-
found.provisioning.push("could not create your Crew row (no write access) \u2014 ask a workspace admin to add one with your user_id");
|
|
13590
|
+
if (out.models.issues) {
|
|
13591
|
+
try {
|
|
13592
|
+
const issuesModel = await get(o, `/data-models/${out.models.issues}`);
|
|
13593
|
+
const statusField = issuesModel.fields.find((f) => f.columnName === DEFAULT_CONTRACT.columns.status);
|
|
13594
|
+
const known = /* @__PURE__ */ new Set([
|
|
13595
|
+
...DEFAULT_CONTRACT.statuses.open,
|
|
13596
|
+
...DEFAULT_CONTRACT.statuses.resolved,
|
|
13597
|
+
DEFAULT_CONTRACT.statuses.draft
|
|
13598
|
+
]);
|
|
13599
|
+
out.unrecognizedStatuses = (statusField?.fieldType?.choiceOptions ?? []).filter((opt) => !known.has(opt.value)).sort((a, b) => (a.position ?? 0) - (b.position ?? 0)).map((opt) => ({ value: opt.value, label: opt.label ?? void 0, position: opt.position ?? void 0 }));
|
|
13600
|
+
} catch {
|
|
13601
|
+
}
|
|
13610
13602
|
}
|
|
13611
|
-
|
|
13612
|
-
|
|
13613
|
-
|
|
13614
|
-
|
|
13615
|
-
|
|
13616
|
-
|
|
13617
|
-
return ` - route: ${route}
|
|
13618
|
-
enabled: false # arm it deliberately, once doctor is green
|
|
13619
|
-
${opts.area ? ` area: "${opts.area}"
|
|
13620
|
-
` : ""} ${dir === null ? "repos: {} # `crew repos clone` / `crew repos add` fill this in" : `dir: "${dir}"`}
|
|
13621
|
-
# worktreePrefix: omitted \u2014 each repo derives its own from its checkout's
|
|
13622
|
-
# directory name (ISSUE-400). Only set this if every repo this route
|
|
13623
|
-
# serves genuinely wants the SAME prefix, which is rare once an area has
|
|
13624
|
-
# more than one repo.
|
|
13625
|
-
# What a host must be to build this \u2014 unix, macos, linux, or windows \u2014
|
|
13626
|
-
# belongs in each repo's own .crew.yaml, not here (see docs/REPO_SPEC.md).
|
|
13627
|
-
baseUrl: ${opts.baseUrl ? `"${opts.baseUrl}"` : '"REPLACE \u2014 the same baseUrl as your other routes"'}
|
|
13628
|
-
${opts.hostPassengers ? " hostPassengers: true\n" : ""}${apiKeyFileLine}${apiKeyVarLine}
|
|
13629
|
-
`;
|
|
13630
|
-
}
|
|
13631
|
-
|
|
13632
|
-
// src/cli.ts
|
|
13633
|
-
import {
|
|
13634
|
-
getSessionStore as getSessionStore3,
|
|
13635
|
-
hostFromUrl as hostFromUrl4,
|
|
13636
|
-
loginWithDeviceCode,
|
|
13637
|
-
getOrCreateShipId,
|
|
13638
|
-
DeviceLoginError
|
|
13639
|
-
} from "@tablation/client";
|
|
13640
|
-
|
|
13641
|
-
// src/seat-keys.ts
|
|
13642
|
-
import { getSessionStore as getSessionStore2, hostFromUrl as hostFromUrl3 } from "@tablation/client";
|
|
13643
|
-
var SEAT_KEY_ROLES = ROLE_NAMES.filter((r) => r !== "pair");
|
|
13644
|
-
function keyIsAdmin(route) {
|
|
13645
|
-
const role = route.resolved?.role;
|
|
13646
|
-
return role === void 0 || role === "WORKSPACE_ADMIN" || role === "PLATFORM_ADMIN";
|
|
13647
|
-
}
|
|
13648
|
-
var MEMBER_NO_ROW = "has no linked Agents row \u2014 a workspace admin's `crew agents sync` creates the persona rows; run it here afterwards to link this ship's seats";
|
|
13649
|
-
var MEMBER_NO_KEY = 'no agent key for this seat \u2014 run `crew agents sync` (needs the "Mint agent keys" capability on your role)';
|
|
13650
|
-
var FORBIDDEN_LINE = 'seat keys: not minted \u2014 your role does not grant "Mint agent keys"; ask a workspace admin to add it to your project role (or run sync with an admin key)';
|
|
13651
|
-
function seatKeySlug(workspaceSlug, agentId) {
|
|
13652
|
-
return `${workspaceSlug}#agent:${agentId}`;
|
|
13653
|
-
}
|
|
13654
|
-
function slugFor(route, agentId) {
|
|
13655
|
-
const [workspaceSlug] = splitRoute(route.route);
|
|
13656
|
-
return seatKeySlug(workspaceSlug, agentId);
|
|
13657
|
-
}
|
|
13658
|
-
function seatAgentId(route, role) {
|
|
13659
|
-
return route.resolved?.agentPersonas?.[role]?.agentId;
|
|
13660
|
-
}
|
|
13661
|
-
async function resolveReleaseKey(route, getStore = getSessionStore2) {
|
|
13662
|
-
const ship = (reason) => ({ kind: "ship", apiKey: resolveApiKey(route), reason });
|
|
13663
|
-
const agentId = route.resolved?.releaseAgentId;
|
|
13664
|
-
if (!agentId) return ship(keyIsAdmin(route) ? "release seat has no linked Agents row (run `crew agents sync`)" : `release seat ${MEMBER_NO_ROW}`);
|
|
13665
|
-
try {
|
|
13666
|
-
const session = await getStore().get(hostFromUrl3(route.baseUrl), slugFor(route, agentId));
|
|
13667
|
-
if (session?.apiKey) return { kind: "agent", apiKey: session.apiKey, agentId };
|
|
13668
|
-
return ship(keyIsAdmin(route) ? "no agent key stored for the release seat (run `crew agents sync`)" : MEMBER_NO_KEY);
|
|
13669
|
-
} catch {
|
|
13670
|
-
return ship("OS keychain unavailable");
|
|
13603
|
+
if (out.models.issues) {
|
|
13604
|
+
try {
|
|
13605
|
+
await get(o, `/data-models/${out.models.issues}/events?limit=1`);
|
|
13606
|
+
} catch (e) {
|
|
13607
|
+
if (e instanceof ConnectHttpError && e.status === 404) problems.push(EVENT_LOG_MISSING);
|
|
13608
|
+
}
|
|
13671
13609
|
}
|
|
13672
|
-
|
|
13673
|
-
|
|
13674
|
-
|
|
13675
|
-
|
|
13676
|
-
|
|
13677
|
-
if (!agentId) return ship(keyIsAdmin(route) ? "seat has no linked Agents row (run `crew agents sync`)" : `seat ${MEMBER_NO_ROW}`);
|
|
13610
|
+
out.areaModelId = find("Projects");
|
|
13611
|
+
out.reposModelId = find("Repos");
|
|
13612
|
+
out.shipsModelId = find("Ships");
|
|
13613
|
+
out.epicsModelId = find("Epics");
|
|
13614
|
+
out.locksModelId = find("Locks");
|
|
13678
13615
|
try {
|
|
13679
|
-
const
|
|
13680
|
-
|
|
13681
|
-
|
|
13616
|
+
const views = await get(o, `/views?workspaceId=${ws.id}`);
|
|
13617
|
+
const candidates = views.filter((v) => v.recordLinkOnly === true && v.published === true && !!v.publicSlug && (v.pages ?? []).some((p) => (p.components ?? []).some((c) => c.dataModelId === out.models.issues)));
|
|
13618
|
+
if (candidates.length === 1) {
|
|
13619
|
+
out.recordLinkViewId = candidates[0].id;
|
|
13620
|
+
} else if (candidates.length === 0) {
|
|
13621
|
+
problems.push("no record-link-only view bound to Issues \u2014 attention events will carry no link");
|
|
13622
|
+
} else {
|
|
13623
|
+
problems.push(
|
|
13624
|
+
`${candidates.length} record-link-only views bound to Issues \u2014 set resolved.recordLinkViewId by hand`
|
|
13625
|
+
);
|
|
13626
|
+
}
|
|
13682
13627
|
} catch {
|
|
13683
|
-
return ship("OS keychain unavailable");
|
|
13684
13628
|
}
|
|
13685
|
-
|
|
13686
|
-
|
|
13687
|
-
|
|
13688
|
-
|
|
13689
|
-
|
|
13629
|
+
if (out.reposModelId) {
|
|
13630
|
+
const repos = await get(
|
|
13631
|
+
o,
|
|
13632
|
+
`/data-models/${out.reposModelId}/records?limit=200`
|
|
13633
|
+
);
|
|
13634
|
+
out.repoNames = Object.fromEntries(repos.map((r) => [r.id, r.name ?? ""]));
|
|
13635
|
+
const remotes = Object.fromEntries(
|
|
13636
|
+
repos.filter((r) => r.remote).map((r) => [r.id, r.remote])
|
|
13637
|
+
);
|
|
13638
|
+
if (Object.keys(remotes).length > 0) out.repoRemotes = remotes;
|
|
13690
13639
|
}
|
|
13691
|
-
|
|
13692
|
-
|
|
13693
|
-
|
|
13694
|
-
|
|
13695
|
-
|
|
13696
|
-
|
|
13697
|
-
|
|
13698
|
-
|
|
13699
|
-
|
|
13700
|
-
|
|
13701
|
-
];
|
|
13702
|
-
let store;
|
|
13703
|
-
try {
|
|
13704
|
-
store = (o.getStore ?? getSessionStore2)();
|
|
13705
|
-
} catch (e) {
|
|
13706
|
-
return targets.map(({ role }) => ({
|
|
13707
|
-
role,
|
|
13708
|
-
action: "failed",
|
|
13709
|
-
detail: describeStoreError(e)
|
|
13710
|
-
}));
|
|
13640
|
+
if (o.area) {
|
|
13641
|
+
if (!out.areaModelId) problems.push(`--area given but this project has no "Projects" table`);
|
|
13642
|
+
else {
|
|
13643
|
+
const areas = await get(o, `/data-models/${out.areaModelId}/records?limit=200`);
|
|
13644
|
+
const hit = areas.find((a) => eq(a.name, o.area));
|
|
13645
|
+
if (hit) {
|
|
13646
|
+
out.areaId = hit.id;
|
|
13647
|
+
out.areaName = hit.name;
|
|
13648
|
+
} else problems.push(`no area named "${o.area}" \u2014 have: ${areas.map((a) => a.name).join(", ")}`);
|
|
13649
|
+
}
|
|
13711
13650
|
}
|
|
13712
|
-
|
|
13713
|
-
|
|
13714
|
-
|
|
13715
|
-
|
|
13651
|
+
let shipRowId;
|
|
13652
|
+
if (out.shipsModelId && o.ship && !o.dryRun) {
|
|
13653
|
+
const shipRows = await get(
|
|
13654
|
+
o,
|
|
13655
|
+
`/data-models/${out.shipsModelId}/records?limit=200`
|
|
13656
|
+
);
|
|
13657
|
+
const hostPassengers = o.ship.hostPassengers ?? false;
|
|
13658
|
+
const sshPublicKey = o.ship.sshPublicKey;
|
|
13659
|
+
let shipColumns = /* @__PURE__ */ new Set();
|
|
13716
13660
|
try {
|
|
13717
|
-
await
|
|
13718
|
-
|
|
13719
|
-
|
|
13720
|
-
workspaceId: resolved.workspaceId,
|
|
13721
|
-
identityId: "_probe",
|
|
13722
|
-
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
13723
|
-
});
|
|
13724
|
-
await store.clear(host, probeSlug);
|
|
13725
|
-
} catch (e) {
|
|
13726
|
-
const detail = `${describeStoreError(e)}; no keys minted`;
|
|
13727
|
-
return targets.map(({ role, agentId }) => agentId ? { role, action: "failed", detail } : { role, action: "no_agent" });
|
|
13661
|
+
const shipsModel = await get(o, `/data-models/${out.shipsModelId}`);
|
|
13662
|
+
shipColumns = new Set((shipsModel.fields ?? []).map((f) => f.columnName));
|
|
13663
|
+
} catch {
|
|
13728
13664
|
}
|
|
13729
|
-
|
|
13730
|
-
|
|
13731
|
-
|
|
13732
|
-
|
|
13733
|
-
continue;
|
|
13665
|
+
let ownerId;
|
|
13666
|
+
if (out.meId) {
|
|
13667
|
+
if (shipColumns.has("owner_id")) ownerId = out.meId;
|
|
13668
|
+
else provisioning.push("could not record the ship owner \u2014 the Ships table has no owner_id column yet (update the Issues template)");
|
|
13734
13669
|
}
|
|
13735
|
-
const
|
|
13736
|
-
|
|
13737
|
-
|
|
13738
|
-
|
|
13739
|
-
|
|
13740
|
-
|
|
13741
|
-
|
|
13742
|
-
|
|
13743
|
-
|
|
13670
|
+
const machineId = shipColumns.has("machine_id") ? o.ship.machineId : void 0;
|
|
13671
|
+
let mine;
|
|
13672
|
+
let matchedBy = "name";
|
|
13673
|
+
let writeMachineId = false;
|
|
13674
|
+
const byName = shipRows.filter((r) => (r.name ?? "").trim() === o.ship.name.trim());
|
|
13675
|
+
if (machineId) {
|
|
13676
|
+
const byId = shipRows.filter((r) => (r.machine_id ?? "") === machineId);
|
|
13677
|
+
if (byId.length > 0) {
|
|
13678
|
+
mine = byId;
|
|
13679
|
+
matchedBy = "machine_id";
|
|
13680
|
+
} else {
|
|
13681
|
+
mine = byName.filter((r) => !r.machine_id);
|
|
13682
|
+
writeMachineId = true;
|
|
13744
13683
|
}
|
|
13745
|
-
|
|
13746
|
-
|
|
13747
|
-
|
|
13748
|
-
|
|
13749
|
-
|
|
13750
|
-
|
|
13751
|
-
|
|
13752
|
-
|
|
13753
|
-
|
|
13754
|
-
|
|
13755
|
-
|
|
13756
|
-
|
|
13684
|
+
} else {
|
|
13685
|
+
mine = byName;
|
|
13686
|
+
}
|
|
13687
|
+
if (mine.length === 1) {
|
|
13688
|
+
shipRowId = mine[0].id;
|
|
13689
|
+
out.shipRow = { id: shipRowId, name: o.ship.name, action: "matched", matchedBy };
|
|
13690
|
+
const patchBody = {};
|
|
13691
|
+
if (matchedBy === "machine_id") {
|
|
13692
|
+
const rowName = (mine[0].name ?? "").trim();
|
|
13693
|
+
const route = `${out.workspaceSlug}/${out.projectSlug ?? out.projectId}`;
|
|
13694
|
+
const plan = planNameSync(o.ship.name.trim(), rowName, o.ship.lastSyncedName?.(route));
|
|
13695
|
+
if (plan.action === "push") {
|
|
13696
|
+
patchBody.name = o.ship.name.trim();
|
|
13697
|
+
provisioning.push(`renamed Ships row "${rowName}" to "${o.ship.name.trim()}" (crew.yaml changed)`);
|
|
13698
|
+
} else if (plan.action === "adopt" || plan.action === "row-wins") {
|
|
13699
|
+
out.shipRow.name = rowName;
|
|
13700
|
+
out.shipNameAdopted = rowName;
|
|
13701
|
+
provisioning.push(`matched Ships row "${rowName}" by machine id \u2014 this host's name becomes "${rowName}"${plan.action === "row-wins" ? " (crew.yaml also changed; the Ships row wins)" : ""}`);
|
|
13702
|
+
}
|
|
13703
|
+
patchBody.last_seen = (/* @__PURE__ */ new Date()).toISOString();
|
|
13757
13704
|
}
|
|
13758
|
-
if (
|
|
13759
|
-
|
|
13760
|
-
|
|
13705
|
+
if (writeMachineId) patchBody.machine_id = machineId;
|
|
13706
|
+
if ((mine[0].host_passengers ?? false) !== hostPassengers) patchBody.host_passengers = hostPassengers;
|
|
13707
|
+
if (sshPublicKey !== void 0 && (mine[0].ssh_public_key ?? "") !== sshPublicKey) {
|
|
13708
|
+
patchBody.ssh_public_key = sshPublicKey;
|
|
13761
13709
|
}
|
|
13762
|
-
|
|
13763
|
-
if (
|
|
13764
|
-
out.
|
|
13765
|
-
|
|
13710
|
+
if (ownerId !== void 0 && (mine[0].owner_id ?? "") !== ownerId) patchBody.owner_id = ownerId;
|
|
13711
|
+
if (Object.keys(patchBody).length > 0) {
|
|
13712
|
+
await patch(o, `/data-models/${out.shipsModelId}/records/${shipRowId}`, patchBody);
|
|
13713
|
+
if ("host_passengers" in patchBody) provisioning.push(`synced host_passengers=${hostPassengers} onto Ships row "${o.ship.name}"`);
|
|
13714
|
+
if ("ssh_public_key" in patchBody) provisioning.push(`synced ssh_public_key onto Ships row "${o.ship.name}"`);
|
|
13715
|
+
if ("owner_id" in patchBody) provisioning.push(`recorded owner on Ships row "${o.ship.name}"`);
|
|
13766
13716
|
}
|
|
13717
|
+
} else if (mine.length === 0) {
|
|
13718
|
+
const created = await post2(o, `/data-models/${out.shipsModelId}/records`, {
|
|
13719
|
+
name: o.ship.name,
|
|
13720
|
+
...o.ship.platform ? { platform: o.ship.platform } : {},
|
|
13721
|
+
host_passengers: hostPassengers,
|
|
13722
|
+
...sshPublicKey !== void 0 ? { ssh_public_key: sshPublicKey } : {},
|
|
13723
|
+
...ownerId !== void 0 ? { owner_id: ownerId } : {},
|
|
13724
|
+
...writeMachineId ? { machine_id: machineId } : {}
|
|
13725
|
+
});
|
|
13726
|
+
shipRowId = created.id;
|
|
13727
|
+
out.shipRow = { id: shipRowId, name: o.ship.name, action: "created", matchedBy };
|
|
13728
|
+
provisioning.push(`created Ships row "${o.ship.name}"`);
|
|
13729
|
+
} else {
|
|
13730
|
+
problems.push(
|
|
13731
|
+
matchedBy === "machine_id" ? `${mine.length} Ships rows carry this machine's id \u2014 which one is this machine?` : `${mine.length} Ships rows named "${o.ship.name}" \u2014 which one is this machine?`
|
|
13732
|
+
);
|
|
13733
|
+
}
|
|
13734
|
+
}
|
|
13735
|
+
let agentIdFor = () => void 0;
|
|
13736
|
+
if (shipRowId) {
|
|
13737
|
+
try {
|
|
13738
|
+
const agentsModel = await get(o, `/data-models/agents?workspaceId=${ws.id}`);
|
|
13739
|
+
const agentRows = await get(
|
|
13740
|
+
o,
|
|
13741
|
+
`/data-models/${agentsModel.id}/records?limit=200`
|
|
13742
|
+
);
|
|
13743
|
+
agentIdFor = (role) => agentRows.find((r) => r.name === PERSONA_NAME[role])?.id;
|
|
13744
|
+
} catch {
|
|
13745
|
+
}
|
|
13746
|
+
}
|
|
13747
|
+
if (out.models.crew) {
|
|
13748
|
+
const rows = await get(
|
|
13749
|
+
o,
|
|
13750
|
+
`/data-models/${out.models.crew}/records?limit=200`
|
|
13751
|
+
);
|
|
13752
|
+
let seatShipId = shipRowId;
|
|
13753
|
+
if (!seatShipId && o.dryRun && o.ship && out.shipsModelId) {
|
|
13767
13754
|
try {
|
|
13768
|
-
const
|
|
13769
|
-
|
|
13770
|
-
|
|
13771
|
-
|
|
13772
|
-
|
|
13773
|
-
|
|
13774
|
-
|
|
13775
|
-
|
|
13776
|
-
|
|
13777
|
-
|
|
13778
|
-
|
|
13779
|
-
|
|
13780
|
-
|
|
13781
|
-
|
|
13782
|
-
|
|
13783
|
-
|
|
13784
|
-
|
|
13785
|
-
|
|
13786
|
-
|
|
13787
|
-
|
|
13788
|
-
|
|
13755
|
+
const shipRows = await get(
|
|
13756
|
+
o,
|
|
13757
|
+
`/data-models/${out.shipsModelId}/records?limit=200`
|
|
13758
|
+
);
|
|
13759
|
+
const byId = o.ship.machineId ? shipRows.filter((r) => r.machine_id === o.ship.machineId) : [];
|
|
13760
|
+
const mineRows = byId.length > 0 ? byId : shipRows.filter((r) => (r.name ?? "").trim() === o.ship.name.trim() && !r.machine_id);
|
|
13761
|
+
if (mineRows.length === 1) seatShipId = mineRows[0].id;
|
|
13762
|
+
} catch {
|
|
13763
|
+
}
|
|
13764
|
+
}
|
|
13765
|
+
const scoped = seatShipId ? rows.filter((r) => r.ship_id === seatShipId) : rows.filter((r) => !r.ship_id);
|
|
13766
|
+
const unclaimed = seatShipId ? rows.filter((r) => !r.ship_id) : [];
|
|
13767
|
+
for (const role of ["dev", "design", "qa", "triage"]) {
|
|
13768
|
+
const searchTerm = role === "dev" ? "develop" : role;
|
|
13769
|
+
let id = seatFor(scoped, searchTerm);
|
|
13770
|
+
let seatAction = "matched";
|
|
13771
|
+
if (!id && seatShipId && !shipRowId) {
|
|
13772
|
+
const legacyId = seatFor(unclaimed, searchTerm);
|
|
13773
|
+
if (legacyId) {
|
|
13774
|
+
id = legacyId;
|
|
13775
|
+
seatAction = "claimed";
|
|
13776
|
+
}
|
|
13777
|
+
} else if (!id && shipRowId) {
|
|
13778
|
+
const legacyId = seatFor(unclaimed, searchTerm);
|
|
13779
|
+
if (legacyId) {
|
|
13780
|
+
await patch(o, `/data-models/${out.models.crew}/records/${legacyId}`, { ship_id: shipRowId });
|
|
13781
|
+
id = legacyId;
|
|
13782
|
+
seatAction = "claimed";
|
|
13783
|
+
provisioning.push(`claimed existing Crew row for the ${role} seat`);
|
|
13784
|
+
} else {
|
|
13785
|
+
const created = await post2(o, `/data-models/${out.models.crew}/records`, {
|
|
13786
|
+
name: `${PERSONA_NAME[role]} agent`,
|
|
13787
|
+
ship_id: shipRowId,
|
|
13788
|
+
...agentIdFor(role) ? { agent_id: agentIdFor(role) } : {}
|
|
13789
|
+
});
|
|
13790
|
+
id = created.id;
|
|
13791
|
+
seatAction = "created";
|
|
13792
|
+
provisioning.push(`created Crew row for the ${role} seat`);
|
|
13789
13793
|
}
|
|
13790
|
-
out.push({
|
|
13791
|
-
role,
|
|
13792
|
-
action: "failed",
|
|
13793
|
-
detail: revoked ? `minted but could not be stored (${describeStoreError(e)}); revoked` : `a key for ${role} is live on the server and not stored (${describeStoreError(e)}) \u2014 revoke it from that seat's Agents row`
|
|
13794
|
-
});
|
|
13795
|
-
continue;
|
|
13796
13794
|
}
|
|
13797
|
-
|
|
13798
|
-
|
|
13799
|
-
|
|
13795
|
+
if (id) {
|
|
13796
|
+
out.seats[role] = id;
|
|
13797
|
+
const rowShip = rows.find((r) => r.id === id)?.ship_id ?? shipRowId ?? void 0;
|
|
13798
|
+
(out.seatRows ??= []).push({ role, id, action: seatAction, ...rowShip ? { shipId: rowShip } : {} });
|
|
13799
|
+
} else if (o.dryRun && o.ship && out.shipsModelId) {
|
|
13800
|
+
(out.seatRows ??= []).push({ role, id: "", action: "would-create" });
|
|
13801
|
+
} else problems.push(`no Crew row looking like the ${role} seat \u2014 create one, or omit that role`);
|
|
13800
13802
|
}
|
|
13801
|
-
|
|
13802
|
-
|
|
13803
|
-
|
|
13804
|
-
|
|
13805
|
-
|
|
13806
|
-
|
|
13807
|
-
|
|
13808
|
-
|
|
13809
|
-
|
|
13810
|
-
|
|
13811
|
-
|
|
13812
|
-
|
|
13813
|
-
|
|
13814
|
-
case "failed":
|
|
13815
|
-
return `${o.role}: could not mint an agent key (${o.detail ?? "unknown"}) \u2014 writes as the ship`;
|
|
13816
|
-
}
|
|
13817
|
-
}
|
|
13818
|
-
async function revokeSeatKeys(route, o) {
|
|
13819
|
-
const out = [];
|
|
13820
|
-
if (!route.resolved) return out;
|
|
13821
|
-
const resolved = route.resolved;
|
|
13822
|
-
const doFetch = o.fetchImpl ?? fetch;
|
|
13823
|
-
const targets = [
|
|
13824
|
-
...SEAT_KEY_ROLES.filter((r) => resolved.seats[r]).map((role) => ({ role, agentId: seatAgentId(route, role) })),
|
|
13825
|
-
...resolved.releaseSeat ? [{ role: "release", agentId: resolved.releaseAgentId }] : []
|
|
13826
|
-
];
|
|
13827
|
-
let store;
|
|
13828
|
-
try {
|
|
13829
|
-
store = (o.getStore ?? getSessionStore2)();
|
|
13830
|
-
} catch (e) {
|
|
13831
|
-
return targets.map(({ role }) => ({ role, action: "failed", detail: describeStoreError(e) }));
|
|
13832
|
-
}
|
|
13833
|
-
const host = hostFromUrl3(route.baseUrl);
|
|
13834
|
-
const base = route.baseUrl.replace(/\/+$/, "");
|
|
13835
|
-
for (const { role, agentId } of targets) {
|
|
13836
|
-
if (!agentId) {
|
|
13837
|
-
out.push({ role, action: "no_agent" });
|
|
13838
|
-
continue;
|
|
13803
|
+
const pairId = seatFor(scoped, "pair") || (seatShipId ? seatFor(unclaimed, "pair") : void 0);
|
|
13804
|
+
if (pairId) out.seats.pair = pairId;
|
|
13805
|
+
if (shipRowId) {
|
|
13806
|
+
const releaseId = seatFor(scoped, "release");
|
|
13807
|
+
if (releaseId) out.releaseSeat = releaseId;
|
|
13808
|
+
else {
|
|
13809
|
+
const created = await post2(o, `/data-models/${out.models.crew}/records`, {
|
|
13810
|
+
name: "Release agent",
|
|
13811
|
+
ship_id: shipRowId
|
|
13812
|
+
});
|
|
13813
|
+
out.releaseSeat = created.id;
|
|
13814
|
+
provisioning.push("created Crew row for the release seat");
|
|
13815
|
+
}
|
|
13839
13816
|
}
|
|
13840
|
-
const
|
|
13817
|
+
const seatIds = /* @__PURE__ */ new Set([...Object.values(out.seats), ...out.releaseSeat ? [out.releaseSeat] : []]);
|
|
13818
|
+
const holdRows = rows.filter((r) => !seatIds.has(r.id));
|
|
13819
|
+
out.holds = holdRows.map((r) => ({ id: r.id, name: r.name ?? "", email: r.email, user_id: r.user_id }));
|
|
13820
|
+
out.crewModelId = out.models.crew;
|
|
13821
|
+
const people = holdRows.filter((r) => !r.agent_id && (!r.ship_id || !!r.user_id));
|
|
13822
|
+
const members = /* @__PURE__ */ new Map();
|
|
13841
13823
|
try {
|
|
13842
|
-
const
|
|
13843
|
-
|
|
13844
|
-
out.
|
|
13845
|
-
|
|
13846
|
-
|
|
13847
|
-
|
|
13848
|
-
|
|
13849
|
-
continue;
|
|
13824
|
+
const list = await get(
|
|
13825
|
+
o,
|
|
13826
|
+
`/workspaces/${out.workspaceId}/members`
|
|
13827
|
+
);
|
|
13828
|
+
for (const m of Array.isArray(list) ? list : []) {
|
|
13829
|
+
const key = m.id ?? m.userId;
|
|
13830
|
+
if (key) members.set(key, { identityId: m.identityId, email: m.email });
|
|
13850
13831
|
}
|
|
13851
|
-
|
|
13852
|
-
|
|
13853
|
-
|
|
13832
|
+
} catch {
|
|
13833
|
+
}
|
|
13834
|
+
for (const h of out.holds) {
|
|
13835
|
+
const e = h.user_id ? members.get(h.user_id)?.email : void 0;
|
|
13836
|
+
if (e) h.memberEmail = e;
|
|
13837
|
+
}
|
|
13838
|
+
out.candidates = people.map((r) => ({
|
|
13839
|
+
id: r.id,
|
|
13840
|
+
name: r.name ?? "",
|
|
13841
|
+
email: r.email,
|
|
13842
|
+
user_id: r.user_id,
|
|
13843
|
+
detail: r.email || (r.user_id ? members.get(r.user_id)?.email : void 0) || void 0
|
|
13844
|
+
}));
|
|
13845
|
+
const isMine = (r) => !!r.user_id && (r.user_id === out.meId || !!out.meIdentityId && members.get(r.user_id)?.identityId === out.meIdentityId);
|
|
13846
|
+
const emailOf = (r) => [r.email, r.user_id ? members.get(r.user_id)?.email : void 0].filter((e) => !!e);
|
|
13847
|
+
if (!out.meId) {
|
|
13848
|
+
if (out.candidates.length === 0) problems.push("no non-seat Crew rows \u2014 which row is the operator?");
|
|
13849
|
+
else {
|
|
13850
|
+
const byEmail = out.meEmail ? people.filter((r) => emailOf(r).some((e) => eq(e, out.meEmail))) : [];
|
|
13851
|
+
if (byEmail.length === 1) out.operator = byEmail[0].id;
|
|
13852
|
+
else if (out.candidates.length === 1) out.operator = out.candidates[0].id;
|
|
13854
13853
|
}
|
|
13855
|
-
|
|
13856
|
-
|
|
13857
|
-
|
|
13858
|
-
|
|
13859
|
-
|
|
13860
|
-
|
|
13861
|
-
|
|
13854
|
+
} else {
|
|
13855
|
+
const candidates = people.filter((r) => !r.user_id || isMine(r));
|
|
13856
|
+
const mine = people.find(isMine);
|
|
13857
|
+
const byEmail = out.meEmail ? candidates.filter((r) => emailOf(r).some((e) => eq(e, out.meEmail))) : [];
|
|
13858
|
+
const unlinked = people.filter((r) => !r.user_id);
|
|
13859
|
+
const only = candidates.length === 1 && unlinked.length === 1 ? candidates[0] : void 0;
|
|
13860
|
+
const pick = mine ?? (byEmail.length === 1 ? byEmail[0] : only);
|
|
13861
|
+
if (pick) {
|
|
13862
|
+
out.operator = pick.id;
|
|
13863
|
+
out.operatorAction = isMine(pick) ? "matched" : "linked";
|
|
13864
|
+
if (!isMine(pick) && o.ship) await linkOperatorRow(o, out, pick);
|
|
13865
|
+
} else {
|
|
13866
|
+
out.operatorCompared = `signed in as ${out.meEmail ?? "unknown email"} (identity ${out.meIdentityId ?? out.meId}); ` + (people.length === 0 ? "no person rows in the Crew table" : people.map((r) => {
|
|
13867
|
+
const m = r.user_id ? members.get(r.user_id) : void 0;
|
|
13868
|
+
return `"${r.name || "unnamed row"}" <${r.email ?? "no email"}>` + (r.user_id ? ` linked to member ${m?.email ?? r.user_id}${m?.identityId ? ` (identity ${m.identityId})` : ""}` : " unlinked");
|
|
13869
|
+
}).join("; "));
|
|
13870
|
+
if (o.ship && (!o.interactive || candidates.length === 0)) await createOperatorRow(o, out);
|
|
13862
13871
|
}
|
|
13863
|
-
await store.clear(host, slug);
|
|
13864
|
-
out.push({ role, action: "revoked", detail: del2.ok ? void 0 : "server already had no such key" });
|
|
13865
|
-
} catch (e) {
|
|
13866
|
-
out.push({ role, action: "failed", detail: /Command failed|security|sk_|-w /i.test(e.message) ? describeStoreError(e) : e.message });
|
|
13867
13872
|
}
|
|
13868
13873
|
}
|
|
13869
13874
|
return out;
|
|
13870
13875
|
}
|
|
13871
|
-
function
|
|
13872
|
-
|
|
13873
|
-
|
|
13874
|
-
|
|
13875
|
-
|
|
13876
|
-
|
|
13877
|
-
|
|
13878
|
-
|
|
13879
|
-
|
|
13880
|
-
|
|
13876
|
+
async function linkOperatorRow(o, found, row) {
|
|
13877
|
+
const name = row.name || row.id;
|
|
13878
|
+
if (!found.meId || !found.crewModelId) return;
|
|
13879
|
+
if (o.dryRun) {
|
|
13880
|
+
found.provisioning.push(`would link Crew row "${name}" to your account`);
|
|
13881
|
+
return;
|
|
13882
|
+
}
|
|
13883
|
+
const body = { user_id: found.meId };
|
|
13884
|
+
if (!row.email && found.meEmail) body.email = found.meEmail;
|
|
13885
|
+
try {
|
|
13886
|
+
await patch(o, `/data-models/${found.crewModelId}/records/${row.id}`, body);
|
|
13887
|
+
found.provisioning.push(`linked Crew row "${name}" to your account`);
|
|
13888
|
+
} catch (e) {
|
|
13889
|
+
if (!(e instanceof ConnectHttpError) || e.status !== 403) throw e;
|
|
13890
|
+
found.provisioning.push(
|
|
13891
|
+
`could not link your Crew row (no write access) \u2014 ask a workspace admin to set user_id on "${name}"`
|
|
13892
|
+
);
|
|
13881
13893
|
}
|
|
13882
13894
|
}
|
|
13883
|
-
async function
|
|
13884
|
-
|
|
13885
|
-
|
|
13886
|
-
|
|
13887
|
-
|
|
13895
|
+
async function createOperatorRow(o, found) {
|
|
13896
|
+
if (!found.meId || !found.crewModelId) return;
|
|
13897
|
+
const name = found.meName || found.meEmail || "Operator";
|
|
13898
|
+
if (o.dryRun) {
|
|
13899
|
+
found.provisioning.push(`would create Crew row "${name}" for you`);
|
|
13900
|
+
return;
|
|
13901
|
+
}
|
|
13888
13902
|
try {
|
|
13889
|
-
const
|
|
13890
|
-
|
|
13903
|
+
const created = await post2(o, `/data-models/${found.crewModelId}/records`, {
|
|
13904
|
+
name,
|
|
13905
|
+
...found.meEmail ? { email: found.meEmail } : {},
|
|
13906
|
+
user_id: found.meId
|
|
13891
13907
|
});
|
|
13892
|
-
|
|
13893
|
-
|
|
13894
|
-
|
|
13895
|
-
|
|
13908
|
+
found.operator = created.id;
|
|
13909
|
+
found.operatorAction = "created";
|
|
13910
|
+
found.holds.push({ id: created.id, name, email: found.meEmail, user_id: found.meId });
|
|
13911
|
+
found.candidates.push({ id: created.id, name, email: found.meEmail, user_id: found.meId, detail: found.meEmail });
|
|
13912
|
+
found.provisioning.push(`created Crew row "${name}" for you`);
|
|
13913
|
+
} catch (e) {
|
|
13914
|
+
if (!(e instanceof ConnectHttpError) || e.status !== 403) throw e;
|
|
13915
|
+
found.provisioning.push("could not create your Crew row (no write access) \u2014 ask a workspace admin to add one with your user_id");
|
|
13896
13916
|
}
|
|
13897
13917
|
}
|
|
13918
|
+
function renderConnection(d, route, dir, opts = {}) {
|
|
13919
|
+
const apiKeyFileLine = opts.keychainBacked ? ` # apiKey: resolved automatically from the OS keychain (crew connect) \u2014 no apiKeyFile needed` : opts.apiKeyFile ? ` apiKeyFile: "${opts.apiKeyFile}"` : ` apiKeyFile: "REPLACE \u2014 a file holding this workspace's key"`;
|
|
13920
|
+
const apiKeyVarLine = opts.keychainBacked ? "" : opts.apiKeyVar ? `
|
|
13921
|
+
apiKeyVar: ${opts.apiKeyVar}` : `
|
|
13922
|
+
apiKeyVar: REPLACE_KEY_VAR`;
|
|
13923
|
+
return ` - route: ${route}
|
|
13924
|
+
enabled: false # arm it deliberately, once doctor is green
|
|
13925
|
+
${opts.area ? ` area: "${opts.area}"
|
|
13926
|
+
` : ""} ${dir === null ? "repos: {} # `crew repos clone` / `crew repos add` fill this in" : `dir: "${dir}"`}
|
|
13927
|
+
# worktreePrefix: omitted \u2014 each repo derives its own from its checkout's
|
|
13928
|
+
# directory name (ISSUE-400). Only set this if every repo this route
|
|
13929
|
+
# serves genuinely wants the SAME prefix, which is rare once an area has
|
|
13930
|
+
# more than one repo.
|
|
13931
|
+
# What a host must be to build this \u2014 unix, macos, linux, or windows \u2014
|
|
13932
|
+
# belongs in each repo's own .crew.yaml, not here (see docs/REPO_SPEC.md).
|
|
13933
|
+
baseUrl: ${opts.baseUrl ? `"${opts.baseUrl}"` : '"REPLACE \u2014 the same baseUrl as your other routes"'}
|
|
13934
|
+
${opts.hostPassengers ? " hostPassengers: true\n" : ""}${apiKeyFileLine}${apiKeyVarLine}
|
|
13935
|
+
`;
|
|
13936
|
+
}
|
|
13937
|
+
|
|
13938
|
+
// src/cli.ts
|
|
13939
|
+
import {
|
|
13940
|
+
getSessionStore as getSessionStore3,
|
|
13941
|
+
hostFromUrl as hostFromUrl4,
|
|
13942
|
+
loginWithDeviceCode,
|
|
13943
|
+
getOrCreateShipId,
|
|
13944
|
+
DeviceLoginError
|
|
13945
|
+
} from "@tablation/client";
|
|
13898
13946
|
|
|
13899
13947
|
// src/capability-check.ts
|
|
13900
13948
|
var WRITE_LEVELS = /* @__PURE__ */ new Set(["WRITE", "DELETE"]);
|
|
@@ -17392,6 +17440,7 @@ ${changed2.map((c) => ` - ${c}
|
|
|
17392
17440
|
let seen = precheckTickets.get(c.route);
|
|
17393
17441
|
if (!seen) precheckTickets.set(c.route, seen = tracker.openTickets());
|
|
17394
17442
|
const { byRepo: preByRepo, unplaceable: preUnplaceable } = ticketsByRepo(c, await seen);
|
|
17443
|
+
clearShipAttention(state, SEAT_FORBIDDEN_KEY);
|
|
17395
17444
|
const preModes = { modes: await tracker.projectWorkModes(), agentAssignees: /* @__PURE__ */ new Set() };
|
|
17396
17445
|
const pre = withoutManualVerified(
|
|
17397
17446
|
preByRepo.get(target.name) ?? [],
|
|
@@ -17699,6 +17748,12 @@ ${changed2.map((c) => ` - ${c}
|
|
|
17699
17748
|
await sweepClosedTickets(c, target, repo.branch, tracker, dryRun, remit);
|
|
17700
17749
|
return summary;
|
|
17701
17750
|
} catch (e) {
|
|
17751
|
+
if (isForbiddenError(e)) {
|
|
17752
|
+
await raiseShipAttention({ state, emit: remit, route: c, ship: cfg.ship }, seatForbiddenItem());
|
|
17753
|
+
const line = seatForbiddenRepoLine(scope);
|
|
17754
|
+
remit.warn(line, { step: "release" });
|
|
17755
|
+
return { scope, tests: "skipped", outcome: "error", detail: line };
|
|
17756
|
+
}
|
|
17702
17757
|
remit.warn(`release phase failed: ${e.message}`, { step: "release" });
|
|
17703
17758
|
return { scope, tests: "skipped", outcome: "error", detail: e.message };
|
|
17704
17759
|
} finally {
|
|
@@ -18439,6 +18494,15 @@ ${changed2.map((c) => ` - ${c}
|
|
|
18439
18494
|
process.stdout.write(`${describeMintOutcome(o)}
|
|
18440
18495
|
`);
|
|
18441
18496
|
}
|
|
18497
|
+
if (!dryRun) {
|
|
18498
|
+
for (const p of await probeSeatReads(target, { userAgent: cfg.ship.userAgent })) {
|
|
18499
|
+
if (p.state === "forbidden") {
|
|
18500
|
+
process.stdout.write(`${describeSeatForbidden(p.role)}
|
|
18501
|
+
`);
|
|
18502
|
+
process.exitCode = 1;
|
|
18503
|
+
}
|
|
18504
|
+
}
|
|
18505
|
+
}
|
|
18442
18506
|
}
|
|
18443
18507
|
if (run2.diverged) process.exitCode = 1;
|
|
18444
18508
|
break;
|
|
@@ -19379,6 +19443,10 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
|
|
|
19379
19443
|
mintOutcomes = await mintMissingSeatKeys({ ...existingRoute, resolved: { ...existingRoute.resolved, seats: found.seats } }, { userAgent: cfg.ship.userAgent, shipName: cfg.ship.name });
|
|
19380
19444
|
for (const o of mintOutcomes) process.stderr.write(`${describeMintOutcome(o)}
|
|
19381
19445
|
`);
|
|
19446
|
+
for (const p of await probeSeatReads({ ...existingRoute, resolved: { ...existingRoute.resolved, seats: found.seats } }, { userAgent: cfg.ship.userAgent })) {
|
|
19447
|
+
if (p.state === "forbidden") process.stderr.write(`${describeSeatForbidden(p.role)}
|
|
19448
|
+
`);
|
|
19449
|
+
}
|
|
19382
19450
|
} catch (e) {
|
|
19383
19451
|
process.stderr.write(`seat keys: skipped (${e.message})
|
|
19384
19452
|
`);
|
|
@@ -19543,6 +19611,10 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
|
|
|
19543
19611
|
const minted = await mintMissingSeatKeys(syncRoute, { userAgent: cfg.ship.userAgent, shipName: cfg.ship.name });
|
|
19544
19612
|
for (const m of minted) process.stderr.write(` ${describeMintOutcome(m)}
|
|
19545
19613
|
`);
|
|
19614
|
+
for (const p of await probeSeatReads(syncRoute, { userAgent: cfg.ship.userAgent })) {
|
|
19615
|
+
if (p.state === "forbidden") process.stderr.write(` ${describeSeatForbidden(p.role)}
|
|
19616
|
+
`);
|
|
19617
|
+
}
|
|
19546
19618
|
if (run2.readOnly) {
|
|
19547
19619
|
const missing = run2.personaResult.outcomes.some((p) => p.action === "missing");
|
|
19548
19620
|
process.stderr.write(missing ? " A workspace admin has to open the Crew app or run `crew agents sync` once before this ship's seats can run.\n" : " Seats are linked to the workspace's existing personas.\n");
|
|
@@ -20662,6 +20734,13 @@ watermark: ${state.watermark()}
|
|
|
20662
20734
|
passed += f.passed;
|
|
20663
20735
|
}
|
|
20664
20736
|
}
|
|
20737
|
+
if (!flag("offline")) {
|
|
20738
|
+
for (const p of await probeSeatReads(route, { userAgent: cfg.ship.userAgent })) {
|
|
20739
|
+
if (p.state === "forbidden") {
|
|
20740
|
+
problems.push({ severity: "blocker", kind: "seat_cannot_read_issues", route: route.route, message: `${p.role} agent key cannot read Issues (403)`, remedy: SEAT_NO_READ_REMEDY });
|
|
20741
|
+
} else if (p.state === "ok") passed++;
|
|
20742
|
+
}
|
|
20743
|
+
}
|
|
20665
20744
|
problems.push(...attentionProblems(openShipAttention(state), problems));
|
|
20666
20745
|
if (problems.some((p) => p.severity === "blocker")) process.exitCode = 1;
|
|
20667
20746
|
process.stdout.write(`${JSON.stringify({ offline: flag("offline"), pushAuth, tools: toolRows, problems, passed }, null, 2)}
|
|
@@ -20801,6 +20880,19 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
|
|
|
20801
20880
|
`
|
|
20802
20881
|
);
|
|
20803
20882
|
}
|
|
20883
|
+
if (!flag("offline")) {
|
|
20884
|
+
for (const p of await probeSeatReads(roleRoute, { userAgent: cfg.ship.userAgent })) {
|
|
20885
|
+
if (p.state === "forbidden") {
|
|
20886
|
+
process.stdout.write(`${describeSeatForbidden(p.role)}
|
|
20887
|
+
`);
|
|
20888
|
+
process.exitCode = 1;
|
|
20889
|
+
} else {
|
|
20890
|
+
process.stdout.write(p.state === "ok" ? `seat read: ${p.role} can read Issues
|
|
20891
|
+
` : `seat read: ${p.role} could not be checked (${p.detail})
|
|
20892
|
+
`);
|
|
20893
|
+
}
|
|
20894
|
+
}
|
|
20895
|
+
}
|
|
20804
20896
|
const [ships, crewRows] = await Promise.all([tracker.shipRows(), tracker.crewRows()]);
|
|
20805
20897
|
if (ships.length === 0) {
|
|
20806
20898
|
process.stdout.write("ship record: this workspace has no Ships table (fine)\n");
|