"""Grid Creator order tracking. The Grid Creator page records an order here, then triggers this tenant's voice agent (via a canvas 'speak' shortcut) to mesh-send the job to the Mac creative node under the agent's own identity — so the Mac knows the client/brand. The Mac generates via ChatGPT (no per-image cost), writes the finished images into this tenant's uploads/, and drops a status marker the page polls here. This route only OWNS the order record + status read-back. It deliberately does NOT touch the mesh queue (OVU has no mesh mount) — the voice agent is the bridge, per the confirmed contract in docs/jambot/grid-creator-spec.md. Order/status files live UNDER the uploads/ dir — the one runtime subdir that is bind-mounted to the host (`/mnt/clients//openvoiceui/uploads/`) AND is world-writable (drwxrwxrwx), so BOTH this route (container uid 1001) and the Mac creative node (host uid 1000) can write to it. A top-level runtime/grid-orders/ would live only inside the container fs — the Mac's host-side marker would be invisible to the page. Do NOT move this out from under uploads/ without adding a corresponding compose bind-mount to every tenant. uploads/grid-orders/.json # created by this route (status=queued) uploads/grid-orders/.status.json # written by the Mac on progress/completion Host path the Mac writes to: /mnt/clients//openvoiceui/uploads/grid-orders/ """ from __future__ import annotations import json import os import time from pathlib import Path from flask import Blueprint, request, jsonify from services.paths import RUNTIME_DIR, UPLOADS_DIR grid_gen_bp = Blueprint('grid_gen', __name__) # Under uploads/ (bind-mounted + world-writable) so the Mac's host-side markers are visible here. ORDERS_DIR = RUNTIME_DIR / 'uploads' / 'grid-orders' _VALID_TYPES = {'post', 'logo', 'character', 'mascot'} _IMAGE_EXTS = {'.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp', '.tiff', '.tif', '.avif', '.heic', '.heif'} _MAX_REFS = 6 def _sanitize_refs(raw) -> list: """Keep only real image files that actually live in this tenant's uploads/ dir. Refs are basenames the user picked/uploaded (e.g. 'logo__a1b2c3d4.png'). We reject anything with a path separator or that doesn't resolve to an existing image directly under uploads/, so a ref can never point the Mac at a file outside the tenant's own uploads. """ out = [] for r in (raw or [])[:24]: name = (str(r) or '').strip().lstrip('/') if not name or '/' in name or '\\' in name or '..' in name: continue p = UPLOADS_DIR / name if p.suffix.lower() in _IMAGE_EXTS and p.is_file(): if name not in out: out.append(name) if len(out) >= _MAX_REFS: break return out def _orders_dir() -> Path: ORDERS_DIR.mkdir(parents=True, exist_ok=True) # This route usually creates the dir first (order placed before the Mac ever sees it); make # it world-writable so the Mac (different uid) can drop status markers into the same dir. try: os.chmod(ORDERS_DIR, 0o777) except OSError: pass return ORDERS_DIR @grid_gen_bp.route('/api/grid-gen/orders', methods=['POST']) def create_order(): d = request.get_json(silent=True) or {} rid = (d.get('request_id') or '').strip() if not rid or '/' in rid or '..' in rid: return jsonify({'error': 'valid request_id required'}), 400 if (d.get('grid_type') or '') not in _VALID_TYPES: return jsonify({'error': 'invalid grid_type'}), 400 order = { 'request_id': rid, 'grid_type': d.get('grid_type'), 'brief': (d.get('brief') or '')[:2000], 'count': max(1, min(24, int(d.get('count') or 9))), 'aspect': d.get('aspect') or '1:1', 'layout': 'composite' if d.get('layout') != 'separate' else 'separate', # Reference images the user uploaded/picked from uploads/. The Mac reads these from this # order record (authoritative) and passes them to the generator as --img inputs — the # agent path never has to relay filenames. Basenames only; validated to exist in uploads/. 'refs': _sanitize_refs(d.get('refs')), 'status': 'queued', 'created_at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), } (_orders_dir() / f'{rid}.json').write_text(json.dumps(order)) return jsonify({'ok': True, 'request_id': rid, 'refs': order['refs']}) @grid_gen_bp.route('/api/grid-gen/uploads', methods=['GET']) def list_uploads(): """Image files in this tenant's uploads/ dir, for the reference-image picker. Flat listing (excludes subdirs like grid-orders/, albums/) newest-first, images only. """ out = [] try: entries = [p for p in UPLOADS_DIR.iterdir() if p.is_file() and not p.name.startswith('.') and p.suffix.lower() in _IMAGE_EXTS] except OSError: entries = [] for p in sorted(entries, key=lambda p: p.stat().st_mtime, reverse=True)[:300]: out.append({'name': p.name, 'url': f'/uploads/{p.name}', 'mtime': int(p.stat().st_mtime)}) return jsonify(out) @grid_gen_bp.route('/api/grid-gen/orders', methods=['GET']) def list_orders(): out = [] d = _orders_dir() for f in sorted(d.glob('*.json'), key=lambda p: p.stat().st_mtime, reverse=True): if f.name.endswith('.status.json'): continue try: order = json.loads(f.read_text()) except (OSError, json.JSONDecodeError): continue # Merge the Mac's status marker if present (it wins on status/files/error). marker = d / f"{order.get('request_id')}.status.json" if marker.exists(): try: st = json.loads(marker.read_text()) for k in ('status', 'files', 'error', 'done', 'updated_at'): if k in st: order[k] = st[k] except (OSError, json.JSONDecodeError): pass out.append(order) return jsonify(out[:50])