From ec413e3421acf640d9778a993ca0c213e35a4317 Mon Sep 17 00:00:00 2001 From: ceki-plugin Date: Wed, 26 Aug 2026 18:12:54 +0000 Subject: [PATCH] feat(sdk): attach files to contract create/comment/propose/edit/progress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ContractClient.upload_file(): base64 upload via MCP upload-file, returns the user_files record {id, name, url, size, disk} - create()/comment()/propose()/progress() accept files[] — int ids pass through, str/Path are uploaded first (_resolve_files) - CLI: repeatable --file on create/comment/propose/edit/progress; new `ceki contract upload-file` subcommand - README: document --file / upload-file --- README.md | 17 +++- ceki_sdk/cli.py | 69 +++++++++++++++++ ceki_sdk/contract.py | 101 +++++++++++++++++++++++- tests/test_contract.py | 171 ++++++++++++++++++++++++++++++++++++++++- 4 files changed, 351 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 26f6ee4..65e8d78 100644 --- a/README.md +++ b/README.md @@ -370,11 +370,16 @@ ceki contract history # audit history ceki contract create --label "X" [--status N] [--type N] \ [--kal-schedule N] [--start ..] [--end ..] [--date ..] \ [--duration N] [--amount N] [--currency USD] \ - [--benefitable agent:8|user:61] [--desc ".."] + [--benefitable agent:8|user:61] [--desc ".."] [--file PATH]... ceki contract comment --label ".." [--status N] [--duration N] \ - [--amount N] [--currency USD] [--benefitable agent:8] [--desc ".."] + [--amount N] [--currency USD] [--benefitable agent:8] [--desc ".."] \ + [--file PATH]... ceki contract propose [--status N] [--label ..] [--desc ..] \ - [--duration N] [--amount N] [--currency USD] [--benefitable agent:8] + [--duration N] [--amount N] [--currency USD] [--benefitable agent:8] \ + [--file PATH]... +ceki contract edit [--status N] [--label ..] [--desc ..] [--file PATH]... +ceki contract progress [--status N] --desc ".." [--file PATH]... +ceki contract upload-file PATH [--filename NAME] [--mime TYPE] ceki contract vote --ids 1,2 --vote true|false ceki contract poll # single tick (returns [] on 429) ceki contract watch [sec] # continuous (min 6s, 10/min/token) @@ -382,6 +387,12 @@ ceki contract tools # list available MCP tools ceki contract raw '' # call any tool directly ``` +`--file PATH` (repeatable) attaches a file to the task / comment / correction: +the SDK uploads it to the backend (MCP `upload-file`) and attaches the returned +`user_files.id` to the event. `ceki contract upload-file PATH` uploads a single +file and prints its `{id, url}` record. Files also render in markdown as +`![](url)` — the `url` appears in the event payload under `files[].url`. + #### Environment | Variable | Meaning | diff --git a/ceki_sdk/cli.py b/ceki_sdk/cli.py index 8bed04a..4bb9d12 100644 --- a/ceki_sdk/cli.py +++ b/ceki_sdk/cli.py @@ -993,6 +993,7 @@ def _cmd_contract(args: argparse.Namespace) -> int: qa=args.qa, participants=extra_parts or None, tags=tags, + files=args.file or None, )) elif action == "comment": # `--label` → label (short header), `--desc` → description @@ -1023,6 +1024,7 @@ def _cmd_contract(args: argparse.Namespace) -> int: amount=args.amount, currency=args.currency, benefitable=args.benefitable, + files=args.file or None, )) elif action == "propose": tags = _parse_tags(args.tags) if getattr(args, "tags", None) else None @@ -1041,6 +1043,7 @@ def _cmd_contract(args: argparse.Namespace) -> int: amount=args.amount, currency=args.currency, benefitable=args.benefitable, + files=args.file or None, settings=settings, )) elif action == "edit": @@ -1060,6 +1063,7 @@ def _cmd_contract(args: argparse.Namespace) -> int: amount=args.amount, currency=args.currency, benefitable=args.benefitable, + files=args.file or None, settings=settings, )) elif action == "progress": @@ -1067,6 +1071,13 @@ def _cmd_contract(args: argparse.Namespace) -> int: args.eid, status=args.status, desc=args.desc, + files=args.file or None, + )) + elif action == "upload-file": + _contract_dump(cli.upload_file( + args.path, + filename=args.filename, + mime=args.mime, )) elif action == "vote": ids = [int(s) for s in str(args.ids).split(",") if s.strip()] @@ -1412,6 +1423,16 @@ def build_parser() -> argparse.ArgumentParser: "'backend:Backend:#ff0000'." ), ) + p_cc.add_argument( + "--file", + action="append", + default=[], + dest="file", + help=( + "Repeatable. Local file path to attach to the task " + "(uploaded to the backend first)." + ), + ) p_cco = csub.add_parser("comment", help="Post comment on event") p_cco.add_argument("eid", type=int) @@ -1426,6 +1447,16 @@ def build_parser() -> argparse.ArgumentParser: p_cco.add_argument("--currency") p_cco.add_argument("--benefitable") p_cco.add_argument("--desc") + p_cco.add_argument( + "--file", + action="append", + default=[], + dest="file", + help=( + "Repeatable. Local file path to attach to the comment " + "(uploaded to the backend first)." + ), + ) p_cp = csub.add_parser("propose", help="Propose correction") p_cp.add_argument("eid", type=int) @@ -1447,6 +1478,16 @@ def build_parser() -> argparse.ArgumentParser: "'backend:Backend:#ff0000'. back/2796 persists onto the event." ), ) + p_cp.add_argument( + "--file", + action="append", + default=[], + dest="file", + help=( + "Repeatable. Local file path to attach in the correction " + "(uploaded to the backend first)." + ), + ) p_edit = csub.add_parser("edit", help="Edit task (semantic sugar for propose)") p_edit.add_argument("eid", type=int) @@ -1468,6 +1509,16 @@ def build_parser() -> argparse.ArgumentParser: "'backend:Backend:#ff0000'." ), ) + p_edit.add_argument( + "--file", + action="append", + default=[], + dest="file", + help=( + "Repeatable. Local file path to attach in the edit/correction " + "(uploaded to the backend first)." + ), + ) p_cpr = csub.add_parser( "progress", @@ -1476,12 +1527,30 @@ def build_parser() -> argparse.ArgumentParser: p_cpr.add_argument("eid", type=int) p_cpr.add_argument("--status", type=int) p_cpr.add_argument("--desc", required=True) + p_cpr.add_argument( + "--file", + action="append", + default=[], + dest="file", + help=( + "Repeatable. Local file path to attach to the progress comment " + "(uploaded to the backend first)." + ), + ) p_cv = csub.add_parser("vote", help="Vote on correction(s)") p_cv.add_argument("eid", type=int) p_cv.add_argument("--ids", required=True, help="Comma-separated correction IDs") p_cv.add_argument("--vote", required=True, help="true|false") + p_cuf = csub.add_parser( + "upload-file", + help="Upload a file, print the user_files record (id/url)", + ) + p_cuf.add_argument("path", help="Local file path to upload") + p_cuf.add_argument("--filename", help="Override filename (default: basename)") + p_cuf.add_argument("--mime", help="Override MIME type (default: guessed)") + csub.add_parser("poll", help="Single agent polling tick") p_cw = csub.add_parser("watch", help="Continuous polling") diff --git a/ceki_sdk/contract.py b/ceki_sdk/contract.py index a4cfe24..f76d60f 100644 --- a/ceki_sdk/contract.py +++ b/ceki_sdk/contract.py @@ -2,9 +2,12 @@ from __future__ import annotations +import base64 import json +import mimetypes import os import time +from pathlib import Path from typing import Any import httpx @@ -424,6 +427,7 @@ def create( qa: str | None = None, participants: list[dict[str, Any]] | None = None, tags: list[dict[str, Any]] | None = None, + files: list[int | str | Path] | None = None, ) -> Any: # back/2542: reviewer/qa now live inside users[] (renamed from # participants[]). Element shape unchanged. The `participants` @@ -439,6 +443,7 @@ def create( if participants: users.extend(participants) + files_resolved = self._resolve_files(files) args = _clean({ "contract_id": int(contract_id), "label": label, @@ -456,6 +461,7 @@ def create( "data": data, "benefitable": _benefitable(benefitable), "users": users if users else None, + "files": files_resolved, # back/3165: project tags live in events.settings.tags[]. `tags` # is CLI/SDK sugar — a bare list of {key,label?,color?} dicts — # emitted on the wire under the `settings` blob the backend expects. @@ -478,6 +484,7 @@ def comment( amount: int | None = None, currency: str | None = None, benefitable: str | None = None, + files: list[int | str | Path] | None = None, ) -> Any: """Post a comment event. @@ -487,8 +494,12 @@ def comment( at a word boundary – the first part goes to `label`, the remainder to `description`. This matches backend API behavior and keeps the UI clean without visible duplication. + + `files` attaches user_files to the comment: pass user_files ids + (int) or local paths (str/Path) which are uploaded first. """ label_out, desc_out = _split_label_desc(label, description) + files_resolved = self._resolve_files(files) args = _clean({ "event_id": int(event_id), "label": label_out, @@ -502,6 +513,7 @@ def comment( "amount": amount, "currency": currency, "benefitable": _benefitable(benefitable), + "files": files_resolved, }) return self.call(_TOOL_MAP["comment"], args) @@ -519,9 +531,11 @@ def propose( amount: int | None = None, currency: str | None = None, benefitable: str | None = None, + files: list[int | str | Path] | None = None, settings: dict[str, Any] | None = None, ) -> Any: label_out, desc_out = _split_label_desc(label, description) + files_resolved = self._resolve_files(files) args = _clean({ "event_id": int(event_id), "status_id": status_id, @@ -534,6 +548,7 @@ def propose( "amount": amount, "currency": currency, "benefitable": _benefitable(benefitable), + "files": files_resolved, # back/2796: ProposeCorrectionTool persists settings (tags, # reply_to, blocked_by, do_after) onto the event. Forwarded # verbatim — only attached when the caller supplies it. @@ -547,6 +562,7 @@ def progress( *, status: int | None = None, desc: str, + files: list[int | str | Path] | None = None, ) -> dict[str, Any]: """Status correction (optional) + progress comment in one shot. @@ -554,6 +570,9 @@ def progress( body of a child comment-event, not a label/description overwrite on the parent event. Use this for Hand/QA/Reviewer progress reports — `propose --desc` would clobber the parent spec. + + `files` attaches user_files to the progress comment (ids or local + paths — paths are uploaded first). """ status_result: Any = None if status is not None: @@ -567,7 +586,7 @@ def progress( # `description` is never set on a comment (the UI renders both, # which would duplicate the body for SDK-posted comments). label = desc if (desc or "").strip() else "progress" - comment_result = self.comment(event_id, label=label) + comment_result = self.comment(event_id, label=label, files=files) return {"status_correction": status_result, "comment": comment_result} def vote(self, event_id: int, ids: list[int], vote: bool) -> Any: @@ -577,6 +596,86 @@ def vote(self, event_id: int, ids: list[int], vote: bool) -> Any: "vote": bool(vote), }) + # ── files ──────────────────────────────────────────────────── + + def upload_file( + self, + file: bytes | str | Path, + *, + filename: str | None = None, + mime: str | None = None, + ) -> dict[str, Any]: + """Upload a file to the backend, returning its ``user_files`` record. + + Wraps the MCP ``upload-file`` tool (base64 over JSON-RPC). The + returned ``id`` is what create()/comment()/propose() expect in + their ``files`` param (backend: ``user_files.id``). + + Args: + file: Local path OR raw bytes. Paths are read from disk. + filename: Original filename with extension (defaults to the + basename of a path, or a mime-derived name for raw bytes). + mime: MIME type (e.g. image/png, application/pdf). Auto-guessed + from the filename extension when omitted; the backend sniffs + magic bytes as a final fallback. + + Returns: + Dict shaped ``{"id", "name", "url", "size", "disk"}``. + """ + if isinstance(file, (str, Path)): + path = Path(file) + data = path.read_bytes() + if filename is None: + filename = path.name + else: + data = bytes(file) + if filename is None: + filename = self._default_filename(mime) + + if not filename: + raise ValueError("upload_file: could not derive a filename") + + if mime is None: + guessed, _ = mimetypes.guess_type(filename) + mime = guessed + + b64 = base64.b64encode(data).decode() + result = self.call("upload-file", { + "file_data": b64, + "file_name": filename, + "mime_type": mime, + }) + if not isinstance(result, dict) or not result.get("id"): + raise ContractError(f"upload-file returned no id: {result!r}") + return result + + def _resolve_files( + self, files: list[int | str | Path] | None + ) -> list[int] | None: + """Map a files[] arg to user_files ids. + + Ints pass through (already-uploaded ids); str/Path are uploaded + via upload_file() first and replaced with the returned id. + """ + if not files: + return None + ids: list[int] = [] + for f in files: + if isinstance(f, int): + ids.append(int(f)) + continue + up = self.upload_file(f) + fid = up.get("id") + if not fid: + raise ContractError(f"upload-file returned no id for {f!r}") + ids.append(int(fid)) + return ids + + @staticmethod + def _default_filename(mime: str | None) -> str: + ext = (mimetypes.guess_extension(mime or "") or ".bin").lstrip(".") + return f"file.{ext or 'bin'}" + # ── polling (REST, not MCP) ─────────────────────────────────── def poll(self) -> list[Any]: diff --git a/tests/test_contract.py b/tests/test_contract.py index d03a625..1b739cf 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -280,6 +280,109 @@ def test_vote_payload_shape(): assert body["params"]["arguments"] == {"event_id": 7, "ids": [1, 2], "vote": True} +# ── files / upload ─────────────────────────────────────────────── + + +def test_upload_file_base64_and_defaults(): + http, _ = _http_mock(_mcp_text({"id": 42, "name": "report.pdf", "url": "//x/f", "size": 7, "disk": "upload"})) + c = ContractClient(client=http, endpoint="http://x/mcp/agent", token="t") + res = c.upload_file(b"content", filename="report.pdf") + body = _captured_body(http) + assert body["params"]["name"] == "upload-file" + assert body["params"]["arguments"] == { + "file_data": "Y29udGVudA==", + "file_name": "report.pdf", + "mime_type": "application/pdf", + } + assert res["id"] == 42 + + +def test_upload_file_path_reads_disk_and_mime(): + import base64 as _b64 + import tempfile + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp: + tmp.write(b"\x89PNG\r\n\x1a\nx") + p = tmp.name + try: + http, _ = _http_mock(_mcp_text({"id": 7, "url": "//x/i"})) + c = ContractClient(client=http, endpoint="http://x/mcp/agent", token="t") + c.upload_file(p) + args = _captured_body(http)["params"]["arguments"] + assert args["file_name"].endswith(".png") + assert args["file_data"] == _b64.b64encode(b"\x89PNG\r\n\x1a\nx").decode() + finally: + import os + os.unlink(p) + + +def test_upload_file_no_id_raises(): + http, _ = _http_mock(_mcp_text({"url": "//x"})) + c = ContractClient(client=http, endpoint="http://x/mcp/agent", token="t") + try: + c.upload_file(b"x", filename="a.bin") + except ContractError as e: + assert "no id" in str(e) + else: + raise AssertionError("expected ContractError") + + +def test_create_files_ints_forwarded(): + http, _ = _http_mock(_mcp_text({"id": 1})) + c = ContractClient(client=http, endpoint="http://x/mcp/agent", token="t") + c.create(14, label="hello", files=[11, 22]) + args = _captured_body(http)["params"]["arguments"] + assert args["files"] == [11, 22] + + +def test_create_files_paths_uploaded_then_attached(): + """Local paths are uploaded via upload-file, then attached as ids.""" + import base64 as _b64 + import tempfile + with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as tmp: + tmp.write(b"hi") + p = tmp.name + try: + # First call → upload-file returns id=500; second call → create. + http, resp = _http_mock(_mcp_text({"id": 500, "url": "//x/f"})) + c = ContractClient(client=http, endpoint="http://x/mcp/agent", token="t") + c.create(14, label="hello", files=[p]) + calls = http.post.call_args_list + assert len(calls) == 2 + first = calls[0].kwargs["json"]["params"] + assert first["name"] == "upload-file" + assert first["arguments"]["file_data"] == _b64.b64encode(b"hi").decode() + second = calls[1].kwargs["json"]["params"] + assert second["name"] == "create-contract-event" + assert second["arguments"]["files"] == [500] + finally: + import os + os.unlink(p) + + +def test_comment_files_forwarded(): + http, _ = _http_mock(_mcp_text({"id": 99})) + c = ContractClient(client=http, endpoint="http://x/mcp/agent", token="t") + c.comment(99, label="done", files=[3]) + args = _captured_body(http)["params"]["arguments"] + assert args["files"] == [3] + + +def test_propose_files_forwarded(): + http, _ = _http_mock(_mcp_text({"id": 7})) + c = ContractClient(client=http, endpoint="http://x/mcp/agent", token="t") + c.propose(7, status_id=200, label="L", files=[4, 5]) + args = _captured_body(http)["params"]["arguments"] + assert args["files"] == [4, 5] + + +def test_no_files_key_when_empty(): + http, _ = _http_mock(_mcp_text({"id": 1})) + c = ContractClient(client=http, endpoint="http://x/mcp/agent", token="t") + c.create(14, label="hello") + args = _captured_body(http)["params"]["arguments"] + assert "files" not in args + + def test_history_tool_name(): http, _ = _http_mock(_mcp_text([])) c = ContractClient(client=http, endpoint="http://x/mcp/agent", token="t") @@ -767,7 +870,7 @@ def fake_comment(self, event_id, **kw): assert calls[0][1] == (99,) assert calls[0][2] == {"status_id": 222} assert calls[1][1] == (99,) - assert calls[1][2] == {"label": "r"} + assert calls[1][2] == {"label": "r", "files": None} assert "description" not in calls[1][2] assert result == { "status_correction": {"applied": True, "id": 1}, @@ -795,7 +898,7 @@ def fake_comment(self, event_id, **kw): result = c.progress(99, desc="just an update") assert propose_calls == [] - assert comment_calls == [(99, {"label": "just an update"})] + assert comment_calls == [(99, {"label": "just an update", "files": None})] assert "description" not in comment_calls[0][1] assert result == {"status_correction": None, "comment": {"id": 7}} @@ -910,7 +1013,7 @@ def __enter__(self): def __exit__(self, *a): return False - def progress(self, eid, *, status, desc): + def progress(self, eid, *, status, desc, files=None): captured["eid"] = eid captured["status"] = status captured["desc"] = desc @@ -926,6 +1029,68 @@ def progress(self, eid, *, status, desc): assert captured == {"eid": 99, "status": 222, "desc": "x"} +def test_cli_dispatch_create_forwards_files(monkeypatch, capsys): + """`ceki contract create CID --label x --file a.png` passes files to create().""" + from ceki_sdk import cli as cli_module + + captured: dict = {} + + class FakeClient: + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def create(self, cid, **kw): + captured["cid"] = cid + captured["files"] = kw.get("files") + return {"id": 1} + + monkeypatch.setattr(cli_module, "_contract_client", lambda: FakeClient()) + + parser = cli_module.build_parser() + args = parser.parse_args( + ["contract", "create", "14", "--label", "x", "--file", "a.png", "--file", "b.pdf"] + ) + rc = cli_module._cmd_contract(args) + + assert rc == 0 + assert captured == {"cid": 14, "files": ["a.png", "b.pdf"]} + + +def test_cli_dispatch_upload_file(monkeypatch, capsys): + """`ceki contract upload-file PATH` calls client.upload_file and dumps the record.""" + from ceki_sdk import cli as cli_module + + captured: dict = {} + + class FakeClient: + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def upload_file(self, path, **kw): + captured["path"] = path + captured["kw"] = kw + return {"id": 9, "url": "//x/f", "name": "report.pdf"} + + monkeypatch.setattr(cli_module, "_contract_client", lambda: FakeClient()) + + parser = cli_module.build_parser() + args = parser.parse_args( + ["contract", "upload-file", "/tmp/report.pdf", "--filename", "r.pdf"] + ) + rc = cli_module._cmd_contract(args) + out = capsys.readouterr().out + + assert rc == 0 + assert captured == {"path": "/tmp/report.pdf", "kw": {"filename": "r.pdf", "mime": None}} + assert '"id": 9' in out + + # ── call-human (task 4019) ────────────────────────────────────────