@tablation/crew 0.93.0 → 0.95.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/crew.example.yaml +1 -0
- package/dist/cli.js +538 -125
- package/package.json +1 -1
package/crew.example.yaml
CHANGED
|
@@ -56,6 +56,7 @@ ship:
|
|
|
56
56
|
# deep: { model: claude-opus-5-5 }
|
|
57
57
|
# args: [] # appended to the harness argv
|
|
58
58
|
# env: {} # added to the session environment
|
|
59
|
+
# maxRunMinutes: 240 # wall-clock limit per agent run (default 240; 0 = none); a route's maxRunMinutes overrides it (CREW-2786)
|
|
59
60
|
# gemini: { harness: gemini, bin: gemini, model: gemini-2.5-pro }
|
|
60
61
|
|
|
61
62
|
# shell: bash # defaults per platform
|
package/dist/cli.js
CHANGED
|
@@ -363,11 +363,11 @@ var Emitter = class _Emitter {
|
|
|
363
363
|
const result = await fn();
|
|
364
364
|
this.emit(message, { data: { ms: this.now().getTime() - started, ok: true } });
|
|
365
365
|
return result;
|
|
366
|
-
} catch (
|
|
367
|
-
this.error(`${message} \u2014 ${
|
|
366
|
+
} catch (err3) {
|
|
367
|
+
this.error(`${message} \u2014 ${err3.message}`, {
|
|
368
368
|
data: { ms: this.now().getTime() - started, ok: false }
|
|
369
369
|
});
|
|
370
|
-
throw
|
|
370
|
+
throw err3;
|
|
371
371
|
}
|
|
372
372
|
}
|
|
373
373
|
};
|
|
@@ -2665,7 +2665,8 @@ var ROUTE_KEYS = /* @__PURE__ */ new Set([
|
|
|
2665
2665
|
"promptSet",
|
|
2666
2666
|
"hostPassengers",
|
|
2667
2667
|
"hostPeriscopes",
|
|
2668
|
-
"toolchain"
|
|
2668
|
+
"toolchain",
|
|
2669
|
+
"maxRunMinutes"
|
|
2669
2670
|
]);
|
|
2670
2671
|
function parseWorktreeName(v, where, file) {
|
|
2671
2672
|
const problem = typeof v === "string" ? worktreeNameProblem(v) : "must be a string";
|
|
@@ -3075,7 +3076,21 @@ Create the first of those:
|
|
|
3075
3076
|
function expandCommand(cmd, base) {
|
|
3076
3077
|
return cmd.includes("/") || cmd.startsWith("~") ? expand(cmd, base) : cmd;
|
|
3077
3078
|
}
|
|
3078
|
-
var TOOLCHAIN_KEYS = /* @__PURE__ */ new Set(["harness", "bin", "launcher", "launcherBin", "model", "tiers", "args", "env", "maxThinkingTokens", "auth"]);
|
|
3079
|
+
var TOOLCHAIN_KEYS = /* @__PURE__ */ new Set(["harness", "bin", "launcher", "launcherBin", "model", "tiers", "args", "env", "maxThinkingTokens", "auth", "maxRunMinutes"]);
|
|
3080
|
+
var MAX_RUN_MINUTES_CEILING = Math.floor((2 ** 31 - 1) / 6e4);
|
|
3081
|
+
function parseMaxRunMinutes(raw, where, missing) {
|
|
3082
|
+
if (raw === void 0) return void 0;
|
|
3083
|
+
const n = Number(raw);
|
|
3084
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
3085
|
+
missing.add(`${where} must be a non-negative integer number of minutes, 0 for no limit (got "${String(raw)}")`);
|
|
3086
|
+
return void 0;
|
|
3087
|
+
}
|
|
3088
|
+
if (n > MAX_RUN_MINUTES_CEILING) {
|
|
3089
|
+
missing.add(`${where} is too large (${n}); the most it can be is ${MAX_RUN_MINUTES_CEILING} minutes \u2014 use 0 for no limit`);
|
|
3090
|
+
return void 0;
|
|
3091
|
+
}
|
|
3092
|
+
return n;
|
|
3093
|
+
}
|
|
3079
3094
|
function parsePair(raw, missing) {
|
|
3080
3095
|
if (raw === void 0) return void 0;
|
|
3081
3096
|
if (!Array.isArray(raw)) {
|
|
@@ -3132,6 +3147,7 @@ function parseToolchains(raw, base, missing, file) {
|
|
|
3132
3147
|
if (!Number.isInteger(maxThinkingTokens) || maxThinkingTokens < 0) {
|
|
3133
3148
|
missing.add(`${where}.maxThinkingTokens must be a non-negative integer (got "${entryRaw.maxThinkingTokens}")`);
|
|
3134
3149
|
}
|
|
3150
|
+
const maxRunMinutes = parseMaxRunMinutes(entryRaw.maxRunMinutes, `${where}.maxRunMinutes`, missing);
|
|
3135
3151
|
let auth;
|
|
3136
3152
|
if (entryRaw.auth !== void 0) {
|
|
3137
3153
|
if (entryRaw.auth !== "subscription" && entryRaw.auth !== "inherit") {
|
|
@@ -3153,7 +3169,8 @@ function parseToolchains(raw, base, missing, file) {
|
|
|
3153
3169
|
tiers,
|
|
3154
3170
|
args: Array.isArray(args) ? args.map(String) : [],
|
|
3155
3171
|
env: Object.fromEntries(Object.entries(env).map(([k, v]) => [k, String(v)])),
|
|
3156
|
-
maxThinkingTokens
|
|
3172
|
+
maxThinkingTokens,
|
|
3173
|
+
...maxRunMinutes !== void 0 ? { maxRunMinutes } : {}
|
|
3157
3174
|
});
|
|
3158
3175
|
}
|
|
3159
3176
|
return out;
|
|
@@ -3161,6 +3178,7 @@ function parseToolchains(raw, base, missing, file) {
|
|
|
3161
3178
|
function parseOneRoute(c, where, file, base, stateDir, raw, missing, crewHome, toolchainNames) {
|
|
3162
3179
|
rejectUnknownKeys(c, ROUTE_KEYS, `${where}`, file);
|
|
3163
3180
|
const toolchain = c?.toolchain === void 0 ? void 0 : String(c.toolchain);
|
|
3181
|
+
const maxRunMinutes = parseMaxRunMinutes(c?.maxRunMinutes, `${where}.maxRunMinutes`, missing);
|
|
3164
3182
|
if (toolchain !== void 0 && !toolchainNames.has(toolchain)) {
|
|
3165
3183
|
missing.add(`${where}.toolchain "${toolchain}" is not defined in ship.toolchains (have: ${[...toolchainNames].join(", ")})`);
|
|
3166
3184
|
}
|
|
@@ -3224,6 +3242,7 @@ function parseOneRoute(c, where, file, base, stateDir, raw, missing, crewHome, t
|
|
|
3224
3242
|
repoOverrides,
|
|
3225
3243
|
worktreePrefix: c?.worktreePrefix ? String(c.worktreePrefix) : void 0,
|
|
3226
3244
|
...toolchain !== void 0 ? { toolchain } : {},
|
|
3245
|
+
...maxRunMinutes !== void 0 ? { maxRunMinutes } : {},
|
|
3227
3246
|
promptsDir: resolvePromptsDir(c?.promptSet ? String(c.promptSet) : void 0, crewHome, base),
|
|
3228
3247
|
weight,
|
|
3229
3248
|
baseUrl: String(c?.baseUrl ?? raw.ship?.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, ""),
|
|
@@ -3549,10 +3568,10 @@ function runScript(script, opts) {
|
|
|
3549
3568
|
child.stdout.on("data", absorb);
|
|
3550
3569
|
child.stderr.on("data", absorb);
|
|
3551
3570
|
const timer = opts.timeoutMs ? setTimeout(() => child.kill("SIGKILL"), opts.timeoutMs) : void 0;
|
|
3552
|
-
child.on("error", (
|
|
3571
|
+
child.on("error", (err3) => {
|
|
3553
3572
|
if (timer) clearTimeout(timer);
|
|
3554
3573
|
resolvePromise({ code: 127, output: `${output}
|
|
3555
|
-
crew: cannot run ${shell.bin}: ${
|
|
3574
|
+
crew: cannot run ${shell.bin}: ${err3.message}` });
|
|
3556
3575
|
});
|
|
3557
3576
|
child.on("close", (code) => {
|
|
3558
3577
|
if (timer) clearTimeout(timer);
|
|
@@ -4248,18 +4267,18 @@ function gitRaw(cwd, args) {
|
|
|
4248
4267
|
try {
|
|
4249
4268
|
return execFileSync2("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
4250
4269
|
} catch (e) {
|
|
4251
|
-
const
|
|
4252
|
-
const why = [
|
|
4253
|
-
throw new GitError(`git ${args.join(" ")}: ${why ||
|
|
4270
|
+
const err3 = e;
|
|
4271
|
+
const why = [err3.stderr, err3.stdout].map(streamText).filter(Boolean).join(" / ");
|
|
4272
|
+
throw new GitError(`git ${args.join(" ")}: ${why || err3.message.trim()}`);
|
|
4254
4273
|
}
|
|
4255
4274
|
}
|
|
4256
4275
|
function git(cwd, args) {
|
|
4257
4276
|
try {
|
|
4258
4277
|
return execFileSync2("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
4259
4278
|
} catch (e) {
|
|
4260
|
-
const
|
|
4261
|
-
const why = [
|
|
4262
|
-
throw new GitError(`git ${args.join(" ")}: ${why ||
|
|
4279
|
+
const err3 = e;
|
|
4280
|
+
const why = [err3.stderr, err3.stdout].map(streamText).filter(Boolean).join(" / ");
|
|
4281
|
+
throw new GitError(`git ${args.join(" ")}: ${why || err3.message.trim()}`);
|
|
4263
4282
|
}
|
|
4264
4283
|
}
|
|
4265
4284
|
function gitOk(cwd, args) {
|
|
@@ -4701,9 +4720,9 @@ function ensureRepoCheckout(dir, remote, opts = {}) {
|
|
|
4701
4720
|
try {
|
|
4702
4721
|
execFileSync2("git", ["clone", url, dir], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
4703
4722
|
} catch (e) {
|
|
4704
|
-
const
|
|
4705
|
-
const why = [
|
|
4706
|
-
throw new GitError(`git clone ${url} ${dir}: ${why ||
|
|
4723
|
+
const err3 = e;
|
|
4724
|
+
const why = [err3.stderr, err3.stdout].map(streamText).filter(Boolean).join(" / ");
|
|
4725
|
+
throw new GitError(`git clone ${url} ${dir}: ${why || err3.message.trim()}`);
|
|
4707
4726
|
}
|
|
4708
4727
|
cloneRecorder?.(dir);
|
|
4709
4728
|
return true;
|
|
@@ -4736,8 +4755,8 @@ function acquireCheckoutLock(dir, timeoutMs = 3e4) {
|
|
|
4736
4755
|
}
|
|
4737
4756
|
};
|
|
4738
4757
|
} catch (e) {
|
|
4739
|
-
const
|
|
4740
|
-
if (
|
|
4758
|
+
const err3 = e;
|
|
4759
|
+
if (err3.code !== "EEXIST") throw err3;
|
|
4741
4760
|
try {
|
|
4742
4761
|
if (Date.now() - statSync5(lockPath).mtimeMs > CHECKOUT_LOCK_STALE_MS) {
|
|
4743
4762
|
try {
|
|
@@ -5129,8 +5148,8 @@ function probePushAuth(o, run2 = realGit, baseEnv = process.env) {
|
|
|
5129
5148
|
run2(o.dir, ["push", "--dry-run", o.remote, `HEAD:refs/heads/${PROBE_BRANCH}`], env);
|
|
5130
5149
|
return { state: "ok" };
|
|
5131
5150
|
} catch (e) {
|
|
5132
|
-
const
|
|
5133
|
-
const raw = (
|
|
5151
|
+
const err3 = e;
|
|
5152
|
+
const raw = (err3.stderr ? String(err3.stderr) : err3.message).trim().split("\n").filter(Boolean).slice(-2).join(" / ");
|
|
5134
5153
|
const detail = raw || "push dry-run failed";
|
|
5135
5154
|
const f = o.forge;
|
|
5136
5155
|
if (!f) return { state: "failed", detail, hint: pushAuthHint(o.remoteUrl) };
|
|
@@ -7359,8 +7378,8 @@ var Tracker = class {
|
|
|
7359
7378
|
if (res.status === 404) return [];
|
|
7360
7379
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
7361
7380
|
rows = await res.json();
|
|
7362
|
-
} catch (
|
|
7363
|
-
this.onReadFailure?.(`reading ${type} events failed: ${
|
|
7381
|
+
} catch (err3) {
|
|
7382
|
+
this.onReadFailure?.(`reading ${type} events failed: ${err3 instanceof Error ? err3.message : String(err3)}`);
|
|
7364
7383
|
return [];
|
|
7365
7384
|
}
|
|
7366
7385
|
return rows.filter((e) => e.event_type === type).sort((a, b) => Date.parse(b.occurred_at ?? b.created_at ?? "") - Date.parse(a.occurred_at ?? a.created_at ?? ""));
|
|
@@ -7563,6 +7582,28 @@ var Tracker = class {
|
|
|
7563
7582
|
if (!auth.commentShip) missing.push(`Comments.${this.contract.comments.ship}`);
|
|
7564
7583
|
return missing;
|
|
7565
7584
|
}
|
|
7585
|
+
/**
|
|
7586
|
+
* The runtime-map columns (ISSUE-2791) this workspace's template has not
|
|
7587
|
+
* taken yet, as `table.column` names for `crew doctor`. Each is read as its
|
|
7588
|
+
* default when absent, so this is informational: `Repos.forge_host`
|
|
7589
|
+
* (default `github.com`), `Ships.kind` (`developer_machine`),
|
|
7590
|
+
* `Ships.instance_class` and `Ships.relay_url` (blank). A table the route
|
|
7591
|
+
* does not have is skipped, not reported.
|
|
7592
|
+
*/
|
|
7593
|
+
async missingRuntimeColumns() {
|
|
7594
|
+
const wanted = [
|
|
7595
|
+
[this.route.resolved?.reposModelId, "Repos", ["forge_host"]],
|
|
7596
|
+
[this.route.resolved?.shipsModelId, "Ships", ["kind", "instance_class", "relay_url"]]
|
|
7597
|
+
];
|
|
7598
|
+
const missing = [];
|
|
7599
|
+
for (const [modelId, label, columns] of wanted) {
|
|
7600
|
+
if (!modelId) continue;
|
|
7601
|
+
const have = await this.columnNames(modelId);
|
|
7602
|
+
if (have.size === 0) continue;
|
|
7603
|
+
for (const c of columns) if (!have.has(c)) missing.push(`${label}.${c}`);
|
|
7604
|
+
}
|
|
7605
|
+
return missing;
|
|
7606
|
+
}
|
|
7566
7607
|
/**
|
|
7567
7608
|
* The public `/v/<slug>/r/<token>` link for one ticket (ISSUE-928) —
|
|
7568
7609
|
* fetch-or-create via `POST /views/:viewId/record-links`, so a repeated
|
|
@@ -9190,6 +9231,55 @@ function skillMarkerWarnings(promptsDir, personaPrompts) {
|
|
|
9190
9231
|
return out;
|
|
9191
9232
|
}
|
|
9192
9233
|
|
|
9234
|
+
// src/run-limit.ts
|
|
9235
|
+
var DEFAULT_MAX_RUN_MINUTES = 240;
|
|
9236
|
+
var KILL_GRACE_MS = 5e3;
|
|
9237
|
+
var WALL_CLOCK_REASON = "wall-clock";
|
|
9238
|
+
function resolveMaxRunMinutes(route2, toolchain) {
|
|
9239
|
+
return route2 ?? toolchain ?? DEFAULT_MAX_RUN_MINUTES;
|
|
9240
|
+
}
|
|
9241
|
+
function deadlineFor(spawnedAt, maxRunMinutes) {
|
|
9242
|
+
if (!maxRunMinutes || maxRunMinutes <= 0) return void 0;
|
|
9243
|
+
const t = Date.parse(spawnedAt);
|
|
9244
|
+
return Number.isNaN(t) ? void 0 : new Date(t + maxRunMinutes * 6e4).toISOString();
|
|
9245
|
+
}
|
|
9246
|
+
function msUntilDeadline(deadline, now = Date.now()) {
|
|
9247
|
+
if (!deadline) return void 0;
|
|
9248
|
+
const t = Date.parse(deadline);
|
|
9249
|
+
return Number.isNaN(t) ? void 0 : t - now;
|
|
9250
|
+
}
|
|
9251
|
+
async function killRunGroup(pid, o = {}) {
|
|
9252
|
+
const alive = o.isAlive ?? ((p) => {
|
|
9253
|
+
try {
|
|
9254
|
+
process.kill(p, 0);
|
|
9255
|
+
return true;
|
|
9256
|
+
} catch (e) {
|
|
9257
|
+
return e.code === "EPERM";
|
|
9258
|
+
}
|
|
9259
|
+
});
|
|
9260
|
+
const signal = (sig) => {
|
|
9261
|
+
try {
|
|
9262
|
+
process.kill(-pid, sig);
|
|
9263
|
+
} catch {
|
|
9264
|
+
try {
|
|
9265
|
+
process.kill(pid, sig);
|
|
9266
|
+
} catch {
|
|
9267
|
+
}
|
|
9268
|
+
}
|
|
9269
|
+
};
|
|
9270
|
+
signal("SIGTERM");
|
|
9271
|
+
const end = Date.now() + (o.graceMs ?? KILL_GRACE_MS);
|
|
9272
|
+
while (alive(pid) && Date.now() < end) await new Promise((r) => setTimeout(r, 50));
|
|
9273
|
+
if (alive(pid)) signal("SIGKILL");
|
|
9274
|
+
}
|
|
9275
|
+
var RUN_KILLED_MARKER = "<!-- crew:run-killed -->";
|
|
9276
|
+
function runKilledComment(b) {
|
|
9277
|
+
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.";
|
|
9278
|
+
return `${RUN_KILLED_MARKER}
|
|
9279
|
+
|
|
9280
|
+
The agent run on this ticket was stopped at its wall-clock limit of ${b.limitMinutes} minutes before it handed on${b.branch ? `; its branch is \`${b.branch}\`` : ""}. ${tree} The worktree is left as it was. The next run on this seat resumes from it rather than starting over.`;
|
|
9281
|
+
}
|
|
9282
|
+
|
|
9193
9283
|
// src/agent.ts
|
|
9194
9284
|
var DIGEST_MAX_AGE_SECONDS = 600;
|
|
9195
9285
|
var LEGACY_TOOLCHAIN_HARNESS = "claude";
|
|
@@ -9299,6 +9389,7 @@ function planAgentRun(o) {
|
|
|
9299
9389
|
toolchain: toolchain.name,
|
|
9300
9390
|
harness: toolchain.harness,
|
|
9301
9391
|
provider: toolchainProvider(toolchain),
|
|
9392
|
+
maxRunMinutes: resolveMaxRunMinutes(o.route.maxRunMinutes, toolchain.maxRunMinutes),
|
|
9302
9393
|
resolution: { toolchain: seat.toolchainWhy, model: seat.modelWhy },
|
|
9303
9394
|
warnings: [...seat.warnings ?? [], ...expanded.warnings],
|
|
9304
9395
|
prompt,
|
|
@@ -9388,6 +9479,17 @@ function identityOf(plan, adapterHarness) {
|
|
|
9388
9479
|
promptSha: plan.promptSha
|
|
9389
9480
|
};
|
|
9390
9481
|
}
|
|
9482
|
+
async function killForDeadline(pid, emit, role, ticket, spawnedAt, limitMinutes) {
|
|
9483
|
+
const elapsedMs = Date.now() - Date.parse(spawnedAt);
|
|
9484
|
+
const limit = limitMinutes ?? Math.round(elapsedMs / 6e4);
|
|
9485
|
+
emit.emit(`run_killed: ${role} run exceeded its wall-clock limit of ${limit} min (ran ${Math.round(elapsedMs / 6e4)} min)`, {
|
|
9486
|
+
role,
|
|
9487
|
+
...ticket ? { ticket } : {},
|
|
9488
|
+
data: { event: "run_killed", reason: WALL_CLOCK_REASON, limitMinutes: limit, elapsedMs, pid }
|
|
9489
|
+
});
|
|
9490
|
+
await killRunGroup(pid);
|
|
9491
|
+
return { limitMinutes: limit, elapsedMs };
|
|
9492
|
+
}
|
|
9391
9493
|
var withoutKey = ({ apiKey: _apiKey, ...rest }) => rest;
|
|
9392
9494
|
function openSink(path) {
|
|
9393
9495
|
try {
|
|
@@ -9747,13 +9849,16 @@ function spawnAgent(plan, emit, hintMoved) {
|
|
|
9747
9849
|
detached: true
|
|
9748
9850
|
});
|
|
9749
9851
|
closeSync3(streamFd);
|
|
9852
|
+
const spawnedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
9853
|
+
const deadline = deadlineFor(spawnedAt, plan.maxRunMinutes);
|
|
9750
9854
|
if (child.pid !== void 0) {
|
|
9751
9855
|
const sidecar = {
|
|
9752
9856
|
pid: child.pid,
|
|
9753
9857
|
ownerPid: process.pid,
|
|
9754
|
-
spawnedAt
|
|
9858
|
+
spawnedAt,
|
|
9755
9859
|
role: plan.role,
|
|
9756
9860
|
route: plan.route,
|
|
9861
|
+
...deadline ? { deadline, maxRunMinutes: plan.maxRunMinutes } : {},
|
|
9757
9862
|
ticket: plan.ticket ?? null,
|
|
9758
9863
|
ticketRecordId: plan.ticketRecordId ?? null,
|
|
9759
9864
|
repo: plan.repo ?? null,
|
|
@@ -9773,28 +9878,48 @@ function spawnAgent(plan, emit, hintMoved) {
|
|
|
9773
9878
|
}
|
|
9774
9879
|
}
|
|
9775
9880
|
const obs = new RunObserver(id, adapter, eventsSink, emit.cycle);
|
|
9776
|
-
const meta = { pid: child.pid ?? null };
|
|
9881
|
+
const meta = { pid: child.pid ?? null, ...deadline ? { deadline } : {} };
|
|
9777
9882
|
obs.onClaim = () => writeRunMeta(id, startedAt, obs.liveTicket, meta);
|
|
9778
9883
|
if (child.pid !== void 0) writeRunMeta(id, startedAt, obs.liveTicket, meta);
|
|
9779
9884
|
const tail3 = new FileTail(plan.streamPath);
|
|
9780
9885
|
const timer = setInterval(() => obs.feed(tail3.poll()), TAIL_POLL_MS);
|
|
9886
|
+
let killed;
|
|
9887
|
+
const limitTimer = child.pid !== void 0 && deadline ? setTimeout(() => {
|
|
9888
|
+
const pid = child.pid;
|
|
9889
|
+
void killForDeadline(pid, emit, plan.role, obs.liveTicket ?? plan.ticket ?? null, spawnedAt, plan.maxRunMinutes).then((k) => {
|
|
9890
|
+
killed = k;
|
|
9891
|
+
});
|
|
9892
|
+
}, Math.max(0, msUntilDeadline(deadline) ?? 0)) : void 0;
|
|
9781
9893
|
let spawnFailed = false;
|
|
9782
|
-
child.on("error", (
|
|
9894
|
+
child.on("error", (err3) => {
|
|
9783
9895
|
spawnFailed = true;
|
|
9784
9896
|
clearInterval(timer);
|
|
9785
|
-
|
|
9897
|
+
clearTimeout(limitTimer);
|
|
9898
|
+
reject(new AgentSpawnError(`cannot run ${plan.bin}: ${err3.message}`));
|
|
9786
9899
|
});
|
|
9787
9900
|
child.on("close", async (code) => {
|
|
9788
9901
|
clearInterval(timer);
|
|
9902
|
+
clearTimeout(limitTimer);
|
|
9789
9903
|
obs.feed(tail3.poll());
|
|
9790
9904
|
obs.end();
|
|
9791
9905
|
await endSink(eventsSink);
|
|
9792
9906
|
if (spawnFailed) return;
|
|
9793
|
-
|
|
9794
|
-
|
|
9907
|
+
if (limitTimer && !killed && !obs.sawResult && msUntilDeadline(deadline) <= 0) {
|
|
9908
|
+
killed = { limitMinutes: plan.maxRunMinutes ?? 0, elapsedMs: Date.now() - Date.parse(spawnedAt) };
|
|
9909
|
+
}
|
|
9910
|
+
const { ms: ms3, handoffPending } = await finishRun(
|
|
9911
|
+
id,
|
|
9912
|
+
obs,
|
|
9913
|
+
emit,
|
|
9914
|
+
startedAt,
|
|
9915
|
+
{ code: code ?? 1, ...killed ? { interrupted: WALL_CLOCK_REASON } : {} },
|
|
9916
|
+
plan.agentLog?.apiKey,
|
|
9917
|
+
hintMoved
|
|
9918
|
+
);
|
|
9919
|
+
resolve6({ code: code ?? 1, ms: ms3, ...handoffPending ? { handoffPending } : {}, ...killed ? { killed } : {} });
|
|
9795
9920
|
});
|
|
9796
|
-
child.stdin?.on("error", (
|
|
9797
|
-
emit.warn(`agent closed stdin before the prompt was delivered: ${
|
|
9921
|
+
child.stdin?.on("error", (err3) => {
|
|
9922
|
+
emit.warn(`agent closed stdin before the prompt was delivered: ${err3.message}`, { step: "agent" });
|
|
9798
9923
|
});
|
|
9799
9924
|
child.stdin?.end(plan.prompt);
|
|
9800
9925
|
});
|
|
@@ -9810,18 +9935,23 @@ async function reattachRun(streamPath, sc, emit, o = {}) {
|
|
|
9810
9935
|
obs.feed(tail3.poll());
|
|
9811
9936
|
obs.muted = false;
|
|
9812
9937
|
const adopted = isAlive(sc);
|
|
9938
|
+
let killed;
|
|
9813
9939
|
if (adopted) {
|
|
9814
9940
|
o.onAdopted?.();
|
|
9815
9941
|
const pollMs = o.pollMs ?? TAIL_POLL_MS;
|
|
9816
9942
|
while (isAlive(sc)) {
|
|
9817
9943
|
obs.feed(tail3.poll());
|
|
9944
|
+
if (!killed && (msUntilDeadline(sc.deadline) ?? 1) <= 0) {
|
|
9945
|
+
killed = await killForDeadline(sc.pid, emit, sc.role, obs.liveTicket ?? sc.ticket, sc.spawnedAt, sc.maxRunMinutes);
|
|
9946
|
+
continue;
|
|
9947
|
+
}
|
|
9818
9948
|
await new Promise((r) => setTimeout(r, pollMs));
|
|
9819
9949
|
}
|
|
9820
9950
|
}
|
|
9821
9951
|
obs.feed(tail3.poll());
|
|
9822
9952
|
obs.end();
|
|
9823
9953
|
await endSink(eventsSink);
|
|
9824
|
-
const interrupted = obs.sawResult ? void 0 : adopted ? "no-result" : "daemon-exit";
|
|
9954
|
+
const interrupted = killed ? WALL_CLOCK_REASON : obs.sawResult ? void 0 : adopted ? "no-result" : "daemon-exit";
|
|
9825
9955
|
const { ms: ms3, ticket } = await finishRun(
|
|
9826
9956
|
id,
|
|
9827
9957
|
obs,
|
|
@@ -9830,7 +9960,7 @@ async function reattachRun(streamPath, sc, emit, o = {}) {
|
|
|
9830
9960
|
{ code: null, ...interrupted ? { interrupted } : {} },
|
|
9831
9961
|
o.agentLogKey
|
|
9832
9962
|
);
|
|
9833
|
-
return { adopted, ...interrupted ? { interrupted } : {}, ms: ms3, ticket };
|
|
9963
|
+
return { adopted, ...interrupted ? { interrupted } : {}, ...killed ? { killed } : {}, ms: ms3, ticket };
|
|
9834
9964
|
}
|
|
9835
9965
|
|
|
9836
9966
|
// src/seat-keys.ts
|
|
@@ -9881,8 +10011,8 @@ async function resolveSeatKey(route2, role, getStore = getSessionStore) {
|
|
|
9881
10011
|
}
|
|
9882
10012
|
}
|
|
9883
10013
|
function safeStderrLine(e) {
|
|
9884
|
-
const
|
|
9885
|
-
const raw = typeof
|
|
10014
|
+
const err3 = e;
|
|
10015
|
+
const raw = typeof err3?.stderr === "string" && err3.stderr.trim() ? err3.stderr : typeof err3?.message === "string" ? err3.message : "";
|
|
9886
10016
|
for (const line of raw.split("\n")) {
|
|
9887
10017
|
const t = line.trim();
|
|
9888
10018
|
if (!t || /^Command failed:/i.test(t) || /(^|\s)-w(\s|$)/.test(t)) continue;
|
|
@@ -10378,6 +10508,7 @@ var VALUE_FLAGS = /* @__PURE__ */ new Set([
|
|
|
10378
10508
|
"service",
|
|
10379
10509
|
"topic",
|
|
10380
10510
|
"assignee",
|
|
10511
|
+
"requested-by",
|
|
10381
10512
|
"issue",
|
|
10382
10513
|
"shell",
|
|
10383
10514
|
"resume"
|
|
@@ -10806,12 +10937,12 @@ async function checkCrewFloor(o, deps = {}) {
|
|
|
10806
10937
|
function openOutdated(state) {
|
|
10807
10938
|
return state.shipAttention().previous().find((i) => i.key === CREW_OUTDATED_KEY)?.message ?? null;
|
|
10808
10939
|
}
|
|
10809
|
-
function isUpgradeRequired(
|
|
10810
|
-
return
|
|
10940
|
+
function isUpgradeRequired(err3) {
|
|
10941
|
+
return err3 instanceof TablationApiError3 && err3.status === 426;
|
|
10811
10942
|
}
|
|
10812
|
-
async function raiseFromUpgradeRequired(o,
|
|
10943
|
+
async function raiseFromUpgradeRequired(o, err3) {
|
|
10813
10944
|
const have = sentVersion(o.ship) ?? "unknown";
|
|
10814
|
-
const floor = /(\d+\.\d+\.\d+)/.exec(
|
|
10945
|
+
const floor = /(\d+\.\d+\.\d+)/.exec(err3.message.replace(have, ""))?.[1] ?? "a newer version";
|
|
10815
10946
|
await raiseShipAttention(o, outdatedItem(have, floor));
|
|
10816
10947
|
}
|
|
10817
10948
|
|
|
@@ -11626,8 +11757,8 @@ async function decideCycle(o) {
|
|
|
11626
11757
|
const optional = async (what, read, fallback) => {
|
|
11627
11758
|
try {
|
|
11628
11759
|
return await read;
|
|
11629
|
-
} catch (
|
|
11630
|
-
emit.warn(`reading ${what} failed: ${
|
|
11760
|
+
} catch (err3) {
|
|
11761
|
+
emit.warn(`reading ${what} failed: ${err3 instanceof Error ? err3.message : String(err3)}`);
|
|
11631
11762
|
return fallback;
|
|
11632
11763
|
}
|
|
11633
11764
|
};
|
|
@@ -14330,7 +14461,8 @@ function describeRun(run2, route2, seatIds, worktreeOf = (d, n) => {
|
|
|
14330
14461
|
worktree: repoDir && num ? worktreeOf(repoDir, num) : null,
|
|
14331
14462
|
stream: run2.streamPath,
|
|
14332
14463
|
pid: run2.pid,
|
|
14333
|
-
startedAt: run2.spawnedAt
|
|
14464
|
+
startedAt: run2.spawnedAt,
|
|
14465
|
+
deadline: run2.deadline ?? null
|
|
14334
14466
|
};
|
|
14335
14467
|
}
|
|
14336
14468
|
|
|
@@ -15504,7 +15636,10 @@ async function discover(o) {
|
|
|
15504
15636
|
...hostPeriscopes !== void 0 ? { host_periscopes: hostPeriscopes } : {},
|
|
15505
15637
|
...sshPublicKey !== void 0 ? { ssh_public_key: sshPublicKey } : {},
|
|
15506
15638
|
...ownerId !== void 0 ? { owner_id: ownerId } : {},
|
|
15507
|
-
...writeMachineId ? { machine_id: machineId } : {}
|
|
15639
|
+
...writeMachineId ? { machine_id: machineId } : {},
|
|
15640
|
+
// ISSUE-2791: a ship connecting by hand is a developer machine; the
|
|
15641
|
+
// column arrives with a template update, so only written when present.
|
|
15642
|
+
...shipColumns.has("kind") ? { kind: "developer_machine" } : {}
|
|
15508
15643
|
});
|
|
15509
15644
|
shipRowId = created.id;
|
|
15510
15645
|
out.shipRow = { id: shipRowId, name: o.ship.name, action: "created", matchedBy };
|
|
@@ -16633,7 +16768,7 @@ async function runAgentSuggest(o) {
|
|
|
16633
16768
|
const signal = o.signal ? AbortSignal.any([o.signal, timeout]) : timeout;
|
|
16634
16769
|
const run2 = await new Promise((resolve6) => {
|
|
16635
16770
|
let out = "";
|
|
16636
|
-
let
|
|
16771
|
+
let err3 = "";
|
|
16637
16772
|
const child = spawn4(bin, args, { cwd: o.dir, env, detached: true, stdio: ["pipe", "pipe", "pipe"] });
|
|
16638
16773
|
let settled = false;
|
|
16639
16774
|
const settle = (r) => {
|
|
@@ -16650,7 +16785,7 @@ async function runAgentSuggest(o) {
|
|
|
16650
16785
|
}
|
|
16651
16786
|
child.stdout.destroy();
|
|
16652
16787
|
child.stderr.destroy();
|
|
16653
|
-
settle({ code: null, out, err:
|
|
16788
|
+
settle({ code: null, out, err: err3, aborted: true });
|
|
16654
16789
|
};
|
|
16655
16790
|
if (signal.aborted) onAbort();
|
|
16656
16791
|
else signal.addEventListener("abort", onAbort, { once: true });
|
|
@@ -16658,10 +16793,10 @@ async function runAgentSuggest(o) {
|
|
|
16658
16793
|
out += d.toString();
|
|
16659
16794
|
});
|
|
16660
16795
|
child.stderr.on("data", (d) => {
|
|
16661
|
-
|
|
16796
|
+
err3 += d.toString();
|
|
16662
16797
|
});
|
|
16663
|
-
child.on("error", (e) => settle({ code: null, out, err:
|
|
16664
|
-
child.on("close", (code) => settle({ code, out, err:
|
|
16798
|
+
child.on("error", (e) => settle({ code: null, out, err: err3, spawnError: e, aborted: false }));
|
|
16799
|
+
child.on("close", (code) => settle({ code, out, err: err3, aborted: false }));
|
|
16665
16800
|
child.stdin.on("error", () => {
|
|
16666
16801
|
});
|
|
16667
16802
|
child.stdin.end(buildAgentPrompt(o.sniff));
|
|
@@ -17163,7 +17298,7 @@ function readPeriscopePrior(stateDir, ticketRecordId) {
|
|
|
17163
17298
|
var DEFAULT_MAX_PERISCOPES2 = 3;
|
|
17164
17299
|
var DEFAULT_PERISCOPE_IDLE_MINUTES2 = 120;
|
|
17165
17300
|
function parsePeriscopeUpArgs(positional2, flags) {
|
|
17166
|
-
const usage2 = "usage: crew periscope up [[route] REPO] [--topic TEXT | --issue NNN] [--isolate] [--host HOST] [--assignee CREW_ROW_ID] [--no-open] [--json]";
|
|
17301
|
+
const usage2 = "usage: crew periscope up [[route] REPO] [--topic TEXT | --issue NNN] [--isolate] [--host HOST] [--assignee CREW_ROW_ID] [--requested-by ID] [--no-open] [--json]";
|
|
17167
17302
|
const rest = positional2.slice(2);
|
|
17168
17303
|
if (rest.length > 2) throw new PeriscopeError(usage2);
|
|
17169
17304
|
const topic = flags.topic?.trim();
|
|
@@ -17184,6 +17319,7 @@ ${usage2}`);
|
|
|
17184
17319
|
isolate: flags.isolate,
|
|
17185
17320
|
...flags.host ? { host: flags.host } : {},
|
|
17186
17321
|
...flags.assignee ? { assignee: flags.assignee } : {},
|
|
17322
|
+
...flags.requestedBy?.trim() ? { requestedBy: flags.requestedBy.trim() } : {},
|
|
17187
17323
|
json: flags.json
|
|
17188
17324
|
};
|
|
17189
17325
|
}
|
|
@@ -17670,8 +17806,8 @@ function ensureShipSshKeypair(stateDir) {
|
|
|
17670
17806
|
"-q"
|
|
17671
17807
|
], { stdio: ["ignore", "ignore", "pipe"] });
|
|
17672
17808
|
} catch (e) {
|
|
17673
|
-
const
|
|
17674
|
-
throw new SshKeyError(`ssh-keygen failed: ${
|
|
17809
|
+
const err3 = e;
|
|
17810
|
+
throw new SshKeyError(`ssh-keygen failed: ${err3.stderr?.toString().trim() || err3.message}`);
|
|
17675
17811
|
}
|
|
17676
17812
|
chmodSync3(privateKeyPath, 384);
|
|
17677
17813
|
}
|
|
@@ -17697,9 +17833,9 @@ var realExec = (cmd, args, opts) => {
|
|
|
17697
17833
|
try {
|
|
17698
17834
|
return execFileSync9(cmd, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: opts?.timeoutMs });
|
|
17699
17835
|
} catch (e) {
|
|
17700
|
-
const
|
|
17701
|
-
const why = [
|
|
17702
|
-
throw new Error(`${cmd} ${args.join(" ")}: ${why ||
|
|
17836
|
+
const err3 = e;
|
|
17837
|
+
const why = [err3.stderr, err3.stdout].map((x) => (x ?? "").trim()).filter(Boolean).join(" / ");
|
|
17838
|
+
throw new Error(`${cmd} ${args.join(" ")}: ${why || err3.message.trim()}`);
|
|
17703
17839
|
}
|
|
17704
17840
|
};
|
|
17705
17841
|
var DOCKER_INFO_TIMEOUT_MS = 1e4;
|
|
@@ -18272,8 +18408,8 @@ function runSyncDaemonFromEnv(env = process.env) {
|
|
|
18272
18408
|
const secret = requireEnv(env, "PASSENGER_SYNC_SECRET");
|
|
18273
18409
|
const targets = JSON.parse(requireEnv(env, "PASSENGER_SYNC_TARGETS"));
|
|
18274
18410
|
const server = createSyncDaemonServer(targets, secret);
|
|
18275
|
-
server.on("error", (
|
|
18276
|
-
console.error(`Passenger sync daemon failed to bind 0.0.0.0:${port}: ${
|
|
18411
|
+
server.on("error", (err3) => {
|
|
18412
|
+
console.error(`Passenger sync daemon failed to bind 0.0.0.0:${port}: ${err3 instanceof Error ? err3.message : String(err3)}`);
|
|
18277
18413
|
process.exit(1);
|
|
18278
18414
|
});
|
|
18279
18415
|
server.listen(port, "0.0.0.0", () => {
|
|
@@ -18390,22 +18526,22 @@ function codeOf(e) {
|
|
|
18390
18526
|
const code = e?.code;
|
|
18391
18527
|
return typeof code === "string" ? code : void 0;
|
|
18392
18528
|
}
|
|
18393
|
-
function isTransientError(
|
|
18394
|
-
if (!(
|
|
18395
|
-
const msg =
|
|
18529
|
+
function isTransientError(err3) {
|
|
18530
|
+
if (!(err3 instanceof Error)) return false;
|
|
18531
|
+
const msg = err3.message;
|
|
18396
18532
|
if (msg === "fetch failed" || msg.startsWith("fetch failed")) return true;
|
|
18397
|
-
for (let e =
|
|
18533
|
+
for (let e = err3, depth = 0; e && depth < 4; e = e.cause, depth++) {
|
|
18398
18534
|
const code = codeOf(e);
|
|
18399
18535
|
if (code && NETWORK_CODES.includes(code)) return true;
|
|
18400
18536
|
}
|
|
18401
18537
|
if (NETWORK_CODES.some((c) => msg.includes(c))) return true;
|
|
18402
18538
|
if (/\b(?:HTTP|status)\s*:?\s*(502|503|504)\b/i.test(msg) || /^(502|503|504)\b/.test(msg)) return true;
|
|
18403
|
-
if (
|
|
18539
|
+
if (err3 instanceof SyntaxError && /Unexpected token '?<|is not valid JSON|Unexpected end of JSON/.test(msg)) return true;
|
|
18404
18540
|
return false;
|
|
18405
18541
|
}
|
|
18406
|
-
function describeCause(
|
|
18407
|
-
const first = (
|
|
18408
|
-
const code = codeOf(
|
|
18542
|
+
function describeCause(err3) {
|
|
18543
|
+
const first = (err3 instanceof Error ? err3.message : String(err3)).split("\n")[0];
|
|
18544
|
+
const code = codeOf(err3?.cause);
|
|
18409
18545
|
return code && !first.includes(code) ? `${first} (${code})` : first;
|
|
18410
18546
|
}
|
|
18411
18547
|
|
|
@@ -18434,22 +18570,22 @@ async function runDaemonLoop(o) {
|
|
|
18434
18570
|
o.onRecovered?.({ downMs: now() - downSince });
|
|
18435
18571
|
transientRun = 0;
|
|
18436
18572
|
}
|
|
18437
|
-
} catch (
|
|
18438
|
-
if (isTransientError(
|
|
18573
|
+
} catch (err3) {
|
|
18574
|
+
if (isTransientError(err3)) {
|
|
18439
18575
|
passes++;
|
|
18440
18576
|
if (transientRun === 0) downSince = now();
|
|
18441
18577
|
transientRun++;
|
|
18442
18578
|
const wait = transientBackoffMs(transientRun);
|
|
18443
18579
|
const downMs = now() - downSince;
|
|
18444
|
-
o.onTransient?.({ err:
|
|
18445
|
-
if (transientRun === OUTAGE_ESCALATE_AFTER) o.onOutage?.({ err:
|
|
18580
|
+
o.onTransient?.({ err: err3, consecutive: transientRun, backoffMs: wait, downMs });
|
|
18581
|
+
if (transientRun === OUTAGE_ESCALATE_AFTER) o.onOutage?.({ err: err3, consecutive: transientRun, downMs });
|
|
18446
18582
|
if (o.maxPasses !== void 0 && passes >= o.maxPasses) return "maxPasses";
|
|
18447
18583
|
if (o.signal?.aborted) return "aborted";
|
|
18448
18584
|
await sleep(wait);
|
|
18449
18585
|
continue;
|
|
18450
18586
|
}
|
|
18451
18587
|
passes++;
|
|
18452
|
-
o.onPassError?.(
|
|
18588
|
+
o.onPassError?.(err3);
|
|
18453
18589
|
if (o.maxPasses !== void 0 && passes >= o.maxPasses) return "maxPasses";
|
|
18454
18590
|
if (o.signal?.aborted) return "aborted";
|
|
18455
18591
|
await sleep(backoff);
|
|
@@ -18568,7 +18704,7 @@ async function runOnePassUnguarded(o) {
|
|
|
18568
18704
|
await hydrateApiKeys([o.route], o.getSessionStore);
|
|
18569
18705
|
const emit = o.newEmitter();
|
|
18570
18706
|
const decide = o.decide ?? decideCycle;
|
|
18571
|
-
const
|
|
18707
|
+
const spawn9 = o.spawnRoleAgent ?? runRoleAgent;
|
|
18572
18708
|
const breaker = o.spawnBreaker ?? new SpawnBreaker();
|
|
18573
18709
|
const ctx = { route: o.route, ship: o.ship, state: o.state, emit, getSessionStore: o.getSessionStore };
|
|
18574
18710
|
if (o.configFile) await reconcileRouteShipName({ route: o.route, ship: o.ship, configFile: o.configFile, emit });
|
|
@@ -18672,7 +18808,7 @@ async function runOnePassUnguarded(o) {
|
|
|
18672
18808
|
const settled = new Promise((resolve6) => {
|
|
18673
18809
|
ready = resolve6;
|
|
18674
18810
|
});
|
|
18675
|
-
const run2 =
|
|
18811
|
+
const run2 = spawn9(role, decision, ctx, { ready }).then(() => {
|
|
18676
18812
|
breaker.recordSuccess(role);
|
|
18677
18813
|
clearShipAttention(o.state, `role_parked:${role}`);
|
|
18678
18814
|
}).catch(async (e) => {
|
|
@@ -18780,7 +18916,7 @@ async function reconcileForRoute(ctx, o) {
|
|
|
18780
18916
|
if (wt) dirty = status(wt.path).length > 0;
|
|
18781
18917
|
} catch {
|
|
18782
18918
|
}
|
|
18783
|
-
await tracker.postEvent(sc.ticketRecordId, interruptedComment({
|
|
18919
|
+
await tracker.postEvent(sc.ticketRecordId, result.killed ? runKilledComment({ limitMinutes: result.killed.limitMinutes, branch: wt?.branch ?? null, dirty }) : interruptedComment({
|
|
18784
18920
|
branch: wt?.branch ?? null,
|
|
18785
18921
|
dirty,
|
|
18786
18922
|
reason: result.interrupted === "daemon-exit" ? "the crew daemon exited" : "it exited without a result"
|
|
@@ -18803,6 +18939,23 @@ async function reconcileForRoute(ctx, o) {
|
|
|
18803
18939
|
}
|
|
18804
18940
|
});
|
|
18805
18941
|
}
|
|
18942
|
+
async function postRunKilled(route2, tracker, ticketId, memberId, limitMinutes, emit) {
|
|
18943
|
+
if (!ticketId || !memberId) return;
|
|
18944
|
+
try {
|
|
18945
|
+
const t = await tracker.ticket(ticketId);
|
|
18946
|
+
const target = repoTargetFor(route2, t.repo_id);
|
|
18947
|
+
const num = String(t[tracker.contract.columns.key] ?? "").replace(/^\D+/, "");
|
|
18948
|
+
const wt = target && num ? worktreeForNumber(target.dir, num) : null;
|
|
18949
|
+
let dirty = null;
|
|
18950
|
+
try {
|
|
18951
|
+
if (wt) dirty = status(wt.path).length > 0;
|
|
18952
|
+
} catch {
|
|
18953
|
+
}
|
|
18954
|
+
await tracker.postEvent(ticketId, runKilledComment({ limitMinutes, branch: wt?.branch ?? null, dirty }), memberId, "interrupted");
|
|
18955
|
+
} catch (e) {
|
|
18956
|
+
emit.warn(`could not post the wall-clock limit comment: ${e.message}`, { step: "agent" });
|
|
18957
|
+
}
|
|
18958
|
+
}
|
|
18806
18959
|
async function runRoleAgent(role, decision, ctx, hooks) {
|
|
18807
18960
|
const held = {};
|
|
18808
18961
|
try {
|
|
@@ -18965,8 +19118,12 @@ async function runRoleAgentLocked(role, decision, ctx, hooks, held) {
|
|
|
18965
19118
|
} catch {
|
|
18966
19119
|
}
|
|
18967
19120
|
}
|
|
18968
|
-
await spawnAgent(plan, emit, hintMoved);
|
|
18969
|
-
|
|
19121
|
+
const result = await spawnAgent(plan, emit, hintMoved);
|
|
19122
|
+
if (result.killed) {
|
|
19123
|
+
await postRunKilled(route2, tracker, workingId, memberId, result.killed.limitMinutes, emit);
|
|
19124
|
+
} else {
|
|
19125
|
+
await guardRunHandoff(route2, ship, tracker, role, workingId, emit, runStart);
|
|
19126
|
+
}
|
|
18970
19127
|
} finally {
|
|
18971
19128
|
await settleRunEnd({
|
|
18972
19129
|
tracker,
|
|
@@ -19020,7 +19177,213 @@ import {
|
|
|
19020
19177
|
import { createConnection } from "net";
|
|
19021
19178
|
import { createServer } from "http";
|
|
19022
19179
|
import { basename as basename12, dirname as dirname21, join as join43 } from "path";
|
|
19023
|
-
import { execFile, spawn as
|
|
19180
|
+
import { execFile, spawn as spawn8 } from "child_process";
|
|
19181
|
+
|
|
19182
|
+
// src/periscope-control.ts
|
|
19183
|
+
import { spawn as spawn7 } from "child_process";
|
|
19184
|
+
var reply = (status2, body) => ({ status: status2, body });
|
|
19185
|
+
var err = (status2, message) => reply(status2, { error: message });
|
|
19186
|
+
var TICKET_RE = /^(?:[A-Za-z]+-)?\d+$/;
|
|
19187
|
+
var MAX_FIELD = 200;
|
|
19188
|
+
function plainText(v, field, required) {
|
|
19189
|
+
if (v === void 0 || v === null || v === "") return required ? { error: `${field} is required` } : void 0;
|
|
19190
|
+
if (typeof v !== "string") return { error: `${field} must be a string` };
|
|
19191
|
+
const t = v.trim();
|
|
19192
|
+
if (!t) return required ? { error: `${field} is required` } : void 0;
|
|
19193
|
+
if (t.length > MAX_FIELD || /[\r\n\0]/.test(t)) return { error: `${field} must be one line of at most ${MAX_FIELD} characters` };
|
|
19194
|
+
if (t.startsWith("-")) return { error: `${field} must not start with "-"` };
|
|
19195
|
+
return t;
|
|
19196
|
+
}
|
|
19197
|
+
var isErr = (v) => typeof v === "object" && v !== null && "error" in v;
|
|
19198
|
+
function pickRoute(h, named) {
|
|
19199
|
+
const hosting = h.routes.filter((r) => r.hostPeriscopes);
|
|
19200
|
+
if (named !== void 0) {
|
|
19201
|
+
const hit = hosting.find((r) => r.route === named);
|
|
19202
|
+
return hit ?? err(404, "not found");
|
|
19203
|
+
}
|
|
19204
|
+
if (hosting.length === 1) return hosting[0];
|
|
19205
|
+
return err(400, `route is required (this ship hosts periscopes on: ${hosting.map((r) => r.route).join(", ")})`);
|
|
19206
|
+
}
|
|
19207
|
+
function cliMessage(r) {
|
|
19208
|
+
const lines = r.stderr.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
19209
|
+
const plain = lines.filter((l) => !l.startsWith("{"));
|
|
19210
|
+
const last = plain[plain.length - 1] ?? lines[lines.length - 1] ?? `crew exited with status ${r.code}`;
|
|
19211
|
+
return last.replace(/^crew periscope:\s*/, "");
|
|
19212
|
+
}
|
|
19213
|
+
function failureStatus(r) {
|
|
19214
|
+
const m = cliMessage(r);
|
|
19215
|
+
if (/^no periscope for ticket /.test(m)) return 404;
|
|
19216
|
+
if (/already hosts \d+ live periscope/.test(m)) return 409;
|
|
19217
|
+
return r.code === 1 ? 422 : 500;
|
|
19218
|
+
}
|
|
19219
|
+
function lastJsonLine(stdout) {
|
|
19220
|
+
const lines = stdout.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
19221
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
19222
|
+
try {
|
|
19223
|
+
const v = JSON.parse(lines[i]);
|
|
19224
|
+
if (v && typeof v === "object" && !Array.isArray(v)) return v;
|
|
19225
|
+
} catch {
|
|
19226
|
+
}
|
|
19227
|
+
}
|
|
19228
|
+
return null;
|
|
19229
|
+
}
|
|
19230
|
+
function parseLine(line) {
|
|
19231
|
+
try {
|
|
19232
|
+
const v = JSON.parse(line);
|
|
19233
|
+
return v && typeof v === "object" && !Array.isArray(v) ? v : null;
|
|
19234
|
+
} catch {
|
|
19235
|
+
return null;
|
|
19236
|
+
}
|
|
19237
|
+
}
|
|
19238
|
+
async function execute(h, args, streaming) {
|
|
19239
|
+
const finish = (r) => {
|
|
19240
|
+
if (r.code !== 0) return { status: failureStatus(r), body: { error: cliMessage(r) } };
|
|
19241
|
+
const done = lastJsonLine(r.stdout);
|
|
19242
|
+
return done ? { status: 200, body: done } : { status: 502, body: { error: "the command finished without a result" } };
|
|
19243
|
+
};
|
|
19244
|
+
if (!streaming) {
|
|
19245
|
+
const f = finish(await h.run(args, () => {
|
|
19246
|
+
}));
|
|
19247
|
+
return reply(f.status, f.body);
|
|
19248
|
+
}
|
|
19249
|
+
return {
|
|
19250
|
+
status: 200,
|
|
19251
|
+
body: null,
|
|
19252
|
+
stream: async (write) => {
|
|
19253
|
+
const r = await h.run(args, (line) => {
|
|
19254
|
+
const ev = parseLine(line);
|
|
19255
|
+
if (ev) write(ev);
|
|
19256
|
+
});
|
|
19257
|
+
const f = finish(r);
|
|
19258
|
+
write(f.status === 200 ? f.body : { event: "error", status: f.status, message: f.body.error });
|
|
19259
|
+
}
|
|
19260
|
+
};
|
|
19261
|
+
}
|
|
19262
|
+
var isPeriscopePath = (p) => p === "/periscopes" || p.startsWith("/periscopes/");
|
|
19263
|
+
async function handlePeriscopes(h, q) {
|
|
19264
|
+
if (!h || !h.routes.some((r) => r.hostPeriscopes)) return err(404, "not found");
|
|
19265
|
+
const down = /^\/periscopes\/([^/]+)\/down$/.exec(q.path);
|
|
19266
|
+
const isUp = q.path === "/periscopes" && q.method === "POST";
|
|
19267
|
+
const isList = q.path === "/periscopes" && q.method === "GET";
|
|
19268
|
+
if (q.path === "/periscopes" && !isUp && !isList) return err(405, "method not allowed");
|
|
19269
|
+
if (down && q.method !== "POST") return err(405, "method not allowed");
|
|
19270
|
+
if (q.path !== "/periscopes" && !down) return err(404, "not found");
|
|
19271
|
+
const member = async (workspaceId) => {
|
|
19272
|
+
const v = await h.verifyIn(q.token, workspaceId);
|
|
19273
|
+
if (v === "ok") return void 0;
|
|
19274
|
+
if (v === "unavailable") return err(503, "could not verify the key right now");
|
|
19275
|
+
return err(403, "this key does not belong to the workspace of this route");
|
|
19276
|
+
};
|
|
19277
|
+
if (isList) {
|
|
19278
|
+
const named = q.query.get("route") ?? void 0;
|
|
19279
|
+
const candidates2 = named === void 0 ? h.routes.filter((r) => r.hostPeriscopes) : [pickRoute(h, named)];
|
|
19280
|
+
const rows = [];
|
|
19281
|
+
for (const c of candidates2) {
|
|
19282
|
+
if ("status" in c) return c;
|
|
19283
|
+
const denied2 = await member(c.workspaceId);
|
|
19284
|
+
if (denied2) {
|
|
19285
|
+
if (named !== void 0) return denied2;
|
|
19286
|
+
continue;
|
|
19287
|
+
}
|
|
19288
|
+
const r = await h.run(["periscope", "list", c.route, "--json"], () => {
|
|
19289
|
+
});
|
|
19290
|
+
if (r.code !== 0) return err(failureStatus(r), cliMessage(r));
|
|
19291
|
+
try {
|
|
19292
|
+
const list = JSON.parse(r.stdout);
|
|
19293
|
+
if (Array.isArray(list)) for (const e of list) rows.push({ route: c.route, ...e });
|
|
19294
|
+
} catch {
|
|
19295
|
+
return err(502, "the list command did not answer JSON");
|
|
19296
|
+
}
|
|
19297
|
+
}
|
|
19298
|
+
return reply(200, { periscopes: rows });
|
|
19299
|
+
}
|
|
19300
|
+
const b = q.body;
|
|
19301
|
+
if (!b) return err(400, "body must be a small JSON object");
|
|
19302
|
+
const routeName = plainText(b.route, "route", false);
|
|
19303
|
+
if (isErr(routeName)) return err(400, routeName.error);
|
|
19304
|
+
const picked = pickRoute(h, routeName);
|
|
19305
|
+
if ("status" in picked) return picked;
|
|
19306
|
+
const denied = await member(picked.workspaceId);
|
|
19307
|
+
if (denied) return denied;
|
|
19308
|
+
if (down) {
|
|
19309
|
+
const ticket = decodeURIComponent(down[1]);
|
|
19310
|
+
if (!TICKET_RE.test(ticket)) return err(400, "ticket must be a ticket number such as 123 or CREW-123");
|
|
19311
|
+
if (b.outcome !== "keep" && b.outcome !== "discard") return err(400, 'outcome must be "keep" or "discard"');
|
|
19312
|
+
if (b.allowEmpty !== void 0 && typeof b.allowEmpty !== "boolean") return err(400, "allowEmpty must be a boolean");
|
|
19313
|
+
return execute(h, [
|
|
19314
|
+
"periscope",
|
|
19315
|
+
"down",
|
|
19316
|
+
picked.route,
|
|
19317
|
+
ticket,
|
|
19318
|
+
`--${b.outcome}`,
|
|
19319
|
+
...b.allowEmpty === true ? ["--allow-empty"] : [],
|
|
19320
|
+
"--json"
|
|
19321
|
+
], q.wantsStream);
|
|
19322
|
+
}
|
|
19323
|
+
const repo = plainText(b.repo, "repo", true);
|
|
19324
|
+
const topic = plainText(b.topic, "topic", true);
|
|
19325
|
+
const requestedBy = plainText(b.requestedBy, "requestedBy", false);
|
|
19326
|
+
for (const v of [repo, topic, requestedBy]) if (isErr(v)) return err(400, v.error);
|
|
19327
|
+
if (b.isolate !== void 0 && typeof b.isolate !== "boolean") return err(400, "isolate must be a boolean");
|
|
19328
|
+
if (!h.previewHost) return err(409, "this ship has no ship.previewHost, so a Passenger could not reach a periscope");
|
|
19329
|
+
const live = await h.run(["periscope", "list", picked.route, "--json"], () => {
|
|
19330
|
+
});
|
|
19331
|
+
if (live.code === 0) {
|
|
19332
|
+
try {
|
|
19333
|
+
const n = JSON.parse(live.stdout).length;
|
|
19334
|
+
if (n >= h.maxPeriscopes) return err(409, `this ship already hosts ${n} live periscope(s) (ship.maxPeriscopes is ${h.maxPeriscopes}) \u2014 discard one first`);
|
|
19335
|
+
} catch {
|
|
19336
|
+
}
|
|
19337
|
+
}
|
|
19338
|
+
return execute(h, [
|
|
19339
|
+
"periscope",
|
|
19340
|
+
"up",
|
|
19341
|
+
picked.route,
|
|
19342
|
+
repo,
|
|
19343
|
+
"--topic",
|
|
19344
|
+
topic,
|
|
19345
|
+
"--host",
|
|
19346
|
+
h.previewHost,
|
|
19347
|
+
...requestedBy ? ["--requested-by", requestedBy] : [],
|
|
19348
|
+
...b.isolate === true ? ["--isolate"] : [],
|
|
19349
|
+
"--no-open",
|
|
19350
|
+
"--json"
|
|
19351
|
+
], q.wantsStream);
|
|
19352
|
+
}
|
|
19353
|
+
function runCrew(args, onLine, timeoutMs = 20 * 6e4) {
|
|
19354
|
+
return new Promise((resolve6) => {
|
|
19355
|
+
const child = spawn7(process.execPath, [process.argv[1] ?? "", ...args], { stdio: ["ignore", "pipe", "pipe"] });
|
|
19356
|
+
let stdout = "";
|
|
19357
|
+
let stderr = "";
|
|
19358
|
+
let pending = "";
|
|
19359
|
+
child.stdout.on("data", (d) => {
|
|
19360
|
+
stdout += d.toString("utf8");
|
|
19361
|
+
});
|
|
19362
|
+
child.stderr.on("data", (d) => {
|
|
19363
|
+
const text = d.toString("utf8");
|
|
19364
|
+
stderr += text;
|
|
19365
|
+
pending += text;
|
|
19366
|
+
let i;
|
|
19367
|
+
while ((i = pending.indexOf("\n")) >= 0) {
|
|
19368
|
+
const line = pending.slice(0, i).trim();
|
|
19369
|
+
pending = pending.slice(i + 1);
|
|
19370
|
+
if (line) onLine(line);
|
|
19371
|
+
}
|
|
19372
|
+
});
|
|
19373
|
+
const t = setTimeout(() => child.kill("SIGTERM"), timeoutMs);
|
|
19374
|
+
child.on("error", (e) => {
|
|
19375
|
+
clearTimeout(t);
|
|
19376
|
+
resolve6({ code: 127, stdout, stderr: `${stderr}${e.message}
|
|
19377
|
+
` });
|
|
19378
|
+
});
|
|
19379
|
+
child.on("close", (code) => {
|
|
19380
|
+
clearTimeout(t);
|
|
19381
|
+
resolve6({ code: code ?? 1, stdout, stderr });
|
|
19382
|
+
});
|
|
19383
|
+
});
|
|
19384
|
+
}
|
|
19385
|
+
|
|
19386
|
+
// src/control-api.ts
|
|
19024
19387
|
var PAUSABLE = ["dev", "design", "qa"];
|
|
19025
19388
|
var CONTROL_SOCKET_NAME = "control.sock";
|
|
19026
19389
|
var controlSocketFor = (stateDir) => join43(stateDir, CONTROL_SOCKET_NAME);
|
|
@@ -19051,11 +19414,26 @@ var ControlAuth = class {
|
|
|
19051
19414
|
}
|
|
19052
19415
|
return verdict;
|
|
19053
19416
|
}
|
|
19054
|
-
|
|
19417
|
+
/**
|
|
19418
|
+
* Like `verify`, but the token must belong to `workspaceId` itself, not just
|
|
19419
|
+
* to some workspace this ship serves. The ship's own keys pass.
|
|
19420
|
+
*/
|
|
19421
|
+
async verifyIn(token, workspaceId) {
|
|
19422
|
+
if (!token) return "unauthorized";
|
|
19423
|
+
for (const k of this.o.shipKeys()) if (k && secretsMatch(token, k)) return "ok";
|
|
19424
|
+
const now = (this.o.now ?? Date.now)();
|
|
19425
|
+
const id = `${workspaceId}:${createHash9("sha256").update(token).digest("hex")}`;
|
|
19426
|
+
const hit = this.cache.get(id);
|
|
19427
|
+
if (hit && now - hit.at < (this.o.cacheMs ?? 6e4)) return hit.verdict;
|
|
19428
|
+
const verdict = await this.ask(token, [workspaceId]);
|
|
19429
|
+
if (verdict !== "unavailable") this.cache.set(id, { verdict, at: now });
|
|
19430
|
+
return verdict;
|
|
19431
|
+
}
|
|
19432
|
+
async ask(token, workspaceIds = this.o.workspaceIds) {
|
|
19055
19433
|
const doFetch = this.o.fetch ?? fetch;
|
|
19056
19434
|
const base = this.o.baseUrl.replace(/\/+$/, "");
|
|
19057
19435
|
let forbidden = false;
|
|
19058
|
-
for (const ws of
|
|
19436
|
+
for (const ws of workspaceIds) {
|
|
19059
19437
|
let res;
|
|
19060
19438
|
try {
|
|
19061
19439
|
res = await doFetch(`${base}/api/auth/me?workspaceId=${encodeURIComponent(ws)}`, {
|
|
@@ -19206,8 +19584,8 @@ function waitForChange(dir, ms3, signal) {
|
|
|
19206
19584
|
}
|
|
19207
19585
|
});
|
|
19208
19586
|
}
|
|
19209
|
-
var
|
|
19210
|
-
var
|
|
19587
|
+
var reply2 = (status2, body) => ({ status: status2, body });
|
|
19588
|
+
var err2 = (status2, message) => reply2(status2, { error: message });
|
|
19211
19589
|
function pauseDoc(state) {
|
|
19212
19590
|
return {
|
|
19213
19591
|
crew: state.isPaused(),
|
|
@@ -19247,26 +19625,38 @@ async function readJsonBody(req) {
|
|
|
19247
19625
|
async function tailReply(path, q, rotated, abort) {
|
|
19248
19626
|
const after = q.get("after") ?? void 0;
|
|
19249
19627
|
const tail3 = q.has("tail") ? Number(q.get("tail")) : void 0;
|
|
19250
|
-
if (tail3 !== void 0 && !(Number.isInteger(tail3) && tail3 >= 0 && tail3 <= 1e3)) return
|
|
19628
|
+
if (tail3 !== void 0 && !(Number.isInteger(tail3) && tail3 >= 0 && tail3 <= 1e3)) return err2(400, "tail must be an integer from 0 to 1000");
|
|
19251
19629
|
const wait = q.has("wait") ? Number(q.get("wait")) : 0;
|
|
19252
|
-
if (!(Number.isFinite(wait) && wait >= 0)) return
|
|
19253
|
-
if (after !== void 0 && !decodeCursor(after)) return
|
|
19630
|
+
if (!(Number.isFinite(wait) && wait >= 0)) return err2(400, "wait must be a number of seconds");
|
|
19631
|
+
if (after !== void 0 && !decodeCursor(after)) return err2(400, "after is not a cursor this API issued");
|
|
19254
19632
|
const deadline = Date.now() + Math.min(wait, MAX_WAIT_SECONDS) * 1e3;
|
|
19255
19633
|
let r = readTail(path, { after, tail: tail3, rotated });
|
|
19256
19634
|
while (after && !r.lines.length && !r.reset && Date.now() < deadline && !abort.aborted) {
|
|
19257
19635
|
await waitForChange(dirname21(path), deadline - Date.now(), abort);
|
|
19258
19636
|
r = readTail(path, { after: r.cursor, rotated });
|
|
19259
19637
|
}
|
|
19260
|
-
return
|
|
19638
|
+
return reply2(200, r);
|
|
19261
19639
|
}
|
|
19262
19640
|
async function route(deps, method, url, req, abort) {
|
|
19263
19641
|
const p = url.pathname.replace(/\/+$/, "") || "/";
|
|
19264
19642
|
const get2 = method === "GET";
|
|
19265
19643
|
const post3 = method === "POST";
|
|
19644
|
+
if (isPeriscopePath(p)) {
|
|
19645
|
+
const token = /^Bearer (.+)$/.exec(req.headers.authorization ?? "")?.[1]?.trim() ?? "";
|
|
19646
|
+
const body = method === "POST" ? await readJsonBody(req) : {};
|
|
19647
|
+
return handlePeriscopes(deps.periscopes, {
|
|
19648
|
+
method,
|
|
19649
|
+
path: p,
|
|
19650
|
+
query: url.searchParams,
|
|
19651
|
+
token,
|
|
19652
|
+
body,
|
|
19653
|
+
wantsStream: url.searchParams.get("stream") === "1" || (req.headers.accept ?? "").includes("application/x-ndjson")
|
|
19654
|
+
});
|
|
19655
|
+
}
|
|
19266
19656
|
if (p === "/status" && get2) {
|
|
19267
19657
|
const doc = await deps.status();
|
|
19268
|
-
if (!doc) return
|
|
19269
|
-
return
|
|
19658
|
+
if (!doc) return err2(502, "could not produce the status document");
|
|
19659
|
+
return reply2(200, {
|
|
19270
19660
|
...doc,
|
|
19271
19661
|
daemon: {
|
|
19272
19662
|
alive: true,
|
|
@@ -19278,41 +19668,50 @@ async function route(deps, method, url, req, abort) {
|
|
|
19278
19668
|
});
|
|
19279
19669
|
}
|
|
19280
19670
|
if (p === "/events" && get2) return tailReply(eventFileFor(deps.stateDir), url.searchParams, true, abort);
|
|
19281
|
-
if (p === "/streams" && get2) return
|
|
19671
|
+
if (p === "/streams" && get2) return reply2(200, { streams: liveRuns(deps.stateDir).map(describeStream) });
|
|
19282
19672
|
if (p.startsWith("/streams/") && get2) {
|
|
19283
19673
|
const run2 = decodeURIComponent(p.slice("/streams/".length));
|
|
19284
|
-
if (!RUN_NAME_RE.test(run2) || run2.includes("..") || /\.(pid|meta\.json|events\.jsonl)$/.test(run2)) return
|
|
19674
|
+
if (!RUN_NAME_RE.test(run2) || run2.includes("..") || /\.(pid|meta\.json|events\.jsonl)$/.test(run2)) return err2(400, "not a run name");
|
|
19285
19675
|
const file = join43(deps.stateDir, "streams", run2.endsWith(".jsonl") ? run2 : `${run2}.jsonl`);
|
|
19286
|
-
if (!existsSync39(file)) return
|
|
19676
|
+
if (!existsSync39(file)) return err2(404, "no such run");
|
|
19287
19677
|
return tailReply(file, url.searchParams, false, abort);
|
|
19288
19678
|
}
|
|
19289
19679
|
if ((p === "/pause" || p === "/resume") && post3) {
|
|
19290
19680
|
const body = await readJsonBody(req);
|
|
19291
|
-
if (!body) return
|
|
19681
|
+
if (!body) return err2(400, "body must be a small JSON object");
|
|
19292
19682
|
const role = body.role;
|
|
19293
|
-
if (role !== void 0 && !PAUSABLE.includes(role)) return
|
|
19683
|
+
if (role !== void 0 && !PAUSABLE.includes(role)) return err2(400, `role must be one of ${PAUSABLE.join(", ")}`);
|
|
19294
19684
|
if (p === "/pause") deps.state.pause(role);
|
|
19295
19685
|
else deps.state.resume(role);
|
|
19296
|
-
return
|
|
19686
|
+
return reply2(200, { ok: true, pause: pauseDoc(deps.state) });
|
|
19297
19687
|
}
|
|
19298
19688
|
if ((p === "/release/pause" || p === "/release/resume") && post3) {
|
|
19299
19689
|
deps.state.setReleasePaused(p === "/release/pause");
|
|
19300
|
-
return
|
|
19690
|
+
return reply2(200, { ok: true, pause: pauseDoc(deps.state) });
|
|
19301
19691
|
}
|
|
19302
19692
|
const sched = /^\/scheduler\/(start|stop|restart)$/.exec(p);
|
|
19303
19693
|
if (sched && post3) {
|
|
19304
19694
|
const r = deps.scheduler(sched[1]);
|
|
19305
|
-
return r.accepted ?
|
|
19695
|
+
return r.accepted ? reply2(202, { accepted: true, detail: r.detail }) : err2(409, r.detail);
|
|
19306
19696
|
}
|
|
19307
19697
|
if (p === "/doctor" && get2) {
|
|
19308
19698
|
const doc = await deps.doctor();
|
|
19309
|
-
return doc ?
|
|
19699
|
+
return doc ? reply2(200, doc) : err2(502, "could not produce the doctor report");
|
|
19310
19700
|
}
|
|
19311
19701
|
const known = ["/status", "/events", "/streams", "/pause", "/resume", "/release/pause", "/release/resume", "/doctor"];
|
|
19312
|
-
if (known.includes(p) || p.startsWith("/streams/") || sched) return
|
|
19313
|
-
return
|
|
19702
|
+
if (known.includes(p) || p.startsWith("/streams/") || sched) return err2(405, "method not allowed");
|
|
19703
|
+
return err2(404, "not found");
|
|
19314
19704
|
}
|
|
19315
19705
|
function send(res, r) {
|
|
19706
|
+
if (r.stream) {
|
|
19707
|
+
res.writeHead(200, { "Content-Type": "application/x-ndjson", "Cache-Control": "no-store" });
|
|
19708
|
+
return r.stream((ev) => {
|
|
19709
|
+
if (!res.writableEnded) res.write(`${JSON.stringify(ev)}
|
|
19710
|
+
`);
|
|
19711
|
+
}).then(() => {
|
|
19712
|
+
res.end();
|
|
19713
|
+
});
|
|
19714
|
+
}
|
|
19316
19715
|
const body = JSON.stringify(r.body);
|
|
19317
19716
|
res.writeHead(r.status, { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body), "Cache-Control": "no-store" });
|
|
19318
19717
|
res.end(body);
|
|
@@ -19348,13 +19747,13 @@ async function startControlServer(deps) {
|
|
|
19348
19747
|
try {
|
|
19349
19748
|
const m = /^Bearer (.+)$/.exec(req.headers.authorization ?? "");
|
|
19350
19749
|
const verdict = await deps.auth.verify(m?.[1]?.trim() ?? "");
|
|
19351
|
-
if (verdict === "unauthorized") return send(res,
|
|
19352
|
-
if (verdict === "forbidden") return send(res,
|
|
19353
|
-
if (verdict === "unavailable") return send(res,
|
|
19354
|
-
send(res, await route(deps, req.method ?? "GET", new URL(req.url ?? "/", "http://ship"), req, abort.signal));
|
|
19750
|
+
if (verdict === "unauthorized") return send(res, err2(401, "invalid or revoked key"));
|
|
19751
|
+
if (verdict === "forbidden") return send(res, err2(403, "this key does not belong to a workspace this ship serves"));
|
|
19752
|
+
if (verdict === "unavailable") return send(res, err2(503, "could not verify the key right now"));
|
|
19753
|
+
await send(res, await route(deps, req.method ?? "GET", new URL(req.url ?? "/", "http://ship"), req, abort.signal));
|
|
19355
19754
|
} catch (e) {
|
|
19356
19755
|
log(`control API request failed: ${e.message}`);
|
|
19357
|
-
if (!res.headersSent) send(res,
|
|
19756
|
+
if (!res.headersSent) send(res, err2(500, "internal error"));
|
|
19358
19757
|
else res.end();
|
|
19359
19758
|
}
|
|
19360
19759
|
})();
|
|
@@ -19384,7 +19783,7 @@ async function startControlServer(deps) {
|
|
|
19384
19783
|
});
|
|
19385
19784
|
}
|
|
19386
19785
|
var shq2 = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
|
|
19387
|
-
function runDetached(commands, spawnFn =
|
|
19786
|
+
function runDetached(commands, spawnFn = spawn8) {
|
|
19388
19787
|
const script = `sleep 1; ${commands.map((c) => c.map(shq2).join(" ")).join("; ")}`;
|
|
19389
19788
|
const child = spawnFn("/bin/sh", ["-c", script], { detached: true, stdio: "ignore" });
|
|
19390
19789
|
child.on("error", () => {
|
|
@@ -20790,8 +21189,8 @@ ${cu.tail.trimEnd()}
|
|
|
20790
21189
|
`swept ${r.parked} parked, ${r.restored} restored${r.failed ? `, ${r.failed} failed` : ""}${r.contended ? `, ${r.contended} contended` : ""} (${e.route.route})`,
|
|
20791
21190
|
{ step: "sweep", data: r }
|
|
20792
21191
|
);
|
|
20793
|
-
} catch (
|
|
20794
|
-
remit.warn(`could not sweep ${e.route.route}: ${
|
|
21192
|
+
} catch (err3) {
|
|
21193
|
+
remit.warn(`could not sweep ${e.route.route}: ${err3.message}`, { step: "sweep" });
|
|
20795
21194
|
}
|
|
20796
21195
|
}
|
|
20797
21196
|
process.stdout.write(renderFleet(fleet, state));
|
|
@@ -21307,21 +21706,30 @@ ${cu.tail.trimEnd()}
|
|
|
21307
21706
|
noteDaemonStarted(cfg.ship.stateDir);
|
|
21308
21707
|
const spawnBreaker = new SpawnBreaker();
|
|
21309
21708
|
const controlRoutes = cfg.routes.filter((r) => r.resolved?.workspaceId);
|
|
21709
|
+
const controlAuth = new ControlAuth({
|
|
21710
|
+
baseUrl: route2.baseUrl || DEFAULT_BASE_URL,
|
|
21711
|
+
workspaceIds: [...new Set(controlRoutes.map((r) => r.resolved.workspaceId))],
|
|
21712
|
+
shipKeys: () => cfg.routes.flatMap((r) => {
|
|
21713
|
+
try {
|
|
21714
|
+
return [resolveApiKey(r)];
|
|
21715
|
+
} catch {
|
|
21716
|
+
return [];
|
|
21717
|
+
}
|
|
21718
|
+
}),
|
|
21719
|
+
userAgent: cfg.ship.userAgent
|
|
21720
|
+
});
|
|
21310
21721
|
const controlServer = await startControlServer({
|
|
21311
21722
|
stateDir: cfg.ship.stateDir,
|
|
21312
21723
|
state,
|
|
21313
|
-
auth:
|
|
21314
|
-
|
|
21315
|
-
|
|
21316
|
-
|
|
21317
|
-
|
|
21318
|
-
|
|
21319
|
-
|
|
21320
|
-
|
|
21321
|
-
|
|
21322
|
-
}),
|
|
21323
|
-
userAgent: cfg.ship.userAgent
|
|
21324
|
-
}),
|
|
21724
|
+
auth: controlAuth,
|
|
21725
|
+
// CREW-2859: Passenger periscopes, only on a route that sets hostPeriscopes.
|
|
21726
|
+
periscopes: {
|
|
21727
|
+
routes: controlRoutes.filter((r) => r.enabled).map((r) => ({ route: r.route, workspaceId: r.resolved.workspaceId, hostPeriscopes: r.hostPeriscopes === true })),
|
|
21728
|
+
previewHost: cfg.ship.previewHost,
|
|
21729
|
+
maxPeriscopes: cfg.ship.maxPeriscopes,
|
|
21730
|
+
run: runCrew,
|
|
21731
|
+
verifyIn: (token, ws) => controlAuth.verifyIn(token, ws)
|
|
21732
|
+
},
|
|
21325
21733
|
status: () => crewJson(["status", route2.route, "--json"]),
|
|
21326
21734
|
doctor: () => crewJson(["doctor", route2.route, "--json", "--offline"]),
|
|
21327
21735
|
scheduler: schedulerHandler(planDaemonControl(CREW_HOME, hostPlatform(), detectSystemd())),
|
|
@@ -21346,11 +21754,11 @@ ${cu.tail.trimEnd()}
|
|
|
21346
21754
|
`)
|
|
21347
21755
|
})
|
|
21348
21756
|
}),
|
|
21349
|
-
onTransient: ({ err:
|
|
21350
|
-
`tracker unavailable (${describeCause(
|
|
21757
|
+
onTransient: ({ err: err3, consecutive, backoffMs }) => emit.warn(
|
|
21758
|
+
`tracker unavailable (${describeCause(err3)}); attempt ${consecutive}, retrying in ${Math.round(backoffMs / 1e3)}s`
|
|
21351
21759
|
),
|
|
21352
|
-
onOutage: ({ err:
|
|
21353
|
-
const msg = `tracker unreachable for ${Math.max(1, Math.round(downMs / 6e4))} minutes: ${describeCause(
|
|
21760
|
+
onOutage: ({ err: err3, downMs }) => {
|
|
21761
|
+
const msg = `tracker unreachable for ${Math.max(1, Math.round(downMs / 6e4))} minutes: ${describeCause(err3)}`;
|
|
21354
21762
|
emit.error(msg);
|
|
21355
21763
|
void raiseShipAttention({ state, emit, route: route2, ship: cfg.ship }, {
|
|
21356
21764
|
kind: "tracker_unreachable",
|
|
@@ -21363,8 +21771,8 @@ ${cu.tail.trimEnd()}
|
|
|
21363
21771
|
clearShipAttention(state, TRACKER_OUTAGE_KEY);
|
|
21364
21772
|
emit.emit(`tracker is back after ${Math.max(1, Math.round(downMs / 1e3))}s`);
|
|
21365
21773
|
},
|
|
21366
|
-
onPassError: (
|
|
21367
|
-
`daemon pass failed, continuing: ${
|
|
21774
|
+
onPassError: (err3) => emit.error(
|
|
21775
|
+
`daemon pass failed, continuing: ${err3 instanceof Error ? err3.stack ?? err3.message : String(err3)}`
|
|
21368
21776
|
),
|
|
21369
21777
|
checkStale: makeStaleChecker(watchPaths, { reloadSentinel: reloadSentinelFor(cfg.ship.stateDir) }),
|
|
21370
21778
|
onPass: (result) => {
|
|
@@ -23289,7 +23697,7 @@ ${resumed.url}
|
|
|
23289
23697
|
`);
|
|
23290
23698
|
break;
|
|
23291
23699
|
}
|
|
23292
|
-
const parsedUp = parsePeriscopeUpArgs(positional, { topic: value("topic"), issue: value("issue"), host: value("host"), assignee: value("assignee"), isolate: flag("isolate"), json: jsonP });
|
|
23700
|
+
const parsedUp = parsePeriscopeUpArgs(positional, { topic: value("topic"), issue: value("issue"), host: value("host"), assignee: value("assignee"), requestedBy: value("requested-by"), isolate: flag("isolate"), json: jsonP });
|
|
23293
23701
|
let a;
|
|
23294
23702
|
if (parsedUp.repo !== void 0) a = { ...parsedUp, repo: parsedUp.repo };
|
|
23295
23703
|
else {
|
|
@@ -23347,7 +23755,7 @@ ${where.join("\n")}`);
|
|
|
23347
23755
|
title,
|
|
23348
23756
|
description: `A periscope: a throwaway preview of ${a.repo} for a viewer, opened by \`crew periscope up\`.`,
|
|
23349
23757
|
report_type: "feature",
|
|
23350
|
-
reporter_name: operatorName,
|
|
23758
|
+
reporter_name: a.requestedBy ?? operatorName,
|
|
23351
23759
|
[cP.columns.status]: cP.statuses.draft,
|
|
23352
23760
|
[cP.columns.assignee]: pairId,
|
|
23353
23761
|
...routeP.resolved.areaId ? { [cP.columns.slice]: routeP.resolved.areaId } : {},
|
|
@@ -24150,7 +24558,7 @@ watermark: ${state.watermark()}
|
|
|
24150
24558
|
);
|
|
24151
24559
|
}
|
|
24152
24560
|
for (const x of runs) {
|
|
24153
|
-
process.stdout.write(`running: ${x.role} \u2014 ${x.ticket ?? "(no ticket)"}${x.repo ? ` in ${x.repo}` : ""} (pid ${x.pid}, since ${x.spawnedAt})
|
|
24561
|
+
process.stdout.write(`running: ${x.role} \u2014 ${x.ticket ?? "(no ticket)"}${x.repo ? ` in ${x.repo}` : ""} (pid ${x.pid}, since ${x.spawnedAt}${x.deadline ? `, deadline ${x.deadline}` : ""})
|
|
24154
24562
|
`);
|
|
24155
24563
|
}
|
|
24156
24564
|
if (shipRows.length) {
|
|
@@ -24360,6 +24768,11 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
|
|
|
24360
24768
|
const missingAuthorship = await tracker.missingAuthorshipColumns();
|
|
24361
24769
|
process.stdout.write(
|
|
24362
24770
|
missingAuthorship.length === 0 ? "authorship: filed_by_id, ship_id present\n" : `authorship: missing ${missingAuthorship.join(", ")} \u2014 update the Issues template
|
|
24771
|
+
`
|
|
24772
|
+
);
|
|
24773
|
+
const missingRuntime = await tracker.missingRuntimeColumns();
|
|
24774
|
+
process.stdout.write(
|
|
24775
|
+
missingRuntime.length === 0 ? "runtime columns: forge_host, kind, instance_class, relay_url present\n" : `runtime columns: missing ${missingRuntime.join(", ")} \u2014 read as their defaults; update the Issues template
|
|
24363
24776
|
`
|
|
24364
24777
|
);
|
|
24365
24778
|
process.stdout.write(
|
package/package.json
CHANGED