feat: 任务附件——server/worker/console 链路打通(+ popen goal 新语义补漏)

server:POST /uploads(multipart,413 预检、路径/数量/大小校验、
24h 过期包、used 防一包多用)+ 建任务绑定(同事务写附件行 +
包置 used,cloud 执行器 400)+ 列表/详情带 attachments +
取消删暂存 + 分支带走附件清单;db.py PG 建表 + init_db 补表。
worker:_task_seed_dir(无附件/目录已删静默 None)+ dispatch/
goal 三路(同步本地、popen、Docker 只读挂载 /tmp/codex-seed)
+ cloud 执行器兜底失败 + 终局删暂存。
console:文件/文件夹/图片三路输入,先上传拿 upload_id 再建任务,
列表 [附件×n]、详情列附件路径。
附带:popen 的 goal 命令此前强制要求 verify_command,goal 新语义
任务到本地 worker(走 popen)会直接报错,改成与同步 goal_task
一致(无验收不传 --verify,轮次 0 原样传)。
测试:新增 test_task_attachments.py 15 个。
This commit is contained in:
host committed 2026-10-27 04:30:43 +08:00
1 parent 9537e6f0d2
commit 7c444a688b
8 files changed
+869 -32

No files matched your search

+54 -1
View File
@@ -173,6 +173,12 @@
</select>
</div>
<div id="goalHint" style="display:none" class="muted">goal 模式:输入框内容即目标,不需要专用 prompt 与验收命令;不限轮次,模型确认达成后在回复末尾输出“目标已达成”即结束,未达成会一直运行,可用取消停止。</div>
<div class="bar">
<span class="muted">附件(放进任务工作区,图片与普通文件不区分):</span>
<input type="file" id="attFiles" multiple title="文件">
<input type="file" id="attDir" webkitdirectory title="文件夹">
<input type="file" id="attImg" accept="image/*" multiple title="图片">
</div>
<div class="bar">
<button id="createTask" class="primary">提交任务</button>
<span id="taskMsg" class="muted"></span>
@@ -378,7 +384,7 @@ async function loadTasks() {
var pr = t.pr_url ? "<a href='" + esc(t.pr_url) + "' target='_blank' rel='noopener'>#" + esc(t.pr_number) + "</a>" : "";
return "<tr data-id='" + esc(t.id) + "' class='" + (currentTask && currentTask.id === t.id ? "selected" : "") + "'>" +
"<td>" + esc(shortId(t.id)) + "</td>" +
"<td>" + (t.plan ? "[计划] " : "") + (t.forked_from ? "[分支] " : "") + ((t.goal_mode || t.verify_command) ? "[目标] " : "") + (t.environment_id ? "[环境 " + esc(shortId(t.environment_id)) + "] " : "") + esc(String(t.prompt || "").slice(0, 60)) + "</td>" +
"<td>" + (t.plan ? "[计划] " : "") + (t.forked_from ? "[分支] " : "") + ((t.goal_mode || t.verify_command) ? "[目标] " : "") + (t.environment_id ? "[环境 " + esc(shortId(t.environment_id)) + "] " : "") + ((t.attachments && t.attachments.length) ? "[附件×" + t.attachments.length + "] " : "") + esc(String(t.prompt || "").slice(0, 60)) + "</td>" +
"<td class='st-" + esc(t.status) + "'>" + esc(t.status) + "</td>" +
"<td>" + esc(t.executor || "") + "</td>" +
"<td>" + esc(t.worktree_host || "") + "</td>" +
@@ -505,6 +511,7 @@ async function showDetail(taskId) {
(t.plan ? " | <b>计划模式(只读)</b>" : "") +
(t.forked_from ? " | 分支自 " + esc(shortId(t.forked_from)) : "") +
(t.goal_mode ? " | <b>goal 模式(不限轮次,模型宣告完成)</b>" : (t.verify_command ? " | goal 模式(旧语义,验收命令)" : "")) +
((t.attachments && t.attachments.length) ? " | 附件:" + t.attachments.map(esc).join("、") : "") +
(t.pr_url ? " | <a href='" + esc(t.pr_url) + "' target='_blank' rel='noopener'>PR #" + esc(t.pr_number) + "</a>" : "") +
"<br>prompt:<br>" + esc(t.prompt || "");
var body = "";
@@ -614,6 +621,41 @@ $("#createEnvironment").onclick = async function () {
loadEnvironments();
} catch (e) { $("#envMsg").innerHTML = "<span class='err'>新建失败:" + esc(e.message) + "</span>"; }
};
function gatherAttachments() {
// 三路输入合并成 [{path, file}]:文件路用文件名,文件夹路用
// webkitRelativePath(含子目录),图片路用文件名;同路径只留
// 第一个(跨路重复以先选为准,服务端还会再校验一次)。
var out = [], seen = {};
function add(list, pathOf) {
for (var i = 0; i < list.length; i++) {
var p = pathOf(list[i]);
if (!p || seen[p]) continue;
seen[p] = true;
out.push({ path: p, file: list[i] });
}
}
add($("#attFiles").files, function (f) { return f.name; });
add($("#attDir").files, function (f) { return f.webkitRelativePath || f.name; });
add($("#attImg").files, function (f) { return f.name; });
return out;
}
async function uploadAttachments(items) {
// 先 POST /uploads 拿 upload_id,建任务时绑定;上传失败直接
// 抛错,任务不建(避免无附件的任务先跑起来)。注意不能复用
// api()(它会固定写 Content-Type: application/json,multipart
// 的 boundary 必须由浏览器自己生成)。
var fd = new FormData();
fd.append("paths", JSON.stringify(items.map(function (x) { return x.path; })));
items.forEach(function (x, i) { fd.append("file" + i, x.file); });
var r = await fetch("/uploads", {
method: "POST",
headers: { "X-API-Key": getKey() },
body: fd
});
var d = await r.json().catch(function () { return {}; });
if (!r.ok) throw new Error((d && d.error) || ("HTTP " + r.status));
return d.upload_id;
}
$("#createTask").onclick = async function () {
var pid = $("#projectSelect").value;
var prompt = $("#prompt").value.trim();
@@ -643,10 +685,21 @@ $("#createTask").onclick = async function () {
// 验收命令与轮次(服务端 goal_mode=1 时一律不限轮次)。
body.goal_mode = 1;
}
var atts = gatherAttachments();
if (atts.length) {
try {
$("#taskMsg").textContent = "上传附件中…";
body.upload_id = await uploadAttachments(atts);
} catch (e) {
$("#taskMsg").innerHTML = "<span class='err'>附件上传失败:" + esc(e.message) + "</span>";
return;
}
}
try {
var t = await api("/tasks", "POST", body);
$("#taskMsg").innerHTML = "<span class='ok'>任务已建:" + esc(shortId(t.id)) + "</span>";
$("#prompt").value = "";
$("#attFiles").value = ""; $("#attDir").value = ""; $("#attImg").value = "";
loadTasks();
} catch (e) { $("#taskMsg").innerHTML = "<span class='err'>提交失败:" + esc(e.message) + "</span>"; }
};