Source code for fractal.cli.utils

"""Shared helpers for ``fractal`` CLI commands."""

from __future__ import annotations

import functools
import json
import os
import pathlib
import sys
from collections.abc import Callable
from csv import DictWriter
from typing import Any, Optional

import typer

import fractal.core.worktree
import fractal.util
from fractal.constants import (
    CONFIG_FILE,
    FRACTAL_FOLDER,
    LOCK_FILE,
    PROJECT_FOLDER,
    WORKTREES_FOLDER,
)
from fractal.core.agent import StreamEvent
from fractal.core.config import DEFAULT_RESERVE_FRACTION, RESERVE_PRECISION
from fractal.core.node import Node
from fractal.typing import PathLike

__all__ = [
    'SQLITE_INT_MAX',
    'command',
    'require_non_negative',
    'parse_reserve_budget',
    'StreamRenderer',
    'print_rows',
    'print_json',
    'init_node',
    'resolve_init_target',
    'resolve_node',
    'resolve_user_node',
    'resolve_target',
    'resolve_ledger_target',
]

# signed 64-bit ceiling for SQLite INTEGER columns; an integer cap at or above
# this raises a raw "int too large to convert" from the adapter downstream
SQLITE_INT_MAX = 2**63

# tool-result preview budget: two to three 80-column terminal lines, so one
# verbose tool call cannot flood the streamed transcript
_TOOL_RESULT_MAX = 200
_DIM = '\033[2m'
_RESET = '\033[0m'
_BLUE = '\033[34m'
_YELLOW = '\033[33m'


