mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-11 14:50:14 +00:00
Worker delivery is now a read-time feed over its slice (assigned ∪ filed) — send-backs, comment answers, reassignments, cancels, and acceptance all arrive through one relation; per-event recipient addressing retired. Reassignment delivers before interest ends; a new assignee replays the item's story. Detail pane gains Add a note (never changes state); external pending/consume become space-scoped feed calls.
505 lines
17 KiB
Python
505 lines
17 KiB
Python
"""`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("attach", _cmd_attach, "attach a screenshot/image to an item")
|
|
p.add_argument("id", type=int)
|
|
p.add_argument("file", help="image file (png/jpg/gif/webp, ≤10MB)")
|
|
p.add_argument("--caption", default="")
|
|
|
|
p = cmd("attachment", _cmd_attachment, "download an attachment by ref or name")
|
|
p.add_argument("ref", help="attachment:// ref or <sha256>.<ext> name")
|
|
p.add_argument("-o", "--out", default="", help="output path (default: basename)")
|
|
|
|
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_attach(args) -> int:
|
|
source = Path(args.file).expanduser()
|
|
if not source.is_file():
|
|
print(f"error: no such file: {source}", file=sys.stderr)
|
|
return 1
|
|
result = _dialect(args).attach(
|
|
_space(args), args.id, source.read_bytes(), source.name, caption=args.caption
|
|
)
|
|
ref = result.get("ref") or next(
|
|
(r for r in (result.get("payload") or {}).get("refs", [])), ""
|
|
)
|
|
print(json.dumps(result, indent=2) if args.json else f"attached → {ref}")
|
|
return 0
|
|
|
|
|
|
def _cmd_attachment(args) -> int:
|
|
from .attachments import stored_name
|
|
|
|
stored = stored_name(args.ref) or args.ref
|
|
data, _mime = _dialect(args).attachment(stored)
|
|
out = Path(args.out) if args.out else Path(
|
|
args.ref.rsplit("#", 1)[-1] if "#" in args.ref else stored
|
|
)
|
|
out.write_bytes(data)
|
|
print(str(out))
|
|
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(_space(args), 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(_space(args), 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())
|