| 1 | #!/usr/bin/env python3 |
| 2 | """ |
| 3 | PPT Master - Template and Strategist confirmation UI Server (Steps 3-4) |
| 4 | |
| 5 | Lightweight Flask backend for the Default template choice and interactive |
| 6 | Strategist confirmation page. Stage 1 combines template selection with the |
| 7 | communication contract; its submit writes ``template_selection.json`` and the |
| 8 | stage1-confirmed ``result.json`` in one request. After the agent applies the |
| 9 | choice and completes ``template_handoff.json``, final Stage 2 confirms the deck |
| 10 | solution and production plan. |
| 11 | |
| 12 | This is the default confirmation surface. The chat fallback is used only when |
| 13 | the user explicitly requests chat-only confirmation or the browser launch |
| 14 | fails; it preserves the same staged semantics. |
| 15 | |
| 16 | See scripts/docs/confirm_ui.md for the round-trip data contract and schema. |
| 17 | |
| 18 | Usage: |
| 19 | python3 scripts/confirm_ui/server.py <project_dir> |
| 20 | |
| 21 | Examples: |
| 22 | python3 scripts/confirm_ui/server.py projects/my-project |
| 23 | python3 scripts/confirm_ui/server.py projects/my-project --port 5051 |
| 24 | python3 scripts/confirm_ui/server.py projects/my-project --no-browser |
| 25 | python3 scripts/confirm_ui/server.py projects/my-project --daemon |
| 26 | python3 scripts/confirm_ui/server.py projects/my-project --wait-only --wait-stage stage1 |
| 27 | python3 scripts/confirm_ui/server.py projects/my-project --complete-template-selection |
| 28 | python3 scripts/confirm_ui/server.py projects/my-project --reset-template-selection |
| 29 | |
| 30 | Dependencies: |
| 31 | flask>=3.0.0 |
| 32 | """ |
| 33 | |
| 34 | import argparse |
| 35 | import atexit |
| 36 | import hashlib |
| 37 | import json |
| 38 | import logging |
| 39 | import os |
| 40 | import re |
| 41 | import signal |
| 42 | import subprocess |
| 43 | import sys |
| 44 | import threading |
| 45 | import time |
| 46 | import urllib.error |
| 47 | import urllib.request |
| 48 | import webbrowser |
| 49 | from pathlib import Path |
| 50 | from typing import Optional |
| 51 | |
| 52 | from flask import Flask, jsonify, request, send_from_directory |
| 53 | |
| 54 | # Local — sys.path injection for sibling module (code-style.md §3) |
| 55 | _SCRIPTS_DIR = Path(__file__).resolve().parent.parent |
| 56 | if str(_SCRIPTS_DIR) not in sys.path: |
| 57 | sys.path.insert(0, str(_SCRIPTS_DIR)) |
| 58 | |
| 59 | from console_encoding import configure_utf8_stdio # noqa: E402 |
| 60 | from language_tags import ( # noqa: E402 |
| 61 | LanguageTagError, |
| 62 | language_base, |
| 63 | normalize_language_tag, |
| 64 | ) |
| 65 | from server_common import ( # noqa: E402 |
| 66 | claim_lock as _claim_lock, |
| 67 | clear_lock as _clear_lock, |
| 68 | find_free_port as _find_free_port, |
| 69 | lock_pid as _lock_pid, |
| 70 | popen_detached as _popen_detached, |
| 71 | process_alive as _process_alive, |
| 72 | read_lock as _read_lock, |
| 73 | release_lock as _release_lock, |
| 74 | validate_port as _validate_port, |
| 75 | ) |
| 76 | |
| 77 | configure_utf8_stdio() |
| 78 | |
| 79 | logger = logging.getLogger('confirm_ui') |
| 80 | |
| 81 | # Per-project lock file. Lives at <project_path>/.confirm_ui.lock and matches |
| 82 | # the *.lock entry already in the repo .gitignore. Independent of the live |
| 83 | # preview lock so the two surfaces never collide. |
| 84 | LOCK_FILE_NAME = '.confirm_ui.lock' |
| 85 | |
| 86 | # Round-trip/session files, all under <project_path>/confirm_ui/. |
| 87 | CONFIRM_DIR_NAME = 'confirm_ui' |
| 88 | RECOMMENDATION_STAGE_NAMES = { |
| 89 | 1: 'recommendations.stage1.json', |
| 90 | 2: 'recommendations.stage2.json', |
| 91 | } |
| 92 | RESULT_NAME = 'result.json' |
| 93 | SESSION_NAME = 'session.json' |
| 94 | TEMPLATE_OPTIONS_NAME = 'template_options.json' |
| 95 | TEMPLATE_SELECTION_NAME = 'template_selection.json' |
| 96 | TEMPLATE_HANDOFF_NAME = 'template_handoff.json' |
| 97 | TEMPLATE_SCHEMA_VERSION = 1 |
| 98 | |
| 99 | _PALETTE_ROLES = ( |
| 100 | 'background', |
| 101 | 'secondary_bg', |
| 102 | 'primary', |
| 103 | 'accent', |
| 104 | 'secondary_accent', |
| 105 | 'body_text', |
| 106 | ) |
| 107 | _TYPOGRAPHY_SIZE_ROLES = ('title', 'subtitle', 'annotation') |
| 108 | _HEX_COLOR_RE = re.compile(r'#?(?:[0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})\Z') |
| 109 | |
| 110 | # Static option universe served at /api/catalogs (canvas synced live from config). |
| 111 | _SKILL_DIR = Path(__file__).resolve().parents[2] |
| 112 | _TEMPLATES_DIR = _SKILL_DIR / 'templates' |
| 113 | _CATALOGS_PATH = Path(__file__).resolve().parent / 'static' / 'catalogs.json' |
| 114 | _ICON_LIBRARY_DIR = _TEMPLATES_DIR / 'icons' |
| 115 | _AI_IMAGE_COMPARISON_DIR = _SKILL_DIR / 'references' / 'ai-image-comparison' |
| 116 | _TEMPLATE_LIBRARY_CONFIG = { |
| 117 | 'brand': ('brands', 'brands_index.json'), |
| 118 | 'style': ('styles', 'styles_index.json'), |
| 119 | 'layout': ('layouts', 'layouts_index.json'), |
| 120 | 'deck': ('decks', 'decks_index.json'), |
| 121 | } |
| 122 | _TEMPLATE_KIND_LINE_RE = re.compile( |
| 123 | r'''kind\s*:\s*(?:'([^']*)'|"([^"]*)"|([A-Za-z][A-Za-z0-9_-]*))''' |
| 124 | r'''\s*(?:#.*)?\Z''' |
| 125 | ) |
| 126 | _ICON_PREVIEW_SAMPLES = { |
| 127 | 'chunk-filled': ('home', 'chart-line', 'users', 'target'), |
| 128 | 'tabler-filled': ('home', 'chart-dots', 'user', 'bulb'), |
| 129 | 'tabler-outline': ('home', 'chart-line', 'users', 'bulb'), |
| 130 | 'phosphor-duotone': ('house', 'chart-line', 'users', 'target'), |
| 131 | } |
| 132 | |
| 133 | # Prefer the same memorable entry port as live preview. Normal single-project |
| 134 | # execution releases it between Step 4 and Step 6; concurrent projects advance |
| 135 | # from this base while explicit ``--port`` remains exact. |
| 136 | DEFAULT_PORT = 5050 |
| 137 | PUBLIC_HOST = '127.0.0.1' |
| 138 | STARTUP_TIMEOUT = 10 |
| 139 | |
| 140 | # Default --wait budget, kept just under the 600s Bash-tool ceiling so the |
| 141 | # parent (waiting) command returns before the calling harness kills it. The |
| 142 | # detached child server keeps running on its own --timeout idle budget, so a |
| 143 | # slow user can still confirm after the wait returns; the caller re-checks |
| 144 | # result.json before falling back to chat. |
| 145 | WAIT_TIMEOUT_DEFAULT = 590 |
| 146 | |
| 147 | def _read_json_object(path: Path, retries: int = 2, delay: float = 0.08) -> dict: |
| 148 | """Read a JSON object, retrying briefly around non-atomic external writes.""" |
| 149 | last_error: Exception = ValueError('unknown JSON read error') |
| 150 | for attempt in range(retries + 1): |
| 151 | try: |
| 152 | data = json.loads(path.read_text(encoding='utf-8-sig')) |
| 153 | if isinstance(data, dict): |
| 154 | return data |
| 155 | raise ValueError(f'{path} top-level JSON value must be an object') |
| 156 | except (OSError, json.JSONDecodeError, ValueError) as exc: |
| 157 | last_error = exc |
| 158 | if attempt < retries: |
| 159 | time.sleep(delay) |
| 160 | continue |
| 161 | raise last_error |
| 162 | raise last_error |
| 163 | |
| 164 | |
| 165 | def _write_json_atomic(path: Path, data: dict) -> None: |
| 166 | """Write a JSON object with replace semantics so waiters never see a partial file.""" |
| 167 | path.parent.mkdir(parents=True, exist_ok=True) |
| 168 | tmp = path.with_name(f'.{path.name}.{os.getpid()}.tmp') |
| 169 | try: |
| 170 | tmp.write_text( |
| 171 | json.dumps(data, ensure_ascii=False, indent=2), |
| 172 | encoding='utf-8', |
| 173 | ) |
| 174 | os.replace(tmp, path) |
| 175 | finally: |
| 176 | try: |
| 177 | tmp.unlink(missing_ok=True) |
| 178 | except OSError: |
| 179 | pass |
| 180 | |
| 181 | |
| 182 | def _json_sha256(data: object) -> str: |
| 183 | """Return a stable SHA-256 over one JSON-compatible value.""" |
| 184 | canonical = json.dumps( |
| 185 | data, |
| 186 | ensure_ascii=False, |
| 187 | sort_keys=True, |
| 188 | separators=(',', ':'), |
| 189 | ) |
| 190 | return hashlib.sha256(canonical.encode('utf-8')).hexdigest() |
| 191 | |
| 192 | |
| 193 | def _safe_template_id(template_id: object) -> bool: |
| 194 | """Return whether an index key is one safe directory-segment id.""" |
| 195 | return ( |
| 196 | isinstance(template_id, str) |
| 197 | and re.fullmatch(r'\w[\w.-]*', template_id) is not None |
| 198 | ) |
| 199 | |
| 200 | |
| 201 | def _template_design_spec_path(workspace_root: Path) -> Path: |
| 202 | """Return the current or legacy Design Spec for one workspace root.""" |
| 203 | current = workspace_root / 'templates' / 'design_spec.md' |
| 204 | if current.is_file(): |
| 205 | return current |
| 206 | legacy = workspace_root / 'design_spec.md' |
| 207 | if legacy.is_file(): |
| 208 | return legacy |
| 209 | raise ValueError( |
| 210 | 'template workspace is missing templates/design_spec.md ' |
| 211 | f'or legacy design_spec.md: {workspace_root}' |
| 212 | ) |
| 213 | |
| 214 | |
| 215 | def _template_kind_from_spec(spec_path: Path) -> str: |
| 216 | """Read one supported top-level ``kind`` from Design Spec frontmatter.""" |
| 217 | try: |
| 218 | lines = spec_path.read_text(encoding='utf-8-sig').splitlines() |
| 219 | except OSError as exc: |
| 220 | raise ValueError(f'cannot read template Design Spec {spec_path}: {exc}') from exc |
| 221 | if not lines or lines[0] != '---': |
| 222 | raise ValueError(f'{spec_path} must start with YAML frontmatter') |
| 223 | try: |
| 224 | frontmatter_end = lines.index('---', 1) |
| 225 | except ValueError as exc: |
| 226 | raise ValueError(f'{spec_path} has unterminated YAML frontmatter') from exc |
| 227 | |
| 228 | declared_kind = None |
| 229 | for line_number, line in enumerate(lines[1:frontmatter_end], start=2): |
| 230 | if re.match(r'^\s*kind\s*:', line) is None: |
| 231 | continue |
| 232 | if line != line.lstrip(): |
| 233 | raise ValueError( |
| 234 | f'{spec_path}:{line_number} kind must be a top-level frontmatter field' |
| 235 | ) |
| 236 | match = _TEMPLATE_KIND_LINE_RE.fullmatch(line) |
| 237 | if match is None: |
| 238 | raise ValueError( |
| 239 | f'{spec_path}:{line_number} has an invalid kind declaration' |
| 240 | ) |
| 241 | if declared_kind is not None: |
| 242 | raise ValueError(f'{spec_path} frontmatter declares kind more than once') |
| 243 | declared_kind = next(value for value in match.groups() if value is not None) |
| 244 | |
| 245 | if declared_kind is None: |
| 246 | raise ValueError(f'{spec_path} frontmatter must declare kind') |
| 247 | if declared_kind not in _TEMPLATE_LIBRARY_CONFIG: |
| 248 | supported = ', '.join(_TEMPLATE_LIBRARY_CONFIG) |
| 249 | raise ValueError( |
| 250 | f'{spec_path} frontmatter kind must be one of {supported}; ' |
| 251 | f'got {declared_kind!r}' |
| 252 | ) |
| 253 | return declared_kind |
| 254 | |
| 255 | |
| 256 | def _read_template_options_input(confirm_dir: Path) -> tuple[dict, list[Path]]: |
| 257 | """Read and validate the agent-authored Step-3 template input.""" |
| 258 | options_file = confirm_dir / TEMPLATE_OPTIONS_NAME |
| 259 | data = _read_json_object(options_file) |
| 260 | if type(data.get('schema_version')) is not int or data['schema_version'] != TEMPLATE_SCHEMA_VERSION: |
| 261 | raise ValueError( |
| 262 | f'{TEMPLATE_OPTIONS_NAME} schema_version must be {TEMPLATE_SCHEMA_VERSION}' |
| 263 | ) |
| 264 | if data.get('phase') != 'template': |
| 265 | raise ValueError(f'{TEMPLATE_OPTIONS_NAME} phase must be template') |
| 266 | if data.get('default_mode') not in {'free_design', 'templates'}: |
| 267 | raise ValueError( |
| 268 | f'{TEMPLATE_OPTIONS_NAME} default_mode must be free_design or templates' |
| 269 | ) |
| 270 | if 'lang' in data and ( |
| 271 | not isinstance(data['lang'], str) or not data['lang'].strip() |
| 272 | ): |
| 273 | raise ValueError(f'{TEMPLATE_OPTIONS_NAME} lang must be a non-empty string') |
| 274 | if 'explicit_workspace_roots' not in data: |
| 275 | raise ValueError( |
| 276 | f'{TEMPLATE_OPTIONS_NAME} must include explicit_workspace_roots' |
| 277 | ) |
| 278 | raw_roots = data['explicit_workspace_roots'] |
| 279 | if not isinstance(raw_roots, list): |
| 280 | raise ValueError( |
| 281 | f'{TEMPLATE_OPTIONS_NAME} explicit_workspace_roots must be an array' |
| 282 | ) |
| 283 | if raw_roots and data['default_mode'] != 'templates': |
| 284 | raise ValueError( |
| 285 | f'{TEMPLATE_OPTIONS_NAME} default_mode must be templates when ' |
| 286 | 'explicit_workspace_roots is non-empty' |
| 287 | ) |
| 288 | |
| 289 | roots = [] |
| 290 | seen = set() |
| 291 | for index, raw_root in enumerate(raw_roots): |
| 292 | if not isinstance(raw_root, str) or not raw_root.strip(): |
| 293 | raise ValueError( |
| 294 | f'{TEMPLATE_OPTIONS_NAME} explicit_workspace_roots[{index}] ' |
| 295 | 'must be a non-empty string' |
| 296 | ) |
| 297 | candidate = Path(raw_root) |
| 298 | if not candidate.is_absolute(): |
| 299 | raise ValueError( |
| 300 | f'{TEMPLATE_OPTIONS_NAME} explicit_workspace_roots[{index}] ' |
| 301 | 'must be an absolute path' |
| 302 | ) |
| 303 | try: |
| 304 | root = candidate.resolve() |
| 305 | except (OSError, RuntimeError) as exc: |
| 306 | raise ValueError( |
| 307 | f'cannot resolve explicit workspace root {raw_root}: {exc}' |
| 308 | ) from exc |
| 309 | canonical = str(root) |
| 310 | if canonical in seen: |
| 311 | raise ValueError( |
| 312 | f'{TEMPLATE_OPTIONS_NAME} contains duplicate workspace root: {canonical}' |
| 313 | ) |
| 314 | if not root.is_dir(): |
| 315 | raise ValueError(f'explicit workspace root is not a directory: {canonical}') |
| 316 | _template_design_spec_path(root) |
| 317 | seen.add(canonical) |
| 318 | roots.append(root) |
| 319 | return data, roots |
| 320 | |
| 321 | |
| 322 | def _build_template_library() -> tuple[dict, dict[str, dict], dict[str, dict], dict]: |
| 323 | """Build indexed library groups without scanning template directories.""" |
| 324 | library = {} |
| 325 | candidates = {} |
| 326 | registered_roots = {} |
| 327 | index_contracts = {} |
| 328 | for kind, (directory_name, index_name) in _TEMPLATE_LIBRARY_CONFIG.items(): |
| 329 | kind_dir = (_TEMPLATES_DIR / directory_name).resolve() |
| 330 | index_path = kind_dir / index_name |
| 331 | index_data = _read_json_object(index_path) |
| 332 | index_contracts[kind] = index_data |
| 333 | group = [] |
| 334 | for template_id, metadata in index_data.items(): |
| 335 | if not _safe_template_id(template_id): |
| 336 | raise ValueError( |
| 337 | f'{index_path} contains unsafe template id: {template_id!r}' |
| 338 | ) |
| 339 | if not isinstance(metadata, dict): |
| 340 | raise ValueError( |
| 341 | f'{index_path} entry {template_id!r} must be an object' |
| 342 | ) |
| 343 | workspace_root = (kind_dir / template_id).resolve() |
| 344 | if workspace_root.parent != kind_dir: |
| 345 | raise ValueError( |
| 346 | f'{index_path} entry {template_id!r} does not resolve to a ' |
| 347 | f'direct child of {kind_dir}' |
| 348 | ) |
| 349 | if not workspace_root.is_dir(): |
| 350 | raise ValueError( |
| 351 | f'{index_path} entry {template_id!r} workspace does not exist: ' |
| 352 | f'{workspace_root}' |
| 353 | ) |
| 354 | spec_path = workspace_root / 'templates' / 'design_spec.md' |
| 355 | if not spec_path.is_file(): |
| 356 | raise ValueError( |
| 357 | f'{index_path} entry {template_id!r} is missing {spec_path}' |
| 358 | ) |
| 359 | declared_kind = _template_kind_from_spec(spec_path) |
| 360 | if declared_kind != kind: |
| 361 | raise ValueError( |
| 362 | f'{index_path} entry {template_id!r} declares kind ' |
| 363 | f'{declared_kind!r}, expected {kind!r}' |
| 364 | ) |
| 365 | summary = metadata.get('summary', '') |
| 366 | if not isinstance(summary, str): |
| 367 | raise ValueError( |
| 368 | f'{index_path} entry {template_id!r} summary must be a string' |
| 369 | ) |
| 370 | key = f'library:{kind}:{template_id}' |
| 371 | if key in candidates: |
| 372 | raise ValueError(f'duplicate template candidate key: {key}') |
| 373 | candidate = { |
| 374 | 'key': key, |
| 375 | 'source': 'library', |
| 376 | 'kind': kind, |
| 377 | 'id': template_id, |
| 378 | 'label': template_id, |
| 379 | 'summary': summary, |
| 380 | 'workspace_root': str(workspace_root), |
| 381 | } |
| 382 | canonical_root = candidate['workspace_root'] |
| 383 | if canonical_root in registered_roots: |
| 384 | raise ValueError( |
| 385 | f'duplicate registered workspace root: {canonical_root}' |
| 386 | ) |
| 387 | group.append(candidate) |
| 388 | candidates[key] = candidate |
| 389 | registered_roots[canonical_root] = candidate |
| 390 | library[kind] = group |
| 391 | return library, candidates, registered_roots, index_contracts |
| 392 | |
| 393 | |
| 394 | def _build_template_options(confirm_dir: Path) -> tuple[dict, dict[str, dict]]: |
| 395 | """Return the browser contract and its server-owned candidate whitelist.""" |
| 396 | source, explicit_roots = _read_template_options_input(confirm_dir) |
| 397 | library, candidates, registered_roots, index_contracts = _build_template_library() |
| 398 | explicit = [] |
| 399 | suggested_keys = [] |
| 400 | for root in explicit_roots: |
| 401 | canonical_root = str(root) |
| 402 | registered = registered_roots.get(canonical_root) |
| 403 | if registered is not None: |
| 404 | suggested_keys.append(registered['key']) |
| 405 | continue |
| 406 | digest = hashlib.sha256(canonical_root.encode('utf-8')).hexdigest() |
| 407 | key = f'explicit:{digest}' |
| 408 | if key in candidates: |
| 409 | raise ValueError(f'duplicate template candidate key: {key}') |
| 410 | kind = _template_kind_from_spec(_template_design_spec_path(root)) |
| 411 | candidate = { |
| 412 | 'key': key, |
| 413 | 'source': 'explicit', |
| 414 | 'kind': kind, |
| 415 | 'label': root.name or canonical_root, |
| 416 | 'workspace_root': canonical_root, |
| 417 | } |
| 418 | explicit.append(candidate) |
| 419 | candidates[key] = candidate |
| 420 | suggested_keys.append(key) |
| 421 | |
| 422 | # One supplied exact root is an unambiguous convenience default. Multiple |
| 423 | # roots are candidates for the single-select controls, not an instruction |
| 424 | # to select all of them. |
| 425 | preselected_keys = suggested_keys if len(suggested_keys) == 1 else [] |
| 426 | |
| 427 | response = { |
| 428 | 'schema_version': TEMPLATE_SCHEMA_VERSION, |
| 429 | 'phase': 'template', |
| 430 | 'default_mode': source['default_mode'], |
| 431 | 'library': library, |
| 432 | 'explicit': explicit, |
| 433 | 'preselected_keys': preselected_keys, |
| 434 | } |
| 435 | if 'lang' in source: |
| 436 | response['lang'] = source['lang'].strip() |
| 437 | response['options_sha256'] = _json_sha256({ |
| 438 | 'schema_version': TEMPLATE_SCHEMA_VERSION, |
| 439 | 'phase': 'template', |
| 440 | 'default_mode': response['default_mode'], |
| 441 | 'lang': response.get('lang'), |
| 442 | 'explicit_workspace_roots': [str(root) for root in explicit_roots], |
| 443 | 'library_indexes': index_contracts, |
| 444 | 'library': library, |
| 445 | 'explicit': explicit, |
| 446 | 'preselected_keys': preselected_keys, |
| 447 | }) |
| 448 | return response, candidates |
| 449 | |
| 450 | |
| 451 | def _template_selection_from_candidate(candidate: dict) -> dict: |
| 452 | """Project one trusted browser candidate into the persisted selection.""" |
| 453 | selection = { |
| 454 | 'source': candidate['source'], |
| 455 | 'kind': candidate['kind'], |
| 456 | } |
| 457 | if candidate['source'] == 'library': |
| 458 | selection['id'] = candidate['id'] |
| 459 | selection['workspace_root'] = candidate['workspace_root'] |
| 460 | return selection |
| 461 | |
| 462 | |
| 463 | def _template_selection_sha256( |
| 464 | mode: str, |
| 465 | selections: list[dict], |
| 466 | options_sha256: str, |
| 467 | ) -> str: |
| 468 | """Bind one resolved choice to the candidate/options contract it used.""" |
| 469 | return _json_sha256({ |
| 470 | 'mode': mode, |
| 471 | 'selections': selections, |
| 472 | 'options_sha256': options_sha256, |
| 473 | }) |
| 474 | |
| 475 | |
| 476 | def _validate_template_selection(data: dict) -> None: |
| 477 | """Validate the server-owned template-selection receipt shape.""" |
| 478 | expected_fields = { |
| 479 | 'schema_version', |
| 480 | 'phase', |
| 481 | 'status', |
| 482 | 'mode', |
| 483 | 'selections', |
| 484 | 'options_sha256', |
| 485 | 'selection_sha256', |
| 486 | 'confirmed_at', |
| 487 | } |
| 488 | if set(data) != expected_fields: |
| 489 | raise ValueError(f'{TEMPLATE_SELECTION_NAME} has invalid fields') |
| 490 | if type(data.get('schema_version')) is not int or data['schema_version'] != TEMPLATE_SCHEMA_VERSION: |
| 491 | raise ValueError( |
| 492 | f'{TEMPLATE_SELECTION_NAME} schema_version must be {TEMPLATE_SCHEMA_VERSION}' |
| 493 | ) |
| 494 | if data.get('phase') != 'template': |
| 495 | raise ValueError(f'{TEMPLATE_SELECTION_NAME} phase must be template') |
| 496 | if data.get('status') != 'confirmed': |
| 497 | raise ValueError(f'{TEMPLATE_SELECTION_NAME} status must be confirmed') |
| 498 | mode = data.get('mode') |
| 499 | if mode not in {'free_design', 'templates'}: |
| 500 | raise ValueError( |
| 501 | f'{TEMPLATE_SELECTION_NAME} mode must be free_design or templates' |
| 502 | ) |
| 503 | selections = data.get('selections') |
| 504 | if not isinstance(selections, list): |
| 505 | raise ValueError(f'{TEMPLATE_SELECTION_NAME} selections must be an array') |
| 506 | if mode == 'free_design' and selections: |
| 507 | raise ValueError('free_design cannot carry template selections') |
| 508 | if mode == 'templates' and not selections: |
| 509 | raise ValueError('templates mode requires at least one selection') |
| 510 | |
| 511 | options_sha256 = data.get('options_sha256') |
| 512 | selection_sha256 = data.get('selection_sha256') |
| 513 | if not isinstance(options_sha256, str) or not re.fullmatch(r'[0-9a-f]{64}', options_sha256): |
| 514 | raise ValueError(f'{TEMPLATE_SELECTION_NAME} options_sha256 is invalid') |
| 515 | if not isinstance(selection_sha256, str) or not re.fullmatch(r'[0-9a-f]{64}', selection_sha256): |
| 516 | raise ValueError(f'{TEMPLATE_SELECTION_NAME} selection_sha256 is invalid') |
| 517 | |
| 518 | seen_roots = set() |
| 519 | seen_library_kinds = set() |
| 520 | explicit_count = 0 |
| 521 | for index, selection in enumerate(selections): |
| 522 | if not isinstance(selection, dict): |
| 523 | raise ValueError( |
| 524 | f'{TEMPLATE_SELECTION_NAME} selections[{index}] must be an object' |
| 525 | ) |
| 526 | source = selection.get('source') |
| 527 | if source not in {'library', 'explicit'}: |
| 528 | raise ValueError( |
| 529 | f'{TEMPLATE_SELECTION_NAME} selections[{index}] has invalid source' |
| 530 | ) |
| 531 | kind = selection.get('kind') |
| 532 | if kind not in _TEMPLATE_LIBRARY_CONFIG: |
| 533 | raise ValueError( |
| 534 | f'{TEMPLATE_SELECTION_NAME} selections[{index}] has invalid kind' |
| 535 | ) |
| 536 | expected_keys = {'source', 'kind', 'workspace_root'} |
| 537 | if source == 'library': |
| 538 | expected_keys.add('id') |
| 539 | if kind in seen_library_kinds: |
| 540 | raise ValueError( |
| 541 | 'template selection allows at most one library workspace ' |
| 542 | f'for kind {kind!r}' |
| 543 | ) |
| 544 | seen_library_kinds.add(kind) |
| 545 | if not _safe_template_id(selection.get('id')): |
| 546 | raise ValueError( |
| 547 | f'{TEMPLATE_SELECTION_NAME} selections[{index}] has invalid id' |
| 548 | ) |
| 549 | else: |
| 550 | explicit_count += 1 |
| 551 | if explicit_count > 1: |
| 552 | raise ValueError( |
| 553 | 'template selection allows at most one explicit workspace' |
| 554 | ) |
| 555 | if set(selection) != expected_keys: |
| 556 | raise ValueError( |
| 557 | f'{TEMPLATE_SELECTION_NAME} selections[{index}] has invalid fields' |
| 558 | ) |
| 559 | workspace_root = selection.get('workspace_root') |
| 560 | if not isinstance(workspace_root, str) or not workspace_root: |
| 561 | raise ValueError( |
| 562 | f'{TEMPLATE_SELECTION_NAME} selections[{index}] ' |
| 563 | 'workspace_root must be a non-empty string' |
| 564 | ) |
| 565 | root = Path(workspace_root) |
| 566 | if not root.is_absolute() or str(root.resolve()) != workspace_root: |
| 567 | raise ValueError( |
| 568 | f'{TEMPLATE_SELECTION_NAME} selections[{index}] ' |
| 569 | 'workspace_root must be a canonical absolute path' |
| 570 | ) |
| 571 | if workspace_root in seen_roots: |
| 572 | raise ValueError( |
| 573 | f'{TEMPLATE_SELECTION_NAME} contains duplicate workspace root: ' |
| 574 | f'{workspace_root}' |
| 575 | ) |
| 576 | seen_roots.add(workspace_root) |
| 577 | if not isinstance(data.get('confirmed_at'), str) or not data['confirmed_at']: |
| 578 | raise ValueError( |
| 579 | f'{TEMPLATE_SELECTION_NAME} confirmed_at must be a non-empty string' |
| 580 | ) |
| 581 | expected_selection_sha256 = _template_selection_sha256( |
| 582 | mode, |
| 583 | selections, |
| 584 | options_sha256, |
| 585 | ) |
| 586 | if selection_sha256 != expected_selection_sha256: |
| 587 | raise ValueError(f'{TEMPLATE_SELECTION_NAME} selection_sha256 does not match') |
| 588 | |
| 589 | |
| 590 | def _read_template_selection(selection_file: Path) -> dict: |
| 591 | """Read a selection and revalidate it against current indexed options.""" |
| 592 | data = _read_json_object(selection_file) |
| 593 | _validate_template_selection(data) |
| 594 | options_file = selection_file.parent / TEMPLATE_OPTIONS_NAME |
| 595 | if not _is_newer(selection_file, options_file): |
| 596 | raise ValueError( |
| 597 | f'{TEMPLATE_SELECTION_NAME} must be confirmed after the current ' |
| 598 | f'{TEMPLATE_OPTIONS_NAME}' |
| 599 | ) |
| 600 | options, candidates = _build_template_options(selection_file.parent) |
| 601 | if data['options_sha256'] != options['options_sha256']: |
| 602 | raise ValueError( |
| 603 | f'{TEMPLATE_SELECTION_NAME} options_sha256 no longer matches current options' |
| 604 | ) |
| 605 | available_selections = { |
| 606 | _json_sha256(_template_selection_from_candidate(candidate)) |
| 607 | for candidate in candidates.values() |
| 608 | } |
| 609 | for selection in data['selections']: |
| 610 | if _json_sha256(selection) not in available_selections: |
| 611 | raise ValueError( |
| 612 | f'{TEMPLATE_SELECTION_NAME} references an unavailable candidate' |
| 613 | ) |
| 614 | return data |
| 615 | |
| 616 | |
| 617 | def _validate_template_handoff(data: dict) -> None: |
| 618 | """Validate the agent-owned Step-3 completion receipt shape.""" |
| 619 | expected_fields = { |
| 620 | 'schema_version', |
| 621 | 'phase', |
| 622 | 'status', |
| 623 | 'mode', |
| 624 | 'selection_sha256', |
| 625 | 'completed_at', |
| 626 | } |
| 627 | if set(data) != expected_fields: |
| 628 | raise ValueError(f'{TEMPLATE_HANDOFF_NAME} has invalid fields') |
| 629 | if type(data.get('schema_version')) is not int or data['schema_version'] != TEMPLATE_SCHEMA_VERSION: |
| 630 | raise ValueError( |
| 631 | f'{TEMPLATE_HANDOFF_NAME} schema_version must be {TEMPLATE_SCHEMA_VERSION}' |
| 632 | ) |
| 633 | if data.get('phase') != 'template': |
| 634 | raise ValueError(f'{TEMPLATE_HANDOFF_NAME} phase must be template') |
| 635 | if data.get('status') != 'ready': |
| 636 | raise ValueError(f'{TEMPLATE_HANDOFF_NAME} status must be ready') |
| 637 | if data.get('mode') not in {'free_design', 'templates'}: |
| 638 | raise ValueError( |
| 639 | f'{TEMPLATE_HANDOFF_NAME} mode must be free_design or templates' |
| 640 | ) |
| 641 | selection_sha256 = data.get('selection_sha256') |
| 642 | if not isinstance(selection_sha256, str) or not re.fullmatch(r'[0-9a-f]{64}', selection_sha256): |
| 643 | raise ValueError(f'{TEMPLATE_HANDOFF_NAME} selection_sha256 is invalid') |
| 644 | if not isinstance(data.get('completed_at'), str) or not data['completed_at']: |
| 645 | raise ValueError( |
| 646 | f'{TEMPLATE_HANDOFF_NAME} completed_at must be a non-empty string' |
| 647 | ) |
| 648 | |
| 649 | |
| 650 | def _read_template_handoff(project_path: Path, handoff_file: Path) -> dict: |
| 651 | """Read a handoff and bind it to the current selection and installed state.""" |
| 652 | data = _read_json_object(handoff_file) |
| 653 | _validate_template_handoff(data) |
| 654 | selection_file = handoff_file.parent / TEMPLATE_SELECTION_NAME |
| 655 | selection = _read_template_selection(selection_file) |
| 656 | if not _is_newer(handoff_file, selection_file): |
| 657 | raise ValueError( |
| 658 | f'{TEMPLATE_HANDOFF_NAME} must be completed after the current ' |
| 659 | f'{TEMPLATE_SELECTION_NAME}' |
| 660 | ) |
| 661 | if data['mode'] != selection['mode']: |
| 662 | raise ValueError(f'{TEMPLATE_HANDOFF_NAME} mode does not match selection') |
| 663 | if data['selection_sha256'] != selection['selection_sha256']: |
| 664 | raise ValueError( |
| 665 | f'{TEMPLATE_HANDOFF_NAME} selection_sha256 does not match selection' |
| 666 | ) |
| 667 | if data['mode'] == 'templates': |
| 668 | installed_spec = project_path / 'templates' / 'design_spec.md' |
| 669 | if not installed_spec.is_file(): |
| 670 | raise ValueError( |
| 671 | f'{TEMPLATE_HANDOFF_NAME} requires installed template spec: ' |
| 672 | f'{installed_spec}' |
| 673 | ) |
| 674 | return data |
| 675 | |
| 676 | |
| 677 | def _complete_template_selection(project_path: Path) -> int: |
| 678 | """Write the agent-owned handoff after Stage 1 and template application.""" |
| 679 | confirm_dir = project_path / CONFIRM_DIR_NAME |
| 680 | selection_file = confirm_dir / TEMPLATE_SELECTION_NAME |
| 681 | if not selection_file.exists(): |
| 682 | logger.error('%s not found — the user must confirm Stage 1 first', selection_file) |
| 683 | return 1 |
| 684 | try: |
| 685 | selection = _read_template_selection(selection_file) |
| 686 | except (OSError, json.JSONDecodeError, ValueError) as exc: |
| 687 | logger.error('cannot complete template selection: %s', exc) |
| 688 | return 1 |
| 689 | |
| 690 | result_file = confirm_dir / RESULT_NAME |
| 691 | if _result_stage(result_file) != 'stage1': |
| 692 | logger.error( |
| 693 | 'cannot complete template selection before Stage 1 writes a ' |
| 694 | 'stage1-confirmed result' |
| 695 | ) |
| 696 | return 1 |
| 697 | if selection['mode'] == 'templates': |
| 698 | installed_spec = project_path / 'templates' / 'design_spec.md' |
| 699 | if not installed_spec.is_file(): |
| 700 | logger.error( |
| 701 | 'cannot complete template selection before template apply writes %s', |
| 702 | installed_spec, |
| 703 | ) |
| 704 | return 1 |
| 705 | |
| 706 | handoff_file = confirm_dir / TEMPLATE_HANDOFF_NAME |
| 707 | if handoff_file.exists(): |
| 708 | try: |
| 709 | existing = _read_template_handoff(project_path, handoff_file) |
| 710 | except (OSError, json.JSONDecodeError, ValueError): |
| 711 | existing = None |
| 712 | if ( |
| 713 | existing is not None |
| 714 | and existing['selection_sha256'] == selection['selection_sha256'] |
| 715 | and _is_newer(handoff_file, result_file) |
| 716 | ): |
| 717 | logger.info('template selection already complete: %s', handoff_file) |
| 718 | return 0 |
| 719 | |
| 720 | handoff = { |
| 721 | 'schema_version': TEMPLATE_SCHEMA_VERSION, |
| 722 | 'phase': 'template', |
| 723 | 'status': 'ready', |
| 724 | 'mode': selection['mode'], |
| 725 | 'selection_sha256': selection['selection_sha256'], |
| 726 | 'completed_at': time.strftime('%Y-%m-%dT%H:%M:%S'), |
| 727 | } |
| 728 | _validate_template_handoff(handoff) |
| 729 | _write_json_atomic(handoff_file, handoff) |
| 730 | logger.info('template selection handoff written to %s', handoff_file) |
| 731 | return 0 |
| 732 | |
| 733 | |
| 734 | def _reset_template_selection(confirm_dir: Path) -> int: |
| 735 | """Remove one-run template-selection artifacts before a fresh UI lifecycle.""" |
| 736 | removed = [] |
| 737 | for filename in ( |
| 738 | TEMPLATE_HANDOFF_NAME, |
| 739 | TEMPLATE_SELECTION_NAME, |
| 740 | TEMPLATE_OPTIONS_NAME, |
| 741 | ): |
| 742 | path = confirm_dir / filename |
| 743 | try: |
| 744 | path.unlink() |
| 745 | removed.append(str(path)) |
| 746 | except FileNotFoundError: |
| 747 | continue |
| 748 | except OSError as exc: |
| 749 | logger.error('cannot remove %s: %s', path, exc) |
| 750 | return 1 |
| 751 | if removed: |
| 752 | logger.info('reset template selection artifacts: %s', ', '.join(removed)) |
| 753 | else: |
| 754 | logger.info('template selection artifacts already absent') |
| 755 | return 0 |
| 756 | |
| 757 | |
| 758 | def _stage1_ready_error(confirm_dir: Path) -> Optional[str]: |
| 759 | """Return why the combined template/Stage-1 page cannot be exposed.""" |
| 760 | options_file = confirm_dir / TEMPLATE_OPTIONS_NAME |
| 761 | if not options_file.exists(): |
| 762 | return f'{TEMPLATE_OPTIONS_NAME} not found' |
| 763 | try: |
| 764 | _build_template_options(confirm_dir) |
| 765 | except (OSError, json.JSONDecodeError, ValueError) as exc: |
| 766 | return f'invalid template options: {exc}' |
| 767 | |
| 768 | stage1_file = confirm_dir / RECOMMENDATION_STAGE_NAMES[1] |
| 769 | if not stage1_file.exists(): |
| 770 | return f'{stage1_file.name} not found' |
| 771 | try: |
| 772 | stage1_data = _read_json_object(stage1_file) |
| 773 | if _recommendation_stage(stage1_data) != 1: |
| 774 | return f'{stage1_file.name} does not declare stage1' |
| 775 | except (OSError, json.JSONDecodeError, ValueError) as exc: |
| 776 | return f'cannot validate Stage 1 recommendations: {exc}' |
| 777 | |
| 778 | result_file = confirm_dir / RESULT_NAME |
| 779 | if result_file.exists(): |
| 780 | if not _is_newer(options_file, result_file): |
| 781 | return f'{TEMPLATE_OPTIONS_NAME} must be newer than the prior {RESULT_NAME}' |
| 782 | if not _is_newer(stage1_file, result_file): |
| 783 | return f'{stage1_file.name} must be newer than the prior {RESULT_NAME}' |
| 784 | return None |
| 785 | |
| 786 | |
| 787 | def _stage2_ready_error( |
| 788 | project_path: Path, |
| 789 | confirm_dir: Path, |
| 790 | recommendations_file: Optional[Path] = None, |
| 791 | ) -> Optional[str]: |
| 792 | """Return why final Stage 2 cannot follow the confirmed template choice.""" |
| 793 | selection_file = confirm_dir / TEMPLATE_SELECTION_NAME |
| 794 | if not selection_file.exists(): |
| 795 | return f'{TEMPLATE_SELECTION_NAME} not found after Stage 1 confirmation' |
| 796 | try: |
| 797 | _read_template_selection(selection_file) |
| 798 | except (OSError, json.JSONDecodeError, ValueError) as exc: |
| 799 | return f'invalid template selection: {exc}' |
| 800 | |
| 801 | result_file = confirm_dir / RESULT_NAME |
| 802 | if _result_stage(result_file) != 'stage1': |
| 803 | return f'{RESULT_NAME} does not contain a stage1-confirmed result' |
| 804 | |
| 805 | handoff_file = confirm_dir / TEMPLATE_HANDOFF_NAME |
| 806 | if not handoff_file.exists(): |
| 807 | return f'{TEMPLATE_HANDOFF_NAME} not found after template application' |
| 808 | try: |
| 809 | _read_template_handoff(project_path, handoff_file) |
| 810 | except (OSError, json.JSONDecodeError, ValueError) as exc: |
| 811 | return f'invalid template handoff: {exc}' |
| 812 | if not _is_newer(handoff_file, result_file): |
| 813 | return f'{TEMPLATE_HANDOFF_NAME} must be newer than the Stage 1 {RESULT_NAME}' |
| 814 | |
| 815 | if recommendations_file is not None and not _is_newer( |
| 816 | recommendations_file, |
| 817 | handoff_file, |
| 818 | ): |
| 819 | return f'{recommendations_file.name} must be newer than {TEMPLATE_HANDOFF_NAME}' |
| 820 | return None |
| 821 | |
| 822 | |
| 823 | def _resolve_template_confirmation( |
| 824 | payload: dict, |
| 825 | candidates: dict[str, dict], |
| 826 | options_sha256: str, |
| 827 | ) -> dict: |
| 828 | """Resolve browser keys against the current server-owned candidate set.""" |
| 829 | required_fields = {'mode', 'selection_keys'} |
| 830 | if set(payload) != required_fields: |
| 831 | raise ValueError('template confirmation accepts only mode and selection_keys') |
| 832 | mode = payload.get('mode') |
| 833 | if mode not in {'free_design', 'templates'}: |
| 834 | raise ValueError('mode must be free_design or templates') |
| 835 | selection_keys = payload.get('selection_keys') |
| 836 | if not isinstance(selection_keys, list): |
| 837 | raise ValueError('selection_keys must be an array') |
| 838 | if any(not isinstance(key, str) or not key for key in selection_keys): |
| 839 | raise ValueError('selection_keys must contain non-empty strings') |
| 840 | if len(selection_keys) != len(set(selection_keys)): |
| 841 | raise ValueError('selection_keys must not contain duplicates') |
| 842 | if mode == 'free_design' and selection_keys: |
| 843 | raise ValueError('free_design cannot carry selection_keys') |
| 844 | if mode == 'templates' and not selection_keys: |
| 845 | raise ValueError('templates mode requires at least one selection key') |
| 846 | |
| 847 | unknown_keys = [key for key in selection_keys if key not in candidates] |
| 848 | if unknown_keys: |
| 849 | raise ValueError( |
| 850 | 'selection_keys contains unavailable candidate keys: ' |
| 851 | + ', '.join(unknown_keys) |
| 852 | ) |
| 853 | selections = sorted([ |
| 854 | _template_selection_from_candidate(candidates[key]) |
| 855 | for key in selection_keys |
| 856 | ], key=lambda item: ( |
| 857 | item['source'], |
| 858 | item.get('kind', ''), |
| 859 | item.get('id', ''), |
| 860 | item['workspace_root'], |
| 861 | )) |
| 862 | selection_sha256 = _template_selection_sha256( |
| 863 | mode, |
| 864 | selections, |
| 865 | options_sha256, |
| 866 | ) |
| 867 | receipt = { |
| 868 | 'schema_version': TEMPLATE_SCHEMA_VERSION, |
| 869 | 'phase': 'template', |
| 870 | 'status': 'confirmed', |
| 871 | 'mode': mode, |
| 872 | 'selections': selections, |
| 873 | 'options_sha256': options_sha256, |
| 874 | 'selection_sha256': selection_sha256, |
| 875 | 'confirmed_at': time.strftime('%Y-%m-%dT%H:%M:%S'), |
| 876 | } |
| 877 | _validate_template_selection(receipt) |
| 878 | return receipt |
| 879 | |
| 880 | |
| 881 | def _confirmation_launch_error(confirm_dir: Path) -> Optional[str]: |
| 882 | """Return why the current Default UI lifecycle cannot launch.""" |
| 883 | options_file = confirm_dir / TEMPLATE_OPTIONS_NAME |
| 884 | result_file = confirm_dir / RESULT_NAME |
| 885 | result_stage = _result_stage(result_file) |
| 886 | fresh_template_options = _fresh_template_restart(confirm_dir) |
| 887 | if ( |
| 888 | result_file.exists() |
| 889 | and result_stage is None |
| 890 | and not fresh_template_options |
| 891 | ): |
| 892 | return ( |
| 893 | f'{RESULT_NAME} is not a current stage1/final receipt — reset the ' |
| 894 | f'template selection and write fresh {TEMPLATE_OPTIONS_NAME} before ' |
| 895 | 'starting a new run' |
| 896 | ) |
| 897 | if ( |
| 898 | result_stage == 'final' |
| 899 | and not fresh_template_options |
| 900 | ): |
| 901 | return ( |
| 902 | 'the previous Confirm UI run is complete — reset the template ' |
| 903 | f'selection and write fresh {TEMPLATE_OPTIONS_NAME} before starting a new one' |
| 904 | ) |
| 905 | if options_file.exists(): |
| 906 | try: |
| 907 | _build_template_options(confirm_dir) |
| 908 | selection_file = confirm_dir / TEMPLATE_SELECTION_NAME |
| 909 | if result_stage == 'stage1' and not selection_file.exists(): |
| 910 | return ( |
| 911 | f'{TEMPLATE_SELECTION_NAME} not found for the confirmed ' |
| 912 | 'Stage 1 result' |
| 913 | ) |
| 914 | if selection_file.exists(): |
| 915 | _read_template_selection(selection_file) |
| 916 | except (OSError, json.JSONDecodeError, ValueError) as exc: |
| 917 | return f'invalid template selection input: {exc}' |
| 918 | if result_stage is None or fresh_template_options: |
| 919 | stage1_error = _stage1_ready_error(confirm_dir) |
| 920 | if stage1_error: |
| 921 | return f'Stage 1 is not ready: {stage1_error}' |
| 922 | return None |
| 923 | return f'{options_file} not found — write template options before launch' |
| 924 | |
| 925 | |
| 926 | def _server_url(port: int, path: str = '') -> str: |
| 927 | """Return the loopback URL shown to users and used by readiness probes.""" |
| 928 | suffix = path if path.startswith('/') or not path else f'/{path}' |
| 929 | return f'http://{PUBLIC_HOST}:{port}{suffix}' |
| 930 | |
| 931 | |
| 932 | def _wait_for_server_ready( |
| 933 | port: int, |
| 934 | proc: subprocess.Popen, |
| 935 | project_path: Path, |
| 936 | timeout: int = STARTUP_TIMEOUT, |
| 937 | ) -> bool: |
| 938 | """Wait until this project's detached confirm server is accepting requests.""" |
| 939 | deadline = time.time() + timeout |
| 940 | last_error = '' |
| 941 | health_url = _server_url(port, '/api/health') |
| 942 | while time.time() < deadline: |
| 943 | returncode = proc.poll() |
| 944 | if returncode is not None: |
| 945 | logger.error('confirm UI exited during startup (code=%s)', returncode) |
| 946 | return False |
| 947 | try: |
| 948 | with urllib.request.urlopen(health_url, timeout=1) as resp: |
| 949 | data = json.load(resp) |
| 950 | if ( |
| 951 | resp.status == 200 |
| 952 | and isinstance(data, dict) |
| 953 | and data.get('service') == 'confirm_ui' |
| 954 | and data.get('project') == str(project_path) |
| 955 | and data.get('pid') == proc.pid |
| 956 | ): |
| 957 | return True |
| 958 | last_error = 'health response belongs to another service or project' |
| 959 | except (OSError, ValueError, urllib.error.URLError) as exc: |
| 960 | last_error = str(exc) |
| 961 | time.sleep(0.2) |
| 962 | logger.error( |
| 963 | 'confirm UI did not become ready at %s within %ss%s', |
| 964 | health_url, |
| 965 | timeout, |
| 966 | f' (last error: {last_error})' if last_error else '', |
| 967 | ) |
| 968 | return False |
| 969 | |
| 970 | |
| 971 | def _launch_background_server( |
| 972 | project_path: Path, |
| 973 | *, |
| 974 | preferred_port: int, |
| 975 | exact_port: bool, |
| 976 | idle_timeout: int, |
| 977 | open_browser: bool, |
| 978 | ) -> tuple[subprocess.Popen, int, Path]: |
| 979 | """Start the confirm server child and wait until it is reachable.""" |
| 980 | confirm_dir = project_path / CONFIRM_DIR_NAME |
| 981 | confirm_dir.mkdir(parents=True, exist_ok=True) |
| 982 | log_path = confirm_dir / 'server.log' |
| 983 | port = preferred_port if exact_port else _find_free_port(preferred_port) |
| 984 | cmd = [ |
| 985 | sys.executable, |
| 986 | str(Path(__file__).resolve()), |
| 987 | str(project_path), |
| 988 | '--port', |
| 989 | str(port), |
| 990 | '--timeout', |
| 991 | str(idle_timeout), |
| 992 | '--no-browser', |
| 993 | ] |
| 994 | with log_path.open('a', encoding='utf-8') as log: |
| 995 | proc = _popen_detached( |
| 996 | cmd, |
| 997 | stdout=log, |
| 998 | stderr=subprocess.STDOUT, |
| 999 | stdin=subprocess.DEVNULL, |
| 1000 | logger=logger, |
| 1001 | ) |
| 1002 | logger.info('log: %s', log_path) |
| 1003 | if not _wait_for_server_ready(port, proc, project_path): |
| 1004 | if proc.poll() is None: |
| 1005 | proc.terminate() |
| 1006 | raise RuntimeError(f'confirm UI failed to become reachable: {_server_url(port)}') |
| 1007 | _sync_session_state(confirm_dir, server_port=port, event='server-ready') |
| 1008 | url = _server_url(port) |
| 1009 | logger.info('started confirm UI in background: %s (pid=%s)', url, proc.pid) |
| 1010 | if open_browser: |
| 1011 | webbrowser.open(url) |
| 1012 | return proc, port, log_path |
| 1013 | |
| 1014 | |
| 1015 | def _live_lock(lock_file: Path) -> Optional[dict]: |
| 1016 | """Return a live lock; stale entries are overwritten by the recovered child.""" |
| 1017 | existing = _read_lock(lock_file) |
| 1018 | if not existing: |
| 1019 | return None |
| 1020 | if _process_alive(_lock_pid(existing)): |
| 1021 | return existing |
| 1022 | return None |
| 1023 | |
| 1024 | |
| 1025 | def _preferred_recovery_port(lock_file: Path, fallback: int) -> int: |
| 1026 | """Prefer a stale lock's port so an already-open browser can reconnect.""" |
| 1027 | existing = _read_lock(lock_file) |
| 1028 | try: |
| 1029 | port = int((existing or {}).get('port', 0) or 0) |
| 1030 | return _validate_port(port) if port else fallback |
| 1031 | except (TypeError, ValueError): |
| 1032 | return fallback |
| 1033 | |
| 1034 | |
| 1035 | def _open_browser_async(url: str, delay: float = 0.4) -> None: |
| 1036 | """Open the browser after Flask has had a moment to bind its socket.""" |
| 1037 | def _open() -> None: |
| 1038 | time.sleep(delay) |
| 1039 | webbrowser.open(url) |
| 1040 | |
| 1041 | threading.Thread(target=_open, daemon=True).start() |
| 1042 | |
| 1043 | |
| 1044 | def _wait_for_result( |
| 1045 | result_file: Path, |
| 1046 | proc: subprocess.Popen, |
| 1047 | started_at: float, |
| 1048 | timeout: int, |
| 1049 | expected_stage: str, |
| 1050 | ) -> int: |
| 1051 | """Wait until this launch writes a fresh result file or the server exits.""" |
| 1052 | logger.info('waiting for browser confirmation...') |
| 1053 | deadline = None if timeout <= 0 else time.time() + timeout |
| 1054 | while True: |
| 1055 | if result_file.exists(): |
| 1056 | try: |
| 1057 | if result_file.stat().st_mtime >= started_at: |
| 1058 | actual_stage = _result_stage(result_file) |
| 1059 | if actual_stage != expected_stage: |
| 1060 | logger.error( |
| 1061 | 'confirmation stage mismatch: expected %s, found %s', |
| 1062 | expected_stage, |
| 1063 | actual_stage or 'invalid/absent', |
| 1064 | ) |
| 1065 | return 2 |
| 1066 | logger.info('confirmation received: %s', result_file) |
| 1067 | try: |
| 1068 | proc.wait(timeout=3) |
| 1069 | except subprocess.TimeoutExpired: |
| 1070 | pass |
| 1071 | return 0 |
| 1072 | except OSError: |
| 1073 | pass |
| 1074 | |
| 1075 | returncode = proc.poll() |
| 1076 | if returncode is not None: |
| 1077 | logger.error('confirm UI exited before a fresh result was written') |
| 1078 | return returncode or 1 |
| 1079 | |
| 1080 | if deadline is not None and time.time() >= deadline: |
| 1081 | logger.error( |
| 1082 | 'timed out waiting for browser confirmation — the page is still ' |
| 1083 | 'open; re-check %s before falling back to chat', result_file, |
| 1084 | ) |
| 1085 | return 124 |
| 1086 | |
| 1087 | time.sleep(0.5) |
| 1088 | |
| 1089 | |
| 1090 | def _result_stage(result_file: Path) -> Optional[str]: |
| 1091 | """Return the current result stage (Stage 1 or final), or None.""" |
| 1092 | if not result_file.is_file(): |
| 1093 | return None |
| 1094 | try: |
| 1095 | data = _read_json_object(result_file) |
| 1096 | except (OSError, json.JSONDecodeError, ValueError): |
| 1097 | return None |
| 1098 | stage = _stage_key(data.get('stage')) |
| 1099 | status = data.get('status') |
| 1100 | if stage == 'stage1' and status == 'stage1-confirmed': |
| 1101 | return stage |
| 1102 | if stage == 'final' and status == 'confirmed': |
| 1103 | return stage |
| 1104 | return None |
| 1105 | |
| 1106 | |
| 1107 | def _stage_key(value: object) -> Optional[str]: |
| 1108 | """Normalize the two current recommendation/result stage names.""" |
| 1109 | if value is None: |
| 1110 | return None |
| 1111 | raw = str(value).strip().lower() |
| 1112 | if raw == 'stage1': |
| 1113 | return 'stage1' |
| 1114 | if raw == 'stage2': |
| 1115 | return 'stage2' |
| 1116 | if raw == 'final': |
| 1117 | return 'final' |
| 1118 | return None |
| 1119 | |
| 1120 | |
| 1121 | def _recommendation_stage(data: dict) -> int: |
| 1122 | """Return a recommendation payload's declared stage.""" |
| 1123 | stage = _stage_key(data.get('stage')) |
| 1124 | if stage == 'stage1': |
| 1125 | return 1 |
| 1126 | if stage == 'stage2': |
| 1127 | return 2 |
| 1128 | return 0 |
| 1129 | |
| 1130 | |
| 1131 | def _stage_name(number: Optional[int]) -> Optional[str]: |
| 1132 | """Return the canonical stage key for a stage number.""" |
| 1133 | if number == 1: |
| 1134 | return 'stage1' |
| 1135 | if number == 2: |
| 1136 | return 'stage2' |
| 1137 | return None |
| 1138 | |
| 1139 | |
| 1140 | def _result_stage_number(stage: Optional[str]) -> int: |
| 1141 | """Return result progression: stage1=1, final=2.""" |
| 1142 | if stage == 'stage1': |
| 1143 | return 1 |
| 1144 | if stage == 'final': |
| 1145 | return 2 |
| 1146 | return 0 |
| 1147 | |
| 1148 | |
| 1149 | def _expected_recommendation_stage(result_stage: Optional[str]) -> int: |
| 1150 | """Return the recommendation stage that follows the current result.""" |
| 1151 | if result_stage in {'stage1', 'final'}: |
| 1152 | return 2 |
| 1153 | return 1 |
| 1154 | |
| 1155 | |
| 1156 | def _stage_recommendations_path(confirm_dir: Path, stage_number: int) -> Path: |
| 1157 | """Return the stage-specific recommendation path for one handoff.""" |
| 1158 | return confirm_dir / RECOMMENDATION_STAGE_NAMES[stage_number] |
| 1159 | |
| 1160 | |
| 1161 | def _is_newer(path: Path, baseline: Path) -> bool: |
| 1162 | """Return whether ``path`` was authored after ``baseline``.""" |
| 1163 | try: |
| 1164 | return path.stat().st_mtime_ns > baseline.stat().st_mtime_ns |
| 1165 | except OSError: |
| 1166 | return False |
| 1167 | |
| 1168 | |
| 1169 | def _fresh_template_restart(confirm_dir: Path) -> bool: |
| 1170 | """Return whether fresh template options start a new UI session.""" |
| 1171 | return _is_newer( |
| 1172 | confirm_dir / TEMPLATE_OPTIONS_NAME, |
| 1173 | confirm_dir / RESULT_NAME, |
| 1174 | ) |
| 1175 | |
| 1176 | |
| 1177 | def _active_recommendations_path(confirm_dir: Path) -> Path: |
| 1178 | """Resolve the recommendation file for the current two-stage session.""" |
| 1179 | if _fresh_template_restart(confirm_dir): |
| 1180 | return _stage_recommendations_path(confirm_dir, 1) |
| 1181 | result_stage = _result_stage(confirm_dir / RESULT_NAME) |
| 1182 | expected_stage = _expected_recommendation_stage(result_stage) |
| 1183 | return _stage_recommendations_path(confirm_dir, expected_stage) |
| 1184 | |
| 1185 | |
| 1186 | def _read_active_recommendations( |
| 1187 | confirm_dir: Path, |
| 1188 | *, |
| 1189 | retries: int = 2, |
| 1190 | ) -> tuple[Path, dict]: |
| 1191 | """Read and validate the recommendation payload active for this stage.""" |
| 1192 | rec_file = _active_recommendations_path(confirm_dir) |
| 1193 | data = _read_json_object(rec_file, retries=retries) |
| 1194 | for stage_number, filename in RECOMMENDATION_STAGE_NAMES.items(): |
| 1195 | if rec_file.name != filename: |
| 1196 | continue |
| 1197 | actual_stage = _recommendation_stage(data) |
| 1198 | if actual_stage != stage_number: |
| 1199 | raise ValueError( |
| 1200 | f'{filename} must declare stage={_stage_name(stage_number)}, ' |
| 1201 | f'found {_stage_name(actual_stage) or "absent"}' |
| 1202 | ) |
| 1203 | if stage_number == 2: |
| 1204 | result_file = confirm_dir / RESULT_NAME |
| 1205 | if _result_stage(result_file) == 'stage1': |
| 1206 | if not _is_newer(rec_file, result_file): |
| 1207 | raise ValueError( |
| 1208 | f'{filename} must be authored after the current ' |
| 1209 | 'Stage-1 confirmation' |
| 1210 | ) |
| 1211 | production_error = _stage2_production_recommendations_error(data) |
| 1212 | if production_error: |
| 1213 | raise ValueError(production_error) |
| 1214 | break |
| 1215 | return rec_file, data |
| 1216 | |
| 1217 | |
| 1218 | def _template_confirmation_required(project_path: Path) -> bool: |
| 1219 | """Return whether the confirmed project state has an active template.""" |
| 1220 | confirm_dir = project_path / CONFIRM_DIR_NAME |
| 1221 | handoff = _read_template_handoff( |
| 1222 | project_path, |
| 1223 | confirm_dir / TEMPLATE_HANDOFF_NAME, |
| 1224 | ) |
| 1225 | return handoff['mode'] == 'templates' |
| 1226 | |
| 1227 | |
| 1228 | def _template_stage2_error( |
| 1229 | recommendations: dict, |
| 1230 | *, |
| 1231 | template_required: bool, |
| 1232 | ) -> Optional[str]: |
| 1233 | """Require the natural-language template plan in template Stage 2.""" |
| 1234 | if template_required and 'template_application' not in recommendations: |
| 1235 | return ( |
| 1236 | 'template Stage 2 recommendations must include ' |
| 1237 | 'template_application.value' |
| 1238 | ) |
| 1239 | return None |
| 1240 | |
| 1241 | |
| 1242 | def _localized_text_present(candidate: dict, field: str) -> bool: |
| 1243 | """Return whether a candidate carries non-empty localized prose.""" |
| 1244 | return any( |
| 1245 | isinstance(candidate.get(key), str) and bool(candidate[key].strip()) |
| 1246 | for key in (field, f'{field}_zh', f'{field}_en', f'{field}_ja') |
| 1247 | ) |
| 1248 | |
| 1249 | |
| 1250 | def _recommended_image_usage(recommendations: dict): |
| 1251 | """Return the Stage 2 image-source recommendation in either schema.""" |
| 1252 | recommend = recommendations.get('recommend') |
| 1253 | usage = recommend.get('image_usage') if isinstance(recommend, dict) else None |
| 1254 | if usage is None: |
| 1255 | usage = recommendations.get('image_usage') |
| 1256 | if isinstance(usage, dict): |
| 1257 | usage = usage.get('value') |
| 1258 | return usage |
| 1259 | |
| 1260 | |
| 1261 | def _uses_ai_images(recommendations: dict) -> bool: |
| 1262 | """Return whether Stage 2 proposes AI-generated images.""" |
| 1263 | usage = _recommended_image_usage(recommendations) |
| 1264 | return 'ai' in usage if isinstance(usage, list) else usage == 'ai' |
| 1265 | |
| 1266 | |
| 1267 | def _stage2_production_recommendations_error( |
| 1268 | recommendations: dict, |
| 1269 | ) -> Optional[str]: |
| 1270 | """Require every production control in current Stage 2 recommendations.""" |
| 1271 | recommend = recommendations.get('recommend') |
| 1272 | if not isinstance(recommend, dict): |
| 1273 | recommend = {} |
| 1274 | for field in ('formula_policy', 'generation_mode'): |
| 1275 | value = recommend.get(field) |
| 1276 | if not isinstance(value, str) or not value.strip(): |
| 1277 | return ( |
| 1278 | 'Stage 2 recommendations must include non-empty ' |
| 1279 | f'recommend.{field}' |
| 1280 | ) |
| 1281 | refine_spec = recommendations.get('refine_spec') |
| 1282 | if ( |
| 1283 | not isinstance(refine_spec, dict) |
| 1284 | or not isinstance(refine_spec.get('value'), bool) |
| 1285 | ): |
| 1286 | return 'Stage 2 recommendations must include refine_spec.value as a boolean' |
| 1287 | if _uses_ai_images(recommendations): |
| 1288 | image_ai_path = recommend.get('image_ai_path') |
| 1289 | if not isinstance(image_ai_path, str) or not image_ai_path.strip(): |
| 1290 | return ( |
| 1291 | 'Stage 2 recommendations must include non-empty ' |
| 1292 | 'recommend.image_ai_path when image_usage includes ai' |
| 1293 | ) |
| 1294 | return None |
| 1295 | |
| 1296 | |
| 1297 | def _stage2_production_result_error(result: dict) -> Optional[str]: |
| 1298 | """Require every user-confirmed production control in the final payload.""" |
| 1299 | for field in ('formula_policy', 'generation_mode'): |
| 1300 | value = result.get(field) |
| 1301 | if not isinstance(value, str) or not value.strip(): |
| 1302 | return f'final Stage 2 payload must include non-empty {field}' |
| 1303 | if not isinstance(result.get('refine_spec'), bool): |
| 1304 | return 'final Stage 2 payload must include refine_spec as a boolean' |
| 1305 | if _uses_ai_images(result): |
| 1306 | image_ai_path = result.get('image_ai_path') |
| 1307 | if not isinstance(image_ai_path, str) or not image_ai_path.strip(): |
| 1308 | return ( |
| 1309 | 'final Stage 2 payload must include non-empty image_ai_path ' |
| 1310 | 'when image_usage includes ai' |
| 1311 | ) |
| 1312 | return None |
| 1313 | |
| 1314 | |
| 1315 | def _palette_error(color: object, label: str) -> Optional[str]: |
| 1316 | """Validate one complete user-facing palette.""" |
| 1317 | if not isinstance(color, dict): |
| 1318 | return f'{label} must be an object' |
| 1319 | palette = color.get('palette') |
| 1320 | if not isinstance(palette, dict): |
| 1321 | palette = color |
| 1322 | for role in _PALETTE_ROLES: |
| 1323 | value = palette.get(role) |
| 1324 | if role == 'body_text' and value is None: |
| 1325 | value = palette.get('text') |
| 1326 | if not isinstance(value, str) or not _HEX_COLOR_RE.fullmatch(value.strip()): |
| 1327 | return f'{label}.palette.{role} must be a HEX color' |
| 1328 | return None |
| 1329 | |
| 1330 | |
| 1331 | def _positive_number(value: object) -> bool: |
| 1332 | """Return whether a JSON value is a positive finite number.""" |
| 1333 | try: |
| 1334 | number = float(value) |
| 1335 | except (TypeError, ValueError): |
| 1336 | return False |
| 1337 | return number > 0 and number != float('inf') |
| 1338 | |
| 1339 | |
| 1340 | def _is_english_language(language: object) -> bool: |
| 1341 | """Return whether a recommendation language is an English locale.""" |
| 1342 | if not isinstance(language, str): |
| 1343 | return False |
| 1344 | try: |
| 1345 | return language_base(language) == 'en' |
| 1346 | except LanguageTagError: |
| 1347 | return False |
| 1348 | |
| 1349 | |
| 1350 | def _recommendation_language(recommendations: dict) -> object: |
| 1351 | """Return the deck's main language without conflating it with UI ``lang``.""" |
| 1352 | value = ( |
| 1353 | recommendations.get('primary_language') |
| 1354 | or recommendations.get('content_language') |
| 1355 | or recommendations.get('language') |
| 1356 | ) |
| 1357 | if isinstance(value, dict): |
| 1358 | return value.get('value') or value.get('id') or value.get('code') or '' |
| 1359 | return value |
| 1360 | |
| 1361 | |
| 1362 | def _primary_language_error(recommendations: dict) -> Optional[str]: |
| 1363 | """Require and canonicalize the staged content-language source of truth.""" |
| 1364 | return _canonicalize_primary_language(recommendations, required=True) |
| 1365 | |
| 1366 | |
| 1367 | def _canonicalize_primary_language( |
| 1368 | recommendations: dict, |
| 1369 | *, |
| 1370 | required: bool, |
| 1371 | ) -> Optional[str]: |
| 1372 | """Write a canonical primary language into one recommendation/result object.""" |
| 1373 | value = _recommendation_language(recommendations) |
| 1374 | if not isinstance(value, str) or not value.strip(): |
| 1375 | if required: |
| 1376 | return ( |
| 1377 | 'Stage 1 recommendations must declare a valid primary_language ' |
| 1378 | 'BCP-47 tag; lang controls only the Confirm UI language' |
| 1379 | ) |
| 1380 | return None |
| 1381 | try: |
| 1382 | recommendations['primary_language'] = normalize_language_tag(value) |
| 1383 | except LanguageTagError as exc: |
| 1384 | return f'invalid primary_language: {exc}' |
| 1385 | return None |
| 1386 | |
| 1387 | |
| 1388 | def _typography_font_value( |
| 1389 | font: dict, |
| 1390 | field: str, |
| 1391 | *, |
| 1392 | english_primary: bool, |
| 1393 | ) -> object: |
| 1394 | """Return a canonical typography font value, accepting its language-aware alias.""" |
| 1395 | legacy_field = 'latin' if field == 'english' or english_primary else 'cjk' |
| 1396 | value = font.get(field) |
| 1397 | if not isinstance(value, str) or not value.strip(): |
| 1398 | value = font.get(legacy_field) |
| 1399 | return value |
| 1400 | |
| 1401 | |
| 1402 | def _typography_error( |
| 1403 | typography: object, |
| 1404 | label: str, |
| 1405 | *, |
| 1406 | require_sizes: bool, |
| 1407 | main_language: object = '', |
| 1408 | ) -> Optional[str]: |
| 1409 | """Validate one complete user-facing typography recommendation or choice.""" |
| 1410 | if not isinstance(typography, dict): |
| 1411 | return f'{label} must be an object' |
| 1412 | english_primary = _is_english_language(main_language) |
| 1413 | for role in ('heading', 'body'): |
| 1414 | font = typography.get(role) |
| 1415 | if not isinstance(font, dict): |
| 1416 | return f'{label}.{role} must be an object' |
| 1417 | fields = (('primary', 'latin' if english_primary else 'cjk'),) |
| 1418 | if not english_primary: |
| 1419 | fields += (('english', 'latin'),) |
| 1420 | for field, legacy_field in fields: |
| 1421 | value = _typography_font_value( |
| 1422 | font, |
| 1423 | field, |
| 1424 | english_primary=english_primary, |
| 1425 | ) |
| 1426 | if not isinstance(value, str) or not value.strip(): |
| 1427 | return ( |
| 1428 | f'{label}.{role}.{field} ' |
| 1429 | f'(or legacy {legacy_field}) must be non-empty' |
| 1430 | ) |
| 1431 | if not isinstance(font.get('css'), str) or not font['css'].strip(): |
| 1432 | return f'{label}.{role}.css must be non-empty' |
| 1433 | if not _positive_number(typography.get('body_size')): |
| 1434 | return f'{label}.body_size must be a positive number' |
| 1435 | if not require_sizes: |
| 1436 | return None |
| 1437 | sizes = typography.get('sizes') |
| 1438 | if not isinstance(sizes, dict): |
| 1439 | return f'{label}.sizes must be an object' |
| 1440 | for role in _TYPOGRAPHY_SIZE_ROLES: |
| 1441 | if not _positive_number(sizes.get(role)): |
| 1442 | return f'{label}.sizes.{role} must be a positive number' |
| 1443 | return None |
| 1444 | |
| 1445 | |
| 1446 | def _typography_signature( |
| 1447 | typography: dict, |
| 1448 | *, |
| 1449 | main_language: object, |
| 1450 | ) -> tuple[str, ...]: |
| 1451 | """Return the language-relevant font choices that distinguish one candidate.""" |
| 1452 | english_primary = _is_english_language(main_language) |
| 1453 | fields = ('primary',) if english_primary else ('primary', 'english') |
| 1454 | values = [] |
| 1455 | for role in ('heading', 'body'): |
| 1456 | font = typography[role] |
| 1457 | for field in fields: |
| 1458 | value = _typography_font_value( |
| 1459 | font, |
| 1460 | field, |
| 1461 | english_primary=english_primary, |
| 1462 | ) |
| 1463 | values.append(str(value).strip().casefold()) |
| 1464 | return tuple(values) |
| 1465 | |
| 1466 | |
| 1467 | def _typography_candidates_fixed_error( |
| 1468 | candidates: list, |
| 1469 | *, |
| 1470 | main_language: object, |
| 1471 | ) -> Optional[str]: |
| 1472 | """Reject contradictions in an explicitly fixed typography contract.""" |
| 1473 | fixed = [ |
| 1474 | isinstance(candidate, dict) and candidate.get('fixed') is True |
| 1475 | for candidate in candidates |
| 1476 | ] |
| 1477 | if any(fixed): |
| 1478 | if not all(fixed): |
| 1479 | return 'typography.fixed must be true on every candidate or omitted' |
| 1480 | signatures = [ |
| 1481 | _typography_signature( |
| 1482 | candidate, |
| 1483 | main_language=main_language, |
| 1484 | ) |
| 1485 | for candidate in candidates |
| 1486 | ] |
| 1487 | if any(signature != signatures[0] for signature in signatures[1:]): |
| 1488 | return 'fixed typography candidates must repeat the same font combination' |
| 1489 | return None |
| 1490 | return None |
| 1491 | |
| 1492 | |
| 1493 | def _candidate_list(spec: object) -> list: |
| 1494 | """Return candidates from the current or legacy recommendation shape.""" |
| 1495 | if not isinstance(spec, dict): |
| 1496 | return [] |
| 1497 | candidates = spec.get('candidates') |
| 1498 | if not isinstance(candidates, list): |
| 1499 | candidates = spec.get('options') |
| 1500 | return candidates if isinstance(candidates, list) else [] |
| 1501 | |
| 1502 | |
| 1503 | def _stage2_design_directions_error( |
| 1504 | recommendations: dict, |
| 1505 | *, |
| 1506 | main_language: object = '', |
| 1507 | ) -> Optional[str]: |
| 1508 | """Require three complete coordinated Stage 2 design systems.""" |
| 1509 | main_language = main_language or _recommendation_language(recommendations) |
| 1510 | directions = recommendations.get('design_directions') |
| 1511 | if isinstance(directions, dict): |
| 1512 | candidates = _candidate_list(directions) |
| 1513 | if len(candidates) < 3: |
| 1514 | return 'Stage 2 design_directions must include at least 3 candidates' |
| 1515 | typography_candidates = [] |
| 1516 | for index, candidate in enumerate(candidates, start=1): |
| 1517 | label = f'design_directions.candidates[{index - 1}]' |
| 1518 | if not isinstance(candidate, dict): |
| 1519 | return f'{label} must be an object' |
| 1520 | if not _localized_text_present(candidate, 'name'): |
| 1521 | return f'{label} requires a non-empty localized name' |
| 1522 | for field in ('visual_style', 'icons'): |
| 1523 | if not isinstance(candidate.get(field), str) or not candidate[field].strip(): |
| 1524 | return f'{label}.{field} must be non-empty' |
| 1525 | error = _palette_error(candidate.get('color'), f'{label}.color') |
| 1526 | if error: |
| 1527 | return error |
| 1528 | error = _typography_error( |
| 1529 | candidate.get('typography'), |
| 1530 | f'{label}.typography', |
| 1531 | require_sizes=False, |
| 1532 | main_language=main_language, |
| 1533 | ) |
| 1534 | if error: |
| 1535 | return error |
| 1536 | typography_candidates.append(candidate['typography']) |
| 1537 | if _uses_ai_images(recommendations): |
| 1538 | image_strategy = candidate.get('image_strategy') |
| 1539 | if not isinstance(image_strategy, dict) or not str( |
| 1540 | image_strategy.get('rendering') or '' |
| 1541 | ).strip(): |
| 1542 | return f'{label}.image_strategy.rendering must be non-empty' |
| 1543 | return _typography_candidates_fixed_error( |
| 1544 | typography_candidates, |
| 1545 | main_language=main_language, |
| 1546 | ) |
| 1547 | |
| 1548 | # Legacy staged files remain readable, but they must still provide three |
| 1549 | # complete color combinations and at least one complete typography choice. |
| 1550 | colors = _candidate_list(recommendations.get('color')) |
| 1551 | if len(colors) < 3: |
| 1552 | return 'Stage 2 recommendations must include 3 complete color candidates' |
| 1553 | for index, color in enumerate(colors): |
| 1554 | error = _palette_error(color, f'color.candidates[{index}]') |
| 1555 | if error: |
| 1556 | return error |
| 1557 | typography = _candidate_list(recommendations.get('typography')) |
| 1558 | if not typography: |
| 1559 | return 'Stage 2 recommendations must include typography candidates' |
| 1560 | if main_language and len(typography) < 3: |
| 1561 | return 'Stage 2 recommendations must include 3 typography candidates' |
| 1562 | for index, candidate in enumerate(typography): |
| 1563 | error = _typography_error( |
| 1564 | candidate, |
| 1565 | f'typography.candidates[{index}]', |
| 1566 | require_sizes=False, |
| 1567 | main_language=main_language, |
| 1568 | ) |
| 1569 | if error: |
| 1570 | return error |
| 1571 | if main_language: |
| 1572 | return _typography_candidates_fixed_error( |
| 1573 | typography, |
| 1574 | main_language=main_language, |
| 1575 | ) |
| 1576 | return None |
| 1577 | |
| 1578 | |
| 1579 | def _stage2_custom_candidates_error(recommendations: dict) -> Optional[str]: |
| 1580 | """Require visible AI-authored custom alternatives in new Stage 2 files.""" |
| 1581 | candidates = recommendations.get('custom_candidates') |
| 1582 | if not isinstance(candidates, dict): |
| 1583 | return 'Stage 2 recommendations must include custom_candidates' |
| 1584 | |
| 1585 | for field in ('mode', 'visual_style'): |
| 1586 | candidate = candidates.get(field) |
| 1587 | if not isinstance(candidate, dict): |
| 1588 | return f'custom_candidates.{field} must be an object' |
| 1589 | for prose_field in ('name', 'behavior'): |
| 1590 | if not _localized_text_present(candidate, prose_field): |
| 1591 | return ( |
| 1592 | f'custom_candidates.{field} requires non-empty localized ' |
| 1593 | f'{prose_field}' |
| 1594 | ) |
| 1595 | |
| 1596 | if not _uses_ai_images(recommendations): |
| 1597 | return None |
| 1598 | |
| 1599 | image_candidate = candidates.get('image_strategy') |
| 1600 | if not isinstance(image_candidate, dict): |
| 1601 | return 'custom_candidates.image_strategy must be an object when image_usage includes ai' |
| 1602 | if image_candidate.get('rendering') != 'custom': |
| 1603 | return 'custom_candidates.image_strategy.rendering must be custom' |
| 1604 | for prose_field in ('name', 'visual', 'mood', 'behavior'): |
| 1605 | if not _localized_text_present(image_candidate, prose_field): |
| 1606 | return ( |
| 1607 | 'custom_candidates.image_strategy requires non-empty localized ' |
| 1608 | f'{prose_field}' |
| 1609 | ) |
| 1610 | return None |
| 1611 | |
| 1612 | |
| 1613 | def _submission_stage_error( |
| 1614 | confirm_dir: Path, |
| 1615 | submitted_stage: Optional[str], |
| 1616 | *, |
| 1617 | recommendations_file: Path, |
| 1618 | recommendations: dict, |
| 1619 | template_required: bool, |
| 1620 | ) -> Optional[str]: |
| 1621 | """Reject a confirmation that does not match the staged recommendation.""" |
| 1622 | rec_stage_number = _recommendation_stage(recommendations) |
| 1623 | if rec_stage_number == 0: |
| 1624 | return 'recommendations must declare stage1 or stage2' |
| 1625 | |
| 1626 | if rec_stage_number == 1: |
| 1627 | language_error = _primary_language_error(recommendations) |
| 1628 | if language_error: |
| 1629 | return language_error |
| 1630 | |
| 1631 | if rec_stage_number == 2: |
| 1632 | try: |
| 1633 | previous_result = _read_json_object(confirm_dir / RESULT_NAME) |
| 1634 | except (OSError, json.JSONDecodeError, ValueError): |
| 1635 | previous_result = {} |
| 1636 | language_source = ( |
| 1637 | previous_result |
| 1638 | if _recommendation_language(previous_result) |
| 1639 | else recommendations |
| 1640 | ) |
| 1641 | language_error = _canonicalize_primary_language( |
| 1642 | language_source, |
| 1643 | required=True, |
| 1644 | ) |
| 1645 | if language_error: |
| 1646 | return language_error |
| 1647 | main_language = _recommendation_language(language_source) |
| 1648 | recommendation_error = _template_stage2_error( |
| 1649 | recommendations, |
| 1650 | template_required=template_required, |
| 1651 | ) |
| 1652 | if recommendation_error: |
| 1653 | return recommendation_error |
| 1654 | recommendation_error = _stage2_custom_candidates_error(recommendations) |
| 1655 | if recommendation_error: |
| 1656 | return recommendation_error |
| 1657 | recommendation_error = _stage2_design_directions_error( |
| 1658 | recommendations, |
| 1659 | main_language=main_language, |
| 1660 | ) |
| 1661 | if recommendation_error: |
| 1662 | return recommendation_error |
| 1663 | |
| 1664 | allowed_submissions = { |
| 1665 | 1: {'stage1'}, |
| 1666 | 2: {'final'}, |
| 1667 | } |
| 1668 | if submitted_stage not in allowed_submissions[rec_stage_number]: |
| 1669 | expected = 'final' if rec_stage_number == 2 else 'stage1' |
| 1670 | return ( |
| 1671 | f'confirmation stage mismatch: {recommendations_file.name} is ' |
| 1672 | f'{_stage_name(rec_stage_number)}, so the submitted stage must be ' |
| 1673 | f'{expected}' |
| 1674 | ) |
| 1675 | |
| 1676 | previous_stage = ( |
| 1677 | None |
| 1678 | if _fresh_template_restart(confirm_dir) |
| 1679 | else _result_stage(confirm_dir / RESULT_NAME) |
| 1680 | ) |
| 1681 | allowed_predecessors = { |
| 1682 | 1: {None}, |
| 1683 | 2: {'stage1'}, |
| 1684 | } |
| 1685 | if previous_stage not in allowed_predecessors[rec_stage_number]: |
| 1686 | expected_previous = 'stage1' if rec_stage_number == 2 else 'no prior result' |
| 1687 | return ( |
| 1688 | f'confirmation predecessor mismatch: {_stage_name(rec_stage_number)} ' |
| 1689 | f'requires a confirmed {expected_previous} result, found ' |
| 1690 | f'{previous_stage or "absent"}' |
| 1691 | ) |
| 1692 | return None |
| 1693 | |
| 1694 | |
| 1695 | def _custom_selection_error(result: dict) -> Optional[str]: |
| 1696 | """Require behavior prose whenever a creative custom choice is selected.""" |
| 1697 | if result.get('mode') == 'custom' and not str( |
| 1698 | result.get('mode_behavior') or '' |
| 1699 | ).strip(): |
| 1700 | return 'mode=custom requires non-empty mode_behavior' |
| 1701 | if result.get('visual_style') == 'custom' and not str( |
| 1702 | result.get('visual_style_behavior') or '' |
| 1703 | ).strip(): |
| 1704 | return 'visual_style=custom requires non-empty visual_style_behavior' |
| 1705 | image_strategy = result.get('image_strategy') |
| 1706 | if isinstance(image_strategy, dict) and image_strategy.get('rendering') == 'custom': |
| 1707 | behavior = image_strategy.get('behavior') or image_strategy.get('custom') |
| 1708 | if not str(behavior or '').strip(): |
| 1709 | return 'image_strategy.rendering=custom requires non-empty behavior' |
| 1710 | return None |
| 1711 | |
| 1712 | |
| 1713 | def _stage2_solution_error( |
| 1714 | result: dict, |
| 1715 | *, |
| 1716 | main_language: object = '', |
| 1717 | ) -> Optional[str]: |
| 1718 | """Reject a Stage 2/final payload with an incomplete design system.""" |
| 1719 | production_error = _stage2_production_result_error(result) |
| 1720 | if production_error: |
| 1721 | return production_error |
| 1722 | |
| 1723 | color = result.get('color') |
| 1724 | color_error = _palette_error(color, 'color') |
| 1725 | color_custom = ( |
| 1726 | isinstance(color, dict) |
| 1727 | and color.get('name') == 'custom' |
| 1728 | and str(color.get('custom') or '').strip() |
| 1729 | ) |
| 1730 | if color_error and not color_custom: |
| 1731 | return color_error |
| 1732 | |
| 1733 | typography = result.get('typography') |
| 1734 | typography_error = _typography_error( |
| 1735 | typography, |
| 1736 | 'typography', |
| 1737 | require_sizes=True, |
| 1738 | main_language=main_language, |
| 1739 | ) |
| 1740 | if typography_error: |
| 1741 | return typography_error |
| 1742 | |
| 1743 | if _uses_ai_images(result): |
| 1744 | image_strategy = result.get('image_strategy') |
| 1745 | if not isinstance(image_strategy, dict) or not str( |
| 1746 | image_strategy.get('rendering') or '' |
| 1747 | ).strip(): |
| 1748 | return 'image_usage includes ai, so image_strategy.rendering must be non-empty' |
| 1749 | rendering = image_strategy['rendering'].strip() |
| 1750 | if rendering != 'custom' and rendering not in _ai_rendering_ids(): |
| 1751 | return f'image_strategy.rendering is not a known preset: {rendering}' |
| 1752 | return None |
| 1753 | |
| 1754 | |
| 1755 | def _normalize_custom_selections(result: dict) -> None: |
| 1756 | """Keep custom prose only for the creative choices actually selected.""" |
| 1757 | if result.get('mode') != 'custom': |
| 1758 | result.pop('mode_behavior', None) |
| 1759 | if result.get('visual_style') != 'custom': |
| 1760 | result.pop('visual_style_behavior', None) |
| 1761 | |
| 1762 | image_strategy = result.get('image_strategy') |
| 1763 | if not isinstance(image_strategy, dict): |
| 1764 | return |
| 1765 | legacy_behavior = image_strategy.pop('custom', None) |
| 1766 | if image_strategy.get('rendering') == 'custom': |
| 1767 | if not image_strategy.get('behavior') and legacy_behavior: |
| 1768 | image_strategy['behavior'] = legacy_behavior |
| 1769 | return |
| 1770 | image_strategy.pop('behavior', None) |
| 1771 | |
| 1772 | |
| 1773 | def _expected_result_stage(confirm_dir: Path) -> str: |
| 1774 | """Return the result stage expected from the current recommendations.""" |
| 1775 | try: |
| 1776 | _, recommendations = _read_active_recommendations(confirm_dir) |
| 1777 | except (OSError, json.JSONDecodeError, ValueError): |
| 1778 | return 'final' |
| 1779 | return { |
| 1780 | 1: 'stage1', |
| 1781 | 2: 'final', |
| 1782 | }.get(_recommendation_stage(recommendations), 'final') |
| 1783 | |
| 1784 | |
| 1785 | def _file_version(path: Path) -> Optional[float]: |
| 1786 | """Return a cheap file version for polling state, or None when absent.""" |
| 1787 | try: |
| 1788 | return path.stat().st_mtime |
| 1789 | except OSError: |
| 1790 | return None |
| 1791 | |
| 1792 | |
| 1793 | def _read_session(confirm_dir: Path) -> dict: |
| 1794 | """Read session.json if present, returning an object.""" |
| 1795 | session_file = confirm_dir / SESSION_NAME |
| 1796 | if not session_file.exists(): |
| 1797 | return {} |
| 1798 | try: |
| 1799 | return _read_json_object(session_file) |
| 1800 | except (OSError, json.JSONDecodeError, ValueError): |
| 1801 | return {} |
| 1802 | |
| 1803 | |
| 1804 | def _build_session_state( |
| 1805 | confirm_dir: Path, |
| 1806 | *, |
| 1807 | server_port: Optional[int] = None, |
| 1808 | event: Optional[str] = None, |
| 1809 | ) -> dict: |
| 1810 | """Derive the resumable Confirm UI state from disk artifacts.""" |
| 1811 | previous = _read_session(confirm_dir) |
| 1812 | rec_file = _active_recommendations_path(confirm_dir) |
| 1813 | result_file = confirm_dir / RESULT_NAME |
| 1814 | |
| 1815 | rec_stage_number = 0 |
| 1816 | rec_stage = None |
| 1817 | rec_error = None |
| 1818 | if rec_file.exists(): |
| 1819 | try: |
| 1820 | rec_file, rec_data = _read_active_recommendations(confirm_dir) |
| 1821 | rec_stage_number = _recommendation_stage(rec_data) |
| 1822 | rec_stage = _stage_name(rec_stage_number) |
| 1823 | except (OSError, json.JSONDecodeError, ValueError) as exc: |
| 1824 | rec_error = str(exc) |
| 1825 | |
| 1826 | fresh_restart = _fresh_template_restart(confirm_dir) |
| 1827 | result_stage = None if fresh_restart else _result_stage(result_file) |
| 1828 | result_stage_number = _result_stage_number(result_stage) |
| 1829 | |
| 1830 | if result_stage == 'final': |
| 1831 | expected_stage_number = None |
| 1832 | status = 'done' |
| 1833 | current_stage = 'final' |
| 1834 | elif result_stage == 'stage1': |
| 1835 | expected_stage_number = 2 |
| 1836 | ready = rec_stage_number == 2 |
| 1837 | status = 'ready_user' if ready else 'waiting_agent' |
| 1838 | current_stage = 'stage2' if ready else 'stage1' |
| 1839 | else: |
| 1840 | expected_stage_number = 1 |
| 1841 | ready = rec_stage_number == 1 |
| 1842 | status = 'ready_user' if ready else 'waiting_agent' |
| 1843 | current_stage = 'stage1' |
| 1844 | |
| 1845 | session = { |
| 1846 | 'phase': 'strategist', |
| 1847 | 'status': status, |
| 1848 | 'current_stage': current_stage, |
| 1849 | 'expected_stage': _stage_name(expected_stage_number), |
| 1850 | 'expected_stage_number': expected_stage_number, |
| 1851 | 'recommendation_stage': rec_stage, |
| 1852 | 'recommendation_stage_number': rec_stage_number, |
| 1853 | 'recommendation_file': rec_file.name, |
| 1854 | 'recommendation_version': _file_version(rec_file), |
| 1855 | 'recommendation_error': rec_error, |
| 1856 | 'result_stage': result_stage, |
| 1857 | 'result_stage_number': result_stage_number, |
| 1858 | 'result_version': _file_version(result_file), |
| 1859 | 'server_port': server_port or previous.get('server_port'), |
| 1860 | 'event': event or previous.get('event') or 'derived', |
| 1861 | } |
| 1862 | |
| 1863 | options_file = confirm_dir / TEMPLATE_OPTIONS_NAME |
| 1864 | selection_file = confirm_dir / TEMPLATE_SELECTION_NAME |
| 1865 | handoff_file = confirm_dir / TEMPLATE_HANDOFF_NAME |
| 1866 | session.update({ |
| 1867 | 'template_options_file': TEMPLATE_OPTIONS_NAME, |
| 1868 | 'template_options_version': _file_version(options_file), |
| 1869 | 'template_selection_file': TEMPLATE_SELECTION_NAME, |
| 1870 | 'template_selection_version': _file_version(selection_file), |
| 1871 | 'template_handoff_file': TEMPLATE_HANDOFF_NAME, |
| 1872 | 'template_handoff_version': _file_version(handoff_file), |
| 1873 | }) |
| 1874 | |
| 1875 | if result_stage == 'final': |
| 1876 | session.update({ |
| 1877 | 'template_status': 'ready', |
| 1878 | 'template_error': None, |
| 1879 | }) |
| 1880 | return session |
| 1881 | |
| 1882 | if result_stage is None: |
| 1883 | ready_error = _stage1_ready_error(confirm_dir) |
| 1884 | if ready_error: |
| 1885 | session.update({ |
| 1886 | 'status': 'error', |
| 1887 | 'template_status': 'error', |
| 1888 | 'template_error': ready_error, |
| 1889 | }) |
| 1890 | if not session.get('recommendation_error'): |
| 1891 | session['recommendation_error'] = ready_error |
| 1892 | return session |
| 1893 | session.update({ |
| 1894 | 'template_status': 'ready_user', |
| 1895 | 'template_error': None, |
| 1896 | }) |
| 1897 | return session |
| 1898 | |
| 1899 | ready_error = _stage2_ready_error( |
| 1900 | confirm_dir.parent, |
| 1901 | confirm_dir, |
| 1902 | rec_file if rec_stage_number == 2 else None, |
| 1903 | ) |
| 1904 | if ready_error: |
| 1905 | session.update({ |
| 1906 | 'status': 'waiting_agent', |
| 1907 | 'current_stage': 'stage1', |
| 1908 | 'expected_stage': 'stage2', |
| 1909 | 'expected_stage_number': 2, |
| 1910 | 'template_status': 'waiting_agent', |
| 1911 | 'template_error': ready_error, |
| 1912 | }) |
| 1913 | return session |
| 1914 | session.update({ |
| 1915 | 'template_status': 'ready', |
| 1916 | 'template_error': None, |
| 1917 | }) |
| 1918 | return session |
| 1919 | |
| 1920 | |
| 1921 | def _write_session_state(confirm_dir: Path, session: dict) -> None: |
| 1922 | """Persist session.json only when stable state changes.""" |
| 1923 | previous = _read_session(confirm_dir) |
| 1924 | comparable_previous = dict(previous) |
| 1925 | comparable_current = dict(session) |
| 1926 | comparable_previous.pop('updated_at', None) |
| 1927 | comparable_current.pop('updated_at', None) |
| 1928 | if comparable_previous == comparable_current: |
| 1929 | return |
| 1930 | session = dict(session) |
| 1931 | session['updated_at'] = time.strftime('%Y-%m-%dT%H:%M:%S') |
| 1932 | _write_json_atomic(confirm_dir / SESSION_NAME, session) |
| 1933 | |
| 1934 | |
| 1935 | def _sync_session_state( |
| 1936 | confirm_dir: Path, |
| 1937 | *, |
| 1938 | server_port: Optional[int] = None, |
| 1939 | event: Optional[str] = None, |
| 1940 | ) -> dict: |
| 1941 | """Derive and persist the current session state.""" |
| 1942 | session = _build_session_state( |
| 1943 | confirm_dir, |
| 1944 | server_port=server_port, |
| 1945 | event=event, |
| 1946 | ) |
| 1947 | _write_session_state(confirm_dir, session) |
| 1948 | return session |
| 1949 | |
| 1950 | |
| 1951 | # Earlier-stage choices are not rendered on later pages, so their values live |
| 1952 | # only in browser STATE and would be lost on an in-run refresh. Fold them from |
| 1953 | # result.json into Stage-2 recommendations so the same live run resumes from the |
| 1954 | # user's actual communication contract and complete deck-solution choices. |
| 1955 | _CONTRACT_RECOMMEND_KEYS = ( |
| 1956 | 'canvas', |
| 1957 | ) |
| 1958 | _CONTRACT_VALUE_KEYS = ( |
| 1959 | 'audience', |
| 1960 | 'communication_intent', |
| 1961 | 'audience_outcome', |
| 1962 | 'core_message', |
| 1963 | 'delivery_context', |
| 1964 | 'artifact_afterlife', |
| 1965 | 'content_divergence', |
| 1966 | ) |
| 1967 | _PROACTIVE_EXECUTION_DEFAULTS = { |
| 1968 | 'proactive_speaker_notes': True, |
| 1969 | 'proactive_custom_animations': False, |
| 1970 | 'proactive_narration_audio': False, |
| 1971 | } |
| 1972 | _LOCKED_RECOMMENDATIONS_KEY = '_locked_recommendations' |
| 1973 | |
| 1974 | |
| 1975 | def _resolve_proactive_execution_values( |
| 1976 | source: dict, |
| 1977 | ) -> tuple[dict[str, bool], Optional[str]]: |
| 1978 | """Resolve proactive-execution booleans with backward-compatible defaults.""" |
| 1979 | values = {} |
| 1980 | for key, default in _PROACTIVE_EXECUTION_DEFAULTS.items(): |
| 1981 | if key not in source: |
| 1982 | values[key] = default |
| 1983 | continue |
| 1984 | raw_value = source[key] |
| 1985 | if isinstance(raw_value, dict): |
| 1986 | if 'value' not in raw_value or not isinstance(raw_value['value'], bool): |
| 1987 | return {}, f'{key}.value must be a boolean' |
| 1988 | raw_value = raw_value['value'] |
| 1989 | elif not isinstance(raw_value, bool): |
| 1990 | return {}, f'{key} must be a boolean' |
| 1991 | values[key] = raw_value |
| 1992 | return values, None |
| 1993 | |
| 1994 | |
| 1995 | def _normalize_proactive_execution_result( |
| 1996 | result: dict, |
| 1997 | defaults: dict[str, bool], |
| 1998 | ) -> Optional[str]: |
| 1999 | """Write the independent raw confirmation booleans to the final result.""" |
| 2000 | values = {} |
| 2001 | for key, default in defaults.items(): |
| 2002 | value = result.get(key, default) |
| 2003 | if not isinstance(value, bool): |
| 2004 | return f'{key} must be a boolean' |
| 2005 | values[key] = value |
| 2006 | result.update(values) |
| 2007 | return None |
| 2008 | |
| 2009 | |
| 2010 | def _merge_confirmed_choices(data: dict, result_file: Path) -> None: |
| 2011 | """Fold already-confirmed choices into later-stage recommendations.""" |
| 2012 | try: |
| 2013 | res = _read_json_object(result_file) |
| 2014 | except (OSError, json.JSONDecodeError, ValueError): |
| 2015 | return |
| 2016 | if _result_stage(result_file) != 'stage1': |
| 2017 | return |
| 2018 | recommend = data.setdefault('recommend', {}) |
| 2019 | if not isinstance(recommend, dict): |
| 2020 | recommend = data['recommend'] = {} |
| 2021 | main_language = _recommendation_language(res) |
| 2022 | if main_language: |
| 2023 | try: |
| 2024 | data['primary_language'] = normalize_language_tag(main_language) |
| 2025 | except LanguageTagError: |
| 2026 | # Keep the invalid legacy value visible to the API boundary below, |
| 2027 | # which returns a user-facing contract error instead of hiding it. |
| 2028 | data['primary_language'] = main_language |
| 2029 | for key in _CONTRACT_RECOMMEND_KEYS: |
| 2030 | if res.get(key) not in (None, ''): |
| 2031 | recommend[key] = res[key] |
| 2032 | for key in _CONTRACT_VALUE_KEYS: |
| 2033 | if key in res: |
| 2034 | data[key] = {'value': res.get(key) or ''} |
| 2035 | |
| 2036 | |
| 2037 | def _apply_locked_recommendations( |
| 2038 | result: dict, |
| 2039 | recommendations_file: Path, |
| 2040 | previous_result_file: Path, |
| 2041 | *, |
| 2042 | carry_previous: bool, |
| 2043 | ) -> dict: |
| 2044 | """Restore profile-locked fields and return locks for staged carry-over.""" |
| 2045 | # This marker is server-owned; never accept a client-supplied carry-over map. |
| 2046 | result.pop(_LOCKED_RECOMMENDATIONS_KEY, None) |
| 2047 | locked_values = {} |
| 2048 | previous = {} |
| 2049 | if carry_previous: |
| 2050 | try: |
| 2051 | previous = _read_json_object(previous_result_file) |
| 2052 | except (OSError, json.JSONDecodeError, ValueError): |
| 2053 | previous = {} |
| 2054 | previous_locks = previous.get(_LOCKED_RECOMMENDATIONS_KEY) |
| 2055 | if isinstance(previous_locks, dict): |
| 2056 | locked_values.update(previous_locks) |
| 2057 | for key in _PROACTIVE_EXECUTION_DEFAULTS: |
| 2058 | locked_values.pop(key, None) |
| 2059 | |
| 2060 | try: |
| 2061 | recommendations = _read_json_object(recommendations_file) |
| 2062 | recommendations_loaded = True |
| 2063 | except (OSError, json.JSONDecodeError, ValueError): |
| 2064 | recommendations = {} |
| 2065 | recommendations_loaded = False |
| 2066 | |
| 2067 | # Stage 1 starts a new contract and therefore replaces any stale locks left |
| 2068 | # by an earlier run. Later stages inherit those locks across server restarts. |
| 2069 | if recommendations_loaded and _recommendation_stage(recommendations) == 1: |
| 2070 | locked_values = {} |
| 2071 | for key, field in recommendations.items(): |
| 2072 | if key in _PROACTIVE_EXECUTION_DEFAULTS: |
| 2073 | continue |
| 2074 | if not isinstance(field, dict) or field.get('locked') is not True: |
| 2075 | continue |
| 2076 | if 'value' in field: |
| 2077 | locked_values[key] = field['value'] |
| 2078 | for key, value in locked_values.items(): |
| 2079 | result[key] = value |
| 2080 | return locked_values |
| 2081 | |
| 2082 | |
| 2083 | def _wait_only_for_result( |
| 2084 | result_file: Path, |
| 2085 | lock_file: Path, |
| 2086 | timeout: int, |
| 2087 | target_stage: str = 'final', |
| 2088 | ) -> int: |
| 2089 | """Attach to an already-running confirm server and wait for a target stage. |
| 2090 | |
| 2091 | No child is launched here: the page is open from the preceding ``--daemon`` |
| 2092 | launch, so liveness is tracked via the recorded pid, not a ``proc`` handle. |
| 2093 | Only the stage guard is used (no mtime gate), because intermediate submits |
| 2094 | may happen before this wait command is issued. |
| 2095 | """ |
| 2096 | logger.info('waiting for browser confirmation stage=%s...', target_stage) |
| 2097 | deadline = None if timeout <= 0 else time.time() + timeout |
| 2098 | while True: |
| 2099 | result_status = _wait_result_status(result_file, target_stage) |
| 2100 | if result_status is not None: |
| 2101 | return result_status |
| 2102 | |
| 2103 | confirm_dir = result_file.parent |
| 2104 | if target_stage == 'stage1': |
| 2105 | readiness_error = _stage1_ready_error(confirm_dir) |
| 2106 | else: |
| 2107 | recommendations_file = _active_recommendations_path(confirm_dir) |
| 2108 | readiness_error = _stage2_ready_error( |
| 2109 | confirm_dir.parent, |
| 2110 | confirm_dir, |
| 2111 | recommendations_file, |
| 2112 | ) |
| 2113 | if readiness_error: |
| 2114 | logger.error( |
| 2115 | 'confirmation stage=%s is not ready: %s', |
| 2116 | target_stage, |
| 2117 | readiness_error, |
| 2118 | ) |
| 2119 | return 1 |
| 2120 | |
| 2121 | lock = _read_lock(lock_file) |
| 2122 | pid = _lock_pid(lock) |
| 2123 | if not pid or not _process_alive(pid): |
| 2124 | logger.error('confirm server is no longer running before stage=%s was confirmed', target_stage) |
| 2125 | return 1 |
| 2126 | |
| 2127 | if deadline is not None and time.time() >= deadline: |
| 2128 | logger.error( |
| 2129 | 'timed out waiting for confirmation stage=%s — the page may still ' |
| 2130 | 'be open; re-check %s before falling back to chat', target_stage, result_file, |
| 2131 | ) |
| 2132 | return 124 |
| 2133 | |
| 2134 | time.sleep(0.5) |
| 2135 | |
| 2136 | |
| 2137 | def _wait_result_status( |
| 2138 | result_file: Path, |
| 2139 | target_stage: str, |
| 2140 | ) -> Optional[int]: |
| 2141 | """Return a terminal wait status when the persisted result resolves the target.""" |
| 2142 | if _fresh_template_restart(result_file.parent): |
| 2143 | return None |
| 2144 | current_stage = _result_stage(result_file) |
| 2145 | if current_stage == target_stage: |
| 2146 | if target_stage == 'stage1': |
| 2147 | try: |
| 2148 | _read_template_selection( |
| 2149 | result_file.parent / TEMPLATE_SELECTION_NAME, |
| 2150 | ) |
| 2151 | except (OSError, json.JSONDecodeError, ValueError) as exc: |
| 2152 | logger.error( |
| 2153 | 'Stage 1 result has no valid template selection: %s', |
| 2154 | exc, |
| 2155 | ) |
| 2156 | return 2 |
| 2157 | logger.info('confirmation stage=%s received: %s', target_stage, result_file) |
| 2158 | return 0 |
| 2159 | if _result_stage_number(current_stage) > _result_stage_number(target_stage): |
| 2160 | logger.error( |
| 2161 | 'confirmation skipped expected stage=%s and advanced to %s', |
| 2162 | target_stage, |
| 2163 | current_stage, |
| 2164 | ) |
| 2165 | return 2 |
| 2166 | return None |
| 2167 | |
| 2168 | |
| 2169 | def _shutdown_existing(lock_file: Path) -> int: |
| 2170 | """Stop a confirm server left running for this project (idempotent). |
| 2171 | |
| 2172 | Step 4 always calls this on exit so the page never lingers on its selected |
| 2173 | port. Tries a graceful ``/api/shutdown`` first, falls back to killing the |
| 2174 | recorded pid, then clears the lock. A no-op when nothing is running. |
| 2175 | """ |
| 2176 | existing = _read_lock(lock_file) |
| 2177 | if not existing: |
| 2178 | logger.info('no confirm server running — nothing to stop') |
| 2179 | return 0 |
| 2180 | pid = _lock_pid(existing) |
| 2181 | port = existing.get('port') |
| 2182 | if not _process_alive(pid): |
| 2183 | _clear_lock(lock_file) |
| 2184 | logger.info('confirm server already stopped; cleared stale lock') |
| 2185 | return 0 |
| 2186 | # Graceful first: the server flushes and releases its own lock. |
| 2187 | if port: |
| 2188 | try: |
| 2189 | req = urllib.request.Request( |
| 2190 | f'http://127.0.0.1:{port}/api/shutdown', |
| 2191 | data=b'{"reason": "step4-cleanup"}', |
| 2192 | headers={'Content-Type': 'application/json'}, |
| 2193 | method='POST', |
| 2194 | ) |
| 2195 | urllib.request.urlopen(req, timeout=3) |
| 2196 | except OSError: |
| 2197 | pass # server may already be exiting; fall through to the kill path |
| 2198 | for _ in range(20): # up to ~2s for the graceful exit to land |
| 2199 | if not _process_alive(pid): |
| 2200 | break |
| 2201 | time.sleep(0.1) |
| 2202 | if _process_alive(pid): |
| 2203 | try: |
| 2204 | os.kill(pid, signal.SIGTERM) |
| 2205 | except OSError: |
| 2206 | pass |
| 2207 | _clear_lock(lock_file) |
| 2208 | logger.info('confirm server stopped (pid=%s)', pid) |
| 2209 | return 0 |
| 2210 | |
| 2211 | |
| 2212 | def _build_catalogs() -> dict: |
| 2213 | """Return the static catalog set with the canvas list synced live from |
| 2214 | ``config.CANVAS_FORMATS`` — the single source of truth for canvas formats — |
| 2215 | so the confirm page can never drift from the pipeline's real formats. The |
| 2216 | set of formats and their dimensions come from config; trilingual labels and |
| 2217 | use text are kept from catalogs.json (with a plain fallback for any new id). |
| 2218 | """ |
| 2219 | data = json.loads(_CATALOGS_PATH.read_text(encoding='utf-8')) |
| 2220 | try: |
| 2221 | import config # scripts/ is on sys.path (injected at import time) |
| 2222 | formats = config.CANVAS_FORMATS |
| 2223 | except (ImportError, AttributeError): # missing module/attr → static canvas |
| 2224 | return data |
| 2225 | existing = { |
| 2226 | c.get('id'): c |
| 2227 | for c in data.get('canvas', []) |
| 2228 | if isinstance(c, dict) and c.get('id') |
| 2229 | } |
| 2230 | canvas = [] |
| 2231 | for cid, fmt in formats.items(): |
| 2232 | entry = dict(existing.get(cid, {})) |
| 2233 | entry['id'] = cid |
| 2234 | entry['dim'] = fmt.get('dimensions', entry.get('dim', '')) |
| 2235 | if not entry.get('label'): |
| 2236 | name = fmt.get('name', cid) |
| 2237 | entry['label'] = name |
| 2238 | entry.setdefault('label_zh', name) |
| 2239 | entry.setdefault('label_en', name) |
| 2240 | if not entry.get('use_en') and fmt.get('use_case'): |
| 2241 | entry['use_en'] = fmt['use_case'] |
| 2242 | canvas.append(entry) |
| 2243 | data['canvas'] = canvas |
| 2244 | return data |
| 2245 | |
| 2246 | |
| 2247 | def _icon_preview_svg(library: str, name: str) -> str: |
| 2248 | """Read a trusted sample SVG from the bundled icon templates.""" |
| 2249 | icon_path = _ICON_LIBRARY_DIR / library / f'{name}.svg' |
| 2250 | raw = icon_path.read_text(encoding='utf-8') |
| 2251 | raw = re.sub(r'<\?xml[^>]*>\s*', '', raw) |
| 2252 | raw = re.sub(r'<!--.*?-->\s*', '', raw, flags=re.S) |
| 2253 | return raw.strip() |
| 2254 | |
| 2255 | |
| 2256 | def _build_icon_previews() -> dict: |
| 2257 | previews = {} |
| 2258 | for library, names in _ICON_PREVIEW_SAMPLES.items(): |
| 2259 | items = [] |
| 2260 | for name in names: |
| 2261 | try: |
| 2262 | items.append({'name': name, 'svg': _icon_preview_svg(library, name)}) |
| 2263 | except OSError as exc: |
| 2264 | logger.warning('icon preview sample missing: %s/%s (%s)', library, name, exc) |
| 2265 | previews[library] = items |
| 2266 | return previews |
| 2267 | |
| 2268 | |
| 2269 | def _ai_comparison_items(kind: str) -> list[dict[str, str]]: |
| 2270 | manifest = _AI_IMAGE_COMPARISON_DIR / kind / '_manifest.json' |
| 2271 | if not manifest.exists(): |
| 2272 | return [] |
| 2273 | data = json.loads(manifest.read_text(encoding='utf-8')) |
| 2274 | items = [] |
| 2275 | for item in data.get('items', []): |
| 2276 | filename = item.get('filename') |
| 2277 | if not isinstance(filename, str) or not filename.endswith('.png'): |
| 2278 | continue |
| 2279 | if not re.fullmatch(r'[A-Za-z0-9_.-]+\.png', filename): |
| 2280 | continue |
| 2281 | if not (_AI_IMAGE_COMPARISON_DIR / kind / filename).exists(): |
| 2282 | continue |
| 2283 | item_id = Path(filename).stem |
| 2284 | items.append({ |
| 2285 | 'id': item_id, |
| 2286 | 'label': item.get('type') or item_id, |
| 2287 | 'filename': filename, |
| 2288 | 'purpose': item.get('purpose') or '', |
| 2289 | 'alt_text': item.get('alt_text') or '', |
| 2290 | }) |
| 2291 | return items |
| 2292 | |
| 2293 | |
| 2294 | def _ai_rendering_ids() -> set[str]: |
| 2295 | """Return the rendering presets exposed by the confirmation UI.""" |
| 2296 | return {item['id'] for item in _ai_comparison_items('rendering')} |
| 2297 | |
| 2298 | |
| 2299 | def _build_ai_image_comparison() -> dict: |
| 2300 | return { |
| 2301 | 'rendering': _ai_comparison_items('rendering'), |
| 2302 | } |
| 2303 | |
| 2304 | |
| 2305 | # --- app -------------------------------------------------------------------- |
| 2306 | |
| 2307 | def create_app( |
| 2308 | project_dir: str, |
| 2309 | idle_timeout: int = 900, |
| 2310 | lock_file: Optional[Path] = None, |
| 2311 | server_port: Optional[int] = None, |
| 2312 | ) -> Flask: |
| 2313 | """Create and configure the Flask app for a given project directory.""" |
| 2314 | project_path = Path(project_dir).resolve() |
| 2315 | confirm_dir = project_path / CONFIRM_DIR_NAME |
| 2316 | |
| 2317 | app = Flask(__name__, static_folder='static', static_url_path='/static') |
| 2318 | app.config['PROJECT_PATH'] = project_path |
| 2319 | app.config['CONFIRM_DIR'] = confirm_dir |
| 2320 | app.config['LOCK_FILE'] = lock_file |
| 2321 | app.config['SERVER_PORT'] = server_port |
| 2322 | app.config['LAST_REQUEST_TIME'] = time.time() |
| 2323 | |
| 2324 | @app.before_request |
| 2325 | def _update_activity(): |
| 2326 | app.config['LAST_REQUEST_TIME'] = time.time() |
| 2327 | |
| 2328 | def _exit_with_lock_release(code: int = 0) -> None: |
| 2329 | lf = app.config.get('LOCK_FILE') |
| 2330 | if lf is not None: |
| 2331 | _release_lock(lf) |
| 2332 | os._exit(code) |
| 2333 | |
| 2334 | def _idle_watchdog(): |
| 2335 | if idle_timeout <= 0: |
| 2336 | return |
| 2337 | while True: |
| 2338 | time.sleep(10) |
| 2339 | elapsed = time.time() - app.config['LAST_REQUEST_TIME'] |
| 2340 | if elapsed > idle_timeout: |
| 2341 | logger.info('idle for %ds, shutting down', idle_timeout) |
| 2342 | _exit_with_lock_release(0) |
| 2343 | |
| 2344 | watchdog = threading.Thread(target=_idle_watchdog, daemon=True) |
| 2345 | watchdog.start() |
| 2346 | |
| 2347 | @app.route('/api/shutdown', methods=['POST']) |
| 2348 | def shutdown(): |
| 2349 | data = request.get_json(silent=True) or {} |
| 2350 | reason = data.get('reason') or 'shutdown' |
| 2351 | |
| 2352 | def _stop(): |
| 2353 | time.sleep(0.5) # let HTTP response flush before killing the process |
| 2354 | logger.info('shutting down (%s)', reason) |
| 2355 | _exit_with_lock_release(0) |
| 2356 | threading.Thread(target=_stop, daemon=True).start() |
| 2357 | return jsonify({'status': 'ok'}) |
| 2358 | |
| 2359 | @app.route('/') |
| 2360 | def index(): |
| 2361 | return send_from_directory(app.static_folder, 'index.html') |
| 2362 | |
| 2363 | @app.route('/api/health') |
| 2364 | def health(): |
| 2365 | """Expose a cheap readiness probe for the daemon launcher.""" |
| 2366 | rec_file = _active_recommendations_path(confirm_dir) |
| 2367 | rec_ok = False |
| 2368 | stage = None |
| 2369 | if rec_file.exists(): |
| 2370 | try: |
| 2371 | rec_file, rec_data = _read_active_recommendations( |
| 2372 | confirm_dir, |
| 2373 | retries=0, |
| 2374 | ) |
| 2375 | rec_ok = True |
| 2376 | stage = _recommendation_stage(rec_data) |
| 2377 | except (OSError, json.JSONDecodeError, ValueError): |
| 2378 | rec_ok = False |
| 2379 | resp = jsonify({ |
| 2380 | 'status': 'ok', |
| 2381 | 'service': 'confirm_ui', |
| 2382 | 'pid': os.getpid(), |
| 2383 | 'project': str(project_path), |
| 2384 | 'recommendations': rec_ok, |
| 2385 | 'stage': stage, |
| 2386 | 'session': _build_session_state( |
| 2387 | confirm_dir, |
| 2388 | server_port=app.config.get('SERVER_PORT'), |
| 2389 | ), |
| 2390 | }) |
| 2391 | resp.headers['Cache-Control'] = 'no-store' |
| 2392 | return resp |
| 2393 | |
| 2394 | @app.route('/api/session') |
| 2395 | def get_session(): |
| 2396 | """Expose the derived template/Strategist wizard state for polling.""" |
| 2397 | session = _sync_session_state( |
| 2398 | confirm_dir, |
| 2399 | server_port=app.config.get('SERVER_PORT'), |
| 2400 | event='poll', |
| 2401 | ) |
| 2402 | resp = jsonify(session) |
| 2403 | resp.headers['Cache-Control'] = 'no-store' |
| 2404 | return resp |
| 2405 | |
| 2406 | @app.route('/api/catalogs') |
| 2407 | def get_catalogs(): |
| 2408 | """Serve the option universe; canvas is synced live from config.py so |
| 2409 | the static catalogs.json copy can never drift from the real formats.""" |
| 2410 | try: |
| 2411 | resp = jsonify(_build_catalogs()) |
| 2412 | resp.headers['Cache-Control'] = 'no-store' |
| 2413 | return resp |
| 2414 | except (OSError, json.JSONDecodeError) as exc: |
| 2415 | return jsonify({'error': f'invalid catalogs.json: {exc}'}), 500 |
| 2416 | |
| 2417 | @app.route('/api/icon-previews') |
| 2418 | def get_icon_previews(): |
| 2419 | """Serve real sample icons from templates/icons for the icon chooser.""" |
| 2420 | resp = jsonify(_build_icon_previews()) |
| 2421 | resp.headers['Cache-Control'] = 'no-store' |
| 2422 | return resp |
| 2423 | |
| 2424 | @app.route('/api/ai-image-comparison') |
| 2425 | def get_ai_image_comparison_manifest(): |
| 2426 | """Serve generated-image rendering references for the current UI.""" |
| 2427 | try: |
| 2428 | resp = jsonify(_build_ai_image_comparison()) |
| 2429 | resp.headers['Cache-Control'] = 'no-store' |
| 2430 | return resp |
| 2431 | except (OSError, json.JSONDecodeError) as exc: |
| 2432 | return jsonify({'error': f'invalid ai-image-comparison manifest: {exc}'}), 500 |
| 2433 | |
| 2434 | @app.route('/ai-image-comparison/<kind>/<filename>') |
| 2435 | def get_ai_image_comparison(kind: str, filename: str): |
| 2436 | """Serve rendering images for generated-image strategy candidates.""" |
| 2437 | if kind != 'rendering': |
| 2438 | return jsonify({'error': 'invalid comparison kind'}), 404 |
| 2439 | if not re.fullmatch(r'[A-Za-z0-9_.-]+\.png', filename or ''): |
| 2440 | return jsonify({'error': 'invalid comparison filename'}), 404 |
| 2441 | return send_from_directory(_AI_IMAGE_COMPARISON_DIR / kind, filename) |
| 2442 | |
| 2443 | @app.route('/api/recommendations') |
| 2444 | def get_recommendations(): |
| 2445 | """Serve the Strategist-authored recommendations for this project.""" |
| 2446 | result_file = confirm_dir / RESULT_NAME |
| 2447 | if ( |
| 2448 | _result_stage(result_file) == 'final' |
| 2449 | and not _fresh_template_restart(confirm_dir) |
| 2450 | ): |
| 2451 | return jsonify({ |
| 2452 | 'error': ( |
| 2453 | 'the current Confirm UI run is complete; reset the template ' |
| 2454 | f'selection and write fresh {TEMPLATE_OPTIONS_NAME} before ' |
| 2455 | 'starting another' |
| 2456 | ), |
| 2457 | }), 409 |
| 2458 | rec_file = _active_recommendations_path(confirm_dir) |
| 2459 | if not rec_file.exists(): |
| 2460 | return jsonify({'error': f'{rec_file.name} not found'}), 404 |
| 2461 | try: |
| 2462 | rec_file, data = _read_active_recommendations(confirm_dir) |
| 2463 | except (OSError, json.JSONDecodeError, ValueError) as exc: |
| 2464 | return jsonify({ |
| 2465 | 'error': f'invalid current recommendation file: {exc}', |
| 2466 | }), 400 |
| 2467 | rec_stage_number = _recommendation_stage(data) |
| 2468 | if rec_stage_number == 1: |
| 2469 | stage1_error = _stage1_ready_error(confirm_dir) |
| 2470 | if stage1_error: |
| 2471 | return jsonify({ |
| 2472 | 'error': f'Stage 1 is not ready: {stage1_error}', |
| 2473 | }), 409 |
| 2474 | try: |
| 2475 | template_options, _ = _build_template_options(confirm_dir) |
| 2476 | except (OSError, json.JSONDecodeError, ValueError) as exc: |
| 2477 | return jsonify({ |
| 2478 | 'error': f'invalid template options: {exc}', |
| 2479 | }), 409 |
| 2480 | data['template_options'] = template_options |
| 2481 | template_required = False |
| 2482 | else: |
| 2483 | stage2_error = _stage2_ready_error( |
| 2484 | project_path, |
| 2485 | confirm_dir, |
| 2486 | rec_file, |
| 2487 | ) |
| 2488 | if stage2_error: |
| 2489 | return jsonify({ |
| 2490 | 'error': f'Stage 2 is waiting for template handoff: {stage2_error}', |
| 2491 | }), 409 |
| 2492 | try: |
| 2493 | template_required = _template_confirmation_required(project_path) |
| 2494 | except (OSError, json.JSONDecodeError, ValueError) as exc: |
| 2495 | return jsonify({ |
| 2496 | 'error': f'cannot determine active template mode: {exc}', |
| 2497 | }), 409 |
| 2498 | # Later stages render only downstream sections, so fold earlier confirmed |
| 2499 | # choices from result.json back in. An in-run refresh then re-inits from |
| 2500 | # the user's choices instead of catalog defaults. |
| 2501 | if rec_stage_number >= 2 and result_file.exists(): |
| 2502 | _merge_confirmed_choices(data, result_file) |
| 2503 | language_error = _canonicalize_primary_language( |
| 2504 | data, |
| 2505 | required=True, |
| 2506 | ) |
| 2507 | if language_error: |
| 2508 | return jsonify({'error': language_error}), 409 |
| 2509 | if not template_required: |
| 2510 | data.pop('template_application', None) |
| 2511 | if rec_stage_number == 2: |
| 2512 | recommendation_error = _template_stage2_error( |
| 2513 | data, |
| 2514 | template_required=template_required, |
| 2515 | ) |
| 2516 | if recommendation_error: |
| 2517 | return jsonify({'error': recommendation_error}), 409 |
| 2518 | recommendation_error = _stage2_custom_candidates_error(data) |
| 2519 | if recommendation_error: |
| 2520 | return jsonify({'error': recommendation_error}), 409 |
| 2521 | recommendation_error = _stage2_design_directions_error(data) |
| 2522 | if recommendation_error: |
| 2523 | return jsonify({'error': recommendation_error}), 409 |
| 2524 | if rec_stage_number == 2: |
| 2525 | proactive_values, proactive_error = ( |
| 2526 | _resolve_proactive_execution_values(data) |
| 2527 | ) |
| 2528 | if proactive_error: |
| 2529 | return jsonify({'error': proactive_error}), 409 |
| 2530 | for key, value in proactive_values.items(): |
| 2531 | data[key] = {'value': value} |
| 2532 | # Template application is authored by Strategist from the installed |
| 2533 | # workspace and current content. Never expose legacy mode fields as |
| 2534 | # user-facing confirmation controls. |
| 2535 | recommend = data.get('recommend') |
| 2536 | if isinstance(recommend, dict): |
| 2537 | recommend.pop('template_reuse_scope', None) |
| 2538 | recommend.pop('template_adherence', None) |
| 2539 | data.pop('template_reuse_scope', None) |
| 2540 | data.pop('template_adherence', None) |
| 2541 | # The page polls this endpoint after each confirmation until the AI |
| 2542 | # creates the next stage file, so it must never be cached. |
| 2543 | resp = jsonify(data) |
| 2544 | resp.headers['Cache-Control'] = 'no-store' |
| 2545 | return resp |
| 2546 | |
| 2547 | @app.route('/api/confirm', methods=['POST']) |
| 2548 | def confirm(): |
| 2549 | """Persist the user's final choices to result.json for the AI to read.""" |
| 2550 | payload = request.get_json(silent=True) |
| 2551 | if not isinstance(payload, dict): |
| 2552 | return jsonify({'error': 'invalid payload'}), 400 |
| 2553 | confirm_dir.mkdir(parents=True, exist_ok=True) |
| 2554 | result = dict(payload) |
| 2555 | template_selection_payload = result.pop('template_selection', None) |
| 2556 | result_file = confirm_dir / RESULT_NAME |
| 2557 | raw_stage = result.get('stage') |
| 2558 | stage = _stage_key(raw_stage) |
| 2559 | if raw_stage is not None and stage is None: |
| 2560 | return jsonify({'error': 'invalid confirmation stage'}), 400 |
| 2561 | try: |
| 2562 | rec_file, current_recommendations = _read_active_recommendations( |
| 2563 | confirm_dir, |
| 2564 | ) |
| 2565 | except (OSError, json.JSONDecodeError, ValueError) as exc: |
| 2566 | return jsonify({ |
| 2567 | 'error': ( |
| 2568 | 'cannot confirm without valid current recommendations: ' |
| 2569 | f'{exc}' |
| 2570 | ), |
| 2571 | }), 409 |
| 2572 | rec_stage_number = _recommendation_stage(current_recommendations) |
| 2573 | selection_receipt = None |
| 2574 | selection_file = confirm_dir / TEMPLATE_SELECTION_NAME |
| 2575 | write_selection = False |
| 2576 | if rec_stage_number == 1: |
| 2577 | stage1_error = _stage1_ready_error(confirm_dir) |
| 2578 | if stage1_error: |
| 2579 | return jsonify({ |
| 2580 | 'error': f'Stage 1 is not ready: {stage1_error}', |
| 2581 | }), 409 |
| 2582 | if not isinstance(template_selection_payload, dict): |
| 2583 | return jsonify({ |
| 2584 | 'error': ( |
| 2585 | 'Stage 1 payload must include template_selection with ' |
| 2586 | 'mode and selection_keys' |
| 2587 | ), |
| 2588 | }), 400 |
| 2589 | try: |
| 2590 | template_options, template_candidates = _build_template_options( |
| 2591 | confirm_dir, |
| 2592 | ) |
| 2593 | selection_receipt = _resolve_template_confirmation( |
| 2594 | template_selection_payload, |
| 2595 | template_candidates, |
| 2596 | template_options['options_sha256'], |
| 2597 | ) |
| 2598 | except (OSError, json.JSONDecodeError, ValueError) as exc: |
| 2599 | return jsonify({ |
| 2600 | 'error': f'invalid Stage 1 template selection: {exc}', |
| 2601 | }), 400 |
| 2602 | template_required = selection_receipt['mode'] == 'templates' |
| 2603 | if selection_file.exists(): |
| 2604 | try: |
| 2605 | existing_selection = _read_template_selection(selection_file) |
| 2606 | except (OSError, json.JSONDecodeError, ValueError) as exc: |
| 2607 | return jsonify({ |
| 2608 | 'error': ( |
| 2609 | f'existing template selection is invalid: {exc}; ' |
| 2610 | 'the agent must run --reset-template-selection' |
| 2611 | ), |
| 2612 | }), 409 |
| 2613 | if ( |
| 2614 | existing_selection['selection_sha256'] |
| 2615 | != selection_receipt['selection_sha256'] |
| 2616 | ): |
| 2617 | return jsonify({ |
| 2618 | 'error': ( |
| 2619 | 'Stage 1 already has a different template selection; ' |
| 2620 | 'the agent must run --reset-template-selection' |
| 2621 | ), |
| 2622 | }), 409 |
| 2623 | else: |
| 2624 | write_selection = True |
| 2625 | else: |
| 2626 | if template_selection_payload is not None: |
| 2627 | return jsonify({ |
| 2628 | 'error': 'template_selection is accepted only in Stage 1', |
| 2629 | }), 400 |
| 2630 | stage2_error = _stage2_ready_error( |
| 2631 | project_path, |
| 2632 | confirm_dir, |
| 2633 | rec_file, |
| 2634 | ) |
| 2635 | if stage2_error: |
| 2636 | return jsonify({ |
| 2637 | 'error': f'Stage 2 is waiting for template handoff: {stage2_error}', |
| 2638 | }), 409 |
| 2639 | try: |
| 2640 | template_required = _template_confirmation_required(project_path) |
| 2641 | except (OSError, json.JSONDecodeError, ValueError) as exc: |
| 2642 | return jsonify({ |
| 2643 | 'error': f'cannot determine active template mode: {exc}', |
| 2644 | }), 409 |
| 2645 | stage_error = _submission_stage_error( |
| 2646 | confirm_dir, |
| 2647 | stage, |
| 2648 | recommendations_file=rec_file, |
| 2649 | recommendations=current_recommendations, |
| 2650 | template_required=template_required, |
| 2651 | ) |
| 2652 | if stage_error: |
| 2653 | return jsonify({'error': stage_error}), 409 |
| 2654 | custom_error = _custom_selection_error(result) |
| 2655 | if custom_error: |
| 2656 | return jsonify({'error': custom_error}), 400 |
| 2657 | previous_result = {} |
| 2658 | if rec_stage_number >= 2: |
| 2659 | try: |
| 2660 | previous_result = _read_json_object(result_file) |
| 2661 | except (OSError, json.JSONDecodeError, ValueError): |
| 2662 | pass |
| 2663 | main_language = None |
| 2664 | if rec_stage_number > 0: |
| 2665 | language_source = ( |
| 2666 | previous_result |
| 2667 | if _recommendation_language(previous_result) |
| 2668 | else current_recommendations |
| 2669 | ) |
| 2670 | if not _recommendation_language(language_source): |
| 2671 | language_source = result |
| 2672 | language_error = _canonicalize_primary_language( |
| 2673 | language_source, |
| 2674 | required=True, |
| 2675 | ) |
| 2676 | if language_error: |
| 2677 | return jsonify({'error': language_error}), 409 |
| 2678 | main_language = _recommendation_language(language_source) |
| 2679 | if main_language: |
| 2680 | result['primary_language'] = main_language |
| 2681 | else: |
| 2682 | result.pop('primary_language', None) |
| 2683 | if rec_stage_number == 2: |
| 2684 | solution_error = _stage2_solution_error( |
| 2685 | result, |
| 2686 | main_language=main_language, |
| 2687 | ) |
| 2688 | if solution_error: |
| 2689 | return jsonify({'error': solution_error}), 400 |
| 2690 | if rec_stage_number == 2: |
| 2691 | proactive_defaults, proactive_recommendation_error = ( |
| 2692 | _resolve_proactive_execution_values(current_recommendations) |
| 2693 | ) |
| 2694 | if proactive_recommendation_error: |
| 2695 | return jsonify({ |
| 2696 | 'error': proactive_recommendation_error, |
| 2697 | }), 409 |
| 2698 | proactive_result_error = _normalize_proactive_execution_result( |
| 2699 | result, |
| 2700 | proactive_defaults, |
| 2701 | ) |
| 2702 | if proactive_result_error: |
| 2703 | return jsonify({'error': proactive_result_error}), 400 |
| 2704 | _normalize_custom_selections(result) |
| 2705 | locked_values = _apply_locked_recommendations( |
| 2706 | result, |
| 2707 | rec_file, |
| 2708 | result_file, |
| 2709 | carry_previous=rec_stage_number > 1, |
| 2710 | ) |
| 2711 | if rec_stage_number == 1 or not template_required: |
| 2712 | result.pop('template_application', None) |
| 2713 | locked_values.pop('template_application', None) |
| 2714 | result.pop('template_reuse_scope', None) |
| 2715 | result.pop('template_adherence', None) |
| 2716 | if stage == 'stage1': |
| 2717 | result['stage'] = 'stage1' |
| 2718 | result['status'] = 'stage1-confirmed' |
| 2719 | if locked_values: |
| 2720 | result[_LOCKED_RECOMMENDATIONS_KEY] = locked_values |
| 2721 | else: |
| 2722 | result.pop(_LOCKED_RECOMMENDATIONS_KEY, None) |
| 2723 | result['stage'] = 'final' |
| 2724 | result['status'] = 'confirmed' |
| 2725 | result['confirmed_at'] = time.strftime('%Y-%m-%dT%H:%M:%S') |
| 2726 | if write_selection and selection_receipt is not None: |
| 2727 | _write_json_atomic(selection_file, selection_receipt) |
| 2728 | _write_json_atomic(result_file, result) |
| 2729 | _sync_session_state( |
| 2730 | confirm_dir, |
| 2731 | server_port=app.config.get('SERVER_PORT'), |
| 2732 | event=f'{result["stage"]}-submitted', |
| 2733 | ) |
| 2734 | logger.info('%s confirmation written to %s', result['stage'], result_file) |
| 2735 | return jsonify({'status': 'ok'}) |
| 2736 | |
| 2737 | return app |
| 2738 | |
| 2739 | |
| 2740 | def build_parser() -> argparse.ArgumentParser: |
| 2741 | parser = argparse.ArgumentParser( |
| 2742 | description='PPT Master template and Strategist confirmation UI', |
| 2743 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 2744 | ) |
| 2745 | parser.add_argument('project_dir', help='Path to project directory') |
| 2746 | parser.add_argument( |
| 2747 | '--port', type=int, default=None, |
| 2748 | help=f'Exact port to listen on (default: first free port from {DEFAULT_PORT})', |
| 2749 | ) |
| 2750 | parser.add_argument('--no-browser', action='store_true', help='Do not auto-open browser') |
| 2751 | parser.add_argument( |
| 2752 | '--daemon', action='store_true', |
| 2753 | help='Start the server in the background; combine with --wait to block until confirmation', |
| 2754 | ) |
| 2755 | parser.add_argument( |
| 2756 | '--wait', action='store_true', |
| 2757 | help='With --daemon, wait until the active result.json stage is written', |
| 2758 | ) |
| 2759 | parser.add_argument( |
| 2760 | '--wait-only', action='store_true', |
| 2761 | help='Attach to the confirm server for this project and wait for an ' |
| 2762 | 'already-open page to write the requested receipt. If it is ' |
| 2763 | 'already persisted, return without recovery; otherwise recover a ' |
| 2764 | 'dead server on the recorded/default port so browser polling can resume.', |
| 2765 | ) |
| 2766 | parser.add_argument( |
| 2767 | '--wait-stage', default='final', metavar='{stage1,final}', |
| 2768 | help='Wait for this result.json stage (default: final). Use stage1 ' |
| 2769 | 'after opening the combined template/communication page.', |
| 2770 | ) |
| 2771 | parser.add_argument( |
| 2772 | '--wait-timeout', type=int, default=WAIT_TIMEOUT_DEFAULT, |
| 2773 | help=f'Seconds the wait caller blocks before returning (default: {WAIT_TIMEOUT_DEFAULT}; ' |
| 2774 | '0 = no limit). Kept under the caller\'s tool timeout; the detached server lives on.', |
| 2775 | ) |
| 2776 | parser.add_argument( |
| 2777 | '--timeout', type=int, default=900, |
| 2778 | help='Server idle timeout in seconds (default: 900; 0 = disabled)', |
| 2779 | ) |
| 2780 | parser.add_argument( |
| 2781 | '--shutdown', action='store_true', |
| 2782 | help='Stop a confirm server left running for this project, then exit ' |
| 2783 | '(idempotent). Run at the end of Step 4 so the page never lingers ' |
| 2784 | 'on its selected port before live preview starts.', |
| 2785 | ) |
| 2786 | parser.add_argument( |
| 2787 | '--complete-template-selection', action='store_true', |
| 2788 | help='Agent-only: after Stage 1, bind its template selection to a ready ' |
| 2789 | 'handoff. Template mode requires <project>/templates/design_spec.md.', |
| 2790 | ) |
| 2791 | parser.add_argument( |
| 2792 | '--reset-template-selection', action='store_true', |
| 2793 | help='Agent-only: remove exactly template_options.json, ' |
| 2794 | 'template_selection.json, and template_handoff.json before a ' |
| 2795 | 'fresh one-run UI lifecycle.', |
| 2796 | ) |
| 2797 | return parser |
| 2798 | |
| 2799 | |
| 2800 | def main(argv: Optional[list[str]] = None) -> int: |
| 2801 | parser = build_parser() |
| 2802 | args = parser.parse_args(argv) |
| 2803 | |
| 2804 | logging.basicConfig( |
| 2805 | level=logging.INFO, |
| 2806 | format='[%(asctime)s] [%(levelname)s] confirm_ui: %(message)s', |
| 2807 | datefmt='%H:%M:%S', |
| 2808 | ) |
| 2809 | |
| 2810 | if args.port is not None: |
| 2811 | try: |
| 2812 | args.port = _validate_port(args.port) |
| 2813 | except ValueError as exc: |
| 2814 | logger.error('%s', exc) |
| 2815 | return 2 |
| 2816 | |
| 2817 | project_path = Path(args.project_dir).resolve() |
| 2818 | if not project_path.is_dir(): |
| 2819 | logger.error('%s is not a directory', project_path) |
| 2820 | return 1 |
| 2821 | wait_stage = _stage_key(str(args.wait_stage).strip().lower()) |
| 2822 | if wait_stage not in {'stage1', 'final'}: |
| 2823 | logger.error('--wait-stage must be stage1 or final') |
| 2824 | return 2 |
| 2825 | |
| 2826 | template_control = ( |
| 2827 | args.complete_template_selection |
| 2828 | or args.reset_template_selection |
| 2829 | ) |
| 2830 | if template_control and ( |
| 2831 | args.daemon or args.wait or args.wait_only or args.shutdown |
| 2832 | ): |
| 2833 | logger.error( |
| 2834 | '--complete-template-selection/--reset-template-selection cannot be combined ' |
| 2835 | 'with server, wait, or shutdown actions' |
| 2836 | ) |
| 2837 | return 2 |
| 2838 | if args.complete_template_selection and args.reset_template_selection: |
| 2839 | logger.error( |
| 2840 | '--complete-template-selection and --reset-template-selection are ' |
| 2841 | 'mutually exclusive' |
| 2842 | ) |
| 2843 | return 2 |
| 2844 | if args.complete_template_selection: |
| 2845 | return _complete_template_selection(project_path) |
| 2846 | if args.reset_template_selection: |
| 2847 | return _reset_template_selection(project_path / CONFIRM_DIR_NAME) |
| 2848 | |
| 2849 | # Step 4 cleanup: stop any lingering confirm server and exit. Independent of |
| 2850 | # recommendation files (the page may never have been confirmed). |
| 2851 | if args.shutdown: |
| 2852 | return _shutdown_existing(project_path / LOCK_FILE_NAME) |
| 2853 | |
| 2854 | # Staged wait: attach to the server launched by --daemon and block until |
| 2855 | # the page writes the requested Strategist receipt. |
| 2856 | if args.wait_only: |
| 2857 | lock_file = project_path / LOCK_FILE_NAME |
| 2858 | confirm_dir = project_path / CONFIRM_DIR_NAME |
| 2859 | result_file = confirm_dir / RESULT_NAME |
| 2860 | wait_status = _wait_result_status(result_file, wait_stage) |
| 2861 | if wait_status is not None: |
| 2862 | return wait_status |
| 2863 | if wait_stage == 'stage1': |
| 2864 | readiness_error = _stage1_ready_error(confirm_dir) |
| 2865 | else: |
| 2866 | recommendations_file = _active_recommendations_path(confirm_dir) |
| 2867 | readiness_error = _stage2_ready_error( |
| 2868 | project_path, |
| 2869 | confirm_dir, |
| 2870 | recommendations_file, |
| 2871 | ) |
| 2872 | if readiness_error: |
| 2873 | logger.error( |
| 2874 | 'confirmation stage=%s is not ready: %s', |
| 2875 | wait_stage, |
| 2876 | readiness_error, |
| 2877 | ) |
| 2878 | return 1 |
| 2879 | if not _live_lock(lock_file): |
| 2880 | launch_error = _confirmation_launch_error(confirm_dir) |
| 2881 | if launch_error: |
| 2882 | logger.error('%s', launch_error) |
| 2883 | return 1 |
| 2884 | exact_port = args.port is not None |
| 2885 | recovery_port = ( |
| 2886 | args.port |
| 2887 | if exact_port |
| 2888 | else _preferred_recovery_port(lock_file, DEFAULT_PORT) |
| 2889 | ) |
| 2890 | try: |
| 2891 | _, actual_port, _ = _launch_background_server( |
| 2892 | project_path, |
| 2893 | preferred_port=recovery_port, |
| 2894 | exact_port=exact_port, |
| 2895 | idle_timeout=args.timeout, |
| 2896 | open_browser=False, |
| 2897 | ) |
| 2898 | except RuntimeError as exc: |
| 2899 | logger.error('%s', exc) |
| 2900 | return 1 |
| 2901 | if actual_port != recovery_port and not args.no_browser: |
| 2902 | webbrowser.open(_server_url(actual_port)) |
| 2903 | logger.info( |
| 2904 | 'recovered confirm UI for wait-only at %s; the browser polling should resume', |
| 2905 | _server_url(actual_port), |
| 2906 | ) |
| 2907 | return _wait_only_for_result( |
| 2908 | result_file, |
| 2909 | lock_file, |
| 2910 | args.wait_timeout, |
| 2911 | wait_stage, |
| 2912 | ) |
| 2913 | |
| 2914 | confirm_dir = project_path / CONFIRM_DIR_NAME |
| 2915 | launch_error = _confirmation_launch_error(confirm_dir) |
| 2916 | if launch_error: |
| 2917 | logger.error('%s', launch_error) |
| 2918 | return 1 |
| 2919 | |
| 2920 | if args.daemon: |
| 2921 | lock_file = project_path / LOCK_FILE_NAME |
| 2922 | existing = _read_lock(lock_file) |
| 2923 | if existing and _process_alive(_lock_pid(existing)): |
| 2924 | existing_pid = existing.get('pid', '?') |
| 2925 | existing_port = existing.get('port', '?') |
| 2926 | logger.error( |
| 2927 | 'confirm UI is already running for this project ' |
| 2928 | '(pid=%s, port=%s). Open http://%s:%s', |
| 2929 | existing_pid, existing_port, PUBLIC_HOST, existing_port, |
| 2930 | ) |
| 2931 | return 1 |
| 2932 | |
| 2933 | confirm_dir = project_path / CONFIRM_DIR_NAME |
| 2934 | result_file = confirm_dir / RESULT_NAME |
| 2935 | expected_stage = _expected_result_stage(confirm_dir) |
| 2936 | started_at = time.time() |
| 2937 | try: |
| 2938 | proc, port, _ = _launch_background_server( |
| 2939 | project_path, |
| 2940 | preferred_port=args.port if args.port is not None else DEFAULT_PORT, |
| 2941 | exact_port=args.port is not None, |
| 2942 | idle_timeout=args.timeout, |
| 2943 | open_browser=not args.no_browser, |
| 2944 | ) |
| 2945 | except RuntimeError as exc: |
| 2946 | logger.error('%s', exc) |
| 2947 | return 1 |
| 2948 | if args.wait: |
| 2949 | return _wait_for_result( |
| 2950 | result_file, |
| 2951 | proc, |
| 2952 | started_at, |
| 2953 | args.wait_timeout, |
| 2954 | expected_stage, |
| 2955 | ) |
| 2956 | return 0 |
| 2957 | |
| 2958 | try: |
| 2959 | port = args.port if args.port is not None else _find_free_port(DEFAULT_PORT) |
| 2960 | except RuntimeError as exc: |
| 2961 | logger.error('%s', exc) |
| 2962 | return 1 |
| 2963 | |
| 2964 | # Per-project mutual exclusion: refuse duplicate launches. Stale locks |
| 2965 | # (dead pid) are overwritten by _claim_lock. |
| 2966 | lock_file = project_path / LOCK_FILE_NAME |
| 2967 | existing = _claim_lock(lock_file, port) |
| 2968 | if existing: |
| 2969 | existing_pid = existing.get('pid', '?') |
| 2970 | existing_port = existing.get('port', '?') |
| 2971 | logger.error( |
| 2972 | 'confirm UI is already running for this project ' |
| 2973 | '(pid=%s, port=%s). Open http://%s:%s, or run: kill %s', |
| 2974 | existing_pid, existing_port, PUBLIC_HOST, existing_port, existing_pid, |
| 2975 | ) |
| 2976 | return 1 |
| 2977 | atexit.register(_release_lock, lock_file) |
| 2978 | |
| 2979 | def _on_sigterm(signum: int, _frame) -> None: |
| 2980 | logger.info('received signal %s, exiting', signum) |
| 2981 | sys.exit(0) |
| 2982 | try: |
| 2983 | signal.signal(signal.SIGTERM, _on_sigterm) |
| 2984 | except (ValueError, OSError): |
| 2985 | pass |
| 2986 | |
| 2987 | app = create_app( |
| 2988 | str(project_path), |
| 2989 | idle_timeout=args.timeout, |
| 2990 | lock_file=lock_file, |
| 2991 | server_port=port, |
| 2992 | ) |
| 2993 | |
| 2994 | url = _server_url(port) |
| 2995 | if not args.no_browser: |
| 2996 | _open_browser_async(url) |
| 2997 | |
| 2998 | logger.info('running at %s', url) |
| 2999 | logger.info('project: %s', project_path) |
| 3000 | logger.info('idle timeout: %ds (0 = disabled)', args.timeout) |
| 3001 | app.run(host=PUBLIC_HOST, port=port, debug=False) |
| 3002 | return 0 |
| 3003 | |
| 3004 | |
| 3005 | if __name__ == '__main__': |
| 3006 | raise SystemExit(main()) |
| 3007 |