diff --git a/README.md b/README.md index 498d1bd..ee05754 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,13 @@ The seven events shown on the first launch are sample data. They demonstrate the To restore the example later, import `data/sample/sample_events.csv` from the repository. -### 3. Record Work on This Mac +### 3. Choose the Client or Workstream + +Use the project selector at the top of the app to choose the client, engagement, or workstream before working with data. Create and name a project for a new engagement. Events, settings, imports, automation reviews, reports, and exports stay within that selected project. The selector is locked while a recording is active, so a session cannot move between projects. + +An older database that is upgraded to project-aware storage appears as **Migrated data**. Review and rename that project when its contents are confirmed. + +### 4. Record Work on This Mac Use **Record work** at the top of Home to capture a normal work session: @@ -98,7 +104,7 @@ cd ~/OpsMineFlow && ./scripts/install_mac.sh For existing logs, choose **Home > Start collecting data** and use CSV/JSON or the explicit ActivityWatch localhost import. -### 4. Import a CSV or JSON File +### 5. Import a CSV or JSON File 1. Prepare a CSV or JSON event log. 2. In Finder, select the file and press `Option-Command-C` to copy its full pathname. @@ -109,7 +115,7 @@ For existing logs, choose **Home > Start collecting data** and use CSV/JSON or t CSV commonly uses `case_id`, `activity`, `timestamp_start`, `timestamp_end`, `user`, `app_name`, `url`, and `memo`. The import replaces the current analysis dataset and records the import in local history. -### 5. Analyze the Work +### 6. Analyze the Work - **Dashboard**: totals, application time, business-label time, bottlenecks, and top automation candidates. - **Event Explorer**: masked event-level records. @@ -118,15 +124,15 @@ CSV commonly uses `case_id`, `activity`, `timestamp_start`, `timestamp_end`, `us - **Automation**: sort candidates and save Adopt, Hold, Reject, or Unreviewed states. - **Reports**: review the locally generated Markdown report. -### 6. Export Results +### 7. Export Results Open **Home > Exports**, choose Markdown, JSON, CSV, Mermaid, draw.io, or **LLM handoff (ZIP)**, and preview it. Review masking and confidential flags before choosing **Save to Path** or **Download**. The handoff ZIP is a local, manual transfer artifact: it contains aggregate process evidence and a versioned schema so an external LLM can write Mermaid Markdown, but OpsMineFlow never connects to an LLM or sends the file. See [the handoff bundle example](docs/samples/LLM_MERMAID_HANDOFF.md). -### 7. Check or Remove Local Data +### 8. Check or Remove Local Data -Use **Home > Diagnostics** for API, WebUI, SQLite storage, dependencies, ports, ActivityWatch, and local-only policy. Use **Settings > Delete Data** when the current analysis must be removed. This deletion cannot be undone unless the source file still exists and is imported again. +Use **Home > Diagnostics** for API, WebUI, SQLite storage, dependencies, ports, ActivityWatch, and local-only policy. Use **Settings > Delete Data** when the selected project's analysis must be removed; it does not affect other projects. After clearing its event data, an unneeded project can be deleted from the project selector. These actions cannot be undone unless the source file still exists and is imported again. -### 8. Stop OpsMineFlow +### 9. Stop OpsMineFlow Press `Control-C` in the startup terminal, or run this from another terminal: diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 6a0112c..961ad35 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -34,8 +34,9 @@ async fn local_api_operation( async fn delete_local_data( state: tauri::State<'_, runtime::RuntimeState>, app: tauri::AppHandle, + payload: serde_json::Value, ) -> Result { - state.delete_data_with_native_confirmation(app).await + state.delete_data_with_native_confirmation(app, payload).await } #[tauri::command] @@ -69,9 +70,9 @@ async fn import_selected_file( async fn save_export_with_dialog( state: tauri::State<'_, runtime::RuntimeState>, app: tauri::AppHandle, - format: String, + payload: serde_json::Value, ) -> Result { - state.save_export_with_dialog(app, format).await + state.save_export_with_dialog(app, payload).await } fn main() { diff --git a/apps/desktop/src-tauri/src/runtime.rs b/apps/desktop/src-tauri/src/runtime.rs index e6ab32f..85725c5 100644 --- a/apps/desktop/src-tauri/src/runtime.rs +++ b/apps/desktop/src-tauri/src/runtime.rs @@ -31,6 +31,7 @@ const MAX_API_PROXY_BODY_BYTES: usize = 1_048_576; const MAX_API_PROXY_RESPONSE_BYTES: usize = 4_194_304; const API_SESSION_HEADER: &str = "X-OpsMineFlow-Api-Session"; const DELETE_CHALLENGE_HEADER: &str = "X-OpsMineFlow-Delete-Challenge"; +const PROJECT_HEADER: &str = "X-OpsMineFlow-Project"; const RUNTIME_PROBE_CHALLENGE_HEADER: &str = "X-OpsMineFlow-Runtime-Probe-Challenge"; const MAX_API_PROXY_HEADER_BYTES: usize = 16_384; const FILE_SCOPE_TTL: Duration = Duration::from_secs(300); @@ -439,43 +440,68 @@ impl RuntimeState { if operation.requires_payload && payload.is_none() { return Err("local API operation requires a payload".to_owned()); } - if !operation.requires_payload && payload.is_some() { + let requires_project = operation_requires_project(operation.name); + if !operation.requires_payload && payload.is_some() && !requires_project { return Err("local API operation does not accept a payload".to_owned()); } - send_local_api_request( + let (request_payload, project_id) = if requires_project { + let mut payload = payload.ok_or_else(|| "local API operation requires a project context".to_owned())?; + let project_id = take_project_id(&mut payload)?; + let request_payload = if operation.method == "GET" { + None + } else { + Some(payload) + }; + (request_payload, Some(project_id)) + } else { + (payload, None) + }; + let headers = project_id + .as_deref() + .map(|value| (PROJECT_HEADER, value)) + .into_iter() + .collect::>(); + send_local_api_request_with_headers( &session_secret, &probe_secret, operation.method, operation.path, - payload.as_ref(), - None, + request_payload.as_ref(), + &headers, ) } pub async fn delete_data_with_native_confirmation( &self, app: AppHandle, + payload: Value, ) -> Result { + let mut request_payload = payload; + let project_id = take_project_id(&mut request_payload)?; if !request_native_delete_confirmation(app).await? { return Err("local data deletion was cancelled".to_owned()); } let runtime = self.clone(); tauri::async_runtime::spawn_blocking(move || { - runtime.delete_data_after_native_confirmation() + runtime.delete_data_after_native_confirmation(project_id, request_payload) }) .await .map_err(|_| "local data deletion did not complete".to_owned())? } - fn delete_data_after_native_confirmation(&self) -> Result { + fn delete_data_after_native_confirmation( + &self, + project_id: String, + request_payload: Value, + ) -> Result { let (session_secret, probe_secret) = self.verified_runtime_secrets()?; - let challenge_response = send_local_api_request( + let challenge_response = send_local_api_request_with_headers( &session_secret, &probe_secret, "POST", "/data/delete/challenge", Some(&json!({})), - None, + &[(PROJECT_HEADER, &project_id)], )?; let challenge = challenge_response .get("challenge") @@ -488,13 +514,13 @@ impl RuntimeState { .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_') }) .ok_or_else(|| "local API delete challenge is invalid".to_owned())?; - let response = send_local_api_request( + let response = send_local_api_request_with_headers( &session_secret, &probe_secret, "POST", "/data/delete", - Some(&json!({})), - Some((DELETE_CHALLENGE_HEADER, challenge)), + Some(&request_payload), + &[(PROJECT_HEADER, &project_id), (DELETE_CHALLENGE_HEADER, challenge)], )?; if response.get("deleted").and_then(Value::as_bool) != Some(true) { return Err("local data deletion was not confirmed by the runtime".to_owned()); @@ -663,11 +689,11 @@ impl RuntimeState { pub async fn save_export_with_dialog( &self, app: AppHandle, - format: String, + payload: Value, ) -> Result { let runtime = self.clone(); tauri::async_runtime::spawn_blocking(move || { - runtime.save_export_with_dialog_blocking(app, format) + runtime.save_export_with_dialog_blocking(app, payload) }) .await .map_err(|_| "export save did not complete".to_owned())? @@ -676,8 +702,23 @@ impl RuntimeState { fn save_export_with_dialog_blocking( &self, app: AppHandle, - format: String, + payload: Value, ) -> Result { + let mut request_payload = payload + .as_object() + .cloned() + .ok_or_else(|| "export requires a project context".to_owned())?; + let project_id = request_payload + .remove("project_id") + .and_then(|value| value.as_str().map(str::to_owned)) + .filter(|value| is_canonical_project_id(value)) + .ok_or_else(|| "export requires a valid project context".to_owned())?; + let format = request_payload + .get("format") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "export format is required".to_owned())? + .to_owned(); let (extension, suggested_name) = export_file_details(&format)?; let selection = app .dialog() @@ -713,15 +754,19 @@ impl RuntimeState { } let (session_secret, probe_secret) = self.verified_runtime_secrets()?; let staging_path = self.create_export_staging_path(extension)?; - let response = send_file_transfer_request( + request_payload.insert("format".to_owned(), Value::String(format)); + request_payload.insert( + "path".to_owned(), + Value::String(staging_path.to_string_lossy().into_owned()), + ); + request_payload.insert("overwrite_confirmed".to_owned(), Value::Bool(false)); + let response = send_file_transfer_request_with_headers( &session_secret, &probe_secret, "POST", "/export/save", - Some( - &json!({"format": format, "path": staging_path.to_string_lossy(), "overwrite_confirmed": false}), - ), - None, + Some(&Value::Object(request_payload)), + &[(PROJECT_HEADER, &project_id)], ); let mut response = match response { Ok(response) => response, @@ -796,6 +841,11 @@ impl RuntimeState { ) -> Result { validate_staged_import_file(scope)?; let mut payload = payload.as_object().cloned().unwrap_or_default(); + let project_id = payload + .remove("project_id") + .and_then(|value| value.as_str().map(str::to_owned)) + .filter(|value| is_canonical_project_id(value)) + .ok_or_else(|| "import requires a valid project context".to_owned())?; payload.insert("format".to_owned(), Value::String(scope.format.clone())); payload.insert( "path".to_owned(), @@ -811,13 +861,13 @@ impl RuntimeState { route }; let (session_secret, probe_secret) = self.verified_runtime_secrets()?; - send_file_transfer_request( + send_file_transfer_request_with_headers( &session_secret, &probe_secret, "POST", target, Some(&Value::Object(payload)), - None, + &[(PROJECT_HEADER, &project_id)], ) } @@ -1674,6 +1724,36 @@ fn resolve_api_operation(name: &str) -> Option> { path: "/health", requires_payload: false, }, + "projects" => ApiOperation { + name, + method: "GET", + path: "/projects", + requires_payload: false, + }, + "project_create" => ApiOperation { + name, + method: "POST", + path: "/projects", + requires_payload: true, + }, + "project_select" => ApiOperation { + name, + method: "POST", + path: "/projects/select", + requires_payload: true, + }, + "project_rename" => ApiOperation { + name, + method: "POST", + path: "/projects/rename", + requires_payload: true, + }, + "project_delete" => ApiOperation { + name, + method: "POST", + path: "/projects/delete", + requires_payload: true, + }, "diagnostics" => ApiOperation { name, method: "GET", @@ -1847,65 +1927,96 @@ fn resolve_api_operation(name: &str) -> Option> { Some(operation) } -fn send_local_api_request( +fn operation_requires_project(name: &str) -> bool { + !matches!( + name, + "health" | "projects" | "project_create" | "project_select" | "project_rename" | "project_delete" + ) +} + +fn take_project_id(payload: &mut Value) -> Result { + let project_id = payload + .as_object_mut() + .and_then(|object| object.remove("project_id")) + .and_then(|value| value.as_str().map(str::to_owned)) + .ok_or_else(|| "local API operation requires a project context".to_owned())?; + if !is_canonical_project_id(&project_id) { + return Err("local API project context is invalid".to_owned()); + } + Ok(project_id) +} + +fn is_canonical_project_id(value: &str) -> bool { + value.len() == 36 + && value + .bytes() + .enumerate() + .all(|(index, byte)| match index { + 8 | 13 | 18 | 23 => byte == b'-', + _ => byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase(), + }) +} + +fn send_local_api_request_with_headers( session_secret: &str, probe_secret: &str, method: &str, path: &str, payload: Option<&Value>, - extra_header: Option<(&str, &str)>, + extra_headers: &[(&str, &str)], ) -> Result { - send_local_api_request_with_timeout( + send_local_api_request_with_timeout_and_headers( session_secret, probe_secret, method, path, payload, - extra_header, + extra_headers, API_PROXY_TIMEOUT, ) } -fn send_file_transfer_request( +fn send_file_transfer_request_with_headers( session_secret: &str, probe_secret: &str, method: &str, path: &str, payload: Option<&Value>, - extra_header: Option<(&str, &str)>, + extra_headers: &[(&str, &str)], ) -> Result { - send_local_api_request_with_timeout( + send_local_api_request_with_timeout_and_headers( session_secret, probe_secret, method, path, payload, - extra_header, + extra_headers, FILE_TRANSFER_TIMEOUT, ) } -fn send_local_api_request_with_timeout( +fn send_local_api_request_with_timeout_and_headers( session_secret: &str, probe_secret: &str, method: &str, path: &str, payload: Option<&Value>, - extra_header: Option<(&str, &str)>, + extra_headers: &[(&str, &str)], timeout: Duration, ) -> Result { - send_local_api_request_at_with_timeout( + send_local_api_request_at_with_timeout_and_headers( session_secret, probe_secret, endpoint(), method, path, payload, - extra_header, + extra_headers, timeout, ) } +#[cfg(test)] fn send_local_api_request_at( session_secret: &str, probe_secret: &str, @@ -1927,6 +2038,7 @@ fn send_local_api_request_at( ) } +#[cfg(test)] fn send_local_api_request_at_with_timeout( session_secret: &str, probe_secret: &str, @@ -1936,6 +2048,29 @@ fn send_local_api_request_at_with_timeout( payload: Option<&Value>, extra_header: Option<(&str, &str)>, timeout: Duration, +) -> Result { + let headers = extra_header.into_iter().collect::>(); + send_local_api_request_at_with_timeout_and_headers( + session_secret, + probe_secret, + address, + method, + path, + payload, + &headers, + timeout, + ) +} + +fn send_local_api_request_at_with_timeout_and_headers( + session_secret: &str, + probe_secret: &str, + address: SocketAddr, + method: &str, + path: &str, + payload: Option<&Value>, + extra_headers: &[(&str, &str)], + timeout: Duration, ) -> Result { let deadline = Instant::now() + timeout; let body = if method == "POST" { @@ -1960,7 +2095,7 @@ fn send_local_api_request_at_with_timeout( body.len() )); } - if let Some((name, value)) = extra_header { + for (name, value) in extra_headers { request.push_str(name); request.push_str(": "); request.push_str(value); @@ -2185,6 +2320,25 @@ mod tests { assert!(resolve_api_operation("unknown_operation").is_none()); } + #[test] + fn project_scoped_operations_extract_only_a_canonical_project_id() { + let project_id = "b01eecad-1e18-5e88-bf34-8e8e8358cfcb"; + let mut payload = json!({ + "project_id": project_id, + "expected_revision": 4, + }); + + assert!(operation_requires_project("events_page")); + assert!(!operation_requires_project("projects")); + assert!(!operation_requires_project("project_create")); + assert_eq!(take_project_id(&mut payload).expect("project context"), project_id); + assert_eq!(payload, json!({"expected_revision": 4})); + assert!(take_project_id(&mut json!({ + "project_id": "B01EECAD-1E18-5E88-BF34-8E8E8358CFCB" + })) + .is_err()); + } + #[test] fn local_api_proxy_does_not_allow_path_based_file_operations() { assert!(resolve_api_operation("import_csv").is_none()); diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 7570797..33ce187 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -1,6 +1,8 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { approveEventQuality, + createProject, + deleteProject, deleteLocalData, excludeEvent, exportArtifact, @@ -12,17 +14,20 @@ import { isManagedDesktop, loadDashboardData, loadEventPage, + loadProjects, mergeEvents, pauseRecording, previewActivityWatchLocal, previewImport, previewExport, repairNativeRuntimeState, + renameProject, resumeRecording, runDiagnosticChecks, saveAutomationReview, saveExport, saveSettings, + selectProject, splitEvent, startRecording, stopRecording, @@ -49,6 +54,8 @@ import type { ImportHistoryEntry, ImportPreview, ProcessMap, + Project, + ProjectScope, RecordingStatus, RuntimeStatus, Summary @@ -70,6 +77,8 @@ type DashboardData = { candidates: AutomationCandidate[]; appSwitching: AppSwitching; markdown: string; + projectId: string; + projectRevision: number; }; type AppActions = { @@ -196,10 +205,18 @@ export function App() { const [actionMessage, setActionMessage] = useState(""); const [loading, setLoading] = useState(true); const [working, setWorking] = useState(false); + const [projects, setProjects] = useState([]); + const [selectedProjectId, setSelectedProjectId] = useState(""); const [runtimeStatus, setRuntimeStatus] = useState(null); const refreshInFlight = useRef | null>(null); + const selectedProject = projects.find((project) => project.project_id === selectedProjectId) || null; - function refresh(silent = false) { + function currentProjectScope(): ProjectScope { + if (!selectedProject) throw new Error(t("project.selectionRequired")); + return { projectId: selectedProject.project_id, expectedRevision: selectedProject.revision }; + } + + function refresh(silent = false, requestedProjectId = "") { if (refreshInFlight.current) return refreshInFlight.current; const task = (async () => { if (!silent) setLoading(true); @@ -210,7 +227,17 @@ export function App() { if (runtime && runtime.state !== "ready") { throw new Error(runtimeRecoveryMessage(runtime, t)); } - setData(await loadDashboardData()); + const projectResponse = await loadProjects(); + const requestedProject = projectResponse.projects.find((project) => project.project_id === requestedProjectId); + const rememberedProject = projectResponse.projects.find((project) => project.project_id === selectedProjectId); + const activeProject = projectResponse.projects.find((project) => project.project_id === projectResponse.active_project_id); + const nextProject = requestedProject || rememberedProject || activeProject || projectResponse.projects[0]; + if (!nextProject) throw new Error(t("project.noneAvailable")); + if (nextProject.project_id !== selectedProjectId) setData(null); + setProjects(projectResponse.projects); + setSelectedProjectId(nextProject.project_id); + const dashboard = await loadDashboardData(nextProject.project_id); + setData({ ...dashboard, projectId: nextProject.project_id, projectRevision: nextProject.revision }); } catch (err) { setError(err instanceof Error ? err.message : t("message.apiUnavailable", { error: "" })); } finally { @@ -243,6 +270,93 @@ export function App() { } } + async function switchProject(projectId: string) { + if (!projectId || projectId === selectedProjectId) return; + if (data?.recording.active) { + setActionMessage(t("project.recordingLocked")); + return; + } + setWorking(true); + setError(""); + setActionMessage(""); + try { + await selectProject(projectId); + await refresh(false, projectId); + setActionMessage(t("project.switched")); + } catch (err) { + setError(err instanceof Error ? err.message : t("message.actionFailed")); + } finally { + setWorking(false); + } + } + + async function createAndSelectProject(displayName: string) { + setWorking(true); + setError(""); + setActionMessage(""); + try { + const result = await createProject(displayName); + const project = result.project; + if (!project) throw new Error(t("project.createFailed")); + await selectProject(project.project_id); + await refresh(false, project.project_id); + setActionMessage(t("project.created")); + } catch (err) { + setError(err instanceof Error ? err.message : t("message.actionFailed")); + throw err; + } finally { + setWorking(false); + } + } + + async function renameCurrentProject(displayName: string) { + const project = selectedProject; + if (!project) return; + setWorking(true); + setError(""); + setActionMessage(""); + try { + await renameProject(project.project_id, displayName, project.revision); + await refresh(); + setActionMessage(t("project.renamed")); + } catch (err) { + setError(err instanceof Error ? err.message : t("message.actionFailed")); + throw err; + } finally { + setWorking(false); + } + } + + async function deleteCurrentProject() { + const project = selectedProject; + if (!project) return; + if (data?.recording.active) { + setActionMessage(t("project.recordingLocked")); + return; + } + if (project.event_count > 0) { + setActionMessage(t("project.deleteOnlyEmpty")); + return; + } + setWorking(true); + setError(""); + setActionMessage(""); + try { + const result = await deleteProject(project.project_id, project.revision); + const nextProjectId = result.active_project_id || result.projects[0]?.project_id || ""; + setData(null); + setProjects(result.projects); + setSelectedProjectId(nextProjectId); + await refresh(false, nextProjectId); + setActionMessage(t("project.deleted")); + } catch (err) { + setError(err instanceof Error ? err.message : t("message.actionFailed")); + throw err; + } finally { + setWorking(false); + } + } + useEffect(() => { void refresh(); }, []); @@ -261,9 +375,10 @@ export function App() { }, [data?.recording.active, t]); useEffect(() => { - if (!data?.recording.active) return; + if (!data?.recording.active || !selectedProject) return; + const projectScope: ProjectScope = { projectId: selectedProject.project_id }; const refreshRecordingStatus = () => { - void Promise.all([getNativeRuntimeStatus(), getRecordingStatus()]) + void Promise.all([getNativeRuntimeStatus(), getRecordingStatus(projectScope)]) .then(([runtime, recording]) => { setRuntimeStatus(runtime); if (runtime && runtime.state !== "ready") { @@ -276,7 +391,7 @@ export function App() { }; const timer = window.setInterval(refreshRecordingStatus, 2000); return () => window.clearInterval(timer); - }, [data?.recording.active, t]); + }, [data?.recording.active, selectedProject?.project_id, t]); async function runAction(task: () => Promise) { setWorking(true); @@ -298,7 +413,7 @@ export function App() { setWorking(true); setError(""); try { - const page = await loadEventPage(data.events.length); + const page = await loadEventPage(data.events.length, 500, currentProjectScope()); setData((current) => { if (!current) return current; const known = new Set(current.events.map((event) => event.event_id)); @@ -323,7 +438,7 @@ export function App() { setError(""); setActionMessage(""); try { - return await previewImport(format, path, mapping, dateFormat, timezone); + return await previewImport(format, path, currentProjectScope(), mapping, dateFormat, timezone); } catch (err) { setError(err instanceof Error ? err.message : t("message.previewFailed")); throw err; @@ -333,7 +448,7 @@ export function App() { }, importEvents: (format, path, mapping, dateFormat, timezone) => runAction(async () => { - const result = await importEvents(format, path, mapping, dateFormat, timezone); + const result = await importEvents(format, path, currentProjectScope(), mapping, dateFormat, timezone); return t("message.imported", { count: result.imported_events, source: result.source || format }); }), previewActivityWatch: async (enabled) => { @@ -341,7 +456,7 @@ export function App() { setError(""); setActionMessage(""); try { - return await previewActivityWatchLocal(enabled); + return await previewActivityWatchLocal(enabled, currentProjectScope()); } catch (err) { setError(err instanceof Error ? err.message : t("message.previewFailed")); throw err; @@ -351,7 +466,7 @@ export function App() { }, importActivityWatch: (enabled, mode) => runAction(async () => { - const result = await importActivityWatchLocal(enabled, mode); + const result = await importActivityWatchLocal(enabled, mode, currentProjectScope()); return t("message.activityImported", { count: result.imported_events, skipped: result.skipped_duplicates || 0 }); }), previewExport: async (format) => { @@ -359,7 +474,7 @@ export function App() { setError(""); setActionMessage(""); try { - return await previewExport(format); + return await previewExport(format, currentProjectScope()); } catch (err) { setError(err instanceof Error ? err.message : t("message.exportPreviewFailed")); throw err; @@ -373,10 +488,10 @@ export function App() { return t("message.exportCancelled"); } if (isManagedDesktop()) { - const result = await saveExport(format, ""); + const result = await saveExport(format, "", currentProjectScope()); return t("message.savedExport", { format: result.format, path: result.filename }); } - const filename = downloadExport(format, await exportArtifact(format)); + const filename = downloadExport(format, await exportArtifact(format, currentProjectScope())); return t("message.downloaded", { filename }); }), saveExport: (format, path) => @@ -384,47 +499,47 @@ export function App() { if (!window.confirm(t("message.exportReview"))) { return t("message.exportCancelled"); } - const result = await saveExport(format, path); + const result = await saveExport(format, path, currentProjectScope()); return t("message.savedExport", { format: result.format, path: result.filename }); }), saveSettings: (settings) => runAction(async () => { - await saveSettings(settings); + await saveSettings(settings, currentProjectScope()); return t("message.settingsSaved"); }), saveAutomationReview: (activity, status, note = "") => runAction(async () => { - const result = await saveAutomationReview(activity, status, note); + const result = await saveAutomationReview(activity, status, note, currentProjectScope()); return t("message.reviewSaved", { activity: result.activity }); }), updateEventActivity: (eventId, activity) => runAction(async () => { - await updateEventActivity(eventId, activity); + await updateEventActivity(eventId, activity, currentProjectScope()); return t("message.timelineActivityUpdated"); }), updateEventCaseCorrelation: (eventId, caseId, reason) => runAction(async () => { - await updateEventCaseCorrelation(eventId, caseId, reason); + await updateEventCaseCorrelation(eventId, caseId, reason, currentProjectScope()); return t("message.caseCorrelationUpdated"); }), excludeEvent: (eventId) => runAction(async () => { - await excludeEvent(eventId); + await excludeEvent(eventId, currentProjectScope()); return t("message.timelineEventExcluded"); }), approveEventQuality: (eventId) => runAction(async () => { - await approveEventQuality(eventId); + await approveEventQuality(eventId, currentProjectScope()); return t("message.qualityApproved"); }), splitEvent: (eventId, splitAfterSeconds, firstActivity = "", secondActivity = "") => runAction(async () => { - await splitEvent(eventId, splitAfterSeconds, firstActivity, secondActivity); + await splitEvent(eventId, splitAfterSeconds, firstActivity, secondActivity, currentProjectScope()); return t("message.timelineEventSplit"); }), mergeEvents: (firstEventId, secondEventId, activity = "") => runAction(async () => { - await mergeEvents(firstEventId, secondEventId, activity); + await mergeEvents(firstEventId, secondEventId, activity, currentProjectScope()); return t("message.timelineEventsMerged"); }), runDiagnosticChecks: async () => { @@ -432,7 +547,7 @@ export function App() { setError(""); setActionMessage(""); try { - const result = await runDiagnosticChecks(); + const result = await runDiagnosticChecks(currentProjectScope()); setActionMessage(t("message.diagnosticsFinished")); return result; } catch (err) { @@ -444,28 +559,35 @@ export function App() { }, deleteData: () => runAction(async () => { - await deleteLocalData(); + await deleteLocalData(currentProjectScope()); return t("message.dataDeleted"); }), startRecording: (caseId, activityLabel, clearSample) => runAction(async () => { - if (clearSample) await deleteLocalData(); - await startRecording(caseId, activityLabel); + let projectScope = currentProjectScope(); + if (clearSample) { + await deleteLocalData(projectScope); + const projectsAfterClear = await loadProjects(); + const refreshedProject = projectsAfterClear.projects.find((project) => project.project_id === projectScope.projectId); + if (!refreshedProject) throw new Error(t("project.selectionRequired")); + projectScope = { projectId: refreshedProject.project_id, expectedRevision: refreshedProject.revision }; + } + await startRecording(caseId, activityLabel, projectScope); return t("message.recordingStarted"); }), stopRecording: () => runAction(async () => { - const result = await stopRecording(); + const result = await stopRecording(currentProjectScope()); return t("message.recordingStopped", { count: result.recorded_events }); }), pauseRecording: (reason) => runAction(async () => { - await pauseRecording(reason); + await pauseRecording(reason, currentProjectScope()); return t("message.recordingPaused"); }), resumeRecording: () => runAction(async () => { - await resumeRecording(); + await resumeRecording(currentProjectScope()); return t("message.recordingResumed"); }) }; @@ -492,6 +614,16 @@ export function App() { English + + + + + {editor ? ( +
+ + + +
+ ) : null} +

