@tablation/crew 0.127.4 → 0.127.6
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 +770 -569
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -6979,6 +6979,502 @@ var SignedOutError = class extends Error {
|
|
|
6979
6979
|
};
|
|
6980
6980
|
var SIGNED_OUT_PAUSE_MS = 15 * 60 * 1e3;
|
|
6981
6981
|
|
|
6982
|
+
// src/seat-keys.ts
|
|
6983
|
+
import { hostFromUrl as hostFromUrl2 } from "@tablation/client";
|
|
6984
|
+
var SEAT_KEY_ROLES = ROLE_NAMES.filter((r) => r !== "pair");
|
|
6985
|
+
function keyIsAdmin(route2) {
|
|
6986
|
+
const role = route2.resolved?.role;
|
|
6987
|
+
return role === void 0 || role === "WORKSPACE_ADMIN" || role === "PLATFORM_ADMIN";
|
|
6988
|
+
}
|
|
6989
|
+
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";
|
|
6990
|
+
var MEMBER_NO_KEY = 'no agent key for this seat \u2014 run `crew agents sync` (needs the "Mint agent keys" capability on your role)';
|
|
6991
|
+
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)';
|
|
6992
|
+
function seatKeySlug(workspaceSlug, agentId) {
|
|
6993
|
+
return `${workspaceSlug}#agent:${agentId}`;
|
|
6994
|
+
}
|
|
6995
|
+
function slugFor(route2, agentId) {
|
|
6996
|
+
const [workspaceSlug] = splitRoute(route2.route);
|
|
6997
|
+
return seatKeySlug(workspaceSlug, agentId);
|
|
6998
|
+
}
|
|
6999
|
+
function withFoundSeats(route2, found, previousReleaseAgentId) {
|
|
7000
|
+
const resolved = route2.resolved;
|
|
7001
|
+
if (!resolved) return route2;
|
|
7002
|
+
const releaseSeat2 = found.releaseSeat ?? resolved.releaseSeat;
|
|
7003
|
+
const releaseAgentId = resolved.releaseAgentId ?? previousReleaseAgentId;
|
|
7004
|
+
return {
|
|
7005
|
+
...route2,
|
|
7006
|
+
resolved: {
|
|
7007
|
+
...resolved,
|
|
7008
|
+
seats: found.seats,
|
|
7009
|
+
...releaseSeat2 ? { releaseSeat: releaseSeat2 } : {},
|
|
7010
|
+
...releaseAgentId ? { releaseAgentId } : {}
|
|
7011
|
+
}
|
|
7012
|
+
};
|
|
7013
|
+
}
|
|
7014
|
+
function seatAgentId(route2, role) {
|
|
7015
|
+
return route2.resolved?.agentPersonas?.[role]?.agentId;
|
|
7016
|
+
}
|
|
7017
|
+
async function resolveReleaseKey(route2, getStore = getSessionStore) {
|
|
7018
|
+
const ship = (reason) => ({ kind: "ship", apiKey: resolveApiKey(route2), reason });
|
|
7019
|
+
const agentId = route2.resolved?.releaseAgentId;
|
|
7020
|
+
if (!agentId) return ship(keyIsAdmin(route2) ? "release seat has no linked Agents row (run `crew agents sync`)" : `release seat ${MEMBER_NO_ROW}`);
|
|
7021
|
+
try {
|
|
7022
|
+
const session = await getStore().get(hostFromUrl2(route2.baseUrl), slugFor(route2, agentId));
|
|
7023
|
+
if (session?.apiKey) return { kind: "agent", apiKey: session.apiKey, agentId };
|
|
7024
|
+
return ship(keyIsAdmin(route2) ? "no agent key stored for the release seat (run `crew agents sync`)" : MEMBER_NO_KEY);
|
|
7025
|
+
} catch (e) {
|
|
7026
|
+
const reason = describeReadError(e);
|
|
7027
|
+
return { kind: "ship", apiKey: resolveApiKey(route2), reason, storeError: reason };
|
|
7028
|
+
}
|
|
7029
|
+
}
|
|
7030
|
+
async function resolveSeatKey(route2, role, getStore = getSessionStore) {
|
|
7031
|
+
const ship = (reason) => ({ kind: "ship", apiKey: resolveApiKey(route2), reason });
|
|
7032
|
+
if (!SEAT_KEY_ROLES.includes(role)) return ship(`${role} always writes as the ship`);
|
|
7033
|
+
const agentId = seatAgentId(route2, role);
|
|
7034
|
+
if (!agentId) return ship(keyIsAdmin(route2) ? "seat has no linked Agents row (run `crew agents sync`)" : `seat ${MEMBER_NO_ROW}`);
|
|
7035
|
+
try {
|
|
7036
|
+
const session = await getStore().get(hostFromUrl2(route2.baseUrl), slugFor(route2, agentId));
|
|
7037
|
+
if (session?.apiKey) return { kind: "agent", apiKey: session.apiKey, agentId };
|
|
7038
|
+
return ship(keyIsAdmin(route2) ? "no agent key stored for this seat (run `crew agents sync`)" : MEMBER_NO_KEY);
|
|
7039
|
+
} catch (e) {
|
|
7040
|
+
const reason = describeReadError(e);
|
|
7041
|
+
return { kind: "ship", apiKey: resolveApiKey(route2), reason, storeError: reason };
|
|
7042
|
+
}
|
|
7043
|
+
}
|
|
7044
|
+
function safeStderrLine(e) {
|
|
7045
|
+
const err3 = e;
|
|
7046
|
+
const raw = typeof err3?.stderr === "string" && err3.stderr.trim() ? err3.stderr : typeof err3?.message === "string" ? err3.message : "";
|
|
7047
|
+
for (const line of raw.split("\n")) {
|
|
7048
|
+
const t = line.trim();
|
|
7049
|
+
if (!t || /^Command failed:/i.test(t) || /(^|\s)-w(\s|$)/.test(t)) continue;
|
|
7050
|
+
return t.slice(0, 120);
|
|
7051
|
+
}
|
|
7052
|
+
return void 0;
|
|
7053
|
+
}
|
|
7054
|
+
function describeReadError(e) {
|
|
7055
|
+
const fsErr = e;
|
|
7056
|
+
if (typeof fsErr?.path === "string" && typeof fsErr.code === "string") return `key file store unreadable (${fsErr.code})`;
|
|
7057
|
+
const code = e?.code;
|
|
7058
|
+
const line = safeStderrLine(e);
|
|
7059
|
+
const what = typeof code === "number" ? `security exited ${code}` : typeof code === "string" ? `security failed: ${code}` : void 0;
|
|
7060
|
+
const detail = [what, line].filter(Boolean).join(what && line ? ": " : "");
|
|
7061
|
+
return detail ? `OS keychain unavailable (${detail})` : "OS keychain unavailable";
|
|
7062
|
+
}
|
|
7063
|
+
function keychainExitCode(reason) {
|
|
7064
|
+
const m = /security exited (\d+)/.exec(reason);
|
|
7065
|
+
return m ? Number(m[1]) : void 0;
|
|
7066
|
+
}
|
|
7067
|
+
function describeStoreError(e) {
|
|
7068
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
7069
|
+
if (/User interaction is not allowed/i.test(msg)) {
|
|
7070
|
+
return "keychain unavailable (User interaction is not allowed \u2014 run from a logged-in session, or `security unlock-keychain` first)";
|
|
7071
|
+
}
|
|
7072
|
+
return "keychain unavailable (could not write to the OS keychain)";
|
|
7073
|
+
}
|
|
7074
|
+
async function mintMissingSeatKeys(route2, o) {
|
|
7075
|
+
const out = [];
|
|
7076
|
+
if (!route2.resolved) return out;
|
|
7077
|
+
const resolved = route2.resolved;
|
|
7078
|
+
const doFetch = o.fetchImpl ?? fetch;
|
|
7079
|
+
const targets = [
|
|
7080
|
+
...SEAT_KEY_ROLES.filter((r) => resolved.seats[r]).map((role) => ({ role, agentId: seatAgentId(route2, role), actorRecordId: resolved.seats[role] })),
|
|
7081
|
+
...resolved.releaseSeat ? [{ role: "release", agentId: resolved.releaseAgentId, actorRecordId: resolved.releaseSeat }] : []
|
|
7082
|
+
];
|
|
7083
|
+
let store;
|
|
7084
|
+
try {
|
|
7085
|
+
store = (o.getStore ?? getSessionStore)();
|
|
7086
|
+
} catch (e) {
|
|
7087
|
+
return targets.map(({ role }) => ({
|
|
7088
|
+
role,
|
|
7089
|
+
action: "failed",
|
|
7090
|
+
detail: describeStoreError(e)
|
|
7091
|
+
}));
|
|
7092
|
+
}
|
|
7093
|
+
const host = hostFromUrl2(route2.baseUrl);
|
|
7094
|
+
const base = route2.baseUrl.replace(/\/+$/, "");
|
|
7095
|
+
if (!o.dryRun) {
|
|
7096
|
+
const probeSlug = seatKeySlug(splitRoute(route2.route)[0], "_probe");
|
|
7097
|
+
try {
|
|
7098
|
+
await store.set(host, probeSlug, {
|
|
7099
|
+
apiKey: "probe",
|
|
7100
|
+
workspaceId: resolved.workspaceId,
|
|
7101
|
+
identityId: "_probe",
|
|
7102
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
7103
|
+
});
|
|
7104
|
+
await store.clear(host, probeSlug);
|
|
7105
|
+
} catch (e) {
|
|
7106
|
+
const detail = `${describeStoreError(e)}; no keys minted`;
|
|
7107
|
+
return targets.map(({ role, agentId }) => agentId ? { role, action: "failed", detail } : { role, action: "no_agent" });
|
|
7108
|
+
}
|
|
7109
|
+
}
|
|
7110
|
+
for (const { role, agentId, actorRecordId } of targets) {
|
|
7111
|
+
if (!agentId) {
|
|
7112
|
+
out.push({ role, action: "no_agent" });
|
|
7113
|
+
continue;
|
|
7114
|
+
}
|
|
7115
|
+
const slug2 = slugFor(route2, agentId);
|
|
7116
|
+
try {
|
|
7117
|
+
if ((await store.get(host, slug2))?.apiKey) {
|
|
7118
|
+
out.push({ role, action: "already_stored" });
|
|
7119
|
+
continue;
|
|
7120
|
+
}
|
|
7121
|
+
if (o.dryRun) {
|
|
7122
|
+
out.push({ role, action: "minted", detail: "dry run" });
|
|
7123
|
+
continue;
|
|
7124
|
+
}
|
|
7125
|
+
const res = await doFetch(`${base}/api/workspaces/${resolved.workspaceId}/agents/${agentId}/api-keys`, {
|
|
7126
|
+
method: "POST",
|
|
7127
|
+
headers: {
|
|
7128
|
+
Authorization: `Bearer ${resolveApiKey(route2)}`,
|
|
7129
|
+
"Content-Type": "application/json",
|
|
7130
|
+
"User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0"
|
|
7131
|
+
},
|
|
7132
|
+
body: JSON.stringify({ name: `crew ${o.shipName ?? "ship"} ${role}`, ...actorRecordId ? { actorRecordId } : {} })
|
|
7133
|
+
});
|
|
7134
|
+
if (res.status === 403) {
|
|
7135
|
+
out.push({ role, action: "forbidden" });
|
|
7136
|
+
break;
|
|
7137
|
+
}
|
|
7138
|
+
if (!res.ok) {
|
|
7139
|
+
out.push({ role, action: "failed", detail: `${res.status} ${res.statusText}` });
|
|
7140
|
+
continue;
|
|
7141
|
+
}
|
|
7142
|
+
const body = await res.json();
|
|
7143
|
+
if (!body.key) {
|
|
7144
|
+
out.push({ role, action: "failed", detail: "response carried no key" });
|
|
7145
|
+
continue;
|
|
7146
|
+
}
|
|
7147
|
+
try {
|
|
7148
|
+
const session = {
|
|
7149
|
+
apiKey: body.key,
|
|
7150
|
+
workspaceId: resolved.workspaceId,
|
|
7151
|
+
identityId: agentId,
|
|
7152
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
7153
|
+
...body.id ? { keyId: body.id } : {}
|
|
7154
|
+
};
|
|
7155
|
+
await store.set(host, slug2, session);
|
|
7156
|
+
} catch (e) {
|
|
7157
|
+
let revoked = false;
|
|
7158
|
+
if (body.id) {
|
|
7159
|
+
try {
|
|
7160
|
+
const del2 = await doFetch(`${base}/api/workspaces/${resolved.workspaceId}/agents/${agentId}/api-keys/${body.id}`, {
|
|
7161
|
+
method: "DELETE",
|
|
7162
|
+
headers: { Authorization: `Bearer ${resolveApiKey(route2)}`, "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0" }
|
|
7163
|
+
});
|
|
7164
|
+
revoked = del2.ok;
|
|
7165
|
+
} catch {
|
|
7166
|
+
revoked = false;
|
|
7167
|
+
}
|
|
7168
|
+
}
|
|
7169
|
+
out.push({
|
|
7170
|
+
role,
|
|
7171
|
+
action: "failed",
|
|
7172
|
+
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`
|
|
7173
|
+
});
|
|
7174
|
+
continue;
|
|
7175
|
+
}
|
|
7176
|
+
out.push({ role, action: "minted" });
|
|
7177
|
+
} catch (e) {
|
|
7178
|
+
out.push({ role, action: "failed", detail: /Command failed|security|sk_|-w /i.test(e.message) ? describeStoreError(e) : e.message });
|
|
7179
|
+
}
|
|
7180
|
+
}
|
|
7181
|
+
return out;
|
|
7182
|
+
}
|
|
7183
|
+
function describeMintOutcome(o) {
|
|
7184
|
+
switch (o.action) {
|
|
7185
|
+
case "minted":
|
|
7186
|
+
return `${o.role}: agent key minted${o.detail ? ` (${o.detail})` : ""}`;
|
|
7187
|
+
case "already_stored":
|
|
7188
|
+
return `${o.role}: agent key already stored`;
|
|
7189
|
+
case "no_agent":
|
|
7190
|
+
return `${o.role}: no linked Agents row \u2014 writes as the ship until \`crew agents sync\` links one`;
|
|
7191
|
+
case "forbidden":
|
|
7192
|
+
return FORBIDDEN_LINE;
|
|
7193
|
+
case "failed":
|
|
7194
|
+
return `${o.role}: could not mint an agent key (${o.detail ?? "unknown"}) \u2014 writes as the ship`;
|
|
7195
|
+
}
|
|
7196
|
+
}
|
|
7197
|
+
async function revokeSeatKeys(route2, o) {
|
|
7198
|
+
const out = [];
|
|
7199
|
+
if (!route2.resolved) return out;
|
|
7200
|
+
const resolved = route2.resolved;
|
|
7201
|
+
const doFetch = o.fetchImpl ?? fetch;
|
|
7202
|
+
const targets = [
|
|
7203
|
+
...SEAT_KEY_ROLES.filter((r) => resolved.seats[r]).map((role) => ({ role, agentId: seatAgentId(route2, role), actorRecordId: resolved.seats[role] })),
|
|
7204
|
+
...resolved.releaseSeat ? [{ role: "release", agentId: resolved.releaseAgentId, actorRecordId: resolved.releaseSeat }] : []
|
|
7205
|
+
];
|
|
7206
|
+
let store;
|
|
7207
|
+
try {
|
|
7208
|
+
store = (o.getStore ?? getSessionStore)();
|
|
7209
|
+
} catch (e) {
|
|
7210
|
+
return targets.map(({ role }) => ({ role, action: "failed", detail: describeStoreError(e) }));
|
|
7211
|
+
}
|
|
7212
|
+
const host = hostFromUrl2(route2.baseUrl);
|
|
7213
|
+
const base = route2.baseUrl.replace(/\/+$/, "");
|
|
7214
|
+
for (const { role, agentId } of targets) {
|
|
7215
|
+
if (!agentId) {
|
|
7216
|
+
out.push({ role, action: "no_agent" });
|
|
7217
|
+
continue;
|
|
7218
|
+
}
|
|
7219
|
+
const slug2 = slugFor(route2, agentId);
|
|
7220
|
+
try {
|
|
7221
|
+
const session = await store.get(host, slug2);
|
|
7222
|
+
if (!session?.apiKey) {
|
|
7223
|
+
out.push({ role, action: "not_stored" });
|
|
7224
|
+
continue;
|
|
7225
|
+
}
|
|
7226
|
+
if (!session.keyId) {
|
|
7227
|
+
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" });
|
|
7228
|
+
continue;
|
|
7229
|
+
}
|
|
7230
|
+
if (o.dryRun) {
|
|
7231
|
+
out.push({ role, action: "revoked", detail: "dry run" });
|
|
7232
|
+
continue;
|
|
7233
|
+
}
|
|
7234
|
+
const del2 = await doFetch(`${base}/api/workspaces/${resolved.workspaceId}/agents/${agentId}/api-keys/${session.keyId}`, {
|
|
7235
|
+
method: "DELETE",
|
|
7236
|
+
headers: { Authorization: `Bearer ${resolveApiKey(route2)}`, "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0" }
|
|
7237
|
+
});
|
|
7238
|
+
if (!del2.ok && del2.status !== 404) {
|
|
7239
|
+
out.push({ role, action: "failed", detail: `${del2.status} ${del2.statusText}` });
|
|
7240
|
+
continue;
|
|
7241
|
+
}
|
|
7242
|
+
await store.clear(host, slug2);
|
|
7243
|
+
out.push({ role, action: "revoked", detail: del2.ok ? void 0 : "server already had no such key" });
|
|
7244
|
+
} catch (e) {
|
|
7245
|
+
out.push({ role, action: "failed", detail: /Command failed|security|sk_|-w /i.test(e.message) ? describeStoreError(e) : e.message });
|
|
7246
|
+
}
|
|
7247
|
+
}
|
|
7248
|
+
return out;
|
|
7249
|
+
}
|
|
7250
|
+
function describeRevokeOutcome(o) {
|
|
7251
|
+
switch (o.action) {
|
|
7252
|
+
case "revoked":
|
|
7253
|
+
return `${o.role}: agent key revoked and cleared from the keychain${o.detail ? ` (${o.detail})` : ""}`;
|
|
7254
|
+
case "not_stored":
|
|
7255
|
+
return `${o.role}: no agent key stored on this ship`;
|
|
7256
|
+
case "no_agent":
|
|
7257
|
+
return `${o.role}: no linked Agents row`;
|
|
7258
|
+
case "failed":
|
|
7259
|
+
return `${o.role}: could not revoke the agent key (${o.detail ?? "unknown"}) \u2014 left in the keychain`;
|
|
7260
|
+
}
|
|
7261
|
+
}
|
|
7262
|
+
async function canMintAgentKeys(route2, o = {}) {
|
|
7263
|
+
const resolved = route2.resolved;
|
|
7264
|
+
if (!resolved) return void 0;
|
|
7265
|
+
const agentId = seatAgentId(route2, "dev") ?? SEAT_KEY_ROLES.map((r) => seatAgentId(route2, r)).find(Boolean);
|
|
7266
|
+
if (!agentId) return void 0;
|
|
7267
|
+
try {
|
|
7268
|
+
const res = await (o.fetchImpl ?? fetch)(`${route2.baseUrl.replace(/\/+$/, "")}/api/workspaces/${resolved.workspaceId}/agents/${agentId}/api-keys`, {
|
|
7269
|
+
headers: { Authorization: `Bearer ${resolveApiKey(route2)}`, "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0" }
|
|
7270
|
+
});
|
|
7271
|
+
if (res.ok) return true;
|
|
7272
|
+
return res.status === 403 ? false : void 0;
|
|
7273
|
+
} catch {
|
|
7274
|
+
return void 0;
|
|
7275
|
+
}
|
|
7276
|
+
}
|
|
7277
|
+
|
|
7278
|
+
// src/seat-read-probe.ts
|
|
7279
|
+
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";
|
|
7280
|
+
function probeTables(route2) {
|
|
7281
|
+
const m = route2.resolved?.models;
|
|
7282
|
+
if (!m) return [];
|
|
7283
|
+
const r = route2.resolved;
|
|
7284
|
+
return [
|
|
7285
|
+
{ name: "Issues", modelId: m.issues },
|
|
7286
|
+
{ name: "Comments", modelId: m.comments },
|
|
7287
|
+
{ name: "Crew", modelId: m.crew },
|
|
7288
|
+
{ name: "Ships", modelId: r?.shipsModelId ?? "" }
|
|
7289
|
+
].filter((t) => !!t.modelId);
|
|
7290
|
+
}
|
|
7291
|
+
async function probeTableRead(route2, modelId, apiKey, o = {}) {
|
|
7292
|
+
const url = `${route2.baseUrl.replace(/\/+$/, "")}/api/data-models/${modelId}/records?limit=1`;
|
|
7293
|
+
try {
|
|
7294
|
+
const res = await (o.fetchImpl ?? fetch)(url, {
|
|
7295
|
+
headers: { Authorization: `Bearer ${apiKey}`, "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0" }
|
|
7296
|
+
});
|
|
7297
|
+
if (res.ok) return { state: "ok" };
|
|
7298
|
+
if (res.status === 403) return { state: "forbidden" };
|
|
7299
|
+
return { state: "unknown", detail: `HTTP ${res.status}` };
|
|
7300
|
+
} catch (e) {
|
|
7301
|
+
return { state: "unknown", detail: e.message };
|
|
7302
|
+
}
|
|
7303
|
+
}
|
|
7304
|
+
async function probeSeatReads(route2, o = {}) {
|
|
7305
|
+
const out = [];
|
|
7306
|
+
const roles = [...SEAT_KEY_ROLES.filter((r) => route2.resolved?.seats[r]), ...route2.resolved?.releaseSeat ? ["release"] : []];
|
|
7307
|
+
for (const role of roles) {
|
|
7308
|
+
const id = role === "release" ? await resolveReleaseKey(route2, o.getStore) : await resolveSeatKey(route2, role, o.getStore);
|
|
7309
|
+
if (id.kind !== "agent") continue;
|
|
7310
|
+
const tables = [];
|
|
7311
|
+
for (const t of probeTables(route2)) tables.push({ table: t.name, ...await probeTableRead(route2, t.modelId, id.apiKey, o) });
|
|
7312
|
+
const bad = tables.find((t) => t.state === "forbidden") ?? tables.find((t) => t.state === "unknown");
|
|
7313
|
+
out.push({ role, state: bad?.state ?? (tables.length ? "ok" : "unknown"), detail: bad?.detail ?? (tables.length ? void 0 : "route has no resolved tables"), tables });
|
|
7314
|
+
}
|
|
7315
|
+
return out;
|
|
7316
|
+
}
|
|
7317
|
+
function forbiddenDetail(e, route2) {
|
|
7318
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
7319
|
+
const m = /^([A-Z]+) (\S+) -> 403/.exec(msg);
|
|
7320
|
+
const verb = /No (READ|WRITE) access/i.exec(msg)?.[1]?.toUpperCase();
|
|
7321
|
+
const modelId = /\/data-models\/([\w-]+)/i.exec(m?.[2] ?? "")?.[1];
|
|
7322
|
+
const r = route2?.resolved;
|
|
7323
|
+
const known = [
|
|
7324
|
+
[r?.models?.issues, "Issues"],
|
|
7325
|
+
[r?.models?.comments, "Comments"],
|
|
7326
|
+
[r?.models?.crew, "Crew"],
|
|
7327
|
+
[r?.shipsModelId, "Ships"],
|
|
7328
|
+
[r?.locksModelId, "Locks"],
|
|
7329
|
+
[r?.epicsModelId, "Epics"],
|
|
7330
|
+
[r?.periscopesModelId, "Periscopes"],
|
|
7331
|
+
[r?.reposModelId, "Repos"]
|
|
7332
|
+
];
|
|
7333
|
+
const table2 = modelId ? known.find(([id]) => id && id === modelId)?.[1] ?? `table ${modelId}` : void 0;
|
|
7334
|
+
return { method: m?.[1], path: m?.[2], table: table2, verb };
|
|
7335
|
+
}
|
|
7336
|
+
function remedyFor(d, roleName = "the agent role") {
|
|
7337
|
+
if (d.table && d.verb && !(d.table === "Issues" && d.verb === "READ")) {
|
|
7338
|
+
return `grant ${d.verb} on ${d.table} to ${roleName}`;
|
|
7339
|
+
}
|
|
7340
|
+
return SEAT_NO_READ_REMEDY;
|
|
7341
|
+
}
|
|
7342
|
+
function describeSeatForbidden(role, tables = []) {
|
|
7343
|
+
const bad = tables.filter((t) => t.state === "forbidden").map((t) => t.table);
|
|
7344
|
+
const what = bad.length ? bad.join(", ") : "Issues";
|
|
7345
|
+
const remedy = bad.length && !(bad.length === 1 && bad[0] === "Issues") ? bad.map((t) => remedyFor({ table: t, verb: "READ" })).join("; ") : SEAT_NO_READ_REMEDY;
|
|
7346
|
+
return `seat read: ERROR ${role} agent key cannot read ${what} (403) \u2014 ${remedy}`;
|
|
7347
|
+
}
|
|
7348
|
+
function describeSeatTables(role, tables) {
|
|
7349
|
+
const cells = tables.map((t) => `${t.table} ${t.state === "ok" ? "ok" : t.state === "forbidden" ? "403" : `error (${t.detail ?? "?"})`}`);
|
|
7350
|
+
return `seat read: ${role}: ${cells.join(", ")}`;
|
|
7351
|
+
}
|
|
7352
|
+
function isForbiddenError(e) {
|
|
7353
|
+
const status2 = e?.status;
|
|
7354
|
+
if (status2 === 403) return true;
|
|
7355
|
+
return /\b403\b/.test(e instanceof Error ? e.message : String(e));
|
|
7356
|
+
}
|
|
7357
|
+
var SEAT_FORBIDDEN_KEY = "seat_forbidden:release";
|
|
7358
|
+
function seatForbiddenItem(now = /* @__PURE__ */ new Date(), e, route2) {
|
|
7359
|
+
const d = e === void 0 ? {} : forbiddenDetail(e, route2);
|
|
7360
|
+
const what = d.method && d.table ? `${d.method} ${d.table}${d.verb ? ` -> 403 No ${d.verb} access` : " -> 403"}` : "cannot read the Issues table (403)";
|
|
7361
|
+
return {
|
|
7362
|
+
kind: "seat_forbidden",
|
|
7363
|
+
key: SEAT_FORBIDDEN_KEY,
|
|
7364
|
+
message: `release seat: ${what} \u2014 ${remedyFor(d)}`,
|
|
7365
|
+
since: now.toISOString()
|
|
7366
|
+
};
|
|
7367
|
+
}
|
|
7368
|
+
function seatForbiddenRepoLine(scope) {
|
|
7369
|
+
return `${scope}: skipped \u2014 release seat was refused by the tracker (see attention)`;
|
|
7370
|
+
}
|
|
7371
|
+
var SEAT_WRITE_ISSUE_FIELDS = [
|
|
7372
|
+
"status",
|
|
7373
|
+
"assignee_id",
|
|
7374
|
+
"held_by_ship_id",
|
|
7375
|
+
"work_started_at",
|
|
7376
|
+
"commit_sha",
|
|
7377
|
+
"merged_at",
|
|
7378
|
+
"resolved_at",
|
|
7379
|
+
"released_version",
|
|
7380
|
+
"released_at",
|
|
7381
|
+
"resolution_note",
|
|
7382
|
+
"agent_runs",
|
|
7383
|
+
"tokens_in",
|
|
7384
|
+
"tokens_out",
|
|
7385
|
+
"cache_read_tokens",
|
|
7386
|
+
"cache_write_tokens",
|
|
7387
|
+
"cost_usd"
|
|
7388
|
+
];
|
|
7389
|
+
var canWrite = (level) => level === "WRITE" || level === "DELETE";
|
|
7390
|
+
function refusedIssueFields(body, columns = SEAT_WRITE_ISSUE_FIELDS) {
|
|
7391
|
+
const fields = body?.fields;
|
|
7392
|
+
if (!fields || typeof fields !== "object") return [];
|
|
7393
|
+
return columns.filter((c) => c in fields && !canWrite(fields[c]));
|
|
7394
|
+
}
|
|
7395
|
+
async function getJson(url, apiKey, o) {
|
|
7396
|
+
const res = await (o.fetchImpl ?? fetch)(url, {
|
|
7397
|
+
headers: { Authorization: `Bearer ${apiKey}`, "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0" }
|
|
7398
|
+
});
|
|
7399
|
+
if (!res.ok) return { status: res.status };
|
|
7400
|
+
try {
|
|
7401
|
+
return { status: res.status, body: await res.json() };
|
|
7402
|
+
} catch {
|
|
7403
|
+
return { status: 0 };
|
|
7404
|
+
}
|
|
7405
|
+
}
|
|
7406
|
+
function writeTables(route2) {
|
|
7407
|
+
const r = route2.resolved;
|
|
7408
|
+
return [
|
|
7409
|
+
{ name: "Crew", modelId: r?.models?.crew ?? "" },
|
|
7410
|
+
{ name: "Ships", modelId: r?.shipsModelId ?? "" },
|
|
7411
|
+
{ name: "Locks", modelId: r?.locksModelId ?? "" },
|
|
7412
|
+
{ name: "Periscopes", modelId: r?.periscopesModelId ?? "" }
|
|
7413
|
+
].filter((t) => !!t.modelId);
|
|
7414
|
+
}
|
|
7415
|
+
async function probeKeyWrites(route2, apiKey, o = {}) {
|
|
7416
|
+
const base = route2.baseUrl.replace(/\/+$/, "");
|
|
7417
|
+
const out = [];
|
|
7418
|
+
const issues = route2.resolved?.models?.issues;
|
|
7419
|
+
const guard = async (table2, fn) => {
|
|
7420
|
+
try {
|
|
7421
|
+
out.push(await fn());
|
|
7422
|
+
} catch (e) {
|
|
7423
|
+
out.push({ table: table2, state: "unknown", fields: [], detail: e.message });
|
|
7424
|
+
}
|
|
7425
|
+
};
|
|
7426
|
+
if (issues) {
|
|
7427
|
+
await guard("Issues", async () => {
|
|
7428
|
+
const r = await getJson(`${base}/api/data-models/${issues}/field-access`, apiKey, o);
|
|
7429
|
+
if (r.status === 403) return { table: "Issues", state: "forbidden", fields: [] };
|
|
7430
|
+
if (!r.body) return { table: "Issues", state: "unknown", fields: [], detail: `HTTP ${r.status}` };
|
|
7431
|
+
const fields = refusedIssueFields(r.body);
|
|
7432
|
+
return { table: "Issues", state: fields.length ? "forbidden" : "ok", fields };
|
|
7433
|
+
});
|
|
7434
|
+
}
|
|
7435
|
+
for (const t of writeTables(route2)) {
|
|
7436
|
+
await guard(t.name, async () => {
|
|
7437
|
+
const r = await getJson(`${base}/api/data-models/${t.modelId}/access`, apiKey, o);
|
|
7438
|
+
if (r.status === 403) return { table: t.name, state: "forbidden", fields: [] };
|
|
7439
|
+
if (!r.body) return { table: t.name, state: "unknown", fields: [], detail: `HTTP ${r.status}` };
|
|
7440
|
+
return { table: t.name, state: canWrite(r.body.access) ? "ok" : "forbidden", fields: [] };
|
|
7441
|
+
});
|
|
7442
|
+
}
|
|
7443
|
+
return out;
|
|
7444
|
+
}
|
|
7445
|
+
async function probeSeatWrites(route2, o = {}) {
|
|
7446
|
+
const out = [];
|
|
7447
|
+
const all = [...SEAT_KEY_ROLES.filter((r) => route2.resolved?.seats[r]), ...route2.resolved?.releaseSeat ? ["release"] : []];
|
|
7448
|
+
const roles = o.only ? all.filter((r) => r === o.only) : all;
|
|
7449
|
+
for (const role of roles) {
|
|
7450
|
+
const id = role === "release" ? await resolveReleaseKey(route2, o.getStore) : await resolveSeatKey(route2, role, o.getStore);
|
|
7451
|
+
if (id.kind !== "agent") continue;
|
|
7452
|
+
const tables = await probeKeyWrites(route2, id.apiKey, o);
|
|
7453
|
+
const bad = tables.find((t) => t.state === "forbidden") ?? tables.find((t) => t.state === "unknown");
|
|
7454
|
+
out.push({ role, state: bad?.state ?? "ok", tables });
|
|
7455
|
+
}
|
|
7456
|
+
return out;
|
|
7457
|
+
}
|
|
7458
|
+
function describeWriteRefusals(tables) {
|
|
7459
|
+
return tables.filter((t) => t.state === "forbidden").map((t) => t.fields.length ? `${t.table} (${t.fields.join(", ")})` : t.table).join(", ");
|
|
7460
|
+
}
|
|
7461
|
+
var SEAT_NO_WRITE_REMEDY = "raise the grants on that role to WRITE, or point the project's default agent role at the Issues app template's";
|
|
7462
|
+
function seatCannotWriteMessage(role, tables) {
|
|
7463
|
+
return `${role} agent key cannot write ${describeWriteRefusals(tables)}`;
|
|
7464
|
+
}
|
|
7465
|
+
var seatCannotWriteKey = (role) => `seat_cannot_write:${role}`;
|
|
7466
|
+
function seatCannotWriteItem(role, detail, nowIso = (/* @__PURE__ */ new Date()).toISOString()) {
|
|
7467
|
+
return { kind: "seat_cannot_write", key: seatCannotWriteKey(role), since: nowIso, message: `${role} seat: ${detail} (403) \u2014 ${SEAT_NO_WRITE_REMEDY}` };
|
|
7468
|
+
}
|
|
7469
|
+
async function seatWriteStillRefused(route2, role, o = {}) {
|
|
7470
|
+
try {
|
|
7471
|
+
const [r] = await probeSeatWrites(route2, { ...o, only: role });
|
|
7472
|
+
return !!r && r.state !== "ok";
|
|
7473
|
+
} catch {
|
|
7474
|
+
return true;
|
|
7475
|
+
}
|
|
7476
|
+
}
|
|
7477
|
+
|
|
6982
7478
|
// src/environment.ts
|
|
6983
7479
|
import { basename as basename6 } from "path";
|
|
6984
7480
|
|
|
@@ -8223,6 +8719,22 @@ function isTransportFailure(isError, text2) {
|
|
|
8223
8719
|
}
|
|
8224
8720
|
return TRANSPORT_FAILURE.test(text2);
|
|
8225
8721
|
}
|
|
8722
|
+
var FORBIDDEN_WRITE = /\b403\b[^]*?No WRITE access/i;
|
|
8723
|
+
function isForbiddenWrite(isError, text2) {
|
|
8724
|
+
if (FORBIDDEN_WRITE.test(text2)) return true;
|
|
8725
|
+
if (!isError) {
|
|
8726
|
+
try {
|
|
8727
|
+
const doc = JSON.parse(text2);
|
|
8728
|
+
return !!doc && typeof doc === "object" && doc.statusCode === 403;
|
|
8729
|
+
} catch {
|
|
8730
|
+
}
|
|
8731
|
+
}
|
|
8732
|
+
return false;
|
|
8733
|
+
}
|
|
8734
|
+
function forbiddenDetail403(text2) {
|
|
8735
|
+
const m = /No WRITE access to (?:"[^"]+"|this model|[\w-]+)/i.exec(text2);
|
|
8736
|
+
return m ? m[0] : "a hand-off write was refused";
|
|
8737
|
+
}
|
|
8226
8738
|
var HandoffTranscriptTracker = class {
|
|
8227
8739
|
open = /* @__PURE__ */ new Map();
|
|
8228
8740
|
/** Per ticket record: the last hand-off status write's outcome. */
|
|
@@ -8230,6 +8742,9 @@ var HandoffTranscriptTracker = class {
|
|
|
8230
8742
|
/** Per ticket record: the last hand-off comment that failed after the status write's turn. */
|
|
8231
8743
|
comment = /* @__PURE__ */ new Map();
|
|
8232
8744
|
landed = /* @__PURE__ */ new Set();
|
|
8745
|
+
/** Per ticket record: the last hand-off status write, when the tracker refused it with 403. */
|
|
8746
|
+
forbiddenWrites = /* @__PURE__ */ new Map();
|
|
8747
|
+
statusLanded = false;
|
|
8233
8748
|
lastText = "";
|
|
8234
8749
|
resultLine = "";
|
|
8235
8750
|
feed(parsed) {
|
|
@@ -8250,7 +8765,16 @@ var HandoffTranscriptTracker = class {
|
|
|
8250
8765
|
const w = this.open.get(b.tool_use_id);
|
|
8251
8766
|
if (!w) continue;
|
|
8252
8767
|
this.open.delete(b.tool_use_id);
|
|
8253
|
-
const
|
|
8768
|
+
const text2 = resultText(b.content);
|
|
8769
|
+
if (w.kind === "handoff") {
|
|
8770
|
+
const refused = isForbiddenWrite(b.is_error === true, text2);
|
|
8771
|
+
if (refused) this.forbiddenWrites.set(w.recordId, forbiddenDetail403(text2));
|
|
8772
|
+
else {
|
|
8773
|
+
this.forbiddenWrites.delete(w.recordId);
|
|
8774
|
+
if (!isTransportFailure(b.is_error === true, text2)) this.statusLanded = true;
|
|
8775
|
+
}
|
|
8776
|
+
}
|
|
8777
|
+
const failed = isTransportFailure(b.is_error === true, text2);
|
|
8254
8778
|
if (w.kind === "handoff") this.status.set(w.recordId, { ok: !failed, patch: w.body });
|
|
8255
8779
|
else if (failed && typeof w.body.body === "string") this.comment.set(w.recordId, w.body.body);
|
|
8256
8780
|
else if (!failed) {
|
|
@@ -8260,6 +8784,15 @@ var HandoffTranscriptTracker = class {
|
|
|
8260
8784
|
}
|
|
8261
8785
|
}
|
|
8262
8786
|
}
|
|
8787
|
+
/** The newest hand-off write the tracker refused with 403 and no later success for that ticket, else null. */
|
|
8788
|
+
forbidden() {
|
|
8789
|
+
const last = [...this.forbiddenWrites].at(-1);
|
|
8790
|
+
return last ? { ticketRecordId: last[0], detail: last[1] } : null;
|
|
8791
|
+
}
|
|
8792
|
+
/** A hand-off status write went through, so the seat can write what a hand-off needs. */
|
|
8793
|
+
handoffLanded() {
|
|
8794
|
+
return this.statusLanded && this.forbiddenWrites.size === 0;
|
|
8795
|
+
}
|
|
8263
8796
|
/** The hand-off that did not land, or null when every hand-off write succeeded (or none was tried). */
|
|
8264
8797
|
pending() {
|
|
8265
8798
|
const failed = [...this.status].filter(([, s2]) => !s2.ok).at(-1);
|
|
@@ -9683,18 +10216,28 @@ async function missingAcceptance(i, t) {
|
|
|
9683
10216
|
if (lines2.length === 0 || !i.writer.comments) return null;
|
|
9684
10217
|
const cc = i.contract.comments;
|
|
9685
10218
|
const mine = (await i.writer.comments()).map((c) => c).filter((r) => r[cc.parent] === t.id && r[cc.author] === i.seatId).sort((a, b) => String(a[cc.createdAt] ?? "").localeCompare(String(b[cc.createdAt] ?? "")));
|
|
9686
|
-
const
|
|
9687
|
-
const
|
|
9688
|
-
|
|
10219
|
+
const from = i.since ? Date.parse(i.since) : NaN;
|
|
10220
|
+
const recent = Number.isNaN(from) ? [] : mine.filter((r) => Date.parse(String(r[cc.createdAt] ?? "")) >= from);
|
|
10221
|
+
const candidates2 = recent.length > 0 ? recent : mine.slice(-1);
|
|
10222
|
+
if (candidates2.length === 0) return { missing: lines2, judged: null };
|
|
10223
|
+
let best = null;
|
|
10224
|
+
for (const c of candidates2) {
|
|
10225
|
+
const missing = unquotedAcceptance(lines2, String(c[cc.body] ?? ""));
|
|
10226
|
+
if (missing.length === 0) return null;
|
|
10227
|
+
if (!best || missing.length <= best.missing.length) best = { missing, judged: String(c[cc.createdAt] ?? "") || null };
|
|
10228
|
+
}
|
|
10229
|
+
return best;
|
|
9689
10230
|
}
|
|
9690
|
-
function acceptanceBounceComment(missing, shipName2) {
|
|
10231
|
+
function acceptanceBounceComment(missing, shipName2, judged) {
|
|
9691
10232
|
return `${ACCEPTANCE_BOUNCED_MARKER}
|
|
9692
10233
|
|
|
9693
10234
|
**Hand-off bounced: the hand-off comment does not quote every line of the ticket's Acceptance section.**
|
|
9694
10235
|
|
|
9695
10236
|
${shipName2 ? `Ship: ${shipName2}
|
|
9696
10237
|
|
|
9697
|
-
` : ""}
|
|
10238
|
+
` : ""}${judged ? `Judged the comment posted at ${judged}; no comment this run quoted every line.
|
|
10239
|
+
|
|
10240
|
+
` : "This seat has no comment on the ticket.\n\n"}Not quoted verbatim:
|
|
9698
10241
|
${missing.map((l) => `- ${l}`).join("\n")}
|
|
9699
10242
|
|
|
9700
10243
|
Set back to \`in_progress\`. Post a comment that lists every Acceptance line verbatim, each followed by the test, command or manual check that exercised it in this run and its result (a line you could not exercise is listed with the reason, never omitted), then set \`fixed\` again. The branch is already pushed.`;
|
|
@@ -9715,8 +10258,8 @@ async function enforceHandoff(i) {
|
|
|
9715
10258
|
return { kind: "unverified", why: v.why };
|
|
9716
10259
|
}
|
|
9717
10260
|
if (v.kind === "pushed" && !claimed) {
|
|
9718
|
-
const
|
|
9719
|
-
if (
|
|
10261
|
+
const gap = await missingAcceptance(i, t);
|
|
10262
|
+
if (gap) {
|
|
9720
10263
|
const heldNow = i.hold ? t[i.hold.column] : void 0;
|
|
9721
10264
|
if (i.hold && heldNow && heldNow !== i.hold.shipId || await takenOverSince(i, t.id)) {
|
|
9722
10265
|
i.log.warn(`${t.issue_id} is held by another ship now \u2014 the acceptance check is stale, left alone`, log);
|
|
@@ -9724,10 +10267,11 @@ async function enforceHandoff(i) {
|
|
|
9724
10267
|
}
|
|
9725
10268
|
await i.writer.updateTicket(t.id, {
|
|
9726
10269
|
status: i.contract.statuses.building,
|
|
9727
|
-
|
|
10270
|
+
// CREW-2962: the hand-off may have assigned QA's seat; a bounce is the building seat's again.
|
|
10271
|
+
assignee_id: i.seatId,
|
|
9728
10272
|
...i.hold && !heldNow ? { [i.hold.column]: i.hold.shipId } : {}
|
|
9729
10273
|
});
|
|
9730
|
-
await i.writer.postEvent(t.id, acceptanceBounceComment(missing, i.shipName), i.seatId, "bounced").catch(() => {
|
|
10274
|
+
await i.writer.postEvent(t.id, acceptanceBounceComment(gap.missing, i.shipName, gap.judged), i.seatId, "bounced").catch(() => {
|
|
9731
10275
|
i.log.warn("bounced, but the note failed to post", log);
|
|
9732
10276
|
});
|
|
9733
10277
|
i.log.warn(`${t.issue_id} bounced back to ${i.contract.statuses.building}: hand-off comment lacks the acceptance checklist`, log);
|
|
@@ -9781,7 +10325,7 @@ async function enforceHandoff(i) {
|
|
|
9781
10325
|
}
|
|
9782
10326
|
await i.writer.updateTicket(t.id, {
|
|
9783
10327
|
status: i.contract.statuses.building,
|
|
9784
|
-
|
|
10328
|
+
assignee_id: i.seatId,
|
|
9785
10329
|
...i.hold && !heldBy ? { [i.hold.column]: i.hold.shipId } : {}
|
|
9786
10330
|
});
|
|
9787
10331
|
await i.writer.postEvent(t.id, bounceComment(v, i.shipName, pushError), i.seatId, "bounced").catch(() => {
|
|
@@ -9921,7 +10465,7 @@ ${i.shipName ? `${i.shipName}'s ` : "A "}${i.role} run set it to \`${st.handoff}
|
|
|
9921
10465
|
i.log.warn(`${t.issue_id}: \`${v.remote}/${v.branch}\` is at ${v.origin.slice(0, 7)}, not a tip this checkout built \u2014 left alone`, at);
|
|
9922
10466
|
continue;
|
|
9923
10467
|
}
|
|
9924
|
-
await i.writer.updateTicket(t.id, { status: st.building,
|
|
10468
|
+
await i.writer.updateTicket(t.id, { status: st.building, assignee_id: i.seatId });
|
|
9925
10469
|
await i.writer.postEvent(t.id, bounceComment(v, i.shipName), i.seatId, "bounced").catch(() => {
|
|
9926
10470
|
});
|
|
9927
10471
|
}
|
|
@@ -11678,6 +12222,18 @@ async function finishRun(id, obs, emit, startedAt, end, agentLogKey, hintMoved)
|
|
|
11678
12222
|
emit.warn(`could not save the pending hand-off: ${e.message}`, { step: "agent" });
|
|
11679
12223
|
}
|
|
11680
12224
|
}
|
|
12225
|
+
const forbidden = end.interrupted ? null : obs.handoff.forbidden();
|
|
12226
|
+
if (forbidden) {
|
|
12227
|
+
try {
|
|
12228
|
+
await raiseShipAttention({ state: new State(stateDirOfStream(id.streamPath)), emit }, seatCannotWriteItem(id.role, forbidden.detail));
|
|
12229
|
+
} catch {
|
|
12230
|
+
}
|
|
12231
|
+
} else if (!end.interrupted && obs.handoff.handoffLanded()) {
|
|
12232
|
+
try {
|
|
12233
|
+
clearShipAttention(new State(stateDirOfStream(id.streamPath)), seatCannotWriteKey(id.role));
|
|
12234
|
+
} catch {
|
|
12235
|
+
}
|
|
12236
|
+
}
|
|
11681
12237
|
const before = obs.beforeFirstEdit.result();
|
|
11682
12238
|
try {
|
|
11683
12239
|
writeRunMeta(id, startedAt, obs.liveTicket, {
|
|
@@ -11701,6 +12257,7 @@ async function finishRun(id, obs, emit, startedAt, end, agentLogKey, hintMoved)
|
|
|
11701
12257
|
ms: ms3,
|
|
11702
12258
|
code: end.code,
|
|
11703
12259
|
...pending ? { handoff: "pending" } : {},
|
|
12260
|
+
...forbidden ? { reason: "forbidden", forbidden: forbidden.detail } : {},
|
|
11704
12261
|
...end.interrupted ? { interrupted: end.interrupted } : {},
|
|
11705
12262
|
...identity,
|
|
11706
12263
|
attributedTicket: attributedTicket ?? null,
|
|
@@ -11717,7 +12274,7 @@ async function finishRun(id, obs, emit, startedAt, end, agentLogKey, hintMoved)
|
|
|
11717
12274
|
if (id.agentLog && agentLogKey) {
|
|
11718
12275
|
const report = {
|
|
11719
12276
|
ticketReference: attributedTicket,
|
|
11720
|
-
outcome: end.interrupted ? "interrupted" : signedOut ? "error" : end.code === 0 || end.code === null && obs.sawResult ? "success" : "error",
|
|
12277
|
+
outcome: end.interrupted ? "interrupted" : signedOut || forbidden ? "error" : end.code === 0 || end.code === null && obs.sawResult ? "success" : "error",
|
|
11721
12278
|
startedAt,
|
|
11722
12279
|
finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
11723
12280
|
cycles: obs.cycles,
|
|
@@ -11927,496 +12484,200 @@ async function reattachRun(streamPath, sc, emit, o = {}) {
|
|
|
11927
12484
|
o.onAdopted?.();
|
|
11928
12485
|
const pollMs = o.pollMs ?? TAIL_POLL_MS;
|
|
11929
12486
|
while (isAlive(sc)) {
|
|
11930
|
-
obs.feed(tail3.poll());
|
|
11931
|
-
if (!killed && (msUntilDeadline(sc.deadline) ?? 1) <= 0) {
|
|
11932
|
-
killed = await killForDeadline(() => runner.kill(runner.reattach(sc), WALL_CLOCK_REASON), sc.pid, emit, sc.role, obs.liveTicket ?? sc.ticket, sc.spawnedAt, sc.maxRunMinutes);
|
|
11933
|
-
continue;
|
|
11934
|
-
}
|
|
11935
|
-
await new Promise((r) => setTimeout(r, pollMs));
|
|
11936
|
-
}
|
|
11937
|
-
}
|
|
11938
|
-
obs.feed(tail3.poll());
|
|
11939
|
-
obs.end();
|
|
11940
|
-
await endSink(eventsSink);
|
|
11941
|
-
await runner.release(runner.reattach(sc), emit);
|
|
11942
|
-
const interrupted = killed ? WALL_CLOCK_REASON : obs.sawResult ? void 0 : adopted ? "no-result" : "daemon-exit";
|
|
11943
|
-
const { ms: ms3, ticket } = await finishRun(
|
|
11944
|
-
id,
|
|
11945
|
-
obs,
|
|
11946
|
-
emit,
|
|
11947
|
-
sc.spawnedAt,
|
|
11948
|
-
{ code: null, ...interrupted ? { interrupted } : {} },
|
|
11949
|
-
o.agentLogKey
|
|
11950
|
-
);
|
|
11951
|
-
return { adopted, ...interrupted ? { interrupted } : {}, ...killed ? { killed } : {}, ms: ms3, ticket };
|
|
11952
|
-
}
|
|
11953
|
-
|
|
11954
|
-
// src/interrupted.ts
|
|
11955
|
-
var INTERRUPTED_MARKER = calloutHeader("NOTE", "Interrupted");
|
|
11956
|
-
function interruptedComment(b) {
|
|
11957
|
-
const tree = b.dirty === null ? "The worktree could not be checked." : b.dirty ? "The worktree has uncommitted changes \u2014 they are this seat's own, from that run." : "The worktree is clean.";
|
|
11958
|
-
return `${INTERRUPTED_MARKER}
|
|
11959
|
-
|
|
11960
|
-
The agent run on this ticket was cut short (${b.reason}) before it handed on${b.branch ? `; its branch is \`${b.branch}\`` : ""}. ${tree} The next run on this seat resumes from the worktree rather than starting over.`;
|
|
11961
|
-
}
|
|
11962
|
-
var handled = /* @__PURE__ */ new Set();
|
|
11963
|
-
var pidAlive = (pid) => {
|
|
11964
|
-
try {
|
|
11965
|
-
process.kill(pid, 0);
|
|
11966
|
-
return true;
|
|
11967
|
-
} catch (e) {
|
|
11968
|
-
return e.code === "EPERM";
|
|
11969
|
-
}
|
|
11970
|
-
};
|
|
11971
|
-
function runFinished(streamPath) {
|
|
11972
|
-
const path = `${streamPath}.meta.json`;
|
|
11973
|
-
if (!existsSync27(path)) return false;
|
|
11974
|
-
try {
|
|
11975
|
-
return JSON.parse(readFileSync27(path, "utf8")).running !== true;
|
|
11976
|
-
} catch {
|
|
11977
|
-
return true;
|
|
11978
|
-
}
|
|
11979
|
-
}
|
|
11980
|
-
function orphanedStreams(o) {
|
|
11981
|
-
const dir = join30(o.stateDir, "streams");
|
|
11982
|
-
if (!existsSync27(dir)) return [];
|
|
11983
|
-
const ownerAlive = o.isOwnerAlive ?? pidAlive;
|
|
11984
|
-
const out = [];
|
|
11985
|
-
for (const name of readdirSync9(dir)) {
|
|
11986
|
-
if (!name.endsWith(".jsonl") || name.endsWith(".events.jsonl")) continue;
|
|
11987
|
-
const streamPath = join30(dir, name);
|
|
11988
|
-
if (runFinished(streamPath)) continue;
|
|
11989
|
-
const sidecar = readRunSidecar(streamPath);
|
|
11990
|
-
if (!sidecar || sidecar.route !== o.routeLabel) continue;
|
|
11991
|
-
if (ownerAlive(sidecar.ownerPid)) continue;
|
|
11992
|
-
out.push({ streamPath, sidecar });
|
|
11993
|
-
}
|
|
11994
|
-
return out;
|
|
11995
|
-
}
|
|
11996
|
-
async function reconcileRuns(o) {
|
|
11997
|
-
let adopted = 0;
|
|
11998
|
-
let interrupted = 0;
|
|
11999
|
-
for (const { streamPath, sidecar } of orphanedStreams(o)) {
|
|
12000
|
-
if (handled.has(streamPath)) continue;
|
|
12001
|
-
handled.add(streamPath);
|
|
12002
|
-
const isAlive = o.isAlive ?? runIsAlive;
|
|
12003
|
-
const lock = isAlive(sidecar) ? o.state.acquireRun(sidecar.role, o.maxConcurrentAgents, o.roleConcurrency?.[sidecar.role] ?? 1) : null;
|
|
12004
|
-
const ticketLock = isAlive(sidecar) && sidecar.ticketRecordId ? o.state.acquireTicket(sidecar.ticketRecordId) : null;
|
|
12005
|
-
const work = (async () => {
|
|
12006
|
-
try {
|
|
12007
|
-
const result = await reattachRun(streamPath, sidecar, o.emit, {
|
|
12008
|
-
agentLogKey: await o.agentLogKey?.(sidecar.role).catch(() => void 0),
|
|
12009
|
-
isAlive: o.isAlive,
|
|
12010
|
-
pollMs: o.pollMs,
|
|
12011
|
-
onAdopted: () => o.emit.emit(`adopted ${sidecar.role} run (pid ${sidecar.pid}) left by a previous crew process`, { step: "agent", role: sidecar.role })
|
|
12012
|
-
});
|
|
12013
|
-
if (result.adopted) adopted++;
|
|
12014
|
-
else interrupted++;
|
|
12015
|
-
try {
|
|
12016
|
-
await o.afterRun?.(sidecar, result);
|
|
12017
|
-
} catch (e) {
|
|
12018
|
-
o.emit.warn(`could not follow up on ${sidecar.role} run ${streamPath}: ${e.message}`, { step: "agent" });
|
|
12019
|
-
}
|
|
12020
|
-
} catch (e) {
|
|
12021
|
-
o.emit.warn(`could not reattach ${sidecar.role} run ${streamPath}: ${e.message}`, { step: "agent" });
|
|
12022
|
-
} finally {
|
|
12023
|
-
if (lock?.ok) lock.release();
|
|
12024
|
-
if (ticketLock?.ok) ticketLock.release();
|
|
12025
|
-
}
|
|
12026
|
-
})();
|
|
12027
|
-
if (isAlive(sidecar)) {
|
|
12028
|
-
if (o.inFlight) {
|
|
12029
|
-
o.inFlight.add(work);
|
|
12030
|
-
void work.finally(() => o.inFlight.delete(work));
|
|
12031
|
-
}
|
|
12032
|
-
} else {
|
|
12033
|
-
await work;
|
|
12034
|
-
}
|
|
12035
|
-
}
|
|
12036
|
-
return { adopted, interrupted };
|
|
12037
|
-
}
|
|
12038
|
-
|
|
12039
|
-
// src/live-runs.ts
|
|
12040
|
-
import { existsSync as existsSync28, readdirSync as readdirSync10, statSync as statSync9 } from "fs";
|
|
12041
|
-
import { join as join31 } from "path";
|
|
12042
|
-
function liveRuns(stateDir, isAlive = runIsAlive) {
|
|
12043
|
-
const dir = join31(stateDir, "streams");
|
|
12044
|
-
if (!existsSync28(dir)) return [];
|
|
12045
|
-
const out = [];
|
|
12046
|
-
for (const name of readdirSync10(dir)) {
|
|
12047
|
-
if (!name.endsWith(".jsonl") || name.endsWith(".events.jsonl")) continue;
|
|
12048
|
-
const streamPath = join31(dir, name);
|
|
12049
|
-
if (runFinished(streamPath)) continue;
|
|
12050
|
-
const sidecar = readRunSidecar(streamPath);
|
|
12051
|
-
if (!sidecar || !isAlive(sidecar)) continue;
|
|
12052
|
-
out.push({ ...sidecar, streamPath });
|
|
12053
|
-
}
|
|
12054
|
-
return out.sort((a, b) => a.spawnedAt.localeCompare(b.spawnedAt));
|
|
12055
|
-
}
|
|
12056
|
-
function runActivity(stateDir, isAlive = runIsAlive) {
|
|
12057
|
-
const activeRuns = liveRuns(stateDir, isAlive).length;
|
|
12058
|
-
const dir = join31(stateDir, "streams");
|
|
12059
|
-
let newest = 0;
|
|
12060
|
-
if (existsSync28(dir)) {
|
|
12061
|
-
for (const name of readdirSync10(dir)) {
|
|
12062
|
-
if (!name.endsWith(".jsonl") || name.endsWith(".events.jsonl")) continue;
|
|
12063
|
-
const streamPath = join31(dir, name);
|
|
12064
|
-
if (!runFinished(streamPath)) continue;
|
|
12065
|
-
try {
|
|
12066
|
-
newest = Math.max(newest, statSync9(`${streamPath}.meta.json`).mtimeMs);
|
|
12067
|
-
} catch {
|
|
12068
|
-
}
|
|
12069
|
-
}
|
|
12070
|
-
}
|
|
12071
|
-
return { activeRuns, lastRunEndedAt: newest ? new Date(newest).toISOString() : null };
|
|
12072
|
-
}
|
|
12073
|
-
function siblingRuns(runs, ownTicketRecordId) {
|
|
12074
|
-
return runs.filter((r) => !ownTicketRecordId || r.ticketRecordId !== ownTicketRecordId);
|
|
12075
|
-
}
|
|
12076
|
-
async function settleRunEnd(o) {
|
|
12077
|
-
const { tracker, emit } = o;
|
|
12078
|
-
const all = o.runs ?? liveRuns(o.stateDir);
|
|
12079
|
-
const slug2 = routeSlug(o.route.route);
|
|
12080
|
-
const seatSiblings = siblingRuns(all.filter((r) => r.role === o.role && r.route === slug2), o.ownTicketRecordId);
|
|
12081
|
-
const shipSiblings = siblingRuns(all, o.ownTicketRecordId);
|
|
12082
|
-
const newest = (rs) => rs[rs.length - 1];
|
|
12083
|
-
if (o.memberId) {
|
|
12084
|
-
try {
|
|
12085
|
-
if (seatSiblings.length) await tracker.setCrewStatus(o.memberId, "working", newest(seatSiblings).ticketRecordId);
|
|
12086
|
-
else await tracker.setCrewStatus(o.memberId, "idle");
|
|
12087
|
-
} catch (e) {
|
|
12088
|
-
emit.warn(`could not clear crew status: ${e.message}`, { step: "agent" });
|
|
12089
|
-
}
|
|
12090
|
-
}
|
|
12091
|
-
try {
|
|
12092
|
-
if (shipSiblings.length) {
|
|
12093
|
-
const n = newest(shipSiblings);
|
|
12094
|
-
await tracker.beatEngaged(o.shipName, n.routeName ?? n.route, n.ticketRecordId, n.spawnedAt);
|
|
12095
|
-
} else {
|
|
12096
|
-
await tracker.beatIdle(o.shipName);
|
|
12097
|
-
}
|
|
12098
|
-
} catch (e) {
|
|
12099
|
-
emit.warn(`could not beat ship idle: ${e.message}`, { step: "agent" });
|
|
12100
|
-
}
|
|
12101
|
-
}
|
|
12102
|
-
function describeRun(run3, route2, seatIds, worktreeOf = (d, n) => {
|
|
12103
|
-
try {
|
|
12104
|
-
return worktreeForNumber(d, n)?.path ?? null;
|
|
12105
|
-
} catch {
|
|
12106
|
-
return null;
|
|
12107
|
-
}
|
|
12108
|
-
}) {
|
|
12109
|
-
const ownRoute = run3.route === routeSlug(route2.route);
|
|
12110
|
-
const repoDir = run3.repo ? reposOf(route2).find((t) => t.name === run3.repo)?.dir : void 0;
|
|
12111
|
-
const num = run3.ticket?.replace(/^\D+/, "") ?? "";
|
|
12112
|
-
return {
|
|
12113
|
-
role: run3.role,
|
|
12114
|
-
seat: ownRoute ? seatIds[run3.role] ?? null : null,
|
|
12115
|
-
ticket: run3.ticket,
|
|
12116
|
-
ticketRecordId: run3.ticketRecordId,
|
|
12117
|
-
repo: run3.repo ?? null,
|
|
12118
|
-
worktree: repoDir && num ? worktreeOf(repoDir, num) : null,
|
|
12119
|
-
stream: run3.streamPath,
|
|
12120
|
-
pid: run3.pid,
|
|
12121
|
-
startedAt: run3.spawnedAt,
|
|
12122
|
-
deadline: run3.deadline ?? null
|
|
12123
|
-
};
|
|
12124
|
-
}
|
|
12125
|
-
|
|
12126
|
-
// src/seat-keys.ts
|
|
12127
|
-
import { hostFromUrl as hostFromUrl2 } from "@tablation/client";
|
|
12128
|
-
var SEAT_KEY_ROLES = ROLE_NAMES.filter((r) => r !== "pair");
|
|
12129
|
-
function keyIsAdmin(route2) {
|
|
12130
|
-
const role = route2.resolved?.role;
|
|
12131
|
-
return role === void 0 || role === "WORKSPACE_ADMIN" || role === "PLATFORM_ADMIN";
|
|
12132
|
-
}
|
|
12133
|
-
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";
|
|
12134
|
-
var MEMBER_NO_KEY = 'no agent key for this seat \u2014 run `crew agents sync` (needs the "Mint agent keys" capability on your role)';
|
|
12135
|
-
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)';
|
|
12136
|
-
function seatKeySlug(workspaceSlug, agentId) {
|
|
12137
|
-
return `${workspaceSlug}#agent:${agentId}`;
|
|
12138
|
-
}
|
|
12139
|
-
function slugFor(route2, agentId) {
|
|
12140
|
-
const [workspaceSlug] = splitRoute(route2.route);
|
|
12141
|
-
return seatKeySlug(workspaceSlug, agentId);
|
|
12142
|
-
}
|
|
12143
|
-
function withFoundSeats(route2, found, previousReleaseAgentId) {
|
|
12144
|
-
const resolved = route2.resolved;
|
|
12145
|
-
if (!resolved) return route2;
|
|
12146
|
-
const releaseSeat2 = found.releaseSeat ?? resolved.releaseSeat;
|
|
12147
|
-
const releaseAgentId = resolved.releaseAgentId ?? previousReleaseAgentId;
|
|
12148
|
-
return {
|
|
12149
|
-
...route2,
|
|
12150
|
-
resolved: {
|
|
12151
|
-
...resolved,
|
|
12152
|
-
seats: found.seats,
|
|
12153
|
-
...releaseSeat2 ? { releaseSeat: releaseSeat2 } : {},
|
|
12154
|
-
...releaseAgentId ? { releaseAgentId } : {}
|
|
12487
|
+
obs.feed(tail3.poll());
|
|
12488
|
+
if (!killed && (msUntilDeadline(sc.deadline) ?? 1) <= 0) {
|
|
12489
|
+
killed = await killForDeadline(() => runner.kill(runner.reattach(sc), WALL_CLOCK_REASON), sc.pid, emit, sc.role, obs.liveTicket ?? sc.ticket, sc.spawnedAt, sc.maxRunMinutes);
|
|
12490
|
+
continue;
|
|
12491
|
+
}
|
|
12492
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
12155
12493
|
}
|
|
12156
|
-
}
|
|
12494
|
+
}
|
|
12495
|
+
obs.feed(tail3.poll());
|
|
12496
|
+
obs.end();
|
|
12497
|
+
await endSink(eventsSink);
|
|
12498
|
+
await runner.release(runner.reattach(sc), emit);
|
|
12499
|
+
const interrupted = killed ? WALL_CLOCK_REASON : obs.sawResult ? void 0 : adopted ? "no-result" : "daemon-exit";
|
|
12500
|
+
const { ms: ms3, ticket } = await finishRun(
|
|
12501
|
+
id,
|
|
12502
|
+
obs,
|
|
12503
|
+
emit,
|
|
12504
|
+
sc.spawnedAt,
|
|
12505
|
+
{ code: null, ...interrupted ? { interrupted } : {} },
|
|
12506
|
+
o.agentLogKey
|
|
12507
|
+
);
|
|
12508
|
+
return { adopted, ...interrupted ? { interrupted } : {}, ...killed ? { killed } : {}, ms: ms3, ticket };
|
|
12157
12509
|
}
|
|
12158
|
-
|
|
12159
|
-
|
|
12510
|
+
|
|
12511
|
+
// src/interrupted.ts
|
|
12512
|
+
var INTERRUPTED_MARKER = calloutHeader("NOTE", "Interrupted");
|
|
12513
|
+
function interruptedComment(b) {
|
|
12514
|
+
const tree = b.dirty === null ? "The worktree could not be checked." : b.dirty ? "The worktree has uncommitted changes \u2014 they are this seat's own, from that run." : "The worktree is clean.";
|
|
12515
|
+
return `${INTERRUPTED_MARKER}
|
|
12516
|
+
|
|
12517
|
+
The agent run on this ticket was cut short (${b.reason}) before it handed on${b.branch ? `; its branch is \`${b.branch}\`` : ""}. ${tree} The next run on this seat resumes from the worktree rather than starting over.`;
|
|
12160
12518
|
}
|
|
12161
|
-
|
|
12162
|
-
|
|
12163
|
-
const agentId = route2.resolved?.releaseAgentId;
|
|
12164
|
-
if (!agentId) return ship(keyIsAdmin(route2) ? "release seat has no linked Agents row (run `crew agents sync`)" : `release seat ${MEMBER_NO_ROW}`);
|
|
12519
|
+
var handled = /* @__PURE__ */ new Set();
|
|
12520
|
+
var pidAlive = (pid) => {
|
|
12165
12521
|
try {
|
|
12166
|
-
|
|
12167
|
-
|
|
12168
|
-
return ship(keyIsAdmin(route2) ? "no agent key stored for the release seat (run `crew agents sync`)" : MEMBER_NO_KEY);
|
|
12522
|
+
process.kill(pid, 0);
|
|
12523
|
+
return true;
|
|
12169
12524
|
} catch (e) {
|
|
12170
|
-
|
|
12171
|
-
return { kind: "ship", apiKey: resolveApiKey(route2), reason, storeError: reason };
|
|
12525
|
+
return e.code === "EPERM";
|
|
12172
12526
|
}
|
|
12173
|
-
}
|
|
12174
|
-
|
|
12175
|
-
const
|
|
12176
|
-
if (!
|
|
12177
|
-
const agentId = seatAgentId(route2, role);
|
|
12178
|
-
if (!agentId) return ship(keyIsAdmin(route2) ? "seat has no linked Agents row (run `crew agents sync`)" : `seat ${MEMBER_NO_ROW}`);
|
|
12527
|
+
};
|
|
12528
|
+
function runFinished(streamPath) {
|
|
12529
|
+
const path = `${streamPath}.meta.json`;
|
|
12530
|
+
if (!existsSync27(path)) return false;
|
|
12179
12531
|
try {
|
|
12180
|
-
|
|
12181
|
-
|
|
12182
|
-
return
|
|
12183
|
-
} catch (e) {
|
|
12184
|
-
const reason = describeReadError(e);
|
|
12185
|
-
return { kind: "ship", apiKey: resolveApiKey(route2), reason, storeError: reason };
|
|
12186
|
-
}
|
|
12187
|
-
}
|
|
12188
|
-
function safeStderrLine(e) {
|
|
12189
|
-
const err3 = e;
|
|
12190
|
-
const raw = typeof err3?.stderr === "string" && err3.stderr.trim() ? err3.stderr : typeof err3?.message === "string" ? err3.message : "";
|
|
12191
|
-
for (const line of raw.split("\n")) {
|
|
12192
|
-
const t = line.trim();
|
|
12193
|
-
if (!t || /^Command failed:/i.test(t) || /(^|\s)-w(\s|$)/.test(t)) continue;
|
|
12194
|
-
return t.slice(0, 120);
|
|
12195
|
-
}
|
|
12196
|
-
return void 0;
|
|
12197
|
-
}
|
|
12198
|
-
function describeReadError(e) {
|
|
12199
|
-
const fsErr = e;
|
|
12200
|
-
if (typeof fsErr?.path === "string" && typeof fsErr.code === "string") return `key file store unreadable (${fsErr.code})`;
|
|
12201
|
-
const code = e?.code;
|
|
12202
|
-
const line = safeStderrLine(e);
|
|
12203
|
-
const what = typeof code === "number" ? `security exited ${code}` : typeof code === "string" ? `security failed: ${code}` : void 0;
|
|
12204
|
-
const detail = [what, line].filter(Boolean).join(what && line ? ": " : "");
|
|
12205
|
-
return detail ? `OS keychain unavailable (${detail})` : "OS keychain unavailable";
|
|
12206
|
-
}
|
|
12207
|
-
function keychainExitCode(reason) {
|
|
12208
|
-
const m = /security exited (\d+)/.exec(reason);
|
|
12209
|
-
return m ? Number(m[1]) : void 0;
|
|
12210
|
-
}
|
|
12211
|
-
function describeStoreError(e) {
|
|
12212
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
12213
|
-
if (/User interaction is not allowed/i.test(msg)) {
|
|
12214
|
-
return "keychain unavailable (User interaction is not allowed \u2014 run from a logged-in session, or `security unlock-keychain` first)";
|
|
12532
|
+
return JSON.parse(readFileSync27(path, "utf8")).running !== true;
|
|
12533
|
+
} catch {
|
|
12534
|
+
return true;
|
|
12215
12535
|
}
|
|
12216
|
-
return "keychain unavailable (could not write to the OS keychain)";
|
|
12217
12536
|
}
|
|
12218
|
-
|
|
12537
|
+
function orphanedStreams(o) {
|
|
12538
|
+
const dir = join30(o.stateDir, "streams");
|
|
12539
|
+
if (!existsSync27(dir)) return [];
|
|
12540
|
+
const ownerAlive = o.isOwnerAlive ?? pidAlive;
|
|
12219
12541
|
const out = [];
|
|
12220
|
-
|
|
12221
|
-
|
|
12222
|
-
|
|
12223
|
-
|
|
12224
|
-
|
|
12225
|
-
|
|
12226
|
-
|
|
12227
|
-
|
|
12228
|
-
try {
|
|
12229
|
-
store = (o.getStore ?? getSessionStore)();
|
|
12230
|
-
} catch (e) {
|
|
12231
|
-
return targets.map(({ role }) => ({
|
|
12232
|
-
role,
|
|
12233
|
-
action: "failed",
|
|
12234
|
-
detail: describeStoreError(e)
|
|
12235
|
-
}));
|
|
12236
|
-
}
|
|
12237
|
-
const host = hostFromUrl2(route2.baseUrl);
|
|
12238
|
-
const base = route2.baseUrl.replace(/\/+$/, "");
|
|
12239
|
-
if (!o.dryRun) {
|
|
12240
|
-
const probeSlug = seatKeySlug(splitRoute(route2.route)[0], "_probe");
|
|
12241
|
-
try {
|
|
12242
|
-
await store.set(host, probeSlug, {
|
|
12243
|
-
apiKey: "probe",
|
|
12244
|
-
workspaceId: resolved.workspaceId,
|
|
12245
|
-
identityId: "_probe",
|
|
12246
|
-
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
12247
|
-
});
|
|
12248
|
-
await store.clear(host, probeSlug);
|
|
12249
|
-
} catch (e) {
|
|
12250
|
-
const detail = `${describeStoreError(e)}; no keys minted`;
|
|
12251
|
-
return targets.map(({ role, agentId }) => agentId ? { role, action: "failed", detail } : { role, action: "no_agent" });
|
|
12252
|
-
}
|
|
12542
|
+
for (const name of readdirSync9(dir)) {
|
|
12543
|
+
if (!name.endsWith(".jsonl") || name.endsWith(".events.jsonl")) continue;
|
|
12544
|
+
const streamPath = join30(dir, name);
|
|
12545
|
+
if (runFinished(streamPath)) continue;
|
|
12546
|
+
const sidecar = readRunSidecar(streamPath);
|
|
12547
|
+
if (!sidecar || sidecar.route !== o.routeLabel) continue;
|
|
12548
|
+
if (ownerAlive(sidecar.ownerPid)) continue;
|
|
12549
|
+
out.push({ streamPath, sidecar });
|
|
12253
12550
|
}
|
|
12254
|
-
|
|
12255
|
-
|
|
12256
|
-
|
|
12257
|
-
|
|
12258
|
-
|
|
12259
|
-
|
|
12260
|
-
|
|
12261
|
-
|
|
12262
|
-
|
|
12263
|
-
|
|
12264
|
-
|
|
12265
|
-
|
|
12266
|
-
out.push({ role, action: "minted", detail: "dry run" });
|
|
12267
|
-
continue;
|
|
12268
|
-
}
|
|
12269
|
-
const res = await doFetch(`${base}/api/workspaces/${resolved.workspaceId}/agents/${agentId}/api-keys`, {
|
|
12270
|
-
method: "POST",
|
|
12271
|
-
headers: {
|
|
12272
|
-
Authorization: `Bearer ${resolveApiKey(route2)}`,
|
|
12273
|
-
"Content-Type": "application/json",
|
|
12274
|
-
"User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0"
|
|
12275
|
-
},
|
|
12276
|
-
body: JSON.stringify({ name: `crew ${o.shipName ?? "ship"} ${role}`, ...actorRecordId ? { actorRecordId } : {} })
|
|
12277
|
-
});
|
|
12278
|
-
if (res.status === 403) {
|
|
12279
|
-
out.push({ role, action: "forbidden" });
|
|
12280
|
-
break;
|
|
12281
|
-
}
|
|
12282
|
-
if (!res.ok) {
|
|
12283
|
-
out.push({ role, action: "failed", detail: `${res.status} ${res.statusText}` });
|
|
12284
|
-
continue;
|
|
12285
|
-
}
|
|
12286
|
-
const body = await res.json();
|
|
12287
|
-
if (!body.key) {
|
|
12288
|
-
out.push({ role, action: "failed", detail: "response carried no key" });
|
|
12289
|
-
continue;
|
|
12290
|
-
}
|
|
12551
|
+
return out;
|
|
12552
|
+
}
|
|
12553
|
+
async function reconcileRuns(o) {
|
|
12554
|
+
let adopted = 0;
|
|
12555
|
+
let interrupted = 0;
|
|
12556
|
+
for (const { streamPath, sidecar } of orphanedStreams(o)) {
|
|
12557
|
+
if (handled.has(streamPath)) continue;
|
|
12558
|
+
handled.add(streamPath);
|
|
12559
|
+
const isAlive = o.isAlive ?? runIsAlive;
|
|
12560
|
+
const lock = isAlive(sidecar) ? o.state.acquireRun(sidecar.role, o.maxConcurrentAgents, o.roleConcurrency?.[sidecar.role] ?? 1) : null;
|
|
12561
|
+
const ticketLock = isAlive(sidecar) && sidecar.ticketRecordId ? o.state.acquireTicket(sidecar.ticketRecordId) : null;
|
|
12562
|
+
const work = (async () => {
|
|
12291
12563
|
try {
|
|
12292
|
-
const
|
|
12293
|
-
|
|
12294
|
-
|
|
12295
|
-
|
|
12296
|
-
|
|
12297
|
-
...body.id ? { keyId: body.id } : {}
|
|
12298
|
-
};
|
|
12299
|
-
await store.set(host, slug2, session);
|
|
12300
|
-
} catch (e) {
|
|
12301
|
-
let revoked = false;
|
|
12302
|
-
if (body.id) {
|
|
12303
|
-
try {
|
|
12304
|
-
const del2 = await doFetch(`${base}/api/workspaces/${resolved.workspaceId}/agents/${agentId}/api-keys/${body.id}`, {
|
|
12305
|
-
method: "DELETE",
|
|
12306
|
-
headers: { Authorization: `Bearer ${resolveApiKey(route2)}`, "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0" }
|
|
12307
|
-
});
|
|
12308
|
-
revoked = del2.ok;
|
|
12309
|
-
} catch {
|
|
12310
|
-
revoked = false;
|
|
12311
|
-
}
|
|
12312
|
-
}
|
|
12313
|
-
out.push({
|
|
12314
|
-
role,
|
|
12315
|
-
action: "failed",
|
|
12316
|
-
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`
|
|
12564
|
+
const result = await reattachRun(streamPath, sidecar, o.emit, {
|
|
12565
|
+
agentLogKey: await o.agentLogKey?.(sidecar.role).catch(() => void 0),
|
|
12566
|
+
isAlive: o.isAlive,
|
|
12567
|
+
pollMs: o.pollMs,
|
|
12568
|
+
onAdopted: () => o.emit.emit(`adopted ${sidecar.role} run (pid ${sidecar.pid}) left by a previous crew process`, { step: "agent", role: sidecar.role })
|
|
12317
12569
|
});
|
|
12318
|
-
|
|
12570
|
+
if (result.adopted) adopted++;
|
|
12571
|
+
else interrupted++;
|
|
12572
|
+
try {
|
|
12573
|
+
await o.afterRun?.(sidecar, result);
|
|
12574
|
+
} catch (e) {
|
|
12575
|
+
o.emit.warn(`could not follow up on ${sidecar.role} run ${streamPath}: ${e.message}`, { step: "agent" });
|
|
12576
|
+
}
|
|
12577
|
+
} catch (e) {
|
|
12578
|
+
o.emit.warn(`could not reattach ${sidecar.role} run ${streamPath}: ${e.message}`, { step: "agent" });
|
|
12579
|
+
} finally {
|
|
12580
|
+
if (lock?.ok) lock.release();
|
|
12581
|
+
if (ticketLock?.ok) ticketLock.release();
|
|
12319
12582
|
}
|
|
12320
|
-
|
|
12321
|
-
|
|
12322
|
-
|
|
12323
|
-
|
|
12324
|
-
|
|
12325
|
-
|
|
12326
|
-
}
|
|
12327
|
-
|
|
12328
|
-
|
|
12329
|
-
case "minted":
|
|
12330
|
-
return `${o.role}: agent key minted${o.detail ? ` (${o.detail})` : ""}`;
|
|
12331
|
-
case "already_stored":
|
|
12332
|
-
return `${o.role}: agent key already stored`;
|
|
12333
|
-
case "no_agent":
|
|
12334
|
-
return `${o.role}: no linked Agents row \u2014 writes as the ship until \`crew agents sync\` links one`;
|
|
12335
|
-
case "forbidden":
|
|
12336
|
-
return FORBIDDEN_LINE;
|
|
12337
|
-
case "failed":
|
|
12338
|
-
return `${o.role}: could not mint an agent key (${o.detail ?? "unknown"}) \u2014 writes as the ship`;
|
|
12583
|
+
})();
|
|
12584
|
+
if (isAlive(sidecar)) {
|
|
12585
|
+
if (o.inFlight) {
|
|
12586
|
+
o.inFlight.add(work);
|
|
12587
|
+
void work.finally(() => o.inFlight.delete(work));
|
|
12588
|
+
}
|
|
12589
|
+
} else {
|
|
12590
|
+
await work;
|
|
12591
|
+
}
|
|
12339
12592
|
}
|
|
12593
|
+
return { adopted, interrupted };
|
|
12340
12594
|
}
|
|
12341
|
-
|
|
12595
|
+
|
|
12596
|
+
// src/live-runs.ts
|
|
12597
|
+
import { existsSync as existsSync28, readdirSync as readdirSync10, statSync as statSync9 } from "fs";
|
|
12598
|
+
import { join as join31 } from "path";
|
|
12599
|
+
function liveRuns(stateDir, isAlive = runIsAlive) {
|
|
12600
|
+
const dir = join31(stateDir, "streams");
|
|
12601
|
+
if (!existsSync28(dir)) return [];
|
|
12342
12602
|
const out = [];
|
|
12343
|
-
|
|
12344
|
-
|
|
12345
|
-
|
|
12346
|
-
|
|
12347
|
-
|
|
12348
|
-
|
|
12349
|
-
|
|
12350
|
-
let store;
|
|
12351
|
-
try {
|
|
12352
|
-
store = (o.getStore ?? getSessionStore)();
|
|
12353
|
-
} catch (e) {
|
|
12354
|
-
return targets.map(({ role }) => ({ role, action: "failed", detail: describeStoreError(e) }));
|
|
12603
|
+
for (const name of readdirSync10(dir)) {
|
|
12604
|
+
if (!name.endsWith(".jsonl") || name.endsWith(".events.jsonl")) continue;
|
|
12605
|
+
const streamPath = join31(dir, name);
|
|
12606
|
+
if (runFinished(streamPath)) continue;
|
|
12607
|
+
const sidecar = readRunSidecar(streamPath);
|
|
12608
|
+
if (!sidecar || !isAlive(sidecar)) continue;
|
|
12609
|
+
out.push({ ...sidecar, streamPath });
|
|
12355
12610
|
}
|
|
12356
|
-
|
|
12357
|
-
|
|
12358
|
-
|
|
12359
|
-
|
|
12360
|
-
|
|
12361
|
-
|
|
12611
|
+
return out.sort((a, b) => a.spawnedAt.localeCompare(b.spawnedAt));
|
|
12612
|
+
}
|
|
12613
|
+
function runActivity(stateDir, isAlive = runIsAlive) {
|
|
12614
|
+
const activeRuns = liveRuns(stateDir, isAlive).length;
|
|
12615
|
+
const dir = join31(stateDir, "streams");
|
|
12616
|
+
let newest = 0;
|
|
12617
|
+
if (existsSync28(dir)) {
|
|
12618
|
+
for (const name of readdirSync10(dir)) {
|
|
12619
|
+
if (!name.endsWith(".jsonl") || name.endsWith(".events.jsonl")) continue;
|
|
12620
|
+
const streamPath = join31(dir, name);
|
|
12621
|
+
if (!runFinished(streamPath)) continue;
|
|
12622
|
+
try {
|
|
12623
|
+
newest = Math.max(newest, statSync9(`${streamPath}.meta.json`).mtimeMs);
|
|
12624
|
+
} catch {
|
|
12625
|
+
}
|
|
12362
12626
|
}
|
|
12363
|
-
|
|
12627
|
+
}
|
|
12628
|
+
return { activeRuns, lastRunEndedAt: newest ? new Date(newest).toISOString() : null };
|
|
12629
|
+
}
|
|
12630
|
+
function siblingRuns(runs, ownTicketRecordId) {
|
|
12631
|
+
return runs.filter((r) => !ownTicketRecordId || r.ticketRecordId !== ownTicketRecordId);
|
|
12632
|
+
}
|
|
12633
|
+
async function settleRunEnd(o) {
|
|
12634
|
+
const { tracker, emit } = o;
|
|
12635
|
+
const all = o.runs ?? liveRuns(o.stateDir);
|
|
12636
|
+
const slug2 = routeSlug(o.route.route);
|
|
12637
|
+
const seatSiblings = siblingRuns(all.filter((r) => r.role === o.role && r.route === slug2), o.ownTicketRecordId);
|
|
12638
|
+
const shipSiblings = siblingRuns(all, o.ownTicketRecordId);
|
|
12639
|
+
const newest = (rs) => rs[rs.length - 1];
|
|
12640
|
+
if (o.memberId) {
|
|
12364
12641
|
try {
|
|
12365
|
-
|
|
12366
|
-
|
|
12367
|
-
out.push({ role, action: "not_stored" });
|
|
12368
|
-
continue;
|
|
12369
|
-
}
|
|
12370
|
-
if (!session.keyId) {
|
|
12371
|
-
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" });
|
|
12372
|
-
continue;
|
|
12373
|
-
}
|
|
12374
|
-
if (o.dryRun) {
|
|
12375
|
-
out.push({ role, action: "revoked", detail: "dry run" });
|
|
12376
|
-
continue;
|
|
12377
|
-
}
|
|
12378
|
-
const del2 = await doFetch(`${base}/api/workspaces/${resolved.workspaceId}/agents/${agentId}/api-keys/${session.keyId}`, {
|
|
12379
|
-
method: "DELETE",
|
|
12380
|
-
headers: { Authorization: `Bearer ${resolveApiKey(route2)}`, "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0" }
|
|
12381
|
-
});
|
|
12382
|
-
if (!del2.ok && del2.status !== 404) {
|
|
12383
|
-
out.push({ role, action: "failed", detail: `${del2.status} ${del2.statusText}` });
|
|
12384
|
-
continue;
|
|
12385
|
-
}
|
|
12386
|
-
await store.clear(host, slug2);
|
|
12387
|
-
out.push({ role, action: "revoked", detail: del2.ok ? void 0 : "server already had no such key" });
|
|
12642
|
+
if (seatSiblings.length) await tracker.setCrewStatus(o.memberId, "working", newest(seatSiblings).ticketRecordId);
|
|
12643
|
+
else await tracker.setCrewStatus(o.memberId, "idle");
|
|
12388
12644
|
} catch (e) {
|
|
12389
|
-
|
|
12645
|
+
emit.warn(`could not clear crew status: ${e.message}`, { step: "agent" });
|
|
12390
12646
|
}
|
|
12391
12647
|
}
|
|
12392
|
-
|
|
12393
|
-
|
|
12394
|
-
|
|
12395
|
-
|
|
12396
|
-
|
|
12397
|
-
|
|
12398
|
-
|
|
12399
|
-
|
|
12400
|
-
|
|
12401
|
-
return `${o.role}: no linked Agents row`;
|
|
12402
|
-
case "failed":
|
|
12403
|
-
return `${o.role}: could not revoke the agent key (${o.detail ?? "unknown"}) \u2014 left in the keychain`;
|
|
12648
|
+
try {
|
|
12649
|
+
if (shipSiblings.length) {
|
|
12650
|
+
const n = newest(shipSiblings);
|
|
12651
|
+
await tracker.beatEngaged(o.shipName, n.routeName ?? n.route, n.ticketRecordId, n.spawnedAt);
|
|
12652
|
+
} else {
|
|
12653
|
+
await tracker.beatIdle(o.shipName);
|
|
12654
|
+
}
|
|
12655
|
+
} catch (e) {
|
|
12656
|
+
emit.warn(`could not beat ship idle: ${e.message}`, { step: "agent" });
|
|
12404
12657
|
}
|
|
12405
12658
|
}
|
|
12406
|
-
|
|
12407
|
-
const resolved = route2.resolved;
|
|
12408
|
-
if (!resolved) return void 0;
|
|
12409
|
-
const agentId = seatAgentId(route2, "dev") ?? SEAT_KEY_ROLES.map((r) => seatAgentId(route2, r)).find(Boolean);
|
|
12410
|
-
if (!agentId) return void 0;
|
|
12659
|
+
function describeRun(run3, route2, seatIds, worktreeOf = (d, n) => {
|
|
12411
12660
|
try {
|
|
12412
|
-
|
|
12413
|
-
headers: { Authorization: `Bearer ${resolveApiKey(route2)}`, "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0" }
|
|
12414
|
-
});
|
|
12415
|
-
if (res.ok) return true;
|
|
12416
|
-
return res.status === 403 ? false : void 0;
|
|
12661
|
+
return worktreeForNumber(d, n)?.path ?? null;
|
|
12417
12662
|
} catch {
|
|
12418
|
-
return
|
|
12663
|
+
return null;
|
|
12419
12664
|
}
|
|
12665
|
+
}) {
|
|
12666
|
+
const ownRoute = run3.route === routeSlug(route2.route);
|
|
12667
|
+
const repoDir = run3.repo ? reposOf(route2).find((t) => t.name === run3.repo)?.dir : void 0;
|
|
12668
|
+
const num = run3.ticket?.replace(/^\D+/, "") ?? "";
|
|
12669
|
+
return {
|
|
12670
|
+
role: run3.role,
|
|
12671
|
+
seat: ownRoute ? seatIds[run3.role] ?? null : null,
|
|
12672
|
+
ticket: run3.ticket,
|
|
12673
|
+
ticketRecordId: run3.ticketRecordId,
|
|
12674
|
+
repo: run3.repo ?? null,
|
|
12675
|
+
worktree: repoDir && num ? worktreeOf(repoDir, num) : null,
|
|
12676
|
+
stream: run3.streamPath,
|
|
12677
|
+
pid: run3.pid,
|
|
12678
|
+
startedAt: run3.spawnedAt,
|
|
12679
|
+
deadline: run3.deadline ?? null
|
|
12680
|
+
};
|
|
12420
12681
|
}
|
|
12421
12682
|
|
|
12422
12683
|
// src/deprovision-host.ts
|
|
@@ -19488,100 +19749,6 @@ function formatWhoami(report) {
|
|
|
19488
19749
|
`;
|
|
19489
19750
|
}
|
|
19490
19751
|
|
|
19491
|
-
// src/seat-read-probe.ts
|
|
19492
|
-
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";
|
|
19493
|
-
function probeTables(route2) {
|
|
19494
|
-
const m = route2.resolved?.models;
|
|
19495
|
-
if (!m) return [];
|
|
19496
|
-
const r = route2.resolved;
|
|
19497
|
-
return [
|
|
19498
|
-
{ name: "Issues", modelId: m.issues },
|
|
19499
|
-
{ name: "Comments", modelId: m.comments },
|
|
19500
|
-
{ name: "Crew", modelId: m.crew },
|
|
19501
|
-
{ name: "Ships", modelId: r?.shipsModelId ?? "" }
|
|
19502
|
-
].filter((t) => !!t.modelId);
|
|
19503
|
-
}
|
|
19504
|
-
async function probeTableRead(route2, modelId, apiKey, o = {}) {
|
|
19505
|
-
const url = `${route2.baseUrl.replace(/\/+$/, "")}/api/data-models/${modelId}/records?limit=1`;
|
|
19506
|
-
try {
|
|
19507
|
-
const res = await (o.fetchImpl ?? fetch)(url, {
|
|
19508
|
-
headers: { Authorization: `Bearer ${apiKey}`, "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0" }
|
|
19509
|
-
});
|
|
19510
|
-
if (res.ok) return { state: "ok" };
|
|
19511
|
-
if (res.status === 403) return { state: "forbidden" };
|
|
19512
|
-
return { state: "unknown", detail: `HTTP ${res.status}` };
|
|
19513
|
-
} catch (e) {
|
|
19514
|
-
return { state: "unknown", detail: e.message };
|
|
19515
|
-
}
|
|
19516
|
-
}
|
|
19517
|
-
async function probeSeatReads(route2, o = {}) {
|
|
19518
|
-
const out = [];
|
|
19519
|
-
const roles = [...SEAT_KEY_ROLES.filter((r) => route2.resolved?.seats[r]), ...route2.resolved?.releaseSeat ? ["release"] : []];
|
|
19520
|
-
for (const role of roles) {
|
|
19521
|
-
const id = role === "release" ? await resolveReleaseKey(route2, o.getStore) : await resolveSeatKey(route2, role, o.getStore);
|
|
19522
|
-
if (id.kind !== "agent") continue;
|
|
19523
|
-
const tables = [];
|
|
19524
|
-
for (const t of probeTables(route2)) tables.push({ table: t.name, ...await probeTableRead(route2, t.modelId, id.apiKey, o) });
|
|
19525
|
-
const bad = tables.find((t) => t.state === "forbidden") ?? tables.find((t) => t.state === "unknown");
|
|
19526
|
-
out.push({ role, state: bad?.state ?? (tables.length ? "ok" : "unknown"), detail: bad?.detail ?? (tables.length ? void 0 : "route has no resolved tables"), tables });
|
|
19527
|
-
}
|
|
19528
|
-
return out;
|
|
19529
|
-
}
|
|
19530
|
-
function forbiddenDetail(e, route2) {
|
|
19531
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
19532
|
-
const m = /^([A-Z]+) (\S+) -> 403/.exec(msg);
|
|
19533
|
-
const verb = /No (READ|WRITE) access/i.exec(msg)?.[1]?.toUpperCase();
|
|
19534
|
-
const modelId = /\/data-models\/([\w-]+)/i.exec(m?.[2] ?? "")?.[1];
|
|
19535
|
-
const r = route2?.resolved;
|
|
19536
|
-
const known = [
|
|
19537
|
-
[r?.models?.issues, "Issues"],
|
|
19538
|
-
[r?.models?.comments, "Comments"],
|
|
19539
|
-
[r?.models?.crew, "Crew"],
|
|
19540
|
-
[r?.shipsModelId, "Ships"],
|
|
19541
|
-
[r?.locksModelId, "Locks"],
|
|
19542
|
-
[r?.epicsModelId, "Epics"],
|
|
19543
|
-
[r?.periscopesModelId, "Periscopes"],
|
|
19544
|
-
[r?.reposModelId, "Repos"]
|
|
19545
|
-
];
|
|
19546
|
-
const table2 = modelId ? known.find(([id]) => id && id === modelId)?.[1] ?? `table ${modelId}` : void 0;
|
|
19547
|
-
return { method: m?.[1], path: m?.[2], table: table2, verb };
|
|
19548
|
-
}
|
|
19549
|
-
function remedyFor(d, roleName = "the agent role") {
|
|
19550
|
-
if (d.table && d.verb && !(d.table === "Issues" && d.verb === "READ")) {
|
|
19551
|
-
return `grant ${d.verb} on ${d.table} to ${roleName}`;
|
|
19552
|
-
}
|
|
19553
|
-
return SEAT_NO_READ_REMEDY;
|
|
19554
|
-
}
|
|
19555
|
-
function describeSeatForbidden(role, tables = []) {
|
|
19556
|
-
const bad = tables.filter((t) => t.state === "forbidden").map((t) => t.table);
|
|
19557
|
-
const what = bad.length ? bad.join(", ") : "Issues";
|
|
19558
|
-
const remedy = bad.length && !(bad.length === 1 && bad[0] === "Issues") ? bad.map((t) => remedyFor({ table: t, verb: "READ" })).join("; ") : SEAT_NO_READ_REMEDY;
|
|
19559
|
-
return `seat read: ERROR ${role} agent key cannot read ${what} (403) \u2014 ${remedy}`;
|
|
19560
|
-
}
|
|
19561
|
-
function describeSeatTables(role, tables) {
|
|
19562
|
-
const cells = tables.map((t) => `${t.table} ${t.state === "ok" ? "ok" : t.state === "forbidden" ? "403" : `error (${t.detail ?? "?"})`}`);
|
|
19563
|
-
return `seat read: ${role}: ${cells.join(", ")}`;
|
|
19564
|
-
}
|
|
19565
|
-
function isForbiddenError(e) {
|
|
19566
|
-
const status2 = e?.status;
|
|
19567
|
-
if (status2 === 403) return true;
|
|
19568
|
-
return /\b403\b/.test(e instanceof Error ? e.message : String(e));
|
|
19569
|
-
}
|
|
19570
|
-
var SEAT_FORBIDDEN_KEY = "seat_forbidden:release";
|
|
19571
|
-
function seatForbiddenItem(now = /* @__PURE__ */ new Date(), e, route2) {
|
|
19572
|
-
const d = e === void 0 ? {} : forbiddenDetail(e, route2);
|
|
19573
|
-
const what = d.method && d.table ? `${d.method} ${d.table}${d.verb ? ` -> 403 No ${d.verb} access` : " -> 403"}` : "cannot read the Issues table (403)";
|
|
19574
|
-
return {
|
|
19575
|
-
kind: "seat_forbidden",
|
|
19576
|
-
key: SEAT_FORBIDDEN_KEY,
|
|
19577
|
-
message: `release seat: ${what} \u2014 ${remedyFor(d)}`,
|
|
19578
|
-
since: now.toISOString()
|
|
19579
|
-
};
|
|
19580
|
-
}
|
|
19581
|
-
function seatForbiddenRepoLine(scope) {
|
|
19582
|
-
return `${scope}: skipped \u2014 release seat was refused by the tracker (see attention)`;
|
|
19583
|
-
}
|
|
19584
|
-
|
|
19585
19752
|
// src/cli.ts
|
|
19586
19753
|
import { StaleWriteError as StaleWriteError9 } from "@tablation/client";
|
|
19587
19754
|
|
|
@@ -23493,6 +23660,14 @@ async function runOnePassUnguarded(o) {
|
|
|
23493
23660
|
for (const role of order) {
|
|
23494
23661
|
const count = live.get(role);
|
|
23495
23662
|
if (count === void 0) continue;
|
|
23663
|
+
if (openShipAttention(o.state).some((i) => i.key === seatCannotWriteKey(role))) {
|
|
23664
|
+
if (await (o.seatWriteRefused ?? ((r) => seatWriteStillRefused(o.route, r, { userAgent: o.ship.userAgent })))(role)) {
|
|
23665
|
+
if (count === 0) skipped.push({ role, reason: "seat role cannot write the hand-off fields \u2014 see attention" });
|
|
23666
|
+
live.delete(role);
|
|
23667
|
+
continue;
|
|
23668
|
+
}
|
|
23669
|
+
clearShipAttention(o.state, seatCannotWriteKey(role));
|
|
23670
|
+
}
|
|
23496
23671
|
if (!breaker.allows(role)) {
|
|
23497
23672
|
if (count === 0) skipped.push({ role, reason: "agent recently failed to start \u2014 waiting before retry" });
|
|
23498
23673
|
live.delete(role);
|
|
@@ -23827,6 +24002,17 @@ async function runRoleAgentLocked(role, decision, ctx, hooks, held) {
|
|
|
23827
24002
|
agentPersona: await fetchSeatPersona(route2, role, { userAgent: ship.userAgent }),
|
|
23828
24003
|
resolvedAgentId: await resolveAgentId(route2, role, { userAgent: ship.userAgent })
|
|
23829
24004
|
});
|
|
24005
|
+
const raiseIfSeatForbidden = async (e, what) => {
|
|
24006
|
+
if (identity.kind !== "agent" || !isForbiddenError(e)) return;
|
|
24007
|
+
const d = forbiddenDetail(e, route2);
|
|
24008
|
+
try {
|
|
24009
|
+
await raiseShipAttention(
|
|
24010
|
+
{ state: ctx.state, emit, route: route2, ship },
|
|
24011
|
+
seatCannotWriteItem(role, `${what} refused${d.table ? ` on ${d.table}` : ""}${d.verb ? ` (no ${d.verb} access)` : ""}`)
|
|
24012
|
+
);
|
|
24013
|
+
} catch {
|
|
24014
|
+
}
|
|
24015
|
+
};
|
|
23830
24016
|
emit.enter("agent", role);
|
|
23831
24017
|
emit.emit(`starting agent run for ${route2.route}`);
|
|
23832
24018
|
const memberId = route2.resolved?.seats[role];
|
|
@@ -23835,12 +24021,14 @@ async function runRoleAgentLocked(role, decision, ctx, hooks, held) {
|
|
|
23835
24021
|
await tracker.setCrewStatus(memberId, "working", workingId);
|
|
23836
24022
|
} catch (e) {
|
|
23837
24023
|
emit.warn(`could not set crew status: ${e.message}`, { step: "agent" });
|
|
24024
|
+
await raiseIfSeatForbidden(e, "status write");
|
|
23838
24025
|
}
|
|
23839
24026
|
}
|
|
23840
24027
|
try {
|
|
23841
24028
|
await tracker.beatEngaged(ship.name, route2.route, workingId);
|
|
23842
24029
|
} catch (e) {
|
|
23843
24030
|
emit.warn(`could not beat ship engaged: ${e.message}`, { step: "agent" });
|
|
24031
|
+
await raiseIfSeatForbidden(e, "Ships heartbeat");
|
|
23844
24032
|
}
|
|
23845
24033
|
try {
|
|
23846
24034
|
const runStart = (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -30533,6 +30721,11 @@ watermark: ${state.watermark()}
|
|
|
30533
30721
|
problems.push({ severity: "blocker", kind: "seat_cannot_read_issues", route: route2.route, message: `${p.role} agent key refused on ${p.tables.filter((t) => t.state === "forbidden").map((t) => t.table).join(", ")} (403)`, remedy: describeSeatForbidden(p.role, p.tables).replace(/^.*?— /, "") });
|
|
30534
30722
|
} else if (p.state === "ok") passed++;
|
|
30535
30723
|
}
|
|
30724
|
+
for (const w of await probeSeatWrites(route2, { userAgent: cfg.ship.userAgent })) {
|
|
30725
|
+
if (w.state === "forbidden") {
|
|
30726
|
+
problems.push({ severity: "blocker", kind: "seat_cannot_write", route: route2.route, message: seatCannotWriteMessage(w.role, w.tables), remedy: SEAT_NO_WRITE_REMEDY });
|
|
30727
|
+
} else if (w.state === "ok") passed++;
|
|
30728
|
+
}
|
|
30536
30729
|
}
|
|
30537
30730
|
for (const message of roleCapWarnings(cfg.ship)) problems.push({ severity: "note", kind: "role_cap_above_ship_cap", message });
|
|
30538
30731
|
if (!flag("offline")) {
|
|
@@ -30768,6 +30961,14 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
|
|
|
30768
30961
|
`);
|
|
30769
30962
|
}
|
|
30770
30963
|
}
|
|
30964
|
+
for (const w of await probeSeatWrites(roleRoute, { userAgent: cfg.ship.userAgent })) {
|
|
30965
|
+
if (w.state === "forbidden") {
|
|
30966
|
+
process.stdout.write(`seat write: ERROR ${seatCannotWriteMessage(w.role, w.tables)} (READ only) \u2014 ${SEAT_NO_WRITE_REMEDY}
|
|
30967
|
+
`);
|
|
30968
|
+
process.exitCode = 1;
|
|
30969
|
+
} else if (w.state === "ok") process.stdout.write(`seat write: ${w.role}: ok
|
|
30970
|
+
`);
|
|
30971
|
+
}
|
|
30771
30972
|
}
|
|
30772
30973
|
const [ships, crewRows] = await Promise.all([tracker.shipRows(), tracker.crewRows()]);
|
|
30773
30974
|
if (ships.length === 0) {
|