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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ceki_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
from ._profile import BrowserProfile
from .humanize import HumanProfile

__version__ = "2.23.0"
__version__ = "2.31.0"
__all__ = [
"connect",
"ConnectOptions",
Expand Down
116 changes: 113 additions & 3 deletions ceki_sdk/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,58 @@ def _contract_client():
return ContractClient()


def _parse_participant(spec: str) -> dict[str, Any]:
"""Parse 'agent:5:reviewer' / 'user:7:qa' / 'agent:5:role:42'.

Returns {participable_id: int, participable_type: 'agent'|'user', role_id: int}
— the element shape EventController users[] validation expects
(back/2542 renamed the array key from `participants` to `users`;
element shape unchanged). CLI flag `--participant` keeps its
human-facing name; only the wire key changed.
"""
from .contract import ROLE_QA, ROLE_REVIEWER

if not spec or not isinstance(spec, str):
raise ValueError(f"--participant must be a non-empty string, got: {spec!r}")
parts = spec.split(":")
if len(parts) < 3:
raise ValueError(
f"--participant must be 'type:id:role' (e.g. agent:5:reviewer), got: {spec!r}"
)
ptype, pid, role, *rest = parts
if ptype not in ("agent", "user"):
raise ValueError(f"--participant type must be 'agent' or 'user', got: {ptype!r}")
try:
value = int(pid)
except ValueError as e:
raise ValueError(f"--participant id must be int, got: {pid!r}") from e

role_map = {"reviewer": ROLE_REVIEWER, "qa": ROLE_QA}
if role in role_map:
role_id = role_map[role]
elif role == "role":
if not rest:
raise ValueError(
f"--participant 'role:NUMBER' needs a number, got: {spec!r}"
)
try:
role_id = int(rest[0])
except ValueError as e:
raise ValueError(
f"--participant role id must be int, got: {rest[0]!r}"
) from e
else:
raise ValueError(
f"--participant unknown role {role!r}; expected 'reviewer', 'qa', "
f"or 'role:NUMBER'"
)
return {
"participable_id": value,
"type": ptype,
"role_id": role_id,
}


def _contract_dump(value: Any) -> None:
if isinstance(value, str):
sys.stdout.write(value)
Expand Down Expand Up @@ -448,6 +500,8 @@ def _cmd_contract(args: argparse.Namespace) -> int:
for cid in ids:
print(f"--- contract {cid} ---")
_contract_dump(cli.tasks(int(cid)))
elif action == "my-events":
_contract_dump(cli.my_events())
elif action == "my-jobs":
_contract_dump(cli.my_jobs())
elif action == "task":
Expand All @@ -464,6 +518,14 @@ def _cmd_contract(args: argparse.Namespace) -> int:
_err("contract id required (positional or CEKI_CONTRACT_IDS)", "args")
return 1
data_obj = json.loads(args.data) if args.data else None
try:
extra_parts = [
_parse_participant(spec)
for spec in (getattr(args, "participant", None) or [])
]
except ValueError as e:
_err(str(e), "args")
return 1
_contract_dump(cli.create(
cid,
label=args.label,
Expand All @@ -480,11 +542,20 @@ def _cmd_contract(args: argparse.Namespace) -> int:
description=args.desc,
data=data_obj,
benefitable=args.benefitable,
reviewer=args.reviewer,
qa=args.qa,
participants=extra_parts or None,
))
elif action == "comment":
# A comment's body lives in `label` (events.label is
# unbounded TEXT). `--label` wins when both are given;
# otherwise `--desc` is the body. `description` is never
# sent on a comment — the UI would render it on top of
# `label` and duplicate the body.
body = args.label if args.label is not None else args.desc
_contract_dump(cli.comment(
args.eid,
label=args.label,
label=body,
type_id=args.type,
status_id=args.status,
start=args.start,
Expand All @@ -493,7 +564,6 @@ def _cmd_contract(args: argparse.Namespace) -> int:
duration=args.duration,
amount=args.amount,
currency=args.currency,
description=args.desc,
benefitable=args.benefitable,
))
elif action == "propose":
Expand All @@ -510,6 +580,12 @@ def _cmd_contract(args: argparse.Namespace) -> int:
currency=args.currency,
benefitable=args.benefitable,
))
elif action == "progress":
_contract_dump(cli.progress(
args.eid,
status=args.status,
desc=args.desc,
))
elif action == "vote":
ids = [int(s) for s in str(args.ids).split(",") if s.strip()]
vote = str(args.vote).lower() in ("true", "1", "yes")
Expand Down Expand Up @@ -748,7 +824,21 @@ def build_parser() -> argparse.ArgumentParser:
p_ct = csub.add_parser("tasks", help="List contract events (default: CEKI_CONTRACT_IDS)")
p_ct.add_argument("cid", type=int, nargs="?", help="Contract ID")

