diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index 17fe7476d..471165524 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -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 ``/.../attachments//`` subdirectory. + + ``HERMES_KANBAN_ATTACHMENTS_ROOT`` pins the path directly (highest + precedence) for tests and unusual deployments. + + ``default`` uses ``/kanban/attachments/``; other boards use + ``/kanban/boards//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 ``//``.""" + 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)//``; +-- 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 diff --git a/plugins/kanban/dashboard/dist/index.js b/plugins/kanban/dashboard/dist/index.js index 9a04b6a64..c22c06c12 100644 --- a/plugins/kanban/dashboard/dist/index.js +++ b/plugins/kanban/dashboard/dist/index.js @@ -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 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})`), diff --git a/plugins/kanban/dashboard/dist/style.css b/plugins/kanban/dashboard/dist/style.css index 841890c51..6b396b261 100644 --- a/plugins/kanban/dashboard/dist/style.css +++ b/plugins/kanban/dashboard/dist/style.css @@ -386,6 +386,25 @@ } .hermes-kanban-drawer-close:hover { color: var(--color-foreground); } +/* Attachment download trigger — styled as a link, rendered as a