|
| 1 | +//! Insert path for new CLI code-session rows, including the wire-typo |
| 2 | +//! guards and the frozen transcript-source decision. |
| 3 | +
|
| 4 | +use rusqlite::{params, Result as SqliteResult}; |
| 5 | + |
| 6 | +use agent_core::session::AgentExecMode; |
| 7 | +use database::db::get_connection; |
| 8 | + |
| 9 | +use crate::agent_sessions::cli::native_transcript; |
| 10 | +use crate::agent_sessions::cli::persistence::types::{CodeSession, CreateCodeSessionParams}; |
| 11 | +use crate::agent_sessions::cli::types::{ |
| 12 | + session_defaults, KeySource, SessionRunner, SessionStatus, DEFAULT_CODE_SESSION_FLOW, |
| 13 | + PERSONAL_ORG_ID, |
| 14 | +}; |
| 15 | + |
| 16 | +use super::read::get_session; |
| 17 | +use super::shared::{now_iso, sync_orgtrack_mirror}; |
| 18 | + |
| 19 | +/// Create a new code session. Returns the session ID. |
| 20 | +pub fn create_session( |
| 21 | + session_id: &str, |
| 22 | + params: &CreateCodeSessionParams, |
| 23 | +) -> SqliteResult<CodeSession> { |
| 24 | + let conn = get_connection()?; |
| 25 | + let ts = now_iso(); |
| 26 | + let name = params |
| 27 | + .name |
| 28 | + .clone() |
| 29 | + .unwrap_or_else(|| session_defaults::CODE_SESSION_NAME.to_string()); |
| 30 | + let flow = params |
| 31 | + .flow |
| 32 | + .clone() |
| 33 | + .unwrap_or_else(|| DEFAULT_CODE_SESSION_FLOW.to_string()); |
| 34 | + // Wire-typo guard: `runner` is read back via `SessionRunner::parse` |
| 35 | + // (typed enum) at every read site. If the caller passes a typo'd |
| 36 | + // string here, the row would be persisted as garbage and every |
| 37 | + // subsequent `row_to_session` would reject it as a |
| 38 | + // `FromSqlConversionFailure` — i.e. the session would be created |
| 39 | + // but unloadable. Reject at the entry point instead. |
| 40 | + let runner = match params.runner.as_deref().filter(|s| !s.is_empty()) { |
| 41 | + Some(raw) => SessionRunner::parse(raw) |
| 42 | + .ok_or_else(|| { |
| 43 | + rusqlite::Error::ToSqlConversionFailure( |
| 44 | + format!("unknown SessionRunner value: {raw:?}").into(), |
| 45 | + ) |
| 46 | + })? |
| 47 | + .to_string(), |
| 48 | + None => SessionRunner::Local.to_string(), |
| 49 | + }; |
| 50 | + |
| 51 | + let background = params.background.unwrap_or(false); |
| 52 | + |
| 53 | + // Wire-typo guard for `key_source` — same reasoning as `runner`. |
| 54 | + // `row_to_session` will fail-closed on an unknown column value, so |
| 55 | + // accepting an unvalidated string here would create an unloadable |
| 56 | + // session row (the frontend would see a created session that can |
| 57 | + // never be opened). Validate at the write boundary. |
| 58 | + let key_source_str = match params.key_source.as_deref().filter(|s| !s.is_empty()) { |
| 59 | + Some(raw) => KeySource::parse(raw) |
| 60 | + .ok_or_else(|| { |
| 61 | + rusqlite::Error::ToSqlConversionFailure( |
| 62 | + format!("unknown KeySource value: {raw:?}").into(), |
| 63 | + ) |
| 64 | + })? |
| 65 | + .to_string(), |
| 66 | + None => KeySource::default().to_string(), |
| 67 | + }; |
| 68 | + |
| 69 | + let org_id = params |
| 70 | + .org_id |
| 71 | + .clone() |
| 72 | + .filter(|value| !value.trim().is_empty()) |
| 73 | + .unwrap_or_else(|| PERSONAL_ORG_ID.to_string()); |
| 74 | + |
| 75 | + let additional_dirs_json: Option<String> = params |
| 76 | + .additional_directories |
| 77 | + .as_ref() |
| 78 | + .filter(|v| !v.is_empty()) |
| 79 | + .map(|v| serde_json::to_string(v).unwrap_or_else(|_| "[]".to_string())); |
| 80 | + let product_mode = if params.work_item_id.is_some() { |
| 81 | + "project".to_string() |
| 82 | + } else { |
| 83 | + params |
| 84 | + .product_mode |
| 85 | + .clone() |
| 86 | + .filter(|mode| matches!(mode.as_str(), "build" | "plan" | "ask" | "project")) |
| 87 | + .unwrap_or_else(|| "build".to_string()) |
| 88 | + }; |
| 89 | + |
| 90 | + // Native-transcript capability is decided once at creation and frozen: |
| 91 | + // a later capability flip must never re-route an existing session's |
| 92 | + // replay away from where its turns were actually persisted. |
| 93 | + let transcript_source = key_vault::key_store::ModelType::from_str(¶ms.cli_agent_type) |
| 94 | + .filter(native_transcript::native_transcript_enabled) |
| 95 | + .map(|_| native_transcript::TRANSCRIPT_SOURCE_NATIVE) |
| 96 | + .unwrap_or(native_transcript::TRANSCRIPT_SOURCE_CHUNKS); |
| 97 | + |
| 98 | + conn.execute( |
| 99 | + "INSERT INTO code_sessions |
| 100 | + (session_id, name, status, flow, runner, cli_agent_type, model, tier, |
| 101 | + account_id, repo_path, branch, proxy_token, proxy_url, hosted_token, |
| 102 | + proxy_session_id, background, key_source, additional_directories, |
| 103 | + parent_session_id, org_member_id, org_id, project_id, project_name, |
| 104 | + project_slug, work_item_id, agent_role, created_at, updated_at, |
| 105 | + transcript_source, product_mode, agent_exec_mode) |
| 106 | + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30, ?31)", |
| 107 | + params![ |
| 108 | + session_id, name, SessionStatus::Pending.as_ref(), flow, runner, params.cli_agent_type, |
| 109 | + params.model, params.tier, params.account_id, |
| 110 | + params.repo_path, params.branch, params.proxy_token, params.proxy_url, |
| 111 | + params.hosted_token, params.proxy_session_id, background, key_source_str, |
| 112 | + additional_dirs_json, params.parent_session_id, params.org_member_id, |
| 113 | + org_id, params.project_id, params.project_name, params.project_slug, |
| 114 | + params.work_item_id, params.agent_role, ts, ts, transcript_source, |
| 115 | + product_mode, AgentExecMode::Build.as_str(), |
| 116 | + ], |
| 117 | + )?; |
| 118 | + |
| 119 | + let session = get_session(session_id)?.ok_or(rusqlite::Error::QueryReturnedNoRows)?; |
| 120 | + sync_orgtrack_mirror(session_id); |
| 121 | + Ok(session) |
| 122 | +} |
0 commit comments