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