diff --git a/Makefile b/Makefile index 8a6eafdd7..aba4c4883 100644 --- a/Makefile +++ b/Makefile @@ -97,10 +97,10 @@ uv.lock: pyproject.toml packages/*/pyproject.toml # Install package in editable mode with isolated venv install: .installed - @(cd /tmp && $(CURDIR)/$(VENV_BIN)/python -c "from solstone.think.sol_cli import main") 2>/dev/null || { \ + @(cd /tmp && $(CURDIR)/$(VENV_BIN)/python -c "from solstone.think.sol_compat_cli import main") 2>/dev/null || { \ echo ">>> re-registering editable install"; \ $(UV) pip install -e . --no-deps; \ - if (cd /tmp && $(CURDIR)/$(VENV_BIN)/python -c "from solstone.think.sol_cli import main"); then \ + if (cd /tmp && $(CURDIR)/$(VENV_BIN)/python -c "from solstone.think.sol_compat_cli import main"); then \ echo ">>> re-registered successfully"; \ else \ echo ">>> editable install still broken; run make clean-install"; \ diff --git a/solstone/apps/activities/call.py b/solstone/apps/activities/call.py deleted file mode 100644 index b50d44bfe..000000000 --- a/solstone/apps/activities/call.py +++ /dev/null @@ -1,684 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""CLI commands for completed activity record management. - -Auto-discovered by ``think.call`` and mounted as ``sol call activities ...``. -Every verb reaches the journal only over HTTP via the Convey client. -""" - -import json -import os -import sys -from datetime import datetime, timedelta - -import typer - -from solstone.convey.reasons import ( - ACTIVITY_ALREADY_EXISTS, - ACTIVITY_INVALID, - ACTIVITY_NOT_FOUND, -) -from solstone.think.convey_client import ConveyClientError, convey_cli, get_client - -_PARTICIPATION_ROLES = {"attendee", "mentioned"} -_PARTICIPATION_SOURCES = {"voice", "speaker_label", "transcript", "screen", "other"} - -app = typer.Typer(help="Completed activity record management.") - - -def _get_sol_day() -> str | None: - return os.environ.get("SOL_DAY") or None - - -def _get_sol_facet() -> str | None: - return os.environ.get("SOL_FACET") or None - - -def _resolve_sol_day(arg: str | None) -> str: - if arg: - return arg - env = _get_sol_day() - if env: - return env - typer.echo("Error: day is required (pass as argument or set SOL_DAY).", err=True) - raise typer.Exit(1) - - -def _resolve_sol_day_or_today(arg: str | None) -> str: - if arg: - return arg - env = _get_sol_day() - if env: - return env - return datetime.now().strftime("%Y%m%d") - - -def _resolve_sol_facet(arg: str | None) -> str: - if arg: - return arg - env = _get_sol_facet() - if env: - return env - typer.echo( - "Error: facet is required (pass as argument or set SOL_FACET).", err=True - ) - raise typer.Exit(1) - - -def _valid_segment_key(segment: str) -> bool: - if "_" not in segment: - return False - time_part, len_part = segment.split("_", 1) - if len(time_part) != 6 or not time_part.isdigit() or not len_part.isdigit(): - return False - hour = int(time_part[:2]) - minute = int(time_part[2:4]) - second = int(time_part[4:6]) - return 0 <= hour <= 23 and 0 <= minute <= 59 and 0 <= second <= 59 - - -def _read_stdin_json(*, allow_empty: bool = False) -> dict[str, object]: - """Parse a single JSON object from stdin.""" - raw = sys.stdin.read().strip() - if not raw: - if allow_empty: - return {} - typer.echo("Error: expected JSON object on stdin.", err=True) - raise typer.Exit(1) - - try: - payload = json.loads(raw) - except json.JSONDecodeError as exc: - typer.echo(f"Error: invalid JSON on stdin: {exc}", err=True) - raise typer.Exit(1) from None - - if not isinstance(payload, dict): - typer.echo("Error: expected JSON object on stdin.", err=True) - raise typer.Exit(1) - return payload - - -def _echo_json(payload: object) -> None: - typer.echo(json.dumps(payload, indent=2, ensure_ascii=False)) - - -def _parse_day(value: str, *, label: str) -> datetime: - try: - return datetime.strptime(value, "%Y%m%d") - except ValueError: - typer.echo(f"Error: invalid {label} '{value}'", err=True) - raise typer.Exit(1) from None - - -def _iter_days(start_day: str, end_day: str) -> list[str]: - start = _parse_day(start_day, label="day") - end = _parse_day(end_day, label="day") - if end < start: - typer.echo( - f"Error: --to ({end_day}) must not be before --from ({start_day})", - err=True, - ) - raise typer.Exit(1) - - days: list[str] = [] - cursor = start - while cursor <= end: - days.append(cursor.strftime("%Y%m%d")) - cursor += timedelta(days=1) - return days - - -def _validate_participation(value: object) -> list[dict[str, object]]: - if not isinstance(value, list): - typer.echo("Error: participation must be an array", err=True) - raise typer.Exit(1) - - cleaned_entries: list[dict[str, object]] = [] - for i, entry in enumerate(value): - if not isinstance(entry, dict): - typer.echo(f"Error: participation[{i}] must be an object", err=True) - raise typer.Exit(1) - - name = entry.get("name") - if not isinstance(name, str) or not name.strip(): - typer.echo( - f"Error: participation[{i}] requires a non-empty string 'name'", - err=True, - ) - raise typer.Exit(1) - - role = entry.get("role") - if role not in _PARTICIPATION_ROLES: - typer.echo( - f"Error: participation[{i}] has invalid role '{role}' " - f"(must be one of {sorted(_PARTICIPATION_ROLES)})", - err=True, - ) - raise typer.Exit(1) - - source = entry.get("source") - if source not in _PARTICIPATION_SOURCES: - typer.echo( - f"Error: participation[{i}] has invalid source '{source}' " - f"(must be one of {sorted(_PARTICIPATION_SOURCES)})", - err=True, - ) - raise typer.Exit(1) - - confidence = entry.get("confidence") - if isinstance(confidence, bool) or not isinstance(confidence, (int, float)): - typer.echo( - f"Error: participation[{i}] 'confidence' must be a number", - err=True, - ) - raise typer.Exit(1) - - context = entry.get("context") - if not isinstance(context, str): - typer.echo( - f"Error: participation[{i}] 'context' must be a string", - err=True, - ) - raise typer.Exit(1) - - cleaned_entry = {key: item for key, item in entry.items() if key != "entity_id"} - cleaned_entry["name"] = name.strip() - cleaned_entry["role"] = role - cleaned_entry["source"] = source - cleaned_entry["confidence"] = confidence - cleaned_entry["context"] = context - cleaned_entries.append(cleaned_entry) - - return cleaned_entries - - -def _filter_items( - items: list[dict[str, object]], - *, - activity: str | None, - entity: str | None, - source: str | None, -) -> list[dict[str, object]]: - matches: list[dict[str, object]] = [] - entity_query = entity.lower() if entity else None - for item in items: - record = item["record"] - if not isinstance(record, dict): - continue - if activity and record.get("activity") != activity: - continue - if source and record.get("source") != source: - continue - if entity_query: - active_entities = record.get("active_entities", []) - if not any( - entity_query in str(active_entity).lower() - for active_entity in active_entities - ): - continue - matches.append(item) - return matches - - -def _sort_item_key(item: dict[str, object]) -> tuple[str, str, int, str]: - record = item["record"] - if not isinstance(record, dict): - return ("", "", 0, "") - return ( - str(record.get("day", "")), - str(record.get("facet", "")), - int(record.get("created_at", 0) or 0), - str(record.get("id", "")), - ) - - -def _echo_item_markdown(item: dict[str, object]) -> None: - typer.echo(item["markdown"]) - - -@app.command("list") -@convey_cli -def list_records( - day: str | None = typer.Option( - None, - "--day", - "-d", - help="Journal day in YYYYMMDD format (or set SOL_DAY).", - ), - from_day: str | None = typer.Option( - None, - "--from", - help="Start day for an inclusive range query (YYYYMMDD).", - ), - to_day: str | None = typer.Option( - None, - "--to", - help="End day for an inclusive range query (YYYYMMDD).", - ), - facet: str | None = typer.Option( - None, - "--facet", - "-f", - help="Facet name (or set SOL_FACET). Omit to query all facets.", - ), - activity: str | None = typer.Option( - None, - "--activity", - "-a", - help="Filter by activity type.", - ), - entity: str | None = typer.Option( - None, - "--entity", - help="Filter by active entity.", - ), - source: str | None = typer.Option( - None, - "--source", - help="Filter by record source: anticipated, user, or cogitate.", - ), - include_all: bool = typer.Option( - False, - "--all", - help="Include hidden activity records.", - ), - json_output: bool = typer.Option(False, "--json", help="Output as JSON."), -) -> None: - """List activity records for one day or an inclusive day range.""" - if day and (from_day or to_day): - typer.echo("Error: --day is incompatible with --from/--to.", err=True) - raise typer.Exit(1) - - if day: - resolved_days = [_resolve_sol_day(day)] - elif from_day or to_day: - start_day = from_day or _resolve_sol_day_or_today(None) - end_day = to_day or start_day - resolved_days = _iter_days(start_day, end_day) - else: - resolved_days = [_resolve_sol_day_or_today(None)] - - if source and source not in {"anticipated", "cogitate", "user"}: - typer.echo( - "Error: --source must be 'anticipated', 'cogitate', or 'user'.", - err=True, - ) - raise typer.Exit(1) - - client = get_client() - facet_param = facet or _get_sol_facet() or None - items: list[dict[str, object]] = [] - for resolved_day in resolved_days: - params = {"include_hidden": "1" if include_all else "0"} - if facet_param is not None: - params["facet"] = facet_param - body = client.request( - "GET", f"/app/activities/api/day/{resolved_day}/records", params=params - ) - items.extend(body["items"]) - - items = _filter_items(items, activity=activity, entity=entity, source=source) - items.sort(key=_sort_item_key) - - if json_output: - _echo_json([item["record"] for item in items]) - elif not items: - typer.echo("No activities found.") - else: - typer.echo("\n\n".join(str(item["markdown"]) for item in items)) - - -@app.command("get") -@convey_cli -def get_record( - span_id: str = typer.Argument(help="Activity record ID."), - facet: str | None = typer.Option( - None, - "--facet", - "-f", - help="Facet name (or set SOL_FACET).", - ), - day: str | None = typer.Option( - None, - "--day", - "-d", - help="Journal day in YYYYMMDD format (or set SOL_DAY).", - ), - json_output: bool = typer.Option(False, "--json", help="Output as JSON."), -) -> None: - """Fetch one activity record by ID.""" - resolved_facet = _resolve_sol_facet(facet) - resolved_day = _resolve_sol_day(day) - try: - body = get_client().request( - "GET", - f"/app/activities/api/day/{resolved_day}/record/{span_id}", - params={"facet": resolved_facet}, - ) - except ConveyClientError as err: - if err.reason_code == ACTIVITY_NOT_FOUND.code: - typer.echo(f"activity not found: {span_id}", err=True) - raise typer.Exit(1) from err - raise - - if json_output: - _echo_json(body["record"]) - else: - _echo_item_markdown(body) - - -@app.command("create") -@convey_cli -def create_record( - facet: str | None = typer.Option( - None, - "--facet", - "-f", - help="Facet name (or set SOL_FACET).", - ), - day: str | None = typer.Option( - None, - "--day", - "-d", - help="Journal day in YYYYMMDD format (or set SOL_DAY).", - ), - since_segment: str | None = typer.Option( - None, - "--since-segment", - help="Segment key to anchor the new activity span (HHMMSS_LEN).", - ), - source: str = typer.Option( - "user", - "--source", - help="Record source label: user or cogitate.", - ), - title: str | None = typer.Option( - None, - "--title", - help="Activity title (argv mode).", - ), - activity: str | None = typer.Option( - None, - "--activity", - help="Activity type (argv mode).", - ), - description: str | None = typer.Option( - None, - "--description", - help="One-line description (argv mode).", - ), - details: str | None = typer.Option( - None, - "--details", - help="Longer details (argv mode).", - ), - json_output: bool = typer.Option(False, "--json", help="Output as JSON."), -) -> None: - """Create a new synthetic activity record from argv flags or JSON on stdin.""" - if source not in {"cogitate", "user"}: - typer.echo("Error: --source must be 'cogitate' or 'user'.", err=True) - raise typer.Exit(1) - - resolved_facet = _resolve_sol_facet(facet) - resolved_day = _resolve_sol_day(day) - - if since_segment is not None and not _valid_segment_key(since_segment): - typer.echo( - f"Error: invalid --since-segment '{since_segment}' (expected HHMMSS_LEN)", - err=True, - ) - raise typer.Exit(1) - - payload_flags_supplied = any( - value is not None for value in (title, activity, description, details) - ) - if payload_flags_supplied: - if title is None: - typer.echo("Error: --title is required.", err=True) - raise typer.Exit(1) - if activity is None: - typer.echo("Error: --activity is required.", err=True) - raise typer.Exit(1) - activity_type = activity - body: dict[str, object] = { - "title": title, - "activity": activity, - "source": source, - } - if description is not None: - body["description"] = description - if details is not None: - body["details"] = details - else: - payload = _read_stdin_json() - participation_provided = "participation" in payload - - title = str(payload.get("title") or "").strip() - if not title: - typer.echo("Error: title is required.", err=True) - raise typer.Exit(1) - - activity_type = str(payload.get("activity") or "").strip() - if not activity_type: - typer.echo("Error: activity is required.", err=True) - raise typer.Exit(1) - - body = { - "title": title, - "activity": activity_type, - "source": source, - } - if "description" in payload: - body["description"] = payload["description"] - if "details" in payload: - body["details"] = payload["details"] - if participation_provided: - body["participation"] = _validate_participation(payload["participation"]) - - if since_segment is not None: - body["since_segment"] = since_segment - - try: - response = get_client().request( - "POST", - f"/app/activities/api/day/{resolved_day}/records", - params={"facet": resolved_facet}, - json=body, - ) - except ConveyClientError as err: - if err.reason_code == ACTIVITY_NOT_FOUND.code: - typer.echo( - f"Error: unknown activity for facet '{resolved_facet}': {activity_type}", - err=True, - ) - raise typer.Exit(1) from err - if err.reason_code == ACTIVITY_ALREADY_EXISTS.code: - typer.echo(f"Error: activity already exists: {err.detail}", err=True) - raise typer.Exit(1) from err - if err.reason_code == ACTIVITY_INVALID.code: - typer.echo(f"Error: {err.detail}", err=True) - raise typer.Exit(1) from err - raise - - if json_output: - _echo_json(response["record"]) - else: - _echo_item_markdown(response) - - -@app.command("update") -@convey_cli -def update_record_command( - span_id: str = typer.Argument(help="Activity record ID."), - facet: str | None = typer.Option( - None, - "--facet", - "-f", - help="Facet name (or set SOL_FACET).", - ), - day: str | None = typer.Option( - None, - "--day", - "-d", - help="Journal day in YYYYMMDD format (or set SOL_DAY).", - ), - note: str | None = typer.Option(None, "--note", help="Edit note."), - title: str | None = typer.Option( - None, - "--title", - help="Activity title (argv mode).", - ), - description: str | None = typer.Option( - None, - "--description", - help="One-line description (argv mode).", - ), - details: str | None = typer.Option( - None, - "--details", - help="Longer details (argv mode).", - ), - json_output: bool = typer.Option(False, "--json", help="Output as JSON."), -) -> None: - """Apply an argv flag or stdin JSON patch to one activity record.""" - resolved_facet = _resolve_sol_facet(facet) - resolved_day = _resolve_sol_day(day) - - payload_flags_supplied = any( - value is not None for value in (title, description, details) - ) - if payload_flags_supplied: - patch: dict[str, object] = {} - if title is not None: - patch["title"] = title - if description is not None: - patch["description"] = description - if details is not None: - patch["details"] = details - else: - payload = _read_stdin_json(allow_empty=True) - patch = { - key: value - for key, value in payload.items() - if key in {"title", "description", "details"} - } - if set(payload) - set(patch): - extra = ", ".join(sorted(set(payload) - set(patch))) - typer.echo(f"Error: disallowed update fields: {extra}", err=True) - raise typer.Exit(1) - - if not patch: - typer.echo( - "Error: update payload must include at least one mutable field.", err=True - ) - raise typer.Exit(1) - - note_text = note or f"updated fields: {', '.join(sorted(patch))}" - try: - body = get_client().request( - "POST", - f"/app/activities/api/day/{resolved_day}/record/{span_id}/update", - params={"facet": resolved_facet}, - json={"patch": patch, "note": note_text}, - ) - except ConveyClientError as err: - if err.reason_code == ACTIVITY_NOT_FOUND.code: - typer.echo(f"activity not found: {span_id}", err=True) - raise typer.Exit(1) from err - raise - - if json_output: - _echo_json(body["record"]) - else: - _echo_item_markdown(body) - - -def _set_mute_state( - span_id: str, - *, - facet: str | None, - day: str | None, - reason: str | None, - json_output: bool, - verb: str, -) -> None: - resolved_facet = _resolve_sol_facet(facet) - resolved_day = _resolve_sol_day(day) - try: - body = get_client().request( - "POST", - f"/app/activities/api/day/{resolved_day}/record/{span_id}/{verb}", - params={"facet": resolved_facet}, - json={"reason": reason}, - ) - except ConveyClientError as err: - if err.reason_code == ACTIVITY_NOT_FOUND.code: - typer.echo(f"activity not found: {span_id}", err=True) - raise typer.Exit(1) from err - raise - - if json_output: - _echo_json(body["record"]) - else: - _echo_item_markdown(body) - - -@app.command("mute") -@convey_cli -def mute_record( - span_id: str = typer.Argument(help="Activity record ID."), - facet: str | None = typer.Option( - None, - "--facet", - "-f", - help="Facet name (or set SOL_FACET).", - ), - day: str | None = typer.Option( - None, - "--day", - "-d", - help="Journal day in YYYYMMDD format (or set SOL_DAY).", - ), - reason: str | None = typer.Option(None, "--reason", help="Mute reason."), - json_output: bool = typer.Option(False, "--json", help="Output as JSON."), -) -> None: - """Hide an activity record without deleting it.""" - _set_mute_state( - span_id, - facet=facet, - day=day, - reason=reason, - json_output=json_output, - verb="mute", - ) - - -@app.command("unmute") -@convey_cli -def unmute_record( - span_id: str = typer.Argument(help="Activity record ID."), - facet: str | None = typer.Option( - None, - "--facet", - "-f", - help="Facet name (or set SOL_FACET).", - ), - day: str | None = typer.Option( - None, - "--day", - "-d", - help="Journal day in YYYYMMDD format (or set SOL_DAY).", - ), - reason: str | None = typer.Option(None, "--reason", help="Unmute reason."), - json_output: bool = typer.Option(False, "--json", help="Output as JSON."), -) -> None: - """Restore a previously hidden activity record.""" - _set_mute_state( - span_id, - facet=facet, - day=day, - reason=reason, - json_output=json_output, - verb="unmute", - ) diff --git a/solstone/apps/awareness/call.py b/solstone/apps/awareness/call.py deleted file mode 100644 index c8607807a..000000000 --- a/solstone/apps/awareness/call.py +++ /dev/null @@ -1,143 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""CLI commands for the awareness system. - -Auto-discovered by ``think.call`` and mounted as ``sol call awareness ...``. -Every verb reaches the journal only over HTTP via the Convey client; this -module imports no journal/domain function and performs no filesystem I/O. -""" - -import json - -import typer - -from solstone.think.convey_client import convey_cli, get_client - -app = typer.Typer(help="Awareness system — the journal's self-knowledge.") - -_LOG_PAGE_SIZE = 100 # the awareness /api/log route's max_limit - - -@app.command("status") -@convey_cli -def status( - section: str | None = typer.Argument( - None, help="Section to read (e.g., 'journal'). Omit for all." - ), -) -> None: - """Show current awareness state.""" - state = get_client().request("GET", "/app/awareness/api/state") - if not state: - typer.echo("No awareness state yet.") - return - - if section: - value = state.get(section) - if value is None: - typer.echo(f"No '{section}' state.") - return - typer.echo(json.dumps(value, indent=2)) - else: - typer.echo(json.dumps(state, indent=2)) - - -@app.command("imports") -@convey_cli -def imports_cmd( - record: str | None = typer.Option( - None, "--record", "-r", help="Record a completed import (source type)." - ), - declined: bool = typer.Option( - False, "--declined", help="Record that user declined import offer." - ), - nudge: bool = typer.Option( - False, "--nudge", help="Record that triage nudged about imports." - ), -) -> None: - """Read or update import tracking state.""" - client = get_client() - if record: - state = client.request( - "POST", "/app/awareness/api/imports", json={"record": record} - ) - elif declined: - state = client.request( - "POST", "/app/awareness/api/imports", json={"declined": True} - ) - elif nudge: - state = client.request( - "POST", "/app/awareness/api/imports", json={"nudge": True} - ) - else: - state = client.request("GET", "/app/awareness/api/imports") - typer.echo(json.dumps(state, indent=2)) - - -@app.command("log-read") -@convey_cli -def log_read_cmd( - day: str | None = typer.Argument( - None, help="Day in YYYYMMDD format (defaults to today)." - ), - kind: str | None = typer.Option( - None, "--kind", "-k", help="Filter by entry kind (e.g., 'observation')." - ), - limit: int = typer.Option( - 0, "--limit", "-n", help="Max entries to return (0=all)." - ), -) -> None: - """Read entries from the daily awareness log.""" - client = get_client() - entries: list = [] - offset = 0 - while True: - params: dict = {"limit": _LOG_PAGE_SIZE, "offset": offset} - if day: - params["day"] = day - if kind: - params["kind"] = kind - body = client.request("GET", "/app/awareness/api/log", params=params) - items = body["items"] - entries.extend(items) - offset += len(items) - if not items or len(entries) >= body["total"]: - break - - if limit > 0: - entries = entries[-limit:] - - if not entries: - typer.echo("No entries found.") - return - - typer.echo(json.dumps(entries, indent=2)) - - -@app.command("log") -@convey_cli -def log_cmd( - kind: str = typer.Argument( - help="Entry type: state, observation, nudge, interaction." - ), - message: str | None = typer.Argument(None, help="Human-readable message."), - key: str | None = typer.Option( - None, "--key", "-k", help="Dotted key for state entries." - ), - data: str | None = typer.Option(None, "--data", "-d", help="JSON data payload."), -) -> None: - """Append an entry to the daily awareness log.""" - parsed_data = None - if data: - try: - parsed_data = json.loads(data) - except json.JSONDecodeError: - typer.echo("Error: --data must be valid JSON", err=True) - raise typer.Exit(1) - - entry = get_client().request( - "POST", - "/app/awareness/api/log", - json={"kind": kind, "key": key, "message": message, "data": parsed_data}, - ) - typer.echo(json.dumps(entry, indent=2)) diff --git a/solstone/apps/body/call.py b/solstone/apps/body/call.py deleted file mode 100644 index 14b1ccace..000000000 --- a/solstone/apps/body/call.py +++ /dev/null @@ -1,126 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""CLI commands for body app views. - -Auto-discovered by ``think.call`` and mounted as ``sol call body``. -This module reaches journal data only over the Convey HTTP client. -""" - -from __future__ import annotations - -import json - -import typer - -from solstone.think.convey_client import convey_cli, get_client - -app = typer.Typer(help="Imported health data views.") - - -def _echo_json(payload: object) -> None: - typer.echo(json.dumps(payload, indent=2, sort_keys=True)) - - -@app.command("status") -@convey_cli -def status( - json_output: bool = typer.Option( - False, - "--json", - help="Print the full JSON response.", - ), -) -> None: - """Show imported health data status.""" - - payload = get_client().request("GET", "/app/body/api/status") - if json_output: - _echo_json(payload) - return - - if not isinstance(payload, dict): - typer.echo("I couldn't read the response from the body app.", err=True) - raise typer.Exit(1) - - imports = payload.get("imports") or [] - normalized = payload.get("normalized") or {} - coverage = payload.get("coverage_window") or {} - typer.echo(f"imports: {len(imports)}") - typer.echo(f"entries: {normalized.get('total', 0)}") - if coverage.get("start"): - typer.echo(f"coverage: {coverage.get('start')} to {coverage.get('end')}") - - -@app.command("day") -@convey_cli -def day( - day_value: str = typer.Argument(..., help="Day as YYYYMMDD."), - json_output: bool = typer.Option( - False, - "--json", - help="Print the full JSON response.", - ), -) -> None: - """Show one day of imported health data.""" - - payload = get_client().request("GET", f"/app/body/api/day/{day_value}") - if json_output: - _echo_json(payload) - return - - if not isinstance(payload, dict): - typer.echo("I couldn't read the response from the body app.", err=True) - raise typer.Exit(1) - - glucose = payload.get("glucose") or {} - typer.echo(f"day: {payload.get('day', day_value)}") - typer.echo(f"entries: {payload.get('entry_total', 0)}") - typer.echo( - "glucose: " - f"count={glucose.get('count', 0)} " - f"min={glucose.get('min')} " - f"max={glucose.get('max')} " - f"mean={glucose.get('mean')} " - f"unit={glucose.get('unit')}" - ) - - -@app.command("window") -@convey_cli -def window( - from_value: str = typer.Option( - ..., - "--from", - help="Window start as an ISO timestamp.", - ), - to_value: str = typer.Option( - ..., - "--to", - help="Window end as an ISO timestamp.", - ), - json_output: bool = typer.Option( - False, - "--json", - help="Print the full JSON response.", - ), -) -> None: - """Show imported health context for a time window.""" - - payload = get_client().request( - "GET", - "/app/body/api/window", - params={"from": from_value, "to": to_value}, - ) - if json_output: - _echo_json(payload) - return - - if not isinstance(payload, dict): - typer.echo("I couldn't read the response from the body app.", err=True) - raise typer.Exit(1) - - typer.echo(f"window: {payload.get('from')} to {payload.get('to')}") - typer.echo(f"entries: {payload.get('entry_total', 0)}") - brief = payload.get("brief_label") - if brief: - typer.echo(str(brief)) diff --git a/solstone/apps/body/tests/test_body_app.py b/solstone/apps/body/tests/test_body_app.py index 347323900..1c0ed9e84 100644 --- a/solstone/apps/body/tests/test_body_app.py +++ b/solstone/apps/body/tests/test_body_app.py @@ -850,19 +850,6 @@ def test_body_workspace_template_avoids_surveillance_verbs(): assert found == set(), f"{path.name} contains banned copy terms: {found}" -def test_body_call_module_uses_convey_http_only(): - body_root = Path(body_routes.__file__).resolve().parent - source = (body_root / "call.py").read_text(encoding="utf-8") - - assert "solstone.think.convey_client" in source - assert '@app.command("window")' in source - assert "/app/body/api/window" in source - assert "from pathlib" not in source - assert "import os" not in source - assert "sqlite3" not in source - assert "open(" not in source - - def test_read_routes_create_nothing_in_empty_journal(body_env): env = body_env() imports_root = env.journal / "imports" diff --git a/solstone/apps/chat/call.py b/solstone/apps/chat/call.py deleted file mode 100644 index bf679409d..000000000 --- a/solstone/apps/chat/call.py +++ /dev/null @@ -1,69 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""CLI commands for chat app tools.""" - -from __future__ import annotations - -import json - -import typer - -from solstone.think.convey_client import convey_cli, get_client - -app = typer.Typer(help="Chat tools.") - - -@app.command("start") -@convey_cli -def cmd_start( - summary: str = typer.Option(..., "--summary", help="Short request summary."), - message: str | None = typer.Option(None, "--message", help="Optional message."), - category: str = typer.Option(..., "--category", help="Request category."), - dedupe: str = typer.Option(..., "--dedupe", help="Deduplication key."), - dedupe_window: str | None = typer.Option( - None, - "--dedupe-window", - help="Deduplication window, e.g. 24h.", - ), - since_ts: int = typer.Option(..., "--since-ts", help="Since timestamp in ms."), - trigger_talent: str = typer.Option( - ..., - "--trigger-talent", - help="Talent that triggered the request.", - ), -) -> None: - """Start a sol-initiated chat request.""" - if not summary.strip(): - typer.echo("Error: summary is required", err=True) - raise typer.Exit(1) - if len(summary.strip()) > 80: - typer.echo("Error: summary must be 80 characters or fewer", err=True) - raise typer.Exit(1) - if message is not None and len(message.strip()) > 500: - typer.echo("Error: message must be 500 characters or fewer", err=True) - raise typer.Exit(1) - if not dedupe.strip(): - typer.echo("Error: dedupe is required", err=True) - raise typer.Exit(1) - if not trigger_talent.strip(): - typer.echo("Error: trigger_talent is required", err=True) - raise typer.Exit(1) - if since_ts <= 0: - typer.echo("Error: since_ts must be positive", err=True) - raise typer.Exit(1) - - result = get_client().request( - "POST", - "/api/chat/start", - json={ - "summary": summary, - "message": message, - "category": category, - "dedupe": dedupe, - "dedupe_window": dedupe_window, - "since_ts": since_ts, - "trigger_talent": trigger_talent, - }, - ) - typer.echo(json.dumps(result)) diff --git a/solstone/apps/chat/tests/test_call.py b/solstone/apps/chat/tests/test_call.py deleted file mode 100644 index 025a7a2fe..000000000 --- a/solstone/apps/chat/tests/test_call.py +++ /dev/null @@ -1,158 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -from __future__ import annotations - -import json -import sys -from datetime import datetime -from pathlib import Path - -import pytest -import requests -from typer.testing import CliRunner - -ROOT = Path(__file__).resolve().parents[4] -if str(ROOT) not in sys.path: - sys.path.insert(0, str(ROOT)) - -import solstone.convey.sol_initiated.start as sol_start -from solstone.apps.chat.call import app -from solstone.convey.chat_stream import read_chat_events -from solstone.think.convey_client import ConveyClient -from tests._baseline_harness import make_test_client - -FROZEN_MS = 1_700_000_000_000 -FROZEN_DAY = datetime.fromtimestamp(FROZEN_MS / 1000).strftime("%Y%m%d") -VALID_SINCE_TS = FROZEN_MS - 60_000 - - -@pytest.fixture -def journal(tmp_path, monkeypatch): - monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - config_path = tmp_path / "config" / "journal.json" - config_path.parent.mkdir(parents=True, exist_ok=True) - config_path.write_text( - json.dumps( - { - "setup": {"completed_at": 1700000000000}, - "sol_voice": {"rate_floor_minutes": 0}, - } - ) - + "\n", - encoding="utf-8", - ) - return tmp_path - - -@pytest.fixture -def runner(journal, monkeypatch): - monkeypatch.setattr(sol_start, "now_ms", lambda: FROZEN_MS) - client = ConveyClient(session=make_test_client(journal), base_url="") - monkeypatch.setattr("solstone.apps.chat.call.get_client", lambda: client) - return CliRunner() - - -def _args( - *, - summary: str = "summary", - message: str | None = None, - category: str = "briefing", - dedupe: str = "k", - dedupe_window: str | None = None, - since_ts: int = VALID_SINCE_TS, - trigger_talent: str = "reflection", -) -> list[str]: - args = [ - "--summary", - summary, - "--category", - category, - "--dedupe", - dedupe, - "--since-ts", - str(since_ts), - "--trigger-talent", - trigger_talent, - ] - if message is not None: - args.extend(["--message", message]) - if dedupe_window is not None: - args.extend(["--dedupe-window", dedupe_window]) - return args - - -def test_start_writes_request(runner): - result = runner.invoke(app, _args(message="body")) - - assert result.exit_code == 0 - data = json.loads(result.stdout) - assert data["written"] is True - assert data["deduped"] is False - assert data["throttled"] is None - assert isinstance(data["request_id"], str) and len(data["request_id"]) == 32 - - -def test_start_dedupes_within_window(runner): - first = runner.invoke(app, _args(dedupe="same")) - second = runner.invoke(app, _args(dedupe="same")) - - assert first.exit_code == 0 - data = json.loads(second.stdout) - assert second.exit_code == 0 - assert data["deduped"] is True - assert data["written"] is False - - -@pytest.mark.parametrize( - ("args", "stderr"), - [ - (_args(summary=""), "Error: summary is required\n"), - (_args(summary="x" * 81), "Error: summary must be 80 characters or fewer\n"), - (_args(dedupe=""), "Error: dedupe is required\n"), - (_args(trigger_talent=""), "Error: trigger_talent is required\n"), - (_args(since_ts=0), "Error: since_ts must be positive\n"), - ( - _args(message="x" * 501), - "Error: message must be 500 characters or fewer\n", - ), - ], -) -def test_start_cli_side_validation_errors_before_post(runner, args, stderr): - result = runner.invoke(app, args) - - assert result.exit_code == 1 - assert result.stderr == stderr - assert result.stdout == "" - assert read_chat_events(FROZEN_DAY) == [] - - -def test_start_bad_category_uses_route_internal_owner_voice(runner): - result = runner.invoke(app, _args(category="nope")) - - assert result.exit_code == 1 - assert result.stderr == "I couldn't use one of those values.\n" - assert result.stdout == "" - - -def test_convey_down_prints_require_solstone_message(journal, monkeypatch): - class DownSession: - def get(self, _url): - raise requests.exceptions.ConnectionError() - - def post(self, _url, json=None): - raise requests.exceptions.ConnectionError() - - client = ConveyClient(session=DownSession(), base_url="http://localhost:5015") - monkeypatch.setattr("solstone.apps.chat.call.get_client", lambda: client) - monkeypatch.delenv("SOL_SKIP_SUPERVISOR_CHECK", raising=False) - monkeypatch.delenv("SOL_SUPERVISOR_SPAWNED", raising=False) - - result = CliRunner().invoke(app, _args()) - - assert result.exit_code == 1 - assert ( - result.stderr - == "sol: solstone isn't running. Start it with 'journal up' and retry.\n" - ) - assert result.stdout == "" diff --git a/solstone/apps/entities/call.py b/solstone/apps/entities/call.py deleted file mode 100644 index cde1cfc19..000000000 --- a/solstone/apps/entities/call.py +++ /dev/null @@ -1,1304 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""CLI commands for entity management. - -Auto-discovered by ``think.call`` and mounted as ``sol call entities ...``. -Every verb reaches the journal only over HTTP via the Convey client; this -module imports no journal/domain function and performs no filesystem I/O. -""" - -import json -import os -import re - -import typer - -from solstone.convey.reasons import ( - EDGE_INDEX_UNAVAILABLE, - ENTITY_ALIAS_CONFLICT, - ENTITY_ALREADY_EXISTS, - ENTITY_BLOCKED, - ENTITY_BUSY, - ENTITY_NOT_FOUND, - ENTITY_OPERATION_FAILED, - INVALID_ENTITY_TYPE, - INVALID_REQUEST_VALUE, -) -from solstone.think.convey_client import ConveyClientError, get_client - -app = typer.Typer(help="Entity management.") - - -def _get_sol_facet() -> str | None: - return os.environ.get("SOL_FACET") or None - - -def _resolve_sol_day(arg: str | None) -> str: - if arg: - return arg - env = os.environ.get("SOL_DAY") or None - if env: - return env - typer.echo("Error: day is required (pass as argument or set SOL_DAY).", err=True) - raise typer.Exit(1) - - -def _resolve_sol_facet(arg: str | None) -> str: - if arg: - return arg - env = _get_sol_facet() - if env: - return env - typer.echo( - "Error: facet is required (pass as argument or set SOL_FACET).", err=True - ) - raise typer.Exit(1) - - -def _exit_with(message: str) -> None: - typer.echo(message, err=True) - raise typer.Exit(1) - - -def _params(**values: object) -> dict[str, object]: - return {key: value for key, value in values.items() if value is not None} - - -def _request( - method: str, - path: str, - *, - params: dict[str, object] | None = None, - json_body: dict[str, object] | None = None, -) -> object: - return get_client().request(method, path, params=params, json=json_body) - - -def _handle_entity_error( - err: ConveyClientError, - *, - entity: str | None = None, - type_: str | None = None, -) -> None: - detail = err.detail or "" - if err.reason_code == ENTITY_BUSY.code: - _exit_with(ENTITY_BUSY.message) - if err.reason_code == INVALID_ENTITY_TYPE.code and type_ is not None: - _exit_with(f"Error: Invalid entity type '{type_}'.") - if err.reason_code == ENTITY_BLOCKED.code: - name = detail or entity or "entity" - _exit_with(f"Error: Entity '{name}' is blocked.") - if err.reason_code == ENTITY_NOT_FOUND.code: - name = detail or entity or "entity" - _exit_with(f"Error: Entity '{name}' not found.") - if err.reason_code == EDGE_INDEX_UNAVAILABLE.code: - _exit_with(EDGE_INDEX_UNAVAILABLE.message) - if err.reason_code == ENTITY_ALIAS_CONFLICT.code and detail: - _exit_with(f"Error: {detail}") - if err.reason_code in { - INVALID_REQUEST_VALUE.code, - ENTITY_OPERATION_FAILED.code, - ENTITY_ALREADY_EXISTS.code, - }: - if detail: - _exit_with(f"Error: {detail}") - - typer.echo(err.error, err=True) - raise typer.Exit(1) - - -def _plural(value: int, singular: str, plural: str | None = None) -> str: - noun = singular if value == 1 else plural or f"{singular}s" - return f"{value} {noun}" - - -def _normalize_kinds(values: list[str] | None) -> list[str] | None: - kinds: list[str] = [] - for raw in values or []: - for item in raw.split(","): - kind = item.strip() - if kind: - kinds.append(kind) - return kinds or None - - -def _edge_filter_params( - *, - kinds: list[str] | None, - facet: str | None, - day_from: str | None, - day_to: str | None, -) -> dict[str, object]: - return _params( - kinds=_normalize_kinds(kinds), - facet=facet, - day_from=day_from, - day_to=day_to, - ) - - -def _display_entity(entity_id: object, name: object = None) -> str: - entity_id_text = str(entity_id or "") - name_text = str(name or "") - if name_text and name_text != entity_id_text: - return f"{name_text} ({entity_id_text})" - return entity_id_text - - -def _candidate_display(candidate: dict) -> str: - return _display_entity(candidate.get("id"), candidate.get("name")) - - -def _render_edge_resolution_error(body: dict, *, json_output: bool) -> None: - if json_output: - typer.echo(json.dumps(body, indent=2, ensure_ascii=False)) - raise typer.Exit(1) - - query = str(body.get("query") or "entity") - candidates = body.get("candidates") or [] - if candidates: - labels = ", ".join( - _candidate_display(candidate) for candidate in candidates[:3] - ) - _exit_with(f"Error: Entity '{query}' not found. Did you mean: {labels}") - _exit_with(f"Error: Entity '{query}' not found.") - - -def _edge_body_or_exit( - path: str, - *, - params: dict[str, object], - json_output: bool, -) -> dict: - try: - body = _request("GET", path, params=params) - except ConveyClientError as err: - _handle_entity_error(err) - if not isinstance(body, dict): - _exit_with("I couldn't read the journal response.") - if body.get("resolved") is None and "query" in body and "candidates" in body: - _render_edge_resolution_error(body, json_output=json_output) - return body - - -def _echo_json(body: dict) -> None: - typer.echo(json.dumps(body, indent=2, ensure_ascii=False)) - - -def _format_kinds(kinds: object) -> str: - if not isinstance(kinds, dict): - return "" - parts = [] - for kind in sorted(kinds): - info = kinds.get(kind) - count = info.get("count") if isinstance(info, dict) else None - if count: - parts.append(f"{kind}:{count}") - return ", ".join(parts) - - -def _format_seen(item: dict) -> str: - first_seen = item.get("first_seen") - last_seen = item.get("last_seen") - if first_seen and last_seen and first_seen != last_seen: - return f"seen={first_seen}..{last_seen}" - if first_seen or last_seen: - return f"seen={first_seen or last_seen}" - return "" - - -def _format_directed(item: dict) -> str: - directed = item.get("directed") - if not isinstance(directed, dict): - return "" - parts = [] - out_count = int(directed.get("out") or 0) - in_count = int(directed.get("in") or 0) - if out_count: - parts.append(f"out:{out_count}") - if in_count: - parts.append(f"in:{in_count}") - return f"directed={', '.join(parts)}" if parts else "" - - -def _format_evidence(row: dict, *, include_source: bool = False) -> str: - parts = [str(row.get("day") or "unknown-day"), str(row.get("kind") or "unknown")] - label = str(row.get("label") or "") - anchor = str(row.get("anchor") or "") - text = " ".join(parts) - if label: - text += f" - {label}" - if anchor: - text += f" ({anchor})" - if include_source: - source = str(row.get("source") or "") - path = str(row.get("path") or "") - source_parts = [] - if source: - source_parts.append(f"[{source}]") - if path: - source_parts.append(path) - if source_parts: - text += " " + " ".join(source_parts) - return text - - -def _render_neighbor(index: int, neighbor: dict) -> None: - parts = [ - _display_entity(neighbor.get("entity_id"), neighbor.get("name")), - f"score={float(neighbor.get('score') or 0):.2f}", - f"count={int(neighbor.get('count') or 0)}", - ] - kinds = _format_kinds(neighbor.get("kinds")) - if kinds: - parts.append(f"kinds={kinds}") - seen = _format_seen(neighbor) - if seen: - parts.append(seen) - directed = _format_directed(neighbor) - if directed: - parts.append(directed) - typer.echo(f" {index}. {' '.join(parts)}") - for row in neighbor.get("evidence") or []: - if isinstance(row, dict): - typer.echo(f" - {_format_evidence(row)}") - - -def _render_network(body: dict) -> None: - neighbors = body.get("neighbors") if isinstance(body.get("neighbors"), list) else [] - total = int(body.get("total_neighbors") or 0) - entity_id = str(body.get("entity_id") or "") - if total == 0: - typer.echo(f"No recorded connections for {entity_id}.") - return - - label = _plural(total, "recorded connection") - if len(neighbors) < total: - typer.echo(f"{label} for {entity_id} (showing {len(neighbors)}):") - else: - typer.echo(f"{label} for {entity_id}:") - for index, neighbor in enumerate(neighbors, 1): - if isinstance(neighbor, dict): - _render_neighbor(index, neighbor) - - -def _render_history(body: dict) -> None: - evidence = body.get("evidence") if isinstance(body.get("evidence"), list) else [] - total = int(body.get("total") or 0) - entity_id = str(body.get("entity_id") or "") - peer = _display_entity(body.get("peer_id"), body.get("peer_name")) - if total == 0: - typer.echo(f"No recorded connection history between {entity_id} and {peer}.") - return - - label = _plural(total, "evidence row") - offset = int(body.get("offset") or 0) - suffix = "" - if offset or len(evidence) < total: - if evidence: - suffix = f" (showing {offset + 1}-{offset + len(evidence)})" - else: - suffix = " (showing 0)" - typer.echo(f"{label} for {entity_id} <-> {peer}{suffix}:") - for row in evidence: - if isinstance(row, dict): - typer.echo(f" - {_format_evidence(row, include_source=True)}") - - -def _render_overview(body: dict) -> None: - entities = body.get("entities") if isinstance(body.get("entities"), list) else [] - totals = body.get("totals") if isinstance(body.get("totals"), dict) else {} - total_edges = int(totals.get("edges") or 0) - total_entities = int(totals.get("entities") or 0) - if total_edges == 0 or total_entities == 0: - typer.echo("No recorded connections in the edge index.") - return - - edge_label = _plural(total_edges, "edge") - entity_label = _plural(total_entities, "entity", "entities") - suffix = f" (showing {len(entities)})" if len(entities) < total_entities else "" - typer.echo(f"Network overview: {edge_label} across {entity_label}{suffix}:") - kinds = _format_kinds(body.get("kinds")) - if kinds: - typer.echo(f"Kinds: {kinds}") - for index, entity in enumerate(entities, 1): - if isinstance(entity, dict): - parts = [ - _display_entity(entity.get("entity_id"), entity.get("name")), - f"score={float(entity.get('score') or 0):.2f}", - f"count={int(entity.get('count') or 0)}", - ] - entity_kinds = _format_kinds(entity.get("kinds")) - if entity_kinds: - parts.append(f"kinds={entity_kinds}") - seen = _format_seen(entity) - if seen: - parts.append(seen) - typer.echo(f" {index}. {' '.join(parts)}") - - -def _render_resolve_error(facet: str, entity: str, body: dict) -> None: - if body.get("blocked"): - blocked_name = body.get("blocked_name") or entity - _exit_with(f"Error: Entity '{blocked_name}' is blocked.") - candidates = body.get("candidates") or [] - if candidates: - names = ", ".join(str(c.get("name", "")) for c in candidates[:3]) - _exit_with(f"Error: Entity '{entity}' not found. Did you mean: {names}") - _exit_with(f"Error: Entity '{entity}' not found in facet '{facet}'.") - - -def _resolved_from_body_or_exit(facet: str, entity: str, body: dict) -> dict: - resolved = body.get("resolved") - if isinstance(resolved, dict): - return resolved - _render_resolve_error(facet, entity, body) - raise typer.Exit(1) - - -def _resolve_entity_or_exit(facet: str, entity: str) -> dict: - try: - body = _request( - "GET", - f"/app/entities/api/{facet}/resolve", - params={"name": entity}, - ) - except ConveyClientError as err: - _handle_entity_error(err, entity=entity) - if not isinstance(body, dict): - _exit_with("I couldn't read the journal response.") - return _resolved_from_body_or_exit(facet, entity, body) - - -def _echo_merge_candidate_error(result: dict) -> None: - typer.echo(f"Error: {result.get('error', 'operation failed')}", err=True) - raise typer.Exit(1) - - -def _echo_merge_preview(fields: dict) -> None: - typer.echo("Merge preview:") - akas = fields["akas_added"] - if akas: - typer.echo(f" aliases added: {', '.join(akas)}") - else: - typer.echo(" aliases added: none") - typer.echo(f" emails added: {fields['emails_added_count']}") - typer.echo( - " facet links: " - f"{fields['facet_moved_count']} moved, " - f"{fields['facet_merged_count']} merged" - ) - typer.echo(f" observations moved: {fields['observations_appended']}") - typer.echo( - " speaker labels updated: " - f"{fields['labels_rewritten']} labels, " - f"{fields['corrections_rewritten']} corrections" - ) - typer.echo( - " voice samples moved: " - f"{fields['voiceprints_added']} added, " - f"{fields['voiceprints_target_total']} total" - ) - errors = fields["segment_errors"] - if errors: - typer.echo(f" segment update errors: {len(errors)}") - - -def _require_yes(yes: bool, action: str) -> None: - if not yes: - _exit_with(f"Refusing to {action} without --yes.") - - -def _handle_trust_error(err: ConveyClientError, *, json_output: bool) -> None: - if json_output and err.payload is not None: - typer.echo(json.dumps(err.payload, indent=2, ensure_ascii=False), err=True) - raise typer.Exit(1) - _handle_entity_error(err) - - -def _handle_merge_json_error(err: ConveyClientError) -> None: - """Preserve merge's historical JSON-by-default error shape.""" - payload = {"error": err.detail or err.error} - typer.echo(json.dumps(payload, indent=2, ensure_ascii=False), err=True) - raise typer.Exit(1) - - -def _render_ambiguities(rows: list[dict]) -> None: - if not rows: - typer.echo("No entity ambiguities found.") - return - for row in rows: - scope = row.get("scope") or {} - scope_label = str(scope.get("kind") or "") - if scope.get("facet"): - scope_label += f":{scope['facet']}" - typer.echo( - f"{row.get('ambiguity_id')} {row.get('original_query')} " - f"[{row.get('status')}] scope={scope_label} " - f"tier={row.get('observed_tier')}" - ) - origins = row.get("origins") or [] - if origins: - lanes = ", ".join( - sorted( - { - str(origin.get("lane") or "") - for origin in origins - if isinstance(origin, dict) and origin.get("lane") - } - ) - ) - if lanes: - typer.echo(f" from: {lanes}") - for candidate in row.get("ranked_candidates") or []: - typer.echo( - " - " - f"{_display_entity(candidate.get('id'), candidate.get('name'))} " - f"score={candidate.get('score')}" - ) - - -def _render_entity_versions(body: dict) -> None: - rows = body.get("items") or [] - if not rows: - typer.echo(f"No identity history for {body.get('entity_id', 'entity')}.") - return - for row in rows: - label = ( - f"{row.get('seq')} {row.get('kind')} {row.get('ts')} " - f"{row.get('version_id')}" - ) - if row.get("merge_id"): - label += f" merge={row['merge_id']} ({row.get('merge_state')})" - typer.echo(label) - - -@app.command("list") -def list_entities( - facet: str | None = typer.Argument(None, help="Facet name (or set SOL_FACET)."), - facet_opt: str | None = typer.Option( - None, "--facet", "-f", help="Facet name (or set SOL_FACET)." - ), - day: str | None = typer.Option( - None, "--day", "-d", help="Day (YYYYMMDD) for detected entities." - ), -) -> None: - """List entities for a facet.""" - facet = _resolve_sol_facet(facet or facet_opt) - try: - if day is None: - body = _request("GET", f"/app/entities/api/{facet}") - entities = body.get("attached", []) if isinstance(body, dict) else [] - else: - body = _request( - "GET", - f"/app/entities/api/{facet}/detected", - params={"day": day}, - ) - entities = body.get("items", []) if isinstance(body, dict) else [] - except ConveyClientError as err: - _handle_entity_error(err) - - if not entities: - typer.echo("No entities found.") - return - - label = f"detected for {day}" if day else "attached" - typer.echo(f"{len(entities)} {label} entities:") - for entity in entities: - typer.echo( - " - " - f"{entity.get('name')} ({entity.get('type')}): " - f"{entity.get('description', '')}" - ) - - -@app.command("move") -def move_entity( - entity: str = typer.Argument(help="Entity name or partial match."), - from_facet: str = typer.Option(..., "--from", help="Source facet."), - to_facet: str = typer.Option(..., "--to", help="Destination facet."), - merge: bool = typer.Option( - False, - "--merge", - help="Merge if entity already exists in destination.", - ), - consent: bool = typer.Option( - False, - "--consent", - help="Assert that explicit user approval was obtained before calling this command (agent audit trail).", - ), -) -> None: - """Move an entity from one facet to another.""" - try: - source_body = _request( - "GET", - f"/app/entities/api/{from_facet}/resolve", - params={"name": entity}, - ) - except ConveyClientError as err: - _handle_entity_error(err, entity=entity) - if not isinstance(source_body, dict): - _exit_with("I couldn't read the journal response.") - - if not source_body.get("facet_exists"): - _exit_with(f"Error: Facet '{from_facet}' (--from) does not exist.") - - try: - target_body = _request( - "GET", - f"/app/entities/api/{to_facet}/resolve", - params={"name": entity}, - ) - except ConveyClientError as err: - _handle_entity_error(err, entity=entity) - if not isinstance(target_body, dict): - _exit_with("I couldn't read the journal response.") - - if not target_body.get("facet_exists"): - _exit_with(f"Error: Facet '{to_facet}' (--to) does not exist.") - - resolved = _resolved_from_body_or_exit(from_facet, entity, source_body) - entity_name = str(resolved.get("name", entity)) - - try: - _request( - "POST", - "/app/entities/api/move", - json_body={ - "entity": entity_name, - "from_facet": from_facet, - "to_facet": to_facet, - "merge": merge, - "consent": consent, - }, - ) - except ConveyClientError as err: - _handle_entity_error(err, entity=entity_name) - typer.echo(f"Moved entity '{entity_name}' from '{from_facet}' to '{to_facet}'.") - - -@app.command("detect") -def detect_entity( - type_: str = typer.Argument(metavar="TYPE", help="Entity type."), - entity: str = typer.Argument(help="Entity name or identifier."), - description: str = typer.Argument(help="Description."), - facet: str | None = typer.Option( - None, "--facet", "-f", help="Facet name (or set SOL_FACET)." - ), - day: str | None = typer.Option( - None, "--day", "-d", help="Day (YYYYMMDD, or set SOL_DAY)." - ), -) -> None: - """Record a detected entity for a day in a facet.""" - facet = _resolve_sol_facet(facet) - day = _resolve_sol_day(day) - try: - body = _request( - "POST", - f"/app/entities/api/{facet}/detected", - json_body={ - "day": day, - "type": type_, - "entity": entity, - "description": description, - }, - ) - except ConveyClientError as err: - _handle_entity_error(err, entity=entity, type_=type_) - name = body.get("name", entity) if isinstance(body, dict) else entity - typer.echo(f"Entity '{name}' detected for {day}.") - - -@app.command("attach") -def attach_entity( - type_: str = typer.Argument(metavar="TYPE", help="Entity type."), - entity: str = typer.Argument(help="Entity name."), - description: str = typer.Argument(help="Description."), - facet: str | None = typer.Option( - None, "--facet", "-f", help="Facet name (or set SOL_FACET)." - ), -) -> None: - """Attach an entity permanently to a facet.""" - facet = _resolve_sol_facet(facet) - try: - _request( - "POST", - f"/app/entities/api/{facet}/attach", - json_body={ - "type": type_, - "name": entity, - "description": description, - }, - ) - except ConveyClientError as err: - if err.reason_code == ENTITY_ALREADY_EXISTS.code: - typer.echo(f"Entity '{entity}' already attached.") - return - _handle_entity_error(err, entity=entity, type_=type_) - typer.echo(f"Entity '{entity}' attached.") - - -@app.command("update") -def update_entity( - entity: str = typer.Argument(help="Entity name or identifier."), - description: str = typer.Argument(help="New description."), - facet: str | None = typer.Option( - None, "--facet", "-f", help="Facet name (or set SOL_FACET)." - ), - day: str | None = typer.Option( - None, "--day", "-d", help="Day for detected entities." - ), -) -> None: - """Update an entity description.""" - facet = _resolve_sol_facet(facet) - if day is None: - resolved = _resolve_entity_or_exit(facet, entity) - resolved_name = str(resolved.get("name", entity)) - entity_id = str(resolved.get("id") or "") - try: - _request( - "POST", - f"/app/entities/api/{facet}/update-description", - json_body={ - "entity_id": entity_id, - "description": description, - "entity": entity, - "name": resolved_name, - }, - ) - except ConveyClientError as err: - _handle_entity_error(err, entity=resolved_name) - typer.echo(f"Entity '{resolved_name}' updated.") - return - - try: - _request( - "POST", - f"/app/entities/api/{facet}/update-detected", - json_body={"day": day, "entity": entity, "description": description}, - ) - except ConveyClientError as err: - _handle_entity_error(err, entity=entity) - typer.echo(f"Entity '{entity}' updated for {day}.") - - -@app.command("aka") -def add_aka( - entity: str = typer.Argument(help="Entity name or identifier."), - aka_value: str = typer.Argument(metavar="AKA", help="Alias to add."), - facet: str | None = typer.Option( - None, "--facet", "-f", help="Facet name (or set SOL_FACET)." - ), -) -> None: - """Add an alias to an attached entity.""" - facet = _resolve_sol_facet(facet) - resolved = _resolve_entity_or_exit(facet, entity) - resolved_name = str(resolved.get("name", "")) - - base_name = re.sub(r"\s*\([^)]+\)", "", resolved_name).strip() - first_word = base_name.split()[0] if base_name else None - if first_word and aka_value.lower() == first_word.lower(): - typer.echo( - f"Alias '{aka_value}' is the first word of '{resolved_name}' (skipped)." - ) - return - - aka_list = resolved.get("aka", []) - if not isinstance(aka_list, list): - aka_list = [] - - if aka_value in aka_list: - typer.echo(f"Alias '{aka_value}' already exists for '{resolved_name}'.") - return - - entity_id = str(resolved.get("id") or "") - try: - _request( - "POST", - f"/app/entities/api/{facet}/aka", - json_body={ - "entity_id": entity_id, - "aka": aka_value, - "exclude_name": resolved_name, - "entity": entity, - }, - ) - except ConveyClientError as err: - _handle_entity_error(err, entity=resolved_name) - typer.echo(f"Added alias '{aka_value}' to '{resolved_name}'.") - - -@app.command("record-merge-candidate") -def record_merge_candidate( - source: str = typer.Argument( - help="Variant name (folds INTO the canonical target)." - ), - target: str = typer.Argument(help="Canonical name to keep (merge target)."), - facet: str | None = typer.Option( - None, "--facet", "-f", help="Facet (or set SOL_FACET)." - ), - day: str | None = typer.Option( - None, "--day", "-d", help="Review day YYYYMMDD (or set SOL_DAY)." - ), - evidence: str = typer.Option( - ..., "--evidence", help="Short human summary of the evidence band." - ), - basis: str = typer.Option( - "name-variant", "--basis", help="Variant relationship basis." - ), - detections: int | None = typer.Option( - None, "--detections", help="Combined detection count (strength signal)." - ), - needs: int | None = typer.Option( - None, "--needs", help="Detections still needed to cross promotion threshold." - ), - json_output: bool = typer.Option( - False, "--json", help="Output the record as JSON." - ), -) -> None: - """Record a proposed entity merge (source variant -> canonical target).""" - facet = _resolve_sol_facet(facet) - day = _resolve_sol_day(day) - try: - body = _request( - "POST", - "/app/entities/api/record-merge-candidate", - json_body={ - "facet": facet, - "day": day, - "source": source, - "target": target, - "evidence": evidence, - "basis": basis, - "detections": detections, - "needs": needs, - }, - ) - except ConveyClientError as err: - _handle_entity_error(err) - if not isinstance(body, dict): - _exit_with("I couldn't read the journal response.") - row = body["row"] - if json_output: - typer.echo(json.dumps(row, indent=2, ensure_ascii=False)) - return - if body.get("created"): - typer.echo(f"merge candidate recorded: {source} -> {target}") - return - typer.echo( - f"merge candidate updated: {source} -> {target} (status: {row.get('status')})" - ) - - -@app.command("merge-candidates") -def list_merge_candidates( - facet: str | None = typer.Option(None, "--facet", "-f", help="Filter by facet."), - status: str | None = typer.Option(None, "--status", help="Filter by status."), - json_output: bool = typer.Option(False, "--json", help="Output as JSON."), -) -> None: - """List recorded entity merge candidates.""" - try: - body = _request( - "GET", - "/app/entities/api/merge-candidates", - params=_params(facet=facet, status=status), - ) - except ConveyClientError as err: - _handle_entity_error(err) - rows = body.get("items", []) if isinstance(body, dict) else [] - - if json_output: - typer.echo(json.dumps(rows, indent=2, ensure_ascii=False)) - return - - if not rows: - typer.echo("No merge candidates found.") - return - - for row in rows: - evidence_data = row.get("evidence", {}) - typer.echo( - f"{row.get('source', '')} -> {row.get('target', '')} " - f"[{row.get('status', '')}] facet={row.get('facet', '')} " - f"detections={evidence_data.get('detection_count')} " - f"needs={evidence_data.get('needs')} " - f"last={row.get('last_surfaced', '')}" - ) - - -@app.command("accept-merge-candidate") -def accept_merge_candidate( - source_slug: str = typer.Argument(help="Source entity slug to merge from."), - target_slug: str = typer.Argument(help="Target entity slug to merge into."), - facet: str | None = typer.Option( - None, "--facet", "-f", help="Facet name (or set SOL_FACET)." - ), - commit: bool = typer.Option(False, "--commit/--no-commit"), -) -> None: - """Preview or accept one recorded entity merge candidate.""" - facet = _resolve_sol_facet(facet) - try: - result = _request( - "POST", - "/app/entities/api/accept-merge-candidate", - json_body={ - "facet": facet, - "source_slug": source_slug, - "target_slug": target_slug, - "commit": commit, - }, - ) - except ConveyClientError as err: - _handle_entity_error(err) - if not isinstance(result, dict): - _exit_with("I couldn't read the journal response.") - status = result.get("status") - if status == "error": - _echo_merge_candidate_error(result) - if status == "preview": - _echo_merge_preview(result["fields"]) - return - if status == "accepted": - typer.echo(f"Accepted merge candidate: {source_slug} -> {target_slug}") - if result.get("merge_id"): - typer.echo( - f"Undo with: sol call entities undo-merge {result['merge_id']} --yes" - ) - return - if status == "already_accepted": - typer.echo(f"Merge candidate already accepted: {source_slug} -> {target_slug}") - undo = result.get("undo") or {} - if undo.get("available"): - typer.echo( - f"Undo with: sol call entities undo-merge {undo['merge_id']} --yes" - ) - elif undo.get("reason"): - typer.echo(f"Undo unavailable: {undo['reason']}") - return - typer.echo(f"accept result for {source_slug} -> {target_slug}: {status}") - - -@app.command("dismiss-merge-candidate") -def dismiss_merge_candidate( - source_slug: str = typer.Argument(help="Source entity slug."), - target_slug: str = typer.Argument(help="Target entity slug."), - facet: str | None = typer.Option( - None, "--facet", "-f", help="Facet name (or set SOL_FACET)." - ), -) -> None: - """Dismiss one recorded entity merge candidate.""" - facet = _resolve_sol_facet(facet) - try: - result = _request( - "POST", - "/app/entities/api/dismiss-merge-candidate", - json_body={ - "facet": facet, - "source_slug": source_slug, - "target_slug": target_slug, - }, - ) - except ConveyClientError as err: - _handle_entity_error(err) - if not isinstance(result, dict): - _exit_with("I couldn't read the journal response.") - status = result.get("status") - if status == "error": - _echo_merge_candidate_error(result) - if status == "dismissed": - typer.echo(f"Dismissed merge candidate: {source_slug} -> {target_slug}") - return - if status == "already_dismissed": - typer.echo(f"Merge candidate already dismissed: {source_slug} -> {target_slug}") - return - typer.echo(f"dismiss result for {source_slug} -> {target_slug}: {status}") - - -@app.command("merge") -def merge( - source_slug: str = typer.Argument(help="Source entity slug to merge from."), - target_slug: str = typer.Argument(help="Target entity slug to merge into."), - commit: bool = typer.Option(False, "--commit/--no-commit"), - keep_source_as_aka: bool = typer.Option( - True, - "--keep-source-as-aka/--no-keep-source-as-aka", - ), -) -> None: - """Plan or commit a journal-entity merge.""" - try: - body = _request( - "POST", - "/app/entities/api/merge", - json_body={ - "source_slug": source_slug, - "target_slug": target_slug, - "commit": commit, - "keep_source_as_aka": keep_source_as_aka, - }, - ) - except ConveyClientError as err: - _handle_merge_json_error(err) - if not isinstance(body, dict): - _exit_with("I couldn't read the journal response.") - output = json.dumps(body, indent=2, default=str) - if "error" in body: - typer.echo(output, err=True) - raise typer.Exit(1) - typer.echo(output) - - -@app.command("undo-merge") -def undo_merge( - merge_id: str = typer.Argument(help="Recorded merge id."), - yes: bool = typer.Option(False, "--yes", help="Confirm the recorded split."), - json_output: bool = typer.Option(False, "--json", help="Output as JSON."), -) -> None: - """Undo one recorded entity merge.""" - _require_yes(yes, "undo this merge") - try: - body = _request( - "POST", - f"/app/entities/api/merge/{merge_id}/undo", - json_body={}, - ) - except ConveyClientError as err: - _handle_trust_error(err, json_output=json_output) - if not isinstance(body, dict): - _exit_with("I couldn't read the journal response.") - if json_output: - _echo_json(body) - return - typer.echo( - f"Undid {merge_id}: restored {body.get('source_id')} from " - f"{body.get('target_id')} (history {body.get('history_version_id')})." - ) - - -@app.command("ambiguities") -def list_ambiguities( - status: str | None = typer.Option( - None, - "--status", - help="Filter by open or resolved.", - ), - json_output: bool = typer.Option(False, "--json", help="Output as JSON."), -) -> None: - """List reviewable entity-resolution ambiguities.""" - if status not in {None, "open", "resolved"}: - _exit_with("Error: --status must be open or resolved.") - try: - body = _request( - "GET", - "/app/entities/api/ambiguities", - params=_params(status=status), - ) - except ConveyClientError as err: - _handle_trust_error(err, json_output=json_output) - rows = body.get("items", []) if isinstance(body, dict) else [] - if json_output: - typer.echo(json.dumps(body, indent=2, ensure_ascii=False)) - return - _render_ambiguities(rows) - - -@app.command("resolve-ambiguity") -def resolve_ambiguity( - ambiguity_id: str = typer.Argument(help="Ambiguity id."), - entity_id: str = typer.Argument(help="Existing entity id to choose."), - yes: bool = typer.Option(False, "--yes", help="Confirm this choice."), - json_output: bool = typer.Option(False, "--json", help="Output as JSON."), -) -> None: - """Resolve or replace one persisted ambiguity choice.""" - _require_yes(yes, "resolve this ambiguity") - try: - body = _request( - "POST", - f"/app/entities/api/ambiguities/{ambiguity_id}/resolve", - json_body={"entity_id": entity_id}, - ) - except ConveyClientError as err: - _handle_trust_error(err, json_output=json_output) - if not isinstance(body, dict): - _exit_with("I couldn't read the journal response.") - if json_output: - _echo_json(body) - return - ambiguity = body.get("ambiguity") or {} - typer.echo( - f"Resolved {ambiguity_id} to {entity_id} at {ambiguity.get('resolved_at')}." - ) - - -@app.command("entity-history") -def entity_version_history( - entity_id: str = typer.Argument(help="Journal entity id."), - json_output: bool = typer.Option(False, "--json", help="Output as JSON."), -) -> None: - """Show durable identity versions (not relationship history).""" - try: - body = _request( - "GET", - f"/app/entities/api/journal/entity/{entity_id}/history", - ) - except ConveyClientError as err: - _handle_trust_error(err, json_output=json_output) - if not isinstance(body, dict): - _exit_with("I couldn't read the journal response.") - if json_output: - _echo_json(body) - return - _render_entity_versions(body) - - -@app.command("restore-version") -def restore_version( - entity_id: str = typer.Argument(help="Journal entity id."), - version_id: str = typer.Argument(help="Identity version id."), - yes: bool = typer.Option(False, "--yes", help="Confirm this restore."), - json_output: bool = typer.Option(False, "--json", help="Output as JSON."), -) -> None: - """Restore one ordinary identity version.""" - _require_yes(yes, "restore this identity version") - try: - body = _request( - "POST", - f"/app/entities/api/journal/entity/{entity_id}/restore", - json_body={"version_id": version_id}, - ) - except ConveyClientError as err: - _handle_trust_error(err, json_output=json_output) - if not isinstance(body, dict): - _exit_with("I couldn't read the journal response.") - if json_output: - _echo_json(body) - return - event = body.get("event") or {} - typer.echo( - f"Restored {entity_id} from {version_id}; new history version " - f"{event.get('version_id')}." - ) - - -@app.command("network") -def entity_network( - entity: str = typer.Argument(help="Entity name or identifier."), - kinds: list[str] = typer.Option( - [], - "--kinds", - help="Edge kind filter. Repeat or comma-separate for multiple kinds.", - ), - facet: str | None = typer.Option(None, "--facet", "-f", help="Facet filter."), - day_from: str | None = typer.Option(None, "--day-from", help="Start day YYYYMMDD."), - day_to: str | None = typer.Option(None, "--day-to", help="End day YYYYMMDD."), - limit: int = typer.Option(25, "--limit", "-n", help="Max neighbors."), - evidence_limit: int = typer.Option( - 5, - "--evidence-limit", - help="Evidence rows per neighbor.", - ), - include_principal: bool = typer.Option( - False, - "--include-principal", - help="Include the principal entity in neighbors.", - ), - json_output: bool = typer.Option(False, "--json", help="Output as JSON."), -) -> None: - """Show one-hop recorded connections for an entity.""" - params = _edge_filter_params( - kinds=kinds, - facet=facet, - day_from=day_from, - day_to=day_to, - ) - params.update( - { - "entity": entity, - "limit": limit, - "evidence_limit": evidence_limit, - "include_principal": include_principal, - } - ) - body = _edge_body_or_exit( - "/app/entities/api/network", - params=params, - json_output=json_output, - ) - if json_output: - _echo_json(body) - return - _render_network(body) - - -@app.command("history") -def entity_history( - entity: str = typer.Argument(help="Entity name or identifier."), - peer: str | None = typer.Argument( - None, - help="Peer entity name or identifier. Defaults to the principal entity.", - ), - kinds: list[str] = typer.Option( - [], - "--kinds", - help="Edge kind filter. Repeat or comma-separate for multiple kinds.", - ), - facet: str | None = typer.Option(None, "--facet", "-f", help="Facet filter."), - day_from: str | None = typer.Option(None, "--day-from", help="Start day YYYYMMDD."), - day_to: str | None = typer.Option(None, "--day-to", help="End day YYYYMMDD."), - limit: int = typer.Option(50, "--limit", "-n", help="Max evidence rows."), - offset: int = typer.Option(0, "--offset", help="Evidence row offset."), - json_output: bool = typer.Option(False, "--json", help="Output as JSON."), -) -> None: - """Show recorded connection evidence for an entity pair.""" - params = _edge_filter_params( - kinds=kinds, - facet=facet, - day_from=day_from, - day_to=day_to, - ) - params.update( - _params( - entity=entity, - peer=peer, - limit=limit, - offset=offset, - ) - ) - body = _edge_body_or_exit( - "/app/entities/api/history", - params=params, - json_output=json_output, - ) - if json_output: - _echo_json(body) - return - _render_history(body) - - -@app.command("overview") -def entity_overview( - kinds: list[str] = typer.Option( - [], - "--kinds", - help="Edge kind filter. Repeat or comma-separate for multiple kinds.", - ), - facet: str | None = typer.Option(None, "--facet", "-f", help="Facet filter."), - day_from: str | None = typer.Option(None, "--day-from", help="Start day YYYYMMDD."), - day_to: str | None = typer.Option(None, "--day-to", help="End day YYYYMMDD."), - limit: int = typer.Option(25, "--limit", "-n", help="Max entities."), - json_output: bool = typer.Option(False, "--json", help="Output as JSON."), -) -> None: - """Show a global recorded-connections overview.""" - params = _edge_filter_params( - kinds=kinds, - facet=facet, - day_from=day_from, - day_to=day_to, - ) - params["limit"] = limit - body = _edge_body_or_exit( - "/app/entities/api/overview", - params=params, - json_output=json_output, - ) - if json_output: - _echo_json(body) - return - _render_overview(body) - - -@app.command("observations") -def list_observations( - entity: str = typer.Argument(help="Entity name or identifier."), - facet: str | None = typer.Option( - None, "--facet", "-f", help="Facet name (or set SOL_FACET)." - ), -) -> None: - """List observations for an attached entity.""" - facet = _resolve_sol_facet(facet) - resolved = _resolve_entity_or_exit(facet, entity) - resolved_name = str(resolved.get("name", "")) - try: - body = _request( - "GET", - f"/app/entities/api/{facet}/observations", - params={"name": resolved_name}, - ) - except ConveyClientError as err: - _handle_entity_error(err, entity=resolved_name) - obs = body.get("items", []) if isinstance(body, dict) else [] - - if not obs: - typer.echo(f"No observations for '{resolved_name}'.") - return - - typer.echo(f"{len(obs)} observations for '{resolved_name}':") - for i, observation in enumerate(obs, 1): - typer.echo(f" {i}. {observation.get('content', '')}") - - -@app.command("observe") -def observe_entity( - entity: str = typer.Argument(help="Entity name or identifier."), - content: str = typer.Argument(help="Observation content."), - facet: str | None = typer.Option( - None, "--facet", "-f", help="Facet name (or set SOL_FACET)." - ), - source_day: str | None = typer.Option(None, "--source-day", help="Day (YYYYMMDD)."), -) -> None: - """Add an observation to an attached entity.""" - facet = _resolve_sol_facet(facet) - resolved = _resolve_entity_or_exit(facet, entity) - resolved_name = str(resolved.get("name", "")) - try: - _request( - "POST", - f"/app/entities/api/{facet}/observe", - json_body={ - "name": resolved_name, - "content": content, - "source_day": source_day, - "entity": entity, - }, - ) - except ConveyClientError as err: - _handle_entity_error(err, entity=resolved_name) - typer.echo(f"Observation added to '{resolved_name}'.") - - -@app.command("search") -def entity_search( - query_pos: str | None = typer.Argument(None, help="Text search."), - query: str | None = typer.Option(None, "--query", "-q", help="Text search."), - type_: str | None = typer.Option(None, "--type", "-t", help="Entity type."), - facet: str | None = typer.Option(None, "--facet", "-f", help="Filter by facet."), - since: str | None = typer.Option(None, "--since", help="Detected since YYYYMMDD."), - limit: int = typer.Option(20, "--limit", "-n", help="Max results."), -) -> None: - """Search entities by text, type, facet, or activity.""" - query = query_pos or query - try: - body = _request( - "GET", - "/app/entities/api/search", - params=_params( - query=query, - type=type_, - facet=facet, - since=since, - limit=limit, - ), - ) - except ConveyClientError as err: - _handle_entity_error(err) - results = body.get("items", []) if isinstance(body, dict) else [] - if not results: - typer.echo("No entities found.") - return - typer.echo(f"{len(results)} entities:") - for entity in results: - facets = ", ".join(entity.get("facets", [])) - typer.echo(f" - {entity['name']} ({entity['type']}): {entity['description']}") - if facets: - typer.echo(f" facets: {facets}") diff --git a/solstone/apps/entities/tests/test_call_argforms.py b/solstone/apps/entities/tests/test_call_argforms.py deleted file mode 100644 index 455fd9203..000000000 --- a/solstone/apps/entities/tests/test_call_argforms.py +++ /dev/null @@ -1,48 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""CLI positional/option arg-form parity tests for entities commands.""" - -from __future__ import annotations - -from pathlib import Path - -import pytest -from typer.testing import CliRunner - -from solstone.apps.entities.call import app as entities_app -from solstone.think.convey_client import ConveyClient -from solstone.think.indexer.journal import scan_journal -from tests._baseline_harness import make_test_client - -runner = CliRunner() - - -@pytest.fixture -def indexed_entities_client(journal_copy, monkeypatch: pytest.MonkeyPatch) -> Path: - scan_journal(str(journal_copy), full=True) - - def client() -> ConveyClient: - return ConveyClient( - session=make_test_client(journal_copy), - base_url="", - ) - - monkeypatch.setattr("solstone.apps.entities.call.get_client", client) - return journal_copy - - -def _assert_same_output(left_args: list[str], right_args: list[str]) -> None: - left = runner.invoke(entities_app, left_args) - right = runner.invoke(entities_app, right_args) - - assert left.exit_code == right.exit_code - assert left.stdout == right.stdout - - -def test_list_positional_and_flag_forms_match(indexed_entities_client) -> None: - _assert_same_output(["list", "montague"], ["list", "-f", "montague"]) - - -def test_search_positional_and_flag_forms_match(indexed_entities_client) -> None: - _assert_same_output(["search", "Romeo"], ["search", "-q", "Romeo"]) diff --git a/solstone/apps/entities/tests/test_call_edges.py b/solstone/apps/entities/tests/test_call_edges.py deleted file mode 100644 index 48b824595..000000000 --- a/solstone/apps/entities/tests/test_call_edges.py +++ /dev/null @@ -1,330 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""CLI tests for derived entity edge reads.""" - -from __future__ import annotations - -import json -from pathlib import Path - -import pytest -from typer.testing import CliRunner - -from solstone.apps.entities.call import app as entities_app -from solstone.think.convey_client import ConveyClient -from solstone.think.indexer.journal import DB_NAME, INDEX_DIR, scan_journal -from tests._baseline_harness import make_test_client - -runner = CliRunner() - - -@pytest.fixture -def indexed_entities_client( - journal_copy: Path, monkeypatch: pytest.MonkeyPatch -) -> Path: - scan_journal(str(journal_copy), full=True) - - def client() -> ConveyClient: - return ConveyClient( - session=make_test_client(journal_copy), - base_url="", - ) - - monkeypatch.setattr("solstone.apps.entities.call.get_client", client) - return journal_copy - - -@pytest.fixture -def entities_client_without_index( - journal_copy: Path, - monkeypatch: pytest.MonkeyPatch, -) -> Path: - db_path = journal_copy / INDEX_DIR / DB_NAME - if db_path.exists(): - db_path.unlink() - - def client() -> ConveyClient: - return ConveyClient( - session=make_test_client(journal_copy), - base_url="", - ) - - monkeypatch.setattr("solstone.apps.entities.call.get_client", client) - return journal_copy - - -def _captured_output(result) -> str: - return result.output - - -def _route_payload(journal: Path, path: str, query_string: dict[str, object]) -> dict: - response = make_test_client(journal).get(path, query_string=query_string) - assert response.status_code == 200 - data = response.get_json() - assert isinstance(data, dict) - return data - - -def _plural(count: int, singular: str, plural: str | None = None) -> str: - word = singular if count == 1 else plural or f"{singular}s" - return f"{count} {word}" - - -def _display_entity(entity_id: object, name: object = None) -> str: - entity_id_text = str(entity_id or "") - name_text = str(name or "") - if name_text and name_text != entity_id_text: - return f"{name_text} ({entity_id_text})" - return entity_id_text - - -def _format_kinds(kinds: object) -> str: - if not isinstance(kinds, dict): - return "" - parts = [] - for kind in sorted(kinds): - info = kinds.get(kind) - count = info.get("count") if isinstance(info, dict) else None - if count: - parts.append(f"{kind}:{count}") - return ", ".join(parts) - - -def test_network_truncated_header_and_evidence(indexed_entities_client: Path) -> None: - payload = _route_payload( - indexed_entities_client, - "/app/entities/api/network", - { - "entity": "romeo_montague", - "limit": "1", - "evidence_limit": "1", - }, - ) - result = runner.invoke( - entities_app, - ["network", "romeo_montague", "--limit", "1", "--evidence-limit", "1"], - ) - - assert result.exit_code == 0 - total = int(payload["total_neighbors"]) - shown = len(payload["neighbors"]) - assert shown < total - expected_header = ( - f"{_plural(total, 'recorded connection')} for romeo_montague (showing {shown}):" - ) - assert expected_header in result.output - neighbor = payload["neighbors"][0] - evidence = neighbor["evidence"][0] - assert _display_entity(neighbor["entity_id"], neighbor.get("name")) in result.output - assert "score=" in result.output - assert "kinds=" in result.output - assert "seen=" in result.output - assert str(evidence["day"]) in result.output - assert str(evidence["kind"]) in result.output - assert str(evidence["label"]) in result.output - - -def test_history_truncated_header_and_source_path( - indexed_entities_client: Path, -) -> None: - payload = _route_payload( - indexed_entities_client, - "/app/entities/api/history", - { - "entity": "juliet_capulet", - "peer": "romeo_montague", - "limit": "1", - }, - ) - result = runner.invoke( - entities_app, - ["history", "juliet_capulet", "romeo_montague", "--limit", "1"], - ) - - assert result.exit_code == 0 - total = int(payload["total"]) - shown = len(payload["evidence"]) - assert shown < total - peer = _display_entity(payload["peer_id"], payload.get("peer_name")) - expected_header = ( - f"{_plural(total, 'evidence row')} for juliet_capulet <-> {peer} " - f"(showing 1-{shown}):" - ) - assert expected_header in result.output - evidence = payload["evidence"][0] - assert str(evidence["day"]) in result.output - assert str(evidence["kind"]) in result.output - assert str(evidence["label"]) in result.output - assert f"[{evidence['source']}]" in result.output - assert str(evidence["path"]) in result.output - - -def test_overview_truncated_header(indexed_entities_client: Path) -> None: - payload = _route_payload( - indexed_entities_client, - "/app/entities/api/overview", - {"limit": "1"}, - ) - result = runner.invoke(entities_app, ["overview", "--limit", "1"]) - - assert result.exit_code == 0 - totals = payload["totals"] - shown = len(payload["entities"]) - assert shown < int(totals["entities"]) - expected_header = ( - f"Network overview: {_plural(int(totals['edges']), 'edge')} across " - f"{_plural(int(totals['entities']), 'entity', 'entities')} " - f"(showing {shown}):" - ) - assert expected_header in result.output - assert f"Kinds: {_format_kinds(payload['kinds'])}" in result.output - entity = payload["entities"][0] - assert _display_entity(entity["entity_id"], entity.get("name")) in result.output - - -def test_network_empty_state_exits_zero(indexed_entities_client) -> None: - result = runner.invoke(entities_app, ["network", "alice_johnson"]) - - assert result.exit_code == 0 - assert result.output == "No recorded connections for alice_johnson.\n" - - -def test_resolution_failure_names_query_with_candidates( - indexed_entities_client, -) -> None: - result = runner.invoke(entities_app, ["network", "Jliet"]) - - assert result.exit_code == 1 - output = _captured_output(result) - assert "Error: Entity 'Jliet' not found. Did you mean:" in output - assert "Juliet Capulet (juliet_capulet)" in output - - -def test_resolution_failure_names_query_without_candidates( - indexed_entities_client, -) -> None: - result = runner.invoke(entities_app, ["network", "zzzznotreal"]) - - assert result.exit_code == 1 - assert "Error: Entity 'zzzznotreal' not found." in _captured_output(result) - assert "Did you mean" not in _captured_output(result) - - -def test_resolution_failure_json_is_route_payload(indexed_entities_client) -> None: - result = runner.invoke(entities_app, ["network", "Jliet", "--json"]) - - assert result.exit_code == 1 - data = json.loads(result.output) - assert data["resolved"] is None - assert data["query"] == "Jliet" - assert data["candidates"] - assert "Error:" not in result.output - - -def test_network_json_includes_evidence_class(indexed_entities_client: Path) -> None: - expected = _route_payload( - indexed_entities_client, - "/app/entities/api/network", - { - "entity": "romeo_montague", - "limit": "1", - "evidence_limit": "1", - }, - ) - result = runner.invoke( - entities_app, - [ - "network", - "romeo_montague", - "--limit", - "1", - "--evidence-limit", - "1", - "--json", - ], - ) - - assert result.exit_code == 0 - data = json.loads(result.output) - assert data == expected - assert data["neighbors"] - assert data["neighbors"][0]["evidence_class"] in { - "attendance", - "semantic", - "mixed", - } - - -def test_history_json_is_route_payload(indexed_entities_client: Path) -> None: - expected = _route_payload( - indexed_entities_client, - "/app/entities/api/history", - { - "entity": "juliet_capulet", - "peer": "romeo_montague", - "limit": "1", - }, - ) - result = runner.invoke( - entities_app, - ["history", "juliet_capulet", "romeo_montague", "--limit", "1", "--json"], - ) - - assert result.exit_code == 0 - data = json.loads(result.output) - assert "success" not in data - assert data == expected - - -def test_kinds_bad_kind_detail_survives_real_client( - indexed_entities_client, -) -> None: - result = runner.invoke( - entities_app, - ["network", "romeo_montague", "--kinds", "bad-kind"], - ) - - assert result.exit_code == 1 - assert "Error: Unknown edge kind: 'bad-kind'" in _captured_output(result) - - -def test_kinds_accepts_comma_and_repeat_forms(indexed_entities_client) -> None: - result = runner.invoke( - entities_app, - [ - "overview", - "--kinds", - "attended-with,spoke-with", - "--kinds", - "mentioned", - "--json", - ], - ) - - assert result.exit_code == 0 - data = json.loads(result.output) - assert data["filters"]["kinds"] == [ - "attended-with", - "spoke-with", - "mentioned", - ] - assert data["entities"] - assert all( - entity["evidence_class"] in {"attendance", "semantic", "mixed"} - for entity in data["entities"] - ) - - -def test_unbuilt_index_message_survives_real_client( - entities_client_without_index, -) -> None: - result = runner.invoke(entities_app, ["overview"]) - - assert result.exit_code == 1 - output = _captured_output(result) - assert ( - "I couldn't read your connections because the index hasn't been built yet." - in output - ) - assert "journal indexer --rebuild-edges" in output diff --git a/solstone/apps/entities/tests/test_call_trust_core.py b/solstone/apps/entities/tests/test_call_trust_core.py deleted file mode 100644 index a0e9cc43e..000000000 --- a/solstone/apps/entities/tests/test_call_trust_core.py +++ /dev/null @@ -1,206 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""CLI parity tests for entity trust-core routes.""" - -from __future__ import annotations - -import json - -import pytest -from typer.testing import CliRunner - -from solstone.apps.entities import call as entity_call -from solstone.apps.entities.call import app as entities_app -from solstone.think.convey_client import ConveyClientError - -runner = CliRunner() - - -@pytest.fixture -def request_spy(monkeypatch): - calls: list[tuple[str, str, dict | None, dict | None]] = [] - - def request(method, path, *, params=None, json_body=None): - calls.append((method, path, params, json_body)) - if path == "/app/entities/api/ambiguities": - return { - "items": [ - { - "ambiguity_id": "amb_one", - "original_query": "Sarah", - "status": "open", - "scope": {"kind": "journal"}, - "observed_tier": 5, - "origins": [{"lane": "speaker.discovery"}], - "ranked_candidates": [ - {"id": "sarah_lee", "name": "Sarah Lee", "score": 90} - ], - } - ] - } - if path.endswith("/resolve"): - return { - "ambiguity": { - "ambiguity_id": "amb_one", - "resolved_at": "2026-07-14T12:00:00Z", - }, - "entity": {"id": "sarah_lee", "name": "Sarah Lee"}, - } - if path.endswith("/history"): - return { - "entity_id": "sarah_lee", - "items": [ - { - "seq": 1, - "kind": "create", - "ts": "2026-07-14T11:00:00Z", - "version_id": "vh_one", - } - ], - } - if path.endswith("/restore"): - return { - "restored": True, - "event": {"version_id": "vh_new", "kind": "restore"}, - } - if path.endswith("/undo"): - return { - "undone": True, - "merge_id": "em_one", - "source_id": "source", - "target_id": "target", - "history_version_id": "vh_undo", - } - raise AssertionError(path) - - monkeypatch.setattr(entity_call, "_request", request) - return calls - - -@pytest.mark.parametrize( - "args", - [ - ["undo-merge", "em_one"], - ["resolve-ambiguity", "amb_one", "sarah_lee"], - ["restore-version", "sarah_lee", "vh_one"], - ], -) -def test_write_commands_require_yes_without_http(request_spy, args) -> None: - result = runner.invoke(entities_app, args) - assert result.exit_code == 1 - assert "without --yes" in result.output - assert request_spy == [] - - -def test_ambiguities_text_and_json_are_route_backed(request_spy) -> None: - text = runner.invoke(entities_app, ["ambiguities", "--status", "open"]) - assert text.exit_code == 0 - assert "amb_one Sarah [open]" in text.output - assert "from: speaker.discovery" in text.output - assert "Sarah Lee (sarah_lee)" in text.output - - raw = runner.invoke(entities_app, ["ambiguities", "--json"]) - assert raw.exit_code == 0 - assert json.loads(raw.output)["items"][0]["ambiguity_id"] == "amb_one" - assert request_spy[0][:2] == ("GET", "/app/entities/api/ambiguities") - - -def test_resolve_undo_history_and_restore_text_commands(request_spy) -> None: - resolved = runner.invoke( - entities_app, - ["resolve-ambiguity", "amb_one", "sarah_lee", "--yes"], - ) - assert resolved.exit_code == 0 - assert "Resolved amb_one to sarah_lee" in resolved.output - - undone = runner.invoke(entities_app, ["undo-merge", "em_one", "--yes"]) - assert undone.exit_code == 0 - assert "Undid em_one" in undone.output - - history = runner.invoke(entities_app, ["entity-history", "sarah_lee"]) - assert history.exit_code == 0 - assert "create" in history.output - assert "vh_one" in history.output - - restored = runner.invoke( - entities_app, - ["restore-version", "sarah_lee", "vh_one", "--yes"], - ) - assert restored.exit_code == 0 - assert "new history version vh_new" in restored.output - - assert [call[:2] for call in request_spy] == [ - ("POST", "/app/entities/api/ambiguities/amb_one/resolve"), - ("POST", "/app/entities/api/merge/em_one/undo"), - ("GET", "/app/entities/api/journal/entity/sarah_lee/history"), - ("POST", "/app/entities/api/journal/entity/sarah_lee/restore"), - ] - - -@pytest.mark.parametrize( - "args, expected_key", - [ - (["undo-merge", "em_one", "--yes", "--json"], "undone"), - ( - [ - "resolve-ambiguity", - "amb_one", - "sarah_lee", - "--yes", - "--json", - ], - "ambiguity", - ), - (["entity-history", "sarah_lee", "--json"], "items"), - ( - [ - "restore-version", - "sarah_lee", - "vh_one", - "--yes", - "--json", - ], - "restored", - ), - ], -) -def test_new_commands_json_emit_route_body(request_spy, args, expected_key) -> None: - result = runner.invoke(entities_app, args) - assert result.exit_code == 0 - assert expected_key in json.loads(result.output) - - -def test_repair_required_json_preserves_state_and_does_not_suggest_retry( - monkeypatch, -) -> None: - payload = { - "error": "I couldn't complete that entity operation.", - "reason_code": "entity_operation_failed", - "detail": "rollback failed", - "operation_state": "repair_required", - "mutation_applied": True, - "source_state": {"exists": True}, - "target_state": {"exists": True}, - "safe_remediation": "Inspect before retrying.", - } - - def fail(*args, **kwargs): - raise ConveyClientError( - payload["error"], - reason_code=payload["reason_code"], - detail=payload["detail"], - status=500, - payload=payload, - ) - - monkeypatch.setattr(entity_call, "_request", fail) - - result = runner.invoke( - entities_app, - ["undo-merge", "em_repair", "--yes", "--json"], - ) - - assert result.exit_code == 1 - assert json.loads(result.stderr)["operation_state"] == "repair_required" - assert "sol call entities undo-merge" not in result.stderr diff --git a/solstone/apps/entities/tests/test_merge.py b/solstone/apps/entities/tests/test_merge.py deleted file mode 100644 index b846313fd..000000000 --- a/solstone/apps/entities/tests/test_merge.py +++ /dev/null @@ -1,1222 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""Tests for ``sol call entities merge``.""" - -from __future__ import annotations - -import json -import os -import sqlite3 -from pathlib import Path - -import numpy as np -import pytest -from typer.testing import CliRunner - -from solstone.apps.entities.call import app as entities_app -from solstone.think.convey_client import ConveyClient -from solstone.think.entities import merge as merge_mod -from solstone.think.entities.history import iter_entity_history -from solstone.think.entities.journal import load_journal_entity -from solstone.think.indexer.edges import insert_edges, rebuild_edges -from solstone.think.indexer.journal import get_journal_index -from tests._baseline_harness import make_test_client -from tests._sqlite_assertions import edges_content_hash - -runner = CliRunner() -STREAM = "test" - - -@pytest.fixture(autouse=True) -def _entities_client(monkeypatch: pytest.MonkeyPatch) -> None: - def client() -> ConveyClient: - journal = Path(os.environ["SOLSTONE_JOURNAL"]) - return ConveyClient( - session=make_test_client(journal), - base_url="", - ) - - monkeypatch.setattr("solstone.apps.entities.call.get_client", client) - - -def _read_json(path): - return json.loads(path.read_text(encoding="utf-8")) - - -def _write_json(path, payload) -> None: - path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") - - -def _entity_path(env, entity_id: str): - return env.journal / "entities" / entity_id / "entity.json" - - -def _update_entity(env, entity_id: str, **fields) -> None: - path = _entity_path(env, entity_id) - payload = _read_json(path) - payload.update(fields) - _write_json(path, payload) - - -def _labels_path(env, day: str, segment_key: str): - return env.journal / day / STREAM / segment_key / "talents" / "speaker_labels.json" - - -def _corrections_path(env, day: str, segment_key: str): - return ( - env.journal - / day - / STREAM - / segment_key - / "talents" - / "speaker_corrections.json" - ) - - -def _activity_path(env, facet: str, day: str) -> Path: - return env.journal / "facets" / facet / "activities" / f"{day}.jsonl" - - -def _write_activity_records( - env, - facet: str, - day: str, - records: list[dict], -) -> Path: - path = _activity_path(env, facet, day) - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text( - "".join(json.dumps(record) + "\n" for record in records), - encoding="utf-8", - ) - return path - - -def _audit_log_path(env): - return env.journal / "logs" / "entity-merges.jsonl" - - -def _voiceprint_count(env, entity_id: str) -> int: - path = env.journal / "entities" / entity_id / "voiceprints.npz" - with np.load(path, allow_pickle=False) as data: - return len(data["embeddings"]) - - -def _edge_row( - src: str, - dst: str, - kind: str, - path: str, - *, - weight: int = 1, -) -> dict: - return { - "src": src, - "dst": dst, - "kind": kind, - "source": "merge-test", - "path": path, - "weight": weight, - } - - -def _insert_edge_rows(env, rows: list[dict]) -> None: - conn, _ = get_journal_index(str(env.journal)) - insert_edges(conn, rows) - conn.commit() - conn.close() - - -def _edge_hash(env) -> str: - conn, _ = get_journal_index(str(env.journal)) - try: - return edges_content_hash(conn) - finally: - conn.close() - - -def _edge_rows(env) -> list[dict]: - conn, _ = get_journal_index(str(env.journal)) - conn.row_factory = sqlite3.Row - try: - return [ - dict(row) - for row in conn.execute( - "SELECT src, dst, path FROM edges ORDER BY path" - ).fetchall() - ] - finally: - conn.close() - - -def _edge_rows_with_kind(env) -> list[dict]: - conn, _ = get_journal_index(str(env.journal)) - conn.row_factory = sqlite3.Row - try: - return [ - dict(row) - for row in conn.execute( - "SELECT src, dst, kind, source, path " - "FROM edges ORDER BY kind, path, src, dst" - ).fetchall() - ] - finally: - conn.close() - - -def test_merge_dry_run_plans_without_writing(speakers_env): - env = speakers_env() - env.create_segment("20240101", "143022_300", ["mic_audio"]) - env.create_entity( - "Dry Alias", - voiceprints=[ - ("20240101", "143022_300", "mic_audio", 1), - ("20240101", "143022_300", "mic_audio", 2), - ], - ) - env.create_entity( - "Dry Canon", - voiceprints=[("20240101", "143022_300", "mic_audio", 3)], - ) - env.create_facet_relationship( - "work", - "dry_alias", - observations=["Likes coffee"], - ) - env.create_facet_relationship( - "work", - "dry_canon", - observations=["Senior role"], - ) - env.create_facet_relationship("personal", "dry_alias", description="Runner") - env.create_speaker_labels( - "20240101", - "143022_300", - [ - { - "sentence_id": 1, - "speaker": "dry_alias", - "confidence": "high", - "method": "acoustic", - } - ], - ) - env.create_speaker_corrections( - "20240101", - "143022_300", - [ - { - "sentence_id": 1, - "original_speaker": "dry_alias", - "corrected_speaker": "dry_alias", - "timestamp": 1700000000000, - } - ], - ) - activity_path = _write_activity_records( - env, - "work", - "20240101", - [ - { - "id": "meeting_143022_300", - "activity": "meeting", - "title": "Dry-run merge meeting", - "created_at": 1700000000000, - "participation": [ - {"role": "attendee", "entity_id": "dry_alias"}, - {"role": "attendee", "entity_id": "dry_peer"}, - ], - "commitments": [ - { - "owner_entity_id": "dry_alias", - "counterparty_entity_id": "dry_peer", - "action": "follow up", - } - ], - } - ], - ) - cache_path = env.journal / "awareness" / "discovery_clusters.json" - cache_path.parent.mkdir(parents=True, exist_ok=True) - cache_path.write_text('{"clusters": []}', encoding="utf-8") - - source_before = _entity_path(env, "dry_alias").read_text(encoding="utf-8") - target_before = _entity_path(env, "dry_canon").read_text(encoding="utf-8") - labels_before = _labels_path(env, "20240101", "143022_300").read_text( - encoding="utf-8" - ) - corrections_before = _corrections_path(env, "20240101", "143022_300").read_text( - encoding="utf-8" - ) - activity_before = activity_path.read_text(encoding="utf-8") - - result = runner.invoke(entities_app, ["merge", "dry_alias", "dry_canon"]) - - assert result.exit_code == 0, f"{result.output}\n{result.exception!r}" - data = json.loads(result.output) - assert data["merged"] is False - assert data["identity"]["akas_added"] == [] - assert data["voiceprints"]["added"] == 0 - assert data["facets"]["moved"] == [] - assert data["segments"]["files_scanned"] == 0 - assert "Dry Alias" in data["would_identity"]["akas_added"] - assert data["would_voiceprints"]["added"] == 2 - assert data["would_facets"]["merged"] == ["work"] - assert data["would_facets"]["moved"] == ["personal"] - assert data["would_segments"]["labels_rewritten"] == 1 - assert data["would_segments"]["corrections_rewritten"] == 1 - assert data["activities"] == { - "records_rewritten": 0, - "fields_rewritten": 0, - "files_scanned": 0, - "files_rewritten": 0, - "errors": [], - } - assert data["would_activities"] == { - "records_rewritten": 1, - "fields_rewritten": 2, - "files_scanned": 1, - "files_rewritten": 1, - "errors": [], - } - assert data["would_fold_edges"] is None - assert data["audit_log_path"] is None - assert data["caches_cleared"] == [] - - assert _entity_path(env, "dry_alias").read_text(encoding="utf-8") == source_before - assert _entity_path(env, "dry_canon").read_text(encoding="utf-8") == target_before - assert ( - _labels_path(env, "20240101", "143022_300").read_text(encoding="utf-8") - == labels_before - ) - assert ( - _corrections_path(env, "20240101", "143022_300").read_text(encoding="utf-8") - == corrections_before - ) - assert activity_path.read_text(encoding="utf-8") == activity_before - assert cache_path.exists() - assert load_journal_entity("dry_alias") is not None - assert not _audit_log_path(env).exists() - - -def test_merge_dry_run_reports_would_fold_edges(speakers_env): - env = speakers_env() - env.create_entity("Edge Count Source") - env.create_entity("Edge Count Target") - _insert_edge_rows( - env, - [ - _edge_row("edge_count_source", "edge_count_peer", "co-present", "count/1"), - _edge_row( - "edge_other_peer", "edge_count_source", "committed-to", "count/2" - ), - ], - ) - before_hash = _edge_hash(env) - - result = merge_mod.merge_entity( - "edge_count_source", - "edge_count_target", - commit=False, - ) - - assert result["merged"] is False - assert result["would_fold_edges"] == 2 - assert result["edges"] == { - "rows_folded": 0, - "self_edges_dropped": 0, - "error": None, - } - assert _edge_hash(env) == before_hash - assert load_journal_entity("edge_count_source") is not None - - -def test_merge_commit_deep_merges_and_logs(speakers_env): - env = speakers_env() - env.create_segment("20240101", "143022_300", ["mic_audio"]) - env.create_entity( - "Alice Alias", - voiceprints=[ - ("20240101", "143022_300", "mic_audio", 1), - ("20240101", "143022_300", "mic_audio", 2), - ], - ) - env.create_entity( - "Alice Canonical", - voiceprints=[("20240101", "143022_300", "mic_audio", 3)], - ) - env.create_facet_relationship( - "work", - "alice_alias", - description="Works at Acme", - attached_at=1600000000000, - observations=["Likes coffee", "Morning person"], - ) - env.create_facet_relationship( - "work", - "alice_canonical", - description="Senior engineer", - attached_at=1700000000000, - observations=["Staff role"], - ) - env.create_facet_relationship("personal", "alice_alias", description="Hiker") - env.create_speaker_labels( - "20240101", - "143022_300", - [ - { - "sentence_id": 1, - "speaker": "alice_alias", - "confidence": "high", - "method": "acoustic", - }, - { - "sentence_id": 2, - "speaker": "alice_canonical", - "confidence": "high", - "method": "acoustic", - }, - { - "sentence_id": 3, - "speaker": "alice_alias", - "confidence": "medium", - "method": "context", - }, - ], - ) - env.create_speaker_corrections( - "20240101", - "143022_300", - [ - { - "sentence_id": 1, - "original_speaker": "alice_alias", - "corrected_speaker": "alice_alias", - "timestamp": 1700000000000, - }, - ], - ) - cache_path = env.journal / "awareness" / "discovery_clusters.json" - cache_path.parent.mkdir(parents=True, exist_ok=True) - cache_path.write_text('{"clusters": []}', encoding="utf-8") - - result = runner.invoke( - entities_app, - ["merge", "alice_alias", "alice_canonical", "--commit"], - ) - - assert result.exit_code == 0, f"{result.output}\n{result.exception!r}" - data = json.loads(result.output) - assert data["merged"] is True - assert "Alice Alias" in data["identity"]["akas_added"] - assert data["voiceprints"]["added"] == 2 - assert data["voiceprints"]["target_total"] == 3 - assert "work" in data["facets"]["merged"] - assert "personal" in data["facets"]["moved"] - assert data["facets"]["observations_appended"] == 2 - assert data["segments"]["labels_rewritten"] == 1 - assert data["segments"]["corrections_rewritten"] == 1 - assert data["segments"]["errors"] == [] - assert data["activities"] == { - "records_rewritten": 0, - "fields_rewritten": 0, - "files_scanned": 0, - "files_rewritten": 0, - "errors": [], - } - assert data["audit_log_path"] == str(_audit_log_path(env)) - assert data["caches_cleared"] == ["discovery_clusters"] - - assert load_journal_entity("alice_alias") is None - canonical = load_journal_entity("alice_canonical") - assert canonical is not None - assert "Alice Alias" in canonical["aka"] - - assert _voiceprint_count(env, "alice_canonical") == 3 - - labels = _read_json(_labels_path(env, "20240101", "143022_300")) - speakers = [label["speaker"] for label in labels["labels"]] - assert "alice_alias" not in speakers - assert speakers.count("alice_canonical") == 3 - - corrections = _read_json(_corrections_path(env, "20240101", "143022_300")) - for correction in corrections["corrections"]: - assert correction.get("original_speaker") != "alice_alias" - assert correction.get("corrected_speaker") != "alice_alias" - - observations_path = ( - env.journal - / "facets" - / "work" - / "entities" - / "alice_canonical" - / "observations.jsonl" - ) - contents = [ - json.loads(line)["content"] - for line in observations_path.read_text(encoding="utf-8").splitlines() - if line.strip() - ] - assert set(contents) == {"Staff role", "Likes coffee", "Morning person"} - assert not cache_path.exists() - - audit_entries = [ - json.loads(line) - for line in _audit_log_path(env).read_text(encoding="utf-8").splitlines() - if line.strip() - ] - assert len(audit_entries) == 1 - assert isinstance(audit_entries[0]["ts"], int) - assert audit_entries[0]["caller"] == "entities.merge" - assert audit_entries[0]["source_id"] == "alice_alias" - assert audit_entries[0]["source_display_name"] == "Alice Alias" - assert audit_entries[0]["target_id"] == "alice_canonical" - assert audit_entries[0]["target_display_name"] == "Alice Canonical" - assert audit_entries[0]["principal_transferred"] is False - assert set(audit_entries[0]["counts"]) == { - "identity", - "voiceprints", - "facets", - "segments", - "activities", - "edges", - } - - -def test_merge_commit_folds_edges_and_records_audit_counts(speakers_env): - env = speakers_env() - env.create_entity("Edge Fold Source") - env.create_entity("Edge Fold Target") - _insert_edge_rows( - env, - [ - _edge_row( - "edge_fold_source", - "edge_fold_peer", - "co-present", - "fold/survive", - ), - _edge_row( - "edge_fold_source", - "edge_fold_target", - "co-present", - "fold/self", - ), - ], - ) - - result = merge_mod.merge_entity("edge_fold_source", "edge_fold_target", commit=True) - - assert result["merged"] is True - assert result["edges"] == { - "rows_folded": 2, - "self_edges_dropped": 1, - "error": None, - } - rows = [row for row in _edge_rows(env) if row["path"].startswith("fold/")] - assert len(rows) == 1 - assert all("edge_fold_source" not in {row["src"], row["dst"]} for row in rows) - assert all("edge_fold_target" in {row["src"], row["dst"]} for row in rows) - - audit_entries = [ - json.loads(line) - for line in _audit_log_path(env).read_text(encoding="utf-8").splitlines() - if line.strip() - ] - assert audit_entries[-1]["counts"]["edges"] == { - "rows_folded": 2, - "self_edges_dropped": 1, - "error": None, - } - - -def test_merge_then_rebuild_converges_all_edge_source_kinds(speakers_env): - env = speakers_env() - day = "20240101" - segment_key = "120000_300" - source_id = "merge_edge_source" - target_id = "merge_edge_target" - peer_id = "merge_edge_peer" - mention_id = "merge_edge_mention" - - env.create_segment(day, segment_key, ["audio"], num_sentences=2) - transcript = "\n".join( - [ - json.dumps({"raw": "audio.flac", "model": "test"}), - json.dumps({"text": "Merge Edge Mention came up in planning."}), - json.dumps({"text": "The peer answered."}), - ] - ) - for segment_dir in ( - env.journal / day / STREAM / segment_key, - env.journal / "chronicle" / day / STREAM / segment_key, - ): - (segment_dir / "audio.jsonl").write_text( - transcript + "\n", - encoding="utf-8", - ) - - for name in ( - "Merge Edge Source", - "Merge Edge Target", - "Merge Edge Peer", - "Merge Edge Mention", - ): - env.create_entity(name) - for entity_id in (source_id, target_id, peer_id, mention_id): - env.create_facet_relationship("work", entity_id) - - source_obs_path = ( - env.journal / "facets" / "work" / "entities" / source_id / "observations.jsonl" - ) - source_obs_path.write_text( - json.dumps( - { - "content": "source observation carried forward", - "observed_at": 1700000001000, - } - ) - + "\n", - encoding="utf-8", - ) - target_obs_path = ( - env.journal / "facets" / "work" / "entities" / target_id / "observations.jsonl" - ) - target_obs_path.write_text( - json.dumps( - { - "content": "target relation to source", - "observed_at": 1700000002000, - "source_day": day, - "relation": { - "kind": "family-of", - "target_entity_id": source_id, - "target_name": "Merge Edge Source", - "note": "Self after merge", - }, - } - ) - + "\n", - encoding="utf-8", - ) - peer_obs_path = ( - env.journal / "facets" / "work" / "entities" / peer_id / "observations.jsonl" - ) - peer_obs_path.write_text( - json.dumps( - { - "content": "peer observation relation to source", - "observed_at": 1700000003000, - "source_day": day, - "relation": { - "kind": "knows", - "target_entity_id": source_id, - "target_name": "Merge Edge Source", - "note": "Observation-backed relation", - }, - } - ) - + "\n", - encoding="utf-8", - ) - - detected_path = env.journal / "facets" / "work" / "entities" / f"{day}.jsonl" - detected_path.parent.mkdir(parents=True, exist_ok=True) - detected_path.write_text( - "\n".join( - [ - json.dumps({"name": "Merge Edge Source", "segments": [segment_key]}), - json.dumps({"name": "Merge Edge Peer", "segments": [segment_key]}), - ] - ) - + "\n", - encoding="utf-8", - ) - env.create_speaker_labels( - day, - segment_key, - [ - {"sentence_id": 1, "speaker": source_id}, - {"sentence_id": 2, "speaker": peer_id}, - ], - ) - _write_activity_records( - env, - "work", - day, - [ - { - "id": "meeting_120000_300", - "activity": "meeting", - "title": "Edge convergence review", - "created_at": 1700000000000, - "participation": [ - {"role": "attendee", "entity_id": source_id}, - {"role": "attendee", "entity_id": peer_id}, - ], - "relations": [ - { - "from": "Merge Edge Source", - "to": "Merge Edge Peer", - "from_entity_id": source_id, - "to_entity_id": peer_id, - "kind": kind, - "note": f"{kind} relation", - "quote": None, - } - for kind in ( - "works-with", - "works-at", - "reports-to", - "family-of", - "knows", - "uses", - "created", - "other", - ) - ], - "decisions": [ - { - "owner": "Merge Edge Source", - "counterparty": "Merge Edge Peer", - "owner_entity_id": source_id, - "counterparty_entity_id": peer_id, - "action": "Use the merge convergence fixture", - } - ], - "commitments": [ - { - "owner_entity_id": source_id, - "counterparty_entity_id": peer_id, - "action": "rerun rebuild", - } - ], - } - ], - ) - segment_dir = env.journal / "chronicle" / day / STREAM / segment_key - (segment_dir / "screen.jsonl").write_text( - "\n".join( - [ - json.dumps({"raw": "screen.png", "model": "fixture"}), - json.dumps( - { - "timestamp": 0, - "content": { - "messaging": { - "view": "conversation", - "app": "Signal", - "thread": "Merge Edge Thread", - "messages": [ - { - "sender": "Merge Edge Source", - "timestamp": "2024-01-01T12:00:00Z", - "subject": "", - "text": "Can you review this merge?", - }, - { - "sender": "Merge Edge Peer", - "timestamp": "2024-01-01T12:00:30Z", - "subject": "", - "text": "Yes, I can.", - }, - ], - }, - "calendar": { - "view": "day", - "app": "Calendar", - "events": [ - { - "title": "Merge Edge Calendar", - "start": "2024-01-01T12:30:00Z", - "end": "2024-01-01T13:00:00Z", - "calendar": "Work", - "guests": [ - "Merge Edge Source", - "Merge Edge Peer", - ], - } - ], - }, - }, - } - ), - ] - ) - + "\n", - encoding="utf-8", - ) - talents_dir = segment_dir / "talents" - talents_dir.mkdir(parents=True, exist_ok=True) - (talents_dir / "documents.json").write_text( - json.dumps( - { - "overview": "Merge convergence document.", - "parties": [ - {"name": "Merge Edge Source", "role": "author"}, - {"name": "Merge Edge Peer", "role": "reviewer"}, - ], - "key_provisions": [], - "assets": [], - "conditions": [], - "important_dates": [], - "summary": "Merge source and peer are parties.", - }, - indent=2, - ) - + "\n", - encoding="utf-8", - ) - - rebuild_edges(str(env.journal)) - pre_rows = _edge_rows_with_kind(env) - source_kinds = { - row["kind"] for row in pre_rows if source_id in {row["src"], row["dst"]} - } - new_kinds = { - "works-with", - "works-at", - "reports-to", - "family-of", - "knows", - "uses", - "created", - "other", - "decided-with", - "messaged-with", - "scheduled-with", - "party-of", - } - assert source_kinds == { - "attended-with", - "co-present", - "committed-to", - "decided-with", - "family-of", - "knows", - "mentioned", - "messaged-with", - "party-of", - "scheduled-with", - "spoke-with", - "works-at", - "works-with", - "reports-to", - "uses", - "created", - "other", - } - assert new_kinds <= source_kinds - assert any( - row["source"] == "observation" - and row["kind"] == "knows" - and row["dst"] == source_id - for row in pre_rows - ) - - result = merge_mod.merge_entity(source_id, target_id, commit=True) - - assert result["merged"] is True - assert result["facets"]["observations_appended"] == 1 - assert result["facets"]["observation_relations_rewritten"] == 2 - assert result["activities"]["fields_rewritten"] == 11 - assert all( - source_id not in {row["src"], row["dst"]} for row in _edge_rows_with_kind(env) - ) - target_observations = [ - json.loads(line) - for line in target_obs_path.read_text(encoding="utf-8").splitlines() - if line.strip() - ] - assert {item["content"] for item in target_observations} == { - "target relation to source", - "source observation carried forward", - } - assert target_observations[0]["relation"]["target_entity_id"] == target_id - peer_observations = [ - json.loads(line) - for line in peer_obs_path.read_text(encoding="utf-8").splitlines() - if line.strip() - ] - assert peer_observations[0]["relation"]["target_entity_id"] == target_id - - folded_hash = _edge_hash(env) - rebuild_edges(str(env.journal)) - assert _edge_hash(env) == folded_hash - rebuilt_rows = _edge_rows_with_kind(env) - assert all(source_id not in {row["src"], row["dst"]} for row in rebuilt_rows) - target_kinds = { - row["kind"] for row in rebuilt_rows if target_id in {row["src"], row["dst"]} - } - assert target_kinds == { - "attended-with", - "co-present", - "committed-to", - "decided-with", - "family-of", - "knows", - "mentioned", - "messaged-with", - "party-of", - "scheduled-with", - "spoke-with", - "works-at", - "works-with", - "reports-to", - "uses", - "created", - "other", - } - assert new_kinds <= target_kinds - assert any( - row["source"] == "observation" - and row["kind"] == "knows" - and target_id in {row["src"], row["dst"]} - for row in rebuilt_rows - ) - - first_hash = _edge_hash(env) - rebuild_edges(str(env.journal)) - assert _edge_hash(env) == first_hash - - -def test_merge_commit_rolls_back_when_edge_fold_fails(speakers_env, monkeypatch): - env = speakers_env() - env.create_entity("Edge Failure Source") - env.create_entity("Edge Failure Target") - source_before = _entity_path(env, "edge_failure_source").read_bytes() - target_before = _entity_path(env, "edge_failure_target").read_bytes() - index_before = _edge_hash(env) - - def fail_fold(*args, **kwargs): - raise sqlite3.OperationalError("boom") - - monkeypatch.setattr("solstone.think.indexer.edges.fold_entity_edges", fail_fold) - - result = merge_mod.merge_entity( - "edge_failure_source", - "edge_failure_target", - commit=True, - ) - - assert result == { - "error": "boom", - "failed_phase": "edges", - "source_id": "edge_failure_source", - "target_id": "edge_failure_target", - } - assert _entity_path(env, "edge_failure_source").read_bytes() == source_before - assert _entity_path(env, "edge_failure_target").read_bytes() == target_before - assert load_journal_entity("edge_failure_source") is not None - assert _edge_hash(env) == index_before - assert not _audit_log_path(env).exists() - assert not [ - event - for event in iter_entity_history("edge_failure_target") - if event["kind"] == "merge" - ] - - -def test_merge_cleanup_failure_rolls_back_to_pre_operation_bytes( - speakers_env, - monkeypatch, -): - env = speakers_env() - env.create_entity("Cleanup Alias") - env.create_entity("Cleanup Canon") - source_before = _entity_path(env, "cleanup_alias").read_bytes() - target_before = _entity_path(env, "cleanup_canon").read_bytes() - source_rel_dir = env.create_facet_relationship( - "work", - "cleanup_alias", - description="Source relationship", - ) - source_rel_before = { - path.relative_to(source_rel_dir).as_posix(): path.read_bytes() - for path in source_rel_dir.rglob("*") - if path.is_file() - } - target_rel_path = ( - env.journal / "facets" / "work" / "entities" / "cleanup_canon" / "entity.json" - ) - assert not target_rel_path.exists() - - def fail_cleanup(*args, **kwargs): - raise RuntimeError("cleanup boom") - - monkeypatch.setattr( - merge_mod, - "_apply_destructive_plan", - fail_cleanup, - raising=False, - ) - - result = merge_mod.merge_entity("cleanup_alias", "cleanup_canon", commit=True) - - assert result["error"] == "cleanup boom" - assert result["failed_phase"] == "cleanup" - assert _entity_path(env, "cleanup_alias").read_bytes() == source_before - assert _entity_path(env, "cleanup_canon").read_bytes() == target_before - assert source_rel_dir.exists() - assert { - path.relative_to(source_rel_dir).as_posix(): path.read_bytes() - for path in source_rel_dir.rglob("*") - if path.is_file() - } == source_rel_before - assert not target_rel_path.exists() - assert not _audit_log_path(env).exists() - - -def test_merge_commit_failure_rolls_back_without_resume_marker( - speakers_env, - monkeypatch, -): - env = speakers_env() - env.create_entity("Failure Source") - env.create_entity("Failure Target") - env.create_segment("20240101", "143022_300", ["mic_audio"]) - source_before = _entity_path(env, "failure_source").read_bytes() - target_before = _entity_path(env, "failure_target").read_bytes() - env.create_speaker_labels( - "20240101", - "143022_300", - [ - { - "sentence_id": 1, - "speaker": "failure_source", - "confidence": "high", - "method": "acoustic", - } - ], - ) - labels_path = _labels_path(env, "20240101", "143022_300") - labels_before = labels_path.read_bytes() - - def fail_segments(*args, **kwargs): - raise RuntimeError("segment boom") - - monkeypatch.setattr(merge_mod, "_apply_segment_plan", fail_segments) - - result = merge_mod.merge_entity("failure_source", "failure_target", commit=True) - - assert result["error"] == "segment boom" - assert result["failed_phase"] == "segments" - assert "recovery" not in result - assert result["source_id"] == "failure_source" - assert result["target_id"] == "failure_target" - source = load_journal_entity("failure_source") - assert source is not None - assert "merged_into" not in source - assert _entity_path(env, "failure_source").read_bytes() == source_before - assert _entity_path(env, "failure_target").read_bytes() == target_before - assert labels_path.read_bytes() == labels_before - assert not _audit_log_path(env).exists() - - -def test_merge_segment_rewrites_use_owner_byte_shapes(speakers_env): - env = speakers_env() - env.create_segment("20240101", "143022_300", ["mic_audio"]) - env.create_entity("Bytes Source") - env.create_entity("Bytes Target") - labels_before = [ - { - "sentence_id": 1, - "speaker": "bytes_source", - "confidence": "high", - "method": "acoustic", - }, - { - "sentence_id": 2, - "speaker": "other_person", - "confidence": "medium", - "method": "context", - }, - ] - corrections_before = [ - { - "sentence_id": 1, - "original_speaker": "bytes_source", - "corrected_speaker": "bytes_source", - "original_method": "acoustic", - "timestamp": 1700000000000, - } - ] - env.create_speaker_labels("20240101", "143022_300", labels_before) - env.create_speaker_corrections("20240101", "143022_300", corrections_before) - - result = merge_mod.merge_entity("bytes_source", "bytes_target", commit=True) - - assert result["merged"] is True - expected_labels = { - "labels": [ - { - "sentence_id": 1, - "speaker": "bytes_target", - "confidence": "high", - "method": "acoustic", - }, - { - "sentence_id": 2, - "speaker": "other_person", - "confidence": "medium", - "method": "context", - }, - ], - "owner_centroid_last_refreshed_at": None, - "voiceprint_versions": {}, - } - labels_path = _labels_path(env, "20240101", "143022_300") - assert labels_path.read_bytes() == ( - json.dumps(expected_labels, indent=2) + "\n" - ).encode("utf-8") - assert _read_json(labels_path) == expected_labels - - expected_corrections = { - "corrections": [ - { - "sentence_id": 1, - "original_speaker": "bytes_target", - "corrected_speaker": "bytes_target", - "original_method": "acoustic", - "timestamp": 1700000000000, - } - ] - } - corrections_path = _corrections_path(env, "20240101", "143022_300") - assert corrections_path.read_bytes() == json.dumps( - expected_corrections, - indent=2, - ).encode("utf-8") - assert _read_json(corrections_path) == expected_corrections - - -def test_merge_default_keeps_source_as_aka(speakers_env): - env = speakers_env() - env.create_entity("Keep Alias") - env.create_entity("Keep Canon") - - result = runner.invoke( - entities_app, - ["merge", "keep_alias", "keep_canon", "--commit"], - ) - - assert result.exit_code == 0, f"{result.output}\n{result.exception!r}" - canonical = load_journal_entity("keep_canon") - assert canonical is not None - assert "Keep Alias" in canonical["aka"] - - -def test_merge_no_keep_source_as_aka_keeps_only_existing_aliases(speakers_env): - env = speakers_env() - env.create_entity("Skip Alias") - env.create_entity("Skip Canon") - _update_entity(env, "skip_alias", aka=["SA", "S.A."]) - - result = runner.invoke( - entities_app, - [ - "merge", - "skip_alias", - "skip_canon", - "--commit", - "--no-keep-source-as-aka", - ], - ) - - assert result.exit_code == 0, f"{result.output}\n{result.exception!r}" - canonical = load_journal_entity("skip_canon") - assert canonical is not None - assert "Skip Alias" not in canonical.get("aka", []) - assert {"SA", "S.A."} <= set(canonical["aka"]) - - -def test_merge_transfers_principal_from_source_to_target(speakers_env): - env = speakers_env() - env.create_entity("Principal Source", is_principal=True) - env.create_entity("Principal Target") - - result = runner.invoke( - entities_app, - ["merge", "principal_source", "principal_target", "--commit"], - ) - - assert result.exit_code == 0, f"{result.output}\n{result.exception!r}" - data = json.loads(result.output) - assert data["identity"]["principal_transferred"] is True - assert load_journal_entity("principal_source") is None - target = load_journal_entity("principal_target") - assert target is not None - assert target["is_principal"] is True - - -def test_merge_errors_when_both_entities_are_principal(speakers_env): - env = speakers_env() - env.create_entity("First Principal", is_principal=True) - env.create_entity("Second Principal", is_principal=True) - - result = runner.invoke( - entities_app, - ["merge", "first_principal", "second_principal", "--commit"], - ) - - assert result.exit_code == 1, f"{result.output}\n{result.exception!r}" - data = json.loads(result.output) - assert data["error"] == "Cannot merge two principal entities." - assert load_journal_entity("first_principal") is not None - assert load_journal_entity("second_principal") is not None - - -def test_merge_errors_on_aka_cross_reference(speakers_env): - env = speakers_env() - env.create_entity("Cross Source") - env.create_entity("Cross Target") - env.create_entity("Cross Watcher") - _update_entity(env, "cross_watcher", aka=["cross_source", "Watcher Alias"]) - - result = runner.invoke( - entities_app, - ["merge", "cross_source", "cross_target", "--commit"], - ) - - assert result.exit_code == 1, f"{result.output}\n{result.exception!r}" - data = json.loads(result.output) - assert ( - data["error"] - == "Cannot merge 'cross_source': referenced in aka lists of entity ids: cross_watcher" - ) - assert load_journal_entity("cross_source") is not None - assert load_journal_entity("cross_target") is not None - - -def test_merge_validation_errors(speakers_env): - env = speakers_env() - env.create_entity("Blocked Source") - env.create_entity("Validation Target") - _update_entity(env, "blocked_source", blocked=True) - - cases = [ - ( - ["merge", "validation_target", "validation_target", "--commit"], - "Source and target must be different entities.", - ), - ( - ["merge", "missing_source", "validation_target", "--commit"], - "Source entity not found: missing_source", - ), - ( - ["merge", "validation_target", "missing_target", "--commit"], - "Target entity not found: missing_target", - ), - ( - ["merge", "blocked_source", "validation_target", "--commit"], - "Cannot merge blocked entity: blocked_source", - ), - ] - - for argv, expected_error in cases: - result = runner.invoke(entities_app, argv) - assert result.exit_code == 1, f"{result.output}\n{result.exception!r}" - data = json.loads(result.output) - assert data["error"] == expected_error diff --git a/solstone/apps/facets/call.py b/solstone/apps/facets/call.py deleted file mode 100644 index 8a0a3a2b4..000000000 --- a/solstone/apps/facets/call.py +++ /dev/null @@ -1,86 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""CLI commands for facet review candidates.""" - -from __future__ import annotations - -import json - -import typer - -from solstone.think.convey_client import convey_cli, get_client - -app = typer.Typer(help="Facet review candidates.") - - -def _echo_result(result: dict, *, action: str, name_key: str) -> None: - status = result.get("status") - if status == "accepted": - typer.echo( - f"Accepted facet candidate '{name_key}' as '{result.get('facet_slug')}'." - ) - return - if status == "dismissed": - typer.echo(f"Dismissed facet candidate '{name_key}'.") - return - if status == "already_accepted": - typer.echo(f"Facet candidate '{name_key}' already accepted.") - return - if status == "already_dismissed": - typer.echo(f"Facet candidate '{name_key}' already dismissed.") - return - typer.echo(f"{action} result for '{name_key}': {status}") - - -@app.command("list-candidates") -@convey_cli -def list_facet_candidates( - status: str | None = typer.Option(None, "--status", help="Filter by status."), - json_output: bool = typer.Option(False, "--json", help="Output as JSON."), -) -> None: - """List recorded facet review candidates.""" - body = get_client().request("GET", "/app/curation/api/facet/candidates") - rows = body["items"] - if status is not None: - rows = [row for row in rows if row.get("status") == status] - - if json_output: - typer.echo(json.dumps(rows, indent=2, ensure_ascii=False)) - return - - if not rows: - typer.echo("No facet candidates found.") - return - - for row in rows: - typer.echo( - f"{row.get('name', '')} " - f"[{row.get('status', '')}] " - f"count={row.get('count')} " - f"last={row.get('last_surfaced', '')}" - ) - - -@app.command("accept") -@convey_cli -def accept_facet( - name_key: str = typer.Argument(help="Facet candidate name_key to accept."), -) -> None: - """Accept one facet review candidate.""" - result = get_client().request( - "POST", "/app/curation/api/facet/accept", json={"name_key": name_key} - ) - _echo_result(result, action="accept", name_key=name_key) - - -@app.command("dismiss") -@convey_cli -def dismiss_facet( - name_key: str = typer.Argument(help="Facet candidate name_key to dismiss."), -) -> None: - """Dismiss one facet review candidate.""" - result = get_client().request( - "POST", "/app/curation/api/facet/dismiss", json={"name_key": name_key} - ) - _echo_result(result, action="dismiss", name_key=name_key) diff --git a/solstone/apps/facets/tests/test_call.py b/solstone/apps/facets/tests/test_call.py deleted file mode 100644 index e0f99a5dc..000000000 --- a/solstone/apps/facets/tests/test_call.py +++ /dev/null @@ -1,161 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -from __future__ import annotations - -import json -import sys -from pathlib import Path - -import pytest -import requests -from typer.testing import CliRunner - -ROOT = Path(__file__).resolve().parents[4] -if str(ROOT) not in sys.path: - sys.path.insert(0, str(ROOT)) - -from solstone.apps.facets.call import app -from solstone.convey.reasons import ENTITY_BUSY -from solstone.think.convey_client import ConveyClient -from solstone.think.facet_review_candidates import ( - dismiss_candidate, - record_facet_candidate, -) -from solstone.think.journal_io import LockTimeout -from tests._baseline_harness import make_test_client, mark_setup_complete - - -@pytest.fixture -def journal(tmp_path, monkeypatch): - monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - mark_setup_complete(tmp_path) - return tmp_path - - -@pytest.fixture -def runner(journal, monkeypatch): - client = ConveyClient(session=make_test_client(journal), base_url="") - monkeypatch.setattr("solstone.apps.facets.call.get_client", lambda: client) - return CliRunner() - - -def _seed_candidate( - *, - name: str = "Home Reno", - name_key: str = "home reno", - count: int = 3, - day: str = "20260602", -) -> dict: - return record_facet_candidate( - name, - name_key, - count, - 14, - [{"day": day, "stream": "archon", "segment": "090000_300"}], - day, - ) - - -def test_list_candidates_text_and_json_byte_exact(runner): - row = _seed_candidate() - - text = runner.invoke(app, ["list-candidates"]) - payload = runner.invoke(app, ["list-candidates", "--json"]) - - assert text.exit_code == 0 - assert text.output == "Home Reno [open] count=3 last=20260602\n" - assert payload.exit_code == 0 - assert json.loads(payload.stdout) == [row] - - -def test_list_candidates_filters_status(runner): - _seed_candidate() - _seed_candidate(name="Office Move", name_key="office move", count=2) - dismiss_candidate("office move") - - result = runner.invoke(app, ["list-candidates", "--status", "open"]) - - assert result.exit_code == 0 - assert result.output == "Home Reno [open] count=3 last=20260602\n" - - -def test_list_candidates_empty(runner): - result = runner.invoke(app, ["list-candidates"]) - - assert result.exit_code == 0 - assert result.output == "No facet candidates found.\n" - - -def test_accept_candidate_success_byte_exact(runner): - _seed_candidate() - - result = runner.invoke(app, ["accept", "home reno"]) - - assert result.exit_code == 0 - assert result.output == "Accepted facet candidate 'home reno' as 'home-reno'.\n" - - -def test_dismiss_candidate_success_byte_exact(runner): - _seed_candidate() - - result = runner.invoke(app, ["dismiss", "home reno"]) - - assert result.exit_code == 0 - assert result.output == "Dismissed facet candidate 'home reno'.\n" - - -def test_dismiss_candidate_already_dismissed_byte_exact(runner): - _seed_candidate() - dismiss_candidate("home reno") - - result = runner.invoke(app, ["dismiss", "home reno"]) - - assert result.exit_code == 0 - assert result.output == "Facet candidate 'home reno' already dismissed.\n" - - -def test_accept_missing_candidate_prints_http_error(runner): - result = runner.invoke(app, ["accept", "missing"]) - - assert result.exit_code == 1 - assert result.stderr == "candidate not found\n" - assert result.stdout == "" - - -def test_accept_busy_prints_owner_voice_error(runner, monkeypatch): - def _raise_locktimeout(_name_key): - raise LockTimeout(Path("busy"), 0.01) - - monkeypatch.setattr( - "solstone.apps.curation.routes.accept_facet_candidate", _raise_locktimeout - ) - - result = runner.invoke(app, ["accept", "home reno"]) - - assert result.exit_code == 1 - assert result.stderr == f"{ENTITY_BUSY.message}\n" - assert result.stdout == "" - - -def test_convey_down_prints_require_solstone_message(journal, monkeypatch): - class DownSession: - def get(self, _url): - raise requests.exceptions.ConnectionError() - - def post(self, _url, json=None): - raise requests.exceptions.ConnectionError() - - client = ConveyClient(session=DownSession(), base_url="http://localhost:5015") - monkeypatch.setattr("solstone.apps.facets.call.get_client", lambda: client) - monkeypatch.delenv("SOL_SKIP_SUPERVISOR_CHECK", raising=False) - monkeypatch.delenv("SOL_SUPERVISOR_SPAWNED", raising=False) - - result = CliRunner().invoke(app, ["list-candidates"]) - - assert result.exit_code == 1 - assert ( - result.stderr - == "sol: solstone isn't running. Start it with 'journal up' and retry.\n" - ) - assert result.stdout == "" diff --git a/solstone/apps/import/call.py b/solstone/apps/import/call.py deleted file mode 100644 index ec3991bf5..000000000 --- a/solstone/apps/import/call.py +++ /dev/null @@ -1,167 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""CLI commands for import review and resolution. - -Auto-discovered by ``think.call`` and mounted as ``sol call import ...``. -Every verb reaches the journal only over HTTP via the Convey client; this -module imports no journal/domain function and performs no filesystem I/O. -""" - -from __future__ import annotations - -import json - -import typer - -from solstone.convey.reasons import ( - IMPORT_NOT_FOUND, - INVALID_REQUEST_VALUE, - JOURNAL_SOURCE_PROBLEM, -) -from solstone.think.convey_client import ConveyClientError, get_client - -app = typer.Typer(help="Import review and resolution.") - - -def _fail(message: str) -> None: - typer.echo(f"Error: {message}", err=True) - raise typer.Exit(1) - - -def _params(**values: object) -> dict[str, object]: - return {key: value for key, value in values.items() if value is not None} - - -def _handle_import_error( - err: ConveyClientError, *, source: str, list_staged: bool = False -) -> None: - if err.reason_code == JOURNAL_SOURCE_PROBLEM.code: - _fail( - f"Import source '{source}' not found. Check available sources in " - "~/.local/share/solstone/app-storage/import/journal_sources/." - ) - if list_staged and err.reason_code == INVALID_REQUEST_VALUE.code: - _fail("Area must be one of: entities, facets, config.") - if err.reason_code in {IMPORT_NOT_FOUND.code, INVALID_REQUEST_VALUE.code}: - if err.detail: - _fail(err.detail) - typer.echo(err.error, err=True) - raise typer.Exit(1) - - -@app.command("list-staged") -def list_staged( - source: str = typer.Option(..., "--source", help="Import source name."), - area: str | None = typer.Option( - None, "--area", help="Area: entities, facets, or config." - ), -) -> None: - try: - body = get_client().request( - "GET", - f"/app/import/api/journal-sources/{source}/staged", - params=_params(area=area), - ) - except ConveyClientError as err: - _handle_import_error(err, source=source, list_staged=True) - - items = body.get("items", []) if isinstance(body, dict) else [] - # The route omits non-dict config diffs; the old in-process CLI errored there. - for item in items: - typer.echo(json.dumps(item, ensure_ascii=False)) - - -@app.command("resolve-entity") -def resolve_entity( - source_id: str = typer.Argument(help="Source entity ID."), - action: str = typer.Argument(help="Action: merge, create, or skip."), - source: str = typer.Option(..., "--source", help="Import source name."), - target: str | None = typer.Option( - None, "--target", help="Target entity ID for merge." - ), -) -> None: - try: - body = get_client().request( - "POST", - f"/app/import/api/journal-sources/{source}/resolve-entity", - json={"source_id": source_id, "action": action, "target": target}, - ) - except ConveyClientError as err: - _handle_import_error(err, source=source) - - if action == "merge": - typer.echo(f"Merged {source_id} into {target}.") - elif action == "create": - target_id = body.get("target_id") if isinstance(body, dict) else None - typer.echo(f"Created entity {target_id} from {source_id}.") - else: - typer.echo(f"Skipped staged entity {source_id}.") - - -@app.command("resolve-staged-facet") -def apply_staged_facet( - staged_file: str = typer.Argument( - help="Staged file path relative to facets/staged/." - ), - apply: bool = typer.Option( - False, "--apply", help="Apply the staged item into the journal." - ), - skip: bool = typer.Option(False, "--skip", help="Discard the staged item."), - source: str = typer.Option(..., "--source", help="Import source name."), -) -> None: - if apply == skip: - _fail("Exactly one of --apply or --skip is required.") - - mode = "apply" if apply else "skip" - try: - get_client().request( - "POST", - f"/app/import/api/journal-sources/{source}/resolve-facet", - json={"staged_file": staged_file, "mode": mode}, - ) - except ConveyClientError as err: - _handle_import_error(err, source=source) - - if mode == "apply": - typer.echo(f"Applied staged facet file {staged_file}.") - else: - typer.echo(f"Skipped staged facet file {staged_file}.") - - -@app.command("resolve-config") -def resolve_config( - field: str = typer.Argument(help="Dotted config field path."), - action: str = typer.Argument(help="Action: apply or keep."), - source: str = typer.Option(..., "--source", help="Import source name."), -) -> None: - try: - get_client().request( - "POST", - f"/app/import/api/journal-sources/{source}/resolve-config", - json={"field": field, "action": action}, - ) - except ConveyClientError as err: - _handle_import_error(err, source=source) - - typer.echo(f"Resolved config field {field} with action {action}.") - - -@app.command("resolve-config-all") -def resolve_config_all( - source: str = typer.Option(..., "--source", help="Import source name."), - category: str = typer.Option( - ..., "--category", help="Category: transferable or preference." - ), -) -> None: - try: - body = get_client().request( - "POST", - f"/app/import/api/journal-sources/{source}/resolve-config-all", - json={"category": category}, - ) - except ConveyClientError as err: - _handle_import_error(err, source=source) - - count = body.get("count", 0) if isinstance(body, dict) else 0 - typer.echo(f"Applied {count} {category} config field(s).") diff --git a/solstone/apps/network/call.py b/solstone/apps/network/call.py deleted file mode 100644 index 763c129a3..000000000 --- a/solstone/apps/network/call.py +++ /dev/null @@ -1,471 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""CLI commands for link pairing and paired devices. - -Auto-discovered by ``think.call`` and mounted as ``sol call link ...``. -Every verb reaches the journal only over HTTP via the Convey client. -""" - -import datetime as dt -import math -import shlex -import time -from typing import Any - -import typer - -from solstone.convey.reasons import ( - INVALID_OPERATION_FOR_STATE, - PAIRED_DEVICE_NOT_FOUND, - PAIRING_REQUEST_INVALID, - SERVICE_BUSY, - SERVICE_OPERATION_FAILED, -) -from solstone.think.convey_client import ConveyClientError, convey_cli, get_client - -app = typer.Typer( - help="Link — tunnel service for reaching this solstone from linked systems." -) -private_link_app = typer.Typer(help="private network — reach home from anywhere.") -app.add_typer(private_link_app, name="private-link") - -PAIR_TIMEOUT_SECONDS = 300 -VALID_ROLES = {"", "phone", "observer", "peer"} -LINKED_SYSTEMS_HEADING = "Linked systems:" -PEERS_HEADING = "Peers:" -PRIVATE_LINK_TERMINAL_PHASES = {"enabled", "revoked", "error", "needs_subscription"} -PRIVATE_LINK_SETTING_UP = "setting up your private network..." -PRIVATE_LINK_SETUP_SUCCESS = ( - "your private network is on. your devices can reach home from anywhere." -) -PRIVATE_LINK_SETUP_FAILED = "couldn't finish setting up your private network." -PRIVATE_LINK_PORTAL_CTA = "continue to approve →" -PRIVATE_LINK_NEEDS_SUBSCRIPTION = ( - "your private network needs an active subscription before it can turn on. " - "your consent is saved; set one up, then enable your private network again:" -) -PRIVATE_LINK_DISABLE_SUCCESS = ( - "your private network is off. devices connect directly again." -) -PRIVATE_LINK_DISABLE_FAILED = ( - "couldn't turn off your private network — it's still on. try again." -) -PRIVATE_LINK_NEEDS_REPAIR = "your private network needs setting up again." -PRIVATE_LINK_STATE_LABELS = { - "enabled": "enabled", - "not_enabled": "not enabled", - "inconsistent": "needs repair", -} -CLI_PAIR_LINK_LABEL = "pair-link" -CLI_PAIR_JOIN_HINT = "link this device with:" -CLI_PAIR_CA_FINGERPRINT_LABEL = "CA fingerprint" -CLI_PAIR_NO_LAN_ADDRESS = ( - "can't start pairing — your journal isn't reachable on a network address " - "yet. turn on your private network to pair from anywhere, or connect this " - "device to your home network." -) - - -def _plural(value: int, unit: str) -> str: - return f"{value} {unit}{'s' if value != 1 else ''}" - - -def relative_time(seconds: int | float) -> str: - """Return canonical human readable duration for ``seconds``.""" - if not math.isfinite(seconds) or seconds < 0: - seconds = 0 - seconds = int(seconds) - if seconds < 60: - return _plural(seconds, "second") - minutes = seconds // 60 - if minutes < 60: - return _plural(minutes, "minute") - hours = minutes // 60 - if hours < 24: - return _plural(hours, "hour") - days = hours // 24 - if days < 7: - return _plural(days, "day") - if days < 28: - return _plural(days // 7, "week") - if days < 60: - return "1 month" - return _plural(days // 30, "month") - - -def _now_utc() -> dt.datetime: - return dt.datetime.now(dt.UTC) - - -def _relative_time(iso: str | None) -> str: - if not iso: - return "never" - try: - then = dt.datetime.strptime(iso, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=dt.UTC) - except ValueError: - return iso - now = _now_utc() - delta_seconds = max(0, (now - then).total_seconds()) - return f"{relative_time(delta_seconds)} ago" - - -def _exit_with(message: str, code: int = 1) -> None: - typer.echo(message, err=True) - raise typer.Exit(code) - - -def _private_link_state_label(state: Any) -> str: - return PRIVATE_LINK_STATE_LABELS.get(str(state or ""), str(state or "unknown")) - - -def _get_private_link_status() -> dict[str, Any]: - return get_client().request("GET", "/app/network/api/private-link") - - -def _post_private_link(path: str) -> dict[str, Any]: - try: - return get_client().request("POST", path) - except ConveyClientError as err: - if err.reason_code == INVALID_OPERATION_FOR_STATE.code and err.detail: - _exit_with(err.detail) - if err.reason_code == SERVICE_BUSY.code: - _exit_with(err.detail or "operation already running") - raise - - -def _maybe_echo_private_link_portal(operation: Any) -> None: - if not isinstance(operation, dict): - return - portal_url = operation.get("portal_url") - if not portal_url: - return - typer.echo(f"{PRIVATE_LINK_PORTAL_CTA} {portal_url}") - - -def _poll_private_link_until_terminal( - *, - wait_seconds: float, - poll_interval: float, -) -> tuple[dict[str, Any], str | None, str | None]: - deadline = time.monotonic() + max(0.0, wait_seconds) - interval = max(0.0, poll_interval) - - while True: - status = _get_private_link_status() - operation = status.get("operation") - if not isinstance(operation, dict): - return status, None, None - - phase = str(operation.get("phase") or "") - if phase in PRIVATE_LINK_TERMINAL_PHASES: - guidance = operation.get("guidance") - return status, phase, str(guidance) if guidance else None - - if time.monotonic() >= deadline: - return status, "timeout", "timed out waiting for your private network." - - if interval: - time.sleep(interval) - - -def _echo_private_link_status(status: dict[str, Any]) -> None: - posture = "private network" if status.get("posture") == "spl" else "direct" - typer.echo(f"posture: {posture}") - typer.echo(f"state: {_private_link_state_label(status.get('state'))}") - typer.echo(f"enrolled: {'yes' if status.get('enrolled') else 'no'}") - if status.get("state") == "enabled" and status.get("relay_url"): - typer.echo(f"relay URL: {status['relay_url']}") - operation = status.get("operation") - if isinstance(operation, dict): - phase = operation.get("phase") - if phase: - typer.echo(f"operation: {phase}") - guidance = operation.get("guidance") - if guidance: - typer.echo(str(guidance)) - - -def _echo_private_link_terminal( - status: dict[str, Any], - phase: str | None, - operation_guidance: str | None, -) -> None: - if phase == "enabled": - typer.echo(PRIVATE_LINK_SETUP_SUCCESS) - return - if phase == "needs_subscription": - operation = status.get("operation") - subscribe_url = ( - operation.get("subscribe_url") if isinstance(operation, dict) else None - ) - typer.echo(PRIVATE_LINK_NEEDS_SUBSCRIPTION) - if subscribe_url: - typer.echo(str(subscribe_url)) - return - if phase == "revoked": - typer.echo(PRIVATE_LINK_SETUP_FAILED, err=True) - if operation_guidance: - typer.echo(operation_guidance, err=True) - raise typer.Exit(1) - if phase in {"error", "timeout"}: - typer.echo(PRIVATE_LINK_SETUP_FAILED, err=True) - if operation_guidance: - typer.echo(operation_guidance, err=True) - raise typer.Exit(1) - _echo_private_link_status(status) - - -@private_link_app.command("status") -@convey_cli -def private_link_status() -> None: - """Show private network status.""" - - _echo_private_link_status(_get_private_link_status()) - - -@private_link_app.command("setup") -@convey_cli -def private_link_setup( - wait_seconds: float = typer.Option( - 900.0, "--wait-seconds", help="Maximum seconds to wait for the operation." - ), - poll_interval: float = typer.Option( - 1.0, "--poll-interval", help="Seconds between status polls." - ), -) -> None: - """Set up private network.""" - - typer.echo(PRIVATE_LINK_SETTING_UP) - response = _post_private_link("/app/network/private-link/enable") - _maybe_echo_private_link_portal( - response.get("operation") if isinstance(response, dict) else None - ) - status, phase, operation_guidance = _poll_private_link_until_terminal( - wait_seconds=wait_seconds, - poll_interval=poll_interval, - ) - _echo_private_link_terminal(status, phase, operation_guidance) - - -@private_link_app.command("disable") -@convey_cli -def private_link_disable() -> None: - """Turn off private network.""" - - try: - response = _post_private_link("/app/network/private-link/disable") - except ConveyClientError as err: - if err.reason_code == SERVICE_OPERATION_FAILED.code: - typer.echo(PRIVATE_LINK_DISABLE_FAILED, err=True) - raise typer.Exit(1) from err - raise - - status = response.get("status") if isinstance(response, dict) else None - state = status.get("state") if isinstance(status, dict) else None - if state == "not_enabled": - typer.echo(PRIVATE_LINK_DISABLE_SUCCESS) - return - typer.echo(PRIVATE_LINK_NEEDS_REPAIR, err=True) - raise typer.Exit(1) - - -@app.command() -@convey_cli -def pair( - device_label: str | None = typer.Option( - None, "--device-label", help="Label for the linked system being paired" - ), - as_role: str | None = typer.Option( - None, - "--as", - help=( - "Optional tag for the linked system. Links are role-less by default; " - "only peer has special behavior. One of: phone, observer, peer." - ), - ), - timeout_seconds: int = typer.Option( - PAIR_TIMEOUT_SECONDS, - "--timeout", - help="How long to wait for the linked system before giving up", - ), - no_wait: bool = typer.Option( - False, - "--no-wait", - help="Print the pair link and exit without waiting for the linked system.", - ), -) -> None: - """Start a pairing link, print join-ready credentials, optionally wait.""" - if as_role is not None and as_role not in VALID_ROLES: - typer.echo("invalid role; expected one of: phone, observer, peer", err=True) - raise typer.Exit(2) - - client = get_client() - payload = {"device_label": device_label or ""} - if as_role is not None: - payload["role"] = as_role - try: - resp = client.request("POST", "/app/network/pair-start", json=payload) - except ConveyClientError as err: - if err.reason_code == PAIRING_REQUEST_INVALID.code: - typer.echo(CLI_PAIR_NO_LAN_ADDRESS, err=True) - raise typer.Exit(1) from err - raise - nonce = resp["nonce"] - pair_link = resp["pair_link"] - ca_fp = resp["ca_fingerprint"] - - typer.echo(f"{CLI_PAIR_LINK_LABEL}: {pair_link}") - typer.echo(CLI_PAIR_JOIN_HINT) - join_cmd = ["sol", "link", "join", "--code", pair_link] - if device_label: - join_cmd += ["--label", device_label] - typer.echo(" " + shlex.join(join_cmd)) - typer.echo(f"{CLI_PAIR_CA_FINGERPRINT_LABEL}: sha256:{ca_fp}") - if device_label: - typer.echo(f"Device: {device_label}{' (peer)' if as_role == 'peer' else ''}") - if no_wait: - return - typer.echo("") - typer.echo("Waiting for linked system…") - - before = { - d["fingerprint"] - for d in client.request("GET", "/app/network/api/devices")["devices"] - } - deadline = time.time() + timeout_seconds - while time.time() < deadline: - time.sleep(1.0) - devices = client.request("GET", "/app/network/api/devices")["devices"] - new_entries = [d for d in devices if d["fingerprint"] not in before] - if new_entries: - entry = new_entries[-1] - suffix = " (peer)" if entry["role"] == "peer" else "" - label = entry.get("display_label") or entry["device_label"] - typer.echo(f"Paired: {label}{suffix}") - typer.echo(f" fingerprint: {entry['fingerprint']}") - typer.echo(f" paired_at: {entry['paired_at']}") - raise typer.Exit(0) - nonce_status = client.request( - "GET", - "/app/network/api/pair/nonce-status", - params={"nonce": nonce}, - ) - if nonce_status["used"]: - typer.echo( - "Pair request completed; device should appear in `sol call link list`." - ) - raise typer.Exit(0) - typer.echo("Timed out. Pair code expired.") - raise typer.Exit(2) - - -@app.command("list") -@convey_cli -def list_devices() -> None: - """Print every paired device with its last-seen time.""" - devices = get_client().request("GET", "/app/network/api/devices")["devices"] - if not devices: - typer.echo("No devices linked yet.") - return - linked_systems = [] - peers = [] - for device in devices: - # call.py is a pure HTTP client, so it cannot import link.auth.is_peer. - if device.get("role") == "peer": - peers.append(device) - else: - linked_systems.append(device) - - printed_section = False - for heading, entries in ( - (LINKED_SYSTEMS_HEADING, linked_systems), - (PEERS_HEADING, peers), - ): - if not entries: - continue - if printed_section: - typer.echo("") - typer.echo(heading) - for device in entries: - label = device.get("display_label") or device["device_label"] - typer.echo( - f"- {label}" - f" — added {_relative_time(device['paired_at'])}" - f" — last seen {_relative_time(device['last_seen_at'])}" - f" [{device['fingerprint_short']}]" - ) - printed_section = True - - -@app.command("authorized-clients") -@convey_cli -def authorized_clients() -> None: - """List every authorized client cert: fingerprint, label, last-seen (flat view).""" - devices = get_client().request("GET", "/app/network/api/devices")["devices"] - if not devices: - typer.echo("No authorized clients.") - return - for device in devices: - label = device.get("display_label") or device["device_label"] - typer.echo( - f"{device['fingerprint']} {label}" - f" last seen {_relative_time(device['last_seen_at'])}" - ) - - -@app.command("observer-pause") -def observer_pause() -> None: - """Pause linked observers (not yet available).""" - typer.echo("observer-pause is not yet available.") - - -@app.command() -@convey_cli -def unpair( - target: str = typer.Argument( - ..., help="Device label or fingerprint (sha256:)" - ), -) -> None: - """Revoke a paired device. Next reconnect from that device fails at TLS handshake.""" - payload = ( - {"fingerprint": target} - if target.startswith("sha256:") - else {"device_label": target} - ) - try: - get_client().request("POST", "/app/network/unpair", json=payload) - except ConveyClientError as err: - if err.reason_code == PAIRED_DEVICE_NOT_FOUND.code: - if target.startswith("sha256:"): - typer.echo(f"No paired device with fingerprint {target}") - else: - typer.echo(f"No paired device with label {target!r}") - raise typer.Exit(1) from err - raise - typer.echo("Unpaired.") - - -@app.command() -@convey_cli -def status() -> None: - """Report enrollment, listen-WS state, active tunnel count, relay endpoint.""" - client = get_client() - state = client.request("GET", "/app/network/api/status") - private_link = client.request("GET", "/app/network/api/private-link") - paired_count = len(client.request("GET", "/app/network/api/devices")["devices"]) - if state["instance_id"] is None: - typer.echo("Instance ID: (not provisioned — pair a device to provision)") - typer.echo("Home label: (not provisioned)") - else: - typer.echo(f"Instance ID: {state['instance_id']}") - typer.echo(f"Home label: {state['home_label']}") - typer.echo(f"Relay URL: {state['relay_url']}") - typer.echo(f"Enrolled: {'yes' if state['enrolled'] else 'no'}") - posture = "private network" if private_link.get("posture") == "spl" else "direct" - typer.echo(f"Reach posture: {posture}") - typer.echo( - f"Private network: {_private_link_state_label(private_link.get('state'))}" - ) - typer.echo(f"Paired devices: {paired_count}") - typer.echo("Listen-WS state: (query convey /app/network/api/status for live state)") diff --git a/solstone/apps/network/tests/test_api_status.py b/solstone/apps/network/tests/test_api_status.py index d856db5d3..348446a8b 100644 --- a/solstone/apps/network/tests/test_api_status.py +++ b/solstone/apps/network/tests/test_api_status.py @@ -7,15 +7,11 @@ import json from typing import Any import pytest -from typer.testing import CliRunner import solstone.convey.bridge as convey_bridge -from solstone.apps.network import call as link_call from solstone.apps.network import copy as link_copy from solstone.apps.network import routes as link_routes from solstone.apps.network.tests.conftest import _StubWatcher -from solstone.convey import create_app -from solstone.think.convey_client import ConveyClient from solstone.think.link.local_endpoints import LocalEndpoint from solstone.think.link.paths import LinkState from solstone.think.link.window import read_posture @@ -565,39 +561,3 @@ def test_api_status_unprovisioned(link_env, monkeypatch) -> None: assert data["instance_id"] is None assert data["home_label"] is None assert not (env.journal / "link" / "state.json").exists() - - -def test_cli_status_unprovisioned_does_not_write_state(tmp_path, monkeypatch) -> None: - journal = tmp_path / "journal" - journal.mkdir() - config_dir = journal / "config" - config_dir.mkdir(parents=True, exist_ok=True) - (config_dir / "journal.json").write_text( - json.dumps( - { - "setup": {"completed_at": 1700000000000}, - }, - indent=2, - ), - encoding="utf-8", - ) - monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal)) - app = create_app(journal=str(journal)) - app.config["TESTING"] = True - test_client = app.test_client() - client = ConveyClient(session=test_client, base_url="") - monkeypatch.setattr(link_call, "get_client", lambda: client) - - def fail_save(self) -> None: - raise AssertionError("LinkState.save should not be called by status") - - monkeypatch.setattr(LinkState, "save", fail_save) - - result = CliRunner().invoke(link_call.app, ["status"]) - - assert result.exit_code == 0 - assert ( - "Instance ID: (not provisioned — pair a device to provision)" in result.stdout - ) - assert "Home label: (not provisioned)" in result.stdout - assert not (journal / "link" / "state.json").exists() diff --git a/solstone/apps/network/tests/test_call_unpair_record_revoke_parity.py b/solstone/apps/network/tests/test_call_unpair_record_revoke_parity.py deleted file mode 100644 index a3c23ae72..000000000 --- a/solstone/apps/network/tests/test_call_unpair_record_revoke_parity.py +++ /dev/null @@ -1,215 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -from __future__ import annotations - -import json -import logging -from importlib import import_module - -from typer.testing import CliRunner - -from solstone.apps.network import call as link_call -from solstone.apps.network import routes as link_routes -from solstone.think.convey_client import ConveyClient -from solstone.think.link.auth import AuthorizedClients -from solstone.think.link.paths import authorized_clients_path - -journal_sources = import_module("solstone.apps.import.journal_sources") -load_journal_source_by_fingerprint = journal_sources.load_journal_source_by_fingerprint -mint_pl_journal_source_record = journal_sources.mint_pl_journal_source_record -save_journal_source = journal_sources.save_journal_source - -PAIRED_AT = "2026-05-20T00:00:00Z" -PHONE_FINGERPRINT = "sha256:" + ("a" * 64) -PEER_FINGERPRINT = "sha256:" + ("c" * 64) -UNKNOWN_ROLE_FINGERPRINT = "sha256:" + ("d" * 64) - - -def _short(fingerprint: str) -> str: - return fingerprint.removeprefix("sha256:")[:16] - - -def _authorized() -> AuthorizedClients: - return AuthorizedClients(authorized_clients_path()) - - -def _add_authorized(fingerprint: str, device_label: str, *, role: str = "") -> None: - _authorized().add( - fingerprint, - device_label, - "inst-1", - role=role, - paired_at=PAIRED_AT, - ) - - -def _invoke(arg: str): - return CliRunner().invoke(link_call.app, ["unpair", arg]) - - -def _configure_cli(env, monkeypatch) -> None: - client = ConveyClient(session=env.client, base_url="") - monkeypatch.setattr(link_call, "get_client", lambda: client) - - -def _action_entries(env) -> list[dict]: - actions_dir = env.journal / "config" / "actions" - entries = [] - if not actions_dir.exists(): - return entries - for path in sorted(actions_dir.glob("*.jsonl")): - for line in path.read_text(encoding="utf-8").splitlines(): - entries.append(json.loads(line)) - return entries - - -def test_unpair_phone_by_fingerprint_removes_authorized(link_env, monkeypatch) -> None: - env = link_env() - _configure_cli(env, monkeypatch) - _add_authorized(PHONE_FINGERPRINT, "phone") - - result = _invoke(PHONE_FINGERPRINT) - - assert result.exit_code == 0 - assert "Unpaired." in result.stdout - assert _authorized().is_authorized(PHONE_FINGERPRINT) is False - assert load_journal_source_by_fingerprint(PHONE_FINGERPRINT) is None - - -def test_unpair_unknown_role_removes_authorized_without_warning( - link_env, - caplog, - monkeypatch, -) -> None: - env = link_env() - _configure_cli(env, monkeypatch) - _add_authorized(UNKNOWN_ROLE_FINGERPRINT, "tablet", role="tablet") - caplog.set_level(logging.WARNING, logger="solstone.apps.network.routes") - - result = _invoke(UNKNOWN_ROLE_FINGERPRINT) - - assert result.exit_code == 0 - assert "Unpaired." in result.stdout - assert _authorized().is_authorized(UNKNOWN_ROLE_FINGERPRINT) is False - assert [ - record - for record in caplog.records - if record.name == "solstone.apps.network.routes" - and record.levelno >= logging.WARNING - ] == [] - - -def test_unpair_peer_revokes_source_removes_authorized_and_logs_action( - link_env, - monkeypatch, -) -> None: - env = link_env() - _configure_cli(env, monkeypatch) - mint_pl_journal_source_record( - fingerprint=PEER_FINGERPRINT, - device_label="peer", - paired_at=PAIRED_AT, - ) - _add_authorized(PEER_FINGERPRINT, "peer", role="peer") - - result = _invoke("peer") - - assert result.exit_code == 0 - assert "Unpaired." in result.stdout - assert _authorized().is_authorized(PEER_FINGERPRINT) is False - source = load_journal_source_by_fingerprint(PEER_FINGERPRINT) - assert source is not None - assert source["revoked"] is True - assert source["revoked_at"] is not None - entries = _action_entries(env) - assert len(entries) == 1 - assert entries[0]["source"] == "app" - assert entries[0]["actor"] == "import" - assert entries[0]["action"] == "journal_source_revoke" - assert entries[0]["params"] == { - "name": "peer", - "key_prefix": _short(PEER_FINGERPRINT), - } - - -def test_unpair_peer_already_revoked_removes_authorized_and_warns( - link_env, - caplog, - monkeypatch, -) -> None: - env = link_env() - _configure_cli(env, monkeypatch) - mint_pl_journal_source_record( - fingerprint=PEER_FINGERPRINT, - device_label="peer-revoked", - paired_at=PAIRED_AT, - ) - source = load_journal_source_by_fingerprint(PEER_FINGERPRINT) - assert source is not None - source["revoked"] = True - source["revoked_at"] = 123 - assert save_journal_source(source) is True - _add_authorized(PEER_FINGERPRINT, "peer-revoked", role="peer") - caplog.set_level(logging.WARNING, logger="solstone.apps.network.routes") - - result = _invoke(PEER_FINGERPRINT) - - assert result.exit_code == 0 - assert "Unpaired." in result.stdout - assert _authorized().is_authorized(PEER_FINGERPRINT) is False - source = load_journal_source_by_fingerprint(PEER_FINGERPRINT) - assert source is not None - assert source["revoked"] is True - assert source["revoked_at"] == 123 - assert _action_entries(env) == [] - assert "already revoked" in caplog.text - - -def test_unpair_peer_missing_source_removes_authorized_and_warns( - link_env, - caplog, - monkeypatch, -) -> None: - env = link_env() - _configure_cli(env, monkeypatch) - _add_authorized(PEER_FINGERPRINT, "peer-missing", role="peer") - caplog.set_level(logging.WARNING, logger="solstone.apps.network.routes") - - result = _invoke(PEER_FINGERPRINT) - - assert result.exit_code == 0 - assert "Unpaired." in result.stdout - assert _authorized().is_authorized(PEER_FINGERPRINT) is False - assert load_journal_source_by_fingerprint(PEER_FINGERPRINT) is None - assert "peer journal source missing" in caplog.text - - -def test_unpair_peer_save_failure_removes_authorized_and_logs_error( - link_env, - caplog, - monkeypatch, -) -> None: - env = link_env() - _configure_cli(env, monkeypatch) - mint_pl_journal_source_record( - fingerprint=PEER_FINGERPRINT, - device_label="peer-save-fails", - paired_at=PAIRED_AT, - ) - _add_authorized(PEER_FINGERPRINT, "peer-save-fails", role="peer") - monkeypatch.setattr(link_routes, "save_journal_source", lambda *_a, **_kw: False) - caplog.set_level(logging.ERROR, logger="solstone.apps.network.routes") - - result = _invoke("peer-save-fails") - - assert result.exit_code == 0 - assert "Unpaired." in result.stdout - assert _authorized().is_authorized(PEER_FINGERPRINT) is False - source = load_journal_source_by_fingerprint(PEER_FINGERPRINT) - assert source is not None - assert source["revoked"] is False - assert source["revoked_at"] is None - assert _action_entries(env) == [] - assert _short(PEER_FINGERPRINT) in caplog.text - assert "failed to save peer journal source" in caplog.text diff --git a/solstone/apps/network/tests/test_private_link_call.py b/solstone/apps/network/tests/test_private_link_call.py deleted file mode 100644 index cc1550f65..000000000 --- a/solstone/apps/network/tests/test_private_link_call.py +++ /dev/null @@ -1,178 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -from __future__ import annotations - -import json - -import pytest -from typer.testing import CliRunner - -from solstone.apps.network import call as link_call -from solstone.apps.network import routes as link_routes -from solstone.think.convey_client import ConveyClient -from solstone.think.link.paths import save_service_token -from solstone.think.services import operations -from tests.helpers.journal_config import seed_journal_config - - -@pytest.fixture(autouse=True) -def clear_private_link_registry(): - operations.clear_registry() - yield - operations.clear_registry() - - -def _configure_cli(env, monkeypatch: pytest.MonkeyPatch) -> None: - client = ConveyClient(session=env.client, base_url="") - monkeypatch.setattr(link_call, "get_client", lambda: client) - - -def _set_posture(env, posture: str) -> None: - config_path = env.journal / "config" / "journal.json" - config = json.loads(config_path.read_text("utf-8")) - config.setdefault("link", {})["posture"] = posture - seed_journal_config(config, env.journal) - - -def _seed_enabled_private_link(env) -> None: - _set_posture(env, "spl") - save_service_token("secret-service-token") - - -def _invoke(*args: str): - return CliRunner().invoke(link_call.app, ["private-link", *args]) - - -def test_private_link_status(link_env, monkeypatch: pytest.MonkeyPatch) -> None: - env = link_env() - _configure_cli(env, monkeypatch) - - result = _invoke("status") - - assert result.exit_code == 0 - assert "posture: direct" in result.stdout - assert "state: not enabled" in result.stdout - assert "enrolled: no" in result.stdout - - -def test_private_link_setup_success( - link_env, - monkeypatch: pytest.MonkeyPatch, -) -> None: - env = link_env() - _configure_cli(env, monkeypatch) - monkeypatch.setattr( - link_routes.spl_handoff, - "run_spl_handoff", - lambda **_kwargs: operations.HandoffResult("enabled", None, False), - ) - - result = _invoke("setup", "--wait-seconds", "1", "--poll-interval", "0.01") - - assert result.exit_code == 0 - assert "setting up your private network" in result.stdout - assert "your private network is on" in result.stdout - - -def test_private_link_setup_prints_consent_url( - link_env, - monkeypatch: pytest.MonkeyPatch, -) -> None: - env = link_env() - _configure_cli(env, monkeypatch) - consent_url = "https://services.test/enable/spl?nonce=NONCE&instance=INSTANCE" - monkeypatch.setattr( - link_routes.spl_handoff, - "build_spl_handoff_url", - lambda: (consent_url, "NONCE", "https://services.test"), - ) - monkeypatch.setattr( - link_routes.spl_handoff, - "run_spl_handoff", - lambda **_kwargs: operations.HandoffResult("enabled", None, False), - ) - - result = _invoke("setup", "--wait-seconds", "1", "--poll-interval", "0.01") - - assert result.exit_code == 0 - assert f"{link_call.PRIVATE_LINK_PORTAL_CTA} {consent_url}" in result.stdout - - -def test_private_link_setup_error_exits_nonzero( - link_env, - monkeypatch: pytest.MonkeyPatch, -) -> None: - env = link_env() - _configure_cli(env, monkeypatch) - monkeypatch.setattr( - link_routes.spl_handoff, - "run_spl_handoff", - lambda **_kwargs: operations.HandoffResult("error", "try again", True), - ) - - result = _invoke("setup", "--wait-seconds", "1", "--poll-interval", "0.01") - output = result.stdout + result.stderr - - assert result.exit_code == 1 - assert "couldn't finish setting up your private network" in output - assert "try again" in output - - -def test_private_link_setup_needs_subscription_exits_zero( - link_env, - monkeypatch: pytest.MonkeyPatch, -) -> None: - env = link_env() - _configure_cli(env, monkeypatch) - subscribe_url = "https://services.test/account/subscription" - monkeypatch.setattr( - link_routes.spl_handoff, - "run_spl_handoff", - lambda **_kwargs: operations.HandoffResult( - "needs_subscription", - "your private network needs an active subscription before it can turn on.", - False, - subscribe_url=subscribe_url, - ), - ) - - result = _invoke("setup", "--wait-seconds", "1", "--poll-interval", "0.01") - - assert result.exit_code == 0 - assert "your private network needs an active subscription" in result.stdout - assert subscribe_url in result.stdout - - -def test_private_link_disable_success( - link_env, - monkeypatch: pytest.MonkeyPatch, -) -> None: - env = link_env() - _configure_cli(env, monkeypatch) - _seed_enabled_private_link(env) - - result = _invoke("disable") - - assert result.exit_code == 0 - assert "your private network is off" in result.stdout - - -def test_private_link_disable_failure_prints_repair( - link_env, - monkeypatch: pytest.MonkeyPatch, -) -> None: - env = link_env() - _configure_cli(env, monkeypatch) - _seed_enabled_private_link(env) - - def fail_disable(): - raise RuntimeError("config locked") - - monkeypatch.setattr(link_routes.spl, "disable_spl", fail_disable) - - result = _invoke("disable") - output = result.stdout + result.stderr - - assert result.exit_code == 1 - assert "couldn't turn off your private network" in output diff --git a/solstone/apps/settings/call.py b/solstone/apps/settings/call.py deleted file mode 100644 index 09b9a7791..000000000 --- a/solstone/apps/settings/call.py +++ /dev/null @@ -1,368 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""CLI commands for journal settings management. - -Auto-discovered by ``think.call`` and mounted as ``sol call settings ...``. -""" - -import json -from typing import Any - -import typer - -from solstone.convey.reasons import INVALID_CONFIG_VALUE -from solstone.think.convey_client import ConveyClientError, convey_cli, get_client - -# Mirrors solstone.apps.settings.routes.API_KEY_ENV_VARS (the canonical order -# used in the "Invalid env var" message); reconstructed here rather than -# imported (HTTP-only gate) or read from the response (Flask sorts JSON keys). -_API_KEY_ENV_VARS = [ - "PLAUD_ACCESS_TOKEN", -] -_SERVICE_KEY_VALIDATION_NAME = { - "PLAUD_ACCESS_TOKEN": "plaud", -} - -app = typer.Typer( - help="Journal settings — service keys, transcription, identity, and observer." -) - -keys_app = typer.Typer(help="API key management.") -app.add_typer(keys_app, name="keys") -transcribe_app = typer.Typer(help="Transcription backend configuration.") -app.add_typer(transcribe_app, name="transcribe") -identity_app = typer.Typer(help="Journal owner identity.") -app.add_typer(identity_app, name="identity") -observer_app = typer.Typer(help="Observer capture settings.") -app.add_typer(observer_app, name="observer") -convey_app = typer.Typer(help="Convey access configuration.") -app.add_typer(convey_app, name="convey") -processing_app = typer.Typer(help="Deferred-processing mode and drain window.") -app.add_typer(processing_app, name="processing") - - -def _request( - method: str, - path: str, - *, - params: dict[str, object] | None = None, - json_body: dict[str, object] | None = None, -) -> Any: - return get_client().request(method, path, params=params, json=json_body) - - -def _get_config() -> dict[str, Any]: - return _request("GET", "/app/settings/api/config") - - -def _post_config( - section: str, - data: dict[str, Any] | None = None, - *, - key: str | None = None, - value: Any = None, -) -> dict[str, Any]: - body: dict[str, Any] = {"section": section} - if data is not None: - body["data"] = data - if key is not None: - body["key"] = key - body["value"] = value - return _request("POST", "/app/settings/api/config", json_body=body) - - -def _echo_json(payload: Any) -> None: - typer.echo(json.dumps(payload, indent=2)) - - -def _exit_with(message: str) -> None: - typer.echo(message, err=True) - raise typer.Exit(1) - - -def _validate_env_var_or_exit(env_var: str) -> None: - if env_var not in _API_KEY_ENV_VARS: - _exit_with( - f"Invalid env var: {env_var}. Must be one of: {', '.join(_API_KEY_ENV_VARS)}" - ) - - -@processing_app.command("show") -@convey_cli -def processing_show() -> None: - """Show effective deferred-processing settings.""" - result = _request("GET", "/app/settings/api/processing") - _echo_json(result) - - -@processing_app.command("set") -@convey_cli -def processing_set( - mode: str | None = typer.Option(None, "--mode", help="realtime or deferred."), - window_start: str | None = typer.Option( - None, - "--window-start", - help="HH:MM drain window start.", - ), - window_end: str | None = typer.Option( - None, - "--window-end", - help="HH:MM drain window end.", - ), - time_window: bool | None = typer.Option( - None, - "--time-window/--no-time-window", - help="Enable the time-window drain condition.", - ), - display_powersave: bool | None = typer.Option( - None, - "--display-powersave/--no-display-powersave", - help="Enable the display-powersave drain condition.", - ), -) -> None: - """Set deferred-processing mode and drain window.""" - data: dict[str, Any] = {} - if mode is not None: - data["mode"] = mode - tw: dict[str, Any] = {} - if window_start is not None: - tw["start"] = window_start - if window_end is not None: - tw["end"] = window_end - if time_window is not None: - tw["enabled"] = time_window - gate: dict[str, Any] = {} - if tw: - gate["time_window"] = tw - if display_powersave is not None: - gate["display_powersave"] = {"enabled": display_powersave} - if gate: - data["gate"] = gate - if not data: - _exit_with( - "error: provide at least one of " - "--mode/--window-start/--window-end/--time-window/--display-powersave" - ) - try: - response = _post_config("processing", data) - except ConveyClientError as err: - if err.reason_code == INVALID_CONFIG_VALUE.code and err.detail: - _exit_with(err.detail) - raise - _echo_json(response.get("config", {}).get("processing", {})) - - -@convey_app.command("status") -@convey_cli -def convey_status() -> None: - """Show Convey network and dashboard URL status.""" - - result = _request("GET", "/app/settings/api/convey/status") - typer.echo(result["status_text"]) - - -@app.command("show") -@convey_cli -def show() -> None: - """Show a summary of journal settings.""" - - config = _get_config() - env_config = config.get("env", {}) - keys = {key: bool(env_config.get(key)) for key in _API_KEY_ENV_VARS} - summary = { - "identity": config.get("identity", {}), - "transcribe": config.get("transcribe", {}), - "observe": config.get("observe", {}), - "keys": keys, - } - _echo_json(summary) - - -@keys_app.command("show") -@convey_cli -def keys_show() -> None: - """Show configured API key status.""" - - config = _get_config() - env_config = config.get("env", {}) - status = {key: bool(env_config.get(key)) for key in _API_KEY_ENV_VARS} - _echo_json(status) - - -@keys_app.command("set") -@convey_cli -def keys_set( - env_var: str = typer.Argument(..., help="Environment variable to set."), - value: str = typer.Argument(..., help="API key value."), -) -> None: - """Set an API key in journal config.""" - - _validate_env_var_or_exit(env_var) - response = _post_config("env", key=env_var, value=value) - validation = response.get("key_validation", {}).get( - _SERVICE_KEY_VALIDATION_NAME.get(env_var, ""), - ) - _echo_json({"env_var": env_var, "set": True, "validation": validation}) - - -@keys_app.command("clear") -@convey_cli -def keys_clear( - env_var: str = typer.Argument(..., help="Environment variable to clear."), -) -> None: - """Clear an API key from journal config.""" - - _validate_env_var_or_exit(env_var) - _post_config("env", key=env_var, value="") - _echo_json({"env_var": env_var, "cleared": True}) - - -@keys_app.command("validate") -@convey_cli -def keys_validate( - cache_result: bool = typer.Option( - False, "--cache-result", help="Persist service-token validation results." - ), -) -> None: - """Validate all configured API keys without persisting by default.""" - - method = "POST" if cache_result else "GET" - response = _request(method, "/app/settings/api/validate-keys") - _echo_json({"key_validation": response.get("key_validation", {})}) - - -@transcribe_app.command("show") -@convey_cli -def transcribe_show() -> None: - """Show transcription backend configuration.""" - - response = _request("GET", "/app/settings/api/transcribe") - _echo_json( - { - "backends": response.get("backends", []), - "api_keys": response.get("api_keys", {}), - "config": response.get("config", {}), - } - ) - - -@transcribe_app.command("set-backend") -@convey_cli -def transcribe_set_backend( - backend: str = typer.Argument(..., help="Transcription backend."), -) -> None: - """Set the transcription backend.""" - - response = _request("GET", "/app/settings/api/transcribe") - valid = sorted(item["name"] for item in response.get("backends", [])) - if backend not in valid: - _exit_with(f"Invalid backend: {backend}. Must be one of: {', '.join(valid)}") - update = _post_config("transcribe", {"backend": backend}) - _echo_json(update.get("config", {}).get("transcribe", {})) - - -@identity_app.command("show") -@convey_cli -def identity_show() -> None: - """Show journal identity config.""" - - config = _get_config() - _echo_json(config.get("identity", {})) - - -@identity_app.command("set") -@convey_cli -def identity_set( - name: str | None = typer.Option(None, "--name"), - preferred: str | None = typer.Option(None, "--preferred"), - bio: str | None = typer.Option(None, "--bio"), - timezone_name: str | None = typer.Option(None, "--timezone"), - pronouns: str | None = typer.Option(None, "--pronouns"), - add_email: str | None = typer.Option(None, "--add-email"), - remove_email: str | None = typer.Option(None, "--remove-email"), - add_alias: str | None = typer.Option(None, "--add-alias"), - remove_alias: str | None = typer.Option(None, "--remove-alias"), -) -> None: - """Update journal owner identity.""" - - config = _get_config() - identity = dict(config.get("identity", {})) - data: dict[str, Any] = {} - - if name is not None: - data["name"] = name - if preferred is not None: - data["preferred"] = preferred - if bio is not None: - data["bio"] = bio - if timezone_name is not None: - data["timezone"] = timezone_name - - if pronouns is not None: - try: - data["pronouns"] = json.loads(pronouns) - except json.JSONDecodeError: - typer.echo("Invalid JSON in pronouns", err=True) - raise typer.Exit(1) - - if add_email is not None or remove_email is not None: - emails = list(identity.get("email_addresses", [])) - if add_email is not None and add_email not in emails: - emails.append(add_email) - if remove_email is not None: - emails = [email for email in emails if email != remove_email] - data["email_addresses"] = emails - - if add_alias is not None or remove_alias is not None: - aliases = list(identity.get("aliases", [])) - if add_alias is not None and add_alias not in aliases: - aliases.append(add_alias) - if remove_alias is not None: - aliases = [alias for alias in aliases if alias != remove_alias] - data["aliases"] = aliases - - response = _post_config("identity", data) - _echo_json(response.get("config", {}).get("identity", {})) - - -@observer_app.command("show") -@convey_cli -def observer_show() -> None: - """Show observer configuration with defaults.""" - - response = _request("GET", "/app/settings/api/observe") - _echo_json(response) - - -@observer_app.command("set") -@convey_cli -def observer_set( - enabled: bool | None = typer.Option(None, "--enabled/--no-enabled"), - capture_interval: int | None = typer.Option(None, "--capture-interval"), -) -> None: - """Update observer capture settings.""" - - current = _request("GET", "/app/settings/api/observe") - defaults = current.get("defaults", {}).get("tmux", {}) - tmux: dict[str, Any] = {} - - if capture_interval is not None: - min_val = defaults["capture_interval_min"] - max_val = defaults["capture_interval_max"] - if capture_interval < min_val or capture_interval > max_val: - _exit_with( - "tmux.capture_interval must be an integer between " - f"{min_val} and {max_val}" - ) - tmux["capture_interval"] = capture_interval - - if enabled is not None: - tmux["enabled"] = enabled - - response = _request( - "POST", - "/app/settings/api/observe", - json_body={"tmux": tmux}, - ) - _echo_json(response.get("tmux", {})) diff --git a/solstone/apps/sol/call.py b/solstone/apps/sol/call.py deleted file mode 100644 index 486861e65..000000000 --- a/solstone/apps/sol/call.py +++ /dev/null @@ -1,68 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""CLI commands for the agent identity system. - -Every verb reaches the journal only over HTTP via the Convey client; this -module imports no journal/domain function and performs no filesystem I/O. -Auto-discovered by ``think.call`` and mounted as ``sol call sol ...``. -""" - -import json - -import typer - -from solstone.think.convey_client import convey_cli, get_client - -app = typer.Typer(help="Agent identity — name and status.") - - -@app.command("set-name") -@convey_cli -def set_name( - name: str = typer.Argument(..., help="New agent name."), - status: str = typer.Option( - "chosen", - "--status", - "-s", - help="Name status (chosen, self-named, deferred, default).", - ), -) -> None: - """Set the agent name.""" - agent = get_client().request( - "POST", - "/app/sol/api/set-name", - json={"name": name, "status": status}, - ) - typer.echo(json.dumps(agent, indent=2)) - - -@app.command("reset") -@convey_cli -def reset() -> None: - """Reset the agent name to default.""" - agent = get_client().request("POST", "/app/sol/api/reset") - typer.echo(json.dumps(agent, indent=2)) - - -@app.command("set-owner") -@convey_cli -def set_owner( - name: str = typer.Argument(..., help="Owner name."), - bio: str = typer.Option(None, "--bio", "-b", help="Short owner bio."), -) -> None: - """Set the journal owner's name (and optional bio).""" - body = get_client().request( - "POST", - "/app/sol/api/set-owner", - json={"name": name, "bio": bio}, - ) - typer.echo(json.dumps(body, indent=2)) - - -@app.command("sol-init") -@convey_cli -def sol_init() -> None: - """Initialize the identity directory.""" - body = get_client().request("POST", "/app/sol/api/sol-init") - typer.echo(json.dumps(body, indent=2)) diff --git a/solstone/apps/speakers/call.py b/solstone/apps/speakers/call.py deleted file mode 100644 index 852454b0c..000000000 --- a/solstone/apps/speakers/call.py +++ /dev/null @@ -1,1290 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""CLI interface for speaker voiceprint management. - -Auto-discovered by ``think.call`` and mounted as ``sol call speakers ...``. -Every verb reaches the journal only over HTTP via the Convey client; this -module imports no journal/domain function and performs no filesystem I/O. - -Commands with ``--commit`` preview by default and persist only when passed. -Other write verbs, including ``correct``, ``tag-owner``, ``build-from-tags``, -and ``rebuild-owner``, persist immediately. -For ``attribute-segment``, ``--save`` / ``--accumulate`` only take effect -when ``--commit`` is also passed. - -Commands: - sol call speakers status [section] - sol call speakers bootstrap [--commit] [--json] - sol call speakers resolve-names [--commit] [--json] - sol call speakers attribute-segment [--commit] [--json] - sol call speakers correct [--json] - sol call speakers propagate-correction [--commit] [--json] - sol call speakers backfill [--commit] [--json] - sol call speakers backfill-last-seen [--commit] [--json] - sol call speakers wipe [--commit] [--json] - sol call speakers discover [--json] - sol call speakers presence [--json] - sol call speakers identify [--entity-id ID] [--request-id ID] - sol call speakers identify-undo - sol call speakers identify-operations - sol call speakers identify-operation - sol call speakers dismiss-cluster --disposition - sol call speakers dismissals - sol call speakers keep-separate-list - sol call speakers merge-names - sol call speakers link-import --entity-id - sol call speakers seed-from-imports [--commit] [--json] - sol call speakers suggest [--limit N] [--json] - sol call speakers detect [--force] - sol call speakers build-from-tags [--json] - sol call speakers rebuild-owner [--override] [--json] - sol call speakers tag-owner [--json] - sol call speakers sentences [--json] - sol call speakers day-segments [--limit N] [--json] - sol call speakers confirm-owner [--backfill] [--json] - sol call speakers reject-owner - sol call speakers owner-ready -""" - -import json -import time -from typing import Any - -import typer - -from solstone.convey.reasons import ( - ENTITY_BLOCKED, - INVALID_DAY, - INVALID_SEGMENT_OR_STREAM, - SPEAKER_COMMAND_FAILED, - SPEAKER_IDENTIFY_CONFLICT, - SPEAKER_IDENTIFY_OPERATION_NOT_FOUND, - SPEAKER_IDENTIFY_RECOVERABLE, - SPEAKER_IDENTIFY_REPAIR_REQUIRED, - SPEAKER_OWNER_CENTROID_REQUIRED, - SPEAKER_OWNER_IDENTITY_REQUIRED, - SPEAKER_REVIEW_UNAVAILABLE, - SPEAKER_SENTENCE_MISSING, -) -from solstone.think.convey_client import ConveyClientError, convey_cli, get_client - -app = typer.Typer( - name="speakers", - help="Speaker voiceprint management.", - no_args_is_help=True, -) - - -def _request( - method: str, - path: str, - *, - params: dict[str, Any] | None = None, - json_body: dict[str, Any] | None = None, -) -> Any: - return get_client().request(method, path, params=params, json=json_body) - - -def _exit_owner_centroid_required(err: ConveyClientError) -> None: - typer.echo(f"Error: {err.detail}", err=True) - raise typer.Exit(1) from err - - -def _exit_speaker_command_failed(err: ConveyClientError) -> None: - typer.echo(err.detail or err.error, err=True) - raise typer.Exit(1) from err - - -_IDENTIFY_OPERATION_FAILURE_CODES = { - SPEAKER_IDENTIFY_RECOVERABLE.code, - SPEAKER_IDENTIFY_REPAIR_REQUIRED.code, - SPEAKER_IDENTIFY_CONFLICT.code, - SPEAKER_IDENTIFY_OPERATION_NOT_FOUND.code, -} - - -def _exit_identify_operation_failed( - err: ConveyClientError, - *, - request_id: str | None = None, -) -> None: - payload = err.payload if isinstance(err.payload, dict) else {} - operation_id = payload.get("operation_id") - retry_request_id = request_id or payload.get("request_id") - typer.echo(err.detail or err.error, err=True) - if retry_request_id: - typer.echo( - f"Retry with the same --request-id {retry_request_id}.", - err=True, - ) - typer.echo( - "Inspect operations with: sol call speakers identify-operations", - err=True, - ) - if operation_id: - typer.echo( - f"Inspect this operation with: sol call speakers identify-operation {operation_id}", - err=True, - ) - raise typer.Exit(1) from err - - -@app.command("status") -@convey_cli -def status( - section: str | None = typer.Argument( - None, - help=( - "Section to show (embeddings, owner, speakers, pool, clusters, imports, " - "attribution, quality). Omit for all." - ), - ), -) -> None: - """Show speaker subsystem status as JSON.""" - body = _request("GET", "/app/speakers/api/status") - valid = [ - "embeddings", - "owner", - "speakers", - "pool", - "clusters", - "imports", - "attribution", - "quality", - ] - if section is None: - result = body - elif section in valid: - result = body[section] - else: - result = {"error": f"Unknown section '{section}'. Valid: {', '.join(valid)}"} - typer.echo(json.dumps(result, indent=2, default=str)) - - -@app.command("bootstrap") -@convey_cli -def bootstrap( - commit: bool = typer.Option( - False, - "--commit", - help="Persist results. Without this flag the command only reports what would happen.", - ), - json_output: bool = typer.Option( - False, "--json", help="Output full result as JSON." - ), -) -> None: - """Bootstrap voiceprints from single-speaker segments. - - Scans the full journal for segments where speakers.json lists exactly - one speaker. In those segments, all non-owner embeddings belong to that - speaker. Saves them as voiceprints using the owner centroid for - owner subtraction. - """ - if not commit and not json_output: - typer.echo("REPORT ONLY — pass --commit to persist.\n") - - if not json_output: - typer.echo("Bootstrapping voiceprints from single-speaker segments...") - try: - stats = _request( - "POST", "/app/speakers/api/bootstrap", json_body={"commit": commit} - ) - except ConveyClientError as err: - if err.reason_code == SPEAKER_OWNER_CENTROID_REQUIRED.code: - _exit_owner_centroid_required(err) - raise - - if json_output: - typer.echo(json.dumps(stats, indent=2, default=str)) - return - - typer.echo(f"\nSegments scanned: {stats['segments_scanned']}") - typer.echo(f"Single-speaker segments: {stats['single_speaker_segments']}") - typer.echo(f"Unique speakers: {len(stats['speakers_found'])}") - typer.echo(f"Entities created: {stats['entities_created']}") - typer.echo(f"Embeddings saved: {stats['embeddings_saved']}") - typer.echo(f"Embeddings skipped (owner): {stats['embeddings_skipped_owner']}") - typer.echo( - f"Embeddings skipped (duplicate): {stats['embeddings_skipped_duplicate']}" - ) - - if stats["speakers_found"]: - typer.echo("\nTop speakers by embedding count:") - sorted_speakers = sorted( - stats["speakers_found"].items(), key=lambda x: x[1], reverse=True - ) - for name, count in sorted_speakers[:15]: - typer.echo(f" {name}: {count}") - if len(sorted_speakers) > 15: - typer.echo(f" ... and {len(sorted_speakers) - 15} more") - - if stats["errors"]: - typer.echo(f"\nErrors ({len(stats['errors'])}):", err=True) - for err in stats["errors"]: - typer.echo(f" {err}", err=True) - - -@app.command("resolve-names") -@convey_cli -def resolve_names( - commit: bool = typer.Option( - False, - "--commit", - help="Persist results. Without this flag the command only reports what would happen.", - ), - json_output: bool = typer.Option( - False, "--json", help="Output full result as JSON." - ), -) -> None: - """Resolve speaker name variants using voiceprint similarity. - - Compares voiceprint centroids between all entities. Pairs with cosine - similarity > 0.90 are flagged as the same person. Unambiguous variants - (short name is first word of full name) are auto-merged by adding the - short name as an aka on the canonical entity. - """ - if not commit and not json_output: - typer.echo("REPORT ONLY — pass --commit to persist.\n") - - if not json_output: - typer.echo("Resolving speaker name variants...") - stats = _request( - "POST", "/app/speakers/api/resolve-names", json_body={"commit": commit} - ) - - if json_output: - typer.echo(json.dumps(stats, indent=2, default=str)) - return - - typer.echo(f"\nEntities with voiceprints: {stats['entities_with_voiceprints']}") - typer.echo(f"Pairs compared: {stats['pairs_compared']}") - typer.echo(f"High-similarity pairs: {len(stats['matches_found'])}") - - if stats["auto_merged"]: - typer.echo(f"\nAuto-merged ({len(stats['auto_merged'])}):") - for merge in stats["auto_merged"]: - typer.echo( - f" {merge['alias']} -> {merge['canonical']} ({merge['similarity']})" - ) - - if stats["ambiguous"]: - typer.echo(f"\nAmbiguous ({len(stats['ambiguous'])}):") - for amb in stats["ambiguous"]: - candidates = ", ".join( - f"{c['name']} ({c['similarity']})" for c in amb["candidates"] - ) - typer.echo(f" {amb['name']}: {candidates}") - - if stats["errors"]: - typer.echo(f"\nErrors ({len(stats['errors'])}):", err=True) - for err in stats["errors"]: - typer.echo(f" {err}", err=True) - - -@app.command("attribute-segment") -@convey_cli -def attribute_segment_cmd( - day: str = typer.Argument(..., help="Day in YYYYMMDD format."), - stream: str = typer.Argument(..., help="Stream name."), - segment: str = typer.Argument(..., help="Segment key (HHMMSS_LEN)."), - commit: bool = typer.Option( - False, "--commit", help="Persist speaker labels and voiceprint accumulation." - ), - save: bool = typer.Option( - True, "--save/--no-save", help="Write speaker_labels.json." - ), - accumulate: bool = typer.Option( - True, - "--accumulate/--no-accumulate", - help="Run voiceprint accumulation.", - ), - json_output: bool = typer.Option( - False, "--json", help="Output full result as JSON." - ), -) -> None: - """Run speaker attribution (Layers 1-3) on a single segment. - - Classifies each sentence using owner detection, structural heuristics, - and acoustic voiceprint matching. Optionally writes speaker_labels.json - and accumulates high-confidence voiceprints. - """ - error_detail = None - try: - wrap = _request( - "POST", - "/app/speakers/api/attribute-segment", - json_body={ - "day": day, - "stream": stream, - "segment": segment, - "commit": commit, - "save": save, - "accumulate": accumulate, - }, - ) - except ConveyClientError as err: - if err.reason_code == SPEAKER_OWNER_CENTROID_REQUIRED.code: - wrap = None - error_detail = err.detail - else: - raise - - if not commit and not json_output: - typer.echo("REPORT ONLY — pass --commit to persist.\n") - - if error_detail is not None: - typer.echo(f"Error: {error_detail}", err=True) - raise typer.Exit(1) - - result = wrap["result"] - labels = result.get("labels", []) - unmatched = result.get("unmatched", []) - source = result.get("source") - - if json_output: - typer.echo(json.dumps(result, indent=2)) - else: - resolved = sum(1 for lab in labels if lab["speaker"] is not None) - typer.echo(f"Sentences: {len(labels)}") - typer.echo(f"Resolved: {resolved}") - typer.echo(f"Unmatched: {len(unmatched)}") - - methods: dict[str, int] = {} - for lab in labels: - m = lab.get("method") or "unmatched" - methods[m] = methods.get(m, 0) + 1 - typer.echo("\nBy method:") - for method, count in sorted(methods.items()): - typer.echo(f" {method}: {count}") - - if commit and save and not json_output: - typer.echo(f"\nWrote: {wrap['written_path']}") - - if commit and accumulate and source: - saved = wrap.get("accumulated") - if saved and not json_output: - typer.echo("\nAccumulated voiceprints:") - for eid, count in saved.items(): - typer.echo(f" {eid}: {count} embeddings") - - -@app.command("correct") -@convey_cli -def correct_cmd( - day: str = typer.Argument(..., help="Day in YYYYMMDD format."), - stream: str = typer.Argument(..., help="Stream name."), - segment: str = typer.Argument(..., help="Segment key (HHMMSS_LEN)."), - source: str = typer.Argument(..., help="Audio source stem."), - sentence_id: int = typer.Argument(..., help="Sentence id to correct."), - new_speaker: str = typer.Argument( - ..., help="Entity ID to assign to this statement." - ), - json_output: bool = typer.Option(False, "--json", help="Output as JSON."), -) -> None: - """Correct one speaker attribution immediately.""" - result = _request( - "POST", - "/app/speakers/api/correct-attribution", - json_body={ - "day": day, - "stream": stream, - "segment_key": segment, - "source": source, - "sentence_id": sentence_id, - "new_speaker": new_speaker, - }, - ) - - if json_output: - typer.echo(json.dumps(result, indent=2, default=str)) - return - - if result.get("status") == "already_correct": - typer.echo(f"Already correct: {day}/{stream}/{segment} #{sentence_id}") - return - - old_speaker = result.get("old_speaker") or "unassigned" - typer.echo( - f"Corrected {day}/{stream}/{segment} #{sentence_id}: " - f"{old_speaker} -> {result.get('new_speaker')}" - ) - removal = result.get("voiceprint_removal") or {} - typer.echo(f"Voiceprint removal: {removal.get('outcome', 'unknown')}") - offer = result.get("propagation_offer") or {} - if offer.get("available") is True: - typer.echo( - "Propagation available: " - f"{offer.get('statement_count', 0)} statements in " - f"{offer.get('segment_count', 0)} segments would change" - ) - typer.echo( - "Preview with: " - f"sol call speakers propagate-correction {result.get('old_speaker')} " - f"{result.get('new_speaker')}" - ) - else: - typer.echo("Propagation: nothing else would change") - - -@app.command("propagate-correction") -@convey_cli -def propagate_correction_cmd( - old_speaker: str = typer.Argument(..., help="Entity ID to move away from."), - new_speaker: str = typer.Argument(..., help="Entity ID to move toward."), - commit: bool = typer.Option( - False, - "--commit", - help="Persist results. Without this flag the command only reports what would happen.", - ), - json_output: bool = typer.Option( - False, "--json", help="Output full result as JSON." - ), -) -> None: - """Preview or apply scoped correction propagation. - - Reversal uses the same verb with the two speaker ids swapped. - """ - if not commit and not json_output: - typer.echo("REPORT ONLY — pass --commit to persist.\n") - - result = _request( - "POST", - "/app/speakers/api/propagate-correction", - json_body={ - "old_speaker": old_speaker, - "new_speaker": new_speaker, - "commit": commit, - }, - ) - - if json_output: - typer.echo(json.dumps(result, indent=2, default=str)) - return - - statement_count = int(result.get("statement_count") or 0) - segment_count = int(result.get("segment_count") or 0) - action = "Applied" if commit else "Would change" - if statement_count == 0: - typer.echo("Nothing else would change.") - else: - typer.echo( - f"{action}: {statement_count} statements in {segment_count} segments" - ) - - errors = result.get("errors") or [] - if errors: - typer.echo("\nErrors:", err=True) - for error in errors: - typer.echo(f" {error}", err=True) - - if commit: - reversal = result.get("reversal") or {} - if reversal: - typer.echo( - "Reverse with: " - f"sol call {reversal.get('verb')} " - f"{reversal.get('old_speaker')} {reversal.get('new_speaker')} --commit" - ) - - -@app.command("backfill") -@convey_cli -def backfill( - commit: bool = typer.Option( - False, - "--commit", - help="Persist results. Without this flag the command only reports what would happen.", - ), - reattribute: bool = typer.Option( - False, - "--reattribute", - help="Also re-attribute segments that already have speaker labels (preserves user corrections).", - ), - json_output: bool = typer.Option( - False, "--json", help="Output full result as JSON." - ), -) -> None: - """Run speaker attribution across all segments with embeddings. - - Processes segments oldest-first for progressive voiceprint building. - Skips segments that already have speaker_labels.json (safe to re-run). - """ - if not commit and not json_output: - typer.echo("REPORT ONLY — pass --commit to persist.\n") - - if not json_output: - typer.echo("Scanning journal for segments with embeddings...") - - start = time.monotonic() - stats = _request( - "POST", - "/app/speakers/api/backfill", - json_body={"commit": commit, "reattribute": reattribute}, - ) - elapsed = time.monotonic() - start - - if json_output: - typer.echo(json.dumps(stats, indent=2, default=str)) - return - - typer.echo("\n") - typer.echo(f"Total segments scanned: {stats['total_segments']}") - typer.echo(f"With embeddings: {stats['total_eligible']}") - typer.echo(f"Without embeddings: {stats['skipped_no_embed']}") - typer.echo(f"Already labeled (skipped): {stats['already_labeled']}") - typer.echo(f"Processed this run: {stats['processed']}") - typer.echo(f"Elapsed: {elapsed:.1f}s") - - speakers = stats.get("speakers_seen", {}) - if speakers: - typer.echo(f"\nSpeakers identified ({len(speakers)}):") - sorted_speakers = sorted(speakers.items(), key=lambda x: x[1], reverse=True) - for eid, count in sorted_speakers[:20]: - typer.echo(f" {eid}: {count} attributions") - if len(sorted_speakers) > 20: - typer.echo(f" ... and {len(sorted_speakers) - 20} more") - - if stats["errors"]: - typer.echo(f"\nErrors ({len(stats['errors'])}):", err=True) - for err in stats["errors"][:10]: - typer.echo(f" {err}", err=True) - if len(stats["errors"]) > 10: - typer.echo(f" ... and {len(stats['errors']) - 10} more", err=True) - - -@app.command("backfill-last-seen") -@convey_cli -def backfill_last_seen_cmd( - commit: bool = typer.Option( - False, - "--commit", - help="Persist results. Without this flag the command only reports what would happen.", - ), - json_output: bool = typer.Option( - False, "--json", help="Output full result as JSON." - ), -) -> None: - """Backfill last_seen_ts on existing voiceprint metadata rows.""" - if not commit and not json_output: - typer.echo("REPORT ONLY — pass --commit to persist.\n") - - stats = _request( - "POST", "/app/speakers/api/backfill-last-seen", json_body={"commit": commit} - ) - - if json_output: - typer.echo(json.dumps(stats, indent=2, default=str)) - return - - typer.echo(f"Speaker label files read: {stats['labels_read']}") - typer.echo(f"Entities seen: {stats['entities_seen']}") - typer.echo(f"Voiceprint rows scanned: {stats['rows_scanned']}") - typer.echo(f"Rows pending: {stats['rows_pending']}") - typer.echo(f"Rows written: {stats['rows_written']}") - - pending = stats.get("pending", {}) - if pending: - typer.echo("\nPending by entity:") - for entity_id, item in pending.items(): - typer.echo(f" {entity_id}: {item['rows']}") - - if stats.get("errors"): - typer.echo("\nErrors:", err=True) - for error in stats["errors"]: - typer.echo(f" {error}", err=True) - - -@app.command() -@convey_cli -def wipe( - commit: bool = typer.Option( - False, - "--commit", - help="Actually delete files. Without this flag the command only reports what would happen.", - ), - json_output: bool = typer.Option( - False, "--json", help="Output full result as JSON." - ), -) -> None: - """Remove all legacy speaker artifacts from the journal (DESTRUCTIVE). - - DESTRUCTIVE. Without --commit, prints a report of what would be - removed. With --commit, permanently deletes segment-embedding NPZs, - speaker labels/corrections, per-entity voiceprints, owner centroids, - and the owner-candidate snapshot. - """ - if not commit and not json_output: - typer.echo("REPORT ONLY — pass --commit to persist.\n") - - report = _request("POST", "/app/speakers/api/wipe", json_body={"commit": commit}) - - if json_output: - typer.echo(json.dumps(report, indent=2, default=str)) - return - - typer.echo( - f"segment_embeddings : {report['segment_embeddings']['count']} files " - f"({report['segment_embeddings']['bytes']} B)" - ) - typer.echo( - f"speaker_labels : {report['speaker_labels']['count']} files " - f"({report['speaker_labels']['bytes']} B)" - ) - typer.echo( - f"speaker_corrections: {report['speaker_corrections']['count']} files " - f"({report['speaker_corrections']['bytes']} B)" - ) - typer.echo( - f"entity_voiceprints : {report['entity_voiceprints']['count']} files " - f"({report['entity_voiceprints']['bytes']} B)" - ) - typer.echo( - f"owner_centroids : {report['owner_centroids']['count']} files " - f"({report['owner_centroids']['bytes']} B)" - ) - typer.echo( - f"owner_candidate : {report['owner_candidate']['count']} files " - f"({report['owner_candidate']['bytes']} B)" - ) - typer.echo( - f"total : {report['total_files']} files ({report['total_bytes']} B)" - ) - - -@app.command() -@convey_cli -def discover( - json_output: bool = typer.Option( - False, "--json", help="Output full result as JSON." - ), -) -> None: - """Discover recurring unknown speakers across segments.""" - result = _request("POST", "/app/speakers/api/discovery/scan") - if json_output: - typer.echo(json.dumps(result, indent=2, default=str)) - return - clusters = result.get("clusters", []) - - if not clusters: - typer.echo("No recurring unknown speakers found.") - raise typer.Exit() - - typer.echo(f"Found {len(clusters)} unknown speaker cluster(s):\n") - for cluster in clusters: - typer.echo( - f" Cluster {cluster['cluster_id']}: " - f"{cluster['size']} samples across {cluster['segment_count']} segments" - ) - for sample in cluster.get("samples", []): - text_preview = (sample.get("text") or "")[:60] - typer.echo( - f" - {sample['day']}/{sample['stream']}/{sample['segment_key']} " - f"sid={sample['sentence_id']}: {text_preview}" - ) - typer.echo() - - -@app.command() -@convey_cli -def presence( - cluster_id: int = typer.Argument(..., help="Cluster ID from discovery output."), - json_output: bool = typer.Option( - False, "--json", help="Output full result as JSON." - ), -) -> None: - """Show who was around a discovered unknown voice cluster.""" - try: - result = _request( - "GET", - f"/app/speakers/api/discovery/cluster/{cluster_id}/presence", - ) - except ConveyClientError as err: - if err.reason_code == SPEAKER_REVIEW_UNAVAILABLE.code: - typer.echo(f"Cluster {cluster_id} was not found.", err=True) - typer.echo( - "Run 'sol call speakers discover' to produce valid cluster ids.", - err=True, - ) - raise typer.Exit(1) from err - raise - - if json_output: - typer.echo(json.dumps(result, indent=2, default=str)) - return - - facts = result.get("facts", {}) - candidates = result.get("candidates", {}) - typer.echo( - f"Cluster {result['cluster_id']}: " - f"{facts.get('statement_count', 0)} statements, " - f"{facts.get('segment_count', 0)} segments, " - f"{facts.get('conversation_count', 0)} conversations" - ) - typer.echo( - "Evidence: complete" - if result.get("evidence_complete") - else f"Evidence: {len(result.get('evidence_gaps') or [])} gap(s)" - ) - - co_presence = candidates.get("co_presence") or [] - mention = candidates.get("mention") or [] - if not co_presence and not mention: - typer.echo("No candidate entities found.") - return - - if co_presence: - typer.echo("\nCo-presence:") - for candidate in co_presence: - typer.echo( - f" {candidate['name']} ({candidate['entity_id']}): " - f"screen={candidate['screen_conversations']}, " - f"meeting_days={candidate['meeting_days']}, " - f"voice={'yes' if candidate['has_voice'] else 'no'}" - ) - if mention: - typer.echo("\nMentions:") - for candidate in mention: - typer.echo( - f" {candidate['name']} ({candidate['entity_id']}): " - f"setting={candidate['setting_conversations']}, " - f"speakers={candidate['speaker_conversations']}, " - f"voice={'yes' if candidate['has_voice'] else 'no'}" - ) - - -@app.command() -@convey_cli -def identify( - cluster_id: int = typer.Argument(..., help="Cluster ID from discovery output."), - name: str | None = typer.Argument( - None, help="Speaker name (optional when --entity-id is given)." - ), - entity_id: str | None = typer.Option( - None, "--entity-id", help="Link to existing entity ID instead of name matching." - ), - create: bool = typer.Option( - False, "--create", help="Create a new entity when the name matches nothing." - ), - entity_type: str = typer.Option( - "Person", "--entity-type", help="Entity type for --create." - ), - resolve_only: bool = typer.Option( - False, "--resolve-only", help="Resolve without writing (dry run)." - ), - request_id: str | None = typer.Option( - None, "--request-id", help="Stable request id for retry/resume." - ), - reviewed_near_match_entity_id: list[str] | None = typer.Option( - None, - "--reviewed-near-match-entity-id", - help="Reviewed near-match entity id to keep separate when creating.", - ), -) -> None: - """Identify a discovered unknown speaker cluster.""" - if not name and not entity_id: - raise typer.BadParameter("name or --entity-id is required") - try: - result = _request( - "POST", - "/app/speakers/api/discovery/identify-cli", - json_body={ - "cluster_id": cluster_id, - "name": name, - "entity_id": entity_id, - "create_new": create, - "entity_type": entity_type, - "resolve_only": resolve_only, - "request_id": request_id, - "reviewed_near_match_entity_ids": reviewed_near_match_entity_id, - }, - ) - except ConveyClientError as err: - if err.reason_code in _IDENTIFY_OPERATION_FAILURE_CODES: - _exit_identify_operation_failed(err, request_id=request_id) - if err.reason_code == SPEAKER_COMMAND_FAILED.code: - _exit_speaker_command_failed(err) - raise - typer.echo(json.dumps(result, indent=2, default=str)) - - -@app.command("identify-undo") -@convey_cli -def identify_undo( - operation_id: str = typer.Argument(..., help="Identify operation id to undo."), -) -> None: - """Undo a committed speaker identify operation.""" - try: - result = _request( - "POST", - "/app/speakers/api/discovery/identify/undo", - json_body={"operation_id": operation_id}, - ) - except ConveyClientError as err: - if err.reason_code in _IDENTIFY_OPERATION_FAILURE_CODES: - _exit_identify_operation_failed(err) - if err.reason_code == SPEAKER_COMMAND_FAILED.code: - _exit_speaker_command_failed(err) - raise - typer.echo(json.dumps(result, indent=2, default=str)) - - -@app.command("identify-operations") -@convey_cli -def identify_operations() -> None: - """List redacted speaker identify operations.""" - result = _request("GET", "/app/speakers/api/discovery/identify/operations") - typer.echo(json.dumps(result, indent=2, default=str)) - - -@app.command("identify-operation") -@convey_cli -def identify_operation( - operation_id: str = typer.Argument(..., help="Identify operation id to inspect."), -) -> None: - """Show one redacted speaker identify operation.""" - try: - result = _request( - "GET", - f"/app/speakers/api/discovery/identify/operations/{operation_id}", - ) - except ConveyClientError as err: - if err.reason_code in _IDENTIFY_OPERATION_FAILURE_CODES: - _exit_identify_operation_failed(err) - raise - typer.echo(json.dumps(result, indent=2, default=str)) - - -@app.command("dismiss-cluster") -@convey_cli -def dismiss_cluster( - cluster_id: int = typer.Argument(..., help="Cluster ID from discovery output."), - disposition: str = typer.Option( - ..., - "--disposition", - help="Dismissal disposition: not_a_person or quiet.", - ), -) -> None: - """Dismiss a current discovery cluster from listing surfaces.""" - try: - result = _request( - "POST", - "/app/speakers/api/discovery/dismiss", - json_body={"cluster_id": cluster_id, "disposition": disposition}, - ) - except ConveyClientError as err: - if err.reason_code == SPEAKER_COMMAND_FAILED.code: - _exit_speaker_command_failed(err) - raise - typer.echo(json.dumps(result, indent=2, default=str)) - - -@app.command("dismissals") -@convey_cli -def dismissals() -> None: - """List folded speaker cluster dismissals.""" - result = _request("GET", "/app/speakers/api/discovery/dismissals") - typer.echo(json.dumps(result, indent=2, default=str)) - - -@app.command("keep-separate-list") -@convey_cli -def keep_separate_list() -> None: - """List folded speaker keep-separate assertions.""" - result = _request("GET", "/app/speakers/api/name-variants/keep-separate") - typer.echo(json.dumps(result, indent=2, default=str)) - - -@app.command("merge-names") -@convey_cli -def merge_names_cmd( - alias: str = typer.Argument(..., help="Alias/variant speaker name to merge from."), - canonical: str = typer.Argument(..., help="Canonical speaker name to merge into."), -) -> None: - """Merge a speaker name variant into a canonical entity.""" - try: - result = _request( - "POST", - "/app/speakers/api/merge-names", - json_body={"alias": alias, "canonical": canonical}, - ) - except ConveyClientError as err: - if err.reason_code == SPEAKER_COMMAND_FAILED.code: - _exit_speaker_command_failed(err) - raise - typer.echo(json.dumps(result, indent=2, default=str)) - - -@app.command("link-import") -@convey_cli -def link_import_cmd( - name: str = typer.Argument(..., help="Import participant name to link."), - entity_id: str = typer.Option(..., "--entity-id", help="Entity ID to link to."), -) -> None: - """Link an import participant name as an aka on an existing entity.""" - try: - result = _request( - "POST", - "/app/speakers/api/link-import", - json_body={"name": name, "entity_id": entity_id}, - ) - except ConveyClientError as err: - if err.reason_code == SPEAKER_COMMAND_FAILED.code: - _exit_speaker_command_failed(err) - raise - typer.echo(json.dumps(result, indent=2, default=str)) - - -@app.command("seed-from-imports") -@convey_cli -def seed_from_imports_cmd( - commit: bool = typer.Option( - False, - "--commit", - help="Persist results. Without this flag the command only reports what would happen.", - ), - json_output: bool = typer.Option( - False, "--json", help="Output full result as JSON." - ), -) -> None: - """Seed voiceprints from import segments with speaker-attributed transcripts. - - Scans import streams for segments with both conversation_transcript.jsonl - (with speaker labels) and audio embeddings. Maps each embedding to a speaker - via time-based alignment, matches speakers to existing entities, and saves - embeddings as voiceprints with owner contamination guard. - """ - if not commit and not json_output: - typer.echo("REPORT ONLY — pass --commit to persist.\n") - - if not json_output: - typer.echo("Seeding voiceprints from import segments...") - try: - stats = _request( - "POST", - "/app/speakers/api/seed-from-imports", - json_body={"commit": commit}, - ) - except ConveyClientError as err: - if err.reason_code == SPEAKER_OWNER_CENTROID_REQUIRED.code: - _exit_owner_centroid_required(err) - raise - - if json_output: - typer.echo(json.dumps(stats, indent=2, default=str)) - return - - typer.echo(f"\nSegments scanned: {stats['segments_scanned']}") - typer.echo(f"Segments with speakers: {stats['segments_with_speakers']}") - typer.echo(f"Unique speakers: {len(stats['speakers_found'])}") - typer.echo(f"Embeddings saved: {stats['embeddings_saved']}") - typer.echo(f"Embeddings skipped (owner): {stats['embeddings_skipped_owner']}") - typer.echo( - f"Embeddings skipped (duplicate): {stats['embeddings_skipped_duplicate']}" - ) - - if stats["speakers_found"]: - typer.echo("\nSpeakers by embedding count:") - sorted_speakers = sorted( - stats["speakers_found"].items(), key=lambda x: x[1], reverse=True - ) - for name, count in sorted_speakers[:15]: - typer.echo(f" {name}: {count}") - - if stats["speakers_unmatched"]: - typer.echo(f"\nUnmatched speakers ({len(stats['speakers_unmatched'])}):") - for name in stats["speakers_unmatched"]: - typer.echo(f" {name}") - - if stats["errors"]: - typer.echo(f"\nErrors ({len(stats['errors'])}):", err=True) - for err in stats["errors"]: - typer.echo(f" {err}", err=True) - - -@app.command() -@convey_cli -def suggest( - limit: int = typer.Option( - 5, "--limit", "-n", help="Maximum suggestions to return." - ), - json_output: bool = typer.Option(False, "--json", help="Output as JSON array."), -) -> None: - """Suggest speaker curation opportunities.""" - body = _request("GET", "/app/speakers/api/suggest", params={"limit": limit}) - if json_output: - typer.echo(json.dumps(body["items"], indent=2, default=str)) - return - - typer.echo(body["markdown"]) - - -@app.command("detect") -@convey_cli -def detect_cmd( - force: bool = typer.Option( - False, - "--force", - help="Bypass the candidate-exists and rejection-cooldown early-outs.", - ), -) -> None: - """Run owner voice candidate detection.""" - result = _request( - "POST", - "/app/speakers/api/owner/detect", - json_body={"force": force}, - ) - typer.echo(json.dumps(result, indent=2, default=str)) - - -@app.command("build-from-tags") -@convey_cli -def build_from_tags_cmd( - json_output: bool = typer.Option(False, "--json", help="Output as JSON."), -) -> None: - """Build the owner voice centroid from validated manual owner tags.""" - result = _request("POST", "/app/speakers/api/owner/build-from-tags") - if json_output: - typer.echo(json.dumps(result, indent=2, default=str)) - return - - if result.get("status") == "confirmed": - if result.get("next_step") == "rebuild_owner": - typer.echo("Owner centroid already confirmed.") - typer.echo(f"Next step: {result.get('next_step')}") - typer.echo(f"Guidance: {result.get('guidance')}") - return - typer.echo( - "Owner centroid confirmed from manual tags " - f"(principal: {result['principal_id']}, cluster_size: {result['cluster_size']})" - ) - return - - if result.get("status") == "low_quality": - typer.echo("Owner manual tags are not ready.") - typer.echo(f"Reason: {result.get('low_quality_reason')}") - typer.echo(f"Observed: {result.get('observed_value')}") - typer.echo(f"Threshold: {result.get('threshold_value')}") - typer.echo(f"Manual tags: {result.get('manual_tags_count')}") - typer.echo(f"Can build from tags: {result.get('can_build_from_tags')}") - typer.echo(f"Next step: {result.get('next_step')}") - typer.echo(f"Guidance: {result.get('guidance')}") - return - - typer.echo(json.dumps(result, indent=2, default=str)) - - -@app.command("rebuild-owner") -@convey_cli -def rebuild_owner_cmd( - override: bool = typer.Option( - False, - "--override", - help="Bypass only incumbent-regression refusals; absolute evidence floors still apply.", - ), - json_output: bool = typer.Option(False, "--json", help="Output as JSON."), -) -> None: - """Rebuild the owner voice centroid from current manual owner tags.""" - result = _request( - "POST", - "/app/speakers/api/owner/rebuild", - json_body={"override": override}, - ) - if json_output: - typer.echo(json.dumps(result, indent=2, default=str)) - return - - status = result.get("status") - if status == "rebuilt": - typer.echo( - "Owner centroid rebuilt " - f"(principal: {result['principal_id']}, cluster_size: {result['cluster_size']})" - ) - if result.get("override_applied"): - typer.echo("Override applied: true") - if result.get("next_step") != "none": - typer.echo(f"Next step: {result.get('next_step')}") - typer.echo(f"Guidance: {result.get('guidance')}") - return - - if status == "unchanged": - typer.echo("Owner centroid unchanged; evidence already matches.") - if result.get("next_step") != "none": - typer.echo(f"Next step: {result.get('next_step')}") - typer.echo(f"Guidance: {result.get('guidance')}") - return - - if status in {"low_quality", "refused", "rejected_regression"}: - typer.echo("Owner centroid rebuild did not write.") - if result.get("reason"): - typer.echo(f"Reason: {result.get('reason')}") - elif result.get("low_quality_reason"): - typer.echo(f"Reason: {result.get('low_quality_reason')}") - if "observed_value" in result: - typer.echo(f"Observed: {result.get('observed_value')}") - if "threshold_value" in result: - typer.echo(f"Threshold: {result.get('threshold_value')}") - typer.echo(f"Next step: {result.get('next_step')}") - typer.echo(f"Guidance: {result.get('guidance')}") - return - - typer.echo(json.dumps(result, indent=2, default=str)) - - -@app.command("tag-owner") -@convey_cli -def tag_owner_cmd( - day: str = typer.Argument(..., help="Day in YYYYMMDD format."), - stream: str = typer.Argument(..., help="Stream name."), - segment: str = typer.Argument(..., help="Segment key (HHMMSS_LEN)."), - source: str = typer.Argument(..., help="Audio source stem."), - sentence_id: int = typer.Argument(..., help="Sentence id to tag."), - json_output: bool = typer.Option(False, "--json", help="Output as JSON."), -) -> None: - """Tag one sentence as the owner voice.""" - try: - result = _request( - "POST", - "/app/speakers/api/owner/tag-cli", - json_body={ - "day": day, - "stream": stream, - "segment_key": segment, - "source": source, - "sentence_id": sentence_id, - }, - ) - except ConveyClientError as err: - if err.reason_code in { - ENTITY_BLOCKED.code, - INVALID_DAY.code, - INVALID_SEGMENT_OR_STREAM.code, - SPEAKER_OWNER_IDENTITY_REQUIRED.code, - SPEAKER_SENTENCE_MISSING.code, - }: - _exit_speaker_command_failed(err) - raise - - if json_output: - typer.echo(json.dumps(result, indent=2, default=str)) - return - - if result.get("status") == "already_assigned": - typer.echo( - f"Owner sentence already tagged: {day}/{stream}/{segment} #{sentence_id}" - ) - else: - typer.echo(f"Tagged owner sentence: {day}/{stream}/{segment} #{sentence_id}") - - -@app.command("sentences") -@convey_cli -def sentences_cmd( - day: str = typer.Argument(..., help="Day in YYYYMMDD format."), - stream: str = typer.Argument(..., help="Stream name."), - segment: str = typer.Argument(..., help="Segment key (HHMMSS_LEN)."), - source: str = typer.Argument(..., help="Audio source stem."), - json_output: bool = typer.Option(False, "--json", help="Output as JSON."), -) -> None: - """List sentence ids for one speaker-review source.""" - body = _request( - "GET", f"/app/speakers/api/review-cli/{day}/{stream}/{segment}/{source}" - ) - if json_output: - typer.echo(json.dumps(body, indent=2, default=str)) - return - - typer.echo(f"Sentences for {day}/{stream}/{segment}/{source}:") - for sentence in body["sentences"]: - marker = "*" if sentence.get("has_embedding") else "-" - typer.echo(f"{marker} {sentence['sentence_id']}: {sentence['text']}") - - -@app.command("day-segments") -@convey_cli -def day_segments_cmd( - day: str = typer.Argument(..., help="Day in YYYYMMDD format."), - limit: int = typer.Option(20, "--limit", "-n", help="Maximum segments to return."), - json_output: bool = typer.Option(False, "--json", help="Output as JSON."), -) -> None: - """List speaker-review segments for a day.""" - body = _request( - "GET", - f"/app/speakers/api/segments-cli/{day}", - params={"limit": limit}, - ) - if json_output: - typer.echo(json.dumps(body, indent=2, default=str)) - return - - typer.echo( - f"Showing {body['returned']} of {body['total']} segments " - f"(limit {body['limit']})" - ) - for segment in body["segments"]: - sources = ", ".join(segment.get("sources", [])) - typer.echo(f"{segment['stream']}/{segment['key']}: {sources}") - - -@app.command("confirm-owner") -@convey_cli -def confirm_owner_cmd( - backfill_after: bool = typer.Option( - True, - "--backfill/--no-backfill", - help="Run attribution backfill after confirming.", - ), - json_output: bool = typer.Option(False, "--json", help="Output as JSON."), -) -> None: - """Confirm the owner voice candidate and save the centroid. - - By default, automatically runs attribution backfill on all segments - after saving the centroid. - """ - try: - result = _request("POST", "/app/speakers/api/owner/confirm-cli") - except ConveyClientError as err: - if err.reason_code == SPEAKER_COMMAND_FAILED.code: - _exit_speaker_command_failed(err) - raise - - if not json_output: - typer.echo( - f"Owner centroid confirmed (principal: {result['principal_id']}, " - f"cluster_size: {result['cluster_size']})" - ) - - if backfill_after: - if not json_output: - typer.echo("Running attribution backfill...") - - stats = _request( - "POST", "/app/speakers/api/backfill", json_body={"commit": True} - ) - - if json_output: - result["backfill"] = stats - else: - typer.echo( - f"Backfill complete: {stats['processed']} segments processed, " - f"{stats['already_labeled']} already labeled" - ) - - if json_output: - typer.echo(json.dumps(result, indent=2, default=str)) - - -@app.command("reject-owner") -@convey_cli -def reject_owner_cmd() -> None: - """Reject the owner voice candidate and enter 14-day cooldown.""" - result = _request("POST", "/app/speakers/api/owner/reject-cli") - typer.echo(json.dumps(result, indent=2, default=str)) - - -@app.command("owner-ready") -@convey_cli -def owner_ready_cmd() -> None: - """Check if owner voice detection should be surfaced to the user.""" - result = _request("POST", "/app/speakers/api/owner/ready") - typer.echo(json.dumps(result, indent=2, default=str)) diff --git a/solstone/apps/support/call.py b/solstone/apps/support/call.py deleted file mode 100644 index 5f0802c8a..000000000 --- a/solstone/apps/support/call.py +++ /dev/null @@ -1,698 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""CLI commands for solstone support. - -Auto-discovered by ``think.call`` and mounted as ``sol call support ...``. - -Subcommands provide full access to the support portal: registration, KB search, -ticket management, feedback, announcements, and local diagnostics. -""" - -from __future__ import annotations - -import functools -import json -import platform -import subprocess -from importlib.metadata import PackageNotFoundError -from importlib.metadata import version as _pkg_version -from pathlib import Path - -import typer - -from solstone.apps.support.copy import FEEDBACK_SUBJECT -from solstone.think.convey_client import ( - ConveyClient, - ConveyClientError, - ConveyUnreachableError, -) - -app = typer.Typer(help="Support tools — file tickets, search KB, give feedback.") - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def get_client() -> ConveyClient: - return ConveyClient(require_service=False) - - -def _emit_unreachable_notice() -> None: - typer.echo( - "I couldn't reach support because solstone isn't reachable right now.", - err=True, - ) - typer.echo("To file a support ticket, visit https://support.solstone.app", err=True) - - -def _support_cli(fn): - @functools.wraps(fn) - def wrapper(*args, **kwargs): - try: - return fn(*args, **kwargs) - except ConveyUnreachableError: - _emit_unreachable_notice() - raise typer.Exit(1) from None - except ConveyClientError as err: - typer.echo(err.error, err=True) - raise typer.Exit(1) from err - - return wrapper - - -def _json_out(data: object) -> None: - """Pretty-print JSON to stdout.""" - typer.echo(json.dumps(data, indent=2, default=str)) - - -def _check_enabled(client: ConveyClient) -> dict: - config = client.request("GET", "/app/support/api/config") - if not config.get("enabled"): - typer.echo("Support agent is disabled in settings.", err=True) - raise typer.Exit(1) - return config - - -def _print_dry_run_preview( - *, - subject: str, - product: str, - severity: str, - category: str | None, - body: str, - diagnostics: dict, - portal_url: str, -) -> None: - """Print the would-be ticket payload for a dry run. No network I/O.""" - version = diagnostics.get("version") or "unknown" - revision = diagnostics.get("revision") or "none" - typer.echo( - "DRY RUN — nothing was sent. Re-run with --submit to actually file this." - ) - typer.echo(f"Build identity — version: {version} revision: {revision}") - typer.echo("\n--- Would send ---") - typer.echo(f"Subject: {subject}") - typer.echo(f"Product: {product}") - typer.echo(f"Severity: {severity}") - if category: - typer.echo(f"Category: {category}") - typer.echo(f"Body: {body}") - typer.echo(f"\nuser_context ({len(json.dumps(diagnostics, default=str))} bytes):") - typer.echo(json.dumps(diagnostics, indent=2, default=str)) - typer.echo(f"\nWould POST to: {portal_url}") - typer.echo("--- End dry run ---") - - -def _capture_draft( - client: ConveyClient, - *, - verb: str, - payload: dict, - diagnostics_snapshot: dict | None, -) -> None: - """POST the exact submit-path payload to the dormant draft-capture endpoint. - - Non-fatal: a capture failure prints a notice and returns; it never raises, so a - dry-run / no-network reply stays infallible. ConveyUnreachableError is a - ConveyClientError subclass, so the single except covers unreachable too. - """ - try: - client.request( - "POST", - "/app/support/api/draft", - json={ - "verb": verb, - "payload": payload, - "diagnostics_snapshot": diagnostics_snapshot, - }, - ) - except ConveyClientError: - typer.echo( - "(Draft not captured — solstone wasn't reachable to save it for review.)", - err=True, - ) - - -def _local_build_identity() -> dict: - try: - ver = _pkg_version("solstone") - except PackageNotFoundError: - ver = None - # parents[2] of solstone/apps/support/call.py is the solstone package dir; - # git rev-parse walks up from there to the checkout .git. Never raises. - package_dir = Path(__file__).parents[2] - try: - result = subprocess.run( - ["git", "rev-parse", "--short", "HEAD"], - cwd=package_dir, - capture_output=True, - text=True, - timeout=5, - ) - rev = result.stdout.strip() if result.returncode == 0 else None - except Exception: - rev = None - return { - "version": ver, - "revision": rev, - "platform": { - "system": platform.system(), - "release": platform.release(), - "machine": platform.machine(), - "python": platform.python_version(), - }, - } - - -# --------------------------------------------------------------------------- -# Commands -# --------------------------------------------------------------------------- - - -@app.command("register") -@_support_cli -def register() -> None: - """(Re-)register with the support portal.""" - client = get_client() - _check_enabled(client) - result = client.request("POST", "/app/support/api/register") - typer.echo(f"Registered as: {result.get('handle', '?')}") - - -@app.command("search") -@_support_cli -def search( - query: str = typer.Argument(..., help="Search query for KB articles."), -) -> None: - """Search knowledge base articles.""" - client = get_client() - _check_enabled(client) - articles = client.request("GET", "/app/support/api/articles", params={"q": query}) - if not articles: - typer.echo("No articles found.") - return - - for a in articles: - typer.echo(f" [{a.get('slug', '?')}] {a.get('title', 'Untitled')}") - typer.echo( - f"\n{len(articles)} article(s) found. Use `sol call support article ` to read." - ) - - -@app.command("article") -@_support_cli -def article( - slug: str = typer.Argument(..., help="Article slug."), - as_json: bool = typer.Option(False, "--json", help="Output raw JSON."), -) -> None: - """Read a KB article.""" - client = get_client() - _check_enabled(client) - data = client.request("GET", f"/app/support/api/articles/{slug}") - - if as_json: - _json_out(data) - else: - typer.echo(f"# {data.get('title', 'Untitled')}\n") - typer.echo(data.get("content", "(no content)")) - - -@app.command("create") -@_support_cli -def create( - subject: str = typer.Option(..., "--subject", "-s", help="Ticket subject."), - description: str = typer.Option( - ..., "--description", "-d", help="Ticket description." - ), - product: str = typer.Option("solstone", "--product", "-p", help="Product name."), - severity: str = typer.Option( - "medium", "--severity", help="low, medium, high, critical." - ), - category: str | None = typer.Option( - None, "--category", help="bug, feature, question, account." - ), - skip_kb: bool = typer.Option( - False, "--skip-kb", help="Skip KB search before filing." - ), - submit: bool = typer.Option( - False, - "--submit", - help=( - "Actually file the ticket with sol pbc. Without --submit this is a " - "dry run and nothing is sent." - ), - ), - yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt."), - anonymous: bool = typer.Option( - False, "--anonymous", help="Strip installation identifiers." - ), -) -> None: - """File a support ticket (KB-first flow with consent gate).""" - client = get_client() - config = _check_enabled(client) - if not submit: - diagnostics = client.request("GET", "/app/support/api/diagnostics") - _print_dry_run_preview( - subject=subject, - product=product, - severity=severity, - category=category, - body=description, - diagnostics=diagnostics, - portal_url=config["portal_url"], - ) - _capture_draft( - client, - verb="create", - payload={ - "subject": subject, - "description": description, - "product": product, - "severity": severity, - "category": category, - "user_context": diagnostics, - "auto_context": False, - "anonymous": anonymous, - }, - diagnostics_snapshot=diagnostics, - ) - return - - # Step 1: KB-first — search before filing - if not skip_kb: - typer.echo("Searching knowledge base...") - articles = client.request( - "GET", "/app/support/api/articles", params={"q": subject} - ) - if articles: - typer.echo(f"\nFound {len(articles)} related article(s):") - for a in articles: - typer.echo(f" [{a.get('slug', '?')}] {a.get('title', '')}") - typer.echo( - "\nThese may answer your question. " - "Use `sol call support article ` to read." - ) - if not yes: - proceed = typer.confirm("Still want to file a ticket?") - if not proceed: - typer.echo("Cancelled.") - return - - # Step 2: Collect diagnostics - diagnostics = client.request("GET", "/app/support/api/diagnostics") - - # Step 3: Present draft for review (consent gate) - typer.echo("\n--- Ticket Draft ---") - typer.echo(f"Subject: {subject}") - typer.echo(f"Product: {product}") - typer.echo(f"Severity: {severity}") - if category: - typer.echo(f"Category: {category}") - typer.echo(f"Description: {description}") - typer.echo(f"\nDiagnostic data ({len(json.dumps(diagnostics))} bytes):") - typer.echo(json.dumps(diagnostics, indent=2, default=str)) - typer.echo("--- End Draft ---\n") - - if not yes: - approved = typer.confirm("Submit this ticket?") - if not approved: - typer.echo("Cancelled — nothing was sent.") - return - - # Step 4: Submit - result = client.request( - "POST", - "/app/support/api/tickets", - json={ - "subject": subject, - "description": description, - "product": product, - "severity": severity, - "category": category, - "user_context": diagnostics, - "auto_context": False, - "anonymous": anonymous, - }, - ) - typer.echo(f"Ticket created: #{result.get('id', '?')}") - - -@app.command("list") -@_support_cli -def list_tickets( - status: str | None = typer.Option(None, "--status", help="Filter by status."), - as_json: bool = typer.Option(False, "--json", help="Output raw JSON."), -) -> None: - """List your support tickets.""" - client = get_client() - _check_enabled(client) - params = {"status": status} if status else None - tickets = client.request("GET", "/app/support/api/tickets", params=params) - if as_json: - _json_out(tickets) - return - - if not tickets: - typer.echo("No tickets found.") - return - - for t in tickets: - status_str = t.get("status", "?") - typer.echo( - f" #{t.get('id', '?'):>4} [{status_str:<12}] {t.get('subject', 'untitled')}" - ) - typer.echo(f"\n{len(tickets)} ticket(s).") - - -@app.command("show") -@_support_cli -def show( - ticket_id: int = typer.Argument(..., help="Ticket ID."), - as_json: bool = typer.Option(False, "--json", help="Output raw JSON."), -) -> None: - """View a ticket with its message thread.""" - client = get_client() - _check_enabled(client) - data = client.request("GET", f"/app/support/api/tickets/{ticket_id}") - - if as_json: - _json_out(data) - return - - typer.echo(f"# Ticket #{data.get('id', '?')}: {data.get('subject', '')}") - typer.echo( - f"Status: {data.get('status', '?')} | Severity: {data.get('severity', '?')}" - ) - typer.echo(f"Created: {data.get('created_at', '?')}") - typer.echo(f"\n{data.get('description', '')}") - - messages = data.get("messages", []) - if messages: - typer.echo(f"\n--- {len(messages)} message(s) ---") - for msg in messages: - handle = msg.get("handle", "?") - typer.echo(f"\n[{handle}] {msg.get('created_at', '')}") - typer.echo(msg.get("content", "")) - attachments = msg.get("attachments", []) - if attachments: - for att in attachments: - size = att.get("size_bytes", 0) - if size >= 1024 * 1024: - size_str = f"{size / 1024 / 1024:.1f} MB" - elif size >= 1024: - size_str = f"{size / 1024:.0f} KB" - else: - size_str = f"{size} bytes" - typer.echo(f" 📎 {att.get('filename', '?')} ({size_str})") - - -@app.command("reply") -@_support_cli -def reply( - ticket_id: int = typer.Argument(..., help="Ticket ID."), - body: str = typer.Option(..., "--body", "-b", help="Reply content."), - submit: bool = typer.Option( - True, - "--submit/--no-submit", - help=( - "Send the reply to solstone support (default). Pass --no-submit to " - "capture a draft for review without contacting the portal." - ), - ), - yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."), -) -> None: - """Reply to a ticket.""" - client = get_client() - _check_enabled(client) - - if not submit: - # No-network capture: stash the exact submit body as a draft. The portal is - # not contacted at all; nothing is sent. - typer.echo( - "DRY RUN — nothing was sent. Re-run with --submit to actually send this." - ) - typer.echo(f"Reply to ticket #{ticket_id}:\n{body}") - _capture_draft( - client, - verb="reply", - payload={"ticket_id": ticket_id, "content": body}, - diagnostics_snapshot=None, - ) - return - - if not yes: - typer.echo(f"Reply to ticket #{ticket_id}:\n{body}\n") - if not typer.confirm("Send this reply?"): - typer.echo("Cancelled.") - return - - client.request( - "POST", - f"/app/support/api/tickets/{ticket_id}/reply", - json={"content": body}, - ) - typer.echo(f"Reply sent to ticket #{ticket_id}.") - - -@app.command("attach") -@_support_cli -def attach( - ticket_id: int = typer.Argument(..., help="Ticket ID to attach files to."), - files: list[Path] = typer.Argument(..., help="File(s) to attach."), - submit: bool = typer.Option( - True, - "--submit/--no-submit", - help=( - "Upload the file(s) to solstone support (default). Pass --no-submit " - "to prepare a single-file draft for review without contacting the portal." - ), - ), - yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."), -) -> None: - """Attach file(s) to a ticket.""" - client = get_client() - _check_enabled(client) - - if not submit: - if len(files) > 1: - typer.echo( - "Attach one file at a time when preparing a draft for review.", - err=True, - ) - raise typer.Exit(1) - f = files[0] - if not f.is_file(): - typer.echo(f"Error: file not found: {f}", err=True) - raise typer.Exit(1) - try: - client.upload( - "/app/support/api/draft", - files={"file": (f.name, str(f), None)}, - data={"verb": "attach", "ticket_id": str(ticket_id)}, - ) - except ConveyUnreachableError: - raise - except ConveyClientError as err: - typer.echo(err.detail or err.error, err=True) - raise typer.Exit(1) from err - - size = f.stat().st_size - if size >= 1024 * 1024: - size_str = f"{size / 1024 / 1024:.1f} MB" - elif size >= 1024: - size_str = f"{size / 1024:.0f} KB" - else: - size_str = f"{size} bytes" - typer.echo( - "DRY RUN — nothing was sent. Re-run without --no-submit to upload this." - ) - typer.echo(f"Attachment draft for ticket #{ticket_id}: {f.name} ({size_str})") - return - - # Validate files up front - for f in files: - if not f.is_file(): - typer.echo(f"Error: file not found: {f}", err=True) - raise typer.Exit(1) - - # Consent gate — show what will be uploaded - typer.echo(f"\n--- Attachment Review (ticket #{ticket_id}) ---") - for f in files: - size = f.stat().st_size - if size >= 1024 * 1024: - size_str = f"{size / 1024 / 1024:.1f} MB" - elif size >= 1024: - size_str = f"{size / 1024:.0f} KB" - else: - size_str = f"{size} bytes" - typer.echo(f" {f.name} ({size_str})") - typer.echo("--- End Review ---\n") - - if not yes: - approved = typer.confirm("Upload these files?") - if not approved: - typer.echo("Cancelled — nothing was sent.") - return - - path = f"/app/support/api/tickets/{ticket_id}/attachments" - for f in files: - try: - result = client.upload(path, files={"file": (f.name, str(f), None)}) - except ConveyUnreachableError: - raise - except ConveyClientError as err: - typer.echo(f"Skipped {f.name}: {err.error}", err=True) - continue - typer.echo(f"Attached: {f.name} (id: {result.get('id', '?')})") - - -@app.command("feedback") -@_support_cli -def feedback( - body: str = typer.Option(..., "--body", "-b", help="Your feedback."), - product: str = typer.Option("solstone", "--product", "-p", help="Product name."), - anonymous: bool = typer.Option(False, "--anonymous", help="submit anonymously."), - submit: bool = typer.Option( - False, - "--submit", - help=( - "Actually send the feedback to sol pbc. Without --submit this is a " - "dry run and nothing is sent." - ), - ), - yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."), -) -> None: - """Submit feedback (lower friction than a full ticket).""" - client = get_client() - config = _check_enabled(client) - if not submit: - # Fixed display values mirror tools.support_feedback (source of truth): - # subject=FEEDBACK_SUBJECT, severity="low", category="feedback". - diagnostics = client.request("GET", "/app/support/api/diagnostics") - _print_dry_run_preview( - subject=FEEDBACK_SUBJECT, - product=product, - severity="low", - category="feedback", - body=body, - diagnostics=diagnostics, - portal_url=config["portal_url"], - ) - _capture_draft( - client, - verb="feedback", - payload={"body": body, "product": product, "anonymous": anonymous}, - diagnostics_snapshot=diagnostics, - ) - return - - if not yes: - typer.echo(f"Feedback:\n{body}\n") - anon_note = " (anonymous)" if anonymous else "" - if not typer.confirm(f"Submit this feedback{anon_note}?"): - typer.echo("Cancelled.") - return - - result = client.request( - "POST", - "/app/support/api/feedback", - json={"body": body, "product": product, "anonymous": anonymous}, - ) - typer.echo(f"Feedback submitted: #{result.get('id', '?')}") - - -@app.command("announcements") -@_support_cli -def announcements( - as_json: bool = typer.Option(False, "--json", help="Output raw JSON."), -) -> None: - """Check for product updates and known issues.""" - client = get_client() - _check_enabled(client) - items = client.request("GET", "/app/support/api/announcements") - if as_json: - _json_out(items) - return - - if not items: - typer.echo("No active announcements.") - return - - for a in items: - icon = {"known-issue": "⚠️", "maintenance": "🔧"}.get(a.get("type", ""), "📢") - typer.echo(f" {icon} {a.get('title', 'Untitled')}") - if a.get("content"): - typer.echo(f" {a['content'][:120]}") - typer.echo(f"\n{len(items)} announcement(s).") - - -@app.command("diagnose") -def diagnose( - as_json: bool = typer.Option(False, "--json", help="Output raw JSON."), -) -> None: - """Show journal-host diagnostics (read-only).""" - client = get_client() - try: - data = client.request("GET", "/app/support/api/diagnostics") - except ConveyUnreachableError: - identity = _local_build_identity() - if as_json: - _json_out(identity) - else: - typer.echo("# Local Diagnostics\n") - typer.echo(f"Version: {identity.get('version') or 'unknown'}") - typer.echo(f"Revision: {identity.get('revision') or 'none'}") - plat = identity.get("platform", {}) - typer.echo( - f"Platform: {plat.get('system', '?')} {plat.get('release', '')} " - f"({plat.get('machine', '')})" - ) - typer.echo(f"Python: {plat.get('python', '?')}") - _emit_unreachable_notice() - raise typer.Exit(1) from None - except ConveyClientError as err: - typer.echo(err.error, err=True) - raise typer.Exit(1) from err - - if as_json: - _json_out(data) - else: - typer.echo("# Local Diagnostics\n") - typer.echo(f"Version: {data.get('version', 'unknown')}") - plat = data.get("platform", {}) - typer.echo( - f"Platform: {plat.get('system', '?')} {plat.get('release', '')} " - f"({plat.get('machine', '')})" - ) - typer.echo(f"Python: {plat.get('python', '?')}") - - services = data.get("services", {}) - if services: - typer.echo("\nServices:") - for name, status in sorted(services.items()): - icon = "✓" if status == "running" else "✗" - typer.echo(f" {icon} {name}: {status}") - - brain = data.get("brain_health", {}) - lines = brain.get("lines") if isinstance(brain, dict) else None - if isinstance(lines, list) and lines: - typer.echo("") - for line in lines: - typer.echo(str(line)) - - errors = data.get("recent_errors", []) - if errors: - typer.echo(f"\nRecent errors ({len(errors)}):") - for e in errors: - t = e.get("time", "") - if t and e.get("time_approximate"): - t = "~" + t - prefix = (t + " ") if t else "" - typer.echo( - f" {prefix}[{e.get('service', '?')}] {e.get('message', '')[:100]}" - ) - else: - typer.echo("\nNo recent errors.") diff --git a/solstone/apps/thinking/call.py b/solstone/apps/thinking/call.py deleted file mode 100644 index aa2cb8c59..000000000 --- a/solstone/apps/thinking/call.py +++ /dev/null @@ -1,768 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""CLI commands for Thinking configuration.""" - -from __future__ import annotations - -import json -import time -from typing import Any - -import typer - -from solstone.convey.reasons import ( - INVALID_CONFIG_VALUE, - INVALID_OPERATION_FOR_STATE, - SERVICE_BUSY, -) -from solstone.think.convey_client import ConveyClientError, convey_cli, get_client - -# Mirrors solstone.apps.thinking.routes.AI_KEY_ENV_VARS; reconstructed here -# rather than imported so this call.py remains a pure Convey HTTP client. -_AI_KEY_ENV_VARS = [ - "GOOGLE_API_KEY", - "ANTHROPIC_API_KEY", - "OPENAI_API_KEY", -] -_AI_ENV_TO_PROVIDER = { - "GOOGLE_API_KEY": "google", - "ANTHROPIC_API_KEY": "anthropic", - "OPENAI_API_KEY": "openai", -} -_PROVIDERS = ("anthropic", "google", "openai", "local") -_CLOUD_PROVIDERS = ("anthropic", "google", "openai") - -# Mirrors solstone.apps.thinking.copy; reconstructed here so this call.py -# remains a pure Convey HTTP client. -_SCOUT_STATE_OFF = "off" -_SCOUT_STATE_REQUESTED = "requested" -_SCOUT_STATE_INVITED = "invited" -_SCOUT_STATE_ON = "on" -_SCOUT_STATE_ENDED = "ended" -_SCOUT_STATE_MANUAL_KEY_PRESENT = "manual_key_present" -_SCOUT_STATE_REPAIR_NEEDED = "repair_needed" -_SCOUT_PRODUCT_STATES = { - _SCOUT_STATE_OFF, - _SCOUT_STATE_REQUESTED, - _SCOUT_STATE_INVITED, - _SCOUT_STATE_ON, - _SCOUT_STATE_ENDED, - _SCOUT_STATE_MANUAL_KEY_PRESENT, - _SCOUT_STATE_REPAIR_NEEDED, -} -_SCOUT_TERMINAL_PHASES = { - _SCOUT_STATE_INVITED, - _SCOUT_STATE_REQUESTED, - _SCOUT_STATE_ENDED, - _SCOUT_STATE_REPAIR_NEEDED, -} -_SCOUT_GUIDANCE = { - _SCOUT_STATE_OFF: "Scout is off.", - _SCOUT_STATE_REQUESTED: "Scout is waiting for approval.", - _SCOUT_STATE_INVITED: "Scout is ready; use the Scout lane in Thinking.", - _SCOUT_STATE_ON: "Scout is on.", - _SCOUT_STATE_ENDED: "Scout has ended; enable Scout to use it again.", - _SCOUT_STATE_MANUAL_KEY_PRESENT: "Clear the BYO Gemini key before enabling Scout.", - _SCOUT_STATE_REPAIR_NEEDED: "Scout needs repair; try again from Thinking.", -} -_SCOUT_CONSENT_CTA = "continue to approve →" - -# Mirrors solstone.apps.thinking.routes._CONFIDENTIAL_PHASE_TO_PRODUCT -# (routes.py:151-157); reconstructed here so this call.py remains a pure -# Convey HTTP client. -_CONFIDENTIAL_PHASE_TO_PRODUCT = { - "starting": "starting", - "waiting": "waiting", - "enabled": "not_verified", - "early_access": "early_access", - "error": "repair_needed", -} -# Mirrors solstone.think.services.operations.TERMINAL_PHASES -# (operations.py:32-34); reconstructed here so this call.py remains a pure -# Convey HTTP client. -_CONFIDENTIAL_RAW_TERMINAL_PHASES = { - "enabled", - "needs_subscription", - "revoked", - "error", - "early_access", -} -_CONFIDENTIAL_TERMINAL_PHASES = { - _CONFIDENTIAL_PHASE_TO_PRODUCT.get(phase, phase) - for phase in _CONFIDENTIAL_RAW_TERMINAL_PHASES -} -_CONFIDENTIAL_PHASE_NOT_VERIFIED = "not_verified" -_CONFIDENTIAL_SUCCESS_PHASES = {_CONFIDENTIAL_PHASE_NOT_VERIFIED} -_CONFIDENTIAL_OUTCOME_TERMINAL = "terminal" -_CONFIDENTIAL_OUTCOME_TIMEOUT = "timeout" -_CONFIDENTIAL_OUTCOME_SWEPT_CONFIGURED = "swept_configured" -_CONFIDENTIAL_OUTCOME_SWEPT_UNCONFIGURED = "swept_unconfigured" -# Mirrors the confidential browser portal-link literal at thinking.js:1202. -_CONFIDENTIAL_CONSENT_CTA = "continue in browser →" - -app = typer.Typer(help="Thinking providers, keys, and local model setup.") - -keys_app = typer.Typer(help="AI key management.") -app.add_typer(keys_app, name="keys") -providers_app = typer.Typer(help="AI provider configuration.") -app.add_typer(providers_app, name="providers") -local_app = typer.Typer(help="Local model readiness and setup.") -app.add_typer(local_app, name="local") -confidential_app = typer.Typer(help="Confidential processing lane.") -app.add_typer(confidential_app, name="confidential") -scout_app = typer.Typer( - help="Scout program — a Gemini key we provision on your behalf." -) -app.add_typer(scout_app, name="scout") - - -def _request( - method: str, - path: str, - *, - params: dict[str, object | None] | None = None, - json_body: dict[str, object | None] | None = None, -) -> Any: - clean_params = ( - {key: value for key, value in params.items() if value is not None} - if params - else None - ) - clean_body = ( - {key: value for key, value in json_body.items() if value is not None} - if json_body - else None - ) - return get_client().request( - method, - path, - params=clean_params, - json=clean_body, - ) - - -def _echo_json(payload: Any) -> None: - typer.echo(json.dumps(payload, indent=2)) - - -def _exit_with(message: str, code: int = 1) -> None: - typer.echo(message, err=True) - raise typer.Exit(code) - - -def _validate_env_var_or_exit(env_var: str) -> None: - if env_var not in _AI_KEY_ENV_VARS: - _exit_with( - f"Invalid env var: {env_var}. Must be one of: {', '.join(_AI_KEY_ENV_VARS)}" - ) - - -def _validate_provider_or_exit(provider: str, *, cloud_only: bool = False) -> None: - valid = _CLOUD_PROVIDERS if cloud_only else _PROVIDERS - if provider not in valid: - _exit_with(f"Invalid provider: {provider}. Must be one of: {', '.join(valid)}") - - -def _get_providers() -> dict[str, Any]: - return _request("GET", "/app/thinking/api/providers") - - -def _get_keys() -> dict[str, Any]: - return _request("GET", "/app/thinking/api/keys") - - -def _get_scout_status() -> dict[str, Any]: - return _request("GET", "/app/thinking/api/scout") - - -def _get_confidential_state() -> dict[str, Any]: - response = _get_providers() - active_lane = response.get("active_lane", {}) - return active_lane if isinstance(active_lane, dict) else {} - - -def _post_scout_action(path: str) -> dict[str, Any]: - try: - return _request("POST", path) - except ConveyClientError as err: - if err.reason_code == INVALID_OPERATION_FOR_STATE.code and err.detail: - _exit_with(err.detail) - raise - - -def _post_confidential_action(path: str) -> dict[str, Any]: - try: - return _request("POST", path) - except ConveyClientError as err: - if ( - err.reason_code - in { - INVALID_OPERATION_FOR_STATE.code, - SERVICE_BUSY.code, - } - and err.detail - ): - _exit_with(err.detail) - raise - - -def _scout_guidance(key: Any) -> str | None: - return _SCOUT_GUIDANCE.get(str(key or "")) - - -def _echo_scout_guidance(key: Any) -> None: - guidance = _scout_guidance(key) - if guidance: - typer.echo(guidance) - - -def _maybe_echo_scout_portal(operation: Any) -> None: - if not isinstance(operation, dict): - return - portal_url = operation.get("portal_url") - if portal_url: - typer.echo(f"{_SCOUT_CONSENT_CTA} {portal_url}") - - -def _maybe_echo_confidential_portal(operation: Any) -> None: - if not isinstance(operation, dict): - return - portal_url = operation.get("portal_url") - if portal_url: - typer.echo(f"{_CONFIDENTIAL_CONSENT_CTA} {portal_url}") - - -def _poll_scout_until_terminal( - *, - wait_seconds: float, - poll_interval: float, -) -> tuple[dict[str, Any], str | None, str | None]: - deadline = time.monotonic() + max(0.0, wait_seconds) - interval = max(0.0, poll_interval) - - while True: - status = _get_scout_status() - operation = status.get("operation") - if not isinstance(operation, dict): - return status, None, None - - phase = str(operation.get("phase") or "") - if phase in _SCOUT_TERMINAL_PHASES: - guidance = operation.get("guidance") - return status, phase, str(guidance) if guidance else None - - if time.monotonic() >= deadline: - return ( - status, - _SCOUT_STATE_REPAIR_NEEDED, - "Timed out waiting for Scout.", - ) - - if interval: - time.sleep(interval) - - -def _poll_confidential_until_terminal( - *, - wait_seconds: float, - poll_interval: float, -) -> tuple[dict[str, Any], str | None, str]: - deadline = time.monotonic() + max(0.0, wait_seconds) - interval = max(0.0, poll_interval) - - while True: - state_block = _get_confidential_state() - operation = state_block.get("confidential_operation") - if not isinstance(operation, dict): - outcome = ( - _CONFIDENTIAL_OUTCOME_SWEPT_CONFIGURED - if state_block.get("confidential_provenance_configured") is True - else _CONFIDENTIAL_OUTCOME_SWEPT_UNCONFIGURED - ) - return state_block, None, outcome - - phase = str(operation.get("phase") or "") - if phase in _CONFIDENTIAL_TERMINAL_PHASES: - return state_block, phase, _CONFIDENTIAL_OUTCOME_TERMINAL - - if time.monotonic() >= deadline: - return state_block, None, _CONFIDENTIAL_OUTCOME_TIMEOUT - - if interval: - time.sleep(interval) - - -def _echo_scout_terminal( - status: dict[str, Any], - phase: str | None, - operation_guidance: str | None, -) -> None: - state = status.get("state") - typer.echo(f"state: {state}") - if phase: - typer.echo(f"operation: {phase}") - if operation_guidance: - typer.echo(operation_guidance) - _echo_scout_guidance(phase or state) - - -def _echo_confidential_terminal( - state_block: dict[str, Any], - phase: str | None, -) -> None: - operation = state_block.get("confidential_operation") - if not isinstance(operation, dict): - operation = {} - attestation = state_block.get("confidential_attestation") - if not isinstance(attestation, dict): - attestation = {} - - typer.echo(f"confidential_enabled: {state_block.get('confidential_enabled')}") - typer.echo( - "confidential_provenance_configured: " - f"{state_block.get('confidential_provenance_configured')}" - ) - typer.echo(f"attestation_state: {attestation.get('state')}") - typer.echo(f"attestation_reason: {attestation.get('reason')}") - typer.echo(f"attestation_observed_at: {attestation.get('observed_at')}") - typer.echo(f"attestation_expires_at: {attestation.get('expires_at')}") - - operation_phase = phase or str(operation.get("phase") or "") - if operation_phase: - typer.echo(f"operation: {operation_phase}") - guidance = operation.get("guidance") - if guidance: - typer.echo(str(guidance)) - subscribe_url = operation.get("subscribe_url") - if subscribe_url: - typer.echo(f"subscribe_url: {subscribe_url}") - - -@scout_app.command("status") -@convey_cli -def scout_status() -> None: - """Show scout status.""" - - response = _get_scout_status() - _echo_json(response) - _echo_scout_guidance(response.get("state")) - - -@confidential_app.command("status") -@convey_cli -def confidential_status() -> None: - """Show confidential processing status.""" - - state = _get_confidential_state() - operation = state.get("confidential_operation") - operation_phase = operation.get("phase") if isinstance(operation, dict) else None - _echo_json( - { - "confidential_enabled": state.get("confidential_enabled"), - "confidential_provenance_configured": state.get( - "confidential_provenance_configured" - ), - "confidential_operation_phase": operation_phase, - "confidential_attestation": state.get("confidential_attestation"), - } - ) - - -@scout_app.command("check") -@convey_cli -def scout_check() -> None: - """Check scout status over the connection.""" - - response = _post_scout_action("/app/thinking/api/scout/check") - _echo_json(response) - _echo_scout_guidance(response.get("state")) - - -@scout_app.command("enable") -@convey_cli -def scout_enable( - wait_seconds: float = typer.Option( - 900.0, "--wait-seconds", help="Maximum seconds to wait for the operation." - ), - poll_interval: float = typer.Option( - 1.0, "--poll-interval", help="Seconds between status polls." - ), -) -> None: - """Enable scout — a Gemini key we provision on your behalf.""" - - response = _post_scout_action("/app/thinking/api/scout/enable") - _maybe_echo_scout_portal( - response.get("operation") if isinstance(response, dict) else None - ) - status, phase, operation_guidance = _poll_scout_until_terminal( - wait_seconds=wait_seconds, - poll_interval=poll_interval, - ) - _echo_scout_terminal(status, phase, operation_guidance) - if phase == _SCOUT_STATE_REPAIR_NEEDED: - raise typer.Exit(1) - - -@confidential_app.command("enable") -@convey_cli -def confidential_enable( - wait_seconds: float = typer.Option( - 900.0, "--wait-seconds", help="Maximum seconds to wait for the operation." - ), - poll_interval: float = typer.Option( - 1.0, "--poll-interval", help="Seconds between status polls." - ), -) -> None: - """Enable confidential processing.""" - - response = _post_confidential_action("/app/thinking/api/confidential/enable") - _maybe_echo_confidential_portal( - response.get("operation") if isinstance(response, dict) else None - ) - state, phase, outcome = _poll_confidential_until_terminal( - wait_seconds=wait_seconds, - poll_interval=poll_interval, - ) - _echo_confidential_terminal(state, phase) - - if ( - outcome == _CONFIDENTIAL_OUTCOME_TERMINAL - and phase in _CONFIDENTIAL_SUCCESS_PHASES - ) or outcome == _CONFIDENTIAL_OUTCOME_SWEPT_CONFIGURED: - typer.echo("next: sol call thinking confidential recheck") - return - - if outcome == _CONFIDENTIAL_OUTCOME_TIMEOUT: - typer.echo( - "operation continues server-side; " - "sol call thinking confidential status shows its progress." - ) - elif outcome == _CONFIDENTIAL_OUTCOME_SWEPT_UNCONFIGURED: - typer.echo( - "operation ended without enabling confidential processing; " - "check sol call thinking confidential status." - ) - - raise typer.Exit(1) - - -@scout_app.command("refresh") -@convey_cli -def scout_refresh( - wait_seconds: float = typer.Option( - 900.0, "--wait-seconds", help="Maximum seconds to wait for the operation." - ), - poll_interval: float = typer.Option( - 1.0, "--poll-interval", help="Seconds between status polls." - ), -) -> None: - """Refresh scout status.""" - - response = _post_scout_action("/app/thinking/api/scout/refresh") - _maybe_echo_scout_portal( - response.get("operation") if isinstance(response, dict) else None - ) - status, phase, operation_guidance = _poll_scout_until_terminal( - wait_seconds=wait_seconds, - poll_interval=poll_interval, - ) - _echo_scout_terminal(status, phase, operation_guidance) - if phase == _SCOUT_STATE_REPAIR_NEEDED: - raise typer.Exit(1) - - -@confidential_app.command("recheck") -@convey_cli -def confidential_recheck() -> None: - """Recheck confidential processing.""" - - response = _post_confidential_action("/app/thinking/api/confidential/recheck") - state = _get_confidential_state() - payload = { - "ok": response.get("ok"), - "attestation": state.get("confidential_attestation"), - } - if "error" in response: - payload["error"] = response.get("error") - _echo_json(payload) - - -@scout_app.command("disable") -@convey_cli -def scout_disable() -> None: - """Disable scout.""" - - response = _post_scout_action("/app/thinking/api/scout/disable") - _echo_json( - { - "result": response.get("result", {}), - "status": response.get("status", {}), - } - ) - - -@confidential_app.command("disable") -@convey_cli -def confidential_disable() -> None: - """Disable confidential processing.""" - - response = _post_confidential_action("/app/thinking/api/confidential/disable") - _echo_json({"result": response.get("result", {})}) - - -@keys_app.command("show") -@convey_cli -def keys_show() -> None: - """Show configured AI key status.""" - - response = _get_keys() - _echo_json( - { - "api_keys": response.get("api_keys", {}), - "env": response.get("env", {}), - "key_validation": response.get("key_validation", {}), - } - ) - - -@keys_app.command("set") -@convey_cli -def keys_set( - env_var: str = typer.Argument(..., help="Environment variable to set."), - value: str = typer.Argument(..., help="API key value."), -) -> None: - """Set an AI key in journal config.""" - - _validate_env_var_or_exit(env_var) - try: - response = _request( - "PUT", - "/app/thinking/api/keys", - json_body={"env_var": env_var, "value": value}, - ) - except ConveyClientError as err: - if err.reason_code == INVALID_CONFIG_VALUE.code and err.detail: - _exit_with(err.detail) - raise - provider = _AI_ENV_TO_PROVIDER[env_var] - _echo_json( - { - "env_var": env_var, - "set": True, - "validation": response.get("key_validation", {}).get(provider), - } - ) - - -@keys_app.command("clear") -@convey_cli -def keys_clear( - env_var: str = typer.Argument(..., help="Environment variable to clear."), -) -> None: - """Clear an AI key from journal config.""" - - _validate_env_var_or_exit(env_var) - _request( - "PUT", - "/app/thinking/api/keys", - json_body={"env_var": env_var, "value": ""}, - ) - _echo_json({"env_var": env_var, "cleared": True}) - - -@keys_app.command("validate") -@convey_cli -def keys_validate( - cache_result: bool = typer.Option( - False, "--cache-result", help="Persist results to providers.key_validation." - ), -) -> None: - """Validate configured AI keys.""" - - method = "POST" if cache_result else "GET" - response = _request(method, "/app/thinking/api/validate-keys") - _echo_json({"key_validation": response.get("key_validation", {})}) - - -@providers_app.command("show") -@convey_cli -def providers_show( - human: bool = typer.Option(False, "--human", help="Print one-line statuses."), -) -> None: - """Show provider configuration.""" - - response = _get_providers() - if human: - active = response.get("active_lane", {}) - typer.echo(f"active lane: {active.get('lane', 'none')}") - for name, status in sorted(response.get("provider_status", {}).items()): - issues = status.get("issues", []) - if issues: - status_text = issues[0] - elif status.get("cogitate_ready") or status.get("generate_ready"): - status_text = "ready" - else: - status_text = "not ready" - typer.echo(f"{name}: {status_text}") - return - _echo_json( - { - "providers": response.get("providers", []), - "provider_status": response.get("provider_status", {}), - "active_lane": response.get("active_lane", {}), - "active": response.get("active", {}), - "local_override": response.get("local_override", {}), - "api_keys": response.get("api_keys", {}), - "key_validation": response.get("key_validation", {}), - } - ) - - -def _set_active_provider( - provider: str, - model: str | None, -) -> dict[str, Any]: - _validate_provider_or_exit(provider) - if provider == "local": - if model is not None: - _exit_with("--model is only valid for cloud providers.") - local_override = _get_providers().get("local_override", {}) - lane = "byo" if local_override.get("enabled") else "local" - else: - lane = "byo" - payload: dict[str, object | None] = { - "lane": lane, - "provider": provider, - "model": model, - } - try: - response = _request( - "POST", - "/app/thinking/api/providers", - json_body=payload, - ) - except ConveyClientError as err: - if err.reason_code == INVALID_CONFIG_VALUE.code and err.detail: - _exit_with(err.detail) - raise - return response.get("active", {}) - - -@providers_app.command("set-active") -@convey_cli -def providers_set_active( - provider: str = typer.Option(..., "--provider", help="Active provider."), - model: str | None = typer.Option(None, "--model", help="Model override."), -) -> None: - """Set the provider and model used for all thinking.""" - - _echo_json(_set_active_provider(provider, model)) - - -@app.command("set-local-endpoint") -@convey_cli -def set_local_endpoint( - url: str = typer.Option(..., "--url", help="OpenAI-compatible endpoint URL."), - model: str = typer.Option(..., "--model", help="Served model id."), - credential: str | None = typer.Option( - None, - "--credential", - help="Optional bearer credential for the endpoint.", - ), -) -> None: - """Set the BYO local provider endpoint.""" - - payload: dict[str, object | None] = { - "endpoint_url": url, - "served_model_id": model, - } - if credential is not None: - payload["credential"] = credential - response = _request( - "POST", - "/app/thinking/api/local/endpoint", - json_body=payload, - ) - _echo_json(response.get("local_endpoint", response)) - - -@app.command("clear-local-endpoint") -@convey_cli -def clear_local_endpoint() -> None: - """Clear the BYO local provider endpoint.""" - - response = _request("DELETE", "/app/thinking/api/local/endpoint") - _echo_json(response.get("local_endpoint", response)) - - -@local_app.command("readiness") -@convey_cli -def local_readiness() -> None: - """Show local provider readiness.""" - - _echo_json(_request("GET", "/app/thinking/api/providers/local/status")) - - -@local_app.command("status") -@convey_cli -def local_status() -> None: - """Show local provider status.""" - - _echo_json(_request("GET", "/app/thinking/api/providers/local/status")) - - -@local_app.command("availability") -@convey_cli -def local_availability( - model: str | None = typer.Option(None, "--model", help="Local model id."), -) -> None: - """Show local model availability.""" - - _echo_json( - _request( - "GET", - "/app/thinking/api/local/availability", - params={"model": model}, - ) - ) - - -@local_app.command("bootstrap") -@convey_cli -def local_bootstrap( - model: str | None = typer.Option(None, "--model", help="Local model id."), -) -> None: - """Start local model setup.""" - - _echo_json( - _request( - "POST", - "/app/thinking/api/local/bootstrap", - params={"model": model}, - ) - ) - - -@local_app.command("bootstrap-status") -@convey_cli -def local_bootstrap_status( - model: str | None = typer.Option(None, "--model", help="Local model id."), -) -> None: - """Show local setup status.""" - - _echo_json( - _request( - "GET", - "/app/thinking/api/local/bootstrap/status", - params={"model": model}, - ) - ) - - -@local_app.command("models") -@convey_cli -def local_models() -> None: - """List local models.""" - - _echo_json(_request("GET", "/app/thinking/api/local/models")) diff --git a/solstone/apps/transcripts/call.py b/solstone/apps/transcripts/call.py deleted file mode 100644 index c5d9e81b9..000000000 --- a/solstone/apps/transcripts/call.py +++ /dev/null @@ -1,370 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""CLI commands for transcript browsing. - -Auto-discovered by ``think.call`` and mounted as ``sol call transcripts ...``. -Every verb reaches the journal only over HTTP via the Convey client; this -module imports no journal/domain function and performs no filesystem I/O. - -Commands: - sol call transcripts scan - sol call transcripts segments - sol call transcripts read [--segment KEY] [--stream STREAM] - sol call transcripts speakers [--json] - sol call transcripts stats -""" - -import json -import os - -import typer - -from solstone.think.convey_client import convey_cli, get_client - -app = typer.Typer(help="Transcript browsing.") - - -def _resolve_sol_day(arg: str | None) -> str: - if arg: - return arg - env = os.environ.get("SOL_DAY") or None - if env: - return env - typer.echo("Error: day is required (pass as argument or set SOL_DAY).", err=True) - raise typer.Exit(1) - - -def _resolve_sol_segment(arg: str | None) -> str | None: - if arg: - return arg - return os.environ.get("SOL_SEGMENT") or None - - -def _get_sol_stream() -> str | None: - return os.environ.get("SOL_STREAM") or None - - -def _truncated_echo(text: str, max_bytes: int) -> None: - encoded = text.encode("utf-8") - if max_bytes > 0 and len(encoded) > max_bytes: - truncated = encoded[:max_bytes].decode("utf-8", errors="ignore") - typer.echo(truncated) - typer.echo( - f"[truncated: {len(encoded):,} bytes total, --max {max_bytes:,}]", err=True - ) - else: - typer.echo(text) - - -def _pending_slot_range(start: str) -> tuple[str, str]: - hour_s, minute_s = start.split(":") - hour = int(hour_s) - minute = int(minute_s) - slot_minute = minute - (minute % 15) - end_hour = hour - end_minute = slot_minute + 15 - if end_minute >= 60: - end_hour = (end_hour + 1) % 24 - end_minute -= 60 - return f"{hour:02d}:{slot_minute:02d}", f"{end_hour:02d}:{end_minute:02d}" - - -def _format_pending_scan_note(starts: list[str]) -> str: - count = len(starts) - noun = "segment" if count == 1 else "segments" - return f"{count} {noun} pending at {', '.join(starts)}" - - -def _slot_overlaps_range(slot: tuple[str, str], range_: tuple[str, str]) -> bool: - def _to_min(hhmm: str) -> int: - hour_s, minute_s = hhmm.split(":") - return int(hour_s) * 60 + int(minute_s) - - slot_start, slot_end = (_to_min(slot[0]), _to_min(slot[1])) - range_start, range_end = (_to_min(range_[0]), _to_min(range_[1])) - return slot_start < range_end and slot_end > range_start - - -def _speaker_rows(payload: dict) -> list[dict]: - rows: list[dict] = [] - for chunk in payload.get("chunks", []): - if chunk.get("type") != "audio": - continue - label = chunk.get("speaker_label") or None - rows.append( - { - "sentence_id": chunk.get("sentence_id"), - "speaker_source": chunk.get("speaker_source"), - "time": chunk.get("time", ""), - "text": chunk.get("markdown", ""), - "has_embedding": bool(chunk.get("has_embedding")), - "actionable": bool(chunk.get("speaker_actionable")), - "speaker": label, - } - ) - return rows - - -def _row_speaker_text(row: dict) -> tuple[str, str]: - label = row.get("speaker") or {} - if not label or label.get("confidence_state") == "unknown": - return "unknown voice", "unknown" - return ( - str(label.get("name") or label.get("entity_id") or "unknown voice"), - str(label.get("confidence_state") or label.get("confidence") or "unknown"), - ) - - -@app.command("scan") -@convey_cli -def scan( - day: str | None = typer.Argument( - default=None, help="Day YYYYMMDD (default: SOL_DAY env)." - ), -) -> None: - """List transcript coverage ranges for a day.""" - day = _resolve_sol_day(day) - data = get_client().request("GET", f"/app/transcripts/api/day/{day}") - transcript_ranges = [(r["start"], r["end"]) for r in data["audio"]] - screen_ranges = [(r["start"], r["end"]) for r in data["screen"]] - segments = data["segments"] - pending_by_slot: dict[tuple[str, str], list[str]] = {} - for segment in segments: - if segment.get("data_state", {}).get("audio") != "pending": - continue - slot = _pending_slot_range(segment["start"]) - pending_by_slot.setdefault(slot, []).append(segment["start"]) - for starts in pending_by_slot.values(): - starts.sort() - - typer.echo("Transcripts:") - if transcript_ranges: - for start, end in transcript_ranges: - starts = [ - pending_start - for slot, slot_starts in pending_by_slot.items() - if _slot_overlaps_range(slot, (start, end)) - for pending_start in slot_starts - ] - line = f" {start} - {end}" - if starts: - starts.sort() - line += f" ({_format_pending_scan_note(starts)})" - typer.echo(line) - else: - typer.echo(" (none)") - - typer.echo("Percepts:") - if screen_ranges: - for start, end in screen_ranges: - typer.echo(f" {start} - {end}") - else: - typer.echo(" (none)") - - -@app.command("segments") -@convey_cli -def segments( - day: str | None = typer.Argument( - default=None, help="Day YYYYMMDD (default: SOL_DAY env)." - ), -) -> None: - """List recording segments for a day.""" - day = _resolve_sol_day(day) - segment_list = get_client().request("GET", f"/app/transcripts/api/segments/{day}")[ - "segments" - ] - if not segment_list: - typer.echo("No segments.") - return - - for segment in segment_list: - key = segment.get("key", "") - start = segment.get("start", "") - end = segment.get("end", "") - types = ", ".join(segment.get("types", [])) - typer.echo(f"{key} {start} - {end} [{types}]") - - -@app.command("speakers") -@convey_cli -def speakers( - day: str = typer.Argument(..., help="Day YYYYMMDD."), - stream: str = typer.Argument(..., help="Stream name."), - segment: str = typer.Argument(..., help="Segment key (HHMMSS_LEN)."), - json_output: bool = typer.Option(False, "--json", help="Output as JSON."), -) -> None: - """List speaker correction rows for one transcript segment.""" - payload = get_client().request( - "GET", - f"/app/transcripts/api/segment/{day}/{stream}/{segment}", - ) - rows = _speaker_rows(payload) - result = { - "day": day, - "stream": stream, - "segment_key": segment, - "speaker_labels": payload.get("speaker_labels") or {}, - "sentences": rows, - } - if json_output: - typer.echo(json.dumps(result, indent=2, default=str)) - return - - typer.echo(f"Speakers for {day}/{stream}/{segment}:") - if rows: - for row in rows: - marker = "*" if row["actionable"] else "-" - speaker_name, confidence = _row_speaker_text(row) - typer.echo( - f" {marker} #{row['sentence_id']} " - f"{row['speaker_source']} {row['time']} " - f"{speaker_name} [{confidence}] {row['text']}" - ) - else: - typer.echo(" (none)") - typer.echo("") - typer.echo( - "* actionable: sol call speakers correct " - " " - ) - typer.echo( - "- not actionable: sol call speakers tag-owner " - " " - ) - - -@app.command("read") -@convey_cli -def read( - day: str | None = typer.Argument( - default=None, help="Day YYYYMMDD (default: SOL_DAY env)." - ), - start: str | None = typer.Option(None, "--start", help="Start time (HHMMSS)."), - length: int | None = typer.Option(None, "--length", help="Length in minutes."), - segment: str | None = typer.Option( - None, "--segment", help="Segment key (HHMMSS_LEN, default: SOL_SEGMENT env)." - ), - segments: str | None = typer.Option( - None, "--segments", help="Comma-separated segment keys for a span." - ), - stream: str | None = typer.Option( - None, "--stream", help="Stream name (default: SOL_STREAM env)." - ), - full: bool = typer.Option( - False, "--full", help="Include transcripts, screen, and agents." - ), - raw: bool = typer.Option( - False, "--raw", help="Include transcripts and screen only." - ), - transcripts: bool = typer.Option( - False, "--transcripts", help="Include transcript content." - ), - audio: bool = typer.Option( - False, "--audio", help="Alias for --transcripts.", hidden=True - ), - percepts: bool = typer.Option(False, "--percepts", help="Include screen percepts."), - screen: bool = typer.Option( - False, "--screen", help="Alias for --percepts.", hidden=True - ), - agents: bool = typer.Option(False, "--agents", help="Include agent outputs."), - max_bytes: int = typer.Option( - 16384, "--max", help="Max output bytes (0 = unlimited)." - ), -) -> None: - """Read transcript content for a day, segment, or time range.""" - day = _resolve_sol_day(day) - segment = _resolve_sol_segment(segment) - stream = stream or _get_sol_stream() - # --audio is an alias for --transcripts, --screen is an alias for --percepts - transcripts = transcripts or audio - percepts = percepts or screen - - if full and raw: - typer.echo("Error: Cannot use --full and --raw together.", err=True) - raise typer.Exit(1) - - if (full or raw) and (transcripts or percepts or agents): - typer.echo( - "Error: Cannot mix --full/--raw with individual source flags.", err=True - ) - raise typer.Exit(1) - - if full: - sources: dict[str, bool] = { - "transcripts": True, - "percepts": True, - "agents": True, - } - elif raw: - sources = {"transcripts": True, "percepts": True, "agents": False} - elif transcripts or percepts or agents: - sources = {"transcripts": transcripts, "percepts": percepts, "agents": agents} - else: - sources = {"transcripts": True, "percepts": False, "agents": True} - - # Validate mutually exclusive selection modes - mode_count = sum( - [ - segment is not None, - segments is not None, - start is not None or length is not None, - ] - ) - if mode_count > 1: - typer.echo( - "Error: Cannot mix --segment, --segments, and --start/--length.", - err=True, - ) - raise typer.Exit(1) - - params: dict[str, str] = { - "transcripts": "1" if sources["transcripts"] else "0", - "percepts": "1" if sources["percepts"] else "0", - "agents": "1" if sources["agents"] else "0", - } - if start is not None or length is not None: - from datetime import datetime, timedelta - - effective_start = start if start is not None else "000000" - if length is not None: - start_dt = datetime.strptime(effective_start, "%H%M%S") - params["end"] = (start_dt + timedelta(minutes=length)).strftime("%H%M%S") - else: - params["end"] = "235959" - params["start"] = effective_start - elif segments is not None: - params["segments"] = segments - if stream: - params["stream"] = stream - elif segment is not None: - params["segment"] = segment - if stream: - params["stream"] = stream - markdown = get_client().request( - "GET", f"/app/transcripts/api/read/{day}", params=params - )["markdown"] - - _truncated_echo(markdown, max_bytes) - - -@app.command("stats") -@convey_cli -def stats(month: str = typer.Argument(help="Month (YYYYMM).")) -> None: - """Show daily transcript coverage counts for a month.""" - client = get_client() - day_totals = client.request("GET", f"/app/transcripts/api/stats/{month}") - days = sorted(day_totals.keys()) - days_with_data = 0 - for day in days: - ranges = client.request("GET", f"/app/transcripts/api/ranges/{day}") - n_transcripts = len(ranges["audio"]) - n_percepts = len(ranges["screen"]) - days_with_data += 1 - typer.echo(f"{day} transcripts:{n_transcripts} percepts:{n_percepts}") - if not days_with_data: - typer.echo(f"No data for {month}.") - return - typer.echo("") - typer.echo(f"Total: {days_with_data} days with data") diff --git a/solstone/apps/transcripts/tests/test_call.py b/solstone/apps/transcripts/tests/test_call.py deleted file mode 100644 index 0821b94be..000000000 --- a/solstone/apps/transcripts/tests/test_call.py +++ /dev/null @@ -1,501 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""Golden parity tests for transcripts CLI commands.""" - -import json - -import pytest -import requests -from typer.testing import CliRunner - -from solstone.apps.transcripts.call import app -from solstone.think.convey_client import ConveyClient -from tests._baseline_harness import make_test_client, mark_setup_complete - - -@pytest.fixture -def journal(tmp_path, monkeypatch): - monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - mark_setup_complete(tmp_path) - return tmp_path - - -@pytest.fixture -def runner(journal, monkeypatch): - client = ConveyClient(session=make_test_client(journal), base_url="") - monkeypatch.setattr("solstone.apps.transcripts.call.get_client", lambda: client) - return CliRunner() - - -def _write_segment( - journal_root, - day: str, - segment: str, - *, - audio_jsonl: bool = False, - audio_flac: bool = False, - screen_jsonl: bool = False, -) -> None: - segment_dir = journal_root / "chronicle" / day / "default" / segment - segment_dir.mkdir(parents=True, exist_ok=True) - if audio_jsonl: - (segment_dir / "audio.jsonl").write_text( - '{"raw": "audio.flac"}\n{"start": "00:00:01", "text": "audio"}\n', - encoding="utf-8", - ) - if audio_flac: - (segment_dir / "audio.flac").write_bytes(b"audio") - if screen_jsonl: - (segment_dir / "screen.jsonl").write_text( - '{"raw": "screen.webm"}\n' - '{"timestamp": 1, "analysis": {"primary": "work"}}\n', - encoding="utf-8", - ) - - -def _route_markdown(journal, day: str, params: dict[str, str]) -> str: - client = ConveyClient(session=make_test_client(journal), base_url="") - return client.request("GET", f"/app/transcripts/api/read/{day}", params=params)[ - "markdown" - ] - - -class _FakeClient: - def __init__(self, payload: dict) -> None: - self.payload = payload - self.calls: list[tuple[str, str, dict[str, str] | None]] = [] - - def request( - self, - method: str, - path: str, - params: dict[str, str] | None = None, - ) -> dict: - self.calls.append((method, path, params)) - return self.payload - - -def _speaker_payload() -> dict: - return { - "speaker_labels": { - "present": True, - "loaded": True, - "source": "audio", - "ambiguous": False, - }, - "chunks": [ - { - "type": "audio", - "sentence_id": 1, - "speaker_source": "audio", - "time": "00:00:05", - "markdown": "(mic) hello", - "has_embedding": True, - "speaker_actionable": True, - "speaker_label": { - "name": "Romeo Montague", - "entity_id": "romeo_montague", - "confidence": "high", - "confidence_state": "high", - "is_owner": True, - }, - }, - { - "type": "audio", - "sentence_id": 2, - "speaker_source": "audio", - "time": "00:00:20", - "markdown": "(mic) unlabeled", - "has_embedding": True, - "speaker_actionable": False, - }, - { - "type": "screen", - "time": "00:00:22", - "markdown": "screen", - }, - ], - } - - -def test_scan_output_byte_identical_when_no_pending_segments(runner, journal): - day = "20990102" - _write_segment( - journal, - day, - "090000_300", - audio_jsonl=True, - screen_jsonl=True, - ) - - result = runner.invoke(app, ["scan", day]) - - assert result.exit_code == 0 - assert result.output == ( - "Transcripts:\n 09:00 - 09:15\nPercepts:\n 09:00 - 09:15\n" - ) - - -def test_scan_output_annotates_pending_inside_range(runner, journal): - day = "20990103" - _write_segment(journal, day, "090000_300", audio_jsonl=True) - _write_segment(journal, day, "090500_300", audio_flac=True) - - result = runner.invoke(app, ["scan", day]) - - assert result.exit_code == 0 - assert result.output == ( - "Transcripts:\n" - " 09:00 - 09:15 (1 segment pending at 09:05)\n" - "Percepts:\n" - " (none)\n" - ) - - -def test_scan_output_reports_pending_only_range(runner, journal): - day = "20990104" - _write_segment(journal, day, "091500_300", audio_flac=True) - - result = runner.invoke(app, ["scan", day]) - - assert result.exit_code == 0 - assert result.output == ( - "Transcripts:\n" - " 09:15 - 09:30 (1 segment pending at 09:15)\n" - "Percepts:\n" - " (none)\n" - ) - - -def test_scan_output_pluralizes_multiple_pending(runner, journal): - day = "20990105" - _write_segment(journal, day, "090000_300", audio_jsonl=True) - _write_segment(journal, day, "090500_300", audio_flac=True) - _write_segment(journal, day, "091000_300", audio_flac=True) - - result = runner.invoke(app, ["scan", day]) - - assert result.exit_code == 0 - assert result.output == ( - "Transcripts:\n" - " 09:00 - 09:15 (2 segments pending at 09:05, 09:10)\n" - "Percepts:\n" - " (none)\n" - ) - - -def test_scan_empty_day(runner): - result = runner.invoke(app, ["scan", "20990101"]) - - assert result.exit_code == 0 - assert result.output == "Transcripts:\n (none)\nPercepts:\n (none)\n" - - -def test_segments_seeded_day_byte_identical(runner, journal): - day = "20990106" - _write_segment(journal, day, "090000_300", audio_jsonl=True, screen_jsonl=True) - - result = runner.invoke(app, ["segments", day]) - - assert result.exit_code == 0 - assert result.output == "090000_300 09:00 - 09:05 [audio, screen]\n" - - -def test_segments_empty(runner): - result = runner.invoke(app, ["segments", "20990101"]) - - assert result.exit_code == 0 - assert result.output == "No segments.\n" - - -def test_speakers_human_output_uses_structured_segment_endpoint( - runner, - monkeypatch, -): - fake = _FakeClient(_speaker_payload()) - monkeypatch.setattr("solstone.apps.transcripts.call.get_client", lambda: fake) - - result = runner.invoke(app, ["speakers", "20260304", "default", "090000_300"]) - - assert result.exit_code == 0 - assert fake.calls == [ - ( - "GET", - "/app/transcripts/api/segment/20260304/default/090000_300", - None, - ) - ] - assert result.output == ( - "Speakers for 20260304/default/090000_300:\n" - " * #1 audio 00:00:05 Romeo Montague [high] (mic) hello\n" - " - #2 audio 00:00:20 unknown voice [unknown] (mic) unlabeled\n" - "\n" - "* actionable: sol call speakers correct " - " \n" - "- not actionable: sol call speakers tag-owner " - " \n" - ) - - -def test_speakers_json_output_exposes_sentence_ids_and_sources( - runner, - monkeypatch, -): - fake = _FakeClient(_speaker_payload()) - monkeypatch.setattr("solstone.apps.transcripts.call.get_client", lambda: fake) - - result = runner.invoke( - app, - ["speakers", "20260304", "default", "090000_300", "--json"], - ) - - assert result.exit_code == 0 - data = json.loads(result.stdout) - assert data["speaker_labels"]["source"] == "audio" - assert data["sentences"] == [ - { - "sentence_id": 1, - "speaker_source": "audio", - "time": "00:00:05", - "text": "(mic) hello", - "has_embedding": True, - "actionable": True, - "speaker": { - "name": "Romeo Montague", - "entity_id": "romeo_montague", - "confidence": "high", - "confidence_state": "high", - "is_owner": True, - }, - }, - { - "sentence_id": 2, - "speaker_source": "audio", - "time": "00:00:20", - "text": "(mic) unlabeled", - "has_embedding": True, - "actionable": False, - "speaker": None, - }, - ] - - -def test_read_default_matches_route_markdown(runner, journal): - day = "20990107" - _write_segment(journal, day, "090000_300", audio_jsonl=True) - expected = _route_markdown( - journal, - day, - {"transcripts": "1", "percepts": "0", "agents": "1"}, - ) - - result = runner.invoke(app, ["read", day]) - - assert result.exit_code == 0 - assert result.stdout == expected + "\n" - - -@pytest.mark.parametrize( - ("args", "stderr"), - [ - ( - ["read", "20990107", "--full", "--raw"], - "Error: Cannot use --full and --raw together.\n", - ), - ( - ["read", "20990107", "--full", "--transcripts"], - "Error: Cannot mix --full/--raw with individual source flags.\n", - ), - ( - [ - "read", - "20990107", - "--segment", - "090000_300", - "--start", - "090000", - "--length", - "5", - ], - "Error: Cannot mix --segment, --segments, and --start/--length.\n", - ), - ], -) -def test_read_cli_side_validation_errors_byte_exact(runner, args, stderr): - result = runner.invoke(app, args) - - assert result.exit_code == 1 - assert result.stderr == stderr - assert result.stdout == "" - - -def test_read_start_alone_routes_range_read(runner, journal): - day = "20990107" - _write_segment(journal, day, "090000_300", audio_jsonl=True) - expected = _route_markdown( - journal, - day, - { - "transcripts": "1", - "percepts": "0", - "agents": "1", - "start": "000000", - "end": "235959", - }, - ) - - result = runner.invoke(app, ["read", day, "--start", "000000"]) - - assert result.exit_code == 0 - assert result.stdout == expected + "\n" - - -def test_read_length_alone_routes_from_midnight(runner, journal): - day = "20990107" - _write_segment(journal, day, "090000_300", audio_jsonl=True) - expected = _route_markdown( - journal, - day, - { - "transcripts": "1", - "percepts": "0", - "agents": "1", - "start": "000000", - "end": "003000", - }, - ) - - result = runner.invoke(app, ["read", day, "--length", "30"]) - - assert result.exit_code == 0 - assert result.stdout == expected + "\n" - - -def test_read_truncation_reports_exact_byte_counts(runner, journal): - day = "20990108" - max_bytes = 12 - _write_segment(journal, day, "090000_300", audio_jsonl=True) - expected = _route_markdown( - journal, - day, - {"transcripts": "1", "percepts": "0", "agents": "1"}, - ) - expected_stdout = expected.encode("utf-8")[:max_bytes].decode( - "utf-8", errors="ignore" - ) - - result = runner.invoke(app, ["read", day, "--max", str(max_bytes)]) - - assert result.exit_code == 0 - assert result.stdout == expected_stdout + "\n" - assert result.stderr == ( - f"[truncated: {len(expected.encode('utf-8')):,} bytes total, " - f"--max {max_bytes:,}]\n" - ) - - -def test_stats_seeded_month_byte_identical(runner, journal): - day = "20990109" - _write_segment(journal, day, "090000_300", audio_jsonl=True, screen_jsonl=True) - - result = runner.invoke(app, ["stats", "209901"]) - - assert result.exit_code == 0 - assert result.output == ( - "20990109 transcripts:1 percepts:1\n\nTotal: 1 days with data\n" - ) - - -def test_stats_empty_month(runner): - result = runner.invoke(app, ["stats", "209902"]) - - assert result.exit_code == 0 - assert result.output == "No data for 209902.\n" - - -def test_scan_from_sol_day(runner, journal, monkeypatch): - day = "20990110" - _write_segment(journal, day, "090000_300", audio_jsonl=True) - monkeypatch.setenv("SOL_DAY", day) - - result = runner.invoke(app, ["scan"]) - - assert result.exit_code == 0 - assert result.output == "Transcripts:\n 09:00 - 09:15\nPercepts:\n (none)\n" - - -def test_read_from_sol_day(runner, journal, monkeypatch): - day = "20990111" - _write_segment(journal, day, "090000_300", audio_jsonl=True) - monkeypatch.setenv("SOL_DAY", day) - - result = runner.invoke(app, ["read"]) - - assert result.exit_code == 0 - assert "## 2099-01-11 09:00:00 - 09:05:00" in result.stdout - - -def test_read_from_sol_day_and_segment(runner, journal, monkeypatch): - day = "20990112" - _write_segment(journal, day, "090000_300", audio_jsonl=True) - monkeypatch.setenv("SOL_DAY", day) - monkeypatch.setenv("SOL_SEGMENT", "090000_300") - - result = runner.invoke(app, ["read"]) - - assert result.exit_code == 0 - assert "## 2099-01-12 09:00:00 - 09:05:00" in result.stdout - - -def test_read_from_sol_stream(runner, journal, monkeypatch): - day = "20990113" - _write_segment(journal, day, "090000_300", audio_jsonl=True) - monkeypatch.setenv("SOL_DAY", day) - monkeypatch.setenv("SOL_SEGMENT", "090000_300") - monkeypatch.setenv("SOL_STREAM", "default") - - result = runner.invoke(app, ["read"]) - - assert result.exit_code == 0 - assert "## 2099-01-13 09:00:00 - 09:05:00" in result.stdout - - -def test_convey_down_prints_require_solstone_message(journal, monkeypatch): - class DownSession: - def get(self, _url): - raise requests.exceptions.ConnectionError() - - def post(self, _url, json=None): - raise requests.exceptions.ConnectionError() - - client = ConveyClient(session=DownSession(), base_url="http://localhost:5015") - monkeypatch.setattr("solstone.apps.transcripts.call.get_client", lambda: client) - monkeypatch.delenv("SOL_SKIP_SUPERVISOR_CHECK", raising=False) - monkeypatch.delenv("SOL_SUPERVISOR_SPAWNED", raising=False) - - result = CliRunner().invoke(app, ["scan", "20240101"]) - - assert result.exit_code == 1 - assert ( - result.stderr - == "sol: solstone isn't running. Start it with 'journal up' and retry.\n" - ) - assert result.stdout == "" - - -@pytest.mark.parametrize("command", ["scan", "segments", "read"]) -def test_malformed_day_prints_owner_voice_error(runner, command): - result = runner.invoke(app, [command, "notaday"]) - - assert result.exit_code == 1 - assert result.stderr == "I couldn't use that day.\n" - assert result.stdout == "" - - -def test_malformed_month_prints_owner_voice_error(runner): - result = runner.invoke(app, ["stats", "bad"]) - - assert result.exit_code == 1 - assert result.stderr == "I couldn't use that month.\n" - assert result.stdout == "" diff --git a/solstone/think/call.py b/solstone/think/call.py index 41e9bd1b9..08ff15208 100644 --- a/solstone/think/call.py +++ b/solstone/think/call.py @@ -1,132 +1,28 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright (c) 2026 sol pbc -"""CLI interface for app tools via Typer. +"""CLI interface for the journal compatibility subtree. -Provides ``sol call [args]`` as a human-friendly CLI that -parallels app tool functions. Each app can contribute a ``call.py`` -module exporting a ``app = typer.Typer()`` instance whose commands are -auto-discovered and mounted as sub-commands. - -Discovery scans ``apps/*/call.py``, imports modules, and mounts subcommands. +The native `sol` binary owns migrated `sol call ` commands. The +remaining Python Typer surface is exactly `sol call journal ...`, mounted here +for the private native compatibility helper. """ -import importlib -import logging -import os -from pathlib import Path - import typer -logger = logging.getLogger(__name__) -# Keep in lockstep with scripts/check_access_imports_clean.py. The network app -# keeps its shipped CLI subcommand name `sol call link`. -CALL_NAME_OVERRIDES = {"network": "link"} +from solstone.think.tools.call import app as journal_app call_app = typer.Typer( name="call", - help="Call app functions from the command line.", + help="Call journal functions from the command line.", no_args_is_help=True, ) - -def _discover_app_calls() -> None: - """Discover and mount Typer sub-apps from apps/*/call.py. - - Each ``call.py`` must export an ``app`` variable that is a - ``typer.Typer`` instance. The app directory name becomes the - sub-command name (e.g. ``sol call entities search ...``). - - Errors in one app do not prevent others from loading. - """ - strict = os.environ.get("SOLSTONE_STRICT_CALL_DISCOVERY") == "1" - apps_dir = Path(__file__).parent.parent / "apps" - - if not apps_dir.exists(): - logger.debug("No apps/ directory found, skipping app call discovery") - return - - for app_dir in sorted(apps_dir.iterdir()): - if not app_dir.is_dir() or app_dir.name.startswith("_"): - continue - - call_file = app_dir / "call.py" - if not call_file.exists(): - continue - - app_name = app_dir.name - - try: - module = importlib.import_module(f"solstone.apps.{app_name}.call") - - sub_app = getattr(module, "app", None) - if not isinstance(sub_app, typer.Typer): - if strict: - raise ImportError( - f"apps/{app_name}/call.py has no 'app' Typer instance" - ) - logger.warning( - f"apps/{app_name}/call.py has no 'app' Typer instance, skipping" - ) - continue - - call_app.add_typer( - sub_app, name=CALL_NAME_OVERRIDES.get(app_name, app_name) - ) - logger.info(f"Loaded CLI commands from app: {app_name}") - except Exception as e: - if strict: - raise ImportError( - f"Failed to load CLI from app '{app_name}': {e}" - ) from e - logger.error( - f"Failed to load CLI from app '{app_name}': {e}", exc_info=True - ) - - -_discover_app_calls() - -# Mount built-in CLIs (not auto-discovered since they live under think/) -from solstone.think.tools.call import app as journal_app -from solstone.think.tools.health import app as health_app -from solstone.think.tools.ledger import app as ledger_app -from solstone.think.tools.profile import app as profile_app - - -def _moved_stub(journal_cmd: str) -> typer.Typer: - """Placeholder sub-app: the command moved to `journal `.""" - stub = typer.Typer( - help=f"Moved to `journal {journal_cmd}`.", - invoke_without_command=True, - no_args_is_help=False, - context_settings={"ignore_unknown_options": True, "allow_extra_args": True}, - ) - - @stub.callback( - invoke_without_command=True, - context_settings={"ignore_unknown_options": True, "allow_extra_args": True}, - ) - def _moved( - ctx: typer.Context, - _args: list[str] = typer.Argument(None), - ) -> None: - del ctx, _args - typer.echo(f"Moved to `journal {journal_cmd}` — run that instead.", err=True) - raise typer.Exit(2) - - return stub - - -call_app.add_typer(health_app, name="health") call_app.add_typer(journal_app, name="journal") -call_app.add_typer(ledger_app, name="ledger") -call_app.add_typer(_moved_stub("navigate"), name="navigate") -call_app.add_typer(profile_app, name="profile") -call_app.add_typer(_moved_stub("identity"), name="identity") def main() -> None: - """Entry point for ``sol call``.""" + """Entry point for the private ``sol call journal`` compatibility path.""" from solstone.think.utils import CorruptConfigError try: diff --git a/solstone/think/chat_cli.py b/solstone/think/chat_cli.py deleted file mode 100644 index f41e0523c..000000000 --- a/solstone/think/chat_cli.py +++ /dev/null @@ -1,434 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""CLI command for chatting with the journal agent.""" - -from __future__ import annotations - -import argparse -import json -import sys -import threading -import time -from collections.abc import Iterable, Iterator -from typing import Any - -import requests - -from solstone.apps.chat.copy import ( - CHAT_LIVENESS_THINKING, - talent_label_for, -) -from solstone.think.convey_client import ( - ConveyClient, - ConveyClientError, - ConveyUnreachableError, - resolve_base_url, -) -from solstone.think.utils import setup_cli - -POST_TIMEOUT_SECONDS = 10 -POLL_SECONDS = 2 -IDLE_CEILING_SECONDS = 240 - -SERVICE_DOWN_MESSAGE = ( - "sol: solstone isn't running. Start it with 'journal up' and retry." -) -QUEUED_MESSAGE = "Sol is busy right now — your message is queued." -LIVE_PROGRESS_UNAVAILABLE_MESSAGE = "Live progress was unavailable." -LOST_CONTACT_MESSAGE = ( - "sol: Lost contact with Sol before it finished — check 'journal doctor'." -) -EMPTY_ANSWER_MESSAGE = "sol: Sol returned an empty answer." -MALFORMED_RESPONSE_MESSAGE = "I couldn't read the chat response." -COMPOSING_MESSAGE = "Composing your answer…" - - -class _TimeoutSession(requests.Session): - def request(self, method, url, **kwargs): - kwargs.setdefault("timeout", POST_TIMEOUT_SECONDS) - return super().request(method, url, **kwargs) - - -def _build_client(base_url: str) -> ConveyClient: - return ConveyClient( - base_url=base_url, - require_service=False, - session=_TimeoutSession(), - ) - - -def iter_sse_events(chunks: Iterable[bytes]) -> Iterator[dict]: - buffer = "" - data_lines: list[str] = [] - - for chunk in chunks: - buffer += chunk.decode("utf-8", errors="replace") - while "\n" in buffer: - line, buffer = buffer.split("\n", 1) - if line.endswith("\r"): - line = line[:-1] - - if line == "": - if data_lines: - raw = "\n".join(data_lines) - try: - obj = json.loads(raw) - except json.JSONDecodeError: - pass - else: - if isinstance(obj, dict): - yield obj - data_lines = [] - continue - - if line.startswith(":"): - continue - - if line.startswith("data:"): - value = line[len("data:") :] - if value.startswith(" "): - value = value[1:] - data_lines.append(value) - - -def _open_sse(base_url: str) -> Iterable[bytes] | None: - url = base_url.rstrip("/") + "/sse/events" - try: - response = requests.get( - url, - stream=True, - timeout=(POST_TIMEOUT_SECONDS, None), - ) - except requests.exceptions.RequestException: - return None - return response.iter_content(chunk_size=None) - - -def _post_chat( - client: ConveyClient, - message: str, - facet: str | None, -) -> tuple[str, bool]: - payload = {"message": message} - if facet: - payload["facet"] = facet - try: - data = client.request("POST", "/api/chat", json=payload) - except ConveyUnreachableError: - raise - except ConveyClientError as exc: - if exc.status is not None and 200 <= exc.status < 300: - raise ValueError(MALFORMED_RESPONSE_MESSAGE) from exc - raise - - if not isinstance(data, dict): - raise ValueError(MALFORMED_RESPONSE_MESSAGE) - use_id = str(data.get("use_id") or "").strip() - if not use_id: - raise ValueError(MALFORMED_RESPONSE_MESSAGE) - return use_id, bool(data.get("queued")) - - -def _render_post_error(exc: ConveyClientError) -> str: - lines = [f"sol: {exc.error}"] - detail = str(exc.detail or "").strip() - if detail: - lines.append(f"sol: {detail}") - return "\n".join(lines) - - -def _origin_logical_use_id(message: dict) -> str: - origin = message.get("origin") - if not isinstance(origin, dict): - return "" - return str(origin.get("logical_use_id") or "") - - -def _is_fold_terminal_message(message: dict, use_id: str) -> bool: - return ( - message.get("requested_target") is None - and _origin_logical_use_id(message) == use_id - ) - - -def _session_terminal(client: ConveyClient, use_id: str) -> dict | None: - try: - data = client.request("GET", "/api/chat/session") - except ConveyClientError: - return None - latest = (data or {}).get("latest_sol_message") or {} - if str(latest.get("use_id") or "") != use_id and not _is_fold_terminal_message( - latest, - use_id, - ): - return None - if latest.get("requested_target") is not None: - return None - return {"kind": "finish", "result": str(latest.get("text") or "")} - - -def _collapse_whitespace(value: Any) -> str: - return " ".join(str(value or "").split()) - - -def _truncate(text: str, limit: int) -> str: - if len(text) <= limit: - return text - return text[: limit - 1] + "…" - - -def _task_suffix(task: Any) -> str: - collapsed = _collapse_whitespace(task) - if not collapsed: - return "" - return f" ({_truncate(collapsed, 100)})" - - -def _tool_name(event: dict) -> str: - return _collapse_whitespace(event.get("tool")) or "unknown" - - -def _render_progress(event: dict, *, verbose: bool) -> str | None: - tract = event.get("tract") - event_name = event.get("event") or event.get("kind") - - if verbose: - if tract == "cortex" and event_name == "start": - provider = _collapse_whitespace(event.get("provider")) or "unknown" - model = _collapse_whitespace(event.get("model")) or "unknown" - return f"Provider: {provider}; model: {model}" - if tract == "cortex" and event_name == "thinking": - summary = _collapse_whitespace(event.get("summary")) - if summary: - return f"Thinking: {_truncate(summary, 200)}" - if tract == "cortex" and event_name == "tool_end": - return f"· {_tool_name(event)} done" - return None - - if tract == "cortex": - if event_name in {"start", "thinking"}: - return CHAT_LIVENESS_THINKING - if event_name == "tool_start": - return f"· {_tool_name(event)}" - return None - - if tract == "chat" and event_name == "sol_message": - target = event.get("requested_target") - try: - label = talent_label_for(target, "running") - except ValueError: - return None - return label + _task_suffix(event.get("requested_task")) - - if tract == "chat" and event_name == "talent_finished": - return COMPOSING_MESSAGE - - return None - - -def _terminal_error_message( - reason: str, - provider: str, -) -> str: - from solstone.convey.provider_readiness import chat_view - - rendered = chat_view(reason or "unknown", provider or "") - return str(rendered.get("message") or reason or "unknown") - - -def _print_stderr(message: str) -> None: - print(message, file=sys.stderr) - - -def main() -> None: - """Entry point for ``sol chat``.""" - parser = argparse.ArgumentParser( - prog="sol chat", - description="Chat with your journal", - ) - parser.add_argument("message", nargs="*", help="Chat message") - parser.add_argument("--facet", help="Facet context") - args = setup_cli(parser) - - from solstone.think.identity import ensure_identity_directory - - ensure_identity_directory() - - if not args.message: - parser.print_help() - return - - message = " ".join(args.message).strip() - base_url = resolve_base_url() - client = _build_client(base_url) - state: dict[str, Any] = { - "use_id": None, - "last_event_at": time.monotonic(), - "terminal": None, - "last_progress": None, - } - lock = threading.Lock() - done = threading.Event() - sse_ended = threading.Event() - - def set_terminal(terminal: dict[str, str]) -> None: - with lock: - if not done.is_set(): - state["terminal"] = terminal - done.set() - - def emit_progress(line: str | None) -> None: - if not line: - return - should_print = False - with lock: - if line != state["last_progress"]: - state["last_progress"] = line - should_print = True - if should_print: - _print_stderr(line) - - def render_event_progress(msg: dict[str, Any]) -> None: - emit_progress(_render_progress(msg, verbose=False)) - if args.verbose: - emit_progress(_render_progress(msg, verbose=True)) - - def callback(msg: dict) -> None: - event_tract = msg.get("tract") - event_name = msg.get("event") - with lock: - logical_use_id = state["use_id"] - - if ( - event_tract == "cortex" - and msg.get("chat_proxy") is True - and logical_use_id is not None - and msg.get("use_id") == logical_use_id - ): - with lock: - state["last_event_at"] = time.monotonic() - if event_name == "finish": - set_terminal( - { - "kind": "finish", - "result": str(msg.get("result") or ""), - } - ) - return - if event_name == "error": - set_terminal( - { - "kind": "error", - "reason": str(msg.get("error") or "unknown"), - "provider": str(msg.get("provider") or ""), - "detail": str(msg.get("detail") or ""), - } - ) - return - render_event_progress(msg) - return - - if event_tract != "chat": - return - - if logical_use_id is not None and msg.get("use_id") == logical_use_id: - with lock: - state["last_event_at"] = time.monotonic() - if event_name == "sol_message" and msg.get("requested_target"): - render_event_progress(msg) - return - - if ( - logical_use_id is not None - and event_name == "sol_message" - and _is_fold_terminal_message(msg, logical_use_id) - ): - with lock: - state["last_event_at"] = time.monotonic() - set_terminal( - { - "kind": "finish", - "result": str(msg.get("text") or ""), - } - ) - return - - if logical_use_id is not None and event_name == "talent_finished": - with lock: - state["last_event_at"] = time.monotonic() - render_event_progress(msg) - - sse_chunks = _open_sse(base_url) - try: - use_id, queued = _post_chat(client, message, args.facet) - except ConveyUnreachableError: - _print_stderr(SERVICE_DOWN_MESSAGE) - sys.exit(1) - except ConveyClientError as exc: - _print_stderr(_render_post_error(exc)) - sys.exit(1) - except ValueError as exc: - _print_stderr(f"sol: {exc}") - sys.exit(1) - - with lock: - state["use_id"] = use_id - state["last_event_at"] = time.monotonic() - if queued: - emit_progress(QUEUED_MESSAGE) - - def reader() -> None: - try: - for event in iter_sse_events(sse_chunks or []): - if done.is_set(): - return - callback(event) - finally: - if not done.is_set(): - sse_ended.set() - - reader_thread = threading.Thread(target=reader, daemon=True) - reader_thread.start() - - try: - while not done.wait(POLL_SECONDS): - with lock: - idle_for = time.monotonic() - float(state["last_event_at"]) - if sse_ended.is_set() or idle_for >= IDLE_CEILING_SECONDS: - terminal = _session_terminal(client, use_id) - if terminal is not None: - _print_stderr(LIVE_PROGRESS_UNAVAILABLE_MESSAGE) - set_terminal(terminal) - break - if idle_for >= IDLE_CEILING_SECONDS: - set_terminal({"kind": "lost_contact"}) - break - except KeyboardInterrupt: - _print_stderr("\nInterrupted.") - sys.exit(1) - - with lock: - terminal = state["terminal"] - - if terminal is None or terminal.get("kind") == "lost_contact": - _print_stderr(LOST_CONTACT_MESSAGE) - sys.exit(1) - - if terminal.get("kind") == "finish": - result = str(terminal.get("result") or "") - if not result.strip(): - _print_stderr(EMPTY_ANSWER_MESSAGE) - sys.exit(1) - print(result) - return - - if terminal.get("kind") == "error": - reason = str(terminal.get("reason") or "unknown") - provider = str(terminal.get("provider") or "") - message = _terminal_error_message(reason, provider) - _print_stderr(f"sol: {message}") - sys.exit(1) - - _print_stderr(LOST_CONTACT_MESSAGE) - sys.exit(1) diff --git a/solstone/think/doctor.py b/solstone/think/doctor.py index f2596f9d6..7af07983b 100644 --- a/solstone/think/doctor.py +++ b/solstone/think/doctor.py @@ -252,7 +252,7 @@ def sol_importable_check(args: Args) -> CheckResult: return make_result(check, "skip", ".venv absent; run make install") probe = run_probe( check, - [str(python_bin), "-c", "from solstone.think.sol_cli import main"], + [str(python_bin), "-c", "from solstone.think.sol_compat_cli import main"], cwd=Path("/"), timeout=2.0, allow_nonzero=True, @@ -265,7 +265,7 @@ def sol_importable_check(args: Args) -> CheckResult: return make_result( check, "ok", - "from solstone.think.sol_cli import main succeeded outside repo cwd", + "from solstone.think.sol_compat_cli import main succeeded outside repo cwd", ) stderr = probe.stderr.strip() if "ModuleNotFoundError: No module named 'solstone'" in stderr: @@ -278,7 +278,10 @@ def sol_importable_check(args: Args) -> CheckResult: first_line = next((line for line in stderr.splitlines() if line.strip()), "") detail = truncate( first_line - or f"from solstone.think.sol_cli import main failed with exit {probe.returncode}", + or ( + "from solstone.think.sol_compat_cli import main failed with exit " + f"{probe.returncode}" + ), 120, ) return make_result(check, "fail", detail, fix) diff --git a/solstone/think/import_client.py b/solstone/think/import_client.py deleted file mode 100644 index 31a6c12e3..000000000 --- a/solstone/think/import_client.py +++ /dev/null @@ -1,388 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""Thin `sol import` client for the local Convey import API.""" - -from __future__ import annotations - -import argparse -import json -import logging -import sys -import uuid -from pathlib import Path -from typing import Any - -from solstone.think.convey_client import ( - ConveyClient, - ConveyClientError, - ConveyUnreachableError, - get_client, -) - -IMPORT_API = "/app/import/api" -MALFORMED_RESPONSE = "I couldn't read the journal response." -JOURNAL_HOST_HINT = "Run this on the journal host with `journal importer`." -MODE_DISPOSITIONS = { - "positional_media": "http-client", - "--timestamp": "http-client", - "--facet": "http-client", - "--setting": "http-client", - "--source": "http-client", - "--force": "http-client", - "--auto": "http-client", - "--deterministic-only": "http-client", - "--dry-run": "reject-journal-host", - "--json": "client-output", - "-v/--verbose": "client-logging", - "--backends": "reject-journal-host", - "--sync": "reject-journal-host", - "--save": "reject-journal-host", - "--path": "reject-journal-host", - "--list-importers": "reject-journal-host", - "journal-source": "relocate-sol-call-import", -} - - -def _build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Import media through the journal") - parser.add_argument("media", nargs="?", help="Path to a file or journal-host path") - parser.add_argument("extra", nargs="*", help=argparse.SUPPRESS) - parser.add_argument( - "--timestamp", help="Timestamp YYYYMMDD_HHMMSS for journal entry" - ) - parser.add_argument("--facet", help="Facet name for this import") - parser.add_argument( - "--setting", - help="Contextual setting description to store with import metadata", - ) - parser.add_argument( - "--source", - help=( - "Import source hint: a file importer name (e.g. obsidian, plaud, ics) " - "or a canonical category (audio, image, document, text)" - ), - ) - parser.add_argument( - "--force", - action="store_true", - help="Force re-import by deleting existing import directory", - ) - parser.add_argument( - "--auto", - nargs="?", - const=True, - default=None, - help="Accept the server-detected timestamp", - ) - parser.add_argument( - "--deterministic-only", - action="store_true", - help="Use only deterministic timestamp detection; skip model detection", - ) - parser.add_argument( - "--dry-run", - action="store_true", - help="Show what would be imported without writing to the journal", - ) - parser.add_argument( - "--backends", - action="store_true", - help="List syncable importer backends", - ) - parser.add_argument( - "--sync", - metavar="BACKEND", - help="Sync catalog from a backend", - ) - parser.add_argument( - "--save", - action="store_true", - help="With --sync: download and import new files", - ) - parser.add_argument( - "--path", - help="With --sync: override the default source directory path", - ) - parser.add_argument( - "--list-importers", - action="store_true", - help="List available file importers", - ) - parser.add_argument("--json", action="store_true", help="Output JSON") - parser.add_argument("-v", "--verbose", action="store_true", help="Verbose logging") - return parser - - -def _exit_rejected(parser: argparse.ArgumentParser, message: str) -> int: - parser.exit(2, f"sol import: {message}\n") - return 2 - - -def _reject_unsupported_modes( - parser: argparse.ArgumentParser, - args: argparse.Namespace, -) -> int | None: - if args.media == "journal-source": - return _exit_rejected( - parser, - "journal-source management moved to `sol call import `.", - ) - if args.dry_run: - return _exit_rejected( - parser, f"`--dry-run` requires the journal host. {JOURNAL_HOST_HINT}" - ) - if args.backends: - return _exit_rejected( - parser, f"`--backends` requires the journal host. {JOURNAL_HOST_HINT}" - ) - if args.list_importers: - return _exit_rejected( - parser, - f"`--list-importers` requires the journal host. {JOURNAL_HOST_HINT}", - ) - if args.sync: - return _exit_rejected( - parser, f"`--sync` requires the journal host. {JOURNAL_HOST_HINT}" - ) - if args.save: - return _exit_rejected( - parser, f"`--save` requires the journal host. {JOURNAL_HOST_HINT}" - ) - if args.path: - return _exit_rejected( - parser, f"`--path` requires the journal host. {JOURNAL_HOST_HINT}" - ) - if args.auto not in (None, True): - return _exit_rejected( - parser, - "`--auto ` requires the journal host. " - "Use `--timestamp` here or run `journal importer`.", - ) - return None - - -def _payload_value(value: str | None) -> str | None: - if value is None: - return None - stripped = value.strip() - return stripped or None - - -def _save_media( - client: ConveyClient, - args: argparse.Namespace, - client_item_id: str, -) -> dict[str, Any]: - media_path = Path(args.media).expanduser() - data = { - key: value - for key, value in { - "client_item_id": client_item_id, - "facet": _payload_value(args.facet), - "setting": _payload_value(args.setting), - "source_hint": _payload_value(args.source), - }.items() - if value is not None - } - if args.deterministic_only: - data["deterministic_only"] = "true" - if media_path.exists() and media_path.is_file(): - return client.upload( - f"{IMPORT_API}/save", - files={ - "file": ( - media_path.name, - media_path, - "application/octet-stream", - ) - }, - data=data, - ) - return client.request( - "POST", - f"{IMPORT_API}/save-path", - json={ - **data, - "path": str(media_path), - }, - ) - - -def _start_import( - client: ConveyClient, - args: argparse.Namespace, - save_response: dict[str, Any], -) -> dict[str, Any]: - path = save_response.get("path") - timestamp = args.timestamp or save_response.get("timestamp") - if not isinstance(path, str) or not path: - raise ConveyClientError(MALFORMED_RESPONSE) - if not isinstance(timestamp, str) or not timestamp: - raise ConveyClientError(MALFORMED_RESPONSE) - - payload: dict[str, Any] = { - "path": path, - "timestamp": timestamp, - "force": bool(args.force), - } - start_response = client.request("POST", f"{IMPORT_API}/start", json=payload) - if not isinstance(start_response, dict): - raise ConveyClientError(MALFORMED_RESPONSE) - task_id = start_response.get("task_id") - if not isinstance(task_id, str) or not task_id: - raise ConveyClientError(MALFORMED_RESPONSE) - return start_response - - -def _is_malformed(err: ConveyClientError) -> bool: - return err.error == MALFORMED_RESPONSE - - -def _print_client_error(operation: str, err: ConveyClientError) -> None: - if _is_malformed(err): - print("sol import: couldn't read journal response", file=sys.stderr) - return - print(f"sol import: failed to {operation}: {err.error}", file=sys.stderr) - if err.detail: - print(f"sol import: {err.detail}", file=sys.stderr) - - -def _print_partial_error(staged_path: str, err: ConveyClientError) -> None: - if _is_malformed(err): - print( - f"sol import: staged {staged_path} but processing was not queued: " - "couldn't read journal response", - file=sys.stderr, - ) - return - print( - f"sol import: staged {staged_path} but processing was not queued: {err.error}", - file=sys.stderr, - ) - if err.detail: - print(f"sol import: {err.detail}", file=sys.stderr) - - -def _print_success( - save_response: dict[str, Any], - start_response: dict[str, Any], - *, - json_out: bool, -) -> None: - timestamp = save_response.get("timestamp") - path = save_response.get("path") - if json_out: - print( - json.dumps( - { - "status": "queued", - "path": path, - "timestamp": timestamp, - "save": save_response, - "start": start_response, - }, - sort_keys=True, - ) - ) - return - - print(f"staged {path}") - if timestamp: - print(f"timestamp {timestamp}") - task_id = start_response.get("task_id") - if task_id: - print(f"queued processing task {task_id}") - else: - print("queued processing") - - -def _print_duplicate(save_response: dict[str, Any], *, json_out: bool) -> None: - if json_out: - print(json.dumps(save_response, sort_keys=True)) - return - - duplicate = save_response.get("duplicate") - if not isinstance(duplicate, dict): - print("sol import: duplicate import; skipping") - return - - state = duplicate.get("state") - import_id = duplicate.get("import_id") or "unknown" - if state == "imported": - imported_at = duplicate.get("imported_at") or "unknown date" - entry_count = duplicate.get("entry_count") - entries = f" ({entry_count} entries)" if entry_count is not None else "" - print(f"sol import: already imported on {imported_at}{entries}; skipping") - return - if state == "staged": - print(f"sol import: already staged as {import_id}; skipping") - return - - print("sol import: duplicate import; skipping") - - -def _run(args: argparse.Namespace, client: ConveyClient) -> int: - client_item_id = uuid.uuid4().hex - try: - save_response = _save_media(client, args, client_item_id) - if not isinstance(save_response, dict): - raise ConveyClientError(MALFORMED_RESPONSE) - except ConveyUnreachableError: - print( - "sol import: couldn't reach the journal. Start it with 'journal up' and retry.", - file=sys.stderr, - ) - return 1 - except ConveyClientError as err: - _print_client_error("stage import", err) - return 1 - - if ( - save_response.get("status") == "duplicate" - or save_response.get("recommended_action") == "do_not_start" - ): - _print_duplicate(save_response, json_out=bool(args.json)) - return 0 - - staged_path = str(save_response.get("path") or args.media) - try: - start_response = _start_import(client, args, save_response) - except ConveyUnreachableError: - print( - f"sol import: staged {staged_path} but processing was not queued: " - "couldn't reach the journal", - file=sys.stderr, - ) - return 1 - except ConveyClientError as err: - _print_partial_error(staged_path, err) - return 1 - - _print_success(save_response, start_response, json_out=bool(args.json)) - return 0 - - -def main( - argv: list[str] | None = None, - *, - client: ConveyClient | None = None, -) -> int: - parser = _build_parser() - args = parser.parse_args(sys.argv[1:] if argv is None else argv) - if args.verbose: - logging.basicConfig(level=logging.DEBUG) - - rejected = _reject_unsupported_modes(parser, args) - if rejected is not None: - return rejected - if args.extra: - parser.error(f"unexpected argument(s): {' '.join(args.extra)}") - if not args.media: - parser.error("the following arguments are required: media") - - return _run(args, client or get_client()) - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/solstone/think/link/cli.py b/solstone/think/link/cli.py index 0f6a2569f..df357f77f 100644 --- a/solstone/think/link/cli.py +++ b/solstone/think/link/cli.py @@ -34,12 +34,6 @@ def _build_parser() -> argparse.ArgumentParser: def main(argv: list[str] | None = None) -> int: """CLI entry point for `sol link`.""" args = list(sys.argv[1:] if argv is None else argv) - if sys.argv[0] == "journal link": - from solstone.apps.network.call import app as link_management_app - - link_management_app(args=args, prog_name="journal link") - return 0 # unreachable: the Typer app raises SystemExit - parser = _build_parser() namespace = parser.parse_args(args) if namespace.command is None: diff --git a/solstone/think/skills_build.py b/solstone/think/skills_build.py index f1261e645..b3f4766bf 100644 --- a/solstone/think/skills_build.py +++ b/solstone/think/skills_build.py @@ -5,15 +5,13 @@ from __future__ import annotations -import importlib import os import tempfile +import tomllib from dataclasses import dataclass from pathlib import Path import frontmatter -import typer -from typer.main import get_command from solstone.think.command_polarity import classify_verb @@ -136,33 +134,31 @@ def _load_fragments() -> dict[str, Fragment]: return fragments -def _resolve_typer_app(app_name: str, source: Path) -> typer.Typer: - module_name = ( - "solstone.think.tools.health" +def _command_names(app_name: str, source: Path) -> tuple[str, ...]: + authority = ( + ROOT / "solstone" / "think" / "tools" / "native" / app_name / "authority.toml" if app_name == "health" - else f"solstone.apps.{app_name}.call" + else ROOT / "solstone" / "apps" / app_name / "native" / "authority.toml" ) try: - module = importlib.import_module(module_name) + data = tomllib.loads(authority.read_text(encoding="utf-8")) except Exception as exc: - raise ValueError(f"{source}: cannot import {module_name}: {exc}") from exc - - app = getattr(module, "app", None) - if not isinstance(app, typer.Typer): - raise ValueError(f"{source}: {module_name} does not export a Typer app") - return app - - -def _command_names(app_name: str, source: Path) -> tuple[str, ...]: - app = _resolve_typer_app(app_name, source) - try: - command = get_command(app) - names = tuple(sorted(command.commands.keys())) - except Exception as exc: - raise ValueError(f"{source}: cannot introspect Typer commands: {exc}") from exc + raise ValueError( + f"{source}: cannot read native authority {authority}: {exc}" + ) from exc + names = { + path[1] + for entry in data.get("entries", []) + if isinstance(entry, dict) + and isinstance(path := entry.get("path"), list) + and len(path) >= 2 + and path[0] == app_name + and isinstance(path[1], str) + and path[1] + } if not names: - raise ValueError(f"{source}: Typer app has no commands") - return names + raise ValueError(f"{source}: native authority has no {app_name} commands") + return tuple(sorted(names)) def _format_list(values: tuple[str, ...]) -> str: diff --git a/solstone/think/sol_cli.py b/solstone/think/sol_cli.py index a20570fc0..eccf56eb7 100644 --- a/solstone/think/sol_cli.py +++ b/solstone/think/sol_cli.py @@ -1,17 +1,11 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright (c) 2026 sol pbc -"""Unified CLI for solstone — `sol` (journal access) and `journal` (the journal host). +"""Journal host CLI dispatcher. -Usage: - sol Show status and available commands - sol [args] Run a subcommand - journal [args] Run a service module by dotted path - -Examples: - sol import data.json Import data into journal - journal think 20250101 Run daily processing for a day - journal solstone.think.talents -h Show help for a service module +The public `sol` and `solstone` access commands are native binaries owned by +`solstone-core`. This module survives only as the `journal` console script +entry point for service and universal commands. """ from __future__ import annotations @@ -26,29 +20,16 @@ from typing import Any, Literal, NamedTuple import setproctitle -# ============================================================================= -# Command Registry -# ============================================================================= -# Maps short command names to module paths. -# All modules must have a main() function as entry point. -# -# To add a new command: -# 1. Add entry here: "name": "package.module" -# 2. Ensure module has main() function -# -# Aliases for compound commands can be added to ALIASES dict below. -# ============================================================================= - class Command(NamedTuple): module: str - surface: Literal["access", "service", "universal"] + surface: Literal["service", "universal"] class Alias(NamedTuple): module: str preset_args: list[str] - surface: Literal["access", "service", "universal"] + surface: Literal["service", "universal"] class HelpGroup(NamedTuple): @@ -56,15 +37,6 @@ class HelpGroup(NamedTuple): commands: tuple[str, ...] -JOURNAL_ACCESS_CMD_ERROR = ( - "'{cmd}' is a journal-access command — run it with 'sol {cmd}' instead.\n" - "('journal' surfaces only journal-service commands; see 'journal --help'.)" -) -SOL_SERVICE_CMD_REMOVED_ERROR = ( - "'{cmd}' moved to 'journal {cmd}' — run that instead.\n" - "('sol' is the journal-access surface; 'journal' surfaces journal-service " - "commands; see 'journal --help'.)" -) JOURNAL_VERSION_MISMATCH_ERROR = """Journal package versions are out of sync. solstone is installed at {solstone_version}, but {leaf_name} is installed at {leaf_version}. @@ -92,7 +64,7 @@ See https://github.com/solpbc/solstone-journal/blob/main/INSTALL.md def _installed_packaging_versions() -> dict[str, str | None]: - """Installed versions of the split's four dists, each a version string or None.""" + """Installed versions of the split dists, each a version string or None.""" result: dict[str, str | None] = {} for name in ( "solstone", @@ -121,7 +93,7 @@ def _guard_journal_coherence() -> None: if v is not None } if any(v == solstone_version for v in leaves.values()): - return # healthy (incl. both-leaves-one-matching — doctor's problem) + return if leaves: leaf_name = ( "solstone-journal-cuda" @@ -140,21 +112,10 @@ def _guard_journal_coherence() -> None: if versions["solstone-journal-host"] is not None: sys.stderr.write(JOURNAL_HOST_SHIM_MIGRATION_ERROR) sys.exit(1) - return # no leaf, no shim - - -SOL_HELP_GROUP_CONVERSATION = "Conversation" -SOL_HELP_GROUP_YOUR_JOURNAL = "Your journal" -SOL_HELP_GROUP_DIAGNOSE = "See & diagnose" -SOL_HELP_GROUP_TOOLS = "Tools" -SOL_HELP_GROUP_SERVICE_HEADING = "Journal service (also available as `journal `)" -SOL_HELP_GROUP_ALIASES = "Aliases" COMMANDS: dict[str, Command] = { - # think package - daily processing and analysis "backup": Command("solstone.think.backup_cli", "service"), - "import": Command("solstone.think.import_client", "access"), "importer": Command("solstone.think.importers.cli", "service"), "think": Command("solstone.think.thinking", "service"), "indexer": Command("solstone.think.indexer", "service"), @@ -164,14 +125,12 @@ COMMANDS: dict[str, Command] = { "maintenance": Command("solstone.think.maintenance_cli", "service"), "top": Command("solstone.think.top", "service"), "health": Command("solstone.think.health_cli", "service"), - "notify": Command("solstone.think.notify_cli", "access"), "doctor": Command("solstone.think.doctor", "universal"), "check": Command("solstone.think.check", "universal"), "contract": Command("solstone.think.contract_cli", "universal"), "config": Command("solstone.think.config_cli", "service"), "install-models": Command("solstone.think.install_models", "service"), "install-provider": Command("solstone.think.install_provider", "service"), - "skills": Command("solstone.think.skills_cli", "access"), "settings": Command("solstone.think.settings_cli", "service"), "streams": Command("solstone.think.streams", "service"), "segment": Command("solstone.think.segment", "service"), @@ -181,7 +140,6 @@ COMMANDS: dict[str, Command] = { "solstone.think.backfill_processing_records", "service" ), "warm": Command("solstone.think.warm", "service"), - # observe package - multimodal capture "transcribe": Command("solstone.observe.transcribe", "service"), "describe": Command("solstone.observe.describe", "service"), "depict": Command("solstone.observe.depict", "service"), @@ -190,20 +148,16 @@ COMMANDS: dict[str, Command] = { "export": Command("solstone.observe.export", "service"), "grab": Command("solstone.observe.grab", "service"), "observer": Command("solstone.observe.observer_cli", "service"), - # AI providers and talent execution "brain": Command("solstone.think.brain_cli", "service"), "facet-candidates": Command("solstone.think.facet_candidates_cli", "service"), "cortex": Command("solstone.think.cortex", "service"), "talent": Command("solstone.think.talent_cli", "service"), "link": Command("solstone.think.link", "universal"), "spl": Command("solstone.think.spl", "service"), - "call": Command("solstone.think.call", "access"), "navigate": Command("solstone.think.tools.navigate", "service"), "identity": Command("solstone.think.tools.sol", "service"), "engage": Command("solstone.think.engage", "service"), - "chat": Command("solstone.think.chat_cli", "access"), "heartbeat": Command("solstone.think.heartbeat", "service"), - # convey package - web UI "convey": Command("solstone.convey.cli", "service"), "restart-convey": Command("solstone.convey.restart", "service"), "maint": Command("solstone.convey.maint_cli", "service"), @@ -211,35 +165,11 @@ COMMANDS: dict[str, Command] = { "setup": Command("solstone.think.setup", "service"), } -# ============================================================================= -# Aliases for Compound Commands -# ============================================================================= -# Maps alias names to (module, default_args) tuples. -# These provide shortcuts for common operations with preset arguments. -# -# Example: "up": Alias("solstone.think.service", ["up"], "service") -# Running "journal up" is equivalent to "journal service up" -# ============================================================================= - ALIASES: dict[str, Alias] = { "up": Alias("solstone.think.service", ["up"], "service"), "down": Alias("solstone.think.service", ["down"], "service"), } -# Owner-facing command groupings for `sol --help`. -# -# Access-tagged commands are assigned to one of the four intent groups below. -# Future access commands must be assigned here deliberately. -ACCESS_HELP_GROUPS: tuple[HelpGroup, ...] = ( - HelpGroup(SOL_HELP_GROUP_CONVERSATION, ("chat",)), - HelpGroup(SOL_HELP_GROUP_YOUR_JOURNAL, ("call", "import")), - HelpGroup(SOL_HELP_GROUP_DIAGNOSE, ("notify", "doctor", "check")), - HelpGroup( - SOL_HELP_GROUP_TOOLS, - ("skills", "link", "contract"), - ), -) - def get_status() -> dict[str, Any]: """Return current journal status information.""" @@ -269,62 +199,13 @@ def print_status() -> None: def service_help_group() -> HelpGroup: """Return the derived Journal service help group in registry order.""" return HelpGroup( - SOL_HELP_GROUP_SERVICE_HEADING, + "Journal service commands", tuple( name for name, command in COMMANDS.items() if command.surface == "service" ), ) -def help_groups() -> tuple[HelpGroup, ...]: - """Return all owner-facing help groups in display order.""" - return ACCESS_HELP_GROUPS - - -def _print_help_group(group: HelpGroup) -> None: - print(group.heading) - for cmd in group.commands: - if cmd in COMMANDS: - module = COMMANDS[cmd].module - print(f" {cmd:16} {module}") - print() - - -def _alias_target_label(alias: Alias) -> str: - for name, command in COMMANDS.items(): - if command.module == alias.module and not alias.preset_args: - return name - if command.module == alias.module and alias.preset_args: - return " ".join([name] + alias.preset_args) - return " ".join([alias.module] + alias.preset_args) - - -def print_help() -> None: - """Print help with status and available commands.""" - print("sol - journal access CLI (solstone)\n") - print_status() - - print("Usage: sol [args...]\n") - - for group in help_groups(): - _print_help_group(group) - - # Print call sub-apps - try: - from solstone.think.call import call_app - - print("Apps (sol call ):") - for group in call_app.registered_groups: - name = group.name or "" - help_text = group.typer_instance.info.help - if not isinstance(help_text, str): - help_text = "" - print(f" call {name:16} {help_text}") - print() - except Exception: - pass - - def print_journal_help() -> None: """Print help for the journal service command surface.""" print("journal - the journal host CLI (solstone)\n") @@ -349,7 +230,7 @@ def print_journal_help() -> None: args_str = ( " ".join(command_alias.preset_args) if command_alias.preset_args else "" ) - print(f" {name:16} → {command_alias.module} {args_str}") + print(f" {name:16} -> {command_alias.module} {args_str}") print() print("Options:") @@ -363,32 +244,18 @@ def print_journal_help() -> None: def resolve_command(name: str) -> tuple[str, list[str], str]: - """Resolve command name to module path and any preset args. - - Args: - name: Command name, alias, or module path - - Returns: - Tuple of (module_path, preset_args, surface) - - Raises: - ValueError: If command not found - """ - # Check aliases first (they override commands) + """Resolve command name to module path and any preset args.""" if name in ALIASES: command_alias = ALIASES[name] return command_alias.module, command_alias.preset_args, command_alias.surface - # Check command registry if name in COMMANDS: command = COMMANDS[name] return command.module, [], command.surface - # Check if it looks like a module path (contains ".") if "." in name: return name, [], "service" - # Not found available = sorted(set(COMMANDS.keys()) | set(ALIASES.keys())) raise ValueError( f"Unknown command: {name}\nAvailable commands: {', '.join(available[:10])}..." @@ -396,14 +263,7 @@ def resolve_command(name: str) -> tuple[str, list[str], str]: def run_command(module_path: str) -> int: - """Import and run a module's main() function. - - Args: - module_path: Dotted module path (e.g., "solstone.think.importers.cli") - - Returns: - Exit code (0 for success) - """ + """Import and run a module's main() function.""" try: module = importlib.import_module(module_path) except ImportError as e: @@ -414,59 +274,37 @@ def run_command(module_path: str) -> int: print(f"Error: Module '{module_path}' has no main() function", file=sys.stderr) return 1 - # Call main - it may call sys.exit() internally try: result = module.main() return 0 if result is None else int(result) except SystemExit as e: - # Preserve exit code from subcommand - # SystemExit can have int code, string message, or None if isinstance(e.code, int): return e.code - elif isinstance(e.code, str): + if isinstance(e.code, str): print(e.code, file=sys.stderr) return 1 - else: - return 0 if not e.code else 1 + return 0 if not e.code else 1 -def _dispatch(binary: str, allowed_surfaces: frozenset[str] | None) -> None: - """Dispatch a top-level CLI binary to a registered command.""" - # No arguments - show status and help +def _dispatch(binary: str) -> None: + """Dispatch the journal CLI binary to a registered command.""" if len(sys.argv) < 2: - if binary == "journal": - print_journal_help() - else: - print_help() + print_journal_help() return if sys.argv[1] in ("-v", "--verbose"): logging.basicConfig(level=logging.DEBUG) del sys.argv[1] if len(sys.argv) < 2: - if binary == "journal": - print_journal_help() - else: - print_help() + print_journal_help() return cmd = sys.argv[1] - # Help flags - if cmd in ("--help", "-h"): - if binary == "journal": - print_journal_help() - else: - print_help() - return - if cmd == "help" and len(sys.argv) <= 2: - if binary == "journal": - print_journal_help() - else: - print_help() + if cmd in ("--help", "-h") or (cmd == "help" and len(sys.argv) <= 2): + print_journal_help() return - # Version flag if cmd in ("--version", "-V"): try: _v = _pkg_version("solstone") @@ -475,7 +313,6 @@ def _dispatch(binary: str, allowed_surfaces: frozenset[str] | None) -> None: print(f"{binary} (solstone) {_v}") return - # Path flag if cmd == "--path": from solstone.think.utils import get_journal_info @@ -483,14 +320,12 @@ def _dispatch(binary: str, allowed_surfaces: frozenset[str] | None) -> None: print(path) return - # Root command — print repo root for scripting: SOL=$(sol root) if cmd == "root": from solstone.think.utils import get_project_root print(get_project_root()) return - # Resolve command to module path rest = sys.argv[2:] try: module_path, preset_args, surface = resolve_command(cmd) @@ -498,55 +333,19 @@ def _dispatch(binary: str, allowed_surfaces: frozenset[str] | None) -> None: print(f"Error: {e}", file=sys.stderr) sys.exit(1) - if binary == "sol" and surface == "service": - from solstone.think.service import _managed_wrapper, reconcile_installed_unit - - reconciled = reconcile_installed_unit() - if ( - reconciled.was_stale - and reconciled.stale_binary == "sol" - and reconciled.stale_verb == cmd - ): - journal_wrapper = _managed_wrapper("journal") - # Route through `journal start` (the canonical entry that runs the - # version-marker / wrapper / skill refresh), not `journal {cmd}`. - # The unit was just rewritten to `journal start `; the shim - # exec should match so the upgrade-time refresh fires on this - # boot rather than waiting for the next restart. - os.execv(str(journal_wrapper), [str(journal_wrapper), "start", *rest]) - print(SOL_SERVICE_CMD_REMOVED_ERROR.format(cmd=cmd), file=sys.stderr) - sys.exit(2) - - if allowed_surfaces is not None and surface not in allowed_surfaces: - sys.stderr.write(JOURNAL_ACCESS_CMD_ERROR.format(cmd=cmd) + "\n") - sys.exit(2) - if surface == "service": _guard_journal_coherence() - # Set process title for ps/top visibility setproctitle.setproctitle(f"{binary}:{cmd}") - - # Adjust sys.argv for the subcommand - # Original: ["sol", "chat", "--help"] - # Becomes: ["sol chat", "--help"] - # This makes argparse show "usage: ..." in help. sys.argv = [f"{binary} {cmd}"] + preset_args + rest - - # Run the command exit_code = run_command(module_path) sys.exit(exit_code) -def main() -> None: - """Main entry point for sol CLI.""" - _dispatch("sol", allowed_surfaces=None) - - def journal_main() -> None: """Main entry point for journal service CLI.""" - _dispatch("journal", allowed_surfaces=frozenset({"service", "universal"})) + _dispatch("journal") if __name__ == "__main__": - main() + journal_main() diff --git a/solstone/think/tools/health.py b/solstone/think/tools/health.py deleted file mode 100644 index 09be4da53..000000000 --- a/solstone/think/tools/health.py +++ /dev/null @@ -1,212 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -from __future__ import annotations - -import json -from datetime import datetime, timedelta -from typing import NoReturn, Optional - -import typer - -from solstone.think.convey_client import ConveyClientError, get_client - -app = typer.Typer( - help="Health: journal-data trust signals (for infrastructure/service liveness, use `journal health`).", - no_args_is_help=True, -) - - -def _params(**values: object) -> dict[str, object]: - return {key: value for key, value in values.items() if value is not None} - - -def _exit_with(message: str) -> NoReturn: - typer.echo(message, err=True) - raise typer.Exit(1) - - -def _handle_health_error(err: ConveyClientError) -> NoReturn: - _exit_with(err.detail or err.error) - - -def _dash(value: object) -> object: - return "—" if value is None else value - - -def _render_summary(report: dict) -> None: - capture = report["capture_health"] - synthesis = report["synthesis_health"] - consumer_signal = report["consumer_signal"] - - typer.echo(f"Range: {report['range'][0]} -> {report['range'][1]}") - typer.echo("Capture") - typer.echo(f" hours_with_capture: {capture['hours_with_capture']}") - typer.echo(f" hours_total: {capture['hours_total']}") - typer.echo(f" coverage_ratio: {_dash(capture['coverage_ratio'])}") - typer.echo( - " facets_with_recent_capture: " - + ", ".join(capture["facets_with_recent_capture"]) - ) - typer.echo(" facets_silent_24h: " + ", ".join(capture["facets_silent_24h"])) - typer.echo(f" last_segment_at: {_dash(capture['last_segment_at'])}") - typer.echo("Synthesis") - typer.echo(f" activities_count: {synthesis['activities_count']}") - typer.echo( - " activities_with_participation: " - + str(synthesis["activities_with_participation"]) - ) - typer.echo(f" activities_with_story: {synthesis['activities_with_story']}") - typer.echo(f" activities_user_edited: {synthesis['activities_user_edited']}") - typer.echo( - " activities_anticipated_unfilled: " - + str(synthesis["activities_anticipated_unfilled"]) - ) - typer.echo( - " talent_run_failures_24h: " + str(_dash(synthesis["talent_run_failures_24h"])) - ) - typer.echo( - " talent_degraded_outputs_24h: " - + str(_dash(synthesis["talent_degraded_outputs_24h"])) - ) - typer.echo( - " indexer_last_rebuild_at: " + str(_dash(synthesis["indexer_last_rebuild_at"])) - ) - backlog = report["segment_backlog"] - n = backlog["not_thought"] - m = backlog["days_with_backlog"] - seg_word = "segment" if n == 1 else "segments" - day_word = "day" if m == 1 else "days" - if backlog["errors"] and n > 0: - typer.echo( - f" at least {n} {seg_word} across {m} {day_word} " - "awaiting thinking (status incomplete)" - ) - elif backlog["errors"]: - typer.echo(" Segment analysis status unavailable") - elif n > 0: - typer.echo(f" {n} {seg_word} across {m} {day_word} awaiting thinking") - typer.echo("Consumer Signals") - typer.echo( - f" ledger_open_items_total: {consumer_signal['ledger_open_items_total']}" - ) - typer.echo( - f" ledger_stale_items_count: {consumer_signal['ledger_stale_items_count']}" - ) - typer.echo(f" profile_entities_total: {consumer_signal['profile_entities_total']}") - brain = report.get("brain_health", {}) - lines = brain.get("lines") if isinstance(brain, dict) else None - if isinstance(lines, list) and lines: - for line in lines: - typer.echo(str(line)) - typer.echo("Notes") - if not report["notes"]: - typer.echo(" none") - return - for note in report["notes"]: - typer.echo(f" [{note['severity']}] {note['category']}: {note['message']}") - - -def _render_full(report: dict) -> None: - _render_summary(report) - if not report["capture_health"]["facets_silent_24h"]: - return - - typer.echo("Silent Facet Detail") - for facet in report["capture_health"]["facets_silent_24h"]: - matching = [ - note - for note in report["notes"] - if note["category"] == "capture" and note["message"].startswith(f"{facet}:") - ] - if not matching: - continue - for note in matching: - typer.echo(f" {facet}: [{note['severity']}] {note['message']}") - - -@app.command("summary") -def summary( - day: str | None = typer.Option(None, "--day"), - json_out: bool = typer.Option(False, "--json"), -) -> None: - """Summarize journal-data trust signals for one day.""" - try: - report = get_client().request( - "GET", "/api/health/summary", params=_params(day=day) - ) - except ConveyClientError as err: - _handle_health_error(err) - if json_out: - typer.echo(json.dumps(report, indent=2)) - return - _render_summary(report) - - -@app.command("full") -def full( - day: str | None = typer.Option(None, "--day"), - json_out: bool = typer.Option(False, "--json"), -) -> None: - """Render the full journal-data trust report for one day.""" - try: - report = get_client().request( - "GET", "/api/health/full", params=_params(day=day) - ) - except ConveyClientError as err: - _handle_health_error(err) - if json_out: - typer.echo(json.dumps(report, indent=2)) - return - _render_full(report) - - -@app.command("for-range") -def for_range( - day_from: str | None = typer.Option(None, "--day-from"), - day_to: str | None = typer.Option(None, "--day-to"), - json_out: bool = typer.Option(False, "--json"), -) -> None: - """Render the journal-data trust report for an inclusive day range.""" - try: - report = get_client().request( - "GET", - "/api/health/range", - params=_params(day_from=day_from, day_to=day_to), - ) - except ConveyClientError as err: - _handle_health_error(err) - if json_out: - typer.echo(json.dumps(report, indent=2)) - return - _render_full(report) - - -@app.command( - "pipeline", - help="Thin wrapper around think.pipeline_health. For journal-data trust checks use `summary` / `full` / `for-range`.", -) -def pipeline( - day: Optional[str] = typer.Option( - None, "--day", help="Day to summarize (YYYYMMDD)." - ), - yesterday: bool = typer.Option( - False, "--yesterday", help="Summarize yesterday's pipeline." - ), -) -> None: - """Summarize think pipeline health for one day.""" - if day is not None and yesterday: - typer.echo("--day and --yesterday are mutually exclusive", err=True) - raise typer.Exit(1) - - if day is not None: - target = day - elif yesterday: - target = (datetime.now() - timedelta(days=1)).strftime("%Y%m%d") - else: - target = datetime.now().strftime("%Y%m%d") - - summary = get_client().request( - "GET", "/api/health/pipeline", params={"day": target} - ) - typer.echo(json.dumps(summary, indent=2, sort_keys=False)) diff --git a/solstone/think/tools/ledger.py b/solstone/think/tools/ledger.py deleted file mode 100644 index b6df98b73..000000000 --- a/solstone/think/tools/ledger.py +++ /dev/null @@ -1,201 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -import json -from typing import NoReturn - -import typer - -from solstone.convey.reasons import ACTIVITIES_BUSY, LEDGER_ITEM_NOT_FOUND -from solstone.think.convey_client import ( - ConveyClientError, - get_client, - paginate_collection, -) - -app = typer.Typer(help="Ledger: commitments ↔ closures view", no_args_is_help=True) - - -def _params(**values: object) -> dict[str, object]: - return {key: value for key, value in values.items() if value is not None} - - -def _exit_with(message: str) -> NoReturn: - typer.echo(message, err=True) - raise typer.Exit(1) - - -def _handle_ledger_error( - err: ConveyClientError, - *, - item_id: str | None = None, -) -> NoReturn: - if err.reason_code == LEDGER_ITEM_NOT_FOUND.code: - _exit_with(f"ledger item not found: {item_id}") - if err.reason_code == ACTIVITIES_BUSY.code: - _exit_with(ACTIVITIES_BUSY.message) - _exit_with(err.detail or err.error) - - -def _render_table(headers: list[str], rows: list[list[str]]) -> None: - if not rows: - return - widths = [ - max(len(header), *(len(row[index]) for row in rows)) - for index, header in enumerate(headers) - ] - typer.echo( - " ".join(header.ljust(widths[index]) for index, header in enumerate(headers)) - ) - typer.echo(" ".join("-" * width for width in widths)) - for row in rows: - typer.echo( - " ".join(cell.ljust(widths[index]) for index, cell in enumerate(row)) - ) - - -def _item_summary(item: dict) -> str: - if item["counterparty"]: - return f"{item['owner']}: {item['summary']} -> {item['counterparty']}" - return f"{item['owner']}: {item['summary']}" - - -def _render_items(items: list[dict]) -> None: - if not items: - typer.echo("No ledger items found.") - return - rows = [ - [ - item["id"], - item["state"], - str(item["age_days"]), - _item_summary(item), - item["when"] or "", - str(item["opened_at"]), - str(item["closed_at"] or ""), - ] - for item in items - ] - _render_table( - ["id", "state", "age_days", "summary", "when", "opened_at", "closed_at"], - rows, - ) - - -def _render_decisions(items: list[dict]) -> None: - if not items: - typer.echo("No decisions found.") - return - rows = [ - [item["id"], item["day"], item["owner"], item["action"], item["context"]] - for item in items - ] - _render_table(["id", "day", "owner", "action", "context"], rows) - - -@app.command("list") -def list_cmd( - state: str = typer.Option("open"), - owner: str | None = typer.Option(None), - counterparty: str | None = typer.Option(None), - age_days_gte: int | None = typer.Option(None, "--age-days-gte"), - closed_since: str | None = typer.Option(None, "--closed-since"), - top: int | None = typer.Option(None, "--top"), - sort: str | None = typer.Option(None), - facets: str | None = typer.Option(None, help="csv"), - json_out: bool = typer.Option(False, "--json"), -) -> None: - """List ledger items.""" - if sort is not None and sort not in { - "age_days_desc", - "opened_at_desc", - "closed_at_desc", - }: - raise typer.BadParameter( - "sort must be one of age_days_desc, opened_at_desc, closed_at_desc" - ) - try: - items = paginate_collection( - get_client(), - "/api/ledger", - params=_params( - state=state, - owner=owner, - counterparty=counterparty, - age_days_gte=age_days_gte, - closed_since=closed_since, - sort=sort, - facets=facets, - ), - top=top, - ) - except ConveyClientError as err: - _handle_ledger_error(err) - if json_out: - typer.echo(json.dumps(items, indent=2)) - return - _render_items(items) - - -@app.command("get") -def get_cmd(item_id: str, json_out: bool = typer.Option(False, "--json")) -> None: - """Fetch one ledger item.""" - try: - item = get_client().request("GET", f"/api/ledger/{item_id}") - except ConveyClientError as err: - _handle_ledger_error(err, item_id=item_id) - if json_out: - typer.echo(json.dumps([item], indent=2)) - return - _render_items([item]) - - -@app.command("close") -def close_cmd( - item_id: str, - note: str = typer.Option(..., "--note"), - as_state: str = typer.Option("closed", "--as"), - json_out: bool = typer.Option(False, "--json"), -) -> None: - """Manually close or drop one ledger item.""" - if as_state not in {"closed", "dropped"}: - raise typer.BadParameter("as_state must be 'closed' or 'dropped'") - try: - item = get_client().request( - "POST", - f"/api/ledger/{item_id}/close", - json={"note": note, "as_state": as_state}, - ) - except ConveyClientError as err: - _handle_ledger_error(err, item_id=item_id) - if json_out: - typer.echo(json.dumps([item], indent=2)) - return - _render_items([item]) - - -@app.command("decisions") -def decisions_cmd( - owner: str | None = typer.Option(None), - since: str | None = typer.Option(None), - involving: str | None = typer.Option(None), - top: int | None = typer.Option(None), - facets: str | None = typer.Option(None, help="csv"), - json_out: bool = typer.Option(False, "--json"), -) -> None: - """List deduplicated decisions.""" - try: - items = paginate_collection( - get_client(), - "/api/ledger/decisions", - params=_params( - owner=owner, since=since, involving=involving, facets=facets - ), - top=top, - ) - except ConveyClientError as err: - _handle_ledger_error(err) - if json_out: - typer.echo(json.dumps(items, indent=2)) - return - _render_decisions(items) diff --git a/solstone/think/tools/profile.py b/solstone/think/tools/profile.py deleted file mode 100644 index 96fe4319b..000000000 --- a/solstone/think/tools/profile.py +++ /dev/null @@ -1,219 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -import json -from typing import Any, Callable, NoReturn -from urllib.parse import quote - -import typer - -from solstone.convey.reasons import ENTITY_NOT_FOUND -from solstone.think.convey_client import ( - ConveyClientError, - get_client, - paginate_collection, -) - -app = typer.Typer(help="Profile consumer surface.", no_args_is_help=True) - - -def _params(**values: object) -> dict[str, object]: - return {key: value for key, value in values.items() if value is not None} - - -def _exit_with(message: str) -> NoReturn: - typer.echo(message, err=True) - raise typer.Exit(1) - - -def _handle_profile_error(err: ConveyClientError, *, name: str) -> NoReturn: - if err.reason_code == ENTITY_NOT_FOUND.code or err.status == 404: - _exit_with(f"profile not found: {name}") - _exit_with(err.detail or err.error) - - -def _render_table( - items: list[Any], columns: list[tuple[str, Callable[[Any], str]]] -) -> None: - if not items: - return - headers = [header for header, _ in columns] - rows = [[getter(item) for _, getter in columns] for item in items] - widths = [ - max(len(header), *(len(row[index]) for row in rows)) - for index, header in enumerate(headers) - ] - typer.echo( - " ".join(header.ljust(widths[index]) for index, header in enumerate(headers)) - ) - typer.echo(" ".join("-" * width for width in widths)) - for row in rows: - typer.echo( - " ".join(cell.ljust(widths[index]) for index, cell in enumerate(row)) - ) - - -def _item_summary(item: dict) -> str: - if item["counterparty"]: - return f"{item['owner']}: {item['summary']} -> {item['counterparty']}" - return f"{item['owner']}: {item['summary']}" - - -def _render_full(profile: dict) -> None: - facets_label = ",".join(profile["facets"]) if profile["facets"] else "-" - typer.echo( - f"{profile['name']} · {profile['type']} · " - f"facets={facets_label} · self={profile['is_self']}" - ) - typer.echo("") - cadence = profile["cadence"] - typer.echo("Cadence:") - typer.echo(f" last_seen: {cadence['last_seen']}") - typer.echo( - f" recent_interactions_count_30d: {cadence['recent_interactions_count_30d']}" - ) - typer.echo(f" avg_interval_days: {cadence['avg_interval_days']}") - typer.echo(f" gone_quiet_since: {cadence['gone_quiet_since']}") - typer.echo("") - - typer.echo("Open loops") - if profile["open_with_them"]: - _render_table( - list(profile["open_with_them"]), - [ - ("id", lambda item: item["id"]), - ("state", lambda item: item["state"]), - ("age_days", lambda item: str(item["age_days"])), - ("summary", _item_summary), - ("when", lambda item: item["when"] or ""), - ], - ) - else: - typer.echo("No open loops.") - typer.echo("") - - typer.echo("Closed 30d") - if profile["closed_with_them_30d"]: - _render_table( - list(profile["closed_with_them_30d"]), - [ - ("id", lambda item: item["id"]), - ("closed_at", lambda item: str(item["closed_at"] or "")), - ("summary", _item_summary), - ], - ) - else: - typer.echo("No closed items.") - typer.echo("") - - typer.echo("Decisions") - if profile["decisions_involving_them"]: - _render_table( - list(profile["decisions_involving_them"]), - [ - ("id", lambda item: item["id"]), - ("day", lambda item: item["day"]), - ("owner", lambda item: item["owner"]), - ("action", lambda item: item["action"]), - ("context", lambda item: item["context"]), - ], - ) - else: - typer.echo("No decisions.") - - -def _render_brief(profile_brief: dict) -> None: - typer.echo(f"entity_id: {profile_brief['entity_id']}") - typer.echo(f"name: {profile_brief['name']}") - typer.echo(f"type: {profile_brief['type']}") - typer.echo(f"description: {profile_brief['description']}") - typer.echo(f"last_seen: {profile_brief['last_seen']}") - typer.echo(f"open_loop_count: {profile_brief['open_loop_count']}") - typer.echo(f"decisions_count_30d: {profile_brief['decisions_count_30d']}") - - -def _render_cadence(cadence: dict) -> None: - typer.echo( - f"recent_interactions_count_30d: {cadence['recent_interactions_count_30d']}" - ) - typer.echo(f"last_seen: {cadence['last_seen']}") - typer.echo(f"avg_interval_days: {cadence['avg_interval_days']}") - typer.echo(f"gone_quiet_since: {cadence['gone_quiet_since']}") - - -@app.command("full") -def full_cmd( - name: str, - facets: str | None = typer.Option(None, "--facets"), - include_mentions: bool = typer.Option(False, "--include-mentions"), - json_out: bool = typer.Option(False, "--json"), -) -> None: - try: - profile = get_client().request( - "GET", - f"/api/profile/{quote(name, safe='')}", - params=_params( - facets=facets, - include_mentions="true" if include_mentions else None, - ), - ) - except ConveyClientError as err: - _handle_profile_error(err, name=name) - if json_out: - typer.echo(json.dumps(profile)) - return - _render_full(profile) - - -@app.command("brief") -def brief_cmd(name: str, json_out: bool = typer.Option(False, "--json")) -> None: - try: - profile_brief = get_client().request( - "GET", f"/api/profile/{quote(name, safe='')}/brief" - ) - except ConveyClientError as err: - _handle_profile_error(err, name=name) - if json_out: - typer.echo(json.dumps(profile_brief)) - return - _render_brief(profile_brief) - - -@app.command("cadence") -def cadence_cmd( - name: str, - include_mentions: bool = typer.Option(False, "--include-mentions"), - json_out: bool = typer.Option(False, "--json"), -) -> None: - try: - cadence = get_client().request( - "GET", - f"/api/profile/{quote(name, safe='')}/cadence", - params=_params(include_mentions="true" if include_mentions else None), - ) - except ConveyClientError as err: - _handle_profile_error(err, name=name) - if json_out: - typer.echo(json.dumps(cadence)) - return - _render_cadence(cadence) - - -@app.command("list-active") -def list_active( - window_days: int = typer.Option(30, "--window-days"), - json_out: bool = typer.Option(False, "--json"), -) -> None: - try: - ids = paginate_collection( - get_client(), - "/api/profiles/active", - params={"window_days": window_days}, - ) - except ConveyClientError as err: - _exit_with(err.detail or err.error) - if json_out: - typer.echo(json.dumps(ids)) - return - for entity_id in ids: - typer.echo(entity_id) diff --git a/tests/link/test_link_cli_namespace.py b/tests/link/test_link_cli_namespace.py index 91b87a019..debac5597 100644 --- a/tests/link/test_link_cli_namespace.py +++ b/tests/link/test_link_cli_namespace.py @@ -57,10 +57,6 @@ def test_journal_link_routes_to_management_app_help( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: - from typer.main import get_command - - from solstone.apps.network import call as link_call - monkeypatch.setattr(sys, "argv", ["journal link", "--help"]) with pytest.raises(SystemExit) as exc: @@ -68,14 +64,11 @@ def test_journal_link_routes_to_management_app_help( assert exc.value.code == 0 out = capsys.readouterr().out - management_commands = set(get_command(link_call.app).commands) - assert "Usage: journal link" in out - assert "unpair" in out - assert "authorized-clients" in out - assert "join" not in management_commands - assert "serve" not in management_commands - assert "join a solstone with a short code or pair link" not in out - assert "serve a loopback proxy over a link tunnel" not in out + assert "usage: journal link" in out + assert "{join,serve}" in out + assert "join a solstone with a short code or pair link" in out + assert "serve a loopback proxy over a link tunnel" in out + assert "authorized-clients" not in out def test_sol_link_list_is_unknown_client_subcommand( diff --git a/tests/test_access_imports_lazy.py b/tests/test_access_imports_lazy.py deleted file mode 100644 index bcaf563e1..000000000 --- a/tests/test_access_imports_lazy.py +++ /dev/null @@ -1,89 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""Import-firewall regression for the thin `sol` access surface.""" - -from __future__ import annotations - -import json -import subprocess -import sys - -import pytest - -from scripts.check_access_imports_clean import BLOCKED_FAMILIES - -FORBIDDEN = set(BLOCKED_FAMILIES) - -MODULES = ( - "solstone.think.chat_cli", - "solstone.think.call", - "solstone.think.link", - "solstone.apps", - "solstone.think.notify_cli", - "solstone.think.skills_cli", - "solstone.think.doctor", - "solstone.think.check", - "solstone.convey.reasons", - "solstone.think.sol_cli", - "solstone.think.import_client", - "solstone.convey.provider_readiness", - "solstone.think.providers.state", -) - -PROBE = """ -import importlib -import json -import sys - -module = sys.argv[1] -importlib.import_module(module) -print("MODULES_JSON:" + json.dumps(sorted(sys.modules))) -""" - - -def _probe_modules(module: str) -> set[str]: - result = subprocess.run( - [sys.executable, "-c", PROBE, module], - capture_output=True, - text=True, - timeout=90, - ) - assert result.returncode == 0, ( - f"probe for {module} exited {result.returncode}\n" - f"--- stdout ---\n{result.stdout}\n--- stderr ---\n{result.stderr}" - ) - sentinel = [ - line for line in result.stdout.splitlines() if line.startswith("MODULES_JSON:") - ] - assert len(sentinel) == 1, ( - f"expected exactly one MODULES_JSON line, got {len(sentinel)}\n" - f"--- stdout ---\n{result.stdout}\n--- stderr ---\n{result.stderr}" - ) - return set(json.loads(sentinel[0][len("MODULES_JSON:") :])) - - -def _leaked_families(modules: set[str]) -> list[str]: - leaked: list[str] = [] - for forbidden in sorted(FORBIDDEN): - if any( - loaded == forbidden or loaded.startswith(f"{forbidden}.") - for loaded in modules - ): - leaked.append(forbidden) - return leaked - - -@pytest.mark.timeout(120) -@pytest.mark.parametrize("module", MODULES) -def test_access_surface_import_stays_light(module: str) -> None: - modules = _probe_modules(module) - assert module in modules - leaked = _leaked_families(modules) - assert not leaked, f"{module} pulled in forbidden modules: {leaked}" - - -def test_chat_cli_import_does_not_load_callosum_or_chat_stream() -> None: - modules = _probe_modules("solstone.think.chat_cli") - assert "solstone.think.callosum" not in modules - assert "solstone.convey.chat_stream" not in modules diff --git a/tests/test_activities_call_parity.py b/tests/test_activities_call_parity.py deleted file mode 100644 index 9305bfbc6..000000000 --- a/tests/test_activities_call_parity.py +++ /dev/null @@ -1,854 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -import json -from datetime import datetime -from pathlib import Path - -import pytest -import requests -from typer.testing import CliRunner - -import solstone.apps.activities.routes as activities_routes -from solstone.apps.activities.call import app -from solstone.convey.reasons import ACTIVITIES_BUSY -from solstone.think.convey_client import TIMEOUT_MESSAGE, ConveyClient -from solstone.think.journal_io import LockTimeout -from tests._baseline_harness import make_test_client - -DAY = "20260418" -PREV_DAY = "20260417" -FACET = "work" - - -@pytest.fixture -def journal(tmp_path, monkeypatch): - monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - monkeypatch.setenv("SOL_DAY", DAY) - monkeypatch.setenv("SOL_FACET", FACET) - - import solstone.think.utils as think_utils - - think_utils._journal_path_cache = None - _seed_journal(tmp_path) - return tmp_path - - -@pytest.fixture -def runner(journal, monkeypatch): - client = ConveyClient(session=make_test_client(journal), base_url="") - monkeypatch.setattr("solstone.apps.activities.call.get_client", lambda: client) - monkeypatch.setattr( - activities_routes, "log_call_action", lambda *args, **kwargs: None - ) - return CliRunner() - - -def _seed_journal( - journal: Path, - *, - facets: tuple[str, ...] = (FACET,), - activity_config: list[dict] | None = None, -) -> None: - config_dir = journal / "config" - config_dir.mkdir(parents=True, exist_ok=True) - (config_dir / "journal.json").write_text( - json.dumps( - { - "setup": {"completed_at": 1700000000000}, - } - ) - + "\n", - encoding="utf-8", - ) - - config_entries = activity_config or [{"id": "coding"}, {"id": "meeting"}] - for facet in facets: - facet_dir = journal / "facets" / facet - activities_dir = facet_dir / "activities" - activities_dir.mkdir(parents=True, exist_ok=True) - (facet_dir / "facet.json").write_text( - json.dumps({"title": f"Test {facet}", "description": "Test facet"}) + "\n", - encoding="utf-8", - ) - (activities_dir / "activities.jsonl").write_text( - "".join( - json.dumps(entry, ensure_ascii=False) + "\n" for entry in config_entries - ), - encoding="utf-8", - ) - - -def _write_records(journal: Path, facet: str, day: str, records: list[dict]) -> None: - path = journal / "facets" / facet / "activities" / f"{day}.jsonl" - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text( - "".join(json.dumps(record, ensure_ascii=False) + "\n" for record in records), - encoding="utf-8", - ) - - -def _read_records(journal: Path, facet: str = FACET, day: str = DAY) -> list[dict]: - path = journal / "facets" / facet / "activities" / f"{day}.jsonl" - return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()] - - -def _base_record(**overrides) -> dict: - record = { - "id": "coding_090000_300", - "activity": "coding", - "title": "Focused coding", - "description": "Implementing the CLI", - "segments": ["090000_300"], - "active_entities": ["Ada Lovelace"], - "created_at": 1, - "source": "user", - } - record.update(overrides) - return record - - -def _valid_participation_entry(**overrides) -> dict: - entry = { - "name": "JB", - "role": "attendee", - "source": "voice", - "confidence": 0.98, - "context": "Spoke during the meeting", - } - entry.update(overrides) - return entry - - -def _busy(*_args, **_kwargs): - raise LockTimeout(Path("busy"), 0.01) - - -def test_list_empty_text_and_json(runner): - text = runner.invoke(app, ["list", "--day", DAY, "--facet", FACET]) - as_json = runner.invoke(app, ["list", "--day", DAY, "--facet", FACET, "--json"]) - - assert text.exit_code == 0 - assert text.stdout == "No activities found.\n" - assert text.stderr == "" - assert as_json.exit_code == 0 - assert as_json.stdout == "[]\n" - - -def test_list_text_render_and_json_enrichment(runner, journal): - _write_records(journal, FACET, DAY, [_base_record()]) - - text = runner.invoke(app, ["list", "--day", DAY, "--facet", FACET]) - as_json = runner.invoke(app, ["list", "--day", DAY, "--facet", FACET, "--json"]) - - assert text.exit_code == 0 - assert text.stdout == ( - "### Focused coding\n" - "- Activity: coding\n" - "- Facet: work\n" - "- Day: 20260418\n" - "- Time: 09:00-09:05\n" - "- Description: Implementing the CLI\n" - ) - payload = json.loads(as_json.stdout) - assert payload[0]["facet"] == FACET - assert payload[0]["day"] == DAY - - -def test_list_defaults_to_today_without_day_or_env(runner, journal, monkeypatch): - monkeypatch.delenv("SOL_DAY", raising=False) - today = datetime.now().strftime("%Y%m%d") - _write_records( - journal, - FACET, - today, - [_base_record(title="Today coding", description="Using today's default")], - ) - - result = runner.invoke(app, ["list", "--facet", FACET]) - - assert result.exit_code == 0 - assert "Today coding" in result.stdout - - -@pytest.mark.parametrize( - ("args", "stderr"), - [ - ( - ["list", "--day", DAY, "--from", PREV_DAY, "--facet", FACET], - "Error: --day is incompatible with --from/--to.\n", - ), - ( - ["list", "--from", DAY, "--to", PREV_DAY, "--facet", FACET], - f"Error: --to ({PREV_DAY}) must not be before --from ({DAY})\n", - ), - ( - ["list", "--from", "bad", "--facet", FACET], - "Error: invalid day 'bad'\n", - ), - ( - ["list", "--day", DAY, "--facet", FACET, "--source", "calendar"], - "Error: --source must be 'anticipated', 'cogitate', or 'user'.\n", - ), - ], -) -def test_list_errors(runner, args, stderr): - result = runner.invoke(app, args) - - assert result.exit_code == 1 - assert result.stdout == "" - assert result.stderr == stderr - - -def test_list_bad_day_argument_preserves_empty_lookup(runner): - result = runner.invoke(app, ["list", "--day", "bad", "--facet", FACET]) - - assert result.exit_code == 0 - assert result.stdout == "No activities found.\n" - - -def test_list_filters_hidden_all_facets_and_sort_order(runner, journal, monkeypatch): - _seed_journal(journal, facets=("personal",)) - monkeypatch.delenv("SOL_FACET", raising=False) - _write_records( - journal, - FACET, - DAY, - [ - _base_record(id="coding_090000_300", created_at=2), - _base_record( - id="meeting_100000_300", - activity="meeting", - title="Muted meeting", - description="Hidden", - active_entities=["Grace Hopper"], - segments=["100000_300"], - source="cogitate", - hidden=True, - created_at=1, - ), - ], - ) - _write_records( - journal, - "personal", - DAY, - [_base_record(id="coding_080000_300", title="Personal coding", created_at=1)], - ) - _write_records( - journal, - FACET, - PREV_DAY, - [_base_record(id="coding_070000_300", title="Previous coding", created_at=9)], - ) - - all_facets = runner.invoke(app, ["list", "--day", DAY, "--json"]) - single_facet = runner.invoke( - app, ["list", "--day", DAY, "--facet", FACET, "--json"] - ) - hidden = runner.invoke( - app, ["list", "--day", DAY, "--facet", FACET, "--all", "--json"] - ) - filtered = runner.invoke( - app, - [ - "list", - "--day", - DAY, - "--facet", - FACET, - "--activity", - "coding", - "--entity", - "lovel", - "--source", - "user", - "--json", - ], - ) - sorted_range = runner.invoke( - app, ["list", "--from", PREV_DAY, "--to", DAY, "--all", "--json"] - ) - - assert [item["facet"] for item in json.loads(all_facets.stdout)] == [ - "personal", - "work", - ] - assert [item["facet"] for item in json.loads(single_facet.stdout)] == ["work"] - assert any(item.get("hidden") for item in json.loads(hidden.stdout)) - assert [item["id"] for item in json.loads(filtered.stdout)] == ["coding_090000_300"] - assert [item["id"] for item in json.loads(sorted_range.stdout)] == [ - "coding_070000_300", - "coding_080000_300", - "meeting_100000_300", - "coding_090000_300", - ] - - -def test_get_found_text_json_and_missing(runner, journal): - _write_records(journal, FACET, DAY, [_base_record()]) - - text = runner.invoke( - app, ["get", "coding_090000_300", "--day", DAY, "--facet", FACET] - ) - as_json = runner.invoke( - app, ["get", "coding_090000_300", "--day", DAY, "--facet", FACET, "--json"] - ) - missing = runner.invoke(app, ["get", "missing", "--day", DAY, "--facet", FACET]) - - assert text.exit_code == 0 - assert text.stdout.startswith("### Focused coding\n- Activity: coding\n") - assert json.loads(as_json.stdout)["id"] == "coding_090000_300" - assert missing.exit_code == 1 - assert missing.stdout == "" - assert missing.stderr == "activity not found: missing\n" - - -def test_create_success_json_and_text(runner): - as_json = runner.invoke( - app, - [ - "create", - "--day", - DAY, - "--facet", - FACET, - "--since-segment", - "090000_300", - "--source", - "cogitate", - "--json", - ], - input=json.dumps({"title": "Team sync", "activity": "meeting"}), - ) - text = runner.invoke( - app, - [ - "create", - "--day", - DAY, - "--facet", - FACET, - "--since-segment", - "100000_300", - ], - input=json.dumps({"title": "Focused coding", "activity": "coding"}), - ) - - payload = json.loads(as_json.stdout) - assert as_json.exit_code == 0 - assert payload["id"] == "meeting_090000_300" - assert payload["source"] == "cogitate" - assert payload["segments"] == ["090000_300"] - assert payload["edits"][-1]["actor"] == "cogitate:activities" - assert payload["edits"][-1]["fields"] == [ - "activity", - "title", - "description", - "details", - "source", - ] - assert text.exit_code == 0 - assert text.stdout.startswith("### Focused coding\n- Activity: coding\n") - - -def test_create_argv_success(runner): - result = runner.invoke( - app, - [ - "create", - "--day", - DAY, - "--facet", - FACET, - "--source", - "cogitate", - "--title", - "Team sync", - "--activity", - "meeting", - "--description", - "Synced", - "--details", - "notes", - "--since-segment", - "090000_300", - "--json", - ], - ) - - payload = json.loads(result.stdout) - assert result.exit_code == 0 - assert payload["id"] == "meeting_090000_300" - assert payload["source"] == "cogitate" - assert payload["segments"] == ["090000_300"] - assert payload["description"] == "Synced" - assert payload["details"] == "notes" - assert payload["edits"][-1]["actor"] == "cogitate:activities" - assert payload["edits"][-1]["fields"] == [ - "activity", - "title", - "description", - "details", - "source", - ] - - -def test_create_argv_missing_required_flags(runner): - missing_title = runner.invoke( - app, - [ - "create", - "--day", - DAY, - "--facet", - FACET, - "--activity", - "meeting", - ], - ) - missing_activity = runner.invoke( - app, - [ - "create", - "--day", - DAY, - "--facet", - FACET, - "--title", - "X", - ], - ) - - assert missing_title.exit_code == 1 - assert missing_title.stdout == "" - assert missing_title.stderr == "Error: --title is required.\n" - assert missing_activity.exit_code == 1 - assert missing_activity.stdout == "" - assert missing_activity.stderr == "Error: --activity is required.\n" - - -def test_create_argv_surfaces_server_title_rejection(runner): - result = runner.invoke( - app, - [ - "create", - "--day", - DAY, - "--facet", - FACET, - "--source", - "cogitate", - "--title", - "", - "--activity", - "meeting", - ], - ) - - assert result.exit_code == 1 - assert result.stdout == "" - assert result.stderr == "Error: title must not be empty\n" - - -def test_create_argv_flags_ignore_stdin(runner): - result = runner.invoke( - app, - [ - "create", - "--day", - DAY, - "--facet", - FACET, - "--source", - "cogitate", - "--title", - "Flag title", - "--activity", - "meeting", - "--description", - "Flag description", - "--json", - ], - input=json.dumps({"title": "IGNORED", "activity": "coding"}), - ) - - payload = json.loads(result.stdout) - assert result.exit_code == 0 - assert payload["title"] == "Flag title" - assert payload["activity"] == "meeting" - assert payload["description"] == "Flag description" - - -@pytest.mark.parametrize( - ("args", "stdin", "stderr"), - [ - ( - ["create", "--day", DAY, "--facet", FACET, "--source", "calendar"], - "{}", - "Error: --source must be 'cogitate' or 'user'.\n", - ), - ( - ["create", "--day", DAY, "--facet", FACET], - "", - "Error: expected JSON object on stdin.\n", - ), - ( - ["create", "--day", DAY, "--facet", FACET], - "{not valid", - "Error: invalid JSON on stdin: Expecting property name enclosed in double quotes: line 1 column 2 (char 1)\n", - ), - ( - ["create", "--day", DAY, "--facet", FACET], - "[]", - "Error: expected JSON object on stdin.\n", - ), - ( - ["create", "--day", DAY, "--facet", FACET], - json.dumps({"activity": "coding"}), - "Error: title is required.\n", - ), - ( - ["create", "--day", DAY, "--facet", FACET], - json.dumps({"title": "No type"}), - "Error: activity is required.\n", - ), - ( - ["create", "--day", DAY, "--facet", FACET, "--since-segment", "bad"], - json.dumps({"title": "Bad segment", "activity": "coding"}), - "Error: invalid --since-segment 'bad' (expected HHMMSS_LEN)\n", - ), - ], -) -def test_create_cli_side_errors(runner, args, stdin, stderr): - result = runner.invoke(app, args, input=stdin) - - assert result.exit_code == 1 - assert result.stdout == "" - assert result.stderr == stderr - - -def test_create_unknown_duplicate_and_busy(runner, monkeypatch): - unknown = runner.invoke( - app, - ["create", "--day", DAY, "--facet", FACET], - input=json.dumps({"title": "Unknown", "activity": "unknown"}), - ) - first = runner.invoke( - app, - ["create", "--day", DAY, "--facet", FACET, "--since-segment", "090000_300"], - input=json.dumps({"title": "Team sync", "activity": "meeting"}), - ) - duplicate = runner.invoke( - app, - ["create", "--day", DAY, "--facet", FACET, "--since-segment", "090000_300"], - input=json.dumps({"title": "Team sync", "activity": "meeting"}), - ) - monkeypatch.setattr(activities_routes, "append_activity_record", _busy) - busy = runner.invoke( - app, - ["create", "--day", DAY, "--facet", FACET, "--since-segment", "100000_300"], - input=json.dumps({"title": "Team sync", "activity": "meeting"}), - ) - - assert unknown.exit_code == 1 - assert unknown.stderr == "Error: unknown activity for facet 'work': unknown\n" - assert first.exit_code == 0 - assert duplicate.exit_code == 1 - assert duplicate.stderr == "Error: activity already exists: meeting_090000_300\n" - assert busy.exit_code == 1 - assert busy.stderr == f"{ACTIVITIES_BUSY.message}\n" - - -@pytest.mark.parametrize( - ("entry", "message"), - [ - ({"name": "JB"}, "Error: participation[0] has invalid role 'None'"), - ("JB", "Error: participation[0] must be an object"), - ( - _valid_participation_entry(name=""), - "Error: participation[0] requires a non-empty string 'name'", - ), - ( - _valid_participation_entry(role="observer"), - "Error: participation[0] has invalid role 'observer' (must be one of ['attendee', 'mentioned'])", - ), - ( - _valid_participation_entry(source="calendar"), - "Error: participation[0] has invalid source 'calendar' (must be one of ['other', 'screen', 'speaker_label', 'transcript', 'voice'])", - ), - ( - _valid_participation_entry(confidence=True), - "Error: participation[0] 'confidence' must be a number", - ), - ( - _valid_participation_entry(context=7), - "Error: participation[0] 'context' must be a string", - ), - ], -) -def test_create_participation_structural_errors(runner, entry, message): - payload = {"title": "Team sync", "activity": "meeting", "participation": [entry]} - - result = runner.invoke( - app, ["create", "--day", DAY, "--facet", FACET], input=json.dumps(payload) - ) - - assert result.exit_code == 1 - assert message in result.stderr - - -def test_create_rejects_non_array_participation(runner): - result = runner.invoke( - app, - ["create", "--day", DAY, "--facet", FACET], - input=json.dumps( - {"title": "Team sync", "activity": "meeting", "participation": {}} - ), - ) - - assert result.exit_code == 1 - assert result.stderr == "Error: participation must be an array\n" - - -def test_create_participation_entity_resolution_and_empty_persistence(runner, journal): - entities_path = journal / "facets" / FACET / "entities" / f"{DAY}.jsonl" - entities_path.parent.mkdir(parents=True, exist_ok=True) - entities_path.write_text( - json.dumps( - { - "id": "john_borthwick", - "type": "Person", - "name": "John Borthwick", - "aka": ["JB"], - } - ) - + "\n", - encoding="utf-8", - ) - - resolved = runner.invoke( - app, - [ - "create", - "--day", - DAY, - "--facet", - FACET, - "--since-segment", - "090000_300", - "--json", - ], - input=json.dumps( - { - "title": "Team sync", - "activity": "meeting", - "participation": [ - _valid_participation_entry(entity_id="fake", extra="keep") - ], - } - ), - ) - empty = runner.invoke( - app, - [ - "create", - "--day", - DAY, - "--facet", - FACET, - "--since-segment", - "100000_300", - "--json", - ], - input=json.dumps( - {"title": "Empty", "activity": "meeting", "participation": []} - ), - ) - - resolved_payload = json.loads(resolved.stdout) - empty_payload = json.loads(empty.stdout) - assert resolved_payload["participation"][0]["entity_id"] == "john_borthwick" - assert resolved_payload["participation"][0]["extra"] == "keep" - assert "participation" in resolved_payload["edits"][0]["fields"] - assert empty_payload["participation"] == [] - assert "participation" in empty_payload["edits"][0]["fields"] - - -def test_update_success_errors_and_busy(runner, journal, monkeypatch): - _write_records(journal, FACET, DAY, [_base_record()]) - - success = runner.invoke( - app, - ["update", "coding_090000_300", "--day", DAY, "--facet", FACET, "--json"], - input=json.dumps({"details": "New details", "title": "Focused coding"}), - ) - disallowed = runner.invoke( - app, - ["update", "coding_090000_300", "--day", DAY, "--facet", FACET], - input=json.dumps({"activity": "meeting"}), - ) - empty = runner.invoke( - app, - ["update", "coding_090000_300", "--day", DAY, "--facet", FACET], - input=json.dumps({}), - ) - missing = runner.invoke( - app, - ["update", "missing", "--day", DAY, "--facet", FACET], - input=json.dumps({"title": "Nope"}), - ) - monkeypatch.setattr(activities_routes, "update_activity_record", _busy) - busy = runner.invoke( - app, - ["update", "coding_090000_300", "--day", DAY, "--facet", FACET], - input=json.dumps({"title": "Busy"}), - ) - - payload = json.loads(success.stdout) - assert payload["title"] == "Focused coding" - assert payload["details"] == "New details" - assert payload["edits"][-1]["note"] == "updated fields: details, title" - assert disallowed.stderr == "Error: disallowed update fields: activity\n" - assert ( - empty.stderr - == "Error: update payload must include at least one mutable field.\n" - ) - assert missing.stderr == "activity not found: missing\n" - assert busy.stderr == f"{ACTIVITIES_BUSY.message}\n" - - -def test_update_argv_success_and_empty_details(runner, journal): - _write_records(journal, FACET, DAY, [_base_record(details="Old details")]) - - success = runner.invoke( - app, - [ - "update", - "coding_090000_300", - "--day", - DAY, - "--facet", - FACET, - "--title", - "Focused coding", - "--details", - "New details", - "--json", - ], - ) - empty_details = runner.invoke( - app, - [ - "update", - "coding_090000_300", - "--day", - DAY, - "--facet", - FACET, - "--details", - "", - "--json", - ], - ) - - success_payload = json.loads(success.stdout) - empty_details_payload = json.loads(empty_details.stdout) - assert success.exit_code == 0 - assert success_payload["title"] == "Focused coding" - assert success_payload["details"] == "New details" - assert empty_details.exit_code == 0 - assert empty_details_payload["details"] == "" - - -@pytest.mark.parametrize( - "args", - [ - ["update", "coding_090000_300", "--day", DAY, "--facet", FACET, "--note", "x"], - ["update", "coding_090000_300", "--day", DAY, "--facet", FACET, "--json"], - ], -) -def test_update_no_mutable_field_no_stdin(runner, journal, args): - _write_records(journal, FACET, DAY, [_base_record()]) - - result = runner.invoke(app, args) - - assert result.exit_code == 1 - assert result.stdout == "" - assert ( - result.stderr - == "Error: update payload must include at least one mutable field.\n" - ) - - -def test_mute_unmute_success_not_found_and_busy(runner, journal, monkeypatch): - _write_records(journal, FACET, DAY, [_base_record()]) - - muted = runner.invoke( - app, - [ - "mute", - "coding_090000_300", - "--day", - DAY, - "--facet", - FACET, - "--reason", - "noise", - "--json", - ], - ) - unmuted = runner.invoke( - app, ["unmute", "coding_090000_300", "--day", DAY, "--facet", FACET] - ) - missing = runner.invoke(app, ["mute", "missing", "--day", DAY, "--facet", FACET]) - monkeypatch.setattr(activities_routes, "mute_activity_record", _busy) - busy = runner.invoke( - app, ["mute", "coding_090000_300", "--day", DAY, "--facet", FACET] - ) - - assert json.loads(muted.stdout)["hidden"] is True - assert unmuted.exit_code == 0 - assert unmuted.stdout.startswith("### Focused coding\n") - assert missing.exit_code == 1 - assert missing.stderr == "activity not found: missing\n" - assert busy.exit_code == 1 - assert busy.stderr == f"{ACTIVITIES_BUSY.message}\n" - - -def test_convey_down_prints_require_solstone_message(journal, monkeypatch): - class DownSession: - def get(self, _url): - raise requests.exceptions.ConnectionError() - - def post(self, _url, json=None): - raise requests.exceptions.ConnectionError() - - client = ConveyClient(session=DownSession(), base_url="http://localhost:5015") - monkeypatch.setattr("solstone.apps.activities.call.get_client", lambda: client) - monkeypatch.delenv("SOL_SKIP_SUPERVISOR_CHECK", raising=False) - monkeypatch.delenv("SOL_SUPERVISOR_SPAWNED", raising=False) - - result = CliRunner().invoke(app, ["list", "--day", DAY, "--facet", FACET]) - - assert result.exit_code == 1 - assert ( - result.stderr - == "sol: solstone isn't running. Start it with 'journal up' and retry.\n" - ) - assert result.stdout == "" - - -def test_convey_timeout_prints_timeout_message(journal, monkeypatch): - class TimeoutSession: - def get(self, _url): - raise requests.exceptions.ReadTimeout() - - client = ConveyClient(session=TimeoutSession(), base_url="http://localhost:5015") - monkeypatch.setattr("solstone.apps.activities.call.get_client", lambda: client) - - result = CliRunner().invoke(app, ["list", "--day", DAY, "--facet", FACET]) - - assert result.exit_code == 1 - assert result.stderr == f"{TIMEOUT_MESSAGE}\n" - assert result.stdout == "" diff --git a/tests/test_activities_cli_create.py b/tests/test_activities_cli_create.py deleted file mode 100644 index fea01727c..000000000 --- a/tests/test_activities_cli_create.py +++ /dev/null @@ -1,366 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -import json - -from typer.testing import CliRunner - -from solstone.apps.activities.call import app -from solstone.think.convey_client import ConveyClient -from tests._baseline_harness import make_test_client - -runner = CliRunner() - - -def _write_detected_entities(tmp_path, facet: str, day: str, rows: list[dict]) -> None: - entities_path = tmp_path / "facets" / facet / "entities" / f"{day}.jsonl" - entities_path.parent.mkdir(parents=True, exist_ok=True) - entities_path.write_text( - "".join(json.dumps(row, ensure_ascii=False) + "\n" for row in rows), - encoding="utf-8", - ) - - -def _configure_cli_env(tmp_path, monkeypatch) -> None: - monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - - config_dir = tmp_path / "config" - config_dir.mkdir(parents=True, exist_ok=True) - (config_dir / "journal.json").write_text( - json.dumps( - { - "setup": {"completed_at": 1700000000000}, - } - ) - + "\n", - encoding="utf-8", - ) - facet_dir = tmp_path / "facets" / "work" - activities_dir = facet_dir / "activities" - activities_dir.mkdir(parents=True, exist_ok=True) - (facet_dir / "facet.json").write_text( - json.dumps({"title": "Test work", "description": "Test facet"}) + "\n", - encoding="utf-8", - ) - (activities_dir / "activities.jsonl").write_text( - json.dumps({"id": "meeting"}, ensure_ascii=False) + "\n", - encoding="utf-8", - ) - - import solstone.think.utils as think_utils - - think_utils._journal_path_cache = None - client = ConveyClient(session=make_test_client(tmp_path), base_url="") - monkeypatch.setattr("solstone.apps.activities.call.get_client", lambda: client) - - -def _base_payload() -> dict: - return { - "title": "Team sync", - "activity": "meeting", - } - - -def _valid_participation_entry(**overrides) -> dict: - entry = { - "name": "JB", - "role": "attendee", - "source": "voice", - "confidence": 0.98, - "context": "Spoke during the meeting", - } - entry.update(overrides) - return entry - - -def _invoke_create(payload: dict, *, day: str = "20260418", facet: str = "work"): - return runner.invoke( - app, - [ - "create", - "--facet", - facet, - "--day", - day, - "--since-segment", - "090000_300", - "--source", - "cogitate", - ], - input=json.dumps(payload), - ) - - -def _read_written_record( - tmp_path, *, day: str = "20260418", facet: str = "work" -) -> dict: - records_path = tmp_path / "facets" / facet / "activities" / f"{day}.jsonl" - lines = records_path.read_text(encoding="utf-8").splitlines() - assert len(lines) == 1 - return json.loads(lines[0]) - - -def test_create_resolves_participation_entity_ids(tmp_path, monkeypatch): - _configure_cli_env(tmp_path, monkeypatch) - - _write_detected_entities( - tmp_path, - "work", - "20260418", - [ - { - "id": "john_borthwick", - "type": "Person", - "name": "John Borthwick", - "aka": ["JB"], - } - ], - ) - - payload = _base_payload() - payload["participation"] = [ - _valid_participation_entry(entity_id="fake_id", extra="keep-me"), - _valid_participation_entry( - name="Alex", - role="mentioned", - source="transcript", - confidence=0.55, - context="Mentioned as a follow-up owner", - entity_id="fake_id", - ), - ] - - result = _invoke_create(payload) - - assert result.exit_code == 0 - record = _read_written_record(tmp_path) - assert record["participation"][0]["entity_id"] == "john_borthwick" - assert record["participation"][1]["entity_id"] is None - assert record["participation"][0]["extra"] == "keep-me" - assert "participation" in record["edits"][0]["fields"] - - import solstone.think.utils as think_utils - - think_utils._journal_path_cache = None - - -def test_create_ambiguous_participation_keeps_entity_id_none(tmp_path, monkeypatch): - from solstone.think.entities import load_ambiguities - - _configure_cli_env(tmp_path, monkeypatch) - - _write_detected_entities( - tmp_path, - "work", - "20260418", - [ - {"id": "sarah_connor", "type": "Person", "name": "Sarah Connor"}, - {"id": "sarah_lee", "type": "Person", "name": "Sarah Lee"}, - ], - ) - - payload = _base_payload() - payload["participation"] = [_valid_participation_entry(name="Sarah")] - - result = _invoke_create(payload) - - assert result.exit_code == 0 - record = _read_written_record(tmp_path) - assert record["participation"][0]["name"] == "Sarah" - assert record["participation"][0]["entity_id"] is None - rows = load_ambiguities() - assert len(rows) == 1 - assert rows[0]["normalized_query"] == "sarah" - - import solstone.think.utils as think_utils - - think_utils._journal_path_cache = None - - -def test_create_omits_participation_when_not_provided(tmp_path, monkeypatch): - _configure_cli_env(tmp_path, monkeypatch) - - result = _invoke_create(_base_payload()) - - assert result.exit_code == 0 - record = _read_written_record(tmp_path) - assert "participation" not in record - assert "participation" not in record["edits"][0]["fields"] - - import solstone.think.utils as think_utils - - think_utils._journal_path_cache = None - - -def test_create_persists_empty_participation_array(tmp_path, monkeypatch): - _configure_cli_env(tmp_path, monkeypatch) - - payload = _base_payload() - payload["participation"] = [] - - result = _invoke_create(payload) - - assert result.exit_code == 0 - record = _read_written_record(tmp_path) - assert record["participation"] == [] - assert "participation" in record["edits"][0]["fields"] - - import solstone.think.utils as think_utils - - think_utils._journal_path_cache = None - - -def test_create_rejects_non_list_participation(tmp_path, monkeypatch): - _configure_cli_env(tmp_path, monkeypatch) - - payload = _base_payload() - payload["participation"] = {"name": "JB"} - activities_path = tmp_path / "facets" / "work" / "activities" / "20260418.jsonl" - - result = _invoke_create(payload) - - assert result.exit_code == 1 - assert "Error: participation must be an array" in result.output - assert not activities_path.exists() - - import solstone.think.utils as think_utils - - think_utils._journal_path_cache = None - - -def test_create_rejects_non_object_participation_entry(tmp_path, monkeypatch): - _configure_cli_env(tmp_path, monkeypatch) - - payload = _base_payload() - payload["participation"] = ["JB"] - activities_path = tmp_path / "facets" / "work" / "activities" / "20260418.jsonl" - - result = _invoke_create(payload) - - assert result.exit_code == 1 - assert "Error: participation[0] must be an object" in result.output - assert not activities_path.exists() - - import solstone.think.utils as think_utils - - think_utils._journal_path_cache = None - - -def test_create_participation_resolver_is_read_only(tmp_path, monkeypatch): - _configure_cli_env(tmp_path, monkeypatch) - - _write_detected_entities( - tmp_path, - "work", - "20260418", - [ - { - "id": "john_borthwick", - "type": "Person", - "name": "John Borthwick", - "aka": ["JB"], - } - ], - ) - _write_detected_entities( - tmp_path, - "work", - "20260417", - [{"id": "other_person", "type": "Person", "name": "Other Person"}], - ) - - entities_dir = tmp_path / "facets" / "work" / "entities" - snapshot_before = { - p.name: (p.stat().st_size, p.stat().st_mtime_ns) for p in entities_dir.iterdir() - } - - payload = _base_payload() - payload["participation"] = [_valid_participation_entry()] - - result = _invoke_create(payload) - - snapshot_after = { - p.name: (p.stat().st_size, p.stat().st_mtime_ns) for p in entities_dir.iterdir() - } - assert result.exit_code == 0 - assert snapshot_after == snapshot_before - - import solstone.think.utils as think_utils - - think_utils._journal_path_cache = None - - -def test_create_rejects_bad_participation_name(tmp_path, monkeypatch): - _configure_cli_env(tmp_path, monkeypatch) - activities_path = tmp_path / "facets" / "work" / "activities" / "20260418.jsonl" - - cases = [ - ( - _valid_participation_entry(name=""), - "Error: participation[0] requires a non-empty string 'name'", - ), - ( - _valid_participation_entry(name=7), - "Error: participation[0] requires a non-empty string 'name'", - ), - ( - { - key: value - for key, value in _valid_participation_entry().items() - if key != "name" - }, - "Error: participation[0] requires a non-empty string 'name'", - ), - ] - - for entry, message in cases: - payload = _base_payload() - payload["participation"] = [entry] - - result = _invoke_create(payload) - - assert result.exit_code == 1 - assert message in result.output - assert not activities_path.exists() - - import solstone.think.utils as think_utils - - think_utils._journal_path_cache = None - - -def test_create_rejects_invalid_participation_fields(tmp_path, monkeypatch): - _configure_cli_env(tmp_path, monkeypatch) - activities_path = tmp_path / "facets" / "work" / "activities" / "20260418.jsonl" - - cases = [ - ( - _valid_participation_entry(role="observer"), - "Error: participation[0] has invalid role 'observer' (must be one of ['attendee', 'mentioned'])", - ), - ( - _valid_participation_entry(source="calendar"), - "Error: participation[0] has invalid source 'calendar' (must be one of ['other', 'screen', 'speaker_label', 'transcript', 'voice'])", - ), - ( - _valid_participation_entry(confidence="high"), - "Error: participation[0] 'confidence' must be a number", - ), - ( - _valid_participation_entry(context=7), - "Error: participation[0] 'context' must be a string", - ), - ] - - for entry, message in cases: - payload = _base_payload() - payload["participation"] = [entry] - - result = _invoke_create(payload) - - assert result.exit_code == 1 - assert message in result.output - assert not activities_path.exists() - - import solstone.think.utils as think_utils - - think_utils._journal_path_cache = None diff --git a/tests/test_app_support.py b/tests/test_app_support.py index 77aaf1c9c..b36c0fae8 100644 --- a/tests/test_app_support.py +++ b/tests/test_app_support.py @@ -3,26 +3,16 @@ """Tests for support app routes.""" -import base64 import json import os import re -from datetime import date, datetime, timedelta -from pathlib import Path +from datetime import datetime, timedelta import pytest -import requests -from typer.testing import CliRunner -from solstone.apps.support.call import app as support_cli_app from solstone.apps.support.diagnostics import collect_recent_errors -from solstone.convey.chat_stream import read_chat_events from solstone.think.brain_health import HEADLINES -DRY_RUN_BANNER = ( - "DRY RUN — nothing was sent. Re-run with --submit to actually file this." -) -STUB_DIAGNOSTICS = {"version": "9.9.9", "revision": "abc1234", "sample": "value"} _LEAK_NEEDLES = ("private_key", "keypair", "access_token") @@ -51,21 +41,6 @@ def journal(tmp_path, monkeypatch): return tmp_path -@pytest.fixture -def cli(journal, monkeypatch): - from solstone.think.convey_client import ConveyClient - from tests._baseline_harness import make_test_client, mark_setup_complete - - mark_setup_complete(journal) - client = ConveyClient( - session=make_test_client(journal), - base_url="", - require_service=False, - ) - monkeypatch.setattr("solstone.apps.support.call.get_client", lambda: client) - return CliRunner() - - @pytest.fixture def support_client(): """Create a Flask test client with support blueprint.""" @@ -89,84 +64,6 @@ class _TicketsClient: return self.tickets -class DownSession: - def get(self, _url): - raise requests.exceptions.ConnectionError() - - def post(self, _url, json=None): - raise requests.exceptions.ConnectionError() - - -def _enable_support_cli(monkeypatch): - monkeypatch.setattr("solstone.apps.support.portal.is_enabled", lambda: True) - - -def _stub_dry_run_context(monkeypatch): - monkeypatch.setattr( - "solstone.apps.support.diagnostics.collect_all", - lambda: dict(STUB_DIAGNOSTICS), - ) - monkeypatch.setattr( - "solstone.apps.support.portal._get_portal_url_from_settings", - lambda: "https://support.example.test", - ) - - -def _today_support_drafts() -> list[dict]: - return [ - event - for event in read_chat_events(date.today().strftime("%Y%m%d")) - if event["kind"] == "support_draft" - ] - - -def _patch_flask_upload(monkeypatch): - from solstone.think.convey_client import ( - MALFORMED_RESPONSE_MESSAGE, - SERVER_ERROR_MESSAGE, - ConveyClient, - ConveyClientError, - ) - - def flask_upload(self, path, *, files, data=None): - form = dict(data or {}) - handles = [] - try: - for field, (filename, file_path, content_type) in files.items(): - handle = Path(file_path).open("rb") - handles.append(handle) - if content_type is None: - form[field] = (handle, filename) - else: - form[field] = (handle, filename, content_type) - response = self._session.post( - path, - data=form, - content_type="multipart/form-data", - ) - finally: - for handle in handles: - handle.close() - - parsed = response.get_json(silent=True) - status = response.status_code - if 200 <= status < 300: - if parsed is not None: - return parsed - raise ConveyClientError(MALFORMED_RESPONSE_MESSAGE, status=status) - if isinstance(parsed, dict) and ("error" in parsed or "reason_code" in parsed): - error = parsed.get("error") or parsed.get("reason_code") - raise ConveyClientError( - str(error), - reason_code=parsed.get("reason_code"), - detail=parsed.get("detail"), - status=status, - ) - raise ConveyClientError(SERVER_ERROR_MESSAGE, status=status) - - monkeypatch.setattr(ConveyClient, "upload", flask_upload) - - def test_config_route_reports_enabled_and_portal_url(support_client, monkeypatch): monkeypatch.setattr("solstone.apps.support.portal.is_enabled", lambda: True) monkeypatch.setattr( @@ -667,503 +564,6 @@ def test_feedback_anonymous_drops_smuggled_email(support_client, monkeypatch): assert "user_email" not in captured[0] -def test_cli_dry_run_never_constructs_portal_client(cli, monkeypatch): - tripped: list[str] = [] - - class BlockedPortalClient: - def __init__(self, *args, **kwargs): - tripped.append("PortalClient") - raise AssertionError("PortalClient must not be constructed") - - def blocked_get_client(*args, **kwargs): - tripped.append("get_client") - raise AssertionError("get_client must not be called") - - _enable_support_cli(monkeypatch) - _stub_dry_run_context(monkeypatch) - monkeypatch.setattr( - "solstone.apps.support.portal.PortalClient", BlockedPortalClient - ) - monkeypatch.setattr("solstone.apps.support.portal.get_client", blocked_get_client) - - # Convey localhost only: no external support.solstone.app, no account mint. - feedback_result = cli.invoke(support_cli_app, ["feedback", "-b", "x"]) - create_result = cli.invoke(support_cli_app, ["create", "-s", "s", "-d", "d"]) - - assert feedback_result.exit_code == 0 - assert create_result.exit_code == 0 - assert tripped == [] - - -def test_cli_feedback_dry_run_preview_content(cli, monkeypatch): - _enable_support_cli(monkeypatch) - _stub_dry_run_context(monkeypatch) - - result = cli.invoke(support_cli_app, ["feedback", "-b", "owner feedback"]) - - assert result.exit_code == 0 - assert result.stdout.splitlines()[0] == DRY_RUN_BANNER - assert "Build identity — version:" in result.stdout - assert "Subject: feedback" in result.stdout - assert "Body: owner feedback" in result.stdout - assert "Severity: low" in result.stdout - assert "Category: feedback" in result.stdout - - -def test_cli_create_dry_run_preview_content(cli, monkeypatch): - _enable_support_cli(monkeypatch) - _stub_dry_run_context(monkeypatch) - - result = cli.invoke(support_cli_app, ["create", "-s", "subj", "-d", "desc"]) - - assert result.exit_code == 0 - assert result.stdout.splitlines()[0] == DRY_RUN_BANNER - assert "Build identity — version:" in result.stdout - assert "Subject: subj" in result.stdout - - -def test_cli_create_dry_run_captures_exact_submit_body(cli, monkeypatch): - _enable_support_cli(monkeypatch) - _stub_dry_run_context(monkeypatch) - - result = cli.invoke( - support_cli_app, - ["create", "-s", "Subj", "-d", "Desc", "--anonymous"], - ) - - assert result.exit_code == 0 - assert "DRY RUN" in result.stdout - drafts = _today_support_drafts() - assert len(drafts) == 1 - draft = drafts[0] - assert draft["verb"] == "create" - assert draft["diagnostics_snapshot"] == STUB_DIAGNOSTICS - assert set(draft["payload"]) == { - "subject", - "description", - "product", - "severity", - "category", - "user_context", - "auto_context", - "anonymous", - } - assert draft["payload"]["subject"] == "Subj" - assert draft["payload"]["description"] == "Desc" - assert draft["payload"]["product"] == "solstone" - assert draft["payload"]["severity"] == "medium" - assert draft["payload"]["category"] is None - assert draft["payload"]["user_context"] == STUB_DIAGNOSTICS - assert draft["payload"]["auto_context"] is False - assert draft["payload"]["anonymous"] is True - - -def test_cli_feedback_dry_run_captures_submit_body(cli, monkeypatch): - _enable_support_cli(monkeypatch) - _stub_dry_run_context(monkeypatch) - - result = cli.invoke(support_cli_app, ["feedback", "-b", "Nice", "--anonymous"]) - - assert result.exit_code == 0 - assert "DRY RUN" in result.stdout - drafts = _today_support_drafts() - assert len(drafts) == 1 - draft = drafts[0] - assert draft["verb"] == "feedback" - assert draft["payload"] == { - "body": "Nice", - "product": "solstone", - "anonymous": True, - } - assert draft["diagnostics_snapshot"] == STUB_DIAGNOSTICS - - -def test_cli_reply_no_submit_captures_draft_without_submit(cli, monkeypatch): - _enable_support_cli(monkeypatch) - - result = cli.invoke( - support_cli_app, - ["reply", "42", "-b", "more info", "--no-submit"], - ) - - assert result.exit_code == 0 - assert "DRY RUN" in result.stdout - drafts = _today_support_drafts() - assert len(drafts) == 1 - draft = drafts[0] - assert draft["verb"] == "reply" - assert draft["payload"] == {"ticket_id": 42, "content": "more info"} - assert draft["diagnostics_snapshot"] is None - - -def test_cli_reply_default_still_submits(cli, monkeypatch): - replies: list[tuple[int, str]] = [] - - class ReplyClient: - def reply_to_ticket(self, ticket_id: int, content: str) -> dict: - replies.append((ticket_id, content)) - return {"id": 1} - - _enable_support_cli(monkeypatch) - monkeypatch.setattr( - "solstone.apps.support.routes._get_client", - lambda: ReplyClient(), - ) - - result = cli.invoke(support_cli_app, ["reply", "42", "-b", "hi", "--yes"]) - - assert result.exit_code == 0 - assert "Reply sent to ticket #42." in result.stdout - assert replies == [(42, "hi")] - assert _today_support_drafts() == [] - - -def test_cli_attach_no_submit_captures_file_bytes_without_submit( - cli, monkeypatch, tmp_path -): - _enable_support_cli(monkeypatch) - _patch_flask_upload(monkeypatch) - monkeypatch.setattr( - "solstone.apps.support.portal.get_client", - lambda *_args, **_kwargs: pytest.fail("portal client should not be used"), - ) - monkeypatch.setattr( - "solstone.apps.support.tools.support_attach", - lambda *_args, **_kwargs: pytest.fail("support_attach should not be called"), - ) - file_path = tmp_path / "evidence.txt" - file_bytes = b"captured at draft time" - file_path.write_bytes(file_bytes) - - result = cli.invoke( - support_cli_app, - ["attach", "42", str(file_path), "--no-submit"], - ) - - assert result.exit_code == 0 - assert "DRY RUN" in result.stdout - assert "evidence.txt" in result.stdout - drafts = _today_support_drafts() - assert len(drafts) == 1 - draft = drafts[0] - assert draft["verb"] == "attach" - assert draft["diagnostics_snapshot"] is None - assert draft["payload"]["ticket_id"] == 42 - assert draft["payload"]["filename"] == "evidence.txt" - assert draft["payload"]["content_type"] == "text/plain" - assert draft["payload"]["byte_size"] == len(file_bytes) - assert draft["payload"]["content_b64"] == base64.b64encode(file_bytes).decode( - "ascii" - ) - - -def test_cli_attach_no_submit_requires_one_file(cli, monkeypatch, tmp_path): - _enable_support_cli(monkeypatch) - one = tmp_path / "one.txt" - two = tmp_path / "two.txt" - one.write_text("one", encoding="utf-8") - two.write_text("two", encoding="utf-8") - - result = cli.invoke( - support_cli_app, - ["attach", "42", str(one), str(two), "--no-submit"], - ) - - assert result.exit_code != 0 - assert "Attach one file at a time" in result.stderr - assert _today_support_drafts() == [] - - -def test_cli_attach_no_submit_rejects_disallowed_type(cli, monkeypatch, tmp_path): - _enable_support_cli(monkeypatch) - _patch_flask_upload(monkeypatch) - file_path = tmp_path / "payload.exe" - file_path.write_bytes(b"nope") - - result = cli.invoke( - support_cli_app, - ["attach", "42", str(file_path), "--no-submit"], - ) - - assert result.exit_code != 0 - assert "Unsupported file type: .exe" in result.stderr - assert "Allowed:" in result.stderr - assert ".txt" in result.stderr - assert _today_support_drafts() == [] - - -def test_cli_attach_no_submit_rejects_oversize(cli, monkeypatch, tmp_path): - from solstone.apps.support.portal import PortalClient - - _enable_support_cli(monkeypatch) - _patch_flask_upload(monkeypatch) - monkeypatch.setattr(PortalClient, "MAX_ATTACHMENT_SIZE", 3) - file_path = tmp_path / "large.txt" - file_path.write_bytes(b"larger") - - result = cli.invoke( - support_cli_app, - ["attach", "42", str(file_path), "--no-submit"], - ) - - assert result.exit_code != 0 - assert "File too large" in result.stderr - assert _today_support_drafts() == [] - - -def test_cli_draft_capture_failure_is_nonfatal_and_visible(cli, monkeypatch): - from solstone.think.convey_client import ConveyClient, ConveyClientError - - original_request = ConveyClient.request - - def fail_draft(self, method, path, **kwargs): - if path == "/app/support/api/draft": - raise ConveyClientError("draft failed") - return original_request(self, method, path, **kwargs) - - _enable_support_cli(monkeypatch) - _stub_dry_run_context(monkeypatch) - monkeypatch.setattr(ConveyClient, "request", fail_draft) - - result = cli.invoke(support_cli_app, ["create", "-s", "Subj", "-d", "Desc"]) - - assert result.exit_code == 0 - assert "DRY RUN" in result.stdout - assert "(Draft not captured" in result.stderr - - -def test_cli_feedback_yes_without_submit_is_still_dry_run(cli, monkeypatch): - _enable_support_cli(monkeypatch) - _stub_dry_run_context(monkeypatch) - - result = cli.invoke(support_cli_app, ["feedback", "-b", "x", "-y"]) - - assert result.exit_code == 0 - assert result.stdout.splitlines()[0] == DRY_RUN_BANNER - - -def test_cli_feedback_submit_calls_tool(cli, monkeypatch): - captured: list[dict] = [] - - def recorder(**kwargs): - captured.append(kwargs) - return {"id": 1} - - _enable_support_cli(monkeypatch) - monkeypatch.setattr("solstone.apps.support.tools.support_feedback", recorder) - - result = cli.invoke(support_cli_app, ["feedback", "-b", "hello", "--submit", "-y"]) - - assert result.exit_code == 0 - assert len(captured) == 1 - assert captured[0]["body"] == "hello" - - -def test_cli_create_submit_calls_tool(cli, monkeypatch): - captured: list[dict] = [] - diagnostics = {"version": "9.9.9", "revision": "abc1234"} - - def recorder(**kwargs): - captured.append(kwargs) - return {"id": 1} - - _enable_support_cli(monkeypatch) - monkeypatch.setattr( - "solstone.apps.support.diagnostics.collect_all", - lambda: diagnostics, - ) - monkeypatch.setattr("solstone.apps.support.tools.support_create", recorder) - - result = cli.invoke( - support_cli_app, - ["create", "-s", "S", "-d", "D", "--skip-kb", "--submit", "-y"], - ) - - assert result.exit_code == 0 - assert len(captured) == 1 - assert captured[0]["subject"] == "S" - assert captured[0]["description"] == "D" - assert captured[0]["auto_context"] is False - assert captured[0]["user_context"] == diagnostics - - -def test_cli_create_submit_confirm_negative_does_not_call_tool(cli, monkeypatch): - captured: list[dict] = [] - - def recorder(**kwargs): - captured.append(kwargs) - return {"id": 1} - - _enable_support_cli(monkeypatch) - monkeypatch.setattr( - "solstone.apps.support.diagnostics.collect_all", - lambda: {"version": "9.9.9", "revision": "abc1234"}, - ) - monkeypatch.setattr("solstone.apps.support.tools.support_create", recorder) - - result = cli.invoke( - support_cli_app, - ["create", "-s", "S", "-d", "D", "--skip-kb", "--submit"], - input="n\n", - ) - - assert result.exit_code == 0 - assert captured == [] - assert "Cancelled — nothing was sent." in result.stdout - - -def test_search_disabled_prints_byte_locked_message(cli, monkeypatch): - monkeypatch.setattr("solstone.apps.support.portal.is_enabled", lambda: False) - - result = cli.invoke(support_cli_app, ["search", "foo"]) - - assert result.exit_code == 1 - assert result.stdout == "" - assert result.stderr == "Support agent is disabled in settings.\n" - - -def test_diagnose_is_ungated_when_disabled(cli, monkeypatch): - monkeypatch.setattr("solstone.apps.support.portal.is_enabled", lambda: False) - - result = cli.invoke(support_cli_app, ["diagnose"]) - - assert result.exit_code == 0 - assert "# Local Diagnostics" in result.stdout - assert "disabled" not in result.stdout - - -def test_search_convey_down_prints_notice(monkeypatch): - from solstone.think.convey_client import ConveyClient - - client = ConveyClient( - session=DownSession(), - base_url="http://localhost:5015", - require_service=False, - ) - monkeypatch.setattr("solstone.apps.support.call.get_client", lambda: client) - - result = CliRunner().invoke(support_cli_app, ["search", "foo"]) - - assert result.exit_code == 1 - assert result.stdout == "" - assert result.stderr == ( - "I couldn't reach support because solstone isn't reachable right now.\n" - "To file a support ticket, visit https://support.solstone.app\n" - ) - - -def test_diagnose_convey_down_prints_build_identity_then_notice(monkeypatch): - from solstone.think.convey_client import ConveyClient - - client = ConveyClient( - session=DownSession(), - base_url="http://localhost:5015", - require_service=False, - ) - monkeypatch.setattr("solstone.apps.support.call.get_client", lambda: client) - - result = CliRunner().invoke(support_cli_app, ["diagnose"]) - - assert result.exit_code == 1 - assert result.stdout.startswith("# Local Diagnostics") - assert "Version:" in result.stdout - assert "Platform:" in result.stdout - assert result.stderr == ( - "I couldn't reach support because solstone isn't reachable right now.\n" - "To file a support ticket, visit https://support.solstone.app\n" - ) - - -def test_article_portal_error_is_reason_message(cli, monkeypatch): - class ArticleClient: - def get_article(self, slug): - raise RuntimeError("boom") - - _enable_support_cli(monkeypatch) - monkeypatch.setattr( - "solstone.apps.support.routes._get_client", - lambda: ArticleClient(), - ) - - result = cli.invoke(support_cli_app, ["article", "intro"]) - - assert result.exit_code == 1 - assert result.stdout == "" - assert result.stderr == "I couldn't reach support right now.\n" - - -def test_list_success_renders_rows(cli, monkeypatch): - class ListClient: - def list_tickets(self, status=None): - return [{"id": 7, "status": "open", "subject": "Hi"}] - - _enable_support_cli(monkeypatch) - monkeypatch.setattr( - "solstone.apps.support.routes._get_client", - lambda: ListClient(), - ) - - result = cli.invoke(support_cli_app, ["list"]) - - assert result.exit_code == 0 - assert " # 7 [open ] Hi" in result.stdout - assert "1 ticket(s)." in result.stdout - - -def test_attach_success_and_skip_via_fake_client(monkeypatch, tmp_path): - from solstone.think.convey_client import ConveyClientError - - a = tmp_path / "a.png" - b = tmp_path / "b.png" - a.write_bytes(b"a") - b.write_bytes(b"b") - - class FakeClient: - def __init__(self): - self.uploaded = [] - - def request(self, method, path, **kw): - assert path == "/app/support/api/config" - return {"enabled": True, "portal_url": "https://support.example.test"} - - def upload(self, path, *, files, data=None): - self.uploaded.append((path, files)) - return {"id": len(self.uploaded)} - - fake = FakeClient() - monkeypatch.setattr("solstone.apps.support.call.get_client", lambda: fake) - - result = CliRunner().invoke(support_cli_app, ["attach", "42", str(a), str(b), "-y"]) - - assert result.exit_code == 0 - assert "Attached: a.png (id: 1)" in result.stdout - assert "Attached: b.png (id: 2)" in result.stdout - assert len(fake.uploaded) == 2 - assert fake.uploaded[0][0] == "/app/support/api/tickets/42/attachments" - assert fake.uploaded[0][1]["file"] == ("a.png", str(a), None) - assert fake.uploaded[1][0] == "/app/support/api/tickets/42/attachments" - assert fake.uploaded[1][1]["file"] == ("b.png", str(b), None) - - class SkippingFakeClient(FakeClient): - def upload(self, path, *, files, data=None): - self.uploaded.append((path, files)) - if len(self.uploaded) == 1: - raise ConveyClientError("I couldn't use one of those values.") - return {"id": len(self.uploaded)} - - skipping_fake = SkippingFakeClient() - monkeypatch.setattr("solstone.apps.support.call.get_client", lambda: skipping_fake) - - skipped = CliRunner().invoke( - support_cli_app, ["attach", "42", str(a), str(b), "-y"] - ) - - assert skipped.exit_code == 0 - assert "Attached: b.png (id: 2)" in skipped.stdout - assert skipped.stderr == "Skipped a.png: I couldn't use one of those values.\n" - assert len(skipping_fake.uploaded) == 2 - - def test_feedback_identified_empty_email_omits_kwarg(support_client, monkeypatch): captured: list[dict] = [] @@ -1531,23 +931,6 @@ def test_unparseable_first_line_uses_mtime(tmp_path, monkeypatch): assert entry["time_approximate"] is True -def test_window_excludes_old_and_cli_empty_state(cli, tmp_path, monkeypatch): - health_dir = _health_dir(tmp_path, monkeypatch) - stale = (datetime.now() - timedelta(days=30)).isoformat(timespec="seconds") - _write_log( - health_dir, - "stale.log", - [f"{stale} [stale:stderr] ERROR:root:too-old"], - ) - - assert collect_recent_errors() == [] - - result = cli.invoke(support_cli_app, ["diagnose"]) - - assert result.exit_code == 0 - assert "No recent errors." in result.stdout - - def test_unreadable_log_degrades_gracefully(tmp_path, monkeypatch): health_dir = _health_dir(tmp_path, monkeypatch) (health_dir / "bad.log").mkdir() @@ -1561,28 +944,3 @@ def test_unreadable_log_degrades_gracefully(tmp_path, monkeypatch): result = collect_recent_errors() assert any("survived" in entry["message"] for entry in result) - - -def test_cli_count_matches_printed_rows(cli, tmp_path, monkeypatch): - health_dir = _health_dir(tmp_path, monkeypatch) - now = datetime.now() - count = 3 - lines = [ - ( - f"{(now - timedelta(minutes=i + 1)).isoformat(timespec='seconds')} " - f"[count:stderr] ERROR:root:count-{i}" - ) - for i in range(count) - ] - _write_log(health_dir, "count.log", lines) - - result = cli.invoke(support_cli_app, ["diagnose"]) - - assert result.exit_code == 0 - assert f"Recent errors ({count}):" in result.stdout - rows = [ - line - for line in result.stdout.splitlines() - if line.startswith(" ") and "[count]" in line and "ERROR:root:count-" in line - ] - assert len(rows) == count diff --git a/tests/test_awareness_call_parity.py b/tests/test_awareness_call_parity.py deleted file mode 100644 index ebfb3d3f1..000000000 --- a/tests/test_awareness_call_parity.py +++ /dev/null @@ -1,292 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -import json -from pathlib import Path - -import pytest -import requests -from typer.testing import CliRunner - -import solstone.think.awareness as awareness_mod -from solstone.apps.awareness.call import app -from solstone.convey.reasons import AWARENESS_BUSY -from solstone.think.convey_client import ConveyClient -from solstone.think.journal_io import LockTimeout -from tests._awareness_harness import make_awareness_test_client - -FROZEN_MS = 1700000000000 -FROZEN_ISO = "20260415T12:00:00" -FROZEN_DAY = "20260415" - - -@pytest.fixture -def journal(tmp_path, monkeypatch): - # Env must point at the tmp journal so BOTH the seed helpers (append_log/ - # update_state) and the in-process route handlers resolve get_journal() to it. - monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - return tmp_path - - -@pytest.fixture -def runner(journal, monkeypatch): - client = ConveyClient(session=make_awareness_test_client(), base_url="") - monkeypatch.setattr("solstone.apps.awareness.call.get_client", lambda: client) - return CliRunner() - - -@pytest.fixture -def frozen_clock(monkeypatch): - # Patch the awareness module's private clock helpers so mutation entries are - # deterministic for both direct seeding and the in-process route handlers. - monkeypatch.setattr(awareness_mod, "_now_ts", lambda: FROZEN_MS) - monkeypatch.setattr(awareness_mod, "_now_iso", lambda: FROZEN_ISO) - monkeypatch.setattr(awareness_mod, "_today", lambda: FROZEN_DAY) - - -def _write_current(journal, state): - awareness_dir = journal / "awareness" - awareness_dir.mkdir(exist_ok=True) - (awareness_dir / "current.json").write_text(json.dumps(state), encoding="utf-8") - - -def _write_awareness_log(journal: Path, day: str, count: int) -> None: - awareness_dir = journal / "awareness" - awareness_dir.mkdir(exist_ok=True) - entries = [ - json.dumps({"ts": FROZEN_MS + i, "kind": "observation", "message": f"m{i}"}) - for i in range(count) - ] - (awareness_dir / f"{day}.jsonl").write_text( - "\n".join(entries) + "\n", - encoding="utf-8", - ) - - -def test_status_full_state_json(runner): - awareness_mod.update_state("onboarding", {"status": "observing"}) - - result = runner.invoke(app, ["status"]) - - assert result.exit_code == 0 - assert json.loads(result.stdout) == {"onboarding": {"status": "observing"}} - - -def test_status_empty_journal_human_line(runner): - result = runner.invoke(app, ["status"]) - - assert result.exit_code == 0 - assert result.stdout == "No awareness state yet.\n" - - -def test_status_section_json(runner): - awareness_mod.update_state("onboarding", {"status": "observing"}) - - result = runner.invoke(app, ["status", "onboarding"]) - - assert result.exit_code == 0 - assert json.loads(result.stdout) == {"status": "observing"} - - -def test_status_section_non_dict_values(runner, journal): - _write_current(journal, {"flags": [1, 2, 3], "count": 5}) - - flags = runner.invoke(app, ["status", "flags"]) - count = runner.invoke(app, ["status", "count"]) - - assert flags.exit_code == 0 - assert json.loads(flags.stdout) == [1, 2, 3] - assert count.exit_code == 0 - assert json.loads(count.stdout) == 5 - - -def test_status_section_null_is_missing(runner, journal): - _write_current(journal, {"voiceprint": None}) - - result = runner.invoke(app, ["status", "voiceprint"]) - - assert result.exit_code == 0 - assert result.stdout == "No 'voiceprint' state.\n" - - -def test_status_missing_section_human_line(runner): - awareness_mod.update_state("onboarding", {"status": "observing"}) - - result = runner.invoke(app, ["status", "nope"]) - - assert result.exit_code == 0 - assert result.stdout == "No 'nope' state.\n" - - -def test_status_section_empty_journal_uses_empty_state_line(runner): - result = runner.invoke(app, ["status", "onboarding"]) - - assert result.exit_code == 0 - assert result.stdout == "No awareness state yet.\n" - - -def test_imports_default_state(runner): - result = runner.invoke(app, ["imports"]) - - assert result.exit_code == 0 - assert json.loads(result.stdout) == awareness_mod._default_imports() - - -def test_imports_record_is_deterministic_without_clock_freeze(runner): - result = runner.invoke(app, ["imports", "--record", "chatgpt"]) - - assert result.exit_code == 0 - assert json.loads(result.stdout) == { - "has_imported": True, - "import_count": 1, - "sources_used": ["chatgpt"], - "offer_declined": None, - "last_nudge": None, - } - - -def test_imports_declined_uses_frozen_clock(runner, frozen_clock): - result = runner.invoke(app, ["imports", "--declined"]) - - assert result.exit_code == 0 - assert json.loads(result.stdout) == { - "has_imported": False, - "import_count": 0, - "sources_used": [], - "offer_declined": FROZEN_ISO, - "last_nudge": None, - } - - -def test_imports_nudge_uses_frozen_clock(runner, frozen_clock): - result = runner.invoke(app, ["imports", "--nudge"]) - - assert result.exit_code == 0 - assert json.loads(result.stdout) == { - "has_imported": False, - "import_count": 0, - "sources_used": [], - "offer_declined": None, - "last_nudge": FROZEN_ISO, - } - - -def test_log_read_empty_journal_human_line(runner): - result = runner.invoke(app, ["log-read"]) - - assert result.exit_code == 0 - assert result.stdout == "No entries found.\n" - - -def test_log_read_fetches_all_entries_past_default_cap_and_page_boundary( - runner, journal -): - _write_awareness_log(journal, "20260101", 25) - _write_awareness_log(journal, "20260102", 150) - - capped = runner.invoke(app, ["log-read", "20260101"]) - paged = runner.invoke(app, ["log-read", "20260102"]) - - assert capped.exit_code == 0 - capped_data = json.loads(capped.stdout) - assert isinstance(capped_data, list) - assert len(capped_data) == 25 - assert paged.exit_code == 0 - assert len(json.loads(paged.stdout)) == 150 - - -def test_log_read_kind_filter(runner): - for i in range(3): - awareness_mod.append_log("observation", message=f"o{i}", day="20260101") - for i in range(2): - awareness_mod.append_log("nudge", message=f"n{i}", day="20260101") - - result = runner.invoke(app, ["log-read", "20260101", "--kind", "observation"]) - - assert result.exit_code == 0 - data = json.loads(result.stdout) - assert len(data) == 3 - assert all(entry["kind"] == "observation" for entry in data) - - -def test_log_read_limit_is_client_side_last_n_slice(runner): - for i in range(5): - awareness_mod.append_log("observation", message=f"m{i}", day="20260101") - - result = runner.invoke(app, ["log-read", "20260101", "--limit", "2"]) - - assert result.exit_code == 0 - data = json.loads(result.stdout) - assert [entry["message"] for entry in data] == ["m3", "m4"] - - -def test_log_creates_entry_with_message_and_key(runner, frozen_clock): - result = runner.invoke( - app, ["log", "observation", "saw a meeting", "--key", "test"] - ) - - assert result.exit_code == 0 - assert json.loads(result.stdout) == { - "ts": FROZEN_MS, - "kind": "observation", - "key": "test", - "message": "saw a meeting", - } - - -def test_log_invalid_data_fails_cli_side_without_post(runner): - result = runner.invoke(app, ["log", "observation", "--data", "{not valid"]) - - assert result.exit_code == 1 - assert result.stderr == "Error: --data must be valid JSON\n" - assert result.stdout == "" - assert awareness_mod.read_log() == [] - - -def test_log_creates_entry_with_json_data(runner, frozen_clock): - result = runner.invoke(app, ["log", "observation", "--data", '{"meetings": 2}']) - - assert result.exit_code == 0 - assert json.loads(result.stdout) == { - "ts": FROZEN_MS, - "kind": "observation", - "data": {"meetings": 2}, - } - - -def test_convey_down_prints_require_solstone_message(journal, monkeypatch): - class DownSession: - def get(self, _url): - raise requests.exceptions.ConnectionError() - - def post(self, _url, json=None): - raise requests.exceptions.ConnectionError() - - client = ConveyClient(session=DownSession(), base_url="http://localhost:5015") - monkeypatch.setattr("solstone.apps.awareness.call.get_client", lambda: client) - monkeypatch.delenv("SOL_SKIP_SUPERVISOR_CHECK", raising=False) - monkeypatch.delenv("SOL_SUPERVISOR_SPAWNED", raising=False) - - result = CliRunner().invoke(app, ["status"]) - - assert result.exit_code == 1 - assert ( - result.stderr - == "sol: solstone isn't running. Start it with 'journal up' and retry.\n" - ) - assert result.stdout == "" - - -def test_imports_busy_prints_owner_voice_error(runner, monkeypatch): - def _raises_locktimeout(_source_type): - raise LockTimeout(Path("busy"), 0.01) - - monkeypatch.setattr( - "solstone.apps.awareness.routes.record_import", _raises_locktimeout - ) - - result = runner.invoke(app, ["imports", "--record", "x"]) - - assert result.exit_code == 1 - assert result.stderr == f"{AWARENESS_BUSY.message}\n" - assert result.stdout == "" diff --git a/tests/test_brain_health_cutover_parity.py b/tests/test_brain_health_cutover_parity.py index 815efd5e9..a3960419a 100644 --- a/tests/test_brain_health_cutover_parity.py +++ b/tests/test_brain_health_cutover_parity.py @@ -141,52 +141,12 @@ def _snapshot(case: Case, surface: str) -> dict[str, Any]: } -def _minimal_report(brain: dict[str, Any]) -> dict[str, Any]: - return { - "range": ["20260721", "20260721"], - "capture_health": { - "hours_with_capture": 0, - "hours_total": 0, - "coverage_ratio": None, - "facets_with_recent_capture": [], - "facets_silent_24h": [], - "last_segment_at": None, - }, - "synthesis_health": { - "activities_count": 0, - "activities_with_participation": 0, - "activities_with_story": 0, - "activities_user_edited": 0, - "activities_anticipated_unfilled": 0, - "talent_run_failures_24h": None, - "talent_degraded_outputs_24h": None, - "indexer_last_rebuild_at": None, - }, - "consumer_signal": { - "ledger_open_items_total": 0, - "ledger_stale_items_count": 0, - "profile_entities_total": 0, - }, - "segment_backlog": { - "not_thought": 0, - "days_with_backlog": 0, - "errors": [], - }, - "brain_health": { - "snapshot": brain, - "lines": ["Brain Health", f" {brain['headline']}"], - }, - "notes": [], - } - - -def test_state_parity_matrix(monkeypatch, capsys): +def test_state_parity_matrix(monkeypatch): from solstone.apps.health import routes as health_routes from solstone.apps.home.health_glance import build_health_glance from solstone.apps.support import diagnostics from solstone.apps.thinking import routes as thinking_routes from solstone.think import doctor, top - from solstone.think.tools import health as health_tool app = Flask(__name__) app.register_blueprint(health_routes.health_bp) @@ -291,11 +251,6 @@ def test_state_parity_matrix(monkeypatch, capsys): assert section["snapshot"]["action"] == _expected_action(case, "support") cli_brain = _snapshot(case, "cli") - health_tool._render_summary(_minimal_report(cli_brain)) - rendered = capsys.readouterr().out - assert "Brain Health" in rendered - assert HEADLINES[case.state] in rendered - monkeypatch.setattr( "solstone.think.brain_health.build_brain_snapshot", lambda *_a, **_k: cli_brain, diff --git a/tests/test_call.py b/tests/test_call.py index 05578a577..3e14dbb1c 100644 --- a/tests/test_call.py +++ b/tests/test_call.py @@ -1,7 +1,7 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright (c) 2026 sol pbc -"""Tests for think/call.py CLI dispatcher and app discovery.""" +"""Tests for think/call.py journal compatibility dispatcher.""" import json @@ -148,7 +148,7 @@ def merge_journal(tmp_path, monkeypatch): class TestDiscovery: - """Tests for app CLI discovery.""" + """Tests for the surviving journal CLI mount.""" def test_main_prints_corrupt_config_and_exits_nonzero(self, monkeypatch, capsys): """main() should surface corrupt config in owner voice.""" @@ -171,18 +171,17 @@ class TestDiscovery: def test_no_args_shows_help(self): """Running 'sol call' with no args shows help.""" result = runner.invoke(call_app, []) - assert "Call app functions" in result.output + assert "Call journal functions" in result.output def test_unknown_app_fails(self): """Unknown app name should produce an error.""" result = runner.invoke(call_app, ["nonexistent"]) assert result.exit_code != 0 - def test_network_app_keeps_link_call_name(self): + def test_only_journal_group_is_registered(self): names = {group.name for group in call_app.registered_groups} - assert "link" in names - assert "network" not in names + assert names == {"journal"} class TestJournal: diff --git a/tests/test_chat_cli.py b/tests/test_chat_cli.py deleted file mode 100644 index 796d6981b..000000000 --- a/tests/test_chat_cli.py +++ /dev/null @@ -1,820 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -from __future__ import annotations - -import json -import sys -import time -from collections import deque -from collections.abc import Iterable -from typing import Any - -import pytest - -from solstone.think import chat_cli -from solstone.think.convey_client import ( - ConveyClientError, - ConveyUnreachableError, -) - -USE_ID = "1713626000000" -FOREIGN_USE_ID = "1713626000001" - - -class FakeClient: - def __init__(self, responses: Iterable[Any]) -> None: - self.responses = deque(responses) - self.calls: list[tuple[str, str, Any]] = [] - self.base_url = "" - - def request( - self, - method: str, - path: str, - *, - params: Any = None, - json: Any = None, - ) -> Any: - self.calls.append((method, path, json)) - if not self.responses: - raise AssertionError(f"unexpected request: {method} {path}") - response = self.responses.popleft() - if isinstance(response, BaseException): - raise response - return response - - -class FakeSseResponse: - def __init__(self, chunks: list[bytes]) -> None: - self.chunks = chunks - self.chunk_size = None - - def iter_content(self, chunk_size=None): - self.chunk_size = chunk_size - return iter(self.chunks) - - -def _patch_identity(monkeypatch: pytest.MonkeyPatch) -> None: - import solstone.think.identity as identity - - monkeypatch.setattr(identity, "ensure_identity_directory", lambda: None) - - -def _frame(event: dict[str, Any]) -> bytes: - return b"data: " + json.dumps(event).encode("utf-8") + b"\n\n" - - -def _install_main_fakes( - monkeypatch: pytest.MonkeyPatch, - *, - argv: list[str], - client: FakeClient, - sse_chunks: Iterable[bytes] | None, - base_url: str = "http://home.example:9999", -) -> list[str]: - monkeypatch.setattr(sys, "argv", ["sol chat", *argv]) - _patch_identity(monkeypatch) - monkeypatch.setattr(chat_cli, "resolve_base_url", lambda: base_url) - - def build_client(resolved_base_url: str) -> FakeClient: - client.base_url = resolved_base_url - return client - - open_calls: list[str] = [] - - def open_sse(resolved_base_url: str) -> Iterable[bytes] | None: - open_calls.append(resolved_base_url) - if sse_chunks is None: - return None - return iter(sse_chunks) - - monkeypatch.setattr(chat_cli, "_build_client", build_client) - monkeypatch.setattr(chat_cli, "_open_sse", open_sse) - return open_calls - - -def _post_success(*, queued: bool = False) -> dict[str, Any]: - return {"use_id": USE_ID, "queued": queued} - - -def _session_finish( - *, - use_id: str = USE_ID, - text: str = "Recovered answer", - requested_target: str | None = None, - origin: dict[str, Any] | None = None, -) -> dict[str, Any]: - return { - "latest_sol_message": { - "use_id": use_id, - "text": text, - "requested_target": requested_target, - "origin": origin, - } - } - - -def test_iter_sse_events_handles_comments_chunking_multiline_and_bad_json() -> None: - chunks = [ - b": heartbeat\n\n", - b'data: {"a":', - b"1}\n\n", - b'data: {"b": 2}\n\ndata: {"c": 3}\n\n', - b'data: {"multi":\n', - b"data: true}\n\n", - b"data: not json\n\n", - b'data: {"incomplete": true}', - ] - - assert list(chat_cli.iter_sse_events(chunks)) == [ - {"a": 1}, - {"b": 2}, - {"c": 3}, - {"multi": True}, - ] - - -def test_open_sse_uses_resolved_events_url_and_stream_timeout(monkeypatch) -> None: - response = FakeSseResponse([b": heartbeat\n\n"]) - captured: dict[str, Any] = {} - - def fake_get(url: str, **kwargs: Any) -> FakeSseResponse: - captured["url"] = url - captured.update(kwargs) - return response - - monkeypatch.setattr(chat_cli.requests, "get", fake_get) - - chunks = chat_cli._open_sse("http://home.example:9999/") - - assert list(chunks or []) == [b": heartbeat\n\n"] - assert captured == { - "url": "http://home.example:9999/sse/events", - "stream": True, - "timeout": (chat_cli.POST_TIMEOUT_SECONDS, None), - } - assert response.chunk_size is None - - -def test_timeout_session_uses_monkeypatched_post_timeout(monkeypatch) -> None: - captured: dict[str, Any] = {} - monkeypatch.setattr(chat_cli, "POST_TIMEOUT_SECONDS", 0.25) - - def fake_request(self, method, url, **kwargs): - captured.update(kwargs) - return "ok" - - monkeypatch.setattr(chat_cli.requests.Session, "request", fake_request) - - assert chat_cli._TimeoutSession().request("GET", "http://example.test") == "ok" - assert captured["timeout"] == 0.25 - - -def test_post_chat_includes_facet_when_supplied() -> None: - client = FakeClient([_post_success()]) - - assert chat_cli._post_chat(client, "find this", "work") == (USE_ID, False) - assert client.calls == [ - ("POST", "/api/chat", {"message": "find this", "facet": "work"}) - ] - - -def test_post_chat_omits_empty_facet() -> None: - client = FakeClient([_post_success()]) - - assert chat_cli._post_chat(client, "hello", None) == (USE_ID, False) - assert client.calls == [("POST", "/api/chat", {"message": "hello"})] - - -def test_render_post_error_uses_error_and_detail() -> None: - exc = ConveyClientError( - "Missing required field.", - detail="message is required", - status=400, - ) - - assert chat_cli._render_post_error(exc) == ( - "sol: Missing required field.\nsol: message is required" - ) - - -@pytest.mark.parametrize( - ("event", "verbose", "expected"), - [ - ( - {"tract": "cortex", "event": "start", "provider": "google"}, - False, - "sol is thinking…", - ), - ( - {"tract": "cortex", "event": "start", "provider": "google"}, - True, - "Provider: google; model: unknown", - ), - ( - {"tract": "cortex", "event": "thinking", "summary": "reading context"}, - False, - "sol is thinking…", - ), - ( - {"tract": "cortex", "event": "thinking", "summary": "reading context"}, - True, - "Thinking: reading context", - ), - ( - {"tract": "cortex", "event": "tool_start", "tool": "journal_search"}, - False, - "· journal_search", - ), - ( - {"tract": "cortex", "event": "tool_end", "tool": "journal_search"}, - False, - None, - ), - ( - {"tract": "cortex", "event": "tool_end", "tool": "journal_search"}, - True, - "· journal_search done", - ), - ( - { - "tract": "chat", - "event": "sol_message", - "requested_target": "exec", - "requested_task": " find \n the Adrian quote ", - }, - False, - "Making that change… (find the Adrian quote)", - ), - ( - { - "tract": "chat", - "event": "sol_message", - "requested_target": "read", - "requested_task": "patterns this month", - }, - False, - "reading your journal… (patterns this month)", - ), - ( - {"tract": "chat", "event": "talent_finished", "use_id": "talent-1"}, - False, - "Composing your answer…", - ), - ], -) -def test_render_progress_rows(event, verbose, expected) -> None: - assert chat_cli._render_progress(event, verbose=verbose) == expected - - -def test_render_progress_truncates_task_suffix_to_100_chars() -> None: - line = chat_cli._render_progress( - { - "tract": "chat", - "event": "sol_message", - "requested_target": "exec", - "requested_task": "x" * 101, - }, - verbose=False, - ) - - assert line == f"Making that change… ({'x' * 99}…)" - - -def test_render_progress_truncates_verbose_thinking_to_200_chars() -> None: - line = chat_cli._render_progress( - { - "tract": "cortex", - "event": "thinking", - "summary": "x" * 201, - }, - verbose=True, - ) - - assert line == f"Thinking: {'x' * 199}…" - - -def test_render_progress_lines_are_deduped_by_caller_pattern() -> None: - lines: list[str] = [] - last = None - for event in [ - {"tract": "cortex", "event": "start"}, - {"tract": "cortex", "event": "thinking"}, - ]: - line = chat_cli._render_progress(event, verbose=False) - if line != last: - lines.append(line) - last = line - - assert lines == ["sol is thinking…"] - - -def test_terminal_error_message_uses_chat_view_for_known_reason() -> None: - assert chat_cli._terminal_error_message("chat_timeout", "") == ( - "chat took too long" - ) - - -def test_session_terminal_finish_only_current_use_id() -> None: - assert chat_cli._session_terminal(FakeClient([_session_finish()]), USE_ID) == { - "kind": "finish", - "result": "Recovered answer", - } - assert ( - chat_cli._session_terminal( - FakeClient([_session_finish(use_id=FOREIGN_USE_ID)]), - USE_ID, - ) - is None - ) - assert chat_cli._session_terminal( - FakeClient( - [ - _session_finish( - use_id=FOREIGN_USE_ID, - text="Folded answer", - origin={"logical_use_id": USE_ID, "ask": "hello"}, - ) - ] - ), - USE_ID, - ) == {"kind": "finish", "result": "Folded answer"} - assert ( - chat_cli._session_terminal( - FakeClient([_session_finish(requested_target="exec")]), - USE_ID, - ) - is None - ) - assert ( - chat_cli._session_terminal( - FakeClient([ConveyClientError("bad", status=500)]), - USE_ID, - ) - is None - ) - - -def test_main_live_sse_finish_prints_answer_without_fallback_warning( - monkeypatch, capsys -) -> None: - client = FakeClient([_post_success()]) - _install_main_fakes( - monkeypatch, - argv=["hello"], - client=client, - sse_chunks=[ - _frame( - { - "tract": "cortex", - "event": "finish", - "chat_proxy": True, - "use_id": USE_ID, - "result": "Live answer", - } - ) - ], - ) - - chat_cli.main() - - captured = capsys.readouterr() - assert captured.out == "Live answer\n" - assert captured.err == "" - assert client.calls == [("POST", "/api/chat", {"message": "hello"})] - - -def test_main_live_sse_origin_fold_prints_answer(monkeypatch, capsys) -> None: - client = FakeClient([_post_success()]) - _install_main_fakes( - monkeypatch, - argv=["hello"], - client=client, - sse_chunks=[ - _frame( - { - "tract": "chat", - "event": "sol_message", - "use_id": FOREIGN_USE_ID, - "text": "Folded answer", - "requested_target": None, - "origin": {"logical_use_id": USE_ID, "ask": "hello"}, - } - ) - ], - ) - - chat_cli.main() - - captured = capsys.readouterr() - assert captured.out == "Folded answer\n" - assert captured.err == "" - assert client.calls == [("POST", "/api/chat", {"message": "hello"})] - - -def test_main_live_sse_error_prints_terminal_error_without_session_poll( - monkeypatch, capsys -) -> None: - client = FakeClient([_post_success()]) - _install_main_fakes( - monkeypatch, - argv=["hello"], - client=client, - sse_chunks=[ - _frame( - { - "tract": "cortex", - "event": "error", - "chat_proxy": True, - "use_id": USE_ID, - "error": "chat_timeout", - "provider": "", - "detail": "", - } - ) - ], - ) - - with pytest.raises(SystemExit) as exc_info: - chat_cli.main() - - assert exc_info.value.code == 1 - captured = capsys.readouterr() - assert captured.out == "" - assert captured.err == "sol: chat took too long\n" - assert client.calls == [("POST", "/api/chat", {"message": "hello"})] - - -def test_main_sse_firehose_filtering_preserves_callback_parity( - monkeypatch, capsys -) -> None: - client = FakeClient([_post_success()]) - events = [ - {"tract": "cortex", "event": "start", "chat_proxy": True, "use_id": USE_ID}, - { - "tract": "cortex", - "event": "thinking", - "chat_proxy": True, - "use_id": FOREIGN_USE_ID, - "summary": "foreign", - }, - {"tract": "observe", "event": "status"}, - { - "tract": "cortex", - "event": "tool_start", - "chat_proxy": True, - "use_id": USE_ID, - "tool": "journal_search", - }, - { - "tract": "chat", - "event": "sol_message", - "use_id": USE_ID, - "requested_target": "exec", - "requested_task": "find it", - }, - { - "tract": "cortex", - "event": "finish", - "chat_proxy": True, - "use_id": USE_ID, - "result": "Done", - }, - ] - _install_main_fakes( - monkeypatch, - argv=["hello"], - client=client, - sse_chunks=[_frame(event) for event in events], - ) - - chat_cli.main() - - captured = capsys.readouterr() - assert captured.out == "Done\n" - assert captured.err == ( - "sol is thinking…\n· journal_search\nMaking that change… (find it)\n" - ) - - -def test_main_preserves_talent_finished_without_use_id_match( - monkeypatch, capsys -) -> None: - client = FakeClient([_post_success()]) - _install_main_fakes( - monkeypatch, - argv=["hello"], - client=client, - sse_chunks=[ - _frame( - { - "tract": "chat", - "event": "talent_finished", - "use_id": FOREIGN_USE_ID, - "name": "exec", - "summary": "done", - } - ), - _frame( - { - "tract": "cortex", - "event": "finish", - "chat_proxy": True, - "use_id": USE_ID, - "result": "Final", - } - ), - ], - ) - - chat_cli.main() - - captured = capsys.readouterr() - assert captured.out == "Final\n" - assert captured.err == f"{chat_cli.COMPOSING_MESSAGE}\n" - - -def test_main_uses_resolved_base_url_for_post_sse_and_session( - monkeypatch, capsys -) -> None: - client = FakeClient([_post_success(), _session_finish(text="Recovered")]) - monkeypatch.setattr(chat_cli, "POLL_SECONDS", 0.01) - open_calls = _install_main_fakes( - monkeypatch, - argv=["hello"], - client=client, - sse_chunks=None, - base_url="http://home.example:9999", - ) - - chat_cli.main() - - captured = capsys.readouterr() - assert captured.out == "Recovered\n" - assert captured.err == f"{chat_cli.LIVE_PROGRESS_UNAVAILABLE_MESSAGE}\n" - assert client.base_url == "http://home.example:9999" - assert open_calls == ["http://home.example:9999"] - assert client.calls == [ - ("POST", "/api/chat", {"message": "hello"}), - ("GET", "/api/chat/session", None), - ] - - -def test_main_dead_sse_recovers_current_turn_from_session(monkeypatch, capsys) -> None: - client = FakeClient([_post_success(), _session_finish(text="Recovered answer")]) - monkeypatch.setattr(chat_cli, "POLL_SECONDS", 0.01) - _install_main_fakes( - monkeypatch, - argv=["hello"], - client=client, - sse_chunks=None, - ) - - chat_cli.main() - - captured = capsys.readouterr() - assert captured.out == "Recovered answer\n" - assert captured.err == f"{chat_cli.LIVE_PROGRESS_UNAVAILABLE_MESSAGE}\n" - - -def test_main_dead_sse_ignores_foreign_session_turn_until_lost_contact( - monkeypatch, capsys -) -> None: - client = FakeClient([_post_success(), _session_finish(use_id=FOREIGN_USE_ID)]) - monkeypatch.setattr(chat_cli, "POLL_SECONDS", 0.01) - monkeypatch.setattr(chat_cli, "IDLE_CEILING_SECONDS", 0) - _install_main_fakes( - monkeypatch, - argv=["hello"], - client=client, - sse_chunks=None, - ) - - with pytest.raises(SystemExit) as exc_info: - chat_cli.main() - - assert exc_info.value.code == 1 - captured = capsys.readouterr() - assert captured.out == "" - assert captured.err == f"{chat_cli.LOST_CONTACT_MESSAGE}\n" - - -def test_main_idle_live_sse_polls_session_at_ceiling(monkeypatch, capsys) -> None: - def live_idle_stream(): - yield _frame( - { - "tract": "cortex", - "event": "start", - "chat_proxy": True, - "use_id": USE_ID, - } - ) - while True: - time.sleep(1) - - client = FakeClient([_post_success(), _session_finish(text="Recovered")]) - monkeypatch.setattr(chat_cli, "POLL_SECONDS", 0.01) - monkeypatch.setattr(chat_cli, "IDLE_CEILING_SECONDS", 0) - _install_main_fakes( - monkeypatch, - argv=["hello"], - client=client, - sse_chunks=live_idle_stream(), - ) - - chat_cli.main() - - captured = capsys.readouterr() - assert captured.out == "Recovered\n" - assert captured.err in ( - f"{chat_cli.LIVE_PROGRESS_UNAVAILABLE_MESSAGE}\n", - ( - f"{chat_cli.CHAT_LIVENESS_THINKING}\n" - f"{chat_cli.LIVE_PROGRESS_UNAVAILABLE_MESSAGE}\n" - ), - ) - - -def test_main_dead_sse_no_session_terminal_lost_contact(monkeypatch, capsys) -> None: - client = FakeClient([_post_success(), {"latest_sol_message": None}]) - monkeypatch.setattr(chat_cli, "POLL_SECONDS", 0.01) - monkeypatch.setattr(chat_cli, "IDLE_CEILING_SECONDS", 0) - _install_main_fakes( - monkeypatch, - argv=["hello"], - client=client, - sse_chunks=None, - ) - - with pytest.raises(SystemExit) as exc_info: - chat_cli.main() - - assert exc_info.value.code == 1 - captured = capsys.readouterr() - assert captured.out == "" - assert captured.err == f"{chat_cli.LOST_CONTACT_MESSAGE}\n" - - -def test_main_stream_end_no_terminal_lost_contact(monkeypatch, capsys) -> None: - client = FakeClient([_post_success(), {"latest_sol_message": None}]) - monkeypatch.setattr(chat_cli, "POLL_SECONDS", 0.01) - monkeypatch.setattr(chat_cli, "IDLE_CEILING_SECONDS", 0) - _install_main_fakes( - monkeypatch, - argv=["hello"], - client=client, - sse_chunks=[], - ) - - with pytest.raises(SystemExit) as exc_info: - chat_cli.main() - - assert exc_info.value.code == 1 - captured = capsys.readouterr() - assert captured.out == "" - assert captured.err == f"{chat_cli.LOST_CONTACT_MESSAGE}\n" - - -def test_main_reader_thread_no_stray_progress_after_done(monkeypatch, capsys) -> None: - client = FakeClient([_post_success()]) - _install_main_fakes( - monkeypatch, - argv=["hello"], - client=client, - sse_chunks=[ - _frame( - { - "tract": "cortex", - "event": "finish", - "chat_proxy": True, - "use_id": USE_ID, - "result": "Final", - } - ), - _frame( - { - "tract": "cortex", - "event": "tool_start", - "chat_proxy": True, - "use_id": USE_ID, - "tool": "late_tool", - } - ), - ], - ) - - chat_cli.main() - - captured = capsys.readouterr() - assert captured.out == "Final\n" - assert captured.err == "" - - -def test_main_empty_result_is_error(monkeypatch, capsys) -> None: - client = FakeClient([_post_success()]) - _install_main_fakes( - monkeypatch, - argv=["hello"], - client=client, - sse_chunks=[ - _frame( - { - "tract": "cortex", - "event": "finish", - "chat_proxy": True, - "use_id": USE_ID, - "result": " ", - } - ) - ], - ) - - with pytest.raises(SystemExit) as exc_info: - chat_cli.main() - - assert exc_info.value.code == 1 - captured = capsys.readouterr() - assert captured.out == "" - assert captured.err == f"{chat_cli.EMPTY_ANSWER_MESSAGE}\n" - - -def test_main_queued_response_prints_busy_line(monkeypatch, capsys) -> None: - client = FakeClient([_post_success(queued=True)]) - _install_main_fakes( - monkeypatch, - argv=["hello"], - client=client, - sse_chunks=[ - _frame( - { - "tract": "cortex", - "event": "finish", - "chat_proxy": True, - "use_id": USE_ID, - "result": "Queued answer", - } - ) - ], - ) - - chat_cli.main() - - captured = capsys.readouterr() - assert captured.out == "Queued answer\n" - assert captured.err == f"{chat_cli.QUEUED_MESSAGE}\n" - - -def test_main_post_error_mapping(monkeypatch, capsys) -> None: - cases = [ - ( - ConveyUnreachableError("down"), - f"{chat_cli.SERVICE_DOWN_MESSAGE}\n", - ), - ( - ConveyClientError( - "Missing required field.", - detail="message is required", - status=400, - ), - "sol: Missing required field.\nsol: message is required\n", - ), - ( - ConveyClientError("malformed", status=200), - f"sol: {chat_cli.MALFORMED_RESPONSE_MESSAGE}\n", - ), - ( - {}, - f"sol: {chat_cli.MALFORMED_RESPONSE_MESSAGE}\n", - ), - ] - - for response, expected_err in cases: - client = FakeClient([response]) - _install_main_fakes( - monkeypatch, - argv=["hello"], - client=client, - sse_chunks=[], - ) - - with pytest.raises(SystemExit) as exc_info: - chat_cli.main() - - assert exc_info.value.code == 1 - captured = capsys.readouterr() - assert captured.out == "" - assert captured.err == expected_err - - -def test_main_rejects_removed_provider_and_talent_options(monkeypatch) -> None: - monkeypatch.setattr(sys, "argv", ["sol chat", "--provider", "google", "hello"]) - with pytest.raises(SystemExit) as provider_exit: - chat_cli.main() - - monkeypatch.setattr(sys, "argv", ["sol chat", "--talent", "chat", "hello"]) - with pytest.raises(SystemExit) as talent_exit: - chat_cli.main() - - assert provider_exit.value.code == 2 - assert talent_exit.value.code == 2 diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 4287dcee9..c70143ec4 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -131,7 +131,7 @@ class TestSolImportable: assert result.status == "ok" assert ( result.detail - == "from solstone.think.sol_cli import main succeeded outside repo cwd" + == "from solstone.think.sol_compat_cli import main succeeded outside repo cwd" ) def test_fail_on_module_not_found(self, doctor, monkeypatch, tmp_path): @@ -1309,11 +1309,18 @@ def test_journal_readiness_battery_does_not_import_host_or_inference_modules(): assert "LEAKED" not in result.stdout -def test_sol_doctor_subprocess_json_shape(): - """End-to-end: `sol doctor --json` via the venv entry point produces valid diagnostic JSON.""" +def test_journal_doctor_readiness_subprocess_json_shape(): + """End-to-end: journal readiness doctor produces valid diagnostic JSON.""" repo_root = Path(__file__).resolve().parent.parent result = subprocess.run( - [sys.executable, "-m", "solstone.think.sol_cli", "doctor", "--json"], + [ + sys.executable, + "-m", + "solstone.think.sol_cli", + "doctor", + "--readiness", + "--json", + ], capture_output=True, text=True, cwd=repo_root, @@ -1328,15 +1335,10 @@ def test_sol_doctor_subprocess_json_shape(): payload = json.loads(result.stdout) assert "checks" in payload and isinstance(payload["checks"], list) assert "summary" in payload and isinstance(payload["summary"], dict) + from solstone.think import doctor as doctor_module + assert {check["name"] for check in payload["checks"]} == { - "python_version", - "sol_importable", - "journal_leaf_exclusivity", - "journal_package_version", - "retired_host_shim", - "local_bin_sol_reachable", - "stale_alias_symlink", - "skill_state", + check.name for check, _runner in doctor_module.JOURNAL_READINESS_CHECKS } @@ -1350,7 +1352,14 @@ def test_doctor_runs_with_minimal_path_env(tmp_path): "SOLSTONE_JOURNAL": str(journal), } result = subprocess.run( - [sys.executable, "-m", "solstone.think.sol_cli", "doctor", "--json"], + [ + sys.executable, + "-m", + "solstone.think.sol_cli", + "doctor", + "--readiness", + "--json", + ], env=env, capture_output=True, text=True, @@ -1361,20 +1370,12 @@ def test_doctor_runs_with_minimal_path_env(tmp_path): f"stdout={result.stdout}\nstderr={result.stderr}" ) payload = json.loads(result.stdout) + from solstone.think import doctor as doctor_module + names = {check["name"] for check in payload["checks"]} assert names == { - "python_version", - "sol_importable", - "journal_leaf_exclusivity", - "journal_package_version", - "retired_host_shim", - "local_bin_sol_reachable", - "stale_alias_symlink", - "skill_state", + check.name for check, _runner in doctor_module.JOURNAL_READINESS_CHECKS } assert not any( - name.startswith("service_") - or name == "journal_sync" - or name.startswith("feature:") - for name in names + name.startswith("service_") or name == "journal_sync" for name in names ) diff --git a/tests/test_entities_call_parity.py b/tests/test_entities_call_parity.py deleted file mode 100644 index d3e4b28e3..000000000 --- a/tests/test_entities_call_parity.py +++ /dev/null @@ -1,1313 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""HTTP-client parity tests for ``sol call entities``.""" - -from __future__ import annotations - -import json -import os -from pathlib import Path - -import pytest -from typer.testing import CliRunner - -from solstone.apps.entities.call import app -from solstone.convey.reasons import ENTITY_BUSY -from solstone.think.convey_client import ConveyClient -from solstone.think.entities import ( - block_journal_entity, - detach_facet_entity, - entity_slug, - load_facet_relationship, - save_entities, -) -from solstone.think.entities.errors import EntityNotFoundError -from solstone.think.entities.journal import load_journal_entity, save_journal_entity -from solstone.think.entities.observations import add_observation, save_observations -from solstone.think.entities.review_candidates import save_candidates -from solstone.think.journal_io import LockTimeout -from tests._baseline_harness import make_test_client - -runner = CliRunner() - - -@pytest.fixture(autouse=True) -def _entities_client(monkeypatch: pytest.MonkeyPatch) -> None: - def client() -> ConveyClient: - journal = Path(os.environ["SOLSTONE_JOURNAL"]) - return ConveyClient( - session=make_test_client(journal), - base_url="", - ) - - monkeypatch.setattr("solstone.apps.entities.call.get_client", client) - monkeypatch.setenv("SOL_SKIP_SUPERVISOR_CHECK", "1") - - -@pytest.fixture -def entity_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - _reset_journal_cache() - for facet in ["personal", "work", "other"]: - _ensure_facet(tmp_path, facet) - _ensure_config(tmp_path) - - def create( - attached: list[dict] | None = None, - detected: list[dict] | None = None, - day: str | None = None, - facet: str = "personal", - observations: list[str] | None = None, - observation_entity: str | None = None, - ) -> Path: - _ensure_facet(tmp_path, facet) - if attached: - save_entities(facet, attached, day=None) - if detected and day: - save_entities(facet, detected, day=day) - if observations and observation_entity: - for index, content in enumerate(observations, 1): - add_observation(facet, observation_entity, content, str(index)) - return tmp_path - - yield create - _reset_journal_cache() - - -@pytest.fixture -def entity_move_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - _reset_journal_cache() - _ensure_config(tmp_path) - - def create( - entity_name: str = "Alice Johnson", - src_facet: str = "work", - dst_facet: str = "personal", - src_observations: list[dict] | None = None, - dst_observations: list[dict] | None = None, - create_dst_entity: bool = False, - ) -> tuple[Path, str, str, str]: - _ensure_facet(tmp_path, src_facet) - _ensure_facet(tmp_path, dst_facet) - entity = { - "type": "Person", - "name": entity_name, - "description": "Friend", - "attached_at": 1000, - "updated_at": 1000, - } - save_entities(src_facet, [entity], day=None) - if src_observations: - save_observations(src_facet, entity_name, src_observations) - if create_dst_entity: - save_entities(dst_facet, [entity], day=None) - if dst_observations: - save_observations(dst_facet, entity_name, dst_observations) - return tmp_path, src_facet, dst_facet, entity_name - - yield create - _reset_journal_cache() - - -def _reset_journal_cache() -> None: - import solstone.think.utils as think_utils - - think_utils._journal_path_cache = None - - -def _ensure_config(journal: Path) -> None: - config_dir = journal / "config" - config_dir.mkdir(parents=True, exist_ok=True) - (config_dir / "journal.json").write_text( - json.dumps( - { - "setup": {"completed_at": 1700000000000}, - } - ) - + "\n", - encoding="utf-8", - ) - - -def _ensure_facet(journal: Path, facet: str) -> None: - facet_dir = journal / "facets" / facet - facet_dir.mkdir(parents=True, exist_ok=True) - (facet_dir / "facet.json").write_text( - json.dumps({"title": facet.title(), "description": f"{facet} facet"}) + "\n", - encoding="utf-8", - ) - - -def _candidate_rows(journal: Path) -> list[dict]: - path = journal / "entities" / "review-candidates.jsonl" - if not path.exists(): - return [] - return [ - json.loads(line) - for line in path.read_text(encoding="utf-8").splitlines() - if line.strip() - ] - - -def _seed_merge_entities() -> None: - save_journal_entity( - { - "id": "kognova_inc", - "name": "Kognova Inc", - "type": "Company", - "aka": ["Kognova Incorporated"], - } - ) - save_journal_entity( - { - "id": "kognova", - "name": "Kognova", - "type": "Company", - "aka": [], - } - ) - - -def _seed_merge_candidate(status: str = "open") -> None: - save_candidates( - [ - { - "facet": "work", - "source": "Kognova Inc", - "source_slug": "kognova_inc", - "target": "Kognova", - "target_slug": "kognova", - "status": status, - "evidence": { - "basis": "name-variant", - "summary": "Kognova Inc / Kognova", - "detection_count": 4, - "needs": 0, - }, - } - ] - ) - - -def _busy(journal: Path) -> LockTimeout: - return LockTimeout(journal / "busy.lock", 0.01) - - -def _mark_blocked_without_detach(name: str) -> None: - blocked_entity = load_journal_entity(entity_slug(name)) - assert blocked_entity is not None - blocked_entity["blocked"] = True - save_journal_entity(blocked_entity) - - -def test_list_attached_detected_empty_and_env_byte_exact( - entity_env, monkeypatch: pytest.MonkeyPatch -) -> None: - entity_env( - attached=[ - { - "type": "Person", - "name": "Alice Johnson", - "description": "Friend", - "attached_at": 1000, - "updated_at": 1000, - }, - { - "type": "Company", - "name": "Acme Corp", - "description": "Client", - "attached_at": 1001, - "updated_at": 1001, - }, - ], - detected=[ - {"type": "Person", "name": "Alice", "description": "Met at conference"}, - {"type": "Tool", "name": "pytest", "description": "Testing tool"}, - ], - day="20240101", - ) - - attached = runner.invoke(app, ["list", "personal"]) - detected = runner.invoke(app, ["list", "personal", "--day", "20240101"]) - - assert attached.exit_code == 0 - assert attached.stdout == ( - "2 attached entities:\n" - " - Acme Corp (Company): Client\n" - " - Alice Johnson (Person): Friend\n" - ) - assert attached.stderr == "" - assert detected.exit_code == 0 - assert detected.stdout == ( - "2 detected for 20240101 entities:\n" - " - Alice (Person): Met at conference\n" - " - pytest (Tool): Testing tool\n" - ) - - entity_env(facet="other") - empty = runner.invoke(app, ["list", "other"]) - assert empty.exit_code == 0 - assert empty.stdout == "No entities found.\n" - - monkeypatch.setenv("SOL_FACET", "personal") - env_result = runner.invoke(app, ["list"]) - assert env_result.exit_code == 0 - assert "Alice Johnson" in env_result.stdout - - -def test_missing_sol_env_errors_byte_exact(entity_env, monkeypatch) -> None: - entity_env() - monkeypatch.delenv("SOL_FACET", raising=False) - monkeypatch.delenv("SOL_DAY", raising=False) - - list_result = runner.invoke(app, ["list"]) - detect_result = runner.invoke( - app, - ["detect", "Person", "Alice", "Met", "-f", "personal"], - ) - - assert list_result.exit_code == 1 - assert list_result.stderr == ( - "Error: facet is required (pass as argument or set SOL_FACET).\n" - ) - assert detect_result.exit_code == 1 - assert detect_result.stderr == ( - "Error: day is required (pass as argument or set SOL_DAY).\n" - ) - - -def test_detect_success_duplicate_invalid_blocked_busy_and_env_byte_exact( - entity_env, - monkeypatch: pytest.MonkeyPatch, -) -> None: - journal = entity_env() - result = runner.invoke( - app, - [ - "detect", - "Person", - "Alice", - "Met at conference", - "--facet", - "personal", - "--day", - "20240101", - ], - ) - duplicate = runner.invoke( - app, - [ - "detect", - "Person", - "Alice", - "Second", - "--facet", - "personal", - "--day", - "20240101", - ], - ) - invalid = runner.invoke( - app, - ["detect", "AB", "Alice", "Met", "--facet", "personal", "--day", "20240101"], - ) - - assert result.exit_code == 0 - assert result.stdout == "Entity 'Alice' detected for 20240101.\n" - assert duplicate.exit_code == 1 - assert duplicate.stderr == "Error: Entity 'Alice' already detected for 20240101\n" - assert invalid.exit_code == 1 - assert invalid.stderr == "Error: Invalid entity type 'AB'.\n" - - monkeypatch.setenv("SOL_DAY", "20240102") - monkeypatch.setenv("SOL_FACET", "personal") - env_result = runner.invoke(app, ["detect", "Person", "Bob", "Met at party"]) - assert env_result.exit_code == 0 - assert env_result.stdout == "Entity 'Bob' detected for 20240102.\n" - - override_result = runner.invoke( - app, - [ - "detect", - "Person", - "Carol", - "Met at lunch", - "--day", - "20240105", - ], - ) - assert override_result.exit_code == 0 - assert override_result.stdout == "Entity 'Carol' detected for 20240105.\n" - - entity_env( - attached=[ - {"type": "Person", "name": "Blocked Person", "description": "Blocked"} - ] - ) - blocked_entity = load_journal_entity(entity_slug("Blocked Person")) - assert blocked_entity is not None - blocked_entity["blocked"] = True - save_journal_entity(blocked_entity) - blocked = runner.invoke( - app, - [ - "detect", - "Person", - "Blocked Person", - "Met", - "--facet", - "personal", - "--day", - "20240103", - ], - ) - assert blocked.exit_code == 1 - assert blocked.stderr == "Error: Entity 'Blocked Person' is blocked.\n" - - def raise_busy(*args, **kwargs): - raise _busy(journal) - - monkeypatch.setattr( - "solstone.apps.entities.routes.save_detected_entity", raise_busy - ) - busy = runner.invoke( - app, - [ - "detect", - "Person", - "Charlie", - "Met", - "--facet", - "personal", - "--day", - "20240104", - ], - ) - assert busy.exit_code == 1 - assert busy.stderr == ENTITY_BUSY.message + "\n" - - -def test_attach_new_existing_detached_invalid_blocked_busy_byte_exact( - entity_env, - monkeypatch: pytest.MonkeyPatch, -) -> None: - journal = entity_env() - new_result = runner.invoke( - app, - ["attach", "Person", "Alice Johnson", "Friend", "-f", "personal"], - ) - existing = runner.invoke( - app, - ["attach", "Person", "Alice Johnson", "Friend", "-f", "personal"], - ) - invalid = runner.invoke( - app, - ["attach", "AB", "Someone", "Friend", "-f", "personal"], - ) - - assert new_result.exit_code == 0 - assert new_result.stdout == "Entity 'Alice Johnson' attached.\n" - assert existing.exit_code == 0 - assert existing.stdout == "Entity 'Alice Johnson' already attached.\n" - assert invalid.exit_code == 1 - assert invalid.stderr == "Error: Invalid entity type 'AB'.\n" - - detach_facet_entity("personal", "alice_johnson") - reattached = runner.invoke( - app, - ["attach", "Person", "Alice Johnson", "Friend again", "-f", "personal"], - ) - relationship = load_facet_relationship("personal", "alice_johnson") - assert reattached.exit_code == 0 - assert reattached.stdout == "Entity 'Alice Johnson' attached.\n" - assert relationship is not None - assert "detached" not in relationship - assert relationship["description"] == "Friend again" - - entity_env(attached=[{"type": "Person", "name": "Blocked", "description": ""}]) - block_journal_entity(entity_slug("Blocked")) - blocked = runner.invoke( - app, - ["attach", "Person", "Blocked", "Friend", "-f", "personal"], - ) - assert blocked.exit_code == 1 - assert blocked.stderr == "Error: Entity 'Blocked' is blocked.\n" - - def raise_not_found(*args, **kwargs): - raise EntityNotFoundError() - - monkeypatch.setattr( - "solstone.apps.entities.routes.attach_or_reactivate_entity", - raise_not_found, - ) - owner_missing = runner.invoke( - app, - ["attach", "Person", "Missing Owner", "Friend", "-f", "personal"], - ) - assert owner_missing.exit_code == 1 - assert owner_missing.stderr == "Error: Entity 'Missing Owner' not found.\n" - - def raise_busy(*args, **kwargs): - raise _busy(journal) - - monkeypatch.setattr( - "solstone.apps.entities.routes.attach_or_reactivate_entity", - raise_busy, - ) - busy = runner.invoke( - app, - ["attach", "Person", "Busy", "Friend", "-f", "personal"], - ) - assert busy.exit_code == 1 - assert busy.stderr == ENTITY_BUSY.message + "\n" - - -def test_update_attached_detected_and_resolve_errors_byte_exact( - entity_env, - monkeypatch: pytest.MonkeyPatch, -) -> None: - journal = entity_env( - attached=[ - { - "type": "Person", - "name": "Alice Johnson", - "description": "Old", - "attached_at": 1000, - "updated_at": 1000, - } - ], - detected=[{"type": "Person", "name": "Alice", "description": "Old"}], - day="20240101", - ) - - attached = runner.invoke( - app, - ["update", "Alice Johnson", "New description", "-f", "personal"], - ) - detected = runner.invoke( - app, - ["update", "Alice", "New desc", "-f", "personal", "--day", "20240101"], - ) - missing = runner.invoke( - app, - ["update", "Missing", "New description", "-f", "personal"], - ) - detected_missing = runner.invoke( - app, - ["update", "Missing", "New desc", "-f", "personal", "--day", "20240101"], - ) - missing_plain = runner.invoke( - app, - ["update", "Unknown", "New description", "-f", "other"], - ) - - assert attached.exit_code == 0 - assert attached.stdout == "Entity 'Alice Johnson' updated.\n" - assert detected.exit_code == 0 - assert detected.stdout == "Entity 'Alice' updated for 20240101.\n" - assert missing.exit_code == 1 - assert missing.stderr == ( - "Error: Entity 'Missing' not found. Did you mean: Alice Johnson\n" - ) - assert detected_missing.exit_code == 1 - assert detected_missing.stderr == "Error: Entity 'Missing' not found for 20240101\n" - assert missing_plain.exit_code == 1 - assert ( - missing_plain.stderr == "Error: Entity 'Unknown' not found in facet 'other'.\n" - ) - - def raise_busy(*args, **kwargs): - raise _busy(journal) - - monkeypatch.setattr( - "solstone.apps.entities.routes.update_detected_entity", - raise_busy, - ) - detected_busy = runner.invoke( - app, - ["update", "Alice", "New desc", "-f", "personal", "--day", "20240101"], - ) - assert detected_busy.exit_code == 1 - assert detected_busy.stderr == ENTITY_BUSY.message + "\n" - - entity_env( - attached=[ - {"type": "Person", "name": "Blocked Person", "description": "Blocked"} - ] - ) - _mark_blocked_without_detach("Blocked Person") - blocked = runner.invoke( - app, - ["update", "Blocked Person", "New description", "-f", "personal"], - ) - assert blocked.exit_code == 1 - assert blocked.stderr == "Error: Entity 'Blocked Person' is blocked.\n" - - -def test_move_success_merge_and_error_order_byte_exact( - entity_move_env, - monkeypatch: pytest.MonkeyPatch, -) -> None: - journal, src_facet, dst_facet, entity_name = entity_move_env() - slug = entity_slug(entity_name) - moved = runner.invoke( - app, - ["move", entity_name, "--from", src_facet, "--to", dst_facet], - ) - assert moved.exit_code == 0 - assert moved.stdout == ("Moved entity 'Alice Johnson' from 'work' to 'personal'.\n") - assert not (journal / "facets" / src_facet / "entities" / slug).exists() - assert (journal / "facets" / dst_facet / "entities" / slug).exists() - - journal, src_facet, dst_facet, entity_name = entity_move_env( - src_observations=[ - {"content": "Prefers async", "observed_at": 1000}, - {"content": "Uses Vim", "observed_at": 1001}, - ], - dst_observations=[ - {"content": "Prefers async", "observed_at": 1000}, - {"content": "Likes tea", "observed_at": 1002}, - ], - create_dst_entity=True, - ) - merge_result = runner.invoke( - app, - ["move", entity_name, "--from", src_facet, "--to", dst_facet, "--merge"], - ) - dst_obs_path = ( - journal / "facets" / dst_facet / "entities" / slug / "observations.jsonl" - ) - observations = [ - json.loads(line) - for line in dst_obs_path.read_text(encoding="utf-8").splitlines() - ] - assert merge_result.exit_code == 0 - assert not (journal / "facets" / src_facet / "entities" / slug).exists() - assert len(observations) == 3 - - entity_move_env(create_dst_entity=True) - exists = runner.invoke( - app, - ["move", entity_name, "--from", src_facet, "--to", dst_facet], - ) - missing_to = runner.invoke( - app, - ["move", entity_name, "--from", src_facet, "--to", "missing"], - ) - missing_from = runner.invoke( - app, - ["move", entity_name, "--from", "missing", "--to", dst_facet], - ) - missing_entity = runner.invoke( - app, - ["move", "Missing", "--from", src_facet, "--to", dst_facet], - ) - _ensure_facet(journal, "other") - missing_plain = runner.invoke( - app, - ["move", "Unknown", "--from", "other", "--to", dst_facet], - ) - entity_move_env(entity_name="Blocked Person") - _mark_blocked_without_detach("Blocked Person") - blocked = runner.invoke( - app, - ["move", "Blocked Person", "--from", src_facet, "--to", dst_facet], - ) - assert exists.exit_code == 1 - assert exists.stderr == ( - "Error: Entity already exists in destination facet. Use --merge to merge.\n" - ) - assert missing_from.exit_code == 1 - assert missing_from.stderr == "Error: Facet 'missing' (--from) does not exist.\n" - assert missing_to.exit_code == 1 - assert missing_to.stderr == "Error: Facet 'missing' (--to) does not exist.\n" - assert missing_entity.exit_code == 1 - assert missing_entity.stderr == ( - "Error: Entity 'Missing' not found. Did you mean: Alice Johnson\n" - ) - assert missing_plain.exit_code == 1 - assert ( - missing_plain.stderr == "Error: Entity 'Unknown' not found in facet 'other'.\n" - ) - assert blocked.exit_code == 1 - assert blocked.stderr == "Error: Entity 'Blocked Person' is blocked.\n" - - def raise_missing_dir(*args, **kwargs): - raise EntityNotFoundError() - - monkeypatch.setattr( - "solstone.apps.entities.routes.move_facet_entity", - raise_missing_dir, - ) - source_dir_missing = runner.invoke( - app, - ["move", entity_name, "--from", src_facet, "--to", dst_facet, "--merge"], - ) - assert source_dir_missing.exit_code == 1 - assert source_dir_missing.stderr == ( - "Error: Entity data directory not found in source facet.\n" - ) - - -def test_aka_success_duplicate_first_word_conflict_and_busy_byte_exact( - entity_env, - monkeypatch: pytest.MonkeyPatch, -) -> None: - journal = entity_env( - attached=[ - { - "type": "Person", - "name": "Alice Johnson", - "description": "Friend", - "attached_at": 1000, - "updated_at": 1000, - "aka": ["Ali"], - }, - { - "type": "Person", - "name": "Bob Smith", - "description": "Neighbor", - "attached_at": 1001, - "updated_at": 1001, - }, - { - "type": "Person", - "name": "Michael Bauer", - "description": "Colleague", - "attached_at": 1002, - "updated_at": 1002, - }, - ] - ) - - added = runner.invoke(app, ["aka", "Alice Johnson", "Ally", "-f", "personal"]) - parenthetical = runner.invoke( - app, - [ - "aka", - "Michael Bauer", - "Michael Bauer (RadialNexus)", - "-f", - "personal", - ], - ) - duplicate = runner.invoke(app, ["aka", "Alice Johnson", "Ali", "-f", "personal"]) - first_word = runner.invoke( - app, - ["aka", "Alice Johnson", "Alice", "-f", "personal"], - ) - conflict = runner.invoke( - app, - ["aka", "Alice Johnson", "Bob Smith", "-f", "personal"], - ) - - assert added.exit_code == 0 - assert added.stdout == "Added alias 'Ally' to 'Alice Johnson'.\n" - assert parenthetical.exit_code == 0 - assert parenthetical.stdout == ( - "Added alias 'Michael Bauer (RadialNexus)' to 'Michael Bauer'.\n" - ) - assert duplicate.exit_code == 0 - assert duplicate.stdout == "Alias 'Ali' already exists for 'Alice Johnson'.\n" - assert first_word.exit_code == 0 - assert first_word.stdout == ( - "Alias 'Alice' is the first word of 'Alice Johnson' (skipped).\n" - ) - assert conflict.exit_code == 1 - assert conflict.stderr == ( - "Error: Alias 'Bob Smith' conflicts with entity 'Bob Smith'.\n" - ) - - missing_plain = runner.invoke(app, ["aka", "Missing", "Miss", "-f", "other"]) - assert missing_plain.exit_code == 1 - assert ( - missing_plain.stderr == "Error: Entity 'Missing' not found in facet 'other'.\n" - ) - - entity_env( - facet="other", - attached=[ - {"type": "Person", "name": "Blocked Person", "description": "Blocked"} - ], - ) - _mark_blocked_without_detach("Blocked Person") - blocked = runner.invoke(app, ["aka", "Blocked Person", "BP", "-f", "other"]) - assert blocked.exit_code == 1 - assert blocked.stderr == "Error: Entity 'Blocked Person' is blocked.\n" - - def raise_not_found(*args, **kwargs): - raise EntityNotFoundError() - - monkeypatch.setattr("solstone.apps.entities.routes.add_entity_aka", raise_not_found) - owner_missing = runner.invoke( - app, - ["aka", "Alice Johnson", "Owner Missing", "-f", "personal"], - ) - assert owner_missing.exit_code == 1 - assert owner_missing.stderr == "Error: Entity 'Alice Johnson' not found.\n" - - def raise_busy(*args, **kwargs): - raise _busy(journal) - - monkeypatch.setattr("solstone.apps.entities.routes.add_entity_aka", raise_busy) - busy = runner.invoke(app, ["aka", "Alice Johnson", "Busy", "-f", "personal"]) - assert busy.exit_code == 1 - assert busy.stderr == ENTITY_BUSY.message + "\n" - - -def test_update_owner_errors_and_observe_busy_byte_exact( - entity_env, - monkeypatch: pytest.MonkeyPatch, -) -> None: - journal = entity_env( - attached=[ - { - "type": "Person", - "name": "Alice Johnson", - "description": "Friend", - "attached_at": 1000, - "updated_at": 1000, - } - ] - ) - - def raise_not_found(*args, **kwargs): - raise EntityNotFoundError() - - monkeypatch.setattr( - "solstone.apps.entities.routes.update_facet_entity_description", - raise_not_found, - ) - update_missing = runner.invoke( - app, - ["update", "Alice Johnson", "New description", "-f", "personal"], - ) - assert update_missing.exit_code == 1 - assert update_missing.stderr == "Error: Entity 'Alice Johnson' not found.\n" - - def raise_busy(*args, **kwargs): - raise _busy(journal) - - monkeypatch.setattr( - "solstone.apps.entities.routes.update_facet_entity_description", - raise_busy, - ) - update_busy = runner.invoke( - app, - ["update", "Alice Johnson", "New description", "-f", "personal"], - ) - assert update_busy.exit_code == 1 - assert update_busy.stderr == ENTITY_BUSY.message + "\n" - - empty_observations = runner.invoke( - app, - ["observations", "Alice Johnson", "-f", "personal"], - ) - assert empty_observations.exit_code == 0 - assert empty_observations.stdout == "No observations for 'Alice Johnson'.\n" - - monkeypatch.setattr("solstone.apps.entities.routes.add_observation", raise_busy) - observe_busy = runner.invoke( - app, - ["observe", "Alice Johnson", "Likes coffee", "-f", "personal"], - ) - assert observe_busy.exit_code == 1 - assert observe_busy.stderr == ENTITY_BUSY.message + "\n" - - -def test_observations_and_observe_byte_exact(entity_env) -> None: - entity_env( - attached=[ - { - "type": "Person", - "name": "Alice Johnson", - "description": "Friend", - "attached_at": 1000, - "updated_at": 1000, - } - ], - observations=["Likes coffee", "Expert in Python"], - observation_entity="Alice Johnson", - ) - - listed = runner.invoke(app, ["observations", "Alice Johnson", "-f", "personal"]) - observed = runner.invoke( - app, - ["observe", "Alice Johnson", "Prefers morning", "-f", "personal"], - ) - empty_content = runner.invoke( - app, - ["observe", "Alice Johnson", " ", "-f", "personal"], - ) - missing = runner.invoke( - app, ["observe", "Missing", "Likes coffee", "-f", "personal"] - ) - missing_observations = runner.invoke( - app, ["observations", "Missing", "-f", "other"] - ) - entity_env( - facet="other", - attached=[ - {"type": "Person", "name": "Blocked Person", "description": "Blocked"} - ], - ) - _mark_blocked_without_detach("Blocked Person") - blocked_observations = runner.invoke( - app, ["observations", "Blocked Person", "-f", "other"] - ) - blocked_observe = runner.invoke( - app, ["observe", "Blocked Person", "Likes coffee", "-f", "other"] - ) - - assert listed.exit_code == 0 - assert listed.stdout == ( - "2 observations for 'Alice Johnson':\n" - " 1. Likes coffee\n" - " 2. Expert in Python\n" - ) - assert observed.exit_code == 0 - assert observed.stdout == "Observation added to 'Alice Johnson'.\n" - assert empty_content.exit_code == 1 - assert empty_content.stderr == "Error: Observation content cannot be empty\n" - assert missing.exit_code == 1 - assert missing.stderr == ( - "Error: Entity 'Missing' not found. Did you mean: Alice Johnson\n" - ) - assert missing_observations.exit_code == 1 - assert missing_observations.stderr == ( - "Error: Entity 'Missing' not found in facet 'other'.\n" - ) - assert blocked_observations.exit_code == 1 - assert blocked_observations.stderr == "Error: Entity 'Blocked Person' is blocked.\n" - assert blocked_observe.exit_code == 1 - assert blocked_observe.stderr == "Error: Entity 'Blocked Person' is blocked.\n" - - -def test_record_merge_candidate_and_json_semantics( - entity_env, - monkeypatch: pytest.MonkeyPatch, -) -> None: - journal = entity_env() - created = runner.invoke( - app, - [ - "record-merge-candidate", - "Kognova Inc", - "Kognova", - "--facet", - "work", - "--day", - "20260602", - "--evidence", - "Kognova Inc / Kognova - needs 1 more", - "--detections", - "3", - "--needs", - "1", - ], - ) - updated = runner.invoke( - app, - [ - "record-merge-candidate", - "Kognova Inc", - "Kognova", - "--facet", - "work", - "--day", - "20260603", - "--evidence", - "new summary", - "--detections", - "4", - ], - ) - json_result = runner.invoke( - app, - [ - "record-merge-candidate", - "Newco Inc", - "Newco", - "--facet", - "work", - "--day", - "20260602", - "--evidence", - "json row", - "--json", - ], - ) - same_slug = runner.invoke( - app, - [ - "record-merge-candidate", - "Kognova", - "kognova", - "--facet", - "work", - "--day", - "20260602", - "--evidence", - "same", - ], - ) - - rows = _candidate_rows(journal) - assert created.exit_code == 0 - assert created.stdout == "merge candidate recorded: Kognova Inc -> Kognova\n" - assert updated.exit_code == 0 - assert updated.stdout == ( - "merge candidate updated: Kognova Inc -> Kognova (status: open)\n" - ) - assert rows[0]["last_surfaced"] == "20260603" - assert rows[0]["evidence"]["detection_count"] == 4 - assert json_result.exit_code == 0 - parsed = json.loads(json_result.stdout) - assert parsed["source"] == "Newco Inc" - assert parsed["target"] == "Newco" - assert same_slug.exit_code == 1 - assert same_slug.stderr == "Error: source and target resolve to the same entity.\n" - - preserved_row = { - "facet": "work", - "source": "Preserve Inc", - "source_slug": "preserve_inc", - "target": "Preserve", - "target_slug": "preserve", - "status": "dismissed", - "custom": "keep-me", - "evidence": { - "basis": "old-basis", - "summary": "old summary", - "detection_count": 1, - "needs": 2, - }, - } - save_candidates(_candidate_rows(journal) + [preserved_row]) - preserved = runner.invoke( - app, - [ - "record-merge-candidate", - "Preserve Inc", - "Preserve", - "--facet", - "work", - "--day", - "20260604", - "--evidence", - "preserved", - "--detections", - "5", - ], - ) - preserved_rows = _candidate_rows(journal) - preserved_after = next( - row for row in preserved_rows if row["source_slug"] == "preserve_inc" - ) - assert preserved.exit_code == 0 - assert preserved.stdout == ( - "merge candidate updated: Preserve Inc -> Preserve (status: dismissed)\n" - ) - assert preserved_after["status"] == "dismissed" - assert preserved_after["custom"] == "keep-me" - assert preserved_after["evidence"]["summary"] == "preserved" - - def raise_busy(*args, **kwargs): - raise _busy(journal) - - monkeypatch.setattr( - "solstone.apps.entities.routes.record_entity_merge_candidate", - raise_busy, - ) - busy = runner.invoke( - app, - [ - "record-merge-candidate", - "Busy Inc", - "Busy", - "--facet", - "work", - "--day", - "20260602", - "--evidence", - "busy", - ], - ) - assert busy.exit_code == 1 - assert busy.stderr == ENTITY_BUSY.message + "\n" - - -def test_merge_candidates_json_semantic_and_text_byte_exact(entity_env) -> None: - entity_env() - empty = runner.invoke(app, ["merge-candidates"]) - _seed_merge_candidate() - - json_result = runner.invoke(app, ["merge-candidates", "--json"]) - filtered = runner.invoke(app, ["merge-candidates", "--facet", "other", "--json"]) - status_filtered = runner.invoke( - app, ["merge-candidates", "--status", "open", "--json"] - ) - text = runner.invoke(app, ["merge-candidates"]) - - assert empty.exit_code == 0 - assert empty.stdout == "No merge candidates found.\n" - assert json_result.exit_code == 0 - assert json.loads(json_result.stdout)[0]["source"] == "Kognova Inc" - assert filtered.exit_code == 0 - assert json.loads(filtered.stdout) == [] - assert status_filtered.exit_code == 0 - assert json.loads(status_filtered.stdout)[0]["status"] == "open" - assert text.exit_code == 0 - assert text.stdout == ( - "Kognova Inc -> Kognova [open] facet=work detections=4 needs=0 last=\n" - ) - - -def test_accept_and_dismiss_merge_candidate_statuses(entity_env) -> None: - journal = entity_env() - _seed_merge_entities() - _seed_merge_candidate() - - preview = runner.invoke( - app, - ["accept-merge-candidate", "kognova_inc", "kognova", "--facet", "work"], - ) - accepted = runner.invoke( - app, - [ - "accept-merge-candidate", - "kognova_inc", - "kognova", - "--facet", - "work", - "--commit", - ], - ) - accepted_again = runner.invoke( - app, - [ - "accept-merge-candidate", - "kognova_inc", - "kognova", - "--facet", - "work", - "--commit", - ], - ) - - assert preview.exit_code == 0 - assert preview.stdout == ( - "Merge preview:\n" - " aliases added: Kognova Inc, Kognova Incorporated\n" - " emails added: 0\n" - " facet links: 0 moved, 0 merged\n" - " observations moved: 0\n" - " speaker labels updated: 0 labels, 0 corrections\n" - " voice samples moved: 0 added, 0 total\n" - ) - assert accepted.exit_code == 0 - assert accepted.stdout.startswith( - "Accepted merge candidate: kognova_inc -> kognova\n" - "Undo with: sol call entities undo-merge em_" - ) - assert accepted.stdout.endswith(" --yes\n") - assert accepted_again.exit_code == 0 - assert accepted_again.stdout.startswith( - "Merge candidate already accepted: kognova_inc -> kognova\n" - "Undo with: sol call entities undo-merge em_" - ) - assert accepted_again.stdout.endswith(" --yes\n") - assert _candidate_rows(journal)[0]["status"] == "accepted" - - _seed_merge_candidate() - dismissed = runner.invoke( - app, - ["dismiss-merge-candidate", "kognova_inc", "kognova", "--facet", "work"], - ) - dismissed_again = runner.invoke( - app, - ["dismiss-merge-candidate", "kognova_inc", "kognova", "--facet", "work"], - ) - assert dismissed.exit_code == 0 - assert dismissed.stdout == "Dismissed merge candidate: kognova_inc -> kognova\n" - assert dismissed_again.exit_code == 0 - assert dismissed_again.stdout == ( - "Merge candidate already dismissed: kognova_inc -> kognova\n" - ) - - -def test_merge_candidate_domain_errors_and_busy_byte_exact( - entity_env, - monkeypatch: pytest.MonkeyPatch, -) -> None: - journal = entity_env() - - accept_missing = runner.invoke( - app, - ["accept-merge-candidate", "kognova_inc", "kognova", "--facet", "work"], - ) - dismiss_missing = runner.invoke( - app, - ["dismiss-merge-candidate", "kognova_inc", "kognova", "--facet", "work"], - ) - assert accept_missing.exit_code == 1 - assert accept_missing.stderr == "Error: candidate not found\n" - assert dismiss_missing.exit_code == 1 - assert dismiss_missing.stderr == "Error: candidate not found\n" - - _seed_merge_candidate(status="accepted") - preview_accepted = runner.invoke( - app, - ["accept-merge-candidate", "kognova_inc", "kognova", "--facet", "work"], - ) - assert preview_accepted.exit_code == 1 - assert preview_accepted.stderr == ( - "Error: cannot preview candidate with status accepted\n" - ) - - def raise_busy(*args, **kwargs): - raise _busy(journal) - - monkeypatch.setattr( - "solstone.apps.entities.routes.accept_entity_candidate", - raise_busy, - ) - accept_busy = runner.invoke( - app, - [ - "accept-merge-candidate", - "kognova_inc", - "kognova", - "--facet", - "work", - "--commit", - ], - ) - assert accept_busy.exit_code == 1 - assert accept_busy.stderr == ENTITY_BUSY.message + "\n" - - monkeypatch.setattr( - "solstone.apps.entities.routes.dismiss_entity_candidate", - raise_busy, - ) - dismiss_busy = runner.invoke( - app, - ["dismiss-merge-candidate", "kognova_inc", "kognova", "--facet", "work"], - ) - assert dismiss_busy.exit_code == 1 - assert dismiss_busy.stderr == ENTITY_BUSY.message + "\n" - - -def test_merge_json_semantics_and_search(entity_env) -> None: - entity_env( - attached=[ - { - "type": "Person", - "name": "Alice Johnson", - "description": "Friend", - "attached_at": 1000, - "updated_at": 1000, - } - ] - ) - _seed_merge_entities() - search = runner.invoke(app, ["search", "--type", "Person", "--limit", "1"]) - search_without_facets = runner.invoke( - app, ["search", "--type", "Company", "--limit", "1"] - ) - no_results = runner.invoke(app, ["search", "--query", "NoSuchName"]) - - save_journal_entity( - { - "id": "blocked_source", - "name": "Blocked Source", - "type": "Company", - "blocked": True, - } - ) - save_journal_entity( - { - "id": "first_principal", - "name": "First Principal", - "type": "Person", - "is_principal": True, - } - ) - save_journal_entity( - { - "id": "second_principal", - "name": "Second Principal", - "type": "Person", - "is_principal": True, - } - ) - save_journal_entity( - {"id": "cross_source", "name": "Cross Source", "type": "Company"} - ) - save_journal_entity( - {"id": "cross_target", "name": "Cross Target", "type": "Company"} - ) - save_journal_entity( - { - "id": "cross_watcher", - "name": "Cross Watcher", - "type": "Company", - "aka": ["cross_source"], - } - ) - - merge_dry_run = runner.invoke(app, ["merge", "kognova_inc", "kognova"]) - merge_error = runner.invoke(app, ["merge", "missing", "kognova", "--commit"]) - merge_missing_target = runner.invoke( - app, ["merge", "kognova", "missing_target", "--commit"] - ) - merge_same = runner.invoke(app, ["merge", "kognova", "kognova", "--commit"]) - merge_blocked = runner.invoke( - app, ["merge", "blocked_source", "kognova", "--commit"] - ) - merge_principal = runner.invoke( - app, ["merge", "first_principal", "second_principal", "--commit"] - ) - merge_aka_xref = runner.invoke( - app, ["merge", "cross_source", "cross_target", "--commit"] - ) - merge_commit = runner.invoke(app, ["merge", "kognova_inc", "kognova", "--commit"]) - - assert merge_dry_run.exit_code == 0 - assert json.loads(merge_dry_run.stdout)["merged"] is False - assert merge_error.exit_code == 1 - assert json.loads(merge_error.stderr) == { - "error": "Source entity not found: missing" - } - assert merge_missing_target.exit_code == 1 - assert json.loads(merge_missing_target.stderr) == { - "error": "Target entity not found: missing_target" - } - assert merge_same.exit_code == 1 - assert json.loads(merge_same.stderr) == { - "error": "Source and target must be different entities." - } - assert merge_blocked.exit_code == 1 - assert json.loads(merge_blocked.stderr) == { - "error": "Cannot merge blocked entity: blocked_source" - } - assert merge_principal.exit_code == 1 - assert json.loads(merge_principal.stderr) == { - "error": "Cannot merge two principal entities." - } - assert merge_aka_xref.exit_code == 1 - assert json.loads(merge_aka_xref.stderr) == { - "error": "Cannot merge 'cross_source': referenced in aka lists of entity ids: cross_watcher" - } - assert search.exit_code == 0 - assert search.stdout == ( - "1 entities:\n - Alice Johnson (Person): Friend\n facets: personal\n" - ) - assert search_without_facets.exit_code == 0 - assert search_without_facets.stdout == "1 entities:\n - Kognova (Company): \n" - assert no_results.exit_code == 0 - assert no_results.stdout == "No entities found.\n" - assert merge_commit.exit_code == 0 - assert json.loads(merge_commit.stdout)["merged"] is True diff --git a/tests/test_health_call_parity.py b/tests/test_health_call_parity.py deleted file mode 100644 index 7d0635a41..000000000 --- a/tests/test_health_call_parity.py +++ /dev/null @@ -1,244 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -from __future__ import annotations - -import json -from urllib.parse import urlsplit - -import pytest -import requests -from typer.testing import CliRunner - -from solstone.think.convey_client import ConveyClient -from solstone.think.processing import DRAIN_STATE_REALTIME -from solstone.think.surfaces import health as health_surface -from solstone.think.tools.health import app -from tests._baseline_harness import make_test_client, mark_setup_complete -from tests.test_surfaces_health import ( - _brain_health_payload, - _minimal_facet_tree, - _segment_backlog, - _utc_dt, -) - - -@pytest.fixture -def journal(tmp_path, monkeypatch): - monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - mark_setup_complete(tmp_path) - return tmp_path - - -@pytest.fixture -def runner(journal, monkeypatch): - client = ConveyClient(session=make_test_client(journal), base_url="") - monkeypatch.setattr("solstone.think.tools.health.get_client", lambda: client) - return CliRunner() - - -class _Response: - def __init__(self, body: object, status_code: int = 200) -> None: - self.status_code = status_code - self.text = json.dumps(body) - - -class _TextResponse: - status_code = 200 - text = "not json" - - -class _CaptureSession: - def __init__(self, body: object) -> None: - self.body = body - self.urls: list[str] = [] - - def get(self, url: str): - parsed = urlsplit(url) - self.urls.append(parsed.path + (f"?{parsed.query}" if parsed.query else "")) - return _Response(self.body) - - -def _report() -> dict[str, object]: - return { - "generated_at": 1, - "range": ["20260410", "20260410"], - "facets": ["work"], - "capture_health": { - "hours_with_capture": 0, - "hours_total": 24, - "coverage_ratio": None, - "facets_with_recent_capture": [], - "facets_silent_24h": ["work"], - "last_segment_at": None, - }, - "synthesis_health": { - "activities_count": 0, - "activities_with_participation": 0, - "activities_with_story": 0, - "activities_user_edited": 0, - "activities_anticipated_unfilled": 0, - "talent_run_failures_24h": None, - "talent_degraded_outputs_24h": None, - "indexer_last_rebuild_at": None, - }, - "consumer_signal": { - "ledger_open_items_total": 0, - "ledger_stale_items_count": 0, - "profile_entities_total": 0, - }, - "segment_backlog": { - "not_thought": 0, - "days_with_backlog": 0, - "errors": [], - "not_sensed": 0, - "awaiting_analysis_text": None, - "last_drained_at": None, - "drain_state": DRAIN_STATE_REALTIME, - "display_powersave_detectable": False, - }, - "notes": [], - "brain_health": _brain_health_payload(), - } - - -def _pipeline_summary(day: str = "20260410") -> dict[str, object]: - return { - "day": day, - "generated_at": 123, - "status": "healthy", - "anomalies": [{"kind": "ordered", "error": "kept"}], - "runs": { - "segment": {"count": 1, "duration_ms_total": 20}, - "daily": {"count": 0, "duration_ms_total": 0}, - "activity": {"count": 2, "duration_ms_total": 30}, - }, - "talents": { - "dispatched": 3, - "completed": 2, - "failed": 1, - "failed_list": [{"name": "sense", "reason": "boom"}], - }, - "activities": { - "detected": 4, - "persisted": 3, - "talents_fired": True, - }, - "exhausted_segments": {"count": 0, "segments": []}, - } - - -def _freeze_health_surface(journal, monkeypatch) -> None: - monkeypatch.setattr(health_surface, "_resolve_now", lambda: _utc_dt("20260410")) - monkeypatch.setattr( - health_surface, - "_build_brain_health", - lambda: _brain_health_payload(), - ) - monkeypatch.setattr( - health_surface, - "read_segment_backlog", - lambda: _segment_backlog({}), - ) - _minimal_facet_tree(journal) - - -def test_health_request_mapping(monkeypatch): - session = _CaptureSession(_report()) - client = ConveyClient(session=session, base_url="") - monkeypatch.setattr("solstone.think.tools.health.get_client", lambda: client) - runner = CliRunner() - - summary = runner.invoke(app, ["summary", "--day", "20260410", "--json"]) - full = runner.invoke(app, ["full", "--day", "20260411", "--json"]) - range_result = runner.invoke( - app, - [ - "for-range", - "--day-from", - "20260404", - "--day-to", - "20260410", - "--json", - ], - ) - - assert summary.exit_code == 0 - assert full.exit_code == 0 - assert range_result.exit_code == 0 - assert session.urls == [ - "/api/health/summary?day=20260410", - "/api/health/full?day=20260411", - "/api/health/range?day_from=20260404&day_to=20260410", - ] - - -def test_pipeline_http_cutover_preserves_local_pretty_bytes(monkeypatch): - summary = _pipeline_summary() - session = _CaptureSession(summary) - client = ConveyClient(session=session, base_url="") - monkeypatch.setattr("solstone.think.tools.health.get_client", lambda: client) - - result = CliRunner().invoke(app, ["pipeline", "--day", "20260410"]) - - assert result.exit_code == 0 - assert result.stdout == json.dumps(summary, indent=2, sort_keys=False) + "\n" - assert session.urls == ["/api/health/pipeline?day=20260410"] - - -def test_health_null_fields_render_dash(runner, journal, monkeypatch): - _freeze_health_surface(journal, monkeypatch) - - result = runner.invoke(app, ["summary", "--day", "20260410"]) - - assert result.exit_code == 0 - assert " coverage_ratio: —\n" in result.stdout - assert " last_segment_at: —\n" in result.stdout - assert " talent_run_failures_24h: —\n" in result.stdout - assert "None" not in result.stdout - - -def test_health_range_validation_detail_to_stderr(runner): - result = runner.invoke(app, ["for-range", "--day-from", "20260404"]) - - assert result.exit_code == 1 - assert "both endpoints or neither" in result.stderr - assert result.stdout == "" - - -def test_health_malformed_response_exits_without_stdout(monkeypatch): - class BadSession: - def get(self, _url: str): - return _TextResponse() - - client = ConveyClient(session=BadSession(), base_url="") - monkeypatch.setattr("solstone.think.tools.health.get_client", lambda: client) - - result = CliRunner().invoke(app, ["summary", "--json"]) - - assert result.exit_code == 1 - assert "I couldn't read the journal response." in result.stderr - assert result.stdout == "" - - -def test_health_convey_down_prints_require_solstone_message(journal, monkeypatch): - class DownSession: - def get(self, _url): - raise requests.exceptions.ConnectionError() - - def post(self, _url, json=None): - raise requests.exceptions.ConnectionError() - - client = ConveyClient(session=DownSession(), base_url="http://localhost:5015") - monkeypatch.setattr("solstone.think.tools.health.get_client", lambda: client) - monkeypatch.delenv("SOL_SKIP_SUPERVISOR_CHECK", raising=False) - monkeypatch.delenv("SOL_SUPERVISOR_SPAWNED", raising=False) - - result = CliRunner().invoke(app, ["summary"]) - - assert result.exit_code == 1 - assert ( - result.stderr - == "sol: solstone isn't running. Start it with 'journal up' and retry.\n" - ) - assert result.stdout == "" diff --git a/tests/test_health_routes.py b/tests/test_health_routes.py index fdb5913b0..bf5a0fd9d 100644 --- a/tests/test_health_routes.py +++ b/tests/test_health_routes.py @@ -7,10 +7,10 @@ import json from solstone.convey import create_app from solstone.convey import health as convey_health +from solstone.think.brain_health import HEADLINES from solstone.think.surfaces import health as health_surface from tests._baseline_harness import make_test_client from tests.test_surfaces_health import ( - _brain_snapshot, _minimal_facet_tree, _segment_backlog, _utc_dt, @@ -42,6 +42,38 @@ def _configure_unset_journal(tmp_path, monkeypatch) -> None: monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) +def _brain_snapshot() -> dict[str, object]: + return { + "state": "ready", + "headline": HEADLINES["ready"], + "reason_code": None, + "reason_text": None, + "failing_component": None, + "action": None, + "identity": {"lane": "cloud", "provider": "google", "model": "gemini"}, + "evidence": { + "observed_at": "2026-04-10T12:00:00Z", + "age_seconds": 60, + "age_text": "1m", + }, + "components": { + "generate": { + "status": "ready", + "reason_code": None, + "reason_text": None, + "observed_at": "2026-04-10T12:00:00Z", + }, + "cogitate": { + "status": "ready", + "reason_code": None, + "reason_text": None, + "observed_at": "2026-04-10T12:00:00Z", + }, + }, + "progressing": False, + } + + def _freeze_health_surface(tmp_path, monkeypatch) -> None: monkeypatch.setattr(health_surface, "_resolve_now", lambda: _utc_dt("20260410")) monkeypatch.setattr( diff --git a/tests/test_import_call.py b/tests/test_import_call.py deleted file mode 100644 index 6b7031671..000000000 --- a/tests/test_import_call.py +++ /dev/null @@ -1,907 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -from __future__ import annotations - -import json -import os -from importlib import import_module -from pathlib import Path - -import pytest -import requests -from typer.testing import CliRunner - -import solstone.convey.state as convey_state -import solstone.think.utils as think_utils -from solstone.think.convey_client import ConveyClient -from solstone.think.entities.journal import ( - load_journal_entity, - save_journal_entity, -) -from solstone.think.entities.relationships import load_facet_relationship -from tests._baseline_harness import make_test_client, mark_setup_complete - -import_call = import_module("solstone.apps.import.call") -import_resolve = import_module("solstone.apps.import.resolve") -journal_sources = import_module("solstone.apps.import.journal_sources") - -app = import_call.app -create_state_directory = journal_sources.create_state_directory -generate_key = journal_sources.generate_key -get_state_directory = journal_sources.get_state_directory -save_journal_source = journal_sources.save_journal_source -runner = CliRunner() - -FROZEN_TS = "2026-04-14T00:00:00+00:00" - - -def _source(name="test-source", key=None, **overrides): - if key is None: - key = generate_key() - source = { - "name": name, - "key": key, - "created_at": 1000, - "enabled": True, - "revoked": False, - "revoked_at": None, - "stats": { - "segments_received": 0, - "entities_received": 0, - "facets_received": 0, - "imports_received": 0, - "config_received": 0, - }, - } - source.update(overrides) - return source - - -@pytest.fixture -def import_env(tmp_path, monkeypatch): - """Set up a temp journal with an import source and state directory.""" - - monkeypatch.setattr(convey_state, "journal_root", str(tmp_path), raising=False) - monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - monkeypatch.setenv("SOL_SKIP_SUPERVISOR_CHECK", "1") - think_utils._journal_path_cache = None - (tmp_path / "apps" / "import" / "journal_sources").mkdir( - parents=True, exist_ok=True - ) - (tmp_path / "config").mkdir(parents=True, exist_ok=True) - mark_setup_complete(tmp_path) - - key = generate_key() - source = _source(key=key) - save_journal_source(source) - key_prefix = key[:8] - create_state_directory(tmp_path, key_prefix) - monkeypatch.setattr(import_resolve, "_now_iso", lambda: FROZEN_TS) - - def _client() -> ConveyClient: - journal = Path(os.environ["SOLSTONE_JOURNAL"]) - return ConveyClient( - session=make_test_client(journal), - base_url="", - ) - - monkeypatch.setattr("solstone.apps.import.call.get_client", _client) - - return { - "root": tmp_path, - "key": key, - "key_prefix": key_prefix, - "source": source, - } - - -def _write_json(path: Path, data: dict) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text( - json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8" - ) - - -def _read_json(path: Path) -> dict: - return json.loads(path.read_text(encoding="utf-8")) - - -def _read_log(path: Path) -> list[dict]: - if not path.exists(): - return [] - return [ - json.loads(line) - for line in path.read_text(encoding="utf-8").splitlines() - if line.strip() - ] - - -def _write_entity_state(key_prefix: str, state: dict) -> None: - state_path = get_state_directory(key_prefix) / "entities" / "state.json" - _write_json(state_path, state) - - -def test_list_staged_empty_state(import_env): - result = runner.invoke(app, ["list-staged", "--source", "test-source"]) - - assert result.exit_code == 0 - assert result.stdout.strip() == "" - - -def test_list_staged_with_staged_entities(import_env): - staged_path = ( - get_state_directory(import_env["key_prefix"]) - / "entities" - / "staged" - / "test-entity.json" - ) - _write_json( - staged_path, - { - "source_entity": { - "id": "test-entity", - "name": "Test Entity", - "type": "Tool", - }, - "match_candidates": [ - {"id": "target-id", "name": "Target Entity", "tier": 8} - ], - "reason": "low_confidence_match", - "staged_at": "2026-04-14T00:00:00+00:00", - }, - ) - - result = runner.invoke( - app, - ["list-staged", "--source", "test-source", "--area", "entities"], - ) - - assert result.exit_code == 0 - lines = [json.loads(line) for line in result.stdout.splitlines() if line.strip()] - assert lines == [ - { - "area": "entities", - "source_id": "test-entity", - "reason": "low_confidence_match", - "source_entity": { - "id": "test-entity", - "name": "Test Entity", - "type": "Tool", - }, - "match_candidates": [ - {"id": "target-id", "name": "Target Entity", "tier": 8} - ], - "staged_at": "2026-04-14T00:00:00+00:00", - } - ] - - -def test_list_staged_with_config_diff(import_env): - diff_path = get_state_directory(import_env["key_prefix"]) / "config" / "diff.json" - _write_json( - diff_path, - { - "identity.name": { - "source": "Remote User", - "target": "Local User", - "category": "transferable", - } - }, - ) - - result = runner.invoke( - app, - ["list-staged", "--source", "test-source", "--area", "config"], - ) - - assert result.exit_code == 0 - lines = [json.loads(line) for line in result.stdout.splitlines() if line.strip()] - assert lines == [ - { - "area": "config", - "diff": { - "identity.name": { - "source": "Remote User", - "target": "Local User", - "category": "transferable", - } - }, - } - ] - - -def test_list_staged_with_staged_facets(import_env): - staged_file = ( - "personal/entity_relationship/entities__source_entity__entity.json.staged.json" - ) - staged_path = ( - get_state_directory(import_env["key_prefix"]) - / "facets" - / "staged" - / staged_file - ) - _write_json( - staged_path, - { - "reason": "unmapped_entity", - "source_entity_id": "source_entity", - "explanation": "Entity 'source_entity' has no mapping in entities/state.json id_map", - "source_path": "entities/source_entity/entity.json", - "source_data": json.dumps( - {"entity_id": "source_entity"}, ensure_ascii=False, indent=2 - ) - + "\n", - "staged_at": "2026-04-14T00:00:00+00:00", - }, - ) - - result = runner.invoke( - app, - ["list-staged", "--source", "test-source", "--area", "facets"], - ) - - assert result.exit_code == 0 - lines = [json.loads(line) for line in result.stdout.splitlines() if line.strip()] - assert lines == [ - { - "area": "facets", - "staged_file": staged_file, - "facet": "personal", - "file_type": "entity_relationship", - "reason": "unmapped_entity", - "source_entity_id": "source_entity", - "explanation": "Entity 'source_entity' has no mapping in entities/state.json id_map", - "source_path": "entities/source_entity/entity.json", - "source_data": json.dumps( - {"entity_id": "source_entity"}, ensure_ascii=False, indent=2 - ) - + "\n", - "staged_at": "2026-04-14T00:00:00+00:00", - } - ] - - -def test_resolve_entity_merge(import_env): - save_journal_entity( - { - "id": "target-id", - "name": "Alice Johnson", - "type": "Person", - "aka": ["Ali"], - "emails": ["alice@old.com"], - "created_at": 2000, - } - ) - staged_path = ( - get_state_directory(import_env["key_prefix"]) - / "entities" - / "staged" - / "test-entity.json" - ) - _write_json( - staged_path, - { - "source_entity": { - "id": "test-entity", - "name": "Alice Johnson", - "type": "Person", - "aka": ["AJ"], - "emails": ["alice@new.com"], - "created_at": 1000, - }, - "match_candidates": [ - {"id": "target-id", "name": "Alice Johnson", "tier": 8} - ], - "reason": "low_confidence_match", - "staged_at": "2026-04-14T00:00:00+00:00", - }, - ) - - result = runner.invoke( - app, - [ - "resolve-entity", - "test-entity", - "merge", - "--source", - "test-source", - "--target", - "target-id", - ], - ) - - assert result.exit_code == 0 - assert not staged_path.exists() - merged = load_journal_entity("target-id") - assert merged is not None - assert merged["aka"] == ["AJ", "Ali"] - assert merged["emails"] == ["alice@old.com", "alice@new.com"] - assert merged["created_at"] == 1000 - - state = _read_json( - get_state_directory(import_env["key_prefix"]) / "entities" / "state.json" - ) - assert state["id_map"]["test-entity"] == "target-id" - - log_entries = _read_log( - get_state_directory(import_env["key_prefix"]) / "entities" / "log.jsonl" - ) - assert log_entries[-1]["ts"] == FROZEN_TS - assert log_entries[-1]["action"] == "resolved_merge" - assert log_entries[-1]["resolved_by"] == "talent" - - -def test_resolve_entity_create(import_env): - save_journal_entity( - {"id": "test-entity", "name": "Occupied Entity", "type": "Tool"} - ) - staged_path = ( - get_state_directory(import_env["key_prefix"]) - / "entities" - / "staged" - / "test-entity.json" - ) - _write_json( - staged_path, - { - "source_entity": { - "id": "test-entity", - "name": "Fresh Entity", - "type": "Tool", - }, - "match_candidates": [ - {"id": "test-entity", "name": "Occupied Entity", "tier": None} - ], - "reason": "id_collision", - "staged_at": "2026-04-14T00:00:00+00:00", - }, - ) - - result = runner.invoke( - app, - [ - "resolve-entity", - "test-entity", - "create", - "--source", - "test-source", - ], - ) - - assert result.exit_code == 0 - assert not staged_path.exists() - created = load_journal_entity("fresh_entity") - assert created is not None - assert created["name"] == "Fresh Entity" - - state = _read_json( - get_state_directory(import_env["key_prefix"]) / "entities" / "state.json" - ) - assert state["id_map"]["test-entity"] == "fresh_entity" - - -def test_resolve_entity_create_principal_conflict(import_env): - save_journal_entity( - { - "id": "existing-principal", - "name": "Existing Principal", - "type": "Person", - "is_principal": True, - } - ) - staged_path = ( - get_state_directory(import_env["key_prefix"]) - / "entities" - / "staged" - / "new-principal.json" - ) - _write_json( - staged_path, - { - "source_entity": { - "id": "new-principal", - "name": "New Principal", - "type": "Person", - "is_principal": True, - }, - "match_candidates": [], - "reason": "principal_conflict", - "staged_at": "2026-04-14T00:00:00+00:00", - }, - ) - - result = runner.invoke( - app, - [ - "resolve-entity", - "new-principal", - "create", - "--source", - "test-source", - ], - ) - - assert result.exit_code == 0 - created = load_journal_entity("new-principal") - assert created is not None - assert created["is_principal"] is False - - -def test_resolve_entity_skip(import_env): - staged_path = ( - get_state_directory(import_env["key_prefix"]) - / "entities" - / "staged" - / "test-entity.json" - ) - _write_json( - staged_path, - { - "source_entity": { - "id": "test-entity", - "name": "Skip Entity", - "type": "Tool", - }, - "match_candidates": [], - "reason": "principal_conflict", - "staged_at": "2026-04-14T00:00:00+00:00", - }, - ) - - result = runner.invoke( - app, - ["resolve-entity", "test-entity", "skip", "--source", "test-source"], - ) - - assert result.exit_code == 0 - assert not staged_path.exists() - assert load_journal_entity("test-entity") is None - - log_entries = _read_log( - get_state_directory(import_env["key_prefix"]) / "entities" / "log.jsonl" - ) - assert log_entries[-1]["ts"] == FROZEN_TS - assert log_entries[-1]["action"] == "resolved_skip" - assert log_entries[-1]["resolved_by"] == "talent" - - -def test_resolve_config_apply(import_env): - diff_path = get_state_directory(import_env["key_prefix"]) / "config" / "diff.json" - _write_json( - diff_path, - { - "identity.name": { - "source": "Remote User", - "target": "Local User", - "category": "transferable", - } - }, - ) - _write_json( - get_state_directory(import_env["key_prefix"]) / "config" / "source_config.json", - {"identity": {"name": "Remote User"}}, - ) - _write_json( - import_env["root"] / "config" / "journal.json", - { - "identity": {"name": "Local User"}, - "setup": {"completed_at": 1700000000000}, - }, - ) - - result = runner.invoke( - app, - [ - "resolve-config", - "identity.name", - "apply", - "--source", - "test-source", - ], - ) - - assert result.exit_code == 0 - journal_config = _read_json(import_env["root"] / "config" / "journal.json") - assert journal_config["identity"]["name"] == "Remote User" - assert not diff_path.exists() - - log_entries = _read_log( - get_state_directory(import_env["key_prefix"]) / "config" / "log.jsonl" - ) - assert log_entries[-1]["ts"] == FROZEN_TS - assert log_entries[-1]["action"] == "config_field_applied" - assert log_entries[-1]["resolved_by"] == "talent" - - -def test_resolve_config_keep(import_env): - diff_path = get_state_directory(import_env["key_prefix"]) / "config" / "diff.json" - _write_json( - diff_path, - { - "retention.days": { - "source": 30, - "target": 90, - "category": "preference", - } - }, - ) - _write_json( - get_state_directory(import_env["key_prefix"]) / "config" / "source_config.json", - {"retention": {"days": 30}}, - ) - _write_json( - import_env["root"] / "config" / "journal.json", - { - "retention": {"days": 90}, - "setup": {"completed_at": 1700000000000}, - }, - ) - - result = runner.invoke( - app, - [ - "resolve-config", - "retention.days", - "keep", - "--source", - "test-source", - ], - ) - - assert result.exit_code == 0 - journal_config = _read_json(import_env["root"] / "config" / "journal.json") - assert journal_config["retention"]["days"] == 90 - assert not diff_path.exists() - - -def test_resolve_config_all_transferable(import_env): - diff_path = get_state_directory(import_env["key_prefix"]) / "config" / "diff.json" - _write_json( - diff_path, - { - "identity.name": { - "source": "Remote User", - "target": "Local User", - "category": "transferable", - }, - "retention.days": { - "source": 30, - "target": 90, - "category": "preference", - }, - }, - ) - _write_json( - get_state_directory(import_env["key_prefix"]) / "config" / "source_config.json", - { - "identity": {"name": "Remote User"}, - "retention": {"days": 30}, - }, - ) - _write_json( - import_env["root"] / "config" / "journal.json", - { - "identity": {"name": "Local User"}, - "retention": {"days": 90}, - "setup": {"completed_at": 1700000000000}, - }, - ) - - result = runner.invoke( - app, - [ - "resolve-config-all", - "--source", - "test-source", - "--category", - "transferable", - ], - ) - - assert result.exit_code == 0 - journal_config = _read_json(import_env["root"] / "config" / "journal.json") - assert journal_config["identity"]["name"] == "Remote User" - assert journal_config["retention"]["days"] == 90 - - remaining_diff = _read_json(diff_path) - assert list(remaining_diff) == ["retention.days"] - - -def test_resolve_facet_apply_unmapped_entity(import_env): - _write_entity_state( - import_env["key_prefix"], - {"id_map": {"source_entity": "target_entity"}, "received": {}}, - ) - staged_file = ( - "personal/entity_relationship/entities__source_entity__entity.json.staged.json" - ) - staged_path = ( - get_state_directory(import_env["key_prefix"]) - / "facets" - / "staged" - / staged_file - ) - _write_json( - staged_path, - { - "reason": "unmapped_entity", - "source_entity_id": "source_entity", - "explanation": "Entity 'source_entity' has no mapping in entities/state.json id_map", - "source_path": "entities/source_entity/entity.json", - "source_data": json.dumps( - {"entity_id": "source_entity", "description": "imported relationship"}, - ensure_ascii=False, - indent=2, - ) - + "\n", - "staged_at": "2026-04-14T00:00:00+00:00", - }, - ) - - result = runner.invoke( - app, - [ - "resolve-staged-facet", - staged_file, - "--apply", - "--source", - "test-source", - ], - ) - - assert result.exit_code == 0 - assert not staged_path.exists() - relationship = load_facet_relationship("personal", "target_entity") - assert relationship is not None - assert relationship["entity_id"] == "target_entity" - assert relationship["description"] == "imported relationship" - - log_entries = _read_log( - get_state_directory(import_env["key_prefix"]) / "facets" / "log.jsonl" - ) - assert log_entries[-1]["ts"] == FROZEN_TS - assert log_entries[-1]["action"] == "resolved_apply" - assert log_entries[-1]["resolved_by"] == "talent" - - -def test_resolve_facet_apply_facet_json_conflict(import_env): - target_path = import_env["root"] / "facets" / "personal" / "facet.json" - _write_json(target_path, {"title": "Local"}) - staged_file = "personal/facet_json/facet.json.staged.json" - staged_path = ( - get_state_directory(import_env["key_prefix"]) - / "facets" - / "staged" - / staged_file - ) - _write_json( - staged_path, - { - "reason": "facet_json_conflict", - "source_content": {"title": "Remote"}, - "target_content": {"title": "Local"}, - "staged_at": "2026-04-14T00:00:00+00:00", - }, - ) - - result = runner.invoke( - app, - [ - "resolve-staged-facet", - staged_file, - "--apply", - "--source", - "test-source", - ], - ) - - assert result.exit_code == 0 - assert not staged_path.exists() - assert _read_json(target_path) == {"title": "Remote"} - - log_entries = _read_log( - get_state_directory(import_env["key_prefix"]) / "facets" / "log.jsonl" - ) - assert log_entries[-1]["ts"] == FROZEN_TS - assert log_entries[-1]["action"] == "resolved_apply" - assert log_entries[-1]["item_id"] == "personal/facet.json" - assert log_entries[-1]["resolved_by"] == "talent" - - -def test_resolve_facet_unmapped_entity_fails_without_mapping(import_env): - staged_file = ( - "personal/entity_relationship/entities__source_entity__entity.json.staged.json" - ) - staged_path = ( - get_state_directory(import_env["key_prefix"]) - / "facets" - / "staged" - / staged_file - ) - _write_json( - staged_path, - { - "reason": "unmapped_entity", - "source_entity_id": "source_entity", - "explanation": "Entity 'source_entity' has no mapping in entities/state.json id_map", - "source_path": "entities/source_entity/entity.json", - "source_data": json.dumps( - {"entity_id": "source_entity"}, ensure_ascii=False, indent=2 - ) - + "\n", - "staged_at": "2026-04-14T00:00:00+00:00", - }, - ) - - result = runner.invoke( - app, - [ - "resolve-staged-facet", - staged_file, - "--apply", - "--source", - "test-source", - ], - ) - - assert result.exit_code == 1 - assert ( - result.stderr - == "Error: Entity source_entity has no mapping yet. Run entity review first.\n" - ) - assert staged_path.exists() - - -def test_resolve_facet_skip(import_env): - staged_file = ( - "personal/entity_relationship/entities__source_entity__entity.json.staged.json" - ) - staged_path = ( - get_state_directory(import_env["key_prefix"]) - / "facets" - / "staged" - / staged_file - ) - _write_json( - staged_path, - { - "reason": "unmapped_entity", - "source_entity_id": "source_entity", - "explanation": "Entity 'source_entity' has no mapping in entities/state.json id_map", - "source_path": "entities/source_entity/entity.json", - "source_data": json.dumps( - {"entity_id": "source_entity"}, ensure_ascii=False, indent=2 - ) - + "\n", - "staged_at": "2026-04-14T00:00:00+00:00", - }, - ) - - result = runner.invoke( - app, - [ - "resolve-staged-facet", - staged_file, - "--skip", - "--source", - "test-source", - ], - ) - - assert result.exit_code == 0 - assert not staged_path.exists() - assert load_facet_relationship("personal", "source_entity") is None - - -def test_resolve_staged_facet_requires_exactly_one_mode_flag(import_env): - staged_file = "personal/facet_json/facet.json.staged.json" - staged_path = ( - get_state_directory(import_env["key_prefix"]) - / "facets" - / "staged" - / staged_file - ) - _write_json( - staged_path, - { - "reason": "facet_json_conflict", - "source_content": {"title": "Remote"}, - "target_content": {"title": "Local"}, - "staged_at": "2026-04-14T00:00:00+00:00", - }, - ) - - result = runner.invoke( - app, - ["resolve-staged-facet", staged_file, "--source", "test-source"], - ) - - assert result.exit_code == 1 - assert result.stderr == "Error: Exactly one of --apply or --skip is required.\n" - - -def test_resolve_staged_facet_rejects_both_apply_and_skip(import_env): - staged_file = "personal/facet_json/facet.json.staged.json" - staged_path = ( - get_state_directory(import_env["key_prefix"]) - / "facets" - / "staged" - / staged_file - ) - _write_json( - staged_path, - { - "reason": "facet_json_conflict", - "source_content": {"title": "Remote"}, - "target_content": {"title": "Local"}, - "staged_at": "2026-04-14T00:00:00+00:00", - }, - ) - - result = runner.invoke( - app, - [ - "resolve-staged-facet", - staged_file, - "--apply", - "--skip", - "--source", - "test-source", - ], - ) - - assert result.exit_code == 1 - assert result.stderr == "Error: Exactly one of --apply or --skip is required.\n" - - -def test_resolve_facet_command_removed(import_env): - legacy_command = "resolve" + "-facet" - result = runner.invoke( - app, - [ - legacy_command, - "personal/facet_json/facet.json.staged.json", - "--apply", - "--source", - "test-source", - ], - ) - - assert result.exit_code != 0 - - -def test_resolve_source_not_found(import_env): - result = runner.invoke( - app, - ["list-staged", "--source", "nonexistent"], - ) - - assert result.exit_code == 1 - assert ( - result.stderr - == "Error: Import source 'nonexistent' not found. Check available sources in " - "~/.local/share/solstone/app-storage/import/journal_sources/.\n" - ) - - -def test_convey_down_prints_require_solstone_message(monkeypatch) -> None: - class DownSession: - def get(self, _url): - raise requests.exceptions.ConnectionError() - - def post(self, _url, json=None): - raise requests.exceptions.ConnectionError() - - client = ConveyClient(session=DownSession(), base_url="http://localhost:5015") - monkeypatch.setattr("solstone.apps.import.call.get_client", lambda: client) - monkeypatch.delenv("SOL_SKIP_SUPERVISOR_CHECK", raising=False) - monkeypatch.delenv("SOL_SUPERVISOR_SPAWNED", raising=False) - - result = CliRunner().invoke(app, ["list-staged", "--source", "test-source"]) - - assert result.exit_code == 1 - assert ( - result.stderr - == "sol: solstone isn't running. Start it with 'journal up' and retry.\n" - ) diff --git a/tests/test_import_client.py b/tests/test_import_client.py deleted file mode 100644 index ff6bddfcb..000000000 --- a/tests/test_import_client.py +++ /dev/null @@ -1,388 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -from __future__ import annotations - -import json -from pathlib import Path -from typing import Any - -import pytest - -from solstone.think import import_client -from solstone.think.convey_client import ConveyClientError, ConveyUnreachableError - - -class FakeClient: - def __init__( - self, - *, - upload_response: Any = None, - request_responses: list[Any] | None = None, - upload_error: Exception | None = None, - request_errors: list[Exception | None] | None = None, - ) -> None: - self.upload_response = upload_response or { - "path": "/journal/imports/20260101_120000/sample.txt", - "timestamp": "20260101_120000", - } - self.request_responses = request_responses or [ - {"status": "ok", "task_id": "task-1"} - ] - self.upload_error = upload_error - self.request_errors = request_errors or [] - self.uploads: list[dict[str, Any]] = [] - self.requests: list[dict[str, Any]] = [] - - def upload(self, path: str, *, files: dict[str, Any], data: Any = None) -> Any: - self.uploads.append({"path": path, "files": files, "data": data}) - if self.upload_error is not None: - raise self.upload_error - return self.upload_response - - def request( - self, - method: str, - path: str, - *, - params: Any = None, - json: Any = None, - ) -> Any: - self.requests.append( - {"method": method, "path": path, "params": params, "json": json} - ) - index = len(self.requests) - 1 - if index < len(self.request_errors) and self.request_errors[index] is not None: - raise self.request_errors[index] - return self.request_responses[index] - - -def _pop_client_item_id(payload: dict[str, Any]) -> str: - client_item_id = payload.pop("client_item_id") - assert isinstance(client_item_id, str) - assert client_item_id - return client_item_id - - -def test_mode_disposition_table_covers_d5_modes() -> None: - assert import_client.MODE_DISPOSITIONS == { - "positional_media": "http-client", - "--timestamp": "http-client", - "--facet": "http-client", - "--setting": "http-client", - "--source": "http-client", - "--force": "http-client", - "--auto": "http-client", - "--deterministic-only": "http-client", - "--dry-run": "reject-journal-host", - "--json": "client-output", - "-v/--verbose": "client-logging", - "--backends": "reject-journal-host", - "--sync": "reject-journal-host", - "--save": "reject-journal-host", - "--path": "reject-journal-host", - "--list-importers": "reject-journal-host", - "journal-source": "relocate-sol-call-import", - } - - -def test_file_save_then_start( - tmp_path: Path, capsys: pytest.CaptureFixture[str] -) -> None: - media = tmp_path / "sample.txt" - media.write_text("hello", encoding="utf-8") - client = FakeClient() - - code = import_client.main([str(media)], client=client) # type: ignore[arg-type] - - assert code == 0 - save_data = dict(client.uploads[0]["data"]) - client_item_id = _pop_client_item_id(save_data) - assert len(client_item_id) == 32 - assert len(client.uploads) == 1 - assert client.uploads[0]["path"] == "/app/import/api/save" - assert client.uploads[0]["files"] == { - "file": ("sample.txt", media, "application/octet-stream"), - } - assert save_data == {} - assert client.requests == [ - { - "method": "POST", - "path": "/app/import/api/start", - "params": None, - "json": { - "path": "/journal/imports/20260101_120000/sample.txt", - "timestamp": "20260101_120000", - "force": False, - }, - } - ] - assert "queued processing task task-1" in capsys.readouterr().out - - -def test_save_path_then_start(tmp_path: Path) -> None: - media_dir = tmp_path / "vault" - media_dir.mkdir() - client = FakeClient( - request_responses=[ - { - "path": str(media_dir), - "timestamp": "20260101_130000", - }, - {"status": "ok", "task_id": "task-2"}, - ] - ) - - code = import_client.main([str(media_dir)], client=client) # type: ignore[arg-type] - - assert code == 0 - assert client.uploads == [] - save_json = dict(client.requests[0]["json"]) - client_item_id = _pop_client_item_id(save_json) - assert len(client_item_id) == 32 - assert client.requests[0]["method"] == "POST" - assert client.requests[0]["path"] == "/app/import/api/save-path" - assert client.requests[0]["params"] is None - assert save_json == {"path": str(media_dir)} - assert client.requests[1]["path"] == "/app/import/api/start" - assert client.requests[1]["json"]["timestamp"] == "20260101_130000" - - -def test_timestamp_override_only_goes_to_start(tmp_path: Path) -> None: - media = tmp_path / "sample.txt" - media.write_text("hello", encoding="utf-8") - client = FakeClient() - - code = import_client.main( - [str(media), "--timestamp", "20260202_030405"], - client=client, # type: ignore[arg-type] - ) - - assert code == 0 - assert "timestamp" not in client.uploads[0]["data"] - assert client.requests[0]["json"]["timestamp"] == "20260202_030405" - - -def test_metadata_and_start_options_forward(tmp_path: Path) -> None: - media = tmp_path / "sample.txt" - media.write_text("hello", encoding="utf-8") - client = FakeClient() - - code = import_client.main( - [ - str(media), - "--facet", - "work", - "--setting", - "office", - "--source", - "ics", - "--force", - ], - client=client, # type: ignore[arg-type] - ) - - assert code == 0 - save_data = dict(client.uploads[0]["data"]) - _pop_client_item_id(save_data) - assert save_data == { - "facet": "work", - "setting": "office", - "source_hint": "ics", - } - assert client.requests[0]["json"] == { - "path": "/journal/imports/20260101_120000/sample.txt", - "timestamp": "20260101_120000", - "force": True, - } - - -def test_deterministic_only_forwards_only_on_save_data(tmp_path: Path) -> None: - media = tmp_path / "sample.txt" - media.write_text("hello", encoding="utf-8") - client = FakeClient() - - code = import_client.main( - [str(media), "--deterministic-only"], - client=client, # type: ignore[arg-type] - ) - - assert code == 0 - save_data = dict(client.uploads[0]["data"]) - _pop_client_item_id(save_data) - assert save_data == {"deterministic_only": "true"} - assert "deterministic_only" not in client.requests[0]["json"] - - -def test_json_output_shape(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: - media = tmp_path / "sample.txt" - media.write_text("hello", encoding="utf-8") - client = FakeClient() - - code = import_client.main( - [str(media), "--json"], - client=client, # type: ignore[arg-type] - ) - - assert code == 0 - payload = json.loads(capsys.readouterr().out) - assert payload == { - "status": "queued", - "path": "/journal/imports/20260101_120000/sample.txt", - "timestamp": "20260101_120000", - "save": { - "path": "/journal/imports/20260101_120000/sample.txt", - "timestamp": "20260101_120000", - }, - "start": {"status": "ok", "task_id": "task-1"}, - } - - -def test_duplicate_save_skips_start_and_returns_success( - tmp_path: Path, capsys: pytest.CaptureFixture[str] -) -> None: - media = tmp_path / "sample.txt" - media.write_text("hello", encoding="utf-8") - client = FakeClient( - upload_response={ - "schema_version": 1, - "status": "duplicate", - "recommended_action": "do_not_start", - "path": "/journal/imports/20260101_120000/sample.txt", - "timestamp": "20260101_120000", - "duplicate": { - "import_id": "20260101_120000", - "imported_at": "2026-01-01T12:00:00", - "entry_count": 2, - "state": "imported", - }, - } - ) - - code = import_client.main([str(media)], client=client) # type: ignore[arg-type] - - assert code == 0 - assert client.requests == [] - captured = capsys.readouterr() - assert ( - "sol import: already imported on 2026-01-01T12:00:00 (2 entries); skipping" - ) in captured.out - assert captured.err == "" - - -def test_duplicate_json_outputs_save_response_without_start( - tmp_path: Path, capsys: pytest.CaptureFixture[str] -) -> None: - media = tmp_path / "sample.txt" - media.write_text("hello", encoding="utf-8") - duplicate_response = { - "schema_version": 1, - "status": "duplicate", - "recommended_action": "do_not_start", - "path": "/journal/imports/20260101_120000/sample.txt", - "timestamp": "20260101_120000", - "duplicate": { - "import_id": "20260101_120000", - "imported_at": None, - "entry_count": None, - "state": "staged", - }, - } - client = FakeClient(upload_response=duplicate_response) - - code = import_client.main( - [str(media), "--json"], - client=client, # type: ignore[arg-type] - ) - - assert code == 0 - assert client.requests == [] - assert json.loads(capsys.readouterr().out) == duplicate_response - - -def test_unreachable_is_clean( - tmp_path: Path, capsys: pytest.CaptureFixture[str] -) -> None: - media = tmp_path / "sample.txt" - media.write_text("hello", encoding="utf-8") - client = FakeClient(upload_error=ConveyUnreachableError("down")) - - code = import_client.main([str(media)], client=client) # type: ignore[arg-type] - - assert code == 1 - assert "couldn't reach the journal" in capsys.readouterr().err - - -def test_typed_error_is_clean( - tmp_path: Path, capsys: pytest.CaptureFixture[str] -) -> None: - media = tmp_path / "sample.txt" - media.write_text("hello", encoding="utf-8") - client = FakeClient( - upload_error=ConveyClientError("bad request", detail="invalid file") - ) - - code = import_client.main([str(media)], client=client) # type: ignore[arg-type] - - captured = capsys.readouterr() - assert code == 1 - assert "failed to stage import: bad request" in captured.err - assert "invalid file" in captured.err - - -def test_malformed_response_is_clean( - tmp_path: Path, capsys: pytest.CaptureFixture[str] -) -> None: - media = tmp_path / "sample.txt" - media.write_text("hello", encoding="utf-8") - client = FakeClient(upload_response={"timestamp": "20260101_120000"}) - - code = import_client.main([str(media)], client=client) # type: ignore[arg-type] - - assert code == 1 - assert "couldn't read journal response" in capsys.readouterr().err - - -def test_partial_save_but_queue_failed( - tmp_path: Path, capsys: pytest.CaptureFixture[str] -) -> None: - media = tmp_path / "sample.txt" - media.write_text("hello", encoding="utf-8") - client = FakeClient(request_errors=[ConveyClientError("queue failed")]) - - code = import_client.main([str(media)], client=client) # type: ignore[arg-type] - - captured = capsys.readouterr() - assert code == 1 - assert ( - "staged /journal/imports/20260101_120000/sample.txt " - "but processing was not queued: queue failed" - ) in captured.err - - -@pytest.mark.parametrize( - ("argv", "expected"), - [ - (["media.txt", "--dry-run"], "`--dry-run` requires the journal host"), - (["--backends"], "`--backends` requires the journal host"), - (["--list-importers"], "`--list-importers` requires the journal host"), - (["--sync", "plaud"], "`--sync` requires the journal host"), - (["media.txt", "--save"], "`--save` requires the journal host"), - (["media.txt", "--path", "/tmp/src"], "`--path` requires the journal host"), - ( - ["media.txt", "--auto", "timestamps are Pacific"], - "`--auto ` requires the journal host", - ), - (["journal-source", "list"], "sol call import "), - ], -) -def test_reject_modes_exit_cleanly( - argv: list[str], - expected: str, - capsys: pytest.CaptureFixture[str], -) -> None: - with pytest.raises(SystemExit) as exc_info: - import_client.main(argv, client=FakeClient()) # type: ignore[arg-type] - - assert exc_info.value.code == 2 - assert expected in capsys.readouterr().err diff --git a/tests/test_install_verify.py b/tests/test_install_verify.py index 01259e1b4..1b09c8b11 100644 --- a/tests/test_install_verify.py +++ b/tests/test_install_verify.py @@ -24,9 +24,9 @@ def _run_in_tmp(args: list[str]) -> subprocess.CompletedProcess[str]: ) -def test_import_think_sol_cli_from_tmp(): +def test_import_think_sol_compat_cli_from_tmp(): result = _run_in_tmp( - [str(VENV_PYTHON), "-c", "from solstone.think.sol_cli import main"] + [str(VENV_PYTHON), "-c", "from solstone.think.sol_compat_cli import main"] ) assert result.returncode == 0, result.stderr diff --git a/tests/test_ledger_call_parity.py b/tests/test_ledger_call_parity.py deleted file mode 100644 index 8006e9a2f..000000000 --- a/tests/test_ledger_call_parity.py +++ /dev/null @@ -1,266 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -from __future__ import annotations - -import json -from pathlib import Path -from urllib.parse import urlsplit - -import pytest -import requests -from typer.testing import CliRunner - -from solstone.convey.reasons import ACTIVITIES_BUSY -from solstone.think.convey_client import ConveyClient -from solstone.think.journal_io import LockTimeout -from solstone.think.tools.ledger import app -from tests._baseline_harness import make_test_client, mark_setup_complete -from tests.test_surfaces_ledger import ( - _commitment, - _decision, - _minimal_facet_tree, - _utc_ms, - _write_story_activity, -) - - -@pytest.fixture -def journal(tmp_path, monkeypatch): - monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - mark_setup_complete(tmp_path) - return tmp_path - - -@pytest.fixture -def runner(journal, monkeypatch): - client = ConveyClient(session=make_test_client(journal), base_url="") - monkeypatch.setattr("solstone.think.tools.ledger.get_client", lambda: client) - return CliRunner() - - -class _Response: - def __init__(self, body: object, status_code: int = 200) -> None: - self.status_code = status_code - self.text = json.dumps(body) - - -class _CaptureSession: - def __init__(self) -> None: - self.urls: list[str] = [] - self.posts: list[tuple[str, object]] = [] - - def get(self, url: str): - captured = _path_with_query(url) - self.urls.append(captured) - if captured.startswith("/api/ledger/decisions") or captured.startswith( - "/api/ledger?" - ): - return _Response({"items": [], "total": 0}) - return _Response(_item("abc123")) - - def post(self, url: str, json=None): - self.posts.append((_path_with_query(url), json)) - return _Response(_item("abc123", state=json["as_state"])) - - -class _MalformedEnvelopeSession: - def get(self, _url: str): - return _Response({"items": {}, "total": 1}) - - -def _item(item_id: str, *, state: str = "open", action: str = "send proposal") -> dict: - return { - "id": item_id, - "state": state, - "owner": "Mina", - "owner_entity_id": "mina", - "counterparty": "Ravi", - "counterparty_entity_id": "ravi", - "action": action, - "summary": action, - "when": None, - "context": "Context.", - "opened_at": 1, - "closed_at": None, - "age_days": 0, - "sources": [], - } - - -def _path_with_query(url: str) -> str: - parsed = urlsplit(url) - return parsed.path + (f"?{parsed.query}" if parsed.query else "") - - -def _seed_commitments(journal: Path, count: int) -> None: - _minimal_facet_tree(journal) - start = _utc_ms("2026-04-10T09:00:00Z") - for index in range(count): - _write_story_activity( - "work", - "20260410", - f"meeting_{index:06d}_300", - start + index, - commitments=[_commitment(action=f"action number {index}")], - ) - - -def test_ledger_request_mapping(monkeypatch): - session = _CaptureSession() - client = ConveyClient(session=session, base_url="") - monkeypatch.setattr("solstone.think.tools.ledger.get_client", lambda: client) - runner = CliRunner() - - list_result = runner.invoke( - app, - [ - "list", - "--state", - "all", - "--owner", - "Mina", - "--counterparty", - "Ravi", - "--age-days-gte", - "3", - "--closed-since", - "20260401", - "--sort", - "opened_at_desc", - "--facets", - "work,personal", - "--top", - "5", - "--json", - ], - ) - decisions_result = runner.invoke( - app, - [ - "decisions", - "--owner", - "Mina", - "--since", - "20260401", - "--involving", - "Ravi", - "--top", - "2", - "--facets", - "work,personal", - "--json", - ], - ) - get_result = runner.invoke(app, ["get", "abc123", "--json"]) - close_result = runner.invoke( - app, ["close", "abc123", "--note", "done", "--as", "dropped", "--json"] - ) - - assert list_result.exit_code == 0 - assert decisions_result.exit_code == 0 - assert get_result.exit_code == 0 - assert close_result.exit_code == 0 - assert session.urls == [ - "/api/ledger?state=all&owner=Mina&counterparty=Ravi&age_days_gte=3&closed_since=20260401&sort=opened_at_desc&facets=work%2Cpersonal&limit=100&offset=0", - "/api/ledger/decisions?owner=Mina&since=20260401&involving=Ravi&facets=work%2Cpersonal&limit=100&offset=0", - "/api/ledger/abc123", - ] - assert session.posts == [ - ("/api/ledger/abc123/close", {"note": "done", "as_state": "dropped"}) - ] - assert "top" not in session.urls[0] - assert "top" not in session.urls[1] - - -def test_ledger_get_and_close_json_wrap_single_object(runner, journal): - _seed_commitments(journal, 1) - listed = runner.invoke(app, ["list", "--json"]) - item_id = json.loads(listed.stdout)[0]["id"] - - fetched = runner.invoke(app, ["get", item_id, "--json"]) - closed = runner.invoke(app, ["close", item_id, "--note", "done", "--json"]) - - assert fetched.exit_code == 0 - assert closed.exit_code == 0 - fetched_payload = json.loads(fetched.stdout) - closed_payload = json.loads(closed.stdout) - assert isinstance(fetched_payload, list) - assert isinstance(closed_payload, list) - assert fetched_payload[0]["id"] == item_id - assert closed_payload[0]["state"] == "closed" - - -def test_ledger_decisions_json_array(runner, journal): - _minimal_facet_tree(journal) - _write_story_activity( - "work", - "20260410", - "meeting_090000_300", - _utc_ms("2026-04-10T09:00:00Z"), - decisions=[_decision()], - ) - - result = runner.invoke(app, ["decisions", "--json"]) - - assert result.exit_code == 0 - payload = json.loads(result.stdout) - assert isinstance(payload, list) - assert payload[0]["action"] == "move launch review" - - -def test_ledger_malformed_envelope_exits_without_stdout(monkeypatch): - client = ConveyClient(session=_MalformedEnvelopeSession(), base_url="") - monkeypatch.setattr("solstone.think.tools.ledger.get_client", lambda: client) - - result = CliRunner().invoke(app, ["list", "--json"]) - - assert result.exit_code == 1 - assert "I couldn't read the journal response." in result.stderr - assert result.stdout == "" - - -def test_ledger_missing_item_error(runner, journal): - _minimal_facet_tree(journal) - - result = runner.invoke(app, ["get", "missing", "--json"]) - - assert result.exit_code == 1 - assert result.stderr == "ledger item not found: missing\n" - assert result.stdout == "" - - -def test_ledger_close_busy_uses_owner_voice_error(runner, monkeypatch): - def _raise_busy(*_args, **_kwargs): - raise LockTimeout(Path("busy"), 0.01) - - monkeypatch.setattr("solstone.convey.ledger.ledger.close", _raise_busy) - - result = runner.invoke(app, ["close", "abc123", "--note", "done", "--json"]) - - assert result.exit_code == 1 - assert result.stderr == f"{ACTIVITIES_BUSY.message}\n" - assert result.stdout == "" - - -def test_ledger_convey_down_prints_require_solstone_message(journal, monkeypatch): - class DownSession: - def get(self, _url): - raise requests.exceptions.ConnectionError() - - def post(self, _url, json=None): - raise requests.exceptions.ConnectionError() - - client = ConveyClient(session=DownSession(), base_url="http://localhost:5015") - monkeypatch.setattr("solstone.think.tools.ledger.get_client", lambda: client) - monkeypatch.delenv("SOL_SKIP_SUPERVISOR_CHECK", raising=False) - monkeypatch.delenv("SOL_SUPERVISOR_SPAWNED", raising=False) - - result = CliRunner().invoke(app, ["list"]) - - assert result.exit_code == 1 - assert ( - result.stderr - == "sol: solstone isn't running. Start it with 'journal up' and retry.\n" - ) - assert result.stdout == "" diff --git a/tests/test_link_call_parity.py b/tests/test_link_call_parity.py deleted file mode 100644 index 9135a7376..000000000 --- a/tests/test_link_call_parity.py +++ /dev/null @@ -1,613 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -import json -import re - -import pytest -import requests -from typer.testing import CliRunner - -from solstone.apps.network import call as link_call -from solstone.apps.network import routes as link_routes -from solstone.apps.network.tests.conftest import _StubWatcher -from solstone.think.convey_client import ConveyClient -from solstone.think.link.auth import AuthorizedClients -from solstone.think.link.local_endpoints import LocalEndpoint -from solstone.think.link.nonces import NonceStore -from solstone.think.link.paths import ( - LinkState, - authorized_clients_path, - nonces_path, - save_service_token, -) -from tests._baseline_harness import make_test_client - -PAIRED_AT = "2026-04-19T00:00:00Z" -LAST_SEEN_AT = "2026-04-19T00:30:00Z" -FROZEN_NOW = "2026-04-19T01:00:00Z" - - -@pytest.fixture -def journal(tmp_path, monkeypatch): - monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - config_dir = tmp_path / "config" - config_dir.mkdir(parents=True, exist_ok=True) - (config_dir / "journal.json").write_text( - json.dumps( - { - "setup": {"completed_at": 1700000000000}, - }, - indent=2, - ), - encoding="utf-8", - ) - return tmp_path - - -@pytest.fixture -def runner(journal, monkeypatch): - client = ConveyClient(session=make_test_client(journal), base_url="") - monkeypatch.setattr("solstone.apps.network.call.get_client", lambda: client) - return CliRunner() - - -def _authorized() -> AuthorizedClients: - return AuthorizedClients(authorized_clients_path()) - - -def _nonces() -> NonceStore: - return NonceStore(nonces_path()) - - -def _install_pair_watcher( - monkeypatch: pytest.MonkeyPatch, - endpoints: list[LocalEndpoint] | None = None, - *, - route_ipv4: str | None = "192.168.1.50", -) -> None: - if endpoints is None: - endpoints = [LocalEndpoint(ip="192.168.1.50", port=7657, scope="lan")] - monkeypatch.setattr( - link_routes, - "get_interface_watcher", - lambda: _StubWatcher(endpoints), - ) - monkeypatch.setattr(link_routes, "_detect_lan_ip", lambda: route_ipv4) - - -def _add_device( - fingerprint: str, - label: str, - *, - role: str = "", - paired_at: str = PAIRED_AT, - last_seen_at: str | None = None, - client_label: str = "", -) -> None: - store = _authorized() - store.add( - fingerprint, - label, - "inst-1", - role=role, - paired_at=paired_at, - client_label=client_label, - ) - if last_seen_at is not None: - store.touch_last_seen(fingerprint, now=_parse_iso(last_seen_at)) - - -def _parse_iso(value: str): - import datetime as dt - - return dt.datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=dt.UTC) - - -class _TimeoutTime: - def __init__(self) -> None: - self._calls = 0 - - def time(self) -> float: - self._calls += 1 - return 0.0 if self._calls <= 2 else 2.0 - - def sleep(self, _seconds: float) -> None: - return None - - -class _PairingTime: - def __init__(self, *, on_sleep) -> None: - self._on_sleep = on_sleep - - def time(self) -> float: - return 0.0 - - def sleep(self, _seconds: float) -> None: - self._on_sleep() - - -def test_pair_rejects_invalid_roles_without_minting(runner): - for role in ("bogus", "Observer"): - result = runner.invoke( - link_call.app, - ["pair", "--device-label", "test-laptop", "--as", role, "--timeout", "1"], - ) - - assert result.exit_code == 2 - assert result.stdout == "" - assert result.stderr == "invalid role; expected one of: phone, observer, peer\n" - assert _nonces().snapshot() == [] - - -def test_pair_mints_nonce_prints_payload_and_times_out(runner, monkeypatch): - _install_pair_watcher(monkeypatch) - monkeypatch.setattr(link_call, "time", _TimeoutTime()) - - result = runner.invoke( - link_call.app, - ["pair", "--device-label", "Test Phone", "--as", "observer", "--timeout", "1"], - ) - - assert result.exit_code == 2 - assert result.stderr == "" - pair_link_match = re.search( - r"pair-link: (https://go\.solstone\.app/p#[0-9A-HJKMNP-TV-Z]{64})", - result.stdout, - ) - assert pair_link_match - pair_link = pair_link_match.group(1) - assert "link this device with:\n" in result.stdout - assert " sol link join --code " in result.stdout - assert pair_link in result.stdout - assert "--label" in result.stdout - assert "CA fingerprint: sha256:" in result.stdout - assert "relay short-code" not in result.stdout - assert "Pair URL: http" not in result.stdout - assert "Pair code:" not in result.stdout - assert "Device: Test Phone\n\nWaiting for linked system…\n" in result.stdout - assert result.stdout.endswith("Timed out. Pair code expired.\n") - nonces = _nonces().snapshot() - assert len(nonces) == 1 - assert nonces[0].device_label == "Test Phone" - assert nonces[0].role == "observer" - - -def test_pair_no_wait_mints_nonce_prints_payload_and_exits(runner, monkeypatch): - _install_pair_watcher(monkeypatch) - - result = runner.invoke( - link_call.app, - ["pair", "--device-label", "Headless", "--as", "observer", "--no-wait"], - ) - - assert result.exit_code == 0 - assert result.stderr == "" - assert "pair-link: https://go.solstone.app/p#" in result.stdout - assert "link this device with:\n" in result.stdout - assert " sol link join --code " in result.stdout - assert "CA fingerprint: sha256:" in result.stdout - assert "Device: Headless\n" in result.stdout - assert "pair-link freshness:" not in result.stdout - assert "Waiting for linked system" not in result.stdout - assert "Timed out. Pair code expired." not in result.stdout - nonces = _nonces().snapshot() - assert len(nonces) == 1 - assert nonces[0].device_label == "Headless" - assert nonces[0].role == "observer" - - -def test_pair_no_wait_spl_prints_payload_without_relay_freshness_hint( - journal, - runner, - monkeypatch, -): - config_path = journal / "config" / "journal.json" - config = json.loads(config_path.read_text("utf-8")) - config["link"] = {"posture": "spl"} - config_path.write_text(json.dumps(config, indent=2), encoding="utf-8") - save_service_token("svc-token") - pair_window_calls: list[dict] = [] - - class _FakePairWindowHandle: - def wait_open(self, timeout: float | None = None) -> bool: - return True - - def _record_start_pair_window(**kwargs: object) -> _FakePairWindowHandle: - pair_window_calls.append(kwargs) - return _FakePairWindowHandle() - - monkeypatch.setattr( - link_routes, - "start_pair_window", - _record_start_pair_window, - ) - - result = runner.invoke( - link_call.app, - ["pair", "--device-label", "Relay Phone", "--no-wait"], - ) - - assert result.exit_code == 0 - assert "pair-link: https://go.solstone.app/p#" in result.stdout - assert "pair-link freshness:" not in result.stdout - assert "Waiting for linked system" not in result.stdout - assert len(pair_window_calls) == 1 - - -@pytest.mark.parametrize( - ("extra_args", "expected_role"), - [ - ([], ""), - (["--as", "peer"], "peer"), - ], -) -def test_pair_mints_default_and_peer_roles( - runner, monkeypatch, extra_args, expected_role -): - _install_pair_watcher(monkeypatch) - monkeypatch.setattr(link_call, "time", _TimeoutTime()) - - result = runner.invoke( - link_call.app, - [ - "pair", - "--device-label", - "test-laptop", - *extra_args, - "--timeout", - "1", - ], - ) - - assert result.exit_code == 2 - nonces = _nonces().snapshot() - assert len(nonces) == 1 - assert nonces[0].role == expected_role - - -def test_pair_without_device_label_mints_empty_label( - runner, - monkeypatch, -): - _install_pair_watcher(monkeypatch) - monkeypatch.setattr(link_call, "time", _TimeoutTime()) - - result = runner.invoke( - link_call.app, - ["pair", "--timeout", "1"], - ) - - assert result.exit_code == 2 - assert "\nDevice: " not in result.stdout - nonces = _nonces().snapshot() - assert len(nonces) == 1 - assert nonces[0].device_label == "" - - -def test_pair_reports_newly_paired_device(runner, monkeypatch): - def add_device() -> None: - if not _authorized().snapshot(): - _add_device("sha256:" + ("1" * 64), "Phone") - - _install_pair_watcher(monkeypatch) - monkeypatch.setattr(link_call, "time", _PairingTime(on_sleep=add_device)) - - result = runner.invoke( - link_call.app, - ["pair", "--device-label", "Phone", "--timeout", "5"], - ) - - assert result.exit_code == 0 - assert ( - "Paired: Phone\n" - " fingerprint: sha256:1111111111111111111111111111111111111111111111111111111111111111\n" - f" paired_at: {PAIRED_AT}\n" - ) in result.stdout - - -def test_pair_reports_display_label_for_newly_paired_device(runner, monkeypatch): - def add_device() -> None: - if not _authorized().snapshot(): - _add_device( - "sha256:" + ("1" * 64), - "", - client_label="client-host", - ) - - _install_pair_watcher(monkeypatch) - monkeypatch.setattr(link_call, "time", _PairingTime(on_sleep=add_device)) - - result = runner.invoke(link_call.app, ["pair", "--timeout", "5"]) - - assert result.exit_code == 0 - assert ( - "Paired: client-host\n" - " fingerprint: sha256:1111111111111111111111111111111111111111111111111111111111111111\n" - f" paired_at: {PAIRED_AT}\n" - ) in result.stdout - - -def test_pair_reports_nonce_consumed_fallback(runner, monkeypatch): - def consume_nonce() -> None: - nonces = _nonces().snapshot() - if nonces and not nonces[0].used: - _nonces().consume(nonces[0].value) - - _install_pair_watcher(monkeypatch) - monkeypatch.setattr(link_call, "time", _PairingTime(on_sleep=consume_nonce)) - - result = runner.invoke( - link_call.app, - ["pair", "--device-label", "Phone", "--timeout", "5"], - ) - - assert result.exit_code == 0 - assert ( - "Pair request completed; device should appear in `sol call link list`.\n" - in result.stdout - ) - - -def test_pair_reports_no_lan_address_without_nonce(runner, monkeypatch): - _install_pair_watcher(monkeypatch, [], route_ipv4=None) - - result = runner.invoke( - link_call.app, - ["pair", "--device-label", "Phone", "--timeout", "1"], - ) - - assert result.exit_code == 1 - assert "isn't reachable on a network address" in result.stderr - assert _nonces().snapshot() == [] - - -def test_list_empty_store(runner): - result = runner.invoke(link_call.app, ["list"]) - - assert result.exit_code == 0 - assert result.stdout == "No devices linked yet.\n" - - -def test_list_linked_systems_only_omits_peer_heading(runner): - _add_device("sha256:aaaaaaaaaaaaaaaa0000", "alpha") - _add_device("sha256:bbbbbbbbbbbbbbbb0000", "beta") - - result = runner.invoke(link_call.app, ["list"]) - - assert result.exit_code == 0 - assert "Linked systems:\n" in result.stdout - assert "Peers:" not in result.stdout - assert result.stdout.count("- ") == 2 - - -def test_list_grouped_output_and_device_line(runner, monkeypatch): - monkeypatch.setattr(link_call, "_now_utc", lambda: _parse_iso(FROZEN_NOW)) - _add_device( - "sha256:0123456789abcdef0000", - "phone", - role="phone", - paired_at=PAIRED_AT, - last_seen_at=LAST_SEEN_AT, - ) - _add_device( - "sha256:aaaaaaaaaaaaaaaa0000", - "linked", - paired_at=PAIRED_AT, - ) - _add_device( - "sha256:bbbbbbbbbbbbbbbb0000", - "observer", - role="observer", - paired_at=PAIRED_AT, - ) - _add_device("sha256:cccccccccccccccc0000", "peer", role="peer", paired_at=PAIRED_AT) - _add_device("sha256:dddddddddddddddd0000", "tablet", role="tablet") - - result = runner.invoke(link_call.app, ["list"]) - - assert result.exit_code == 0 - assert ( - result.stdout == "Linked systems:\n" - "- phone — added 1 hour ago — last seen 30 minutes ago [0123456789abcdef]\n" - "- linked — added 1 hour ago — last seen never [aaaaaaaaaaaaaaaa]\n" - "- observer — added 1 hour ago — last seen never [bbbbbbbbbbbbbbbb]\n" - "- tablet — added 1 hour ago — last seen never [dddddddddddddddd]\n" - "\n" - "Peers:\n" - "- peer — added 1 hour ago — last seen never [cccccccccccccccc]\n" - ) - - -def test_list_uses_display_label_when_assigned_label_is_empty(runner, monkeypatch): - monkeypatch.setattr(link_call, "_now_utc", lambda: _parse_iso(FROZEN_NOW)) - _add_device( - "sha256:0123456789abcdef0000", - "", - paired_at=PAIRED_AT, - client_label="client-host", - ) - - result = runner.invoke(link_call.app, ["list"]) - - assert result.exit_code == 0 - assert result.stdout == ( - "Linked systems:\n" - "- client-host — added 1 hour ago — last seen never [0123456789abcdef]\n" - ) - - -def test_list_uses_server_composed_display_labels(runner, monkeypatch): - monkeypatch.setattr(link_call, "_now_utc", lambda: _parse_iso(FROZEN_NOW)) - _add_device( - "sha256:" + ("a" * 64), - "laptop", - paired_at=PAIRED_AT, - client_label="host-1", - ) - _add_device( - "sha256:" + ("b" * 64), - "phone", - paired_at=PAIRED_AT, - client_label="phone", - ) - - result = runner.invoke(link_call.app, ["list"]) - - assert result.exit_code == 0 - lines = [line for line in result.stdout.splitlines() if line.startswith("- ")] - assert len(lines) == 2 - assert lines[0] != lines[1] - assert any("laptop (host-1)" in line for line in lines) - assert any(line.startswith("- phone —") for line in lines) - - -def test_list_legacy_observer_role_uses_linked_systems_heading(runner): - _add_device("sha256:bbbbbbbbbbbbbbbb0000", "observer", role="observer") - - result = runner.invoke(link_call.app, ["list"]) - - assert result.exit_code == 0 - assert "Linked systems:\n" in result.stdout - assert "Peers:" not in result.stdout - - -def test_authorized_clients_flat_view(runner, monkeypatch): - monkeypatch.setattr(link_call, "_now_utc", lambda: _parse_iso(FROZEN_NOW)) - first_fingerprint = "sha256:" + ("a" * 64) - second_fingerprint = "sha256:" + ("b" * 64) - _add_device( - first_fingerprint, - "laptop", - paired_at=PAIRED_AT, - last_seen_at=LAST_SEEN_AT, - client_label="host-1", - ) - _add_device( - second_fingerprint, - "phone", - paired_at=PAIRED_AT, - client_label="phone", - ) - - result = runner.invoke(link_call.app, ["authorized-clients"]) - - assert result.exit_code == 0 - assert result.stdout == ( - f"{first_fingerprint} laptop (host-1) last seen 30 minutes ago\n" - f"{second_fingerprint} phone last seen never\n" - ) - - -def test_authorized_clients_empty_store(runner): - result = runner.invoke(link_call.app, ["authorized-clients"]) - - assert result.exit_code == 0 - assert result.stdout == "No authorized clients.\n" - - -def test_observer_pause_stub_does_not_require_service(runner, monkeypatch): - def fail_get_client(): - raise AssertionError("observer-pause must not make an HTTP request") - - monkeypatch.setattr(link_call, "get_client", fail_get_client) - - result = runner.invoke(link_call.app, ["observer-pause"]) - - assert result.exit_code == 0 - assert result.stdout == "observer-pause is not yet available.\n" - - -def test_unpair_success_and_not_found_outputs(runner): - _add_device("sha256:" + ("a" * 64), "phone") - - success = runner.invoke(link_call.app, ["unpair", "phone"]) - missing_fp = runner.invoke(link_call.app, ["unpair", "sha256:" + ("b" * 64)]) - missing_label = runner.invoke(link_call.app, ["unpair", "missing label"]) - - assert success.exit_code == 0 - assert success.stdout == "Unpaired.\n" - assert _authorized().is_authorized("sha256:" + ("a" * 64)) is False - assert missing_fp.exit_code == 1 - assert missing_fp.stdout == f"No paired device with fingerprint sha256:{'b' * 64}\n" - assert missing_fp.stderr == "" - assert missing_label.exit_code == 1 - assert missing_label.stdout == "No paired device with label 'missing label'\n" - assert missing_label.stderr == "" - - -def test_unpair_same_label_removes_first_inserted_match(runner): - _add_device("sha256:phone", "laptop") - _add_device("sha256:observer", "laptop", role="observer") - - result = runner.invoke(link_call.app, ["unpair", "laptop"]) - - assert result.exit_code == 0 - remaining = _authorized().snapshot() - assert len(remaining) == 1 - assert remaining[0].fingerprint == "sha256:observer" - assert remaining[0].role == "observer" - - -def test_status_provisioned_and_not_provisioned(journal, runner): - state = LinkState.load_or_create() - _add_device("sha256:" + ("a" * 64), "phone") - - provisioned = runner.invoke(link_call.app, ["status"]) - - assert provisioned.exit_code == 0 - assert provisioned.stdout == ( - f"Instance ID: {state.instance_id}\n" - f"Home label: {state.home_label}\n" - "Relay URL: https://link.solstone.app\n" - "Enrolled: no\n" - "Reach posture: direct\n" - "Private network: not enabled\n" - "Paired devices: 1\n" - "Listen-WS state: (query convey /app/network/api/status for live state)\n" - ) - - -def test_status_unprovisioned_does_not_write_state(journal, runner, monkeypatch): - def fail_save(self) -> None: - raise AssertionError("LinkState.save should not be called by status") - - monkeypatch.setattr(LinkState, "save", fail_save) - - result = runner.invoke(link_call.app, ["status"]) - - assert result.exit_code == 0 - assert result.stdout == ( - "Instance ID: (not provisioned — pair a device to provision)\n" - "Home label: (not provisioned)\n" - "Relay URL: https://link.solstone.app\n" - "Enrolled: no\n" - "Reach posture: direct\n" - "Private network: not enabled\n" - "Paired devices: 0\n" - "Listen-WS state: (query convey /app/network/api/status for live state)\n" - ) - assert not (journal / "link" / "state.json").exists() - - -def test_convey_down_prints_require_solstone_message(journal, monkeypatch): - class DownSession: - def get(self, _url): - raise requests.exceptions.ConnectionError() - - def post(self, _url, json=None): - raise requests.exceptions.ConnectionError() - - client = ConveyClient(session=DownSession(), base_url="http://localhost:5015") - monkeypatch.setattr("solstone.apps.network.call.get_client", lambda: client) - monkeypatch.delenv("SOL_SKIP_SUPERVISOR_CHECK", raising=False) - monkeypatch.delenv("SOL_SUPERVISOR_SPAWNED", raising=False) - - result = CliRunner().invoke(link_call.app, ["status"]) - - assert result.exit_code == 1 - assert ( - result.stderr - == "sol: solstone isn't running. Start it with 'journal up' and retry.\n" - ) - assert result.stdout == "" diff --git a/tests/test_local_command_home.py b/tests/test_local_command_home.py index 05979f2f3..52d67f0ad 100644 --- a/tests/test_local_command_home.py +++ b/tests/test_local_command_home.py @@ -4,14 +4,10 @@ from __future__ import annotations import sys -from pathlib import Path -from unittest.mock import MagicMock import pytest -from typer.testing import CliRunner from solstone.think import sol_cli -from solstone.think.service import Reconciled LOCAL_COMMANDS = { "navigate": "solstone.think.tools.navigate", @@ -60,46 +56,6 @@ def test_local_commands_run_under_journal( } -@pytest.mark.parametrize("command", LOCAL_COMMANDS) -def test_local_commands_reject_under_sol(monkeypatch, capsys, command: str) -> None: - monkeypatch.setattr( - "solstone.think.service.reconcile_installed_unit", - lambda: Reconciled(False, None, None, None), - ) - monkeypatch.setattr(sys, "argv", ["sol", command]) - - with pytest.raises(SystemExit) as exc_info: - sol_cli.main() - - assert exc_info.value.code == 2 - assert ( - sol_cli.SOL_SERVICE_CMD_REMOVED_ERROR.format(cmd=command) - in capsys.readouterr().err - ) - - -def test_local_command_redirect_does_not_exec_stale_unrelated_unit( - monkeypatch, capsys -) -> None: - execv = MagicMock() - monkeypatch.setattr( - "solstone.think.service.reconcile_installed_unit", - lambda: Reconciled(True, "sol", "supervisor", Path("unit")), - ) - monkeypatch.setattr(sol_cli.os, "execv", execv) - monkeypatch.setattr(sys, "argv", ["sol", "navigate"]) - - with pytest.raises(SystemExit) as exc_info: - sol_cli.main() - - assert exc_info.value.code == 2 - assert ( - sol_cli.SOL_SERVICE_CMD_REMOVED_ERROR.format(cmd="navigate") - in capsys.readouterr().err - ) - execv.assert_not_called() - - def test_local_commands_are_journal_help_only(monkeypatch, capsys) -> None: monkeypatch.setattr(sol_cli, "print_status", lambda: None) @@ -108,37 +64,3 @@ def test_local_commands_are_journal_help_only(monkeypatch, capsys) -> None: for command in LOCAL_COMMANDS: assert command in journal_help assert sol_cli.COMMANDS[command].surface == "service" - - sol_cli.print_help() - sol_help = capsys.readouterr().out - owner_groups = sol_help.split("Apps (sol call ):", 1)[0] - for command in LOCAL_COMMANDS: - assert f" {command:16}" not in owner_groups - - -@pytest.mark.parametrize( - ("args", "pointer"), - [ - (["navigate", "/x"], "journal navigate"), - (["identity"], "journal identity"), - ], -) -def test_old_sol_call_paths_redirect(args: list[str], pointer: str) -> None: - from solstone.think.call import call_app - - result = CliRunner().invoke(call_app, args) - - assert result.exit_code != 0 - combined = result.output + result.stderr - assert "journal " in combined - assert pointer in combined - - -def test_old_sol_call_help_lists_moved_stubs() -> None: - from solstone.think.call import call_app - - result = CliRunner().invoke(call_app, ["--help"]) - - assert result.exit_code == 0 - assert "Moved to `journal navigate`." in result.output - assert "Moved to `journal identity`." in result.output diff --git a/tests/test_maintenance.py b/tests/test_maintenance.py index 8e7cd1d2b..8c61ec298 100644 --- a/tests/test_maintenance.py +++ b/tests/test_maintenance.py @@ -512,11 +512,8 @@ def test_cli_sync_malformed_schedules_prints_path_and_cause( assert "Expecting property name" in err -def test_cli_registration_is_service_surface_and_sol_rejects_maintenance( - monkeypatch, capsys -): +def test_cli_registration_is_service_surface(): from solstone.think import sol_cli - from solstone.think.service import Reconciled module_path, preset_args, surface = sol_cli.resolve_command("maintenance") assert module_path == "solstone.think.maintenance_cli" @@ -524,23 +521,6 @@ def test_cli_registration_is_service_surface_and_sol_rejects_maintenance( assert surface == "service" assert "maintenance" in sol_cli.service_help_group().commands - monkeypatch.setattr( - "solstone.think.service.reconcile_installed_unit", - lambda: Reconciled(False, None, None, None), - ) - monkeypatch.setattr( - sol_cli, - "run_command", - lambda _module_path: pytest.fail("service command should not run under sol"), - ) - monkeypatch.setattr(sys, "argv", ["sol", "maintenance"]) - - with pytest.raises(SystemExit) as exc_info: - sol_cli.main() - - assert exc_info.value.code == 2 - assert "maintenance" in capsys.readouterr().err - def test_register_then_scheduler_init_surfaces_runtime_cap(tmp_path, monkeypatch): _use_journal(tmp_path, monkeypatch) diff --git a/tests/test_profile_call_parity.py b/tests/test_profile_call_parity.py deleted file mode 100644 index 3fe4f508c..000000000 --- a/tests/test_profile_call_parity.py +++ /dev/null @@ -1,226 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -from __future__ import annotations - -import json -from urllib.parse import urlsplit - -import pytest -import requests -from typer.testing import CliRunner - -from solstone.think.convey_client import ConveyClient -from solstone.think.surfaces import profile as profile_surface -from solstone.think.tools.profile import app -from tests._baseline_harness import make_test_client, mark_setup_complete -from tests.test_surfaces_profile import ( - _activity_record, - _append_activity, - _configure_env, - _minimal_facet_tree, - _participant, - _utc_ms, - _write_facet_relationship, - _write_story_activity, -) - - -@pytest.fixture -def journal(tmp_path, monkeypatch): - _configure_env(tmp_path, monkeypatch) - mark_setup_complete(tmp_path) - return tmp_path - - -@pytest.fixture -def runner(journal, monkeypatch): - client = ConveyClient(session=make_test_client(journal), base_url="") - monkeypatch.setattr("solstone.think.tools.profile.get_client", lambda: client) - return CliRunner() - - -class _Response: - def __init__(self, body: object, status_code: int = 200) -> None: - self.status_code = status_code - self.text = json.dumps(body) - - -class _Html404Response: - status_code = 404 - text = "404 Not Found" - - -class _CaptureSession: - def __init__(self) -> None: - self.urls: list[str] = [] - - def get(self, url: str): - captured = _path_with_query(url) - self.urls.append(captured) - if captured.startswith("/api/profiles/active"): - return _Response({"items": [], "total": 0}) - return _Response({}) - - -class _MalformedEnvelopeSession: - def get(self, _url: str): - return _Response({"items": {}, "total": 1}) - - -def _seed_ravi(journal, monkeypatch) -> None: - _minimal_facet_tree( - journal, - journal_entities=({"id": "ravi", "name": "Ravi", "type": "Person"},), - ) - _write_facet_relationship(journal, "work", "ravi", description="Customer") - monkeypatch.setattr(profile_surface, "_today_day", lambda: "20260420") - _append_activity( - "work", - "20260418", - _activity_record( - "20260418", - [_participant("ravi", name="Ravi")], - record_id="meeting_cli", - ), - ) - _write_story_activity( - "work", - "20260418", - "meeting_story", - _utc_ms("20260418", 9), - commitments=[ - { - "owner": "Mina", - "owner_entity_id": "mina", - "action": "send proposal", - "counterparty": "Ravi", - "counterparty_entity_id": "ravi", - "when": "tomorrow", - "context": "Follow-up.", - } - ], - ) - - -def _path_with_query(url: str) -> str: - parsed = urlsplit(url) - return parsed.path + (f"?{parsed.query}" if parsed.query else "") - - -def test_profile_request_mapping(monkeypatch): - session = _CaptureSession() - client = ConveyClient(session=session, base_url="") - monkeypatch.setattr("solstone.think.tools.profile.get_client", lambda: client) - runner = CliRunner() - - full = runner.invoke( - app, - ["full", "Ravi", "--facets", "work", "--include-mentions", "--json"], - ) - brief = runner.invoke(app, ["brief", "Ravi", "--json"]) - cadence = runner.invoke(app, ["cadence", "Ravi", "--include-mentions", "--json"]) - active = runner.invoke(app, ["list-active", "--window-days", "45", "--json"]) - - assert full.exit_code == 0 - assert brief.exit_code == 0 - assert cadence.exit_code == 0 - assert active.exit_code == 0 - assert session.urls == [ - "/api/profile/Ravi?facets=work&include_mentions=true", - "/api/profile/Ravi/brief", - "/api/profile/Ravi/cadence?include_mentions=true", - "/api/profiles/active?window_days=45&limit=100&offset=0", - ] - - -def test_profile_name_path_encoding(monkeypatch): - session = _CaptureSession() - client = ConveyClient(session=session, base_url="") - monkeypatch.setattr("solstone.think.tools.profile.get_client", lambda: client) - runner = CliRunner() - - names = ["space name", "percent%name", "question?name", "hash#name"] - for name in names: - result = runner.invoke(app, ["full", name, "--json"]) - assert result.exit_code == 0 - - assert session.urls == [ - "/api/profile/space%20name", - "/api/profile/percent%25name", - "/api/profile/question%3Fname", - "/api/profile/hash%23name", - ] - - -def test_profile_full_json_and_plain(runner, journal, monkeypatch): - _seed_ravi(journal, monkeypatch) - - plain = runner.invoke(app, ["full", "Ravi"]) - json_result = runner.invoke(app, ["full", "Ravi", "--json"]) - - assert plain.exit_code == 0 - assert "Cadence:" in plain.stdout - assert json_result.exit_code == 0 - payload = json.loads(json_result.stdout) - assert payload["entity_id"] == "ravi" - assert "cadence" in payload - - -def test_profile_not_found_json_exits_without_stdout(runner, journal): - _minimal_facet_tree(journal) - - result = runner.invoke(app, ["full", "missing", "--json"]) - - assert result.exit_code == 1 - assert result.stderr == "profile not found: missing\n" - assert result.stdout == "" - - -def test_profile_bare_404_maps_to_not_found(monkeypatch): - class SlashSession: - def get(self, _url: str): - return _Html404Response() - - client = ConveyClient(session=SlashSession(), base_url="") - monkeypatch.setattr("solstone.think.tools.profile.get_client", lambda: client) - - result = CliRunner().invoke(app, ["full", "slash/name", "--json"]) - - assert result.exit_code == 1 - assert result.stderr == "profile not found: slash/name\n" - assert result.stdout == "" - - -def test_profile_list_active_malformed_envelope_exits_without_stdout(monkeypatch): - client = ConveyClient(session=_MalformedEnvelopeSession(), base_url="") - monkeypatch.setattr("solstone.think.tools.profile.get_client", lambda: client) - - result = CliRunner().invoke(app, ["list-active", "--json"]) - - assert result.exit_code == 1 - assert "I couldn't read the journal response." in result.stderr - assert result.stdout == "" - - -def test_profile_convey_down_prints_require_solstone_message(journal, monkeypatch): - class DownSession: - def get(self, _url): - raise requests.exceptions.ConnectionError() - - def post(self, _url, json=None): - raise requests.exceptions.ConnectionError() - - client = ConveyClient(session=DownSession(), base_url="http://localhost:5015") - monkeypatch.setattr("solstone.think.tools.profile.get_client", lambda: client) - monkeypatch.delenv("SOL_SKIP_SUPERVISOR_CHECK", raising=False) - monkeypatch.delenv("SOL_SUPERVISOR_SPAWNED", raising=False) - - result = CliRunner().invoke(app, ["list-active"]) - - assert result.exit_code == 1 - assert ( - result.stderr - == "sol: solstone isn't running. Start it with 'journal up' and retry.\n" - ) - assert result.stdout == "" diff --git a/tests/test_read_talent_cli_contract.py b/tests/test_read_talent_cli_contract.py index 4d503bf58..07bd4b37e 100644 --- a/tests/test_read_talent_cli_contract.py +++ b/tests/test_read_talent_cli_contract.py @@ -8,15 +8,19 @@ from __future__ import annotations import re from pathlib import Path -import typer - -from solstone.apps.entities.call import app as entities_app +import scripts.build_native_sol_inventory as inventory ROOT = Path(__file__).resolve().parents[1] def _live_entities_verbs() -> set[str]: - return set(typer.main.get_command(entities_app).commands.keys()) + return { + entry.path[1] + for entry in inventory.discover(inventory.REPO_ROOT) + if entry.surface == "sol-call" + and len(entry.path) >= 2 + and entry.path[0] == "entities" + } def _extract_entities_verbs_from_read_talent(text: str) -> set[str]: diff --git a/tests/test_service.py b/tests/test_service.py index d0f2367b7..21ef9b8b0 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -1987,7 +1987,7 @@ class TestRegistry: def test_service_group_exists(self): from solstone.think import sol_cli as sol - assert sol.service_help_group().heading == sol.SOL_HELP_GROUP_SERVICE_HEADING + assert sol.service_help_group().heading == "Journal service commands" assert "service" in sol.service_help_group().commands diff --git a/tests/test_settings_call_parity.py b/tests/test_settings_call_parity.py deleted file mode 100644 index e89a8948e..000000000 --- a/tests/test_settings_call_parity.py +++ /dev/null @@ -1,356 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -from __future__ import annotations - -import json -from datetime import datetime, timezone -from pathlib import Path -from typing import Any -from urllib.parse import urlsplit - -import pytest -from typer.testing import CliRunner - -import solstone.apps.settings.call as settings_call -import solstone.apps.settings.routes as settings_routes -from solstone.think.convey_client import ConveyClient -from tests._baseline_harness import make_test_client - -runner = CliRunner() - -API_ENV_KEYS = ( - "GOOGLE_API_KEY", - "ANTHROPIC_API_KEY", - "OPENAI_API_KEY", - "PLAUD_ACCESS_TOKEN", - "GOOGLE_APPLICATION_CREDENTIALS", -) - - -class _FixedDateTime: - @classmethod - def now(cls, tz=None): - return datetime(2026, 4, 17, 12, 0, tzinfo=tz or timezone.utc) - - -class _CaptureSession: - def __init__(self, inner): - self.inner = inner - self.gets: list[str] = [] - self.posts: list[tuple[str, dict[str, Any] | None]] = [] - - def get(self, url: str): - self.gets.append(urlsplit(url).path) - return self.inner.get(url) - - def post(self, url: str, json=None): - self.posts.append((urlsplit(url).path, json)) - return self.inner.post(url, json=json) - - def put(self, url: str, json=None): - return self.inner.put(url, json=json) - - def delete(self, url: str): - return self.inner.delete(url) - - -@pytest.fixture(autouse=True) -def _settings_client(journal_copy: Path, monkeypatch: pytest.MonkeyPatch) -> None: - for key in API_ENV_KEYS: - monkeypatch.delenv(key, raising=False) - client = ConveyClient( - session=make_test_client(journal_copy), - base_url="", - ) - monkeypatch.setattr(settings_call, "get_client", lambda: client) - monkeypatch.setenv("SOL_SKIP_SUPERVISOR_CHECK", "1") - - -@pytest.fixture -def fake_validators(monkeypatch: pytest.MonkeyPatch) -> None: - def validate_token(token: str) -> dict[str, Any]: - return {"valid": True, "token": token[-4:]} - - monkeypatch.setattr(settings_routes, "datetime", _FixedDateTime) - monkeypatch.setattr("solstone.think.importers.plaud.validate_token", validate_token) - - -def _read_config(journal: Path) -> dict[str, Any]: - return json.loads((journal / "config" / "journal.json").read_text(encoding="utf-8")) - - -def _write_config(journal: Path, payload: dict[str, Any]) -> None: - (journal / "config" / "journal.json").write_text( - json.dumps(payload, indent=2) + "\n", - encoding="utf-8", - ) - - -def _assert_json(result, expected: Any) -> None: - assert result.exit_code == 0 - assert json.loads(result.stdout) == expected - assert result.stderr == "" - - -def test_show_and_read_verbs_select_http_fields(journal_copy: Path) -> None: - show = runner.invoke(settings_call.app, ["show"]) - keys = runner.invoke(settings_call.app, ["keys", "show"]) - transcribe = runner.invoke(settings_call.app, ["transcribe", "show"]) - identity = runner.invoke(settings_call.app, ["identity", "show"]) - observer = runner.invoke(settings_call.app, ["observer", "show"]) - - assert show.exit_code == 0 - show_payload = json.loads(show.stdout) - assert list(show_payload) == [ - "identity", - "transcribe", - "observe", - "keys", - ] - assert show_payload["identity"]["name"] == "Test User" - assert list(show_payload["keys"]) == [ - "PLAUD_ACCESS_TOKEN", - ] - - assert keys.exit_code == 0 - assert json.loads(keys.stdout) == {key: False for key in show_payload["keys"]} - assert transcribe.exit_code == 0 - assert set(json.loads(transcribe.stdout)) == {"backends", "api_keys", "config"} - assert identity.exit_code == 0 - assert json.loads(identity.stdout)["name"] == "Test User" - assert observer.exit_code == 0 - assert json.loads(observer.stdout)["tmux"]["capture_interval"] == 5 - - -def test_settings_config_projects_service_validation_only(journal_copy: Path) -> None: - config = _read_config(journal_copy) - config["service_key_validation"] = { - "plaud": {"valid": False, "error": "bad token"}, - } - _write_config(journal_copy, config) - - payload = settings_call.get_client().request("GET", "/app/settings/api/config") - - assert "providers" not in payload - assert payload["key_validation"] == { - "plaud": {"valid": False, "error": "bad token"}, - } - - -def test_keys_set_clear_validate_and_invalid_env( - journal_copy: Path, - fake_validators: None, -) -> None: - invalid = runner.invoke(settings_call.app, ["keys", "set", "BOGUS", "value"]) - assert invalid.exit_code == 1 - assert invalid.stderr == ( - "Invalid env var: BOGUS. Must be one of: PLAUD_ACCESS_TOKEN\n" - ) - - ai_key = runner.invoke( - settings_call.app, - ["keys", "set", "ANTHROPIC_API_KEY", "anthropic-test-key"], - ) - assert ai_key.exit_code == 1 - assert ai_key.stderr.startswith("Invalid env var: ANTHROPIC_API_KEY.") - assert "ANTHROPIC_API_KEY" not in _read_config(journal_copy).get("env", {}) - - service_set = runner.invoke( - settings_call.app, - ["keys", "set", "PLAUD_ACCESS_TOKEN", "plaud-token"], - ) - assert service_set.exit_code == 0 - assert json.loads(service_set.stdout) == { - "env_var": "PLAUD_ACCESS_TOKEN", - "set": True, - "validation": { - "valid": True, - "token": "oken", - "timestamp": "2026-04-17T12:00:00+00:00", - }, - } - assert _read_config(journal_copy)["service_key_validation"]["plaud"]["valid"] - keys_shown = runner.invoke(settings_call.app, ["keys", "show"]) - assert keys_shown.exit_code == 0 - assert "plaud-token" not in keys_shown.stdout - - cleared = runner.invoke(settings_call.app, ["keys", "clear", "PLAUD_ACCESS_TOKEN"]) - _assert_json(cleared, {"env_var": "PLAUD_ACCESS_TOKEN", "cleared": True}) - assert _read_config(journal_copy)["env"]["PLAUD_ACCESS_TOKEN"] == "" - - before = (journal_copy / "config" / "journal.json").read_text(encoding="utf-8") - validate = runner.invoke(settings_call.app, ["keys", "validate"]) - assert validate.exit_code == 0 - assert json.loads(validate.stdout)["key_validation"] == {} - assert (journal_copy / "config" / "journal.json").read_text( - encoding="utf-8" - ) == before - - runner.invoke( - settings_call.app, ["keys", "set", "PLAUD_ACCESS_TOKEN", "plaud-token"] - ) - cached = runner.invoke(settings_call.app, ["keys", "validate", "--cache-result"]) - assert cached.exit_code == 0 - assert _read_config(journal_copy)["service_key_validation"]["plaud"]["valid"] - - -def test_transcribe_setters(journal_copy: Path) -> None: - transcribe_bad = runner.invoke( - settings_call.app, - ["transcribe", "set-backend", "invalid"], - ) - assert transcribe_bad.exit_code == 1 - assert transcribe_bad.stderr == ( - "Invalid backend: invalid. Must be one of: parakeet, parakeet-cpp\n" - ) - - transcribe_set = runner.invoke( - settings_call.app, - ["transcribe", "set-backend", "parakeet-cpp"], - ) - assert transcribe_set.exit_code == 0 - assert json.loads(transcribe_set.stdout)["backend"] == "parakeet-cpp" - assert _read_config(journal_copy)["transcribe"]["backend"] == "parakeet-cpp" - - -def test_processing_show_uses_effective_settings_endpoint( - journal_copy: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - session = _CaptureSession(make_test_client(journal_copy)) - client = ConveyClient(session=session, base_url="") - monkeypatch.setattr(settings_call, "get_client", lambda: client) - - result = runner.invoke(settings_call.app, ["processing", "show"]) - - assert result.exit_code == 0 - assert session.gets == ["/app/settings/api/processing"] - payload = json.loads(result.stdout) - assert payload["mode"] == "realtime" - assert payload["gate"]["time_window"]["start"] == "02:00" - assert result.stderr == "" - - -def test_processing_set_posts_only_supplied_fields( - journal_copy: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - session = _CaptureSession(make_test_client(journal_copy)) - client = ConveyClient(session=session, base_url="") - monkeypatch.setattr(settings_call, "get_client", lambda: client) - - result = runner.invoke( - settings_call.app, - ["processing", "set", "--mode", "deferred", "--window-start", "03:00"], - ) - - assert result.exit_code == 0 - assert session.posts == [ - ( - "/app/settings/api/config", - { - "section": "processing", - "data": { - "mode": "deferred", - "gate": {"time_window": {"start": "03:00"}}, - }, - }, - ) - ] - payload = json.loads(result.stdout) - assert payload["mode"] == "deferred" - assert payload["gate"]["time_window"]["start"] == "03:00" - assert result.stderr == "" - - -def test_processing_set_surfaces_invalid_config_detail() -> None: - result = runner.invoke( - settings_call.app, - ["processing", "set", "--mode", "bogus"], - ) - - assert result.exit_code == 1 - assert result.stdout == "" - assert result.stderr == "processing mode must be realtime or deferred\n" - - -def test_identity_and_observer_setters(journal_copy: Path) -> None: - bad_pronouns = runner.invoke( - settings_call.app, - ["identity", "set", "--pronouns", "{bad"], - ) - assert bad_pronouns.exit_code == 1 - assert bad_pronouns.stderr == "Invalid JSON in pronouns\n" - - name = runner.invoke(settings_call.app, ["identity", "set", "--name", "New Name"]) - assert name.exit_code == 0 - assert json.loads(name.stdout)["name"] == "New Name" - - add_email = runner.invoke( - settings_call.app, - ["identity", "set", "--add-email", "new@example.com"], - ) - assert add_email.exit_code == 0 - assert "new@example.com" in json.loads(add_email.stdout)["email_addresses"] - - remove_email = runner.invoke( - settings_call.app, - ["identity", "set", "--remove-email", "test@example.com"], - ) - assert remove_email.exit_code == 0 - assert "test@example.com" not in json.loads(remove_email.stdout)["email_addresses"] - - observer_bad = runner.invoke( - settings_call.app, - ["observer", "set", "--capture-interval", "100"], - ) - assert observer_bad.exit_code == 1 - assert observer_bad.stderr == ( - "tmux.capture_interval must be an integer between 1 and 60\n" - ) - - observer_set = runner.invoke( - settings_call.app, - ["observer", "set", "--no-enabled", "--capture-interval", "10"], - ) - assert observer_set.exit_code == 0 - assert json.loads(observer_set.stdout) == { - "capture_interval": 10, - "enabled": False, - } - assert _read_config(journal_copy)["observe"]["tmux"] == { - "capture_interval": 10, - "enabled": False, - } - - -def test_convey_status_dashboard_url( - journal_copy: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - status = runner.invoke(settings_call.app, ["convey", "status"]) - assert status.exit_code == 0 - assert status.stdout == ( - "convey\n" - " bind: 127.0.0.1:5015\n" - " dashboard url: http://localhost:5015\n" - ) - - config_before = _read_config(journal_copy) - manual_status = runner.invoke(settings_call.app, ["convey", "status"]) - assert manual_status.exit_code == 0 - assert "dashboard url: http://localhost:5015" in manual_status.stdout - assert _read_config(journal_copy) == config_before - - calls = [] - with monkeypatch.context() as m: - m.setattr(settings_call, "get_client", lambda: calls.append("called")) - missing = runner.invoke(settings_call.app, ["convey", "host-url"]) - assert missing.exit_code != 0 - assert "No such command" in missing.stderr - assert calls == [] - - client = make_test_client(journal_copy) - response = client.get("/app/settings/api/convey/host-url") - assert response.status_code == 404 diff --git a/tests/test_skills_build.py b/tests/test_skills_build.py index 5c36bba9c..69c9d0c86 100644 --- a/tests/test_skills_build.py +++ b/tests/test_skills_build.py @@ -22,6 +22,11 @@ def _copy_fragment_tree(tmp_path: Path) -> Path: dst = root / rel_path dst.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(src, dst) + for src in sorted((REAL_ROOT / "solstone").glob("**/native/**/authority.toml")): + rel_path = src.relative_to(REAL_ROOT) + dst = root / rel_path + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, dst) return root diff --git a/tests/test_sol.py b/tests/test_sol.py index 8cca6c009..126e71545 100644 --- a/tests/test_sol.py +++ b/tests/test_sol.py @@ -1,7 +1,9 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright (c) 2026 sol pbc -"""Tests for sol.py unified CLI.""" +"""Tests for the surviving journal CLI dispatcher.""" + +from __future__ import annotations import logging import os @@ -13,7 +15,6 @@ from unittest.mock import MagicMock, patch import pytest from solstone.think import sol_cli as sol -from solstone.think.sol_cli import JOURNAL_ACCESS_CMD_ERROR REPO_ROOT = Path(__file__).resolve().parent.parent EMPTY_PACKAGING_VERSIONS = { @@ -52,12 +53,6 @@ def service_command_names() -> list[str]: ) -def access_command_names() -> list[str]: - return sorted( - name for name, command in sol.COMMANDS.items() if command.surface == "access" - ) - - def universal_command_names() -> list[str]: return sorted( name for name, command in sol.COMMANDS.items() if command.surface == "universal" @@ -72,7 +67,7 @@ def service_alias_names() -> list[str]: ] -def run_dispatch(monkeypatch, binary: str, name: str) -> dict[str, object]: +def run_journal_dispatch(monkeypatch, name: str) -> dict[str, object]: result: dict[str, object] = {} def fake_run_command(module_path: str) -> int: @@ -82,37 +77,31 @@ def run_dispatch(monkeypatch, binary: str, name: str) -> dict[str, object]: monkeypatch.setattr(sol, "run_command", fake_run_command) monkeypatch.setattr(sol.setproctitle, "setproctitle", lambda _title: None) - monkeypatch.setattr(sys, "argv", [binary, name]) + monkeypatch.setattr(sys, "argv", ["journal", name]) with pytest.raises(SystemExit) as exc_info: - if binary == "journal": - sol.journal_main() - else: - sol.main() + sol.journal_main() assert exc_info.value.code == 0 return result -class TestResolveCommand: - """Tests for resolve_command() function.""" +def test_python_sol_surface_is_removed() -> None: + assert not hasattr(sol, "main") + assert not hasattr(sol, "ACCESS_HELP_GROUPS") + assert not hasattr(sol, "SOL_SERVICE_CMD_REMOVED_ERROR") + assert not hasattr(sol, "print_help") + assert all(command.surface != "access" for command in sol.COMMANDS.values()) - def test_resolve_known_command(self): - """Test resolving a known command from registry.""" - module_path, preset_args, surface = sol.resolve_command("import") - assert module_path == "solstone.think.import_client" - assert preset_args == [] - assert surface == "access" +class TestResolveCommand: def test_resolve_importer_service_command(self): - """Test resolving the service-side import engine.""" module_path, preset_args, surface = sol.resolve_command("importer") assert module_path == "solstone.think.importers.cli" assert preset_args == [] assert surface == "service" def test_resolve_direct_module_path(self): - """Test resolving a direct module path with dot.""" module_path, preset_args, surface = sol.resolve_command( "solstone.think.importers.cli" ) @@ -121,7 +110,6 @@ class TestResolveCommand: assert surface == "service" def test_resolve_nested_module_path(self): - """Test resolving a deeply nested module path.""" module_path, preset_args, surface = sol.resolve_command( "solstone.observe.linux.observer" ) @@ -130,14 +118,11 @@ class TestResolveCommand: assert surface == "service" def test_resolve_unknown_command_raises(self): - """Test that unknown command raises ValueError.""" with pytest.raises(ValueError) as exc_info: sol.resolve_command("nonexistent") assert "Unknown command: nonexistent" in str(exc_info.value) def test_resolve_alias_with_preset_args(self): - """Test resolving an alias that includes preset arguments.""" - # Add a test alias sol.ALIASES["test-alias"] = sol.Alias( "solstone.think.indexer", ["--rescan"], "service" ) @@ -150,18 +135,16 @@ class TestResolveCommand: del sol.ALIASES["test-alias"] def test_alias_takes_precedence_over_command(self): - """Test that aliases override commands with same name.""" - # Add an alias that shadows a command - sol.ALIASES["import"] = sol.Alias( + sol.ALIASES["importer"] = sol.Alias( "solstone.think.cluster", ["--force"], "service" ) try: - module_path, preset_args, surface = sol.resolve_command("import") + module_path, preset_args, surface = sol.resolve_command("importer") assert module_path == "solstone.think.cluster" assert preset_args == ["--force"] assert surface == "service" finally: - del sol.ALIASES["import"] + del sol.ALIASES["importer"] class TestJournalCoherenceGuard: @@ -254,10 +237,7 @@ class TestJournalCoherenceGuard: class TestRunCommand: - """Tests for run_command() function.""" - def test_run_command_success(self): - """Test running a command that exits cleanly.""" mock_module = MagicMock() mock_module.main = MagicMock(return_value=None) @@ -267,7 +247,6 @@ class TestRunCommand: mock_module.main.assert_called_once() def test_run_command_with_system_exit(self): - """Test running a command that calls sys.exit(0).""" mock_module = MagicMock() mock_module.main = MagicMock(side_effect=SystemExit(0)) @@ -276,7 +255,6 @@ class TestRunCommand: assert exit_code == 0 def test_run_command_with_nonzero_exit(self): - """Test running a command that calls sys.exit(1).""" mock_module = MagicMock() mock_module.main = MagicMock(side_effect=SystemExit(1)) @@ -285,7 +263,6 @@ class TestRunCommand: assert exit_code == 1 def test_run_command_with_string_exit(self, capsys): - """Test running a command that raises SystemExit with a string message.""" mock_module = MagicMock() mock_module.main = MagicMock(side_effect=SystemExit("Error: something failed")) @@ -297,15 +274,13 @@ class TestRunCommand: assert "Error: something failed" in captured.err def test_run_command_import_error(self): - """Test handling ImportError for nonexistent module.""" with patch( "importlib.import_module", side_effect=ImportError("No module named 'fake'") ): exit_code = sol.run_command("fake.module") assert exit_code == 1 - def test_run_command_access_import_error_keeps_raw_error(self, capsys): - """Import errors keep the canonical raw message.""" + def test_run_command_import_error_keeps_raw_error(self, capsys): missing = ModuleNotFoundError("No module named 'numpy'", name="numpy") with patch("importlib.import_module", side_effect=missing): exit_code = sol.run_command("solstone.think.notify_cli") @@ -316,15 +291,15 @@ class TestRunCommand: assert "solstone[journal]" not in captured.err def test_run_command_no_main_function(self): - """Test handling module without main() function.""" - mock_module = MagicMock(spec=[]) # No 'main' attribute + mock_module = MagicMock(spec=[]) with patch("importlib.import_module", return_value=mock_module): exit_code = sol.run_command("test.module") assert exit_code == 1 - def test_main_propagates_integer_return_code_via_real_subprocess(self, tmp_path): - """Would fail on the parent commit because cmd_journal() returned 1 but journal exited 0.""" + def test_journal_main_propagates_integer_return_code_via_real_subprocess( + self, tmp_path + ): env = {**os.environ, "SOLSTONE_JOURNAL": str(tmp_path)} code = ( "from solstone.think.sol_cli import journal_main; " @@ -344,10 +319,7 @@ class TestRunCommand: class TestGetStatus: - """Tests for get_status() function.""" - def test_status_with_override(self, monkeypatch, tmp_path): - """Test status when journal env is set and exists.""" monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) status = sol.get_status() @@ -356,7 +328,6 @@ class TestGetStatus: assert status["journal_exists"] is True def test_status_with_nonexistent_journal(self, monkeypatch, tmp_path): - """Test status when the journal env points to a nonexistent dir.""" nonexistent = tmp_path / "nonexistent" monkeypatch.setenv("SOLSTONE_JOURNAL", str(nonexistent)) @@ -366,7 +337,6 @@ class TestGetStatus: assert status["journal_exists"] is False def test_status_without_override(self, monkeypatch): - """Test status when no journal env is set uses source-tree fallback.""" monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) monkeypatch.setattr("solstone.think.user_config.read_user_config", lambda: {}) status = sol.get_status() @@ -375,24 +345,10 @@ class TestGetStatus: assert isinstance(status["journal_exists"], bool) -class TestMain: - """Tests for main() function.""" - - def test_main_no_args_shows_help(self, monkeypatch, capsys): - """Test that running with no args shows help.""" - monkeypatch.setattr(sys, "argv", ["sol"]) - monkeypatch.setenv("SOLSTONE_JOURNAL", "/tmp/test") - - sol.main() - - captured = capsys.readouterr() - assert "sol - journal access CLI (solstone)" in captured.out - assert "Usage: sol " in captured.out - +class TestJournalMain: def test_journal_bare_verbose_shows_help_and_configures_debug( self, monkeypatch, capsys ): - """Test bare journal -v shows help after enabling DEBUG logging.""" calls = [] monkeypatch.setattr(sys, "argv", ["journal", "-v"]) monkeypatch.setenv("SOLSTONE_JOURNAL", "/tmp/test") @@ -410,7 +366,6 @@ class TestMain: assert calls == [{"level": logging.DEBUG}] def test_leading_verbose_strips_before_dispatch(self, monkeypatch): - """Test leading sol -v is consumed before subcommand argv rewrite.""" captured: dict[str, object] = {} calls = [] @@ -429,84 +384,68 @@ class TestMain: monkeypatch.setattr( sys, "argv", - ["sol", "-v", "import", "--day", "20250101"], + ["journal", "-v", "importer", "--day", "20250101"], ) with pytest.raises(SystemExit) as exc_info: - sol.main() + sol.journal_main() rewritten_argv = captured["argv"] assert exc_info.value.code == 0 - assert captured["module"] == "solstone.think.import_client" + assert captured["module"] == "solstone.think.importers.cli" assert isinstance(rewritten_argv, list) - assert rewritten_argv[0] == "sol import" + assert rewritten_argv[0] == "journal importer" assert "-v" not in rewritten_argv assert "--verbose" not in rewritten_argv assert "--day" in rewritten_argv assert "20250101" in rewritten_argv assert calls == [{"level": logging.DEBUG}] - def test_main_help_flag(self, monkeypatch, capsys): - """Test --help flag shows help.""" - monkeypatch.setattr(sys, "argv", ["sol", "--help"]) - monkeypatch.setenv("SOLSTONE_JOURNAL", "/tmp/test") - - sol.main() - - captured = capsys.readouterr() - assert "sol - journal access CLI (solstone)" in captured.out - - def test_main_help_command_without_question(self, monkeypatch, capsys): - """Test bare 'help' command shows static help.""" - monkeypatch.setattr(sys, "argv", ["sol", "help"]) + def test_journal_help_flag(self, monkeypatch, capsys): + monkeypatch.setattr(sys, "argv", ["journal", "--help"]) monkeypatch.setenv("SOLSTONE_JOURNAL", "/tmp/test") - sol.main() + sol.journal_main() captured = capsys.readouterr() - assert "sol - journal access CLI (solstone)" in captured.out + assert "journal - the journal host CLI (solstone)" in captured.out - def test_main_version_flag(self, monkeypatch, capsys): - """Test --version flag shows version.""" - monkeypatch.setattr(sys, "argv", ["sol", "--version"]) + def test_journal_version_flag(self, monkeypatch, capsys): + monkeypatch.setattr(sys, "argv", ["journal", "--version"]) - sol.main() + sol.journal_main() captured = capsys.readouterr() - assert "sol (solstone)" in captured.out + assert "journal (solstone)" in captured.out - def test_main_path_flag(self, monkeypatch, capsys): - """Test --path flag prints resolved journal path.""" - monkeypatch.setattr(sys, "argv", ["sol", "--path"]) + def test_journal_path_flag(self, monkeypatch, capsys): + monkeypatch.setattr(sys, "argv", ["journal", "--path"]) monkeypatch.setenv("SOLSTONE_JOURNAL", "/tmp/test-journal") - sol.main() + sol.journal_main() captured = capsys.readouterr() assert captured.out.strip() == "/tmp/test-journal" - def test_main_path_flag_default(self, monkeypatch, capsys): - """Test --path prints project root journal when no override set.""" - monkeypatch.setattr(sys, "argv", ["sol", "--path"]) + def test_journal_path_flag_default(self, monkeypatch, capsys): + monkeypatch.setattr(sys, "argv", ["journal", "--path"]) monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) - sol.main() + sol.journal_main() captured = capsys.readouterr() path = captured.out.strip() assert path != "" assert path.endswith("/journal") - def test_main_unknown_command_exits(self, monkeypatch): - """Test that unknown command exits with code 1.""" - monkeypatch.setattr(sys, "argv", ["sol", "unknown-command"]) + def test_journal_unknown_command_exits(self, monkeypatch): + monkeypatch.setattr(sys, "argv", ["journal", "unknown-command"]) with pytest.raises(SystemExit) as exc_info: - sol.main() + sol.journal_main() assert exc_info.value.code == 1 - def test_main_adjusts_sys_argv(self, monkeypatch): - """Test that sys.argv is adjusted for subcommand.""" - monkeypatch.setattr(sys, "argv", ["sol", "import", "--day", "20250101"]) + def test_journal_adjusts_sys_argv(self, monkeypatch): + monkeypatch.setattr(sys, "argv", ["journal", "importer", "--day", "20250101"]) captured_argv = [] @@ -518,43 +457,37 @@ class TestMain: with patch("importlib.import_module", return_value=mock_module): with pytest.raises(SystemExit): - sol.main() + sol.journal_main() - assert captured_argv[0] == "sol import" + assert captured_argv[0] == "journal importer" assert "--day" in captured_argv assert "20250101" in captured_argv class TestCommandRegistry: - """Tests for command registry completeness.""" - def test_all_commands_have_modules(self): - """Test that all registered commands point to valid module paths.""" for cmd, command in sol.COMMANDS.items(): assert "." in command.module, f"Command '{cmd}' has invalid module path" - def test_groups_contain_valid_commands(self): - """Test that all commands in groups exist in registry.""" - for group in sol.help_groups(): - for cmd in group.commands: - assert cmd in sol.COMMANDS, ( - f"Command '{cmd}' in group '{group.heading}' not in registry" - ) + def test_service_group_contains_valid_commands(self): + for cmd in sol.service_help_group().commands: + assert cmd in sol.COMMANDS, f"Command '{cmd}' not in registry" def test_critical_commands_registered(self): - """Test that critical commands are registered.""" - critical = ["import", "brain", "think", "indexer", "transcribe"] + critical = ["importer", "brain", "think", "indexer", "transcribe"] for cmd in critical: assert cmd in sol.COMMANDS, f"Critical command '{cmd}' not registered" + def test_access_commands_are_not_registered(self): + for cmd in ("import", "chat", "call", "notify", "skills"): + assert cmd not in sol.COMMANDS + def test_services_namespace_removed(self): - """The dissolved services switchboard is not a journal CLI namespace.""" assert "services" not in sol.COMMANDS assert "services" not in sol.service_help_group().commands def test_every_registry_entry_has_surface_tag(self): - """All commands and aliases declare the CLI surface they belong to.""" - valid_surfaces = {"access", "service", "universal"} + valid_surfaces = {"service", "universal"} for name, command in sol.COMMANDS.items(): assert command.surface in valid_surfaces, ( f"Command '{name}' has invalid surface '{command.surface}'" @@ -565,16 +498,15 @@ class TestCommandRegistry: ) def test_service_entries_dispatch_through_journal(self, monkeypatch): - """Service commands and aliases preserve module and preset args on journal.""" for name in service_command_names(): - journal_result = run_dispatch(monkeypatch, "journal", name) + journal_result = run_journal_dispatch(monkeypatch, name) command = sol.COMMANDS[name] assert journal_result["module"] == command.module assert journal_result["argv"] == [f"journal {name}"] for name in service_alias_names(): - journal_result = run_dispatch(monkeypatch, "journal", name) + journal_result = run_journal_dispatch(monkeypatch, name) command_alias = sol.ALIASES[name] assert journal_result["module"] == command_alias.module @@ -584,47 +516,19 @@ class TestCommandRegistry: ) @pytest.mark.parametrize("name", universal_command_names()) - def test_universal_entries_dispatch_through_sol_and_journal( - self, monkeypatch, name - ): - """Universal commands are available from both binaries.""" + def test_universal_entries_dispatch_through_journal(self, monkeypatch, name): command = sol.COMMANDS[name] - sol_result = run_dispatch(monkeypatch, "sol", name) - journal_result = run_dispatch(monkeypatch, "journal", name) + journal_result = run_journal_dispatch(monkeypatch, name) - assert sol_result["module"] == command.module - assert sol_result["argv"] == [f"sol {name}"] assert journal_result["module"] == command.module assert journal_result["argv"] == [f"journal {name}"] - @pytest.mark.parametrize("name", access_command_names()) - def test_journal_rejects_access_tagged_commands(self, monkeypatch, capsys, name): - """The journal binary exposes only service-tagged registry entries.""" + def test_journal_import_is_unknown(self, monkeypatch, capsys): monkeypatch.setattr( sol, "run_command", - lambda _module_path, **_kwargs: pytest.fail( - "access command should not run" - ), - ) - monkeypatch.setattr(sys, "argv", ["journal", name]) - - with pytest.raises(SystemExit) as exc_info: - sol.journal_main() - - captured = capsys.readouterr() - assert exc_info.value.code == 2 - assert JOURNAL_ACCESS_CMD_ERROR.format(cmd=name) in captured.err - - def test_journal_import_keeps_access_routing_error(self, monkeypatch, capsys): - """The service-side engine is `journal importer`; `journal import` remains invalid.""" - monkeypatch.setattr( - sol, - "run_command", - lambda _module_path, **_kwargs: pytest.fail( - "journal import should not run" - ), + lambda _module_path: pytest.fail("journal import should not run"), ) monkeypatch.setattr(sys, "argv", ["journal", "import", "--help"]) @@ -632,11 +536,10 @@ class TestCommandRegistry: sol.journal_main() captured = capsys.readouterr() - assert exc_info.value.code == 2 - assert JOURNAL_ACCESS_CMD_ERROR.format(cmd="import") in captured.err + assert exc_info.value.code == 1 + assert "Unknown command: import" in captured.err def test_journal_help_lists_service_and_universal_surfaces(self): - """journal --help renders service and universal command lists.""" code = ( "from solstone.think.sol_cli import journal_main; " "import sys; " @@ -659,70 +562,15 @@ class TestCommandRegistry: } for name in service_command_names() + universal_command_names(): assert name in rendered_commands - for name in access_command_names(): - assert name not in rendered_commands assert "sol call" not in result.stdout - def test_sol_help_lists_access_groups_only(self): - """sol --help renders only access top-level entries.""" - code = ( - "from solstone.think.sol_cli import main; " - "import sys; " - "sys.argv = ['sol', '--help']; " - "main()" - ) - result = subprocess.run( - [sys.executable, "-c", code], - capture_output=True, - text=True, - cwd=REPO_ROOT, - timeout=60, - ) - - assert result.returncode == 0, result.stderr - lines = result.stdout.splitlines() - rendered_commands = { - line.strip().split()[0] - for line in lines - if line.strip().split() and line.strip().split()[0] in sol.COMMANDS - } - expected_group_headers = {group.heading for group in sol.help_groups()} - rendered_group_headers = { - line for line in lines if line in expected_group_headers - } - - rendered_aliases = set() - in_aliases = False - for line in lines: - if line == sol.SOL_HELP_GROUP_ALIASES: - in_aliases = True - continue - if in_aliases and not line.strip(): - break - if in_aliases: - name = line.strip().split()[0] - if name in sol.ALIASES: - rendered_aliases.add(name) - - assert rendered_commands == set( - access_command_names() + universal_command_names() - ) - assert rendered_group_headers == expected_group_headers - assert rendered_aliases == set() - - def test_setproctitle_prefix_uses_active_binary(self, monkeypatch): - """The process title identifies whether sol or journal dispatched the command.""" + def test_setproctitle_prefix_uses_journal_binary(self, monkeypatch): titles = [] - monkeypatch.setattr(sol, "run_command", lambda _module_path, **_kwargs: 0) + monkeypatch.setattr(sol, "run_command", lambda _module_path: 0) monkeypatch.setattr(sol.setproctitle, "setproctitle", titles.append) - monkeypatch.setattr(sys, "argv", ["sol", "chat"]) - with pytest.raises(SystemExit): - sol.main() - monkeypatch.setattr(sys, "argv", ["journal", "supervisor"]) with pytest.raises(SystemExit): sol.journal_main() - assert titles[0].startswith("sol:") - assert titles[1].startswith("journal:") + assert titles == ["journal:supervisor"] diff --git a/tests/test_sol_call_parity.py b/tests/test_sol_call_parity.py deleted file mode 100644 index 3acd4ccc6..000000000 --- a/tests/test_sol_call_parity.py +++ /dev/null @@ -1,119 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -from __future__ import annotations - -import json -import re - -import pytest -import requests -from typer.testing import CliRunner - -from solstone.apps.sol.call import app -from solstone.think.convey_client import ConveyClient -from solstone.think.journal_config import read_journal_config -from tests._baseline_harness import make_test_client, mark_setup_complete -from tests.helpers.journal_config import seed_journal_config - - -@pytest.fixture -def journal(tmp_path, monkeypatch): - monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - mark_setup_complete(tmp_path) - return tmp_path - - -@pytest.fixture -def runner(journal, monkeypatch): - client = ConveyClient(session=make_test_client(journal), base_url="") - monkeypatch.setattr("solstone.apps.sol.call.get_client", lambda: client) - return CliRunner() - - -def test_set_name_updates_config(runner) -> None: - seed_journal_config({"setup": {"completed_at": 1700000000000}}) - - result = runner.invoke(app, ["set-name", "aria", "--status", "chosen"]) - - assert result.exit_code == 0 - output = json.loads(result.stdout) - assert output["name"] == "aria" - assert output["name_status"] == "chosen" - assert re.fullmatch(r"\d{4}-\d{2}-\d{2}", output["named_date"]) - assert read_journal_config()["agent"] == output - - -def test_reset_updates_agent(runner) -> None: - seed_journal_config( - { - "setup": {"completed_at": 1700000000000}, - "agent": { - "name": "aria", - "name_status": "chosen", - "named_date": "2026-04-19", - }, - } - ) - - result = runner.invoke(app, ["reset"]) - - assert result.exit_code == 0 - assert json.loads(result.stdout) == { - "name": "sol", - "name_status": "default", - "named_date": None, - } - assert read_journal_config()["agent"]["name"] == "sol" - - -def test_set_owner_name_only_and_bio(runner) -> None: - seed_journal_config({"setup": {"completed_at": 1700000000000}}) - - name_only = runner.invoke(app, ["set-owner", "Jer"]) - with_bio = runner.invoke(app, ["set-owner", "Jer", "--bio", "Building solstone"]) - - assert name_only.exit_code == 0 - assert json.loads(name_only.stdout) == {"name": "Jer", "bio": ""} - assert with_bio.exit_code == 0 - assert json.loads(with_bio.stdout) == { - "name": "Jer", - "bio": "Building solstone", - } - config = read_journal_config() - assert config["identity"]["name"] == "Jer" - assert config["identity"]["bio"] == "Building solstone" - - -def test_sol_init_creates_identity_files(runner, journal) -> None: - result = runner.invoke(app, ["sol-init"]) - - assert result.exit_code == 0 - assert json.loads(result.stdout) == { - "identity_dir": str(journal / "identity"), - "status": "ok", - } - assert (journal / "identity" / "partner.md").exists() - - -def test_convey_down_prints_require_solstone_message(journal, monkeypatch) -> None: - class DownSession: - def get(self, _url): - raise requests.exceptions.ConnectionError() - - def post(self, _url, json=None): - raise requests.exceptions.ConnectionError() - - client = ConveyClient(session=DownSession(), base_url="http://localhost:5015") - monkeypatch.setattr("solstone.apps.sol.call.get_client", lambda: client) - monkeypatch.delenv("SOL_SKIP_SUPERVISOR_CHECK", raising=False) - monkeypatch.delenv("SOL_SUPERVISOR_SPAWNED", raising=False) - - result = CliRunner().invoke(app, ["reset"]) - - assert result.exit_code == 1 - assert ( - result.stderr - == "sol: solstone isn't running. Start it with 'journal up' and retry.\n" - ) - assert result.stdout == "" diff --git a/tests/test_sol_cli_help.py b/tests/test_sol_cli_help.py deleted file mode 100644 index 9344d6387..000000000 --- a/tests/test_sol_cli_help.py +++ /dev/null @@ -1,129 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -from __future__ import annotations - -import pytest - -from solstone.think import sol_cli - -HELP_HEADINGS = [ - sol_cli.SOL_HELP_GROUP_CONVERSATION, - sol_cli.SOL_HELP_GROUP_YOUR_JOURNAL, - sol_cli.SOL_HELP_GROUP_DIAGNOSE, - sol_cli.SOL_HELP_GROUP_TOOLS, -] - - -def _assigned_groups(command_name: str) -> list[str]: - return [ - group.heading - for group in sol_cli.help_groups() - if command_name in group.commands - ] - - -def _access_command_names() -> list[str]: - return sorted( - name - for name, command in sol_cli.COMMANDS.items() - if command.surface in {"access", "universal"} - ) - - -def _service_command_names() -> list[str]: - return sorted( - name - for name, command in sol_cli.COMMANDS.items() - if command.surface == "service" - ) - - -@pytest.mark.parametrize("command_name", _access_command_names()) -def test_sol_help_group_assignment_is_exact_partition(command_name: str) -> None: - assigned = _assigned_groups(command_name) - assert len(assigned) == 1, f"{command_name!r} appears in {assigned!r}" - - -@pytest.mark.parametrize("command_name", _service_command_names()) -def test_sol_help_group_excludes_service_commands(command_name: str) -> None: - assert _assigned_groups(command_name) == [] - - -def test_sol_help_groups_reference_only_registered_commands() -> None: - grouped = [] - for group in sol_cli.help_groups(): - grouped.extend(group.commands) - for command_name in group.commands: - assert command_name in sol_cli.COMMANDS - - assert set(grouped) == set(_access_command_names()) - assert len(grouped) == len(set(grouped)) - - -def test_sol_help_heading_order_and_apps_position(monkeypatch, capsys) -> None: - monkeypatch.setattr(sol_cli, "print_status", lambda: None) - - sol_cli.print_help() - lines = capsys.readouterr().out.splitlines() - - heading_positions = {heading: lines.index(heading) for heading in HELP_HEADINGS} - assert list(heading_positions) == HELP_HEADINGS - assert [heading_positions[heading] for heading in HELP_HEADINGS] == sorted( - heading_positions.values() - ) - - apps_position = lines.index("Apps (sol call ):") - assert heading_positions[sol_cli.SOL_HELP_GROUP_TOOLS] < apps_position - assert sol_cli.SOL_HELP_GROUP_SERVICE_HEADING not in lines - assert sol_cli.SOL_HELP_GROUP_ALIASES not in lines - assert "Direct module syntax: sol [args]" not in lines - - -def test_access_help_groups_match_canonical_membership() -> None: - assert sol_cli.ACCESS_HELP_GROUPS == ( - sol_cli.HelpGroup(sol_cli.SOL_HELP_GROUP_CONVERSATION, ("chat",)), - sol_cli.HelpGroup(sol_cli.SOL_HELP_GROUP_YOUR_JOURNAL, ("call", "import")), - sol_cli.HelpGroup( - sol_cli.SOL_HELP_GROUP_DIAGNOSE, ("notify", "doctor", "check") - ), - sol_cli.HelpGroup(sol_cli.SOL_HELP_GROUP_TOOLS, ("skills", "link", "contract")), - ) - - -class TestPrintStatusDays: - def test_chronicle_with_day_dirs(self, monkeypatch, tmp_path, capsys) -> None: - for day in ("20260101", "20260102", "20260103"): - (tmp_path / "chronicle" / day).mkdir(parents=True) - monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - - sol_cli.print_status() - - assert "Days: 3" in capsys.readouterr().out - - def test_chronicle_with_mixed_names(self, monkeypatch, tmp_path, capsys) -> None: - for name in ("20260101", "notaday", "12345"): - (tmp_path / "chronicle" / name).mkdir(parents=True) - monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - - sol_cli.print_status() - - assert "Days: 1" in capsys.readouterr().out - - def test_chronicle_absent(self, monkeypatch, tmp_path, capsys) -> None: - monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - - sol_cli.print_status() - - assert "Days: 0" in capsys.readouterr().out - - def test_legacy_root_level_days_not_counted( - self, monkeypatch, tmp_path, capsys - ) -> None: - # AC23 intentionally ignores legacy root-level day directories. - (tmp_path / "20260101").mkdir() - monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - - sol_cli.print_status() - - assert "Days: 0" in capsys.readouterr().out diff --git a/tests/test_sol_service_hard_error.py b/tests/test_sol_service_hard_error.py deleted file mode 100644 index adbde4fa6..000000000 --- a/tests/test_sol_service_hard_error.py +++ /dev/null @@ -1,140 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -from __future__ import annotations - -import sys -from pathlib import Path -from unittest.mock import MagicMock - -import pytest - -from solstone.think import sol_cli -from solstone.think.service import Reconciled - - -def service_command_names() -> list[str]: - return sorted( - name - for name, command in sol_cli.COMMANDS.items() - if command.surface == "service" - ) - - -def service_alias_names() -> list[str]: - return sorted( - name for name, alias in sol_cli.ALIASES.items() if alias.surface == "service" - ) - - -def _patch_no_stale_unit(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr( - "solstone.think.service.reconcile_installed_unit", - lambda: Reconciled(False, None, None, None), - ) - - -@pytest.mark.parametrize("name", service_command_names()) -def test_sol_service_commands_hard_error(monkeypatch, capsys, name): - _patch_no_stale_unit(monkeypatch) - monkeypatch.setattr( - sol_cli, - "run_command", - lambda _module_path: pytest.fail("service command should not run"), - ) - monkeypatch.setattr(sys, "argv", ["sol", name]) - - with pytest.raises(SystemExit) as exc_info: - sol_cli.main() - - captured = capsys.readouterr() - assert exc_info.value.code == 2 - assert sol_cli.SOL_SERVICE_CMD_REMOVED_ERROR.format(cmd=name) in captured.err - - -@pytest.mark.parametrize("name", service_alias_names()) -def test_sol_service_aliases_hard_error(monkeypatch, capsys, name): - _patch_no_stale_unit(monkeypatch) - monkeypatch.setattr( - sol_cli, - "run_command", - lambda _module_path: pytest.fail("service alias should not run"), - ) - monkeypatch.setattr(sys, "argv", ["sol", name]) - - with pytest.raises(SystemExit) as exc_info: - sol_cli.main() - - captured = capsys.readouterr() - assert exc_info.value.code == 2 - assert sol_cli.SOL_SERVICE_CMD_REMOVED_ERROR.format(cmd=name) in captured.err - - -def test_sol_access_commands_still_dispatch(monkeypatch): - result: dict[str, object] = {} - - def fake_run_command(module_path: str) -> int: - result["module"] = module_path - result["argv"] = sys.argv[:] - return 0 - - monkeypatch.setattr(sol_cli, "run_command", fake_run_command) - monkeypatch.setattr(sol_cli.setproctitle, "setproctitle", lambda _title: None) - monkeypatch.setattr(sys, "argv", ["sol", "chat"]) - - with pytest.raises(SystemExit) as exc_info: - sol_cli.main() - - assert exc_info.value.code == 0 - assert result == { - "module": sol_cli.COMMANDS["chat"].module, - "argv": ["sol chat"], - } - - -def test_stale_sol_unit_execs_journal(monkeypatch): - execv = MagicMock(side_effect=RuntimeError("execv called")) - monkeypatch.setattr( - "solstone.think.service.reconcile_installed_unit", - lambda: Reconciled(True, "sol", "supervisor", Path("unit")), - ) - monkeypatch.setattr( - "solstone.think.service._managed_wrapper", - lambda binary: f"/tmp/{binary}", - ) - monkeypatch.setattr(sol_cli.os, "execv", execv) - monkeypatch.setattr(sys, "argv", ["sol", "supervisor", "5015"]) - - with pytest.raises(RuntimeError, match="execv called"): - sol_cli.main() - - # Shim routes through `journal start`, the canonical service entry point, - # not directly through `journal supervisor`. - execv.assert_called_once_with("/tmp/journal", ["/tmp/journal", "start", "5015"]) - - -def test_mismatched_stale_unit_does_not_unlock_human_sol_service(monkeypatch, capsys): - monkeypatch.setattr( - "solstone.think.service.reconcile_installed_unit", - lambda: Reconciled(True, "sol", "heartbeat", Path("unit")), - ) - monkeypatch.setattr(sys, "argv", ["sol", "supervisor"]) - - with pytest.raises(SystemExit) as exc_info: - sol_cli.main() - - captured = capsys.readouterr() - assert exc_info.value.code == 2 - assert ( - sol_cli.SOL_SERVICE_CMD_REMOVED_ERROR.format(cmd="supervisor") in captured.err - ) - - -def test_sol_service_hard_error_exit_code_is_2(monkeypatch): - _patch_no_stale_unit(monkeypatch) - monkeypatch.setattr(sys, "argv", ["sol", "supervisor"]) - - with pytest.raises(SystemExit) as exc_info: - sol_cli.main() - - assert exc_info.value.code == 2 diff --git a/tests/test_speakers_call_parity.py b/tests/test_speakers_call_parity.py deleted file mode 100644 index 75e0f9c47..000000000 --- a/tests/test_speakers_call_parity.py +++ /dev/null @@ -1,1680 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -from __future__ import annotations - -import json -from pathlib import Path -from types import SimpleNamespace -from typing import Any - -import pytest -import requests -from typer.testing import CliRunner - -import solstone.apps.speakers.call as speakers_call -import solstone.apps.speakers.routes as speakers_routes -from solstone.apps.speakers.call import app -from solstone.apps.speakers.encoder_config import OWNER_BOOTSTRAP_MIN_STMTS -from solstone.convey.reasons import ( - ENTITY_BLOCKED, - INVALID_SEGMENT_OR_STREAM, - SPEAKER_LABELS_BUSY, - SPEAKER_OWNER_IDENTITY_REQUIRED, - SPEAKER_SENTENCE_MISSING, - SPEAKER_VOICEPRINT_BUSY, -) -from solstone.think.convey_client import ConveyClient -from solstone.think.journal_io import LockTimeout -from tests._baseline_harness import make_test_client, mark_setup_complete - - -@pytest.fixture -def journal(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: - monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - mark_setup_complete(tmp_path) - return tmp_path - - -@pytest.fixture -def runner(journal: Path, monkeypatch: pytest.MonkeyPatch) -> CliRunner: - client = ConveyClient(session=make_test_client(journal), base_url="") - monkeypatch.setattr(speakers_call, "get_client", lambda: client) - return CliRunner() - - -def _bootstrap_stats() -> dict[str, Any]: - return { - "segments_scanned": 5, - "single_speaker_segments": 3, - "speakers_found": {"Alice": 2, "Bob": 1}, - "entities_created": 1, - "embeddings_saved": 3, - "embeddings_skipped_owner": 1, - "embeddings_skipped_duplicate": 2, - "errors": [], - } - - -def _resolve_stats() -> dict[str, Any]: - return { - "entities_with_voiceprints": 3, - "pairs_compared": 2, - "matches_found": [{"alias": "Al", "canonical": "Alice"}], - "auto_merged": [{"alias": "Al", "canonical": "Alice", "similarity": 0.93}], - "ambiguous": [ - { - "name": "Bob", - "candidates": [{"name": "Bobby", "similarity": 0.91}], - } - ], - "errors": [], - } - - -def _attribute_result() -> dict[str, Any]: - return { - "labels": [ - {"sentence_id": 1, "speaker": "alice", "method": "owner"}, - {"sentence_id": 2, "speaker": None, "method": None}, - {"sentence_id": 3, "speaker": "bob", "method": "acoustic"}, - ], - "unmatched": [{"sentence_id": 2}], - "source": "mic", - "metadata": {"model": "test"}, - } - - -def _correct_result() -> dict[str, Any]: - return { - "status": "corrected", - "old_speaker": "alice", - "new_speaker": "bob", - "voiceprint_removal": { - "outcome": "rewritten", - "entity_id": "alice", - "keys_removed": ["20260101/120000_10/mic_audio#2"], - "file_deleted": False, - "path": "entities/alice/voiceprints.npz", - }, - "propagation_offer": { - "available": True, - "statement_count": 3, - "segment_count": 2, - "route": "/app/speakers/api/propagate-correction", - "request": { - "old_speaker": "alice", - "new_speaker": "bob", - "commit": False, - }, - }, - } - - -def _propagate_result(*, commit: bool) -> dict[str, Any]: - return { - "status": "applied" if commit else "preview", - "commit": commit, - "old_speaker": "alice", - "new_speaker": "bob", - "segments_scanned": 4, - "segments_considered": 4, - "segment_count": 2, - "statement_count": 3, - "changes": [], - "segments": [], - "errors": [], - "reversal": { - "verb": "speakers propagate-correction", - "old_speaker": "bob", - "new_speaker": "alice", - "bounded_to": "segments where these two appear", - }, - } - - -def _backfill_stats() -> dict[str, Any]: - return { - "total_segments": 10, - "total_eligible": 7, - "skipped_no_embed": 3, - "already_labeled": 2, - "processed": 5, - "speakers_seen": {"alice": 3, "bob": 1}, - "errors": [], - } - - -def _backfill_last_seen_stats() -> dict[str, Any]: - return { - "labels_read": 2, - "entities_seen": 2, - "rows_scanned": 5, - "rows_pending": 2, - "rows_written": 0, - "pending": {"alice": {"rows": 2}}, - "errors": [], - } - - -def _wipe_report() -> dict[str, Any]: - return { - "segment_embeddings": {"count": 1, "bytes": 10, "paths": ["a.npz"]}, - "speaker_labels": {"count": 2, "bytes": 20, "paths": ["labels.json"]}, - "speaker_corrections": {"count": 3, "bytes": 30, "paths": []}, - "entity_voiceprints": {"count": 4, "bytes": 40, "paths": []}, - "owner_centroids": {"count": 5, "bytes": 50, "paths": []}, - "owner_candidate": {"count": 6, "bytes": 60, "paths": []}, - "total_files": 21, - "total_bytes": 210, - } - - -def _seed_stats() -> dict[str, Any]: - return { - "segments_scanned": 4, - "segments_with_speakers": 3, - "speakers_found": {"Alice": 3, "Bob": 1}, - "embeddings_saved": 4, - "embeddings_skipped_owner": 1, - "embeddings_skipped_duplicate": 2, - "speakers_unmatched": ["Unknown"], - "errors": [], - } - - -def _assert_json_stdout(result, expected: Any) -> None: - assert result.exit_code == 0 - assert json.loads(result.stdout) == expected - assert result.stderr == "" - - -def _owner_tag_guidance(manual_tags_count: int) -> str: - needed = OWNER_BOOTSTRAP_MIN_STMTS - manual_tags_count - return ( - "Use sol call speakers tag-owner " - " on owner sentences in raw media until " - f"you have {OWNER_BOOTSTRAP_MIN_STMTS} validated owner tags; " - f"{needed} more needed. Then run sol call speakers build-from-tags." - ) - - -def test_status_full_section_and_unknown( - runner: CliRunner, monkeypatch: pytest.MonkeyPatch -) -> None: - status = { - "embeddings": {"total": 1}, - "owner": {"status": "confirmed"}, - "speakers": [{"name": "Alice"}], - "pool": {"candidate_count": 2}, - "clusters": {"count": 0}, - "imports": {"names": []}, - "attribution": {"labels": 2}, - } - monkeypatch.setattr(speakers_routes, "get_speakers_status", lambda section: status) - - full = runner.invoke(app, ["status"]) - speakers = runner.invoke(app, ["status", "speakers"]) - pool = runner.invoke(app, ["status", "pool"]) - unknown = runner.invoke(app, ["status", "nope"]) - - _assert_json_stdout(full, status) - _assert_json_stdout(speakers, [{"name": "Alice"}]) - _assert_json_stdout(pool, {"candidate_count": 2}) - _assert_json_stdout( - unknown, - { - "error": ( - "Unknown section 'nope'. Valid: embeddings, owner, speakers, " - "pool, clusters, imports, attribution, quality" - ) - }, - ) - - -def test_bootstrap_text_commit_json_and_owner_error( - runner: CliRunner, monkeypatch: pytest.MonkeyPatch -) -> None: - monkeypatch.setattr( - speakers_routes, "bootstrap_voiceprints", lambda dry_run: _bootstrap_stats() - ) - - dry = runner.invoke(app, ["bootstrap"]) - commit = runner.invoke(app, ["bootstrap", "--commit"]) - json_result = runner.invoke(app, ["bootstrap", "--json"]) - - assert dry.exit_code == 0 - assert dry.stderr == "" - assert dry.stdout == ( - "REPORT ONLY — pass --commit to persist.\n" - "\n" - "Bootstrapping voiceprints from single-speaker segments...\n" - "\n" - "Segments scanned: 5\n" - "Single-speaker segments: 3\n" - "Unique speakers: 2\n" - "Entities created: 1\n" - "Embeddings saved: 3\n" - "Embeddings skipped (owner): 1\n" - "Embeddings skipped (duplicate): 2\n" - "\n" - "Top speakers by embedding count:\n" - " Alice: 2\n" - " Bob: 1\n" - ) - assert commit.exit_code == 0 - assert commit.stderr == "" - assert commit.stdout == dry.stdout.removeprefix( - "REPORT ONLY — pass --commit to persist.\n\n" - ) - _assert_json_stdout(json_result, _bootstrap_stats()) - - monkeypatch.setattr( - speakers_routes, - "bootstrap_voiceprints", - lambda dry_run: { - "error": "No confirmed owner centroid. Run owner detection first." - }, - ) - error = runner.invoke(app, ["bootstrap"]) - - assert error.exit_code == 1 - assert error.stdout == ( - "REPORT ONLY — pass --commit to persist.\n" - "\n" - "Bootstrapping voiceprints from single-speaker segments...\n" - ) - assert ( - error.stderr - == "Error: No confirmed owner centroid. Run owner detection first.\n" - ) - - -def test_resolve_names_text_and_json( - runner: CliRunner, monkeypatch: pytest.MonkeyPatch -) -> None: - monkeypatch.setattr( - speakers_routes, "resolve_name_variants", lambda dry_run: _resolve_stats() - ) - - text = runner.invoke(app, ["resolve-names"]) - json_result = runner.invoke(app, ["resolve-names", "--json"]) - - assert text.exit_code == 0 - assert text.stderr == "" - assert text.stdout == ( - "REPORT ONLY — pass --commit to persist.\n" - "\n" - "Resolving speaker name variants...\n" - "\n" - "Entities with voiceprints: 3\n" - "Pairs compared: 2\n" - "High-similarity pairs: 1\n" - "\n" - "Auto-merged (1):\n" - " Al -> Alice (0.93)\n" - "\n" - "Ambiguous (1):\n" - " Bob: Bobby (0.91)\n" - ) - _assert_json_stdout(json_result, _resolve_stats()) - - -def test_attribute_segment_text_json_error_and_commit_outputs( - runner: CliRunner, monkeypatch: pytest.MonkeyPatch -) -> None: - monkeypatch.setattr( - speakers_routes, - "attribute_segment", - lambda day, stream, segment: _attribute_result(), - ) - monkeypatch.setattr( - speakers_routes, - "save_speaker_labels", - lambda seg_dir, labels, metadata: Path("/tmp/speaker_labels.json"), - ) - monkeypatch.setattr( - speakers_routes, - "accumulate_voiceprints", - lambda day, stream, segment, labels, source: {"alice": 2}, - ) - - text = runner.invoke(app, ["attribute-segment", "20260101", "mic", "120000_10"]) - json_result = runner.invoke( - app, ["attribute-segment", "20260101", "mic", "120000_10", "--json"] - ) - commit = runner.invoke( - app, ["attribute-segment", "20260101", "mic", "120000_10", "--commit"] - ) - - assert text.exit_code == 0 - assert text.stderr == "" - assert text.stdout == ( - "REPORT ONLY — pass --commit to persist.\n" - "\n" - "Sentences: 3\n" - "Resolved: 2\n" - "Unmatched: 1\n" - "\n" - "By method:\n" - " acoustic: 1\n" - " owner: 1\n" - " unmatched: 1\n" - ) - _assert_json_stdout(json_result, _attribute_result()) - assert commit.exit_code == 0 - assert commit.stderr == "" - assert commit.stdout == ( - "Sentences: 3\n" - "Resolved: 2\n" - "Unmatched: 1\n" - "\n" - "By method:\n" - " acoustic: 1\n" - " owner: 1\n" - " unmatched: 1\n" - "\n" - "Wrote: /tmp/speaker_labels.json\n" - "\n" - "Accumulated voiceprints:\n" - " alice: 2 embeddings\n" - ) - - monkeypatch.setattr( - speakers_routes, - "attribute_segment", - lambda day, stream, segment: {"error": "no_owner_centroid"}, - ) - error = runner.invoke(app, ["attribute-segment", "20260101", "mic", "120000_10"]) - - assert error.exit_code == 1 - assert error.stdout == "REPORT ONLY — pass --commit to persist.\n\n" - assert error.stderr == "Error: no_owner_centroid\n" - - -def test_correct_text_and_json_use_correction_route( - runner: CliRunner, - monkeypatch: pytest.MonkeyPatch, -) -> None: - calls: list[tuple[str, str, dict[str, Any] | None]] = [] - - def fake_request( - method: str, - path: str, - *, - params: dict[str, Any] | None = None, - json_body: dict[str, Any] | None = None, - ) -> dict[str, Any]: - calls.append((method, path, json_body)) - assert params is None - return _correct_result() - - monkeypatch.setattr(speakers_call, "_request", fake_request) - - text = runner.invoke( - app, - [ - "correct", - "20260101", - "mic", - "120000_10", - "mic_audio", - "2", - "bob", - ], - ) - json_result = runner.invoke( - app, - [ - "correct", - "20260101", - "mic", - "120000_10", - "mic_audio", - "2", - "bob", - "--json", - ], - ) - - assert text.exit_code == 0 - assert text.stderr == "" - assert text.stdout == ( - "Corrected 20260101/mic/120000_10 #2: alice -> bob\n" - "Voiceprint removal: rewritten\n" - "Propagation available: 3 statements in 2 segments would change\n" - "Preview with: sol call speakers propagate-correction alice bob\n" - ) - _assert_json_stdout(json_result, _correct_result()) - assert calls == [ - ( - "POST", - "/app/speakers/api/correct-attribution", - { - "day": "20260101", - "stream": "mic", - "segment_key": "120000_10", - "source": "mic_audio", - "sentence_id": 2, - "new_speaker": "bob", - }, - ), - ( - "POST", - "/app/speakers/api/correct-attribution", - { - "day": "20260101", - "stream": "mic", - "segment_key": "120000_10", - "source": "mic_audio", - "sentence_id": 2, - "new_speaker": "bob", - }, - ), - ] - - -def test_propagate_correction_previews_by_default_and_commits( - runner: CliRunner, - monkeypatch: pytest.MonkeyPatch, -) -> None: - calls: list[tuple[str, str, dict[str, Any] | None]] = [] - - def fake_request( - method: str, - path: str, - *, - params: dict[str, Any] | None = None, - json_body: dict[str, Any] | None = None, - ) -> dict[str, Any]: - calls.append((method, path, json_body)) - assert params is None - return _propagate_result(commit=bool(json_body and json_body.get("commit"))) - - monkeypatch.setattr(speakers_call, "_request", fake_request) - - preview = runner.invoke(app, ["propagate-correction", "alice", "bob"]) - commit = runner.invoke( - app, - ["propagate-correction", "alice", "bob", "--commit"], - ) - json_result = runner.invoke( - app, - ["propagate-correction", "alice", "bob", "--json"], - ) - - assert preview.exit_code == 0 - assert preview.stderr == "" - assert preview.stdout == ( - "REPORT ONLY — pass --commit to persist.\n" - "\n" - "Would change: 3 statements in 2 segments\n" - ) - assert commit.exit_code == 0 - assert commit.stderr == "" - assert commit.stdout == ( - "Applied: 3 statements in 2 segments\n" - "Reverse with: sol call speakers propagate-correction bob alice --commit\n" - ) - _assert_json_stdout(json_result, _propagate_result(commit=False)) - assert calls == [ - ( - "POST", - "/app/speakers/api/propagate-correction", - {"old_speaker": "alice", "new_speaker": "bob", "commit": False}, - ), - ( - "POST", - "/app/speakers/api/propagate-correction", - {"old_speaker": "alice", "new_speaker": "bob", "commit": True}, - ), - ( - "POST", - "/app/speakers/api/propagate-correction", - {"old_speaker": "alice", "new_speaker": "bob", "commit": False}, - ), - ] - - -def test_backfill_text_commit_without_progress_and_json( - runner: CliRunner, monkeypatch: pytest.MonkeyPatch -) -> None: - monkeypatch.setattr( - speakers_routes, - "backfill_segments", - lambda dry_run, progress_callback: _backfill_stats(), - ) - monkeypatch.setattr(speakers_call.time, "monotonic", lambda: 0.0) - - dry = runner.invoke(app, ["backfill"]) - commit = runner.invoke(app, ["backfill", "--commit"]) - json_result = runner.invoke(app, ["backfill", "--json"]) - - dry_expected = ( - "REPORT ONLY — pass --commit to persist.\n" - "\n" - "Scanning journal for segments with embeddings...\n" - "\n" - "\n" - "Total segments scanned: 10\n" - "With embeddings: 7\n" - "Without embeddings: 3\n" - "Already labeled (skipped): 2\n" - "Processed this run: 5\n" - "Elapsed: 0.0s\n" - "\n" - "Speakers identified (2):\n" - " alice: 3 attributions\n" - " bob: 1 attributions\n" - ) - assert dry.exit_code == 0 - assert dry.stderr == "" - assert dry.stdout == dry_expected - assert commit.exit_code == 0 - assert commit.stderr == "" - assert commit.stdout == dry_expected.removeprefix( - "REPORT ONLY — pass --commit to persist.\n\n" - ) - assert "\n 202" not in commit.stdout - _assert_json_stdout(json_result, _backfill_stats()) - - -def test_backfill_last_seen_text_and_json( - runner: CliRunner, monkeypatch: pytest.MonkeyPatch -) -> None: - monkeypatch.setattr( - speakers_routes, - "backfill_last_seen", - lambda dry_run: _backfill_last_seen_stats(), - ) - - text = runner.invoke(app, ["backfill-last-seen"]) - json_result = runner.invoke(app, ["backfill-last-seen", "--json"]) - - assert text.exit_code == 0 - assert text.stderr == "" - assert text.stdout == ( - "REPORT ONLY — pass --commit to persist.\n" - "\n" - "Speaker label files read: 2\n" - "Entities seen: 2\n" - "Voiceprint rows scanned: 5\n" - "Rows pending: 2\n" - "Rows written: 0\n" - "\n" - "Pending by entity:\n" - " alice: 2\n" - ) - _assert_json_stdout(json_result, _backfill_last_seen_stats()) - - -def test_wipe_text_and_json(runner: CliRunner, monkeypatch: pytest.MonkeyPatch) -> None: - class Report: - def to_dict(self) -> dict[str, Any]: - return _wipe_report() - - monkeypatch.setattr( - speakers_routes, "wipe_speaker_artifacts", lambda dry_run: Report() - ) - - text = runner.invoke(app, ["wipe"]) - json_result = runner.invoke(app, ["wipe", "--json"]) - - assert text.exit_code == 0 - assert text.stderr == "" - assert text.stdout == ( - "REPORT ONLY — pass --commit to persist.\n" - "\n" - "segment_embeddings : 1 files (10 B)\n" - "speaker_labels : 2 files (20 B)\n" - "speaker_corrections: 3 files (30 B)\n" - "entity_voiceprints : 4 files (40 B)\n" - "owner_centroids : 5 files (50 B)\n" - "owner_candidate : 6 files (60 B)\n" - "total : 21 files (210 B)\n" - ) - _assert_json_stdout(json_result, _wipe_report()) - - -def test_discover_text_no_clusters_clusters_and_json( - runner: CliRunner, monkeypatch: pytest.MonkeyPatch -) -> None: - cluster_result = { - "clusters": [ - { - "cluster_id": 1, - "size": 2, - "segment_count": 1, - "samples": [ - { - "day": "20260101", - "stream": "mic", - "segment_key": "120000_10", - "sentence_id": 7, - "text": "hello from an unknown speaker", - } - ], - } - ] - } - - monkeypatch.setattr( - speakers_routes, "discover_unknown_speakers", lambda: {"clusters": []} - ) - empty = runner.invoke(app, ["discover"]) - - assert empty.exit_code == 0 - assert empty.stdout == "No recurring unknown speakers found.\n" - assert empty.stderr == "" - - monkeypatch.setattr( - speakers_routes, "discover_unknown_speakers", lambda: cluster_result - ) - text = runner.invoke(app, ["discover"]) - json_result = runner.invoke(app, ["discover", "--json"]) - - assert text.exit_code == 0 - assert text.stderr == "" - assert text.stdout == ( - "Found 1 unknown speaker cluster(s):\n" - "\n" - " Cluster 1: 2 samples across 1 segments\n" - " - 20260101/mic/120000_10 sid=7: hello from an unknown speaker\n" - "\n" - ) - _assert_json_stdout(json_result, cluster_result) - - -def test_presence_json_and_missing_cluster_hint( - runner: CliRunner, monkeypatch: pytest.MonkeyPatch -) -> None: - presence_result = { - "cluster_id": 7, - "facts": { - "statement_count": 2, - "segment_count": 1, - "day_count": 1, - "streams": ["mic"], - "conversation_count": 1, - "samples": [], - }, - "evidence_complete": True, - "evidence_gaps": [], - "candidates": { - "co_presence": [ - { - "entity_id": "alice", - "name": "Alice", - "has_voice": True, - "screen_conversations": 1, - "meeting_days": 0, - "setting_conversations": 0, - "speaker_conversations": 0, - } - ], - "mention": [], - }, - } - monkeypatch.setattr( - speakers_routes, - "get_cluster_presence", - lambda cluster_id: presence_result if cluster_id == 7 else None, - ) - - json_result = runner.invoke(app, ["presence", "7", "--json"]) - missing = runner.invoke(app, ["presence", "404"]) - - _assert_json_stdout(json_result, presence_result) - assert missing.exit_code == 1 - assert missing.stdout == "" - assert missing.stderr == ( - "Cluster 404 was not found.\n" - "Run 'sol call speakers discover' to produce valid cluster ids.\n" - ) - - -def test_identify_forwards_entity_id_create_resolve_only_and_no_match( - runner: CliRunner, monkeypatch: pytest.MonkeyPatch -) -> None: - seen: list[dict[str, Any]] = [] - - def identify_cluster(cluster_id: int, **kwargs): - seen.append({"cluster_id": cluster_id, **kwargs}) - if kwargs["resolve_only"]: - return { - "status": "resolved", - "entity_id": kwargs["entity_id"] or "alice", - "entity_name": kwargs["name"] or "Alice", - "has_voice": False, - } - if kwargs["entity_id"]: - return {"status": "identified", "entity_id": kwargs["entity_id"]} - if kwargs["create_new"]: - return { - "status": "identified", - "entity_id": "alice", - "entity_created": True, - } - return { - "status": "no_match", - "candidates": [{"id": "alice", "name": "Alice", "tier": 8, "score": 10.0}], - } - - monkeypatch.setattr(speakers_routes, "identify_cluster", identify_cluster) - entity_id = runner.invoke( - app, ["identify", "3", "Alice", "--entity-id", "person-alice"] - ) - no_match = runner.invoke(app, ["identify", "4", "Nope"]) - create = runner.invoke( - app, - ["identify", "5", "Alice", "--create", "--entity-type", "Organization"], - ) - resolve = runner.invoke(app, ["identify", "6", "Alice", "--resolve-only"]) - with_request = runner.invoke( - app, - [ - "identify", - "8", - "Alice", - "--create", - "--request-id", - "req-identify", - "--reviewed-near-match-entity-id", - "bob", - "--reviewed-near-match-entity-id", - "carol", - ], - ) - missing_target = runner.invoke(app, ["identify", "7"]) - - _assert_json_stdout( - entity_id, {"status": "identified", "entity_id": "person-alice"} - ) - _assert_json_stdout( - no_match, - { - "status": "no_match", - "candidates": [{"id": "alice", "name": "Alice", "tier": 8, "score": 10.0}], - }, - ) - _assert_json_stdout( - create, - {"status": "identified", "entity_id": "alice", "entity_created": True}, - ) - _assert_json_stdout( - resolve, - { - "status": "resolved", - "entity_id": "alice", - "entity_name": "Alice", - "has_voice": False, - }, - ) - _assert_json_stdout( - with_request, - {"status": "identified", "entity_id": "alice", "entity_created": True}, - ) - assert missing_target.exit_code != 0 - assert seen == [ - { - "cluster_id": 3, - "name": "Alice", - "entity_id": "person-alice", - "resolve_only": False, - "create_new": False, - "entity_type": "Person", - "request_id": None, - "reviewed_near_match_entity_ids": None, - }, - { - "cluster_id": 4, - "name": "Nope", - "entity_id": None, - "resolve_only": False, - "create_new": False, - "entity_type": "Person", - "request_id": None, - "reviewed_near_match_entity_ids": None, - }, - { - "cluster_id": 5, - "name": "Alice", - "entity_id": None, - "resolve_only": False, - "create_new": True, - "entity_type": "Organization", - "request_id": None, - "reviewed_near_match_entity_ids": None, - }, - { - "cluster_id": 6, - "name": "Alice", - "entity_id": None, - "resolve_only": True, - "create_new": False, - "entity_type": "Person", - "request_id": None, - "reviewed_near_match_entity_ids": None, - }, - { - "cluster_id": 8, - "name": "Alice", - "entity_id": None, - "resolve_only": False, - "create_new": True, - "entity_type": "Person", - "request_id": "req-identify", - "reviewed_near_match_entity_ids": ["bob", "carol"], - }, - ] - - -def test_identify_recoverable_cli_exits_with_retry_guidance( - runner: CliRunner, monkeypatch: pytest.MonkeyPatch -) -> None: - monkeypatch.setattr( - speakers_routes, - "identify_cluster", - lambda *_args, **_kwargs: { - "status": "recoverable", - "operation_id": "idop_retry", - "request_id": "req-retry", - "completed_phases": [], - "pending_phases": ["entity"], - "detail": "forced", - }, - ) - - result = runner.invoke( - app, - ["identify", "9", "Alice", "--request-id", "req-retry"], - ) - - assert result.exit_code == 1 - assert result.stdout == "" - assert "forced" in result.stderr - assert "Retry with the same --request-id req-retry." in result.stderr - assert "sol call speakers identify-operations" in result.stderr - assert "sol call speakers identify-operation idop_retry" in result.stderr - - -def test_identify_operation_dismissal_and_keep_separate_cli_verbs( - runner: CliRunner, monkeypatch: pytest.MonkeyPatch -) -> None: - state = SimpleNamespace( - operation_id="idop_123", - request_id="req-123", - terminal_status="committed", - target_entity_id="alice", - will_create=False, - entity_type="Person", - reviewed_near_match_entity_ids=(), - cluster_member_set=frozenset({("20240101", "test", "120000_300", "audio", 1)}), - completed_phases=("entity", "sentinel"), - pending_phases=(), - phase_checkpoints={"entity": {}, "sentinel": {}}, - undo_phase_checkpoints={}, - result={"status": "identified", "operation_id": "idop_123"}, - undo_report=None, - repair_required=None, - undo_repair_required=None, - ) - member = { - "day": "20240101", - "stream": "test", - "segment_key": "120000_300", - "source": "audio", - "sentence_id": 1, - } - monkeypatch.setattr(speakers_routes, "fold_all_operations", lambda: [state]) - monkeypatch.setattr( - speakers_routes, - "fold_operation", - lambda operation_id: state if operation_id == "idop_123" else None, - ) - monkeypatch.setattr( - speakers_routes, - "undo_identify_operation", - lambda operation_id: {"status": "undone", "operation_id": operation_id}, - ) - monkeypatch.setattr( - speakers_routes, - "load_discovery_cache", - lambda: {"clusters": {"7": [member]}}, - ) - monkeypatch.setattr( - speakers_routes, - "record_cluster_dismissal", - lambda members, disposition: { - "dismiss_event_id": "cdev_123", - "disposition": disposition, - "member_count": len(members), - }, - ) - monkeypatch.setattr( - speakers_routes, - "list_dismissals", - lambda: [{"dismissal_id": "cdev_123", "member_count": 1}], - ) - monkeypatch.setattr( - speakers_routes, - "list_assertions", - lambda: [{"assertion_id": "ksep_123", "source_count": 1}], - ) - - undo = runner.invoke(app, ["identify-undo", "idop_123"]) - operations = runner.invoke(app, ["identify-operations"]) - operation = runner.invoke(app, ["identify-operation", "idop_123"]) - dismiss = runner.invoke( - app, - ["dismiss-cluster", "7", "--disposition", "quiet"], - ) - dismissals = runner.invoke(app, ["dismissals"]) - keep_separate = runner.invoke(app, ["keep-separate-list"]) - - _assert_json_stdout(undo, {"status": "undone", "operation_id": "idop_123"}) - assert json.loads(operations.stdout)["operations"][0]["operation_id"] == "idop_123" - assert json.loads(operation.stdout)["operation"]["operation_id"] == "idop_123" - _assert_json_stdout( - dismiss, - { - "status": "dismissed", - "dismiss_event_id": "cdev_123", - "disposition": "quiet", - "member_count": 1, - }, - ) - assert json.loads(dismissals.stdout)["dismissals"][0]["dismissal_id"] == "cdev_123" - assert ( - json.loads(keep_separate.stdout)["assertions"][0]["assertion_id"] == "ksep_123" - ) - - -def test_merge_names_success_simple_error_and_multi_key_error( - runner: CliRunner, monkeypatch: pytest.MonkeyPatch -) -> None: - monkeypatch.setattr( - speakers_routes, - "merge_names", - lambda alias, canonical: { - "status": "merged", - "alias": alias, - "canonical": canonical, - }, - ) - success = runner.invoke(app, ["merge-names", "Al", "Alice"]) - _assert_json_stdout( - success, {"status": "merged", "alias": "Al", "canonical": "Alice"} - ) - - simple_error = {"error": "Alias entity not found"} - monkeypatch.setattr( - speakers_routes, "merge_names", lambda alias, canonical: simple_error - ) - simple = runner.invoke(app, ["merge-names", "Al", "Alice"]) - assert simple.exit_code == 1 - assert simple.stdout == "" - assert simple.stderr == json.dumps(simple_error, indent=2, default=str) + "\n" - - multi_error = { - "error": "Merge failed", - "failed_phase": "labels", - "recovery": "Retry after fixing labels", - } - monkeypatch.setattr( - speakers_routes, "merge_names", lambda alias, canonical: multi_error - ) - multi = runner.invoke(app, ["merge-names", "Al", "Alice"]) - assert multi.exit_code == 1 - assert multi.stdout == "" - assert multi.stderr == json.dumps(multi_error, indent=2, default=str) + "\n" - - -def test_link_import_success_and_entity_not_found_error( - runner: CliRunner, monkeypatch: pytest.MonkeyPatch -) -> None: - monkeypatch.setattr( - speakers_routes, - "link_import", - lambda name, entity_id: { - "status": "linked", - "name": name, - "entity_id": entity_id, - }, - ) - success = runner.invoke( - app, ["link-import", "Alice", "--entity-id", "person-alice"] - ) - _assert_json_stdout( - success, - {"status": "linked", "name": "Alice", "entity_id": "person-alice"}, - ) - - error_payload = {"error": "Entity not found: person-missing"} - monkeypatch.setattr( - speakers_routes, "link_import", lambda name, entity_id: error_payload - ) - error = runner.invoke( - app, ["link-import", "Alice", "--entity-id", "person-missing"] - ) - assert error.exit_code == 1 - assert error.stdout == "" - assert error.stderr == json.dumps(error_payload, indent=2, default=str) + "\n" - - -def test_seed_from_imports_text_and_owner_error( - runner: CliRunner, monkeypatch: pytest.MonkeyPatch -) -> None: - monkeypatch.setattr( - speakers_routes, "seed_from_imports", lambda dry_run: _seed_stats() - ) - - text = runner.invoke(app, ["seed-from-imports"]) - - assert text.exit_code == 0 - assert text.stderr == "" - assert text.stdout == ( - "REPORT ONLY — pass --commit to persist.\n" - "\n" - "Seeding voiceprints from import segments...\n" - "\n" - "Segments scanned: 4\n" - "Segments with speakers: 3\n" - "Unique speakers: 2\n" - "Embeddings saved: 4\n" - "Embeddings skipped (owner): 1\n" - "Embeddings skipped (duplicate): 2\n" - "\n" - "Speakers by embedding count:\n" - " Alice: 3\n" - " Bob: 1\n" - "\n" - "Unmatched speakers (1):\n" - " Unknown\n" - ) - - monkeypatch.setattr( - speakers_routes, - "seed_from_imports", - lambda dry_run: { - "error": "No confirmed owner centroid. Run owner detection first." - }, - ) - error = runner.invoke(app, ["seed-from-imports"]) - - assert error.exit_code == 1 - assert error.stdout == ( - "REPORT ONLY — pass --commit to persist.\n" - "\n" - "Seeding voiceprints from import segments...\n" - ) - assert ( - error.stderr - == "Error: No confirmed owner centroid. Run owner detection first.\n" - ) - - -def test_suggest_json_is_bare_items_and_text_is_server_markdown( - runner: CliRunner, monkeypatch: pytest.MonkeyPatch -) -> None: - items = [{"kind": "name_variant", "name": "Al"}] - monkeypatch.setattr(speakers_routes, "suggest_opportunities", lambda limit: items) - monkeypatch.setattr( - speakers_routes, "format_suggestions", lambda results: "server markdown" - ) - - text = runner.invoke(app, ["suggest", "--limit", "9"]) - json_result = runner.invoke(app, ["suggest", "--json"]) - - assert text.exit_code == 0 - assert text.stdout == "server markdown\n" - assert text.stderr == "" - _assert_json_stdout(json_result, items) - - -def test_detect_success_json_and_busy_owner_voice( - runner: CliRunner, monkeypatch: pytest.MonkeyPatch -) -> None: - monkeypatch.setattr( - speakers_routes, - "detect_owner_candidate", - lambda *, force=False: { - "status": "candidate", - "cluster_size": 4, - "force": force, - }, - ) - success = runner.invoke(app, ["detect"]) - _assert_json_stdout( - success, - {"status": "candidate", "cluster_size": 4, "force": False}, - ) - - forced = runner.invoke(app, ["detect", "--force"]) - _assert_json_stdout( - forced, {"status": "candidate", "cluster_size": 4, "force": True} - ) - - monkeypatch.setattr( - speakers_routes, - "detect_owner_candidate", - lambda *, force=False: {"error_kind": "voiceprint_busy", "error": "busy"}, - ) - busy = runner.invoke(app, ["detect"]) - - assert busy.exit_code == 1 - assert busy.stdout == "" - assert busy.stderr == f"{SPEAKER_VOICEPRINT_BUSY.message}\n" - - -def test_detect_help_has_no_json_option(runner: CliRunner) -> None: - result = runner.invoke(app, ["detect", "--help"]) - - assert result.exit_code == 0 - assert "--json" not in result.stdout - - -def test_build_from_tags_low_quality_json_and_busy( - runner: CliRunner, monkeypatch: pytest.MonkeyPatch -) -> None: - manual_tags_count = 2 - low_quality = { - "status": "low_quality", - "source": "manual_tags", - "recommendation": "low_quality", - "segments_available": manual_tags_count, - "embeddings_available": manual_tags_count, - "low_quality_reason": "too_few_stmts", - "observed_value": float(manual_tags_count), - "threshold_value": float(OWNER_BOOTSTRAP_MIN_STMTS), - "manual_tags_count": manual_tags_count, - "can_build_from_tags": False, - "next_step": "seed_manual_tags", - "guidance": _owner_tag_guidance(manual_tags_count), - } - monkeypatch.setattr( - speakers_routes, - "bootstrap_owner_from_manual_tags", - lambda: low_quality, - ) - - text = runner.invoke(app, ["build-from-tags"]) - json_result = runner.invoke(app, ["build-from-tags", "--json"]) - - assert text.exit_code == 0 - assert text.stderr == "" - assert text.stdout == ( - "Owner manual tags are not ready.\n" - "Reason: too_few_stmts\n" - f"Observed: {float(manual_tags_count)}\n" - f"Threshold: {float(OWNER_BOOTSTRAP_MIN_STMTS)}\n" - f"Manual tags: {manual_tags_count}\n" - "Can build from tags: False\n" - "Next step: seed_manual_tags\n" - f"Guidance: {_owner_tag_guidance(manual_tags_count)}\n" - ) - _assert_json_stdout(json_result, low_quality) - - monkeypatch.setattr( - speakers_routes, - "bootstrap_owner_from_manual_tags", - lambda: {"error_kind": "voiceprint_busy", "error": "locked"}, - ) - busy = runner.invoke(app, ["build-from-tags"]) - - assert busy.exit_code == 1 - assert busy.stdout == "" - assert busy.stderr == f"{SPEAKER_VOICEPRINT_BUSY.message}\n" - - -def test_rebuild_owner_cli_posts_to_http_and_formats_json( - runner: CliRunner, monkeypatch: pytest.MonkeyPatch -) -> None: - calls: list[bool] = [] - payload = { - "status": "rebuilt", - "principal_id": "jer", - "cluster_size": 30, - "override_applied": True, - "next_step": "none", - "guidance": "", - } - - def fake_rebuild_owner_centroid(*, override: bool = False) -> dict[str, Any]: - calls.append(override) - return dict(payload) - - monkeypatch.setattr( - speakers_routes, - "rebuild_owner_centroid", - fake_rebuild_owner_centroid, - ) - - text = runner.invoke(app, ["rebuild-owner", "--override"]) - json_result = runner.invoke(app, ["rebuild-owner", "--override", "--json"]) - - assert text.exit_code == 0 - assert text.stderr == "" - assert text.stdout == ( - "Owner centroid rebuilt (principal: jer, cluster_size: 30)\n" - "Override applied: true\n" - ) - _assert_json_stdout(json_result, payload) - assert calls == [True, True] - - -def test_rebuild_owner_cli_honest_refusal_next_step_guidance( - runner: CliRunner, monkeypatch: pytest.MonkeyPatch -) -> None: - refusal = { - "status": "rejected_regression", - "reason": "centroid_agreement_too_low", - "next_step": "review_or_override", - "guidance": "Review the manual owner tags.", - } - monkeypatch.setattr( - speakers_routes, - "rebuild_owner_centroid", - lambda *, override=False: refusal, - ) - - result = runner.invoke(app, ["rebuild-owner"]) - - assert result.exit_code == 0 - assert result.stderr == "" - assert result.stdout == ( - "Owner centroid rebuild did not write.\n" - "Reason: centroid_agreement_too_low\n" - "Next step: review_or_override\n" - "Guidance: Review the manual owner tags.\n" - ) - - -def test_tag_owner_success_retry_safe_actionable_errors_and_busy( - runner: CliRunner, monkeypatch: pytest.MonkeyPatch -) -> None: - args = ["tag-owner", "20260101", "mic", "120000_10", "mic_audio", "1"] - monkeypatch.setattr(speakers_routes, "_principal_id_or_none", lambda: "jer") - monkeypatch.setattr( - speakers_routes, - "_assign_attribution_impl", - lambda *_unused: speakers_routes.success_response({"status": "assigned"}), - ) - - assigned = runner.invoke(app, args) - - assert assigned.exit_code == 0 - assert assigned.stderr == "" - assert assigned.stdout == ("Tagged owner sentence: 20260101/mic/120000_10 #1\n") - - monkeypatch.setattr( - speakers_routes, - "_assign_attribution_impl", - lambda *_unused: speakers_routes.success_response( - {"status": "already_assigned"} - ), - ) - already = runner.invoke(app, args) - - assert already.exit_code == 0 - assert already.stderr == "" - assert already.stdout == ( - "Owner sentence already tagged: 20260101/mic/120000_10 #1\n" - ) - - cases = [ - ( - "sentence missing", - SPEAKER_SENTENCE_MISSING, - "Pick a different sentence with an embedding.", - ), - ( - "no embedding", - SPEAKER_SENTENCE_MISSING, - "Pick a different sentence with an embedding.", - ), - ( - "invalid segment", - INVALID_SEGMENT_OR_STREAM, - "Use a valid day, stream, and segment, then pick a sentence.", - ), - ("blocked entity", ENTITY_BLOCKED, "Choose an unblocked speaker."), - ] - for _case, reason, detail in cases: - - def attribution_error(*_unused, reason=reason, detail=detail): - return speakers_routes.error_response(reason, detail=detail) - - monkeypatch.setattr( - speakers_routes, - "_assign_attribution_impl", - attribution_error, - ) - result = runner.invoke(app, args) - - assert result.exit_code == 1 - assert result.stdout == "" - assert result.stderr == f"{detail}\n" - - monkeypatch.setattr( - speakers_routes, - "_assign_attribution_impl", - lambda *_unused: speakers_routes.error_response( - SPEAKER_VOICEPRINT_BUSY, detail="locked" - ), - ) - voice_busy = runner.invoke(app, args) - - assert voice_busy.exit_code == 1 - assert voice_busy.stdout == "" - assert voice_busy.stderr == f"{SPEAKER_VOICEPRINT_BUSY.message}\n" - - monkeypatch.setattr( - speakers_routes, - "_assign_attribution_impl", - lambda *_unused: speakers_routes.error_response( - SPEAKER_LABELS_BUSY, detail="locked" - ), - ) - labels_busy = runner.invoke(app, args) - - assert labels_busy.exit_code == 1 - assert labels_busy.stdout == "" - assert labels_busy.stderr == f"{SPEAKER_LABELS_BUSY.message}\n" - - -def test_tag_owner_identity_required_error( - runner: CliRunner, monkeypatch: pytest.MonkeyPatch -) -> None: - monkeypatch.setattr(speakers_routes, "_principal_id_or_none", lambda: None) - monkeypatch.setattr(speakers_routes, "principal_identity_or_none", lambda: None) - - result = runner.invoke( - app, ["tag-owner", "20260101", "mic", "120000_10", "mic_audio", "1"] - ) - - assert result.exit_code == 1 - assert result.stdout == "" - assert result.stderr == f"{SPEAKER_OWNER_IDENTITY_REQUIRED.message}\n" - - -def test_sentences_projects_sentence_id_text_and_json( - runner: CliRunner, monkeypatch: pytest.MonkeyPatch -) -> None: - monkeypatch.setattr( - speakers_routes, - "_load_sentences", - lambda day, segment_key, source, stream=None: ( - [ - {"id": 1, "text": "hello owner sentence", "has_embedding": True}, - {"id": 2, "text": "missing embedding", "has_embedding": False}, - ], - None, - ), - ) - monkeypatch.setattr( - speakers_routes, - "_load_speaker_labels", - lambda _segment_dir: { - "labels": [], - "skipped": True, - "reason": "pre-bootstrap owner", - }, - ) - stub_json_result = runner.invoke( - app, ["sentences", "20260101", "mic", "120000_10", "mic_audio", "--json"] - ) - - _assert_json_stdout( - stub_json_result, - { - "success": True, - "day": "20260101", - "stream": "mic", - "segment_key": "120000_10", - "source": "mic_audio", - "sentences": [ - { - "sentence_id": 1, - "text": "hello owner sentence", - "has_embedding": True, - "speaker": None, - "confidence": None, - "method": None, - "needs_review": True, - }, - { - "sentence_id": 2, - "text": "missing embedding", - "has_embedding": False, - "speaker": None, - "confidence": None, - "method": None, - "needs_review": True, - }, - ], - }, - ) - - monkeypatch.setattr( - speakers_routes, - "_load_speaker_labels", - lambda _segment_dir: { - "labels": [ - { - "sentence_id": 1, - "speaker": "jer", - "confidence": "high", - "method": "user_assigned", - } - ] - }, - ) - - text = runner.invoke( - app, ["sentences", "20260101", "mic", "120000_10", "mic_audio"] - ) - json_result = runner.invoke( - app, ["sentences", "20260101", "mic", "120000_10", "mic_audio", "--json"] - ) - - assert text.exit_code == 0 - assert text.stderr == "" - assert text.stdout == ( - "Sentences for 20260101/mic/120000_10/mic_audio:\n" - "* 1: hello owner sentence\n" - "- 2: missing embedding\n" - ) - _assert_json_stdout( - json_result, - { - "success": True, - "day": "20260101", - "stream": "mic", - "segment_key": "120000_10", - "source": "mic_audio", - "sentences": [ - { - "sentence_id": 1, - "text": "hello owner sentence", - "has_embedding": True, - "speaker": "jer", - "confidence": "high", - "method": "user_assigned", - "needs_review": False, - }, - { - "sentence_id": 2, - "text": "missing embedding", - "has_embedding": False, - "speaker": None, - "confidence": None, - "method": None, - "needs_review": True, - }, - ], - }, - ) - - -def test_day_segments_reports_total_when_limited( - runner: CliRunner, monkeypatch: pytest.MonkeyPatch -) -> None: - monkeypatch.setattr( - speakers_routes, - "_scan_segment_embeddings", - lambda day: [ - {"stream": "sys", "key": "100000_60", "sources": ["sys_audio"]}, - {"stream": "mic", "key": "090000_60", "sources": ["mic_audio"]}, - ], - ) - - text = runner.invoke(app, ["day-segments", "20260101", "--limit", "1"]) - json_result = runner.invoke( - app, ["day-segments", "20260101", "--limit", "1", "--json"] - ) - - assert text.exit_code == 0 - assert text.stderr == "" - assert text.stdout == ( - "Showing 1 of 2 segments (limit 1)\nmic/090000_60: mic_audio\n" - ) - _assert_json_stdout( - json_result, - { - "success": True, - "day": "20260101", - "segments": [ - {"stream": "mic", "key": "090000_60", "sources": ["mic_audio"]} - ], - "returned": 1, - "limit": 1, - "total": 2, - }, - ) - - -def test_confirm_owner_text_backfill_json_and_family2_error( - runner: CliRunner, monkeypatch: pytest.MonkeyPatch -) -> None: - monkeypatch.setattr( - speakers_routes, - "confirm_owner_candidate", - lambda: {"status": "confirmed", "principal_id": "jer", "cluster_size": 4}, - ) - monkeypatch.setattr( - speakers_routes, - "backfill_segments", - lambda dry_run, progress_callback: _backfill_stats(), - ) - - no_backfill = runner.invoke(app, ["confirm-owner", "--no-backfill"]) - default = runner.invoke(app, ["confirm-owner"]) - json_result = runner.invoke(app, ["confirm-owner", "--json"]) - - assert no_backfill.exit_code == 0 - assert no_backfill.stderr == "" - assert no_backfill.stdout == ( - "Owner centroid confirmed (principal: jer, cluster_size: 4)\n" - ) - assert default.exit_code == 0 - assert default.stderr == "" - assert default.stdout == ( - "Owner centroid confirmed (principal: jer, cluster_size: 4)\n" - "Running attribution backfill...\n" - "Backfill complete: 5 segments processed, 2 already labeled\n" - ) - _assert_json_stdout( - json_result, - { - "status": "confirmed", - "principal_id": "jer", - "cluster_size": 4, - "backfill": _backfill_stats(), - }, - ) - - error_payload = {"error": "No candidate available"} - monkeypatch.setattr( - speakers_routes, "confirm_owner_candidate", lambda: error_payload - ) - error = runner.invoke(app, ["confirm-owner", "--no-backfill"]) - - assert error.exit_code == 1 - assert error.stdout == "" - assert error.stderr == json.dumps(error_payload, indent=2, default=str) + "\n" - - -def test_reject_owner_and_owner_ready_json( - runner: CliRunner, monkeypatch: pytest.MonkeyPatch -) -> None: - monkeypatch.setattr( - speakers_routes, "reject_owner_candidate", lambda: {"status": "rejected"} - ) - monkeypatch.setattr( - speakers_routes, - "owner_detection_ready", - lambda: {"ready": True, "reason": "enough_segments"}, - ) - - reject = runner.invoke(app, ["reject-owner"]) - ready = runner.invoke(app, ["owner-ready"]) - - _assert_json_stdout(reject, {"status": "rejected"}) - _assert_json_stdout(ready, {"ready": True, "reason": "enough_segments"}) - - -def test_convey_down_prints_require_solstone_message( - monkeypatch: pytest.MonkeyPatch, -) -> None: - class DownSession: - def get(self, _url): - raise requests.exceptions.ConnectionError() - - def post(self, _url, json=None): - raise requests.exceptions.ConnectionError() - - client = ConveyClient(session=DownSession(), base_url="http://localhost:5015") - monkeypatch.setattr(speakers_call, "get_client", lambda: client) - monkeypatch.delenv("SOL_SKIP_SUPERVISOR_CHECK", raising=False) - monkeypatch.delenv("SOL_SUPERVISOR_SPAWNED", raising=False) - - result = CliRunner().invoke(app, ["status"]) - - assert result.exit_code == 1 - assert ( - result.stderr - == "sol: solstone isn't running. Start it with 'journal up' and retry.\n" - ) - assert result.stdout == "" - - -def test_attribute_save_busy_prints_owner_voice_error( - runner: CliRunner, monkeypatch: pytest.MonkeyPatch -) -> None: - monkeypatch.setattr( - speakers_routes, - "attribute_segment", - lambda day, stream, segment: _attribute_result(), - ) - - def save_speaker_labels(_seg_dir, _labels, _metadata): - raise LockTimeout(Path("speaker_labels.json"), 0.01) - - monkeypatch.setattr(speakers_routes, "save_speaker_labels", save_speaker_labels) - - result = runner.invoke( - app, - [ - "attribute-segment", - "20260101", - "mic", - "120000_10", - "--commit", - "--no-accumulate", - ], - ) - - assert result.exit_code == 1 - assert result.stdout == "" - assert result.stderr == f"{SPEAKER_LABELS_BUSY.message}\n" diff --git a/tests/test_surfaces_health.py b/tests/test_surfaces_health.py index 0a6aab6d9..aaecf6db6 100644 --- a/tests/test_surfaces_health.py +++ b/tests/test_surfaces_health.py @@ -3,20 +3,14 @@ from __future__ import annotations -import builtins import json import os -import re from datetime import UTC, datetime, timedelta from pathlib import Path from unittest.mock import MagicMock import pytest -from click.testing import Result -from typer.testing import CliRunner -from solstone.think.brain_health import HEADLINES -from solstone.think.convey_client import ConveyClient from solstone.think.pipeline_health import SegmentBacklog, SegmentCompletion from solstone.think.processing import ( DISPLAY_POWERSAVE_UNAVAILABLE, @@ -35,9 +29,7 @@ from solstone.think.processing import ( format_awaiting_analysis, ) from solstone.think.surfaces import health as health_surface -from tests._baseline_harness import make_test_client -_RUNNER = CliRunner() _SPEC_POINTER = "cpo/specs/in-flight/consumer-surface-health.md" @@ -192,20 +184,6 @@ def _write_indexer_db(tmp_path: Path, dt: datetime) -> int: return path.stat().st_mtime_ns // 1_000_000 -def _write_pipeline_log( - tmp_path: Path, - day: str, - filename: str, - *rows: dict[str, object], -) -> None: - path = tmp_path / "chronicle" / day / "health" / filename - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text( - "".join(json.dumps(row) + "\n" for row in rows), - encoding="utf-8", - ) - - def _segment_backlog( per_day_counts: dict[str, int], *, @@ -264,91 +242,6 @@ def _stub_display_powersave( ) -def _brain_snapshot( - *, - state: str = "ready", - headline: str = HEADLINES["ready"], - reason_text: str | None = None, - component: str | None = None, - action: dict[str, object] | None = None, -) -> dict[str, object]: - return { - "state": state, - "headline": headline, - "reason_code": "provider_key_invalid" if reason_text else None, - "reason_text": reason_text, - "failing_component": component, - "action": action, - "identity": {"lane": "cloud", "provider": "google", "model": "gemini"}, - "evidence": { - "observed_at": "2026-04-10T12:00:00Z", - "age_seconds": 60, - "age_text": "1m", - }, - "components": { - "generate": { - "status": "ready", - "reason_code": None, - "reason_text": None, - "observed_at": "2026-04-10T12:00:00Z", - }, - "cogitate": { - "status": "ready", - "reason_code": None, - "reason_text": None, - "observed_at": "2026-04-10T12:00:00Z", - }, - }, - "progressing": False, - } - - -def _brain_health_payload( - *, - state: str = "ready", - headline: str = HEADLINES["ready"], - reason_text: str | None = None, - component: str | None = None, - action: dict[str, object] | None = None, -) -> dict[str, object]: - snapshot = _brain_snapshot( - state=state, - headline=headline, - reason_text=reason_text, - component=component, - action=action, - ) - lines = ["Brain Health", f" {headline}"] - if reason_text is None: - lines.append(" cloud google/gemini, checked 1m ago") - else: - suffix = f" ({component})" if component else "" - lines.append(f" cloud google/gemini — {reason_text}{suffix}") - if action is not None: - target = action.get("href") or action.get("command") - lines.append(f" → {action['label']}: {target}") - return {"snapshot": snapshot, "lines": lines} - - -def _patch_health_cli_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - client = ConveyClient(session=make_test_client(tmp_path), base_url="") - monkeypatch.setattr("solstone.think.tools.health.get_client", lambda: client) - - -def _invoke_health_summary( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> Result: - _configure_env(tmp_path, monkeypatch) - _set_now(monkeypatch, _utc_dt("20260410")) - _minimal_facet_tree(tmp_path) - _patch_health_cli_client(tmp_path, monkeypatch) - - from solstone.think.call import call_app - - return _RUNNER.invoke(call_app, ["health", "summary", "--day", "20260410"]) - - def test_summary_metrics_from_controlled_range(tmp_path, monkeypatch): _configure_env(tmp_path, monkeypatch) _set_now(monkeypatch, _utc_dt("20260410")) @@ -1390,379 +1283,3 @@ def test_talent_run_failures_24h_use_today_and_yesterday_day_indices( report = health_surface.summary("20260410") assert report.synthesis_health.talent_run_failures_24h == 2 - - -def test_cli_summary_renders_segment_backlog(tmp_path, monkeypatch): - monkeypatch.setattr( - health_surface, - "read_segment_backlog", - lambda: _segment_backlog({"20260409": 1, "20260410": 2}), - ) - - result = _invoke_health_summary(tmp_path, monkeypatch) - - assert result.exit_code == 0 - assert "3 segments across 2 days awaiting thinking" in result.stdout - - -def test_cli_summary_renders_singular_segment_backlog(tmp_path, monkeypatch): - monkeypatch.setattr( - health_surface, - "read_segment_backlog", - lambda: _segment_backlog({"20260410": 1}), - ) - - result = _invoke_health_summary(tmp_path, monkeypatch) - - assert result.exit_code == 0 - assert "1 segment across 1 day awaiting thinking" in result.stdout - - -def test_cli_summary_renders_segment_backlog_unavailable(tmp_path, monkeypatch): - monkeypatch.setattr( - health_surface, - "read_segment_backlog", - lambda: _segment_backlog({}, errors=("20260101",)), - ) - - result = _invoke_health_summary(tmp_path, monkeypatch) - - assert result.exit_code == 0 - assert "Segment analysis status unavailable" in result.stdout - assert "awaiting thinking" not in result.stdout - - -def test_cli_summary_renders_partial_segment_backlog(tmp_path, monkeypatch): - monkeypatch.setattr( - health_surface, - "read_segment_backlog", - lambda: _segment_backlog({"20260409": 1, "20260410": 2}, errors=("bad",)), - ) - - result = _invoke_health_summary(tmp_path, monkeypatch) - - assert result.exit_code == 0 - assert "at least" in result.stdout - assert "awaiting thinking (status incomplete)" in result.stdout - - -def test_cli_summary_omits_caught_up_segment_backlog(tmp_path, monkeypatch): - monkeypatch.setattr( - health_surface, - "read_segment_backlog", - lambda: _segment_backlog({}), - ) - - result = _invoke_health_summary(tmp_path, monkeypatch) - - assert result.exit_code == 0 - assert "awaiting thinking" not in result.stdout - - -def test_cli_health_summary_full_range_json(tmp_path, monkeypatch): - _configure_env(tmp_path, monkeypatch) - _set_now(monkeypatch, _utc_dt("20260410")) - _minimal_facet_tree(tmp_path) - _patch_health_cli_client(tmp_path, monkeypatch) - - from solstone.think.call import call_app - - summary_result = _RUNNER.invoke(call_app, ["health", "summary", "--json"]) - full_result = _RUNNER.invoke(call_app, ["health", "full", "--json"]) - range_result = _RUNNER.invoke( - call_app, - [ - "health", - "for-range", - "--day-from", - "20260409", - "--day-to", - "20260410", - "--json", - ], - ) - - for result in (summary_result, full_result, range_result): - assert result.exit_code == 0 - payload = json.loads(result.stdout) - assert set(payload) == { - "generated_at", - "range", - "facets", - "capture_health", - "synthesis_health", - "consumer_signal", - "segment_backlog", - "notes", - "brain_health", - } - assert list(payload) == sorted(payload) - - -def test_brain_health_carried_through_verbatim_json(tmp_path, monkeypatch): - _configure_env(tmp_path, monkeypatch) - _set_now(monkeypatch, _utc_dt("20260410")) - _minimal_facet_tree(tmp_path) - monkeypatch.setattr( - health_surface, "read_segment_backlog", lambda: _segment_backlog({}) - ) - sentinel = _brain_health_payload( - state="blocked", - headline="SENTINEL HEADLINE", - reason_text="SENTINEL REASON", - component="generate", - action={"label": "open thinking", "href": "/app/thinking/#main"}, - ) - monkeypatch.setattr( - health_surface, - "_build_brain_health", - lambda: sentinel, - ) - _patch_health_cli_client(tmp_path, monkeypatch) - - from solstone.think.call import call_app - - result = _RUNNER.invoke(call_app, ["health", "summary", "--json"]) - - assert result.exit_code == 0 - payload = json.loads(result.stdout) - assert payload["brain_health"] == sentinel - - -def test_other_sections_unchanged_across_brain_states(tmp_path, monkeypatch): - _configure_env(tmp_path, monkeypatch) - _set_now(monkeypatch, _utc_dt("20260410")) - _minimal_facet_tree(tmp_path) - monkeypatch.setattr( - health_surface, "read_segment_backlog", lambda: _segment_backlog({}) - ) - _patch_health_cli_client(tmp_path, monkeypatch) - - from solstone.think.call import call_app - - rendered_payloads = [] - for payload in ( - _brain_health_payload(state="ready", headline=HEADLINES["ready"]), - _brain_health_payload( - state="blocked", - headline=HEADLINES["blocked"], - reason_text="choose a thinking engine", - component="configuration", - ), - _brain_health_payload( - state="unknown", - headline=HEADLINES["unknown"], - reason_text="missing evidence", - component=None, - ), - ): - monkeypatch.setattr( - health_surface, - "_build_brain_health", - lambda payload=payload: payload, - ) - result = _RUNNER.invoke(call_app, ["health", "summary", "--json"]) - assert result.exit_code == 0 - payload = json.loads(result.stdout) - without_brain = { - key: value for key, value in payload.items() if key != "brain_health" - } - rendered_payloads.append(json.dumps(without_brain, sort_keys=True)) - - assert rendered_payloads[0] == rendered_payloads[1] == rendered_payloads[2] - - -def test_human_brain_health_lines_are_printed(tmp_path, monkeypatch): - _configure_env(tmp_path, monkeypatch) - _set_now(monkeypatch, _utc_dt("20260410")) - _minimal_facet_tree(tmp_path) - monkeypatch.setattr( - health_surface, "read_segment_backlog", lambda: _segment_backlog({}) - ) - monkeypatch.setattr( - health_surface, - "_build_brain_health", - lambda: _brain_health_payload( - state="blocked", - headline=HEADLINES["blocked"], - reason_text="SENTINEL REASON", - component="generate", - action={"label": "open thinking", "href": "/app/thinking/#main"}, - ), - ) - _patch_health_cli_client(tmp_path, monkeypatch) - - from solstone.think.call import call_app - - result = _RUNNER.invoke(call_app, ["health", "summary"]) - - assert result.exit_code == 0 - assert "Brain Health" in result.stdout - assert HEADLINES["blocked"] in result.stdout - assert "cloud google/gemini — SENTINEL REASON (generate)" in result.stdout - assert "→ open thinking: /app/thinking/#main" in result.stdout - - -def test_human_block_renderer_stays_thin(tmp_path, monkeypatch): - _configure_env(tmp_path, monkeypatch) - _set_now(monkeypatch, _utc_dt("20260410")) - _minimal_facet_tree(tmp_path) - monkeypatch.setattr( - health_surface, "read_segment_backlog", lambda: _segment_backlog({}) - ) - monkeypatch.setattr( - health_surface, - "_build_brain_health", - lambda: _brain_health_payload( - state="blocked", - headline=HEADLINES["blocked"], - reason_text="SENTINEL REASON", - component="generate", - ), - ) - _patch_health_cli_client(tmp_path, monkeypatch) - - from solstone.think.call import call_app - - real_import = builtins.__import__ - - def guarded_import(name, globals=None, locals=None, fromlist=(), level=0): - if name == "solstone.think.brain_health": - raise AssertionError("health renderer imported brain health renderer") - return real_import(name, globals, locals, fromlist, level) - - monkeypatch.setattr(builtins, "__import__", guarded_import) - - result = _RUNNER.invoke(call_app, ["health", "summary"]) - - assert result.exit_code == 0 - assert "SENTINEL REASON" in result.stdout - - -def test_brain_health_degrades_when_builder_raises(tmp_path, monkeypatch): - _configure_env(tmp_path, monkeypatch) - _set_now(monkeypatch, _utc_dt("20260410")) - _minimal_facet_tree(tmp_path) - monkeypatch.setattr( - health_surface, "read_segment_backlog", lambda: _segment_backlog({}) - ) - - def raise_brain_error(*_args, **_kwargs) -> dict[str, object]: - raise RuntimeError("brain failed") - - monkeypatch.setattr( - health_surface, - "build_brain_snapshot", - raise_brain_error, - ) - _patch_health_cli_client(tmp_path, monkeypatch) - - from solstone.think.call import call_app - - result = _RUNNER.invoke(call_app, ["health", "summary", "--json"]) - - assert result.exit_code == 0 - payload = json.loads(result.stdout) - assert payload["brain_health"]["snapshot"]["state"] == "unknown" - assert payload["brain_health"]["snapshot"]["headline"] == HEADLINES["unknown"] - assert "capture_health" in payload - - -def test_backlog_and_brain_health_render_independently(tmp_path, monkeypatch): - _configure_env(tmp_path, monkeypatch) - _set_now(monkeypatch, _utc_dt("20260410")) - _minimal_facet_tree(tmp_path) - monkeypatch.setattr( - health_surface, - "read_segment_backlog", - lambda: _segment_backlog({"20260409": 1, "20260410": 2}), - ) - monkeypatch.setattr( - health_surface, - "_build_brain_health", - lambda: _brain_health_payload( - state="blocked", - headline=HEADLINES["blocked"], - reason_text="SENTINEL REASON", - component="generate", - ), - ) - _patch_health_cli_client(tmp_path, monkeypatch) - - from solstone.think.call import call_app - - result = _RUNNER.invoke(call_app, ["health", "summary"]) - - assert result.exit_code == 0 - assert "3 segments across 2 days awaiting thinking" in result.stdout - assert "Brain Health" in result.stdout - assert "SENTINEL REASON" in result.stdout - - -def test_cli_help_disambiguates_and_lists_health_once(tmp_path, monkeypatch): - _configure_env(tmp_path, monkeypatch) - _minimal_facet_tree(tmp_path) - - from solstone.think.call import call_app - - health_help = _RUNNER.invoke(call_app, ["health", "--help"]) - root_help = _RUNNER.invoke(call_app, ["--help"]) - normalized_help = " ".join(health_help.stdout.split()) - - assert health_help.exit_code == 0 - assert ( - "Health: journal-data trust signals (for infrastructure/service liveness, use `journal health`)." - in normalized_help - ) - assert root_help.exit_code == 0 - assert ( - sum( - 1 - for line in root_help.stdout.splitlines() - if re.search(r"│\s+health\s+Health:", line) - ) - == 1 - ) - - -def test_cli_pipeline_relocated_behavior(tmp_path, monkeypatch): - _configure_env(tmp_path, monkeypatch) - _minimal_facet_tree(tmp_path) - day = "20260101" - expected_today = datetime.now().strftime("%Y%m%d") - expected_yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y%m%d") - _write_pipeline_log( - tmp_path, - day, - "123_segment.jsonl", - {"event": "run.start", "mode": "segment"}, - {"event": "talent.dispatch", "mode": "segment"}, - {"event": "talent.complete", "mode": "segment"}, - {"event": "run.complete", "mode": "segment", "duration_ms": 42}, - ) - _patch_health_cli_client(tmp_path, monkeypatch) - - from solstone.think.call import call_app - - default_result = _RUNNER.invoke(call_app, ["health", "pipeline"]) - day_result = _RUNNER.invoke(call_app, ["health", "pipeline", "--day", day]) - yesterday_result = _RUNNER.invoke(call_app, ["health", "pipeline", "--yesterday"]) - error_result = _RUNNER.invoke( - call_app, - ["health", "pipeline", "--day", day, "--yesterday"], - ) - - assert default_result.exit_code == 0 - assert json.loads(default_result.stdout)["day"] == expected_today - assert day_result.exit_code == 0 - day_payload = json.loads(day_result.stdout) - assert day_payload["day"] == day - assert day_payload["runs"]["segment"]["count"] == 1 - assert day_payload["talents"]["dispatched"] >= 1 - assert day_payload["talents"]["outstanding_failed"] == 0 - assert day_payload["talents"]["failed_list"] == [] - assert day_payload["talents"]["failed_list_truncated"] is False - assert yesterday_result.exit_code == 0 - assert json.loads(yesterday_result.stdout)["day"] == expected_yesterday - assert error_result.exit_code == 1 - assert "mutually exclusive" in error_result.stderr diff --git a/tests/test_thinking_call_parity.py b/tests/test_thinking_call_parity.py deleted file mode 100644 index 19b93df8a..000000000 --- a/tests/test_thinking_call_parity.py +++ /dev/null @@ -1,1016 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -from __future__ import annotations - -import json -import threading -from datetime import datetime, timezone -from pathlib import Path -from typing import Any - -import pytest -from typer.testing import CliRunner - -import solstone.apps.thinking.call as thinking_call -import solstone.apps.thinking.routes as thinking_routes -from solstone.apps.thinking import copy as thinking_copy -from solstone.think.convey_client import ConveyClient, ConveyUnreachableError -from solstone.think.services import operations, scout, scout_handoff, spp, spp_handoff -from tests._baseline_harness import make_test_client -from tests.helpers.module_mocks import inline_thread_constructor, module_mock - -runner = CliRunner() - -API_ENV_KEYS = ( - "GOOGLE_API_KEY", - "ANTHROPIC_API_KEY", - "OPENAI_API_KEY", - "PLAUD_ACCESS_TOKEN", -) - - -class _FixedDateTime: - @classmethod - def now(cls, tz=None): - return datetime(2026, 4, 17, 12, 0, tzinfo=tz or timezone.utc) - - -@pytest.fixture(autouse=True) -def _thinking_client(journal_copy: Path, monkeypatch: pytest.MonkeyPatch) -> None: - for key in API_ENV_KEYS: - monkeypatch.delenv(key, raising=False) - config = json.loads( - (journal_copy / "config" / "journal.json").read_text(encoding="utf-8") - ) - providers = config.setdefault("providers", {}) - providers.pop("generate", None) - providers.pop("cogitate", None) - providers["active"] = { - "provider": "google", - "model": "gemini-custom-flash-test", - } - (journal_copy / "config" / "journal.json").write_text( - json.dumps(config, indent=2) + "\n", - encoding="utf-8", - ) - client = ConveyClient( - session=make_test_client(journal_copy), - base_url="", - ) - monkeypatch.setattr(thinking_call, "get_client", lambda: client) - monkeypatch.setenv("SOL_SKIP_SUPERVISOR_CHECK", "1") - - -@pytest.fixture(autouse=True) -def _clear_service_operations() -> None: - operations.clear_registry() - yield - operations.clear_registry() - - -@pytest.fixture -def fake_validators(monkeypatch: pytest.MonkeyPatch) -> None: - def validate_key(provider: str, api_key: str) -> dict[str, Any]: - return {"valid": True, "provider": provider, "fingerprint": api_key[-4:]} - - monkeypatch.setattr(thinking_routes, "datetime", _FixedDateTime) - monkeypatch.setattr(thinking_routes, "validate_key", validate_key) - - -def _read_config(journal: Path) -> dict[str, Any]: - return json.loads((journal / "config" / "journal.json").read_text(encoding="utf-8")) - - -def _write_config(journal: Path, payload: dict[str, Any]) -> None: - (journal / "config" / "journal.json").write_text( - json.dumps(payload, indent=2) + "\n", - encoding="utf-8", - ) - - -def _assert_json(result, expected: Any) -> None: - assert result.exit_code == 0 - assert json.loads(result.stdout) == expected - assert result.stderr == "" - - -def _approved_scout_payload(key: str = "google-scout-key") -> dict[str, str]: - return { - "state": "approved", - "google_api_key": key, - "dispatch_token": "dispatch-secret", - "account_id": "acct-secret", - "created_at": "2026-05-24T00:00:00Z", - } - - -def _confidential_payload(suffix: str = "one") -> dict[str, str]: - return { - "endpoint_url": f"https://spp-{suffix}.example.test/v1", - "served_model_id": f"confidential-model-{suffix}", - "credential": f"credential-{suffix}", - "account_id": f"acct-{suffix}", - "created_at": "2026-05-24T00:00:00Z", - } - - -def _clear_scout(journal: Path) -> None: - config = _read_config(journal) - config.setdefault("env", {}).pop("GOOGLE_API_KEY", None) - config.setdefault("services", {}).pop("scout", None) - _write_config(journal, config) - - -def _first_json(stdout: str) -> tuple[Any, str]: - payload, index = json.JSONDecoder().raw_decode(stdout) - return payload, stdout[index:].strip() - - -def _stable_confidential_handoff_url(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr( - spp_handoff, - "build_confidential_handoff_url", - lambda: ( - "http://portal.test/enable/spp?nonce=NONCE", - "NONCE", - "http://portal.test", - ), - ) - - -def _confidential_status_subset(state: dict[str, Any]) -> dict[str, Any]: - operation = state.get("confidential_operation") - operation_phase = operation.get("phase") if isinstance(operation, dict) else None - return { - "confidential_enabled": state.get("confidential_enabled"), - "confidential_provenance_configured": state.get( - "confidential_provenance_configured" - ), - "confidential_operation_phase": operation_phase, - "confidential_attestation": state.get("confidential_attestation"), - } - - -def _checked_confidential_attestation() -> dict[str, str | None]: - return { - "state": "stale", - "reason": "brain_record_missing", - "observed_at": None, - "expires_at": None, - } - - -def _assert_no_confidential_secret(output: str, suffix: str = "secret") -> None: - assert f"credential-{suffix}" not in output - assert f"acct-{suffix}" not in output - - -def test_show_verbs_select_http_fields() -> None: - keys = runner.invoke(thinking_call.app, ["keys", "show"]) - providers = runner.invoke(thinking_call.app, ["providers", "show"]) - - assert keys.exit_code == 0 - keys_payload = json.loads(keys.stdout) - assert set(keys_payload) == {"api_keys", "env", "key_validation"} - assert set(keys_payload["env"]) == { - "GOOGLE_API_KEY", - "ANTHROPIC_API_KEY", - "OPENAI_API_KEY", - } - - assert providers.exit_code == 0 - providers_payload = json.loads(providers.stdout) - assert providers_payload["active"]["provider"] == "google" - assert providers_payload["local_override"]["enabled"] is False - assert providers_payload["active_lane"]["lane"] == "byo" - assert providers_payload["active_lane"]["confidential_attestation"] == { - "state": "off", - "reason": "confidential_not_configured", - "observed_at": None, - "expires_at": None, - } - - -def test_scout_status_matches_http_payload(journal_copy: Path) -> None: - _clear_scout(journal_copy) - expected = thinking_call._get_scout_status() - - result = runner.invoke(thinking_call.app, ["scout", "status"]) - - assert result.exit_code == 0 - payload, guidance = _first_json(result.stdout) - assert payload == expected - assert guidance == thinking_call._SCOUT_GUIDANCE[thinking_copy.SCOUT_STATE_OFF] - - -def test_scout_check_matches_http_response(journal_copy: Path) -> None: - _clear_scout(journal_copy) - expected = thinking_call._request( - "POST", - "/app/thinking/api/scout/check", - ) - - result = runner.invoke(thinking_call.app, ["scout", "check"]) - - assert result.exit_code == 0 - payload, guidance = _first_json(result.stdout) - assert payload == expected - assert guidance == thinking_call._SCOUT_GUIDANCE[payload["state"]] - - -def test_scout_disable_matches_http_response(journal_copy: Path) -> None: - _clear_scout(journal_copy) - scout.provision_scout_handoff(_approved_scout_payload()) - expected_response = thinking_call._request( - "POST", - "/app/thinking/api/scout/disable", - ) - - scout.provision_scout_handoff(_approved_scout_payload()) - result = runner.invoke(thinking_call.app, ["scout", "disable"]) - - _assert_json( - result, - { - "result": expected_response["result"], - "status": expected_response["status"], - }, - ) - - -def test_scout_enable_polls_terminal_success( - journal_copy: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - _clear_scout(journal_copy) - - def runner_result(**_kwargs): - scout.provision_scout_handoff(_approved_scout_payload()) - return operations.HandoffResult("enabled", None, False) - - monkeypatch.setattr(scout_handoff, "run_scout_handoff", runner_result) - monkeypatch.setattr( - operations, - "threading", - module_mock( - operations.threading, - Thread=inline_thread_constructor(), - ), - ) - - result = runner.invoke( - thinking_call.app, - ["scout", "enable", "--wait-seconds", "2", "--poll-interval", "0"], - ) - - assert result.exit_code == 0 - assert result.stderr == "" - # Status and operation are sampled non-atomically; phase is stable here. - assert "operation: invited\n" in result.stdout - assert thinking_call._SCOUT_GUIDANCE[thinking_copy.SCOUT_STATE_INVITED] in ( - result.stdout - ) - - -def test_scout_enable_exits_nonzero_on_repair_needed( - journal_copy: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - _clear_scout(journal_copy) - monkeypatch.setattr( - scout_handoff, - "run_scout_handoff", - lambda **_kwargs: operations.HandoffResult( - "error", - "Try again.", - True, - ), - ) - - result = runner.invoke( - thinking_call.app, - ["scout", "enable", "--wait-seconds", "2", "--poll-interval", "0"], - ) - - assert result.exit_code == 1 - assert result.stderr == "" - assert "operation: repair_needed\n" in result.stdout - assert "Try again.\n" in result.stdout - assert ( - thinking_call._SCOUT_GUIDANCE[thinking_copy.SCOUT_STATE_REPAIR_NEEDED] - in result.stdout - ) - - -def test_scout_cli_copy_mirror_matches_thinking_copy() -> None: - local_states = { - thinking_call._SCOUT_STATE_OFF, - thinking_call._SCOUT_STATE_REQUESTED, - thinking_call._SCOUT_STATE_INVITED, - thinking_call._SCOUT_STATE_ON, - thinking_call._SCOUT_STATE_ENDED, - thinking_call._SCOUT_STATE_MANUAL_KEY_PRESENT, - thinking_call._SCOUT_STATE_REPAIR_NEEDED, - } - - assert local_states == set(thinking_copy.SCOUT_STATE_LABELS) - assert thinking_call._SCOUT_PRODUCT_STATES == set(thinking_copy.SCOUT_STATE_LABELS) - assert set(thinking_call._SCOUT_GUIDANCE) == set(thinking_copy.SCOUT_STATE_LABELS) - assert thinking_call._SCOUT_TERMINAL_PHASES == { - thinking_copy.SCOUT_STATE_INVITED, - thinking_copy.SCOUT_STATE_REQUESTED, - thinking_copy.SCOUT_STATE_ENDED, - thinking_copy.SCOUT_STATE_REPAIR_NEEDED, - } - assert thinking_call._SCOUT_CONSENT_CTA == thinking_copy.SCOUT_CONSENT_CTA - - -def test_confidential_status_matches_http_active_lane_subset( - journal_copy: Path, -) -> None: - before = (journal_copy / "config" / "journal.json").read_text(encoding="utf-8") - expected = _confidential_status_subset(thinking_call._get_confidential_state()) - - result = runner.invoke(thinking_call.app, ["confidential", "status"]) - - _assert_json(result, expected) - assert (journal_copy / "config" / "journal.json").read_text( - encoding="utf-8" - ) == before - - -def test_confidential_status_omits_operation_portal_nonce( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setattr( - thinking_call, - "_get_confidential_state", - lambda: { - "confidential_enabled": False, - "confidential_provenance_configured": False, - "confidential_operation": { - "phase": "starting", - "portal_url": "http://portal.test/enable/spp?nonce=SECRETNONCE", - }, - "confidential_attestation": _checked_confidential_attestation(), - }, - ) - - result = runner.invoke(thinking_call.app, ["confidential", "status"]) - - payload = json.loads(result.stdout) - output = result.stdout + result.stderr - assert result.exit_code == 0 - assert payload["confidential_operation_phase"] == "starting" - assert "SECRETNONCE" not in output - assert "portal_url" not in output - - -@pytest.mark.parametrize( - ("refresh_ok", "expected_error"), - [ - (True, None), - (False, "check_not_started"), - ], -) -def test_confidential_recheck_rereads_attestation( - monkeypatch: pytest.MonkeyPatch, - refresh_ok: bool, - expected_error: str | None, -) -> None: - spp.provision_confidential_handoff(_confidential_payload("recheck")) - monkeypatch.setattr( - thinking_routes, - "request_brain_refresh", - lambda *, surface: surface == "thinking" and refresh_ok, - ) - monkeypatch.setattr( - thinking_routes, - "build_brain_snapshot", - lambda *_args, **_kwargs: {"state": "checking"}, - ) - - result = runner.invoke(thinking_call.app, ["confidential", "recheck"]) - - assert result.exit_code == 0 - assert result.stderr == "" - payload = json.loads(result.stdout) - expected = { - "ok": refresh_ok, - "attestation": thinking_call._get_confidential_state().get( - "confidential_attestation" - ), - } - if expected_error is not None: - expected["error"] = expected_error - assert payload == expected - assert "brain" not in payload - - -_NOT_VERIFIED_GUIDANCE = ( - "Hardware attestation is not yet verified. " - "Thinking stays blocked until verification finishes." -) - - -@pytest.mark.parametrize( - ( - "raw_phase", - "guidance", - "retryable", - "subscribe_url", - "expected_phase", - "expected_exit", - ), - [ - ( - "enabled", - _NOT_VERIFIED_GUIDANCE, - False, - None, - "not_verified", - 0, - ), - ("error", "Try again.", True, None, "repair_needed", 1), - ("early_access", None, False, None, "early_access", 1), - ( - "needs_subscription", - "Subscription required.", - False, - "https://subscribe.example.test", - "needs_subscription", - 1, - ), - ("revoked", "Access was revoked.", False, None, "revoked", 1), - ], -) -def test_confidential_enable_terminal_phase_exit_codes( - monkeypatch: pytest.MonkeyPatch, - raw_phase: str, - guidance: str | None, - retryable: bool, - subscribe_url: str | None, - expected_phase: str, - expected_exit: int, -) -> None: - _stable_confidential_handoff_url(monkeypatch) - - def runner_result(**_kwargs): - if raw_phase == "enabled": - spp.provision_confidential_handoff(_confidential_payload("terminal")) - return operations.HandoffResult( - raw_phase, - guidance, - retryable, - subscribe_url=subscribe_url, - ) - - monkeypatch.setattr(spp_handoff, "run_confidential_handoff", runner_result) - monkeypatch.setattr( - operations, - "threading", - module_mock( - operations.threading, - Thread=inline_thread_constructor(), - ), - ) - - result = runner.invoke( - thinking_call.app, - ["confidential", "enable", "--wait-seconds", "2", "--poll-interval", "0"], - ) - - output = result.stdout + result.stderr - assert result.exit_code == expected_exit - assert result.stderr == "" - assert ( - "continue in browser → http://portal.test/enable/spp?nonce=NONCE" - in result.stdout - ) - assert f"operation: {expected_phase}\n" in result.stdout - if guidance: - assert guidance in result.stdout - if subscribe_url: - assert f"subscribe_url: {subscribe_url}\n" in result.stdout - if expected_exit == 0: - assert "next: sol call thinking confidential recheck\n" in result.stdout - assert "attestation_state: verified" not in result.stdout - else: - assert "next: sol call thinking confidential recheck" not in result.stdout - assert "credential-terminal" not in output - assert "acct-terminal" not in output - - -def test_confidential_enable_timeout_does_not_fabricate_phase( - monkeypatch: pytest.MonkeyPatch, -) -> None: - _stable_confidential_handoff_url(monkeypatch) - release = threading.Event() - finished = threading.Event() - - def runner_result(**_kwargs): - try: - release.wait(5) - return operations.HandoffResult("pending", "Still pending.", False) - finally: - finished.set() - - monkeypatch.setattr(spp_handoff, "run_confidential_handoff", runner_result) - - try: - result = runner.invoke( - thinking_call.app, - [ - "confidential", - "enable", - "--wait-seconds", - "0", - "--poll-interval", - "0", - ], - ) - finally: - release.set() - finished.wait(1) - - output = result.stdout + result.stderr - assert result.exit_code == 1 - assert "operation continues server-side" in output - assert "sol call thinking confidential status" in output - assert "repair_needed" not in output - assert "Timed out waiting for Scout" not in output - - -@pytest.mark.parametrize( - ("configured", "expected_exit"), - [ - (True, 0), - (False, 1), - ], -) -def test_confidential_enable_swept_operation_resolution( - monkeypatch: pytest.MonkeyPatch, - configured: bool, - expected_exit: int, -) -> None: - def fake_post_confidential_action(path: str) -> dict[str, Any]: - assert path == "/app/thinking/api/confidential/enable" - return { - "operation": { - "phase": "starting", - "portal_url": "http://portal.test/enable/spp?nonce=NONCE", - } - } - - monkeypatch.setattr( - thinking_call, - "_post_confidential_action", - fake_post_confidential_action, - ) - attestation = _checked_confidential_attestation() - states = [ - { - "confidential_enabled": False, - "confidential_provenance_configured": False, - "confidential_operation": { - "phase": "starting", - "guidance": None, - "subscribe_url": None, - }, - "confidential_attestation": attestation, - }, - { - "confidential_enabled": configured, - "confidential_provenance_configured": configured, - "confidential_operation": None, - "confidential_attestation": attestation, - }, - ] - - def scripted_state() -> dict[str, Any]: - if states: - return states.pop(0) - return { - "confidential_enabled": configured, - "confidential_provenance_configured": configured, - "confidential_operation": None, - "confidential_attestation": attestation, - } - - monkeypatch.setattr(thinking_call, "_get_confidential_state", scripted_state) - - result = runner.invoke( - thinking_call.app, - ["confidential", "enable", "--wait-seconds", "2", "--poll-interval", "0"], - ) - - output = result.stdout + result.stderr - assert result.exit_code == expected_exit - assert ( - "continue in browser → http://portal.test/enable/spp?nonce=NONCE" - in result.stdout - ) - if configured: - assert "next: sol call thinking confidential recheck\n" in result.stdout - assert "operation ended without enabling confidential processing" not in output - else: - assert "operation ended without enabling confidential processing" in output - assert "sol call thinking confidential status" in output - assert "next: sol call thinking confidential recheck" not in output - - -def test_confidential_unreachable_uses_convey_cli_message( - monkeypatch: pytest.MonkeyPatch, -) -> None: - class UnreachableClient: - def request(self, *_args, **_kwargs): - raise ConveyUnreachableError("I couldn't reach the journal over HTTP.") - - monkeypatch.setattr(thinking_call, "get_client", lambda: UnreachableClient()) - - result = runner.invoke(thinking_call.app, ["confidential", "status"]) - - assert result.exit_code == 1 - assert result.stdout == "" - assert result.stderr == "I couldn't reach the journal over HTTP.\n" - - -def test_confidential_multi_verb_outputs_scrub_secrets( - monkeypatch: pytest.MonkeyPatch, -) -> None: - spp.provision_confidential_handoff(_confidential_payload("secret")) - monkeypatch.setattr( - thinking_routes, - "request_brain_refresh", - lambda *, surface: surface == "thinking", - ) - monkeypatch.setattr( - thinking_routes, - "build_brain_snapshot", - lambda *_args, **_kwargs: {"state": "checking"}, - ) - - results = [ - runner.invoke(thinking_call.app, ["confidential", "status"]), - runner.invoke(thinking_call.app, ["confidential", "recheck"]), - runner.invoke(thinking_call.app, ["confidential", "enable"]), - runner.invoke(thinking_call.app, ["confidential", "disable"]), - ] - - assert [result.exit_code for result in results] == [0, 0, 1, 0] - for result in results: - _assert_no_confidential_secret(result.stdout + result.stderr) - - -def test_confidential_service_busy_surfaces_detail( - monkeypatch: pytest.MonkeyPatch, -) -> None: - _stable_confidential_handoff_url(monkeypatch) - started = threading.Event() - release = threading.Event() - finished = threading.Event() - - def blocking_flow() -> operations.HandoffResult: - try: - started.set() - release.wait(5) - return operations.HandoffResult("pending", "Still pending.", False) - finally: - finished.set() - - operations.start_operation( - thinking_routes.SERVICE_SPP, - "enable", - "http://portal.test/enable/spp?nonce=BUSY", - blocking_flow, - ) - assert started.wait(1) - - try: - result = runner.invoke(thinking_call.app, ["confidential", "enable"]) - finally: - release.set() - finished.wait(1) - - assert result.exit_code == 1 - assert result.stdout == "" - assert result.stderr == "operation already running\n" - - -def test_confidential_refusals_surface_route_detail() -> None: - inactive = runner.invoke(thinking_call.app, ["confidential", "recheck"]) - - assert inactive.exit_code == 1 - assert inactive.stdout == "" - assert inactive.stderr == "confidential processing is not active.\n" - - spp.provision_confidential_handoff(_confidential_payload("refusal")) - already_enabled = runner.invoke(thinking_call.app, ["confidential", "enable"]) - - assert already_enabled.exit_code == 1 - assert already_enabled.stdout == "" - assert already_enabled.stderr == "confidential processing is already set up.\n" - - -def test_confidential_disable_matches_http_result_without_secret() -> None: - spp.provision_confidential_handoff(_confidential_payload("disable")) - - result = runner.invoke(thinking_call.app, ["confidential", "disable"]) - - assert result.exit_code == 0 - assert result.stderr == "" - payload = json.loads(result.stdout) - assert set(payload) == {"result"} - assert set(payload["result"]) == {"was_enabled", "credential_preserved"} - assert payload["result"]["was_enabled"] is True - assert payload["result"]["credential_preserved"] is False - assert "credential-disable" not in result.stdout - assert "acct-disable" not in result.stdout - assert "spp-disable.example.test" not in result.stdout - assert "confidential-model-disable" not in result.stdout - - -def test_confidential_cli_phase_mirror_matches_routes() -> None: - assert ( - thinking_call._CONFIDENTIAL_PHASE_TO_PRODUCT - == thinking_routes._CONFIDENTIAL_PHASE_TO_PRODUCT - ) - assert thinking_call._CONFIDENTIAL_RAW_TERMINAL_PHASES == operations.TERMINAL_PHASES - assert thinking_call._CONFIDENTIAL_TERMINAL_PHASES == { - "not_verified", - "needs_subscription", - "revoked", - "repair_needed", - "early_access", - } - - -def test_keys_set_clear_validate_and_invalid_env( - journal_copy: Path, - fake_validators: None, -) -> None: - invalid = runner.invoke(thinking_call.app, ["keys", "set", "BOGUS", "value"]) - assert invalid.exit_code == 1 - assert invalid.stderr == ( - "Invalid env var: BOGUS. Must be one of: " - "GOOGLE_API_KEY, ANTHROPIC_API_KEY, OPENAI_API_KEY\n" - ) - - provider_set = runner.invoke( - thinking_call.app, - ["keys", "set", "ANTHROPIC_API_KEY", "anthropic-test-key"], - ) - assert provider_set.exit_code == 0 - assert json.loads(provider_set.stdout) == { - "env_var": "ANTHROPIC_API_KEY", - "set": True, - "validation": { - "valid": True, - "provider": "anthropic", - "fingerprint": "-key", - "timestamp": "2026-04-17T12:00:00+00:00", - }, - } - assert ( - _read_config(journal_copy)["env"]["ANTHROPIC_API_KEY"] == "anthropic-test-key" - ) - - keys_shown = runner.invoke(thinking_call.app, ["keys", "show"]) - assert keys_shown.exit_code == 0 - assert "anthropic-test-key" not in keys_shown.stdout - - cleared = runner.invoke(thinking_call.app, ["keys", "clear", "ANTHROPIC_API_KEY"]) - _assert_json(cleared, {"env_var": "ANTHROPIC_API_KEY", "cleared": True}) - assert "ANTHROPIC_API_KEY" not in _read_config(journal_copy)["env"] - - before = (journal_copy / "config" / "journal.json").read_text(encoding="utf-8") - validate = runner.invoke(thinking_call.app, ["keys", "validate"]) - assert validate.exit_code == 0 - assert (journal_copy / "config" / "journal.json").read_text( - encoding="utf-8" - ) == before - - cached = runner.invoke(thinking_call.app, ["keys", "validate", "--cache-result"]) - assert cached.exit_code == 0 - - -def test_providers_show_human_and_set_errors( - monkeypatch: pytest.MonkeyPatch, -) -> None: - provider_status = { - "anthropic": {"issues": ["ANTHROPIC_API_KEY not set"]}, - "google": {"generate_ready": True, "cogitate_ready": True, "issues": []}, - "local": { - "generate_ready": False, - "cogitate_ready": False, - "issues": ["binary_missing"], - }, - "openai": {"generate_ready": True, "cogitate_ready": True, "issues": []}, - } - monkeypatch.setattr( - thinking_routes, - "build_provider_status", - lambda providers, **_kwargs: provider_status, - ) - - human = runner.invoke(thinking_call.app, ["providers", "show", "--human"]) - assert human.exit_code == 0 - assert human.stdout == ( - "active lane: byo\n" - "anthropic: ANTHROPIC_API_KEY not set\n" - "google: ready\n" - "local: binary_missing\n" - "openai: ready\n" - ) - - success = runner.invoke( - thinking_call.app, - ["providers", "set-active", "--provider", "openai"], - ) - assert success.exit_code == 0 - assert json.loads(success.stdout)["provider"] == "openai" - - bad_provider = runner.invoke( - thinking_call.app, - ["providers", "set-active", "--provider", "invalid"], - ) - assert bad_provider.exit_code == 1 - assert bad_provider.stderr == ( - "Invalid provider: invalid. Must be one of: anthropic, google, openai, local\n" - ) - - bad_tier = runner.invoke( - thinking_call.app, - ["providers", "set-active", "--tier", "9"], - ) - assert bad_tier.exit_code != 0 - assert "No such option: --tier" in bad_tier.stderr - - -def test_local_endpoint_verbs_use_http_shapes( - monkeypatch: pytest.MonkeyPatch, -) -> None: - calls: list[dict[str, Any]] = [] - - def fake_request( - method: str, - path: str, - *, - params: dict[str, object] | None = None, - json_body: dict[str, object] | None = None, - ) -> dict[str, Any]: - calls.append( - { - "method": method, - "path": path, - "params": params, - "json_body": json_body, - } - ) - return { - "local_endpoint": { - "enabled": method != "DELETE", - "endpoint_url": "http://host.test", - "served_model_id": "served-model", - "credential_configured": bool((json_body or {}).get("credential")), - } - } - - monkeypatch.setattr(thinking_call, "_request", fake_request) - - no_credential = runner.invoke( - thinking_call.app, - [ - "set-local-endpoint", - "--url", - "http://host.test", - "--model", - "served-model", - ], - ) - - assert no_credential.exit_code == 0 - assert calls[-1] == { - "method": "POST", - "path": "/app/thinking/api/local/endpoint", - "params": None, - "json_body": { - "endpoint_url": "http://host.test", - "served_model_id": "served-model", - }, - } - - with_credential = runner.invoke( - thinking_call.app, - [ - "set-local-endpoint", - "--url", - "http://host.test", - "--model", - "served-model", - "--credential", - "test-token-PLACEHOLDER", - ], - ) - - assert with_credential.exit_code == 0 - assert calls[-1]["json_body"] == { - "endpoint_url": "http://host.test", - "served_model_id": "served-model", - "credential": "test-token-PLACEHOLDER", - } - - cleared = runner.invoke(thinking_call.app, ["clear-local-endpoint"]) - - assert cleared.exit_code == 0 - assert calls[-1] == { - "method": "DELETE", - "path": "/app/thinking/api/local/endpoint", - "params": None, - "json_body": None, - } - - -def test_local_verbs_hit_expected_endpoints(monkeypatch: pytest.MonkeyPatch) -> None: - calls: list[dict[str, Any]] = [] - - def fake_request( - method: str, - path: str, - *, - params: dict[str, object] | None = None, - json_body: dict[str, object] | None = None, - ) -> dict[str, Any]: - calls.append( - { - "method": method, - "path": path, - "params": params, - "json_body": json_body, - } - ) - return {"ok": True} - - monkeypatch.setattr(thinking_call, "_request", fake_request) - - assert runner.invoke(thinking_call.app, ["local", "readiness"]).exit_code == 0 - assert runner.invoke(thinking_call.app, ["local", "status"]).exit_code == 0 - assert ( - runner.invoke( - thinking_call.app, ["local", "availability", "--model", "m"] - ).exit_code - == 0 - ) - assert ( - runner.invoke( - thinking_call.app, ["local", "bootstrap", "--model", "m"] - ).exit_code - == 0 - ) - assert ( - runner.invoke( - thinking_call.app, ["local", "bootstrap-status", "--model", "m"] - ).exit_code - == 0 - ) - assert runner.invoke(thinking_call.app, ["local", "models"]).exit_code == 0 - - assert calls == [ - { - "method": "GET", - "path": "/app/thinking/api/providers/local/status", - "params": None, - "json_body": None, - }, - { - "method": "GET", - "path": "/app/thinking/api/providers/local/status", - "params": None, - "json_body": None, - }, - { - "method": "GET", - "path": "/app/thinking/api/local/availability", - "params": {"model": "m"}, - "json_body": None, - }, - { - "method": "POST", - "path": "/app/thinking/api/local/bootstrap", - "params": {"model": "m"}, - "json_body": None, - }, - { - "method": "GET", - "path": "/app/thinking/api/local/bootstrap/status", - "params": {"model": "m"}, - "json_body": None, - }, - { - "method": "GET", - "path": "/app/thinking/api/local/models", - "params": None, - "json_body": None, - }, - ]