agent-board-runner 0.1.12 → 0.1.14
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 +1198 -991
- package/dist/index.js +1118 -984
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -610,32 +610,33 @@ async function logActivity(supabase, params) {
|
|
|
610
610
|
}
|
|
611
611
|
|
|
612
612
|
// src/sessions.ts
|
|
613
|
-
async function getRunningSessionForProject(supabase, projectId,
|
|
614
|
-
const { data, error } = await supabase.from("sessions").select("id, card_id, agent_state, cards!card_id!inner(project_id
|
|
613
|
+
async function getRunningSessionForProject(supabase, projectId, runnerId) {
|
|
614
|
+
const { data, error } = await supabase.from("sessions").select("id, card_id, agent_state, cards!card_id!inner(project_id)").eq("cards.project_id", projectId).eq("runner_id", runnerId).eq("status", "running").limit(1).maybeSingle();
|
|
615
615
|
if (error) throw error;
|
|
616
616
|
if (!data) return null;
|
|
617
617
|
const row = data;
|
|
618
618
|
return { id: row.id, cardId: row.card_id, agentState: row.agent_state };
|
|
619
619
|
}
|
|
620
|
-
async function listQueuedSessionsForRunner(supabase,
|
|
620
|
+
async function listQueuedSessionsForRunner(supabase, runnerId) {
|
|
621
621
|
const { data, error } = await supabase.from("sessions").select(
|
|
622
|
-
"id, card_id, claude_session_id, agent_state, created_at, cards!card_id!inner(project_id,
|
|
623
|
-
).eq("
|
|
622
|
+
"id, card_id, claude_session_id, agent_state, created_at, cards!card_id!inner(project_id, title, instructions, file_references, plan_first, prerequisite_card_id, prototype_first, prototype_url, prototype_approved)"
|
|
623
|
+
).eq("runner_id", runnerId).eq("status", "queued").order("created_at", { ascending: true });
|
|
624
624
|
if (error) throw error;
|
|
625
625
|
return data;
|
|
626
626
|
}
|
|
627
|
-
async function listPausedSessionsForRunner(supabase,
|
|
628
|
-
const { data, error } = await supabase.from("sessions").select("card_id, cards!card_id!inner(project_id
|
|
627
|
+
async function listPausedSessionsForRunner(supabase, runnerId) {
|
|
628
|
+
const { data, error } = await supabase.from("sessions").select("card_id, cards!card_id!inner(project_id)").eq("runner_id", runnerId).eq("status", "paused");
|
|
629
629
|
if (error) throw error;
|
|
630
630
|
return data.map((row) => ({
|
|
631
631
|
card_id: row.card_id,
|
|
632
632
|
project_id: row.cards.project_id
|
|
633
633
|
}));
|
|
634
634
|
}
|
|
635
|
-
async function createSession(supabase, userId, cardId, resumeClaudeSessionId) {
|
|
635
|
+
async function createSession(supabase, userId, cardId, runnerId, resumeClaudeSessionId) {
|
|
636
636
|
const { data, error } = await supabase.from("sessions").insert({
|
|
637
637
|
card_id: cardId,
|
|
638
638
|
user_id: userId,
|
|
639
|
+
runner_id: runnerId,
|
|
639
640
|
status: "queued",
|
|
640
641
|
// Pre-set so spawnSessionPty takes the resume path (see its doc comment) instead of
|
|
641
642
|
// starting a blank conversation and resending the card's instructions.
|
|
@@ -644,6 +645,11 @@ async function createSession(supabase, userId, cardId, resumeClaudeSessionId) {
|
|
|
644
645
|
if (error) throw error;
|
|
645
646
|
return data.id;
|
|
646
647
|
}
|
|
648
|
+
async function getDefaultRunnerIdForUser(supabase, ownerUserId) {
|
|
649
|
+
const { data, error } = await supabase.from("runners").select("id").eq("user_id", ownerUserId).eq("is_default", true).maybeSingle();
|
|
650
|
+
if (error) throw error;
|
|
651
|
+
return data?.id ?? null;
|
|
652
|
+
}
|
|
647
653
|
async function updateSession(supabase, sessionId, changes, activityContext) {
|
|
648
654
|
const { error } = await supabase.from("sessions").update({
|
|
649
655
|
...changes.status !== void 0 ? { status: changes.status } : {},
|
|
@@ -666,11 +672,26 @@ async function updateSession(supabase, sessionId, changes, activityContext) {
|
|
|
666
672
|
}
|
|
667
673
|
}
|
|
668
674
|
async function getActiveSessionForCard(supabase, cardId) {
|
|
669
|
-
const { data, error } = await supabase.from("sessions").select("id, status, agent_state").eq("card_id", cardId).in("status", ["queued", "running", "waiting_input", "paused"]).order("created_at", { ascending: false }).limit(1).maybeSingle();
|
|
675
|
+
const { data, error } = await supabase.from("sessions").select("id, status, agent_state, runner_id").eq("card_id", cardId).in("status", ["queued", "running", "waiting_input", "paused"]).order("created_at", { ascending: false }).limit(1).maybeSingle();
|
|
670
676
|
if (error) throw error;
|
|
671
677
|
if (!data) return null;
|
|
672
678
|
const row = data;
|
|
673
|
-
return { id: row.id, status: row.status, agentState: row.agent_state };
|
|
679
|
+
return { id: row.id, status: row.status, agentState: row.agent_state, runnerId: row.runner_id };
|
|
680
|
+
}
|
|
681
|
+
var STALE_RUNNER_HEARTBEAT_MS = 8 * 6e4;
|
|
682
|
+
async function getRunnerHeartbeats(supabase, runnerIds) {
|
|
683
|
+
const uniqueIds = Array.from(new Set(runnerIds));
|
|
684
|
+
if (uniqueIds.length === 0) return /* @__PURE__ */ new Map();
|
|
685
|
+
const { data, error } = await supabase.from("runners").select("id, last_heartbeat_at").in("id", uniqueIds);
|
|
686
|
+
if (error) throw error;
|
|
687
|
+
return new Map(data.map((row) => [row.id, row.last_heartbeat_at]));
|
|
688
|
+
}
|
|
689
|
+
function isRunnerHeartbeatStale(lastHeartbeatAt) {
|
|
690
|
+
return lastHeartbeatAt === null || Date.now() - new Date(lastHeartbeatAt).getTime() > STALE_RUNNER_HEARTBEAT_MS;
|
|
691
|
+
}
|
|
692
|
+
async function claimReferenceRunner(supabase, projectId, runnerId) {
|
|
693
|
+
const { error } = await supabase.from("projects").update({ runner_id: runnerId }).eq("id", projectId);
|
|
694
|
+
if (error) throw error;
|
|
674
695
|
}
|
|
675
696
|
async function pauseRunningSession(supabase, sessionId, activityContext) {
|
|
676
697
|
const { data, error } = await supabase.from("sessions").update({ status: "paused" }).eq("id", sessionId).eq("status", "running").select("id");
|
|
@@ -725,7 +746,7 @@ async function getProjectRepos(supabase, projectId) {
|
|
|
725
746
|
}
|
|
726
747
|
async function listCardsInColumn(supabase, projectId, column) {
|
|
727
748
|
const { data, error } = await supabase.from("cards").select(
|
|
728
|
-
"id, project_id, title, instructions, column_name, assigned_user_id, deploy_error, file_references, preview_urls, custom_action_status, requeue_requested_at, restart_requested_at"
|
|
749
|
+
"id, project_id, title, instructions, column_name, assigned_user_id, runner_id, deploy_error, file_references, preview_urls, custom_action_status, requeue_requested_at, restart_requested_at"
|
|
729
750
|
).eq("project_id", projectId).eq("column_name", column);
|
|
730
751
|
if (error) throw error;
|
|
731
752
|
return data;
|
|
@@ -2159,11 +2180,13 @@ function attachAgentEventsServer() {
|
|
|
2159
2180
|
|
|
2160
2181
|
// src/auth.ts
|
|
2161
2182
|
import { createClient } from "@supabase/supabase-js";
|
|
2183
|
+
import { createPrivateKey, randomInt, sign } from "node:crypto";
|
|
2162
2184
|
import { existsSync as existsSync4, mkdirSync as mkdirSync4, readFileSync as readFileSync4, rmSync as rmSync2, writeFileSync as writeFileSync7 } from "node:fs";
|
|
2163
2185
|
import { join as join9 } from "node:path";
|
|
2164
2186
|
import open from "open";
|
|
2165
2187
|
|
|
2166
2188
|
// src/config.ts
|
|
2189
|
+
import { generateKeyPairSync, randomUUID as randomUUID5 } from "node:crypto";
|
|
2167
2190
|
import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync6 } from "node:fs";
|
|
2168
2191
|
import { hostname, homedir as homedir4 } from "node:os";
|
|
2169
2192
|
import { join as join8, resolve as resolve2 } from "node:path";
|
|
@@ -2177,6 +2200,19 @@ function saveConfig(config) {
|
|
|
2177
2200
|
if (!existsSync3(CONFIG_DIR)) mkdirSync3(CONFIG_DIR, { recursive: true });
|
|
2178
2201
|
writeFileSync6(CONFIG_FILE, JSON.stringify(config, null, 2), "utf-8");
|
|
2179
2202
|
}
|
|
2203
|
+
function generateRecoveryKeypair() {
|
|
2204
|
+
const { publicKey, privateKey } = generateKeyPairSync("ed25519", {
|
|
2205
|
+
publicKeyEncoding: { type: "spki", format: "pem" },
|
|
2206
|
+
privateKeyEncoding: { type: "pkcs8", format: "pem" }
|
|
2207
|
+
});
|
|
2208
|
+
return { recoveryPublicKey: publicKey, recoveryPrivateKey: privateKey };
|
|
2209
|
+
}
|
|
2210
|
+
function ensureRecoveryKeypair(config) {
|
|
2211
|
+
if (config.recoveryPublicKey && config.recoveryPrivateKey) return config;
|
|
2212
|
+
Object.assign(config, generateRecoveryKeypair());
|
|
2213
|
+
saveConfig(config);
|
|
2214
|
+
return config;
|
|
2215
|
+
}
|
|
2180
2216
|
function applyAllowedRoot(config, explicitPath) {
|
|
2181
2217
|
const raw = explicitPath ?? process.env.AGENT_BOARD_ALLOWED_ROOT;
|
|
2182
2218
|
if (!raw) return config;
|
|
@@ -2257,6 +2293,58 @@ async function requireSession(supabase) {
|
|
|
2257
2293
|
}
|
|
2258
2294
|
return session;
|
|
2259
2295
|
}
|
|
2296
|
+
function resolveWebUrl() {
|
|
2297
|
+
const url = process.env.AGENT_BOARD_WEB_URL;
|
|
2298
|
+
if (!url) {
|
|
2299
|
+
throw new Error(
|
|
2300
|
+
"AGENT_BOARD_WEB_URL is not set \u2014 point it at the deployed agent-board web app (e.g. https://board.example.com) to use `request-approval` on a machine with no browser."
|
|
2301
|
+
);
|
|
2302
|
+
}
|
|
2303
|
+
return url.replace(/\/+$/, "");
|
|
2304
|
+
}
|
|
2305
|
+
async function postJson(url, body) {
|
|
2306
|
+
const response = await fetch(url, {
|
|
2307
|
+
method: "POST",
|
|
2308
|
+
headers: { "Content-Type": "application/json" },
|
|
2309
|
+
body: JSON.stringify(body)
|
|
2310
|
+
});
|
|
2311
|
+
const data = await response.json().catch(() => ({}));
|
|
2312
|
+
if (!response.ok) throw new Error(data.error ?? `Request to ${url} failed with status ${response.status}`);
|
|
2313
|
+
return data;
|
|
2314
|
+
}
|
|
2315
|
+
var POLL_TIMEOUT_MS = 15 * 60 * 1e3;
|
|
2316
|
+
async function recoverSession(config) {
|
|
2317
|
+
const webUrl = resolveWebUrl();
|
|
2318
|
+
let nonce;
|
|
2319
|
+
try {
|
|
2320
|
+
({ nonce } = await postJson(`${webUrl}/api/public/runner-recovery/challenge`, {
|
|
2321
|
+
runnerId: config.runnerId
|
|
2322
|
+
}));
|
|
2323
|
+
} catch (error2) {
|
|
2324
|
+
throw new Error(
|
|
2325
|
+
`Recupera\xE7\xE3o autom\xE1tica indispon\xEDvel para este runner (${error2 instanceof Error ? error2.message : error2}). Rode "agent-board-runner request-approval --email <seu-email>" para configur\xE1-lo (uma \xFAnica vez).`
|
|
2326
|
+
);
|
|
2327
|
+
}
|
|
2328
|
+
const signature = sign(null, Buffer.from(nonce, "utf8"), createPrivateKey(config.recoveryPrivateKey)).toString("base64");
|
|
2329
|
+
const { accessToken, refreshToken, email } = await postJson(
|
|
2330
|
+
`${webUrl}/api/public/runner-recovery/verify`,
|
|
2331
|
+
{ runnerId: config.runnerId, signature }
|
|
2332
|
+
);
|
|
2333
|
+
const supabase = createAuthedSupabaseClient();
|
|
2334
|
+
const { error } = await supabase.auth.setSession({ access_token: accessToken, refresh_token: refreshToken });
|
|
2335
|
+
if (error) throw error;
|
|
2336
|
+
console.log(`Sess\xE3o recuperada automaticamente (${email}) \u2014 sem interven\xE7\xE3o manual.`);
|
|
2337
|
+
return { email };
|
|
2338
|
+
}
|
|
2339
|
+
async function ensureSession(config) {
|
|
2340
|
+
const probe = createAuthedSupabaseClient();
|
|
2341
|
+
const existing = await getSession(probe);
|
|
2342
|
+
if (existing) return { client: probe, session: existing };
|
|
2343
|
+
await recoverSession(config);
|
|
2344
|
+
const client = createAuthedSupabaseClient();
|
|
2345
|
+
const session = await requireSession(client);
|
|
2346
|
+
return { client, session };
|
|
2347
|
+
}
|
|
2260
2348
|
|
|
2261
2349
|
// src/branchWatcher.ts
|
|
2262
2350
|
import { execFile as execFile3 } from "node:child_process";
|
|
@@ -2326,1086 +2414,1111 @@ async function ensureBranchWatchers(supabase, config) {
|
|
|
2326
2414
|
|
|
2327
2415
|
// src/columnActions.ts
|
|
2328
2416
|
import { exec } from "node:child_process";
|
|
2329
|
-
import { join as
|
|
2417
|
+
import { join as join12 } from "node:path";
|
|
2330
2418
|
import { promisify as promisify4 } from "node:util";
|
|
2331
2419
|
|
|
2332
|
-
// src/
|
|
2333
|
-
|
|
2334
|
-
try {
|
|
2335
|
-
const { data, error } = await supabase.rpc("resolve_project_secrets", { p_project_id: projectId, p_repo_id: repoId });
|
|
2336
|
-
if (error) throw error;
|
|
2337
|
-
const rows = data ?? [];
|
|
2338
|
-
const env = {};
|
|
2339
|
-
for (const row of rows) env[row.name] = row.value;
|
|
2340
|
-
if (rows.length > 0) {
|
|
2341
|
-
logger.runner.info(
|
|
2342
|
-
"VAULT",
|
|
2343
|
-
`Resolved ${rows.length} secret(s) for project ${projectId}${repoId ? ` (repo ${repoId})` : ""}: ${rows.map((r) => r.name).join(", ")}.`
|
|
2344
|
-
);
|
|
2345
|
-
}
|
|
2346
|
-
return env;
|
|
2347
|
-
} catch (err) {
|
|
2348
|
-
logger.runner.error("VAULT", `Failed to resolve secrets for project ${projectId}${repoId ? ` (repo ${repoId})` : ""}:`, err);
|
|
2349
|
-
return {};
|
|
2350
|
-
}
|
|
2351
|
-
}
|
|
2420
|
+
// src/orchestrator.ts
|
|
2421
|
+
import { randomUUID as randomUUID7 } from "node:crypto";
|
|
2352
2422
|
|
|
2353
|
-
// src/
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2423
|
+
// src/agentSession.ts
|
|
2424
|
+
import { randomUUID as randomUUID6 } from "node:crypto";
|
|
2425
|
+
import { spawn as spawn3 } from "node-pty";
|
|
2426
|
+
|
|
2427
|
+
// src/sessionName.ts
|
|
2428
|
+
var MAX_LENGTH = 60;
|
|
2429
|
+
var FALLBACK_NAME = "card";
|
|
2430
|
+
function toSessionName(title) {
|
|
2431
|
+
const slug = title.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
2432
|
+
if (slug.length === 0) return FALLBACK_NAME;
|
|
2433
|
+
if (slug.length <= MAX_LENGTH) return slug;
|
|
2434
|
+
const truncated = slug.slice(0, MAX_LENGTH);
|
|
2435
|
+
const lastHyphen = truncated.lastIndexOf("-");
|
|
2436
|
+
return (lastHyphen > 0 ? truncated.slice(0, lastHyphen) : truncated).replace(/-+$/, "");
|
|
2358
2437
|
}
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2438
|
+
|
|
2439
|
+
// src/agentSession.ts
|
|
2440
|
+
var TUI_IDLE_MS = 1500;
|
|
2441
|
+
var TUI_READY_TIMEOUT_MS = 6e4;
|
|
2442
|
+
var SUBMIT_DELAY_MS = 150;
|
|
2443
|
+
var PASTE_START = "\x1B[200~";
|
|
2444
|
+
var PASTE_END = "\x1B[201~";
|
|
2445
|
+
var SHIFT_TAB = "\x1B[Z";
|
|
2446
|
+
var SHIFT_TAB_PRESSES_TO_PLAN_MODE = 4;
|
|
2447
|
+
var SHIFT_TAB_DELAY_MS = 400;
|
|
2448
|
+
var PLAN_MODE_INDICATOR = /plan mode on/i;
|
|
2449
|
+
function spawnAgentPty(repoPath, cardId, cardTitle, instructions, sessionId, ticket, planFirst) {
|
|
2450
|
+
ensureDirTrusted(repoPath);
|
|
2451
|
+
ensureMcpServersTrusted(repoPath);
|
|
2452
|
+
const claudeSessionId = randomUUID6();
|
|
2453
|
+
const pty = spawn3(
|
|
2454
|
+
CLAUDE_BINARY,
|
|
2455
|
+
[
|
|
2456
|
+
"--dangerously-skip-permissions",
|
|
2457
|
+
"--model",
|
|
2458
|
+
"claude-sonnet-5",
|
|
2459
|
+
"--session-id",
|
|
2460
|
+
claudeSessionId,
|
|
2461
|
+
...claudeCliSettingsArgs(),
|
|
2462
|
+
`/rename ${toSessionName(cardTitle)}`
|
|
2463
|
+
],
|
|
2464
|
+
{
|
|
2465
|
+
name: "xterm-color",
|
|
2466
|
+
cols: 120,
|
|
2467
|
+
rows: 32,
|
|
2468
|
+
cwd: repoPath,
|
|
2469
|
+
env: statuslineEnv(sessionId, ticket)
|
|
2470
|
+
}
|
|
2471
|
+
);
|
|
2472
|
+
sendMessageWhenIdle(pty, cardId, instructions, planFirst);
|
|
2473
|
+
return { pty, claudeSessionId };
|
|
2366
2474
|
}
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2475
|
+
function sendMessageWhenIdle(pty, cardId, message, planFirst) {
|
|
2476
|
+
let idleTimer;
|
|
2477
|
+
let deadlineTimer;
|
|
2478
|
+
let dataSubscription;
|
|
2479
|
+
let settled = false;
|
|
2480
|
+
const stopWaiting = () => {
|
|
2481
|
+
if (settled) return false;
|
|
2482
|
+
settled = true;
|
|
2483
|
+
clearTimeout(idleTimer);
|
|
2484
|
+
clearTimeout(deadlineTimer);
|
|
2485
|
+
dataSubscription?.dispose();
|
|
2486
|
+
return true;
|
|
2487
|
+
};
|
|
2488
|
+
const send = async () => {
|
|
2489
|
+
if (!stopWaiting()) return;
|
|
2490
|
+
if (planFirst) await enterPlanMode(pty, cardId);
|
|
2491
|
+
try {
|
|
2492
|
+
pty.write(`${PASTE_START}${message}${PASTE_END}`);
|
|
2493
|
+
setTimeout(() => {
|
|
2494
|
+
try {
|
|
2495
|
+
pty.write("\r");
|
|
2496
|
+
} catch (err) {
|
|
2497
|
+
logger.card.error(cardId, "Failed to submit the card instructions to the agent:", err);
|
|
2498
|
+
}
|
|
2499
|
+
}, SUBMIT_DELAY_MS);
|
|
2500
|
+
} catch (err) {
|
|
2501
|
+
logger.card.error(cardId, "Failed to send the card instructions to the agent:", err);
|
|
2502
|
+
}
|
|
2503
|
+
};
|
|
2504
|
+
deadlineTimer = setTimeout(send, TUI_READY_TIMEOUT_MS);
|
|
2505
|
+
dataSubscription = pty.onData(() => {
|
|
2506
|
+
clearTimeout(idleTimer);
|
|
2507
|
+
idleTimer = setTimeout(send, TUI_IDLE_MS);
|
|
2508
|
+
});
|
|
2509
|
+
pty.onExit(() => stopWaiting());
|
|
2377
2510
|
}
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
const
|
|
2381
|
-
|
|
2382
|
-
|
|
2511
|
+
async function enterPlanMode(pty, cardId) {
|
|
2512
|
+
let recentOutput = "";
|
|
2513
|
+
const subscription = pty.onData((chunk) => {
|
|
2514
|
+
recentOutput += normalizeTerminalOutput(chunk);
|
|
2515
|
+
});
|
|
2383
2516
|
try {
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
const targets = resolveTargets(project, children, repoRoot, card.column_name, "before");
|
|
2388
|
-
if (targets.length === 0) return;
|
|
2389
|
-
const allCards = await listCardsInColumn(supabase, project.id, card.column_name);
|
|
2390
|
-
const cards = allCards.filter((candidate) => candidate.assigned_user_id === config.ownerUserId);
|
|
2391
|
-
if (cards.length === 0) return;
|
|
2392
|
-
await Promise.all(
|
|
2393
|
-
cards.map((candidate) => updateCard(supabase, candidate.id, { customActionStatus: "running", customActionOutput: null }))
|
|
2394
|
-
);
|
|
2395
|
-
let failure = null;
|
|
2396
|
-
for (const target of targets) {
|
|
2397
|
-
const secretsEnv = await resolveProjectSecretsEnv(supabase, project.id, target.repoId);
|
|
2398
|
-
const result = await runCommand(target, secretsEnv);
|
|
2399
|
-
if (!result.ok) {
|
|
2400
|
-
failure = { label: target.label, output: result.output };
|
|
2401
|
-
break;
|
|
2402
|
-
}
|
|
2517
|
+
for (let attempt = 1; attempt <= SHIFT_TAB_PRESSES_TO_PLAN_MODE; attempt++) {
|
|
2518
|
+
pty.write(SHIFT_TAB);
|
|
2519
|
+
await new Promise((resolve4) => setTimeout(resolve4, SHIFT_TAB_DELAY_MS));
|
|
2403
2520
|
}
|
|
2404
|
-
if (
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
cards.map(
|
|
2409
|
-
(candidate) => updateCard(supabase, candidate.id, { customActionStatus: "failed", customActionOutput: message })
|
|
2410
|
-
)
|
|
2411
|
-
);
|
|
2412
|
-
} else {
|
|
2413
|
-
logger.runner.info("COLUMN_ACTION", `Command passed for project ${project.id} in "${card.column_name}".`);
|
|
2414
|
-
await Promise.all(
|
|
2415
|
-
cards.map(
|
|
2416
|
-
(candidate) => updateCard(supabase, candidate.id, { customActionStatus: "passed", customActionOutput: null })
|
|
2417
|
-
)
|
|
2521
|
+
if (!PLAN_MODE_INDICATOR.test(recentOutput)) {
|
|
2522
|
+
logger.card.warn(
|
|
2523
|
+
cardId,
|
|
2524
|
+
`Sent ${SHIFT_TAB_PRESSES_TO_PLAN_MODE} Shift+Tab presses but never saw the plan-mode indicator \u2014 the session may not actually be in Plan Mode.`
|
|
2418
2525
|
);
|
|
2419
2526
|
}
|
|
2527
|
+
} catch (err) {
|
|
2528
|
+
logger.card.error(cardId, "Failed to switch the session into Plan Mode:", err);
|
|
2420
2529
|
} finally {
|
|
2421
|
-
|
|
2530
|
+
subscription.dispose();
|
|
2422
2531
|
}
|
|
2423
2532
|
}
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2533
|
+
function sendMessageToIdleSession(pty, cardId, message) {
|
|
2534
|
+
try {
|
|
2535
|
+
pty.write(`${PASTE_START}${message}${PASTE_END}`);
|
|
2536
|
+
setTimeout(() => {
|
|
2537
|
+
try {
|
|
2538
|
+
pty.write("\r");
|
|
2539
|
+
} catch (err) {
|
|
2540
|
+
logger.card.error(cardId, "Failed to submit a message to the idle session:", err);
|
|
2541
|
+
}
|
|
2542
|
+
}, SUBMIT_DELAY_MS);
|
|
2543
|
+
} catch (err) {
|
|
2544
|
+
logger.card.error(cardId, "Failed to send a message to the idle session:", err);
|
|
2436
2545
|
}
|
|
2437
|
-
return { ok: true };
|
|
2438
2546
|
}
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2547
|
+
function resumeAgentPty(repoPath, claudeSessionId, sessionId, ticket) {
|
|
2548
|
+
ensureDirTrusted(repoPath);
|
|
2549
|
+
ensureMcpServersTrusted(repoPath);
|
|
2550
|
+
return spawn3(
|
|
2551
|
+
CLAUDE_BINARY,
|
|
2552
|
+
[
|
|
2553
|
+
"--dangerously-skip-permissions",
|
|
2554
|
+
"--model",
|
|
2555
|
+
"claude-sonnet-5",
|
|
2556
|
+
"--resume",
|
|
2557
|
+
claudeSessionId,
|
|
2558
|
+
...claudeCliSettingsArgs()
|
|
2559
|
+
],
|
|
2560
|
+
{
|
|
2561
|
+
name: "xterm-color",
|
|
2562
|
+
cols: 120,
|
|
2563
|
+
rows: 32,
|
|
2564
|
+
cwd: repoPath,
|
|
2565
|
+
env: statuslineEnv(sessionId, ticket)
|
|
2447
2566
|
}
|
|
2448
|
-
|
|
2567
|
+
);
|
|
2449
2568
|
}
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
await clearDeployActionRerunRequest(supabase, project.id);
|
|
2569
|
+
|
|
2570
|
+
// src/cardAttachments.ts
|
|
2571
|
+
import { mkdir as mkdir2, writeFile as writeFile2 } from "node:fs/promises";
|
|
2572
|
+
import { tmpdir as tmpdir6 } from "node:os";
|
|
2573
|
+
import { extname, join as join11 } from "node:path";
|
|
2574
|
+
var ATTACHMENTS_ROOT = join11(tmpdir6(), "agent-board", "card-attachments");
|
|
2575
|
+
function sanitizeFileName(fileName) {
|
|
2576
|
+
return fileName.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
2459
2577
|
}
|
|
2460
|
-
async function
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2578
|
+
async function downloadCardAttachments(supabase, cardId) {
|
|
2579
|
+
const { data, error } = await supabase.from("card_attachments").select("id, storage_path, file_name").eq("card_id", cardId).order("created_at", { ascending: true });
|
|
2580
|
+
if (error) {
|
|
2581
|
+
logger.card.error(cardId, "Failed to list attachments:", error.message);
|
|
2582
|
+
return [];
|
|
2583
|
+
}
|
|
2584
|
+
const rows = data ?? [];
|
|
2585
|
+
if (rows.length === 0) return [];
|
|
2586
|
+
const targetDir = join11(ATTACHMENTS_ROOT, cardId);
|
|
2587
|
+
await mkdir2(targetDir, { recursive: true });
|
|
2588
|
+
const paths = [];
|
|
2589
|
+
for (const row of rows) {
|
|
2590
|
+
const baseName = sanitizeFileName(row.file_name);
|
|
2591
|
+
const fileName = extname(baseName) ? baseName : `${baseName}${extname(row.storage_path)}`;
|
|
2592
|
+
const localPath = join11(targetDir, `${row.id}-${fileName}`);
|
|
2593
|
+
try {
|
|
2594
|
+
const { data: blob, error: downloadError } = await supabase.storage.from(CARD_ATTACHMENTS_BUCKET).download(row.storage_path);
|
|
2595
|
+
if (downloadError) throw downloadError;
|
|
2596
|
+
await writeFile2(localPath, Buffer.from(await blob.arrayBuffer()));
|
|
2597
|
+
paths.push(localPath);
|
|
2598
|
+
} catch (err) {
|
|
2599
|
+
logger.card.error(cardId, `Failed to download attachment ${row.storage_path}:`, err);
|
|
2465
2600
|
}
|
|
2466
2601
|
}
|
|
2602
|
+
return paths;
|
|
2467
2603
|
}
|
|
2604
|
+
function buildInstructionsWithAttachments(instructions, attachmentPaths) {
|
|
2605
|
+
if (attachmentPaths.length === 0) return instructions;
|
|
2606
|
+
const list = attachmentPaths.map((path) => `- ${path}`).join("\n");
|
|
2607
|
+
return `${instructions}
|
|
2468
2608
|
|
|
2469
|
-
|
|
2470
|
-
|
|
2609
|
+
Files attached to this card (use each one as part of the task):
|
|
2610
|
+
${list}`;
|
|
2611
|
+
}
|
|
2471
2612
|
|
|
2472
|
-
// src/
|
|
2473
|
-
|
|
2613
|
+
// src/fileReferences.ts
|
|
2614
|
+
function buildInstructionsWithFileReferences(instructions, fileReferences) {
|
|
2615
|
+
if (fileReferences.length === 0) return instructions;
|
|
2616
|
+
const list = fileReferences.map((path) => `- ${path}`).join("\n");
|
|
2617
|
+
return `${instructions}
|
|
2474
2618
|
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
import { accessSync, constants } from "node:fs";
|
|
2478
|
-
import { homedir as homedir5 } from "node:os";
|
|
2479
|
-
import { delimiter, join as join12 } from "node:path";
|
|
2480
|
-
import { bin as bundledBinaryPath, install as downloadCloudflared } from "cloudflared";
|
|
2481
|
-
var TRYCLOUDFLARE_URL_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
|
|
2482
|
-
var STARTUP_TIMEOUT_MS = 2e4;
|
|
2483
|
-
var IS_WINDOWS2 = process.platform === "win32";
|
|
2484
|
-
var BINARY_NAME = IS_WINDOWS2 ? "cloudflared.exe" : "cloudflared";
|
|
2485
|
-
var CLOUDFLARED_INSTALL_DOCS_URL = "https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/";
|
|
2486
|
-
function wellKnownDirectories() {
|
|
2487
|
-
const home = homedir5();
|
|
2488
|
-
if (IS_WINDOWS2) {
|
|
2489
|
-
const candidates = [
|
|
2490
|
-
process.env.ProgramFiles && join12(process.env.ProgramFiles, "cloudflared"),
|
|
2491
|
-
process.env["ProgramFiles(x86)"] && join12(process.env["ProgramFiles(x86)"], "cloudflared"),
|
|
2492
|
-
process.env.LOCALAPPDATA && join12(process.env.LOCALAPPDATA, "Microsoft", "WinGet", "Links"),
|
|
2493
|
-
join12(home, "scoop", "shims"),
|
|
2494
|
-
process.env.ChocolateyInstall && join12(process.env.ChocolateyInstall, "bin")
|
|
2495
|
-
];
|
|
2496
|
-
return candidates.filter((dir) => Boolean(dir));
|
|
2497
|
-
}
|
|
2498
|
-
return ["/usr/local/bin", "/usr/bin", "/opt/homebrew/bin", join12(home, ".local", "bin")];
|
|
2619
|
+
Files/folders referenced on this card (read them before starting \u2014 they are part of the task):
|
|
2620
|
+
${list}`;
|
|
2499
2621
|
}
|
|
2500
|
-
|
|
2501
|
-
|
|
2622
|
+
|
|
2623
|
+
// src/mcpContextNote.ts
|
|
2624
|
+
function buildInstructionsWithMcpContext(instructions, cardId, projectId) {
|
|
2625
|
+
return `${instructions}
|
|
2626
|
+
|
|
2627
|
+
Agent Board MCP: this card's id is \`${cardId}\` and its project id is \`${projectId}\` \u2014 pass these directly to board tools (\`update_card\`, \`create_card\`, \`add_card_comment\`, etc.), no lookup needed. If this task turns out too large for one session, use \`create_card\` to split it into subtask cards (chain them with \`prerequisiteCardId\` when one depends on another) instead of trying to fit everything here. If part of the delivery needs a human to do something you can't do yourself (rotate a secret, approve something in a third-party dashboard, delete a resource manually, etc.), register each step with \`add_manual_actions\` instead of just mentioning it in your final message \u2014 that's the only place humans will reliably see it. Before finishing, post a short summary of what you did as a comment on this card via \`add_card_comment\` so the project's humans have visibility without opening this terminal.`;
|
|
2502
2628
|
}
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2629
|
+
|
|
2630
|
+
// src/prototypeGateNote.ts
|
|
2631
|
+
function buildInstructionsWithPrototypeGate(instructions, prototypeFirst) {
|
|
2632
|
+
if (!prototypeFirst) return instructions;
|
|
2633
|
+
return `${instructions}
|
|
2634
|
+
|
|
2635
|
+
Prototipagem visual obrigat\xF3ria antes de implementar (prototypeFirst est\xE1 ligado neste card):
|
|
2636
|
+
|
|
2637
|
+
1. Antes de qualquer altera\xE7\xE3o de c\xF3digo real, publique SOMENTE um prot\xF3tipo visual (um Claude
|
|
2638
|
+
Artifact \u2014 HTML est\xE1tico, sem l\xF3gica/integra\xE7\xE3o de verdade) do que voc\xEA vai construir.
|
|
2639
|
+
Reaproveite ao m\xE1ximo o design system REAL deste reposit\xF3rio (n\xE3o o do agent-board): leia o
|
|
2640
|
+
tailwind config, as CSS vars/tema e os componentes j\xE1 existentes neste \`cwd\` antes de
|
|
2641
|
+
desenhar qualquer coisa. Use as skills \`frontend-design\`/\`artifact-design\` j\xE1 dispon\xEDveis
|
|
2642
|
+
antes de publicar.
|
|
2643
|
+
2. Se os anexos/instru\xE7\xF5es deste card inclu\xEDrem um print de uma tela j\xE1 existente pedindo
|
|
2644
|
+
ajustes, o prot\xF3tipo \xE9 diferente: publique uma p\xE1gina INTERATIVA que mostra o print e deixa
|
|
2645
|
+
quem est\xE1 revisando clicar nele para soltar um pino numerado com um campo de texto por pino \u2014
|
|
2646
|
+
persista cada pino usando a capability \`db\` do pr\xF3prio Artifact (carregue a skill
|
|
2647
|
+
\`artifact-capabilities\` antes de declarar a capability). Nesse caso, a nota que voc\xEA passar
|
|
2648
|
+
para \`publish_prototype\` deve pedir para marcarem os pontos de ajuste nele, n\xE3o para
|
|
2649
|
+
aprovarem um resultado ainda.
|
|
2650
|
+
3. Chame a tool MCP \`publish_prototype\` com o link do Artifact e uma nota curta do que fez/pede.
|
|
2651
|
+
Essa chamada j\xE1 zera a aprova\xE7\xE3o e avisa os humanos do projeto \u2014 n\xE3o poste o link via
|
|
2652
|
+
add_card_comment manualmente tamb\xE9m, seria duplicado.
|
|
2653
|
+
4. Depois de chamar \`publish_prototype\`, PARE o turno \u2014 n\xE3o fique num loop verificando
|
|
2654
|
+
aprova\xE7\xE3o nem monitorando o artifact sozinho. A sess\xE3o ser\xE1 retomada automaticamente quando
|
|
2655
|
+
houver uma revis\xE3o nova (aprova\xE7\xE3o ou pedido de ajuste); tentar continuar por conta pr\xF3pria
|
|
2656
|
+
antes disso \xE9 desperd\xEDcio.
|
|
2657
|
+
5. Quando a sess\xE3o for retomada, a mensagem vai dizer se o prot\xF3tipo foi aprovado:
|
|
2658
|
+
- **Aprovado**: prossiga para a implementa\xE7\xE3o real agora, usando o prot\xF3tipo (e, se for o
|
|
2659
|
+
caso de ajuste em tela existente, os pontos marcados nele \u2014 leia-os de volta via leitura do
|
|
2660
|
+
banco do artifact) como refer\xEAncia. Se os pontos marcados forem grandes/numerosos o
|
|
2661
|
+
suficiente para valer separar, quebre em cards novos (\`create_card\` +
|
|
2662
|
+
\`prerequisiteCardId\`, encadeando os que dependem um do outro) e aplique o mesmo fluxo de
|
|
2663
|
+
prot\xF3tipo em cada um antes de implementar; resolva no pr\xF3prio card quando o ajuste for
|
|
2664
|
+
pequeno.
|
|
2665
|
+
- **N\xE3o aprovado**: confira os coment\xE1rios deixados no Artifact (e, se for a p\xE1gina de
|
|
2666
|
+
anota\xE7\xE3o, os pontos salvos), ajuste o prot\xF3tipo e publique a nova vers\xE3o de novo via
|
|
2667
|
+
\`publish_prototype\` na MESMA url \u2014 depois pare o turno de novo, voltando ao passo 4.`;
|
|
2510
2668
|
}
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2669
|
+
|
|
2670
|
+
// src/usageResume.ts
|
|
2671
|
+
var RESET_BUFFER_MS = 3e4;
|
|
2672
|
+
var UNKNOWN_RESET_RETRY_MS = 5 * 6e4;
|
|
2673
|
+
var pending = /* @__PURE__ */ new Map();
|
|
2674
|
+
function scheduleUsageResume(supabase, config, cardId, sessionId, pty) {
|
|
2675
|
+
if (pending.has(sessionId)) return;
|
|
2676
|
+
void (async () => {
|
|
2677
|
+
const usage = await getClaudeUsage(supabase, config.ownerUserId).catch((err) => {
|
|
2678
|
+
logger.card.error(cardId, "Failed to read claude_usage for usage-limit resume:", err);
|
|
2679
|
+
return null;
|
|
2680
|
+
});
|
|
2681
|
+
const now = Date.now();
|
|
2682
|
+
const candidates = [usage?.sessionResetsAt, usage?.weekResetsAt].map((iso) => iso ? new Date(iso).getTime() : null).filter((ms) => ms !== null && ms > now);
|
|
2683
|
+
const resetAt = candidates.length > 0 ? Math.min(...candidates) : null;
|
|
2684
|
+
const delay = resetAt !== null ? resetAt - now + RESET_BUFFER_MS : UNKNOWN_RESET_RETRY_MS;
|
|
2685
|
+
logger.card.info(
|
|
2686
|
+
cardId,
|
|
2687
|
+
resetAt !== null ? `Usage limit hit \u2014 will send "continue" automatically at ${new Date(resetAt).toISOString()}.` : `Usage limit hit but no reset time known yet \u2014 will retry in ${Math.round(delay / 6e4)}min.`
|
|
2688
|
+
);
|
|
2689
|
+
const timer = setTimeout(() => {
|
|
2690
|
+
pending.delete(sessionId);
|
|
2691
|
+
sendMessageToIdleSession(pty, cardId, "continue");
|
|
2692
|
+
}, delay);
|
|
2693
|
+
timer.unref();
|
|
2694
|
+
pending.set(sessionId, timer);
|
|
2695
|
+
})();
|
|
2516
2696
|
}
|
|
2517
|
-
|
|
2518
|
-
const
|
|
2519
|
-
if (
|
|
2520
|
-
|
|
2697
|
+
function cancelUsageResume(sessionId) {
|
|
2698
|
+
const timer = pending.get(sessionId);
|
|
2699
|
+
if (!timer) return;
|
|
2700
|
+
clearTimeout(timer);
|
|
2701
|
+
pending.delete(sessionId);
|
|
2702
|
+
}
|
|
2703
|
+
|
|
2704
|
+
// src/orchestrator.ts
|
|
2705
|
+
var TERMINAL_TICKET_TTL_MS = 60 * 60 * 1e3;
|
|
2706
|
+
var RESUME_CONVERSATION_MISSING_PATTERN = /no conversation found with session id/i;
|
|
2707
|
+
var inFlightCardIds = /* @__PURE__ */ new Set();
|
|
2708
|
+
var activeSessionByCard = /* @__PURE__ */ new Map();
|
|
2709
|
+
var intentionallyStoppedSessionIds = /* @__PURE__ */ new Set();
|
|
2710
|
+
var dispatchingRunner = false;
|
|
2711
|
+
async function isEffectiveOwner(supabase, card, config) {
|
|
2712
|
+
if (card.assigned_user_id !== config.ownerUserId) return false;
|
|
2713
|
+
if (card.runner_id) return card.runner_id === config.runnerId;
|
|
2714
|
+
const defaultRunnerId = await getDefaultRunnerIdForUser(supabase, config.ownerUserId);
|
|
2715
|
+
if (defaultRunnerId) return defaultRunnerId === config.runnerId;
|
|
2716
|
+
return true;
|
|
2717
|
+
}
|
|
2718
|
+
async function scanForWork(supabase, config) {
|
|
2719
|
+
for (const project of config.registeredProjects) {
|
|
2720
|
+
await reconcileStaleSessions(supabase, config, project.id);
|
|
2721
|
+
const { data: cards, error } = await supabase.from("cards").select("*").eq("project_id", project.id).eq("column_name", "desenvolvimento").eq("assigned_user_id", config.ownerUserId);
|
|
2722
|
+
if (error) {
|
|
2723
|
+
logger.runner.error("ORCHESTRATOR", `Failed to scan cards for project ${project.id}:`, error.message);
|
|
2724
|
+
continue;
|
|
2725
|
+
}
|
|
2726
|
+
for (const card of cards ?? []) {
|
|
2727
|
+
void enqueueCard(supabase, config, card);
|
|
2728
|
+
}
|
|
2729
|
+
}
|
|
2730
|
+
void dispatchRunnerQueue(supabase, config);
|
|
2731
|
+
}
|
|
2732
|
+
async function enqueueCard(supabase, config, card) {
|
|
2733
|
+
if (inFlightCardIds.has(card.id)) return;
|
|
2734
|
+
const registered = config.registeredProjects.find((project) => project.id === card.project_id);
|
|
2735
|
+
if (!registered) {
|
|
2736
|
+
logger.card.warn(card.id, `Ignoring card: project ${card.project_id} is not registered on this runner.`);
|
|
2737
|
+
return;
|
|
2738
|
+
}
|
|
2739
|
+
if (!await isEffectiveOwner(supabase, card, config)) return;
|
|
2740
|
+
inFlightCardIds.add(card.id);
|
|
2521
2741
|
try {
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2742
|
+
const active = await getActiveSessionForCard(supabase, card.id);
|
|
2743
|
+
if (active) {
|
|
2744
|
+
if (active.runnerId !== config.runnerId) {
|
|
2745
|
+
const holderHeartbeat = active.runnerId === null ? null : (await getRunnerHeartbeats(supabase, [active.runnerId])).get(active.runnerId) ?? null;
|
|
2746
|
+
if (active.runnerId !== null && !isRunnerHeartbeatStale(holderHeartbeat)) return;
|
|
2747
|
+
logger.card.warn(
|
|
2748
|
+
card.id,
|
|
2749
|
+
`Active session ${active.id} belongs to runner ${active.runnerId ?? "(none)"}, which looks offline \u2014 reclaiming for ${config.runnerId}.`
|
|
2750
|
+
);
|
|
2751
|
+
await updateSession(supabase, active.id, { status: "failed", endedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
2752
|
+
activeSessionByCard.delete(card.id);
|
|
2753
|
+
} else {
|
|
2754
|
+
return;
|
|
2755
|
+
}
|
|
2756
|
+
}
|
|
2757
|
+
await createQueuedSession(supabase, config, card);
|
|
2758
|
+
} finally {
|
|
2759
|
+
inFlightCardIds.delete(card.id);
|
|
2527
2760
|
}
|
|
2761
|
+
await dispatchRunnerQueue(supabase, config);
|
|
2528
2762
|
}
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2763
|
+
async function createQueuedSession(supabase, config, card) {
|
|
2764
|
+
const resumeClaudeSessionId = await getLastClaudeSessionIdForCard(supabase, card.id);
|
|
2765
|
+
const sessionId = await createSession(supabase, config.ownerUserId, card.id, config.runnerId, resumeClaudeSessionId);
|
|
2766
|
+
logger.card.info(
|
|
2767
|
+
card.id,
|
|
2768
|
+
resumeClaudeSessionId ? `"${card.title}" queued (session ${sessionId}, will resume prior conversation ${resumeClaudeSessionId}).` : `"${card.title}" queued (session ${sessionId}).`
|
|
2769
|
+
);
|
|
2536
2770
|
}
|
|
2537
|
-
async function
|
|
2538
|
-
const
|
|
2539
|
-
if (
|
|
2540
|
-
logger.runner.error("
|
|
2541
|
-
return
|
|
2771
|
+
async function reconcileStaleSessions(supabase, config, projectId) {
|
|
2772
|
+
const { data, error } = await supabase.from("sessions").select("id, card_id, cards!card_id!inner(project_id, column_name)").eq("cards.project_id", projectId).eq("runner_id", config.runnerId).in("status", ["queued", "running", "waiting_input", "paused"]).neq("cards.column_name", "desenvolvimento");
|
|
2773
|
+
if (error) {
|
|
2774
|
+
logger.runner.error("ORCHESTRATOR", `Failed to scan stale sessions for project ${projectId}:`, error.message);
|
|
2775
|
+
return;
|
|
2542
2776
|
}
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
resolve4(value);
|
|
2564
|
-
};
|
|
2565
|
-
const timer = setTimeout(() => {
|
|
2566
|
-
logger.runner.warn("CLOUDFLARE", `cloudflared for ${label} did not print a trycloudflare.com URL within ${STARTUP_TIMEOUT_MS}ms.`);
|
|
2567
|
-
settle(null);
|
|
2568
|
-
}, STARTUP_TIMEOUT_MS);
|
|
2569
|
-
const onOutput = (chunk) => {
|
|
2570
|
-
if (settled) return;
|
|
2571
|
-
const match = chunk.toString("utf-8").match(TRYCLOUDFLARE_URL_RE);
|
|
2572
|
-
if (match) settle({ url: match[0], child: child2 });
|
|
2573
|
-
};
|
|
2574
|
-
child2.stdout?.on("data", onOutput);
|
|
2575
|
-
child2.stderr?.on("data", onOutput);
|
|
2576
|
-
child2.on("error", (err) => {
|
|
2577
|
-
logger.runner.error("CLOUDFLARE", `cloudflared process error for ${label} (${executable}):`, err);
|
|
2578
|
-
settle(null);
|
|
2579
|
-
});
|
|
2580
|
-
child2.on("exit", (code) => {
|
|
2581
|
-
logger.runner.info("CLOUDFLARE", `cloudflared tunnel for ${label} exited (code ${code}).`);
|
|
2582
|
-
settle(null);
|
|
2583
|
-
});
|
|
2584
|
-
});
|
|
2585
|
-
}
|
|
2586
|
-
function toTunnelHostname(url) {
|
|
2587
|
-
return new URL(url).host;
|
|
2588
|
-
}
|
|
2589
|
-
|
|
2590
|
-
// src/devServer.ts
|
|
2591
|
-
var STARTUP_TIMEOUT_MS2 = 45e3;
|
|
2592
|
-
var IS_WINDOWS3 = process.platform === "win32";
|
|
2593
|
-
var LOCAL_URL_PORT_RE = /https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\]|0\.0\.0\.0):(\d{2,5})/i;
|
|
2594
|
-
var GENERIC_PORT_RE = /(?:listening|running|server).{0,40}?:?\s*(\d{4,5})\b/i;
|
|
2595
|
-
var ANSI_ESCAPE_RE2 = /\x1b\[[0-9;]*m/g;
|
|
2596
|
-
function detectPortFromOutput(chunk) {
|
|
2597
|
-
const clean = chunk.replace(ANSI_ESCAPE_RE2, "");
|
|
2598
|
-
const match = LOCAL_URL_PORT_RE.exec(clean) ?? GENERIC_PORT_RE.exec(clean);
|
|
2599
|
-
return match ? Number(match[1]) : null;
|
|
2600
|
-
}
|
|
2601
|
-
function killProcessTree(child2) {
|
|
2602
|
-
if (child2.pid == null) return;
|
|
2603
|
-
if (IS_WINDOWS3) {
|
|
2604
|
-
spawn4("taskkill", ["/pid", String(child2.pid), "/t", "/f"], { stdio: "ignore" });
|
|
2605
|
-
} else {
|
|
2606
|
-
child2.kill();
|
|
2777
|
+
const stale = data ?? [];
|
|
2778
|
+
if (stale.length === 0) return;
|
|
2779
|
+
for (const { id: sessionId, card_id: cardId, cards } of stale) {
|
|
2780
|
+
intentionallyStoppedSessionIds.add(sessionId);
|
|
2781
|
+
stopPtySession(sessionId);
|
|
2782
|
+
cancelUsageResume(sessionId);
|
|
2783
|
+
activeSessionByCard.delete(cardId);
|
|
2784
|
+
await updateSession(
|
|
2785
|
+
supabase,
|
|
2786
|
+
sessionId,
|
|
2787
|
+
{
|
|
2788
|
+
status: "done",
|
|
2789
|
+
endedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2790
|
+
// See stopCardSession's doc comment: "fim" is the authoritative completion signal for
|
|
2791
|
+
// dispatchRunnerQueue's prerequisite gate, which only ever reads agent_state.
|
|
2792
|
+
...cards.column_name === "fim" ? { agentState: "done" } : {}
|
|
2793
|
+
},
|
|
2794
|
+
{ projectId, cardId, userId: config.ownerUserId }
|
|
2795
|
+
);
|
|
2796
|
+
logger.card.info(cardId, `Ended stale session ${sessionId} (its card left "desenvolvimento" while this runner missed the event).`);
|
|
2607
2797
|
}
|
|
2798
|
+
await dispatchRunnerQueue(supabase, config);
|
|
2608
2799
|
}
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
return existing.result;
|
|
2615
|
-
}
|
|
2616
|
-
let child2;
|
|
2800
|
+
async function dispatchRunnerQueue(supabase, config) {
|
|
2801
|
+
if (dispatchingRunner) return;
|
|
2802
|
+
dispatchingRunner = true;
|
|
2803
|
+
let queued = [];
|
|
2804
|
+
const promotedCardIds = /* @__PURE__ */ new Set();
|
|
2617
2805
|
try {
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
clearTimeout(timer);
|
|
2630
|
-
if (!value) {
|
|
2631
|
-
killProcessTree(child2);
|
|
2632
|
-
previews.delete(key);
|
|
2806
|
+
queued = await listQueuedSessionsForRunner(supabase, config.runnerId);
|
|
2807
|
+
if (queued.length === 0) return;
|
|
2808
|
+
const queuedProjectIds = new Set(queued.map((entry) => entry.cards.project_id));
|
|
2809
|
+
const maxConcurrentProjects = await getRunnerMaxConcurrentProjects(supabase, config.runnerId);
|
|
2810
|
+
const runningProjectIds = /* @__PURE__ */ new Set();
|
|
2811
|
+
for (const project of config.registeredProjects) {
|
|
2812
|
+
const running = await getRunningSessionForProject(supabase, project.id, config.runnerId);
|
|
2813
|
+
if (!running) continue;
|
|
2814
|
+
if ((running.agentState === "done" || running.agentState === "waiting_prototype_review") && queuedProjectIds.has(project.id)) {
|
|
2815
|
+
await pauseFinishedSession(supabase, config, project.id, running.cardId, running.id);
|
|
2816
|
+
continue;
|
|
2633
2817
|
}
|
|
2634
|
-
|
|
2635
|
-
}
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
if (
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2818
|
+
runningProjectIds.add(project.id);
|
|
2819
|
+
}
|
|
2820
|
+
let freeProjectSlots = maxConcurrentProjects - runningProjectIds.size;
|
|
2821
|
+
if (freeProjectSlots <= 0) return;
|
|
2822
|
+
const promotedThisPass = /* @__PURE__ */ new Set();
|
|
2823
|
+
for (const entry of queued) {
|
|
2824
|
+
const projectId = entry.cards.project_id;
|
|
2825
|
+
if (runningProjectIds.has(projectId) || promotedThisPass.has(projectId)) continue;
|
|
2826
|
+
if (freeProjectSlots <= 0) break;
|
|
2827
|
+
const prerequisiteCardId = entry.cards.prerequisite_card_id;
|
|
2828
|
+
if (prerequisiteCardId) {
|
|
2829
|
+
const prerequisiteAgentState = await getLatestAgentStateForCard(supabase, prerequisiteCardId);
|
|
2830
|
+
if (prerequisiteAgentState !== "done") {
|
|
2831
|
+
logger.card.info(
|
|
2832
|
+
entry.card_id,
|
|
2833
|
+
`Waiting on prerequisite card ${prerequisiteCardId} to reach agent state "done" (currently "${prerequisiteAgentState ?? "none"}") before starting \u2014 leaving queued.`
|
|
2834
|
+
);
|
|
2835
|
+
continue;
|
|
2836
|
+
}
|
|
2653
2837
|
}
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
logger.runner.
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
});
|
|
2669
|
-
previews.set(key, entry);
|
|
2670
|
-
return entry.result;
|
|
2671
|
-
}
|
|
2672
|
-
function releaseCard(cardId) {
|
|
2673
|
-
for (const [key, entry] of previews) {
|
|
2674
|
-
if (!entry.cardIds.delete(cardId)) continue;
|
|
2675
|
-
if (entry.cardIds.size > 0) continue;
|
|
2676
|
-
killProcessTree(entry.child);
|
|
2677
|
-
entry.tunnelChild?.kill();
|
|
2678
|
-
previews.delete(key);
|
|
2838
|
+
try {
|
|
2839
|
+
await promoteQueuedSession(supabase, config, entry);
|
|
2840
|
+
promotedThisPass.add(projectId);
|
|
2841
|
+
promotedCardIds.add(entry.card_id);
|
|
2842
|
+
freeProjectSlots -= 1;
|
|
2843
|
+
} catch (err) {
|
|
2844
|
+
logger.card.error(entry.card_id, `Failed to promote queued session ${entry.id}:`, err);
|
|
2845
|
+
}
|
|
2846
|
+
}
|
|
2847
|
+
} finally {
|
|
2848
|
+
await publishQueueSnapshots(supabase, config, queued, promotedCardIds).catch(
|
|
2849
|
+
(err) => logger.runner.error("ORCHESTRATOR", "Failed to publish queue snapshots:", err)
|
|
2850
|
+
);
|
|
2851
|
+
dispatchingRunner = false;
|
|
2679
2852
|
}
|
|
2680
2853
|
}
|
|
2681
|
-
function
|
|
2682
|
-
const
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
entry.
|
|
2687
|
-
|
|
2854
|
+
async function publishQueueSnapshots(supabase, config, queued, promotedCardIds) {
|
|
2855
|
+
const paused = await listPausedSessionsForRunner(supabase, config.runnerId);
|
|
2856
|
+
const queuedByProject = /* @__PURE__ */ new Map();
|
|
2857
|
+
for (const entry of queued) {
|
|
2858
|
+
if (promotedCardIds.has(entry.card_id)) continue;
|
|
2859
|
+
const list = queuedByProject.get(entry.cards.project_id) ?? [];
|
|
2860
|
+
list.push(entry.card_id);
|
|
2861
|
+
queuedByProject.set(entry.cards.project_id, list);
|
|
2688
2862
|
}
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
killProcessTree(entry.child);
|
|
2695
|
-
entry.tunnelChild?.kill();
|
|
2696
|
-
previews.delete(key);
|
|
2863
|
+
const pausedByProject = /* @__PURE__ */ new Map();
|
|
2864
|
+
for (const { card_id: cardId, project_id: projectId } of paused) {
|
|
2865
|
+
const list = pausedByProject.get(projectId) ?? [];
|
|
2866
|
+
list.push(cardId);
|
|
2867
|
+
pausedByProject.set(projectId, list);
|
|
2697
2868
|
}
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
var execFileAsync4 = promisify5(execFile4);
|
|
2704
|
-
async function pushBranchToMain(repoPath) {
|
|
2705
|
-
try {
|
|
2706
|
-
await execFileAsync4("git", ["push", "origin", "HEAD:main"], { cwd: repoPath });
|
|
2707
|
-
return { ok: true };
|
|
2708
|
-
} catch (err) {
|
|
2709
|
-
const stderr = err.stderr;
|
|
2710
|
-
const error = stderr?.trim() || (err instanceof Error ? err.message : String(err));
|
|
2711
|
-
return { ok: false, error };
|
|
2869
|
+
for (const project of config.registeredProjects) {
|
|
2870
|
+
publishQueueEvent(project.id, {
|
|
2871
|
+
queuedCardIds: queuedByProject.get(project.id) ?? [],
|
|
2872
|
+
pausedCardIds: pausedByProject.get(project.id) ?? []
|
|
2873
|
+
});
|
|
2712
2874
|
}
|
|
2713
2875
|
}
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
import { randomUUID as randomUUID6 } from "node:crypto";
|
|
2717
|
-
|
|
2718
|
-
// src/agentSession.ts
|
|
2719
|
-
import { randomUUID as randomUUID5 } from "node:crypto";
|
|
2720
|
-
import { spawn as spawn5 } from "node-pty";
|
|
2721
|
-
|
|
2722
|
-
// src/sessionName.ts
|
|
2723
|
-
var MAX_LENGTH = 60;
|
|
2724
|
-
var FALLBACK_NAME = "card";
|
|
2725
|
-
function toSessionName(title) {
|
|
2726
|
-
const slug = title.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
2727
|
-
if (slug.length === 0) return FALLBACK_NAME;
|
|
2728
|
-
if (slug.length <= MAX_LENGTH) return slug;
|
|
2729
|
-
const truncated = slug.slice(0, MAX_LENGTH);
|
|
2730
|
-
const lastHyphen = truncated.lastIndexOf("-");
|
|
2731
|
-
return (lastHyphen > 0 ? truncated.slice(0, lastHyphen) : truncated).replace(/-+$/, "");
|
|
2732
|
-
}
|
|
2733
|
-
|
|
2734
|
-
// src/agentSession.ts
|
|
2735
|
-
var TUI_IDLE_MS = 1500;
|
|
2736
|
-
var TUI_READY_TIMEOUT_MS = 6e4;
|
|
2737
|
-
var SUBMIT_DELAY_MS = 150;
|
|
2738
|
-
var PASTE_START = "\x1B[200~";
|
|
2739
|
-
var PASTE_END = "\x1B[201~";
|
|
2740
|
-
var SHIFT_TAB = "\x1B[Z";
|
|
2741
|
-
var SHIFT_TAB_PRESSES_TO_PLAN_MODE = 4;
|
|
2742
|
-
var SHIFT_TAB_DELAY_MS = 400;
|
|
2743
|
-
var PLAN_MODE_INDICATOR = /plan mode on/i;
|
|
2744
|
-
function spawnAgentPty(repoPath, cardId, cardTitle, instructions, sessionId, ticket, planFirst) {
|
|
2745
|
-
ensureDirTrusted(repoPath);
|
|
2746
|
-
ensureMcpServersTrusted(repoPath);
|
|
2747
|
-
const claudeSessionId = randomUUID5();
|
|
2748
|
-
const pty = spawn5(
|
|
2749
|
-
CLAUDE_BINARY,
|
|
2750
|
-
[
|
|
2751
|
-
"--dangerously-skip-permissions",
|
|
2752
|
-
"--model",
|
|
2753
|
-
"claude-sonnet-5",
|
|
2754
|
-
"--session-id",
|
|
2755
|
-
claudeSessionId,
|
|
2756
|
-
...claudeCliSettingsArgs(),
|
|
2757
|
-
`/rename ${toSessionName(cardTitle)}`
|
|
2758
|
-
],
|
|
2759
|
-
{
|
|
2760
|
-
name: "xterm-color",
|
|
2761
|
-
cols: 120,
|
|
2762
|
-
rows: 32,
|
|
2763
|
-
cwd: repoPath,
|
|
2764
|
-
env: statuslineEnv(sessionId, ticket)
|
|
2765
|
-
}
|
|
2766
|
-
);
|
|
2767
|
-
sendMessageWhenIdle(pty, cardId, instructions, planFirst);
|
|
2768
|
-
return { pty, claudeSessionId };
|
|
2876
|
+
function buildPrototypeReviewFollowUp(prototypeApproved, prototypeUrl) {
|
|
2877
|
+
return prototypeApproved ? "O prot\xF3tipo foi revisado e aprovado \u2014 prossiga para a implementa\xE7\xE3o real, usando-o como refer\xEAncia." : `O prot\xF3tipo ainda n\xE3o foi aprovado${prototypeUrl ? ` (${prototypeUrl})` : ""} \u2014 confira os coment\xE1rios deixados nele (e, se for um prot\xF3tipo de marca\xE7\xE3o de pontos, os pontos salvos) e ajuste, publicando a nova vers\xE3o com publish_prototype. Depois, pare o turno de novo: a sess\xE3o ser\xE1 retomada quando houver outra revis\xE3o.`;
|
|
2769
2878
|
}
|
|
2770
|
-
function
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
setTimeout(() => {
|
|
2789
|
-
try {
|
|
2790
|
-
pty.write("\r");
|
|
2791
|
-
} catch (err) {
|
|
2792
|
-
logger.card.error(cardId, "Failed to submit the card instructions to the agent:", err);
|
|
2793
|
-
}
|
|
2794
|
-
}, SUBMIT_DELAY_MS);
|
|
2795
|
-
} catch (err) {
|
|
2796
|
-
logger.card.error(cardId, "Failed to send the card instructions to the agent:", err);
|
|
2879
|
+
async function promoteQueuedSession(supabase, config, entry) {
|
|
2880
|
+
const existingHandle = getPtySession(entry.id);
|
|
2881
|
+
if (existingHandle) {
|
|
2882
|
+
setPtySessionReadOnly(entry.id, false);
|
|
2883
|
+
await updateSession(
|
|
2884
|
+
supabase,
|
|
2885
|
+
entry.id,
|
|
2886
|
+
{ status: "running" },
|
|
2887
|
+
{ projectId: entry.cards.project_id, cardId: entry.card_id, userId: config.ownerUserId }
|
|
2888
|
+
);
|
|
2889
|
+
activeSessionByCard.set(entry.card_id, entry.id);
|
|
2890
|
+
logger.card.info(entry.card_id, "Resumed paused session from the front of the queue.");
|
|
2891
|
+
if (entry.agent_state === "waiting_prototype_review" && !existingHandle.exited) {
|
|
2892
|
+
sendMessageToIdleSession(
|
|
2893
|
+
existingHandle.pty,
|
|
2894
|
+
entry.card_id,
|
|
2895
|
+
buildPrototypeReviewFollowUp(entry.cards.prototype_approved, entry.cards.prototype_url)
|
|
2896
|
+
);
|
|
2797
2897
|
}
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
idleTimer = setTimeout(send, TUI_IDLE_MS);
|
|
2803
|
-
});
|
|
2804
|
-
pty.onExit(() => stopWaiting());
|
|
2898
|
+
return;
|
|
2899
|
+
}
|
|
2900
|
+
const spawned = await spawnSessionPty(supabase, config, entry);
|
|
2901
|
+
if (spawned) logger.card.info(entry.card_id, "Promoted from queue.");
|
|
2805
2902
|
}
|
|
2806
|
-
async function
|
|
2807
|
-
|
|
2808
|
-
const
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2903
|
+
async function spawnSessionPty(supabase, config, entry) {
|
|
2904
|
+
const { id: sessionId, card_id: cardId, cards: card, claude_session_id: claudeSessionId } = entry;
|
|
2905
|
+
const registered = config.registeredProjects.find((project) => project.id === card.project_id);
|
|
2906
|
+
if (!registered) {
|
|
2907
|
+
logger.card.error(cardId, `Session ${sessionId}: project ${card.project_id} is not registered \u2014 skipping.`);
|
|
2908
|
+
await updateSession(
|
|
2909
|
+
supabase,
|
|
2910
|
+
sessionId,
|
|
2911
|
+
{ status: "failed", endedAt: (/* @__PURE__ */ new Date()).toISOString() },
|
|
2912
|
+
{ projectId: card.project_id, cardId, userId: config.ownerUserId }
|
|
2913
|
+
);
|
|
2914
|
+
return false;
|
|
2915
|
+
}
|
|
2916
|
+
const ticket = randomUUID7();
|
|
2917
|
+
let pty;
|
|
2918
|
+
let newClaudeSessionId = null;
|
|
2919
|
+
let resumeConversationMissing = false;
|
|
2920
|
+
try {
|
|
2921
|
+
if (claudeSessionId) {
|
|
2922
|
+
pty = resumeAgentPty(registered.repoPath, claudeSessionId, sessionId, ticket);
|
|
2923
|
+
pty.onData((chunk) => {
|
|
2924
|
+
if (RESUME_CONVERSATION_MISSING_PATTERN.test(normalizeTerminalOutput(chunk))) resumeConversationMissing = true;
|
|
2925
|
+
});
|
|
2926
|
+
} else {
|
|
2927
|
+
const attachmentPaths = await downloadCardAttachments(supabase, cardId);
|
|
2928
|
+
const instructions = buildInstructionsWithMcpContext(
|
|
2929
|
+
buildInstructionsWithPrototypeGate(
|
|
2930
|
+
buildInstructionsWithFileReferences(
|
|
2931
|
+
buildInstructionsWithAttachments(card.instructions, attachmentPaths),
|
|
2932
|
+
card.file_references
|
|
2933
|
+
),
|
|
2934
|
+
card.prototype_first
|
|
2935
|
+
),
|
|
2818
2936
|
cardId,
|
|
2819
|
-
|
|
2937
|
+
card.project_id
|
|
2820
2938
|
);
|
|
2939
|
+
const spawned = spawnAgentPty(registered.repoPath, cardId, card.title, instructions, sessionId, ticket, card.plan_first);
|
|
2940
|
+
pty = spawned.pty;
|
|
2941
|
+
newClaudeSessionId = spawned.claudeSessionId;
|
|
2821
2942
|
}
|
|
2822
2943
|
} catch (err) {
|
|
2823
|
-
logger.card.error(cardId,
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
}
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
setTimeout(() => {
|
|
2832
|
-
try {
|
|
2833
|
-
pty.write("\r");
|
|
2834
|
-
} catch (err) {
|
|
2835
|
-
logger.card.error(cardId, "Failed to submit a message to the idle session:", err);
|
|
2836
|
-
}
|
|
2837
|
-
}, SUBMIT_DELAY_MS);
|
|
2838
|
-
} catch (err) {
|
|
2839
|
-
logger.card.error(cardId, "Failed to send a message to the idle session:", err);
|
|
2944
|
+
logger.card.error(cardId, `Failed to spawn pty for session ${sessionId}:`, err);
|
|
2945
|
+
await updateSession(
|
|
2946
|
+
supabase,
|
|
2947
|
+
sessionId,
|
|
2948
|
+
{ status: "failed", endedAt: (/* @__PURE__ */ new Date()).toISOString() },
|
|
2949
|
+
{ projectId: card.project_id, cardId, userId: config.ownerUserId }
|
|
2950
|
+
);
|
|
2951
|
+
return false;
|
|
2840
2952
|
}
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
"--dangerously-skip-permissions",
|
|
2849
|
-
"--model",
|
|
2850
|
-
"claude-sonnet-5",
|
|
2851
|
-
"--resume",
|
|
2852
|
-
claudeSessionId,
|
|
2853
|
-
...claudeCliSettingsArgs()
|
|
2854
|
-
],
|
|
2953
|
+
registerPtySession(sessionId, pty, ticket);
|
|
2954
|
+
activeSessionByCard.set(cardId, sessionId);
|
|
2955
|
+
publishAgentStateEvent(card.project_id, cardId, sessionId, newClaudeSessionId ? "starting" : entry.agent_state ?? "starting");
|
|
2956
|
+
const activityWatcher = watchAgentActivity(supabase, config, card.project_id, cardId, sessionId, pty);
|
|
2957
|
+
await updateSession(
|
|
2958
|
+
supabase,
|
|
2959
|
+
sessionId,
|
|
2855
2960
|
{
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2961
|
+
status: "running",
|
|
2962
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2963
|
+
terminalTicket: ticket,
|
|
2964
|
+
terminalTicketExpiresAt: new Date(Date.now() + TERMINAL_TICKET_TTL_MS).toISOString(),
|
|
2965
|
+
// Only stamp a fresh spawn as "starting" — a resumed session (claudeSessionId was
|
|
2966
|
+
// already set) keeps whatever agent_state it had before the restart (e.g.
|
|
2967
|
+
// "usage_limited"), so reconcileUsageLimitedSessions can find it afterwards. The
|
|
2968
|
+
// freshly (re)constructed AgentActivityWatcher below will correct it the moment real
|
|
2969
|
+
// output gives it something conclusive to classify.
|
|
2970
|
+
...newClaudeSessionId ? { agentState: "starting", claudeSessionId: newClaudeSessionId } : {}
|
|
2971
|
+
},
|
|
2972
|
+
{ projectId: card.project_id, cardId, userId: config.ownerUserId }
|
|
2862
2973
|
);
|
|
2863
|
-
}
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
}
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
const targetDir = join13(ATTACHMENTS_ROOT, cardId);
|
|
2882
|
-
await mkdir2(targetDir, { recursive: true });
|
|
2883
|
-
const paths = [];
|
|
2884
|
-
for (const row of rows) {
|
|
2885
|
-
const baseName = sanitizeFileName(row.file_name);
|
|
2886
|
-
const fileName = extname(baseName) ? baseName : `${baseName}${extname(row.storage_path)}`;
|
|
2887
|
-
const localPath = join13(targetDir, `${row.id}-${fileName}`);
|
|
2888
|
-
try {
|
|
2889
|
-
const { data: blob, error: downloadError } = await supabase.storage.from(CARD_ATTACHMENTS_BUCKET).download(row.storage_path);
|
|
2890
|
-
if (downloadError) throw downloadError;
|
|
2891
|
-
await writeFile2(localPath, Buffer.from(await blob.arrayBuffer()));
|
|
2892
|
-
paths.push(localPath);
|
|
2893
|
-
} catch (err) {
|
|
2894
|
-
logger.card.error(cardId, `Failed to download attachment ${row.storage_path}:`, err);
|
|
2974
|
+
pty.onExit(({ exitCode }) => {
|
|
2975
|
+
const wasIntentional = intentionallyStoppedSessionIds.delete(sessionId);
|
|
2976
|
+
activityWatcher.stop();
|
|
2977
|
+
unregisterAgentActivityWatcher(sessionId);
|
|
2978
|
+
cancelUsageResume(sessionId);
|
|
2979
|
+
activeSessionByCard.delete(cardId);
|
|
2980
|
+
if (resumeConversationMissing) {
|
|
2981
|
+
logger.card.warn(
|
|
2982
|
+
cardId,
|
|
2983
|
+
`Session ${sessionId}: --resume ${claudeSessionId} found no such conversation \u2014 clearing it so the next attempt starts a fresh one instead of repeating the same failure.`
|
|
2984
|
+
);
|
|
2985
|
+
void updateSession(
|
|
2986
|
+
supabase,
|
|
2987
|
+
sessionId,
|
|
2988
|
+
{ status: "failed", endedAt: (/* @__PURE__ */ new Date()).toISOString(), claudeSessionId: null },
|
|
2989
|
+
{ projectId: card.project_id, cardId, userId: config.ownerUserId }
|
|
2990
|
+
);
|
|
2991
|
+
return;
|
|
2895
2992
|
}
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
return `${instructions}
|
|
2903
|
-
|
|
2904
|
-
Files attached to this card (use each one as part of the task):
|
|
2905
|
-
${list}`;
|
|
2906
|
-
}
|
|
2907
|
-
|
|
2908
|
-
// src/fileReferences.ts
|
|
2909
|
-
function buildInstructionsWithFileReferences(instructions, fileReferences) {
|
|
2910
|
-
if (fileReferences.length === 0) return instructions;
|
|
2911
|
-
const list = fileReferences.map((path) => `- ${path}`).join("\n");
|
|
2912
|
-
return `${instructions}
|
|
2913
|
-
|
|
2914
|
-
Files/folders referenced on this card (read them before starting \u2014 they are part of the task):
|
|
2915
|
-
${list}`;
|
|
2993
|
+
logger.card.warn(cardId, `Session exited (code ${exitCode}) \u2014 this only happens if the process crashed or was killed, the card doesn't auto-advance.`);
|
|
2994
|
+
if (!wasIntentional && exitCode !== 0) {
|
|
2995
|
+
publishAgentState(supabase, card.project_id, cardId, sessionId, "error");
|
|
2996
|
+
}
|
|
2997
|
+
});
|
|
2998
|
+
return true;
|
|
2916
2999
|
}
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
Agent Board MCP: this card's id is \`${cardId}\` and its project id is \`${projectId}\` \u2014 pass these directly to board tools (\`update_card\`, \`create_card\`, \`add_card_comment\`, etc.), no lookup needed. If this task turns out too large for one session, use \`create_card\` to split it into subtask cards (chain them with \`prerequisiteCardId\` when one depends on another) instead of trying to fit everything here. If part of the delivery needs a human to do something you can't do yourself (rotate a secret, approve something in a third-party dashboard, delete a resource manually, etc.), register each step with \`add_manual_actions\` instead of just mentioning it in your final message \u2014 that's the only place humans will reliably see it. Before finishing, post a short summary of what you did as a comment on this card via \`add_card_comment\` so the project's humans have visibility without opening this terminal.`;
|
|
3000
|
+
function publishAgentState(supabase, projectId, cardId, sessionId, state) {
|
|
3001
|
+
publishAgentStateEvent(projectId, cardId, sessionId, state);
|
|
3002
|
+
updateSession(supabase, sessionId, { agentState: state }).catch(
|
|
3003
|
+
(err) => logger.card.error(cardId, `Failed to persist agent state "${state}" for session ${sessionId}:`, err)
|
|
3004
|
+
);
|
|
2923
3005
|
}
|
|
2924
|
-
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
antes de publicar.
|
|
2938
|
-
2. Se os anexos/instru\xE7\xF5es deste card inclu\xEDrem um print de uma tela j\xE1 existente pedindo
|
|
2939
|
-
ajustes, o prot\xF3tipo \xE9 diferente: publique uma p\xE1gina INTERATIVA que mostra o print e deixa
|
|
2940
|
-
quem est\xE1 revisando clicar nele para soltar um pino numerado com um campo de texto por pino \u2014
|
|
2941
|
-
persista cada pino usando a capability \`db\` do pr\xF3prio Artifact (carregue a skill
|
|
2942
|
-
\`artifact-capabilities\` antes de declarar a capability). Nesse caso, a nota que voc\xEA passar
|
|
2943
|
-
para \`publish_prototype\` deve pedir para marcarem os pontos de ajuste nele, n\xE3o para
|
|
2944
|
-
aprovarem um resultado ainda.
|
|
2945
|
-
3. Chame a tool MCP \`publish_prototype\` com o link do Artifact e uma nota curta do que fez/pede.
|
|
2946
|
-
Essa chamada j\xE1 zera a aprova\xE7\xE3o e avisa os humanos do projeto \u2014 n\xE3o poste o link via
|
|
2947
|
-
add_card_comment manualmente tamb\xE9m, seria duplicado.
|
|
2948
|
-
4. Depois de chamar \`publish_prototype\`, PARE o turno \u2014 n\xE3o fique num loop verificando
|
|
2949
|
-
aprova\xE7\xE3o nem monitorando o artifact sozinho. A sess\xE3o ser\xE1 retomada automaticamente quando
|
|
2950
|
-
houver uma revis\xE3o nova (aprova\xE7\xE3o ou pedido de ajuste); tentar continuar por conta pr\xF3pria
|
|
2951
|
-
antes disso \xE9 desperd\xEDcio.
|
|
2952
|
-
5. Quando a sess\xE3o for retomada, a mensagem vai dizer se o prot\xF3tipo foi aprovado:
|
|
2953
|
-
- **Aprovado**: prossiga para a implementa\xE7\xE3o real agora, usando o prot\xF3tipo (e, se for o
|
|
2954
|
-
caso de ajuste em tela existente, os pontos marcados nele \u2014 leia-os de volta via leitura do
|
|
2955
|
-
banco do artifact) como refer\xEAncia. Se os pontos marcados forem grandes/numerosos o
|
|
2956
|
-
suficiente para valer separar, quebre em cards novos (\`create_card\` +
|
|
2957
|
-
\`prerequisiteCardId\`, encadeando os que dependem um do outro) e aplique o mesmo fluxo de
|
|
2958
|
-
prot\xF3tipo em cada um antes de implementar; resolva no pr\xF3prio card quando o ajuste for
|
|
2959
|
-
pequeno.
|
|
2960
|
-
- **N\xE3o aprovado**: confira os coment\xE1rios deixados no Artifact (e, se for a p\xE1gina de
|
|
2961
|
-
anota\xE7\xE3o, os pontos salvos), ajuste o prot\xF3tipo e publique a nova vers\xE3o de novo via
|
|
2962
|
-
\`publish_prototype\` na MESMA url \u2014 depois pare o turno de novo, voltando ao passo 4.`;
|
|
3006
|
+
function watchAgentActivity(supabase, config, projectId, cardId, sessionId, pty) {
|
|
3007
|
+
const watcher = new AgentActivityWatcher((state) => {
|
|
3008
|
+
publishAgentState(supabase, projectId, cardId, sessionId, state);
|
|
3009
|
+
if (state === "usage_limited") {
|
|
3010
|
+
scheduleUsageResume(supabase, config, cardId, sessionId, pty);
|
|
3011
|
+
} else {
|
|
3012
|
+
cancelUsageResume(sessionId);
|
|
3013
|
+
}
|
|
3014
|
+
if (state === "done" || state === "waiting_prototype_review") void dispatchRunnerQueue(supabase, config);
|
|
3015
|
+
});
|
|
3016
|
+
pty.onData((chunk) => watcher.push(chunk));
|
|
3017
|
+
registerAgentActivityWatcher(sessionId, watcher);
|
|
3018
|
+
return watcher;
|
|
2963
3019
|
}
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
function scheduleUsageResume(supabase, config, cardId, sessionId, pty) {
|
|
2970
|
-
if (pending.has(sessionId)) return;
|
|
2971
|
-
void (async () => {
|
|
2972
|
-
const usage = await getClaudeUsage(supabase, config.ownerUserId).catch((err) => {
|
|
2973
|
-
logger.card.error(cardId, "Failed to read claude_usage for usage-limit resume:", err);
|
|
2974
|
-
return null;
|
|
2975
|
-
});
|
|
2976
|
-
const now = Date.now();
|
|
2977
|
-
const candidates = [usage?.sessionResetsAt, usage?.weekResetsAt].map((iso) => iso ? new Date(iso).getTime() : null).filter((ms) => ms !== null && ms > now);
|
|
2978
|
-
const resetAt = candidates.length > 0 ? Math.min(...candidates) : null;
|
|
2979
|
-
const delay = resetAt !== null ? resetAt - now + RESET_BUFFER_MS : UNKNOWN_RESET_RETRY_MS;
|
|
2980
|
-
logger.card.info(
|
|
2981
|
-
cardId,
|
|
2982
|
-
resetAt !== null ? `Usage limit hit \u2014 will send "continue" automatically at ${new Date(resetAt).toISOString()}.` : `Usage limit hit but no reset time known yet \u2014 will retry in ${Math.round(delay / 6e4)}min.`
|
|
2983
|
-
);
|
|
2984
|
-
const timer = setTimeout(() => {
|
|
2985
|
-
pending.delete(sessionId);
|
|
2986
|
-
sendMessageToIdleSession(pty, cardId, "continue");
|
|
2987
|
-
}, delay);
|
|
2988
|
-
timer.unref();
|
|
2989
|
-
pending.set(sessionId, timer);
|
|
2990
|
-
})();
|
|
3020
|
+
async function pauseFinishedSession(supabase, config, projectId, cardId, sessionId) {
|
|
3021
|
+
const paused = await pauseRunningSession(supabase, sessionId, { projectId, cardId, userId: config.ownerUserId });
|
|
3022
|
+
if (!paused) return;
|
|
3023
|
+
setPtySessionReadOnly(sessionId, true);
|
|
3024
|
+
logger.card.info(cardId, `Session ${sessionId} paused (agent reached "done", another session needs this project's WIP slot).`);
|
|
2991
3025
|
}
|
|
2992
|
-
function
|
|
2993
|
-
const
|
|
2994
|
-
if (!
|
|
2995
|
-
|
|
2996
|
-
|
|
3026
|
+
async function requeuePausedCard(supabase, config, card) {
|
|
3027
|
+
const active = await getActiveSessionForCard(supabase, card.id);
|
|
3028
|
+
if (!active) return;
|
|
3029
|
+
if (active.status === "paused") {
|
|
3030
|
+
await updateSession(
|
|
3031
|
+
supabase,
|
|
3032
|
+
active.id,
|
|
3033
|
+
{ status: "queued" },
|
|
3034
|
+
{ projectId: card.project_id, cardId: card.id, userId: config.ownerUserId }
|
|
3035
|
+
);
|
|
3036
|
+
await updateCard(supabase, card.id, { requeueRequestedAt: null });
|
|
3037
|
+
logger.card.info(card.id, `Session ${active.id} requeued at the front of the line (was paused).`);
|
|
3038
|
+
await dispatchRunnerQueue(supabase, config);
|
|
3039
|
+
return;
|
|
3040
|
+
}
|
|
3041
|
+
if (active.status === "running" && active.agentState === "waiting_prototype_review") {
|
|
3042
|
+
const handle = getPtySession(active.id);
|
|
3043
|
+
await updateCard(supabase, card.id, { requeueRequestedAt: null });
|
|
3044
|
+
if (handle && !handle.exited) {
|
|
3045
|
+
sendMessageToIdleSession(handle.pty, card.id, buildPrototypeReviewFollowUp(card.prototype_approved, card.prototype_url));
|
|
3046
|
+
logger.card.info(card.id, `Session ${active.id} was never paused (alone in its project) \u2014 sent the prototype review follow-up directly.`);
|
|
3047
|
+
}
|
|
3048
|
+
}
|
|
2997
3049
|
}
|
|
2998
|
-
|
|
2999
|
-
// src/orchestrator.ts
|
|
3000
|
-
var TERMINAL_TICKET_TTL_MS = 60 * 60 * 1e3;
|
|
3001
|
-
var RESUME_CONVERSATION_MISSING_PATTERN = /no conversation found with session id/i;
|
|
3002
|
-
var inFlightCardIds = /* @__PURE__ */ new Set();
|
|
3003
|
-
var activeSessionByCard = /* @__PURE__ */ new Map();
|
|
3004
|
-
var intentionallyStoppedSessionIds = /* @__PURE__ */ new Set();
|
|
3005
|
-
var dispatchingRunner = false;
|
|
3006
|
-
async function scanForWork(supabase, config) {
|
|
3050
|
+
async function reconcileOrphanedSessions(supabase, config) {
|
|
3007
3051
|
for (const project of config.registeredProjects) {
|
|
3008
|
-
|
|
3009
|
-
|
|
3052
|
+
const { data, error } = await supabase.from("sessions").select(
|
|
3053
|
+
"id, card_id, claude_session_id, agent_state, created_at, status, cards!card_id!inner(project_id, title, instructions, file_references, plan_first)"
|
|
3054
|
+
).eq("cards.project_id", project.id).eq("runner_id", config.runnerId).in("status", ["running", "paused"]);
|
|
3010
3055
|
if (error) {
|
|
3011
|
-
logger.runner.error("ORCHESTRATOR", `Failed to scan
|
|
3056
|
+
logger.runner.error("ORCHESTRATOR", `Failed to scan orphaned sessions for project ${project.id}:`, error.message);
|
|
3012
3057
|
continue;
|
|
3013
3058
|
}
|
|
3014
|
-
for (const
|
|
3015
|
-
|
|
3059
|
+
for (const entry of data ?? []) {
|
|
3060
|
+
let spawned;
|
|
3061
|
+
try {
|
|
3062
|
+
spawned = await spawnSessionPty(supabase, config, entry);
|
|
3063
|
+
} catch (err) {
|
|
3064
|
+
logger.card.error(entry.card_id, `Failed to re-spawn orphaned session ${entry.id}:`, err);
|
|
3065
|
+
continue;
|
|
3066
|
+
}
|
|
3067
|
+
if (!spawned) continue;
|
|
3068
|
+
if (entry.status === "paused") {
|
|
3069
|
+
await updateSession(
|
|
3070
|
+
supabase,
|
|
3071
|
+
entry.id,
|
|
3072
|
+
{ status: "paused" },
|
|
3073
|
+
{ projectId: project.id, cardId: entry.card_id, userId: config.ownerUserId }
|
|
3074
|
+
);
|
|
3075
|
+
setPtySessionReadOnly(entry.id, true);
|
|
3076
|
+
logger.card.info(entry.card_id, "Re-spawned paused session (runner restarted) \u2014 still read-only until requeued.");
|
|
3077
|
+
} else {
|
|
3078
|
+
logger.card.info(entry.card_id, "Re-spawned orphaned session (runner restarted since it was last running).");
|
|
3079
|
+
}
|
|
3016
3080
|
}
|
|
3017
3081
|
}
|
|
3018
|
-
void dispatchRunnerQueue(supabase, config);
|
|
3019
3082
|
}
|
|
3020
|
-
async function
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
await createQueuedSession(supabase, config, card);
|
|
3033
|
-
} finally {
|
|
3034
|
-
inFlightCardIds.delete(card.id);
|
|
3083
|
+
async function reconcileUsageLimitedSessions(supabase, config) {
|
|
3084
|
+
for (const project of config.registeredProjects) {
|
|
3085
|
+
const { data, error } = await supabase.from("sessions").select("id, card_id, cards!card_id!inner(project_id)").eq("cards.project_id", project.id).eq("runner_id", config.runnerId).eq("status", "running").eq("agent_state", "usage_limited");
|
|
3086
|
+
if (error) {
|
|
3087
|
+
logger.runner.error("ORCHESTRATOR", `Failed to scan usage-limited sessions for project ${project.id}:`, error.message);
|
|
3088
|
+
continue;
|
|
3089
|
+
}
|
|
3090
|
+
for (const { id: sessionId, card_id: cardId } of data ?? []) {
|
|
3091
|
+
const handle = getPtySession(sessionId);
|
|
3092
|
+
if (!handle) continue;
|
|
3093
|
+
scheduleUsageResume(supabase, config, cardId, sessionId, handle.pty);
|
|
3094
|
+
}
|
|
3035
3095
|
}
|
|
3036
|
-
await dispatchRunnerQueue(supabase, config);
|
|
3037
3096
|
}
|
|
3038
|
-
async function
|
|
3039
|
-
const
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3097
|
+
async function stopCardSession(supabase, config, card) {
|
|
3098
|
+
const sessionId = activeSessionByCard.get(card.id) ?? (await getActiveSessionForCard(supabase, card.id))?.id;
|
|
3099
|
+
if (!sessionId) return;
|
|
3100
|
+
intentionallyStoppedSessionIds.add(sessionId);
|
|
3101
|
+
stopPtySession(sessionId);
|
|
3102
|
+
cancelUsageResume(sessionId);
|
|
3103
|
+
activeSessionByCard.delete(card.id);
|
|
3104
|
+
await updateSession(
|
|
3105
|
+
supabase,
|
|
3106
|
+
sessionId,
|
|
3107
|
+
{
|
|
3108
|
+
status: "done",
|
|
3109
|
+
endedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3110
|
+
...card.column_name === "fim" ? { agentState: "done" } : {}
|
|
3111
|
+
},
|
|
3112
|
+
{ projectId: card.project_id, cardId: card.id, userId: config.ownerUserId }
|
|
3044
3113
|
);
|
|
3114
|
+
logger.card.info(card.id, `Session ${sessionId} stopped.`);
|
|
3115
|
+
await dispatchRunnerQueue(supabase, config);
|
|
3045
3116
|
}
|
|
3046
|
-
async function
|
|
3047
|
-
const
|
|
3048
|
-
if (
|
|
3049
|
-
logger.runner.error("ORCHESTRATOR", `Failed to scan stale sessions for project ${projectId}:`, error.message);
|
|
3050
|
-
return;
|
|
3051
|
-
}
|
|
3052
|
-
const stale = data ?? [];
|
|
3053
|
-
if (stale.length === 0) return;
|
|
3054
|
-
for (const { id: sessionId, card_id: cardId, cards } of stale) {
|
|
3117
|
+
async function restartCardSession(supabase, config, card) {
|
|
3118
|
+
const sessionId = activeSessionByCard.get(card.id) ?? (await getActiveSessionForCard(supabase, card.id))?.id;
|
|
3119
|
+
if (sessionId) {
|
|
3055
3120
|
intentionallyStoppedSessionIds.add(sessionId);
|
|
3056
3121
|
stopPtySession(sessionId);
|
|
3057
3122
|
cancelUsageResume(sessionId);
|
|
3058
|
-
activeSessionByCard.delete(
|
|
3123
|
+
activeSessionByCard.delete(card.id);
|
|
3059
3124
|
await updateSession(
|
|
3060
3125
|
supabase,
|
|
3061
3126
|
sessionId,
|
|
3062
|
-
{
|
|
3063
|
-
|
|
3064
|
-
endedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3065
|
-
// See stopCardSession's doc comment: "fim" is the authoritative completion signal for
|
|
3066
|
-
// dispatchRunnerQueue's prerequisite gate, which only ever reads agent_state.
|
|
3067
|
-
...cards.column_name === "fim" ? { agentState: "done" } : {}
|
|
3068
|
-
},
|
|
3069
|
-
{ projectId, cardId, userId: config.ownerUserId }
|
|
3127
|
+
{ status: "failed", claudeSessionId: null, endedAt: (/* @__PURE__ */ new Date()).toISOString() },
|
|
3128
|
+
{ projectId: card.project_id, cardId: card.id, userId: config.ownerUserId }
|
|
3070
3129
|
);
|
|
3071
|
-
logger.card.info(
|
|
3130
|
+
logger.card.info(card.id, `Session ${sessionId} abandoned by "Reiniciar sess\xE3o"; starting a fresh one.`);
|
|
3072
3131
|
}
|
|
3073
|
-
await
|
|
3132
|
+
await updateCard(supabase, card.id, { restartRequestedAt: null });
|
|
3133
|
+
await enqueueCard(supabase, config, card);
|
|
3074
3134
|
}
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
let queued = [];
|
|
3079
|
-
const promotedCardIds = /* @__PURE__ */ new Set();
|
|
3135
|
+
|
|
3136
|
+
// src/vaultSecrets.ts
|
|
3137
|
+
async function resolveProjectSecretsEnv(supabase, projectId, repoId) {
|
|
3080
3138
|
try {
|
|
3081
|
-
|
|
3082
|
-
if (
|
|
3083
|
-
const
|
|
3084
|
-
const
|
|
3085
|
-
const
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
continue;
|
|
3092
|
-
}
|
|
3093
|
-
runningProjectIds.add(project.id);
|
|
3139
|
+
const { data, error } = await supabase.rpc("resolve_project_secrets", { p_project_id: projectId, p_repo_id: repoId });
|
|
3140
|
+
if (error) throw error;
|
|
3141
|
+
const rows = data ?? [];
|
|
3142
|
+
const env = {};
|
|
3143
|
+
for (const row of rows) env[row.name] = row.value;
|
|
3144
|
+
if (rows.length > 0) {
|
|
3145
|
+
logger.runner.info(
|
|
3146
|
+
"VAULT",
|
|
3147
|
+
`Resolved ${rows.length} secret(s) for project ${projectId}${repoId ? ` (repo ${repoId})` : ""}: ${rows.map((r) => r.name).join(", ")}.`
|
|
3148
|
+
);
|
|
3094
3149
|
}
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
3150
|
+
return env;
|
|
3151
|
+
} catch (err) {
|
|
3152
|
+
logger.runner.error("VAULT", `Failed to resolve secrets for project ${projectId}${repoId ? ` (repo ${repoId})` : ""}:`, err);
|
|
3153
|
+
return {};
|
|
3154
|
+
}
|
|
3155
|
+
}
|
|
3156
|
+
|
|
3157
|
+
// src/columnActions.ts
|
|
3158
|
+
var execAsync = promisify4(exec);
|
|
3159
|
+
var OUTPUT_TAIL_CHARS = 4e3;
|
|
3160
|
+
function tail(text) {
|
|
3161
|
+
return text.length > OUTPUT_TAIL_CHARS ? text.slice(text.length - OUTPUT_TAIL_CHARS) : text;
|
|
3162
|
+
}
|
|
3163
|
+
function resolveTargets(project, children, repoRoot, column, timing) {
|
|
3164
|
+
if (children.length > 0) {
|
|
3165
|
+
return children.flatMap(
|
|
3166
|
+
(child2) => (child2.column_actions[column] ?? []).filter((action) => action.timing === timing).map((action) => ({ label: child2.subpath, cwd: join12(repoRoot, child2.subpath), command: action.command, repoId: child2.id }))
|
|
3167
|
+
);
|
|
3168
|
+
}
|
|
3169
|
+
return (project.column_actions[column] ?? []).filter((action) => action.timing === timing).map((action) => ({ label: null, cwd: repoRoot, command: action.command, repoId: null }));
|
|
3170
|
+
}
|
|
3171
|
+
var EXEC_OPTIONS = { maxBuffer: 10 * 1024 * 1024 };
|
|
3172
|
+
async function runCommand(target, extraEnv) {
|
|
3173
|
+
try {
|
|
3174
|
+
const { stdout, stderr } = await execAsync(target.command, { ...EXEC_OPTIONS, cwd: target.cwd, env: { ...process.env, ...extraEnv } });
|
|
3175
|
+
return { ok: true, output: tail(`${stdout}${stderr}`) };
|
|
3176
|
+
} catch (err) {
|
|
3177
|
+
const { stdout, stderr } = err;
|
|
3178
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3179
|
+
return { ok: false, output: tail(`${stdout ?? ""}${stderr ?? ""}` || message) };
|
|
3180
|
+
}
|
|
3181
|
+
}
|
|
3182
|
+
var inFlightColumns = /* @__PURE__ */ new Set();
|
|
3183
|
+
async function handleColumnEntered(supabase, config, card) {
|
|
3184
|
+
const key = `${card.project_id}:${card.column_name}`;
|
|
3185
|
+
if (inFlightColumns.has(key)) return;
|
|
3186
|
+
inFlightColumns.add(key);
|
|
3187
|
+
try {
|
|
3188
|
+
const project = await getProject(supabase, card.project_id);
|
|
3189
|
+
const children = await getProjectRepos(supabase, project.id);
|
|
3190
|
+
const repoRoot = resolveRepoPath(config, project.repo_path);
|
|
3191
|
+
const targets = resolveTargets(project, children, repoRoot, card.column_name, "before");
|
|
3192
|
+
if (targets.length === 0) return;
|
|
3193
|
+
const allCards = await listCardsInColumn(supabase, project.id, card.column_name);
|
|
3194
|
+
const cards = (await Promise.all(allCards.map(async (candidate) => await isEffectiveOwner(supabase, candidate, config) ? candidate : null))).filter((candidate) => candidate !== null);
|
|
3195
|
+
if (cards.length === 0) return;
|
|
3196
|
+
await Promise.all(
|
|
3197
|
+
cards.map((candidate) => updateCard(supabase, candidate.id, { customActionStatus: "running", customActionOutput: null }))
|
|
3198
|
+
);
|
|
3199
|
+
let failure = null;
|
|
3200
|
+
for (const target of targets) {
|
|
3201
|
+
const secretsEnv = await resolveProjectSecretsEnv(supabase, project.id, target.repoId);
|
|
3202
|
+
const result = await runCommand(target, secretsEnv);
|
|
3203
|
+
if (!result.ok) {
|
|
3204
|
+
failure = { label: target.label, output: result.output };
|
|
3205
|
+
break;
|
|
3120
3206
|
}
|
|
3121
3207
|
}
|
|
3208
|
+
if (failure) {
|
|
3209
|
+
const message = failure.label ? `[${failure.label}] ${failure.output}` : failure.output;
|
|
3210
|
+
logger.runner.warn("COLUMN_ACTION", `Command failed for project ${project.id} in "${card.column_name}": ${message}`);
|
|
3211
|
+
await Promise.all(
|
|
3212
|
+
cards.map(
|
|
3213
|
+
(candidate) => updateCard(supabase, candidate.id, { customActionStatus: "failed", customActionOutput: message })
|
|
3214
|
+
)
|
|
3215
|
+
);
|
|
3216
|
+
} else {
|
|
3217
|
+
logger.runner.info("COLUMN_ACTION", `Command passed for project ${project.id} in "${card.column_name}".`);
|
|
3218
|
+
await Promise.all(
|
|
3219
|
+
cards.map(
|
|
3220
|
+
(candidate) => updateCard(supabase, candidate.id, { customActionStatus: "passed", customActionOutput: null })
|
|
3221
|
+
)
|
|
3222
|
+
);
|
|
3223
|
+
}
|
|
3122
3224
|
} finally {
|
|
3123
|
-
|
|
3124
|
-
(err) => logger.runner.error("ORCHESTRATOR", "Failed to publish queue snapshots:", err)
|
|
3125
|
-
);
|
|
3126
|
-
dispatchingRunner = false;
|
|
3225
|
+
inFlightColumns.delete(key);
|
|
3127
3226
|
}
|
|
3128
3227
|
}
|
|
3129
|
-
async function
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
list.push(cardId);
|
|
3142
|
-
pausedByProject.set(projectId, list);
|
|
3228
|
+
async function clearColumnActionStatus(supabase, card) {
|
|
3229
|
+
if (card.custom_action_status === null) return;
|
|
3230
|
+
await updateCard(supabase, card.id, { customActionStatus: null, customActionOutput: null });
|
|
3231
|
+
}
|
|
3232
|
+
async function runAfterColumnActions(supabase, project, children, repoRoot, column) {
|
|
3233
|
+
const targets = resolveTargets(project, children, repoRoot, column, "after");
|
|
3234
|
+
for (const target of targets) {
|
|
3235
|
+
const secretsEnv = await resolveProjectSecretsEnv(supabase, project.id, target.repoId);
|
|
3236
|
+
const result = await runCommand(target, secretsEnv);
|
|
3237
|
+
if (!result.ok) {
|
|
3238
|
+
return { ok: false, error: target.label ? `${target.label}: ${result.output}` : result.output };
|
|
3239
|
+
}
|
|
3143
3240
|
}
|
|
3144
|
-
|
|
3145
|
-
|
|
3146
|
-
|
|
3147
|
-
|
|
3148
|
-
|
|
3241
|
+
return { ok: true };
|
|
3242
|
+
}
|
|
3243
|
+
async function checkPendingColumnActions(supabase, config) {
|
|
3244
|
+
for (const registered of config.registeredProjects) {
|
|
3245
|
+
for (const column of BOARD_COLUMNS) {
|
|
3246
|
+
const cards = await listCardsInColumn(supabase, registered.id, column);
|
|
3247
|
+
let pending2;
|
|
3248
|
+
for (const card of cards) {
|
|
3249
|
+
if (card.custom_action_status !== null) continue;
|
|
3250
|
+
if (await isEffectiveOwner(supabase, card, config)) {
|
|
3251
|
+
pending2 = card;
|
|
3252
|
+
break;
|
|
3253
|
+
}
|
|
3254
|
+
}
|
|
3255
|
+
if (pending2) await handleColumnEntered(supabase, config, pending2);
|
|
3256
|
+
}
|
|
3149
3257
|
}
|
|
3150
3258
|
}
|
|
3151
|
-
function
|
|
3152
|
-
|
|
3259
|
+
async function handleDeployActionRerunRequested(supabase, config, project) {
|
|
3260
|
+
const allCards = await listCardsInColumn(supabase, project.id, "deploy");
|
|
3261
|
+
const cards = (await Promise.all(allCards.map(async (card) => await isEffectiveOwner(supabase, card, config) ? card : null))).filter((card) => card !== null);
|
|
3262
|
+
if (cards.length === 0) {
|
|
3263
|
+
logger.runner.info("COLUMN_ACTION", `Rerun requested for project ${project.id}'s "deploy" column but no card assigned to this runner's user is waiting there.`);
|
|
3264
|
+
} else {
|
|
3265
|
+
await handleColumnEntered(supabase, config, cards[0]);
|
|
3266
|
+
}
|
|
3267
|
+
await clearDeployActionRerunRequest(supabase, project.id);
|
|
3153
3268
|
}
|
|
3154
|
-
async function
|
|
3155
|
-
const
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
supabase,
|
|
3160
|
-
entry.id,
|
|
3161
|
-
{ status: "running" },
|
|
3162
|
-
{ projectId: entry.cards.project_id, cardId: entry.card_id, userId: config.ownerUserId }
|
|
3163
|
-
);
|
|
3164
|
-
activeSessionByCard.set(entry.card_id, entry.id);
|
|
3165
|
-
logger.card.info(entry.card_id, "Resumed paused session from the front of the queue.");
|
|
3166
|
-
if (entry.agent_state === "waiting_prototype_review" && !existingHandle.exited) {
|
|
3167
|
-
sendMessageToIdleSession(
|
|
3168
|
-
existingHandle.pty,
|
|
3169
|
-
entry.card_id,
|
|
3170
|
-
buildPrototypeReviewFollowUp(entry.cards.prototype_approved, entry.cards.prototype_url)
|
|
3171
|
-
);
|
|
3269
|
+
async function checkPendingDeployActionReruns(supabase, config) {
|
|
3270
|
+
for (const registered of config.registeredProjects) {
|
|
3271
|
+
const project = await getProject(supabase, registered.id);
|
|
3272
|
+
if (project.deploy_action_rerun_requested_at) {
|
|
3273
|
+
await handleDeployActionRerunRequested(supabase, config, project);
|
|
3172
3274
|
}
|
|
3173
|
-
return;
|
|
3174
3275
|
}
|
|
3175
|
-
const spawned = await spawnSessionPty(supabase, config, entry);
|
|
3176
|
-
if (spawned) logger.card.info(entry.card_id, "Promoted from queue.");
|
|
3177
3276
|
}
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3277
|
+
|
|
3278
|
+
// src/deploy.ts
|
|
3279
|
+
import { join as join14 } from "node:path";
|
|
3280
|
+
|
|
3281
|
+
// src/devServer.ts
|
|
3282
|
+
import { spawn as spawn5 } from "node:child_process";
|
|
3283
|
+
|
|
3284
|
+
// src/cloudflared.ts
|
|
3285
|
+
import { spawn as spawn4 } from "node:child_process";
|
|
3286
|
+
import { accessSync, constants } from "node:fs";
|
|
3287
|
+
import { homedir as homedir5 } from "node:os";
|
|
3288
|
+
import { delimiter, join as join13 } from "node:path";
|
|
3289
|
+
import { bin as bundledBinaryPath, install as downloadCloudflared } from "cloudflared";
|
|
3290
|
+
var TRYCLOUDFLARE_URL_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
|
|
3291
|
+
var STARTUP_TIMEOUT_MS = 2e4;
|
|
3292
|
+
var IS_WINDOWS2 = process.platform === "win32";
|
|
3293
|
+
var BINARY_NAME = IS_WINDOWS2 ? "cloudflared.exe" : "cloudflared";
|
|
3294
|
+
var CLOUDFLARED_INSTALL_DOCS_URL = "https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/";
|
|
3295
|
+
function wellKnownDirectories() {
|
|
3296
|
+
const home = homedir5();
|
|
3297
|
+
if (IS_WINDOWS2) {
|
|
3298
|
+
const candidates = [
|
|
3299
|
+
process.env.ProgramFiles && join13(process.env.ProgramFiles, "cloudflared"),
|
|
3300
|
+
process.env["ProgramFiles(x86)"] && join13(process.env["ProgramFiles(x86)"], "cloudflared"),
|
|
3301
|
+
process.env.LOCALAPPDATA && join13(process.env.LOCALAPPDATA, "Microsoft", "WinGet", "Links"),
|
|
3302
|
+
join13(home, "scoop", "shims"),
|
|
3303
|
+
process.env.ChocolateyInstall && join13(process.env.ChocolateyInstall, "bin")
|
|
3304
|
+
];
|
|
3305
|
+
return candidates.filter((dir) => Boolean(dir));
|
|
3306
|
+
}
|
|
3307
|
+
return ["/usr/local/bin", "/usr/bin", "/opt/homebrew/bin", join13(home, ".local", "bin")];
|
|
3308
|
+
}
|
|
3309
|
+
function pathDirectories() {
|
|
3310
|
+
return (process.env.PATH ?? "").split(delimiter).map((dir) => dir.trim().replace(/^"|"$/g, "")).filter(Boolean);
|
|
3311
|
+
}
|
|
3312
|
+
function isExecutableFile(path) {
|
|
3313
|
+
try {
|
|
3314
|
+
accessSync(path, constants.X_OK);
|
|
3315
|
+
return true;
|
|
3316
|
+
} catch {
|
|
3189
3317
|
return false;
|
|
3190
3318
|
}
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
3319
|
+
}
|
|
3320
|
+
function findInstalledBinary() {
|
|
3321
|
+
const override = process.env.AGENT_BOARD_CLOUDFLARED_PATH;
|
|
3322
|
+
const directories = [...pathDirectories(), ...wellKnownDirectories()];
|
|
3323
|
+
const candidates = [...override ? [override] : [], ...directories.map((dir) => join13(dir, BINARY_NAME))];
|
|
3324
|
+
return candidates.find(isExecutableFile) ?? null;
|
|
3325
|
+
}
|
|
3326
|
+
async function locateCloudflared() {
|
|
3327
|
+
const installed = findInstalledBinary();
|
|
3328
|
+
if (installed) return installed;
|
|
3329
|
+
if (isExecutableFile(bundledBinaryPath)) return bundledBinaryPath;
|
|
3195
3330
|
try {
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
pty.onData((chunk) => {
|
|
3199
|
-
if (RESUME_CONVERSATION_MISSING_PATTERN.test(normalizeTerminalOutput(chunk))) resumeConversationMissing = true;
|
|
3200
|
-
});
|
|
3201
|
-
} else {
|
|
3202
|
-
const attachmentPaths = await downloadCardAttachments(supabase, cardId);
|
|
3203
|
-
const instructions = buildInstructionsWithMcpContext(
|
|
3204
|
-
buildInstructionsWithPrototypeGate(
|
|
3205
|
-
buildInstructionsWithFileReferences(
|
|
3206
|
-
buildInstructionsWithAttachments(card.instructions, attachmentPaths),
|
|
3207
|
-
card.file_references
|
|
3208
|
-
),
|
|
3209
|
-
card.prototype_first
|
|
3210
|
-
),
|
|
3211
|
-
cardId,
|
|
3212
|
-
card.project_id
|
|
3213
|
-
);
|
|
3214
|
-
const spawned = spawnAgentPty(registered.repoPath, cardId, card.title, instructions, sessionId, ticket, card.plan_first);
|
|
3215
|
-
pty = spawned.pty;
|
|
3216
|
-
newClaudeSessionId = spawned.claudeSessionId;
|
|
3217
|
-
}
|
|
3331
|
+
logger.runner.info("CLOUDFLARE", "Downloading the cloudflared binary (first run only)...");
|
|
3332
|
+
return await downloadCloudflared(bundledBinaryPath);
|
|
3218
3333
|
} catch (err) {
|
|
3219
|
-
logger.
|
|
3220
|
-
|
|
3221
|
-
supabase,
|
|
3222
|
-
sessionId,
|
|
3223
|
-
{ status: "failed", endedAt: (/* @__PURE__ */ new Date()).toISOString() },
|
|
3224
|
-
{ projectId: card.project_id, cardId, userId: config.ownerUserId }
|
|
3225
|
-
);
|
|
3226
|
-
return false;
|
|
3334
|
+
logger.runner.error("CLOUDFLARE", `Failed to download cloudflared. Install it manually: ${CLOUDFLARED_INSTALL_DOCS_URL}`, err);
|
|
3335
|
+
return null;
|
|
3227
3336
|
}
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
|
|
3255
|
-
|
|
3256
|
-
logger.card.warn(
|
|
3257
|
-
cardId,
|
|
3258
|
-
`Session ${sessionId}: --resume ${claudeSessionId} found no such conversation \u2014 clearing it so the next attempt starts a fresh one instead of repeating the same failure.`
|
|
3259
|
-
);
|
|
3260
|
-
void updateSession(
|
|
3261
|
-
supabase,
|
|
3262
|
-
sessionId,
|
|
3263
|
-
{ status: "failed", endedAt: (/* @__PURE__ */ new Date()).toISOString(), claudeSessionId: null },
|
|
3264
|
-
{ projectId: card.project_id, cardId, userId: config.ownerUserId }
|
|
3265
|
-
);
|
|
3337
|
+
}
|
|
3338
|
+
var lookup = null;
|
|
3339
|
+
function resolveCloudflaredPath() {
|
|
3340
|
+
lookup ??= locateCloudflared().then((path) => {
|
|
3341
|
+
if (!path) lookup = null;
|
|
3342
|
+
return path;
|
|
3343
|
+
});
|
|
3344
|
+
return lookup;
|
|
3345
|
+
}
|
|
3346
|
+
async function startQuickTunnel(label, port) {
|
|
3347
|
+
const executable = await resolveCloudflaredPath();
|
|
3348
|
+
if (!executable) {
|
|
3349
|
+
logger.runner.error("CLOUDFLARE", `No cloudflared binary available, so ${label} cannot be exposed.`);
|
|
3350
|
+
return null;
|
|
3351
|
+
}
|
|
3352
|
+
return new Promise((resolve4) => {
|
|
3353
|
+
let child2;
|
|
3354
|
+
try {
|
|
3355
|
+
child2 = spawn4(executable, [
|
|
3356
|
+
"tunnel",
|
|
3357
|
+
"--url",
|
|
3358
|
+
`http://localhost:${port}`,
|
|
3359
|
+
"--http-host-header",
|
|
3360
|
+
`localhost:${port}`
|
|
3361
|
+
]);
|
|
3362
|
+
} catch (err) {
|
|
3363
|
+
logger.runner.error("CLOUDFLARE", `Failed to spawn cloudflared for ${label}:`, err);
|
|
3364
|
+
resolve4(null);
|
|
3266
3365
|
return;
|
|
3267
3366
|
}
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3367
|
+
let settled = false;
|
|
3368
|
+
const settle = (value) => {
|
|
3369
|
+
if (settled) return;
|
|
3370
|
+
settled = true;
|
|
3371
|
+
clearTimeout(timer);
|
|
3372
|
+
resolve4(value);
|
|
3373
|
+
};
|
|
3374
|
+
const timer = setTimeout(() => {
|
|
3375
|
+
logger.runner.warn("CLOUDFLARE", `cloudflared for ${label} did not print a trycloudflare.com URL within ${STARTUP_TIMEOUT_MS}ms.`);
|
|
3376
|
+
settle(null);
|
|
3377
|
+
}, STARTUP_TIMEOUT_MS);
|
|
3378
|
+
const onOutput = (chunk) => {
|
|
3379
|
+
if (settled) return;
|
|
3380
|
+
const match = chunk.toString("utf-8").match(TRYCLOUDFLARE_URL_RE);
|
|
3381
|
+
if (match) settle({ url: match[0], child: child2 });
|
|
3382
|
+
};
|
|
3383
|
+
child2.stdout?.on("data", onOutput);
|
|
3384
|
+
child2.stderr?.on("data", onOutput);
|
|
3385
|
+
child2.on("error", (err) => {
|
|
3386
|
+
logger.runner.error("CLOUDFLARE", `cloudflared process error for ${label} (${executable}):`, err);
|
|
3387
|
+
settle(null);
|
|
3388
|
+
});
|
|
3389
|
+
child2.on("exit", (code) => {
|
|
3390
|
+
logger.runner.info("CLOUDFLARE", `cloudflared tunnel for ${label} exited (code ${code}).`);
|
|
3391
|
+
settle(null);
|
|
3392
|
+
});
|
|
3272
3393
|
});
|
|
3273
|
-
return true;
|
|
3274
3394
|
}
|
|
3275
|
-
function
|
|
3276
|
-
|
|
3277
|
-
updateSession(supabase, sessionId, { agentState: state }).catch(
|
|
3278
|
-
(err) => logger.card.error(cardId, `Failed to persist agent state "${state}" for session ${sessionId}:`, err)
|
|
3279
|
-
);
|
|
3395
|
+
function toTunnelHostname(url) {
|
|
3396
|
+
return new URL(url).host;
|
|
3280
3397
|
}
|
|
3281
|
-
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
registerAgentActivityWatcher(sessionId, watcher);
|
|
3293
|
-
return watcher;
|
|
3398
|
+
|
|
3399
|
+
// src/devServer.ts
|
|
3400
|
+
var STARTUP_TIMEOUT_MS2 = 45e3;
|
|
3401
|
+
var IS_WINDOWS3 = process.platform === "win32";
|
|
3402
|
+
var LOCAL_URL_PORT_RE = /https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\]|0\.0\.0\.0):(\d{2,5})/i;
|
|
3403
|
+
var GENERIC_PORT_RE = /(?:listening|running|server).{0,40}?:?\s*(\d{4,5})\b/i;
|
|
3404
|
+
var ANSI_ESCAPE_RE2 = /\x1b\[[0-9;]*m/g;
|
|
3405
|
+
function detectPortFromOutput(chunk) {
|
|
3406
|
+
const clean = chunk.replace(ANSI_ESCAPE_RE2, "");
|
|
3407
|
+
const match = LOCAL_URL_PORT_RE.exec(clean) ?? GENERIC_PORT_RE.exec(clean);
|
|
3408
|
+
return match ? Number(match[1]) : null;
|
|
3294
3409
|
}
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
if (
|
|
3298
|
-
|
|
3299
|
-
|
|
3410
|
+
function killProcessTree(child2) {
|
|
3411
|
+
if (child2.pid == null) return;
|
|
3412
|
+
if (IS_WINDOWS3) {
|
|
3413
|
+
spawn5("taskkill", ["/pid", String(child2.pid), "/t", "/f"], { stdio: "ignore" });
|
|
3414
|
+
} else {
|
|
3415
|
+
child2.kill();
|
|
3416
|
+
}
|
|
3300
3417
|
}
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
if (
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
active.id,
|
|
3308
|
-
{ status: "queued" },
|
|
3309
|
-
{ projectId: card.project_id, cardId: card.id, userId: config.ownerUserId }
|
|
3310
|
-
);
|
|
3311
|
-
await updateCard(supabase, card.id, { requeueRequestedAt: null });
|
|
3312
|
-
logger.card.info(card.id, `Session ${active.id} requeued at the front of the line (was paused).`);
|
|
3313
|
-
await dispatchRunnerQueue(supabase, config);
|
|
3314
|
-
return;
|
|
3418
|
+
var previews = /* @__PURE__ */ new Map();
|
|
3419
|
+
function ensurePreview(key, label, cwd, command, cardId, extraEnv = {}) {
|
|
3420
|
+
const existing = previews.get(key);
|
|
3421
|
+
if (existing) {
|
|
3422
|
+
existing.cardIds.add(cardId);
|
|
3423
|
+
return existing.result;
|
|
3315
3424
|
}
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
}
|
|
3425
|
+
let child2;
|
|
3426
|
+
try {
|
|
3427
|
+
child2 = spawn5(command, { cwd, shell: true, windowsHide: true, env: { ...process.env, ...extraEnv } });
|
|
3428
|
+
} catch (err) {
|
|
3429
|
+
logger.runner.error("DEV_SERVER", `Failed to spawn "${command}" in ${cwd}:`, err);
|
|
3430
|
+
return Promise.resolve(null);
|
|
3323
3431
|
}
|
|
3324
|
-
}
|
|
3325
|
-
|
|
3326
|
-
|
|
3327
|
-
const
|
|
3328
|
-
|
|
3329
|
-
|
|
3330
|
-
|
|
3331
|
-
|
|
3332
|
-
|
|
3333
|
-
|
|
3334
|
-
for (const entry of data ?? []) {
|
|
3335
|
-
let spawned;
|
|
3336
|
-
try {
|
|
3337
|
-
spawned = await spawnSessionPty(supabase, config, entry);
|
|
3338
|
-
} catch (err) {
|
|
3339
|
-
logger.card.error(entry.card_id, `Failed to re-spawn orphaned session ${entry.id}:`, err);
|
|
3340
|
-
continue;
|
|
3432
|
+
const entry = { child: child2, tunnelChild: null, cardIds: /* @__PURE__ */ new Set([cardId]), result: null };
|
|
3433
|
+
entry.result = new Promise((resolve4) => {
|
|
3434
|
+
let settled = false;
|
|
3435
|
+
const settle = (value) => {
|
|
3436
|
+
if (settled) return;
|
|
3437
|
+
settled = true;
|
|
3438
|
+
clearTimeout(timer);
|
|
3439
|
+
if (!value) {
|
|
3440
|
+
killProcessTree(child2);
|
|
3441
|
+
previews.delete(key);
|
|
3341
3442
|
}
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
3352
|
-
|
|
3353
|
-
|
|
3443
|
+
resolve4(value);
|
|
3444
|
+
};
|
|
3445
|
+
const timer = setTimeout(() => {
|
|
3446
|
+
logger.runner.warn("DEV_SERVER", `"${command}" in ${cwd} did not reveal a port within ${STARTUP_TIMEOUT_MS2}ms.`);
|
|
3447
|
+
settle(null);
|
|
3448
|
+
}, STARTUP_TIMEOUT_MS2);
|
|
3449
|
+
const onOutput = async (chunk) => {
|
|
3450
|
+
if (settled) return;
|
|
3451
|
+
const port = detectPortFromOutput(chunk.toString("utf-8"));
|
|
3452
|
+
if (port == null) return;
|
|
3453
|
+
settled = true;
|
|
3454
|
+
clearTimeout(timer);
|
|
3455
|
+
const tunnel = await startQuickTunnel(`dev server ${key}`, port);
|
|
3456
|
+
if (!tunnel) {
|
|
3457
|
+
logger.runner.warn("DEV_SERVER", `Detected port ${port} for ${key} but cloudflared could not tunnel it.`);
|
|
3458
|
+
killProcessTree(child2);
|
|
3459
|
+
previews.delete(key);
|
|
3460
|
+
resolve4(null);
|
|
3461
|
+
return;
|
|
3354
3462
|
}
|
|
3355
|
-
|
|
3463
|
+
entry.tunnelChild = tunnel.child;
|
|
3464
|
+
logger.runner.info("DEV_SERVER", `${key} is up on port ${port}, tunneled at ${tunnel.url}.`);
|
|
3465
|
+
resolve4({ label, url: tunnel.url });
|
|
3466
|
+
};
|
|
3467
|
+
child2.stdout?.on("data", onOutput);
|
|
3468
|
+
child2.stderr?.on("data", onOutput);
|
|
3469
|
+
child2.on("error", (err) => {
|
|
3470
|
+
logger.runner.error("DEV_SERVER", `Dev server process error for ${key} (${command} in ${cwd}):`, err);
|
|
3471
|
+
settle(null);
|
|
3472
|
+
});
|
|
3473
|
+
child2.on("exit", (code) => {
|
|
3474
|
+
logger.runner.info("DEV_SERVER", `Dev server for ${key} exited (code ${code}) before/without a usable preview.`);
|
|
3475
|
+
settle(null);
|
|
3476
|
+
});
|
|
3477
|
+
});
|
|
3478
|
+
previews.set(key, entry);
|
|
3479
|
+
return entry.result;
|
|
3480
|
+
}
|
|
3481
|
+
function releaseCard(cardId) {
|
|
3482
|
+
for (const [key, entry] of previews) {
|
|
3483
|
+
if (!entry.cardIds.delete(cardId)) continue;
|
|
3484
|
+
if (entry.cardIds.size > 0) continue;
|
|
3485
|
+
killProcessTree(entry.child);
|
|
3486
|
+
entry.tunnelChild?.kill();
|
|
3487
|
+
previews.delete(key);
|
|
3356
3488
|
}
|
|
3357
3489
|
}
|
|
3358
|
-
|
|
3359
|
-
|
|
3360
|
-
|
|
3361
|
-
if (
|
|
3362
|
-
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
for (const { id: sessionId, card_id: cardId } of data ?? []) {
|
|
3366
|
-
const handle = getPtySession(sessionId);
|
|
3367
|
-
if (!handle) continue;
|
|
3368
|
-
scheduleUsageResume(supabase, config, cardId, sessionId, handle.pty);
|
|
3369
|
-
}
|
|
3490
|
+
function stopProjectPreviews(projectId) {
|
|
3491
|
+
const prefix = `${projectId}:`;
|
|
3492
|
+
for (const [key, entry] of previews) {
|
|
3493
|
+
if (!key.startsWith(prefix)) continue;
|
|
3494
|
+
killProcessTree(entry.child);
|
|
3495
|
+
entry.tunnelChild?.kill();
|
|
3496
|
+
previews.delete(key);
|
|
3370
3497
|
}
|
|
3371
3498
|
}
|
|
3372
|
-
|
|
3373
|
-
const
|
|
3374
|
-
|
|
3375
|
-
|
|
3376
|
-
|
|
3377
|
-
|
|
3378
|
-
|
|
3379
|
-
|
|
3380
|
-
supabase,
|
|
3381
|
-
sessionId,
|
|
3382
|
-
{
|
|
3383
|
-
status: "done",
|
|
3384
|
-
endedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3385
|
-
...card.column_name === "fim" ? { agentState: "done" } : {}
|
|
3386
|
-
},
|
|
3387
|
-
{ projectId: card.project_id, cardId: card.id, userId: config.ownerUserId }
|
|
3388
|
-
);
|
|
3389
|
-
logger.card.info(card.id, `Session ${sessionId} stopped.`);
|
|
3390
|
-
await dispatchRunnerQueue(supabase, config);
|
|
3499
|
+
function stopAll() {
|
|
3500
|
+
for (const key of [...previews.keys()]) {
|
|
3501
|
+
const entry = previews.get(key);
|
|
3502
|
+
if (!entry) continue;
|
|
3503
|
+
killProcessTree(entry.child);
|
|
3504
|
+
entry.tunnelChild?.kill();
|
|
3505
|
+
previews.delete(key);
|
|
3506
|
+
}
|
|
3391
3507
|
}
|
|
3392
|
-
|
|
3393
|
-
|
|
3394
|
-
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
await
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
logger.card.info(card.id, `Session ${sessionId} abandoned by "Reiniciar sess\xE3o"; starting a fresh one.`);
|
|
3508
|
+
|
|
3509
|
+
// src/git.ts
|
|
3510
|
+
import { execFile as execFile4 } from "node:child_process";
|
|
3511
|
+
import { promisify as promisify5 } from "node:util";
|
|
3512
|
+
var execFileAsync4 = promisify5(execFile4);
|
|
3513
|
+
async function pushBranchToMain(repoPath) {
|
|
3514
|
+
try {
|
|
3515
|
+
await execFileAsync4("git", ["push", "origin", "HEAD:main"], { cwd: repoPath });
|
|
3516
|
+
return { ok: true };
|
|
3517
|
+
} catch (err) {
|
|
3518
|
+
const stderr = err.stderr;
|
|
3519
|
+
const error = stderr?.trim() || (err instanceof Error ? err.message : String(err));
|
|
3520
|
+
return { ok: false, error };
|
|
3406
3521
|
}
|
|
3407
|
-
await updateCard(supabase, card.id, { restartRequestedAt: null });
|
|
3408
|
-
await enqueueCard(supabase, config, card);
|
|
3409
3522
|
}
|
|
3410
3523
|
|
|
3411
3524
|
// src/deploy.ts
|
|
@@ -3429,7 +3542,9 @@ async function handleDeployRequested(supabase, config, project) {
|
|
|
3429
3542
|
}
|
|
3430
3543
|
async function runDeploy(supabase, config, project) {
|
|
3431
3544
|
const allCards = await listCardsInColumn(supabase, project.id, "deploy");
|
|
3432
|
-
const cards = allCards.
|
|
3545
|
+
const cards = (await Promise.all(allCards.map(async (card) => await isEffectiveOwner(supabase, card, config) ? card : null))).filter(
|
|
3546
|
+
(card) => card !== null
|
|
3547
|
+
);
|
|
3433
3548
|
if (cards.length === 0) {
|
|
3434
3549
|
logger.runner.info("DEPLOY", `Deploy requested for project ${project.id} but no cards assigned to this runner's user are waiting in "deploy".`);
|
|
3435
3550
|
await clearProjectDeployRequest(supabase, project.id);
|
|
@@ -3509,10 +3624,24 @@ async function syncRegisteredProjects(supabase, config, registeredPaths) {
|
|
|
3509
3624
|
const localById = new Map(config.registeredProjects.map((project) => [project.id, project]));
|
|
3510
3625
|
const linkedRunnerIds = await listProjectRunnerIds(supabase, config.runnerId);
|
|
3511
3626
|
let changed = false;
|
|
3627
|
+
const otherReferenceRunnerIds = remoteProjects.filter((remote) => localById.has(remote.id) && remote.runner_id !== config.runnerId).map((remote) => remote.runner_id);
|
|
3628
|
+
const referenceHeartbeats = await getRunnerHeartbeats(supabase, otherReferenceRunnerIds);
|
|
3512
3629
|
for (const remote of remoteProjects) {
|
|
3513
3630
|
const local = localById.get(remote.id);
|
|
3514
3631
|
if (local) {
|
|
3515
|
-
|
|
3632
|
+
let isReferenceRunner = remote.runner_id === config.runnerId;
|
|
3633
|
+
if (!isReferenceRunner && isRunnerHeartbeatStale(referenceHeartbeats.get(remote.runner_id) ?? null)) {
|
|
3634
|
+
try {
|
|
3635
|
+
await claimReferenceRunner(supabase, remote.id, config.runnerId);
|
|
3636
|
+
isReferenceRunner = true;
|
|
3637
|
+
logger.runner.info(
|
|
3638
|
+
"PROJECT_SYNC",
|
|
3639
|
+
`Claimed reference runner for project ${remote.id} \u2014 previous one (${remote.runner_id}) looks offline.`
|
|
3640
|
+
);
|
|
3641
|
+
} catch (err) {
|
|
3642
|
+
logger.runner.error("PROJECT_SYNC", `Failed to claim reference runner for project ${remote.id}:`, err);
|
|
3643
|
+
}
|
|
3644
|
+
}
|
|
3516
3645
|
if (local.isReferenceRunner !== isReferenceRunner) {
|
|
3517
3646
|
local.isReferenceRunner = isReferenceRunner;
|
|
3518
3647
|
changed = true;
|
|
@@ -3606,7 +3735,7 @@ async function checkPendingPreviews(supabase, config) {
|
|
|
3606
3735
|
for (const registered of config.registeredProjects) {
|
|
3607
3736
|
const cards = await listCardsInColumn(supabase, registered.id, "revisao");
|
|
3608
3737
|
for (const card of cards) {
|
|
3609
|
-
if (card
|
|
3738
|
+
if (!await isEffectiveOwner(supabase, card, config)) continue;
|
|
3610
3739
|
if (card.preview_urls.length > 0) continue;
|
|
3611
3740
|
await handleCardEnteredRevisao(supabase, config, card);
|
|
3612
3741
|
}
|
|
@@ -3621,7 +3750,9 @@ async function handleDevServerRestartRequested(supabase, config, project) {
|
|
|
3621
3750
|
}
|
|
3622
3751
|
stopProjectPreviews(project.id);
|
|
3623
3752
|
const allCards = await listCardsInColumn(supabase, project.id, "revisao");
|
|
3624
|
-
const cards = allCards.
|
|
3753
|
+
const cards = (await Promise.all(allCards.map(async (card) => await isEffectiveOwner(supabase, card, config) ? card : null))).filter(
|
|
3754
|
+
(card) => card !== null
|
|
3755
|
+
);
|
|
3625
3756
|
if (cards.length === 0) {
|
|
3626
3757
|
logger.runner.info("REVIEW", `Dev server restart requested for project ${project.id} but no card assigned to this runner's user is in "revisao" \u2014 nothing to respawn.`);
|
|
3627
3758
|
await clearDevServerRestartRequest(supabase, project.id);
|
|
@@ -3754,7 +3885,7 @@ async function startRunner() {
|
|
|
3754
3885
|
logger.runner.error("BOOT", 'No runner set up yet. Run "agent-board-runner start --path <repos-root>" first.');
|
|
3755
3886
|
process.exit(1);
|
|
3756
3887
|
}
|
|
3757
|
-
const config = applyRunnerName(applyAllowedRoot(loadedConfig));
|
|
3888
|
+
const config = ensureRecoveryKeypair(applyRunnerName(applyAllowedRoot(loadedConfig)));
|
|
3758
3889
|
if (!config.allowedRootPath) {
|
|
3759
3890
|
logger.runner.error(
|
|
3760
3891
|
"BOOT",
|
|
@@ -3762,10 +3893,9 @@ async function startRunner() {
|
|
|
3762
3893
|
);
|
|
3763
3894
|
process.exit(1);
|
|
3764
3895
|
}
|
|
3765
|
-
|
|
3766
|
-
let session;
|
|
3896
|
+
let supabase, session;
|
|
3767
3897
|
try {
|
|
3768
|
-
session = await
|
|
3898
|
+
({ client: supabase, session } = await ensureSession(config));
|
|
3769
3899
|
} catch (error) {
|
|
3770
3900
|
logger.runner.error("BOOT", error instanceof Error ? error.message : String(error));
|
|
3771
3901
|
process.exit(1);
|
|
@@ -3777,7 +3907,7 @@ async function startRunner() {
|
|
|
3777
3907
|
if (event === "SIGNED_OUT") {
|
|
3778
3908
|
logger.runner.error(
|
|
3779
3909
|
"AUTH",
|
|
3780
|
-
|
|
3910
|
+
"Supabase session was lost (refresh failed or was revoked) \u2014 exiting so the process manager restarts this runner; the next boot recovers automatically."
|
|
3781
3911
|
);
|
|
3782
3912
|
process.exit(1);
|
|
3783
3913
|
}
|
|
@@ -3802,6 +3932,10 @@ async function startRunner() {
|
|
|
3802
3932
|
user_id: config.ownerUserId,
|
|
3803
3933
|
name: config.runnerName,
|
|
3804
3934
|
auth_token_hash: config.authToken,
|
|
3935
|
+
// Re-sent on every boot with a real session — idempotent (same local key every time) and
|
|
3936
|
+
// what makes automatic recovery possible the next time this runner loses its session, see
|
|
3937
|
+
// ensureSession()/recoverSession() in auth.ts.
|
|
3938
|
+
recovery_public_key: config.recoveryPublicKey,
|
|
3805
3939
|
version: RUNNER_VERSION
|
|
3806
3940
|
});
|
|
3807
3941
|
if (runnerError) {
|