[docs] def command( app: typer.Typer, name: str, **kwargs: Any, ) -> Callable: """Register a CLI command on ``app`` with error wrapping.""" def decorator(f: Callable, /) -> Callable: if private := name.startswith('_'): kwargs.setdefault('hidden', True) @functools.wraps(f) def wrapper(*args: Any, **kwargs: Any) -> Any: try: return f(*args, **kwargs) except (typer.Exit, typer.Abort, typer.BadParameter): raise except BrokenPipeError: # a downstream reader closed the pipe (not an error): # point stdout at devnull so the interpreter's exit # flush stays quiet, and end the pipeline successfully devnull = os.open(os.devnull, os.O_WRONLY) os.dup2(devnull, sys.stdout.fileno()) raise SystemExit(0) from None except Exception as e: error = type(e).__name__ if private else 'Error' typer.echo(f'{error}: {e}', err=True) raise SystemExit(1) from None return app.command(name, **kwargs)(wrapper) return decorator
[docs] def require_non_negative(**limits: Optional[float]) -> None: """Reject negative numeric CLI options as ``BadParameter``. Each keyword maps a limit option's name to its value (``None`` is skipped); the first negative raises with that option's flag spelling. Centralizes the ``>= 0`` guard so every numeric ``--max-*`` cap is validated uniformly at the CLI boundary -- unbounded is expressed by omitting the flag, never by a negative sentinel. Args: **limits: Option name mapped to its value (e.g. ``max_depth=max_depth``). Raises: typer.BadParameter: If any value is negative. """ for name, value in limits.items(): if value is None: continue flag = name.replace('_', '-') if value < 0: raise typer.BadParameter(f'--{flag} must be >= 0.') # an integer cap is written to a SQLite INTEGER column; one that overflows # a signed 64-bit int raises a raw adapter error (and can desync config # from the DB on update), so reject it here -- float costs (REAL) are exempt if isinstance(value, int) and not isinstance(value, bool): if value >= SQLITE_INT_MAX: raise typer.BadParameter(f'--{flag} must be < {SQLITE_INT_MAX}.')
[docs] def parse_reserve_budget( value: Optional[str], max_cost: Optional[float], *, default: str = f'{DEFAULT_RESERVE_FRACTION:.0%}', ) -> Optional[float]: """Resolve ``--reserve-budget`` to a USD amount. The value is a USD number or ``N%`` of ``max_cost``; when omitted it falls back to ``default`` (``10%`` of ``max_cost``), so a budget reserves a cleanup buffer by default, and with no ``max_cost`` there is no reserve. The reserve is not enforced -- it only moves when the node enters reserve mode (the budget is treated as drained ``reserve_budget`` USD before ``max_cost`` is reached). Args: value: The raw ``--reserve-budget`` string (USD or ``N%``), or ``None`` to take ``default``. max_cost: The node's ``--max-cost`` in USD; required when ``value`` is an explicit reserve. default: Reserve applied when ``value`` is ``None`` (USD or ``N%``). Returns: The reserve in USD -- ``default`` applied to ``max_cost`` when ``value`` is ``None``, or ``None`` when neither ``value`` nor ``max_cost`` is set. Raises: typer.BadParameter: If an explicit value is given without ``max_cost``, is not a number, is negative, or is >= 99% of ``max_cost``. """ if value is None: if max_cost is None: return None value = default if max_cost is None: raise typer.BadParameter('--reserve-budget requires --max-cost.') if max_cost <= 0: raise typer.BadParameter('--max-cost must be greater than 0.') value = value.strip() try: if value.endswith('%'): reserve = float(value[:-1]) / 100 * max_cost else: reserve = float(value) except ValueError: raise typer.BadParameter('--reserve-budget must be a number or N%.') from None if reserve < 0: raise typer.BadParameter('--reserve-budget must be >= 0.') if reserve >= 0.99 * max_cost: raise typer.BadParameter('--reserve-budget must be < 99% of --max-cost.') # money materializes at display precision -- a bare percent product # (10% of $6) would otherwise persist binary noise into config.json # and every echo that quotes it return round(reserve, RESERVE_PRECISION)
[docs] class StreamRenderer: """ANSI terminal renderer for parsed agent stream events. The single CLI renderer over the seam's normalized events -- one instance per stream, passed as the ``render`` callback to ``Agent.stream`` (the loop's pane rendering) and ``Node.chat`` so core never prints. Branches on ``kind`` only, never on the provider; session and cost facts render nothing (they are recorded, not displayed). Tracks the open text run (a delta without a trailing newline) so tool headers and closing summaries start below it, and ``close()`` ends a truncated stream on a fresh line with the ``-- $?`` placeholder summary. Every write flushes: piped stdout is block-buffered while the driving command's stderr echoes are write-through, so an unflushed line would land out of order in a merged capture. """ def __init__(self: StreamRenderer) -> None: """Initialize ``StreamRenderer``.""" self._streaming = False self._open = False self._resulted = False def __call__(self: StreamRenderer, event: StreamEvent) -> None: """Render one parsed agent stream event.""" self._open = True # text deltas stream inline (block opens ride as newline deltas); a # delta without a trailing newline leaves the text run open if event.kind == 'text': print(event.text, end='', flush=True) self._streaming = not event.text.endswith('\n') # tool-use headers, below any open text run elif event.kind == 'tool': self._break() print(f'\n{_DIM}{_BLUE}> {event.tool}{_RESET}', end='', flush=True) # tool results, truncated to a preview elif event.kind == 'tool_result': lines = event.message.split('\n') preview = '\n'.join(lines[:8]) if len(preview) > _TOOL_RESULT_MAX: preview = preview[:_TOOL_RESULT_MAX] + '...' # a char-truncated preview that also has more than # 8 lines must still show the "more lines" signal if len(lines) > 8: preview += '\n...' color = _YELLOW if event.failed else _DIM label = ' (error)' if event.failed else '' # the preview's own leading newline closes any open text run self._streaming = False print(f'\n{color}{preview}{label}{_RESET}', end='', flush=True) # the closing summary -- the facts vary per provider (a codex result # closes on wall time and carries no authoritative cost), so a final # result summarizes turns/duration/cost while a per-turn close prints # the recorded turn cost alone -- an absent cost reads '$?', never $0 elif event.kind == 'result': self._break() cost_str = f'${event.cost:.4f}' if event.cost is not None else '$?' if event.final: parts = [] if event.turns is not None: parts.append(f'{event.turns} turns') if event.duration is not None: parts.append(f'{event.duration:.1f}s') parts.append(cost_str) summary = ', '.join(parts) else: summary = cost_str print(f'\n{_DIM}-- {summary}{_RESET}', flush=True) self._resulted = True # stream-borne errors (some agents report failures on the JSON stream, # not stderr, so without this a failed turn leaves no explanation) elif event.kind == 'error': print( f'{_YELLOW}agent error: {event.message}{_RESET}', file=sys.stderr, flush=True, )
[docs] def close(self: StreamRenderer) -> None: """End the stream on a fresh line, settling the closing summary. Called by the driving command after the stream drains -- a truncated stream (killed before its result frame) otherwise leaves the cursor mid-line and shows no closing summary, so the ``-- $?`` placeholder marks the unaccounted turn. Idempotent, and resets for the next stream (the loop reuses one renderer across steps). """ if not self._open: return self._break() if not self._resulted: print(f'\n{_DIM}-- $?{_RESET}', flush=True) self._open = False self._resulted = False
def _break(self: StreamRenderer) -> None: """Close an open text run on a fresh line (a visual break).""" if self._streaming: print(flush=True) self._streaming = False
[docs] def init_node(path: PathLike) -> Node: """Resolve a ``Node`` for init (accepts repo root as-is). When an agent runs init from inside its own worktree (``_NODE`` set) with the default path, resolve to the caller's repo root so the new node nests under the main repo rather than the worktree (the parent is resolved from ``_NODE`` in ``Node.init``). """ # an agent in its own worktree with the default path uses its repo root, but # only if the caller lives in the cwd's repo (a stale _NODE = wrong repo) caller = Node.resolve_caller() if caller is not None and str(path) == '.': try: cwd = pathlib.Path.cwd().resolve() cwd_root = Node(cwd).repo_dir if cwd_root == caller.repo_dir: return Node(caller.repo_dir) except RuntimeError: pass # otherwise resolve the given path as-is return Node(_absolute(path))
[docs] def resolve_init_target(path: PathLike) -> tuple[Node, pathlib.Path]: """Resolve the git-root-anchored node and project path for an init target. ``fractal init``/``node init`` accept a repo root or a sub-project folder as ``path``. The user node and every child must be anchored at the git root -- ``Node.node_dir`` derives the ``<project>/`` prefix from the ``.worktrees/.project`` cache, so anchoring at a sub-project folder would double the prefix. This resolves the target, then returns a ``Node`` at its git root plus the target's project-relative path. Args: path: Repo root or sub-project folder (absolute or relative). Returns: ``(node, path)`` -- a ``Node`` at the git root and the target's path relative to it (``.`` for the repo root). Raises: typer.BadParameter: If the target is a linked git worktree (outside the main repo root, so never a sub-project path). """ target = init_node(path) node = init_node(target.repo_dir) # a linked worktree (git worktree add ../feature) lives outside the main # repo root, so it can never be a sub-project path -- refuse with the # remedy instead of leaking relative_to's raw ValueError if not target.worktree.is_relative_to(target.repo_dir): raise typer.BadParameter( f'{target.worktree} is a linked worktree of {target.repo_dir}.' f' Run `fractal init` from the main checkout.' ) path = target.worktree.relative_to(target.repo_dir) return node, path
[docs] def resolve_node(path: PathLike, *, check: bool = True) -> Node: """Resolve a ``Node`` instance from a path argument. Args: path: Worktree directory (absolute or relative). check: Require an initialized node at the resolved path (mirrors ``resolve_target``), so a user-facing command run outside a node fails cleanly instead of leaking a raw internal error. The pre-init private setter (``config _set``, which writes the very ``config.json`` this checks) passes ``check=False``. Returns: Node bound to the resolved path. Raises: typer.BadParameter: If ``path`` is a repo root with active worktrees (should use the worktree path), or (when ``check``) the resolved path is not an initialized node. """ # resolve absolute path path = _absolute(path) # canonicalize to git worktree root (handles subdirectories) toplevel = fractal.util.git.toplevel(path, check=False) if toplevel is not None: path = toplevel.resolve() # detect repo root passed instead of worktree # (.git is a directory at the repo root, a file in worktrees) if (path / '.git').is_dir(): # check for a user node on the current branch -- it nests under the project # prefix from the .worktrees/.project cache (sub-project nodes nest deeper) if branch := fractal.util.git.branch(path, check=False): project = fractal.core.worktree.project_path(path, branch) if project == '.': node_dir = path / FRACTAL_FOLDER / branch else: node_dir = path / project / FRACTAL_FOLDER / branch if (node_dir / CONFIG_FILE).exists(): return Node(path) if (path / WORKTREES_FOLDER).is_dir(): # inside a running node -- resolve to caller's worktree if result := Node.resolve_caller(): return result # collect worktree candidates paths = [] for entry in (path / WORKTREES_FOLDER).iterdir(): if entry.is_dir() and entry.name not in (PROJECT_FOLDER, LOCK_FILE): paths.append(entry) # one worktree -- resolve to it if len(paths) == 1: (path,) = paths typer.echo(f'Resolved to .worktrees/{path.name}/', err=True) # multiple -- error with list elif len(paths) > 1: paths = ', '.join( f'.worktrees/{entry.name}/' for entry in sorted(paths) ) raise typer.BadParameter( f'Multiple nodes found: {paths}.' f' Name one with --node, or run from its worktree.' ) # otherwise construct node at worktree path node = Node(path) # require an initialized node unless a pre-init caller opted out, so a # user-facing command run outside a node fails cleanly (mirrors resolve_target) if check and not node.exists(): raise typer.BadParameter( f'No fractal node at {node.worktree}. Run `fractal init` first.' ) return node
[docs] def resolve_user_node(path: PathLike) -> Node: """Resolve the tree's user (root) node from any checkout, by config not branch. The tree-wide brake (``pause``/``resume``) must always anchor on the user node, but :func:`resolve_node` keys on the repo's *current* branch -- so on a non-init checkout (the user on their own branch while nodes run) it silently mis-scopes to a lone child or dies on two. :meth:`Node.resolve_user` finds the ``config.json`` marked ``user: true`` and pins a ``Node`` to that branch, independent of the git checkout; this wrapper turns its no-fractal ``None`` into the CLI refusal. Args: path: Any path inside the repo. Returns: The user (root) node, branch-pinned. Raises: typer.BadParameter: If the repo has no user node (no fractal). """ user = Node.resolve_user(path) if user is None: repo = Node(path).repo_dir raise typer.BadParameter( f'No user node found under {repo}. Run `fractal init` at the repo root.' ) return user
[docs] def resolve_target(path: PathLike, node: Optional[str] = None) -> Node: """Resolve the node to act on, anchored at the caller's worktree. ``path`` identifies the caller's own worktree (default cwd); ``node``, when given, selects another node by branch name within the same repo. Omitting ``node`` targets the caller's own node. Args: path: Caller's worktree directory (absolute or relative). node: Target node's branch name, or ``None`` for the caller. Returns: Node bound to the resolved target worktree. Raises: typer.BadParameter: If ``node`` names a branch with no worktree, or the resolved target is not an initialized node. """ # resolve target node if node: # resolve absolute anchor path path = _absolute(path) # locate the named node's worktree within the same repo repo_dir = Node(path).repo_dir worktree_dir = fractal.util.git.find_worktree(repo_dir, node) if worktree_dir is None: # accept a unique short name (trailing branch segment) by # resolving it against the caller's registered nodes node = _resolve_node_name(path, node) worktree_dir = fractal.util.git.find_worktree(repo_dir, node) if worktree_dir is None: raise typer.BadParameter(f'No node found for branch: {node!r}.') target = Node(worktree_dir) else: target = resolve_node(path) # require an initialized node at the resolved target if target.exists(): return target raise typer.BadParameter(f'No fractal node at {target.worktree}.')
[docs] def resolve_ledger_target( path: PathLike, node: Optional[str], ) -> tuple[Node, Optional[str], Optional[int]]: """Resolve a cost-ledger target, answering for deleted branches. A live target resolves normally as ``(node, None, None)``. A deleted branch fails resolution while its recorded history persists in the shared db, so it resolves through the caller as ``(caller, branch, latest_run)`` -- how the fallback applies stays per command (``remaining`` reports ``no budget`` without delegating; ``spent``/``breakdown`` substitute ``latest_run`` for an absent scope). The fallback accepts the same unique short names live resolution does: the registry rows that expansion consults die with the node, so the trailing segment expands against the recorded runs instead. Args: path: Caller's worktree directory (absolute or relative). node: Target node's branch name, or ``None`` for the caller. Returns: ``(node, deleted, latest_run)`` -- the resolved target, the deleted branch name (``None`` for a live target), and the deleted branch's latest recorded run id. Raises: typer.BadParameter: If resolution fails and the branch never recorded a run (there is no history to answer from), or a short name matches several live or recorded branches. """ # a deleted branch fails resolution: answer through the caller (shared db) # when it has a recorded run, keep the not-found error when it never ran try: return resolve_target(path, node), None, None except typer.BadParameter: if node is None: raise caller = resolve_node(path) # a live-ambiguous short name re-raises the refusal: the fallback # answers for deleted branches, never over live twins the registry # itself refused to pick between live = [] for row in caller.list(all_nodes=True): *_, leaf = row['node'].rsplit('.', 1) if leaf == node: live.append(row['node']) if len(live) > 1: raise latest = caller.record.run_latest(branch=node) if latest is None: # expand a short name against the recorded runs -- a unique # trailing-segment match answers, ambiguity refuses (mirroring # _resolve_node_name, whose registry rows died with the node) matches = [] for branch in caller.record.run_branches(): *_, leaf = branch.rsplit('.', 1) if leaf == node: matches.append(branch) if len(matches) > 1: options = ', '.join(sorted(matches)) raise typer.BadParameter( f'Ambiguous node name {node!r} (matches: {options}).' f' Use the full branch.' ) from None if len(matches) == 1: (node,) = matches latest = caller.record.run_latest(branch=node) if latest is None: raise return caller, node, latest
# ------ helper functions def _absolute(path: PathLike) -> pathlib.Path: """Resolve a path argument to an absolute path (anchored at the cwd). Args: path: Path to absolutize (absolute or relative). Returns: The resolved absolute path. """ path = pathlib.Path(path) if not path.is_absolute(): path = pathlib.Path.cwd() / path return path.resolve() def _resolve_node_name(path: PathLike, name: str) -> str: """Resolve a short node name to a full branch name. When ``name`` is not already a full branch with a worktree, match it against the trailing segment of the caller's registered node branches (e.g. ``c1`` -> ``main.task.c1``). Returns the unique full branch, or ``name`` unchanged when nothing matches (so the caller still raises a clear "not found" error). Args: path: Caller's worktree directory. name: Short node name to resolve. Returns: The resolved full branch name, or ``name`` if no match. Raises: typer.BadParameter: If the short name matches more than one node. """ caller = resolve_node(path) rows = caller.list(all_nodes=True) matches = [] for row in rows: *_, leaf = row['node'].rsplit('.', 1) if leaf == name: matches.append(row['node']) if len(matches) == 1: return matches[0] if len(matches) > 1: options = ', '.join(sorted(matches)) raise typer.BadParameter( f'Ambiguous node name {name!r} (matches: {options}). Use the full branch.' ) return name