+ {recordingActive ? t("project.recordingLocked") : t("project.keyboardHelp")} +

+ + ); +} + function View({ tab, data, diff --git a/apps/desktop/src/api.ts b/apps/desktop/src/api.ts index be52878..b0c943a 100644 --- a/apps/desktop/src/api.ts +++ b/apps/desktop/src/api.ts @@ -19,6 +19,9 @@ import type { ImportHistoryEntry, ImportPreview, ProcessMap, + ProjectMutationResponse, + ProjectsResponse, + ProjectScope, RecordingStatus, RuntimeStatus, Summary @@ -40,6 +43,7 @@ function isApprovedDevelopmentApiBase(value: string | undefined): boolean { const DEVELOPMENT_ROUTES: Record = { health: { method: "GET", path: "/health" }, + projects: { method: "GET", path: "/projects" }, diagnostics: { method: "GET", path: "/diagnostics" }, recording_status: { method: "GET", path: "/recording/status" }, settings: { method: "GET", path: "/settings" }, @@ -77,7 +81,11 @@ const DEVELOPMENT_ROUTES: Record { @@ -90,15 +98,29 @@ export async function repairNativeRuntimeState(): Promise return invoke("repair_runtime_state"); } -async function localApiOperation(operation: string, payload?: unknown): Promise { - if (isTauri()) return invoke("local_api_operation", { operation, payload: payload ?? null }); +function withProjectScope(payload: unknown, projectScope?: ProjectScope): Record { + const base = payload && typeof payload === "object" && !Array.isArray(payload) ? payload as Record : {}; + if (!projectScope) return base; + return { + ...base, + project_id: projectScope.projectId, + ...(projectScope.expectedRevision === undefined ? {} : { expected_revision: projectScope.expectedRevision }) + }; +} + +async function localApiOperation(operation: string, payload?: unknown, projectScope?: ProjectScope): Promise { + const scopedPayload = withProjectScope(payload, projectScope); + if (isTauri()) return invoke("local_api_operation", { operation, payload: Object.keys(scopedPayload).length ? scopedPayload : null }); if (!DIRECT_DEVELOPMENT_API) throw new Error("The packaged app requires its managed local runtime."); const route = DEVELOPMENT_ROUTES[operation]; if (!route) throw new Error("Local API operation is not available in development."); + const headers: Record = {}; + if (route.method === "POST") headers["content-type"] = "application/json"; + if (projectScope?.projectId) headers["x-opsmineflow-project"] = projectScope.projectId; const response = await fetch(`${DEV_API_BASE}${route.path}`, { method: route.method, - headers: route.method === "POST" ? { "content-type": "application/json" } : undefined, - body: route.method === "POST" ? JSON.stringify(payload ?? {}) : undefined + headers: Object.keys(headers).length ? headers : undefined, + body: route.method === "POST" ? JSON.stringify(scopedPayload) : undefined }); if (!response.ok) { let message = `Local API returned ${response.status}`; @@ -113,28 +135,49 @@ async function localApiOperation(operation: string, payload?: unknown): Promi return response.json() as Promise; } -function getJson(operation: string): Promise { - return localApiOperation(operation); +function getJson(operation: string, projectScope?: ProjectScope): Promise { + return localApiOperation(operation, undefined, projectScope); +} + +function postJson(operation: string, payload: unknown = {}, projectScope?: ProjectScope): Promise { + return localApiOperation(operation, payload, projectScope); +} + +export async function loadProjects(): Promise { + return getJson("projects"); +} + +export async function createProject(displayName: string): Promise { + return postJson("project_create", { display_name: displayName }); } -function postJson(operation: string, payload: unknown = {}): Promise { - return localApiOperation(operation, payload); +export async function selectProject(projectId: string): Promise { + return postJson("project_select", { project_id: projectId }); } -export async function loadDashboardData() { +export async function renameProject(projectId: string, displayName: string, expectedRevision: number): Promise { + return postJson("project_rename", { project_id: projectId, display_name: displayName, expected_revision: expectedRevision }); +} + +export async function deleteProject(projectId: string, expectedRevision: number): Promise { + return postJson("project_delete", { project_id: projectId, expected_revision: expectedRevision }); +} + +export async function loadDashboardData(projectId: string) { + const projectScope: ProjectScope = { projectId }; const [health, diagnostics, recording, settings, importHistory, eventPage, quality, summary, processMap, candidates, appSwitching, report] = await Promise.all([ getJson("health"), - getJson("diagnostics"), - getJson("recording_status"), - getJson("settings"), - getJson("import_history"), - postJson("events_page", { offset: 0, limit: 500 }), - getJson("event_quality"), - getJson("summary"), - getJson("process_map"), - getJson("automation_candidates"), - getJson("app_switching"), - getJson<{ markdown: string }>("report_markdown") + getJson("diagnostics", projectScope), + getJson("recording_status", projectScope), + getJson("settings", projectScope), + getJson<{ imports: ImportHistoryEntry[] }>("import_history", projectScope), + postJson("events_page", { offset: 0, limit: 500 }, projectScope), + getJson("event_quality", projectScope), + getJson("summary", projectScope), + getJson("process_map", projectScope), + getJson("automation_candidates", projectScope), + getJson("app_switching", projectScope), + getJson<{ markdown: string }>("report_markdown", projectScope) ]); return { @@ -142,7 +185,7 @@ export async function loadDashboardData() { diagnostics, recording, settings, - importHistory, + importHistory: importHistory.imports, events: eventPage.events, eventTotal: eventPage.total, quality, @@ -154,28 +197,28 @@ export async function loadDashboardData() { }; } -export async function loadEventPage(offset: number, limit = 500) { - return postJson("events_page", { offset, limit }); +export async function loadEventPage(offset: number, limit: number, projectScope: ProjectScope) { + return postJson("events_page", { offset, limit }, projectScope); } -export async function getRecordingStatus() { - return getJson("recording_status"); +export async function getRecordingStatus(projectScope: ProjectScope) { + return getJson("recording_status", projectScope); } -export async function startRecording(caseId: string, activityLabel: string) { - return postJson("recording_start", { case_id: caseId, activity_label: activityLabel, consent: true }); +export async function startRecording(caseId: string, activityLabel: string, projectScope: ProjectScope) { + return postJson("recording_start", { case_id: caseId, activity_label: activityLabel, consent: true }, projectScope); } -export async function stopRecording() { - return postJson("recording_stop"); +export async function stopRecording(projectScope: ProjectScope) { + return postJson("recording_stop", {}, projectScope); } -export async function pauseRecording(reason = "") { - return postJson("recording_pause", { reason }); +export async function pauseRecording(reason: string, projectScope: ProjectScope) { + return postJson("recording_pause", { reason }, projectScope); } -export async function resumeRecording() { - return postJson("recording_resume"); +export async function resumeRecording(projectScope: ProjectScope) { + return postJson("recording_resume", {}, projectScope); } export type ImportResult = { @@ -210,115 +253,132 @@ function importPayload(path: string, mapping?: CsvMapping, dateFormat = "", time return payload; } -export async function importEvents(format: "csv" | "json", path: string, mapping?: CsvMapping, dateFormat = "", timezone = "UTC") { +export async function importEvents( + format: "csv" | "json", + path: string, + projectScope: ProjectScope, + mapping?: CsvMapping, + dateFormat = "", + timezone = "UTC" +) { + const payload = withProjectScope(importPayload(path, format === "csv" ? mapping : undefined, dateFormat, timezone), projectScope); if (isTauri()) { return invoke("import_selected_file", { handle: path, - payload: importPayload(path, format === "csv" ? mapping : undefined, dateFormat, timezone) + payload }); } - return postJson(`import_${format}`, importPayload(path, format === "csv" ? mapping : undefined, dateFormat, timezone)); -} - -export async function previewImport(format: "csv" | "json", path: string, mapping?: CsvMapping, dateFormat = "", timezone = "UTC") { + return postJson(`import_${format}`, payload, projectScope); +} + +export async function previewImport( + format: "csv" | "json", + path: string, + projectScope: ProjectScope, + mapping?: CsvMapping, + dateFormat = "", + timezone = "UTC" +) { + const payload = withProjectScope({ format, ...importPayload(path, format === "csv" ? mapping : undefined, dateFormat, timezone) }, projectScope); if (isTauri()) { return invoke("preview_selected_import", { handle: path, - payload: { format, ...importPayload(path, format === "csv" ? mapping : undefined, dateFormat, timezone) } + payload }); } - return postJson("import_preview", { - format, - ...importPayload(path, format === "csv" ? mapping : undefined, dateFormat, timezone) - }); + return postJson("import_preview", payload, projectScope); } -export async function previewActivityWatchLocal(enabled: boolean) { - return postJson("activitywatch_preview", { enabled, base_url: "http://127.0.0.1:5600" }); +export async function previewActivityWatchLocal(enabled: boolean, projectScope: ProjectScope) { + return postJson("activitywatch_preview", { enabled, base_url: "http://127.0.0.1:5600" }, projectScope); } -export async function importActivityWatchLocal(enabled: boolean, mode: ActivityWatchImportMode = "replace") { - return postJson("activitywatch_import", { enabled, mode, base_url: "http://127.0.0.1:5600" }); +export async function importActivityWatchLocal(enabled: boolean, mode: ActivityWatchImportMode, projectScope: ProjectScope) { + return postJson("activitywatch_import", { enabled, mode, base_url: "http://127.0.0.1:5600" }, projectScope); } -export async function saveSettings(settings: Partial) { - return postJson("settings_update", settings); +export async function saveSettings(settings: Partial, projectScope: ProjectScope) { + return postJson("settings_update", settings, projectScope); } -export async function runDiagnosticChecks() { - return postJson("diagnostics_checks"); +export async function runDiagnosticChecks(projectScope: ProjectScope) { + return postJson("diagnostics_checks", {}, projectScope); } -export async function saveAutomationReview(activity: string, status: AutomationReviewStatus, note = "") { - return postJson<{ activity: string; review_status: AutomationReviewStatus; review_note: string }>("automation_review", { activity, status, note }); +export async function saveAutomationReview(activity: string, status: AutomationReviewStatus, note: string, projectScope: ProjectScope) { + return postJson<{ activity: string; review_status: AutomationReviewStatus; review_note: string }>("automation_review", { activity, status, note }, projectScope); } -export async function updateEventActivity(eventId: string, activity: string) { - return postJson<{ event: EventRecord }>("event_activity", { event_id: eventId, activity }); +export async function updateEventActivity(eventId: string, activity: string, projectScope: ProjectScope) { + return postJson<{ event: EventRecord }>("event_activity", { event_id: eventId, activity }, projectScope); } -export async function updateEventCaseCorrelation(eventId: string, caseId: string, reason: string) { +export async function updateEventCaseCorrelation(eventId: string, caseId: string, reason: string, projectScope: ProjectScope) { return postJson<{ event: EventRecord }>("event_case_correlation", { event_id: eventId, case_id: caseId, reason - }); + }, projectScope); } -export async function excludeEvent(eventId: string) { - return postJson<{ excluded: boolean; event_id: string }>("event_exclude", { event_id: eventId }); +export async function excludeEvent(eventId: string, projectScope: ProjectScope) { + return postJson<{ excluded: boolean; event_id: string }>("event_exclude", { event_id: eventId }, projectScope); } -export async function approveEventQuality(eventId: string) { - return postJson<{ event_id: string; quality_review_status: string }>("event_quality_review", { event_id: eventId, status: "approved" }); +export async function approveEventQuality(eventId: string, projectScope: ProjectScope) { + return postJson<{ event_id: string; quality_review_status: string }>("event_quality_review", { event_id: eventId, status: "approved" }, projectScope); } -export async function splitEvent(eventId: string, splitAfterSeconds: number, firstActivity = "", secondActivity = "") { +export async function splitEvent(eventId: string, splitAfterSeconds: number, firstActivity: string, secondActivity: string, projectScope: ProjectScope) { return postJson<{ split: boolean; events: EventRecord[] }>("event_split", { event_id: eventId, split_after_seconds: splitAfterSeconds, first_activity: firstActivity, second_activity: secondActivity - }); + }, projectScope); } -export async function mergeEvents(firstEventId: string, secondEventId: string, activity = "") { +export async function mergeEvents(firstEventId: string, secondEventId: string, activity: string, projectScope: ProjectScope) { return postJson<{ merged: boolean; event: EventRecord }>("event_merge", { first_event_id: firstEventId, second_event_id: secondEventId, activity - }); + }, projectScope); } -export async function deleteLocalData() { - if (isTauri()) return invoke<{ deleted: boolean }>("delete_local_data"); - const challenge = await postJson<{ challenge: string }>("delete_challenge"); +export async function deleteLocalData(projectScope: ProjectScope) { + if (isTauri()) return invoke<{ deleted: boolean }>("delete_local_data", { payload: withProjectScope({}, projectScope) }); + const challenge = await postJson<{ challenge: string }>("delete_challenge", {}, projectScope); if (!DIRECT_DEVELOPMENT_API) throw new Error("The packaged app requires its managed local runtime."); const route = DEVELOPMENT_ROUTES.delete_data; const response = await fetch(`${DEV_API_BASE}${route.path}`, { method: "POST", - headers: { "content-type": "application/json", "x-opsmineflow-delete-challenge": challenge.challenge }, - body: "{}" + headers: { + "content-type": "application/json", + "x-opsmineflow-delete-challenge": challenge.challenge, + "x-opsmineflow-project": projectScope.projectId + }, + body: JSON.stringify(withProjectScope({}, projectScope)) }); if (!response.ok) throw new Error(`Local API returned ${response.status}`); return response.json() as Promise<{ deleted: boolean }>; } -export async function exportArtifact(format: ExportFormat) { +export async function exportArtifact(format: ExportFormat, projectScope: ProjectScope) { if (isTauri()) throw new Error("Packaged exports must use the native save dialog."); - if (format === "markdown") return getJson<{ markdown: string }>("report_markdown"); - if (format === "json") return postJson<{ json: string }>("export_json"); - if (format === "csv") return postJson<{ filename: string; zip_base64: string }>("export_csv"); - if (format === "mermaid") return postJson<{ mermaid: string }>("export_mermaid"); - if (format === "llm-handoff") return postJson<{ filename: string; zip_base64: string }>("export_llm_handoff"); - return postJson<{ drawio: string }>("export_drawio"); + if (format === "markdown") return getJson<{ markdown: string }>("report_markdown", projectScope); + if (format === "json") return postJson<{ json: string }>("export_json", {}, projectScope); + if (format === "csv") return postJson<{ filename: string; zip_base64: string }>("export_csv", {}, projectScope); + if (format === "mermaid") return postJson<{ mermaid: string }>("export_mermaid", {}, projectScope); + if (format === "llm-handoff") return postJson<{ filename: string; zip_base64: string }>("export_llm_handoff", {}, projectScope); + return postJson<{ drawio: string }>("export_drawio", {}, projectScope); } -export async function previewExport(format: ExportFormat) { - return postJson("export_preview", { format }); +export async function previewExport(format: ExportFormat, projectScope: ProjectScope) { + return postJson("export_preview", { format }, projectScope); } -export async function saveExport(format: ExportFormat, path: string) { - if (isTauri()) return invoke("save_export_with_dialog", { format }); - return postJson("export_save", { format, path }); +export async function saveExport(format: ExportFormat, path: string, projectScope: ProjectScope) { + if (isTauri()) return invoke("save_export_with_dialog", { payload: withProjectScope({ format }, projectScope) }); + return postJson("export_save", { format, path }, projectScope); } diff --git a/apps/desktop/src/locales/en.json b/apps/desktop/src/locales/en.json index 3b300f3..7292be5 100644 --- a/apps/desktop/src/locales/en.json +++ b/apps/desktop/src/locales/en.json @@ -1,6 +1,28 @@ { "app.tagline": "Local-first task mining for consent-based As-Is discovery", "language.label": "Language", + "project.title": "Project", + "project.current": "Current project", + "project.select": "Choose project", + "project.loading": "Loading projects…", + "project.noneAvailable": "No local project is available. Create a project, then reload OpsMineFlow.", + "project.selectionRequired": "Choose a project before working with local data.", + "project.create": "New project", + "project.rename": "Rename", + "project.delete": "Delete empty", + "project.createName": "Name the new project", + "project.renameName": "Project name", + "project.namePlaceholder": "Example: Invoice operations review", + "project.eventCount": "{count} events", + "project.legacy": "Legacy data", + "project.switched": "Project switched. The dashboard was reloaded for this project only.", + "project.created": "Project created and selected.", + "project.createFailed": "The project was created, but OpsMineFlow could not select it. Reload projects before continuing.", + "project.renamed": "Project name updated.", + "project.deleted": "Empty project deleted.", + "project.deleteOnlyEmpty": "Only an empty project can be deleted here. Keep its data until a dedicated deletion or retention workflow is ready.", + "project.recordingLocked": "Stop recording before switching, creating, or deleting a project. The current recording stays in its original project.", + "project.keyboardHelp": "Keyboard: Option+P chooses a project. Command/Ctrl+Shift+N creates one.", "status.network": "Network", "status.localOnly": "Local Only", "status.checking": "Checking", @@ -90,6 +112,7 @@ "message.recordingResumed": "Recording resumed.", "message.recordingStopped": "Recording stopped with {count} completed events.", "confirm.deleteData": "Delete all imported events, labels, reviews, and import history from this Mac? Privacy settings will remain.", + "confirm.deleteProject": "Delete the empty project “{name}”? This cannot be undone.", "sample.title": "Sample data is currently displayed", "sample.body": "These 7 events are a first-run example, not your activity. Reload only reads the latest local state; it does not clear data.", "empty.title": "No analysis data yet", diff --git a/apps/desktop/src/locales/ja.json b/apps/desktop/src/locales/ja.json index 2f585f3..9e43557 100644 --- a/apps/desktop/src/locales/ja.json +++ b/apps/desktop/src/locales/ja.json @@ -1,6 +1,28 @@ { "app.tagline": "同意に基づく現状把握のためのローカル専用タスク分析", "language.label": "表示言語", + "project.title": "案件", + "project.current": "現在の案件", + "project.select": "案件を選択", + "project.loading": "案件を読み込み中…", + "project.noneAvailable": "利用できるローカル案件がない。案件を作成してからOpsMineFlowを再読込して。", + "project.selectionRequired": "ローカルデータを扱う前に案件を選んで。", + "project.create": "案件を作成", + "project.rename": "名前を変更", + "project.delete": "空の案件を削除", + "project.createName": "新しい案件名", + "project.renameName": "案件名", + "project.namePlaceholder": "例: 請求業務の見直し", + "project.eventCount": "{count} 件のイベント", + "project.legacy": "既存データ", + "project.switched": "案件を切り替え、この案件だけのダッシュボードを再読込した。", + "project.created": "案件を作成して選択した。", + "project.createFailed": "案件は作成されたが、選択できなかった。続ける前に案件を再読込して。", + "project.renamed": "案件名を更新した。", + "project.deleted": "空の案件を削除した。", + "project.deleteOnlyEmpty": "ここで削除できるのは空の案件だけ。専用の削除・保持期限の機能ができるまでは、データを残しておいて。", + "project.recordingLocked": "記録を停止してから、案件の切替・作成・削除をしてね。いまの記録は開始した案件だけに残る。", + "project.keyboardHelp": "キーボード: Option+Pで案件選択、Command/Ctrl+Shift+Nで案件作成。", "status.network": "通信", "status.localOnly": "ローカルのみ", "status.checking": "確認中", @@ -90,6 +112,7 @@ "message.recordingResumed": "記録を再開した。", "message.recordingStopped": "記録を停止し、{count} 件のイベントを確定した。", "confirm.deleteData": "このMacから取り込みイベント、ラベル、レビュー、取り込み履歴をすべて削除する? プライバシー設定は残る。", + "confirm.deleteProject": "空の案件「{name}」を削除する? この操作は元に戻せない。", "sample.title": "現在はサンプルデータを表示中", "sample.body": "この7件は初回確認用の例で、あなたの操作記録ではない。「最新状態を再読込」では削除されない。", "empty.title": "分析データはまだない", diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 73605cb..3921315 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -85,6 +85,104 @@ textarea { font-weight: 700; } +.project-switcher { + background: #ffffff; + border: 1px solid #9ebbb6; + border-radius: 8px; + display: grid; + gap: 6px; + max-width: min(100%, 620px); + padding: 8px; +} + +.project-switcher-heading { + align-items: baseline; + display: flex; + gap: 7px; + min-width: 0; +} + +.project-switcher-heading span, +.project-switcher-help { + color: #58646b; + font-size: 12px; +} + +.project-switcher-heading b { + color: #163235; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.project-switcher-controls, +.project-editor { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.project-switcher select { + flex: 1 1 180px; + min-width: 0; +} + +.project-switcher button { + background: #ffffff; + border: 1px solid #9ebbb6; + border-radius: 6px; + color: #163235; + cursor: pointer; + min-height: 32px; + padding: 5px 8px; +} + +.project-switcher button:hover:not(:disabled), +.project-switcher button:focus-visible { + border-color: #0f766e; + outline: 2px solid #83c7bd; + outline-offset: 1px; +} + +.project-switcher button:disabled, +.project-switcher select:disabled, +.project-switcher input:disabled { + cursor: not-allowed; + opacity: 0.58; +} + +.project-delete-button { + border-color: #c98a7d !important; + color: #8f2f21 !important; +} + +.project-editor { + align-items: end; + border-top: 1px solid #dce3e3; + padding-top: 7px; +} + +.project-editor label { + display: grid; + flex: 1 1 220px; + gap: 3px; +} + +.project-editor label span { + color: #58646b; + font-size: 12px; +} + +.project-switcher-help { + margin: 0; +} + +.project-switcher-help.is-locked { + color: #7c4d00; + font-weight: 600; +} + .status-pill, .refresh-button, .tab { @@ -1840,6 +1938,11 @@ th { justify-content: flex-start; } + .project-switcher { + max-width: none; + width: 100%; + } + .data-state-banner { align-items: stretch; flex-direction: column; diff --git a/apps/desktop/src/types.ts b/apps/desktop/src/types.ts index af4025c..cb28fa9 100644 --- a/apps/desktop/src/types.ts +++ b/apps/desktop/src/types.ts @@ -5,6 +5,30 @@ export type Health = { llm_supported: boolean; }; +export type Project = { + project_id: string; + display_name: string; + origin: string; + revision: number; + event_count: number; +}; + +export type ProjectsResponse = { + projects: Project[]; + active_project_id: string; +}; + +export type ProjectMutationResponse = ProjectsResponse & { + project?: Project; + deleted_project_id?: string; + replacement_project_id?: string; +}; + +export type ProjectScope = { + projectId: string; + expectedRevision?: number; +}; + export type RuntimeStatus = { state: "ready" | "unavailable" | "port_collision" | "stopped" | string; endpoint: string; diff --git a/apps/desktop/test/i18n.test.mjs b/apps/desktop/test/i18n.test.mjs index 6eac6e4..86784ac 100644 --- a/apps/desktop/test/i18n.test.mjs +++ b/apps/desktop/test/i18n.test.mjs @@ -59,7 +59,7 @@ test("beginner workflow labels are explicit in both languages", () => { test("the packaged WebUI uses the allowlisted Tauri proxy instead of a direct local API session", () => { assert.match(apiSource, /invoke\("local_api_operation"/); - assert.match(apiSource, /invoke<\{ deleted: boolean \}>\("delete_local_data"\)/); + assert.match(apiSource, /invoke<\{ deleted: boolean \}>\("delete_local_data", \{ payload: withProjectScope/); assert.match(apiSource, /import\.meta\.env\.DEV/); assert.match(apiSource, /isApprovedDevelopmentApiBase/); assert.match(apiSource, /url\.hostname === "127\.0\.0\.1"/); @@ -69,17 +69,19 @@ test("the packaged WebUI uses the allowlisted Tauri proxy instead of a direct lo }); test("large event lists use bounded pages and offer a user-triggered next page", () => { - assert.match(apiSource, /postJson\("events_page", \{ offset, limit \}\)/); + assert.match(apiSource, /postJson\("events_page", \{ offset, limit \}, projectScope\)/); assert.match(apiSource, /if \(isTauri\(\)\) throw new Error\("Packaged exports must use the native save dialog/); assert.match(appSource, /async function loadMoreEvents\(\)/); - assert.match(appSource, /loadEventPage\(data\.events\.length\)/); + assert.match(appSource, /loadEventPage\(data\.events\.length, 500, currentProjectScope\(\)\)/); assert.match(appSource, /t\("events\.loadMore"/); }); test("recording polling stays lightweight and dashboard refreshes are single-flight", () => { - assert.match(apiSource, /export async function getRecordingStatus\(\)/); + assert.match(apiSource, /export async function getRecordingStatus\(projectScope: ProjectScope\)/); assert.match(appSource, /const refreshInFlight = useRef \| null>\(null\)/); - assert.match(appSource, /Promise\.all\(\[getNativeRuntimeStatus\(\), getRecordingStatus\(\)\]\)/); + assert.match(appSource, /Promise\.all\(\[getNativeRuntimeStatus\(\), getRecordingStatus\(projectScope\)\]\)/); + assert.match(appSource, /const projectsAfterClear = await loadProjects\(\)/); + assert.match(appSource, /expectedRevision: refreshedProject\.revision/); assert.doesNotMatch(appSource, /setInterval\(\(\) => void refresh\(true\), 2000\)/); }); diff --git a/docs/OPEN_QUESTIONS.md b/docs/OPEN_QUESTIONS.md index b458295..fc385bc 100644 --- a/docs/OPEN_QUESTIONS.md +++ b/docs/OPEN_QUESTIONS.md @@ -31,3 +31,4 @@ See [product/COLLECTION_ROADMAP.md](product/COLLECTION_ROADMAP.md) for the full - Confirm whether SVG export should use a bundled local renderer or remain deferred while Mermaid and draw.io exports are available. - Define the minimum evidence required to promote each collector from technical preview to controlled beta. +- Define the user-facing preview, confirmation, retention, and deletion receipt for project data versus workspace migration snapshots before the all-data lifecycle work is released (tracked by #52 and #54). diff --git a/docs/architecture/DATA_MODEL.md b/docs/architecture/DATA_MODEL.md index d1be865..6e760e3 100644 --- a/docs/architecture/DATA_MODEL.md +++ b/docs/architecture/DATA_MODEL.md @@ -23,10 +23,22 @@ OpsMineFlow uses a standard event record as the common contract between importer The product local workflow uses SQLite in the user's application data directory by default. Tests and explicit callers can still use an in-memory `EventStore`. Storage remains local-only and can be redirected with `OPSMINEFLOW_DATA_DIR`. +### Project Boundary + +A workspace can contain multiple named projects (for example, a client, workstream, or engagement). A project has an opaque UUID, a display name, origin, timestamps, and a revision. The UUID is the storage and API authority; display names are never used as identifiers. + +Every user-data relation is scoped by `project_id`: events, manual labels, settings, metadata, import history, automation reviews, and recording-derived audit state. Composite primary/foreign keys keep records from one project from resolving into another, even when event IDs are identical. Reads and writes use an explicit project context and a project revision compare-and-swap check. The workspace's remembered active project is only a UI convenience; it is not a server-side data-access default. + +The desktop runtime removes the `project_id` from an allowlisted UI operation payload, validates its canonical UUID form, and sends it as the local `X-OpsMineFlow-Project` header. The local API rejects project-scoped routes without that header. A recording session binds to its project at start and cannot write events into another selected project. + +Users may clear the selected project's data without affecting other projects. A project can be deleted only after its event dataset is empty; deleting the final project is prevented by immediately selecting an existing replacement project. + ### Schema Evolution and Recovery Persistent databases use `PRAGMA user_version` together with an append-only `schema_migrations` ledger. OpsMineFlow applies ordered migrations only at startup, in one SQLite transaction. A migration never rewrites an already-applied migration: a schema change requires a new, sequential migration entry and a matching registry checksum. +Schema version 3 introduced project isolation. It atomically rebuilds the scoped tables, creates a deterministic opaque `Migrated data` project for existing records, backfills every legacy row into that project, and records before/after row counts and content fingerprints in workspace metadata. A failed upgrade leaves the prior schema intact; a retry starts from the original legacy state rather than a partial project migration. + Before upgrading an existing recognized database, the app creates a SQLite online-backup snapshot in the local `backups/` directory. The backup directory is owner-only and the snapshot file is owner-read/write only. The app retains at most the three newest migration snapshots after an attempt that created a snapshot, whether that attempt commits or rolls back. The app verifies database integrity and foreign-key consistency before and after migration, then checkpoints WAL after a successful upgrade. A post-commit WAL checkpoint warning does not roll back a completed schema migration; diagnostics reports it separately for follow-up. -If a database was created by a newer app version, has an unknown migration ledger, or is not a recognized legacy schema, OpsMineFlow fails closed. It does not create tables, overwrite the database, seed sample data, or attempt an automatic restore. Keep the original database and use the pre-upgrade snapshot for manual recovery with a compatible build. **Delete Data** removes both active analysis records and migration snapshots; filesystem or Time Machine backups are outside the app's control. +If a database was created by a newer app version, has an unknown migration ledger, or is not a recognized legacy schema, OpsMineFlow fails closed. It does not create tables, overwrite the database, seed sample data, or attempt an automatic restore. Keep the original database and use the pre-upgrade snapshot for manual recovery with a compatible build. Clearing a project never removes workspace-level migration snapshots; backup retention and all-data deletion are separately defined lifecycle operations. Filesystem or Time Machine backups are outside the app's control. diff --git a/docs/architecture/PRIVACY_SECURITY.md b/docs/architecture/PRIVACY_SECURITY.md index 264d1dd..aba6438 100644 --- a/docs/architecture/PRIVACY_SECURITY.md +++ b/docs/architecture/PRIVACY_SECURITY.md @@ -24,6 +24,7 @@ OpsMineFlow is designed for consent-based business improvement analysis. - Exact localhost Host validation, production Origin rejection, bounded JSON request bodies, and development-only CORS. - Minimal unauthenticated readiness endpoints only; user data, diagnostics, reports, and exports require the desktop runtime session. - One-time, short-lived delete challenges that remain inside the Rust runtime. +- Explicit opaque project context on every data operation. The Rust runtime validates the UUID, carries it in a fixed local header, and the API rejects a missing context; a remembered project selection never authorizes a data read or write. - No external telemetry. - No LLM integrations. - Audit-friendly documentation. @@ -32,6 +33,8 @@ OpsMineFlow is designed for consent-based business improvement analysis. The packaged desktop UI never receives the local API session secret and never sends a direct request to the loopback API. A Rust-owned sidecar launcher creates the secret for one runtime, passes it to the local API, and removes it from inherited child environments. Before a request carries that secret, Rust verifies the sidecar on the same TCP connection with a per-runtime HMAC probe; a listener that cannot prove ownership receives no API session secret. The Rust command layer maps each permitted UI action to a fixed local route and applies time and response-size limits. +For project-scoped routes, the Rust layer also removes the UI-supplied `project_id` from the JSON body after validating its canonical opaque UUID form and sends the value only as `X-OpsMineFlow-Project`. The API binds all data queries and mutations to that context and uses a revision compare-and-swap check to reject stale writes. The active-project preference in SQLite is never consulted to infer a request's data scope. + `GET /health` and `GET /runtime/health` are intentionally public so the launcher can safely determine whether it owns a previous local sidecar. Their public response is minimal operational metadata and does not read SQLite or disclose event counts. The runtime ownership nonce, PID, and HMAC proof appear only when Rust supplies a fresh probe challenge. All WebUI product routes require the per-runtime secret in production. Recorder ingestion uses its separate, explicit recording-session control instead of the WebUI secret. Browser-based development requires an explicit insecure-development opt-in and is not a packaged-product access path. Deletion requires a second, single-use challenge. The API issues and consumes that challenge atomically; the challenge is never returned to JavaScript in the packaged desktop app. diff --git a/docs/operations/RUNBOOK.md b/docs/operations/RUNBOOK.md index 354dc41..996a137 100644 --- a/docs/operations/RUNBOOK.md +++ b/docs/operations/RUNBOOK.md @@ -14,7 +14,13 @@ Open **OpsMineFlow.app** from Applications. The app starts its managed local run Quit **OpsMineFlow.app**. It stops only the local runtime it owns and leaves unrelated processes untouched. -### 4. Record a Work Session +### 4. Choose a Project + +Use the always-visible project selector to choose the client, engagement, or workstream before recording, importing, analysing, or exporting. Create a project with a clear display name when starting a new engagement. Project selection is disabled while a recording session is active so that one session cannot cross project boundaries. + +The selected project is an explicit data boundary: its events, labels, privacy settings, import history, automation reviews, reports, and exports do not include another project's records. A migrated pre-project database appears as **Migrated data**; rename it after confirming its contents. + +### 5. Record a Work Session 1. Open **Home > Record work**. 2. Enter a case or work-unit name and a work label. @@ -25,7 +31,7 @@ Quit **OpsMineFlow.app**. It stops only the local runtime it owns and leaves unr The recorder stores only frontmost application names, bundle identifiers, timestamps, and durations. It does not collect window titles, URLs, input text, screenshots, audio, or camera data. Use **Settings > Excluded apps** before starting when an application must be omitted. -### 5. Import Logs +### 6. Import Logs 1. Open **Home > Import**. 2. Choose CSV or JSON. @@ -36,7 +42,7 @@ The recorder stores only frontmost application names, bundle identifiers, timest ActivityWatch import is optional. Enable it only when the participant-approved scope includes ActivityWatch localhost data. If it is unavailable, use a CSV or JSON export instead. -### 6. Analyze +### 7. Analyze - **Dashboard**: totals, durations, and top signals - **Event Explorer**: masked event-level records @@ -47,7 +53,7 @@ ActivityWatch import is optional. Enable it only when the participant-approved s Automation review states are stored in the local SQLite database and included in Markdown and JSON exports. -### 7. Export +### 8. Export 1. Open **Home > Exports**. 2. Choose Markdown, JSON, CSV, Mermaid, draw.io, or **LLM handoff (ZIP)**. @@ -61,13 +67,15 @@ Treat export preview as the final manual checkpoint before sharing output with a `LLM handoff (ZIP)` creates a versioned, deterministic local ZIP for manual sharing with an external LLM. It contains aggregate process evidence, public JSON Schemas, and fixed Mermaid-writing constraints; it does not make any LLM, cloud, or network call. The export omits raw event rows, IDs, URLs, titles, aliases, metadata, and review notes. Activity labels and app names remain event-derived data and must be reviewed before sharing. See [the sample contract](../samples/LLM_MERMAID_HANDOFF.md). -### 8. Delete Local Analysis Data +### 9. Clear or Delete a Project 1. Open **Settings**. 2. Choose **Delete Data**. 3. Confirm the deletion prompt. -This removes imported events, manual labels, automation reviews, and import history from the local SQLite database. Privacy settings remain available for the next import. +This removes imported events, manual labels, automation reviews, and import history from the currently selected project only. The other projects in the local workspace remain unchanged. Privacy settings remain available for the next import into that project. + +To remove a project itself, first clear its event data, then use the project selector's delete action. OpsMineFlow keeps at least one project available and does not permit deletion while that project has an active recording session. ## Diagnostics @@ -85,7 +93,7 @@ Choose **Run Checks** to execute the local license and network guardrails. The c ## Local Data -The default SQLite database is stored under the user's macOS application data directory. Set `OPSMINEFLOW_DATA_DIR` before startup only when a separate local workspace is required. +The default SQLite database is stored under the user's macOS application data directory. One database can hold several explicitly isolated projects. Set `OPSMINEFLOW_DATA_DIR` before startup only when a separate local workspace is required. Exports are written only to the local path chosen by the user. @@ -93,7 +101,7 @@ Exports are written only to the local path chosen by the user. At startup, OpsMineFlow checks the local SQLite schema before loading records. When an upgrade is needed, it creates a private pre-upgrade snapshot under the local data directory's `backups/` folder and runs the ordered migration transaction. The three newest migration snapshots are retained. Diagnostics reports the schema version, migration status, integrity status, and whether a backup was created; it never exposes the backup path in the UI. -If startup reports that the database is from a newer app version, unknown, or failed to migrate, stop using that database. Do not delete or overwrite it. Preserve the database and its `backups/` folder, then open the snapshot only with a compatible OpsMineFlow build or follow the support/recovery procedure documented for that release. **Delete Data** removes both active analysis records and migration snapshots, but it cannot erase operating-system or Time Machine backups. +If startup reports that the database is from a newer app version, unknown, or failed to migrate, stop using that database. Do not delete or overwrite it. Preserve the database and its `backups/` folder, then open the snapshot only with a compatible OpsMineFlow build or follow the support/recovery procedure documented for that release. Clearing a project does not remove workspace-level migration snapshots. It cannot erase operating-system or Time Machine backups. ## Problem Resolution diff --git a/scripts/perf_http_smoke.sh b/scripts/perf_http_smoke.sh index b058f41..457536f 100755 --- a/scripts/perf_http_smoke.sh +++ b/scripts/perf_http_smoke.sh @@ -51,6 +51,7 @@ from pathlib import Path api_port = int(sys.argv[1]) root = Path(sys.argv[2]) base_url = f"http://127.0.0.1:{api_port}" +project_headers: dict[str, str] = {} limits = { "import": 15.0, "dashboard": 15.0, @@ -62,12 +63,12 @@ limits = { def request(path: str, payload: dict[str, object] | None = None) -> tuple[object, float]: started = time.perf_counter() if payload is None: - request_object = urllib.request.Request(base_url + path, method="GET") + request_object = urllib.request.Request(base_url + path, headers=project_headers, method="GET") else: request_object = urllib.request.Request( base_url + path, data=json.dumps(payload).encode("utf-8"), - headers={"content-type": "application/json"}, + headers={"content-type": "application/json", **project_headers}, method="POST", ) with urllib.request.urlopen(request_object, timeout=limits["export"] + 5) as response: @@ -120,6 +121,10 @@ def write_sources(count: int) -> tuple[Path, Path]: wait_for_health() +projects, _ = request("/projects") +if not isinstance(projects, dict) or not isinstance(projects.get("active_project_id"), str): + raise SystemExit("local API did not return an active project") +project_headers["X-OpsMineFlow-Project"] = projects["active_project_id"] results: list[dict[str, object]] = [] dashboard_requests: tuple[tuple[str, dict[str, object] | None], ...] = ( ("/health", None), diff --git a/scripts/smoke_lifecycle.sh b/scripts/smoke_lifecycle.sh index 118a2c4..74479ac 100755 --- a/scripts/smoke_lifecycle.sh +++ b/scripts/smoke_lifecycle.sh @@ -83,18 +83,22 @@ import urllib.request from pathlib import Path base = f"http://127.0.0.1:{sys.argv[1]}" +project_headers = {} def request(path, payload=None, headers=None): data = None if payload is None else json.dumps(payload).encode("utf-8") req = urllib.request.Request( base + path, data=data, - headers={"Content-Type": "application/json", **(headers or {})}, + headers={"Content-Type": "application/json", **project_headers, **(headers or {})}, method="GET" if payload is None else "POST", ) with urllib.request.urlopen(req, timeout=2) as response: return json.loads(response.read().decode("utf-8")) +projects = request("/projects") +project_headers["X-OpsMineFlow-Project"] = projects["active_project_id"] + diagnostics = request("/diagnostics") assert diagnostics["runtime_policy"]["local_only"] is True assert diagnostics["privacy_evidence"]["status"] == "passed" @@ -136,7 +140,8 @@ assert preview["event_count"] == 7 result = request("/import/csv", {"path": "data/sample/sample_events.csv"}) assert result["imported_events"] == 7 -events = request("/events") +events_payload = request("/events") +events = events_payload["events"] assert len(events) == 7 quality = request("/analytics/event-quality") assert quality["summary"]["total_events"] == 7 @@ -162,7 +167,7 @@ merged = request( ) excluded = request("/events/exclude", {"event_id": merged["event"]["event_id"]}) assert excluded["excluded"] is True -assert len(request("/events")) == 6 +assert len(request("/events")["events"]) == 6 export_preview = request("/export/preview", {"format": "markdown"}) assert export_preview["byte_size"] > 0 @@ -172,7 +177,7 @@ delete_challenge = request("/data/delete/challenge", {}) delete_result = request("/data/delete", {}, {"X-OpsMineFlow-Delete-Challenge": delete_challenge["challenge"]}) assert delete_result["deleted"] is True -assert request("/events") == [] +assert request("/events")["events"] == [] PY SECOND_OUTPUT="$(./scripts/run_local.sh)" diff --git a/scripts/smoke_local.sh b/scripts/smoke_local.sh index 61caf40..6170496 100755 --- a/scripts/smoke_local.sh +++ b/scripts/smoke_local.sh @@ -48,6 +48,7 @@ import urllib.request port = sys.argv[1] base_dir = sys.argv[2] base = f"http://127.0.0.1:{port}" +project_headers: dict[str, str] = {} def request( @@ -56,13 +57,14 @@ def request( headers: dict[str, str] | None = None, ) -> dict[str, object] | list[object]: if payload is None: - with urllib.request.urlopen(base + path, timeout=2) as response: + req = urllib.request.Request(base + path, headers={**project_headers, **(headers or {})}, method="GET") + with urllib.request.urlopen(req, timeout=2) as response: return json.loads(response.read().decode("utf-8")) data = json.dumps(payload).encode("utf-8") req = urllib.request.Request( base + path, data=data, - headers={"content-type": "application/json", **(headers or {})}, + headers={"content-type": "application/json", **project_headers, **(headers or {})}, method="POST", ) with urllib.request.urlopen(req, timeout=2) as response: @@ -81,6 +83,10 @@ for _ in range(40): else: raise SystemExit(f"API did not become ready: {last_error}") +projects = request("/projects") +assert isinstance(projects, dict) +project_headers["X-OpsMineFlow-Project"] = str(projects["active_project_id"]) + diagnostics = request("/diagnostics") assert isinstance(diagnostics, dict) assert diagnostics["runtime_policy"]["local_only"] is True @@ -130,8 +136,10 @@ result = request("/import/csv", {"path": "data/sample/sample_events.csv"}) assert isinstance(result, dict) assert result["imported_events"] == 7 -events = request("/events") -assert len(events) == 7 +events_payload = request("/events") +assert isinstance(events_payload, dict) +events = events_payload["events"] +assert isinstance(events, list) and len(events) == 7 quality = request("/analytics/event-quality") assert isinstance(quality, dict) assert quality["summary"]["total_events"] == 7 @@ -157,11 +165,13 @@ merged = request( ) excluded = request("/events/exclude", {"event_id": merged["event"]["event_id"]}) assert excluded["excluded"] is True -assert len(request("/events")) == 6 +remaining_events = request("/events") +assert isinstance(remaining_events, dict) +assert len(remaining_events["events"]) == 6 history = request("/import/history") -assert isinstance(history, list) -assert history and history[0]["source"] == "csv" +assert isinstance(history, dict) +assert history["imports"] and history["imports"][0]["source"] == "csv" settings = request("/settings", {"retention_days": 21}) assert isinstance(settings, dict) @@ -199,7 +209,8 @@ assert isinstance(deleted, dict) assert deleted["deleted"] is True events = request("/events") -assert events == [] +assert isinstance(events, dict) +assert events["events"] == [] PY if [[ -d apps/desktop/node_modules ]]; then diff --git a/services/local-api/src/opsmineflow_api/app.py b/services/local-api/src/opsmineflow_api/app.py index fd89b92..0d69bdd 100644 --- a/services/local-api/src/opsmineflow_api/app.py +++ b/services/local-api/src/opsmineflow_api/app.py @@ -25,6 +25,7 @@ DELETE_CHALLENGE_HEADER, DeleteChallengeStore, LocalApiPolicy, + PROJECT_HEADER, RUNTIME_PROBE_CHALLENGE_HEADER, RequestRejected, consume_runtime_credentials, @@ -69,7 +70,14 @@ from .child_process import sanitized_subprocess_environment from .llm_handoff import build_handoff_bundle from .recording import recording_manager -from .storage import EventStore, StorageCommitError, StoreSnapshot, default_store +from .storage import ( + EventStore, + ProjectConflictError, + ProjectNotFoundError, + StorageCommitError, + StoreSnapshot, + default_store, +) try: from fastapi import FastAPI, Header, HTTPException, Request @@ -86,14 +94,18 @@ BaseModel = object # type: ignore[assignment] -class PathImportRequest(BaseModel): # type: ignore[misc, valid-type] +class ProjectMutationRequest(BaseModel): # type: ignore[misc, valid-type] + expected_revision: int | None = None + + +class PathImportRequest(ProjectMutationRequest): # type: ignore[misc, valid-type] path: str mapping: dict[str, str] | None = None date_format: str = "" timezone: str = "UTC" -class ImportPreviewRequest(BaseModel): # type: ignore[misc, valid-type] +class ImportPreviewRequest(ProjectMutationRequest): # type: ignore[misc, valid-type] format: str path: str mapping: dict[str, str] | None = None @@ -101,61 +113,61 @@ class ImportPreviewRequest(BaseModel): # type: ignore[misc, valid-type] timezone: str = "UTC" -class LabelRequest(BaseModel): # type: ignore[misc, valid-type] +class LabelRequest(ProjectMutationRequest): # type: ignore[misc, valid-type] event_id: str label: str -class EventActivityUpdateRequest(BaseModel): # type: ignore[misc, valid-type] +class EventActivityUpdateRequest(ProjectMutationRequest): # type: ignore[misc, valid-type] event_id: str activity: str -class EventExcludeRequest(BaseModel): # type: ignore[misc, valid-type] +class EventExcludeRequest(ProjectMutationRequest): # type: ignore[misc, valid-type] event_id: str -class EventQualityReviewRequest(BaseModel): # type: ignore[misc, valid-type] +class EventQualityReviewRequest(ProjectMutationRequest): # type: ignore[misc, valid-type] event_id: str status: str = "approved" -class EventCaseCorrelationUpdateRequest(BaseModel): # type: ignore[misc, valid-type] +class EventCaseCorrelationUpdateRequest(ProjectMutationRequest): # type: ignore[misc, valid-type] event_id: str case_id: str reason: str -class EventSplitRequest(BaseModel): # type: ignore[misc, valid-type] +class EventSplitRequest(ProjectMutationRequest): # type: ignore[misc, valid-type] event_id: str split_after_seconds: float first_activity: str = "" second_activity: str = "" -class EventMergeRequest(BaseModel): # type: ignore[misc, valid-type] +class EventMergeRequest(ProjectMutationRequest): # type: ignore[misc, valid-type] first_event_id: str second_event_id: str activity: str = "" -class EventPageRequest(BaseModel): # type: ignore[misc, valid-type] +class EventPageRequest(ProjectMutationRequest): # type: ignore[misc, valid-type] offset: int = 0 limit: int = DEFAULT_EVENT_PAGE_SIZE -class ActivityWatchImportRequest(BaseModel): # type: ignore[misc, valid-type] +class ActivityWatchImportRequest(ProjectMutationRequest): # type: ignore[misc, valid-type] enabled: bool = False base_url: str = "http://127.0.0.1:5600" mode: str = "replace" -class ActivityWatchPreviewRequest(BaseModel): # type: ignore[misc, valid-type] +class ActivityWatchPreviewRequest(ProjectMutationRequest): # type: ignore[misc, valid-type] enabled: bool = False base_url: str = "http://127.0.0.1:5600" -class SettingsRequest(BaseModel): # type: ignore[misc, valid-type] +class SettingsRequest(ProjectMutationRequest): # type: ignore[misc, valid-type] mask_url_paths: bool | None = None mask_window_titles: bool | None = None retention_days: int | None = None @@ -165,23 +177,23 @@ class SettingsRequest(BaseModel): # type: ignore[misc, valid-type] excluded_domains: list[str] | None = None -class AutomationReviewRequest(BaseModel): # type: ignore[misc, valid-type] +class AutomationReviewRequest(ProjectMutationRequest): # type: ignore[misc, valid-type] activity: str status: str note: str = "" -class ExportPreviewRequest(BaseModel): # type: ignore[misc, valid-type] +class ExportPreviewRequest(ProjectMutationRequest): # type: ignore[misc, valid-type] format: str -class ExportSaveRequest(BaseModel): # type: ignore[misc, valid-type] +class ExportSaveRequest(ProjectMutationRequest): # type: ignore[misc, valid-type] format: str path: str overwrite_confirmed: bool = False -class RecordingStartRequest(BaseModel): # type: ignore[misc, valid-type] +class RecordingStartRequest(ProjectMutationRequest): # type: ignore[misc, valid-type] case_id: str activity_label: str consent: bool = False @@ -206,6 +218,23 @@ class RecordingHeartbeatRequest(BaseModel): # type: ignore[misc, valid-type] current_app: str = "" +class ProjectCreateRequest(BaseModel): # type: ignore[misc, valid-type] + display_name: str + + +class ProjectSelectRequest(BaseModel): # type: ignore[misc, valid-type] + project_id: str + + +class ProjectRenameRequest(ProjectMutationRequest): # type: ignore[misc, valid-type] + project_id: str + display_name: str + + +class ProjectDeleteRequest(ProjectMutationRequest): # type: ignore[misc, valid-type] + project_id: str + + def allowed_webui_origins() -> list[str]: webui_port = int(os.environ.get("OPSMINEFLOW_WEBUI_PORT", "5173")) return [ @@ -228,6 +257,37 @@ def local_api_policy() -> LocalApiPolicy: DELETE_CHALLENGES = DeleteChallengeStore() +def project_store(project_id: str, *, expected_revision: int | None = None) -> EventStore: + """Resolve an explicit, opaque project context for one local API operation.""" + + if not project_id.strip(): + raise _bad_request("Project context is required.") + try: + return default_store().for_project(project_id, expected_revision=expected_revision) + except ProjectNotFoundError as exc: + raise _not_found("Project was not found.") from exc + except ProjectConflictError as exc: + raise _conflict(str(exc)) from exc + except ValueError as exc: + raise _bad_request(str(exc)) from exc + + +def project_response(store: EventStore, payload: Mapping[str, object] | None = None) -> dict[str, object]: + response = dict(payload or {}) + snapshot = store.snapshot() + response["project_id"] = snapshot.project_id + response["project_revision"] = snapshot.project_revision + return response + + +def projects_response() -> dict[str, object]: + workspace = default_store() + return { + "projects": [project.to_api_dict() for project in workspace.list_projects()], + "active_project_id": workspace.active_project_id(), + } + + def _analysis_for_store(store: EventStore, snapshot: StoreSnapshot | None = None): active_snapshot = snapshot or store.snapshot() config = _mining_config_for_settings(active_snapshot.settings) @@ -1346,7 +1406,7 @@ def create_diagnostics(store: EventStore | None = None) -> dict[str, Any]: "status": _activitywatch_status(activitywatch_enabled), "remediation": "Enable ActivityWatch import only when the user explicitly wants localhost ActivityWatch data.", }, - "recording": recording_manager.status(), + "recording": recording_manager.status(store_snapshot.project_id), "privacy_evidence": privacy_capture_evidence(), "guardrails": { "license_policy": { @@ -1482,7 +1542,7 @@ def _run_guardrail_script(script_name: str) -> dict[str, object]: allow_origins=allowed_webui_origins(), allow_credentials=False, allow_methods=["GET", "POST"], - allow_headers=["content-type", DELETE_CHALLENGE_HEADER], + allow_headers=["content-type", DELETE_CHALLENGE_HEADER, PROJECT_HEADER], ) @app.middleware("http") @@ -1503,6 +1563,16 @@ async def enforce_local_api_policy(request: Request, call_next: Any): async def storage_commit_error(_request: Request, exc: StorageCommitError): assert JSONResponse is not None return JSONResponse({"error": exc.to_api_dict()}, status_code=503) + + @app.exception_handler(ProjectConflictError) + async def project_conflict_error(_request: Request, exc: ProjectConflictError): + assert JSONResponse is not None + return JSONResponse({"error": str(exc)}, status_code=409) + + @app.exception_handler(ProjectNotFoundError) + async def project_not_found_error(_request: Request, _exc: ProjectNotFoundError): + assert JSONResponse is not None + return JSONResponse({"error": "Project was not found."}, status_code=404) else: app = None @@ -1519,6 +1589,12 @@ def _bad_request(message: str) -> Exception: return ValueError(message) +def _conflict(message: str) -> Exception: + if FastAPI is not None: + return HTTPException(status_code=409, detail=message) + return ProjectConflictError(message) + + def _forbidden(message: str) -> Exception: if FastAPI is not None: return HTTPException(status_code=403, detail=message) @@ -1535,48 +1611,122 @@ def health() -> dict[str, Any]: def runtime_health(request: Request) -> dict[str, Any]: return create_runtime_health(request.headers.get(RUNTIME_PROBE_CHALLENGE_HEADER, "")) + @app.get("/projects") + def projects() -> dict[str, object]: + return projects_response() + + @app.post("/projects") + def create_project_endpoint(request: ProjectCreateRequest) -> dict[str, object]: + project = default_store().create_project(request.display_name) + return {**projects_response(), "project": project.to_api_dict()} + + @app.post("/projects/select") + def select_project_endpoint(request: ProjectSelectRequest) -> dict[str, object]: + project = default_store().select_project(request.project_id) + return {**projects_response(), "project": project.to_api_dict()} + + @app.post("/projects/rename") + def rename_project_endpoint(request: ProjectRenameRequest) -> dict[str, object]: + try: + project = default_store().rename_project( + request.project_id, + request.display_name, + expected_revision=request.expected_revision, + ) + except ProjectConflictError as exc: + raise _conflict(str(exc)) + except ProjectNotFoundError: + raise _not_found("Project was not found.") + except ValueError as exc: + raise _bad_request(str(exc)) + return {**projects_response(), "project": project.to_api_dict()} + + @app.post("/projects/delete") + def delete_project_endpoint(request: ProjectDeleteRequest) -> dict[str, object]: + try: + with recording_manager.project_deletion_guard(request.project_id): + replacement_project_id = default_store().delete_project( + request.project_id, + expected_revision=request.expected_revision, + ) + except ProjectConflictError as exc: + raise _conflict(str(exc)) + except ProjectNotFoundError: + raise _not_found("Project was not found.") + except ValueError as exc: + raise _bad_request(str(exc)) + return {**projects_response(), "deleted_project_id": request.project_id, "replacement_project_id": replacement_project_id} + @app.get("/diagnostics") - def diagnostics() -> dict[str, Any]: - return create_diagnostics() + def diagnostics(x_opsmineflow_project: str = Header(default="")) -> dict[str, Any]: + store = project_store(x_opsmineflow_project) + return project_response(store, create_diagnostics(store)) @app.post("/diagnostics/checks") - def diagnostics_checks() -> dict[str, Any]: - return run_diagnostic_checks() + def diagnostics_checks(x_opsmineflow_project: str = Header(default="")) -> dict[str, Any]: + store = project_store(x_opsmineflow_project) + return project_response(store, run_diagnostic_checks()) @app.get("/settings") - def settings() -> dict[str, object]: - return default_store().get_settings() + def settings(x_opsmineflow_project: str = Header(default="")) -> dict[str, object]: + store = project_store(x_opsmineflow_project) + return project_response(store, store.get_settings()) @app.get("/import/history") - def import_history() -> list[dict[str, object]]: - return default_store().list_import_history() + def import_history(x_opsmineflow_project: str = Header(default="")) -> dict[str, object]: + store = project_store(x_opsmineflow_project) + return project_response(store, {"imports": store.list_import_history()}) @app.get("/recording/status") - def recording_status() -> dict[str, Any]: - return recording_manager.status() + def recording_status(x_opsmineflow_project: str = Header(default="")) -> dict[str, Any]: + store = project_store(x_opsmineflow_project) + return project_response(store, recording_manager.status(store.project_id)) @app.post("/recording/start") - def recording_start(request: RecordingStartRequest) -> dict[str, Any]: + def recording_start( + request: RecordingStartRequest, + x_opsmineflow_project: str = Header(default=""), + ) -> dict[str, Any]: try: - return recording_manager.start(request.case_id, request.activity_label, request.consent) + store = project_store(x_opsmineflow_project, expected_revision=request.expected_revision) + return project_response( + store, + recording_manager.start(request.case_id, request.activity_label, request.consent, store=store), + ) except (ValueError, RuntimeError) as exc: raise _bad_request(str(exc)) @app.post("/recording/stop") - def recording_stop() -> dict[str, Any]: - return recording_manager.stop(default_store()) + def recording_stop( + request: ProjectMutationRequest, + x_opsmineflow_project: str = Header(default=""), + ) -> dict[str, Any]: + # Ingested recording events advance the project revision. Stopping the + # session must resolve the current revision after validating the + # immutable project context, otherwise a start-time revision traps the + # user in an un-stoppable active session. + store = project_store(x_opsmineflow_project) + return project_response(store, recording_manager.stop(store)) @app.post("/recording/pause") - def recording_pause(request: RecordingPauseRequest) -> dict[str, Any]: + def recording_pause( + request: RecordingPauseRequest, + x_opsmineflow_project: str = Header(default=""), + ) -> dict[str, Any]: try: - return recording_manager.pause(request.reason) + store = project_store(x_opsmineflow_project) + return project_response(store, recording_manager.pause(request.reason, project_id=store.project_id)) except ValueError as exc: raise _bad_request(str(exc)) @app.post("/recording/resume") - def recording_resume() -> dict[str, Any]: + def recording_resume( + request: ProjectMutationRequest, + x_opsmineflow_project: str = Header(default=""), + ) -> dict[str, Any]: try: - return recording_manager.resume() + store = project_store(x_opsmineflow_project) + return project_response(store, recording_manager.resume(project_id=store.project_id)) except ValueError as exc: raise _bad_request(str(exc)) @@ -1603,224 +1753,342 @@ def recording_heartbeat( raise _forbidden(str(exc)) @app.post("/import/preview") - def import_preview(request: ImportPreviewRequest) -> dict[str, Any]: + def import_preview( + request: ImportPreviewRequest, + x_opsmineflow_project: str = Header(default=""), + ) -> dict[str, Any]: try: - return create_import_preview( + store = project_store(x_opsmineflow_project, expected_revision=request.expected_revision) + return project_response(store, create_import_preview( request.format, request.path, request.mapping, request.date_format, request.timezone, - ) + )) except FileNotFoundError as exc: raise _not_found(str(exc)) except ValueError as exc: raise _bad_request(str(exc)) @app.post("/import/activitywatch-preview") - def preview_activitywatch(request: ActivityWatchPreviewRequest) -> dict[str, Any]: + def preview_activitywatch( + request: ActivityWatchPreviewRequest, + x_opsmineflow_project: str = Header(default=""), + ) -> dict[str, Any]: try: - return create_activitywatch_preview(request.enabled, request.base_url) + store = project_store(x_opsmineflow_project, expected_revision=request.expected_revision) + return project_response(store, create_activitywatch_preview(request.enabled, request.base_url, store)) except ValueError as exc: raise _bad_request(str(exc)) @app.post("/settings") - def update_settings(request: SettingsRequest) -> dict[str, object]: - return default_store().update_settings(request.model_dump(exclude_none=True)) + def update_settings( + request: SettingsRequest, + x_opsmineflow_project: str = Header(default=""), + ) -> dict[str, object]: + store = project_store(x_opsmineflow_project, expected_revision=request.expected_revision) + updates = request.model_dump(exclude_none=True) + updates.pop("expected_revision", None) + return project_response(store, store.update_settings(updates)) @app.post("/import/csv") - def import_csv(request: PathImportRequest) -> dict[str, Any]: + def import_csv( + request: PathImportRequest, + x_opsmineflow_project: str = Header(default=""), + ) -> dict[str, Any]: try: - return import_path_into_store( + store = project_store(x_opsmineflow_project, expected_revision=request.expected_revision) + return project_response(store, import_path_into_store( "csv", request.path, mapping=request.mapping, date_format=request.date_format, timezone_name=request.timezone, - ) + store=store, + )) except FileNotFoundError as exc: raise _not_found(str(exc)) @app.post("/import/json") - def import_json(request: PathImportRequest) -> dict[str, Any]: + def import_json( + request: PathImportRequest, + x_opsmineflow_project: str = Header(default=""), + ) -> dict[str, Any]: try: - return import_path_into_store("json", request.path) + store = project_store(x_opsmineflow_project, expected_revision=request.expected_revision) + return project_response(store, import_path_into_store("json", request.path, store=store)) except FileNotFoundError as exc: raise _not_found(str(exc)) @app.post("/import/activitywatch-local") - def import_activitywatch(request: ActivityWatchImportRequest) -> dict[str, Any]: + def import_activitywatch( + request: ActivityWatchImportRequest, + x_opsmineflow_project: str = Header(default=""), + ) -> dict[str, Any]: try: - return import_activitywatch_into_store(request.enabled, request.base_url, request.mode) + store = project_store(x_opsmineflow_project, expected_revision=request.expected_revision) + return project_response( + store, + import_activitywatch_into_store(request.enabled, request.base_url, request.mode, store=store), + ) except ValueError as exc: raise _bad_request(str(exc)) @app.get("/events") - def events() -> list[dict[str, Any]]: - return create_event_page(0, MAX_EVENT_PAGE_SIZE)["events"] + def events(x_opsmineflow_project: str = Header(default="")) -> dict[str, Any]: + store = project_store(x_opsmineflow_project) + return project_response(store, {"events": create_event_page(0, MAX_EVENT_PAGE_SIZE, store)["events"]}) @app.post("/events/page") - def event_page(request: EventPageRequest) -> dict[str, Any]: + def event_page( + request: EventPageRequest, + x_opsmineflow_project: str = Header(default=""), + ) -> dict[str, Any]: try: - return create_event_page(request.offset, request.limit) + store = project_store(x_opsmineflow_project, expected_revision=request.expected_revision) + return project_response(store, create_event_page(request.offset, request.limit, store)) except ValueError as exc: raise _bad_request(str(exc)) @app.post("/events/label") - def label_event(request: LabelRequest) -> dict[str, Any]: + def label_event( + request: LabelRequest, + x_opsmineflow_project: str = Header(default=""), + ) -> dict[str, Any]: try: - default_store().set_label(request.event_id, request.label) + store = project_store(x_opsmineflow_project, expected_revision=request.expected_revision) + store.set_label(request.event_id, request.label) except KeyError: raise _not_found("Event was not found") - return {"event_id": request.event_id, "label": request.label} + return project_response(store, {"event_id": request.event_id, "label": request.label}) @app.post("/events/activity") - def update_event_activity(request: EventActivityUpdateRequest) -> dict[str, Any]: + def update_event_activity( + request: EventActivityUpdateRequest, + x_opsmineflow_project: str = Header(default=""), + ) -> dict[str, Any]: try: - return {"event": default_store().update_event_activity(request.event_id, request.activity)} + store = project_store(x_opsmineflow_project, expected_revision=request.expected_revision) + return project_response(store, {"event": store.update_event_activity(request.event_id, request.activity)}) except KeyError: raise _not_found("Event was not found") + except ProjectConflictError as exc: + raise _conflict(str(exc)) except ValueError as exc: raise _bad_request(str(exc)) @app.post("/events/exclude") - def exclude_event(request: EventExcludeRequest) -> dict[str, Any]: + def exclude_event( + request: EventExcludeRequest, + x_opsmineflow_project: str = Header(default=""), + ) -> dict[str, Any]: try: - return default_store().exclude_event(request.event_id) + store = project_store(x_opsmineflow_project, expected_revision=request.expected_revision) + return project_response(store, store.exclude_event(request.event_id)) except KeyError: raise _not_found("Event was not found") + except ProjectConflictError as exc: + raise _conflict(str(exc)) @app.post("/events/quality-review") - def review_event_quality(request: EventQualityReviewRequest) -> dict[str, Any]: + def review_event_quality( + request: EventQualityReviewRequest, + x_opsmineflow_project: str = Header(default=""), + ) -> dict[str, Any]: try: - return default_store().set_event_quality_review(request.event_id, request.status) + store = project_store(x_opsmineflow_project, expected_revision=request.expected_revision) + return project_response(store, store.set_event_quality_review(request.event_id, request.status)) except KeyError: raise _not_found("Event was not found") + except ProjectConflictError as exc: + raise _conflict(str(exc)) except ValueError as exc: raise _bad_request(str(exc)) @app.post("/events/case-correlation") - def update_event_case_correlation(request: EventCaseCorrelationUpdateRequest) -> dict[str, Any]: + def update_event_case_correlation( + request: EventCaseCorrelationUpdateRequest, + x_opsmineflow_project: str = Header(default=""), + ) -> dict[str, Any]: try: - event = default_store().update_event_case_correlation(request.event_id, request.case_id, request.reason) + store = project_store(x_opsmineflow_project, expected_revision=request.expected_revision) + event = store.update_event_case_correlation(request.event_id, request.case_id, request.reason) except KeyError: raise _not_found("Event was not found") + except ProjectConflictError as exc: + raise _conflict(str(exc)) except ValueError as exc: raise _bad_request(str(exc)) - return {"event": event_to_api_dict(event, default_store().get_settings())} + return project_response(store, {"event": event_to_api_dict(event, store.get_settings())}) @app.post("/events/split") - def split_event(request: EventSplitRequest) -> dict[str, Any]: + def split_event( + request: EventSplitRequest, + x_opsmineflow_project: str = Header(default=""), + ) -> dict[str, Any]: try: - return default_store().split_event( + store = project_store(x_opsmineflow_project, expected_revision=request.expected_revision) + return project_response(store, store.split_event( request.event_id, request.split_after_seconds, request.first_activity, request.second_activity, - ) + )) except KeyError: raise _not_found("Event was not found") + except ProjectConflictError as exc: + raise _conflict(str(exc)) except ValueError as exc: raise _bad_request(str(exc)) @app.post("/events/merge") - def merge_events(request: EventMergeRequest) -> dict[str, Any]: + def merge_events( + request: EventMergeRequest, + x_opsmineflow_project: str = Header(default=""), + ) -> dict[str, Any]: try: - return default_store().merge_adjacent_events( + store = project_store(x_opsmineflow_project, expected_revision=request.expected_revision) + return project_response(store, store.merge_adjacent_events( request.first_event_id, request.second_event_id, request.activity, - ) + )) except KeyError: raise _not_found("Event was not found") + except ProjectConflictError as exc: + raise _conflict(str(exc)) except ValueError as exc: raise _bad_request(str(exc)) @app.post("/data/delete") - def delete_data(x_opsmineflow_delete_challenge: str = Header(default="")) -> dict[str, Any]: + def delete_data( + request: ProjectMutationRequest, + x_opsmineflow_delete_challenge: str = Header(default=""), + x_opsmineflow_project: str = Header(default=""), + ) -> dict[str, Any]: if not DELETE_CHALLENGES.consume(x_opsmineflow_delete_challenge): raise _forbidden("delete challenge is invalid or expired") - recording_manager.stop(default_store(), record_import=False) - default_store().clear() - return {"deleted": True} + store = project_store(x_opsmineflow_project, expected_revision=request.expected_revision) + if recording_manager.status(store.project_id).get("active"): + recording_manager.stop(store, record_import=False) + store.clear() + return project_response(store, {"deleted": True}) @app.post("/data/delete/challenge") def delete_challenge() -> dict[str, str]: return {"challenge": DELETE_CHALLENGES.issue()} @app.get("/analytics/summary") - def analytics_summary() -> dict[str, Any]: - return create_summary() + def analytics_summary(x_opsmineflow_project: str = Header(default="")) -> dict[str, Any]: + store = project_store(x_opsmineflow_project) + return project_response(store, create_summary(store)) @app.get("/analytics/app-switching") - def analytics_app_switching() -> dict[str, Any]: - return create_app_switching() + def analytics_app_switching(x_opsmineflow_project: str = Header(default="")) -> dict[str, Any]: + store = project_store(x_opsmineflow_project) + return project_response(store, create_app_switching(store)) @app.get("/analytics/automation-candidates") - def analytics_automation_candidates() -> dict[str, Any]: - return create_automation_candidates() + def analytics_automation_candidates(x_opsmineflow_project: str = Header(default="")) -> dict[str, Any]: + store = project_store(x_opsmineflow_project) + return project_response(store, create_automation_candidates(store)) @app.get("/analytics/event-quality") - def analytics_event_quality() -> dict[str, Any]: - return create_event_quality_report() + def analytics_event_quality(x_opsmineflow_project: str = Header(default="")) -> dict[str, Any]: + store = project_store(x_opsmineflow_project) + return project_response(store, create_event_quality_report(store)) @app.post("/automation/review") - def automation_review(request: AutomationReviewRequest) -> dict[str, str]: + def automation_review( + request: AutomationReviewRequest, + x_opsmineflow_project: str = Header(default=""), + ) -> dict[str, Any]: try: - return default_store().set_automation_review(request.activity, request.status, request.note) + store = project_store(x_opsmineflow_project, expected_revision=request.expected_revision) + return project_response(store, store.set_automation_review(request.activity, request.status, request.note)) + except ProjectConflictError as exc: + raise _conflict(str(exc)) except ValueError as exc: raise _bad_request(str(exc)) @app.get("/analytics/process-map") - def analytics_process_map() -> dict[str, Any]: - return create_process_map() + def analytics_process_map(x_opsmineflow_project: str = Header(default="")) -> dict[str, Any]: + store = project_store(x_opsmineflow_project) + return project_response(store, create_process_map(store)) @app.get("/reports/markdown") - def report_markdown() -> dict[str, str]: - return {"markdown": create_markdown_report()} + def report_markdown(x_opsmineflow_project: str = Header(default="")) -> dict[str, Any]: + store = project_store(x_opsmineflow_project) + return project_response(store, {"markdown": create_markdown_report(store)}) @app.post("/export/mermaid") - def export_mermaid_endpoint() -> dict[str, str]: - return {"mermaid": str(create_export_artifact("mermaid")["content"])} + def export_mermaid_endpoint(x_opsmineflow_project: str = Header(default="")) -> dict[str, Any]: + store = project_store(x_opsmineflow_project) + return project_response(store, {"mermaid": str(create_export_artifact("mermaid", store)["content"])}) @app.post("/export/drawio") - def export_drawio_endpoint() -> dict[str, str]: - return {"drawio": str(create_export_artifact("drawio")["content"])} + def export_drawio_endpoint(x_opsmineflow_project: str = Header(default="")) -> dict[str, Any]: + store = project_store(x_opsmineflow_project) + return project_response(store, {"drawio": str(create_export_artifact("drawio", store)["content"])}) @app.post("/export/svg") - def export_svg_endpoint() -> dict[str, str]: - return {"status": "planned", "message": "SVG export will use a local renderer."} + def export_svg_endpoint(x_opsmineflow_project: str = Header(default="")) -> dict[str, Any]: + store = project_store(x_opsmineflow_project) + return project_response(store, {"status": "planned", "message": "SVG export will use a local renderer."}) @app.post("/export/csv") - def export_csv_endpoint() -> dict[str, Any]: - artifact = create_export_artifact("csv") + def export_csv_endpoint(x_opsmineflow_project: str = Header(default="")) -> dict[str, Any]: + store = project_store(x_opsmineflow_project) + artifact = create_export_artifact("csv", store) content = artifact["content"] if not isinstance(content, bytes): raise RuntimeError("CSV export must be a ZIP bundle.") - return { - "filename": artifact["filename"], - "zip_base64": base64.b64encode(content).decode("ascii"), - } + return project_response( + store, + {"filename": artifact["filename"], "zip_base64": base64.b64encode(content).decode("ascii")}, + ) @app.post("/export/json") - def export_json_endpoint() -> dict[str, Any]: - return {"json": str(create_export_artifact("json")["content"])} + def export_json_endpoint(x_opsmineflow_project: str = Header(default="")) -> dict[str, Any]: + store = project_store(x_opsmineflow_project) + return project_response(store, {"json": str(create_export_artifact("json", store)["content"])}) @app.post("/export/llm-handoff") - def export_llm_handoff_endpoint() -> dict[str, str]: - return export_llm_handoff_payload() + def export_llm_handoff_endpoint(x_opsmineflow_project: str = Header(default="")) -> dict[str, Any]: + store = project_store(x_opsmineflow_project) + return project_response(store, export_llm_handoff_payload(store)) @app.post("/export/preview") - def export_preview_endpoint(request: ExportPreviewRequest) -> dict[str, Any]: + def export_preview_endpoint( + request: ExportPreviewRequest, + x_opsmineflow_project: str = Header(default=""), + ) -> dict[str, Any]: try: - artifact = create_export_artifact(request.format) + store = project_store(x_opsmineflow_project, expected_revision=request.expected_revision) + artifact = create_export_artifact(request.format, store) except ValueError as exc: raise _bad_request(str(exc)) - return {key: artifact[key] for key in ("format", "filename", "byte_size", "preview", "confidential_count", "warning")} + return project_response( + store, + {key: artifact[key] for key in ("format", "filename", "byte_size", "preview", "confidential_count", "warning")}, + ) @app.post("/export/save") - def export_save_endpoint(request: ExportSaveRequest) -> dict[str, Any]: + def export_save_endpoint( + request: ExportSaveRequest, + x_opsmineflow_project: str = Header(default=""), + ) -> dict[str, Any]: try: - return save_export_artifact(request.format, request.path, overwrite_confirmed=request.overwrite_confirmed) + store = project_store(x_opsmineflow_project, expected_revision=request.expected_revision) + return project_response( + store, + save_export_artifact( + request.format, + request.path, + overwrite_confirmed=request.overwrite_confirmed, + store=store, + ), + ) except ValueError as exc: raise _bad_request(str(exc)) diff --git a/services/local-api/src/opsmineflow_api/auth.py b/services/local-api/src/opsmineflow_api/auth.py index 6198a43..313974d 100644 --- a/services/local-api/src/opsmineflow_api/auth.py +++ b/services/local-api/src/opsmineflow_api/auth.py @@ -10,6 +10,7 @@ API_SESSION_HEADER = "X-OpsMineFlow-Api-Session" DELETE_CHALLENGE_HEADER = "X-OpsMineFlow-Delete-Challenge" +PROJECT_HEADER = "X-OpsMineFlow-Project" RUNTIME_PROBE_CHALLENGE_HEADER = "X-OpsMineFlow-Runtime-Probe-Challenge" MAX_REQUEST_BODY_BYTES = 1_048_576 DELETE_CHALLENGE_TTL_SECONDS = 60 @@ -24,6 +25,7 @@ } PROTECTED_ROUTES = { ("GET", "/diagnostics"), + ("GET", "/projects"), ("GET", "/settings"), ("GET", "/import/history"), ("GET", "/recording/status"), @@ -35,6 +37,10 @@ ("GET", "/analytics/process-map"), ("GET", "/reports/markdown"), ("POST", "/diagnostics/checks"), + ("POST", "/projects"), + ("POST", "/projects/select"), + ("POST", "/projects/rename"), + ("POST", "/projects/delete"), ("POST", "/recording/start"), ("POST", "/recording/stop"), ("POST", "/recording/pause"), @@ -74,6 +80,7 @@ "Content-Type", API_SESSION_HEADER, DELETE_CHALLENGE_HEADER, + PROJECT_HEADER, RUNTIME_PROBE_CHALLENGE_HEADER, "Access-Control-Request-Method", "Access-Control-Request-Headers", @@ -169,7 +176,9 @@ def _authorize_preflight( for value in headers.get("Access-Control-Request-Headers", "").split(",") if value.strip() } - if not requested_headers.issubset({"content-type", DELETE_CHALLENGE_HEADER.lower()}): + if not requested_headers.issubset( + {"content-type", DELETE_CHALLENGE_HEADER.lower(), PROJECT_HEADER.lower()} + ): raise RequestRejected(400, "local API header is not allowed") def _authorize_metadata( diff --git a/services/local-api/src/opsmineflow_api/migrations.py b/services/local-api/src/opsmineflow_api/migrations.py index b0e3524..a155afe 100644 --- a/services/local-api/src/opsmineflow_api/migrations.py +++ b/services/local-api/src/opsmineflow_api/migrations.py @@ -1,6 +1,7 @@ from __future__ import annotations import hashlib +import json import os import secrets import sqlite3 @@ -15,9 +16,15 @@ import fcntl -CURRENT_SCHEMA_VERSION = 2 +CURRENT_SCHEMA_VERSION = 3 MAX_MIGRATION_BACKUPS = 3 _MIGRATION_LOCK = threading.RLock() +# The value is deliberately an opaque, stable UUID rather than a user-facing +# display name. A v3 migration creates this one project exactly once while it +# atomically moves the pre-project single dataset into the scoped tables. +LEGACY_PROJECT_ID = "b01eecad-1e18-5e88-bf34-8e8e8358cfcb" +LEGACY_PROJECT_DISPLAY_NAME = "Migrated data" +LEGACY_PROJECT_ORIGIN = "legacy_migration" _KNOWN_LEGACY_TABLES = frozenset( { "events", @@ -89,6 +96,73 @@ # import paths. The table layouts intentionally remain identical to v1. _SCHEMA_SIGNATURES[2] = _SCHEMA_SIGNATURES[1] +_SCHEMA_SIGNATURES[3] = { + "schema_migrations": _SCHEMA_SIGNATURES[1]["schema_migrations"], + "projects": ( + ( + ("project_id", "TEXT", 1, None, 1), + ("display_name", "TEXT", 1, None, 0), + ("origin", "TEXT", 1, None, 0), + ("created_at", "TEXT", 1, None, 0), + ("updated_at", "TEXT", 1, None, 0), + ("revision", "INTEGER", 1, "0", 0), + ), + ), + "workspace_metadata": ( + ( + ("key", "TEXT", 0, None, 1), + ("value", "TEXT", 1, None, 0), + ), + ), + "events": ( + ( + ("project_id", "TEXT", 1, None, 1), + ("event_id", "TEXT", 1, None, 2), + ("payload_json", "TEXT", 1, None, 0), + ), + ), + "manual_labels": ( + ( + ("project_id", "TEXT", 1, None, 1), + ("event_id", "TEXT", 1, None, 2), + ("label", "TEXT", 1, None, 0), + ), + ), + "settings": ( + ( + ("project_id", "TEXT", 1, None, 1), + ("key", "TEXT", 1, None, 2), + ("value_json", "TEXT", 1, None, 0), + ), + ), + "metadata": ( + ( + ("project_id", "TEXT", 1, None, 1), + ("key", "TEXT", 1, None, 2), + ("value", "TEXT", 1, None, 0), + ), + ), + "import_history": ( + ( + ("project_id", "TEXT", 1, None, 1), + ("id", "INTEGER", 1, None, 2), + ("source", "TEXT", 1, None, 0), + ("path", "TEXT", 1, None, 0), + ("event_count", "INTEGER", 1, None, 0), + ("imported_at", "TEXT", 1, None, 0), + ), + ), + "automation_reviews": ( + ( + ("project_id", "TEXT", 1, None, 1), + ("activity", "TEXT", 1, None, 2), + ("status", "TEXT", 1, None, 0), + ("note", "TEXT", 1, "''", 0), + ("updated_at", "TEXT", 1, None, 0), + ), + ), +} + class MigrationError(RuntimeError): """A database cannot be safely opened or migrated.""" @@ -130,6 +204,9 @@ def apply(self, connection: sqlite3.Connection) -> None: (_safe_import_display_name(str(source), str(path)), int(row_id)), ) continue + if step == "rebuild_as_project_scoped": + _rebuild_as_project_scoped(connection) + continue raise MigrationInvariantError(f"Unknown legacy migration step: {step}") @@ -191,6 +268,10 @@ def apply(self, connection: sqlite3.Connection) -> None: _MIGRATION_002_LEGACY_STEPS = ("redact_import_history_paths",) _MIGRATION_002_CHECKSUM = "77ecd83da344c9734128dfa62c8c85fd8c34652d2811d3f4b2d8bb5530dfdb17" +_MIGRATION_003_STATEMENTS: tuple[str, ...] = () +_MIGRATION_003_LEGACY_STEPS = ("rebuild_as_project_scoped",) +_MIGRATION_003_CHECKSUM = "96a37ab2bf12768fb045f3c09b16a74a591e92a4456536aa6da64147b12e77cd" + MIGRATIONS: tuple[Migration, ...] = ( Migration( version=1, @@ -206,6 +287,13 @@ def apply(self, connection: sqlite3.Connection) -> None: statements=_MIGRATION_002_STATEMENTS, legacy_steps=_MIGRATION_002_LEGACY_STEPS, ), + Migration( + version=3, + name="scope_records_to_projects", + checksum=_MIGRATION_003_CHECKSUM, + statements=_MIGRATION_003_STATEMENTS, + legacy_steps=_MIGRATION_003_LEGACY_STEPS, + ), ) @@ -218,6 +306,307 @@ def _safe_import_display_name(source: str, path_value: str) -> str: return name or "Imported file" +def _rebuild_as_project_scoped(connection: sqlite3.Connection) -> None: + """Move the v2 global dataset into one durable legacy project. + + SQLite cannot add composite primary keys or foreign keys with ``ALTER + TABLE``. The v3 migration therefore builds fully constrained replacement + tables, verifies a canonical before/after snapshot while still inside the + startup transaction, then swaps the tables in one commit. + """ + + before_snapshot = _legacy_dataset_snapshot(connection) + before_hash = _dataset_hash(before_snapshot) + before_counts = _dataset_counts(before_snapshot) + now = datetime.now(timezone.utc).isoformat() + + connection.execute( + """ + CREATE TABLE projects ( + project_id TEXT NOT NULL PRIMARY KEY, + display_name TEXT NOT NULL, + origin TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + revision INTEGER NOT NULL DEFAULT 0 CHECK (revision >= 0) + ) + """ + ) + connection.execute( + """ + CREATE TABLE workspace_metadata ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ) + """ + ) + connection.execute( + """ + CREATE TRIGGER workspace_metadata_active_project_insert + BEFORE INSERT ON workspace_metadata + WHEN NEW.key = 'active_project_id' + AND NOT EXISTS (SELECT 1 FROM projects WHERE project_id = NEW.value) + BEGIN + SELECT RAISE(ABORT, 'active project must exist'); + END + """ + ) + connection.execute( + """ + CREATE TRIGGER workspace_metadata_active_project_update + BEFORE UPDATE OF key, value ON workspace_metadata + WHEN NEW.key = 'active_project_id' + AND NOT EXISTS (SELECT 1 FROM projects WHERE project_id = NEW.value) + BEGIN + SELECT RAISE(ABORT, 'active project must exist'); + END + """ + ) + connection.execute( + """ + CREATE TRIGGER projects_active_project_delete + BEFORE DELETE ON projects + WHEN EXISTS ( + SELECT 1 FROM workspace_metadata + WHERE key = 'active_project_id' AND value = OLD.project_id + ) + BEGIN + SELECT RAISE(ABORT, 'active project cannot be deleted'); + END + """ + ) + connection.execute( + """ + CREATE TABLE events_v3 ( + project_id TEXT NOT NULL, + event_id TEXT NOT NULL, + payload_json TEXT NOT NULL, + PRIMARY KEY (project_id, event_id), + FOREIGN KEY (project_id) REFERENCES projects(project_id) ON DELETE RESTRICT + ) + """ + ) + connection.execute( + """ + CREATE TABLE manual_labels_v3 ( + project_id TEXT NOT NULL, + event_id TEXT NOT NULL, + label TEXT NOT NULL, + PRIMARY KEY (project_id, event_id), + FOREIGN KEY (project_id) REFERENCES projects(project_id) ON DELETE RESTRICT, + FOREIGN KEY (project_id, event_id) + REFERENCES events_v3(project_id, event_id) ON DELETE CASCADE + ) + """ + ) + connection.execute( + """ + CREATE TABLE settings_v3 ( + project_id TEXT NOT NULL, + key TEXT NOT NULL, + value_json TEXT NOT NULL, + PRIMARY KEY (project_id, key), + FOREIGN KEY (project_id) REFERENCES projects(project_id) ON DELETE RESTRICT + ) + """ + ) + connection.execute( + """ + CREATE TABLE metadata_v3 ( + project_id TEXT NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY (project_id, key), + FOREIGN KEY (project_id) REFERENCES projects(project_id) ON DELETE RESTRICT + ) + """ + ) + connection.execute( + """ + CREATE TABLE import_history_v3 ( + project_id TEXT NOT NULL, + id INTEGER NOT NULL, + source TEXT NOT NULL, + path TEXT NOT NULL, + event_count INTEGER NOT NULL, + imported_at TEXT NOT NULL, + PRIMARY KEY (project_id, id), + FOREIGN KEY (project_id) REFERENCES projects(project_id) ON DELETE RESTRICT + ) + """ + ) + connection.execute( + """ + CREATE TABLE automation_reviews_v3 ( + project_id TEXT NOT NULL, + activity TEXT NOT NULL, + status TEXT NOT NULL, + note TEXT NOT NULL DEFAULT '', + updated_at TEXT NOT NULL, + PRIMARY KEY (project_id, activity), + FOREIGN KEY (project_id) REFERENCES projects(project_id) ON DELETE RESTRICT + ) + """ + ) + + connection.execute( + """ + INSERT INTO projects(project_id, display_name, origin, created_at, updated_at, revision) + VALUES(?, ?, ?, ?, ?, 0) + """, + (LEGACY_PROJECT_ID, LEGACY_PROJECT_DISPLAY_NAME, LEGACY_PROJECT_ORIGIN, now, now), + ) + connection.execute( + "INSERT INTO events_v3(project_id, event_id, payload_json) " + "SELECT ?, event_id, payload_json FROM events", + (LEGACY_PROJECT_ID,), + ) + connection.execute( + "INSERT INTO manual_labels_v3(project_id, event_id, label) " + "SELECT ?, event_id, label FROM manual_labels", + (LEGACY_PROJECT_ID,), + ) + connection.execute( + "INSERT INTO settings_v3(project_id, key, value_json) " + "SELECT ?, key, value_json FROM settings", + (LEGACY_PROJECT_ID,), + ) + connection.execute( + "INSERT INTO metadata_v3(project_id, key, value) " + "SELECT ?, key, value FROM metadata", + (LEGACY_PROJECT_ID,), + ) + connection.execute( + "INSERT INTO import_history_v3(project_id, id, source, path, event_count, imported_at) " + "SELECT ?, id, source, path, event_count, imported_at FROM import_history", + (LEGACY_PROJECT_ID,), + ) + connection.execute( + "INSERT INTO automation_reviews_v3(project_id, activity, status, note, updated_at) " + "SELECT ?, activity, status, note, updated_at FROM automation_reviews", + (LEGACY_PROJECT_ID,), + ) + + after_snapshot = _project_scoped_dataset_snapshot(connection, LEGACY_PROJECT_ID, table_suffix="_v3") + after_hash = _dataset_hash(after_snapshot) + after_counts = _dataset_counts(after_snapshot) + if after_hash != before_hash or after_counts != before_counts: + raise MigrationError("Project migration verification failed; the legacy dataset was not copied exactly.") + + audit_values = ( + ("legacy_v2_before_hash", before_hash), + ("legacy_v2_before_counts", _canonical_json(before_counts)), + ("legacy_v3_after_hash", after_hash), + ("legacy_v3_after_counts", _canonical_json(after_counts)), + ("active_project_id", LEGACY_PROJECT_ID), + ) + connection.executemany("INSERT INTO workspace_metadata(key, value) VALUES(?, ?)", audit_values) + + # Drop the unscoped tables only after every row has been copied and + # verified. The transaction in ``migrate_database`` makes this swap atomic. + for table_name in ( + "manual_labels", + "automation_reviews", + "settings", + "metadata", + "import_history", + "events", + ): + connection.execute(f"DROP TABLE {table_name}") + for old_name, new_name in ( + ("events_v3", "events"), + ("manual_labels_v3", "manual_labels"), + ("settings_v3", "settings"), + ("metadata_v3", "metadata"), + ("import_history_v3", "import_history"), + ("automation_reviews_v3", "automation_reviews"), + ): + connection.execute(f"ALTER TABLE {old_name} RENAME TO {new_name}") + + +def _legacy_dataset_snapshot(connection: sqlite3.Connection) -> dict[str, list[list[object]]]: + return { + "events": _fetch_rows(connection, "SELECT event_id, payload_json FROM events ORDER BY event_id"), + "manual_labels": _fetch_rows(connection, "SELECT event_id, label FROM manual_labels ORDER BY event_id"), + "settings": _fetch_rows(connection, "SELECT key, value_json FROM settings ORDER BY key"), + "metadata": _fetch_rows(connection, "SELECT key, value FROM metadata ORDER BY key"), + "import_history": _fetch_rows( + connection, + "SELECT id, source, path, event_count, imported_at FROM import_history ORDER BY id", + ), + "automation_reviews": _fetch_rows( + connection, + "SELECT activity, status, note, updated_at FROM automation_reviews ORDER BY activity", + ), + } + + +def _project_scoped_dataset_snapshot( + connection: sqlite3.Connection, + project_id: str, + *, + table_suffix: str = "", +) -> dict[str, list[list[object]]]: + return { + "events": _fetch_rows( + connection, + f"SELECT event_id, payload_json FROM events{table_suffix} WHERE project_id = ? ORDER BY event_id", + (project_id,), + ), + "manual_labels": _fetch_rows( + connection, + f"SELECT event_id, label FROM manual_labels{table_suffix} WHERE project_id = ? ORDER BY event_id", + (project_id,), + ), + "settings": _fetch_rows( + connection, + f"SELECT key, value_json FROM settings{table_suffix} WHERE project_id = ? ORDER BY key", + (project_id,), + ), + "metadata": _fetch_rows( + connection, + f"SELECT key, value FROM metadata{table_suffix} WHERE project_id = ? ORDER BY key", + (project_id,), + ), + "import_history": _fetch_rows( + connection, + ( + f"SELECT id, source, path, event_count, imported_at FROM import_history{table_suffix} " + "WHERE project_id = ? ORDER BY id" + ), + (project_id,), + ), + "automation_reviews": _fetch_rows( + connection, + ( + f"SELECT activity, status, note, updated_at FROM automation_reviews{table_suffix} " + "WHERE project_id = ? ORDER BY activity" + ), + (project_id,), + ), + } + + +def _fetch_rows( + connection: sqlite3.Connection, + statement: str, + parameters: tuple[object, ...] = (), +) -> list[list[object]]: + return [list(row) for row in connection.execute(statement, parameters).fetchall()] + + +def _dataset_counts(snapshot: dict[str, list[list[object]]]) -> dict[str, int]: + return {name: len(rows) for name, rows in snapshot.items()} + + +def _dataset_hash(snapshot: dict[str, list[list[object]]]) -> str: + return hashlib.sha256(_canonical_json(snapshot).encode("utf-8")).hexdigest() + + +def _canonical_json(value: object) -> str: + return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True) + + @dataclass(frozen=True) class MigrationReport: previous_version: int diff --git a/services/local-api/src/opsmineflow_api/recording.py b/services/local-api/src/opsmineflow_api/recording.py index 5575db4..ea852ce 100644 --- a/services/local-api/src/opsmineflow_api/recording.py +++ b/services/local-api/src/opsmineflow_api/recording.py @@ -8,6 +8,7 @@ import threading import time import uuid +from contextlib import contextmanager from datetime import datetime, timezone from pathlib import Path from typing import Any @@ -94,11 +95,12 @@ def availability(self) -> dict[str, Any]: "rate_limit_per_minute": INGEST_RATE_LIMIT, } - def status(self) -> dict[str, Any]: + def status(self, project_id: str | None = None) -> dict[str, Any]: with self._lock: self._refresh_process_state() availability = self.availability() - if self._session is None: + wrong_project = project_id is not None and self._session is not None and self._session.get("project_id") != project_id + if self._session is None or wrong_project: return { **availability, "active": False, @@ -114,12 +116,19 @@ def status(self) -> dict[str, Any]: "current_app": "", "recorded_events": 0, "last_heartbeat_at": "", - "last_error": self._last_error, + "last_error": "" if wrong_project else self._last_error, "capture_scope": "frontmost_app_only", } return {**availability, **self._session, "last_error": self._last_error, "capture_scope": "frontmost_app_only"} - def start(self, case_id: str, activity_label: str, consent: bool) -> dict[str, Any]: + def start( + self, + case_id: str, + activity_label: str, + consent: bool, + *, + store: EventStore | None = None, + ) -> dict[str, Any]: with self._lock: self._refresh_process_state() if self._session and self._session.get("active"): @@ -133,6 +142,14 @@ def start(self, case_id: str, activity_label: str, consent: bool) -> dict[str, A availability = self.availability() if not availability["available"]: raise RuntimeError(str(availability["remediation"])) + active_store = store or default_store() + # The project may have been deleted after the request resolved its + # scoped store but before this lifecycle lock was acquired. + project_id = ( + active_store.get_project().project_id + if store is not None + else active_store.snapshot().project_id + ) session_id = f"rec_{uuid.uuid4().hex}" runtime_dir = default_data_dir() / "runtime" @@ -166,6 +183,7 @@ def start(self, case_id: str, activity_label: str, consent: bool) -> dict[str, A self._session = { "active": True, "session_id": session_id, + "project_id": project_id, "case_id": normalized_case[:200], "activity_label": normalized_activity[:200], "started_at": _now_iso(), @@ -178,30 +196,32 @@ def start(self, case_id: str, activity_label: str, consent: bool) -> dict[str, A "recorded_events": 0, "last_heartbeat_at": "", } - return self.status() + return self.status(project_id) - def pause(self, reason: str = "") -> dict[str, Any]: + def pause(self, reason: str = "", *, project_id: str | None = None) -> dict[str, Any]: with self._lock: self._refresh_process_state() if self._session is None or not self._session.get("active"): raise ValueError("No recording session is active.") + self._assert_session_project(project_id) if self._session.get("paused"): - return self.status() + return self.status(project_id) self._session["paused"] = True self._session["paused_at"] = _now_iso() self._session["pause_reason"] = reason.strip()[:200] or "manual_pause" self._session["current_app"] = "" - return self.status() + return self.status(project_id) - def resume(self) -> dict[str, Any]: + def resume(self, *, project_id: str | None = None) -> dict[str, Any]: with self._lock: self._refresh_process_state() if self._session is None or not self._session.get("active"): raise ValueError("No recording session is active.") + self._assert_session_project(project_id) if not self._session.get("paused"): - return self.status() + return self.status(project_id) self._close_pause_interval() - return self.status() + return self.status(project_id) def heartbeat(self, token: str, session_id: str, current_app: str) -> dict[str, Any]: with self._lock: @@ -226,7 +246,7 @@ def ingest(self, token: str, payload: dict[str, Any], store: EventStore | None = self._session["last_heartbeat_at"] = _now_iso() return {"accepted": True, "appended": 0, "paused": True, "event_id": ""} event = native_event_from_payload(payload, self._session) - active_store = store or default_store() + active_store = self._session_store(store) appended = active_store.append([event]) # Storage may reject a write. Do not consume a sequence or rate # budget until its durable outcome is known, so a retry can replay. @@ -241,6 +261,7 @@ def stop(self, store: EventStore | None = None, *, record_import: bool = True) - self._refresh_process_state() if self._session is None or not self._session.get("active"): return self.status() + self._assert_session_project(store.snapshot().project_id if store is not None else None) process = self._process if self._stop_file is not None: self._stop_file.touch(exist_ok=True) @@ -263,7 +284,7 @@ def stop(self, store: EventStore | None = None, *, record_import: bool = True) - recorded_events = int(self._session.get("recorded_events", 0)) if record_import and recorded_events > 0: try: - (store or default_store()).record_import( + self._session_store(store).record_import( "native_recording", str(self._session["case_id"]), recorded_events, @@ -275,7 +296,25 @@ def stop(self, store: EventStore | None = None, *, record_import: bool = True) - self._session["active"] = False self._session["current_app"] = "" self._cleanup_process() - return self.status() + return self.status(str(self._session.get("project_id") or "")) + + def ensure_project_deletable(self, project_id: str) -> None: + """A project cannot disappear while its native collector still owns a session.""" + + with self._lock: + self._refresh_process_state() + if self._session is not None and self._session.get("active") and self._session.get("project_id") == project_id: + raise ValueError("Stop the active recording before deleting its project.") + + @contextmanager + def project_deletion_guard(self, project_id: str): + """Serialize project deletion with recording start for that project.""" + + with self._lock: + self._refresh_process_state() + if self._session is not None and self._session.get("active") and self._session.get("project_id") == project_id: + raise ValueError("Stop the active recording before deleting its project.") + yield def shutdown(self) -> None: try: @@ -294,6 +333,30 @@ def _authorize(self, token: str, session_id: str) -> None: self._last_error = "Recording session token expired." raise PermissionError("Recording session token expired.") + def _assert_session_project(self, project_id: str | None) -> None: + if ( + project_id is not None + and self._session is not None + and self._session.get("project_id") + and self._session.get("project_id") != project_id + ): + raise ValueError("Recording session belongs to a different project.") + + def _session_store(self, store: EventStore | None) -> EventStore: + if self._session is None: + raise ValueError("No recording session is active.") + project_id = str(self._session.get("project_id") or "") + workspace = store or default_store() + if not project_id: + # Legacy in-memory tests and interrupted pre-v1 sessions lack a + # binding. Adopt the caller's immutable store once, then preserve + # that value for all subsequent ingest/stop retries. + project_id = workspace.snapshot().project_id + self._session["project_id"] = project_id + if workspace.snapshot().project_id == project_id: + return workspace + return workspace.for_project(project_id) + def _check_rate_limit(self) -> float: now = time.monotonic() cutoff = now - INGEST_RATE_WINDOW_SECONDS diff --git a/services/local-api/src/opsmineflow_api/server.py b/services/local-api/src/opsmineflow_api/server.py index 9a7a6b1..b824bd2 100644 --- a/services/local-api/src/opsmineflow_api/server.py +++ b/services/local-api/src/opsmineflow_api/server.py @@ -30,17 +30,20 @@ export_llm_handoff_payload, import_activitywatch_into_store, import_path_into_store, + project_response, + projects_response, run_diagnostic_checks, save_export_artifact, ) from .auth import ( DELETE_CHALLENGE_HEADER, + PROJECT_HEADER, RUNTIME_PROBE_CHALLENGE_HEADER, LocalApiPolicy, RequestRejected, ) from .recording import recording_manager -from .storage import StorageCommitError, default_store +from .storage import ProjectConflictError, ProjectNotFoundError, StorageCommitError, default_store HOST = os.environ.get("OPSMINEFLOW_API_HOST", "127.0.0.1") PORT = int(os.environ.get("OPSMINEFLOW_API_PORT", "8765")) @@ -66,38 +69,46 @@ def do_GET(self) -> None: if path == "/runtime/health": self._send_json(create_runtime_health(self.headers.get(RUNTIME_PROBE_CHALLENGE_HEADER, ""))) return + if path == "/projects": + self._send_json(projects_response()) + return + try: + store = self._project_store() + except (ValueError, ProjectNotFoundError) as exc: + self._send_json({"error": str(exc) or "Project was not found."}, status=400 if isinstance(exc, ValueError) else 404) + return if path == "/diagnostics": - self._send_json(create_diagnostics()) + self._send_json(project_response(store, create_diagnostics(store))) return if path == "/settings": - self._send_json(default_store().get_settings()) + self._send_json(project_response(store, store.get_settings())) return if path == "/import/history": - self._send_json(default_store().list_import_history()) + self._send_json(project_response(store, {"imports": store.list_import_history()})) return if path == "/recording/status": - self._send_json(recording_manager.status()) + self._send_json(project_response(store, recording_manager.status(store.project_id))) return if path == "/events": - self._send_json(create_event_page(0, 500)["events"]) + self._send_json(project_response(store, {"events": create_event_page(0, 500, store)["events"]})) return if path == "/analytics/summary": - self._send_json(create_summary()) + self._send_json(project_response(store, create_summary(store))) return if path == "/analytics/app-switching": - self._send_json(create_app_switching()) + self._send_json(project_response(store, create_app_switching(store))) return if path == "/analytics/automation-candidates": - self._send_json(create_automation_candidates()) + self._send_json(project_response(store, create_automation_candidates(store))) return if path == "/analytics/event-quality": - self._send_json(create_event_quality_report()) + self._send_json(project_response(store, create_event_quality_report(store))) return if path == "/analytics/process-map": - self._send_json(create_process_map()) + self._send_json(project_response(store, create_process_map(store))) return if path == "/reports/markdown": - self._send_json({"markdown": create_markdown_report()}) + self._send_json(project_response(store, {"markdown": create_markdown_report(store)})) return self._send_json({"error": "not found"}, status=404) @@ -106,37 +117,84 @@ def do_POST(self) -> None: if not self._authorize_request(path): return if path == "/data/delete": - self._read_json() + payload = self._read_json() if not DELETE_CHALLENGES.consume(self.headers.get(DELETE_CHALLENGE_HEADER, "")): self._send_json({"error": "delete challenge is invalid or expired"}, status=403) return try: - recording_manager.stop(default_store(), record_import=False) - default_store().clear() + store = self._project_store(payload) + if recording_manager.status(store.project_id).get("active"): + recording_manager.stop(store, record_import=False) + store.clear() except StorageCommitError as exc: self._send_json({"error": exc.to_api_dict()}, status=503) return - self._send_json({"deleted": True}) + except ProjectConflictError as exc: + self._send_json({"error": str(exc)}, status=409) + return + except (ProjectNotFoundError, ValueError) as exc: + self._send_json({"error": str(exc)}, status=404 if isinstance(exc, ProjectNotFoundError) else 400) + return + self._send_json(project_response(store, {"deleted": True})) return try: payload = self._read_json() + if path == "/projects": + project = default_store().create_project(str(payload.get("display_name") or "")) + self._send_json({**projects_response(), "project": project.to_api_dict()}) + return + if path == "/projects/select": + project = default_store().select_project(str(payload.get("project_id") or "")) + self._send_json({**projects_response(), "project": project.to_api_dict()}) + return + if path == "/projects/rename": + project = default_store().rename_project( + str(payload.get("project_id") or ""), + str(payload.get("display_name") or ""), + expected_revision=payload.get("expected_revision"), + ) + self._send_json({**projects_response(), "project": project.to_api_dict()}) + return + if path == "/projects/delete": + project_id = str(payload.get("project_id") or "") + with recording_manager.project_deletion_guard(project_id): + replacement_project_id = default_store().delete_project( + project_id, + expected_revision=payload.get("expected_revision"), + ) + self._send_json( + { + **projects_response(), + "deleted_project_id": project_id, + "replacement_project_id": replacement_project_id, + } + ) + return if path == "/recording/start": + store = self._project_store(payload) self._send_json( - recording_manager.start( - str(payload.get("case_id") or ""), - str(payload.get("activity_label") or ""), - bool(payload.get("consent")), + project_response( + store, + recording_manager.start( + str(payload.get("case_id") or ""), + str(payload.get("activity_label") or ""), + bool(payload.get("consent")), + store=store, + ), ) ) return if path == "/recording/stop": - self._send_json(recording_manager.stop(default_store())) + store = self._project_store() + self._send_json(project_response(store, recording_manager.stop(store))) return if path == "/recording/pause": - self._send_json(recording_manager.pause(str(payload.get("reason") or ""))) + store = self._project_store() + self._send_json(project_response(store, recording_manager.pause(str(payload.get("reason") or ""), project_id=store.project_id))) return if path == "/recording/resume": - self._send_json(recording_manager.resume()) + store = self._project_store() + self._send_json(project_response(store, recording_manager.resume(project_id=store.project_id))) return if path == "/recording/events": self._send_json( @@ -156,78 +214,91 @@ def do_POST(self) -> None: ) ) return + if path == "/data/delete/challenge": + self._send_json({"challenge": DELETE_CHALLENGES.issue()}) + return + store = self._project_store(payload) if path == "/events/page": - self._send_json( + self._send_json(project_response( + store, create_event_page( int(payload.get("offset") or 0), int(payload.get("limit") or 250), - ) - ) + store, + ), + )) return if path == "/import/preview": - self._send_json( + self._send_json(project_response( + store, create_import_preview( str(payload.get("format") or ""), str(payload.get("path") or ""), payload.get("mapping") if isinstance(payload.get("mapping"), dict) else None, str(payload.get("date_format") or ""), str(payload.get("timezone") or "UTC"), - ) - ) + ), + )) return if path == "/import/activitywatch-preview": - self._send_json( + self._send_json(project_response( + store, create_activitywatch_preview( bool(payload.get("enabled")), str(payload.get("base_url") or "http://127.0.0.1:5600"), - ) - ) + store, + ), + )) return if path == "/import/csv": - self._send_json( + self._send_json(project_response( + store, import_path_into_store( "csv", str(payload.get("path") or ""), + store=store, mapping=payload.get("mapping") if isinstance(payload.get("mapping"), dict) else None, date_format=str(payload.get("date_format") or ""), timezone_name=str(payload.get("timezone") or "UTC"), - ) - ) + ), + )) return if path == "/import/json": - self._send_json(import_path_into_store("json", str(payload.get("path") or ""))) + self._send_json(project_response(store, import_path_into_store("json", str(payload.get("path") or ""), store=store))) return if path == "/import/activitywatch-local": - self._send_json( + self._send_json(project_response( + store, import_activitywatch_into_store( bool(payload.get("enabled")), str(payload.get("base_url") or "http://127.0.0.1:5600"), str(payload.get("mode") or "replace"), - ) - ) + store=store, + ), + )) return if path == "/events/label": try: - default_store().set_label(str(payload.get("event_id") or ""), str(payload.get("label") or "")) + store.set_label(str(payload.get("event_id") or ""), str(payload.get("label") or "")) except KeyError: self._send_json({"error": "Event was not found"}, status=404) return - self._send_json({"event_id": payload.get("event_id"), "label": payload.get("label")}) + self._send_json(project_response(store, {"event_id": payload.get("event_id"), "label": payload.get("label")})) return if path == "/events/activity": try: - event = default_store().update_event_activity( + event = store.update_event_activity( str(payload.get("event_id") or ""), str(payload.get("activity") or ""), ) except KeyError: self._send_json({"error": "Event was not found"}, status=404) return - self._send_json({"event": event}) + self._send_json(project_response(store, {"event": event})) return if path == "/events/case-correlation": try: - event = default_store().update_event_case_correlation( + event = store.update_event_case_correlation( str(payload.get("event_id") or ""), str(payload.get("case_id") or ""), str(payload.get("reason") or ""), @@ -240,21 +311,21 @@ def do_POST(self) -> None: return from .app import event_to_api_dict - self._send_json({"event": event_to_api_dict(event, default_store().get_settings())}) + self._send_json(project_response(store, {"event": event_to_api_dict(event, store.get_settings())})) return if path == "/events/exclude": try: - self._send_json(default_store().exclude_event(str(payload.get("event_id") or ""))) + self._send_json(project_response(store, store.exclude_event(str(payload.get("event_id") or "")))) except KeyError: self._send_json({"error": "Event was not found"}, status=404) return if path == "/events/quality-review": try: self._send_json( - default_store().set_event_quality_review( + project_response(store, store.set_event_quality_review( str(payload.get("event_id") or ""), str(payload.get("status") or "approved"), - ) + )) ) except KeyError: self._send_json({"error": "Event was not found"}, status=404) @@ -264,12 +335,12 @@ def do_POST(self) -> None: if path == "/events/split": try: self._send_json( - default_store().split_event( + project_response(store, store.split_event( str(payload.get("event_id") or ""), float(payload.get("split_after_seconds") or 0), str(payload.get("first_activity") or ""), str(payload.get("second_activity") or ""), - ) + )) ) except KeyError: self._send_json({"error": "Event was not found"}, status=404) @@ -277,73 +348,75 @@ def do_POST(self) -> None: if path == "/events/merge": try: self._send_json( - default_store().merge_adjacent_events( + project_response(store, store.merge_adjacent_events( str(payload.get("first_event_id") or ""), str(payload.get("second_event_id") or ""), str(payload.get("activity") or ""), - ) + )) ) except KeyError: self._send_json({"error": "Event was not found"}, status=404) return if path == "/settings": - self._send_json(default_store().update_settings(payload)) + updates = dict(payload) + updates.pop("expected_revision", None) + self._send_json(project_response(store, store.update_settings(updates))) return if path == "/diagnostics/checks": - self._send_json(run_diagnostic_checks()) + self._send_json(project_response(store, run_diagnostic_checks())) return if path == "/automation/review": self._send_json( - default_store().set_automation_review( + project_response(store, store.set_automation_review( str(payload.get("activity") or ""), str(payload.get("status") or ""), str(payload.get("note") or ""), - ) + )) ) return - if path == "/data/delete/challenge": - self._send_json({"challenge": DELETE_CHALLENGES.issue()}) - return if path == "/export/mermaid": - self._send_json({"mermaid": create_export_artifact("mermaid")["content"]}) + self._send_json(project_response(store, {"mermaid": create_export_artifact("mermaid", store)["content"]})) return if path == "/export/drawio": - self._send_json({"drawio": create_export_artifact("drawio")["content"]}) + self._send_json(project_response(store, {"drawio": create_export_artifact("drawio", store)["content"]})) return if path == "/export/svg": - self._send_json({"status": "planned", "message": "SVG export will use a local renderer."}) + self._send_json(project_response(store, {"status": "planned", "message": "SVG export will use a local renderer."})) return if path == "/export/csv": - artifact = create_export_artifact("csv") + artifact = create_export_artifact("csv", store) content = artifact["content"] if not isinstance(content, bytes): raise RuntimeError("CSV export must be a ZIP bundle.") - self._send_json( + self._send_json(project_response( + store, { "filename": artifact["filename"], "zip_base64": base64.b64encode(content).decode("ascii"), - } - ) + }, + )) return if path == "/export/json": - artifact = create_export_artifact("json") - self._send_json({"json": artifact["content"]}) + artifact = create_export_artifact("json", store) + self._send_json(project_response(store, {"json": artifact["content"]})) return if path == "/export/llm-handoff": - self._send_json(export_llm_handoff_payload()) + self._send_json(project_response(store, export_llm_handoff_payload(store))) return if path == "/export/preview": - artifact = create_export_artifact(str(payload.get("format") or "")) - self._send_json({key: artifact[key] for key in ("format", "filename", "byte_size", "preview", "confidential_count", "warning")}) + artifact = create_export_artifact(str(payload.get("format") or ""), store) + self._send_json(project_response(store, {key: artifact[key] for key in ("format", "filename", "byte_size", "preview", "confidential_count", "warning")})) return if path == "/export/save": - self._send_json( + self._send_json(project_response( + store, save_export_artifact( str(payload.get("format") or ""), str(payload.get("path") or ""), + store=store, overwrite_confirmed=bool(payload.get("overwrite_confirmed")), - ) - ) + ), + )) return except FileNotFoundError as exc: self._send_json({"error": str(exc)}, status=404) @@ -351,6 +424,12 @@ def do_POST(self) -> None: except StorageCommitError as exc: self._send_json({"error": exc.to_api_dict()}, status=503) return + except ProjectConflictError as exc: + self._send_json({"error": str(exc)}, status=409) + return + except ProjectNotFoundError: + self._send_json({"error": "Project was not found."}, status=404) + return except (ValueError, RuntimeError) as exc: self._send_json({"error": str(exc)}, status=400) return @@ -381,6 +460,15 @@ def _read_json(self) -> dict[str, Any]: body = self.rfile.read(length).decode("utf-8") return json.loads(body) + def _project_store(self, payload: dict[str, Any] | None = None): + project_id = self.headers.get(PROJECT_HEADER, "").strip() + if not project_id: + raise ValueError("Project context is required.") + expected_revision = (payload or {}).get("expected_revision") + if expected_revision is not None and (isinstance(expected_revision, bool) or not isinstance(expected_revision, int)): + raise ValueError("Project revision must be an integer.") + return default_store().for_project(project_id, expected_revision=expected_revision) + def _authorize_request(self, path: str | None = None) -> bool: policy = getattr(self.server, "security_policy", LOCAL_API_POLICY) assert isinstance(policy, LocalApiPolicy) @@ -402,7 +490,7 @@ def _send_cors_headers(self) -> None: if origin in allowed_webui_origins(): self.send_header("Access-Control-Allow-Origin", origin) self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") - self.send_header("Access-Control-Allow-Headers", f"content-type, {DELETE_CHALLENGE_HEADER}") + self.send_header("Access-Control-Allow-Headers", f"content-type, {DELETE_CHALLENGE_HEADER}, {PROJECT_HEADER}") def main() -> None: diff --git a/services/local-api/src/opsmineflow_api/storage.py b/services/local-api/src/opsmineflow_api/storage.py index f0fe134..2ac9431 100644 --- a/services/local-api/src/opsmineflow_api/storage.py +++ b/services/local-api/src/opsmineflow_api/storage.py @@ -4,6 +4,8 @@ import json import os import sqlite3 +import uuid +from collections import OrderedDict from collections.abc import Callable, Mapping from dataclasses import replace from dataclasses import dataclass, field @@ -18,7 +20,12 @@ from opsmineflow_mining.models import StandardEvent from opsmineflow_mining.privacy import extract_domain, looks_confidential, mask_url, mask_window_title -from .migrations import CURRENT_SCHEMA_VERSION, MigrationReport, delete_migration_backups, migrate_database +from .migrations import ( + CURRENT_SCHEMA_VERSION, + LEGACY_PROJECT_ID, + MigrationReport, + migrate_database, +) DEFAULT_SETTINGS: dict[str, object] = { @@ -31,6 +38,8 @@ "excluded_domains": [], } +MAX_CACHED_PROJECT_VIEWS = 2 + AUTOMATION_REVIEW_STATUSES = {"unreviewed", "adopted", "on_hold", "rejected"} @@ -71,6 +80,38 @@ def to_api_dict(self) -> dict[str, object]: } +class ProjectNotFoundError(KeyError): + """The caller supplied an opaque project identifier that does not exist.""" + + +class ProjectConflictError(ValueError): + """A project mutation was based on an older project revision.""" + + +@dataclass(frozen=True) +class Project: + """Non-sensitive project catalogue data safe to expose to the desktop UI.""" + + project_id: str + display_name: str + origin: str + created_at: str + updated_at: str + revision: int + event_count: int = 0 + + def to_api_dict(self) -> dict[str, object]: + return { + "project_id": self.project_id, + "display_name": self.display_name, + "origin": self.origin, + "created_at": self.created_at, + "updated_at": self.updated_at, + "revision": self.revision, + "event_count": self.event_count, + } + + @dataclass(frozen=True) class StoreSnapshot: """One self-consistent local state observed by a reader or mutation.""" @@ -82,6 +123,8 @@ class StoreSnapshot: import_history: tuple[Mapping[str, object], ...] automation_reviews: Mapping[str, str] automation_review_notes: Mapping[str, str] + project_id: str + project_revision: int generation: int @@ -104,26 +147,39 @@ class EventStore: automation_reviews: dict[str, str] = field(default_factory=dict) automation_review_notes: dict[str, str] = field(default_factory=dict) db_path: Path | None = None + project_id: str = "" + expected_revision: int | None = None migration_fault_injector: Callable[[int], None] | None = field(default=None, repr=False, compare=False) mutation_fault_injector: Callable[[str], None] | None = field(default=None, repr=False, compare=False) + _migration_ready: bool = field(default=False, repr=False, compare=False) + _parent_migration_report: MigrationReport | None = field(default=None, repr=False, compare=False) _migration_report: MigrationReport | None = field(default=None, init=False, repr=False) _analysis_cache: dict[object, object] = field(default_factory=dict, init=False, repr=False, compare=False) _analysis_lock: RLock = field(default_factory=RLock, init=False, repr=False, compare=False) _mutation_lock: RLock = field(default_factory=RLock, init=False, repr=False, compare=False) + _project_view_lock: RLock = field(default_factory=RLock, init=False, repr=False, compare=False) + _project_views: OrderedDict[str, "EventStore"] = field(default_factory=OrderedDict, init=False, repr=False, compare=False) _generation: int = field(default=0, init=False, repr=False, compare=False) + _project_revision: int = field(default=0, init=False, repr=False, compare=False) _writes_blocked: bool = field(default=False, init=False, repr=False, compare=False) def __post_init__(self) -> None: + self.project_id = _normalize_project_id(self.project_id or LEGACY_PROJECT_ID) if self.db_path is None: self.events = _uniquify_event_ids(self._filter_events(list(self.events))) return self.db_path = Path(self.db_path) self.db_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) os.chmod(self.db_path.parent, 0o700) - self._migration_report = migrate_database( - self.db_path, - fault_injector=self.migration_fault_injector, - ) + if self._migration_ready: + if self._parent_migration_report is None: + raise StorageCommitError("storage_recovery_required") + self._migration_report = self._parent_migration_report + else: + self._migration_report = migrate_database( + self.db_path, + fault_injector=self.migration_fault_injector, + ) if self.events: self.replace(self.events) else: @@ -135,6 +191,242 @@ def snapshot(self) -> StoreSnapshot: with self._mutation_lock: return self._snapshot_locked() + def for_project(self, project_id: str, *, expected_revision: int | None = None) -> "EventStore": + """Open an immutable project context; never use the mutable active pointer for data access.""" + + normalized_project_id = _normalize_project_id(project_id) + if self.db_path is None: + if normalized_project_id != self.project_id: + raise ProjectNotFoundError(normalized_project_id) + if expected_revision is not None and expected_revision != self._project_revision: + raise ProjectConflictError("Project changed. Refresh it before applying another change.") + return self + if expected_revision is None: + # Dashboard reads arrive in parallel. Reuse one project-bound view + # so a 100k-event project is deserialized once, not once per + # endpoint. The view remains explicit: this cache never consults + # or falls back to the workspace active-project preference. + with self._project_view_lock: + cached = self._project_views.get(normalized_project_id) + if cached is not None: + self._project_views.move_to_end(normalized_project_id) + return cached + cached = EventStore( + db_path=self.db_path, + project_id=normalized_project_id, + _migration_ready=True, + _parent_migration_report=self._migration_report, + ) + self._project_views[normalized_project_id] = cached + while len(self._project_views) > MAX_CACHED_PROJECT_VIEWS: + self._project_views.popitem(last=False) + return cached + # A revision-bound operation must load and validate the durable state + # itself. Drop any read view so the next unversioned dashboard refresh + # cannot reuse a stale pre-mutation snapshot. + self._invalidate_project_view(normalized_project_id) + return EventStore( + db_path=self.db_path, + project_id=normalized_project_id, + expected_revision=expected_revision, + _migration_ready=True, + _parent_migration_report=self._migration_report, + ) + + def list_projects(self) -> list[Project]: + if self.db_path is None: + return [ + Project( + project_id=self.project_id, + display_name="In-memory project", + origin="memory", + created_at="", + updated_at="", + revision=self._project_revision, + event_count=len(self.events), + ) + ] + with self._connect() as connection: + rows = connection.execute( + """ + SELECT p.project_id, p.display_name, p.origin, p.created_at, p.updated_at, p.revision, + COUNT(e.event_id) AS event_count + FROM projects AS p + LEFT JOIN events AS e ON e.project_id = p.project_id + GROUP BY p.project_id + ORDER BY p.updated_at DESC, p.project_id ASC + """ + ).fetchall() + return [_project_from_row(row) for row in rows] + + def active_project_id(self) -> str: + if self.db_path is None: + return self.project_id + with self._connect() as connection: + row = connection.execute( + "SELECT value FROM workspace_metadata WHERE key = 'active_project_id'" + ).fetchone() + if row is None: + raise StorageCommitError("storage_constraint_failed") + return _normalize_project_id(str(row[0])) + + def get_project(self, project_id: str | None = None) -> Project: + normalized_project_id = _normalize_project_id(project_id or self.project_id) + if self.db_path is None: + if normalized_project_id != self.project_id: + raise ProjectNotFoundError(normalized_project_id) + return self.list_projects()[0] + with self._connect() as connection: + row = connection.execute( + """ + SELECT p.project_id, p.display_name, p.origin, p.created_at, p.updated_at, p.revision, + COUNT(e.event_id) AS event_count + FROM projects AS p + LEFT JOIN events AS e ON e.project_id = p.project_id + WHERE p.project_id = ? + GROUP BY p.project_id + """, + (normalized_project_id,), + ).fetchone() + if row is None: + raise ProjectNotFoundError(normalized_project_id) + return _project_from_row(row) + + def create_project(self, display_name: str, *, origin: str = "user") -> Project: + if self.db_path is None: + raise StorageCommitError("storage_constraint_failed") + name = _normalize_project_display_name(display_name) + normalized_origin = origin.strip() or "user" + now = datetime.now(timezone.utc).isoformat() + project = Project( + project_id=str(uuid.uuid4()), + display_name=name, + origin=normalized_origin, + created_at=now, + updated_at=now, + revision=0, + event_count=0, + ) + with self._connect() as connection: + try: + connection.execute("BEGIN IMMEDIATE") + _assert_project_name_available(connection, name) + connection.execute( + """ + INSERT INTO projects(project_id, display_name, origin, created_at, updated_at, revision) + VALUES(?, ?, ?, ?, ?, ?) + """, + ( + project.project_id, + project.display_name, + project.origin, + project.created_at, + project.updated_at, + project.revision, + ), + ) + connection.execute("COMMIT") + except Exception: + if connection.in_transaction: + connection.execute("ROLLBACK") + raise + return project + + def select_project(self, project_id: str) -> Project: + normalized_project_id = _normalize_project_id(project_id) + if self.db_path is None: + return self.get_project(normalized_project_id) + with self._connect() as connection: + try: + connection.execute("BEGIN IMMEDIATE") + project = _project_for_id(connection, normalized_project_id) + connection.execute( + """ + INSERT INTO workspace_metadata(key, value) VALUES('active_project_id', ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value + """, + (normalized_project_id,), + ) + connection.execute("COMMIT") + except Exception: + if connection.in_transaction: + connection.execute("ROLLBACK") + raise + return project + + def rename_project(self, project_id: str, display_name: str, *, expected_revision: int | None = None) -> Project: + normalized_project_id = _normalize_project_id(project_id) + name = _normalize_project_display_name(display_name) + if self.db_path is None: + if normalized_project_id != self.project_id: + raise ProjectNotFoundError(normalized_project_id) + return self.get_project() + with self._connect() as connection: + try: + connection.execute("BEGIN IMMEDIATE") + project = _project_for_id(connection, normalized_project_id) + _assert_requested_project_revision(project, expected_revision) + _assert_project_name_available(connection, name, excluding_project_id=normalized_project_id) + updated_at = datetime.now(timezone.utc).isoformat() + updated_revision = project.revision + 1 + connection.execute( + "UPDATE projects SET display_name = ?, updated_at = ?, revision = ? WHERE project_id = ?", + (name, updated_at, updated_revision, normalized_project_id), + ) + connection.execute("COMMIT") + except Exception: + if connection.in_transaction: + connection.execute("ROLLBACK") + raise + self._invalidate_project_view(normalized_project_id) + return self.get_project(normalized_project_id) + + def delete_project(self, project_id: str, *, expected_revision: int | None = None) -> str: + """Delete one project and only its scoped rows; keep at least one project available.""" + + normalized_project_id = _normalize_project_id(project_id) + if self.db_path is None: + raise StorageCommitError("storage_constraint_failed") + with self._connect() as connection: + try: + connection.execute("BEGIN IMMEDIATE") + project = _project_for_id(connection, normalized_project_id) + _assert_requested_project_revision(project, expected_revision) + if project.event_count > 0: + raise ValueError("Clear the project's data before deleting the project.") + remaining = connection.execute( + "SELECT project_id FROM projects WHERE project_id != ? ORDER BY updated_at DESC, project_id ASC LIMIT 1", + (normalized_project_id,), + ).fetchone() + if remaining is None: + raise ValueError("Create another project before deleting the last project.") + replacement_project_id = str(remaining[0]) + active_project_id = connection.execute( + "SELECT value FROM workspace_metadata WHERE key = 'active_project_id'" + ).fetchone() + if active_project_id is not None and str(active_project_id[0]) == normalized_project_id: + connection.execute( + "UPDATE workspace_metadata SET value = ? WHERE key = 'active_project_id'", + (replacement_project_id,), + ) + for table_name in ( + "manual_labels", + "automation_reviews", + "settings", + "metadata", + "import_history", + "events", + ): + connection.execute(f"DELETE FROM {table_name} WHERE project_id = ?", (normalized_project_id,)) + connection.execute("DELETE FROM projects WHERE project_id = ?", (normalized_project_id,)) + connection.execute("COMMIT") + except Exception: + if connection.in_transaction: + connection.execute("ROLLBACK") + raise + self._invalidate_project_view(normalized_project_id) + return replacement_project_id + def _snapshot_locked(self) -> StoreSnapshot: return StoreSnapshot( events=tuple(self.events), @@ -144,6 +436,8 @@ def _snapshot_locked(self) -> StoreSnapshot: import_history=tuple(MappingProxyType(dict(item)) for item in self.import_history), automation_reviews=MappingProxyType(dict(self.automation_reviews)), automation_review_notes=MappingProxyType(dict(self.automation_review_notes)), + project_id=self.project_id, + project_revision=self._project_revision, generation=self._generation, ) @@ -179,6 +473,8 @@ def _candidate( automation_review_notes=MappingProxyType( dict(current.automation_review_notes if automation_review_notes is None else automation_review_notes) ), + project_id=current.project_id, + project_revision=current.project_revision + 1, generation=current.generation + 1, ) @@ -199,6 +495,7 @@ def _commit_candidate(self, candidate: StoreSnapshot) -> None: connection = self._connect() connection.execute("BEGIN IMMEDIATE") transaction_started = True + self._assert_expected_project_revision(connection, candidate) persisted = self._write_candidate(connection, candidate) if self.mutation_fault_injector is not None: self.mutation_fault_injector("before_commit") @@ -221,7 +518,7 @@ def _commit_candidate(self, candidate: StoreSnapshot) -> None: if failure is not None: if committed or (transaction_started and not rollback_confirmed): self._reconcile_after_uncertain_commit(failure) - if isinstance(failure, StorageCommitError): + if isinstance(failure, (StorageCommitError, ProjectNotFoundError, ProjectConflictError)): raise failure raise StorageCommitError(_storage_error_code(failure)) from failure if persisted is None: @@ -242,6 +539,19 @@ def _rollback_after_failed_commit(self, connection: sqlite3.Connection | None) - return False return False + def _assert_expected_project_revision(self, connection: sqlite3.Connection, candidate: StoreSnapshot) -> None: + row = connection.execute( + "SELECT revision FROM projects WHERE project_id = ?", + (candidate.project_id,), + ).fetchone() + if row is None: + raise ProjectNotFoundError(candidate.project_id) + durable_revision = int(row[0]) + if self.expected_revision is not None and durable_revision != self.expected_revision: + raise ProjectConflictError("Project changed. Refresh it before applying another change.") + if durable_revision != candidate.project_revision - 1: + raise ProjectConflictError("Project changed. Refresh it before applying another change.") + def _reconcile_after_uncertain_commit(self, error: Exception) -> None: """Converge memory with durable SQLite state after an uncertain response.""" @@ -266,31 +576,39 @@ def _reconcile_after_connection_failure(self, error: Exception) -> None: def _write_candidate(self, connection: sqlite3.Connection, candidate: StoreSnapshot) -> StoreSnapshot: """Write every coupled table in the caller-owned transaction.""" - connection.execute("DELETE FROM events") + project_id = candidate.project_id + connection.execute("DELETE FROM events WHERE project_id = ?", (project_id,)) connection.executemany( - "INSERT INTO events(event_id, payload_json) VALUES(?, ?)", - [(event.event_id, json.dumps(event.to_dict(), ensure_ascii=False)) for event in candidate.events], + "INSERT INTO events(project_id, event_id, payload_json) VALUES(?, ?, ?)", + [ + (project_id, event.event_id, json.dumps(event.to_dict(), ensure_ascii=False)) + for event in candidate.events + ], ) - connection.execute("DELETE FROM manual_labels") + connection.execute("DELETE FROM manual_labels WHERE project_id = ?", (project_id,)) connection.executemany( - "INSERT INTO manual_labels(event_id, label) VALUES(?, ?)", - sorted(candidate.manual_labels.items()), + "INSERT INTO manual_labels(project_id, event_id, label) VALUES(?, ?, ?)", + [(project_id, event_id, label) for event_id, label in sorted(candidate.manual_labels.items())], ) - connection.execute("DELETE FROM settings") + connection.execute("DELETE FROM settings WHERE project_id = ?", (project_id,)) connection.executemany( - "INSERT INTO settings(key, value_json) VALUES(?, ?)", - [(key, json.dumps(value, ensure_ascii=False)) for key, value in sorted(candidate.settings.items())], + "INSERT INTO settings(project_id, key, value_json) VALUES(?, ?, ?)", + [ + (project_id, key, json.dumps(value, ensure_ascii=False)) + for key, value in sorted(candidate.settings.items()) + ], ) - connection.execute("DELETE FROM metadata") + connection.execute("DELETE FROM metadata WHERE project_id = ?", (project_id,)) connection.executemany( - "INSERT INTO metadata(key, value) VALUES(?, ?)", - sorted(candidate.metadata.items()), + "INSERT INTO metadata(project_id, key, value) VALUES(?, ?, ?)", + [(project_id, key, value) for key, value in sorted(candidate.metadata.items())], ) - connection.execute("DELETE FROM automation_reviews") + connection.execute("DELETE FROM automation_reviews WHERE project_id = ?", (project_id,)) connection.executemany( - "INSERT INTO automation_reviews(activity, status, note, updated_at) VALUES(?, ?, ?, ?)", + "INSERT INTO automation_reviews(project_id, activity, status, note, updated_at) VALUES(?, ?, ?, ?, ?)", [ ( + project_id, activity, status, candidate.automation_review_notes.get(activity, ""), @@ -299,23 +617,38 @@ def _write_candidate(self, connection: sqlite3.Connection, candidate: StoreSnaps for activity, status in sorted(candidate.automation_reviews.items()) ], ) - connection.execute("DELETE FROM import_history") + connection.execute("DELETE FROM import_history WHERE project_id = ?", (project_id,)) persisted_history: list[dict[str, object]] = [] + next_history_id = int( + connection.execute( + "SELECT COALESCE(MAX(id), 0) + 1 FROM import_history WHERE project_id = ?", + (project_id,), + ).fetchone()[0] + ) for item in candidate.import_history: - if "id" in item: - connection.execute( - "INSERT INTO import_history(id, source, path, event_count, imported_at) VALUES(?, ?, ?, ?, ?)", - (item["id"], item["source"], item["path"], item["event_count"], item["imported_at"]), - ) - persisted_history.append(dict(item)) - else: - cursor = connection.execute( - "INSERT INTO import_history(source, path, event_count, imported_at) VALUES(?, ?, ?, ?)", - (item["source"], item["path"], item["event_count"], item["imported_at"]), - ) - persisted_item = dict(item) - persisted_item["id"] = int(cursor.lastrowid) - persisted_history.append(persisted_item) + persisted_item = dict(item) + history_id = int(persisted_item.get("id", next_history_id)) + next_history_id = max(next_history_id, history_id + 1) + connection.execute( + "INSERT INTO import_history(project_id, id, source, path, event_count, imported_at) VALUES(?, ?, ?, ?, ?, ?)", + ( + project_id, + history_id, + persisted_item["source"], + persisted_item["path"], + persisted_item["event_count"], + persisted_item["imported_at"], + ), + ) + persisted_item["id"] = history_id + persisted_history.append(persisted_item) + updated_at = datetime.now(timezone.utc).isoformat() + result = connection.execute( + "UPDATE projects SET revision = ?, updated_at = ? WHERE project_id = ?", + (candidate.project_revision, updated_at, project_id), + ) + if result.rowcount != 1: + raise ProjectNotFoundError(project_id) return replace( candidate, import_history=tuple(MappingProxyType(dict(item)) for item in persisted_history), @@ -329,6 +662,7 @@ def _apply_candidate(self, candidate: StoreSnapshot) -> None: self.import_history = [dict(item) for item in candidate.import_history] self.automation_reviews = dict(candidate.automation_reviews) self.automation_review_notes = dict(candidate.automation_review_notes) + self._project_revision = candidate.project_revision self._generation = candidate.generation self._invalidate_analysis() @@ -609,6 +943,13 @@ def merge_adjacent_events(self, first_event_id: str, second_event_id: str, activ return {"merged": True, "event": merged.to_dict()} def clear(self) -> None: + """Clear only this project's database rows. + + Workspace-level migration snapshots are intentionally retained. Their + retention and deletion policy belongs to the dedicated backup and + lifecycle work rather than a selected-project operation. + """ + with self._mutation_lock: current = self._snapshot_locked() self._commit_candidate( @@ -622,13 +963,6 @@ def clear(self) -> None: automation_review_notes={}, ) ) - if self.db_path is not None: - try: - delete_migration_backups(self.db_path) - except OSError: - # The data deletion is committed. A future start/clear retries - # backup cleanup without pretending the DB mutation rolled back. - pass def set_automation_review(self, activity: str, status: str, note: str = "") -> dict[str, str]: normalized_activity = activity.strip() @@ -713,6 +1047,8 @@ def diagnostics(self, snapshot: StoreSnapshot | None = None) -> dict[str, object return { "storage_mode": "sqlite" if self.db_path else "memory", "storage_path": str(self.db_path) if self.db_path else "", + "project_id": active_snapshot.project_id, + "project_revision": active_snapshot.project_revision, "event_count": len(active_snapshot.events), "manual_label_count": len(active_snapshot.manual_labels), "import_history_count": len(active_snapshot.import_history), @@ -734,15 +1070,43 @@ def _connect(self) -> sqlite3.Connection: connection.execute("PRAGMA foreign_keys = ON") return connection + def _invalidate_project_view(self, project_id: str) -> None: + with self._project_view_lock: + self._project_views.pop(project_id, None) + def _load(self) -> None: with self._connect() as conn: - event_rows = conn.execute("SELECT payload_json FROM events ORDER BY rowid").fetchall() - label_rows = conn.execute("SELECT event_id, label FROM manual_labels ORDER BY event_id").fetchall() - setting_rows = conn.execute("SELECT key, value_json FROM settings ORDER BY key").fetchall() - metadata_rows = conn.execute("SELECT key, value FROM metadata ORDER BY key").fetchall() - review_rows = conn.execute("SELECT activity, status, note FROM automation_reviews ORDER BY activity").fetchall() + project_row = conn.execute( + "SELECT revision FROM projects WHERE project_id = ?", + (self.project_id,), + ).fetchone() + if project_row is None: + raise ProjectNotFoundError(self.project_id) + if self.expected_revision is not None and int(project_row[0]) != self.expected_revision: + raise ProjectConflictError("Project changed. Refresh it before applying another change.") + event_rows = conn.execute( + "SELECT payload_json FROM events WHERE project_id = ? ORDER BY rowid", + (self.project_id,), + ).fetchall() + label_rows = conn.execute( + "SELECT event_id, label FROM manual_labels WHERE project_id = ? ORDER BY event_id", + (self.project_id,), + ).fetchall() + setting_rows = conn.execute( + "SELECT key, value_json FROM settings WHERE project_id = ? ORDER BY key", + (self.project_id,), + ).fetchall() + metadata_rows = conn.execute( + "SELECT key, value FROM metadata WHERE project_id = ? ORDER BY key", + (self.project_id,), + ).fetchall() + review_rows = conn.execute( + "SELECT activity, status, note FROM automation_reviews WHERE project_id = ? ORDER BY activity", + (self.project_id,), + ).fetchall() import_rows = conn.execute( - "SELECT id, source, path, event_count, imported_at FROM import_history ORDER BY id" + "SELECT id, source, path, event_count, imported_at FROM import_history WHERE project_id = ? ORDER BY id", + (self.project_id,), ).fetchall() self.events = sorted((StandardEvent(**json.loads(row[0])) for row in event_rows), key=event_sort_key) self.manual_labels = {str(event_id): str(label) for event_id, label in label_rows} @@ -767,6 +1131,7 @@ def _load(self) -> None: } for row_id, source, path, event_count, imported_at in import_rows ] + self._project_revision = int(project_row[0]) self._generation += 1 self._invalidate_analysis() @@ -910,6 +1275,66 @@ def _storage_error_code(error: Exception) -> str: return "storage_commit_failed" +def _normalize_project_id(value: object) -> str: + try: + return str(uuid.UUID(str(value).strip())) + except (AttributeError, TypeError, ValueError) as exc: + raise ValueError("Project ID must be a UUID.") from exc + + +def _normalize_project_display_name(value: object) -> str: + name = str(value).strip() + if not name or len(name) > 120 or any(ord(character) < 32 for character in name): + raise ValueError("Project name must be 1-120 printable characters.") + return name + + +def _project_from_row(row: tuple[object, ...]) -> Project: + return Project( + project_id=_normalize_project_id(row[0]), + display_name=str(row[1]), + origin=str(row[2]), + created_at=str(row[3]), + updated_at=str(row[4]), + revision=int(row[5]), + event_count=int(row[6]), + ) + + +def _project_for_id(connection: sqlite3.Connection, project_id: str) -> Project: + row = connection.execute( + """ + SELECT p.project_id, p.display_name, p.origin, p.created_at, p.updated_at, p.revision, + COUNT(e.event_id) AS event_count + FROM projects AS p + LEFT JOIN events AS e ON e.project_id = p.project_id + WHERE p.project_id = ? + GROUP BY p.project_id + """, + (project_id,), + ).fetchone() + if row is None: + raise ProjectNotFoundError(project_id) + return _project_from_row(row) + + +def _assert_requested_project_revision(project: Project, expected_revision: int | None) -> None: + if expected_revision is not None and project.revision != expected_revision: + raise ProjectConflictError("Project changed. Refresh it before applying another change.") + + +def _assert_project_name_available( + connection: sqlite3.Connection, + display_name: str, + *, + excluding_project_id: str = "", +) -> None: + rows = connection.execute("SELECT project_id, display_name FROM projects").fetchall() + target = display_name.casefold() + if any(str(name).casefold() == target and str(project_id) != excluding_project_id for project_id, name in rows): + raise ValueError("A project with that name already exists.") + + _STORE: EventStore | None = None diff --git a/services/local-api/tests/test_auth.py b/services/local-api/tests/test_auth.py index 59eb488..c7e7710 100644 --- a/services/local-api/tests/test_auth.py +++ b/services/local-api/tests/test_auth.py @@ -7,11 +7,12 @@ import threading import unittest from http.server import ThreadingHTTPServer +from types import SimpleNamespace from unittest.mock import patch api_app_module = importlib.import_module("opsmineflow_api.app") fastapi_app = api_app_module.app -from opsmineflow_api.auth import API_SESSION_HEADER, DeleteChallengeStore, LocalApiPolicy, RequestRejected +from opsmineflow_api.auth import API_SESSION_HEADER, PROJECT_HEADER, DeleteChallengeStore, LocalApiPolicy, RequestRejected from opsmineflow_api.server import LocalApiHandler from opsmineflow_api.server import _start_parent_watchdog from opsmineflow_api.storage import StorageCommitError @@ -138,6 +139,14 @@ def test_rejection_happens_before_snapshot_generation(self) -> None: self.assertEqual(status, 401) self.assertEqual(payload["error"], "local API authorization failed") + def test_project_scoped_route_rejects_a_missing_project_header_before_reading_data(self) -> None: + headers = {API_SESSION_HEADER: "b" * 64} + with patch("opsmineflow_api.server.create_event_page", side_effect=AssertionError("must not read another project")): + status, payload = self._request("GET", "/events", headers=headers) + + self.assertEqual(status, 400) + self.assertEqual(payload, {"error": "Project context is required."}) + def test_public_health_is_minimal_and_does_not_create_snapshot(self) -> None: with patch("opsmineflow_api.server.create_api_snapshot", side_effect=AssertionError("must not analyze")): status, payload = self._request("GET", "/health") @@ -146,12 +155,20 @@ def test_public_health_is_minimal_and_does_not_create_snapshot(self) -> None: self.assertEqual(payload, {"status": "ok", "bind": "127.0.0.1", "local_only": True, "llm_supported": False}) def test_delete_challenge_is_required_and_single_use_on_the_http_boundary(self) -> None: - session_headers = {API_SESSION_HEADER: "b" * 64, "Content-Type": "application/json"} + project_id = "b01eecad-1e18-5e88-bf34-8e8e8358cfcb" + session_headers = { + API_SESSION_HEADER: "b" * 64, + "Content-Type": "application/json", + PROJECT_HEADER: project_id, + } with ( patch("opsmineflow_api.server.DELETE_CHALLENGES", DeleteChallengeStore()), patch("opsmineflow_api.server.default_store") as default_store, patch("opsmineflow_api.server.recording_manager.stop") as stop_recording, + patch("opsmineflow_api.server.recording_manager.status", return_value={"active": True}), ): + scoped_store = default_store.return_value.for_project.return_value + scoped_store.snapshot.return_value = SimpleNamespace(project_id=project_id, project_revision=3) status, issued = self._request("POST", "/data/delete/challenge", {}, session_headers) self.assertEqual(status, 200) challenge = str(issued["challenge"]) @@ -163,7 +180,7 @@ def test_delete_challenge_is_required_and_single_use_on_the_http_boundary(self) {**session_headers, "X-OpsMineFlow-Delete-Challenge": challenge}, ) self.assertEqual(status, 200) - self.assertEqual(deleted, {"deleted": True}) + self.assertEqual(deleted, {"deleted": True, "project_id": project_id, "project_revision": 3}) status, rejected = self._request( "POST", @@ -174,13 +191,49 @@ def test_delete_challenge_is_required_and_single_use_on_the_http_boundary(self) self.assertEqual(status, 403) self.assertEqual(rejected["error"], "delete challenge is invalid or expired") - stop_recording.assert_called_once_with(default_store.return_value, record_import=False) - default_store.return_value.clear.assert_called_once() + default_store.return_value.for_project.assert_called_with(project_id, expected_revision=None) + stop_recording.assert_called_once_with(scoped_store, record_import=False) + scoped_store.clear.assert_called_once() + + def test_delete_of_another_project_does_not_stop_an_unrelated_recording(self) -> None: + project_id = "b01eecad-1e18-5e88-bf34-8e8e8358cfcb" + session_headers = { + API_SESSION_HEADER: "b" * 64, + "Content-Type": "application/json", + PROJECT_HEADER: project_id, + } + with ( + patch("opsmineflow_api.server.DELETE_CHALLENGES", DeleteChallengeStore()), + patch("opsmineflow_api.server.default_store") as default_store, + patch("opsmineflow_api.server.recording_manager.stop") as stop_recording, + patch("opsmineflow_api.server.recording_manager.status", return_value={"active": False}), + ): + default_store.return_value.for_project.return_value.snapshot.return_value = SimpleNamespace( + project_id=project_id, + project_revision=8, + ) + status, issued = self._request("POST", "/data/delete/challenge", {}, session_headers) + self.assertEqual(status, 200) + status, deleted = self._request( + "POST", + "/data/delete", + {}, + {**session_headers, "X-OpsMineFlow-Delete-Challenge": str(issued["challenge"])}, + ) + + self.assertEqual(status, 200) + self.assertEqual(deleted, {"deleted": True, "project_id": project_id, "project_revision": 8}) + stop_recording.assert_not_called() + default_store.return_value.for_project.return_value.clear.assert_called_once() def test_storage_commit_failure_has_a_stable_handwritten_http_contract(self) -> None: - headers = {API_SESSION_HEADER: "b" * 64, "Content-Type": "application/json"} + headers = { + API_SESSION_HEADER: "b" * 64, + "Content-Type": "application/json", + PROJECT_HEADER: "b01eecad-1e18-5e88-bf34-8e8e8358cfcb", + } with patch("opsmineflow_api.server.default_store") as default_store: - default_store.return_value.set_label.side_effect = StorageCommitError("storage_busy") + default_store.return_value.for_project.return_value.set_label.side_effect = StorageCommitError("storage_busy") status, payload = self._request( "POST", "/events/label", diff --git a/services/local-api/tests/test_project_isolation.py b/services/local-api/tests/test_project_isolation.py new file mode 100644 index 0000000..e93dcbd --- /dev/null +++ b/services/local-api/tests/test_project_isolation.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import tempfile +import unittest +from dataclasses import replace +from pathlib import Path +from unittest.mock import patch + +from opsmineflow_mining import load_events_from_csv + +from opsmineflow_api.app import create_export_artifact, create_process_map +from opsmineflow_api.storage import MAX_CACHED_PROJECT_VIEWS, EventStore, ProjectConflictError + + +class ProjectIsolationTests(unittest.TestCase): + def _event(self): + return load_events_from_csv("data/sample/sample_events.csv")[0] + + def test_same_event_id_and_all_scoped_state_are_isolated_between_projects(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + workspace = EventStore(db_path=Path(temp_dir) / "opsmineflow.sqlite3") + project_a = workspace.create_project("Accounts payable") + project_b = workspace.create_project("Customer support") + event = self._event() + event_a = replace(event, activity_raw="AP-only review", activity_normalized="AP-only review") + event_b = replace(event, activity_raw="Support-only review", activity_normalized="Support-only review") + + store_a = workspace.for_project(project_a.project_id) + store_b = workspace.for_project(project_b.project_id) + store_a.replace([event_a], import_source="csv", import_path="accounts.csv") + store_a.set_label(event.event_id, "AP review") + store_a.update_settings({"excluded_apps": ["Private Browser"]}) + store_a.set_automation_review("Review invoice", "adopted", "owned by finance") + + store_b.replace([event_b], import_source="csv", import_path="support.csv") + store_b.set_label(event.event_id, "Support review") + store_b.update_settings({"excluded_domains": ["internal.example"]}) + store_b.set_automation_review("Review invoice", "rejected", "not a support workflow") + + reopened_a = workspace.for_project(project_a.project_id) + reopened_b = workspace.for_project(project_b.project_id) + self.assertEqual([item.event_id for item in reopened_a.snapshot().events], [event.event_id]) + self.assertEqual([item.event_id for item in reopened_b.snapshot().events], [event.event_id]) + self.assertEqual(reopened_a.snapshot().manual_labels[event.event_id], "AP review") + self.assertEqual(reopened_b.snapshot().manual_labels[event.event_id], "Support review") + self.assertEqual(reopened_a.get_settings()["excluded_apps"], ["Private Browser"]) + self.assertEqual(reopened_b.get_settings()["excluded_apps"], []) + self.assertEqual(reopened_a.snapshot().automation_reviews["Review invoice"], "adopted") + self.assertEqual(reopened_b.snapshot().automation_reviews["Review invoice"], "rejected") + self.assertEqual(reopened_a.list_import_history()[0]["path"], "accounts.csv") + self.assertEqual(reopened_b.list_import_history()[0]["path"], "support.csv") + self.assertEqual(create_process_map(reopened_a)["nodes"][0]["activity"], "AP-only review") + self.assertEqual(create_process_map(reopened_b)["nodes"][0]["activity"], "Support-only review") + self.assertIn("AP-only review", str(create_export_artifact("json", reopened_a)["content"])) + self.assertNotIn("Support-only review", str(create_export_artifact("json", reopened_a)["content"])) + + reopened_a.clear() + after_clear_b = workspace.for_project(project_b.project_id) + self.assertEqual(len(after_clear_b.snapshot().events), 1) + self.assertEqual(after_clear_b.snapshot().events[0].activity_normalized, "Support-only review") + self.assertEqual(after_clear_b.snapshot().manual_labels[event.event_id], "Support review") + self.assertEqual(after_clear_b.list_import_history()[0]["path"], "support.csv") + + def test_stale_project_revision_cannot_overwrite_a_newer_project_state(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + workspace = EventStore(db_path=Path(temp_dir) / "opsmineflow.sqlite3") + project = workspace.create_project("Revenue operations") + event = self._event() + initial = workspace.for_project(project.project_id) + initial.replace([event]) + revision = initial.snapshot().project_revision + + stale = workspace.for_project(project.project_id, expected_revision=revision) + current = workspace.for_project(project.project_id) + current.set_label(event.event_id, "current") + + with self.assertRaises(ProjectConflictError): + stale.set_label(event.event_id, "stale") + with self.assertRaises(ProjectConflictError): + workspace.for_project(project.project_id, expected_revision=revision) + self.assertEqual(workspace.for_project(project.project_id).snapshot().manual_labels[event.event_id], "current") + + def test_project_scope_reuses_the_initialized_schema_without_running_migrations(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + workspace = EventStore(db_path=Path(temp_dir) / "opsmineflow.sqlite3") + project = workspace.create_project("Migration-free scope") + + with patch("opsmineflow_api.storage.migrate_database", side_effect=AssertionError("must not migrate per request")): + scoped = workspace.for_project(project.project_id) + repeated = workspace.for_project(project.project_id) + + self.assertEqual(scoped.snapshot().project_id, project.project_id) + self.assertIs(scoped, repeated) + + def test_revision_bound_mutation_invalidates_the_cached_project_view(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + workspace = EventStore(db_path=Path(temp_dir) / "opsmineflow.sqlite3") + project = workspace.create_project("Cache invalidation") + cached = workspace.for_project(project.project_id) + revision = cached.snapshot().project_revision + + mutation = workspace.for_project(project.project_id, expected_revision=revision) + mutation.update_settings({"retention_days": 21}) + refreshed = workspace.for_project(project.project_id) + + self.assertIsNot(cached, refreshed) + self.assertEqual(refreshed.get_settings()["retention_days"], 21) + + def test_project_view_cache_is_bounded_and_evicts_the_least_recent_scope(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + workspace = EventStore(db_path=Path(temp_dir) / "opsmineflow.sqlite3") + projects = [workspace.create_project(f"Cache {index}") for index in range(MAX_CACHED_PROJECT_VIEWS + 1)] + + for project in projects: + workspace.for_project(project.project_id) + + self.assertEqual(len(workspace._project_views), MAX_CACHED_PROJECT_VIEWS) + self.assertNotIn(projects[0].project_id, workspace._project_views) + self.assertEqual(list(workspace._project_views), [project.project_id for project in projects[1:]]) + + def test_project_delete_requires_an_empty_dataset_and_updates_the_durable_selection(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + workspace = EventStore(db_path=Path(temp_dir) / "opsmineflow.sqlite3") + populated = workspace.create_project("Populated") + empty = workspace.create_project("Empty") + workspace.for_project(populated.project_id).replace([self._event()]) + workspace.select_project(empty.project_id) + + with self.assertRaisesRegex(ValueError, "Clear the project's data"): + workspace.delete_project(populated.project_id) + + replacement = workspace.delete_project(empty.project_id) + self.assertNotEqual(replacement, empty.project_id) + self.assertEqual(workspace.active_project_id(), replacement) + self.assertNotIn(empty.project_id, {project.project_id for project in workspace.list_projects()}) + + +if __name__ == "__main__": + unittest.main() diff --git a/services/local-api/tests/test_storage_migrations.py b/services/local-api/tests/test_storage_migrations.py index 84ef9a1..c5fcbe3 100644 --- a/services/local-api/tests/test_storage_migrations.py +++ b/services/local-api/tests/test_storage_migrations.py @@ -12,10 +12,12 @@ from opsmineflow_api.migrations import ( CURRENT_SCHEMA_VERSION, + LEGACY_PROJECT_ID, MIGRATIONS, MigrationError, MigrationInvariantError, UnsupportedSchemaError, + migrate_database, validate_migration_registry, ) from opsmineflow_api.storage import EventStore @@ -23,6 +25,32 @@ class StorageMigrationTests(unittest.TestCase): + def test_fresh_database_applies_v3_and_creates_a_durable_empty_legacy_project(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + db_path = Path(temp_dir) / "opsmineflow.sqlite3" + + report = migrate_database(db_path) + reopened = migrate_database(db_path) + + with sqlite3.connect(db_path) as connection: + project = connection.execute( + "SELECT project_id, display_name, origin, revision FROM projects" + ).fetchone() + workspace_metadata = dict(connection.execute("SELECT key, value FROM workspace_metadata").fetchall()) + empty_snapshot = _v3_dataset_snapshot(connection, LEGACY_PROJECT_ID) + ledger = connection.execute("SELECT version FROM schema_migrations ORDER BY version").fetchall() + + self.assertEqual(report.previous_version, 0) + self.assertEqual(report.applied_migrations, (1, 2, 3)) + self.assertEqual(reopened.status, "current") + self.assertEqual(project, (LEGACY_PROJECT_ID, "Migrated data", "legacy_migration", 0)) + self.assertEqual(ledger, [(1,), (2,), (3,)]) + self.assertEqual(workspace_metadata["active_project_id"], LEGACY_PROJECT_ID) + self.assertEqual(workspace_metadata["legacy_v2_before_hash"], _dataset_hash(empty_snapshot)) + self.assertEqual(workspace_metadata["legacy_v3_after_hash"], _dataset_hash(empty_snapshot)) + self.assertEqual(json.loads(workspace_metadata["legacy_v2_before_counts"]), _dataset_counts(empty_snapshot)) + self.assertEqual(json.loads(workspace_metadata["legacy_v3_after_counts"]), _dataset_counts(empty_snapshot)) + def test_legacy_database_migrates_once_and_preserves_data(self) -> None: events = load_events_from_csv("data/sample/sample_events.csv") with tempfile.TemporaryDirectory() as temp_dir: @@ -68,6 +96,7 @@ def test_legacy_database_migrates_once_and_preserves_data(self) -> None: [ (1, "baseline_event_store", MIGRATIONS[0].checksum), (2, "redact_import_history_paths", MIGRATIONS[1].checksum), + (3, "scope_records_to_projects", MIGRATIONS[2].checksum), ], ) @@ -98,9 +127,161 @@ def test_all_historical_v01_table_sets_upgrade_to_the_baseline_schema(self) -> N "import_history", "automation_reviews", "schema_migrations", + "projects", + "workspace_metadata", }, ) + def test_v2_backfill_creates_one_legacy_project_and_preserves_auditable_snapshot(self) -> None: + events = load_events_from_csv("data/sample/sample_events.csv") + with tempfile.TemporaryDirectory() as temp_dir: + db_path = Path(temp_dir) / "opsmineflow.sqlite3" + _create_v2_database(db_path, events[:2]) + before = _v2_dataset_snapshot(db_path) + + report = migrate_database(db_path) + reopened = migrate_database(db_path) + + with sqlite3.connect(db_path) as connection: + connection.execute("PRAGMA foreign_keys = ON") + project = connection.execute( + "SELECT project_id, display_name, origin, revision FROM projects" + ).fetchone() + workspace_metadata = dict( + connection.execute("SELECT key, value FROM workspace_metadata ORDER BY key").fetchall() + ) + after = _v3_dataset_snapshot(connection, LEGACY_PROJECT_ID) + table_names = { + row[0] + for row in connection.execute( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'" + ).fetchall() + } + foreign_keys = { + table_name: connection.execute(f"PRAGMA foreign_key_list({table_name})").fetchall() + for table_name in ( + "events", + "manual_labels", + "settings", + "metadata", + "import_history", + "automation_reviews", + ) + } + + with self.assertRaises(sqlite3.IntegrityError): + connection.execute( + "INSERT INTO events(project_id, event_id, payload_json) VALUES(?, ?, ?)", + ("missing-project", "event-1", "{}"), + ) + connection.execute( + "INSERT INTO projects(project_id, display_name, origin, created_at, updated_at, revision) " + "VALUES(?, ?, ?, ?, ?, ?)", + ("d350dc56-7cfe-4df4-ae0a-bfcf9467d9e0", "Second project", "test", "now", "now", 0), + ) + connection.execute( + "INSERT INTO events(project_id, event_id, payload_json) VALUES(?, ?, ?)", + ("d350dc56-7cfe-4df4-ae0a-bfcf9467d9e0", events[0].event_id, "{}"), + ) + connection.execute( + "INSERT INTO manual_labels(project_id, event_id, label) VALUES(?, ?, ?)", + ("d350dc56-7cfe-4df4-ae0a-bfcf9467d9e0", events[0].event_id, "Second label"), + ) + with self.assertRaises(sqlite3.IntegrityError): + connection.execute( + "INSERT INTO manual_labels(project_id, event_id, label) VALUES(?, ?, ?)", + ("d350dc56-7cfe-4df4-ae0a-bfcf9467d9e0", "missing-event", "Rejected"), + ) + with self.assertRaises(sqlite3.IntegrityError): + connection.execute( + "INSERT INTO workspace_metadata(key, value) VALUES(?, ?)", + ("active_project_id", "missing-project"), + ) + scoped_event_counts = connection.execute( + "SELECT project_id, COUNT(*) FROM events GROUP BY project_id ORDER BY project_id" + ).fetchall() + scoped_label_counts = connection.execute( + "SELECT project_id, COUNT(*) FROM manual_labels GROUP BY project_id ORDER BY project_id" + ).fetchall() + foreign_key_check = connection.execute("PRAGMA foreign_key_check").fetchall() + + self.assertEqual(report.previous_version, 2) + self.assertEqual(report.schema_version, 3) + self.assertEqual(report.applied_migrations, (3,)) + self.assertEqual(reopened.status, "current") + self.assertEqual(project, (LEGACY_PROJECT_ID, "Migrated data", "legacy_migration", 0)) + self.assertEqual(after, before) + self.assertEqual(workspace_metadata["active_project_id"], LEGACY_PROJECT_ID) + self.assertEqual(workspace_metadata["legacy_v2_before_hash"], workspace_metadata["legacy_v3_after_hash"]) + self.assertEqual(workspace_metadata["legacy_v2_before_hash"], _dataset_hash(before)) + self.assertEqual(json.loads(workspace_metadata["legacy_v2_before_counts"]), _dataset_counts(before)) + self.assertEqual(json.loads(workspace_metadata["legacy_v3_after_counts"]), _dataset_counts(after)) + self.assertEqual( + table_names, + { + "automation_reviews", + "events", + "import_history", + "manual_labels", + "metadata", + "projects", + "schema_migrations", + "settings", + "workspace_metadata", + }, + ) + self.assertTrue(all(foreign_keys.values())) + self.assertEqual(scoped_event_counts, [(LEGACY_PROJECT_ID, 2), ("d350dc56-7cfe-4df4-ae0a-bfcf9467d9e0", 1)]) + self.assertEqual(scoped_label_counts, [(LEGACY_PROJECT_ID, 1), ("d350dc56-7cfe-4df4-ae0a-bfcf9467d9e0", 1)]) + self.assertEqual(foreign_key_check, []) + + def test_v3_interruption_rolls_back_to_v2_and_a_redo_backfills_once(self) -> None: + events = load_events_from_csv("data/sample/sample_events.csv") + with tempfile.TemporaryDirectory() as temp_dir: + db_path = Path(temp_dir) / "opsmineflow.sqlite3" + _create_v2_database(db_path, events[:1]) + + def fail_after_v3(version: int) -> None: + if version == 3: + raise RuntimeError("intentional v3 migration interruption") + + with self.assertRaises(MigrationError): + migrate_database(db_path, fault_injector=fail_after_v3) + + with sqlite3.connect(db_path) as connection: + interrupted_version = connection.execute("PRAGMA user_version").fetchone()[0] + interrupted_ledger = connection.execute( + "SELECT version FROM schema_migrations ORDER BY version" + ).fetchall() + interrupted_tables = { + row[0] + for row in connection.execute( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'" + ).fetchall() + } + interrupted_events = connection.execute("SELECT COUNT(*) FROM events").fetchone()[0] + + redo = migrate_database(db_path) + with sqlite3.connect(db_path) as connection: + project_count = connection.execute("SELECT COUNT(*) FROM projects").fetchone()[0] + active_project = connection.execute( + "SELECT value FROM workspace_metadata WHERE key = 'active_project_id'" + ).fetchone()[0] + migrated_events = connection.execute( + "SELECT COUNT(*) FROM events WHERE project_id = ?", (LEGACY_PROJECT_ID,) + ).fetchone()[0] + foreign_key_check = connection.execute("PRAGMA foreign_key_check").fetchall() + + self.assertEqual(interrupted_version, 2) + self.assertEqual(interrupted_ledger, [(1,), (2,)]) + self.assertNotIn("projects", interrupted_tables) + self.assertEqual(interrupted_events, 1) + self.assertEqual(redo.applied_migrations, (3,)) + self.assertEqual(project_count, 1) + self.assertEqual(active_project, LEGACY_PROJECT_ID) + self.assertEqual(migrated_events, 1) + self.assertEqual(foreign_key_check, []) + def test_wal_legacy_database_snapshot_includes_committed_rows(self) -> None: events = load_events_from_csv("data/sample/sample_events.csv") with tempfile.TemporaryDirectory() as temp_dir: @@ -199,7 +380,7 @@ def fail_after_first_migration(_version: int) -> None: self.assertEqual(len(backup_paths), 3) - def test_delete_data_removes_migration_snapshots(self) -> None: + def test_project_clear_retains_workspace_migration_snapshots(self) -> None: events = load_events_from_csv("data/sample/sample_events.csv") with tempfile.TemporaryDirectory() as temp_dir: db_path = Path(temp_dir) / "opsmineflow.sqlite3" @@ -212,7 +393,7 @@ def test_delete_data_removes_migration_snapshots(self) -> None: store.clear() backup_paths = list((db_path.parent / "backups").glob("*.sqlite3*")) - self.assertEqual(backup_paths, []) + self.assertEqual(len(backup_paths), 2) def test_newer_schema_is_rejected_without_mutating_database(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: @@ -363,5 +544,90 @@ def _create_legacy_database(db_path: Path, events: list[object], *, table_count: ) +def _create_v2_database(db_path: Path, events: list[object]) -> None: + _create_legacy_database(db_path, events) + applied_at = "2026-07-20T00:00:00+00:00" + with sqlite3.connect(db_path) as connection: + for migration in MIGRATIONS[:2]: + migration.apply(connection) + connection.execute( + "INSERT INTO schema_migrations(version, name, checksum, applied_at) VALUES(?, ?, ?, ?)", + (migration.version, migration.name, migration.checksum, applied_at), + ) + connection.execute(f"PRAGMA user_version = {migration.version}") + + +def _v2_dataset_snapshot(db_path: Path) -> dict[str, list[list[object]]]: + with sqlite3.connect(db_path) as connection: + return { + "events": _fetch_rows(connection, "SELECT event_id, payload_json FROM events ORDER BY event_id"), + "manual_labels": _fetch_rows(connection, "SELECT event_id, label FROM manual_labels ORDER BY event_id"), + "settings": _fetch_rows(connection, "SELECT key, value_json FROM settings ORDER BY key"), + "metadata": _fetch_rows(connection, "SELECT key, value FROM metadata ORDER BY key"), + "import_history": _fetch_rows( + connection, + "SELECT id, source, path, event_count, imported_at FROM import_history ORDER BY id", + ), + "automation_reviews": _fetch_rows( + connection, + "SELECT activity, status, note, updated_at FROM automation_reviews ORDER BY activity", + ), + } + + +def _v3_dataset_snapshot(connection: sqlite3.Connection, project_id: str) -> dict[str, list[list[object]]]: + return { + "events": _fetch_rows( + connection, + "SELECT event_id, payload_json FROM events WHERE project_id = ? ORDER BY event_id", + (project_id,), + ), + "manual_labels": _fetch_rows( + connection, + "SELECT event_id, label FROM manual_labels WHERE project_id = ? ORDER BY event_id", + (project_id,), + ), + "settings": _fetch_rows( + connection, + "SELECT key, value_json FROM settings WHERE project_id = ? ORDER BY key", + (project_id,), + ), + "metadata": _fetch_rows( + connection, + "SELECT key, value FROM metadata WHERE project_id = ? ORDER BY key", + (project_id,), + ), + "import_history": _fetch_rows( + connection, + "SELECT id, source, path, event_count, imported_at FROM import_history " + "WHERE project_id = ? ORDER BY id", + (project_id,), + ), + "automation_reviews": _fetch_rows( + connection, + "SELECT activity, status, note, updated_at FROM automation_reviews " + "WHERE project_id = ? ORDER BY activity", + (project_id,), + ), + } + + +def _fetch_rows( + connection: sqlite3.Connection, + statement: str, + parameters: tuple[object, ...] = (), +) -> list[list[object]]: + return [list(row) for row in connection.execute(statement, parameters).fetchall()] + + +def _dataset_counts(snapshot: dict[str, list[list[object]]]) -> dict[str, int]: + return {name: len(rows) for name, rows in snapshot.items()} + + +def _dataset_hash(snapshot: dict[str, list[list[object]]]) -> str: + canonical = json.dumps(snapshot, ensure_ascii=False, separators=(",", ":"), sort_keys=True) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + def _sha256(path: Path) -> str: return hashlib.sha256(path.read_bytes()).hexdigest()