3065 lines
115 KiB
Python
3065 lines
115 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
PPT Master - Template and Strategist confirmation UI Server (Steps 3-4)
|
|
|
|
Lightweight Flask backend for the Default template choice and interactive
|
|
Strategist confirmation page. Stage 1 combines template selection with the
|
|
communication contract; its submit writes ``template_selection.json`` and the
|
|
stage1-confirmed ``result.json`` in one request. After the agent applies the
|
|
choice and completes ``template_handoff.json``, final Stage 2 confirms the deck
|
|
solution and production plan.
|
|
|
|
This is the default confirmation surface. The chat fallback is used only when
|
|
the user explicitly requests chat-only confirmation or the browser launch
|
|
fails; it preserves the same staged semantics.
|
|
|
|
See scripts/docs/confirm_ui.md for the round-trip data contract and schema.
|
|
|
|
Usage:
|
|
python3 scripts/confirm_ui/server.py <project_dir>
|
|
|
|
Examples:
|
|
python3 scripts/confirm_ui/server.py projects/my-project
|
|
python3 scripts/confirm_ui/server.py projects/my-project --port 5051
|
|
python3 scripts/confirm_ui/server.py projects/my-project --no-browser
|
|
python3 scripts/confirm_ui/server.py projects/my-project --daemon
|
|
python3 scripts/confirm_ui/server.py projects/my-project --wait-only --wait-stage stage1
|
|
python3 scripts/confirm_ui/server.py projects/my-project --complete-template-selection
|
|
python3 scripts/confirm_ui/server.py projects/my-project --reset-template-selection
|
|
|
|
Dependencies:
|
|
flask>=3.0.0
|
|
"""
|
|
|
|
import argparse
|
|
import atexit
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
import webbrowser
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
from flask import Flask, jsonify, request, send_from_directory
|
|
|
|
# Local — sys.path injection for sibling module (code-style.md §3)
|
|
_SCRIPTS_DIR = Path(__file__).resolve().parent.parent
|
|
if str(_SCRIPTS_DIR) not in sys.path:
|
|
sys.path.insert(0, str(_SCRIPTS_DIR))
|
|
|
|
from console_encoding import configure_utf8_stdio # noqa: E402
|
|
from language_tags import ( # noqa: E402
|
|
LanguageTagError,
|
|
language_base,
|
|
normalize_language_tag,
|
|
)
|
|
from server_common import ( # noqa: E402
|
|
claim_lock as _claim_lock,
|
|
clear_lock as _clear_lock,
|
|
find_free_port as _find_free_port,
|
|
lock_pid as _lock_pid,
|
|
popen_detached as _popen_detached,
|
|
process_alive as _process_alive,
|
|
read_lock as _read_lock,
|
|
release_lock as _release_lock,
|
|
validate_port as _validate_port,
|
|
)
|
|
|
|
configure_utf8_stdio()
|
|
|
|
logger = logging.getLogger('confirm_ui')
|
|
|
|
# Per-project lock file. Lives at <project_path>/.confirm_ui.lock and matches
|
|
# the *.lock entry already in the repo .gitignore. Independent of the live
|
|
# preview lock so the two surfaces never collide.
|
|
LOCK_FILE_NAME = '.confirm_ui.lock'
|
|
|
|
# Round-trip/session files, all under <project_path>/confirm_ui/.
|
|
CONFIRM_DIR_NAME = 'confirm_ui'
|
|
RECOMMENDATION_STAGE_NAMES = {
|
|
1: 'recommendations.stage1.json',
|
|
2: 'recommendations.stage2.json',
|
|
}
|
|
RESULT_NAME = 'result.json'
|
|
SESSION_NAME = 'session.json'
|
|
TEMPLATE_OPTIONS_NAME = 'template_options.json'
|
|
TEMPLATE_SELECTION_NAME = 'template_selection.json'
|
|
TEMPLATE_HANDOFF_NAME = 'template_handoff.json'
|
|
TEMPLATE_SCHEMA_VERSION = 1
|
|
|
|
_PALETTE_ROLES = (
|
|
'background',
|
|
'secondary_bg',
|
|
'primary',
|
|
'accent',
|
|
'secondary_accent',
|
|
'body_text',
|
|
)
|
|
_TYPOGRAPHY_SIZE_ROLES = ('title', 'subtitle', 'annotation')
|
|
_HEX_COLOR_RE = re.compile(r'#?(?:[0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})\Z')
|
|
|
|
# Static option universe served at /api/catalogs (canvas synced live from config).
|
|
_SKILL_DIR = Path(__file__).resolve().parents[2]
|
|
_TEMPLATES_DIR = _SKILL_DIR / 'templates'
|
|
_CATALOGS_PATH = Path(__file__).resolve().parent / 'static' / 'catalogs.json'
|
|
_ICON_LIBRARY_DIR = _TEMPLATES_DIR / 'icons'
|
|
_AI_IMAGE_COMPARISON_DIR = _SKILL_DIR / 'references' / 'ai-image-comparison'
|
|
_TEMPLATE_LIBRARY_CONFIG = {
|
|
'brand': ('brands', 'brands_index.json'),
|
|
'style': ('styles', 'styles_index.json'),
|
|
'layout': ('layouts', 'layouts_index.json'),
|
|
'deck': ('decks', 'decks_index.json'),
|
|
}
|
|
_TEMPLATE_KIND_LINE_RE = re.compile(
|
|
r'''kind\s*:\s*(?:'([^']*)'|"([^"]*)"|([A-Za-z][A-Za-z0-9_-]*))'''
|
|
r'''\s*(?:#.*)?\Z'''
|
|
)
|
|
_ICON_PREVIEW_SAMPLES = {
|
|
'chunk-filled': ('home', 'chart-line', 'users', 'target'),
|
|
'tabler-filled': ('home', 'chart-dots', 'user', 'bulb'),
|
|
'tabler-outline': ('home', 'chart-line', 'users', 'bulb'),
|
|
'phosphor-duotone': ('house', 'chart-line', 'users', 'target'),
|
|
}
|
|
|
|
# Keep the long-standing Confirm UI entry port. Live preview uses a separate
|
|
# base range so stale preview tabs cannot address a later Confirm UI process.
|
|
# Concurrent Confirm UI sessions advance while explicit ``--port`` remains exact.
|
|
DEFAULT_PORT = 5050
|
|
PUBLIC_HOST = '127.0.0.1'
|
|
STARTUP_TIMEOUT = 10
|
|
|
|
# Default --wait budget, kept just under the 600s Bash-tool ceiling so the
|
|
# parent (waiting) command returns before the calling harness kills it. The
|
|
# detached child server keeps running on its own --timeout idle budget, so a
|
|
# slow user can still confirm after the wait returns; the caller re-checks
|
|
# result.json before falling back to chat.
|
|
WAIT_TIMEOUT_DEFAULT = 590
|
|
|
|
def _read_json_object(path: Path, retries: int = 2, delay: float = 0.08) -> dict:
|
|
"""Read a JSON object, retrying briefly around non-atomic external writes."""
|
|
last_error: Exception = ValueError('unknown JSON read error')
|
|
for attempt in range(retries + 1):
|
|
try:
|
|
data = json.loads(path.read_text(encoding='utf-8-sig'))
|
|
if isinstance(data, dict):
|
|
return data
|
|
raise ValueError(f'{path} top-level JSON value must be an object')
|
|
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
|
last_error = exc
|
|
if attempt < retries:
|
|
time.sleep(delay)
|
|
continue
|
|
raise last_error
|
|
raise last_error
|
|
|
|
|
|
def _write_json_atomic(path: Path, data: dict) -> None:
|
|
"""Write a JSON object with replace semantics so waiters never see a partial file."""
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp = path.with_name(f'.{path.name}.{os.getpid()}.tmp')
|
|
try:
|
|
tmp.write_text(
|
|
json.dumps(data, ensure_ascii=False, indent=2),
|
|
encoding='utf-8',
|
|
)
|
|
os.replace(tmp, path)
|
|
finally:
|
|
try:
|
|
tmp.unlink(missing_ok=True)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def _json_sha256(data: object) -> str:
|
|
"""Return a stable SHA-256 over one JSON-compatible value."""
|
|
canonical = json.dumps(
|
|
data,
|
|
ensure_ascii=False,
|
|
sort_keys=True,
|
|
separators=(',', ':'),
|
|
)
|
|
return hashlib.sha256(canonical.encode('utf-8')).hexdigest()
|
|
|
|
|
|
def _safe_template_id(template_id: object) -> bool:
|
|
"""Return whether an index key is one safe directory-segment id."""
|
|
return (
|
|
isinstance(template_id, str)
|
|
and re.fullmatch(r'\w[\w.-]*', template_id) is not None
|
|
)
|
|
|
|
|
|
def _template_design_spec_path(workspace_root: Path) -> Path:
|
|
"""Return the current or legacy Design Spec for one workspace root."""
|
|
current = workspace_root / 'templates' / 'design_spec.md'
|
|
if current.is_file():
|
|
return current
|
|
legacy = workspace_root / 'design_spec.md'
|
|
if legacy.is_file():
|
|
return legacy
|
|
raise ValueError(
|
|
'template workspace is missing templates/design_spec.md '
|
|
f'or legacy design_spec.md: {workspace_root}'
|
|
)
|
|
|
|
|
|
def _template_kind_from_spec(spec_path: Path) -> str:
|
|
"""Read one supported top-level ``kind`` from Design Spec frontmatter."""
|
|
try:
|
|
lines = spec_path.read_text(encoding='utf-8-sig').splitlines()
|
|
except OSError as exc:
|
|
raise ValueError(f'cannot read template Design Spec {spec_path}: {exc}') from exc
|
|
if not lines or lines[0] != '---':
|
|
raise ValueError(f'{spec_path} must start with YAML frontmatter')
|
|
try:
|
|
frontmatter_end = lines.index('---', 1)
|
|
except ValueError as exc:
|
|
raise ValueError(f'{spec_path} has unterminated YAML frontmatter') from exc
|
|
|
|
declared_kind = None
|
|
for line_number, line in enumerate(lines[1:frontmatter_end], start=2):
|
|
if re.match(r'^\s*kind\s*:', line) is None:
|
|
continue
|
|
if line != line.lstrip():
|
|
raise ValueError(
|
|
f'{spec_path}:{line_number} kind must be a top-level frontmatter field'
|
|
)
|
|
match = _TEMPLATE_KIND_LINE_RE.fullmatch(line)
|
|
if match is None:
|
|
raise ValueError(
|
|
f'{spec_path}:{line_number} has an invalid kind declaration'
|
|
)
|
|
if declared_kind is not None:
|
|
raise ValueError(f'{spec_path} frontmatter declares kind more than once')
|
|
declared_kind = next(value for value in match.groups() if value is not None)
|
|
|
|
if declared_kind is None:
|
|
raise ValueError(f'{spec_path} frontmatter must declare kind')
|
|
if declared_kind not in _TEMPLATE_LIBRARY_CONFIG:
|
|
supported = ', '.join(_TEMPLATE_LIBRARY_CONFIG)
|
|
raise ValueError(
|
|
f'{spec_path} frontmatter kind must be one of {supported}; '
|
|
f'got {declared_kind!r}'
|
|
)
|
|
return declared_kind
|
|
|
|
|
|
def _read_template_options_input(confirm_dir: Path) -> tuple[dict, list[Path]]:
|
|
"""Read and validate the agent-authored Step-3 template input."""
|
|
options_file = confirm_dir / TEMPLATE_OPTIONS_NAME
|
|
data = _read_json_object(options_file)
|
|
if type(data.get('schema_version')) is not int or data['schema_version'] != TEMPLATE_SCHEMA_VERSION:
|
|
raise ValueError(
|
|
f'{TEMPLATE_OPTIONS_NAME} schema_version must be {TEMPLATE_SCHEMA_VERSION}'
|
|
)
|
|
if data.get('phase') != 'template':
|
|
raise ValueError(f'{TEMPLATE_OPTIONS_NAME} phase must be template')
|
|
if data.get('default_mode') not in {'free_design', 'templates'}:
|
|
raise ValueError(
|
|
f'{TEMPLATE_OPTIONS_NAME} default_mode must be free_design or templates'
|
|
)
|
|
if 'lang' in data and (
|
|
not isinstance(data['lang'], str) or not data['lang'].strip()
|
|
):
|
|
raise ValueError(f'{TEMPLATE_OPTIONS_NAME} lang must be a non-empty string')
|
|
if 'explicit_workspace_roots' not in data:
|
|
raise ValueError(
|
|
f'{TEMPLATE_OPTIONS_NAME} must include explicit_workspace_roots'
|
|
)
|
|
raw_roots = data['explicit_workspace_roots']
|
|
if not isinstance(raw_roots, list):
|
|
raise ValueError(
|
|
f'{TEMPLATE_OPTIONS_NAME} explicit_workspace_roots must be an array'
|
|
)
|
|
if raw_roots and data['default_mode'] != 'templates':
|
|
raise ValueError(
|
|
f'{TEMPLATE_OPTIONS_NAME} default_mode must be templates when '
|
|
'explicit_workspace_roots is non-empty'
|
|
)
|
|
|
|
roots = []
|
|
seen = set()
|
|
for index, raw_root in enumerate(raw_roots):
|
|
if not isinstance(raw_root, str) or not raw_root.strip():
|
|
raise ValueError(
|
|
f'{TEMPLATE_OPTIONS_NAME} explicit_workspace_roots[{index}] '
|
|
'must be a non-empty string'
|
|
)
|
|
candidate = Path(raw_root)
|
|
if not candidate.is_absolute():
|
|
raise ValueError(
|
|
f'{TEMPLATE_OPTIONS_NAME} explicit_workspace_roots[{index}] '
|
|
'must be an absolute path'
|
|
)
|
|
try:
|
|
root = candidate.resolve()
|
|
except (OSError, RuntimeError) as exc:
|
|
raise ValueError(
|
|
f'cannot resolve explicit workspace root {raw_root}: {exc}'
|
|
) from exc
|
|
canonical = str(root)
|
|
if canonical in seen:
|
|
raise ValueError(
|
|
f'{TEMPLATE_OPTIONS_NAME} contains duplicate workspace root: {canonical}'
|
|
)
|
|
if not root.is_dir():
|
|
raise ValueError(f'explicit workspace root is not a directory: {canonical}')
|
|
_template_design_spec_path(root)
|
|
seen.add(canonical)
|
|
roots.append(root)
|
|
return data, roots
|
|
|
|
|
|
def _build_template_library() -> tuple[dict, dict[str, dict], dict[str, dict], dict]:
|
|
"""Build indexed library groups without scanning template directories."""
|
|
library = {}
|
|
candidates = {}
|
|
registered_roots = {}
|
|
index_contracts = {}
|
|
for kind, (directory_name, index_name) in _TEMPLATE_LIBRARY_CONFIG.items():
|
|
kind_dir = (_TEMPLATES_DIR / directory_name).resolve()
|
|
index_path = kind_dir / index_name
|
|
index_data = _read_json_object(index_path)
|
|
index_contracts[kind] = index_data
|
|
group = []
|
|
for template_id, metadata in index_data.items():
|
|
if not _safe_template_id(template_id):
|
|
raise ValueError(
|
|
f'{index_path} contains unsafe template id: {template_id!r}'
|
|
)
|
|
if not isinstance(metadata, dict):
|
|
raise ValueError(
|
|
f'{index_path} entry {template_id!r} must be an object'
|
|
)
|
|
workspace_root = (kind_dir / template_id).resolve()
|
|
if workspace_root.parent != kind_dir:
|
|
raise ValueError(
|
|
f'{index_path} entry {template_id!r} does not resolve to a '
|
|
f'direct child of {kind_dir}'
|
|
)
|
|
if not workspace_root.is_dir():
|
|
raise ValueError(
|
|
f'{index_path} entry {template_id!r} workspace does not exist: '
|
|
f'{workspace_root}'
|
|
)
|
|
spec_path = workspace_root / 'templates' / 'design_spec.md'
|
|
if not spec_path.is_file():
|
|
raise ValueError(
|
|
f'{index_path} entry {template_id!r} is missing {spec_path}'
|
|
)
|
|
declared_kind = _template_kind_from_spec(spec_path)
|
|
if declared_kind != kind:
|
|
raise ValueError(
|
|
f'{index_path} entry {template_id!r} declares kind '
|
|
f'{declared_kind!r}, expected {kind!r}'
|
|
)
|
|
summary = metadata.get('summary', '')
|
|
if not isinstance(summary, str):
|
|
raise ValueError(
|
|
f'{index_path} entry {template_id!r} summary must be a string'
|
|
)
|
|
key = f'library:{kind}:{template_id}'
|
|
if key in candidates:
|
|
raise ValueError(f'duplicate template candidate key: {key}')
|
|
candidate = {
|
|
'key': key,
|
|
'source': 'library',
|
|
'kind': kind,
|
|
'id': template_id,
|
|
'label': template_id,
|
|
'summary': summary,
|
|
'workspace_root': str(workspace_root),
|
|
}
|
|
canonical_root = candidate['workspace_root']
|
|
if canonical_root in registered_roots:
|
|
raise ValueError(
|
|
f'duplicate registered workspace root: {canonical_root}'
|
|
)
|
|
group.append(candidate)
|
|
candidates[key] = candidate
|
|
registered_roots[canonical_root] = candidate
|
|
library[kind] = group
|
|
return library, candidates, registered_roots, index_contracts
|
|
|
|
|
|
def _build_template_options(confirm_dir: Path) -> tuple[dict, dict[str, dict]]:
|
|
"""Return the browser contract and its server-owned candidate whitelist."""
|
|
source, explicit_roots = _read_template_options_input(confirm_dir)
|
|
library, candidates, registered_roots, index_contracts = _build_template_library()
|
|
explicit = []
|
|
suggested_keys = []
|
|
for root in explicit_roots:
|
|
canonical_root = str(root)
|
|
registered = registered_roots.get(canonical_root)
|
|
if registered is not None:
|
|
suggested_keys.append(registered['key'])
|
|
continue
|
|
digest = hashlib.sha256(canonical_root.encode('utf-8')).hexdigest()
|
|
key = f'explicit:{digest}'
|
|
if key in candidates:
|
|
raise ValueError(f'duplicate template candidate key: {key}')
|
|
kind = _template_kind_from_spec(_template_design_spec_path(root))
|
|
candidate = {
|
|
'key': key,
|
|
'source': 'explicit',
|
|
'kind': kind,
|
|
'label': root.name or canonical_root,
|
|
'workspace_root': canonical_root,
|
|
}
|
|
explicit.append(candidate)
|
|
candidates[key] = candidate
|
|
suggested_keys.append(key)
|
|
|
|
# One supplied exact root is an unambiguous convenience default. Multiple
|
|
# roots are candidates for the single-select controls, not an instruction
|
|
# to select all of them.
|
|
preselected_keys = suggested_keys if len(suggested_keys) == 1 else []
|
|
|
|
response = {
|
|
'schema_version': TEMPLATE_SCHEMA_VERSION,
|
|
'phase': 'template',
|
|
'default_mode': source['default_mode'],
|
|
'library': library,
|
|
'explicit': explicit,
|
|
'preselected_keys': preselected_keys,
|
|
}
|
|
if 'lang' in source:
|
|
response['lang'] = source['lang'].strip()
|
|
response['options_sha256'] = _json_sha256({
|
|
'schema_version': TEMPLATE_SCHEMA_VERSION,
|
|
'phase': 'template',
|
|
'default_mode': response['default_mode'],
|
|
'lang': response.get('lang'),
|
|
'explicit_workspace_roots': [str(root) for root in explicit_roots],
|
|
'library_indexes': index_contracts,
|
|
'library': library,
|
|
'explicit': explicit,
|
|
'preselected_keys': preselected_keys,
|
|
})
|
|
return response, candidates
|
|
|
|
|
|
def _template_selection_from_candidate(candidate: dict) -> dict:
|
|
"""Project one trusted browser candidate into the persisted selection."""
|
|
selection = {
|
|
'source': candidate['source'],
|
|
'kind': candidate['kind'],
|
|
}
|
|
if candidate['source'] == 'library':
|
|
selection['id'] = candidate['id']
|
|
selection['workspace_root'] = candidate['workspace_root']
|
|
return selection
|
|
|
|
|
|
def _template_selection_sha256(
|
|
mode: str,
|
|
selections: list[dict],
|
|
options_sha256: str,
|
|
) -> str:
|
|
"""Bind one resolved choice to the candidate/options contract it used."""
|
|
return _json_sha256({
|
|
'mode': mode,
|
|
'selections': selections,
|
|
'options_sha256': options_sha256,
|
|
})
|
|
|
|
|
|
def _validate_template_selection(data: dict) -> None:
|
|
"""Validate the server-owned template-selection receipt shape."""
|
|
expected_fields = {
|
|
'schema_version',
|
|
'phase',
|
|
'status',
|
|
'mode',
|
|
'selections',
|
|
'options_sha256',
|
|
'selection_sha256',
|
|
'confirmed_at',
|
|
}
|
|
if set(data) != expected_fields:
|
|
raise ValueError(f'{TEMPLATE_SELECTION_NAME} has invalid fields')
|
|
if type(data.get('schema_version')) is not int or data['schema_version'] != TEMPLATE_SCHEMA_VERSION:
|
|
raise ValueError(
|
|
f'{TEMPLATE_SELECTION_NAME} schema_version must be {TEMPLATE_SCHEMA_VERSION}'
|
|
)
|
|
if data.get('phase') != 'template':
|
|
raise ValueError(f'{TEMPLATE_SELECTION_NAME} phase must be template')
|
|
if data.get('status') != 'confirmed':
|
|
raise ValueError(f'{TEMPLATE_SELECTION_NAME} status must be confirmed')
|
|
mode = data.get('mode')
|
|
if mode not in {'free_design', 'templates'}:
|
|
raise ValueError(
|
|
f'{TEMPLATE_SELECTION_NAME} mode must be free_design or templates'
|
|
)
|
|
selections = data.get('selections')
|
|
if not isinstance(selections, list):
|
|
raise ValueError(f'{TEMPLATE_SELECTION_NAME} selections must be an array')
|
|
if mode == 'free_design' and selections:
|
|
raise ValueError('free_design cannot carry template selections')
|
|
if mode == 'templates' and not selections:
|
|
raise ValueError('templates mode requires at least one selection')
|
|
|
|
options_sha256 = data.get('options_sha256')
|
|
selection_sha256 = data.get('selection_sha256')
|
|
if not isinstance(options_sha256, str) or not re.fullmatch(r'[0-9a-f]{64}', options_sha256):
|
|
raise ValueError(f'{TEMPLATE_SELECTION_NAME} options_sha256 is invalid')
|
|
if not isinstance(selection_sha256, str) or not re.fullmatch(r'[0-9a-f]{64}', selection_sha256):
|
|
raise ValueError(f'{TEMPLATE_SELECTION_NAME} selection_sha256 is invalid')
|
|
|
|
seen_roots = set()
|
|
seen_library_kinds = set()
|
|
explicit_count = 0
|
|
for index, selection in enumerate(selections):
|
|
if not isinstance(selection, dict):
|
|
raise ValueError(
|
|
f'{TEMPLATE_SELECTION_NAME} selections[{index}] must be an object'
|
|
)
|
|
source = selection.get('source')
|
|
if source not in {'library', 'explicit'}:
|
|
raise ValueError(
|
|
f'{TEMPLATE_SELECTION_NAME} selections[{index}] has invalid source'
|
|
)
|
|
kind = selection.get('kind')
|
|
if kind not in _TEMPLATE_LIBRARY_CONFIG:
|
|
raise ValueError(
|
|
f'{TEMPLATE_SELECTION_NAME} selections[{index}] has invalid kind'
|
|
)
|
|
expected_keys = {'source', 'kind', 'workspace_root'}
|
|
if source == 'library':
|
|
expected_keys.add('id')
|
|
if kind in seen_library_kinds:
|
|
raise ValueError(
|
|
'template selection allows at most one library workspace '
|
|
f'for kind {kind!r}'
|
|
)
|
|
seen_library_kinds.add(kind)
|
|
if not _safe_template_id(selection.get('id')):
|
|
raise ValueError(
|
|
f'{TEMPLATE_SELECTION_NAME} selections[{index}] has invalid id'
|
|
)
|
|
else:
|
|
explicit_count += 1
|
|
if explicit_count > 1:
|
|
raise ValueError(
|
|
'template selection allows at most one explicit workspace'
|
|
)
|
|
if set(selection) != expected_keys:
|
|
raise ValueError(
|
|
f'{TEMPLATE_SELECTION_NAME} selections[{index}] has invalid fields'
|
|
)
|
|
workspace_root = selection.get('workspace_root')
|
|
if not isinstance(workspace_root, str) or not workspace_root:
|
|
raise ValueError(
|
|
f'{TEMPLATE_SELECTION_NAME} selections[{index}] '
|
|
'workspace_root must be a non-empty string'
|
|
)
|
|
root = Path(workspace_root)
|
|
if not root.is_absolute() or str(root.resolve()) != workspace_root:
|
|
raise ValueError(
|
|
f'{TEMPLATE_SELECTION_NAME} selections[{index}] '
|
|
'workspace_root must be a canonical absolute path'
|
|
)
|
|
if workspace_root in seen_roots:
|
|
raise ValueError(
|
|
f'{TEMPLATE_SELECTION_NAME} contains duplicate workspace root: '
|
|
f'{workspace_root}'
|
|
)
|
|
seen_roots.add(workspace_root)
|
|
if not isinstance(data.get('confirmed_at'), str) or not data['confirmed_at']:
|
|
raise ValueError(
|
|
f'{TEMPLATE_SELECTION_NAME} confirmed_at must be a non-empty string'
|
|
)
|
|
expected_selection_sha256 = _template_selection_sha256(
|
|
mode,
|
|
selections,
|
|
options_sha256,
|
|
)
|
|
if selection_sha256 != expected_selection_sha256:
|
|
raise ValueError(f'{TEMPLATE_SELECTION_NAME} selection_sha256 does not match')
|
|
|
|
|
|
def _read_template_selection(selection_file: Path) -> dict:
|
|
"""Read a selection and revalidate it against current indexed options."""
|
|
data = _read_json_object(selection_file)
|
|
_validate_template_selection(data)
|
|
options_file = selection_file.parent / TEMPLATE_OPTIONS_NAME
|
|
if not _is_newer(selection_file, options_file):
|
|
raise ValueError(
|
|
f'{TEMPLATE_SELECTION_NAME} must be confirmed after the current '
|
|
f'{TEMPLATE_OPTIONS_NAME}'
|
|
)
|
|
options, candidates = _build_template_options(selection_file.parent)
|
|
if data['options_sha256'] != options['options_sha256']:
|
|
raise ValueError(
|
|
f'{TEMPLATE_SELECTION_NAME} options_sha256 no longer matches current options'
|
|
)
|
|
available_selections = {
|
|
_json_sha256(_template_selection_from_candidate(candidate))
|
|
for candidate in candidates.values()
|
|
}
|
|
for selection in data['selections']:
|
|
if _json_sha256(selection) not in available_selections:
|
|
raise ValueError(
|
|
f'{TEMPLATE_SELECTION_NAME} references an unavailable candidate'
|
|
)
|
|
return data
|
|
|
|
|
|
def _validate_template_handoff(data: dict) -> None:
|
|
"""Validate the agent-owned Step-3 completion receipt shape."""
|
|
expected_fields = {
|
|
'schema_version',
|
|
'phase',
|
|
'status',
|
|
'mode',
|
|
'selection_sha256',
|
|
'completed_at',
|
|
}
|
|
if set(data) != expected_fields:
|
|
raise ValueError(f'{TEMPLATE_HANDOFF_NAME} has invalid fields')
|
|
if type(data.get('schema_version')) is not int or data['schema_version'] != TEMPLATE_SCHEMA_VERSION:
|
|
raise ValueError(
|
|
f'{TEMPLATE_HANDOFF_NAME} schema_version must be {TEMPLATE_SCHEMA_VERSION}'
|
|
)
|
|
if data.get('phase') != 'template':
|
|
raise ValueError(f'{TEMPLATE_HANDOFF_NAME} phase must be template')
|
|
if data.get('status') != 'ready':
|
|
raise ValueError(f'{TEMPLATE_HANDOFF_NAME} status must be ready')
|
|
if data.get('mode') not in {'free_design', 'templates'}:
|
|
raise ValueError(
|
|
f'{TEMPLATE_HANDOFF_NAME} mode must be free_design or templates'
|
|
)
|
|
selection_sha256 = data.get('selection_sha256')
|
|
if not isinstance(selection_sha256, str) or not re.fullmatch(r'[0-9a-f]{64}', selection_sha256):
|
|
raise ValueError(f'{TEMPLATE_HANDOFF_NAME} selection_sha256 is invalid')
|
|
if not isinstance(data.get('completed_at'), str) or not data['completed_at']:
|
|
raise ValueError(
|
|
f'{TEMPLATE_HANDOFF_NAME} completed_at must be a non-empty string'
|
|
)
|
|
|
|
|
|
def _read_template_handoff(project_path: Path, handoff_file: Path) -> dict:
|
|
"""Read a handoff and bind it to the current selection and installed state."""
|
|
data = _read_json_object(handoff_file)
|
|
_validate_template_handoff(data)
|
|
selection_file = handoff_file.parent / TEMPLATE_SELECTION_NAME
|
|
selection = _read_template_selection(selection_file)
|
|
if not _is_newer(handoff_file, selection_file):
|
|
raise ValueError(
|
|
f'{TEMPLATE_HANDOFF_NAME} must be completed after the current '
|
|
f'{TEMPLATE_SELECTION_NAME}'
|
|
)
|
|
if data['mode'] != selection['mode']:
|
|
raise ValueError(f'{TEMPLATE_HANDOFF_NAME} mode does not match selection')
|
|
if data['selection_sha256'] != selection['selection_sha256']:
|
|
raise ValueError(
|
|
f'{TEMPLATE_HANDOFF_NAME} selection_sha256 does not match selection'
|
|
)
|
|
if data['mode'] == 'templates':
|
|
if not _installed_template_specs(project_path):
|
|
raise ValueError(
|
|
f'{TEMPLATE_HANDOFF_NAME} requires at least one installed '
|
|
f'template spec: {project_path / "templates"}/'
|
|
'design_spec.<kind>.<id>.md'
|
|
)
|
|
return data
|
|
|
|
|
|
def _installed_template_specs(project_path: Path) -> list[Path]:
|
|
"""Return every template spec installed into the project by the apply stage.
|
|
|
|
The apply stage installs one file per selected workspace, named
|
|
``design_spec.<kind>.<id>.md``. A bare ``design_spec.md`` under
|
|
``templates/`` means the project is itself a Create Template workspace and
|
|
is deliberately excluded here.
|
|
"""
|
|
return sorted(
|
|
path
|
|
for path in (project_path / 'templates').glob('design_spec.*.md')
|
|
if path.is_file()
|
|
)
|
|
|
|
|
|
def _complete_template_selection(project_path: Path) -> int:
|
|
"""Write the agent-owned handoff after Stage 1 and template application."""
|
|
confirm_dir = project_path / CONFIRM_DIR_NAME
|
|
selection_file = confirm_dir / TEMPLATE_SELECTION_NAME
|
|
if not selection_file.exists():
|
|
logger.error('%s not found — the user must confirm Stage 1 first', selection_file)
|
|
return 1
|
|
try:
|
|
selection = _read_template_selection(selection_file)
|
|
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
|
logger.error('cannot complete template selection: %s', exc)
|
|
return 1
|
|
|
|
result_file = confirm_dir / RESULT_NAME
|
|
if _result_stage(result_file) != 'stage1':
|
|
logger.error(
|
|
'cannot complete template selection before Stage 1 writes a '
|
|
'stage1-confirmed result'
|
|
)
|
|
return 1
|
|
if selection['mode'] == 'templates':
|
|
if not _installed_template_specs(project_path):
|
|
logger.error(
|
|
'cannot complete template selection before template apply '
|
|
'writes %s/design_spec.<kind>.<id>.md',
|
|
project_path / 'templates',
|
|
)
|
|
return 1
|
|
|
|
handoff_file = confirm_dir / TEMPLATE_HANDOFF_NAME
|
|
if handoff_file.exists():
|
|
try:
|
|
existing = _read_template_handoff(project_path, handoff_file)
|
|
except (OSError, json.JSONDecodeError, ValueError):
|
|
existing = None
|
|
if (
|
|
existing is not None
|
|
and existing['selection_sha256'] == selection['selection_sha256']
|
|
and _is_newer(handoff_file, result_file)
|
|
):
|
|
logger.info('template selection already complete: %s', handoff_file)
|
|
return 0
|
|
|
|
handoff = {
|
|
'schema_version': TEMPLATE_SCHEMA_VERSION,
|
|
'phase': 'template',
|
|
'status': 'ready',
|
|
'mode': selection['mode'],
|
|
'selection_sha256': selection['selection_sha256'],
|
|
'completed_at': time.strftime('%Y-%m-%dT%H:%M:%S'),
|
|
}
|
|
_validate_template_handoff(handoff)
|
|
_write_json_atomic(handoff_file, handoff)
|
|
logger.info('template selection handoff written to %s', handoff_file)
|
|
return 0
|
|
|
|
|
|
def _reset_template_selection(confirm_dir: Path) -> int:
|
|
"""Remove one-run template-selection artifacts before a fresh UI lifecycle."""
|
|
removed = []
|
|
for filename in (
|
|
TEMPLATE_HANDOFF_NAME,
|
|
TEMPLATE_SELECTION_NAME,
|
|
TEMPLATE_OPTIONS_NAME,
|
|
):
|
|
path = confirm_dir / filename
|
|
try:
|
|
path.unlink()
|
|
removed.append(str(path))
|
|
except FileNotFoundError:
|
|
continue
|
|
except OSError as exc:
|
|
logger.error('cannot remove %s: %s', path, exc)
|
|
return 1
|
|
if removed:
|
|
logger.info('reset template selection artifacts: %s', ', '.join(removed))
|
|
else:
|
|
logger.info('template selection artifacts already absent')
|
|
return 0
|
|
|
|
|
|
def _stage1_ready_error(confirm_dir: Path) -> Optional[str]:
|
|
"""Return why the combined template/Stage-1 page cannot be exposed."""
|
|
options_file = confirm_dir / TEMPLATE_OPTIONS_NAME
|
|
if not options_file.exists():
|
|
return f'{TEMPLATE_OPTIONS_NAME} not found'
|
|
try:
|
|
_build_template_options(confirm_dir)
|
|
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
|
return f'invalid template options: {exc}'
|
|
|
|
stage1_file = confirm_dir / RECOMMENDATION_STAGE_NAMES[1]
|
|
if not stage1_file.exists():
|
|
return f'{stage1_file.name} not found'
|
|
try:
|
|
stage1_data = _read_json_object(stage1_file)
|
|
if _recommendation_stage(stage1_data) != 1:
|
|
return f'{stage1_file.name} does not declare stage1'
|
|
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
|
return f'cannot validate Stage 1 recommendations: {exc}'
|
|
|
|
result_file = confirm_dir / RESULT_NAME
|
|
if result_file.exists():
|
|
if not _is_newer(options_file, result_file):
|
|
return f'{TEMPLATE_OPTIONS_NAME} must be newer than the prior {RESULT_NAME}'
|
|
if not _is_newer(stage1_file, result_file):
|
|
return f'{stage1_file.name} must be newer than the prior {RESULT_NAME}'
|
|
return None
|
|
|
|
|
|
def _stage2_ready_error(
|
|
project_path: Path,
|
|
confirm_dir: Path,
|
|
recommendations_file: Optional[Path] = None,
|
|
) -> Optional[str]:
|
|
"""Return why final Stage 2 cannot follow the confirmed template choice."""
|
|
selection_file = confirm_dir / TEMPLATE_SELECTION_NAME
|
|
if not selection_file.exists():
|
|
return f'{TEMPLATE_SELECTION_NAME} not found after Stage 1 confirmation'
|
|
try:
|
|
_read_template_selection(selection_file)
|
|
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
|
return f'invalid template selection: {exc}'
|
|
|
|
result_file = confirm_dir / RESULT_NAME
|
|
if _result_stage(result_file) != 'stage1':
|
|
return f'{RESULT_NAME} does not contain a stage1-confirmed result'
|
|
|
|
handoff_file = confirm_dir / TEMPLATE_HANDOFF_NAME
|
|
if not handoff_file.exists():
|
|
return f'{TEMPLATE_HANDOFF_NAME} not found after template application'
|
|
try:
|
|
_read_template_handoff(project_path, handoff_file)
|
|
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
|
return f'invalid template handoff: {exc}'
|
|
if not _is_newer(handoff_file, result_file):
|
|
return f'{TEMPLATE_HANDOFF_NAME} must be newer than the Stage 1 {RESULT_NAME}'
|
|
|
|
if recommendations_file is not None and not _is_newer(
|
|
recommendations_file,
|
|
handoff_file,
|
|
):
|
|
return f'{recommendations_file.name} must be newer than {TEMPLATE_HANDOFF_NAME}'
|
|
return None
|
|
|
|
|
|
def _resolve_template_confirmation(
|
|
payload: dict,
|
|
candidates: dict[str, dict],
|
|
options_sha256: str,
|
|
) -> dict:
|
|
"""Resolve browser keys against the current server-owned candidate set."""
|
|
required_fields = {'mode', 'selection_keys'}
|
|
if set(payload) != required_fields:
|
|
raise ValueError('template confirmation accepts only mode and selection_keys')
|
|
mode = payload.get('mode')
|
|
if mode not in {'free_design', 'templates'}:
|
|
raise ValueError('mode must be free_design or templates')
|
|
selection_keys = payload.get('selection_keys')
|
|
if not isinstance(selection_keys, list):
|
|
raise ValueError('selection_keys must be an array')
|
|
if any(not isinstance(key, str) or not key for key in selection_keys):
|
|
raise ValueError('selection_keys must contain non-empty strings')
|
|
if len(selection_keys) != len(set(selection_keys)):
|
|
raise ValueError('selection_keys must not contain duplicates')
|
|
if mode == 'free_design' and selection_keys:
|
|
raise ValueError('free_design cannot carry selection_keys')
|
|
if mode == 'templates' and not selection_keys:
|
|
raise ValueError('templates mode requires at least one selection key')
|
|
|
|
unknown_keys = [key for key in selection_keys if key not in candidates]
|
|
if unknown_keys:
|
|
raise ValueError(
|
|
'selection_keys contains unavailable candidate keys: '
|
|
+ ', '.join(unknown_keys)
|
|
)
|
|
selections = sorted([
|
|
_template_selection_from_candidate(candidates[key])
|
|
for key in selection_keys
|
|
], key=lambda item: (
|
|
item['source'],
|
|
item.get('kind', ''),
|
|
item.get('id', ''),
|
|
item['workspace_root'],
|
|
))
|
|
selection_sha256 = _template_selection_sha256(
|
|
mode,
|
|
selections,
|
|
options_sha256,
|
|
)
|
|
receipt = {
|
|
'schema_version': TEMPLATE_SCHEMA_VERSION,
|
|
'phase': 'template',
|
|
'status': 'confirmed',
|
|
'mode': mode,
|
|
'selections': selections,
|
|
'options_sha256': options_sha256,
|
|
'selection_sha256': selection_sha256,
|
|
'confirmed_at': time.strftime('%Y-%m-%dT%H:%M:%S'),
|
|
}
|
|
_validate_template_selection(receipt)
|
|
return receipt
|
|
|
|
|
|
def _confirmation_launch_error(confirm_dir: Path) -> Optional[str]:
|
|
"""Return why the current Default UI lifecycle cannot launch."""
|
|
options_file = confirm_dir / TEMPLATE_OPTIONS_NAME
|
|
result_file = confirm_dir / RESULT_NAME
|
|
result_stage = _result_stage(result_file)
|
|
fresh_template_options = _fresh_template_restart(confirm_dir)
|
|
if (
|
|
result_file.exists()
|
|
and result_stage is None
|
|
and not fresh_template_options
|
|
):
|
|
return (
|
|
f'{RESULT_NAME} is not a current stage1/final receipt — reset the '
|
|
f'template selection and write fresh {TEMPLATE_OPTIONS_NAME} before '
|
|
'starting a new run'
|
|
)
|
|
if (
|
|
result_stage == 'final'
|
|
and not fresh_template_options
|
|
):
|
|
return (
|
|
'the previous Confirm UI run is complete — reset the template '
|
|
f'selection and write fresh {TEMPLATE_OPTIONS_NAME} before starting a new one'
|
|
)
|
|
if options_file.exists():
|
|
try:
|
|
_build_template_options(confirm_dir)
|
|
selection_file = confirm_dir / TEMPLATE_SELECTION_NAME
|
|
if result_stage == 'stage1' and not selection_file.exists():
|
|
return (
|
|
f'{TEMPLATE_SELECTION_NAME} not found for the confirmed '
|
|
'Stage 1 result'
|
|
)
|
|
if selection_file.exists():
|
|
_read_template_selection(selection_file)
|
|
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
|
return f'invalid template selection input: {exc}'
|
|
if result_stage is None or fresh_template_options:
|
|
stage1_error = _stage1_ready_error(confirm_dir)
|
|
if stage1_error:
|
|
return f'Stage 1 is not ready: {stage1_error}'
|
|
return None
|
|
return f'{options_file} not found — write template options before launch'
|
|
|
|
|
|
def _server_url(port: int, path: str = '') -> str:
|
|
"""Return the loopback URL shown to users and used by readiness probes."""
|
|
suffix = path if path.startswith('/') or not path else f'/{path}'
|
|
return f'http://{PUBLIC_HOST}:{port}{suffix}'
|
|
|
|
|
|
def _wait_for_server_ready(
|
|
port: int,
|
|
proc: subprocess.Popen,
|
|
project_path: Path,
|
|
timeout: int = STARTUP_TIMEOUT,
|
|
) -> bool:
|
|
"""Wait until this project's detached confirm server is accepting requests."""
|
|
deadline = time.time() + timeout
|
|
last_error = ''
|
|
health_url = _server_url(port, '/api/health')
|
|
while time.time() < deadline:
|
|
returncode = proc.poll()
|
|
if returncode is not None:
|
|
logger.error('confirm UI exited during startup (code=%s)', returncode)
|
|
return False
|
|
try:
|
|
with urllib.request.urlopen(health_url, timeout=1) as resp:
|
|
data = json.load(resp)
|
|
if (
|
|
resp.status == 200
|
|
and isinstance(data, dict)
|
|
and data.get('service') == 'confirm_ui'
|
|
and data.get('project') == str(project_path)
|
|
and data.get('pid') == proc.pid
|
|
):
|
|
return True
|
|
last_error = 'health response belongs to another service or project'
|
|
except (OSError, ValueError, urllib.error.URLError) as exc:
|
|
last_error = str(exc)
|
|
time.sleep(0.2)
|
|
logger.error(
|
|
'confirm UI did not become ready at %s within %ss%s',
|
|
health_url,
|
|
timeout,
|
|
f' (last error: {last_error})' if last_error else '',
|
|
)
|
|
return False
|
|
|
|
|
|
def _launch_background_server(
|
|
project_path: Path,
|
|
*,
|
|
preferred_port: int,
|
|
exact_port: bool,
|
|
idle_timeout: int,
|
|
open_browser: bool,
|
|
) -> tuple[subprocess.Popen, int, Path]:
|
|
"""Start the confirm server child and wait until it is reachable."""
|
|
confirm_dir = project_path / CONFIRM_DIR_NAME
|
|
confirm_dir.mkdir(parents=True, exist_ok=True)
|
|
log_path = confirm_dir / 'server.log'
|
|
port = preferred_port if exact_port else _find_free_port(preferred_port)
|
|
cmd = [
|
|
sys.executable,
|
|
str(Path(__file__).resolve()),
|
|
str(project_path),
|
|
'--port',
|
|
str(port),
|
|
'--timeout',
|
|
str(idle_timeout),
|
|
'--no-browser',
|
|
]
|
|
with log_path.open('a', encoding='utf-8') as log:
|
|
proc = _popen_detached(
|
|
cmd,
|
|
stdout=log,
|
|
stderr=subprocess.STDOUT,
|
|
stdin=subprocess.DEVNULL,
|
|
logger=logger,
|
|
)
|
|
logger.info('log: %s', log_path)
|
|
if not _wait_for_server_ready(port, proc, project_path):
|
|
if proc.poll() is None:
|
|
proc.terminate()
|
|
raise RuntimeError(f'confirm UI failed to become reachable: {_server_url(port)}')
|
|
_sync_session_state(confirm_dir, server_port=port, event='server-ready')
|
|
url = _server_url(port)
|
|
logger.info('started confirm UI in background: %s (pid=%s)', url, proc.pid)
|
|
if open_browser:
|
|
webbrowser.open(url)
|
|
return proc, port, log_path
|
|
|
|
|
|
def _live_lock(lock_file: Path) -> Optional[dict]:
|
|
"""Return a live lock; stale entries are overwritten by the recovered child."""
|
|
existing = _read_lock(lock_file)
|
|
if not existing:
|
|
return None
|
|
if _process_alive(_lock_pid(existing)):
|
|
return existing
|
|
return None
|
|
|
|
|
|
def _preferred_recovery_port(lock_file: Path, fallback: int) -> int:
|
|
"""Prefer a stale lock's port so an already-open browser can reconnect."""
|
|
existing = _read_lock(lock_file)
|
|
try:
|
|
port = int((existing or {}).get('port', 0) or 0)
|
|
return _validate_port(port) if port else fallback
|
|
except (TypeError, ValueError):
|
|
return fallback
|
|
|
|
|
|
def _open_browser_async(url: str, delay: float = 0.4) -> None:
|
|
"""Open the browser after Flask has had a moment to bind its socket."""
|
|
def _open() -> None:
|
|
time.sleep(delay)
|
|
webbrowser.open(url)
|
|
|
|
threading.Thread(target=_open, daemon=True).start()
|
|
|
|
|
|
def _wait_for_result(
|
|
result_file: Path,
|
|
proc: subprocess.Popen,
|
|
started_at: float,
|
|
timeout: int,
|
|
expected_stage: str,
|
|
) -> int:
|
|
"""Wait until this launch writes a fresh result file or the server exits."""
|
|
logger.info('waiting for browser confirmation...')
|
|
deadline = None if timeout <= 0 else time.time() + timeout
|
|
while True:
|
|
if result_file.exists():
|
|
try:
|
|
if result_file.stat().st_mtime >= started_at:
|
|
actual_stage = _result_stage(result_file)
|
|
if actual_stage != expected_stage:
|
|
logger.error(
|
|
'confirmation stage mismatch: expected %s, found %s',
|
|
expected_stage,
|
|
actual_stage or 'invalid/absent',
|
|
)
|
|
return 2
|
|
logger.info('confirmation received: %s', result_file)
|
|
try:
|
|
proc.wait(timeout=3)
|
|
except subprocess.TimeoutExpired:
|
|
pass
|
|
return 0
|
|
except OSError:
|
|
pass
|
|
|
|
returncode = proc.poll()
|
|
if returncode is not None:
|
|
logger.error('confirm UI exited before a fresh result was written')
|
|
return returncode or 1
|
|
|
|
if deadline is not None and time.time() >= deadline:
|
|
logger.error(
|
|
'timed out waiting for browser confirmation — the page is still '
|
|
'open; re-check %s before falling back to chat', result_file,
|
|
)
|
|
return 124
|
|
|
|
time.sleep(0.5)
|
|
|
|
|
|
def _result_stage(result_file: Path) -> Optional[str]:
|
|
"""Return the current result stage (Stage 1 or final), or None."""
|
|
if not result_file.is_file():
|
|
return None
|
|
try:
|
|
data = _read_json_object(result_file)
|
|
except (OSError, json.JSONDecodeError, ValueError):
|
|
return None
|
|
stage = _stage_key(data.get('stage'))
|
|
status = data.get('status')
|
|
if stage == 'stage1' and status == 'stage1-confirmed':
|
|
return stage
|
|
if stage == 'final' and status == 'confirmed':
|
|
return stage
|
|
return None
|
|
|
|
|
|
def _stage_key(value: object) -> Optional[str]:
|
|
"""Normalize the two current recommendation/result stage names."""
|
|
if value is None:
|
|
return None
|
|
raw = str(value).strip().lower()
|
|
if raw == 'stage1':
|
|
return 'stage1'
|
|
if raw == 'stage2':
|
|
return 'stage2'
|
|
if raw == 'final':
|
|
return 'final'
|
|
return None
|
|
|
|
|
|
def _recommendation_stage(data: dict) -> int:
|
|
"""Return a recommendation payload's declared stage."""
|
|
stage = _stage_key(data.get('stage'))
|
|
if stage == 'stage1':
|
|
return 1
|
|
if stage == 'stage2':
|
|
return 2
|
|
return 0
|
|
|
|
|
|
def _stage_name(number: Optional[int]) -> Optional[str]:
|
|
"""Return the canonical stage key for a stage number."""
|
|
if number == 1:
|
|
return 'stage1'
|
|
if number == 2:
|
|
return 'stage2'
|
|
return None
|
|
|
|
|
|
def _result_stage_number(stage: Optional[str]) -> int:
|
|
"""Return result progression: stage1=1, final=2."""
|
|
if stage == 'stage1':
|
|
return 1
|
|
if stage == 'final':
|
|
return 2
|
|
return 0
|
|
|
|
|
|
def _expected_recommendation_stage(result_stage: Optional[str]) -> int:
|
|
"""Return the recommendation stage that follows the current result."""
|
|
if result_stage in {'stage1', 'final'}:
|
|
return 2
|
|
return 1
|
|
|
|
|
|
def _stage_recommendations_path(confirm_dir: Path, stage_number: int) -> Path:
|
|
"""Return the stage-specific recommendation path for one handoff."""
|
|
return confirm_dir / RECOMMENDATION_STAGE_NAMES[stage_number]
|
|
|
|
|
|
def _is_newer(path: Path, baseline: Path) -> bool:
|
|
"""Return whether ``path`` was authored after ``baseline``."""
|
|
try:
|
|
return path.stat().st_mtime_ns > baseline.stat().st_mtime_ns
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def _fresh_template_restart(confirm_dir: Path) -> bool:
|
|
"""Return whether fresh template options start a new UI session."""
|
|
return _is_newer(
|
|
confirm_dir / TEMPLATE_OPTIONS_NAME,
|
|
confirm_dir / RESULT_NAME,
|
|
)
|
|
|
|
|
|
def _active_recommendations_path(confirm_dir: Path) -> Path:
|
|
"""Resolve the recommendation file for the current two-stage session."""
|
|
if _fresh_template_restart(confirm_dir):
|
|
return _stage_recommendations_path(confirm_dir, 1)
|
|
result_stage = _result_stage(confirm_dir / RESULT_NAME)
|
|
expected_stage = _expected_recommendation_stage(result_stage)
|
|
return _stage_recommendations_path(confirm_dir, expected_stage)
|
|
|
|
|
|
def _read_active_recommendations(
|
|
confirm_dir: Path,
|
|
*,
|
|
retries: int = 2,
|
|
) -> tuple[Path, dict]:
|
|
"""Read and validate the recommendation payload active for this stage."""
|
|
rec_file = _active_recommendations_path(confirm_dir)
|
|
data = _read_json_object(rec_file, retries=retries)
|
|
for stage_number, filename in RECOMMENDATION_STAGE_NAMES.items():
|
|
if rec_file.name != filename:
|
|
continue
|
|
actual_stage = _recommendation_stage(data)
|
|
if actual_stage != stage_number:
|
|
raise ValueError(
|
|
f'{filename} must declare stage={_stage_name(stage_number)}, '
|
|
f'found {_stage_name(actual_stage) or "absent"}'
|
|
)
|
|
if stage_number == 2:
|
|
result_file = confirm_dir / RESULT_NAME
|
|
if _result_stage(result_file) == 'stage1':
|
|
if not _is_newer(rec_file, result_file):
|
|
raise ValueError(
|
|
f'{filename} must be authored after the current '
|
|
'Stage-1 confirmation'
|
|
)
|
|
production_error = _stage2_production_recommendations_error(data)
|
|
if production_error:
|
|
raise ValueError(production_error)
|
|
break
|
|
return rec_file, data
|
|
|
|
|
|
def _template_confirmation_required(project_path: Path) -> bool:
|
|
"""Return whether the confirmed project state has an active template."""
|
|
confirm_dir = project_path / CONFIRM_DIR_NAME
|
|
handoff = _read_template_handoff(
|
|
project_path,
|
|
confirm_dir / TEMPLATE_HANDOFF_NAME,
|
|
)
|
|
return handoff['mode'] == 'templates'
|
|
|
|
|
|
def _template_stage2_error(
|
|
recommendations: dict,
|
|
*,
|
|
template_required: bool,
|
|
) -> Optional[str]:
|
|
"""Require the natural-language template plan in template Stage 2."""
|
|
if template_required and 'template_application' not in recommendations:
|
|
return (
|
|
'template Stage 2 recommendations must include '
|
|
'template_application.value'
|
|
)
|
|
return None
|
|
|
|
|
|
def _localized_text_present(candidate: dict, field: str) -> bool:
|
|
"""Return whether a candidate carries non-empty localized prose."""
|
|
return any(
|
|
isinstance(candidate.get(key), str) and bool(candidate[key].strip())
|
|
for key in (field, f'{field}_zh', f'{field}_zh_tw', f'{field}_en', f'{field}_ja')
|
|
)
|
|
|
|
|
|
def _recommended_image_usage(recommendations: dict):
|
|
"""Return the Stage 2 image-source recommendation in either schema."""
|
|
recommend = recommendations.get('recommend')
|
|
usage = recommend.get('image_usage') if isinstance(recommend, dict) else None
|
|
if usage is None:
|
|
usage = recommendations.get('image_usage')
|
|
if isinstance(usage, dict):
|
|
usage = usage.get('value')
|
|
return usage
|
|
|
|
|
|
def _uses_ai_images(recommendations: dict) -> bool:
|
|
"""Return whether Stage 2 proposes AI-generated images."""
|
|
usage = _recommended_image_usage(recommendations)
|
|
return 'ai' in usage if isinstance(usage, list) else usage == 'ai'
|
|
|
|
|
|
def _stage2_production_recommendations_error(
|
|
recommendations: dict,
|
|
) -> Optional[str]:
|
|
"""Require every production control in current Stage 2 recommendations."""
|
|
recommend = recommendations.get('recommend')
|
|
if not isinstance(recommend, dict):
|
|
recommend = {}
|
|
for field in ('formula_policy', 'generation_mode'):
|
|
value = recommend.get(field)
|
|
if not isinstance(value, str) or not value.strip():
|
|
return (
|
|
'Stage 2 recommendations must include non-empty '
|
|
f'recommend.{field}'
|
|
)
|
|
refine_spec = recommendations.get('refine_spec')
|
|
if (
|
|
not isinstance(refine_spec, dict)
|
|
or not isinstance(refine_spec.get('value'), bool)
|
|
):
|
|
return 'Stage 2 recommendations must include refine_spec.value as a boolean'
|
|
if _uses_ai_images(recommendations):
|
|
image_ai_path = recommend.get('image_ai_path')
|
|
if not isinstance(image_ai_path, str) or not image_ai_path.strip():
|
|
return (
|
|
'Stage 2 recommendations must include non-empty '
|
|
'recommend.image_ai_path when image_usage includes ai'
|
|
)
|
|
return None
|
|
|
|
|
|
def _stage2_production_result_error(result: dict) -> Optional[str]:
|
|
"""Require every user-confirmed production control in the final payload."""
|
|
for field in ('formula_policy', 'generation_mode'):
|
|
value = result.get(field)
|
|
if not isinstance(value, str) or not value.strip():
|
|
return f'final Stage 2 payload must include non-empty {field}'
|
|
if not isinstance(result.get('refine_spec'), bool):
|
|
return 'final Stage 2 payload must include refine_spec as a boolean'
|
|
if _uses_ai_images(result):
|
|
image_ai_path = result.get('image_ai_path')
|
|
if not isinstance(image_ai_path, str) or not image_ai_path.strip():
|
|
return (
|
|
'final Stage 2 payload must include non-empty image_ai_path '
|
|
'when image_usage includes ai'
|
|
)
|
|
return None
|
|
|
|
|
|
def _palette_error(color: object, label: str) -> Optional[str]:
|
|
"""Validate one complete user-facing palette."""
|
|
if not isinstance(color, dict):
|
|
return f'{label} must be an object'
|
|
palette = color.get('palette')
|
|
if not isinstance(palette, dict):
|
|
palette = color
|
|
for role in _PALETTE_ROLES:
|
|
value = palette.get(role)
|
|
if role == 'body_text' and value is None:
|
|
value = palette.get('text')
|
|
if not isinstance(value, str) or not _HEX_COLOR_RE.fullmatch(value.strip()):
|
|
return f'{label}.palette.{role} must be a HEX color'
|
|
return None
|
|
|
|
|
|
def _positive_number(value: object) -> bool:
|
|
"""Return whether a JSON value is a positive finite number."""
|
|
try:
|
|
number = float(value)
|
|
except (TypeError, ValueError):
|
|
return False
|
|
return number > 0 and number != float('inf')
|
|
|
|
|
|
def _is_english_language(language: object) -> bool:
|
|
"""Return whether a recommendation language is an English locale."""
|
|
if not isinstance(language, str):
|
|
return False
|
|
try:
|
|
return language_base(language) == 'en'
|
|
except LanguageTagError:
|
|
return False
|
|
|
|
|
|
def _recommendation_language(recommendations: dict) -> object:
|
|
"""Return the deck's main language without conflating it with UI ``lang``."""
|
|
value = (
|
|
recommendations.get('primary_language')
|
|
or recommendations.get('content_language')
|
|
or recommendations.get('language')
|
|
)
|
|
if isinstance(value, dict):
|
|
return value.get('value') or value.get('id') or value.get('code') or ''
|
|
return value
|
|
|
|
|
|
def _primary_language_error(recommendations: dict) -> Optional[str]:
|
|
"""Require and canonicalize the staged content-language source of truth."""
|
|
return _canonicalize_primary_language(recommendations, required=True)
|
|
|
|
|
|
def _canonicalize_primary_language(
|
|
recommendations: dict,
|
|
*,
|
|
required: bool,
|
|
) -> Optional[str]:
|
|
"""Write a canonical primary language into one recommendation/result object."""
|
|
value = _recommendation_language(recommendations)
|
|
if not isinstance(value, str) or not value.strip():
|
|
if required:
|
|
return (
|
|
'Stage 1 recommendations must declare a valid primary_language '
|
|
'BCP-47 tag; lang controls only the Confirm UI language'
|
|
)
|
|
return None
|
|
try:
|
|
recommendations['primary_language'] = normalize_language_tag(value)
|
|
except LanguageTagError as exc:
|
|
return f'invalid primary_language: {exc}'
|
|
return None
|
|
|
|
|
|
def _typography_font_value(
|
|
font: dict,
|
|
field: str,
|
|
*,
|
|
english_primary: bool,
|
|
) -> object:
|
|
"""Return a canonical typography font value, accepting its language-aware alias."""
|
|
legacy_field = 'latin' if field == 'english' or english_primary else 'cjk'
|
|
value = font.get(field)
|
|
if not isinstance(value, str) or not value.strip():
|
|
value = font.get(legacy_field)
|
|
return value
|
|
|
|
|
|
def _typography_error(
|
|
typography: object,
|
|
label: str,
|
|
*,
|
|
require_sizes: bool,
|
|
main_language: object = '',
|
|
) -> Optional[str]:
|
|
"""Validate one complete user-facing typography recommendation or choice."""
|
|
if not isinstance(typography, dict):
|
|
return f'{label} must be an object'
|
|
english_primary = _is_english_language(main_language)
|
|
for role in ('heading', 'body'):
|
|
font = typography.get(role)
|
|
if not isinstance(font, dict):
|
|
return f'{label}.{role} must be an object'
|
|
fields = (('primary', 'latin' if english_primary else 'cjk'),)
|
|
if not english_primary:
|
|
fields += (('english', 'latin'),)
|
|
for field, legacy_field in fields:
|
|
value = _typography_font_value(
|
|
font,
|
|
field,
|
|
english_primary=english_primary,
|
|
)
|
|
if not isinstance(value, str) or not value.strip():
|
|
return (
|
|
f'{label}.{role}.{field} '
|
|
f'(or legacy {legacy_field}) must be non-empty'
|
|
)
|
|
if not isinstance(font.get('css'), str) or not font['css'].strip():
|
|
return f'{label}.{role}.css must be non-empty'
|
|
if not _positive_number(typography.get('body_size')):
|
|
return f'{label}.body_size must be a positive number'
|
|
if not require_sizes:
|
|
return None
|
|
sizes = typography.get('sizes')
|
|
if not isinstance(sizes, dict):
|
|
return f'{label}.sizes must be an object'
|
|
for role in _TYPOGRAPHY_SIZE_ROLES:
|
|
if not _positive_number(sizes.get(role)):
|
|
return f'{label}.sizes.{role} must be a positive number'
|
|
return None
|
|
|
|
|
|
def _typography_signature(
|
|
typography: dict,
|
|
*,
|
|
main_language: object,
|
|
) -> tuple[str, ...]:
|
|
"""Return the language-relevant font choices that distinguish one candidate."""
|
|
english_primary = _is_english_language(main_language)
|
|
fields = ('primary',) if english_primary else ('primary', 'english')
|
|
values = []
|
|
for role in ('heading', 'body'):
|
|
font = typography[role]
|
|
for field in fields:
|
|
value = _typography_font_value(
|
|
font,
|
|
field,
|
|
english_primary=english_primary,
|
|
)
|
|
values.append(str(value).strip().casefold())
|
|
return tuple(values)
|
|
|
|
|
|
def _typography_candidates_fixed_error(
|
|
candidates: list,
|
|
*,
|
|
main_language: object,
|
|
) -> Optional[str]:
|
|
"""Reject contradictions in an explicitly fixed typography contract."""
|
|
fixed = [
|
|
isinstance(candidate, dict) and candidate.get('fixed') is True
|
|
for candidate in candidates
|
|
]
|
|
if any(fixed):
|
|
if not all(fixed):
|
|
return 'typography.fixed must be true on every candidate or omitted'
|
|
signatures = [
|
|
_typography_signature(
|
|
candidate,
|
|
main_language=main_language,
|
|
)
|
|
for candidate in candidates
|
|
]
|
|
if any(signature != signatures[0] for signature in signatures[1:]):
|
|
return 'fixed typography candidates must repeat the same font combination'
|
|
return None
|
|
return None
|
|
|
|
|
|
def _candidate_list(spec: object) -> list:
|
|
"""Return candidates from the current or legacy recommendation shape."""
|
|
if not isinstance(spec, dict):
|
|
return []
|
|
candidates = spec.get('candidates')
|
|
if not isinstance(candidates, list):
|
|
candidates = spec.get('options')
|
|
return candidates if isinstance(candidates, list) else []
|
|
|
|
|
|
def _stage2_design_directions_error(
|
|
recommendations: dict,
|
|
*,
|
|
main_language: object = '',
|
|
) -> Optional[str]:
|
|
"""Require three complete custom systems and a valid preferred direction."""
|
|
main_language = main_language or _recommendation_language(recommendations)
|
|
directions = recommendations.get('design_directions')
|
|
if isinstance(directions, dict):
|
|
candidates = _candidate_list(directions)
|
|
if len(candidates) != 3:
|
|
return 'Stage 2 design_directions must include exactly 3 candidates'
|
|
selected = directions.get('selected', 0)
|
|
if type(selected) is not int or not 0 <= selected < len(candidates):
|
|
return 'Stage 2 design_directions.selected must be an integer from 0 to 2'
|
|
typography_candidates = []
|
|
direction_ids = set()
|
|
for index, candidate in enumerate(candidates, start=1):
|
|
label = f'design_directions.candidates[{index - 1}]'
|
|
if not isinstance(candidate, dict):
|
|
return f'{label} must be an object'
|
|
direction_id = str(candidate.get('id') or '').strip()
|
|
if not direction_id:
|
|
return f'{label}.id must be non-empty'
|
|
if direction_id in direction_ids:
|
|
return f'{label}.id must be unique'
|
|
direction_ids.add(direction_id)
|
|
if not _localized_text_present(candidate, 'name'):
|
|
return f'{label} requires a non-empty localized name'
|
|
for field in ('mode', 'visual_style', 'icons'):
|
|
if not isinstance(candidate.get(field), str) or not candidate[field].strip():
|
|
return f'{label}.{field} must be non-empty'
|
|
if candidate['mode'] != 'custom':
|
|
return f'{label}.mode must be custom'
|
|
if not _localized_text_present(candidate, 'mode_behavior'):
|
|
return f'{label}.mode=custom requires non-empty localized mode_behavior'
|
|
if candidate['visual_style'] != 'custom':
|
|
return f'{label}.visual_style must be custom'
|
|
if not _localized_text_present(candidate, 'visual_style_behavior'):
|
|
return (
|
|
f'{label}.visual_style=custom requires non-empty localized '
|
|
'visual_style_behavior'
|
|
)
|
|
error = _palette_error(candidate.get('color'), f'{label}.color')
|
|
if error:
|
|
return error
|
|
error = _typography_error(
|
|
candidate.get('typography'),
|
|
f'{label}.typography',
|
|
require_sizes=False,
|
|
main_language=main_language,
|
|
)
|
|
if error:
|
|
return error
|
|
typography_candidates.append(candidate['typography'])
|
|
image_strategy = candidate.get('image_strategy')
|
|
if not isinstance(image_strategy, dict):
|
|
return f'{label}.image_strategy must be an object'
|
|
rendering = str(image_strategy.get('rendering') or '').strip()
|
|
if not rendering:
|
|
return f'{label}.image_strategy.rendering must be non-empty'
|
|
if rendering != 'custom':
|
|
return f'{label}.image_strategy.rendering must be custom'
|
|
for prose_field in ('name', 'visual', 'mood'):
|
|
if not _localized_text_present(image_strategy, prose_field):
|
|
return (
|
|
f'{label}.image_strategy requires non-empty localized '
|
|
f'{prose_field}'
|
|
)
|
|
if not _localized_text_present(image_strategy, 'behavior'):
|
|
return (
|
|
f'{label}.image_strategy.rendering=custom requires non-empty '
|
|
'localized behavior'
|
|
)
|
|
return _typography_candidates_fixed_error(
|
|
typography_candidates,
|
|
main_language=main_language,
|
|
)
|
|
|
|
# Legacy staged files remain readable, but they must still provide three
|
|
# complete color combinations and at least one complete typography choice.
|
|
colors = _candidate_list(recommendations.get('color'))
|
|
if len(colors) < 3:
|
|
return 'Stage 2 recommendations must include 3 complete color candidates'
|
|
for index, color in enumerate(colors):
|
|
error = _palette_error(color, f'color.candidates[{index}]')
|
|
if error:
|
|
return error
|
|
typography = _candidate_list(recommendations.get('typography'))
|
|
if not typography:
|
|
return 'Stage 2 recommendations must include typography candidates'
|
|
if main_language and len(typography) < 3:
|
|
return 'Stage 2 recommendations must include 3 typography candidates'
|
|
for index, candidate in enumerate(typography):
|
|
error = _typography_error(
|
|
candidate,
|
|
f'typography.candidates[{index}]',
|
|
require_sizes=False,
|
|
main_language=main_language,
|
|
)
|
|
if error:
|
|
return error
|
|
if main_language:
|
|
return _typography_candidates_fixed_error(
|
|
typography,
|
|
main_language=main_language,
|
|
)
|
|
return None
|
|
|
|
|
|
def _stage2_custom_candidates_error(recommendations: dict) -> Optional[str]:
|
|
"""Validate optional legacy standalone custom alternatives."""
|
|
candidates = recommendations.get('custom_candidates')
|
|
if candidates is None:
|
|
return None
|
|
if not isinstance(candidates, dict):
|
|
return 'custom_candidates must be an object when present'
|
|
|
|
for field in ('mode', 'visual_style'):
|
|
candidate = candidates.get(field)
|
|
if candidate is None:
|
|
continue
|
|
if not isinstance(candidate, dict):
|
|
return f'custom_candidates.{field} must be an object'
|
|
for prose_field in ('name', 'behavior'):
|
|
if not _localized_text_present(candidate, prose_field):
|
|
return (
|
|
f'custom_candidates.{field} requires non-empty localized '
|
|
f'{prose_field}'
|
|
)
|
|
|
|
image_candidate = candidates.get('image_strategy')
|
|
if image_candidate is None:
|
|
return None
|
|
if not isinstance(image_candidate, dict):
|
|
return 'custom_candidates.image_strategy must be an object'
|
|
if image_candidate.get('rendering') != 'custom':
|
|
return 'custom_candidates.image_strategy.rendering must be custom'
|
|
for prose_field in ('name', 'visual', 'mood', 'behavior'):
|
|
if not _localized_text_present(image_candidate, prose_field):
|
|
return (
|
|
'custom_candidates.image_strategy requires non-empty localized '
|
|
f'{prose_field}'
|
|
)
|
|
return None
|
|
|
|
|
|
def _submission_stage_error(
|
|
confirm_dir: Path,
|
|
submitted_stage: Optional[str],
|
|
*,
|
|
recommendations_file: Path,
|
|
recommendations: dict,
|
|
template_required: bool,
|
|
) -> Optional[str]:
|
|
"""Reject a confirmation that does not match the staged recommendation."""
|
|
rec_stage_number = _recommendation_stage(recommendations)
|
|
if rec_stage_number == 0:
|
|
return 'recommendations must declare stage1 or stage2'
|
|
|
|
if rec_stage_number == 1:
|
|
language_error = _primary_language_error(recommendations)
|
|
if language_error:
|
|
return language_error
|
|
|
|
if rec_stage_number == 2:
|
|
try:
|
|
previous_result = _read_json_object(confirm_dir / RESULT_NAME)
|
|
except (OSError, json.JSONDecodeError, ValueError):
|
|
previous_result = {}
|
|
language_source = (
|
|
previous_result
|
|
if _recommendation_language(previous_result)
|
|
else recommendations
|
|
)
|
|
language_error = _canonicalize_primary_language(
|
|
language_source,
|
|
required=True,
|
|
)
|
|
if language_error:
|
|
return language_error
|
|
main_language = _recommendation_language(language_source)
|
|
recommendation_error = _template_stage2_error(
|
|
recommendations,
|
|
template_required=template_required,
|
|
)
|
|
if recommendation_error:
|
|
return recommendation_error
|
|
recommendation_error = _stage2_custom_candidates_error(recommendations)
|
|
if recommendation_error:
|
|
return recommendation_error
|
|
recommendation_error = _stage2_design_directions_error(
|
|
recommendations,
|
|
main_language=main_language,
|
|
)
|
|
if recommendation_error:
|
|
return recommendation_error
|
|
|
|
allowed_submissions = {
|
|
1: {'stage1'},
|
|
2: {'final'},
|
|
}
|
|
if submitted_stage not in allowed_submissions[rec_stage_number]:
|
|
expected = 'final' if rec_stage_number == 2 else 'stage1'
|
|
return (
|
|
f'confirmation stage mismatch: {recommendations_file.name} is '
|
|
f'{_stage_name(rec_stage_number)}, so the submitted stage must be '
|
|
f'{expected}'
|
|
)
|
|
|
|
previous_stage = (
|
|
None
|
|
if _fresh_template_restart(confirm_dir)
|
|
else _result_stage(confirm_dir / RESULT_NAME)
|
|
)
|
|
allowed_predecessors = {
|
|
1: {None},
|
|
2: {'stage1'},
|
|
}
|
|
if previous_stage not in allowed_predecessors[rec_stage_number]:
|
|
expected_previous = 'stage1' if rec_stage_number == 2 else 'no prior result'
|
|
return (
|
|
f'confirmation predecessor mismatch: {_stage_name(rec_stage_number)} '
|
|
f'requires a confirmed {expected_previous} result, found '
|
|
f'{previous_stage or "absent"}'
|
|
)
|
|
return None
|
|
|
|
|
|
def _custom_selection_error(result: dict) -> Optional[str]:
|
|
"""Require behavior prose whenever a creative custom choice is selected."""
|
|
if result.get('mode') == 'custom' and not str(
|
|
result.get('mode_behavior') or ''
|
|
).strip():
|
|
return 'mode=custom requires non-empty mode_behavior'
|
|
if result.get('visual_style') == 'custom' and not str(
|
|
result.get('visual_style_behavior') or ''
|
|
).strip():
|
|
return 'visual_style=custom requires non-empty visual_style_behavior'
|
|
image_strategy = result.get('image_strategy')
|
|
if isinstance(image_strategy, dict) and image_strategy.get('rendering') == 'custom':
|
|
behavior = image_strategy.get('behavior') or image_strategy.get('custom')
|
|
if not str(behavior or '').strip():
|
|
return 'image_strategy.rendering=custom requires non-empty behavior'
|
|
return None
|
|
|
|
|
|
def _stage2_solution_error(
|
|
result: dict,
|
|
*,
|
|
main_language: object = '',
|
|
) -> Optional[str]:
|
|
"""Reject a Stage 2/final payload with an incomplete design system."""
|
|
production_error = _stage2_production_result_error(result)
|
|
if production_error:
|
|
return production_error
|
|
|
|
color = result.get('color')
|
|
color_error = _palette_error(color, 'color')
|
|
color_custom = (
|
|
isinstance(color, dict)
|
|
and color.get('name') == 'custom'
|
|
and str(color.get('custom') or '').strip()
|
|
)
|
|
if color_error and not color_custom:
|
|
return color_error
|
|
|
|
typography = result.get('typography')
|
|
typography_error = _typography_error(
|
|
typography,
|
|
'typography',
|
|
require_sizes=True,
|
|
main_language=main_language,
|
|
)
|
|
if typography_error:
|
|
return typography_error
|
|
|
|
if _uses_ai_images(result):
|
|
image_strategy = result.get('image_strategy')
|
|
if not isinstance(image_strategy, dict) or not str(
|
|
image_strategy.get('rendering') or ''
|
|
).strip():
|
|
return 'image_usage includes ai, so image_strategy.rendering must be non-empty'
|
|
rendering = image_strategy['rendering'].strip()
|
|
if rendering != 'custom' and rendering not in _ai_rendering_ids():
|
|
return f'image_strategy.rendering is not a known preset: {rendering}'
|
|
return None
|
|
|
|
|
|
def _normalize_custom_selections(result: dict) -> None:
|
|
"""Keep custom prose only for the creative choices actually selected."""
|
|
if result.get('mode') != 'custom':
|
|
result.pop('mode_behavior', None)
|
|
if result.get('visual_style') != 'custom':
|
|
result.pop('visual_style_behavior', None)
|
|
|
|
image_strategy = result.get('image_strategy')
|
|
if not isinstance(image_strategy, dict):
|
|
return
|
|
legacy_behavior = image_strategy.pop('custom', None)
|
|
if image_strategy.get('rendering') == 'custom':
|
|
if not image_strategy.get('behavior') and legacy_behavior:
|
|
image_strategy['behavior'] = legacy_behavior
|
|
return
|
|
image_strategy.pop('behavior', None)
|
|
|
|
|
|
def _expected_result_stage(confirm_dir: Path) -> str:
|
|
"""Return the result stage expected from the current recommendations."""
|
|
try:
|
|
_, recommendations = _read_active_recommendations(confirm_dir)
|
|
except (OSError, json.JSONDecodeError, ValueError):
|
|
return 'final'
|
|
return {
|
|
1: 'stage1',
|
|
2: 'final',
|
|
}.get(_recommendation_stage(recommendations), 'final')
|
|
|
|
|
|
def _file_version(path: Path) -> Optional[float]:
|
|
"""Return a cheap file version for polling state, or None when absent."""
|
|
try:
|
|
return path.stat().st_mtime
|
|
except OSError:
|
|
return None
|
|
|
|
|
|
def _read_session(confirm_dir: Path) -> dict:
|
|
"""Read session.json if present, returning an object."""
|
|
session_file = confirm_dir / SESSION_NAME
|
|
if not session_file.exists():
|
|
return {}
|
|
try:
|
|
return _read_json_object(session_file)
|
|
except (OSError, json.JSONDecodeError, ValueError):
|
|
return {}
|
|
|
|
|
|
def _build_session_state(
|
|
confirm_dir: Path,
|
|
*,
|
|
server_port: Optional[int] = None,
|
|
event: Optional[str] = None,
|
|
) -> dict:
|
|
"""Derive the resumable Confirm UI state from disk artifacts."""
|
|
previous = _read_session(confirm_dir)
|
|
rec_file = _active_recommendations_path(confirm_dir)
|
|
result_file = confirm_dir / RESULT_NAME
|
|
|
|
rec_stage_number = 0
|
|
rec_stage = None
|
|
rec_error = None
|
|
if rec_file.exists():
|
|
try:
|
|
rec_file, rec_data = _read_active_recommendations(confirm_dir)
|
|
rec_stage_number = _recommendation_stage(rec_data)
|
|
rec_stage = _stage_name(rec_stage_number)
|
|
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
|
rec_error = str(exc)
|
|
|
|
fresh_restart = _fresh_template_restart(confirm_dir)
|
|
result_stage = None if fresh_restart else _result_stage(result_file)
|
|
result_stage_number = _result_stage_number(result_stage)
|
|
|
|
if result_stage == 'final':
|
|
expected_stage_number = None
|
|
status = 'done'
|
|
current_stage = 'final'
|
|
elif result_stage == 'stage1':
|
|
expected_stage_number = 2
|
|
ready = rec_stage_number == 2
|
|
status = 'ready_user' if ready else 'waiting_agent'
|
|
current_stage = 'stage2' if ready else 'stage1'
|
|
else:
|
|
expected_stage_number = 1
|
|
ready = rec_stage_number == 1
|
|
status = 'ready_user' if ready else 'waiting_agent'
|
|
current_stage = 'stage1'
|
|
|
|
session = {
|
|
'phase': 'strategist',
|
|
'status': status,
|
|
'current_stage': current_stage,
|
|
'expected_stage': _stage_name(expected_stage_number),
|
|
'expected_stage_number': expected_stage_number,
|
|
'recommendation_stage': rec_stage,
|
|
'recommendation_stage_number': rec_stage_number,
|
|
'recommendation_file': rec_file.name,
|
|
'recommendation_version': _file_version(rec_file),
|
|
'recommendation_error': rec_error,
|
|
'result_stage': result_stage,
|
|
'result_stage_number': result_stage_number,
|
|
'result_version': _file_version(result_file),
|
|
'server_port': server_port or previous.get('server_port'),
|
|
'event': event or previous.get('event') or 'derived',
|
|
}
|
|
|
|
options_file = confirm_dir / TEMPLATE_OPTIONS_NAME
|
|
selection_file = confirm_dir / TEMPLATE_SELECTION_NAME
|
|
handoff_file = confirm_dir / TEMPLATE_HANDOFF_NAME
|
|
session.update({
|
|
'template_options_file': TEMPLATE_OPTIONS_NAME,
|
|
'template_options_version': _file_version(options_file),
|
|
'template_selection_file': TEMPLATE_SELECTION_NAME,
|
|
'template_selection_version': _file_version(selection_file),
|
|
'template_handoff_file': TEMPLATE_HANDOFF_NAME,
|
|
'template_handoff_version': _file_version(handoff_file),
|
|
})
|
|
|
|
if result_stage == 'final':
|
|
session.update({
|
|
'template_status': 'ready',
|
|
'template_error': None,
|
|
})
|
|
return session
|
|
|
|
if result_stage is None:
|
|
ready_error = _stage1_ready_error(confirm_dir)
|
|
if ready_error:
|
|
session.update({
|
|
'status': 'error',
|
|
'template_status': 'error',
|
|
'template_error': ready_error,
|
|
})
|
|
if not session.get('recommendation_error'):
|
|
session['recommendation_error'] = ready_error
|
|
return session
|
|
session.update({
|
|
'template_status': 'ready_user',
|
|
'template_error': None,
|
|
})
|
|
return session
|
|
|
|
ready_error = _stage2_ready_error(
|
|
confirm_dir.parent,
|
|
confirm_dir,
|
|
rec_file if rec_stage_number == 2 else None,
|
|
)
|
|
if ready_error:
|
|
session.update({
|
|
'status': 'waiting_agent',
|
|
'current_stage': 'stage1',
|
|
'expected_stage': 'stage2',
|
|
'expected_stage_number': 2,
|
|
'template_status': 'waiting_agent',
|
|
'template_error': ready_error,
|
|
})
|
|
return session
|
|
session.update({
|
|
'template_status': 'ready',
|
|
'template_error': None,
|
|
})
|
|
return session
|
|
|
|
|
|
def _write_session_state(confirm_dir: Path, session: dict) -> None:
|
|
"""Persist session.json only when stable state changes."""
|
|
previous = _read_session(confirm_dir)
|
|
comparable_previous = dict(previous)
|
|
comparable_current = dict(session)
|
|
comparable_previous.pop('updated_at', None)
|
|
comparable_current.pop('updated_at', None)
|
|
if comparable_previous == comparable_current:
|
|
return
|
|
session = dict(session)
|
|
session['updated_at'] = time.strftime('%Y-%m-%dT%H:%M:%S')
|
|
_write_json_atomic(confirm_dir / SESSION_NAME, session)
|
|
|
|
|
|
def _sync_session_state(
|
|
confirm_dir: Path,
|
|
*,
|
|
server_port: Optional[int] = None,
|
|
event: Optional[str] = None,
|
|
) -> dict:
|
|
"""Derive and persist the current session state."""
|
|
session = _build_session_state(
|
|
confirm_dir,
|
|
server_port=server_port,
|
|
event=event,
|
|
)
|
|
_write_session_state(confirm_dir, session)
|
|
return session
|
|
|
|
|
|
# Earlier-stage choices are not rendered on later pages, so their values live
|
|
# only in browser STATE and would be lost on an in-run refresh. Fold them from
|
|
# result.json into Stage-2 recommendations so the same live run resumes from the
|
|
# user's actual communication contract and complete deck-solution choices.
|
|
_CONTRACT_RECOMMEND_KEYS = (
|
|
'canvas',
|
|
)
|
|
_CONTRACT_VALUE_KEYS = (
|
|
'audience',
|
|
'communication_intent',
|
|
'audience_outcome',
|
|
'core_message',
|
|
'delivery_context',
|
|
'artifact_afterlife',
|
|
'content_divergence',
|
|
)
|
|
_PROACTIVE_EXECUTION_DEFAULTS = {
|
|
'proactive_speaker_notes': True,
|
|
'proactive_custom_animations': False,
|
|
'proactive_narration_audio': False,
|
|
}
|
|
_LOCKED_RECOMMENDATIONS_KEY = '_locked_recommendations'
|
|
|
|
|
|
def _resolve_proactive_execution_values(
|
|
source: dict,
|
|
) -> tuple[dict[str, bool], Optional[str]]:
|
|
"""Resolve proactive-execution booleans with backward-compatible defaults."""
|
|
values = {}
|
|
for key, default in _PROACTIVE_EXECUTION_DEFAULTS.items():
|
|
if key not in source:
|
|
values[key] = default
|
|
continue
|
|
raw_value = source[key]
|
|
if isinstance(raw_value, dict):
|
|
if 'value' not in raw_value or not isinstance(raw_value['value'], bool):
|
|
return {}, f'{key}.value must be a boolean'
|
|
raw_value = raw_value['value']
|
|
elif not isinstance(raw_value, bool):
|
|
return {}, f'{key} must be a boolean'
|
|
values[key] = raw_value
|
|
return values, None
|
|
|
|
|
|
def _normalize_proactive_execution_result(
|
|
result: dict,
|
|
defaults: dict[str, bool],
|
|
) -> Optional[str]:
|
|
"""Write the independent raw confirmation booleans to the final result."""
|
|
values = {}
|
|
for key, default in defaults.items():
|
|
value = result.get(key, default)
|
|
if not isinstance(value, bool):
|
|
return f'{key} must be a boolean'
|
|
values[key] = value
|
|
result.update(values)
|
|
return None
|
|
|
|
|
|
def _merge_confirmed_choices(data: dict, result_file: Path) -> None:
|
|
"""Fold already-confirmed choices into later-stage recommendations."""
|
|
try:
|
|
res = _read_json_object(result_file)
|
|
except (OSError, json.JSONDecodeError, ValueError):
|
|
return
|
|
if _result_stage(result_file) != 'stage1':
|
|
return
|
|
recommend = data.setdefault('recommend', {})
|
|
if not isinstance(recommend, dict):
|
|
recommend = data['recommend'] = {}
|
|
main_language = _recommendation_language(res)
|
|
if main_language:
|
|
try:
|
|
data['primary_language'] = normalize_language_tag(main_language)
|
|
except LanguageTagError:
|
|
# Keep the invalid legacy value visible to the API boundary below,
|
|
# which returns a user-facing contract error instead of hiding it.
|
|
data['primary_language'] = main_language
|
|
for key in _CONTRACT_RECOMMEND_KEYS:
|
|
if res.get(key) not in (None, ''):
|
|
recommend[key] = res[key]
|
|
for key in _CONTRACT_VALUE_KEYS:
|
|
if key in res:
|
|
data[key] = {'value': res.get(key) or ''}
|
|
|
|
|
|
def _apply_locked_recommendations(
|
|
result: dict,
|
|
recommendations_file: Path,
|
|
previous_result_file: Path,
|
|
*,
|
|
carry_previous: bool,
|
|
) -> dict:
|
|
"""Restore profile-locked fields and return locks for staged carry-over."""
|
|
# This marker is server-owned; never accept a client-supplied carry-over map.
|
|
result.pop(_LOCKED_RECOMMENDATIONS_KEY, None)
|
|
locked_values = {}
|
|
previous = {}
|
|
if carry_previous:
|
|
try:
|
|
previous = _read_json_object(previous_result_file)
|
|
except (OSError, json.JSONDecodeError, ValueError):
|
|
previous = {}
|
|
previous_locks = previous.get(_LOCKED_RECOMMENDATIONS_KEY)
|
|
if isinstance(previous_locks, dict):
|
|
locked_values.update(previous_locks)
|
|
for key in _PROACTIVE_EXECUTION_DEFAULTS:
|
|
locked_values.pop(key, None)
|
|
|
|
try:
|
|
recommendations = _read_json_object(recommendations_file)
|
|
recommendations_loaded = True
|
|
except (OSError, json.JSONDecodeError, ValueError):
|
|
recommendations = {}
|
|
recommendations_loaded = False
|
|
|
|
# Stage 1 starts a new contract and therefore replaces any stale locks left
|
|
# by an earlier run. Later stages inherit those locks across server restarts.
|
|
if recommendations_loaded and _recommendation_stage(recommendations) == 1:
|
|
locked_values = {}
|
|
for key, field in recommendations.items():
|
|
if key in _PROACTIVE_EXECUTION_DEFAULTS:
|
|
continue
|
|
if not isinstance(field, dict) or field.get('locked') is not True:
|
|
continue
|
|
if 'value' in field:
|
|
locked_values[key] = field['value']
|
|
for key, value in locked_values.items():
|
|
result[key] = value
|
|
return locked_values
|
|
|
|
|
|
def _wait_only_for_result(
|
|
result_file: Path,
|
|
lock_file: Path,
|
|
timeout: int,
|
|
target_stage: str = 'final',
|
|
) -> int:
|
|
"""Attach to an already-running confirm server and wait for a target stage.
|
|
|
|
No child is launched here: the page is open from the preceding ``--daemon``
|
|
launch, so liveness is tracked via the recorded pid, not a ``proc`` handle.
|
|
Only the stage guard is used (no mtime gate), because intermediate submits
|
|
may happen before this wait command is issued.
|
|
"""
|
|
logger.info('waiting for browser confirmation stage=%s...', target_stage)
|
|
deadline = None if timeout <= 0 else time.time() + timeout
|
|
while True:
|
|
result_status = _wait_result_status(result_file, target_stage)
|
|
if result_status is not None:
|
|
return result_status
|
|
|
|
confirm_dir = result_file.parent
|
|
if target_stage == 'stage1':
|
|
readiness_error = _stage1_ready_error(confirm_dir)
|
|
else:
|
|
recommendations_file = _active_recommendations_path(confirm_dir)
|
|
readiness_error = _stage2_ready_error(
|
|
confirm_dir.parent,
|
|
confirm_dir,
|
|
recommendations_file,
|
|
)
|
|
if readiness_error:
|
|
logger.error(
|
|
'confirmation stage=%s is not ready: %s',
|
|
target_stage,
|
|
readiness_error,
|
|
)
|
|
return 1
|
|
|
|
lock = _read_lock(lock_file)
|
|
pid = _lock_pid(lock)
|
|
if not pid or not _process_alive(pid):
|
|
logger.error('confirm server is no longer running before stage=%s was confirmed', target_stage)
|
|
return 1
|
|
|
|
if deadline is not None and time.time() >= deadline:
|
|
logger.error(
|
|
'timed out waiting for confirmation stage=%s — the page may still '
|
|
'be open; re-check %s before falling back to chat', target_stage, result_file,
|
|
)
|
|
return 124
|
|
|
|
time.sleep(0.5)
|
|
|
|
|
|
def _wait_result_status(
|
|
result_file: Path,
|
|
target_stage: str,
|
|
) -> Optional[int]:
|
|
"""Return a terminal wait status when the persisted result resolves the target."""
|
|
if _fresh_template_restart(result_file.parent):
|
|
return None
|
|
current_stage = _result_stage(result_file)
|
|
if current_stage == target_stage:
|
|
if target_stage == 'stage1':
|
|
try:
|
|
_read_template_selection(
|
|
result_file.parent / TEMPLATE_SELECTION_NAME,
|
|
)
|
|
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
|
logger.error(
|
|
'Stage 1 result has no valid template selection: %s',
|
|
exc,
|
|
)
|
|
return 2
|
|
logger.info('confirmation stage=%s received: %s', target_stage, result_file)
|
|
if target_stage == 'stage1':
|
|
logger.info(
|
|
'[NEXT] Stage 1 is intermediate: complete the template handoff, '
|
|
'author fresh Stage 2, then wait for final confirmation.'
|
|
)
|
|
return 0
|
|
if _result_stage_number(current_stage) > _result_stage_number(target_stage):
|
|
logger.error(
|
|
'confirmation skipped expected stage=%s and advanced to %s',
|
|
target_stage,
|
|
current_stage,
|
|
)
|
|
return 2
|
|
return None
|
|
|
|
|
|
def _shutdown_existing(lock_file: Path) -> int:
|
|
"""Stop a confirm server left running for this project (idempotent).
|
|
|
|
Step 4 always calls this on exit so the page never lingers on its selected
|
|
port. Tries a graceful ``/api/shutdown`` first, falls back to killing the
|
|
recorded pid, then clears the lock. A no-op when nothing is running.
|
|
"""
|
|
existing = _read_lock(lock_file)
|
|
if not existing:
|
|
logger.info('no confirm server running — nothing to stop')
|
|
return 0
|
|
pid = _lock_pid(existing)
|
|
port = existing.get('port')
|
|
if not _process_alive(pid):
|
|
_clear_lock(lock_file)
|
|
logger.info('confirm server already stopped; cleared stale lock')
|
|
return 0
|
|
# Graceful first: the server flushes and releases its own lock.
|
|
if port:
|
|
try:
|
|
req = urllib.request.Request(
|
|
f'http://127.0.0.1:{port}/api/shutdown',
|
|
data=b'{"reason": "step4-cleanup"}',
|
|
headers={'Content-Type': 'application/json'},
|
|
method='POST',
|
|
)
|
|
urllib.request.urlopen(req, timeout=3)
|
|
except OSError:
|
|
pass # server may already be exiting; fall through to the kill path
|
|
for _ in range(20): # up to ~2s for the graceful exit to land
|
|
if not _process_alive(pid):
|
|
break
|
|
time.sleep(0.1)
|
|
if _process_alive(pid):
|
|
try:
|
|
os.kill(pid, signal.SIGTERM)
|
|
except OSError:
|
|
pass
|
|
_clear_lock(lock_file)
|
|
logger.info('confirm server stopped (pid=%s)', pid)
|
|
return 0
|
|
|
|
|
|
def _build_catalogs() -> dict:
|
|
"""Return the static catalog set with the canvas list synced live from
|
|
``config.CANVAS_FORMATS`` — the single source of truth for canvas formats —
|
|
so the confirm page can never drift from the pipeline's real formats. The
|
|
set of formats and their dimensions come from config; four-language labels
|
|
and use text are kept from catalogs.json (with a plain fallback for new ids).
|
|
"""
|
|
data = json.loads(_CATALOGS_PATH.read_text(encoding='utf-8'))
|
|
try:
|
|
import config # scripts/ is on sys.path (injected at import time)
|
|
formats = config.CANVAS_FORMATS
|
|
except (ImportError, AttributeError): # missing module/attr → static canvas
|
|
return data
|
|
existing = {
|
|
c.get('id'): c
|
|
for c in data.get('canvas', [])
|
|
if isinstance(c, dict) and c.get('id')
|
|
}
|
|
canvas = []
|
|
for cid, fmt in formats.items():
|
|
entry = dict(existing.get(cid, {}))
|
|
entry['id'] = cid
|
|
entry['dim'] = fmt.get('dimensions', entry.get('dim', ''))
|
|
if not entry.get('label'):
|
|
name = fmt.get('name', cid)
|
|
entry['label'] = name
|
|
entry.setdefault('label_zh', name)
|
|
entry.setdefault('label_en', name)
|
|
if not entry.get('use_en') and fmt.get('use_case'):
|
|
entry['use_en'] = fmt['use_case']
|
|
canvas.append(entry)
|
|
data['canvas'] = canvas
|
|
return data
|
|
|
|
|
|
def _icon_preview_svg(library: str, name: str) -> str:
|
|
"""Read a trusted sample SVG from the bundled icon templates."""
|
|
icon_path = _ICON_LIBRARY_DIR / library / f'{name}.svg'
|
|
raw = icon_path.read_text(encoding='utf-8')
|
|
raw = re.sub(r'<\?xml[^>]*>\s*', '', raw)
|
|
raw = re.sub(r'<!--.*?-->\s*', '', raw, flags=re.S)
|
|
return raw.strip()
|
|
|
|
|
|
def _build_icon_previews() -> dict:
|
|
previews = {}
|
|
for library, names in _ICON_PREVIEW_SAMPLES.items():
|
|
items = []
|
|
for name in names:
|
|
try:
|
|
items.append({'name': name, 'svg': _icon_preview_svg(library, name)})
|
|
except OSError as exc:
|
|
logger.warning('icon preview sample missing: %s/%s (%s)', library, name, exc)
|
|
previews[library] = items
|
|
return previews
|
|
|
|
|
|
def _ai_comparison_items(kind: str) -> list[dict[str, str]]:
|
|
manifest = _AI_IMAGE_COMPARISON_DIR / kind / '_manifest.json'
|
|
if not manifest.exists():
|
|
return []
|
|
data = json.loads(manifest.read_text(encoding='utf-8'))
|
|
items = []
|
|
for item in data.get('items', []):
|
|
filename = item.get('filename')
|
|
if not isinstance(filename, str) or not filename.endswith('.png'):
|
|
continue
|
|
if not re.fullmatch(r'[A-Za-z0-9_.-]+\.png', filename):
|
|
continue
|
|
if not (_AI_IMAGE_COMPARISON_DIR / kind / filename).exists():
|
|
continue
|
|
item_id = Path(filename).stem
|
|
items.append({
|
|
'id': item_id,
|
|
'label': item.get('type') or item_id,
|
|
'filename': filename,
|
|
'purpose': item.get('purpose') or '',
|
|
'alt_text': item.get('alt_text') or '',
|
|
})
|
|
return items
|
|
|
|
|
|
def _ai_rendering_ids() -> set[str]:
|
|
"""Return the rendering presets exposed by the confirmation UI."""
|
|
return {item['id'] for item in _ai_comparison_items('rendering')}
|
|
|
|
|
|
def _build_ai_image_comparison() -> dict:
|
|
return {
|
|
'rendering': _ai_comparison_items('rendering'),
|
|
}
|
|
|
|
|
|
# --- app --------------------------------------------------------------------
|
|
|
|
def create_app(
|
|
project_dir: str,
|
|
idle_timeout: int = 900,
|
|
lock_file: Optional[Path] = None,
|
|
server_port: Optional[int] = None,
|
|
) -> Flask:
|
|
"""Create and configure the Flask app for a given project directory."""
|
|
project_path = Path(project_dir).resolve()
|
|
confirm_dir = project_path / CONFIRM_DIR_NAME
|
|
|
|
app = Flask(__name__, static_folder='static', static_url_path='/static')
|
|
app.config['PROJECT_PATH'] = project_path
|
|
app.config['CONFIRM_DIR'] = confirm_dir
|
|
app.config['LOCK_FILE'] = lock_file
|
|
app.config['SERVER_PORT'] = server_port
|
|
app.config['LAST_REQUEST_TIME'] = time.time()
|
|
|
|
@app.before_request
|
|
def _update_activity():
|
|
app.config['LAST_REQUEST_TIME'] = time.time()
|
|
|
|
def _exit_with_lock_release(code: int = 0) -> None:
|
|
lf = app.config.get('LOCK_FILE')
|
|
if lf is not None:
|
|
_release_lock(lf)
|
|
os._exit(code)
|
|
|
|
def _idle_watchdog():
|
|
if idle_timeout <= 0:
|
|
return
|
|
while True:
|
|
time.sleep(10)
|
|
elapsed = time.time() - app.config['LAST_REQUEST_TIME']
|
|
if elapsed > idle_timeout:
|
|
logger.info('idle for %ds, shutting down', idle_timeout)
|
|
_exit_with_lock_release(0)
|
|
|
|
watchdog = threading.Thread(target=_idle_watchdog, daemon=True)
|
|
watchdog.start()
|
|
|
|
@app.route('/api/shutdown', methods=['POST'])
|
|
def shutdown():
|
|
data = request.get_json(silent=True) or {}
|
|
reason = data.get('reason') or 'shutdown'
|
|
|
|
def _stop():
|
|
time.sleep(0.5) # let HTTP response flush before killing the process
|
|
logger.info('shutting down (%s)', reason)
|
|
_exit_with_lock_release(0)
|
|
threading.Thread(target=_stop, daemon=True).start()
|
|
return jsonify({'status': 'ok'})
|
|
|
|
@app.route('/')
|
|
def index():
|
|
return send_from_directory(app.static_folder, 'index.html')
|
|
|
|
@app.route('/api/health')
|
|
def health():
|
|
"""Expose a cheap readiness probe for the daemon launcher."""
|
|
rec_file = _active_recommendations_path(confirm_dir)
|
|
rec_ok = False
|
|
stage = None
|
|
if rec_file.exists():
|
|
try:
|
|
rec_file, rec_data = _read_active_recommendations(
|
|
confirm_dir,
|
|
retries=0,
|
|
)
|
|
rec_ok = True
|
|
stage = _recommendation_stage(rec_data)
|
|
except (OSError, json.JSONDecodeError, ValueError):
|
|
rec_ok = False
|
|
resp = jsonify({
|
|
'status': 'ok',
|
|
'service': 'confirm_ui',
|
|
'pid': os.getpid(),
|
|
'project': str(project_path),
|
|
'recommendations': rec_ok,
|
|
'stage': stage,
|
|
'session': _build_session_state(
|
|
confirm_dir,
|
|
server_port=app.config.get('SERVER_PORT'),
|
|
),
|
|
})
|
|
resp.headers['Cache-Control'] = 'no-store'
|
|
return resp
|
|
|
|
@app.route('/api/session')
|
|
def get_session():
|
|
"""Expose the derived template/Strategist wizard state for polling."""
|
|
session = _sync_session_state(
|
|
confirm_dir,
|
|
server_port=app.config.get('SERVER_PORT'),
|
|
event='poll',
|
|
)
|
|
resp = jsonify(session)
|
|
resp.headers['Cache-Control'] = 'no-store'
|
|
return resp
|
|
|
|
@app.route('/api/catalogs')
|
|
def get_catalogs():
|
|
"""Serve the option universe; canvas is synced live from config.py so
|
|
the static catalogs.json copy can never drift from the real formats."""
|
|
try:
|
|
resp = jsonify(_build_catalogs())
|
|
resp.headers['Cache-Control'] = 'no-store'
|
|
return resp
|
|
except (OSError, json.JSONDecodeError) as exc:
|
|
return jsonify({'error': f'invalid catalogs.json: {exc}'}), 500
|
|
|
|
@app.route('/api/icon-previews')
|
|
def get_icon_previews():
|
|
"""Serve real sample icons from templates/icons for the icon chooser."""
|
|
resp = jsonify(_build_icon_previews())
|
|
resp.headers['Cache-Control'] = 'no-store'
|
|
return resp
|
|
|
|
@app.route('/api/ai-image-comparison')
|
|
def get_ai_image_comparison_manifest():
|
|
"""Serve generated-image rendering references for the current UI."""
|
|
try:
|
|
resp = jsonify(_build_ai_image_comparison())
|
|
resp.headers['Cache-Control'] = 'no-store'
|
|
return resp
|
|
except (OSError, json.JSONDecodeError) as exc:
|
|
return jsonify({'error': f'invalid ai-image-comparison manifest: {exc}'}), 500
|
|
|
|
@app.route('/ai-image-comparison/<kind>/<filename>')
|
|
def get_ai_image_comparison(kind: str, filename: str):
|
|
"""Serve rendering images for generated-image strategy candidates."""
|
|
if kind != 'rendering':
|
|
return jsonify({'error': 'invalid comparison kind'}), 404
|
|
if not re.fullmatch(r'[A-Za-z0-9_.-]+\.png', filename or ''):
|
|
return jsonify({'error': 'invalid comparison filename'}), 404
|
|
return send_from_directory(_AI_IMAGE_COMPARISON_DIR / kind, filename)
|
|
|
|
@app.route('/api/recommendations')
|
|
def get_recommendations():
|
|
"""Serve the Strategist-authored recommendations for this project."""
|
|
result_file = confirm_dir / RESULT_NAME
|
|
if (
|
|
_result_stage(result_file) == 'final'
|
|
and not _fresh_template_restart(confirm_dir)
|
|
):
|
|
return jsonify({
|
|
'error': (
|
|
'the current Confirm UI run is complete; reset the template '
|
|
f'selection and write fresh {TEMPLATE_OPTIONS_NAME} before '
|
|
'starting another'
|
|
),
|
|
}), 409
|
|
rec_file = _active_recommendations_path(confirm_dir)
|
|
if not rec_file.exists():
|
|
return jsonify({'error': f'{rec_file.name} not found'}), 404
|
|
try:
|
|
rec_file, data = _read_active_recommendations(confirm_dir)
|
|
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
|
return jsonify({
|
|
'error': f'invalid current recommendation file: {exc}',
|
|
}), 400
|
|
rec_stage_number = _recommendation_stage(data)
|
|
if rec_stage_number == 1:
|
|
stage1_error = _stage1_ready_error(confirm_dir)
|
|
if stage1_error:
|
|
return jsonify({
|
|
'error': f'Stage 1 is not ready: {stage1_error}',
|
|
}), 409
|
|
try:
|
|
template_options, _ = _build_template_options(confirm_dir)
|
|
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
|
return jsonify({
|
|
'error': f'invalid template options: {exc}',
|
|
}), 409
|
|
data['template_options'] = template_options
|
|
template_required = False
|
|
else:
|
|
stage2_error = _stage2_ready_error(
|
|
project_path,
|
|
confirm_dir,
|
|
rec_file,
|
|
)
|
|
if stage2_error:
|
|
return jsonify({
|
|
'error': f'Stage 2 is waiting for template handoff: {stage2_error}',
|
|
}), 409
|
|
try:
|
|
template_required = _template_confirmation_required(project_path)
|
|
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
|
return jsonify({
|
|
'error': f'cannot determine active template mode: {exc}',
|
|
}), 409
|
|
# Later stages render only downstream sections, so fold earlier confirmed
|
|
# choices from result.json back in. An in-run refresh then re-inits from
|
|
# the user's choices instead of catalog defaults.
|
|
if rec_stage_number >= 2 and result_file.exists():
|
|
_merge_confirmed_choices(data, result_file)
|
|
language_error = _canonicalize_primary_language(
|
|
data,
|
|
required=True,
|
|
)
|
|
if language_error:
|
|
return jsonify({'error': language_error}), 409
|
|
if not template_required:
|
|
data.pop('template_application', None)
|
|
if rec_stage_number == 2:
|
|
recommendation_error = _template_stage2_error(
|
|
data,
|
|
template_required=template_required,
|
|
)
|
|
if recommendation_error:
|
|
return jsonify({'error': recommendation_error}), 409
|
|
recommendation_error = _stage2_custom_candidates_error(data)
|
|
if recommendation_error:
|
|
return jsonify({'error': recommendation_error}), 409
|
|
recommendation_error = _stage2_design_directions_error(data)
|
|
if recommendation_error:
|
|
return jsonify({'error': recommendation_error}), 409
|
|
if rec_stage_number == 2:
|
|
proactive_values, proactive_error = (
|
|
_resolve_proactive_execution_values(data)
|
|
)
|
|
if proactive_error:
|
|
return jsonify({'error': proactive_error}), 409
|
|
for key, value in proactive_values.items():
|
|
data[key] = {'value': value}
|
|
# Template application is authored by Strategist from the installed
|
|
# workspace and current content. Never expose legacy mode fields as
|
|
# user-facing confirmation controls.
|
|
recommend = data.get('recommend')
|
|
if isinstance(recommend, dict):
|
|
recommend.pop('template_reuse_scope', None)
|
|
recommend.pop('template_adherence', None)
|
|
data.pop('template_reuse_scope', None)
|
|
data.pop('template_adherence', None)
|
|
# The page polls this endpoint after each confirmation until the AI
|
|
# creates the next stage file, so it must never be cached.
|
|
resp = jsonify(data)
|
|
resp.headers['Cache-Control'] = 'no-store'
|
|
return resp
|
|
|
|
@app.route('/api/confirm', methods=['POST'])
|
|
def confirm():
|
|
"""Persist the user's final choices to result.json for the AI to read."""
|
|
payload = request.get_json(silent=True)
|
|
if not isinstance(payload, dict):
|
|
return jsonify({'error': 'invalid payload'}), 400
|
|
confirm_dir.mkdir(parents=True, exist_ok=True)
|
|
result = dict(payload)
|
|
template_selection_payload = result.pop('template_selection', None)
|
|
result_file = confirm_dir / RESULT_NAME
|
|
raw_stage = result.get('stage')
|
|
stage = _stage_key(raw_stage)
|
|
if raw_stage is not None and stage is None:
|
|
return jsonify({'error': 'invalid confirmation stage'}), 400
|
|
try:
|
|
rec_file, current_recommendations = _read_active_recommendations(
|
|
confirm_dir,
|
|
)
|
|
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
|
return jsonify({
|
|
'error': (
|
|
'cannot confirm without valid current recommendations: '
|
|
f'{exc}'
|
|
),
|
|
}), 409
|
|
rec_stage_number = _recommendation_stage(current_recommendations)
|
|
selection_receipt = None
|
|
selection_file = confirm_dir / TEMPLATE_SELECTION_NAME
|
|
write_selection = False
|
|
if rec_stage_number == 1:
|
|
stage1_error = _stage1_ready_error(confirm_dir)
|
|
if stage1_error:
|
|
return jsonify({
|
|
'error': f'Stage 1 is not ready: {stage1_error}',
|
|
}), 409
|
|
if not isinstance(template_selection_payload, dict):
|
|
return jsonify({
|
|
'error': (
|
|
'Stage 1 payload must include template_selection with '
|
|
'mode and selection_keys'
|
|
),
|
|
}), 400
|
|
try:
|
|
template_options, template_candidates = _build_template_options(
|
|
confirm_dir,
|
|
)
|
|
selection_receipt = _resolve_template_confirmation(
|
|
template_selection_payload,
|
|
template_candidates,
|
|
template_options['options_sha256'],
|
|
)
|
|
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
|
return jsonify({
|
|
'error': f'invalid Stage 1 template selection: {exc}',
|
|
}), 400
|
|
template_required = selection_receipt['mode'] == 'templates'
|
|
if selection_file.exists():
|
|
try:
|
|
existing_selection = _read_template_selection(selection_file)
|
|
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
|
return jsonify({
|
|
'error': (
|
|
f'existing template selection is invalid: {exc}; '
|
|
'the agent must run --reset-template-selection'
|
|
),
|
|
}), 409
|
|
if (
|
|
existing_selection['selection_sha256']
|
|
!= selection_receipt['selection_sha256']
|
|
):
|
|
return jsonify({
|
|
'error': (
|
|
'Stage 1 already has a different template selection; '
|
|
'the agent must run --reset-template-selection'
|
|
),
|
|
}), 409
|
|
else:
|
|
write_selection = True
|
|
else:
|
|
if template_selection_payload is not None:
|
|
return jsonify({
|
|
'error': 'template_selection is accepted only in Stage 1',
|
|
}), 400
|
|
stage2_error = _stage2_ready_error(
|
|
project_path,
|
|
confirm_dir,
|
|
rec_file,
|
|
)
|
|
if stage2_error:
|
|
return jsonify({
|
|
'error': f'Stage 2 is waiting for template handoff: {stage2_error}',
|
|
}), 409
|
|
try:
|
|
template_required = _template_confirmation_required(project_path)
|
|
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
|
return jsonify({
|
|
'error': f'cannot determine active template mode: {exc}',
|
|
}), 409
|
|
stage_error = _submission_stage_error(
|
|
confirm_dir,
|
|
stage,
|
|
recommendations_file=rec_file,
|
|
recommendations=current_recommendations,
|
|
template_required=template_required,
|
|
)
|
|
if stage_error:
|
|
return jsonify({'error': stage_error}), 409
|
|
custom_error = _custom_selection_error(result)
|
|
if custom_error:
|
|
return jsonify({'error': custom_error}), 400
|
|
previous_result = {}
|
|
if rec_stage_number >= 2:
|
|
try:
|
|
previous_result = _read_json_object(result_file)
|
|
except (OSError, json.JSONDecodeError, ValueError):
|
|
pass
|
|
main_language = None
|
|
if rec_stage_number > 0:
|
|
language_source = (
|
|
previous_result
|
|
if _recommendation_language(previous_result)
|
|
else current_recommendations
|
|
)
|
|
if not _recommendation_language(language_source):
|
|
language_source = result
|
|
language_error = _canonicalize_primary_language(
|
|
language_source,
|
|
required=True,
|
|
)
|
|
if language_error:
|
|
return jsonify({'error': language_error}), 409
|
|
main_language = _recommendation_language(language_source)
|
|
if main_language:
|
|
result['primary_language'] = main_language
|
|
else:
|
|
result.pop('primary_language', None)
|
|
if rec_stage_number == 2:
|
|
solution_error = _stage2_solution_error(
|
|
result,
|
|
main_language=main_language,
|
|
)
|
|
if solution_error:
|
|
return jsonify({'error': solution_error}), 400
|
|
if rec_stage_number == 2:
|
|
proactive_defaults, proactive_recommendation_error = (
|
|
_resolve_proactive_execution_values(current_recommendations)
|
|
)
|
|
if proactive_recommendation_error:
|
|
return jsonify({
|
|
'error': proactive_recommendation_error,
|
|
}), 409
|
|
proactive_result_error = _normalize_proactive_execution_result(
|
|
result,
|
|
proactive_defaults,
|
|
)
|
|
if proactive_result_error:
|
|
return jsonify({'error': proactive_result_error}), 400
|
|
_normalize_custom_selections(result)
|
|
locked_values = _apply_locked_recommendations(
|
|
result,
|
|
rec_file,
|
|
result_file,
|
|
carry_previous=rec_stage_number > 1,
|
|
)
|
|
if rec_stage_number == 1 or not template_required:
|
|
result.pop('template_application', None)
|
|
locked_values.pop('template_application', None)
|
|
result.pop('template_reuse_scope', None)
|
|
result.pop('template_adherence', None)
|
|
if stage == 'stage1':
|
|
result['stage'] = 'stage1'
|
|
result['status'] = 'stage1-confirmed'
|
|
if locked_values:
|
|
result[_LOCKED_RECOMMENDATIONS_KEY] = locked_values
|
|
else:
|
|
result.pop(_LOCKED_RECOMMENDATIONS_KEY, None)
|
|
result['stage'] = 'final'
|
|
result['status'] = 'confirmed'
|
|
result['confirmed_at'] = time.strftime('%Y-%m-%dT%H:%M:%S')
|
|
if write_selection and selection_receipt is not None:
|
|
_write_json_atomic(selection_file, selection_receipt)
|
|
_write_json_atomic(result_file, result)
|
|
_sync_session_state(
|
|
confirm_dir,
|
|
server_port=app.config.get('SERVER_PORT'),
|
|
event=f'{result["stage"]}-submitted',
|
|
)
|
|
logger.info('%s confirmation written to %s', result['stage'], result_file)
|
|
return jsonify({'status': 'ok'})
|
|
|
|
return app
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(
|
|
description='PPT Master template and Strategist confirmation UI',
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
)
|
|
parser.add_argument('project_dir', help='Path to project directory')
|
|
parser.add_argument(
|
|
'--port', type=int, default=None,
|
|
help=f'Exact port to listen on (default: first free port from {DEFAULT_PORT})',
|
|
)
|
|
parser.add_argument('--no-browser', action='store_true', help='Do not auto-open browser')
|
|
parser.add_argument(
|
|
'--daemon', action='store_true',
|
|
help='Start the server in the background; combine with --wait to block until confirmation',
|
|
)
|
|
parser.add_argument(
|
|
'--wait', action='store_true',
|
|
help='With --daemon, wait until the active result.json stage is written',
|
|
)
|
|
parser.add_argument(
|
|
'--wait-only', action='store_true',
|
|
help='Attach to the confirm server for this project and wait for an '
|
|
'already-open page to write the requested receipt. If it is '
|
|
'already persisted, return without recovery; otherwise recover a '
|
|
'dead server on the recorded/default port so browser polling can resume.',
|
|
)
|
|
parser.add_argument(
|
|
'--wait-stage', default='final', metavar='{stage1,final}',
|
|
help='Wait for this result.json stage (default: final). Use stage1 '
|
|
'after opening the combined template/communication page.',
|
|
)
|
|
parser.add_argument(
|
|
'--wait-timeout', type=int, default=WAIT_TIMEOUT_DEFAULT,
|
|
help=f'Seconds the wait caller blocks before returning (default: {WAIT_TIMEOUT_DEFAULT}; '
|
|
'0 = no limit). Kept under the caller\'s tool timeout; the detached server lives on.',
|
|
)
|
|
parser.add_argument(
|
|
'--timeout', type=int, default=900,
|
|
help='Server idle timeout in seconds (default: 900; 0 = disabled)',
|
|
)
|
|
parser.add_argument(
|
|
'--shutdown', action='store_true',
|
|
help='Stop a confirm server left running for this project, then exit '
|
|
'(idempotent). Run at the end of Step 4 so the page never lingers '
|
|
'on its selected port before live preview starts.',
|
|
)
|
|
parser.add_argument(
|
|
'--complete-template-selection', action='store_true',
|
|
help='Agent-only: after Stage 1, bind its template selection to a ready '
|
|
'handoff. Template mode requires at least one '
|
|
'<project>/templates/design_spec.<kind>.<id>.md.',
|
|
)
|
|
parser.add_argument(
|
|
'--reset-template-selection', action='store_true',
|
|
help='Agent-only: remove exactly template_options.json, '
|
|
'template_selection.json, and template_handoff.json before a '
|
|
'fresh one-run UI lifecycle.',
|
|
)
|
|
return parser
|
|
|
|
|
|
def main(argv: Optional[list[str]] = None) -> int:
|
|
parser = build_parser()
|
|
args = parser.parse_args(argv)
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format='[%(asctime)s] [%(levelname)s] confirm_ui: %(message)s',
|
|
datefmt='%H:%M:%S',
|
|
)
|
|
|
|
if args.port is not None:
|
|
try:
|
|
args.port = _validate_port(args.port)
|
|
except ValueError as exc:
|
|
logger.error('%s', exc)
|
|
return 2
|
|
|
|
project_path = Path(args.project_dir).resolve()
|
|
if not project_path.is_dir():
|
|
logger.error('%s is not a directory', project_path)
|
|
return 1
|
|
wait_stage = _stage_key(str(args.wait_stage).strip().lower())
|
|
if wait_stage not in {'stage1', 'final'}:
|
|
logger.error('--wait-stage must be stage1 or final')
|
|
return 2
|
|
|
|
template_control = (
|
|
args.complete_template_selection
|
|
or args.reset_template_selection
|
|
)
|
|
if template_control and (
|
|
args.daemon or args.wait or args.wait_only or args.shutdown
|
|
):
|
|
logger.error(
|
|
'--complete-template-selection/--reset-template-selection cannot be combined '
|
|
'with server, wait, or shutdown actions'
|
|
)
|
|
return 2
|
|
if args.complete_template_selection and args.reset_template_selection:
|
|
logger.error(
|
|
'--complete-template-selection and --reset-template-selection are '
|
|
'mutually exclusive'
|
|
)
|
|
return 2
|
|
if args.complete_template_selection:
|
|
return _complete_template_selection(project_path)
|
|
if args.reset_template_selection:
|
|
return _reset_template_selection(project_path / CONFIRM_DIR_NAME)
|
|
|
|
# Step 4 cleanup: stop any lingering confirm server and exit. Independent of
|
|
# recommendation files (the page may never have been confirmed).
|
|
if args.shutdown:
|
|
return _shutdown_existing(project_path / LOCK_FILE_NAME)
|
|
|
|
# Staged wait: attach to the server launched by --daemon and block until
|
|
# the page writes the requested Strategist receipt.
|
|
if args.wait_only:
|
|
lock_file = project_path / LOCK_FILE_NAME
|
|
confirm_dir = project_path / CONFIRM_DIR_NAME
|
|
result_file = confirm_dir / RESULT_NAME
|
|
wait_status = _wait_result_status(result_file, wait_stage)
|
|
if wait_status is not None:
|
|
return wait_status
|
|
if wait_stage == 'stage1':
|
|
readiness_error = _stage1_ready_error(confirm_dir)
|
|
else:
|
|
recommendations_file = _active_recommendations_path(confirm_dir)
|
|
readiness_error = _stage2_ready_error(
|
|
project_path,
|
|
confirm_dir,
|
|
recommendations_file,
|
|
)
|
|
if readiness_error:
|
|
logger.error(
|
|
'confirmation stage=%s is not ready: %s',
|
|
wait_stage,
|
|
readiness_error,
|
|
)
|
|
return 1
|
|
if not _live_lock(lock_file):
|
|
launch_error = _confirmation_launch_error(confirm_dir)
|
|
if launch_error:
|
|
logger.error('%s', launch_error)
|
|
return 1
|
|
exact_port = args.port is not None
|
|
recovery_port = (
|
|
args.port
|
|
if exact_port
|
|
else _preferred_recovery_port(lock_file, DEFAULT_PORT)
|
|
)
|
|
try:
|
|
_, actual_port, _ = _launch_background_server(
|
|
project_path,
|
|
preferred_port=recovery_port,
|
|
exact_port=exact_port,
|
|
idle_timeout=args.timeout,
|
|
open_browser=False,
|
|
)
|
|
except RuntimeError as exc:
|
|
logger.error('%s', exc)
|
|
return 1
|
|
if actual_port != recovery_port and not args.no_browser:
|
|
webbrowser.open(_server_url(actual_port))
|
|
logger.info(
|
|
'recovered confirm UI for wait-only at %s; the browser polling should resume',
|
|
_server_url(actual_port),
|
|
)
|
|
return _wait_only_for_result(
|
|
result_file,
|
|
lock_file,
|
|
args.wait_timeout,
|
|
wait_stage,
|
|
)
|
|
|
|
confirm_dir = project_path / CONFIRM_DIR_NAME
|
|
launch_error = _confirmation_launch_error(confirm_dir)
|
|
if launch_error:
|
|
logger.error('%s', launch_error)
|
|
return 1
|
|
|
|
if args.daemon:
|
|
lock_file = project_path / LOCK_FILE_NAME
|
|
existing = _read_lock(lock_file)
|
|
if existing and _process_alive(_lock_pid(existing)):
|
|
existing_pid = existing.get('pid', '?')
|
|
existing_port = existing.get('port', '?')
|
|
logger.error(
|
|
'confirm UI is already running for this project '
|
|
'(pid=%s, port=%s). Open http://%s:%s',
|
|
existing_pid, existing_port, PUBLIC_HOST, existing_port,
|
|
)
|
|
return 1
|
|
|
|
confirm_dir = project_path / CONFIRM_DIR_NAME
|
|
result_file = confirm_dir / RESULT_NAME
|
|
expected_stage = _expected_result_stage(confirm_dir)
|
|
started_at = time.time()
|
|
try:
|
|
proc, port, _ = _launch_background_server(
|
|
project_path,
|
|
preferred_port=args.port if args.port is not None else DEFAULT_PORT,
|
|
exact_port=args.port is not None,
|
|
idle_timeout=args.timeout,
|
|
open_browser=not args.no_browser,
|
|
)
|
|
except RuntimeError as exc:
|
|
logger.error('%s', exc)
|
|
return 1
|
|
if args.wait:
|
|
return _wait_for_result(
|
|
result_file,
|
|
proc,
|
|
started_at,
|
|
args.wait_timeout,
|
|
expected_stage,
|
|
)
|
|
return 0
|
|
|
|
try:
|
|
port = args.port if args.port is not None else _find_free_port(DEFAULT_PORT)
|
|
except RuntimeError as exc:
|
|
logger.error('%s', exc)
|
|
return 1
|
|
|
|
# Per-project mutual exclusion: refuse duplicate launches. Stale locks
|
|
# (dead pid) are overwritten by _claim_lock.
|
|
lock_file = project_path / LOCK_FILE_NAME
|
|
existing = _claim_lock(lock_file, port)
|
|
if existing:
|
|
existing_pid = existing.get('pid', '?')
|
|
existing_port = existing.get('port', '?')
|
|
logger.error(
|
|
'confirm UI is already running for this project '
|
|
'(pid=%s, port=%s). Open http://%s:%s, or run: kill %s',
|
|
existing_pid, existing_port, PUBLIC_HOST, existing_port, existing_pid,
|
|
)
|
|
return 1
|
|
atexit.register(_release_lock, lock_file)
|
|
|
|
def _on_sigterm(signum: int, _frame) -> None:
|
|
logger.info('received signal %s, exiting', signum)
|
|
sys.exit(0)
|
|
try:
|
|
signal.signal(signal.SIGTERM, _on_sigterm)
|
|
except (ValueError, OSError):
|
|
pass
|
|
|
|
app = create_app(
|
|
str(project_path),
|
|
idle_timeout=args.timeout,
|
|
lock_file=lock_file,
|
|
server_port=port,
|
|
)
|
|
|
|
url = _server_url(port)
|
|
if not args.no_browser:
|
|
_open_browser_async(url)
|
|
|
|
logger.info('running at %s', url)
|
|
logger.info('project: %s', project_path)
|
|
logger.info('idle timeout: %ds (0 = disabled)', args.timeout)
|
|
app.run(host=PUBLIC_HOST, port=port, debug=False)
|
|
return 0
|
|
|
|
|
|
if __name__ == '__main__':
|
|
raise SystemExit(main())
|