@tablation/crew 0.127.5 → 0.127.7
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 +757 -563
- 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);
|
|
@@ -11030,10 +11563,7 @@ var SandboxRunner = class {
|
|
|
11030
11563
|
}
|
|
11031
11564
|
}
|
|
11032
11565
|
};
|
|
11033
|
-
|
|
11034
|
-
const err3 = e;
|
|
11035
|
-
return (err3.stderr?.trim() || err3.message || String(e)).split("\n").slice(-3).join(" / ");
|
|
11036
|
-
}
|
|
11566
|
+
var describeExecError = describe;
|
|
11037
11567
|
function gitOrigin(cwd) {
|
|
11038
11568
|
try {
|
|
11039
11569
|
return execFileSync6("git", ["-C", cwd, "remote", "get-url", "origin"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
@@ -11689,6 +12219,18 @@ async function finishRun(id, obs, emit, startedAt, end, agentLogKey, hintMoved)
|
|
|
11689
12219
|
emit.warn(`could not save the pending hand-off: ${e.message}`, { step: "agent" });
|
|
11690
12220
|
}
|
|
11691
12221
|
}
|
|
12222
|
+
const forbidden = end.interrupted ? null : obs.handoff.forbidden();
|
|
12223
|
+
if (forbidden) {
|
|
12224
|
+
try {
|
|
12225
|
+
await raiseShipAttention({ state: new State(stateDirOfStream(id.streamPath)), emit }, seatCannotWriteItem(id.role, forbidden.detail));
|
|
12226
|
+
} catch {
|
|
12227
|
+
}
|
|
12228
|
+
} else if (!end.interrupted && obs.handoff.handoffLanded()) {
|
|
12229
|
+
try {
|
|
12230
|
+
clearShipAttention(new State(stateDirOfStream(id.streamPath)), seatCannotWriteKey(id.role));
|
|
12231
|
+
} catch {
|
|
12232
|
+
}
|
|
12233
|
+
}
|
|
11692
12234
|
const before = obs.beforeFirstEdit.result();
|
|
11693
12235
|
try {
|
|
11694
12236
|
writeRunMeta(id, startedAt, obs.liveTicket, {
|
|
@@ -11712,6 +12254,7 @@ async function finishRun(id, obs, emit, startedAt, end, agentLogKey, hintMoved)
|
|
|
11712
12254
|
ms: ms3,
|
|
11713
12255
|
code: end.code,
|
|
11714
12256
|
...pending ? { handoff: "pending" } : {},
|
|
12257
|
+
...forbidden ? { reason: "forbidden", forbidden: forbidden.detail } : {},
|
|
11715
12258
|
...end.interrupted ? { interrupted: end.interrupted } : {},
|
|
11716
12259
|
...identity,
|
|
11717
12260
|
attributedTicket: attributedTicket ?? null,
|
|
@@ -11728,7 +12271,7 @@ async function finishRun(id, obs, emit, startedAt, end, agentLogKey, hintMoved)
|
|
|
11728
12271
|
if (id.agentLog && agentLogKey) {
|
|
11729
12272
|
const report = {
|
|
11730
12273
|
ticketReference: attributedTicket,
|
|
11731
|
-
outcome: end.interrupted ? "interrupted" : signedOut ? "error" : end.code === 0 || end.code === null && obs.sawResult ? "success" : "error",
|
|
12274
|
+
outcome: end.interrupted ? "interrupted" : signedOut || forbidden ? "error" : end.code === 0 || end.code === null && obs.sawResult ? "success" : "error",
|
|
11732
12275
|
startedAt,
|
|
11733
12276
|
finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
11734
12277
|
cycles: obs.cycles,
|
|
@@ -11938,496 +12481,200 @@ async function reattachRun(streamPath, sc, emit, o = {}) {
|
|
|
11938
12481
|
o.onAdopted?.();
|
|
11939
12482
|
const pollMs = o.pollMs ?? TAIL_POLL_MS;
|
|
11940
12483
|
while (isAlive(sc)) {
|
|
11941
|
-
obs.feed(tail3.poll());
|
|
11942
|
-
if (!killed && (msUntilDeadline(sc.deadline) ?? 1) <= 0) {
|
|
11943
|
-
killed = await killForDeadline(() => runner.kill(runner.reattach(sc), WALL_CLOCK_REASON), sc.pid, emit, sc.role, obs.liveTicket ?? sc.ticket, sc.spawnedAt, sc.maxRunMinutes);
|
|
11944
|
-
continue;
|
|
11945
|
-
}
|
|
11946
|
-
await new Promise((r) => setTimeout(r, pollMs));
|
|
11947
|
-
}
|
|
11948
|
-
}
|
|
11949
|
-
obs.feed(tail3.poll());
|
|
11950
|
-
obs.end();
|
|
11951
|
-
await endSink(eventsSink);
|
|
11952
|
-
await runner.release(runner.reattach(sc), emit);
|
|
11953
|
-
const interrupted = killed ? WALL_CLOCK_REASON : obs.sawResult ? void 0 : adopted ? "no-result" : "daemon-exit";
|
|
11954
|
-
const { ms: ms3, ticket } = await finishRun(
|
|
11955
|
-
id,
|
|
11956
|
-
obs,
|
|
11957
|
-
emit,
|
|
11958
|
-
sc.spawnedAt,
|
|
11959
|
-
{ code: null, ...interrupted ? { interrupted } : {} },
|
|
11960
|
-
o.agentLogKey
|
|
11961
|
-
);
|
|
11962
|
-
return { adopted, ...interrupted ? { interrupted } : {}, ...killed ? { killed } : {}, ms: ms3, ticket };
|
|
11963
|
-
}
|
|
11964
|
-
|
|
11965
|
-
// src/interrupted.ts
|
|
11966
|
-
var INTERRUPTED_MARKER = calloutHeader("NOTE", "Interrupted");
|
|
11967
|
-
function interruptedComment(b) {
|
|
11968
|
-
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.";
|
|
11969
|
-
return `${INTERRUPTED_MARKER}
|
|
11970
|
-
|
|
11971
|
-
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.`;
|
|
11972
|
-
}
|
|
11973
|
-
var handled = /* @__PURE__ */ new Set();
|
|
11974
|
-
var pidAlive = (pid) => {
|
|
11975
|
-
try {
|
|
11976
|
-
process.kill(pid, 0);
|
|
11977
|
-
return true;
|
|
11978
|
-
} catch (e) {
|
|
11979
|
-
return e.code === "EPERM";
|
|
11980
|
-
}
|
|
11981
|
-
};
|
|
11982
|
-
function runFinished(streamPath) {
|
|
11983
|
-
const path = `${streamPath}.meta.json`;
|
|
11984
|
-
if (!existsSync27(path)) return false;
|
|
11985
|
-
try {
|
|
11986
|
-
return JSON.parse(readFileSync27(path, "utf8")).running !== true;
|
|
11987
|
-
} catch {
|
|
11988
|
-
return true;
|
|
11989
|
-
}
|
|
11990
|
-
}
|
|
11991
|
-
function orphanedStreams(o) {
|
|
11992
|
-
const dir = join30(o.stateDir, "streams");
|
|
11993
|
-
if (!existsSync27(dir)) return [];
|
|
11994
|
-
const ownerAlive = o.isOwnerAlive ?? pidAlive;
|
|
11995
|
-
const out = [];
|
|
11996
|
-
for (const name of readdirSync9(dir)) {
|
|
11997
|
-
if (!name.endsWith(".jsonl") || name.endsWith(".events.jsonl")) continue;
|
|
11998
|
-
const streamPath = join30(dir, name);
|
|
11999
|
-
if (runFinished(streamPath)) continue;
|
|
12000
|
-
const sidecar = readRunSidecar(streamPath);
|
|
12001
|
-
if (!sidecar || sidecar.route !== o.routeLabel) continue;
|
|
12002
|
-
if (ownerAlive(sidecar.ownerPid)) continue;
|
|
12003
|
-
out.push({ streamPath, sidecar });
|
|
12004
|
-
}
|
|
12005
|
-
return out;
|
|
12006
|
-
}
|
|
12007
|
-
async function reconcileRuns(o) {
|
|
12008
|
-
let adopted = 0;
|
|
12009
|
-
let interrupted = 0;
|
|
12010
|
-
for (const { streamPath, sidecar } of orphanedStreams(o)) {
|
|
12011
|
-
if (handled.has(streamPath)) continue;
|
|
12012
|
-
handled.add(streamPath);
|
|
12013
|
-
const isAlive = o.isAlive ?? runIsAlive;
|
|
12014
|
-
const lock = isAlive(sidecar) ? o.state.acquireRun(sidecar.role, o.maxConcurrentAgents, o.roleConcurrency?.[sidecar.role] ?? 1) : null;
|
|
12015
|
-
const ticketLock = isAlive(sidecar) && sidecar.ticketRecordId ? o.state.acquireTicket(sidecar.ticketRecordId) : null;
|
|
12016
|
-
const work = (async () => {
|
|
12017
|
-
try {
|
|
12018
|
-
const result = await reattachRun(streamPath, sidecar, o.emit, {
|
|
12019
|
-
agentLogKey: await o.agentLogKey?.(sidecar.role).catch(() => void 0),
|
|
12020
|
-
isAlive: o.isAlive,
|
|
12021
|
-
pollMs: o.pollMs,
|
|
12022
|
-
onAdopted: () => o.emit.emit(`adopted ${sidecar.role} run (pid ${sidecar.pid}) left by a previous crew process`, { step: "agent", role: sidecar.role })
|
|
12023
|
-
});
|
|
12024
|
-
if (result.adopted) adopted++;
|
|
12025
|
-
else interrupted++;
|
|
12026
|
-
try {
|
|
12027
|
-
await o.afterRun?.(sidecar, result);
|
|
12028
|
-
} catch (e) {
|
|
12029
|
-
o.emit.warn(`could not follow up on ${sidecar.role} run ${streamPath}: ${e.message}`, { step: "agent" });
|
|
12030
|
-
}
|
|
12031
|
-
} catch (e) {
|
|
12032
|
-
o.emit.warn(`could not reattach ${sidecar.role} run ${streamPath}: ${e.message}`, { step: "agent" });
|
|
12033
|
-
} finally {
|
|
12034
|
-
if (lock?.ok) lock.release();
|
|
12035
|
-
if (ticketLock?.ok) ticketLock.release();
|
|
12036
|
-
}
|
|
12037
|
-
})();
|
|
12038
|
-
if (isAlive(sidecar)) {
|
|
12039
|
-
if (o.inFlight) {
|
|
12040
|
-
o.inFlight.add(work);
|
|
12041
|
-
void work.finally(() => o.inFlight.delete(work));
|
|
12042
|
-
}
|
|
12043
|
-
} else {
|
|
12044
|
-
await work;
|
|
12045
|
-
}
|
|
12046
|
-
}
|
|
12047
|
-
return { adopted, interrupted };
|
|
12048
|
-
}
|
|
12049
|
-
|
|
12050
|
-
// src/live-runs.ts
|
|
12051
|
-
import { existsSync as existsSync28, readdirSync as readdirSync10, statSync as statSync9 } from "fs";
|
|
12052
|
-
import { join as join31 } from "path";
|
|
12053
|
-
function liveRuns(stateDir, isAlive = runIsAlive) {
|
|
12054
|
-
const dir = join31(stateDir, "streams");
|
|
12055
|
-
if (!existsSync28(dir)) return [];
|
|
12056
|
-
const out = [];
|
|
12057
|
-
for (const name of readdirSync10(dir)) {
|
|
12058
|
-
if (!name.endsWith(".jsonl") || name.endsWith(".events.jsonl")) continue;
|
|
12059
|
-
const streamPath = join31(dir, name);
|
|
12060
|
-
if (runFinished(streamPath)) continue;
|
|
12061
|
-
const sidecar = readRunSidecar(streamPath);
|
|
12062
|
-
if (!sidecar || !isAlive(sidecar)) continue;
|
|
12063
|
-
out.push({ ...sidecar, streamPath });
|
|
12064
|
-
}
|
|
12065
|
-
return out.sort((a, b) => a.spawnedAt.localeCompare(b.spawnedAt));
|
|
12066
|
-
}
|
|
12067
|
-
function runActivity(stateDir, isAlive = runIsAlive) {
|
|
12068
|
-
const activeRuns = liveRuns(stateDir, isAlive).length;
|
|
12069
|
-
const dir = join31(stateDir, "streams");
|
|
12070
|
-
let newest = 0;
|
|
12071
|
-
if (existsSync28(dir)) {
|
|
12072
|
-
for (const name of readdirSync10(dir)) {
|
|
12073
|
-
if (!name.endsWith(".jsonl") || name.endsWith(".events.jsonl")) continue;
|
|
12074
|
-
const streamPath = join31(dir, name);
|
|
12075
|
-
if (!runFinished(streamPath)) continue;
|
|
12076
|
-
try {
|
|
12077
|
-
newest = Math.max(newest, statSync9(`${streamPath}.meta.json`).mtimeMs);
|
|
12078
|
-
} catch {
|
|
12079
|
-
}
|
|
12080
|
-
}
|
|
12081
|
-
}
|
|
12082
|
-
return { activeRuns, lastRunEndedAt: newest ? new Date(newest).toISOString() : null };
|
|
12083
|
-
}
|
|
12084
|
-
function siblingRuns(runs, ownTicketRecordId) {
|
|
12085
|
-
return runs.filter((r) => !ownTicketRecordId || r.ticketRecordId !== ownTicketRecordId);
|
|
12086
|
-
}
|
|
12087
|
-
async function settleRunEnd(o) {
|
|
12088
|
-
const { tracker, emit } = o;
|
|
12089
|
-
const all = o.runs ?? liveRuns(o.stateDir);
|
|
12090
|
-
const slug2 = routeSlug(o.route.route);
|
|
12091
|
-
const seatSiblings = siblingRuns(all.filter((r) => r.role === o.role && r.route === slug2), o.ownTicketRecordId);
|
|
12092
|
-
const shipSiblings = siblingRuns(all, o.ownTicketRecordId);
|
|
12093
|
-
const newest = (rs) => rs[rs.length - 1];
|
|
12094
|
-
if (o.memberId) {
|
|
12095
|
-
try {
|
|
12096
|
-
if (seatSiblings.length) await tracker.setCrewStatus(o.memberId, "working", newest(seatSiblings).ticketRecordId);
|
|
12097
|
-
else await tracker.setCrewStatus(o.memberId, "idle");
|
|
12098
|
-
} catch (e) {
|
|
12099
|
-
emit.warn(`could not clear crew status: ${e.message}`, { step: "agent" });
|
|
12100
|
-
}
|
|
12101
|
-
}
|
|
12102
|
-
try {
|
|
12103
|
-
if (shipSiblings.length) {
|
|
12104
|
-
const n = newest(shipSiblings);
|
|
12105
|
-
await tracker.beatEngaged(o.shipName, n.routeName ?? n.route, n.ticketRecordId, n.spawnedAt);
|
|
12106
|
-
} else {
|
|
12107
|
-
await tracker.beatIdle(o.shipName);
|
|
12108
|
-
}
|
|
12109
|
-
} catch (e) {
|
|
12110
|
-
emit.warn(`could not beat ship idle: ${e.message}`, { step: "agent" });
|
|
12111
|
-
}
|
|
12112
|
-
}
|
|
12113
|
-
function describeRun(run3, route2, seatIds, worktreeOf = (d, n) => {
|
|
12114
|
-
try {
|
|
12115
|
-
return worktreeForNumber(d, n)?.path ?? null;
|
|
12116
|
-
} catch {
|
|
12117
|
-
return null;
|
|
12118
|
-
}
|
|
12119
|
-
}) {
|
|
12120
|
-
const ownRoute = run3.route === routeSlug(route2.route);
|
|
12121
|
-
const repoDir = run3.repo ? reposOf(route2).find((t) => t.name === run3.repo)?.dir : void 0;
|
|
12122
|
-
const num = run3.ticket?.replace(/^\D+/, "") ?? "";
|
|
12123
|
-
return {
|
|
12124
|
-
role: run3.role,
|
|
12125
|
-
seat: ownRoute ? seatIds[run3.role] ?? null : null,
|
|
12126
|
-
ticket: run3.ticket,
|
|
12127
|
-
ticketRecordId: run3.ticketRecordId,
|
|
12128
|
-
repo: run3.repo ?? null,
|
|
12129
|
-
worktree: repoDir && num ? worktreeOf(repoDir, num) : null,
|
|
12130
|
-
stream: run3.streamPath,
|
|
12131
|
-
pid: run3.pid,
|
|
12132
|
-
startedAt: run3.spawnedAt,
|
|
12133
|
-
deadline: run3.deadline ?? null
|
|
12134
|
-
};
|
|
12135
|
-
}
|
|
12136
|
-
|
|
12137
|
-
// src/seat-keys.ts
|
|
12138
|
-
import { hostFromUrl as hostFromUrl2 } from "@tablation/client";
|
|
12139
|
-
var SEAT_KEY_ROLES = ROLE_NAMES.filter((r) => r !== "pair");
|
|
12140
|
-
function keyIsAdmin(route2) {
|
|
12141
|
-
const role = route2.resolved?.role;
|
|
12142
|
-
return role === void 0 || role === "WORKSPACE_ADMIN" || role === "PLATFORM_ADMIN";
|
|
12143
|
-
}
|
|
12144
|
-
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";
|
|
12145
|
-
var MEMBER_NO_KEY = 'no agent key for this seat \u2014 run `crew agents sync` (needs the "Mint agent keys" capability on your role)';
|
|
12146
|
-
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)';
|
|
12147
|
-
function seatKeySlug(workspaceSlug, agentId) {
|
|
12148
|
-
return `${workspaceSlug}#agent:${agentId}`;
|
|
12149
|
-
}
|
|
12150
|
-
function slugFor(route2, agentId) {
|
|
12151
|
-
const [workspaceSlug] = splitRoute(route2.route);
|
|
12152
|
-
return seatKeySlug(workspaceSlug, agentId);
|
|
12153
|
-
}
|
|
12154
|
-
function withFoundSeats(route2, found, previousReleaseAgentId) {
|
|
12155
|
-
const resolved = route2.resolved;
|
|
12156
|
-
if (!resolved) return route2;
|
|
12157
|
-
const releaseSeat2 = found.releaseSeat ?? resolved.releaseSeat;
|
|
12158
|
-
const releaseAgentId = resolved.releaseAgentId ?? previousReleaseAgentId;
|
|
12159
|
-
return {
|
|
12160
|
-
...route2,
|
|
12161
|
-
resolved: {
|
|
12162
|
-
...resolved,
|
|
12163
|
-
seats: found.seats,
|
|
12164
|
-
...releaseSeat2 ? { releaseSeat: releaseSeat2 } : {},
|
|
12165
|
-
...releaseAgentId ? { releaseAgentId } : {}
|
|
12484
|
+
obs.feed(tail3.poll());
|
|
12485
|
+
if (!killed && (msUntilDeadline(sc.deadline) ?? 1) <= 0) {
|
|
12486
|
+
killed = await killForDeadline(() => runner.kill(runner.reattach(sc), WALL_CLOCK_REASON), sc.pid, emit, sc.role, obs.liveTicket ?? sc.ticket, sc.spawnedAt, sc.maxRunMinutes);
|
|
12487
|
+
continue;
|
|
12488
|
+
}
|
|
12489
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
12166
12490
|
}
|
|
12167
|
-
}
|
|
12491
|
+
}
|
|
12492
|
+
obs.feed(tail3.poll());
|
|
12493
|
+
obs.end();
|
|
12494
|
+
await endSink(eventsSink);
|
|
12495
|
+
await runner.release(runner.reattach(sc), emit);
|
|
12496
|
+
const interrupted = killed ? WALL_CLOCK_REASON : obs.sawResult ? void 0 : adopted ? "no-result" : "daemon-exit";
|
|
12497
|
+
const { ms: ms3, ticket } = await finishRun(
|
|
12498
|
+
id,
|
|
12499
|
+
obs,
|
|
12500
|
+
emit,
|
|
12501
|
+
sc.spawnedAt,
|
|
12502
|
+
{ code: null, ...interrupted ? { interrupted } : {} },
|
|
12503
|
+
o.agentLogKey
|
|
12504
|
+
);
|
|
12505
|
+
return { adopted, ...interrupted ? { interrupted } : {}, ...killed ? { killed } : {}, ms: ms3, ticket };
|
|
12168
12506
|
}
|
|
12169
|
-
|
|
12170
|
-
|
|
12507
|
+
|
|
12508
|
+
// src/interrupted.ts
|
|
12509
|
+
var INTERRUPTED_MARKER = calloutHeader("NOTE", "Interrupted");
|
|
12510
|
+
function interruptedComment(b) {
|
|
12511
|
+
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.";
|
|
12512
|
+
return `${INTERRUPTED_MARKER}
|
|
12513
|
+
|
|
12514
|
+
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.`;
|
|
12171
12515
|
}
|
|
12172
|
-
|
|
12173
|
-
|
|
12174
|
-
const agentId = route2.resolved?.releaseAgentId;
|
|
12175
|
-
if (!agentId) return ship(keyIsAdmin(route2) ? "release seat has no linked Agents row (run `crew agents sync`)" : `release seat ${MEMBER_NO_ROW}`);
|
|
12516
|
+
var handled = /* @__PURE__ */ new Set();
|
|
12517
|
+
var pidAlive = (pid) => {
|
|
12176
12518
|
try {
|
|
12177
|
-
|
|
12178
|
-
|
|
12179
|
-
return ship(keyIsAdmin(route2) ? "no agent key stored for the release seat (run `crew agents sync`)" : MEMBER_NO_KEY);
|
|
12519
|
+
process.kill(pid, 0);
|
|
12520
|
+
return true;
|
|
12180
12521
|
} catch (e) {
|
|
12181
|
-
|
|
12182
|
-
return { kind: "ship", apiKey: resolveApiKey(route2), reason, storeError: reason };
|
|
12522
|
+
return e.code === "EPERM";
|
|
12183
12523
|
}
|
|
12184
|
-
}
|
|
12185
|
-
|
|
12186
|
-
const
|
|
12187
|
-
if (!
|
|
12188
|
-
const agentId = seatAgentId(route2, role);
|
|
12189
|
-
if (!agentId) return ship(keyIsAdmin(route2) ? "seat has no linked Agents row (run `crew agents sync`)" : `seat ${MEMBER_NO_ROW}`);
|
|
12524
|
+
};
|
|
12525
|
+
function runFinished(streamPath) {
|
|
12526
|
+
const path = `${streamPath}.meta.json`;
|
|
12527
|
+
if (!existsSync27(path)) return false;
|
|
12190
12528
|
try {
|
|
12191
|
-
|
|
12192
|
-
|
|
12193
|
-
return
|
|
12194
|
-
} catch (e) {
|
|
12195
|
-
const reason = describeReadError(e);
|
|
12196
|
-
return { kind: "ship", apiKey: resolveApiKey(route2), reason, storeError: reason };
|
|
12197
|
-
}
|
|
12198
|
-
}
|
|
12199
|
-
function safeStderrLine(e) {
|
|
12200
|
-
const err3 = e;
|
|
12201
|
-
const raw = typeof err3?.stderr === "string" && err3.stderr.trim() ? err3.stderr : typeof err3?.message === "string" ? err3.message : "";
|
|
12202
|
-
for (const line of raw.split("\n")) {
|
|
12203
|
-
const t = line.trim();
|
|
12204
|
-
if (!t || /^Command failed:/i.test(t) || /(^|\s)-w(\s|$)/.test(t)) continue;
|
|
12205
|
-
return t.slice(0, 120);
|
|
12206
|
-
}
|
|
12207
|
-
return void 0;
|
|
12208
|
-
}
|
|
12209
|
-
function describeReadError(e) {
|
|
12210
|
-
const fsErr = e;
|
|
12211
|
-
if (typeof fsErr?.path === "string" && typeof fsErr.code === "string") return `key file store unreadable (${fsErr.code})`;
|
|
12212
|
-
const code = e?.code;
|
|
12213
|
-
const line = safeStderrLine(e);
|
|
12214
|
-
const what = typeof code === "number" ? `security exited ${code}` : typeof code === "string" ? `security failed: ${code}` : void 0;
|
|
12215
|
-
const detail = [what, line].filter(Boolean).join(what && line ? ": " : "");
|
|
12216
|
-
return detail ? `OS keychain unavailable (${detail})` : "OS keychain unavailable";
|
|
12217
|
-
}
|
|
12218
|
-
function keychainExitCode(reason) {
|
|
12219
|
-
const m = /security exited (\d+)/.exec(reason);
|
|
12220
|
-
return m ? Number(m[1]) : void 0;
|
|
12221
|
-
}
|
|
12222
|
-
function describeStoreError(e) {
|
|
12223
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
12224
|
-
if (/User interaction is not allowed/i.test(msg)) {
|
|
12225
|
-
return "keychain unavailable (User interaction is not allowed \u2014 run from a logged-in session, or `security unlock-keychain` first)";
|
|
12529
|
+
return JSON.parse(readFileSync27(path, "utf8")).running !== true;
|
|
12530
|
+
} catch {
|
|
12531
|
+
return true;
|
|
12226
12532
|
}
|
|
12227
|
-
return "keychain unavailable (could not write to the OS keychain)";
|
|
12228
12533
|
}
|
|
12229
|
-
|
|
12534
|
+
function orphanedStreams(o) {
|
|
12535
|
+
const dir = join30(o.stateDir, "streams");
|
|
12536
|
+
if (!existsSync27(dir)) return [];
|
|
12537
|
+
const ownerAlive = o.isOwnerAlive ?? pidAlive;
|
|
12230
12538
|
const out = [];
|
|
12231
|
-
|
|
12232
|
-
|
|
12233
|
-
|
|
12234
|
-
|
|
12235
|
-
|
|
12236
|
-
|
|
12237
|
-
|
|
12238
|
-
|
|
12239
|
-
try {
|
|
12240
|
-
store = (o.getStore ?? getSessionStore)();
|
|
12241
|
-
} catch (e) {
|
|
12242
|
-
return targets.map(({ role }) => ({
|
|
12243
|
-
role,
|
|
12244
|
-
action: "failed",
|
|
12245
|
-
detail: describeStoreError(e)
|
|
12246
|
-
}));
|
|
12247
|
-
}
|
|
12248
|
-
const host = hostFromUrl2(route2.baseUrl);
|
|
12249
|
-
const base = route2.baseUrl.replace(/\/+$/, "");
|
|
12250
|
-
if (!o.dryRun) {
|
|
12251
|
-
const probeSlug = seatKeySlug(splitRoute(route2.route)[0], "_probe");
|
|
12252
|
-
try {
|
|
12253
|
-
await store.set(host, probeSlug, {
|
|
12254
|
-
apiKey: "probe",
|
|
12255
|
-
workspaceId: resolved.workspaceId,
|
|
12256
|
-
identityId: "_probe",
|
|
12257
|
-
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
12258
|
-
});
|
|
12259
|
-
await store.clear(host, probeSlug);
|
|
12260
|
-
} catch (e) {
|
|
12261
|
-
const detail = `${describeStoreError(e)}; no keys minted`;
|
|
12262
|
-
return targets.map(({ role, agentId }) => agentId ? { role, action: "failed", detail } : { role, action: "no_agent" });
|
|
12263
|
-
}
|
|
12539
|
+
for (const name of readdirSync9(dir)) {
|
|
12540
|
+
if (!name.endsWith(".jsonl") || name.endsWith(".events.jsonl")) continue;
|
|
12541
|
+
const streamPath = join30(dir, name);
|
|
12542
|
+
if (runFinished(streamPath)) continue;
|
|
12543
|
+
const sidecar = readRunSidecar(streamPath);
|
|
12544
|
+
if (!sidecar || sidecar.route !== o.routeLabel) continue;
|
|
12545
|
+
if (ownerAlive(sidecar.ownerPid)) continue;
|
|
12546
|
+
out.push({ streamPath, sidecar });
|
|
12264
12547
|
}
|
|
12265
|
-
|
|
12266
|
-
|
|
12267
|
-
|
|
12268
|
-
|
|
12269
|
-
|
|
12270
|
-
|
|
12271
|
-
|
|
12272
|
-
|
|
12273
|
-
|
|
12274
|
-
|
|
12275
|
-
|
|
12276
|
-
|
|
12277
|
-
out.push({ role, action: "minted", detail: "dry run" });
|
|
12278
|
-
continue;
|
|
12279
|
-
}
|
|
12280
|
-
const res = await doFetch(`${base}/api/workspaces/${resolved.workspaceId}/agents/${agentId}/api-keys`, {
|
|
12281
|
-
method: "POST",
|
|
12282
|
-
headers: {
|
|
12283
|
-
Authorization: `Bearer ${resolveApiKey(route2)}`,
|
|
12284
|
-
"Content-Type": "application/json",
|
|
12285
|
-
"User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0"
|
|
12286
|
-
},
|
|
12287
|
-
body: JSON.stringify({ name: `crew ${o.shipName ?? "ship"} ${role}`, ...actorRecordId ? { actorRecordId } : {} })
|
|
12288
|
-
});
|
|
12289
|
-
if (res.status === 403) {
|
|
12290
|
-
out.push({ role, action: "forbidden" });
|
|
12291
|
-
break;
|
|
12292
|
-
}
|
|
12293
|
-
if (!res.ok) {
|
|
12294
|
-
out.push({ role, action: "failed", detail: `${res.status} ${res.statusText}` });
|
|
12295
|
-
continue;
|
|
12296
|
-
}
|
|
12297
|
-
const body = await res.json();
|
|
12298
|
-
if (!body.key) {
|
|
12299
|
-
out.push({ role, action: "failed", detail: "response carried no key" });
|
|
12300
|
-
continue;
|
|
12301
|
-
}
|
|
12548
|
+
return out;
|
|
12549
|
+
}
|
|
12550
|
+
async function reconcileRuns(o) {
|
|
12551
|
+
let adopted = 0;
|
|
12552
|
+
let interrupted = 0;
|
|
12553
|
+
for (const { streamPath, sidecar } of orphanedStreams(o)) {
|
|
12554
|
+
if (handled.has(streamPath)) continue;
|
|
12555
|
+
handled.add(streamPath);
|
|
12556
|
+
const isAlive = o.isAlive ?? runIsAlive;
|
|
12557
|
+
const lock = isAlive(sidecar) ? o.state.acquireRun(sidecar.role, o.maxConcurrentAgents, o.roleConcurrency?.[sidecar.role] ?? 1) : null;
|
|
12558
|
+
const ticketLock = isAlive(sidecar) && sidecar.ticketRecordId ? o.state.acquireTicket(sidecar.ticketRecordId) : null;
|
|
12559
|
+
const work = (async () => {
|
|
12302
12560
|
try {
|
|
12303
|
-
const
|
|
12304
|
-
|
|
12305
|
-
|
|
12306
|
-
|
|
12307
|
-
|
|
12308
|
-
...body.id ? { keyId: body.id } : {}
|
|
12309
|
-
};
|
|
12310
|
-
await store.set(host, slug2, session);
|
|
12311
|
-
} catch (e) {
|
|
12312
|
-
let revoked = false;
|
|
12313
|
-
if (body.id) {
|
|
12314
|
-
try {
|
|
12315
|
-
const del2 = await doFetch(`${base}/api/workspaces/${resolved.workspaceId}/agents/${agentId}/api-keys/${body.id}`, {
|
|
12316
|
-
method: "DELETE",
|
|
12317
|
-
headers: { Authorization: `Bearer ${resolveApiKey(route2)}`, "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0" }
|
|
12318
|
-
});
|
|
12319
|
-
revoked = del2.ok;
|
|
12320
|
-
} catch {
|
|
12321
|
-
revoked = false;
|
|
12322
|
-
}
|
|
12323
|
-
}
|
|
12324
|
-
out.push({
|
|
12325
|
-
role,
|
|
12326
|
-
action: "failed",
|
|
12327
|
-
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`
|
|
12561
|
+
const result = await reattachRun(streamPath, sidecar, o.emit, {
|
|
12562
|
+
agentLogKey: await o.agentLogKey?.(sidecar.role).catch(() => void 0),
|
|
12563
|
+
isAlive: o.isAlive,
|
|
12564
|
+
pollMs: o.pollMs,
|
|
12565
|
+
onAdopted: () => o.emit.emit(`adopted ${sidecar.role} run (pid ${sidecar.pid}) left by a previous crew process`, { step: "agent", role: sidecar.role })
|
|
12328
12566
|
});
|
|
12329
|
-
|
|
12567
|
+
if (result.adopted) adopted++;
|
|
12568
|
+
else interrupted++;
|
|
12569
|
+
try {
|
|
12570
|
+
await o.afterRun?.(sidecar, result);
|
|
12571
|
+
} catch (e) {
|
|
12572
|
+
o.emit.warn(`could not follow up on ${sidecar.role} run ${streamPath}: ${e.message}`, { step: "agent" });
|
|
12573
|
+
}
|
|
12574
|
+
} catch (e) {
|
|
12575
|
+
o.emit.warn(`could not reattach ${sidecar.role} run ${streamPath}: ${e.message}`, { step: "agent" });
|
|
12576
|
+
} finally {
|
|
12577
|
+
if (lock?.ok) lock.release();
|
|
12578
|
+
if (ticketLock?.ok) ticketLock.release();
|
|
12330
12579
|
}
|
|
12331
|
-
|
|
12332
|
-
|
|
12333
|
-
|
|
12334
|
-
|
|
12335
|
-
|
|
12336
|
-
|
|
12337
|
-
}
|
|
12338
|
-
|
|
12339
|
-
|
|
12340
|
-
case "minted":
|
|
12341
|
-
return `${o.role}: agent key minted${o.detail ? ` (${o.detail})` : ""}`;
|
|
12342
|
-
case "already_stored":
|
|
12343
|
-
return `${o.role}: agent key already stored`;
|
|
12344
|
-
case "no_agent":
|
|
12345
|
-
return `${o.role}: no linked Agents row \u2014 writes as the ship until \`crew agents sync\` links one`;
|
|
12346
|
-
case "forbidden":
|
|
12347
|
-
return FORBIDDEN_LINE;
|
|
12348
|
-
case "failed":
|
|
12349
|
-
return `${o.role}: could not mint an agent key (${o.detail ?? "unknown"}) \u2014 writes as the ship`;
|
|
12580
|
+
})();
|
|
12581
|
+
if (isAlive(sidecar)) {
|
|
12582
|
+
if (o.inFlight) {
|
|
12583
|
+
o.inFlight.add(work);
|
|
12584
|
+
void work.finally(() => o.inFlight.delete(work));
|
|
12585
|
+
}
|
|
12586
|
+
} else {
|
|
12587
|
+
await work;
|
|
12588
|
+
}
|
|
12350
12589
|
}
|
|
12590
|
+
return { adopted, interrupted };
|
|
12351
12591
|
}
|
|
12352
|
-
|
|
12592
|
+
|
|
12593
|
+
// src/live-runs.ts
|
|
12594
|
+
import { existsSync as existsSync28, readdirSync as readdirSync10, statSync as statSync9 } from "fs";
|
|
12595
|
+
import { join as join31 } from "path";
|
|
12596
|
+
function liveRuns(stateDir, isAlive = runIsAlive) {
|
|
12597
|
+
const dir = join31(stateDir, "streams");
|
|
12598
|
+
if (!existsSync28(dir)) return [];
|
|
12353
12599
|
const out = [];
|
|
12354
|
-
|
|
12355
|
-
|
|
12356
|
-
|
|
12357
|
-
|
|
12358
|
-
|
|
12359
|
-
|
|
12360
|
-
|
|
12361
|
-
let store;
|
|
12362
|
-
try {
|
|
12363
|
-
store = (o.getStore ?? getSessionStore)();
|
|
12364
|
-
} catch (e) {
|
|
12365
|
-
return targets.map(({ role }) => ({ role, action: "failed", detail: describeStoreError(e) }));
|
|
12600
|
+
for (const name of readdirSync10(dir)) {
|
|
12601
|
+
if (!name.endsWith(".jsonl") || name.endsWith(".events.jsonl")) continue;
|
|
12602
|
+
const streamPath = join31(dir, name);
|
|
12603
|
+
if (runFinished(streamPath)) continue;
|
|
12604
|
+
const sidecar = readRunSidecar(streamPath);
|
|
12605
|
+
if (!sidecar || !isAlive(sidecar)) continue;
|
|
12606
|
+
out.push({ ...sidecar, streamPath });
|
|
12366
12607
|
}
|
|
12367
|
-
|
|
12368
|
-
|
|
12369
|
-
|
|
12370
|
-
|
|
12371
|
-
|
|
12372
|
-
|
|
12608
|
+
return out.sort((a, b) => a.spawnedAt.localeCompare(b.spawnedAt));
|
|
12609
|
+
}
|
|
12610
|
+
function runActivity(stateDir, isAlive = runIsAlive) {
|
|
12611
|
+
const activeRuns = liveRuns(stateDir, isAlive).length;
|
|
12612
|
+
const dir = join31(stateDir, "streams");
|
|
12613
|
+
let newest = 0;
|
|
12614
|
+
if (existsSync28(dir)) {
|
|
12615
|
+
for (const name of readdirSync10(dir)) {
|
|
12616
|
+
if (!name.endsWith(".jsonl") || name.endsWith(".events.jsonl")) continue;
|
|
12617
|
+
const streamPath = join31(dir, name);
|
|
12618
|
+
if (!runFinished(streamPath)) continue;
|
|
12619
|
+
try {
|
|
12620
|
+
newest = Math.max(newest, statSync9(`${streamPath}.meta.json`).mtimeMs);
|
|
12621
|
+
} catch {
|
|
12622
|
+
}
|
|
12373
12623
|
}
|
|
12374
|
-
|
|
12624
|
+
}
|
|
12625
|
+
return { activeRuns, lastRunEndedAt: newest ? new Date(newest).toISOString() : null };
|
|
12626
|
+
}
|
|
12627
|
+
function siblingRuns(runs, ownTicketRecordId) {
|
|
12628
|
+
return runs.filter((r) => !ownTicketRecordId || r.ticketRecordId !== ownTicketRecordId);
|
|
12629
|
+
}
|
|
12630
|
+
async function settleRunEnd(o) {
|
|
12631
|
+
const { tracker, emit } = o;
|
|
12632
|
+
const all = o.runs ?? liveRuns(o.stateDir);
|
|
12633
|
+
const slug2 = routeSlug(o.route.route);
|
|
12634
|
+
const seatSiblings = siblingRuns(all.filter((r) => r.role === o.role && r.route === slug2), o.ownTicketRecordId);
|
|
12635
|
+
const shipSiblings = siblingRuns(all, o.ownTicketRecordId);
|
|
12636
|
+
const newest = (rs) => rs[rs.length - 1];
|
|
12637
|
+
if (o.memberId) {
|
|
12375
12638
|
try {
|
|
12376
|
-
|
|
12377
|
-
|
|
12378
|
-
out.push({ role, action: "not_stored" });
|
|
12379
|
-
continue;
|
|
12380
|
-
}
|
|
12381
|
-
if (!session.keyId) {
|
|
12382
|
-
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" });
|
|
12383
|
-
continue;
|
|
12384
|
-
}
|
|
12385
|
-
if (o.dryRun) {
|
|
12386
|
-
out.push({ role, action: "revoked", detail: "dry run" });
|
|
12387
|
-
continue;
|
|
12388
|
-
}
|
|
12389
|
-
const del2 = await doFetch(`${base}/api/workspaces/${resolved.workspaceId}/agents/${agentId}/api-keys/${session.keyId}`, {
|
|
12390
|
-
method: "DELETE",
|
|
12391
|
-
headers: { Authorization: `Bearer ${resolveApiKey(route2)}`, "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0" }
|
|
12392
|
-
});
|
|
12393
|
-
if (!del2.ok && del2.status !== 404) {
|
|
12394
|
-
out.push({ role, action: "failed", detail: `${del2.status} ${del2.statusText}` });
|
|
12395
|
-
continue;
|
|
12396
|
-
}
|
|
12397
|
-
await store.clear(host, slug2);
|
|
12398
|
-
out.push({ role, action: "revoked", detail: del2.ok ? void 0 : "server already had no such key" });
|
|
12639
|
+
if (seatSiblings.length) await tracker.setCrewStatus(o.memberId, "working", newest(seatSiblings).ticketRecordId);
|
|
12640
|
+
else await tracker.setCrewStatus(o.memberId, "idle");
|
|
12399
12641
|
} catch (e) {
|
|
12400
|
-
|
|
12642
|
+
emit.warn(`could not clear crew status: ${e.message}`, { step: "agent" });
|
|
12401
12643
|
}
|
|
12402
12644
|
}
|
|
12403
|
-
|
|
12404
|
-
|
|
12405
|
-
|
|
12406
|
-
|
|
12407
|
-
|
|
12408
|
-
|
|
12409
|
-
|
|
12410
|
-
|
|
12411
|
-
|
|
12412
|
-
return `${o.role}: no linked Agents row`;
|
|
12413
|
-
case "failed":
|
|
12414
|
-
return `${o.role}: could not revoke the agent key (${o.detail ?? "unknown"}) \u2014 left in the keychain`;
|
|
12645
|
+
try {
|
|
12646
|
+
if (shipSiblings.length) {
|
|
12647
|
+
const n = newest(shipSiblings);
|
|
12648
|
+
await tracker.beatEngaged(o.shipName, n.routeName ?? n.route, n.ticketRecordId, n.spawnedAt);
|
|
12649
|
+
} else {
|
|
12650
|
+
await tracker.beatIdle(o.shipName);
|
|
12651
|
+
}
|
|
12652
|
+
} catch (e) {
|
|
12653
|
+
emit.warn(`could not beat ship idle: ${e.message}`, { step: "agent" });
|
|
12415
12654
|
}
|
|
12416
12655
|
}
|
|
12417
|
-
|
|
12418
|
-
const resolved = route2.resolved;
|
|
12419
|
-
if (!resolved) return void 0;
|
|
12420
|
-
const agentId = seatAgentId(route2, "dev") ?? SEAT_KEY_ROLES.map((r) => seatAgentId(route2, r)).find(Boolean);
|
|
12421
|
-
if (!agentId) return void 0;
|
|
12656
|
+
function describeRun(run3, route2, seatIds, worktreeOf = (d, n) => {
|
|
12422
12657
|
try {
|
|
12423
|
-
|
|
12424
|
-
headers: { Authorization: `Bearer ${resolveApiKey(route2)}`, "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0" }
|
|
12425
|
-
});
|
|
12426
|
-
if (res.ok) return true;
|
|
12427
|
-
return res.status === 403 ? false : void 0;
|
|
12658
|
+
return worktreeForNumber(d, n)?.path ?? null;
|
|
12428
12659
|
} catch {
|
|
12429
|
-
return
|
|
12660
|
+
return null;
|
|
12430
12661
|
}
|
|
12662
|
+
}) {
|
|
12663
|
+
const ownRoute = run3.route === routeSlug(route2.route);
|
|
12664
|
+
const repoDir = run3.repo ? reposOf(route2).find((t) => t.name === run3.repo)?.dir : void 0;
|
|
12665
|
+
const num = run3.ticket?.replace(/^\D+/, "") ?? "";
|
|
12666
|
+
return {
|
|
12667
|
+
role: run3.role,
|
|
12668
|
+
seat: ownRoute ? seatIds[run3.role] ?? null : null,
|
|
12669
|
+
ticket: run3.ticket,
|
|
12670
|
+
ticketRecordId: run3.ticketRecordId,
|
|
12671
|
+
repo: run3.repo ?? null,
|
|
12672
|
+
worktree: repoDir && num ? worktreeOf(repoDir, num) : null,
|
|
12673
|
+
stream: run3.streamPath,
|
|
12674
|
+
pid: run3.pid,
|
|
12675
|
+
startedAt: run3.spawnedAt,
|
|
12676
|
+
deadline: run3.deadline ?? null
|
|
12677
|
+
};
|
|
12431
12678
|
}
|
|
12432
12679
|
|
|
12433
12680
|
// src/deprovision-host.ts
|
|
@@ -12571,6 +12818,10 @@ function cloneScript(o) {
|
|
|
12571
12818
|
const mirror = `${o.mirrorDir}/${mirrorRelPath(o.repo)}`;
|
|
12572
12819
|
const depsRoot = `${o.mirrorDir}/${depsRelDir(o.repo)}`;
|
|
12573
12820
|
return [
|
|
12821
|
+
// The mirror is written by the helper container as root and mounted read-only,
|
|
12822
|
+
// so git sees it as owned by someone else and refuses it. Global (not `-c`) so
|
|
12823
|
+
// it holds for every later git call in the run that reads through the alternates.
|
|
12824
|
+
`git config --global --add safe.directory '*'`,
|
|
12574
12825
|
`git clone --quiet --shared ${q(mirror)} ${q(o.workDir)}`,
|
|
12575
12826
|
`cd ${q(o.workDir)}`,
|
|
12576
12827
|
`git remote set-url origin ${q(o.origin)}`,
|
|
@@ -12805,7 +13056,10 @@ var SandboxCache = class {
|
|
|
12805
13056
|
};
|
|
12806
13057
|
function describe(e) {
|
|
12807
13058
|
const err3 = e;
|
|
12808
|
-
|
|
13059
|
+
const lines2 = (err3.stderr?.trim() || err3.message || String(e)).split("\n");
|
|
13060
|
+
const tail3 = lines2.slice(-3);
|
|
13061
|
+
const first = lines2.find((l) => /^(fatal|error):/.test(l.trim()));
|
|
13062
|
+
return (first && !tail3.includes(first) ? [first, ...tail3] : tail3).join(" / ");
|
|
12809
13063
|
}
|
|
12810
13064
|
function cacheSettingsOf(cfg) {
|
|
12811
13065
|
if (!cfg?.cacheVolume) return null;
|
|
@@ -19499,100 +19753,6 @@ function formatWhoami(report) {
|
|
|
19499
19753
|
`;
|
|
19500
19754
|
}
|
|
19501
19755
|
|
|
19502
|
-
// src/seat-read-probe.ts
|
|
19503
|
-
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";
|
|
19504
|
-
function probeTables(route2) {
|
|
19505
|
-
const m = route2.resolved?.models;
|
|
19506
|
-
if (!m) return [];
|
|
19507
|
-
const r = route2.resolved;
|
|
19508
|
-
return [
|
|
19509
|
-
{ name: "Issues", modelId: m.issues },
|
|
19510
|
-
{ name: "Comments", modelId: m.comments },
|
|
19511
|
-
{ name: "Crew", modelId: m.crew },
|
|
19512
|
-
{ name: "Ships", modelId: r?.shipsModelId ?? "" }
|
|
19513
|
-
].filter((t) => !!t.modelId);
|
|
19514
|
-
}
|
|
19515
|
-
async function probeTableRead(route2, modelId, apiKey, o = {}) {
|
|
19516
|
-
const url = `${route2.baseUrl.replace(/\/+$/, "")}/api/data-models/${modelId}/records?limit=1`;
|
|
19517
|
-
try {
|
|
19518
|
-
const res = await (o.fetchImpl ?? fetch)(url, {
|
|
19519
|
-
headers: { Authorization: `Bearer ${apiKey}`, "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0" }
|
|
19520
|
-
});
|
|
19521
|
-
if (res.ok) return { state: "ok" };
|
|
19522
|
-
if (res.status === 403) return { state: "forbidden" };
|
|
19523
|
-
return { state: "unknown", detail: `HTTP ${res.status}` };
|
|
19524
|
-
} catch (e) {
|
|
19525
|
-
return { state: "unknown", detail: e.message };
|
|
19526
|
-
}
|
|
19527
|
-
}
|
|
19528
|
-
async function probeSeatReads(route2, o = {}) {
|
|
19529
|
-
const out = [];
|
|
19530
|
-
const roles = [...SEAT_KEY_ROLES.filter((r) => route2.resolved?.seats[r]), ...route2.resolved?.releaseSeat ? ["release"] : []];
|
|
19531
|
-
for (const role of roles) {
|
|
19532
|
-
const id = role === "release" ? await resolveReleaseKey(route2, o.getStore) : await resolveSeatKey(route2, role, o.getStore);
|
|
19533
|
-
if (id.kind !== "agent") continue;
|
|
19534
|
-
const tables = [];
|
|
19535
|
-
for (const t of probeTables(route2)) tables.push({ table: t.name, ...await probeTableRead(route2, t.modelId, id.apiKey, o) });
|
|
19536
|
-
const bad = tables.find((t) => t.state === "forbidden") ?? tables.find((t) => t.state === "unknown");
|
|
19537
|
-
out.push({ role, state: bad?.state ?? (tables.length ? "ok" : "unknown"), detail: bad?.detail ?? (tables.length ? void 0 : "route has no resolved tables"), tables });
|
|
19538
|
-
}
|
|
19539
|
-
return out;
|
|
19540
|
-
}
|
|
19541
|
-
function forbiddenDetail(e, route2) {
|
|
19542
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
19543
|
-
const m = /^([A-Z]+) (\S+) -> 403/.exec(msg);
|
|
19544
|
-
const verb = /No (READ|WRITE) access/i.exec(msg)?.[1]?.toUpperCase();
|
|
19545
|
-
const modelId = /\/data-models\/([\w-]+)/i.exec(m?.[2] ?? "")?.[1];
|
|
19546
|
-
const r = route2?.resolved;
|
|
19547
|
-
const known = [
|
|
19548
|
-
[r?.models?.issues, "Issues"],
|
|
19549
|
-
[r?.models?.comments, "Comments"],
|
|
19550
|
-
[r?.models?.crew, "Crew"],
|
|
19551
|
-
[r?.shipsModelId, "Ships"],
|
|
19552
|
-
[r?.locksModelId, "Locks"],
|
|
19553
|
-
[r?.epicsModelId, "Epics"],
|
|
19554
|
-
[r?.periscopesModelId, "Periscopes"],
|
|
19555
|
-
[r?.reposModelId, "Repos"]
|
|
19556
|
-
];
|
|
19557
|
-
const table2 = modelId ? known.find(([id]) => id && id === modelId)?.[1] ?? `table ${modelId}` : void 0;
|
|
19558
|
-
return { method: m?.[1], path: m?.[2], table: table2, verb };
|
|
19559
|
-
}
|
|
19560
|
-
function remedyFor(d, roleName = "the agent role") {
|
|
19561
|
-
if (d.table && d.verb && !(d.table === "Issues" && d.verb === "READ")) {
|
|
19562
|
-
return `grant ${d.verb} on ${d.table} to ${roleName}`;
|
|
19563
|
-
}
|
|
19564
|
-
return SEAT_NO_READ_REMEDY;
|
|
19565
|
-
}
|
|
19566
|
-
function describeSeatForbidden(role, tables = []) {
|
|
19567
|
-
const bad = tables.filter((t) => t.state === "forbidden").map((t) => t.table);
|
|
19568
|
-
const what = bad.length ? bad.join(", ") : "Issues";
|
|
19569
|
-
const remedy = bad.length && !(bad.length === 1 && bad[0] === "Issues") ? bad.map((t) => remedyFor({ table: t, verb: "READ" })).join("; ") : SEAT_NO_READ_REMEDY;
|
|
19570
|
-
return `seat read: ERROR ${role} agent key cannot read ${what} (403) \u2014 ${remedy}`;
|
|
19571
|
-
}
|
|
19572
|
-
function describeSeatTables(role, tables) {
|
|
19573
|
-
const cells = tables.map((t) => `${t.table} ${t.state === "ok" ? "ok" : t.state === "forbidden" ? "403" : `error (${t.detail ?? "?"})`}`);
|
|
19574
|
-
return `seat read: ${role}: ${cells.join(", ")}`;
|
|
19575
|
-
}
|
|
19576
|
-
function isForbiddenError(e) {
|
|
19577
|
-
const status2 = e?.status;
|
|
19578
|
-
if (status2 === 403) return true;
|
|
19579
|
-
return /\b403\b/.test(e instanceof Error ? e.message : String(e));
|
|
19580
|
-
}
|
|
19581
|
-
var SEAT_FORBIDDEN_KEY = "seat_forbidden:release";
|
|
19582
|
-
function seatForbiddenItem(now = /* @__PURE__ */ new Date(), e, route2) {
|
|
19583
|
-
const d = e === void 0 ? {} : forbiddenDetail(e, route2);
|
|
19584
|
-
const what = d.method && d.table ? `${d.method} ${d.table}${d.verb ? ` -> 403 No ${d.verb} access` : " -> 403"}` : "cannot read the Issues table (403)";
|
|
19585
|
-
return {
|
|
19586
|
-
kind: "seat_forbidden",
|
|
19587
|
-
key: SEAT_FORBIDDEN_KEY,
|
|
19588
|
-
message: `release seat: ${what} \u2014 ${remedyFor(d)}`,
|
|
19589
|
-
since: now.toISOString()
|
|
19590
|
-
};
|
|
19591
|
-
}
|
|
19592
|
-
function seatForbiddenRepoLine(scope) {
|
|
19593
|
-
return `${scope}: skipped \u2014 release seat was refused by the tracker (see attention)`;
|
|
19594
|
-
}
|
|
19595
|
-
|
|
19596
19756
|
// src/cli.ts
|
|
19597
19757
|
import { StaleWriteError as StaleWriteError9 } from "@tablation/client";
|
|
19598
19758
|
|
|
@@ -23504,6 +23664,14 @@ async function runOnePassUnguarded(o) {
|
|
|
23504
23664
|
for (const role of order) {
|
|
23505
23665
|
const count = live.get(role);
|
|
23506
23666
|
if (count === void 0) continue;
|
|
23667
|
+
if (openShipAttention(o.state).some((i) => i.key === seatCannotWriteKey(role))) {
|
|
23668
|
+
if (await (o.seatWriteRefused ?? ((r) => seatWriteStillRefused(o.route, r, { userAgent: o.ship.userAgent })))(role)) {
|
|
23669
|
+
if (count === 0) skipped.push({ role, reason: "seat role cannot write the hand-off fields \u2014 see attention" });
|
|
23670
|
+
live.delete(role);
|
|
23671
|
+
continue;
|
|
23672
|
+
}
|
|
23673
|
+
clearShipAttention(o.state, seatCannotWriteKey(role));
|
|
23674
|
+
}
|
|
23507
23675
|
if (!breaker.allows(role)) {
|
|
23508
23676
|
if (count === 0) skipped.push({ role, reason: "agent recently failed to start \u2014 waiting before retry" });
|
|
23509
23677
|
live.delete(role);
|
|
@@ -23838,6 +24006,17 @@ async function runRoleAgentLocked(role, decision, ctx, hooks, held) {
|
|
|
23838
24006
|
agentPersona: await fetchSeatPersona(route2, role, { userAgent: ship.userAgent }),
|
|
23839
24007
|
resolvedAgentId: await resolveAgentId(route2, role, { userAgent: ship.userAgent })
|
|
23840
24008
|
});
|
|
24009
|
+
const raiseIfSeatForbidden = async (e, what) => {
|
|
24010
|
+
if (identity.kind !== "agent" || !isForbiddenError(e)) return;
|
|
24011
|
+
const d = forbiddenDetail(e, route2);
|
|
24012
|
+
try {
|
|
24013
|
+
await raiseShipAttention(
|
|
24014
|
+
{ state: ctx.state, emit, route: route2, ship },
|
|
24015
|
+
seatCannotWriteItem(role, `${what} refused${d.table ? ` on ${d.table}` : ""}${d.verb ? ` (no ${d.verb} access)` : ""}`)
|
|
24016
|
+
);
|
|
24017
|
+
} catch {
|
|
24018
|
+
}
|
|
24019
|
+
};
|
|
23841
24020
|
emit.enter("agent", role);
|
|
23842
24021
|
emit.emit(`starting agent run for ${route2.route}`);
|
|
23843
24022
|
const memberId = route2.resolved?.seats[role];
|
|
@@ -23846,12 +24025,14 @@ async function runRoleAgentLocked(role, decision, ctx, hooks, held) {
|
|
|
23846
24025
|
await tracker.setCrewStatus(memberId, "working", workingId);
|
|
23847
24026
|
} catch (e) {
|
|
23848
24027
|
emit.warn(`could not set crew status: ${e.message}`, { step: "agent" });
|
|
24028
|
+
await raiseIfSeatForbidden(e, "status write");
|
|
23849
24029
|
}
|
|
23850
24030
|
}
|
|
23851
24031
|
try {
|
|
23852
24032
|
await tracker.beatEngaged(ship.name, route2.route, workingId);
|
|
23853
24033
|
} catch (e) {
|
|
23854
24034
|
emit.warn(`could not beat ship engaged: ${e.message}`, { step: "agent" });
|
|
24035
|
+
await raiseIfSeatForbidden(e, "Ships heartbeat");
|
|
23855
24036
|
}
|
|
23856
24037
|
try {
|
|
23857
24038
|
const runStart = (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -30544,6 +30725,11 @@ watermark: ${state.watermark()}
|
|
|
30544
30725
|
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(/^.*?— /, "") });
|
|
30545
30726
|
} else if (p.state === "ok") passed++;
|
|
30546
30727
|
}
|
|
30728
|
+
for (const w of await probeSeatWrites(route2, { userAgent: cfg.ship.userAgent })) {
|
|
30729
|
+
if (w.state === "forbidden") {
|
|
30730
|
+
problems.push({ severity: "blocker", kind: "seat_cannot_write", route: route2.route, message: seatCannotWriteMessage(w.role, w.tables), remedy: SEAT_NO_WRITE_REMEDY });
|
|
30731
|
+
} else if (w.state === "ok") passed++;
|
|
30732
|
+
}
|
|
30547
30733
|
}
|
|
30548
30734
|
for (const message of roleCapWarnings(cfg.ship)) problems.push({ severity: "note", kind: "role_cap_above_ship_cap", message });
|
|
30549
30735
|
if (!flag("offline")) {
|
|
@@ -30779,6 +30965,14 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
|
|
|
30779
30965
|
`);
|
|
30780
30966
|
}
|
|
30781
30967
|
}
|
|
30968
|
+
for (const w of await probeSeatWrites(roleRoute, { userAgent: cfg.ship.userAgent })) {
|
|
30969
|
+
if (w.state === "forbidden") {
|
|
30970
|
+
process.stdout.write(`seat write: ERROR ${seatCannotWriteMessage(w.role, w.tables)} (READ only) \u2014 ${SEAT_NO_WRITE_REMEDY}
|
|
30971
|
+
`);
|
|
30972
|
+
process.exitCode = 1;
|
|
30973
|
+
} else if (w.state === "ok") process.stdout.write(`seat write: ${w.role}: ok
|
|
30974
|
+
`);
|
|
30975
|
+
}
|
|
30782
30976
|
}
|
|
30783
30977
|
const [ships, crewRows] = await Promise.all([tracker.shipRows(), tracker.crewRows()]);
|
|
30784
30978
|
if (ships.length === 0) {
|