agent-board-runner 0.1.0 → 0.1.2
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/README.md +14 -0
- package/dist/cli.js +2647 -1311
- package/dist/index.js +2720 -1384
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,123 +1,179 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
2
|
import { config as loadEnv } from "dotenv";
|
|
3
3
|
|
|
4
|
+
// ../../packages/shared-types/dist/index.js
|
|
5
|
+
var BOARD_COLUMNS = [
|
|
6
|
+
"backlog",
|
|
7
|
+
"desenvolvimento",
|
|
8
|
+
"revisao",
|
|
9
|
+
"deploy",
|
|
10
|
+
"fim"
|
|
11
|
+
];
|
|
12
|
+
var COLUMN_SHELL_KINDS = ["shell", "claude"];
|
|
13
|
+
var RUNNER_VERSION = "0.1.0";
|
|
14
|
+
var CARD_ATTACHMENTS_BUCKET = "agent-board-card-images";
|
|
15
|
+
var MAX_CARD_ATTACHMENT_BYTES = 25 * 1024 * 1024;
|
|
16
|
+
|
|
4
17
|
// src/runner.ts
|
|
5
18
|
import { createServer } from "node:http";
|
|
6
19
|
|
|
7
|
-
// src/
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import { join as join2 } from "node:path";
|
|
11
|
-
import open from "open";
|
|
20
|
+
// src/agentEventsServer.ts
|
|
21
|
+
import { parse as parse2 } from "node:url";
|
|
22
|
+
import { WebSocketServer } from "ws";
|
|
12
23
|
|
|
13
|
-
// src/
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
// src/filesServer.ts
|
|
25
|
+
import { execFile as execFile2 } from "node:child_process";
|
|
26
|
+
import { randomUUID as randomUUID4 } from "node:crypto";
|
|
27
|
+
import { existsSync as existsSync2, lstatSync, mkdirSync as mkdirSync2, readdirSync, readFileSync as readFileSync2, rmSync, writeFileSync as writeFileSync5 } from "node:fs";
|
|
28
|
+
import { homedir as homedir3 } from "node:os";
|
|
29
|
+
import { join as join6, resolve, sep } from "node:path";
|
|
30
|
+
import { parse } from "node:url";
|
|
31
|
+
import { promisify as promisify2 } from "node:util";
|
|
32
|
+
|
|
33
|
+
// src/agentActivity.ts
|
|
34
|
+
var RECENT_WINDOW_MS = 6e3;
|
|
35
|
+
var IDLE_AFTER_MS = 45e3;
|
|
36
|
+
var IDLE_AFTER_MS_TOOL_PENDING = 3e5;
|
|
37
|
+
var EVAL_INTERVAL_MS = 2e3;
|
|
38
|
+
var ANSI_PATTERN = /\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
|
|
39
|
+
var BOX_DRAWING_PATTERN = /[─-╿]/g;
|
|
40
|
+
var WORKING_PATTERN = /esc to interrupt/i;
|
|
41
|
+
function isAgentHookMoment(value) {
|
|
42
|
+
return value === "implementing" || value === "waiting_plan_approval" || value === "commit_detected" || value === "turn_stopped" || value === "notification";
|
|
26
43
|
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
44
|
+
var USAGE_LIMIT_PATTERN = /(usage limit reached|limit reached[^\n]{0,60}resets|reached your (?:usage|session) limit|out of (?:weekly|session) (?:usage|limit))/i;
|
|
45
|
+
var PLAN_APPROVAL_PATTERN = /would you like to proceed\??/i;
|
|
46
|
+
var USER_PROMPT_PATTERNS = [
|
|
47
|
+
/do you want to\b/i,
|
|
48
|
+
/would you like to\b/i,
|
|
49
|
+
/do you trust the files\b/i,
|
|
50
|
+
/press enter to continue/i,
|
|
51
|
+
/\(y\/n\)/i,
|
|
52
|
+
/❯\s*1\.\s/
|
|
53
|
+
];
|
|
54
|
+
var TOOL_CALL_MARKER = "\u23FA";
|
|
55
|
+
var TOOL_RESULT_MARKER = "\u23BF";
|
|
56
|
+
var COMMIT_SUCCESS_PATTERN = /\[\S+\s+[0-9a-f]{7,40}\]/;
|
|
57
|
+
function normalizeTerminalOutput(raw) {
|
|
58
|
+
return raw.replace(ANSI_PATTERN, "").replace(BOX_DRAWING_PATTERN, " ").replace(/[ \t]+/g, " ");
|
|
35
59
|
}
|
|
36
|
-
function
|
|
37
|
-
|
|
38
|
-
if (
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
60
|
+
function classifyAgentOutput(recentOutput, options) {
|
|
61
|
+
if (WORKING_PATTERN.test(recentOutput)) return "implementing";
|
|
62
|
+
if (options.usageLimited) return "usage_limited";
|
|
63
|
+
if (options.planApprovalPending) return "waiting_plan_approval";
|
|
64
|
+
if (USER_PROMPT_PATTERNS.some((pattern) => pattern.test(recentOutput))) return "waiting_user";
|
|
65
|
+
const idleThreshold = options.toolCallPending ? IDLE_AFTER_MS_TOOL_PENDING : IDLE_AFTER_MS;
|
|
66
|
+
if (options.silentForMs >= idleThreshold) return options.commitDetected ? "done" : "waiting_user";
|
|
67
|
+
return options.previous;
|
|
42
68
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
69
|
+
var AgentActivityWatcher = class {
|
|
70
|
+
constructor(onChange) {
|
|
71
|
+
this.onChange = onChange;
|
|
72
|
+
this.timer = setInterval(() => this.evaluate(), EVAL_INTERVAL_MS);
|
|
73
|
+
this.timer.unref();
|
|
46
74
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
75
|
+
chunks = [];
|
|
76
|
+
// Starts as "starting" (not "implementing"): nothing classifiable has arrived yet, and the
|
|
77
|
+
// caller stamps the session row the same way right when the pty is spawned — see
|
|
78
|
+
// orchestrator.spawnSessionPty.
|
|
79
|
+
state = "starting";
|
|
80
|
+
lastOutputAt = Date.now();
|
|
81
|
+
usageLimited = false;
|
|
82
|
+
planApprovalPending = false;
|
|
83
|
+
toolCallPending = false;
|
|
84
|
+
commitDetected = false;
|
|
85
|
+
timer;
|
|
86
|
+
push(chunk) {
|
|
87
|
+
const now = Date.now();
|
|
88
|
+
const text = normalizeTerminalOutput(chunk);
|
|
89
|
+
this.lastOutputAt = now;
|
|
90
|
+
this.chunks.push({ at: now, text });
|
|
91
|
+
if (USAGE_LIMIT_PATTERN.test(text)) this.usageLimited = true;
|
|
92
|
+
if (PLAN_APPROVAL_PATTERN.test(text)) this.planApprovalPending = true;
|
|
93
|
+
if (COMMIT_SUCCESS_PATTERN.test(text)) this.commitDetected = true;
|
|
94
|
+
const lastToolCallIdx = text.lastIndexOf(TOOL_CALL_MARKER);
|
|
95
|
+
const lastToolResultIdx = text.lastIndexOf(TOOL_RESULT_MARKER);
|
|
96
|
+
if (lastToolCallIdx !== -1 || lastToolResultIdx !== -1) this.toolCallPending = lastToolCallIdx > lastToolResultIdx;
|
|
97
|
+
if (WORKING_PATTERN.test(text)) {
|
|
98
|
+
this.usageLimited = false;
|
|
99
|
+
this.planApprovalPending = false;
|
|
100
|
+
this.commitDetected = false;
|
|
101
|
+
}
|
|
102
|
+
this.evaluate(now);
|
|
66
103
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
getItem(key) {
|
|
70
|
-
return readSessionFile()[key] ?? null;
|
|
71
|
-
},
|
|
72
|
-
setItem(key, value) {
|
|
73
|
-
if (!existsSync2(CONFIG_DIR)) mkdirSync2(CONFIG_DIR, { recursive: true });
|
|
74
|
-
const all = readSessionFile();
|
|
75
|
-
all[key] = value;
|
|
76
|
-
writeFileSync2(SESSION_FILE, JSON.stringify(all), "utf-8");
|
|
77
|
-
},
|
|
78
|
-
removeItem(key) {
|
|
79
|
-
const all = readSessionFile();
|
|
80
|
-
delete all[key];
|
|
81
|
-
writeFileSync2(SESSION_FILE, JSON.stringify(all), "utf-8");
|
|
104
|
+
stop() {
|
|
105
|
+
clearInterval(this.timer);
|
|
82
106
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
107
|
+
/**
|
|
108
|
+
* Applies a precise moment reported by the agent-moment hook script (see
|
|
109
|
+
* agentMomentHookScript.ts, routed here via filesServer.ts's /agent-hook route and
|
|
110
|
+
* orchestrator.ts's registerAgentActivityWatcher) — takes priority over the text/idle-timer
|
|
111
|
+
* path below rather than racing it, since a hook firing means Claude Code itself just told
|
|
112
|
+
* us exactly what happened.
|
|
113
|
+
*
|
|
114
|
+
* `usageLimited` is still owned by the text path (no hook event exists for it — see
|
|
115
|
+
* USAGE_LIMIT_PATTERN): `turn_stopped`/`notification` must not clobber that latch just
|
|
116
|
+
* because the CLI also happens to stop/notify around a limit message.
|
|
117
|
+
*/
|
|
118
|
+
reportHookMoment(moment) {
|
|
119
|
+
this.lastOutputAt = Date.now();
|
|
120
|
+
switch (moment) {
|
|
121
|
+
case "implementing":
|
|
122
|
+
this.usageLimited = false;
|
|
123
|
+
this.planApprovalPending = false;
|
|
124
|
+
this.commitDetected = false;
|
|
125
|
+
this.setState("implementing");
|
|
126
|
+
break;
|
|
127
|
+
case "waiting_plan_approval":
|
|
128
|
+
this.planApprovalPending = true;
|
|
129
|
+
this.setState("waiting_plan_approval");
|
|
130
|
+
break;
|
|
131
|
+
case "commit_detected":
|
|
132
|
+
this.commitDetected = true;
|
|
133
|
+
break;
|
|
134
|
+
case "turn_stopped":
|
|
135
|
+
if (this.usageLimited) break;
|
|
136
|
+
this.setState(this.commitDetected ? "done" : "waiting_user");
|
|
137
|
+
break;
|
|
138
|
+
case "notification":
|
|
139
|
+
if (this.usageLimited || this.planApprovalPending) break;
|
|
140
|
+
if (this.state === "implementing" || this.state === "starting") this.setState("waiting_user");
|
|
141
|
+
break;
|
|
93
142
|
}
|
|
94
|
-
});
|
|
95
|
-
}
|
|
96
|
-
async function getSession(supabase) {
|
|
97
|
-
const { data } = await supabase.auth.getSession();
|
|
98
|
-
return data.session;
|
|
99
|
-
}
|
|
100
|
-
async function requireSession(supabase) {
|
|
101
|
-
const session = await getSession(supabase);
|
|
102
|
-
if (!session) {
|
|
103
|
-
throw new Error('Not logged in. Run "agent-board-runner login" first.');
|
|
104
143
|
}
|
|
105
|
-
|
|
144
|
+
evaluate(now = Date.now()) {
|
|
145
|
+
while (this.chunks.length > 0 && now - this.chunks[0].at > RECENT_WINDOW_MS) this.chunks.shift();
|
|
146
|
+
const next = classifyAgentOutput(this.chunks.map((chunk) => chunk.text).join(""), {
|
|
147
|
+
silentForMs: now - this.lastOutputAt,
|
|
148
|
+
usageLimited: this.usageLimited,
|
|
149
|
+
planApprovalPending: this.planApprovalPending,
|
|
150
|
+
toolCallPending: this.toolCallPending,
|
|
151
|
+
commitDetected: this.commitDetected,
|
|
152
|
+
previous: this.state
|
|
153
|
+
});
|
|
154
|
+
this.setState(next);
|
|
155
|
+
}
|
|
156
|
+
setState(next) {
|
|
157
|
+
if (next === this.state) return;
|
|
158
|
+
this.state = next;
|
|
159
|
+
this.onChange(next);
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
var activeWatchers = /* @__PURE__ */ new Map();
|
|
163
|
+
function registerAgentActivityWatcher(sessionId, watcher) {
|
|
164
|
+
activeWatchers.set(sessionId, watcher);
|
|
165
|
+
}
|
|
166
|
+
function unregisterAgentActivityWatcher(sessionId) {
|
|
167
|
+
activeWatchers.delete(sessionId);
|
|
168
|
+
}
|
|
169
|
+
function handleAgentHookEvent(sessionId, moment) {
|
|
170
|
+
if (!isAgentHookMoment(moment)) return false;
|
|
171
|
+
const watcher = activeWatchers.get(sessionId);
|
|
172
|
+
if (!watcher) return false;
|
|
173
|
+
watcher.reportHookMoment(moment);
|
|
174
|
+
return true;
|
|
106
175
|
}
|
|
107
176
|
|
|
108
|
-
// src/deploy.ts
|
|
109
|
-
import { join as join6 } from "node:path";
|
|
110
|
-
|
|
111
|
-
// src/devServer.ts
|
|
112
|
-
import { spawn as spawn2 } from "node:child_process";
|
|
113
|
-
|
|
114
|
-
// src/cloudflared.ts
|
|
115
|
-
import { spawn } from "node:child_process";
|
|
116
|
-
import { accessSync, constants } from "node:fs";
|
|
117
|
-
import { homedir as homedir2 } from "node:os";
|
|
118
|
-
import { delimiter, join as join3 } from "node:path";
|
|
119
|
-
import { bin as bundledBinaryPath, install as downloadCloudflared } from "cloudflared";
|
|
120
|
-
|
|
121
177
|
// src/logger.ts
|
|
122
178
|
var ANSI = {
|
|
123
179
|
reset: "\x1B[0m",
|
|
@@ -176,337 +232,757 @@ var logger = {
|
|
|
176
232
|
card: scoped("CARD")
|
|
177
233
|
};
|
|
178
234
|
|
|
179
|
-
// src/
|
|
180
|
-
|
|
181
|
-
|
|
235
|
+
// src/ptyRegistry.ts
|
|
236
|
+
import { randomUUID } from "node:crypto";
|
|
237
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
238
|
+
import { tmpdir } from "node:os";
|
|
239
|
+
import { join } from "node:path";
|
|
240
|
+
var MAX_BUFFERED_CHUNKS = 2e3;
|
|
241
|
+
var registry = /* @__PURE__ */ new Map();
|
|
242
|
+
var PASTE_ROOT = join(tmpdir(), "agent-board-pastes");
|
|
243
|
+
var IMAGE_EXT_BY_CODE = ["png", "jpg", "gif", "webp"];
|
|
244
|
+
async function savePastedImage(sessionId, extCode, bytes) {
|
|
245
|
+
const dir = join(PASTE_ROOT, sessionId);
|
|
246
|
+
await mkdir(dir, { recursive: true });
|
|
247
|
+
const ext = IMAGE_EXT_BY_CODE[extCode] ?? "png";
|
|
248
|
+
const path = join(dir, `paste-${Date.now()}-${randomUUID().slice(0, 8)}.${ext}`);
|
|
249
|
+
await writeFile(path, bytes);
|
|
250
|
+
return path;
|
|
251
|
+
}
|
|
252
|
+
function registerPtySession(sessionId, pty, ticket) {
|
|
253
|
+
const handle = { pty, ticket, outputBuffer: [], sockets: /* @__PURE__ */ new Set(), readOnly: false };
|
|
254
|
+
pty.onData((data) => {
|
|
255
|
+
handle.outputBuffer.push(data);
|
|
256
|
+
if (handle.outputBuffer.length > MAX_BUFFERED_CHUNKS) handle.outputBuffer.shift();
|
|
257
|
+
for (const socket of handle.sockets) {
|
|
258
|
+
if (socket.readyState === socket.OPEN) socket.send(data);
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
registry.set(sessionId, handle);
|
|
262
|
+
}
|
|
263
|
+
function getPtySession(sessionId) {
|
|
264
|
+
return registry.get(sessionId);
|
|
265
|
+
}
|
|
266
|
+
function setPtySessionReadOnly(sessionId, readOnly) {
|
|
267
|
+
const handle = registry.get(sessionId);
|
|
268
|
+
if (handle) handle.readOnly = readOnly;
|
|
269
|
+
}
|
|
270
|
+
function resizePtySession(sessionId, cols, rows) {
|
|
271
|
+
const handle = registry.get(sessionId);
|
|
272
|
+
if (!handle) return;
|
|
273
|
+
handle.pty.resize(cols, rows);
|
|
274
|
+
}
|
|
275
|
+
function stopPtySession(sessionId) {
|
|
276
|
+
const handle = registry.get(sessionId);
|
|
277
|
+
if (!handle) return;
|
|
278
|
+
for (const socket of handle.sockets) socket.close(4e3, "Session ended");
|
|
279
|
+
handle.pty.kill();
|
|
280
|
+
registry.delete(sessionId);
|
|
281
|
+
void rm(join(PASTE_ROOT, sessionId), { recursive: true, force: true }).catch(() => {
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// src/shellSession.ts
|
|
286
|
+
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
287
|
+
import { spawn } from "node-pty";
|
|
288
|
+
var SHELL_COMMAND = process.platform === "win32" ? process.env.COMSPEC ?? "cmd.exe" : process.env.SHELL ?? "bash";
|
|
289
|
+
function spawnShellPty(repoPath, sessionIdPrefix) {
|
|
290
|
+
const sessionId = `${sessionIdPrefix}:${randomUUID2()}`;
|
|
291
|
+
const ticket = randomUUID2();
|
|
292
|
+
const pty = spawn(SHELL_COMMAND, [], {
|
|
293
|
+
name: "xterm-color",
|
|
294
|
+
cols: 120,
|
|
295
|
+
rows: 32,
|
|
296
|
+
cwd: repoPath
|
|
297
|
+
});
|
|
298
|
+
registerPtySession(sessionId, pty, ticket);
|
|
299
|
+
pty.onExit(({ exitCode }) => {
|
|
300
|
+
logger.runner.info("SHELL", `Shell ${sessionId} exited (code ${exitCode}).`);
|
|
301
|
+
stopPtySession(sessionId);
|
|
302
|
+
});
|
|
303
|
+
return { sessionId, ticket };
|
|
304
|
+
}
|
|
305
|
+
function openShellSession(repoPath) {
|
|
306
|
+
return spawnShellPty(repoPath, "shell");
|
|
307
|
+
}
|
|
308
|
+
function closeShellSession(sessionId) {
|
|
309
|
+
stopPtySession(sessionId);
|
|
310
|
+
}
|
|
311
|
+
var columnShells = /* @__PURE__ */ new Map();
|
|
312
|
+
function openOrReuseColumnShell(key, repoPath, initialCommand) {
|
|
313
|
+
const existing = columnShells.get(key);
|
|
314
|
+
if (existing && getPtySession(existing.sessionId)) return existing;
|
|
315
|
+
const shell = spawnShellPty(repoPath, "column-shell");
|
|
316
|
+
columnShells.set(key, shell);
|
|
317
|
+
if (initialCommand) getPtySession(shell.sessionId)?.pty.write(`${initialCommand}\r`);
|
|
318
|
+
return shell;
|
|
319
|
+
}
|
|
320
|
+
function closeColumnShell(key) {
|
|
321
|
+
const existing = columnShells.get(key);
|
|
322
|
+
if (existing) stopPtySession(existing.sessionId);
|
|
323
|
+
columnShells.delete(key);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// src/skillsServer.ts
|
|
327
|
+
import { execFile } from "node:child_process";
|
|
328
|
+
import { homedir } from "node:os";
|
|
329
|
+
import { promisify } from "node:util";
|
|
330
|
+
var execFileAsync = promisify(execFile);
|
|
182
331
|
var IS_WINDOWS = process.platform === "win32";
|
|
183
|
-
var
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
332
|
+
var ANSI_ESCAPE_RE = /\x1b\[[0-9;]*[A-Za-z]/g;
|
|
333
|
+
function stripAnsi(text) {
|
|
334
|
+
return text.replace(ANSI_ESCAPE_RE, "");
|
|
335
|
+
}
|
|
336
|
+
var SKILLS_LIST_TIMEOUT_MS = 2e4;
|
|
337
|
+
var SKILLS_FIND_TIMEOUT_MS = 2e4;
|
|
338
|
+
var SKILLS_REMOVE_TIMEOUT_MS = 2e4;
|
|
339
|
+
var SKILLS_INSTALL_TIMEOUT_MS = 6e4;
|
|
340
|
+
var SKILLS_UPDATE_TIMEOUT_MS = 6e4;
|
|
341
|
+
var MAX_SKILLS_BODY_BYTES = 1e4;
|
|
342
|
+
var CLI_ERROR_MESSAGE_MAX_LENGTH = 2e3;
|
|
343
|
+
var mutationLocksByScope = /* @__PURE__ */ new Map();
|
|
344
|
+
function withScopeLock(scopeKey, task) {
|
|
345
|
+
const previous = mutationLocksByScope.get(scopeKey) ?? Promise.resolve();
|
|
346
|
+
const current = previous.then(task, task);
|
|
347
|
+
mutationLocksByScope.set(
|
|
348
|
+
scopeKey,
|
|
349
|
+
current.then(
|
|
350
|
+
() => void 0,
|
|
351
|
+
() => void 0
|
|
352
|
+
)
|
|
353
|
+
);
|
|
354
|
+
return current;
|
|
355
|
+
}
|
|
356
|
+
function resolveScope(query, isGlobal) {
|
|
357
|
+
if (isGlobal) {
|
|
358
|
+
if (!authenticateAgent(query)) throw new HttpError(401, "Ticket inv\xE1lido ou expirado.");
|
|
359
|
+
return { cwd: homedir(), lockKey: "global", globalFlag: ["-g"] };
|
|
196
360
|
}
|
|
197
|
-
|
|
361
|
+
const entry = authenticate(query);
|
|
362
|
+
if (!entry) throw new HttpError(401, "Ticket inv\xE1lido, expirado, ou projeto desconhecido.");
|
|
363
|
+
return { cwd: entry.repoRoot, lockKey: entry.repoRoot, globalFlag: [] };
|
|
198
364
|
}
|
|
199
|
-
function
|
|
200
|
-
|
|
365
|
+
async function runSkillsCli(args, cwd, timeoutMs) {
|
|
366
|
+
try {
|
|
367
|
+
const { stdout } = await execFileAsync("npx", ["skills", ...args], { cwd, timeout: timeoutMs, shell: IS_WINDOWS });
|
|
368
|
+
return stripAnsi(stdout);
|
|
369
|
+
} catch (err) {
|
|
370
|
+
const cliErr = err;
|
|
371
|
+
if (cliErr.killed) {
|
|
372
|
+
throw new HttpError(504, `Comando da CLI skills excedeu o tempo limite de ${Math.round(timeoutMs / 1e3)}s.`);
|
|
373
|
+
}
|
|
374
|
+
if (typeof cliErr.stderr === "string" && cliErr.stderr.trim()) {
|
|
375
|
+
throw new HttpError(400, cliErr.stderr.trim().slice(0, CLI_ERROR_MESSAGE_MAX_LENGTH));
|
|
376
|
+
}
|
|
377
|
+
throw new HttpError(500, err instanceof Error ? err.message : "Erro desconhecido ao executar a CLI skills.");
|
|
378
|
+
}
|
|
201
379
|
}
|
|
202
|
-
function
|
|
380
|
+
function sendSkillsError(res, err) {
|
|
381
|
+
if (err instanceof HttpError) {
|
|
382
|
+
sendJson(res, err.status, { error: err.message });
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
logger.runner.error("SKILLS", "Unexpected error handling a skills request:", err);
|
|
386
|
+
sendJson(res, 500, { error: "Erro interno do runner." });
|
|
387
|
+
}
|
|
388
|
+
function parseListOutput(stdout) {
|
|
389
|
+
let parsed;
|
|
203
390
|
try {
|
|
204
|
-
|
|
205
|
-
return true;
|
|
391
|
+
parsed = JSON.parse(stdout);
|
|
206
392
|
} catch {
|
|
207
|
-
|
|
393
|
+
throw new HttpError(500, "Resposta inesperada da CLI skills (list).");
|
|
394
|
+
}
|
|
395
|
+
if (!Array.isArray(parsed)) throw new HttpError(500, "Resposta inesperada da CLI skills (list).");
|
|
396
|
+
return parsed;
|
|
397
|
+
}
|
|
398
|
+
var FIND_ENTRY_RE = /^(?<package>[^\s@]+)@(?<skill>\S+)\s+(?<installs>[\d.,]+[KM]?)\s+installs?$/;
|
|
399
|
+
var FIND_URL_RE = /^└\s+(?<url>\S+)$/;
|
|
400
|
+
function parseFindOutput(stdout) {
|
|
401
|
+
const lines = stdout.split(/\r?\n/);
|
|
402
|
+
const results = [];
|
|
403
|
+
for (let i = 0; i < lines.length; i++) {
|
|
404
|
+
const match = FIND_ENTRY_RE.exec(lines[i].trim());
|
|
405
|
+
if (!match?.groups) continue;
|
|
406
|
+
const urlMatch = i + 1 < lines.length ? FIND_URL_RE.exec(lines[i + 1].trim()) : null;
|
|
407
|
+
results.push({
|
|
408
|
+
package: match.groups.package,
|
|
409
|
+
skill: match.groups.skill,
|
|
410
|
+
installs: match.groups.installs,
|
|
411
|
+
url: urlMatch?.groups?.url ?? ""
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
return results;
|
|
415
|
+
}
|
|
416
|
+
var POPULAR_SEARCH_TERMS = ["design", "test", "code", "git", "docs", "review", "data", "deploy"];
|
|
417
|
+
var POPULAR_SKILLS_LIMIT = 10;
|
|
418
|
+
var POPULAR_SKILLS_TTL_MS = 12 * 60 * 60 * 1e3;
|
|
419
|
+
function parseInstallsCount(installs) {
|
|
420
|
+
const match = /^([\d.]+)\s*([KM])?$/i.exec(installs.trim());
|
|
421
|
+
if (!match) return 0;
|
|
422
|
+
const value = Number.parseFloat(match[1]);
|
|
423
|
+
if (Number.isNaN(value)) return 0;
|
|
424
|
+
const suffix = match[2]?.toUpperCase();
|
|
425
|
+
if (suffix === "K") return value * 1e3;
|
|
426
|
+
if (suffix === "M") return value * 1e6;
|
|
427
|
+
return value;
|
|
428
|
+
}
|
|
429
|
+
var popularSkillsCache = null;
|
|
430
|
+
var popularSkillsPromise = null;
|
|
431
|
+
async function computePopularSkills(cwd) {
|
|
432
|
+
const perTerm = await Promise.all(
|
|
433
|
+
POPULAR_SEARCH_TERMS.map(
|
|
434
|
+
(term) => runSkillsCli(["find", term], cwd, SKILLS_FIND_TIMEOUT_MS).then(parseFindOutput).catch(() => [])
|
|
435
|
+
)
|
|
436
|
+
);
|
|
437
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
438
|
+
for (const entry of perTerm.flat()) {
|
|
439
|
+
const key = `${entry.package}@${entry.skill}`;
|
|
440
|
+
if (!byKey.has(key)) byKey.set(key, entry);
|
|
441
|
+
}
|
|
442
|
+
return [...byKey.values()].sort((a, b) => parseInstallsCount(b.installs) - parseInstallsCount(a.installs)).slice(0, POPULAR_SKILLS_LIMIT);
|
|
443
|
+
}
|
|
444
|
+
function getPopularSkills(cwd) {
|
|
445
|
+
if (popularSkillsCache && Date.now() - popularSkillsCache.computedAt < POPULAR_SKILLS_TTL_MS) {
|
|
446
|
+
return Promise.resolve(popularSkillsCache.entries);
|
|
447
|
+
}
|
|
448
|
+
if (!popularSkillsPromise) {
|
|
449
|
+
popularSkillsPromise = computePopularSkills(cwd).then((entries) => {
|
|
450
|
+
popularSkillsCache = { computedAt: Date.now(), entries };
|
|
451
|
+
return entries;
|
|
452
|
+
}).finally(() => {
|
|
453
|
+
popularSkillsPromise = null;
|
|
454
|
+
});
|
|
208
455
|
}
|
|
456
|
+
return popularSkillsPromise;
|
|
209
457
|
}
|
|
210
|
-
function
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
458
|
+
function parseCliResultOutput(stdout) {
|
|
459
|
+
const trimmed = stdout.trim();
|
|
460
|
+
if (!trimmed) return { output: "" };
|
|
461
|
+
try {
|
|
462
|
+
return JSON.parse(trimmed);
|
|
463
|
+
} catch {
|
|
464
|
+
return { output: trimmed };
|
|
465
|
+
}
|
|
215
466
|
}
|
|
216
|
-
async function
|
|
217
|
-
const installed = findInstalledBinary();
|
|
218
|
-
if (installed) return installed;
|
|
219
|
-
if (isExecutableFile(bundledBinaryPath)) return bundledBinaryPath;
|
|
467
|
+
async function handleSkillsList(res, query, isGlobal) {
|
|
220
468
|
try {
|
|
221
|
-
|
|
222
|
-
|
|
469
|
+
const { cwd, globalFlag } = resolveScope(query, isGlobal);
|
|
470
|
+
const stdout = await runSkillsCli(["list", "--json", ...globalFlag], cwd, SKILLS_LIST_TIMEOUT_MS);
|
|
471
|
+
sendJson(res, 200, { skills: parseListOutput(stdout) });
|
|
223
472
|
} catch (err) {
|
|
224
|
-
|
|
225
|
-
return null;
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
var lookup = null;
|
|
229
|
-
function resolveCloudflaredPath() {
|
|
230
|
-
lookup ??= locateCloudflared().then((path) => {
|
|
231
|
-
if (!path) lookup = null;
|
|
232
|
-
return path;
|
|
233
|
-
});
|
|
234
|
-
return lookup;
|
|
235
|
-
}
|
|
236
|
-
async function startQuickTunnel(label, port) {
|
|
237
|
-
const executable = await resolveCloudflaredPath();
|
|
238
|
-
if (!executable) {
|
|
239
|
-
logger.runner.error("CLOUDFLARE", `No cloudflared binary available, so ${label} cannot be exposed.`);
|
|
240
|
-
return null;
|
|
473
|
+
sendSkillsError(res, err);
|
|
241
474
|
}
|
|
242
|
-
return new Promise((resolve4) => {
|
|
243
|
-
let child2;
|
|
244
|
-
try {
|
|
245
|
-
child2 = spawn(executable, [
|
|
246
|
-
"tunnel",
|
|
247
|
-
"--url",
|
|
248
|
-
`http://localhost:${port}`,
|
|
249
|
-
"--http-host-header",
|
|
250
|
-
`localhost:${port}`
|
|
251
|
-
]);
|
|
252
|
-
} catch (err) {
|
|
253
|
-
logger.runner.error("CLOUDFLARE", `Failed to spawn cloudflared for ${label}:`, err);
|
|
254
|
-
resolve4(null);
|
|
255
|
-
return;
|
|
256
|
-
}
|
|
257
|
-
let settled = false;
|
|
258
|
-
const settle = (value) => {
|
|
259
|
-
if (settled) return;
|
|
260
|
-
settled = true;
|
|
261
|
-
clearTimeout(timer);
|
|
262
|
-
resolve4(value);
|
|
263
|
-
};
|
|
264
|
-
const timer = setTimeout(() => {
|
|
265
|
-
logger.runner.warn("CLOUDFLARE", `cloudflared for ${label} did not print a trycloudflare.com URL within ${STARTUP_TIMEOUT_MS}ms.`);
|
|
266
|
-
settle(null);
|
|
267
|
-
}, STARTUP_TIMEOUT_MS);
|
|
268
|
-
const onOutput = (chunk) => {
|
|
269
|
-
if (settled) return;
|
|
270
|
-
const match = chunk.toString("utf-8").match(TRYCLOUDFLARE_URL_RE);
|
|
271
|
-
if (match) settle({ url: match[0], child: child2 });
|
|
272
|
-
};
|
|
273
|
-
child2.stdout?.on("data", onOutput);
|
|
274
|
-
child2.stderr?.on("data", onOutput);
|
|
275
|
-
child2.on("error", (err) => {
|
|
276
|
-
logger.runner.error("CLOUDFLARE", `cloudflared process error for ${label} (${executable}):`, err);
|
|
277
|
-
settle(null);
|
|
278
|
-
});
|
|
279
|
-
child2.on("exit", (code) => {
|
|
280
|
-
logger.runner.info("CLOUDFLARE", `cloudflared tunnel for ${label} exited (code ${code}).`);
|
|
281
|
-
settle(null);
|
|
282
|
-
});
|
|
283
|
-
});
|
|
284
|
-
}
|
|
285
|
-
function toTunnelHostname(url) {
|
|
286
|
-
return new URL(url).host;
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
// src/devServer.ts
|
|
290
|
-
var STARTUP_TIMEOUT_MS2 = 45e3;
|
|
291
|
-
var IS_WINDOWS2 = process.platform === "win32";
|
|
292
|
-
var LOCAL_URL_PORT_RE = /https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\]|0\.0\.0\.0):(\d{2,5})/i;
|
|
293
|
-
var GENERIC_PORT_RE = /(?:listening|running|server).{0,40}?:?\s*(\d{4,5})\b/i;
|
|
294
|
-
var ANSI_ESCAPE_RE = /\x1b\[[0-9;]*m/g;
|
|
295
|
-
function detectPortFromOutput(chunk) {
|
|
296
|
-
const clean = chunk.replace(ANSI_ESCAPE_RE, "");
|
|
297
|
-
const match = LOCAL_URL_PORT_RE.exec(clean) ?? GENERIC_PORT_RE.exec(clean);
|
|
298
|
-
return match ? Number(match[1]) : null;
|
|
299
475
|
}
|
|
300
|
-
function
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
476
|
+
async function handleSkillsFind(res, query, isGlobal) {
|
|
477
|
+
try {
|
|
478
|
+
const { cwd } = resolveScope(query, isGlobal);
|
|
479
|
+
const q = typeof query.q === "string" ? query.q : "";
|
|
480
|
+
const owner = typeof query.owner === "string" ? query.owner : "";
|
|
481
|
+
const args = ["find"];
|
|
482
|
+
if (q) args.push(q);
|
|
483
|
+
if (owner) args.push("--owner", owner);
|
|
484
|
+
const stdout = await runSkillsCli(args, cwd, SKILLS_FIND_TIMEOUT_MS);
|
|
485
|
+
sendJson(res, 200, { results: parseFindOutput(stdout) });
|
|
486
|
+
} catch (err) {
|
|
487
|
+
sendSkillsError(res, err);
|
|
306
488
|
}
|
|
307
489
|
}
|
|
308
|
-
|
|
309
|
-
function ensurePreview(key, label, cwd, command, cardId) {
|
|
310
|
-
const existing = previews.get(key);
|
|
311
|
-
if (existing) {
|
|
312
|
-
existing.cardIds.add(cardId);
|
|
313
|
-
return existing.result;
|
|
314
|
-
}
|
|
315
|
-
let child2;
|
|
490
|
+
async function handleSkillsPopular(res, query, isGlobal) {
|
|
316
491
|
try {
|
|
317
|
-
|
|
492
|
+
const { cwd } = resolveScope(query, isGlobal);
|
|
493
|
+
const entries = await getPopularSkills(cwd);
|
|
494
|
+
sendJson(res, 200, { results: entries });
|
|
318
495
|
} catch (err) {
|
|
319
|
-
|
|
320
|
-
return Promise.resolve(null);
|
|
496
|
+
sendSkillsError(res, err);
|
|
321
497
|
}
|
|
322
|
-
const entry = { child: child2, tunnelChild: null, cardIds: /* @__PURE__ */ new Set([cardId]), result: null };
|
|
323
|
-
entry.result = new Promise((resolve4) => {
|
|
324
|
-
let settled = false;
|
|
325
|
-
const settle = (value) => {
|
|
326
|
-
if (settled) return;
|
|
327
|
-
settled = true;
|
|
328
|
-
clearTimeout(timer);
|
|
329
|
-
if (!value) {
|
|
330
|
-
killProcessTree(child2);
|
|
331
|
-
previews.delete(key);
|
|
332
|
-
}
|
|
333
|
-
resolve4(value);
|
|
334
|
-
};
|
|
335
|
-
const timer = setTimeout(() => {
|
|
336
|
-
logger.runner.warn("DEV_SERVER", `"${command}" in ${cwd} did not reveal a port within ${STARTUP_TIMEOUT_MS2}ms.`);
|
|
337
|
-
settle(null);
|
|
338
|
-
}, STARTUP_TIMEOUT_MS2);
|
|
339
|
-
const onOutput = async (chunk) => {
|
|
340
|
-
if (settled) return;
|
|
341
|
-
const port = detectPortFromOutput(chunk.toString("utf-8"));
|
|
342
|
-
if (port == null) return;
|
|
343
|
-
settled = true;
|
|
344
|
-
clearTimeout(timer);
|
|
345
|
-
const tunnel = await startQuickTunnel(`dev server ${key}`, port);
|
|
346
|
-
if (!tunnel) {
|
|
347
|
-
logger.runner.warn("DEV_SERVER", `Detected port ${port} for ${key} but cloudflared could not tunnel it.`);
|
|
348
|
-
killProcessTree(child2);
|
|
349
|
-
previews.delete(key);
|
|
350
|
-
resolve4(null);
|
|
351
|
-
return;
|
|
352
|
-
}
|
|
353
|
-
entry.tunnelChild = tunnel.child;
|
|
354
|
-
logger.runner.info("DEV_SERVER", `${key} is up on port ${port}, tunneled at ${tunnel.url}.`);
|
|
355
|
-
resolve4({ label, url: tunnel.url });
|
|
356
|
-
};
|
|
357
|
-
child2.stdout?.on("data", onOutput);
|
|
358
|
-
child2.stderr?.on("data", onOutput);
|
|
359
|
-
child2.on("error", (err) => {
|
|
360
|
-
logger.runner.error("DEV_SERVER", `Dev server process error for ${key} (${command} in ${cwd}):`, err);
|
|
361
|
-
settle(null);
|
|
362
|
-
});
|
|
363
|
-
child2.on("exit", (code) => {
|
|
364
|
-
logger.runner.info("DEV_SERVER", `Dev server for ${key} exited (code ${code}) before/without a usable preview.`);
|
|
365
|
-
settle(null);
|
|
366
|
-
});
|
|
367
|
-
});
|
|
368
|
-
previews.set(key, entry);
|
|
369
|
-
return entry.result;
|
|
370
498
|
}
|
|
371
|
-
function
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
499
|
+
async function handleSkillsInstall(req, res, query, isGlobal) {
|
|
500
|
+
try {
|
|
501
|
+
const { cwd, lockKey, globalFlag } = resolveScope(query, isGlobal);
|
|
502
|
+
const raw = await readRequestBody(req, MAX_SKILLS_BODY_BYTES);
|
|
503
|
+
let body;
|
|
504
|
+
try {
|
|
505
|
+
body = JSON.parse(raw);
|
|
506
|
+
} catch {
|
|
507
|
+
throw new HttpError(400, "Corpo inv\xE1lido.");
|
|
508
|
+
}
|
|
509
|
+
if (!body.package || typeof body.package !== "string") throw new HttpError(400, "Campo package \xE9 obrigat\xF3rio.");
|
|
510
|
+
const args = ["add", body.package];
|
|
511
|
+
if (body.skill) args.push("-s", body.skill);
|
|
512
|
+
if (body.agent) args.push("-a", body.agent);
|
|
513
|
+
args.push(...globalFlag, "-y", "--json");
|
|
514
|
+
const stdout = await withScopeLock(lockKey, () => runSkillsCli(args, cwd, SKILLS_INSTALL_TIMEOUT_MS));
|
|
515
|
+
sendJson(res, 200, { result: parseCliResultOutput(stdout) });
|
|
516
|
+
} catch (err) {
|
|
517
|
+
sendSkillsError(res, err);
|
|
378
518
|
}
|
|
379
519
|
}
|
|
380
|
-
function
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
520
|
+
async function handleSkillsRemove(req, res, query, isGlobal) {
|
|
521
|
+
try {
|
|
522
|
+
const { cwd, lockKey, globalFlag } = resolveScope(query, isGlobal);
|
|
523
|
+
const raw = await readRequestBody(req, MAX_SKILLS_BODY_BYTES);
|
|
524
|
+
let body;
|
|
525
|
+
try {
|
|
526
|
+
body = JSON.parse(raw);
|
|
527
|
+
} catch {
|
|
528
|
+
throw new HttpError(400, "Corpo inv\xE1lido.");
|
|
529
|
+
}
|
|
530
|
+
if (!body.name || typeof body.name !== "string") throw new HttpError(400, "Campo name \xE9 obrigat\xF3rio.");
|
|
531
|
+
const args = ["remove", body.name, ...globalFlag, "--json"];
|
|
532
|
+
const stdout = await withScopeLock(lockKey, () => runSkillsCli(args, cwd, SKILLS_REMOVE_TIMEOUT_MS));
|
|
533
|
+
sendJson(res, 200, { result: parseCliResultOutput(stdout) });
|
|
534
|
+
} catch (err) {
|
|
535
|
+
sendSkillsError(res, err);
|
|
387
536
|
}
|
|
388
537
|
}
|
|
389
|
-
function
|
|
390
|
-
|
|
391
|
-
const
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
538
|
+
async function handleSkillsUpdate(req, res, query, isGlobal) {
|
|
539
|
+
try {
|
|
540
|
+
const { cwd, lockKey, globalFlag } = resolveScope(query, isGlobal);
|
|
541
|
+
const raw = await readRequestBody(req, MAX_SKILLS_BODY_BYTES);
|
|
542
|
+
let body = {};
|
|
543
|
+
if (raw.trim()) {
|
|
544
|
+
try {
|
|
545
|
+
body = JSON.parse(raw);
|
|
546
|
+
} catch {
|
|
547
|
+
throw new HttpError(400, "Corpo inv\xE1lido.");
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
const names = Array.isArray(body.names) ? body.names.filter((name) => typeof name === "string" && name.length > 0) : [];
|
|
551
|
+
const args = ["update", ...names, ...globalFlag, "-y", "--json"];
|
|
552
|
+
const stdout = await withScopeLock(lockKey, () => runSkillsCli(args, cwd, SKILLS_UPDATE_TIMEOUT_MS));
|
|
553
|
+
sendJson(res, 200, { result: parseCliResultOutput(stdout) });
|
|
554
|
+
} catch (err) {
|
|
555
|
+
sendSkillsError(res, err);
|
|
396
556
|
}
|
|
397
557
|
}
|
|
398
558
|
|
|
399
|
-
// src/
|
|
400
|
-
|
|
401
|
-
import { promisify } from "node:util";
|
|
402
|
-
var execFileAsync = promisify(execFile);
|
|
403
|
-
async function pushBranchToMain(repoPath) {
|
|
559
|
+
// src/activityLog.ts
|
|
560
|
+
async function logActivity(supabase, params) {
|
|
404
561
|
try {
|
|
405
|
-
|
|
406
|
-
|
|
562
|
+
const { error } = await supabase.from("activity_log").insert({
|
|
563
|
+
project_id: params.projectId,
|
|
564
|
+
card_id: params.cardId,
|
|
565
|
+
user_id: params.userId,
|
|
566
|
+
event_type: params.eventType,
|
|
567
|
+
payload: params.payload ?? {}
|
|
568
|
+
});
|
|
569
|
+
if (error) throw error;
|
|
407
570
|
} catch (err) {
|
|
408
|
-
|
|
409
|
-
const error = stderr?.trim() || (err instanceof Error ? err.message : String(err));
|
|
410
|
-
return { ok: false, error };
|
|
571
|
+
logger.runner.error("ACTIVITY", `Failed to record activity log entry (${params.eventType}):`, err);
|
|
411
572
|
}
|
|
412
573
|
}
|
|
413
574
|
|
|
414
|
-
// src/
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
var IDLE_AFTER_MS = 45e3;
|
|
420
|
-
var EVAL_INTERVAL_MS = 2e3;
|
|
421
|
-
var ANSI_PATTERN = /\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
|
|
422
|
-
var BOX_DRAWING_PATTERN = /[─-╿]/g;
|
|
423
|
-
var WORKING_PATTERN = /esc to interrupt/i;
|
|
424
|
-
var USAGE_LIMIT_PATTERN = /(usage limit reached|limit reached[^\n]{0,60}resets|reached your (?:usage|session) limit|out of (?:weekly|session) (?:usage|limit))/i;
|
|
425
|
-
var PLAN_APPROVAL_PATTERN = /would you like to proceed\??/i;
|
|
426
|
-
var USER_PROMPT_PATTERNS = [
|
|
427
|
-
/do you want to\b/i,
|
|
428
|
-
/would you like to\b/i,
|
|
429
|
-
/do you trust the files\b/i,
|
|
430
|
-
/press enter to continue/i,
|
|
431
|
-
/\(y\/n\)/i,
|
|
432
|
-
/❯\s*1\.\s/
|
|
433
|
-
];
|
|
434
|
-
function normalizeTerminalOutput(raw) {
|
|
435
|
-
return raw.replace(ANSI_PATTERN, "").replace(BOX_DRAWING_PATTERN, " ").replace(/[ \t]+/g, " ");
|
|
575
|
+
// src/sessions.ts
|
|
576
|
+
async function countRunningSessions(supabase, projectId, ownerUserId) {
|
|
577
|
+
const { count, error } = await supabase.from("sessions").select("id, cards!card_id!inner(project_id, assigned_user_id)", { count: "exact", head: true }).eq("cards.project_id", projectId).eq("cards.assigned_user_id", ownerUserId).eq("status", "running");
|
|
578
|
+
if (error) throw error;
|
|
579
|
+
return count ?? 0;
|
|
436
580
|
}
|
|
437
|
-
function
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
if (
|
|
442
|
-
|
|
443
|
-
return options.previous;
|
|
581
|
+
async function listQueuedSessionsForRunner(supabase, ownerUserId) {
|
|
582
|
+
const { data, error } = await supabase.from("sessions").select(
|
|
583
|
+
"id, card_id, claude_session_id, agent_state, created_at, cards!card_id!inner(project_id, assigned_user_id, title, instructions, file_references, plan_first)"
|
|
584
|
+
).eq("cards.assigned_user_id", ownerUserId).eq("status", "queued").order("created_at", { ascending: true });
|
|
585
|
+
if (error) throw error;
|
|
586
|
+
return data;
|
|
444
587
|
}
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
588
|
+
async function createSession(supabase, userId, cardId, resumeClaudeSessionId) {
|
|
589
|
+
const { data, error } = await supabase.from("sessions").insert({
|
|
590
|
+
card_id: cardId,
|
|
591
|
+
user_id: userId,
|
|
592
|
+
status: "queued",
|
|
593
|
+
// Pre-set so spawnSessionPty takes the resume path (see its doc comment) instead of
|
|
594
|
+
// starting a blank conversation and resending the card's instructions.
|
|
595
|
+
...resumeClaudeSessionId ? { claude_session_id: resumeClaudeSessionId } : {}
|
|
596
|
+
}).select("id").single();
|
|
597
|
+
if (error) throw error;
|
|
598
|
+
return data.id;
|
|
599
|
+
}
|
|
600
|
+
async function updateSession(supabase, sessionId, changes, activityContext) {
|
|
601
|
+
const { error } = await supabase.from("sessions").update({
|
|
602
|
+
...changes.status !== void 0 ? { status: changes.status } : {},
|
|
603
|
+
...changes.agentState !== void 0 ? { agent_state: changes.agentState } : {},
|
|
604
|
+
...changes.startedAt !== void 0 ? { started_at: changes.startedAt } : {},
|
|
605
|
+
...changes.endedAt !== void 0 ? { ended_at: changes.endedAt } : {},
|
|
606
|
+
...changes.terminalTicket !== void 0 ? { terminal_ticket: changes.terminalTicket } : {},
|
|
607
|
+
...changes.terminalTicketExpiresAt !== void 0 ? { terminal_ticket_expires_at: changes.terminalTicketExpiresAt } : {},
|
|
608
|
+
...changes.claudeSessionId !== void 0 ? { claude_session_id: changes.claudeSessionId } : {}
|
|
609
|
+
}).eq("id", sessionId);
|
|
610
|
+
if (error) throw error;
|
|
611
|
+
if (changes.status !== void 0 && activityContext) {
|
|
612
|
+
await logActivity(supabase, {
|
|
613
|
+
projectId: activityContext.projectId,
|
|
614
|
+
cardId: activityContext.cardId,
|
|
615
|
+
userId: activityContext.userId,
|
|
616
|
+
eventType: "session_status_changed",
|
|
617
|
+
payload: { status: changes.status }
|
|
618
|
+
});
|
|
472
619
|
}
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
620
|
+
}
|
|
621
|
+
async function getActiveSessionForCard(supabase, cardId) {
|
|
622
|
+
const { data, error } = await supabase.from("sessions").select("id, status").eq("card_id", cardId).in("status", ["queued", "running", "waiting_input", "paused"]).order("created_at", { ascending: false }).limit(1).maybeSingle();
|
|
623
|
+
if (error) throw error;
|
|
624
|
+
return data;
|
|
625
|
+
}
|
|
626
|
+
async function pauseRunningSession(supabase, sessionId, activityContext) {
|
|
627
|
+
const { data, error } = await supabase.from("sessions").update({ status: "paused" }).eq("id", sessionId).eq("status", "running").select("id");
|
|
628
|
+
if (error) throw error;
|
|
629
|
+
const applied = (data ?? []).length > 0;
|
|
630
|
+
if (applied) {
|
|
631
|
+
await logActivity(supabase, {
|
|
632
|
+
projectId: activityContext.projectId,
|
|
633
|
+
cardId: activityContext.cardId,
|
|
634
|
+
userId: activityContext.userId,
|
|
635
|
+
eventType: "session_status_changed",
|
|
636
|
+
payload: { status: "paused" }
|
|
480
637
|
});
|
|
481
|
-
if (next === this.state) return;
|
|
482
|
-
this.state = next;
|
|
483
|
-
this.onChange(next);
|
|
484
638
|
}
|
|
485
|
-
|
|
639
|
+
return applied;
|
|
640
|
+
}
|
|
641
|
+
async function getLastClaudeSessionIdForCard(supabase, cardId) {
|
|
642
|
+
const { data, error } = await supabase.from("sessions").select("claude_session_id").eq("card_id", cardId).order("created_at", { ascending: false }).limit(1).maybeSingle();
|
|
643
|
+
if (error) throw error;
|
|
644
|
+
return data?.claude_session_id ?? null;
|
|
645
|
+
}
|
|
646
|
+
async function updateCard(supabase, cardId, changes) {
|
|
647
|
+
const { error } = await supabase.from("cards").update({
|
|
648
|
+
...changes.column !== void 0 ? { column_name: changes.column } : {},
|
|
649
|
+
...changes.previewUrls !== void 0 ? { preview_urls: changes.previewUrls } : {},
|
|
650
|
+
...changes.deployError !== void 0 ? { deploy_error: changes.deployError } : {},
|
|
651
|
+
...changes.customActionStatus !== void 0 ? { custom_action_status: changes.customActionStatus } : {},
|
|
652
|
+
...changes.customActionOutput !== void 0 ? { custom_action_output: changes.customActionOutput } : {},
|
|
653
|
+
...changes.requeueRequestedAt !== void 0 ? { requeue_requested_at: changes.requeueRequestedAt } : {}
|
|
654
|
+
}).eq("id", cardId);
|
|
655
|
+
if (error) throw error;
|
|
656
|
+
}
|
|
657
|
+
async function getProject(supabase, projectId) {
|
|
658
|
+
const { data, error } = await supabase.from("projects").select("*").eq("id", projectId).single();
|
|
659
|
+
if (error) throw error;
|
|
660
|
+
return data;
|
|
661
|
+
}
|
|
662
|
+
async function getProjectRepos(supabase, projectId) {
|
|
663
|
+
const { data, error } = await supabase.from("project_repos").select("subpath, dev_command, column_actions").eq("project_id", projectId);
|
|
664
|
+
if (error) throw error;
|
|
665
|
+
return data;
|
|
666
|
+
}
|
|
667
|
+
async function listCardsInColumn(supabase, projectId, column) {
|
|
668
|
+
const { data, error } = await supabase.from("cards").select(
|
|
669
|
+
"id, project_id, title, instructions, column_name, assigned_user_id, deploy_error, file_references, preview_urls, custom_action_status, requeue_requested_at"
|
|
670
|
+
).eq("project_id", projectId).eq("column_name", column);
|
|
671
|
+
if (error) throw error;
|
|
672
|
+
return data;
|
|
673
|
+
}
|
|
674
|
+
async function clearProjectDeployRequest(supabase, projectId) {
|
|
675
|
+
const { error } = await supabase.from("projects").update({ deploy_requested_at: null }).eq("id", projectId);
|
|
676
|
+
if (error) throw error;
|
|
677
|
+
}
|
|
678
|
+
async function clearDevServerRestartRequest(supabase, projectId) {
|
|
679
|
+
const { error } = await supabase.from("projects").update({ dev_server_restart_requested_at: null }).eq("id", projectId);
|
|
680
|
+
if (error) throw error;
|
|
681
|
+
}
|
|
682
|
+
async function clearDeployActionRerunRequest(supabase, projectId) {
|
|
683
|
+
const { error } = await supabase.from("projects").update({ deploy_action_rerun_requested_at: null }).eq("id", projectId);
|
|
684
|
+
if (error) throw error;
|
|
685
|
+
}
|
|
686
|
+
async function updateProjectFilesTicket(supabase, projectId, ticket, expiresAt) {
|
|
687
|
+
const { error } = await supabase.from("projects").update({ files_ticket: ticket, files_ticket_expires_at: expiresAt }).eq("id", projectId);
|
|
688
|
+
if (error) throw error;
|
|
689
|
+
}
|
|
690
|
+
async function updateRunnerSetupTicket(supabase, runnerId, ticket, expiresAt, allowedRootPath) {
|
|
691
|
+
const { error } = await supabase.from("runners").update({ setup_ticket: ticket, setup_ticket_expires_at: expiresAt, allowed_root_path: allowedRootPath }).eq("id", runnerId);
|
|
692
|
+
if (error) throw error;
|
|
693
|
+
}
|
|
694
|
+
async function updateRunnerAgentTicket(supabase, runnerId, ticket, expiresAt) {
|
|
695
|
+
const { error } = await supabase.from("runners").update({ agent_ticket: ticket, agent_ticket_expires_at: expiresAt }).eq("id", runnerId);
|
|
696
|
+
if (error) throw error;
|
|
697
|
+
}
|
|
698
|
+
async function listMyProjects(supabase) {
|
|
699
|
+
const { data, error } = await supabase.from("projects").select("id, runner_id, repo_path, logo_path");
|
|
700
|
+
if (error) throw error;
|
|
701
|
+
return data;
|
|
702
|
+
}
|
|
703
|
+
async function listProjectRunnerIds(supabase, runnerId) {
|
|
704
|
+
const { data, error } = await supabase.from("project_runners").select("project_id").eq("runner_id", runnerId);
|
|
705
|
+
if (error) throw error;
|
|
706
|
+
return new Set(data.map((row) => row.project_id));
|
|
707
|
+
}
|
|
708
|
+
async function addProjectRunner(supabase, projectId, runnerId) {
|
|
709
|
+
const { error } = await supabase.from("project_runners").insert({ project_id: projectId, runner_id: runnerId });
|
|
710
|
+
if (error) throw error;
|
|
711
|
+
}
|
|
712
|
+
async function getRunnerMaxConcurrentProjects(supabase, runnerId) {
|
|
713
|
+
const { data, error } = await supabase.from("runners").select("max_concurrent_projects").eq("id", runnerId).single();
|
|
714
|
+
if (error) throw error;
|
|
715
|
+
return data.max_concurrent_projects;
|
|
716
|
+
}
|
|
717
|
+
async function updateProjectLogoPath(supabase, projectId, logoPath) {
|
|
718
|
+
const { error } = await supabase.from("projects").update({ logo_path: logoPath }).eq("id", projectId);
|
|
719
|
+
if (error) throw error;
|
|
720
|
+
}
|
|
721
|
+
async function upsertColumnShell(supabase, projectId, column, kind, sessionId, ticket) {
|
|
722
|
+
const { error } = await supabase.from("column_shells").upsert(
|
|
723
|
+
{ project_id: projectId, column_name: column, kind, session_id: sessionId, ticket },
|
|
724
|
+
{ onConflict: "project_id,column_name,kind" }
|
|
725
|
+
);
|
|
726
|
+
if (error) throw error;
|
|
727
|
+
}
|
|
728
|
+
async function deleteColumnShell(supabase, projectId, column, kind) {
|
|
729
|
+
const { error } = await supabase.from("column_shells").delete().eq("project_id", projectId).eq("column_name", column).eq("kind", kind);
|
|
730
|
+
if (error) throw error;
|
|
731
|
+
}
|
|
732
|
+
async function getClaudeUsage(supabase, userId) {
|
|
733
|
+
const { data, error } = await supabase.from("claude_usage").select("session_resets_at, week_resets_at").eq("user_id", userId).maybeSingle();
|
|
734
|
+
if (error) throw error;
|
|
735
|
+
if (!data) return null;
|
|
736
|
+
return { sessionResetsAt: data.session_resets_at, weekResetsAt: data.week_resets_at };
|
|
737
|
+
}
|
|
738
|
+
async function upsertClaudeUsage(supabase, userId, changes) {
|
|
739
|
+
const { error } = await supabase.from("claude_usage").upsert({
|
|
740
|
+
user_id: userId,
|
|
741
|
+
...changes.sessionPercent !== void 0 ? { session_pct: changes.sessionPercent } : {},
|
|
742
|
+
...changes.sessionResetsAt !== void 0 ? { session_resets_at: changes.sessionResetsAt } : {},
|
|
743
|
+
...changes.weekPercent !== void 0 ? { week_pct: changes.weekPercent } : {},
|
|
744
|
+
...changes.weekResetsAt !== void 0 ? { week_resets_at: changes.weekResetsAt } : {},
|
|
745
|
+
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
746
|
+
});
|
|
747
|
+
if (error) throw error;
|
|
748
|
+
}
|
|
486
749
|
|
|
487
|
-
// src/
|
|
488
|
-
import { randomUUID } from "node:crypto";
|
|
489
|
-
import {
|
|
750
|
+
// src/usageMonitor.ts
|
|
751
|
+
import { randomUUID as randomUUID3 } from "node:crypto";
|
|
752
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync as writeFileSync4 } from "node:fs";
|
|
753
|
+
import { homedir as homedir2, tmpdir as tmpdir5 } from "node:os";
|
|
754
|
+
import { join as join5 } from "node:path";
|
|
755
|
+
import { spawn as spawn2 } from "node-pty";
|
|
490
756
|
|
|
491
|
-
// src/
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
757
|
+
// src/agentMomentHookScript.ts
|
|
758
|
+
import { writeFileSync } from "node:fs";
|
|
759
|
+
import { tmpdir as tmpdir2 } from "node:os";
|
|
760
|
+
import { join as join2 } from "node:path";
|
|
761
|
+
var AGENT_MOMENT_HOOK_SCRIPT_PATH = join2(tmpdir2(), "agent-board-agent-moment-hook.cjs");
|
|
762
|
+
var SCRIPT_SOURCE = `#!/usr/bin/env node
|
|
763
|
+
"use strict";
|
|
764
|
+
const http = require("node:http");
|
|
765
|
+
|
|
766
|
+
function readStdin() {
|
|
767
|
+
return new Promise((resolve) => {
|
|
768
|
+
let raw = "";
|
|
769
|
+
process.stdin.setEncoding("utf8");
|
|
770
|
+
process.stdin.on("data", (chunk) => { raw += chunk; });
|
|
771
|
+
process.stdin.on("end", () => resolve(raw));
|
|
772
|
+
process.stdin.on("error", () => resolve(raw));
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// Mirrors agentActivity.ts's COMMIT_SUCCESS_PATTERN (git's own success banner, e.g.
|
|
777
|
+
// "[main 1a2b3c4] fix: ..."), checked against the tool_response Claude Code hands back for a
|
|
778
|
+
// Bash call rather than the raw scrollback \u2014 the banner can't have scrolled out of a sliding
|
|
779
|
+
// text window if it's read straight off the command's own result.
|
|
780
|
+
const COMMIT_SUCCESS_PATTERN = /\\[\\S+\\s+[0-9a-f]{7,40}\\]/;
|
|
781
|
+
const GIT_COMMIT_COMMAND_PATTERN = /\\bgit\\b[^\\n]*\\bcommit\\b/i;
|
|
782
|
+
|
|
783
|
+
// Which "moment" (if any) this hook invocation reports, from the single JSON payload Claude
|
|
784
|
+
// Code gives a hook on stdin. Returning null means "nothing conclusive" \u2014 the hook exits
|
|
785
|
+
// quietly and the runner's text-based fallback (agentActivity.ts) stays in charge.
|
|
786
|
+
function detectMoment(input) {
|
|
787
|
+
const eventName = input.hook_event_name;
|
|
788
|
+
|
|
789
|
+
// A new turn actually starting \u2014 whether typed by a human in the terminal or submitted
|
|
790
|
+
// programmatically (the card's opening instructions, or the auto "continue" after a
|
|
791
|
+
// usage-limit wait \u2014 see agentSession.ts).
|
|
792
|
+
if (eventName === "UserPromptSubmit") return "implementing";
|
|
793
|
+
|
|
794
|
+
// Claude Code's own exit-plan-mode tool call: this is the precise instant it stops to ask
|
|
795
|
+
// "would you like to proceed?", before the CLI even renders the prompt.
|
|
796
|
+
if (eventName === "PreToolUse" && input.tool_name === "ExitPlanMode") return "waiting_plan_approval";
|
|
797
|
+
|
|
798
|
+
// The plan prompt resolving one way or the other: approved, the turn resumes implementing
|
|
799
|
+
// right away with no new UserPromptSubmit to catch it; asked to revise, a fresh
|
|
800
|
+
// UserPromptSubmit follows almost immediately anyway. Either way "implementing" is correct
|
|
801
|
+
// or self-corrects within a beat \u2014 never gets stuck on "waiting_plan_approval" forever.
|
|
802
|
+
if (eventName === "PostToolUse" && input.tool_name === "ExitPlanMode") return "implementing";
|
|
803
|
+
|
|
804
|
+
// A commit that actually succeeded, read off the Bash tool's own structured result instead
|
|
805
|
+
// of guessing from scrollback text (exact, and immune to locale/color/format changes to
|
|
806
|
+
// git's own banner).
|
|
807
|
+
if (eventName === "PostToolUse" && input.tool_name === "Bash") {
|
|
808
|
+
const command = String((input.tool_input && input.tool_input.command) || "");
|
|
809
|
+
if (!GIT_COMMIT_COMMAND_PATTERN.test(command)) return null;
|
|
810
|
+
const response = JSON.stringify(input.tool_response || "");
|
|
811
|
+
return COMMIT_SUCCESS_PATTERN.test(response) ? "commit_detected" : null;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// The turn actually ending \u2014 Claude Code back at its input box, no idle timer needed.
|
|
815
|
+
if (eventName === "Stop") return "turn_stopped";
|
|
816
|
+
|
|
817
|
+
// Claude Code's own notice that it's waiting on the user (permission prompts under normal
|
|
818
|
+
// permission modes, or its 60s-idle nudge) \u2014 a generic fallback for whatever specific
|
|
819
|
+
// prompt isn't one of the moments above.
|
|
820
|
+
if (eventName === "Notification") return "notification";
|
|
821
|
+
|
|
822
|
+
return null;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function reportToRunner(sessionId, ticket, port, moment) {
|
|
826
|
+
return new Promise((resolve) => {
|
|
827
|
+
try {
|
|
828
|
+
const body = JSON.stringify({ sessionId, ticket, moment });
|
|
829
|
+
const req = http.request(
|
|
830
|
+
{
|
|
831
|
+
host: "127.0.0.1",
|
|
832
|
+
port,
|
|
833
|
+
path: "/agent-hook",
|
|
834
|
+
method: "POST",
|
|
835
|
+
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) },
|
|
836
|
+
timeout: 1000,
|
|
837
|
+
},
|
|
838
|
+
(res) => {
|
|
839
|
+
res.resume();
|
|
840
|
+
resolve();
|
|
841
|
+
},
|
|
842
|
+
);
|
|
843
|
+
req.on("error", () => resolve());
|
|
844
|
+
req.on("timeout", () => { req.destroy(); resolve(); });
|
|
845
|
+
req.end(body);
|
|
846
|
+
} catch {
|
|
847
|
+
resolve();
|
|
848
|
+
}
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
(async () => {
|
|
853
|
+
try {
|
|
854
|
+
const input = JSON.parse(await readStdin());
|
|
855
|
+
const moment = detectMoment(input);
|
|
856
|
+
if (moment) {
|
|
857
|
+
const sessionId = process.env.AGENT_BOARD_SESSION_ID;
|
|
858
|
+
const ticket = process.env.AGENT_BOARD_STATUSLINE_TICKET;
|
|
859
|
+
const port = Number(process.env.AGENT_BOARD_STATUSLINE_PORT);
|
|
860
|
+
if (sessionId && ticket && port) await reportToRunner(sessionId, ticket, port, moment);
|
|
861
|
+
}
|
|
862
|
+
} catch {
|
|
863
|
+
// A malformed/unexpected hook payload must never block the agent's own turn.
|
|
864
|
+
}
|
|
865
|
+
process.exit(0);
|
|
866
|
+
})();
|
|
867
|
+
`;
|
|
868
|
+
function ensureAgentMomentHookScriptWritten() {
|
|
869
|
+
writeFileSync(AGENT_MOMENT_HOOK_SCRIPT_PATH, SCRIPT_SOURCE, { mode: 493 });
|
|
870
|
+
logger.runner.info("AGENT-MOMENT-HOOK", `Agent-moment hook script written to ${AGENT_MOMENT_HOOK_SCRIPT_PATH}.`);
|
|
871
|
+
}
|
|
872
|
+
function agentMomentHookSettingsFragment() {
|
|
873
|
+
const hookEntry = {
|
|
874
|
+
type: "command",
|
|
875
|
+
command: `${JSON.stringify(process.execPath)} ${JSON.stringify(AGENT_MOMENT_HOOK_SCRIPT_PATH)}`
|
|
876
|
+
};
|
|
877
|
+
return {
|
|
878
|
+
hooks: {
|
|
879
|
+
UserPromptSubmit: [{ hooks: [hookEntry] }],
|
|
880
|
+
PreToolUse: [{ matcher: "ExitPlanMode", hooks: [hookEntry] }],
|
|
881
|
+
PostToolUse: [
|
|
882
|
+
{ matcher: "ExitPlanMode", hooks: [hookEntry] },
|
|
883
|
+
{ matcher: "Bash", hooks: [hookEntry] }
|
|
884
|
+
],
|
|
885
|
+
Stop: [{ hooks: [hookEntry] }],
|
|
886
|
+
Notification: [{ hooks: [hookEntry] }]
|
|
887
|
+
}
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// src/safetyHookScript.ts
|
|
892
|
+
import { writeFileSync as writeFileSync2 } from "node:fs";
|
|
893
|
+
import { tmpdir as tmpdir3 } from "node:os";
|
|
894
|
+
import { join as join3 } from "node:path";
|
|
895
|
+
var SAFETY_HOOK_SCRIPT_PATH = join3(tmpdir3(), "agent-board-safety-hook.cjs");
|
|
896
|
+
var SCRIPT_SOURCE2 = `#!/usr/bin/env node
|
|
897
|
+
"use strict";
|
|
898
|
+
|
|
899
|
+
function readStdin() {
|
|
900
|
+
return new Promise((resolve) => {
|
|
901
|
+
let raw = "";
|
|
902
|
+
process.stdin.setEncoding("utf8");
|
|
903
|
+
process.stdin.on("data", (chunk) => { raw += chunk; });
|
|
904
|
+
process.stdin.on("end", () => resolve(raw));
|
|
905
|
+
process.stdin.on("error", () => resolve(raw));
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
const PROTECTED_NAMES = ["node", "claude", "pm2", "cloudflared"];
|
|
910
|
+
|
|
911
|
+
// Each one matches a construct that kills by process name/pattern rather than by PID.
|
|
912
|
+
const KILL_BY_NAME_CONSTRUCTS = [
|
|
913
|
+
/\\btaskkill\\b[^\\n]*\\/im\\b/i, // Windows: taskkill ... /IM <name>
|
|
914
|
+
/\\bstop-process\\b[^\\n]*-(?:process)?name\\b/i, // PowerShell: Stop-Process -Name/-ProcessName <name>
|
|
915
|
+
/\\bget-process\\b[^\\n]*\\|[^\\n]*\\bstop-process\\b/i, // PowerShell: Get-Process <name> | Stop-Process
|
|
916
|
+
/\\bwmic\\b[^\\n]*\\bprocess\\b[^\\n]*\\bcall\\s+terminate\\b/i, // wmic process where name='<name>' call terminate
|
|
917
|
+
/\\b(?:pkill|killall)\\b/i, // POSIX: pkill/killall <name or -f pattern>
|
|
918
|
+
];
|
|
919
|
+
|
|
920
|
+
function mentionsProtectedName(command) {
|
|
921
|
+
return PROTECTED_NAMES.some((name) => new RegExp(\`\\\\b\${name}(?:\\\\.exe)?\\\\b\`, "i").test(command));
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
function findViolation(command) {
|
|
925
|
+
if (!mentionsProtectedName(command)) return null;
|
|
926
|
+
const matched = KILL_BY_NAME_CONSTRUCTS.find((pattern) => pattern.test(command));
|
|
927
|
+
return matched ? PROTECTED_NAMES.find((name) => new RegExp(\`\\\\b\${name}(?:\\\\.exe)?\\\\b\`, "i").test(command)) : null;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
(async () => {
|
|
931
|
+
try {
|
|
932
|
+
const input = JSON.parse(await readStdin());
|
|
933
|
+
if (input.hook_event_name === "PreToolUse" && input.tool_name === "Bash") {
|
|
934
|
+
const command = String((input.tool_input && input.tool_input.command) || "");
|
|
935
|
+
const violation = findViolation(command);
|
|
936
|
+
if (violation) {
|
|
937
|
+
process.stdout.write(JSON.stringify({
|
|
938
|
+
hookSpecificOutput: {
|
|
939
|
+
hookEventName: "PreToolUse",
|
|
940
|
+
permissionDecision: "deny",
|
|
941
|
+
permissionDecisionReason:
|
|
942
|
+
\`Blocked by Agent Board: this would kill every "\${violation}" process on the machine, not \` +
|
|
943
|
+
"just this project's \u2014 that includes the Agent Board runner itself, every other card's " +
|
|
944
|
+
"session, and other projects' dev-server previews. Target the specific PID instead (find it " +
|
|
945
|
+
'with "netstat -ano | findstr :<port>" or "Get-Process", then "taskkill /pid <pid> /t /f" or ' +
|
|
946
|
+
'"Stop-Process -Id <pid> -Force").',
|
|
947
|
+
},
|
|
948
|
+
}));
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
} catch {
|
|
952
|
+
// A malformed/unexpected hook payload must never block a legitimate command.
|
|
953
|
+
}
|
|
954
|
+
process.exit(0);
|
|
955
|
+
})();
|
|
956
|
+
`;
|
|
957
|
+
function ensureSafetyHookScriptWritten() {
|
|
958
|
+
writeFileSync2(SAFETY_HOOK_SCRIPT_PATH, SCRIPT_SOURCE2, { mode: 493 });
|
|
959
|
+
logger.runner.info("SAFETY-HOOK", `Process-kill safety hook written to ${SAFETY_HOOK_SCRIPT_PATH}.`);
|
|
960
|
+
}
|
|
961
|
+
function safetyHookSettingsFragment() {
|
|
962
|
+
return {
|
|
963
|
+
hooks: {
|
|
964
|
+
PreToolUse: [
|
|
965
|
+
{
|
|
966
|
+
matcher: "Bash",
|
|
967
|
+
hooks: [
|
|
968
|
+
{
|
|
969
|
+
type: "command",
|
|
970
|
+
command: `${JSON.stringify(process.execPath)} ${JSON.stringify(SAFETY_HOOK_SCRIPT_PATH)}`
|
|
971
|
+
}
|
|
972
|
+
]
|
|
973
|
+
}
|
|
974
|
+
]
|
|
975
|
+
}
|
|
976
|
+
};
|
|
501
977
|
}
|
|
502
978
|
|
|
503
979
|
// src/statuslineScript.ts
|
|
504
980
|
import { writeFileSync as writeFileSync3 } from "node:fs";
|
|
505
|
-
import { tmpdir } from "node:os";
|
|
981
|
+
import { tmpdir as tmpdir4 } from "node:os";
|
|
506
982
|
import { join as join4 } from "node:path";
|
|
507
983
|
var CLAUDE_BINARY = process.platform === "win32" ? "claude.exe" : "claude";
|
|
508
|
-
var STATUSLINE_SCRIPT_PATH = join4(
|
|
509
|
-
var
|
|
984
|
+
var STATUSLINE_SCRIPT_PATH = join4(tmpdir4(), "agent-board-statusline.cjs");
|
|
985
|
+
var SCRIPT_SOURCE3 = `#!/usr/bin/env node
|
|
510
986
|
"use strict";
|
|
511
987
|
const http = require("node:http");
|
|
512
988
|
|
|
@@ -579,19 +1055,16 @@ function reportToRunner(sessionId, ticket, port, rateLimits) {
|
|
|
579
1055
|
})();
|
|
580
1056
|
`;
|
|
581
1057
|
function ensureStatuslineScriptWritten() {
|
|
582
|
-
writeFileSync3(STATUSLINE_SCRIPT_PATH,
|
|
1058
|
+
writeFileSync3(STATUSLINE_SCRIPT_PATH, SCRIPT_SOURCE3, { mode: 493 });
|
|
583
1059
|
logger.runner.info("STATUSLINE", `Status line script written to ${STATUSLINE_SCRIPT_PATH}.`);
|
|
584
1060
|
}
|
|
585
|
-
function
|
|
586
|
-
return
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
}
|
|
593
|
-
})
|
|
594
|
-
];
|
|
1061
|
+
function statuslineSettingsFragment() {
|
|
1062
|
+
return {
|
|
1063
|
+
statusLine: {
|
|
1064
|
+
type: "command",
|
|
1065
|
+
command: `${JSON.stringify(process.execPath)} ${JSON.stringify(STATUSLINE_SCRIPT_PATH)}`
|
|
1066
|
+
}
|
|
1067
|
+
};
|
|
595
1068
|
}
|
|
596
1069
|
function statuslineEnv(sessionId, ticket) {
|
|
597
1070
|
return {
|
|
@@ -607,1122 +1080,1913 @@ function statuslineEnv(sessionId, ticket) {
|
|
|
607
1080
|
};
|
|
608
1081
|
}
|
|
609
1082
|
|
|
610
|
-
// src/
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
var SHIFT_TAB_PRESSES_TO_PLAN_MODE = 4;
|
|
618
|
-
var SHIFT_TAB_DELAY_MS = 400;
|
|
619
|
-
var PLAN_MODE_INDICATOR = /plan mode on/i;
|
|
620
|
-
function spawnAgentPty(repoPath, cardId, cardTitle, instructions, sessionId, ticket, planFirst) {
|
|
621
|
-
const claudeSessionId = randomUUID();
|
|
622
|
-
const pty = spawn3(
|
|
623
|
-
CLAUDE_BINARY,
|
|
624
|
-
[
|
|
625
|
-
"--dangerously-skip-permissions",
|
|
626
|
-
"--model",
|
|
627
|
-
"claude-sonnet-5",
|
|
628
|
-
"--session-id",
|
|
629
|
-
claudeSessionId,
|
|
630
|
-
...statuslineSettingsArgs(),
|
|
631
|
-
`/rename ${toSessionName(cardTitle)}`
|
|
632
|
-
],
|
|
633
|
-
{
|
|
634
|
-
name: "xterm-color",
|
|
635
|
-
cols: 120,
|
|
636
|
-
rows: 32,
|
|
637
|
-
cwd: repoPath,
|
|
638
|
-
env: statuslineEnv(sessionId, ticket)
|
|
1083
|
+
// src/claudeCliSettings.ts
|
|
1084
|
+
function mergeHookFragments(...fragments) {
|
|
1085
|
+
const merged = {};
|
|
1086
|
+
for (const fragment of fragments) {
|
|
1087
|
+
const hooks = fragment.hooks ?? {};
|
|
1088
|
+
for (const [eventName, entries] of Object.entries(hooks)) {
|
|
1089
|
+
merged[eventName] = [...merged[eventName] ?? [], ...entries];
|
|
639
1090
|
}
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
return { pty, claudeSessionId };
|
|
1091
|
+
}
|
|
1092
|
+
return merged;
|
|
643
1093
|
}
|
|
644
|
-
function
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
clearTimeout(idleTimer);
|
|
653
|
-
clearTimeout(deadlineTimer);
|
|
654
|
-
dataSubscription?.dispose();
|
|
655
|
-
return true;
|
|
656
|
-
};
|
|
657
|
-
const send = async () => {
|
|
658
|
-
if (!stopWaiting()) return;
|
|
659
|
-
if (planFirst) await enterPlanMode(pty, cardId);
|
|
660
|
-
try {
|
|
661
|
-
pty.write(`${PASTE_START}${message}${PASTE_END}`);
|
|
662
|
-
setTimeout(() => {
|
|
663
|
-
try {
|
|
664
|
-
pty.write("\r");
|
|
665
|
-
} catch (err) {
|
|
666
|
-
logger.card.error(cardId, "Failed to submit the card instructions to the agent:", err);
|
|
667
|
-
}
|
|
668
|
-
}, SUBMIT_DELAY_MS);
|
|
669
|
-
} catch (err) {
|
|
670
|
-
logger.card.error(cardId, "Failed to send the card instructions to the agent:", err);
|
|
671
|
-
}
|
|
672
|
-
};
|
|
673
|
-
deadlineTimer = setTimeout(send, TUI_READY_TIMEOUT_MS);
|
|
674
|
-
dataSubscription = pty.onData(() => {
|
|
675
|
-
clearTimeout(idleTimer);
|
|
676
|
-
idleTimer = setTimeout(send, TUI_IDLE_MS);
|
|
677
|
-
});
|
|
678
|
-
pty.onExit(() => stopWaiting());
|
|
1094
|
+
function claudeCliSettingsArgs() {
|
|
1095
|
+
return [
|
|
1096
|
+
"--settings",
|
|
1097
|
+
JSON.stringify({
|
|
1098
|
+
...statuslineSettingsFragment(),
|
|
1099
|
+
hooks: mergeHookFragments(safetyHookSettingsFragment(), agentMomentHookSettingsFragment())
|
|
1100
|
+
})
|
|
1101
|
+
];
|
|
679
1102
|
}
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
1103
|
+
|
|
1104
|
+
// src/usageMonitor.ts
|
|
1105
|
+
var SESSION_ID = "usage-monitor";
|
|
1106
|
+
var TICKET = randomUUID3();
|
|
1107
|
+
var SCRATCH_DIR = join5(tmpdir5(), "agent-board-usage-monitor");
|
|
1108
|
+
var POLL_INTERVAL_MS = Number(process.env.AGENT_BOARD_USAGE_POLL_INTERVAL_MS ?? 15 * 6e4);
|
|
1109
|
+
var IDLE_MS = 2e3;
|
|
1110
|
+
var HARD_TIMEOUT_MS = 6e4;
|
|
1111
|
+
function usageMonitorSessionId() {
|
|
1112
|
+
return SESSION_ID;
|
|
1113
|
+
}
|
|
1114
|
+
function usageMonitorTicket() {
|
|
1115
|
+
return TICKET;
|
|
1116
|
+
}
|
|
1117
|
+
function ensureScratchDirTrusted() {
|
|
1118
|
+
const claudeJsonPath = join5(homedir2(), ".claude.json");
|
|
1119
|
+
if (!existsSync(claudeJsonPath)) return;
|
|
685
1120
|
try {
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
);
|
|
1121
|
+
const raw = readFileSync(claudeJsonPath, "utf8");
|
|
1122
|
+
const data = JSON.parse(raw);
|
|
1123
|
+
data.projects ??= {};
|
|
1124
|
+
const keys = [SCRATCH_DIR, SCRATCH_DIR.replace(/\\/g, "/")];
|
|
1125
|
+
const alreadyTrusted = keys.every((key) => data.projects[key]?.hasTrustDialogAccepted === true);
|
|
1126
|
+
if (alreadyTrusted) return;
|
|
1127
|
+
for (const key of keys) {
|
|
1128
|
+
data.projects[key] = { ...data.projects[key], hasTrustDialogAccepted: true };
|
|
695
1129
|
}
|
|
1130
|
+
writeFileSync4(claudeJsonPath, JSON.stringify(data, null, 2));
|
|
1131
|
+
logger.runner.info("USAGE_MONITOR", `Pre-trusted scratch directory ${SCRATCH_DIR} in ~/.claude.json.`);
|
|
696
1132
|
} catch (err) {
|
|
697
|
-
logger.
|
|
698
|
-
} finally {
|
|
699
|
-
subscription.dispose();
|
|
1133
|
+
logger.runner.error("USAGE_MONITOR", "Failed to pre-trust the usage-monitor scratch directory:", err);
|
|
700
1134
|
}
|
|
701
1135
|
}
|
|
702
|
-
function
|
|
1136
|
+
function pollUsageOnce() {
|
|
1137
|
+
let pty;
|
|
703
1138
|
try {
|
|
704
|
-
pty
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
1139
|
+
pty = spawn2(
|
|
1140
|
+
CLAUDE_BINARY,
|
|
1141
|
+
[
|
|
1142
|
+
"--dangerously-skip-permissions",
|
|
1143
|
+
"--model",
|
|
1144
|
+
"claude-haiku-4-5",
|
|
1145
|
+
// cheapest model — this exchange exists only to trigger a statusLine update
|
|
1146
|
+
...claudeCliSettingsArgs(),
|
|
1147
|
+
"reply with just the word: ok"
|
|
1148
|
+
],
|
|
1149
|
+
{
|
|
1150
|
+
name: "xterm-color",
|
|
1151
|
+
cols: 80,
|
|
1152
|
+
rows: 24,
|
|
1153
|
+
cwd: SCRATCH_DIR,
|
|
1154
|
+
env: statuslineEnv(SESSION_ID, TICKET)
|
|
710
1155
|
}
|
|
711
|
-
|
|
1156
|
+
);
|
|
712
1157
|
} catch (err) {
|
|
713
|
-
logger.
|
|
1158
|
+
logger.runner.error("USAGE_MONITOR", "Failed to spawn the usage-poll session:", err);
|
|
1159
|
+
return;
|
|
714
1160
|
}
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
],
|
|
727
|
-
{
|
|
728
|
-
name: "xterm-color",
|
|
729
|
-
cols: 120,
|
|
730
|
-
rows: 32,
|
|
731
|
-
cwd: repoPath,
|
|
732
|
-
env: statuslineEnv(sessionId, ticket)
|
|
1161
|
+
let settled = false;
|
|
1162
|
+
let idleTimer;
|
|
1163
|
+
const hardTimer = setTimeout(finish, HARD_TIMEOUT_MS);
|
|
1164
|
+
function finish() {
|
|
1165
|
+
if (settled) return;
|
|
1166
|
+
settled = true;
|
|
1167
|
+
clearTimeout(idleTimer);
|
|
1168
|
+
clearTimeout(hardTimer);
|
|
1169
|
+
try {
|
|
1170
|
+
pty.kill();
|
|
1171
|
+
} catch {
|
|
733
1172
|
}
|
|
734
|
-
|
|
1173
|
+
}
|
|
1174
|
+
pty.onData(() => {
|
|
1175
|
+
clearTimeout(idleTimer);
|
|
1176
|
+
idleTimer = setTimeout(finish, IDLE_MS);
|
|
1177
|
+
});
|
|
1178
|
+
pty.onExit(() => finish());
|
|
1179
|
+
}
|
|
1180
|
+
function startUsageMonitor() {
|
|
1181
|
+
mkdirSync(SCRATCH_DIR, { recursive: true });
|
|
1182
|
+
ensureScratchDirTrusted();
|
|
1183
|
+
pollUsageOnce();
|
|
1184
|
+
setInterval(pollUsageOnce, POLL_INTERVAL_MS);
|
|
735
1185
|
}
|
|
736
1186
|
|
|
737
|
-
//
|
|
738
|
-
var
|
|
739
|
-
var
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
var
|
|
746
|
-
function
|
|
747
|
-
|
|
1187
|
+
// src/filesServer.ts
|
|
1188
|
+
var execFileAsync2 = promisify2(execFile2);
|
|
1189
|
+
var CLAUDE_BYPASS_COMMAND = "claude --dangerously-skip-permissions";
|
|
1190
|
+
var FILES_TICKET_TTL_MS = 15 * 60 * 1e3;
|
|
1191
|
+
var FILES_TICKET_REFRESH_MARGIN_MS = FILES_TICKET_TTL_MS / 3;
|
|
1192
|
+
var MAX_FILE_SIZE_BYTES = 1e6;
|
|
1193
|
+
var MAX_LOGO_SIZE_BYTES = 5e6;
|
|
1194
|
+
var IGNORED_ENTRY_NAMES = /* @__PURE__ */ new Set([".git", ".claude", "node_modules", "dist", "build"]);
|
|
1195
|
+
var AGENT_SAFE_SUBDIR_NAMES = /* @__PURE__ */ new Set(["skills", "commands", "agents", "rules"]);
|
|
1196
|
+
function ensureSkillsDirExists(claudeDir) {
|
|
1197
|
+
try {
|
|
1198
|
+
mkdirSync2(join6(claudeDir, "skills"), { recursive: true });
|
|
1199
|
+
} catch (err) {
|
|
1200
|
+
logger.runner.warn("FILES", "Could not create .claude/skills:", err);
|
|
1201
|
+
}
|
|
748
1202
|
}
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
1203
|
+
var BRAND_ICON_BASE_NAMES = ["logo", "favicon"];
|
|
1204
|
+
var MIME_TYPES_BY_EXT = {
|
|
1205
|
+
svg: "image/svg+xml",
|
|
1206
|
+
png: "image/png",
|
|
1207
|
+
jpg: "image/jpeg",
|
|
1208
|
+
jpeg: "image/jpeg",
|
|
1209
|
+
webp: "image/webp",
|
|
1210
|
+
gif: "image/gif",
|
|
1211
|
+
ico: "image/x-icon"
|
|
1212
|
+
};
|
|
1213
|
+
var ticketsByProject = /* @__PURE__ */ new Map();
|
|
1214
|
+
var setupTicket = null;
|
|
1215
|
+
var agentTicket = null;
|
|
1216
|
+
var statuslineSupabase = null;
|
|
1217
|
+
var statuslineConfig = null;
|
|
1218
|
+
var MAX_STATUSLINE_BODY_BYTES = 16e3;
|
|
1219
|
+
var HttpError = class extends Error {
|
|
1220
|
+
constructor(status, message) {
|
|
1221
|
+
super(message);
|
|
1222
|
+
this.status = status;
|
|
754
1223
|
}
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
const
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
const
|
|
762
|
-
const
|
|
763
|
-
|
|
1224
|
+
};
|
|
1225
|
+
async function ensureFilesTickets(supabase, config) {
|
|
1226
|
+
for (const project of config.registeredProjects) {
|
|
1227
|
+
if (!project.isReferenceRunner) continue;
|
|
1228
|
+
const existing = ticketsByProject.get(project.id);
|
|
1229
|
+
if (existing && existing.expiresAt - Date.now() > FILES_TICKET_REFRESH_MARGIN_MS) continue;
|
|
1230
|
+
const ticket = randomUUID4();
|
|
1231
|
+
const expiresAtMs = Date.now() + FILES_TICKET_TTL_MS;
|
|
1232
|
+
ticketsByProject.set(project.id, { ticket, expiresAt: expiresAtMs, repoRoot: resolve(project.repoPath) });
|
|
764
1233
|
try {
|
|
765
|
-
|
|
766
|
-
if (downloadError) throw downloadError;
|
|
767
|
-
await writeFile(localPath, Buffer.from(await blob.arrayBuffer()));
|
|
768
|
-
paths.push(localPath);
|
|
1234
|
+
await updateProjectFilesTicket(supabase, project.id, ticket, new Date(expiresAtMs).toISOString());
|
|
769
1235
|
} catch (err) {
|
|
770
|
-
logger.
|
|
1236
|
+
logger.runner.error("FILES", `Failed to publish files ticket for project ${project.id}:`, err);
|
|
771
1237
|
}
|
|
772
1238
|
}
|
|
773
|
-
return paths;
|
|
774
|
-
}
|
|
775
|
-
function buildInstructionsWithImages(instructions, imagePaths) {
|
|
776
|
-
if (imagePaths.length === 0) return instructions;
|
|
777
|
-
const list = imagePaths.map((path) => `- ${path}`).join("\n");
|
|
778
|
-
return `${instructions}
|
|
779
|
-
|
|
780
|
-
Images attached to this card (read each one before starting; they are part of the task):
|
|
781
|
-
${list}`;
|
|
782
1239
|
}
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
if (
|
|
787
|
-
const
|
|
788
|
-
return
|
|
789
|
-
|
|
790
|
-
Files/folders referenced on this card (read them before starting \u2014 they are part of the task):
|
|
791
|
-
${list}`;
|
|
1240
|
+
function authenticate(query) {
|
|
1241
|
+
const projectId = typeof query.projectId === "string" ? query.projectId : null;
|
|
1242
|
+
const ticket = typeof query.ticket === "string" ? query.ticket : null;
|
|
1243
|
+
if (!projectId || !ticket) return null;
|
|
1244
|
+
const entry = ticketsByProject.get(projectId);
|
|
1245
|
+
if (!entry || entry.ticket !== ticket || entry.expiresAt < Date.now()) return null;
|
|
1246
|
+
return entry;
|
|
792
1247
|
}
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
pty.onData((data) => {
|
|
800
|
-
handle.outputBuffer.push(data);
|
|
801
|
-
if (handle.outputBuffer.length > MAX_BUFFERED_CHUNKS) handle.outputBuffer.shift();
|
|
802
|
-
for (const socket of handle.sockets) {
|
|
803
|
-
if (socket.readyState === socket.OPEN) socket.send(data);
|
|
804
|
-
}
|
|
805
|
-
});
|
|
806
|
-
registry.set(sessionId, handle);
|
|
807
|
-
}
|
|
808
|
-
function getPtySession(sessionId) {
|
|
809
|
-
return registry.get(sessionId);
|
|
810
|
-
}
|
|
811
|
-
function resizePtySession(sessionId, cols, rows) {
|
|
812
|
-
const handle = registry.get(sessionId);
|
|
813
|
-
if (!handle) return;
|
|
814
|
-
handle.pty.resize(cols, rows);
|
|
815
|
-
}
|
|
816
|
-
function stopPtySession(sessionId) {
|
|
817
|
-
const handle = registry.get(sessionId);
|
|
818
|
-
if (!handle) return;
|
|
819
|
-
for (const socket of handle.sockets) socket.close(4e3, "Session ended");
|
|
820
|
-
handle.pty.kill();
|
|
821
|
-
registry.delete(sessionId);
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
// src/activityLog.ts
|
|
825
|
-
async function logActivity(supabase, params) {
|
|
1248
|
+
async function ensureSetupTicket(supabase, config) {
|
|
1249
|
+
if (!config.allowedRootPath) return;
|
|
1250
|
+
if (setupTicket && setupTicket.expiresAt - Date.now() > FILES_TICKET_REFRESH_MARGIN_MS) return;
|
|
1251
|
+
const ticket = randomUUID4();
|
|
1252
|
+
const expiresAtMs = Date.now() + FILES_TICKET_TTL_MS;
|
|
1253
|
+
setupTicket = { ticket, expiresAt: expiresAtMs, rootPath: resolve(config.allowedRootPath) };
|
|
826
1254
|
try {
|
|
827
|
-
|
|
828
|
-
project_id: params.projectId,
|
|
829
|
-
card_id: params.cardId,
|
|
830
|
-
user_id: params.userId,
|
|
831
|
-
event_type: params.eventType,
|
|
832
|
-
payload: params.payload ?? {}
|
|
833
|
-
});
|
|
834
|
-
if (error) throw error;
|
|
1255
|
+
await updateRunnerSetupTicket(supabase, config.runnerId, ticket, new Date(expiresAtMs).toISOString(), config.allowedRootPath);
|
|
835
1256
|
} catch (err) {
|
|
836
|
-
logger.runner.error("
|
|
1257
|
+
logger.runner.error("FILES", `Failed to publish setup ticket for runner ${config.runnerId}:`, err);
|
|
837
1258
|
}
|
|
838
1259
|
}
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
return count ?? 0;
|
|
1260
|
+
function authenticateSetup(query) {
|
|
1261
|
+
const ticket = typeof query.ticket === "string" ? query.ticket : null;
|
|
1262
|
+
if (!ticket || !setupTicket) return null;
|
|
1263
|
+
if (setupTicket.ticket !== ticket || setupTicket.expiresAt < Date.now()) return null;
|
|
1264
|
+
return setupTicket;
|
|
845
1265
|
}
|
|
846
|
-
async function
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
1266
|
+
async function ensureAgentTicket(supabase, config) {
|
|
1267
|
+
if (agentTicket && agentTicket.expiresAt - Date.now() > FILES_TICKET_REFRESH_MARGIN_MS) return;
|
|
1268
|
+
const ticket = randomUUID4();
|
|
1269
|
+
const expiresAtMs = Date.now() + FILES_TICKET_TTL_MS;
|
|
1270
|
+
agentTicket = { ticket, expiresAt: expiresAtMs, repoRoot: resolve(homedir3(), ".claude") };
|
|
1271
|
+
try {
|
|
1272
|
+
await updateRunnerAgentTicket(supabase, config.runnerId, ticket, new Date(expiresAtMs).toISOString());
|
|
1273
|
+
} catch (err) {
|
|
1274
|
+
logger.runner.error("FILES", `Failed to publish agent ticket for runner ${config.runnerId}:`, err);
|
|
1275
|
+
}
|
|
852
1276
|
}
|
|
853
|
-
|
|
854
|
-
const
|
|
855
|
-
if (
|
|
856
|
-
return
|
|
1277
|
+
function authenticateAgent(query) {
|
|
1278
|
+
const ticket = typeof query.ticket === "string" ? query.ticket : null;
|
|
1279
|
+
if (!ticket || !agentTicket) return null;
|
|
1280
|
+
if (agentTicket.ticket !== ticket || agentTicket.expiresAt < Date.now()) return null;
|
|
1281
|
+
return agentTicket;
|
|
857
1282
|
}
|
|
858
|
-
|
|
859
|
-
const
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
...changes.terminalTicket !== void 0 ? { terminal_ticket: changes.terminalTicket } : {},
|
|
865
|
-
...changes.terminalTicketExpiresAt !== void 0 ? { terminal_ticket_expires_at: changes.terminalTicketExpiresAt } : {},
|
|
866
|
-
...changes.claudeSessionId !== void 0 ? { claude_session_id: changes.claudeSessionId } : {}
|
|
867
|
-
}).eq("id", sessionId);
|
|
868
|
-
if (error) throw error;
|
|
869
|
-
if (changes.status !== void 0 && activityContext) {
|
|
870
|
-
await logActivity(supabase, {
|
|
871
|
-
projectId: activityContext.projectId,
|
|
872
|
-
cardId: activityContext.cardId,
|
|
873
|
-
userId: activityContext.userId,
|
|
874
|
-
eventType: "session_status_changed",
|
|
875
|
-
payload: { status: changes.status }
|
|
876
|
-
});
|
|
1283
|
+
function resolveWithinRepo(repoRoot, relPath) {
|
|
1284
|
+
const normalizedRel = relPath.replace(/^[/\\]+/, "");
|
|
1285
|
+
const resolved = resolve(repoRoot, normalizedRel);
|
|
1286
|
+
const rootWithSep = repoRoot.endsWith(sep) ? repoRoot : repoRoot + sep;
|
|
1287
|
+
if (resolved !== repoRoot && !resolved.startsWith(rootWithSep)) {
|
|
1288
|
+
throw new HttpError(403, "Caminho fora do reposit\xF3rio.");
|
|
877
1289
|
}
|
|
1290
|
+
return resolved;
|
|
878
1291
|
}
|
|
879
|
-
|
|
880
|
-
const
|
|
881
|
-
|
|
882
|
-
|
|
1292
|
+
function listDirectory(repoRoot, relPath, options) {
|
|
1293
|
+
const target = resolveWithinRepo(repoRoot, relPath);
|
|
1294
|
+
let stat;
|
|
1295
|
+
try {
|
|
1296
|
+
stat = lstatSync(target);
|
|
1297
|
+
} catch {
|
|
1298
|
+
throw new HttpError(404, "Diret\xF3rio n\xE3o encontrado.");
|
|
1299
|
+
}
|
|
1300
|
+
if (stat.isSymbolicLink()) throw new HttpError(403, "Links simb\xF3licos n\xE3o s\xE3o suportados.");
|
|
1301
|
+
if (!stat.isDirectory()) throw new HttpError(400, "O caminho informado n\xE3o \xE9 um diret\xF3rio.");
|
|
1302
|
+
const prefix = relPath.replace(/\/+$/, "");
|
|
1303
|
+
return readdirSync(target, { withFileTypes: true }).filter(
|
|
1304
|
+
(dirent) => !dirent.isSymbolicLink() && (!IGNORED_ENTRY_NAMES.has(dirent.name) || options?.includeDotClaude && dirent.name === ".claude")
|
|
1305
|
+
).map((dirent) => ({
|
|
1306
|
+
name: dirent.name,
|
|
1307
|
+
path: prefix ? `${prefix}/${dirent.name}` : dirent.name,
|
|
1308
|
+
type: dirent.isDirectory() ? "dir" : "file"
|
|
1309
|
+
})).sort((a, b) => a.type !== b.type ? a.type === "dir" ? -1 : 1 : a.name.localeCompare(b.name));
|
|
883
1310
|
}
|
|
884
|
-
|
|
885
|
-
const
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
1311
|
+
function listSafeAgentDirectory(baseDir, relPath, atClaudeRoot) {
|
|
1312
|
+
const segments = relPath === "" ? [] : relPath.split("/");
|
|
1313
|
+
if (atClaudeRoot) {
|
|
1314
|
+
if (segments.length === 0) {
|
|
1315
|
+
ensureSkillsDirExists(baseDir);
|
|
1316
|
+
const entries2 = listDirectory(baseDir, relPath, { includeDotClaude: true });
|
|
1317
|
+
return entries2.filter(
|
|
1318
|
+
(entry) => entry.type === "file" && entry.name === "CLAUDE.md" || entry.type === "dir" && AGENT_SAFE_SUBDIR_NAMES.has(entry.name)
|
|
1319
|
+
);
|
|
1320
|
+
}
|
|
1321
|
+
return listDirectory(baseDir, relPath, { includeDotClaude: true });
|
|
1322
|
+
}
|
|
1323
|
+
const isRepoRootLevel = segments.length === 0 || segments.length === 1 && segments[0] !== ".claude";
|
|
1324
|
+
if (isRepoRootLevel) {
|
|
1325
|
+
ensureSkillsDirExists(join6(baseDir, relPath, ".claude"));
|
|
1326
|
+
const entries2 = listDirectory(baseDir, relPath, { includeDotClaude: true });
|
|
1327
|
+
return entries2.filter((entry) => entry.type === "file" && entry.name === "CLAUDE.md" || entry.type === "dir" && entry.name === ".claude");
|
|
1328
|
+
}
|
|
1329
|
+
const entries = listDirectory(baseDir, relPath, { includeDotClaude: true });
|
|
1330
|
+
if (segments[segments.length - 1] === ".claude") {
|
|
1331
|
+
return entries.filter((entry) => entry.type === "dir" && AGENT_SAFE_SUBDIR_NAMES.has(entry.name));
|
|
1332
|
+
}
|
|
1333
|
+
return entries;
|
|
891
1334
|
}
|
|
892
|
-
|
|
893
|
-
const
|
|
894
|
-
|
|
895
|
-
|
|
1335
|
+
function assertSafeAgentPath(relPath, atClaudeRoot) {
|
|
1336
|
+
const segments = relPath.split("/");
|
|
1337
|
+
function matches(rest) {
|
|
1338
|
+
if (rest.length === 1 && rest[0] === "CLAUDE.md") return true;
|
|
1339
|
+
if (atClaudeRoot) return rest.length >= 2 && AGENT_SAFE_SUBDIR_NAMES.has(rest[0]);
|
|
1340
|
+
return rest.length >= 3 && rest[0] === ".claude" && AGENT_SAFE_SUBDIR_NAMES.has(rest[1]);
|
|
1341
|
+
}
|
|
1342
|
+
if (matches(segments)) return;
|
|
1343
|
+
if (!atClaudeRoot && segments.length > 1 && matches(segments.slice(1))) return;
|
|
1344
|
+
throw new HttpError(403, "Caminho fora da \xE1rea permitida para o Agente.");
|
|
896
1345
|
}
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
1346
|
+
function assertWritableAgentPath(relPath, atClaudeRoot) {
|
|
1347
|
+
assertSafeAgentPath(relPath, atClaudeRoot);
|
|
1348
|
+
const segments = relPath.split("/");
|
|
1349
|
+
if (segments[segments.length - 1] !== "CLAUDE.md") {
|
|
1350
|
+
throw new HttpError(403, "Apenas CLAUDE.md pode ser criado ou editado por aqui.");
|
|
1351
|
+
}
|
|
901
1352
|
}
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
1353
|
+
function listDirectoryForBrowse(rootPath, relPath) {
|
|
1354
|
+
return listDirectory(rootPath, relPath).map((entry) => ({
|
|
1355
|
+
...entry,
|
|
1356
|
+
hasGit: entry.type === "dir" && existsSync2(join6(rootPath, entry.path, ".git"))
|
|
1357
|
+
}));
|
|
906
1358
|
}
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
1359
|
+
var SAFE_CLONE_URL_PATTERN = /^(https?:\/\/|git:\/\/|ssh:\/\/|git@[\w.-]+:)\S+$/i;
|
|
1360
|
+
function isSafeGitUrl(url) {
|
|
1361
|
+
return url.length > 0 && !/\s/.test(url) && SAFE_CLONE_URL_PATTERN.test(url);
|
|
910
1362
|
}
|
|
911
|
-
|
|
912
|
-
const
|
|
913
|
-
|
|
1363
|
+
function deriveRepoFolderName(url) {
|
|
1364
|
+
const lastSegment = url.replace(/\/+$/, "").split(/[/:]/).pop() ?? "";
|
|
1365
|
+
const name = lastSegment.replace(/\.git$/i, "");
|
|
1366
|
+
if (!/^[\w.-]+$/.test(name) || name === "." || name === "..") return null;
|
|
1367
|
+
return name;
|
|
914
1368
|
}
|
|
915
|
-
async function
|
|
916
|
-
const
|
|
917
|
-
if (
|
|
1369
|
+
async function handleCloneRequest(req, res, query) {
|
|
1370
|
+
const setupEntry = authenticateSetup(query);
|
|
1371
|
+
if (!setupEntry) {
|
|
1372
|
+
sendJson(res, 401, { error: "Ticket inv\xE1lido, expirado, ou navega\xE7\xE3o de pastas desativada." });
|
|
1373
|
+
return;
|
|
1374
|
+
}
|
|
1375
|
+
let body;
|
|
1376
|
+
try {
|
|
1377
|
+
const raw = await readRequestBody(req, 2e3);
|
|
1378
|
+
body = JSON.parse(raw);
|
|
1379
|
+
} catch (err) {
|
|
1380
|
+
const status = err instanceof HttpError ? err.status : 400;
|
|
1381
|
+
sendJson(res, status, { error: "Corpo inv\xE1lido." });
|
|
1382
|
+
return;
|
|
1383
|
+
}
|
|
1384
|
+
const url = typeof body.url === "string" ? body.url.trim() : "";
|
|
1385
|
+
if (!isSafeGitUrl(url)) {
|
|
1386
|
+
sendJson(res, 400, { error: "URL de git inv\xE1lida. Use https://, ssh:// ou o formato git@host:caminho." });
|
|
1387
|
+
return;
|
|
1388
|
+
}
|
|
1389
|
+
const folderName = deriveRepoFolderName(url);
|
|
1390
|
+
if (!folderName) {
|
|
1391
|
+
sendJson(res, 400, { error: "N\xE3o foi poss\xEDvel determinar o nome da pasta a partir da URL." });
|
|
1392
|
+
return;
|
|
1393
|
+
}
|
|
1394
|
+
const destPath = join6(setupEntry.rootPath, folderName);
|
|
1395
|
+
if (existsSync2(destPath)) {
|
|
1396
|
+
sendJson(res, 409, { error: `J\xE1 existe uma pasta "${folderName}" na raiz permitida.` });
|
|
1397
|
+
return;
|
|
1398
|
+
}
|
|
1399
|
+
try {
|
|
1400
|
+
await execFileAsync2("git", ["clone", "--", url, destPath], { timeout: 9e4 });
|
|
1401
|
+
sendJson(res, 200, { name: folderName, path: folderName, type: "dir", hasGit: true });
|
|
1402
|
+
} catch (err) {
|
|
1403
|
+
try {
|
|
1404
|
+
rmSync(destPath, { recursive: true, force: true });
|
|
1405
|
+
} catch {
|
|
1406
|
+
}
|
|
1407
|
+
logger.runner.error("FILES", `git clone failed for ${url}:`, err);
|
|
1408
|
+
const stderr = err && typeof err === "object" && "stderr" in err ? String(err.stderr ?? "").trim() : "";
|
|
1409
|
+
const timedOut = err && typeof err === "object" && "killed" in err && err.killed;
|
|
1410
|
+
sendJson(res, 502, {
|
|
1411
|
+
error: timedOut ? "O clone excedeu o tempo limite do t\xFAnel (reposit\xF3rio muito grande?)." : stderr || "Falha ao clonar o reposit\xF3rio."
|
|
1412
|
+
});
|
|
1413
|
+
}
|
|
918
1414
|
}
|
|
919
|
-
|
|
920
|
-
const
|
|
921
|
-
|
|
1415
|
+
function looksBinary(buffer) {
|
|
1416
|
+
const sample = buffer.subarray(0, Math.min(buffer.length, 8e3));
|
|
1417
|
+
return sample.includes(0);
|
|
922
1418
|
}
|
|
923
|
-
|
|
924
|
-
const
|
|
925
|
-
|
|
926
|
-
|
|
1419
|
+
function readTextFile(repoRoot, relPath) {
|
|
1420
|
+
const target = resolveWithinRepo(repoRoot, relPath);
|
|
1421
|
+
let stat;
|
|
1422
|
+
try {
|
|
1423
|
+
stat = lstatSync(target);
|
|
1424
|
+
} catch {
|
|
1425
|
+
throw new HttpError(404, "Arquivo n\xE3o encontrado.");
|
|
1426
|
+
}
|
|
1427
|
+
if (stat.isSymbolicLink()) throw new HttpError(403, "Links simb\xF3licos n\xE3o s\xE3o suportados.");
|
|
1428
|
+
if (!stat.isFile()) throw new HttpError(400, "O caminho informado n\xE3o \xE9 um arquivo.");
|
|
1429
|
+
if (stat.size > MAX_FILE_SIZE_BYTES) {
|
|
1430
|
+
throw new HttpError(413, `Arquivo maior que o limite de ${MAX_FILE_SIZE_BYTES / 1e6}MB para visualiza\xE7\xE3o.`);
|
|
1431
|
+
}
|
|
1432
|
+
const buffer = readFileSync2(target);
|
|
1433
|
+
if (looksBinary(buffer)) throw new HttpError(415, "Arquivo bin\xE1rio n\xE3o pode ser exibido.");
|
|
1434
|
+
return { content: buffer.toString("utf8"), size: stat.size };
|
|
927
1435
|
}
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
1436
|
+
function writeTextFile(repoRoot, relPath, content) {
|
|
1437
|
+
if (Buffer.byteLength(content, "utf8") > MAX_FILE_SIZE_BYTES) {
|
|
1438
|
+
throw new HttpError(413, `Conte\xFAdo maior que o limite de ${MAX_FILE_SIZE_BYTES / 1e6}MB.`);
|
|
1439
|
+
}
|
|
1440
|
+
const target = resolveWithinRepo(repoRoot, relPath);
|
|
1441
|
+
writeFileSync5(target, content, "utf8");
|
|
932
1442
|
}
|
|
933
|
-
|
|
934
|
-
const
|
|
935
|
-
|
|
1443
|
+
function findBrandIconPath(repoRoot) {
|
|
1444
|
+
const patterns = BRAND_ICON_BASE_NAMES.map((baseName) => ({
|
|
1445
|
+
baseName,
|
|
1446
|
+
pattern: new RegExp(`^${baseName}\\.[a-z0-9]+$`, "i")
|
|
1447
|
+
}));
|
|
1448
|
+
const candidatesByBaseName = new Map(
|
|
1449
|
+
BRAND_ICON_BASE_NAMES.map((baseName) => [baseName, []])
|
|
1450
|
+
);
|
|
1451
|
+
function walk(dir, relDir, depth) {
|
|
1452
|
+
let entries;
|
|
1453
|
+
try {
|
|
1454
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
1455
|
+
} catch {
|
|
1456
|
+
return;
|
|
1457
|
+
}
|
|
1458
|
+
for (const entry of entries) {
|
|
1459
|
+
if (entry.isSymbolicLink() || IGNORED_ENTRY_NAMES.has(entry.name)) continue;
|
|
1460
|
+
const relPath = relDir ? `${relDir}/${entry.name}` : entry.name;
|
|
1461
|
+
if (entry.isDirectory()) {
|
|
1462
|
+
walk(join6(dir, entry.name), relPath, depth + 1);
|
|
1463
|
+
} else if (entry.isFile()) {
|
|
1464
|
+
const match = patterns.find(({ pattern }) => pattern.test(entry.name));
|
|
1465
|
+
if (match) candidatesByBaseName.get(match.baseName).push({ relPath, depth });
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
walk(repoRoot, "", 0);
|
|
1470
|
+
for (const baseName of BRAND_ICON_BASE_NAMES) {
|
|
1471
|
+
const candidates = candidatesByBaseName.get(baseName);
|
|
1472
|
+
if (candidates.length === 0) continue;
|
|
1473
|
+
candidates.sort((a, b) => a.depth - b.depth || a.relPath.localeCompare(b.relPath));
|
|
1474
|
+
return candidates[0].relPath;
|
|
1475
|
+
}
|
|
1476
|
+
return null;
|
|
936
1477
|
}
|
|
937
|
-
|
|
938
|
-
const
|
|
939
|
-
|
|
940
|
-
|
|
1478
|
+
function readImageFile(repoRoot, relPath) {
|
|
1479
|
+
const target = resolveWithinRepo(repoRoot, relPath);
|
|
1480
|
+
let stat;
|
|
1481
|
+
try {
|
|
1482
|
+
stat = lstatSync(target);
|
|
1483
|
+
} catch {
|
|
1484
|
+
throw new HttpError(404, "Arquivo de logo n\xE3o encontrado.");
|
|
1485
|
+
}
|
|
1486
|
+
if (stat.isSymbolicLink()) throw new HttpError(403, "Links simb\xF3licos n\xE3o s\xE3o suportados.");
|
|
1487
|
+
if (!stat.isFile()) throw new HttpError(400, "O caminho configurado n\xE3o \xE9 um arquivo.");
|
|
1488
|
+
if (stat.size > MAX_LOGO_SIZE_BYTES) {
|
|
1489
|
+
throw new HttpError(413, `Arquivo maior que o limite de ${MAX_LOGO_SIZE_BYTES / 1e6}MB.`);
|
|
1490
|
+
}
|
|
1491
|
+
const ext = relPath.split(".").pop()?.toLowerCase() ?? "";
|
|
1492
|
+
return { buffer: readFileSync2(target), contentType: MIME_TYPES_BY_EXT[ext] ?? "application/octet-stream" };
|
|
941
1493
|
}
|
|
942
|
-
|
|
943
|
-
const
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
return { sessionResetsAt: data.session_resets_at, weekResetsAt: data.week_resets_at };
|
|
951
|
-
}
|
|
952
|
-
async function upsertClaudeUsage(supabase, userId, changes) {
|
|
953
|
-
const { error } = await supabase.from("claude_usage").upsert({
|
|
954
|
-
user_id: userId,
|
|
955
|
-
...changes.sessionPercent !== void 0 ? { session_pct: changes.sessionPercent } : {},
|
|
956
|
-
...changes.sessionResetsAt !== void 0 ? { session_resets_at: changes.sessionResetsAt } : {},
|
|
957
|
-
...changes.weekPercent !== void 0 ? { week_pct: changes.weekPercent } : {},
|
|
958
|
-
...changes.weekResetsAt !== void 0 ? { week_resets_at: changes.weekResetsAt } : {},
|
|
959
|
-
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1494
|
+
function sendJson(res, status, body) {
|
|
1495
|
+
const payload = JSON.stringify(body);
|
|
1496
|
+
res.writeHead(status, {
|
|
1497
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
1498
|
+
// This runner is a trusted process on the user's own machine and the web app is served
|
|
1499
|
+
// from a different origin (e.g. Vercel) — the ticket is the actual security boundary,
|
|
1500
|
+
// so CORS is left wide open rather than trying to keep an allowlist of web origins.
|
|
1501
|
+
"Access-Control-Allow-Origin": "*"
|
|
960
1502
|
});
|
|
961
|
-
|
|
1503
|
+
res.end(payload);
|
|
962
1504
|
}
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
1505
|
+
function readRequestBody(req, maxBytes) {
|
|
1506
|
+
return new Promise((resolve4, reject) => {
|
|
1507
|
+
let received = 0;
|
|
1508
|
+
const chunks = [];
|
|
1509
|
+
req.on("data", (chunk) => {
|
|
1510
|
+
received += chunk.length;
|
|
1511
|
+
if (received > maxBytes) {
|
|
1512
|
+
req.destroy();
|
|
1513
|
+
reject(new HttpError(413, "Corpo da requisi\xE7\xE3o excede o limite."));
|
|
1514
|
+
return;
|
|
1515
|
+
}
|
|
1516
|
+
chunks.push(chunk);
|
|
974
1517
|
});
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
const delay = resetAt !== null ? resetAt - now + RESET_BUFFER_MS : UNKNOWN_RESET_RETRY_MS;
|
|
979
|
-
logger.card.info(
|
|
980
|
-
cardId,
|
|
981
|
-
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.`
|
|
982
|
-
);
|
|
983
|
-
const timer = setTimeout(() => {
|
|
984
|
-
pending.delete(sessionId);
|
|
985
|
-
sendContinue(pty, cardId);
|
|
986
|
-
}, delay);
|
|
987
|
-
timer.unref();
|
|
988
|
-
pending.set(sessionId, timer);
|
|
989
|
-
})();
|
|
990
|
-
}
|
|
991
|
-
function cancelUsageResume(sessionId) {
|
|
992
|
-
const timer = pending.get(sessionId);
|
|
993
|
-
if (!timer) return;
|
|
994
|
-
clearTimeout(timer);
|
|
995
|
-
pending.delete(sessionId);
|
|
1518
|
+
req.on("end", () => resolve4(Buffer.concat(chunks).toString("utf8")));
|
|
1519
|
+
req.on("error", reject);
|
|
1520
|
+
});
|
|
996
1521
|
}
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
const { data: cards, error } = await supabase.from("cards").select("*").eq("project_id", project.id).eq("column_name", "desenvolvimento").eq("assigned_user_id", config.ownerUserId);
|
|
1007
|
-
if (error) {
|
|
1008
|
-
logger.runner.error("ORCHESTRATOR", `Failed to scan cards for project ${project.id}:`, error.message);
|
|
1009
|
-
continue;
|
|
1010
|
-
}
|
|
1011
|
-
for (const card of cards ?? []) {
|
|
1012
|
-
void enqueueCard(supabase, config, card);
|
|
1013
|
-
}
|
|
1522
|
+
async function handleStatuslineReport(req, res, supabase, config) {
|
|
1523
|
+
let body;
|
|
1524
|
+
try {
|
|
1525
|
+
const raw = await readRequestBody(req, MAX_STATUSLINE_BODY_BYTES);
|
|
1526
|
+
body = JSON.parse(raw);
|
|
1527
|
+
} catch (err) {
|
|
1528
|
+
const status = err instanceof HttpError ? err.status : 400;
|
|
1529
|
+
sendJson(res, status, { error: "Corpo inv\xE1lido." });
|
|
1530
|
+
return;
|
|
1014
1531
|
}
|
|
1015
|
-
|
|
1016
|
-
}
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
const
|
|
1020
|
-
|
|
1021
|
-
|
|
1532
|
+
if (!body.sessionId || !body.ticket) {
|
|
1533
|
+
sendJson(res, 400, { error: "sessionId e ticket s\xE3o obrigat\xF3rios." });
|
|
1534
|
+
return;
|
|
1535
|
+
}
|
|
1536
|
+
const isMonitor = body.sessionId === usageMonitorSessionId();
|
|
1537
|
+
const ticketValid = isMonitor ? body.ticket === usageMonitorTicket() : getPtySession(body.sessionId)?.ticket === body.ticket;
|
|
1538
|
+
if (!ticketValid) {
|
|
1539
|
+
sendJson(res, 403, { error: "Ticket inv\xE1lido para esta sess\xE3o." });
|
|
1022
1540
|
return;
|
|
1023
1541
|
}
|
|
1024
|
-
if (card.assigned_user_id !== config.ownerUserId) return;
|
|
1025
|
-
inFlightCardIds.add(card.id);
|
|
1026
1542
|
try {
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1543
|
+
await upsertClaudeUsage(supabase, config.ownerUserId, {
|
|
1544
|
+
...typeof body.fiveHour?.used_percentage === "number" ? { sessionPercent: body.fiveHour.used_percentage } : {},
|
|
1545
|
+
...typeof body.fiveHour?.resets_at === "number" ? { sessionResetsAt: new Date(body.fiveHour.resets_at * 1e3).toISOString() } : {},
|
|
1546
|
+
...typeof body.sevenDay?.used_percentage === "number" ? { weekPercent: body.sevenDay.used_percentage } : {},
|
|
1547
|
+
...typeof body.sevenDay?.resets_at === "number" ? { weekResetsAt: new Date(body.sevenDay.resets_at * 1e3).toISOString() } : {}
|
|
1548
|
+
});
|
|
1549
|
+
res.writeHead(204, { "Access-Control-Allow-Origin": "*" });
|
|
1550
|
+
res.end();
|
|
1551
|
+
} catch (err) {
|
|
1552
|
+
logger.runner.error("STATUSLINE", `Failed to persist Claude usage from session ${body.sessionId}:`, err);
|
|
1553
|
+
sendJson(res, 500, { error: "Erro interno do runner." });
|
|
1032
1554
|
}
|
|
1033
|
-
await dispatchRunnerQueue(supabase, config);
|
|
1034
1555
|
}
|
|
1035
|
-
async function
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1556
|
+
async function handleAgentHookReport(req, res) {
|
|
1557
|
+
let body;
|
|
1558
|
+
try {
|
|
1559
|
+
const raw = await readRequestBody(req, MAX_STATUSLINE_BODY_BYTES);
|
|
1560
|
+
body = JSON.parse(raw);
|
|
1561
|
+
} catch (err) {
|
|
1562
|
+
const status = err instanceof HttpError ? err.status : 400;
|
|
1563
|
+
sendJson(res, status, { error: "Corpo inv\xE1lido." });
|
|
1043
1564
|
return;
|
|
1044
1565
|
}
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
stopPtySession(sessionId);
|
|
1049
|
-
cancelUsageResume(sessionId);
|
|
1050
|
-
activeSessionByCard.delete(cardId);
|
|
1051
|
-
await updateSession(
|
|
1052
|
-
supabase,
|
|
1053
|
-
sessionId,
|
|
1054
|
-
{ status: "done", endedAt: (/* @__PURE__ */ new Date()).toISOString() },
|
|
1055
|
-
{ projectId, cardId, userId: config.ownerUserId }
|
|
1056
|
-
);
|
|
1057
|
-
logger.card.info(cardId, `Ended stale session ${sessionId} (its card left "desenvolvimento" while this runner missed the event).`);
|
|
1566
|
+
if (!body.sessionId || !body.ticket || !body.moment) {
|
|
1567
|
+
sendJson(res, 400, { error: "sessionId, ticket e moment s\xE3o obrigat\xF3rios." });
|
|
1568
|
+
return;
|
|
1058
1569
|
}
|
|
1059
|
-
|
|
1570
|
+
if (getPtySession(body.sessionId)?.ticket !== body.ticket) {
|
|
1571
|
+
sendJson(res, 403, { error: "Ticket inv\xE1lido para esta sess\xE3o." });
|
|
1572
|
+
return;
|
|
1573
|
+
}
|
|
1574
|
+
handleAgentHookEvent(body.sessionId, body.moment);
|
|
1575
|
+
res.writeHead(204, { "Access-Control-Allow-Origin": "*" });
|
|
1576
|
+
res.end();
|
|
1060
1577
|
}
|
|
1061
|
-
async function
|
|
1062
|
-
|
|
1063
|
-
dispatchingRunner = true;
|
|
1578
|
+
async function handleBranchRequest(entry, res, relPath) {
|
|
1579
|
+
let cwd;
|
|
1064
1580
|
try {
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
const runningCount = await countRunningSessions(supabase, project.id, config.ownerUserId);
|
|
1071
|
-
if (runningCount > 0) runningProjectIds.add(project.id);
|
|
1072
|
-
}
|
|
1073
|
-
let freeProjectSlots = maxConcurrentProjects - runningProjectIds.size;
|
|
1074
|
-
if (freeProjectSlots <= 0) return;
|
|
1075
|
-
const promotedThisPass = /* @__PURE__ */ new Set();
|
|
1076
|
-
for (const entry of queued) {
|
|
1077
|
-
const projectId = entry.cards.project_id;
|
|
1078
|
-
if (runningProjectIds.has(projectId) || promotedThisPass.has(projectId)) continue;
|
|
1079
|
-
if (freeProjectSlots <= 0) break;
|
|
1080
|
-
try {
|
|
1081
|
-
await promoteQueuedSession(supabase, config, entry);
|
|
1082
|
-
promotedThisPass.add(projectId);
|
|
1083
|
-
freeProjectSlots -= 1;
|
|
1084
|
-
} catch (err) {
|
|
1085
|
-
logger.card.error(entry.card_id, `Failed to promote queued session ${entry.id}:`, err);
|
|
1086
|
-
}
|
|
1581
|
+
cwd = relPath ? resolveWithinRepo(entry.repoRoot, relPath) : entry.repoRoot;
|
|
1582
|
+
} catch (err) {
|
|
1583
|
+
if (err instanceof HttpError) {
|
|
1584
|
+
sendJson(res, err.status, { error: err.message });
|
|
1585
|
+
return;
|
|
1087
1586
|
}
|
|
1088
|
-
|
|
1089
|
-
|
|
1587
|
+
throw err;
|
|
1588
|
+
}
|
|
1589
|
+
try {
|
|
1590
|
+
const { stdout } = await execFileAsync2("git", ["branch", "--show-current"], { cwd });
|
|
1591
|
+
sendJson(res, 200, { branch: stdout.trim() });
|
|
1592
|
+
} catch (err) {
|
|
1593
|
+
logger.runner.error("FILES", "Unexpected error handling a branch request:", err);
|
|
1594
|
+
sendJson(res, 500, { error: "Erro interno do runner." });
|
|
1090
1595
|
}
|
|
1091
1596
|
}
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1597
|
+
var DIFF_EXEC_OPTIONS = { maxBuffer: 10 * 1024 * 1024 };
|
|
1598
|
+
function parseGitStatusPorcelain(output) {
|
|
1599
|
+
return output.split("\n").filter((line) => line.length > 0).map((line) => {
|
|
1600
|
+
const status = line.slice(0, 2).trim();
|
|
1601
|
+
const rest = line.slice(3);
|
|
1602
|
+
const arrowIndex = rest.indexOf(" -> ");
|
|
1603
|
+
const path = arrowIndex >= 0 ? rest.slice(arrowIndex + 4) : rest;
|
|
1604
|
+
return { path, status };
|
|
1605
|
+
});
|
|
1095
1606
|
}
|
|
1096
|
-
async function
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
{ status: "failed", endedAt: (/* @__PURE__ */ new Date()).toISOString() },
|
|
1105
|
-
{ projectId: card.project_id, cardId, userId: config.ownerUserId }
|
|
1106
|
-
);
|
|
1107
|
-
return false;
|
|
1607
|
+
async function getRepoDiffForTarget(label, cwd) {
|
|
1608
|
+
let files;
|
|
1609
|
+
try {
|
|
1610
|
+
const { stdout } = await execFileAsync2("git", ["status", "--porcelain=v1"], { cwd });
|
|
1611
|
+
files = parseGitStatusPorcelain(stdout);
|
|
1612
|
+
} catch (err) {
|
|
1613
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1614
|
+
return { label, files: [], diff: "", error: message };
|
|
1108
1615
|
}
|
|
1109
|
-
|
|
1110
|
-
let pty;
|
|
1111
|
-
let newClaudeSessionId = null;
|
|
1616
|
+
if (files.length === 0) return { label, files, diff: "" };
|
|
1112
1617
|
try {
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
}
|
|
1618
|
+
const { stdout } = await execFileAsync2("git", ["diff", "HEAD"], { ...DIFF_EXEC_OPTIONS, cwd });
|
|
1619
|
+
return { label, files, diff: stdout };
|
|
1620
|
+
} catch {
|
|
1621
|
+
return { label, files, diff: "" };
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
async function handleDiffRequest(entry, res, projectId) {
|
|
1625
|
+
try {
|
|
1626
|
+
const children = statuslineSupabase && projectId ? await getProjectRepos(statuslineSupabase, projectId) : [];
|
|
1627
|
+
const targets = children.length > 0 ? children.map((child2) => ({ label: child2.subpath, cwd: join6(entry.repoRoot, child2.subpath) })) : [{ label: null, cwd: entry.repoRoot }];
|
|
1628
|
+
const repos = await Promise.all(targets.map((target) => getRepoDiffForTarget(target.label, target.cwd)));
|
|
1629
|
+
sendJson(res, 200, { repos });
|
|
1125
1630
|
} catch (err) {
|
|
1126
|
-
logger.
|
|
1127
|
-
|
|
1128
|
-
supabase,
|
|
1129
|
-
sessionId,
|
|
1130
|
-
{ status: "failed", endedAt: (/* @__PURE__ */ new Date()).toISOString() },
|
|
1131
|
-
{ projectId: card.project_id, cardId, userId: config.ownerUserId }
|
|
1132
|
-
);
|
|
1133
|
-
return false;
|
|
1631
|
+
logger.runner.error("FILES", "Unexpected error handling a diff request:", err);
|
|
1632
|
+
sendJson(res, 500, { error: "Erro interno do runner." });
|
|
1134
1633
|
}
|
|
1135
|
-
registerPtySession(sessionId, pty, ticket);
|
|
1136
|
-
activeSessionByCard.set(cardId, sessionId);
|
|
1137
|
-
const activityWatcher = watchAgentActivity(supabase, config, cardId, sessionId, pty);
|
|
1138
|
-
await updateSession(
|
|
1139
|
-
supabase,
|
|
1140
|
-
sessionId,
|
|
1141
|
-
{
|
|
1142
|
-
status: "running",
|
|
1143
|
-
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1144
|
-
terminalTicket: ticket,
|
|
1145
|
-
terminalTicketExpiresAt: new Date(Date.now() + TERMINAL_TICKET_TTL_MS).toISOString(),
|
|
1146
|
-
// Only stamp a fresh spawn as "implementing" — a resumed session (claudeSessionId was
|
|
1147
|
-
// already set) keeps whatever agent_state it had before the restart (e.g.
|
|
1148
|
-
// "usage_limited"), so reconcileUsageLimitedSessions can find it afterwards. The
|
|
1149
|
-
// freshly (re)constructed AgentActivityWatcher below will correct it the moment real
|
|
1150
|
-
// output gives it something conclusive to classify.
|
|
1151
|
-
...newClaudeSessionId ? { agentState: "implementing", claudeSessionId: newClaudeSessionId } : {}
|
|
1152
|
-
},
|
|
1153
|
-
{ projectId: card.project_id, cardId, userId: config.ownerUserId }
|
|
1154
|
-
);
|
|
1155
|
-
pty.onExit(({ exitCode }) => {
|
|
1156
|
-
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.`);
|
|
1157
|
-
activityWatcher.stop();
|
|
1158
|
-
cancelUsageResume(sessionId);
|
|
1159
|
-
activeSessionByCard.delete(cardId);
|
|
1160
|
-
});
|
|
1161
|
-
return true;
|
|
1162
1634
|
}
|
|
1163
|
-
function
|
|
1164
|
-
const
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1635
|
+
async function handleAgentFileWrite(req, res, atClaudeRoot, query) {
|
|
1636
|
+
const entry = atClaudeRoot ? authenticateAgent(query) : authenticate(query);
|
|
1637
|
+
if (!entry) {
|
|
1638
|
+
sendJson(res, 401, { error: "Ticket inv\xE1lido ou expirado." });
|
|
1639
|
+
return;
|
|
1640
|
+
}
|
|
1641
|
+
let body;
|
|
1642
|
+
try {
|
|
1643
|
+
const raw = await readRequestBody(req, MAX_FILE_SIZE_BYTES + 1e3);
|
|
1644
|
+
body = JSON.parse(raw);
|
|
1645
|
+
} catch (err) {
|
|
1646
|
+
const status = err instanceof HttpError ? err.status : 400;
|
|
1647
|
+
sendJson(res, status, { error: "Corpo inv\xE1lido." });
|
|
1648
|
+
return;
|
|
1649
|
+
}
|
|
1650
|
+
if (typeof body.content !== "string") {
|
|
1651
|
+
sendJson(res, 400, { error: "Campo content \xE9 obrigat\xF3rio." });
|
|
1652
|
+
return;
|
|
1653
|
+
}
|
|
1654
|
+
const relPath = typeof query.path === "string" ? query.path : "";
|
|
1655
|
+
try {
|
|
1656
|
+
assertWritableAgentPath(relPath, atClaudeRoot);
|
|
1657
|
+
writeTextFile(entry.repoRoot, relPath, body.content);
|
|
1658
|
+
sendJson(res, 200, { ok: true });
|
|
1659
|
+
} catch (err) {
|
|
1660
|
+
if (err instanceof HttpError) {
|
|
1661
|
+
sendJson(res, err.status, { error: err.message });
|
|
1662
|
+
return;
|
|
1172
1663
|
}
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1664
|
+
logger.runner.error("FILES", "Unexpected error handling an agent file write:", err);
|
|
1665
|
+
sendJson(res, 500, { error: "Erro interno do runner." });
|
|
1666
|
+
}
|
|
1176
1667
|
}
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1668
|
+
function handleRequest(req, res) {
|
|
1669
|
+
if (req.method === "OPTIONS") {
|
|
1670
|
+
res.writeHead(204, {
|
|
1671
|
+
"Access-Control-Allow-Origin": "*",
|
|
1672
|
+
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
1673
|
+
// Needed since the new write routes below send a JSON body (Content-Type:
|
|
1674
|
+
// application/json), which makes the browser preflight the request — unlike every
|
|
1675
|
+
// other route here, which is a plain GET the browser never preflights.
|
|
1676
|
+
"Access-Control-Allow-Headers": "Content-Type"
|
|
1677
|
+
});
|
|
1678
|
+
res.end();
|
|
1679
|
+
return;
|
|
1680
|
+
}
|
|
1681
|
+
const { pathname, query } = parse(req.url ?? "", true);
|
|
1682
|
+
if (pathname === "/agent-skills-list" || pathname === "/global-agent-skills-list") {
|
|
1683
|
+
if (req.method !== "GET") {
|
|
1684
|
+
sendJson(res, 405, { error: "M\xE9todo n\xE3o suportado." });
|
|
1685
|
+
return;
|
|
1183
1686
|
}
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
}
|
|
1192
|
-
if (spawned) {
|
|
1193
|
-
logger.card.info(entry.card_id, "Re-spawned orphaned session (runner restarted since it was last running).");
|
|
1194
|
-
}
|
|
1687
|
+
void handleSkillsList(res, query, pathname === "/global-agent-skills-list");
|
|
1688
|
+
return;
|
|
1689
|
+
}
|
|
1690
|
+
if (pathname === "/agent-skills-find" || pathname === "/global-agent-skills-find") {
|
|
1691
|
+
if (req.method !== "GET") {
|
|
1692
|
+
sendJson(res, 405, { error: "M\xE9todo n\xE3o suportado." });
|
|
1693
|
+
return;
|
|
1195
1694
|
}
|
|
1695
|
+
void handleSkillsFind(res, query, pathname === "/global-agent-skills-find");
|
|
1696
|
+
return;
|
|
1196
1697
|
}
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
if (error) {
|
|
1202
|
-
logger.runner.error("ORCHESTRATOR", `Failed to scan usage-limited sessions for project ${project.id}:`, error.message);
|
|
1203
|
-
continue;
|
|
1698
|
+
if (pathname === "/agent-skills-popular" || pathname === "/global-agent-skills-popular") {
|
|
1699
|
+
if (req.method !== "GET") {
|
|
1700
|
+
sendJson(res, 405, { error: "M\xE9todo n\xE3o suportado." });
|
|
1701
|
+
return;
|
|
1204
1702
|
}
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1703
|
+
void handleSkillsPopular(res, query, pathname === "/global-agent-skills-popular");
|
|
1704
|
+
return;
|
|
1705
|
+
}
|
|
1706
|
+
if (pathname === "/agent-skills-install" || pathname === "/global-agent-skills-install") {
|
|
1707
|
+
if (req.method !== "POST") {
|
|
1708
|
+
sendJson(res, 405, { error: "M\xE9todo n\xE3o suportado." });
|
|
1709
|
+
return;
|
|
1209
1710
|
}
|
|
1711
|
+
void handleSkillsInstall(req, res, query, pathname === "/global-agent-skills-install");
|
|
1712
|
+
return;
|
|
1210
1713
|
}
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
activeSessionByCard.delete(card.id);
|
|
1218
|
-
await updateSession(
|
|
1219
|
-
supabase,
|
|
1220
|
-
sessionId,
|
|
1221
|
-
{ status: "done", endedAt: (/* @__PURE__ */ new Date()).toISOString() },
|
|
1222
|
-
{ projectId: card.project_id, cardId: card.id, userId: config.ownerUserId }
|
|
1223
|
-
);
|
|
1224
|
-
logger.card.info(card.id, `Session ${sessionId} stopped.`);
|
|
1225
|
-
await dispatchRunnerQueue(supabase, config);
|
|
1226
|
-
}
|
|
1227
|
-
|
|
1228
|
-
// src/deploy.ts
|
|
1229
|
-
async function handleDeployRequested(supabase, config, project) {
|
|
1230
|
-
const registered = config.registeredProjects.find((candidate) => candidate.id === project.id);
|
|
1231
|
-
if (!registered) {
|
|
1232
|
-
logger.runner.warn("DEPLOY", `Ignoring deploy request: project ${project.id} is not registered on this runner.`);
|
|
1714
|
+
if (pathname === "/agent-skills-remove" || pathname === "/global-agent-skills-remove") {
|
|
1715
|
+
if (req.method !== "POST") {
|
|
1716
|
+
sendJson(res, 405, { error: "M\xE9todo n\xE3o suportado." });
|
|
1717
|
+
return;
|
|
1718
|
+
}
|
|
1719
|
+
void handleSkillsRemove(req, res, query, pathname === "/global-agent-skills-remove");
|
|
1233
1720
|
return;
|
|
1234
1721
|
}
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1722
|
+
if (pathname === "/agent-skills-update" || pathname === "/global-agent-skills-update") {
|
|
1723
|
+
if (req.method !== "POST") {
|
|
1724
|
+
sendJson(res, 405, { error: "M\xE9todo n\xE3o suportado." });
|
|
1725
|
+
return;
|
|
1726
|
+
}
|
|
1727
|
+
void handleSkillsUpdate(req, res, query, pathname === "/global-agent-skills-update");
|
|
1240
1728
|
return;
|
|
1241
1729
|
}
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
for (const target of targets) {
|
|
1247
|
-
const result = await pushBranchToMain(target.path);
|
|
1248
|
-
if (result.ok) {
|
|
1249
|
-
logger.runner.info("DEPLOY", target.label ? `Pushed HEAD to main in "${target.label}".` : "Pushed HEAD to main.");
|
|
1250
|
-
} else {
|
|
1251
|
-
logger.runner.error("DEPLOY", target.label ? `Push to main failed in "${target.label}": ${result.error}` : `Push to main failed: ${result.error}`);
|
|
1252
|
-
failures.push(target.label ? `${target.label}: ${result.error}` : result.error);
|
|
1730
|
+
if (req.method === "POST" && pathname === "/statusline") {
|
|
1731
|
+
if (!statuslineSupabase || !statuslineConfig) {
|
|
1732
|
+
sendJson(res, 500, { error: "Runner ainda n\xE3o inicializado." });
|
|
1733
|
+
return;
|
|
1253
1734
|
}
|
|
1735
|
+
void handleStatuslineReport(req, res, statuslineSupabase, statuslineConfig);
|
|
1736
|
+
return;
|
|
1254
1737
|
}
|
|
1255
|
-
if (
|
|
1256
|
-
|
|
1257
|
-
await Promise.all(cards.map((card) => updateCard(supabase, card.id, { deployError: message })));
|
|
1258
|
-
await clearProjectDeployRequest(supabase, project.id);
|
|
1738
|
+
if (req.method === "POST" && pathname === "/agent-hook") {
|
|
1739
|
+
void handleAgentHookReport(req, res);
|
|
1259
1740
|
return;
|
|
1260
1741
|
}
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
async function checkPendingDeploys(supabase, config) {
|
|
1265
|
-
for (const registered of config.registeredProjects) {
|
|
1266
|
-
const project = await getProject(supabase, registered.id);
|
|
1267
|
-
if (project.deploy_requested_at) {
|
|
1268
|
-
await handleDeployRequested(supabase, config, project);
|
|
1269
|
-
}
|
|
1742
|
+
if (req.method === "POST" && (pathname === "/agent-file" || pathname === "/global-agent-file")) {
|
|
1743
|
+
void handleAgentFileWrite(req, res, pathname === "/global-agent-file", query);
|
|
1744
|
+
return;
|
|
1270
1745
|
}
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
releaseCard(card.id);
|
|
1275
|
-
logger.card.info(card.id, 'Reached "fim" \u2014 session stopped, preview tunnel (if any) closed.');
|
|
1276
|
-
}
|
|
1277
|
-
|
|
1278
|
-
// src/filesServer.ts
|
|
1279
|
-
import { execFile as execFile2 } from "node:child_process";
|
|
1280
|
-
import { randomUUID as randomUUID5 } from "node:crypto";
|
|
1281
|
-
import { existsSync as existsSync4, lstatSync, readdirSync, readFileSync as readFileSync4 } from "node:fs";
|
|
1282
|
-
import { join as join8, resolve as resolve2, sep } from "node:path";
|
|
1283
|
-
import { parse } from "node:url";
|
|
1284
|
-
import { promisify as promisify2 } from "node:util";
|
|
1285
|
-
|
|
1286
|
-
// src/shellSession.ts
|
|
1287
|
-
import { randomUUID as randomUUID3 } from "node:crypto";
|
|
1288
|
-
import { spawn as spawn4 } from "node-pty";
|
|
1289
|
-
var SHELL_COMMAND = process.platform === "win32" ? process.env.COMSPEC ?? "cmd.exe" : process.env.SHELL ?? "bash";
|
|
1290
|
-
function openShellSession(repoPath) {
|
|
1291
|
-
const sessionId = `shell:${randomUUID3()}`;
|
|
1292
|
-
const ticket = randomUUID3();
|
|
1293
|
-
const pty = spawn4(SHELL_COMMAND, [], {
|
|
1294
|
-
name: "xterm-color",
|
|
1295
|
-
cols: 120,
|
|
1296
|
-
rows: 32,
|
|
1297
|
-
cwd: repoPath
|
|
1298
|
-
});
|
|
1299
|
-
registerPtySession(sessionId, pty, ticket);
|
|
1300
|
-
pty.onExit(({ exitCode }) => {
|
|
1301
|
-
logger.runner.info("SHELL", `Standalone shell ${sessionId} exited (code ${exitCode}).`);
|
|
1302
|
-
stopPtySession(sessionId);
|
|
1303
|
-
});
|
|
1304
|
-
return { sessionId, ticket };
|
|
1305
|
-
}
|
|
1306
|
-
|
|
1307
|
-
// src/usageMonitor.ts
|
|
1308
|
-
import { randomUUID as randomUUID4 } from "node:crypto";
|
|
1309
|
-
import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync4 } from "node:fs";
|
|
1310
|
-
import { homedir as homedir3, tmpdir as tmpdir3 } from "node:os";
|
|
1311
|
-
import { join as join7 } from "node:path";
|
|
1312
|
-
import { spawn as spawn5 } from "node-pty";
|
|
1313
|
-
var SESSION_ID = "usage-monitor";
|
|
1314
|
-
var TICKET = randomUUID4();
|
|
1315
|
-
var SCRATCH_DIR = join7(tmpdir3(), "agent-board-usage-monitor");
|
|
1316
|
-
var POLL_INTERVAL_MS = Number(process.env.AGENT_BOARD_USAGE_POLL_INTERVAL_MS ?? 15 * 6e4);
|
|
1317
|
-
var IDLE_MS = 2e3;
|
|
1318
|
-
var HARD_TIMEOUT_MS = 6e4;
|
|
1319
|
-
function usageMonitorSessionId() {
|
|
1320
|
-
return SESSION_ID;
|
|
1321
|
-
}
|
|
1322
|
-
function usageMonitorTicket() {
|
|
1323
|
-
return TICKET;
|
|
1324
|
-
}
|
|
1325
|
-
function ensureScratchDirTrusted() {
|
|
1326
|
-
const claudeJsonPath = join7(homedir3(), ".claude.json");
|
|
1327
|
-
if (!existsSync3(claudeJsonPath)) return;
|
|
1328
|
-
try {
|
|
1329
|
-
const raw = readFileSync3(claudeJsonPath, "utf8");
|
|
1330
|
-
const data = JSON.parse(raw);
|
|
1331
|
-
data.projects ??= {};
|
|
1332
|
-
const keys = [SCRATCH_DIR, SCRATCH_DIR.replace(/\\/g, "/")];
|
|
1333
|
-
const alreadyTrusted = keys.every((key) => data.projects[key]?.hasTrustDialogAccepted === true);
|
|
1334
|
-
if (alreadyTrusted) return;
|
|
1335
|
-
for (const key of keys) {
|
|
1336
|
-
data.projects[key] = { ...data.projects[key], hasTrustDialogAccepted: true };
|
|
1337
|
-
}
|
|
1338
|
-
writeFileSync4(claudeJsonPath, JSON.stringify(data, null, 2));
|
|
1339
|
-
logger.runner.info("USAGE_MONITOR", `Pre-trusted scratch directory ${SCRATCH_DIR} in ~/.claude.json.`);
|
|
1340
|
-
} catch (err) {
|
|
1341
|
-
logger.runner.error("USAGE_MONITOR", "Failed to pre-trust the usage-monitor scratch directory:", err);
|
|
1746
|
+
if (req.method === "POST" && pathname === "/clone") {
|
|
1747
|
+
void handleCloneRequest(req, res, query);
|
|
1748
|
+
return;
|
|
1342
1749
|
}
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
let pty;
|
|
1346
|
-
try {
|
|
1347
|
-
pty = spawn5(
|
|
1348
|
-
CLAUDE_BINARY,
|
|
1349
|
-
[
|
|
1350
|
-
"--dangerously-skip-permissions",
|
|
1351
|
-
"--model",
|
|
1352
|
-
"claude-haiku-4-5",
|
|
1353
|
-
// cheapest model — this exchange exists only to trigger a statusLine update
|
|
1354
|
-
...statuslineSettingsArgs(),
|
|
1355
|
-
"reply with just the word: ok"
|
|
1356
|
-
],
|
|
1357
|
-
{
|
|
1358
|
-
name: "xterm-color",
|
|
1359
|
-
cols: 80,
|
|
1360
|
-
rows: 24,
|
|
1361
|
-
cwd: SCRATCH_DIR,
|
|
1362
|
-
env: statuslineEnv(SESSION_ID, TICKET)
|
|
1363
|
-
}
|
|
1364
|
-
);
|
|
1365
|
-
} catch (err) {
|
|
1366
|
-
logger.runner.error("USAGE_MONITOR", "Failed to spawn the usage-poll session:", err);
|
|
1750
|
+
if (req.method !== "GET") {
|
|
1751
|
+
sendJson(res, 405, { error: "M\xE9todo n\xE3o suportado." });
|
|
1367
1752
|
return;
|
|
1368
1753
|
}
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
clearTimeout(hardTimer);
|
|
1754
|
+
const relPath = typeof query.path === "string" ? query.path : "";
|
|
1755
|
+
if (pathname === "/browse") {
|
|
1756
|
+
const setupEntry = authenticateSetup(query);
|
|
1757
|
+
if (!setupEntry) {
|
|
1758
|
+
sendJson(res, 401, { error: "Ticket inv\xE1lido, expirado, ou navega\xE7\xE3o de pastas desativada." });
|
|
1759
|
+
return;
|
|
1760
|
+
}
|
|
1377
1761
|
try {
|
|
1378
|
-
|
|
1379
|
-
} catch {
|
|
1762
|
+
sendJson(res, 200, { entries: listDirectoryForBrowse(setupEntry.rootPath, relPath) });
|
|
1763
|
+
} catch (err) {
|
|
1764
|
+
if (err instanceof HttpError) {
|
|
1765
|
+
sendJson(res, err.status, { error: err.message });
|
|
1766
|
+
return;
|
|
1767
|
+
}
|
|
1768
|
+
logger.runner.error("FILES", "Unexpected error handling a browse request:", err);
|
|
1769
|
+
sendJson(res, 500, { error: "Erro interno do runner." });
|
|
1380
1770
|
}
|
|
1771
|
+
return;
|
|
1381
1772
|
}
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
}
|
|
1388
|
-
function startUsageMonitor() {
|
|
1389
|
-
mkdirSync3(SCRATCH_DIR, { recursive: true });
|
|
1390
|
-
ensureScratchDirTrusted();
|
|
1391
|
-
pollUsageOnce();
|
|
1392
|
-
setInterval(pollUsageOnce, POLL_INTERVAL_MS);
|
|
1393
|
-
}
|
|
1394
|
-
|
|
1395
|
-
// src/filesServer.ts
|
|
1396
|
-
var execFileAsync2 = promisify2(execFile2);
|
|
1397
|
-
var FILES_TICKET_TTL_MS = 15 * 60 * 1e3;
|
|
1398
|
-
var FILES_TICKET_REFRESH_MARGIN_MS = FILES_TICKET_TTL_MS / 3;
|
|
1399
|
-
var MAX_FILE_SIZE_BYTES = 1e6;
|
|
1400
|
-
var MAX_LOGO_SIZE_BYTES = 5e6;
|
|
1401
|
-
var IGNORED_ENTRY_NAMES = /* @__PURE__ */ new Set([".git", ".claude", "node_modules", "dist", "build"]);
|
|
1402
|
-
var BRAND_ICON_BASE_NAMES = ["logo", "favicon"];
|
|
1403
|
-
var MIME_TYPES_BY_EXT = {
|
|
1404
|
-
svg: "image/svg+xml",
|
|
1405
|
-
png: "image/png",
|
|
1406
|
-
jpg: "image/jpeg",
|
|
1407
|
-
jpeg: "image/jpeg",
|
|
1408
|
-
webp: "image/webp",
|
|
1409
|
-
gif: "image/gif",
|
|
1410
|
-
ico: "image/x-icon"
|
|
1411
|
-
};
|
|
1412
|
-
var ticketsByProject = /* @__PURE__ */ new Map();
|
|
1413
|
-
var setupTicket = null;
|
|
1414
|
-
var statuslineSupabase = null;
|
|
1415
|
-
var statuslineConfig = null;
|
|
1416
|
-
var MAX_STATUSLINE_BODY_BYTES = 16e3;
|
|
1417
|
-
var HttpError = class extends Error {
|
|
1418
|
-
constructor(status, message) {
|
|
1419
|
-
super(message);
|
|
1420
|
-
this.status = status;
|
|
1421
|
-
}
|
|
1422
|
-
};
|
|
1423
|
-
async function ensureFilesTickets(supabase, config) {
|
|
1424
|
-
for (const project of config.registeredProjects) {
|
|
1425
|
-
if (!project.isReferenceRunner) continue;
|
|
1426
|
-
const existing = ticketsByProject.get(project.id);
|
|
1427
|
-
if (existing && existing.expiresAt - Date.now() > FILES_TICKET_REFRESH_MARGIN_MS) continue;
|
|
1428
|
-
const ticket = randomUUID5();
|
|
1429
|
-
const expiresAtMs = Date.now() + FILES_TICKET_TTL_MS;
|
|
1430
|
-
ticketsByProject.set(project.id, { ticket, expiresAt: expiresAtMs, repoRoot: resolve2(project.repoPath) });
|
|
1773
|
+
if (pathname === "/global-agent-tree" || pathname === "/global-agent-file") {
|
|
1774
|
+
const agentEntry = authenticateAgent(query);
|
|
1775
|
+
if (!agentEntry) {
|
|
1776
|
+
sendJson(res, 401, { error: "Ticket inv\xE1lido ou expirado." });
|
|
1777
|
+
return;
|
|
1778
|
+
}
|
|
1431
1779
|
try {
|
|
1432
|
-
|
|
1780
|
+
if (pathname === "/global-agent-tree") {
|
|
1781
|
+
sendJson(res, 200, { entries: listSafeAgentDirectory(agentEntry.repoRoot, relPath, true) });
|
|
1782
|
+
} else {
|
|
1783
|
+
assertSafeAgentPath(relPath, true);
|
|
1784
|
+
sendJson(res, 200, readTextFile(agentEntry.repoRoot, relPath));
|
|
1785
|
+
}
|
|
1433
1786
|
} catch (err) {
|
|
1434
|
-
|
|
1787
|
+
if (err instanceof HttpError) {
|
|
1788
|
+
sendJson(res, err.status, { error: err.message });
|
|
1789
|
+
return;
|
|
1790
|
+
}
|
|
1791
|
+
logger.runner.error("FILES", "Unexpected error handling a global agent request:", err);
|
|
1792
|
+
sendJson(res, 500, { error: "Erro interno do runner." });
|
|
1435
1793
|
}
|
|
1794
|
+
return;
|
|
1795
|
+
}
|
|
1796
|
+
const entry = authenticate(query);
|
|
1797
|
+
if (!entry) {
|
|
1798
|
+
sendJson(res, 401, { error: "Ticket inv\xE1lido, expirado, ou projeto desconhecido." });
|
|
1799
|
+
return;
|
|
1800
|
+
}
|
|
1801
|
+
if (pathname === "/branch") {
|
|
1802
|
+
void handleBranchRequest(entry, res, relPath);
|
|
1803
|
+
return;
|
|
1804
|
+
}
|
|
1805
|
+
if (pathname === "/diff") {
|
|
1806
|
+
const projectId = typeof query.projectId === "string" ? query.projectId : "";
|
|
1807
|
+
void handleDiffRequest(entry, res, projectId);
|
|
1808
|
+
return;
|
|
1436
1809
|
}
|
|
1437
|
-
}
|
|
1438
|
-
function authenticate(query) {
|
|
1439
|
-
const projectId = typeof query.projectId === "string" ? query.projectId : null;
|
|
1440
|
-
const ticket = typeof query.ticket === "string" ? query.ticket : null;
|
|
1441
|
-
if (!projectId || !ticket) return null;
|
|
1442
|
-
const entry = ticketsByProject.get(projectId);
|
|
1443
|
-
if (!entry || entry.ticket !== ticket || entry.expiresAt < Date.now()) return null;
|
|
1444
|
-
return entry;
|
|
1445
|
-
}
|
|
1446
|
-
async function ensureSetupTicket(supabase, config) {
|
|
1447
|
-
if (!config.allowedRootPath) return;
|
|
1448
|
-
if (setupTicket && setupTicket.expiresAt - Date.now() > FILES_TICKET_REFRESH_MARGIN_MS) return;
|
|
1449
|
-
const ticket = randomUUID5();
|
|
1450
|
-
const expiresAtMs = Date.now() + FILES_TICKET_TTL_MS;
|
|
1451
|
-
setupTicket = { ticket, expiresAt: expiresAtMs, rootPath: resolve2(config.allowedRootPath) };
|
|
1452
1810
|
try {
|
|
1453
|
-
|
|
1811
|
+
if (pathname === "/tree") {
|
|
1812
|
+
sendJson(res, 200, { entries: listDirectory(entry.repoRoot, relPath) });
|
|
1813
|
+
return;
|
|
1814
|
+
}
|
|
1815
|
+
if (pathname === "/file") {
|
|
1816
|
+
sendJson(res, 200, readTextFile(entry.repoRoot, relPath));
|
|
1817
|
+
return;
|
|
1818
|
+
}
|
|
1819
|
+
if (pathname === "/agent-tree") {
|
|
1820
|
+
sendJson(res, 200, { entries: listSafeAgentDirectory(entry.repoRoot, relPath, false) });
|
|
1821
|
+
return;
|
|
1822
|
+
}
|
|
1823
|
+
if (pathname === "/agent-file") {
|
|
1824
|
+
assertSafeAgentPath(relPath, false);
|
|
1825
|
+
sendJson(res, 200, readTextFile(entry.repoRoot, relPath));
|
|
1826
|
+
return;
|
|
1827
|
+
}
|
|
1828
|
+
if (pathname === "/logo") {
|
|
1829
|
+
if (!relPath) throw new HttpError(404, "Nenhuma logo configurada para este projeto.");
|
|
1830
|
+
const { buffer, contentType } = readImageFile(entry.repoRoot, relPath);
|
|
1831
|
+
res.writeHead(200, {
|
|
1832
|
+
"Content-Type": contentType,
|
|
1833
|
+
"Content-Length": buffer.length,
|
|
1834
|
+
"Cache-Control": "no-cache",
|
|
1835
|
+
"Access-Control-Allow-Origin": "*"
|
|
1836
|
+
});
|
|
1837
|
+
res.end(buffer);
|
|
1838
|
+
return;
|
|
1839
|
+
}
|
|
1840
|
+
if (pathname === "/shell/open") {
|
|
1841
|
+
sendJson(res, 200, openShellSession(entry.repoRoot));
|
|
1842
|
+
return;
|
|
1843
|
+
}
|
|
1844
|
+
if (pathname === "/shell/close") {
|
|
1845
|
+
const sessionId = typeof query.sessionId === "string" ? query.sessionId : "";
|
|
1846
|
+
if (!sessionId) throw new HttpError(400, "Par\xE2metro sessionId \xE9 obrigat\xF3rio.");
|
|
1847
|
+
closeShellSession(sessionId);
|
|
1848
|
+
sendJson(res, 200, { ok: true });
|
|
1849
|
+
return;
|
|
1850
|
+
}
|
|
1851
|
+
if (pathname === "/column-shell/open" || pathname === "/column-shell/close") {
|
|
1852
|
+
const projectId = typeof query.projectId === "string" ? query.projectId : "";
|
|
1853
|
+
const column = typeof query.column === "string" ? query.column : null;
|
|
1854
|
+
if (!column || !BOARD_COLUMNS.includes(column)) throw new HttpError(400, "Par\xE2metro column inv\xE1lido ou ausente.");
|
|
1855
|
+
const kind = typeof query.kind === "string" ? query.kind : "shell";
|
|
1856
|
+
if (!COLUMN_SHELL_KINDS.includes(kind)) throw new HttpError(400, "Par\xE2metro kind inv\xE1lido.");
|
|
1857
|
+
const key = `${projectId}:${column}:${kind}`;
|
|
1858
|
+
if (pathname === "/column-shell/open") {
|
|
1859
|
+
const shell = openOrReuseColumnShell(key, entry.repoRoot, kind === "claude" ? CLAUDE_BYPASS_COMMAND : void 0);
|
|
1860
|
+
if (statuslineSupabase) void upsertColumnShell(statuslineSupabase, projectId, column, kind, shell.sessionId, shell.ticket);
|
|
1861
|
+
sendJson(res, 200, shell);
|
|
1862
|
+
return;
|
|
1863
|
+
}
|
|
1864
|
+
closeColumnShell(key);
|
|
1865
|
+
if (statuslineSupabase) void deleteColumnShell(statuslineSupabase, projectId, column, kind);
|
|
1866
|
+
sendJson(res, 200, { ok: true });
|
|
1867
|
+
return;
|
|
1868
|
+
}
|
|
1869
|
+
sendJson(res, 404, { error: "Rota desconhecida." });
|
|
1454
1870
|
} catch (err) {
|
|
1455
|
-
|
|
1871
|
+
if (err instanceof HttpError) {
|
|
1872
|
+
sendJson(res, err.status, { error: err.message });
|
|
1873
|
+
return;
|
|
1874
|
+
}
|
|
1875
|
+
logger.runner.error("FILES", "Unexpected error handling a files request:", err);
|
|
1876
|
+
sendJson(res, 500, { error: "Erro interno do runner." });
|
|
1456
1877
|
}
|
|
1457
1878
|
}
|
|
1458
|
-
function
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1879
|
+
function attachFilesServer(httpServer, supabase, config) {
|
|
1880
|
+
statuslineSupabase = supabase;
|
|
1881
|
+
statuslineConfig = config;
|
|
1882
|
+
httpServer.on("request", handleRequest);
|
|
1883
|
+
logger.runner.info("FILES", "Files HTTP handler attached.");
|
|
1463
1884
|
}
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1885
|
+
|
|
1886
|
+
// src/agentEventsServer.ts
|
|
1887
|
+
var stateByProject = /* @__PURE__ */ new Map();
|
|
1888
|
+
var socketsByProject = /* @__PURE__ */ new Map();
|
|
1889
|
+
function sendSnapshot(socket, projectId) {
|
|
1890
|
+
const states = Array.from(stateByProject.get(projectId)?.values() ?? []);
|
|
1891
|
+
socket.send(JSON.stringify({ type: "snapshot", states }));
|
|
1892
|
+
}
|
|
1893
|
+
function publishAgentStateEvent(projectId, cardId, sessionId, agentState) {
|
|
1894
|
+
let projectState = stateByProject.get(projectId);
|
|
1895
|
+
if (!projectState) {
|
|
1896
|
+
projectState = /* @__PURE__ */ new Map();
|
|
1897
|
+
stateByProject.set(projectId, projectState);
|
|
1898
|
+
}
|
|
1899
|
+
projectState.set(cardId, { sessionId, cardId, agentState });
|
|
1900
|
+
const sockets = socketsByProject.get(projectId);
|
|
1901
|
+
if (!sockets || sockets.size === 0) return;
|
|
1902
|
+
const payload = JSON.stringify({ type: "state", sessionId, cardId, agentState });
|
|
1903
|
+
for (const socket of sockets) {
|
|
1904
|
+
if (socket.readyState === socket.OPEN) socket.send(payload);
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
function attachAgentEventsServer() {
|
|
1908
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
1909
|
+
wss.on("connection", (socket, request) => {
|
|
1910
|
+
const { query } = parse2(request.url ?? "", true);
|
|
1911
|
+
const projectId = typeof query.projectId === "string" ? query.projectId : null;
|
|
1912
|
+
const entry = authenticate(query);
|
|
1913
|
+
if (!projectId || !entry) {
|
|
1914
|
+
logger.runner.warn("AGENT-EVENTS", "Agent events WS rejected: invalid or expired ticket.");
|
|
1915
|
+
socket.close(4401, "Invalid or expired ticket");
|
|
1916
|
+
return;
|
|
1917
|
+
}
|
|
1918
|
+
let sockets = socketsByProject.get(projectId);
|
|
1919
|
+
if (!sockets) {
|
|
1920
|
+
sockets = /* @__PURE__ */ new Set();
|
|
1921
|
+
socketsByProject.set(projectId, sockets);
|
|
1922
|
+
}
|
|
1923
|
+
sockets.add(socket);
|
|
1924
|
+
sendSnapshot(socket, projectId);
|
|
1925
|
+
socket.on("close", () => {
|
|
1926
|
+
sockets.delete(socket);
|
|
1927
|
+
});
|
|
1928
|
+
});
|
|
1929
|
+
logger.runner.info("AGENT-EVENTS", "Agent events WebSocket handler attached.");
|
|
1930
|
+
return wss;
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
// src/auth.ts
|
|
1934
|
+
import { createClient } from "@supabase/supabase-js";
|
|
1935
|
+
import { existsSync as existsSync4, mkdirSync as mkdirSync4, readFileSync as readFileSync4, rmSync as rmSync2, writeFileSync as writeFileSync7 } from "node:fs";
|
|
1936
|
+
import { join as join8 } from "node:path";
|
|
1937
|
+
import open from "open";
|
|
1938
|
+
|
|
1939
|
+
// src/config.ts
|
|
1940
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync6 } from "node:fs";
|
|
1941
|
+
import { hostname, homedir as homedir4 } from "node:os";
|
|
1942
|
+
import { join as join7, resolve as resolve2 } from "node:path";
|
|
1943
|
+
var CONFIG_DIR = join7(homedir4(), ".agent-board");
|
|
1944
|
+
var CONFIG_FILE = join7(CONFIG_DIR, "credentials.json");
|
|
1945
|
+
function loadConfig() {
|
|
1946
|
+
if (!existsSync3(CONFIG_FILE)) return null;
|
|
1947
|
+
return JSON.parse(readFileSync3(CONFIG_FILE, "utf-8"));
|
|
1948
|
+
}
|
|
1949
|
+
function saveConfig(config) {
|
|
1950
|
+
if (!existsSync3(CONFIG_DIR)) mkdirSync3(CONFIG_DIR, { recursive: true });
|
|
1951
|
+
writeFileSync6(CONFIG_FILE, JSON.stringify(config, null, 2), "utf-8");
|
|
1952
|
+
}
|
|
1953
|
+
function applyAllowedRoot(config, explicitPath) {
|
|
1954
|
+
const raw = explicitPath ?? process.env.AGENT_BOARD_ALLOWED_ROOT;
|
|
1955
|
+
if (!raw) return config;
|
|
1956
|
+
const resolved = resolve2(raw);
|
|
1957
|
+
if (config.allowedRootPath === resolved) return config;
|
|
1958
|
+
config.allowedRootPath = resolved;
|
|
1959
|
+
saveConfig(config);
|
|
1960
|
+
return config;
|
|
1961
|
+
}
|
|
1962
|
+
function applyRunnerName(config) {
|
|
1963
|
+
const name = process.env.AGENT_BOARD_RUNNER_NAME ?? hostname();
|
|
1964
|
+
if (config.runnerName === name) return config;
|
|
1965
|
+
config.runnerName = name;
|
|
1966
|
+
saveConfig(config);
|
|
1967
|
+
return config;
|
|
1968
|
+
}
|
|
1969
|
+
function resolveRepoPath(config, relativeRepoPath) {
|
|
1970
|
+
if (!config.allowedRootPath) {
|
|
1971
|
+
throw new Error("Runner has no repos root configured (AGENT_BOARD_ALLOWED_ROOT or `start --path`).");
|
|
1470
1972
|
}
|
|
1471
|
-
return
|
|
1973
|
+
return resolve2(config.allowedRootPath, relativeRepoPath);
|
|
1472
1974
|
}
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1975
|
+
|
|
1976
|
+
// src/auth.ts
|
|
1977
|
+
var SESSION_FILE = join8(CONFIG_DIR, "session.json");
|
|
1978
|
+
var DEFAULT_SUPABASE_URL = "https://brpxshvnmxgkbffgdfcc.supabase.co";
|
|
1979
|
+
var DEFAULT_SUPABASE_ANON_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImJycHhzaHZubXhna2JmZmdkZmNjIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODc1OTQwMTEsImV4cCI6MjEwMzE3MDAxMX0.ErsAGZysLFgNvijetR7w1YvIziXC-1D2sx04Hm2UW9c";
|
|
1980
|
+
function resolveSupabaseUrl() {
|
|
1981
|
+
return process.env.AGENT_BOARD_SUPABASE_URL ?? DEFAULT_SUPABASE_URL;
|
|
1982
|
+
}
|
|
1983
|
+
function resolveSupabaseAnonKey() {
|
|
1984
|
+
return process.env.AGENT_BOARD_SUPABASE_ANON_KEY ?? DEFAULT_SUPABASE_ANON_KEY;
|
|
1985
|
+
}
|
|
1986
|
+
function readSessionFile() {
|
|
1987
|
+
if (!existsSync4(SESSION_FILE)) return {};
|
|
1476
1988
|
try {
|
|
1477
|
-
|
|
1989
|
+
return JSON.parse(readFileSync4(SESSION_FILE, "utf-8"));
|
|
1478
1990
|
} catch {
|
|
1479
|
-
|
|
1991
|
+
return {};
|
|
1480
1992
|
}
|
|
1481
|
-
if (stat.isSymbolicLink()) throw new HttpError(403, "Links simb\xF3licos n\xE3o s\xE3o suportados.");
|
|
1482
|
-
if (!stat.isDirectory()) throw new HttpError(400, "O caminho informado n\xE3o \xE9 um diret\xF3rio.");
|
|
1483
|
-
const prefix = relPath.replace(/\/+$/, "");
|
|
1484
|
-
return readdirSync(target, { withFileTypes: true }).filter((dirent) => !dirent.isSymbolicLink() && !IGNORED_ENTRY_NAMES.has(dirent.name)).map((dirent) => ({
|
|
1485
|
-
name: dirent.name,
|
|
1486
|
-
path: prefix ? `${prefix}/${dirent.name}` : dirent.name,
|
|
1487
|
-
type: dirent.isDirectory() ? "dir" : "file"
|
|
1488
|
-
})).sort((a, b) => a.type !== b.type ? a.type === "dir" ? -1 : 1 : a.name.localeCompare(b.name));
|
|
1489
1993
|
}
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1994
|
+
var fileStorage = {
|
|
1995
|
+
getItem(key) {
|
|
1996
|
+
return readSessionFile()[key] ?? null;
|
|
1997
|
+
},
|
|
1998
|
+
setItem(key, value) {
|
|
1999
|
+
if (!existsSync4(CONFIG_DIR)) mkdirSync4(CONFIG_DIR, { recursive: true });
|
|
2000
|
+
const all = readSessionFile();
|
|
2001
|
+
all[key] = value;
|
|
2002
|
+
writeFileSync7(SESSION_FILE, JSON.stringify(all), "utf-8");
|
|
2003
|
+
},
|
|
2004
|
+
removeItem(key) {
|
|
2005
|
+
const all = readSessionFile();
|
|
2006
|
+
delete all[key];
|
|
2007
|
+
writeFileSync7(SESSION_FILE, JSON.stringify(all), "utf-8");
|
|
2008
|
+
}
|
|
2009
|
+
};
|
|
2010
|
+
function createAuthedSupabaseClient() {
|
|
2011
|
+
return createClient(resolveSupabaseUrl(), resolveSupabaseAnonKey(), {
|
|
2012
|
+
db: { schema: "agent_board" },
|
|
2013
|
+
auth: {
|
|
2014
|
+
storage: fileStorage,
|
|
2015
|
+
persistSession: true,
|
|
2016
|
+
autoRefreshToken: true,
|
|
2017
|
+
detectSessionInUrl: false,
|
|
2018
|
+
flowType: "pkce"
|
|
2019
|
+
}
|
|
2020
|
+
});
|
|
1495
2021
|
}
|
|
1496
|
-
function
|
|
1497
|
-
const
|
|
1498
|
-
return
|
|
2022
|
+
async function getSession(supabase) {
|
|
2023
|
+
const { data } = await supabase.auth.getSession();
|
|
2024
|
+
return data.session;
|
|
1499
2025
|
}
|
|
1500
|
-
function
|
|
1501
|
-
const
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
stat = lstatSync(target);
|
|
1505
|
-
} catch {
|
|
1506
|
-
throw new HttpError(404, "Arquivo n\xE3o encontrado.");
|
|
2026
|
+
async function requireSession(supabase) {
|
|
2027
|
+
const session = await getSession(supabase);
|
|
2028
|
+
if (!session) {
|
|
2029
|
+
throw new Error('Not logged in. Run "agent-board-runner login" first.');
|
|
1507
2030
|
}
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
2031
|
+
return session;
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
// src/columnActions.ts
|
|
2035
|
+
import { exec } from "node:child_process";
|
|
2036
|
+
import { join as join9 } from "node:path";
|
|
2037
|
+
import { promisify as promisify3 } from "node:util";
|
|
2038
|
+
var execAsync = promisify3(exec);
|
|
2039
|
+
var OUTPUT_TAIL_CHARS = 4e3;
|
|
2040
|
+
function tail(text) {
|
|
2041
|
+
return text.length > OUTPUT_TAIL_CHARS ? text.slice(text.length - OUTPUT_TAIL_CHARS) : text;
|
|
2042
|
+
}
|
|
2043
|
+
function resolveTargets(project, children, repoRoot, column, timing) {
|
|
2044
|
+
if (children.length > 0) {
|
|
2045
|
+
return children.flatMap(
|
|
2046
|
+
(child2) => (child2.column_actions[column] ?? []).filter((action) => action.timing === timing).map((action) => ({ label: child2.subpath, cwd: join9(repoRoot, child2.subpath), command: action.command }))
|
|
2047
|
+
);
|
|
1512
2048
|
}
|
|
1513
|
-
|
|
1514
|
-
if (looksBinary(buffer)) throw new HttpError(415, "Arquivo bin\xE1rio n\xE3o pode ser exibido.");
|
|
1515
|
-
return { content: buffer.toString("utf8"), size: stat.size };
|
|
2049
|
+
return (project.column_actions[column] ?? []).filter((action) => action.timing === timing).map((action) => ({ label: null, cwd: repoRoot, command: action.command }));
|
|
1516
2050
|
}
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
2051
|
+
var EXEC_OPTIONS = { maxBuffer: 10 * 1024 * 1024 };
|
|
2052
|
+
async function runCommand(target) {
|
|
2053
|
+
try {
|
|
2054
|
+
const { stdout, stderr } = await execAsync(target.command, { ...EXEC_OPTIONS, cwd: target.cwd });
|
|
2055
|
+
return { ok: true, output: tail(`${stdout}${stderr}`) };
|
|
2056
|
+
} catch (err) {
|
|
2057
|
+
const { stdout, stderr } = err;
|
|
2058
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2059
|
+
return { ok: false, output: tail(`${stdout ?? ""}${stderr ?? ""}` || message) };
|
|
2060
|
+
}
|
|
2061
|
+
}
|
|
2062
|
+
var inFlightColumns = /* @__PURE__ */ new Set();
|
|
2063
|
+
async function handleColumnEntered(supabase, config, card) {
|
|
2064
|
+
const key = `${card.project_id}:${card.column_name}`;
|
|
2065
|
+
if (inFlightColumns.has(key)) return;
|
|
2066
|
+
inFlightColumns.add(key);
|
|
2067
|
+
try {
|
|
2068
|
+
const project = await getProject(supabase, card.project_id);
|
|
2069
|
+
const children = await getProjectRepos(supabase, project.id);
|
|
2070
|
+
const repoRoot = resolveRepoPath(config, project.repo_path);
|
|
2071
|
+
const targets = resolveTargets(project, children, repoRoot, card.column_name, "before");
|
|
2072
|
+
if (targets.length === 0) return;
|
|
2073
|
+
const allCards = await listCardsInColumn(supabase, project.id, card.column_name);
|
|
2074
|
+
const cards = allCards.filter((candidate) => candidate.assigned_user_id === config.ownerUserId);
|
|
2075
|
+
if (cards.length === 0) return;
|
|
2076
|
+
await Promise.all(
|
|
2077
|
+
cards.map((candidate) => updateCard(supabase, candidate.id, { customActionStatus: "running", customActionOutput: null }))
|
|
2078
|
+
);
|
|
2079
|
+
let failure = null;
|
|
2080
|
+
for (const target of targets) {
|
|
2081
|
+
const result = await runCommand(target);
|
|
2082
|
+
if (!result.ok) {
|
|
2083
|
+
failure = { label: target.label, output: result.output };
|
|
2084
|
+
break;
|
|
1540
2085
|
}
|
|
1541
2086
|
}
|
|
2087
|
+
if (failure) {
|
|
2088
|
+
const message = failure.label ? `[${failure.label}] ${failure.output}` : failure.output;
|
|
2089
|
+
logger.runner.warn("COLUMN_ACTION", `Command failed for project ${project.id} in "${card.column_name}": ${message}`);
|
|
2090
|
+
await Promise.all(
|
|
2091
|
+
cards.map(
|
|
2092
|
+
(candidate) => updateCard(supabase, candidate.id, { customActionStatus: "failed", customActionOutput: message })
|
|
2093
|
+
)
|
|
2094
|
+
);
|
|
2095
|
+
} else {
|
|
2096
|
+
logger.runner.info("COLUMN_ACTION", `Command passed for project ${project.id} in "${card.column_name}".`);
|
|
2097
|
+
await Promise.all(
|
|
2098
|
+
cards.map(
|
|
2099
|
+
(candidate) => updateCard(supabase, candidate.id, { customActionStatus: "passed", customActionOutput: null })
|
|
2100
|
+
)
|
|
2101
|
+
);
|
|
2102
|
+
}
|
|
2103
|
+
} finally {
|
|
2104
|
+
inFlightColumns.delete(key);
|
|
2105
|
+
}
|
|
2106
|
+
}
|
|
2107
|
+
async function clearColumnActionStatus(supabase, card) {
|
|
2108
|
+
if (card.custom_action_status === null) return;
|
|
2109
|
+
await updateCard(supabase, card.id, { customActionStatus: null, customActionOutput: null });
|
|
2110
|
+
}
|
|
2111
|
+
async function runAfterColumnActions(project, children, repoRoot, column) {
|
|
2112
|
+
const targets = resolveTargets(project, children, repoRoot, column, "after");
|
|
2113
|
+
for (const target of targets) {
|
|
2114
|
+
const result = await runCommand(target);
|
|
2115
|
+
if (!result.ok) {
|
|
2116
|
+
return { ok: false, error: target.label ? `${target.label}: ${result.output}` : result.output };
|
|
2117
|
+
}
|
|
1542
2118
|
}
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
2119
|
+
return { ok: true };
|
|
2120
|
+
}
|
|
2121
|
+
async function checkPendingColumnActions(supabase, config) {
|
|
2122
|
+
for (const registered of config.registeredProjects) {
|
|
2123
|
+
for (const column of BOARD_COLUMNS) {
|
|
2124
|
+
const cards = await listCardsInColumn(supabase, registered.id, column);
|
|
2125
|
+
const pending2 = cards.find(
|
|
2126
|
+
(card) => card.assigned_user_id === config.ownerUserId && card.custom_action_status === null
|
|
2127
|
+
);
|
|
2128
|
+
if (pending2) await handleColumnEntered(supabase, config, pending2);
|
|
2129
|
+
}
|
|
1549
2130
|
}
|
|
1550
|
-
return null;
|
|
1551
2131
|
}
|
|
1552
|
-
function
|
|
1553
|
-
const
|
|
1554
|
-
|
|
2132
|
+
async function handleDeployActionRerunRequested(supabase, config, project) {
|
|
2133
|
+
const allCards = await listCardsInColumn(supabase, project.id, "deploy");
|
|
2134
|
+
const cards = allCards.filter((card) => card.assigned_user_id === config.ownerUserId);
|
|
2135
|
+
if (cards.length === 0) {
|
|
2136
|
+
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.`);
|
|
2137
|
+
} else {
|
|
2138
|
+
await handleColumnEntered(supabase, config, cards[0]);
|
|
2139
|
+
}
|
|
2140
|
+
await clearDeployActionRerunRequest(supabase, project.id);
|
|
2141
|
+
}
|
|
2142
|
+
async function checkPendingDeployActionReruns(supabase, config) {
|
|
2143
|
+
for (const registered of config.registeredProjects) {
|
|
2144
|
+
const project = await getProject(supabase, registered.id);
|
|
2145
|
+
if (project.deploy_action_rerun_requested_at) {
|
|
2146
|
+
await handleDeployActionRerunRequested(supabase, config, project);
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
// src/deploy.ts
|
|
2152
|
+
import { join as join12 } from "node:path";
|
|
2153
|
+
|
|
2154
|
+
// src/devServer.ts
|
|
2155
|
+
import { spawn as spawn4 } from "node:child_process";
|
|
2156
|
+
|
|
2157
|
+
// src/cloudflared.ts
|
|
2158
|
+
import { spawn as spawn3 } from "node:child_process";
|
|
2159
|
+
import { accessSync, constants } from "node:fs";
|
|
2160
|
+
import { homedir as homedir5 } from "node:os";
|
|
2161
|
+
import { delimiter, join as join10 } from "node:path";
|
|
2162
|
+
import { bin as bundledBinaryPath, install as downloadCloudflared } from "cloudflared";
|
|
2163
|
+
var TRYCLOUDFLARE_URL_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
|
|
2164
|
+
var STARTUP_TIMEOUT_MS = 2e4;
|
|
2165
|
+
var IS_WINDOWS2 = process.platform === "win32";
|
|
2166
|
+
var BINARY_NAME = IS_WINDOWS2 ? "cloudflared.exe" : "cloudflared";
|
|
2167
|
+
var CLOUDFLARED_INSTALL_DOCS_URL = "https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/";
|
|
2168
|
+
function wellKnownDirectories() {
|
|
2169
|
+
const home = homedir5();
|
|
2170
|
+
if (IS_WINDOWS2) {
|
|
2171
|
+
const candidates = [
|
|
2172
|
+
process.env.ProgramFiles && join10(process.env.ProgramFiles, "cloudflared"),
|
|
2173
|
+
process.env["ProgramFiles(x86)"] && join10(process.env["ProgramFiles(x86)"], "cloudflared"),
|
|
2174
|
+
process.env.LOCALAPPDATA && join10(process.env.LOCALAPPDATA, "Microsoft", "WinGet", "Links"),
|
|
2175
|
+
join10(home, "scoop", "shims"),
|
|
2176
|
+
process.env.ChocolateyInstall && join10(process.env.ChocolateyInstall, "bin")
|
|
2177
|
+
];
|
|
2178
|
+
return candidates.filter((dir) => Boolean(dir));
|
|
2179
|
+
}
|
|
2180
|
+
return ["/usr/local/bin", "/usr/bin", "/opt/homebrew/bin", join10(home, ".local", "bin")];
|
|
2181
|
+
}
|
|
2182
|
+
function pathDirectories() {
|
|
2183
|
+
return (process.env.PATH ?? "").split(delimiter).map((dir) => dir.trim().replace(/^"|"$/g, "")).filter(Boolean);
|
|
2184
|
+
}
|
|
2185
|
+
function isExecutableFile(path) {
|
|
1555
2186
|
try {
|
|
1556
|
-
|
|
2187
|
+
accessSync(path, constants.X_OK);
|
|
2188
|
+
return true;
|
|
1557
2189
|
} catch {
|
|
1558
|
-
|
|
2190
|
+
return false;
|
|
1559
2191
|
}
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
2192
|
+
}
|
|
2193
|
+
function findInstalledBinary() {
|
|
2194
|
+
const override = process.env.AGENT_BOARD_CLOUDFLARED_PATH;
|
|
2195
|
+
const directories = [...pathDirectories(), ...wellKnownDirectories()];
|
|
2196
|
+
const candidates = [...override ? [override] : [], ...directories.map((dir) => join10(dir, BINARY_NAME))];
|
|
2197
|
+
return candidates.find(isExecutableFile) ?? null;
|
|
2198
|
+
}
|
|
2199
|
+
async function locateCloudflared() {
|
|
2200
|
+
const installed = findInstalledBinary();
|
|
2201
|
+
if (installed) return installed;
|
|
2202
|
+
if (isExecutableFile(bundledBinaryPath)) return bundledBinaryPath;
|
|
2203
|
+
try {
|
|
2204
|
+
logger.runner.info("CLOUDFLARE", "Downloading the cloudflared binary (first run only)...");
|
|
2205
|
+
return await downloadCloudflared(bundledBinaryPath);
|
|
2206
|
+
} catch (err) {
|
|
2207
|
+
logger.runner.error("CLOUDFLARE", `Failed to download cloudflared. Install it manually: ${CLOUDFLARED_INSTALL_DOCS_URL}`, err);
|
|
2208
|
+
return null;
|
|
1564
2209
|
}
|
|
1565
|
-
const ext = relPath.split(".").pop()?.toLowerCase() ?? "";
|
|
1566
|
-
return { buffer: readFileSync4(target), contentType: MIME_TYPES_BY_EXT[ext] ?? "application/octet-stream" };
|
|
1567
2210
|
}
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
// from a different origin (e.g. Vercel) — the ticket is the actual security boundary,
|
|
1574
|
-
// so CORS is left wide open rather than trying to keep an allowlist of web origins.
|
|
1575
|
-
"Access-Control-Allow-Origin": "*"
|
|
2211
|
+
var lookup = null;
|
|
2212
|
+
function resolveCloudflaredPath() {
|
|
2213
|
+
lookup ??= locateCloudflared().then((path) => {
|
|
2214
|
+
if (!path) lookup = null;
|
|
2215
|
+
return path;
|
|
1576
2216
|
});
|
|
1577
|
-
|
|
2217
|
+
return lookup;
|
|
1578
2218
|
}
|
|
1579
|
-
function
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
2219
|
+
async function startQuickTunnel(label, port) {
|
|
2220
|
+
const executable = await resolveCloudflaredPath();
|
|
2221
|
+
if (!executable) {
|
|
2222
|
+
logger.runner.error("CLOUDFLARE", `No cloudflared binary available, so ${label} cannot be exposed.`);
|
|
2223
|
+
return null;
|
|
2224
|
+
}
|
|
2225
|
+
return new Promise((resolve4) => {
|
|
2226
|
+
let child2;
|
|
2227
|
+
try {
|
|
2228
|
+
child2 = spawn3(executable, [
|
|
2229
|
+
"tunnel",
|
|
2230
|
+
"--url",
|
|
2231
|
+
`http://localhost:${port}`,
|
|
2232
|
+
"--http-host-header",
|
|
2233
|
+
`localhost:${port}`
|
|
2234
|
+
]);
|
|
2235
|
+
} catch (err) {
|
|
2236
|
+
logger.runner.error("CLOUDFLARE", `Failed to spawn cloudflared for ${label}:`, err);
|
|
2237
|
+
resolve4(null);
|
|
2238
|
+
return;
|
|
2239
|
+
}
|
|
2240
|
+
let settled = false;
|
|
2241
|
+
const settle = (value) => {
|
|
2242
|
+
if (settled) return;
|
|
2243
|
+
settled = true;
|
|
2244
|
+
clearTimeout(timer);
|
|
2245
|
+
resolve4(value);
|
|
2246
|
+
};
|
|
2247
|
+
const timer = setTimeout(() => {
|
|
2248
|
+
logger.runner.warn("CLOUDFLARE", `cloudflared for ${label} did not print a trycloudflare.com URL within ${STARTUP_TIMEOUT_MS}ms.`);
|
|
2249
|
+
settle(null);
|
|
2250
|
+
}, STARTUP_TIMEOUT_MS);
|
|
2251
|
+
const onOutput = (chunk) => {
|
|
2252
|
+
if (settled) return;
|
|
2253
|
+
const match = chunk.toString("utf-8").match(TRYCLOUDFLARE_URL_RE);
|
|
2254
|
+
if (match) settle({ url: match[0], child: child2 });
|
|
2255
|
+
};
|
|
2256
|
+
child2.stdout?.on("data", onOutput);
|
|
2257
|
+
child2.stderr?.on("data", onOutput);
|
|
2258
|
+
child2.on("error", (err) => {
|
|
2259
|
+
logger.runner.error("CLOUDFLARE", `cloudflared process error for ${label} (${executable}):`, err);
|
|
2260
|
+
settle(null);
|
|
2261
|
+
});
|
|
2262
|
+
child2.on("exit", (code) => {
|
|
2263
|
+
logger.runner.info("CLOUDFLARE", `cloudflared tunnel for ${label} exited (code ${code}).`);
|
|
2264
|
+
settle(null);
|
|
1591
2265
|
});
|
|
1592
|
-
req.on("end", () => resolve4(Buffer.concat(chunks).toString("utf8")));
|
|
1593
|
-
req.on("error", reject);
|
|
1594
2266
|
});
|
|
1595
2267
|
}
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
2268
|
+
function toTunnelHostname(url) {
|
|
2269
|
+
return new URL(url).host;
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
// src/devServer.ts
|
|
2273
|
+
var STARTUP_TIMEOUT_MS2 = 45e3;
|
|
2274
|
+
var IS_WINDOWS3 = process.platform === "win32";
|
|
2275
|
+
var LOCAL_URL_PORT_RE = /https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\]|0\.0\.0\.0):(\d{2,5})/i;
|
|
2276
|
+
var GENERIC_PORT_RE = /(?:listening|running|server).{0,40}?:?\s*(\d{4,5})\b/i;
|
|
2277
|
+
var ANSI_ESCAPE_RE2 = /\x1b\[[0-9;]*m/g;
|
|
2278
|
+
function detectPortFromOutput(chunk) {
|
|
2279
|
+
const clean = chunk.replace(ANSI_ESCAPE_RE2, "");
|
|
2280
|
+
const match = LOCAL_URL_PORT_RE.exec(clean) ?? GENERIC_PORT_RE.exec(clean);
|
|
2281
|
+
return match ? Number(match[1]) : null;
|
|
2282
|
+
}
|
|
2283
|
+
function killProcessTree(child2) {
|
|
2284
|
+
if (child2.pid == null) return;
|
|
2285
|
+
if (IS_WINDOWS3) {
|
|
2286
|
+
spawn4("taskkill", ["/pid", String(child2.pid), "/t", "/f"], { stdio: "ignore" });
|
|
2287
|
+
} else {
|
|
2288
|
+
child2.kill();
|
|
1609
2289
|
}
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
2290
|
+
}
|
|
2291
|
+
var previews = /* @__PURE__ */ new Map();
|
|
2292
|
+
function ensurePreview(key, label, cwd, command, cardId) {
|
|
2293
|
+
const existing = previews.get(key);
|
|
2294
|
+
if (existing) {
|
|
2295
|
+
existing.cardIds.add(cardId);
|
|
2296
|
+
return existing.result;
|
|
1615
2297
|
}
|
|
2298
|
+
let child2;
|
|
1616
2299
|
try {
|
|
1617
|
-
|
|
1618
|
-
...typeof body.fiveHour?.used_percentage === "number" ? { sessionPercent: body.fiveHour.used_percentage } : {},
|
|
1619
|
-
...typeof body.fiveHour?.resets_at === "number" ? { sessionResetsAt: new Date(body.fiveHour.resets_at * 1e3).toISOString() } : {},
|
|
1620
|
-
...typeof body.sevenDay?.used_percentage === "number" ? { weekPercent: body.sevenDay.used_percentage } : {},
|
|
1621
|
-
...typeof body.sevenDay?.resets_at === "number" ? { weekResetsAt: new Date(body.sevenDay.resets_at * 1e3).toISOString() } : {}
|
|
1622
|
-
});
|
|
1623
|
-
res.writeHead(204, { "Access-Control-Allow-Origin": "*" });
|
|
1624
|
-
res.end();
|
|
2300
|
+
child2 = spawn4(command, { cwd, shell: true, windowsHide: true });
|
|
1625
2301
|
} catch (err) {
|
|
1626
|
-
logger.runner.error("
|
|
1627
|
-
|
|
2302
|
+
logger.runner.error("DEV_SERVER", `Failed to spawn "${command}" in ${cwd}:`, err);
|
|
2303
|
+
return Promise.resolve(null);
|
|
2304
|
+
}
|
|
2305
|
+
const entry = { child: child2, tunnelChild: null, cardIds: /* @__PURE__ */ new Set([cardId]), result: null };
|
|
2306
|
+
entry.result = new Promise((resolve4) => {
|
|
2307
|
+
let settled = false;
|
|
2308
|
+
const settle = (value) => {
|
|
2309
|
+
if (settled) return;
|
|
2310
|
+
settled = true;
|
|
2311
|
+
clearTimeout(timer);
|
|
2312
|
+
if (!value) {
|
|
2313
|
+
killProcessTree(child2);
|
|
2314
|
+
previews.delete(key);
|
|
2315
|
+
}
|
|
2316
|
+
resolve4(value);
|
|
2317
|
+
};
|
|
2318
|
+
const timer = setTimeout(() => {
|
|
2319
|
+
logger.runner.warn("DEV_SERVER", `"${command}" in ${cwd} did not reveal a port within ${STARTUP_TIMEOUT_MS2}ms.`);
|
|
2320
|
+
settle(null);
|
|
2321
|
+
}, STARTUP_TIMEOUT_MS2);
|
|
2322
|
+
const onOutput = async (chunk) => {
|
|
2323
|
+
if (settled) return;
|
|
2324
|
+
const port = detectPortFromOutput(chunk.toString("utf-8"));
|
|
2325
|
+
if (port == null) return;
|
|
2326
|
+
settled = true;
|
|
2327
|
+
clearTimeout(timer);
|
|
2328
|
+
const tunnel = await startQuickTunnel(`dev server ${key}`, port);
|
|
2329
|
+
if (!tunnel) {
|
|
2330
|
+
logger.runner.warn("DEV_SERVER", `Detected port ${port} for ${key} but cloudflared could not tunnel it.`);
|
|
2331
|
+
killProcessTree(child2);
|
|
2332
|
+
previews.delete(key);
|
|
2333
|
+
resolve4(null);
|
|
2334
|
+
return;
|
|
2335
|
+
}
|
|
2336
|
+
entry.tunnelChild = tunnel.child;
|
|
2337
|
+
logger.runner.info("DEV_SERVER", `${key} is up on port ${port}, tunneled at ${tunnel.url}.`);
|
|
2338
|
+
resolve4({ label, url: tunnel.url });
|
|
2339
|
+
};
|
|
2340
|
+
child2.stdout?.on("data", onOutput);
|
|
2341
|
+
child2.stderr?.on("data", onOutput);
|
|
2342
|
+
child2.on("error", (err) => {
|
|
2343
|
+
logger.runner.error("DEV_SERVER", `Dev server process error for ${key} (${command} in ${cwd}):`, err);
|
|
2344
|
+
settle(null);
|
|
2345
|
+
});
|
|
2346
|
+
child2.on("exit", (code) => {
|
|
2347
|
+
logger.runner.info("DEV_SERVER", `Dev server for ${key} exited (code ${code}) before/without a usable preview.`);
|
|
2348
|
+
settle(null);
|
|
2349
|
+
});
|
|
2350
|
+
});
|
|
2351
|
+
previews.set(key, entry);
|
|
2352
|
+
return entry.result;
|
|
2353
|
+
}
|
|
2354
|
+
function releaseCard(cardId) {
|
|
2355
|
+
for (const [key, entry] of previews) {
|
|
2356
|
+
if (!entry.cardIds.delete(cardId)) continue;
|
|
2357
|
+
if (entry.cardIds.size > 0) continue;
|
|
2358
|
+
killProcessTree(entry.child);
|
|
2359
|
+
entry.tunnelChild?.kill();
|
|
2360
|
+
previews.delete(key);
|
|
2361
|
+
}
|
|
2362
|
+
}
|
|
2363
|
+
function stopProjectPreviews(projectId) {
|
|
2364
|
+
const prefix = `${projectId}:`;
|
|
2365
|
+
for (const [key, entry] of previews) {
|
|
2366
|
+
if (!key.startsWith(prefix)) continue;
|
|
2367
|
+
killProcessTree(entry.child);
|
|
2368
|
+
entry.tunnelChild?.kill();
|
|
2369
|
+
previews.delete(key);
|
|
2370
|
+
}
|
|
2371
|
+
}
|
|
2372
|
+
function stopAll() {
|
|
2373
|
+
for (const key of [...previews.keys()]) {
|
|
2374
|
+
const entry = previews.get(key);
|
|
2375
|
+
if (!entry) continue;
|
|
2376
|
+
killProcessTree(entry.child);
|
|
2377
|
+
entry.tunnelChild?.kill();
|
|
2378
|
+
previews.delete(key);
|
|
2379
|
+
}
|
|
2380
|
+
}
|
|
2381
|
+
|
|
2382
|
+
// src/git.ts
|
|
2383
|
+
import { execFile as execFile3 } from "node:child_process";
|
|
2384
|
+
import { promisify as promisify4 } from "node:util";
|
|
2385
|
+
var execFileAsync3 = promisify4(execFile3);
|
|
2386
|
+
async function pushBranchToMain(repoPath) {
|
|
2387
|
+
try {
|
|
2388
|
+
await execFileAsync3("git", ["push", "origin", "HEAD:main"], { cwd: repoPath });
|
|
2389
|
+
return { ok: true };
|
|
2390
|
+
} catch (err) {
|
|
2391
|
+
const stderr = err.stderr;
|
|
2392
|
+
const error = stderr?.trim() || (err instanceof Error ? err.message : String(err));
|
|
2393
|
+
return { ok: false, error };
|
|
2394
|
+
}
|
|
2395
|
+
}
|
|
2396
|
+
|
|
2397
|
+
// src/orchestrator.ts
|
|
2398
|
+
import { randomUUID as randomUUID6 } from "node:crypto";
|
|
2399
|
+
|
|
2400
|
+
// src/agentSession.ts
|
|
2401
|
+
import { randomUUID as randomUUID5 } from "node:crypto";
|
|
2402
|
+
import { spawn as spawn5 } from "node-pty";
|
|
2403
|
+
|
|
2404
|
+
// src/sessionName.ts
|
|
2405
|
+
var MAX_LENGTH = 60;
|
|
2406
|
+
var FALLBACK_NAME = "card";
|
|
2407
|
+
function toSessionName(title) {
|
|
2408
|
+
const slug = title.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
2409
|
+
if (slug.length === 0) return FALLBACK_NAME;
|
|
2410
|
+
if (slug.length <= MAX_LENGTH) return slug;
|
|
2411
|
+
const truncated = slug.slice(0, MAX_LENGTH);
|
|
2412
|
+
const lastHyphen = truncated.lastIndexOf("-");
|
|
2413
|
+
return (lastHyphen > 0 ? truncated.slice(0, lastHyphen) : truncated).replace(/-+$/, "");
|
|
2414
|
+
}
|
|
2415
|
+
|
|
2416
|
+
// src/agentSession.ts
|
|
2417
|
+
var TUI_IDLE_MS = 1500;
|
|
2418
|
+
var TUI_READY_TIMEOUT_MS = 6e4;
|
|
2419
|
+
var SUBMIT_DELAY_MS = 150;
|
|
2420
|
+
var PASTE_START = "\x1B[200~";
|
|
2421
|
+
var PASTE_END = "\x1B[201~";
|
|
2422
|
+
var SHIFT_TAB = "\x1B[Z";
|
|
2423
|
+
var SHIFT_TAB_PRESSES_TO_PLAN_MODE = 4;
|
|
2424
|
+
var SHIFT_TAB_DELAY_MS = 400;
|
|
2425
|
+
var PLAN_MODE_INDICATOR = /plan mode on/i;
|
|
2426
|
+
function spawnAgentPty(repoPath, cardId, cardTitle, instructions, sessionId, ticket, planFirst) {
|
|
2427
|
+
const claudeSessionId = randomUUID5();
|
|
2428
|
+
const pty = spawn5(
|
|
2429
|
+
CLAUDE_BINARY,
|
|
2430
|
+
[
|
|
2431
|
+
"--dangerously-skip-permissions",
|
|
2432
|
+
"--model",
|
|
2433
|
+
"claude-sonnet-5",
|
|
2434
|
+
"--session-id",
|
|
2435
|
+
claudeSessionId,
|
|
2436
|
+
...claudeCliSettingsArgs(),
|
|
2437
|
+
`/rename ${toSessionName(cardTitle)}`
|
|
2438
|
+
],
|
|
2439
|
+
{
|
|
2440
|
+
name: "xterm-color",
|
|
2441
|
+
cols: 120,
|
|
2442
|
+
rows: 32,
|
|
2443
|
+
cwd: repoPath,
|
|
2444
|
+
env: statuslineEnv(sessionId, ticket)
|
|
2445
|
+
}
|
|
2446
|
+
);
|
|
2447
|
+
sendMessageWhenIdle(pty, cardId, instructions, planFirst);
|
|
2448
|
+
return { pty, claudeSessionId };
|
|
2449
|
+
}
|
|
2450
|
+
function sendMessageWhenIdle(pty, cardId, message, planFirst) {
|
|
2451
|
+
let idleTimer;
|
|
2452
|
+
let deadlineTimer;
|
|
2453
|
+
let dataSubscription;
|
|
2454
|
+
let settled = false;
|
|
2455
|
+
const stopWaiting = () => {
|
|
2456
|
+
if (settled) return false;
|
|
2457
|
+
settled = true;
|
|
2458
|
+
clearTimeout(idleTimer);
|
|
2459
|
+
clearTimeout(deadlineTimer);
|
|
2460
|
+
dataSubscription?.dispose();
|
|
2461
|
+
return true;
|
|
2462
|
+
};
|
|
2463
|
+
const send = async () => {
|
|
2464
|
+
if (!stopWaiting()) return;
|
|
2465
|
+
if (planFirst) await enterPlanMode(pty, cardId);
|
|
2466
|
+
try {
|
|
2467
|
+
pty.write(`${PASTE_START}${message}${PASTE_END}`);
|
|
2468
|
+
setTimeout(() => {
|
|
2469
|
+
try {
|
|
2470
|
+
pty.write("\r");
|
|
2471
|
+
} catch (err) {
|
|
2472
|
+
logger.card.error(cardId, "Failed to submit the card instructions to the agent:", err);
|
|
2473
|
+
}
|
|
2474
|
+
}, SUBMIT_DELAY_MS);
|
|
2475
|
+
} catch (err) {
|
|
2476
|
+
logger.card.error(cardId, "Failed to send the card instructions to the agent:", err);
|
|
2477
|
+
}
|
|
2478
|
+
};
|
|
2479
|
+
deadlineTimer = setTimeout(send, TUI_READY_TIMEOUT_MS);
|
|
2480
|
+
dataSubscription = pty.onData(() => {
|
|
2481
|
+
clearTimeout(idleTimer);
|
|
2482
|
+
idleTimer = setTimeout(send, TUI_IDLE_MS);
|
|
2483
|
+
});
|
|
2484
|
+
pty.onExit(() => stopWaiting());
|
|
2485
|
+
}
|
|
2486
|
+
async function enterPlanMode(pty, cardId) {
|
|
2487
|
+
let recentOutput = "";
|
|
2488
|
+
const subscription = pty.onData((chunk) => {
|
|
2489
|
+
recentOutput += normalizeTerminalOutput(chunk);
|
|
2490
|
+
});
|
|
2491
|
+
try {
|
|
2492
|
+
for (let attempt = 1; attempt <= SHIFT_TAB_PRESSES_TO_PLAN_MODE; attempt++) {
|
|
2493
|
+
pty.write(SHIFT_TAB);
|
|
2494
|
+
await new Promise((resolve4) => setTimeout(resolve4, SHIFT_TAB_DELAY_MS));
|
|
2495
|
+
}
|
|
2496
|
+
if (!PLAN_MODE_INDICATOR.test(recentOutput)) {
|
|
2497
|
+
logger.card.warn(
|
|
2498
|
+
cardId,
|
|
2499
|
+
`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.`
|
|
2500
|
+
);
|
|
2501
|
+
}
|
|
2502
|
+
} catch (err) {
|
|
2503
|
+
logger.card.error(cardId, "Failed to switch the session into Plan Mode:", err);
|
|
2504
|
+
} finally {
|
|
2505
|
+
subscription.dispose();
|
|
2506
|
+
}
|
|
2507
|
+
}
|
|
2508
|
+
function sendContinue(pty, cardId) {
|
|
2509
|
+
try {
|
|
2510
|
+
pty.write(`${PASTE_START}continue${PASTE_END}`);
|
|
2511
|
+
setTimeout(() => {
|
|
2512
|
+
try {
|
|
2513
|
+
pty.write("\r");
|
|
2514
|
+
} catch (err) {
|
|
2515
|
+
logger.card.error(cardId, 'Failed to submit the auto-resume "continue" message:', err);
|
|
2516
|
+
}
|
|
2517
|
+
}, SUBMIT_DELAY_MS);
|
|
2518
|
+
} catch (err) {
|
|
2519
|
+
logger.card.error(cardId, 'Failed to send the auto-resume "continue" message:', err);
|
|
2520
|
+
}
|
|
2521
|
+
}
|
|
2522
|
+
function resumeAgentPty(repoPath, claudeSessionId, sessionId, ticket) {
|
|
2523
|
+
return spawn5(
|
|
2524
|
+
CLAUDE_BINARY,
|
|
2525
|
+
[
|
|
2526
|
+
"--dangerously-skip-permissions",
|
|
2527
|
+
"--model",
|
|
2528
|
+
"claude-sonnet-5",
|
|
2529
|
+
"--resume",
|
|
2530
|
+
claudeSessionId,
|
|
2531
|
+
...claudeCliSettingsArgs()
|
|
2532
|
+
],
|
|
2533
|
+
{
|
|
2534
|
+
name: "xterm-color",
|
|
2535
|
+
cols: 120,
|
|
2536
|
+
rows: 32,
|
|
2537
|
+
cwd: repoPath,
|
|
2538
|
+
env: statuslineEnv(sessionId, ticket)
|
|
2539
|
+
}
|
|
2540
|
+
);
|
|
2541
|
+
}
|
|
2542
|
+
|
|
2543
|
+
// src/cardAttachments.ts
|
|
2544
|
+
import { mkdir as mkdir2, writeFile as writeFile2 } from "node:fs/promises";
|
|
2545
|
+
import { tmpdir as tmpdir6 } from "node:os";
|
|
2546
|
+
import { extname, join as join11 } from "node:path";
|
|
2547
|
+
var ATTACHMENTS_ROOT = join11(tmpdir6(), "agent-board", "card-attachments");
|
|
2548
|
+
function sanitizeFileName(fileName) {
|
|
2549
|
+
return fileName.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
2550
|
+
}
|
|
2551
|
+
async function downloadCardAttachments(supabase, cardId) {
|
|
2552
|
+
const { data, error } = await supabase.from("card_attachments").select("id, storage_path, file_name").eq("card_id", cardId).order("created_at", { ascending: true });
|
|
2553
|
+
if (error) {
|
|
2554
|
+
logger.card.error(cardId, "Failed to list attachments:", error.message);
|
|
2555
|
+
return [];
|
|
2556
|
+
}
|
|
2557
|
+
const rows = data ?? [];
|
|
2558
|
+
if (rows.length === 0) return [];
|
|
2559
|
+
const targetDir = join11(ATTACHMENTS_ROOT, cardId);
|
|
2560
|
+
await mkdir2(targetDir, { recursive: true });
|
|
2561
|
+
const paths = [];
|
|
2562
|
+
for (const row of rows) {
|
|
2563
|
+
const baseName = sanitizeFileName(row.file_name);
|
|
2564
|
+
const fileName = extname(baseName) ? baseName : `${baseName}${extname(row.storage_path)}`;
|
|
2565
|
+
const localPath = join11(targetDir, `${row.id}-${fileName}`);
|
|
2566
|
+
try {
|
|
2567
|
+
const { data: blob, error: downloadError } = await supabase.storage.from(CARD_ATTACHMENTS_BUCKET).download(row.storage_path);
|
|
2568
|
+
if (downloadError) throw downloadError;
|
|
2569
|
+
await writeFile2(localPath, Buffer.from(await blob.arrayBuffer()));
|
|
2570
|
+
paths.push(localPath);
|
|
2571
|
+
} catch (err) {
|
|
2572
|
+
logger.card.error(cardId, `Failed to download attachment ${row.storage_path}:`, err);
|
|
2573
|
+
}
|
|
2574
|
+
}
|
|
2575
|
+
return paths;
|
|
2576
|
+
}
|
|
2577
|
+
function buildInstructionsWithAttachments(instructions, attachmentPaths) {
|
|
2578
|
+
if (attachmentPaths.length === 0) return instructions;
|
|
2579
|
+
const list = attachmentPaths.map((path) => `- ${path}`).join("\n");
|
|
2580
|
+
return `${instructions}
|
|
2581
|
+
|
|
2582
|
+
Files attached to this card (use each one as part of the task):
|
|
2583
|
+
${list}`;
|
|
2584
|
+
}
|
|
2585
|
+
|
|
2586
|
+
// src/fileReferences.ts
|
|
2587
|
+
function buildInstructionsWithFileReferences(instructions, fileReferences) {
|
|
2588
|
+
if (fileReferences.length === 0) return instructions;
|
|
2589
|
+
const list = fileReferences.map((path) => `- ${path}`).join("\n");
|
|
2590
|
+
return `${instructions}
|
|
2591
|
+
|
|
2592
|
+
Files/folders referenced on this card (read them before starting \u2014 they are part of the task):
|
|
2593
|
+
${list}`;
|
|
2594
|
+
}
|
|
2595
|
+
|
|
2596
|
+
// src/mcpContextNote.ts
|
|
2597
|
+
function buildInstructionsWithMcpContext(instructions, cardId, projectId) {
|
|
2598
|
+
return `${instructions}
|
|
2599
|
+
|
|
2600
|
+
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. 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.`;
|
|
2601
|
+
}
|
|
2602
|
+
|
|
2603
|
+
// src/usageResume.ts
|
|
2604
|
+
var RESET_BUFFER_MS = 3e4;
|
|
2605
|
+
var UNKNOWN_RESET_RETRY_MS = 5 * 6e4;
|
|
2606
|
+
var pending = /* @__PURE__ */ new Map();
|
|
2607
|
+
function scheduleUsageResume(supabase, config, cardId, sessionId, pty) {
|
|
2608
|
+
if (pending.has(sessionId)) return;
|
|
2609
|
+
void (async () => {
|
|
2610
|
+
const usage = await getClaudeUsage(supabase, config.ownerUserId).catch((err) => {
|
|
2611
|
+
logger.card.error(cardId, "Failed to read claude_usage for usage-limit resume:", err);
|
|
2612
|
+
return null;
|
|
2613
|
+
});
|
|
2614
|
+
const now = Date.now();
|
|
2615
|
+
const candidates = [usage?.sessionResetsAt, usage?.weekResetsAt].map((iso) => iso ? new Date(iso).getTime() : null).filter((ms) => ms !== null && ms > now);
|
|
2616
|
+
const resetAt = candidates.length > 0 ? Math.min(...candidates) : null;
|
|
2617
|
+
const delay = resetAt !== null ? resetAt - now + RESET_BUFFER_MS : UNKNOWN_RESET_RETRY_MS;
|
|
2618
|
+
logger.card.info(
|
|
2619
|
+
cardId,
|
|
2620
|
+
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.`
|
|
2621
|
+
);
|
|
2622
|
+
const timer = setTimeout(() => {
|
|
2623
|
+
pending.delete(sessionId);
|
|
2624
|
+
sendContinue(pty, cardId);
|
|
2625
|
+
}, delay);
|
|
2626
|
+
timer.unref();
|
|
2627
|
+
pending.set(sessionId, timer);
|
|
2628
|
+
})();
|
|
2629
|
+
}
|
|
2630
|
+
function cancelUsageResume(sessionId) {
|
|
2631
|
+
const timer = pending.get(sessionId);
|
|
2632
|
+
if (!timer) return;
|
|
2633
|
+
clearTimeout(timer);
|
|
2634
|
+
pending.delete(sessionId);
|
|
2635
|
+
}
|
|
2636
|
+
|
|
2637
|
+
// src/orchestrator.ts
|
|
2638
|
+
var TERMINAL_TICKET_TTL_MS = 60 * 60 * 1e3;
|
|
2639
|
+
var inFlightCardIds = /* @__PURE__ */ new Set();
|
|
2640
|
+
var activeSessionByCard = /* @__PURE__ */ new Map();
|
|
2641
|
+
var intentionallyStoppedSessionIds = /* @__PURE__ */ new Set();
|
|
2642
|
+
var dispatchingRunner = false;
|
|
2643
|
+
async function scanForWork(supabase, config) {
|
|
2644
|
+
for (const project of config.registeredProjects) {
|
|
2645
|
+
await reconcileStaleSessions(supabase, config, project.id);
|
|
2646
|
+
const { data: cards, error } = await supabase.from("cards").select("*").eq("project_id", project.id).eq("column_name", "desenvolvimento").eq("assigned_user_id", config.ownerUserId);
|
|
2647
|
+
if (error) {
|
|
2648
|
+
logger.runner.error("ORCHESTRATOR", `Failed to scan cards for project ${project.id}:`, error.message);
|
|
2649
|
+
continue;
|
|
2650
|
+
}
|
|
2651
|
+
for (const card of cards ?? []) {
|
|
2652
|
+
void enqueueCard(supabase, config, card);
|
|
2653
|
+
}
|
|
2654
|
+
}
|
|
2655
|
+
void dispatchRunnerQueue(supabase, config);
|
|
2656
|
+
}
|
|
2657
|
+
async function enqueueCard(supabase, config, card) {
|
|
2658
|
+
if (inFlightCardIds.has(card.id)) return;
|
|
2659
|
+
const registered = config.registeredProjects.find((project) => project.id === card.project_id);
|
|
2660
|
+
if (!registered) {
|
|
2661
|
+
logger.card.warn(card.id, `Ignoring card: project ${card.project_id} is not registered on this runner.`);
|
|
2662
|
+
return;
|
|
2663
|
+
}
|
|
2664
|
+
if (card.assigned_user_id !== config.ownerUserId) return;
|
|
2665
|
+
inFlightCardIds.add(card.id);
|
|
2666
|
+
try {
|
|
2667
|
+
const active = await getActiveSessionForCard(supabase, card.id);
|
|
2668
|
+
if (active) return;
|
|
2669
|
+
await createQueuedSession(supabase, config, card);
|
|
2670
|
+
} finally {
|
|
2671
|
+
inFlightCardIds.delete(card.id);
|
|
2672
|
+
}
|
|
2673
|
+
await dispatchRunnerQueue(supabase, config);
|
|
2674
|
+
}
|
|
2675
|
+
async function createQueuedSession(supabase, config, card) {
|
|
2676
|
+
const resumeClaudeSessionId = await getLastClaudeSessionIdForCard(supabase, card.id);
|
|
2677
|
+
const sessionId = await createSession(supabase, config.ownerUserId, card.id, resumeClaudeSessionId);
|
|
2678
|
+
logger.card.info(
|
|
2679
|
+
card.id,
|
|
2680
|
+
resumeClaudeSessionId ? `"${card.title}" queued (session ${sessionId}, will resume prior conversation ${resumeClaudeSessionId}).` : `"${card.title}" queued (session ${sessionId}).`
|
|
2681
|
+
);
|
|
2682
|
+
}
|
|
2683
|
+
async function reconcileStaleSessions(supabase, config, projectId) {
|
|
2684
|
+
const { data, error } = await supabase.from("sessions").select("id, card_id, cards!card_id!inner(project_id, column_name, assigned_user_id)").eq("cards.project_id", projectId).eq("cards.assigned_user_id", config.ownerUserId).in("status", ["queued", "running", "waiting_input", "paused"]).neq("cards.column_name", "desenvolvimento");
|
|
2685
|
+
if (error) {
|
|
2686
|
+
logger.runner.error("ORCHESTRATOR", `Failed to scan stale sessions for project ${projectId}:`, error.message);
|
|
2687
|
+
return;
|
|
2688
|
+
}
|
|
2689
|
+
const stale = data ?? [];
|
|
2690
|
+
if (stale.length === 0) return;
|
|
2691
|
+
for (const { id: sessionId, card_id: cardId } of stale) {
|
|
2692
|
+
intentionallyStoppedSessionIds.add(sessionId);
|
|
2693
|
+
stopPtySession(sessionId);
|
|
2694
|
+
cancelUsageResume(sessionId);
|
|
2695
|
+
activeSessionByCard.delete(cardId);
|
|
2696
|
+
await updateSession(
|
|
2697
|
+
supabase,
|
|
2698
|
+
sessionId,
|
|
2699
|
+
{ status: "done", endedAt: (/* @__PURE__ */ new Date()).toISOString() },
|
|
2700
|
+
{ projectId, cardId, userId: config.ownerUserId }
|
|
2701
|
+
);
|
|
2702
|
+
logger.card.info(cardId, `Ended stale session ${sessionId} (its card left "desenvolvimento" while this runner missed the event).`);
|
|
2703
|
+
}
|
|
2704
|
+
await dispatchRunnerQueue(supabase, config);
|
|
2705
|
+
}
|
|
2706
|
+
async function dispatchRunnerQueue(supabase, config) {
|
|
2707
|
+
if (dispatchingRunner) return;
|
|
2708
|
+
dispatchingRunner = true;
|
|
2709
|
+
try {
|
|
2710
|
+
const queued = await listQueuedSessionsForRunner(supabase, config.ownerUserId);
|
|
2711
|
+
if (queued.length === 0) return;
|
|
2712
|
+
const maxConcurrentProjects = await getRunnerMaxConcurrentProjects(supabase, config.runnerId);
|
|
2713
|
+
const runningProjectIds = /* @__PURE__ */ new Set();
|
|
2714
|
+
for (const project of config.registeredProjects) {
|
|
2715
|
+
const runningCount = await countRunningSessions(supabase, project.id, config.ownerUserId);
|
|
2716
|
+
if (runningCount > 0) runningProjectIds.add(project.id);
|
|
2717
|
+
}
|
|
2718
|
+
let freeProjectSlots = maxConcurrentProjects - runningProjectIds.size;
|
|
2719
|
+
if (freeProjectSlots <= 0) return;
|
|
2720
|
+
const promotedThisPass = /* @__PURE__ */ new Set();
|
|
2721
|
+
for (const entry of queued) {
|
|
2722
|
+
const projectId = entry.cards.project_id;
|
|
2723
|
+
if (runningProjectIds.has(projectId) || promotedThisPass.has(projectId)) continue;
|
|
2724
|
+
if (freeProjectSlots <= 0) break;
|
|
2725
|
+
try {
|
|
2726
|
+
await promoteQueuedSession(supabase, config, entry);
|
|
2727
|
+
promotedThisPass.add(projectId);
|
|
2728
|
+
freeProjectSlots -= 1;
|
|
2729
|
+
} catch (err) {
|
|
2730
|
+
logger.card.error(entry.card_id, `Failed to promote queued session ${entry.id}:`, err);
|
|
2731
|
+
}
|
|
2732
|
+
}
|
|
2733
|
+
} finally {
|
|
2734
|
+
dispatchingRunner = false;
|
|
2735
|
+
}
|
|
2736
|
+
}
|
|
2737
|
+
async function promoteQueuedSession(supabase, config, entry) {
|
|
2738
|
+
const existingHandle = getPtySession(entry.id);
|
|
2739
|
+
if (existingHandle) {
|
|
2740
|
+
setPtySessionReadOnly(entry.id, false);
|
|
2741
|
+
await updateSession(
|
|
2742
|
+
supabase,
|
|
2743
|
+
entry.id,
|
|
2744
|
+
{ status: "running" },
|
|
2745
|
+
{ projectId: entry.cards.project_id, cardId: entry.card_id, userId: config.ownerUserId }
|
|
2746
|
+
);
|
|
2747
|
+
activeSessionByCard.set(entry.card_id, entry.id);
|
|
2748
|
+
logger.card.info(entry.card_id, "Resumed paused session from the front of the queue.");
|
|
2749
|
+
return;
|
|
1628
2750
|
}
|
|
2751
|
+
const spawned = await spawnSessionPty(supabase, config, entry);
|
|
2752
|
+
if (spawned) logger.card.info(entry.card_id, "Promoted from queue.");
|
|
1629
2753
|
}
|
|
1630
|
-
async function
|
|
2754
|
+
async function spawnSessionPty(supabase, config, entry) {
|
|
2755
|
+
const { id: sessionId, card_id: cardId, cards: card, claude_session_id: claudeSessionId } = entry;
|
|
2756
|
+
const registered = config.registeredProjects.find((project) => project.id === card.project_id);
|
|
2757
|
+
if (!registered) {
|
|
2758
|
+
logger.card.error(cardId, `Session ${sessionId}: project ${card.project_id} is not registered \u2014 skipping.`);
|
|
2759
|
+
await updateSession(
|
|
2760
|
+
supabase,
|
|
2761
|
+
sessionId,
|
|
2762
|
+
{ status: "failed", endedAt: (/* @__PURE__ */ new Date()).toISOString() },
|
|
2763
|
+
{ projectId: card.project_id, cardId, userId: config.ownerUserId }
|
|
2764
|
+
);
|
|
2765
|
+
return false;
|
|
2766
|
+
}
|
|
2767
|
+
const ticket = randomUUID6();
|
|
2768
|
+
let pty;
|
|
2769
|
+
let newClaudeSessionId = null;
|
|
1631
2770
|
try {
|
|
1632
|
-
|
|
1633
|
-
|
|
2771
|
+
if (claudeSessionId) {
|
|
2772
|
+
pty = resumeAgentPty(registered.repoPath, claudeSessionId, sessionId, ticket);
|
|
2773
|
+
} else {
|
|
2774
|
+
const attachmentPaths = await downloadCardAttachments(supabase, cardId);
|
|
2775
|
+
const instructions = buildInstructionsWithMcpContext(
|
|
2776
|
+
buildInstructionsWithFileReferences(
|
|
2777
|
+
buildInstructionsWithAttachments(card.instructions, attachmentPaths),
|
|
2778
|
+
card.file_references
|
|
2779
|
+
),
|
|
2780
|
+
cardId,
|
|
2781
|
+
card.project_id
|
|
2782
|
+
);
|
|
2783
|
+
const spawned = spawnAgentPty(registered.repoPath, cardId, card.title, instructions, sessionId, ticket, card.plan_first);
|
|
2784
|
+
pty = spawned.pty;
|
|
2785
|
+
newClaudeSessionId = spawned.claudeSessionId;
|
|
2786
|
+
}
|
|
1634
2787
|
} catch (err) {
|
|
1635
|
-
logger.
|
|
1636
|
-
|
|
2788
|
+
logger.card.error(cardId, `Failed to spawn pty for session ${sessionId}:`, err);
|
|
2789
|
+
await updateSession(
|
|
2790
|
+
supabase,
|
|
2791
|
+
sessionId,
|
|
2792
|
+
{ status: "failed", endedAt: (/* @__PURE__ */ new Date()).toISOString() },
|
|
2793
|
+
{ projectId: card.project_id, cardId, userId: config.ownerUserId }
|
|
2794
|
+
);
|
|
2795
|
+
return false;
|
|
1637
2796
|
}
|
|
2797
|
+
registerPtySession(sessionId, pty, ticket);
|
|
2798
|
+
activeSessionByCard.set(cardId, sessionId);
|
|
2799
|
+
publishAgentStateEvent(card.project_id, cardId, sessionId, newClaudeSessionId ? "starting" : entry.agent_state ?? "starting");
|
|
2800
|
+
const activityWatcher = watchAgentActivity(supabase, config, card.project_id, cardId, sessionId, pty);
|
|
2801
|
+
await updateSession(
|
|
2802
|
+
supabase,
|
|
2803
|
+
sessionId,
|
|
2804
|
+
{
|
|
2805
|
+
status: "running",
|
|
2806
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2807
|
+
terminalTicket: ticket,
|
|
2808
|
+
terminalTicketExpiresAt: new Date(Date.now() + TERMINAL_TICKET_TTL_MS).toISOString(),
|
|
2809
|
+
// Only stamp a fresh spawn as "starting" — a resumed session (claudeSessionId was
|
|
2810
|
+
// already set) keeps whatever agent_state it had before the restart (e.g.
|
|
2811
|
+
// "usage_limited"), so reconcileUsageLimitedSessions can find it afterwards. The
|
|
2812
|
+
// freshly (re)constructed AgentActivityWatcher below will correct it the moment real
|
|
2813
|
+
// output gives it something conclusive to classify.
|
|
2814
|
+
...newClaudeSessionId ? { agentState: "starting", claudeSessionId: newClaudeSessionId } : {}
|
|
2815
|
+
},
|
|
2816
|
+
{ projectId: card.project_id, cardId, userId: config.ownerUserId }
|
|
2817
|
+
);
|
|
2818
|
+
pty.onExit(({ exitCode }) => {
|
|
2819
|
+
const wasIntentional = intentionallyStoppedSessionIds.delete(sessionId);
|
|
2820
|
+
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.`);
|
|
2821
|
+
activityWatcher.stop();
|
|
2822
|
+
unregisterAgentActivityWatcher(sessionId);
|
|
2823
|
+
cancelUsageResume(sessionId);
|
|
2824
|
+
activeSessionByCard.delete(cardId);
|
|
2825
|
+
if (!wasIntentional && exitCode !== 0) {
|
|
2826
|
+
publishAgentState(supabase, card.project_id, cardId, sessionId, "error");
|
|
2827
|
+
}
|
|
2828
|
+
});
|
|
2829
|
+
return true;
|
|
1638
2830
|
}
|
|
1639
|
-
function
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
2831
|
+
function publishAgentState(supabase, projectId, cardId, sessionId, state) {
|
|
2832
|
+
publishAgentStateEvent(projectId, cardId, sessionId, state);
|
|
2833
|
+
updateSession(supabase, sessionId, { agentState: state }).catch(
|
|
2834
|
+
(err) => logger.card.error(cardId, `Failed to persist agent state "${state}" for session ${sessionId}:`, err)
|
|
2835
|
+
);
|
|
2836
|
+
}
|
|
2837
|
+
function watchAgentActivity(supabase, config, projectId, cardId, sessionId, pty) {
|
|
2838
|
+
const watcher = new AgentActivityWatcher((state) => {
|
|
2839
|
+
publishAgentState(supabase, projectId, cardId, sessionId, state);
|
|
2840
|
+
if (state === "usage_limited") {
|
|
2841
|
+
scheduleUsageResume(supabase, config, cardId, sessionId, pty);
|
|
2842
|
+
} else {
|
|
2843
|
+
cancelUsageResume(sessionId);
|
|
2844
|
+
}
|
|
2845
|
+
if (state === "done") void pauseFinishedSession(supabase, config, projectId, cardId, sessionId);
|
|
2846
|
+
});
|
|
2847
|
+
pty.onData((chunk) => watcher.push(chunk));
|
|
2848
|
+
registerAgentActivityWatcher(sessionId, watcher);
|
|
2849
|
+
return watcher;
|
|
2850
|
+
}
|
|
2851
|
+
async function pauseFinishedSession(supabase, config, projectId, cardId, sessionId) {
|
|
2852
|
+
const paused = await pauseRunningSession(supabase, sessionId, { projectId, cardId, userId: config.ownerUserId });
|
|
2853
|
+
if (!paused) return;
|
|
2854
|
+
setPtySessionReadOnly(sessionId, true);
|
|
2855
|
+
logger.card.info(cardId, `Session ${sessionId} paused (agent reached "done") \u2014 WIP slot freed, pty stays viewable read-only.`);
|
|
2856
|
+
await dispatchRunnerQueue(supabase, config);
|
|
2857
|
+
}
|
|
2858
|
+
async function requeuePausedCard(supabase, config, card) {
|
|
2859
|
+
const active = await getActiveSessionForCard(supabase, card.id);
|
|
2860
|
+
if (!active || active.status !== "paused") return;
|
|
2861
|
+
await updateSession(
|
|
2862
|
+
supabase,
|
|
2863
|
+
active.id,
|
|
2864
|
+
{ status: "queued" },
|
|
2865
|
+
{ projectId: card.project_id, cardId: card.id, userId: config.ownerUserId }
|
|
2866
|
+
);
|
|
2867
|
+
await updateCard(supabase, card.id, { requeueRequestedAt: null });
|
|
2868
|
+
logger.card.info(card.id, `Session ${active.id} requeued at the front of the line (was paused).`);
|
|
2869
|
+
await dispatchRunnerQueue(supabase, config);
|
|
2870
|
+
}
|
|
2871
|
+
async function reconcileOrphanedSessions(supabase, config) {
|
|
2872
|
+
for (const project of config.registeredProjects) {
|
|
2873
|
+
const { data, error } = await supabase.from("sessions").select(
|
|
2874
|
+
"id, card_id, claude_session_id, agent_state, created_at, status, cards!card_id!inner(project_id, assigned_user_id, title, instructions, file_references, plan_first)"
|
|
2875
|
+
).eq("cards.project_id", project.id).eq("cards.assigned_user_id", config.ownerUserId).in("status", ["running", "paused"]);
|
|
2876
|
+
if (error) {
|
|
2877
|
+
logger.runner.error("ORCHESTRATOR", `Failed to scan orphaned sessions for project ${project.id}:`, error.message);
|
|
2878
|
+
continue;
|
|
2879
|
+
}
|
|
2880
|
+
for (const entry of data ?? []) {
|
|
2881
|
+
let spawned;
|
|
2882
|
+
try {
|
|
2883
|
+
spawned = await spawnSessionPty(supabase, config, entry);
|
|
2884
|
+
} catch (err) {
|
|
2885
|
+
logger.card.error(entry.card_id, `Failed to re-spawn orphaned session ${entry.id}:`, err);
|
|
2886
|
+
continue;
|
|
2887
|
+
}
|
|
2888
|
+
if (!spawned) continue;
|
|
2889
|
+
if (entry.status === "paused") {
|
|
2890
|
+
await updateSession(
|
|
2891
|
+
supabase,
|
|
2892
|
+
entry.id,
|
|
2893
|
+
{ status: "paused" },
|
|
2894
|
+
{ projectId: project.id, cardId: entry.card_id, userId: config.ownerUserId }
|
|
2895
|
+
);
|
|
2896
|
+
setPtySessionReadOnly(entry.id, true);
|
|
2897
|
+
logger.card.info(entry.card_id, "Re-spawned paused session (runner restarted) \u2014 still read-only until requeued.");
|
|
2898
|
+
} else {
|
|
2899
|
+
logger.card.info(entry.card_id, "Re-spawned orphaned session (runner restarted since it was last running).");
|
|
2900
|
+
}
|
|
2901
|
+
}
|
|
1644
2902
|
}
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
2903
|
+
}
|
|
2904
|
+
async function reconcileUsageLimitedSessions(supabase, config) {
|
|
2905
|
+
for (const project of config.registeredProjects) {
|
|
2906
|
+
const { data, error } = await supabase.from("sessions").select("id, card_id, cards!card_id!inner(project_id, assigned_user_id)").eq("cards.project_id", project.id).eq("cards.assigned_user_id", config.ownerUserId).eq("status", "running").eq("agent_state", "usage_limited");
|
|
2907
|
+
if (error) {
|
|
2908
|
+
logger.runner.error("ORCHESTRATOR", `Failed to scan usage-limited sessions for project ${project.id}:`, error.message);
|
|
2909
|
+
continue;
|
|
1650
2910
|
}
|
|
1651
|
-
|
|
2911
|
+
for (const { id: sessionId, card_id: cardId } of data ?? []) {
|
|
2912
|
+
const handle = getPtySession(sessionId);
|
|
2913
|
+
if (!handle) continue;
|
|
2914
|
+
scheduleUsageResume(supabase, config, cardId, sessionId, handle.pty);
|
|
2915
|
+
}
|
|
2916
|
+
}
|
|
2917
|
+
}
|
|
2918
|
+
async function stopCardSession(supabase, config, card) {
|
|
2919
|
+
const sessionId = activeSessionByCard.get(card.id) ?? (await getActiveSessionForCard(supabase, card.id))?.id;
|
|
2920
|
+
if (!sessionId) return;
|
|
2921
|
+
intentionallyStoppedSessionIds.add(sessionId);
|
|
2922
|
+
stopPtySession(sessionId);
|
|
2923
|
+
cancelUsageResume(sessionId);
|
|
2924
|
+
activeSessionByCard.delete(card.id);
|
|
2925
|
+
await updateSession(
|
|
2926
|
+
supabase,
|
|
2927
|
+
sessionId,
|
|
2928
|
+
{ status: "done", endedAt: (/* @__PURE__ */ new Date()).toISOString() },
|
|
2929
|
+
{ projectId: card.project_id, cardId: card.id, userId: config.ownerUserId }
|
|
2930
|
+
);
|
|
2931
|
+
logger.card.info(card.id, `Session ${sessionId} stopped.`);
|
|
2932
|
+
await dispatchRunnerQueue(supabase, config);
|
|
2933
|
+
}
|
|
2934
|
+
|
|
2935
|
+
// src/deploy.ts
|
|
2936
|
+
async function handleDeployRequested(supabase, config, project) {
|
|
2937
|
+
const registered = config.registeredProjects.find((candidate) => candidate.id === project.id);
|
|
2938
|
+
if (!registered) {
|
|
2939
|
+
logger.runner.warn("DEPLOY", `Ignoring deploy request: project ${project.id} is not registered on this runner.`);
|
|
1652
2940
|
return;
|
|
1653
2941
|
}
|
|
1654
|
-
|
|
1655
|
-
|
|
2942
|
+
const allCards = await listCardsInColumn(supabase, project.id, "deploy");
|
|
2943
|
+
const cards = allCards.filter((card) => card.assigned_user_id === config.ownerUserId);
|
|
2944
|
+
if (cards.length === 0) {
|
|
2945
|
+
logger.runner.info("DEPLOY", `Deploy requested for project ${project.id} but no cards assigned to this runner's user are waiting in "deploy".`);
|
|
2946
|
+
await clearProjectDeployRequest(supabase, project.id);
|
|
1656
2947
|
return;
|
|
1657
2948
|
}
|
|
1658
|
-
const
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
sendJson(res, err.status, { error: err.message });
|
|
1670
|
-
return;
|
|
1671
|
-
}
|
|
1672
|
-
logger.runner.error("FILES", "Unexpected error handling a browse request:", err);
|
|
1673
|
-
sendJson(res, 500, { error: "Erro interno do runner." });
|
|
2949
|
+
const children = await getProjectRepos(supabase, project.id);
|
|
2950
|
+
const repoRoot = resolveRepoPath(config, project.repo_path);
|
|
2951
|
+
const targets = children.length > 0 ? children.map((child2) => ({ label: child2.subpath, path: join12(repoRoot, child2.subpath) })) : [{ label: null, path: repoRoot }];
|
|
2952
|
+
const failures = [];
|
|
2953
|
+
for (const target of targets) {
|
|
2954
|
+
const result = await pushBranchToMain(target.path);
|
|
2955
|
+
if (result.ok) {
|
|
2956
|
+
logger.runner.info("DEPLOY", target.label ? `Pushed HEAD to main in "${target.label}".` : "Pushed HEAD to main.");
|
|
2957
|
+
} else {
|
|
2958
|
+
logger.runner.error("DEPLOY", target.label ? `Push to main failed in "${target.label}": ${result.error}` : `Push to main failed: ${result.error}`);
|
|
2959
|
+
failures.push(target.label ? `${target.label}: ${result.error}` : result.error);
|
|
1674
2960
|
}
|
|
1675
|
-
return;
|
|
1676
2961
|
}
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
2962
|
+
if (failures.length > 0) {
|
|
2963
|
+
const message = failures.join("; ");
|
|
2964
|
+
await Promise.all(cards.map((card) => updateCard(supabase, card.id, { deployError: message })));
|
|
2965
|
+
await clearProjectDeployRequest(supabase, project.id);
|
|
1680
2966
|
return;
|
|
1681
2967
|
}
|
|
1682
|
-
|
|
1683
|
-
|
|
2968
|
+
const columnActionResult = await runAfterColumnActions(project, children, repoRoot, "deploy");
|
|
2969
|
+
if (!columnActionResult.ok) {
|
|
2970
|
+
logger.runner.error("DEPLOY", `Deploy column action failed for project ${project.id}: ${columnActionResult.error}`);
|
|
2971
|
+
await Promise.all(cards.map((card) => updateCard(supabase, card.id, { deployError: columnActionResult.error })));
|
|
2972
|
+
await clearProjectDeployRequest(supabase, project.id);
|
|
1684
2973
|
return;
|
|
1685
2974
|
}
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
}
|
|
1695
|
-
if (pathname === "/logo") {
|
|
1696
|
-
if (!relPath) throw new HttpError(404, "Nenhuma logo configurada para este projeto.");
|
|
1697
|
-
const { buffer, contentType } = readImageFile(entry.repoRoot, relPath);
|
|
1698
|
-
res.writeHead(200, {
|
|
1699
|
-
"Content-Type": contentType,
|
|
1700
|
-
"Content-Length": buffer.length,
|
|
1701
|
-
"Cache-Control": "no-cache",
|
|
1702
|
-
"Access-Control-Allow-Origin": "*"
|
|
1703
|
-
});
|
|
1704
|
-
res.end(buffer);
|
|
1705
|
-
return;
|
|
1706
|
-
}
|
|
1707
|
-
if (pathname === "/shell/open") {
|
|
1708
|
-
sendJson(res, 200, openShellSession(entry.repoRoot));
|
|
1709
|
-
return;
|
|
1710
|
-
}
|
|
1711
|
-
sendJson(res, 404, { error: "Rota desconhecida." });
|
|
1712
|
-
} catch (err) {
|
|
1713
|
-
if (err instanceof HttpError) {
|
|
1714
|
-
sendJson(res, err.status, { error: err.message });
|
|
1715
|
-
return;
|
|
2975
|
+
await Promise.all(cards.map((card) => updateCard(supabase, card.id, { deployError: null, column: "fim" })));
|
|
2976
|
+
await clearProjectDeployRequest(supabase, project.id);
|
|
2977
|
+
}
|
|
2978
|
+
async function checkPendingDeploys(supabase, config) {
|
|
2979
|
+
for (const registered of config.registeredProjects) {
|
|
2980
|
+
const project = await getProject(supabase, registered.id);
|
|
2981
|
+
if (project.deploy_requested_at) {
|
|
2982
|
+
await handleDeployRequested(supabase, config, project);
|
|
1716
2983
|
}
|
|
1717
|
-
logger.runner.error("FILES", "Unexpected error handling a files request:", err);
|
|
1718
|
-
sendJson(res, 500, { error: "Erro interno do runner." });
|
|
1719
2984
|
}
|
|
1720
2985
|
}
|
|
1721
|
-
function
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
logger.runner.info("FILES", "Files HTTP handler attached.");
|
|
2986
|
+
async function handleCardReachedFim(supabase, config, card) {
|
|
2987
|
+
await stopCardSession(supabase, config, card);
|
|
2988
|
+
releaseCard(card.id);
|
|
2989
|
+
logger.card.info(card.id, 'Reached "fim" \u2014 session stopped, preview tunnel (if any) closed.');
|
|
1726
2990
|
}
|
|
1727
2991
|
|
|
1728
2992
|
// src/repoValidation.ts
|
|
@@ -1818,7 +3082,7 @@ async function syncRegisteredProjects(supabase, config, registeredPaths) {
|
|
|
1818
3082
|
}
|
|
1819
3083
|
|
|
1820
3084
|
// src/review.ts
|
|
1821
|
-
import { join as
|
|
3085
|
+
import { join as join13 } from "node:path";
|
|
1822
3086
|
var inFlightCardIds2 = /* @__PURE__ */ new Set();
|
|
1823
3087
|
async function handleCardEnteredRevisao(supabase, config, card) {
|
|
1824
3088
|
if (inFlightCardIds2.has(card.id)) return;
|
|
@@ -1827,7 +3091,7 @@ async function handleCardEnteredRevisao(supabase, config, card) {
|
|
|
1827
3091
|
const project = await getProject(supabase, card.project_id);
|
|
1828
3092
|
const children = await getProjectRepos(supabase, project.id);
|
|
1829
3093
|
const repoRoot = resolveRepoPath(config, project.repo_path);
|
|
1830
|
-
const targets = children.length > 0 ? children.filter((child2) => child2.dev_command).map((child2) => ({ label: child2.subpath, cwd:
|
|
3094
|
+
const targets = children.length > 0 ? children.filter((child2) => child2.dev_command).map((child2) => ({ label: child2.subpath, cwd: join13(repoRoot, child2.subpath), command: child2.dev_command })) : project.dev_command ? [{ label: "", cwd: repoRoot, command: project.dev_command }] : [];
|
|
1831
3095
|
if (targets.length === 0) {
|
|
1832
3096
|
logger.card.info(card.id, `No dev_command configured for project ${project.id}, skipping automatic preview.`);
|
|
1833
3097
|
return;
|
|
@@ -1885,12 +3149,14 @@ async function checkPendingDevServerRestarts(supabase, config) {
|
|
|
1885
3149
|
}
|
|
1886
3150
|
|
|
1887
3151
|
// src/terminalServer.ts
|
|
1888
|
-
import { parse as
|
|
1889
|
-
import { WebSocketServer } from "ws";
|
|
1890
|
-
|
|
1891
|
-
|
|
3152
|
+
import { parse as parse3 } from "node:url";
|
|
3153
|
+
import { WebSocketServer as WebSocketServer2 } from "ws";
|
|
3154
|
+
var FRAME_TYPE_RESIZE = 0;
|
|
3155
|
+
var FRAME_TYPE_IMAGE = 1;
|
|
3156
|
+
function attachTerminalServer() {
|
|
3157
|
+
const wss = new WebSocketServer2({ noServer: true });
|
|
1892
3158
|
wss.on("connection", (socket, request) => {
|
|
1893
|
-
const { query } =
|
|
3159
|
+
const { query } = parse3(request.url ?? "", true);
|
|
1894
3160
|
const sessionId = typeof query.sessionId === "string" ? query.sessionId : null;
|
|
1895
3161
|
const ticket = typeof query.ticket === "string" ? query.ticket : null;
|
|
1896
3162
|
if (!sessionId || !ticket) {
|
|
@@ -1908,14 +3174,28 @@ function attachTerminalServer(httpServer) {
|
|
|
1908
3174
|
for (const chunk of handle.outputBuffer) socket.send(chunk);
|
|
1909
3175
|
socket.on("message", (data, isBinary) => {
|
|
1910
3176
|
if (isBinary) {
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
3177
|
+
const buffer = data;
|
|
3178
|
+
const frameType = buffer[0];
|
|
3179
|
+
if (frameType === FRAME_TYPE_IMAGE) {
|
|
3180
|
+
if (handle.readOnly) return;
|
|
3181
|
+
const extCode = buffer[1];
|
|
3182
|
+
const bytes = buffer.subarray(2);
|
|
3183
|
+
savePastedImage(sessionId, extCode, bytes).then((path) => handle.pty.write(`${path} `)).catch((err) => logger.runner.warn("TERMINAL", `Terminal WS: failed to save pasted image for session ${sessionId}:`, err));
|
|
3184
|
+
return;
|
|
3185
|
+
}
|
|
3186
|
+
if (frameType === FRAME_TYPE_RESIZE) {
|
|
3187
|
+
try {
|
|
3188
|
+
const { cols, rows } = JSON.parse(buffer.subarray(1).toString("utf8"));
|
|
3189
|
+
if (typeof cols === "number" && typeof rows === "number") resizePtySession(sessionId, cols, rows);
|
|
3190
|
+
} catch (err) {
|
|
3191
|
+
logger.runner.warn("TERMINAL", `Terminal WS: ignoring malformed resize frame for session ${sessionId}:`, err);
|
|
3192
|
+
}
|
|
3193
|
+
return;
|
|
1916
3194
|
}
|
|
3195
|
+
logger.runner.warn("TERMINAL", `Terminal WS: ignoring binary frame with unknown type ${frameType} for session ${sessionId}.`);
|
|
1917
3196
|
return;
|
|
1918
3197
|
}
|
|
3198
|
+
if (handle.readOnly) return;
|
|
1919
3199
|
handle.pty.write(data.toString());
|
|
1920
3200
|
});
|
|
1921
3201
|
socket.on("close", () => {
|
|
@@ -1923,6 +3203,7 @@ function attachTerminalServer(httpServer) {
|
|
|
1923
3203
|
});
|
|
1924
3204
|
});
|
|
1925
3205
|
logger.runner.info("TERMINAL", "Terminal WebSocket handler attached.");
|
|
3206
|
+
return wss;
|
|
1926
3207
|
}
|
|
1927
3208
|
|
|
1928
3209
|
// src/terminalTunnel.ts
|
|
@@ -1997,6 +3278,16 @@ async function startRunner() {
|
|
|
1997
3278
|
}
|
|
1998
3279
|
config.ownerUserId = session.user.id;
|
|
1999
3280
|
await supabase.auth.startAutoRefresh();
|
|
3281
|
+
supabase.auth.onAuthStateChange((event) => {
|
|
3282
|
+
logger.runner.info("AUTH", `Session state changed: ${event}.`);
|
|
3283
|
+
if (event === "SIGNED_OUT") {
|
|
3284
|
+
logger.runner.error(
|
|
3285
|
+
"AUTH",
|
|
3286
|
+
'Supabase session was lost (refresh failed or was revoked) \u2014 every write from now on will fail row-level security checks. Run "agent-board-runner login" again and restart the runner.'
|
|
3287
|
+
);
|
|
3288
|
+
process.exit(1);
|
|
3289
|
+
}
|
|
3290
|
+
});
|
|
2000
3291
|
const { error: linkError } = await supabase.rpc("link_my_membership");
|
|
2001
3292
|
if (linkError) {
|
|
2002
3293
|
logger.runner.error("BOOT", "Failed to link pending project invites:", linkError);
|
|
@@ -2016,13 +3307,16 @@ async function startRunner() {
|
|
|
2016
3307
|
id: config.runnerId,
|
|
2017
3308
|
user_id: config.ownerUserId,
|
|
2018
3309
|
name: config.runnerName,
|
|
2019
|
-
auth_token_hash: config.authToken
|
|
3310
|
+
auth_token_hash: config.authToken,
|
|
3311
|
+
version: RUNNER_VERSION
|
|
2020
3312
|
});
|
|
2021
3313
|
if (runnerError) {
|
|
2022
3314
|
logger.runner.error("BOOT", "Failed to upsert this runner's row in Supabase:", runnerError);
|
|
2023
3315
|
process.exit(1);
|
|
2024
3316
|
}
|
|
2025
3317
|
const registeredPaths = new Set(config.registeredProjects.map((project) => project.id));
|
|
3318
|
+
const lastKnownColumn = /* @__PURE__ */ new Map();
|
|
3319
|
+
const lastKnownRequeueRequestedAt = /* @__PURE__ */ new Map();
|
|
2026
3320
|
await syncRegisteredProjects(supabase, config, registeredPaths).catch((err) => logger.runner.error("PROJECT_SYNC", "syncRegisteredProjects failed:", err));
|
|
2027
3321
|
logger.runner.info("BOOT", `Agent Board runner "${config.runnerName}" starting (${registeredPaths.size} project(s) registered).`);
|
|
2028
3322
|
for (const project of config.registeredProjects) {
|
|
@@ -2032,9 +3326,22 @@ async function startRunner() {
|
|
|
2032
3326
|
await reconcileUsageLimitedSessions(supabase, config).catch((err) => logger.runner.error("ORCHESTRATOR", "reconcileUsageLimitedSessions failed:", err));
|
|
2033
3327
|
const terminalPort = Number(process.env.AGENT_BOARD_TERMINAL_PORT ?? 8787);
|
|
2034
3328
|
ensureStatuslineScriptWritten();
|
|
3329
|
+
ensureSafetyHookScriptWritten();
|
|
3330
|
+
ensureAgentMomentHookScriptWritten();
|
|
2035
3331
|
const httpServer = createServer();
|
|
2036
|
-
attachTerminalServer(
|
|
3332
|
+
const terminalWss = attachTerminalServer();
|
|
3333
|
+
const agentEventsWss = attachAgentEventsServer();
|
|
2037
3334
|
attachFilesServer(httpServer, supabase, config);
|
|
3335
|
+
httpServer.on("upgrade", (request, socket, head) => {
|
|
3336
|
+
const pathname = (request.url ?? "").split("?")[0];
|
|
3337
|
+
if (pathname === "/terminal") {
|
|
3338
|
+
terminalWss.handleUpgrade(request, socket, head, (ws) => terminalWss.emit("connection", ws, request));
|
|
3339
|
+
} else if (pathname === "/agent-events") {
|
|
3340
|
+
agentEventsWss.handleUpgrade(request, socket, head, (ws) => agentEventsWss.emit("connection", ws, request));
|
|
3341
|
+
} else {
|
|
3342
|
+
socket.destroy();
|
|
3343
|
+
}
|
|
3344
|
+
});
|
|
2038
3345
|
let httpServerReady = false;
|
|
2039
3346
|
let realtimeReady = false;
|
|
2040
3347
|
function logRunnerReadyOnceBothUp() {
|
|
@@ -2051,16 +3358,29 @@ async function startRunner() {
|
|
|
2051
3358
|
void ensureTerminalTunnel(supabase, config, terminalPort).catch((err) => logger.runner.error("CLOUDFLARE", "ensureTerminalTunnel failed:", err));
|
|
2052
3359
|
void ensureFilesTickets(supabase, config).catch((err) => logger.runner.error("FILES", "ensureFilesTickets failed:", err));
|
|
2053
3360
|
void ensureSetupTicket(supabase, config).catch((err) => logger.runner.error("FILES", "ensureSetupTicket failed:", err));
|
|
3361
|
+
void ensureAgentTicket(supabase, config).catch((err) => logger.runner.error("FILES", "ensureAgentTicket failed:", err));
|
|
3362
|
+
const presenceChannel = supabase.channel(`runner-presence:${config.runnerId}`);
|
|
3363
|
+
presenceChannel.subscribe((status) => {
|
|
3364
|
+
if (status === "SUBSCRIBED") {
|
|
3365
|
+
void presenceChannel.track({ online: true, version: RUNNER_VERSION });
|
|
3366
|
+
}
|
|
3367
|
+
});
|
|
2054
3368
|
let shuttingDown = false;
|
|
2055
3369
|
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
2056
3370
|
process.on(signal, () => {
|
|
2057
3371
|
if (shuttingDown) return;
|
|
2058
3372
|
shuttingDown = true;
|
|
2059
3373
|
stopAll();
|
|
3374
|
+
void presenceChannel.untrack();
|
|
2060
3375
|
void stopTerminalTunnel(supabase, config).finally(() => process.exit(0));
|
|
2061
3376
|
});
|
|
2062
3377
|
}
|
|
3378
|
+
const HEARTBEAT_WRITE_INTERVAL_MS = 5 * 6e4;
|
|
3379
|
+
let lastHeartbeatWriteAt = 0;
|
|
2063
3380
|
async function heartbeat() {
|
|
3381
|
+
const now = Date.now();
|
|
3382
|
+
if (now - lastHeartbeatWriteAt < HEARTBEAT_WRITE_INTERVAL_MS) return;
|
|
3383
|
+
lastHeartbeatWriteAt = now;
|
|
2064
3384
|
await supabase.from("runners").update({ last_heartbeat_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", config.runnerId);
|
|
2065
3385
|
}
|
|
2066
3386
|
supabase.channel(`runner:${config.runnerId}`).on(
|
|
@@ -2068,24 +3388,34 @@ async function startRunner() {
|
|
|
2068
3388
|
{ event: "UPDATE", schema: "agent_board", table: "cards" },
|
|
2069
3389
|
(payload) => {
|
|
2070
3390
|
const card = payload.new;
|
|
2071
|
-
const previous = payload.old;
|
|
2072
3391
|
if (!registeredPaths.has(card.project_id)) return;
|
|
2073
3392
|
if (card.assigned_user_id !== config.ownerUserId) return;
|
|
3393
|
+
const previousColumn = lastKnownColumn.get(card.id);
|
|
3394
|
+
lastKnownColumn.set(card.id, card.column_name);
|
|
2074
3395
|
if (card.column_name === "desenvolvimento") {
|
|
2075
3396
|
void enqueueCard(supabase, config, card);
|
|
2076
3397
|
}
|
|
2077
|
-
if (
|
|
3398
|
+
if (previousColumn === "desenvolvimento" && card.column_name !== "desenvolvimento") {
|
|
2078
3399
|
void stopCardSession(supabase, config, card);
|
|
2079
3400
|
}
|
|
2080
|
-
if (card.column_name === "revisao" &&
|
|
3401
|
+
if (card.column_name === "revisao" && previousColumn !== "revisao") {
|
|
2081
3402
|
void handleCardEnteredRevisao(supabase, config, card);
|
|
2082
3403
|
}
|
|
2083
|
-
if (
|
|
3404
|
+
if (previousColumn === "revisao" && card.column_name !== "revisao") {
|
|
2084
3405
|
releaseCard(card.id);
|
|
2085
3406
|
}
|
|
2086
|
-
if (card.column_name === "fim" &&
|
|
3407
|
+
if (card.column_name === "fim" && previousColumn !== "fim") {
|
|
2087
3408
|
void handleCardReachedFim(supabase, config, card);
|
|
2088
3409
|
}
|
|
3410
|
+
const previousRequeueRequestedAt = lastKnownRequeueRequestedAt.get(card.id);
|
|
3411
|
+
lastKnownRequeueRequestedAt.set(card.id, card.requeue_requested_at);
|
|
3412
|
+
if (card.requeue_requested_at && card.requeue_requested_at !== previousRequeueRequestedAt) {
|
|
3413
|
+
void requeuePausedCard(supabase, config, card);
|
|
3414
|
+
}
|
|
3415
|
+
if (previousColumn !== void 0 && previousColumn !== card.column_name) {
|
|
3416
|
+
void clearColumnActionStatus(supabase, card);
|
|
3417
|
+
void handleColumnEntered(supabase, config, card);
|
|
3418
|
+
}
|
|
2089
3419
|
}
|
|
2090
3420
|
).on(
|
|
2091
3421
|
"postgres_changes",
|
|
@@ -2100,6 +3430,9 @@ async function startRunner() {
|
|
|
2100
3430
|
if (project.dev_server_restart_requested_at && project.dev_server_restart_requested_at !== previous.dev_server_restart_requested_at) {
|
|
2101
3431
|
void handleDevServerRestartRequested(supabase, config, project);
|
|
2102
3432
|
}
|
|
3433
|
+
if (project.deploy_action_rerun_requested_at && project.deploy_action_rerun_requested_at !== previous.deploy_action_rerun_requested_at) {
|
|
3434
|
+
void handleDeployActionRerunRequested(supabase, config, project);
|
|
3435
|
+
}
|
|
2103
3436
|
}
|
|
2104
3437
|
).subscribe((status) => {
|
|
2105
3438
|
if (status === "SUBSCRIBED") {
|
|
@@ -2116,11 +3449,14 @@ async function startRunner() {
|
|
|
2116
3449
|
await ensureTerminalTunnel(supabase, config, terminalPort);
|
|
2117
3450
|
await ensureFilesTickets(supabase, config);
|
|
2118
3451
|
await ensureSetupTicket(supabase, config);
|
|
3452
|
+
await ensureAgentTicket(supabase, config);
|
|
2119
3453
|
await scanForWork(supabase, config);
|
|
2120
3454
|
await reconcileUsageLimitedSessions(supabase, config).catch((err) => logger.runner.error("ORCHESTRATOR", "reconcileUsageLimitedSessions failed:", err));
|
|
2121
3455
|
await checkPendingDeploys(supabase, config).catch((err) => logger.runner.error("DEPLOY", "checkPendingDeploys failed:", err));
|
|
2122
3456
|
await checkPendingPreviews(supabase, config).catch((err) => logger.runner.error("REVIEW", "checkPendingPreviews failed:", err));
|
|
2123
3457
|
await checkPendingDevServerRestarts(supabase, config).catch((err) => logger.runner.error("REVIEW", "checkPendingDevServerRestarts failed:", err));
|
|
3458
|
+
await checkPendingColumnActions(supabase, config).catch((err) => logger.runner.error("COLUMN_ACTION", "checkPendingColumnActions failed:", err));
|
|
3459
|
+
await checkPendingDeployActionReruns(supabase, config).catch((err) => logger.runner.error("COLUMN_ACTION", "checkPendingDeployActionReruns failed:", err));
|
|
2124
3460
|
}
|
|
2125
3461
|
void tick();
|
|
2126
3462
|
setInterval(() => void tick(), 6e4);
|