fix: stabilize attachment upload and storage flows
This commit is contained in:
@@ -3,41 +3,24 @@ 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";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
const execFile = promisify(execFileCallback);
|
||||
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const runtimeDir = await fs.mkdtemp(path.join(os.tmpdir(), "boss-attachment-analysis-"));
|
||||
const stateFile = path.join(runtimeDir, "data", "boss-state.json");
|
||||
const require = createRequire(import.meta.url);
|
||||
const taskBaseCommit = "3307f7916220b74a8e7d0d8e8b2b12f888d0632a";
|
||||
const sourceStateFile = path.join(rootDir, "data", "boss-state.json");
|
||||
|
||||
process.env.BOSS_RUNTIME_ROOT = runtimeDir;
|
||||
process.env.BOSS_STATE_FILE = stateFile;
|
||||
process.env.BOSS_AUTH_AUTO_LOGIN = "0";
|
||||
|
||||
const { NextRequest } = require("next/server");
|
||||
const authLoginRoute = require(path.join(rootDir, ".next/standalone/.next/server/app/api/auth/login/route.js"));
|
||||
const attachmentsRoute = require(
|
||||
path.join(rootDir, ".next/standalone/.next/server/app/api/v1/projects/[projectId]/attachments/route.js"),
|
||||
);
|
||||
const analyzeRoute = require(
|
||||
path.join(
|
||||
rootDir,
|
||||
".next/standalone/.next/server/app/api/v1/projects/[projectId]/attachments/[attachmentId]/analyze/route.js",
|
||||
),
|
||||
);
|
||||
|
||||
const loginHandler = authLoginRoute.routeModule.userland.POST;
|
||||
const uploadHandler = attachmentsRoute.routeModule.userland.POST;
|
||||
const analyzeHandler = analyzeRoute.routeModule.userland.POST;
|
||||
|
||||
async function invokeRoute(handler, url, init = {}, context) {
|
||||
const request = new NextRequest(url, {
|
||||
method: init.method ?? "GET",
|
||||
headers: init.headers,
|
||||
body: init.body,
|
||||
});
|
||||
return handler(request, context);
|
||||
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) {
|
||||
@@ -47,20 +30,73 @@ function parseCookieValue(setCookieHeader, cookieName) {
|
||||
return match[1];
|
||||
}
|
||||
|
||||
async function loginAsAdmin() {
|
||||
const response = await invokeRoute(
|
||||
loginHandler,
|
||||
"http://localhost/api/auth/login",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
account: "17600003315",
|
||||
password: "boss123456",
|
||||
method: "password",
|
||||
}),
|
||||
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_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");
|
||||
@@ -68,74 +104,135 @@ async function loginAsAdmin() {
|
||||
return { cookie, payload };
|
||||
}
|
||||
|
||||
async function uploadAttachment(cookie, projectId, fileName, type, bytes) {
|
||||
async function uploadAttachment(baseUrl, cookie, projectId, fileName, type, bytes) {
|
||||
const form = new FormData();
|
||||
form.set("file", new File([bytes], fileName, { type }));
|
||||
|
||||
const response = await invokeRoute(
|
||||
uploadHandler,
|
||||
`http://localhost/api/v1/projects/${projectId}/attachments`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { cookie: `boss_session=${cookie}` },
|
||||
body: form,
|
||||
const response = await fetch(`${baseUrl}/api/v1/projects/${projectId}/attachments`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
cookie: `boss_session=${cookie}`,
|
||||
},
|
||||
{ params: Promise.resolve({ projectId }) },
|
||||
);
|
||||
body: form,
|
||||
});
|
||||
assert.equal(response.status, 200, `upload ${fileName} should succeed`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
const { cookie } = await loginAsAdmin();
|
||||
|
||||
const textUpload = await uploadAttachment(
|
||||
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",
|
||||
);
|
||||
|
||||
const manualUpload = await uploadAttachment(
|
||||
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 analyzeResponse = await invokeRoute(
|
||||
analyzeHandler,
|
||||
`http://localhost/api/v1/projects/master-agent/attachments/${manualUpload.attachment.attachmentId}/analyze`,
|
||||
{
|
||||
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}` },
|
||||
},
|
||||
{
|
||||
params: Promise.resolve({
|
||||
projectId: "master-agent",
|
||||
attachmentId: manualUpload.attachment.attachmentId,
|
||||
}),
|
||||
},
|
||||
);
|
||||
assert.equal(analyzeResponse.status, 200, "manual analyze should succeed");
|
||||
const analyzePayload = await analyzeResponse.json();
|
||||
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",
|
||||
);
|
||||
headers: {
|
||||
cookie: `boss_session=${cookie}`,
|
||||
},
|
||||
});
|
||||
assert.equal(response.status, 200, "manual analyze should succeed");
|
||||
return response.json();
|
||||
}
|
||||
|
||||
console.log("attachment analysis validation passed");
|
||||
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",
|
||||
);
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user