| 1 | //! Merged inside the existing Runtime API auth layer. No new listener, token, |
| 2 | //! CORS policy, or client-constructed capability grant is introduced here. |
| 3 | use super::*; |
| 4 | use crate::native_memory::NativeMemoryStore; |
| 5 | use codewhale_memory::{ |
| 6 | Access, Capability, Draft, Evidence, MemoryBackend, Scope, SourceKind, Status, |
| 7 | ValidationReceipt, |
| 8 | }; |
| 9 | |
| 10 | type LensError = (StatusCode, Json<Value>); |
| 11 | fn fail(error: codewhale_memory::Error) -> LensError { |
| 12 | let status = match &error { |
| 13 | codewhale_memory::Error::Denied => StatusCode::FORBIDDEN, |
| 14 | codewhale_memory::Error::NotFound => StatusCode::NOT_FOUND, |
| 15 | codewhale_memory::Error::RevisionConflict |
| 16 | | codewhale_memory::Error::IdempotencyConflict |
| 17 | | codewhale_memory::Error::KeyConflict => StatusCode::CONFLICT, |
| 18 | codewhale_memory::Error::Disabled => StatusCode::SERVICE_UNAVAILABLE, |
| 19 | codewhale_memory::Error::Sql(_) | codewhale_memory::Error::Io(_) => { |
| 20 | StatusCode::INTERNAL_SERVER_ERROR |
| 21 | } |
| 22 | _ => StatusCode::UNPROCESSABLE_ENTITY, |
| 23 | }; |
| 24 | (status, Json(json!({"error":error.code()}))) |
| 25 | } |
| 26 | fn internal() -> LensError { |
| 27 | ( |
| 28 | StatusCode::INTERNAL_SERVER_ERROR, |
| 29 | Json(json!({"error":"memory_unavailable"})), |
| 30 | ) |
| 31 | } |
| 32 | fn invalid() -> LensError { |
| 33 | fail(codewhale_memory::Error::Invalid("invalid request".into())) |
| 34 | } |
| 35 | #[derive(Debug, Default, Deserialize)] |
| 36 | #[serde(deny_unknown_fields)] |
| 37 | struct LensQuery { |
| 38 | thread_id: Option<String>, |
| 39 | after: Option<String>, |
| 40 | limit: Option<usize>, |
| 41 | } |
| 42 | #[derive(Debug, Default, Deserialize)] |
| 43 | #[serde(deny_unknown_fields)] |
| 44 | struct EventQuery { |
| 45 | after: Option<i64>, |
| 46 | limit: Option<usize>, |
| 47 | } |
| 48 | #[derive(Debug, Deserialize)] |
| 49 | #[serde(tag = "action", rename_all = "snake_case", deny_unknown_fields)] |
| 50 | enum Action { |
| 51 | Approve { |
| 52 | id: String, |
| 53 | revision: i64, |
| 54 | #[serde(default)] |
| 55 | validation: Option<ValidationReceipt>, |
| 56 | }, |
| 57 | Reject { |
| 58 | id: String, |
| 59 | revision: i64, |
| 60 | }, |
| 61 | Forget { |
| 62 | id: String, |
| 63 | revision: i64, |
| 64 | }, |
| 65 | Preferences { |
| 66 | id: String, |
| 67 | revision: i64, |
| 68 | pinned: bool, |
| 69 | suppressed: bool, |
| 70 | }, |
| 71 | Remember { |
| 72 | request_key: String, |
| 73 | title: String, |
| 74 | body: String, |
| 75 | #[serde(default)] |
| 76 | global: bool, |
| 77 | }, |
| 78 | Replace { |
| 79 | id: String, |
| 80 | revision: i64, |
| 81 | request_key: String, |
| 82 | title: String, |
| 83 | body: String, |
| 84 | #[serde(default)] |
| 85 | validation: Option<ValidationReceipt>, |
| 86 | }, |
| 87 | } |
| 88 | #[derive(Debug, Deserialize)] |
| 89 | #[serde(deny_unknown_fields)] |
| 90 | struct ActionRequest { |
| 91 | thread_id: Option<String>, |
| 92 | command: Action, |
| 93 | } |
| 94 | fn valid_revision(revision: i64) -> Result<(), LensError> { |
| 95 | if !(0..i64::MAX).contains(&revision) { |
| 96 | return Err(invalid()); |
| 97 | } |
| 98 | Ok(()) |
| 99 | } |
| 100 | // This object is created exclusively by the authenticated server, not deserialized. |
| 101 | struct BoundMemory { |
| 102 | store: codewhale_memory::Store, |
| 103 | access: Access, |
| 104 | workspace_scope: Option<Scope>, |
| 105 | snapshot: codewhale_memory::Snapshot, |
| 106 | } |
| 107 | fn bind(state: &RuntimeApiState, thread_id: Option<&str>) -> Result<BoundMemory, LensError> { |
| 108 | let anchor = { |
| 109 | let config = state.config.read(); |
| 110 | if !config.memory_enabled() { |
| 111 | return Err(fail(codewhale_memory::Error::Disabled)); |
| 112 | } |
| 113 | config.memory_path() |
| 114 | }; |
| 115 | let native = NativeMemoryStore::from_memory_anchor(&anchor); |
| 116 | let workspace_id = NativeMemoryStore::workspace_id(&state.workspace).map_err(|_| internal())?; |
| 117 | let owner = NativeMemoryStore::owner_scope(); |
| 118 | let mut scopes = vec![owner.clone()]; |
| 119 | let workspace_scope = workspace_id |
| 120 | .as_deref() |
| 121 | .map(NativeMemoryStore::workspace_scope) |
| 122 | .transpose() |
| 123 | .map_err(|_| internal())?; |
| 124 | if let Some(scope) = &workspace_scope { |
| 125 | scopes.push(scope.clone()); |
| 126 | } |
| 127 | if let Some(id) = thread_id { |
| 128 | if id.is_empty() |
| 129 | || id.len() > 128 |
| 130 | || !id |
| 131 | .bytes() |
| 132 | .all(|b| b.is_ascii_alphanumeric() || b"_-.:".contains(&b)) |
| 133 | { |
| 134 | return Err(invalid()); |
| 135 | } |
| 136 | // The server token already authorizes this local runtime. This only |
| 137 | // narrows reads; the request cannot specify tenant/user/workspace grants. |
| 138 | scopes.push(workspace_scope.as_ref().unwrap_or(&owner).session(id)); |
| 139 | } |
| 140 | let access = Access::operator(scopes).map_err(fail)?; |
| 141 | let store = native.open_structured().map_err(|_| internal())?; |
| 142 | let snapshot = codewhale_memory::workspace::snapshot( |
| 143 | &state.workspace, |
| 144 | store.dependency_paths(&access).map_err(fail)?, |
| 145 | ) |
| 146 | .map_err(fail)?; |
| 147 | Ok(BoundMemory { |
| 148 | store, |
| 149 | access, |
| 150 | workspace_scope, |
| 151 | snapshot, |
| 152 | }) |
| 153 | } |
| 154 | pub(super) fn routes() -> Router<RuntimeApiState> { |
| 155 | Router::new() |
| 156 | .route("/v1/memory/lens", get(read_lens)) |
| 157 | .route("/v1/memory/lens/actions", post(act)) |
| 158 | .route("/v1/memory/events", get(events)) |
| 159 | } |
| 160 | async fn read_lens( |
| 161 | State(state): State<RuntimeApiState>, |
| 162 | Query(query): Query<LensQuery>, |
| 163 | ) -> Result<Json<Value>, LensError> { |
| 164 | tokio::task::spawn_blocking(move || { |
| 165 | let b = bind(&state, query.thread_id.as_deref())?; |
| 166 | let snapshot = b |
| 167 | .store |
| 168 | .lens_snapshot( |
| 169 | &b.access, |
| 170 | query.thread_id.as_deref(), |
| 171 | query.after.as_deref(), |
| 172 | query.limit.unwrap_or(100), |
| 173 | &b.snapshot, |
| 174 | ) |
| 175 | .map_err(fail)?; |
| 176 | Ok(Json( |
| 177 | serde_json::to_value(snapshot).map_err(|_| internal())?, |
| 178 | )) |
| 179 | }) |
| 180 | .await |
| 181 | .map_err(|_| internal())? |
| 182 | } |
| 183 | async fn events( |
| 184 | State(state): State<RuntimeApiState>, |
| 185 | Query(query): Query<EventQuery>, |
| 186 | ) -> Result<Json<Value>, LensError> { |
| 187 | tokio::task::spawn_blocking(move || { |
| 188 | let b = bind(&state, None)?; |
| 189 | let page = b |
| 190 | .store |
| 191 | .event_page( |
| 192 | &b.access, |
| 193 | query.after.unwrap_or(0), |
| 194 | query.limit.unwrap_or(200), |
| 195 | ) |
| 196 | .map_err(fail)?; |
| 197 | Ok(Json(serde_json::to_value(page).map_err(|_| internal())?)) |
| 198 | }) |
| 199 | .await |
| 200 | .map_err(|_| internal())? |
| 201 | } |
| 202 | async fn act( |
| 203 | State(state): State<RuntimeApiState>, |
| 204 | Json(request): Json<ActionRequest>, |
| 205 | ) -> Result<Json<Value>, LensError> { |
| 206 | tokio::task::spawn_blocking(move||{ |
| 207 | let mut b=bind(&state,request.thread_id.as_deref())?; |
| 208 | // Mutations here are operator controls. No dispatch acknowledgement is |
| 209 | // exposed over HTTP; only the engine can attest its own history/transport. |
| 210 | b.access.require(Capability::Review).map_err(fail)?; |
| 211 | match request.command { |
| 212 | Action::Approve{id,revision,validation}=>{ |
| 213 | valid_revision(revision)?; |
| 214 | let m=b.store.get(&b.access,&id).map_err(fail)?; |
| 215 | if !(m.status==Status::Active&&m.revision==revision+1) {b.store.approve(&b.access,&id,revision,validation.as_ref(),&b.snapshot).map_err(fail)?;} |
| 216 | }, |
| 217 | Action::Reject{id,revision}=>{ |
| 218 | valid_revision(revision)?; |
| 219 | let m=b.store.get(&b.access,&id).map_err(fail)?; |
| 220 | if !(m.status==Status::Rejected&&m.revision==revision+1) {b.store.reject(&b.access,&id,revision).map_err(fail)?;} |
| 221 | }, |
| 222 | Action::Forget{id,revision}=>{ |
| 223 | valid_revision(revision)?; |
| 224 | match b.store.forget(&b.access,&id,revision) { |
| 225 | Ok(_)|Err(codewhale_memory::Error::NotFound)=>{}, |
| 226 | Err(error)=>return Err(fail(error)), |
| 227 | } |
| 228 | }, |
| 229 | Action::Preferences{id,revision,pinned,suppressed}=>{valid_revision(revision)?;b.store.set_preferences(&b.access,&id,revision,pinned,suppressed).map_err(fail)?;}, |
| 230 | Action::Remember{request_key,title,body,global}=>{ |
| 231 | let scope=if global {NativeMemoryStore::owner_scope()} else {b.workspace_scope.ok_or_else(invalid)?}; |
| 232 | // Explicit human operation: candidate capture followed by trusted |
| 233 | // review. observation=0 makes retry identity stable; created_at is |
| 234 | // the actual local receipt time, not an invented source timestamp. |
| 235 | let draft=Draft::note(scope,title,body,Evidence{kind:SourceKind::User,uri:"codewhale:context-lens".into(),locator:"Explicit remember action".into(),sha256:None,observed_at:0}); |
| 236 | let receipt=b.store.capture(&b.access,&request_key,draft).map_err(fail)?; |
| 237 | if receipt.memory.status==Status::Candidate {b.store.approve(&b.access,&receipt.memory.id,receipt.memory.revision,None,&b.snapshot).map_err(fail)?;} |
| 238 | }, |
| 239 | Action::Replace{id,revision,request_key,title,body,validation}=>{ |
| 240 | valid_revision(revision)?; |
| 241 | let old=b.store.get(&b.access,&id).map_err(fail)?; |
| 242 | let mut draft=old.draft.clone();draft.title=title;draft.body=body; |
| 243 | draft.evidence=vec![Evidence{kind:SourceKind::User,uri:format!("codewhale:memory:{id}"),locator:"Explicit correction".into(),sha256:None,observed_at:0}]; |
| 244 | draft.parent_ids.clear(); |
| 245 | let captured=b.store.capture(&b.access,&request_key,draft).map_err(fail)?; |
| 246 | if let Some(replacement)=b.store.replacement_of(&b.access,&id).map_err(fail)? { |
| 247 | if replacement.id!=captured.memory.id {return Err(fail(codewhale_memory::Error::RevisionConflict));} |
| 248 | } else { |
| 249 | b.store.supersede(&b.access,(&id,revision),(&captured.memory.id,captured.memory.revision),validation.as_ref(),&b.snapshot).map_err(fail)?; |
| 250 | } |
| 251 | }, |
| 252 | } |
| 253 | Ok(Json(json!({"ok":true,"refresh_required":true,"already_sent_context_cannot_be_retracted":true}))) |
| 254 | }).await.map_err(|_|internal())? |
| 255 | } |
| 256 |