diff --git a/coworker/server/app.py b/coworker/server/app.py index c2e60e159..06d339066 100644 --- a/coworker/server/app.py +++ b/coworker/server/app.py @@ -1112,6 +1112,26 @@ def board_journal_append(request: Request, body: dict): ), ) + @app.get("/v1/board/journal/export") + def board_journal_export( + request: Request, + case: str, + format: str = "markdown", + include_raw: str = "", + ): + return _board( + request, + lambda actor: { + "report": manager.journal_store.export( + actor, + case, + store=manager.team_store, + format=format, + include_raw=bool(include_raw), + ) + }, + ) + @app.get("/v1/memory") def memory() -> dict[str, Any]: return {"memory": manager.list_memory()} diff --git a/coworker/server/manager.py b/coworker/server/manager.py index cf9b740fa..d34298376 100644 --- a/coworker/server/manager.py +++ b/coworker/server/manager.py @@ -2027,7 +2027,7 @@ def _team_tools_for( actor=actor, attachments=self.attachment_store, ) + journal_tools( - self.journal_store, actor=actor, space=space + self.journal_store, actor=actor, space=space, store=self.team_store ) if role == "lead": tools.append(self._steer_tool(session_id)) diff --git a/coworker/teams/cli.py b/coworker/teams/cli.py index 98d730825..aa2dd8d78 100644 --- a/coworker/teams/cli.py +++ b/coworker/teams/cli.py @@ -24,7 +24,7 @@ import os import sys from pathlib import Path -from typing import Any, Optional +from typing import Optional from .model import BoardError, space_for_workspace from .store import CLAIM_POLICIES @@ -160,6 +160,20 @@ def cmd(name: str, func, help: str, parent=board_sub): p.add_argument("--entity", action="append", default=[], dest="entities") p.add_argument("--ref", action="append", default=[], dest="refs") + p = cmd( + "export", + _cmd_export, + "export a case and linked items into a report", + parent=journal_sub, + ) + p.add_argument("case", nargs="?", default="") + p.add_argument("--case", dest="case_opt", default="", help="case identifier") + p.add_argument( + "--format", choices=("markdown", "json"), default="markdown" + ) + p.add_argument("-o", "--out", default="", help="output path (default: stdout)") + p.add_argument("--raw", action="store_true", dest="include_raw") + return parser @@ -500,5 +514,27 @@ def _cmd_append(args) -> int: return 0 +def _cmd_export(args) -> int: + case = args.case or args.case_opt + if not case: + raise BoardError( + "case is required (pass case as argument or --case )" + ) + fmt = "json" if args.json else args.format + report = _dialect(args).journal_export( + case, + format=fmt, + include_raw=args.include_raw, + ) + if args.out: + out_path = Path(args.out).expanduser() + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(report, encoding="utf-8") + print(f"wrote {out_path}") + else: + print(report) + return 0 + + if __name__ == "__main__": sys.exit(main()) diff --git a/coworker/teams/dialect.py b/coworker/teams/dialect.py index df66ac6e6..1a5492509 100644 --- a/coworker/teams/dialect.py +++ b/coworker/teams/dialect.py @@ -117,6 +117,9 @@ def journal_read( limit: int = 100, ) -> list[dict[str, Any]]: ... def journal_overview(self) -> list[dict[str, Any]]: ... + def journal_export( + self, case: str, *, format: str = "markdown", include_raw: bool = False + ) -> str: ... class LocalDialect: @@ -296,6 +299,18 @@ def journal_overview(self) -> list[dict[str, Any]]: self._need_journal() return self.journal.overview(self.actor) + def journal_export( + self, case: str, *, format: str = "markdown", include_raw: bool = False + ) -> str: + self._need_journal() + return self.journal.export( + self.actor, + case, + store=self.store, + format=format, + include_raw=include_raw, + ) + def _need_journal(self) -> None: if self.journal is None: raise BoardError("no journal store is attached to this board") @@ -536,6 +551,18 @@ def journal_read( def journal_overview(self) -> list[dict[str, Any]]: return self._get("/v1/board/journal/cases")["cases"] + def journal_export( + self, case: str, *, format: str = "markdown", include_raw: bool = False + ) -> str: + return self._get( + "/v1/board/journal/export", + { + "case": case, + "format": format, + "include_raw": "1" if include_raw else None, + }, + )["report"] + def close(self) -> None: self._client.close() diff --git a/coworker/teams/journal.py b/coworker/teams/journal.py index 1cdc180d8..ff03e3fb1 100644 --- a/coworker/teams/journal.py +++ b/coworker/teams/journal.py @@ -279,6 +279,69 @@ def cases(self, actor: Actor) -> list[str]: ).fetchall() return [row["case_id"] for row in rows] + def export( + self, + actor: Actor, + case: str, + *, + store: Optional[Any] = None, + format: str = "markdown", + include_raw: bool = False, + ) -> str: + """Compile a journal case and linked items into a standalone report.""" + with self._lock: + if not self._case_exists(case): + raise BoardError(f"no case '{case}'") + self._check_access(actor, case) + meta_row = self._conn.execute( + "SELECT * FROM journal_meta WHERE case_id = ?", (case,) + ).fetchone() + meta = dict(meta_row) if meta_row else {} + + entries: list[dict[str, Any]] = [] + since_seq = 0 + while True: + page = self.read( + actor, case, include_raw=include_raw, since_seq=since_seq, limit=1000 + ) + entries.extend(page) + if len(page) < 1000: + break + since_seq = page[-1]["seq"] + items: list[dict[str, Any]] = [] + if store is not None: + spaces = {e.get("space") for e in entries if e.get("space")} + if hasattr(store, "spaces"): + try: + spaces.update(store.spaces()) + except Exception: + pass + for space in spaces: + try: + space_items = store.list_items(space, actor) + items.extend( + it + for it in space_items + if it.get("case_id") == case or it.get("case") == case + ) + except Exception: + pass + known_ids = {it.get("id") for it in items} + for e in entries: + item_id = e.get("item") + space = e.get("space") + if item_id and space and item_id not in known_ids: + try: + it = store.get_item(space, item_id, actor=actor) + items.append(it) + known_ids.add(item_id) + except Exception: + pass + + return format_case_report( + case, entries, meta=meta, items=items, format=format + ) + # ---------------------------------------------------------------------- grants def grant(self, actor: Actor, case: str, principal: str) -> None: @@ -432,3 +495,136 @@ def _row_to_entry(row: sqlite3.Row) -> dict[str, Any]: entry["role"] = entry.pop("actor_role") entry["item"] = entry.pop("item_id") return entry + + +def format_case_report( + case: str, + entries: list[dict[str, Any]], + *, + meta: Optional[dict[str, Any]] = None, + items: Optional[list[dict[str, Any]]] = None, + format: str = "markdown", +) -> str: + """Format a journal case and any linked work items into a report.""" + if format.lower() == "json": + data = { + "case": case, + "meta": meta or {}, + "entries": entries, + "items": items or [], + "summary": { + "total_entries": len(entries), + "total_items": len(items or []), + "findings": sum( + 1 for e in entries if e.get("kind") == "finding" + ), + "evidence": sum( + 1 for e in entries if e.get("kind") == "evidence" + ), + "decisions": sum( + 1 for e in entries if e.get("kind") == "decision" + ), + "notes": sum(1 for e in entries if e.get("kind") == "note"), + }, + } + return json.dumps(data, indent=2) + + lines = [f"# Journal Case Report: {case}", ""] + if meta: + created = meta.get("created_ts") or meta.get("created") or "" + head = meta.get("head_hash") or "" + if created: + lines.append(f"- **Created**: {created}") + if head: + lines.append(f"- **Head Hash**: `{head}`") + lines.append(f"- **Total Entries**: {len(entries)}") + if items: + lines.append(f"- **Linked Items**: {len(items)}") + lines.append("") + + findings = [e for e in entries if e.get("kind") == "finding"] + decisions = [e for e in entries if e.get("kind") == "decision"] + evidence = [e for e in entries if e.get("kind") == "evidence"] + + lines.append("## Executive Summary") + lines.append(f"- **Findings**: {len(findings)}") + lines.append(f"- **Decisions**: {len(decisions)}") + lines.append(f"- **Evidence**: {len(evidence)}") + if items: + lines.append(f"- **Linked Items**: {len(items)}") + lines.append("") + + if findings: + lines.append("## Findings") + for f in findings: + ts = f.get("ts", "") + author = f.get("author", "unknown") + body = (f.get("body") or "").strip() + lines.append( + f"### Finding #{f.get('seq', '?')} ({author} at {ts})" + ) + lines.append(body) + if f.get("entities"): + lines.append(f"- **Entities**: {', '.join(f['entities'])}") + if f.get("refs"): + lines.append(f"- **References**: {', '.join(f['refs'])}") + lines.append("") + + if decisions: + lines.append("## Decisions") + for d in decisions: + ts = d.get("ts", "") + author = d.get("author", "unknown") + body = (d.get("body") or "").strip() + lines.append(f"- **[{ts}] {author}**: {body}") + lines.append("") + + if evidence: + lines.append("## Evidence") + for ev in evidence: + ts = ev.get("ts", "") + author = ev.get("author", "unknown") + body = (ev.get("body") or "").strip() + lines.append( + f"### Evidence #{ev.get('seq', '?')} ({author} at {ts})" + ) + lines.append(body) + if ev.get("refs"): + lines.append(f"- **References**: {', '.join(ev['refs'])}") + lines.append("") + + if items: + lines.append("## Linked Work Items") + lines.append("| ID | Title | State | Assignee | Criteria |") + lines.append("|---|---|---|---|---|") + for it in items: + iid = f"#{it.get('id', '?')}" + title = str(it.get("title", "")).replace("|", "\\|") + state = it.get("state", "") + assignee = it.get("assignee") or "-" + criteria = ( + str(it.get("criteria", "")) + .replace("|", "\\|") + .replace("\n", " ") + ) + lines.append( + f"| {iid} | {title} | {state} | {assignee} | {criteria} |" + ) + lines.append("") + + lines.append("## Chronological Activity Log") + for e in entries: + seq = e.get("seq", "?") + kind = (e.get("kind") or "note").upper() + author = e.get("author", "unknown") + ts = e.get("ts", "") + body = (e.get("body") or "").strip() + lines.append(f"#### [{seq}] {kind} · {author} · {ts}") + lines.append(body) + if e.get("entities"): + lines.append(f"- *Entities*: {', '.join(e['entities'])}") + if e.get("refs"): + lines.append(f"- *Refs*: {', '.join(e['refs'])}") + lines.append("") + + return "\n".join(lines).strip() + "\n" diff --git a/coworker/teams/tools.py b/coworker/teams/tools.py index e004a037d..30fd3a43f 100644 --- a/coworker/teams/tools.py +++ b/coworker/teams/tools.py @@ -11,7 +11,7 @@ from __future__ import annotations -from typing import Callable, Optional +from typing import Any, Callable, Optional import aisuite as ai @@ -26,7 +26,7 @@ # open, unassigned item — the store arbitrates races, the lead supervises by # exception (every claim lands in its feed; reassign/cancel revokes). WORKER_VERBS = ("create_item", "list_items", "transition", "comment", "claim") -JOURNAL_VERBS = ("journal_append", "journal_read") +JOURNAL_VERBS = ("journal_append", "journal_read", "export_journal_report") # Explicit schema: the auto-generator's normalizer strips every `title` key to drop # pydantic metadata, which also deletes a PARAMETER named `title` from properties. @@ -193,6 +193,7 @@ def journal_tools( actor: Actor, space: str = "", taint: Callable[[], bool] = lambda: False, + store: Optional[Any] = None, ) -> list: def journal_append( case: str, @@ -249,6 +250,25 @@ def journal_read( except (BoardError, ValueError) as error: return {"error": str(error)} + def export_journal_report( + case: str, + format: str = "markdown", + include_raw: bool = False, + ) -> dict: + """Export a journal case and its referenced board items into a compiled + deliverable report (markdown or json).""" + try: + content = journal.export( + actor, + case, + store=store, + format=format, + include_raw=include_raw, + ) + return {"case": case, "format": format, "content": content} + except (BoardError, ValueError) as error: + return {"error": str(error)} + local = locals() return [_wrap(local[name]) for name in JOURNAL_VERBS] diff --git a/tests/test_team_journal.py b/tests/test_team_journal.py index 7d18efe93..10c91ef31 100644 --- a/tests/test_team_journal.py +++ b/tests/test_team_journal.py @@ -1,6 +1,8 @@ """Journal store: case-keyed and board-independent, grants ride assignment, filtered reads, raw-capture discipline, per-case hash chains.""" +import json + import pytest from coworker.teams import ( @@ -12,7 +14,9 @@ Role, TeamStore, ) +from coworker.teams.dialect import LocalDialect from coworker.teams.model import JOURNAL_BODY_LIMIT +from coworker.teams.tools import journal_tools USER = Actor(id="user", role=Role.USER) LEAD = Actor(id="lead-1", role=Role.LEAD) @@ -176,3 +180,115 @@ def test_per_case_hash_chains_verify_and_detect_tampering(journal, tmp_path): with pytest.raises(ChainError): journal.verify_chain("findings") assert journal.verify_chain("ops") == 1 # other case unaffected + + +def test_export_markdown_and_json(journal): + journal.append( + LEAD, + "findings", + "Vulnerability detected in auth module", + kind="finding", + entities=["auth.py"], + refs=["auth.py:42"], + ) + journal.append( + LEAD, "findings", "Refactor session handling", kind="decision" + ) + journal.append(LEAD, "findings", "curl repro output", kind="evidence") + journal.append(LEAD, "findings", "temporary trace", kind="raw") + + md = journal.export(LEAD, "findings", format="markdown") + assert "# Journal Case Report: findings" in md + assert "## Executive Summary" in md + assert "- **Findings**: 1" in md + assert "- **Decisions**: 1" in md + assert "- **Evidence**: 1" in md + assert "### Finding #" in md + assert "Vulnerability detected in auth module" in md + assert "Refactor session handling" in md + assert "curl repro output" in md + assert "temporary trace" not in md # raw excluded by default + + md_raw = journal.export( + LEAD, "findings", format="markdown", include_raw=True + ) + assert "temporary trace" in md_raw + + raw_json = journal.export(LEAD, "findings", format="json") + data = json.loads(raw_json) + assert data["case"] == "findings" + assert data["summary"]["findings"] == 1 + assert data["summary"]["decisions"] == 1 + assert data["summary"]["evidence"] == 1 + assert len(data["entries"]) == 3 + + +def test_export_unauthorized_and_nonexistent(journal): + journal.append(LEAD, "findings", "case notes") + with pytest.raises(AuthorityError, match="no grant"): + journal.export(OTHER, "findings") + with pytest.raises(BoardError, match="no case"): + journal.export(LEAD, "nonexistent") + + +def test_export_includes_linked_board_items(board, journal): + item_id = case_item(board, case="incident-1", assignee="worker-1") + journal.append( + WORKER, + "incident-1", + "Root cause identified", + kind="finding", + item=item_id, + space=SPACE, + ) + + md = journal.export(LEAD, "incident-1", store=board, format="markdown") + assert "## Linked Work Items" in md + assert f"#{item_id}" in md + assert "Task" in md + assert "worker-1" in md + + raw_json = journal.export(LEAD, "incident-1", store=board, format="json") + data = json.loads(raw_json) + assert len(data["items"]) == 1 + assert data["items"][0]["id"] == item_id + + +def test_local_dialect_journal_export(board, journal): + item_id = case_item(board, case="audit", assignee="worker-1") + journal.append( + WORKER, "audit", "Secret in config", kind="finding", item=item_id + ) + dialect = LocalDialect(board, journal=journal, actor=LEAD) + + res = dialect.journal_export(case="audit", format="markdown") + assert "# Journal Case Report: audit" in res + + res_json = dialect.journal_export(case="audit", format="json") + data = json.loads(res_json) + assert data["case"] == "audit" + + +def test_journal_tools_export(board, journal): + journal.append(LEAD, "bug-101", "Memory leak in worker pool", kind="finding") + tools = journal_tools(journal, actor=LEAD, space=SPACE, store=board) + export_fn = next(t for t in tools if t.__name__ == "export_journal_report") + + res = export_fn(case="bug-101", format="markdown") + assert res["case"] == "bug-101" + assert res["format"] == "markdown" + assert "# Journal Case Report: bug-101" in res["content"] + + res_err = export_fn(case="nonexistent") + assert "error" in res_err + + + +def test_export_includes_findings_after_first_page(journal): + for i in range(1000): + journal.append(USER, "large", f"note {i}") + journal.append(USER, "large", "final finding", kind="finding") + report = json.loads(journal.export(USER, "large", format="json")) + assert len(report["entries"]) == 1001 + assert report["summary"]["findings"] == 1 + assert report["entries"][-1]["body"] == "final finding" diff --git a/tests/test_team_open_surface.py b/tests/test_team_open_surface.py index 6cde40d58..43eec2a78 100644 --- a/tests/test_team_open_surface.py +++ b/tests/test_team_open_surface.py @@ -442,6 +442,10 @@ def test_remote_dialect_round_trip(api): assert "branch:x" in shown["refs"] entries = lead.journal_read("case-r") assert entries[0]["body"] == "wire finding" + exported = lead.journal_export("case-r", format="markdown") + assert "# Journal Case Report: case-r" in exported + assert "wire finding" in exported + assert "Remote item" in exported done = lead.transition("proj", item["id"], "done") assert done["state"] == "done" @@ -1009,6 +1013,18 @@ def test_cli_headless_flow(tmp_path, capsys): capsys.readouterr() assert main(["journal", "read", "case-cli", *space_args]) == 0 assert "found it" in capsys.readouterr().out + # journal export + assert main(["journal", "export", "case-cli", *space_args]) == 0 + export_out = capsys.readouterr().out + assert "# Journal Case Report: case-cli" in export_out + assert "found it" in export_out + + out_file = tmp_path / "report.md" + assert main( + ["journal", "export", "case-cli", "-o", str(out_file), *space_args] + ) == 0 + assert out_file.exists() + assert "# Journal Case Report: case-cli" in out_file.read_text() def test_cli_worker_cannot_show_a_foreign_item(tmp_path, capsys):