feat: OpenMesh 基础平台与 MD/PDF 转换技能
- 后端: coworker 智能体框架, WS API, 文件上传, 附件处理 - 前端: Open WebUI, 文件全量走 upload API (含 MD/TXT/JSON 等文本类) - 技能: md-to-office (pandoc + wkhtmltopdf) - 修复: 上传文件路径丢失, Agent 搜索浪费, 输出文件跑到 uploads/ - 打包: PyInstaller one-dir, 预打包 pandoc/wkhtmltopdf/chromium
This commit is contained in:
34
coworker/teams/__init__.py
Normal file
34
coworker/teams/__init__.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""Agent teams substrate. Two append-only stores, one record discipline:
|
||||
the board log (space-scoped — a board lives and dies with its team) and the
|
||||
journal store (case-keyed — knowledge that outlives boards and teams)."""
|
||||
|
||||
from .journal import JournalStore
|
||||
from .model import (
|
||||
Actor,
|
||||
AuthorityError,
|
||||
BoardError,
|
||||
BoardNotFoundError,
|
||||
ChainError,
|
||||
ItemState,
|
||||
Role,
|
||||
)
|
||||
from .store import TeamStore
|
||||
from .tools import board_tools, journal_tools
|
||||
|
||||
__all__ = [
|
||||
"Actor",
|
||||
"AuthorityError",
|
||||
"BoardError",
|
||||
"BoardNotFoundError",
|
||||
"ChainError",
|
||||
"ItemState",
|
||||
"JournalStore",
|
||||
"Role",
|
||||
"TeamStore",
|
||||
"board_tools",
|
||||
"journal_tools",
|
||||
]
|
||||
|
||||
# BoardDialect / LocalDialect / RemoteDialect live in .dialect, BoardTokens in
|
||||
# .tokens — imported directly by their consumers (CLI, MCP server, `/v1/board`)
|
||||
# to keep this package root light for the common in-app path.
|
||||
114
coworker/teams/attachments.py
Normal file
114
coworker/teams/attachments.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""Content-addressed attachments for board items — screenshots first.
|
||||
|
||||
Review artifacts don't belong in the repo (they aren't source, and they die with
|
||||
checkouts) and don't belong in the board log (events carry refs, never blobs — no
|
||||
megabytes under the hash chain). They live here: files named by their sha256 in the
|
||||
state dir, bridged into the board as a normal comment event carrying an
|
||||
`attachment://<hash>.<ext>#<name>` ref.
|
||||
|
||||
Content addressing buys three things: dedupe for free (the same screenshot attached
|
||||
twice stores once), immutability by construction (the ref can never dangle onto
|
||||
changed bytes), and location independence — on a hosted board the same ref resolves
|
||||
to object storage instead of this directory.
|
||||
|
||||
Scope is images-only and ~10MB to start; the allowlist is the policy choke point
|
||||
when that widens.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from .model import BoardError, BoardNotFoundError
|
||||
|
||||
ATTACHMENT_SCHEME = "attachment://"
|
||||
MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024
|
||||
|
||||
# Extension → mime for the types we accept. Sniffed magic must agree with the
|
||||
# claimed extension — a .png that isn't a PNG is refused, not renamed.
|
||||
_IMAGE_TYPES = {
|
||||
"png": "image/png",
|
||||
"jpg": "image/jpeg",
|
||||
"jpeg": "image/jpeg",
|
||||
"gif": "image/gif",
|
||||
"webp": "image/webp",
|
||||
}
|
||||
|
||||
_MAGIC = {
|
||||
"png": b"\x89PNG\r\n\x1a\n",
|
||||
"jpg": b"\xff\xd8\xff",
|
||||
"jpeg": b"\xff\xd8\xff",
|
||||
"gif": b"GIF8",
|
||||
"webp": b"RIFF", # RIFF….WEBP — checked with the fourcc below
|
||||
}
|
||||
|
||||
_STORED_NAME = re.compile(r"[0-9a-f]{64}\.[a-z0-9]{1,5}")
|
||||
|
||||
|
||||
class AttachmentStore:
|
||||
def __init__(self, root: str | Path) -> None:
|
||||
self.root = Path(root).expanduser()
|
||||
|
||||
def put(self, data: bytes, filename: str) -> str:
|
||||
"""Store one attachment; returns its `attachment://` ref. Idempotent —
|
||||
identical bytes land on the same file."""
|
||||
ext = _validate(data, filename)
|
||||
stored = f"{hashlib.sha256(data).hexdigest()}.{ext}"
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
target = self.root / stored
|
||||
if not target.exists():
|
||||
tmp = target.with_suffix(target.suffix + ".tmp")
|
||||
tmp.write_bytes(data)
|
||||
tmp.replace(target)
|
||||
safe_name = Path(filename).name.replace("#", "_")
|
||||
return f"{ATTACHMENT_SCHEME}{stored}#{safe_name}"
|
||||
|
||||
def path_for(self, stored: str) -> Path:
|
||||
"""Resolve a stored name (`<sha256>.<ext>`) to its file. The strict name
|
||||
check is the traversal guard — nothing else reaches the filesystem."""
|
||||
stored = validate_stored_name(stored)
|
||||
path = self.root / stored
|
||||
if not path.exists():
|
||||
raise BoardNotFoundError("attachment not found")
|
||||
return path
|
||||
|
||||
def mime_for(self, stored: str) -> str:
|
||||
return _IMAGE_TYPES.get(stored.rsplit(".", 1)[-1], "application/octet-stream")
|
||||
|
||||
|
||||
def stored_name(ref: str) -> Optional[str]:
|
||||
"""`attachment://<hash>.<ext>#<name>` → `<hash>.<ext>`; None for other refs."""
|
||||
if not ref.startswith(ATTACHMENT_SCHEME):
|
||||
return None
|
||||
return ref[len(ATTACHMENT_SCHEME):].split("#", 1)[0]
|
||||
|
||||
|
||||
def validate_stored_name(stored: str) -> str:
|
||||
"""Return one normalized stored name, rejecting malformed input."""
|
||||
stored = stored.strip()
|
||||
if not _STORED_NAME.fullmatch(stored):
|
||||
raise BoardError(f"not an attachment name: {stored!r}")
|
||||
return stored
|
||||
|
||||
|
||||
def _validate(data: bytes, filename: str) -> str:
|
||||
if not data:
|
||||
raise BoardError("attachment is empty")
|
||||
if len(data) > MAX_ATTACHMENT_BYTES:
|
||||
raise BoardError(
|
||||
f"attachment exceeds {MAX_ATTACHMENT_BYTES // (1024 * 1024)}MB"
|
||||
)
|
||||
ext = Path(filename).suffix.lstrip(".").lower()
|
||||
if ext not in _IMAGE_TYPES:
|
||||
raise BoardError(
|
||||
f"unsupported attachment type .{ext or '?'} — images only for now"
|
||||
f" ({', '.join(sorted(set(_IMAGE_TYPES)))})"
|
||||
)
|
||||
if not data.startswith(_MAGIC[ext]) or (
|
||||
ext == "webp" and data[8:12] != b"WEBP"
|
||||
):
|
||||
raise BoardError(f"file content does not look like .{ext}")
|
||||
return "jpg" if ext == "jpeg" else ext
|
||||
215
coworker/teams/chat.py
Normal file
215
coworker/teams/chat.py
Normal file
@@ -0,0 +1,215 @@
|
||||
"""The chat store — group chat as its own abstraction (eighth pass, 2026-08-16).
|
||||
|
||||
A GROUP is `{group_id, name, members[]}` plus an append-only message log and
|
||||
per-member unread cursors. One group per team in v1 (created at the staffing gate
|
||||
when chat is enabled), but nothing here knows about boards or teams — groups can
|
||||
later serve non-team chats and the external-chat dialect.
|
||||
|
||||
Wake semantics live in the read side: an agent post is "for" exactly its @mentioned
|
||||
members; a USER post is for every member ([User] outranks — posting to the channel
|
||||
is rare and deliberate). Un-mentioned agent chatter wakes nobody, which is what
|
||||
keeps chat an exception channel structurally.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from .model import BoardError
|
||||
|
||||
|
||||
class ChatStore:
|
||||
def __init__(self, db_path: str | Path) -> None:
|
||||
self.db_path = str(db_path)
|
||||
if self.db_path != ":memory:":
|
||||
Path(self.db_path).expanduser().parent.mkdir(parents=True, exist_ok=True)
|
||||
self._lock = threading.RLock()
|
||||
self._conn = sqlite3.connect(self.db_path, check_same_thread=False)
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
self._conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS chat_groups (
|
||||
group_id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
members TEXT NOT NULL,
|
||||
created_ts TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS chat_messages (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
group_id TEXT NOT NULL,
|
||||
ts TEXT NOT NULL,
|
||||
author TEXT NOT NULL,
|
||||
author_role TEXT NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
mentions TEXT NOT NULL DEFAULT '[]'
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_group ON chat_messages (group_id, seq);
|
||||
CREATE TABLE IF NOT EXISTS chat_cursors (
|
||||
cursor_key TEXT PRIMARY KEY,
|
||||
read_seq INTEGER NOT NULL
|
||||
);
|
||||
""")
|
||||
self._conn.commit()
|
||||
|
||||
# ---------------------------------------------------------------------- groups
|
||||
|
||||
def create_group(self, name: str, members: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""`members`: [{name, persona, role}] — `name` is the member's handle
|
||||
(@mention target). The user participates implicitly and is not a member row."""
|
||||
handles = [str(m.get("name", "")).strip() for m in members]
|
||||
if not name.strip():
|
||||
raise BoardError("group name is required")
|
||||
if not all(handles) or len(set(handles)) != len(handles):
|
||||
raise BoardError("every member needs a unique name")
|
||||
group = {
|
||||
"group_id": uuid.uuid4().hex[:12],
|
||||
"name": name.strip(),
|
||||
"members": [
|
||||
{
|
||||
"name": str(m.get("name")),
|
||||
"persona": str(m.get("persona", "")),
|
||||
"role": str(m.get("role", "worker")),
|
||||
}
|
||||
for m in members
|
||||
],
|
||||
"created_ts": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
with self._lock:
|
||||
self._conn.execute(
|
||||
"INSERT INTO chat_groups (group_id, name, members, created_ts)"
|
||||
" VALUES (?, ?, ?, ?)",
|
||||
(
|
||||
group["group_id"],
|
||||
group["name"],
|
||||
json.dumps(group["members"]),
|
||||
group["created_ts"],
|
||||
),
|
||||
)
|
||||
self._conn.commit()
|
||||
return group
|
||||
|
||||
def get_group(self, group_id: str) -> Optional[dict[str, Any]]:
|
||||
with self._lock:
|
||||
row = self._conn.execute(
|
||||
"SELECT * FROM chat_groups WHERE group_id = ?", (group_id,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
group = dict(row)
|
||||
group["members"] = json.loads(group.pop("members") or "[]")
|
||||
return group
|
||||
|
||||
# -------------------------------------------------------------------- messages
|
||||
|
||||
def post(
|
||||
self, group_id: str, author: str, text: str, *, author_role: str = "worker"
|
||||
) -> dict[str, Any]:
|
||||
"""Append one message. Mentions are parsed against member handles —
|
||||
`@name` anywhere in the text — so tagging needs no separate parameter."""
|
||||
group = self.get_group(group_id)
|
||||
if group is None:
|
||||
raise BoardError(f"no chat group '{group_id}'")
|
||||
if not (text or "").strip():
|
||||
raise BoardError("message text is required")
|
||||
handles = {m["name"] for m in group["members"]}
|
||||
mentions = sorted(
|
||||
{
|
||||
m.group(1)
|
||||
for m in re.finditer(r"@([\w.-]+)", text)
|
||||
if m.group(1) in handles
|
||||
}
|
||||
)
|
||||
message = {
|
||||
"group_id": group_id,
|
||||
"ts": datetime.now(timezone.utc).isoformat(),
|
||||
"author": author,
|
||||
"author_role": author_role,
|
||||
"text": text,
|
||||
"mentions": mentions,
|
||||
}
|
||||
with self._lock:
|
||||
cursor = self._conn.execute(
|
||||
"INSERT INTO chat_messages"
|
||||
" (group_id, ts, author, author_role, text, mentions)"
|
||||
" VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
group_id,
|
||||
message["ts"],
|
||||
author,
|
||||
author_role,
|
||||
text,
|
||||
json.dumps(mentions),
|
||||
),
|
||||
)
|
||||
self._conn.commit()
|
||||
return {**message, "seq": cursor.lastrowid}
|
||||
|
||||
def messages(
|
||||
self, group_id: str, *, since_seq: int = 0, limit: int = 200
|
||||
) -> list[dict[str, Any]]:
|
||||
with self._lock:
|
||||
rows = self._conn.execute(
|
||||
"SELECT * FROM chat_messages WHERE group_id = ? AND seq > ?"
|
||||
" ORDER BY seq LIMIT ?",
|
||||
(group_id, since_seq, max(1, min(int(limit or 200), 2000))),
|
||||
).fetchall()
|
||||
return [_row_to_message(row) for row in rows]
|
||||
|
||||
# ------------------------------------------------------- unread / wake reads
|
||||
|
||||
def unread_for(self, group_id: str, member: str) -> list[dict[str, Any]]:
|
||||
"""Messages this member should be WOKEN for: posts that @mention it, plus
|
||||
every user post. Its own posts never count."""
|
||||
out = []
|
||||
for message in self.messages(group_id, since_seq=self._cursor(group_id, member)):
|
||||
if message["author"] == member:
|
||||
continue
|
||||
if member in message["mentions"] or message["author_role"] == "user":
|
||||
out.append(message)
|
||||
return out
|
||||
|
||||
def unread_count(self, group_id: str, member: str) -> int:
|
||||
"""Plain unread count (all messages since the member's cursor) — drives the
|
||||
sidebar badge for the USER, whose 'member' key is "user"."""
|
||||
with self._lock:
|
||||
row = self._conn.execute(
|
||||
"SELECT COUNT(*) AS n FROM chat_messages WHERE group_id = ?"
|
||||
" AND seq > ? AND author != ?",
|
||||
(group_id, self._cursor(group_id, member), member),
|
||||
).fetchone()
|
||||
return int(row["n"])
|
||||
|
||||
def consume(self, group_id: str, member: str, upto_seq: int) -> None:
|
||||
with self._lock:
|
||||
self._conn.execute(
|
||||
"INSERT INTO chat_cursors (cursor_key, read_seq) VALUES (?, ?)"
|
||||
" ON CONFLICT(cursor_key) DO UPDATE SET read_seq ="
|
||||
" MAX(read_seq, ?)",
|
||||
(f"{group_id}:{member}", int(upto_seq), int(upto_seq)),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def close(self) -> None:
|
||||
self._conn.close()
|
||||
|
||||
def _cursor(self, group_id: str, member: str) -> int:
|
||||
row = self._conn.execute(
|
||||
"SELECT read_seq FROM chat_cursors WHERE cursor_key = ?",
|
||||
(f"{group_id}:{member}",),
|
||||
).fetchone()
|
||||
return int(row["read_seq"]) if row else 0
|
||||
|
||||
|
||||
def _row_to_message(row: sqlite3.Row) -> dict[str, Any]:
|
||||
message = dict(row)
|
||||
try:
|
||||
message["mentions"] = json.loads(message.get("mentions") or "[]")
|
||||
except json.JSONDecodeError:
|
||||
message["mentions"] = []
|
||||
return message
|
||||
504
coworker/teams/cli.py
Normal file
504
coworker/teams/cli.py
Normal file
@@ -0,0 +1,504 @@
|
||||
"""`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(_space(args), 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())
|
||||
560
coworker/teams/dialect.py
Normal file
560
coworker/teams/dialect.py
Normal file
@@ -0,0 +1,560 @@
|
||||
"""The BoardDialect seam — where "a board" stops meaning "our SQLite file".
|
||||
|
||||
A dialect is where the board of record LIVES, seen from a client's chair:
|
||||
- LocalDialect: this machine's TeamStore/JournalStore, direct SQLite. For the
|
||||
standalone/headless case where the caller is the only writer.
|
||||
- RemoteDialect: one wire protocol (the `/v1/board` HTTP API) to a board served
|
||||
elsewhere — the running OpenWorker sidecar on this machine, a teammate's machine,
|
||||
or a hosted board service later. Identity rides the token; the server binds it to
|
||||
an actor+role and the store enforces authority, so a remote client is safe by
|
||||
construction.
|
||||
|
||||
External trackers (Jira/Linear) are deliberately NOT dialects: making a pre-LLM
|
||||
tracker the board of record means contorting our state machine and delivery cursors
|
||||
onto its API. They join as MIRRORS instead — one more subscriber with a cursor over
|
||||
the append-only event log, replaying events outward (decided 2026-08-16). The board
|
||||
stays the abstraction and the source of truth.
|
||||
|
||||
Every front door — the `team-board` MCP server, the `ocw` CLI, remote OpenWorker
|
||||
instances — bottoms out in this one verb surface. Dialect instances are
|
||||
identity-bound: one actor per instance, matching the one-identity-per-process shape
|
||||
of an external harness.
|
||||
|
||||
Cross-process write safety: the store's hash-chain append is read-head-then-write
|
||||
under an in-process lock, so two processes must never write one SQLite file
|
||||
directly. Rule: when a server is up, clients go remote; LocalDialect is for the
|
||||
headless case where this process is the only writer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional, Protocol
|
||||
|
||||
from .journal import JournalStore
|
||||
from .model import Actor, BoardError, Role
|
||||
from .store import TeamStore
|
||||
|
||||
|
||||
class BoardDialect(Protocol):
|
||||
"""The verb surface a board client sees, identity already bound."""
|
||||
|
||||
def whoami(self) -> dict[str, Any]: ...
|
||||
def spaces(self) -> list[str]: ...
|
||||
def list_items(
|
||||
self,
|
||||
space: str,
|
||||
*,
|
||||
state: Optional[str] = None,
|
||||
assignee: Optional[str] = None,
|
||||
) -> list[dict[str, Any]]: ...
|
||||
def get_item(self, space: str, item_id: int) -> dict[str, Any]: ...
|
||||
def create_item(
|
||||
self,
|
||||
space: str,
|
||||
*,
|
||||
title: str,
|
||||
criteria: str,
|
||||
description: str = "",
|
||||
parent: Optional[int] = None,
|
||||
case: Optional[str] = None,
|
||||
) -> dict[str, Any]: ...
|
||||
def transition(
|
||||
self,
|
||||
space: str,
|
||||
item_id: int,
|
||||
to: str,
|
||||
*,
|
||||
comment: str = "",
|
||||
refs: Optional[list[str]] = None,
|
||||
) -> dict[str, Any]: ...
|
||||
def comment(
|
||||
self,
|
||||
space: str,
|
||||
item_id: int,
|
||||
body: str,
|
||||
*,
|
||||
refs: Optional[list[str]] = None,
|
||||
) -> dict[str, Any]: ...
|
||||
def assign(self, space: str, item_id: int, assignee: str) -> dict[str, Any]: ...
|
||||
def claim(self, space: str, item_id: int) -> dict[str, Any]: ...
|
||||
def link(self, space: str, src: int, kind: str, dst: int) -> dict[str, Any]: ...
|
||||
def attach(
|
||||
self,
|
||||
space: str,
|
||||
item_id: int,
|
||||
data: bytes,
|
||||
filename: str,
|
||||
*,
|
||||
caption: str = "",
|
||||
) -> dict[str, Any]: ...
|
||||
def attachment(self, space: str, stored: str) -> tuple[bytes, str]:
|
||||
"""Read a blob referenced by an actor-visible item in ``space``."""
|
||||
...
|
||||
def policy(self, space: str) -> dict[str, Any]: ...
|
||||
def set_policy(self, space: str, *, claims: str) -> dict[str, Any]: ...
|
||||
def pending(self, space: str, *, limit: int = 200) -> list[dict[str, Any]]: ...
|
||||
def consume(self, space: str, upto_seq: int) -> None: ...
|
||||
def journal_append(
|
||||
self,
|
||||
case: str,
|
||||
body: str,
|
||||
*,
|
||||
kind: str = "note",
|
||||
space: Optional[str] = None,
|
||||
item: Optional[int] = None,
|
||||
entities: Optional[list[str]] = None,
|
||||
refs: Optional[list[str]] = None,
|
||||
) -> dict[str, Any]: ...
|
||||
def journal_read(
|
||||
self,
|
||||
case: str,
|
||||
*,
|
||||
item: Optional[int] = None,
|
||||
author: Optional[str] = None,
|
||||
kind: Optional[str] = None,
|
||||
entity: Optional[str] = None,
|
||||
include_raw: bool = False,
|
||||
limit: int = 100,
|
||||
) -> list[dict[str, Any]]: ...
|
||||
def journal_overview(self) -> list[dict[str, Any]]: ...
|
||||
|
||||
|
||||
class LocalDialect:
|
||||
"""Direct store access, one bound identity. The headless/standalone backing."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store: TeamStore,
|
||||
journal: Optional[JournalStore],
|
||||
actor: Actor,
|
||||
*,
|
||||
attachments: Any = None,
|
||||
) -> None:
|
||||
self.store = store
|
||||
self.journal = journal
|
||||
self.actor = actor
|
||||
self.attachments = attachments
|
||||
|
||||
def whoami(self) -> dict[str, Any]:
|
||||
return {"actor": self.actor.id, "role": self.actor.role.value}
|
||||
|
||||
def spaces(self) -> list[str]:
|
||||
return self.store.spaces()
|
||||
|
||||
def list_items(
|
||||
self,
|
||||
space: str,
|
||||
*,
|
||||
state: Optional[str] = None,
|
||||
assignee: Optional[str] = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
return self.store.list_items(space, self.actor, state=state, assignee=assignee)
|
||||
|
||||
def get_item(self, space: str, item_id: int) -> dict[str, Any]:
|
||||
return self.store.get_item(space, item_id, actor=self.actor)
|
||||
|
||||
def create_item(
|
||||
self,
|
||||
space: str,
|
||||
*,
|
||||
title: str,
|
||||
criteria: str,
|
||||
description: str = "",
|
||||
parent: Optional[int] = None,
|
||||
case: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
return self.store.create_item(
|
||||
space,
|
||||
self.actor,
|
||||
title=title,
|
||||
criteria=criteria,
|
||||
description=description,
|
||||
parent=parent,
|
||||
case=case,
|
||||
)
|
||||
|
||||
def transition(
|
||||
self,
|
||||
space: str,
|
||||
item_id: int,
|
||||
to: str,
|
||||
*,
|
||||
comment: str = "",
|
||||
refs: Optional[list[str]] = None,
|
||||
) -> dict[str, Any]:
|
||||
return self.store.transition(
|
||||
space, self.actor, item_id, to, comment=comment, refs=refs
|
||||
)
|
||||
|
||||
def comment(
|
||||
self,
|
||||
space: str,
|
||||
item_id: int,
|
||||
body: str,
|
||||
*,
|
||||
refs: Optional[list[str]] = None,
|
||||
) -> dict[str, Any]:
|
||||
return self.store.comment(space, self.actor, item_id, body, refs=refs)
|
||||
|
||||
def assign(self, space: str, item_id: int, assignee: str) -> dict[str, Any]:
|
||||
return self.store.assign(space, self.actor, item_id, assignee)
|
||||
|
||||
def claim(self, space: str, item_id: int) -> dict[str, Any]:
|
||||
return self.store.claim(space, self.actor, item_id)
|
||||
|
||||
def link(self, space: str, src: int, kind: str, dst: int) -> dict[str, Any]:
|
||||
return self.store.link(space, self.actor, src, kind, dst)
|
||||
|
||||
def attach(
|
||||
self,
|
||||
space: str,
|
||||
item_id: int,
|
||||
data: bytes,
|
||||
filename: str,
|
||||
*,
|
||||
caption: str = "",
|
||||
) -> dict[str, Any]:
|
||||
# Attach = store blob + an attributed attachment-comment event. Comment
|
||||
# authority IS attach authority (workers attach on their slice only).
|
||||
if self.attachments is None:
|
||||
raise BoardError("no attachment store is attached to this board")
|
||||
ref = self.attachments.put(data, filename)
|
||||
return self.store.attach_ref(
|
||||
space,
|
||||
self.actor,
|
||||
item_id,
|
||||
caption or f"attached {filename}",
|
||||
ref,
|
||||
)
|
||||
|
||||
def attachment(self, space: str, stored: str) -> tuple[bytes, str]:
|
||||
if self.attachments is None:
|
||||
raise BoardError("no attachment store is attached to this board")
|
||||
self.store.require_attachment_access(space, self.actor, stored)
|
||||
path = self.attachments.path_for(stored)
|
||||
return path.read_bytes(), self.attachments.mime_for(stored)
|
||||
|
||||
def policy(self, space: str) -> dict[str, Any]:
|
||||
return self.store.policy(space)
|
||||
|
||||
def set_policy(self, space: str, *, claims: str) -> dict[str, Any]:
|
||||
return self.store.set_policy(space, self.actor, claims=claims)
|
||||
|
||||
def pending(self, space: str, *, limit: int = 200) -> list[dict[str, Any]]:
|
||||
return self.store.feed_for(space, self.actor.id, limit=limit)
|
||||
|
||||
def consume(self, space: str, upto_seq: int) -> None:
|
||||
self.store.consume_feed(space, self.actor.id, int(upto_seq))
|
||||
|
||||
def journal_append(
|
||||
self,
|
||||
case: str,
|
||||
body: str,
|
||||
*,
|
||||
kind: str = "note",
|
||||
space: Optional[str] = None,
|
||||
item: Optional[int] = None,
|
||||
entities: Optional[list[str]] = None,
|
||||
refs: Optional[list[str]] = None,
|
||||
) -> dict[str, Any]:
|
||||
self._need_journal()
|
||||
return self.journal.append(
|
||||
self.actor,
|
||||
case,
|
||||
body,
|
||||
kind=kind,
|
||||
space=space,
|
||||
item=item,
|
||||
entities=entities,
|
||||
refs=refs,
|
||||
)
|
||||
|
||||
def journal_read(
|
||||
self,
|
||||
case: str,
|
||||
*,
|
||||
item: Optional[int] = None,
|
||||
author: Optional[str] = None,
|
||||
kind: Optional[str] = None,
|
||||
entity: Optional[str] = None,
|
||||
include_raw: bool = False,
|
||||
limit: int = 100,
|
||||
) -> list[dict[str, Any]]:
|
||||
self._need_journal()
|
||||
return self.journal.read(
|
||||
self.actor,
|
||||
case,
|
||||
item=item,
|
||||
author=author,
|
||||
kind=kind,
|
||||
entity=entity,
|
||||
include_raw=include_raw,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
def journal_overview(self) -> list[dict[str, Any]]:
|
||||
self._need_journal()
|
||||
return self.journal.overview(self.actor)
|
||||
|
||||
def _need_journal(self) -> None:
|
||||
if self.journal is None:
|
||||
raise BoardError("no journal store is attached to this board")
|
||||
|
||||
|
||||
class RemoteDialect:
|
||||
"""The `/v1/board` HTTP client. `base_url` is an OpenWorker sidecar or a hosted
|
||||
board service; the Bearer token carries identity — the server resolves it to an
|
||||
actor+role, so this client never states who it is, it proves it."""
|
||||
|
||||
def __init__(
|
||||
self, base_url: str, token: str, *, client: Any = None, timeout: float = 30.0
|
||||
) -> None:
|
||||
import httpx
|
||||
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self._client = client or httpx.Client(
|
||||
base_url=self.base_url,
|
||||
timeout=timeout,
|
||||
)
|
||||
self._client.headers["Authorization"] = f"Bearer {token}"
|
||||
|
||||
# -- plumbing --------------------------------------------------------------
|
||||
|
||||
def _get(self, path: str, params: Optional[dict] = None) -> Any:
|
||||
response = self._client.get(
|
||||
path, params={k: v for k, v in (params or {}).items() if v is not None}
|
||||
)
|
||||
return self._unwrap(response)
|
||||
|
||||
def _post(self, path: str, body: dict) -> Any:
|
||||
response = self._client.post(
|
||||
path, json={k: v for k, v in body.items() if v is not None}
|
||||
)
|
||||
return self._unwrap(response)
|
||||
|
||||
@staticmethod
|
||||
def _unwrap(response: Any) -> Any:
|
||||
if response.status_code == 401:
|
||||
raise BoardError("board token was not accepted (401) — mint one with"
|
||||
" `ocw board token` on the serving machine")
|
||||
try:
|
||||
data = response.json()
|
||||
except ValueError:
|
||||
data = {}
|
||||
if response.status_code >= 400:
|
||||
raise BoardError(
|
||||
str(data.get("error") or data.get("detail") or response.text)
|
||||
)
|
||||
return data
|
||||
|
||||
# -- verbs -----------------------------------------------------------------
|
||||
|
||||
def whoami(self) -> dict[str, Any]:
|
||||
return self._get("/v1/board/whoami")
|
||||
|
||||
def spaces(self) -> list[str]:
|
||||
return self._get("/v1/board/spaces")["spaces"]
|
||||
|
||||
def list_items(
|
||||
self,
|
||||
space: str,
|
||||
*,
|
||||
state: Optional[str] = None,
|
||||
assignee: Optional[str] = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
return self._get(
|
||||
"/v1/board/items",
|
||||
{"space": space, "state": state, "assignee": assignee},
|
||||
)["items"]
|
||||
|
||||
def get_item(self, space: str, item_id: int) -> dict[str, Any]:
|
||||
return self._get("/v1/board/item", {"space": space, "id": item_id})
|
||||
|
||||
def create_item(
|
||||
self,
|
||||
space: str,
|
||||
*,
|
||||
title: str,
|
||||
criteria: str,
|
||||
description: str = "",
|
||||
parent: Optional[int] = None,
|
||||
case: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
return self._post(
|
||||
"/v1/board/items",
|
||||
{
|
||||
"space": space,
|
||||
"title": title,
|
||||
"criteria": criteria,
|
||||
"description": description,
|
||||
"parent": parent,
|
||||
"case": case,
|
||||
},
|
||||
)
|
||||
|
||||
def transition(
|
||||
self,
|
||||
space: str,
|
||||
item_id: int,
|
||||
to: str,
|
||||
*,
|
||||
comment: str = "",
|
||||
refs: Optional[list[str]] = None,
|
||||
) -> dict[str, Any]:
|
||||
return self._post(
|
||||
"/v1/board/items/transition",
|
||||
{
|
||||
"space": space,
|
||||
"id": item_id,
|
||||
"to": to,
|
||||
"comment": comment,
|
||||
"refs": refs or [],
|
||||
},
|
||||
)
|
||||
|
||||
def comment(
|
||||
self,
|
||||
space: str,
|
||||
item_id: int,
|
||||
body: str,
|
||||
*,
|
||||
refs: Optional[list[str]] = None,
|
||||
) -> dict[str, Any]:
|
||||
return self._post(
|
||||
"/v1/board/items/comment",
|
||||
{"space": space, "id": item_id, "body": body, "refs": refs or []},
|
||||
)
|
||||
|
||||
def assign(self, space: str, item_id: int, assignee: str) -> dict[str, Any]:
|
||||
return self._post(
|
||||
"/v1/board/items/assign",
|
||||
{"space": space, "id": item_id, "assignee": assignee},
|
||||
)
|
||||
|
||||
def claim(self, space: str, item_id: int) -> dict[str, Any]:
|
||||
return self._post("/v1/board/items/claim", {"space": space, "id": item_id})
|
||||
|
||||
def link(self, space: str, src: int, kind: str, dst: int) -> dict[str, Any]:
|
||||
return self._post(
|
||||
"/v1/board/link", {"space": space, "src": src, "kind": kind, "dst": dst}
|
||||
)
|
||||
|
||||
def attach(
|
||||
self,
|
||||
space: str,
|
||||
item_id: int,
|
||||
data: bytes,
|
||||
filename: str,
|
||||
*,
|
||||
caption: str = "",
|
||||
) -> dict[str, Any]:
|
||||
import base64
|
||||
|
||||
return self._post(
|
||||
"/v1/board/items/attach",
|
||||
{
|
||||
"space": space,
|
||||
"id": item_id,
|
||||
"filename": filename,
|
||||
"caption": caption,
|
||||
"data_b64": base64.b64encode(data).decode("ascii"),
|
||||
},
|
||||
)
|
||||
|
||||
def attachment(self, space: str, stored: str) -> tuple[bytes, str]:
|
||||
response = self._client.get(
|
||||
"/v1/board/attachment", params={"space": space, "name": stored}
|
||||
)
|
||||
if response.status_code >= 400:
|
||||
self._unwrap(response) # raises with the server's message
|
||||
return response.content, response.headers.get(
|
||||
"content-type", "application/octet-stream"
|
||||
)
|
||||
|
||||
def policy(self, space: str) -> dict[str, Any]:
|
||||
return self._get("/v1/board/policy", {"space": space})
|
||||
|
||||
def set_policy(self, space: str, *, claims: str) -> dict[str, Any]:
|
||||
return self._post("/v1/board/policy", {"space": space, "claims": claims})
|
||||
|
||||
def pending(self, space: str, *, limit: int = 200) -> list[dict[str, Any]]:
|
||||
return self._get("/v1/board/pending", {"space": space, "limit": limit})[
|
||||
"events"
|
||||
]
|
||||
|
||||
def consume(self, space: str, upto_seq: int) -> None:
|
||||
self._post("/v1/board/consume", {"space": space, "upto_seq": int(upto_seq)})
|
||||
|
||||
def journal_append(
|
||||
self,
|
||||
case: str,
|
||||
body: str,
|
||||
*,
|
||||
kind: str = "note",
|
||||
space: Optional[str] = None,
|
||||
item: Optional[int] = None,
|
||||
entities: Optional[list[str]] = None,
|
||||
refs: Optional[list[str]] = None,
|
||||
) -> dict[str, Any]:
|
||||
return self._post(
|
||||
"/v1/board/journal",
|
||||
{
|
||||
"case": case,
|
||||
"body": body,
|
||||
"kind": kind,
|
||||
"space": space,
|
||||
"item": item,
|
||||
"entities": entities or [],
|
||||
"refs": refs or [],
|
||||
},
|
||||
)
|
||||
|
||||
def journal_read(
|
||||
self,
|
||||
case: str,
|
||||
*,
|
||||
item: Optional[int] = None,
|
||||
author: Optional[str] = None,
|
||||
kind: Optional[str] = None,
|
||||
entity: Optional[str] = None,
|
||||
include_raw: bool = False,
|
||||
limit: int = 100,
|
||||
) -> list[dict[str, Any]]:
|
||||
return self._get(
|
||||
"/v1/board/journal",
|
||||
{
|
||||
"case": case,
|
||||
"item": item,
|
||||
"author": author,
|
||||
"kind": kind,
|
||||
"entity": entity,
|
||||
"include_raw": "1" if include_raw else None,
|
||||
"limit": limit,
|
||||
},
|
||||
)["entries"]
|
||||
|
||||
def journal_overview(self) -> list[dict[str, Any]]:
|
||||
return self._get("/v1/board/journal/cases")["cases"]
|
||||
|
||||
def close(self) -> None:
|
||||
self._client.close()
|
||||
|
||||
|
||||
def local_dialect(
|
||||
db_dir, *, actor: str = "user", role: str = "user"
|
||||
) -> LocalDialect:
|
||||
"""Open the state dir's stores directly as one bound identity — the headless
|
||||
backing for the CLI and MCP server when no OpenWorker server is running."""
|
||||
from pathlib import Path
|
||||
|
||||
from .attachments import AttachmentStore
|
||||
|
||||
base = Path(db_dir).expanduser()
|
||||
journal = JournalStore(base / "journal.db")
|
||||
store = TeamStore(base / "teams.db", journal=journal)
|
||||
return LocalDialect(
|
||||
store,
|
||||
journal,
|
||||
Actor(id=actor, role=Role(role)),
|
||||
attachments=AttachmentStore(base / "attachments"),
|
||||
)
|
||||
434
coworker/teams/journal.py
Normal file
434
coworker/teams/journal.py
Normal file
@@ -0,0 +1,434 @@
|
||||
"""The journal store — case-keyed knowledge that outlives boards and teams.
|
||||
|
||||
Split from the board log on purpose (decided 2026-08-16): a board is a team-scoped
|
||||
artifact and can be archived with its team, but a journal case follows the
|
||||
INVESTIGATION — it may span two boards, survive a team, or belong to an Ops case no
|
||||
board ever references. So cases live in their own store, hash-chained per case, with
|
||||
their own grant table. What stays unified with the board is the record shape and the
|
||||
discipline: attributed, timestamped, append-only, taint-flagged — the policy/audit
|
||||
choke point is the API layer, not table co-location.
|
||||
|
||||
Access model: the user is never gated. Everyone else needs a grant on the case:
|
||||
- creating a case (first append) grants its creator;
|
||||
- assignment feeds grants automatically (assign an item carrying a case → the
|
||||
assignee gains it; reassignment moves it) — "sharing rides assignment";
|
||||
- explicit grants cover cross-team sharing.
|
||||
|
||||
Backing is SQLite for now (same as everything else in the state dir); the store is
|
||||
deliberately small enough to swap the backing later without touching the verb
|
||||
surface. Retrieval order stays: filters (here) → entity index → vectors as a
|
||||
derived index.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from .model import (
|
||||
JOURNAL_BODY_LIMIT,
|
||||
JOURNAL_KINDS,
|
||||
Actor,
|
||||
AuthorityError,
|
||||
BoardError,
|
||||
ChainError,
|
||||
Role,
|
||||
)
|
||||
from .store import GENESIS, _canonical, _hash
|
||||
|
||||
_HASHED_FIELDS = (
|
||||
"ts",
|
||||
"case_id",
|
||||
"kind",
|
||||
"actor",
|
||||
"actor_role",
|
||||
"space",
|
||||
"item_id",
|
||||
"payload",
|
||||
"taint",
|
||||
"prev_hash",
|
||||
)
|
||||
|
||||
|
||||
class JournalStore:
|
||||
def __init__(self, db_path: str | Path) -> None:
|
||||
self.db_path = str(db_path)
|
||||
if self.db_path != ":memory:":
|
||||
Path(self.db_path).expanduser().parent.mkdir(parents=True, exist_ok=True)
|
||||
self._lock = threading.RLock()
|
||||
self._conn = sqlite3.connect(self.db_path, check_same_thread=False)
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
self._conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS journal_entries (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts TEXT NOT NULL,
|
||||
case_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
actor TEXT NOT NULL,
|
||||
actor_role TEXT NOT NULL,
|
||||
persona TEXT DEFAULT '',
|
||||
model TEXT DEFAULT '',
|
||||
session_id TEXT DEFAULT '',
|
||||
space TEXT,
|
||||
item_id INTEGER,
|
||||
payload TEXT NOT NULL,
|
||||
taint INTEGER NOT NULL DEFAULT 0,
|
||||
prev_hash TEXT NOT NULL,
|
||||
hash TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_journal_case
|
||||
ON journal_entries (case_id, seq);
|
||||
CREATE INDEX IF NOT EXISTS idx_journal_item
|
||||
ON journal_entries (case_id, space, item_id, seq);
|
||||
CREATE TABLE IF NOT EXISTS journal_grants (
|
||||
case_id TEXT NOT NULL,
|
||||
principal TEXT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
space TEXT DEFAULT '',
|
||||
item_id INTEGER,
|
||||
UNIQUE (case_id, principal, source, space, item_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS journal_meta (
|
||||
case_id TEXT PRIMARY KEY,
|
||||
head_hash TEXT NOT NULL,
|
||||
created_ts TEXT NOT NULL
|
||||
);
|
||||
""")
|
||||
self._conn.commit()
|
||||
|
||||
# ---------------------------------------------------------------------- verbs
|
||||
|
||||
def append(
|
||||
self,
|
||||
actor: Actor,
|
||||
case: str,
|
||||
body: str,
|
||||
*,
|
||||
kind: str = "note",
|
||||
space: Optional[str] = None,
|
||||
item: Optional[int] = None,
|
||||
entities: Optional[list[str]] = None,
|
||||
refs: Optional[list[str]] = None,
|
||||
taint: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
if not (case or "").strip():
|
||||
raise BoardError("case is required")
|
||||
if not (body or "").strip():
|
||||
raise BoardError("entry body is required")
|
||||
if kind not in JOURNAL_KINDS:
|
||||
raise BoardError(f"unknown entry kind: {kind} (use one of {JOURNAL_KINDS})")
|
||||
if len(body) > JOURNAL_BODY_LIMIT:
|
||||
raise BoardError(
|
||||
f"entry body over {JOURNAL_BODY_LIMIT} chars — save the full"
|
||||
" capture to a file and journal an excerpt that references it"
|
||||
)
|
||||
with self._lock:
|
||||
exists = self._case_exists(case)
|
||||
if exists:
|
||||
self._check_access(actor, case)
|
||||
ts = datetime.now(timezone.utc).isoformat()
|
||||
prev = self._head_hash(case)
|
||||
record = {
|
||||
"ts": ts,
|
||||
"case_id": case,
|
||||
"kind": kind,
|
||||
"actor": actor.id,
|
||||
"actor_role": actor.role.value,
|
||||
"space": space,
|
||||
"item_id": item,
|
||||
"payload": _canonical(
|
||||
{
|
||||
"body": body,
|
||||
"entities": sorted(set(entities or [])),
|
||||
"refs": [str(ref) for ref in refs or []],
|
||||
}
|
||||
),
|
||||
"taint": 1 if taint else 0,
|
||||
"prev_hash": prev,
|
||||
}
|
||||
record["hash"] = _hash(record, fields=_HASHED_FIELDS)
|
||||
try:
|
||||
cursor = self._conn.execute(
|
||||
"""
|
||||
INSERT INTO journal_entries
|
||||
(ts, case_id, kind, actor, actor_role, persona, model,
|
||||
session_id, space, item_id, payload, taint, prev_hash, hash)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
ts,
|
||||
case,
|
||||
kind,
|
||||
actor.id,
|
||||
actor.role.value,
|
||||
actor.persona,
|
||||
actor.model,
|
||||
actor.session_id,
|
||||
space,
|
||||
item,
|
||||
record["payload"],
|
||||
record["taint"],
|
||||
prev,
|
||||
record["hash"],
|
||||
),
|
||||
)
|
||||
if not exists:
|
||||
self._conn.execute(
|
||||
"INSERT INTO journal_meta (case_id, head_hash, created_ts)"
|
||||
" VALUES (?, ?, ?)",
|
||||
(case, record["hash"], ts),
|
||||
)
|
||||
# A new case belongs to whoever opened it.
|
||||
self._grant_locked(case, actor.id, source="creator")
|
||||
else:
|
||||
self._conn.execute(
|
||||
"UPDATE journal_meta SET head_hash = ? WHERE case_id = ?",
|
||||
(record["hash"], case),
|
||||
)
|
||||
self._conn.commit()
|
||||
except Exception:
|
||||
self._conn.rollback()
|
||||
raise
|
||||
return {**record, "seq": cursor.lastrowid}
|
||||
|
||||
def read(
|
||||
self,
|
||||
actor: Actor,
|
||||
case: str,
|
||||
*,
|
||||
item: Optional[int] = None,
|
||||
author: Optional[str] = None,
|
||||
kind: Optional[str] = None,
|
||||
entity: Optional[str] = None,
|
||||
since_seq: int = 0,
|
||||
include_raw: bool = False,
|
||||
limit: int = 100,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Filtered read. `raw` captures are skipped unless asked for (by
|
||||
`kind="raw"` or `include_raw`) so dumps never bury the signal entries."""
|
||||
with self._lock:
|
||||
self._check_access(actor, case)
|
||||
where = ["case_id = ?", "seq > ?"]
|
||||
params: list[Any] = [case, since_seq]
|
||||
if item is not None:
|
||||
where.append("item_id = ?")
|
||||
params.append(item)
|
||||
if author:
|
||||
where.append("actor = ?")
|
||||
params.append(author)
|
||||
if kind:
|
||||
if kind not in JOURNAL_KINDS:
|
||||
raise BoardError(f"unknown entry kind: {kind}")
|
||||
where.append("kind = ?")
|
||||
params.append(kind)
|
||||
elif not include_raw:
|
||||
where.append("kind != 'raw'")
|
||||
rows = self._conn.execute(
|
||||
"SELECT * FROM journal_entries WHERE "
|
||||
+ " AND ".join(where)
|
||||
+ " ORDER BY seq",
|
||||
params,
|
||||
).fetchall()
|
||||
out = []
|
||||
cap = max(1, min(int(limit or 100), 1000))
|
||||
for row in rows:
|
||||
entry = _row_to_entry(row)
|
||||
if entity and entity not in entry["entities"]:
|
||||
continue
|
||||
out.append(entry)
|
||||
if len(out) >= cap:
|
||||
break
|
||||
return out
|
||||
|
||||
def overview(self, actor: Actor) -> list[dict[str, Any]]:
|
||||
"""Case list with entry counts and last activity — the rail's summary view."""
|
||||
visible = self.cases(actor)
|
||||
if not visible:
|
||||
return []
|
||||
with self._lock:
|
||||
rows = self._conn.execute(
|
||||
"SELECT case_id, COUNT(*) AS entries, MAX(ts) AS last_ts"
|
||||
" FROM journal_entries GROUP BY case_id"
|
||||
).fetchall()
|
||||
counts = {row["case_id"]: dict(row) for row in rows}
|
||||
return [
|
||||
{
|
||||
"case": case,
|
||||
"entries": counts.get(case, {}).get("entries", 0),
|
||||
"last_ts": counts.get(case, {}).get("last_ts") or "",
|
||||
}
|
||||
for case in visible
|
||||
]
|
||||
|
||||
def cases(self, actor: Actor) -> list[str]:
|
||||
"""Cases visible to this actor (all of them for the user)."""
|
||||
with self._lock:
|
||||
if actor.role == Role.USER:
|
||||
rows = self._conn.execute(
|
||||
"SELECT case_id FROM journal_meta ORDER BY case_id"
|
||||
).fetchall()
|
||||
else:
|
||||
rows = self._conn.execute(
|
||||
"SELECT DISTINCT case_id FROM journal_grants WHERE principal = ?"
|
||||
" ORDER BY case_id",
|
||||
(actor.id,),
|
||||
).fetchall()
|
||||
return [row["case_id"] for row in rows]
|
||||
|
||||
# ---------------------------------------------------------------------- grants
|
||||
|
||||
def grant(self, actor: Actor, case: str, principal: str) -> None:
|
||||
"""Explicit cross-team sharing. The user may grant any case; a lead may
|
||||
grant cases it holds. Workers never grant — evidence flows up, access
|
||||
flows down."""
|
||||
if actor.role == Role.WORKER or actor.role == Role.SYSTEM:
|
||||
raise AuthorityError("only the user or a lead may grant a case")
|
||||
with self._lock:
|
||||
if not self._case_exists(case):
|
||||
raise BoardError(f"no case '{case}'")
|
||||
if actor.role == Role.LEAD:
|
||||
self._check_access(actor, case)
|
||||
self._grant_locked(case, principal, source="grant")
|
||||
self._conn.commit()
|
||||
|
||||
def revoke(self, actor: Actor, case: str, principal: str) -> None:
|
||||
if actor.role == Role.WORKER or actor.role == Role.SYSTEM:
|
||||
raise AuthorityError("only the user or a lead may revoke a case grant")
|
||||
with self._lock:
|
||||
if actor.role == Role.LEAD:
|
||||
self._check_access(actor, case)
|
||||
self._conn.execute(
|
||||
"DELETE FROM journal_grants WHERE case_id = ? AND principal = ?"
|
||||
" AND source = 'grant'",
|
||||
(case, principal),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def ensure_case(self, case: str, creator: str) -> None:
|
||||
"""Create a case (empty, chain at genesis) if it doesn't exist, granting
|
||||
its creator. Called by the board when an item attaches a case ref — so
|
||||
the case belongs to whoever attached it, not to whichever assignee
|
||||
happens to journal first. Standalone cases (no board) are still created
|
||||
by their first append."""
|
||||
if not (case or "").strip():
|
||||
return
|
||||
with self._lock:
|
||||
if not self._case_exists(case):
|
||||
self._conn.execute(
|
||||
"INSERT INTO journal_meta (case_id, head_hash, created_ts)"
|
||||
" VALUES (?, ?, ?)",
|
||||
(case, GENESIS, datetime.now(timezone.utc).isoformat()),
|
||||
)
|
||||
self._grant_locked(case, creator, source="creator")
|
||||
self._conn.commit()
|
||||
|
||||
def sync_assignment(
|
||||
self,
|
||||
case: str,
|
||||
*,
|
||||
space: str,
|
||||
item_id: int,
|
||||
assignee: str,
|
||||
previous: str = "",
|
||||
) -> None:
|
||||
"""Called by the board on assign: access rides assignment. The previous
|
||||
assignee loses the grant THIS item carried (grants from its other items
|
||||
or explicit shares survive)."""
|
||||
if not case:
|
||||
return
|
||||
with self._lock:
|
||||
if previous:
|
||||
self._conn.execute(
|
||||
"DELETE FROM journal_grants WHERE case_id = ? AND principal = ?"
|
||||
" AND source = 'assignment' AND space = ? AND item_id = ?",
|
||||
(case, previous, space, item_id),
|
||||
)
|
||||
self._grant_locked(
|
||||
case, assignee, source="assignment", space=space, item_id=item_id
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
# ----------------------------------------------------------------- integrity
|
||||
|
||||
def verify_chain(self, case: str) -> int:
|
||||
with self._lock:
|
||||
rows = self._conn.execute(
|
||||
"SELECT * FROM journal_entries WHERE case_id = ? ORDER BY seq",
|
||||
(case,),
|
||||
).fetchall()
|
||||
prev = GENESIS
|
||||
for row in rows:
|
||||
record = {key: row[key] for key in _HASHED_FIELDS}
|
||||
if row["prev_hash"] != prev:
|
||||
raise ChainError(f"entry {row['seq']}: chain linkage broken")
|
||||
if _hash(record, fields=_HASHED_FIELDS) != row["hash"]:
|
||||
raise ChainError(f"entry {row['seq']}: content does not match hash")
|
||||
prev = row["hash"]
|
||||
# Tail truncation is invisible to the chain itself; the stored head sees it.
|
||||
if rows and prev != self._head_hash(case):
|
||||
raise ChainError("case log ends before the recorded head — tail deleted")
|
||||
return len(rows)
|
||||
|
||||
def close(self) -> None:
|
||||
self._conn.close()
|
||||
|
||||
# ------------------------------------------------------------------ internals
|
||||
|
||||
def _check_access(self, actor: Actor, case: str) -> None:
|
||||
if actor.role == Role.USER:
|
||||
return
|
||||
row = self._conn.execute(
|
||||
"SELECT 1 FROM journal_grants WHERE case_id = ? AND principal = ?"
|
||||
" LIMIT 1",
|
||||
(case, actor.id),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise AuthorityError(f"{actor.id} has no grant on case '{case}'")
|
||||
|
||||
def _grant_locked(
|
||||
self,
|
||||
case: str,
|
||||
principal: str,
|
||||
*,
|
||||
source: str,
|
||||
space: str = "",
|
||||
item_id: Optional[int] = None,
|
||||
) -> None:
|
||||
self._conn.execute(
|
||||
"INSERT OR IGNORE INTO journal_grants"
|
||||
" (case_id, principal, source, space, item_id) VALUES (?, ?, ?, ?, ?)",
|
||||
(case, principal, source, space, item_id),
|
||||
)
|
||||
|
||||
def _case_exists(self, case: str) -> bool:
|
||||
return (
|
||||
self._conn.execute(
|
||||
"SELECT 1 FROM journal_meta WHERE case_id = ?", (case,)
|
||||
).fetchone()
|
||||
is not None
|
||||
)
|
||||
|
||||
def _head_hash(self, case: str) -> str:
|
||||
row = self._conn.execute(
|
||||
"SELECT head_hash FROM journal_meta WHERE case_id = ?", (case,)
|
||||
).fetchone()
|
||||
return row["head_hash"] if row else GENESIS
|
||||
|
||||
|
||||
def _row_to_entry(row: sqlite3.Row) -> dict[str, Any]:
|
||||
entry = dict(row)
|
||||
try:
|
||||
payload = json.loads(entry.pop("payload") or "{}")
|
||||
except json.JSONDecodeError:
|
||||
payload = {}
|
||||
entry["body"] = payload.get("body")
|
||||
entry["entities"] = payload.get("entities") or []
|
||||
entry["refs"] = payload.get("refs") or []
|
||||
entry["author"] = entry.pop("actor")
|
||||
entry["role"] = entry.pop("actor_role")
|
||||
entry["item"] = entry.pop("item_id")
|
||||
return entry
|
||||
211
coworker/teams/mcp_server.py
Normal file
211
coworker/teams/mcp_server.py
Normal file
@@ -0,0 +1,211 @@
|
||||
"""`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_attach(item: int, path: str, caption: str = "") -> Any:
|
||||
"""Attach a screenshot or image (png/jpg/gif/webp, ≤10MB) from a local
|
||||
file to a work item — so the lead/reviewer can SEE what you did. Give it
|
||||
a caption saying what the image shows. Great with review hand-offs."""
|
||||
from pathlib import Path as _Path
|
||||
|
||||
source = _Path(path).expanduser()
|
||||
if not source.is_file():
|
||||
return {"error": f"no such file: {path}"}
|
||||
return _safe(
|
||||
dialect.attach,
|
||||
space,
|
||||
item,
|
||||
source.read_bytes(),
|
||||
source.name,
|
||||
caption=caption,
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
def board_pending() -> Any:
|
||||
"""Your unconsumed feed: every event on items assigned to you or filed
|
||||
by you — assignments, send-backs with feedback, comments from the lead
|
||||
or user, cancellations. Check at the start of a work session and before
|
||||
finishing; acknowledge with board_consume."""
|
||||
return _safe(dialect.pending, space)
|
||||
|
||||
@mcp.tool()
|
||||
def board_consume(upto_seq: int) -> Any:
|
||||
"""Acknowledge feed events up to a sequence number (from board_pending),
|
||||
so they are not re-delivered."""
|
||||
return _safe(lambda: (dialect.consume(space, 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")
|
||||
97
coworker/teams/model.py
Normal file
97
coworker/teams/model.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""Work-item model for agent teams — states, actors, links, errors.
|
||||
|
||||
The board is not a database of record: it is a projection of the append-only team
|
||||
event log (see teams.store). These are the shapes the projection folds into, and the
|
||||
rules the verbs enforce. Deliberately minimal — no sprints, estimates, priorities, or
|
||||
custom fields; anyone needing those graduates to a real tracker via connectors.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class ItemState(str, Enum):
|
||||
OPEN = "open"
|
||||
IN_PROGRESS = "in_progress"
|
||||
BLOCKED = "blocked"
|
||||
REVIEW = "review"
|
||||
DONE = "done"
|
||||
CANCELED = "canceled"
|
||||
|
||||
|
||||
# Legal edges of the state machine. There is NO draft/proposed state (decided
|
||||
# 2026-08-16): a plan proposal lives in the conversation (plan-approval flow) and
|
||||
# the board only ever contains accepted work — items are created `open`, and the
|
||||
# control point for work starting is ASSIGNMENT (a granted, revocable authority),
|
||||
# not a per-item approval. review→done stays the verification gate; canceled→open
|
||||
# is reopen.
|
||||
EDGES: dict[ItemState, set[ItemState]] = {
|
||||
ItemState.OPEN: {ItemState.IN_PROGRESS, ItemState.CANCELED},
|
||||
ItemState.IN_PROGRESS: {ItemState.BLOCKED, ItemState.REVIEW, ItemState.CANCELED},
|
||||
ItemState.BLOCKED: {ItemState.IN_PROGRESS, ItemState.CANCELED},
|
||||
ItemState.REVIEW: {ItemState.DONE, ItemState.IN_PROGRESS, ItemState.CANCELED},
|
||||
ItemState.DONE: set(),
|
||||
ItemState.CANCELED: {ItemState.OPEN},
|
||||
}
|
||||
|
||||
# Targets a worker may move its OWN item to. Workers never approve, never close:
|
||||
# done is the lead's verdict at review, cancel is a lead/user board decision.
|
||||
WORKER_TARGETS = {ItemState.IN_PROGRESS, ItemState.BLOCKED, ItemState.REVIEW}
|
||||
|
||||
|
||||
class Role(str, Enum):
|
||||
USER = "user"
|
||||
LEAD = "lead"
|
||||
WORKER = "worker"
|
||||
SYSTEM = "system"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Actor:
|
||||
"""Who is speaking to the board. `id` is the agent instance id ("user" for the
|
||||
human); role decides verb authority — the capability firebreak in data form."""
|
||||
|
||||
id: str
|
||||
role: Role
|
||||
persona: str = ""
|
||||
model: str = ""
|
||||
session_id: str = ""
|
||||
|
||||
|
||||
LINK_KINDS = ("parent", "blocks") # link(src, "parent", dst): dst is src's parent
|
||||
# link(src, "blocks", dst): src blocks dst
|
||||
|
||||
# `note` is any observation — the journal is not only for investigations. `raw` is
|
||||
# a capture (log excerpt, command output); reads skip raw unless asked, and large
|
||||
# payloads belong in a file the entry references.
|
||||
JOURNAL_KINDS = ("finding", "evidence", "decision", "note", "raw")
|
||||
|
||||
# An entry body is an excerpt/summary, never a blob: oversized payloads make every
|
||||
# read (and replay) drag. Full captures live as files the entry points at.
|
||||
JOURNAL_BODY_LIMIT = 16_000
|
||||
|
||||
|
||||
def space_for_workspace(workspace: str | Path) -> str:
|
||||
"""Spaces are keyed to the project/workspace (boards are views over a space).
|
||||
The resolved path is the one unambiguous local key; a display name is its
|
||||
basename."""
|
||||
return str(Path(workspace).expanduser().resolve())
|
||||
|
||||
|
||||
class BoardError(Exception):
|
||||
"""A verb call the board refuses — illegal transition, missing item, bad input."""
|
||||
|
||||
|
||||
class BoardNotFoundError(BoardError):
|
||||
"""A requested board object is missing or is not visible to the actor."""
|
||||
|
||||
|
||||
class AuthorityError(BoardError):
|
||||
"""The actor's role does not permit this verb on this item."""
|
||||
|
||||
|
||||
class ChainError(Exception):
|
||||
"""Hash-chain verification failed — the log was modified out of band."""
|
||||
140
coworker/teams/registry.py
Normal file
140
coworker/teams/registry.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""Team registry — which sessions form a team: one lead, its workers, their board.
|
||||
|
||||
A team is created at the staffing gate ("Create team & start"): worker sessions are
|
||||
PRE-SPAWNED as durable state on disk (spawn ≠ first turn — an unassigned worker costs
|
||||
zero tokens; its first model turn fires when the first assignment lands). The registry
|
||||
is the roster the wake plumbing walks each tick, and the tie that scopes staleness
|
||||
digests by role membership.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import uuid
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class TeamWorker:
|
||||
actor: str # the lead-given NAME — board actor id, assignee handle, @mention target
|
||||
persona: str
|
||||
session_id: str
|
||||
model: str = ""
|
||||
reason: str = "" # why the lead staffed it — surfaces in teammates' rosters
|
||||
|
||||
|
||||
@dataclass
|
||||
class Team:
|
||||
team_id: str
|
||||
space: str
|
||||
lead_session: str
|
||||
lead_actor: str
|
||||
workers: list[TeamWorker] = field(default_factory=list)
|
||||
chat_enabled: bool = False
|
||||
chat_group: str = "" # ChatStore group_id when chat is enabled
|
||||
paused: bool = False # budget/user pause: the wake gate skips a paused team
|
||||
created_at: str = field(
|
||||
default_factory=lambda: datetime.now(timezone.utc).isoformat()
|
||||
)
|
||||
# Rolling budget gate: automatic wakes this hour (reset when the hour rolls).
|
||||
wake_hour: str = ""
|
||||
wakes_this_hour: int = 0
|
||||
|
||||
|
||||
class TeamRegistry:
|
||||
def __init__(self, path: Optional[str | Path] = None) -> None:
|
||||
self.path = Path(path) if path else None
|
||||
self._lock = threading.Lock()
|
||||
self._teams: dict[str, Team] = {}
|
||||
if self.path and self.path.is_file():
|
||||
for raw in json.loads(self.path.read_text(encoding="utf-8")).get(
|
||||
"teams", []
|
||||
):
|
||||
workers = [TeamWorker(**w) for w in raw.pop("workers", [])]
|
||||
team = Team(**{**raw, "workers": []})
|
||||
team.workers = workers
|
||||
self._teams[team.team_id] = team
|
||||
|
||||
def _save(self) -> None:
|
||||
if not self.path:
|
||||
return
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.path.write_text(
|
||||
json.dumps(
|
||||
{"teams": [asdict(t) for t in self._teams.values()]}, indent=2
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def create(
|
||||
self,
|
||||
*,
|
||||
space: str,
|
||||
lead_session: str,
|
||||
lead_actor: str,
|
||||
workers: list[TeamWorker],
|
||||
chat_enabled: bool = False,
|
||||
chat_group: str = "",
|
||||
) -> Team:
|
||||
team = Team(
|
||||
team_id=uuid.uuid4().hex[:12],
|
||||
space=space,
|
||||
lead_session=lead_session,
|
||||
lead_actor=lead_actor,
|
||||
workers=workers,
|
||||
chat_enabled=chat_enabled,
|
||||
chat_group=chat_group,
|
||||
)
|
||||
with self._lock:
|
||||
self._teams[team.team_id] = team
|
||||
self._save()
|
||||
return team
|
||||
|
||||
def all(self) -> list[Team]:
|
||||
return list(self._teams.values())
|
||||
|
||||
def get(self, team_id: str) -> Optional[Team]:
|
||||
return self._teams.get(team_id)
|
||||
|
||||
def for_lead_session(self, session_id: str) -> Optional[Team]:
|
||||
for team in self._teams.values():
|
||||
if team.lead_session == session_id:
|
||||
return team
|
||||
return None
|
||||
|
||||
def for_worker_session(self, session_id: str) -> Optional[tuple[Team, TeamWorker]]:
|
||||
for team in self._teams.values():
|
||||
for worker in team.workers:
|
||||
if worker.session_id == session_id:
|
||||
return team, worker
|
||||
return None
|
||||
|
||||
def set_paused(self, team_id: str, paused: bool) -> None:
|
||||
with self._lock:
|
||||
team = self._teams.get(team_id)
|
||||
if team is not None:
|
||||
team.paused = paused
|
||||
self._save()
|
||||
|
||||
def count_wake(self, team_id: str, *, cap: int) -> bool:
|
||||
"""The budget gate at the wake gate: count one automatic wake against the
|
||||
team's rolling hour; False = over cap (the caller skips the wake and the
|
||||
team reads as paused-for-budget until the hour rolls). A runaway loop
|
||||
stops BETWEEN turns, never mid-flight."""
|
||||
hour = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H")
|
||||
with self._lock:
|
||||
team = self._teams.get(team_id)
|
||||
if team is None:
|
||||
return False
|
||||
if team.wake_hour != hour:
|
||||
team.wake_hour, team.wakes_this_hour = hour, 0
|
||||
if team.wakes_this_hour >= cap:
|
||||
self._save()
|
||||
return False
|
||||
team.wakes_this_hour += 1
|
||||
self._save()
|
||||
return True
|
||||
1290
coworker/teams/store.py
Normal file
1290
coworker/teams/store.py
Normal file
File diff suppressed because it is too large
Load Diff
97
coworker/teams/tokens.py
Normal file
97
coworker/teams/tokens.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""Board join tokens — identity for external board clients.
|
||||
|
||||
A token binds an ACTOR and a ROLE server-side: an external harness (another agent
|
||||
CLI, a headless OpenWorker, the `ocw` CLI from a second machine) presents the token
|
||||
and the server resolves who it is — the client never states its own identity, and a
|
||||
worker token cannot claim to be the lead. Authority then falls to the store, same
|
||||
as for in-app agents: the token is identity, the store is the gate.
|
||||
|
||||
Storage is hash-only (sha256): the plaintext is shown once at mint and never
|
||||
persisted, so the registry file leaking doesn't leak the credentials. Revocation is
|
||||
per-token, keyed by the display prefix.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import secrets
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from .model import Actor, Role
|
||||
|
||||
_TOKEN_PREFIX = "owb_" # OpenWorker board — greppable in configs, meaningless to guess
|
||||
|
||||
|
||||
class BoardTokens:
|
||||
def __init__(self, path: str | Path) -> None:
|
||||
self.path = Path(path).expanduser()
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def mint(self, actor: str, role: str = "worker", *, label: str = "") -> str:
|
||||
"""Create a token for one actor identity; returns the plaintext ONCE."""
|
||||
actor = (actor or "").strip()
|
||||
if not actor:
|
||||
raise ValueError("actor is required")
|
||||
Role(role) # validate early — a bad role should fail at mint, not at use
|
||||
token = _TOKEN_PREFIX + secrets.token_urlsafe(32)
|
||||
with self._lock:
|
||||
entries = self._load()
|
||||
entries[_digest(token)] = {
|
||||
"actor": actor,
|
||||
"role": role,
|
||||
"label": label,
|
||||
"prefix": token[:12],
|
||||
"created_ts": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
self._save(entries)
|
||||
return token
|
||||
|
||||
def resolve(self, token: str) -> Optional[Actor]:
|
||||
if not token:
|
||||
return None
|
||||
with self._lock:
|
||||
entry = self._load().get(_digest(token))
|
||||
if entry is None:
|
||||
return None
|
||||
return Actor(id=entry["actor"], role=Role(entry["role"]))
|
||||
|
||||
def entries(self) -> list[dict[str, Any]]:
|
||||
with self._lock:
|
||||
return sorted(self._load().values(), key=lambda e: e["created_ts"])
|
||||
|
||||
def revoke(self, prefix: str) -> int:
|
||||
"""Revoke every token whose display prefix matches; returns the count."""
|
||||
prefix = (prefix or "").strip()
|
||||
if not prefix:
|
||||
return 0
|
||||
with self._lock:
|
||||
entries = self._load()
|
||||
keep = {
|
||||
key: entry
|
||||
for key, entry in entries.items()
|
||||
if not entry["prefix"].startswith(prefix)
|
||||
}
|
||||
removed = len(entries) - len(keep)
|
||||
if removed:
|
||||
self._save(keep)
|
||||
return removed
|
||||
|
||||
def _load(self) -> dict[str, dict[str, Any]]:
|
||||
try:
|
||||
return json.loads(self.path.read_text())
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
def _save(self, entries: dict[str, dict[str, Any]]) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = self.path.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(entries, indent=2))
|
||||
tmp.replace(self.path)
|
||||
|
||||
|
||||
def _digest(token: str) -> str:
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
400
coworker/teams/tools.py
Normal file
400
coworker/teams/tools.py
Normal file
@@ -0,0 +1,400 @@
|
||||
"""Board and journal verbs as agent tools.
|
||||
|
||||
The verbs are generic on purpose (the connector-dialect play): the local TeamStore is
|
||||
the default backing, and a Jira/Linear-backed dialect can implement the same tool
|
||||
surface later. Registration is gated by the persona's `team:` trait — a lead gets the
|
||||
full set, a worker gets the worker set, solo personas get none of this.
|
||||
|
||||
The engine decides `taint` (whether this agent touched untrusted content this
|
||||
session) and passes it at construction — the model never self-reports provenance.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable, Optional
|
||||
|
||||
import aisuite as ai
|
||||
|
||||
from .journal import JournalStore
|
||||
from .model import Actor, BoardError, Role
|
||||
from .store import TeamStore
|
||||
|
||||
LEAD_VERBS = ("create_item", "list_items", "transition", "comment", "assign", "link")
|
||||
# Workers file items too (a bug spotted in passing, a follow-up) — new items land
|
||||
# `open` and unassigned; nothing runs until the item is assigned. `claim` is
|
||||
# self-assignment: on an open-claims board (the default) a worker may pick up an
|
||||
# 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")
|
||||
|
||||
# Explicit schema: the auto-generator's normalizer strips every `title` key to drop
|
||||
# pydantic metadata, which also deletes a PARAMETER named `title` from properties.
|
||||
# Registered via `__coworker_schema__` (same escape hatch as todo_write).
|
||||
_CREATE_ITEM_SCHEMA = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "create_item",
|
||||
"description": (
|
||||
"Create a work item (open, unassigned — work starts when it is"
|
||||
" assigned). `criteria` is the acceptance criteria — what gets verified"
|
||||
" before the item can be done; required. `parent` links it under"
|
||||
" another item; `case` names its journal case (children inherit the"
|
||||
" parent's case by default)."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string"},
|
||||
"criteria": {"type": "string"},
|
||||
"description": {"type": "string"},
|
||||
"parent": {"type": "integer"},
|
||||
"case": {"type": "string"},
|
||||
},
|
||||
"required": ["title", "criteria"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def board_tools(
|
||||
store: TeamStore,
|
||||
*,
|
||||
space: str,
|
||||
actor: Actor,
|
||||
taint: Callable[[], bool] = lambda: False,
|
||||
attachments=None,
|
||||
) -> list:
|
||||
"""The board verbs for one agent, pre-bound to its space and identity.
|
||||
|
||||
Authority is enforced twice on purpose: the returned set is role-filtered
|
||||
(a worker never even sees `assign`), and the store re-checks every call —
|
||||
the tool layer is convenience, the store is the gate.
|
||||
"""
|
||||
|
||||
def create_item(
|
||||
title: str,
|
||||
criteria: str,
|
||||
description: str = "",
|
||||
parent: Optional[int] = None,
|
||||
case: str = "",
|
||||
) -> dict:
|
||||
"""Create a work item (open, unassigned — work starts when it is
|
||||
assigned). `criteria` is the acceptance criteria — what gets verified
|
||||
before the item can be done; required. `parent` links it under another
|
||||
item; `case` names its journal case (children inherit the parent's case
|
||||
by default)."""
|
||||
return _call(
|
||||
store.create_item,
|
||||
space,
|
||||
actor,
|
||||
title=title,
|
||||
criteria=criteria,
|
||||
description=description,
|
||||
parent=parent,
|
||||
case=case or None,
|
||||
)
|
||||
|
||||
def list_items(state: str = "", assignee: str = "") -> dict:
|
||||
"""List work items on the board, optionally filtered by state
|
||||
(open/in_progress/blocked/review/done/canceled) or assignee."""
|
||||
try:
|
||||
return {"items": store.list_items(space, actor, state=state or None, assignee=assignee or None)}
|
||||
except (BoardError, ValueError) as error:
|
||||
return {"error": str(error)}
|
||||
|
||||
def transition(
|
||||
item: int, to: str, comment: str = "", refs: Optional[list] = None
|
||||
) -> dict:
|
||||
"""Move a work item to a new state. Workers move their own item to
|
||||
in_progress, blocked, or review (attach the blocker or a hand-off summary
|
||||
as `comment`, and artifact pointers — branch, report, session — as
|
||||
`refs`); done requires review verification first."""
|
||||
return _call(
|
||||
store.transition,
|
||||
space,
|
||||
actor,
|
||||
item,
|
||||
to,
|
||||
comment=comment,
|
||||
refs=[str(ref) for ref in refs or []],
|
||||
taint=taint(),
|
||||
)
|
||||
|
||||
def comment(item: int, body: str, refs: Optional[list] = None) -> dict:
|
||||
"""Add a comment to a work item. Comments are durable and attributed —
|
||||
answers that matter belong here, not in chat. `refs` attach artifact
|
||||
pointers (branch, PR, report, file:line) to the item."""
|
||||
return _call(
|
||||
store.comment,
|
||||
space,
|
||||
actor,
|
||||
item,
|
||||
body,
|
||||
refs=[str(ref) for ref in refs or []],
|
||||
taint=taint(),
|
||||
)
|
||||
|
||||
def claim(item: int) -> dict:
|
||||
"""Claim an open, unassigned work item for yourself. First claim wins;
|
||||
the item becomes your assignment. Only claim work you can start on —
|
||||
the lead sees every claim and can reassign."""
|
||||
return _call(store.claim, space, actor, item)
|
||||
|
||||
def assign(item: int, assignee: str) -> dict:
|
||||
"""Assign a work item to a worker coworker. The item itself becomes the
|
||||
worker's assignment — write the description and criteria accordingly."""
|
||||
return _call(store.assign, space, actor, item, assignee)
|
||||
|
||||
def link(src: int, kind: str, dst: int) -> dict:
|
||||
"""Link two work items: kind `parent` (dst becomes src's parent) or
|
||||
`blocks` (src blocks dst)."""
|
||||
return _call(store.link, space, actor, src, kind, dst)
|
||||
|
||||
def attach_image(item: int, path: str, caption: str = "") -> dict:
|
||||
"""Attach a screenshot or image file (png/jpg/gif/webp, ≤10MB) to a work
|
||||
item so the lead/reviewer can SEE what you did — pair it with your review
|
||||
hand-off. `caption` says what the image shows."""
|
||||
from pathlib import Path as _Path
|
||||
|
||||
source = _Path(path).expanduser()
|
||||
if not source.is_file():
|
||||
return {"error": f"no such file: {path}"}
|
||||
try:
|
||||
ref = attachments.put(source.read_bytes(), source.name)
|
||||
except (BoardError, ValueError) as error:
|
||||
return {"error": str(error)}
|
||||
return _call(
|
||||
store.attach_ref,
|
||||
space,
|
||||
actor,
|
||||
item,
|
||||
caption or f"attached {source.name}",
|
||||
ref,
|
||||
taint=taint(),
|
||||
)
|
||||
|
||||
verbs = LEAD_VERBS if actor.role in (Role.USER, Role.LEAD) else WORKER_VERBS
|
||||
if attachments is not None:
|
||||
verbs = verbs + ("attach_image",)
|
||||
local = locals()
|
||||
out = []
|
||||
for name in verbs:
|
||||
wrapped = _wrap(local[name])
|
||||
if name == "create_item":
|
||||
wrapped.__coworker_schema__ = _CREATE_ITEM_SCHEMA
|
||||
out.append(wrapped)
|
||||
return out
|
||||
|
||||
|
||||
def journal_tools(
|
||||
journal: "JournalStore",
|
||||
*,
|
||||
actor: Actor,
|
||||
space: str = "",
|
||||
taint: Callable[[], bool] = lambda: False,
|
||||
) -> list:
|
||||
def journal_append(
|
||||
case: str,
|
||||
body: str,
|
||||
kind: str = "note",
|
||||
item: Optional[int] = None,
|
||||
entities: Optional[list] = None,
|
||||
refs: Optional[list] = None,
|
||||
) -> dict:
|
||||
"""Append an entry to a journal case as you work: kind is finding,
|
||||
evidence, decision, note (any observation), or raw (a capture like a log
|
||||
excerpt — for large captures, save the full output to a file and journal
|
||||
an excerpt that references it). `entities` are the concrete things it is
|
||||
about (file paths, resource names, CVE ids) — they power later recall;
|
||||
`refs` are pointers (file:line, commit, url)."""
|
||||
return _call(
|
||||
journal.append,
|
||||
actor,
|
||||
case,
|
||||
body,
|
||||
kind=kind,
|
||||
space=space or None,
|
||||
item=item,
|
||||
entities=[str(entity) for entity in entities or []],
|
||||
refs=[str(ref) for ref in refs or []],
|
||||
taint=taint(),
|
||||
)
|
||||
|
||||
def journal_read(
|
||||
case: str,
|
||||
item: Optional[int] = None,
|
||||
author: str = "",
|
||||
kind: str = "",
|
||||
entity: str = "",
|
||||
include_raw: bool = False,
|
||||
limit: int = 50,
|
||||
) -> dict:
|
||||
"""Read a journal case, filtered: by item, author, entry kind, or entity.
|
||||
Prefer narrow filtered reads over pulling the whole case. Raw captures
|
||||
are skipped unless you pass include_raw or kind="raw"."""
|
||||
try:
|
||||
return {
|
||||
"entries": journal.read(
|
||||
actor,
|
||||
case,
|
||||
item=item,
|
||||
author=author or None,
|
||||
kind=kind or None,
|
||||
entity=entity or None,
|
||||
include_raw=include_raw,
|
||||
limit=limit,
|
||||
)
|
||||
}
|
||||
except (BoardError, ValueError) as error:
|
||||
return {"error": str(error)}
|
||||
|
||||
local = locals()
|
||||
return [_wrap(local[name]) for name in JOURNAL_VERBS]
|
||||
|
||||
|
||||
# The staffing gate's schema carrier. Like propose_plan, the real handling lives in
|
||||
# the TurnEngine (it needs the out-of-band approval round-trip): it emits
|
||||
# TEAM_PROPOSED and waits; approval PRE-SPAWNS the worker sessions and returns the
|
||||
# roster (actor ids) to the lead. This body only runs when no approver is wired.
|
||||
_PROPOSE_TEAM_SCHEMA = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "propose_team",
|
||||
"description": (
|
||||
"Propose the worker coworkers you need for this board. Give EACH member"
|
||||
" a short unique callname (`name`, e.g. 'nia', 'webb', 'checks') — it"
|
||||
" becomes their handle for assignment and @mentions, and lets you staff"
|
||||
" two of the same coworker. The user sees the roster and approves it;"
|
||||
" approval creates the worker sessions and returns the handles. Only"
|
||||
" team-capable worker coworkers may be proposed."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"members": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"persona": {"type": "string"},
|
||||
"name": {"type": "string"},
|
||||
"model": {"type": "string"},
|
||||
"reason": {"type": "string"},
|
||||
},
|
||||
"required": ["persona", "name"],
|
||||
},
|
||||
},
|
||||
"enable_chat": {"type": "boolean"},
|
||||
"note": {"type": "string"},
|
||||
},
|
||||
"required": ["members"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# The decomposition gate's schema carrier — the board-flavored sibling of
|
||||
# propose_plan, usable in ANY permission mode (proposing costs nothing; the board
|
||||
# only ever holds accepted work). The engine intercepts it; approval creates the
|
||||
# items and returns their ids.
|
||||
_PROPOSE_ITEMS_SCHEMA = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "propose_work_items",
|
||||
"description": (
|
||||
"Present your decomposition to the user as proposed WORK ITEMS for the"
|
||||
" team board. Approval creates them on the board (ids come back in the"
|
||||
" result); rejection returns feedback to revise. Each item needs a"
|
||||
" title and acceptance criteria — what gets verified before it can be"
|
||||
" done. This is not propose_plan: it carries no implementation steps"
|
||||
" and works in any mode — it is how a lead plans and coordinates via"
|
||||
" the board."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string"},
|
||||
"criteria": {"type": "string"},
|
||||
"description": {"type": "string"},
|
||||
"case": {"type": "string"},
|
||||
},
|
||||
"required": ["title", "criteria"],
|
||||
},
|
||||
},
|
||||
"note": {"type": "string"},
|
||||
},
|
||||
"required": ["items"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def propose_work_items_tool() -> object:
|
||||
def propose_work_items(items: Optional[list] = None, note: str = "") -> dict:
|
||||
"""Present proposed work items ({title, criteria, description?, case?})
|
||||
for the user's approval; approval creates them on the board."""
|
||||
return {
|
||||
"approved": False,
|
||||
"error": "item proposals aren't available in this surface",
|
||||
}
|
||||
|
||||
wrapped = ai.tool(
|
||||
propose_work_items,
|
||||
metadata=ai.ToolMetadata(
|
||||
category="team",
|
||||
risk_level="low",
|
||||
capabilities=["team"],
|
||||
),
|
||||
)
|
||||
wrapped.__coworker_schema__ = _PROPOSE_ITEMS_SCHEMA
|
||||
return wrapped
|
||||
|
||||
|
||||
def propose_team_tool() -> object:
|
||||
def propose_team(
|
||||
members: Optional[list] = None, enable_chat: bool = False, note: str = ""
|
||||
) -> dict:
|
||||
"""Propose the worker roster for this board (the staffing gate). Each member
|
||||
is {persona, model?, reason?}. The user approves; approval creates the
|
||||
worker sessions and returns their actor ids for assignment."""
|
||||
return {
|
||||
"approved": False,
|
||||
"error": "team staffing isn't available in this surface",
|
||||
}
|
||||
|
||||
wrapped = ai.tool(
|
||||
propose_team,
|
||||
metadata=ai.ToolMetadata(
|
||||
category="team",
|
||||
risk_level="medium",
|
||||
capabilities=["team"],
|
||||
),
|
||||
)
|
||||
wrapped.__coworker_schema__ = _PROPOSE_TEAM_SCHEMA
|
||||
return wrapped
|
||||
|
||||
|
||||
def _call(func, *args, **kwargs) -> dict:
|
||||
try:
|
||||
result = func(*args, **kwargs)
|
||||
return result if isinstance(result, dict) else {"ok": True}
|
||||
except (BoardError, ValueError) as error:
|
||||
return {"error": str(error)}
|
||||
|
||||
|
||||
def _wrap(func):
|
||||
risk = "medium" if func.__name__ == "assign" else "low"
|
||||
return ai.tool(
|
||||
func,
|
||||
metadata=ai.ToolMetadata(
|
||||
category="team",
|
||||
risk_level=risk,
|
||||
capabilities=["team"],
|
||||
),
|
||||
)
|
||||
Reference in New Issue
Block a user