csub.add_parser("my-jobs", help="List events assigned to me")
csub.add_parser(
"my-events",
help=(
"List contract events assigned to me (get-my-events). "
"The 'plate' feed. Wire tool renamed from get-my-jobs."
),
)
csub.add_parser(
"my-jobs",
help=(
"List hire schedules I posted, type 3 (get-my-jobs). "
"The listings feed. Wire tool reused after the backend swap "
"(formerly get-hire-jobs); for contract events use 'my-events'."
),
)

p_ctask = csub.add_parser("task", help="Get event")
p_ctask.add_argument("eid", type=int, help="Event ID")
Expand Down Expand Up @@ -779,6 +869,18 @@ def build_parser() -> argparse.ArgumentParser:
p_cc.add_argument("--amount", type=int)
p_cc.add_argument("--currency")
p_cc.add_argument("--benefitable", help="agent:8 or user:61")
p_cc.add_argument("--reviewer", help="agent:8 or user:61 (role_id 5 shortcut)")
p_cc.add_argument("--qa", help="agent:8 or user:61 (role_id 6 shortcut)")
p_cc.add_argument(
"--participant",
action="append",
default=[],
dest="participant",
help=(
"Repeatable. agent:N:reviewer | user:N:qa | agent:N:role:NUMBER. "
"Stacks on top of --reviewer/--qa."
),
)
p_cc.add_argument("--desc")
p_cc.add_argument("--data", help="Extra JSON object passed through as `data`")

Expand Down Expand Up @@ -809,6 +911,14 @@ def build_parser() -> argparse.ArgumentParser:
p_cp.add_argument("--currency")
p_cp.add_argument("--benefitable")

p_cpr = csub.add_parser(
"progress",
help="Status correction + progress comment (description is not touched)",
)
p_cpr.add_argument("eid", type=int)
p_cpr.add_argument("--status", type=int)
p_cpr.add_argument("--desc", required=True)

p_cv = csub.add_parser("vote", help="Vote on correction(s)")
p_cv.add_argument("eid", type=int)
p_cv.add_argument("--ids", required=True, help="Comma-separated correction IDs")
Expand Down
97 changes: 95 additions & 2 deletions ceki_sdk/contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@

from ._config import default_api_url

# Contract role IDs (back/2542 users[] payload — renamed from participants[]).
ROLE_REVIEWER = 5
ROLE_QA = 6


def _benefitable(value: str | None) -> dict[str, Any] | None:
if not value:
Expand All @@ -22,6 +26,26 @@ def _benefitable(value: str | None) -> dict[str, Any] | None:
return {"type": btype, "value": int(bid)}


def _participant(value: str | None, role_id: int) -> dict[str, Any] | None:
"""Parse 'agent:8' / 'user:61' into {participable_id, type, role_id}.

Wire shape declared by the create-contract-event MCP tool schema:
`participable_id` + `type` (short token: 'agent' or 'user') +
`role_id`. The MCP tool drops any field it does not know about, so
sending `participable_type` (FQCN) silently loses the type and the
backend membership lookup defaults to user → misleading 422
"Participant must be a member of the contract". Send `type`.
"""
base = _benefitable(value)
if base is None:
return None
return {
"participable_id": base["value"],
"type": base["type"],
"role_id": role_id,
}


def _clean(args: dict[str, Any]) -> dict[str, Any]:
return {k: v for k, v in args.items() if v is not None}

Expand Down Expand Up @@ -59,10 +83,17 @@ def _resolve_token() -> str:
return os.getenv("CEKI_AGENT_TOKEN") or os.getenv("CEKI_API_KEY") or ""


