Files
boss/scripts/validate-attachment-analysis.mjs
2026-03-29 17:10:58 +08:00

254 lines
8.6 KiB
JavaScript

#!/usr/bin/env node
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { spawn } from "node:child_process";
import { execFile as execFileCallback } from "node:child_process";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
const execFile = promisify(execFileCallback);
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const taskBaseCommit = "3307f7916220b74a8e7d0d8e8b2b12f888d0632a";
const sourceStateFile = path.join(rootDir, "data", "boss-state.json");
async function createSeededRuntime(root, runtimeName) {
const runtimeDir = await fs.mkdtemp(path.join(os.tmpdir(), runtimeName));
const stateFile = path.join(runtimeDir, "data", "boss-state.json");
await fs.mkdir(path.join(runtimeDir, "data", "uploads"), { recursive: true });
await fs.mkdir(path.join(runtimeDir, "public", "downloads"), { recursive: true });
const state = JSON.parse(await fs.readFile(sourceStateFile, "utf8"));
await fs.writeFile(stateFile, JSON.stringify(state, null, 2), "utf8");
return { runtimeDir, stateFile };
}
function parseCookieValue(setCookieHeader, cookieName) {
assert.ok(setCookieHeader, "set-cookie header is missing");
const match = setCookieHeader.match(new RegExp(`${cookieName}=([^;]+)`));
assert.ok(match, `${cookieName} cookie is missing`);
return match[1];
}
async function waitForServer(baseUrl, child, getServerLogs) {
for (let attempt = 0; attempt < 60; attempt += 1) {
if (child.exitCode !== null) {
throw new Error(`SERVER_EXITED_EARLY:${child.exitCode}:${getServerLogs()}`);
}
try {
const response = await fetch(`${baseUrl}/api/health`);
if (response.ok) {
return;
}
} catch {
// keep waiting
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
throw new Error(`SERVER_START_TIMEOUT:${getServerLogs()}`);
}
async function startStandaloneServer(appRoot, runtimeDir, port) {
const baseUrl = `http://127.0.0.1:${port}`;
let logs = "";
const child = spawn("node", [".next/standalone/server.js"], {
cwd: appRoot,
env: {
...process.env,
PORT: String(port),
HOSTNAME: "127.0.0.1",
BOSS_PUBLIC_BASE_URL: baseUrl,
BOSS_RUNTIME_ROOT: runtimeDir,
BOSS_STATE_FILE: path.join(runtimeDir, "data", "boss-state.json"),
BOSS_AUTH_AUTO_LOGIN: "0",
},
stdio: ["ignore", "pipe", "pipe"],
});
child.stdout.on("data", (chunk) => {
logs += chunk.toString();
});
child.stderr.on("data", (chunk) => {
logs += chunk.toString();
});
await waitForServer(baseUrl, child, () => logs);
return {
baseUrl,
child,
getLogs: () => logs,
async stop() {
if (child.exitCode === null) {
child.kill("SIGTERM");
await new Promise((resolve) => setTimeout(resolve, 500));
}
},
};
}
async function loginAsAdmin(baseUrl) {
const response = await fetch(`${baseUrl}/api/auth/login`, {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
account: "17600003315",
password: "boss123456",
method: "password",
}),
});
assert.equal(response.status, 200, "login should succeed");
const payload = await response.json();
assert.equal(payload.ok, true, "login payload should be ok");
const cookie = parseCookieValue(response.headers.get("set-cookie"), "boss_session");
return { cookie, payload };
}
async function uploadAttachment(baseUrl, cookie, projectId, fileName, type, bytes) {
const form = new FormData();
form.set("file", new File([bytes], fileName, { type }));
const response = await fetch(`${baseUrl}/api/v1/projects/${projectId}/attachments`, {
method: "POST",
headers: {
cookie: `boss_session=${cookie}`,
},
body: form,
});
assert.equal(response.status, 200, `upload ${fileName} should succeed`);
return response.json();
}
async function analyzeAttachment(baseUrl, cookie, projectId, attachmentId) {
const response = await fetch(`${baseUrl}/api/v1/projects/${projectId}/attachments/${attachmentId}/analyze`, {
method: "POST",
headers: {
cookie: `boss_session=${cookie}`,
},
});
assert.equal(response.status, 200, "manual analyze should succeed");
return response.json();
}
async function verifyHistoricalPrecheck() {
const worktreePath = await fs.mkdtemp(path.join(os.tmpdir(), "boss-attachment-precheck-"));
let server;
try {
await execFile("git", ["worktree", "add", "--detach", worktreePath, taskBaseCommit], {
cwd: rootDir,
maxBuffer: 32 * 1024 * 1024,
});
await execFile("npm", ["ci", "--ignore-scripts", "--no-audit", "--no-fund"], {
cwd: worktreePath,
env: process.env,
maxBuffer: 32 * 1024 * 1024,
});
await execFile("npm", ["run", "build"], {
cwd: worktreePath,
env: process.env,
maxBuffer: 32 * 1024 * 1024,
});
const { runtimeDir } = await createSeededRuntime(worktreePath, "boss-attachment-precheck-");
server = await startStandaloneServer(worktreePath, runtimeDir, 3115);
const { cookie } = await loginAsAdmin(server.baseUrl);
const response = await fetch(`${server.baseUrl}/api/v1/projects/master-agent/attachments/att-missing/analyze`, {
method: "POST",
headers: {
cookie: `boss_session=${cookie}`,
},
});
assert.notEqual(response.status, 200, "pre-implementation analyze route should not succeed");
} finally {
await server?.stop();
await execFile("git", ["worktree", "remove", "--force", worktreePath], {
cwd: rootDir,
maxBuffer: 32 * 1024 * 1024,
}).catch(() => undefined);
await fs.rm(worktreePath, { recursive: true, force: true });
}
}
await verifyHistoricalPrecheck();
const { runtimeDir } = await createSeededRuntime(rootDir, "boss-attachment-current-");
const currentServer = await startStandaloneServer(rootDir, runtimeDir, 3116);
try {
const { cookie } = await loginAsAdmin(currentServer.baseUrl);
const textUpload = await uploadAttachment(
currentServer.baseUrl,
cookie,
"master-agent",
"analysis-note.txt",
"text/plain",
Buffer.from("text attachment for automatic analysis"),
);
assert.equal(textUpload.attachment.analysisState, "queued_auto", "text attachment should queue automatically");
assert.ok(textUpload.analysisTask, "queued auto attachment should create a master agent task");
assert.equal(
textUpload.analysisTask.taskType,
"attachment_analysis",
"queued task type should be attachment_analysis",
);
assert.equal(
textUpload.analysisTask.attachmentFileName,
"analysis-note.txt",
"queued task should carry attachment file name",
);
assert.ok(textUpload.analysisTask.attachmentDownloadUrl, "queued task should expose attachment download url");
assert.ok(
textUpload.analysisTask.attachmentDownloadUrl.startsWith(currentServer.baseUrl),
"queued task should use the current runtime origin for attachment download",
);
const promptDownloadUrlMatch = textUpload.analysisTask.executionPrompt.match(/downloadUrl:\s+(http[^\s]+)/);
assert.ok(promptDownloadUrlMatch, "execution prompt should include attachment download url");
const unauthDownloadResponse = await fetch(textUpload.analysisTask.attachmentDownloadUrl);
assert.equal(unauthDownloadResponse.status, 200, "attachment download url should be readable with task token");
assert.equal(
await unauthDownloadResponse.text(),
"text attachment for automatic analysis",
"downloaded attachment content should match the uploaded text",
);
const manualUpload = await uploadAttachment(
currentServer.baseUrl,
cookie,
"master-agent",
"manual-binary.bin",
"application/octet-stream",
Buffer.from([0, 1, 2, 3]),
);
assert.equal(
manualUpload.attachment.analysisState,
"ready_manual",
"binary attachment should be manually analyzable",
);
const analyzePayload = await analyzeAttachment(
currentServer.baseUrl,
cookie,
"master-agent",
manualUpload.attachment.attachmentId,
);
assert.ok(analyzePayload.taskId, "manual analyze should return a taskId");
assert.ok(analyzePayload.task, "manual analyze should return a task payload");
assert.equal(
analyzePayload.task.taskType,
"attachment_analysis",
"manual analyze task should be attachment_analysis",
);
assert.equal(
analyzePayload.task.attachmentId,
manualUpload.attachment.attachmentId,
"manual task should link the attachment",
);
console.log("attachment analysis validation passed");
} finally {
await currentServer.stop();
await fs.rm(runtimeDir, { recursive: true, force: true });
}