diff --git a/src/void_tools/cli.py b/src/void_tools/cli.py index 569d470..ce1fbae 100644 --- a/src/void_tools/cli.py +++ b/src/void_tools/cli.py @@ -11,6 +11,80 @@ import requests from atproto import models from void_tools.client import get_client +def validate_outbox(outbox, mapping, strict=False): + """ + Pre-flight validation of outbox schema. + Returns (is_valid, errors, warnings). + If strict=True, warnings are treated as fatal errors. + """ + errors = [] + warnings = [] + + actions = outbox.get("actions", []) + if not actions: + errors.append("Empty actions list. Nothing to dispatch.") + return False, errors, warnings + + for i, act in enumerate(actions): + action_type = act.get("action") + target_id = act.get("target_id") + + # Critical: action field must exist + if action_type is None: + errors.append(f"Action {i}: Missing required 'action' field (must be post, reply, annotate, or ignore)") + continue + + # Unknown action type + valid_actions = {"post", "reply", "annotate", "ignore"} + if action_type not in valid_actions: + errors.append(f"Action {i}: Unknown action type '{action_type}'. Must be one of: {valid_actions}") + continue + + # Posts validation (for post and reply) + if action_type in ("post", "reply"): + posts = act.get("posts", []) + if not posts: + errors.append(f"Action {i} ({action_type}): Empty posts list") + + for j, post_text in enumerate(posts): + # Grapheme count check (rough approximation: len() for ASCII, but Bluesky uses 300 graphemes) + # This is a basic check - actual Bluesky validation happens at API level + if len(post_text) > 350: # Conservative buffer + errors.append(f"Action {i}, post {j}: Text exceeds ~350 characters (Bluesky limit is 300 graphemes)") + + # Mandatory annotation check + annotation = act.get("annotation") + if not annotation: + errors.append(f"Action {i} ({action_type}): Missing mandatory 'annotation' block") + elif not isinstance(annotation, dict): + errors.append(f"Action {i} ({action_type}): 'annotation' must be a dict with 'text' and optional 'motivation'") + elif not annotation.get("text"): + errors.append(f"Action {i} ({action_type}): Annotation missing 'text' field") + + # Reply-specific validation + if action_type == "reply": + if not target_id: + errors.append(f"Action {i} (reply): Missing 'target_id' field") + elif not target_id.startswith("at://") and target_id not in mapping: + errors.append(f"Action {i} (reply): target_id '{target_id}' not found in inbox mapping") + + # Annotate-specific validation + if action_type == "annotate": + text = act.get("text") + if not text: + errors.append(f"Action {i} (annotate): Missing 'text' field") + if not target_id: + errors.append(f"Action {i} (annotate): Missing 'target_id' field") + + # Ignore-specific validation + if action_type == "ignore": + reason = act.get("reason", "unspecified") + if reason == "unspecified": + warnings.append(f"Action {i} (ignore): Missing 'reason' field (using default 'unspecified')") + + is_valid = len(errors) == 0 and (not strict or len(warnings) == 0) + return is_valid, errors, warnings + def mirror_to_letta(text, target_uri): """Mirrors an annotation to Letta archival memory.""" agent_id = os.getenv("AGENT_ID") @@ -126,14 +200,21 @@ def cmd_sync(args): existing_inbox = [] if os.path.exists("inbox.yaml"): with open("inbox.yaml", "r") as f: - existing_inbox = yaml.safe_load(f) or [] + loaded = yaml.safe_load(f) or [] + # Handle case where inbox.yaml is a dict with 'items' key vs a list + if isinstance(loaded, dict): + existing_inbox = loaded.get("items", []) + elif isinstance(loaded, list): + existing_inbox = loaded + else: + existing_inbox = [] existing_mapping = {} if os.path.exists(".inbox_mapping.json"): with open(".inbox_mapping.json", "r") as f: existing_mapping = json.load(f) - inbox = existing_inbox.copy() + inbox = list(existing_inbox) mapping = existing_mapping.copy() # Determine highest existing local_id index to prevent collisions @@ -362,9 +443,33 @@ def cmd_dispatch(args): print("Constitutional Linter passed.") else: print("Warning: constitutional_linter.py not found. Skipping linting.") + + # Pre-flight validation: check all actions before any dispatch + print("Running pre-flight validation...") + actions = outbox.get("actions", []) + is_valid, errors, warnings = validate_outbox(outbox, mapping, strict=not getattr(args, 'lenient', False)) + + if warnings: + for w in warnings: + print(f" Warning: {w}", file=sys.stderr) + + if errors: + for e in errors: + print(f" Error: {e}", file=sys.stderr) + print(f"\nValidation failed: {len(errors)} error(s) found. Dispatch aborted.", file=sys.stderr) + sys.exit(1) + + if not is_valid: # Strict mode with warnings + print(f"\nValidation failed: {len(warnings)} warning(s) treated as fatal (strict mode). Dispatch aborted.", file=sys.stderr) + sys.exit(1) + + print("Pre-flight validation passed.") + + if getattr(args, 'dry_run', False): + print("Dry-run mode: Validation complete. No posts dispatched.") + sys.exit(0) client = get_client() - actions = outbox.get("actions", []) # Phase 1: Output validation - prevent empty outbox dispatch if not actions: @@ -552,7 +657,9 @@ def cmd_dispatch(args): print(f"Error annotating {target_uri}: {e}") else: - print(f"Unknown action type: {action_type}") + # This should never happen due to pre-flight validation + print(f"FATAL: Unknown action type '{action_type}' reached dispatch loop. Validation bypassed.", file=sys.stderr) + sys.exit(1) config = outbox.get("config", {}) if config.get("mark_seen"): @@ -1040,6 +1147,8 @@ def main(): dispatch_parser = subparsers.add_parser("dispatch", help="Dispatch posts from an outbox YAML file") dispatch_parser.add_argument("file", nargs="?", default="outbox.yaml", help="Path to outbox YAML file") + dispatch_parser.add_argument("--lenient", action="store_true", help="Allow warnings to pass (default: strict mode, warnings are fatal)") + dispatch_parser.add_argument("--dry-run", action="store_true", help="Validate only, do not actually dispatch") # Annotate annotate_parser = subparsers.add_parser("annotate", help="Directly annotate a URI")