# Wire names swapped on the backend:
# get-my-jobs (formerly contract tasks) → get-my-events
# get-hire-jobs (formerly posted hire jobs) → get-my-jobs
# The two sugar keys reflect the new, non-cross-contaminated semantics:
# "my-events" = contract events assigned to me (the plate feed)
# "my-jobs" = hire schedules I posted (type 3) (the listings feed)
_TOOL_MAP = {
"list": "get-my-contracts",
"members": "get-contract-members",
"tasks": "get-contract-events",
"my-events": "get-my-events",
"my-jobs": "get-my-jobs",
"task": "get-event",
"children": "get-event-children",
Expand Down Expand Up @@ -175,7 +206,21 @@ def members(self, contract_id: int) -> Any:
def tasks(self, contract_id: int) -> Any:
return self.call(_TOOL_MAP["tasks"], {"contract_id": int(contract_id)})

def my_events(self) -> Any:
"""Contract events assigned to me — the agent's plate feed.

Calls `get-my-events` (formerly `get-my-jobs`; backend renamed
the wire tool when the listings feed reclaimed `get-my-jobs`).
"""
return self.call(_TOOL_MAP["my-events"], {})

def my_jobs(self) -> Any:
"""Hire schedules I posted (type 3) — the listings feed.

Calls `get-my-jobs` (the wire name was reused for this semantic
after the backend swap; previously this method returned contract
events — use `my_events()` for that now).
"""
return self.call(_TOOL_MAP["my-jobs"], {})

def task(self, event_id: int) -> Any:
Expand Down Expand Up @@ -206,7 +251,24 @@ def create(
description: str | None = None,
data: dict[str, Any] | None = None,
benefitable: str | None = None,
reviewer: str | None = None,
qa: str | None = None,
participants: list[dict[str, Any]] | None = None,
) -> Any:
# back/2542: reviewer/qa now live inside users[] (renamed from
# participants[]). Element shape unchanged. The `participants`
# kwarg name is kept as a stable Python API for callers, but on
# the wire it is emitted under the `users` key.
users: list[dict[str, Any]] = []
rev = _participant(reviewer, ROLE_REVIEWER)
if rev is not None:
users.append(rev)
qa_p = _participant(qa, ROLE_QA)
if qa_p is not None:
users.append(qa_p)
if participants:
users.extend(participants)

args = _clean({
"contract_id": int(contract_id),
"label": label,
Expand All @@ -223,6 +285,7 @@ def create(
"description": description,
"data": data,
"benefitable": _benefitable(benefitable),
"users": users if users else None,
})
return self.call(_TOOL_MAP["create"], args)

Expand All @@ -239,9 +302,16 @@ def comment(
duration: int | None = None,
amount: int | None = None,
currency: str | None = None,
description: str | None = None,
benefitable: str | None = None,
) -> Any:
"""Post a comment event.

The comment body lives entirely in `label` (events.label is
unbounded TEXT). `description` is deliberately NOT exposed: the
web UI renders both `label` and `description` on a comment, and
the human-typed path only writes to `label`. Passing both would
produce a visible duplicate in the renderer.
"""
args = _clean({
"event_id": int(event_id),
"label": label,
Expand All @@ -253,7 +323,6 @@ def comment(
"duration": duration,
"amount": amount,
"currency": currency,
"description": description,
"benefitable": _benefitable(benefitable),
})
return self.call(_TOOL_MAP["comment"], args)
Expand Down Expand Up @@ -288,6 +357,30 @@ def propose(
})
return self.call(_TOOL_MAP["propose"], args)

def progress(
self,
event_id: int,
*,
status: int | None = None,
desc: str,
) -> dict[str, Any]:
"""Status correction (optional) + progress comment in one shot.

The event's own description is NOT touched. `--desc` becomes the
body of a child comment-event, not a label/description overwrite
on the parent event. Use this for Hand/QA/Reviewer progress
reports — `propose --desc` would clobber the parent spec.
"""
status_result: Any = None
if status is not None:
status_result = self.propose(event_id, status_id=int(status))
# events.label is unbounded TEXT — the full body lives there, and
# `description` is never set on a comment (the UI renders both,
# which would duplicate the body for SDK-posted comments).
label = desc if (desc or "").strip() else "progress"
comment_result = self.comment(event_id, label=label)
return {"status_correction": status_result, "comment": comment_result}

def vote(self, event_id: int, ids: list[int], vote: bool) -> Any:
return self.call(_TOOL_MAP["vote"], {
"event_id": int(event_id),
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "ceki-sdk"
version = "2.26.0"
version = "2.31.0"
description = "Python SDK for browser.ceki.me — rent real browsers from real people"
readme = "README.md"
license = {text = "MIT"}
Expand Down
Loading
Loading