Skip to main content

pharos_native/runtime/
architect_http.rs

1//! Architect HTTP request, preview, editor, and export handlers.
2
3use super::*;
4use pharos_runtime_architect::AgentErrorKind;
5
6pub(super) fn architect_state_parent(app: &AppRuntime) -> Option<PathBuf> {
7    app.state_path
8        .as_deref()
9        .map(Path::new)
10        .and_then(Path::parent)
11        .map(Path::to_path_buf)
12}
13
14/// Resolves Architect attachments and preview images through its independent media configuration.
15fn architect_media_root(app: &AppRuntime) -> Option<PathBuf> {
16    let app_root = app.app_root.as_deref().map(Path::new)?;
17    if config::dynamic_config_effective_value(app_root, None, "MEDIA_ROOT").is_some() {
18        config::app_media_root(app_root)
19    } else {
20        architect_state_parent(app).map(|parent| parent.join("media/architect"))
21    }
22}
23
24pub(super) fn architect_vault_paths(app: &AppRuntime) -> Option<(PathBuf, PathBuf)> {
25    let state_parent = architect_state_parent(app)?;
26    let vault_dir = state_parent.join("vault");
27    Some((
28        vault_dir.join("architect-llm-vendor-secrets.enc"),
29        vault_dir.join("architect-root.key"),
30    ))
31}
32
33pub(super) fn architect_generated_source_root(app: &AppRuntime) -> Option<PathBuf> {
34    let app_root = app.app_root.as_deref().map(Path::new)?;
35    let runtime_conf = app.runtime_conf_path.as_deref().map(Path::new);
36    config::dynamic_config_effective_value(
37        app_root,
38        runtime_conf,
39        "architect_generated_source_root",
40    )
41    .map(PathBuf::from)
42    .or_else(|| architect_state_parent(app).map(|state_parent| state_parent.join("generated")))
43}
44
45pub(super) fn architect_import_source_app_roots(app: &AppRuntime) -> Vec<PathBuf> {
46    let mut roots = Vec::new();
47    let mut push_root = |path: PathBuf| {
48        if !path.as_os_str().is_empty() && !roots.iter().any(|existing| existing == &path) {
49            roots.push(path);
50        }
51    };
52    if let Some(app_root) = app.app_root.as_deref().map(Path::new)
53        && let Some(parent) = app_root.parent()
54    {
55        push_root(parent.to_path_buf());
56    }
57    if let Some(artifact_root) = app.artifact_dir.as_deref().map(Path::new)
58        && let Some(parent) = artifact_root.parent()
59    {
60        push_root(parent.to_path_buf());
61    }
62    if let Ok(cwd) = std::env::current_dir() {
63        push_root(cwd.join("apps"));
64        push_root(cwd.join("..").join("apps"));
65    }
66    roots
67}
68
69pub(super) fn ensure_architect_root_key(root_key_file: &Path) -> Result<(), i32> {
70    let Some(parent) = root_key_file.parent() else {
71        return Err(69);
72    };
73    stdfs::create_dir_all(parent).map_err(|_| 69)?;
74    #[cfg(unix)]
75    {
76        use std::os::unix::fs::PermissionsExt;
77        stdfs::set_permissions(parent, stdfs::Permissions::from_mode(0o700)).map_err(|_| 69)?;
78    }
79    if let Ok(metadata) = stdfs::symlink_metadata(root_key_file) {
80        return if metadata.is_file()
81            && !metadata.file_type().is_symlink()
82            && (32..=4096).contains(&metadata.len())
83        {
84            Ok(())
85        } else {
86            Err(69)
87        };
88    }
89    let root_key = dynamic::random_secret_hex(32).map_err(|_| 69)?;
90    let mut options = stdfs::OpenOptions::new();
91    options.write(true).create_new(true);
92    #[cfg(unix)]
93    {
94        use std::os::unix::fs::OpenOptionsExt;
95        options.mode(0o600);
96    }
97    let mut file = options.open(root_key_file).map_err(|_| 69)?;
98    std::io::Write::write_all(&mut file, root_key.as_bytes()).map_err(|_| 69)?;
99    file.sync_all().map_err(|_| 69)
100}
101
102fn request_query_value(target: &str, field: &str) -> Option<String> {
103    let query = target.split_once('?')?.1;
104    json_commands::request_form_value(query, field)
105}
106
107fn architect_fragment_html(
108    app: &AppRuntime,
109    relative_path: &str,
110    params: &Value,
111) -> Option<String> {
112    let artifact_root = app.artifact_dir.as_deref().map(Path::new)?;
113    json_commands::template_render_file(&artifact_root.join(relative_path), params)
114}
115
116pub(super) fn architect_attachment_media_type(filename: &str, supplied: Option<String>) -> String {
117    let inferred = match filename
118        .to_ascii_lowercase()
119        .rsplit('.')
120        .next()
121        .unwrap_or_default()
122    {
123        "css" => Some("text/css"),
124        "html" | "htm" => Some("text/html"),
125        "svg" => Some("image/svg+xml"),
126        "woff" => Some("font/woff"),
127        "woff2" => Some("font/woff2"),
128        "ttf" => Some("font/ttf"),
129        "otf" => Some("font/otf"),
130        "txt" | "md" => Some("text/plain"),
131        "json" => Some("application/json"),
132        "yml" | "yaml" => Some("application/yaml"),
133        "toml" => Some("application/toml"),
134        "sql" => Some("application/sql"),
135        "js" => Some("application/javascript"),
136        "zip" => Some("application/zip"),
137        "docx" => Some("application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
138        _ => None,
139    };
140    inferred
141        .map(str::to_owned)
142        .or(supplied.filter(|value| value.len() <= 120 && !value.contains(['\r', '\n'])))
143        .unwrap_or_else(|| "application/octet-stream".to_owned())
144}
145
146/// Forwards bounded app filters while retaining Architect preview selection as outer routing authority.
147fn architect_preview_query_target(route: &str, outer: &str) -> Option<String> {
148    if route.len() > 16 * 1024
149        || outer.len() > 16 * 1024
150        || route.contains(['\0', '\r', '\n'])
151        || outer.contains(['\0', '\r', '\n'])
152    {
153        return None;
154    }
155    let mut target = route.to_owned();
156    let mut count = 0;
157    for pair in outer
158        .split_once('?')
159        .map(|(_, query)| query)
160        .unwrap_or("")
161        .split('&')
162        .filter(|pair| !pair.is_empty())
163    {
164        let key = json_commands::url_decode(pair.split('=').next()?)?;
165        if matches!(key.as_str(), "task_id" | "app_id" | "path") {
166            continue;
167        }
168        count += 1;
169        if count > 64 {
170            return None;
171        }
172        target.push(if target.contains('?') { '&' } else { '?' });
173        target.push_str(pair);
174        if target.len() > 16 * 1024 {
175            return None;
176        }
177    }
178    Some(target)
179}
180
181pub(super) fn architect_task_console_response(
182    app: &AppRuntime,
183    request: &http::HttpRequest,
184    method: &str,
185) -> Vec<u8> {
186    let Some(artifact_root) = app.artifact_dir.as_deref().map(Path::new) else {
187        return http_text_response(500, "Internal Server Error", "response unavailable\n");
188    };
189    let csrf_token = dynamic::csrf_token_for_response(request.cookie.as_deref());
190    let task_route = matched_app_route(app, "GET", "/tasks/status").unwrap_or_else(|| {
191        serde_json::json!({
192            "page_id": "task_status",
193            "feature_id": "architect_builder_console",
194            "template": "templates/fragments/task_console.html",
195            "queries": ["recent_tasks", "recent_events"]
196        })
197    });
198    let body = match feature_runtime::render_feature_page_body(
199        app,
200        &task_route,
201        "/tasks/status",
202        match active_session_user_id(app, request.cookie.as_deref()) {
203            Ok(value) => value,
204            Err(error) => return std::convert::identity(database_error_response(error)),
205        },
206        &csrf_token,
207    ) {
208        Ok(body) => body,
209        Err(error) => {
210            eprintln!("architect task console render failed: {error:?}");
211            return http_text_response(500, "Internal Server Error", "response unavailable\n");
212        }
213    };
214    let body = architect_guard_preview_actions(app, &body, true);
215    let body = dynamic::csrf_inject_hidden_inputs(artifact_root, &body, &csrf_token);
216    http_html_response(200, "OK", &body, method)
217}
218
219pub(super) fn architect_editor_panel_response(
220    app: &AppRuntime,
221    request: &http::HttpRequest,
222    method: &str,
223) -> Vec<u8> {
224    let Some(artifact_root) = app.artifact_dir.as_deref().map(Path::new) else {
225        return http_text_response(500, "Internal Server Error", "response unavailable\n");
226    };
227    let csrf_token = dynamic::csrf_token_for_response(request.cookie.as_deref());
228    let editor_route = matched_app_route(app, "GET", "/editor").unwrap_or_else(|| {
229        serde_json::json!({
230            "page_id": "editor",
231            "feature_id": "architect_builder_console",
232            "template": "templates/editor.body.html",
233            "queries": ["editor_projects", "editor_versions", "source_exports"]
234        })
235    });
236    let body = match feature_runtime::render_feature_page_body(
237        app,
238        &editor_route,
239        "/editor",
240        match active_session_user_id(app, request.cookie.as_deref()) {
241            Ok(value) => value,
242            Err(error) => return std::convert::identity(database_error_response(error)),
243        },
244        &csrf_token,
245    ) {
246        Ok(body) => body,
247        Err(error) => {
248            eprintln!("architect editor render failed: {error:?}");
249            return http_text_response(500, "Internal Server Error", "response unavailable\n");
250        }
251    };
252    let body = architect_guard_preview_actions(app, &body, false);
253    let body = dynamic::csrf_inject_hidden_inputs(artifact_root, &body, &csrf_token);
254    http_html_response(200, "OK", &body, method)
255}
256
257pub(super) fn architect_settings_access_denied_response(
258    app: &AppRuntime,
259    request: &http::HttpRequest,
260    method: &str,
261    target: &str,
262) -> Vec<u8> {
263    let Some(artifact_root) = app.artifact_dir.as_deref().map(Path::new) else {
264        return http_text_response(500, "Internal Server Error", "response unavailable\n");
265    };
266    let return_url = app_scope_path(app, "/");
267    let params = serde_json::json!({
268        "error_code": "access_denied",
269        "return_delay_seconds": "3",
270        "return_url": return_url,
271    });
272    let Some(body) = json_commands::template_render_file(
273        &artifact_root.join("templates/errors/access_denied.body.html"),
274        &params,
275    ) else {
276        return http_text_response(500, "Internal Server Error", "response unavailable\n");
277    };
278    let actor_id = match active_session_user_id(app, request.cookie.as_deref()) {
279        Ok(value) => value,
280        Err(error) => return std::convert::identity(database_error_response(error)),
281    };
282    let rendered = json_commands::template_layout_page_html(
283        artifact_root,
284        app.base_path.as_deref(),
285        Some("architect_shell"),
286        Some("en"),
287        "Access denied",
288        &body,
289        Some(target),
290        actor_id > 0,
291        0,
292    )
293    .unwrap_or(body);
294    let refresh_header = format!("Refresh: 3; url={}", app_scope_path(app, "/"));
295    http_html_response_with_headers(403, "Forbidden", &rendered, method, &refresh_header)
296}
297
298pub(super) fn architect_settings_review_response(
299    app: &AppRuntime,
300    request: &http::HttpRequest,
301    method: &str,
302) -> Vec<u8> {
303    let Some(artifact_root) = app.artifact_dir.as_deref().map(Path::new) else {
304        return http_text_response(500, "Internal Server Error", "response unavailable\n");
305    };
306    let csrf_token = dynamic::csrf_token_for_response(request.cookie.as_deref());
307    let mut settings_route = matched_app_route(app, "GET", "/settings").unwrap_or_else(|| {
308        serde_json::json!({
309            "page_id": "settings",
310            "feature_id": "architect_builder_console",
311            "template": "templates/settings.body.html",
312            "queries": ["llm_vendors", "settings_llm_vendor_options", "target_profiles", "workspace_settings", "privacy_settings", "architect_users", "recent_vault_audits", "source_exports", "recent_attachments", "recent_attachment_extracts", "recent_transcripts"]
313        })
314    });
315    let actor_id = match active_session_user_id(app, request.cookie.as_deref()) {
316        Ok(value) => value,
317        Err(error) => return std::convert::identity(database_error_response(error)),
318    };
319    match actor_has_route_policy(app, actor_id, "manage_llm_vendors") {
320        Ok(true) => {}
321        Ok(false) => {
322            return architect_settings_access_denied_response(app, request, method, "/settings");
323        }
324        Err(error) => return database_error_response(error),
325    }
326    match actor_has_route_policy(app, actor_id, "manage_builder_settings") {
327        Ok(true) => {}
328        Ok(false) => {
329            settings_route["page_id"] = serde_json::json!("settings_llm");
330            settings_route["queries"] = serde_json::json!(["llm_vendors"]);
331        }
332        Err(error) => return database_error_response(error),
333    }
334    let mut rendered_body = None;
335    let mut last_error = None;
336    let max_attempts = 40;
337    for attempt in 0..max_attempts {
338        match feature_runtime::render_feature_page_body(
339            app,
340            &settings_route,
341            "/settings",
342            actor_id,
343            &csrf_token,
344        ) {
345            Ok(body) => {
346                rendered_body = Some(body);
347                break;
348            }
349            Err(error) => {
350                last_error = Some(error);
351                if attempt + 1 < max_attempts {
352                    std::thread::sleep(Duration::from_millis(100));
353                }
354            }
355        }
356    }
357    let body = match rendered_body {
358        Some(body) => body,
359        None => {
360            if let Some(error) = last_error {
361                eprintln!("architect settings review render failed: {error:?}");
362            }
363            return http_text_response(500, "Internal Server Error", "response unavailable\n");
364        }
365    };
366    let body = dynamic::csrf_inject_hidden_inputs(artifact_root, &body, &csrf_token);
367    if dynamic::request_is_hx(Some(request)) {
368        return http_html_response(200, "OK", &body, method);
369    }
370    let target = app_relative_target(app, request.target.as_str());
371    let rendered = json_commands::template_layout_page_html(
372        artifact_root,
373        app.base_path.as_deref(),
374        Some("architect_shell"),
375        Some("en"),
376        "Settings",
377        &body,
378        Some(&target),
379        actor_id > 0,
380        1,
381    )
382    .unwrap_or(body);
383    let rendered = dynamic::csrf_inject_hidden_inputs(artifact_root, &rendered, &csrf_token);
384    http_html_response_with_csrf(200, "OK", &rendered, method, Some(&csrf_token))
385}
386
387pub(super) fn architect_account_level(value: &str) -> Option<&'static str> {
388    match value {
389        "architect_admin" => Some("architect_admin"),
390        "build_user" => Some("build_user"),
391        _ => None,
392    }
393}
394
395pub(super) fn architect_form_text(request: &http::HttpRequest, field: &str) -> String {
396    dynamic::request_form_value(request, field)
397        .unwrap_or_default()
398        .trim()
399        .to_owned()
400}
401
402pub(super) fn architect_valid_username(value: &str) -> bool {
403    !value.is_empty()
404        && value.len() <= 120
405        && value
406            .bytes()
407            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
408}
409
410pub(super) fn architect_valid_email(value: &str) -> bool {
411    let Some((local, domain)) = value.split_once('@') else {
412        return false;
413    };
414    !local.is_empty()
415        && !domain.is_empty()
416        && value.len() <= 240
417        && !value.contains(['\r', '\n', '<', '>', ' '])
418        && domain.contains('.')
419}
420
421pub(super) fn architect_manage_users_allowed_response(
422    app: &AppRuntime,
423    request: &http::HttpRequest,
424) -> Result<i64, Vec<u8>> {
425    if app.app_id.as_deref() != Some("architect") {
426        return Err(http_text_response(404, "Not Found", "not found\n"));
427    }
428    let actor_id = match active_session_user_id(app, request.cookie.as_deref()) {
429        Ok(value) => value,
430        Err(error) => return Err(database_error_response(error)),
431    };
432    if actor_id <= 0 {
433        return Err(app_login_redirect(app, "/settings"));
434    }
435    if !match actor_has_route_policy(app, actor_id, "manage_architect_users") {
436        Ok(value) => value,
437        Err(error) => return Err(database_error_response(error)),
438    } {
439        return Err(http_text_response(403, "Forbidden", "capability denied\n"));
440    }
441    Ok(actor_id)
442}
443
444pub(super) fn architect_create_user_response(
445    app: &AppRuntime,
446    request: &http::HttpRequest,
447    method: &str,
448) -> Vec<u8> {
449    if let Err(response) = architect_manage_users_allowed_response(app, request) {
450        return response;
451    }
452    let username = architect_form_text(request, "username");
453    let email = architect_form_text(request, "email");
454    let account_level = architect_form_text(request, "account_level");
455    let password = dynamic::request_form_value(request, "password").unwrap_or_default();
456    let password_confirm =
457        dynamic::request_form_value(request, "password_confirm").unwrap_or_default();
458    let Some(role) = architect_account_level(&account_level) else {
459        return http_text_response(422, "Unprocessable Entity", "invalid account level\n");
460    };
461    if !architect_valid_username(&username) || !architect_valid_email(&email) {
462        return http_text_response(422, "Unprocessable Entity", "invalid user details\n");
463    }
464    if password.len() < 8 || password.len() > 256 || password != password_confirm {
465        return http_text_response(422, "Unprocessable Entity", "invalid password\n");
466    }
467    let Ok(password_hash) = dynamic::hash_password(&password) else {
468        return http_text_response(500, "Internal Server Error", "password hash unavailable\n");
469    };
470    match architect_create_user(app, &username, &email, &username, role, &password_hash) {
471        Ok(user_id) => {
472            if let Ok(cache_scope) = super::app_security_cache_scope(app) {
473                pharos_runtime_security::local_role_cache_revoke_actor(&cache_scope, user_id);
474                pharos_runtime_security::local_session_cache_revoke_user(&cache_scope, user_id);
475                super::cache::session_cache_revoke_user(&cache_scope, user_id);
476            }
477            architect_settings_review_response(app, request, method)
478        }
479        Err(_) => http_text_response(422, "Unprocessable Entity", "user could not be created\n"),
480    }
481}
482
483pub(super) fn architect_set_user_password_response(
484    app: &AppRuntime,
485    request: &http::HttpRequest,
486    method: &str,
487) -> Vec<u8> {
488    if let Err(response) = architect_manage_users_allowed_response(app, request) {
489        return response;
490    }
491    let user_id = architect_form_text(request, "user_id")
492        .parse::<i64>()
493        .unwrap_or(0);
494    let password = dynamic::request_form_value(request, "password").unwrap_or_default();
495    let password_confirm =
496        dynamic::request_form_value(request, "password_confirm").unwrap_or_default();
497    if user_id <= 0 {
498        return http_text_response(
499            422,
500            "Unprocessable Entity",
501            "invalid user update
502",
503        );
504    }
505    if password.len() < 8 || password.len() > 256 || password != password_confirm {
506        return http_text_response(
507            422,
508            "Unprocessable Entity",
509            "invalid password
510",
511        );
512    }
513    let Ok(password_hash) = dynamic::hash_password(&password) else {
514        return http_text_response(
515            500,
516            "Internal Server Error",
517            "password hash unavailable
518",
519        );
520    };
521    match architect_set_user_password(app, user_id, &password_hash) {
522        Ok(()) => architect_settings_review_response(app, request, method),
523        Err(_) => http_text_response(
524            422,
525            "Unprocessable Entity",
526            "password could not be updated
527",
528        ),
529    }
530}
531
532pub(super) fn architect_update_user_response(
533    app: &AppRuntime,
534    request: &http::HttpRequest,
535    method: &str,
536) -> Vec<u8> {
537    let actor_id = match architect_manage_users_allowed_response(app, request) {
538        Ok(actor_id) => actor_id,
539        Err(response) => return response,
540    };
541    let user_id = architect_form_text(request, "user_id")
542        .parse::<i64>()
543        .unwrap_or(0);
544    let is_active = architect_form_text(request, "is_active")
545        .parse::<i64>()
546        .unwrap_or(-1);
547    let account_level = architect_form_text(request, "account_level");
548    let password = dynamic::request_form_value(request, "password").unwrap_or_default();
549    let password_confirm =
550        dynamic::request_form_value(request, "password_confirm").unwrap_or_default();
551    let password_change_requested = !password.is_empty() || !password_confirm.is_empty();
552    let Some(role) = architect_account_level(&account_level) else {
553        return http_text_response(422, "Unprocessable Entity", "invalid account level\n");
554    };
555    if user_id <= 0 || !matches!(is_active, 0 | 1) {
556        return http_text_response(422, "Unprocessable Entity", "invalid user update\n");
557    }
558    if user_id == actor_id && (is_active == 0 || role != "architect_admin") {
559        return http_text_response(
560            422,
561            "Unprocessable Entity",
562            "cannot remove your own admin access\n",
563        );
564    }
565    let password_hash = if password_change_requested {
566        if password.len() < 8 || password.len() > 256 || password != password_confirm {
567            return http_text_response(422, "Unprocessable Entity", "invalid password\n");
568        }
569        match dynamic::hash_password(&password) {
570            Ok(password_hash) => Some(password_hash),
571            Err(_) => {
572                return http_text_response(
573                    500,
574                    "Internal Server Error",
575                    "password hash unavailable\n",
576                );
577            }
578        }
579    } else {
580        None
581    };
582    match architect_update_user(app, user_id, role, is_active == 1) {
583        Ok(()) => {
584            if let Some(password_hash) = password_hash.as_deref()
585                && architect_set_user_password(app, user_id, password_hash).is_err()
586            {
587                return http_text_response(
588                    422,
589                    "Unprocessable Entity",
590                    "password could not be updated\n",
591                );
592            }
593            if let Ok(cache_scope) = super::app_security_cache_scope(app) {
594                pharos_runtime_security::local_role_cache_revoke_actor(&cache_scope, user_id);
595                pharos_runtime_security::local_session_cache_revoke_user(&cache_scope, user_id);
596                super::cache::session_cache_revoke_user(&cache_scope, user_id);
597            }
598            architect_settings_review_response(app, request, method)
599        }
600        Err(_) => http_text_response(422, "Unprocessable Entity", "user could not be updated\n"),
601    }
602}
603
604/// Deletes another account through the logical contract and revokes cached access.
605pub(super) fn architect_delete_user_response(
606    app: &AppRuntime,
607    request: &http::HttpRequest,
608    method: &str,
609) -> Vec<u8> {
610    let actor_id = match architect_manage_users_allowed_response(app, request) {
611        Ok(actor_id) => actor_id,
612        Err(response) => return response,
613    };
614    let user_id = architect_form_text(request, "user_id")
615        .parse::<i64>()
616        .unwrap_or(0);
617    if user_id <= 0 || user_id == actor_id {
618        return http_text_response(
619            422,
620            "Unprocessable Entity",
621            "cannot delete your own account or an invalid user\n",
622        );
623    }
624    let (config, adapter, schema) = match architect_user_storage(app) {
625        Ok(storage) => storage,
626        Err(_) => {
627            return http_text_response(503, "Service Unavailable", "user storage unavailable\n");
628        }
629    };
630    match pharos_runtime_architect::user_state::delete_user(
631        &config,
632        adapter.as_ref(),
633        &schema,
634        user_id,
635    ) {
636        Ok(()) => {
637            if let Ok(cache_scope) = super::app_security_cache_scope(app) {
638                pharos_runtime_security::local_role_cache_revoke_actor(&cache_scope, user_id);
639                pharos_runtime_security::local_session_cache_revoke_user(&cache_scope, user_id);
640                super::cache::session_cache_revoke_user(&cache_scope, user_id);
641            }
642            #[cfg(not(target_os = "wasi"))]
643            if let Ok(authority) =
644                pharos_runtime_architect::chatgpt::InstalledArchitect::from_runtime(app)
645                && pharos_runtime_architect::chatgpt::service::erase_deleted_user(
646                    &authority, user_id,
647                )
648                .is_err()
649            {
650                return http_text_response(
651                    503,
652                    "Service Unavailable",
653                    "User deleted; ChatGPT credential cleanup requires retry",
654                );
655            }
656            architect_settings_review_response(app, request, method)
657        }
658        Err(pharos_runtime_database::operations::DatabaseError::Conflict) => http_text_response(
659            409,
660            "Conflict",
661            "user has retained project or activity history; disable the account instead\n",
662        ),
663        Err(error) => database_error_response(error),
664    }
665}
666
667/// Resolves the configured database and typed Architect account declarations.
668fn architect_user_storage(
669    app: &AppRuntime,
670) -> Result<
671    (
672        pharos_runtime_database::DbBackendConfig,
673        std::sync::Arc<dyn pharos_runtime_database::operations::DatabaseOperations>,
674        pharos_runtime_database::operations::AppSchema,
675    ),
676    i32,
677> {
678    architect_database_storage_for_app(app, &["user_account", "store_membership"])
679}
680
681/// Resolves the configured adapter and the Architect entities needed by one route.
682fn architect_database_storage_for_app(
683    app: &AppRuntime,
684    entities: &[&str],
685) -> Result<
686    (
687        pharos_runtime_database::DbBackendConfig,
688        std::sync::Arc<dyn pharos_runtime_database::operations::DatabaseOperations>,
689        pharos_runtime_database::operations::AppSchema,
690    ),
691    i32,
692> {
693    let app_root = app.app_root.as_deref().map(Path::new).ok_or(69)?;
694    let artifact_root = app
695        .artifact_dir
696        .as_deref()
697        .map(Path::new)
698        .unwrap_or(app_root);
699    agent_architect::architect_database_storage(
700        app_root,
701        artifact_root,
702        app.runtime_conf_path.as_deref().map(Path::new),
703        entities,
704    )
705    .map_err(|_| 69)
706}
707
708/// Opens the Architect task store through the configured logical database contract.
709pub(super) fn architect_task_store_for_app(
710    app: &AppRuntime,
711) -> Result<pharos_runtime_architect::database::ArchitectStore, i32> {
712    let app_root = app.app_root.as_deref().map(Path::new).ok_or(69)?;
713    let artifact_root = app
714        .artifact_dir
715        .as_deref()
716        .map(Path::new)
717        .unwrap_or(app_root);
718    agent_architect::architect_worker_store(
719        app_root,
720        artifact_root,
721        app.runtime_conf_path.as_deref().map(Path::new),
722    )
723    .map_err(|_| 69)
724}
725
726/// Creates an Architect account through one logical database transaction.
727fn architect_create_user(
728    app: &AppRuntime,
729    username: &str,
730    email: &str,
731    display_name: &str,
732    role: &str,
733    password_hash: &str,
734) -> Result<i64, i32> {
735    let (config, adapter, schema) = architect_user_storage(app)?;
736    pharos_runtime_architect::user_state::create_user(
737        &config,
738        adapter.as_ref(),
739        &schema,
740        pharos_runtime_architect::user_state::NewUser {
741            username,
742            email,
743            display_name,
744            role,
745            password_hash,
746        },
747    )
748    .map_err(|_| 69)
749}
750
751pub(super) fn architect_set_user_password(
752    app: &AppRuntime,
753    user_id: i64,
754    password_hash: &str,
755) -> Result<(), i32> {
756    let app_root = app.app_root.as_deref().map(Path::new).ok_or(69)?;
757    let runtime_conf = app.runtime_conf_path.as_deref().map(Path::new);
758    #[cfg(not(target_os = "wasi"))]
759    if let Ok(authority) = pharos_runtime_architect::chatgpt::InstalledArchitect::from_runtime(app)
760    {
761        let store = architect_task_store_for_app(app)?;
762        pharos_runtime_architect::chatgpt::service::disconnect(&authority, &store, user_id, false)
763            .map_err(|_| 69)?;
764    }
765    database::dynamic_relational_update_user_password(
766        app_root,
767        runtime_conf,
768        app.app_id.as_deref(),
769        user_id,
770        password_hash,
771    )
772    .map_err(|_| 69)
773}
774
775/// Updates Architect activation and role through one logical transaction.
776fn architect_update_user(
777    app: &AppRuntime,
778    user_id: i64,
779    role: &str,
780    is_active: bool,
781) -> Result<(), i32> {
782    let (config, adapter, schema) = architect_user_storage(app)?;
783    pharos_runtime_architect::user_state::update_user(
784        &config,
785        adapter.as_ref(),
786        &schema,
787        user_id,
788        role,
789        is_active,
790    )
791    .map_err(|_| 69)
792}
793
794pub(super) fn architect_configure_llm_vendor_connection_response(
795    app: &AppRuntime,
796    request: &http::HttpRequest,
797    method: &str,
798) -> Vec<u8> {
799    if app.app_id.as_deref() != Some("architect") {
800        return http_text_response(404, "Not Found", "not found\n");
801    }
802    let actor_id = match active_session_user_id(app, request.cookie.as_deref()) {
803        Ok(value) => value,
804        Err(error) => return std::convert::identity(database_error_response(error)),
805    };
806    if actor_id <= 0 {
807        return app_login_redirect(app, "/settings");
808    }
809    if !match actor_has_route_policy(app, actor_id, "manage_llm_vendors") {
810        Ok(value) => value,
811        Err(error) => return std::convert::identity(database_error_response(error)),
812    } {
813        return http_text_response(403, "Forbidden", "capability denied\n");
814    }
815    let Ok(store) = architect_task_store_for_app(app) else {
816        return http_text_response(503, "Service Unavailable", "database unavailable\n");
817    };
818    let llm_vendor_id = dynamic::request_form_value(request, "llm_vendor_id").unwrap_or_default();
819    let llm_vendor_kind =
820        dynamic::request_form_value(request, "llm_vendor_kind").unwrap_or_default();
821    let display_name = dynamic::request_form_value(request, "display_name").unwrap_or_default();
822    let model_id = dynamic::request_form_value(request, "model_id").unwrap_or_default();
823    let base_url = dynamic::request_form_value(request, "base_url").unwrap_or_default();
824    let enabled = dynamic::request_form_value(request, "enabled").unwrap_or_else(|| "0".to_owned());
825    match architect_configure_llm_vendor_connection(
826        &store,
827        &llm_vendor_id,
828        &llm_vendor_kind,
829        &display_name,
830        &model_id,
831        &base_url,
832        &enabled,
833    ) {
834        Ok(()) => architect_settings_review_response(app, request, method),
835        Err(_) => http_text_response(
836            422,
837            "Unprocessable Entity",
838            "LLM vendor settings rejected\n",
839        ),
840    }
841}
842
843/// Saves validated non-secret LLM vendor settings through the selected database adapter.
844pub(super) fn architect_configure_llm_vendor_connection(
845    store: &pharos_runtime_architect::database::ArchitectStore,
846    llm_vendor_id: &str,
847    llm_vendor_kind: &str,
848    display_name: &str,
849    model_id: &str,
850    base_url: &str,
851    enabled: &str,
852) -> Result<(), i32> {
853    if !matches!(
854        llm_vendor_id,
855        "openai_compatible"
856            | "anthropic_claude"
857            | "google_gemini"
858            | "nvidia_nim"
859            | "ollama_compatible"
860            | "local_whisper"
861    ) {
862        return Err(64);
863    }
864    if !matches!(llm_vendor_kind, "cloud" | "local") {
865        return Err(64);
866    }
867    let display_name = display_name.trim();
868    let model_id = model_id.trim();
869    let base_url = base_url.trim();
870    if display_name.is_empty()
871        || display_name.len() > 120
872        || model_id.is_empty()
873        || model_id.len() > 128
874        || base_url.is_empty()
875        || base_url.len() > 512
876    {
877        return Err(64);
878    }
879    let enabled_value = match enabled {
880        "1" => true,
881        "0" => false,
882        _ => return Err(64),
883    };
884    let cloud_egress_allowed = llm_vendor_kind == "cloud";
885    use pharos_runtime_database::operations::{Mutation, Predicate, WriteValue};
886    store.transact(vec![Mutation::Update {
887        entity: "architect_llm_vendor".to_owned(),
888        filter: Predicate::Equal("llm_vendor_id".to_owned(), serde_json::json!(llm_vendor_id)),
889        values: std::collections::BTreeMap::from([
890            ("llm_vendor_kind".to_owned(), WriteValue::Literal(serde_json::json!(llm_vendor_kind))),
891            ("display_name".to_owned(), WriteValue::Literal(serde_json::json!(display_name))),
892            ("model_id".to_owned(), WriteValue::Literal(serde_json::json!(model_id))),
893            ("base_url".to_owned(), WriteValue::Literal(serde_json::json!(base_url))),
894            ("enabled".to_owned(), WriteValue::Literal(serde_json::json!(enabled_value))),
895            ("cloud_egress_allowed".to_owned(), WriteValue::Literal(serde_json::json!(cloud_egress_allowed))),
896            ("activation_status".to_owned(), WriteValue::Literal(serde_json::json!("draft_saved"))),
897            ("status_message".to_owned(), WriteValue::Literal(serde_json::json!("LLM vendor settings saved. Add or rotate the API Key, then run a connection test before routing is enabled."))),
898            ("last_tested_at".to_owned(), WriteValue::Literal(Value::Null)),
899            ("updated_at".to_owned(), WriteValue::Literal(serde_json::json!(current_timestamp_utc()))),
900        ]), expected_rows: Some(1),
901    }]).map_err(|_| 69)?;
902    Ok(())
903}
904
905pub(super) fn architect_rotate_llm_vendor_secret_response(
906    app: &AppRuntime,
907    request: &http::HttpRequest,
908    method: &str,
909) -> Vec<u8> {
910    if app.app_id.as_deref() != Some("architect") {
911        return http_text_response(404, "Not Found", "not found\n");
912    }
913    let actor_id = match active_session_user_id(app, request.cookie.as_deref()) {
914        Ok(value) => value,
915        Err(error) => return std::convert::identity(database_error_response(error)),
916    };
917    if actor_id <= 0 {
918        return app_login_redirect(app, "/settings");
919    }
920    if !match actor_has_route_policy(app, actor_id, "manage_llm_vendors") {
921        Ok(value) => value,
922        Err(error) => return std::convert::identity(database_error_response(error)),
923    } {
924        return http_text_response(403, "Forbidden", "capability denied\n");
925    }
926    let Ok(store) = architect_task_store_for_app(app) else {
927        return http_text_response(503, "Service Unavailable", "database unavailable\n");
928    };
929    let Some((vault_path, root_key_file)) = architect_vault_paths(app) else {
930        return http_text_response(503, "Service Unavailable", "vault unavailable\n");
931    };
932    if ensure_architect_root_key(&root_key_file).is_err() {
933        return http_text_response(503, "Service Unavailable", "vault key unavailable\n");
934    }
935    let llm_vendor_id = dynamic::request_form_value(request, "llm_vendor_id").unwrap_or_default();
936    let mut llm_vendor_secret =
937        dynamic::request_form_value(request, "llm_vendor_secret").unwrap_or_default();
938    let trimmed_secret = llm_vendor_secret.trim().as_bytes().to_vec();
939    llm_vendor_secret.clear();
940    let Ok((config, adapter, schema)) =
941        architect_database_storage_for_app(app, &["architect_llm_vendor", "architect_vault_audit"])
942    else {
943        return http_text_response(503, "Service Unavailable", "database unavailable\n");
944    };
945    let result = agent_vault::put_architect_runtime_llm_vendor_secret(
946        &config,
947        adapter.as_ref(),
948        &schema,
949        &vault_path,
950        &root_key_file,
951        &llm_vendor_id,
952        &trimmed_secret,
953    );
954    drop(trimmed_secret);
955    match result {
956        Ok(_) => {
957            if architect_mark_llm_vendor_secret_saved(&store, &llm_vendor_id).is_err() {
958                return http_text_response(
959                    503,
960                    "Service Unavailable",
961                    "LLM vendor status unavailable\n",
962                );
963            }
964            architect_settings_review_response(app, request, method)
965        }
966        Err(error) => {
967            eprintln!("architect LLM vendor secret rotation failed: {error:?}");
968            http_text_response(422, "Unprocessable Entity", "LLM vendor secret rejected\n")
969        }
970    }
971}
972
973/// Marks encrypted LLM vendor credentials ready for a connection test.
974pub(super) fn architect_mark_llm_vendor_secret_saved(
975    store: &pharos_runtime_architect::database::ArchitectStore,
976    llm_vendor_id: &str,
977) -> Result<(), i32> {
978    use pharos_runtime_database::operations::{Mutation, Predicate, WriteValue};
979    store.transact(vec![Mutation::Update {
980        entity: "architect_llm_vendor".to_owned(),
981        filter: Predicate::Equal("llm_vendor_id".to_owned(), serde_json::json!(llm_vendor_id)),
982        values: std::collections::BTreeMap::from([
983            ("activation_status".to_owned(), WriteValue::Literal(serde_json::json!("draft_saved"))),
984            ("status_message".to_owned(), WriteValue::Literal(serde_json::json!("LLM vendor API Key saved in the encrypted runtime vault. Run a connection test before routing tasks to this LLM vendor."))),
985            ("updated_at".to_owned(), WriteValue::Literal(serde_json::json!(current_timestamp_utc()))),
986        ]), expected_rows: Some(1),
987    }]).map_err(|_| 69)?;
988    Ok(())
989}
990
991#[derive(Debug, Clone)]
992pub(super) struct ArchitectLlmVendorState {
993    llm_vendor_kind: String,
994    model_id: String,
995    base_url: String,
996    enabled: bool,
997    cloud_egress_allowed: bool,
998    activation_status: String,
999    secret_ref: String,
1000}
1001
1002pub(super) fn architect_test_llm_vendor_connection_response(
1003    app: &AppRuntime,
1004    request: &http::HttpRequest,
1005    method: &str,
1006) -> Vec<u8> {
1007    if app.app_id.as_deref() != Some("architect") {
1008        return http_text_response(404, "Not Found", "not found\n");
1009    }
1010    let actor_id = match active_session_user_id(app, request.cookie.as_deref()) {
1011        Ok(value) => value,
1012        Err(error) => return std::convert::identity(database_error_response(error)),
1013    };
1014    if actor_id <= 0 {
1015        return app_login_redirect(app, "/settings");
1016    }
1017    if !match actor_has_route_policy(app, actor_id, "manage_llm_vendors") {
1018        Ok(value) => value,
1019        Err(error) => return std::convert::identity(database_error_response(error)),
1020    } {
1021        return http_text_response(403, "Forbidden", "capability denied\n");
1022    }
1023    let Ok(store) = architect_task_store_for_app(app) else {
1024        return http_text_response(503, "Service Unavailable", "database unavailable\n");
1025    };
1026    let Some((vault_path, root_key_file)) = architect_vault_paths(app) else {
1027        return http_text_response(503, "Service Unavailable", "vault unavailable\n");
1028    };
1029    let llm_vendor_id = dynamic::request_form_value(request, "llm_vendor_id").unwrap_or_default();
1030    let (status, message) =
1031        architect_test_llm_vendor_connection(&store, &vault_path, &root_key_file, &llm_vendor_id);
1032    if architect_update_llm_vendor_activation_status(&store, &llm_vendor_id, &status, &message)
1033        .is_err()
1034    {
1035        return http_text_response(
1036            503,
1037            "Service Unavailable",
1038            "LLM vendor status unavailable\n",
1039        );
1040    }
1041    architect_settings_review_response(app, request, method)
1042}
1043
1044pub(super) fn architect_revoke_llm_vendor_connection_response(
1045    app: &AppRuntime,
1046    request: &http::HttpRequest,
1047    method: &str,
1048) -> Vec<u8> {
1049    if app.app_id.as_deref() != Some("architect") {
1050        return http_text_response(404, "Not Found", "not found\n");
1051    }
1052    let actor_id = match active_session_user_id(app, request.cookie.as_deref()) {
1053        Ok(value) => value,
1054        Err(error) => return std::convert::identity(database_error_response(error)),
1055    };
1056    if actor_id <= 0 {
1057        return app_login_redirect(app, "/settings");
1058    }
1059    if !match actor_has_route_policy(app, actor_id, "manage_llm_vendors") {
1060        Ok(value) => value,
1061        Err(error) => return std::convert::identity(database_error_response(error)),
1062    } {
1063        return http_text_response(403, "Forbidden", "capability denied\n");
1064    }
1065    let Ok(store) = architect_task_store_for_app(app) else {
1066        return http_text_response(503, "Service Unavailable", "database unavailable\n");
1067    };
1068    let llm_vendor_id = dynamic::request_form_value(request, "llm_vendor_id").unwrap_or_default();
1069    if architect_revoke_llm_vendor_connection(&store, &llm_vendor_id).is_err() {
1070        return http_text_response(
1071            503,
1072            "Service Unavailable",
1073            "LLM vendor revoke unavailable\n",
1074        );
1075    }
1076    architect_settings_review_response(app, request, method)
1077}
1078
1079/// Revokes LLM vendor routing and records its vault audit in one logical transaction.
1080pub(super) fn architect_revoke_llm_vendor_connection(
1081    store: &pharos_runtime_architect::database::ArchitectStore,
1082    llm_vendor_id: &str,
1083) -> Result<(), i32> {
1084    use pharos_runtime_database::operations::{Mutation, Predicate, WriteValue};
1085    if llm_vendor_id == "builtin" || llm_vendor_id.is_empty() || llm_vendor_id.len() > 64 {
1086        return Err(64);
1087    }
1088    let now = current_timestamp_utc();
1089    store.transact(vec![
1090        Mutation::Update {
1091            entity: "architect_llm_vendor".to_owned(),
1092            filter: Predicate::Equal("llm_vendor_id".to_owned(), serde_json::json!(llm_vendor_id)),
1093            values: std::collections::BTreeMap::from([
1094                ("enabled".to_owned(), WriteValue::Literal(serde_json::json!(false))),
1095                ("activation_status".to_owned(), WriteValue::Literal(serde_json::json!("revoked"))),
1096                ("status_message".to_owned(), WriteValue::Literal(serde_json::json!("LLM vendor routing was revoked. Save settings, rotate the API Key if needed, and test again before reuse."))),
1097                ("secret_ref".to_owned(), WriteValue::Literal(Value::Null)),
1098                ("updated_at".to_owned(), WriteValue::Literal(serde_json::json!(now))),
1099            ]), expected_rows: Some(1),
1100        },
1101        Mutation::Insert {
1102            entity: "architect_vault_audit".to_owned(),
1103            values: std::collections::BTreeMap::from([
1104                ("llm_vendor_id".to_owned(), WriteValue::Literal(serde_json::json!(llm_vendor_id))),
1105                ("secret_ref".to_owned(), WriteValue::Literal(serde_json::json!(format!("architect.{llm_vendor_id}.api_key")))),
1106                ("event_kind".to_owned(), WriteValue::Literal(serde_json::json!("secret_unset"))),
1107                ("fingerprint".to_owned(), WriteValue::Literal(serde_json::json!("removed"))),
1108                ("created_at".to_owned(), WriteValue::Literal(serde_json::json!(now))),
1109            ]),
1110        },
1111    ]).map_err(|_| 69)?;
1112    Ok(())
1113}
1114
1115pub(super) fn architect_test_llm_vendor_connection(
1116    store: &pharos_runtime_architect::database::ArchitectStore,
1117    vault_path: &Path,
1118    root_key_file: &Path,
1119    llm_vendor_id: &str,
1120) -> (String, String) {
1121    let Some(state) = architect_llm_vendor_state_in_store(store, llm_vendor_id) else {
1122        return (
1123            "test_failed".to_owned(),
1124            "LLM vendor configuration could not be loaded.".to_owned(),
1125        );
1126    };
1127    if !state.enabled {
1128        return (
1129            "test_failed".to_owned(),
1130            "Enable this LLM vendor before running the connection test.".to_owned(),
1131        );
1132    }
1133    if state.model_id.trim().is_empty() || state.base_url.trim().is_empty() {
1134        return (
1135            "test_failed".to_owned(),
1136            "Model id and base URL are required before testing this provider.".to_owned(),
1137        );
1138    }
1139    if state.secret_ref.trim().is_empty() {
1140        return (
1141            "missing_secret".to_owned(),
1142            "Save an LLM vendor API key in the encrypted vault before testing this LLM vendor."
1143                .to_owned(),
1144        );
1145    }
1146    if state.llm_vendor_kind == "cloud" && !state.cloud_egress_allowed {
1147        return (
1148            "test_failed".to_owned(),
1149            "Allow cloud egress for this LLM vendor before testing a cloud endpoint.".to_owned(),
1150        );
1151    }
1152    let allow_plaintext_local = state.llm_vendor_kind == "local";
1153    if state.base_url.starts_with("http://") && !allow_plaintext_local {
1154        return (
1155            "test_failed".to_owned(),
1156            "HTTP LLM vendor tests are limited to local LLM vendor endpoints.".to_owned(),
1157        );
1158    }
1159    if state.base_url.starts_with("http://")
1160        && !pharos_runtime_architect::llm_vendor::url_uses_loopback_http(&state.base_url)
1161    {
1162        return (
1163            "test_failed".to_owned(),
1164            "Local HTTP LLM vendor tests must use a loopback localhost endpoint.".to_owned(),
1165        );
1166    }
1167    let Ok(secret_bytes) = agent_vault::get_architect_runtime_llm_vendor_secret(
1168        vault_path,
1169        root_key_file,
1170        llm_vendor_id,
1171    ) else {
1172        return (
1173            "missing_secret".to_owned(),
1174            "Saved LLM vendor secret could not be read from the encrypted vault.".to_owned(),
1175        );
1176    };
1177    let mut secret = match String::from_utf8(secret_bytes) {
1178        Ok(value) => value,
1179        Err(_) => {
1180            return (
1181                "test_failed".to_owned(),
1182                "Saved LLM vendor secret is not valid text for this adapter.".to_owned(),
1183            );
1184        }
1185    };
1186    let Some((method, url, content_type, payload, headers, failure_label)) =
1187        architect_llm_vendor_connection_request(llm_vendor_id, &state, secret.trim())
1188    else {
1189        secret.clear();
1190        return (
1191            "test_failed".to_owned(),
1192            "LLM vendor base URL or model id is invalid.".to_owned(),
1193        );
1194    };
1195    let response = pharos_runtime_integrations::webhook::send_request(
1196        method,
1197        &url,
1198        content_type,
1199        &payload,
1200        &headers,
1201        pharos_runtime_integrations::webhook::WebhookLimits {
1202            connect_timeout: std::time::Duration::from_secs(5),
1203            operation_timeout: std::time::Duration::from_secs(15),
1204            max_response_bytes: 128 * 1024,
1205            allow_plaintext_local,
1206        },
1207    );
1208    secret.clear();
1209    match response {
1210        Some(value) if (200..300).contains(&value.status_code) => {
1211            if llm_vendor_id == "local_whisper" {
1212                (
1213                    "transcription_active".to_owned(),
1214                    "Whisper-compatible audio transcription test passed. This LLM vendor is now available for voice transcription.".to_owned(),
1215                )
1216            } else {
1217                (
1218                    "tested_active".to_owned(),
1219                    "LLM vendor connection test passed. This LLM vendor is now available for task routing.".to_owned(),
1220                )
1221            }
1222        }
1223        Some(value) if matches!(value.status_code, 401 | 403) => (
1224            "test_failed".to_owned(),
1225            format!(
1226                "LLM vendor rejected the saved API key with HTTP status {}. Rotate the secret and test again{}",
1227                value.status_code,
1228                pharos_runtime_architect::llm_vendor::llm_vendor_failure_excerpt(&value.body)
1229            ),
1230        ),
1231        Some(value) => (
1232            "test_failed".to_owned(),
1233            format!(
1234                "LLM vendor connection test reached the endpoint but failed with HTTP status {}. Check the base URL, model id, and provider type{}",
1235                value.status_code,
1236                pharos_runtime_architect::llm_vendor::llm_vendor_failure_excerpt(&value.body)
1237            ),
1238        ),
1239        None => (
1240            "test_failed".to_owned(),
1241            format!("LLM vendor connection test could not reach a valid {failure_label} endpoint."),
1242        ),
1243    }
1244}
1245
1246type ArchitectLlmVendorConnectionRequest = (
1247    &'static str,
1248    String,
1249    Option<&'static str>,
1250    String,
1251    Vec<(&'static str, String)>,
1252    &'static str,
1253);
1254
1255pub(super) fn architect_llm_vendor_connection_request(
1256    llm_vendor_id: &str,
1257    state: &ArchitectLlmVendorState,
1258    secret: &str,
1259) -> Option<ArchitectLlmVendorConnectionRequest> {
1260    if secret.is_empty() || secret.len() > 4096 {
1261        return None;
1262    }
1263    match llm_vendor_id {
1264        "anthropic_claude" => Some((
1265            "POST",
1266            pharos_runtime_architect::llm_vendor::join_vendor_url(&state.base_url, "messages")?,
1267            Some("application/json"),
1268            serde_json::to_string(&serde_json::json!({
1269                "model": state.model_id,
1270                "max_tokens": 8,
1271                "messages": [{"role": "user", "content": "Return ok."}]
1272            }))
1273            .ok()?,
1274            vec![
1275                ("x-api-key", secret.to_owned()),
1276                ("anthropic-version", "2023-06-01".to_owned()),
1277            ],
1278            "Anthropic Messages",
1279        )),
1280        "google_gemini" => Some((
1281            "POST",
1282            pharos_runtime_architect::llm_vendor::gemini_generate_content_url_option(
1283                &state.base_url,
1284                &state.model_id,
1285            )?,
1286            Some("application/json"),
1287            serde_json::to_string(&serde_json::json!({
1288                "contents": [{"parts": [{"text": "Return ok."}]}],
1289                "generationConfig": {"temperature": 0}
1290            }))
1291            .ok()?,
1292            vec![("x-goog-api-key", secret.to_owned())],
1293            "Gemini generateContent",
1294        )),
1295        "local_whisper" => Some((
1296            "GET",
1297            pharos_runtime_architect::llm_vendor::join_vendor_url(&state.base_url, "models")?,
1298            None,
1299            String::new(),
1300            vec![("Authorization", format!("Bearer {secret}"))],
1301            "Whisper-compatible audio",
1302        )),
1303        "openai_compatible" | "ollama_compatible" | "nvidia_nim" => Some((
1304            "POST",
1305            pharos_runtime_architect::llm_vendor::join_vendor_url(&state.base_url, "chat/completions")?,
1306            Some("application/json"),
1307            serde_json::to_string(&serde_json::json!({
1308                "model": state.model_id,
1309                "messages": [{"role": "user", "content": "Return only ok."}],
1310                if llm_vendor_id == "openai_compatible" { "max_completion_tokens" } else { "max_tokens" }: 64
1311            })).ok()?,
1312            vec![("Authorization", format!("Bearer {secret}"))],
1313            "OpenAI-compatible",
1314        )),
1315        _ => None,
1316    }
1317}
1318
1319/// Reads one LLM vendor record from the configured Architect database.
1320fn architect_llm_vendor_state_in_store(
1321    store: &pharos_runtime_architect::database::ArchitectStore,
1322    llm_vendor_id: &str,
1323) -> Option<ArchitectLlmVendorState> {
1324    use pharos_runtime_database::operations::Predicate;
1325    let row = store
1326        .query_one(
1327            "architect_llm_vendor",
1328            &[
1329                "llm_vendor_kind",
1330                "model_id",
1331                "base_url",
1332                "enabled",
1333                "cloud_egress_allowed",
1334                "activation_status",
1335                "secret_ref",
1336            ],
1337            Predicate::Equal("llm_vendor_id".to_owned(), serde_json::json!(llm_vendor_id)),
1338        )
1339        .ok()??;
1340    let text = |field: &str| row.get(field).and_then(Value::as_str).map(str::to_owned);
1341    let enabled = |field: &str| {
1342        row.get(field)
1343            .and_then(Value::as_bool)
1344            .or_else(|| {
1345                row.get(field)
1346                    .and_then(Value::as_i64)
1347                    .map(|value| value == 1)
1348            })
1349            .unwrap_or(false)
1350    };
1351    Some(ArchitectLlmVendorState {
1352        llm_vendor_kind: text("llm_vendor_kind")?,
1353        model_id: text("model_id")?,
1354        base_url: text("base_url")?,
1355        enabled: enabled("enabled"),
1356        cloud_egress_allowed: enabled("cloud_egress_allowed"),
1357        activation_status: text("activation_status")?,
1358        secret_ref: text("secret_ref").unwrap_or_default(),
1359    })
1360}
1361
1362/// Reads the vendor selected for a task without physical backend queries.
1363fn architect_task_llm_vendor_in_store(
1364    store: &pharos_runtime_architect::database::ArchitectStore,
1365    task_id: i64,
1366) -> Option<String> {
1367    use pharos_runtime_database::operations::Predicate;
1368    store
1369        .query_one(
1370            "architect_task",
1371            &["llm_vendor_id"],
1372            Predicate::Equal("id".to_owned(), serde_json::json!(task_id)),
1373        )
1374        .ok()??
1375        .get("llm_vendor_id")
1376        .and_then(Value::as_str)
1377        .map(str::to_owned)
1378}
1379
1380/// Queues an accepted voice transcript as one guarded task transaction.
1381fn architect_queue_transcribed_task_in_store(
1382    store: &pharos_runtime_architect::database::ArchitectStore,
1383    task_id: i64,
1384    brief: &str,
1385) -> Result<(), i32> {
1386    use pharos_runtime_database::operations::{Mutation, Predicate, WriteValue};
1387    let brief = brief.chars().take(8192).collect::<String>();
1388    let now = current_timestamp_utc();
1389    store
1390        .transact(vec![
1391            Mutation::Update {
1392                entity: "architect_task".to_owned(),
1393                filter: Predicate::All(vec![
1394                    Predicate::Equal("id".to_owned(), serde_json::json!(task_id)),
1395                    Predicate::Any(vec![
1396                        Predicate::Equal("task_status".to_owned(), serde_json::json!("draft")),
1397                        Predicate::Equal("task_status".to_owned(), serde_json::json!("queued")),
1398                    ]),
1399                ]),
1400                values: std::collections::BTreeMap::from([
1401                    (
1402                        "brief".to_owned(),
1403                        WriteValue::Literal(serde_json::json!(brief)),
1404                    ),
1405                    (
1406                        "task_status".to_owned(),
1407                        WriteValue::Literal(serde_json::json!("queued")),
1408                    ),
1409                    (
1410                        "updated_at".to_owned(),
1411                        WriteValue::Literal(serde_json::json!(now)),
1412                    ),
1413                ]),
1414                expected_rows: Some(1),
1415            },
1416            Mutation::Insert {
1417                entity: "architect_task_event".to_owned(),
1418                values: std::collections::BTreeMap::from([
1419                    (
1420                        "task_id".to_owned(),
1421                        WriteValue::Literal(serde_json::json!(task_id)),
1422                    ),
1423                    (
1424                        "event_kind".to_owned(),
1425                        WriteValue::Literal(serde_json::json!("plan_requested")),
1426                    ),
1427                    (
1428                        "event_summary".to_owned(),
1429                        WriteValue::Literal(serde_json::json!(
1430                            "Voice transcript was accepted automatically and queued for planning."
1431                        )),
1432                    ),
1433                    (
1434                        "created_at".to_owned(),
1435                        WriteValue::Literal(serde_json::json!(now)),
1436                    ),
1437                ]),
1438            },
1439        ])
1440        .map_err(|_| 69)?;
1441    Ok(())
1442}
1443
1444/// Saves bounded connection-test status through the configured database adapter.
1445pub(super) fn architect_update_llm_vendor_activation_status(
1446    store: &pharos_runtime_architect::database::ArchitectStore,
1447    llm_vendor_id: &str,
1448    status: &str,
1449    message: &str,
1450) -> Result<(), i32> {
1451    use pharos_runtime_database::operations::{Mutation, Predicate, WriteValue};
1452    let message: String = message.chars().take(512).collect();
1453    let now = current_timestamp_utc();
1454    store
1455        .transact(vec![Mutation::Update {
1456            entity: "architect_llm_vendor".to_owned(),
1457            filter: Predicate::Equal("llm_vendor_id".to_owned(), serde_json::json!(llm_vendor_id)),
1458            values: std::collections::BTreeMap::from([
1459                (
1460                    "activation_status".to_owned(),
1461                    WriteValue::Literal(serde_json::json!(status)),
1462                ),
1463                (
1464                    "status_message".to_owned(),
1465                    WriteValue::Literal(serde_json::json!(message)),
1466                ),
1467                (
1468                    "last_tested_at".to_owned(),
1469                    WriteValue::Literal(serde_json::json!(now)),
1470                ),
1471                (
1472                    "updated_at".to_owned(),
1473                    WriteValue::Literal(serde_json::json!(now)),
1474                ),
1475            ]),
1476            expected_rows: Some(1),
1477        }])
1478        .map_err(|_| 69)?;
1479    Ok(())
1480}
1481
1482pub(super) fn architect_preview_image_content_type(
1483    filename: &str,
1484    supplied: Option<&str>,
1485) -> Option<&'static str> {
1486    let lower = filename.to_ascii_lowercase();
1487    let by_extension = if lower.ends_with(".png") {
1488        Some("image/png")
1489    } else if lower.ends_with(".jpg") || lower.ends_with(".jpeg") {
1490        Some("image/jpeg")
1491    } else if lower.ends_with(".webp") {
1492        Some("image/webp")
1493    } else if lower.ends_with(".gif") {
1494        Some("image/gif")
1495    } else {
1496        None
1497    }?;
1498    if let Some(supplied) = supplied {
1499        let supplied = supplied.to_ascii_lowercase();
1500        if !supplied.is_empty()
1501            && !supplied.starts_with(by_extension)
1502            && !(by_extension == "image/jpeg" && supplied.starts_with("image/jpg"))
1503        {
1504            return None;
1505        }
1506    }
1507    Some(by_extension)
1508}
1509
1510pub(super) fn architect_preview_image_media_path(value: &str) -> Option<PathBuf> {
1511    let name = value.strip_prefix("/media/architect-preview/")?;
1512    if name.is_empty()
1513        || name.len() > 180
1514        || name.contains('/')
1515        || name.contains('\\')
1516        || name.contains("..")
1517        || !name.chars().all(|character| {
1518            character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_')
1519        })
1520    {
1521        return None;
1522    }
1523    Some(PathBuf::from(name))
1524}
1525
1526pub(super) fn architect_preview_image_response(
1527    app: &AppRuntime,
1528    media_path: &str,
1529    method: &str,
1530) -> Vec<u8> {
1531    let Some(filename) = architect_preview_image_media_path(media_path) else {
1532        return http_text_response(404, "Not Found", "not found\n");
1533    };
1534    let Some(media_root) = architect_media_root(app) else {
1535        return http_text_response(503, "Service Unavailable", "media storage unavailable\n");
1536    };
1537    let disk_path = media_root.join("preview").join(&filename);
1538    let Some(content_type) =
1539        architect_preview_image_content_type(filename.to_string_lossy().as_ref(), None)
1540    else {
1541        return http_text_response(404, "Not Found", "not found\n");
1542    };
1543    let Ok(body) = stdfs::read(&disk_path) else {
1544        return http_text_response(404, "Not Found", "not found\n");
1545    };
1546    http_inline_binary_response(&body, content_type, method)
1547}
1548
1549fn http_inline_binary_response(body: &[u8], content_type: &str, method: &str) -> Vec<u8> {
1550    let headers = format!(
1551        "HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nX-Content-Type-Options: nosniff\r\nCache-Control: private, max-age=300\r\nConnection: close\r\n\r\n",
1552        body.len()
1553    );
1554    if method == "HEAD" {
1555        return headers.into_bytes();
1556    }
1557    let mut response = headers.into_bytes();
1558    response.extend_from_slice(body);
1559    response
1560}
1561
1562pub(super) fn architect_upload_preview_image_response(
1563    app: &AppRuntime,
1564    request: &http::HttpRequest,
1565    method: &str,
1566) -> Vec<u8> {
1567    if app.app_id.as_deref() != Some("architect") {
1568        return http_text_response(404, "Not Found", "not found\n");
1569    }
1570    if let Err(response) = authorize_app_mutation(app, request, "/apps/view/upload-image") {
1571        return response;
1572    }
1573    let Some(content_type) = request.content_type.as_deref() else {
1574        return http_json_response(
1575            422,
1576            "Unprocessable Entity",
1577            r#"{"error":"missing multipart body"}"#,
1578            method,
1579        );
1580    };
1581    if !content_type
1582        .to_ascii_lowercase()
1583        .starts_with("multipart/form-data")
1584    {
1585        return http_json_response(
1586            415,
1587            "Unsupported Media Type",
1588            r#"{"error":"multipart body required"}"#,
1589            method,
1590        );
1591    }
1592    let task_id = dynamic::request_form_value(request, "task_id")
1593        .and_then(|value| value.parse::<i64>().ok())
1594        .unwrap_or(0);
1595    if task_id <= 0 {
1596        return http_json_response(
1597            422,
1598            "Unprocessable Entity",
1599            r#"{"error":"invalid task id"}"#,
1600            method,
1601        );
1602    }
1603    let Some((filename, bytes)) =
1604        json_commands::dynamic_multipart_find_part(&request.body, content_type, "image")
1605    else {
1606        return http_json_response(
1607            422,
1608            "Unprocessable Entity",
1609            r#"{"error":"image file required"}"#,
1610            method,
1611        );
1612    };
1613    if bytes.is_empty() || bytes.len() > 5 * 1024 * 1024 {
1614        return http_json_response(
1615            422,
1616            "Unprocessable Entity",
1617            r#"{"error":"image size must be 1 byte to 5 MB"}"#,
1618            method,
1619        );
1620    }
1621    let safe_name = filename
1622        .as_deref()
1623        .and_then(dynamic::safe_upload_filename)
1624        .filter(|value| !value.is_empty())
1625        .unwrap_or_else(|| "image.png".to_owned());
1626    let Some(content_type) = architect_preview_image_content_type(&safe_name, None) else {
1627        return http_json_response(
1628            422,
1629            "Unprocessable Entity",
1630            r#"{"error":"supported image types are PNG, JPEG, WebP, and GIF"}"#,
1631            method,
1632        );
1633    };
1634    let extension = Path::new(&safe_name)
1635        .extension()
1636        .and_then(|value| value.to_str())
1637        .unwrap_or("png")
1638        .to_ascii_lowercase();
1639    let nonce = dynamic::random_secret_hex(12).unwrap_or_else(|_| current_timestamp_utc());
1640    let stored_name = format!("task{task_id}-{nonce}.{extension}");
1641    let Some(media_root) = architect_media_root(app) else {
1642        return http_text_response(503, "Service Unavailable", "media storage unavailable\n");
1643    };
1644    let storage_root = media_root.join("preview");
1645    if stdfs::create_dir_all(&storage_root).is_err() {
1646        return http_json_response(
1647            503,
1648            "Service Unavailable",
1649            r#"{"error":"upload storage unavailable"}"#,
1650            method,
1651        );
1652    }
1653    let stored_path = storage_root.join(&stored_name);
1654    if fs::write_binary_file(&stored_path, &bytes, 0o640).is_err() {
1655        return http_json_response(
1656            503,
1657            "Service Unavailable",
1658            r#"{"error":"upload storage unavailable"}"#,
1659            method,
1660        );
1661    }
1662    let media_path = format!("/media/architect-preview/{stored_name}");
1663    let body = serde_json::json!({
1664        "schema": "pharos.architect.preview_image_upload.v1",
1665        "media_path": media_path,
1666        "filename": safe_name,
1667        "content_type": content_type,
1668    })
1669    .to_string();
1670    http_json_response(200, "OK", &body, method)
1671}
1672
1673/// Serves a task-owned image asset without requiring a generated app or invoking extraction.
1674pub(super) fn architect_read_image_asset_response(
1675    app: &AppRuntime,
1676    request: &http::HttpRequest,
1677    method: &str,
1678) -> Vec<u8> {
1679    if app.app_id.as_deref() != Some("architect") {
1680        return http_text_response(404, "Not Found", "not found\n");
1681    }
1682    let actor = active_session_user_id(app, request.cookie.as_deref()).unwrap_or(0);
1683    if actor <= 0 {
1684        return app_login_redirect(app, "/");
1685    }
1686    let task_id = request_query_value(&request.target, "task_id")
1687        .and_then(|v| v.parse::<i64>().ok())
1688        .unwrap_or(0);
1689    let attachment_id = request_query_value(&request.target, "attachment_id")
1690        .and_then(|v| v.parse::<i64>().ok())
1691        .unwrap_or(0);
1692    let Ok(store) = architect_task_store_for_app(app) else {
1693        return http_text_response(503, "Service Unavailable", "database unavailable\n");
1694    };
1695    if task_id <= 0
1696        || attachment_id <= 0
1697        || pharos_runtime_architect::task_ownership::task_owner(&store, task_id)
1698            .ok()
1699            .flatten()
1700            != Some(actor)
1701    {
1702        return http_text_response(404, "Not Found", "image unavailable\n");
1703    }
1704    let Some(root) = architect_media_root(app) else {
1705        return http_text_response(503, "Service Unavailable", "media storage unavailable\n");
1706    };
1707    match pharos_runtime_architect::image_assets::read_image_in_store(
1708        &store,
1709        task_id,
1710        attachment_id,
1711        &root,
1712    ) {
1713        Ok((bytes, content_type)) => http_inline_binary_response(&bytes, &content_type, method),
1714        Err(_) => http_text_response(404, "Not Found", "image unavailable\n"),
1715    }
1716}
1717
1718pub(super) fn architect_upload_attachment_response(
1719    app: &AppRuntime,
1720    request: &http::HttpRequest,
1721    method: &str,
1722) -> Vec<u8> {
1723    if app.app_id.as_deref() != Some("architect") {
1724        return http_text_response(404, "Not Found", "not found\n");
1725    }
1726    if let Err(response) = authorize_app_mutation(app, request, "/attachments/upload") {
1727        return response;
1728    }
1729    let Some(content_type) = request.content_type.as_deref() else {
1730        return http_text_response(422, "Unprocessable Entity", "missing multipart body\n");
1731    };
1732    if !content_type
1733        .to_ascii_lowercase()
1734        .starts_with("multipart/form-data")
1735    {
1736        return http_text_response(415, "Unsupported Media Type", "multipart body required\n");
1737    }
1738    let Ok(store) = architect_task_store_for_app(app) else {
1739        return http_text_response(503, "Service Unavailable", "database unavailable\n");
1740    };
1741    let Some(state_parent) = architect_state_parent(app) else {
1742        return http_text_response(503, "Service Unavailable", "state unavailable\n");
1743    };
1744    let task_id = dynamic::request_form_value(request, "task_id")
1745        .and_then(|value| agent_ingest::parse_task_id(&value).ok())
1746        .unwrap_or(0);
1747    if task_id <= 0 {
1748        return http_text_response(422, "Unprocessable Entity", "invalid task id\n");
1749    }
1750    let actor = active_session_user_id(app, request.cookie.as_deref()).unwrap_or(0);
1751    if actor <= 0
1752        || pharos_runtime_architect::task_ownership::task_owner(&store, task_id)
1753            .ok()
1754            .flatten()
1755            != Some(actor)
1756    {
1757        return http_text_response(403, "Forbidden", "This task belongs to another user.\n");
1758    }
1759    if dynamic::request_form_value(request, "list_image_assets").as_deref() == Some("true") {
1760        let after_id =
1761            dynamic::request_form_value(request, "after_id").unwrap_or_else(|| "0".into());
1762        let Ok(after_id) = after_id.parse::<i64>() else {
1763            return http_text_response(422, "Unprocessable Entity", "invalid image cursor\n");
1764        };
1765        return match pharos_runtime_architect::image_assets::list_images_in_store(&store, task_id, after_id)
1766            .and_then(|page| {
1767                serde_json::to_string(&serde_json::json!({"task_id":task_id,"images":page.images,"next_after":page.next_after}))
1768                    .map_err(|_| agent_bridge::AgentErrorKind::InvalidEnvelope)
1769            }) {
1770            Ok(body) => http_json_response(200, "OK", &body, method),
1771            Err(_) => http_text_response(
1772                422,
1773                "Unprocessable Entity",
1774                "image asset list unavailable\n",
1775            ),
1776        };
1777    }
1778    if dynamic::request_form_value(request, "finalize_image_assets").as_deref() == Some("true") {
1779        let persisted =
1780            match pharos_runtime_architect::data_binding::has_source(&store, task_id, actor) {
1781                true => {
1782                    let Some(root) = architect_generated_source_root(app) else {
1783                        return http_text_response(
1784                            503,
1785                            "Service Unavailable",
1786                            "Generated source unavailable.\n",
1787                        );
1788                    };
1789                    if let Err(error) =
1790                        architect_prepare_csv_page_bindings(app, &store, task_id, actor, &root, "")
1791                    {
1792                        return http_text_response(
1793                            422,
1794                            "Unprocessable Entity",
1795                            &format!("Image source import failed: {error}\n"),
1796                        );
1797                    }
1798                    true
1799                }
1800                false => false,
1801            };
1802        return http_json_response(
1803            200,
1804            "OK",
1805            &serde_json::json!({"task_id":task_id,"persisted":persisted}).to_string(),
1806            method,
1807        );
1808    }
1809    let image_asset =
1810        dynamic::request_form_value(request, "attachment_role").as_deref() == Some("image_asset");
1811    let temp_root = state_parent.join("tmp/uploads");
1812    if stdfs::create_dir_all(&temp_root).is_err() {
1813        return http_text_response(503, "Service Unavailable", "upload storage unavailable\n");
1814    }
1815    let nonce = dynamic::random_secret_hex(12).unwrap_or_else(|_| current_timestamp_utc());
1816    let body_path = temp_root.join(format!("{nonce}.multipart"));
1817    let input_path = temp_root.join(format!("{nonce}.upload"));
1818    if fs::write_binary_file(&body_path, &request.body, 0o600).is_err() {
1819        return http_text_response(503, "Service Unavailable", "upload storage unavailable\n");
1820    }
1821    let filename =
1822        json_commands::dynamic_multipart_filename(content_type, &body_path, "attachment")
1823            .and_then(|value| dynamic::safe_upload_filename(&value))
1824            .unwrap_or_else(|| "attachment.bin".to_owned());
1825    let media_type = architect_attachment_media_type(
1826        &filename,
1827        dynamic::request_form_value(request, "media_type"),
1828    );
1829    let extract_status = json_commands::dynamic_multipart_extract_file(
1830        content_type,
1831        &body_path,
1832        "attachment",
1833        &input_path,
1834    );
1835    let _ = stdfs::remove_file(&body_path);
1836    if extract_status != 0 {
1837        let _ = stdfs::remove_file(&input_path);
1838        return http_text_response(422, "Unprocessable Entity", "attachment file required\n");
1839    }
1840    let Some(storage_root) = architect_media_root(app) else {
1841        return http_text_response(503, "Service Unavailable", "media storage unavailable\n");
1842    };
1843    if stdfs::create_dir_all(&storage_root).is_err() {
1844        let _ = stdfs::remove_file(&input_path);
1845        return http_text_response(503, "Service Unavailable", "upload storage unavailable\n");
1846    }
1847    if dynamic::request_form_value(request, "attachment_role").as_deref() == Some("csv_data") {
1848        let actor_id = match active_session_user_id(app, request.cookie.as_deref()) {
1849            Ok(actor) => actor,
1850            Err(error) => return database_error_response(error),
1851        };
1852        let result = pharos_runtime_architect::csv_import::inspect_or_prepare(
1853            &store,
1854            task_id,
1855            actor_id,
1856            &input_path,
1857            &storage_root,
1858            &filename,
1859            &dynamic::request_form_value(request, "csv_table").unwrap_or_default(),
1860            dynamic::request_form_value(request, "csv_existing").as_deref() == Some("true"),
1861            dynamic::request_form_value(request, "csv_mapping").as_deref(),
1862        );
1863        let _ = stdfs::remove_file(&input_path);
1864        return match result {
1865            Ok(value) => http_json_response(200, "OK", &value.to_string(), method),
1866            Err(message) => http_text_response(
1867                422,
1868                "Unprocessable Entity",
1869                &format!("{filename}: {message}"),
1870            ),
1871        };
1872    }
1873    if image_asset {
1874        let relative_path = dynamic::request_form_value(request, "relative_path")
1875            .unwrap_or_else(|| filename.clone());
1876        let result = pharos_runtime_architect::image_assets::import_image_in_store(
1877            &store,
1878            task_id,
1879            &input_path,
1880            &storage_root,
1881            &relative_path,
1882        );
1883        let _ = stdfs::remove_file(&input_path);
1884        return match result {
1885            Ok(asset) => match serde_json::to_string(&asset) {
1886                Ok(body) => http_json_response(200, "OK", &body, method),
1887                Err(_) => http_text_response(
1888                    503,
1889                    "Service Unavailable",
1890                    "image import result unavailable\n",
1891                ),
1892            },
1893            Err(error) => http_text_response(
1894                422,
1895                "Unprocessable Entity",
1896                &format!(
1897                    "image import rejected: {error:?}; use PNG, JPEG, WebP or GIF up to 2 MiB with a relative image path\n"
1898                ),
1899            ),
1900        };
1901    }
1902    let ingest = pharos_runtime_architect::attachment_ingest::ingest_attachment_in_store(
1903        &store,
1904        task_id,
1905        &input_path,
1906        &storage_root,
1907        &filename,
1908        &media_type,
1909    );
1910    let _ = stdfs::remove_file(&input_path);
1911    match ingest {
1912        Ok(ingest) => {
1913            if media_type.to_ascii_lowercase().starts_with("audio/") {
1914                let llm_vendor_id = dynamic::request_form_value(request, "llm_vendor_id")
1915                    .filter(|value| !value.trim().is_empty())
1916                    .or_else(|| architect_task_llm_vendor_in_store(&store, task_id))
1917                    .unwrap_or_else(|| "builtin".to_owned());
1918                if matches!(llm_vendor_id.as_str(), "builtin" | "chatgpt") {
1919                    if agent_ingest::extract_architect_attachment_from_store(
1920                        &store,
1921                        task_id,
1922                        ingest.attachment_id,
1923                        &storage_root,
1924                    )
1925                    .is_err()
1926                    {
1927                        return http_text_response(
1928                            422,
1929                            "Unprocessable Entity",
1930                            "local audio transcription unavailable\n",
1931                        );
1932                    }
1933                } else {
1934                    let Some((vault_path, root_key_file)) = architect_vault_paths(app) else {
1935                        return http_text_response(
1936                            503,
1937                            "Service Unavailable",
1938                            "vault unavailable\n",
1939                        );
1940                    };
1941                    if architect_transcribe_audio_attachment_and_queue(
1942                        &store,
1943                        &vault_path,
1944                        &root_key_file,
1945                        &storage_root,
1946                        &ingest.storage_ref,
1947                        task_id,
1948                        &llm_vendor_id,
1949                        &filename,
1950                        &media_type,
1951                    )
1952                    .is_err()
1953                    {
1954                        return http_text_response(
1955                            422,
1956                            "Unprocessable Entity",
1957                            "audio transcription unavailable\n",
1958                        );
1959                    }
1960                }
1961            } else if let Err(error) = agent_ingest::extract_architect_attachment_from_store(
1962                &store,
1963                task_id,
1964                ingest.attachment_id,
1965                &storage_root,
1966            ) {
1967                let message = if error == agent_bridge::AgentErrorKind::CapacityExceeded {
1968                    format!("Context is empty or exceeds its extraction limit. Keep structured site-context JSON within {} KiB; use smaller context files or CSV for larger record sets.\n", pharos_runtime_architect::attachment_ingest::MAX_STRUCTURED_SITE_CONTEXT_BYTES / 1024)
1969                } else {
1970                    "attachment text extraction unavailable\n".to_owned()
1971                };
1972                return http_text_response(422, "Unprocessable Entity", &message);
1973            }
1974            architect_settings_review_response(app, request, method)
1975        }
1976        Err(error) => {
1977            let message = match error {
1978                agent_bridge::AgentErrorKind::CapacityExceeded => {
1979                    "attachment rejected: context files must be nonempty and at most 10 MiB each\n"
1980                }
1981                agent_bridge::AgentErrorKind::InvalidIdentifier => {
1982                    "attachment rejected: builder task was not found\n"
1983                }
1984                agent_bridge::AgentErrorKind::InvalidPath => {
1985                    "attachment rejected: storage or database write failed\n"
1986                }
1987                agent_bridge::AgentErrorKind::InvalidEnvelope => {
1988                    "attachment rejected: attachment metadata was invalid\n"
1989                }
1990                agent_bridge::AgentErrorKind::PolicyViolation => {
1991                    "attachment rejected: file type or storage policy blocked the upload\n"
1992                }
1993                agent_bridge::AgentErrorKind::CredentialUnavailable => {
1994                    "attachment rejected: required credential was unavailable\n"
1995                }
1996                agent_bridge::AgentErrorKind::LockBusy => {
1997                    "attachment rejected: database was busy\n"
1998                }
1999                _ => "attachment rejected: server could not accept the context file\n",
2000            };
2001            http_text_response(
2002                422,
2003                "Unprocessable Entity",
2004                &format!("{filename}: {message}"),
2005            )
2006        }
2007    }
2008}
2009
2010/// Sends a saved Architect audio attachment to a tested external LLM vendor and queues the transcribed task brief.
2011pub(super) fn architect_transcribe_audio_attachment_and_queue(
2012    store: &pharos_runtime_architect::database::ArchitectStore,
2013    vault_path: &Path,
2014    root_key_file: &Path,
2015    storage_root: &Path,
2016    storage_ref: &str,
2017    task_id: i64,
2018    llm_vendor_id: &str,
2019    filename: &str,
2020    media_type: &str,
2021) -> Result<(), i32> {
2022    let state = architect_llm_vendor_state_in_store(store, llm_vendor_id).ok_or(69)?;
2023    let transcription_active = state.activation_status == "tested_active"
2024        || state.activation_status == "transcription_active";
2025    if !state.enabled || !transcription_active || state.secret_ref.is_empty() {
2026        return Err(65);
2027    }
2028    if state.llm_vendor_kind == "cloud" && !state.cloud_egress_allowed {
2029        return Err(65);
2030    }
2031    let allow_plaintext_local = state.llm_vendor_kind == "local";
2032    if state.base_url.starts_with("http://") && !allow_plaintext_local {
2033        return Err(65);
2034    }
2035    if state.base_url.starts_with("http://")
2036        && !pharos_runtime_architect::llm_vendor::url_uses_loopback_http(&state.base_url)
2037    {
2038        return Err(65);
2039    }
2040    let attachment_relative = architect_storage_ref_path(storage_ref).ok_or(66)?;
2041    let audio_path = storage_root.join(attachment_relative);
2042    let audio = stdfs::read(audio_path).map_err(|_| 66)?;
2043    if audio.is_empty() || audio.len() > 10 * 1024 * 1024 {
2044        return Err(65);
2045    }
2046    let mut secret = String::from_utf8(
2047        agent_vault::get_architect_runtime_llm_vendor_secret(
2048            vault_path,
2049            root_key_file,
2050            llm_vendor_id,
2051        )
2052        .map_err(|_| 66)?,
2053    )
2054    .map_err(|_| 66)?;
2055    let url = pharos_runtime_architect::llm_vendor::audio_transcriptions_url(&state.base_url)
2056        .ok_or(64)?;
2057    let boundary = format!(
2058        "pharos-architect-audio-{}",
2059        current_timestamp_utc().replace([':', '-'], "")
2060    );
2061    let payload = architect_audio_transcription_multipart(
2062        &boundary,
2063        &state.model_id,
2064        filename,
2065        media_type,
2066        &audio,
2067    )
2068    .ok_or(65)?;
2069    let content_type = format!("multipart/form-data; boundary={boundary}");
2070    let response = pharos_runtime_integrations::webhook::send_request_bytes(
2071        "POST",
2072        &url,
2073        Some(&content_type),
2074        &payload,
2075        &[("Authorization", format!("Bearer {}", secret.trim()))],
2076        pharos_runtime_integrations::webhook::WebhookLimits {
2077            connect_timeout: std::time::Duration::from_secs(5),
2078            operation_timeout: std::time::Duration::from_secs(60),
2079            max_response_bytes: 128 * 1024,
2080            allow_plaintext_local,
2081        },
2082    );
2083    secret.clear();
2084    let Some(response) = response else {
2085        return Err(69);
2086    };
2087    if !(200..300).contains(&response.status_code) {
2088        return Err(69);
2089    }
2090    let transcript_text = architect_parse_transcription_text(&response.body).ok_or(65)?;
2091    if transcript_text.len() < 8 {
2092        return Err(65);
2093    }
2094    pharos_runtime_architect::attachment_ingest::ingest_transcript_in_store(
2095        store,
2096        task_id,
2097        llm_vendor_id,
2098        &transcript_text,
2099    )
2100    .map_err(|_| 69)?;
2101    architect_queue_transcribed_task_in_store(store, task_id, &transcript_text)
2102}
2103
2104/// Converts an Architect storage reference into a safe relative path below the configured storage root.
2105pub(super) fn architect_storage_ref_path(value: &str) -> Option<&Path> {
2106    let path = Path::new(value);
2107    if value.is_empty()
2108        || path.is_absolute()
2109        || path
2110            .components()
2111            .any(|component| !matches!(component, std::path::Component::Normal(_)))
2112    {
2113        return None;
2114    }
2115    Some(path)
2116}
2117
2118/// Builds a bounded multipart/form-data payload for external LLM vendor audio transcription.
2119pub(super) fn architect_audio_transcription_multipart(
2120    boundary: &str,
2121    model_id: &str,
2122    filename: &str,
2123    media_type: &str,
2124    audio: &[u8],
2125) -> Option<Vec<u8>> {
2126    if boundary.len() > 80
2127        || model_id.is_empty()
2128        || model_id.len() > 128
2129        || filename.contains(['\r', '\n', '"'])
2130        || media_type.contains(['\r', '\n'])
2131        || media_type.len() > 120
2132    {
2133        return None;
2134    }
2135    let mut payload = Vec::with_capacity(audio.len().saturating_add(512));
2136    payload.extend_from_slice(
2137        format!(
2138            "--{boundary}\r\nContent-Disposition: form-data; name=\"model\"\r\n\r\n{model_id}\r\n"
2139        )
2140        .as_bytes(),
2141    );
2142    payload.extend_from_slice(format!("--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{filename}\"\r\nContent-Type: {media_type}\r\n\r\n").as_bytes());
2143    payload.extend_from_slice(audio);
2144    payload.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
2145    Some(payload)
2146}
2147
2148/// Extracts the transcript text field from an OpenAI-compatible audio transcription response.
2149pub(super) fn architect_parse_transcription_text(body: &str) -> Option<String> {
2150    let value: Value = serde_json::from_str(body).ok()?;
2151    let text = value.get("text").and_then(Value::as_str)?.trim();
2152    if text.is_empty() || text.len() > 8192 || text.contains('\0') {
2153        return None;
2154    }
2155    Some(text.to_owned())
2156}
2157
2158pub(super) fn architect_upload_transcript_response(
2159    app: &AppRuntime,
2160    request: &http::HttpRequest,
2161    method: &str,
2162) -> Vec<u8> {
2163    if app.app_id.as_deref() != Some("architect") {
2164        return http_text_response(404, "Not Found", "not found\n");
2165    }
2166    if let Err(response) = authorize_app_mutation(app, request, "/transcripts/upload") {
2167        return response;
2168    }
2169    let Ok(store) = architect_task_store_for_app(app) else {
2170        return http_text_response(503, "Service Unavailable", "database unavailable\n");
2171    };
2172    let task_id = dynamic::request_form_value(request, "task_id")
2173        .and_then(|value| agent_ingest::parse_task_id(&value).ok())
2174        .unwrap_or(0);
2175    if task_id <= 0 {
2176        return http_text_response(422, "Unprocessable Entity", "invalid task id\n");
2177    }
2178    let llm_vendor_id = dynamic::request_form_value(request, "llm_vendor_id")
2179        .filter(|value| !value.trim().is_empty())
2180        .unwrap_or_else(|| "builtin".to_owned());
2181    let mut transcript_text =
2182        dynamic::request_form_value(request, "transcript_text").unwrap_or_default();
2183    if transcript_text.trim().is_empty()
2184        && let Some(content_type) = request.content_type.as_deref()
2185        && content_type
2186            .to_ascii_lowercase()
2187            .starts_with("multipart/form-data")
2188        && let Some(state_parent) = architect_state_parent(app)
2189    {
2190        let temp_root = state_parent.join("tmp/transcripts");
2191        if stdfs::create_dir_all(&temp_root).is_err() {
2192            return http_text_response(
2193                503,
2194                "Service Unavailable",
2195                "transcript storage unavailable\n",
2196            );
2197        }
2198        let nonce = dynamic::random_secret_hex(12).unwrap_or_else(|_| current_timestamp_utc());
2199        let body_path = temp_root.join(format!("{nonce}.multipart"));
2200        if fs::write_binary_file(&body_path, &request.body, 0o600).is_err() {
2201            return http_text_response(
2202                503,
2203                "Service Unavailable",
2204                "transcript storage unavailable\n",
2205            );
2206        }
2207        transcript_text =
2208            json_commands::dynamic_multipart_extract_text(content_type, &body_path, "transcript")
2209                .unwrap_or_default();
2210        let _ = stdfs::remove_file(&body_path);
2211    }
2212    match pharos_runtime_architect::attachment_ingest::ingest_transcript_in_store(
2213        &store,
2214        task_id,
2215        &llm_vendor_id,
2216        &transcript_text,
2217    ) {
2218        Ok(_) => architect_settings_review_response(app, request, method),
2219        Err(_) => http_text_response(422, "Unprocessable Entity", "transcript rejected\n"),
2220    }
2221}
2222
2223/// Checks console task actions against the authenticated submitter or legacy project creator.
2224pub(super) fn architect_task_actor_allowed_response(
2225    app: &AppRuntime,
2226    request: &http::HttpRequest,
2227) -> Result<(), Vec<u8>> {
2228    let task_id = dynamic::request_form_value(request, "task_id")
2229        .and_then(|value| value.parse::<i64>().ok())
2230        .unwrap_or(0);
2231    if task_id <= 0 {
2232        return Ok(());
2233    }
2234    let actor_id =
2235        active_session_user_id(app, request.cookie.as_deref()).map_err(database_error_response)?;
2236    if actor_id <= 0 {
2237        return Err(app_login_redirect(app, "/"));
2238    }
2239    let store = architect_task_store_for_app(app)
2240        .map_err(|_| http_text_response(503, "Service Unavailable", "database unavailable\n"))?;
2241    let owner = pharos_runtime_architect::task_ownership::task_owner(&store, task_id)
2242        .map_err(database_error_response)?;
2243    if owner != Some(actor_id) {
2244        return Err(http_text_response(
2245            403,
2246            "Forbidden",
2247            "task belongs to another user\n",
2248        ));
2249    }
2250    Ok(())
2251}
2252
2253pub(super) fn architect_submit_builtin_task_response(
2254    app: &AppRuntime,
2255    request: &http::HttpRequest,
2256    method: &str,
2257) -> Vec<u8> {
2258    if app.app_id.as_deref() != Some("architect") {
2259        return http_text_response(404, "Not Found", "not found\n");
2260    }
2261    let actor_id = match active_session_user_id(app, request.cookie.as_deref()) {
2262        Ok(value) => value,
2263        Err(error) => return std::convert::identity(database_error_response(error)),
2264    };
2265    if actor_id <= 0 {
2266        return app_login_redirect(app, "/");
2267    }
2268    if !match actor_has_route_policy(app, actor_id, "submit_builder_task") {
2269        Ok(value) => value,
2270        Err(error) => return std::convert::identity(database_error_response(error)),
2271    } {
2272        return http_text_response(403, "Forbidden", "capability denied\n");
2273    }
2274    let Ok(store) = architect_task_store_for_app(app) else {
2275        return http_text_response(503, "Service Unavailable", "database unavailable\n");
2276    };
2277    let output_app_id = dynamic::request_form_value(request, "output_app_id").unwrap_or_default();
2278    if dynamic::request_form_value(request, "image_import_only").as_deref() == Some("true") {
2279        return match pharos_runtime_architect::task_repository::image_asset_task_for_actor(
2280            &store,
2281            &output_app_id,
2282            actor_id,
2283        ) {
2284            Ok(task_id) => http_json_response(
2285                200,
2286                "OK",
2287                &serde_json::json!({"task_id":task_id}).to_string(),
2288                method,
2289            ),
2290            Err(agent_bridge::AgentErrorKind::PolicyViolation) => {
2291                http_text_response(403, "Forbidden", "app id belongs to another user\n")
2292            }
2293            Err(_) => http_text_response(
2294                422,
2295                "Unprocessable Entity",
2296                "image collection unavailable\n",
2297            ),
2298        };
2299    }
2300    let llm_vendor_id = dynamic::request_form_value(request, "llm_vendor_id")
2301        .filter(|value| !value.trim().is_empty())
2302        .unwrap_or_else(|| "builtin".to_owned());
2303    let brief = dynamic::request_form_value(request, "brief").unwrap_or_default();
2304    #[cfg(not(target_os = "wasi"))]
2305    if llm_vendor_id == "chatgpt" {
2306        let authority =
2307            match pharos_runtime_architect::chatgpt::InstalledArchitect::from_runtime(app) {
2308                Ok(authority) => authority,
2309                Err(error) => return architect_chatgpt_http::connection_failure(error),
2310            };
2311        let model = dynamic::request_form_value(request, "chatgpt_model_id").unwrap_or_default();
2312        let runtime = match tokio::runtime::Builder::new_current_thread()
2313            .enable_all()
2314            .build()
2315        {
2316            Ok(runtime) => runtime,
2317            Err(_) => {
2318                return architect_chatgpt_http::connection_failure(
2319                    pharos_runtime_architect::chatgpt::ConnectionError::Storage,
2320                );
2321            }
2322        };
2323        let binding = match runtime.block_on(
2324            pharos_runtime_architect::chatgpt::service::prepare_task_binding(
2325                &authority, &store, actor_id, &model,
2326            ),
2327        ) {
2328            Ok(binding) => binding,
2329            Err(error) => return architect_chatgpt_http::connection_failure(error),
2330        };
2331        return match pharos_runtime_architect::task_repository::queue_or_retry_chatgpt_task(
2332            &store,
2333            &output_app_id,
2334            &brief,
2335            actor_id,
2336            &binding,
2337        ) {
2338            Ok(_) => architect_task_console_response(app, request, method),
2339            Err(agent_bridge::AgentErrorKind::LockBusy) => http_text_response(
2340                409,
2341                "Conflict",
2342                "The current task is running; wait before changing its account or model",
2343            ),
2344            Err(agent_bridge::AgentErrorKind::PolicyViolation) => {
2345                http_text_response(403, "Forbidden", "App id belongs to another user")
2346            }
2347            Err(_) => http_text_response(
2348                422,
2349                "Unprocessable Entity",
2350                "ChatGPT task could not be queued; refresh the connection and try again",
2351            ),
2352        };
2353    }
2354    match agent_architect::queue_or_retry_architect_task(
2355        &store,
2356        &output_app_id,
2357        &llm_vendor_id,
2358        &brief,
2359        actor_id,
2360    ) {
2361        Ok(_) => architect_task_console_response(app, request, method),
2362        Err(agent_bridge::AgentErrorKind::PolicyViolation) => {
2363            http_text_response(403, "Forbidden", "app id belongs to another user\n")
2364        }
2365        Err(_) => http_text_response(422, "Unprocessable Entity", "task creation unavailable\n"),
2366    }
2367}
2368
2369/// Applies explicit CSV/page/image requests through the ordinary owner-checked source transaction.
2370fn architect_prepare_csv_page_bindings(
2371    app: &AppRuntime,
2372    store: &pharos_runtime_architect::database::ArchitectStore,
2373    task_id: i64,
2374    actor_id: i64,
2375    generated_root: &Path,
2376    brief: &str,
2377) -> Result<(), String> {
2378    let bind_pages =
2379        pharos_runtime_architect::data_binding::requested_for_task(store, task_id, brief)?;
2380    let media_root = architect_media_root(app).ok_or("Image storage is unavailable.")?;
2381    if let Some(change) = pharos_runtime_architect::data_binding::prepare(
2382        store,
2383        task_id,
2384        actor_id,
2385        &media_root,
2386        bind_pages,
2387    )? {
2388        agent_architect::apply_architect_editor_source_edit(
2389            store,
2390            change,
2391            generated_root,
2392            actor_id,
2393        )
2394        .map_err(|error| format!("Validation failed: {error:?}; accepted source was preserved."))?;
2395    }
2396    store.insert("architect_task_event", BTreeMap::from([
2397        ("task_id".to_owned(), pharos_runtime_database::operations::WriteValue::Literal(serde_json::json!(task_id))),
2398        ("event_kind".to_owned(), pharos_runtime_database::operations::WriteValue::Literal(serde_json::json!("data_pages_populated"))),
2399        ("event_summary".to_owned(), pharos_runtime_database::operations::WriteValue::Literal(serde_json::json!(if bind_pages {"CSV tables linked to matching pages; image references resolved into app-owned source assets."} else {"Imported image history retained as portable app-owned static assets."}))),
2400        ("created_at".to_owned(), pharos_runtime_database::operations::WriteValue::Literal(serde_json::json!(current_timestamp_utc())))
2401    ])).map_err(|_|"Data binding evidence could not be saved.")?;
2402    Ok(())
2403}
2404
2405pub(super) fn architect_auto_build_task_response(
2406    app: &AppRuntime,
2407    request: &http::HttpRequest,
2408    method: &str,
2409) -> Vec<u8> {
2410    if app.app_id.as_deref() != Some("architect") {
2411        return http_text_response(404, "Not Found", "not found\n");
2412    }
2413    let actor_id = match active_session_user_id(app, request.cookie.as_deref()) {
2414        Ok(value) => value,
2415        Err(error) => return std::convert::identity(database_error_response(error)),
2416    };
2417    if actor_id <= 0 {
2418        return app_login_redirect(app, "/");
2419    }
2420    if !match actor_has_route_policy(app, actor_id, "review_builder_plan") {
2421        Ok(value) => value,
2422        Err(error) => return std::convert::identity(database_error_response(error)),
2423    } {
2424        return http_text_response(403, "Forbidden", "capability denied\n");
2425    }
2426    let Ok(store) = architect_task_store_for_app(app) else {
2427        return http_text_response(503, "Service Unavailable", "database unavailable\n");
2428    };
2429    let Some(generated_root) = architect_generated_source_root(app) else {
2430        return http_text_response(503, "Service Unavailable", "generated source unavailable\n");
2431    };
2432    let task_id = dynamic::request_form_value(request, "task_id")
2433        .and_then(|value| value.parse::<i64>().ok())
2434        .unwrap_or(0);
2435    if task_id <= 0 {
2436        return http_text_response(422, "Unprocessable Entity", "invalid task id\n");
2437    }
2438    if pharos_runtime_architect::task_ownership::task_owner(&store, task_id)
2439        .ok()
2440        .flatten()
2441        != Some(actor_id)
2442    {
2443        return http_text_response(403, "Forbidden", "This task belongs to another user.\n");
2444    }
2445    if dynamic::request_form_value(request, "validate_existing_source").as_deref() == Some("true") {
2446        return match agent_architect::validate_existing_architect_task_source(
2447            &store,
2448            task_id,
2449            &generated_root,
2450            actor_id,
2451        ) {
2452            Ok(()) => architect_task_console_response(app, request, method),
2453            Err(error) => {
2454                eprintln!("Architect existing source validation failed: {error:?}");
2455                http_text_response(
2456                    422,
2457                    "Unprocessable Entity",
2458                    "Existing app source validation failed; accepted source was preserved.\n",
2459                )
2460            }
2461        };
2462    }
2463    let task_brief = match pharos_runtime_architect::task_repository::load_task(&store, task_id) {
2464        Ok(task) => task
2465            .context_evidence
2466            .sources
2467            .iter()
2468            .find(|source| source.source_id == "brief")
2469            .map(|source| source.selected_excerpt.clone())
2470            .unwrap_or_default(),
2471        Err(_) => {
2472            return http_text_response(
2473                422,
2474                "Unprocessable Entity",
2475                "Builder task is unavailable.\n",
2476            );
2477        }
2478    };
2479    if dynamic::request_form_value(request, "complete_csv_binding").as_deref() == Some("true") {
2480        if !pharos_runtime_architect::task_repository::load_task(&store, task_id)
2481            .is_ok_and(|task| task.task_status == "validated")
2482        {
2483            return http_text_response(
2484                409,
2485                "Conflict",
2486                "The app must finish its initial build before CSV data can be connected.\n",
2487            );
2488        }
2489        if let Err(message) = architect_prepare_csv_page_bindings(
2490            app,
2491            &store,
2492            task_id,
2493            actor_id,
2494            &generated_root,
2495            &task_brief,
2496        ) {
2497            return http_text_response(
2498                422,
2499                "Unprocessable Entity",
2500                &format!("CSV page binding failed: {message}\n"),
2501            );
2502        }
2503        if !architect_prepare_validated_generated_preview(&store, task_id) {
2504            return http_text_response(
2505                422,
2506                "Unprocessable Entity",
2507                "Imported-data preview is still preparing.\n",
2508            );
2509        }
2510        return architect_task_console_response(app, request, method);
2511    }
2512    let llm_vendor_vault =
2513        architect_vault_paths(app).map(|(vault_path, root_key_file)| {
2514            agent_architect::ArchitectLlmVendorVaultPaths {
2515                vault_path,
2516                root_key_file,
2517                chatgpt_authority:
2518                    pharos_runtime_architect::chatgpt::InstalledArchitect::from_runtime(app).ok(),
2519            }
2520        });
2521    let mut last_error = None;
2522    for attempt in 0..6 {
2523        match agent_architect::run_architect_task_to_validated(
2524            &store,
2525            task_id,
2526            &generated_root,
2527            llm_vendor_vault.clone(),
2528        ) {
2529            Ok(_) => {
2530                if let Err(message) = architect_prepare_csv_page_bindings(
2531                    app,
2532                    &store,
2533                    task_id,
2534                    actor_id,
2535                    &generated_root,
2536                    if dynamic::request_form_value(request, "defer_csv_binding").as_deref()
2537                        == Some("true")
2538                    {
2539                        ""
2540                    } else {
2541                        &task_brief
2542                    },
2543                ) {
2544                    if pharos_runtime_architect::task_repository::transition_task(
2545                        &store,
2546                        task_id,
2547                        "failed",
2548                        "data_binding_failed",
2549                        &message,
2550                        BTreeMap::new(),
2551                    )
2552                    .is_err()
2553                    {
2554                        return http_text_response(
2555                            503,
2556                            "Service Unavailable",
2557                            "Data binding failure could not be recorded.\n",
2558                        );
2559                    }
2560                    return http_text_response(
2561                        422,
2562                        "Unprocessable Entity",
2563                        &format!("CSV page binding failed: {message}\n"),
2564                    );
2565                }
2566                if !architect_prepare_validated_generated_preview(&store, task_id) {
2567                    return http_text_response(
2568                        422,
2569                        "Unprocessable Entity",
2570                        "generated app preview is still preparing\n",
2571                    );
2572                }
2573                return architect_task_console_response(app, request, method);
2574            }
2575            Err(
2576                error @ (agent_bridge::AgentErrorKind::LockBusy
2577                | agent_bridge::AgentErrorKind::InvalidIdentifier),
2578            ) if attempt < 5 => {
2579                last_error = Some(error);
2580                std::thread::sleep(Duration::from_millis(150 * (attempt + 1) as u64));
2581            }
2582            Err(error) => {
2583                last_error = Some(error);
2584                break;
2585            }
2586        }
2587    }
2588    let message = match last_error {
2589        Some(agent_bridge::AgentErrorKind::DatabaseConflict) => {
2590            "Automatic app build could not save a database change because a constraint or concurrent update was rejected. Check the app migrations; accepted source was preserved.\n".to_owned()
2591        }
2592        Some(agent_bridge::AgentErrorKind::LockBusy) => {
2593            "automatic app build database was busy\n".to_owned()
2594        }
2595        Some(agent_bridge::AgentErrorKind::InvalidIdentifier) => {
2596            "automatic app build could not read the queued task\n".to_owned()
2597        }
2598        Some(error) => {
2599            let failure = store
2600                .query_one(
2601                    "architect_task",
2602                    &["task_status", "validation_summary"],
2603                    pharos_runtime_database::operations::Predicate::Equal(
2604                        "id".to_owned(),
2605                        serde_json::json!(task_id),
2606                    ),
2607                )
2608                .ok()
2609                .flatten()
2610                .filter(|row| {
2611                    row.get("task_status").and_then(serde_json::Value::as_str) == Some("failed")
2612                })
2613                .and_then(|row| {
2614                    row.get("validation_summary")
2615                        .and_then(serde_json::Value::as_str)
2616                        .filter(|message| {
2617                            message.starts_with("LLM build failed:")
2618                                || message.starts_with("App requirements need attention:")
2619                                || message.starts_with("Architect app model synthesis failed:")
2620                                || message.starts_with("Source planning rejected:")
2621                                || message.starts_with("Reference source could not be read")
2622                        })
2623                        .map(str::to_owned)
2624                });
2625            failure
2626                .map(|message| format!("{message}\n"))
2627                .unwrap_or_else(|| format!("automatic app build unavailable: {error:?}\n"))
2628        }
2629        None => "automatic app build unavailable\n".to_owned(),
2630    };
2631    http_text_response(422, "Unprocessable Entity", &message)
2632}
2633
2634pub(super) fn architect_approve_apply_plan_response(
2635    app: &AppRuntime,
2636    request: &http::HttpRequest,
2637    method: &str,
2638) -> Vec<u8> {
2639    if app.app_id.as_deref() != Some("architect") {
2640        return http_text_response(404, "Not Found", "not found\n");
2641    }
2642    let actor_id = match active_session_user_id(app, request.cookie.as_deref()) {
2643        Ok(value) => value,
2644        Err(error) => return std::convert::identity(database_error_response(error)),
2645    };
2646    if actor_id <= 0 {
2647        return app_login_redirect(app, "/");
2648    }
2649    if !match actor_has_route_policy(app, actor_id, "review_builder_plan") {
2650        Ok(value) => value,
2651        Err(error) => return std::convert::identity(database_error_response(error)),
2652    } {
2653        return http_text_response(403, "Forbidden", "capability denied\n");
2654    }
2655    let Ok(store) = architect_task_store_for_app(app) else {
2656        return http_text_response(503, "Service Unavailable", "database unavailable\n");
2657    };
2658    let Some(generated_root) = architect_generated_source_root(app) else {
2659        return http_text_response(503, "Service Unavailable", "generated source unavailable\n");
2660    };
2661    let task_id = dynamic::request_form_value(request, "task_id")
2662        .and_then(|value| value.parse::<i64>().ok())
2663        .unwrap_or(0);
2664    if task_id <= 0 {
2665        return http_text_response(422, "Unprocessable Entity", "invalid task id\n");
2666    }
2667    match agent_architect::approve_architect_task_plan(&store, task_id, &generated_root) {
2668        Ok(_) => {
2669            if !architect_prepare_validated_generated_preview(&store, task_id) {
2670                return http_text_response(
2671                    422,
2672                    "Unprocessable Entity",
2673                    "generated app preview is still preparing\n",
2674                );
2675            }
2676            architect_task_console_response(app, request, method)
2677        }
2678        Err(_) => http_text_response(422, "Unprocessable Entity", "plan apply unavailable\n"),
2679    }
2680}
2681
2682pub(super) fn architect_delete_task_response(
2683    app: &AppRuntime,
2684    request: &http::HttpRequest,
2685    method: &str,
2686) -> Vec<u8> {
2687    if app.app_id.as_deref() != Some("architect") {
2688        return http_text_response(404, "Not Found", "not found\n");
2689    }
2690    let actor_id = match active_session_user_id(app, request.cookie.as_deref()) {
2691        Ok(value) => value,
2692        Err(error) => return std::convert::identity(database_error_response(error)),
2693    };
2694    if actor_id <= 0 {
2695        return app_login_redirect(app, "/");
2696    }
2697    if !match actor_has_route_policy(app, actor_id, "review_builder_plan") {
2698        Ok(value) => value,
2699        Err(error) => return std::convert::identity(database_error_response(error)),
2700    } {
2701        return http_text_response(403, "Forbidden", "capability denied\n");
2702    }
2703    let Ok(store) = architect_task_store_for_app(app) else {
2704        return http_text_response(503, "Service Unavailable", "database unavailable\n");
2705    };
2706    let Some(generated_root) = architect_generated_source_root(app) else {
2707        return http_text_response(503, "Service Unavailable", "generated source unavailable\n");
2708    };
2709    let Some(state_parent) = architect_state_parent(app) else {
2710        return http_text_response(503, "Service Unavailable", "state unavailable\n");
2711    };
2712    let task_id = dynamic::request_form_value(request, "task_id")
2713        .and_then(|value| value.parse::<i64>().ok())
2714        .unwrap_or(0);
2715    if task_id <= 0 {
2716        return http_text_response(422, "Unprocessable Entity", "invalid task id\n");
2717    }
2718    let export_root = state_parent.join("exports/source");
2719    let Some(media_root) = architect_media_root(app) else {
2720        return http_text_response(503, "Service Unavailable", "media storage unavailable\n");
2721    };
2722    if stdfs::create_dir_all(&export_root).is_err() || stdfs::create_dir_all(&media_root).is_err() {
2723        return http_text_response(503, "Service Unavailable", "task storage unavailable\n");
2724    }
2725    let delete_task_result =
2726        pharos_runtime_architect::project_state::delete_architect_task_in_store(
2727            &store,
2728            task_id,
2729            &generated_root,
2730            &export_root,
2731            &media_root,
2732        );
2733    match delete_task_result {
2734        Ok(_) => architect_task_console_response(app, request, method),
2735        Err(error) => {
2736            eprintln!("architect task {task_id} deletion failed: {error:?}");
2737            http_text_response(422, "Unprocessable Entity", "task delete unavailable\n")
2738        }
2739    }
2740}
2741
2742pub(super) fn architect_generated_app_preview_response(
2743    app: &AppRuntime,
2744    request: &http::HttpRequest,
2745    method: &str,
2746) -> Vec<u8> {
2747    if app.app_id.as_deref() != Some("architect") {
2748        return http_text_response(404, "Not Found", "not found\n");
2749    }
2750    let actor_id = match active_session_user_id(app, request.cookie.as_deref()) {
2751        Ok(value) => value,
2752        Err(error) => return std::convert::identity(database_error_response(error)),
2753    };
2754    if actor_id <= 0 {
2755        return app_login_redirect(app, "/");
2756    }
2757    if !match actor_has_route_policy(app, actor_id, "review_builder_plan") {
2758        Ok(value) => value,
2759        Err(error) => return std::convert::identity(database_error_response(error)),
2760    } {
2761        return http_text_response(403, "Forbidden", "capability denied\n");
2762    }
2763    let task_id = request_query_value(&request.target, "task_id")
2764        .and_then(|value| value.parse::<i64>().ok())
2765        .unwrap_or(0);
2766    if task_id <= 0 {
2767        return http_route_redirect("/architect/");
2768    }
2769    let Ok(store) = architect_task_store_for_app(app) else {
2770        return http_text_response(503, "Service Unavailable", "database unavailable\n");
2771    };
2772    let connection_namespace =
2773        match pharos_runtime_architect::task_ownership::owned_preview_namespace(
2774            &store, task_id, actor_id,
2775        ) {
2776            Ok(namespace) => namespace,
2777            Err(error) => return database_error_response(error),
2778        };
2779    let generated_root = architect_generated_source_root(app);
2780    let accepted_editor_root = architect_current_editor_preview_root(&store, task_id);
2781    let has_accepted_editor_version = accepted_editor_root.is_some();
2782    let (output_app_id, preview_root, preview_workspace) =
2783        if let Some((output_app_id, current_editor_root)) = accepted_editor_root {
2784            let current_editor_workspace = architect_editor_preview_workspace(&current_editor_root);
2785            if !architect_sync_editor_preview_config(&store, task_id, &current_editor_workspace) {
2786                return http_text_response(
2787                    503,
2788                    "Service Unavailable",
2789                    "preview configuration unavailable\n",
2790                );
2791            }
2792            (output_app_id, current_editor_root, current_editor_workspace)
2793        } else {
2794            let output_app_id = store
2795                .query_one(
2796                    "architect_task",
2797                    &["output_app_id"],
2798                    pharos_runtime_database::operations::Predicate::Equal(
2799                        "id".to_owned(),
2800                        serde_json::json!(task_id),
2801                    ),
2802                )
2803                .ok()
2804                .flatten()
2805                .and_then(|task| {
2806                    task.get("output_app_id")
2807                        .and_then(Value::as_str)
2808                        .map(str::to_owned)
2809                });
2810            let Some(output_app_id) = output_app_id else {
2811                return http_text_response(404, "Not Found", "generated app preview unavailable\n");
2812            };
2813            let review_workspace_path = architect_validated_task_preview_info(&store, task_id)
2814                .map(|(_, review_workspace_path)| review_workspace_path);
2815            let generated_source_root = generated_root
2816                .as_ref()
2817                .map(|root| root.join(&output_app_id))
2818                .filter(|root| root.join("pharos.app.yml").is_file());
2819            let preview_workspace = review_workspace_path
2820                .as_deref()
2821                .map(PathBuf::from)
2822                .filter(|path| path.is_dir())
2823                .or_else(|| {
2824                    generated_source_root
2825                        .as_ref()
2826                        .and_then(|root| root.parent().map(Path::to_path_buf))
2827                })
2828                .unwrap_or_else(|| PathBuf::from("."));
2829            let built_preview_root = preview_workspace
2830                .join("dist")
2831                .join("apps")
2832                .join(&output_app_id);
2833            let source_preview_root = preview_workspace.join("apps").join(&output_app_id);
2834            let preview_root = if built_preview_root.join("pharos.app.json").is_file() {
2835                built_preview_root
2836            } else if source_preview_root.join("pharos.app.yml").is_file() {
2837                source_preview_root
2838            } else if let Some(root) = generated_source_root {
2839                root
2840            } else {
2841                return http_text_response(404, "Not Found", "generated app preview unavailable\n");
2842            };
2843            (output_app_id, preview_root, preview_workspace)
2844        };
2845    let preview_path = request_query_value(&request.target, "path")
2846        .unwrap_or_else(|| "/".to_owned())
2847        .trim()
2848        .to_owned();
2849    let base_path = format!("/{}", output_app_id.replace('_', "-"));
2850    let route_path = if preview_path.is_empty() || preview_path == base_path {
2851        "/".to_owned()
2852    } else if let Some(stripped) = preview_path.strip_prefix(&base_path) {
2853        stripped.to_owned()
2854    } else {
2855        preview_path.clone()
2856    };
2857    let preview_root = architect_ready_preview_asset_root(
2858        &preview_root,
2859        &preview_workspace,
2860        &output_app_id,
2861        &route_path,
2862        method,
2863    )
2864    .or_else(|| {
2865        architect_prepared_preview_root_if_ready(&preview_root, &preview_workspace, &output_app_id)
2866    })
2867    .or_else(|| {
2868        if has_accepted_editor_version || architect_task_is_validated(&store, task_id) {
2869            architect_materialized_preview_root(&preview_root, &preview_workspace, &output_app_id)
2870        } else {
2871            None
2872        }
2873    })
2874    .unwrap_or_else(|| preview_root.to_path_buf());
2875    if (route_path.starts_with("/media/images/")
2876        || request
2877            .content_type
2878            .as_deref()
2879            .is_some_and(|value| value.starts_with("multipart/form-data")))
2880        && !architect_configure_preview_media(&preview_root, &preview_workspace, &output_app_id)
2881    {
2882        return http_text_response(
2883            503,
2884            "Service Unavailable",
2885            "preview media storage unavailable\n",
2886        );
2887    }
2888    if route_path.starts_with("/media/architect-preview/") {
2889        return architect_preview_image_response(app, &route_path, method);
2890    }
2891    if route_path.starts_with("/static/") || route_path.starts_with("/media/") {
2892        let response = pharos_runtime_static::artifact_server::serve_artifact_request(
2893            &preview_root,
2894            method,
2895            &route_path,
2896        );
2897        return architect_generated_preview_static_response(
2898            response,
2899            task_id,
2900            &output_app_id,
2901            &route_path,
2902            method,
2903        );
2904    }
2905    let Some(mut preview_app) =
2906        architect_generated_preview_app_runtime(&preview_root, &preview_workspace, &output_app_id)
2907    else {
2908        return http_text_response(404, "Not Found", "generated app preview unavailable\n");
2909    };
2910    preview_app.connection_namespace = Some(connection_namespace);
2911    super::event_discovery::register_preview(&preview_app);
2912    let mut preview_request = request.clone();
2913    // Older UCAL exports embed the original mount in their public-view links.
2914    let route_path = if route_path.starts_with("/ucal/b/")
2915        && matched_app_route(&preview_app, method, &route_path).is_none()
2916        && matched_app_route(&preview_app, method, &route_path[5..]).is_some()
2917    {
2918        route_path[5..].to_owned()
2919    } else {
2920        route_path
2921    };
2922    let route_path = architect_preview_home_route(&preview_app, method, route_path);
2923    preview_request.target = match architect_preview_query_target(&route_path, &request.target) {
2924        Some(target) => target,
2925        None => return http_text_response(400, "Bad Request", "Invalid preview query.\n"),
2926    };
2927    let preview_cookie_name = format!("pharos_preview_{task_id}");
2928    let preview_token = dynamic::dynamic_cookie_value(
2929        request.cookie.as_deref().unwrap_or_default(),
2930        &preview_cookie_name,
2931    );
2932    preview_request.cookie = request.cookie.as_deref().map(|cookies| {
2933        let mut values = cookies
2934            .split(';')
2935            .map(str::trim)
2936            .filter(|cookie| !cookie.starts_with("pharos_session="))
2937            .map(str::to_owned)
2938            .collect::<Vec<_>>();
2939        if let Some(token) = preview_token {
2940            values.push(format!("pharos_session={token}"));
2941        }
2942        values.join("; ")
2943    });
2944    if let Some(response) = architect_preview_private_asset_response(&preview_app, &preview_request)
2945    {
2946        return response;
2947    }
2948    #[cfg(not(target_os = "wasi"))]
2949    if let Some(response) = super::social_connections::response_at(
2950        &preview_app,
2951        &preview_request,
2952        &preview_request.target,
2953        Some(&format!(
2954            "{}/apps/view?task_id={task_id}&path=%2F__pharos%2Fsocial%2F{{provider}}%2Fcallback",
2955            app.base_path.as_deref().unwrap_or("/architect")
2956        )),
2957    ) {
2958        return rewrite_architect_generated_preview_response(
2959            response,
2960            app,
2961            &preview_app,
2962            &output_app_id,
2963            task_id,
2964            &preview_request,
2965            &dynamic::csrf_token_for_response(request.cookie.as_deref()),
2966        );
2967    }
2968    if !matches!(method, "GET" | "HEAD")
2969        && let Err(response) = authorize_app_mutation(
2970            &preview_app,
2971            &preview_request,
2972            preview_request.target.split('?').next().unwrap_or("/"),
2973        )
2974    {
2975        return rewrite_architect_generated_preview_response(
2976            response,
2977            app,
2978            &preview_app,
2979            &output_app_id,
2980            task_id,
2981            &preview_request,
2982            &dynamic::csrf_token_for_response(request.cookie.as_deref()),
2983        );
2984    }
2985    let Some(response) = app_declarative_route_response(&preview_app, &preview_request) else {
2986        return http_text_response(404, "Not Found", "generated app route unavailable\n");
2987    };
2988    let csrf_token = dynamic::csrf_token_for_response(request.cookie.as_deref());
2989    rewrite_architect_generated_preview_response(
2990        response,
2991        app,
2992        &preview_app,
2993        &output_app_id,
2994        task_id,
2995        &preview_request,
2996        &csrf_token,
2997    )
2998}
2999
3000/// Serves entitlement-authorized preview downloads through the normal private-asset boundary.
3001fn architect_preview_private_asset_response(
3002    app: &AppRuntime,
3003    request: &http::HttpRequest,
3004) -> Option<Vec<u8>> {
3005    if !matches!(request.method.as_str(), "GET" | "HEAD") {
3006        return None;
3007    }
3008    let actor = match active_session_user_id(app, request.cookie.as_deref()) {
3009        Ok(actor) => actor,
3010        Err(error) => return Some(database_error_response(error)),
3011    };
3012    let path = request.target.split('?').next().unwrap_or("/");
3013    let asset = match feature_runtime::authorize_private_asset(app, path, actor) {
3014        Ok(Some(asset)) => asset,
3015        Ok(None) => return None,
3016        Err(_) => {
3017            return Some(http_text_response(
3018                503,
3019                "Service Unavailable",
3020                "private asset authorization unavailable\n",
3021            ));
3022        }
3023    };
3024    let range = dynamic::request_header_value(request, "range");
3025    let if_range = dynamic::request_header_value(request, "if-range");
3026    let stream = match artifact_server::serve_app_private_asset(
3027        app,
3028        &asset.private_root,
3029        &asset.asset_key,
3030        &request.method,
3031        range.as_deref(),
3032        if_range.as_deref(),
3033    ) {
3034        Ok(stream) => stream,
3035        Err(error) => return Some(http_stream_error(error, &request.method)),
3036    };
3037    // Architect rewrites buffered page responses; binary assets bypass that text-only step.
3038    let bound = app.resource_budgets.max_response_bytes.min(8 * 1024 * 1024);
3039    if stream.content_length > bound as u64 {
3040        return Some(http_text_response(
3041            413,
3042            "Payload Too Large",
3043            "preview download exceeds response budget\n",
3044        ));
3045    }
3046    let mut response = Vec::new();
3047    let capacity = if request.method == "HEAD" {
3048        4096
3049    } else {
3050        stream.content_length as usize + 4096
3051    };
3052    if response.try_reserve(capacity).is_err() {
3053        return Some(http_text_response(
3054            503,
3055            "Service Unavailable",
3056            "preview download unavailable\n",
3057        ));
3058    }
3059    write_stream_response(&mut response, &stream, &request.method);
3060    Some(response)
3061}
3062
3063/// Resolves the default View link to the declared home page without changing explicit nested paths.
3064fn architect_preview_home_route(app: &AppRuntime, method: &str, path: String) -> String {
3065    if path != "/"
3066        || !matches!(method, "GET" | "HEAD")
3067        || matched_app_route(app, method, &path).is_some()
3068    {
3069        return path;
3070    }
3071    app.application_ir
3072        .as_ref()
3073        .and_then(|ir| {
3074            ir.routes
3075                .values()
3076                .find(|route| {
3077                    route.kind == "page"
3078                        && route.method == "GET"
3079                        && route
3080                            .page_id
3081                            .as_ref()
3082                            .is_some_and(|id| id.as_str() == "home")
3083                })
3084                .map(|route| route.path.clone())
3085        })
3086        .unwrap_or(path)
3087}
3088
3089/// Configures only a materialized artifact to use stable, isolated preview media storage.
3090fn architect_configure_preview_media(root: &Path, workspace: &Path, app_id: &str) -> bool {
3091    let is_artifact = root
3092        .components()
3093        .any(|part| part.as_os_str() == ".preview-artifacts")
3094        || root.starts_with(workspace.join("dist").join("apps"));
3095    if !is_artifact || !root.join("pharos.app.json").is_file() {
3096        return false;
3097    }
3098    let media = super::architect_preview_accounts::state_root(workspace)
3099        .join(app_id)
3100        .join("media");
3101    if !media.is_absolute() || media.starts_with(root) {
3102        return false;
3103    }
3104    let path = root.join("app.conf");
3105    let Ok(text) = stdfs::read_to_string(&path) else {
3106        return false;
3107    };
3108    if text.len() > 1024 * 1024 {
3109        return false;
3110    }
3111    let setting = format!("MEDIA_ROOT={}", media.display());
3112    let mut found = false;
3113    let mut lines = Vec::new();
3114    for line in text.lines() {
3115        if line
3116            .split_once('=')
3117            .is_some_and(|(key, _)| key.trim() == "MEDIA_ROOT")
3118        {
3119            if !found {
3120                lines.push(setting.clone());
3121                found = true;
3122            }
3123        } else {
3124            lines.push(line.to_owned());
3125        }
3126    }
3127    if !found {
3128        lines.push(setting);
3129    }
3130    let updated = format!("{}\n", lines.join("\n"));
3131    if updated == text {
3132        return true;
3133    }
3134    super::fs::write_file_within(root, &path, updated.as_bytes(), 0o600).is_ok()
3135}
3136
3137pub(super) fn architect_generated_preview_app_runtime(
3138    preview_root: &Path,
3139    preview_workspace: &Path,
3140    output_app_id: &str,
3141) -> Option<AppRuntime> {
3142    let manifest = read_source_manifest_value(preview_root).ok()?;
3143    let application_ir =
3144        pharos_runtime_core::application_ir::compile_application_ir(preview_root, &manifest)
3145            .ok()?;
3146    let runtime_conf_path = preview_workspace.join("runtime.conf");
3147    let preview = AppRuntime {
3148        is_editor_preview: true,
3149        application_ir: Some(std::sync::Arc::new(application_ir)),
3150        artifact_dir: Some(preview_root.to_string_lossy().into_owned()),
3151        app_id: Some(output_app_id.to_owned()),
3152        app_root: Some(preview_root.to_string_lossy().into_owned()),
3153        build_dir: preview_root
3154            .parent()
3155            .map(|path| path.to_string_lossy().into_owned()),
3156        host_profile: Some("dynamic-app".to_owned()),
3157        state_path: Some(
3158            super::architect_preview_accounts::state_root(preview_workspace)
3159                .join(output_app_id)
3160                .join("runtime-state.json")
3161                .to_string_lossy()
3162                .into_owned(),
3163        ),
3164        base_path: Some(format!("/{}", output_app_id.replace('_', "-"))),
3165        log_path: Some(
3166            preview_workspace
3167                .join("logs")
3168                .join(format!("{output_app_id}.log"))
3169                .to_string_lossy()
3170                .into_owned(),
3171        ),
3172        log_format: Some("service".to_owned()),
3173        error_report_path: Some(String::new()),
3174        log_level: Some("WARN".to_owned()),
3175        runtime_conf_path: Some(runtime_conf_path.to_string_lossy().into_owned()),
3176        ..AppRuntime::default()
3177    };
3178    Some(preview)
3179}
3180
3181fn rewrite_architect_generated_preview_response(
3182    response: Vec<u8>,
3183    architect_app: &AppRuntime,
3184    preview_app: &AppRuntime,
3185    output_app_id: &str,
3186    task_id: i64,
3187    preview_request: &http::HttpRequest,
3188    csrf_token: &str,
3189) -> Vec<u8> {
3190    let method = preview_request.method.as_str();
3191    let Some(separator) = response.windows(4).position(|bytes| bytes == b"\r\n\r\n") else {
3192        return response;
3193    };
3194    let Ok(headers) = std::str::from_utf8(&response[..separator]) else {
3195        return http_text_response(
3196            500,
3197            "Internal Server Error",
3198            "generated app preview failed\n",
3199        );
3200    };
3201    let body = &response[separator + 4..];
3202    let headers = headers
3203        .lines()
3204        .map(|line| {
3205            if line.starts_with("Set-Cookie: pharos_session=") {
3206                return line
3207                    .replacen("pharos_session=", &format!("pharos_preview_{task_id}="), 1)
3208                    .replace(
3209                        &format!("Path=/{}", output_app_id.replace('_', "-")),
3210                        "Path=/architect",
3211                    );
3212            }
3213            if let Some((name, value)) = line.split_once(':')
3214                && (name.eq_ignore_ascii_case("location")
3215                    || name.eq_ignore_ascii_case("hx-redirect"))
3216                && let Some(location) =
3217                    architect_preview_redirect_location(value.trim(), output_app_id, task_id)
3218            {
3219                return format!("{name}: {location}");
3220            }
3221            line.to_owned()
3222        })
3223        .collect::<Vec<_>>()
3224        .join("\r\n");
3225    let is_html_response = headers.lines().any(|line| {
3226        line.split_once(':').is_some_and(|(name, value)| {
3227            name.trim().eq_ignore_ascii_case("content-type")
3228                && value.trim().to_ascii_lowercase().starts_with("text/html")
3229        })
3230    });
3231    if method == "HEAD" || !is_html_response {
3232        let mut rewritten = Vec::with_capacity(headers.len() + 4 + body.len());
3233        rewritten.extend_from_slice(headers.as_bytes());
3234        rewritten.extend_from_slice(b"\r\n\r\n");
3235        rewritten.extend_from_slice(body);
3236        return rewritten;
3237    }
3238    let Ok(body) = std::str::from_utf8(body) else {
3239        return http_text_response(
3240            500,
3241            "Internal Server Error",
3242            "generated app preview failed\n",
3243        );
3244    };
3245    let html = rewrite_architect_generated_preview_forms(
3246        &rewrite_architect_generated_preview_urls(body, output_app_id, task_id),
3247        preview_app,
3248        task_id,
3249    );
3250    let route = matched_app_route(
3251        preview_app,
3252        method,
3253        preview_request.target.split('?').next().unwrap_or("/"),
3254    );
3255    let source_template = route
3256        .as_ref()
3257        .and_then(|route| route.get("page_id"))
3258        .and_then(Value::as_str)
3259        .and_then(|page_id| {
3260            let root = Path::new(
3261                preview_app
3262                    .artifact_dir
3263                    .as_deref()
3264                    .or(preview_app.app_root.as_deref())?,
3265            );
3266            let declaration = json_commands::declared_page_relative_path(root, page_id)?;
3267            let page = json_commands::json_doc_load_file(&root.join(declaration))?;
3268            page.get("template_file")
3269                .and_then(Value::as_str)
3270                .map(str::to_owned)
3271        })
3272        .unwrap_or_default();
3273    let html = add_architect_edit_toolbar(
3274        &html,
3275        architect_app,
3276        task_id,
3277        csrf_token,
3278        preview_app,
3279        &source_template,
3280    );
3281    let mut rewritten_headers = Vec::new();
3282    for line in headers.lines() {
3283        if line.to_ascii_lowercase().starts_with("content-length:") {
3284            rewritten_headers.push(format!("Content-Length: {}", html.len()));
3285        } else {
3286            rewritten_headers.push(line.to_owned());
3287        }
3288    }
3289    format!("{}\r\n\r\n{}", rewritten_headers.join("\r\n"), html).into_bytes()
3290}
3291
3292/// Keeps redirects within the selected app preview without absorbing another app or origin.
3293fn architect_preview_redirect_location(
3294    location: &str,
3295    output_app_id: &str,
3296    task_id: i64,
3297) -> Option<String> {
3298    let base = format!("/{}", output_app_id.replace('_', "-"));
3299    let suffix = location.strip_prefix(&base)?;
3300    let path = if suffix.is_empty() {
3301        "/".to_owned()
3302    } else if suffix.starts_with('/') && !suffix.starts_with("//") {
3303        suffix.to_owned()
3304    } else if suffix.starts_with('?') {
3305        format!("/{suffix}")
3306    } else {
3307        return None;
3308    };
3309    Some(format!(
3310        "/architect/app/edit?task_id={task_id}&app_id={}&path={}",
3311        json_commands::url_encode_query_component(output_app_id),
3312        json_commands::url_encode_query_component(&path)
3313    ))
3314}
3315
3316#[cfg(test)]
3317mod preview_redirect_tests {
3318    use super::*;
3319
3320    /// Binary downloads retain exact bytes while preview session cookies keep their isolation.
3321    #[test]
3322    fn preview_binary_download_preserves_body_and_scopes_cookie() {
3323        let body = [0x50, 0x4b, 0xff, 0xfe, 0, 0x81];
3324        let mut response = b"HTTP/1.1 200 OK\r\nContent-Type: application/zip\r\nContent-Length: 6\r\nSet-Cookie: pharos_session=controlled; Path=/commerce-app; HttpOnly\r\n\r\n".to_vec();
3325        response.extend_from_slice(&body);
3326        let app = AppRuntime::default();
3327        let request = http::HttpRequest {
3328            method: "GET".into(),
3329            ..Default::default()
3330        };
3331        let rewritten = rewrite_architect_generated_preview_response(
3332            response,
3333            &app,
3334            &app,
3335            "commerce_app",
3336            306,
3337            &request,
3338            "controlled-csrf",
3339        );
3340        let separator = rewritten
3341            .windows(4)
3342            .position(|bytes| bytes == b"\r\n\r\n")
3343            .unwrap();
3344        assert_eq!(&rewritten[separator + 4..], &body);
3345        let headers = std::str::from_utf8(&rewritten[..separator]).unwrap();
3346        assert!(headers.contains("pharos_preview_306=controlled; Path=/architect; HttpOnly"));
3347        assert!(headers.contains("Content-Length: 6"));
3348    }
3349
3350    /// Tests text generation with the configured model instead of accepting model-catalog access as inference readiness.
3351    #[test]
3352    fn llm_connection_probe_exercises_generation_without_temperature_override() {
3353        for vendor in ["openai_compatible", "ollama_compatible", "nvidia_nim"] {
3354            let state = ArchitectLlmVendorState {
3355                llm_vendor_kind: "cloud".to_owned(),
3356                model_id: "configured-model".to_owned(),
3357                base_url: "https://provider.example/v1".to_owned(),
3358                enabled: true,
3359                cloud_egress_allowed: true,
3360                activation_status: "tested_active".to_owned(),
3361                secret_ref: "saved".to_owned(),
3362            };
3363            let (method, url, content_type, body, _, _) =
3364                architect_llm_vendor_connection_request(vendor, &state, "test-credential").unwrap();
3365            assert_eq!(method, "POST");
3366            assert_eq!(url, "https://provider.example/v1/chat/completions");
3367            assert_eq!(content_type, Some("application/json"));
3368            let payload: serde_json::Value = serde_json::from_str(&body).unwrap();
3369            assert_eq!(payload["model"], "configured-model");
3370            assert!(payload["messages"].is_array());
3371            assert!(payload.get("temperature").is_none());
3372        }
3373    }
3374
3375    /// Preserves nested login query values and rejects cross-app and external locations.
3376    #[test]
3377    fn preview_login_redirect_keeps_app_scope_and_nested_query() {
3378        let redirect = architect_preview_redirect_location(
3379            "/complete-app/accounts/login/?next=/private?tab=1&sort=asc",
3380            "complete_app",
3381            12,
3382        )
3383        .unwrap();
3384        assert_eq!(
3385            request_query_value(&redirect, "path").as_deref(),
3386            Some("/accounts/login/?next=/private?tab=1&sort=asc")
3387        );
3388        assert_eq!(
3389            request_query_value(&redirect, "app_id").as_deref(),
3390            Some("complete_app")
3391        );
3392        assert!(
3393            architect_preview_redirect_location("https://other.example/login", "complete_app", 12)
3394                .is_none()
3395        );
3396        assert!(
3397            architect_preview_redirect_location("/complete-app-other/login", "complete_app", 12)
3398                .is_none()
3399        );
3400        assert!(
3401            architect_preview_redirect_location("/complete-app//other.example", "complete_app", 12)
3402                .is_none()
3403        );
3404    }
3405}
3406
3407pub(super) fn architect_preview_artifact_root(
3408    preview_root: &Path,
3409    preview_workspace: &Path,
3410    output_app_id: &str,
3411) -> PathBuf {
3412    let preview_key_base = preview_root
3413        .file_name()
3414        .and_then(|name| name.to_str())
3415        .filter(|name| !name.is_empty())
3416        .unwrap_or("source");
3417    let preview_key_digest =
3418        dynamic::hex_digest(&Sha256::digest(preview_root.to_string_lossy().as_bytes()));
3419    let preview_key = format!(
3420        "{}-{}",
3421        preview_key_base
3422            .chars()
3423            .filter(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-'))
3424            .take(48)
3425            .collect::<String>(),
3426        &preview_key_digest[..16]
3427    );
3428    preview_workspace
3429        .join(".preview-artifacts")
3430        .join(preview_key)
3431        .join("apps")
3432        .join(output_app_id)
3433}
3434
3435/// Shares a build lock only while requests are preparing the same editable preview artifact.
3436fn architect_preview_artifact_lock(root: &Path) -> std::sync::Arc<std::sync::Mutex<()>> {
3437    type Locks = std::collections::HashMap<PathBuf, std::sync::Weak<std::sync::Mutex<()>>>;
3438    static LOCKS: std::sync::OnceLock<std::sync::Mutex<Locks>> = std::sync::OnceLock::new();
3439    let mut locks = LOCKS
3440        .get_or_init(|| std::sync::Mutex::new(Locks::new()))
3441        .lock()
3442        .unwrap_or_else(std::sync::PoisonError::into_inner);
3443    locks.retain(|_, lock| lock.strong_count() > 0);
3444    if let Some(lock) = locks.get(root).and_then(std::sync::Weak::upgrade) {
3445        return lock;
3446    }
3447    let lock = std::sync::Arc::new(std::sync::Mutex::new(()));
3448    locks.insert(root.to_path_buf(), std::sync::Arc::downgrade(&lock));
3449    lock
3450}
3451
3452pub(super) fn architect_materialized_preview_root(
3453    preview_root: &Path,
3454    preview_workspace: &Path,
3455    output_app_id: &str,
3456) -> Option<PathBuf> {
3457    if preview_root.join("pharos.app.json").is_file() {
3458        return Some(preview_root.to_path_buf());
3459    }
3460    if !preview_root.join("pharos.app.yml").is_file() {
3461        return None;
3462    }
3463    let artifact_root =
3464        architect_preview_artifact_root(preview_root, preview_workspace, output_app_id);
3465    let lock = architect_preview_artifact_lock(&artifact_root);
3466    let _preparation = lock
3467        .lock()
3468        .unwrap_or_else(std::sync::PoisonError::into_inner);
3469    let build_dir = artifact_root.parent().and_then(Path::parent)?.to_path_buf();
3470    let artifact_manifest = artifact_root.join("pharos.app.json");
3471    if artifact_manifest.is_file()
3472        && !architect_preview_source_is_newer_than_artifact(preview_root, &artifact_manifest)
3473    {
3474        return architect_prepare_preview_database(
3475            &artifact_root,
3476            preview_workspace,
3477            output_app_id,
3478        )
3479        .then_some(artifact_root);
3480    }
3481    build_app_from_root(preview_root, &build_dir).ok()?;
3482    if artifact_manifest.is_file() {
3483        architect_prepare_preview_database(&artifact_root, preview_workspace, output_app_id)
3484            .then_some(artifact_root)
3485    } else {
3486        None
3487    }
3488}
3489
3490pub(super) fn architect_prepare_preview_database(
3491    app_root: &Path,
3492    preview_workspace: &Path,
3493    output_app_id: &str,
3494) -> bool {
3495    if !app_root.join("pharos.app.json").is_file() && !app_root.join("pharos.app.yml").is_file() {
3496        return false;
3497    }
3498    let runtime_conf = preview_workspace.join("runtime.conf");
3499    if !runtime_conf.is_file() {
3500        return false;
3501    }
3502    database::dynamic_runtime_prepare(app_root, Some(&runtime_conf), Some(output_app_id)) == 0
3503        && super::architect_preview_accounts::prepare(app_root, preview_workspace, output_app_id)
3504            .is_ok()
3505}
3506
3507/// Serves public files from an accepted fresh artifact without repeating database migrations or account preparation.
3508fn architect_ready_preview_asset_root(
3509    preview_root: &Path,
3510    preview_workspace: &Path,
3511    output_app_id: &str,
3512    path: &str,
3513    method: &str,
3514) -> Option<PathBuf> {
3515    if !matches!(method, "GET" | "HEAD")
3516        || !(path.starts_with("/static/") || path.starts_with("/media/"))
3517        || !architect_preview_artifact_ready(preview_root, preview_workspace, output_app_id)
3518    {
3519        return None;
3520    }
3521    Some(if preview_root.join("pharos.app.json").is_file() {
3522        preview_root.to_path_buf()
3523    } else {
3524        architect_preview_artifact_root(preview_root, preview_workspace, output_app_id)
3525    })
3526}
3527
3528pub(super) fn architect_prepared_preview_root_if_ready(
3529    preview_root: &Path,
3530    preview_workspace: &Path,
3531    output_app_id: &str,
3532) -> Option<PathBuf> {
3533    if preview_root.join("pharos.app.json").is_file() {
3534        return architect_prepare_preview_database(preview_root, preview_workspace, output_app_id)
3535            .then(|| preview_root.to_path_buf());
3536    }
3537    let artifact_root =
3538        architect_preview_artifact_root(preview_root, preview_workspace, output_app_id);
3539    let lock = architect_preview_artifact_lock(&artifact_root);
3540    let _preparation = lock
3541        .lock()
3542        .unwrap_or_else(std::sync::PoisonError::into_inner);
3543    let artifact_manifest = artifact_root.join("pharos.app.json");
3544    if artifact_manifest.is_file()
3545        && !architect_preview_source_is_newer_than_artifact(preview_root, &artifact_manifest)
3546    {
3547        architect_prepare_preview_database(&artifact_root, preview_workspace, output_app_id)
3548            .then_some(artifact_root)
3549    } else {
3550        None
3551    }
3552}
3553
3554pub(super) fn architect_preview_artifact_ready(
3555    preview_root: &Path,
3556    preview_workspace: &Path,
3557    output_app_id: &str,
3558) -> bool {
3559    if preview_root.join("pharos.app.json").is_file() {
3560        return true;
3561    }
3562    let artifact_manifest =
3563        architect_preview_artifact_root(preview_root, preview_workspace, output_app_id)
3564            .join("pharos.app.json");
3565    artifact_manifest.is_file()
3566        && !architect_preview_source_is_newer_than_artifact(preview_root, &artifact_manifest)
3567}
3568
3569pub(super) fn architect_editor_preview_workspace(preview_root: &Path) -> PathBuf {
3570    preview_root
3571        .parent()
3572        .map(Path::to_path_buf)
3573        .unwrap_or_else(|| preview_root.to_path_buf())
3574}
3575
3576/// Gives an editor version the same backend and isolated data namespace as its validated task.
3577pub(super) fn architect_sync_editor_preview_config(
3578    store: &pharos_runtime_architect::database::ArchitectStore,
3579    task_id: i64,
3580    editor_workspace: &Path,
3581) -> bool {
3582    let Ok(Some((app_id, review_workspace))) =
3583        pharos_runtime_architect::task_repository::accepted_preview_configuration(store, task_id)
3584    else {
3585        return false;
3586    };
3587    pharos_runtime_architect::lifecycle_validation::synchronize_editor_preview_config(
3588        editor_workspace,
3589        &app_id,
3590        &review_workspace,
3591        store.config(),
3592    )
3593    .is_ok()
3594}
3595
3596pub(super) fn architect_prepare_validated_generated_preview(
3597    store: &pharos_runtime_architect::database::ArchitectStore,
3598    task_id: i64,
3599) -> bool {
3600    let Some((output_app_id, preview_root)) = architect_current_editor_preview_root(store, task_id)
3601    else {
3602        return false;
3603    };
3604    let preview_workspace = architect_editor_preview_workspace(&preview_root);
3605    if !architect_sync_editor_preview_config(store, task_id, &preview_workspace) {
3606        return false;
3607    }
3608    architect_materialized_preview_root(&preview_root, &preview_workspace, &output_app_id)
3609        .is_some_and(|artifact_root| artifact_root.join("pharos.app.json").is_file())
3610}
3611
3612pub(super) fn architect_guard_preview_actions(
3613    app: &AppRuntime,
3614    html: &str,
3615    prepare_missing: bool,
3616) -> String {
3617    let Ok(store) = architect_task_store_for_app(app) else {
3618        return html.to_owned();
3619    };
3620    let task_ids: std::collections::BTreeSet<i64> = html
3621        .split("?task_id=")
3622        .skip(1)
3623        .filter_map(|part| part.split(['&', '"']).next()?.parse().ok())
3624        .filter(|id| *id > 0)
3625        .take(65)
3626        .collect();
3627    let metadata = match pharos_runtime_architect::task_repository::task_action_metadata(
3628        &store,
3629        &task_ids.into_iter().collect::<Vec<_>>(),
3630    ) {
3631        Ok(metadata) => metadata,
3632        Err(error) => {
3633            eprintln!("Architect task actions unavailable: {error:?}");
3634            std::collections::BTreeMap::new()
3635        }
3636    };
3637    let guarded = architect_guard_task_action_anchors(
3638        app,
3639        &store,
3640        &metadata,
3641        html,
3642        prepare_missing,
3643        "<a class=\"icon-action-button secondary-button\" href=\"/architect/app/edit?task_id=",
3644        ArchitectTaskActionKind::Preview,
3645    );
3646    architect_guard_task_action_anchors(
3647        app,
3648        &store,
3649        &metadata,
3650        &guarded,
3651        prepare_missing,
3652        "<a class=\"icon-action-button secondary-button\" href=\"/architect/exports/download?task_id=",
3653        ArchitectTaskActionKind::Export,
3654    )
3655}
3656
3657/// Identifies which task-card action is being guarded by validation state.
3658enum ArchitectTaskActionKind {
3659    Preview,
3660    Export,
3661}
3662
3663/// Replaces task-card action anchors with inert declarative placeholders until the task state allows the action.
3664fn architect_guard_task_action_anchors(
3665    app: &AppRuntime,
3666    store: &pharos_runtime_architect::database::ArchitectStore,
3667    metadata: &std::collections::BTreeMap<
3668        i64,
3669        pharos_runtime_architect::task_repository::TaskActionMetadata,
3670    >,
3671    html: &str,
3672    prepare_missing: bool,
3673    needle: &str,
3674    action_kind: ArchitectTaskActionKind,
3675) -> String {
3676    let mut out = String::with_capacity(html.len());
3677    let mut rest = html;
3678    while let Some(index) = rest.find(needle) {
3679        out.push_str(&rest[..index]);
3680        let anchor_start = &rest[index..];
3681        let task_start = index + needle.len();
3682        let task_rest = &rest[task_start..];
3683        let task_end = task_rest
3684            .find('&')
3685            .or_else(|| task_rest.find('\"'))
3686            .unwrap_or(0);
3687        let task_id = task_rest[..task_end].parse::<i64>().unwrap_or(0);
3688        let Some(anchor_end) = anchor_start.find("</a>").map(|value| value + "</a>".len()) else {
3689            out.push_str(anchor_start);
3690            return out;
3691        };
3692        let anchor = &anchor_start[..anchor_end];
3693        let task = metadata.get(&task_id);
3694        let task_validated = task.is_some_and(|task| task.validated);
3695        match action_kind {
3696            ArchitectTaskActionKind::Export if task_validated => out.push_str(anchor),
3697            ArchitectTaskActionKind::Preview => {
3698                let preview_ready = task_validated
3699                    && (task.is_some_and(|task| {
3700                        let Some(root) = task.source_root.as_deref() else {
3701                            return false;
3702                        };
3703                        let workspace = architect_editor_preview_workspace(root);
3704                        pharos_runtime_architect::lifecycle_validation::synchronize_editor_preview_config(
3705                            &workspace, &task.app_id, &task.review_workspace, store.config(),
3706                        ).is_ok()
3707                            && architect_preview_artifact_ready(root, &workspace, &task.app_id)
3708                    }) || (prepare_missing
3709                        && architect_prepare_validated_generated_preview(store, task_id)));
3710                if preview_ready {
3711                    out.push_str(anchor);
3712                } else if task_validated {
3713                    out.push_str(
3714                        &architect_fragment_html(
3715                            app,
3716                            "templates/fragments/preview_pending_button.html",
3717                            &serde_json::json!({}),
3718                        )
3719                        .unwrap_or_default(),
3720                    );
3721                } else {
3722                    out.push_str(
3723                        &architect_fragment_html(
3724                            app,
3725                            "templates/fragments/preview_unavailable_spacer.html",
3726                            &serde_json::json!({}),
3727                        )
3728                        .unwrap_or_default(),
3729                    );
3730                }
3731            }
3732            ArchitectTaskActionKind::Export => out.push_str(
3733                &architect_fragment_html(
3734                    app,
3735                    "templates/fragments/preview_unavailable_spacer.html",
3736                    &serde_json::json!({}),
3737                )
3738                .unwrap_or_default(),
3739            ),
3740        }
3741        rest = &anchor_start[anchor_end..];
3742    }
3743    out.push_str(rest);
3744    out
3745}
3746
3747pub(super) fn architect_task_is_validated(
3748    store: &pharos_runtime_architect::database::ArchitectStore,
3749    task_id: i64,
3750) -> bool {
3751    use pharos_runtime_database::operations::Predicate;
3752    store
3753        .query_one(
3754            "architect_task",
3755            &["task_status"],
3756            Predicate::Equal("id".to_owned(), serde_json::json!(task_id)),
3757        )
3758        .ok()
3759        .flatten()
3760        .and_then(|row| {
3761            row.get("task_status")
3762                .and_then(Value::as_str)
3763                .map(str::to_owned)
3764        })
3765        .is_some_and(|status| status == "validated")
3766}
3767
3768pub(super) fn architect_preview_source_is_newer_than_artifact(
3769    source_root: &Path,
3770    artifact_path: &Path,
3771) -> bool {
3772    let artifact_modified = stdfs::metadata(artifact_path)
3773        .and_then(|metadata| metadata.modified())
3774        .unwrap_or(SystemTime::UNIX_EPOCH);
3775    let mut stack = vec![source_root.to_path_buf()];
3776    while let Some(path) = stack.pop() {
3777        let Ok(entries) = stdfs::read_dir(&path) else {
3778            return true;
3779        };
3780        for entry in entries.flatten() {
3781            let entry_path = entry.path();
3782            let file_name = entry_path
3783                .file_name()
3784                .and_then(|name| name.to_str())
3785                .unwrap_or_default();
3786            if matches!(file_name, ".git" | ".preview-artifacts" | "target" | "var") {
3787                continue;
3788            }
3789            let Ok(metadata) = stdfs::symlink_metadata(&entry_path) else {
3790                return true;
3791            };
3792            if metadata.is_dir() {
3793                stack.push(entry_path);
3794                continue;
3795            }
3796            if metadata
3797                .modified()
3798                .map(|modified| modified > artifact_modified)
3799                .unwrap_or(true)
3800            {
3801                return true;
3802            }
3803        }
3804    }
3805    false
3806}
3807
3808pub(super) fn architect_generated_preview_static_response(
3809    mut response: artifact_server::StaticResponse,
3810    task_id: i64,
3811    output_app_id: &str,
3812    route_path: &str,
3813    method: &str,
3814) -> Vec<u8> {
3815    if method != "HEAD"
3816        && response.status_code == 200
3817        && response.content_type.starts_with("text/css")
3818        && let Ok(css) = String::from_utf8(response.body.clone())
3819    {
3820        let rewritten =
3821            rewrite_architect_generated_preview_css_urls(&css, task_id, output_app_id, route_path);
3822        response.content_length = rewritten.len() as u64;
3823        response.body = rewritten.into_bytes();
3824    }
3825    http_static_response(response, method)
3826}
3827
3828fn add_architect_edit_toolbar(
3829    html: &str,
3830    architect_app: &AppRuntime,
3831    task_id: i64,
3832    csrf_token: &str,
3833    preview_app: &AppRuntime,
3834    source_template: &str,
3835) -> String {
3836    let Some(insert_at) = architect_preview_body_content_start(html) else {
3837        return html.to_owned();
3838    };
3839    let Some(artifact_root) = architect_app.artifact_dir.as_deref().map(Path::new) else {
3840        return html.to_owned();
3841    };
3842    let preview_overrides = architect_preview_overrides_json(preview_app);
3843    let Some(toolbar) = json_commands::template_render_file(
3844        &artifact_root.join("templates/fragments/edit_toolbar.html"),
3845        &serde_json::json!({
3846            "task_id": task_id,
3847            "csrf_token": csrf_token,
3848            "preview_overrides": architect_preview_script_json_text(&preview_overrides),
3849            "preview_source_template": json_commands::html_escape_text(source_template),
3850        }),
3851    ) else {
3852        return html.to_owned();
3853    };
3854    let toolbar = dynamic::csrf_inject_hidden_inputs(artifact_root, &toolbar, csrf_token);
3855    let toolbar = pharos_runtime_architect::editable_grouping::scope_groups(
3856        &toolbar,
3857        architect_app.app_id.as_deref().unwrap_or("architect"),
3858        true,
3859    )
3860    .unwrap_or(toolbar);
3861    format!("{}\n{}{}", &html[..insert_at], toolbar, &html[insert_at..])
3862}
3863
3864/// Locates the end of a document body opening tag; partial app fragments have no host toolbar.
3865fn architect_preview_body_content_start(html: &str) -> Option<usize> {
3866    let bytes = html.as_bytes();
3867    let start = bytes.windows(5).enumerate().find_map(|(index, tag)| {
3868        (tag.eq_ignore_ascii_case(b"<body")
3869            && bytes
3870                .get(index + 5)
3871                .is_some_and(|byte| byte.is_ascii_whitespace() || *byte == b'>'))
3872        .then_some(index + 5)
3873    })?;
3874    let mut quote = None;
3875    for (offset, byte) in bytes[start..].iter().enumerate() {
3876        match (quote, *byte) {
3877            (Some(open), close) if open == close => quote = None,
3878            (None, b'\'' | b'"') => quote = Some(*byte),
3879            (None, b'>') => return Some(start + offset + 1),
3880            _ => {}
3881        }
3882    }
3883    None
3884}
3885
3886pub(super) fn architect_preview_overrides_json(preview_app: &AppRuntime) -> String {
3887    let Some(app_root) = preview_app.app_root.as_deref().map(Path::new) else {
3888        return architect_empty_preview_overrides_json();
3889    };
3890    let path = app_root.join("static/architect-preview-overrides.json");
3891    let Ok(text) = stdfs::read_to_string(path) else {
3892        return architect_empty_preview_overrides_json();
3893    };
3894    if text.len() > 64 * 1024 {
3895        return architect_empty_preview_overrides_json();
3896    }
3897    match serde_json::from_str::<Value>(&text) {
3898        Ok(value)
3899            if value.get("schema").and_then(Value::as_str)
3900                == Some("pharos.architect.preview_overrides.v1") =>
3901        {
3902            text
3903        }
3904        _ => architect_empty_preview_overrides_json(),
3905    }
3906}
3907
3908pub(super) fn architect_empty_preview_overrides_json() -> String {
3909    r#"{"schema":"pharos.architect.preview_overrides.v1","overrides":{}}"#.to_owned()
3910}
3911
3912pub(super) fn architect_preview_script_json_text(json: &str) -> String {
3913    let value = serde_json::from_str::<Value>(json).unwrap_or_else(
3914        |_| serde_json::json!({"schema":"pharos.architect.preview_overrides.v1","overrides":{}}),
3915    );
3916    serde_json::to_string(&value)
3917        .unwrap_or_else(|_| architect_empty_preview_overrides_json())
3918        .replace('<', "\\u003c")
3919        .replace('>', "\\u003e")
3920        .replace('&', "\\u0026")
3921}
3922
3923fn rewrite_architect_generated_preview_css_urls(
3924    css: &str,
3925    task_id: i64,
3926    output_app_id: &str,
3927    route_path: &str,
3928) -> String {
3929    pharos_runtime_architect::preview_style::rewrite_architect_generated_preview_css_urls(
3930        css,
3931        task_id,
3932        output_app_id,
3933        route_path,
3934    )
3935}
3936
3937fn rewrite_architect_generated_preview_forms(
3938    html: &str,
3939    preview_app: &AppRuntime,
3940    task_id: i64,
3941) -> String {
3942    let Some(ir) = preview_app.application_ir.as_ref() else {
3943        return html.to_owned();
3944    };
3945    let routes = ir
3946        .routes
3947        .values()
3948        .map(
3949            |route| pharos_runtime_architect::preview_style::ArchitectPreviewActionRoute {
3950                kind: route.kind.clone(),
3951                action_id: route
3952                    .action_id
3953                    .as_ref()
3954                    .map(|value| value.as_str().to_owned()),
3955                path: route.path.clone(),
3956                method: route.method.clone(),
3957            },
3958        )
3959        .collect::<Vec<_>>();
3960    pharos_runtime_architect::preview_style::rewrite_architect_generated_preview_forms(
3961        html,
3962        preview_app.app_id.as_deref(),
3963        &routes,
3964        task_id,
3965    )
3966}
3967
3968fn rewrite_architect_generated_preview_urls(
3969    html: &str,
3970    output_app_id: &str,
3971    task_id: i64,
3972) -> String {
3973    pharos_runtime_architect::preview_style::rewrite_architect_generated_preview_urls(
3974        html,
3975        output_app_id,
3976        task_id,
3977    )
3978}
3979
3980/// Resolves preview workspace metadata through the configured Architect store.
3981pub(super) fn architect_validated_task_preview_info(
3982    store: &pharos_runtime_architect::database::ArchitectStore,
3983    task_id: i64,
3984) -> Option<(String, String)> {
3985    pharos_runtime_architect::task_repository::validated_preview_info(store, task_id)
3986        .ok()
3987        .flatten()
3988}
3989
3990/// Resolves the current editor source from logical task and version records.
3991pub(super) fn architect_current_editor_preview_root(
3992    store: &pharos_runtime_architect::database::ArchitectStore,
3993    task_id: i64,
3994) -> Option<(String, PathBuf)> {
3995    pharos_runtime_architect::task_repository::current_editor_preview_root(store, task_id)
3996        .ok()
3997        .flatten()
3998}
3999
4000pub(super) fn architect_view_editor_source_file_response(
4001    app: &AppRuntime,
4002    request: &http::HttpRequest,
4003    method: &str,
4004) -> Vec<u8> {
4005    if app.app_id.as_deref() != Some("architect") {
4006        return http_text_response(404, "Not Found", "not found\n");
4007    }
4008    let actor_id = match active_session_user_id(app, request.cookie.as_deref()) {
4009        Ok(value) => value,
4010        Err(error) => return std::convert::identity(database_error_response(error)),
4011    };
4012    if actor_id <= 0 {
4013        return app_login_redirect(app, "/editor");
4014    }
4015    if !match actor_has_route_policy(app, actor_id, "review_builder_plan") {
4016        Ok(value) => value,
4017        Err(error) => return std::convert::identity(database_error_response(error)),
4018    } {
4019        return http_text_response(403, "Forbidden", "capability denied\n");
4020    }
4021    let Some(artifact_root) = app.artifact_dir.as_deref().map(Path::new) else {
4022        return http_text_response(500, "Internal Server Error", "response unavailable\n");
4023    };
4024    let Ok(store) = architect_task_store_for_app(app) else {
4025        return http_text_response(503, "Service Unavailable", "database unavailable\n");
4026    };
4027    let version_id = dynamic::request_form_value(request, "version_id")
4028        .and_then(|value| value.parse::<i64>().ok())
4029        .unwrap_or(0);
4030    let project_id = dynamic::request_form_value(request, "project_id")
4031        .and_then(|value| value.parse::<i64>().ok())
4032        .unwrap_or(0);
4033    let source_path = dynamic::request_form_value(request, "source_path").unwrap_or_default();
4034    if version_id <= 0 || source_path.trim().is_empty() {
4035        return http_text_response(422, "Unprocessable Entity", "invalid source file\n");
4036    }
4037    let source_view = if let (
4038        Some(generated_root),
4039        Some(output_app_id),
4040        Some(version_key),
4041        Some(source_root),
4042    ) = (
4043        architect_generated_source_root(app),
4044        dynamic::request_form_value(request, "output_app_id"),
4045        dynamic::request_form_value(request, "version_key"),
4046        dynamic::request_form_value(request, "source_root"),
4047    ) {
4048        let source_root_path = PathBuf::from(source_root.trim());
4049        let allowed_root = generated_root.join(".architect-editor").join("projects");
4050        let source_root_canon = source_root_path.canonicalize();
4051        let allowed_root_canon = allowed_root.canonicalize();
4052        match (source_root_canon, allowed_root_canon) {
4053            (Ok(source_root_canon), Ok(allowed_root_canon))
4054                if project_id > 0 && source_root_canon.starts_with(&allowed_root_canon) =>
4055            {
4056                agent_architect::load_architect_editor_source_view_from_root(
4057                    project_id,
4058                    version_id,
4059                    output_app_id.trim(),
4060                    version_key.trim(),
4061                    &source_root_canon,
4062                    Some(source_path.trim()),
4063                )
4064            }
4065            _ => agent_architect::load_architect_editor_source_view_in_store(
4066                &store,
4067                version_id,
4068                Some(source_path.trim()),
4069            ),
4070        }
4071    } else {
4072        agent_architect::load_architect_editor_source_view_in_store(
4073            &store,
4074            version_id,
4075            Some(source_path.trim()),
4076        )
4077    };
4078    let source_view = match source_view {
4079        Ok(view) => view,
4080        Err(error) => {
4081            eprintln!("architect editor source view failed: {error:?}");
4082            return http_text_response(422, "Unprocessable Entity", "source view unavailable\n");
4083        }
4084    };
4085    let Some(selected) = source_view.selected else {
4086        return http_text_response(422, "Unprocessable Entity", "source view unavailable\n");
4087    };
4088    let source_path_options = source_view
4089        .files
4090        .iter()
4091        .filter_map(|file| {
4092            json_commands::template_render_file(
4093                &artifact_root.join("templates/fragments/editor_source_path_option.html"),
4094                &serde_json::json!({
4095                    "path": &file.path,
4096                    "selected": if file.path == selected.path { "selected" } else { "" },
4097                }),
4098            )
4099        })
4100        .collect::<String>();
4101    let params = serde_json::json!({
4102        "project_id": source_view.project_id.to_string(),
4103        "version_id": source_view.version_id.to_string(),
4104        "output_app_id": &source_view.output_app_id,
4105        "version_key": &source_view.version_key,
4106        "source_root": &source_view.source_root,
4107        "selected_path": &selected.path,
4108        "selected_sha256": &selected.sha256,
4109        "selected_size_bytes": selected.size_bytes.to_string(),
4110        "selected_content": &selected.content,
4111        "source_path_options": source_path_options,
4112    });
4113    let Some(body) = json_commands::template_render_file(
4114        &artifact_root.join("templates/fragments/editor_source_viewer.html"),
4115        &params,
4116    ) else {
4117        return http_text_response(500, "Internal Server Error", "source view unavailable\n");
4118    };
4119    let csrf_token = dynamic::csrf_token_for_response(request.cookie.as_deref());
4120    let body = dynamic::csrf_inject_hidden_inputs(artifact_root, &body, &csrf_token);
4121    http_html_response(200, "OK", &body, method)
4122}
4123
4124pub(super) fn architect_prepare_editor_source_edit_response(
4125    app: &AppRuntime,
4126    request: &http::HttpRequest,
4127    method: &str,
4128) -> Vec<u8> {
4129    if app.app_id.as_deref() != Some("architect") {
4130        return http_text_response(404, "Not Found", "not found\n");
4131    }
4132    let actor_id = match active_session_user_id(app, request.cookie.as_deref()) {
4133        Ok(value) => value,
4134        Err(error) => return std::convert::identity(database_error_response(error)),
4135    };
4136    if actor_id <= 0 {
4137        return app_login_redirect(app, "/editor");
4138    }
4139    if !match actor_has_route_policy(app, actor_id, "review_builder_plan") {
4140        Ok(value) => value,
4141        Err(error) => return std::convert::identity(database_error_response(error)),
4142    } {
4143        return http_text_response(403, "Forbidden", "capability denied\n");
4144    }
4145    let Some(artifact_root) = app.artifact_dir.as_deref().map(Path::new) else {
4146        return http_text_response(500, "Internal Server Error", "response unavailable\n");
4147    };
4148    let Ok(store) = architect_task_store_for_app(app) else {
4149        return http_text_response(503, "Service Unavailable", "database unavailable\n");
4150    };
4151    let version_id = dynamic::request_form_value(request, "version_id")
4152        .and_then(|value| value.parse::<i64>().ok())
4153        .unwrap_or(0);
4154    let source_path = dynamic::request_form_value(request, "source_path").unwrap_or_default();
4155    let mut source_content =
4156        dynamic::request_form_value(request, "source_content").unwrap_or_default();
4157    let grouping = dynamic::request_form_value(request, "source_grouping").unwrap_or_default();
4158    let source_bundle = dynamic::request_form_value(request, "source_files");
4159    if !grouping.is_empty() && source_bundle.is_none() {
4160        if grouping != "existing"
4161            || !source_path.trim().ends_with(".html")
4162            || !agent_architect::source_export_relative_path_allowed(Path::new(source_path.trim()))
4163        {
4164            return architect_preview_edit_error_response(
4165                app,
4166                "Grouping preparation unavailable",
4167                "Choose an HTML source file to prepare existing container groups.",
4168                method,
4169            );
4170        }
4171        source_content = match pharos_runtime_architect::editable_grouping::prepare_existing_groups(
4172            &source_content,
4173            source_path.trim(),
4174        ) {
4175            Ok(proposed) => proposed,
4176            Err(diagnostic) => {
4177                return architect_preview_edit_error_response(
4178                    app,
4179                    "Source grouping rejected",
4180                    &format!(
4181                        "{} at byte {}: {}",
4182                        source_path.trim(),
4183                        diagnostic.offset,
4184                        diagnostic.message
4185                    ),
4186                    method,
4187                );
4188            }
4189        };
4190    }
4191    if grouping == "existing" && source_bundle.is_none() {
4192        let view = match agent_architect::load_architect_editor_source_view_in_store(
4193            &store, version_id, None,
4194        ) {
4195            Ok(view) => view,
4196            Err(_) => {
4197                return architect_preview_edit_error_response(
4198                    app,
4199                    "Grouping preparation unavailable",
4200                    "The selected source version is unavailable.",
4201                    method,
4202                );
4203            }
4204        };
4205        source_content = match pharos_runtime_architect::editable_grouping::scope_groups(
4206            &source_content,
4207            &view.output_app_id,
4208            false,
4209        ) {
4210            Ok(proposed) => proposed,
4211            Err(_) => {
4212                return architect_preview_edit_error_response(
4213                    app,
4214                    "Grouping preparation unavailable",
4215                    "The grouping proposal exceeds the source limits.",
4216                    method,
4217                );
4218            }
4219        };
4220    }
4221    if source_path.trim().ends_with(".html")
4222        && let Err(diagnostic) =
4223            pharos_runtime_architect::editable_grouping::validate(&source_content)
4224    {
4225        return architect_preview_edit_error_response(
4226            app,
4227            "Source grouping rejected",
4228            &format!(
4229                "{} at byte {}: {}",
4230                source_path.trim(),
4231                diagnostic.offset,
4232                diagnostic.message
4233            ),
4234            method,
4235        );
4236    }
4237    let source_prepare_result = if let Some(bundle) = source_bundle {
4238        if !grouping.is_empty() && grouping != "existing" {
4239            return architect_preview_edit_error_response(
4240                app,
4241                "Source grouping rejected",
4242                "Unknown grouping policy.",
4243                method,
4244            );
4245        }
4246        architect_prepare_source_bundle(
4247            &store,
4248            version_id,
4249            actor_id,
4250            &bundle,
4251            grouping == "existing",
4252        )
4253    } else if !agent_architect::source_export_relative_path_allowed(Path::new(source_path.trim())) {
4254        Err(agent_bridge::AgentErrorKind::PolicyViolation)
4255    } else {
4256        agent_architect::prepare_architect_editor_source_edit_in_store(
4257            &store,
4258            version_id,
4259            source_path.trim(),
4260            &source_content,
4261            actor_id,
4262        )
4263    };
4264    match source_prepare_result {
4265        Ok(plan) => {
4266            let params = serde_json::json!({
4267                "change_id": plan.change_id.to_string(),
4268                "source_path": &plan.source_path,
4269                "old_sha256": json_commands::html_escape_text(&plan.old_sha256),
4270                "new_sha256": json_commands::html_escape_text(&plan.new_sha256),
4271                "old_size_bytes": plan.old_size_bytes.to_string(),
4272                "new_size_bytes": plan.new_size_bytes.to_string(),
4273                "diff_preview": &plan.diff_preview,
4274            });
4275            let Some(body) = json_commands::template_render_file(
4276                &artifact_root.join("templates/fragments/editor_source_edit_result.html"),
4277                &params,
4278            ) else {
4279                return http_text_response(
4280                    500,
4281                    "Internal Server Error",
4282                    "source edit unavailable\n",
4283                );
4284            };
4285            let csrf_token = dynamic::csrf_token_for_response(request.cookie.as_deref());
4286            let body = dynamic::csrf_inject_hidden_inputs(artifact_root, &body, &csrf_token);
4287            http_html_response(200, "OK", &body, method)
4288        }
4289        Err(error) => {
4290            eprintln!("architect editor source edit prepare failed: {error:?}");
4291            let message = match error {
4292                agent_bridge::AgentErrorKind::PolicyViolation => {
4293                    "The selected path is outside the editable generated source snapshot."
4294                }
4295                agent_bridge::AgentErrorKind::CapacityExceeded => {
4296                    "The selected source file is too large for browser editing."
4297                }
4298                agent_bridge::AgentErrorKind::StalePlan => {
4299                    "The proposed content matches the current source, so no edit was prepared."
4300                }
4301                _ => {
4302                    "The source edit could not be prepared. Review the selected file and try again."
4303                }
4304            };
4305            let params = serde_json::json!({
4306                "error_message": json_commands::html_escape_text(message),
4307            });
4308            let Some(body) = json_commands::template_render_file(
4309                &artifact_root.join("templates/fragments/editor_source_edit_error.html"),
4310                &params,
4311            ) else {
4312                return http_text_response(
4313                    422,
4314                    "Unprocessable Entity",
4315                    "source edit unavailable\n",
4316                );
4317            };
4318            http_html_response(422, "Unprocessable Entity", &body, method)
4319        }
4320    }
4321}
4322
4323/// Stages a bounded multi-file source transaction for an owned editable project.
4324/// New declarations and their templates can therefore be validated and promoted together.
4325fn architect_prepare_source_bundle(
4326    store: &pharos_runtime_architect::database::ArchitectStore,
4327    version_id: i64,
4328    actor_id: i64,
4329    bundle: &str,
4330    prepare_grouping: bool,
4331) -> Result<
4332    pharos_runtime_architect::task_state::ArchitectEditorSourceEditPlan,
4333    agent_bridge::AgentErrorKind,
4334> {
4335    use agent_bridge::AgentErrorKind;
4336    if bundle.len() > 1024 * 1024 {
4337        return Err(AgentErrorKind::CapacityExceeded);
4338    }
4339    let mut files: Vec<(String, String)> =
4340        serde_json::from_str(bundle).map_err(|_| AgentErrorKind::InvalidEnvelope)?;
4341    if files.is_empty()
4342        || files.len() > pharos_runtime_architect::editor_state::MAX_SOURCE_EDIT_FILES
4343    {
4344        return Err(AgentErrorKind::CapacityExceeded);
4345    }
4346    let namespace = if prepare_grouping {
4347        Some(
4348            agent_architect::load_architect_editor_source_view_in_store(store, version_id, None)?
4349                .output_app_id,
4350        )
4351    } else {
4352        None
4353    };
4354    let mut paths = std::collections::BTreeSet::new();
4355    for (path, content) in &mut files {
4356        if !paths.insert(path.clone())
4357            || !agent_architect::source_export_relative_path_allowed(Path::new(path))
4358        {
4359            return Err(AgentErrorKind::PolicyViolation);
4360        }
4361        if path.ends_with(".html") {
4362            if let Some(namespace) = &namespace {
4363                let grouped = pharos_runtime_architect::editable_grouping::prepare_existing_groups(
4364                    content, path,
4365                )
4366                .map_err(|_| AgentErrorKind::InvalidEnvelope)?;
4367                *content = pharos_runtime_architect::editable_grouping::scope_groups(
4368                    &grouped, namespace, false,
4369                )?;
4370            }
4371            pharos_runtime_architect::editable_grouping::validate_for_edit(content)?;
4372        }
4373    }
4374    pharos_runtime_architect::editor_state::prepare_editor_changes_in_store(
4375        store,
4376        version_id,
4377        &files,
4378        actor_id,
4379        "source_edit",
4380    )
4381}
4382
4383pub(super) fn architect_prepare_editor_structured_edit_response(
4384    app: &AppRuntime,
4385    request: &http::HttpRequest,
4386    method: &str,
4387) -> Vec<u8> {
4388    if app.app_id.as_deref() != Some("architect") {
4389        return http_text_response(404, "Not Found", "not found\n");
4390    }
4391    let actor_id = match active_session_user_id(app, request.cookie.as_deref()) {
4392        Ok(value) => value,
4393        Err(error) => return std::convert::identity(database_error_response(error)),
4394    };
4395    if actor_id <= 0 {
4396        return app_login_redirect(app, "/editor");
4397    }
4398    if !match actor_has_route_policy(app, actor_id, "review_builder_plan") {
4399        Ok(value) => value,
4400        Err(error) => return std::convert::identity(database_error_response(error)),
4401    } {
4402        return http_text_response(403, "Forbidden", "capability denied\n");
4403    }
4404    let Some(artifact_root) = app.artifact_dir.as_deref().map(Path::new) else {
4405        return http_text_response(500, "Internal Server Error", "response unavailable\n");
4406    };
4407    let Ok(store) = architect_task_store_for_app(app) else {
4408        return http_text_response(503, "Service Unavailable", "database unavailable\n");
4409    };
4410    let version_id = dynamic::request_form_value(request, "version_id")
4411        .and_then(|value| value.parse::<i64>().ok())
4412        .unwrap_or(0);
4413    let edit_kind = dynamic::request_form_value(request, "edit_kind").unwrap_or_default();
4414    let mut params = serde_json::Map::new();
4415    for key in [
4416        "operation",
4417        "entity_id",
4418        "new_entity_id",
4419        "field_id",
4420        "new_field_id",
4421        "field_type",
4422        "required",
4423        "helper_text",
4424        "options",
4425        "pattern",
4426        "archetype",
4427        "title",
4428        "route_path",
4429        "button_count",
4430        "icon_name",
4431        "target_id",
4432        "accent",
4433        "placement",
4434        "body_copy",
4435    ] {
4436        if let Some(value) = dynamic::request_form_value(request, key) {
4437            params.insert(key.to_owned(), serde_json::Value::String(value));
4438        }
4439    }
4440    let params = serde_json::Value::Object(params);
4441    let edit_kind = if edit_kind == "field" {
4442        dynamic::request_form_value(request, "operation").unwrap_or_else(|| "field_add".to_owned())
4443    } else if edit_kind == "layout" {
4444        "layout_pattern".to_owned()
4445    } else {
4446        edit_kind
4447    };
4448    let structured_prepare_result =
4449        agent_architect::prepare_architect_editor_structured_edit_in_store(
4450            &store,
4451            version_id,
4452            edit_kind.trim(),
4453            &params,
4454            actor_id,
4455        );
4456    match structured_prepare_result {
4457        Ok(plan) => {
4458            let mut rows = String::new();
4459            for item in plan.plans {
4460                let params = serde_json::json!({
4461                    "change_id": item.change_id.to_string(),
4462                    "source_path": json_commands::html_escape_text(&item.source_path),
4463                    "old_size_bytes": item.old_size_bytes.to_string(),
4464                    "new_size_bytes": item.new_size_bytes.to_string(),
4465                    "diff_preview": &item.diff_preview,
4466                });
4467                if let Some(row) = json_commands::template_render_file(
4468                    &artifact_root.join("templates/fragments/editor_structured_edit_row.html"),
4469                    &params,
4470                ) {
4471                    rows.push_str(&row);
4472                }
4473            }
4474            let params = serde_json::json!({
4475                "structured_change_rows": rows,
4476            });
4477            let Some(body) = json_commands::template_render_file(
4478                &artifact_root.join("templates/fragments/editor_structured_edit_result.html"),
4479                &params,
4480            ) else {
4481                return http_text_response(
4482                    500,
4483                    "Internal Server Error",
4484                    "structured edit unavailable\n",
4485                );
4486            };
4487            let csrf_token = dynamic::csrf_token_for_response(request.cookie.as_deref());
4488            let body = dynamic::csrf_inject_hidden_inputs(artifact_root, &body, &csrf_token);
4489            http_html_response(200, "OK", &body, method)
4490        }
4491        Err(error) => {
4492            eprintln!("architect structured editor prepare failed: {error:?}");
4493            let message = match error {
4494                agent_bridge::AgentErrorKind::StalePlan => {
4495                    "The structured edit did not change the selected generated source."
4496                }
4497                agent_bridge::AgentErrorKind::InvalidIdentifier => {
4498                    "The structured edit references an unknown entity, field, page, or source file."
4499                }
4500                agent_bridge::AgentErrorKind::CapacityExceeded => {
4501                    "The structured edit is too large for browser review."
4502                }
4503                _ => {
4504                    "The structured edit could not be prepared. Review the selected project and try again."
4505                }
4506            };
4507            let params = serde_json::json!({
4508                "error_message": json_commands::html_escape_text(message),
4509            });
4510            let Some(body) = json_commands::template_render_file(
4511                &artifact_root.join("templates/fragments/editor_source_edit_error.html"),
4512                &params,
4513            ) else {
4514                return http_text_response(
4515                    422,
4516                    "Unprocessable Entity",
4517                    "structured edit unavailable\n",
4518                );
4519            };
4520            http_html_response(422, "Unprocessable Entity", &body, method)
4521        }
4522    }
4523}
4524
4525pub(super) fn architect_apply_editor_source_edit_response(
4526    app: &AppRuntime,
4527    request: &http::HttpRequest,
4528    method: &str,
4529) -> Vec<u8> {
4530    if app.app_id.as_deref() != Some("architect") {
4531        return http_text_response(404, "Not Found", "not found\n");
4532    }
4533    let actor_id = match active_session_user_id(app, request.cookie.as_deref()) {
4534        Ok(value) => value,
4535        Err(error) => return std::convert::identity(database_error_response(error)),
4536    };
4537    if actor_id <= 0 {
4538        return app_login_redirect(app, "/editor");
4539    }
4540    if !match actor_has_route_policy(app, actor_id, "review_builder_plan") {
4541        Ok(value) => value,
4542        Err(error) => return std::convert::identity(database_error_response(error)),
4543    } {
4544        return http_text_response(403, "Forbidden", "capability denied\n");
4545    }
4546    let Ok(store) = architect_task_store_for_app(app) else {
4547        return http_text_response(503, "Service Unavailable", "database unavailable\n");
4548    };
4549    let Some(generated_root) = architect_generated_source_root(app) else {
4550        return http_text_response(503, "Service Unavailable", "generated source unavailable\n");
4551    };
4552    let change_id = dynamic::request_form_value(request, "change_id")
4553        .and_then(|value| value.parse::<i64>().ok())
4554        .unwrap_or(0);
4555    if change_id <= 0 {
4556        return http_text_response(422, "Unprocessable Entity", "invalid source edit\n");
4557    }
4558    let inject_failure_after_validation =
4559        dynamic::request_form_value(request, "inject_failure_after_validation")
4560            .is_some_and(|value| value == "true" || value == "1");
4561    let preview_task_id =
4562        pharos_runtime_architect::editor_state::load_planned_edit_in_store(&store, change_id)
4563            .ok()
4564            .and_then(|edit| pharos_runtime_architect::editor_state::planned_editor_task_id(&edit));
4565    let apply_result = if inject_failure_after_validation {
4566        agent_architect::apply_architect_editor_source_edit_with_injected_failure(
4567            &store,
4568            change_id,
4569            &generated_root,
4570            actor_id,
4571        )
4572    } else {
4573        agent_architect::apply_architect_editor_source_edit(
4574            &store,
4575            change_id,
4576            &generated_root,
4577            actor_id,
4578        )
4579    };
4580    let Some(artifact_root) = app.artifact_dir.as_deref().map(Path::new) else {
4581        return http_text_response(500, "Internal Server Error", "response unavailable\n");
4582    };
4583    match apply_result {
4584        Ok(result) => {
4585            if let Some(task_id) = preview_task_id
4586                && !architect_prepare_validated_generated_preview(&store, task_id)
4587            {
4588                eprintln!("Architect source edit accepted; preview preparation remains pending");
4589            }
4590            let params = serde_json::json!({
4591                "change_id": result.change_id.to_string(),
4592                "output_app_id": json_commands::html_escape_text(&result.output_app_id),
4593                "source_path": json_commands::html_escape_text(&result.source_path),
4594                "source_fingerprint": json_commands::html_escape_text(&result.source_fingerprint),
4595                "validation_summary": json_commands::html_escape_text(&result.validation_summary),
4596            });
4597            let Some(body) = json_commands::template_render_file(
4598                &artifact_root.join("templates/fragments/editor_source_edit_applied.html"),
4599                &params,
4600            ) else {
4601                return http_text_response(
4602                    500,
4603                    "Internal Server Error",
4604                    "source edit response unavailable\n",
4605                );
4606            };
4607            http_html_response(200, "OK", &body, method)
4608        }
4609        Err(error) => {
4610            eprintln!("architect editor source edit apply failed: {error:?}");
4611            let message = if inject_failure_after_validation {
4612                "The injected failure proof ran after validation and rolled back without promoting the generated source."
4613            } else {
4614                "The source edit could not be applied. Validation, rollback, or persistence blocked the change."
4615            };
4616            let params = serde_json::json!({
4617                "error_message": json_commands::html_escape_text(message),
4618            });
4619            let Some(body) = json_commands::template_render_file(
4620                &artifact_root.join("templates/fragments/editor_source_edit_error.html"),
4621                &params,
4622            ) else {
4623                return http_text_response(
4624                    422,
4625                    "Unprocessable Entity",
4626                    "source edit unavailable\n",
4627                );
4628            };
4629            http_html_response(422, "Unprocessable Entity", &body, method)
4630        }
4631    }
4632}
4633
4634pub(super) fn architect_apply_preview_edit_response(
4635    app: &AppRuntime,
4636    request: &http::HttpRequest,
4637    method: &str,
4638) -> Vec<u8> {
4639    if app.app_id.as_deref() != Some("architect") {
4640        return http_text_response(404, "Not Found", "not found\n");
4641    }
4642    let actor_id = match active_session_user_id(app, request.cookie.as_deref()) {
4643        Ok(value) => value,
4644        Err(error) => return std::convert::identity(database_error_response(error)),
4645    };
4646    if actor_id <= 0 {
4647        return app_login_redirect(app, "/");
4648    }
4649    if !match actor_has_route_policy(app, actor_id, "review_builder_plan") {
4650        Ok(value) => value,
4651        Err(error) => return std::convert::identity(database_error_response(error)),
4652    } {
4653        return http_text_response(403, "Forbidden", "capability denied\n");
4654    }
4655    let Ok(store) = architect_task_store_for_app(app) else {
4656        return http_text_response(503, "Service Unavailable", "database unavailable\n");
4657    };
4658    let Some(generated_root) = architect_generated_source_root(app) else {
4659        return http_text_response(503, "Service Unavailable", "generated source unavailable\n");
4660    };
4661    let task_id = dynamic::request_form_value(request, "task_id")
4662        .and_then(|value| value.parse::<i64>().ok())
4663        .unwrap_or(0);
4664    if task_id <= 0 {
4665        return architect_preview_edit_error_response(
4666            app,
4667            "Preview edit unavailable",
4668            "The preview editor did not receive a valid generated app task.",
4669            method,
4670        );
4671    }
4672    if pharos_runtime_architect::task_ownership::task_owner(&store, task_id)
4673        .ok()
4674        .flatten()
4675        != Some(actor_id)
4676    {
4677        return http_text_response(403, "Forbidden", "This task belongs to another user.\n");
4678    }
4679    let Some(version_id) = architect_current_editor_version_id_for_task(&store, task_id) else {
4680        return architect_preview_edit_error_response(
4681            app,
4682            "Preview edit unavailable",
4683            "The generated app is not registered as an editable Architect project version yet.",
4684            method,
4685        );
4686    };
4687    let ui_element = dynamic::request_form_value(request, "preview_ui_element").unwrap_or_default();
4688    let operation = dynamic::request_form_value(request, "preview_operation").unwrap_or_default();
4689    let selector = dynamic::request_form_value(request, "preview_selector").unwrap_or_default();
4690    let style_payload =
4691        dynamic::request_form_value(request, "preview_style_payload").unwrap_or_default();
4692    if operation == "undo_edit" || operation == "redo_edit" {
4693        let preview_history_result = architect_commit_preview_edit(
4694            &store,
4695            version_id,
4696            &generated_root,
4697            actor_id,
4698            &operation,
4699            false,
4700            |_| {
4701                architect_apply_preview_history_edit(
4702                    &store,
4703                    version_id,
4704                    &generated_root,
4705                    operation == "redo_edit",
4706                )
4707            },
4708        );
4709        return match preview_history_result {
4710            Ok(_) => {
4711                let body = architect_fragment_html(
4712                    app,
4713                    "templates/fragments/preview_edit_applied.html",
4714                    &serde_json::json!({}),
4715                )
4716                .unwrap_or_default();
4717                http_html_response(200, "OK", &body, method)
4718            }
4719            Err(error) => {
4720                eprintln!("architect preview history edit failed: {error:?}");
4721                architect_preview_edit_error_response(
4722                    app,
4723                    if operation == "redo_edit" {
4724                        "Redo failed"
4725                    } else {
4726                        "Undo failed"
4727                    },
4728                    architect_preview_style_error_message(error),
4729                    method,
4730                )
4731            }
4732        };
4733    }
4734    if operation == "edit_selected" && !style_payload.trim().is_empty() {
4735        let preview_apply_result = architect_commit_preview_edit(
4736            &store,
4737            version_id,
4738            &generated_root,
4739            actor_id,
4740            "preview styles",
4741            true,
4742            |_| {
4743                architect_apply_preview_style_edit(
4744                    &store,
4745                    task_id,
4746                    version_id,
4747                    &generated_root,
4748                    actor_id,
4749                    &selector,
4750                    &style_payload,
4751                )
4752            },
4753        );
4754        return match preview_apply_result {
4755            Ok(_) => {
4756                let body = architect_fragment_html(
4757                    app,
4758                    "templates/fragments/preview_edit_saved.html",
4759                    &serde_json::json!({}),
4760                )
4761                .unwrap_or_default();
4762                http_html_response(200, "OK", &body, method)
4763            }
4764            Err(error) => {
4765                eprintln!("architect preview style edit apply failed: {error:?}");
4766                architect_preview_edit_error_response(
4767                    app,
4768                    "Style edit failed",
4769                    architect_preview_style_error_message(error),
4770                    method,
4771                )
4772            }
4773        };
4774    }
4775    if operation == "delete_element" || operation == "duplicate_element" {
4776        let preview_apply_result = architect_commit_preview_edit(
4777            &store,
4778            version_id,
4779            &generated_root,
4780            actor_id,
4781            if operation == "duplicate_element" {
4782                "preview duplication"
4783            } else {
4784                "preview deletion"
4785            },
4786            true,
4787            |_| {
4788                architect_apply_preview_unit_edit(
4789                    &store,
4790                    version_id,
4791                    &generated_root,
4792                    &selector,
4793                    operation == "duplicate_element",
4794                )
4795            },
4796        );
4797        return match preview_apply_result {
4798            Ok(_) => {
4799                let body = architect_fragment_html(
4800                    app,
4801                    "templates/fragments/preview_edit_applied.html",
4802                    &serde_json::json!({}),
4803                )
4804                .unwrap_or_default();
4805                http_html_response(200, "OK", &body, method)
4806            }
4807            Err(error) => {
4808                eprintln!("architect preview delete edit apply failed: {error:?}");
4809                architect_preview_edit_error_response(
4810                    app,
4811                    "Preview edit failed",
4812                    architect_preview_style_error_message(error),
4813                    method,
4814                )
4815            }
4816        };
4817    }
4818    if operation == "move_element" {
4819        let source_selector =
4820            dynamic::request_form_value(request, "preview_move_source").unwrap_or_default();
4821        let target_selector =
4822            dynamic::request_form_value(request, "preview_move_target").unwrap_or_default();
4823        let placement =
4824            dynamic::request_form_value(request, "preview_move_placement").unwrap_or_default();
4825        let source_template = dynamic::request_form_value(request, "preview_source_template");
4826        let preview_apply_result = architect_commit_preview_edit(
4827            &store,
4828            version_id,
4829            &generated_root,
4830            actor_id,
4831            "preview move",
4832            true,
4833            |view| {
4834                architect_apply_preview_move_from_view(
4835                    view.clone(),
4836                    &generated_root,
4837                    &source_selector,
4838                    &target_selector,
4839                    &placement,
4840                    source_template.as_deref(),
4841                )
4842            },
4843        );
4844        return match preview_apply_result {
4845            Ok(_) => {
4846                let body = architect_fragment_html(
4847                    app,
4848                    "templates/fragments/preview_edit_applied.html",
4849                    &serde_json::json!({}),
4850                )
4851                .unwrap_or_default();
4852                http_html_response(200, "OK", &body, method)
4853            }
4854            Err(error) => {
4855                eprintln!(
4856                    "architect preview move edit apply failed: {error:?}; source={:?}; target={:?}; placement={:?}",
4857                    source_selector.chars().take(2048).collect::<String>(),
4858                    target_selector.chars().take(2048).collect::<String>(),
4859                    placement.chars().take(32).collect::<String>()
4860                );
4861                architect_preview_edit_error_response(
4862                    app,
4863                    "Preview move failed",
4864                    match error {
4865                        agent_bridge::AgentErrorKind::PolicyViolation => {
4866                            "The move could not be applied safely. Check that the destination is outside the moved element and that its source definitions agree."
4867                        }
4868                        _ => architect_preview_style_error_message(error),
4869                    },
4870                    method,
4871                )
4872            }
4873        };
4874    }
4875    let title = dynamic::request_form_value(request, "preview_title").unwrap_or_default();
4876    let body_copy = dynamic::request_form_value(request, "preview_body_copy").unwrap_or_default();
4877    let button_count = dynamic::request_form_value(request, "preview_button_count")
4878        .and_then(|value| value.parse::<u8>().ok())
4879        .unwrap_or(3)
4880        .clamp(1, 8)
4881        .to_string();
4882    let icon_name = dynamic::request_form_value(request, "preview_icon_name").unwrap_or_default();
4883    let Some((pattern, placement, editor_operation)) =
4884        architect_preview_edit_mapping(&ui_element, &operation)
4885    else {
4886        return architect_preview_edit_error_response(
4887            app,
4888            "Preview edit unavailable",
4889            "Choose an add, edit, duplicate, or delete operation for an Architect-managed UI element.",
4890            method,
4891        );
4892    };
4893    let element_markup = if pattern == "element_template" {
4894        let Some(markup) = architect_preview_element_template(app, &ui_element) else {
4895            return architect_preview_edit_error_response(
4896                app,
4897                "Preview edit unavailable",
4898                "Architect could not load the selected element template.",
4899                method,
4900            );
4901        };
4902        markup
4903    } else {
4904        String::new()
4905    };
4906    let source_paths = if operation == "add_element" && !selector.trim().is_empty() {
4907        match architect_preview_selected_source(&store, version_id, &selector) {
4908            Ok(selected) => selected
4909                .files
4910                .into_iter()
4911                .map(|(path, _)| path)
4912                .collect::<Vec<_>>(),
4913            Err(error) => {
4914                return architect_preview_edit_error_response(
4915                    app,
4916                    "Preview edit failed",
4917                    architect_preview_style_error_message(error),
4918                    method,
4919                );
4920            }
4921        }
4922    } else {
4923        Vec::new()
4924    };
4925    let params = serde_json::json!({
4926        "source_paths": source_paths,
4927        "selector": selector,
4928        "operation": editor_operation,
4929        "pattern": pattern,
4930        "archetype": ui_element.trim(),
4931        "title": title.trim(),
4932        "body_copy": body_copy.trim(),
4933        "placement": placement,
4934        "button_count": button_count,
4935        "icon_name": icon_name.trim(),
4936        "element_markup": element_markup,
4937    });
4938    let preview_prepare_result = agent_architect::prepare_architect_editor_structured_edit_in_store(
4939        &store,
4940        version_id,
4941        "layout_pattern",
4942        &params,
4943        actor_id,
4944    );
4945    let plan = match preview_prepare_result {
4946        Ok(plan) => plan,
4947        Err(error) => {
4948            eprintln!("architect preview edit prepare failed: {error:?}");
4949            return architect_preview_edit_error_response(
4950                app,
4951                "Preview edit unavailable",
4952                "Architect could not prepare a bounded source change for the selected preview element.",
4953                method,
4954            );
4955        }
4956    };
4957    let Some(change_id) = plan.plans.first().map(|item| item.change_id) else {
4958        return architect_preview_edit_error_response(
4959            app,
4960            "Preview edit unavailable",
4961            "Architect did not produce a source change for the selected preview operation.",
4962            method,
4963        );
4964    };
4965    let preview_apply_result = (|| {
4966        architect_record_preview_undo_snapshot(&store, version_id)?;
4967        agent_architect::apply_architect_editor_source_edit(
4968            &store,
4969            change_id,
4970            &generated_root,
4971            actor_id,
4972        )
4973    })();
4974    match preview_apply_result {
4975        Ok(result) => {
4976            if let Err(error) = architect_stage_validated_preview_artifact(
4977                &store,
4978                task_id,
4979                result.change_id,
4980                &generated_root,
4981            ) {
4982                eprintln!("architect preview artifact staging failed: {error:?}");
4983            }
4984            let body = architect_fragment_html(
4985                app,
4986                "templates/fragments/preview_edit_applied.html",
4987                &serde_json::json!({}),
4988            )
4989            .unwrap_or_default();
4990            http_html_response(200, "OK", &body, method)
4991        }
4992        Err(error) => {
4993            eprintln!("architect preview edit apply failed: {error:?}");
4994            architect_preview_edit_error_response(
4995                app,
4996                "Preview edit failed validation",
4997                "The source change was not written because the generated app did not pass the validation gate.",
4998                method,
4999            )
5000        }
5001    }
5002}
5003
5004/// Reuses the artifact that passed the editor gate for the new version's first preview request.
5005fn architect_stage_validated_preview_artifact(
5006    store: &pharos_runtime_architect::database::ArchitectStore,
5007    task_id: i64,
5008    change_id: i64,
5009    generated_root: &Path,
5010) -> Result<(), agent_bridge::AgentErrorKind> {
5011    use pharos_runtime_database::operations::Predicate;
5012    let task = store
5013        .query_one(
5014            "architect_task",
5015            &["output_app_id"],
5016            Predicate::Equal("id".to_owned(), serde_json::json!(task_id)),
5017        )
5018        .map_err(|_| agent_bridge::AgentErrorKind::InvalidPath)?
5019        .ok_or(agent_bridge::AgentErrorKind::InvalidPath)?;
5020    let output_app_id = task
5021        .get("output_app_id")
5022        .and_then(serde_json::Value::as_str)
5023        .ok_or(agent_bridge::AgentErrorKind::InvalidEnvelope)?;
5024    let output_app_id = agent_architect::normalize_architect_preview_app_id(output_app_id)?;
5025    let validated_root = generated_root
5026        .join(".architect-editor")
5027        .join("validation")
5028        .join(format!("change-{change_id}"))
5029        .join("dist")
5030        .join("apps")
5031        .join(&output_app_id);
5032    if !validated_root.join("pharos.app.json").is_file() {
5033        return Err(agent_bridge::AgentErrorKind::InvalidPath);
5034    }
5035    let mut staged = false;
5036    if let Some((version_app_id, source_root)) =
5037        architect_current_editor_preview_root(store, task_id)
5038    {
5039        let expected_source_root = generated_root
5040            .join(".architect-editor")
5041            .join("projects")
5042            .join(&output_app_id)
5043            .join("versions")
5044            .join(format!("edit-{change_id}"));
5045        if version_app_id == output_app_id
5046            && source_root.canonicalize().ok() == expected_source_root.canonicalize().ok()
5047            && source_root.is_dir()
5048        {
5049            let workspace = architect_editor_preview_workspace(&source_root);
5050            if !architect_sync_editor_preview_config(store, task_id, &workspace) {
5051                return Err(agent_bridge::AgentErrorKind::InvalidPath);
5052            }
5053            architect_copy_validated_preview_artifact(
5054                &validated_root,
5055                &source_root,
5056                &workspace,
5057                &output_app_id,
5058            )?;
5059            staged = true;
5060        }
5061    }
5062    staged
5063        .then_some(())
5064        .ok_or(agent_bridge::AgentErrorKind::InvalidPath)
5065}
5066
5067/// Places a validated build at the artifact cache path used by one generated preview root.
5068fn architect_copy_validated_preview_artifact(
5069    validated_root: &Path,
5070    source_root: &Path,
5071    preview_workspace: &Path,
5072    output_app_id: &str,
5073) -> Result<(), agent_bridge::AgentErrorKind> {
5074    let artifact_root =
5075        architect_preview_artifact_root(source_root, preview_workspace, output_app_id);
5076    let lock = architect_preview_artifact_lock(&artifact_root);
5077    let _preparation = lock
5078        .lock()
5079        .unwrap_or_else(std::sync::PoisonError::into_inner);
5080    pharos_runtime_architect::source_management::copy_generated_app_artifact(
5081        validated_root,
5082        &artifact_root,
5083    )?;
5084    let manifest_path = artifact_root.join("pharos.app.json");
5085    let manifest =
5086        stdfs::read(&manifest_path).map_err(|_| agent_bridge::AgentErrorKind::InvalidPath)?;
5087    stdfs::write(&manifest_path, manifest)
5088        .map_err(|_| agent_bridge::AgentErrorKind::InvalidPath)?;
5089    if architect_preview_source_is_newer_than_artifact(source_root, &manifest_path) {
5090        return Err(agent_bridge::AgentErrorKind::StalePlan);
5091    }
5092    if !architect_prepare_preview_database(&artifact_root, preview_workspace, output_app_id) {
5093        return Err(agent_bridge::AgentErrorKind::InvalidPath);
5094    }
5095    Ok(())
5096}
5097
5098const ARCHITECT_PREVIEW_HISTORY_LIMIT: usize = 20;
5099const ARCHITECT_PREVIEW_LEGACY_HISTORY_PATH: &str = "static/architect-preview-history.json";
5100const ARCHITECT_PREVIEW_HISTORY_MAX_BYTES: usize = 64 * 1024 * 1024;
5101const ARCHITECT_PREVIEW_HISTORY_FILES: [&str; 6] = [
5102    "templates/home.body.html",
5103    "static/css/site.css",
5104    "static/css/style.css",
5105    "static/css/styles.css",
5106    "static/css/app.css",
5107    "static/architect-preview-overrides.json",
5108];
5109
5110/// Returns the empty on-disk value used when a preview file has not been created yet.
5111fn architect_preview_default_file_content(relative_path: &str) -> String {
5112    match relative_path {
5113        "static/architect-preview-overrides.json" => architect_empty_preview_overrides_json(),
5114        _ => String::new(),
5115    }
5116}
5117
5118/// Captures the editable preview files that participate in one undo history entry.
5119fn architect_preview_file_snapshot(
5120    source_root: &Path,
5121    stylesheet: Option<&str>,
5122) -> Result<serde_json::Value, agent_bridge::AgentErrorKind> {
5123    let mut files = serde_json::Map::new();
5124    let mut paths = ARCHITECT_PREVIEW_HISTORY_FILES
5125        .iter()
5126        .map(|path| (*path).to_owned())
5127        .collect::<std::collections::BTreeSet<_>>();
5128    paths.extend(stylesheet.map(str::to_owned));
5129    paths.extend(
5130        pharos_runtime_architect::source_bundle::source_paths(source_root)?
5131            .into_iter()
5132            .filter(|path| {
5133                path.extension()
5134                    .is_some_and(|extension| extension == "html")
5135            })
5136            .map(|path| path.to_string_lossy().into_owned()),
5137    );
5138    let mut total_bytes = 0_usize;
5139    for relative_path in paths {
5140        let relative_path = relative_path.as_str();
5141        if (relative_path.ends_with(".css") || relative_path == "templates/home.body.html")
5142            && !source_root.join(relative_path).is_file()
5143        {
5144            continue;
5145        }
5146        let path = source_root.join(relative_path);
5147        let content = if !path.exists() {
5148            architect_preview_default_file_content(relative_path)
5149        } else {
5150            let file =
5151                stdfs::File::open(path).map_err(|_| agent_bridge::AgentErrorKind::InvalidPath)?;
5152            let mut content = String::new();
5153            let mut reader = std::io::Read::take(file, 256 * 1024 + 1);
5154            std::io::Read::read_to_string(&mut reader, &mut content)
5155                .map_err(|_| agent_bridge::AgentErrorKind::InvalidPath)?;
5156            if content.len() > 256 * 1024 {
5157                return Err(agent_bridge::AgentErrorKind::CapacityExceeded);
5158            }
5159            content
5160        };
5161        total_bytes = total_bytes.saturating_add(content.len());
5162        if total_bytes > 2 * 1024 * 1024 {
5163            return Err(agent_bridge::AgentErrorKind::CapacityExceeded);
5164        }
5165        files.insert(relative_path.to_owned(), serde_json::Value::String(content));
5166    }
5167    Ok(serde_json::Value::Object(files))
5168}
5169
5170/// Resolves the generated-source-local history file for an Architect preview version.
5171fn architect_preview_history_path(view: &agent_architect::ArchitectEditorSourceView) -> PathBuf {
5172    // Source versions are stored at projects/<app>/versions/<version>.
5173    Path::new(&view.source_root)
5174        .parent()
5175        .and_then(Path::parent)
5176        .unwrap_or_else(|| Path::new(&view.source_root))
5177        .join("architect-preview-history.json")
5178}
5179
5180/// Serializes preview changes and preserves accepted snapshots on promotion failure.
5181fn architect_commit_preview_edit<F>(
5182    store: &pharos_runtime_architect::database::ArchitectStore,
5183    version_id: i64,
5184    generated_root: &Path,
5185    actor_id: i64,
5186    summary: &str,
5187    record_undo: bool,
5188    apply: F,
5189) -> Result<(), agent_bridge::AgentErrorKind>
5190where
5191    F: FnOnce(
5192        &agent_architect::ArchitectEditorSourceView,
5193    ) -> Result<(), agent_bridge::AgentErrorKind>,
5194{
5195    let _guard = pharos_runtime_architect::lifecycle_validation::architect_current_dir_lock()
5196        .lock()
5197        .map_err(|_| agent_bridge::AgentErrorKind::PolicyViolation)?;
5198    let view =
5199        agent_architect::load_architect_editor_source_view_in_store(store, version_id, None)?;
5200    let project = store
5201        .query_one(
5202            "architect_editor_project",
5203            &["current_version_id"],
5204            pharos_runtime_database::operations::Predicate::Equal(
5205                "id".to_owned(),
5206                serde_json::json!(view.project_id),
5207            ),
5208        )
5209        .map_err(|_| agent_bridge::AgentErrorKind::InvalidPath)?;
5210    if project
5211        .as_ref()
5212        .and_then(|p| p.get("current_version_id"))
5213        .and_then(Value::as_i64)
5214        != Some(version_id)
5215    {
5216        return Err(agent_bridge::AgentErrorKind::StalePlan);
5217    }
5218    let base_root = Path::new(&view.source_root);
5219    let workspace = architect_editor_preview_workspace(base_root);
5220    let reusable_artifact =
5221        architect_preview_artifact_ready(base_root, &workspace, &view.output_app_id)
5222            .then(|| architect_preview_artifact_root(base_root, &workspace, &view.output_app_id));
5223    let history = architect_read_preview_history(&view);
5224    pharos_runtime_architect::source_management::copy_generated_app_source(
5225        Path::new(&view.source_root),
5226        &generated_root.join(&view.output_app_id),
5227    )?;
5228    let result = (|| {
5229        if record_undo {
5230            architect_record_preview_undo_snapshot_from_view(&view)?;
5231        }
5232        apply(&view)?;
5233        let live_root = generated_root
5234            .join(&view.output_app_id)
5235            .canonicalize()
5236            .map_err(|_| agent_bridge::AgentErrorKind::InvalidPath)?;
5237        let legacy_history = live_root.join(ARCHITECT_PREVIEW_LEGACY_HISTORY_PATH);
5238        if legacy_history.exists() {
5239            if !legacy_history
5240                .canonicalize()
5241                .map_err(|_| agent_bridge::AgentErrorKind::InvalidPath)?
5242                .starts_with(&live_root)
5243            {
5244                return Err(agent_bridge::AgentErrorKind::PolicyViolation);
5245            }
5246            stdfs::remove_file(legacy_history)
5247                .map_err(|_| agent_bridge::AgentErrorKind::InvalidPath)?;
5248        }
5249        pharos_runtime_architect::editor_state::persist_preview_version_from_view_in_store(
5250            store,
5251            view.clone(),
5252            generated_root,
5253            actor_id,
5254            summary,
5255        )
5256    })();
5257    if result.is_err() {
5258        pharos_runtime_architect::source_management::copy_generated_app_source(
5259            Path::new(&view.source_root),
5260            &generated_root.join(&view.output_app_id),
5261        )?;
5262        architect_write_preview_history(&view, &history)?;
5263    }
5264    if result.is_ok()
5265        && let Some(artifact) = reusable_artifact
5266        && let Err(error) = architect_stage_preview_only_successor(store, &view, &artifact)
5267    {
5268        // Accepted source remains authoritative; a cache failure falls back to the normal build path.
5269        eprintln!("architect preview artifact reuse unavailable: {error:?}");
5270    }
5271    result
5272}
5273
5274/// Carries a checked, patched preview artifact into its immediate accepted successor without recompiling contracts.
5275/// Only visual preview transactions use this path; source/model/contract changes retain their full build gate.
5276fn architect_stage_preview_only_successor(
5277    store: &pharos_runtime_architect::database::ArchitectStore,
5278    base: &agent_architect::ArchitectEditorSourceView,
5279    artifact: &Path,
5280) -> Result<(), agent_bridge::AgentErrorKind> {
5281    use agent_bridge::AgentErrorKind;
5282    use pharos_runtime_database::operations::Predicate;
5283    let project = store
5284        .query_one(
5285            "architect_editor_project",
5286            &["current_version_id"],
5287            Predicate::Equal("id".to_owned(), serde_json::json!(base.project_id)),
5288        )
5289        .map_err(|_| AgentErrorKind::InvalidPath)?
5290        .ok_or(AgentErrorKind::InvalidIdentifier)?;
5291    let current = project
5292        .get("current_version_id")
5293        .and_then(Value::as_i64)
5294        .ok_or(AgentErrorKind::InvalidEnvelope)?;
5295    let version = store
5296        .query_one(
5297            "architect_editor_version",
5298            &["project_id", "parent_version_id", "source_root"],
5299            Predicate::Equal("id".to_owned(), serde_json::json!(current)),
5300        )
5301        .map_err(|_| AgentErrorKind::InvalidPath)?
5302        .ok_or(AgentErrorKind::InvalidIdentifier)?;
5303    if version.get("project_id").and_then(Value::as_i64) != Some(base.project_id)
5304        || version.get("parent_version_id").and_then(Value::as_i64) != Some(base.version_id)
5305    {
5306        return Err(AgentErrorKind::StalePlan);
5307    }
5308    let root = Path::new(
5309        version
5310            .get("source_root")
5311            .and_then(Value::as_str)
5312            .ok_or(AgentErrorKind::InvalidEnvelope)?,
5313    )
5314    .canonicalize()
5315    .map_err(|_| AgentErrorKind::InvalidPath)?;
5316    let parent = Path::new(&base.source_root)
5317        .canonicalize()
5318        .map_err(|_| AgentErrorKind::InvalidPath)?;
5319    if root == parent || root.parent() != parent.parent() {
5320        return Err(AgentErrorKind::PolicyViolation);
5321    }
5322    let workspace = architect_editor_preview_workspace(&root);
5323    let result =
5324        architect_copy_validated_preview_artifact(artifact, &root, &workspace, &base.output_app_id);
5325    if result.is_err() {
5326        architect_invalidate_preview_artifact(&architect_preview_artifact_root(
5327            &root,
5328            &workspace,
5329            &base.output_app_id,
5330        ))?;
5331    }
5332    result
5333}
5334
5335/// Removes only a failed derived cache's readiness marker so the next request rebuilds from accepted source.
5336fn architect_invalidate_preview_artifact(root: &Path) -> Result<(), agent_bridge::AgentErrorKind> {
5337    match stdfs::remove_file(root.join("pharos.app.json")) {
5338        Ok(()) => Ok(()),
5339        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
5340        Err(_) => Err(agent_bridge::AgentErrorKind::InvalidPath),
5341    }
5342}
5343
5344/// Loads preview edit history and falls back to an empty bounded history on stale data.
5345fn architect_read_preview_history(
5346    view: &agent_architect::ArchitectEditorSourceView,
5347) -> serde_json::Value {
5348    let path = architect_preview_history_path(view);
5349    let mut parsed = stdfs::File::open(path)
5350        .or_else(|_| {
5351            stdfs::File::open(
5352                Path::new(&view.source_root).join(ARCHITECT_PREVIEW_LEGACY_HISTORY_PATH),
5353            )
5354        })
5355        .ok()
5356        .and_then(|file| {
5357            let mut text = String::new();
5358            let mut reader =
5359                std::io::Read::take(file, ARCHITECT_PREVIEW_HISTORY_MAX_BYTES as u64 + 1);
5360            std::io::Read::read_to_string(&mut reader, &mut text).ok()?;
5361            (text.len() <= ARCHITECT_PREVIEW_HISTORY_MAX_BYTES).then_some(text)
5362        })
5363        .and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok())
5364        .unwrap_or_else(|| {
5365            serde_json::json!({
5366                "schema": "pharos.architect.preview-history.v1",
5367                "undo": [],
5368                "redo": []
5369            })
5370        });
5371    if parsed.get("schema").and_then(serde_json::Value::as_str)
5372        == Some("pharos.architect.preview-history.v1")
5373    {
5374        for key in ["undo", "redo"] {
5375            if let Some(items) = parsed.get_mut(key).and_then(Value::as_array_mut) {
5376                architect_trim_preview_history(items);
5377            }
5378        }
5379        parsed
5380    } else {
5381        serde_json::json!({
5382            "schema": "pharos.architect.preview-history.v1",
5383            "undo": [],
5384            "redo": []
5385        })
5386    }
5387}
5388
5389/// Retains the newest history entries with one linear compaction.
5390fn architect_trim_preview_history(items: &mut Vec<Value>) {
5391    let excess = items.len().saturating_sub(ARCHITECT_PREVIEW_HISTORY_LIMIT);
5392    if excess > 0 {
5393        items.drain(..excess);
5394    }
5395}
5396
5397/// Persists the bounded preview edit history beside the generated source files.
5398fn architect_write_preview_history(
5399    view: &agent_architect::ArchitectEditorSourceView,
5400    history: &serde_json::Value,
5401) -> Result<(), agent_bridge::AgentErrorKind> {
5402    let path = architect_preview_history_path(view);
5403    let Some(parent) = path.parent() else {
5404        return Err(agent_bridge::AgentErrorKind::InvalidPath);
5405    };
5406    stdfs::create_dir_all(parent).map_err(|_| agent_bridge::AgentErrorKind::InvalidPath)?;
5407    let text = serde_json::to_string_pretty(history)
5408        .map_err(|_| agent_bridge::AgentErrorKind::InvalidEnvelope)?;
5409    if text.len() > ARCHITECT_PREVIEW_HISTORY_MAX_BYTES {
5410        return Err(agent_bridge::AgentErrorKind::CapacityExceeded);
5411    }
5412    stdfs::write(path, text).map_err(|_| agent_bridge::AgentErrorKind::InvalidPath)
5413}
5414
5415/// Records the current preview source before a mutating edit and clears redo state.
5416fn architect_record_preview_undo_snapshot(
5417    store: &pharos_runtime_architect::database::ArchitectStore,
5418    version_id: i64,
5419) -> Result<(), agent_bridge::AgentErrorKind> {
5420    let view =
5421        agent_architect::load_architect_editor_source_view_in_store(store, version_id, None)?;
5422    architect_record_preview_undo_snapshot_from_view(&view)
5423}
5424
5425/// Captures undo content from the base inventory already validated for this editor operation.
5426fn architect_record_preview_undo_snapshot_from_view(
5427    view: &agent_architect::ArchitectEditorSourceView,
5428) -> Result<(), agent_bridge::AgentErrorKind> {
5429    let snapshot = architect_preview_file_snapshot(
5430        Path::new(&view.source_root),
5431        pharos_runtime_architect::preview_style::architect_preview_stylesheet_path(view),
5432    )?;
5433    let mut history = architect_read_preview_history(view);
5434    let Some(object) = history.as_object_mut() else {
5435        return Err(agent_bridge::AgentErrorKind::InvalidEnvelope);
5436    };
5437    let undo = object
5438        .entry("undo".to_owned())
5439        .or_insert_with(|| serde_json::Value::Array(Vec::new()));
5440    let Some(undo_items) = undo.as_array_mut() else {
5441        return Err(agent_bridge::AgentErrorKind::InvalidEnvelope);
5442    };
5443    if undo_items.last() != Some(&snapshot) {
5444        undo_items.push(snapshot);
5445    }
5446    architect_trim_preview_history(undo_items);
5447    object.insert("redo".to_owned(), serde_json::Value::Array(Vec::new()));
5448    architect_write_preview_history(view, &history)
5449}
5450
5451/// Restores all source files captured in a single preview undo or redo snapshot.
5452fn architect_restore_preview_snapshot(
5453    generated_root: &Path,
5454    view: &agent_architect::ArchitectEditorSourceView,
5455    snapshot: &serde_json::Value,
5456) -> Result<(), agent_bridge::AgentErrorKind> {
5457    let Some(files) = snapshot.as_object() else {
5458        return Err(agent_bridge::AgentErrorKind::InvalidEnvelope);
5459    };
5460    for relative_path in files.keys() {
5461        let relative_path = relative_path.as_str();
5462        if !ARCHITECT_PREVIEW_HISTORY_FILES.contains(&relative_path)
5463            && !relative_path.ends_with(".html")
5464            && Some(relative_path)
5465                != pharos_runtime_architect::preview_style::architect_preview_stylesheet_path(view)
5466        {
5467            return Err(agent_bridge::AgentErrorKind::PolicyViolation);
5468        }
5469        let content = files
5470            .get(relative_path)
5471            .and_then(serde_json::Value::as_str)
5472            .map(str::to_owned)
5473            .unwrap_or_else(|| architect_preview_default_file_content(relative_path));
5474        architect_write_preview_source_file(generated_root, view, relative_path, &content)?;
5475    }
5476    Ok(())
5477}
5478
5479/// Applies one undo or redo operation to the generated preview source files.
5480fn architect_apply_preview_history_edit(
5481    store: &pharos_runtime_architect::database::ArchitectStore,
5482    version_id: i64,
5483    generated_root: &Path,
5484    redo: bool,
5485) -> Result<(), agent_bridge::AgentErrorKind> {
5486    let view =
5487        agent_architect::load_architect_editor_source_view_in_store(store, version_id, None)?;
5488    let current = architect_preview_file_snapshot(
5489        Path::new(&view.source_root),
5490        pharos_runtime_architect::preview_style::architect_preview_stylesheet_path(&view),
5491    )?;
5492    let mut history = architect_read_preview_history(&view);
5493    let Some(object) = history.as_object_mut() else {
5494        return Err(agent_bridge::AgentErrorKind::InvalidEnvelope);
5495    };
5496    let mut undo_items = object
5497        .remove("undo")
5498        .and_then(|value| value.as_array().cloned())
5499        .unwrap_or_default();
5500    let mut redo_items = object
5501        .remove("redo")
5502        .and_then(|value| value.as_array().cloned())
5503        .unwrap_or_default();
5504    let snapshot = if redo {
5505        redo_items
5506            .pop()
5507            .ok_or(agent_bridge::AgentErrorKind::StalePlan)?
5508    } else {
5509        undo_items
5510            .pop()
5511            .ok_or(agent_bridge::AgentErrorKind::StalePlan)?
5512    };
5513    if redo {
5514        undo_items.push(current);
5515        architect_trim_preview_history(&mut undo_items);
5516    } else {
5517        redo_items.push(current);
5518        architect_trim_preview_history(&mut redo_items);
5519    }
5520    object.insert("undo".to_owned(), serde_json::Value::Array(undo_items));
5521    object.insert("redo".to_owned(), serde_json::Value::Array(redo_items));
5522    architect_restore_preview_snapshot(generated_root, &view, &snapshot)?;
5523    architect_write_preview_history(&view, &history)
5524}
5525
5526/// Moves source units using the transaction's checked base inventory, avoiding a second media scan.
5527fn architect_apply_preview_move_from_view(
5528    view: agent_architect::ArchitectEditorSourceView,
5529    generated_root: &Path,
5530    source_selector: &str,
5531    target_selector: &str,
5532    placement: &str,
5533    source_template: Option<&str>,
5534) -> Result<(), agent_bridge::AgentErrorKind> {
5535    let selected =
5536        architect_preview_move_sources_from_view(view, source_selector, source_template)?;
5537    let changes = selected
5538        .files
5539        .iter()
5540        .map(|(path, content)| {
5541            pharos_runtime_architect::preview_move::move_preview_html_element(
5542                content,
5543                source_selector,
5544                target_selector,
5545                placement,
5546            )
5547            .map(|updated| (path, updated))
5548        })
5549        .collect::<Result<Vec<_>, _>>()?;
5550    for (path, updated) in changes {
5551        architect_write_preview_source_file(generated_root, &selected.view, path, &updated)?;
5552    }
5553    Ok(())
5554}
5555
5556/// Uses a declared page template to disambiguate source keys shared by different pages.
5557fn architect_preview_move_sources_from_view(
5558    view: agent_architect::ArchitectEditorSourceView,
5559    source_selector: &str,
5560    source_template: Option<&str>,
5561) -> Result<ArchitectPreviewSources, agent_bridge::AgentErrorKind> {
5562    architect_preview_sources_with_template(view, source_selector, source_template)
5563}
5564
5565/// Deletes or duplicates a selected unit in its owning source fragment.
5566fn architect_apply_preview_unit_edit(
5567    store: &pharos_runtime_architect::database::ArchitectStore,
5568    version_id: i64,
5569    generated_root: &Path,
5570    selector: &str,
5571    duplicate: bool,
5572) -> Result<(), agent_bridge::AgentErrorKind> {
5573    let selected = architect_preview_selected_source(store, version_id, selector)?;
5574    let view = selected.view;
5575    if !duplicate {
5576        let changes = selected
5577            .files
5578            .iter()
5579            .map(|(path, content)| {
5580                pharos_runtime_architect::preview_move::delete_preview_html_element(
5581                    content, selector,
5582                )
5583                .map(|updated| (path, updated))
5584            })
5585            .collect::<Result<Vec<_>, _>>()?;
5586        for (path, updated) in changes {
5587            architect_write_preview_source_file(generated_root, &view, path, &updated)?;
5588        }
5589        return Ok(());
5590    }
5591    let (path, content) = selected
5592        .files
5593        .first()
5594        .ok_or(agent_bridge::AgentErrorKind::InvalidIdentifier)?;
5595    let unit =
5596        pharos_runtime_architect::preview_move::duplicate_preview_html_unit(content, selector)?;
5597    let mut changes = vec![(path.clone(), unit.content.clone())];
5598    for (path, content) in selected.files.iter().skip(1) {
5599        let copied =
5600            pharos_runtime_architect::preview_move::duplicate_preview_html_unit(content, selector)?;
5601        if copied.keys != unit.keys || copied.ids != unit.ids {
5602            return Err(agent_bridge::AgentErrorKind::PolicyViolation);
5603        }
5604        changes.push((path.clone(), copied.content));
5605    }
5606    if let Some(stylesheet) =
5607        pharos_runtime_architect::preview_style::architect_preview_stylesheet_path(&view)
5608    {
5609        let selected = agent_architect::load_architect_editor_source_view_in_store(
5610            store,
5611            version_id,
5612            Some(stylesheet),
5613        )?;
5614        let source = selected
5615            .selected
5616            .as_ref()
5617            .ok_or(agent_bridge::AgentErrorKind::InvalidPath)?;
5618        let styles = pharos_runtime_architect::preview_style::duplicate_component_styles(
5619            &source.content,
5620            &unit,
5621        )?;
5622        if styles != source.content {
5623            changes.push((stylesheet.to_owned(), styles));
5624        }
5625    }
5626    let properties = "static/architect-preview-overrides.json";
5627    if view.files.iter().any(|file| file.path == properties) {
5628        let selected = agent_architect::load_architect_editor_source_view_in_store(
5629            store,
5630            version_id,
5631            Some(properties),
5632        )?;
5633        let source = selected
5634            .selected
5635            .as_ref()
5636            .ok_or(agent_bridge::AgentErrorKind::InvalidPath)?;
5637        let copied = pharos_runtime_architect::preview_style::duplicate_component_properties(
5638            &source.content,
5639            &unit,
5640        )?;
5641        changes.push((properties.to_owned(), copied));
5642    }
5643    for (path, source) in changes {
5644        architect_write_preview_source_file(generated_root, &view, &path, &source)?;
5645    }
5646    Ok(())
5647}
5648
5649/// Source files sharing one byte-identical logical editor unit.
5650struct ArchitectPreviewSources {
5651    view: agent_architect::ArchitectEditorSourceView,
5652    files: Vec<(String, String)>,
5653}
5654
5655/// Resolves shared alias declarations while rejecting conflicting cross-file identities.
5656fn architect_preview_selected_source(
5657    store: &pharos_runtime_architect::database::ArchitectStore,
5658    version_id: i64,
5659    selector: &str,
5660) -> Result<ArchitectPreviewSources, agent_bridge::AgentErrorKind> {
5661    let view =
5662        agent_architect::load_architect_editor_source_view_in_store(store, version_id, None)?;
5663    architect_preview_sources_from_view(view, selector)
5664}
5665
5666/// Reads bounded source units inside one accepted app root before planning any writes.
5667fn architect_preview_sources_from_view(
5668    view: agent_architect::ArchitectEditorSourceView,
5669    selector: &str,
5670) -> Result<ArchitectPreviewSources, agent_bridge::AgentErrorKind> {
5671    architect_preview_sources_with_template(view, selector, None)
5672}
5673
5674/// Resolves shared units together, using a known template only for conflicting page declarations.
5675fn architect_preview_sources_with_template(
5676    view: agent_architect::ArchitectEditorSourceView,
5677    selector: &str,
5678    source_template: Option<&str>,
5679) -> Result<ArchitectPreviewSources, agent_bridge::AgentErrorKind> {
5680    let keyed = pharos_runtime_architect::editable_grouping::style_key_selector(selector).is_some();
5681    let root = Path::new(&view.source_root)
5682        .canonicalize()
5683        .map_err(|_| agent_bridge::AgentErrorKind::InvalidPath)?;
5684    let mut selected = Vec::new();
5685    let mut shared_unit: Option<String> = None;
5686    let mut conflicting = false;
5687    let mut total = 0_usize;
5688    for file in view.files.iter().filter(|file| {
5689        if keyed {
5690            file.path.ends_with(".html")
5691        } else {
5692            file.path == "templates/home.body.html"
5693        }
5694    }) {
5695        if file.size_bytes > 256 * 1024 {
5696            return Err(agent_bridge::AgentErrorKind::CapacityExceeded);
5697        }
5698        total = total.saturating_add(file.size_bytes);
5699        if total > 2 * 1024 * 1024 {
5700            return Err(agent_bridge::AgentErrorKind::CapacityExceeded);
5701        }
5702        let path = root
5703            .join(&file.path)
5704            .canonicalize()
5705            .map_err(|_| agent_bridge::AgentErrorKind::InvalidPath)?;
5706        if !path.starts_with(&root) {
5707            return Err(agent_bridge::AgentErrorKind::PolicyViolation);
5708        }
5709        let mut reader = std::io::Read::take(
5710            stdfs::File::open(path).map_err(|_| agent_bridge::AgentErrorKind::InvalidPath)?,
5711            256 * 1024 + 1,
5712        );
5713        let mut content = String::new();
5714        std::io::Read::read_to_string(&mut reader, &mut content)
5715            .map_err(|_| agent_bridge::AgentErrorKind::InvalidPath)?;
5716        if content.len() > 256 * 1024 {
5717            return Err(agent_bridge::AgentErrorKind::CapacityExceeded);
5718        }
5719        if let Some(unit) =
5720            pharos_runtime_architect::preview_move::preview_element_source(&content, selector)?
5721        {
5722            if shared_unit
5723                .as_deref()
5724                .is_some_and(|previous| previous != unit)
5725            {
5726                conflicting = true;
5727            }
5728            if shared_unit.is_none() {
5729                shared_unit = Some(unit.to_owned());
5730            }
5731            selected.push((file.path.clone(), content));
5732        }
5733    }
5734    if selected.is_empty() {
5735        return Err(agent_bridge::AgentErrorKind::InvalidIdentifier);
5736    }
5737    if conflicting {
5738        let template = source_template
5739            .filter(|template| template.len() <= 512 && template.ends_with(".html"))
5740            .ok_or(agent_bridge::AgentErrorKind::PolicyViolation)?;
5741        selected.retain(|(path, _)| path == template);
5742        if selected.len() != 1 {
5743            return Err(agent_bridge::AgentErrorKind::PolicyViolation);
5744        }
5745    }
5746    Ok(ArchitectPreviewSources {
5747        view,
5748        files: selected,
5749    })
5750}
5751
5752pub(super) fn architect_apply_preview_style_edit(
5753    store: &pharos_runtime_architect::database::ArchitectStore,
5754    _task_id: i64,
5755    version_id: i64,
5756    generated_root: &Path,
5757    _actor_id: i64,
5758    selector: &str,
5759    style_payload: &str,
5760) -> Result<(), agent_bridge::AgentErrorKind> {
5761    if style_payload.len() > 4096 {
5762        return Err(agent_bridge::AgentErrorKind::CapacityExceeded);
5763    }
5764    let selector = architect_preview_css_selector(selector)?;
5765    let payload: serde_json::Value = serde_json::from_str(style_payload)
5766        .map_err(|_| agent_bridge::AgentErrorKind::InvalidEnvelope)?;
5767    let declarations = architect_preview_style_declarations(&payload)?;
5768    let property_override = architect_preview_property_override(&payload)?;
5769    if declarations.is_empty() && property_override.is_empty() {
5770        return Err(agent_bridge::AgentErrorKind::StalePlan);
5771    }
5772    let view =
5773        agent_architect::load_architect_editor_source_view_in_store(store, version_id, None)?;
5774    if pharos_runtime_architect::editable_grouping::key_namespace(&selector)
5775        .is_some_and(|namespace| namespace != view.output_app_id)
5776    {
5777        return Err(agent_bridge::AgentErrorKind::PolicyViolation);
5778    }
5779    if !declarations.is_empty() {
5780        let path =
5781            pharos_runtime_architect::preview_style::architect_preview_stylesheet_path(&view)
5782                .ok_or(agent_bridge::AgentErrorKind::InvalidIdentifier)?;
5783        let selected_view = agent_architect::load_architect_editor_source_view_in_store(
5784            store,
5785            version_id,
5786            Some(path),
5787        )?;
5788        let selected = selected_view
5789            .selected
5790            .as_ref()
5791            .ok_or(agent_bridge::AgentErrorKind::InvalidIdentifier)?;
5792        let updated =
5793            architect_preview_append_style_override(&selected.content, &selector, &declarations)?;
5794        architect_write_preview_source_file(generated_root, &view, path, &updated)?;
5795    }
5796    if !property_override.is_empty() {
5797        let overrides =
5798            architect_preview_merge_property_override(&view, &selector, property_override)?;
5799        architect_write_preview_source_file(
5800            generated_root,
5801            &view,
5802            "static/architect-preview-overrides.json",
5803            &overrides,
5804        )?;
5805    }
5806    Ok(())
5807}
5808
5809pub(super) fn architect_write_preview_source_file(
5810    generated_root: &Path,
5811    view: &agent_architect::ArchitectEditorSourceView,
5812    relative_path: &str,
5813    content: &str,
5814) -> Result<(), agent_bridge::AgentErrorKind> {
5815    if content.len() > 256 * 1024 {
5816        return Err(agent_bridge::AgentErrorKind::CapacityExceeded);
5817    }
5818    let version_root = Path::new(&view.source_root);
5819    let generated_app_root = generated_root.join(&view.output_app_id);
5820    let editor_preview_workspace = architect_editor_preview_workspace(version_root);
5821    let editor_preview_artifact_root = architect_preview_artifact_root(
5822        version_root,
5823        &editor_preview_workspace,
5824        &view.output_app_id,
5825    );
5826    let mut roots = vec![generated_app_root];
5827    if editor_preview_artifact_root
5828        .join("pharos.app.json")
5829        .is_file()
5830    {
5831        roots.push(editor_preview_artifact_root);
5832    }
5833    for root in roots {
5834        let canonical_root = root
5835            .canonicalize()
5836            .map_err(|_| agent_bridge::AgentErrorKind::InvalidPath)?;
5837        let canonical_allowed = if root.starts_with(generated_root) {
5838            generated_root
5839                .canonicalize()
5840                .map_err(|_| agent_bridge::AgentErrorKind::InvalidPath)?
5841        } else {
5842            generated_root
5843                .join(".architect-editor")
5844                .canonicalize()
5845                .map_err(|_| agent_bridge::AgentErrorKind::InvalidPath)?
5846        };
5847        if !canonical_root.starts_with(&canonical_allowed) {
5848            return Err(agent_bridge::AgentErrorKind::PolicyViolation);
5849        }
5850        let relative = Path::new(relative_path);
5851        if !agent_architect::source_export_relative_path_allowed(relative) {
5852            return Err(agent_bridge::AgentErrorKind::PolicyViolation);
5853        }
5854        let output_path = root.join(relative);
5855        let Some(parent) = output_path.parent() else {
5856            return Err(agent_bridge::AgentErrorKind::InvalidPath);
5857        };
5858        stdfs::create_dir_all(parent).map_err(|_| agent_bridge::AgentErrorKind::InvalidPath)?;
5859        let canonical_parent = parent
5860            .canonicalize()
5861            .map_err(|_| agent_bridge::AgentErrorKind::InvalidPath)?;
5862        if !canonical_parent.starts_with(&canonical_root)
5863            || stdfs::symlink_metadata(&output_path)
5864                .is_ok_and(|metadata| metadata.file_type().is_symlink())
5865        {
5866            return Err(agent_bridge::AgentErrorKind::PolicyViolation);
5867        }
5868        stdfs::write(output_path, content)
5869            .map_err(|_| agent_bridge::AgentErrorKind::InvalidPath)?;
5870    }
5871    Ok(())
5872}
5873
5874pub(super) fn architect_preview_edit_mapping(
5875    ui_element: &str,
5876    operation: &str,
5877) -> Option<(&'static str, &'static str, &'static str)> {
5878    let placement = match operation {
5879        "edit_selected" => "append",
5880        "delete_element" => "append",
5881        "add_element" | "duplicate_element" => "append",
5882        _ => return None,
5883    };
5884    if operation == "delete_element" {
5885        return Some(("card_section", placement, "delete_layout_pattern"));
5886    }
5887    let pattern = match ui_element {
5888        "app_shell" | "icon_nav" => "compact_nav",
5889        "card_grid" | "card" => "card_section",
5890        "form_row" | "select_options" => "form_row",
5891        "button_bar_vertical" => "vertical_button_bar",
5892        "button_bar_horizontal" => "horizontal_button_bar",
5893        "popup" => "modal_popup",
5894        "status_panel" => "status_panel",
5895        "separator" => "green_wave_separator",
5896        "div" | "span" | "section" | "article" | "aside" | "header" | "footer" | "nav" | "form"
5897        | "paragraph" | "heading_1" | "heading_2" | "heading_3" | "strong" | "emphasis"
5898        | "small" | "label" | "button" | "field" | "text_input" | "number_input" | "checkbox"
5899        | "textarea" | "select" | "link" => "element_template",
5900        _ => return None,
5901    };
5902    Some((pattern, placement, "upsert_layout_pattern"))
5903}
5904
5905fn architect_preview_element_template(app: &AppRuntime, ui_element: &str) -> Option<String> {
5906    let root = app.artifact_dir.as_deref().or(app.app_root.as_deref())?;
5907    let path = Path::new(root).join("data/editor/element_templates.json");
5908    let text = stdfs::read_to_string(path).ok()?;
5909    if text.len() > 16 * 1024 {
5910        return None;
5911    }
5912    let templates: serde_json::Map<String, Value> = serde_json::from_str(&text).ok()?;
5913    let markup = templates.get(ui_element)?.as_str()?;
5914    (markup.len() <= 1024).then(|| markup.to_owned())
5915}
5916
5917pub(super) fn architect_preview_edit_error_response(
5918    app: &AppRuntime,
5919    title: &str,
5920    detail: &str,
5921    method: &str,
5922) -> Vec<u8> {
5923    let params = serde_json::json!({
5924        "title": json_commands::html_escape_text(title),
5925        "detail": json_commands::html_escape_text(detail),
5926    });
5927    let Some(body) =
5928        architect_fragment_html(app, "templates/fragments/preview_edit_error.html", &params)
5929    else {
5930        return http_text_response(422, "Unprocessable Entity", "preview edit unavailable\n");
5931    };
5932    http_html_response(422, "Unprocessable Entity", &body, method)
5933}
5934
5935/// Uses the same validated accepted version for editing as preview rendering, regardless of retry state.
5936pub(super) fn architect_current_editor_version_id_for_task(
5937    store: &pharos_runtime_architect::database::ArchitectStore,
5938    task_id: i64,
5939) -> Option<i64> {
5940    pharos_runtime_architect::task_repository::accepted_editor_version_for_task(store, task_id)
5941        .ok()
5942        .flatten()
5943        .map(|accepted| accepted.version_id)
5944}
5945
5946pub(super) fn architect_import_source_app_project_response(
5947    app: &AppRuntime,
5948    request: &http::HttpRequest,
5949    _method: &str,
5950) -> Vec<u8> {
5951    if app.app_id.as_deref() != Some("architect") {
5952        return http_text_response(404, "Not Found", "not found\n");
5953    }
5954    let actor_id = match active_session_user_id(app, request.cookie.as_deref()) {
5955        Ok(value) => value,
5956        Err(error) => return std::convert::identity(database_error_response(error)),
5957    };
5958    if actor_id <= 0 {
5959        return app_login_redirect(app, "/editor");
5960    }
5961    if !match actor_has_route_policy(app, actor_id, "review_builder_plan") {
5962        Ok(value) => value,
5963        Err(error) => return std::convert::identity(database_error_response(error)),
5964    } {
5965        return http_text_response(403, "Forbidden", "capability denied\n");
5966    }
5967    let Ok(store) = architect_task_store_for_app(app) else {
5968        return http_text_response(503, "Service Unavailable", "database unavailable\n");
5969    };
5970    let Some(generated_root) = architect_generated_source_root(app) else {
5971        return http_text_response(503, "Service Unavailable", "generated source unavailable\n");
5972    };
5973    let source_app_id = dynamic::request_form_value(request, "source_app_id").unwrap_or_default();
5974    let output_app_id = dynamic::request_form_value(request, "output_app_id").unwrap_or_default();
5975    let content_type = request.content_type.as_deref().unwrap_or_default();
5976    let import_result = if content_type
5977        .to_ascii_lowercase()
5978        .starts_with("multipart/form-data")
5979    {
5980        let Some(state_parent) = architect_state_parent(app) else {
5981            return http_text_response(503, "Service Unavailable", "state unavailable\n");
5982        };
5983        let upload = match architect_import::stage_upload(request, content_type, &state_parent) {
5984            Ok(upload) => upload,
5985            Err(message) => return http_text_response(422, "Unprocessable Entity", message),
5986        };
5987        pharos_runtime_architect::project_state::import_architect_source_app_project_from_root_in_store(
5988            &store,
5989            &upload.app_root,
5990            (!output_app_id.trim().is_empty()).then_some(output_app_id.trim()),
5991            &generated_root,
5992            actor_id,
5993        )
5994    } else {
5995        if source_app_id.trim().is_empty() || output_app_id.trim().is_empty() {
5996            return http_text_response(422, "Unprocessable Entity", "invalid source app import\n");
5997        }
5998        let roots = architect_import_source_app_roots(app);
5999        pharos_runtime_architect::project_state::import_architect_source_app_project_in_store(
6000            &store,
6001            source_app_id.trim(),
6002            output_app_id.trim(),
6003            &roots,
6004            &generated_root,
6005            actor_id,
6006        )
6007    };
6008    match import_result {
6009        Ok(imported) => {
6010            if !architect_prepare_validated_generated_preview(&store, imported.task_id) {
6011                return http_text_response(
6012                    422,
6013                    "Unprocessable Entity",
6014                    "source imported but preview validation failed; inspect the project source\n",
6015                );
6016            }
6017            http_route_redirect_for_request(
6018                request,
6019                &app_scope_path(
6020                    app,
6021                    &format!(
6022                        "/app/edit?task_id={}&app_id={}&path=/",
6023                        imported.task_id, imported.output_app_id,
6024                    ),
6025                ),
6026            )
6027        }
6028        Err(error) => {
6029            eprintln!("architect source app import failed: {error:?}");
6030            let message = match error {
6031                pharos_runtime_architect::AgentErrorKind::CapacityExceeded => {
6032                    "Source app exceeds the supported limit of 512 files or 80 MiB expanded source."
6033                }
6034                pharos_runtime_architect::AgentErrorKind::PolicyViolation => {
6035                    "Source import conflicts with an existing app id or contains prohibited runtime, secret or linked files. Choose a new editable app id and upload app source only."
6036                }
6037                pharos_runtime_architect::AgentErrorKind::InvalidIdentifier => {
6038                    "Source app manifest or editable app id is invalid. Use a valid pharos.app.yml and app id."
6039                }
6040                _ => {
6041                    "Source app could not be imported. Check the manifest and source files; diagnostic details are recorded in the server log."
6042                }
6043            };
6044            http_text_response(422, "Unprocessable Entity", message)
6045        }
6046    }
6047}
6048
6049pub(super) fn architect_rename_editor_project_response(
6050    app: &AppRuntime,
6051    request: &http::HttpRequest,
6052    method: &str,
6053) -> Vec<u8> {
6054    if app.app_id.as_deref() != Some("architect") {
6055        return http_text_response(404, "Not Found", "not found\n");
6056    }
6057    let actor_id = match active_session_user_id(app, request.cookie.as_deref()) {
6058        Ok(value) => value,
6059        Err(error) => return std::convert::identity(database_error_response(error)),
6060    };
6061    if actor_id <= 0 {
6062        return app_login_redirect(app, "/editor");
6063    }
6064    if !match actor_has_route_policy(app, actor_id, "review_builder_plan") {
6065        Ok(value) => value,
6066        Err(error) => return std::convert::identity(database_error_response(error)),
6067    } {
6068        return http_text_response(403, "Forbidden", "capability denied\n");
6069    }
6070    let mut project_id = dynamic::request_form_value(request, "project_id")
6071        .and_then(|value| value.parse::<i64>().ok())
6072        .unwrap_or(0);
6073    let title = dynamic::request_form_value(request, "title").unwrap_or_default();
6074    let new_id = dynamic::request_form_value(request, "new_output_app_id").unwrap_or_default();
6075    if !new_id.trim().is_empty() {
6076        let Ok(store) = architect_task_store_for_app(app) else {
6077            return http_text_response(503, "Service Unavailable", "database unavailable\n");
6078        };
6079        if project_id <= 0 {
6080            let old_id =
6081                dynamic::request_form_value(request, "source_output_app_id").unwrap_or_default();
6082            project_id = store
6083                .query_one(
6084                    "architect_editor_project",
6085                    &["id"],
6086                    pharos_runtime_database::operations::Predicate::Equal(
6087                        "output_app_id".into(),
6088                        serde_json::json!(old_id),
6089                    ),
6090                )
6091                .ok()
6092                .flatten()
6093                .and_then(|row| row["id"].as_i64())
6094                .unwrap_or(0);
6095        }
6096        let Some(root) = architect_generated_source_root(app) else {
6097            return http_text_response(
6098                503,
6099                "Service Unavailable",
6100                "generated source unavailable\n",
6101            );
6102        };
6103        let mut preview =
6104            super::architect_project_identity::NativeIdentityPreview::new(app, request, actor_id);
6105        match pharos_runtime_architect::project_identity::rename_app_identity(
6106            &store,
6107            project_id,
6108            actor_id,
6109            new_id.trim(),
6110            &root,
6111            &mut preview,
6112        ) {
6113            Ok(_) => return architect_task_console_response(app, request, method),
6114            Err(error) => {
6115                eprintln!("Architect app identity rename failed: {error:?}");
6116                return http_text_response(
6117                    422,
6118                    "Unprocessable Entity",
6119                    match error {
6120                        AgentErrorKind::LockBusy => {
6121                            "Finish or cancel the active build before renaming this app.\n"
6122                        }
6123                        AgentErrorKind::StalePlan => {
6124                            "The project or preview changed during rename. Reload and retry; accepted identity is unchanged.\n"
6125                        }
6126                        AgentErrorKind::InvalidIdentifier => {
6127                            "Use a valid app ID containing lowercase letters, numbers and underscores.\n"
6128                        }
6129                        _ => {
6130                            "App ID rename unavailable. Check that the ID is unused and the project belongs to you; accepted identity is unchanged.\n"
6131                        }
6132                    },
6133                );
6134            }
6135        }
6136    }
6137    if project_id <= 0 || title.trim().is_empty() {
6138        return http_text_response(
6139            422,
6140            "Unprocessable Entity",
6141            "invalid editor project rename\n",
6142        );
6143    }
6144    let Ok(store) = architect_task_store_for_app(app) else {
6145        return http_text_response(503, "Service Unavailable", "database unavailable\n");
6146    };
6147    match store.query_one(
6148        "architect_editor_project",
6149        &["created_by"],
6150        pharos_runtime_database::operations::Predicate::Equal(
6151            "id".into(),
6152            serde_json::json!(project_id),
6153        ),
6154    ) {
6155        Ok(Some(project)) if project["created_by"] == serde_json::json!(actor_id) => {}
6156        Ok(_) => return http_text_response(403, "Forbidden", "project belongs to another user\n"),
6157        Err(_) => return http_text_response(503, "Service Unavailable", "database unavailable\n"),
6158    }
6159    let rename_result =
6160        agent_architect::rename_architect_editor_project_for_app(app, project_id, &title);
6161    match rename_result {
6162        Ok(_) => architect_editor_panel_response(app, request, method),
6163        Err(error) => {
6164            eprintln!("architect editor project rename failed: {error:?}");
6165            http_text_response(
6166                422,
6167                "Unprocessable Entity",
6168                "editor project rename unavailable\n",
6169            )
6170        }
6171    }
6172}
6173
6174pub(super) fn architect_duplicate_editor_project_response(
6175    app: &AppRuntime,
6176    request: &http::HttpRequest,
6177    method: &str,
6178) -> Vec<u8> {
6179    if app.app_id.as_deref() != Some("architect") {
6180        return http_text_response(404, "Not Found", "not found\n");
6181    }
6182    let actor_id = match active_session_user_id(app, request.cookie.as_deref()) {
6183        Ok(value) => value,
6184        Err(error) => return std::convert::identity(database_error_response(error)),
6185    };
6186    if actor_id <= 0 {
6187        return app_login_redirect(app, "/editor");
6188    }
6189    if !match actor_has_route_policy(app, actor_id, "review_builder_plan") {
6190        Ok(value) => value,
6191        Err(error) => return std::convert::identity(database_error_response(error)),
6192    } {
6193        return http_text_response(403, "Forbidden", "capability denied\n");
6194    }
6195    let Ok(store) = architect_task_store_for_app(app) else {
6196        return http_text_response(503, "Service Unavailable", "database unavailable\n");
6197    };
6198    let Some(generated_root) = architect_generated_source_root(app) else {
6199        return http_text_response(503, "Service Unavailable", "generated source unavailable\n");
6200    };
6201    let project_id = dynamic::request_form_value(request, "project_id")
6202        .and_then(|value| value.parse::<i64>().ok())
6203        .unwrap_or(0);
6204    let output_app_id = dynamic::request_form_value(request, "output_app_id").unwrap_or_default();
6205    if project_id <= 0 {
6206        return http_text_response(422, "Unprocessable Entity", "invalid editor project\n");
6207    }
6208    let duplicate_fragment = |output_app_id: &str| {
6209        let params = serde_json::json!({
6210            "output_app_id": json_commands::html_escape_text(output_app_id),
6211            "csrf_token": json_commands::html_escape_text(&dynamic::csrf_token_for_response(request.cookie.as_deref())),
6212        });
6213        let Some(body) = architect_fragment_html(
6214            app,
6215            "templates/fragments/editor_duplicate_applied.html",
6216            &params,
6217        ) else {
6218            return http_text_response(
6219                500,
6220                "Internal Server Error",
6221                "project duplicate unavailable\n",
6222            );
6223        };
6224        http_html_response(200, "OK", &body, method)
6225    };
6226    let duplicate_result =
6227        pharos_runtime_architect::project_state::duplicate_architect_editor_project_in_store(
6228            &store,
6229            project_id,
6230            &output_app_id,
6231            &generated_root,
6232        );
6233    match duplicate_result {
6234        Ok(result) => duplicate_fragment(&result.output_app_id),
6235        Err(error) => {
6236            eprintln!("architect editor project duplicate failed: {error:?}");
6237            http_text_response(
6238                422,
6239                "Unprocessable Entity",
6240                "editor project duplicate unavailable\n",
6241            )
6242        }
6243    }
6244}
6245
6246pub(super) fn architect_delete_editor_project_response(
6247    app: &AppRuntime,
6248    request: &http::HttpRequest,
6249    method: &str,
6250) -> Vec<u8> {
6251    if app.app_id.as_deref() != Some("architect") {
6252        return http_text_response(404, "Not Found", "not found\n");
6253    }
6254    let actor_id = match active_session_user_id(app, request.cookie.as_deref()) {
6255        Ok(value) => value,
6256        Err(error) => return std::convert::identity(database_error_response(error)),
6257    };
6258    if actor_id <= 0 {
6259        return app_login_redirect(app, "/editor");
6260    }
6261    if !match actor_has_route_policy(app, actor_id, "review_builder_plan") {
6262        Ok(value) => value,
6263        Err(error) => return std::convert::identity(database_error_response(error)),
6264    } {
6265        return http_text_response(403, "Forbidden", "capability denied\n");
6266    }
6267    let Ok(store) = architect_task_store_for_app(app) else {
6268        return http_text_response(503, "Service Unavailable", "database unavailable\n");
6269    };
6270    let Some(generated_root) = architect_generated_source_root(app) else {
6271        return http_text_response(503, "Service Unavailable", "generated source unavailable\n");
6272    };
6273    let Some(state_parent) = architect_state_parent(app) else {
6274        return http_text_response(503, "Service Unavailable", "state unavailable\n");
6275    };
6276    let project_id = dynamic::request_form_value(request, "project_id")
6277        .and_then(|value| value.parse::<i64>().ok())
6278        .unwrap_or(0);
6279    if project_id <= 0 {
6280        return http_text_response(422, "Unprocessable Entity", "invalid editor project\n");
6281    }
6282    let export_root = state_parent.join("exports/source");
6283    let Some(media_root) = architect_media_root(app) else {
6284        return http_text_response(503, "Service Unavailable", "media storage unavailable\n");
6285    };
6286    if stdfs::create_dir_all(&export_root).is_err() || stdfs::create_dir_all(&media_root).is_err() {
6287        return http_text_response(503, "Service Unavailable", "project storage unavailable\n");
6288    }
6289    let delete_project_result =
6290        pharos_runtime_architect::project_state::delete_architect_editor_project_in_store(
6291            &store,
6292            project_id,
6293            &generated_root,
6294            &export_root,
6295            &media_root,
6296        );
6297    match delete_project_result {
6298        Ok(_) => architect_editor_panel_response(app, request, method),
6299        Err(error) => {
6300            eprintln!("architect editor project delete failed: {error:?}");
6301            http_text_response(
6302                422,
6303                "Unprocessable Entity",
6304                "editor project delete unavailable\n",
6305            )
6306        }
6307    }
6308}
6309
6310pub(super) fn architect_rollback_editor_version_response(
6311    app: &AppRuntime,
6312    request: &http::HttpRequest,
6313    method: &str,
6314) -> Vec<u8> {
6315    if app.app_id.as_deref() != Some("architect") {
6316        return http_text_response(404, "Not Found", "not found\n");
6317    }
6318    let actor_id = match active_session_user_id(app, request.cookie.as_deref()) {
6319        Ok(value) => value,
6320        Err(error) => return std::convert::identity(database_error_response(error)),
6321    };
6322    if actor_id <= 0 {
6323        return app_login_redirect(app, "/editor");
6324    }
6325    if !match actor_has_route_policy(app, actor_id, "review_builder_plan") {
6326        Ok(value) => value,
6327        Err(error) => return std::convert::identity(database_error_response(error)),
6328    } {
6329        return http_text_response(403, "Forbidden", "capability denied\n");
6330    }
6331    let Ok(store) = architect_task_store_for_app(app) else {
6332        return http_text_response(503, "Service Unavailable", "database unavailable\n");
6333    };
6334    let Some(generated_root) = architect_generated_source_root(app) else {
6335        return http_text_response(503, "Service Unavailable", "generated source unavailable\n");
6336    };
6337    let project_id = dynamic::request_form_value(request, "project_id")
6338        .and_then(|value| value.parse::<i64>().ok())
6339        .unwrap_or(0);
6340    let version_id = dynamic::request_form_value(request, "version_id")
6341        .and_then(|value| value.parse::<i64>().ok())
6342        .unwrap_or(0);
6343    if project_id <= 0 || version_id <= 0 {
6344        return http_text_response(422, "Unprocessable Entity", "invalid editor version\n");
6345    }
6346    let rollback_result =
6347        pharos_runtime_architect::project_state::rollback_architect_editor_project_in_store(
6348            &store,
6349            project_id,
6350            version_id,
6351            &generated_root,
6352        );
6353    match rollback_result {
6354        Ok(result) => {
6355            let Some(artifact_root) = app.artifact_dir.as_deref().map(Path::new) else {
6356                return http_text_response(500, "Internal Server Error", "response unavailable\n");
6357            };
6358            let csrf_token = dynamic::csrf_token_for_response(request.cookie.as_deref());
6359            let params = serde_json::json!({
6360                "output_app_id": json_commands::html_escape_text(&result.output_app_id),
6361                "version_id": result.version_id.to_string(),
6362                "csrf_token": csrf_token,
6363            });
6364            let Some(body) = json_commands::template_render_file(
6365                &artifact_root.join("templates/fragments/editor_rollback_applied.html"),
6366                &params,
6367            ) else {
6368                return http_text_response(500, "Internal Server Error", "response unavailable\n");
6369            };
6370            http_html_response(200, "OK", &body, method)
6371        }
6372        Err(error) => {
6373            eprintln!("architect editor rollback failed: {error:?}");
6374            http_text_response(422, "Unprocessable Entity", "editor rollback unavailable\n")
6375        }
6376    }
6377}
6378
6379pub(super) fn architect_download_source_export_response(
6380    app: &AppRuntime,
6381    request: &http::HttpRequest,
6382    method: &str,
6383) -> Vec<u8> {
6384    if app.app_id.as_deref() != Some("architect") {
6385        return http_text_response(404, "Not Found", "not found\n");
6386    }
6387    let actor_id = match active_session_user_id(app, request.cookie.as_deref()) {
6388        Ok(value) => value,
6389        Err(error) => return std::convert::identity(database_error_response(error)),
6390    };
6391    if actor_id <= 0 {
6392        return app_login_redirect(app, "/settings");
6393    }
6394    if !match actor_has_route_policy(app, actor_id, "export_builder_source") {
6395        Ok(value) => value,
6396        Err(error) => return std::convert::identity(database_error_response(error)),
6397    } {
6398        return http_text_response(403, "Forbidden", "capability denied\n");
6399    }
6400    let Ok((config, adapter, schema)) = architect_database_storage_for_app(
6401        app,
6402        &[
6403            "architect_task",
6404            "architect_source_export",
6405            "architect_task_event",
6406            "architect_editor_project",
6407            "architect_editor_version",
6408        ],
6409    ) else {
6410        return architect_export_diagnostic_response(
6411            app,
6412            503,
6413            "Service Unavailable",
6414            "Export database unavailable",
6415            "Architect could not open the project database that tracks generated source exports.",
6416            method,
6417        );
6418    };
6419    let Some(state_parent) = architect_state_parent(app) else {
6420        return architect_export_diagnostic_response(
6421            app,
6422            503,
6423            "Service Unavailable",
6424            "Export state unavailable",
6425            "Architect could not resolve the runtime state directory used for source-export storage.",
6426            method,
6427        );
6428    };
6429    let export_root = state_parent.join("exports/source");
6430    if stdfs::create_dir_all(&export_root).is_err() {
6431        return architect_export_diagnostic_response(
6432            app,
6433            503,
6434            "Service Unavailable",
6435            "Export storage unavailable",
6436            "Architect could not create or open the source-export storage directory.",
6437            method,
6438        );
6439    }
6440    let export_id = if let Some(export_id) = request_query_value(&request.target, "export_id") {
6441        export_id
6442    } else if let Some(task_id) = request_query_value(&request.target, "task_id")
6443        .and_then(|value| value.parse::<i64>().ok())
6444        .filter(|value| *value > 0)
6445    {
6446        let Some(generated_root) = architect_generated_source_root(app) else {
6447            return architect_export_diagnostic_response(
6448                app,
6449                503,
6450                "Service Unavailable",
6451                "Generated source unavailable",
6452                "Architect could not resolve the generated source root for this app.",
6453                method,
6454            );
6455        };
6456        let (archive_name, source_root) =
6457            match pharos_runtime_architect::source_export::validated_architect_task_source(
6458                &config,
6459                adapter.as_ref(),
6460                &schema,
6461                task_id,
6462                &generated_root,
6463            ) {
6464                Ok(source) => source,
6465                Err(agent_bridge::AgentErrorKind::PolicyViolation) => {
6466                    return architect_export_diagnostic_response(
6467                        app,
6468                        422,
6469                        "Unprocessable Entity",
6470                        "Export task unavailable",
6471                        "Source export is available only after Architect validates the generated app.",
6472                        method,
6473                    );
6474                }
6475                Err(_) => {
6476                    return architect_export_diagnostic_response(
6477                        app,
6478                        422,
6479                        "Unprocessable Entity",
6480                        "Export task unavailable",
6481                        "No validated generated app source tree is available for this task. Rebuild or reopen the generated app before downloading an export.",
6482                        method,
6483                    );
6484                }
6485            };
6486        if method == "HEAD" {
6487            return http_download_response(
6488                &[],
6489                "application/x-tar",
6490                &format!("{archive_name}.tar"),
6491                method,
6492            );
6493        }
6494        let Ok(bytes) = architect_source_tar_archive(&source_root, &archive_name) else {
6495            return architect_export_diagnostic_response(
6496                app,
6497                422,
6498                "Unprocessable Entity",
6499                "Source archive unavailable",
6500                "Architect found the source tree but rejected the archive because it was empty, too large, contained unsafe paths, or included runtime-only material.",
6501                method,
6502            );
6503        };
6504        return http_download_response(
6505            &bytes,
6506            "application/x-tar",
6507            &format!("{archive_name}.tar"),
6508            method,
6509        );
6510    } else {
6511        return architect_export_diagnostic_response(
6512            app,
6513            422,
6514            "Unprocessable Entity",
6515            "Missing export id",
6516            "Use Download export from a built app or project card so Architect can resolve the generated source bundle.",
6517            method,
6518        );
6519    };
6520    let download = match agent_architect::read_architect_source_export(
6521        &config,
6522        adapter.as_ref(),
6523        &schema,
6524        &export_id,
6525        &export_root,
6526    ) {
6527        Ok(download) => download,
6528        Err(_) => {
6529            return architect_export_diagnostic_response(
6530                app,
6531                404,
6532                "Not Found",
6533                "Source export unavailable",
6534                "The requested export id is missing, expired, or no longer matches the source bundle digest.",
6535                method,
6536            );
6537        }
6538    };
6539    let Ok(bytes) = stdfs::read(&download.bundle_path) else {
6540        return architect_export_diagnostic_response(
6541            app,
6542            404,
6543            "Not Found",
6544            "Source export file unavailable",
6545            "The export metadata exists, but the bundle file is missing from export storage.",
6546            method,
6547        );
6548    };
6549    let Ok((archive_name, archive_bytes)) = architect_source_bundle_json_to_tar(&bytes) else {
6550        return architect_export_diagnostic_response(
6551            app,
6552            422,
6553            "Unprocessable Entity",
6554            "Source archive unavailable",
6555            "Architect found export metadata but could not convert it into a complete app archive.",
6556            method,
6557        );
6558    };
6559    http_download_response(
6560        &archive_bytes,
6561        "application/x-tar",
6562        &format!("{archive_name}.tar"),
6563        method,
6564    )
6565}
6566
6567pub(super) fn architect_export_diagnostic_response(
6568    app: &AppRuntime,
6569    status_code: u16,
6570    status_text: &str,
6571    title: &str,
6572    detail: &str,
6573    method: &str,
6574) -> Vec<u8> {
6575    let params = serde_json::json!({
6576        "title": json_commands::html_escape_text(title),
6577        "detail": json_commands::html_escape_text(detail),
6578    });
6579    let Some(body) =
6580        architect_fragment_html(app, "templates/fragments/export_diagnostic.html", &params)
6581    else {
6582        return http_text_response(status_code, status_text, "export unavailable\n");
6583    };
6584    http_html_response(status_code, status_text, &body, method)
6585}
6586
6587/// Converts an Architect JSON source bundle into the same tar format used by task downloads.
6588pub(super) fn architect_source_bundle_json_to_tar(bundle: &[u8]) -> Result<(String, Vec<u8>), ()> {
6589    pharos_runtime_architect::source_export::source_bundle_json_to_tar(bundle)
6590}
6591
6592/// Builds a deterministic tar archive from one generated Architect app source tree.
6593pub(super) fn architect_source_tar_archive(
6594    source_root: &Path,
6595    archive_root: &str,
6596) -> Result<Vec<u8>, ()> {
6597    pharos_runtime_architect::source_export::source_tar_archive(source_root, archive_root)
6598}
6599
6600#[cfg(test)]
6601mod preview_history_tests {
6602
6603    /// Failed cache promotion cannot leave a ready marker or modify accepted source and adjacent caches.
6604    #[test]
6605    fn failed_preview_cache_loses_only_its_readiness_marker() {
6606        let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
6607            .join("tmp/preview-cache-failure")
6608            .join(format!(
6609                "{}-{}",
6610                std::process::id(),
6611                std::time::SystemTime::now()
6612                    .duration_since(std::time::UNIX_EPOCH)
6613                    .unwrap()
6614                    .as_nanos()
6615            ));
6616        let source = root.join("source");
6617        let cache = root.join("cache");
6618        std::fs::create_dir_all(&source).unwrap();
6619        std::fs::create_dir_all(&cache).unwrap();
6620        std::fs::write(source.join("pharos.app.yml"), "app_id: cache_check\n").unwrap();
6621        std::fs::write(cache.join("pharos.app.json"), "{}").unwrap();
6622        std::fs::write(cache.join("retained.txt"), "retained cache evidence").unwrap();
6623        super::architect_invalidate_preview_artifact(&cache).unwrap();
6624        super::architect_invalidate_preview_artifact(&cache).unwrap();
6625        assert!(!cache.join("pharos.app.json").exists());
6626        assert!(source.join("pharos.app.yml").is_file());
6627        assert!(cache.join("retained.txt").is_file());
6628        std::fs::remove_dir_all(root).unwrap();
6629    }
6630    use super::*;
6631
6632    /// Identical alias units share edits, while conflicting definitions remain immutable.
6633    #[test]
6634    fn shared_preview_alias_units_preserve_source_safety() {
6635        let root = Path::new(env!("CARGO_MANIFEST_DIR"))
6636            .join("tmp/0.8.4-testing")
6637            .join(format!("shared-preview-alias-unit-{}", std::process::id()));
6638        stdfs::create_dir_all(root.join("templates/pages")).unwrap();
6639        stdfs::write(root.join("pharos.app.yml"), "app_id: alias_unit\n").unwrap();
6640        let unit = "<div data-editor-key=\"shared-back\" data-editor-element=\"action\"><a href=\"/\">Back to site</a></div>";
6641        let first = format!("<main id=\"first\">{unit}</main>");
6642        let second = format!("<main id=\"second\">{unit}</main>");
6643        stdfs::write(root.join("templates/pages/first.html"), &first).unwrap();
6644        stdfs::write(root.join("templates/pages/second.html"), &second).unwrap();
6645        let view = agent_architect::load_architect_editor_source_view_from_root(
6646            1,
6647            1,
6648            "alias_unit",
6649            "test",
6650            &root,
6651            None,
6652        )
6653        .unwrap();
6654        let selector = "div[data-editor-key=\"shared-back\"]";
6655        let selected = architect_preview_sources_from_view(view.clone(), selector).unwrap();
6656        assert_eq!(selected.files.len(), 2);
6657        assert_eq!(
6658            architect_preview_move_sources_from_view(
6659                view.clone(),
6660                selector,
6661                Some("templates/pages/first.html")
6662            )
6663            .unwrap()
6664            .files
6665            .len(),
6666            2
6667        );
6668        let child_selector = "div[data-editor-key=\"shared-back\"] > a:nth-of-type(1)";
6669        let children = architect_preview_sources_from_view(view.clone(), child_selector).unwrap();
6670        assert_eq!(children.files.len(), 2);
6671        for (_, content) in &children.files {
6672            assert!(
6673                pharos_runtime_architect::preview_move::preview_element_source(
6674                    content,
6675                    child_selector
6676                )
6677                .unwrap()
6678                .unwrap()
6679                .starts_with("<a ")
6680            );
6681        }
6682        for (_, content) in &selected.files {
6683            let deleted = pharos_runtime_architect::preview_move::delete_preview_html_element(
6684                content, selector,
6685            )
6686            .unwrap();
6687            assert!(!deleted.contains("Back to site"));
6688            let duplicated = pharos_runtime_architect::preview_move::duplicate_preview_html_unit(
6689                content, selector,
6690            )
6691            .unwrap();
6692            assert!(duplicated.content.contains("shared-back-copy-1"));
6693        }
6694        let conflicting = second.replace("Back to site", "Different action");
6695        stdfs::write(root.join("templates/pages/second.html"), &conflicting).unwrap();
6696        let page = architect_preview_move_sources_from_view(
6697            view.clone(),
6698            selector,
6699            Some("templates/pages/first.html"),
6700        )
6701        .unwrap();
6702        assert_eq!(page.files.len(), 1);
6703        assert_eq!(page.files[0].0, "templates/pages/first.html");
6704        assert!(matches!(
6705            architect_preview_move_sources_from_view(
6706                view.clone(),
6707                selector,
6708                Some("../../outside.html")
6709            ),
6710            Err(agent_bridge::AgentErrorKind::PolicyViolation)
6711        ));
6712        assert!(matches!(
6713            architect_preview_sources_from_view(view.clone(), selector),
6714            Err(agent_bridge::AgentErrorKind::PolicyViolation)
6715        ));
6716        assert_eq!(
6717            stdfs::read_to_string(root.join("templates/pages/first.html")).unwrap(),
6718            first
6719        );
6720        assert_eq!(
6721            stdfs::read_to_string(root.join("templates/pages/second.html")).unwrap(),
6722            conflicting
6723        );
6724        #[cfg(unix)]
6725        {
6726            let mut escaped = view;
6727            let mut file = escaped.files[0].clone();
6728            file.path = "templates/pages/escape.html".into();
6729            std::os::unix::fs::symlink(
6730                Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml"),
6731                root.join(&file.path),
6732            )
6733            .unwrap();
6734            escaped.files.insert(0, file);
6735            assert!(matches!(
6736                architect_preview_sources_from_view(escaped, selector),
6737                Err(agent_bridge::AgentErrorKind::PolicyViolation)
6738            ));
6739        }
6740        stdfs::remove_dir_all(root).unwrap();
6741    }
6742
6743    /// Distinguishes complete attributed layout documents from replacement fragments.
6744    #[test]
6745    fn toolbar_is_inserted_inside_the_document_body_and_never_inside_fragments() {
6746        let document = include_str!("../../apps/commerce_spa/templates/layout/base.html");
6747        let index = architect_preview_body_content_start(document).unwrap();
6748        assert!(document[..index].contains("<body class="));
6749        assert!(document[..index].ends_with('>'));
6750        let fragment = include_str!("../../apps/menu_spa/templates/cart.body.html");
6751        assert_eq!(architect_preview_body_content_start(fragment), None);
6752        let opening = document.find("<body").unwrap();
6753        assert_eq!(
6754            architect_preview_body_content_start(&document[..opening + 5]),
6755            None
6756        );
6757    }
6758
6759    /// Exercises the actual shared fragment and includes, including HTML validation patterns.
6760    #[test]
6761    fn shared_preview_toolbar_renders_with_color_hex_fields() {
6762        let template = Path::new(env!("CARGO_MANIFEST_DIR"))
6763            .join("apps/architect/templates/fragments/edit_toolbar.html");
6764        let html = json_commands::template_render_file(
6765            &template,
6766            &serde_json::json!({
6767                "task_id": 306,
6768                "csrf_token": "test-token",
6769                "preview_overrides": "{}",
6770                "preview_source_template": "templates/pages/bookings.html",
6771            }),
6772        )
6773        .expect("Shared toolbar must render; literal template braces in HTML patterns break it");
6774        assert!(html.contains("data-pharos-preview-scope"));
6775        assert!(html.contains("data-pharos-preview-config-menu"));
6776        assert!(html.contains("generated-preview.js?v="));
6777        assert!(html.contains("data-pharos-color-hex=\"border_color\""));
6778        assert!(html.contains("data-pharos-color-hex=\"shadow_color\""));
6779        assert!(html.contains("data-pharos-border-opacity"));
6780        for name in [
6781            "font_size",
6782            "font_weight",
6783            "text_case",
6784            "label_text",
6785            "helper_text",
6786            "tooltip",
6787        ] {
6788            assert!(html.contains(&format!("data-pharos-edit-control=\"{name}\"")));
6789        }
6790        assert!(html.contains("&#123;6&#125;"));
6791        assert!(!html.contains("{include:"));
6792    }
6793
6794    /// Imported oversized history retains the newest twenty edits in each direction.
6795    #[test]
6796    fn imported_history_enforces_twenty_entry_limit() {
6797        let root = Path::new(env!("CARGO_MANIFEST_DIR"))
6798            .join("tmp/0.8.4-testing")
6799            .join(format!("preview-history-limit-unit-{}", std::process::id()));
6800        let version_root = root.join("versions/test");
6801        stdfs::create_dir_all(version_root.join("static")).unwrap();
6802        let view = agent_architect::ArchitectEditorSourceView {
6803            schema: String::new(),
6804            project_id: 0,
6805            version_id: 0,
6806            output_app_id: "history_unit".into(),
6807            version_key: "test".into(),
6808            source_root: version_root.to_string_lossy().into_owned(),
6809            files: vec![],
6810            total_bytes: 0,
6811            selected: None,
6812        };
6813        let items = (0..25).map(|i| serde_json::json!({"static/css/style.css": format!("#field {{ width: {i}px; }}")})).collect::<Vec<_>>();
6814        architect_write_preview_history(
6815            &view,
6816            &serde_json::json!({
6817                "schema": "pharos.architect.preview-history.v1", "undo": items, "redo": items,
6818            }),
6819        )
6820        .unwrap();
6821        let loaded = architect_read_preview_history(&view);
6822        for key in ["undo", "redo"] {
6823            let entries = loaded[key].as_array().unwrap();
6824            assert_eq!(entries.len(), 20);
6825            assert_eq!(entries[0]["static/css/style.css"], "#field { width: 5px; }");
6826            assert_eq!(
6827                entries[19]["static/css/style.css"],
6828                "#field { width: 24px; }"
6829            );
6830        }
6831        stdfs::remove_dir_all(root).unwrap();
6832    }
6833
6834    /// Preview file and history writes leave the accepted snapshot fingerprint intact.
6835    #[test]
6836    fn preview_writes_preserve_accepted_source_snapshot() {
6837        let root = Path::new(env!("CARGO_MANIFEST_DIR"))
6838            .join("tmp/0.8.4-testing")
6839            .join(format!("preview-snapshot-unit-{}", std::process::id()));
6840        let version_root = root.join(".architect-editor/projects/history_unit/versions/original");
6841        let live_root = root.join("history_unit");
6842        for directory in [&version_root, &live_root] {
6843            stdfs::create_dir_all(directory.join("static/css")).unwrap();
6844            stdfs::write(
6845                directory.join("static/css/style.css"),
6846                "main { color: blue; }",
6847            )
6848            .unwrap();
6849        }
6850        let view = agent_architect::ArchitectEditorSourceView {
6851            schema: String::new(),
6852            project_id: 1,
6853            version_id: 1,
6854            output_app_id: "history_unit".into(),
6855            version_key: "original".into(),
6856            source_root: version_root.to_string_lossy().into_owned(),
6857            files: vec![],
6858            total_bytes: 0,
6859            selected: None,
6860        };
6861        let fingerprint =
6862            pharos_runtime_architect::source_management::source_tree_fingerprint(&version_root)
6863                .unwrap();
6864        architect_write_preview_source_file(
6865            &root,
6866            &view,
6867            "static/css/style.css",
6868            "main { color: green; }",
6869        )
6870        .unwrap();
6871        architect_write_preview_history(&view, &serde_json::json!({"schema":"pharos.architect.preview-history.v1","undo":[],"redo":[]})).unwrap();
6872        assert_eq!(
6873            stdfs::read_to_string(live_root.join("static/css/style.css")).unwrap(),
6874            "main { color: green; }"
6875        );
6876        assert_eq!(
6877            pharos_runtime_architect::source_management::source_tree_fingerprint(&version_root)
6878                .unwrap(),
6879            fingerprint
6880        );
6881        assert!(!architect_preview_history_path(&view).starts_with(&version_root));
6882        stdfs::remove_dir_all(root).unwrap();
6883    }
6884
6885    /// Coalesces concurrent preparation of one artifact without blocking an unrelated preview.
6886    #[test]
6887    fn preview_artifact_preparation_locks_are_scoped() {
6888        let root = Path::new("preview-artifact-lock-test");
6889        let first = architect_preview_artifact_lock(root);
6890        let second = architect_preview_artifact_lock(root);
6891        assert!(std::sync::Arc::ptr_eq(&first, &second));
6892        let guard = first.lock().unwrap();
6893        let contender = std::thread::spawn(move || {
6894            assert!(second.try_lock().is_err());
6895            let other = architect_preview_artifact_lock(Path::new("other-preview-lock-test"));
6896            assert!(other.try_lock().is_ok());
6897        });
6898        contender.join().unwrap();
6899        drop(guard);
6900        drop(first);
6901        assert!(architect_preview_artifact_lock(root).try_lock().is_ok());
6902    }
6903
6904    /// Imported styles participate in undo without creating unused stylesheet files.
6905    #[test]
6906    fn imported_stylesheet_history_captures_only_existing_css() {
6907        let root = Path::new(env!("CARGO_MANIFEST_DIR"))
6908            .join("tmp/0.8.4-testing")
6909            .join(format!("preview-history-unit-{}", std::process::id()));
6910        stdfs::create_dir_all(root.join("static/css")).unwrap();
6911        stdfs::write(root.join("static/css/style.css"), "#field { width: 98px; }").unwrap();
6912        let before = architect_preview_file_snapshot(&root, None).unwrap();
6913        assert_eq!(before["static/css/style.css"], "#field { width: 98px; }");
6914        assert!(before.get("static/css/site.css").is_none());
6915        assert!(before.get("templates/home.body.html").is_none());
6916        stdfs::write(root.join("static/css/style.css"), "#field { width: auto; }").unwrap();
6917        let after = architect_preview_file_snapshot(&root, None).unwrap();
6918        assert_ne!(
6919            before["static/css/style.css"],
6920            after["static/css/style.css"]
6921        );
6922        stdfs::write(root.join("static/css/theme.css"), "main { color: blue; }").unwrap();
6923        let custom = architect_preview_file_snapshot(&root, Some("static/css/theme.css")).unwrap();
6924        assert_eq!(custom["static/css/theme.css"], "main { color: blue; }");
6925        stdfs::write(
6926            root.join("static/css/style.css"),
6927            vec![b'x'; 256 * 1024 + 1],
6928        )
6929        .unwrap();
6930        assert_eq!(
6931            architect_preview_file_snapshot(&root, None),
6932            Err(agent_bridge::AgentErrorKind::CapacityExceeded)
6933        );
6934        stdfs::remove_dir_all(root).unwrap();
6935    }
6936    /// Keeps app search filters without forwarding encoded or duplicate preview-control parameters.
6937    #[test]
6938    fn preview_query_parameters_remain_inside_the_selected_app() {
6939        assert_eq!(
6940            architect_preview_query_target(
6941                "/__pharos/social/x/callback",
6942                "/architect/apps/view?task_id=306&path=%2F__pharos%2Fsocial%2Fx%2Fcallback&code=one-time&state=opaque"
6943            ),
6944            Some("/__pharos/social/x/callback?code=one-time&state=opaque".into())
6945        );
6946
6947        assert_eq!(
6948            architect_preview_query_target(
6949                "/artists",
6950                "/architect/app/edit?task_id=7&app_id=catalog&path=%2Fartists&q=Beta&%74ask_id=99"
6951            ),
6952            Some("/artists?q=Beta".into())
6953        );
6954        assert_eq!(
6955            architect_preview_query_target(
6956                "/artists?q=Alpha",
6957                "/architect/app/edit?task_id=7&path=%2Fartists&genre=house"
6958            ),
6959            Some("/artists?q=Alpha&genre=house".into())
6960        );
6961        assert!(
6962            architect_preview_query_target("/artists", "/architect/app/edit?%GG=bad").is_none()
6963        );
6964        assert!(
6965            architect_preview_query_target(
6966                "/artists",
6967                &format!("/architect/app/edit?{}", "q=too-long&".repeat(2000))
6968            )
6969            .is_none()
6970        );
6971    }
6972    /// Public assets require a compiled artifact, while pages, private downloads and writes retain database preparation.
6973    #[test]
6974    fn ready_preview_assets_do_not_require_database_preparation() {
6975        let root = Path::new(env!("CARGO_MANIFEST_DIR"))
6976            .join("tmp/0.8.4-testing")
6977            .join(format!("preview-assets-unit-{}", std::process::id()));
6978        stdfs::create_dir_all(&root).unwrap();
6979        assert!(
6980            architect_ready_preview_asset_root(&root, &root, "sample", "/static/a.css", "GET")
6981                .is_none()
6982        );
6983        stdfs::write(root.join("pharos.app.json"), "{}").unwrap();
6984        assert_eq!(
6985            architect_ready_preview_asset_root(&root, &root, "sample", "/static/a.css", "GET"),
6986            Some(root.clone())
6987        );
6988        assert_eq!(
6989            architect_ready_preview_asset_root(&root, &root, "sample", "/media/a.png", "HEAD"),
6990            Some(root.clone())
6991        );
6992        assert!(
6993            architect_ready_preview_asset_root(&root, &root, "sample", "/artists", "GET").is_none()
6994        );
6995        assert!(
6996            architect_ready_preview_asset_root(
6997                &root,
6998                &root,
6999                "sample",
7000                "/private/downloads/a.zip",
7001                "GET"
7002            )
7003            .is_none()
7004        );
7005        assert!(
7006            architect_ready_preview_asset_root(&root, &root, "sample", "/static/a.css", "POST")
7007                .is_none()
7008        );
7009        stdfs::remove_dir_all(root).unwrap();
7010    }
7011}
7012
7013#[cfg(test)]
7014mod preview_image_storage_tests {
7015    use super::*;
7016
7017    /// Keeps image uploads across preview versions without rewriting imported source configuration.
7018    #[test]
7019    fn preview_image_upload_uses_stable_external_storage() {
7020        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
7021            .join("tmp")
7022            .join(format!(
7023                "pharos-preview-images-{}",
7024                dynamic::random_secret_hex(8).unwrap()
7025            ));
7026        let workspace = root.join("workspace");
7027        let state = root.join("state");
7028        stdfs::create_dir_all(&workspace).unwrap();
7029        stdfs::write(
7030            workspace.join("runtime.conf"),
7031            format!("state_dir={}\n", state.display()),
7032        )
7033        .unwrap();
7034        for version in ["one", "two"] {
7035            let artifact = root
7036                .join(".preview-artifacts")
7037                .join(version)
7038                .join("apps/sample");
7039            stdfs::create_dir_all(&artifact).unwrap();
7040            stdfs::write(artifact.join("pharos.app.json"), "{}").unwrap();
7041            stdfs::write(
7042                artifact.join("app.conf"),
7043                "MEDIA_ROOT=/var/lib/pharos/media/sample\nother=value\n",
7044            )
7045            .unwrap();
7046            assert!(architect_configure_preview_media(
7047                &artifact, &workspace, "sample"
7048            ));
7049            let media = config::app_media_root(&artifact).unwrap();
7050            assert_eq!(media, state.join("sample/media"));
7051            assert!(
7052                stdfs::read_to_string(artifact.join("app.conf"))
7053                    .unwrap()
7054                    .contains("other=value")
7055            );
7056            assert!(architect_configure_preview_media(
7057                &artifact, &workspace, "sample"
7058            ));
7059        }
7060        let source = root.join("source");
7061        stdfs::create_dir_all(&source).unwrap();
7062        stdfs::write(source.join("pharos.app.json"), "{}").unwrap();
7063        stdfs::write(source.join("app.conf"), "MEDIA_ROOT=/keep/source/value\n").unwrap();
7064        assert!(!architect_configure_preview_media(
7065            &source, &workspace, "sample"
7066        ));
7067        assert_eq!(
7068            stdfs::read_to_string(source.join("app.conf")).unwrap(),
7069            "MEDIA_ROOT=/keep/source/value\n"
7070        );
7071        stdfs::remove_dir_all(root).unwrap();
7072    }
7073}