1use 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
14fn 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
146fn 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 ¶ms,
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
604pub(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
667fn 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
681fn 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
708pub(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
726fn 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
775fn 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
843pub(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
973pub(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
1079pub(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
1319fn 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
1362fn 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
1380fn 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
1444pub(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
1673pub(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
2010pub(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
2104pub(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
2118pub(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
2148pub(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
2223pub(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
2369fn 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(¤t_editor_root);
2785 if !architect_sync_editor_preview_config(&store, task_id, ¤t_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 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
3000fn 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 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
3063fn 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
3089fn 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
3292fn 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 #[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 #[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 #[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
3435fn 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
3507fn 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
3576pub(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
3657enum ArchitectTaskActionKind {
3659 Preview,
3660 Export,
3661}
3662
3663fn 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
3864fn 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
3980pub(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
3990pub(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 ¶ms,
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 ¶ms,
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 ¶ms,
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
4323fn 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 ¶ms,
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 ¶ms,
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 ¶ms,
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 ¶ms,
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 ¶ms,
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 ¶ms,
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 ¶ms,
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
5004fn 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
5067fn 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
5110fn 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
5118fn 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
5170fn architect_preview_history_path(view: &agent_architect::ArchitectEditorSourceView) -> PathBuf {
5172 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
5180fn 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 eprintln!("architect preview artifact reuse unavailable: {error:?}");
5270 }
5271 result
5272}
5273
5274fn 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
5335fn 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
5344fn 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
5389fn 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
5397fn 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
5415fn 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
5425fn 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
5451fn 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
5479fn 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
5526fn 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
5556fn 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
5565fn 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
5649struct ArchitectPreviewSources {
5651 view: agent_architect::ArchitectEditorSourceView,
5652 files: Vec<(String, String)>,
5653}
5654
5655fn 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
5666fn 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
5674fn 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", ¶ms)
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
5935pub(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 ¶ms,
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 ¶ms,
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", ¶ms)
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
6587pub(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
6592pub(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 #[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 #[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 #[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 #[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("{6}"));
6791 assert!(!html.contains("{include:"));
6792 }
6793
6794 #[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 #[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 #[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 #[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 #[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 #[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 #[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}