Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions coworker/server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()}
Expand Down
2 changes: 1 addition & 1 deletion coworker/server/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
38 changes: 37 additions & 1 deletion coworker/teams/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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 <id>)"
)
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())
27 changes: 27 additions & 0 deletions coworker/teams/dialect.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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()

Expand Down
196 changes: 196 additions & 0 deletions coworker/teams/journal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"
Loading