site-agent-pro 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +689 -0
- package/dist/auth/credentialStore.js +62 -0
- package/dist/auth/inbox.js +193 -0
- package/dist/auth/profile.js +379 -0
- package/dist/auth/runner.js +1124 -0
- package/dist/backend/dashboardData.js +194 -0
- package/dist/backend/runArtifacts.js +48 -0
- package/dist/backend/runRepository.js +93 -0
- package/dist/bin.js +2 -0
- package/dist/cli/backfillSiteChecks.js +143 -0
- package/dist/cli/run.js +309 -0
- package/dist/cli/trade.js +69 -0
- package/dist/config.js +199 -0
- package/dist/core/agentProfiles.js +55 -0
- package/dist/core/aggregateReport.js +382 -0
- package/dist/core/audit.js +30 -0
- package/dist/core/customTaskSuite.js +148 -0
- package/dist/core/evaluator.js +217 -0
- package/dist/core/executor.js +788 -0
- package/dist/core/fallbackReport.js +335 -0
- package/dist/core/formHeuristics.js +411 -0
- package/dist/core/gameplaySummary.js +164 -0
- package/dist/core/interaction.js +202 -0
- package/dist/core/pageState.js +201 -0
- package/dist/core/planner.js +1669 -0
- package/dist/core/processSubmissionBatch.js +204 -0
- package/dist/core/runAuditJob.js +170 -0
- package/dist/core/runner.js +2352 -0
- package/dist/core/siteBrief.js +107 -0
- package/dist/core/siteChecks.js +1526 -0
- package/dist/core/taskDirectives.js +279 -0
- package/dist/core/taskHeuristics.js +263 -0
- package/dist/dashboard/client.js +1256 -0
- package/dist/dashboard/contracts.js +95 -0
- package/dist/dashboard/narrative.js +277 -0
- package/dist/dashboard/server.js +458 -0
- package/dist/dashboard/theme.js +888 -0
- package/dist/index.js +84 -0
- package/dist/llm/client.js +188 -0
- package/dist/paystack/account.js +123 -0
- package/dist/paystack/client.js +100 -0
- package/dist/paystack/index.js +13 -0
- package/dist/paystack/test-paystack.js +83 -0
- package/dist/paystack/transfer.js +138 -0
- package/dist/paystack/types.js +74 -0
- package/dist/paystack/webhook.js +121 -0
- package/dist/prompts/browserAgent.js +124 -0
- package/dist/prompts/reviewer.js +71 -0
- package/dist/reporting/clickReplay.js +290 -0
- package/dist/reporting/html.js +930 -0
- package/dist/reporting/markdown.js +238 -0
- package/dist/reporting/template.js +1141 -0
- package/dist/schemas/types.js +361 -0
- package/dist/submissions/customTasks.js +196 -0
- package/dist/submissions/html.js +770 -0
- package/dist/submissions/model.js +56 -0
- package/dist/submissions/publicUrl.js +76 -0
- package/dist/submissions/service.js +74 -0
- package/dist/submissions/store.js +37 -0
- package/dist/submissions/types.js +65 -0
- package/dist/trade/engine.js +241 -0
- package/dist/trade/evm/erc20.js +44 -0
- package/dist/trade/extractor.js +148 -0
- package/dist/trade/policy.js +35 -0
- package/dist/trade/session.js +31 -0
- package/dist/trade/types.js +107 -0
- package/dist/trade/validator.js +148 -0
- package/dist/utils/files.js +59 -0
- package/dist/utils/log.js +24 -0
- package/dist/utils/playwrightCompat.js +14 -0
- package/dist/utils/time.js +3 -0
- package/dist/wallet/provider.js +345 -0
- package/dist/wallet/relay.js +129 -0
- package/dist/wallet/wallet.js +178 -0
- package/docs/01-installation.md +134 -0
- package/docs/02-running-your-first-audit.md +136 -0
- package/docs/03-configuration.md +233 -0
- package/docs/04-how-the-agent-thinks.md +41 -0
- package/docs/05-extending-personas-and-tasks.md +42 -0
- package/docs/06-hardening-for-production.md +92 -0
- package/package.json +60 -0
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import dotenv from "dotenv";
|
|
5
|
+
import ts from "typescript";
|
|
6
|
+
import { buildRunDetail, buildStandaloneReportHtml, listVisibleRunSummaries } from "../backend/dashboardData.js";
|
|
7
|
+
import { artifactContentType, isAllowedDashboardArtifact, isImageArtifact, isVideoArtifact } from "../backend/runArtifacts.js";
|
|
8
|
+
import { createLocalRunRepository } from "../backend/runRepository.js";
|
|
9
|
+
import { config } from "../config.js";
|
|
10
|
+
import { buildDefaultTradeRunOptions } from "../trade/policy.js";
|
|
11
|
+
import { TradeStrategySchema } from "../trade/types.js";
|
|
12
|
+
import { readUtf8 } from "../utils/files.js";
|
|
13
|
+
import { info, warn } from "../utils/log.js";
|
|
14
|
+
import { canAccessPublicReport, renderExpiredReportPage, renderLandingPage, renderReportUnavailablePage, renderSubmissionStatusPage } from "../submissions/html.js";
|
|
15
|
+
import { readSubmittedInstructionSource, SUBMISSION_TASKS_REQUIRED_MESSAGE } from "../submissions/customTasks.js";
|
|
16
|
+
import { findSubmissionByReportToken } from "../submissions/store.js";
|
|
17
|
+
import { parseSubmissionTargetMode, validateSubmissionUrl } from "../submissions/publicUrl.js";
|
|
18
|
+
import { SubmissionService } from "../submissions/service.js";
|
|
19
|
+
import { DASHBOARD_CSS as SHARED_DASHBOARD_CSS, DASHBOARD_HEAD_TAGS } from "./theme.js";
|
|
20
|
+
import { handleWebhook } from "../paystack/index.js";
|
|
21
|
+
dotenv.config();
|
|
22
|
+
const DASHBOARD_SRC_DIR = path.join(process.cwd(), "src", "dashboard");
|
|
23
|
+
const CLIENT_ENTRY = path.join(DASHBOARD_SRC_DIR, "client.ts");
|
|
24
|
+
const NARRATIVE_ENTRY = path.join(DASHBOARD_SRC_DIR, "narrative.ts");
|
|
25
|
+
const DEFAULT_PORT = 4173;
|
|
26
|
+
const DEFAULT_HOST = "127.0.0.1";
|
|
27
|
+
const RENDER_HOST = "0.0.0.0";
|
|
28
|
+
function parsePort(value) {
|
|
29
|
+
const parsed = Number(value);
|
|
30
|
+
return Number.isInteger(parsed) && parsed >= 1 && parsed <= 65535 ? parsed : DEFAULT_PORT;
|
|
31
|
+
}
|
|
32
|
+
function resolveDashboardHost() {
|
|
33
|
+
const configuredHost = process.env.DASHBOARD_HOST?.trim();
|
|
34
|
+
if (configuredHost) {
|
|
35
|
+
return configuredHost;
|
|
36
|
+
}
|
|
37
|
+
return process.env.RENDER === "true" ? RENDER_HOST : DEFAULT_HOST;
|
|
38
|
+
}
|
|
39
|
+
function transpileDashboardModule(entryPath) {
|
|
40
|
+
const source = readUtf8(entryPath);
|
|
41
|
+
const transpiled = ts.transpileModule(source, {
|
|
42
|
+
compilerOptions: {
|
|
43
|
+
target: ts.ScriptTarget.ES2022,
|
|
44
|
+
module: ts.ModuleKind.ES2022,
|
|
45
|
+
importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove,
|
|
46
|
+
isolatedModules: true
|
|
47
|
+
},
|
|
48
|
+
fileName: entryPath
|
|
49
|
+
});
|
|
50
|
+
return transpiled.outputText;
|
|
51
|
+
}
|
|
52
|
+
function getClientScript() {
|
|
53
|
+
return transpileDashboardModule(CLIENT_ENTRY);
|
|
54
|
+
}
|
|
55
|
+
function getNarrativeScript() {
|
|
56
|
+
return transpileDashboardModule(NARRATIVE_ENTRY);
|
|
57
|
+
}
|
|
58
|
+
function renderDashboardHtml() {
|
|
59
|
+
return `<!doctype html>
|
|
60
|
+
<html lang="en">
|
|
61
|
+
<head>
|
|
62
|
+
<meta charset="utf-8" />
|
|
63
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
64
|
+
<title>Site Agent Dashboard</title>
|
|
65
|
+
${DASHBOARD_HEAD_TAGS}
|
|
66
|
+
<style>${SHARED_DASHBOARD_CSS}</style>
|
|
67
|
+
</head>
|
|
68
|
+
<body>
|
|
69
|
+
<div id="app"></div>
|
|
70
|
+
<script type="module" src="/app.js"></script>
|
|
71
|
+
</body>
|
|
72
|
+
</html>`;
|
|
73
|
+
}
|
|
74
|
+
function sendText(res, statusCode, body, contentType) {
|
|
75
|
+
res.writeHead(statusCode, {
|
|
76
|
+
"Content-Type": `${contentType}; charset=utf-8`,
|
|
77
|
+
"Cache-Control": "no-store"
|
|
78
|
+
});
|
|
79
|
+
res.end(body);
|
|
80
|
+
}
|
|
81
|
+
function sendJson(res, data, statusCode = 200) {
|
|
82
|
+
sendText(res, statusCode, JSON.stringify(data, null, 2), "application/json");
|
|
83
|
+
}
|
|
84
|
+
function sendRedirect(res, location, statusCode = 303) {
|
|
85
|
+
res.writeHead(statusCode, { Location: location });
|
|
86
|
+
res.end();
|
|
87
|
+
}
|
|
88
|
+
async function readRequestBody(req) {
|
|
89
|
+
const chunks = [];
|
|
90
|
+
for await (const chunk of req) {
|
|
91
|
+
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
92
|
+
}
|
|
93
|
+
return Buffer.concat(chunks);
|
|
94
|
+
}
|
|
95
|
+
async function readRequestForm(req) {
|
|
96
|
+
const body = await readRequestBody(req);
|
|
97
|
+
const contentType = req.headers["content-type"] ?? "application/x-www-form-urlencoded";
|
|
98
|
+
const request = new Request("http://site-agent.local/submit", {
|
|
99
|
+
method: "POST",
|
|
100
|
+
headers: {
|
|
101
|
+
"content-type": Array.isArray(contentType) ? (contentType[0] ?? "application/x-www-form-urlencoded") : contentType
|
|
102
|
+
},
|
|
103
|
+
body: new Uint8Array(body)
|
|
104
|
+
});
|
|
105
|
+
return request.formData();
|
|
106
|
+
}
|
|
107
|
+
async function handleRequest(req, res, args) {
|
|
108
|
+
const requestUrl = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
109
|
+
const pathParts = requestUrl.pathname.split("/").filter(Boolean);
|
|
110
|
+
if (requestUrl.pathname === "/favicon.ico") {
|
|
111
|
+
res.writeHead(204);
|
|
112
|
+
res.end();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (req.method === "POST" && requestUrl.pathname === "/webhooks/paystack") {
|
|
116
|
+
await handleWebhook(req, res, {
|
|
117
|
+
onChargeSuccess: async (data) => {
|
|
118
|
+
const amountNaira = (Number(data["amount"] ?? 0)) / 100;
|
|
119
|
+
const customer = data["customer"];
|
|
120
|
+
const email = customer?.["email"];
|
|
121
|
+
info(`[paystack] Received ₦${amountNaira} from ${email ?? "unknown"} — triggering auto-audit`);
|
|
122
|
+
// Trigger a default audit run when payment is received
|
|
123
|
+
// In a real production app, you'd match the 'metadata' or 'email' to a specific user/site
|
|
124
|
+
await args.submissionService.createSubmission({
|
|
125
|
+
url: "https://www.hackquest.io/hackathons/0G-APAC-Hackathon", // Default target for this hackathon agent
|
|
126
|
+
agentCount: 1,
|
|
127
|
+
customTasks: [
|
|
128
|
+
"Perform a full site audit focusing on the hackathon tracks and submission requirements.",
|
|
129
|
+
"Verify all links in the overview and prize tabs are reachable.",
|
|
130
|
+
"Check for any mobile layout issues on the main landing page."
|
|
131
|
+
],
|
|
132
|
+
instructionText: "Automated audit triggered via Paystack payment."
|
|
133
|
+
});
|
|
134
|
+
},
|
|
135
|
+
onTransferSuccess: (data) => {
|
|
136
|
+
info(`[paystack] Transfer successful: ${data["transfer_code"]}`);
|
|
137
|
+
},
|
|
138
|
+
onTransferFailed: (data) => {
|
|
139
|
+
warn(`[paystack] Transfer failed: ${data["transfer_code"]}`);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (req.method === "POST" && requestUrl.pathname === "/submit") {
|
|
145
|
+
const form = await readRequestForm(req);
|
|
146
|
+
const urlInput = typeof form.get("url") === "string" ? form.get("url") : "";
|
|
147
|
+
const selectedTargetMode = parseSubmissionTargetMode(form.get("target"));
|
|
148
|
+
const submittedInstructions = await readSubmittedInstructionSource(form);
|
|
149
|
+
const requestedAgentCount = Number(form.get("agents") ?? "1");
|
|
150
|
+
const requestedTradeEnabled = form.has("trade_enabled");
|
|
151
|
+
const requestedTradeDryRun = form.has("trade_dry_run");
|
|
152
|
+
const requestedTradeStrategy = typeof form.get("trade_strategy") === "string" ? String(form.get("trade_strategy")) : "";
|
|
153
|
+
const requestedTradeConfirmations = Number(form.get("trade_confirmations") ?? "");
|
|
154
|
+
const normalizedAgentCount = Number.isFinite(requestedAgentCount)
|
|
155
|
+
? Math.min(5, Math.max(1, Math.round(requestedAgentCount)))
|
|
156
|
+
: 1;
|
|
157
|
+
const defaultTradeOptions = buildDefaultTradeRunOptions();
|
|
158
|
+
const normalizedTradeOptions = {
|
|
159
|
+
enabled: requestedTradeEnabled || requestedTradeDryRun || defaultTradeOptions.enabled,
|
|
160
|
+
dryRun: requestedTradeDryRun,
|
|
161
|
+
strategy: TradeStrategySchema.safeParse(requestedTradeStrategy).success
|
|
162
|
+
? TradeStrategySchema.parse(requestedTradeStrategy)
|
|
163
|
+
: defaultTradeOptions.strategy,
|
|
164
|
+
confirmations: Number.isInteger(requestedTradeConfirmations) && requestedTradeConfirmations >= 0 && requestedTradeConfirmations <= 12
|
|
165
|
+
? requestedTradeConfirmations
|
|
166
|
+
: defaultTradeOptions.confirmations
|
|
167
|
+
};
|
|
168
|
+
const urlValidation = validateSubmissionUrl(urlInput, {
|
|
169
|
+
allowPrivateHosts: true,
|
|
170
|
+
targetMode: selectedTargetMode
|
|
171
|
+
});
|
|
172
|
+
const submissionError = !urlValidation.valid
|
|
173
|
+
? urlValidation.reason ?? "Enter a valid http or https URL."
|
|
174
|
+
: submittedInstructions.customTasks.length === 0
|
|
175
|
+
? SUBMISSION_TASKS_REQUIRED_MESSAGE
|
|
176
|
+
: null;
|
|
177
|
+
if (submissionError) {
|
|
178
|
+
sendText(res, 400, renderLandingPage({
|
|
179
|
+
error: submissionError,
|
|
180
|
+
submittedUrl: urlInput,
|
|
181
|
+
selectedAgentCount: normalizedAgentCount,
|
|
182
|
+
submittedInstructions: submittedInstructions.instructionText,
|
|
183
|
+
tradeOptions: normalizedTradeOptions,
|
|
184
|
+
allowPrivateTargets: true,
|
|
185
|
+
selectedTargetMode
|
|
186
|
+
}), "text/html");
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const submission = await args.submissionService.createSubmission({
|
|
190
|
+
url: urlValidation.normalizedUrl ?? urlInput.trim(),
|
|
191
|
+
agentCount: normalizedAgentCount,
|
|
192
|
+
tradeOptions: normalizedTradeOptions,
|
|
193
|
+
customTasks: submittedInstructions.customTasks,
|
|
194
|
+
instructionText: submittedInstructions.instructionText,
|
|
195
|
+
instructionFileName: submittedInstructions.instructionFileName
|
|
196
|
+
});
|
|
197
|
+
sendRedirect(res, `/submissions/${encodeURIComponent(submission.id)}`);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (req.method !== "GET") {
|
|
201
|
+
sendText(res, 405, "Method not allowed", "text/plain");
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (requestUrl.pathname === "/health") {
|
|
205
|
+
sendJson(res, { ok: true, service: "site-agent-dashboard" });
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (requestUrl.pathname === "/") {
|
|
209
|
+
sendText(res, 200, renderLandingPage({ allowPrivateTargets: true }), "text/html");
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (requestUrl.pathname === "/dashboard") {
|
|
213
|
+
sendText(res, 200, renderDashboardHtml(), "text/html");
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (requestUrl.pathname === "/app.js") {
|
|
217
|
+
sendText(res, 200, getClientScript(), "application/javascript");
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (requestUrl.pathname === "/narrative.js") {
|
|
221
|
+
sendText(res, 200, getNarrativeScript(), "application/javascript");
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
if (pathParts[0] === "submissions" && pathParts[1] && pathParts.length === 2) {
|
|
225
|
+
const submission = await args.submissionService.getSubmission(decodeURIComponent(pathParts[1]));
|
|
226
|
+
if (!submission) {
|
|
227
|
+
sendText(res, 404, renderReportUnavailablePage({
|
|
228
|
+
title: "Submission not found",
|
|
229
|
+
message: "We could not find that submission."
|
|
230
|
+
}), "text/html");
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
sendText(res, 200, renderSubmissionStatusPage({ appBaseUrl: args.appBaseUrl, submission }), "text/html");
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (pathParts[0] === "r" && pathParts[1] && pathParts.length === 2) {
|
|
237
|
+
const submission = findSubmissionByReportToken(decodeURIComponent(pathParts[1]));
|
|
238
|
+
if (!submission) {
|
|
239
|
+
sendText(res, 404, renderReportUnavailablePage({
|
|
240
|
+
title: "Task output not found",
|
|
241
|
+
message: "This task output link does not exist."
|
|
242
|
+
}), "text/html");
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const access = canAccessPublicReport(submission);
|
|
246
|
+
if (!access.allowed) {
|
|
247
|
+
const html = access.reason === "This task output link has expired."
|
|
248
|
+
? renderExpiredReportPage(submission)
|
|
249
|
+
: renderReportUnavailablePage({
|
|
250
|
+
title: "Task output not ready",
|
|
251
|
+
message: access.reason ?? "This task output is not ready yet."
|
|
252
|
+
});
|
|
253
|
+
const statusCode = access.reason === "This task output link has expired." ? 410 : 202;
|
|
254
|
+
sendText(res, statusCode, html, "text/html");
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
const htmlReport = await buildStandaloneReportHtml(args.runRepository, submission.runId ?? "");
|
|
258
|
+
if (!htmlReport) {
|
|
259
|
+
sendText(res, 404, renderReportUnavailablePage({
|
|
260
|
+
title: "Task output not found",
|
|
261
|
+
message: "The task output artifact could not be loaded."
|
|
262
|
+
}), "text/html");
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
sendText(res, 200, htmlReport, "text/html");
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if ((pathParts[0] === "reports" || pathParts[0] === "outputs") && pathParts[1] && pathParts.length === 2) {
|
|
269
|
+
const runId = decodeURIComponent(pathParts[1]);
|
|
270
|
+
const htmlReport = await buildStandaloneReportHtml(args.runRepository, runId);
|
|
271
|
+
if (!htmlReport) {
|
|
272
|
+
sendText(res, 404, "Task output not found", "text/plain");
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
sendText(res, 200, htmlReport, "text/html");
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
if (requestUrl.pathname === "/api/runs") {
|
|
279
|
+
const runs = await listVisibleRunSummaries(args.runRepository);
|
|
280
|
+
sendJson(res, runs);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (pathParts[0] === "api" && pathParts[1] === "runs" && pathParts[2]) {
|
|
284
|
+
const runId = decodeURIComponent(pathParts[2]);
|
|
285
|
+
if (pathParts.length === 3) {
|
|
286
|
+
const runDetail = await buildRunDetail(args.runRepository, runId);
|
|
287
|
+
if (!runDetail) {
|
|
288
|
+
sendJson(res, { error: `Run '${runId}' not found.` }, 404);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
sendJson(res, runDetail);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
if (pathParts.length === 5 && pathParts[3] === "artifacts" && pathParts[4]) {
|
|
295
|
+
const fileName = decodeURIComponent(pathParts[4]);
|
|
296
|
+
if (!isAllowedDashboardArtifact(fileName)) {
|
|
297
|
+
sendJson(res, { error: "Artifact not available for download." }, 400);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (!(await args.runRepository.hasRun(runId))) {
|
|
301
|
+
sendJson(res, { error: `Run '${runId}' not found.` }, 404);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (fileName === "report.html") {
|
|
305
|
+
const htmlReport = (await args.runRepository.readTextArtifact(runId, "report.html")) ??
|
|
306
|
+
(await buildStandaloneReportHtml(args.runRepository, runId));
|
|
307
|
+
if (!htmlReport) {
|
|
308
|
+
sendJson(res, { error: `Artifact '${fileName}' not found.` }, 404);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
res.writeHead(200, {
|
|
312
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
313
|
+
"Content-Disposition": `attachment; filename="${runId}-${fileName}"`,
|
|
314
|
+
"Cache-Control": "no-store"
|
|
315
|
+
});
|
|
316
|
+
res.end(htmlReport);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
if (isImageArtifact(fileName)) {
|
|
320
|
+
const artifact = await args.runRepository.readBinaryArtifact(runId, fileName);
|
|
321
|
+
if (!artifact) {
|
|
322
|
+
sendJson(res, { error: `Artifact '${fileName}' not found.` }, 404);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
res.writeHead(200, {
|
|
326
|
+
"Content-Type": artifactContentType(fileName),
|
|
327
|
+
"Cache-Control": "no-store"
|
|
328
|
+
});
|
|
329
|
+
res.end(artifact);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
if (isVideoArtifact(fileName)) {
|
|
333
|
+
const artifactPath = await args.runRepository.resolveArtifactPath(runId, fileName);
|
|
334
|
+
if (!artifactPath) {
|
|
335
|
+
sendJson(res, { error: `Artifact '${fileName}' not found.` }, 404);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
const stat = fs.statSync(artifactPath);
|
|
339
|
+
const fileSize = stat.size;
|
|
340
|
+
const range = req.headers.range;
|
|
341
|
+
if (range) {
|
|
342
|
+
const parts = range.replace(/bytes=/, "").split("-");
|
|
343
|
+
const startPart = parts[0];
|
|
344
|
+
if (startPart === undefined) {
|
|
345
|
+
res.writeHead(400);
|
|
346
|
+
res.end("Invalid Range");
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
const start = parseInt(startPart, 10);
|
|
350
|
+
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
|
|
351
|
+
if (start >= fileSize) {
|
|
352
|
+
res.writeHead(416, { "Content-Range": `bytes */${fileSize}` });
|
|
353
|
+
res.end();
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
const chunksize = end - start + 1;
|
|
357
|
+
const file = fs.createReadStream(artifactPath, { start, end });
|
|
358
|
+
res.writeHead(206, {
|
|
359
|
+
"Content-Range": `bytes ${start}-${end}/${fileSize}`,
|
|
360
|
+
"Accept-Ranges": "bytes",
|
|
361
|
+
"Content-Length": chunksize,
|
|
362
|
+
"Content-Type": artifactContentType(fileName),
|
|
363
|
+
"Cache-Control": "no-store"
|
|
364
|
+
});
|
|
365
|
+
file.pipe(res);
|
|
366
|
+
}
|
|
367
|
+
else {
|
|
368
|
+
res.writeHead(200, {
|
|
369
|
+
"Content-Length": fileSize,
|
|
370
|
+
"Content-Type": artifactContentType(fileName),
|
|
371
|
+
"Cache-Control": "no-store"
|
|
372
|
+
});
|
|
373
|
+
fs.createReadStream(artifactPath).pipe(res);
|
|
374
|
+
}
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
const artifact = await args.runRepository.readTextArtifact(runId, fileName);
|
|
378
|
+
if (!artifact) {
|
|
379
|
+
sendJson(res, { error: `Artifact '${fileName}' not found.` }, 404);
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
res.writeHead(200, {
|
|
383
|
+
"Content-Type": fileName.endsWith(".json") ? "application/json; charset=utf-8" : "text/markdown; charset=utf-8",
|
|
384
|
+
"Content-Disposition": `attachment; filename="${runId}-${fileName}"`,
|
|
385
|
+
"Cache-Control": "no-store"
|
|
386
|
+
});
|
|
387
|
+
res.end(artifact);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
sendText(res, 404, "Not found", "text/plain");
|
|
392
|
+
}
|
|
393
|
+
function freePort(port) {
|
|
394
|
+
return new Promise((resolve) => {
|
|
395
|
+
// Find and kill whatever process is holding the port
|
|
396
|
+
const isWin = process.platform === "win32";
|
|
397
|
+
const cmd = isWin
|
|
398
|
+
? `for /f "tokens=5" %a in ('netstat -aon ^| findstr :${port}') do taskkill /F /PID %a`
|
|
399
|
+
: `lsof -ti:${port} | xargs kill -9`;
|
|
400
|
+
import("node:child_process").then(({ exec }) => {
|
|
401
|
+
exec(cmd, () => resolve()); // ignore errors (port may already be free)
|
|
402
|
+
}).catch(() => resolve());
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
function tryListen(server, port, host) {
|
|
406
|
+
return new Promise((resolve, reject) => {
|
|
407
|
+
server.once("error", (err) => {
|
|
408
|
+
if (err.code === "EADDRINUSE") {
|
|
409
|
+
resolve(-1); // signal: port busy, try next
|
|
410
|
+
}
|
|
411
|
+
else {
|
|
412
|
+
reject(err);
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
server.listen(port, host, () => resolve(port));
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
export async function startDashboard(options = {}) {
|
|
419
|
+
const preferredPort = options.port ?? parsePort(process.env.PORT ?? process.env.DASHBOARD_PORT);
|
|
420
|
+
const host = options.host ?? resolveDashboardHost();
|
|
421
|
+
const submissionService = new SubmissionService();
|
|
422
|
+
const runRepository = createLocalRunRepository();
|
|
423
|
+
const server = http.createServer((req, res) => {
|
|
424
|
+
handleRequest(req, res, { appBaseUrl: "", runRepository, submissionService }).catch((error) => {
|
|
425
|
+
const message = error instanceof Error ? error.message : "Unknown dashboard error";
|
|
426
|
+
warn(`Dashboard request failed: ${message}`);
|
|
427
|
+
sendJson(res, { error: message }, 500);
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
// Free the preferred port if something else is already using it (e.g. a stale session)
|
|
431
|
+
await freePort(preferredPort);
|
|
432
|
+
// Small delay to allow the OS to release the port after the kill
|
|
433
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
434
|
+
let boundPort = -1;
|
|
435
|
+
const MAX_ATTEMPTS = 3;
|
|
436
|
+
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
|
437
|
+
const candidatePort = preferredPort + attempt;
|
|
438
|
+
const result = await tryListen(server, candidatePort, host);
|
|
439
|
+
if (result !== -1) {
|
|
440
|
+
boundPort = result;
|
|
441
|
+
break;
|
|
442
|
+
}
|
|
443
|
+
if (attempt === 0) {
|
|
444
|
+
warn(`Port ${preferredPort} still in use after freeing, trying ${preferredPort + 1}...`);
|
|
445
|
+
}
|
|
446
|
+
server.removeAllListeners("error");
|
|
447
|
+
}
|
|
448
|
+
if (boundPort === -1) {
|
|
449
|
+
throw new Error(`Could not find a free port after ${MAX_ATTEMPTS} attempts starting from ${preferredPort}.`);
|
|
450
|
+
}
|
|
451
|
+
const url = `http://${host === DEFAULT_HOST ? "localhost" : host}:${boundPort}`;
|
|
452
|
+
const appBaseUrl = config.appBaseUrl || url;
|
|
453
|
+
// Patch appBaseUrl into the request handler closure via a shared ref
|
|
454
|
+
server.__appBaseUrl = appBaseUrl;
|
|
455
|
+
info(`Dashboard ready at ${url}`);
|
|
456
|
+
submissionService.resumePendingSubmissions();
|
|
457
|
+
return { server, port: boundPort, host, url };
|
|
458
|
+
}
|