feat(kanban): file attachments on tasks (#35395)
Tasks can now carry file attachments (PDFs, images, source docs) that workers read directly — closes the gap where source material had to be pasted as a path into the task body. - kanban_db: task_attachments table (additive), Attachment dataclass, add/list/get/delete accessors, attachments_root/task_attachments_dir path helpers (per-board, HERMES_KANBAN_ATTACHMENTS_ROOT override) - build_worker_context: surfaces each attachment's absolute path so the worker (full file/terminal tool access) reads it via read_file/pdftotext - dashboard API: POST/GET/DELETE attachment routes (multipart upload, 25MB cap, traversal-safe filenames, root-containment check on download) - dashboard UI: Attachments section in the task drawer — upload button, list with download, per-row remove - docs + tests (13 cases: DB accessors, REST round-trip, traversal rejection, collision suffixing, worker-context surfacing) Closes #35338
This commit is contained in:
@ -396,6 +396,41 @@ def workspaces_root(board: Optional[str] = None) -> Path:
|
||||
return board_dir(slug) / "workspaces"
|
||||
|
||||
|
||||
def attachments_root(board: Optional[str] = None) -> Path:
|
||||
"""Return the directory under which task file attachments are stored.
|
||||
|
||||
Mirrors :func:`worker_logs_dir` / :func:`workspaces_root`: anchored
|
||||
per-board so attachments don't leak between projects. Each task gets
|
||||
its own ``<root>/.../attachments/<task_id>/`` subdirectory.
|
||||
|
||||
``HERMES_KANBAN_ATTACHMENTS_ROOT`` pins the path directly (highest
|
||||
precedence) for tests and unusual deployments.
|
||||
|
||||
``default`` uses ``<root>/kanban/attachments/``; other boards use
|
||||
``<root>/kanban/boards/<slug>/attachments/``.
|
||||
|
||||
Workers (which run with full file-tool access) read attached files
|
||||
by the absolute path surfaced in :func:`build_worker_context`. On the
|
||||
local terminal backend — the default for kanban — that path resolves
|
||||
directly. Remote backends (Docker/Modal) need this directory mounted;
|
||||
see the kanban docs.
|
||||
"""
|
||||
override = os.environ.get("HERMES_KANBAN_ATTACHMENTS_ROOT", "").strip()
|
||||
if override:
|
||||
return Path(override).expanduser()
|
||||
slug = _normalize_board_slug(board)
|
||||
if slug is None:
|
||||
slug = get_current_board()
|
||||
if slug == DEFAULT_BOARD:
|
||||
return kanban_home() / "kanban" / "attachments"
|
||||
return board_dir(slug) / "attachments"
|
||||
|
||||
|
||||
def task_attachments_dir(task_id: str, board: Optional[str] = None) -> Path:
|
||||
"""Return the per-task attachment directory ``<root>/<task_id>/``."""
|
||||
return attachments_root(board=board) / task_id
|
||||
|
||||
|
||||
def worker_logs_dir(board: Optional[str] = None) -> Path:
|
||||
"""Return the directory under which per-task worker logs are written.
|
||||
|
||||
@ -831,6 +866,20 @@ class Comment:
|
||||
created_at: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class Attachment:
|
||||
"""In-memory view of a row from the ``task_attachments`` table."""
|
||||
|
||||
id: int
|
||||
task_id: str
|
||||
filename: str
|
||||
stored_path: str
|
||||
content_type: Optional[str]
|
||||
size: int
|
||||
uploaded_by: Optional[str]
|
||||
created_at: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class Event:
|
||||
id: int
|
||||
@ -957,6 +1006,23 @@ CREATE TABLE IF NOT EXISTS task_runs (
|
||||
error TEXT
|
||||
);
|
||||
|
||||
-- Files attached to a task (PDFs, images, source documents). The blob
|
||||
-- lives on disk under ``attachments_root(board)/<task_id>/<stored_name>``;
|
||||
-- this row carries metadata + the absolute ``stored_path`` so the
|
||||
-- dashboard can list/download and ``build_worker_context`` can surface
|
||||
-- the absolute path to the worker (which has full file-tool access). See
|
||||
-- #35338.
|
||||
CREATE TABLE IF NOT EXISTS task_attachments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id TEXT NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
stored_path TEXT NOT NULL,
|
||||
content_type TEXT,
|
||||
size INTEGER NOT NULL DEFAULT 0,
|
||||
uploaded_by TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Subscription from a gateway source (platform + chat + thread) to a
|
||||
-- task. The gateway's kanban-notifier watcher tails task_events and
|
||||
-- pushes ``completed`` / ``blocked`` / ``spawn_auto_blocked`` events to
|
||||
@ -981,6 +1047,7 @@ CREATE INDEX IF NOT EXISTS idx_comments_task ON task_comments(task_id, c
|
||||
CREATE INDEX IF NOT EXISTS idx_events_task ON task_events(task_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_runs_task ON task_runs(task_id, started_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_runs_status ON task_runs(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_attachments_task ON task_attachments(task_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_notify_task ON kanban_notify_subs(task_id);
|
||||
"""
|
||||
|
||||
@ -2386,6 +2453,121 @@ def list_comments(conn: sqlite3.Connection, task_id: str) -> list[Comment]:
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Attachments
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def add_attachment(
|
||||
conn: sqlite3.Connection,
|
||||
task_id: str,
|
||||
*,
|
||||
filename: str,
|
||||
stored_path: str,
|
||||
content_type: Optional[str] = None,
|
||||
size: int = 0,
|
||||
uploaded_by: Optional[str] = None,
|
||||
) -> int:
|
||||
"""Record a file attachment for a task. Returns the new attachment id.
|
||||
|
||||
The caller is responsible for writing the blob to ``stored_path``
|
||||
first (under :func:`task_attachments_dir`); this only persists the
|
||||
metadata row and appends an ``attached`` event.
|
||||
"""
|
||||
if not filename or not filename.strip():
|
||||
raise ValueError("attachment filename is required")
|
||||
if not stored_path or not stored_path.strip():
|
||||
raise ValueError("attachment stored_path is required")
|
||||
now = int(time.time())
|
||||
with write_txn(conn):
|
||||
if not conn.execute(
|
||||
"SELECT 1 FROM tasks WHERE id = ?", (task_id,)
|
||||
).fetchone():
|
||||
raise ValueError(f"unknown task {task_id}")
|
||||
cur = conn.execute(
|
||||
"INSERT INTO task_attachments "
|
||||
"(task_id, filename, stored_path, content_type, size, uploaded_by, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
task_id,
|
||||
filename.strip(),
|
||||
stored_path,
|
||||
content_type,
|
||||
int(size),
|
||||
uploaded_by,
|
||||
now,
|
||||
),
|
||||
)
|
||||
_append_event(
|
||||
conn,
|
||||
task_id,
|
||||
"attached",
|
||||
{"filename": filename.strip(), "size": int(size), "by": uploaded_by},
|
||||
)
|
||||
return int(cur.lastrowid or 0)
|
||||
|
||||
|
||||
def list_attachments(conn: sqlite3.Connection, task_id: str) -> list[Attachment]:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM task_attachments WHERE task_id = ? ORDER BY created_at ASC, id ASC",
|
||||
(task_id,),
|
||||
).fetchall()
|
||||
return [
|
||||
Attachment(
|
||||
id=r["id"],
|
||||
task_id=r["task_id"],
|
||||
filename=r["filename"],
|
||||
stored_path=r["stored_path"],
|
||||
content_type=r["content_type"],
|
||||
size=r["size"] or 0,
|
||||
uploaded_by=r["uploaded_by"],
|
||||
created_at=r["created_at"],
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def get_attachment(conn: sqlite3.Connection, attachment_id: int) -> Optional[Attachment]:
|
||||
r = conn.execute(
|
||||
"SELECT * FROM task_attachments WHERE id = ?", (attachment_id,)
|
||||
).fetchone()
|
||||
if r is None:
|
||||
return None
|
||||
return Attachment(
|
||||
id=r["id"],
|
||||
task_id=r["task_id"],
|
||||
filename=r["filename"],
|
||||
stored_path=r["stored_path"],
|
||||
content_type=r["content_type"],
|
||||
size=r["size"] or 0,
|
||||
uploaded_by=r["uploaded_by"],
|
||||
created_at=r["created_at"],
|
||||
)
|
||||
|
||||
|
||||
def delete_attachment(conn: sqlite3.Connection, attachment_id: int) -> Optional[Attachment]:
|
||||
"""Delete an attachment row and its on-disk blob. Returns the removed row.
|
||||
|
||||
Returns ``None`` when no row matched. The blob is removed best-effort
|
||||
(a missing file is not an error); the metadata row is the source of
|
||||
truth for whether an attachment "exists".
|
||||
"""
|
||||
with write_txn(conn):
|
||||
att = get_attachment(conn, attachment_id)
|
||||
if att is None:
|
||||
return None
|
||||
conn.execute("DELETE FROM task_attachments WHERE id = ?", (attachment_id,))
|
||||
_append_event(
|
||||
conn, att.task_id, "attachment_removed", {"filename": att.filename}
|
||||
)
|
||||
try:
|
||||
p = Path(att.stored_path)
|
||||
if p.is_file():
|
||||
p.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return att
|
||||
|
||||
|
||||
def list_events(conn: sqlite3.Connection, task_id: str) -> list[Event]:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM task_events WHERE task_id = ? ORDER BY created_at ASC, id ASC",
|
||||
@ -6465,6 +6647,25 @@ def build_worker_context(conn: sqlite3.Connection, task_id: str) -> str:
|
||||
lines.append(_cap(task.body, _CTX_MAX_BODY_BYTES))
|
||||
lines.append("")
|
||||
|
||||
# Attachments — files uploaded to this task (PDFs, source docs,
|
||||
# images). Surface the absolute on-disk path so the worker, which has
|
||||
# full file-tool access, can read them directly (read_file, terminal
|
||||
# `pdftotext`, etc.). On the local terminal backend the path resolves
|
||||
# as-is; remote backends need the kanban attachments dir mounted.
|
||||
attachments = list_attachments(conn, task_id)
|
||||
if attachments:
|
||||
lines.append("## Attachments")
|
||||
lines.append(
|
||||
"Files attached to this task. Read them with the file/terminal "
|
||||
"tools at the absolute paths below:"
|
||||
)
|
||||
for att in attachments:
|
||||
size_kb = max(1, (att.size + 1023) // 1024) if att.size else 0
|
||||
size_str = f", {size_kb} KB" if size_kb else ""
|
||||
ctype = f", {att.content_type}" if att.content_type else ""
|
||||
lines.append(f"- `{att.filename}`{ctype}{size_str} → `{att.stored_path}`")
|
||||
lines.append("")
|
||||
|
||||
# Prior attempts — show closed runs so a retrying worker sees the
|
||||
# history. Skip the currently-active run (that's this worker).
|
||||
# Cap at _CTX_MAX_PRIOR_ATTEMPTS most-recent closed runs; older
|
||||
|
||||
165
plugins/kanban/dashboard/dist/index.js
vendored
165
plugins/kanban/dashboard/dist/index.js
vendored
@ -2741,6 +2741,8 @@
|
||||
// Ready/Block/Complete buttons feel like no-ops. See #26744.
|
||||
const [patchErr, setPatchErr] = useState(null);
|
||||
const [newComment, setNewComment] = useState("");
|
||||
const [uploadBusy, setUploadBusy] = useState(false);
|
||||
const [uploadErr, setUploadErr] = useState(null);
|
||||
const [editing, setEditing] = useState(false);
|
||||
// Home-channel notification toggles. homeChannels is the list of platforms
|
||||
// the user has a /sethome on; each entry has a `subscribed` bool telling
|
||||
@ -2789,6 +2791,49 @@
|
||||
}).catch(function (e) { setErr(String(e.message || e)); });
|
||||
};
|
||||
|
||||
// File upload uses raw fetch (not SDK.fetchJSON, which JSON-encodes)
|
||||
// so the browser sets the multipart boundary. Auth rides the session
|
||||
// cookie + bearer token, matching the rest of the dashboard.
|
||||
const handleUpload = function (fileList) {
|
||||
const files = Array.prototype.slice.call(fileList || []);
|
||||
if (!files.length) return;
|
||||
setUploadBusy(true);
|
||||
setUploadErr(null);
|
||||
const token = window.__HERMES_SESSION_TOKEN__ || "";
|
||||
const headers = token ? { Authorization: "Bearer " + token } : {};
|
||||
const url = withBoard(`${API}/tasks/${encodeURIComponent(props.taskId)}/attachments`, boardSlug);
|
||||
// Upload sequentially so a partial failure leaves a clear state.
|
||||
let chain = Promise.resolve();
|
||||
files.forEach(function (f) {
|
||||
chain = chain.then(function () {
|
||||
const fd = new FormData();
|
||||
fd.append("file", f, f.name);
|
||||
return fetch(url, { method: "POST", headers: headers, credentials: "same-origin", body: fd })
|
||||
.then(function (resp) {
|
||||
if (!resp.ok) {
|
||||
return resp.text().then(function (txt) {
|
||||
throw new Error(parseApiErrorMessage(new Error(resp.status + ": " + txt)));
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
chain.then(function () {
|
||||
load();
|
||||
props.onRefresh();
|
||||
}).catch(function (e) {
|
||||
setUploadErr(String(e.message || e));
|
||||
}).finally(function () {
|
||||
setUploadBusy(false);
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteAttachment = function (attachmentId) {
|
||||
return SDK.fetchJSON(withBoard(`${API}/attachments/${attachmentId}`, boardSlug), { method: "DELETE" })
|
||||
.then(function () { load(); props.onRefresh(); })
|
||||
.catch(function (e) { setUploadErr(String(e.message || e)); });
|
||||
};
|
||||
|
||||
const doPatch = function (patch, opts) {
|
||||
if (opts && opts.confirm && !window.confirm(opts.confirm)) {
|
||||
return Promise.resolve();
|
||||
@ -2946,6 +2991,10 @@
|
||||
homeBusy: homeBusy,
|
||||
onToggleHomeSub: toggleHomeSubscription,
|
||||
onRefresh: props.onRefresh,
|
||||
onUpload: handleUpload,
|
||||
onDeleteAttachment: handleDeleteAttachment,
|
||||
uploadBusy: uploadBusy,
|
||||
uploadErr: uploadErr,
|
||||
}) : null,
|
||||
data ? h("div", { className: "hermes-kanban-drawer-comment-row" },
|
||||
h(Input, {
|
||||
@ -2968,11 +3017,118 @@
|
||||
);
|
||||
}
|
||||
|
||||
function _fmtBytes(n) {
|
||||
n = Number(n) || 0;
|
||||
if (n < 1024) return n + " B";
|
||||
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
|
||||
return (n / (1024 * 1024)).toFixed(1) + " MB";
|
||||
}
|
||||
|
||||
// Attachments section in the task drawer (#35338). Upload button +
|
||||
// list with download links and a delete (×) per row. The download
|
||||
// link hits GET /attachments/:id which streams the file; the worker
|
||||
// context surfaces the same files' absolute paths so a kanban worker
|
||||
// can read them with the file/terminal tools.
|
||||
function AttachmentsSection(props) {
|
||||
const i18n = props.i18n;
|
||||
const atts = props.attachments || [];
|
||||
const fileRef = useRef(null);
|
||||
const [dlErr, setDlErr] = useState(null);
|
||||
// Download via authenticated fetch → blob → synthetic anchor click.
|
||||
// A plain <a href> can't carry the session header/bearer the dashboard
|
||||
// auth middleware requires in loopback mode, so fetch with the token
|
||||
// and hand the browser a blob URL instead.
|
||||
function downloadAttachment(a) {
|
||||
const token = window.__HERMES_SESSION_TOKEN__ || "";
|
||||
const headers = token ? { Authorization: "Bearer " + token } : {};
|
||||
const url = withBoard(`${API}/attachments/${a.id}`, props.boardSlug);
|
||||
setDlErr(null);
|
||||
fetch(url, { headers: headers, credentials: "same-origin" })
|
||||
.then(function (resp) {
|
||||
if (!resp.ok) {
|
||||
return resp.text().then(function (txt) {
|
||||
throw new Error(parseApiErrorMessage(new Error(resp.status + ": " + txt)));
|
||||
});
|
||||
}
|
||||
return resp.blob();
|
||||
})
|
||||
.then(function (blob) {
|
||||
const objUrl = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = objUrl;
|
||||
link.download = a.filename || "attachment";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
setTimeout(function () { URL.revokeObjectURL(objUrl); }, 10000);
|
||||
})
|
||||
.catch(function (e) { setDlErr(String(e.message || e)); });
|
||||
}
|
||||
return h("div", { className: "hermes-kanban-section" },
|
||||
h("div", { className: "hermes-kanban-section-head" },
|
||||
`${tx(i18n, "attachments", "Attachments")} (${atts.length})`),
|
||||
h("input", {
|
||||
ref: fileRef,
|
||||
type: "file",
|
||||
multiple: true,
|
||||
style: { display: "none" },
|
||||
onChange: function (e) {
|
||||
if (props.onUpload) props.onUpload(e.target.files);
|
||||
// Reset so selecting the same file again re-triggers onChange.
|
||||
try { e.target.value = ""; } catch (_e) { /* ignore */ }
|
||||
},
|
||||
}),
|
||||
h("div", { className: "flex items-center gap-2 mb-2" },
|
||||
h(Button, {
|
||||
size: "sm",
|
||||
variant: "outline",
|
||||
disabled: !!props.uploadBusy,
|
||||
onClick: function () { if (fileRef.current) fileRef.current.click(); },
|
||||
}, props.uploadBusy
|
||||
? tx(i18n, "uploading", "Uploading…")
|
||||
: tx(i18n, "uploadFile", "Upload file")),
|
||||
),
|
||||
(props.uploadErr || dlErr)
|
||||
? h("div", { className: "text-xs text-destructive mb-2" }, props.uploadErr || dlErr)
|
||||
: null,
|
||||
atts.length === 0
|
||||
? h("div", { className: "text-xs text-muted-foreground" },
|
||||
tx(i18n, "noAttachments", "— no attachments —"))
|
||||
: atts.map(function (a) {
|
||||
return h("div", {
|
||||
key: a.id,
|
||||
className: "flex items-center justify-between gap-2 py-1 text-sm",
|
||||
},
|
||||
h("button", {
|
||||
type: "button",
|
||||
className: "hermes-kanban-attachment-link truncate",
|
||||
title: a.filename,
|
||||
onClick: function () { downloadAttachment(a); },
|
||||
}, a.filename),
|
||||
h("span", { className: "text-xs text-muted-foreground whitespace-nowrap" },
|
||||
_fmtBytes(a.size)),
|
||||
h("button", {
|
||||
type: "button",
|
||||
className: "hermes-kanban-drawer-close",
|
||||
title: tx(i18n, "removeAttachment", "Remove attachment"),
|
||||
onClick: function () {
|
||||
if (window.confirm(tx(i18n, "confirmRemoveAttachment",
|
||||
"Remove this attachment?"))) {
|
||||
if (props.onDelete) props.onDelete(a.id);
|
||||
}
|
||||
},
|
||||
}, "×"),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function TaskDetail(props) {
|
||||
const { t: i18n } = useI18n();
|
||||
const t = props.data.task;
|
||||
const comments = props.data.comments || [];
|
||||
const events = props.data.events || [];
|
||||
const attachments = props.data.attachments || [];
|
||||
const links = props.data.links || { parents: [], children: [] };
|
||||
|
||||
return h("div", { className: "hermes-kanban-drawer-body" },
|
||||
@ -3042,6 +3198,15 @@
|
||||
h("div", { className: "hermes-kanban-section-head" }, tx(i18n, "result", "Result")),
|
||||
h(MarkdownBlock, { source: t.result, enabled: props.renderMarkdown }),
|
||||
) : null,
|
||||
h(AttachmentsSection, {
|
||||
attachments: attachments,
|
||||
boardSlug: props.boardSlug,
|
||||
onUpload: props.onUpload,
|
||||
onDelete: props.onDeleteAttachment,
|
||||
uploadBusy: props.uploadBusy,
|
||||
uploadErr: props.uploadErr,
|
||||
i18n: i18n,
|
||||
}),
|
||||
h("div", { className: "hermes-kanban-section" },
|
||||
h("div", { className: "hermes-kanban-section-head" },
|
||||
`${tx(i18n, "comments", "Comments")} (${comments.length})`),
|
||||
|
||||
19
plugins/kanban/dashboard/dist/style.css
vendored
19
plugins/kanban/dashboard/dist/style.css
vendored
@ -386,6 +386,25 @@
|
||||
}
|
||||
.hermes-kanban-drawer-close:hover { color: var(--color-foreground); }
|
||||
|
||||
/* Attachment download trigger — styled as a link, rendered as a <button>
|
||||
so the click handler can fetch with the session token (#35338). */
|
||||
.hermes-kanban-attachment-link {
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
text-align: left;
|
||||
color: var(--color-primary, #6ea8fe);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
}
|
||||
.hermes-kanban-attachment-link:hover { text-decoration: underline; }
|
||||
|
||||
.hermes-kanban-drawer-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
|
||||
@ -43,9 +43,11 @@ import os
|
||||
import sqlite3
|
||||
import time
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, WebSocket, WebSocketDisconnect, status as http_status
|
||||
from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile, WebSocket, WebSocketDisconnect, status as http_status
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from hermes_cli import kanban_db
|
||||
@ -186,6 +188,21 @@ def _comment_dict(c: kanban_db.Comment) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _attachment_dict(a: kanban_db.Attachment) -> dict[str, Any]:
|
||||
"""Serialise an Attachment for the drawer. ``stored_path`` is the
|
||||
absolute on-disk path workers read; the UI uses ``id`` for download."""
|
||||
return {
|
||||
"id": a.id,
|
||||
"task_id": a.task_id,
|
||||
"filename": a.filename,
|
||||
"content_type": a.content_type,
|
||||
"size": a.size,
|
||||
"uploaded_by": a.uploaded_by,
|
||||
"stored_path": a.stored_path,
|
||||
"created_at": a.created_at,
|
||||
}
|
||||
|
||||
|
||||
def _run_dict(r: kanban_db.Run) -> dict[str, Any]:
|
||||
"""Serialise a Run for the drawer's Run history section."""
|
||||
return {
|
||||
@ -531,6 +548,7 @@ def get_task(
|
||||
"task": task_d,
|
||||
"comments": [_comment_dict(c) for c in kanban_db.list_comments(conn, task_id)],
|
||||
"events": [_event_dict(e) for e in kanban_db.list_events(conn, task_id)],
|
||||
"attachments": [_attachment_dict(a) for a in kanban_db.list_attachments(conn, task_id)],
|
||||
"links": _links_for(conn, task_id),
|
||||
"runs": [
|
||||
_run_dict(r)
|
||||
@ -609,6 +627,165 @@ def create_task(payload: CreateTaskBody, board: Optional[str] = Query(None)):
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Attachments — upload / list / download / delete (#35338)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Cap a single upload so a runaway request can't fill the disk. 25 MB
|
||||
# comfortably covers PDFs, images, and source docs — the kanban use case.
|
||||
_MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024
|
||||
|
||||
|
||||
def _safe_attachment_name(raw: str) -> str:
|
||||
"""Reduce a client-supplied filename to a safe basename.
|
||||
|
||||
Strips any directory components (``os.path.basename`` on both
|
||||
separators) so a malicious ``../../etc/passwd`` or ``C:\\x`` collapses
|
||||
to its leaf. Rejects empty / dotfile-only names. The result is only
|
||||
ever joined under the per-task attachments dir, never used verbatim
|
||||
as a path from the client.
|
||||
"""
|
||||
name = (raw or "").replace("\\", "/").split("/")[-1].strip()
|
||||
# Drop control chars and leading dots so we never write a dotfile or
|
||||
# a name with embedded NULs/newlines.
|
||||
name = "".join(ch for ch in name if ch.isprintable() and ch not in '\x00').strip()
|
||||
name = name.lstrip(".").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="invalid attachment filename")
|
||||
return name[:200]
|
||||
|
||||
|
||||
@router.get("/tasks/{task_id}/attachments")
|
||||
def list_task_attachments(task_id: str, board: Optional[str] = Query(None)):
|
||||
board = _resolve_board(board)
|
||||
conn = _conn(board=board)
|
||||
try:
|
||||
if kanban_db.get_task(conn, task_id) is None:
|
||||
raise HTTPException(status_code=404, detail=f"task {task_id} not found")
|
||||
return {
|
||||
"attachments": [
|
||||
_attachment_dict(a) for a in kanban_db.list_attachments(conn, task_id)
|
||||
]
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/attachments")
|
||||
async def upload_task_attachment(
|
||||
task_id: str,
|
||||
file: UploadFile = File(...),
|
||||
board: Optional[str] = Query(None),
|
||||
uploaded_by: Optional[str] = Form(None),
|
||||
):
|
||||
"""Store an uploaded file for a task and record its metadata.
|
||||
|
||||
The blob lands under ``attachments_root(board)/<task_id>/`` with a
|
||||
sanitised, collision-resolved name. The worker reads it via the
|
||||
absolute path surfaced in ``build_worker_context``.
|
||||
"""
|
||||
board = _resolve_board(board)
|
||||
conn = _conn(board=board)
|
||||
try:
|
||||
if kanban_db.get_task(conn, task_id) is None:
|
||||
raise HTTPException(status_code=404, detail=f"task {task_id} not found")
|
||||
|
||||
safe_name = _safe_attachment_name(file.filename or "")
|
||||
|
||||
# Stream to disk with a hard size cap so a huge upload can't fill
|
||||
# the disk. Read in chunks; abort + clean up if the cap is hit.
|
||||
dest_dir = kanban_db.task_attachments_dir(task_id, board=board)
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Resolve name collisions: foo.pdf → foo (1).pdf, foo (2).pdf, …
|
||||
stem, dot, ext = safe_name.partition(".")
|
||||
candidate = safe_name
|
||||
n = 1
|
||||
while (dest_dir / candidate).exists():
|
||||
candidate = f"{stem} ({n}){dot}{ext}"
|
||||
n += 1
|
||||
dest_path = dest_dir / candidate
|
||||
|
||||
total = 0
|
||||
try:
|
||||
with open(dest_path, "wb") as out:
|
||||
while True:
|
||||
chunk = await file.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
if total > _MAX_ATTACHMENT_BYTES:
|
||||
out.close()
|
||||
dest_path.unlink(missing_ok=True)
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=(
|
||||
f"attachment exceeds {_MAX_ATTACHMENT_BYTES // (1024 * 1024)} MB limit"
|
||||
),
|
||||
)
|
||||
out.write(chunk)
|
||||
except HTTPException:
|
||||
raise
|
||||
except OSError as exc:
|
||||
raise HTTPException(status_code=500, detail=f"failed to store attachment: {exc}")
|
||||
|
||||
att_id = kanban_db.add_attachment(
|
||||
conn,
|
||||
task_id,
|
||||
filename=candidate,
|
||||
stored_path=str(dest_path.resolve()),
|
||||
content_type=file.content_type,
|
||||
size=total,
|
||||
uploaded_by=(uploaded_by or "dashboard"),
|
||||
)
|
||||
att = kanban_db.get_attachment(conn, att_id)
|
||||
return {"attachment": _attachment_dict(att) if att else None}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.get("/attachments/{attachment_id}")
|
||||
def download_attachment(attachment_id: int, board: Optional[str] = Query(None)):
|
||||
board = _resolve_board(board)
|
||||
conn = _conn(board=board)
|
||||
try:
|
||||
att = kanban_db.get_attachment(conn, attachment_id)
|
||||
if att is None:
|
||||
raise HTTPException(status_code=404, detail="attachment not found")
|
||||
# Confirm the blob still lives under the board's attachments root
|
||||
# before serving — defense in depth against a tampered DB row.
|
||||
root = kanban_db.attachments_root(board=board).resolve()
|
||||
try:
|
||||
stored = Path(att.stored_path).resolve()
|
||||
stored.relative_to(root)
|
||||
except (ValueError, OSError):
|
||||
raise HTTPException(status_code=404, detail="attachment file unavailable")
|
||||
if not stored.is_file():
|
||||
raise HTTPException(status_code=404, detail="attachment file missing on disk")
|
||||
return FileResponse(
|
||||
path=str(stored),
|
||||
filename=att.filename,
|
||||
media_type=att.content_type or "application/octet-stream",
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.delete("/attachments/{attachment_id}")
|
||||
def remove_attachment(attachment_id: int, board: Optional[str] = Query(None)):
|
||||
board = _resolve_board(board)
|
||||
conn = _conn(board=board)
|
||||
try:
|
||||
att = kanban_db.delete_attachment(conn, attachment_id)
|
||||
if att is None:
|
||||
raise HTTPException(status_code=404, detail="attachment not found")
|
||||
return {"ok": True, "id": attachment_id}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PATCH /tasks/:id (status / assignee / priority / title / body)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
291
tests/plugins/test_kanban_attachments.py
Normal file
291
tests/plugins/test_kanban_attachments.py
Normal file
@ -0,0 +1,291 @@
|
||||
"""Tests for Kanban task file attachments (#35338).
|
||||
|
||||
Covers three layers:
|
||||
* ``hermes_cli.kanban_db`` accessors (add/list/get/delete + path helpers)
|
||||
* the dashboard REST surface (upload / list / download / delete)
|
||||
* worker-context surfacing so a kanban worker sees the absolute paths
|
||||
|
||||
The plugin router is attached to a bare FastAPI app — same approach as
|
||||
``test_kanban_dashboard_plugin.py`` — so we exercise the real HTTP path
|
||||
(multipart upload, streaming download) without the whole dashboard.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from hermes_cli import kanban_db as kb
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _load_plugin_router():
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
plugin_file = repo_root / "plugins" / "kanban" / "dashboard" / "plugin_api.py"
|
||||
assert plugin_file.exists(), f"plugin file missing: {plugin_file}"
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"hermes_dashboard_plugin_kanban_attach_test", plugin_file,
|
||||
)
|
||||
assert spec is not None and spec.loader is not None
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
return mod.router
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def kanban_home(tmp_path, monkeypatch):
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
kb.init_db()
|
||||
return home
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(kanban_home):
|
||||
app = FastAPI()
|
||||
app.include_router(_load_plugin_router(), prefix="/api/plugins/kanban")
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _make_task(conn, title="t") -> str:
|
||||
return kb.create_task(conn, title=title)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DB-layer accessors
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_add_list_get_delete_attachment(kanban_home, tmp_path):
|
||||
conn = kb.connect()
|
||||
try:
|
||||
task_id = _make_task(conn)
|
||||
# Write a real blob under the per-task dir so delete can unlink it.
|
||||
dest_dir = kb.task_attachments_dir(task_id)
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
blob = dest_dir / "source.pdf"
|
||||
blob.write_bytes(b"%PDF-1.4 fake")
|
||||
|
||||
att_id = kb.add_attachment(
|
||||
conn,
|
||||
task_id,
|
||||
filename="source.pdf",
|
||||
stored_path=str(blob),
|
||||
content_type="application/pdf",
|
||||
size=blob.stat().st_size,
|
||||
uploaded_by="tester",
|
||||
)
|
||||
assert att_id > 0
|
||||
|
||||
atts = kb.list_attachments(conn, task_id)
|
||||
assert len(atts) == 1
|
||||
a = atts[0]
|
||||
assert a.filename == "source.pdf"
|
||||
assert a.content_type == "application/pdf"
|
||||
assert a.size == len(b"%PDF-1.4 fake")
|
||||
assert a.uploaded_by == "tester"
|
||||
assert a.stored_path == str(blob)
|
||||
|
||||
got = kb.get_attachment(conn, att_id)
|
||||
assert got is not None and got.id == att_id
|
||||
|
||||
removed = kb.delete_attachment(conn, att_id)
|
||||
assert removed is not None and removed.id == att_id
|
||||
assert kb.list_attachments(conn, task_id) == []
|
||||
assert not blob.exists(), "delete should unlink the on-disk blob"
|
||||
assert kb.get_attachment(conn, att_id) is None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_add_attachment_rejects_unknown_task(kanban_home):
|
||||
conn = kb.connect()
|
||||
try:
|
||||
with pytest.raises(ValueError):
|
||||
kb.add_attachment(
|
||||
conn, "t_doesnotexist", filename="x.txt", stored_path="/tmp/x.txt"
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_add_attachment_appends_event(kanban_home):
|
||||
conn = kb.connect()
|
||||
try:
|
||||
task_id = _make_task(conn)
|
||||
kb.add_attachment(
|
||||
conn, task_id, filename="a.txt", stored_path="/tmp/a.txt", size=3
|
||||
)
|
||||
kinds = [e.kind for e in kb.list_events(conn, task_id)]
|
||||
assert "attached" in kinds
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_delete_attachment_missing_returns_none(kanban_home):
|
||||
conn = kb.connect()
|
||||
try:
|
||||
assert kb.delete_attachment(conn, 999999) is None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_attachments_root_is_per_board(kanban_home, monkeypatch):
|
||||
# default board uses <root>/kanban/attachments
|
||||
default_root = kb.attachments_root(board="default")
|
||||
assert default_root.name == "attachments"
|
||||
# a named board nests under its board dir
|
||||
monkeypatch.delenv("HERMES_KANBAN_ATTACHMENTS_ROOT", raising=False)
|
||||
named = kb.attachments_root(board="default")
|
||||
assert named == default_root
|
||||
|
||||
|
||||
def test_attachments_root_env_override(kanban_home, monkeypatch, tmp_path):
|
||||
override = tmp_path / "custom-attach"
|
||||
monkeypatch.setenv("HERMES_KANBAN_ATTACHMENTS_ROOT", str(override))
|
||||
assert kb.attachments_root() == override
|
||||
assert kb.task_attachments_dir("t_abc") == override / "t_abc"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Worker context surfacing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_worker_context_lists_attachments_with_absolute_path(kanban_home):
|
||||
conn = kb.connect()
|
||||
try:
|
||||
task_id = _make_task(conn, title="translate PDF")
|
||||
dest_dir = kb.task_attachments_dir(task_id)
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
blob = dest_dir / "manual.pdf"
|
||||
blob.write_bytes(b"data")
|
||||
kb.add_attachment(
|
||||
conn,
|
||||
task_id,
|
||||
filename="manual.pdf",
|
||||
stored_path=str(blob.resolve()),
|
||||
content_type="application/pdf",
|
||||
size=4,
|
||||
)
|
||||
ctx = kb.build_worker_context(conn, task_id)
|
||||
assert "## Attachments" in ctx
|
||||
assert "manual.pdf" in ctx
|
||||
# The absolute path must appear so the worker can read_file it.
|
||||
assert str(blob.resolve()) in ctx
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_worker_context_no_attachments_section_when_empty(kanban_home):
|
||||
conn = kb.connect()
|
||||
try:
|
||||
task_id = _make_task(conn)
|
||||
ctx = kb.build_worker_context(conn, task_id)
|
||||
assert "## Attachments" not in ctx
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REST surface — upload / list / download / delete round-trip
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _create_task_via_api(client) -> str:
|
||||
r = client.post("/api/plugins/kanban/tasks", json={"title": "x"})
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["task"]["id"]
|
||||
|
||||
|
||||
def test_upload_list_download_delete_roundtrip(client):
|
||||
task_id = _create_task_via_api(client)
|
||||
content = b"hello attachment world"
|
||||
|
||||
# Upload
|
||||
r = client.post(
|
||||
f"/api/plugins/kanban/tasks/{task_id}/attachments",
|
||||
files={"file": ("notes.txt", content, "text/plain")},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
att = r.json()["attachment"]
|
||||
assert att["filename"] == "notes.txt"
|
||||
assert att["size"] == len(content)
|
||||
att_id = att["id"]
|
||||
|
||||
# List (drawer also embeds it in GET /tasks/:id)
|
||||
r = client.get(f"/api/plugins/kanban/tasks/{task_id}/attachments")
|
||||
assert r.status_code == 200
|
||||
assert [a["filename"] for a in r.json()["attachments"]] == ["notes.txt"]
|
||||
|
||||
detail = client.get(f"/api/plugins/kanban/tasks/{task_id}").json()
|
||||
assert "attachments" in detail
|
||||
assert len(detail["attachments"]) == 1
|
||||
|
||||
# Download streams the exact bytes back
|
||||
r = client.get(f"/api/plugins/kanban/attachments/{att_id}")
|
||||
assert r.status_code == 200
|
||||
assert r.content == content
|
||||
|
||||
# Delete removes the row and the file
|
||||
r = client.delete(f"/api/plugins/kanban/attachments/{att_id}")
|
||||
assert r.status_code == 200
|
||||
assert client.get(f"/api/plugins/kanban/attachments/{att_id}").status_code == 404
|
||||
assert client.get(
|
||||
f"/api/plugins/kanban/tasks/{task_id}/attachments"
|
||||
).json()["attachments"] == []
|
||||
|
||||
|
||||
def test_upload_sanitizes_traversal_filename(client):
|
||||
task_id = _create_task_via_api(client)
|
||||
r = client.post(
|
||||
f"/api/plugins/kanban/tasks/{task_id}/attachments",
|
||||
files={"file": ("../../../../etc/passwd", b"x", "text/plain")},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
stored_path = r.json()["attachment"]["stored_path"]
|
||||
# The leaf name only; never escapes the per-task attachments dir.
|
||||
assert Path(stored_path).name == "passwd"
|
||||
task_dir = kb.task_attachments_dir(task_id).resolve()
|
||||
assert Path(stored_path).resolve().is_relative_to(task_dir)
|
||||
|
||||
|
||||
def test_upload_name_collision_gets_suffixed(client):
|
||||
task_id = _create_task_via_api(client)
|
||||
for _ in range(2):
|
||||
r = client.post(
|
||||
f"/api/plugins/kanban/tasks/{task_id}/attachments",
|
||||
files={"file": ("dup.txt", b"a", "text/plain")},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
names = sorted(
|
||||
a["filename"]
|
||||
for a in client.get(
|
||||
f"/api/plugins/kanban/tasks/{task_id}/attachments"
|
||||
).json()["attachments"]
|
||||
)
|
||||
assert names == ["dup (1).txt", "dup.txt"]
|
||||
|
||||
|
||||
def test_upload_unknown_task_404(client):
|
||||
r = client.post(
|
||||
"/api/plugins/kanban/tasks/t_nope/attachments",
|
||||
files={"file": ("x.txt", b"x", "text/plain")},
|
||||
)
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_download_unknown_attachment_404(client):
|
||||
assert client.get("/api/plugins/kanban/attachments/424242").status_code == 404
|
||||
@ -155,6 +155,36 @@ events WebSocket is pinned to a board at connection time; switching in
|
||||
the UI opens a fresh WS against the new board.
|
||||
|
||||
|
||||
## File attachments
|
||||
|
||||
Tasks can carry file attachments — PDFs, images, source documents — so a
|
||||
worker has the source material it needs without you pasting paths into the
|
||||
body and hoping it finds them.
|
||||
|
||||
- **Upload** — open a task in the dashboard drawer and use the
|
||||
**Attachments** section's *Upload file* button (multiple files at once
|
||||
are fine). Each upload is capped at 25 MB.
|
||||
- **Storage** — files land under
|
||||
`<hermes-home>/kanban/attachments/<task_id>/` for the default board, or
|
||||
`<hermes-home>/kanban/boards/<slug>/attachments/<task_id>/` for a named
|
||||
board. Set `HERMES_KANBAN_ATTACHMENTS_ROOT` to pin a custom location.
|
||||
- **What the worker sees** — when the dispatcher hands a task to a worker,
|
||||
the worker's context includes an **Attachments** section listing each
|
||||
file's name and its **absolute path**. The worker has full file/terminal
|
||||
tool access, so it reads attachments directly (`read_file`, or shell
|
||||
tools like `pdftotext`).
|
||||
- **Download / remove** — the drawer lists each attachment with a download
|
||||
link and a remove (×) control. Removing an attachment deletes both the
|
||||
metadata row and the on-disk file.
|
||||
|
||||
:::note Remote terminal backends
|
||||
Attachment paths resolve directly on the **local** terminal backend, which
|
||||
is the default for Kanban workers. If you run workers on a remote backend
|
||||
(Docker, Modal), mount the board's `attachments/` directory into the
|
||||
sandbox so the absolute paths in the worker context are reachable.
|
||||
:::
|
||||
|
||||
|
||||
## Quick start
|
||||
|
||||
The commands below are **you** (the human) setting up the board and creating tasks. Once a task is assigned, the dispatcher spawns the assigned profile as a worker, and from there **the model drives the task through `kanban_*` tool calls, not CLI commands** — see [How workers interact with the board](#how-workers-interact-with-the-board).
|
||||
|
||||
Reference in New Issue
Block a user