agent-board-runner 0.1.11 → 0.1.13
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 +998 -976
- package/dist/index.js +998 -976
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -772,32 +772,33 @@ async function logActivity(supabase, params) {
|
|
|
772
772
|
}
|
|
773
773
|
|
|
774
774
|
// src/sessions.ts
|
|
775
|
-
async function getRunningSessionForProject(supabase, projectId,
|
|
776
|
-
const { data, error } = await supabase.from("sessions").select("id, card_id, agent_state, cards!card_id!inner(project_id
|
|
775
|
+
async function getRunningSessionForProject(supabase, projectId, runnerId) {
|
|
776
|
+
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();
|
|
777
777
|
if (error) throw error;
|
|
778
778
|
if (!data) return null;
|
|
779
779
|
const row = data;
|
|
780
780
|
return { id: row.id, cardId: row.card_id, agentState: row.agent_state };
|
|
781
781
|
}
|
|
782
|
-
async function listQueuedSessionsForRunner(supabase,
|
|
782
|
+
async function listQueuedSessionsForRunner(supabase, runnerId) {
|
|
783
783
|
const { data, error } = await supabase.from("sessions").select(
|
|
784
|
-
"id, card_id, claude_session_id, agent_state, created_at, cards!card_id!inner(project_id,
|
|
785
|
-
).eq("
|
|
784
|
+
"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)"
|
|
785
|
+
).eq("runner_id", runnerId).eq("status", "queued").order("created_at", { ascending: true });
|
|
786
786
|
if (error) throw error;
|
|
787
787
|
return data;
|
|
788
788
|
}
|
|
789
|
-
async function listPausedSessionsForRunner(supabase,
|
|
790
|
-
const { data, error } = await supabase.from("sessions").select("card_id, cards!card_id!inner(project_id
|
|
789
|
+
async function listPausedSessionsForRunner(supabase, runnerId) {
|
|
790
|
+
const { data, error } = await supabase.from("sessions").select("card_id, cards!card_id!inner(project_id)").eq("runner_id", runnerId).eq("status", "paused");
|
|
791
791
|
if (error) throw error;
|
|
792
792
|
return data.map((row) => ({
|
|
793
793
|
card_id: row.card_id,
|
|
794
794
|
project_id: row.cards.project_id
|
|
795
795
|
}));
|
|
796
796
|
}
|
|
797
|
-
async function createSession(supabase, userId, cardId, resumeClaudeSessionId) {
|
|
797
|
+
async function createSession(supabase, userId, cardId, runnerId, resumeClaudeSessionId) {
|
|
798
798
|
const { data, error } = await supabase.from("sessions").insert({
|
|
799
799
|
card_id: cardId,
|
|
800
800
|
user_id: userId,
|
|
801
|
+
runner_id: runnerId,
|
|
801
802
|
status: "queued",
|
|
802
803
|
// Pre-set so spawnSessionPty takes the resume path (see its doc comment) instead of
|
|
803
804
|
// starting a blank conversation and resending the card's instructions.
|
|
@@ -806,6 +807,11 @@ async function createSession(supabase, userId, cardId, resumeClaudeSessionId) {
|
|
|
806
807
|
if (error) throw error;
|
|
807
808
|
return data.id;
|
|
808
809
|
}
|
|
810
|
+
async function getDefaultRunnerIdForUser(supabase, ownerUserId) {
|
|
811
|
+
const { data, error } = await supabase.from("runners").select("id").eq("user_id", ownerUserId).eq("is_default", true).maybeSingle();
|
|
812
|
+
if (error) throw error;
|
|
813
|
+
return data?.id ?? null;
|
|
814
|
+
}
|
|
809
815
|
async function updateSession(supabase, sessionId, changes, activityContext) {
|
|
810
816
|
const { error } = await supabase.from("sessions").update({
|
|
811
817
|
...changes.status !== void 0 ? { status: changes.status } : {},
|
|
@@ -887,7 +893,7 @@ async function getProjectRepos(supabase, projectId) {
|
|
|
887
893
|
}
|
|
888
894
|
async function listCardsInColumn(supabase, projectId, column) {
|
|
889
895
|
const { data, error } = await supabase.from("cards").select(
|
|
890
|
-
"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"
|
|
896
|
+
"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"
|
|
891
897
|
).eq("project_id", projectId).eq("column_name", column);
|
|
892
898
|
if (error) throw error;
|
|
893
899
|
return data;
|
|
@@ -2387,1086 +2393,1098 @@ async function ensureBranchWatchers(supabase, config) {
|
|
|
2387
2393
|
|
|
2388
2394
|
// src/columnActions.ts
|
|
2389
2395
|
import { exec } from "node:child_process";
|
|
2390
|
-
import { join as
|
|
2396
|
+
import { join as join12 } from "node:path";
|
|
2391
2397
|
import { promisify as promisify4 } from "node:util";
|
|
2392
2398
|
|
|
2393
|
-
// src/
|
|
2394
|
-
|
|
2395
|
-
try {
|
|
2396
|
-
const { data, error } = await supabase.rpc("resolve_project_secrets", { p_project_id: projectId, p_repo_id: repoId });
|
|
2397
|
-
if (error) throw error;
|
|
2398
|
-
const rows = data ?? [];
|
|
2399
|
-
const env = {};
|
|
2400
|
-
for (const row of rows) env[row.name] = row.value;
|
|
2401
|
-
if (rows.length > 0) {
|
|
2402
|
-
logger.runner.info(
|
|
2403
|
-
"VAULT",
|
|
2404
|
-
`Resolved ${rows.length} secret(s) for project ${projectId}${repoId ? ` (repo ${repoId})` : ""}: ${rows.map((r) => r.name).join(", ")}.`
|
|
2405
|
-
);
|
|
2406
|
-
}
|
|
2407
|
-
return env;
|
|
2408
|
-
} catch (err) {
|
|
2409
|
-
logger.runner.error("VAULT", `Failed to resolve secrets for project ${projectId}${repoId ? ` (repo ${repoId})` : ""}:`, err);
|
|
2410
|
-
return {};
|
|
2411
|
-
}
|
|
2412
|
-
}
|
|
2399
|
+
// src/orchestrator.ts
|
|
2400
|
+
import { randomUUID as randomUUID7 } from "node:crypto";
|
|
2413
2401
|
|
|
2414
|
-
// src/
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2402
|
+
// src/agentSession.ts
|
|
2403
|
+
import { randomUUID as randomUUID6 } from "node:crypto";
|
|
2404
|
+
import { spawn as spawn3 } from "node-pty";
|
|
2405
|
+
|
|
2406
|
+
// src/sessionName.ts
|
|
2407
|
+
var MAX_LENGTH = 60;
|
|
2408
|
+
var FALLBACK_NAME = "card";
|
|
2409
|
+
function toSessionName(title) {
|
|
2410
|
+
const slug = title.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
2411
|
+
if (slug.length === 0) return FALLBACK_NAME;
|
|
2412
|
+
if (slug.length <= MAX_LENGTH) return slug;
|
|
2413
|
+
const truncated = slug.slice(0, MAX_LENGTH);
|
|
2414
|
+
const lastHyphen = truncated.lastIndexOf("-");
|
|
2415
|
+
return (lastHyphen > 0 ? truncated.slice(0, lastHyphen) : truncated).replace(/-+$/, "");
|
|
2419
2416
|
}
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2417
|
+
|
|
2418
|
+
// src/agentSession.ts
|
|
2419
|
+
var TUI_IDLE_MS = 1500;
|
|
2420
|
+
var TUI_READY_TIMEOUT_MS = 6e4;
|
|
2421
|
+
var SUBMIT_DELAY_MS = 150;
|
|
2422
|
+
var PASTE_START = "\x1B[200~";
|
|
2423
|
+
var PASTE_END = "\x1B[201~";
|
|
2424
|
+
var SHIFT_TAB = "\x1B[Z";
|
|
2425
|
+
var SHIFT_TAB_PRESSES_TO_PLAN_MODE = 4;
|
|
2426
|
+
var SHIFT_TAB_DELAY_MS = 400;
|
|
2427
|
+
var PLAN_MODE_INDICATOR = /plan mode on/i;
|
|
2428
|
+
function spawnAgentPty(repoPath, cardId, cardTitle, instructions, sessionId, ticket, planFirst) {
|
|
2429
|
+
ensureDirTrusted(repoPath);
|
|
2430
|
+
ensureMcpServersTrusted(repoPath);
|
|
2431
|
+
const claudeSessionId = randomUUID6();
|
|
2432
|
+
const pty = spawn3(
|
|
2433
|
+
CLAUDE_BINARY,
|
|
2434
|
+
[
|
|
2435
|
+
"--dangerously-skip-permissions",
|
|
2436
|
+
"--model",
|
|
2437
|
+
"claude-sonnet-5",
|
|
2438
|
+
"--session-id",
|
|
2439
|
+
claudeSessionId,
|
|
2440
|
+
...claudeCliSettingsArgs(),
|
|
2441
|
+
`/rename ${toSessionName(cardTitle)}`
|
|
2442
|
+
],
|
|
2443
|
+
{
|
|
2444
|
+
name: "xterm-color",
|
|
2445
|
+
cols: 120,
|
|
2446
|
+
rows: 32,
|
|
2447
|
+
cwd: repoPath,
|
|
2448
|
+
env: statuslineEnv(sessionId, ticket)
|
|
2449
|
+
}
|
|
2450
|
+
);
|
|
2451
|
+
sendMessageWhenIdle(pty, cardId, instructions, planFirst);
|
|
2452
|
+
return { pty, claudeSessionId };
|
|
2427
2453
|
}
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2454
|
+
function sendMessageWhenIdle(pty, cardId, message, planFirst) {
|
|
2455
|
+
let idleTimer;
|
|
2456
|
+
let deadlineTimer;
|
|
2457
|
+
let dataSubscription;
|
|
2458
|
+
let settled = false;
|
|
2459
|
+
const stopWaiting = () => {
|
|
2460
|
+
if (settled) return false;
|
|
2461
|
+
settled = true;
|
|
2462
|
+
clearTimeout(idleTimer);
|
|
2463
|
+
clearTimeout(deadlineTimer);
|
|
2464
|
+
dataSubscription?.dispose();
|
|
2465
|
+
return true;
|
|
2466
|
+
};
|
|
2467
|
+
const send = async () => {
|
|
2468
|
+
if (!stopWaiting()) return;
|
|
2469
|
+
if (planFirst) await enterPlanMode(pty, cardId);
|
|
2470
|
+
try {
|
|
2471
|
+
pty.write(`${PASTE_START}${message}${PASTE_END}`);
|
|
2472
|
+
setTimeout(() => {
|
|
2473
|
+
try {
|
|
2474
|
+
pty.write("\r");
|
|
2475
|
+
} catch (err) {
|
|
2476
|
+
logger.card.error(cardId, "Failed to submit the card instructions to the agent:", err);
|
|
2477
|
+
}
|
|
2478
|
+
}, SUBMIT_DELAY_MS);
|
|
2479
|
+
} catch (err) {
|
|
2480
|
+
logger.card.error(cardId, "Failed to send the card instructions to the agent:", err);
|
|
2481
|
+
}
|
|
2482
|
+
};
|
|
2483
|
+
deadlineTimer = setTimeout(send, TUI_READY_TIMEOUT_MS);
|
|
2484
|
+
dataSubscription = pty.onData(() => {
|
|
2485
|
+
clearTimeout(idleTimer);
|
|
2486
|
+
idleTimer = setTimeout(send, TUI_IDLE_MS);
|
|
2487
|
+
});
|
|
2488
|
+
pty.onExit(() => stopWaiting());
|
|
2438
2489
|
}
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
const
|
|
2442
|
-
|
|
2443
|
-
|
|
2490
|
+
async function enterPlanMode(pty, cardId) {
|
|
2491
|
+
let recentOutput = "";
|
|
2492
|
+
const subscription = pty.onData((chunk) => {
|
|
2493
|
+
recentOutput += normalizeTerminalOutput(chunk);
|
|
2494
|
+
});
|
|
2444
2495
|
try {
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
const targets = resolveTargets(project, children, repoRoot, card.column_name, "before");
|
|
2449
|
-
if (targets.length === 0) return;
|
|
2450
|
-
const allCards = await listCardsInColumn(supabase, project.id, card.column_name);
|
|
2451
|
-
const cards = allCards.filter((candidate) => candidate.assigned_user_id === config.ownerUserId);
|
|
2452
|
-
if (cards.length === 0) return;
|
|
2453
|
-
await Promise.all(
|
|
2454
|
-
cards.map((candidate) => updateCard(supabase, candidate.id, { customActionStatus: "running", customActionOutput: null }))
|
|
2455
|
-
);
|
|
2456
|
-
let failure = null;
|
|
2457
|
-
for (const target of targets) {
|
|
2458
|
-
const secretsEnv = await resolveProjectSecretsEnv(supabase, project.id, target.repoId);
|
|
2459
|
-
const result = await runCommand(target, secretsEnv);
|
|
2460
|
-
if (!result.ok) {
|
|
2461
|
-
failure = { label: target.label, output: result.output };
|
|
2462
|
-
break;
|
|
2463
|
-
}
|
|
2496
|
+
for (let attempt = 1; attempt <= SHIFT_TAB_PRESSES_TO_PLAN_MODE; attempt++) {
|
|
2497
|
+
pty.write(SHIFT_TAB);
|
|
2498
|
+
await new Promise((resolve5) => setTimeout(resolve5, SHIFT_TAB_DELAY_MS));
|
|
2464
2499
|
}
|
|
2465
|
-
if (
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
cards.map(
|
|
2470
|
-
(candidate) => updateCard(supabase, candidate.id, { customActionStatus: "failed", customActionOutput: message })
|
|
2471
|
-
)
|
|
2472
|
-
);
|
|
2473
|
-
} else {
|
|
2474
|
-
logger.runner.info("COLUMN_ACTION", `Command passed for project ${project.id} in "${card.column_name}".`);
|
|
2475
|
-
await Promise.all(
|
|
2476
|
-
cards.map(
|
|
2477
|
-
(candidate) => updateCard(supabase, candidate.id, { customActionStatus: "passed", customActionOutput: null })
|
|
2478
|
-
)
|
|
2500
|
+
if (!PLAN_MODE_INDICATOR.test(recentOutput)) {
|
|
2501
|
+
logger.card.warn(
|
|
2502
|
+
cardId,
|
|
2503
|
+
`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.`
|
|
2479
2504
|
);
|
|
2480
2505
|
}
|
|
2506
|
+
} catch (err) {
|
|
2507
|
+
logger.card.error(cardId, "Failed to switch the session into Plan Mode:", err);
|
|
2481
2508
|
} finally {
|
|
2482
|
-
|
|
2483
|
-
}
|
|
2484
|
-
}
|
|
2485
|
-
async function clearColumnActionStatus(supabase, card) {
|
|
2486
|
-
if (card.custom_action_status === null) return;
|
|
2487
|
-
await updateCard(supabase, card.id, { customActionStatus: null, customActionOutput: null });
|
|
2488
|
-
}
|
|
2489
|
-
async function runAfterColumnActions(supabase, project, children, repoRoot, column) {
|
|
2490
|
-
const targets = resolveTargets(project, children, repoRoot, column, "after");
|
|
2491
|
-
for (const target of targets) {
|
|
2492
|
-
const secretsEnv = await resolveProjectSecretsEnv(supabase, project.id, target.repoId);
|
|
2493
|
-
const result = await runCommand(target, secretsEnv);
|
|
2494
|
-
if (!result.ok) {
|
|
2495
|
-
return { ok: false, error: target.label ? `${target.label}: ${result.output}` : result.output };
|
|
2496
|
-
}
|
|
2497
|
-
}
|
|
2498
|
-
return { ok: true };
|
|
2499
|
-
}
|
|
2500
|
-
async function checkPendingColumnActions(supabase, config) {
|
|
2501
|
-
for (const registered of config.registeredProjects) {
|
|
2502
|
-
for (const column of BOARD_COLUMNS) {
|
|
2503
|
-
const cards = await listCardsInColumn(supabase, registered.id, column);
|
|
2504
|
-
const pending2 = cards.find(
|
|
2505
|
-
(card) => card.assigned_user_id === config.ownerUserId && card.custom_action_status === null
|
|
2506
|
-
);
|
|
2507
|
-
if (pending2) await handleColumnEntered(supabase, config, pending2);
|
|
2508
|
-
}
|
|
2509
|
+
subscription.dispose();
|
|
2509
2510
|
}
|
|
2510
2511
|
}
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2512
|
+
function sendMessageToIdleSession(pty, cardId, message) {
|
|
2513
|
+
try {
|
|
2514
|
+
pty.write(`${PASTE_START}${message}${PASTE_END}`);
|
|
2515
|
+
setTimeout(() => {
|
|
2516
|
+
try {
|
|
2517
|
+
pty.write("\r");
|
|
2518
|
+
} catch (err) {
|
|
2519
|
+
logger.card.error(cardId, "Failed to submit a message to the idle session:", err);
|
|
2520
|
+
}
|
|
2521
|
+
}, SUBMIT_DELAY_MS);
|
|
2522
|
+
} catch (err) {
|
|
2523
|
+
logger.card.error(cardId, "Failed to send a message to the idle session:", err);
|
|
2518
2524
|
}
|
|
2519
|
-
await clearDeployActionRerunRequest(supabase, project.id);
|
|
2520
2525
|
}
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
+
function resumeAgentPty(repoPath, claudeSessionId, sessionId, ticket) {
|
|
2527
|
+
ensureDirTrusted(repoPath);
|
|
2528
|
+
ensureMcpServersTrusted(repoPath);
|
|
2529
|
+
return spawn3(
|
|
2530
|
+
CLAUDE_BINARY,
|
|
2531
|
+
[
|
|
2532
|
+
"--dangerously-skip-permissions",
|
|
2533
|
+
"--model",
|
|
2534
|
+
"claude-sonnet-5",
|
|
2535
|
+
"--resume",
|
|
2536
|
+
claudeSessionId,
|
|
2537
|
+
...claudeCliSettingsArgs()
|
|
2538
|
+
],
|
|
2539
|
+
{
|
|
2540
|
+
name: "xterm-color",
|
|
2541
|
+
cols: 120,
|
|
2542
|
+
rows: 32,
|
|
2543
|
+
cwd: repoPath,
|
|
2544
|
+
env: statuslineEnv(sessionId, ticket)
|
|
2526
2545
|
}
|
|
2527
|
-
|
|
2546
|
+
);
|
|
2528
2547
|
}
|
|
2529
2548
|
|
|
2530
|
-
// src/
|
|
2531
|
-
import {
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
import { spawn as spawn3 } from "node:child_process";
|
|
2538
|
-
import { accessSync, constants } from "node:fs";
|
|
2539
|
-
import { homedir as homedir5 } from "node:os";
|
|
2540
|
-
import { delimiter, join as join12 } from "node:path";
|
|
2541
|
-
import { bin as bundledBinaryPath, install as downloadCloudflared } from "cloudflared";
|
|
2542
|
-
var TRYCLOUDFLARE_URL_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
|
|
2543
|
-
var STARTUP_TIMEOUT_MS = 2e4;
|
|
2544
|
-
var IS_WINDOWS2 = process.platform === "win32";
|
|
2545
|
-
var BINARY_NAME = IS_WINDOWS2 ? "cloudflared.exe" : "cloudflared";
|
|
2546
|
-
var CLOUDFLARED_INSTALL_DOCS_URL = "https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/";
|
|
2547
|
-
function wellKnownDirectories() {
|
|
2548
|
-
const home = homedir5();
|
|
2549
|
-
if (IS_WINDOWS2) {
|
|
2550
|
-
const candidates = [
|
|
2551
|
-
process.env.ProgramFiles && join12(process.env.ProgramFiles, "cloudflared"),
|
|
2552
|
-
process.env["ProgramFiles(x86)"] && join12(process.env["ProgramFiles(x86)"], "cloudflared"),
|
|
2553
|
-
process.env.LOCALAPPDATA && join12(process.env.LOCALAPPDATA, "Microsoft", "WinGet", "Links"),
|
|
2554
|
-
join12(home, "scoop", "shims"),
|
|
2555
|
-
process.env.ChocolateyInstall && join12(process.env.ChocolateyInstall, "bin")
|
|
2556
|
-
];
|
|
2557
|
-
return candidates.filter((dir) => Boolean(dir));
|
|
2558
|
-
}
|
|
2559
|
-
return ["/usr/local/bin", "/usr/bin", "/opt/homebrew/bin", join12(home, ".local", "bin")];
|
|
2560
|
-
}
|
|
2561
|
-
function pathDirectories() {
|
|
2562
|
-
return (process.env.PATH ?? "").split(delimiter).map((dir) => dir.trim().replace(/^"|"$/g, "")).filter(Boolean);
|
|
2549
|
+
// src/cardAttachments.ts
|
|
2550
|
+
import { mkdir as mkdir2, writeFile as writeFile2 } from "node:fs/promises";
|
|
2551
|
+
import { tmpdir as tmpdir6 } from "node:os";
|
|
2552
|
+
import { extname, join as join11 } from "node:path";
|
|
2553
|
+
var ATTACHMENTS_ROOT = join11(tmpdir6(), "agent-board", "card-attachments");
|
|
2554
|
+
function sanitizeFileName(fileName) {
|
|
2555
|
+
return fileName.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
2563
2556
|
}
|
|
2564
|
-
function
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2557
|
+
async function downloadCardAttachments(supabase, cardId) {
|
|
2558
|
+
const { data, error } = await supabase.from("card_attachments").select("id, storage_path, file_name").eq("card_id", cardId).order("created_at", { ascending: true });
|
|
2559
|
+
if (error) {
|
|
2560
|
+
logger.card.error(cardId, "Failed to list attachments:", error.message);
|
|
2561
|
+
return [];
|
|
2562
|
+
}
|
|
2563
|
+
const rows = data ?? [];
|
|
2564
|
+
if (rows.length === 0) return [];
|
|
2565
|
+
const targetDir = join11(ATTACHMENTS_ROOT, cardId);
|
|
2566
|
+
await mkdir2(targetDir, { recursive: true });
|
|
2567
|
+
const paths = [];
|
|
2568
|
+
for (const row of rows) {
|
|
2569
|
+
const baseName = sanitizeFileName(row.file_name);
|
|
2570
|
+
const fileName = extname(baseName) ? baseName : `${baseName}${extname(row.storage_path)}`;
|
|
2571
|
+
const localPath = join11(targetDir, `${row.id}-${fileName}`);
|
|
2572
|
+
try {
|
|
2573
|
+
const { data: blob, error: downloadError } = await supabase.storage.from(CARD_ATTACHMENTS_BUCKET).download(row.storage_path);
|
|
2574
|
+
if (downloadError) throw downloadError;
|
|
2575
|
+
await writeFile2(localPath, Buffer.from(await blob.arrayBuffer()));
|
|
2576
|
+
paths.push(localPath);
|
|
2577
|
+
} catch (err) {
|
|
2578
|
+
logger.card.error(cardId, `Failed to download attachment ${row.storage_path}:`, err);
|
|
2579
|
+
}
|
|
2570
2580
|
}
|
|
2581
|
+
return paths;
|
|
2571
2582
|
}
|
|
2572
|
-
function
|
|
2573
|
-
|
|
2574
|
-
const
|
|
2575
|
-
|
|
2576
|
-
|
|
2583
|
+
function buildInstructionsWithAttachments(instructions, attachmentPaths) {
|
|
2584
|
+
if (attachmentPaths.length === 0) return instructions;
|
|
2585
|
+
const list = attachmentPaths.map((path) => `- ${path}`).join("\n");
|
|
2586
|
+
return `${instructions}
|
|
2587
|
+
|
|
2588
|
+
Files attached to this card (use each one as part of the task):
|
|
2589
|
+
${list}`;
|
|
2577
2590
|
}
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
if (
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
return null;
|
|
2588
|
-
}
|
|
2591
|
+
|
|
2592
|
+
// src/fileReferences.ts
|
|
2593
|
+
function buildInstructionsWithFileReferences(instructions, fileReferences) {
|
|
2594
|
+
if (fileReferences.length === 0) return instructions;
|
|
2595
|
+
const list = fileReferences.map((path) => `- ${path}`).join("\n");
|
|
2596
|
+
return `${instructions}
|
|
2597
|
+
|
|
2598
|
+
Files/folders referenced on this card (read them before starting \u2014 they are part of the task):
|
|
2599
|
+
${list}`;
|
|
2589
2600
|
}
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
return lookup;
|
|
2601
|
+
|
|
2602
|
+
// src/mcpContextNote.ts
|
|
2603
|
+
function buildInstructionsWithMcpContext(instructions, cardId, projectId) {
|
|
2604
|
+
return `${instructions}
|
|
2605
|
+
|
|
2606
|
+
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.`;
|
|
2597
2607
|
}
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2608
|
+
|
|
2609
|
+
// src/prototypeGateNote.ts
|
|
2610
|
+
function buildInstructionsWithPrototypeGate(instructions, prototypeFirst) {
|
|
2611
|
+
if (!prototypeFirst) return instructions;
|
|
2612
|
+
return `${instructions}
|
|
2613
|
+
|
|
2614
|
+
Prototipagem visual obrigat\xF3ria antes de implementar (prototypeFirst est\xE1 ligado neste card):
|
|
2615
|
+
|
|
2616
|
+
1. Antes de qualquer altera\xE7\xE3o de c\xF3digo real, publique SOMENTE um prot\xF3tipo visual (um Claude
|
|
2617
|
+
Artifact \u2014 HTML est\xE1tico, sem l\xF3gica/integra\xE7\xE3o de verdade) do que voc\xEA vai construir.
|
|
2618
|
+
Reaproveite ao m\xE1ximo o design system REAL deste reposit\xF3rio (n\xE3o o do agent-board): leia o
|
|
2619
|
+
tailwind config, as CSS vars/tema e os componentes j\xE1 existentes neste \`cwd\` antes de
|
|
2620
|
+
desenhar qualquer coisa. Use as skills \`frontend-design\`/\`artifact-design\` j\xE1 dispon\xEDveis
|
|
2621
|
+
antes de publicar.
|
|
2622
|
+
2. Se os anexos/instru\xE7\xF5es deste card inclu\xEDrem um print de uma tela j\xE1 existente pedindo
|
|
2623
|
+
ajustes, o prot\xF3tipo \xE9 diferente: publique uma p\xE1gina INTERATIVA que mostra o print e deixa
|
|
2624
|
+
quem est\xE1 revisando clicar nele para soltar um pino numerado com um campo de texto por pino \u2014
|
|
2625
|
+
persista cada pino usando a capability \`db\` do pr\xF3prio Artifact (carregue a skill
|
|
2626
|
+
\`artifact-capabilities\` antes de declarar a capability). Nesse caso, a nota que voc\xEA passar
|
|
2627
|
+
para \`publish_prototype\` deve pedir para marcarem os pontos de ajuste nele, n\xE3o para
|
|
2628
|
+
aprovarem um resultado ainda.
|
|
2629
|
+
3. Chame a tool MCP \`publish_prototype\` com o link do Artifact e uma nota curta do que fez/pede.
|
|
2630
|
+
Essa chamada j\xE1 zera a aprova\xE7\xE3o e avisa os humanos do projeto \u2014 n\xE3o poste o link via
|
|
2631
|
+
add_card_comment manualmente tamb\xE9m, seria duplicado.
|
|
2632
|
+
4. Depois de chamar \`publish_prototype\`, PARE o turno \u2014 n\xE3o fique num loop verificando
|
|
2633
|
+
aprova\xE7\xE3o nem monitorando o artifact sozinho. A sess\xE3o ser\xE1 retomada automaticamente quando
|
|
2634
|
+
houver uma revis\xE3o nova (aprova\xE7\xE3o ou pedido de ajuste); tentar continuar por conta pr\xF3pria
|
|
2635
|
+
antes disso \xE9 desperd\xEDcio.
|
|
2636
|
+
5. Quando a sess\xE3o for retomada, a mensagem vai dizer se o prot\xF3tipo foi aprovado:
|
|
2637
|
+
- **Aprovado**: prossiga para a implementa\xE7\xE3o real agora, usando o prot\xF3tipo (e, se for o
|
|
2638
|
+
caso de ajuste em tela existente, os pontos marcados nele \u2014 leia-os de volta via leitura do
|
|
2639
|
+
banco do artifact) como refer\xEAncia. Se os pontos marcados forem grandes/numerosos o
|
|
2640
|
+
suficiente para valer separar, quebre em cards novos (\`create_card\` +
|
|
2641
|
+
\`prerequisiteCardId\`, encadeando os que dependem um do outro) e aplique o mesmo fluxo de
|
|
2642
|
+
prot\xF3tipo em cada um antes de implementar; resolva no pr\xF3prio card quando o ajuste for
|
|
2643
|
+
pequeno.
|
|
2644
|
+
- **N\xE3o aprovado**: confira os coment\xE1rios deixados no Artifact (e, se for a p\xE1gina de
|
|
2645
|
+
anota\xE7\xE3o, os pontos salvos), ajuste o prot\xF3tipo e publique a nova vers\xE3o de novo via
|
|
2646
|
+
\`publish_prototype\` na MESMA url \u2014 depois pare o turno de novo, voltando ao passo 4.`;
|
|
2647
|
+
}
|
|
2648
|
+
|
|
2649
|
+
// src/usageResume.ts
|
|
2650
|
+
var RESET_BUFFER_MS = 3e4;
|
|
2651
|
+
var UNKNOWN_RESET_RETRY_MS = 5 * 6e4;
|
|
2652
|
+
var pending = /* @__PURE__ */ new Map();
|
|
2653
|
+
function scheduleUsageResume(supabase, config, cardId, sessionId, pty) {
|
|
2654
|
+
if (pending.has(sessionId)) return;
|
|
2655
|
+
void (async () => {
|
|
2656
|
+
const usage = await getClaudeUsage(supabase, config.ownerUserId).catch((err) => {
|
|
2657
|
+
logger.card.error(cardId, "Failed to read claude_usage for usage-limit resume:", err);
|
|
2658
|
+
return null;
|
|
2644
2659
|
});
|
|
2645
|
-
|
|
2660
|
+
const now = Date.now();
|
|
2661
|
+
const candidates = [usage?.sessionResetsAt, usage?.weekResetsAt].map((iso) => iso ? new Date(iso).getTime() : null).filter((ms) => ms !== null && ms > now);
|
|
2662
|
+
const resetAt = candidates.length > 0 ? Math.min(...candidates) : null;
|
|
2663
|
+
const delay = resetAt !== null ? resetAt - now + RESET_BUFFER_MS : UNKNOWN_RESET_RETRY_MS;
|
|
2664
|
+
logger.card.info(
|
|
2665
|
+
cardId,
|
|
2666
|
+
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.`
|
|
2667
|
+
);
|
|
2668
|
+
const timer = setTimeout(() => {
|
|
2669
|
+
pending.delete(sessionId);
|
|
2670
|
+
sendMessageToIdleSession(pty, cardId, "continue");
|
|
2671
|
+
}, delay);
|
|
2672
|
+
timer.unref();
|
|
2673
|
+
pending.set(sessionId, timer);
|
|
2674
|
+
})();
|
|
2646
2675
|
}
|
|
2647
|
-
function
|
|
2648
|
-
|
|
2676
|
+
function cancelUsageResume(sessionId) {
|
|
2677
|
+
const timer = pending.get(sessionId);
|
|
2678
|
+
if (!timer) return;
|
|
2679
|
+
clearTimeout(timer);
|
|
2680
|
+
pending.delete(sessionId);
|
|
2649
2681
|
}
|
|
2650
2682
|
|
|
2651
|
-
// src/
|
|
2652
|
-
var
|
|
2653
|
-
var
|
|
2654
|
-
var
|
|
2655
|
-
var
|
|
2656
|
-
var
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2683
|
+
// src/orchestrator.ts
|
|
2684
|
+
var TERMINAL_TICKET_TTL_MS = 60 * 60 * 1e3;
|
|
2685
|
+
var RESUME_CONVERSATION_MISSING_PATTERN = /no conversation found with session id/i;
|
|
2686
|
+
var inFlightCardIds = /* @__PURE__ */ new Set();
|
|
2687
|
+
var activeSessionByCard = /* @__PURE__ */ new Map();
|
|
2688
|
+
var intentionallyStoppedSessionIds = /* @__PURE__ */ new Set();
|
|
2689
|
+
var dispatchingRunner = false;
|
|
2690
|
+
async function isEffectiveOwner(supabase, card, config) {
|
|
2691
|
+
if (card.assigned_user_id !== config.ownerUserId) return false;
|
|
2692
|
+
if (card.runner_id) return card.runner_id === config.runnerId;
|
|
2693
|
+
const defaultRunnerId = await getDefaultRunnerIdForUser(supabase, config.ownerUserId);
|
|
2694
|
+
if (defaultRunnerId) return defaultRunnerId === config.runnerId;
|
|
2695
|
+
return true;
|
|
2661
2696
|
}
|
|
2662
|
-
function
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
2697
|
+
async function scanForWork(supabase, config) {
|
|
2698
|
+
for (const project of config.registeredProjects) {
|
|
2699
|
+
await reconcileStaleSessions(supabase, config, project.id);
|
|
2700
|
+
const { data: cards, error } = await supabase.from("cards").select("*").eq("project_id", project.id).eq("column_name", "desenvolvimento").eq("assigned_user_id", config.ownerUserId);
|
|
2701
|
+
if (error) {
|
|
2702
|
+
logger.runner.error("ORCHESTRATOR", `Failed to scan cards for project ${project.id}:`, error.message);
|
|
2703
|
+
continue;
|
|
2704
|
+
}
|
|
2705
|
+
for (const card of cards ?? []) {
|
|
2706
|
+
void enqueueCard(supabase, config, card);
|
|
2707
|
+
}
|
|
2668
2708
|
}
|
|
2709
|
+
void dispatchRunnerQueue(supabase, config);
|
|
2669
2710
|
}
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
const
|
|
2673
|
-
if (
|
|
2674
|
-
|
|
2675
|
-
return
|
|
2676
|
-
}
|
|
2677
|
-
|
|
2711
|
+
async function enqueueCard(supabase, config, card) {
|
|
2712
|
+
if (inFlightCardIds.has(card.id)) return;
|
|
2713
|
+
const registered = config.registeredProjects.find((project) => project.id === card.project_id);
|
|
2714
|
+
if (!registered) {
|
|
2715
|
+
logger.card.warn(card.id, `Ignoring card: project ${card.project_id} is not registered on this runner.`);
|
|
2716
|
+
return;
|
|
2717
|
+
}
|
|
2718
|
+
if (!await isEffectiveOwner(supabase, card, config)) return;
|
|
2719
|
+
inFlightCardIds.add(card.id);
|
|
2678
2720
|
try {
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
|
|
2721
|
+
const active = await getActiveSessionForCard(supabase, card.id);
|
|
2722
|
+
if (active) return;
|
|
2723
|
+
await createQueuedSession(supabase, config, card);
|
|
2724
|
+
} finally {
|
|
2725
|
+
inFlightCardIds.delete(card.id);
|
|
2683
2726
|
}
|
|
2684
|
-
|
|
2685
|
-
entry.result = new Promise((resolve5) => {
|
|
2686
|
-
let settled = false;
|
|
2687
|
-
const settle = (value) => {
|
|
2688
|
-
if (settled) return;
|
|
2689
|
-
settled = true;
|
|
2690
|
-
clearTimeout(timer);
|
|
2691
|
-
if (!value) {
|
|
2692
|
-
killProcessTree(child2);
|
|
2693
|
-
previews.delete(key);
|
|
2694
|
-
}
|
|
2695
|
-
resolve5(value);
|
|
2696
|
-
};
|
|
2697
|
-
const timer = setTimeout(() => {
|
|
2698
|
-
logger.runner.warn("DEV_SERVER", `"${command2}" in ${cwd} did not reveal a port within ${STARTUP_TIMEOUT_MS2}ms.`);
|
|
2699
|
-
settle(null);
|
|
2700
|
-
}, STARTUP_TIMEOUT_MS2);
|
|
2701
|
-
const onOutput = async (chunk) => {
|
|
2702
|
-
if (settled) return;
|
|
2703
|
-
const port = detectPortFromOutput(chunk.toString("utf-8"));
|
|
2704
|
-
if (port == null) return;
|
|
2705
|
-
settled = true;
|
|
2706
|
-
clearTimeout(timer);
|
|
2707
|
-
const tunnel = await startQuickTunnel(`dev server ${key}`, port);
|
|
2708
|
-
if (!tunnel) {
|
|
2709
|
-
logger.runner.warn("DEV_SERVER", `Detected port ${port} for ${key} but cloudflared could not tunnel it.`);
|
|
2710
|
-
killProcessTree(child2);
|
|
2711
|
-
previews.delete(key);
|
|
2712
|
-
resolve5(null);
|
|
2713
|
-
return;
|
|
2714
|
-
}
|
|
2715
|
-
entry.tunnelChild = tunnel.child;
|
|
2716
|
-
logger.runner.info("DEV_SERVER", `${key} is up on port ${port}, tunneled at ${tunnel.url}.`);
|
|
2717
|
-
resolve5({ label, url: tunnel.url });
|
|
2718
|
-
};
|
|
2719
|
-
child2.stdout?.on("data", onOutput);
|
|
2720
|
-
child2.stderr?.on("data", onOutput);
|
|
2721
|
-
child2.on("error", (err) => {
|
|
2722
|
-
logger.runner.error("DEV_SERVER", `Dev server process error for ${key} (${command2} in ${cwd}):`, err);
|
|
2723
|
-
settle(null);
|
|
2724
|
-
});
|
|
2725
|
-
child2.on("exit", (code) => {
|
|
2726
|
-
logger.runner.info("DEV_SERVER", `Dev server for ${key} exited (code ${code}) before/without a usable preview.`);
|
|
2727
|
-
settle(null);
|
|
2728
|
-
});
|
|
2729
|
-
});
|
|
2730
|
-
previews.set(key, entry);
|
|
2731
|
-
return entry.result;
|
|
2727
|
+
await dispatchRunnerQueue(supabase, config);
|
|
2732
2728
|
}
|
|
2733
|
-
function
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
}
|
|
2729
|
+
async function createQueuedSession(supabase, config, card) {
|
|
2730
|
+
const resumeClaudeSessionId = await getLastClaudeSessionIdForCard(supabase, card.id);
|
|
2731
|
+
const sessionId = await createSession(supabase, config.ownerUserId, card.id, config.runnerId, resumeClaudeSessionId);
|
|
2732
|
+
logger.card.info(
|
|
2733
|
+
card.id,
|
|
2734
|
+
resumeClaudeSessionId ? `"${card.title}" queued (session ${sessionId}, will resume prior conversation ${resumeClaudeSessionId}).` : `"${card.title}" queued (session ${sessionId}).`
|
|
2735
|
+
);
|
|
2741
2736
|
}
|
|
2742
|
-
function
|
|
2743
|
-
const
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
entry.tunnelChild?.kill();
|
|
2748
|
-
previews.delete(key);
|
|
2737
|
+
async function reconcileStaleSessions(supabase, config, projectId) {
|
|
2738
|
+
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");
|
|
2739
|
+
if (error) {
|
|
2740
|
+
logger.runner.error("ORCHESTRATOR", `Failed to scan stale sessions for project ${projectId}:`, error.message);
|
|
2741
|
+
return;
|
|
2749
2742
|
}
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
for (const
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2743
|
+
const stale = data ?? [];
|
|
2744
|
+
if (stale.length === 0) return;
|
|
2745
|
+
for (const { id: sessionId, card_id: cardId, cards } of stale) {
|
|
2746
|
+
intentionallyStoppedSessionIds.add(sessionId);
|
|
2747
|
+
stopPtySession(sessionId);
|
|
2748
|
+
cancelUsageResume(sessionId);
|
|
2749
|
+
activeSessionByCard.delete(cardId);
|
|
2750
|
+
await updateSession(
|
|
2751
|
+
supabase,
|
|
2752
|
+
sessionId,
|
|
2753
|
+
{
|
|
2754
|
+
status: "done",
|
|
2755
|
+
endedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2756
|
+
// See stopCardSession's doc comment: "fim" is the authoritative completion signal for
|
|
2757
|
+
// dispatchRunnerQueue's prerequisite gate, which only ever reads agent_state.
|
|
2758
|
+
...cards.column_name === "fim" ? { agentState: "done" } : {}
|
|
2759
|
+
},
|
|
2760
|
+
{ projectId, cardId, userId: config.ownerUserId }
|
|
2761
|
+
);
|
|
2762
|
+
logger.card.info(cardId, `Ended stale session ${sessionId} (its card left "desenvolvimento" while this runner missed the event).`);
|
|
2758
2763
|
}
|
|
2764
|
+
await dispatchRunnerQueue(supabase, config);
|
|
2759
2765
|
}
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
async function pushBranchToMain(repoPath) {
|
|
2766
|
+
async function dispatchRunnerQueue(supabase, config) {
|
|
2767
|
+
if (dispatchingRunner) return;
|
|
2768
|
+
dispatchingRunner = true;
|
|
2769
|
+
let queued = [];
|
|
2770
|
+
const promotedCardIds = /* @__PURE__ */ new Set();
|
|
2766
2771
|
try {
|
|
2767
|
-
await
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
const
|
|
2771
|
-
const
|
|
2772
|
-
|
|
2772
|
+
queued = await listQueuedSessionsForRunner(supabase, config.runnerId);
|
|
2773
|
+
if (queued.length === 0) return;
|
|
2774
|
+
const queuedProjectIds = new Set(queued.map((entry) => entry.cards.project_id));
|
|
2775
|
+
const maxConcurrentProjects = await getRunnerMaxConcurrentProjects(supabase, config.runnerId);
|
|
2776
|
+
const runningProjectIds = /* @__PURE__ */ new Set();
|
|
2777
|
+
for (const project of config.registeredProjects) {
|
|
2778
|
+
const running = await getRunningSessionForProject(supabase, project.id, config.runnerId);
|
|
2779
|
+
if (!running) continue;
|
|
2780
|
+
if ((running.agentState === "done" || running.agentState === "waiting_prototype_review") && queuedProjectIds.has(project.id)) {
|
|
2781
|
+
await pauseFinishedSession(supabase, config, project.id, running.cardId, running.id);
|
|
2782
|
+
continue;
|
|
2783
|
+
}
|
|
2784
|
+
runningProjectIds.add(project.id);
|
|
2785
|
+
}
|
|
2786
|
+
let freeProjectSlots = maxConcurrentProjects - runningProjectIds.size;
|
|
2787
|
+
if (freeProjectSlots <= 0) return;
|
|
2788
|
+
const promotedThisPass = /* @__PURE__ */ new Set();
|
|
2789
|
+
for (const entry of queued) {
|
|
2790
|
+
const projectId = entry.cards.project_id;
|
|
2791
|
+
if (runningProjectIds.has(projectId) || promotedThisPass.has(projectId)) continue;
|
|
2792
|
+
if (freeProjectSlots <= 0) break;
|
|
2793
|
+
const prerequisiteCardId = entry.cards.prerequisite_card_id;
|
|
2794
|
+
if (prerequisiteCardId) {
|
|
2795
|
+
const prerequisiteAgentState = await getLatestAgentStateForCard(supabase, prerequisiteCardId);
|
|
2796
|
+
if (prerequisiteAgentState !== "done") {
|
|
2797
|
+
logger.card.info(
|
|
2798
|
+
entry.card_id,
|
|
2799
|
+
`Waiting on prerequisite card ${prerequisiteCardId} to reach agent state "done" (currently "${prerequisiteAgentState ?? "none"}") before starting \u2014 leaving queued.`
|
|
2800
|
+
);
|
|
2801
|
+
continue;
|
|
2802
|
+
}
|
|
2803
|
+
}
|
|
2804
|
+
try {
|
|
2805
|
+
await promoteQueuedSession(supabase, config, entry);
|
|
2806
|
+
promotedThisPass.add(projectId);
|
|
2807
|
+
promotedCardIds.add(entry.card_id);
|
|
2808
|
+
freeProjectSlots -= 1;
|
|
2809
|
+
} catch (err) {
|
|
2810
|
+
logger.card.error(entry.card_id, `Failed to promote queued session ${entry.id}:`, err);
|
|
2811
|
+
}
|
|
2812
|
+
}
|
|
2813
|
+
} finally {
|
|
2814
|
+
await publishQueueSnapshots(supabase, config, queued, promotedCardIds).catch(
|
|
2815
|
+
(err) => logger.runner.error("ORCHESTRATOR", "Failed to publish queue snapshots:", err)
|
|
2816
|
+
);
|
|
2817
|
+
dispatchingRunner = false;
|
|
2773
2818
|
}
|
|
2774
2819
|
}
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
const
|
|
2791
|
-
|
|
2792
|
-
|
|
2820
|
+
async function publishQueueSnapshots(supabase, config, queued, promotedCardIds) {
|
|
2821
|
+
const paused = await listPausedSessionsForRunner(supabase, config.runnerId);
|
|
2822
|
+
const queuedByProject = /* @__PURE__ */ new Map();
|
|
2823
|
+
for (const entry of queued) {
|
|
2824
|
+
if (promotedCardIds.has(entry.card_id)) continue;
|
|
2825
|
+
const list = queuedByProject.get(entry.cards.project_id) ?? [];
|
|
2826
|
+
list.push(entry.card_id);
|
|
2827
|
+
queuedByProject.set(entry.cards.project_id, list);
|
|
2828
|
+
}
|
|
2829
|
+
const pausedByProject = /* @__PURE__ */ new Map();
|
|
2830
|
+
for (const { card_id: cardId, project_id: projectId } of paused) {
|
|
2831
|
+
const list = pausedByProject.get(projectId) ?? [];
|
|
2832
|
+
list.push(cardId);
|
|
2833
|
+
pausedByProject.set(projectId, list);
|
|
2834
|
+
}
|
|
2835
|
+
for (const project of config.registeredProjects) {
|
|
2836
|
+
publishQueueEvent(project.id, {
|
|
2837
|
+
queuedCardIds: queuedByProject.get(project.id) ?? [],
|
|
2838
|
+
pausedCardIds: pausedByProject.get(project.id) ?? []
|
|
2839
|
+
});
|
|
2840
|
+
}
|
|
2793
2841
|
}
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
var TUI_IDLE_MS = 1500;
|
|
2797
|
-
var TUI_READY_TIMEOUT_MS = 6e4;
|
|
2798
|
-
var SUBMIT_DELAY_MS = 150;
|
|
2799
|
-
var PASTE_START = "\x1B[200~";
|
|
2800
|
-
var PASTE_END = "\x1B[201~";
|
|
2801
|
-
var SHIFT_TAB = "\x1B[Z";
|
|
2802
|
-
var SHIFT_TAB_PRESSES_TO_PLAN_MODE = 4;
|
|
2803
|
-
var SHIFT_TAB_DELAY_MS = 400;
|
|
2804
|
-
var PLAN_MODE_INDICATOR = /plan mode on/i;
|
|
2805
|
-
function spawnAgentPty(repoPath, cardId, cardTitle, instructions, sessionId, ticket, planFirst) {
|
|
2806
|
-
ensureDirTrusted(repoPath);
|
|
2807
|
-
ensureMcpServersTrusted(repoPath);
|
|
2808
|
-
const claudeSessionId = randomUUID6();
|
|
2809
|
-
const pty = spawn5(
|
|
2810
|
-
CLAUDE_BINARY,
|
|
2811
|
-
[
|
|
2812
|
-
"--dangerously-skip-permissions",
|
|
2813
|
-
"--model",
|
|
2814
|
-
"claude-sonnet-5",
|
|
2815
|
-
"--session-id",
|
|
2816
|
-
claudeSessionId,
|
|
2817
|
-
...claudeCliSettingsArgs(),
|
|
2818
|
-
`/rename ${toSessionName(cardTitle)}`
|
|
2819
|
-
],
|
|
2820
|
-
{
|
|
2821
|
-
name: "xterm-color",
|
|
2822
|
-
cols: 120,
|
|
2823
|
-
rows: 32,
|
|
2824
|
-
cwd: repoPath,
|
|
2825
|
-
env: statuslineEnv(sessionId, ticket)
|
|
2826
|
-
}
|
|
2827
|
-
);
|
|
2828
|
-
sendMessageWhenIdle(pty, cardId, instructions, planFirst);
|
|
2829
|
-
return { pty, claudeSessionId };
|
|
2842
|
+
function buildPrototypeReviewFollowUp(prototypeApproved, prototypeUrl) {
|
|
2843
|
+
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.`;
|
|
2830
2844
|
}
|
|
2831
|
-
function
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
setTimeout(() => {
|
|
2850
|
-
try {
|
|
2851
|
-
pty.write("\r");
|
|
2852
|
-
} catch (err) {
|
|
2853
|
-
logger.card.error(cardId, "Failed to submit the card instructions to the agent:", err);
|
|
2854
|
-
}
|
|
2855
|
-
}, SUBMIT_DELAY_MS);
|
|
2856
|
-
} catch (err) {
|
|
2857
|
-
logger.card.error(cardId, "Failed to send the card instructions to the agent:", err);
|
|
2845
|
+
async function promoteQueuedSession(supabase, config, entry) {
|
|
2846
|
+
const existingHandle = getPtySession(entry.id);
|
|
2847
|
+
if (existingHandle) {
|
|
2848
|
+
setPtySessionReadOnly(entry.id, false);
|
|
2849
|
+
await updateSession(
|
|
2850
|
+
supabase,
|
|
2851
|
+
entry.id,
|
|
2852
|
+
{ status: "running" },
|
|
2853
|
+
{ projectId: entry.cards.project_id, cardId: entry.card_id, userId: config.ownerUserId }
|
|
2854
|
+
);
|
|
2855
|
+
activeSessionByCard.set(entry.card_id, entry.id);
|
|
2856
|
+
logger.card.info(entry.card_id, "Resumed paused session from the front of the queue.");
|
|
2857
|
+
if (entry.agent_state === "waiting_prototype_review" && !existingHandle.exited) {
|
|
2858
|
+
sendMessageToIdleSession(
|
|
2859
|
+
existingHandle.pty,
|
|
2860
|
+
entry.card_id,
|
|
2861
|
+
buildPrototypeReviewFollowUp(entry.cards.prototype_approved, entry.cards.prototype_url)
|
|
2862
|
+
);
|
|
2858
2863
|
}
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
idleTimer = setTimeout(send, TUI_IDLE_MS);
|
|
2864
|
-
});
|
|
2865
|
-
pty.onExit(() => stopWaiting());
|
|
2864
|
+
return;
|
|
2865
|
+
}
|
|
2866
|
+
const spawned = await spawnSessionPty(supabase, config, entry);
|
|
2867
|
+
if (spawned) logger.card.info(entry.card_id, "Promoted from queue.");
|
|
2866
2868
|
}
|
|
2867
|
-
async function
|
|
2868
|
-
|
|
2869
|
-
const
|
|
2870
|
-
|
|
2871
|
-
|
|
2869
|
+
async function spawnSessionPty(supabase, config, entry) {
|
|
2870
|
+
const { id: sessionId, card_id: cardId, cards: card, claude_session_id: claudeSessionId } = entry;
|
|
2871
|
+
const registered = config.registeredProjects.find((project) => project.id === card.project_id);
|
|
2872
|
+
if (!registered) {
|
|
2873
|
+
logger.card.error(cardId, `Session ${sessionId}: project ${card.project_id} is not registered \u2014 skipping.`);
|
|
2874
|
+
await updateSession(
|
|
2875
|
+
supabase,
|
|
2876
|
+
sessionId,
|
|
2877
|
+
{ status: "failed", endedAt: (/* @__PURE__ */ new Date()).toISOString() },
|
|
2878
|
+
{ projectId: card.project_id, cardId, userId: config.ownerUserId }
|
|
2879
|
+
);
|
|
2880
|
+
return false;
|
|
2881
|
+
}
|
|
2882
|
+
const ticket = randomUUID7();
|
|
2883
|
+
let pty;
|
|
2884
|
+
let newClaudeSessionId = null;
|
|
2885
|
+
let resumeConversationMissing = false;
|
|
2872
2886
|
try {
|
|
2873
|
-
|
|
2874
|
-
pty.
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
2887
|
+
if (claudeSessionId) {
|
|
2888
|
+
pty = resumeAgentPty(registered.repoPath, claudeSessionId, sessionId, ticket);
|
|
2889
|
+
pty.onData((chunk) => {
|
|
2890
|
+
if (RESUME_CONVERSATION_MISSING_PATTERN.test(normalizeTerminalOutput(chunk))) resumeConversationMissing = true;
|
|
2891
|
+
});
|
|
2892
|
+
} else {
|
|
2893
|
+
const attachmentPaths = await downloadCardAttachments(supabase, cardId);
|
|
2894
|
+
const instructions = buildInstructionsWithMcpContext(
|
|
2895
|
+
buildInstructionsWithPrototypeGate(
|
|
2896
|
+
buildInstructionsWithFileReferences(
|
|
2897
|
+
buildInstructionsWithAttachments(card.instructions, attachmentPaths),
|
|
2898
|
+
card.file_references
|
|
2899
|
+
),
|
|
2900
|
+
card.prototype_first
|
|
2901
|
+
),
|
|
2879
2902
|
cardId,
|
|
2880
|
-
|
|
2903
|
+
card.project_id
|
|
2881
2904
|
);
|
|
2905
|
+
const spawned = spawnAgentPty(registered.repoPath, cardId, card.title, instructions, sessionId, ticket, card.plan_first);
|
|
2906
|
+
pty = spawned.pty;
|
|
2907
|
+
newClaudeSessionId = spawned.claudeSessionId;
|
|
2882
2908
|
}
|
|
2883
2909
|
} catch (err) {
|
|
2884
|
-
logger.card.error(cardId,
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
}
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
setTimeout(() => {
|
|
2893
|
-
try {
|
|
2894
|
-
pty.write("\r");
|
|
2895
|
-
} catch (err) {
|
|
2896
|
-
logger.card.error(cardId, "Failed to submit a message to the idle session:", err);
|
|
2897
|
-
}
|
|
2898
|
-
}, SUBMIT_DELAY_MS);
|
|
2899
|
-
} catch (err) {
|
|
2900
|
-
logger.card.error(cardId, "Failed to send a message to the idle session:", err);
|
|
2910
|
+
logger.card.error(cardId, `Failed to spawn pty for session ${sessionId}:`, err);
|
|
2911
|
+
await updateSession(
|
|
2912
|
+
supabase,
|
|
2913
|
+
sessionId,
|
|
2914
|
+
{ status: "failed", endedAt: (/* @__PURE__ */ new Date()).toISOString() },
|
|
2915
|
+
{ projectId: card.project_id, cardId, userId: config.ownerUserId }
|
|
2916
|
+
);
|
|
2917
|
+
return false;
|
|
2901
2918
|
}
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
"--dangerously-skip-permissions",
|
|
2910
|
-
"--model",
|
|
2911
|
-
"claude-sonnet-5",
|
|
2912
|
-
"--resume",
|
|
2913
|
-
claudeSessionId,
|
|
2914
|
-
...claudeCliSettingsArgs()
|
|
2915
|
-
],
|
|
2919
|
+
registerPtySession(sessionId, pty, ticket);
|
|
2920
|
+
activeSessionByCard.set(cardId, sessionId);
|
|
2921
|
+
publishAgentStateEvent(card.project_id, cardId, sessionId, newClaudeSessionId ? "starting" : entry.agent_state ?? "starting");
|
|
2922
|
+
const activityWatcher = watchAgentActivity(supabase, config, card.project_id, cardId, sessionId, pty);
|
|
2923
|
+
await updateSession(
|
|
2924
|
+
supabase,
|
|
2925
|
+
sessionId,
|
|
2916
2926
|
{
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
|
|
2927
|
+
status: "running",
|
|
2928
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2929
|
+
terminalTicket: ticket,
|
|
2930
|
+
terminalTicketExpiresAt: new Date(Date.now() + TERMINAL_TICKET_TTL_MS).toISOString(),
|
|
2931
|
+
// Only stamp a fresh spawn as "starting" — a resumed session (claudeSessionId was
|
|
2932
|
+
// already set) keeps whatever agent_state it had before the restart (e.g.
|
|
2933
|
+
// "usage_limited"), so reconcileUsageLimitedSessions can find it afterwards. The
|
|
2934
|
+
// freshly (re)constructed AgentActivityWatcher below will correct it the moment real
|
|
2935
|
+
// output gives it something conclusive to classify.
|
|
2936
|
+
...newClaudeSessionId ? { agentState: "starting", claudeSessionId: newClaudeSessionId } : {}
|
|
2937
|
+
},
|
|
2938
|
+
{ projectId: card.project_id, cardId, userId: config.ownerUserId }
|
|
2923
2939
|
);
|
|
2924
|
-
}
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
}
|
|
2934
|
-
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
const targetDir = join13(ATTACHMENTS_ROOT, cardId);
|
|
2943
|
-
await mkdir2(targetDir, { recursive: true });
|
|
2944
|
-
const paths = [];
|
|
2945
|
-
for (const row of rows) {
|
|
2946
|
-
const baseName = sanitizeFileName(row.file_name);
|
|
2947
|
-
const fileName = extname(baseName) ? baseName : `${baseName}${extname(row.storage_path)}`;
|
|
2948
|
-
const localPath = join13(targetDir, `${row.id}-${fileName}`);
|
|
2949
|
-
try {
|
|
2950
|
-
const { data: blob, error: downloadError } = await supabase.storage.from(CARD_ATTACHMENTS_BUCKET).download(row.storage_path);
|
|
2951
|
-
if (downloadError) throw downloadError;
|
|
2952
|
-
await writeFile2(localPath, Buffer.from(await blob.arrayBuffer()));
|
|
2953
|
-
paths.push(localPath);
|
|
2954
|
-
} catch (err) {
|
|
2955
|
-
logger.card.error(cardId, `Failed to download attachment ${row.storage_path}:`, err);
|
|
2940
|
+
pty.onExit(({ exitCode }) => {
|
|
2941
|
+
const wasIntentional = intentionallyStoppedSessionIds.delete(sessionId);
|
|
2942
|
+
activityWatcher.stop();
|
|
2943
|
+
unregisterAgentActivityWatcher(sessionId);
|
|
2944
|
+
cancelUsageResume(sessionId);
|
|
2945
|
+
activeSessionByCard.delete(cardId);
|
|
2946
|
+
if (resumeConversationMissing) {
|
|
2947
|
+
logger.card.warn(
|
|
2948
|
+
cardId,
|
|
2949
|
+
`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.`
|
|
2950
|
+
);
|
|
2951
|
+
void updateSession(
|
|
2952
|
+
supabase,
|
|
2953
|
+
sessionId,
|
|
2954
|
+
{ status: "failed", endedAt: (/* @__PURE__ */ new Date()).toISOString(), claudeSessionId: null },
|
|
2955
|
+
{ projectId: card.project_id, cardId, userId: config.ownerUserId }
|
|
2956
|
+
);
|
|
2957
|
+
return;
|
|
2956
2958
|
}
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
return `${instructions}
|
|
2964
|
-
|
|
2965
|
-
Files attached to this card (use each one as part of the task):
|
|
2966
|
-
${list}`;
|
|
2959
|
+
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.`);
|
|
2960
|
+
if (!wasIntentional && exitCode !== 0) {
|
|
2961
|
+
publishAgentState(supabase, card.project_id, cardId, sessionId, "error");
|
|
2962
|
+
}
|
|
2963
|
+
});
|
|
2964
|
+
return true;
|
|
2967
2965
|
}
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
return `${instructions}
|
|
2974
|
-
|
|
2975
|
-
Files/folders referenced on this card (read them before starting \u2014 they are part of the task):
|
|
2976
|
-
${list}`;
|
|
2966
|
+
function publishAgentState(supabase, projectId, cardId, sessionId, state) {
|
|
2967
|
+
publishAgentStateEvent(projectId, cardId, sessionId, state);
|
|
2968
|
+
updateSession(supabase, sessionId, { agentState: state }).catch(
|
|
2969
|
+
(err) => logger.card.error(cardId, `Failed to persist agent state "${state}" for session ${sessionId}:`, err)
|
|
2970
|
+
);
|
|
2977
2971
|
}
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2972
|
+
function watchAgentActivity(supabase, config, projectId, cardId, sessionId, pty) {
|
|
2973
|
+
const watcher = new AgentActivityWatcher((state) => {
|
|
2974
|
+
publishAgentState(supabase, projectId, cardId, sessionId, state);
|
|
2975
|
+
if (state === "usage_limited") {
|
|
2976
|
+
scheduleUsageResume(supabase, config, cardId, sessionId, pty);
|
|
2977
|
+
} else {
|
|
2978
|
+
cancelUsageResume(sessionId);
|
|
2979
|
+
}
|
|
2980
|
+
if (state === "done" || state === "waiting_prototype_review") void dispatchRunnerQueue(supabase, config);
|
|
2981
|
+
});
|
|
2982
|
+
pty.onData((chunk) => watcher.push(chunk));
|
|
2983
|
+
registerAgentActivityWatcher(sessionId, watcher);
|
|
2984
|
+
return watcher;
|
|
2984
2985
|
}
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
Prototipagem visual obrigat\xF3ria antes de implementar (prototypeFirst est\xE1 ligado neste card):
|
|
2992
|
-
|
|
2993
|
-
1. Antes de qualquer altera\xE7\xE3o de c\xF3digo real, publique SOMENTE um prot\xF3tipo visual (um Claude
|
|
2994
|
-
Artifact \u2014 HTML est\xE1tico, sem l\xF3gica/integra\xE7\xE3o de verdade) do que voc\xEA vai construir.
|
|
2995
|
-
Reaproveite ao m\xE1ximo o design system REAL deste reposit\xF3rio (n\xE3o o do agent-board): leia o
|
|
2996
|
-
tailwind config, as CSS vars/tema e os componentes j\xE1 existentes neste \`cwd\` antes de
|
|
2997
|
-
desenhar qualquer coisa. Use as skills \`frontend-design\`/\`artifact-design\` j\xE1 dispon\xEDveis
|
|
2998
|
-
antes de publicar.
|
|
2999
|
-
2. Se os anexos/instru\xE7\xF5es deste card inclu\xEDrem um print de uma tela j\xE1 existente pedindo
|
|
3000
|
-
ajustes, o prot\xF3tipo \xE9 diferente: publique uma p\xE1gina INTERATIVA que mostra o print e deixa
|
|
3001
|
-
quem est\xE1 revisando clicar nele para soltar um pino numerado com um campo de texto por pino \u2014
|
|
3002
|
-
persista cada pino usando a capability \`db\` do pr\xF3prio Artifact (carregue a skill
|
|
3003
|
-
\`artifact-capabilities\` antes de declarar a capability). Nesse caso, a nota que voc\xEA passar
|
|
3004
|
-
para \`publish_prototype\` deve pedir para marcarem os pontos de ajuste nele, n\xE3o para
|
|
3005
|
-
aprovarem um resultado ainda.
|
|
3006
|
-
3. Chame a tool MCP \`publish_prototype\` com o link do Artifact e uma nota curta do que fez/pede.
|
|
3007
|
-
Essa chamada j\xE1 zera a aprova\xE7\xE3o e avisa os humanos do projeto \u2014 n\xE3o poste o link via
|
|
3008
|
-
add_card_comment manualmente tamb\xE9m, seria duplicado.
|
|
3009
|
-
4. Depois de chamar \`publish_prototype\`, PARE o turno \u2014 n\xE3o fique num loop verificando
|
|
3010
|
-
aprova\xE7\xE3o nem monitorando o artifact sozinho. A sess\xE3o ser\xE1 retomada automaticamente quando
|
|
3011
|
-
houver uma revis\xE3o nova (aprova\xE7\xE3o ou pedido de ajuste); tentar continuar por conta pr\xF3pria
|
|
3012
|
-
antes disso \xE9 desperd\xEDcio.
|
|
3013
|
-
5. Quando a sess\xE3o for retomada, a mensagem vai dizer se o prot\xF3tipo foi aprovado:
|
|
3014
|
-
- **Aprovado**: prossiga para a implementa\xE7\xE3o real agora, usando o prot\xF3tipo (e, se for o
|
|
3015
|
-
caso de ajuste em tela existente, os pontos marcados nele \u2014 leia-os de volta via leitura do
|
|
3016
|
-
banco do artifact) como refer\xEAncia. Se os pontos marcados forem grandes/numerosos o
|
|
3017
|
-
suficiente para valer separar, quebre em cards novos (\`create_card\` +
|
|
3018
|
-
\`prerequisiteCardId\`, encadeando os que dependem um do outro) e aplique o mesmo fluxo de
|
|
3019
|
-
prot\xF3tipo em cada um antes de implementar; resolva no pr\xF3prio card quando o ajuste for
|
|
3020
|
-
pequeno.
|
|
3021
|
-
- **N\xE3o aprovado**: confira os coment\xE1rios deixados no Artifact (e, se for a p\xE1gina de
|
|
3022
|
-
anota\xE7\xE3o, os pontos salvos), ajuste o prot\xF3tipo e publique a nova vers\xE3o de novo via
|
|
3023
|
-
\`publish_prototype\` na MESMA url \u2014 depois pare o turno de novo, voltando ao passo 4.`;
|
|
2986
|
+
async function pauseFinishedSession(supabase, config, projectId, cardId, sessionId) {
|
|
2987
|
+
const paused = await pauseRunningSession(supabase, sessionId, { projectId, cardId, userId: config.ownerUserId });
|
|
2988
|
+
if (!paused) return;
|
|
2989
|
+
setPtySessionReadOnly(sessionId, true);
|
|
2990
|
+
logger.card.info(cardId, `Session ${sessionId} paused (agent reached "done", another session needs this project's WIP slot).`);
|
|
3024
2991
|
}
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
logger.card.error(cardId, "Failed to read claude_usage for usage-limit resume:", err);
|
|
3035
|
-
return null;
|
|
3036
|
-
});
|
|
3037
|
-
const now = Date.now();
|
|
3038
|
-
const candidates = [usage?.sessionResetsAt, usage?.weekResetsAt].map((iso) => iso ? new Date(iso).getTime() : null).filter((ms) => ms !== null && ms > now);
|
|
3039
|
-
const resetAt = candidates.length > 0 ? Math.min(...candidates) : null;
|
|
3040
|
-
const delay = resetAt !== null ? resetAt - now + RESET_BUFFER_MS : UNKNOWN_RESET_RETRY_MS;
|
|
3041
|
-
logger.card.info(
|
|
3042
|
-
cardId,
|
|
3043
|
-
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.`
|
|
2992
|
+
async function requeuePausedCard(supabase, config, card) {
|
|
2993
|
+
const active = await getActiveSessionForCard(supabase, card.id);
|
|
2994
|
+
if (!active) return;
|
|
2995
|
+
if (active.status === "paused") {
|
|
2996
|
+
await updateSession(
|
|
2997
|
+
supabase,
|
|
2998
|
+
active.id,
|
|
2999
|
+
{ status: "queued" },
|
|
3000
|
+
{ projectId: card.project_id, cardId: card.id, userId: config.ownerUserId }
|
|
3044
3001
|
);
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
}
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3002
|
+
await updateCard(supabase, card.id, { requeueRequestedAt: null });
|
|
3003
|
+
logger.card.info(card.id, `Session ${active.id} requeued at the front of the line (was paused).`);
|
|
3004
|
+
await dispatchRunnerQueue(supabase, config);
|
|
3005
|
+
return;
|
|
3006
|
+
}
|
|
3007
|
+
if (active.status === "running" && active.agentState === "waiting_prototype_review") {
|
|
3008
|
+
const handle = getPtySession(active.id);
|
|
3009
|
+
await updateCard(supabase, card.id, { requeueRequestedAt: null });
|
|
3010
|
+
if (handle && !handle.exited) {
|
|
3011
|
+
sendMessageToIdleSession(handle.pty, card.id, buildPrototypeReviewFollowUp(card.prototype_approved, card.prototype_url));
|
|
3012
|
+
logger.card.info(card.id, `Session ${active.id} was never paused (alone in its project) \u2014 sent the prototype review follow-up directly.`);
|
|
3013
|
+
}
|
|
3014
|
+
}
|
|
3058
3015
|
}
|
|
3059
|
-
|
|
3060
|
-
// src/orchestrator.ts
|
|
3061
|
-
var TERMINAL_TICKET_TTL_MS = 60 * 60 * 1e3;
|
|
3062
|
-
var RESUME_CONVERSATION_MISSING_PATTERN = /no conversation found with session id/i;
|
|
3063
|
-
var inFlightCardIds = /* @__PURE__ */ new Set();
|
|
3064
|
-
var activeSessionByCard = /* @__PURE__ */ new Map();
|
|
3065
|
-
var intentionallyStoppedSessionIds = /* @__PURE__ */ new Set();
|
|
3066
|
-
var dispatchingRunner = false;
|
|
3067
|
-
async function scanForWork(supabase, config) {
|
|
3016
|
+
async function reconcileOrphanedSessions(supabase, config) {
|
|
3068
3017
|
for (const project of config.registeredProjects) {
|
|
3069
|
-
|
|
3070
|
-
|
|
3018
|
+
const { data, error } = await supabase.from("sessions").select(
|
|
3019
|
+
"id, card_id, claude_session_id, agent_state, created_at, status, cards!card_id!inner(project_id, title, instructions, file_references, plan_first)"
|
|
3020
|
+
).eq("cards.project_id", project.id).eq("runner_id", config.runnerId).in("status", ["running", "paused"]);
|
|
3071
3021
|
if (error) {
|
|
3072
|
-
logger.runner.error("ORCHESTRATOR", `Failed to scan
|
|
3022
|
+
logger.runner.error("ORCHESTRATOR", `Failed to scan orphaned sessions for project ${project.id}:`, error.message);
|
|
3073
3023
|
continue;
|
|
3074
3024
|
}
|
|
3075
|
-
for (const
|
|
3076
|
-
|
|
3025
|
+
for (const entry of data ?? []) {
|
|
3026
|
+
let spawned;
|
|
3027
|
+
try {
|
|
3028
|
+
spawned = await spawnSessionPty(supabase, config, entry);
|
|
3029
|
+
} catch (err) {
|
|
3030
|
+
logger.card.error(entry.card_id, `Failed to re-spawn orphaned session ${entry.id}:`, err);
|
|
3031
|
+
continue;
|
|
3032
|
+
}
|
|
3033
|
+
if (!spawned) continue;
|
|
3034
|
+
if (entry.status === "paused") {
|
|
3035
|
+
await updateSession(
|
|
3036
|
+
supabase,
|
|
3037
|
+
entry.id,
|
|
3038
|
+
{ status: "paused" },
|
|
3039
|
+
{ projectId: project.id, cardId: entry.card_id, userId: config.ownerUserId }
|
|
3040
|
+
);
|
|
3041
|
+
setPtySessionReadOnly(entry.id, true);
|
|
3042
|
+
logger.card.info(entry.card_id, "Re-spawned paused session (runner restarted) \u2014 still read-only until requeued.");
|
|
3043
|
+
} else {
|
|
3044
|
+
logger.card.info(entry.card_id, "Re-spawned orphaned session (runner restarted since it was last running).");
|
|
3045
|
+
}
|
|
3077
3046
|
}
|
|
3078
3047
|
}
|
|
3079
|
-
void dispatchRunnerQueue(supabase, config);
|
|
3080
3048
|
}
|
|
3081
|
-
async function
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
await createQueuedSession(supabase, config, card);
|
|
3094
|
-
} finally {
|
|
3095
|
-
inFlightCardIds.delete(card.id);
|
|
3049
|
+
async function reconcileUsageLimitedSessions(supabase, config) {
|
|
3050
|
+
for (const project of config.registeredProjects) {
|
|
3051
|
+
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");
|
|
3052
|
+
if (error) {
|
|
3053
|
+
logger.runner.error("ORCHESTRATOR", `Failed to scan usage-limited sessions for project ${project.id}:`, error.message);
|
|
3054
|
+
continue;
|
|
3055
|
+
}
|
|
3056
|
+
for (const { id: sessionId, card_id: cardId } of data ?? []) {
|
|
3057
|
+
const handle = getPtySession(sessionId);
|
|
3058
|
+
if (!handle) continue;
|
|
3059
|
+
scheduleUsageResume(supabase, config, cardId, sessionId, handle.pty);
|
|
3060
|
+
}
|
|
3096
3061
|
}
|
|
3097
|
-
await dispatchRunnerQueue(supabase, config);
|
|
3098
3062
|
}
|
|
3099
|
-
async function
|
|
3100
|
-
const
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3063
|
+
async function stopCardSession(supabase, config, card) {
|
|
3064
|
+
const sessionId = activeSessionByCard.get(card.id) ?? (await getActiveSessionForCard(supabase, card.id))?.id;
|
|
3065
|
+
if (!sessionId) return;
|
|
3066
|
+
intentionallyStoppedSessionIds.add(sessionId);
|
|
3067
|
+
stopPtySession(sessionId);
|
|
3068
|
+
cancelUsageResume(sessionId);
|
|
3069
|
+
activeSessionByCard.delete(card.id);
|
|
3070
|
+
await updateSession(
|
|
3071
|
+
supabase,
|
|
3072
|
+
sessionId,
|
|
3073
|
+
{
|
|
3074
|
+
status: "done",
|
|
3075
|
+
endedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3076
|
+
...card.column_name === "fim" ? { agentState: "done" } : {}
|
|
3077
|
+
},
|
|
3078
|
+
{ projectId: card.project_id, cardId: card.id, userId: config.ownerUserId }
|
|
3105
3079
|
);
|
|
3080
|
+
logger.card.info(card.id, `Session ${sessionId} stopped.`);
|
|
3081
|
+
await dispatchRunnerQueue(supabase, config);
|
|
3106
3082
|
}
|
|
3107
|
-
async function
|
|
3108
|
-
const
|
|
3109
|
-
if (
|
|
3110
|
-
logger.runner.error("ORCHESTRATOR", `Failed to scan stale sessions for project ${projectId}:`, error.message);
|
|
3111
|
-
return;
|
|
3112
|
-
}
|
|
3113
|
-
const stale = data ?? [];
|
|
3114
|
-
if (stale.length === 0) return;
|
|
3115
|
-
for (const { id: sessionId, card_id: cardId, cards } of stale) {
|
|
3083
|
+
async function restartCardSession(supabase, config, card) {
|
|
3084
|
+
const sessionId = activeSessionByCard.get(card.id) ?? (await getActiveSessionForCard(supabase, card.id))?.id;
|
|
3085
|
+
if (sessionId) {
|
|
3116
3086
|
intentionallyStoppedSessionIds.add(sessionId);
|
|
3117
3087
|
stopPtySession(sessionId);
|
|
3118
3088
|
cancelUsageResume(sessionId);
|
|
3119
|
-
activeSessionByCard.delete(
|
|
3089
|
+
activeSessionByCard.delete(card.id);
|
|
3120
3090
|
await updateSession(
|
|
3121
3091
|
supabase,
|
|
3122
3092
|
sessionId,
|
|
3123
|
-
{
|
|
3124
|
-
|
|
3125
|
-
endedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3126
|
-
// See stopCardSession's doc comment: "fim" is the authoritative completion signal for
|
|
3127
|
-
// dispatchRunnerQueue's prerequisite gate, which only ever reads agent_state.
|
|
3128
|
-
...cards.column_name === "fim" ? { agentState: "done" } : {}
|
|
3129
|
-
},
|
|
3130
|
-
{ projectId, cardId, userId: config.ownerUserId }
|
|
3093
|
+
{ status: "failed", claudeSessionId: null, endedAt: (/* @__PURE__ */ new Date()).toISOString() },
|
|
3094
|
+
{ projectId: card.project_id, cardId: card.id, userId: config.ownerUserId }
|
|
3131
3095
|
);
|
|
3132
|
-
logger.card.info(
|
|
3096
|
+
logger.card.info(card.id, `Session ${sessionId} abandoned by "Reiniciar sess\xE3o"; starting a fresh one.`);
|
|
3133
3097
|
}
|
|
3134
|
-
await
|
|
3098
|
+
await updateCard(supabase, card.id, { restartRequestedAt: null });
|
|
3099
|
+
await enqueueCard(supabase, config, card);
|
|
3135
3100
|
}
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
let queued = [];
|
|
3140
|
-
const promotedCardIds = /* @__PURE__ */ new Set();
|
|
3101
|
+
|
|
3102
|
+
// src/vaultSecrets.ts
|
|
3103
|
+
async function resolveProjectSecretsEnv(supabase, projectId, repoId) {
|
|
3141
3104
|
try {
|
|
3142
|
-
|
|
3143
|
-
if (
|
|
3144
|
-
const
|
|
3145
|
-
const
|
|
3146
|
-
const
|
|
3147
|
-
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
continue;
|
|
3153
|
-
}
|
|
3154
|
-
runningProjectIds.add(project.id);
|
|
3105
|
+
const { data, error } = await supabase.rpc("resolve_project_secrets", { p_project_id: projectId, p_repo_id: repoId });
|
|
3106
|
+
if (error) throw error;
|
|
3107
|
+
const rows = data ?? [];
|
|
3108
|
+
const env = {};
|
|
3109
|
+
for (const row of rows) env[row.name] = row.value;
|
|
3110
|
+
if (rows.length > 0) {
|
|
3111
|
+
logger.runner.info(
|
|
3112
|
+
"VAULT",
|
|
3113
|
+
`Resolved ${rows.length} secret(s) for project ${projectId}${repoId ? ` (repo ${repoId})` : ""}: ${rows.map((r) => r.name).join(", ")}.`
|
|
3114
|
+
);
|
|
3155
3115
|
}
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3116
|
+
return env;
|
|
3117
|
+
} catch (err) {
|
|
3118
|
+
logger.runner.error("VAULT", `Failed to resolve secrets for project ${projectId}${repoId ? ` (repo ${repoId})` : ""}:`, err);
|
|
3119
|
+
return {};
|
|
3120
|
+
}
|
|
3121
|
+
}
|
|
3122
|
+
|
|
3123
|
+
// src/columnActions.ts
|
|
3124
|
+
var execAsync = promisify4(exec);
|
|
3125
|
+
var OUTPUT_TAIL_CHARS = 4e3;
|
|
3126
|
+
function tail(text) {
|
|
3127
|
+
return text.length > OUTPUT_TAIL_CHARS ? text.slice(text.length - OUTPUT_TAIL_CHARS) : text;
|
|
3128
|
+
}
|
|
3129
|
+
function resolveTargets(project, children, repoRoot, column, timing) {
|
|
3130
|
+
if (children.length > 0) {
|
|
3131
|
+
return children.flatMap(
|
|
3132
|
+
(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 }))
|
|
3133
|
+
);
|
|
3134
|
+
}
|
|
3135
|
+
return (project.column_actions[column] ?? []).filter((action) => action.timing === timing).map((action) => ({ label: null, cwd: repoRoot, command: action.command, repoId: null }));
|
|
3136
|
+
}
|
|
3137
|
+
var EXEC_OPTIONS = { maxBuffer: 10 * 1024 * 1024 };
|
|
3138
|
+
async function runCommand(target, extraEnv) {
|
|
3139
|
+
try {
|
|
3140
|
+
const { stdout, stderr } = await execAsync(target.command, { ...EXEC_OPTIONS, cwd: target.cwd, env: { ...process.env, ...extraEnv } });
|
|
3141
|
+
return { ok: true, output: tail(`${stdout}${stderr}`) };
|
|
3142
|
+
} catch (err) {
|
|
3143
|
+
const { stdout, stderr } = err;
|
|
3144
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3145
|
+
return { ok: false, output: tail(`${stdout ?? ""}${stderr ?? ""}` || message) };
|
|
3146
|
+
}
|
|
3147
|
+
}
|
|
3148
|
+
var inFlightColumns = /* @__PURE__ */ new Set();
|
|
3149
|
+
async function handleColumnEntered(supabase, config, card) {
|
|
3150
|
+
const key = `${card.project_id}:${card.column_name}`;
|
|
3151
|
+
if (inFlightColumns.has(key)) return;
|
|
3152
|
+
inFlightColumns.add(key);
|
|
3153
|
+
try {
|
|
3154
|
+
const project = await getProject(supabase, card.project_id);
|
|
3155
|
+
const children = await getProjectRepos(supabase, project.id);
|
|
3156
|
+
const repoRoot = resolveRepoPath(config, project.repo_path);
|
|
3157
|
+
const targets = resolveTargets(project, children, repoRoot, card.column_name, "before");
|
|
3158
|
+
if (targets.length === 0) return;
|
|
3159
|
+
const allCards = await listCardsInColumn(supabase, project.id, card.column_name);
|
|
3160
|
+
const cards = (await Promise.all(allCards.map(async (candidate) => await isEffectiveOwner(supabase, candidate, config) ? candidate : null))).filter((candidate) => candidate !== null);
|
|
3161
|
+
if (cards.length === 0) return;
|
|
3162
|
+
await Promise.all(
|
|
3163
|
+
cards.map((candidate) => updateCard(supabase, candidate.id, { customActionStatus: "running", customActionOutput: null }))
|
|
3164
|
+
);
|
|
3165
|
+
let failure = null;
|
|
3166
|
+
for (const target of targets) {
|
|
3167
|
+
const secretsEnv = await resolveProjectSecretsEnv(supabase, project.id, target.repoId);
|
|
3168
|
+
const result = await runCommand(target, secretsEnv);
|
|
3169
|
+
if (!result.ok) {
|
|
3170
|
+
failure = { label: target.label, output: result.output };
|
|
3171
|
+
break;
|
|
3181
3172
|
}
|
|
3182
3173
|
}
|
|
3174
|
+
if (failure) {
|
|
3175
|
+
const message = failure.label ? `[${failure.label}] ${failure.output}` : failure.output;
|
|
3176
|
+
logger.runner.warn("COLUMN_ACTION", `Command failed for project ${project.id} in "${card.column_name}": ${message}`);
|
|
3177
|
+
await Promise.all(
|
|
3178
|
+
cards.map(
|
|
3179
|
+
(candidate) => updateCard(supabase, candidate.id, { customActionStatus: "failed", customActionOutput: message })
|
|
3180
|
+
)
|
|
3181
|
+
);
|
|
3182
|
+
} else {
|
|
3183
|
+
logger.runner.info("COLUMN_ACTION", `Command passed for project ${project.id} in "${card.column_name}".`);
|
|
3184
|
+
await Promise.all(
|
|
3185
|
+
cards.map(
|
|
3186
|
+
(candidate) => updateCard(supabase, candidate.id, { customActionStatus: "passed", customActionOutput: null })
|
|
3187
|
+
)
|
|
3188
|
+
);
|
|
3189
|
+
}
|
|
3183
3190
|
} finally {
|
|
3184
|
-
|
|
3185
|
-
(err) => logger.runner.error("ORCHESTRATOR", "Failed to publish queue snapshots:", err)
|
|
3186
|
-
);
|
|
3187
|
-
dispatchingRunner = false;
|
|
3191
|
+
inFlightColumns.delete(key);
|
|
3188
3192
|
}
|
|
3189
3193
|
}
|
|
3190
|
-
async function
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
list.push(cardId);
|
|
3203
|
-
pausedByProject.set(projectId, list);
|
|
3194
|
+
async function clearColumnActionStatus(supabase, card) {
|
|
3195
|
+
if (card.custom_action_status === null) return;
|
|
3196
|
+
await updateCard(supabase, card.id, { customActionStatus: null, customActionOutput: null });
|
|
3197
|
+
}
|
|
3198
|
+
async function runAfterColumnActions(supabase, project, children, repoRoot, column) {
|
|
3199
|
+
const targets = resolveTargets(project, children, repoRoot, column, "after");
|
|
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
|
+
return { ok: false, error: target.label ? `${target.label}: ${result.output}` : result.output };
|
|
3205
|
+
}
|
|
3204
3206
|
}
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
|
|
3207
|
+
return { ok: true };
|
|
3208
|
+
}
|
|
3209
|
+
async function checkPendingColumnActions(supabase, config) {
|
|
3210
|
+
for (const registered of config.registeredProjects) {
|
|
3211
|
+
for (const column of BOARD_COLUMNS) {
|
|
3212
|
+
const cards = await listCardsInColumn(supabase, registered.id, column);
|
|
3213
|
+
let pending2;
|
|
3214
|
+
for (const card of cards) {
|
|
3215
|
+
if (card.custom_action_status !== null) continue;
|
|
3216
|
+
if (await isEffectiveOwner(supabase, card, config)) {
|
|
3217
|
+
pending2 = card;
|
|
3218
|
+
break;
|
|
3219
|
+
}
|
|
3220
|
+
}
|
|
3221
|
+
if (pending2) await handleColumnEntered(supabase, config, pending2);
|
|
3222
|
+
}
|
|
3210
3223
|
}
|
|
3211
3224
|
}
|
|
3212
|
-
function
|
|
3213
|
-
|
|
3225
|
+
async function handleDeployActionRerunRequested(supabase, config, project) {
|
|
3226
|
+
const allCards = await listCardsInColumn(supabase, project.id, "deploy");
|
|
3227
|
+
const cards = (await Promise.all(allCards.map(async (card) => await isEffectiveOwner(supabase, card, config) ? card : null))).filter((card) => card !== null);
|
|
3228
|
+
if (cards.length === 0) {
|
|
3229
|
+
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.`);
|
|
3230
|
+
} else {
|
|
3231
|
+
await handleColumnEntered(supabase, config, cards[0]);
|
|
3232
|
+
}
|
|
3233
|
+
await clearDeployActionRerunRequest(supabase, project.id);
|
|
3214
3234
|
}
|
|
3215
|
-
async function
|
|
3216
|
-
const
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
supabase,
|
|
3221
|
-
entry.id,
|
|
3222
|
-
{ status: "running" },
|
|
3223
|
-
{ projectId: entry.cards.project_id, cardId: entry.card_id, userId: config.ownerUserId }
|
|
3224
|
-
);
|
|
3225
|
-
activeSessionByCard.set(entry.card_id, entry.id);
|
|
3226
|
-
logger.card.info(entry.card_id, "Resumed paused session from the front of the queue.");
|
|
3227
|
-
if (entry.agent_state === "waiting_prototype_review" && !existingHandle.exited) {
|
|
3228
|
-
sendMessageToIdleSession(
|
|
3229
|
-
existingHandle.pty,
|
|
3230
|
-
entry.card_id,
|
|
3231
|
-
buildPrototypeReviewFollowUp(entry.cards.prototype_approved, entry.cards.prototype_url)
|
|
3232
|
-
);
|
|
3235
|
+
async function checkPendingDeployActionReruns(supabase, config) {
|
|
3236
|
+
for (const registered of config.registeredProjects) {
|
|
3237
|
+
const project = await getProject(supabase, registered.id);
|
|
3238
|
+
if (project.deploy_action_rerun_requested_at) {
|
|
3239
|
+
await handleDeployActionRerunRequested(supabase, config, project);
|
|
3233
3240
|
}
|
|
3234
|
-
return;
|
|
3235
3241
|
}
|
|
3236
|
-
const spawned = await spawnSessionPty(supabase, config, entry);
|
|
3237
|
-
if (spawned) logger.card.info(entry.card_id, "Promoted from queue.");
|
|
3238
3242
|
}
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3243
|
+
|
|
3244
|
+
// src/deploy.ts
|
|
3245
|
+
import { join as join14 } from "node:path";
|
|
3246
|
+
|
|
3247
|
+
// src/devServer.ts
|
|
3248
|
+
import { spawn as spawn5 } from "node:child_process";
|
|
3249
|
+
|
|
3250
|
+
// src/cloudflared.ts
|
|
3251
|
+
import { spawn as spawn4 } from "node:child_process";
|
|
3252
|
+
import { accessSync, constants } from "node:fs";
|
|
3253
|
+
import { homedir as homedir5 } from "node:os";
|
|
3254
|
+
import { delimiter, join as join13 } from "node:path";
|
|
3255
|
+
import { bin as bundledBinaryPath, install as downloadCloudflared } from "cloudflared";
|
|
3256
|
+
var TRYCLOUDFLARE_URL_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
|
|
3257
|
+
var STARTUP_TIMEOUT_MS = 2e4;
|
|
3258
|
+
var IS_WINDOWS2 = process.platform === "win32";
|
|
3259
|
+
var BINARY_NAME = IS_WINDOWS2 ? "cloudflared.exe" : "cloudflared";
|
|
3260
|
+
var CLOUDFLARED_INSTALL_DOCS_URL = "https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/";
|
|
3261
|
+
function wellKnownDirectories() {
|
|
3262
|
+
const home = homedir5();
|
|
3263
|
+
if (IS_WINDOWS2) {
|
|
3264
|
+
const candidates = [
|
|
3265
|
+
process.env.ProgramFiles && join13(process.env.ProgramFiles, "cloudflared"),
|
|
3266
|
+
process.env["ProgramFiles(x86)"] && join13(process.env["ProgramFiles(x86)"], "cloudflared"),
|
|
3267
|
+
process.env.LOCALAPPDATA && join13(process.env.LOCALAPPDATA, "Microsoft", "WinGet", "Links"),
|
|
3268
|
+
join13(home, "scoop", "shims"),
|
|
3269
|
+
process.env.ChocolateyInstall && join13(process.env.ChocolateyInstall, "bin")
|
|
3270
|
+
];
|
|
3271
|
+
return candidates.filter((dir) => Boolean(dir));
|
|
3272
|
+
}
|
|
3273
|
+
return ["/usr/local/bin", "/usr/bin", "/opt/homebrew/bin", join13(home, ".local", "bin")];
|
|
3274
|
+
}
|
|
3275
|
+
function pathDirectories() {
|
|
3276
|
+
return (process.env.PATH ?? "").split(delimiter).map((dir) => dir.trim().replace(/^"|"$/g, "")).filter(Boolean);
|
|
3277
|
+
}
|
|
3278
|
+
function isExecutableFile(path) {
|
|
3279
|
+
try {
|
|
3280
|
+
accessSync(path, constants.X_OK);
|
|
3281
|
+
return true;
|
|
3282
|
+
} catch {
|
|
3250
3283
|
return false;
|
|
3251
3284
|
}
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
|
|
3255
|
-
|
|
3285
|
+
}
|
|
3286
|
+
function findInstalledBinary() {
|
|
3287
|
+
const override = process.env.AGENT_BOARD_CLOUDFLARED_PATH;
|
|
3288
|
+
const directories = [...pathDirectories(), ...wellKnownDirectories()];
|
|
3289
|
+
const candidates = [...override ? [override] : [], ...directories.map((dir) => join13(dir, BINARY_NAME))];
|
|
3290
|
+
return candidates.find(isExecutableFile) ?? null;
|
|
3291
|
+
}
|
|
3292
|
+
async function locateCloudflared() {
|
|
3293
|
+
const installed = findInstalledBinary();
|
|
3294
|
+
if (installed) return installed;
|
|
3295
|
+
if (isExecutableFile(bundledBinaryPath)) return bundledBinaryPath;
|
|
3256
3296
|
try {
|
|
3257
|
-
|
|
3258
|
-
|
|
3259
|
-
pty.onData((chunk) => {
|
|
3260
|
-
if (RESUME_CONVERSATION_MISSING_PATTERN.test(normalizeTerminalOutput(chunk))) resumeConversationMissing = true;
|
|
3261
|
-
});
|
|
3262
|
-
} else {
|
|
3263
|
-
const attachmentPaths = await downloadCardAttachments(supabase, cardId);
|
|
3264
|
-
const instructions = buildInstructionsWithMcpContext(
|
|
3265
|
-
buildInstructionsWithPrototypeGate(
|
|
3266
|
-
buildInstructionsWithFileReferences(
|
|
3267
|
-
buildInstructionsWithAttachments(card.instructions, attachmentPaths),
|
|
3268
|
-
card.file_references
|
|
3269
|
-
),
|
|
3270
|
-
card.prototype_first
|
|
3271
|
-
),
|
|
3272
|
-
cardId,
|
|
3273
|
-
card.project_id
|
|
3274
|
-
);
|
|
3275
|
-
const spawned = spawnAgentPty(registered.repoPath, cardId, card.title, instructions, sessionId, ticket, card.plan_first);
|
|
3276
|
-
pty = spawned.pty;
|
|
3277
|
-
newClaudeSessionId = spawned.claudeSessionId;
|
|
3278
|
-
}
|
|
3297
|
+
logger.runner.info("CLOUDFLARE", "Downloading the cloudflared binary (first run only)...");
|
|
3298
|
+
return await downloadCloudflared(bundledBinaryPath);
|
|
3279
3299
|
} catch (err) {
|
|
3280
|
-
logger.
|
|
3281
|
-
|
|
3282
|
-
supabase,
|
|
3283
|
-
sessionId,
|
|
3284
|
-
{ status: "failed", endedAt: (/* @__PURE__ */ new Date()).toISOString() },
|
|
3285
|
-
{ projectId: card.project_id, cardId, userId: config.ownerUserId }
|
|
3286
|
-
);
|
|
3287
|
-
return false;
|
|
3300
|
+
logger.runner.error("CLOUDFLARE", `Failed to download cloudflared. Install it manually: ${CLOUDFLARED_INSTALL_DOCS_URL}`, err);
|
|
3301
|
+
return null;
|
|
3288
3302
|
}
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
|
|
3298
|
-
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
|
|
3309
|
-
|
|
3310
|
-
|
|
3311
|
-
|
|
3312
|
-
|
|
3313
|
-
|
|
3314
|
-
|
|
3315
|
-
|
|
3316
|
-
|
|
3317
|
-
logger.card.warn(
|
|
3318
|
-
cardId,
|
|
3319
|
-
`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.`
|
|
3320
|
-
);
|
|
3321
|
-
void updateSession(
|
|
3322
|
-
supabase,
|
|
3323
|
-
sessionId,
|
|
3324
|
-
{ status: "failed", endedAt: (/* @__PURE__ */ new Date()).toISOString(), claudeSessionId: null },
|
|
3325
|
-
{ projectId: card.project_id, cardId, userId: config.ownerUserId }
|
|
3326
|
-
);
|
|
3303
|
+
}
|
|
3304
|
+
var lookup = null;
|
|
3305
|
+
function resolveCloudflaredPath() {
|
|
3306
|
+
lookup ??= locateCloudflared().then((path) => {
|
|
3307
|
+
if (!path) lookup = null;
|
|
3308
|
+
return path;
|
|
3309
|
+
});
|
|
3310
|
+
return lookup;
|
|
3311
|
+
}
|
|
3312
|
+
async function startQuickTunnel(label, port) {
|
|
3313
|
+
const executable = await resolveCloudflaredPath();
|
|
3314
|
+
if (!executable) {
|
|
3315
|
+
logger.runner.error("CLOUDFLARE", `No cloudflared binary available, so ${label} cannot be exposed.`);
|
|
3316
|
+
return null;
|
|
3317
|
+
}
|
|
3318
|
+
return new Promise((resolve5) => {
|
|
3319
|
+
let child2;
|
|
3320
|
+
try {
|
|
3321
|
+
child2 = spawn4(executable, [
|
|
3322
|
+
"tunnel",
|
|
3323
|
+
"--url",
|
|
3324
|
+
`http://localhost:${port}`,
|
|
3325
|
+
"--http-host-header",
|
|
3326
|
+
`localhost:${port}`
|
|
3327
|
+
]);
|
|
3328
|
+
} catch (err) {
|
|
3329
|
+
logger.runner.error("CLOUDFLARE", `Failed to spawn cloudflared for ${label}:`, err);
|
|
3330
|
+
resolve5(null);
|
|
3327
3331
|
return;
|
|
3328
3332
|
}
|
|
3329
|
-
|
|
3330
|
-
|
|
3331
|
-
|
|
3332
|
-
|
|
3333
|
+
let settled = false;
|
|
3334
|
+
const settle = (value) => {
|
|
3335
|
+
if (settled) return;
|
|
3336
|
+
settled = true;
|
|
3337
|
+
clearTimeout(timer);
|
|
3338
|
+
resolve5(value);
|
|
3339
|
+
};
|
|
3340
|
+
const timer = setTimeout(() => {
|
|
3341
|
+
logger.runner.warn("CLOUDFLARE", `cloudflared for ${label} did not print a trycloudflare.com URL within ${STARTUP_TIMEOUT_MS}ms.`);
|
|
3342
|
+
settle(null);
|
|
3343
|
+
}, STARTUP_TIMEOUT_MS);
|
|
3344
|
+
const onOutput = (chunk) => {
|
|
3345
|
+
if (settled) return;
|
|
3346
|
+
const match = chunk.toString("utf-8").match(TRYCLOUDFLARE_URL_RE);
|
|
3347
|
+
if (match) settle({ url: match[0], child: child2 });
|
|
3348
|
+
};
|
|
3349
|
+
child2.stdout?.on("data", onOutput);
|
|
3350
|
+
child2.stderr?.on("data", onOutput);
|
|
3351
|
+
child2.on("error", (err) => {
|
|
3352
|
+
logger.runner.error("CLOUDFLARE", `cloudflared process error for ${label} (${executable}):`, err);
|
|
3353
|
+
settle(null);
|
|
3354
|
+
});
|
|
3355
|
+
child2.on("exit", (code) => {
|
|
3356
|
+
logger.runner.info("CLOUDFLARE", `cloudflared tunnel for ${label} exited (code ${code}).`);
|
|
3357
|
+
settle(null);
|
|
3358
|
+
});
|
|
3333
3359
|
});
|
|
3334
|
-
return true;
|
|
3335
3360
|
}
|
|
3336
|
-
function
|
|
3337
|
-
|
|
3338
|
-
updateSession(supabase, sessionId, { agentState: state }).catch(
|
|
3339
|
-
(err) => logger.card.error(cardId, `Failed to persist agent state "${state}" for session ${sessionId}:`, err)
|
|
3340
|
-
);
|
|
3361
|
+
function toTunnelHostname(url) {
|
|
3362
|
+
return new URL(url).host;
|
|
3341
3363
|
}
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
3352
|
-
|
|
3353
|
-
registerAgentActivityWatcher(sessionId, watcher);
|
|
3354
|
-
return watcher;
|
|
3364
|
+
|
|
3365
|
+
// src/devServer.ts
|
|
3366
|
+
var STARTUP_TIMEOUT_MS2 = 45e3;
|
|
3367
|
+
var IS_WINDOWS3 = process.platform === "win32";
|
|
3368
|
+
var LOCAL_URL_PORT_RE = /https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\]|0\.0\.0\.0):(\d{2,5})/i;
|
|
3369
|
+
var GENERIC_PORT_RE = /(?:listening|running|server).{0,40}?:?\s*(\d{4,5})\b/i;
|
|
3370
|
+
var ANSI_ESCAPE_RE2 = /\x1b\[[0-9;]*m/g;
|
|
3371
|
+
function detectPortFromOutput(chunk) {
|
|
3372
|
+
const clean = chunk.replace(ANSI_ESCAPE_RE2, "");
|
|
3373
|
+
const match = LOCAL_URL_PORT_RE.exec(clean) ?? GENERIC_PORT_RE.exec(clean);
|
|
3374
|
+
return match ? Number(match[1]) : null;
|
|
3355
3375
|
}
|
|
3356
|
-
|
|
3357
|
-
|
|
3358
|
-
if (
|
|
3359
|
-
|
|
3360
|
-
|
|
3376
|
+
function killProcessTree(child2) {
|
|
3377
|
+
if (child2.pid == null) return;
|
|
3378
|
+
if (IS_WINDOWS3) {
|
|
3379
|
+
spawn5("taskkill", ["/pid", String(child2.pid), "/t", "/f"], { stdio: "ignore" });
|
|
3380
|
+
} else {
|
|
3381
|
+
child2.kill();
|
|
3382
|
+
}
|
|
3361
3383
|
}
|
|
3362
|
-
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
if (
|
|
3366
|
-
|
|
3367
|
-
|
|
3368
|
-
active.id,
|
|
3369
|
-
{ status: "queued" },
|
|
3370
|
-
{ projectId: card.project_id, cardId: card.id, userId: config.ownerUserId }
|
|
3371
|
-
);
|
|
3372
|
-
await updateCard(supabase, card.id, { requeueRequestedAt: null });
|
|
3373
|
-
logger.card.info(card.id, `Session ${active.id} requeued at the front of the line (was paused).`);
|
|
3374
|
-
await dispatchRunnerQueue(supabase, config);
|
|
3375
|
-
return;
|
|
3384
|
+
var previews = /* @__PURE__ */ new Map();
|
|
3385
|
+
function ensurePreview(key, label, cwd, command2, cardId, extraEnv = {}) {
|
|
3386
|
+
const existing = previews.get(key);
|
|
3387
|
+
if (existing) {
|
|
3388
|
+
existing.cardIds.add(cardId);
|
|
3389
|
+
return existing.result;
|
|
3376
3390
|
}
|
|
3377
|
-
|
|
3378
|
-
|
|
3379
|
-
|
|
3380
|
-
|
|
3381
|
-
|
|
3382
|
-
|
|
3383
|
-
}
|
|
3391
|
+
let child2;
|
|
3392
|
+
try {
|
|
3393
|
+
child2 = spawn5(command2, { cwd, shell: true, windowsHide: true, env: { ...process.env, ...extraEnv } });
|
|
3394
|
+
} catch (err) {
|
|
3395
|
+
logger.runner.error("DEV_SERVER", `Failed to spawn "${command2}" in ${cwd}:`, err);
|
|
3396
|
+
return Promise.resolve(null);
|
|
3384
3397
|
}
|
|
3385
|
-
}
|
|
3386
|
-
|
|
3387
|
-
|
|
3388
|
-
const
|
|
3389
|
-
|
|
3390
|
-
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
|
|
3394
|
-
|
|
3395
|
-
for (const entry of data ?? []) {
|
|
3396
|
-
let spawned;
|
|
3397
|
-
try {
|
|
3398
|
-
spawned = await spawnSessionPty(supabase, config, entry);
|
|
3399
|
-
} catch (err) {
|
|
3400
|
-
logger.card.error(entry.card_id, `Failed to re-spawn orphaned session ${entry.id}:`, err);
|
|
3401
|
-
continue;
|
|
3398
|
+
const entry = { child: child2, tunnelChild: null, cardIds: /* @__PURE__ */ new Set([cardId]), result: null };
|
|
3399
|
+
entry.result = new Promise((resolve5) => {
|
|
3400
|
+
let settled = false;
|
|
3401
|
+
const settle = (value) => {
|
|
3402
|
+
if (settled) return;
|
|
3403
|
+
settled = true;
|
|
3404
|
+
clearTimeout(timer);
|
|
3405
|
+
if (!value) {
|
|
3406
|
+
killProcessTree(child2);
|
|
3407
|
+
previews.delete(key);
|
|
3402
3408
|
}
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
|
|
3414
|
-
|
|
3409
|
+
resolve5(value);
|
|
3410
|
+
};
|
|
3411
|
+
const timer = setTimeout(() => {
|
|
3412
|
+
logger.runner.warn("DEV_SERVER", `"${command2}" in ${cwd} did not reveal a port within ${STARTUP_TIMEOUT_MS2}ms.`);
|
|
3413
|
+
settle(null);
|
|
3414
|
+
}, STARTUP_TIMEOUT_MS2);
|
|
3415
|
+
const onOutput = async (chunk) => {
|
|
3416
|
+
if (settled) return;
|
|
3417
|
+
const port = detectPortFromOutput(chunk.toString("utf-8"));
|
|
3418
|
+
if (port == null) return;
|
|
3419
|
+
settled = true;
|
|
3420
|
+
clearTimeout(timer);
|
|
3421
|
+
const tunnel = await startQuickTunnel(`dev server ${key}`, port);
|
|
3422
|
+
if (!tunnel) {
|
|
3423
|
+
logger.runner.warn("DEV_SERVER", `Detected port ${port} for ${key} but cloudflared could not tunnel it.`);
|
|
3424
|
+
killProcessTree(child2);
|
|
3425
|
+
previews.delete(key);
|
|
3426
|
+
resolve5(null);
|
|
3427
|
+
return;
|
|
3415
3428
|
}
|
|
3416
|
-
|
|
3429
|
+
entry.tunnelChild = tunnel.child;
|
|
3430
|
+
logger.runner.info("DEV_SERVER", `${key} is up on port ${port}, tunneled at ${tunnel.url}.`);
|
|
3431
|
+
resolve5({ label, url: tunnel.url });
|
|
3432
|
+
};
|
|
3433
|
+
child2.stdout?.on("data", onOutput);
|
|
3434
|
+
child2.stderr?.on("data", onOutput);
|
|
3435
|
+
child2.on("error", (err) => {
|
|
3436
|
+
logger.runner.error("DEV_SERVER", `Dev server process error for ${key} (${command2} in ${cwd}):`, err);
|
|
3437
|
+
settle(null);
|
|
3438
|
+
});
|
|
3439
|
+
child2.on("exit", (code) => {
|
|
3440
|
+
logger.runner.info("DEV_SERVER", `Dev server for ${key} exited (code ${code}) before/without a usable preview.`);
|
|
3441
|
+
settle(null);
|
|
3442
|
+
});
|
|
3443
|
+
});
|
|
3444
|
+
previews.set(key, entry);
|
|
3445
|
+
return entry.result;
|
|
3446
|
+
}
|
|
3447
|
+
function releaseCard(cardId) {
|
|
3448
|
+
for (const [key, entry] of previews) {
|
|
3449
|
+
if (!entry.cardIds.delete(cardId)) continue;
|
|
3450
|
+
if (entry.cardIds.size > 0) continue;
|
|
3451
|
+
killProcessTree(entry.child);
|
|
3452
|
+
entry.tunnelChild?.kill();
|
|
3453
|
+
previews.delete(key);
|
|
3417
3454
|
}
|
|
3418
3455
|
}
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
|
|
3422
|
-
if (
|
|
3423
|
-
|
|
3424
|
-
|
|
3425
|
-
|
|
3426
|
-
for (const { id: sessionId, card_id: cardId } of data ?? []) {
|
|
3427
|
-
const handle = getPtySession(sessionId);
|
|
3428
|
-
if (!handle) continue;
|
|
3429
|
-
scheduleUsageResume(supabase, config, cardId, sessionId, handle.pty);
|
|
3430
|
-
}
|
|
3456
|
+
function stopProjectPreviews(projectId) {
|
|
3457
|
+
const prefix = `${projectId}:`;
|
|
3458
|
+
for (const [key, entry] of previews) {
|
|
3459
|
+
if (!key.startsWith(prefix)) continue;
|
|
3460
|
+
killProcessTree(entry.child);
|
|
3461
|
+
entry.tunnelChild?.kill();
|
|
3462
|
+
previews.delete(key);
|
|
3431
3463
|
}
|
|
3432
3464
|
}
|
|
3433
|
-
|
|
3434
|
-
const
|
|
3435
|
-
|
|
3436
|
-
|
|
3437
|
-
|
|
3438
|
-
|
|
3439
|
-
|
|
3440
|
-
|
|
3441
|
-
supabase,
|
|
3442
|
-
sessionId,
|
|
3443
|
-
{
|
|
3444
|
-
status: "done",
|
|
3445
|
-
endedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3446
|
-
...card.column_name === "fim" ? { agentState: "done" } : {}
|
|
3447
|
-
},
|
|
3448
|
-
{ projectId: card.project_id, cardId: card.id, userId: config.ownerUserId }
|
|
3449
|
-
);
|
|
3450
|
-
logger.card.info(card.id, `Session ${sessionId} stopped.`);
|
|
3451
|
-
await dispatchRunnerQueue(supabase, config);
|
|
3465
|
+
function stopAll() {
|
|
3466
|
+
for (const key of [...previews.keys()]) {
|
|
3467
|
+
const entry = previews.get(key);
|
|
3468
|
+
if (!entry) continue;
|
|
3469
|
+
killProcessTree(entry.child);
|
|
3470
|
+
entry.tunnelChild?.kill();
|
|
3471
|
+
previews.delete(key);
|
|
3472
|
+
}
|
|
3452
3473
|
}
|
|
3453
|
-
|
|
3454
|
-
|
|
3455
|
-
|
|
3456
|
-
|
|
3457
|
-
|
|
3458
|
-
|
|
3459
|
-
|
|
3460
|
-
await
|
|
3461
|
-
|
|
3462
|
-
|
|
3463
|
-
|
|
3464
|
-
|
|
3465
|
-
|
|
3466
|
-
logger.card.info(card.id, `Session ${sessionId} abandoned by "Reiniciar sess\xE3o"; starting a fresh one.`);
|
|
3474
|
+
|
|
3475
|
+
// src/git.ts
|
|
3476
|
+
import { execFile as execFile4 } from "node:child_process";
|
|
3477
|
+
import { promisify as promisify5 } from "node:util";
|
|
3478
|
+
var execFileAsync4 = promisify5(execFile4);
|
|
3479
|
+
async function pushBranchToMain(repoPath) {
|
|
3480
|
+
try {
|
|
3481
|
+
await execFileAsync4("git", ["push", "origin", "HEAD:main"], { cwd: repoPath });
|
|
3482
|
+
return { ok: true };
|
|
3483
|
+
} catch (err) {
|
|
3484
|
+
const stderr = err.stderr;
|
|
3485
|
+
const error = stderr?.trim() || (err instanceof Error ? err.message : String(err));
|
|
3486
|
+
return { ok: false, error };
|
|
3467
3487
|
}
|
|
3468
|
-
await updateCard(supabase, card.id, { restartRequestedAt: null });
|
|
3469
|
-
await enqueueCard(supabase, config, card);
|
|
3470
3488
|
}
|
|
3471
3489
|
|
|
3472
3490
|
// src/deploy.ts
|
|
@@ -3490,7 +3508,9 @@ async function handleDeployRequested(supabase, config, project) {
|
|
|
3490
3508
|
}
|
|
3491
3509
|
async function runDeploy(supabase, config, project) {
|
|
3492
3510
|
const allCards = await listCardsInColumn(supabase, project.id, "deploy");
|
|
3493
|
-
const cards = allCards.
|
|
3511
|
+
const cards = (await Promise.all(allCards.map(async (card) => await isEffectiveOwner(supabase, card, config) ? card : null))).filter(
|
|
3512
|
+
(card) => card !== null
|
|
3513
|
+
);
|
|
3494
3514
|
if (cards.length === 0) {
|
|
3495
3515
|
logger.runner.info("DEPLOY", `Deploy requested for project ${project.id} but no cards assigned to this runner's user are waiting in "deploy".`);
|
|
3496
3516
|
await clearProjectDeployRequest(supabase, project.id);
|
|
@@ -3667,7 +3687,7 @@ async function checkPendingPreviews(supabase, config) {
|
|
|
3667
3687
|
for (const registered of config.registeredProjects) {
|
|
3668
3688
|
const cards = await listCardsInColumn(supabase, registered.id, "revisao");
|
|
3669
3689
|
for (const card of cards) {
|
|
3670
|
-
if (card
|
|
3690
|
+
if (!await isEffectiveOwner(supabase, card, config)) continue;
|
|
3671
3691
|
if (card.preview_urls.length > 0) continue;
|
|
3672
3692
|
await handleCardEnteredRevisao(supabase, config, card);
|
|
3673
3693
|
}
|
|
@@ -3682,7 +3702,9 @@ async function handleDevServerRestartRequested(supabase, config, project) {
|
|
|
3682
3702
|
}
|
|
3683
3703
|
stopProjectPreviews(project.id);
|
|
3684
3704
|
const allCards = await listCardsInColumn(supabase, project.id, "revisao");
|
|
3685
|
-
const cards = allCards.
|
|
3705
|
+
const cards = (await Promise.all(allCards.map(async (card) => await isEffectiveOwner(supabase, card, config) ? card : null))).filter(
|
|
3706
|
+
(card) => card !== null
|
|
3707
|
+
);
|
|
3686
3708
|
if (cards.length === 0) {
|
|
3687
3709
|
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.`);
|
|
3688
3710
|
await clearDevServerRestartRequest(supabase, project.id);
|