diff --git a/coworker/teams/cli.py b/coworker/teams/cli.py new file mode 100644 index 00000000..18dce0cd --- /dev/null +++ b/coworker/teams/cli.py @@ -0,0 +1,467 @@ +"""`ocw` — the board and journal from any shell, for any harness. + +The board is an open surface (OPE-100): the same role-scoped verbs the in-app +agents get, usable by an external agent CLI, a script, or a human. Point it at a +running OpenWorker server (same machine or remote) or straight at a state dir. + +Backing resolution, in order: +1. `--url` + `--token` (or OCW_BOARD_URL / OCW_BOARD_TOKEN) — a remote board. +2. `--db DIR` — direct SQLite in that state dir (headless; you are the only writer). +3. A running local server, discovered via its sidecar token files — the CLI mints + itself a local user token on first use. This is preferred over direct SQLite + whenever a server is up: two processes must never write one board file. +4. Direct SQLite on the default state dir (nothing else is running). + +`ocw board mcp` serves the same surface as an MCP server on stdio — the way to +hand a board to an external coding agent: point the agent's MCP config at +`ocw board mcp --url … --token … --space …` and ask it to claim a work item. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from pathlib import Path +from typing import Any, Optional + +from .model import BoardError, space_for_workspace +from .store import CLAIM_POLICIES + +_STATES = ("open", "in_progress", "blocked", "review", "done", "canceled") + + +def main(argv: Optional[list[str]] = None) -> int: + parser = _parser() + args = parser.parse_args(argv) + if not getattr(args, "cmd", None): + parser.print_help() + return 2 + try: + return args.func(args) + except BoardError as error: + print(f"error: {error}", file=sys.stderr) + return 1 + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="ocw", description="OpenWorker team board + journal CLI." + ) + sub = parser.add_subparsers(dest="group") + + board = sub.add_parser("board", help="work-item board verbs") + board_sub = board.add_subparsers(dest="cmd") + + def cmd(name: str, func, help: str, parent=board_sub): + p = parent.add_parser(name, help=help) + _backing_args(p) + p.set_defaults(func=func, cmd=name) + return p + + p = cmd("list", _cmd_list, "list items") + p.add_argument("--state", choices=_STATES, default="") + p.add_argument("--assignee", default="") + p.add_argument("--mine", action="store_true", help="only items assigned to me") + + p = cmd("show", _cmd_show, "one item, with comments") + p.add_argument("id", type=int) + + p = cmd("create", _cmd_create, "file a new item (open, unassigned)") + p.add_argument("title") + p.add_argument("--criteria", required=True, help="acceptance criteria") + p.add_argument("--description", default="") + p.add_argument("--parent", type=int, default=None) + p.add_argument("--case", default="") + + p = cmd("claim", _cmd_claim, "claim an open, unassigned item for yourself") + p.add_argument("id", type=int) + + p = cmd("move", _cmd_move, "transition an item") + p.add_argument("id", type=int) + p.add_argument("to", choices=_STATES[1:] + ("open",)) + p.add_argument("--comment", default="") + p.add_argument("--ref", action="append", default=[], dest="refs") + + p = cmd("comment", _cmd_comment, "comment on an item") + p.add_argument("id", type=int) + p.add_argument("body") + p.add_argument("--ref", action="append", default=[], dest="refs") + + p = cmd("assign", _cmd_assign, "assign an item (lead/user)") + p.add_argument("id", type=int) + p.add_argument("assignee") + + p = cmd("link", _cmd_link, "link two items") + p.add_argument("src", type=int) + p.add_argument("kind", choices=("parent", "blocks")) + p.add_argument("dst", type=int) + + p = cmd("policy", _cmd_policy, "show or set the board's claim policy") + p.add_argument("--claims", choices=CLAIM_POLICIES, default="") + + p = cmd("pending", _cmd_pending, "my unconsumed deliveries (assignments etc.)") + p.add_argument("--consume", action="store_true", help="advance my cursor") + p.add_argument("--limit", type=int, default=50) + + cmd("spaces", _cmd_spaces, "list known board spaces") + + # `token` manages the serving machine's registry file directly — it takes no + # backing/identity flags of its own (minting is what CREATES identities). + p = board_sub.add_parser( + "token", help="mint/list/revoke board join tokens (serving machine)" + ) + p.add_argument("action", choices=("mint", "list", "revoke")) + p.add_argument("--actor", default="", help="callname the token binds (mint)") + p.add_argument( + "--role", choices=("worker", "lead", "user"), default="worker" + ) + p.add_argument("--label", default="", help="what this token is for (mint)") + p.add_argument("--prefix", default="", help="token prefix to revoke") + p.add_argument("--db", default="", help="state dir holding the registry") + p.add_argument("--json", action="store_true") + p.set_defaults(func=_cmd_token, cmd="token") + + p = cmd("mcp", _cmd_mcp, "serve this board over MCP on stdio") + + journal = sub.add_parser("journal", help="journal case verbs") + journal_sub = journal.add_subparsers(dest="cmd") + + p = cmd("cases", _cmd_cases, "cases I can read", parent=journal_sub) + + p = cmd("read", _cmd_read, "read a case (filtered)", parent=journal_sub) + p.add_argument("case") + p.add_argument("--item", type=int, default=None) + p.add_argument("--author", default="") + p.add_argument("--kind", default="") + p.add_argument("--entity", default="") + p.add_argument("--raw", action="store_true", dest="include_raw") + p.add_argument("--limit", type=int, default=50) + + p = cmd("append", _cmd_append, "append an entry to a case", parent=journal_sub) + p.add_argument("case") + p.add_argument("body") + p.add_argument( + "--kind", + choices=("finding", "evidence", "decision", "note", "raw"), + default="note", + ) + p.add_argument("--item", type=int, default=None) + p.add_argument("--entity", action="append", default=[], dest="entities") + p.add_argument("--ref", action="append", default=[], dest="refs") + + return parser + + +def _backing_args(p: argparse.ArgumentParser) -> None: + p.add_argument("--url", default=os.environ.get("OCW_BOARD_URL", "")) + p.add_argument("--token", default=os.environ.get("OCW_BOARD_TOKEN", "")) + p.add_argument("--db", default="", help="state dir for direct (headless) access") + p.add_argument("--actor", dest="local_actor", default="user") + p.add_argument("--role", dest="local_role", default="user") + p.add_argument( + "--space", + default=os.environ.get("OCW_BOARD_SPACE", ""), + help="board space (default: this directory's workspace)", + ) + p.add_argument("--json", action="store_true", help="machine-readable output") + + +# ------------------------------------------------------------------ backing + + +def _space(args) -> str: + return args.space or space_for_workspace(Path.cwd()) + + +def _dialect(args): + from .dialect import RemoteDialect, local_dialect + + if args.url: + if not args.token: + raise BoardError("--token (or OCW_BOARD_TOKEN) is required with --url") + return RemoteDialect(args.url, args.token) + if args.db: + return local_dialect(args.db, actor=args.local_actor, role=args.local_role) + server = _discover_server() + if server is not None: + return RemoteDialect(server, _local_cli_token()) + from ..secrets import state_dir + + return local_dialect(state_dir(), actor=args.local_actor, role=args.local_role) + + +def _discover_server() -> Optional[str]: + """A running local server, found via its per-port sidecar token files.""" + import httpx + + from ..secrets import state_dir + + ports = [] + try: + for path in state_dir().glob("sidecar-*.token"): + try: + ports.append(int(path.stem.split("-")[1])) + except (IndexError, ValueError): + continue + except OSError: + return None + for port in sorted(ports, reverse=True): + url = f"http://127.0.0.1:{port}" + try: + if httpx.get(f"{url}/v1/health", timeout=1.5).status_code == 200: + return url + except httpx.HTTPError: + continue + return None + + +def _local_cli_token() -> str: + """The CLI's own user token against the local server. Minted once into the + shared registry; the plaintext is cached user-only in the state dir — the + user's own credential on the user's own machine, same pattern as the sidecar + token file.""" + from ..secrets import state_dir, write_private_text + + from .tokens import BoardTokens + + cache = state_dir() / "ocw-cli.token" + tokens = BoardTokens(state_dir() / "board-tokens.json") + try: + cached = cache.read_text().strip() + if cached and tokens.resolve(cached) is not None: + return cached + except OSError: + pass + token = tokens.mint("user", "user", label="local ocw CLI") + write_private_text(cache, token + "\n") + return token + + +# ------------------------------------------------------------------ board cmds + + +def _cmd_list(args) -> int: + dialect = _dialect(args) + assignee = args.assignee or (dialect.whoami()["actor"] if args.mine else "") + items = dialect.list_items( + _space(args), state=args.state or None, assignee=assignee or None + ) + if args.json: + print(json.dumps(items, indent=2)) + return 0 + if not items: + print("no items") + return 0 + for item in items: + who = f" @{item['assignee']}" if item["assignee"] else "" + print(f"#{item['id']:<4} {item['state']:<12}{who:<14} {item['title']}") + return 0 + + +def _cmd_show(args) -> int: + item = _dialect(args).get_item(_space(args), args.id) + if args.json: + print(json.dumps(item, indent=2)) + return 0 + print(f"#{item['id']} {item['title']} [{item['state']}]") + if item["assignee"]: + print(f"assignee: {item['assignee']}") + print(f"created by: {item['creator']}") + if item["description"]: + print(f"\n{item['description']}") + print(f"\nDone when: {item['criteria']}") + if item.get("refs"): + print("refs: " + ", ".join(item["refs"])) + for link in item.get("links") or []: + print(f"link: {link['kind']} #{link['item']}") + for comment in item.get("comments") or []: + print(f"\n[{comment['ts']}] {comment['author']}: {comment['body']}") + return 0 + + +def _cmd_create(args) -> int: + item = _dialect(args).create_item( + _space(args), + title=args.title, + criteria=args.criteria, + description=args.description, + parent=args.parent, + case=args.case or None, + ) + print(json.dumps(item, indent=2) if args.json else f"created #{item['id']}") + return 0 + + +def _cmd_claim(args) -> int: + item = _dialect(args).claim(_space(args), args.id) + print( + json.dumps(item, indent=2) + if args.json + else f"claimed #{item['id']} — it's yours; move it to in_progress when you start" + ) + return 0 + + +def _cmd_move(args) -> int: + item = _dialect(args).transition( + _space(args), args.id, args.to, comment=args.comment, refs=args.refs + ) + print(json.dumps(item, indent=2) if args.json else f"#{item['id']} → {item['state']}") + return 0 + + +def _cmd_comment(args) -> int: + _dialect(args).comment(_space(args), args.id, args.body, refs=args.refs) + print("ok" if not args.json else json.dumps({"ok": True})) + return 0 + + +def _cmd_assign(args) -> int: + item = _dialect(args).assign(_space(args), args.id, args.assignee) + print( + json.dumps(item, indent=2) + if args.json + else f"#{item['id']} → @{item['assignee']}" + ) + return 0 + + +def _cmd_link(args) -> int: + _dialect(args).link(_space(args), args.src, args.kind, args.dst) + print("ok" if not args.json else json.dumps({"ok": True})) + return 0 + + +def _cmd_policy(args) -> int: + dialect = _dialect(args) + policy = ( + dialect.set_policy(_space(args), claims=args.claims) + if args.claims + else dialect.policy(_space(args)) + ) + print(json.dumps(policy) if args.json else f"claims: {policy['claims']}") + return 0 + + +def _cmd_pending(args) -> int: + dialect = _dialect(args) + events = dialect.pending(limit=args.limit) + if args.json: + print(json.dumps(events, indent=2)) + else: + for event in events: + print(f"[{event['seq']}] {event['kind']} #{event.get('item_id')}" + f" from {event['actor']}: {json.dumps(event['payload'])}") + if not events: + print("nothing pending") + if args.consume and events: + dialect.consume(events[-1]["seq"]) + return 0 + + +def _cmd_spaces(args) -> int: + spaces = _dialect(args).spaces() + print(json.dumps(spaces) if args.json else "\n".join(spaces) or "no spaces") + return 0 + + +def _cmd_token(args) -> int: + from ..secrets import state_dir + + from .tokens import BoardTokens + + tokens = BoardTokens( + (Path(args.db).expanduser() if args.db else state_dir()) / "board-tokens.json" + ) + if args.action == "mint": + if not args.actor: + print("error: --actor is required to mint", file=sys.stderr) + return 1 + token = tokens.mint(args.actor, args.role, label=args.label) + print(token) + print( + f"# binds actor '{args.actor}' as {args.role}; shown once — store it" + " in the client's config (OCW_BOARD_TOKEN)", + file=sys.stderr, + ) + return 0 + if args.action == "revoke": + removed = tokens.revoke(args.prefix) + print(f"revoked {removed} token(s)") + return 0 + entries = tokens.entries() + if args.json: + print(json.dumps(entries, indent=2)) + return 0 + for entry in entries: + label = f" ({entry['label']})" if entry["label"] else "" + print(f"{entry['prefix']}… {entry['actor']:<16} {entry['role']:<8}{label}") + if not entries: + print("no tokens") + return 0 + + +def _cmd_mcp(args) -> int: + from .mcp_server import serve + + serve(_dialect(args), space=_space(args)) + return 0 + + +# ------------------------------------------------------------------ journal cmds + + +def _cmd_cases(args) -> int: + cases = _dialect(args).journal_overview() + if args.json: + print(json.dumps(cases, indent=2)) + return 0 + for case in cases: + print( + f"{case.get('case', '?'):<28} {case.get('entries', 0)} entries" + + (f" (last {case['last_ts']})" if case.get("last_ts") else "") + ) + if not cases: + print("no cases") + return 0 + + +def _cmd_read(args) -> int: + entries = _dialect(args).journal_read( + args.case, + item=args.item, + author=args.author or None, + kind=args.kind or None, + entity=args.entity or None, + include_raw=args.include_raw, + limit=args.limit, + ) + if args.json: + print(json.dumps(entries, indent=2)) + return 0 + for entry in entries: + print(f"[{entry['ts']}] {entry['author']} {entry['kind']}:" + f" {entry.get('body') or ''}") + if not entries: + print("no entries") + return 0 + + +def _cmd_append(args) -> int: + _dialect(args).journal_append( + args.case, + args.body, + kind=args.kind, + space=_space(args), + item=args.item, + entities=args.entities, + refs=args.refs, + ) + print("ok" if not args.json else json.dumps({"ok": True})) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/coworker/teams/mcp_server.py b/coworker/teams/mcp_server.py new file mode 100644 index 00000000..bff7cd90 --- /dev/null +++ b/coworker/teams/mcp_server.py @@ -0,0 +1,191 @@ +"""`team-board` — the board and journal as an MCP server on stdio. + +The way an external coding agent joins a team: its MCP config runs +`ocw board mcp --url … --token … --space …` (or `--db …` headless), it sees the +role-scoped board tools, and the user asks it to claim an item and work. Identity +and authority never live here: the dialect is already bound to one actor (token or +local flags), and every write is judged by the store/server — this file is a thin +adapter, safe to hand to any harness. + +Tool results are JSON — raw data for the agent, not prose. +""" + +from __future__ import annotations + +from typing import Any, Optional + +from .model import BoardError + + +def build(dialect, *, space: str): + """Assemble the FastMCP server for one dialect+space. Split from serve() so + tests can inspect the registered tool set without a transport.""" + from mcp.server.fastmcp import FastMCP + + who = dialect.whoami() + role = who.get("role", "worker") + mcp = FastMCP( + "team-board", + instructions=( + f"A shared team work board (you are '{who.get('actor')}', role" + f" {role}) plus the team journal. Items carry acceptance criteria —" + " what gets verified before they can be done. Typical worker loop:" + " board_list → board_claim an open item → board_move to in_progress →" + " work, journal_append findings as you go → board_move to review with" + " a hand-off comment and refs. Never mark items done — done is the" + " verdict after review." + ), + ) + + def _safe(func, *args, **kwargs) -> Any: + try: + return func(*args, **kwargs) + except (BoardError, ValueError) as error: + return {"error": str(error)} + + @mcp.tool() + def board_list(state: str = "", assignee: str = "") -> Any: + """List work items on the board, optionally filtered by state + (open/in_progress/blocked/review/done/canceled) or assignee.""" + return _safe( + dialect.list_items, space, state=state or None, assignee=assignee or None + ) + + @mcp.tool() + def board_show(item: int) -> Any: + """One work item in full: description, acceptance criteria, refs, links, + and every comment.""" + return _safe(dialect.get_item, space, item) + + @mcp.tool() + def board_create( + title: str, + criteria: str, + description: str = "", + parent: Optional[int] = None, + case: str = "", + ) -> Any: + """File a new work item (open, unassigned — work starts when it is + assigned or claimed). `criteria` is the acceptance criteria — what gets + verified before the item can be done; required.""" + return _safe( + dialect.create_item, + space, + title=title, + criteria=criteria, + description=description, + parent=parent, + case=case or None, + ) + + @mcp.tool() + def board_claim(item: int) -> Any: + """Claim an open, unassigned item for yourself. First claim wins; the + item becomes your assignment. Only claim work you can start on now.""" + return _safe(dialect.claim, space, item) + + @mcp.tool() + def board_move(item: int, to: str, comment: str = "", refs: list[str] = []) -> Any: + """Move a work item: in_progress when you start, blocked with the blocker + as `comment`, review with a hand-off comment and artifact refs (branch, + PR, file:line) when finished.""" + return _safe( + dialect.transition, space, item, to, comment=comment, refs=list(refs or []) + ) + + @mcp.tool() + def board_comment(item: int, body: str, refs: list[str] = []) -> Any: + """Comment on a work item — durable and attributed; answers that matter + belong here. `refs` attach artifact pointers.""" + return _safe(dialect.comment, space, item, body, refs=list(refs or [])) + + @mcp.tool() + def board_pending() -> Any: + """Your unconsumed deliveries — assignments addressed to you, cancel + notices. Check at the start of a work session; acknowledge with + board_consume.""" + return _safe(dialect.pending) + + @mcp.tool() + def board_consume(upto_seq: int) -> Any: + """Acknowledge deliveries up to a sequence number (from board_pending), + so they are not re-delivered.""" + return _safe(lambda: (dialect.consume(upto_seq), {"ok": True})[1]) + + if role in ("lead", "user"): + + @mcp.tool() + def board_assign(item: int, assignee: str) -> Any: + """Assign a work item to a worker (or to yourself to reserve it).""" + return _safe(dialect.assign, space, item, assignee) + + @mcp.tool() + def board_link(src: int, kind: str, dst: int) -> Any: + """Link two items: `parent` (dst becomes src's parent) or `blocks` + (src blocks dst).""" + return _safe(dialect.link, space, src, kind, dst) + + @mcp.tool() + def board_policy(claims: str = "") -> Any: + """Show the board's claim policy, or set it: `open` (workers may + self-claim open items) or `lead-only`.""" + if claims: + return _safe(dialect.set_policy, space, claims=claims) + return _safe(dialect.policy, space) + + @mcp.tool() + def journal_append( + case: str, + body: str, + kind: str = "note", + item: Optional[int] = None, + entities: list[str] = [], + refs: list[str] = [], + ) -> Any: + """Append to a journal case as you work: kind is finding, evidence, + decision, note, or raw (a capture excerpt referencing a file). + `entities` are the concrete things it is about (paths, resources, ids).""" + return _safe( + dialect.journal_append, + case, + body, + kind=kind, + space=space, + item=item, + entities=list(entities or []), + refs=list(refs or []), + ) + + @mcp.tool() + def journal_read( + case: str, + item: Optional[int] = None, + author: str = "", + kind: str = "", + entity: str = "", + include_raw: bool = False, + limit: int = 50, + ) -> Any: + """Read a journal case, filtered by item, author, entry kind, or entity. + Prefer narrow reads; raw captures are skipped unless asked.""" + return _safe( + dialect.journal_read, + case, + item=item, + author=author or None, + kind=kind or None, + entity=entity or None, + include_raw=include_raw, + limit=limit, + ) + + @mcp.tool() + def journal_cases() -> Any: + """The journal cases you can read, with entry counts.""" + return _safe(dialect.journal_overview) + + return mcp + + +def serve(dialect, *, space: str) -> None: + build(dialect, space=space).run("stdio") diff --git a/pyproject.toml b/pyproject.toml index 9561bd91..36b48a75 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,6 +53,9 @@ bedrock = ["boto3>=1.34"] openworker = "coworker.cli:main" openworker-server = "coworker.server.run:main" openworker-connectors = "coworker.connectors.cli:main" +# The board as an open surface (OPE-100): `ocw board …` / `ocw journal …`, +# including `ocw board mcp` — the stdio MCP server external harnesses attach to. +ocw = "coworker.teams.cli:main" [tool.setuptools.packages.find] where = ["."] diff --git a/tests/test_team_open_surface.py b/tests/test_team_open_surface.py new file mode 100644 index 00000000..4246bc6f --- /dev/null +++ b/tests/test_team_open_surface.py @@ -0,0 +1,417 @@ +"""OPE-100 — the board as an open surface: claim + policy knob, the BoardDialect +seam (local and remote), token-bound identity on `/v1/board`, and the MCP/CLI +front doors.""" + +import json + +import pytest + +from coworker.teams import Actor, AuthorityError, BoardError, JournalStore, Role, TeamStore +from coworker.teams.dialect import LocalDialect, RemoteDialect, local_dialect +from coworker.teams.tokens import BoardTokens + +USER = Actor(id="user", role=Role.USER) +LEAD = Actor(id="lead-1", role=Role.LEAD, persona="swe-lead") +NIA = Actor(id="nia", role=Role.WORKER, persona="swe-worker") +WEBB = Actor(id="webb", role=Role.WORKER, persona="swe-worker") + + +@pytest.fixture +def store(tmp_path): + journal = JournalStore(tmp_path / "journal.db") + store = TeamStore(tmp_path / "teams.db", journal=journal) + yield store + store.close() + journal.close() + + +def seed(store, space="proj", case=None): + return store.create_item( + space, LEAD, title="Build the API", criteria="routes pass tests", case=case + ) + + +# ------------------------------------------------------------------ claim verb + + +def test_claim_self_assigns_an_open_item(store): + item = seed(store) + claimed = store.claim("proj", NIA, item["id"]) + assert claimed["assignee"] == "nia" + assert claimed["state"] == "open" # claiming is not starting + + +def test_second_claim_loses_cleanly(store): + item = seed(store) + store.claim("proj", NIA, item["id"]) + with pytest.raises(BoardError, match="already claimed by nia"): + store.claim("proj", WEBB, item["id"]) + + +def test_claim_requires_open_and_unassigned(store): + item = seed(store) + store.assign("proj", LEAD, item["id"], "nia") + # an assigned item is not claimable, even while still open + with pytest.raises(BoardError, match="already claimed by nia"): + store.claim("proj", WEBB, item["id"]) + store.transition("proj", NIA, item["id"], "in_progress") + # and a non-open item never is + with pytest.raises(BoardError, match="only open items"): + store.claim("proj", WEBB, item["id"]) + + +def test_lead_only_policy_blocks_worker_claims(store): + item = seed(store) + store.set_policy("proj", LEAD, claims="lead-only") + with pytest.raises(AuthorityError, match="lead-only"): + store.claim("proj", NIA, item["id"]) + # flipping back re-opens the queue + store.set_policy("proj", USER, claims="open") + assert store.claim("proj", NIA, item["id"])["assignee"] == "nia" + + +def test_policy_defaults_open_and_validates(store): + assert store.policy("proj") == {"claims": "open"} + with pytest.raises(BoardError): + store.set_policy("proj", LEAD, claims="anarchy") + with pytest.raises(AuthorityError): + store.set_policy("proj", NIA, claims="lead-only") + + +def test_claim_feeds_the_lead_subscription(store): + item = seed(store) + store.claim("proj", NIA, item["id"]) + subs = store.subscribed_events("proj", "lead-1") + claims = [e for e in subs if e["kind"] == "item_assigned"] + assert len(claims) == 1 + assert claims[0]["actor"] == "nia" + assert claims[0]["payload"]["claimed"] is True + + +def test_lead_assigns_are_not_subscription_news(store): + item = seed(store) + store.assign("proj", USER, item["id"], "nia") + subs = store.subscribed_events("proj", "lead-1") + assert not [e for e in subs if e["kind"] == "item_assigned"] + + +def test_claim_feeds_journal_grants_like_assignment(store): + item = seed(store, case="case-alpha") + store.claim("proj", NIA, item["id"]) + # nia can now read the case it was granted through the claim + assert "case-alpha" in store.journal.cases(NIA) + + +# ------------------------------------------------------------------ dialects + + +def test_local_dialect_binds_identity(tmp_path): + dialect = local_dialect(tmp_path, actor="nia", role="worker") + assert dialect.whoami() == {"actor": "nia", "role": "worker"} + with pytest.raises(AuthorityError): + dialect.assign("proj", 1, "webb") # workers never assign + + +def test_local_dialect_full_worker_loop(tmp_path): + lead = local_dialect(tmp_path, actor="lead-1", role="lead") + item = lead.create_item( + "proj", title="Build it", criteria="tests pass", case="case-b" + ) + worker = LocalDialect(lead.store, lead.journal, NIA) + claimed = worker.claim("proj", item["id"]) + assert claimed["assignee"] == "nia" + worker.transition("proj", item["id"], "in_progress") + worker.journal_append("case-b", "found the flaky fixture", kind="finding") + worker.transition("proj", item["id"], "review", comment="branch ready") + shown = worker.get_item("proj", item["id"]) + assert shown["state"] == "review" + assert [e["body"] for e in worker.journal_read("case-b")] == [ + "found the flaky fixture" + ] + + +# ------------------------------------------------------------- HTTP board API + + +@pytest.fixture +def api(tmp_path, monkeypatch): + """The real FastAPI app over a real manager state dir, driven in-process.""" + monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state")) + monkeypatch.setenv("COWORKER_API_TOKEN", "sidecar-secret") + from coworker.permissions import Mode + from coworker.server.app import create_app + from coworker.server.manager import SessionManager + + manager = SessionManager( + workspace=None, + data_dir=tmp_path / "state", + model="openai:gpt-test", + mode=Mode("interactive"), + ) + from fastapi.testclient import TestClient + + app = create_app(manager) + client = TestClient(app, base_url="http://board.test") + yield client, manager, app + client.close() + + +def _tokens(manager) -> BoardTokens: + return manager.board_tokens + + +def test_board_api_requires_a_token(api): + client, _, _ = api + response = client.get("/v1/board/items", params={"space": "proj"}) + assert response.status_code == 401 + assert "board token" in response.json()["error"] + + +def test_board_api_rejects_the_sidecar_token_as_a_board_token(api): + client, _, _ = api + response = client.get( + "/v1/board/whoami", + headers={"Authorization": "Bearer sidecar-secret"}, + ) + assert response.status_code == 401 + + +def test_token_binds_identity_and_store_enforces_authority(api): + client, manager, app = api + lead_token = _tokens(manager).mint("lead-1", "lead") + nia_token = _tokens(manager).mint("nia", "worker") + lead = {"Authorization": f"Bearer {lead_token}"} + nia = {"Authorization": f"Bearer {nia_token}"} + + assert client.get("/v1/board/whoami", headers=nia).json() == { + "actor": "nia", + "role": "worker", + } + + created = client.post( + "/v1/board/items", + headers=lead, + json={"space": "proj", "title": "Build it", "criteria": "tests pass"}, + ) + assert created.status_code == 200 + item_id = created.json()["id"] + + # a worker token cannot assign — 403 from the store's authority check + denied = client.post( + "/v1/board/items/assign", + headers=nia, + json={"space": "proj", "id": item_id, "assignee": "nia"}, + ) + assert denied.status_code == 403 + + # but it can claim, then work the item + claimed = client.post( + "/v1/board/items/claim", headers=nia, json={"space": "proj", "id": item_id} + ) + assert claimed.status_code == 200 + assert claimed.json()["assignee"] == "nia" + + moved = client.post( + "/v1/board/items/transition", + headers=nia, + json={"space": "proj", "id": item_id, "to": "in_progress"}, + ) + assert moved.status_code == 200 + + # bad input is a 400 with the store's message, not a 500 + bad = client.post( + "/v1/board/items/transition", + headers=nia, + json={"space": "proj", "id": item_id, "to": "done"}, + ) + assert bad.status_code == 403 or bad.status_code == 400 + + +def test_remote_dialect_round_trip(api): + client, manager, app = api + lead_token = _tokens(manager).mint("lead-1", "lead") + nia_token = _tokens(manager).mint("nia", "worker") + from fastapi.testclient import TestClient + + lead = RemoteDialect( + "http://board.test", + lead_token, + client=TestClient(app, base_url="http://board.test"), + ) + nia = RemoteDialect( + "http://board.test", + nia_token, + client=TestClient(app, base_url="http://board.test"), + ) + + item = lead.create_item( + "proj", title="Remote item", criteria="works over the wire", case="case-r" + ) + assert lead.policy("proj") == {"claims": "open"} + claimed = nia.claim("proj", item["id"]) + assert claimed["assignee"] == "nia" + nia.transition("proj", item["id"], "in_progress") + nia.journal_append("case-r", "wire finding", kind="finding", item=item["id"]) + nia.transition("proj", item["id"], "review", comment="ready", refs=["branch:x"]) + + # the lead sees the review, verifies, and closes + shown = lead.get_item("proj", item["id"]) + assert shown["state"] == "review" + assert "branch:x" in shown["refs"] + entries = lead.journal_read("case-r") + assert entries[0]["body"] == "wire finding" + done = lead.transition("proj", item["id"], "done") + assert done["state"] == "done" + + # errors surface as BoardError with the server's message + with pytest.raises(BoardError, match="only open items"): + nia.claim("proj", item["id"]) + + # policy flip over the wire blocks the next worker claim + second = lead.create_item("proj", title="Held back", criteria="c") + lead.set_policy("proj", claims="lead-only") + with pytest.raises(BoardError, match="lead-only"): + nia.claim("proj", second["id"]) + + +def test_pending_and_consume_over_the_wire(api): + client, manager, app = api + lead_token = _tokens(manager).mint("lead-1", "lead") + nia_token = _tokens(manager).mint("nia", "worker") + from fastapi.testclient import TestClient + + lead = RemoteDialect( + "http://board.test", + lead_token, + client=TestClient(app, base_url="http://board.test"), + ) + nia = RemoteDialect( + "http://board.test", + nia_token, + client=TestClient(app, base_url="http://board.test"), + ) + item = lead.create_item("proj", title="Queued", criteria="c") + lead.assign("proj", item["id"], "nia") + events = nia.pending() + assert events and events[-1]["kind"] == "item_assigned" + nia.consume(events[-1]["seq"]) + assert nia.pending() == [] + + +# ------------------------------------------------------------------ tokens + + +def test_tokens_are_hash_stored_and_revocable(tmp_path): + tokens = BoardTokens(tmp_path / "board-tokens.json") + token = tokens.mint("nia", "worker", label="laptop") + # plaintext never touches disk + assert token not in (tmp_path / "board-tokens.json").read_text() + actor = tokens.resolve(token) + assert (actor.id, actor.role) == ("nia", Role.WORKER) + assert tokens.resolve("owb_forged") is None + assert tokens.revoke(token[:12]) == 1 + assert tokens.resolve(token) is None + + +def test_token_mint_validates_role(tmp_path): + tokens = BoardTokens(tmp_path / "board-tokens.json") + with pytest.raises(ValueError): + tokens.mint("nia", "admin") + + +# ------------------------------------------------------------------ MCP server + + +def test_mcp_tool_surface_is_role_scoped(tmp_path): + import anyio + + from coworker.teams.mcp_server import build + + worker = build( + local_dialect(tmp_path, actor="nia", role="worker"), space="proj" + ) + lead = build( + local_dialect(tmp_path, actor="lead-1", role="lead"), space="proj" + ) + + def names(server): + return {tool.name for tool in anyio.run(server.list_tools)} + + worker_names = names(worker) + lead_names = names(lead) + assert "board_claim" in worker_names + assert "board_assign" not in worker_names + assert "board_policy" not in worker_names + assert {"board_assign", "board_link", "board_policy"} <= lead_names + assert "journal_append" in worker_names + + +def test_mcp_worker_loop_through_call_tool(tmp_path): + import anyio + + from coworker.teams.mcp_server import build + + lead_dialect = local_dialect(tmp_path, actor="lead-1", role="lead") + item = lead_dialect.create_item("proj", title="Via MCP", criteria="c") + worker = build( + LocalDialect(lead_dialect.store, lead_dialect.journal, NIA), space="proj" + ) + + def call(name, arguments): + return anyio.run(lambda: worker.call_tool(name, arguments)) + + call("board_claim", {"item": item["id"]}) + call("board_move", {"item": item["id"], "to": "in_progress"}) + shown = lead_dialect.get_item("proj", item["id"]) + assert (shown["assignee"], shown["state"]) == ("nia", "in_progress") + + +# ------------------------------------------------------------------ CLI + + +def test_cli_headless_flow(tmp_path, capsys): + from coworker.teams.cli import main + + space_args = ["--db", str(tmp_path), "--space", "proj"] + assert main( + ["board", "create", "CLI item", "--criteria", "prints", *space_args, + "--actor", "lead-1", "--role", "lead"] + ) == 0 + capsys.readouterr() + assert main( + ["board", "claim", "1", *space_args, "--actor", "nia", "--role", "worker"] + ) == 0 + assert "claimed #1" in capsys.readouterr().out + assert main(["board", "list", *space_args, "--json"]) == 0 + items = json.loads(capsys.readouterr().out) + assert [(i["id"], i["assignee"]) for i in items] == [(1, "nia")] + # a losing claim exits 1 with the store's message on stderr + assert main( + ["board", "claim", "1", *space_args, "--actor", "webb", "--role", "worker"] + ) == 1 + assert "already claimed by nia" in capsys.readouterr().err + # policy knob round-trips + assert main(["board", "policy", "--claims", "lead-only", *space_args]) == 0 + assert "lead-only" in capsys.readouterr().out + # journal append + read + assert main( + ["journal", "append", "case-cli", "found it", "--kind", "finding", + *space_args] + ) == 0 + capsys.readouterr() + assert main(["journal", "read", "case-cli", *space_args]) == 0 + assert "found it" in capsys.readouterr().out + + +def test_cli_token_mint_and_list(tmp_path, capsys): + from coworker.teams.cli import main + + assert main( + ["board", "token", "mint", "--actor", "nia", "--role", "worker", + "--label", "laptop", "--db", str(tmp_path)] + ) == 0 + token = capsys.readouterr().out.strip() + assert token.startswith("owb_") + assert main(["board", "token", "list", "--db", str(tmp_path)]) == 0 + out = capsys.readouterr().out + assert "nia" in out and "laptop" in out and token not in out