From 6baf24c0639ebc735650a8a218ffd647bde3571d Mon Sep 17 00:00:00 2001 From: John Spahn <44337821+jspahn80134@users.noreply.github.com> Date: Sun, 9 Nov 2025 14:52:16 -0700 Subject: [PATCH 01/45] Added Capture to Main CodeChat --- .gitignore | 2 + dist-workspace.toml | 2 +- extensions/VSCode/src/extension.ts | 253 +++++++++++- server/log4rs.yml | 2 +- server/src/capture.rs | 628 +++++++++++++++++++++-------- server/src/webserver.rs | 89 +++- 6 files changed, 793 insertions(+), 183 deletions(-) diff --git a/.gitignore b/.gitignore index 1d695e01..e0eb371a 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,5 @@ # # dist build output target/ +capture_config.json +server/capture_config.json diff --git a/dist-workspace.toml b/dist-workspace.toml index 0d75623d..9a5d4fca 100644 --- a/dist-workspace.toml +++ b/dist-workspace.toml @@ -25,7 +25,7 @@ members = ["cargo:server/"] # Config for 'dist' [dist] # The preferred dist version to use in CI (Cargo.toml SemVer syntax) -cargo-dist-version = "0.30.0" +cargo-dist-version = "0.30.2" # CI backends to support ci = "github" # The installers to generate for each app diff --git a/extensions/VSCode/src/extension.ts b/extensions/VSCode/src/extension.ts index cc6eaa76..a9ff0350 100644 --- a/extensions/VSCode/src/extension.ts +++ b/extensions/VSCode/src/extension.ts @@ -105,6 +105,135 @@ let codeChatEditorServer: CodeChatEditorServer | undefined; initServer(ext.extensionPath); } +// Types for talking to the Rust /capture endpoint. +// This mirrors `CaptureEventWire` in webserver.rs. +interface CaptureEventPayload { + user_id: string; + assignment_id?: string; + group_id?: string; + file_path?: string; + event_type: string; + data: any; // sent as JSON +} + +// TODO: replace these with something real (e.g., VS Code settings) +// For now, we hard-code to prove that the pipeline works end-to-end. +const CAPTURE_USER_ID = "test-user"; +const CAPTURE_ASSIGNMENT_ID = "demo-assignment"; +const CAPTURE_GROUP_ID = "demo-group"; + +// Base URL for the CodeChat server's /capture endpoint. +// NOTE: keep this in sync with whatever port your server actually uses. +const CAPTURE_SERVER_BASE = "http://127.0.0.1:8080"; + +// Simple classification of what the user is currently doing. +type ActivityKind = "doc" | "code" | "other"; + +// Language IDs that we treat as "documentation" for the dissertation metrics. +// You can refine this later if you want. +const DOC_LANG_IDS = new Set([ + "markdown", + "plaintext", + "latex", + "restructuredtext", +]); + +// Track the last activity kind and when a reflective-writing (doc) session started. +let lastActivityKind: ActivityKind = "other"; +let docSessionStart: number | null = null; + +// Heuristic: classify a document as documentation vs. code vs. other. +function classifyDocument( + doc: vscode.TextDocument | undefined, +): ActivityKind { + if (!doc) { + return "other"; + } + if (DOC_LANG_IDS.has(doc.languageId)) { + return "doc"; + } + // Everything else we treat as code for now. + return "code"; +} + +// Update activity state, emit switch + doc_session events as needed. +function noteActivity(kind: ActivityKind, filePath?: string) { + const now = Date.now(); + + // Handle entering / leaving a "doc" session. + if (kind === "doc") { + if (docSessionStart === null) { + // Starting a new reflective-writing session. + docSessionStart = now; + void sendCaptureEvent(CAPTURE_SERVER_BASE, "session_start", filePath, { + mode: "doc", + }); + } + } else { + if (docSessionStart !== null) { + // Ending a reflective-writing session. + const durationMs = now - docSessionStart; + docSessionStart = null; + void sendCaptureEvent(CAPTURE_SERVER_BASE, "doc_session", filePath, { + duration_ms: durationMs, + duration_seconds: durationMs / 1000.0, + }); + void sendCaptureEvent(CAPTURE_SERVER_BASE, "session_end", filePath, { + mode: "doc", + }); + } + } + + // If we switched between doc and code, log a switch_pane event. + const docOrCode = (k: ActivityKind) => k === "doc" || k === "code"; + if ( + docOrCode(lastActivityKind) && + docOrCode(kind) && + kind !== lastActivityKind + ) { + void sendCaptureEvent(CAPTURE_SERVER_BASE, "switch_pane", filePath, { + from: lastActivityKind, + to: kind, + }); + } + + lastActivityKind = kind; +} + +// Helper to send a capture event to the Rust server. +async function sendCaptureEvent( + serverBaseUrl: string, // e.g. "http://127.0.0.1:8080" + eventType: string, + filePath?: string, + data: any = {} +): Promise { + const payload: CaptureEventPayload = { + user_id: CAPTURE_USER_ID, + assignment_id: CAPTURE_ASSIGNMENT_ID, + group_id: CAPTURE_GROUP_ID, + file_path: filePath, + event_type: eventType, + data, + }; + + try { + const resp = await fetch(`${serverBaseUrl}/capture`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }); + + if (!resp.ok) { + console.error("Capture event failed:", resp.status, await resp.text()); + } + } catch (err) { + console.error("Error sending capture event:", err); + } +} + + // Activation/deactivation // ----------------------- // @@ -121,6 +250,18 @@ export const activate = (context: vscode.ExtensionContext) => { async () => { console_log("CodeChat Editor extension: starting."); + // CAPTURE: mark the start of an editor session. + const active = vscode.window.activeTextEditor; + const startFilePath = active?.document.fileName; + void sendCaptureEvent( + CAPTURE_SERVER_BASE, + "session_start", + startFilePath, + { + mode: "vscode_extension", + }, + ); + if (!subscribed) { subscribed = true; @@ -142,6 +283,31 @@ export const activate = (context: vscode.ExtensionContext) => { event.reason }, ${format_struct(event.contentChanges)}.`, ); + + // CAPTURE: classify this as documentation vs. code and log a write_* event. + const doc = event.document; + const kind = classifyDocument(doc); + const filePath = doc.fileName; + const charsTyped = event.contentChanges + .map((c) => c.text.length) + .reduce((a, b) => a + b, 0); + + if (kind === "doc") { + void sendCaptureEvent(CAPTURE_SERVER_BASE, "write_doc", filePath, { + chars_typed: charsTyped, + languageId: doc.languageId, + }); + } else if (kind === "code") { + void sendCaptureEvent(CAPTURE_SERVER_BASE, "write_code", filePath, { + chars_typed: charsTyped, + languageId: doc.languageId, + }); + } + + // Update our notion of current activity + doc session. + noteActivity(kind, filePath); + + // Existing behavior: trigger CodeChat render. send_update(true); }), ); @@ -158,22 +324,92 @@ export const activate = (context: vscode.ExtensionContext) => { ignore_active_editor_change = false; return; } + + // CAPTURE: update activity + possible switch_pane/doc_session. + const doc = event.document; + const kind = classifyDocument(doc); + const filePath = doc.fileName; + noteActivity(kind, filePath); + send_update(false); }), ); context.subscriptions.push( vscode.window.onDidChangeTextEditorSelection( - (_event) => { + (event) => { if (ignore_selection_change) { ignore_selection_change = false; return; } + + // CAPTURE: treat a selection change as "activity" in this document. + const doc = event.textEditor.document; + const kind = classifyDocument(doc); + const filePath = doc.fileName; + noteActivity(kind, filePath); + send_update(false); }, ), ); - } + // Capture event: listen for file saves (dissertation instrumentation) + context.subscriptions.push( + vscode.workspace.onDidSaveTextDocument((doc) => { + // This is the first full end-to-end capture test. + // When a document is saved, send an event to the Rust server. + + void sendCaptureEvent( + "http://127.0.0.1:8080", // <-- update if your server uses a different port + "save", + doc.fileName, + { + reason: "manual_save", + languageId: doc.languageId, + lineCount: doc.lineCount, + }, + ); + }), + ); + + // Capture: start of a debug/run session. + context.subscriptions.push( + vscode.debug.onDidStartDebugSession((session) => { + const active = vscode.window.activeTextEditor; + const filePath = active?.document.fileName; + void sendCaptureEvent( + CAPTURE_SERVER_BASE, + "run", + filePath, + { + sessionName: session.name, + sessionType: session.type, + }, + ); + }), + ); + + // Capture: compile/build events via VS Code tasks. + context.subscriptions.push( + vscode.tasks.onDidStartTaskProcess((e) => { + const active = vscode.window.activeTextEditor; + const filePath = active?.document.fileName; + const task = e.execution.task; + void sendCaptureEvent( + CAPTURE_SERVER_BASE, + "compile", + filePath, + { + taskName: task.name, + taskSource: task.source, + definition: task.definition, + processId: e.processId, + }, + ); + }), + ); + + } // if subscribed // Get the CodeChat Client's location from the VSCode // configuration. @@ -529,6 +765,19 @@ export const activate = (context: vscode.ExtensionContext) => { // On deactivation, close everything down. export const deactivate = async () => { console_log("CodeChat Editor extension: deactivating."); + + // CAPTURE: mark the end of an editor session. + const active = vscode.window.activeTextEditor; + const endFilePath = active?.document.fileName; + await sendCaptureEvent( + CAPTURE_SERVER_BASE, + "session_end", + endFilePath, + { + mode: "vscode_extension", + }, + ); + await stop_client(); webview_panel?.dispose(); console_log("CodeChat Editor extension: deactivated."); diff --git a/server/log4rs.yml b/server/log4rs.yml index ff3752ed..0a36bfed 100644 --- a/server/log4rs.yml +++ b/server/log4rs.yml @@ -35,7 +35,7 @@ appenders: pattern: "{d} {l} {t} {L} - {m}{n}" root: - level: info + level: debug appenders: - console_appender - file_appender \ No newline at end of file diff --git a/server/src/capture.rs b/server/src/capture.rs index 3f8f7c15..174a4cbb 100644 --- a/server/src/capture.rs +++ b/server/src/capture.rs @@ -13,227 +13,503 @@ // You should have received a copy of the GNU General Public License along with // the CodeChat Editor. If not, see // [http://www.gnu.org/licenses](http://www.gnu.org/licenses). -/// # `Capture.rs` -- Capture CodeChat Editor Events -// ## Submodules -// -// ## Imports -// -// Standard library -use indoc::indoc; -use std::fs; + +/// `capture.rs` -- Capture CodeChat Editor Events +/// ============================================== +/// +/// This module provides an asynchronous event capture facility backed by a +/// PostgreSQL database. It is designed to support the dissertation study by +/// recording process-level data such as: +/// +/// * Frequency and timing of writing entries +/// * Edits to documentation and code +/// * Switches between documentation and coding activity +/// * Duration of engagement with reflective writing +/// * Save, compile, and run events +/// +/// Events are sent from the client (browser and/or VS Code extension) to the +/// server as JSON. The server enqueues events into an asynchronous worker which +/// performs batched inserts into the `events` table. +/// +/// Database schema +/// --------------- +/// +/// The following SQL statement creates the `events` table used by this module: +/// +/// ```sql +/// CREATE TABLE events ( +/// id SERIAL PRIMARY KEY, +/// user_id TEXT NOT NULL, +/// assignment_id TEXT, +/// group_id TEXT, +/// file_path TEXT, +/// event_type TEXT NOT NULL, +/// timestamp TEXT NOT NULL, +/// data TEXT +/// ); +/// ``` +/// +/// * `user_id` – participant identifier (student id, pseudonym, etc.). +/// * `assignment_id` – logical assignment / lab identifier. +/// * `group_id` – optional grouping (treatment / comparison, section). +/// * `file_path` – logical path of the file being edited. +/// * `event_type` – coarse event type (see `event_type` constants below). +/// * `timestamp` – RFC3339 timestamp (in UTC). +/// * `data` – JSON payload with event-specific details. + use std::io; -use std::path::Path; -use std::sync::Arc; -// Third-party -use chrono::Local; -use log::{error, info}; +use chrono::{DateTime, Utc}; +use log::{debug, error, info, warn}; use serde::{Deserialize, Serialize}; -use tokio::sync::Mutex; +use tokio::sync::mpsc; use tokio_postgres::{Client, NoTls}; -// Local - -/* ## The Event Structure: - - The `Event` struct represents an event to be stored in the database. - - Fields: - `user_id`: The ID of the user associated with the event. - - `event_type`: The type of event (e.g., "keystroke", "file_open"). - `data`: - Optional additional data associated with the event. +/// Canonical event type strings. Keep these stable for analysis. +pub mod event_types { + pub const WRITE_DOC: &str = "write_doc"; + pub const WRITE_CODE: &str = "write_code"; + pub const SWITCH_PANE: &str = "switch_pane"; + pub const DOC_SESSION: &str = "doc_session"; // duration of reflective writing + pub const SAVE: &str = "save"; + pub const COMPILE: &str = "compile"; + pub const RUN: &str = "run"; + pub const SESSION_START: &str = "session_start"; + pub const SESSION_END: &str = "session_end"; +} - ### Example +/// Configuration used to construct the PostgreSQL connection string. +/// +/// You can populate this from a JSON file or environment variables in +/// `main.rs`; this module stays agnostic. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CaptureConfig { + pub host: String, + pub user: String, + pub password: String, + pub dbname: String, + /// Optional: application-level identifier for this deployment (e.g., course + /// code or semester). Not stored in the DB directly; callers can embed this + /// in `data` if desired. + #[serde(default)] + pub app_id: Option, +} - let event = Event { user_id: "user123".to_string(), event_type: - "keystroke".to_string(), data: Some("Pressed key A".to_string()), }; -*/ +impl CaptureConfig { + /// Build a libpq-style connection string. + pub fn to_conn_str(&self) -> String { + format!( + "host={} user={} password={} dbname={}", + self.host, self.user, self.password, self.dbname + ) + } +} -#[derive(Deserialize, Debug)] -pub struct Event { +/// The in-memory representation of a single capture event. +#[derive(Debug, Clone)] +pub struct CaptureEvent { pub user_id: String, + pub assignment_id: Option, + pub group_id: Option, + pub file_path: Option, pub event_type: String, - pub data: Option, + /// When the event occurred, in UTC. + pub timestamp: DateTime, + /// Event-specific payload, stored as JSON text in the DB. + pub data: serde_json::Value, } -/* - ## The Config Structure: - - The `Config` struct represents the database connection parameters read from - `config.json`. - - Fields: - `db_host`: The hostname or IP address of the database server. - - `db_user`: The username for the database connection. - `db_password`: The - password for the database connection. - `db_name`: The name of the database. - - let config = Config { db_host: "localhost".to_string(), db_user: - "your_db_user".to_string(), db_password: "your_db_password".to_string(), - db_name: "your_db_name".to_string(), }; -*/ +impl CaptureEvent { + /// Convenience constructor when the caller already has a timestamp. + pub fn new( + user_id: String, + assignment_id: Option, + group_id: Option, + file_path: Option, + event_type: impl Into, + timestamp: DateTime, + data: serde_json::Value, + ) -> Self { + Self { + user_id, + assignment_id, + group_id, + file_path, + event_type: event_type.into(), + timestamp, + data, + } + } -#[derive(Deserialize, Serialize, Debug)] -pub struct Config { - pub db_ip: String, - pub db_user: String, - pub db_password: String, - pub db_name: String, + /// Convenience constructor which uses the current time. + pub fn now( + user_id: String, + assignment_id: Option, + group_id: Option, + file_path: Option, + event_type: impl Into, + data: serde_json::Value, + ) -> Self { + Self::new( + user_id, + assignment_id, + group_id, + file_path, + event_type, + Utc::now(), + data, + ) + } } -/* - - ## The EventCapture Structure: - - The `EventCapture` struct provides methods to interact with the database. It -holds a `tokio_postgres::Client` for database operations. - -### Usage Example - -#\[tokio::main\] async fn main() -> Result<(), Box> { - -``` - // Create an instance of EventCapture using the configuration file - let event_capture = EventCapture::new("config.json").await?; - - // Create an event - let event = Event { - user_id: "user123".to_string(), - event_type: "keystroke".to_string(), - data: Some("Pressed key A".to_string()), - }; - - // Insert the event into the database - event_capture.insert_event(event).await?; - - Ok(()) -``` -} */ +/// Internal worker message. Identical to `CaptureEvent`, but separated in case +/// we later want to add batching / flush control signals. +type WorkerMsg = CaptureEvent; +/// Handle used by the rest of the server to record events. +/// +/// Cloning this handle is cheap: it only clones an `mpsc::UnboundedSender`. +#[derive(Clone)] pub struct EventCapture { - db_client: Arc>, + tx: mpsc::UnboundedSender, } -/* - ## The EventCapture Implementation -*/ - impl EventCapture { - /* - Creates a new `EventCapture` instance by reading the database connection parameters from the `config.json` file and connecting to the PostgreSQL database. - # Arguments - - config_path: The file path to the config.json file. - - # Returns - - A `Result` containing an `EventCapture` instance - */ - - pub async fn new>(config_path: P) -> Result { - // Read the configuration file - let config_content = fs::read_to_string(config_path).map_err(io::Error::other)?; - let config: Config = serde_json::from_str(&config_content) - .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; - - // Build the connection string for the PostgreSQL database - let conn_str = format!( - "host={} user={} password={} dbname={}", - config.db_ip, config.db_user, config.db_password, config.db_name - ); - + /// Create a new `EventCapture` instance and spawn a background worker which + /// consumes events and inserts them into PostgreSQL. + /// + /// This function is synchronous so it can be called from non-async server + /// setup code. It spawns an async task internally which performs the + /// database connection and event processing. + pub fn new(config: CaptureConfig) -> Result { + let conn_str = config.to_conn_str(); + + // High-level DB connection details (no password). info!( - "Attempting Capture Database Connection. IP:[{}] Username:[{}] Database Name:[{}]", - config.db_ip, config.db_user, config.db_name + "Capture: preparing PostgreSQL connection (host={}, dbname={}, user={}, app_id={:?})", + config.host, config.dbname, config.user, config.app_id ); + debug!("Capture: raw PostgreSQL connection string: {}", conn_str); - // Connect to the database asynchronously - let (client, connection) = tokio_postgres::connect(&conn_str, NoTls) - .await - .map_err(|e| io::Error::new(io::ErrorKind::ConnectionRefused, e))?; + let (tx, mut rx) = mpsc::unbounded_channel::(); - // Spawn a task to manage the database connection in the background + // Spawn a background task that will connect to PostgreSQL and then + // process events. This task runs on the Tokio/Actix runtime once the + // system starts, so the caller does not need to be async. tokio::spawn(async move { - if let Err(e) = connection.await { - error!("Database connection error: [{e}]"); + info!("Capture: attempting to connect to PostgreSQL…"); + + match tokio_postgres::connect(&conn_str, NoTls).await { + Ok((client, connection)) => { + info!("Capture: successfully connected to PostgreSQL."); + + // Drive the connection in its own task. + tokio::spawn(async move { + if let Err(err) = connection.await { + error!("Capture PostgreSQL connection error: {err}"); + } + }); + + // Main event loop: pull events off the channel and insert + // them into the database. + while let Some(event) = rx.recv().await { + debug!( + "Capture: inserting event: type={}, user_id={}, assignment_id={:?}, group_id={:?}, file_path={:?}", + event.event_type, + event.user_id, + event.assignment_id, + event.group_id, + event.file_path + ); + + if let Err(err) = insert_event(&client, &event).await { + error!( + "Capture: FAILED to insert event (type={}, user_id={}): {err}", + event.event_type, event.user_id + ); + } else { + debug!("Capture: event insert successful."); + } + } + + info!("Capture: event channel closed; background worker exiting."); + } + Err(err) => { + // NOTE: we *don't* pass `err` twice here; `{err}` in the format + // string already grabs the local `err` binding. + error!( + "Capture: FAILED to connect to PostgreSQL (host={}, dbname={}, user={}): {err}", + config.host, + config.dbname, + config.user, + ); + // Drain and drop any events so we don't hold the sender. + warn!("Capture: draining pending events after failed DB connection."); + while rx.recv().await.is_some() {} + warn!("Capture: all pending events dropped due to connection failure."); + } } }); - info!( - "Connected to Database [{}] as User [{}]", - config.db_name, config.db_user + Ok(Self { tx }) + } + + /// Enqueue an event for insertion. This is non-blocking. + pub fn log(&self, event: CaptureEvent) { + debug!( + "Capture: queueing event: type={}, user_id={}, assignment_id={:?}, group_id={:?}, file_path={:?}", + event.event_type, + event.user_id, + event.assignment_id, + event.group_id, + event.file_path ); - Ok(EventCapture { - db_client: Arc::new(Mutex::new(client)), - }) + if let Err(err) = self.tx.send(event) { + error!("Capture: FAILED to enqueue capture event: {err}"); + } } +} - /* - Inserts an event into the database. +/// Insert a single event into the `events` table. +async fn insert_event(client: &Client, event: &CaptureEvent) -> Result { + let timestamp = event.timestamp.to_rfc3339(); + let data_text = event.data.to_string(); + + debug!( + "Capture: executing INSERT for user_id={}, event_type={}, timestamp={}", + event.user_id, event.event_type, timestamp + ); + + client + .execute( + "INSERT INTO events \ + (user_id, assignment_id, group_id, file_path, event_type, timestamp, data) \ + VALUES ($1, $2, $3, $4, $5, $6, $7)", + &[ + &event.user_id, + &event.assignment_id, + &event.group_id, + &event.file_path, + &event.event_type, + ×tamp, + &data_text, + ], + ) + .await +} - # Arguments - - `event`: An `Event` instance containing the event data to insert. +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn capture_config_to_conn_str_is_well_formed() { + let cfg = CaptureConfig { + host: "localhost".to_string(), + user: "alice".to_string(), + password: "secret".to_string(), + dbname: "codechat_capture".to_string(), + app_id: Some("spring25-study".to_string()), + }; + + let conn = cfg.to_conn_str(); + // Very simple checks: we don't care about ordering beyond what we format. + assert!(conn.contains("host=localhost")); + assert!(conn.contains("user=alice")); + assert!(conn.contains("password=secret")); + assert!(conn.contains("dbname=codechat_capture")); + } - # Returns - A `Result` indicating success or containing a `tokio_postgres::Error`. + #[test] + fn capture_event_new_sets_all_fields() { + let ts = Utc::now(); + + let ev = CaptureEvent::new( + "user123".to_string(), + Some("lab1".to_string()), + Some("groupA".to_string()), + Some("/path/to/file.rs".to_string()), + "write_doc", + ts, + json!({ "chars_typed": 42 }), + ); - # Example - #[tokio::main] - async fn main() -> Result<(), Box> { - let event_capture = EventCapture::new("config.json").await?; + assert_eq!(ev.user_id, "user123"); + assert_eq!(ev.assignment_id.as_deref(), Some("lab1")); + assert_eq!(ev.group_id.as_deref(), Some("groupA")); + assert_eq!(ev.file_path.as_deref(), Some("/path/to/file.rs")); + assert_eq!(ev.event_type, "write_doc"); + assert_eq!(ev.timestamp, ts); + assert_eq!(ev.data, json!({ "chars_typed": 42 })); + } - let event = Event { - user_id: "user123".to_string(), - event_type: "keystroke".to_string(), - data: Some("Pressed key A".to_string()), - }; + #[test] + fn capture_event_now_uses_current_time_and_fields() { + let before = Utc::now(); + let ev = CaptureEvent::now( + "user123".to_string(), + None, + None, + None, + "save", + json!({ "reason": "manual" }), + ); + let after = Utc::now(); + + assert_eq!(ev.user_id, "user123"); + assert!(ev.assignment_id.is_none()); + assert!(ev.group_id.is_none()); + assert!(ev.file_path.is_none()); + assert_eq!(ev.event_type, "save"); + assert_eq!(ev.data, json!({ "reason": "manual" })); + + // Timestamp sanity check: it should be between before and after + assert!(ev.timestamp >= before); + assert!(ev.timestamp <= after); + } - event_capture.insert_event(event).await?; - Ok(()) - } - */ + #[test] + fn capture_config_json_round_trip() { + let json_text = r#" + { + "host": "db.example.com", + "user": "bob", + "password": "hunter2", + "dbname": "cc_events", + "app_id": "fall25" + } + "#; + + let cfg: CaptureConfig = serde_json::from_str(json_text).expect("JSON should parse"); + assert_eq!(cfg.host, "db.example.com"); + assert_eq!(cfg.user, "bob"); + assert_eq!(cfg.password, "hunter2"); + assert_eq!(cfg.dbname, "cc_events"); + assert_eq!(cfg.app_id.as_deref(), Some("fall25")); + + // And it should serialize back to JSON without error + let _back = serde_json::to_string(&cfg).expect("Should serialize"); + } - pub async fn insert_event(&self, event: Event) -> Result<(), io::Error> { - let current_time = Local::now(); - let formatted_time = current_time.to_rfc3339(); + use std::fs; + use tokio::time::{sleep, Duration}; + + /// Integration-style test: verify that EventCapture actually inserts into the DB. + /// + /// Reads connection parameters from `capture_config.json` in the current working directory. + /// Logs the config and connection details via log4rs so you can confirm what is used. + /// + /// Run this test with: + /// cargo test event_capture_inserts_event_into_db -- --ignored --nocapture + /// + /// You must have a PostgreSQL database and a `capture_config.json` file such as: + /// { + /// "host": "localhost", + /// "user": "codechat_test_user", + /// "password": "codechat_test_password", + /// "dbname": "codechat_capture_test", + /// "app_id": "integration-test" + /// } + #[tokio::test] + #[ignore] + async fn event_capture_inserts_event_into_db() -> Result<(), Box> { + // Initialize logging for this test, using the same log4rs.yml as the server. + // If logging is already initialized, this will just return an error which we ignore. + let _ = log4rs::init_file("log4rs.yml", Default::default()); + + // 1. Load the capture configuration from file. + let cfg_text = fs::read_to_string("capture_config.json") + .expect("capture_config.json must exist in project root for this test"); + let cfg: CaptureConfig = + serde_json::from_str(&cfg_text).expect("capture_config.json must be valid JSON"); + + log::info!( + "TEST: Loaded DB config from capture_config.json: host={}, user={}, dbname={}, app_id={:?}", + cfg.host, + cfg.user, + cfg.dbname, + cfg.app_id + ); - // SQL statement to insert the event into the 'events' table - let stmt = indoc! {" - INSERT INTO events (user_id, event_type, timestamp, data) - VALUES ($1, $2, $3, $4) - "}; + // 2. Connect directly for setup + verification. + let conn_str = cfg.to_conn_str(); + log::info!("TEST: Attempting direct tokio_postgres connection for verification."); - // Acquire a lock on the database client for thread-safe access - let client = self.db_client.lock().await; + let (client, connection) = tokio_postgres::connect(&conn_str, NoTls).await?; + tokio::spawn(async move { + if let Err(e) = connection.await { + log::error!("TEST: direct connection error: {e}"); + } + }); - // Execute the SQL statement with the event data + // 3. Ensure the `events` table exists and is empty. client - .execute( - stmt, - &[ - &event.user_id, - &event.event_type, - &formatted_time, - &event.data, - ], + .batch_execute( + "CREATE TABLE IF NOT EXISTS events ( + id SERIAL PRIMARY KEY, + user_id TEXT NOT NULL, + assignment_id TEXT, + group_id TEXT, + file_path TEXT, + event_type TEXT NOT NULL, + timestamp TEXT NOT NULL, + data TEXT + ); + TRUNCATE TABLE events;", ) - .await - .map_err(io::Error::other)?; + .await?; + log::info!("TEST: events table ensured and truncated."); + + // 4. Start the EventCapture worker using the loaded config. + let capture = EventCapture::new(cfg.clone())?; + log::info!("TEST: EventCapture worker started."); + + // 5. Log a test event. + let expected_data = json!({ "chars_typed": 123 }); + let event = CaptureEvent::now( + "test-user".to_string(), + Some("hw1".to_string()), + Some("groupA".to_string()), + Some("/tmp/test.rs".to_string()), + event_types::WRITE_DOC, + expected_data.clone(), + ); + + log::info!("TEST: logging a test capture event."); + capture.log(event); - info!("Event inserted into database: {event:?}"); + // 6. Give the background worker time to insert the event. + sleep(Duration::from_millis(300)).await; + // 7. Verify the inserted record. + let row = client + .query_one( + "SELECT user_id, assignment_id, group_id, file_path, event_type, data + FROM events + ORDER BY id DESC + LIMIT 1", + &[], + ) + .await?; + + let user_id: String = row.get(0); + let assignment_id: Option = row.get(1); + let group_id: Option = row.get(2); + let file_path: Option = row.get(3); + let event_type: String = row.get(4); + let data_text: String = row.get(5); + let data_value: serde_json::Value = serde_json::from_str(&data_text)?; + + assert_eq!(user_id, "test-user"); + assert_eq!(assignment_id.as_deref(), Some("hw1")); + assert_eq!(group_id.as_deref(), Some("groupA")); + assert_eq!(file_path.as_deref(), Some("/tmp/test.rs")); + assert_eq!(event_type, event_types::WRITE_DOC); + assert_eq!(data_value, expected_data); + + log::info!("✅ TEST: EventCapture integration test succeeded and wrote to database."); Ok(()) } } - -/* Database Schema (SQL DDL) - -The following SQL statement creates the `events` table used by this library: - -CREATE TABLE events ( id SERIAL PRIMARY KEY, user_id TEXT NOT NULL, -event_type TEXT NOT NULL, timestamp TEXT NOT NULL, data TEXT ); - -- **`id SERIAL PRIMARY KEY`**: Auto-incrementing primary key. -- **`user_id TEXT NOT NULL`**: The ID of the user associated with the event. -- **`event_type TEXT NOT NULL`**: The type of event. -- **`timestamp TEXT NOT NULL`**: The timestamp of the event. -- **`data TEXT`**: Optional additional data associated with the event. - **Note:** Ensure this table exists in your PostgreSQL database before using - the library. */ diff --git a/server/src/webserver.rs b/server/src/webserver.rs index c178db35..3fbb8242 100644 --- a/server/src/webserver.rs +++ b/server/src/webserver.rs @@ -36,15 +36,17 @@ use std::{ // ### Third-party use actix_files; + use actix_web::{ App, HttpRequest, HttpResponse, HttpServer, dev::{Server, ServerHandle, ServiceFactory, ServiceRequest}, error::Error, - get, + get, post, http::header::{ContentType, DispositionType}, middleware, web::{self, Data}, }; + use actix_web_httpauth::{extractors::basic::BasicAuth, middleware::HttpAuthentication}; use actix_ws::AggregatedMessage; use bytes::Bytes; @@ -86,6 +88,10 @@ use crate::processing::{ CodeChatForWeb, TranslationResultsString, find_path_to_toc, source_to_codechat_for_web_string, }; +use crate::capture::{EventCapture, CaptureConfig, CaptureEvent}; + +use chrono::Utc; + // Data structures // --------------- // @@ -302,6 +308,8 @@ pub struct AppState { pub connection_id: Arc>>, /// The auth credentials if authentication is used. credentials: Option, + // Added to support capture - JDS - 11/2025 + pub capture: Option, } pub type WebAppState = web::Data; @@ -312,6 +320,20 @@ pub struct Credentials { pub password: String, } +/// JSON payload received from clients for capture events. +/// +/// The server will supply the timestamp; clients do not need to send it. +#[derive(Debug, Deserialize)] +pub struct CaptureEventWire { + pub user_id: String, + pub assignment_id: Option, + pub group_id: Option, + pub file_path: Option, + pub event_type: String, + /// Arbitrary event-specific data stored as JSON. + pub data: serde_json::Value, +} + // Macros // ------ /// Create a macro to report an error when enqueueing an item. @@ -495,6 +517,31 @@ async fn stop(app_state: WebAppState) -> HttpResponse { HttpResponse::NoContent().finish() } +#[post("/capture")] +async fn capture_endpoint( + app_state: WebAppState, + payload: web::Json, +) -> HttpResponse { + let wire = payload.into_inner(); + + if let Some(capture) = &app_state.capture { + let event = CaptureEvent { + user_id: wire.user_id, + assignment_id: wire.assignment_id, + group_id: wire.group_id, + file_path: wire.file_path, + event_type: wire.event_type, + // Server decides when the event is recorded. + timestamp: Utc::now(), + data: wire.data, + }; + + capture.log(event); + } + + HttpResponse::Ok().finish() +} + // Get the `mode` query parameter to determine `is_test_mode`; default to // `false`. pub fn get_test_mode(req: &HttpRequest) -> bool { @@ -1326,8 +1373,6 @@ pub fn setup_server( addr: &SocketAddr, credentials: Option, ) -> std::io::Result<(Server, Data)> { - // Connect to the Capture Database - //let _event_capture = EventCapture::new("config.json").await?; // Pre-load the bundled files before starting the webserver. let _ = &*BUNDLED_FILES_MAP; @@ -1411,6 +1456,42 @@ pub fn configure_logger(level: LevelFilter) -> Result<(), Box) -> WebAppState { + // Initialize event capture from a config file (optional). + let capture: Option = { + // Build path: /capture_config.json + let mut config_path = ROOT_PATH.lock().unwrap().clone(); + config_path.push("capture_config.json"); + + match fs::read_to_string(&config_path) { + Ok(json) => { + match serde_json::from_str::(&json) { + Ok(cfg) => match EventCapture::new(cfg) { + Ok(ec) => { + eprintln!("Capture: enabled (config file: {config_path:?})"); + Some(ec) + } + Err(err) => { + eprintln!("Capture: failed to initialize from {config_path:?}: {err}"); + None + } + }, + Err(err) => { + eprintln!( + "Capture: invalid JSON in {config_path:?}: {err}" + ); + None + } + } + } + Err(err) => { + eprintln!( + "Capture: disabled (config file not found or unreadable: {config_path:?}: {err})" + ); + None + } + } + }; + web::Data::new(AppState { server_handle: Mutex::new(None), filewatcher_next_connection_id: Mutex::new(0), @@ -1421,6 +1502,7 @@ pub fn make_app_data(credentials: Option) -> WebAppState { client_queues: Arc::new(Mutex::new(HashMap::new())), connection_id: Arc::new(Mutex::new(HashSet::new())), credentials, + capture, }) } @@ -1450,6 +1532,7 @@ where .service(vscode_client_framework) .service(ping) .service(stop) + .service(capture_endpoint) // Reroute to the filewatcher filesystem for typical user-requested // URLs. .route("/", web::get().to(filewatcher_root_fs_redirect)) From d3f6b543870c896d88d74eba9ff4e6b7bd7c3d6a Mon Sep 17 00:00:00 2001 From: John Spahn <44337821+jspahn80134@users.noreply.github.com> Date: Fri, 12 Dec 2025 10:05:03 -0700 Subject: [PATCH 02/45] Merged capture code with latest extension --- extensions/VSCode/src/extension.ts | 571 +++++++++++++++++------------ 1 file changed, 340 insertions(+), 231 deletions(-) diff --git a/extensions/VSCode/src/extension.ts b/extensions/VSCode/src/extension.ts index 1e2bc9bb..c09dde45 100644 --- a/extensions/VSCode/src/extension.ts +++ b/extensions/VSCode/src/extension.ts @@ -3,7 +3,8 @@ // This file is part of the CodeChat Editor. The CodeChat Editor is free // software: you can redistribute it and/or modify it under the terms of the GNU // General Public License as published by the Free Software Foundation, either -// version 3 of the License, or (at your option) any later version. +// version 3 of the License, or (at your option) any later version of the GNU +// General Public License. // // The CodeChat Editor is distributed in the hope that it will be useful, but // WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or @@ -110,6 +111,136 @@ let codeChatEditorServer: CodeChatEditorServer | undefined; initServer(ext.extensionPath); } +// ----------------------------------------------------------------------------- +// CAPTURE (Dissertation instrumentation) +// ----------------------------------------------------------------------------- + +// Types for talking to the Rust /capture endpoint. +// This mirrors `CaptureEventWire` in webserver.rs. +interface CaptureEventPayload { + user_id: string; + assignment_id?: string; + group_id?: string; + file_path?: string; + event_type: string; + data: any; // sent as JSON +} + +// TODO: replace these with something real (e.g., VS Code settings) +// For now, we hard-code to prove that the pipeline works end-to-end. +const CAPTURE_USER_ID = "test-user"; +const CAPTURE_ASSIGNMENT_ID = "demo-assignment"; +const CAPTURE_GROUP_ID = "demo-group"; + +// Base URL for the CodeChat server's /capture endpoint. +// NOTE: keep this in sync with whatever port your server actually uses. +const CAPTURE_SERVER_BASE = "http://127.0.0.1:8080"; + +// Simple classification of what the user is currently doing. +type ActivityKind = "doc" | "code" | "other"; + +// Language IDs that we treat as "documentation" for the dissertation metrics. +// You can refine this later if you want. +const DOC_LANG_IDS = new Set([ + "markdown", + "plaintext", + "latex", + "restructuredtext", +]); + +// Track the last activity kind and when a reflective-writing (doc) session started. +let lastActivityKind: ActivityKind = "other"; +let docSessionStart: number | null = null; + +// Heuristic: classify a document as documentation vs. code vs. other. +function classifyDocument(doc: vscode.TextDocument | undefined): ActivityKind { + if (!doc) { + return "other"; + } + if (DOC_LANG_IDS.has(doc.languageId)) { + return "doc"; + } + // Everything else we treat as code for now. + return "code"; +} + +// Helper to send a capture event to the Rust server. +async function sendCaptureEvent( + serverBaseUrl: string, // e.g. "http://127.0.0.1:8080" + eventType: string, + filePath?: string, + data: any = {}, +): Promise { + const payload: CaptureEventPayload = { + user_id: CAPTURE_USER_ID, + assignment_id: CAPTURE_ASSIGNMENT_ID, + group_id: CAPTURE_GROUP_ID, + file_path: filePath, + event_type: eventType, + data, + }; + + try { + const resp = await fetch(`${serverBaseUrl}/capture`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }); + + if (!resp.ok) { + console.error( + "Capture event failed:", + resp.status, + await resp.text(), + ); + } + } catch (err) { + console.error("Error sending capture event:", err); + } +} + +// Update activity state, emit switch + doc_session events as needed. +function noteActivity(kind: ActivityKind, filePath?: string) { + const now = Date.now(); + + // Handle entering / leaving a "doc" session. + if (kind === "doc") { + if (docSessionStart === null) { + // Starting a new reflective-writing session. + docSessionStart = now; + void sendCaptureEvent(CAPTURE_SERVER_BASE, "session_start", filePath, { + mode: "doc", + }); + } + } else { + if (docSessionStart !== null) { + // Ending a reflective-writing session. + const durationMs = now - docSessionStart; + docSessionStart = null; + void sendCaptureEvent(CAPTURE_SERVER_BASE, "doc_session", filePath, { + duration_ms: durationMs, + duration_seconds: durationMs / 1000.0, + }); + void sendCaptureEvent(CAPTURE_SERVER_BASE, "session_end", filePath, { + mode: "doc", + }); + } + } + + // If we switched between doc and code, log a switch_pane event. + const docOrCode = (k: ActivityKind) => k === "doc" || k === "code"; + if (docOrCode(lastActivityKind) && docOrCode(kind) && kind !== lastActivityKind) { + void sendCaptureEvent(CAPTURE_SERVER_BASE, "switch_pane", filePath, { + from: lastActivityKind, + to: kind, + }); + } + + lastActivityKind = kind; +} + // Activation/deactivation // ----------------------------------------------------------------------------- // @@ -126,6 +257,18 @@ export const activate = (context: vscode.ExtensionContext) => { async () => { console_log("CodeChat Editor extension: starting."); + // CAPTURE: mark the start of an editor session. + const active = vscode.window.activeTextEditor; + const startFilePath = active?.document.fileName; + void sendCaptureEvent( + CAPTURE_SERVER_BASE, + "session_start", + startFilePath, + { + mode: "vscode_extension", + }, + ); + if (!subscribed) { subscribed = true; @@ -147,6 +290,40 @@ export const activate = (context: vscode.ExtensionContext) => { event.reason }, ${format_struct(event.contentChanges)}.`, ); + + // CAPTURE: classify this as documentation vs. code and log a write_* event. + const doc = event.document; + const kind = classifyDocument(doc); + const filePath = doc.fileName; + const charsTyped = event.contentChanges + .map((c) => c.text.length) + .reduce((a, b) => a + b, 0); + + if (kind === "doc") { + void sendCaptureEvent( + CAPTURE_SERVER_BASE, + "write_doc", + filePath, + { + chars_typed: charsTyped, + languageId: doc.languageId, + }, + ); + } else if (kind === "code") { + void sendCaptureEvent( + CAPTURE_SERVER_BASE, + "write_code", + filePath, + { + chars_typed: charsTyped, + languageId: doc.languageId, + }, + ); + } + + // Update our notion of current activity + doc session. + noteActivity(kind, filePath); + send_update(true); }), ); @@ -163,48 +340,109 @@ export const activate = (context: vscode.ExtensionContext) => { ignore_active_editor_change = false; return; } + // Skip an update if we've already sent a `CurrentFile` for this editor. - if ( - current_editor === - vscode.window.activeTextEditor - ) { + if (current_editor === vscode.window.activeTextEditor) { return; } + + // CAPTURE: update activity + possible switch_pane/doc_session. + const doc = event.document; + const kind = classifyDocument(doc); + const filePath = doc.fileName; + noteActivity(kind, filePath); + send_update(true); }), ); context.subscriptions.push( - vscode.window.onDidChangeTextEditorSelection( - (_event) => { - if (ignore_selection_change) { - ignore_selection_change = false; - return; - } - console_log( - "CodeChat Editor extension: sending updated cursor/scroll position.", - ); - send_update(false); - }, - ), + vscode.window.onDidChangeTextEditorSelection((event) => { + if (ignore_selection_change) { + ignore_selection_change = false; + return; + } + + console_log( + "CodeChat Editor extension: sending updated cursor/scroll position.", + ); + + // CAPTURE: treat a selection change as "activity" in this document. + const doc = event.textEditor.document; + const kind = classifyDocument(doc); + const filePath = doc.fileName; + noteActivity(kind, filePath); + + send_update(false); + }), + ); + + // CAPTURE: listen for file saves. + context.subscriptions.push( + vscode.workspace.onDidSaveTextDocument((doc) => { + void sendCaptureEvent( + CAPTURE_SERVER_BASE, + "save", + doc.fileName, + { + reason: "manual_save", + languageId: doc.languageId, + lineCount: doc.lineCount, + }, + ); + }), + ); + + // CAPTURE: start of a debug/run session. + context.subscriptions.push( + vscode.debug.onDidStartDebugSession((session) => { + const active = vscode.window.activeTextEditor; + const filePath = active?.document.fileName; + void sendCaptureEvent( + CAPTURE_SERVER_BASE, + "run", + filePath, + { + sessionName: session.name, + sessionType: session.type, + }, + ); + }), + ); + + // CAPTURE: compile/build events via VS Code tasks. + context.subscriptions.push( + vscode.tasks.onDidStartTaskProcess((e) => { + const active = vscode.window.activeTextEditor; + const filePath = active?.document.fileName; + const task = e.execution.task; + void sendCaptureEvent( + CAPTURE_SERVER_BASE, + "compile", + filePath, + { + taskName: task.name, + taskSource: task.source, + definition: task.definition, + processId: e.processId, + }, + ); + }), ); } - // Get the CodeChat Client's location from the VSCode - // configuration. + // Get the CodeChat Client's location from the VSCode configuration. const codechat_client_location_str = vscode.workspace .getConfiguration("CodeChatEditor.Server") .get("ClientLocation"); assert(typeof codechat_client_location_str === "string"); switch (codechat_client_location_str) { case "html": - codechat_client_location = - CodeChatEditorClientLocation.html; + codechat_client_location = CodeChatEditorClientLocation.html; break; case "browser": - codechat_client_location = - CodeChatEditorClientLocation.browser; + codechat_client_location = CodeChatEditorClientLocation.browser; break; default: @@ -213,45 +451,24 @@ export const activate = (context: vscode.ExtensionContext) => { // Create or reveal the webview panel; if this is an external // browser, we'll open it after the client is created. - if ( - codechat_client_location === - CodeChatEditorClientLocation.html - ) { + if (codechat_client_location === CodeChatEditorClientLocation.html) { if (webview_panel !== undefined) { - // As below, don't take the focus when revealing. webview_panel.reveal(undefined, true); } else { - // Create a webview panel. webview_panel = vscode.window.createWebviewPanel( "CodeChat Editor", "CodeChat Editor", { - // Without this, the focus becomes this webview; - // setting this allows the code window open - // before this command was executed to retain - // the focus and be immediately rendered. preserveFocus: true, - // Put this in the a column beside the current - // column. viewColumn: vscode.ViewColumn.Beside, }, - // See - // [WebViewOptions](https://code.visualstudio.com/api/references/vscode-api#WebviewOptions). { enableScripts: true, - // Without this, the websocket connection is - // dropped when the panel is hidden. retainContextWhenHidden: true, }, ); webview_panel.onDidDispose(async () => { - // Shut down the render client when the webview - // panel closes. - console_log( - "CodeChat Editor extension: shut down webview.", - ); - // Closing the webview abruptly closes the Client, - // which produces an error. Don't report it. + console_log("CodeChat Editor extension: shut down webview."); quiet_next_error = true; webview_panel = undefined; await stop_client(); @@ -259,13 +476,9 @@ export const activate = (context: vscode.ExtensionContext) => { } } - // Provide a simple status display while the CodeChat Editor - // Server is starting up. + // Provide a simple status display while the server is starting up. if (webview_panel !== undefined) { - // If we have an ID, then the GUI is already running; don't - // replace it. - webview_panel.webview.html = - "

CodeChat Editor

Loading...

"; + webview_panel.webview.html = "

CodeChat Editor

Loading...

"; } else { vscode.window.showInformationMessage( "The CodeChat Editor is loading in an external browser...", @@ -277,19 +490,13 @@ export const activate = (context: vscode.ExtensionContext) => { codeChatEditorServer = new CodeChatEditorServer(); const hosted_in_ide = - codechat_client_location === - CodeChatEditorClientLocation.html; + codechat_client_location === CodeChatEditorClientLocation.html; console_log( `CodeChat Editor extension: sending message Opened(${hosted_in_ide}).`, ); await codeChatEditorServer.sendMessageOpened(hosted_in_ide); - // For the external browser, we can immediately send the - // `CurrentFile` message. For the WebView, we must first wait to - // receive the HTML for the WebView (the `ClientHtml` message). - if ( - codechat_client_location === - CodeChatEditorClientLocation.browser - ) { + + if (codechat_client_location === CodeChatEditorClientLocation.browser) { send_update(false); } @@ -299,10 +506,8 @@ export const activate = (context: vscode.ExtensionContext) => { console_log("CodeChat Editor extension: queue closed."); break; } - // Parse the data into a message. - const { id, message } = JSON.parse( - message_raw, - ) as EditorMessage; + + const { id, message } = JSON.parse(message_raw) as EditorMessage; console_log( `CodeChat Editor extension: Received data id = ${id}, message = ${format_struct( message, @@ -318,11 +523,9 @@ export const activate = (context: vscode.ExtensionContext) => { const key = keys[0]; const value = Object.values(message)[0]; - // Process this message. switch (key) { case "Update": { - const current_update = - value as UpdateMessageContents; + const current_update = value as UpdateMessageContents; const doc = get_document(current_update.file_path); if (doc === undefined) { await sendResult(id, { @@ -332,38 +535,25 @@ export const activate = (context: vscode.ExtensionContext) => { } if (current_update.contents !== undefined) { const source = current_update.contents.source; - // This will produce a change event, which we'll - // ignore. The change may also produce a - // selection change, which should also be - // ignored. + ignore_text_document_change = true; ignore_selection_change = true; - // Use a workspace edit, since calls to - // `TextEditor.edit` must be made to the active - // editor only. + const wse = new vscode.WorkspaceEdit(); - // Is this plain text, or a diff? + if ("Plain" in source) { wse.replace( doc.uri, doc.validateRange( - new vscode.Range( - 0, - 0, - doc.lineCount, - 0, - ), + new vscode.Range(0, 0, doc.lineCount, 0), ), source.Plain.doc, ); } else { assert("Diff" in source); - // If this diff was not made against the - // text we currently have, reject it. + if (source.Diff.version !== version) { await sendResult(id, "OutOfSync"); - // Send an `Update` with the full text to - // re-sync the Client. console_log( "CodeChat Editor extension: sending update because Client is out of sync.", ); @@ -372,26 +562,12 @@ export const activate = (context: vscode.ExtensionContext) => { } const diffs = source.Diff.doc; for (const diff of diffs) { - // Convert from character offsets from the - // beginning of the document to a - // `Position` (line, then offset on that - // line) needed by VSCode. const from = doc.positionAt(diff.from); if (diff.to === undefined) { - // This is an insert. - wse.insert( - doc.uri, - from, - diff.insert, - ); + wse.insert(doc.uri, from, diff.insert); } else { - // This is a replace or delete. const to = doc.positionAt(diff.to); - wse.replace( - doc.uri, - new Range(from, to), - diff.insert, - ); + wse.replace(doc.uri, new Range(from, to), diff.insert); } } } @@ -399,30 +575,17 @@ export const activate = (context: vscode.ExtensionContext) => { ignore_text_document_change = false; ignore_selection_change = false; - // Now that we've updated our text, update the - // associated version as well. version = current_update.contents.version; } - // Update the cursor and scroll position if - // provided. const editor = get_text_editor(doc); + let scroll_line = current_update.scroll_position; if (scroll_line !== undefined && editor) { ignore_selection_change = true; - const scroll_position = new vscode.Position( - // The VSCode line is zero-based; the - // CodeMirror line is one-based. - scroll_line - 1, - 0, - ); + const scroll_position = new vscode.Position(scroll_line - 1, 0); editor.revealRange( - new vscode.Range( - scroll_position, - scroll_position, - ), - // This is still not the top of the - // viewport, but a bit below it. + new vscode.Range(scroll_position, scroll_position), TextEditorRevealType.AtTop, ); } @@ -430,17 +593,9 @@ export const activate = (context: vscode.ExtensionContext) => { let cursor_line = current_update.cursor_position; if (cursor_line !== undefined && editor) { ignore_selection_change = true; - const cursor_position = new vscode.Position( - // The VSCode line is zero-based; the - // CodeMirror line is one-based. - cursor_line - 1, - 0, - ); + const cursor_position = new vscode.Position(cursor_line - 1, 0); editor.selections = [ - new vscode.Selection( - cursor_position, - cursor_position, - ), + new vscode.Selection(cursor_position, cursor_position), ]; } await sendResult(id); @@ -453,55 +608,33 @@ export const activate = (context: vscode.ExtensionContext) => { if (is_text) { let document; try { - document = - await vscode.workspace.openTextDocument( - current_file, - ); + document = await vscode.workspace.openTextDocument(current_file); } catch (e) { await sendResult(id, { - OpenFileFailed: [ - current_file, - (e as Error).toString(), - ], + OpenFileFailed: [current_file, (e as Error).toString()], }); continue; } ignore_active_editor_change = true; - current_editor = - await vscode.window.showTextDocument( - document, - current_editor?.viewColumn, - ); + current_editor = await vscode.window.showTextDocument( + document, + current_editor?.viewColumn, + ); ignore_active_editor_change = false; await sendResult(id); } else { - // TODO: open using a custom document editor. - // See - // [openCustomDocument](https://code.visualstudio.com/api/references/vscode-api#CustomEditorProvider.openCustomDocument), - // which can evidently be called - // [indirectly](https://stackoverflow.com/a/65101181/4374935). - // See also - // [Built-in Commands](https://code.visualstudio.com/api/references/commands). - // For now, simply respond with an OK, since the - // following doesn't work. if (false) { commands .executeCommand( "vscode.open", vscode.Uri.file(current_file), - { - viewColumn: - current_editor?.viewColumn, - }, + { viewColumn: current_editor?.viewColumn }, ) .then( async () => await sendResult(id), async (reason) => await sendResult(id, { - OpenFileFailed: [ - current_file, - reason, - ], + OpenFileFailed: [current_file, reason], }), ); } @@ -511,7 +644,6 @@ export const activate = (context: vscode.ExtensionContext) => { } case "Result": { - // Report if this was an error. const result_contents = value as MessageResult; if ("Err" in result_contents) { show_error( @@ -523,23 +655,15 @@ export const activate = (context: vscode.ExtensionContext) => { case "LoadFile": { const load_file = value as string; - // Look through all open documents to see if we have - // the requested file. const doc = get_document(load_file); const load_file_result: null | [string, number] = - doc === undefined - ? null - : [ - doc.getText(), - (version = Math.random()), - ]; + doc === undefined ? null : [doc.getText(), (version = rand())]; console_log( - `CodeChat Editor extension: Result(LoadFile(${format_struct(load_file_result)}))`, - ); - await codeChatEditorServer.sendResultLoadfile( - id, - load_file_result, + `CodeChat Editor extension: Result(LoadFile(${format_struct( + load_file_result, + )}))`, ); + await codeChatEditorServer.sendResultLoadfile(id, load_file_result); break; } @@ -548,17 +672,13 @@ export const activate = (context: vscode.ExtensionContext) => { assert(webview_panel !== undefined); webview_panel.webview.html = client_html; await sendResult(id); - // Now that the Client is loaded, send the editor's - // current file to the server. send_update(false); break; } default: console.error( - `Unhandled message ${key}(${format_struct( - value, - )}`, + `Unhandled message ${key}(${format_struct(value)}`, ); break; } @@ -571,6 +691,33 @@ export const activate = (context: vscode.ExtensionContext) => { // On deactivation, close everything down. export const deactivate = async () => { console_log("CodeChat Editor extension: deactivating."); + + // CAPTURE: if we were in a doc session, close it out so duration is recorded. + if (docSessionStart !== null) { + const now = Date.now(); + const durationMs = now - docSessionStart; + docSessionStart = null; + const active = vscode.window.activeTextEditor; + const filePath = active?.document.fileName; + + await sendCaptureEvent(CAPTURE_SERVER_BASE, "doc_session", filePath, { + duration_ms: durationMs, + duration_seconds: durationMs / 1000.0, + closed_by: "extension_deactivate", + }); + await sendCaptureEvent(CAPTURE_SERVER_BASE, "session_end", filePath, { + mode: "doc", + closed_by: "extension_deactivate", + }); + } + + // CAPTURE: mark the end of an editor session. + const active = vscode.window.activeTextEditor; + const endFilePath = active?.document.fileName; + await sendCaptureEvent(CAPTURE_SERVER_BASE, "session_end", endFilePath, { + mode: "vscode_extension", + }); + await stop_client(); webview_panel?.dispose(); console_log("CodeChat Editor extension: deactivated."); @@ -592,7 +739,9 @@ const format_struct = (complex_data_structure: any): string => const sendResult = async (id: number, result?: ResultErrTypes) => { assert(codeChatEditorServer); console_log( - `CodeChat Editor extension: sending Result(id = ${id}, ${format_struct(result)}).`, + `CodeChat Editor extension: sending Result(id = ${id}, ${format_struct( + result, + )}).`, ); try { await codeChatEditorServer.sendResult( @@ -610,56 +759,41 @@ const sendResult = async (id: number, result?: ResultErrTypes) => { const send_update = (this_is_dirty: boolean) => { is_dirty ||= this_is_dirty; if (can_render()) { - // Render after some inactivity: cancel any existing timer, then ... if (idle_timer !== undefined) { clearTimeout(idle_timer); } - // ... schedule a render after an autosave timeout. idle_timer = setTimeout(async () => { if (can_render()) { const ate = vscode.window.activeTextEditor; if (ate !== undefined && ate !== current_editor) { - // Send a new current file after a short delay; this allows - // the user to rapidly cycle through several editors without - // needing to reload the Client with each cycle. current_editor = ate; - const current_file = ate!.document.fileName; + const current_file = ate.document.fileName; console_log( `CodeChat Editor extension: sending CurrentFile(${current_file}}).`, ); try { - await codeChatEditorServer!.sendMessageCurrentFile( - current_file, - ); + await codeChatEditorServer!.sendMessageCurrentFile(current_file); } catch (e) { show_error(`Error sending CurrentFile message: ${e}.`); } - // Since we just requested a new file, the contents are - // clean by definition. is_dirty = false; - // Don't send an updated cursor position until this file is - // loaded. return; } - // The - // [Position](https://code.visualstudio.com/api/references/vscode-api#Position) - // encodes the line as a zero-based value. In contrast, - // CodeMirror - // [Text.line](https://codemirror.net/docs/ref/#state.Text.line) - // is 1-based. - const cursor_position = - current_editor!.selection.active.line + 1; + const cursor_position = current_editor!.selection.active.line + 1; const scroll_position = current_editor!.visibleRanges[0].start.line + 1; const file_path = current_editor!.document.fileName; - // Send contents only if necessary. + const option_contents: null | [string, number] = is_dirty ? [current_editor!.document.getText(), (version = rand())] : null; is_dirty = false; + console_log( - `CodeChat Editor extension: sending Update(${file_path}, ${cursor_position}, ${scroll_position}, ${format_struct(option_contents)})`, + `CodeChat Editor extension: sending Update(${file_path}, ${cursor_position}, ${scroll_position}, ${format_struct( + option_contents, + )})`, ); await codeChatEditorServer!.sendMessageUpdatePlain( file_path, @@ -672,8 +806,7 @@ const send_update = (this_is_dirty: boolean) => { } }; -// Gracefully shut down the render client if possible. Shut down the client as -// well. +// Gracefully shut down the render client if possible. Shut down the client as well. const stop_client = async () => { console_log("CodeChat Editor extension: stopping client."); if (codeChatEditorServer !== undefined) { @@ -682,8 +815,6 @@ const stop_client = async () => { codeChatEditorServer = undefined; } - // Shut the timer down after the client is undefined, to ensure it can't be - // started again by a call to `start_render()`. if (idle_timer !== undefined) { clearTimeout(idle_timer); idle_timer = undefined; @@ -700,10 +831,7 @@ const show_error = (message: string) => { } console.error(`CodeChat Editor extension: ${message}`); if (webview_panel !== undefined) { - // If the panel was displaying other content, reset it for errors. - if ( - !webview_panel.webview.html.startsWith("

CodeChat Editor

") - ) { + if (!webview_panel.webview.html.startsWith("

CodeChat Editor

")) { webview_panel.webview.html = "

CodeChat Editor

"; } webview_panel.webview.html += `

${escape( @@ -716,42 +844,23 @@ const show_error = (message: string) => { } }; -// Only render if the window and editor are active, we have a valid render -// client, and the webview is visible. +// Only render if the window and editor are active, we have a valid render client, +// and the webview is visible. const can_render = () => { return ( (vscode.window.activeTextEditor !== undefined || current_editor !== undefined) && codeChatEditorServer !== undefined && - // TODO: I don't think these matter -- the Server is in charge of - // sending output to the Client. (codechat_client_location === CodeChatEditorClientLocation.browser || webview_panel !== undefined) ); }; const get_document = (file_path: string) => { - // Look through all open documents to see if we have the requested file. for (const doc of vscode.workspace.textDocuments) { - // Make the possibly incorrect assumption that only Windows filesystems - // are case-insensitive; I don't know how to easily determine the - // case-sensitivity of the current filesystem without extra probing code - // (write a file in mixed case, try to open it in another mixed case.) - // Per - // [How to Work with Different Filesystems](https://nodejs.org/en/learn/manipulating-files/working-with-different-filesystems#filesystem-behavior), - // "Be wary of inferring filesystem behavior from `process.platform`. - // For example, do not assume that because your program is running on - // Darwin that you are therefore working on a case-insensitive - // filesystem (HFS+), as the user may be using a case-sensitive - // filesystem (HFSX)." - // - // The same article - // [recommends](https://nodejs.org/en/learn/manipulating-files/working-with-different-filesystems#be-prepared-for-slight-differences-in-comparison-functions) - // using `toUpperCase` for case-insensitive filename comparisons. if ( (!is_windows && doc.fileName === file_path) || - (is_windows && - doc.fileName.toUpperCase() === file_path.toUpperCase()) + (is_windows && doc.fileName.toUpperCase() === file_path.toUpperCase()) ) { return doc; } From 7f316c49d1cfb67e5c4a81348ad05f5b6aa35c50 Mon Sep 17 00:00:00 2001 From: John Spahn <44337821+jspahn80134@users.noreply.github.com> Date: Sun, 14 Dec 2025 12:26:43 -0700 Subject: [PATCH 03/45] Modified capture test --- server/src/capture.rs | 244 +++++++++++++++++++++++++++++------------- 1 file changed, 171 insertions(+), 73 deletions(-) diff --git a/server/src/capture.rs b/server/src/capture.rs index 174a4cbb..5096ffda 100644 --- a/server/src/capture.rs +++ b/server/src/capture.rs @@ -15,24 +15,24 @@ // [http://www.gnu.org/licenses](http://www.gnu.org/licenses). /// `capture.rs` -- Capture CodeChat Editor Events -/// ============================================== +/// ============================================================================ /// /// This module provides an asynchronous event capture facility backed by a /// PostgreSQL database. It is designed to support the dissertation study by /// recording process-level data such as: /// -/// * Frequency and timing of writing entries -/// * Edits to documentation and code -/// * Switches between documentation and coding activity -/// * Duration of engagement with reflective writing -/// * Save, compile, and run events +/// * Frequency and timing of writing entries +/// * Edits to documentation and code +/// * Switches between documentation and coding activity +/// * Duration of engagement with reflective writing +/// * Save, compile, and run events /// /// Events are sent from the client (browser and/or VS Code extension) to the /// server as JSON. The server enqueues events into an asynchronous worker which /// performs batched inserts into the `events` table. /// /// Database schema -/// --------------- +/// ---------------------------------------------------------------------------- /// /// The following SQL statement creates the `events` table used by this module: /// @@ -49,13 +49,13 @@ /// ); /// ``` /// -/// * `user_id` – participant identifier (student id, pseudonym, etc.). -/// * `assignment_id` – logical assignment / lab identifier. -/// * `group_id` – optional grouping (treatment / comparison, section). -/// * `file_path` – logical path of the file being edited. -/// * `event_type` – coarse event type (see `event_type` constants below). -/// * `timestamp` – RFC3339 timestamp (in UTC). -/// * `data` – JSON payload with event-specific details. +/// * `user_id` – participant identifier (student id, pseudonym, etc.). +/// * `assignment_id` – logical assignment / lab identifier. +/// * `group_id` – optional grouping (treatment / comparison, section). +/// * `file_path` – logical path of the file being edited. +/// * `event_type` – coarse event type (see `event_type` constants below). +/// * `timestamp` – RFC3339 timestamp (in UTC). +/// * `data` – JSON payload with event-specific details. use std::io; @@ -64,6 +64,7 @@ use log::{debug, error, info, warn}; use serde::{Deserialize, Serialize}; use tokio::sync::mpsc; use tokio_postgres::{Client, NoTls}; +use std::error::Error; /// Canonical event type strings. Keep these stable for analysis. pub mod event_types { @@ -234,20 +235,30 @@ impl EventCapture { info!("Capture: event channel closed; background worker exiting."); } - Err(err) => { - // NOTE: we *don't* pass `err` twice here; `{err}` in the format - // string already grabs the local `err` binding. - error!( - "Capture: FAILED to connect to PostgreSQL (host={}, dbname={}, user={}): {err}", - config.host, - config.dbname, - config.user, - ); - // Drain and drop any events so we don't hold the sender. - warn!("Capture: draining pending events after failed DB connection."); - while rx.recv().await.is_some() {} - warn!("Capture: all pending events dropped due to connection failure."); - } + +Err(err) => { + let ctx = format!( + "Capture: FAILED to connect to PostgreSQL (host={}, dbname={}, user={})", + config.host, config.dbname, config.user + ); + + log_pg_connect_error(&ctx, &err); + + // Drain and drop any events so we don't hold the sender. + warn!("Capture: draining pending events after failed DB connection."); + while rx.recv().await.is_some() {} + warn!("Capture: all pending events dropped due to connection failure."); +} + + // Err(err) => { // NOTE: we *don't* pass `err` twice here; + // `{err}` in the format // string already grabs the local `err` + // binding. error!( "Capture: FAILED to connect to PostgreSQL + // (host={}, dbname={}, user={}): {err}", config.host, + // config.dbname, config.user, ); // Drain and drop any events + // so we don't hold the sender. warn!("Capture: draining pending + // events after failed DB connection."); while + // rx.recv().await.is\_some() {} warn!("Capture: all pending + // events dropped due to connection failure."); } } }); @@ -271,6 +282,47 @@ impl EventCapture { } } +fn log_pg_connect_error(context: &str, err: &tokio_postgres::Error) { + // If Postgres returned a structured DbError, log it ONCE and bail. + if let Some(db) = err.as_db_error() { + // Example: 28P01 = invalid\_password + error!( + "{context}: PostgreSQL {} (SQLSTATE {})", + db.message(), + db.code().code() + ); + + if let Some(detail) = db.detail() { + error!("{context}: detail: {detail}"); + } + if let Some(hint) = db.hint() { + error!("{context}: hint: {hint}"); + } + return; + } + + // Otherwise, try to find an underlying std::io::Error (refused, timed out, + // DNS, etc.) + let mut current: &(dyn Error + 'static) = err; + while let Some(source) = current.source() { + if let Some(ioe) = source.downcast_ref::() { + error!( + "{context}: I/O error kind={:?} raw_os_error={:?} msg={}", + ioe.kind(), + ioe.raw_os_error(), + ioe + ); + return; + } + current = source; + } + + // Fallback: log once (Display) + error!("{context}: {err}"); +} + + + /// Insert a single event into the `events` table. async fn insert_event(client: &Client, event: &CaptureEvent) -> Result { let timestamp = event.timestamp.to_rfc3339(); @@ -315,7 +367,8 @@ mod tests { }; let conn = cfg.to_conn_str(); - // Very simple checks: we don't care about ordering beyond what we format. + // Very simple checks: we don't care about ordering beyond what we + // format. assert!(conn.contains("host=localhost")); assert!(conn.contains("user=alice")); assert!(conn.contains("password=secret")); @@ -394,29 +447,29 @@ mod tests { } use std::fs; - use tokio::time::{sleep, Duration}; + //use tokio::time::{sleep, Duration}; - /// Integration-style test: verify that EventCapture actually inserts into the DB. + /// Integration-style test: verify that EventCapture actually inserts into + /// the DB. /// - /// Reads connection parameters from `capture_config.json` in the current working directory. - /// Logs the config and connection details via log4rs so you can confirm what is used. + /// Reads connection parameters from `capture_config.json` in the current + /// working directory. Logs the config and connection details via log4rs so + /// you can confirm what is used. /// - /// Run this test with: - /// cargo test event_capture_inserts_event_into_db -- --ignored --nocapture + /// Run this test with: cargo test event\_capture\_inserts\_event\_into\_db + /// -- --ignored --nocapture /// - /// You must have a PostgreSQL database and a `capture_config.json` file such as: - /// { - /// "host": "localhost", - /// "user": "codechat_test_user", - /// "password": "codechat_test_password", - /// "dbname": "codechat_capture_test", - /// "app_id": "integration-test" - /// } + /// You must have a PostgreSQL database and a `capture_config.json` file + /// such as: { "host": "localhost", "user": "codechat\_test\_user", + /// "password": "codechat\_test\_password", "dbname": + /// "codechat\_capture\_test", "app\_id": "integration-test" } #[tokio::test] #[ignore] async fn event_capture_inserts_event_into_db() -> Result<(), Box> { - // Initialize logging for this test, using the same log4rs.yml as the server. - // If logging is already initialized, this will just return an error which we ignore. + + // Initialize logging for this test, using the same log4rs.yml as the + // server. If logging is already initialized, this will just return an + // error which we ignore. let _ = log4rs::init_file("log4rs.yml", Default::default()); // 1. Load the capture configuration from file. @@ -444,23 +497,52 @@ mod tests { } }); - // 3. Ensure the `events` table exists and is empty. - client - .batch_execute( - "CREATE TABLE IF NOT EXISTS events ( - id SERIAL PRIMARY KEY, - user_id TEXT NOT NULL, - assignment_id TEXT, - group_id TEXT, - file_path TEXT, - event_type TEXT NOT NULL, - timestamp TEXT NOT NULL, - data TEXT - ); - TRUNCATE TABLE events;", + // Verify the events table already exists + let row = client + .query_one( + r#" + SELECT EXISTS ( + SELECT 1 + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'events' + ) AS exists + "#, + &[], ) .await?; - log::info!("TEST: events table ensured and truncated."); + + let exists: bool = row.get("exists"); + assert!( + exists, + "TEST SETUP ERROR: public.events table does not exist. \ + It must be created by a migration or admin step." + ); + + // Insert a single test row (this is what the app really needs) + let test_user_id = format!( + "TEST_USER_{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() + ); + + let insert_row = client + .query_one( + r#" + INSERT INTO public.events + (user_id, assignment_id, group_id, file_path, event_type, timestamp, data) + VALUES + ($1, NULL, NULL, NULL, 'test_event', $2, '{"test":true}') + RETURNING id + "#, + &[&test_user_id, &format!("{:?}", std::time::SystemTime::now())], + ) + .await?; + + let inserted_id: i32 = insert_row.get("id"); + info!("TEST: inserted event id={}", inserted_id); // 4. Start the EventCapture worker using the loaded config. let capture = EventCapture::new(cfg.clone())?; @@ -480,19 +562,35 @@ mod tests { log::info!("TEST: logging a test capture event."); capture.log(event); - // 6. Give the background worker time to insert the event. - sleep(Duration::from_millis(300)).await; - - // 7. Verify the inserted record. - let row = client - .query_one( - "SELECT user_id, assignment_id, group_id, file_path, event_type, data - FROM events - ORDER BY id DESC - LIMIT 1", - &[], - ) - .await?; + // 6. Wait (deterministically) for the background worker to insert the event, + // then fetch THAT row (instead of "latest row in the table"). + use tokio::time::{sleep, Duration, Instant}; + + let deadline = Instant::now() + Duration::from_secs(2); + + let row = loop { + match client + .query_one( + r#" + SELECT user_id, assignment_id, group_id, file_path, event_type, data + FROM events + WHERE user_id = $1 AND event_type = $2 + ORDER BY id DESC + LIMIT 1 + "#, + &[&"test-user", &event_types::WRITE_DOC], + ) + .await + { + Ok(row) => break row, // found it + Err(_) => { + if Instant::now() >= deadline { + return Err("Timed out waiting for EventCapture insert".into()); + } + sleep(Duration::from_millis(50)).await; + } + } + }; let user_id: String = row.get(0); let assignment_id: Option = row.get(1); From a70d0d7c0d84a7e4bd0ada7db28de35f01a73c40 Mon Sep 17 00:00:00 2001 From: John Spahn <44337821+jspahn80134@users.noreply.github.com> Date: Fri, 19 Dec 2025 13:11:45 -0700 Subject: [PATCH 04/45] Ongoing Development --- extensions/VSCode/src/extension.ts | 33 +++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/extensions/VSCode/src/extension.ts b/extensions/VSCode/src/extension.ts index c09dde45..c791e6b8 100644 --- a/extensions/VSCode/src/extension.ts +++ b/extensions/VSCode/src/extension.ts @@ -52,6 +52,7 @@ import { MAX_MESSAGE_LENGTH, } from "../../../client/src/debug_enabled.mjs"; import { ResultErrTypes } from "../../../client/src/rust-types/ResultErrTypes"; +import * as os from "os"; // Globals // ----------------------------------------------------------------------------- @@ -128,7 +129,24 @@ interface CaptureEventPayload { // TODO: replace these with something real (e.g., VS Code settings) // For now, we hard-code to prove that the pipeline works end-to-end. -const CAPTURE_USER_ID = "test-user"; +const CAPTURE_USER_ID: string = (() => { + try { + const u = os.userInfo().username; + if (u && u.trim().length > 0) { + return u.trim(); + } + } catch (_) { + // fall through + } + + // Fallbacks (should rarely be needed) + return ( + process.env["USERNAME"] || + process.env["USER"] || + "unknown-user" + ); +})(); + const CAPTURE_ASSIGNMENT_ID = "demo-assignment"; const CAPTURE_GROUP_ID = "demo-group"; @@ -879,3 +897,16 @@ const console_log = (...args: any) => { console.log(...args); } }; + +function getCurrentUsername(): string { + try { + // Most reliable on Windows/macOS/Linux + const u = os.userInfo().username; + if (u && u.trim().length > 0) return u.trim(); + } catch (_) {} + + // Fallbacks + const envUser = process.env["USERNAME"] || process.env["USER"]; + return (envUser && envUser.trim().length > 0) ? envUser.trim() : "unknown-user"; +} + From 3e02ceec3928c042868578c7cb053969634f5fbf Mon Sep 17 00:00:00 2001 From: John Spahn <44337821+jspahn80134@users.noreply.github.com> Date: Fri, 13 Feb 2026 10:26:34 -0700 Subject: [PATCH 05/45] Capture Changes --- extensions/VSCode/src/extension.ts | 116 ++++++++++++++++++++++++++++- server/src/capture.rs | 2 + server/src/webserver.rs | 56 ++++++++++---- 3 files changed, 156 insertions(+), 18 deletions(-) diff --git a/extensions/VSCode/src/extension.ts b/extensions/VSCode/src/extension.ts index cd110e11..6b34aedb 100644 --- a/extensions/VSCode/src/extension.ts +++ b/extensions/VSCode/src/extension.ts @@ -55,6 +55,8 @@ import { import { ResultErrTypes } from "../../../client/src/rust-types/ResultErrTypes.js"; import * as os from "os"; +import * as crypto from "crypto"; + // Globals // ----------------------------------------------------------------------------- enum CodeChatEditorClientLocation { @@ -62,6 +64,9 @@ enum CodeChatEditorClientLocation { browser, } +// Create a unique session ID for logging +const CAPTURE_SESSION_ID = crypto.randomUUID(); + // True on Windows, false on OS X / Linux. const is_windows = process.platform === "win32"; @@ -117,6 +122,59 @@ let codeChatEditorServer: CodeChatEditorServer | undefined; // CAPTURE (Dissertation instrumentation) // ----------------------------------------------------------------------------- +function isInMarkdownCodeFence(doc: vscode.TextDocument, line: number): boolean { + // Very simple fence tracker: toggles when encountering ``` or ~~~ at start of line. + // Good enough for dissertation instrumentation; refine later if needed. + let inFence = false; + for (let i = 0; i <= line; i++) { + const t = doc.lineAt(i).text.trim(); + if (t.startsWith("```") || t.startsWith("~~~")) { + inFence = !inFence; + } + } + return inFence; +} + +function isInRstCodeBlock(doc: vscode.TextDocument, line: number): boolean { + // Heuristic: find the most recent ".. code-block::" (or "::") and see if we're in its indented region. + // This won’t be perfect, but it’s far better than file-level classification. + let blockLine = -1; + for (let i = line; i >= 0; i--) { + const t = doc.lineAt(i).text; + const tt = t.trim(); + if (tt.startsWith(".. code-block::") || tt === "::") { + blockLine = i; + break; + } + // If we hit a non-indented line after searching upward too far, keep going; rst blocks can be separated by blank lines. + } + if (blockLine < 0) return false; + + // RST code block content usually begins after optional blank line(s), indented. + // Determine whether current line is indented relative to block directive line. + const cur = doc.lineAt(line).text; + if (cur.trim().length === 0) return false; + + // If it's indented at least one space/tab, treat it as inside block. + return /^\s+/.test(cur); +} + +function classifyAtPosition(doc: vscode.TextDocument, pos: vscode.Position): ActivityKind { + if (DOC_LANG_IDS.has(doc.languageId)) { + if (doc.languageId === "markdown") { + return isInMarkdownCodeFence(doc, pos.line) ? "code" : "doc"; + } + if (doc.languageId === "restructuredtext") { + return isInRstCodeBlock(doc, pos.line) ? "code" : "doc"; + } + // Other doc types: default to doc + return "doc"; + } + return "code"; +} + + + // Types for talking to the Rust /capture endpoint. // This mirrors `CaptureEventWire` in webserver.rs. interface CaptureEventPayload { @@ -196,7 +254,12 @@ async function sendCaptureEvent( group_id: CAPTURE_GROUP_ID, file_path: filePath, event_type: eventType, - data, + data: { + ...data, + session_id: CAPTURE_SESSION_ID, + client_timestamp_ms: Date.now(), + client_tz_offset_min: new Date().getTimezoneOffset(), + }, }; try { @@ -312,7 +375,11 @@ export const activate = (context: vscode.ExtensionContext) => { // CAPTURE: classify this as documentation vs. code and log a write_* event. const doc = event.document; - const kind = classifyDocument(doc); +// const kind = classifyDocument(doc); + const firstChange = event.contentChanges[0]; + const pos = firstChange.range.start; + const kind = classifyAtPosition(doc, pos); + const filePath = doc.fileName; const charsTyped = event.contentChanges .map((c) => c.text.length) @@ -370,7 +437,10 @@ export const activate = (context: vscode.ExtensionContext) => { // CAPTURE: update activity + possible switch_pane/doc_session. const doc = event.document; - const kind = classifyDocument(doc); + // const kind = classifyDocument(doc); + const pos = event.selection?.active ?? new vscode.Position(0, 0); + const kind = classifyAtPosition(doc, pos); + const filePath = doc.fileName; noteActivity(kind, filePath); @@ -391,7 +461,9 @@ export const activate = (context: vscode.ExtensionContext) => { // CAPTURE: treat a selection change as "activity" in this document. const doc = event.textEditor.document; - const kind = classifyDocument(doc); + // const kind = classifyDocument(doc); + const pos = event.selections?.[0]?.active ?? event.textEditor.selection.active; + const kind = classifyAtPosition(doc, pos); const filePath = doc.fileName; noteActivity(kind, filePath); @@ -399,6 +471,42 @@ export const activate = (context: vscode.ExtensionContext) => { }), ); + // CAPTURE: end of a debug/run session. + context.subscriptions.push( + vscode.debug.onDidTerminateDebugSession((session) => { + const active = vscode.window.activeTextEditor; + const filePath = active?.document.fileName; + void sendCaptureEvent( + CAPTURE_SERVER_BASE, + "run_end", + filePath, + { + sessionName: session.name, + sessionType: session.type, + }, + ); + }), + ); + + // CAPTURE: compile/build end events via VS Code tasks. + context.subscriptions.push( + vscode.tasks.onDidEndTaskProcess((e) => { + const active = vscode.window.activeTextEditor; + const filePath = active?.document.fileName; + const task = e.execution.task; + void sendCaptureEvent( + CAPTURE_SERVER_BASE, + "compile_end", + filePath, + { + taskName: task.name, + taskSource: task.source, + exitCode: e.exitCode, + }, + ); + }), + ); + // CAPTURE: listen for file saves. context.subscriptions.push( vscode.workspace.onDidSaveTextDocument((doc) => { diff --git a/server/src/capture.rs b/server/src/capture.rs index 5096ffda..fd7eede9 100644 --- a/server/src/capture.rs +++ b/server/src/capture.rs @@ -77,6 +77,8 @@ pub mod event_types { pub const RUN: &str = "run"; pub const SESSION_START: &str = "session_start"; pub const SESSION_END: &str = "session_end"; + pub const COMPILE_END: &str = "compile_end"; + pub const RUN_END: &str = "run_end"; } /// Configuration used to construct the PostgreSQL connection string. diff --git a/server/src/webserver.rs b/server/src/webserver.rs index c876d778..c963bab7 100644 --- a/server/src/webserver.rs +++ b/server/src/webserver.rs @@ -406,10 +406,18 @@ pub struct CaptureEventWire { pub group_id: Option, pub file_path: Option, pub event_type: String, - /// Arbitrary event-specific data stored as JSON. - pub data: serde_json::Value, + + /// Optional client-side timestamp (milliseconds since Unix epoch). + pub client_timestamp_ms: Option, + + /// Optional client timezone offset in minutes (JS Date().getTimezoneOffset()). + pub client_tz_offset_min: Option, + + /// Arbitrary event-specific data stored as JSON (optional). + pub data: Option, } + // Macros // ----------------------------------------------------------------------------- /// Create a macro to report an error when enqueueing an item. @@ -600,21 +608,41 @@ async fn capture_endpoint( ) -> HttpResponse { let wire = payload.into_inner(); - if let Some(capture) = &app_state.capture { - let event = CaptureEvent { - user_id: wire.user_id, - assignment_id: wire.assignment_id, - group_id: wire.group_id, - file_path: wire.file_path, - event_type: wire.event_type, - // Server decides when the event is recorded. - timestamp: Utc::now(), - data: wire.data, - }; + if let Some(capture) = &app_state.capture { + // Default missing data to empty object + let mut data = wire.data.unwrap_or_else(|| serde_json::json!({})); + + // Ensure data is an object so we can attach fields + if !data.is_object() { + data = serde_json::json!({ "value": data }); + } - capture.log(event); + // Add client timestamp fields if present (even if extension also sends them; + // overwriting is fine and consistent). + if let serde_json::Value::Object(map) = &mut data { + if let Some(ms) = wire.client_timestamp_ms { + map.insert("client_timestamp_ms".to_string(), serde_json::json!(ms)); + } + if let Some(tz) = wire.client_tz_offset_min { + map.insert("client_tz_offset_min".to_string(), serde_json::json!(tz)); + } } + let event = CaptureEvent { + user_id: wire.user_id, + assignment_id: wire.assignment_id, + group_id: wire.group_id, + file_path: wire.file_path, + event_type: wire.event_type, + // Server decides when the event is recorded. + timestamp: Utc::now(), + data, + }; + + capture.log(event); +} + + HttpResponse::Ok().finish() } From 8ad17f6c2189b729a4393789b1394e4ae6407779 Mon Sep 17 00:00:00 2001 From: John Spahn <44337821+jspahn80134@users.noreply.github.com> Date: Sat, 14 Feb 2026 15:30:07 -0700 Subject: [PATCH 06/45] Capture Integration Updates --- client/src/CodeMirror-integration.mts | 8 +-- extensions/VSCode/src/extension.ts | 86 ++++++++++++++++----------- 2 files changed, 55 insertions(+), 39 deletions(-) diff --git a/client/src/CodeMirror-integration.mts b/client/src/CodeMirror-integration.mts index a1f7e83b..eefa5e26 100644 --- a/client/src/CodeMirror-integration.mts +++ b/client/src/CodeMirror-integration.mts @@ -46,7 +46,7 @@ // 5. Define a set of StateEffects to add/update/etc. doc blocks. // // Imports -// ----------------------------------------------------------------------------- +// ------- // // ### Third-party import { basicSetup } from "codemirror"; @@ -104,7 +104,7 @@ import { assert } from "./assert.mjs"; import { show_toast } from "./show_toast.mjs"; // Globals -// ----------------------------------------------------------------------------- +// ------- let current_view: EditorView; // This indicates that a call to `on_dirty` is scheduled, but hasn't run yet. let on_dirty_scheduled = false; @@ -137,7 +137,7 @@ const exceptionSink = EditorView.exceptionSink.of((exception) => { }); // Doc blocks in CodeMirror -// ----------------------------------------------------------------------------- +// ------------------------ // // The goal: given a [Range](https://codemirror.net/docs/ref/#state.Range) of // lines containing a doc block (a delimiter, indent, and contents) residing at @@ -825,7 +825,7 @@ export const DocBlockPlugin = ViewPlugin.fromClass( ); // UI -// ----------------------------------------------------------------------------- +// -- // // There doesn't seem to be any tracking of a dirty/clean flag built into // CodeMirror v6 (although diff --git a/extensions/VSCode/src/extension.ts b/extensions/VSCode/src/extension.ts index 6b34aedb..e2933241 100644 --- a/extensions/VSCode/src/extension.ts +++ b/extensions/VSCode/src/extension.ts @@ -16,13 +16,13 @@ // [http://www.gnu.org/licenses](http://www.gnu.org/licenses). // // `extension.ts` - The CodeChat Editor Visual Studio Code extension -// ============================================================================= +// ================================================================= // // This extension creates a webview, then uses a websocket connection to the // CodeChat Editor Server and Client to render editor text in that webview. // // Imports -// ----------------------------------------------------------------------------- +// ------- // // ### Node.js packages import assert from "assert"; @@ -58,7 +58,7 @@ import * as os from "os"; import * as crypto from "crypto"; // Globals -// ----------------------------------------------------------------------------- +// ------- enum CodeChatEditorClientLocation { html, browser, @@ -118,13 +118,15 @@ let codeChatEditorServer: CodeChatEditorServer | undefined; initServer(ext.extensionPath); } -// ----------------------------------------------------------------------------- +// --- +// // CAPTURE (Dissertation instrumentation) -// ----------------------------------------------------------------------------- +// -------------------------------------- function isInMarkdownCodeFence(doc: vscode.TextDocument, line: number): boolean { - // Very simple fence tracker: toggles when encountering ``` or ~~~ at start of line. - // Good enough for dissertation instrumentation; refine later if needed. + // Very simple fence tracker: toggles when encountering \`\`\` or ~~~ at + // start of line. Good enough for dissertation instrumentation; refine later + // if needed. let inFence = false; for (let i = 0; i <= line; i++) { const t = doc.lineAt(i).text.trim(); @@ -136,8 +138,9 @@ function isInMarkdownCodeFence(doc: vscode.TextDocument, line: number): boolean } function isInRstCodeBlock(doc: vscode.TextDocument, line: number): boolean { - // Heuristic: find the most recent ".. code-block::" (or "::") and see if we're in its indented region. - // This won’t be perfect, but it’s far better than file-level classification. + // Heuristic: find the most recent ".. code-block::" (or "::") and see if + // we're in its indented region. This won’t be perfect, but it’s far better + // than file-level classification. let blockLine = -1; for (let i = line; i >= 0; i--) { const t = doc.lineAt(i).text; @@ -146,12 +149,14 @@ function isInRstCodeBlock(doc: vscode.TextDocument, line: number): boolean { blockLine = i; break; } - // If we hit a non-indented line after searching upward too far, keep going; rst blocks can be separated by blank lines. + // If we hit a non-indented line after searching upward too far, keep + // going; rst blocks can be separated by blank lines. } if (blockLine < 0) return false; - // RST code block content usually begins after optional blank line(s), indented. - // Determine whether current line is indented relative to block directive line. + // RST code block content usually begins after optional blank line(s), + // indented. Determine whether current line is indented relative to block + // directive line. const cur = doc.lineAt(line).text; if (cur.trim().length === 0) return false; @@ -175,8 +180,8 @@ function classifyAtPosition(doc: vscode.TextDocument, pos: vscode.Position): Act -// Types for talking to the Rust /capture endpoint. -// This mirrors `CaptureEventWire` in webserver.rs. +// Types for talking to the Rust /capture endpoint. This mirrors +// `CaptureEventWire` in webserver.rs. interface CaptureEventPayload { user_id: string; assignment_id?: string; @@ -186,8 +191,8 @@ interface CaptureEventPayload { data: any; // sent as JSON } -// TODO: replace these with something real (e.g., VS Code settings) -// For now, we hard-code to prove that the pipeline works end-to-end. +// TODO: replace these with something real (e.g., VS Code settings) For now, we +// hard-code to prove that the pipeline works end-to-end. const CAPTURE_USER_ID: string = (() => { try { const u = os.userInfo().username; @@ -209,8 +214,8 @@ const CAPTURE_USER_ID: string = (() => { const CAPTURE_ASSIGNMENT_ID = "demo-assignment"; const CAPTURE_GROUP_ID = "demo-group"; -// Base URL for the CodeChat server's /capture endpoint. -// NOTE: keep this in sync with whatever port your server actually uses. +// Base URL for the CodeChat server's /capture endpoint. NOTE: keep this in sync +// with whatever port your server actually uses. const CAPTURE_SERVER_BASE = "http://127.0.0.1:8080"; // Simple classification of what the user is currently doing. @@ -225,7 +230,8 @@ const DOC_LANG_IDS = new Set([ "restructuredtext", ]); -// Track the last activity kind and when a reflective-writing (doc) session started. +// Track the last activity kind and when a reflective-writing (doc) session +// started. let lastActivityKind: ActivityKind = "other"; let docSessionStart: number | null = null; @@ -283,7 +289,7 @@ async function sendCaptureEvent( } } -// Update activity state, emit switch + doc_session events as needed. +// Update activity state, emit switch + doc\_session events as needed. function noteActivity(kind: ActivityKind, filePath?: string) { const now = Date.now(); @@ -311,7 +317,7 @@ function noteActivity(kind: ActivityKind, filePath?: string) { } } - // If we switched between doc and code, log a switch_pane event. + // If we switched between doc and code, log a switch\_pane event. const docOrCode = (k: ActivityKind) => k === "doc" || k === "code"; if (docOrCode(lastActivityKind) && docOrCode(kind) && kind !== lastActivityKind) { void sendCaptureEvent(CAPTURE_SERVER_BASE, "switch_pane", filePath, { @@ -324,7 +330,7 @@ function noteActivity(kind: ActivityKind, filePath?: string) { } // Activation/deactivation -// ----------------------------------------------------------------------------- +// ----------------------- // // This is invoked when the extension is activated. It either creates a new // CodeChat Editor Server instance or reveals the currently running one. @@ -373,9 +379,12 @@ export const activate = (context: vscode.ExtensionContext) => { }, ${format_struct(event.contentChanges)}.`, ); - // CAPTURE: classify this as documentation vs. code and log a write_* event. + // CAPTURE: classify this as documentation vs. code + // and log a write\_\* event. const doc = event.document; -// const kind = classifyDocument(doc); +// ``` +// const kind = classifyDocument(doc); +// ``` const firstChange = event.contentChanges[0]; const pos = firstChange.range.start; const kind = classifyAtPosition(doc, pos); @@ -407,7 +416,8 @@ export const activate = (context: vscode.ExtensionContext) => { ); } - // Update our notion of current activity + doc session. + // Update our notion of current activity + doc + // session. noteActivity(kind, filePath); send_update(true); @@ -435,7 +445,8 @@ export const activate = (context: vscode.ExtensionContext) => { return; } - // CAPTURE: update activity + possible switch_pane/doc_session. + // CAPTURE: update activity + possible + // switch\_pane/doc\_session. const doc = event.document; // const kind = classifyDocument(doc); const pos = event.selection?.active ?? new vscode.Position(0, 0); @@ -459,7 +470,8 @@ export const activate = (context: vscode.ExtensionContext) => { "CodeChat Editor extension: sending updated cursor/scroll position.", ); - // CAPTURE: treat a selection change as "activity" in this document. + // CAPTURE: treat a selection change as "activity" + // in this document. const doc = event.textEditor.document; // const kind = classifyDocument(doc); const pos = event.selections?.[0]?.active ?? event.textEditor.selection.active; @@ -561,7 +573,8 @@ export const activate = (context: vscode.ExtensionContext) => { ); } - // Get the CodeChat Client's location from the VSCode configuration. + // Get the CodeChat Client's location from the VSCode + // configuration. const codechat_client_location_str = vscode.workspace .getConfiguration("CodeChatEditor.Server") .get("ClientLocation"); @@ -606,7 +619,8 @@ export const activate = (context: vscode.ExtensionContext) => { } } - // Provide a simple status display while the server is starting up. + // Provide a simple status display while the server is starting + // up. if (webview_panel !== undefined) { webview_panel.webview.html = "

CodeChat Editor

Loading...

"; } else { @@ -866,7 +880,8 @@ export const activate = (context: vscode.ExtensionContext) => { export const deactivate = async () => { console_log("CodeChat Editor extension: deactivating."); - // CAPTURE: if we were in a doc session, close it out so duration is recorded. + // CAPTURE: if we were in a doc session, close it out so duration is + // recorded. if (docSessionStart !== null) { const now = Date.now(); const durationMs = now - docSessionStart; @@ -898,7 +913,7 @@ export const deactivate = async () => { }; // Supporting functions -// ----------------------------------------------------------------------------- +// -------------------- // // Format a complex data structure as a string when in debug mode. /*eslint-disable-next-line @typescript-eslint/no-explicit-any */ @@ -981,7 +996,8 @@ const send_update = (this_is_dirty: boolean) => { } }; -// Gracefully shut down the render client if possible. Shut down the client as well. +// Gracefully shut down the render client if possible. Shut down the client as +// well. const stop_client = async () => { console_log("CodeChat Editor extension: stopping client."); if (codeChatEditorServer !== undefined) { @@ -1019,8 +1035,8 @@ const show_error = (message: string) => { } }; -// Only render if the window and editor are active, we have a valid render client, -// and the webview is visible. +// Only render if the window and editor are active, we have a valid render +// client, and the webview is visible. const can_render = () => { return ( (vscode.window.activeTextEditor !== undefined || @@ -1032,7 +1048,7 @@ const can_render = () => { }; const get_document = (file_path: string) => { - for (const doc of vscode.workspace.textDocuments) { + for ( const doc of vscode.workspace.textDocuments) { if ( (!is_windows && doc.fileName === file_path) || (is_windows && doc.fileName.toUpperCase() === file_path.toUpperCase()) From 1edd6dd9c99b5f9642ac99a234a8b038c1e7d36d Mon Sep 17 00:00:00 2001 From: John Spahn <44337821+jspahn80134@users.noreply.github.com> Date: Mon, 13 Apr 2026 08:31:43 -0600 Subject: [PATCH 07/45] Minor statrup fix --- extensions/VSCode/.gitignore | 2 +- server/src/capture.rs | 130 ++++++++++++++++++----------------- 2 files changed, 67 insertions(+), 65 deletions(-) diff --git a/extensions/VSCode/.gitignore b/extensions/VSCode/.gitignore index 8c5160c5..3780ba9f 100644 --- a/extensions/VSCode/.gitignore +++ b/extensions/VSCode/.gitignore @@ -33,5 +33,5 @@ src/index.d.ts src/index.js src/codechat-editor-client.win32-x64-msvc.node .windows/ - +*.log # CodeChat Editor lexer: python. See TODO. diff --git a/server/src/capture.rs b/server/src/capture.rs index fd7eede9..842b5815 100644 --- a/server/src/capture.rs +++ b/server/src/capture.rs @@ -57,7 +57,7 @@ /// * `timestamp` – RFC3339 timestamp (in UTC). /// * `data` – JSON payload with event-specific details. -use std::io; +use std::{io, thread}; use chrono::{DateTime, Utc}; use log::{debug, error, info, warn}; @@ -196,73 +196,75 @@ impl EventCapture { let (tx, mut rx) = mpsc::unbounded_channel::(); - // Spawn a background task that will connect to PostgreSQL and then - // process events. This task runs on the Tokio/Actix runtime once the - // system starts, so the caller does not need to be async. - tokio::spawn(async move { - info!("Capture: attempting to connect to PostgreSQL…"); - - match tokio_postgres::connect(&conn_str, NoTls).await { - Ok((client, connection)) => { - info!("Capture: successfully connected to PostgreSQL."); - - // Drive the connection in its own task. - tokio::spawn(async move { - if let Err(err) = connection.await { - error!("Capture PostgreSQL connection error: {err}"); - } - }); - - // Main event loop: pull events off the channel and insert - // them into the database. - while let Some(event) = rx.recv().await { - debug!( - "Capture: inserting event: type={}, user_id={}, assignment_id={:?}, group_id={:?}, file_path={:?}", - event.event_type, - event.user_id, - event.assignment_id, - event.group_id, - event.file_path - ); - - if let Err(err) = insert_event(&client, &event).await { - error!( - "Capture: FAILED to insert event (type={}, user_id={}): {err}", - event.event_type, event.user_id - ); - } else { - debug!("Capture: event insert successful."); + // Create a dedicated runtime so capture can be started from sync code + // before the Actix/Tokio server runtime exists. + thread::Builder::new() + .name("codechat-capture".to_string()) + .spawn(move || { + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(1) + .enable_all() + .build() + .expect("Capture: failed to build Tokio runtime"); + + runtime.block_on(async move { + info!("Capture: attempting to connect to PostgreSQL…"); + + match tokio_postgres::connect(&conn_str, NoTls).await { + Ok((client, connection)) => { + info!("Capture: successfully connected to PostgreSQL."); + + // Drive the connection in its own task. + tokio::spawn(async move { + if let Err(err) = connection.await { + error!("Capture PostgreSQL connection error: {err}"); + } + }); + + // Main event loop: pull events off the channel and insert + // them into the database. + while let Some(event) = rx.recv().await { + debug!( + "Capture: inserting event: type={}, user_id={}, assignment_id={:?}, group_id={:?}, file_path={:?}", + event.event_type, + event.user_id, + event.assignment_id, + event.group_id, + event.file_path + ); + + if let Err(err) = insert_event(&client, &event).await { + error!( + "Capture: FAILED to insert event (type={}, user_id={}): {err}", + event.event_type, event.user_id + ); + } else { + debug!("Capture: event insert successful."); + } + } + + info!("Capture: event channel closed; background worker exiting."); } - } - - info!("Capture: event channel closed; background worker exiting."); - } -Err(err) => { - let ctx = format!( - "Capture: FAILED to connect to PostgreSQL (host={}, dbname={}, user={})", - config.host, config.dbname, config.user - ); - - log_pg_connect_error(&ctx, &err); + Err(err) => { + let ctx = format!( + "Capture: FAILED to connect to PostgreSQL (host={}, dbname={}, user={})", + config.host, config.dbname, config.user + ); - // Drain and drop any events so we don't hold the sender. - warn!("Capture: draining pending events after failed DB connection."); - while rx.recv().await.is_some() {} - warn!("Capture: all pending events dropped due to connection failure."); -} + log_pg_connect_error(&ctx, &err); - // Err(err) => { // NOTE: we *don't* pass `err` twice here; - // `{err}` in the format // string already grabs the local `err` - // binding. error!( "Capture: FAILED to connect to PostgreSQL - // (host={}, dbname={}, user={}): {err}", config.host, - // config.dbname, config.user, ); // Drain and drop any events - // so we don't hold the sender. warn!("Capture: draining pending - // events after failed DB connection."); while - // rx.recv().await.is\_some() {} warn!("Capture: all pending - // events dropped due to connection failure."); } - } - }); + // Drain and drop any events so we don't hold the sender. + warn!("Capture: draining pending events after failed DB connection."); + while rx.recv().await.is_some() {} + warn!("Capture: all pending events dropped due to connection failure."); + } + } + }); + }) + .map_err(|err| { + io::Error::other(format!("Capture: failed to start worker thread: {err}")) + })?; Ok(Self { tx }) } From fe4abbc52ad7e0e4fac2efaaeab57f69ea1df521 Mon Sep 17 00:00:00 2001 From: John Spahn <44337821+jspahn80134@users.noreply.github.com> Date: Tue, 21 Apr 2026 22:07:29 -0600 Subject: [PATCH 08/45] Code Review Workoff --- .gitignore | 3 + extensions/VSCode/src/extension.ts | 148 +++++++++---------- extensions/VSCode/src/lib.rs | 7 + server/capture_config.json | 9 -- server/src/capture.rs | 2 +- server/src/ide.rs | 8 ++ server/src/ide/filewatcher.rs | 1 + server/src/translation.rs | 222 +++++++++++++++++++++++++++-- server/src/webserver.rs | 117 +++++++-------- 9 files changed, 368 insertions(+), 149 deletions(-) delete mode 100644 server/capture_config.json diff --git a/.gitignore b/.gitignore index 7f3f076b..2b4f8c30 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,7 @@ # dist build output target/ +# The runtime capture config is resolved from the repository root. +server/capture_config.json + # CodeChat Editor lexer: python. See TODO. diff --git a/extensions/VSCode/src/extension.ts b/extensions/VSCode/src/extension.ts index e2933241..1f4fc7c4 100644 --- a/extensions/VSCode/src/extension.ts +++ b/extensions/VSCode/src/extension.ts @@ -180,7 +180,7 @@ function classifyAtPosition(doc: vscode.TextDocument, pos: vscode.Position): Act -// Types for talking to the Rust /capture endpoint. This mirrors +// Types for sending capture events to the Rust server. This mirrors // `CaptureEventWire` in webserver.rs. interface CaptureEventPayload { user_id: string; @@ -188,6 +188,8 @@ interface CaptureEventPayload { group_id?: string; file_path?: string; event_type: string; + client_timestamp_ms?: number; + client_tz_offset_min?: number; data: any; // sent as JSON } @@ -214,9 +216,10 @@ const CAPTURE_USER_ID: string = (() => { const CAPTURE_ASSIGNMENT_ID = "demo-assignment"; const CAPTURE_GROUP_ID = "demo-group"; -// Base URL for the CodeChat server's /capture endpoint. NOTE: keep this in sync -// with whatever port your server actually uses. -const CAPTURE_SERVER_BASE = "http://127.0.0.1:8080"; +let capture_output_channel: vscode.OutputChannel | undefined; +let captureFailureLogged = false; +let captureTransportReady = false; +let extensionCaptureSessionStarted = false; // Simple classification of what the user is currently doing. type ActivityKind = "doc" | "code" | "other"; @@ -249,7 +252,6 @@ function classifyDocument(doc: vscode.TextDocument | undefined): ActivityKind { // Helper to send a capture event to the Rust server. async function sendCaptureEvent( - serverBaseUrl: string, // e.g. "http://127.0.0.1:8080" eventType: string, filePath?: string, data: any = {}, @@ -260,33 +262,60 @@ async function sendCaptureEvent( group_id: CAPTURE_GROUP_ID, file_path: filePath, event_type: eventType, + client_timestamp_ms: Date.now(), + client_tz_offset_min: new Date().getTimezoneOffset(), data: { ...data, session_id: CAPTURE_SESSION_ID, - client_timestamp_ms: Date.now(), - client_tz_offset_min: new Date().getTimezoneOffset(), }, }; - try { - const resp = await fetch(`${serverBaseUrl}/capture`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(payload), - }); + if (codeChatEditorServer === undefined) { + reportCaptureFailure("CodeChat server is not running"); + return; + } + if (!captureTransportReady) { + capture_output_channel?.appendLine( + `${new Date().toISOString()} capture skipped before server handshake: ${JSON.stringify(payload)}`, + ); + return; + } - if (!resp.ok) { - console.error( - "Capture event failed:", - resp.status, - await resp.text(), - ); - } + logCaptureEvent(payload); + + try { + await codeChatEditorServer.sendCaptureEvent(JSON.stringify(payload)); + captureFailureLogged = false; } catch (err) { - console.error("Error sending capture event:", err); + reportCaptureFailure(err instanceof Error ? err.message : String(err)); + } +} + +function logCaptureEvent(payload: CaptureEventPayload) { + capture_output_channel?.appendLine( + `${new Date().toISOString()} ${JSON.stringify(payload)}`, + ); +} + +function reportCaptureFailure(message: string) { + capture_output_channel?.appendLine( + `${new Date().toISOString()} capture send failed: ${message}`, + ); + if (captureFailureLogged) { + return; + } + captureFailureLogged = true; + console.warn(`CodeChat capture event was not queued: ${message}`); +} + +async function startExtensionCaptureSession(filePath?: string) { + if (extensionCaptureSessionStarted) { + return; } + extensionCaptureSessionStarted = true; + await sendCaptureEvent("session_start", filePath, { + mode: "vscode_extension", + }); } // Update activity state, emit switch + doc\_session events as needed. @@ -298,7 +327,7 @@ function noteActivity(kind: ActivityKind, filePath?: string) { if (docSessionStart === null) { // Starting a new reflective-writing session. docSessionStart = now; - void sendCaptureEvent(CAPTURE_SERVER_BASE, "session_start", filePath, { + void sendCaptureEvent("session_start", filePath, { mode: "doc", }); } @@ -307,11 +336,11 @@ function noteActivity(kind: ActivityKind, filePath?: string) { // Ending a reflective-writing session. const durationMs = now - docSessionStart; docSessionStart = null; - void sendCaptureEvent(CAPTURE_SERVER_BASE, "doc_session", filePath, { + void sendCaptureEvent("doc_session", filePath, { duration_ms: durationMs, duration_seconds: durationMs / 1000.0, }); - void sendCaptureEvent(CAPTURE_SERVER_BASE, "session_end", filePath, { + void sendCaptureEvent("session_end", filePath, { mode: "doc", }); } @@ -320,7 +349,7 @@ function noteActivity(kind: ActivityKind, filePath?: string) { // If we switched between doc and code, log a switch\_pane event. const docOrCode = (k: ActivityKind) => k === "doc" || k === "code"; if (docOrCode(lastActivityKind) && docOrCode(kind) && kind !== lastActivityKind) { - void sendCaptureEvent(CAPTURE_SERVER_BASE, "switch_pane", filePath, { + void sendCaptureEvent("switch_pane", filePath, { from: lastActivityKind, to: kind, }); @@ -335,6 +364,9 @@ function noteActivity(kind: ActivityKind, filePath?: string) { // This is invoked when the extension is activated. It either creates a new // CodeChat Editor Server instance or reveals the currently running one. export const activate = (context: vscode.ExtensionContext) => { + capture_output_channel = vscode.window.createOutputChannel("CodeChat Capture"); + context.subscriptions.push(capture_output_channel); + context.subscriptions.push( vscode.commands.registerCommand( "extension.codeChatEditorDeactivate", @@ -345,18 +377,6 @@ export const activate = (context: vscode.ExtensionContext) => { async () => { console_log("CodeChat Editor extension: starting."); - // CAPTURE: mark the start of an editor session. - const active = vscode.window.activeTextEditor; - const startFilePath = active?.document.fileName; - void sendCaptureEvent( - CAPTURE_SERVER_BASE, - "session_start", - startFilePath, - { - mode: "vscode_extension", - }, - ); - if (!subscribed) { subscribed = true; @@ -379,8 +399,8 @@ export const activate = (context: vscode.ExtensionContext) => { }, ${format_struct(event.contentChanges)}.`, ); - // CAPTURE: classify this as documentation vs. code - // and log a write\_\* event. + // CAPTURE: update session/switch state. The server + // classifies write_* events after parsing. const doc = event.document; // ``` // const kind = classifyDocument(doc); @@ -390,31 +410,6 @@ export const activate = (context: vscode.ExtensionContext) => { const kind = classifyAtPosition(doc, pos); const filePath = doc.fileName; - const charsTyped = event.contentChanges - .map((c) => c.text.length) - .reduce((a, b) => a + b, 0); - - if (kind === "doc") { - void sendCaptureEvent( - CAPTURE_SERVER_BASE, - "write_doc", - filePath, - { - chars_typed: charsTyped, - languageId: doc.languageId, - }, - ); - } else if (kind === "code") { - void sendCaptureEvent( - CAPTURE_SERVER_BASE, - "write_code", - filePath, - { - chars_typed: charsTyped, - languageId: doc.languageId, - }, - ); - } // Update our notion of current activity + doc // session. @@ -489,7 +484,6 @@ export const activate = (context: vscode.ExtensionContext) => { const active = vscode.window.activeTextEditor; const filePath = active?.document.fileName; void sendCaptureEvent( - CAPTURE_SERVER_BASE, "run_end", filePath, { @@ -507,7 +501,6 @@ export const activate = (context: vscode.ExtensionContext) => { const filePath = active?.document.fileName; const task = e.execution.task; void sendCaptureEvent( - CAPTURE_SERVER_BASE, "compile_end", filePath, { @@ -523,7 +516,6 @@ export const activate = (context: vscode.ExtensionContext) => { context.subscriptions.push( vscode.workspace.onDidSaveTextDocument((doc) => { void sendCaptureEvent( - CAPTURE_SERVER_BASE, "save", doc.fileName, { @@ -541,7 +533,6 @@ export const activate = (context: vscode.ExtensionContext) => { const active = vscode.window.activeTextEditor; const filePath = active?.document.fileName; void sendCaptureEvent( - CAPTURE_SERVER_BASE, "run", filePath, { @@ -559,7 +550,6 @@ export const activate = (context: vscode.ExtensionContext) => { const filePath = active?.document.fileName; const task = e.execution.task; void sendCaptureEvent( - CAPTURE_SERVER_BASE, "compile", filePath, { @@ -632,6 +622,9 @@ export const activate = (context: vscode.ExtensionContext) => { // Start the server. console_log("CodeChat Editor extension: starting server."); codeChatEditorServer = new CodeChatEditorServer(); + captureFailureLogged = false; + captureTransportReady = false; + extensionCaptureSessionStarted = false; const hosted_in_ide = codechat_client_location === CodeChatEditorClientLocation.html; @@ -641,6 +634,9 @@ export const activate = (context: vscode.ExtensionContext) => { await codeChatEditorServer.sendMessageOpened(hosted_in_ide); if (codechat_client_location === CodeChatEditorClientLocation.browser) { + captureTransportReady = true; + const active = vscode.window.activeTextEditor; + void startExtensionCaptureSession(active?.document.fileName); send_update(false); } @@ -860,6 +856,9 @@ export const activate = (context: vscode.ExtensionContext) => { assert(webview_panel !== undefined); webview_panel.webview.html = client_html; await sendResult(id); + captureTransportReady = true; + const active = vscode.window.activeTextEditor; + void startExtensionCaptureSession(active?.document.fileName); send_update(false); break; } @@ -889,12 +888,12 @@ export const deactivate = async () => { const active = vscode.window.activeTextEditor; const filePath = active?.document.fileName; - await sendCaptureEvent(CAPTURE_SERVER_BASE, "doc_session", filePath, { + await sendCaptureEvent("doc_session", filePath, { duration_ms: durationMs, duration_seconds: durationMs / 1000.0, closed_by: "extension_deactivate", }); - await sendCaptureEvent(CAPTURE_SERVER_BASE, "session_end", filePath, { + await sendCaptureEvent("session_end", filePath, { mode: "doc", closed_by: "extension_deactivate", }); @@ -903,7 +902,7 @@ export const deactivate = async () => { // CAPTURE: mark the end of an editor session. const active = vscode.window.activeTextEditor; const endFilePath = active?.document.fileName; - await sendCaptureEvent(CAPTURE_SERVER_BASE, "session_end", endFilePath, { + await sendCaptureEvent("session_end", endFilePath, { mode: "vscode_extension", }); @@ -1005,6 +1004,7 @@ const stop_client = async () => { await codeChatEditorServer.stopServer(); codeChatEditorServer = undefined; } + captureTransportReady = false; if (idle_timer !== undefined) { clearTimeout(idle_timer); diff --git a/extensions/VSCode/src/lib.rs b/extensions/VSCode/src/lib.rs index bef47679..b869640a 100644 --- a/extensions/VSCode/src/lib.rs +++ b/extensions/VSCode/src/lib.rs @@ -80,6 +80,13 @@ impl CodeChatEditorServer { self.0.send_message_opened(hosted_in_ide).await } + #[napi] + pub async fn send_capture_event(&self, capture_event_json: String) -> std::io::Result { + let capture_event = serde_json::from_str(&capture_event_json) + .map_err(|err| std::io::Error::other(err.to_string()))?; + self.0.send_capture_event(capture_event).await + } + #[napi] pub async fn send_message_current_file(&self, url: String) -> std::io::Result { self.0.send_message_current_file(url).await diff --git a/server/capture_config.json b/server/capture_config.json deleted file mode 100644 index 574f1477..00000000 --- a/server/capture_config.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "host": "3.146.138.182", - "port": 5432, - "dbname": "CodeChatCaptureDB", - "user": "CodeChatCaptureUser", - "password": "OB3yc8Hk9SuVjzXMdUDr0C7w4PqLQisn", - "max_connections": 5, - "timeout_seconds": 30 -} diff --git a/server/src/capture.rs b/server/src/capture.rs index 842b5815..bd7a73e0 100644 --- a/server/src/capture.rs +++ b/server/src/capture.rs @@ -48,7 +48,7 @@ /// data TEXT /// ); /// ``` -/// +/// New comment /// * `user_id` – participant identifier (student id, pseudonym, etc.). /// * `assignment_id` – logical assignment / lab identifier. /// * `group_id` – optional grouping (treatment / comparison, section). diff --git a/server/src/ide.rs b/server/src/ide.rs index 21037d95..92141707 100644 --- a/server/src/ide.rs +++ b/server/src/ide.rs @@ -237,6 +237,14 @@ impl CodeChatEditorServer { .await } + pub async fn send_capture_event( + &self, + capture_event: webserver::CaptureEventWire, + ) -> std::io::Result { + self.send_message_timeout(EditorMessageContents::Capture(capture_event)) + .await + } + // Send a `CurrentFile` message. The other parameter (true if text/false if // binary/None if ignored) is ignored by the server, so it's always sent as // `None`. diff --git a/server/src/ide/filewatcher.rs b/server/src/ide/filewatcher.rs index 93b2829a..3068ca23 100644 --- a/server/src/ide/filewatcher.rs +++ b/server/src/ide/filewatcher.rs @@ -668,6 +668,7 @@ async fn processing_task( EditorMessageContents::Opened(_) | EditorMessageContents::OpenUrl(_) | + EditorMessageContents::Capture(_) | EditorMessageContents::ClientHtml(_) | EditorMessageContents::RequestClose => { let err = ResultErrTypes::ClientIllegalMessage; diff --git a/server/src/translation.rs b/server/src/translation.rs index 141d822b..9a20c4e2 100644 --- a/server/src/translation.rs +++ b/server/src/translation.rs @@ -221,6 +221,7 @@ use tokio::{ // ### Local use crate::{ + capture::event_types, lexer::supported_languages::MARKDOWN_MODE, processing::{ CodeChatForWeb, CodeMirror, CodeMirrorDiff, CodeMirrorDiffable, CodeMirrorDocBlock, @@ -230,11 +231,11 @@ use crate::{ }, queue_send, queue_send_func, webserver::{ - EditorMessage, EditorMessageContents, INITIAL_MESSAGE_ID, MESSAGE_ID_INCREMENT, - ProcessingTaskHttpRequest, ProcessingTaskHttpRequestFlags, ResultErrTypes, ResultOkTypes, - SimpleHttpResponse, SimpleHttpResponseError, UpdateMessageContents, WebAppState, - WebsocketQueues, file_to_response, path_to_url, send_response, try_canonicalize, - try_read_as_text, url_to_path, + CaptureEventWire, EditorMessage, EditorMessageContents, INITIAL_MESSAGE_ID, + MESSAGE_ID_INCREMENT, ProcessingTaskHttpRequest, ProcessingTaskHttpRequestFlags, + ResultErrTypes, ResultOkTypes, SimpleHttpResponse, SimpleHttpResponseError, + UpdateMessageContents, WebAppState, WebsocketQueues, file_to_response, log_capture_event, + path_to_url, send_response, try_canonicalize, try_read_as_text, url_to_path, }, }; @@ -384,6 +385,7 @@ pub fn create_translation_queues( /// allows factoring out lengthy contents in the loop into subfunctions. struct TranslationTask { // These parameters are passed to us. + app_state: WebAppState, connection_id_raw: String, prefix: &'static [&'static str], allow_source_diffs: bool, @@ -432,6 +434,69 @@ struct TranslationTask { /// Has the full (non-diff) version of the current file been sent? Don't /// send diffs until this is sent. sent_full: bool, + capture_context: CaptureContext, +} + +#[derive(Clone, Debug, Default)] +struct CaptureContext { + user_id: Option, + assignment_id: Option, + group_id: Option, + session_id: Option, + client_tz_offset_min: Option, +} + +impl CaptureContext { + fn update_from_wire(&mut self, wire: &CaptureEventWire) { + if !wire.user_id.trim().is_empty() { + self.user_id = Some(wire.user_id.clone()); + } + if let Some(assignment_id) = &wire.assignment_id { + self.assignment_id = Some(assignment_id.clone()); + } + if let Some(group_id) = &wire.group_id { + self.group_id = Some(group_id.clone()); + } + if let Some(client_tz_offset_min) = wire.client_tz_offset_min { + self.client_tz_offset_min = Some(client_tz_offset_min); + } + if let Some(serde_json::Value::Object(data)) = &wire.data + && let Some(session_id) = data.get("session_id").and_then(serde_json::Value::as_str) + { + self.session_id = Some(session_id.to_string()); + } + } + + fn capture_event( + &self, + event_type: &str, + file_path: Option, + data: serde_json::Value, + ) -> Option { + let mut data = match data { + serde_json::Value::Object(map) => map, + other => { + let mut map = serde_json::Map::new(); + map.insert("value".to_string(), other); + map + } + }; + if let Some(session_id) = &self.session_id { + data.entry("session_id".to_string()) + .or_insert_with(|| serde_json::json!(session_id)); + } + + Some(CaptureEventWire { + user_id: self.user_id.clone()?, + assignment_id: self.assignment_id.clone(), + group_id: self.group_id.clone(), + file_path, + event_type: event_type.to_string(), + client_timestamp_ms: None, + client_tz_offset_min: self.client_tz_offset_min, + data: Some(serde_json::Value::Object(data)), + }) + } } /// This is the processing task for the Visual Studio Code IDE. It handles all @@ -463,6 +528,7 @@ pub async fn translation_task( let mut continue_loop = true; let mut tt = TranslationTask { + app_state: app_state.clone(), connection_id_raw, prefix, allow_source_diffs, @@ -486,6 +552,7 @@ pub async fn translation_task( version: 0.0, // Don't send diffs until this is sent. sent_full: false, + capture_context: CaptureContext::default(), }; while continue_loop { select! { @@ -512,6 +579,11 @@ pub async fn translation_task( EditorMessageContents::Result(_) => continue_loop = tt.ide_result(ide_message).await, EditorMessageContents::Update(_) => continue_loop = tt.ide_update(ide_message).await, + EditorMessageContents::Capture(capture_event) => { + tt.capture_context.update_from_wire(&capture_event); + log_capture_event(&app_state, capture_event); + send_response(&tt.to_ide_tx, ide_message.id, Ok(ResultOkTypes::Void)).await; + }, // Update the current file; translate it to a URL then // pass it to the Client. @@ -607,6 +679,11 @@ pub async fn translation_task( }, EditorMessageContents::Update(_) => continue_loop = tt.client_update(client_message).await, + EditorMessageContents::Capture(capture_event) => { + tt.capture_context.update_from_wire(&capture_event); + log_capture_event(&app_state, capture_event); + send_response(&tt.to_client_tx, client_message.id, Ok(ResultOkTypes::Void)).await; + }, // Update the current file; translate it to a URL then // pass it to the IDE. @@ -697,6 +774,103 @@ pub async fn translation_task( // These provide translation for messages passing through the Server. impl TranslationTask { + fn capture_file_path(file_path: &std::path::Path) -> Option { + file_path.to_str().map(str::to_string) + } + + fn log_server_capture_event( + &self, + event_type: &str, + file_path: &std::path::Path, + data: serde_json::Value, + ) { + let Some(capture_event) = self.capture_context.capture_event( + event_type, + Self::capture_file_path(file_path), + data, + ) else { + debug!("Skipping server-classified capture event; capture identity is not known yet."); + return; + }; + log_capture_event(&self.app_state, capture_event); + } + + fn log_raw_write_event(&self, file_path: &std::path::Path, before: &str, after: &str) { + if before == after { + return; + } + self.log_server_capture_event( + event_types::WRITE_CODE, + file_path, + serde_json::json!({ + "source": "server_translation", + "classification_basis": "raw_text", + "diff": diff_str(before, after), + }), + ); + } + + fn log_code_mirror_write_events( + &self, + file_path: &std::path::Path, + metadata: &SourceFileMetadata, + before_doc: &str, + before_doc_blocks: Option<&CodeMirrorDocBlockVec>, + after: &CodeMirror, + source: &str, + ) { + if metadata.mode == MARKDOWN_MODE { + if !compare_html(before_doc, &after.doc) { + self.log_server_capture_event( + event_types::WRITE_DOC, + file_path, + serde_json::json!({ + "source": source, + "classification_basis": "markdown_document", + "mode": metadata.mode, + "diff": diff_str(before_doc, &after.doc), + }), + ); + } + return; + } + + if before_doc != after.doc { + self.log_server_capture_event( + event_types::WRITE_CODE, + file_path, + serde_json::json!({ + "source": source, + "classification_basis": "codemirror_code_text", + "mode": metadata.mode, + "diff": diff_str(before_doc, &after.doc), + }), + ); + } + + let doc_blocks_changed = match before_doc_blocks { + Some(before) => !doc_block_compare(before, &after.doc_blocks), + None => !after.doc_blocks.is_empty(), + }; + if doc_blocks_changed { + let doc_block_diff = before_doc_blocks.map(|before| { + serde_json::json!(diff_code_mirror_doc_blocks(before, &after.doc_blocks)) + }); + self.log_server_capture_event( + event_types::WRITE_DOC, + file_path, + serde_json::json!({ + "source": source, + "classification_basis": "codemirror_doc_blocks", + "mode": metadata.mode, + "doc_block_count_before": before_doc_blocks.map_or(0, Vec::len), + "doc_block_count_after": after.doc_blocks.len(), + "doc_block_diff": doc_block_diff, + }), + ); + } + } + // Pass a `Result` message to the Client, unless it's a `LoadFile` result. async fn ide_result(&mut self, ide_message: EditorMessage) -> bool { let EditorMessageContents::Result(ref result) = ide_message.message else { @@ -892,6 +1066,16 @@ impl TranslationTask { else { panic!("Unexpected diff value."); }; + if self.sent_full { + self.log_code_mirror_write_events( + &clean_file_path, + &ccfw.metadata, + &self.code_mirror_doc, + self.code_mirror_doc_blocks.as_ref(), + code_mirror_translated, + "ide", + ); + } // Send a diff if possible. let client_contents = if self.sent_full { self.diff_code_mirror( @@ -937,6 +1121,13 @@ impl TranslationTask { Err(ResultErrTypes::TodoBinarySupport) } TranslationResultsString::Unknown => { + if self.sent_full { + self.log_raw_write_event( + &clean_file_path, + &self.source_code, + &code_mirror.doc, + ); + } // Send the new raw contents. debug!("Sending translated contents to Client."); queue_send_func!(self.to_client_tx.send(EditorMessage { @@ -953,13 +1144,16 @@ impl TranslationTask { mode: "".to_string(), }, source: CodeMirrorDiffable::Plain(CodeMirror { - doc: code_mirror.doc, + doc: code_mirror.doc.clone(), doc_blocks: vec![] }), version: contents.version }), }), })); + self.source_code = code_mirror.doc; + self.code_mirror_doc = self.source_code.clone(); + self.code_mirror_doc_blocks = Some(vec![]); Ok(ResultOkTypes::Void) } TranslationResultsString::Toc(_) => { @@ -1042,12 +1236,22 @@ impl TranslationTask { // what we just received. This must be updated // before we can translate back to check for changes // (the next step). - let CodeMirrorDiffable::Plain(code_mirror) = cfw.source else { + let CodeMirrorDiffable::Plain(ref code_mirror) = cfw.source else { // TODO: support diffable! panic!("Diff not supported."); }; - self.code_mirror_doc = code_mirror.doc; - self.code_mirror_doc_blocks = Some(code_mirror.doc_blocks); + if self.sent_full { + self.log_code_mirror_write_events( + &clean_file_path, + &cfw.metadata, + &self.code_mirror_doc, + self.code_mirror_doc_blocks.as_ref(), + code_mirror, + "client", + ); + } + self.code_mirror_doc = code_mirror.doc.clone(); + self.code_mirror_doc_blocks = Some(code_mirror.doc_blocks.clone()); // We may need to change this version if we send a // diff back to the Client. let mut cfw_version = cfw.version; diff --git a/server/src/webserver.rs b/server/src/webserver.rs index c963bab7..ef533981 100644 --- a/server/src/webserver.rs +++ b/server/src/webserver.rs @@ -42,9 +42,9 @@ use actix_web::{ App, HttpRequest, HttpResponse, HttpServer, dev::{Server, ServerHandle, ServiceFactory, ServiceRequest}, error::Error, - get, post, + get, http::header::{ContentType, DispositionType}, - middleware, + middleware, post, web::{self, Data}, }; @@ -95,7 +95,7 @@ use crate::{ }, }; -use crate::capture::{EventCapture, CaptureConfig, CaptureEvent}; +use crate::capture::{CaptureConfig, CaptureEvent, EventCapture}; use chrono::Utc; @@ -204,6 +204,8 @@ pub enum EditorMessageContents { // Server will determine the value if needed. Option, ), + /// Record an instrumentation event. Valid destinations: Server. + Capture(CaptureEventWire), // #### These messages may only be sent by the IDE. /// This is the first message sent when the IDE starts up. It may only be @@ -385,7 +387,7 @@ pub struct AppState { /// The auth credentials if authentication is used. credentials: Option, // Added to support capture - JDS - 11/2025 - pub capture: Option, + pub capture: Option, } pub type WebAppState = web::Data; @@ -399,25 +401,32 @@ pub struct Credentials { /// JSON payload received from clients for capture events. /// /// The server will supply the timestamp; clients do not need to send it. -#[derive(Debug, Deserialize)] +#[derive(Debug, Serialize, Deserialize, PartialEq, TS)] +#[ts(export, optional_fields)] pub struct CaptureEventWire { pub user_id: String, + #[serde(skip_serializing_if = "Option::is_none")] pub assignment_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub group_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub file_path: Option, pub event_type: String, /// Optional client-side timestamp (milliseconds since Unix epoch). + #[serde(skip_serializing_if = "Option::is_none")] pub client_timestamp_ms: Option, /// Optional client timezone offset in minutes (JS Date().getTimezoneOffset()). + #[serde(skip_serializing_if = "Option::is_none")] pub client_tz_offset_min: Option, /// Arbitrary event-specific data stored as JSON (optional). + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(type = "unknown")] pub data: Option, } - // Macros // ----------------------------------------------------------------------------- /// Create a macro to report an error when enqueueing an item. @@ -606,44 +615,45 @@ async fn capture_endpoint( app_state: WebAppState, payload: web::Json, ) -> HttpResponse { - let wire = payload.into_inner(); - - if let Some(capture) = &app_state.capture { - // Default missing data to empty object - let mut data = wire.data.unwrap_or_else(|| serde_json::json!({})); + log_capture_event(&app_state, payload.into_inner()); + HttpResponse::Ok().finish() +} - // Ensure data is an object so we can attach fields - if !data.is_object() { - data = serde_json::json!({ "value": data }); - } +/// Log a capture event if capture is enabled. +pub fn log_capture_event(app_state: &WebAppState, wire: CaptureEventWire) { + if let Some(capture) = &app_state.capture { + // Default missing data to empty object + let mut data = wire.data.unwrap_or_else(|| serde_json::json!({})); - // Add client timestamp fields if present (even if extension also sends them; - // overwriting is fine and consistent). - if let serde_json::Value::Object(map) = &mut data { - if let Some(ms) = wire.client_timestamp_ms { - map.insert("client_timestamp_ms".to_string(), serde_json::json!(ms)); - } - if let Some(tz) = wire.client_tz_offset_min { - map.insert("client_tz_offset_min".to_string(), serde_json::json!(tz)); + // Ensure data is an object so we can attach fields + if !data.is_object() { + data = serde_json::json!({ "value": data }); } - } - - let event = CaptureEvent { - user_id: wire.user_id, - assignment_id: wire.assignment_id, - group_id: wire.group_id, - file_path: wire.file_path, - event_type: wire.event_type, - // Server decides when the event is recorded. - timestamp: Utc::now(), - data, - }; - capture.log(event); -} + // Add client timestamp fields if present (even if extension also sends them; + // overwriting is fine and consistent). + if let serde_json::Value::Object(map) = &mut data { + if let Some(ms) = wire.client_timestamp_ms { + map.insert("client_timestamp_ms".to_string(), serde_json::json!(ms)); + } + if let Some(tz) = wire.client_tz_offset_min { + map.insert("client_tz_offset_min".to_string(), serde_json::json!(tz)); + } + } + let event = CaptureEvent { + user_id: wire.user_id, + assignment_id: wire.assignment_id, + group_id: wire.group_id, + file_path: wire.file_path, + event_type: wire.event_type, + // Server decides when the event is recorded. + timestamp: Utc::now(), + data, + }; - HttpResponse::Ok().finish() + capture.log(event); + } } // Get the `mode` query parameter to determine `is_test_mode`; default to @@ -1489,7 +1499,6 @@ pub fn setup_server( addr: &SocketAddr, credentials: Option, ) -> std::io::Result<(Server, Data)> { - // Pre-load the bundled files before starting the webserver. let _ = &*BUNDLED_FILES_MAP; let app_data = make_app_data(credentials); @@ -1576,26 +1585,22 @@ pub fn make_app_data(credentials: Option) -> WebAppState { config_path.push("capture_config.json"); match fs::read_to_string(&config_path) { - Ok(json) => { - match serde_json::from_str::(&json) { - Ok(cfg) => match EventCapture::new(cfg) { - Ok(ec) => { - eprintln!("Capture: enabled (config file: {config_path:?})"); - Some(ec) - } - Err(err) => { - eprintln!("Capture: failed to initialize from {config_path:?}: {err}"); - None - } - }, + Ok(json) => match serde_json::from_str::(&json) { + Ok(cfg) => match EventCapture::new(cfg) { + Ok(ec) => { + eprintln!("Capture: enabled (config file: {config_path:?})"); + Some(ec) + } Err(err) => { - eprintln!( - "Capture: invalid JSON in {config_path:?}: {err}" - ); + eprintln!("Capture: failed to initialize from {config_path:?}: {err}"); None } + }, + Err(err) => { + eprintln!("Capture: invalid JSON in {config_path:?}: {err}"); + None } - } + }, Err(err) => { eprintln!( "Capture: disabled (config file not found or unreadable: {config_path:?}: {err})" @@ -1645,7 +1650,7 @@ where .service(vscode_client_framework) .service(ping) .service(stop) - .service(capture_endpoint) + .service(capture_endpoint) // Reroute to the filewatcher filesystem for typical user-requested // URLs. .route("/", web::get().to(filewatcher_root_fs_redirect)) From 54cfac70f9401baa28c585004f1d38c40c9bc3ae Mon Sep 17 00:00:00 2001 From: John Spahn <44337821+jspahn80134@users.noreply.github.com> Date: Wed, 22 Apr 2026 09:14:05 -0600 Subject: [PATCH 09/45] Fix capture lint failure --- server/src/capture.rs | 102 ++++++++++++++++++++---------------------- 1 file changed, 49 insertions(+), 53 deletions(-) diff --git a/server/src/capture.rs b/server/src/capture.rs index bd7a73e0..c8904a75 100644 --- a/server/src/capture.rs +++ b/server/src/capture.rs @@ -14,57 +14,57 @@ // the CodeChat Editor. If not, see // [http://www.gnu.org/licenses](http://www.gnu.org/licenses). -/// `capture.rs` -- Capture CodeChat Editor Events -/// ============================================================================ -/// -/// This module provides an asynchronous event capture facility backed by a -/// PostgreSQL database. It is designed to support the dissertation study by -/// recording process-level data such as: -/// -/// * Frequency and timing of writing entries -/// * Edits to documentation and code -/// * Switches between documentation and coding activity -/// * Duration of engagement with reflective writing -/// * Save, compile, and run events -/// -/// Events are sent from the client (browser and/or VS Code extension) to the -/// server as JSON. The server enqueues events into an asynchronous worker which -/// performs batched inserts into the `events` table. -/// -/// Database schema -/// ---------------------------------------------------------------------------- -/// -/// The following SQL statement creates the `events` table used by this module: -/// -/// ```sql -/// CREATE TABLE events ( -/// id SERIAL PRIMARY KEY, -/// user_id TEXT NOT NULL, -/// assignment_id TEXT, -/// group_id TEXT, -/// file_path TEXT, -/// event_type TEXT NOT NULL, -/// timestamp TEXT NOT NULL, -/// data TEXT -/// ); -/// ``` -/// New comment -/// * `user_id` – participant identifier (student id, pseudonym, etc.). -/// * `assignment_id` – logical assignment / lab identifier. -/// * `group_id` – optional grouping (treatment / comparison, section). -/// * `file_path` – logical path of the file being edited. -/// * `event_type` – coarse event type (see `event_type` constants below). -/// * `timestamp` – RFC3339 timestamp (in UTC). -/// * `data` – JSON payload with event-specific details. +// `capture.rs` -- Capture CodeChat Editor Events +// ============================================================================ +// +// This module provides an asynchronous event capture facility backed by a +// PostgreSQL database. It is designed to support the dissertation study by +// recording process-level data such as: +// +// * Frequency and timing of writing entries +// * Edits to documentation and code +// * Switches between documentation and coding activity +// * Duration of engagement with reflective writing +// * Save, compile, and run events +// +// Events are sent from the client (browser and/or VS Code extension) to the +// server as JSON. The server enqueues events into an asynchronous worker which +// performs batched inserts into the `events` table. +// +// Database schema +// ---------------------------------------------------------------------------- +// +// The following SQL statement creates the `events` table used by this module: +// +// ```sql +// CREATE TABLE events ( +// id SERIAL PRIMARY KEY, +// user_id TEXT NOT NULL, +// assignment_id TEXT, +// group_id TEXT, +// file_path TEXT, +// event_type TEXT NOT NULL, +// timestamp TEXT NOT NULL, +// data TEXT +// ); +// ``` +// +// * `user_id` – participant identifier (student id, pseudonym, etc.). +// * `assignment_id` – logical assignment / lab identifier. +// * `group_id` – optional grouping (treatment / comparison, section). +// * `file_path` – logical path of the file being edited. +// * `event_type` – coarse event type (see `event_type` constants below). +// * `timestamp` – RFC3339 timestamp (in UTC). +// * `data` – JSON payload with event-specific details. use std::{io, thread}; use chrono::{DateTime, Utc}; use log::{debug, error, info, warn}; use serde::{Deserialize, Serialize}; +use std::error::Error; use tokio::sync::mpsc; use tokio_postgres::{Client, NoTls}; -use std::error::Error; /// Canonical event type strings. Keep these stable for analysis. pub mod event_types { @@ -273,11 +273,7 @@ impl EventCapture { pub fn log(&self, event: CaptureEvent) { debug!( "Capture: queueing event: type={}, user_id={}, assignment_id={:?}, group_id={:?}, file_path={:?}", - event.event_type, - event.user_id, - event.assignment_id, - event.group_id, - event.file_path + event.event_type, event.user_id, event.assignment_id, event.group_id, event.file_path ); if let Err(err) = self.tx.send(event) { @@ -325,8 +321,6 @@ fn log_pg_connect_error(context: &str, err: &tokio_postgres::Error) { error!("{context}: {err}"); } - - /// Insert a single event into the `events` table. async fn insert_event(client: &Client, event: &CaptureEvent) -> Result { let timestamp = event.timestamp.to_rfc3339(); @@ -470,7 +464,6 @@ mod tests { #[tokio::test] #[ignore] async fn event_capture_inserts_event_into_db() -> Result<(), Box> { - // Initialize logging for this test, using the same log4rs.yml as the // server. If logging is already initialized, this will just return an // error which we ignore. @@ -541,7 +534,10 @@ mod tests { ($1, NULL, NULL, NULL, 'test_event', $2, '{"test":true}') RETURNING id "#, - &[&test_user_id, &format!("{:?}", std::time::SystemTime::now())], + &[ + &test_user_id, + &format!("{:?}", std::time::SystemTime::now()), + ], ) .await?; @@ -568,7 +564,7 @@ mod tests { // 6. Wait (deterministically) for the background worker to insert the event, // then fetch THAT row (instead of "latest row in the table"). - use tokio::time::{sleep, Duration, Instant}; + use tokio::time::{Duration, Instant, sleep}; let deadline = Instant::now() + Duration::from_secs(2); From 63ca1c175bebfecd6d788323e9b78ca5e283fdf3 Mon Sep 17 00:00:00 2001 From: John Spahn <44337821+jspahn80134@users.noreply.github.com> Date: Wed, 22 Apr 2026 09:27:15 -0600 Subject: [PATCH 10/45] Update rustls-webpki for audit advisory --- server/Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/Cargo.lock b/server/Cargo.lock index f93653c3..dd33246d 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -3368,9 +3368,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.12" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "aws-lc-rs", "ring", From e0f2b380743fc752506674deb58148fde3622356 Mon Sep 17 00:00:00 2001 From: John Spahn <44337821+jspahn80134@users.noreply.github.com> Date: Wed, 22 Apr 2026 11:23:18 -0600 Subject: [PATCH 11/45] Fix VS Code extension lint --- extensions/VSCode/src/extension.ts | 290 +++++++++++++++-------------- 1 file changed, 155 insertions(+), 135 deletions(-) diff --git a/extensions/VSCode/src/extension.ts b/extensions/VSCode/src/extension.ts index 266e02ba..03c053bf 100644 --- a/extensions/VSCode/src/extension.ts +++ b/extensions/VSCode/src/extension.ts @@ -123,7 +123,10 @@ let codeChatEditorServer: CodeChatEditorServer | undefined; // CAPTURE (Dissertation instrumentation) // -------------------------------------- -function isInMarkdownCodeFence(doc: vscode.TextDocument, line: number): boolean { +function isInMarkdownCodeFence( + doc: vscode.TextDocument, + line: number, +): boolean { // Very simple fence tracker: toggles when encountering \`\`\` or ~~~ at // start of line. Good enough for dissertation instrumentation; refine later // if needed. @@ -164,7 +167,10 @@ function isInRstCodeBlock(doc: vscode.TextDocument, line: number): boolean { return /^\s+/.test(cur); } -function classifyAtPosition(doc: vscode.TextDocument, pos: vscode.Position): ActivityKind { +function classifyAtPosition( + doc: vscode.TextDocument, + pos: vscode.Position, +): ActivityKind { if (DOC_LANG_IDS.has(doc.languageId)) { if (doc.languageId === "markdown") { return isInMarkdownCodeFence(doc, pos.line) ? "code" : "doc"; @@ -178,10 +184,10 @@ function classifyAtPosition(doc: vscode.TextDocument, pos: vscode.Position): Act return "code"; } - - // Types for sending capture events to the Rust server. This mirrors // `CaptureEventWire` in webserver.rs. +type CaptureEventData = Record; + interface CaptureEventPayload { user_id: string; assignment_id?: string; @@ -190,7 +196,7 @@ interface CaptureEventPayload { event_type: string; client_timestamp_ms?: number; client_tz_offset_min?: number; - data: any; // sent as JSON + data: CaptureEventData; } // TODO: replace these with something real (e.g., VS Code settings) For now, we @@ -206,11 +212,7 @@ const CAPTURE_USER_ID: string = (() => { } // Fallbacks (should rarely be needed) - return ( - process.env["USERNAME"] || - process.env["USER"] || - "unknown-user" - ); + return process.env["USERNAME"] || process.env["USER"] || "unknown-user"; })(); const CAPTURE_ASSIGNMENT_ID = "demo-assignment"; @@ -238,23 +240,11 @@ const DOC_LANG_IDS = new Set([ let lastActivityKind: ActivityKind = "other"; let docSessionStart: number | null = null; -// Heuristic: classify a document as documentation vs. code vs. other. -function classifyDocument(doc: vscode.TextDocument | undefined): ActivityKind { - if (!doc) { - return "other"; - } - if (DOC_LANG_IDS.has(doc.languageId)) { - return "doc"; - } - // Everything else we treat as code for now. - return "code"; -} - // Helper to send a capture event to the Rust server. async function sendCaptureEvent( eventType: string, filePath?: string, - data: any = {}, + data: CaptureEventData = {}, ): Promise { const payload: CaptureEventPayload = { user_id: CAPTURE_USER_ID, @@ -348,7 +338,11 @@ function noteActivity(kind: ActivityKind, filePath?: string) { // If we switched between doc and code, log a switch\_pane event. const docOrCode = (k: ActivityKind) => k === "doc" || k === "code"; - if (docOrCode(lastActivityKind) && docOrCode(kind) && kind !== lastActivityKind) { + if ( + docOrCode(lastActivityKind) && + docOrCode(kind) && + kind !== lastActivityKind + ) { void sendCaptureEvent("switch_pane", filePath, { from: lastActivityKind, to: kind, @@ -364,7 +358,8 @@ function noteActivity(kind: ActivityKind, filePath?: string) { // This is invoked when the extension is activated. It either creates a new // CodeChat Editor Server instance or reveals the currently running one. export const activate = (context: vscode.ExtensionContext) => { - capture_output_channel = vscode.window.createOutputChannel("CodeChat Capture"); + capture_output_channel = + vscode.window.createOutputChannel("CodeChat Capture"); context.subscriptions.push(capture_output_channel); context.subscriptions.push( @@ -402,9 +397,6 @@ export const activate = (context: vscode.ExtensionContext) => { // CAPTURE: update session/switch state. The server // classifies write_* events after parsing. const doc = event.document; -// ``` -// const kind = classifyDocument(doc); -// ``` const firstChange = event.contentChanges[0]; const pos = firstChange.range.start; const kind = classifyAtPosition(doc, pos); @@ -443,8 +435,9 @@ export const activate = (context: vscode.ExtensionContext) => { // CAPTURE: update activity + possible // switch\_pane/doc\_session. const doc = event.document; - // const kind = classifyDocument(doc); - const pos = event.selection?.active ?? new vscode.Position(0, 0); + const pos = + event.selection?.active ?? + new vscode.Position(0, 0); const kind = classifyAtPosition(doc, pos); const filePath = doc.fileName; @@ -455,27 +448,30 @@ export const activate = (context: vscode.ExtensionContext) => { ); context.subscriptions.push( - vscode.window.onDidChangeTextEditorSelection((event) => { - if (ignore_selection_change) { - ignore_selection_change = false; - return; - } - - console_log( - "CodeChat Editor extension: sending updated cursor/scroll position.", - ); + vscode.window.onDidChangeTextEditorSelection( + (event) => { + if (ignore_selection_change) { + ignore_selection_change = false; + return; + } - // CAPTURE: treat a selection change as "activity" - // in this document. - const doc = event.textEditor.document; - // const kind = classifyDocument(doc); - const pos = event.selections?.[0]?.active ?? event.textEditor.selection.active; - const kind = classifyAtPosition(doc, pos); - const filePath = doc.fileName; - noteActivity(kind, filePath); + console_log( + "CodeChat Editor extension: sending updated cursor/scroll position.", + ); - send_update(false); - }), + // CAPTURE: treat a selection change as "activity" + // in this document. + const doc = event.textEditor.document; + const pos = + event.selections?.[0]?.active ?? + event.textEditor.selection.active; + const kind = classifyAtPosition(doc, pos); + const filePath = doc.fileName; + noteActivity(kind, filePath); + + send_update(false); + }, + ), ); // CAPTURE: end of a debug/run session. @@ -483,14 +479,10 @@ export const activate = (context: vscode.ExtensionContext) => { vscode.debug.onDidTerminateDebugSession((session) => { const active = vscode.window.activeTextEditor; const filePath = active?.document.fileName; - void sendCaptureEvent( - "run_end", - filePath, - { - sessionName: session.name, - sessionType: session.type, - }, - ); + void sendCaptureEvent("run_end", filePath, { + sessionName: session.name, + sessionType: session.type, + }); }), ); @@ -500,30 +492,22 @@ export const activate = (context: vscode.ExtensionContext) => { const active = vscode.window.activeTextEditor; const filePath = active?.document.fileName; const task = e.execution.task; - void sendCaptureEvent( - "compile_end", - filePath, - { - taskName: task.name, - taskSource: task.source, - exitCode: e.exitCode, - }, - ); + void sendCaptureEvent("compile_end", filePath, { + taskName: task.name, + taskSource: task.source, + exitCode: e.exitCode, + }); }), ); // CAPTURE: listen for file saves. context.subscriptions.push( vscode.workspace.onDidSaveTextDocument((doc) => { - void sendCaptureEvent( - "save", - doc.fileName, - { - reason: "manual_save", - languageId: doc.languageId, - lineCount: doc.lineCount, - }, - ); + void sendCaptureEvent("save", doc.fileName, { + reason: "manual_save", + languageId: doc.languageId, + lineCount: doc.lineCount, + }); }), ); @@ -532,14 +516,10 @@ export const activate = (context: vscode.ExtensionContext) => { vscode.debug.onDidStartDebugSession((session) => { const active = vscode.window.activeTextEditor; const filePath = active?.document.fileName; - void sendCaptureEvent( - "run", - filePath, - { - sessionName: session.name, - sessionType: session.type, - }, - ); + void sendCaptureEvent("run", filePath, { + sessionName: session.name, + sessionType: session.type, + }); }), ); @@ -549,16 +529,12 @@ export const activate = (context: vscode.ExtensionContext) => { const active = vscode.window.activeTextEditor; const filePath = active?.document.fileName; const task = e.execution.task; - void sendCaptureEvent( - "compile", - filePath, - { - taskName: task.name, - taskSource: task.source, - definition: task.definition, - processId: e.processId, - }, - ); + void sendCaptureEvent("compile", filePath, { + taskName: task.name, + taskSource: task.source, + definition: task.definition, + processId: e.processId, + }); }), ); } @@ -571,11 +547,13 @@ export const activate = (context: vscode.ExtensionContext) => { assert(typeof codechat_client_location_str === "string"); switch (codechat_client_location_str) { case "html": - codechat_client_location = CodeChatEditorClientLocation.html; + codechat_client_location = + CodeChatEditorClientLocation.html; break; case "browser": - codechat_client_location = CodeChatEditorClientLocation.browser; + codechat_client_location = + CodeChatEditorClientLocation.browser; break; default: @@ -584,7 +562,10 @@ export const activate = (context: vscode.ExtensionContext) => { // Create or reveal the webview panel; if this is an external // browser, we'll open it after the client is created. - if (codechat_client_location === CodeChatEditorClientLocation.html) { + if ( + codechat_client_location === + CodeChatEditorClientLocation.html + ) { if (webview_panel !== undefined) { webview_panel.reveal(undefined, true); } else { @@ -601,7 +582,9 @@ export const activate = (context: vscode.ExtensionContext) => { }, ); webview_panel.onDidDispose(async () => { - console_log("CodeChat Editor extension: shut down webview."); + console_log( + "CodeChat Editor extension: shut down webview.", + ); quiet_next_error = true; webview_panel = undefined; await stop_client(); @@ -612,7 +595,8 @@ export const activate = (context: vscode.ExtensionContext) => { // Provide a simple status display while the server is starting // up. if (webview_panel !== undefined) { - webview_panel.webview.html = "

CodeChat Editor

Loading...

"; + webview_panel.webview.html = + "

CodeChat Editor

Loading...

"; } else { vscode.window.showInformationMessage( "The CodeChat Editor is loading in an external browser...", @@ -627,16 +611,22 @@ export const activate = (context: vscode.ExtensionContext) => { extensionCaptureSessionStarted = false; const hosted_in_ide = - codechat_client_location === CodeChatEditorClientLocation.html; + codechat_client_location === + CodeChatEditorClientLocation.html; console_log( `CodeChat Editor extension: sending message Opened(${hosted_in_ide}).`, ); await codeChatEditorServer.sendMessageOpened(hosted_in_ide); - if (codechat_client_location === CodeChatEditorClientLocation.browser) { + if ( + codechat_client_location === + CodeChatEditorClientLocation.browser + ) { captureTransportReady = true; const active = vscode.window.activeTextEditor; - void startExtensionCaptureSession(active?.document.fileName); + void startExtensionCaptureSession( + active?.document.fileName, + ); send_update(false); } @@ -647,7 +637,9 @@ export const activate = (context: vscode.ExtensionContext) => { break; } - const { id, message } = JSON.parse(message_raw) as EditorMessage; + const { id, message } = JSON.parse( + message_raw, + ) as EditorMessage; console_log( `CodeChat Editor extension: Received data id = ${id}, message = ${format_struct( message, @@ -666,7 +658,8 @@ export const activate = (context: vscode.ExtensionContext) => { switch (key) { case "Update": { - const current_update = value as UpdateMessageContents; + const current_update = + value as UpdateMessageContents; const doc = get_document(current_update.file_path); if (doc === undefined) { await sendResult(id, { @@ -686,7 +679,12 @@ export const activate = (context: vscode.ExtensionContext) => { wse.replace( doc.uri, doc.validateRange( - new vscode.Range(0, 0, doc.lineCount, 0), + new vscode.Range( + 0, + 0, + doc.lineCount, + 0, + ), ), source.Plain.doc, ); @@ -712,10 +710,18 @@ export const activate = (context: vscode.ExtensionContext) => { for (const diff of diffs) { const from = doc.positionAt(diff.from); if (diff.to === undefined) { - wse.insert(doc.uri, from, diff.insert); + wse.insert( + doc.uri, + from, + diff.insert, + ); } else { const to = doc.positionAt(diff.to); - wse.replace(doc.uri, new Range(from, to), diff.insert); + wse.replace( + doc.uri, + new Range(from, to), + diff.insert, + ); } } } @@ -740,7 +746,10 @@ export const activate = (context: vscode.ExtensionContext) => { 0, ); editor.revealRange( - new vscode.Range(scroll_position, scroll_position), + new vscode.Range( + scroll_position, + scroll_position, + ), TextEditorRevealType.AtTop, ); } @@ -748,9 +757,15 @@ export const activate = (context: vscode.ExtensionContext) => { const cursor_line = current_update.cursor_position; if (cursor_line !== undefined && editor) { ignore_selection_change = true; - const cursor_position = new vscode.Position(cursor_line - 1, 0); + const cursor_position = new vscode.Position( + cursor_line - 1, + 0, + ); editor.selections = [ - new vscode.Selection(cursor_position, cursor_position), + new vscode.Selection( + cursor_position, + cursor_position, + ), ]; // I'd prefer to set `ignore_selection_change = // false` here, but even doing so after a @@ -769,18 +784,25 @@ export const activate = (context: vscode.ExtensionContext) => { if (is_text) { let document; try { - document = await vscode.workspace.openTextDocument(current_file); + document = + await vscode.workspace.openTextDocument( + current_file, + ); } catch (e) { await sendResult(id, { - OpenFileFailed: [current_file, (e as Error).toString()], + OpenFileFailed: [ + current_file, + (e as Error).toString(), + ], }); continue; } ignore_active_editor_change = true; - current_editor = await vscode.window.showTextDocument( - document, - current_editor?.viewColumn, - ); + current_editor = + await vscode.window.showTextDocument( + document, + current_editor?.viewColumn, + ); ignore_active_editor_change = false; await sendResult(id); } else { @@ -857,7 +879,10 @@ export const activate = (context: vscode.ExtensionContext) => { console_log( `CodeChat Editor extension: Result(LoadFile(id = ${id}, ${format_struct(load_file_result)}))`, ); - await codeChatEditorServer.sendResultLoadfile(id, load_file_result); + await codeChatEditorServer.sendResultLoadfile( + id, + load_file_result, + ); break; } @@ -868,7 +893,9 @@ export const activate = (context: vscode.ExtensionContext) => { await sendResult(id); captureTransportReady = true; const active = vscode.window.activeTextEditor; - void startExtensionCaptureSession(active?.document.fileName); + void startExtensionCaptureSession( + active?.document.fileName, + ); send_update(false); break; } @@ -971,7 +998,9 @@ const send_update = (this_is_dirty: boolean) => { `CodeChat Editor extension: sending CurrentFile(${current_file}}).`, ); try { - await codeChatEditorServer!.sendMessageCurrentFile(current_file); + await codeChatEditorServer!.sendMessageCurrentFile( + current_file, + ); } catch (e) { show_error(`Error sending CurrentFile message: ${e}.`); } @@ -979,7 +1008,8 @@ const send_update = (this_is_dirty: boolean) => { return; } - const cursor_position = current_editor!.selection.active.line + 1; + const cursor_position = + current_editor!.selection.active.line + 1; const scroll_position = current_editor!.visibleRanges[0].start.line + 1; const file_path = current_editor!.document.fileName; @@ -1032,7 +1062,9 @@ const show_error = (message: string) => { } console.error(`CodeChat Editor extension: ${message}`); if (webview_panel !== undefined) { - if (!webview_panel.webview.html.startsWith("

CodeChat Editor

")) { + if ( + !webview_panel.webview.html.startsWith("

CodeChat Editor

") + ) { webview_panel.webview.html = "

CodeChat Editor

"; } webview_panel.webview.html += `

${escape( @@ -1058,10 +1090,11 @@ const can_render = () => { }; const get_document = (file_path: string) => { - for ( const doc of vscode.workspace.textDocuments) { + for (const doc of vscode.workspace.textDocuments) { if ( (!is_windows && doc.fileName === file_path) || - (is_windows && doc.fileName.toUpperCase() === file_path.toUpperCase()) + (is_windows && + doc.fileName.toUpperCase() === file_path.toUpperCase()) ) { return doc; } @@ -1081,16 +1114,3 @@ const console_log = (...args: any) => { console.log(...args); } }; - -function getCurrentUsername(): string { - try { - // Most reliable on Windows/macOS/Linux - const u = os.userInfo().username; - if (u && u.trim().length > 0) return u.trim(); - } catch (_) {} - - // Fallbacks - const envUser = process.env["USERNAME"] || process.env["USER"]; - return (envUser && envUser.trim().length > 0) ? envUser.trim() : "unknown-user"; -} - From a2c5e88ab0d7faf3133a5cb16332ae9e5ffe0830 Mon Sep 17 00:00:00 2001 From: "JDS-WORKSTATION\\jspah" <44337821+jspahn80134@users.noreply.github.com> Date: Sun, 3 May 2026 09:07:05 -0600 Subject: [PATCH 12/45] Ongoing development --- .gitignore | 8 +- capture_config.example.json | 9 + capture_config.json | 9 - extensions/VSCode/package.json | 109 +++++- extensions/VSCode/src/extension.ts | 354 ++++++++++++++++- extensions/VSCode/src/lib.rs | 6 + server/scripts/export_capture_metrics.py | 469 +++++++++++++++++++++++ server/src/capture.rs | 280 +++++++++++++- server/src/ide.rs | 6 + server/src/main.rs | 11 +- server/src/translation.rs | 31 ++ server/src/webserver.rs | 166 ++++++-- 12 files changed, 1378 insertions(+), 80 deletions(-) create mode 100644 capture_config.example.json delete mode 100644 capture_config.json create mode 100644 server/scripts/export_capture_metrics.py diff --git a/.gitignore b/.gitignore index 2b4f8c30..867dfce6 100644 --- a/.gitignore +++ b/.gitignore @@ -21,8 +21,14 @@ # # dist build output target/ +/server/bindings/ -# The runtime capture config is resolved from the repository root. +# Runtime capture configuration and local fallback capture logs. +/capture_config.json +/capture-events-fallback.jsonl +/capture-metrics-*.csv +/server/scripts/output +/server/scripts/capture-metrics-*.csv server/capture_config.json # CodeChat Editor lexer: python. See TODO. diff --git a/capture_config.example.json b/capture_config.example.json new file mode 100644 index 00000000..22980ee2 --- /dev/null +++ b/capture_config.example.json @@ -0,0 +1,9 @@ +{ + "host": "your-aws-rds-endpoint.amazonaws.com", + "port": 5432, + "user": "your-db-user", + "password": "your-db-password", + "dbname": "your-db-name", + "app_id": "dissertation", + "fallback_path": "capture-events-fallback.jsonl" +} diff --git a/capture_config.json b/capture_config.json deleted file mode 100644 index 574f1477..00000000 --- a/capture_config.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "host": "3.146.138.182", - "port": 5432, - "dbname": "CodeChatCaptureDB", - "user": "CodeChatCaptureUser", - "password": "OB3yc8Hk9SuVjzXMdUDr0C7w4PqLQisn", - "max_connections": 5, - "timeout_seconds": 30 -} diff --git a/extensions/VSCode/package.json b/extensions/VSCode/package.json index 4d1805d9..9ceabfd4 100644 --- a/extensions/VSCode/package.json +++ b/extensions/VSCode/package.json @@ -44,7 +44,15 @@ "version": "0.1.54", "activationEvents": [ "onCommand:extension.codeChatEditorActivate", - "onCommand:extension.codeChatEditorDeactivate" + "onCommand:extension.codeChatEditorDeactivate", + "onCommand:extension.codeChatCaptureStatus", + "onCommand:extension.codeChatInsertReflectionPrompt", + "onCommand:extension.codeChatCaptureTaskStart", + "onCommand:extension.codeChatCaptureTaskSubmit", + "onCommand:extension.codeChatCaptureDebugTaskStart", + "onCommand:extension.codeChatCaptureDebugTaskSubmit", + "onCommand:extension.codeChatCaptureHandoffStart", + "onCommand:extension.codeChatCaptureHandoffEnd" ], "contributes": { "configuration": { @@ -62,6 +70,73 @@ "In the default external web browser" ], "markdownDescription": "Select the location of the CodeChat Editor Client. After changing this value, you **must** close then restart the CodeChat Editor extension." + }, + "CodeChatEditor.Capture.Enabled": { + "type": "boolean", + "default": false, + "markdownDescription": "Enable dissertation instrumentation capture." + }, + "CodeChatEditor.Capture.ConsentEnabled": { + "type": "boolean", + "default": false, + "markdownDescription": "Allow capture after participant consent is recorded for the current study session." + }, + "CodeChatEditor.Capture.ParticipantId": { + "type": "string", + "default": "", + "markdownDescription": "Pseudonymous participant identifier used as the capture user_id." + }, + "CodeChatEditor.Capture.AssignmentId": { + "type": "string", + "default": "", + "markdownDescription": "Assignment, lab, or task identifier attached to capture events." + }, + "CodeChatEditor.Capture.GroupId": { + "type": "string", + "default": "", + "markdownDescription": "Study group, section, or team identifier attached to capture events." + }, + "CodeChatEditor.Capture.CourseId": { + "type": "string", + "default": "", + "markdownDescription": "Course or deployment identifier attached to capture events." + }, + "CodeChatEditor.Capture.TaskId": { + "type": "string", + "default": "", + "markdownDescription": "Current study task identifier attached to capture events." + }, + "CodeChatEditor.Capture.Mode": { + "type": "string", + "default": "treatment", + "enum": [ + "treatment", + "comparison", + "capture-only" + ], + "enumDescriptions": [ + "Capture events and enable reflective prompt commands.", + "Capture events without reflective prompt scaffolding.", + "Capture events only for baseline or pilot instrumentation." + ], + "markdownDescription": "Study condition/mode attached to capture events." + }, + "CodeChatEditor.Capture.HashFilePaths": { + "type": "boolean", + "default": true, + "markdownDescription": "Hash local file paths before they are sent to capture storage." + }, + "CodeChatEditor.Capture.PromptTemplates": { + "type": "array", + "default": [ + "What changed in your understanding of this code?", + "What assumption are you making, and how could you test it?", + "What would another developer need to know before maintaining this?" + ], + "items": { + "type": "string" + }, + "markdownDescription": "Reflective writing prompts available in treatment mode." } } }, @@ -73,6 +148,38 @@ { "command": "extension.codeChatEditorDeactivate", "title": "Disable the CodeChat Editor" + }, + { + "command": "extension.codeChatCaptureStatus", + "title": "Show CodeChat Capture Status" + }, + { + "command": "extension.codeChatInsertReflectionPrompt", + "title": "CodeChat: Insert Reflection Prompt" + }, + { + "command": "extension.codeChatCaptureTaskStart", + "title": "CodeChat Capture: Task Start" + }, + { + "command": "extension.codeChatCaptureTaskSubmit", + "title": "CodeChat Capture: Task Submit" + }, + { + "command": "extension.codeChatCaptureDebugTaskStart", + "title": "CodeChat Capture: Debug Task Start" + }, + { + "command": "extension.codeChatCaptureDebugTaskSubmit", + "title": "CodeChat Capture: Debug Task Submit" + }, + { + "command": "extension.codeChatCaptureHandoffStart", + "title": "CodeChat Capture: Handoff Start" + }, + { + "command": "extension.codeChatCaptureHandoffEnd", + "title": "CodeChat Capture: Handoff End" } ] }, diff --git a/extensions/VSCode/src/extension.ts b/extensions/VSCode/src/extension.ts index 03c053bf..64268504 100644 --- a/extensions/VSCode/src/extension.ts +++ b/extensions/VSCode/src/extension.ts @@ -53,7 +53,6 @@ import { MAX_MESSAGE_LENGTH, } from "../../../client/src/debug_enabled.mjs"; import { ResultErrTypes } from "../../../client/src/rust-types/ResultErrTypes.js"; -import * as os from "os"; import * as crypto from "crypto"; @@ -189,9 +188,18 @@ function classifyAtPosition( type CaptureEventData = Record; interface CaptureEventPayload { + event_id?: string; + sequence_number?: number; + schema_version?: number; user_id: string; assignment_id?: string; group_id?: string; + condition?: string; + course_id?: string; + task_id?: string; + event_source?: string; + language_id?: string; + file_hash?: string; file_path?: string; event_type: string; client_timestamp_ms?: number; @@ -199,29 +207,47 @@ interface CaptureEventPayload { data: CaptureEventData; } -// TODO: replace these with something real (e.g., VS Code settings) For now, we -// hard-code to prove that the pipeline works end-to-end. -const CAPTURE_USER_ID: string = (() => { - try { - const u = os.userInfo().username; - if (u && u.trim().length > 0) { - return u.trim(); - } - } catch (_) { - // fall through - } +type CaptureMode = "treatment" | "comparison" | "capture-only"; + +interface StudySettings { + enabled: boolean; + consentEnabled: boolean; + participantId: string; + assignmentId?: string; + groupId?: string; + condition: CaptureMode; + courseId?: string; + taskId?: string; + hashFilePaths: boolean; + promptTemplates: string[]; +} - // Fallbacks (should rarely be needed) - return process.env["USERNAME"] || process.env["USER"] || "unknown-user"; -})(); +interface CaptureStatus { + enabled: boolean; + state: string; + queued_events: number; + persisted_events: number; + fallback_events: number; + failed_events: number; + last_error?: string | null; + fallback_path?: string | null; +} -const CAPTURE_ASSIGNMENT_ID = "demo-assignment"; -const CAPTURE_GROUP_ID = "demo-group"; +const CAPTURE_SCHEMA_VERSION = 2; +const CAPTURE_EVENT_SOURCE = "vscode_extension"; +const DEFAULT_REFLECTION_PROMPTS = [ + "What changed in your understanding of this code?", + "What assumption are you making, and how could you test it?", + "What would another developer need to know before maintaining this?", +]; let capture_output_channel: vscode.OutputChannel | undefined; let captureFailureLogged = false; let captureTransportReady = false; let extensionCaptureSessionStarted = false; +let captureSequenceNumber = 0; +let capture_status_bar_item: vscode.StatusBarItem | undefined; +let capture_status_timer: NodeJS.Timeout | undefined; // Simple classification of what the user is currently doing. type ActivityKind = "doc" | "code" | "other"; @@ -240,23 +266,114 @@ const DOC_LANG_IDS = new Set([ let lastActivityKind: ActivityKind = "other"; let docSessionStart: number | null = null; +function optionalString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : undefined; +} + +function loadStudySettings(): StudySettings { + const config = vscode.workspace.getConfiguration("CodeChatEditor.Capture"); + const modeValue = optionalString(config.get("Mode")); + const condition: CaptureMode = + modeValue === "comparison" || modeValue === "capture-only" + ? modeValue + : "treatment"; + const promptTemplates = config.get("PromptTemplates"); + + return { + enabled: config.get("Enabled", false), + consentEnabled: config.get("ConsentEnabled", false), + participantId: optionalString(config.get("ParticipantId")) ?? "", + assignmentId: optionalString(config.get("AssignmentId")), + groupId: optionalString(config.get("GroupId")), + condition, + courseId: optionalString(config.get("CourseId")), + taskId: optionalString(config.get("TaskId")), + hashFilePaths: config.get("HashFilePaths", true), + promptTemplates: + Array.isArray(promptTemplates) && promptTemplates.length > 0 + ? promptTemplates + .filter( + (prompt): prompt is string => + typeof prompt === "string", + ) + .map((prompt) => prompt.trim()) + .filter((prompt) => prompt.length > 0) + : DEFAULT_REFLECTION_PROMPTS, + }; +} + +function captureDisabledReason(settings: StudySettings): string | undefined { + if (!settings.enabled) { + return "disabled in settings"; + } + if (!settings.consentEnabled) { + return "waiting for consent"; + } + if (settings.participantId.length === 0) { + return "participant id is not configured"; + } + return undefined; +} + +function hashText(value: string): string { + return crypto.createHash("sha256").update(value).digest("hex"); +} + +function buildFileFields( + filePath: string | undefined, + settings: StudySettings, +): Pick { + const document = + filePath === undefined + ? vscode.window.activeTextEditor?.document + : get_document(filePath); + if (filePath === undefined) { + return { + language_id: document?.languageId, + }; + } + return { + file_path: settings.hashFilePaths ? undefined : filePath, + file_hash: settings.hashFilePaths ? hashText(filePath) : undefined, + language_id: document?.languageId, + }; +} + // Helper to send a capture event to the Rust server. async function sendCaptureEvent( eventType: string, filePath?: string, data: CaptureEventData = {}, ): Promise { + const settings = loadStudySettings(); + const disabledReason = captureDisabledReason(settings); + if (disabledReason !== undefined) { + updateCaptureStatusBar(`Capture: ${disabledReason}`, disabledReason); + return; + } + const fileFields = buildFileFields(filePath, settings); const payload: CaptureEventPayload = { - user_id: CAPTURE_USER_ID, - assignment_id: CAPTURE_ASSIGNMENT_ID, - group_id: CAPTURE_GROUP_ID, - file_path: filePath, + event_id: crypto.randomUUID(), + sequence_number: ++captureSequenceNumber, + schema_version: CAPTURE_SCHEMA_VERSION, + user_id: settings.participantId, + assignment_id: settings.assignmentId, + group_id: settings.groupId, + condition: settings.condition, + course_id: settings.courseId, + task_id: settings.taskId, + event_source: CAPTURE_EVENT_SOURCE, + ...fileFields, event_type: eventType, client_timestamp_ms: Date.now(), client_tz_offset_min: new Date().getTimezoneOffset(), data: { ...data, session_id: CAPTURE_SESSION_ID, + capture_mode: settings.condition, + path_privacy: settings.hashFilePaths ? "sha256" : "plain", }, }; @@ -276,6 +393,7 @@ async function sendCaptureEvent( try { await codeChatEditorServer.sendCaptureEvent(JSON.stringify(payload)); captureFailureLogged = false; + void refreshCaptureStatus(); } catch (err) { reportCaptureFailure(err instanceof Error ? err.message : String(err)); } @@ -291,6 +409,7 @@ function reportCaptureFailure(message: string) { capture_output_channel?.appendLine( `${new Date().toISOString()} capture send failed: ${message}`, ); + updateCaptureStatusBar("Capture: Error", message); if (captureFailureLogged) { return; } @@ -298,6 +417,140 @@ function reportCaptureFailure(message: string) { console.warn(`CodeChat capture event was not queued: ${message}`); } +function updateCaptureStatusBar(text: string, tooltip?: string) { + if (capture_status_bar_item === undefined) { + return; + } + capture_status_bar_item.text = text; + capture_status_bar_item.tooltip = tooltip; + capture_status_bar_item.show(); +} + +async function refreshCaptureStatus(): Promise { + const settings = loadStudySettings(); + const disabledReason = captureDisabledReason(settings); + if (disabledReason !== undefined) { + updateCaptureStatusBar(`Capture: ${disabledReason}`, disabledReason); + return; + } + if (codeChatEditorServer === undefined) { + updateCaptureStatusBar( + "Capture: Waiting", + "CodeChat server is not running", + ); + return; + } + + try { + const status = JSON.parse( + codeChatEditorServer.getCaptureStatus(), + ) as CaptureStatus; + const label = + status.state === "database" + ? "Capture: DB" + : status.state === "fallback" + ? "Capture: Fallback" + : status.state === "starting" + ? "Capture: Starting" + : "Capture: Off"; + updateCaptureStatusBar( + label, + [ + `state=${status.state}`, + `queued=${status.queued_events}`, + `db=${status.persisted_events}`, + `fallback=${status.fallback_events}`, + `failed=${status.failed_events}`, + status.last_error ? `last_error=${status.last_error}` : "", + status.fallback_path + ? `fallback_path=${status.fallback_path}` + : "", + ] + .filter((line) => line.length > 0) + .join("\n"), + ); + } catch (err) { + updateCaptureStatusBar( + "Capture: Error", + err instanceof Error ? err.message : String(err), + ); + } +} + +async function showCaptureStatus(): Promise { + await refreshCaptureStatus(); + const tooltip = capture_status_bar_item?.tooltip; + vscode.window.showInformationMessage( + typeof tooltip === "string" + ? tooltip + : (tooltip?.value ?? "Capture status unavailable"), + ); +} + +async function recordStudyLifecycleEvent(eventType: string): Promise { + const active = vscode.window.activeTextEditor; + await sendCaptureEvent(eventType, active?.document.fileName, { + command: eventType, + languageId: active?.document.languageId, + }); +} + +function reflectionPromptText(languageId: string, prompt: string): string { + if (languageId === "markdown") { + return `\n\n### Reflection\n\n${prompt}\n\n`; + } + if (languageId === "restructuredtext") { + return `\n.. ${prompt}\n`; + } + if (languageId === "plaintext" || languageId === "latex") { + return `\n${prompt}\n`; + } + const commentPrefix = + languageId === "python" || + languageId === "shellscript" || + languageId === "powershell" || + languageId === "ruby" + ? "#" + : "//"; + return `\n${commentPrefix} Reflection: ${prompt}\n`; +} + +async function insertReflectionPrompt(): Promise { + const settings = loadStudySettings(); + if (settings.condition !== "treatment") { + vscode.window.showInformationMessage( + "Reflection prompts are disabled for this capture mode.", + ); + return; + } + const editor = vscode.window.activeTextEditor; + if (editor === undefined) { + vscode.window.showInformationMessage("Open a text editor first."); + return; + } + const prompt = await vscode.window.showQuickPick(settings.promptTemplates, { + placeHolder: "Select a reflection prompt", + }); + if (prompt === undefined) { + return; + } + + await editor.insertSnippet( + new vscode.SnippetString( + reflectionPromptText(editor.document.languageId, prompt), + ), + ); + await sendCaptureEvent( + "reflection_prompt_inserted", + editor.document.fileName, + { + prompt_hash: hashText(prompt), + prompt_length: prompt.length, + languageId: editor.document.languageId, + }, + ); +} + async function startExtensionCaptureSession(filePath?: string) { if (extensionCaptureSessionStarted) { return; @@ -361,8 +614,65 @@ export const activate = (context: vscode.ExtensionContext) => { capture_output_channel = vscode.window.createOutputChannel("CodeChat Capture"); context.subscriptions.push(capture_output_channel); + capture_status_bar_item = vscode.window.createStatusBarItem( + vscode.StatusBarAlignment.Left, + 100, + ); + capture_status_bar_item.command = "extension.codeChatCaptureStatus"; + context.subscriptions.push(capture_status_bar_item); + capture_status_timer = setInterval(() => { + void refreshCaptureStatus(); + }, 5000); + context.subscriptions.push({ + dispose: () => { + if (capture_status_timer !== undefined) { + clearInterval(capture_status_timer); + capture_status_timer = undefined; + } + }, + }); + context.subscriptions.push( + vscode.workspace.onDidChangeConfiguration((event) => { + if (event.affectsConfiguration("CodeChatEditor.Capture")) { + void refreshCaptureStatus(); + } + }), + ); + void refreshCaptureStatus(); context.subscriptions.push( + vscode.commands.registerCommand( + "extension.codeChatCaptureStatus", + showCaptureStatus, + ), + vscode.commands.registerCommand( + "extension.codeChatInsertReflectionPrompt", + insertReflectionPrompt, + ), + vscode.commands.registerCommand( + "extension.codeChatCaptureTaskStart", + () => recordStudyLifecycleEvent("task_start"), + ), + vscode.commands.registerCommand( + "extension.codeChatCaptureTaskSubmit", + () => recordStudyLifecycleEvent("task_submit"), + ), + vscode.commands.registerCommand( + "extension.codeChatCaptureDebugTaskStart", + () => recordStudyLifecycleEvent("debug_task_start"), + ), + vscode.commands.registerCommand( + "extension.codeChatCaptureDebugTaskSubmit", + () => recordStudyLifecycleEvent("debug_task_submit"), + ), + vscode.commands.registerCommand( + "extension.codeChatCaptureHandoffStart", + () => recordStudyLifecycleEvent("handoff_start"), + ), + vscode.commands.registerCommand( + "extension.codeChatCaptureHandoffEnd", + () => recordStudyLifecycleEvent("handoff_end"), + ), vscode.commands.registerCommand( "extension.codeChatEditorDeactivate", deactivate, @@ -609,6 +919,7 @@ export const activate = (context: vscode.ExtensionContext) => { captureFailureLogged = false; captureTransportReady = false; extensionCaptureSessionStarted = false; + void refreshCaptureStatus(); const hosted_in_ide = codechat_client_location === @@ -1045,6 +1356,7 @@ const stop_client = async () => { codeChatEditorServer = undefined; } captureTransportReady = false; + void refreshCaptureStatus(); if (idle_timer !== undefined) { clearTimeout(idle_timer); diff --git a/extensions/VSCode/src/lib.rs b/extensions/VSCode/src/lib.rs index e94472c3..aa8169fa 100644 --- a/extensions/VSCode/src/lib.rs +++ b/extensions/VSCode/src/lib.rs @@ -87,6 +87,12 @@ impl CodeChatEditorServer { self.0.send_capture_event(capture_event).await } + #[napi] + pub fn get_capture_status(&self) -> Result { + serde_json::to_string(&self.0.capture_status()) + .map_err(|err| Error::new(Status::GenericFailure, err.to_string())) + } + #[napi] pub async fn send_message_current_file(&self, url: String) -> std::io::Result { self.0.send_message_current_file(url).await diff --git a/server/scripts/export_capture_metrics.py b/server/scripts/export_capture_metrics.py new file mode 100644 index 00000000..d95a7c66 --- /dev/null +++ b/server/scripts/export_capture_metrics.py @@ -0,0 +1,469 @@ +#!/usr/bin/env python3 +"""Export dissertation-oriented metrics from CodeChat capture events. + +Default use pulls events directly from PostgreSQL using `capture_config.json` +or the `CODECHAT_CAPTURE_*` environment variables: + + python server/scripts/export_capture_metrics.py --out capture-metrics.csv + +The optional positional `input` is only for fallback JSONL logs: + + python server/scripts/export_capture_metrics.py capture-events-fallback.jsonl --out capture-metrics.csv +""" + +from __future__ import annotations + +import argparse +import csv +import json +import os +import re +import shutil +import subprocess +from collections import defaultdict +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Iterable, Iterator + + +EVENT_FIELDS = [ + "write_doc", + "write_code", + "doc_session", + "switch_pane", + "save", + "compile", + "compile_end", + "run", + "run_end", + "task_start", + "task_submit", + "debug_task_start", + "debug_task_submit", + "handoff_start", + "handoff_end", + "reflection_prompt_inserted", +] + + +@dataclass(frozen=True) +class DbConfig: + host: str + user: str + password: str + dbname: str + port: int | None = None + + +@dataclass +class MetricRow: + user_id: str + assignment_id: str + group_id: str + session_id: str + condition: str + course_id: str + task_id: str + event_count: int = 0 + first_event_at: str = "" + last_event_at: str = "" + doc_session_seconds: float = 0.0 + counts: dict[str, int] = field(default_factory=lambda: defaultdict(int)) + + +def parse_timestamp(value: Any) -> datetime | None: + if isinstance(value, datetime): + return value + if not isinstance(value, str) or not value: + return None + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + + +def as_data(value: Any) -> dict[str, Any]: + if isinstance(value, dict): + return value + if isinstance(value, str): + try: + parsed = json.loads(value) + except json.JSONDecodeError: + return {} + return parsed if isinstance(parsed, dict) else {} + return {} + + +def iter_jsonl_events(path: Path) -> Iterator[dict[str, Any]]: + with path.open("r", encoding="utf-8") as input_file: + for line_number, line in enumerate(input_file, start=1): + line = line.strip() + if not line: + continue + try: + record = json.loads(line) + except json.JSONDecodeError as err: + raise SystemExit(f"{path}:{line_number}: invalid JSON: {err}") from err + event = record.get("event", record) + if not isinstance(event, dict): + continue + event["data"] = as_data(event.get("data")) + yield event + + +def load_db_config(config_path: Path) -> DbConfig: + env_config = db_config_from_env() + if env_config is not None: + return env_config + + config_path = resolve_config_path(config_path) + try: + config = json.loads(config_path.read_text(encoding="utf-8")) + except FileNotFoundError as err: + searched = "\n ".join(str(path) for path in config_search_paths(config_path)) + raise SystemExit( + "No DB config found. Create a local capture_config.json, set " + "CODECHAT_CAPTURE_* env vars, or pass a fallback JSONL input file.\n" + f"Searched:\n {searched}" + ) from err + except json.JSONDecodeError as err: + raise SystemExit(f"{config_path}: invalid JSON: {err}") from err + + missing = [name for name in ["host", "user", "password", "dbname"] if not config.get(name)] + if missing: + raise SystemExit(f"{config_path}: missing required DB field(s): {', '.join(missing)}") + + return DbConfig( + host=str(config["host"]), + user=str(config["user"]), + password=str(config["password"]), + dbname=str(config["dbname"]), + port=int(config["port"]) if config.get("port") is not None else None, + ) + + +def resolve_config_path(config_path: Path) -> Path: + for candidate in config_search_paths(config_path): + if candidate.exists(): + return candidate + return config_path + + +def config_search_paths(config_path: Path) -> list[Path]: + if config_path.is_absolute(): + return [config_path] + + script_repo_root = Path(__file__).resolve().parents[2] + paths = [Path.cwd() / config_path, script_repo_root / config_path] + + unique_paths: list[Path] = [] + for path in paths: + if path not in unique_paths: + unique_paths.append(path) + return unique_paths + + +def db_config_from_env() -> DbConfig | None: + host = env_value("CODECHAT_CAPTURE_HOST") + if host is None: + return None + missing = [ + name + for name in [ + "CODECHAT_CAPTURE_USER", + "CODECHAT_CAPTURE_PASSWORD", + "CODECHAT_CAPTURE_DBNAME", + ] + if env_value(name) is None + ] + if missing: + raise SystemExit( + "Missing required capture DB environment variable(s): " + ", ".join(missing) + ) + + port_text = env_value("CODECHAT_CAPTURE_PORT") + return DbConfig( + host=host, + user=env_value("CODECHAT_CAPTURE_USER") or "", + password=env_value("CODECHAT_CAPTURE_PASSWORD") or "", + dbname=env_value("CODECHAT_CAPTURE_DBNAME") or "", + port=int(port_text) if port_text is not None else None, + ) + + +def env_value(name: str) -> str | None: + value = os.environ.get(name) + if value is None: + return None + value = value.strip() + return value or None + + +def sql_identifier(identifier: str) -> str: + parts = identifier.split(".") + for part in parts: + if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", part): + raise SystemExit(f"Invalid SQL identifier: {identifier!r}") + return ".".join(f'"{part}"' for part in parts) + + +def iter_db_events(config: DbConfig, table: str) -> Iterator[dict[str, Any]]: + try: + import psycopg + except ImportError: + yield from iter_db_events_with_psql(config, table) + return + + connect_kwargs = { + "host": config.host, + "user": config.user, + "password": config.password, + "dbname": config.dbname, + } + if config.port is not None: + connect_kwargs["port"] = config.port + + query = ( + "SELECT user_id, assignment_id, group_id, file_path, event_type, timestamp, data " + f"FROM {sql_identifier(table)} " + "ORDER BY timestamp" + ) + with psycopg.connect(**connect_kwargs) as conn: + with conn.cursor() as cursor: + cursor.execute(query) + for ( + user_id, + assignment_id, + group_id, + file_path, + event_type, + timestamp, + data, + ) in cursor: + yield { + "user_id": user_id, + "assignment_id": assignment_id, + "group_id": group_id, + "file_path": file_path, + "event_type": event_type, + "timestamp": timestamp, + "data": as_data(data), + } + + +def iter_db_events_with_psql(config: DbConfig, table: str) -> Iterator[dict[str, Any]]: + psql_path = find_psql() + if psql_path is None: + raise SystemExit( + "PostgreSQL export needs a local PostgreSQL client to connect to the AWS DB.\n" + "The AWS PostgreSQL server is remote; it cannot provide Python's local DB driver.\n" + "Install one of these on this Windows machine:\n" + " python -m pip install \"psycopg[binary]\"\n" + "or install PostgreSQL command-line tools so psql.exe is available on PATH." + ) + + env = os.environ.copy() + env["PGPASSWORD"] = config.password + command = [ + psql_path, + "--no-password", + "--no-align", + "--tuples-only", + "--quiet", + "--set", + "ON_ERROR_STOP=1", + "--host", + config.host, + "--username", + config.user, + "--dbname", + config.dbname, + "--command", + psql_json_query(table), + ] + if config.port is not None: + command.extend(["--port", str(config.port)]) + + result = subprocess.run( + command, + env=env, + check=False, + capture_output=True, + text=True, + ) + if result.returncode != 0: + raise SystemExit( + "psql failed while querying the AWS PostgreSQL DB:\n" + f"{result.stderr.strip() or result.stdout.strip()}" + ) + + for line_number, line in enumerate(result.stdout.splitlines(), start=1): + line = line.strip() + if not line: + continue + try: + event = json.loads(line) + except json.JSONDecodeError as err: + raise SystemExit(f"psql output line {line_number}: invalid JSON: {err}") from err + event["data"] = as_data(event.get("data")) + yield event + + +def find_psql() -> str | None: + psql_path = shutil.which("psql") + if psql_path is not None: + return psql_path + + program_files = Path(os.environ.get("ProgramFiles", r"C:\Program Files")) + candidates = sorted(program_files.glob(r"PostgreSQL/*/bin/psql.exe"), reverse=True) + return str(candidates[0]) if candidates else None + + +def psql_json_query(table: str) -> str: + return ( + "SELECT json_build_object(" + "'user_id', user_id, " + "'assignment_id', assignment_id, " + "'group_id', group_id, " + "'file_path', file_path, " + "'event_type', event_type, " + "'timestamp', timestamp, " + "'data', data" + ")::text " + f"FROM {sql_identifier(table)} " + "ORDER BY timestamp" + ) + + +def key_for_event(event: dict[str, Any]) -> tuple[str, str, str, str, str, str, str]: + data = event["data"] + return ( + str(event.get("user_id") or ""), + str(event.get("assignment_id") or ""), + str(event.get("group_id") or ""), + str(data.get("session_id") or ""), + str(data.get("condition") or ""), + str(data.get("course_id") or ""), + str(data.get("task_id") or ""), + ) + + +def update_row(row: MetricRow, event: dict[str, Any]) -> None: + event_type = str(event.get("event_type") or "") + data = event["data"] + row.event_count += 1 + if event_type in EVENT_FIELDS: + row.counts[event_type] += 1 + if event_type == "doc_session": + duration = data.get("duration_seconds") + if isinstance(duration, (int, float)): + row.doc_session_seconds += float(duration) + + parsed_timestamp = parse_timestamp(event.get("timestamp")) + if parsed_timestamp is not None: + timestamp_text = parsed_timestamp.isoformat() + if not row.first_event_at or timestamp_text < row.first_event_at: + row.first_event_at = timestamp_text + if not row.last_event_at or timestamp_text > row.last_event_at: + row.last_event_at = timestamp_text + + +def export_metrics(events: Iterable[dict[str, Any]], output_path: Path) -> None: + rows: dict[tuple[str, str, str, str, str, str, str], MetricRow] = {} + for event in events: + key = key_for_event(event) + row = rows.setdefault(key, MetricRow(*key)) + update_row(row, event) + + fieldnames = [ + "user_id", + "assignment_id", + "group_id", + "session_id", + "condition", + "course_id", + "task_id", + "event_count", + "first_event_at", + "last_event_at", + "doc_session_seconds", + *[f"{event_type}_events" for event_type in EVENT_FIELDS], + ] + with output_path.open("w", encoding="utf-8", newline="") as output_file: + writer = csv.DictWriter(output_file, fieldnames=fieldnames) + writer.writeheader() + for row in sorted(rows.values(), key=lambda r: (r.user_id, r.session_id)): + writer.writerow( + { + "user_id": row.user_id, + "assignment_id": row.assignment_id, + "group_id": row.group_id, + "session_id": row.session_id, + "condition": row.condition, + "course_id": row.course_id, + "task_id": row.task_id, + "event_count": row.event_count, + "first_event_at": row.first_event_at, + "last_event_at": row.last_event_at, + "doc_session_seconds": f"{row.doc_session_seconds:.3f}", + **{ + f"{event_type}_events": row.counts[event_type] + for event_type in EVENT_FIELDS + }, + } + ) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument( + "input", + nargs="?", + type=Path, + help="Optional capture JSONL fallback file. Omit to read PostgreSQL.", + ) + parser.add_argument( + "--out", + type=Path, + default=None, + help="Output CSV file. Defaults to a timestamped capture-metrics-YYYYMMDD-HHMMSS.csv file.", + ) + parser.add_argument( + "--db", + action="store_true", + help="Read PostgreSQL. This is the default when no JSONL input is supplied.", + ) + parser.add_argument( + "--config", + type=Path, + default=Path("capture_config.json"), + help="Capture DB config JSON path. Ignored when CODECHAT_CAPTURE_* env vars are set.", + ) + parser.add_argument( + "--table", + default="events", + help='Capture events table name. Defaults to "events".', + ) + args = parser.parse_args() + + if args.db and args.input is not None: + parser.error("do not pass a JSONL input path with --db") + + events = ( + iter_jsonl_events(args.input) + if args.input is not None + else iter_db_events(load_db_config(args.config), args.table) + ) + output_path = args.out or default_output_path() + export_metrics(events, output_path) + print(f"Wrote {output_path}") + + +def default_output_path() -> Path: + timestamp = datetime.now(timezone.utc).astimezone().strftime("%Y%m%d-%H%M%S") + return Path(f"capture-metrics-{timestamp}.csv") + + +if __name__ == "__main__": + main() diff --git a/server/src/capture.rs b/server/src/capture.rs index c8904a75..0b317038 100644 --- a/server/src/capture.rs +++ b/server/src/capture.rs @@ -57,7 +57,14 @@ // * `timestamp` – RFC3339 timestamp (in UTC). // * `data` – JSON payload with event-specific details. -use std::{io, thread}; +use std::{ + env, + fs::{self, OpenOptions}, + io::{self, Write}, + path::{Path, PathBuf}, + sync::{Arc, Mutex}, + thread, +}; use chrono::{DateTime, Utc}; use log::{debug, error, info, warn}; @@ -79,6 +86,13 @@ pub mod event_types { pub const SESSION_END: &str = "session_end"; pub const COMPILE_END: &str = "compile_end"; pub const RUN_END: &str = "run_end"; + pub const TASK_START: &str = "task_start"; + pub const TASK_SUBMIT: &str = "task_submit"; + pub const DEBUG_TASK_START: &str = "debug_task_start"; + pub const DEBUG_TASK_SUBMIT: &str = "debug_task_submit"; + pub const HANDOFF_START: &str = "handoff_start"; + pub const HANDOFF_END: &str = "handoff_end"; + pub const REFLECTION_PROMPT_INSERTED: &str = "reflection_prompt_inserted"; } /// Configuration used to construct the PostgreSQL connection string. @@ -88,6 +102,8 @@ pub mod event_types { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CaptureConfig { pub host: String, + #[serde(default)] + pub port: Option, pub user: String, pub password: String, pub dbname: String, @@ -96,16 +112,111 @@ pub struct CaptureConfig { /// in `data` if desired. #[serde(default)] pub app_id: Option, + /// Local JSONL file used when PostgreSQL is unavailable. + #[serde(default)] + pub fallback_path: Option, } impl CaptureConfig { /// Build a libpq-style connection string. pub fn to_conn_str(&self) -> String { + let mut parts = vec![ + format!("host={}", self.host), + format!("user={}", self.user), + format!("password={}", self.password), + format!("dbname={}", self.dbname), + ]; + if let Some(port) = self.port { + parts.push(format!("port={port}")); + } + parts.join(" ") + } + + /// Return a human-readable summary that never includes the password. + pub fn redacted_summary(&self) -> String { format!( - "host={} user={} password={} dbname={}", - self.host, self.user, self.password, self.dbname + "host={}, port={:?}, user={}, dbname={}, app_id={:?}, fallback_path={:?}", + self.host, self.port, self.user, self.dbname, self.app_id, self.fallback_path ) } + + /// Build capture configuration from environment variables. If no capture + /// host is configured, return `Ok(None)` so callers can fall back to a file. + pub fn from_env() -> Result, String> { + let Some(host) = env_var_trimmed("CODECHAT_CAPTURE_HOST") else { + return Ok(None); + }; + + let port = match env_var_trimmed("CODECHAT_CAPTURE_PORT") { + Some(port) => Some(port.parse::().map_err(|err| { + format!("CODECHAT_CAPTURE_PORT must be a valid port number: {err}") + })?), + None => None, + }; + + Ok(Some(Self { + host, + port, + user: required_env_var("CODECHAT_CAPTURE_USER")?, + password: required_env_var("CODECHAT_CAPTURE_PASSWORD")?, + dbname: required_env_var("CODECHAT_CAPTURE_DBNAME")?, + app_id: env_var_trimmed("CODECHAT_CAPTURE_APP_ID"), + fallback_path: env_var_trimmed("CODECHAT_CAPTURE_FALLBACK_PATH").map(PathBuf::from), + })) + } +} + +fn env_var_trimmed(name: &str) -> Option { + env::var(name) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn required_env_var(name: &str) -> Result { + env_var_trimmed(name).ok_or_else(|| format!("{name} is required when capture env is used")) +} + +/// Capture worker health, exposed through `/capture/status` and the VS Code +/// status item. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct CaptureStatus { + pub enabled: bool, + pub state: String, + pub queued_events: u64, + pub persisted_events: u64, + pub fallback_events: u64, + pub failed_events: u64, + pub last_error: Option, + pub fallback_path: Option, +} + +impl CaptureStatus { + pub fn disabled() -> Self { + Self { + enabled: false, + state: "disabled".to_string(), + queued_events: 0, + persisted_events: 0, + fallback_events: 0, + failed_events: 0, + last_error: None, + fallback_path: None, + } + } + + fn starting(fallback_path: Option) -> Self { + Self { + enabled: true, + state: "starting".to_string(), + queued_events: 0, + persisted_events: 0, + fallback_events: 0, + failed_events: 0, + last_error: None, + fallback_path, + } + } } /// The in-memory representation of a single capture event. @@ -175,6 +286,7 @@ type WorkerMsg = CaptureEvent; #[derive(Clone)] pub struct EventCapture { tx: mpsc::UnboundedSender, + status: Arc>, } impl EventCapture { @@ -184,17 +296,24 @@ impl EventCapture { /// This function is synchronous so it can be called from non-async server /// setup code. It spawns an async task internally which performs the /// database connection and event processing. - pub fn new(config: CaptureConfig) -> Result { + pub fn new(mut config: CaptureConfig) -> Result { + let fallback_path = config + .fallback_path + .get_or_insert_with(|| PathBuf::from("capture-events-fallback.jsonl")) + .clone(); let conn_str = config.to_conn_str(); + let status = Arc::new(Mutex::new(CaptureStatus::starting(Some( + fallback_path.clone(), + )))); // High-level DB connection details (no password). info!( - "Capture: preparing PostgreSQL connection (host={}, dbname={}, user={}, app_id={:?})", - config.host, config.dbname, config.user, config.app_id + "Capture: preparing PostgreSQL connection ({})", + config.redacted_summary() ); - debug!("Capture: raw PostgreSQL connection string: {}", conn_str); let (tx, mut rx) = mpsc::unbounded_channel::(); + let status_worker = status.clone(); // Create a dedicated runtime so capture can be started from sync code // before the Actix/Tokio server runtime exists. @@ -208,16 +327,27 @@ impl EventCapture { .expect("Capture: failed to build Tokio runtime"); runtime.block_on(async move { - info!("Capture: attempting to connect to PostgreSQL…"); + info!("Capture: attempting to connect to PostgreSQL."); match tokio_postgres::connect(&conn_str, NoTls).await { Ok((client, connection)) => { info!("Capture: successfully connected to PostgreSQL."); + update_status(&status_worker, |status| { + status.state = "database".to_string(); + status.last_error = None; + }); // Drive the connection in its own task. + let status_connection = status_worker.clone(); tokio::spawn(async move { if let Err(err) = connection.await { error!("Capture PostgreSQL connection error: {err}"); + update_status(&status_connection, |status| { + status.state = "fallback".to_string(); + status.last_error = Some(format!( + "PostgreSQL connection error: {err}" + )); + }); } }); @@ -238,7 +368,25 @@ impl EventCapture { "Capture: FAILED to insert event (type={}, user_id={}): {err}", event.event_type, event.user_id ); + update_status(&status_worker, |status| { + status.state = "fallback".to_string(); + status.last_error = Some(format!( + "PostgreSQL insert failed: {err}" + )); + }); + write_event_to_fallback( + &fallback_path, + &event, + &status_worker, + Some(format!("PostgreSQL insert failed: {err}")), + ); } else { + update_status(&status_worker, |status| { + status.persisted_events += 1; + if status.state != "database" { + status.state = "database".to_string(); + } + }); debug!("Capture: event insert successful."); } } @@ -254,10 +402,26 @@ impl EventCapture { log_pg_connect_error(&ctx, &err); - // Drain and drop any events so we don't hold the sender. - warn!("Capture: draining pending events after failed DB connection."); - while rx.recv().await.is_some() {} - warn!("Capture: all pending events dropped due to connection failure."); + update_status(&status_worker, |status| { + status.state = "fallback".to_string(); + status.last_error = Some(format!( + "PostgreSQL connection failed: {err}" + )); + }); + + warn!( + "Capture: writing pending events to fallback JSONL at {:?}.", + fallback_path + ); + while let Some(event) = rx.recv().await { + write_event_to_fallback( + &fallback_path, + &event, + &status_worker, + Some("PostgreSQL connection unavailable".to_string()), + ); + } + warn!("Capture: event channel closed; fallback worker exiting."); } } }); @@ -266,7 +430,7 @@ impl EventCapture { io::Error::other(format!("Capture: failed to start worker thread: {err}")) })?; - Ok(Self { tx }) + Ok(Self { tx, status }) } /// Enqueue an event for insertion. This is non-blocking. @@ -278,10 +442,87 @@ impl EventCapture { if let Err(err) = self.tx.send(event) { error!("Capture: FAILED to enqueue capture event: {err}"); + update_status(&self.status, |status| { + status.failed_events += 1; + status.last_error = Some(format!("Failed to enqueue capture event: {err}")); + }); + } else { + update_status(&self.status, |status| { + status.queued_events += 1; + }); + } + } + + pub fn status(&self) -> CaptureStatus { + self.status + .lock() + .map(|status| status.clone()) + .unwrap_or_else(|_| { + let mut status = CaptureStatus::disabled(); + status.last_error = Some("Capture status lock is poisoned".to_string()); + status + }) + } +} + +fn update_status(status: &Arc>, f: impl FnOnce(&mut CaptureStatus)) { + match status.lock() { + Ok(mut guard) => f(&mut guard), + Err(err) => error!("Capture: unable to update status: {err}"), + } +} + +fn write_event_to_fallback( + fallback_path: &Path, + event: &CaptureEvent, + status: &Arc>, + last_error: Option, +) { + match append_fallback_event(fallback_path, event) { + Ok(()) => update_status(status, |status| { + status.fallback_events += 1; + status.last_error = last_error; + }), + Err(err) => { + error!( + "Capture: FAILED to write fallback event to {:?}: {err}", + fallback_path + ); + update_status(status, |status| { + status.failed_events += 1; + status.last_error = Some(format!("Fallback write failed: {err}")); + }); } } } +fn append_fallback_event(fallback_path: &Path, event: &CaptureEvent) -> io::Result<()> { + if let Some(parent) = fallback_path.parent() + && !parent.as_os_str().is_empty() + { + fs::create_dir_all(parent)?; + } + + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(fallback_path)?; + let record = serde_json::json!({ + "fallback_timestamp": Utc::now().to_rfc3339(), + "event": { + "user_id": event.user_id, + "assignment_id": event.assignment_id, + "group_id": event.group_id, + "file_path": event.file_path, + "event_type": event.event_type, + "timestamp": event.timestamp.to_rfc3339(), + "data": event.data, + } + }); + writeln!(file, "{record}")?; + Ok(()) +} + fn log_pg_connect_error(context: &str, err: &tokio_postgres::Error) { // If Postgres returned a structured DbError, log it ONCE and bail. if let Some(db) = err.as_db_error() { @@ -358,10 +599,12 @@ mod tests { fn capture_config_to_conn_str_is_well_formed() { let cfg = CaptureConfig { host: "localhost".to_string(), + port: Some(5432), user: "alice".to_string(), password: "secret".to_string(), dbname: "codechat_capture".to_string(), app_id: Some("spring25-study".to_string()), + fallback_path: Some(PathBuf::from("capture-events-fallback.jsonl")), }; let conn = cfg.to_conn_str(); @@ -371,6 +614,8 @@ mod tests { assert!(conn.contains("user=alice")); assert!(conn.contains("password=secret")); assert!(conn.contains("dbname=codechat_capture")); + assert!(conn.contains("port=5432")); + assert!(!cfg.redacted_summary().contains("secret")); } #[test] @@ -427,18 +672,25 @@ mod tests { { "host": "db.example.com", "user": "bob", + "port": 5433, "password": "hunter2", "dbname": "cc_events", - "app_id": "fall25" + "app_id": "fall25", + "fallback_path": "capture-events-fallback.jsonl" } "#; let cfg: CaptureConfig = serde_json::from_str(json_text).expect("JSON should parse"); assert_eq!(cfg.host, "db.example.com"); + assert_eq!(cfg.port, Some(5433)); assert_eq!(cfg.user, "bob"); assert_eq!(cfg.password, "hunter2"); assert_eq!(cfg.dbname, "cc_events"); assert_eq!(cfg.app_id.as_deref(), Some("fall25")); + assert_eq!( + cfg.fallback_path.as_deref(), + Some(std::path::Path::new("capture-events-fallback.jsonl")) + ); // And it should serialize back to JSON without error let _back = serde_json::to_string(&cfg).expect("Should serialize"); diff --git a/server/src/ide.rs b/server/src/ide.rs index 181bde33..bdd5147a 100644 --- a/server/src/ide.rs +++ b/server/src/ide.rs @@ -93,6 +93,7 @@ async fn start_server( // Provide a class to start and stop the server. All its fields are opaque, // since only Rust should use them. pub struct CodeChatEditorServer { + app_state: WebAppState, server_handle: ServerHandle, from_ide_tx: Sender, to_ide_rx: Arc>>, @@ -141,6 +142,7 @@ impl CodeChatEditorServer { let (expired_messages_tx, expired_messages_rx) = mpsc::channel(100); Ok(CodeChatEditorServer { + app_state, server_handle, from_ide_tx: websocket_queues.from_websocket_tx, to_ide_rx: Arc::new(Mutex::new(websocket_queues.to_websocket_rx)), @@ -259,6 +261,10 @@ impl CodeChatEditorServer { .await } + pub fn capture_status(&self) -> crate::capture::CaptureStatus { + webserver::capture_status(&self.app_state) + } + // Send a `CurrentFile` message. The other parameter (true if text/false if // binary/None if ignored) is ignored by the server, so it's always sent as // `None`. diff --git a/server/src/main.rs b/server/src/main.rs index bc443035..f98d4c96 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -332,10 +332,15 @@ fn port_in_range(s: &str) -> Result { fn parse_credentials(s: &str) -> Result { // For simplicity, require a username to have no colons. - let split: Vec<_> = s.splitn(2, ":").collect(); + let Some((username, password)) = s.split_once(':') else { + return Err("auth must use the form username:password".to_string()); + }; + if username.is_empty() { + return Err("auth username may not be empty".to_string()); + } Ok(Credentials { - username: split[0].to_string(), - password: split[1].to_string(), + username: username.to_string(), + password: password.to_string(), }) } diff --git a/server/src/translation.rs b/server/src/translation.rs index f1116ccf..46ca7b9d 100644 --- a/server/src/translation.rs +++ b/server/src/translation.rs @@ -443,8 +443,13 @@ struct CaptureContext { user_id: Option, assignment_id: Option, group_id: Option, + condition: Option, + course_id: Option, + task_id: Option, + event_source: Option, session_id: Option, client_tz_offset_min: Option, + schema_version: Option, } impl CaptureContext { @@ -458,6 +463,21 @@ impl CaptureContext { if let Some(group_id) = &wire.group_id { self.group_id = Some(group_id.clone()); } + if let Some(condition) = &wire.condition { + self.condition = Some(condition.clone()); + } + if let Some(course_id) = &wire.course_id { + self.course_id = Some(course_id.clone()); + } + if let Some(task_id) = &wire.task_id { + self.task_id = Some(task_id.clone()); + } + if let Some(event_source) = &wire.event_source { + self.event_source = Some(event_source.clone()); + } + if let Some(schema_version) = wire.schema_version { + self.schema_version = Some(schema_version); + } if let Some(client_tz_offset_min) = wire.client_tz_offset_min { self.client_tz_offset_min = Some(client_tz_offset_min); } @@ -486,11 +506,22 @@ impl CaptureContext { data.entry("session_id".to_string()) .or_insert_with(|| serde_json::json!(session_id)); } + data.entry("source".to_string()) + .or_insert_with(|| serde_json::json!("server_translation")); Some(CaptureEventWire { + event_id: None, + sequence_number: None, + schema_version: self.schema_version, user_id: self.user_id.clone()?, assignment_id: self.assignment_id.clone(), group_id: self.group_id.clone(), + condition: self.condition.clone(), + course_id: self.course_id.clone(), + task_id: self.task_id.clone(), + event_source: self.event_source.clone(), + language_id: None, + file_hash: None, file_path, event_type: event_type.to_string(), client_timestamp_ms: None, diff --git a/server/src/webserver.rs b/server/src/webserver.rs index fbeb86e6..e9d4e214 100644 --- a/server/src/webserver.rs +++ b/server/src/webserver.rs @@ -97,7 +97,7 @@ use crate::{ }, }; -use crate::capture::{CaptureConfig, CaptureEvent, EventCapture}; +use crate::capture::{CaptureConfig, CaptureEvent, CaptureStatus, EventCapture}; use chrono::Utc; @@ -407,12 +407,30 @@ pub struct Credentials { #[derive(Debug, Serialize, Deserialize, PartialEq, TS)] #[ts(export, optional_fields)] pub struct CaptureEventWire { + #[serde(skip_serializing_if = "Option::is_none")] + pub event_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sequence_number: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub schema_version: Option, pub user_id: String, #[serde(skip_serializing_if = "Option::is_none")] pub assignment_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub group_id: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub condition: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub course_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub task_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub event_source: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub language_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub file_hash: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub file_path: Option, pub event_type: String, @@ -623,13 +641,23 @@ async fn capture_endpoint( app_state: WebAppState, payload: web::Json, ) -> HttpResponse { - log_capture_event(&app_state, payload.into_inner()); - HttpResponse::Ok().finish() + let status = log_capture_event(&app_state, payload.into_inner()); + if status.enabled { + HttpResponse::Accepted().json(status) + } else { + HttpResponse::ServiceUnavailable().json(status) + } +} + +#[get("/capture/status")] +async fn capture_status_endpoint(app_state: WebAppState) -> HttpResponse { + HttpResponse::Ok().json(capture_status(&app_state)) } /// Log a capture event if capture is enabled. -pub fn log_capture_event(app_state: &WebAppState, wire: CaptureEventWire) { +pub fn log_capture_event(app_state: &WebAppState, wire: CaptureEventWire) -> CaptureStatus { if let Some(capture) = &app_state.capture { + let server_timestamp = Utc::now(); // Default missing data to empty object let mut data = wire.data.unwrap_or_else(|| serde_json::json!({})); @@ -641,12 +669,49 @@ pub fn log_capture_event(app_state: &WebAppState, wire: CaptureEventWire) { // Add client timestamp fields if present (even if extension also sends them; // overwriting is fine and consistent). if let serde_json::Value::Object(map) = &mut data { + if let Some(event_id) = &wire.event_id { + map.insert("event_id".to_string(), serde_json::json!(event_id)); + } + if let Some(sequence_number) = wire.sequence_number { + map.insert( + "sequence_number".to_string(), + serde_json::json!(sequence_number), + ); + } + if let Some(schema_version) = wire.schema_version { + map.insert( + "schema_version".to_string(), + serde_json::json!(schema_version), + ); + } + if let Some(condition) = &wire.condition { + map.insert("condition".to_string(), serde_json::json!(condition)); + } + if let Some(course_id) = &wire.course_id { + map.insert("course_id".to_string(), serde_json::json!(course_id)); + } + if let Some(task_id) = &wire.task_id { + map.insert("task_id".to_string(), serde_json::json!(task_id)); + } + if let Some(event_source) = &wire.event_source { + map.insert("event_source".to_string(), serde_json::json!(event_source)); + } + if let Some(language_id) = &wire.language_id { + map.insert("language_id".to_string(), serde_json::json!(language_id)); + } + if let Some(file_hash) = &wire.file_hash { + map.insert("file_hash".to_string(), serde_json::json!(file_hash)); + } if let Some(ms) = wire.client_timestamp_ms { map.insert("client_timestamp_ms".to_string(), serde_json::json!(ms)); } if let Some(tz) = wire.client_tz_offset_min { map.insert("client_tz_offset_min".to_string(), serde_json::json!(tz)); } + map.insert( + "server_timestamp_ms".to_string(), + serde_json::json!(server_timestamp.timestamp_millis()), + ); } let event = CaptureEvent { @@ -656,14 +721,25 @@ pub fn log_capture_event(app_state: &WebAppState, wire: CaptureEventWire) { file_path: wire.file_path, event_type: wire.event_type, // Server decides when the event is recorded. - timestamp: Utc::now(), + timestamp: server_timestamp, data, }; capture.log(event); + capture.status() + } else { + CaptureStatus::disabled() } } +pub fn capture_status(app_state: &WebAppState) -> CaptureStatus { + app_state + .capture + .as_ref() + .map(EventCapture::status) + .unwrap_or_else(CaptureStatus::disabled) +} + // Get the `mode` query parameter to determine `is_test_mode`; default to // `false`. pub fn get_test_mode(req: &HttpRequest) -> bool { @@ -1588,36 +1664,19 @@ pub fn configure_logger(level: LevelFilter) -> Result<(), Box) -> WebAppState { // Initialize event capture from a config file (optional). - let capture: Option = { - // Build path: /capture_config.json - let mut config_path = ROOT_PATH.lock().unwrap().clone(); - config_path.push("capture_config.json"); - - match fs::read_to_string(&config_path) { - Ok(json) => match serde_json::from_str::(&json) { - Ok(cfg) => match EventCapture::new(cfg) { - Ok(ec) => { - eprintln!("Capture: enabled (config file: {config_path:?})"); - Some(ec) - } - Err(err) => { - eprintln!("Capture: failed to initialize from {config_path:?}: {err}"); - None - } - }, - Err(err) => { - eprintln!("Capture: invalid JSON in {config_path:?}: {err}"); - None - } - }, + let capture: Option = load_capture_config().and_then(|cfg| { + let summary = cfg.redacted_summary(); + match EventCapture::new(cfg) { + Ok(ec) => { + eprintln!("Capture: enabled ({summary})"); + Some(ec) + } Err(err) => { - eprintln!( - "Capture: disabled (config file not found or unreadable: {config_path:?}: {err})" - ); + eprintln!("Capture: failed to initialize ({summary}): {err}"); None } } - }; + }); web::Data::new(AppState { server_handle: Mutex::new(None), @@ -1633,6 +1692,50 @@ pub fn make_app_data(credentials: Option) -> WebAppState { }) } +fn load_capture_config() -> Option { + match CaptureConfig::from_env() { + Ok(Some(cfg)) => return Some(with_default_capture_fallback_path(cfg)), + Ok(None) => {} + Err(err) => { + eprintln!("Capture: invalid environment configuration: {err}"); + return None; + } + } + + let mut config_path = ROOT_PATH.lock().unwrap().clone(); + config_path.push("capture_config.json"); + + match fs::read_to_string(&config_path) { + Ok(json) => match serde_json::from_str::(&json) { + Ok(cfg) => Some(with_default_capture_fallback_path(cfg)), + Err(err) => { + eprintln!("Capture: invalid JSON in {config_path:?}: {err}"); + None + } + }, + Err(err) => { + eprintln!( + "Capture: disabled (no CODECHAT_CAPTURE_* env and no readable config at {config_path:?}: {err})" + ); + None + } + } +} + +fn with_default_capture_fallback_path(mut cfg: CaptureConfig) -> CaptureConfig { + let root_path = ROOT_PATH.lock().unwrap().clone(); + match &cfg.fallback_path { + Some(path) if path.is_relative() => { + cfg.fallback_path = Some(root_path.join(path)); + } + Some(_) => {} + None => { + cfg.fallback_path = Some(root_path.join("capture-events-fallback.jsonl")); + } + } + cfg +} + // Configure the web application. I'd like to make this return an // `App`, but `AppEntry` is a private module. pub fn configure_app(app: App, app_data: &WebAppState) -> App @@ -1660,6 +1763,7 @@ where .service(ping) .service(stop) .service(capture_endpoint) + .service(capture_status_endpoint) // Reroute to the filewatcher filesystem for typical user-requested // URLs. .route("/", web::get().to(filewatcher_root_fs_redirect)) From 3db0ef35a083ba036b6ef47eea2214f14c8c5c86 Mon Sep 17 00:00:00 2001 From: "JDS-WORKSTATION\\jspah" <44337821+jspahn80134@users.noreply.github.com> Date: Sun, 3 May 2026 09:34:07 -0600 Subject: [PATCH 13/45] Fix capture enum clippy warning --- server/src/ide.rs | 2 +- server/src/translation.rs | 4 ++-- server/src/webserver.rs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/server/src/ide.rs b/server/src/ide.rs index bdd5147a..f7b4e8ee 100644 --- a/server/src/ide.rs +++ b/server/src/ide.rs @@ -257,7 +257,7 @@ impl CodeChatEditorServer { &self, capture_event: webserver::CaptureEventWire, ) -> std::io::Result { - self.send_message_timeout(EditorMessageContents::Capture(capture_event)) + self.send_message_timeout(EditorMessageContents::Capture(Box::new(capture_event))) .await } diff --git a/server/src/translation.rs b/server/src/translation.rs index 46ca7b9d..97ab61b4 100644 --- a/server/src/translation.rs +++ b/server/src/translation.rs @@ -613,7 +613,7 @@ pub async fn translation_task( EditorMessageContents::Update(_) => continue_loop = tt.ide_update(ide_message).await, EditorMessageContents::Capture(capture_event) => { tt.capture_context.update_from_wire(&capture_event); - log_capture_event(&app_state, capture_event); + log_capture_event(&app_state, *capture_event); send_response(&tt.to_ide_tx, ide_message.id, Ok(ResultOkTypes::Void)).await; }, @@ -713,7 +713,7 @@ pub async fn translation_task( EditorMessageContents::Update(_) => continue_loop = tt.client_update(client_message).await, EditorMessageContents::Capture(capture_event) => { tt.capture_context.update_from_wire(&capture_event); - log_capture_event(&app_state, capture_event); + log_capture_event(&app_state, *capture_event); send_response(&tt.to_client_tx, client_message.id, Ok(ResultOkTypes::Void)).await; }, diff --git a/server/src/webserver.rs b/server/src/webserver.rs index e9d4e214..43a93031 100644 --- a/server/src/webserver.rs +++ b/server/src/webserver.rs @@ -208,7 +208,7 @@ pub enum EditorMessageContents { Option, ), /// Record an instrumentation event. Valid destinations: Server. - Capture(CaptureEventWire), + Capture(Box), // #### These messages may only be sent by the IDE. /// This is the first message sent when the IDE starts up. It may only be From bf6e60d5df96f73f241a19e58397d42fc70bff7a Mon Sep 17 00:00:00 2001 From: "JDS-WORKSTATION\\jspah" <44337821+jspahn80134@users.noreply.github.com> Date: Fri, 8 May 2026 11:19:46 -0600 Subject: [PATCH 14/45] Add rich capture analysis dataset and schema --- .gitignore | 2 + server/scripts/capture_events_schema.sql | 184 ++++ server/scripts/export_capture_metrics.py | 1261 +++++++++++++++++++--- server/src/capture.rs | 176 ++- 4 files changed, 1474 insertions(+), 149 deletions(-) create mode 100644 server/scripts/capture_events_schema.sql diff --git a/.gitignore b/.gitignore index 867dfce6..d5a73a07 100644 --- a/.gitignore +++ b/.gitignore @@ -27,8 +27,10 @@ target/ /capture_config.json /capture-events-fallback.jsonl /capture-metrics-*.csv +/capture-analysis-*/ /server/scripts/output /server/scripts/capture-metrics-*.csv +/server/scripts/capture-analysis-*/ server/capture_config.json # CodeChat Editor lexer: python. See TODO. diff --git a/server/scripts/capture_events_schema.sql b/server/scripts/capture_events_schema.sql new file mode 100644 index 00000000..8e211e9c --- /dev/null +++ b/server/scripts/capture_events_schema.sql @@ -0,0 +1,184 @@ +-- CodeChat capture event schema for dissertation analysis. +-- +-- This script is safe to run on an existing legacy `events` table. It adds the +-- richer typed metadata columns, converts `timestamp` and `data` to analysis- +-- friendly PostgreSQL types, and backfills typed metadata from existing JSON +-- payloads where possible. + +BEGIN; + +CREATE TABLE IF NOT EXISTS public.events ( + id BIGSERIAL PRIMARY KEY, + event_id TEXT, + sequence_number BIGINT, + schema_version INTEGER, + user_id TEXT NOT NULL, + assignment_id TEXT, + group_id TEXT, + condition TEXT, + course_id TEXT, + task_id TEXT, + session_id TEXT, + event_source TEXT, + language_id TEXT, + file_hash TEXT, + file_path TEXT, + path_privacy TEXT, + capture_mode TEXT, + event_type TEXT NOT NULL, + "timestamp" TIMESTAMPTZ NOT NULL DEFAULT now(), + client_timestamp_ms BIGINT, + client_tz_offset_min INTEGER, + server_timestamp_ms BIGINT, + data JSONB NOT NULL DEFAULT '{}'::jsonb, + inserted_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +ALTER TABLE public.events ADD COLUMN IF NOT EXISTS event_id TEXT; +ALTER TABLE public.events ADD COLUMN IF NOT EXISTS sequence_number BIGINT; +ALTER TABLE public.events ADD COLUMN IF NOT EXISTS schema_version INTEGER; +ALTER TABLE public.events ADD COLUMN IF NOT EXISTS condition TEXT; +ALTER TABLE public.events ADD COLUMN IF NOT EXISTS course_id TEXT; +ALTER TABLE public.events ADD COLUMN IF NOT EXISTS task_id TEXT; +ALTER TABLE public.events ADD COLUMN IF NOT EXISTS session_id TEXT; +ALTER TABLE public.events ADD COLUMN IF NOT EXISTS event_source TEXT; +ALTER TABLE public.events ADD COLUMN IF NOT EXISTS language_id TEXT; +ALTER TABLE public.events ADD COLUMN IF NOT EXISTS file_hash TEXT; +ALTER TABLE public.events ADD COLUMN IF NOT EXISTS path_privacy TEXT; +ALTER TABLE public.events ADD COLUMN IF NOT EXISTS capture_mode TEXT; +ALTER TABLE public.events ADD COLUMN IF NOT EXISTS client_timestamp_ms BIGINT; +ALTER TABLE public.events ADD COLUMN IF NOT EXISTS client_tz_offset_min INTEGER; +ALTER TABLE public.events ADD COLUMN IF NOT EXISTS server_timestamp_ms BIGINT; +ALTER TABLE public.events ADD COLUMN IF NOT EXISTS inserted_at TIMESTAMPTZ NOT NULL DEFAULT now(); + +DO $$ +DECLARE + current_type TEXT; +BEGIN + SELECT data_type INTO current_type + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'events' + AND column_name = 'timestamp'; + + IF current_type IS DISTINCT FROM 'timestamp with time zone' THEN + ALTER TABLE public.events + ALTER COLUMN "timestamp" TYPE TIMESTAMPTZ + USING COALESCE(NULLIF("timestamp"::text, '')::timestamptz, now()); + END IF; +END $$; + +DO $$ +DECLARE + current_type TEXT; +BEGIN + SELECT data_type INTO current_type + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'events' + AND column_name = 'data'; + + IF current_type IS DISTINCT FROM 'jsonb' THEN + ALTER TABLE public.events + ALTER COLUMN data TYPE JSONB + USING CASE + WHEN data IS NULL OR btrim(data::text) = '' THEN '{}'::jsonb + ELSE data::jsonb + END; + END IF; +END $$; + +UPDATE public.events +SET data = '{}'::jsonb +WHERE data IS NULL; + +ALTER TABLE public.events ALTER COLUMN data SET DEFAULT '{}'::jsonb; +ALTER TABLE public.events ALTER COLUMN data SET NOT NULL; +ALTER TABLE public.events ALTER COLUMN "timestamp" SET DEFAULT now(); +ALTER TABLE public.events ALTER COLUMN "timestamp" SET NOT NULL; + +UPDATE public.events +SET + event_id = COALESCE(event_id, NULLIF(data->>'event_id', '')), + sequence_number = COALESCE( + sequence_number, + CASE + WHEN data->>'sequence_number' ~ '^-?[0-9]+$' + THEN (data->>'sequence_number')::bigint + END + ), + schema_version = COALESCE( + schema_version, + CASE + WHEN data->>'schema_version' ~ '^-?[0-9]+$' + THEN (data->>'schema_version')::integer + END + ), + condition = COALESCE(condition, NULLIF(data->>'condition', '')), + course_id = COALESCE(course_id, NULLIF(data->>'course_id', '')), + task_id = COALESCE(task_id, NULLIF(data->>'task_id', '')), + session_id = COALESCE(session_id, NULLIF(data->>'session_id', '')), + event_source = COALESCE(event_source, NULLIF(data->>'event_source', '')), + language_id = COALESCE( + language_id, + NULLIF(data->>'language_id', ''), + NULLIF(data->>'languageId', '') + ), + file_hash = COALESCE(file_hash, NULLIF(data->>'file_hash', '')), + path_privacy = COALESCE(path_privacy, NULLIF(data->>'path_privacy', '')), + capture_mode = COALESCE(capture_mode, NULLIF(data->>'capture_mode', '')), + client_timestamp_ms = COALESCE( + client_timestamp_ms, + CASE + WHEN data->>'client_timestamp_ms' ~ '^-?[0-9]+$' + THEN (data->>'client_timestamp_ms')::bigint + END + ), + client_tz_offset_min = COALESCE( + client_tz_offset_min, + CASE + WHEN data->>'client_tz_offset_min' ~ '^-?[0-9]+$' + THEN (data->>'client_tz_offset_min')::integer + END + ), + server_timestamp_ms = COALESCE( + server_timestamp_ms, + CASE + WHEN data->>'server_timestamp_ms' ~ '^-?[0-9]+$' + THEN (data->>'server_timestamp_ms')::bigint + ELSE floor(extract(epoch from "timestamp") * 1000)::bigint + END + ); + +CREATE INDEX IF NOT EXISTS events_timestamp_idx + ON public.events ("timestamp"); + +CREATE INDEX IF NOT EXISTS events_type_timestamp_idx + ON public.events (event_type, "timestamp"); + +CREATE INDEX IF NOT EXISTS events_participant_session_idx + ON public.events (user_id, assignment_id, session_id, task_id); + +CREATE INDEX IF NOT EXISTS events_file_hash_idx + ON public.events (file_hash) + WHERE file_hash IS NOT NULL; + +CREATE INDEX IF NOT EXISTS events_event_id_idx + ON public.events (event_id) + WHERE event_id IS NOT NULL; + +CREATE INDEX IF NOT EXISTS events_data_gin_idx + ON public.events USING GIN (data); + +COMMENT ON TABLE public.events IS + 'CodeChat dissertation capture events with typed analysis metadata and event-specific JSONB payloads.'; +COMMENT ON COLUMN public.events.user_id IS 'Participant identifier supplied by capture settings.'; +COMMENT ON COLUMN public.events.assignment_id IS 'Assignment or lab identifier supplied by capture settings.'; +COMMENT ON COLUMN public.events.group_id IS 'Study group, section, or cohort identifier.'; +COMMENT ON COLUMN public.events.condition IS 'Study condition, such as treatment, comparison, or capture-only.'; +COMMENT ON COLUMN public.events.session_id IS 'Capture session UUID emitted by the VS Code extension.'; +COMMENT ON COLUMN public.events.file_hash IS 'SHA-256 hash of the file path when path hashing is enabled.'; +COMMENT ON COLUMN public.events.file_path IS 'Raw captured file path; NULL when path hashing is enabled.'; +COMMENT ON COLUMN public.events.data IS 'Event-specific JSON payload. Duplicates typed metadata for portable fallback exports.'; + +COMMIT; diff --git a/server/scripts/export_capture_metrics.py b/server/scripts/export_capture_metrics.py index d95a7c66..e2ceea22 100644 --- a/server/scripts/export_capture_metrics.py +++ b/server/scripts/export_capture_metrics.py @@ -6,6 +6,10 @@ python server/scripts/export_capture_metrics.py --out capture-metrics.csv +To produce the richer analysis dataset: + + python server/scripts/export_capture_metrics.py --dataset-dir capture-analysis + The optional positional `input` is only for fallback JSONL logs: python server/scripts/export_capture_metrics.py capture-events-fallback.jsonl --out capture-metrics.csv @@ -15,19 +19,22 @@ import argparse import csv +import hashlib import json import os import re import shutil import subprocess -from collections import defaultdict -from dataclasses import dataclass, field +from collections import Counter, defaultdict, deque +from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path from typing import Any, Iterable, Iterator EVENT_FIELDS = [ + "session_start", + "session_end", "write_doc", "write_code", "doc_session", @@ -46,6 +53,292 @@ "reflection_prompt_inserted", ] +IDENTITY_FIELDS = [ + "user_id", + "assignment_id", + "group_id", + "session_id", + "condition", + "course_id", + "task_id", +] + +EVENT_ROW_FIELDS = [ + "event_index", + *IDENTITY_FIELDS, + "event_id", + "sequence_number", + "schema_version", + "event_source", + "event_type", + "timestamp", + "client_timestamp_ms", + "server_timestamp_ms", + "client_tz_offset_min", + "client_server_latency_ms", + "elapsed_session_seconds", + "gap_seconds", + "file_id", + "file_hash", + "path_privacy", + "language_id", + "capture_mode", + "classification_basis", + "write_source", + "mode", + "activity_from", + "activity_to", + "duration_seconds", + "duration_ms", + "line_count", + "prompt_hash", + "prompt_length", + "command", + "task_name", + "task_source", + "exit_code", + "run_session_name", + "run_session_type", + "save_reason", + "doc_block_count_before", + "doc_block_count_after", + "diff_hunks", + "diff_inserted_chars", + "diff_deleted_units", + "diff_replacement_hunks", + "doc_block_transactions", + "doc_block_diff_hunks", + "doc_block_inserted_chars", + "doc_block_deleted_units", +] + +SESSION_SUMMARY_FIELDS = [ + *IDENTITY_FIELDS, + "event_count", + "first_event_at", + "last_event_at", + "active_span_seconds", + "events_per_minute", + "mean_gap_seconds", + "max_gap_seconds", + "doc_session_seconds", + "doc_session_share_of_span", + "write_events", + "doc_write_share", + *[f"{event_type}_events" for event_type in EVENT_FIELDS], + "doc_to_code_switches", + "code_to_doc_switches", + "compile_success_events", + "compile_failure_events", + "total_prompt_chars", + "unique_file_count", + "unique_language_count", + "file_ids", + "language_ids", + "event_sources", + "diff_hunks", + "diff_inserted_chars", + "diff_deleted_units", + "doc_block_transactions", + "doc_block_diff_hunks", + "doc_block_inserted_chars", + "doc_block_deleted_units", + "client_server_latency_ms_mean", + "client_server_latency_ms_max", + "first_sequence_number", + "last_sequence_number", + "missing_sequence_gaps", + "duplicate_event_ids", + "data_quality_notes", +] + +FILE_SUMMARY_FIELDS = [ + *IDENTITY_FIELDS, + "file_id", + "file_hash", + "language_id", + "path_privacy", + "event_count", + "first_event_at", + "last_event_at", + "active_span_seconds", + "doc_session_seconds", + "write_doc_events", + "write_code_events", + "save_events", + "compile_events", + "compile_end_events", + "run_events", + "run_end_events", + "switch_pane_events", + "line_count_first", + "line_count_last", + "line_count_max", + "doc_block_count_before_min", + "doc_block_count_after_last", + "classification_bases", + "write_sources", + "diff_hunks", + "diff_inserted_chars", + "diff_deleted_units", + "doc_block_transactions", + "doc_block_diff_hunks", + "doc_block_inserted_chars", + "doc_block_deleted_units", +] + +TASK_LIFECYCLE_FIELDS = [ + *IDENTITY_FIELDS, + "lifecycle_kind", + "lifecycle_index", + "completed", + "start_event_type", + "end_event_type", + "start_at", + "end_at", + "duration_seconds", + "start_event_id", + "end_event_id", + "start_file_id", + "end_file_id", + "language_id", + "command", + "data_quality_notes", +] + +LIFECYCLE_PAIRS = { + "task_start": ("task", "task_submit"), + "debug_task_start": ("debug_task", "debug_task_submit"), + "handoff_start": ("handoff", "handoff_end"), +} + +LIFECYCLE_END_TYPES = { + end_type: (kind, start_type) + for start_type, (kind, end_type) in LIFECYCLE_PAIRS.items() +} + +RAW_FILE_PATH_FIELD = "file_path" + +DB_METADATA_FIELDS = [ + "event_id", + "sequence_number", + "schema_version", + "condition", + "course_id", + "task_id", + "session_id", + "event_source", + "language_id", + "file_hash", + "path_privacy", + "capture_mode", + "client_timestamp_ms", + "client_tz_offset_min", + "server_timestamp_ms", +] + +FIELD_DESCRIPTIONS = { + "event_index": "One-based event order after sorting by timestamp and sequence number.", + "user_id": "Participant identifier supplied by capture settings.", + "assignment_id": "Assignment or lab identifier supplied by capture settings.", + "group_id": "Study group, section, or cohort identifier supplied by capture settings.", + "session_id": "Capture session UUID emitted by the VS Code extension.", + "condition": "Study condition, such as treatment, comparison, or capture-only.", + "course_id": "Course or deployment identifier supplied by capture settings.", + "task_id": "Task identifier supplied by capture settings.", + "event_id": "Client-generated UUID when available.", + "sequence_number": "Client-side monotonically increasing sequence number when available.", + "schema_version": "Capture payload schema version.", + "event_source": "Capture source, such as vscode_extension.", + "event_type": "Canonical CodeChat capture event type.", + "timestamp": "Server-recorded event timestamp.", + "client_timestamp_ms": "Client-side timestamp in milliseconds since Unix epoch.", + "server_timestamp_ms": "Server-side timestamp in milliseconds since Unix epoch.", + "client_tz_offset_min": "Client timezone offset from JavaScript Date().getTimezoneOffset().", + "client_server_latency_ms": "Approximate server timestamp minus client timestamp.", + "elapsed_session_seconds": "Seconds since the first event in the same participant/session/task row.", + "gap_seconds": "Seconds since the prior event in the same participant/session/task row.", + "file_id": "Privacy-preserving file identifier. Uses captured file hash when available, otherwise a SHA-256 hash of the captured path.", + "file_hash": "Captured SHA-256 file path hash when the extension supplied one.", + "file_path": "Raw captured file path. Only exported with --include-file-paths.", + "path_privacy": "Path privacy mode reported by capture settings.", + "language_id": "VS Code language identifier when available.", + "capture_mode": "Capture mode reported in event data.", + "classification_basis": "Server-side write-classification basis when available.", + "write_source": "Write event source, such as server_translation or CodeMirror update path.", + "mode": "Event-specific mode or CodeChat lexer mode.", + "activity_from": "Previous activity kind for switch_pane events.", + "activity_to": "New activity kind for switch_pane events.", + "duration_seconds": "Event-specific duration in seconds.", + "duration_ms": "Event-specific duration in milliseconds.", + "line_count": "Document line count captured on save events.", + "prompt_hash": "SHA-256 hash of the inserted reflection prompt.", + "prompt_length": "Length of the inserted reflection prompt.", + "command": "Lifecycle command name recorded by the extension.", + "task_name": "VS Code task name for compile/build events.", + "task_source": "VS Code task source for compile/build events.", + "exit_code": "Compile/build process exit code when available.", + "run_session_name": "VS Code debug/run session name.", + "run_session_type": "VS Code debug/run session type.", + "save_reason": "Save reason reported by the extension.", + "doc_block_count_before": "Documentation block count before a classified doc-block edit.", + "doc_block_count_after": "Documentation block count after a classified doc-block edit.", + "diff_hunks": "Number of text diff hunks in the event payload.", + "diff_inserted_chars": "Characters inserted across text diff hunks.", + "diff_deleted_units": "UTF-16 code units removed across text diff hunks.", + "diff_replacement_hunks": "Text diff hunks that both removed and inserted content.", + "doc_block_transactions": "Number of doc-block add/update/delete transactions.", + "doc_block_diff_hunks": "Nested text diff hunks inside doc-block transactions.", + "doc_block_inserted_chars": "Characters inserted inside doc-block transaction diffs.", + "doc_block_deleted_units": "UTF-16 code units removed inside doc-block transaction diffs.", + "event_count": "Number of events in the aggregate row.", + "first_event_at": "Earliest event timestamp in the aggregate row.", + "last_event_at": "Latest event timestamp in the aggregate row.", + "active_span_seconds": "Seconds between first and last event in the aggregate row.", + "events_per_minute": "Event count divided by active span in minutes.", + "mean_gap_seconds": "Mean within-session gap between consecutive timestamped events.", + "max_gap_seconds": "Largest within-session gap between consecutive timestamped events.", + "doc_session_seconds": "Total duration of doc_session events.", + "doc_session_share_of_span": "doc_session_seconds divided by active_span_seconds.", + "write_events": "write_doc_events plus write_code_events.", + "doc_write_share": "write_doc_events divided by all write events.", + "doc_to_code_switches": "switch_pane events moving from documentation to code.", + "code_to_doc_switches": "switch_pane events moving from code to documentation.", + "compile_success_events": "compile_end events with exit_code equal to 0.", + "compile_failure_events": "compile_end events with nonzero exit_code.", + "total_prompt_chars": "Sum of reflection prompt lengths.", + "unique_file_count": "Number of distinct file_id values in the aggregate row.", + "unique_language_count": "Number of distinct language_id values in the aggregate row.", + "file_ids": "Semicolon-delimited file_id values in the aggregate row.", + "language_ids": "Semicolon-delimited language_id values in the aggregate row.", + "event_sources": "Semicolon-delimited event_source values in the aggregate row.", + "client_server_latency_ms_mean": "Mean approximate client-to-server timestamp delta.", + "client_server_latency_ms_max": "Largest approximate client-to-server timestamp delta.", + "first_sequence_number": "Smallest captured sequence number in the aggregate row.", + "last_sequence_number": "Largest captured sequence number in the aggregate row.", + "missing_sequence_gaps": "Count of missing sequence-number slots within the aggregate row.", + "duplicate_event_ids": "Number of repeated event_id values in the aggregate row.", + "data_quality_notes": "Semicolon-delimited notes about missing or suspicious capture metadata.", + "line_count_first": "First observed line count for the file aggregate.", + "line_count_last": "Last observed line count for the file aggregate.", + "line_count_max": "Maximum observed line count for the file aggregate.", + "doc_block_count_before_min": "Minimum observed doc block count before edits.", + "doc_block_count_after_last": "Last observed doc block count after edits.", + "classification_bases": "Semicolon-delimited write classification bases.", + "write_sources": "Semicolon-delimited write event sources.", + "lifecycle_kind": "Lifecycle family: task, debug_task, or handoff.", + "lifecycle_index": "One-based lifecycle index within participant/session/task/kind.", + "completed": "1 when a lifecycle end event was observed, otherwise 0.", + "start_event_type": "Lifecycle start event type.", + "end_event_type": "Lifecycle end/submit event type.", + "start_at": "Lifecycle start timestamp.", + "end_at": "Lifecycle end timestamp.", + "start_event_id": "event_id for the lifecycle start event.", + "end_event_id": "event_id for the lifecycle end event.", + "start_file_id": "file_id associated with the lifecycle start event.", + "end_file_id": "file_id associated with the lifecycle end event.", +} + @dataclass(frozen=True) class DbConfig: @@ -56,22 +349,6 @@ class DbConfig: port: int | None = None -@dataclass -class MetricRow: - user_id: str - assignment_id: str - group_id: str - session_id: str - condition: str - course_id: str - task_id: str - event_count: int = 0 - first_event_at: str = "" - last_event_at: str = "" - doc_session_seconds: float = 0.0 - counts: dict[str, int] = field(default_factory=lambda: defaultdict(int)) - - def parse_timestamp(value: Any) -> datetime | None: if isinstance(value, datetime): return value @@ -95,6 +372,24 @@ def as_data(value: Any) -> dict[str, Any]: return {} +def normalize_db_record(record: dict[str, Any]) -> dict[str, Any]: + data = as_data(record.get("data")) + for field_name in DB_METADATA_FIELDS: + value = record.get(field_name) + if value is not None and data.get(field_name) is None: + data[field_name] = value + + return { + "user_id": record.get("user_id"), + "assignment_id": record.get("assignment_id"), + "group_id": record.get("group_id"), + "file_path": record.get("file_path"), + "event_type": record.get("event_type"), + "timestamp": record.get("timestamp"), + "data": data, + } + + def iter_jsonl_events(path: Path) -> Iterator[dict[str, Any]]: with path.open("r", encoding="utf-8") as input_file: for line_number, line in enumerate(input_file, start=1): @@ -224,32 +519,12 @@ def iter_db_events(config: DbConfig, table: str) -> Iterator[dict[str, Any]]: if config.port is not None: connect_kwargs["port"] = config.port - query = ( - "SELECT user_id, assignment_id, group_id, file_path, event_type, timestamp, data " - f"FROM {sql_identifier(table)} " - "ORDER BY timestamp" - ) + query = psql_json_query(table) with psycopg.connect(**connect_kwargs) as conn: with conn.cursor() as cursor: cursor.execute(query) - for ( - user_id, - assignment_id, - group_id, - file_path, - event_type, - timestamp, - data, - ) in cursor: - yield { - "user_id": user_id, - "assignment_id": assignment_id, - "group_id": group_id, - "file_path": file_path, - "event_type": event_type, - "timestamp": timestamp, - "data": as_data(data), - } + for (record_text,) in cursor: + yield normalize_db_record(json.loads(record_text)) def iter_db_events_with_psql(config: DbConfig, table: str) -> Iterator[dict[str, Any]]: @@ -303,11 +578,10 @@ def iter_db_events_with_psql(config: DbConfig, table: str) -> Iterator[dict[str, if not line: continue try: - event = json.loads(line) + record = json.loads(line) except json.JSONDecodeError as err: raise SystemExit(f"psql output line {line_number}: invalid JSON: {err}") from err - event["data"] = as_data(event.get("data")) - yield event + yield normalize_db_record(record) def find_psql() -> str | None: @@ -322,97 +596,777 @@ def find_psql() -> str | None: def psql_json_query(table: str) -> str: return ( - "SELECT json_build_object(" - "'user_id', user_id, " - "'assignment_id', assignment_id, " - "'group_id', group_id, " - "'file_path', file_path, " - "'event_type', event_type, " - "'timestamp', timestamp, " - "'data', data" - ")::text " - f"FROM {sql_identifier(table)} " - "ORDER BY timestamp" + "SELECT to_jsonb(events_row)::text " + f"FROM {sql_identifier(table)} AS events_row " + 'ORDER BY events_row."timestamp"' ) -def key_for_event(event: dict[str, Any]) -> tuple[str, str, str, str, str, str, str]: - data = event["data"] - return ( - str(event.get("user_id") or ""), - str(event.get("assignment_id") or ""), - str(event.get("group_id") or ""), - str(data.get("session_id") or ""), - str(data.get("condition") or ""), - str(data.get("course_id") or ""), - str(data.get("task_id") or ""), +def text_value(value: Any) -> str: + if value is None: + return "" + if isinstance(value, str): + return value + if isinstance(value, (dict, list)): + return json.dumps(value, sort_keys=True, separators=(",", ":")) + return str(value) + + +def first_data_value(data: dict[str, Any], *names: str) -> Any: + for name in names: + if name in data and data[name] is not None: + return data[name] + return None + + +def data_text(data: dict[str, Any], *names: str) -> str: + return text_value(first_data_value(data, *names)) + + +def int_value(value: Any) -> int | None: + if isinstance(value, bool): + return int(value) + if isinstance(value, int): + return value + if isinstance(value, float) and value.is_integer(): + return int(value) + if isinstance(value, str): + value = value.strip() + if not value: + return None + try: + return int(value) + except ValueError: + return None + return None + + +def float_value(value: Any) -> float | None: + if isinstance(value, bool): + return float(value) + if isinstance(value, (int, float)): + return float(value) + if isinstance(value, str): + value = value.strip() + if not value: + return None + try: + return float(value) + except ValueError: + return None + return None + + +def int_or_blank(value: Any) -> int | str: + number = int_value(value) + return number if number is not None else "" + + +def float_or_blank(value: Any) -> float | str: + number = float_value(value) + return number if number is not None else "" + + +def csv_value(value: Any) -> str | int: + if value is None: + return "" + if isinstance(value, float): + return f"{value:.3f}" + if isinstance(value, bool): + return "1" if value else "0" + return value + + +def write_csv(path: Path, fieldnames: list[str], rows: Iterable[dict[str, Any]]) -> None: + if path.parent != Path("."): + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8", newline="") as output_file: + writer = csv.DictWriter(output_file, fieldnames=fieldnames, extrasaction="ignore") + writer.writeheader() + for row in rows: + writer.writerow({field: csv_value(row.get(field, "")) for field in fieldnames}) + + +def aware_datetime(value: datetime | None) -> datetime | None: + if value is None: + return None + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + + +def seconds_between(start: datetime | None, end: datetime | None) -> float | None: + if start is None or end is None: + return None + return (end - start).total_seconds() + + +def timestamp_for_csv(value: datetime | None, fallback: Any = "") -> str: + if value is not None: + return value.isoformat() + return text_value(fallback) + + +def sha256_text(value: str) -> str: + return hashlib.sha256(value.encode("utf-8")).hexdigest() + + +def file_id_for(file_path: str, file_hash: str) -> str: + if file_hash: + return file_hash + if file_path: + return sha256_text(file_path) + return "" + + +def fields_with_optional_file_path( + fieldnames: list[str], include_file_paths: bool +) -> list[str]: + if not include_file_paths or RAW_FILE_PATH_FIELD in fieldnames: + return fieldnames + fields = list(fieldnames) + insert_after = "file_hash" if "file_hash" in fields else "file_id" + fields.insert(fields.index(insert_after) + 1, RAW_FILE_PATH_FIELD) + return fields + + +def identity_key(row: dict[str, Any]) -> tuple[str, ...]: + return tuple(text_value(row.get(field)) for field in IDENTITY_FIELDS) + + +def semicolon_join(values: Iterable[str]) -> str: + return ";".join(sorted(value for value in values if value)) + + +def add_number(acc: list[float], value: Any) -> None: + number = float_value(value) + if number is not None: + acc.append(number) + + +def string_diff_stats(value: Any) -> Counter[str]: + stats: Counter[str] = Counter() + if isinstance(value, list): + for item in value: + stats.update(string_diff_stats(item)) + return stats + if not isinstance(value, dict): + return stats + + if "from" in value and "insert" in value: + from_value = int_value(value.get("from")) or 0 + to_value = int_value(value.get("to")) + removed_units = max(0, (to_value or from_value) - from_value) + inserted_chars = len(text_value(value.get("insert"))) + stats["hunks"] += 1 + stats["inserted_chars"] += inserted_chars + stats["deleted_units"] += removed_units + if removed_units > 0 and inserted_chars > 0: + stats["replacement_hunks"] += 1 + return stats + + for child in value.values(): + stats.update(string_diff_stats(child)) + return stats + + +def doc_block_contents(value: Any) -> str: + if isinstance(value, list) and len(value) >= 5: + return text_value(value[4]) + if isinstance(value, dict): + return text_value(value.get("contents")) + return "" + + +def doc_block_diff_stats(value: Any) -> Counter[str]: + stats: Counter[str] = Counter() + if not isinstance(value, list): + return stats + + for transaction in value: + stats["transactions"] += 1 + if not isinstance(transaction, dict): + continue + if "Add" in transaction: + stats["inserted_chars"] += len(doc_block_contents(transaction["Add"])) + elif "Update" in transaction: + update_stats = string_diff_stats(transaction["Update"]) + stats["hunks"] += update_stats["hunks"] + stats["inserted_chars"] += update_stats["inserted_chars"] + stats["deleted_units"] += update_stats["deleted_units"] + elif "Delete" in transaction: + continue + else: + update_stats = string_diff_stats(transaction) + stats["hunks"] += update_stats["hunks"] + stats["inserted_chars"] += update_stats["inserted_chars"] + stats["deleted_units"] += update_stats["deleted_units"] + return stats + + +def normalize_event_rows( + events: Iterable[dict[str, Any]], include_file_paths: bool = False +) -> list[dict[str, Any]]: + sortable_rows: list[dict[str, Any]] = [] + for original_index, event in enumerate(events, start=1): + data = as_data(event.get("data")) + timestamp = aware_datetime(parse_timestamp(event.get("timestamp"))) + file_path = text_value(event.get("file_path")) + file_hash = data_text(data, "file_hash") + client_timestamp_ms = int_value(data.get("client_timestamp_ms")) + server_timestamp_ms = int_value(data.get("server_timestamp_ms")) + latency_ms = ( + server_timestamp_ms - client_timestamp_ms + if client_timestamp_ms is not None and server_timestamp_ms is not None + else "" + ) + diff_stats = string_diff_stats(data.get("diff")) + doc_block_stats = doc_block_diff_stats(data.get("doc_block_diff")) + + row: dict[str, Any] = { + "event_index": original_index, + "user_id": text_value(event.get("user_id")), + "assignment_id": text_value(event.get("assignment_id")), + "group_id": text_value(event.get("group_id")), + "session_id": data_text(data, "session_id"), + "condition": data_text(data, "condition"), + "course_id": data_text(data, "course_id"), + "task_id": data_text(data, "task_id"), + "event_id": data_text(data, "event_id"), + "sequence_number": int_or_blank(data.get("sequence_number")), + "schema_version": int_or_blank(data.get("schema_version")), + "event_source": data_text(data, "event_source"), + "event_type": text_value(event.get("event_type")), + "timestamp": timestamp_for_csv(timestamp, event.get("timestamp")), + "client_timestamp_ms": client_timestamp_ms + if client_timestamp_ms is not None + else "", + "server_timestamp_ms": server_timestamp_ms + if server_timestamp_ms is not None + else "", + "client_tz_offset_min": int_or_blank(data.get("client_tz_offset_min")), + "client_server_latency_ms": latency_ms, + "elapsed_session_seconds": "", + "gap_seconds": "", + "file_id": file_id_for(file_path, file_hash), + "file_hash": file_hash, + "path_privacy": data_text(data, "path_privacy"), + "language_id": data_text(data, "language_id", "languageId"), + "capture_mode": data_text(data, "capture_mode"), + "classification_basis": data_text(data, "classification_basis"), + "write_source": data_text(data, "source"), + "mode": data_text(data, "mode"), + "activity_from": data_text(data, "from"), + "activity_to": data_text(data, "to"), + "duration_seconds": float_or_blank(data.get("duration_seconds")), + "duration_ms": float_or_blank(data.get("duration_ms")), + "line_count": int_or_blank(first_data_value(data, "lineCount", "line_count")), + "prompt_hash": data_text(data, "prompt_hash"), + "prompt_length": int_or_blank(data.get("prompt_length")), + "command": data_text(data, "command"), + "task_name": data_text(data, "taskName", "task_name"), + "task_source": data_text(data, "taskSource", "task_source"), + "exit_code": int_or_blank(first_data_value(data, "exitCode", "exit_code")), + "run_session_name": data_text(data, "sessionName", "session_name"), + "run_session_type": data_text(data, "sessionType", "session_type"), + "save_reason": data_text(data, "reason"), + "doc_block_count_before": int_or_blank(data.get("doc_block_count_before")), + "doc_block_count_after": int_or_blank(data.get("doc_block_count_after")), + "diff_hunks": diff_stats["hunks"], + "diff_inserted_chars": diff_stats["inserted_chars"], + "diff_deleted_units": diff_stats["deleted_units"], + "diff_replacement_hunks": diff_stats["replacement_hunks"], + "doc_block_transactions": doc_block_stats["transactions"], + "doc_block_diff_hunks": doc_block_stats["hunks"], + "doc_block_inserted_chars": doc_block_stats["inserted_chars"], + "doc_block_deleted_units": doc_block_stats["deleted_units"], + } + if include_file_paths: + row[RAW_FILE_PATH_FIELD] = file_path + + sortable_rows.append( + { + "row": row, + "timestamp": timestamp, + "sequence_number": int_value(row["sequence_number"]), + "original_index": original_index, + } + ) + + max_timestamp = datetime.max.replace(tzinfo=timezone.utc) + sortable_rows.sort( + key=lambda item: ( + item["timestamp"] is None, + item["timestamp"] or max_timestamp, + item["sequence_number"] if item["sequence_number"] is not None else 10**18, + item["original_index"], + ) ) + first_by_session: dict[tuple[str, ...], datetime] = {} + previous_by_session: dict[tuple[str, ...], datetime] = {} + for event_index, item in enumerate(sortable_rows, start=1): + row = item["row"] + row["event_index"] = event_index + timestamp = item["timestamp"] + if timestamp is None: + continue + key = identity_key(row) + first = first_by_session.setdefault(key, timestamp) + row["elapsed_session_seconds"] = seconds_between(first, timestamp) or 0.0 + previous = previous_by_session.get(key) + if previous is not None: + row["gap_seconds"] = seconds_between(previous, timestamp) or 0.0 + previous_by_session[key] = timestamp + + return [item["row"] for item in sortable_rows] + + +def new_session_acc(row: dict[str, Any]) -> dict[str, Any]: + return { + "identity": {field: text_value(row.get(field)) for field in IDENTITY_FIELDS}, + "counts": Counter(), + "event_count": 0, + "first_dt": None, + "last_dt": None, + "gaps": [], + "doc_session_seconds": 0.0, + "doc_to_code_switches": 0, + "code_to_doc_switches": 0, + "compile_success_events": 0, + "compile_failure_events": 0, + "total_prompt_chars": 0, + "file_ids": set(), + "language_ids": set(), + "event_sources": set(), + "latencies": [], + "sequence_numbers": [], + "event_ids": Counter(), + "diff_hunks": 0, + "diff_inserted_chars": 0, + "diff_deleted_units": 0, + "doc_block_transactions": 0, + "doc_block_diff_hunks": 0, + "doc_block_inserted_chars": 0, + "doc_block_deleted_units": 0, + "missing_timestamp_count": 0, + "missing_session_count": 0, + "missing_schema_count": 0, + } -def update_row(row: MetricRow, event: dict[str, Any]) -> None: - event_type = str(event.get("event_type") or "") - data = event["data"] - row.event_count += 1 - if event_type in EVENT_FIELDS: - row.counts[event_type] += 1 + +def update_time_acc(acc: dict[str, Any], row: dict[str, Any]) -> None: + timestamp = aware_datetime(parse_timestamp(row.get("timestamp"))) + if timestamp is None: + acc["missing_timestamp_count"] += 1 + return + if acc["first_dt"] is None or timestamp < acc["first_dt"]: + acc["first_dt"] = timestamp + if acc["last_dt"] is None or timestamp > acc["last_dt"]: + acc["last_dt"] = timestamp + + +def update_session_acc(acc: dict[str, Any], row: dict[str, Any]) -> None: + event_type = text_value(row.get("event_type")) + acc["event_count"] += 1 + acc["counts"][event_type] += 1 + update_time_acc(acc, row) + + add_number(acc["gaps"], row.get("gap_seconds")) if event_type == "doc_session": - duration = data.get("duration_seconds") - if isinstance(duration, (int, float)): - row.doc_session_seconds += float(duration) - - parsed_timestamp = parse_timestamp(event.get("timestamp")) - if parsed_timestamp is not None: - timestamp_text = parsed_timestamp.isoformat() - if not row.first_event_at or timestamp_text < row.first_event_at: - row.first_event_at = timestamp_text - if not row.last_event_at or timestamp_text > row.last_event_at: - row.last_event_at = timestamp_text - - -def export_metrics(events: Iterable[dict[str, Any]], output_path: Path) -> None: - rows: dict[tuple[str, str, str, str, str, str, str], MetricRow] = {} - for event in events: - key = key_for_event(event) - row = rows.setdefault(key, MetricRow(*key)) - update_row(row, event) - - fieldnames = [ - "user_id", - "assignment_id", - "group_id", - "session_id", - "condition", - "course_id", - "task_id", - "event_count", - "first_event_at", - "last_event_at", - "doc_session_seconds", - *[f"{event_type}_events" for event_type in EVENT_FIELDS], + duration = float_value(row.get("duration_seconds")) + if duration is not None: + acc["doc_session_seconds"] += duration + if event_type == "switch_pane": + if row.get("activity_from") == "doc" and row.get("activity_to") == "code": + acc["doc_to_code_switches"] += 1 + if row.get("activity_from") == "code" and row.get("activity_to") == "doc": + acc["code_to_doc_switches"] += 1 + if event_type == "compile_end": + exit_code = int_value(row.get("exit_code")) + if exit_code == 0: + acc["compile_success_events"] += 1 + elif exit_code is not None: + acc["compile_failure_events"] += 1 + + acc["total_prompt_chars"] += int_value(row.get("prompt_length")) or 0 + if row.get("file_id"): + acc["file_ids"].add(text_value(row.get("file_id"))) + if row.get("language_id"): + acc["language_ids"].add(text_value(row.get("language_id"))) + if row.get("event_source"): + acc["event_sources"].add(text_value(row.get("event_source"))) + add_number(acc["latencies"], row.get("client_server_latency_ms")) + + sequence_number = int_value(row.get("sequence_number")) + if sequence_number is not None: + acc["sequence_numbers"].append(sequence_number) + event_id = text_value(row.get("event_id")) + if event_id: + acc["event_ids"][event_id] += 1 + + for field in [ + "diff_hunks", + "diff_inserted_chars", + "diff_deleted_units", + "doc_block_transactions", + "doc_block_diff_hunks", + "doc_block_inserted_chars", + "doc_block_deleted_units", + ]: + acc[field] += int_value(row.get(field)) or 0 + if not row.get("session_id"): + acc["missing_session_count"] += 1 + if not row.get("schema_version"): + acc["missing_schema_count"] += 1 + + +def finalize_session_acc(acc: dict[str, Any]) -> dict[str, Any]: + first_dt = acc["first_dt"] + last_dt = acc["last_dt"] + active_span = seconds_between(first_dt, last_dt) or 0.0 + counts = acc["counts"] + write_events = counts["write_doc"] + counts["write_code"] + sequence_numbers = sorted(set(acc["sequence_numbers"])) + missing_sequence_gaps = sum( + max(0, current - previous - 1) + for previous, current in zip(sequence_numbers, sequence_numbers[1:]) + ) + duplicate_event_ids = sum( + count - 1 for count in acc["event_ids"].values() if count > 1 + ) + notes = [] + if acc["missing_timestamp_count"]: + notes.append(f"missing_timestamp:{acc['missing_timestamp_count']}") + if acc["missing_session_count"]: + notes.append(f"missing_session_id:{acc['missing_session_count']}") + if acc["missing_schema_count"]: + notes.append(f"missing_schema_version:{acc['missing_schema_count']}") + if missing_sequence_gaps: + notes.append(f"missing_sequence_slots:{missing_sequence_gaps}") + if duplicate_event_ids: + notes.append(f"duplicate_event_ids:{duplicate_event_ids}") + + row = { + **acc["identity"], + "event_count": acc["event_count"], + "first_event_at": timestamp_for_csv(first_dt), + "last_event_at": timestamp_for_csv(last_dt), + "active_span_seconds": active_span, + "events_per_minute": (acc["event_count"] / (active_span / 60.0)) + if active_span > 0 + else "", + "mean_gap_seconds": sum(acc["gaps"]) / len(acc["gaps"]) if acc["gaps"] else "", + "max_gap_seconds": max(acc["gaps"]) if acc["gaps"] else "", + "doc_session_seconds": acc["doc_session_seconds"], + "doc_session_share_of_span": acc["doc_session_seconds"] / active_span + if active_span > 0 + else "", + "write_events": write_events, + "doc_write_share": counts["write_doc"] / write_events if write_events else "", + **{f"{event_type}_events": counts[event_type] for event_type in EVENT_FIELDS}, + "doc_to_code_switches": acc["doc_to_code_switches"], + "code_to_doc_switches": acc["code_to_doc_switches"], + "compile_success_events": acc["compile_success_events"], + "compile_failure_events": acc["compile_failure_events"], + "total_prompt_chars": acc["total_prompt_chars"], + "unique_file_count": len(acc["file_ids"]), + "unique_language_count": len(acc["language_ids"]), + "file_ids": semicolon_join(acc["file_ids"]), + "language_ids": semicolon_join(acc["language_ids"]), + "event_sources": semicolon_join(acc["event_sources"]), + "diff_hunks": acc["diff_hunks"], + "diff_inserted_chars": acc["diff_inserted_chars"], + "diff_deleted_units": acc["diff_deleted_units"], + "doc_block_transactions": acc["doc_block_transactions"], + "doc_block_diff_hunks": acc["doc_block_diff_hunks"], + "doc_block_inserted_chars": acc["doc_block_inserted_chars"], + "doc_block_deleted_units": acc["doc_block_deleted_units"], + "client_server_latency_ms_mean": sum(acc["latencies"]) / len(acc["latencies"]) + if acc["latencies"] + else "", + "client_server_latency_ms_max": max(acc["latencies"]) if acc["latencies"] else "", + "first_sequence_number": sequence_numbers[0] if sequence_numbers else "", + "last_sequence_number": sequence_numbers[-1] if sequence_numbers else "", + "missing_sequence_gaps": missing_sequence_gaps, + "duplicate_event_ids": duplicate_event_ids, + "data_quality_notes": ";".join(notes), + } + return row + + +def session_summary_rows(event_rows: Iterable[dict[str, Any]]) -> list[dict[str, Any]]: + accs: dict[tuple[str, ...], dict[str, Any]] = {} + for row in event_rows: + key = identity_key(row) + acc = accs.setdefault(key, new_session_acc(row)) + update_session_acc(acc, row) + return [ + finalize_session_acc(acc) + for _, acc in sorted(accs.items(), key=lambda item: item[0]) ] - with output_path.open("w", encoding="utf-8", newline="") as output_file: - writer = csv.DictWriter(output_file, fieldnames=fieldnames) - writer.writeheader() - for row in sorted(rows.values(), key=lambda r: (r.user_id, r.session_id)): - writer.writerow( + + +def new_file_acc(row: dict[str, Any], include_file_paths: bool) -> dict[str, Any]: + acc = { + "identity": {field: text_value(row.get(field)) for field in IDENTITY_FIELDS}, + "file_id": text_value(row.get("file_id")), + "file_hash": text_value(row.get("file_hash")), + "language_id": text_value(row.get("language_id")), + "path_privacy": text_value(row.get("path_privacy")), + "counts": Counter(), + "event_count": 0, + "first_dt": None, + "last_dt": None, + "doc_session_seconds": 0.0, + "line_count_first": "", + "line_count_last": "", + "line_count_max": "", + "doc_block_count_before_min": "", + "doc_block_count_after_last": "", + "classification_bases": set(), + "write_sources": set(), + "diff_hunks": 0, + "diff_inserted_chars": 0, + "diff_deleted_units": 0, + "doc_block_transactions": 0, + "doc_block_diff_hunks": 0, + "doc_block_inserted_chars": 0, + "doc_block_deleted_units": 0, + } + if include_file_paths: + acc[RAW_FILE_PATH_FIELD] = text_value(row.get(RAW_FILE_PATH_FIELD)) + return acc + + +def update_file_acc(acc: dict[str, Any], row: dict[str, Any]) -> None: + event_type = text_value(row.get("event_type")) + acc["event_count"] += 1 + acc["counts"][event_type] += 1 + update_time_acc(acc, row) + + if event_type == "doc_session": + duration = float_value(row.get("duration_seconds")) + if duration is not None: + acc["doc_session_seconds"] += duration + + line_count = int_value(row.get("line_count")) + if line_count is not None: + if acc["line_count_first"] == "": + acc["line_count_first"] = line_count + acc["line_count_last"] = line_count + acc["line_count_max"] = max(int_value(acc["line_count_max"]) or 0, line_count) + + before_count = int_value(row.get("doc_block_count_before")) + if before_count is not None: + current_min = int_value(acc["doc_block_count_before_min"]) + acc["doc_block_count_before_min"] = ( + before_count if current_min is None else min(current_min, before_count) + ) + after_count = int_value(row.get("doc_block_count_after")) + if after_count is not None: + acc["doc_block_count_after_last"] = after_count + + if row.get("classification_basis"): + acc["classification_bases"].add(text_value(row.get("classification_basis"))) + if row.get("write_source"): + acc["write_sources"].add(text_value(row.get("write_source"))) + + for field in [ + "diff_hunks", + "diff_inserted_chars", + "diff_deleted_units", + "doc_block_transactions", + "doc_block_diff_hunks", + "doc_block_inserted_chars", + "doc_block_deleted_units", + ]: + acc[field] += int_value(row.get(field)) or 0 + + +def finalize_file_acc(acc: dict[str, Any], include_file_paths: bool) -> dict[str, Any]: + first_dt = acc["first_dt"] + last_dt = acc["last_dt"] + row = { + **acc["identity"], + "file_id": acc["file_id"], + "file_hash": acc["file_hash"], + "language_id": acc["language_id"], + "path_privacy": acc["path_privacy"], + "event_count": acc["event_count"], + "first_event_at": timestamp_for_csv(first_dt), + "last_event_at": timestamp_for_csv(last_dt), + "active_span_seconds": seconds_between(first_dt, last_dt) or 0.0, + "doc_session_seconds": acc["doc_session_seconds"], + "write_doc_events": acc["counts"]["write_doc"], + "write_code_events": acc["counts"]["write_code"], + "save_events": acc["counts"]["save"], + "compile_events": acc["counts"]["compile"], + "compile_end_events": acc["counts"]["compile_end"], + "run_events": acc["counts"]["run"], + "run_end_events": acc["counts"]["run_end"], + "switch_pane_events": acc["counts"]["switch_pane"], + "line_count_first": acc["line_count_first"], + "line_count_last": acc["line_count_last"], + "line_count_max": acc["line_count_max"], + "doc_block_count_before_min": acc["doc_block_count_before_min"], + "doc_block_count_after_last": acc["doc_block_count_after_last"], + "classification_bases": semicolon_join(acc["classification_bases"]), + "write_sources": semicolon_join(acc["write_sources"]), + "diff_hunks": acc["diff_hunks"], + "diff_inserted_chars": acc["diff_inserted_chars"], + "diff_deleted_units": acc["diff_deleted_units"], + "doc_block_transactions": acc["doc_block_transactions"], + "doc_block_diff_hunks": acc["doc_block_diff_hunks"], + "doc_block_inserted_chars": acc["doc_block_inserted_chars"], + "doc_block_deleted_units": acc["doc_block_deleted_units"], + } + if include_file_paths: + row[RAW_FILE_PATH_FIELD] = acc.get(RAW_FILE_PATH_FIELD, "") + return row + + +def file_summary_rows( + event_rows: Iterable[dict[str, Any]], include_file_paths: bool +) -> list[dict[str, Any]]: + accs: dict[tuple[str, ...], dict[str, Any]] = {} + for row in event_rows: + key = ( + *identity_key(row), + text_value(row.get("file_id")), + text_value(row.get("language_id")), + ) + acc = accs.setdefault(key, new_file_acc(row, include_file_paths)) + update_file_acc(acc, row) + return [ + finalize_file_acc(acc, include_file_paths) + for _, acc in sorted(accs.items(), key=lambda item: item[0]) + ] + + +def lifecycle_row( + kind: str, + lifecycle_index: int, + start: dict[str, Any] | None, + end: dict[str, Any] | None, +) -> dict[str, Any]: + source = start or end or {} + start_dt = aware_datetime(parse_timestamp(start.get("timestamp") if start else None)) + end_dt = aware_datetime(parse_timestamp(end.get("timestamp") if end else None)) + notes = [] + if start is None: + notes.append("missing_start") + if end is None: + notes.append("missing_end") + return { + **{field: text_value(source.get(field)) for field in IDENTITY_FIELDS}, + "lifecycle_kind": kind, + "lifecycle_index": lifecycle_index, + "completed": 1 if end is not None else 0, + "start_event_type": text_value(start.get("event_type")) if start else "", + "end_event_type": text_value(end.get("event_type")) if end else "", + "start_at": timestamp_for_csv(start_dt), + "end_at": timestamp_for_csv(end_dt), + "duration_seconds": seconds_between(start_dt, end_dt) + if start_dt is not None and end_dt is not None + else "", + "start_event_id": text_value(start.get("event_id")) if start else "", + "end_event_id": text_value(end.get("event_id")) if end else "", + "start_file_id": text_value(start.get("file_id")) if start else "", + "end_file_id": text_value(end.get("file_id")) if end else "", + "language_id": text_value(source.get("language_id")), + "command": text_value(source.get("command")), + "data_quality_notes": ";".join(notes), + } + + +def task_lifecycle_rows(event_rows: Iterable[dict[str, Any]]) -> list[dict[str, Any]]: + starts: dict[tuple[tuple[str, ...], str], deque[dict[str, Any]]] = defaultdict(deque) + lifecycle_indexes: Counter[tuple[tuple[str, ...], str]] = Counter() + rows: list[dict[str, Any]] = [] + + for row in event_rows: + event_type = text_value(row.get("event_type")) + if event_type in LIFECYCLE_PAIRS: + kind, _ = LIFECYCLE_PAIRS[event_type] + starts[(identity_key(row), kind)].append(row) + continue + if event_type not in LIFECYCLE_END_TYPES: + continue + + kind, _start_type = LIFECYCLE_END_TYPES[event_type] + key = (identity_key(row), kind) + start = starts[key].popleft() if starts[key] else None + lifecycle_indexes[key] += 1 + rows.append(lifecycle_row(kind, lifecycle_indexes[key], start, row)) + + for key, queue in sorted(starts.items(), key=lambda item: item[0]): + identity, kind = key + while queue: + start = queue.popleft() + lifecycle_indexes[(identity, kind)] += 1 + rows.append(lifecycle_row(kind, lifecycle_indexes[(identity, kind)], start, None)) + + rows.sort( + key=lambda row: ( + row["user_id"], + row["assignment_id"], + row["session_id"], + row["task_id"], + row["lifecycle_kind"], + row["lifecycle_index"], + ) + ) + return rows + + +def data_dictionary_rows(fieldsets: dict[str, list[str]]) -> list[dict[str, str]]: + rows = [] + for dataset, fields in fieldsets.items(): + for field in fields: + rows.append( { - "user_id": row.user_id, - "assignment_id": row.assignment_id, - "group_id": row.group_id, - "session_id": row.session_id, - "condition": row.condition, - "course_id": row.course_id, - "task_id": row.task_id, - "event_count": row.event_count, - "first_event_at": row.first_event_at, - "last_event_at": row.last_event_at, - "doc_session_seconds": f"{row.doc_session_seconds:.3f}", - **{ - f"{event_type}_events": row.counts[event_type] - for event_type in EVENT_FIELDS - }, + "dataset": dataset, + "column": field, + "description": FIELD_DESCRIPTIONS.get(field, ""), } ) + return rows + + +def export_metrics(event_rows: list[dict[str, Any]], output_path: Path) -> None: + write_csv(output_path, SESSION_SUMMARY_FIELDS, session_summary_rows(event_rows)) + + +def export_analysis_dataset( + event_rows: list[dict[str, Any]], dataset_dir: Path, include_file_paths: bool +) -> list[Path]: + dataset_dir.mkdir(parents=True, exist_ok=True) + event_fields = fields_with_optional_file_path(EVENT_ROW_FIELDS, include_file_paths) + file_fields = fields_with_optional_file_path(FILE_SUMMARY_FIELDS, include_file_paths) + fieldsets = { + "events.csv": event_fields, + "session_summary.csv": SESSION_SUMMARY_FIELDS, + "file_summary.csv": file_fields, + "task_lifecycle.csv": TASK_LIFECYCLE_FIELDS, + } + outputs = [ + dataset_dir / "events.csv", + dataset_dir / "session_summary.csv", + dataset_dir / "file_summary.csv", + dataset_dir / "task_lifecycle.csv", + dataset_dir / "data_dictionary.csv", + ] + write_csv(outputs[0], event_fields, event_rows) + write_csv(outputs[1], SESSION_SUMMARY_FIELDS, session_summary_rows(event_rows)) + write_csv(outputs[2], file_fields, file_summary_rows(event_rows, include_file_paths)) + write_csv(outputs[3], TASK_LIFECYCLE_FIELDS, task_lifecycle_rows(event_rows)) + write_csv( + outputs[4], + ["dataset", "column", "description"], + data_dictionary_rows(fieldsets), + ) + return outputs def main() -> None: @@ -427,7 +1381,31 @@ def main() -> None: "--out", type=Path, default=None, - help="Output CSV file. Defaults to a timestamped capture-metrics-YYYYMMDD-HHMMSS.csv file.", + help=( + "Output session-summary CSV file. Defaults to a timestamped " + "capture-metrics-YYYYMMDD-HHMMSS.csv file when --dataset-dir is omitted." + ), + ) + parser.add_argument( + "--dataset-dir", + nargs="?", + const=Path("__DEFAULT_CAPTURE_ANALYSIS_DIR__"), + type=Path, + default=None, + help=( + "Write a richer analysis dataset directory containing events.csv, " + "session_summary.csv, file_summary.csv, task_lifecycle.csv, and " + "data_dictionary.csv. If no path is supplied, defaults to " + "capture-analysis-YYYYMMDD-HHMMSS." + ), + ) + parser.add_argument( + "--include-file-paths", + action="store_true", + help=( + "Include raw captured file paths in event/file exports. By default, " + "the dataset uses file_id/file_hash only." + ), ) parser.add_argument( "--db", @@ -455,9 +1433,31 @@ def main() -> None: if args.input is not None else iter_db_events(load_db_config(args.config), args.table) ) - output_path = args.out or default_output_path() - export_metrics(events, output_path) - print(f"Wrote {output_path}") + event_rows = normalize_event_rows(events, include_file_paths=args.include_file_paths) + + wrote_outputs = False + if args.out is not None or args.dataset_dir is None: + output_path = args.out or default_output_path() + export_metrics(event_rows, output_path) + print(f"Wrote {output_path}") + wrote_outputs = True + + if args.dataset_dir is not None: + dataset_dir = ( + default_dataset_dir() + if args.dataset_dir == Path("__DEFAULT_CAPTURE_ANALYSIS_DIR__") + else args.dataset_dir + ) + output_paths = export_analysis_dataset( + event_rows, dataset_dir, args.include_file_paths + ) + print(f"Wrote analysis dataset to {dataset_dir}") + for output_path in output_paths: + print(f" {output_path.name}") + wrote_outputs = True + + if not wrote_outputs: + raise SystemExit("No outputs were requested.") def default_output_path() -> Path: @@ -465,5 +1465,10 @@ def default_output_path() -> Path: return Path(f"capture-metrics-{timestamp}.csv") +def default_dataset_dir() -> Path: + timestamp = datetime.now(timezone.utc).astimezone().strftime("%Y%m%d-%H%M%S") + return Path(f"capture-analysis-{timestamp}") + + if __name__ == "__main__": main() diff --git a/server/src/capture.rs b/server/src/capture.rs index 0b317038..e461be64 100644 --- a/server/src/capture.rs +++ b/server/src/capture.rs @@ -34,28 +34,28 @@ // Database schema // ---------------------------------------------------------------------------- // -// The following SQL statement creates the `events` table used by this module: +// The canonical schema and migration DDL lives in +// `server/scripts/capture_events_schema.sql`. The important analysis columns +// are: // // ```sql -// CREATE TABLE events ( -// id SERIAL PRIMARY KEY, -// user_id TEXT NOT NULL, -// assignment_id TEXT, -// group_id TEXT, -// file_path TEXT, -// event_type TEXT NOT NULL, -// timestamp TEXT NOT NULL, -// data TEXT -// ); +// event_id, sequence_number, schema_version, +// user_id, assignment_id, group_id, condition, course_id, task_id, session_id, +// event_source, language_id, file_hash, file_path, path_privacy, capture_mode, +// event_type, timestamp, client_timestamp_ms, client_tz_offset_min, +// server_timestamp_ms, data // ``` // // * `user_id` – participant identifier (student id, pseudonym, etc.). -// * `assignment_id` – logical assignment / lab identifier. -// * `group_id` – optional grouping (treatment / comparison, section). +// * `assignment_id`, `task_id` – logical assignment / task identifiers. +// * `group_id`, `condition`, `course_id` – study grouping metadata. +// * `session_id`, `event_id`, `sequence_number`, `schema_version` – event +// integrity and versioning metadata. // * `file_path` – logical path of the file being edited. +// * `file_hash` – privacy-preserving SHA-256 hash of the file path. // * `event_type` – coarse event type (see `event_type` constants below). // * `timestamp` – RFC3339 timestamp (in UTC). -// * `data` – JSON payload with event-specific details. +// * `data` – JSONB payload with event-specific details. use std::{ env, @@ -562,13 +562,127 @@ fn log_pg_connect_error(context: &str, err: &tokio_postgres::Error) { error!("{context}: {err}"); } +fn capture_data_str(data: &serde_json::Value, names: &[&str]) -> Option { + names.iter().find_map(|name| { + data.get(*name) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + }) +} + +fn capture_data_i64(data: &serde_json::Value, name: &str) -> Option { + let value = data.get(name)?; + value + .as_i64() + .or_else(|| value.as_str()?.trim().parse::().ok()) +} + +fn capture_data_i32(data: &serde_json::Value, name: &str) -> Option { + capture_data_i64(data, name).and_then(|value| i32::try_from(value).ok()) +} + +fn should_retry_legacy_insert(err: &tokio_postgres::Error) -> bool { + matches!( + err.code().map(|code| code.code()), + Some("42703" | "42P01" | "42804") + ) +} + /// Insert a single event into the `events` table. async fn insert_event(client: &Client, event: &CaptureEvent) -> Result { + match insert_rich_event(client, event).await { + Ok(rows) => Ok(rows), + Err(err) if should_retry_legacy_insert(&err) => { + warn!( + "Capture: rich events insert failed against the current schema; retrying legacy insert: {err}" + ); + insert_legacy_event(client, event).await + } + Err(err) => Err(err), + } +} + +async fn insert_rich_event( + client: &Client, + event: &CaptureEvent, +) -> Result { let timestamp = event.timestamp.to_rfc3339(); + let event_id = capture_data_str(&event.data, &["event_id"]); + let sequence_number = capture_data_i64(&event.data, "sequence_number"); + let schema_version = capture_data_i32(&event.data, "schema_version"); + let condition = capture_data_str(&event.data, &["condition"]); + let course_id = capture_data_str(&event.data, &["course_id"]); + let task_id = capture_data_str(&event.data, &["task_id"]); + let session_id = capture_data_str(&event.data, &["session_id"]); + let event_source = capture_data_str(&event.data, &["event_source"]); + let language_id = capture_data_str(&event.data, &["language_id", "languageId"]); + let file_hash = capture_data_str(&event.data, &["file_hash"]); + let path_privacy = capture_data_str(&event.data, &["path_privacy"]); + let capture_mode = capture_data_str(&event.data, &["capture_mode"]); + let client_timestamp_ms = capture_data_i64(&event.data, "client_timestamp_ms"); + let client_tz_offset_min = capture_data_i32(&event.data, "client_tz_offset_min"); + let server_timestamp_ms = capture_data_i64(&event.data, "server_timestamp_ms") + .unwrap_or_else(|| event.timestamp.timestamp_millis()); let data_text = event.data.to_string(); debug!( - "Capture: executing INSERT for user_id={}, event_type={}, timestamp={}", + "Capture: executing rich INSERT for user_id={}, event_type={}, timestamp={}", + event.user_id, event.event_type, timestamp + ); + + client + .execute( + "INSERT INTO events \ + (event_id, sequence_number, schema_version, \ + user_id, assignment_id, group_id, condition, course_id, task_id, session_id, \ + event_source, language_id, file_hash, file_path, path_privacy, capture_mode, \ + event_type, timestamp, client_timestamp_ms, client_tz_offset_min, \ + server_timestamp_ms, data) \ + VALUES \ + ($1, $2, $3, \ + $4, $5, $6, $7, $8, $9, $10, \ + $11, $12, $13, $14, $15, $16, \ + $17, $18::timestamptz, $19, $20, \ + $21, $22::jsonb)", + &[ + &event_id, + &sequence_number, + &schema_version, + &event.user_id, + &event.assignment_id, + &event.group_id, + &condition, + &course_id, + &task_id, + &session_id, + &event_source, + &language_id, + &file_hash, + &event.file_path, + &path_privacy, + &capture_mode, + &event.event_type, + ×tamp, + &client_timestamp_ms, + &client_tz_offset_min, + &server_timestamp_ms, + &data_text, + ], + ) + .await +} + +async fn insert_legacy_event( + client: &Client, + event: &CaptureEvent, +) -> Result { + let timestamp = event.timestamp.to_rfc3339(); + let data_text = event.data.to_string(); + + debug!( + "Capture: executing legacy INSERT for user_id={}, event_type={}, timestamp={}", event.user_id, event.event_type, timestamp ); @@ -666,6 +780,29 @@ mod tests { assert!(ev.timestamp <= after); } + #[test] + fn capture_metadata_helpers_extract_typed_values() { + let data = json!({ + "event_id": "abc-123", + "sequence_number": "42", + "schema_version": 2, + "languageId": "rust", + "client_tz_offset_min": "-360" + }); + + assert_eq!( + capture_data_str(&data, &["language_id", "languageId"]).as_deref(), + Some("rust") + ); + assert_eq!( + capture_data_str(&data, &["event_id"]).as_deref(), + Some("abc-123") + ); + assert_eq!(capture_data_i64(&data, "sequence_number"), Some(42)); + assert_eq!(capture_data_i32(&data, "schema_version"), Some(2)); + assert_eq!(capture_data_i32(&data, "client_tz_offset_min"), Some(-360)); + } + #[test] fn capture_config_json_round_trip() { let json_text = r#" @@ -783,13 +920,10 @@ mod tests { INSERT INTO public.events (user_id, assignment_id, group_id, file_path, event_type, timestamp, data) VALUES - ($1, NULL, NULL, NULL, 'test_event', $2, '{"test":true}') + ($1, NULL, NULL, NULL, 'test_event', $2::timestamptz, '{"test":true}'::jsonb) RETURNING id "#, - &[ - &test_user_id, - &format!("{:?}", std::time::SystemTime::now()), - ], + &[&test_user_id, &Utc::now().to_rfc3339()], ) .await?; @@ -824,7 +958,7 @@ mod tests { match client .query_one( r#" - SELECT user_id, assignment_id, group_id, file_path, event_type, data + SELECT user_id, assignment_id, group_id, file_path, event_type, data::text FROM events WHERE user_id = $1 AND event_type = $2 ORDER BY id DESC From c11054606ecf8c579377e7831f2c4aaafb30dc7c Mon Sep 17 00:00:00 2001 From: "JDS-WORKSTATION\\jspah" <44337821+jspahn80134@users.noreply.github.com> Date: Fri, 8 May 2026 15:54:25 -0600 Subject: [PATCH 15/45] Verify rich capture schema writes Update the ignored PostgreSQL integration test to assert the rich events schema columns and fix timestamp/JSONB parameter casts used by the capture insert path. Verified against the AWS PostgreSQL database with event_capture_inserts_rich_schema_event_into_db. --- server/src/capture.rs | 194 +++++++++++++++++++++++++++--------------- 1 file changed, 126 insertions(+), 68 deletions(-) diff --git a/server/src/capture.rs b/server/src/capture.rs index e461be64..5d41d869 100644 --- a/server/src/capture.rs +++ b/server/src/capture.rs @@ -644,8 +644,8 @@ async fn insert_rich_event( ($1, $2, $3, \ $4, $5, $6, $7, $8, $9, $10, \ $11, $12, $13, $14, $15, $16, \ - $17, $18::timestamptz, $19, $20, \ - $21, $22::jsonb)", + $17, $18::text::timestamptz, $19, $20, \ + $21, $22::text::jsonb)", &[ &event_id, &sequence_number, @@ -690,7 +690,7 @@ async fn insert_legacy_event( .execute( "INSERT INTO events \ (user_id, assignment_id, group_id, file_path, event_type, timestamp, data) \ - VALUES ($1, $2, $3, $4, $5, $6, $7)", + VALUES ($1, $2, $3, $4, $5, $6::text::timestamptz, $7::text::jsonb)", &[ &event.user_id, &event.assignment_id, @@ -836,14 +836,15 @@ mod tests { use std::fs; //use tokio::time::{sleep, Duration}; - /// Integration-style test: verify that EventCapture actually inserts into - /// the DB. + /// Integration-style test: verify that EventCapture inserts into the rich + /// capture schema used by dissertation analysis. /// /// Reads connection parameters from `capture_config.json` in the current /// working directory. Logs the config and connection details via log4rs so /// you can confirm what is used. /// - /// Run this test with: cargo test event\_capture\_inserts\_event\_into\_db + /// Run this test with: + /// cargo test event\_capture\_inserts\_rich_schema\_event\_into\_db /// -- --ignored --nocapture /// /// You must have a PostgreSQL database and a `capture_config.json` file @@ -852,7 +853,8 @@ mod tests { /// "codechat\_capture\_test", "app\_id": "integration-test" } #[tokio::test] #[ignore] - async fn event_capture_inserts_event_into_db() -> Result<(), Box> { + async fn event_capture_inserts_rich_schema_event_into_db() + -> Result<(), Box> { // Initialize logging for this test, using the same log4rs.yml as the // server. If logging is already initialized, this will just return an // error which we ignore. @@ -860,7 +862,10 @@ mod tests { // 1. Load the capture configuration from file. let cfg_text = fs::read_to_string("capture_config.json") - .expect("capture_config.json must exist in project root for this test"); + .or_else(|_| fs::read_to_string("../capture_config.json")) + .expect( + "capture_config.json must exist in the server directory or repo root for this test", + ); let cfg: CaptureConfig = serde_json::from_str(&cfg_text).expect("capture_config.json must be valid JSON"); @@ -883,64 +888,83 @@ mod tests { } }); - // Verify the events table already exists - let row = client - .query_one( - r#" - SELECT EXISTS ( - SELECT 1 - FROM information_schema.tables + let required_columns = [ + "event_id", + "sequence_number", + "schema_version", + "condition", + "course_id", + "task_id", + "session_id", + "event_source", + "language_id", + "file_hash", + "path_privacy", + "capture_mode", + "client_timestamp_ms", + "client_tz_offset_min", + "server_timestamp_ms", + ]; + for column in required_columns { + let row = client + .query_one( + r#" + SELECT data_type + FROM information_schema.columns WHERE table_schema = 'public' - AND table_name = 'events' - ) AS exists - "#, - &[], - ) - .await?; - - let exists: bool = row.get("exists"); - assert!( - exists, - "TEST SETUP ERROR: public.events table does not exist. \ - It must be created by a migration or admin step." - ); - - // Insert a single test row (this is what the app really needs) - let test_user_id = format!( - "TEST_USER_{}", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_millis() - ); - - let insert_row = client - .query_one( - r#" - INSERT INTO public.events - (user_id, assignment_id, group_id, file_path, event_type, timestamp, data) - VALUES - ($1, NULL, NULL, NULL, 'test_event', $2::timestamptz, '{"test":true}'::jsonb) - RETURNING id - "#, - &[&test_user_id, &Utc::now().to_rfc3339()], - ) - .await?; - - let inserted_id: i32 = insert_row.get("id"); - info!("TEST: inserted event id={}", inserted_id); + AND table_name = 'events' + AND column_name = $1 + "#, + &[&column], + ) + .await + .map_err(|err| { + format!( + "TEST SETUP ERROR: missing public.events.{column}; \ + run server/scripts/capture_events_schema.sql first: {err}" + ) + })?; + let data_type: String = row.get(0); + info!("TEST: public.events.{column} type={data_type}"); + } // 4. Start the EventCapture worker using the loaded config. let capture = EventCapture::new(cfg.clone())?; log::info!("TEST: EventCapture worker started."); - // 5. Log a test event. - let expected_data = json!({ "chars_typed": 123 }); + // 5. Log a schema-v2 test event with all typed analysis metadata. + let test_suffix = Utc::now().timestamp_millis().to_string(); + let expected_event_id = format!("TEST_EVENT_{test_suffix}"); + let expected_user_id = format!("TEST_USER_{test_suffix}"); + let expected_session_id = format!("TEST_SESSION_{test_suffix}"); + let expected_file_hash = format!("TEST_FILE_HASH_{test_suffix}"); + let event_timestamp = Utc::now(); + let expected_server_timestamp_ms = event_timestamp.timestamp_millis(); + let expected_client_timestamp_ms = expected_server_timestamp_ms - 50; + let expected_data = json!({ + "event_id": expected_event_id, + "sequence_number": 42, + "schema_version": 2, + "condition": "treatment", + "course_id": "ece-integration", + "task_id": "capture-schema-test", + "session_id": expected_session_id, + "event_source": "integration_test", + "language_id": "rust", + "file_hash": expected_file_hash, + "path_privacy": "sha256", + "capture_mode": "treatment", + "client_timestamp_ms": expected_client_timestamp_ms, + "client_tz_offset_min": 360, + "server_timestamp_ms": expected_server_timestamp_ms, + "chars_typed": 123, + "classification_basis": "integration_test" + }); let event = CaptureEvent::now( - "test-user".to_string(), - Some("hw1".to_string()), - Some("groupA".to_string()), - Some("/tmp/test.rs".to_string()), + expected_user_id.clone(), + Some("hw-integration".to_string()), + Some("group-integration".to_string()), + None, event_types::WRITE_DOC, expected_data.clone(), ); @@ -958,13 +982,17 @@ mod tests { match client .query_one( r#" - SELECT user_id, assignment_id, group_id, file_path, event_type, data::text + SELECT user_id, assignment_id, group_id, file_path, event_type, + event_id, sequence_number, schema_version, condition, course_id, + task_id, session_id, event_source, language_id, file_hash, + path_privacy, capture_mode, client_timestamp_ms, + client_tz_offset_min, server_timestamp_ms, data::text FROM events - WHERE user_id = $1 AND event_type = $2 + WHERE event_id = $1 ORDER BY id DESC LIMIT 1 "#, - &[&"test-user", &event_types::WRITE_DOC], + &[&expected_event_id], ) .await { @@ -978,19 +1006,49 @@ mod tests { } }; - let user_id: String = row.get(0); + let user_id: String = row.get("user_id"); let assignment_id: Option = row.get(1); let group_id: Option = row.get(2); let file_path: Option = row.get(3); let event_type: String = row.get(4); - let data_text: String = row.get(5); + let event_id: Option = row.get(5); + let sequence_number: Option = row.get(6); + let schema_version: Option = row.get(7); + let condition: Option = row.get(8); + let course_id: Option = row.get(9); + let task_id: Option = row.get(10); + let session_id: Option = row.get(11); + let event_source: Option = row.get(12); + let language_id: Option = row.get(13); + let file_hash: Option = row.get(14); + let path_privacy: Option = row.get(15); + let capture_mode: Option = row.get(16); + let client_timestamp_ms: Option = row.get(17); + let client_tz_offset_min: Option = row.get(18); + let server_timestamp_ms: Option = row.get(19); + let data_text: String = row.get(20); let data_value: serde_json::Value = serde_json::from_str(&data_text)?; - assert_eq!(user_id, "test-user"); - assert_eq!(assignment_id.as_deref(), Some("hw1")); - assert_eq!(group_id.as_deref(), Some("groupA")); - assert_eq!(file_path.as_deref(), Some("/tmp/test.rs")); + assert_eq!(user_id, expected_user_id); + assert_eq!(assignment_id.as_deref(), Some("hw-integration")); + assert_eq!(group_id.as_deref(), Some("group-integration")); + assert!(file_path.is_none()); assert_eq!(event_type, event_types::WRITE_DOC); + assert_eq!(event_id.as_deref(), Some(expected_event_id.as_str())); + assert_eq!(sequence_number, Some(42)); + assert_eq!(schema_version, Some(2)); + assert_eq!(condition.as_deref(), Some("treatment")); + assert_eq!(course_id.as_deref(), Some("ece-integration")); + assert_eq!(task_id.as_deref(), Some("capture-schema-test")); + assert_eq!(session_id.as_deref(), Some(expected_session_id.as_str())); + assert_eq!(event_source.as_deref(), Some("integration_test")); + assert_eq!(language_id.as_deref(), Some("rust")); + assert_eq!(file_hash.as_deref(), Some(expected_file_hash.as_str())); + assert_eq!(path_privacy.as_deref(), Some("sha256")); + assert_eq!(capture_mode.as_deref(), Some("treatment")); + assert_eq!(client_timestamp_ms, Some(expected_client_timestamp_ms)); + assert_eq!(client_tz_offset_min, Some(360)); + assert_eq!(server_timestamp_ms, Some(expected_server_timestamp_ms)); assert_eq!(data_value, expected_data); log::info!("✅ TEST: EventCapture integration test succeeded and wrote to database."); From 980b6eb24a49d97f35bbb40db8d374eda72d584f Mon Sep 17 00:00:00 2001 From: "JDS-WORKSTATION\\jspah" <44337821+jspahn80134@users.noreply.github.com> Date: Fri, 8 May 2026 16:34:43 -0600 Subject: [PATCH 16/45] Stabilize WebDriver message timeout macOS CI occasionally delivered the loadfile acknowledgement just after the old two-second harness timeout. Increase the shared browser message wait to five seconds so test_client does not fail on that timing edge. --- server/tests/overall_common/mod.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/server/tests/overall_common/mod.rs b/server/tests/overall_common/mod.rs index 894a4e40..f6fe9539 100644 --- a/server/tests/overall_common/mod.rs +++ b/server/tests/overall_common/mod.rs @@ -123,8 +123,9 @@ impl ExpectedMessages { } } -// Time to wait for `ExpectedMessages`. -pub const TIMEOUT: Duration = Duration::from_millis(2000); +// Time to wait for browser/WebDriver-backed client-server messages. macOS CI can +// take a little over two seconds to return loadfile acknowledgements. +pub const TIMEOUT: Duration = Duration::from_millis(5000); // ### Test harness // From f6ccbca13dc741dd02de654f719e65a987992bf1 Mon Sep 17 00:00:00 2001 From: "JDS-WORKSTATION\\jspah" <44337821+jspahn80134@users.noreply.github.com> Date: Fri, 8 May 2026 16:50:10 -0600 Subject: [PATCH 17/45] Match WebDriver test wait to client timeout The first CI rerun passed the original test_client wait but exposed the same timing issue in test_client_updates while waiting for the autosave content update. Use the client response window as the shared browser test wait budget. --- server/tests/overall_common/mod.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/server/tests/overall_common/mod.rs b/server/tests/overall_common/mod.rs index f6fe9539..49dd644b 100644 --- a/server/tests/overall_common/mod.rs +++ b/server/tests/overall_common/mod.rs @@ -123,9 +123,10 @@ impl ExpectedMessages { } } -// Time to wait for browser/WebDriver-backed client-server messages. macOS CI can -// take a little over two seconds to return loadfile acknowledgements. -pub const TIMEOUT: Duration = Duration::from_millis(5000); +// Time to wait for browser/WebDriver-backed client-server messages. This +// matches the client-side response window and gives CI enough room for autosave +// and loadfile acknowledgements under matrix load. +pub const TIMEOUT: Duration = Duration::from_millis(15000); // ### Test harness // From c7c885b12bd66405188526eeda412d5896a92995 Mon Sep 17 00:00:00 2001 From: "JDS-WORKSTATION\\jspah" <44337821+jspahn80134@users.noreply.github.com> Date: Fri, 8 May 2026 17:10:23 -0600 Subject: [PATCH 18/45] Serialize WebDriver integration tests The overall browser tests share one WebDriver endpoint and were running concurrently inside the same test binary. This was causing test_client_updates to miss its autosave content update on CI, especially macOS/Safari. Guard the harness with a shared async mutex so each browser session runs in isolation. --- server/tests/overall_common/mod.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/server/tests/overall_common/mod.rs b/server/tests/overall_common/mod.rs index 49dd644b..383a52c5 100644 --- a/server/tests/overall_common/mod.rs +++ b/server/tests/overall_common/mod.rs @@ -128,6 +128,11 @@ impl ExpectedMessages { // and loadfile acknowledgements under matrix load. pub const TIMEOUT: Duration = Duration::from_millis(15000); +// Browser-backed tests share a single WebDriver endpoint. Safari on macOS CI is +// unreliable with overlapping sessions, so serialize the harness. +pub(crate) static WEB_DRIVER_TEST_LOCK: tokio::sync::Mutex<()> = + tokio::sync::Mutex::const_new(()); + // ### Test harness // // A test harness. It runs the webdriver, the Server, opens the Client, then @@ -152,6 +157,7 @@ macro_rules! harness { // The output from calling `prep_test_dir!()`. prep_test_dir: (TempDir, PathBuf), ) -> Result<(), Box> { + let _webdriver_test_lock = $crate::overall_common::WEB_DRIVER_TEST_LOCK.lock().await; let (temp_dir, test_dir) = prep_test_dir; // The logger gets configured by (I think) // `start_webdriver_process`, which delegates to `selenium-manager`. From 686196519635df54b3ec978d2b3e0346de37911a Mon Sep 17 00:00:00 2001 From: "JDS-WORKSTATION\\jspah" <44337821+jspahn80134@users.noreply.github.com> Date: Fri, 8 May 2026 17:15:47 -0600 Subject: [PATCH 19/45] Format WebDriver test lock --- server/tests/overall_common/mod.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/server/tests/overall_common/mod.rs b/server/tests/overall_common/mod.rs index 383a52c5..9fa25b0e 100644 --- a/server/tests/overall_common/mod.rs +++ b/server/tests/overall_common/mod.rs @@ -130,8 +130,7 @@ pub const TIMEOUT: Duration = Duration::from_millis(15000); // Browser-backed tests share a single WebDriver endpoint. Safari on macOS CI is // unreliable with overlapping sessions, so serialize the harness. -pub(crate) static WEB_DRIVER_TEST_LOCK: tokio::sync::Mutex<()> = - tokio::sync::Mutex::const_new(()); +pub(crate) static WEB_DRIVER_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); // ### Test harness // From 8e7cf0065e1b84aebe5aa935c0a1838fac14ab78 Mon Sep 17 00:00:00 2001 From: "JDS-WORKSTATION\\jspah" <44337821+jspahn80134@users.noreply.github.com> Date: Mon, 11 May 2026 16:12:58 -0600 Subject: [PATCH 20/45] Address PR review feedback on capture extension Use generated Rust-backed capture wire/status types in the VS Code extension. Restore the explanatory extension comments and the current-file update after LoadFile. Keep study lifecycle commands available for automation while removing them from the Command Palette. --- client/src/shared.mts | 4 + extensions/VSCode/package.json | 24 ----- extensions/VSCode/src/extension.ts | 165 +++++++++++++++++++++-------- server/src/capture.rs | 4 +- 4 files changed, 127 insertions(+), 70 deletions(-) diff --git a/client/src/shared.mts b/client/src/shared.mts index 334fc344..5bc88d9d 100644 --- a/client/src/shared.mts +++ b/client/src/shared.mts @@ -43,6 +43,8 @@ import { CodeMirrorDocBlockTuple } from "./rust-types/CodeMirrorDocBlockTuple.js import { UpdateMessageContents } from "./rust-types/UpdateMessageContents.js"; import { ResultOkTypes } from "./rust-types/ResultOkTypes.js"; import { ResultErrTypes } from "./rust-types/ResultErrTypes.js"; +import { CaptureEventWire } from "./rust-types/CaptureEventWire.js"; +import { CaptureStatus } from "./rust-types/CaptureStatus.js"; // Manually define this, since `ts-rs` can't export `webserver.MessageResult`. type MessageResult = { Ok: ResultOkTypes } | { Err: ResultErrTypes }; @@ -55,6 +57,8 @@ export type { CodeMirror, CodeMirrorDiffable, CodeMirrorDocBlockTuple, + CaptureEventWire, + CaptureStatus, CodeChatForWeb, EditorMessage, EditorMessageContents, diff --git a/extensions/VSCode/package.json b/extensions/VSCode/package.json index 9ceabfd4..14146dd6 100644 --- a/extensions/VSCode/package.json +++ b/extensions/VSCode/package.json @@ -156,30 +156,6 @@ { "command": "extension.codeChatInsertReflectionPrompt", "title": "CodeChat: Insert Reflection Prompt" - }, - { - "command": "extension.codeChatCaptureTaskStart", - "title": "CodeChat Capture: Task Start" - }, - { - "command": "extension.codeChatCaptureTaskSubmit", - "title": "CodeChat Capture: Task Submit" - }, - { - "command": "extension.codeChatCaptureDebugTaskStart", - "title": "CodeChat Capture: Debug Task Start" - }, - { - "command": "extension.codeChatCaptureDebugTaskSubmit", - "title": "CodeChat Capture: Debug Task Submit" - }, - { - "command": "extension.codeChatCaptureHandoffStart", - "title": "CodeChat Capture: Handoff Start" - }, - { - "command": "extension.codeChatCaptureHandoffEnd", - "title": "CodeChat Capture: Handoff End" } ] }, diff --git a/extensions/VSCode/src/extension.ts b/extensions/VSCode/src/extension.ts index 64268504..4ea0cc56 100644 --- a/extensions/VSCode/src/extension.ts +++ b/extensions/VSCode/src/extension.ts @@ -41,6 +41,8 @@ import { CodeChatEditorServer, initServer } from "./index.js"; // ### Local packages import { autosave_timeout_ms, + CaptureEventWire, + CaptureStatus, EditorMessage, EditorMessageContents, KeysOfRustEnum, @@ -170,6 +172,9 @@ function classifyAtPosition( doc: vscode.TextDocument, pos: vscode.Position, ): ActivityKind { + // These helpers are only for documentation-like documents that may embed + // source snippets. Plain source files skip this branch and classify as + // code. if (DOC_LANG_IDS.has(doc.languageId)) { if (doc.languageId === "markdown") { return isInMarkdownCodeFence(doc, pos.line) ? "code" : "doc"; @@ -183,30 +188,8 @@ function classifyAtPosition( return "code"; } -// Types for sending capture events to the Rust server. This mirrors -// `CaptureEventWire` in webserver.rs. type CaptureEventData = Record; -interface CaptureEventPayload { - event_id?: string; - sequence_number?: number; - schema_version?: number; - user_id: string; - assignment_id?: string; - group_id?: string; - condition?: string; - course_id?: string; - task_id?: string; - event_source?: string; - language_id?: string; - file_hash?: string; - file_path?: string; - event_type: string; - client_timestamp_ms?: number; - client_tz_offset_min?: number; - data: CaptureEventData; -} - type CaptureMode = "treatment" | "comparison" | "capture-only"; interface StudySettings { @@ -222,17 +205,6 @@ interface StudySettings { promptTemplates: string[]; } -interface CaptureStatus { - enabled: boolean; - state: string; - queued_events: number; - persisted_events: number; - fallback_events: number; - failed_events: number; - last_error?: string | null; - fallback_path?: string | null; -} - const CAPTURE_SCHEMA_VERSION = 2; const CAPTURE_EVENT_SOURCE = "vscode_extension"; const DEFAULT_REFLECTION_PROMPTS = [ @@ -324,7 +296,7 @@ function hashText(value: string): string { function buildFileFields( filePath: string | undefined, settings: StudySettings, -): Pick { +): Pick { const document = filePath === undefined ? vscode.window.activeTextEditor?.document @@ -354,9 +326,9 @@ async function sendCaptureEvent( return; } const fileFields = buildFileFields(filePath, settings); - const payload: CaptureEventPayload = { + const payload: CaptureEventWire = { event_id: crypto.randomUUID(), - sequence_number: ++captureSequenceNumber, + sequence_number: BigInt(++captureSequenceNumber), schema_version: CAPTURE_SCHEMA_VERSION, user_id: settings.participantId, assignment_id: settings.assignmentId, @@ -367,7 +339,7 @@ async function sendCaptureEvent( event_source: CAPTURE_EVENT_SOURCE, ...fileFields, event_type: eventType, - client_timestamp_ms: Date.now(), + client_timestamp_ms: BigInt(Date.now()), client_tz_offset_min: new Date().getTimezoneOffset(), data: { ...data, @@ -383,7 +355,7 @@ async function sendCaptureEvent( } if (!captureTransportReady) { capture_output_channel?.appendLine( - `${new Date().toISOString()} capture skipped before server handshake: ${JSON.stringify(payload)}`, + `${new Date().toISOString()} capture skipped before server handshake: ${stringifyCapturePayload(payload)}`, ); return; } @@ -391,7 +363,9 @@ async function sendCaptureEvent( logCaptureEvent(payload); try { - await codeChatEditorServer.sendCaptureEvent(JSON.stringify(payload)); + await codeChatEditorServer.sendCaptureEvent( + stringifyCapturePayload(payload), + ); captureFailureLogged = false; void refreshCaptureStatus(); } catch (err) { @@ -399,9 +373,15 @@ async function sendCaptureEvent( } } -function logCaptureEvent(payload: CaptureEventPayload) { +function stringifyCapturePayload(payload: CaptureEventWire): string { + return JSON.stringify(payload, (_key, value) => + typeof value === "bigint" ? Number(value) : value, + ); +} + +function logCaptureEvent(payload: CaptureEventWire) { capture_output_channel?.appendLine( - `${new Date().toISOString()} ${JSON.stringify(payload)}`, + `${new Date().toISOString()} ${stringifyCapturePayload(payload)}`, ); } @@ -649,6 +629,9 @@ export const activate = (context: vscode.ExtensionContext) => { "extension.codeChatInsertReflectionPrompt", insertReflectionPrompt, ), + // Study lifecycle commands are registered for optional study + // automation/keybindings, but they are not contributed to the Command + // Palette. Normal users should only see status and reflection commands. vscode.commands.registerCommand( "extension.codeChatCaptureTaskStart", () => recordStudyLifecycleEvent("task_start"), @@ -877,24 +860,40 @@ export const activate = (context: vscode.ExtensionContext) => { CodeChatEditorClientLocation.html ) { if (webview_panel !== undefined) { + // As below, don't take the focus when revealing. webview_panel.reveal(undefined, true); } else { + // Create a webview panel. webview_panel = vscode.window.createWebviewPanel( "CodeChat Editor", "CodeChat Editor", { + // Without this, the focus becomes this webview; + // setting this allows the code window open + // before this command was executed to retain + // the focus and be immediately rendered. preserveFocus: true, + // Put this in the column beside the current + // column. viewColumn: vscode.ViewColumn.Beside, }, + // See + // [WebViewOptions](https://code.visualstudio.com/api/references/vscode-api#WebviewOptions). { enableScripts: true, + // Without this, the websocket connection is + // dropped when the panel is hidden. retainContextWhenHidden: true, }, ); webview_panel.onDidDispose(async () => { + // Shut down the render client when the webview + // panel closes. console_log( "CodeChat Editor extension: shut down webview.", ); + // Closing the webview abruptly closes the Client, + // which produces an error. Don't report it. quiet_next_error = true; webview_panel = undefined; await stop_client(); @@ -902,9 +901,11 @@ export const activate = (context: vscode.ExtensionContext) => { } } - // Provide a simple status display while the server is starting - // up. + // Provide a simple status display while the CodeChat Editor + // Server is starting up. if (webview_panel !== undefined) { + // If we have an ID, then the GUI is already running; don't + // replace it. webview_panel.webview.html = "

CodeChat Editor

Loading...

"; } else { @@ -929,6 +930,9 @@ export const activate = (context: vscode.ExtensionContext) => { ); await codeChatEditorServer.sendMessageOpened(hosted_in_ide); + // For the external browser, we can immediately send the + // `CurrentFile` message. For the WebView, we must first wait to + // receive the HTML for the WebView (the `ClientHtml` message). if ( codechat_client_location === CodeChatEditorClientLocation.browser @@ -948,6 +952,7 @@ export const activate = (context: vscode.ExtensionContext) => { break; } + // Parse the data into a message. const { id, message } = JSON.parse( message_raw, ) as EditorMessage; @@ -967,6 +972,7 @@ export const activate = (context: vscode.ExtensionContext) => { keys[0] as KeysOfRustEnum; const value = Object.values(message)[0]; + // Process this message. switch (key) { case "Update": { const current_update = @@ -981,11 +987,19 @@ export const activate = (context: vscode.ExtensionContext) => { if (current_update.contents !== undefined) { const source = current_update.contents.source; + // This will produce a change event, which we'll + // ignore. The change may also produce a + // selection change, which should also be + // ignored. ignore_text_document_change = true; ignore_selection_change = true; + // Use a workspace edit, since calls to + // `TextEditor.edit` must be made to the active + // editor only. const wse = new vscode.WorkspaceEdit(); + // Is this plain text, or a diff? if ("Plain" in source) { wse.replace( doc.uri, @@ -1002,6 +1016,8 @@ export const activate = (context: vscode.ExtensionContext) => { } else { assert("Diff" in source); + // If this diff was not made against the + // text we currently have, reject it. if (source.Diff.version !== version) { await sendResult(id, { OutOfSync: [ @@ -1019,14 +1035,20 @@ export const activate = (context: vscode.ExtensionContext) => { } const diffs = source.Diff.doc; for (const diff of diffs) { + // Convert from character offsets from + // the beginning of the document to a + // `Position` (line, then offset on that + // line) needed by VSCode. const from = doc.positionAt(diff.from); if (diff.to === undefined) { + // This is an insert. wse.insert( doc.uri, from, diff.insert, ); } else { + // This is a replace or delete. const to = doc.positionAt(diff.to); wse.replace( doc.uri, @@ -1040,9 +1062,13 @@ export const activate = (context: vscode.ExtensionContext) => { ignore_text_document_change = false; ignore_selection_change = false; + // Now that we've updated our text, update the + // associated version as well. version = current_update.contents.version; } + // Update the cursor and scroll position if + // provided. const editor = get_text_editor(doc); const scroll_line = current_update.scroll_position; @@ -1061,6 +1087,8 @@ export const activate = (context: vscode.ExtensionContext) => { scroll_position, scroll_position, ), + // This is still not the top of the + // viewport, but a bit below it. TextEditorRevealType.AtTop, ); } @@ -1069,6 +1097,8 @@ export const activate = (context: vscode.ExtensionContext) => { if (cursor_line !== undefined && editor) { ignore_selection_change = true; const cursor_position = new vscode.Position( + // The VSCode line is zero-based; the + // CodeMirror line is one-based. cursor_line - 1, 0, ); @@ -1147,6 +1177,7 @@ export const activate = (context: vscode.ExtensionContext) => { } case "Result": { + // Report if this was an error. const result_contents = value as MessageResult; if ("Err" in result_contents) { const err = result_contents["Err"]; @@ -1170,7 +1201,7 @@ export const activate = (context: vscode.ExtensionContext) => { } case "LoadFile": { - const [load_file, is_current] = value as [ + const [load_file, is_client_current] = value as [ string, boolean, ]; @@ -1180,7 +1211,9 @@ export const activate = (context: vscode.ExtensionContext) => { // If we have this file and the request is for the // current file to edit/view in the Client, assign a // version. - if (doc !== undefined && is_current) { + const is_current_ide = + doc !== undefined && is_client_current; + if (is_current_ide) { version = rand(); } const load_file_result: null | [string, number] = @@ -1194,6 +1227,12 @@ export const activate = (context: vscode.ExtensionContext) => { id, load_file_result, ); + // If this is the currently active file in VSCode, + // send its cursor location that VSCode + // automatically restores. + if (is_current_ide) { + send_update(false); + } break; } @@ -1207,6 +1246,8 @@ export const activate = (context: vscode.ExtensionContext) => { void startExtensionCaptureSession( active?.document.fileName, ); + // Now that the Client is loaded, send the editor's + // current file to the server. send_update(false); break; } @@ -1296,13 +1337,18 @@ const sendResult = async (id: number, result?: ResultErrTypes) => { const send_update = (this_is_dirty: boolean) => { is_dirty ||= this_is_dirty; if (can_render()) { + // Render after some inactivity: cancel any existing timer, then ... if (idle_timer !== undefined) { clearTimeout(idle_timer); } + // ... schedule a render after an auto update timeout. idle_timer = setTimeout(async () => { if (can_render()) { const ate = vscode.window.activeTextEditor; if (ate !== undefined && ate !== current_editor) { + // Send a new current file after a short delay; this allows + // the user to rapidly cycle through several editors without + // needing to reload the Client with each cycle. current_editor = ate; const current_file = ate.document.fileName; console_log( @@ -1315,16 +1361,27 @@ const send_update = (this_is_dirty: boolean) => { } catch (e) { show_error(`Error sending CurrentFile message: ${e}.`); } + // Since we just requested a new file, the contents are + // clean by definition. is_dirty = false; + // Don't send an updated cursor position until this file is + // loaded. return; } + // The + // [Position](https://code.visualstudio.com/api/references/vscode-api#Position) + // encodes the line as a zero-based value. In contrast, + // CodeMirror + // [Text.line](https://codemirror.net/docs/ref/#state.Text.line) + // is 1-based. const cursor_position = current_editor!.selection.active.line + 1; const scroll_position = current_editor!.visibleRanges[0].start.line + 1; const file_path = current_editor!.document.fileName; + // Send contents only if necessary. const option_contents: null | [string, number] = is_dirty ? [current_editor!.document.getText(), (version = rand())] : null; @@ -1396,13 +1453,31 @@ const can_render = () => { (vscode.window.activeTextEditor !== undefined || current_editor !== undefined) && codeChatEditorServer !== undefined && + // TODO: I don't think these matter -- the Server is in charge of + // sending output to the Client. (codechat_client_location === CodeChatEditorClientLocation.browser || webview_panel !== undefined) ); }; const get_document = (file_path: string) => { + // Look through all open documents to see if we have the requested file. for (const doc of vscode.workspace.textDocuments) { + // Make the possibly incorrect assumption that only Windows filesystems + // are case-insensitive; I don't know how to easily determine the + // case-sensitivity of the current filesystem without extra probing code + // (write a file in mixed case, try to open it in another mixed case.) + // Per + // [How to Work with Different Filesystems](https://nodejs.org/en/learn/manipulating-files/working-with-different-filesystems#filesystem-behavior), + // "Be wary of inferring filesystem behavior from `process.platform`. + // For example, do not assume that because your program is running on + // Darwin that you are therefore working on a case-insensitive + // filesystem (HFS+), as the user may be using a case-sensitive + // filesystem (HFSX)." + // + // The same article + // [recommends](https://nodejs.org/en/learn/manipulating-files/working-with-different-filesystems#be-prepared-for-slight-differences-in-comparison-functions) + // using `toUpperCase` for case-insensitive filename comparisons. if ( (!is_windows && doc.fileName === file_path) || (is_windows && diff --git a/server/src/capture.rs b/server/src/capture.rs index 5d41d869..ab3fee6f 100644 --- a/server/src/capture.rs +++ b/server/src/capture.rs @@ -72,6 +72,7 @@ use serde::{Deserialize, Serialize}; use std::error::Error; use tokio::sync::mpsc; use tokio_postgres::{Client, NoTls}; +use ts_rs::TS; /// Canonical event type strings. Keep these stable for analysis. pub mod event_types { @@ -179,7 +180,8 @@ fn required_env_var(name: &str) -> Result { /// Capture worker health, exposed through `/capture/status` and the VS Code /// status item. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] +#[ts(export)] pub struct CaptureStatus { pub enabled: bool, pub state: String, From f79285f3f3f7749ddd8614ecd0435ae30def1265 Mon Sep 17 00:00:00 2001 From: "JDS-WORKSTATION\\jspah" <44337821+jspahn80134@users.noreply.github.com> Date: Wed, 13 May 2026 06:58:50 -0600 Subject: [PATCH 21/45] Address capture review cleanups --- extensions/VSCode/src/extension.ts | 38 +++++++++++++----------------- 1 file changed, 17 insertions(+), 21 deletions(-) diff --git a/extensions/VSCode/src/extension.ts b/extensions/VSCode/src/extension.ts index 2abbee88..e0dfaeed 100644 --- a/extensions/VSCode/src/extension.ts +++ b/extensions/VSCode/src/extension.ts @@ -297,15 +297,12 @@ function buildFileFields( filePath: string | undefined, settings: StudySettings, ): Pick { - const document = - filePath === undefined - ? vscode.window.activeTextEditor?.document - : get_document(filePath); if (filePath === undefined) { return { - language_id: document?.languageId, + language_id: vscode.window.activeTextEditor?.document.languageId, }; } + const document = get_document(filePath); return { file_path: settings.hashFilePaths ? undefined : filePath, file_hash: settings.hashFilePaths ? hashText(filePath) : undefined, @@ -360,8 +357,6 @@ async function sendCaptureEvent( return; } - logCaptureEvent(payload); - try { await codeChatEditorServer.sendCaptureEvent( stringifyCapturePayload(payload), @@ -379,12 +374,6 @@ function stringifyCapturePayload(payload: CaptureEventWire): string { ); } -function logCaptureEvent(payload: CaptureEventWire) { - capture_output_channel?.appendLine( - `${new Date().toISOString()} ${stringifyCapturePayload(payload)}`, - ); -} - function reportCaptureFailure(message: string) { capture_output_channel?.appendLine( `${new Date().toISOString()} capture send failed: ${message}`, @@ -425,14 +414,21 @@ async function refreshCaptureStatus(): Promise { const status = JSON.parse( codeChatEditorServer.getCaptureStatus(), ) as CaptureStatus; - const label = - status.state === "database" - ? "Capture: DB" - : status.state === "fallback" - ? "Capture: Fallback" - : status.state === "starting" - ? "Capture: Starting" - : "Capture: Off"; + let label: string; + switch (status.state) { + case "database": + label = "Capture: DB"; + break; + case "fallback": + label = "Capture: Fallback"; + break; + case "starting": + label = "Capture: Starting"; + break; + default: + label = "Capture: Off"; + break; + } updateCaptureStatusBar( label, [ From 9adb84dfb613dc4a7afd2d0d6f7bb2aa273ba39a Mon Sep 17 00:00:00 2001 From: "JDS-WORKSTATION\\jspah" <44337821+jspahn80134@users.noreply.github.com> Date: Wed, 13 May 2026 08:53:57 -0600 Subject: [PATCH 22/45] Stabilize websocket test timeout --- server/src/webserver.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/webserver.rs b/server/src/webserver.rs index d2c53410..53085f58 100644 --- a/server/src/webserver.rs +++ b/server/src/webserver.rs @@ -506,7 +506,7 @@ macro_rules! queue_send_func { // The timeout for a reply from a websocket, in ms. Use a short timeout to speed // up unit tests. pub const REPLY_TIMEOUT_MS: Duration = if cfg!(test) { - Duration::from_millis(500) + Duration::from_millis(2500) } else { Duration::from_millis(15000) }; From f2759d2844e8f9c40d108230afb78631d3d6f291 Mon Sep 17 00:00:00 2001 From: "JDS-WORKSTATION\\jspah" <44337821+jspahn80134@users.noreply.github.com> Date: Wed, 13 May 2026 10:01:21 -0600 Subject: [PATCH 23/45] Make capture event type an enum --- extensions/VSCode/src/extension.ts | 7 +- server/src/capture.rs | 141 ++++++++++++++++++++++------- server/src/translation.rs | 8 +- server/src/webserver.rs | 4 +- 4 files changed, 118 insertions(+), 42 deletions(-) diff --git a/extensions/VSCode/src/extension.ts b/extensions/VSCode/src/extension.ts index e0dfaeed..ddcfc8d0 100644 --- a/extensions/VSCode/src/extension.ts +++ b/extensions/VSCode/src/extension.ts @@ -191,6 +191,7 @@ function classifyAtPosition( type CaptureEventData = Record; type CaptureMode = "treatment" | "comparison" | "capture-only"; +type CaptureEventType = CaptureEventWire["event_type"]; interface StudySettings { enabled: boolean; @@ -312,7 +313,7 @@ function buildFileFields( // Helper to send a capture event to the Rust server. async function sendCaptureEvent( - eventType: string, + eventType: CaptureEventType, filePath?: string, data: CaptureEventData = {}, ): Promise { @@ -463,7 +464,9 @@ async function showCaptureStatus(): Promise { ); } -async function recordStudyLifecycleEvent(eventType: string): Promise { +async function recordStudyLifecycleEvent( + eventType: CaptureEventType, +): Promise { const active = vscode.window.activeTextEditor; await sendCaptureEvent(eventType, active?.document.fileName, { command: eventType, diff --git a/server/src/capture.rs b/server/src/capture.rs index ab3fee6f..25513ac3 100644 --- a/server/src/capture.rs +++ b/server/src/capture.rs @@ -53,7 +53,7 @@ // integrity and versioning metadata. // * `file_path` – logical path of the file being edited. // * `file_hash` – privacy-preserving SHA-256 hash of the file path. -// * `event_type` – coarse event type (see `event_type` constants below). +// * `event_type` – coarse event type (see `CaptureEventType` below). // * `timestamp` – RFC3339 timestamp (in UTC). // * `data` – JSONB payload with event-specific details. @@ -74,26 +74,84 @@ use tokio::sync::mpsc; use tokio_postgres::{Client, NoTls}; use ts_rs::TS; -/// Canonical event type strings. Keep these stable for analysis. +/// Canonical event types. Keep the serialized strings stable for analysis. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export)] +pub enum CaptureEventType { + WriteDoc, + WriteCode, + SwitchPane, + DocSession, + Save, + Compile, + Run, + SessionStart, + SessionEnd, + CompileEnd, + RunEnd, + TaskStart, + TaskSubmit, + DebugTaskStart, + DebugTaskSubmit, + HandoffStart, + HandoffEnd, + ReflectionPromptInserted, +} + +impl CaptureEventType { + pub const fn as_str(self) -> &'static str { + match self { + Self::WriteDoc => "write_doc", + Self::WriteCode => "write_code", + Self::SwitchPane => "switch_pane", + Self::DocSession => "doc_session", + Self::Save => "save", + Self::Compile => "compile", + Self::Run => "run", + Self::SessionStart => "session_start", + Self::SessionEnd => "session_end", + Self::CompileEnd => "compile_end", + Self::RunEnd => "run_end", + Self::TaskStart => "task_start", + Self::TaskSubmit => "task_submit", + Self::DebugTaskStart => "debug_task_start", + Self::DebugTaskSubmit => "debug_task_submit", + Self::HandoffStart => "handoff_start", + Self::HandoffEnd => "handoff_end", + Self::ReflectionPromptInserted => "reflection_prompt_inserted", + } + } +} + +impl std::fmt::Display for CaptureEventType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + pub mod event_types { - pub const WRITE_DOC: &str = "write_doc"; - pub const WRITE_CODE: &str = "write_code"; - pub const SWITCH_PANE: &str = "switch_pane"; - pub const DOC_SESSION: &str = "doc_session"; // duration of reflective writing - pub const SAVE: &str = "save"; - pub const COMPILE: &str = "compile"; - pub const RUN: &str = "run"; - pub const SESSION_START: &str = "session_start"; - pub const SESSION_END: &str = "session_end"; - pub const COMPILE_END: &str = "compile_end"; - pub const RUN_END: &str = "run_end"; - pub const TASK_START: &str = "task_start"; - pub const TASK_SUBMIT: &str = "task_submit"; - pub const DEBUG_TASK_START: &str = "debug_task_start"; - pub const DEBUG_TASK_SUBMIT: &str = "debug_task_submit"; - pub const HANDOFF_START: &str = "handoff_start"; - pub const HANDOFF_END: &str = "handoff_end"; - pub const REFLECTION_PROMPT_INSERTED: &str = "reflection_prompt_inserted"; + use super::CaptureEventType; + + pub const WRITE_DOC: CaptureEventType = CaptureEventType::WriteDoc; + pub const WRITE_CODE: CaptureEventType = CaptureEventType::WriteCode; + pub const SWITCH_PANE: CaptureEventType = CaptureEventType::SwitchPane; + pub const DOC_SESSION: CaptureEventType = CaptureEventType::DocSession; + pub const SAVE: CaptureEventType = CaptureEventType::Save; + pub const COMPILE: CaptureEventType = CaptureEventType::Compile; + pub const RUN: CaptureEventType = CaptureEventType::Run; + pub const SESSION_START: CaptureEventType = CaptureEventType::SessionStart; + pub const SESSION_END: CaptureEventType = CaptureEventType::SessionEnd; + pub const COMPILE_END: CaptureEventType = CaptureEventType::CompileEnd; + pub const RUN_END: CaptureEventType = CaptureEventType::RunEnd; + pub const TASK_START: CaptureEventType = CaptureEventType::TaskStart; + pub const TASK_SUBMIT: CaptureEventType = CaptureEventType::TaskSubmit; + pub const DEBUG_TASK_START: CaptureEventType = CaptureEventType::DebugTaskStart; + pub const DEBUG_TASK_SUBMIT: CaptureEventType = CaptureEventType::DebugTaskSubmit; + pub const HANDOFF_START: CaptureEventType = CaptureEventType::HandoffStart; + pub const HANDOFF_END: CaptureEventType = CaptureEventType::HandoffEnd; + pub const REFLECTION_PROMPT_INSERTED: CaptureEventType = + CaptureEventType::ReflectionPromptInserted; } /// Configuration used to construct the PostgreSQL connection string. @@ -228,7 +286,7 @@ pub struct CaptureEvent { pub assignment_id: Option, pub group_id: Option, pub file_path: Option, - pub event_type: String, + pub event_type: CaptureEventType, /// When the event occurred, in UTC. pub timestamp: DateTime, /// Event-specific payload, stored as JSON text in the DB. @@ -242,7 +300,7 @@ impl CaptureEvent { assignment_id: Option, group_id: Option, file_path: Option, - event_type: impl Into, + event_type: CaptureEventType, timestamp: DateTime, data: serde_json::Value, ) -> Self { @@ -251,7 +309,7 @@ impl CaptureEvent { assignment_id, group_id, file_path, - event_type: event_type.into(), + event_type, timestamp, data, } @@ -263,7 +321,7 @@ impl CaptureEvent { assignment_id: Option, group_id: Option, file_path: Option, - event_type: impl Into, + event_type: CaptureEventType, data: serde_json::Value, ) -> Self { Self::new( @@ -516,7 +574,7 @@ fn append_fallback_event(fallback_path: &Path, event: &CaptureEvent) -> io::Resu "assignment_id": event.assignment_id, "group_id": event.group_id, "file_path": event.file_path, - "event_type": event.event_type, + "event_type": event.event_type.as_str(), "timestamp": event.timestamp.to_rfc3339(), "data": event.data, } @@ -628,10 +686,11 @@ async fn insert_rich_event( let server_timestamp_ms = capture_data_i64(&event.data, "server_timestamp_ms") .unwrap_or_else(|| event.timestamp.timestamp_millis()); let data_text = event.data.to_string(); + let event_type = event.event_type.as_str(); debug!( "Capture: executing rich INSERT for user_id={}, event_type={}, timestamp={}", - event.user_id, event.event_type, timestamp + event.user_id, event_type, timestamp ); client @@ -665,7 +724,7 @@ async fn insert_rich_event( &event.file_path, &path_privacy, &capture_mode, - &event.event_type, + &event_type, ×tamp, &client_timestamp_ms, &client_tz_offset_min, @@ -682,10 +741,11 @@ async fn insert_legacy_event( ) -> Result { let timestamp = event.timestamp.to_rfc3339(); let data_text = event.data.to_string(); + let event_type = event.event_type.as_str(); debug!( "Capture: executing legacy INSERT for user_id={}, event_type={}, timestamp={}", - event.user_id, event.event_type, timestamp + event.user_id, event_type, timestamp ); client @@ -698,7 +758,7 @@ async fn insert_legacy_event( &event.assignment_id, &event.group_id, &event.file_path, - &event.event_type, + &event_type, ×tamp, &data_text, ], @@ -734,6 +794,19 @@ mod tests { assert!(!cfg.redacted_summary().contains("secret")); } + #[test] + fn capture_event_type_uses_stable_serialized_strings() { + assert_eq!( + serde_json::to_value(event_types::WRITE_DOC).unwrap(), + json!("write_doc") + ); + assert_eq!( + serde_json::from_value::(json!("compile_end")).unwrap(), + event_types::COMPILE_END + ); + assert!(serde_json::from_value::(json!("random")).is_err()); + } + #[test] fn capture_event_new_sets_all_fields() { let ts = Utc::now(); @@ -743,7 +816,7 @@ mod tests { Some("lab1".to_string()), Some("groupA".to_string()), Some("/path/to/file.rs".to_string()), - "write_doc", + event_types::WRITE_DOC, ts, json!({ "chars_typed": 42 }), ); @@ -752,7 +825,7 @@ mod tests { assert_eq!(ev.assignment_id.as_deref(), Some("lab1")); assert_eq!(ev.group_id.as_deref(), Some("groupA")); assert_eq!(ev.file_path.as_deref(), Some("/path/to/file.rs")); - assert_eq!(ev.event_type, "write_doc"); + assert_eq!(ev.event_type, event_types::WRITE_DOC); assert_eq!(ev.timestamp, ts); assert_eq!(ev.data, json!({ "chars_typed": 42 })); } @@ -765,7 +838,7 @@ mod tests { None, None, None, - "save", + event_types::SAVE, json!({ "reason": "manual" }), ); let after = Utc::now(); @@ -774,7 +847,7 @@ mod tests { assert!(ev.assignment_id.is_none()); assert!(ev.group_id.is_none()); assert!(ev.file_path.is_none()); - assert_eq!(ev.event_type, "save"); + assert_eq!(ev.event_type, event_types::SAVE); assert_eq!(ev.data, json!({ "reason": "manual" })); // Timestamp sanity check: it should be between before and after @@ -1035,7 +1108,7 @@ mod tests { assert_eq!(assignment_id.as_deref(), Some("hw-integration")); assert_eq!(group_id.as_deref(), Some("group-integration")); assert!(file_path.is_none()); - assert_eq!(event_type, event_types::WRITE_DOC); + assert_eq!(event_type, event_types::WRITE_DOC.as_str()); assert_eq!(event_id.as_deref(), Some(expected_event_id.as_str())); assert_eq!(sequence_number, Some(42)); assert_eq!(schema_version, Some(2)); diff --git a/server/src/translation.rs b/server/src/translation.rs index 820cd896..08e34ce4 100644 --- a/server/src/translation.rs +++ b/server/src/translation.rs @@ -222,7 +222,7 @@ use tokio::{ // ### Local use crate::{ - capture::event_types, + capture::{CaptureEventType, event_types}, lexer::{CodeDocBlock, DocBlock, supported_languages::MARKDOWN_MODE}, processing::{ CodeChatForWeb, CodeMirror, CodeMirrorDiff, CodeMirrorDiffable, CodeMirrorDocBlock, @@ -492,7 +492,7 @@ impl CaptureContext { fn capture_event( &self, - event_type: &str, + event_type: CaptureEventType, file_path: Option, data: serde_json::Value, ) -> Option { @@ -525,7 +525,7 @@ impl CaptureContext { language_id: None, file_hash: None, file_path, - event_type: event_type.to_string(), + event_type, client_timestamp_ms: None, client_tz_offset_min: self.client_tz_offset_min, data: Some(serde_json::Value::Object(data)), @@ -814,7 +814,7 @@ impl TranslationTask { fn log_server_capture_event( &self, - event_type: &str, + event_type: CaptureEventType, file_path: &std::path::Path, data: serde_json::Value, ) { diff --git a/server/src/webserver.rs b/server/src/webserver.rs index 53085f58..3d62083b 100644 --- a/server/src/webserver.rs +++ b/server/src/webserver.rs @@ -97,7 +97,7 @@ use crate::{ }, }; -use crate::capture::{CaptureConfig, CaptureEvent, CaptureStatus, EventCapture}; +use crate::capture::{CaptureConfig, CaptureEvent, CaptureEventType, CaptureStatus, EventCapture}; use chrono::Utc; @@ -456,7 +456,7 @@ pub struct CaptureEventWire { pub file_hash: Option, #[serde(skip_serializing_if = "Option::is_none")] pub file_path: Option, - pub event_type: String, + pub event_type: CaptureEventType, /// Optional client-side timestamp (milliseconds since Unix epoch). #[serde(skip_serializing_if = "Option::is_none")] From 13649688a9a719a781beaa1f1e0ff79f5afe462a Mon Sep 17 00:00:00 2001 From: "JDS-WORKSTATION\\jspah" <44337821+jspahn80134@users.noreply.github.com> Date: Wed, 13 May 2026 10:04:22 -0600 Subject: [PATCH 24/45] Clarify capture activity classification --- extensions/VSCode/src/extension.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/extensions/VSCode/src/extension.ts b/extensions/VSCode/src/extension.ts index ddcfc8d0..50b14b7c 100644 --- a/extensions/VSCode/src/extension.ts +++ b/extensions/VSCode/src/extension.ts @@ -124,11 +124,14 @@ let codeChatEditorServer: CodeChatEditorServer | undefined; // CAPTURE (Dissertation instrumentation) // -------------------------------------- +// Capture uses these helpers only for documentation-like files. Source files +// classify directly as code; Markdown/RST get a finer split so prose edits count +// as documentation activity while embedded snippets count as code activity. function isInMarkdownCodeFence( doc: vscode.TextDocument, line: number, ): boolean { - // Very simple fence tracker: toggles when encountering \`\`\` or ~~~ at + // Very simple fence tracker: toggles when encountering ``` or ~~~ at // start of line. Good enough for dissertation instrumentation; refine later // if needed. let inFence = false; From 92144c2263afd317d144a428664f94695c5d4ce7 Mon Sep 17 00:00:00 2001 From: "JDS-WORKSTATION\\jspah" <44337821+jspahn80134@users.noreply.github.com> Date: Thu, 14 May 2026 09:32:50 -0600 Subject: [PATCH 25/45] Address capture review setup concerns --- extensions/VSCode/package.json | 51 +--- extensions/VSCode/src/extension.ts | 363 +++++++++++++++-------- server/scripts/capture_events_schema.sql | 42 ++- server/scripts/export_capture_metrics.py | 32 +- server/src/capture.rs | 175 +++++------ server/src/translation.rs | 39 +-- server/src/webserver.rs | 35 +-- 7 files changed, 359 insertions(+), 378 deletions(-) diff --git a/extensions/VSCode/package.json b/extensions/VSCode/package.json index b13603b3..b166f77b 100644 --- a/extensions/VSCode/package.json +++ b/extensions/VSCode/package.json @@ -84,59 +84,12 @@ "CodeChatEditor.Capture.ParticipantId": { "type": "string", "default": "", - "markdownDescription": "Pseudonymous participant identifier used as the capture user_id." - }, - "CodeChatEditor.Capture.AssignmentId": { - "type": "string", - "default": "", - "markdownDescription": "Assignment, lab, or task identifier attached to capture events." - }, - "CodeChatEditor.Capture.GroupId": { - "type": "string", - "default": "", - "markdownDescription": "Study group, section, or team identifier attached to capture events." - }, - "CodeChatEditor.Capture.CourseId": { - "type": "string", - "default": "", - "markdownDescription": "Course or deployment identifier attached to capture events." - }, - "CodeChatEditor.Capture.TaskId": { - "type": "string", - "default": "", - "markdownDescription": "Current study task identifier attached to capture events." - }, - "CodeChatEditor.Capture.Mode": { - "type": "string", - "default": "treatment", - "enum": [ - "treatment", - "comparison", - "capture-only" - ], - "enumDescriptions": [ - "Capture events and enable reflective prompt commands.", - "Capture events without reflective prompt scaffolding.", - "Capture events only for baseline or pilot instrumentation." - ], - "markdownDescription": "Study condition/mode attached to capture events." + "markdownDescription": "Pseudonymous participant identifier used as the capture user_id. If left blank, CodeChat generates a UUID when the student gives consent." }, "CodeChatEditor.Capture.HashFilePaths": { "type": "boolean", "default": true, "markdownDescription": "Hash local file paths before they are sent to capture storage." - }, - "CodeChatEditor.Capture.PromptTemplates": { - "type": "array", - "default": [ - "What changed in your understanding of this code?", - "What assumption are you making, and how could you test it?", - "What would another developer need to know before maintaining this?" - ], - "items": { - "type": "string" - }, - "markdownDescription": "Reflective writing prompts available in treatment mode." } } }, @@ -151,7 +104,7 @@ }, { "command": "extension.codeChatCaptureStatus", - "title": "Show CodeChat Capture Status" + "title": "Manage CodeChat Capture" }, { "command": "extension.codeChatInsertReflectionPrompt", diff --git a/extensions/VSCode/src/extension.ts b/extensions/VSCode/src/extension.ts index 50b14b7c..cc1aaeb1 100644 --- a/extensions/VSCode/src/extension.ts +++ b/extensions/VSCode/src/extension.ts @@ -191,22 +191,29 @@ function classifyAtPosition( return "code"; } +// Event-specific payload attached to a capture event. Study metadata such as +// group, course, assignment, and condition is intentionally excluded from the +// student-facing capture settings; analysis can join those values later from a +// researcher-managed participant/date mapping. type CaptureEventData = Record; -type CaptureMode = "treatment" | "comparison" | "capture-only"; +// Event names are generated from the Rust `CaptureEventType` enum, keeping the +// extension and server in sync without re-declaring the string union here. type CaptureEventType = CaptureEventWire["event_type"]; +// Student-facing capture settings. The setup is intentionally small: students +// give consent, toggle capture, and receive or reuse a pseudonymous participant +// UUID. Assignment, course, group, and study-condition metadata are inferred +// during analysis from that participant ID and event timestamps. interface StudySettings { + // True when the student wants capture enabled for the current work session. enabled: boolean; + // True after the student has consented to study capture. consentEnabled: boolean; + // Pseudonymous UUID used as the event user ID; generated when absent. participantId: string; - assignmentId?: string; - groupId?: string; - condition: CaptureMode; - courseId?: string; - taskId?: string; + // True to avoid storing raw local paths in capture events. hashFilePaths: boolean; - promptTemplates: string[]; } const CAPTURE_SCHEMA_VERSION = 2; @@ -217,15 +224,29 @@ const DEFAULT_REFLECTION_PROMPTS = [ "What would another developer need to know before maintaining this?", ]; +// Output channel used for capture diagnostics that should not interrupt normal +// editor use. let capture_output_channel: vscode.OutputChannel | undefined; +// True after the first failed send is logged to the console, suppressing repeat +// console warnings while still writing detailed failures to the output channel. let captureFailureLogged = false; +// True once the CodeChat Client and Server have completed enough startup +// handshake work for capture events to be accepted. let captureTransportReady = false; +// True after a capture-enabled extension session has emitted `session_start`. let extensionCaptureSessionStarted = false; +// Monotonic per-extension event sequence number used to order events produced +// by this VS Code session. let captureSequenceNumber = 0; +// Status bar item that reports capture health and opens the capture controls. let capture_status_bar_item: vscode.StatusBarItem | undefined; +// Timer used to refresh capture status from the running server. let capture_status_timer: NodeJS.Timeout | undefined; -// Simple classification of what the user is currently doing. +// Simple classification of what the user is currently doing. `doc` means +// prose/documentation activity, whether in a Markdown/RST document or a +// CodeChat doc block; write events from the server provide the more precise +// doc-block classification when it is available. type ActivityKind = "doc" | "code" | "other"; // Language IDs that we treat as "documentation" for the dissertation metrics. @@ -250,33 +271,11 @@ function optionalString(value: unknown): string | undefined { function loadStudySettings(): StudySettings { const config = vscode.workspace.getConfiguration("CodeChatEditor.Capture"); - const modeValue = optionalString(config.get("Mode")); - const condition: CaptureMode = - modeValue === "comparison" || modeValue === "capture-only" - ? modeValue - : "treatment"; - const promptTemplates = config.get("PromptTemplates"); - return { enabled: config.get("Enabled", false), consentEnabled: config.get("ConsentEnabled", false), participantId: optionalString(config.get("ParticipantId")) ?? "", - assignmentId: optionalString(config.get("AssignmentId")), - groupId: optionalString(config.get("GroupId")), - condition, - courseId: optionalString(config.get("CourseId")), - taskId: optionalString(config.get("TaskId")), hashFilePaths: config.get("HashFilePaths", true), - promptTemplates: - Array.isArray(promptTemplates) && promptTemplates.length > 0 - ? promptTemplates - .filter( - (prompt): prompt is string => - typeof prompt === "string", - ) - .map((prompt) => prompt.trim()) - .filter((prompt) => prompt.length > 0) - : DEFAULT_REFLECTION_PROMPTS, }; } @@ -287,12 +286,33 @@ function captureDisabledReason(settings: StudySettings): string | undefined { if (!settings.consentEnabled) { return "waiting for consent"; } - if (settings.participantId.length === 0) { - return "participant id is not configured"; - } return undefined; } +async function updateCaptureSetting( + name: string, + value: string | boolean, +): Promise { + const config = vscode.workspace.getConfiguration("CodeChatEditor.Capture"); + await config.update(name, value, vscode.ConfigurationTarget.Global); +} + +async function ensureParticipantId(): Promise { + const config = vscode.workspace.getConfiguration("CodeChatEditor.Capture"); + const existing = optionalString(config.get("ParticipantId")); + if (existing !== undefined) { + return existing; + } + + const generated = crypto.randomUUID(); + await config.update( + "ParticipantId", + generated, + vscode.ConfigurationTarget.Global, + ); + return generated; +} + function hashText(value: string): string { return crypto.createHash("sha256").update(value).digest("hex"); } @@ -326,17 +346,13 @@ async function sendCaptureEvent( updateCaptureStatusBar(`Capture: ${disabledReason}`, disabledReason); return; } + const participantId = await ensureParticipantId(); const fileFields = buildFileFields(filePath, settings); const payload: CaptureEventWire = { event_id: crypto.randomUUID(), sequence_number: BigInt(++captureSequenceNumber), schema_version: CAPTURE_SCHEMA_VERSION, - user_id: settings.participantId, - assignment_id: settings.assignmentId, - group_id: settings.groupId, - condition: settings.condition, - course_id: settings.courseId, - task_id: settings.taskId, + user_id: participantId, event_source: CAPTURE_EVENT_SOURCE, ...fileFields, event_type: eventType, @@ -345,7 +361,6 @@ async function sendCaptureEvent( data: { ...data, session_id: CAPTURE_SESSION_ID, - capture_mode: settings.condition, path_privacy: settings.hashFilePaths ? "sha256" : "plain", }, }; @@ -366,7 +381,7 @@ async function sendCaptureEvent( stringifyCapturePayload(payload), ); captureFailureLogged = false; - void refreshCaptureStatus(); + await refreshCaptureStatus(); } catch (err) { reportCaptureFailure(err instanceof Error ? err.message : String(err)); } @@ -457,16 +472,98 @@ async function refreshCaptureStatus(): Promise { } } -async function showCaptureStatus(): Promise { - await refreshCaptureStatus(); +// A status-bar QuickPick action. Each item owns the async work needed after the +// student chooses it, keeping the capture UI small and easy to scan. +interface CaptureStatusAction extends vscode.QuickPickItem { + run: () => Promise; +} + +function captureStatusDetails(): string { const tooltip = capture_status_bar_item?.tooltip; + return typeof tooltip === "string" + ? tooltip + : (tooltip?.value ?? "Capture status unavailable"); +} + +async function setCaptureEnabled(enabled: boolean): Promise { + const active = vscode.window.activeTextEditor; + const filePath = active?.document.fileName; + const settings = loadStudySettings(); + + if (enabled) { + await ensureParticipantId(); + if (!settings.consentEnabled) { + await updateCaptureSetting("ConsentEnabled", true); + } + await updateCaptureSetting("Enabled", true); + extensionCaptureSessionStarted = false; + await startExtensionCaptureSession(filePath); + vscode.window.showInformationMessage("CodeChat capture is enabled."); + } else { + await endExtensionCaptureSession(filePath, "capture_disabled"); + await updateCaptureSetting("Enabled", false); + vscode.window.showInformationMessage("CodeChat capture is off."); + } + + await refreshCaptureStatus(); +} + +async function copyParticipantId(): Promise { + const participantId = await ensureParticipantId(); + await vscode.env.clipboard.writeText(participantId); vscode.window.showInformationMessage( - typeof tooltip === "string" - ? tooltip - : (tooltip?.value ?? "Capture status unavailable"), + "CodeChat capture participant ID copied.", ); } +async function showCaptureStatus(): Promise { + await refreshCaptureStatus(); + const settings = loadStudySettings(); + const actions: CaptureStatusAction[] = []; + + if (!settings.consentEnabled) { + actions.push({ + label: "Give Consent and Enable Capture", + description: "Generate a pseudonymous participant ID if needed.", + run: () => setCaptureEnabled(true), + }); + } else if (settings.enabled) { + actions.push({ + label: "Turn Capture Off", + description: "Stop recording study events for this editor.", + run: () => setCaptureEnabled(false), + }); + } else { + actions.push({ + label: "Turn Capture On", + description: "Resume recording study events for this editor.", + run: () => setCaptureEnabled(true), + }); + } + + actions.push( + { + label: "Copy Participant ID", + description: settings.participantId || "Generate a new UUID.", + run: copyParticipantId, + }, + { + label: "Show Capture Details", + description: captureStatusDetails().split("\n")[0], + run: async () => { + vscode.window.showInformationMessage(captureStatusDetails()); + }, + }, + ); + + const selected = await vscode.window.showQuickPick(actions, { + placeHolder: "Manage CodeChat capture", + }); + if (selected !== undefined) { + await selected.run(); + } +} + async function recordStudyLifecycleEvent( eventType: CaptureEventType, ): Promise { @@ -498,21 +595,17 @@ function reflectionPromptText(languageId: string, prompt: string): string { } async function insertReflectionPrompt(): Promise { - const settings = loadStudySettings(); - if (settings.condition !== "treatment") { - vscode.window.showInformationMessage( - "Reflection prompts are disabled for this capture mode.", - ); - return; - } const editor = vscode.window.activeTextEditor; if (editor === undefined) { vscode.window.showInformationMessage("Open a text editor first."); return; } - const prompt = await vscode.window.showQuickPick(settings.promptTemplates, { - placeHolder: "Select a reflection prompt", - }); + const prompt = await vscode.window.showQuickPick( + DEFAULT_REFLECTION_PROMPTS, + { + placeHolder: "Select a reflection prompt", + }, + ); if (prompt === undefined) { return; } @@ -537,13 +630,56 @@ async function startExtensionCaptureSession(filePath?: string) { if (extensionCaptureSessionStarted) { return; } + if (captureDisabledReason(loadStudySettings()) !== undefined) { + return; + } extensionCaptureSessionStarted = true; await sendCaptureEvent("session_start", filePath, { mode: "vscode_extension", }); } -// Update activity state, emit switch + doc\_session events as needed. +async function endExtensionCaptureSession( + filePath: string | undefined, + closedBy: string, +): Promise { + if (!extensionCaptureSessionStarted) { + return; + } + await closeDocSession(filePath, closedBy); + await sendCaptureEvent("session_end", filePath, { + mode: "vscode_extension", + closed_by: closedBy, + }); + extensionCaptureSessionStarted = false; +} + +async function closeDocSession( + filePath: string | undefined, + closedBy: string, +): Promise { + if (docSessionStart === null) { + return; + } + + const durationMs = Date.now() - docSessionStart; + docSessionStart = null; + await sendCaptureEvent("doc_session", filePath, { + duration_ms: durationMs, + duration_seconds: durationMs / 1000.0, + closed_by: closedBy, + }); + await sendCaptureEvent("session_end", filePath, { + mode: "doc", + closed_by: closedBy, + }); +} + +// Update activity state and emit switch/doc-session events. Markdown/RST prose +// and CodeChat doc-block edits are both documentation activity for analysis; +// server-side write events classify CodeChat doc-block edits precisely, while +// this extension-side activity tracker uses the best cursor/file context +// available before translation. function noteActivity(kind: ActivityKind, filePath?: string) { const now = Date.now(); @@ -552,7 +688,7 @@ function noteActivity(kind: ActivityKind, filePath?: string) { if (docSessionStart === null) { // Starting a new reflective-writing session. docSessionStart = now; - void sendCaptureEvent("session_start", filePath, { + sendCaptureEvent("session_start", filePath, { mode: "doc", }); } @@ -561,11 +697,11 @@ function noteActivity(kind: ActivityKind, filePath?: string) { // Ending a reflective-writing session. const durationMs = now - docSessionStart; docSessionStart = null; - void sendCaptureEvent("doc_session", filePath, { + sendCaptureEvent("doc_session", filePath, { duration_ms: durationMs, duration_seconds: durationMs / 1000.0, }); - void sendCaptureEvent("session_end", filePath, { + sendCaptureEvent("session_end", filePath, { mode: "doc", }); } @@ -578,7 +714,7 @@ function noteActivity(kind: ActivityKind, filePath?: string) { docOrCode(kind) && kind !== lastActivityKind ) { - void sendCaptureEvent("switch_pane", filePath, { + sendCaptureEvent("switch_pane", filePath, { from: lastActivityKind, to: kind, }); @@ -603,7 +739,7 @@ export const activate = (context: vscode.ExtensionContext) => { capture_status_bar_item.command = "extension.codeChatCaptureStatus"; context.subscriptions.push(capture_status_bar_item); capture_status_timer = setInterval(() => { - void refreshCaptureStatus(); + refreshCaptureStatus(); }, 5000); context.subscriptions.push({ dispose: () => { @@ -614,13 +750,13 @@ export const activate = (context: vscode.ExtensionContext) => { }, }); context.subscriptions.push( - vscode.workspace.onDidChangeConfiguration((event) => { + vscode.workspace.onDidChangeConfiguration(async (event) => { if (event.affectsConfiguration("CodeChatEditor.Capture")) { - void refreshCaptureStatus(); + await refreshCaptureStatus(); } }), ); - void refreshCaptureStatus(); + refreshCaptureStatus(); context.subscriptions.push( vscode.commands.registerCommand( @@ -769,36 +905,10 @@ export const activate = (context: vscode.ExtensionContext) => { ), ); - // CAPTURE: end of a debug/run session. - context.subscriptions.push( - vscode.debug.onDidTerminateDebugSession((session) => { - const active = vscode.window.activeTextEditor; - const filePath = active?.document.fileName; - void sendCaptureEvent("run_end", filePath, { - sessionName: session.name, - sessionType: session.type, - }); - }), - ); - - // CAPTURE: compile/build end events via VS Code tasks. - context.subscriptions.push( - vscode.tasks.onDidEndTaskProcess((e) => { - const active = vscode.window.activeTextEditor; - const filePath = active?.document.fileName; - const task = e.execution.task; - void sendCaptureEvent("compile_end", filePath, { - taskName: task.name, - taskSource: task.source, - exitCode: e.exitCode, - }); - }), - ); - // CAPTURE: listen for file saves. context.subscriptions.push( vscode.workspace.onDidSaveTextDocument((doc) => { - void sendCaptureEvent("save", doc.fileName, { + sendCaptureEvent("save", doc.fileName, { reason: "manual_save", languageId: doc.languageId, lineCount: doc.lineCount, @@ -806,31 +916,50 @@ export const activate = (context: vscode.ExtensionContext) => { }), ); - // CAPTURE: start of a debug/run session. + // CAPTURE: start and end of a debug/run session. context.subscriptions.push( vscode.debug.onDidStartDebugSession((session) => { const active = vscode.window.activeTextEditor; const filePath = active?.document.fileName; - void sendCaptureEvent("run", filePath, { + sendCaptureEvent("run", filePath, { + sessionName: session.name, + sessionType: session.type, + }); + }), + vscode.debug.onDidTerminateDebugSession((session) => { + const active = vscode.window.activeTextEditor; + const filePath = active?.document.fileName; + sendCaptureEvent("run_end", filePath, { sessionName: session.name, sessionType: session.type, }); }), ); - // CAPTURE: compile/build events via VS Code tasks. + // CAPTURE: start and end compile/build events via VS Code + // tasks. context.subscriptions.push( vscode.tasks.onDidStartTaskProcess((e) => { const active = vscode.window.activeTextEditor; const filePath = active?.document.fileName; const task = e.execution.task; - void sendCaptureEvent("compile", filePath, { + sendCaptureEvent("compile", filePath, { taskName: task.name, taskSource: task.source, definition: task.definition, processId: e.processId, }); }), + vscode.tasks.onDidEndTaskProcess((e) => { + const active = vscode.window.activeTextEditor; + const filePath = active?.document.fileName; + const task = e.execution.task; + sendCaptureEvent("compile_end", filePath, { + taskName: task.name, + taskSource: task.source, + exitCode: e.exitCode, + }); + }), ); } @@ -922,7 +1051,7 @@ export const activate = (context: vscode.ExtensionContext) => { captureFailureLogged = false; captureTransportReady = false; extensionCaptureSessionStarted = false; - void refreshCaptureStatus(); + refreshCaptureStatus(); const hosted_in_ide = codechat_client_location === @@ -941,7 +1070,7 @@ export const activate = (context: vscode.ExtensionContext) => { ) { captureTransportReady = true; const active = vscode.window.activeTextEditor; - void startExtensionCaptureSession( + await startExtensionCaptureSession( active?.document.fileName, ); send_update(false); @@ -1255,7 +1384,7 @@ export const activate = (context: vscode.ExtensionContext) => { await sendResult(id); captureTransportReady = true; const active = vscode.window.activeTextEditor; - void startExtensionCaptureSession( + await startExtensionCaptureSession( active?.document.fileName, ); // Now that the Client is loaded, send the editor's @@ -1280,32 +1409,11 @@ export const activate = (context: vscode.ExtensionContext) => { export const deactivate = async () => { console_log("CodeChat Editor extension: deactivating."); - // CAPTURE: if we were in a doc session, close it out so duration is - // recorded. - if (docSessionStart !== null) { - const now = Date.now(); - const durationMs = now - docSessionStart; - docSessionStart = null; - const active = vscode.window.activeTextEditor; - const filePath = active?.document.fileName; - - await sendCaptureEvent("doc_session", filePath, { - duration_ms: durationMs, - duration_seconds: durationMs / 1000.0, - closed_by: "extension_deactivate", - }); - await sendCaptureEvent("session_end", filePath, { - mode: "doc", - closed_by: "extension_deactivate", - }); - } - - // CAPTURE: mark the end of an editor session. const active = vscode.window.activeTextEditor; - const endFilePath = active?.document.fileName; - await sendCaptureEvent("session_end", endFilePath, { - mode: "vscode_extension", - }); + await endExtensionCaptureSession( + active?.document.fileName, + "extension_deactivate", + ); await stop_client(); webview_panel?.dispose(); @@ -1419,13 +1527,18 @@ const send_update = (this_is_dirty: boolean) => { // well. const stop_client = async () => { console_log("CodeChat Editor extension: stopping client."); + const active = vscode.window.activeTextEditor; + await endExtensionCaptureSession( + active?.document.fileName, + "client_stopped", + ); if (codeChatEditorServer !== undefined) { console_log("CodeChat Editor extension: stopping server."); await codeChatEditorServer.stopServer(); codeChatEditorServer = undefined; } captureTransportReady = false; - void refreshCaptureStatus(); + await refreshCaptureStatus(); if (idle_timer !== undefined) { clearTimeout(idle_timer); diff --git a/server/scripts/capture_events_schema.sql b/server/scripts/capture_events_schema.sql index 8e211e9c..ddaee807 100644 --- a/server/scripts/capture_events_schema.sql +++ b/server/scripts/capture_events_schema.sql @@ -1,9 +1,11 @@ -- CodeChat capture event schema for dissertation analysis. -- --- This script is safe to run on an existing legacy `events` table. It adds the --- richer typed metadata columns, converts `timestamp` and `data` to analysis- --- friendly PostgreSQL types, and backfills typed metadata from existing JSON --- payloads where possible. +-- This script updates an existing legacy `events` table to the lean capture +-- schema used for dissertation telemetry. It converts `timestamp` and `data` to +-- analysis-friendly PostgreSQL types and backfills typed telemetry from +-- existing JSON payloads where possible. Study metadata such as course, group, +-- assignment, condition, and task is intentionally omitted: those values are +-- joined during analysis from researcher-managed participant/date mappings. BEGIN; @@ -13,18 +15,12 @@ CREATE TABLE IF NOT EXISTS public.events ( sequence_number BIGINT, schema_version INTEGER, user_id TEXT NOT NULL, - assignment_id TEXT, - group_id TEXT, - condition TEXT, - course_id TEXT, - task_id TEXT, session_id TEXT, event_source TEXT, language_id TEXT, file_hash TEXT, file_path TEXT, path_privacy TEXT, - capture_mode TEXT, event_type TEXT NOT NULL, "timestamp" TIMESTAMPTZ NOT NULL DEFAULT now(), client_timestamp_ms BIGINT, @@ -37,20 +33,23 @@ CREATE TABLE IF NOT EXISTS public.events ( ALTER TABLE public.events ADD COLUMN IF NOT EXISTS event_id TEXT; ALTER TABLE public.events ADD COLUMN IF NOT EXISTS sequence_number BIGINT; ALTER TABLE public.events ADD COLUMN IF NOT EXISTS schema_version INTEGER; -ALTER TABLE public.events ADD COLUMN IF NOT EXISTS condition TEXT; -ALTER TABLE public.events ADD COLUMN IF NOT EXISTS course_id TEXT; -ALTER TABLE public.events ADD COLUMN IF NOT EXISTS task_id TEXT; ALTER TABLE public.events ADD COLUMN IF NOT EXISTS session_id TEXT; ALTER TABLE public.events ADD COLUMN IF NOT EXISTS event_source TEXT; ALTER TABLE public.events ADD COLUMN IF NOT EXISTS language_id TEXT; ALTER TABLE public.events ADD COLUMN IF NOT EXISTS file_hash TEXT; ALTER TABLE public.events ADD COLUMN IF NOT EXISTS path_privacy TEXT; -ALTER TABLE public.events ADD COLUMN IF NOT EXISTS capture_mode TEXT; ALTER TABLE public.events ADD COLUMN IF NOT EXISTS client_timestamp_ms BIGINT; ALTER TABLE public.events ADD COLUMN IF NOT EXISTS client_tz_offset_min INTEGER; ALTER TABLE public.events ADD COLUMN IF NOT EXISTS server_timestamp_ms BIGINT; ALTER TABLE public.events ADD COLUMN IF NOT EXISTS inserted_at TIMESTAMPTZ NOT NULL DEFAULT now(); +ALTER TABLE public.events DROP COLUMN IF EXISTS assignment_id; +ALTER TABLE public.events DROP COLUMN IF EXISTS group_id; +ALTER TABLE public.events DROP COLUMN IF EXISTS condition; +ALTER TABLE public.events DROP COLUMN IF EXISTS course_id; +ALTER TABLE public.events DROP COLUMN IF EXISTS task_id; +ALTER TABLE public.events DROP COLUMN IF EXISTS capture_mode; + DO $$ DECLARE current_type TEXT; @@ -114,9 +113,6 @@ SET THEN (data->>'schema_version')::integer END ), - condition = COALESCE(condition, NULLIF(data->>'condition', '')), - course_id = COALESCE(course_id, NULLIF(data->>'course_id', '')), - task_id = COALESCE(task_id, NULLIF(data->>'task_id', '')), session_id = COALESCE(session_id, NULLIF(data->>'session_id', '')), event_source = COALESCE(event_source, NULLIF(data->>'event_source', '')), language_id = COALESCE( @@ -126,7 +122,6 @@ SET ), file_hash = COALESCE(file_hash, NULLIF(data->>'file_hash', '')), path_privacy = COALESCE(path_privacy, NULLIF(data->>'path_privacy', '')), - capture_mode = COALESCE(capture_mode, NULLIF(data->>'capture_mode', '')), client_timestamp_ms = COALESCE( client_timestamp_ms, CASE @@ -157,7 +152,7 @@ CREATE INDEX IF NOT EXISTS events_type_timestamp_idx ON public.events (event_type, "timestamp"); CREATE INDEX IF NOT EXISTS events_participant_session_idx - ON public.events (user_id, assignment_id, session_id, task_id); + ON public.events (user_id, session_id); CREATE INDEX IF NOT EXISTS events_file_hash_idx ON public.events (file_hash) @@ -171,14 +166,11 @@ CREATE INDEX IF NOT EXISTS events_data_gin_idx ON public.events USING GIN (data); COMMENT ON TABLE public.events IS - 'CodeChat dissertation capture events with typed analysis metadata and event-specific JSONB payloads.'; -COMMENT ON COLUMN public.events.user_id IS 'Participant identifier supplied by capture settings.'; -COMMENT ON COLUMN public.events.assignment_id IS 'Assignment or lab identifier supplied by capture settings.'; -COMMENT ON COLUMN public.events.group_id IS 'Study group, section, or cohort identifier.'; -COMMENT ON COLUMN public.events.condition IS 'Study condition, such as treatment, comparison, or capture-only.'; + 'CodeChat dissertation capture events. Course, group, assignment, condition, and task context are joined during analysis from participant/date mappings.'; +COMMENT ON COLUMN public.events.user_id IS 'Pseudonymous participant UUID generated or supplied by the VS Code extension.'; COMMENT ON COLUMN public.events.session_id IS 'Capture session UUID emitted by the VS Code extension.'; COMMENT ON COLUMN public.events.file_hash IS 'SHA-256 hash of the file path when path hashing is enabled.'; COMMENT ON COLUMN public.events.file_path IS 'Raw captured file path; NULL when path hashing is enabled.'; -COMMENT ON COLUMN public.events.data IS 'Event-specific JSON payload. Duplicates typed metadata for portable fallback exports.'; +COMMENT ON COLUMN public.events.data IS 'Event-specific JSON payload. Duplicates typed telemetry metadata for portable fallback exports.'; COMMIT; diff --git a/server/scripts/export_capture_metrics.py b/server/scripts/export_capture_metrics.py index e2ceea22..9c515735 100644 --- a/server/scripts/export_capture_metrics.py +++ b/server/scripts/export_capture_metrics.py @@ -55,12 +55,7 @@ IDENTITY_FIELDS = [ "user_id", - "assignment_id", - "group_id", "session_id", - "condition", - "course_id", - "task_id", ] EVENT_ROW_FIELDS = [ @@ -82,7 +77,6 @@ "file_hash", "path_privacy", "language_id", - "capture_mode", "classification_basis", "write_source", "mode", @@ -223,15 +217,11 @@ "event_id", "sequence_number", "schema_version", - "condition", - "course_id", - "task_id", "session_id", "event_source", "language_id", "file_hash", "path_privacy", - "capture_mode", "client_timestamp_ms", "client_tz_offset_min", "server_timestamp_ms", @@ -239,13 +229,8 @@ FIELD_DESCRIPTIONS = { "event_index": "One-based event order after sorting by timestamp and sequence number.", - "user_id": "Participant identifier supplied by capture settings.", - "assignment_id": "Assignment or lab identifier supplied by capture settings.", - "group_id": "Study group, section, or cohort identifier supplied by capture settings.", + "user_id": "Pseudonymous participant UUID generated or supplied by the VS Code extension.", "session_id": "Capture session UUID emitted by the VS Code extension.", - "condition": "Study condition, such as treatment, comparison, or capture-only.", - "course_id": "Course or deployment identifier supplied by capture settings.", - "task_id": "Task identifier supplied by capture settings.", "event_id": "Client-generated UUID when available.", "sequence_number": "Client-side monotonically increasing sequence number when available.", "schema_version": "Capture payload schema version.", @@ -256,14 +241,13 @@ "server_timestamp_ms": "Server-side timestamp in milliseconds since Unix epoch.", "client_tz_offset_min": "Client timezone offset from JavaScript Date().getTimezoneOffset().", "client_server_latency_ms": "Approximate server timestamp minus client timestamp.", - "elapsed_session_seconds": "Seconds since the first event in the same participant/session/task row.", - "gap_seconds": "Seconds since the prior event in the same participant/session/task row.", + "elapsed_session_seconds": "Seconds since the first event in the same participant/session row.", + "gap_seconds": "Seconds since the prior event in the same participant/session row.", "file_id": "Privacy-preserving file identifier. Uses captured file hash when available, otherwise a SHA-256 hash of the captured path.", "file_hash": "Captured SHA-256 file path hash when the extension supplied one.", "file_path": "Raw captured file path. Only exported with --include-file-paths.", "path_privacy": "Path privacy mode reported by capture settings.", "language_id": "VS Code language identifier when available.", - "capture_mode": "Capture mode reported in event data.", "classification_basis": "Server-side write-classification basis when available.", "write_source": "Write event source, such as server_translation or CodeMirror update path.", "mode": "Event-specific mode or CodeChat lexer mode.", @@ -381,8 +365,6 @@ def normalize_db_record(record: dict[str, Any]) -> dict[str, Any]: return { "user_id": record.get("user_id"), - "assignment_id": record.get("assignment_id"), - "group_id": record.get("group_id"), "file_path": record.get("file_path"), "event_type": record.get("event_type"), "timestamp": record.get("timestamp"), @@ -826,12 +808,7 @@ def normalize_event_rows( row: dict[str, Any] = { "event_index": original_index, "user_id": text_value(event.get("user_id")), - "assignment_id": text_value(event.get("assignment_id")), - "group_id": text_value(event.get("group_id")), "session_id": data_text(data, "session_id"), - "condition": data_text(data, "condition"), - "course_id": data_text(data, "course_id"), - "task_id": data_text(data, "task_id"), "event_id": data_text(data, "event_id"), "sequence_number": int_or_blank(data.get("sequence_number")), "schema_version": int_or_blank(data.get("schema_version")), @@ -852,7 +829,6 @@ def normalize_event_rows( "file_hash": file_hash, "path_privacy": data_text(data, "path_privacy"), "language_id": data_text(data, "language_id", "languageId"), - "capture_mode": data_text(data, "capture_mode"), "classification_basis": data_text(data, "classification_basis"), "write_source": data_text(data, "source"), "mode": data_text(data, "mode"), @@ -1310,9 +1286,7 @@ def task_lifecycle_rows(event_rows: Iterable[dict[str, Any]]) -> list[dict[str, rows.sort( key=lambda row: ( row["user_id"], - row["assignment_id"], row["session_id"], - row["task_id"], row["lifecycle_kind"], row["lifecycle_index"], ) diff --git a/server/src/capture.rs b/server/src/capture.rs index 25513ac3..dc072bf7 100644 --- a/server/src/capture.rs +++ b/server/src/capture.rs @@ -40,15 +40,14 @@ // // ```sql // event_id, sequence_number, schema_version, -// user_id, assignment_id, group_id, condition, course_id, task_id, session_id, -// event_source, language_id, file_hash, file_path, path_privacy, capture_mode, -// event_type, timestamp, client_timestamp_ms, client_tz_offset_min, -// server_timestamp_ms, data +// user_id, session_id, event_source, language_id, file_hash, file_path, +// path_privacy, event_type, timestamp, client_timestamp_ms, +// client_tz_offset_min, server_timestamp_ms, data // ``` // -// * `user_id` – participant identifier (student id, pseudonym, etc.). -// * `assignment_id`, `task_id` – logical assignment / task identifiers. -// * `group_id`, `condition`, `course_id` – study grouping metadata. +// * `user_id` – pseudonymous participant UUID. Course, group, assignment, and +// study condition are intentionally joined later from researcher-managed +// participant/date mappings instead of being configured by students. // * `session_id`, `event_id`, `sequence_number`, `schema_version` – event // integrity and versioning metadata. // * `file_path` – logical path of the file being edited. @@ -79,23 +78,41 @@ use ts_rs::TS; #[serde(rename_all = "snake_case")] #[ts(export)] pub enum CaptureEventType { + /// Server-classified edit to documentation/prose. WriteDoc, + /// Server-classified edit to executable source code. WriteCode, + /// Editor activity moved between documentation and code contexts. SwitchPane, + /// Duration summary for a documentation/prose activity interval. DocSession, + /// File save observed by the editor. Save, + /// Compile/build task started. Compile, + /// Debug/run session started. Run, + /// Capture or activity session started. SessionStart, + /// Capture or activity session ended. SessionEnd, + /// Compile/build task ended. CompileEnd, + /// Debug/run session ended. RunEnd, + /// Study task started by an external study workflow. TaskStart, + /// Study task submitted by an external study workflow. TaskSubmit, + /// Debugging study task started by an external study workflow. DebugTaskStart, + /// Debugging study task submitted by an external study workflow. DebugTaskSubmit, + /// Collaboration handoff interval started. HandoffStart, + /// Collaboration handoff interval ended. HandoffEnd, + /// A built-in reflection prompt was inserted into the active editor. ReflectionPromptInserted, } @@ -160,11 +177,16 @@ pub mod event_types { /// `main.rs`; this module stays agnostic. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CaptureConfig { + /// PostgreSQL host name or address. pub host: String, + /// Optional PostgreSQL port. Uses libpq's default when omitted. #[serde(default)] pub port: Option, + /// PostgreSQL user name. pub user: String, + /// PostgreSQL password. Never included in redacted summaries. pub password: String, + /// PostgreSQL database name. pub dbname: String, /// Optional: application-level identifier for this deployment (e.g., course /// code or semester). Not stored in the DB directly; callers can embed this @@ -241,13 +263,21 @@ fn required_env_var(name: &str) -> Result { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] #[ts(export)] pub struct CaptureStatus { + /// True when the capture worker is configured and accepting events. pub enabled: bool, + /// Worker state: `starting`, `database`, `fallback`, or `disabled`. pub state: String, + /// Number of events accepted into the worker queue. pub queued_events: u64, + /// Number of events inserted into PostgreSQL. pub persisted_events: u64, + /// Number of events written to the local JSONL fallback. pub fallback_events: u64, + /// Number of failed enqueue or fallback-write attempts. pub failed_events: u64, + /// Most recent capture error, if one is known. pub last_error: Option, + /// Local JSONL fallback path when fallback capture is configured. pub fallback_path: Option, } @@ -282,10 +312,11 @@ impl CaptureStatus { /// The in-memory representation of a single capture event. #[derive(Debug, Clone)] pub struct CaptureEvent { + /// Pseudonymous participant UUID supplied by the extension. pub user_id: String, - pub assignment_id: Option, - pub group_id: Option, + /// Raw file path when path hashing is disabled. pub file_path: Option, + /// Canonical type of the captured event. pub event_type: CaptureEventType, /// When the event occurred, in UTC. pub timestamp: DateTime, @@ -297,8 +328,6 @@ impl CaptureEvent { /// Convenience constructor when the caller already has a timestamp. pub fn new( user_id: String, - assignment_id: Option, - group_id: Option, file_path: Option, event_type: CaptureEventType, timestamp: DateTime, @@ -306,8 +335,6 @@ impl CaptureEvent { ) -> Self { Self { user_id, - assignment_id, - group_id, file_path, event_type, timestamp, @@ -318,21 +345,11 @@ impl CaptureEvent { /// Convenience constructor which uses the current time. pub fn now( user_id: String, - assignment_id: Option, - group_id: Option, file_path: Option, event_type: CaptureEventType, data: serde_json::Value, ) -> Self { - Self::new( - user_id, - assignment_id, - group_id, - file_path, - event_type, - Utc::now(), - data, - ) + Self::new(user_id, file_path, event_type, Utc::now(), data) } } @@ -415,12 +432,8 @@ impl EventCapture { // them into the database. while let Some(event) = rx.recv().await { debug!( - "Capture: inserting event: type={}, user_id={}, assignment_id={:?}, group_id={:?}, file_path={:?}", - event.event_type, - event.user_id, - event.assignment_id, - event.group_id, - event.file_path + "Capture: inserting event: type={}, user_id={}, file_path={:?}", + event.event_type, event.user_id, event.file_path ); if let Err(err) = insert_event(&client, &event).await { @@ -496,8 +509,8 @@ impl EventCapture { /// Enqueue an event for insertion. This is non-blocking. pub fn log(&self, event: CaptureEvent) { debug!( - "Capture: queueing event: type={}, user_id={}, assignment_id={:?}, group_id={:?}, file_path={:?}", - event.event_type, event.user_id, event.assignment_id, event.group_id, event.file_path + "Capture: queueing event: type={}, user_id={}, file_path={:?}", + event.event_type, event.user_id, event.file_path ); if let Err(err) = self.tx.send(event) { @@ -571,8 +584,6 @@ fn append_fallback_event(fallback_path: &Path, event: &CaptureEvent) -> io::Resu "fallback_timestamp": Utc::now().to_rfc3339(), "event": { "user_id": event.user_id, - "assignment_id": event.assignment_id, - "group_id": event.group_id, "file_path": event.file_path, "event_type": event.event_type.as_str(), "timestamp": event.timestamp.to_rfc3339(), @@ -672,15 +683,11 @@ async fn insert_rich_event( let event_id = capture_data_str(&event.data, &["event_id"]); let sequence_number = capture_data_i64(&event.data, "sequence_number"); let schema_version = capture_data_i32(&event.data, "schema_version"); - let condition = capture_data_str(&event.data, &["condition"]); - let course_id = capture_data_str(&event.data, &["course_id"]); - let task_id = capture_data_str(&event.data, &["task_id"]); let session_id = capture_data_str(&event.data, &["session_id"]); let event_source = capture_data_str(&event.data, &["event_source"]); let language_id = capture_data_str(&event.data, &["language_id", "languageId"]); let file_hash = capture_data_str(&event.data, &["file_hash"]); let path_privacy = capture_data_str(&event.data, &["path_privacy"]); - let capture_mode = capture_data_str(&event.data, &["capture_mode"]); let client_timestamp_ms = capture_data_i64(&event.data, "client_timestamp_ms"); let client_tz_offset_min = capture_data_i32(&event.data, "client_tz_offset_min"); let server_timestamp_ms = capture_data_i64(&event.data, "server_timestamp_ms") @@ -697,33 +704,27 @@ async fn insert_rich_event( .execute( "INSERT INTO events \ (event_id, sequence_number, schema_version, \ - user_id, assignment_id, group_id, condition, course_id, task_id, session_id, \ - event_source, language_id, file_hash, file_path, path_privacy, capture_mode, \ + user_id, session_id, \ + event_source, language_id, file_hash, file_path, path_privacy, \ event_type, timestamp, client_timestamp_ms, client_tz_offset_min, \ server_timestamp_ms, data) \ VALUES \ - ($1, $2, $3, \ - $4, $5, $6, $7, $8, $9, $10, \ - $11, $12, $13, $14, $15, $16, \ - $17, $18::text::timestamptz, $19, $20, \ - $21, $22::text::jsonb)", + ($1, $2, $3, \ + $4, $5, \ + $6, $7, $8, $9, $10, \ + $11, $12::text::timestamptz, $13, $14, \ + $15, $16::text::jsonb)", &[ &event_id, &sequence_number, &schema_version, &event.user_id, - &event.assignment_id, - &event.group_id, - &condition, - &course_id, - &task_id, &session_id, &event_source, &language_id, &file_hash, &event.file_path, &path_privacy, - &capture_mode, &event_type, ×tamp, &client_timestamp_ms, @@ -751,12 +752,10 @@ async fn insert_legacy_event( client .execute( "INSERT INTO events \ - (user_id, assignment_id, group_id, file_path, event_type, timestamp, data) \ - VALUES ($1, $2, $3, $4, $5, $6::text::timestamptz, $7::text::jsonb)", + (user_id, file_path, event_type, timestamp, data) \ + VALUES ($1, $2, $3, $4::text::timestamptz, $5::text::jsonb)", &[ &event.user_id, - &event.assignment_id, - &event.group_id, &event.file_path, &event_type, ×tamp, @@ -813,8 +812,6 @@ mod tests { let ev = CaptureEvent::new( "user123".to_string(), - Some("lab1".to_string()), - Some("groupA".to_string()), Some("/path/to/file.rs".to_string()), event_types::WRITE_DOC, ts, @@ -822,8 +819,6 @@ mod tests { ); assert_eq!(ev.user_id, "user123"); - assert_eq!(ev.assignment_id.as_deref(), Some("lab1")); - assert_eq!(ev.group_id.as_deref(), Some("groupA")); assert_eq!(ev.file_path.as_deref(), Some("/path/to/file.rs")); assert_eq!(ev.event_type, event_types::WRITE_DOC); assert_eq!(ev.timestamp, ts); @@ -836,16 +831,12 @@ mod tests { let ev = CaptureEvent::now( "user123".to_string(), None, - None, - None, event_types::SAVE, json!({ "reason": "manual" }), ); let after = Utc::now(); assert_eq!(ev.user_id, "user123"); - assert!(ev.assignment_id.is_none()); - assert!(ev.group_id.is_none()); assert!(ev.file_path.is_none()); assert_eq!(ev.event_type, event_types::SAVE); assert_eq!(ev.data, json!({ "reason": "manual" })); @@ -967,15 +958,11 @@ mod tests { "event_id", "sequence_number", "schema_version", - "condition", - "course_id", - "task_id", "session_id", "event_source", "language_id", "file_hash", "path_privacy", - "capture_mode", "client_timestamp_ms", "client_tz_offset_min", "server_timestamp_ms", @@ -1020,15 +1007,11 @@ mod tests { "event_id": expected_event_id, "sequence_number": 42, "schema_version": 2, - "condition": "treatment", - "course_id": "ece-integration", - "task_id": "capture-schema-test", "session_id": expected_session_id, "event_source": "integration_test", "language_id": "rust", "file_hash": expected_file_hash, "path_privacy": "sha256", - "capture_mode": "treatment", "client_timestamp_ms": expected_client_timestamp_ms, "client_tz_offset_min": 360, "server_timestamp_ms": expected_server_timestamp_ms, @@ -1037,8 +1020,6 @@ mod tests { }); let event = CaptureEvent::now( expected_user_id.clone(), - Some("hw-integration".to_string()), - Some("group-integration".to_string()), None, event_types::WRITE_DOC, expected_data.clone(), @@ -1057,10 +1038,10 @@ mod tests { match client .query_one( r#" - SELECT user_id, assignment_id, group_id, file_path, event_type, - event_id, sequence_number, schema_version, condition, course_id, - task_id, session_id, event_source, language_id, file_hash, - path_privacy, capture_mode, client_timestamp_ms, + SELECT user_id, file_path, event_type, + event_id, sequence_number, schema_version, + session_id, event_source, language_id, file_hash, + path_privacy, client_timestamp_ms, client_tz_offset_min, server_timestamp_ms, data::text FROM events WHERE event_id = $1 @@ -1082,45 +1063,33 @@ mod tests { }; let user_id: String = row.get("user_id"); - let assignment_id: Option = row.get(1); - let group_id: Option = row.get(2); - let file_path: Option = row.get(3); - let event_type: String = row.get(4); - let event_id: Option = row.get(5); - let sequence_number: Option = row.get(6); - let schema_version: Option = row.get(7); - let condition: Option = row.get(8); - let course_id: Option = row.get(9); - let task_id: Option = row.get(10); - let session_id: Option = row.get(11); - let event_source: Option = row.get(12); - let language_id: Option = row.get(13); - let file_hash: Option = row.get(14); - let path_privacy: Option = row.get(15); - let capture_mode: Option = row.get(16); - let client_timestamp_ms: Option = row.get(17); - let client_tz_offset_min: Option = row.get(18); - let server_timestamp_ms: Option = row.get(19); - let data_text: String = row.get(20); + let file_path: Option = row.get(1); + let event_type: String = row.get(2); + let event_id: Option = row.get(3); + let sequence_number: Option = row.get(4); + let schema_version: Option = row.get(5); + let session_id: Option = row.get(6); + let event_source: Option = row.get(7); + let language_id: Option = row.get(8); + let file_hash: Option = row.get(9); + let path_privacy: Option = row.get(10); + let client_timestamp_ms: Option = row.get(11); + let client_tz_offset_min: Option = row.get(12); + let server_timestamp_ms: Option = row.get(13); + let data_text: String = row.get(14); let data_value: serde_json::Value = serde_json::from_str(&data_text)?; assert_eq!(user_id, expected_user_id); - assert_eq!(assignment_id.as_deref(), Some("hw-integration")); - assert_eq!(group_id.as_deref(), Some("group-integration")); assert!(file_path.is_none()); assert_eq!(event_type, event_types::WRITE_DOC.as_str()); assert_eq!(event_id.as_deref(), Some(expected_event_id.as_str())); assert_eq!(sequence_number, Some(42)); assert_eq!(schema_version, Some(2)); - assert_eq!(condition.as_deref(), Some("treatment")); - assert_eq!(course_id.as_deref(), Some("ece-integration")); - assert_eq!(task_id.as_deref(), Some("capture-schema-test")); assert_eq!(session_id.as_deref(), Some(expected_session_id.as_str())); assert_eq!(event_source.as_deref(), Some("integration_test")); assert_eq!(language_id.as_deref(), Some("rust")); assert_eq!(file_hash.as_deref(), Some(expected_file_hash.as_str())); assert_eq!(path_privacy.as_deref(), Some("sha256")); - assert_eq!(capture_mode.as_deref(), Some("treatment")); assert_eq!(client_timestamp_ms, Some(expected_client_timestamp_ms)); assert_eq!(client_tz_offset_min, Some(360)); assert_eq!(server_timestamp_ms, Some(expected_server_timestamp_ms)); diff --git a/server/src/translation.rs b/server/src/translation.rs index 08e34ce4..b915808f 100644 --- a/server/src/translation.rs +++ b/server/src/translation.rs @@ -437,20 +437,29 @@ struct TranslationTask { /// Has the full (non-diff) version of the current file been sent? Don't /// send diffs until this is sent. sent_full: bool, + /// Most recent capture metadata supplied by the IDE. Server-generated + /// capture events reuse this so translated write events retain the same + /// participant/session identity as the extension events. capture_context: CaptureContext, } +/// Participant and session metadata remembered from client capture events. +/// +/// The translation layer generates `write_doc`/`write_code` events after it +/// has parsed CodeChat content. Those events should share the same pseudonymous +/// participant and capture session as the extension-side events, but the server +/// should not ask students for course/group/assignment/task setup values. #[derive(Clone, Debug, Default)] struct CaptureContext { + /// Pseudonymous participant UUID from the latest client capture event. user_id: Option, - assignment_id: Option, - group_id: Option, - condition: Option, - course_id: Option, - task_id: Option, + /// Origin of the client event stream, such as the VS Code extension. event_source: Option, + /// Extension session UUID carried in the event data payload. session_id: Option, + /// Client timezone offset in minutes, retained for generated write events. client_tz_offset_min: Option, + /// Capture payload schema version from the extension. schema_version: Option, } @@ -459,21 +468,6 @@ impl CaptureContext { if !wire.user_id.trim().is_empty() { self.user_id = Some(wire.user_id.clone()); } - if let Some(assignment_id) = &wire.assignment_id { - self.assignment_id = Some(assignment_id.clone()); - } - if let Some(group_id) = &wire.group_id { - self.group_id = Some(group_id.clone()); - } - if let Some(condition) = &wire.condition { - self.condition = Some(condition.clone()); - } - if let Some(course_id) = &wire.course_id { - self.course_id = Some(course_id.clone()); - } - if let Some(task_id) = &wire.task_id { - self.task_id = Some(task_id.clone()); - } if let Some(event_source) = &wire.event_source { self.event_source = Some(event_source.clone()); } @@ -516,11 +510,6 @@ impl CaptureContext { sequence_number: None, schema_version: self.schema_version, user_id: self.user_id.clone()?, - assignment_id: self.assignment_id.clone(), - group_id: self.group_id.clone(), - condition: self.condition.clone(), - course_id: self.course_id.clone(), - task_id: self.task_id.clone(), event_source: self.event_source.clone(), language_id: None, file_hash: None, diff --git a/server/src/webserver.rs b/server/src/webserver.rs index 3d62083b..166d0afc 100644 --- a/server/src/webserver.rs +++ b/server/src/webserver.rs @@ -427,35 +427,37 @@ pub struct Credentials { /// JSON payload received from clients for capture events. /// -/// The server will supply the timestamp; clients do not need to send it. +/// The server supplies the authoritative timestamp. Study metadata such as +/// course, assignment, group, condition, and task is not part of this wire type: +/// those values are inferred later from researcher-managed mappings keyed by +/// the pseudonymous `user_id` and event timestamps. #[derive(Debug, Serialize, Deserialize, PartialEq, TS)] #[ts(export, optional_fields)] pub struct CaptureEventWire { + /// Client-generated unique event identifier. #[serde(skip_serializing_if = "Option::is_none")] pub event_id: Option, + /// Client-local event order for one extension session. #[serde(skip_serializing_if = "Option::is_none")] pub sequence_number: Option, + /// Capture payload schema version. #[serde(skip_serializing_if = "Option::is_none")] pub schema_version: Option, + /// Pseudonymous participant UUID. This is not the student's real identity. pub user_id: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub assignment_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub group_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub condition: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub course_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub task_id: Option, + /// Source of this event, such as the VS Code extension or server translation. #[serde(skip_serializing_if = "Option::is_none")] pub event_source: Option, + /// VS Code language identifier for the active file, when known. #[serde(skip_serializing_if = "Option::is_none")] pub language_id: Option, + /// SHA-256 hash of the local file path when path hashing is enabled. #[serde(skip_serializing_if = "Option::is_none")] pub file_hash: Option, + /// Raw file path only when path hashing is disabled for debugging. #[serde(skip_serializing_if = "Option::is_none")] pub file_path: Option, + /// Canonical capture event type. pub event_type: CaptureEventType, /// Optional client-side timestamp (milliseconds since Unix epoch). @@ -708,15 +710,6 @@ pub fn log_capture_event(app_state: &WebAppState, wire: CaptureEventWire) -> Cap serde_json::json!(schema_version), ); } - if let Some(condition) = &wire.condition { - map.insert("condition".to_string(), serde_json::json!(condition)); - } - if let Some(course_id) = &wire.course_id { - map.insert("course_id".to_string(), serde_json::json!(course_id)); - } - if let Some(task_id) = &wire.task_id { - map.insert("task_id".to_string(), serde_json::json!(task_id)); - } if let Some(event_source) = &wire.event_source { map.insert("event_source".to_string(), serde_json::json!(event_source)); } @@ -740,8 +733,6 @@ pub fn log_capture_event(app_state: &WebAppState, wire: CaptureEventWire) -> Cap let event = CaptureEvent { user_id: wire.user_id, - assignment_id: wire.assignment_id, - group_id: wire.group_id, file_path: wire.file_path, event_type: wire.event_type, // Server decides when the event is recorded. From 585c405fa6ecfc892a09119c2f4153385f0ed489 Mon Sep 17 00:00:00 2001 From: "JDS-WORKSTATION\\jspah" <44337821+jspahn80134@users.noreply.github.com> Date: Fri, 15 May 2026 14:10:32 -0600 Subject: [PATCH 26/45] Improve capture smoke diagnostics --- client/src/CodeChatEditor.mts | 9 +++- client/src/CodeMirror-integration.mts | 18 +++++-- extensions/VSCode/src/extension.ts | 68 ++++++++++++++++++++------- 3 files changed, 73 insertions(+), 22 deletions(-) diff --git a/client/src/CodeChatEditor.mts b/client/src/CodeChatEditor.mts index 672b8b6c..3df3bb86 100644 --- a/client/src/CodeChatEditor.mts +++ b/client/src/CodeChatEditor.mts @@ -693,8 +693,15 @@ export const on_error = (event: Event) => { let err_str: string; if (event instanceof ErrorEvent) { err_str = `${event.filename}:${event.lineno}: ${event.message}`; + if (event.error?.stack) { + err_str += `\n${event.error.stack}`; + } } else if (event instanceof PromiseRejectionEvent) { - err_str = `${event.promise} rejected: ${event.reason}`; + const reason = event.reason; + err_str = `${event.promise} rejected: ${reason}`; + if (reason instanceof Error && reason.stack) { + err_str += `\n${reason.stack}`; + } } else { err_str = `Unexpected error ${typeof event}: ${event}`; } diff --git a/client/src/CodeMirror-integration.mts b/client/src/CodeMirror-integration.mts index 62c8f061..634f5285 100644 --- a/client/src/CodeMirror-integration.mts +++ b/client/src/CodeMirror-integration.mts @@ -294,10 +294,12 @@ export const docBlockField = StateField.define({ prev.spec.widget.delimiter, typeof effect.value.contents === "string" ? effect.value.contents - : apply_diff_str( - prev.spec.widget.contents, - effect.value.contents, - ), + : Array.isArray(effect.value.contents) + ? apply_diff_str( + prev.spec.widget.contents, + effect.value.contents, + ) + : prev.spec.widget.contents, // If autosave is allowed (meaning no autosave // is not true), then this data came from the // user, not the IDE. @@ -1210,7 +1212,13 @@ export const scroll_to_line = ( }; // Apply a `StringDiff` to the before string to produce the after string. -export const apply_diff_str = (before: string, diffs: StringDiff[]) => { +export const apply_diff_str = ( + before: string, + diffs: StringDiff[] | undefined, +) => { + if (diffs === undefined) { + return before; + } // Walk from the last diff to the first. JavaScript doesn't have reverse // iteration AFAIK. let after = before; diff --git a/extensions/VSCode/src/extension.ts b/extensions/VSCode/src/extension.ts index cc1aaeb1..c2c7a7ee 100644 --- a/extensions/VSCode/src/extension.ts +++ b/extensions/VSCode/src/extension.ts @@ -334,6 +334,46 @@ function buildFileFields( }; } +function captureLog(message: string): void { + capture_output_channel?.appendLine( + `${new Date().toISOString()} ${message}`, + ); +} + +function capturePayloadSummary(payload: CaptureEventWire): string { + const data = payload.data as CaptureEventData; + return [ + `type=${payload.event_type}`, + `event_id=${payload.event_id}`, + `sequence=${payload.sequence_number?.toString()}`, + `schema=${payload.schema_version}`, + `user_id=${payload.user_id}`, + `session_id=${data.session_id}`, + `source=${payload.event_source}`, + `language=${payload.language_id ?? ""}`, + `path_privacy=${data.path_privacy ?? ""}`, + payload.file_hash ? `file_hash=${payload.file_hash}` : "", + payload.file_path ? `file_path=${payload.file_path}` : "", + ] + .filter((part) => part.length > 0) + .join(" "); +} + +function captureStatusSummary(status: CaptureStatus): string { + return [ + `state=${status.state}`, + `enabled=${status.enabled}`, + `queued=${status.queued_events}`, + `db=${status.persisted_events}`, + `fallback=${status.fallback_events}`, + `failed=${status.failed_events}`, + status.last_error ? `last_error=${status.last_error}` : "", + status.fallback_path ? `fallback_path=${status.fallback_path}` : "", + ] + .filter((part) => part.length > 0) + .join(" "); +} + // Helper to send a capture event to the Rust server. async function sendCaptureEvent( eventType: CaptureEventType, @@ -343,6 +383,7 @@ async function sendCaptureEvent( const settings = loadStudySettings(); const disabledReason = captureDisabledReason(settings); if (disabledReason !== undefined) { + captureLog(`capture skipped: ${eventType} (${disabledReason})`); updateCaptureStatusBar(`Capture: ${disabledReason}`, disabledReason); return; } @@ -366,21 +407,27 @@ async function sendCaptureEvent( }; if (codeChatEditorServer === undefined) { + captureLog( + `capture skipped: ${capturePayloadSummary(payload)} (server not running)`, + ); reportCaptureFailure("CodeChat server is not running"); return; } if (!captureTransportReady) { - capture_output_channel?.appendLine( - `${new Date().toISOString()} capture skipped before server handshake: ${stringifyCapturePayload(payload)}`, + captureLog( + `capture skipped before server handshake: ${capturePayloadSummary(payload)}`, ); return; } try { - await codeChatEditorServer.sendCaptureEvent( + const messageId = await codeChatEditorServer.sendCaptureEvent( stringifyCapturePayload(payload), ); captureFailureLogged = false; + captureLog( + `capture queued message_id=${messageId}: ${capturePayloadSummary(payload)}`, + ); await refreshCaptureStatus(); } catch (err) { reportCaptureFailure(err instanceof Error ? err.message : String(err)); @@ -450,19 +497,7 @@ async function refreshCaptureStatus(): Promise { } updateCaptureStatusBar( label, - [ - `state=${status.state}`, - `queued=${status.queued_events}`, - `db=${status.persisted_events}`, - `fallback=${status.fallback_events}`, - `failed=${status.failed_events}`, - status.last_error ? `last_error=${status.last_error}` : "", - status.fallback_path - ? `fallback_path=${status.fallback_path}` - : "", - ] - .filter((line) => line.length > 0) - .join("\n"), + captureStatusSummary(status).split(" ").join("\n"), ); } catch (err) { updateCaptureStatusBar( @@ -551,6 +586,7 @@ async function showCaptureStatus(): Promise { label: "Show Capture Details", description: captureStatusDetails().split("\n")[0], run: async () => { + captureLog(`capture status: ${captureStatusDetails()}`); vscode.window.showInformationMessage(captureStatusDetails()); }, }, From 3c13067d2b977cafc17c2c0b54c0c18faa5d1081 Mon Sep 17 00:00:00 2001 From: "JDS-WORKSTATION\\jspah" <44337821+jspahn80134@users.noreply.github.com> Date: Fri, 15 May 2026 15:57:01 -0600 Subject: [PATCH 27/45] Align capture settings state and audit events --- extensions/VSCode/package.json | 6 +- extensions/VSCode/src/extension.ts | 451 ++++++++++++++++++++--- server/scripts/export_capture_metrics.py | 39 ++ server/src/capture.rs | 9 + server/src/translation.rs | 161 +++++++- 5 files changed, 611 insertions(+), 55 deletions(-) diff --git a/extensions/VSCode/package.json b/extensions/VSCode/package.json index b166f77b..36f4c4b3 100644 --- a/extensions/VSCode/package.json +++ b/extensions/VSCode/package.json @@ -71,15 +71,15 @@ ], "markdownDescription": "Select the location of the CodeChat Editor Client. After changing this value, you **must** close then restart the CodeChat Editor extension." }, - "CodeChatEditor.Capture.Enabled": { + "CodeChatEditor.Capture.RecordStudyEvents": { "type": "boolean", "default": false, - "markdownDescription": "Enable dissertation instrumentation capture." + "markdownDescription": "Record CodeChat dissertation capture events. This defaults to off. Consent is recorded through **Manage CodeChat Capture** and also defaults to off.\n\n| Consent recorded | Record study events | What happens |\n| --- | --- | --- |\n| Off | Off | Capture is off. |\n| On | Off | Consent is retained, but recording is paused. |\n| On | On | Capture records study events. |\n| Off | On | Capture waits for consent before recording. |" }, "CodeChatEditor.Capture.ConsentEnabled": { "type": "boolean", "default": false, - "markdownDescription": "Allow capture after participant consent is recorded for the current study session." + "markdownDescription": "Record that participant consent has been given for CodeChat dissertation capture. This defaults to off and persists after setting." }, "CodeChatEditor.Capture.ParticipantId": { "type": "string", diff --git a/extensions/VSCode/src/extension.ts b/extensions/VSCode/src/extension.ts index c2c7a7ee..22b74dc7 100644 --- a/extensions/VSCode/src/extension.ts +++ b/extensions/VSCode/src/extension.ts @@ -216,8 +216,21 @@ interface StudySettings { hashFilePaths: boolean; } +// Derived state for the two user-visible capture checkboxes. This mirrors the +// table shown in Settings and is the single source of truth for whether events +// may be recorded. +type CaptureSettingsState = + | "off" + | "paused" + | "recording" + | "waitingForConsent"; + const CAPTURE_SCHEMA_VERSION = 2; const CAPTURE_EVENT_SOURCE = "vscode_extension"; +// Settings contribution key for the user-facing recording checkbox. The +// shorter `Enabled` setting is deliberately no longer used because it was too +// ambiguous next to consent. +const CAPTURE_RECORD_SETTING_NAME = "RecordStudyEvents"; const DEFAULT_REFLECTION_PROMPTS = [ "What changed in your understanding of this code?", "What assumption are you making, and how could you test it?", @@ -242,6 +255,10 @@ let captureSequenceNumber = 0; let capture_status_bar_item: vscode.StatusBarItem | undefined; // Timer used to refresh capture status from the running server. let capture_status_timer: NodeJS.Timeout | undefined; +// Last capture settings snapshot used to audit user-visible setting changes +// without double-logging when a command and VS Code's configuration event both +// observe the same transition. +let lastCaptureSettings: StudySettings | undefined; // Simple classification of what the user is currently doing. `doc` means // prose/documentation activity, whether in a Markdown/RST document or a @@ -272,19 +289,96 @@ function optionalString(value: unknown): string | undefined { function loadStudySettings(): StudySettings { const config = vscode.workspace.getConfiguration("CodeChatEditor.Capture"); return { - enabled: config.get("Enabled", false), + // Both capture settings default to false; persisted user values override + // these defaults after a student changes the Settings UI. + enabled: config.get(CAPTURE_RECORD_SETTING_NAME, false), consentEnabled: config.get("ConsentEnabled", false), participantId: optionalString(config.get("ParticipantId")) ?? "", hashFilePaths: config.get("HashFilePaths", true), }; } -function captureDisabledReason(settings: StudySettings): string | undefined { - if (!settings.enabled) { - return "disabled in settings"; +// Convert raw settings into the explicit four-row state table. Keeping this as +// a separate helper prevents callers from inventing their own partial rules. +function captureSettingsState(settings: StudySettings): CaptureSettingsState { + if (settings.consentEnabled && settings.enabled) { + return "recording"; + } + if (settings.consentEnabled) { + return "paused"; + } + if (settings.enabled) { + return "waitingForConsent"; } - if (!settings.consentEnabled) { - return "waiting for consent"; + return "off"; +} + +// Compare complete settings snapshots so command-triggered changes and VS Code +// configuration notifications do not emit duplicate audit rows. +function captureSettingsEqual(a: StudySettings, b: StudySettings): boolean { + return ( + a.enabled === b.enabled && + a.consentEnabled === b.consentEnabled && + a.participantId === b.participantId && + a.hashFilePaths === b.hashFilePaths + ); +} + +// Human-readable labels used in status-bar tooltips and QuickPick details. +function captureStateDescription(state: CaptureSettingsState): string { + switch (state) { + case "recording": + return "Capture records study events."; + case "paused": + return "Consent is retained, but recording is paused."; + case "waitingForConsent": + return "Capture waits for consent before recording."; + case "off": + return "Capture is off."; + } +} + +// Build the status bar text and tooltip from the same state table used for +// gating events. This keeps UI feedback and recording behavior aligned. +function captureSettingsStatus(settings: StudySettings): { + label: string; + tooltip: string; + state: CaptureSettingsState; +} { + const state = captureSettingsState(settings); + let label: string; + switch (state) { + case "recording": + label = "Capture: Recording"; + break; + case "paused": + label = "Capture: Paused"; + break; + case "waitingForConsent": + label = "Capture: Waiting for consent"; + break; + case "off": + label = "Capture: Off"; + break; + } + + return { + label, + state, + tooltip: [ + `Consent Enabled: ${settings.consentEnabled ? "On" : "Off"}`, + `Record Study Events: ${settings.enabled ? "On" : "Off"}`, + `State: ${captureStateDescription(state)}`, + ].join("\n"), + }; +} + +// Normal capture events are allowed only in the `recording` row. Audit and +// control events can bypass this through explicit send options. +function captureDisabledReason(settings: StudySettings): string | undefined { + const state = captureSettingsState(settings); + if (state !== "recording") { + return captureStateDescription(state); } return undefined; } @@ -374,21 +468,50 @@ function captureStatusSummary(status: CaptureStatus): string { .join(" "); } +interface CaptureSendOptions { + // Permit audit/control events even when normal capture is paused or waiting + // for consent. + ignoreCaptureSettings?: boolean; + // Update server-side capture state without inserting this event into the DB. + controlOnly?: boolean; + // Explicit active flag carried to the server so it can enable/disable + // translation-generated write events. + captureActive?: boolean; + // Audit rows for consent being turned off still need the participant ID + // that existed before the setting changed. + userId?: string; +} + // Helper to send a capture event to the Rust server. async function sendCaptureEvent( eventType: CaptureEventType, filePath?: string, data: CaptureEventData = {}, + options: CaptureSendOptions = {}, ): Promise { const settings = loadStudySettings(); const disabledReason = captureDisabledReason(settings); - if (disabledReason !== undefined) { + // User activity events stop here unless both consent and recording are on. + if (!options.ignoreCaptureSettings && disabledReason !== undefined) { captureLog(`capture skipped: ${eventType} (${disabledReason})`); - updateCaptureStatusBar(`Capture: ${disabledReason}`, disabledReason); + const status = captureSettingsStatus(settings); + updateCaptureStatusBar(status.label, status.tooltip); return; } - const participantId = await ensureParticipantId(); + // Control-only messages may run after consent is off, so they must not + // generate a fresh participant ID. + const participantId = options.userId + ? options.userId + : options.controlOnly + ? settings.participantId || "capture_control" + : await ensureParticipantId(); const fileFields = buildFileFields(filePath, settings); + // The server uses `capture_active` to decide whether it may generate + // classified write_doc/write_code rows from translated edits. + const captureActive = + options.captureActive ?? + (eventType !== "session_end" && + captureSettingsState(settings) === "recording"); const payload: CaptureEventWire = { event_id: crypto.randomUUID(), sequence_number: BigInt(++captureSequenceNumber), @@ -403,6 +526,10 @@ async function sendCaptureEvent( ...data, session_id: CAPTURE_SESSION_ID, path_privacy: settings.hashFilePaths ? "sha256" : "plain", + capture_active: captureActive, + // A control-only event updates the server's capture context but is + // intentionally not inserted into capture storage. + ...(options.controlOnly ? { capture_control_only: true } : {}), }, }; @@ -426,7 +553,7 @@ async function sendCaptureEvent( ); captureFailureLogged = false; captureLog( - `capture queued message_id=${messageId}: ${capturePayloadSummary(payload)}`, + `${options.controlOnly ? "capture control queued" : "capture queued"} message_id=${messageId}: ${capturePayloadSummary(payload)}`, ); await refreshCaptureStatus(); } catch (err) { @@ -463,15 +590,17 @@ function updateCaptureStatusBar(text: string, tooltip?: string) { async function refreshCaptureStatus(): Promise { const settings = loadStudySettings(); - const disabledReason = captureDisabledReason(settings); - if (disabledReason !== undefined) { - updateCaptureStatusBar(`Capture: ${disabledReason}`, disabledReason); + const settingsStatus = captureSettingsStatus(settings); + // When the settings are not in the recording row, the settings state is the + // authoritative status regardless of the server's DB/fallback state. + if (settingsStatus.state !== "recording") { + updateCaptureStatusBar(settingsStatus.label, settingsStatus.tooltip); return; } if (codeChatEditorServer === undefined) { updateCaptureStatusBar( "Capture: Waiting", - "CodeChat server is not running", + `${settingsStatus.tooltip}\nServer: CodeChat server is not running`, ); return; } @@ -497,7 +626,10 @@ async function refreshCaptureStatus(): Promise { } updateCaptureStatusBar( label, - captureStatusSummary(status).split(" ").join("\n"), + [ + settingsStatus.tooltip, + captureStatusSummary(status).split(" ").join("\n"), + ].join("\n"), ); } catch (err) { updateCaptureStatusBar( @@ -520,26 +652,198 @@ function captureStatusDetails(): string { : (tooltip?.value ?? "Capture status unavailable"); } -async function setCaptureEnabled(enabled: boolean): Promise { +async function setRecordStudyEvents(enabled: boolean): Promise { + // Save the previous settings before updating so the audit event can record + // exactly what changed. + const previousSettings = loadStudySettings(); + await updateCaptureSetting(CAPTURE_RECORD_SETTING_NAME, enabled); + await reconcileCaptureSettings( + "manage_capture_record_study_events", + previousSettings, + ); + + const updatedSettings = loadStudySettings(); + if (enabled && captureSettingsState(updatedSettings) === "recording") { + vscode.window.showInformationMessage( + "CodeChat capture is recording study events.", + ); + } else if (enabled) { + vscode.window.showInformationMessage( + "CodeChat capture is waiting for consent.", + ); + } else { + vscode.window.showInformationMessage( + "CodeChat capture recording is paused.", + ); + } +} + +async function setCaptureConsent(enabled: boolean): Promise { + // Save the previous settings before updating so the audit event can record + // consent transitions, including consent being turned off. + const previousSettings = loadStudySettings(); + + // Consent-on creates the pseudonymous participant ID up front, so the audit + // event and later study events use the same stable identifier. + if (enabled) { + await ensureParticipantId(); + } + await updateCaptureSetting("ConsentEnabled", enabled); + await reconcileCaptureSettings( + "manage_capture_consent_enabled", + previousSettings, + ); + + const updatedSettings = loadStudySettings(); + if (enabled && captureSettingsState(updatedSettings) === "recording") { + vscode.window.showInformationMessage( + "CodeChat capture consent is recorded and recording is on.", + ); + } else if (enabled) { + vscode.window.showInformationMessage( + "CodeChat capture consent is recorded.", + ); + } else { + vscode.window.showInformationMessage( + "CodeChat capture consent is off.", + ); + } +} + +async function giveConsentAndRecordStudyEvents(): Promise { + // This command intentionally changes both user-facing settings together, + // then lets the common reconcile path emit one combined audit event. + const previousSettings = loadStudySettings(); + + await ensureParticipantId(); + await updateCaptureSetting("ConsentEnabled", true); + await updateCaptureSetting(CAPTURE_RECORD_SETTING_NAME, true); + await reconcileCaptureSettings( + "manage_capture_give_consent_and_record", + previousSettings, + ); + vscode.window.showInformationMessage( + "CodeChat capture consent is recorded and recording is on.", + ); +} + +async function sendCaptureSettingsChangedEvent( + previous: StudySettings, + current: StudySettings, + changedBy: string, + filePath?: string, +): Promise { + // Only the consent and recording checkboxes are study-state transitions. + // Other capture settings, such as path hashing, should not create audit + // rows in the dissertation event stream. + const changedSettings: string[] = []; + if (previous.consentEnabled !== current.consentEnabled) { + changedSettings.push("ConsentEnabled"); + } + if (previous.enabled !== current.enabled) { + changedSettings.push(CAPTURE_RECORD_SETTING_NAME); + } + if (changedSettings.length === 0) { + return; + } + + // Prefer the current participant ID, but fall back to the previous value so + // turning consent off can still be attributed to the participant who opted + // out. + let participantId = current.participantId || previous.participantId; + if (current.consentEnabled && participantId.length === 0) { + participantId = await ensureParticipantId(); + } + if (participantId.length === 0) { + captureLog( + `capture settings change skipped: ${changedSettings.join(",")} (no participant id)`, + ); + return; + } + + const previousState = captureSettingsState(previous); + const currentState = captureSettingsState(current); + // This audit event is deliberately allowed even when capture is no longer + // active, because the transition itself is analytically important. + await sendCaptureEvent( + "capture_settings_changed", + filePath, + { + changed_by: changedBy, + changed_settings: changedSettings, + previous_state: previousState, + new_state: currentState, + previous_consent_enabled: previous.consentEnabled, + new_consent_enabled: current.consentEnabled, + previous_record_study_events: previous.enabled, + new_record_study_events: current.enabled, + capture_active_before: previousState === "recording", + capture_active_after: currentState === "recording", + }, + { + ignoreCaptureSettings: true, + captureActive: currentState === "recording", + userId: participantId, + }, + ); +} + +async function reconcileCaptureSettings( + changedBy: string = "settings_ui", + previousSettings?: StudySettings, +): Promise { const active = vscode.window.activeTextEditor; const filePath = active?.document.fileName; const settings = loadStudySettings(); + // The first reconciliation after activation uses the snapshot captured at + // activation; command callers may also provide the pre-change snapshot. + const previous = lastCaptureSettings ?? previousSettings; - if (enabled) { - await ensureParticipantId(); - if (!settings.consentEnabled) { - await updateCaptureSetting("ConsentEnabled", true); - } - await updateCaptureSetting("Enabled", true); - extensionCaptureSessionStarted = false; + // Commands update settings and VS Code then fires a configuration event. + // This guard keeps the DB audit trail to one row per actual transition. + if ( + lastCaptureSettings !== undefined && + captureSettingsEqual(lastCaptureSettings, settings) + ) { + await refreshCaptureStatus(); + return; + } + + // Write the audit row before changing the server active flag, so turning + // capture off records the transition but not any later edit events. + if (previous !== undefined) { + await sendCaptureSettingsChangedEvent( + previous, + settings, + changedBy, + filePath, + ); + } + + const updatedSettings = loadStudySettings(); + // Recording starts only when both checkboxes are on. + if (captureSettingsState(updatedSettings) === "recording") { await startExtensionCaptureSession(filePath); - vscode.window.showInformationMessage("CodeChat capture is enabled."); + } else if ( + // If capture was active before this transition, send a control-only stop + // so the Rust translation layer stops emitting write_doc/write_code + // events from stale context. + extensionCaptureSessionStarted || + (previous !== undefined && + captureSettingsState(previous) === "recording") + ) { + await endExtensionCaptureSession(filePath, changedBy, { + controlOnly: true, + }); } else { - await endExtensionCaptureSession(filePath, "capture_disabled"); - await updateCaptureSetting("Enabled", false); - vscode.window.showInformationMessage("CodeChat capture is off."); + // A stop-control is harmless when a server is present and keeps the + // server context inactive after settings-only transitions. + await sendCaptureStopControl(filePath, changedBy); } + // Refresh the dedupe snapshot after any participant ID generation or audit + // send that may have touched settings. + lastCaptureSettings = loadStudySettings(); await refreshCaptureStatus(); } @@ -554,28 +858,47 @@ async function copyParticipantId(): Promise { async function showCaptureStatus(): Promise { await refreshCaptureStatus(); const settings = loadStudySettings(); - const actions: CaptureStatusAction[] = []; + const settingsStatus = captureSettingsStatus(settings); + // The QuickPick exposes the same two independent switches as Settings, plus + // one convenience action that turns both on at once. + const actions: CaptureStatusAction[] = [ + { + label: "Show Current Capture State", + description: captureStateDescription(settingsStatus.state), + detail: settingsStatus.tooltip, + run: async () => { + captureLog(`capture status: ${settingsStatus.tooltip}`); + vscode.window.showInformationMessage(settingsStatus.tooltip); + }, + }, + ]; - if (!settings.consentEnabled) { - actions.push({ - label: "Give Consent and Enable Capture", - description: "Generate a pseudonymous participant ID if needed.", - run: () => setCaptureEnabled(true), - }); - } else if (settings.enabled) { + if (!settings.consentEnabled || !settings.enabled) { actions.push({ - label: "Turn Capture Off", - description: "Stop recording study events for this editor.", - run: () => setCaptureEnabled(false), - }); - } else { - actions.push({ - label: "Turn Capture On", - description: "Resume recording study events for this editor.", - run: () => setCaptureEnabled(true), + label: "Give Consent and Record Study Events", + description: "Turn both capture settings on.", + run: giveConsentAndRecordStudyEvents, }); } + actions.push({ + label: settings.consentEnabled ? "Turn Consent Off" : "Turn Consent On", + description: settings.consentEnabled + ? "Stop recording if active; keep the recording setting unchanged." + : "Record participant consent; keep the recording setting unchanged.", + run: () => setCaptureConsent(!settings.consentEnabled), + }); + + actions.push({ + label: settings.enabled + ? "Turn Record Study Events Off" + : "Turn Record Study Events On", + description: settings.enabled + ? "Stop recording; keep consent unchanged." + : "Start recording only if consent is already on.", + run: () => setRecordStudyEvents(!settings.enabled), + }); + actions.push( { label: "Copy Participant ID", @@ -669,6 +992,8 @@ async function startExtensionCaptureSession(filePath?: string) { if (captureDisabledReason(loadStudySettings()) !== undefined) { return; } + // Mark this before sending so recursive status refreshes do not emit a + // second session_start for the same extension session. extensionCaptureSessionStarted = true; await sendCaptureEvent("session_start", filePath, { mode: "vscode_extension", @@ -678,10 +1003,20 @@ async function startExtensionCaptureSession(filePath?: string) { async function endExtensionCaptureSession( filePath: string | undefined, closedBy: string, + options: { controlOnly?: boolean } = {}, ): Promise { if (!extensionCaptureSessionStarted) { return; } + if (options.controlOnly) { + // Consent/recording changes must stop server-side write classification + // without inserting a synthetic session_end row after the user opted + // out or paused recording. + docSessionStart = null; + await sendCaptureStopControl(filePath, closedBy); + extensionCaptureSessionStarted = false; + return; + } await closeDocSession(filePath, closedBy); await sendCaptureEvent("session_end", filePath, { mode: "vscode_extension", @@ -690,6 +1025,31 @@ async function endExtensionCaptureSession( extensionCaptureSessionStarted = false; } +async function sendCaptureStopControl( + filePath: string | undefined, + closedBy: string, +): Promise { + if (codeChatEditorServer === undefined || !captureTransportReady) { + return; + } + // This message is sent through the normal capture channel so the server can + // clear its active capture context, but `capture_control_only` prevents it + // from becoming a DB row. + await sendCaptureEvent( + "session_end", + filePath, + { + mode: "vscode_extension", + closed_by: closedBy, + }, + { + ignoreCaptureSettings: true, + controlOnly: true, + captureActive: false, + }, + ); +} + async function closeDocSession( filePath: string | undefined, closedBy: string, @@ -765,6 +1125,7 @@ function noteActivity(kind: ActivityKind, filePath?: string) { // This is invoked when the extension is activated. It either creates a new // CodeChat Editor Server instance or reveals the currently running one. export const activate = (context: vscode.ExtensionContext) => { + lastCaptureSettings = loadStudySettings(); capture_output_channel = vscode.window.createOutputChannel("CodeChat Capture"); context.subscriptions.push(capture_output_channel); @@ -788,7 +1149,7 @@ export const activate = (context: vscode.ExtensionContext) => { context.subscriptions.push( vscode.workspace.onDidChangeConfiguration(async (event) => { if (event.affectsConfiguration("CodeChatEditor.Capture")) { - await refreshCaptureStatus(); + await reconcileCaptureSettings("settings_ui"); } }), ); diff --git a/server/scripts/export_capture_metrics.py b/server/scripts/export_capture_metrics.py index 9c515735..c219caed 100644 --- a/server/scripts/export_capture_metrics.py +++ b/server/scripts/export_capture_metrics.py @@ -35,6 +35,7 @@ EVENT_FIELDS = [ "session_start", "session_end", + "capture_settings_changed", "write_doc", "write_code", "doc_session", @@ -94,6 +95,16 @@ "run_session_name", "run_session_type", "save_reason", + "changed_by", + "changed_settings", + "previous_state", + "new_state", + "previous_consent_enabled", + "new_consent_enabled", + "previous_record_study_events", + "new_record_study_events", + "capture_active_before", + "capture_active_after", "doc_block_count_before", "doc_block_count_after", "diff_hunks", @@ -265,6 +276,16 @@ "run_session_name": "VS Code debug/run session name.", "run_session_type": "VS Code debug/run session type.", "save_reason": "Save reason reported by the extension.", + "changed_by": "Source that changed capture settings, such as Settings UI or Manage CodeChat Capture.", + "changed_settings": "JSON array of capture settings changed by a capture_settings_changed event.", + "previous_state": "Derived capture state before a capture settings transition.", + "new_state": "Derived capture state after a capture settings transition.", + "previous_consent_enabled": "Consent setting before a capture settings transition.", + "new_consent_enabled": "Consent setting after a capture settings transition.", + "previous_record_study_events": "Record Study Events setting before a capture settings transition.", + "new_record_study_events": "Record Study Events setting after a capture settings transition.", + "capture_active_before": "Whether capture was actively recording before a settings transition.", + "capture_active_after": "Whether capture was actively recording after a settings transition.", "doc_block_count_before": "Documentation block count before a classified doc-block edit.", "doc_block_count_after": "Documentation block count after a classified doc-block edit.", "diff_hunks": "Number of text diff hunks in the event payload.", @@ -846,6 +867,24 @@ def normalize_event_rows( "run_session_name": data_text(data, "sessionName", "session_name"), "run_session_type": data_text(data, "sessionType", "session_type"), "save_reason": data_text(data, "reason"), + # Settings-change audit events make consent/recording transitions + # analyzable without inspecting raw JSON payloads. + "changed_by": data_text(data, "changed_by"), + "changed_settings": data_text(data, "changed_settings"), + "previous_state": data_text(data, "previous_state"), + "new_state": data_text(data, "new_state"), + "previous_consent_enabled": int_or_blank( + data.get("previous_consent_enabled") + ), + "new_consent_enabled": int_or_blank(data.get("new_consent_enabled")), + "previous_record_study_events": int_or_blank( + data.get("previous_record_study_events") + ), + "new_record_study_events": int_or_blank( + data.get("new_record_study_events") + ), + "capture_active_before": int_or_blank(data.get("capture_active_before")), + "capture_active_after": int_or_blank(data.get("capture_active_after")), "doc_block_count_before": int_or_blank(data.get("doc_block_count_before")), "doc_block_count_after": int_or_blank(data.get("doc_block_count_after")), "diff_hunks": diff_stats["hunks"], diff --git a/server/src/capture.rs b/server/src/capture.rs index dc072bf7..adeae690 100644 --- a/server/src/capture.rs +++ b/server/src/capture.rs @@ -96,6 +96,8 @@ pub enum CaptureEventType { SessionStart, /// Capture or activity session ended. SessionEnd, + /// Consent or recording settings changed. + CaptureSettingsChanged, /// Compile/build task ended. CompileEnd, /// Debug/run session ended. @@ -128,6 +130,7 @@ impl CaptureEventType { Self::Run => "run", Self::SessionStart => "session_start", Self::SessionEnd => "session_end", + Self::CaptureSettingsChanged => "capture_settings_changed", Self::CompileEnd => "compile_end", Self::RunEnd => "run_end", Self::TaskStart => "task_start", @@ -159,6 +162,8 @@ pub mod event_types { pub const RUN: CaptureEventType = CaptureEventType::Run; pub const SESSION_START: CaptureEventType = CaptureEventType::SessionStart; pub const SESSION_END: CaptureEventType = CaptureEventType::SessionEnd; + /// Audit row emitted when the user changes consent or recording settings. + pub const CAPTURE_SETTINGS_CHANGED: CaptureEventType = CaptureEventType::CaptureSettingsChanged; pub const COMPILE_END: CaptureEventType = CaptureEventType::CompileEnd; pub const RUN_END: CaptureEventType = CaptureEventType::RunEnd; pub const TASK_START: CaptureEventType = CaptureEventType::TaskStart; @@ -803,6 +808,10 @@ mod tests { serde_json::from_value::(json!("compile_end")).unwrap(), event_types::COMPILE_END ); + assert_eq!( + serde_json::to_value(event_types::CAPTURE_SETTINGS_CHANGED).unwrap(), + json!("capture_settings_changed") + ); assert!(serde_json::from_value::(json!("random")).is_err()); } diff --git a/server/src/translation.rs b/server/src/translation.rs index b915808f..ab411923 100644 --- a/server/src/translation.rs +++ b/server/src/translation.rs @@ -451,6 +451,10 @@ struct TranslationTask { /// should not ask students for course/group/assignment/task setup values. #[derive(Clone, Debug, Default)] struct CaptureContext { + /// True only while capture is actively recording. The translation layer + /// must not generate write events from a stale participant/session context + /// after recording or consent is turned off. + active: bool, /// Pseudonymous participant UUID from the latest client capture event. user_id: Option, /// Origin of the client event stream, such as the VS Code extension. @@ -464,7 +468,21 @@ struct CaptureContext { } impl CaptureContext { + /// Refresh server-side capture identity and active/inactive state from an + /// extension capture message. This context is used only for server-generated + /// write classification events, not for deciding whether the original + /// extension event itself is inserted. fn update_from_wire(&mut self, wire: &CaptureEventWire) { + // Session start/end are the coarse lifecycle signals; the explicit + // `capture_active` data field handles settings-change audit events that + // should be inserted while also disabling later translated writes. + match wire.event_type { + CaptureEventType::SessionStart => self.active = true, + CaptureEventType::SessionEnd => self.active = false, + _ => {} + } + // Keep the most recent participant/session metadata so translated write + // events can be joined to the same participant as extension events. if !wire.user_id.trim().is_empty() { self.user_id = Some(wire.user_id.clone()); } @@ -477,10 +495,20 @@ impl CaptureContext { if let Some(client_tz_offset_min) = wire.client_tz_offset_min { self.client_tz_offset_min = Some(client_tz_offset_min); } - if let Some(serde_json::Value::Object(data)) = &wire.data - && let Some(session_id) = data.get("session_id").and_then(serde_json::Value::as_str) - { - self.session_id = Some(session_id.to_string()); + if let Some(serde_json::Value::Object(data)) = &wire.data { + // Settings-change audit events use this flag to tell the server + // whether future translation-generated write events are allowed. + if let Some(active) = data + .get("capture_active") + .and_then(serde_json::Value::as_bool) + { + self.active = active; + } + // The extension's logical capture session ties server-classified + // write events to the same session as extension-originated events. + if let Some(session_id) = data.get("session_id").and_then(serde_json::Value::as_str) { + self.session_id = Some(session_id.to_string()); + } } } @@ -490,6 +518,13 @@ impl CaptureContext { file_path: Option, data: serde_json::Value, ) -> Option { + // Do not generate server-side write_doc/write_code rows unless the + // latest settings state says capture is actively recording. + if !self.active { + return None; + } + // Normalize arbitrary JSON payloads into objects so we can attach + // server-translation metadata consistently. let mut data = match data { serde_json::Value::Object(map) => map, other => { @@ -502,6 +537,8 @@ impl CaptureContext { data.entry("session_id".to_string()) .or_insert_with(|| serde_json::json!(session_id)); } + // Preserve any existing source field, but default server-generated + // events to `server_translation` for analysis. data.entry("source".to_string()) .or_insert_with(|| serde_json::json!("server_translation")); @@ -522,6 +559,20 @@ impl CaptureContext { } } +/// True for a capture message that should update `CaptureContext` only. These +/// messages are used to stop server-side write classification after the user +/// turns off consent or recording, without adding a synthetic DB row. +fn capture_control_only(wire: &CaptureEventWire) -> bool { + matches!( + &wire.data, + Some(serde_json::Value::Object(data)) + if data + .get("capture_control_only") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + ) +} + /// This is the processing task for the Visual Studio Code IDE. It handles all /// the core logic to moving data between the IDE and the client. #[allow(clippy::too_many_arguments)] @@ -603,8 +654,16 @@ pub async fn translation_task( EditorMessageContents::Result(_) => continue_loop = tt.ide_result(ide_message).await, EditorMessageContents::Update(_) => continue_loop = tt.ide_update(ide_message).await, EditorMessageContents::Capture(capture_event) => { + // Capture messages affect both DB storage and the + // translation-layer context used for future + // server-classified write events. + let control_only = capture_control_only(&capture_event); tt.capture_context.update_from_wire(&capture_event); - log_capture_event(&app_state, *capture_event); + if control_only { + debug!("Updated capture context from control-only IDE event."); + } else { + log_capture_event(&app_state, *capture_event); + } send_response(&tt.to_ide_tx, ide_message.id, Ok(ResultOkTypes::Void)).await; }, @@ -703,8 +762,15 @@ pub async fn translation_task( EditorMessageContents::Update(_) => continue_loop = tt.client_update(client_message).await, EditorMessageContents::Capture(capture_event) => { + // Same capture handling as IDE messages: update the + // context first, then store only non-control events. + let control_only = capture_control_only(&capture_event); tt.capture_context.update_from_wire(&capture_event); - log_capture_event(&app_state, *capture_event); + if control_only { + debug!("Updated capture context from control-only Client event."); + } else { + log_capture_event(&app_state, *capture_event); + } send_response(&tt.to_client_tx, client_message.id, Ok(ResultOkTypes::Void)).await; }, @@ -1624,7 +1690,88 @@ fn debug_shorten(val: T) -> String { // ----- #[cfg(test)] mod tests { - use crate::{processing::CodeMirrorDocBlock, translation::doc_blocks_compare}; + use crate::{ + capture::event_types, + processing::CodeMirrorDocBlock, + translation::{CaptureContext, capture_control_only, doc_blocks_compare}, + webserver::CaptureEventWire, + }; + + fn capture_wire( + event_type: crate::capture::CaptureEventType, + data: serde_json::Value, + ) -> CaptureEventWire { + // Minimal test helper for feeding lifecycle/control messages into the + // translation-layer capture context. + CaptureEventWire { + event_id: None, + sequence_number: None, + schema_version: Some(2), + user_id: "participant".to_string(), + event_source: Some("vscode_extension".to_string()), + language_id: None, + file_hash: None, + file_path: None, + event_type, + client_timestamp_ms: None, + client_tz_offset_min: Some(360), + data: Some(data), + } + } + + #[test] + fn capture_context_only_generates_events_while_active() { + let mut context = CaptureContext::default(); + // Without an active capture session, translated writes must be skipped. + assert!( + context + .capture_event(event_types::WRITE_CODE, None, serde_json::json!({})) + .is_none() + ); + + context.update_from_wire(&capture_wire( + event_types::SESSION_START, + serde_json::json!({ + "session_id": "session", + "capture_active": true, + }), + )); + // A session_start activates server-side translated write capture. + assert!( + context + .capture_event(event_types::WRITE_CODE, None, serde_json::json!({})) + .is_some() + ); + + context.update_from_wire(&capture_wire( + event_types::SESSION_END, + serde_json::json!({ + "capture_active": false, + }), + )); + // A session_end deactivates translated write capture so stale context + // cannot continue inserting DB rows. + assert!( + context + .capture_event(event_types::WRITE_CODE, None, serde_json::json!({})) + .is_none() + ); + } + + #[test] + fn capture_control_only_is_detected_from_data() { + // Control-only events are the extension's way to update server capture + // state without storing the stop signal as a normal event row. + let wire = capture_wire( + event_types::SESSION_END, + serde_json::json!({ + "capture_active": false, + "capture_control_only": true, + }), + ); + + assert!(capture_control_only(&wire)); + } #[test] fn test_x1() { From 6b058692b21e74a0599d812a651f1174df937799 Mon Sep 17 00:00:00 2001 From: "JDS-WORKSTATION\\jspah" <44337821+jspahn80134@users.noreply.github.com> Date: Sun, 17 May 2026 10:41:57 -0600 Subject: [PATCH 28/45] Remove dissertation capture export utility --- server/scripts/export_capture_metrics.py | 1487 ---------------------- 1 file changed, 1487 deletions(-) delete mode 100644 server/scripts/export_capture_metrics.py diff --git a/server/scripts/export_capture_metrics.py b/server/scripts/export_capture_metrics.py deleted file mode 100644 index c219caed..00000000 --- a/server/scripts/export_capture_metrics.py +++ /dev/null @@ -1,1487 +0,0 @@ -#!/usr/bin/env python3 -"""Export dissertation-oriented metrics from CodeChat capture events. - -Default use pulls events directly from PostgreSQL using `capture_config.json` -or the `CODECHAT_CAPTURE_*` environment variables: - - python server/scripts/export_capture_metrics.py --out capture-metrics.csv - -To produce the richer analysis dataset: - - python server/scripts/export_capture_metrics.py --dataset-dir capture-analysis - -The optional positional `input` is only for fallback JSONL logs: - - python server/scripts/export_capture_metrics.py capture-events-fallback.jsonl --out capture-metrics.csv -""" - -from __future__ import annotations - -import argparse -import csv -import hashlib -import json -import os -import re -import shutil -import subprocess -from collections import Counter, defaultdict, deque -from dataclasses import dataclass -from datetime import datetime, timezone -from pathlib import Path -from typing import Any, Iterable, Iterator - - -EVENT_FIELDS = [ - "session_start", - "session_end", - "capture_settings_changed", - "write_doc", - "write_code", - "doc_session", - "switch_pane", - "save", - "compile", - "compile_end", - "run", - "run_end", - "task_start", - "task_submit", - "debug_task_start", - "debug_task_submit", - "handoff_start", - "handoff_end", - "reflection_prompt_inserted", -] - -IDENTITY_FIELDS = [ - "user_id", - "session_id", -] - -EVENT_ROW_FIELDS = [ - "event_index", - *IDENTITY_FIELDS, - "event_id", - "sequence_number", - "schema_version", - "event_source", - "event_type", - "timestamp", - "client_timestamp_ms", - "server_timestamp_ms", - "client_tz_offset_min", - "client_server_latency_ms", - "elapsed_session_seconds", - "gap_seconds", - "file_id", - "file_hash", - "path_privacy", - "language_id", - "classification_basis", - "write_source", - "mode", - "activity_from", - "activity_to", - "duration_seconds", - "duration_ms", - "line_count", - "prompt_hash", - "prompt_length", - "command", - "task_name", - "task_source", - "exit_code", - "run_session_name", - "run_session_type", - "save_reason", - "changed_by", - "changed_settings", - "previous_state", - "new_state", - "previous_consent_enabled", - "new_consent_enabled", - "previous_record_study_events", - "new_record_study_events", - "capture_active_before", - "capture_active_after", - "doc_block_count_before", - "doc_block_count_after", - "diff_hunks", - "diff_inserted_chars", - "diff_deleted_units", - "diff_replacement_hunks", - "doc_block_transactions", - "doc_block_diff_hunks", - "doc_block_inserted_chars", - "doc_block_deleted_units", -] - -SESSION_SUMMARY_FIELDS = [ - *IDENTITY_FIELDS, - "event_count", - "first_event_at", - "last_event_at", - "active_span_seconds", - "events_per_minute", - "mean_gap_seconds", - "max_gap_seconds", - "doc_session_seconds", - "doc_session_share_of_span", - "write_events", - "doc_write_share", - *[f"{event_type}_events" for event_type in EVENT_FIELDS], - "doc_to_code_switches", - "code_to_doc_switches", - "compile_success_events", - "compile_failure_events", - "total_prompt_chars", - "unique_file_count", - "unique_language_count", - "file_ids", - "language_ids", - "event_sources", - "diff_hunks", - "diff_inserted_chars", - "diff_deleted_units", - "doc_block_transactions", - "doc_block_diff_hunks", - "doc_block_inserted_chars", - "doc_block_deleted_units", - "client_server_latency_ms_mean", - "client_server_latency_ms_max", - "first_sequence_number", - "last_sequence_number", - "missing_sequence_gaps", - "duplicate_event_ids", - "data_quality_notes", -] - -FILE_SUMMARY_FIELDS = [ - *IDENTITY_FIELDS, - "file_id", - "file_hash", - "language_id", - "path_privacy", - "event_count", - "first_event_at", - "last_event_at", - "active_span_seconds", - "doc_session_seconds", - "write_doc_events", - "write_code_events", - "save_events", - "compile_events", - "compile_end_events", - "run_events", - "run_end_events", - "switch_pane_events", - "line_count_first", - "line_count_last", - "line_count_max", - "doc_block_count_before_min", - "doc_block_count_after_last", - "classification_bases", - "write_sources", - "diff_hunks", - "diff_inserted_chars", - "diff_deleted_units", - "doc_block_transactions", - "doc_block_diff_hunks", - "doc_block_inserted_chars", - "doc_block_deleted_units", -] - -TASK_LIFECYCLE_FIELDS = [ - *IDENTITY_FIELDS, - "lifecycle_kind", - "lifecycle_index", - "completed", - "start_event_type", - "end_event_type", - "start_at", - "end_at", - "duration_seconds", - "start_event_id", - "end_event_id", - "start_file_id", - "end_file_id", - "language_id", - "command", - "data_quality_notes", -] - -LIFECYCLE_PAIRS = { - "task_start": ("task", "task_submit"), - "debug_task_start": ("debug_task", "debug_task_submit"), - "handoff_start": ("handoff", "handoff_end"), -} - -LIFECYCLE_END_TYPES = { - end_type: (kind, start_type) - for start_type, (kind, end_type) in LIFECYCLE_PAIRS.items() -} - -RAW_FILE_PATH_FIELD = "file_path" - -DB_METADATA_FIELDS = [ - "event_id", - "sequence_number", - "schema_version", - "session_id", - "event_source", - "language_id", - "file_hash", - "path_privacy", - "client_timestamp_ms", - "client_tz_offset_min", - "server_timestamp_ms", -] - -FIELD_DESCRIPTIONS = { - "event_index": "One-based event order after sorting by timestamp and sequence number.", - "user_id": "Pseudonymous participant UUID generated or supplied by the VS Code extension.", - "session_id": "Capture session UUID emitted by the VS Code extension.", - "event_id": "Client-generated UUID when available.", - "sequence_number": "Client-side monotonically increasing sequence number when available.", - "schema_version": "Capture payload schema version.", - "event_source": "Capture source, such as vscode_extension.", - "event_type": "Canonical CodeChat capture event type.", - "timestamp": "Server-recorded event timestamp.", - "client_timestamp_ms": "Client-side timestamp in milliseconds since Unix epoch.", - "server_timestamp_ms": "Server-side timestamp in milliseconds since Unix epoch.", - "client_tz_offset_min": "Client timezone offset from JavaScript Date().getTimezoneOffset().", - "client_server_latency_ms": "Approximate server timestamp minus client timestamp.", - "elapsed_session_seconds": "Seconds since the first event in the same participant/session row.", - "gap_seconds": "Seconds since the prior event in the same participant/session row.", - "file_id": "Privacy-preserving file identifier. Uses captured file hash when available, otherwise a SHA-256 hash of the captured path.", - "file_hash": "Captured SHA-256 file path hash when the extension supplied one.", - "file_path": "Raw captured file path. Only exported with --include-file-paths.", - "path_privacy": "Path privacy mode reported by capture settings.", - "language_id": "VS Code language identifier when available.", - "classification_basis": "Server-side write-classification basis when available.", - "write_source": "Write event source, such as server_translation or CodeMirror update path.", - "mode": "Event-specific mode or CodeChat lexer mode.", - "activity_from": "Previous activity kind for switch_pane events.", - "activity_to": "New activity kind for switch_pane events.", - "duration_seconds": "Event-specific duration in seconds.", - "duration_ms": "Event-specific duration in milliseconds.", - "line_count": "Document line count captured on save events.", - "prompt_hash": "SHA-256 hash of the inserted reflection prompt.", - "prompt_length": "Length of the inserted reflection prompt.", - "command": "Lifecycle command name recorded by the extension.", - "task_name": "VS Code task name for compile/build events.", - "task_source": "VS Code task source for compile/build events.", - "exit_code": "Compile/build process exit code when available.", - "run_session_name": "VS Code debug/run session name.", - "run_session_type": "VS Code debug/run session type.", - "save_reason": "Save reason reported by the extension.", - "changed_by": "Source that changed capture settings, such as Settings UI or Manage CodeChat Capture.", - "changed_settings": "JSON array of capture settings changed by a capture_settings_changed event.", - "previous_state": "Derived capture state before a capture settings transition.", - "new_state": "Derived capture state after a capture settings transition.", - "previous_consent_enabled": "Consent setting before a capture settings transition.", - "new_consent_enabled": "Consent setting after a capture settings transition.", - "previous_record_study_events": "Record Study Events setting before a capture settings transition.", - "new_record_study_events": "Record Study Events setting after a capture settings transition.", - "capture_active_before": "Whether capture was actively recording before a settings transition.", - "capture_active_after": "Whether capture was actively recording after a settings transition.", - "doc_block_count_before": "Documentation block count before a classified doc-block edit.", - "doc_block_count_after": "Documentation block count after a classified doc-block edit.", - "diff_hunks": "Number of text diff hunks in the event payload.", - "diff_inserted_chars": "Characters inserted across text diff hunks.", - "diff_deleted_units": "UTF-16 code units removed across text diff hunks.", - "diff_replacement_hunks": "Text diff hunks that both removed and inserted content.", - "doc_block_transactions": "Number of doc-block add/update/delete transactions.", - "doc_block_diff_hunks": "Nested text diff hunks inside doc-block transactions.", - "doc_block_inserted_chars": "Characters inserted inside doc-block transaction diffs.", - "doc_block_deleted_units": "UTF-16 code units removed inside doc-block transaction diffs.", - "event_count": "Number of events in the aggregate row.", - "first_event_at": "Earliest event timestamp in the aggregate row.", - "last_event_at": "Latest event timestamp in the aggregate row.", - "active_span_seconds": "Seconds between first and last event in the aggregate row.", - "events_per_minute": "Event count divided by active span in minutes.", - "mean_gap_seconds": "Mean within-session gap between consecutive timestamped events.", - "max_gap_seconds": "Largest within-session gap between consecutive timestamped events.", - "doc_session_seconds": "Total duration of doc_session events.", - "doc_session_share_of_span": "doc_session_seconds divided by active_span_seconds.", - "write_events": "write_doc_events plus write_code_events.", - "doc_write_share": "write_doc_events divided by all write events.", - "doc_to_code_switches": "switch_pane events moving from documentation to code.", - "code_to_doc_switches": "switch_pane events moving from code to documentation.", - "compile_success_events": "compile_end events with exit_code equal to 0.", - "compile_failure_events": "compile_end events with nonzero exit_code.", - "total_prompt_chars": "Sum of reflection prompt lengths.", - "unique_file_count": "Number of distinct file_id values in the aggregate row.", - "unique_language_count": "Number of distinct language_id values in the aggregate row.", - "file_ids": "Semicolon-delimited file_id values in the aggregate row.", - "language_ids": "Semicolon-delimited language_id values in the aggregate row.", - "event_sources": "Semicolon-delimited event_source values in the aggregate row.", - "client_server_latency_ms_mean": "Mean approximate client-to-server timestamp delta.", - "client_server_latency_ms_max": "Largest approximate client-to-server timestamp delta.", - "first_sequence_number": "Smallest captured sequence number in the aggregate row.", - "last_sequence_number": "Largest captured sequence number in the aggregate row.", - "missing_sequence_gaps": "Count of missing sequence-number slots within the aggregate row.", - "duplicate_event_ids": "Number of repeated event_id values in the aggregate row.", - "data_quality_notes": "Semicolon-delimited notes about missing or suspicious capture metadata.", - "line_count_first": "First observed line count for the file aggregate.", - "line_count_last": "Last observed line count for the file aggregate.", - "line_count_max": "Maximum observed line count for the file aggregate.", - "doc_block_count_before_min": "Minimum observed doc block count before edits.", - "doc_block_count_after_last": "Last observed doc block count after edits.", - "classification_bases": "Semicolon-delimited write classification bases.", - "write_sources": "Semicolon-delimited write event sources.", - "lifecycle_kind": "Lifecycle family: task, debug_task, or handoff.", - "lifecycle_index": "One-based lifecycle index within participant/session/task/kind.", - "completed": "1 when a lifecycle end event was observed, otherwise 0.", - "start_event_type": "Lifecycle start event type.", - "end_event_type": "Lifecycle end/submit event type.", - "start_at": "Lifecycle start timestamp.", - "end_at": "Lifecycle end timestamp.", - "start_event_id": "event_id for the lifecycle start event.", - "end_event_id": "event_id for the lifecycle end event.", - "start_file_id": "file_id associated with the lifecycle start event.", - "end_file_id": "file_id associated with the lifecycle end event.", -} - - -@dataclass(frozen=True) -class DbConfig: - host: str - user: str - password: str - dbname: str - port: int | None = None - - -def parse_timestamp(value: Any) -> datetime | None: - if isinstance(value, datetime): - return value - if not isinstance(value, str) or not value: - return None - try: - return datetime.fromisoformat(value.replace("Z", "+00:00")) - except ValueError: - return None - - -def as_data(value: Any) -> dict[str, Any]: - if isinstance(value, dict): - return value - if isinstance(value, str): - try: - parsed = json.loads(value) - except json.JSONDecodeError: - return {} - return parsed if isinstance(parsed, dict) else {} - return {} - - -def normalize_db_record(record: dict[str, Any]) -> dict[str, Any]: - data = as_data(record.get("data")) - for field_name in DB_METADATA_FIELDS: - value = record.get(field_name) - if value is not None and data.get(field_name) is None: - data[field_name] = value - - return { - "user_id": record.get("user_id"), - "file_path": record.get("file_path"), - "event_type": record.get("event_type"), - "timestamp": record.get("timestamp"), - "data": data, - } - - -def iter_jsonl_events(path: Path) -> Iterator[dict[str, Any]]: - with path.open("r", encoding="utf-8") as input_file: - for line_number, line in enumerate(input_file, start=1): - line = line.strip() - if not line: - continue - try: - record = json.loads(line) - except json.JSONDecodeError as err: - raise SystemExit(f"{path}:{line_number}: invalid JSON: {err}") from err - event = record.get("event", record) - if not isinstance(event, dict): - continue - event["data"] = as_data(event.get("data")) - yield event - - -def load_db_config(config_path: Path) -> DbConfig: - env_config = db_config_from_env() - if env_config is not None: - return env_config - - config_path = resolve_config_path(config_path) - try: - config = json.loads(config_path.read_text(encoding="utf-8")) - except FileNotFoundError as err: - searched = "\n ".join(str(path) for path in config_search_paths(config_path)) - raise SystemExit( - "No DB config found. Create a local capture_config.json, set " - "CODECHAT_CAPTURE_* env vars, or pass a fallback JSONL input file.\n" - f"Searched:\n {searched}" - ) from err - except json.JSONDecodeError as err: - raise SystemExit(f"{config_path}: invalid JSON: {err}") from err - - missing = [name for name in ["host", "user", "password", "dbname"] if not config.get(name)] - if missing: - raise SystemExit(f"{config_path}: missing required DB field(s): {', '.join(missing)}") - - return DbConfig( - host=str(config["host"]), - user=str(config["user"]), - password=str(config["password"]), - dbname=str(config["dbname"]), - port=int(config["port"]) if config.get("port") is not None else None, - ) - - -def resolve_config_path(config_path: Path) -> Path: - for candidate in config_search_paths(config_path): - if candidate.exists(): - return candidate - return config_path - - -def config_search_paths(config_path: Path) -> list[Path]: - if config_path.is_absolute(): - return [config_path] - - script_repo_root = Path(__file__).resolve().parents[2] - paths = [Path.cwd() / config_path, script_repo_root / config_path] - - unique_paths: list[Path] = [] - for path in paths: - if path not in unique_paths: - unique_paths.append(path) - return unique_paths - - -def db_config_from_env() -> DbConfig | None: - host = env_value("CODECHAT_CAPTURE_HOST") - if host is None: - return None - missing = [ - name - for name in [ - "CODECHAT_CAPTURE_USER", - "CODECHAT_CAPTURE_PASSWORD", - "CODECHAT_CAPTURE_DBNAME", - ] - if env_value(name) is None - ] - if missing: - raise SystemExit( - "Missing required capture DB environment variable(s): " + ", ".join(missing) - ) - - port_text = env_value("CODECHAT_CAPTURE_PORT") - return DbConfig( - host=host, - user=env_value("CODECHAT_CAPTURE_USER") or "", - password=env_value("CODECHAT_CAPTURE_PASSWORD") or "", - dbname=env_value("CODECHAT_CAPTURE_DBNAME") or "", - port=int(port_text) if port_text is not None else None, - ) - - -def env_value(name: str) -> str | None: - value = os.environ.get(name) - if value is None: - return None - value = value.strip() - return value or None - - -def sql_identifier(identifier: str) -> str: - parts = identifier.split(".") - for part in parts: - if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", part): - raise SystemExit(f"Invalid SQL identifier: {identifier!r}") - return ".".join(f'"{part}"' for part in parts) - - -def iter_db_events(config: DbConfig, table: str) -> Iterator[dict[str, Any]]: - try: - import psycopg - except ImportError: - yield from iter_db_events_with_psql(config, table) - return - - connect_kwargs = { - "host": config.host, - "user": config.user, - "password": config.password, - "dbname": config.dbname, - } - if config.port is not None: - connect_kwargs["port"] = config.port - - query = psql_json_query(table) - with psycopg.connect(**connect_kwargs) as conn: - with conn.cursor() as cursor: - cursor.execute(query) - for (record_text,) in cursor: - yield normalize_db_record(json.loads(record_text)) - - -def iter_db_events_with_psql(config: DbConfig, table: str) -> Iterator[dict[str, Any]]: - psql_path = find_psql() - if psql_path is None: - raise SystemExit( - "PostgreSQL export needs a local PostgreSQL client to connect to the AWS DB.\n" - "The AWS PostgreSQL server is remote; it cannot provide Python's local DB driver.\n" - "Install one of these on this Windows machine:\n" - " python -m pip install \"psycopg[binary]\"\n" - "or install PostgreSQL command-line tools so psql.exe is available on PATH." - ) - - env = os.environ.copy() - env["PGPASSWORD"] = config.password - command = [ - psql_path, - "--no-password", - "--no-align", - "--tuples-only", - "--quiet", - "--set", - "ON_ERROR_STOP=1", - "--host", - config.host, - "--username", - config.user, - "--dbname", - config.dbname, - "--command", - psql_json_query(table), - ] - if config.port is not None: - command.extend(["--port", str(config.port)]) - - result = subprocess.run( - command, - env=env, - check=False, - capture_output=True, - text=True, - ) - if result.returncode != 0: - raise SystemExit( - "psql failed while querying the AWS PostgreSQL DB:\n" - f"{result.stderr.strip() or result.stdout.strip()}" - ) - - for line_number, line in enumerate(result.stdout.splitlines(), start=1): - line = line.strip() - if not line: - continue - try: - record = json.loads(line) - except json.JSONDecodeError as err: - raise SystemExit(f"psql output line {line_number}: invalid JSON: {err}") from err - yield normalize_db_record(record) - - -def find_psql() -> str | None: - psql_path = shutil.which("psql") - if psql_path is not None: - return psql_path - - program_files = Path(os.environ.get("ProgramFiles", r"C:\Program Files")) - candidates = sorted(program_files.glob(r"PostgreSQL/*/bin/psql.exe"), reverse=True) - return str(candidates[0]) if candidates else None - - -def psql_json_query(table: str) -> str: - return ( - "SELECT to_jsonb(events_row)::text " - f"FROM {sql_identifier(table)} AS events_row " - 'ORDER BY events_row."timestamp"' - ) - - -def text_value(value: Any) -> str: - if value is None: - return "" - if isinstance(value, str): - return value - if isinstance(value, (dict, list)): - return json.dumps(value, sort_keys=True, separators=(",", ":")) - return str(value) - - -def first_data_value(data: dict[str, Any], *names: str) -> Any: - for name in names: - if name in data and data[name] is not None: - return data[name] - return None - - -def data_text(data: dict[str, Any], *names: str) -> str: - return text_value(first_data_value(data, *names)) - - -def int_value(value: Any) -> int | None: - if isinstance(value, bool): - return int(value) - if isinstance(value, int): - return value - if isinstance(value, float) and value.is_integer(): - return int(value) - if isinstance(value, str): - value = value.strip() - if not value: - return None - try: - return int(value) - except ValueError: - return None - return None - - -def float_value(value: Any) -> float | None: - if isinstance(value, bool): - return float(value) - if isinstance(value, (int, float)): - return float(value) - if isinstance(value, str): - value = value.strip() - if not value: - return None - try: - return float(value) - except ValueError: - return None - return None - - -def int_or_blank(value: Any) -> int | str: - number = int_value(value) - return number if number is not None else "" - - -def float_or_blank(value: Any) -> float | str: - number = float_value(value) - return number if number is not None else "" - - -def csv_value(value: Any) -> str | int: - if value is None: - return "" - if isinstance(value, float): - return f"{value:.3f}" - if isinstance(value, bool): - return "1" if value else "0" - return value - - -def write_csv(path: Path, fieldnames: list[str], rows: Iterable[dict[str, Any]]) -> None: - if path.parent != Path("."): - path.parent.mkdir(parents=True, exist_ok=True) - with path.open("w", encoding="utf-8", newline="") as output_file: - writer = csv.DictWriter(output_file, fieldnames=fieldnames, extrasaction="ignore") - writer.writeheader() - for row in rows: - writer.writerow({field: csv_value(row.get(field, "")) for field in fieldnames}) - - -def aware_datetime(value: datetime | None) -> datetime | None: - if value is None: - return None - if value.tzinfo is None: - return value.replace(tzinfo=timezone.utc) - return value.astimezone(timezone.utc) - - -def seconds_between(start: datetime | None, end: datetime | None) -> float | None: - if start is None or end is None: - return None - return (end - start).total_seconds() - - -def timestamp_for_csv(value: datetime | None, fallback: Any = "") -> str: - if value is not None: - return value.isoformat() - return text_value(fallback) - - -def sha256_text(value: str) -> str: - return hashlib.sha256(value.encode("utf-8")).hexdigest() - - -def file_id_for(file_path: str, file_hash: str) -> str: - if file_hash: - return file_hash - if file_path: - return sha256_text(file_path) - return "" - - -def fields_with_optional_file_path( - fieldnames: list[str], include_file_paths: bool -) -> list[str]: - if not include_file_paths or RAW_FILE_PATH_FIELD in fieldnames: - return fieldnames - fields = list(fieldnames) - insert_after = "file_hash" if "file_hash" in fields else "file_id" - fields.insert(fields.index(insert_after) + 1, RAW_FILE_PATH_FIELD) - return fields - - -def identity_key(row: dict[str, Any]) -> tuple[str, ...]: - return tuple(text_value(row.get(field)) for field in IDENTITY_FIELDS) - - -def semicolon_join(values: Iterable[str]) -> str: - return ";".join(sorted(value for value in values if value)) - - -def add_number(acc: list[float], value: Any) -> None: - number = float_value(value) - if number is not None: - acc.append(number) - - -def string_diff_stats(value: Any) -> Counter[str]: - stats: Counter[str] = Counter() - if isinstance(value, list): - for item in value: - stats.update(string_diff_stats(item)) - return stats - if not isinstance(value, dict): - return stats - - if "from" in value and "insert" in value: - from_value = int_value(value.get("from")) or 0 - to_value = int_value(value.get("to")) - removed_units = max(0, (to_value or from_value) - from_value) - inserted_chars = len(text_value(value.get("insert"))) - stats["hunks"] += 1 - stats["inserted_chars"] += inserted_chars - stats["deleted_units"] += removed_units - if removed_units > 0 and inserted_chars > 0: - stats["replacement_hunks"] += 1 - return stats - - for child in value.values(): - stats.update(string_diff_stats(child)) - return stats - - -def doc_block_contents(value: Any) -> str: - if isinstance(value, list) and len(value) >= 5: - return text_value(value[4]) - if isinstance(value, dict): - return text_value(value.get("contents")) - return "" - - -def doc_block_diff_stats(value: Any) -> Counter[str]: - stats: Counter[str] = Counter() - if not isinstance(value, list): - return stats - - for transaction in value: - stats["transactions"] += 1 - if not isinstance(transaction, dict): - continue - if "Add" in transaction: - stats["inserted_chars"] += len(doc_block_contents(transaction["Add"])) - elif "Update" in transaction: - update_stats = string_diff_stats(transaction["Update"]) - stats["hunks"] += update_stats["hunks"] - stats["inserted_chars"] += update_stats["inserted_chars"] - stats["deleted_units"] += update_stats["deleted_units"] - elif "Delete" in transaction: - continue - else: - update_stats = string_diff_stats(transaction) - stats["hunks"] += update_stats["hunks"] - stats["inserted_chars"] += update_stats["inserted_chars"] - stats["deleted_units"] += update_stats["deleted_units"] - return stats - - -def normalize_event_rows( - events: Iterable[dict[str, Any]], include_file_paths: bool = False -) -> list[dict[str, Any]]: - sortable_rows: list[dict[str, Any]] = [] - for original_index, event in enumerate(events, start=1): - data = as_data(event.get("data")) - timestamp = aware_datetime(parse_timestamp(event.get("timestamp"))) - file_path = text_value(event.get("file_path")) - file_hash = data_text(data, "file_hash") - client_timestamp_ms = int_value(data.get("client_timestamp_ms")) - server_timestamp_ms = int_value(data.get("server_timestamp_ms")) - latency_ms = ( - server_timestamp_ms - client_timestamp_ms - if client_timestamp_ms is not None and server_timestamp_ms is not None - else "" - ) - diff_stats = string_diff_stats(data.get("diff")) - doc_block_stats = doc_block_diff_stats(data.get("doc_block_diff")) - - row: dict[str, Any] = { - "event_index": original_index, - "user_id": text_value(event.get("user_id")), - "session_id": data_text(data, "session_id"), - "event_id": data_text(data, "event_id"), - "sequence_number": int_or_blank(data.get("sequence_number")), - "schema_version": int_or_blank(data.get("schema_version")), - "event_source": data_text(data, "event_source"), - "event_type": text_value(event.get("event_type")), - "timestamp": timestamp_for_csv(timestamp, event.get("timestamp")), - "client_timestamp_ms": client_timestamp_ms - if client_timestamp_ms is not None - else "", - "server_timestamp_ms": server_timestamp_ms - if server_timestamp_ms is not None - else "", - "client_tz_offset_min": int_or_blank(data.get("client_tz_offset_min")), - "client_server_latency_ms": latency_ms, - "elapsed_session_seconds": "", - "gap_seconds": "", - "file_id": file_id_for(file_path, file_hash), - "file_hash": file_hash, - "path_privacy": data_text(data, "path_privacy"), - "language_id": data_text(data, "language_id", "languageId"), - "classification_basis": data_text(data, "classification_basis"), - "write_source": data_text(data, "source"), - "mode": data_text(data, "mode"), - "activity_from": data_text(data, "from"), - "activity_to": data_text(data, "to"), - "duration_seconds": float_or_blank(data.get("duration_seconds")), - "duration_ms": float_or_blank(data.get("duration_ms")), - "line_count": int_or_blank(first_data_value(data, "lineCount", "line_count")), - "prompt_hash": data_text(data, "prompt_hash"), - "prompt_length": int_or_blank(data.get("prompt_length")), - "command": data_text(data, "command"), - "task_name": data_text(data, "taskName", "task_name"), - "task_source": data_text(data, "taskSource", "task_source"), - "exit_code": int_or_blank(first_data_value(data, "exitCode", "exit_code")), - "run_session_name": data_text(data, "sessionName", "session_name"), - "run_session_type": data_text(data, "sessionType", "session_type"), - "save_reason": data_text(data, "reason"), - # Settings-change audit events make consent/recording transitions - # analyzable without inspecting raw JSON payloads. - "changed_by": data_text(data, "changed_by"), - "changed_settings": data_text(data, "changed_settings"), - "previous_state": data_text(data, "previous_state"), - "new_state": data_text(data, "new_state"), - "previous_consent_enabled": int_or_blank( - data.get("previous_consent_enabled") - ), - "new_consent_enabled": int_or_blank(data.get("new_consent_enabled")), - "previous_record_study_events": int_or_blank( - data.get("previous_record_study_events") - ), - "new_record_study_events": int_or_blank( - data.get("new_record_study_events") - ), - "capture_active_before": int_or_blank(data.get("capture_active_before")), - "capture_active_after": int_or_blank(data.get("capture_active_after")), - "doc_block_count_before": int_or_blank(data.get("doc_block_count_before")), - "doc_block_count_after": int_or_blank(data.get("doc_block_count_after")), - "diff_hunks": diff_stats["hunks"], - "diff_inserted_chars": diff_stats["inserted_chars"], - "diff_deleted_units": diff_stats["deleted_units"], - "diff_replacement_hunks": diff_stats["replacement_hunks"], - "doc_block_transactions": doc_block_stats["transactions"], - "doc_block_diff_hunks": doc_block_stats["hunks"], - "doc_block_inserted_chars": doc_block_stats["inserted_chars"], - "doc_block_deleted_units": doc_block_stats["deleted_units"], - } - if include_file_paths: - row[RAW_FILE_PATH_FIELD] = file_path - - sortable_rows.append( - { - "row": row, - "timestamp": timestamp, - "sequence_number": int_value(row["sequence_number"]), - "original_index": original_index, - } - ) - - max_timestamp = datetime.max.replace(tzinfo=timezone.utc) - sortable_rows.sort( - key=lambda item: ( - item["timestamp"] is None, - item["timestamp"] or max_timestamp, - item["sequence_number"] if item["sequence_number"] is not None else 10**18, - item["original_index"], - ) - ) - - first_by_session: dict[tuple[str, ...], datetime] = {} - previous_by_session: dict[tuple[str, ...], datetime] = {} - for event_index, item in enumerate(sortable_rows, start=1): - row = item["row"] - row["event_index"] = event_index - timestamp = item["timestamp"] - if timestamp is None: - continue - key = identity_key(row) - first = first_by_session.setdefault(key, timestamp) - row["elapsed_session_seconds"] = seconds_between(first, timestamp) or 0.0 - previous = previous_by_session.get(key) - if previous is not None: - row["gap_seconds"] = seconds_between(previous, timestamp) or 0.0 - previous_by_session[key] = timestamp - - return [item["row"] for item in sortable_rows] - - -def new_session_acc(row: dict[str, Any]) -> dict[str, Any]: - return { - "identity": {field: text_value(row.get(field)) for field in IDENTITY_FIELDS}, - "counts": Counter(), - "event_count": 0, - "first_dt": None, - "last_dt": None, - "gaps": [], - "doc_session_seconds": 0.0, - "doc_to_code_switches": 0, - "code_to_doc_switches": 0, - "compile_success_events": 0, - "compile_failure_events": 0, - "total_prompt_chars": 0, - "file_ids": set(), - "language_ids": set(), - "event_sources": set(), - "latencies": [], - "sequence_numbers": [], - "event_ids": Counter(), - "diff_hunks": 0, - "diff_inserted_chars": 0, - "diff_deleted_units": 0, - "doc_block_transactions": 0, - "doc_block_diff_hunks": 0, - "doc_block_inserted_chars": 0, - "doc_block_deleted_units": 0, - "missing_timestamp_count": 0, - "missing_session_count": 0, - "missing_schema_count": 0, - } - - -def update_time_acc(acc: dict[str, Any], row: dict[str, Any]) -> None: - timestamp = aware_datetime(parse_timestamp(row.get("timestamp"))) - if timestamp is None: - acc["missing_timestamp_count"] += 1 - return - if acc["first_dt"] is None or timestamp < acc["first_dt"]: - acc["first_dt"] = timestamp - if acc["last_dt"] is None or timestamp > acc["last_dt"]: - acc["last_dt"] = timestamp - - -def update_session_acc(acc: dict[str, Any], row: dict[str, Any]) -> None: - event_type = text_value(row.get("event_type")) - acc["event_count"] += 1 - acc["counts"][event_type] += 1 - update_time_acc(acc, row) - - add_number(acc["gaps"], row.get("gap_seconds")) - if event_type == "doc_session": - duration = float_value(row.get("duration_seconds")) - if duration is not None: - acc["doc_session_seconds"] += duration - if event_type == "switch_pane": - if row.get("activity_from") == "doc" and row.get("activity_to") == "code": - acc["doc_to_code_switches"] += 1 - if row.get("activity_from") == "code" and row.get("activity_to") == "doc": - acc["code_to_doc_switches"] += 1 - if event_type == "compile_end": - exit_code = int_value(row.get("exit_code")) - if exit_code == 0: - acc["compile_success_events"] += 1 - elif exit_code is not None: - acc["compile_failure_events"] += 1 - - acc["total_prompt_chars"] += int_value(row.get("prompt_length")) or 0 - if row.get("file_id"): - acc["file_ids"].add(text_value(row.get("file_id"))) - if row.get("language_id"): - acc["language_ids"].add(text_value(row.get("language_id"))) - if row.get("event_source"): - acc["event_sources"].add(text_value(row.get("event_source"))) - add_number(acc["latencies"], row.get("client_server_latency_ms")) - - sequence_number = int_value(row.get("sequence_number")) - if sequence_number is not None: - acc["sequence_numbers"].append(sequence_number) - event_id = text_value(row.get("event_id")) - if event_id: - acc["event_ids"][event_id] += 1 - - for field in [ - "diff_hunks", - "diff_inserted_chars", - "diff_deleted_units", - "doc_block_transactions", - "doc_block_diff_hunks", - "doc_block_inserted_chars", - "doc_block_deleted_units", - ]: - acc[field] += int_value(row.get(field)) or 0 - if not row.get("session_id"): - acc["missing_session_count"] += 1 - if not row.get("schema_version"): - acc["missing_schema_count"] += 1 - - -def finalize_session_acc(acc: dict[str, Any]) -> dict[str, Any]: - first_dt = acc["first_dt"] - last_dt = acc["last_dt"] - active_span = seconds_between(first_dt, last_dt) or 0.0 - counts = acc["counts"] - write_events = counts["write_doc"] + counts["write_code"] - sequence_numbers = sorted(set(acc["sequence_numbers"])) - missing_sequence_gaps = sum( - max(0, current - previous - 1) - for previous, current in zip(sequence_numbers, sequence_numbers[1:]) - ) - duplicate_event_ids = sum( - count - 1 for count in acc["event_ids"].values() if count > 1 - ) - notes = [] - if acc["missing_timestamp_count"]: - notes.append(f"missing_timestamp:{acc['missing_timestamp_count']}") - if acc["missing_session_count"]: - notes.append(f"missing_session_id:{acc['missing_session_count']}") - if acc["missing_schema_count"]: - notes.append(f"missing_schema_version:{acc['missing_schema_count']}") - if missing_sequence_gaps: - notes.append(f"missing_sequence_slots:{missing_sequence_gaps}") - if duplicate_event_ids: - notes.append(f"duplicate_event_ids:{duplicate_event_ids}") - - row = { - **acc["identity"], - "event_count": acc["event_count"], - "first_event_at": timestamp_for_csv(first_dt), - "last_event_at": timestamp_for_csv(last_dt), - "active_span_seconds": active_span, - "events_per_minute": (acc["event_count"] / (active_span / 60.0)) - if active_span > 0 - else "", - "mean_gap_seconds": sum(acc["gaps"]) / len(acc["gaps"]) if acc["gaps"] else "", - "max_gap_seconds": max(acc["gaps"]) if acc["gaps"] else "", - "doc_session_seconds": acc["doc_session_seconds"], - "doc_session_share_of_span": acc["doc_session_seconds"] / active_span - if active_span > 0 - else "", - "write_events": write_events, - "doc_write_share": counts["write_doc"] / write_events if write_events else "", - **{f"{event_type}_events": counts[event_type] for event_type in EVENT_FIELDS}, - "doc_to_code_switches": acc["doc_to_code_switches"], - "code_to_doc_switches": acc["code_to_doc_switches"], - "compile_success_events": acc["compile_success_events"], - "compile_failure_events": acc["compile_failure_events"], - "total_prompt_chars": acc["total_prompt_chars"], - "unique_file_count": len(acc["file_ids"]), - "unique_language_count": len(acc["language_ids"]), - "file_ids": semicolon_join(acc["file_ids"]), - "language_ids": semicolon_join(acc["language_ids"]), - "event_sources": semicolon_join(acc["event_sources"]), - "diff_hunks": acc["diff_hunks"], - "diff_inserted_chars": acc["diff_inserted_chars"], - "diff_deleted_units": acc["diff_deleted_units"], - "doc_block_transactions": acc["doc_block_transactions"], - "doc_block_diff_hunks": acc["doc_block_diff_hunks"], - "doc_block_inserted_chars": acc["doc_block_inserted_chars"], - "doc_block_deleted_units": acc["doc_block_deleted_units"], - "client_server_latency_ms_mean": sum(acc["latencies"]) / len(acc["latencies"]) - if acc["latencies"] - else "", - "client_server_latency_ms_max": max(acc["latencies"]) if acc["latencies"] else "", - "first_sequence_number": sequence_numbers[0] if sequence_numbers else "", - "last_sequence_number": sequence_numbers[-1] if sequence_numbers else "", - "missing_sequence_gaps": missing_sequence_gaps, - "duplicate_event_ids": duplicate_event_ids, - "data_quality_notes": ";".join(notes), - } - return row - - -def session_summary_rows(event_rows: Iterable[dict[str, Any]]) -> list[dict[str, Any]]: - accs: dict[tuple[str, ...], dict[str, Any]] = {} - for row in event_rows: - key = identity_key(row) - acc = accs.setdefault(key, new_session_acc(row)) - update_session_acc(acc, row) - return [ - finalize_session_acc(acc) - for _, acc in sorted(accs.items(), key=lambda item: item[0]) - ] - - -def new_file_acc(row: dict[str, Any], include_file_paths: bool) -> dict[str, Any]: - acc = { - "identity": {field: text_value(row.get(field)) for field in IDENTITY_FIELDS}, - "file_id": text_value(row.get("file_id")), - "file_hash": text_value(row.get("file_hash")), - "language_id": text_value(row.get("language_id")), - "path_privacy": text_value(row.get("path_privacy")), - "counts": Counter(), - "event_count": 0, - "first_dt": None, - "last_dt": None, - "doc_session_seconds": 0.0, - "line_count_first": "", - "line_count_last": "", - "line_count_max": "", - "doc_block_count_before_min": "", - "doc_block_count_after_last": "", - "classification_bases": set(), - "write_sources": set(), - "diff_hunks": 0, - "diff_inserted_chars": 0, - "diff_deleted_units": 0, - "doc_block_transactions": 0, - "doc_block_diff_hunks": 0, - "doc_block_inserted_chars": 0, - "doc_block_deleted_units": 0, - } - if include_file_paths: - acc[RAW_FILE_PATH_FIELD] = text_value(row.get(RAW_FILE_PATH_FIELD)) - return acc - - -def update_file_acc(acc: dict[str, Any], row: dict[str, Any]) -> None: - event_type = text_value(row.get("event_type")) - acc["event_count"] += 1 - acc["counts"][event_type] += 1 - update_time_acc(acc, row) - - if event_type == "doc_session": - duration = float_value(row.get("duration_seconds")) - if duration is not None: - acc["doc_session_seconds"] += duration - - line_count = int_value(row.get("line_count")) - if line_count is not None: - if acc["line_count_first"] == "": - acc["line_count_first"] = line_count - acc["line_count_last"] = line_count - acc["line_count_max"] = max(int_value(acc["line_count_max"]) or 0, line_count) - - before_count = int_value(row.get("doc_block_count_before")) - if before_count is not None: - current_min = int_value(acc["doc_block_count_before_min"]) - acc["doc_block_count_before_min"] = ( - before_count if current_min is None else min(current_min, before_count) - ) - after_count = int_value(row.get("doc_block_count_after")) - if after_count is not None: - acc["doc_block_count_after_last"] = after_count - - if row.get("classification_basis"): - acc["classification_bases"].add(text_value(row.get("classification_basis"))) - if row.get("write_source"): - acc["write_sources"].add(text_value(row.get("write_source"))) - - for field in [ - "diff_hunks", - "diff_inserted_chars", - "diff_deleted_units", - "doc_block_transactions", - "doc_block_diff_hunks", - "doc_block_inserted_chars", - "doc_block_deleted_units", - ]: - acc[field] += int_value(row.get(field)) or 0 - - -def finalize_file_acc(acc: dict[str, Any], include_file_paths: bool) -> dict[str, Any]: - first_dt = acc["first_dt"] - last_dt = acc["last_dt"] - row = { - **acc["identity"], - "file_id": acc["file_id"], - "file_hash": acc["file_hash"], - "language_id": acc["language_id"], - "path_privacy": acc["path_privacy"], - "event_count": acc["event_count"], - "first_event_at": timestamp_for_csv(first_dt), - "last_event_at": timestamp_for_csv(last_dt), - "active_span_seconds": seconds_between(first_dt, last_dt) or 0.0, - "doc_session_seconds": acc["doc_session_seconds"], - "write_doc_events": acc["counts"]["write_doc"], - "write_code_events": acc["counts"]["write_code"], - "save_events": acc["counts"]["save"], - "compile_events": acc["counts"]["compile"], - "compile_end_events": acc["counts"]["compile_end"], - "run_events": acc["counts"]["run"], - "run_end_events": acc["counts"]["run_end"], - "switch_pane_events": acc["counts"]["switch_pane"], - "line_count_first": acc["line_count_first"], - "line_count_last": acc["line_count_last"], - "line_count_max": acc["line_count_max"], - "doc_block_count_before_min": acc["doc_block_count_before_min"], - "doc_block_count_after_last": acc["doc_block_count_after_last"], - "classification_bases": semicolon_join(acc["classification_bases"]), - "write_sources": semicolon_join(acc["write_sources"]), - "diff_hunks": acc["diff_hunks"], - "diff_inserted_chars": acc["diff_inserted_chars"], - "diff_deleted_units": acc["diff_deleted_units"], - "doc_block_transactions": acc["doc_block_transactions"], - "doc_block_diff_hunks": acc["doc_block_diff_hunks"], - "doc_block_inserted_chars": acc["doc_block_inserted_chars"], - "doc_block_deleted_units": acc["doc_block_deleted_units"], - } - if include_file_paths: - row[RAW_FILE_PATH_FIELD] = acc.get(RAW_FILE_PATH_FIELD, "") - return row - - -def file_summary_rows( - event_rows: Iterable[dict[str, Any]], include_file_paths: bool -) -> list[dict[str, Any]]: - accs: dict[tuple[str, ...], dict[str, Any]] = {} - for row in event_rows: - key = ( - *identity_key(row), - text_value(row.get("file_id")), - text_value(row.get("language_id")), - ) - acc = accs.setdefault(key, new_file_acc(row, include_file_paths)) - update_file_acc(acc, row) - return [ - finalize_file_acc(acc, include_file_paths) - for _, acc in sorted(accs.items(), key=lambda item: item[0]) - ] - - -def lifecycle_row( - kind: str, - lifecycle_index: int, - start: dict[str, Any] | None, - end: dict[str, Any] | None, -) -> dict[str, Any]: - source = start or end or {} - start_dt = aware_datetime(parse_timestamp(start.get("timestamp") if start else None)) - end_dt = aware_datetime(parse_timestamp(end.get("timestamp") if end else None)) - notes = [] - if start is None: - notes.append("missing_start") - if end is None: - notes.append("missing_end") - return { - **{field: text_value(source.get(field)) for field in IDENTITY_FIELDS}, - "lifecycle_kind": kind, - "lifecycle_index": lifecycle_index, - "completed": 1 if end is not None else 0, - "start_event_type": text_value(start.get("event_type")) if start else "", - "end_event_type": text_value(end.get("event_type")) if end else "", - "start_at": timestamp_for_csv(start_dt), - "end_at": timestamp_for_csv(end_dt), - "duration_seconds": seconds_between(start_dt, end_dt) - if start_dt is not None and end_dt is not None - else "", - "start_event_id": text_value(start.get("event_id")) if start else "", - "end_event_id": text_value(end.get("event_id")) if end else "", - "start_file_id": text_value(start.get("file_id")) if start else "", - "end_file_id": text_value(end.get("file_id")) if end else "", - "language_id": text_value(source.get("language_id")), - "command": text_value(source.get("command")), - "data_quality_notes": ";".join(notes), - } - - -def task_lifecycle_rows(event_rows: Iterable[dict[str, Any]]) -> list[dict[str, Any]]: - starts: dict[tuple[tuple[str, ...], str], deque[dict[str, Any]]] = defaultdict(deque) - lifecycle_indexes: Counter[tuple[tuple[str, ...], str]] = Counter() - rows: list[dict[str, Any]] = [] - - for row in event_rows: - event_type = text_value(row.get("event_type")) - if event_type in LIFECYCLE_PAIRS: - kind, _ = LIFECYCLE_PAIRS[event_type] - starts[(identity_key(row), kind)].append(row) - continue - if event_type not in LIFECYCLE_END_TYPES: - continue - - kind, _start_type = LIFECYCLE_END_TYPES[event_type] - key = (identity_key(row), kind) - start = starts[key].popleft() if starts[key] else None - lifecycle_indexes[key] += 1 - rows.append(lifecycle_row(kind, lifecycle_indexes[key], start, row)) - - for key, queue in sorted(starts.items(), key=lambda item: item[0]): - identity, kind = key - while queue: - start = queue.popleft() - lifecycle_indexes[(identity, kind)] += 1 - rows.append(lifecycle_row(kind, lifecycle_indexes[(identity, kind)], start, None)) - - rows.sort( - key=lambda row: ( - row["user_id"], - row["session_id"], - row["lifecycle_kind"], - row["lifecycle_index"], - ) - ) - return rows - - -def data_dictionary_rows(fieldsets: dict[str, list[str]]) -> list[dict[str, str]]: - rows = [] - for dataset, fields in fieldsets.items(): - for field in fields: - rows.append( - { - "dataset": dataset, - "column": field, - "description": FIELD_DESCRIPTIONS.get(field, ""), - } - ) - return rows - - -def export_metrics(event_rows: list[dict[str, Any]], output_path: Path) -> None: - write_csv(output_path, SESSION_SUMMARY_FIELDS, session_summary_rows(event_rows)) - - -def export_analysis_dataset( - event_rows: list[dict[str, Any]], dataset_dir: Path, include_file_paths: bool -) -> list[Path]: - dataset_dir.mkdir(parents=True, exist_ok=True) - event_fields = fields_with_optional_file_path(EVENT_ROW_FIELDS, include_file_paths) - file_fields = fields_with_optional_file_path(FILE_SUMMARY_FIELDS, include_file_paths) - fieldsets = { - "events.csv": event_fields, - "session_summary.csv": SESSION_SUMMARY_FIELDS, - "file_summary.csv": file_fields, - "task_lifecycle.csv": TASK_LIFECYCLE_FIELDS, - } - outputs = [ - dataset_dir / "events.csv", - dataset_dir / "session_summary.csv", - dataset_dir / "file_summary.csv", - dataset_dir / "task_lifecycle.csv", - dataset_dir / "data_dictionary.csv", - ] - write_csv(outputs[0], event_fields, event_rows) - write_csv(outputs[1], SESSION_SUMMARY_FIELDS, session_summary_rows(event_rows)) - write_csv(outputs[2], file_fields, file_summary_rows(event_rows, include_file_paths)) - write_csv(outputs[3], TASK_LIFECYCLE_FIELDS, task_lifecycle_rows(event_rows)) - write_csv( - outputs[4], - ["dataset", "column", "description"], - data_dictionary_rows(fieldsets), - ) - return outputs - - -def main() -> None: - parser = argparse.ArgumentParser() - parser.add_argument( - "input", - nargs="?", - type=Path, - help="Optional capture JSONL fallback file. Omit to read PostgreSQL.", - ) - parser.add_argument( - "--out", - type=Path, - default=None, - help=( - "Output session-summary CSV file. Defaults to a timestamped " - "capture-metrics-YYYYMMDD-HHMMSS.csv file when --dataset-dir is omitted." - ), - ) - parser.add_argument( - "--dataset-dir", - nargs="?", - const=Path("__DEFAULT_CAPTURE_ANALYSIS_DIR__"), - type=Path, - default=None, - help=( - "Write a richer analysis dataset directory containing events.csv, " - "session_summary.csv, file_summary.csv, task_lifecycle.csv, and " - "data_dictionary.csv. If no path is supplied, defaults to " - "capture-analysis-YYYYMMDD-HHMMSS." - ), - ) - parser.add_argument( - "--include-file-paths", - action="store_true", - help=( - "Include raw captured file paths in event/file exports. By default, " - "the dataset uses file_id/file_hash only." - ), - ) - parser.add_argument( - "--db", - action="store_true", - help="Read PostgreSQL. This is the default when no JSONL input is supplied.", - ) - parser.add_argument( - "--config", - type=Path, - default=Path("capture_config.json"), - help="Capture DB config JSON path. Ignored when CODECHAT_CAPTURE_* env vars are set.", - ) - parser.add_argument( - "--table", - default="events", - help='Capture events table name. Defaults to "events".', - ) - args = parser.parse_args() - - if args.db and args.input is not None: - parser.error("do not pass a JSONL input path with --db") - - events = ( - iter_jsonl_events(args.input) - if args.input is not None - else iter_db_events(load_db_config(args.config), args.table) - ) - event_rows = normalize_event_rows(events, include_file_paths=args.include_file_paths) - - wrote_outputs = False - if args.out is not None or args.dataset_dir is None: - output_path = args.out or default_output_path() - export_metrics(event_rows, output_path) - print(f"Wrote {output_path}") - wrote_outputs = True - - if args.dataset_dir is not None: - dataset_dir = ( - default_dataset_dir() - if args.dataset_dir == Path("__DEFAULT_CAPTURE_ANALYSIS_DIR__") - else args.dataset_dir - ) - output_paths = export_analysis_dataset( - event_rows, dataset_dir, args.include_file_paths - ) - print(f"Wrote analysis dataset to {dataset_dir}") - for output_path in output_paths: - print(f" {output_path.name}") - wrote_outputs = True - - if not wrote_outputs: - raise SystemExit("No outputs were requested.") - - -def default_output_path() -> Path: - timestamp = datetime.now(timezone.utc).astimezone().strftime("%Y%m%d-%H%M%S") - return Path(f"capture-metrics-{timestamp}.csv") - - -def default_dataset_dir() -> Path: - timestamp = datetime.now(timezone.utc).astimezone().strftime("%Y%m%d-%H%M%S") - return Path(f"capture-analysis-{timestamp}") - - -if __name__ == "__main__": - main() From e91683fa2ec5ef9d72f1acba548f3cac5ec513ce Mon Sep 17 00:00:00 2001 From: "JDS-WORKSTATION\\jspah" <44337821+jspahn80134@users.noreply.github.com> Date: Sun, 17 May 2026 11:42:21 -0600 Subject: [PATCH 29/45] Stabilize doc block update test --- server/tests/overall_1.rs | 70 +++++++++++++++++++++++++-------------- 1 file changed, 46 insertions(+), 24 deletions(-) diff --git a/server/tests/overall_1.rs b/server/tests/overall_1.rs index 61490f7c..b2fb189a 100644 --- a/server/tests/overall_1.rs +++ b/server/tests/overall_1.rs @@ -33,7 +33,7 @@ use std::{error::Error, path::PathBuf, time::Duration}; use dunce::canonicalize; use indoc::indoc; use pretty_assertions::assert_eq; -use thirtyfour::{By, Key, WebDriver, error::WebDriverError}; +use thirtyfour::{By, Key, WebDriver, WebElement, error::WebDriverError}; use tokio::time::sleep; // ### Local @@ -753,6 +753,17 @@ async fn test_client_core( make_test!(test_client_updates, test_client_updates_core); +async fn wait_for_element(driver: &WebDriver, css: &str) -> Result { + for _ in 0..50 { + if let Ok(element) = driver.find(By::Css(css)).await { + return Ok(element); + } + sleep(Duration::from_millis(100)).await; + } + + driver.find(By::Css(css)).await +} + async fn test_client_updates_core( codechat_server: CodeChatEditorServer, driver: WebDriver, @@ -784,39 +795,51 @@ async fn test_client_updates_core( // Target the iframe containing the Client. select_codechat_iframe(&driver).await; - // Select the doc block and add to the line, causing a word wrap. + // Focus the doc block, then wait for the async handoff to the shared inline + // TinyMCE editor before typing. Otherwise WebDriver can type into the + // transient contenteditable div, moving the cursor without marking the doc + // block dirty on macOS Chrome. let contents_css = ".CodeChat-CodeMirror .CodeChat-doc-contents"; let doc_block_contents = driver.find(By::Css(contents_css)).await.unwrap(); + doc_block_contents.click().await.unwrap(); + let doc_block_contents = wait_for_element( + &driver, + ".CodeChat-CodeMirror #TinyMCE-inst:not(.CodeChat-doc-hidden)", + ) + .await + .unwrap(); + + // Add to the line, causing a word wrap. doc_block_contents - .send_keys("" + Key::End + " testing") + .send_keys(Key::End + " testing") .await .unwrap(); - // Get the next message, which could be a cursor update followed by a text + // Get the next message, which could be cursor updates followed by a text // update, or just the text update. let mut client_id = INITIAL_CLIENT_MESSAGE_ID; - let mut msg = codechat_server.get_message_timeout(TIMEOUT).await.unwrap(); - if let EditorMessageContents::Update(ref update) = msg.message + let mut msg = codechat_server + .get_message_timeout(TIMEOUT) + .await + .expect("expected client update after editing doc block"); + while let EditorMessageContents::Update(ref update) = msg.message && update.contents.is_none() { - // Sometimes, we get just a cursor update. If so, verify this then wait - // for the text update. - assert_eq!( - msg, - EditorMessage { - id: client_id, - message: EditorMessageContents::Update(UpdateMessageContents { - file_path: path_str.clone(), - cursor_position: Some(CursorPosition::Line(1)), - scroll_position: Some(1.0), - is_re_translation: false, - contents: None, - }) - } + // Sometimes, we get cursor-only updates. If so, verify and acknowledge + // them, then keep waiting for the text update. + assert_eq!(msg.id, client_id); + assert_eq!(update.file_path, path_str); + assert!(!update.is_re_translation); + assert!( + update.cursor_position.is_some() || update.scroll_position.is_some(), + "cursor-only update must include cursor or scroll state: {msg:#?}", ); + codechat_server.send_result(client_id, None).await.unwrap(); client_id += MESSAGE_ID_INCREMENT; - assert_eq!(client_id, 7.0); - msg = codechat_server.get_message_timeout(TIMEOUT).await.unwrap(); + msg = codechat_server + .get_message_timeout(TIMEOUT) + .await + .expect("expected content update after cursor-only update"); } // Verify the updated text. @@ -850,7 +873,7 @@ async fn test_client_updates_core( ); codechat_server.send_result(client_id, None).await.unwrap(); client_id += MESSAGE_ID_INCREMENT; - assert!(client_id == 10.0 || client_id == 7.0); + assert!(client_id >= 7.0); // The Server sends the Client a wrapped version of the text; the Client // replies with a Result(Ok). @@ -863,7 +886,6 @@ async fn test_client_updates_core( ); server_id += MESSAGE_ID_INCREMENT; - // After this, ID is 13. goto_line(&codechat_server, &driver, &mut client_id, &path_str, 4) .await .unwrap(); From c2818c2d1721eccbefee4af6c1e8681cdbb0a336 Mon Sep 17 00:00:00 2001 From: "JDS-WORKSTATION\\jspah" <44337821+jspahn80134@users.noreply.github.com> Date: Mon, 18 May 2026 08:02:42 -0600 Subject: [PATCH 30/45] Address capture review cleanup --- .gitignore | 1 - capture_config.example.json | 2 +- client/src/CodeMirror-integration.mts | 18 +- extensions/VSCode/package.json | 18 +- extensions/VSCode/src/extension.ts | 9 +- server/scripts/capture_events_schema.sql | 21 +- server/src/capture.rs | 311 +++++++++++++++++------ server/src/translation.rs | 25 +- server/src/webserver.rs | 157 ++++-------- server/tests/overall_1.rs | 26 +- 10 files changed, 331 insertions(+), 257 deletions(-) diff --git a/.gitignore b/.gitignore index d5a73a07..0f09637d 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,5 @@ target/ /server/scripts/output /server/scripts/capture-metrics-*.csv /server/scripts/capture-analysis-*/ -server/capture_config.json # CodeChat Editor lexer: python. See TODO. diff --git a/capture_config.example.json b/capture_config.example.json index 22980ee2..6a2e24b8 100644 --- a/capture_config.example.json +++ b/capture_config.example.json @@ -1,7 +1,7 @@ { "host": "your-aws-rds-endpoint.amazonaws.com", "port": 5432, - "user": "your-db-user", + "user": "codechat_capture_writer", "password": "your-db-password", "dbname": "your-db-name", "app_id": "dissertation", diff --git a/client/src/CodeMirror-integration.mts b/client/src/CodeMirror-integration.mts index 634f5285..62c8f061 100644 --- a/client/src/CodeMirror-integration.mts +++ b/client/src/CodeMirror-integration.mts @@ -294,12 +294,10 @@ export const docBlockField = StateField.define({ prev.spec.widget.delimiter, typeof effect.value.contents === "string" ? effect.value.contents - : Array.isArray(effect.value.contents) - ? apply_diff_str( - prev.spec.widget.contents, - effect.value.contents, - ) - : prev.spec.widget.contents, + : apply_diff_str( + prev.spec.widget.contents, + effect.value.contents, + ), // If autosave is allowed (meaning no autosave // is not true), then this data came from the // user, not the IDE. @@ -1212,13 +1210,7 @@ export const scroll_to_line = ( }; // Apply a `StringDiff` to the before string to produce the after string. -export const apply_diff_str = ( - before: string, - diffs: StringDiff[] | undefined, -) => { - if (diffs === undefined) { - return before; - } +export const apply_diff_str = (before: string, diffs: StringDiff[]) => { // Walk from the last diff to the first. JavaScript doesn't have reverse // iteration AFAIK. let after = before; diff --git a/extensions/VSCode/package.json b/extensions/VSCode/package.json index 36f4c4b3..7b4d894e 100644 --- a/extensions/VSCode/package.json +++ b/extensions/VSCode/package.json @@ -42,18 +42,6 @@ "url": "https://github.com/bjones1/CodeChat_Editor" }, "version": "0.1.54-beta1", - "activationEvents": [ - "onCommand:extension.codeChatEditorActivate", - "onCommand:extension.codeChatEditorDeactivate", - "onCommand:extension.codeChatCaptureStatus", - "onCommand:extension.codeChatInsertReflectionPrompt", - "onCommand:extension.codeChatCaptureTaskStart", - "onCommand:extension.codeChatCaptureTaskSubmit", - "onCommand:extension.codeChatCaptureDebugTaskStart", - "onCommand:extension.codeChatCaptureDebugTaskSubmit", - "onCommand:extension.codeChatCaptureHandoffStart", - "onCommand:extension.codeChatCaptureHandoffEnd" - ], "contributes": { "configuration": { "title": "CodeChat Editor", @@ -74,7 +62,7 @@ "CodeChatEditor.Capture.RecordStudyEvents": { "type": "boolean", "default": false, - "markdownDescription": "Record CodeChat dissertation capture events. This defaults to off. Consent is recorded through **Manage CodeChat Capture** and also defaults to off.\n\n| Consent recorded | Record study events | What happens |\n| --- | --- | --- |\n| Off | Off | Capture is off. |\n| On | Off | Consent is retained, but recording is paused. |\n| On | On | Capture records study events. |\n| Off | On | Capture waits for consent before recording. |" + "markdownDescription": "Record CodeChat dissertation capture events. This defaults to off. Consent is recorded through **Manage CodeChat Editor Capture** and also defaults to off.\n\n| Consent recorded | Record study events | What happens |\n| --- | --- | --- |\n| Off | Off | Capture is off. |\n| On | Off | Consent is retained, but recording is paused. |\n| On | On | Capture records study events. |\n| Off | On | Capture waits for consent before recording. |" }, "CodeChatEditor.Capture.ConsentEnabled": { "type": "boolean", @@ -104,11 +92,11 @@ }, { "command": "extension.codeChatCaptureStatus", - "title": "Manage CodeChat Capture" + "title": "Manage CodeChat Editor Capture" }, { "command": "extension.codeChatInsertReflectionPrompt", - "title": "CodeChat: Insert Reflection Prompt" + "title": "CodeChat Editor: Insert Reflection Prompt" } ] }, diff --git a/extensions/VSCode/src/extension.ts b/extensions/VSCode/src/extension.ts index 22b74dc7..2196ff6b 100644 --- a/extensions/VSCode/src/extension.ts +++ b/extensions/VSCode/src/extension.ts @@ -435,17 +435,16 @@ function captureLog(message: string): void { } function capturePayloadSummary(payload: CaptureEventWire): string { - const data = payload.data as CaptureEventData; return [ `type=${payload.event_type}`, `event_id=${payload.event_id}`, `sequence=${payload.sequence_number?.toString()}`, `schema=${payload.schema_version}`, `user_id=${payload.user_id}`, - `session_id=${data.session_id}`, + `session_id=${payload.session_id}`, `source=${payload.event_source}`, `language=${payload.language_id ?? ""}`, - `path_privacy=${data.path_privacy ?? ""}`, + `path_privacy=${payload.path_privacy ?? ""}`, payload.file_hash ? `file_hash=${payload.file_hash}` : "", payload.file_path ? `file_path=${payload.file_path}` : "", ] @@ -517,15 +516,15 @@ async function sendCaptureEvent( sequence_number: BigInt(++captureSequenceNumber), schema_version: CAPTURE_SCHEMA_VERSION, user_id: participantId, + session_id: CAPTURE_SESSION_ID, event_source: CAPTURE_EVENT_SOURCE, ...fileFields, + path_privacy: settings.hashFilePaths ? "sha256" : "plain", event_type: eventType, client_timestamp_ms: BigInt(Date.now()), client_tz_offset_min: new Date().getTimezoneOffset(), data: { ...data, - session_id: CAPTURE_SESSION_ID, - path_privacy: settings.hashFilePaths ? "sha256" : "plain", capture_active: captureActive, // A control-only event updates the server's capture context but is // intentionally not inserted into capture storage. diff --git a/server/scripts/capture_events_schema.sql b/server/scripts/capture_events_schema.sql index ddaee807..9777f99e 100644 --- a/server/scripts/capture_events_schema.sql +++ b/server/scripts/capture_events_schema.sql @@ -3,7 +3,9 @@ -- This script updates an existing legacy `events` table to the lean capture -- schema used for dissertation telemetry. It converts `timestamp` and `data` to -- analysis-friendly PostgreSQL types and backfills typed telemetry from --- existing JSON payloads where possible. Study metadata such as course, group, +-- older JSON payloads where possible. New capture code writes known telemetry +-- metadata to first-class columns and reserves `data` for event-specific +-- details. Study metadata such as course, group, -- assignment, condition, and task is intentionally omitted: those values are -- joined during analysis from researcher-managed participant/date mappings. @@ -171,6 +173,21 @@ COMMENT ON COLUMN public.events.user_id IS 'Pseudonymous participant UUID genera COMMENT ON COLUMN public.events.session_id IS 'Capture session UUID emitted by the VS Code extension.'; COMMENT ON COLUMN public.events.file_hash IS 'SHA-256 hash of the file path when path hashing is enabled.'; COMMENT ON COLUMN public.events.file_path IS 'Raw captured file path; NULL when path hashing is enabled.'; -COMMENT ON COLUMN public.events.data IS 'Event-specific JSON payload. Duplicates typed telemetry metadata for portable fallback exports.'; +COMMENT ON COLUMN public.events.data IS 'Event-specific JSON payload. Known telemetry metadata lives in typed columns.'; + +-- Least-privilege deployment guidance: +-- students or classroom machines should use a dedicated writer account, not a +-- database owner or administrator account. After replacing the placeholder +-- password/database/user names, a database administrator can grant only the +-- permissions needed for capture inserts: +-- +-- CREATE ROLE codechat_capture_writer LOGIN PASSWORD 'replace-with-secret'; +-- GRANT CONNECT ON DATABASE codechat_capture TO codechat_capture_writer; +-- GRANT USAGE ON SCHEMA public TO codechat_capture_writer; +-- GRANT INSERT ON public.events TO codechat_capture_writer; +-- GRANT USAGE ON SEQUENCE public.events_id_seq TO codechat_capture_writer; +-- +-- Do not grant SELECT, UPDATE, DELETE, CREATE, or ownership privileges to the +-- writer account used in `capture_config.json`. COMMIT; diff --git a/server/src/capture.rs b/server/src/capture.rs index adeae690..0ead61b8 100644 --- a/server/src/capture.rs +++ b/server/src/capture.rs @@ -61,6 +61,8 @@ use std::{ fs::{self, OpenOptions}, io::{self, Write}, path::{Path, PathBuf}, + process, + sync::atomic::{AtomicU64, Ordering}, sync::{Arc, Mutex}, thread, }; @@ -73,6 +75,8 @@ use tokio::sync::mpsc; use tokio_postgres::{Client, NoTls}; use ts_rs::TS; +static NEXT_CAPTURE_EVENT_ID: AtomicU64 = AtomicU64::new(1); + /// Canonical event types. Keep the serialized strings stable for analysis. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, TS)] #[serde(rename_all = "snake_case")] @@ -252,6 +256,59 @@ impl CaptureConfig { } } +/// Load capture configuration from environment variables or the repo/runtime +/// `capture_config.json`. +/// +/// Environment variables take precedence so deployment can inject secrets +/// without writing them to disk. Local development and student-facing setup use +/// the single config file at `root_path/capture_config.json`. +pub fn load_capture_config(root_path: &Path) -> Option { + match CaptureConfig::from_env() { + Ok(Some(cfg)) => return Some(with_default_capture_fallback_path(cfg, root_path)), + Ok(None) => {} + Err(err) => { + warn!("Capture: invalid environment configuration: {err}"); + return None; + } + } + + let config_path = root_path.join("capture_config.json"); + + match fs::read_to_string(&config_path) { + Ok(json) => match serde_json::from_str::(&json) { + Ok(cfg) => Some(with_default_capture_fallback_path(cfg, root_path)), + Err(err) => { + warn!("Capture: invalid JSON in {config_path:?}: {err}"); + None + } + }, + Err(err) => { + info!( + "Capture: disabled (no CODECHAT_CAPTURE_* env and no readable config at {config_path:?}: {err})" + ); + None + } + } +} + +/// Normalize the fallback JSONL path to the runtime root when a relative path +/// or no path is provided. +pub fn with_default_capture_fallback_path( + mut cfg: CaptureConfig, + root_path: &Path, +) -> CaptureConfig { + match &cfg.fallback_path { + Some(path) if path.is_relative() => { + cfg.fallback_path = Some(root_path.join(path)); + } + Some(_) => {} + None => { + cfg.fallback_path = Some(root_path.join("capture-events-fallback.jsonl")); + } + } + cfg +} + fn env_var_trimmed(name: &str) -> Option { env::var(name) .ok() @@ -314,17 +371,67 @@ impl CaptureStatus { } } +/// Typed metadata stored in first-class DB columns instead of the event-specific +/// JSON `data` payload. +#[derive(Debug, Clone, Default)] +pub struct CaptureEventMetadata { + /// Globally unique event identifier, generated by the client or server. + pub event_id: Option, + /// Client-local event order for one extension session. + pub sequence_number: Option, + /// Capture payload schema version. + pub schema_version: Option, + /// Logical capture session UUID. + pub session_id: Option, + /// Origin of the event stream, such as the VS Code extension. + pub event_source: Option, + /// VS Code language identifier for the active file, when known. + pub language_id: Option, + /// Privacy-preserving SHA-256 hash of the local file path. + pub file_hash: Option, + /// Whether the path was sent plainly, hashed, or omitted. + pub path_privacy: Option, + /// Client timestamp, in milliseconds since Unix epoch. + pub client_timestamp_ms: Option, + /// Client timezone offset in minutes. + pub client_tz_offset_min: Option, + /// Server timestamp, in milliseconds since Unix epoch. + pub server_timestamp_ms: Option, +} + /// The in-memory representation of a single capture event. #[derive(Debug, Clone)] pub struct CaptureEvent { + /// Globally unique event identifier, generated by the client or server. + pub event_id: Option, + /// Client-local event order for one extension session. + pub sequence_number: Option, + /// Capture payload schema version. + pub schema_version: Option, /// Pseudonymous participant UUID supplied by the extension. pub user_id: String, + /// Logical capture session UUID. + pub session_id: Option, + /// Origin of the event stream, such as the VS Code extension. + pub event_source: Option, + /// VS Code language identifier for the active file, when known. + pub language_id: Option, + /// Privacy-preserving SHA-256 hash of the local file path. + pub file_hash: Option, /// Raw file path when path hashing is disabled. pub file_path: Option, + /// Whether the path was sent plainly, hashed, or omitted. + pub path_privacy: Option, /// Canonical type of the captured event. pub event_type: CaptureEventType, /// When the event occurred, in UTC. pub timestamp: DateTime, + /// Client timestamp, in milliseconds since Unix epoch. + pub client_timestamp_ms: Option, + /// Client timezone offset in minutes. + pub client_tz_offset_min: Option, + /// Server timestamp, in milliseconds since Unix epoch. + pub server_timestamp_ms: i64, /// Event-specific payload, stored as JSON text in the DB. pub data: serde_json::Value, } @@ -337,12 +444,44 @@ impl CaptureEvent { event_type: CaptureEventType, timestamp: DateTime, data: serde_json::Value, + ) -> Self { + Self::with_metadata( + user_id, + file_path, + event_type, + timestamp, + data, + CaptureEventMetadata::default(), + ) + } + + /// Constructor for callers that already have first-class capture metadata. + pub fn with_metadata( + user_id: String, + file_path: Option, + event_type: CaptureEventType, + timestamp: DateTime, + data: serde_json::Value, + metadata: CaptureEventMetadata, ) -> Self { Self { + event_id: metadata.event_id, + sequence_number: metadata.sequence_number, + schema_version: metadata.schema_version, user_id, + session_id: metadata.session_id, + event_source: metadata.event_source, + language_id: metadata.language_id, + file_hash: metadata.file_hash, file_path, + path_privacy: metadata.path_privacy, event_type, timestamp, + client_timestamp_ms: metadata.client_timestamp_ms, + client_tz_offset_min: metadata.client_tz_offset_min, + server_timestamp_ms: metadata + .server_timestamp_ms + .unwrap_or_else(|| timestamp.timestamp_millis()), data, } } @@ -358,6 +497,17 @@ impl CaptureEvent { } } +/// Generate a server-side event ID for events classified after the original +/// extension message has been processed. +pub fn generate_capture_event_id(prefix: &str) -> String { + let counter = NEXT_CAPTURE_EVENT_ID.fetch_add(1, Ordering::Relaxed); + format!( + "{prefix}-{}-{}-{counter}", + process::id(), + Utc::now().timestamp_micros() + ) +} + /// Internal worker message. Identical to `CaptureEvent`, but separated in case /// we later want to add batching / flush control signals. type WorkerMsg = CaptureEvent; @@ -588,10 +738,21 @@ fn append_fallback_event(fallback_path: &Path, event: &CaptureEvent) -> io::Resu let record = serde_json::json!({ "fallback_timestamp": Utc::now().to_rfc3339(), "event": { + "event_id": event.event_id, + "sequence_number": event.sequence_number, + "schema_version": event.schema_version, "user_id": event.user_id, + "session_id": event.session_id, + "event_source": event.event_source, + "language_id": event.language_id, + "file_hash": event.file_hash, "file_path": event.file_path, + "path_privacy": event.path_privacy, "event_type": event.event_type.as_str(), "timestamp": event.timestamp.to_rfc3339(), + "client_timestamp_ms": event.client_timestamp_ms, + "client_tz_offset_min": event.client_tz_offset_min, + "server_timestamp_ms": event.server_timestamp_ms, "data": event.data, } }); @@ -638,27 +799,6 @@ fn log_pg_connect_error(context: &str, err: &tokio_postgres::Error) { error!("{context}: {err}"); } -fn capture_data_str(data: &serde_json::Value, names: &[&str]) -> Option { - names.iter().find_map(|name| { - data.get(*name) - .and_then(serde_json::Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string) - }) -} - -fn capture_data_i64(data: &serde_json::Value, name: &str) -> Option { - let value = data.get(name)?; - value - .as_i64() - .or_else(|| value.as_str()?.trim().parse::().ok()) -} - -fn capture_data_i32(data: &serde_json::Value, name: &str) -> Option { - capture_data_i64(data, name).and_then(|value| i32::try_from(value).ok()) -} - fn should_retry_legacy_insert(err: &tokio_postgres::Error) -> bool { matches!( err.code().map(|code| code.code()), @@ -685,18 +825,6 @@ async fn insert_rich_event( event: &CaptureEvent, ) -> Result { let timestamp = event.timestamp.to_rfc3339(); - let event_id = capture_data_str(&event.data, &["event_id"]); - let sequence_number = capture_data_i64(&event.data, "sequence_number"); - let schema_version = capture_data_i32(&event.data, "schema_version"); - let session_id = capture_data_str(&event.data, &["session_id"]); - let event_source = capture_data_str(&event.data, &["event_source"]); - let language_id = capture_data_str(&event.data, &["language_id", "languageId"]); - let file_hash = capture_data_str(&event.data, &["file_hash"]); - let path_privacy = capture_data_str(&event.data, &["path_privacy"]); - let client_timestamp_ms = capture_data_i64(&event.data, "client_timestamp_ms"); - let client_tz_offset_min = capture_data_i32(&event.data, "client_tz_offset_min"); - let server_timestamp_ms = capture_data_i64(&event.data, "server_timestamp_ms") - .unwrap_or_else(|| event.timestamp.timestamp_millis()); let data_text = event.data.to_string(); let event_type = event.event_type.as_str(); @@ -720,21 +848,21 @@ async fn insert_rich_event( $11, $12::text::timestamptz, $13, $14, \ $15, $16::text::jsonb)", &[ - &event_id, - &sequence_number, - &schema_version, + &event.event_id, + &event.sequence_number, + &event.schema_version, &event.user_id, - &session_id, - &event_source, - &language_id, - &file_hash, + &event.session_id, + &event.event_source, + &event.language_id, + &event.file_hash, &event.file_path, - &path_privacy, + &event.path_privacy, &event_type, ×tamp, - &client_timestamp_ms, - &client_tz_offset_min, - &server_timestamp_ms, + &event.client_timestamp_ms, + &event.client_tz_offset_min, + &event.server_timestamp_ms, &data_text, ], ) @@ -831,6 +959,8 @@ mod tests { assert_eq!(ev.file_path.as_deref(), Some("/path/to/file.rs")); assert_eq!(ev.event_type, event_types::WRITE_DOC); assert_eq!(ev.timestamp, ts); + assert_eq!(ev.server_timestamp_ms, ts.timestamp_millis()); + assert!(ev.event_id.is_none()); assert_eq!(ev.data, json!({ "chars_typed": 42 })); } @@ -848,6 +978,7 @@ mod tests { assert_eq!(ev.user_id, "user123"); assert!(ev.file_path.is_none()); assert_eq!(ev.event_type, event_types::SAVE); + assert_eq!(ev.server_timestamp_ms, ev.timestamp.timestamp_millis()); assert_eq!(ev.data, json!({ "reason": "manual" })); // Timestamp sanity check: it should be between before and after @@ -856,26 +987,42 @@ mod tests { } #[test] - fn capture_metadata_helpers_extract_typed_values() { - let data = json!({ - "event_id": "abc-123", - "sequence_number": "42", - "schema_version": 2, - "languageId": "rust", - "client_tz_offset_min": "-360" - }); + fn capture_event_with_metadata_sets_analysis_columns() { + let ts = Utc::now(); - assert_eq!( - capture_data_str(&data, &["language_id", "languageId"]).as_deref(), - Some("rust") - ); - assert_eq!( - capture_data_str(&data, &["event_id"]).as_deref(), - Some("abc-123") + let ev = CaptureEvent::with_metadata( + "user123".to_string(), + None, + event_types::WRITE_CODE, + ts, + json!({ "chars_typed": 42 }), + CaptureEventMetadata { + event_id: Some("abc-123".to_string()), + sequence_number: Some(42), + schema_version: Some(2), + session_id: Some("session-1".to_string()), + event_source: Some("vscode_extension".to_string()), + language_id: Some("rust".to_string()), + file_hash: Some("hash".to_string()), + path_privacy: Some("sha256".to_string()), + client_timestamp_ms: Some(ts.timestamp_millis() - 50), + client_tz_offset_min: Some(-360), + server_timestamp_ms: Some(ts.timestamp_millis()), + }, ); - assert_eq!(capture_data_i64(&data, "sequence_number"), Some(42)); - assert_eq!(capture_data_i32(&data, "schema_version"), Some(2)); - assert_eq!(capture_data_i32(&data, "client_tz_offset_min"), Some(-360)); + + assert_eq!(ev.event_id.as_deref(), Some("abc-123")); + assert_eq!(ev.sequence_number, Some(42)); + assert_eq!(ev.schema_version, Some(2)); + assert_eq!(ev.session_id.as_deref(), Some("session-1")); + assert_eq!(ev.event_source.as_deref(), Some("vscode_extension")); + assert_eq!(ev.language_id.as_deref(), Some("rust")); + assert_eq!(ev.file_hash.as_deref(), Some("hash")); + assert_eq!(ev.path_privacy.as_deref(), Some("sha256")); + assert_eq!(ev.client_timestamp_ms, Some(ts.timestamp_millis() - 50)); + assert_eq!(ev.client_tz_offset_min, Some(-360)); + assert_eq!(ev.server_timestamp_ms, ts.timestamp_millis()); + assert_eq!(ev.data, json!({ "chars_typed": 42 })); } #[test] @@ -914,9 +1061,9 @@ mod tests { /// Integration-style test: verify that EventCapture inserts into the rich /// capture schema used by dissertation analysis. /// - /// Reads connection parameters from `capture_config.json` in the current - /// working directory. Logs the config and connection details via log4rs so - /// you can confirm what is used. + /// Reads connection parameters from the repo-root `capture_config.json`. + /// Logs the config and connection details via log4rs so you can confirm + /// what is used. /// /// Run this test with: /// cargo test event\_capture\_inserts\_rich_schema\_event\_into\_db @@ -936,11 +1083,8 @@ mod tests { let _ = log4rs::init_file("log4rs.yml", Default::default()); // 1. Load the capture configuration from file. - let cfg_text = fs::read_to_string("capture_config.json") - .or_else(|_| fs::read_to_string("../capture_config.json")) - .expect( - "capture_config.json must exist in the server directory or repo root for this test", - ); + let cfg_text = fs::read_to_string("../capture_config.json") + .expect("capture_config.json must exist in the repo root for this test"); let cfg: CaptureConfig = serde_json::from_str(&cfg_text).expect("capture_config.json must be valid JSON"); @@ -1013,25 +1157,28 @@ mod tests { let expected_server_timestamp_ms = event_timestamp.timestamp_millis(); let expected_client_timestamp_ms = expected_server_timestamp_ms - 50; let expected_data = json!({ - "event_id": expected_event_id, - "sequence_number": 42, - "schema_version": 2, - "session_id": expected_session_id, - "event_source": "integration_test", - "language_id": "rust", - "file_hash": expected_file_hash, - "path_privacy": "sha256", - "client_timestamp_ms": expected_client_timestamp_ms, - "client_tz_offset_min": 360, - "server_timestamp_ms": expected_server_timestamp_ms, "chars_typed": 123, "classification_basis": "integration_test" }); - let event = CaptureEvent::now( + let event = CaptureEvent::with_metadata( expected_user_id.clone(), None, event_types::WRITE_DOC, + event_timestamp, expected_data.clone(), + CaptureEventMetadata { + event_id: Some(expected_event_id.clone()), + sequence_number: Some(42), + schema_version: Some(2), + session_id: Some(expected_session_id.clone()), + event_source: Some("integration_test".to_string()), + language_id: Some("rust".to_string()), + file_hash: Some(expected_file_hash.clone()), + path_privacy: Some("sha256".to_string()), + client_timestamp_ms: Some(expected_client_timestamp_ms), + client_tz_offset_min: Some(360), + server_timestamp_ms: Some(expected_server_timestamp_ms), + }, ); log::info!("TEST: logging a test capture event."); diff --git a/server/src/translation.rs b/server/src/translation.rs index ab411923..e41d97d7 100644 --- a/server/src/translation.rs +++ b/server/src/translation.rs @@ -459,8 +459,10 @@ struct CaptureContext { user_id: Option, /// Origin of the client event stream, such as the VS Code extension. event_source: Option, - /// Extension session UUID carried in the event data payload. + /// Extension session UUID carried on the capture wire payload. session_id: Option, + /// Whether extension-originated file paths are sent plainly or hashed. + path_privacy: Option, /// Client timezone offset in minutes, retained for generated write events. client_tz_offset_min: Option, /// Capture payload schema version from the extension. @@ -489,6 +491,12 @@ impl CaptureContext { if let Some(event_source) = &wire.event_source { self.event_source = Some(event_source.clone()); } + if let Some(session_id) = &wire.session_id { + self.session_id = Some(session_id.clone()); + } + if let Some(path_privacy) = &wire.path_privacy { + self.path_privacy = Some(path_privacy.clone()); + } if let Some(schema_version) = wire.schema_version { self.schema_version = Some(schema_version); } @@ -504,11 +512,14 @@ impl CaptureContext { { self.active = active; } - // The extension's logical capture session ties server-classified - // write events to the same session as extension-originated events. + // Support older wire payloads that stored this metadata in `data`. if let Some(session_id) = data.get("session_id").and_then(serde_json::Value::as_str) { self.session_id = Some(session_id.to_string()); } + if let Some(path_privacy) = data.get("path_privacy").and_then(serde_json::Value::as_str) + { + self.path_privacy = Some(path_privacy.to_string()); + } } } @@ -533,10 +544,6 @@ impl CaptureContext { map } }; - if let Some(session_id) = &self.session_id { - data.entry("session_id".to_string()) - .or_insert_with(|| serde_json::json!(session_id)); - } // Preserve any existing source field, but default server-generated // events to `server_translation` for analysis. data.entry("source".to_string()) @@ -547,10 +554,12 @@ impl CaptureContext { sequence_number: None, schema_version: self.schema_version, user_id: self.user_id.clone()?, + session_id: self.session_id.clone(), event_source: self.event_source.clone(), language_id: None, file_hash: None, file_path, + path_privacy: self.path_privacy.clone(), event_type, client_timestamp_ms: None, client_tz_offset_min: self.client_tz_offset_min, @@ -1708,10 +1717,12 @@ mod tests { sequence_number: None, schema_version: Some(2), user_id: "participant".to_string(), + session_id: None, event_source: Some("vscode_extension".to_string()), language_id: None, file_hash: None, file_path: None, + path_privacy: None, event_type, client_timestamp_ms: None, client_tz_offset_min: Some(360), diff --git a/server/src/webserver.rs b/server/src/webserver.rs index 166d0afc..57d619c6 100644 --- a/server/src/webserver.rs +++ b/server/src/webserver.rs @@ -45,7 +45,7 @@ use actix_web::{ error::Error, get, http::header::{ContentType, DispositionType}, - middleware, post, + middleware, web::{self, Data}, }; @@ -97,7 +97,10 @@ use crate::{ }, }; -use crate::capture::{CaptureConfig, CaptureEvent, CaptureEventType, CaptureStatus, EventCapture}; +use crate::capture::{ + CaptureEvent, CaptureEventMetadata, CaptureEventType, CaptureStatus, EventCapture, + generate_capture_event_id, load_capture_config, +}; use chrono::Utc; @@ -445,6 +448,9 @@ pub struct CaptureEventWire { pub schema_version: Option, /// Pseudonymous participant UUID. This is not the student's real identity. pub user_id: String, + /// Logical capture session UUID. + #[serde(skip_serializing_if = "Option::is_none")] + pub session_id: Option, /// Source of this event, such as the VS Code extension or server translation. #[serde(skip_serializing_if = "Option::is_none")] pub event_source: Option, @@ -457,6 +463,9 @@ pub struct CaptureEventWire { /// Raw file path only when path hashing is disabled for debugging. #[serde(skip_serializing_if = "Option::is_none")] pub file_path: Option, + /// Whether the path was sent plainly, hashed, or omitted. + #[serde(skip_serializing_if = "Option::is_none")] + pub path_privacy: Option, /// Canonical capture event type. pub event_type: CaptureEventType, @@ -662,19 +671,6 @@ async fn stop(app_state: WebAppState) -> HttpResponse { HttpResponse::NoContent().finish() } -#[post("/capture")] -async fn capture_endpoint( - app_state: WebAppState, - payload: web::Json, -) -> HttpResponse { - let status = log_capture_event(&app_state, payload.into_inner()); - if status.enabled { - HttpResponse::Accepted().json(status) - } else { - HttpResponse::ServiceUnavailable().json(status) - } -} - #[get("/capture/status")] async fn capture_status_endpoint(app_state: WebAppState) -> HttpResponse { HttpResponse::Ok().json(capture_status(&app_state)) @@ -685,60 +681,39 @@ pub fn log_capture_event(app_state: &WebAppState, wire: CaptureEventWire) -> Cap if let Some(capture) = &app_state.capture { let server_timestamp = Utc::now(); // Default missing data to empty object - let mut data = wire.data.unwrap_or_else(|| serde_json::json!({})); + let data = wire.data.unwrap_or_else(|| serde_json::json!({})); - // Ensure data is an object so we can attach fields - if !data.is_object() { - data = serde_json::json!({ "value": data }); - } + let data = if data.is_object() { + data + } else { + serde_json::json!({ "value": data }) + }; - // Add client timestamp fields if present (even if extension also sends them; - // overwriting is fine and consistent). - if let serde_json::Value::Object(map) = &mut data { - if let Some(event_id) = &wire.event_id { - map.insert("event_id".to_string(), serde_json::json!(event_id)); - } - if let Some(sequence_number) = wire.sequence_number { - map.insert( - "sequence_number".to_string(), - serde_json::json!(sequence_number), - ); - } - if let Some(schema_version) = wire.schema_version { - map.insert( - "schema_version".to_string(), - serde_json::json!(schema_version), - ); - } - if let Some(event_source) = &wire.event_source { - map.insert("event_source".to_string(), serde_json::json!(event_source)); - } - if let Some(language_id) = &wire.language_id { - map.insert("language_id".to_string(), serde_json::json!(language_id)); - } - if let Some(file_hash) = &wire.file_hash { - map.insert("file_hash".to_string(), serde_json::json!(file_hash)); - } - if let Some(ms) = wire.client_timestamp_ms { - map.insert("client_timestamp_ms".to_string(), serde_json::json!(ms)); - } - if let Some(tz) = wire.client_tz_offset_min { - map.insert("client_tz_offset_min".to_string(), serde_json::json!(tz)); - } - map.insert( - "server_timestamp_ms".to_string(), - serde_json::json!(server_timestamp.timestamp_millis()), - ); - } + let metadata = CaptureEventMetadata { + event_id: Some( + wire.event_id + .unwrap_or_else(|| generate_capture_event_id("server")), + ), + sequence_number: wire.sequence_number, + schema_version: wire.schema_version, + session_id: wire.session_id, + event_source: wire.event_source, + language_id: wire.language_id, + file_hash: wire.file_hash, + path_privacy: wire.path_privacy, + client_timestamp_ms: wire.client_timestamp_ms, + client_tz_offset_min: wire.client_tz_offset_min, + server_timestamp_ms: Some(server_timestamp.timestamp_millis()), + }; - let event = CaptureEvent { - user_id: wire.user_id, - file_path: wire.file_path, - event_type: wire.event_type, - // Server decides when the event is recorded. - timestamp: server_timestamp, + let event = CaptureEvent::with_metadata( + wire.user_id, + wire.file_path, + wire.event_type, + server_timestamp, data, - }; + metadata, + ); capture.log(event); capture.status() @@ -1679,15 +1654,16 @@ pub fn configure_logger(level: LevelFilter) -> Result<(), Box) -> WebAppState { // Initialize event capture from a config file (optional). - let capture: Option = load_capture_config().and_then(|cfg| { + let root_path = ROOT_PATH.lock().unwrap().clone(); + let capture: Option = load_capture_config(&root_path).and_then(|cfg| { let summary = cfg.redacted_summary(); match EventCapture::new(cfg) { Ok(ec) => { - eprintln!("Capture: enabled ({summary})"); + info!("Capture: enabled ({summary})"); Some(ec) } Err(err) => { - eprintln!("Capture: failed to initialize ({summary}): {err}"); + warn!("Capture: failed to initialize ({summary}): {err}"); None } } @@ -1707,50 +1683,6 @@ pub fn make_app_data(credentials: Option) -> WebAppState { }) } -fn load_capture_config() -> Option { - match CaptureConfig::from_env() { - Ok(Some(cfg)) => return Some(with_default_capture_fallback_path(cfg)), - Ok(None) => {} - Err(err) => { - eprintln!("Capture: invalid environment configuration: {err}"); - return None; - } - } - - let mut config_path = ROOT_PATH.lock().unwrap().clone(); - config_path.push("capture_config.json"); - - match fs::read_to_string(&config_path) { - Ok(json) => match serde_json::from_str::(&json) { - Ok(cfg) => Some(with_default_capture_fallback_path(cfg)), - Err(err) => { - eprintln!("Capture: invalid JSON in {config_path:?}: {err}"); - None - } - }, - Err(err) => { - eprintln!( - "Capture: disabled (no CODECHAT_CAPTURE_* env and no readable config at {config_path:?}: {err})" - ); - None - } - } -} - -fn with_default_capture_fallback_path(mut cfg: CaptureConfig) -> CaptureConfig { - let root_path = ROOT_PATH.lock().unwrap().clone(); - match &cfg.fallback_path { - Some(path) if path.is_relative() => { - cfg.fallback_path = Some(root_path.join(path)); - } - Some(_) => {} - None => { - cfg.fallback_path = Some(root_path.join("capture-events-fallback.jsonl")); - } - } - cfg -} - // Configure the web application. I'd like to make this return an // `App`, but `AppEntry` is a private module. pub fn configure_app(app: App, app_data: &WebAppState) -> App @@ -1777,7 +1709,6 @@ where .service(vscode_client_framework) .service(ping) .service(stop) - .service(capture_endpoint) .service(capture_status_endpoint) // Reroute to the filewatcher filesystem for typical user-requested // URLs. diff --git a/server/tests/overall_1.rs b/server/tests/overall_1.rs index b2fb189a..a4a05497 100644 --- a/server/tests/overall_1.rs +++ b/server/tests/overall_1.rs @@ -33,7 +33,7 @@ use std::{error::Error, path::PathBuf, time::Duration}; use dunce::canonicalize; use indoc::indoc; use pretty_assertions::assert_eq; -use thirtyfour::{By, Key, WebDriver, WebElement, error::WebDriverError}; +use thirtyfour::{By, Key, WebDriver, error::WebDriverError, prelude::ElementQueryable}; use tokio::time::sleep; // ### Local @@ -753,17 +753,6 @@ async fn test_client_core( make_test!(test_client_updates, test_client_updates_core); -async fn wait_for_element(driver: &WebDriver, css: &str) -> Result { - for _ in 0..50 { - if let Ok(element) = driver.find(By::Css(css)).await { - return Ok(element); - } - sleep(Duration::from_millis(100)).await; - } - - driver.find(By::Css(css)).await -} - async fn test_client_updates_core( codechat_server: CodeChatEditorServer, driver: WebDriver, @@ -802,12 +791,13 @@ async fn test_client_updates_core( let contents_css = ".CodeChat-CodeMirror .CodeChat-doc-contents"; let doc_block_contents = driver.find(By::Css(contents_css)).await.unwrap(); doc_block_contents.click().await.unwrap(); - let doc_block_contents = wait_for_element( - &driver, - ".CodeChat-CodeMirror #TinyMCE-inst:not(.CodeChat-doc-hidden)", - ) - .await - .unwrap(); + let doc_block_contents = driver + .query(By::Css( + ".CodeChat-CodeMirror #TinyMCE-inst:not(.CodeChat-doc-hidden)", + )) + .first() + .await + .unwrap(); // Add to the line, causing a word wrap. doc_block_contents From 3b94efd4f687cb582b108b2a5878fa07e6173e28 Mon Sep 17 00:00:00 2001 From: "JDS-WORKSTATION\\jspah" <44337821+jspahn80134@users.noreply.github.com> Date: Tue, 19 May 2026 08:01:19 -0600 Subject: [PATCH 31/45] Refine capture event schema --- extensions/VSCode/package.json | 5 - extensions/VSCode/src/extension.ts | 17 +- server/Cargo.lock | 1 + server/Cargo.toml | 1 + server/scripts/capture_events_schema.sql | 22 +- server/src/capture.rs | 371 +++++++++++------------ server/src/translation.rs | 41 +-- server/src/webserver.rs | 46 +-- 8 files changed, 214 insertions(+), 290 deletions(-) diff --git a/extensions/VSCode/package.json b/extensions/VSCode/package.json index f4b3eaa7..9b80fd3c 100644 --- a/extensions/VSCode/package.json +++ b/extensions/VSCode/package.json @@ -73,11 +73,6 @@ "type": "string", "default": "", "markdownDescription": "Pseudonymous participant identifier used as the capture user_id. If left blank, CodeChat generates a UUID when the student gives consent." - }, - "CodeChatEditor.Capture.HashFilePaths": { - "type": "boolean", - "default": true, - "markdownDescription": "Hash local file paths before they are sent to capture storage." } } }, diff --git a/extensions/VSCode/src/extension.ts b/extensions/VSCode/src/extension.ts index 2196ff6b..a7b9fa6b 100644 --- a/extensions/VSCode/src/extension.ts +++ b/extensions/VSCode/src/extension.ts @@ -212,8 +212,6 @@ interface StudySettings { consentEnabled: boolean; // Pseudonymous UUID used as the event user ID; generated when absent. participantId: string; - // True to avoid storing raw local paths in capture events. - hashFilePaths: boolean; } // Derived state for the two user-visible capture checkboxes. This mirrors the @@ -294,7 +292,6 @@ function loadStudySettings(): StudySettings { enabled: config.get(CAPTURE_RECORD_SETTING_NAME, false), consentEnabled: config.get("ConsentEnabled", false), participantId: optionalString(config.get("ParticipantId")) ?? "", - hashFilePaths: config.get("HashFilePaths", true), }; } @@ -319,8 +316,7 @@ function captureSettingsEqual(a: StudySettings, b: StudySettings): boolean { return ( a.enabled === b.enabled && a.consentEnabled === b.consentEnabled && - a.participantId === b.participantId && - a.hashFilePaths === b.hashFilePaths + a.participantId === b.participantId ); } @@ -413,8 +409,7 @@ function hashText(value: string): string { function buildFileFields( filePath: string | undefined, - settings: StudySettings, -): Pick { +): Pick { if (filePath === undefined) { return { language_id: vscode.window.activeTextEditor?.document.languageId, @@ -422,8 +417,7 @@ function buildFileFields( } const document = get_document(filePath); return { - file_path: settings.hashFilePaths ? undefined : filePath, - file_hash: settings.hashFilePaths ? hashText(filePath) : undefined, + file_hash: hashText(filePath), language_id: document?.languageId, }; } @@ -444,9 +438,7 @@ function capturePayloadSummary(payload: CaptureEventWire): string { `session_id=${payload.session_id}`, `source=${payload.event_source}`, `language=${payload.language_id ?? ""}`, - `path_privacy=${payload.path_privacy ?? ""}`, payload.file_hash ? `file_hash=${payload.file_hash}` : "", - payload.file_path ? `file_path=${payload.file_path}` : "", ] .filter((part) => part.length > 0) .join(" "); @@ -504,7 +496,7 @@ async function sendCaptureEvent( : options.controlOnly ? settings.participantId || "capture_control" : await ensureParticipantId(); - const fileFields = buildFileFields(filePath, settings); + const fileFields = buildFileFields(filePath); // The server uses `capture_active` to decide whether it may generate // classified write_doc/write_code rows from translated edits. const captureActive = @@ -519,7 +511,6 @@ async function sendCaptureEvent( session_id: CAPTURE_SESSION_ID, event_source: CAPTURE_EVENT_SOURCE, ...fileFields, - path_privacy: settings.hashFilePaths ? "sha256" : "plain", event_type: eventType, client_timestamp_ms: BigInt(Date.now()), client_tz_offset_min: new Date().getTimezoneOffset(), diff --git a/server/Cargo.lock b/server/Cargo.lock index 27c1c786..c7e3d2a6 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -777,6 +777,7 @@ dependencies = [ "regex", "serde", "serde_json", + "sha2 0.11.0", "test_utils", "thirtyfour", "thiserror", diff --git a/server/Cargo.toml b/server/Cargo.toml index 7c4cab2b..5120fe74 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -94,6 +94,7 @@ rand = "0.10" regex = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" +sha2 = "0.11" test_utils = { path = "../test_utils" } thiserror = "2.0.12" tokio = { version = "1", features = ["full"] } diff --git a/server/scripts/capture_events_schema.sql b/server/scripts/capture_events_schema.sql index 9777f99e..dece1474 100644 --- a/server/scripts/capture_events_schema.sql +++ b/server/scripts/capture_events_schema.sql @@ -21,13 +21,10 @@ CREATE TABLE IF NOT EXISTS public.events ( event_source TEXT, language_id TEXT, file_hash TEXT, - file_path TEXT, - path_privacy TEXT, event_type TEXT NOT NULL, "timestamp" TIMESTAMPTZ NOT NULL DEFAULT now(), client_timestamp_ms BIGINT, client_tz_offset_min INTEGER, - server_timestamp_ms BIGINT, data JSONB NOT NULL DEFAULT '{}'::jsonb, inserted_at TIMESTAMPTZ NOT NULL DEFAULT now() ); @@ -39,10 +36,8 @@ ALTER TABLE public.events ADD COLUMN IF NOT EXISTS session_id TEXT; ALTER TABLE public.events ADD COLUMN IF NOT EXISTS event_source TEXT; ALTER TABLE public.events ADD COLUMN IF NOT EXISTS language_id TEXT; ALTER TABLE public.events ADD COLUMN IF NOT EXISTS file_hash TEXT; -ALTER TABLE public.events ADD COLUMN IF NOT EXISTS path_privacy TEXT; ALTER TABLE public.events ADD COLUMN IF NOT EXISTS client_timestamp_ms BIGINT; ALTER TABLE public.events ADD COLUMN IF NOT EXISTS client_tz_offset_min INTEGER; -ALTER TABLE public.events ADD COLUMN IF NOT EXISTS server_timestamp_ms BIGINT; ALTER TABLE public.events ADD COLUMN IF NOT EXISTS inserted_at TIMESTAMPTZ NOT NULL DEFAULT now(); ALTER TABLE public.events DROP COLUMN IF EXISTS assignment_id; @@ -51,6 +46,9 @@ ALTER TABLE public.events DROP COLUMN IF EXISTS condition; ALTER TABLE public.events DROP COLUMN IF EXISTS course_id; ALTER TABLE public.events DROP COLUMN IF EXISTS task_id; ALTER TABLE public.events DROP COLUMN IF EXISTS capture_mode; +ALTER TABLE public.events DROP COLUMN IF EXISTS file_path; +ALTER TABLE public.events DROP COLUMN IF EXISTS path_privacy; +ALTER TABLE public.events DROP COLUMN IF EXISTS server_timestamp_ms; DO $$ DECLARE @@ -123,7 +121,6 @@ SET NULLIF(data->>'languageId', '') ), file_hash = COALESCE(file_hash, NULLIF(data->>'file_hash', '')), - path_privacy = COALESCE(path_privacy, NULLIF(data->>'path_privacy', '')), client_timestamp_ms = COALESCE( client_timestamp_ms, CASE @@ -137,14 +134,6 @@ SET WHEN data->>'client_tz_offset_min' ~ '^-?[0-9]+$' THEN (data->>'client_tz_offset_min')::integer END - ), - server_timestamp_ms = COALESCE( - server_timestamp_ms, - CASE - WHEN data->>'server_timestamp_ms' ~ '^-?[0-9]+$' - THEN (data->>'server_timestamp_ms')::bigint - ELSE floor(extract(epoch from "timestamp") * 1000)::bigint - END ); CREATE INDEX IF NOT EXISTS events_timestamp_idx @@ -171,8 +160,9 @@ COMMENT ON TABLE public.events IS 'CodeChat dissertation capture events. Course, group, assignment, condition, and task context are joined during analysis from participant/date mappings.'; COMMENT ON COLUMN public.events.user_id IS 'Pseudonymous participant UUID generated or supplied by the VS Code extension.'; COMMENT ON COLUMN public.events.session_id IS 'Capture session UUID emitted by the VS Code extension.'; -COMMENT ON COLUMN public.events.file_hash IS 'SHA-256 hash of the file path when path hashing is enabled.'; -COMMENT ON COLUMN public.events.file_path IS 'Raw captured file path; NULL when path hashing is enabled.'; +COMMENT ON COLUMN public.events.file_hash IS 'SHA-256 hash of the local file path; raw local paths are not stored.'; +COMMENT ON COLUMN public.events."timestamp" IS 'Server receive/record timestamp in UTC.'; +COMMENT ON COLUMN public.events.client_timestamp_ms IS 'Optional client-observed event timestamp in milliseconds since Unix epoch.'; COMMENT ON COLUMN public.events.data IS 'Event-specific JSON payload. Known telemetry metadata lives in typed columns.'; -- Least-privilege deployment guidance: diff --git a/server/src/capture.rs b/server/src/capture.rs index 0ead61b8..d3a8275e 100644 --- a/server/src/capture.rs +++ b/server/src/capture.rs @@ -40,9 +40,8 @@ // // ```sql // event_id, sequence_number, schema_version, -// user_id, session_id, event_source, language_id, file_hash, file_path, -// path_privacy, event_type, timestamp, client_timestamp_ms, -// client_tz_offset_min, server_timestamp_ms, data +// user_id, session_id, event_source, language_id, file_hash, event_type, +// timestamp, client_timestamp_ms, client_tz_offset_min, data // ``` // // * `user_id` – pseudonymous participant UUID. Course, group, assignment, and @@ -50,10 +49,11 @@ // participant/date mappings instead of being configured by students. // * `session_id`, `event_id`, `sequence_number`, `schema_version` – event // integrity and versioning metadata. -// * `file_path` – logical path of the file being edited. -// * `file_hash` – privacy-preserving SHA-256 hash of the file path. +// * `file_hash` – privacy-preserving SHA-256 hash of the local file path. // * `event_type` – coarse event type (see `CaptureEventType` below). -// * `timestamp` – RFC3339 timestamp (in UTC). +// * `timestamp` – server receive/record timestamp (in UTC). +// * `client_timestamp_ms` – optional client-observed event time for ordering +// and latency analysis. // * `data` – JSONB payload with event-specific details. use std::{ @@ -70,6 +70,7 @@ use std::{ use chrono::{DateTime, Utc}; use log::{debug, error, info, warn}; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use std::error::Error; use tokio::sync::mpsc; use tokio_postgres::{Client, NoTls}; @@ -82,9 +83,10 @@ static NEXT_CAPTURE_EVENT_ID: AtomicU64 = AtomicU64::new(1); #[serde(rename_all = "snake_case")] #[ts(export)] pub enum CaptureEventType { - /// Server-classified edit to documentation/prose. + /// Edit to documentation/prose. In CodeChat files this means doc blocks; + /// fenced or embedded code content is classified as `WriteCode`. WriteDoc, - /// Server-classified edit to executable source code. + /// Edit to executable source code, including code inside CodeChat blocks. WriteCode, /// Editor activity moved between documentation and code contexts. SwitchPane, @@ -154,30 +156,13 @@ impl std::fmt::Display for CaptureEventType { } } -pub mod event_types { - use super::CaptureEventType; - - pub const WRITE_DOC: CaptureEventType = CaptureEventType::WriteDoc; - pub const WRITE_CODE: CaptureEventType = CaptureEventType::WriteCode; - pub const SWITCH_PANE: CaptureEventType = CaptureEventType::SwitchPane; - pub const DOC_SESSION: CaptureEventType = CaptureEventType::DocSession; - pub const SAVE: CaptureEventType = CaptureEventType::Save; - pub const COMPILE: CaptureEventType = CaptureEventType::Compile; - pub const RUN: CaptureEventType = CaptureEventType::Run; - pub const SESSION_START: CaptureEventType = CaptureEventType::SessionStart; - pub const SESSION_END: CaptureEventType = CaptureEventType::SessionEnd; - /// Audit row emitted when the user changes consent or recording settings. - pub const CAPTURE_SETTINGS_CHANGED: CaptureEventType = CaptureEventType::CaptureSettingsChanged; - pub const COMPILE_END: CaptureEventType = CaptureEventType::CompileEnd; - pub const RUN_END: CaptureEventType = CaptureEventType::RunEnd; - pub const TASK_START: CaptureEventType = CaptureEventType::TaskStart; - pub const TASK_SUBMIT: CaptureEventType = CaptureEventType::TaskSubmit; - pub const DEBUG_TASK_START: CaptureEventType = CaptureEventType::DebugTaskStart; - pub const DEBUG_TASK_SUBMIT: CaptureEventType = CaptureEventType::DebugTaskSubmit; - pub const HANDOFF_START: CaptureEventType = CaptureEventType::HandoffStart; - pub const HANDOFF_END: CaptureEventType = CaptureEventType::HandoffEnd; - pub const REFLECTION_PROMPT_INSERTED: CaptureEventType = - CaptureEventType::ReflectionPromptInserted; +/// Hash a local file path before it enters capture storage. The hash is stable +/// enough to group edits to the same file while avoiding raw path collection. +pub fn hash_capture_path(path: &str) -> String { + Sha256::digest(path.as_bytes()) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() } /// Configuration used to construct the PostgreSQL connection string. @@ -208,6 +193,15 @@ pub struct CaptureConfig { } impl CaptureConfig { + /// Validate capture configuration before starting the worker. This catches + /// invalid setup early and avoids ambiguous "random port" behavior. + pub fn validate(&self) -> Result<(), String> { + if self.port == Some(0) { + return Err("capture database port must be between 1 and 65535".to_string()); + } + Ok(()) + } + /// Build a libpq-style connection string. pub fn to_conn_str(&self) -> String { let mut parts = vec![ @@ -244,7 +238,7 @@ impl CaptureConfig { None => None, }; - Ok(Some(Self { + let cfg = Self { host, port, user: required_env_var("CODECHAT_CAPTURE_USER")?, @@ -252,7 +246,9 @@ impl CaptureConfig { dbname: required_env_var("CODECHAT_CAPTURE_DBNAME")?, app_id: env_var_trimmed("CODECHAT_CAPTURE_APP_ID"), fallback_path: env_var_trimmed("CODECHAT_CAPTURE_FALLBACK_PATH").map(PathBuf::from), - })) + }; + cfg.validate()?; + Ok(Some(cfg)) } } @@ -276,7 +272,13 @@ pub fn load_capture_config(root_path: &Path) -> Option { match fs::read_to_string(&config_path) { Ok(json) => match serde_json::from_str::(&json) { - Ok(cfg) => Some(with_default_capture_fallback_path(cfg, root_path)), + Ok(cfg) => match cfg.validate() { + Ok(()) => Some(with_default_capture_fallback_path(cfg, root_path)), + Err(err) => { + warn!("Capture: invalid configuration in {config_path:?}: {err}"); + None + } + }, Err(err) => { warn!("Capture: invalid JSON in {config_path:?}: {err}"); None @@ -320,15 +322,29 @@ fn required_env_var(name: &str) -> Result { env_var_trimmed(name).ok_or_else(|| format!("{name} is required when capture env is used")) } -/// Capture worker health, exposed through `/capture/status` and the VS Code -/// status item. +/// Known capture worker states reported to the VS Code status UI. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export)] +pub enum CaptureState { + /// Capture is not configured or the worker is unavailable. + Disabled, + /// Capture worker is starting and attempting the first database connection. + Starting, + /// Events are being persisted to PostgreSQL. + Database, + /// Events are being written to local JSONL fallback storage. + Fallback, +} + +/// Capture worker health exposed to the VS Code status item. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] #[ts(export)] pub struct CaptureStatus { /// True when the capture worker is configured and accepting events. pub enabled: bool, - /// Worker state: `starting`, `database`, `fallback`, or `disabled`. - pub state: String, + /// Current worker state. + pub state: CaptureState, /// Number of events accepted into the worker queue. pub queued_events: u64, /// Number of events inserted into PostgreSQL. @@ -347,7 +363,7 @@ impl CaptureStatus { pub fn disabled() -> Self { Self { enabled: false, - state: "disabled".to_string(), + state: CaptureState::Disabled, queued_events: 0, persisted_events: 0, fallback_events: 0, @@ -360,7 +376,7 @@ impl CaptureStatus { fn starting(fallback_path: Option) -> Self { Self { enabled: true, - state: "starting".to_string(), + state: CaptureState::Starting, queued_events: 0, persisted_events: 0, fallback_events: 0, @@ -371,34 +387,6 @@ impl CaptureStatus { } } -/// Typed metadata stored in first-class DB columns instead of the event-specific -/// JSON `data` payload. -#[derive(Debug, Clone, Default)] -pub struct CaptureEventMetadata { - /// Globally unique event identifier, generated by the client or server. - pub event_id: Option, - /// Client-local event order for one extension session. - pub sequence_number: Option, - /// Capture payload schema version. - pub schema_version: Option, - /// Logical capture session UUID. - pub session_id: Option, - /// Origin of the event stream, such as the VS Code extension. - pub event_source: Option, - /// VS Code language identifier for the active file, when known. - pub language_id: Option, - /// Privacy-preserving SHA-256 hash of the local file path. - pub file_hash: Option, - /// Whether the path was sent plainly, hashed, or omitted. - pub path_privacy: Option, - /// Client timestamp, in milliseconds since Unix epoch. - pub client_timestamp_ms: Option, - /// Client timezone offset in minutes. - pub client_tz_offset_min: Option, - /// Server timestamp, in milliseconds since Unix epoch. - pub server_timestamp_ms: Option, -} - /// The in-memory representation of a single capture event. #[derive(Debug, Clone)] pub struct CaptureEvent { @@ -418,20 +406,15 @@ pub struct CaptureEvent { pub language_id: Option, /// Privacy-preserving SHA-256 hash of the local file path. pub file_hash: Option, - /// Raw file path when path hashing is disabled. - pub file_path: Option, - /// Whether the path was sent plainly, hashed, or omitted. - pub path_privacy: Option, /// Canonical type of the captured event. pub event_type: CaptureEventType, - /// When the event occurred, in UTC. + /// Server receive/record timestamp, in UTC. pub timestamp: DateTime, - /// Client timestamp, in milliseconds since Unix epoch. + /// Optional client-observed event timestamp, in milliseconds since Unix + /// epoch. pub client_timestamp_ms: Option, /// Client timezone offset in minutes. pub client_tz_offset_min: Option, - /// Server timestamp, in milliseconds since Unix epoch. - pub server_timestamp_ms: i64, /// Event-specific payload, stored as JSON text in the DB. pub data: serde_json::Value, } @@ -440,48 +423,58 @@ impl CaptureEvent { /// Convenience constructor when the caller already has a timestamp. pub fn new( user_id: String, - file_path: Option, + file_hash: Option, event_type: CaptureEventType, timestamp: DateTime, data: serde_json::Value, ) -> Self { - Self::with_metadata( + Self { + event_id: None, + sequence_number: None, + schema_version: None, user_id, - file_path, + session_id: None, + event_source: None, + language_id: None, + file_hash, event_type, timestamp, + client_timestamp_ms: None, + client_tz_offset_min: None, data, - CaptureEventMetadata::default(), - ) + } } - /// Constructor for callers that already have first-class capture metadata. - pub fn with_metadata( + /// Constructor for callers that already have first-class capture columns. + #[allow(clippy::too_many_arguments)] + pub fn with_columns( + event_id: Option, + sequence_number: Option, + schema_version: Option, user_id: String, - file_path: Option, + session_id: Option, + event_source: Option, + language_id: Option, + file_hash: Option, event_type: CaptureEventType, timestamp: DateTime, + client_timestamp_ms: Option, + client_tz_offset_min: Option, data: serde_json::Value, - metadata: CaptureEventMetadata, ) -> Self { Self { - event_id: metadata.event_id, - sequence_number: metadata.sequence_number, - schema_version: metadata.schema_version, + event_id, + sequence_number, + schema_version, user_id, - session_id: metadata.session_id, - event_source: metadata.event_source, - language_id: metadata.language_id, - file_hash: metadata.file_hash, - file_path, - path_privacy: metadata.path_privacy, + session_id, + event_source, + language_id, + file_hash, event_type, timestamp, - client_timestamp_ms: metadata.client_timestamp_ms, - client_tz_offset_min: metadata.client_tz_offset_min, - server_timestamp_ms: metadata - .server_timestamp_ms - .unwrap_or_else(|| timestamp.timestamp_millis()), + client_timestamp_ms, + client_tz_offset_min, data, } } @@ -489,11 +482,11 @@ impl CaptureEvent { /// Convenience constructor which uses the current time. pub fn now( user_id: String, - file_path: Option, + file_hash: Option, event_type: CaptureEventType, data: serde_json::Value, ) -> Self { - Self::new(user_id, file_path, event_type, Utc::now(), data) + Self::new(user_id, file_hash, event_type, Utc::now(), data) } } @@ -565,7 +558,7 @@ impl EventCapture { Ok((client, connection)) => { info!("Capture: successfully connected to PostgreSQL."); update_status(&status_worker, |status| { - status.state = "database".to_string(); + status.state = CaptureState::Database; status.last_error = None; }); @@ -575,7 +568,7 @@ impl EventCapture { if let Err(err) = connection.await { error!("Capture PostgreSQL connection error: {err}"); update_status(&status_connection, |status| { - status.state = "fallback".to_string(); + status.state = CaptureState::Fallback; status.last_error = Some(format!( "PostgreSQL connection error: {err}" )); @@ -587,8 +580,8 @@ impl EventCapture { // them into the database. while let Some(event) = rx.recv().await { debug!( - "Capture: inserting event: type={}, user_id={}, file_path={:?}", - event.event_type, event.user_id, event.file_path + "Capture: inserting event: type={}, user_id={}, file_hash={:?}", + event.event_type, event.user_id, event.file_hash ); if let Err(err) = insert_event(&client, &event).await { @@ -597,7 +590,7 @@ impl EventCapture { event.event_type, event.user_id ); update_status(&status_worker, |status| { - status.state = "fallback".to_string(); + status.state = CaptureState::Fallback; status.last_error = Some(format!( "PostgreSQL insert failed: {err}" )); @@ -611,8 +604,8 @@ impl EventCapture { } else { update_status(&status_worker, |status| { status.persisted_events += 1; - if status.state != "database" { - status.state = "database".to_string(); + if status.state != CaptureState::Database { + status.state = CaptureState::Database; } }); debug!("Capture: event insert successful."); @@ -631,7 +624,7 @@ impl EventCapture { log_pg_connect_error(&ctx, &err); update_status(&status_worker, |status| { - status.state = "fallback".to_string(); + status.state = CaptureState::Fallback; status.last_error = Some(format!( "PostgreSQL connection failed: {err}" )); @@ -664,8 +657,8 @@ impl EventCapture { /// Enqueue an event for insertion. This is non-blocking. pub fn log(&self, event: CaptureEvent) { debug!( - "Capture: queueing event: type={}, user_id={}, file_path={:?}", - event.event_type, event.user_id, event.file_path + "Capture: queueing event: type={}, user_id={}, file_hash={:?}", + event.event_type, event.user_id, event.file_hash ); if let Err(err) = self.tx.send(event) { @@ -746,13 +739,10 @@ fn append_fallback_event(fallback_path: &Path, event: &CaptureEvent) -> io::Resu "event_source": event.event_source, "language_id": event.language_id, "file_hash": event.file_hash, - "file_path": event.file_path, - "path_privacy": event.path_privacy, "event_type": event.event_type.as_str(), "timestamp": event.timestamp.to_rfc3339(), "client_timestamp_ms": event.client_timestamp_ms, "client_tz_offset_min": event.client_tz_offset_min, - "server_timestamp_ms": event.server_timestamp_ms, "data": event.data, } }); @@ -838,15 +828,13 @@ async fn insert_rich_event( "INSERT INTO events \ (event_id, sequence_number, schema_version, \ user_id, session_id, \ - event_source, language_id, file_hash, file_path, path_privacy, \ - event_type, timestamp, client_timestamp_ms, client_tz_offset_min, \ - server_timestamp_ms, data) \ + event_source, language_id, file_hash, \ + event_type, timestamp, client_timestamp_ms, client_tz_offset_min, data) \ VALUES \ ($1, $2, $3, \ $4, $5, \ - $6, $7, $8, $9, $10, \ - $11, $12::text::timestamptz, $13, $14, \ - $15, $16::text::jsonb)", + $6, $7, $8, \ + $9, $10::text::timestamptz, $11, $12, $13::text::jsonb)", &[ &event.event_id, &event.sequence_number, @@ -856,13 +844,10 @@ async fn insert_rich_event( &event.event_source, &event.language_id, &event.file_hash, - &event.file_path, - &event.path_privacy, &event_type, ×tamp, &event.client_timestamp_ms, &event.client_tz_offset_min, - &event.server_timestamp_ms, &data_text, ], ) @@ -881,6 +866,7 @@ async fn insert_legacy_event( "Capture: executing legacy INSERT for user_id={}, event_type={}, timestamp={}", event.user_id, event_type, timestamp ); + let file_path: Option = None; client .execute( @@ -889,7 +875,7 @@ async fn insert_legacy_event( VALUES ($1, $2, $3, $4::text::timestamptz, $5::text::jsonb)", &[ &event.user_id, - &event.file_path, + &file_path, &event_type, ×tamp, &data_text, @@ -929,15 +915,15 @@ mod tests { #[test] fn capture_event_type_uses_stable_serialized_strings() { assert_eq!( - serde_json::to_value(event_types::WRITE_DOC).unwrap(), + serde_json::to_value(CaptureEventType::WriteDoc).unwrap(), json!("write_doc") ); assert_eq!( serde_json::from_value::(json!("compile_end")).unwrap(), - event_types::COMPILE_END + CaptureEventType::CompileEnd ); assert_eq!( - serde_json::to_value(event_types::CAPTURE_SETTINGS_CHANGED).unwrap(), + serde_json::to_value(CaptureEventType::CaptureSettingsChanged).unwrap(), json!("capture_settings_changed") ); assert!(serde_json::from_value::(json!("random")).is_err()); @@ -949,17 +935,16 @@ mod tests { let ev = CaptureEvent::new( "user123".to_string(), - Some("/path/to/file.rs".to_string()), - event_types::WRITE_DOC, + Some("hashed-path".to_string()), + CaptureEventType::WriteDoc, ts, json!({ "chars_typed": 42 }), ); assert_eq!(ev.user_id, "user123"); - assert_eq!(ev.file_path.as_deref(), Some("/path/to/file.rs")); - assert_eq!(ev.event_type, event_types::WRITE_DOC); + assert_eq!(ev.file_hash.as_deref(), Some("hashed-path")); + assert_eq!(ev.event_type, CaptureEventType::WriteDoc); assert_eq!(ev.timestamp, ts); - assert_eq!(ev.server_timestamp_ms, ts.timestamp_millis()); assert!(ev.event_id.is_none()); assert_eq!(ev.data, json!({ "chars_typed": 42 })); } @@ -970,15 +955,14 @@ mod tests { let ev = CaptureEvent::now( "user123".to_string(), None, - event_types::SAVE, + CaptureEventType::Save, json!({ "reason": "manual" }), ); let after = Utc::now(); assert_eq!(ev.user_id, "user123"); - assert!(ev.file_path.is_none()); - assert_eq!(ev.event_type, event_types::SAVE); - assert_eq!(ev.server_timestamp_ms, ev.timestamp.timestamp_millis()); + assert!(ev.file_hash.is_none()); + assert_eq!(ev.event_type, CaptureEventType::Save); assert_eq!(ev.data, json!({ "reason": "manual" })); // Timestamp sanity check: it should be between before and after @@ -987,28 +971,23 @@ mod tests { } #[test] - fn capture_event_with_metadata_sets_analysis_columns() { + fn capture_event_with_columns_sets_analysis_columns() { let ts = Utc::now(); - let ev = CaptureEvent::with_metadata( + let ev = CaptureEvent::with_columns( + Some("abc-123".to_string()), + Some(42), + Some(2), "user123".to_string(), - None, - event_types::WRITE_CODE, + Some("session-1".to_string()), + Some("vscode_extension".to_string()), + Some("rust".to_string()), + Some("hash".to_string()), + CaptureEventType::WriteCode, ts, + Some(ts.timestamp_millis() - 50), + Some(-360), json!({ "chars_typed": 42 }), - CaptureEventMetadata { - event_id: Some("abc-123".to_string()), - sequence_number: Some(42), - schema_version: Some(2), - session_id: Some("session-1".to_string()), - event_source: Some("vscode_extension".to_string()), - language_id: Some("rust".to_string()), - file_hash: Some("hash".to_string()), - path_privacy: Some("sha256".to_string()), - client_timestamp_ms: Some(ts.timestamp_millis() - 50), - client_tz_offset_min: Some(-360), - server_timestamp_ms: Some(ts.timestamp_millis()), - }, ); assert_eq!(ev.event_id.as_deref(), Some("abc-123")); @@ -1018,10 +997,8 @@ mod tests { assert_eq!(ev.event_source.as_deref(), Some("vscode_extension")); assert_eq!(ev.language_id.as_deref(), Some("rust")); assert_eq!(ev.file_hash.as_deref(), Some("hash")); - assert_eq!(ev.path_privacy.as_deref(), Some("sha256")); assert_eq!(ev.client_timestamp_ms, Some(ts.timestamp_millis() - 50)); assert_eq!(ev.client_tz_offset_min, Some(-360)); - assert_eq!(ev.server_timestamp_ms, ts.timestamp_millis()); assert_eq!(ev.data, json!({ "chars_typed": 42 })); } @@ -1055,6 +1032,21 @@ mod tests { let _back = serde_json::to_string(&cfg).expect("Should serialize"); } + #[test] + fn capture_config_rejects_port_zero() { + let cfg = CaptureConfig { + host: "localhost".to_string(), + port: Some(0), + user: "alice".to_string(), + password: "secret".to_string(), + dbname: "codechat_capture".to_string(), + app_id: None, + fallback_path: None, + }; + + assert!(cfg.validate().is_err()); + } + use std::fs; //use tokio::time::{sleep, Duration}; @@ -1115,10 +1107,8 @@ mod tests { "event_source", "language_id", "file_hash", - "path_privacy", "client_timestamp_ms", "client_tz_offset_min", - "server_timestamp_ms", ]; for column in required_columns { let row = client @@ -1154,31 +1144,25 @@ mod tests { let expected_session_id = format!("TEST_SESSION_{test_suffix}"); let expected_file_hash = format!("TEST_FILE_HASH_{test_suffix}"); let event_timestamp = Utc::now(); - let expected_server_timestamp_ms = event_timestamp.timestamp_millis(); - let expected_client_timestamp_ms = expected_server_timestamp_ms - 50; + let expected_client_timestamp_ms = event_timestamp.timestamp_millis() - 50; let expected_data = json!({ "chars_typed": 123, "classification_basis": "integration_test" }); - let event = CaptureEvent::with_metadata( + let event = CaptureEvent::with_columns( + Some(expected_event_id.clone()), + Some(42), + Some(2), expected_user_id.clone(), - None, - event_types::WRITE_DOC, + Some(expected_session_id.clone()), + Some("integration_test".to_string()), + Some("rust".to_string()), + Some(expected_file_hash.clone()), + CaptureEventType::WriteDoc, event_timestamp, + Some(expected_client_timestamp_ms), + Some(360), expected_data.clone(), - CaptureEventMetadata { - event_id: Some(expected_event_id.clone()), - sequence_number: Some(42), - schema_version: Some(2), - session_id: Some(expected_session_id.clone()), - event_source: Some("integration_test".to_string()), - language_id: Some("rust".to_string()), - file_hash: Some(expected_file_hash.clone()), - path_privacy: Some("sha256".to_string()), - client_timestamp_ms: Some(expected_client_timestamp_ms), - client_tz_offset_min: Some(360), - server_timestamp_ms: Some(expected_server_timestamp_ms), - }, ); log::info!("TEST: logging a test capture event."); @@ -1194,11 +1178,10 @@ mod tests { match client .query_one( r#" - SELECT user_id, file_path, event_type, + SELECT user_id, event_type, event_id, sequence_number, schema_version, session_id, event_source, language_id, file_hash, - path_privacy, client_timestamp_ms, - client_tz_offset_min, server_timestamp_ms, data::text + client_timestamp_ms, client_tz_offset_min, data::text FROM events WHERE event_id = $1 ORDER BY id DESC @@ -1219,25 +1202,21 @@ mod tests { }; let user_id: String = row.get("user_id"); - let file_path: Option = row.get(1); - let event_type: String = row.get(2); - let event_id: Option = row.get(3); - let sequence_number: Option = row.get(4); - let schema_version: Option = row.get(5); - let session_id: Option = row.get(6); - let event_source: Option = row.get(7); - let language_id: Option = row.get(8); - let file_hash: Option = row.get(9); - let path_privacy: Option = row.get(10); - let client_timestamp_ms: Option = row.get(11); - let client_tz_offset_min: Option = row.get(12); - let server_timestamp_ms: Option = row.get(13); - let data_text: String = row.get(14); + let event_type: String = row.get(1); + let event_id: Option = row.get(2); + let sequence_number: Option = row.get(3); + let schema_version: Option = row.get(4); + let session_id: Option = row.get(5); + let event_source: Option = row.get(6); + let language_id: Option = row.get(7); + let file_hash: Option = row.get(8); + let client_timestamp_ms: Option = row.get(9); + let client_tz_offset_min: Option = row.get(10); + let data_text: String = row.get(11); let data_value: serde_json::Value = serde_json::from_str(&data_text)?; assert_eq!(user_id, expected_user_id); - assert!(file_path.is_none()); - assert_eq!(event_type, event_types::WRITE_DOC.as_str()); + assert_eq!(event_type, CaptureEventType::WriteDoc.as_str()); assert_eq!(event_id.as_deref(), Some(expected_event_id.as_str())); assert_eq!(sequence_number, Some(42)); assert_eq!(schema_version, Some(2)); @@ -1245,10 +1224,8 @@ mod tests { assert_eq!(event_source.as_deref(), Some("integration_test")); assert_eq!(language_id.as_deref(), Some("rust")); assert_eq!(file_hash.as_deref(), Some(expected_file_hash.as_str())); - assert_eq!(path_privacy.as_deref(), Some("sha256")); assert_eq!(client_timestamp_ms, Some(expected_client_timestamp_ms)); assert_eq!(client_tz_offset_min, Some(360)); - assert_eq!(server_timestamp_ms, Some(expected_server_timestamp_ms)); assert_eq!(data_value, expected_data); log::info!("✅ TEST: EventCapture integration test succeeded and wrote to database."); diff --git a/server/src/translation.rs b/server/src/translation.rs index e41d97d7..1e2288c0 100644 --- a/server/src/translation.rs +++ b/server/src/translation.rs @@ -222,7 +222,7 @@ use tokio::{ // ### Local use crate::{ - capture::{CaptureEventType, event_types}, + capture::{CaptureEventType, hash_capture_path}, lexer::{CodeDocBlock, DocBlock, supported_languages::MARKDOWN_MODE}, processing::{ CodeChatForWeb, CodeMirror, CodeMirrorDiff, CodeMirrorDiffable, CodeMirrorDocBlock, @@ -461,8 +461,6 @@ struct CaptureContext { event_source: Option, /// Extension session UUID carried on the capture wire payload. session_id: Option, - /// Whether extension-originated file paths are sent plainly or hashed. - path_privacy: Option, /// Client timezone offset in minutes, retained for generated write events. client_tz_offset_min: Option, /// Capture payload schema version from the extension. @@ -494,9 +492,6 @@ impl CaptureContext { if let Some(session_id) = &wire.session_id { self.session_id = Some(session_id.clone()); } - if let Some(path_privacy) = &wire.path_privacy { - self.path_privacy = Some(path_privacy.clone()); - } if let Some(schema_version) = wire.schema_version { self.schema_version = Some(schema_version); } @@ -512,14 +507,10 @@ impl CaptureContext { { self.active = active; } - // Support older wire payloads that stored this metadata in `data`. + // Support older wire payloads that stored the session in `data`. if let Some(session_id) = data.get("session_id").and_then(serde_json::Value::as_str) { self.session_id = Some(session_id.to_string()); } - if let Some(path_privacy) = data.get("path_privacy").and_then(serde_json::Value::as_str) - { - self.path_privacy = Some(path_privacy.to_string()); - } } } @@ -557,9 +548,7 @@ impl CaptureContext { session_id: self.session_id.clone(), event_source: self.event_source.clone(), language_id: None, - file_hash: None, - file_path, - path_privacy: self.path_privacy.clone(), + file_hash: file_path.as_deref().map(hash_capture_path), event_type, client_timestamp_ms: None, client_tz_offset_min: self.client_tz_offset_min, @@ -898,7 +887,7 @@ impl TranslationTask { return; } self.log_server_capture_event( - event_types::WRITE_CODE, + CaptureEventType::WriteCode, file_path, serde_json::json!({ "source": "server_translation", @@ -920,7 +909,7 @@ impl TranslationTask { if metadata.mode == MARKDOWN_MODE { if !compare_html(before_doc, &after.doc) { self.log_server_capture_event( - event_types::WRITE_DOC, + CaptureEventType::WriteDoc, file_path, serde_json::json!({ "source": source, @@ -935,7 +924,7 @@ impl TranslationTask { if before_doc != after.doc { self.log_server_capture_event( - event_types::WRITE_CODE, + CaptureEventType::WriteCode, file_path, serde_json::json!({ "source": source, @@ -955,7 +944,7 @@ impl TranslationTask { serde_json::json!(diff_code_mirror_doc_blocks(before, &after.doc_blocks)) }); self.log_server_capture_event( - event_types::WRITE_DOC, + CaptureEventType::WriteDoc, file_path, serde_json::json!({ "source": source, @@ -1700,7 +1689,7 @@ fn debug_shorten(val: T) -> String { #[cfg(test)] mod tests { use crate::{ - capture::event_types, + capture::CaptureEventType, processing::CodeMirrorDocBlock, translation::{CaptureContext, capture_control_only, doc_blocks_compare}, webserver::CaptureEventWire, @@ -1721,8 +1710,6 @@ mod tests { event_source: Some("vscode_extension".to_string()), language_id: None, file_hash: None, - file_path: None, - path_privacy: None, event_type, client_timestamp_ms: None, client_tz_offset_min: Some(360), @@ -1736,12 +1723,12 @@ mod tests { // Without an active capture session, translated writes must be skipped. assert!( context - .capture_event(event_types::WRITE_CODE, None, serde_json::json!({})) + .capture_event(CaptureEventType::WriteCode, None, serde_json::json!({})) .is_none() ); context.update_from_wire(&capture_wire( - event_types::SESSION_START, + CaptureEventType::SessionStart, serde_json::json!({ "session_id": "session", "capture_active": true, @@ -1750,12 +1737,12 @@ mod tests { // A session_start activates server-side translated write capture. assert!( context - .capture_event(event_types::WRITE_CODE, None, serde_json::json!({})) + .capture_event(CaptureEventType::WriteCode, None, serde_json::json!({})) .is_some() ); context.update_from_wire(&capture_wire( - event_types::SESSION_END, + CaptureEventType::SessionEnd, serde_json::json!({ "capture_active": false, }), @@ -1764,7 +1751,7 @@ mod tests { // cannot continue inserting DB rows. assert!( context - .capture_event(event_types::WRITE_CODE, None, serde_json::json!({})) + .capture_event(CaptureEventType::WriteCode, None, serde_json::json!({})) .is_none() ); } @@ -1774,7 +1761,7 @@ mod tests { // Control-only events are the extension's way to update server capture // state without storing the stop signal as a normal event row. let wire = capture_wire( - event_types::SESSION_END, + CaptureEventType::SessionEnd, serde_json::json!({ "capture_active": false, "capture_control_only": true, diff --git a/server/src/webserver.rs b/server/src/webserver.rs index 57d619c6..b678cda4 100644 --- a/server/src/webserver.rs +++ b/server/src/webserver.rs @@ -98,8 +98,8 @@ use crate::{ }; use crate::capture::{ - CaptureEvent, CaptureEventMetadata, CaptureEventType, CaptureStatus, EventCapture, - generate_capture_event_id, load_capture_config, + CaptureEvent, CaptureEventType, CaptureStatus, EventCapture, generate_capture_event_id, + load_capture_config, }; use chrono::Utc; @@ -457,15 +457,10 @@ pub struct CaptureEventWire { /// VS Code language identifier for the active file, when known. #[serde(skip_serializing_if = "Option::is_none")] pub language_id: Option, - /// SHA-256 hash of the local file path when path hashing is enabled. + /// SHA-256 hash of the local file path. Raw local paths are intentionally + /// not accepted on the capture wire. #[serde(skip_serializing_if = "Option::is_none")] pub file_hash: Option, - /// Raw file path only when path hashing is disabled for debugging. - #[serde(skip_serializing_if = "Option::is_none")] - pub file_path: Option, - /// Whether the path was sent plainly, hashed, or omitted. - #[serde(skip_serializing_if = "Option::is_none")] - pub path_privacy: Option, /// Canonical capture event type. pub event_type: CaptureEventType, @@ -671,11 +666,6 @@ async fn stop(app_state: WebAppState) -> HttpResponse { HttpResponse::NoContent().finish() } -#[get("/capture/status")] -async fn capture_status_endpoint(app_state: WebAppState) -> HttpResponse { - HttpResponse::Ok().json(capture_status(&app_state)) -} - /// Log a capture event if capture is enabled. pub fn log_capture_event(app_state: &WebAppState, wire: CaptureEventWire) -> CaptureStatus { if let Some(capture) = &app_state.capture { @@ -689,30 +679,23 @@ pub fn log_capture_event(app_state: &WebAppState, wire: CaptureEventWire) -> Cap serde_json::json!({ "value": data }) }; - let metadata = CaptureEventMetadata { - event_id: Some( + let event = CaptureEvent::with_columns( + Some( wire.event_id .unwrap_or_else(|| generate_capture_event_id("server")), ), - sequence_number: wire.sequence_number, - schema_version: wire.schema_version, - session_id: wire.session_id, - event_source: wire.event_source, - language_id: wire.language_id, - file_hash: wire.file_hash, - path_privacy: wire.path_privacy, - client_timestamp_ms: wire.client_timestamp_ms, - client_tz_offset_min: wire.client_tz_offset_min, - server_timestamp_ms: Some(server_timestamp.timestamp_millis()), - }; - - let event = CaptureEvent::with_metadata( + wire.sequence_number, + wire.schema_version, wire.user_id, - wire.file_path, + wire.session_id, + wire.event_source, + wire.language_id, + wire.file_hash, wire.event_type, server_timestamp, + wire.client_timestamp_ms, + wire.client_tz_offset_min, data, - metadata, ); capture.log(event); @@ -1709,7 +1692,6 @@ where .service(vscode_client_framework) .service(ping) .service(stop) - .service(capture_status_endpoint) // Reroute to the filewatcher filesystem for typical user-requested // URLs. .route("/", web::get().to(filewatcher_root_fs_redirect)) From bee1be8452e891a7735d1a892b2a81fac392d8ab Mon Sep 17 00:00:00 2001 From: "JDS-WORKSTATION\\jspah" <44337821+jspahn80134@users.noreply.github.com> Date: Fri, 22 May 2026 09:04:54 -0600 Subject: [PATCH 32/45] Capture code paste events --- extensions/VSCode/Cargo.lock | 1 + extensions/VSCode/package.json | 25 ++++++ extensions/VSCode/src/extension.ts | 82 +++++++++++++++++- server/scripts/capture_events_schema.sql | 14 +-- server/src/capture.rs | 36 +++----- server/src/translation.rs | 106 ++++++++++++++++++++--- server/src/webserver.rs | 5 -- toc.md | 1 + 8 files changed, 220 insertions(+), 50 deletions(-) diff --git a/extensions/VSCode/Cargo.lock b/extensions/VSCode/Cargo.lock index 0ab77ead..f2c1d535 100644 --- a/extensions/VSCode/Cargo.lock +++ b/extensions/VSCode/Cargo.lock @@ -712,6 +712,7 @@ dependencies = [ "regex", "serde", "serde_json", + "sha2 0.11.0", "test_utils", "thiserror", "tokio", diff --git a/extensions/VSCode/package.json b/extensions/VSCode/package.json index 9b80fd3c..21bddd7b 100644 --- a/extensions/VSCode/package.json +++ b/extensions/VSCode/package.json @@ -92,6 +92,31 @@ { "command": "extension.codeChatInsertReflectionPrompt", "title": "CodeChat Editor: Insert Reflection Prompt" + }, + { + "command": "extension.codeChatCapturePaste", + "title": "CodeChat Editor: Capture Paste" + } + ], + "menus": { + "commandPalette": [ + { + "command": "extension.codeChatCapturePaste", + "when": "false" + } + ] + }, + "keybindings": [ + { + "command": "extension.codeChatCapturePaste", + "key": "ctrl+v", + "mac": "cmd+v", + "when": "editorTextFocus && !editorReadonly && config.CodeChatEditor.Capture.RecordStudyEvents && config.CodeChatEditor.Capture.ConsentEnabled" + }, + { + "command": "extension.codeChatCapturePaste", + "key": "shift+insert", + "when": "editorTextFocus && !editorReadonly && config.CodeChatEditor.Capture.RecordStudyEvents && config.CodeChatEditor.Capture.ConsentEnabled" } ] }, diff --git a/extensions/VSCode/src/extension.ts b/extensions/VSCode/src/extension.ts index a7b9fa6b..26185664 100644 --- a/extensions/VSCode/src/extension.ts +++ b/extensions/VSCode/src/extension.ts @@ -249,6 +249,15 @@ let extensionCaptureSessionStarted = false; // Monotonic per-extension event sequence number used to order events produced // by this VS Code session. let captureSequenceNumber = 0; +// One-shot marker used to associate a paste command with the next document +// change before the normal CodeChat update is sent to the server. +let pendingPasteCapture: + | { + documentUri: string; + beforeVersion: number; + clearTimer: NodeJS.Timeout; + } + | undefined; // Status bar item that reports capture health and opens the capture controls. let capture_status_bar_item: vscode.StatusBarItem | undefined; // Timer used to refresh capture status from the running server. @@ -512,7 +521,6 @@ async function sendCaptureEvent( event_source: CAPTURE_EVENT_SOURCE, ...fileFields, event_type: eventType, - client_timestamp_ms: BigInt(Date.now()), client_tz_offset_min: new Date().getTimezoneOffset(), data: { ...data, @@ -551,6 +559,53 @@ async function sendCaptureEvent( } } +function clearPendingPasteCapture(): void { + if (pendingPasteCapture === undefined) { + return; + } + clearTimeout(pendingPasteCapture.clearTimer); + pendingPasteCapture = undefined; +} + +async function sendPendingCodePasteCapture(filePath: string): Promise { + await sendCaptureEvent( + "code_paste", + filePath, + { + operation: "paste", + pending_code_paste: true, + }, + { + controlOnly: true, + }, + ); +} + +async function capturePasteCommand(): Promise { + const editor = vscode.window.activeTextEditor; + if (editor !== undefined) { + clearPendingPasteCapture(); + pendingPasteCapture = { + documentUri: editor.document.uri.toString(), + beforeVersion: editor.document.version, + clearTimer: setTimeout(() => { + pendingPasteCapture = undefined; + }, 1000), + }; + } + + await vscode.commands.executeCommand("editor.action.clipboardPasteAction"); + + if ( + editor !== undefined && + pendingPasteCapture !== undefined && + pendingPasteCapture.documentUri === editor.document.uri.toString() && + editor.document.version === pendingPasteCapture.beforeVersion + ) { + clearPendingPasteCapture(); + } +} + function stringifyCapturePayload(payload: CaptureEventWire): string { return JSON.stringify(payload, (_key, value) => typeof value === "bigint" ? Number(value) : value, @@ -1154,6 +1209,10 @@ export const activate = (context: vscode.ExtensionContext) => { "extension.codeChatInsertReflectionPrompt", insertReflectionPrompt, ), + vscode.commands.registerCommand( + "extension.codeChatCapturePaste", + capturePasteCommand, + ), // Study lifecycle commands are registered for optional study // automation/keybindings, but they are not contributed to the Command // Palette. Normal users should only see status and reflection commands. @@ -1220,12 +1279,31 @@ export const activate = (context: vscode.ExtensionContext) => { const kind = classifyAtPosition(doc, pos); const filePath = doc.fileName; + const pendingPaste = pendingPasteCapture; + const pendingPasteSend = + pendingPaste !== undefined && + pendingPaste.documentUri === + doc.uri.toString() && + doc.version > pendingPaste.beforeVersion + ? (() => { + clearPendingPasteCapture(); + return sendPendingCodePasteCapture( + filePath, + ); + })() + : undefined; // Update our notion of current activity + doc // session. noteActivity(kind, filePath); - send_update(true); + if (pendingPasteSend !== undefined) { + void pendingPasteSend.finally(() => + send_update(true), + ); + } else { + send_update(true); + } }), ); diff --git a/server/scripts/capture_events_schema.sql b/server/scripts/capture_events_schema.sql index dece1474..0af1a7fb 100644 --- a/server/scripts/capture_events_schema.sql +++ b/server/scripts/capture_events_schema.sql @@ -1,3 +1,6 @@ +-- Capture Events Schema +-- ===================== +-- -- CodeChat capture event schema for dissertation analysis. -- -- This script updates an existing legacy `events` table to the lean capture @@ -23,7 +26,6 @@ CREATE TABLE IF NOT EXISTS public.events ( file_hash TEXT, event_type TEXT NOT NULL, "timestamp" TIMESTAMPTZ NOT NULL DEFAULT now(), - client_timestamp_ms BIGINT, client_tz_offset_min INTEGER, data JSONB NOT NULL DEFAULT '{}'::jsonb, inserted_at TIMESTAMPTZ NOT NULL DEFAULT now() @@ -36,7 +38,6 @@ ALTER TABLE public.events ADD COLUMN IF NOT EXISTS session_id TEXT; ALTER TABLE public.events ADD COLUMN IF NOT EXISTS event_source TEXT; ALTER TABLE public.events ADD COLUMN IF NOT EXISTS language_id TEXT; ALTER TABLE public.events ADD COLUMN IF NOT EXISTS file_hash TEXT; -ALTER TABLE public.events ADD COLUMN IF NOT EXISTS client_timestamp_ms BIGINT; ALTER TABLE public.events ADD COLUMN IF NOT EXISTS client_tz_offset_min INTEGER; ALTER TABLE public.events ADD COLUMN IF NOT EXISTS inserted_at TIMESTAMPTZ NOT NULL DEFAULT now(); @@ -49,6 +50,7 @@ ALTER TABLE public.events DROP COLUMN IF EXISTS capture_mode; ALTER TABLE public.events DROP COLUMN IF EXISTS file_path; ALTER TABLE public.events DROP COLUMN IF EXISTS path_privacy; ALTER TABLE public.events DROP COLUMN IF EXISTS server_timestamp_ms; +ALTER TABLE public.events DROP COLUMN IF EXISTS client_timestamp_ms; DO $$ DECLARE @@ -121,13 +123,6 @@ SET NULLIF(data->>'languageId', '') ), file_hash = COALESCE(file_hash, NULLIF(data->>'file_hash', '')), - client_timestamp_ms = COALESCE( - client_timestamp_ms, - CASE - WHEN data->>'client_timestamp_ms' ~ '^-?[0-9]+$' - THEN (data->>'client_timestamp_ms')::bigint - END - ), client_tz_offset_min = COALESCE( client_tz_offset_min, CASE @@ -162,7 +157,6 @@ COMMENT ON COLUMN public.events.user_id IS 'Pseudonymous participant UUID genera COMMENT ON COLUMN public.events.session_id IS 'Capture session UUID emitted by the VS Code extension.'; COMMENT ON COLUMN public.events.file_hash IS 'SHA-256 hash of the local file path; raw local paths are not stored.'; COMMENT ON COLUMN public.events."timestamp" IS 'Server receive/record timestamp in UTC.'; -COMMENT ON COLUMN public.events.client_timestamp_ms IS 'Optional client-observed event timestamp in milliseconds since Unix epoch.'; COMMENT ON COLUMN public.events.data IS 'Event-specific JSON payload. Known telemetry metadata lives in typed columns.'; -- Least-privilege deployment guidance: diff --git a/server/src/capture.rs b/server/src/capture.rs index d3a8275e..7b99858e 100644 --- a/server/src/capture.rs +++ b/server/src/capture.rs @@ -41,7 +41,7 @@ // ```sql // event_id, sequence_number, schema_version, // user_id, session_id, event_source, language_id, file_hash, event_type, -// timestamp, client_timestamp_ms, client_tz_offset_min, data +// timestamp, client_tz_offset_min, data // ``` // // * `user_id` – pseudonymous participant UUID. Course, group, assignment, and @@ -52,8 +52,6 @@ // * `file_hash` – privacy-preserving SHA-256 hash of the local file path. // * `event_type` – coarse event type (see `CaptureEventType` below). // * `timestamp` – server receive/record timestamp (in UTC). -// * `client_timestamp_ms` – optional client-observed event time for ordering -// and latency analysis. // * `data` – JSONB payload with event-specific details. use std::{ @@ -122,6 +120,8 @@ pub enum CaptureEventType { HandoffEnd, /// A built-in reflection prompt was inserted into the active editor. ReflectionPromptInserted, + /// Code was inserted by a paste operation rather than typed incrementally. + CodePaste, } impl CaptureEventType { @@ -146,6 +146,7 @@ impl CaptureEventType { Self::HandoffStart => "handoff_start", Self::HandoffEnd => "handoff_end", Self::ReflectionPromptInserted => "reflection_prompt_inserted", + Self::CodePaste => "code_paste", } } } @@ -410,9 +411,6 @@ pub struct CaptureEvent { pub event_type: CaptureEventType, /// Server receive/record timestamp, in UTC. pub timestamp: DateTime, - /// Optional client-observed event timestamp, in milliseconds since Unix - /// epoch. - pub client_timestamp_ms: Option, /// Client timezone offset in minutes. pub client_tz_offset_min: Option, /// Event-specific payload, stored as JSON text in the DB. @@ -439,7 +437,6 @@ impl CaptureEvent { file_hash, event_type, timestamp, - client_timestamp_ms: None, client_tz_offset_min: None, data, } @@ -458,7 +455,6 @@ impl CaptureEvent { file_hash: Option, event_type: CaptureEventType, timestamp: DateTime, - client_timestamp_ms: Option, client_tz_offset_min: Option, data: serde_json::Value, ) -> Self { @@ -473,7 +469,6 @@ impl CaptureEvent { file_hash, event_type, timestamp, - client_timestamp_ms, client_tz_offset_min, data, } @@ -741,7 +736,6 @@ fn append_fallback_event(fallback_path: &Path, event: &CaptureEvent) -> io::Resu "file_hash": event.file_hash, "event_type": event.event_type.as_str(), "timestamp": event.timestamp.to_rfc3339(), - "client_timestamp_ms": event.client_timestamp_ms, "client_tz_offset_min": event.client_tz_offset_min, "data": event.data, } @@ -829,12 +823,12 @@ async fn insert_rich_event( (event_id, sequence_number, schema_version, \ user_id, session_id, \ event_source, language_id, file_hash, \ - event_type, timestamp, client_timestamp_ms, client_tz_offset_min, data) \ + event_type, timestamp, client_tz_offset_min, data) \ VALUES \ ($1, $2, $3, \ $4, $5, \ $6, $7, $8, \ - $9, $10::text::timestamptz, $11, $12, $13::text::jsonb)", + $9, $10::text::timestamptz, $11, $12::text::jsonb)", &[ &event.event_id, &event.sequence_number, @@ -846,7 +840,6 @@ async fn insert_rich_event( &event.file_hash, &event_type, ×tamp, - &event.client_timestamp_ms, &event.client_tz_offset_min, &data_text, ], @@ -926,6 +919,10 @@ mod tests { serde_json::to_value(CaptureEventType::CaptureSettingsChanged).unwrap(), json!("capture_settings_changed") ); + assert_eq!( + serde_json::to_value(CaptureEventType::CodePaste).unwrap(), + json!("code_paste") + ); assert!(serde_json::from_value::(json!("random")).is_err()); } @@ -985,7 +982,6 @@ mod tests { Some("hash".to_string()), CaptureEventType::WriteCode, ts, - Some(ts.timestamp_millis() - 50), Some(-360), json!({ "chars_typed": 42 }), ); @@ -997,7 +993,6 @@ mod tests { assert_eq!(ev.event_source.as_deref(), Some("vscode_extension")); assert_eq!(ev.language_id.as_deref(), Some("rust")); assert_eq!(ev.file_hash.as_deref(), Some("hash")); - assert_eq!(ev.client_timestamp_ms, Some(ts.timestamp_millis() - 50)); assert_eq!(ev.client_tz_offset_min, Some(-360)); assert_eq!(ev.data, json!({ "chars_typed": 42 })); } @@ -1107,7 +1102,6 @@ mod tests { "event_source", "language_id", "file_hash", - "client_timestamp_ms", "client_tz_offset_min", ]; for column in required_columns { @@ -1144,7 +1138,6 @@ mod tests { let expected_session_id = format!("TEST_SESSION_{test_suffix}"); let expected_file_hash = format!("TEST_FILE_HASH_{test_suffix}"); let event_timestamp = Utc::now(); - let expected_client_timestamp_ms = event_timestamp.timestamp_millis() - 50; let expected_data = json!({ "chars_typed": 123, "classification_basis": "integration_test" @@ -1160,7 +1153,6 @@ mod tests { Some(expected_file_hash.clone()), CaptureEventType::WriteDoc, event_timestamp, - Some(expected_client_timestamp_ms), Some(360), expected_data.clone(), ); @@ -1181,7 +1173,7 @@ mod tests { SELECT user_id, event_type, event_id, sequence_number, schema_version, session_id, event_source, language_id, file_hash, - client_timestamp_ms, client_tz_offset_min, data::text + client_tz_offset_min, data::text FROM events WHERE event_id = $1 ORDER BY id DESC @@ -1210,9 +1202,8 @@ mod tests { let event_source: Option = row.get(6); let language_id: Option = row.get(7); let file_hash: Option = row.get(8); - let client_timestamp_ms: Option = row.get(9); - let client_tz_offset_min: Option = row.get(10); - let data_text: String = row.get(11); + let client_tz_offset_min: Option = row.get(9); + let data_text: String = row.get(10); let data_value: serde_json::Value = serde_json::from_str(&data_text)?; assert_eq!(user_id, expected_user_id); @@ -1224,7 +1215,6 @@ mod tests { assert_eq!(event_source.as_deref(), Some("integration_test")); assert_eq!(language_id.as_deref(), Some("rust")); assert_eq!(file_hash.as_deref(), Some(expected_file_hash.as_str())); - assert_eq!(client_timestamp_ms, Some(expected_client_timestamp_ms)); assert_eq!(client_tz_offset_min, Some(360)); assert_eq!(data_value, expected_data); diff --git a/server/src/translation.rs b/server/src/translation.rs index 1e2288c0..c868b2cb 100644 --- a/server/src/translation.rs +++ b/server/src/translation.rs @@ -465,6 +465,9 @@ struct CaptureContext { client_tz_offset_min: Option, /// Capture payload schema version from the extension. schema_version: Option, + /// One-shot marker set by the extension when the next classified write came + /// from a paste command. It is consumed by the next write classification. + pending_code_paste: bool, } impl CaptureContext { @@ -511,9 +514,27 @@ impl CaptureContext { if let Some(session_id) = data.get("session_id").and_then(serde_json::Value::as_str) { self.session_id = Some(session_id.to_string()); } + if wire.event_type == CaptureEventType::CodePaste + && data + .get("pending_code_paste") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + { + self.pending_code_paste = true; + } } } + fn take_pending_code_paste(&mut self) -> bool { + let pending = self.pending_code_paste; + self.pending_code_paste = false; + pending + } + + fn clear_pending_code_paste(&mut self) { + self.pending_code_paste = false; + } + fn capture_event( &self, event_type: CaptureEventType, @@ -550,7 +571,6 @@ impl CaptureContext { language_id: None, file_hash: file_path.as_deref().map(hash_capture_path), event_type, - client_timestamp_ms: None, client_tz_offset_min: self.client_tz_offset_min, data: Some(serde_json::Value::Object(data)), }) @@ -882,8 +902,9 @@ impl TranslationTask { log_capture_event(&self.app_state, capture_event); } - fn log_raw_write_event(&self, file_path: &std::path::Path, before: &str, after: &str) { + fn log_raw_write_event(&mut self, file_path: &std::path::Path, before: &str, after: &str) { if before == after { + self.capture_context.clear_pending_code_paste(); return; } self.log_server_capture_event( @@ -895,10 +916,22 @@ impl TranslationTask { "diff": diff_str(before, after), }), ); + if self.capture_context.take_pending_code_paste() { + self.log_server_capture_event( + CaptureEventType::CodePaste, + file_path, + serde_json::json!({ + "source": "server_translation", + "classification_basis": "raw_text", + "operation": "paste", + "block_kind": "code", + }), + ); + } } fn log_code_mirror_write_events( - &self, + &mut self, file_path: &std::path::Path, metadata: &SourceFileMetadata, before_doc: &str, @@ -907,7 +940,8 @@ impl TranslationTask { source: &str, ) { if metadata.mode == MARKDOWN_MODE { - if !compare_html(before_doc, &after.doc) { + let doc_changed = !compare_html(before_doc, &after.doc); + if doc_changed { self.log_server_capture_event( CaptureEventType::WriteDoc, file_path, @@ -919,10 +953,15 @@ impl TranslationTask { }), ); } + self.capture_context.clear_pending_code_paste(); return; } + let mut code_changed = false; + let mut code_classification_basis = None; if before_doc != after.doc { + code_changed = true; + code_classification_basis = Some("codemirror_code_text"); self.log_server_capture_event( CaptureEventType::WriteCode, file_path, @@ -956,6 +995,19 @@ impl TranslationTask { }), ); } + let pending_code_paste = self.capture_context.take_pending_code_paste(); + if pending_code_paste && code_changed { + self.log_server_capture_event( + CaptureEventType::CodePaste, + file_path, + serde_json::json!({ + "source": "server_translation", + "classification_basis": code_classification_basis.unwrap_or("codemirror_code_text"), + "operation": "paste", + "block_kind": "code", + }), + ); + } } // Pass a `Result` message to the Client, unless it's a `LoadFile` result. @@ -1154,14 +1206,19 @@ impl TranslationTask { panic!("Unexpected diff value."); }; if self.sent_full { + let before_doc = self.code_mirror_doc.clone(); + let before_doc_blocks = + self.code_mirror_doc_blocks.clone(); self.log_code_mirror_write_events( &clean_file_path, &ccfw.metadata, - &self.code_mirror_doc, - self.code_mirror_doc_blocks.as_ref(), + &before_doc, + before_doc_blocks.as_ref(), code_mirror_translated, "ide", ); + } else { + self.capture_context.clear_pending_code_paste(); } // Send a diff if possible. let client_contents = if self.sent_full { @@ -1209,11 +1266,15 @@ impl TranslationTask { } TranslationResultsString::Unknown => { if self.sent_full { + let before_source_code = + self.source_code.clone(); self.log_raw_write_event( &clean_file_path, - &self.source_code, + &before_source_code, &code_mirror.doc, ); + } else { + self.capture_context.clear_pending_code_paste(); } // Send the new raw contents. debug!("Sending translated contents to Client."); @@ -1328,14 +1389,18 @@ impl TranslationTask { panic!("Diff not supported."); }; if self.sent_full { + let before_doc = self.code_mirror_doc.clone(); + let before_doc_blocks = self.code_mirror_doc_blocks.clone(); self.log_code_mirror_write_events( &clean_file_path, &cfw.metadata, - &self.code_mirror_doc, - self.code_mirror_doc_blocks.as_ref(), + &before_doc, + before_doc_blocks.as_ref(), code_mirror, "client", ); + } else { + self.capture_context.clear_pending_code_paste(); } self.code_mirror_doc = code_mirror.doc.clone(); self.code_mirror_doc_blocks = Some(code_mirror.doc_blocks.clone()); @@ -1711,7 +1776,6 @@ mod tests { language_id: None, file_hash: None, event_type, - client_timestamp_ms: None, client_tz_offset_min: Some(360), data: Some(data), } @@ -1771,6 +1835,28 @@ mod tests { assert!(capture_control_only(&wire)); } + #[test] + fn code_paste_marker_is_one_shot() { + let mut context = CaptureContext::default(); + context.update_from_wire(&capture_wire( + CaptureEventType::SessionStart, + serde_json::json!({ + "capture_active": true, + }), + )); + context.update_from_wire(&capture_wire( + CaptureEventType::CodePaste, + serde_json::json!({ + "capture_active": true, + "capture_control_only": true, + "pending_code_paste": true, + }), + )); + + assert!(context.take_pending_code_paste()); + assert!(!context.take_pending_code_paste()); + } + #[test] fn test_x1() { let ide = vec![CodeMirrorDocBlock { diff --git a/server/src/webserver.rs b/server/src/webserver.rs index b678cda4..b859df3b 100644 --- a/server/src/webserver.rs +++ b/server/src/webserver.rs @@ -464,10 +464,6 @@ pub struct CaptureEventWire { /// Canonical capture event type. pub event_type: CaptureEventType, - /// Optional client-side timestamp (milliseconds since Unix epoch). - #[serde(skip_serializing_if = "Option::is_none")] - pub client_timestamp_ms: Option, - /// Optional client timezone offset in minutes (JS Date().getTimezoneOffset()). #[serde(skip_serializing_if = "Option::is_none")] pub client_tz_offset_min: Option, @@ -693,7 +689,6 @@ pub fn log_capture_event(app_state: &WebAppState, wire: CaptureEventWire) -> Cap wire.file_hash, wire.event_type, server_timestamp, - wire.client_timestamp_ms, wire.client_tz_offset_min, data, ); diff --git a/toc.md b/toc.md index 4c39fde3..63ede826 100644 --- a/toc.md +++ b/toc.md @@ -30,6 +30,7 @@ Implementation * [python.pest](server/src/lexer/pest/python.pest) * [webserver.rs](server/src/webserver.rs) * [log4rs.yml](server/log4rs.yml) + * [Capture events schema](server/scripts/capture_events_schema.sql) * [ide.rs](server/src/ide.rs) * [filewatcher.rs](server/src/ide/filewatcher.rs) * [vscode.rs](server/src/ide/vscode.rs) From 2827b9a07b598ea6202cc411c9e3dd20ac10ecb8 Mon Sep 17 00:00:00 2001 From: "JDS-WORKSTATION\\jspah" <44337821+jspahn80134@users.noreply.github.com> Date: Fri, 22 May 2026 10:13:40 -0600 Subject: [PATCH 33/45] Stabilize client browser test --- client/src/CodeChatEditor-test.mts | 44 +++++++++++++++++++++-- server/tests/overall_1.rs | 56 ++++++++++++++++++++++++++---- 2 files changed, 91 insertions(+), 9 deletions(-) diff --git a/client/src/CodeChatEditor-test.mts b/client/src/CodeChatEditor-test.mts index 999953d0..6705b5e4 100644 --- a/client/src/CodeChatEditor-test.mts +++ b/client/src/CodeChatEditor-test.mts @@ -43,6 +43,24 @@ import { // From [SO](https://stackoverflow.com/a/39914235). const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); +const RENDER_TIMEOUT_MS = 10000; +const MOCHA_TEST_TIMEOUT_MS = RENDER_TIMEOUT_MS + 5000; + +const waitFor = async ( + description: string, + predicate: () => boolean, + timeoutMs = RENDER_TIMEOUT_MS, +) => { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (predicate()) { + return; + } + await sleep(100); + } + assert.fail(`Timed out waiting for ${description}.`); +}; + // Tests // ----- // @@ -95,11 +113,18 @@ window.CodeChatEditor_test = () => { }); }); - test("GraphViz, Mathjax, Mermaid", async function () { - // Wait for the renderers to run. - await sleep(1500); + test("GraphViz, Mathjax, Mermaid", async function (this: Mocha.Context) { + this.timeout(MOCHA_TEST_TIMEOUT_MS); + // Make sure GraphViz includes a `div` at the top of the shadow // root, with a `svg` inside it. + const getGraphVizRoot = () => + document.getElementsByTagName("graphviz-graph")[0] + ?.shadowRoot?.children[0]; + await waitFor( + "GraphViz SVG", + () => getGraphVizRoot()?.children[0]?.tagName === "svg", + ); const gv = document.getElementsByTagName("graphviz-graph")[0] .shadowRoot!.children[0]; @@ -107,6 +132,13 @@ window.CodeChatEditor_test = () => { assert.equal(gv.children[0].tagName, "svg"); // Mermaid graphs start with a div. + const getMermaidRoot = () => + document.getElementsByTagName("wc-mermaid")[0]?.shadowRoot + ?.children[0]; + await waitFor( + "Mermaid SVG", + () => getMermaidRoot()?.children[0]?.tagName === "svg", + ); const mer = document.getElementsByTagName("wc-mermaid")[0].shadowRoot! .children[0]; @@ -114,6 +146,12 @@ window.CodeChatEditor_test = () => { assert.equal(mer.children[0].tagName, "svg"); // MathJax has its own stuff. + await waitFor( + "MathJax containers", + () => + document.getElementsByTagName("mjx-container") + .length === 2, + ); assert.equal( document.getElementsByTagName("mjx-container").length, 2, diff --git a/server/tests/overall_1.rs b/server/tests/overall_1.rs index 075d3314..e77542b2 100644 --- a/server/tests/overall_1.rs +++ b/server/tests/overall_1.rs @@ -27,7 +27,11 @@ mod overall_common; // ------- // // ### Standard library -use std::{error::Error, path::PathBuf, time::Duration}; +use std::{ + error::Error, + path::PathBuf, + time::{Duration, Instant}, +}; // ### Third-party use dunce::canonicalize; @@ -728,13 +732,9 @@ async fn test_client_core( .await .unwrap(); - // Wait for the tests to run. - sleep(Duration::from_millis(3000)).await; - // Look for the test results. codechat_iframe.clone().enter_frame().await.unwrap(); - let mocha_results = driver.find(By::Css("#mocha-stats .result")).await.unwrap(); - assert_eq!(mocha_results.inner_html().await.unwrap(), "✓"); + wait_for_mocha_success(&driver).await.unwrap(); server_id -= MESSAGE_ID_INCREMENT; assert_eq!( @@ -751,6 +751,50 @@ async fn test_client_core( Ok(()) } +async fn wait_for_mocha_success(driver: &WebDriver) -> Result<(), WebDriverError> { + const MOCHA_TEST_TIMEOUT: Duration = Duration::from_millis(30000); + + let start = Instant::now(); + loop { + if let Ok(mocha_results) = driver.find(By::Css("#mocha-stats .result")).await { + let result = mocha_results.inner_html().await?; + if result == "✓" { + return Ok(()); + } + if result == "✖" { + panic!( + "Browser Mocha tests failed:\n{}", + mocha_failure_text(driver).await + ); + } + } + + if start.elapsed() >= MOCHA_TEST_TIMEOUT { + panic!("Timed out waiting for browser Mocha tests to finish."); + } + sleep(Duration::from_millis(200)).await; + } +} + +async fn mocha_failure_text(driver: &WebDriver) -> String { + let failures = driver + .find_all(By::Css("#mocha-report .fail")) + .await + .unwrap_or_default(); + let mut failure_texts = Vec::new(); + for failure in failures { + let text = failure.text().await.unwrap_or_default(); + if !text.trim().is_empty() { + failure_texts.push(text); + } + } + if failure_texts.is_empty() { + "Mocha reported a failure, but no failure details were found.".to_string() + } else { + failure_texts.join("\n\n") + } +} + make_test!(test_client_updates, test_client_updates_core); async fn test_client_updates_core( From f259b472046042886c355f8b2c5038f1f62656f6 Mon Sep 17 00:00:00 2001 From: "JDS-WORKSTATION\\jspah" <44337821+jspahn80134@users.noreply.github.com> Date: Fri, 22 May 2026 13:34:27 -0600 Subject: [PATCH 34/45] Capture non-paste external code insert candidates Add a code_external_insert_candidate capture event for code edits that look non-incremental but were not observed as paste operations. The classifier records only coarse metadata: basis, confidence, size band, block kind, source, and classification basis. Paste markers continue to take precedence so a single edit is not double-counted as both paste and heuristic external insertion. Include targeted code comments and unit coverage for multi-line, small single-line, and large-block classifier behavior. --- server/src/capture.rs | 8 ++ server/src/translation.rs | 206 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 206 insertions(+), 8 deletions(-) diff --git a/server/src/capture.rs b/server/src/capture.rs index 7b99858e..8320a5e3 100644 --- a/server/src/capture.rs +++ b/server/src/capture.rs @@ -122,6 +122,9 @@ pub enum CaptureEventType { ReflectionPromptInserted, /// Code was inserted by a paste operation rather than typed incrementally. CodePaste, + /// Code changed through a non-paste, non-incremental edit shape; the event + /// stores coarse classification metadata, not inserted code content. + CodeExternalInsertCandidate, } impl CaptureEventType { @@ -147,6 +150,7 @@ impl CaptureEventType { Self::HandoffEnd => "handoff_end", Self::ReflectionPromptInserted => "reflection_prompt_inserted", Self::CodePaste => "code_paste", + Self::CodeExternalInsertCandidate => "code_external_insert_candidate", } } } @@ -923,6 +927,10 @@ mod tests { serde_json::to_value(CaptureEventType::CodePaste).unwrap(), json!("code_paste") ); + assert_eq!( + serde_json::to_value(CaptureEventType::CodeExternalInsertCandidate).unwrap(), + json!("code_external_insert_candidate") + ); assert!(serde_json::from_value::(json!("random")).is_err()); } diff --git a/server/src/translation.rs b/server/src/translation.rs index 7f892ec3..c6209e55 100644 --- a/server/src/translation.rs +++ b/server/src/translation.rs @@ -226,10 +226,10 @@ use crate::{ lexer::{CodeDocBlock, DocBlock, supported_languages::MARKDOWN_MODE}, processing::{ CodeChatForWeb, CodeMirror, CodeMirrorDiff, CodeMirrorDiffable, CodeMirrorDocBlock, - CodeMirrorDocBlockVec, SourceFileMetadata, TranslationResultsString, UNICODE_CURSOR_MARKER, - byte_index_of, codechat_for_web_to_source, diff_code_mirror_doc_blocks, diff_str, - doc_block_html_to_markdown, minify, remove_tinymce_data, source_to_codechat_for_web_string, - transform_html, + CodeMirrorDocBlockVec, SourceFileMetadata, StringDiff, TranslationResultsString, + UNICODE_CURSOR_MARKER, byte_index_of, codechat_for_web_to_source, + diff_code_mirror_doc_blocks, diff_str, doc_block_html_to_markdown, minify, + remove_tinymce_data, source_to_codechat_for_web_string, transform_html, }, queue_send, queue_send_func, webserver::{ @@ -591,6 +591,114 @@ fn capture_control_only(wire: &CaptureEventWire) -> bool { ) } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct CodeExternalInsertCandidate { + basis: &'static str, + confidence: &'static str, + size_band: &'static str, +} + +// A single translation update above this size is unlikely to be ordinary +// incremental typing, even when no paste command was observed by the extension. +const LARGE_CODE_INSERT_LINE_THRESHOLD: usize = 10; + +fn inserted_logical_lines(insert: &str) -> usize { + if insert.trim().is_empty() { + return 0; + } + let newline_count = insert.matches('\n').count(); + if newline_count == 0 { + 1 + } else if insert.ends_with('\n') { + newline_count + } else { + newline_count + 1 + } +} + +fn size_band_for_inserted_lines(inserted_lines: usize) -> &'static str { + if inserted_lines > LARGE_CODE_INSERT_LINE_THRESHOLD { + "large_block" + } else if inserted_lines >= 2 { + "multi_line" + } else { + "single_line" + } +} + +fn classify_code_external_insert_candidate( + diff: &[StringDiff], +) -> Option { + // This classifies the shape of an already-computed code diff. The + // code_external_insert_candidate event emitted from this result stores only + // coarse basis/confidence/size metadata, not inserted text. + let inserted_hunks: Vec<_> = diff + .iter() + .filter(|hunk| !hunk.insert.trim().is_empty()) + .collect(); + if inserted_hunks.is_empty() { + return None; + } + + let total_inserted_lines: usize = inserted_hunks + .iter() + .map(|hunk| inserted_logical_lines(&hunk.insert)) + .sum(); + let max_inserted_lines = inserted_hunks + .iter() + .map(|hunk| inserted_logical_lines(&hunk.insert)) + .max() + .unwrap_or(0); + let size_band = size_band_for_inserted_lines(total_inserted_lines); + let has_large_block = total_inserted_lines > LARGE_CODE_INSERT_LINE_THRESHOLD + || max_inserted_lines > LARGE_CODE_INSERT_LINE_THRESHOLD; + + // Large inserts get the strongest signal because they are the least likely + // to be produced by normal typing within one translation transaction. + if has_large_block { + return Some(CodeExternalInsertCandidate { + basis: "large_single_transaction_insert", + confidence: "high", + size_band: "large_block", + }); + } + + // Replacement and multi-hunk edits are weaker signals: they can happen + // during structured editing, but they still indicate non-incremental code + // entry when no paste marker is pending. + let has_large_replacement = inserted_hunks.iter().any(|hunk| { + let deleted_units = hunk.to.map_or(0, |to| to.saturating_sub(hunk.from)); + let inserted_units = hunk.insert.encode_utf16().count(); + let inserted_lines = inserted_logical_lines(&hunk.insert); + deleted_units > 0 && inserted_lines >= 2 && inserted_units > deleted_units.saturating_mul(2) + }); + if has_large_replacement { + return Some(CodeExternalInsertCandidate { + basis: "large_replacement_insert", + confidence: "medium", + size_band, + }); + } + + if diff.len() >= 3 && inserted_hunks.len() >= 2 { + return Some(CodeExternalInsertCandidate { + basis: "multi_hunk_code_insert", + confidence: "medium", + size_band, + }); + } + + if total_inserted_lines >= 2 { + return Some(CodeExternalInsertCandidate { + basis: "multi_line_non_paste_insert", + confidence: "medium", + size_band, + }); + } + + None +} + /// This is the processing task for the Visual Studio Code IDE. It handles all /// the core logic to moving data between the IDE and the client. #[allow(clippy::too_many_arguments)] @@ -902,20 +1010,43 @@ impl TranslationTask { log_capture_event(&self.app_state, capture_event); } + fn log_code_external_insert_candidate( + &self, + file_path: &std::path::Path, + classification_basis: &str, + candidate: CodeExternalInsertCandidate, + ) { + self.log_server_capture_event( + CaptureEventType::CodeExternalInsertCandidate, + file_path, + serde_json::json!({ + "source": "server_translation", + "classification_basis": classification_basis, + "block_kind": "code", + "basis": candidate.basis, + "confidence": candidate.confidence, + "size_band": candidate.size_band, + }), + ); + } + fn log_raw_write_event(&mut self, file_path: &std::path::Path, before: &str, after: &str) { if before == after { self.capture_context.clear_pending_code_paste(); return; } + let code_diff = diff_str(before, after); self.log_server_capture_event( CaptureEventType::WriteCode, file_path, serde_json::json!({ "source": "server_translation", "classification_basis": "raw_text", - "diff": diff_str(before, after), + "diff": &code_diff, }), ); + // Direct paste evidence wins over the heuristic candidate event so a + // single code edit does not emit two competing external-entry signals. if self.capture_context.take_pending_code_paste() { self.log_server_capture_event( CaptureEventType::CodePaste, @@ -927,6 +1058,8 @@ impl TranslationTask { "block_kind": "code", }), ); + } else if let Some(candidate) = classify_code_external_insert_candidate(&code_diff) { + self.log_code_external_insert_candidate(file_path, "raw_text", candidate); } } @@ -959,9 +1092,11 @@ impl TranslationTask { let mut code_changed = false; let mut code_classification_basis = None; + let mut code_diff = None; if before_doc != after.doc { code_changed = true; code_classification_basis = Some("codemirror_code_text"); + let diff = diff_str(before_doc, &after.doc); self.log_server_capture_event( CaptureEventType::WriteCode, file_path, @@ -969,9 +1104,10 @@ impl TranslationTask { "source": source, "classification_basis": "codemirror_code_text", "mode": metadata.mode, - "diff": diff_str(before_doc, &after.doc), + "diff": &diff, }), ); + code_diff = Some(diff); } let doc_blocks_changed = match before_doc_blocks { @@ -996,6 +1132,8 @@ impl TranslationTask { ); } let pending_code_paste = self.capture_context.take_pending_code_paste(); + // Direct paste evidence wins over the heuristic candidate event so a + // single code edit does not emit two competing external-entry signals. if pending_code_paste && code_changed { self.log_server_capture_event( CaptureEventType::CodePaste, @@ -1007,6 +1145,11 @@ impl TranslationTask { "block_kind": "code", }), ); + } else if let (Some(diff), Some(classification_basis)) = + (code_diff.as_ref(), code_classification_basis) + && let Some(candidate) = classify_code_external_insert_candidate(diff) + { + self.log_code_external_insert_candidate(file_path, classification_basis, candidate); } } @@ -1760,8 +1903,11 @@ fn debug_shorten(val: T) -> String { mod tests { use crate::{ capture::CaptureEventType, - processing::CodeMirrorDocBlock, - translation::{CaptureContext, capture_control_only, doc_blocks_compare}, + processing::{CodeMirrorDocBlock, StringDiff}, + translation::{ + CaptureContext, capture_control_only, classify_code_external_insert_candidate, + doc_blocks_compare, + }, webserver::CaptureEventWire, }; @@ -1862,6 +2008,50 @@ mod tests { assert!(!context.take_pending_code_paste()); } + #[test] + fn code_external_insert_classifier_detects_multiline_insert() { + let diff = vec![StringDiff { + from: 0, + to: None, + insert: "let first = 1;\nlet second = 2;".to_string(), + }]; + + let candidate = classify_code_external_insert_candidate(&diff) + .expect("multi-line insert should be classified"); + assert_eq!(candidate.basis, "multi_line_non_paste_insert"); + assert_eq!(candidate.confidence, "medium"); + assert_eq!(candidate.size_band, "multi_line"); + } + + #[test] + fn code_external_insert_classifier_ignores_small_single_line_insert() { + let diff = vec![StringDiff { + from: 0, + to: None, + insert: "x".to_string(), + }]; + + assert!(classify_code_external_insert_candidate(&diff).is_none()); + } + + #[test] + fn code_external_insert_classifier_detects_large_block_insert() { + let diff = vec![StringDiff { + from: 0, + to: None, + insert: (0..11) + .map(|line| format!("let value_{line} = {line};")) + .collect::>() + .join("\n"), + }]; + + let candidate = classify_code_external_insert_candidate(&diff) + .expect("large block insert should be classified"); + assert_eq!(candidate.basis, "large_single_transaction_insert"); + assert_eq!(candidate.confidence, "high"); + assert_eq!(candidate.size_band, "large_block"); + } + #[test] fn test_x1() { let ide = vec![CodeMirrorDocBlock { From 5ff5063be423ba55fe778a7cf12ba7da068e7e19 Mon Sep 17 00:00:00 2001 From: "JDS-WORKSTATION\\jspah" <44337821+jspahn80134@users.noreply.github.com> Date: Sat, 23 May 2026 08:57:34 -0600 Subject: [PATCH 35/45] Address capture review documentation --- server/scripts/capture_events_schema.sql | 5 +++ server/src/capture.rs | 41 ++++++++++++++++++++++-- server/src/translation.rs | 7 +--- server/src/webserver.rs | 21 ++++++++++-- 4 files changed, 62 insertions(+), 12 deletions(-) diff --git a/server/scripts/capture_events_schema.sql b/server/scripts/capture_events_schema.sql index 0af1a7fb..c517c7a0 100644 --- a/server/scripts/capture_events_schema.sql +++ b/server/scripts/capture_events_schema.sql @@ -153,10 +153,13 @@ CREATE INDEX IF NOT EXISTS events_data_gin_idx COMMENT ON TABLE public.events IS 'CodeChat dissertation capture events. Course, group, assignment, condition, and task context are joined during analysis from participant/date mappings.'; +COMMENT ON COLUMN public.events.event_id IS 'Opaque stable per-event ID for correlation and future deduplication; not used for event ordering.'; +COMMENT ON COLUMN public.events.sequence_number IS 'Client-local event order within one VS Code extension session, useful for ordering and detecting gaps.'; COMMENT ON COLUMN public.events.user_id IS 'Pseudonymous participant UUID generated or supplied by the VS Code extension.'; COMMENT ON COLUMN public.events.session_id IS 'Capture session UUID emitted by the VS Code extension.'; COMMENT ON COLUMN public.events.file_hash IS 'SHA-256 hash of the local file path; raw local paths are not stored.'; COMMENT ON COLUMN public.events."timestamp" IS 'Server receive/record timestamp in UTC.'; +COMMENT ON COLUMN public.events.client_tz_offset_min IS 'Client timezone offset in minutes, used with timestamp to derive local time of day without storing location or full timezone name.'; COMMENT ON COLUMN public.events.data IS 'Event-specific JSON payload. Known telemetry metadata lives in typed columns.'; -- Least-privilege deployment guidance: @@ -165,11 +168,13 @@ COMMENT ON COLUMN public.events.data IS 'Event-specific JSON payload. Known tele -- password/database/user names, a database administrator can grant only the -- permissions needed for capture inserts: -- +-- ```sql -- CREATE ROLE codechat_capture_writer LOGIN PASSWORD 'replace-with-secret'; -- GRANT CONNECT ON DATABASE codechat_capture TO codechat_capture_writer; -- GRANT USAGE ON SCHEMA public TO codechat_capture_writer; -- GRANT INSERT ON public.events TO codechat_capture_writer; -- GRANT USAGE ON SEQUENCE public.events_id_seq TO codechat_capture_writer; +-- ``` -- -- Do not grant SELECT, UPDATE, DELETE, CREATE, or ownership privileges to the -- writer account used in `capture_config.json`. diff --git a/server/src/capture.rs b/server/src/capture.rs index 8320a5e3..78e6d244 100644 --- a/server/src/capture.rs +++ b/server/src/capture.rs @@ -47,11 +47,17 @@ // * `user_id` – pseudonymous participant UUID. Course, group, assignment, and // study condition are intentionally joined later from researcher-managed // participant/date mappings instead of being configured by students. -// * `session_id`, `event_id`, `sequence_number`, `schema_version` – event -// integrity and versioning metadata. +// * `event_id` – opaque stable per-event ID for correlation and future +// deduplication across capture transports or retries. +// * `sequence_number` – ordered, session-local event counter for reconstructing +// event order and detecting gaps. +// * `session_id`, `schema_version` – session grouping and payload versioning +// metadata. // * `file_hash` – privacy-preserving SHA-256 hash of the local file path. // * `event_type` – coarse event type (see `CaptureEventType` below). // * `timestamp` – server receive/record timestamp (in UTC). +// * `client_tz_offset_min` – browser/VS Code timezone offset used to derive +// local time-of-day without storing location or full timezone identity. // * `data` – JSONB payload with event-specific details. use std::{ @@ -396,8 +402,16 @@ impl CaptureStatus { #[derive(Debug, Clone)] pub struct CaptureEvent { /// Globally unique event identifier, generated by the client or server. + /// + /// This is an opaque stable ID for correlation and possible future + /// deduplication. It is not ordered; use `sequence_number` for event order + /// within one extension session. pub event_id: Option, /// Client-local event order for one extension session. + /// + /// This is intentionally ordered so analysis can reconstruct event order and + /// detect missing events. It is not globally unique; use `event_id` for + /// stable event identity. pub sequence_number: Option, /// Capture payload schema version. pub schema_version: Option, @@ -416,8 +430,29 @@ pub struct CaptureEvent { /// Server receive/record timestamp, in UTC. pub timestamp: DateTime, /// Client timezone offset in minutes. + /// + /// Combined with the server UTC timestamp, this supports local time-of-day + /// analysis without collecting student location or a full timezone name. pub client_tz_offset_min: Option, - /// Event-specific payload, stored as JSON text in the DB. + /// Event-specific payload, stored as JSONB in the DB. + /// + /// Known keys include: + /// + /// * Capture/settings control: `capture_active`, `capture_control_only`, + /// `changed_by`, `changed_settings`, previous/new consent and recording + /// booleans. + /// * Activity/session summaries: `mode`, `closed_by`, `duration_ms`, + /// `duration_seconds`, `from`, `to`. + /// * Save/run/compile metadata: `reason`, `lineCount`, `sessionName`, + /// `sessionType`, `taskName`, `taskSource`, `processId`, `exitCode`. + /// * Write classification: `source`, `classification_basis`, `diff`, + /// `doc_block_diff`, `doc_block_count_before`, `doc_block_count_after`, + /// `block_kind`, `basis`, `confidence`, `size_band`. + /// * Paste evidence: `operation`, `pending_code_paste`. + /// + /// Future keys should be documented here, tied to an analysis question, and + /// privacy-reviewed before capture. Do not store raw source text or raw + /// local file paths in this payload. pub data: serde_json::Value, } diff --git a/server/src/translation.rs b/server/src/translation.rs index c6209e55..906d9a59 100644 --- a/server/src/translation.rs +++ b/server/src/translation.rs @@ -510,10 +510,6 @@ impl CaptureContext { { self.active = active; } - // Support older wire payloads that stored the session in `data`. - if let Some(session_id) = data.get("session_id").and_then(serde_json::Value::as_str) { - self.session_id = Some(session_id.to_string()); - } if wire.event_type == CaptureEventType::CodePaste && data .get("pending_code_paste") @@ -1922,7 +1918,7 @@ mod tests { sequence_number: None, schema_version: Some(2), user_id: "participant".to_string(), - session_id: None, + session_id: Some("session".to_string()), event_source: Some("vscode_extension".to_string()), language_id: None, file_hash: None, @@ -1945,7 +1941,6 @@ mod tests { context.update_from_wire(&capture_wire( CaptureEventType::SessionStart, serde_json::json!({ - "session_id": "session", "capture_active": true, }), )); diff --git a/server/src/webserver.rs b/server/src/webserver.rs index b859df3b..d652b253 100644 --- a/server/src/webserver.rs +++ b/server/src/webserver.rs @@ -437,10 +437,14 @@ pub struct Credentials { #[derive(Debug, Serialize, Deserialize, PartialEq, TS)] #[ts(export, optional_fields)] pub struct CaptureEventWire { - /// Client-generated unique event identifier. + /// Client-generated unique event identifier. Unlike `sequence_number`, this + /// is an opaque stable ID for correlation and possible future deduplication + /// across capture transports or retries. #[serde(skip_serializing_if = "Option::is_none")] pub event_id: Option, - /// Client-local event order for one extension session. + /// Client-local event order for one extension session. Unlike `event_id`, + /// this is intentionally ordered so analysis can reconstruct event order and + /// detect gaps within a session. #[serde(skip_serializing_if = "Option::is_none")] pub sequence_number: Option, /// Capture payload schema version. @@ -465,10 +469,21 @@ pub struct CaptureEventWire { pub event_type: CaptureEventType, /// Optional client timezone offset in minutes (JS Date().getTimezoneOffset()). + /// Combined with the server UTC timestamp, this allows local time-of-day + /// analysis without storing the student's location or full timezone name. #[serde(skip_serializing_if = "Option::is_none")] pub client_tz_offset_min: Option, - /// Arbitrary event-specific data stored as JSON (optional). + /// Event-specific data stored as JSON. Known keys include capture controls + /// (`capture_active`, `capture_control_only`), activity/session details + /// (`mode`, `closed_by`, `duration_ms`, `duration_seconds`, `from`, `to`), + /// tool/run/build details (`reason`, `lineCount`, `sessionName`, + /// `sessionType`, `taskName`, `taskSource`, `processId`, `exitCode`), write + /// classification details (`source`, `classification_basis`, `diff`, + /// `doc_block_diff`, `block_kind`, `basis`, `confidence`, `size_band`), and + /// paste markers (`operation`, `pending_code_paste`). Add future keys only + /// when they support a specific analysis question and do not store source + /// text or raw local paths. #[serde(skip_serializing_if = "Option::is_none")] #[ts(type = "unknown")] pub data: Option, From 53c14f08d2f31e1777f0bf70d5f8bef2231e484b Mon Sep 17 00:00:00 2001 From: "JDS-WORKSTATION\\jspah" <44337821+jspahn80134@users.noreply.github.com> Date: Sat, 23 May 2026 10:14:43 -0600 Subject: [PATCH 36/45] Use imported Path in translation --- server/src/translation.rs | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/server/src/translation.rs b/server/src/translation.rs index 906d9a59..98dd5efb 100644 --- a/server/src/translation.rs +++ b/server/src/translation.rs @@ -206,7 +206,13 @@ // ------- // // ### Standard library -use std::{collections::HashMap, ffi::OsStr, fmt::Debug, path::PathBuf, rc::Rc}; +use std::{ + collections::HashMap, + ffi::OsStr, + fmt::Debug, + path::{Path, PathBuf}, + rc::Rc, +}; use htmd::Node; // ### Third-party @@ -985,14 +991,14 @@ pub async fn translation_task( // These provide translation for messages passing through the Server. impl TranslationTask { - fn capture_file_path(file_path: &std::path::Path) -> Option { + fn capture_file_path(file_path: &Path) -> Option { file_path.to_str().map(str::to_string) } fn log_server_capture_event( &self, event_type: CaptureEventType, - file_path: &std::path::Path, + file_path: &Path, data: serde_json::Value, ) { let Some(capture_event) = self.capture_context.capture_event( @@ -1008,7 +1014,7 @@ impl TranslationTask { fn log_code_external_insert_candidate( &self, - file_path: &std::path::Path, + file_path: &Path, classification_basis: &str, candidate: CodeExternalInsertCandidate, ) { @@ -1026,7 +1032,7 @@ impl TranslationTask { ); } - fn log_raw_write_event(&mut self, file_path: &std::path::Path, before: &str, after: &str) { + fn log_raw_write_event(&mut self, file_path: &Path, before: &str, after: &str) { if before == after { self.capture_context.clear_pending_code_paste(); return; @@ -1061,7 +1067,7 @@ impl TranslationTask { fn log_code_mirror_write_events( &mut self, - file_path: &std::path::Path, + file_path: &Path, metadata: &SourceFileMetadata, before_doc: &str, before_doc_blocks: Option<&CodeMirrorDocBlockVec>, From 7eace1a20267d3d6fa74793e53a5e9f3eff6ee51 Mon Sep 17 00:00:00 2001 From: "JDS-WORKSTATION\\jspah" <44337821+jspahn80134@users.noreply.github.com> Date: Sat, 23 May 2026 10:34:02 -0600 Subject: [PATCH 37/45] Address remaining capture review comments --- extensions/VSCode/src/extension.ts | 7 +- server/src/capture.rs | 326 +++++++++++++++++++++++++++++ server/src/ide.rs | 3 +- server/src/translation.rs | 279 ++---------------------- server/src/webserver.rs | 76 ++----- server/tests/overall_1.rs | 47 ++--- 6 files changed, 381 insertions(+), 357 deletions(-) diff --git a/extensions/VSCode/src/extension.ts b/extensions/VSCode/src/extension.ts index 26185664..e2694dca 100644 --- a/extensions/VSCode/src/extension.ts +++ b/extensions/VSCode/src/extension.ts @@ -418,7 +418,7 @@ function hashText(value: string): string { function buildFileFields( filePath: string | undefined, -): Pick { +): Pick { if (filePath === undefined) { return { language_id: vscode.window.activeTextEditor?.document.languageId, @@ -426,7 +426,9 @@ function buildFileFields( } const document = get_document(filePath); return { - file_hash: hashText(filePath), + // Send the path only to the local Rust server so it can apply the same + // privacy-preserving hash rule used by server-generated capture events. + file_path: filePath, language_id: document?.languageId, }; } @@ -447,6 +449,7 @@ function capturePayloadSummary(payload: CaptureEventWire): string { `session_id=${payload.session_id}`, `source=${payload.event_source}`, `language=${payload.language_id ?? ""}`, + payload.file_path ? "file_path=present" : "", payload.file_hash ? `file_hash=${payload.file_hash}` : "", ] .filter((part) => part.length > 0) diff --git a/server/src/capture.rs b/server/src/capture.rs index 78e6d244..05dd8247 100644 --- a/server/src/capture.rs +++ b/server/src/capture.rs @@ -80,6 +80,8 @@ use tokio::sync::mpsc; use tokio_postgres::{Client, NoTls}; use ts_rs::TS; +use crate::processing::StringDiff; + static NEXT_CAPTURE_EVENT_ID: AtomicU64 = AtomicU64::new(1); /// Canonical event types. Keep the serialized strings stable for analysis. @@ -176,6 +178,330 @@ pub fn hash_capture_path(path: &str) -> String { .collect() } +/// JSON payload received from local clients for capture events. +/// +/// The server supplies the authoritative timestamp and hashes any raw local file +/// path before storage. Study metadata such as course, assignment, group, +/// condition, and task is not part of this wire type: those values are inferred +/// later from researcher-managed mappings keyed by the pseudonymous `user_id` +/// and event timestamps. +#[derive(Debug, Serialize, Deserialize, PartialEq, TS)] +#[ts(export, optional_fields)] +pub struct CaptureEventWire { + /// Client-generated unique event identifier. Unlike `sequence_number`, this + /// is an opaque stable ID for correlation and possible future deduplication + /// across capture transports or retries. + #[serde(skip_serializing_if = "Option::is_none")] + pub event_id: Option, + /// Client-local event order for one extension session. Unlike `event_id`, + /// this is intentionally ordered so analysis can reconstruct event order and + /// detect gaps within a session. + #[serde(skip_serializing_if = "Option::is_none")] + pub sequence_number: Option, + /// Capture payload schema version. + #[serde(skip_serializing_if = "Option::is_none")] + pub schema_version: Option, + /// Pseudonymous participant UUID. This is not the student's real identity. + pub user_id: String, + /// Logical capture session UUID. + #[serde(skip_serializing_if = "Option::is_none")] + pub session_id: Option, + /// Source of this event, such as the VS Code extension or server translation. + #[serde(skip_serializing_if = "Option::is_none")] + pub event_source: Option, + /// VS Code language identifier for the active file, when known. + #[serde(skip_serializing_if = "Option::is_none")] + pub language_id: Option, + /// Raw local file path from a trusted local client. This value exists only + /// long enough for the Rust server to normalize hashing; it is never written + /// to the capture database or fallback JSONL. + #[serde(skip_serializing_if = "Option::is_none")] + pub file_path: Option, + /// SHA-256 hash of the local file path. Clients should prefer `file_path` + /// so the server owns hashing, but this remains for server-originated + /// events and backward-compatible local callers. + #[serde(skip_serializing_if = "Option::is_none")] + pub file_hash: Option, + /// Canonical capture event type. + pub event_type: CaptureEventType, + + /// Optional client timezone offset in minutes (JS Date().getTimezoneOffset()). + /// Combined with the server UTC timestamp, this allows local time-of-day + /// analysis without storing the student's location or full timezone name. + #[serde(skip_serializing_if = "Option::is_none")] + pub client_tz_offset_min: Option, + + /// Event-specific data stored as JSON. Known keys include capture controls + /// (`capture_active`, `capture_control_only`), activity/session details + /// (`mode`, `closed_by`, `duration_ms`, `duration_seconds`, `from`, `to`), + /// tool/run/build details (`reason`, `lineCount`, `sessionName`, + /// `sessionType`, `taskName`, `taskSource`, `processId`, `exitCode`), write + /// classification details (`source`, `classification_basis`, `diff`, + /// `doc_block_diff`, `block_kind`, `basis`, `confidence`, `size_band`), and + /// paste markers (`operation`, `pending_code_paste`). Add future keys only + /// when they support a specific analysis question and do not store source + /// text or raw local paths. + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(type = "unknown")] + pub data: Option, +} + +/// Participant and session metadata remembered from client capture events. +/// +/// The translation layer generates `write_doc`/`write_code` events after it has +/// parsed CodeChat content. Those events should share the same pseudonymous +/// participant and capture session as extension-side events, but the server +/// should not ask students for course/group/assignment/task setup values. +#[derive(Clone, Debug, Default)] +pub(crate) struct CaptureContext { + /// True only while capture is actively recording. The translation layer must + /// not generate write events from a stale participant/session context after + /// recording or consent is turned off. + active: bool, + /// Pseudonymous participant UUID from the latest client capture event. + user_id: Option, + /// Origin of the client event stream, such as the VS Code extension. + event_source: Option, + /// Extension session UUID carried on the capture wire payload. + session_id: Option, + /// Client timezone offset in minutes, retained for generated write events. + client_tz_offset_min: Option, + /// Capture payload schema version from the extension. + schema_version: Option, + /// One-shot marker set by the extension when the next classified write came + /// from a paste command. It is consumed by the next write classification. + pending_code_paste: bool, +} + +impl CaptureContext { + /// Refresh server-side capture identity and active/inactive state from an + /// extension capture message. This context is used only for server-generated + /// write classification events, not for deciding whether the original + /// extension event itself is inserted. + pub(crate) fn update_from_wire(&mut self, wire: &CaptureEventWire) { + // Session start/end are the coarse lifecycle signals; the explicit + // `capture_active` data field handles settings-change audit events that + // should be inserted while also disabling later translated writes. + match wire.event_type { + CaptureEventType::SessionStart => self.active = true, + CaptureEventType::SessionEnd => self.active = false, + _ => {} + } + // Keep the most recent participant/session metadata so translated write + // events can be joined to the same participant as extension events. + if !wire.user_id.trim().is_empty() { + self.user_id = Some(wire.user_id.clone()); + } + if let Some(event_source) = &wire.event_source { + self.event_source = Some(event_source.clone()); + } + if let Some(session_id) = &wire.session_id { + self.session_id = Some(session_id.clone()); + } + if let Some(schema_version) = wire.schema_version { + self.schema_version = Some(schema_version); + } + if let Some(client_tz_offset_min) = wire.client_tz_offset_min { + self.client_tz_offset_min = Some(client_tz_offset_min); + } + if let Some(serde_json::Value::Object(data)) = &wire.data { + // Settings-change audit events use this flag to tell the server + // whether future translation-generated write events are allowed. + if let Some(active) = data + .get("capture_active") + .and_then(serde_json::Value::as_bool) + { + self.active = active; + } + if wire.event_type == CaptureEventType::CodePaste + && data + .get("pending_code_paste") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + { + self.pending_code_paste = true; + } + } + } + + pub(crate) fn take_pending_code_paste(&mut self) -> bool { + let pending = self.pending_code_paste; + self.pending_code_paste = false; + pending + } + + pub(crate) fn clear_pending_code_paste(&mut self) { + self.pending_code_paste = false; + } + + pub(crate) fn capture_event( + &self, + event_type: CaptureEventType, + file_path: Option, + data: serde_json::Value, + ) -> Option { + // Do not generate server-side write_doc/write_code rows unless the + // latest settings state says capture is actively recording. + if !self.active { + return None; + } + // Normalize arbitrary JSON payloads into objects so we can attach + // server-translation metadata consistently. + let mut data = match data { + serde_json::Value::Object(map) => map, + other => { + let mut map = serde_json::Map::new(); + map.insert("value".to_string(), other); + map + } + }; + // Preserve any existing source field, but default server-generated + // events to `server_translation` for analysis. + data.entry("source".to_string()) + .or_insert_with(|| serde_json::json!("server_translation")); + + Some(CaptureEventWire { + event_id: None, + sequence_number: None, + schema_version: self.schema_version, + user_id: self.user_id.clone()?, + session_id: self.session_id.clone(), + event_source: self.event_source.clone(), + language_id: None, + file_path, + file_hash: None, + event_type, + client_tz_offset_min: self.client_tz_offset_min, + data: Some(serde_json::Value::Object(data)), + }) + } +} + +/// True for a capture message that should update `CaptureContext` only. These +/// messages are used to stop server-side write classification after the user +/// turns off consent or recording, without adding a synthetic DB row. +pub(crate) fn capture_control_only(wire: &CaptureEventWire) -> bool { + matches!( + &wire.data, + Some(serde_json::Value::Object(data)) + if data + .get("capture_control_only") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + ) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct CodeExternalInsertCandidate { + /// The edit-shape reason for treating this insert as likely external input. + pub(crate) basis: &'static str, + /// Coarse confidence label for analysis filters. + pub(crate) confidence: &'static str, + /// Coarse size label for the inserted code. + pub(crate) size_band: &'static str, +} + +// A single translation update above this size is unlikely to be ordinary +// incremental typing, even when no paste command was observed by the extension. +const LARGE_CODE_INSERT_LINE_THRESHOLD: usize = 10; + +fn inserted_logical_lines(insert: &str) -> usize { + if insert.trim().is_empty() { + return 0; + } + let newline_count = insert.matches('\n').count(); + if newline_count == 0 { + 1 + } else if insert.ends_with('\n') { + newline_count + } else { + newline_count + 1 + } +} + +fn size_band_for_inserted_lines(inserted_lines: usize) -> &'static str { + if inserted_lines > LARGE_CODE_INSERT_LINE_THRESHOLD { + "large_block" + } else if inserted_lines >= 2 { + "multi_line" + } else { + "single_line" + } +} + +pub(crate) fn classify_code_external_insert_candidate( + diff: &[StringDiff], +) -> Option { + // This classifies the shape of an already-computed code diff. The + // code_external_insert_candidate event emitted from this result stores only + // coarse basis/confidence/size metadata, not inserted text. + let inserted_hunks: Vec<_> = diff + .iter() + .filter(|hunk| !hunk.insert.trim().is_empty()) + .collect(); + if inserted_hunks.is_empty() { + return None; + } + + let total_inserted_lines: usize = inserted_hunks + .iter() + .map(|hunk| inserted_logical_lines(&hunk.insert)) + .sum(); + let max_inserted_lines = inserted_hunks + .iter() + .map(|hunk| inserted_logical_lines(&hunk.insert)) + .max() + .unwrap_or(0); + let size_band = size_band_for_inserted_lines(total_inserted_lines); + let has_large_block = total_inserted_lines > LARGE_CODE_INSERT_LINE_THRESHOLD + || max_inserted_lines > LARGE_CODE_INSERT_LINE_THRESHOLD; + + // Large inserts get the strongest signal because they are the least likely + // to be produced by normal typing within one translation transaction. + if has_large_block { + return Some(CodeExternalInsertCandidate { + basis: "large_single_transaction_insert", + confidence: "high", + size_band: "large_block", + }); + } + + // Replacement and multi-hunk edits are weaker signals: they can happen + // during structured editing, but they still indicate non-incremental code + // entry when no paste marker is pending. + let has_large_replacement = inserted_hunks.iter().any(|hunk| { + let deleted_units = hunk.to.map_or(0, |to| to.saturating_sub(hunk.from)); + let inserted_units = hunk.insert.encode_utf16().count(); + let inserted_lines = inserted_logical_lines(&hunk.insert); + deleted_units > 0 && inserted_lines >= 2 && inserted_units > deleted_units.saturating_mul(2) + }); + if has_large_replacement { + return Some(CodeExternalInsertCandidate { + basis: "large_replacement_insert", + confidence: "medium", + size_band, + }); + } + + if diff.len() >= 3 && inserted_hunks.len() >= 2 { + return Some(CodeExternalInsertCandidate { + basis: "multi_hunk_code_insert", + confidence: "medium", + size_band, + }); + } + + if total_inserted_lines >= 2 { + return Some(CodeExternalInsertCandidate { + basis: "multi_line_non_paste_insert", + confidence: "medium", + size_band, + }); + } + + None +} + /// Configuration used to construct the PostgreSQL connection string. /// /// You can populate this from a JSON file or environment variables in diff --git a/server/src/ide.rs b/server/src/ide.rs index aab9e932..e41db36d 100644 --- a/server/src/ide.rs +++ b/server/src/ide.rs @@ -62,6 +62,7 @@ use tokio::{ // ### Local use crate::{ + capture::CaptureEventWire, ide::vscode::{connection_id_raw_to_str, vscode_ide_core}, processing::{CodeChatForWeb, CodeMirror, CodeMirrorDiffable, SourceFileMetadata}, translation::{CreatedTranslationQueues, create_translation_queues}, @@ -255,7 +256,7 @@ impl CodeChatEditorServer { pub async fn send_capture_event( &self, - capture_event: webserver::CaptureEventWire, + capture_event: CaptureEventWire, ) -> std::io::Result { self.send_message_timeout(EditorMessageContents::Capture(Box::new(capture_event))) .await diff --git a/server/src/translation.rs b/server/src/translation.rs index 98dd5efb..fd00b35c 100644 --- a/server/src/translation.rs +++ b/server/src/translation.rs @@ -228,18 +228,21 @@ use tokio::{ // ### Local use crate::{ - capture::{CaptureEventType, hash_capture_path}, + capture::{ + CaptureContext, CaptureEventType, CodeExternalInsertCandidate, capture_control_only, + classify_code_external_insert_candidate, + }, lexer::{CodeDocBlock, DocBlock, supported_languages::MARKDOWN_MODE}, processing::{ CodeChatForWeb, CodeMirror, CodeMirrorDiff, CodeMirrorDiffable, CodeMirrorDocBlock, - CodeMirrorDocBlockVec, SourceFileMetadata, StringDiff, TranslationResultsString, - UNICODE_CURSOR_MARKER, byte_index_of, codechat_for_web_to_source, - diff_code_mirror_doc_blocks, diff_str, doc_block_html_to_markdown, minify, - remove_tinymce_data, source_to_codechat_for_web_string, transform_html, + CodeMirrorDocBlockVec, SourceFileMetadata, TranslationResultsString, UNICODE_CURSOR_MARKER, + byte_index_of, codechat_for_web_to_source, diff_code_mirror_doc_blocks, diff_str, + doc_block_html_to_markdown, minify, remove_tinymce_data, source_to_codechat_for_web_string, + transform_html, }, queue_send, queue_send_func, webserver::{ - CaptureEventWire, CursorPosition, EditorMessage, EditorMessageContents, INITIAL_MESSAGE_ID, + CursorPosition, EditorMessage, EditorMessageContents, INITIAL_MESSAGE_ID, MESSAGE_ID_INCREMENT, ProcessingTaskHttpRequest, ProcessingTaskHttpRequestFlags, ResultErrTypes, ResultOkTypes, SimpleHttpResponse, SimpleHttpResponseError, UpdateMessageContents, WebAppState, WebsocketQueues, file_to_response, log_capture_event, @@ -449,258 +452,6 @@ struct TranslationTask { capture_context: CaptureContext, } -/// Participant and session metadata remembered from client capture events. -/// -/// The translation layer generates `write_doc`/`write_code` events after it -/// has parsed CodeChat content. Those events should share the same pseudonymous -/// participant and capture session as the extension-side events, but the server -/// should not ask students for course/group/assignment/task setup values. -#[derive(Clone, Debug, Default)] -struct CaptureContext { - /// True only while capture is actively recording. The translation layer - /// must not generate write events from a stale participant/session context - /// after recording or consent is turned off. - active: bool, - /// Pseudonymous participant UUID from the latest client capture event. - user_id: Option, - /// Origin of the client event stream, such as the VS Code extension. - event_source: Option, - /// Extension session UUID carried on the capture wire payload. - session_id: Option, - /// Client timezone offset in minutes, retained for generated write events. - client_tz_offset_min: Option, - /// Capture payload schema version from the extension. - schema_version: Option, - /// One-shot marker set by the extension when the next classified write came - /// from a paste command. It is consumed by the next write classification. - pending_code_paste: bool, -} - -impl CaptureContext { - /// Refresh server-side capture identity and active/inactive state from an - /// extension capture message. This context is used only for server-generated - /// write classification events, not for deciding whether the original - /// extension event itself is inserted. - fn update_from_wire(&mut self, wire: &CaptureEventWire) { - // Session start/end are the coarse lifecycle signals; the explicit - // `capture_active` data field handles settings-change audit events that - // should be inserted while also disabling later translated writes. - match wire.event_type { - CaptureEventType::SessionStart => self.active = true, - CaptureEventType::SessionEnd => self.active = false, - _ => {} - } - // Keep the most recent participant/session metadata so translated write - // events can be joined to the same participant as extension events. - if !wire.user_id.trim().is_empty() { - self.user_id = Some(wire.user_id.clone()); - } - if let Some(event_source) = &wire.event_source { - self.event_source = Some(event_source.clone()); - } - if let Some(session_id) = &wire.session_id { - self.session_id = Some(session_id.clone()); - } - if let Some(schema_version) = wire.schema_version { - self.schema_version = Some(schema_version); - } - if let Some(client_tz_offset_min) = wire.client_tz_offset_min { - self.client_tz_offset_min = Some(client_tz_offset_min); - } - if let Some(serde_json::Value::Object(data)) = &wire.data { - // Settings-change audit events use this flag to tell the server - // whether future translation-generated write events are allowed. - if let Some(active) = data - .get("capture_active") - .and_then(serde_json::Value::as_bool) - { - self.active = active; - } - if wire.event_type == CaptureEventType::CodePaste - && data - .get("pending_code_paste") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false) - { - self.pending_code_paste = true; - } - } - } - - fn take_pending_code_paste(&mut self) -> bool { - let pending = self.pending_code_paste; - self.pending_code_paste = false; - pending - } - - fn clear_pending_code_paste(&mut self) { - self.pending_code_paste = false; - } - - fn capture_event( - &self, - event_type: CaptureEventType, - file_path: Option, - data: serde_json::Value, - ) -> Option { - // Do not generate server-side write_doc/write_code rows unless the - // latest settings state says capture is actively recording. - if !self.active { - return None; - } - // Normalize arbitrary JSON payloads into objects so we can attach - // server-translation metadata consistently. - let mut data = match data { - serde_json::Value::Object(map) => map, - other => { - let mut map = serde_json::Map::new(); - map.insert("value".to_string(), other); - map - } - }; - // Preserve any existing source field, but default server-generated - // events to `server_translation` for analysis. - data.entry("source".to_string()) - .or_insert_with(|| serde_json::json!("server_translation")); - - Some(CaptureEventWire { - event_id: None, - sequence_number: None, - schema_version: self.schema_version, - user_id: self.user_id.clone()?, - session_id: self.session_id.clone(), - event_source: self.event_source.clone(), - language_id: None, - file_hash: file_path.as_deref().map(hash_capture_path), - event_type, - client_tz_offset_min: self.client_tz_offset_min, - data: Some(serde_json::Value::Object(data)), - }) - } -} - -/// True for a capture message that should update `CaptureContext` only. These -/// messages are used to stop server-side write classification after the user -/// turns off consent or recording, without adding a synthetic DB row. -fn capture_control_only(wire: &CaptureEventWire) -> bool { - matches!( - &wire.data, - Some(serde_json::Value::Object(data)) - if data - .get("capture_control_only") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false) - ) -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -struct CodeExternalInsertCandidate { - basis: &'static str, - confidence: &'static str, - size_band: &'static str, -} - -// A single translation update above this size is unlikely to be ordinary -// incremental typing, even when no paste command was observed by the extension. -const LARGE_CODE_INSERT_LINE_THRESHOLD: usize = 10; - -fn inserted_logical_lines(insert: &str) -> usize { - if insert.trim().is_empty() { - return 0; - } - let newline_count = insert.matches('\n').count(); - if newline_count == 0 { - 1 - } else if insert.ends_with('\n') { - newline_count - } else { - newline_count + 1 - } -} - -fn size_band_for_inserted_lines(inserted_lines: usize) -> &'static str { - if inserted_lines > LARGE_CODE_INSERT_LINE_THRESHOLD { - "large_block" - } else if inserted_lines >= 2 { - "multi_line" - } else { - "single_line" - } -} - -fn classify_code_external_insert_candidate( - diff: &[StringDiff], -) -> Option { - // This classifies the shape of an already-computed code diff. The - // code_external_insert_candidate event emitted from this result stores only - // coarse basis/confidence/size metadata, not inserted text. - let inserted_hunks: Vec<_> = diff - .iter() - .filter(|hunk| !hunk.insert.trim().is_empty()) - .collect(); - if inserted_hunks.is_empty() { - return None; - } - - let total_inserted_lines: usize = inserted_hunks - .iter() - .map(|hunk| inserted_logical_lines(&hunk.insert)) - .sum(); - let max_inserted_lines = inserted_hunks - .iter() - .map(|hunk| inserted_logical_lines(&hunk.insert)) - .max() - .unwrap_or(0); - let size_band = size_band_for_inserted_lines(total_inserted_lines); - let has_large_block = total_inserted_lines > LARGE_CODE_INSERT_LINE_THRESHOLD - || max_inserted_lines > LARGE_CODE_INSERT_LINE_THRESHOLD; - - // Large inserts get the strongest signal because they are the least likely - // to be produced by normal typing within one translation transaction. - if has_large_block { - return Some(CodeExternalInsertCandidate { - basis: "large_single_transaction_insert", - confidence: "high", - size_band: "large_block", - }); - } - - // Replacement and multi-hunk edits are weaker signals: they can happen - // during structured editing, but they still indicate non-incremental code - // entry when no paste marker is pending. - let has_large_replacement = inserted_hunks.iter().any(|hunk| { - let deleted_units = hunk.to.map_or(0, |to| to.saturating_sub(hunk.from)); - let inserted_units = hunk.insert.encode_utf16().count(); - let inserted_lines = inserted_logical_lines(&hunk.insert); - deleted_units > 0 && inserted_lines >= 2 && inserted_units > deleted_units.saturating_mul(2) - }); - if has_large_replacement { - return Some(CodeExternalInsertCandidate { - basis: "large_replacement_insert", - confidence: "medium", - size_band, - }); - } - - if diff.len() >= 3 && inserted_hunks.len() >= 2 { - return Some(CodeExternalInsertCandidate { - basis: "multi_hunk_code_insert", - confidence: "medium", - size_band, - }); - } - - if total_inserted_lines >= 2 { - return Some(CodeExternalInsertCandidate { - basis: "multi_line_non_paste_insert", - confidence: "medium", - size_band, - }); - } - - None -} - /// This is the processing task for the Visual Studio Code IDE. It handles all /// the core logic to moving data between the IDE and the client. #[allow(clippy::too_many_arguments)] @@ -1904,13 +1655,12 @@ fn debug_shorten(val: T) -> String { #[cfg(test)] mod tests { use crate::{ - capture::CaptureEventType, - processing::{CodeMirrorDocBlock, StringDiff}, - translation::{ - CaptureContext, capture_control_only, classify_code_external_insert_candidate, - doc_blocks_compare, + capture::{ + CaptureContext, CaptureEventType, CaptureEventWire, capture_control_only, + classify_code_external_insert_candidate, }, - webserver::CaptureEventWire, + processing::{CodeMirrorDocBlock, StringDiff}, + translation::doc_blocks_compare, }; fn capture_wire( @@ -1927,6 +1677,7 @@ mod tests { session_id: Some("session".to_string()), event_source: Some("vscode_extension".to_string()), language_id: None, + file_path: None, file_hash: None, event_type, client_tz_offset_min: Some(360), diff --git a/server/src/webserver.rs b/server/src/webserver.rs index d652b253..078bf859 100644 --- a/server/src/webserver.rs +++ b/server/src/webserver.rs @@ -98,8 +98,8 @@ use crate::{ }; use crate::capture::{ - CaptureEvent, CaptureEventType, CaptureStatus, EventCapture, generate_capture_event_id, - load_capture_config, + CaptureEvent, CaptureEventWire, CaptureStatus, EventCapture, generate_capture_event_id, + hash_capture_path, load_capture_config, }; use chrono::Utc; @@ -428,67 +428,6 @@ pub struct Credentials { pub password: String, } -/// JSON payload received from clients for capture events. -/// -/// The server supplies the authoritative timestamp. Study metadata such as -/// course, assignment, group, condition, and task is not part of this wire type: -/// those values are inferred later from researcher-managed mappings keyed by -/// the pseudonymous `user_id` and event timestamps. -#[derive(Debug, Serialize, Deserialize, PartialEq, TS)] -#[ts(export, optional_fields)] -pub struct CaptureEventWire { - /// Client-generated unique event identifier. Unlike `sequence_number`, this - /// is an opaque stable ID for correlation and possible future deduplication - /// across capture transports or retries. - #[serde(skip_serializing_if = "Option::is_none")] - pub event_id: Option, - /// Client-local event order for one extension session. Unlike `event_id`, - /// this is intentionally ordered so analysis can reconstruct event order and - /// detect gaps within a session. - #[serde(skip_serializing_if = "Option::is_none")] - pub sequence_number: Option, - /// Capture payload schema version. - #[serde(skip_serializing_if = "Option::is_none")] - pub schema_version: Option, - /// Pseudonymous participant UUID. This is not the student's real identity. - pub user_id: String, - /// Logical capture session UUID. - #[serde(skip_serializing_if = "Option::is_none")] - pub session_id: Option, - /// Source of this event, such as the VS Code extension or server translation. - #[serde(skip_serializing_if = "Option::is_none")] - pub event_source: Option, - /// VS Code language identifier for the active file, when known. - #[serde(skip_serializing_if = "Option::is_none")] - pub language_id: Option, - /// SHA-256 hash of the local file path. Raw local paths are intentionally - /// not accepted on the capture wire. - #[serde(skip_serializing_if = "Option::is_none")] - pub file_hash: Option, - /// Canonical capture event type. - pub event_type: CaptureEventType, - - /// Optional client timezone offset in minutes (JS Date().getTimezoneOffset()). - /// Combined with the server UTC timestamp, this allows local time-of-day - /// analysis without storing the student's location or full timezone name. - #[serde(skip_serializing_if = "Option::is_none")] - pub client_tz_offset_min: Option, - - /// Event-specific data stored as JSON. Known keys include capture controls - /// (`capture_active`, `capture_control_only`), activity/session details - /// (`mode`, `closed_by`, `duration_ms`, `duration_seconds`, `from`, `to`), - /// tool/run/build details (`reason`, `lineCount`, `sessionName`, - /// `sessionType`, `taskName`, `taskSource`, `processId`, `exitCode`), write - /// classification details (`source`, `classification_basis`, `diff`, - /// `doc_block_diff`, `block_kind`, `basis`, `confidence`, `size_band`), and - /// paste markers (`operation`, `pending_code_paste`). Add future keys only - /// when they support a specific analysis question and do not store source - /// text or raw local paths. - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(type = "unknown")] - pub data: Option, -} - // Macros // ------ /// Create a macro to report an error when enqueueing an item. @@ -689,6 +628,15 @@ pub fn log_capture_event(app_state: &WebAppState, wire: CaptureEventWire) -> Cap } else { serde_json::json!({ "value": data }) }; + // Prefer hashing a raw local path on the server so all capture + // transports use the same path-to-hash rule. The raw path is not stored; + // `file_hash` remains only as a backward-compatible/server-originated + // fallback. + let file_hash = wire + .file_path + .as_deref() + .map(hash_capture_path) + .or(wire.file_hash); let event = CaptureEvent::with_columns( Some( @@ -701,7 +649,7 @@ pub fn log_capture_event(app_state: &WebAppState, wire: CaptureEventWire) -> Cap wire.session_id, wire.event_source, wire.language_id, - wire.file_hash, + file_hash, wire.event_type, server_timestamp, wire.client_tz_offset_min, diff --git a/server/tests/overall_1.rs b/server/tests/overall_1.rs index e77542b2..c1218fed 100644 --- a/server/tests/overall_1.rs +++ b/server/tests/overall_1.rs @@ -27,18 +27,15 @@ mod overall_common; // ------- // // ### Standard library -use std::{ - error::Error, - path::PathBuf, - time::{Duration, Instant}, -}; +use std::{error::Error, path::PathBuf, time::Duration}; // ### Third-party use dunce::canonicalize; use indoc::indoc; use pretty_assertions::assert_eq; -use thirtyfour::{By, Key, WebDriver, error::WebDriverError, prelude::ElementQueryable}; -use tokio::time::sleep; +use thirtyfour::{ + By, Key, WebDriver, WebElement, error::WebDriverError, prelude::ElementQueryable, +}; // ### Local use crate::overall_common::{ @@ -754,25 +751,23 @@ async fn test_client_core( async fn wait_for_mocha_success(driver: &WebDriver) -> Result<(), WebDriverError> { const MOCHA_TEST_TIMEOUT: Duration = Duration::from_millis(30000); - let start = Instant::now(); - loop { - if let Ok(mocha_results) = driver.find(By::Css("#mocha-stats .result")).await { - let result = mocha_results.inner_html().await?; - if result == "✓" { - return Ok(()); - } - if result == "✖" { - panic!( - "Browser Mocha tests failed:\n{}", - mocha_failure_text(driver).await - ); - } - } - - if start.elapsed() >= MOCHA_TEST_TIMEOUT { - panic!("Timed out waiting for browser Mocha tests to finish."); - } - sleep(Duration::from_millis(200)).await; + let mocha_results = driver + .query(By::Css("#mocha-stats .result")) + .wait(MOCHA_TEST_TIMEOUT, Duration::from_millis(200)) + .with_filter(|element: WebElement| async move { + let result = element.inner_html().await?; + Ok(result == "✓" || result == "✖") + }) + .first() + .await?; + let result = mocha_results.inner_html().await?; + if result == "✓" { + Ok(()) + } else { + panic!( + "Browser Mocha tests failed:\n{}", + mocha_failure_text(driver).await + ); } } From 4be3801aa5859bb016577b1a2aed27039e5cc07f Mon Sep 17 00:00:00 2001 From: "JDS-WORKSTATION\\jspah" <44337821+jspahn80134@users.noreply.github.com> Date: Wed, 3 Jun 2026 07:16:07 -0600 Subject: [PATCH 38/45] Address capture review follow-ups --- server/src/capture.rs | 90 ++++++++++++++++++++++++++++++++++++++- server/src/translation.rs | 36 +++++----------- 2 files changed, 99 insertions(+), 27 deletions(-) diff --git a/server/src/capture.rs b/server/src/capture.rs index 05dd8247..36c121c2 100644 --- a/server/src/capture.rs +++ b/server/src/capture.rs @@ -60,8 +60,13 @@ // local time-of-day without storing location or full timezone identity. // * `data` – JSONB payload with event-specific details. +// Imports +// ------- +// +// ### Standard library use std::{ env, + error::Error, fs::{self, OpenOptions}, io::{self, Write}, path::{Path, PathBuf}, @@ -71,15 +76,16 @@ use std::{ thread, }; +// ### Third-party use chrono::{DateTime, Utc}; use log::{debug, error, info, warn}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; -use std::error::Error; use tokio::sync::mpsc; use tokio_postgres::{Client, NoTls}; use ts_rs::TS; +// ### Local use crate::processing::StringDiff; static NEXT_CAPTURE_EVENT_ID: AtomicU64 = AtomicU64::new(1); @@ -334,6 +340,12 @@ impl CaptureContext { self.pending_code_paste = false; } + /// True when server-generated capture events should be logged for this + /// participant/session context. + pub(crate) fn is_active(&self) -> bool { + self.active + } + pub(crate) fn capture_event( &self, event_type: CaptureEventType, @@ -536,6 +548,10 @@ impl CaptureConfig { if self.port == Some(0) { return Err("capture database port must be between 1 and 65535".to_string()); } + validate_conn_str_field("host", &self.host)?; + validate_conn_str_field("user", &self.user)?; + validate_conn_str_field("password", &self.password)?; + validate_conn_str_field("dbname", &self.dbname)?; Ok(()) } @@ -589,6 +605,18 @@ impl CaptureConfig { } } +fn validate_conn_str_field(field_name: &str, value: &str) -> Result<(), String> { + if value.trim().is_empty() { + return Err(format!("capture database {field_name} must not be empty")); + } + if value.chars().any(char::is_whitespace) { + return Err(format!( + "capture database {field_name} must not contain whitespace" + )); + } + Ok(()) +} + /// Load capture configuration from environment variables or the repo/runtime /// `capture_config.json`. /// @@ -1247,6 +1275,18 @@ mod tests { use super::*; use serde_json::json; + fn valid_capture_config() -> CaptureConfig { + CaptureConfig { + host: "localhost".to_string(), + port: Some(5432), + user: "alice".to_string(), + password: "secret".to_string(), + dbname: "codechat_capture".to_string(), + app_id: None, + fallback_path: None, + } + } + #[test] fn capture_config_to_conn_str_is_well_formed() { let cfg = CaptureConfig { @@ -1411,6 +1451,54 @@ mod tests { assert!(cfg.validate().is_err()); } + #[test] + fn capture_config_rejects_unquoted_conn_str_whitespace() { + let mut cfg = valid_capture_config(); + cfg.host = "db host".to_string(); + assert_eq!( + cfg.validate().unwrap_err(), + "capture database host must not contain whitespace" + ); + + let mut cfg = valid_capture_config(); + cfg.user = "alice example".to_string(); + assert_eq!( + cfg.validate().unwrap_err(), + "capture database user must not contain whitespace" + ); + + let mut cfg = valid_capture_config(); + cfg.password = "secret value".to_string(); + assert_eq!( + cfg.validate().unwrap_err(), + "capture database password must not contain whitespace" + ); + + let mut cfg = valid_capture_config(); + cfg.dbname = "codechat capture".to_string(); + assert_eq!( + cfg.validate().unwrap_err(), + "capture database dbname must not contain whitespace" + ); + } + + #[test] + fn capture_config_rejects_empty_conn_str_fields() { + let mut cfg = valid_capture_config(); + cfg.host.clear(); + assert_eq!( + cfg.validate().unwrap_err(), + "capture database host must not be empty" + ); + + let mut cfg = valid_capture_config(); + cfg.user = " \t".to_string(); + assert_eq!( + cfg.validate().unwrap_err(), + "capture database user must not be empty" + ); + } + use std::fs; //use tokio::time::{sleep, Duration}; diff --git a/server/src/translation.rs b/server/src/translation.rs index fd00b35c..ccee50a1 100644 --- a/server/src/translation.rs +++ b/server/src/translation.rs @@ -783,7 +783,8 @@ impl TranslationTask { ); } - fn log_raw_write_event(&mut self, file_path: &Path, before: &str, after: &str) { + fn log_raw_write_event(&mut self, file_path: &Path, after: &str) { + let before = self.source_code.as_str(); if before == after { self.capture_context.clear_pending_code_paste(); return; @@ -820,11 +821,12 @@ impl TranslationTask { &mut self, file_path: &Path, metadata: &SourceFileMetadata, - before_doc: &str, - before_doc_blocks: Option<&CodeMirrorDocBlockVec>, after: &CodeMirror, source: &str, ) { + let before_doc = self.code_mirror_doc.as_str(); + let before_doc_blocks = self.code_mirror_doc_blocks.as_ref(); + if metadata.mode == MARKDOWN_MODE { let doc_changed = !compare_html(before_doc, &after.doc); if doc_changed { @@ -1101,20 +1103,13 @@ impl TranslationTask { else { panic!("Unexpected diff value."); }; - if self.sent_full { - let before_doc = self.code_mirror_doc.clone(); - let before_doc_blocks = - self.code_mirror_doc_blocks.clone(); + if self.capture_context.is_active() { self.log_code_mirror_write_events( &clean_file_path, &ccfw.metadata, - &before_doc, - before_doc_blocks.as_ref(), code_mirror_translated, "ide", ); - } else { - self.capture_context.clear_pending_code_paste(); } // Send a diff if possible. let client_contents = if self.sent_full { @@ -1161,16 +1156,11 @@ impl TranslationTask { Err(ResultErrTypes::TodoBinarySupport) } TranslationResultsString::Unknown => { - if self.sent_full { - let before_source_code = - self.source_code.clone(); + if self.capture_context.is_active() { self.log_raw_write_event( &clean_file_path, - &before_source_code, &code_mirror.doc, ); - } else { - self.capture_context.clear_pending_code_paste(); } // Send the new raw contents. debug!("Sending translated contents to Client."); @@ -1284,19 +1274,13 @@ impl TranslationTask { // TODO: support diffable! panic!("Diff not supported."); }; - if self.sent_full { - let before_doc = self.code_mirror_doc.clone(); - let before_doc_blocks = self.code_mirror_doc_blocks.clone(); + if self.capture_context.is_active() { self.log_code_mirror_write_events( &clean_file_path, &cfw.metadata, - &before_doc, - before_doc_blocks.as_ref(), code_mirror, "client", ); - } else { - self.capture_context.clear_pending_code_paste(); } self.code_mirror_doc = code_mirror.doc.clone(); self.code_mirror_doc_blocks = Some(code_mirror.doc_blocks.clone()); @@ -1663,12 +1647,12 @@ mod tests { translation::doc_blocks_compare, }; + /// Minimal test helper for feeding lifecycle/control messages into the + /// translation-layer capture context. fn capture_wire( event_type: crate::capture::CaptureEventType, data: serde_json::Value, ) -> CaptureEventWire { - // Minimal test helper for feeding lifecycle/control messages into the - // translation-layer capture context. CaptureEventWire { event_id: None, sequence_number: None, From 3fd74f2fb8a65414c81f16fa1957faee64914723 Mon Sep 17 00:00:00 2001 From: "JDS-WORKSTATION\\jspah" <44337821+jspahn80134@users.noreply.github.com> Date: Thu, 4 Jun 2026 07:04:02 -0600 Subject: [PATCH 39/45] Address capture review behavior Remove paste/external-insert heuristic capture rows, make recording session-local, add JSONL fallback capture without DB config, assign server-generated capture sequence numbers, and diff Markdown source writes. --- extensions/VSCode/package.json | 30 --- extensions/VSCode/src/extension.ts | 139 +++++--------- server/src/capture.rs | 288 ++++++++++++----------------- server/src/translation.rs | 196 +++++--------------- server/src/webserver.rs | 31 ++-- 5 files changed, 236 insertions(+), 448 deletions(-) diff --git a/extensions/VSCode/package.json b/extensions/VSCode/package.json index 01fe31a4..61faab8b 100644 --- a/extensions/VSCode/package.json +++ b/extensions/VSCode/package.json @@ -63,11 +63,6 @@ ], "markdownDescription": "Select the location of the CodeChat Editor Client. After changing this value, you **must** close then restart the CodeChat Editor extension." }, - "CodeChatEditor.Capture.RecordStudyEvents": { - "type": "boolean", - "default": false, - "markdownDescription": "Record CodeChat dissertation capture events. This defaults to off. Consent is recorded through **Manage CodeChat Editor Capture** and also defaults to off.\n\n| Consent recorded | Record study events | What happens |\n| --- | --- | --- |\n| Off | Off | Capture is off. |\n| On | Off | Consent is retained, but recording is paused. |\n| On | On | Capture records study events. |\n| Off | On | Capture waits for consent before recording. |" - }, "CodeChatEditor.Capture.ConsentEnabled": { "type": "boolean", "default": false, @@ -96,31 +91,6 @@ { "command": "extension.codeChatInsertReflectionPrompt", "title": "CodeChat Editor: Insert Reflection Prompt" - }, - { - "command": "extension.codeChatCapturePaste", - "title": "CodeChat Editor: Capture Paste" - } - ], - "menus": { - "commandPalette": [ - { - "command": "extension.codeChatCapturePaste", - "when": "false" - } - ] - }, - "keybindings": [ - { - "command": "extension.codeChatCapturePaste", - "key": "ctrl+v", - "mac": "cmd+v", - "when": "editorTextFocus && !editorReadonly && config.CodeChatEditor.Capture.RecordStudyEvents && config.CodeChatEditor.Capture.ConsentEnabled" - }, - { - "command": "extension.codeChatCapturePaste", - "key": "shift+insert", - "when": "editorTextFocus && !editorReadonly && config.CodeChatEditor.Capture.RecordStudyEvents && config.CodeChatEditor.Capture.ConsentEnabled" } ] }, diff --git a/extensions/VSCode/src/extension.ts b/extensions/VSCode/src/extension.ts index e2694dca..d06ab2b4 100644 --- a/extensions/VSCode/src/extension.ts +++ b/extensions/VSCode/src/extension.ts @@ -225,10 +225,9 @@ type CaptureSettingsState = const CAPTURE_SCHEMA_VERSION = 2; const CAPTURE_EVENT_SOURCE = "vscode_extension"; -// Settings contribution key for the user-facing recording checkbox. The -// shorter `Enabled` setting is deliberately no longer used because it was too -// ambiguous next to consent. -const CAPTURE_RECORD_SETTING_NAME = "RecordStudyEvents"; +// Audit label for the user-facing recording toggle. This is intentionally not +// a persisted setting; recording is scoped to the current VS Code window. +const CAPTURE_RECORD_AUDIT_LABEL = "RecordStudyEvents"; const DEFAULT_REFLECTION_PROMPTS = [ "What changed in your understanding of this code?", "What assumption are you making, and how could you test it?", @@ -246,18 +245,14 @@ let captureFailureLogged = false; let captureTransportReady = false; // True after a capture-enabled extension session has emitted `session_start`. let extensionCaptureSessionStarted = false; +// Recording is intentionally scoped to this VS Code extension host session. +// Consent and participant ID persist in settings, but recording must be +// re-enabled after VS Code restarts and can be toggled independently in each +// open VS Code window. +let sessionRecordStudyEvents = false; // Monotonic per-extension event sequence number used to order events produced // by this VS Code session. let captureSequenceNumber = 0; -// One-shot marker used to associate a paste command with the next document -// change before the normal CodeChat update is sent to the server. -let pendingPasteCapture: - | { - documentUri: string; - beforeVersion: number; - clearTimer: NodeJS.Timeout; - } - | undefined; // Status bar item that reports capture health and opens the capture controls. let capture_status_bar_item: vscode.StatusBarItem | undefined; // Timer used to refresh capture status from the running server. @@ -296,9 +291,9 @@ function optionalString(value: unknown): string | undefined { function loadStudySettings(): StudySettings { const config = vscode.workspace.getConfiguration("CodeChatEditor.Capture"); return { - // Both capture settings default to false; persisted user values override - // these defaults after a student changes the Settings UI. - enabled: config.get(CAPTURE_RECORD_SETTING_NAME, false), + // Recording is session-local so capture starts paused in every VS Code + // window/restart. Consent and participant ID remain persisted settings. + enabled: sessionRecordStudyEvents, consentEnabled: config.get("ConsentEnabled", false), participantId: optionalString(config.get("ParticipantId")) ?? "", }; @@ -562,53 +557,6 @@ async function sendCaptureEvent( } } -function clearPendingPasteCapture(): void { - if (pendingPasteCapture === undefined) { - return; - } - clearTimeout(pendingPasteCapture.clearTimer); - pendingPasteCapture = undefined; -} - -async function sendPendingCodePasteCapture(filePath: string): Promise { - await sendCaptureEvent( - "code_paste", - filePath, - { - operation: "paste", - pending_code_paste: true, - }, - { - controlOnly: true, - }, - ); -} - -async function capturePasteCommand(): Promise { - const editor = vscode.window.activeTextEditor; - if (editor !== undefined) { - clearPendingPasteCapture(); - pendingPasteCapture = { - documentUri: editor.document.uri.toString(), - beforeVersion: editor.document.version, - clearTimer: setTimeout(() => { - pendingPasteCapture = undefined; - }, 1000), - }; - } - - await vscode.commands.executeCommand("editor.action.clipboardPasteAction"); - - if ( - editor !== undefined && - pendingPasteCapture !== undefined && - pendingPasteCapture.documentUri === editor.document.uri.toString() && - editor.document.version === pendingPasteCapture.beforeVersion - ) { - clearPendingPasteCapture(); - } -} - function stringifyCapturePayload(payload: CaptureEventWire): string { return JSON.stringify(payload, (_key, value) => typeof value === "bigint" ? Number(value) : value, @@ -704,7 +652,7 @@ async function setRecordStudyEvents(enabled: boolean): Promise { // Save the previous settings before updating so the audit event can record // exactly what changed. const previousSettings = loadStudySettings(); - await updateCaptureSetting(CAPTURE_RECORD_SETTING_NAME, enabled); + sessionRecordStudyEvents = enabled; await reconcileCaptureSettings( "manage_capture_record_study_events", previousSettings, @@ -765,7 +713,7 @@ async function giveConsentAndRecordStudyEvents(): Promise { await ensureParticipantId(); await updateCaptureSetting("ConsentEnabled", true); - await updateCaptureSetting(CAPTURE_RECORD_SETTING_NAME, true); + sessionRecordStudyEvents = true; await reconcileCaptureSettings( "manage_capture_give_consent_and_record", previousSettings, @@ -789,7 +737,7 @@ async function sendCaptureSettingsChangedEvent( changedSettings.push("ConsentEnabled"); } if (previous.enabled !== current.enabled) { - changedSettings.push(CAPTURE_RECORD_SETTING_NAME); + changedSettings.push(CAPTURE_RECORD_AUDIT_LABEL); } if (changedSettings.length === 0) { return; @@ -974,6 +922,9 @@ async function showCaptureStatus(): Promise { async function recordStudyLifecycleEvent( eventType: CaptureEventType, ): Promise { + if (captureDisabledReason(loadStudySettings()) !== undefined) { + return; + } const active = vscode.window.activeTextEditor; await sendCaptureEvent(eventType, active?.document.fileName, { command: eventType, @@ -1212,10 +1163,6 @@ export const activate = (context: vscode.ExtensionContext) => { "extension.codeChatInsertReflectionPrompt", insertReflectionPrompt, ), - vscode.commands.registerCommand( - "extension.codeChatCapturePaste", - capturePasteCommand, - ), // Study lifecycle commands are registered for optional study // automation/keybindings, but they are not contributed to the Command // Palette. Normal users should only see status and reflection commands. @@ -1282,31 +1229,11 @@ export const activate = (context: vscode.ExtensionContext) => { const kind = classifyAtPosition(doc, pos); const filePath = doc.fileName; - const pendingPaste = pendingPasteCapture; - const pendingPasteSend = - pendingPaste !== undefined && - pendingPaste.documentUri === - doc.uri.toString() && - doc.version > pendingPaste.beforeVersion - ? (() => { - clearPendingPasteCapture(); - return sendPendingCodePasteCapture( - filePath, - ); - })() - : undefined; - // Update our notion of current activity + doc // session. noteActivity(kind, filePath); - if (pendingPasteSend !== undefined) { - void pendingPasteSend.finally(() => - send_update(true), - ); - } else { - send_update(true); - } + send_update(true); }), ); @@ -1376,6 +1303,12 @@ export const activate = (context: vscode.ExtensionContext) => { // CAPTURE: listen for file saves. context.subscriptions.push( vscode.workspace.onDidSaveTextDocument((doc) => { + if ( + captureDisabledReason(loadStudySettings()) !== + undefined + ) { + return; + } sendCaptureEvent("save", doc.fileName, { reason: "manual_save", languageId: doc.languageId, @@ -1387,6 +1320,12 @@ export const activate = (context: vscode.ExtensionContext) => { // CAPTURE: start and end of a debug/run session. context.subscriptions.push( vscode.debug.onDidStartDebugSession((session) => { + if ( + captureDisabledReason(loadStudySettings()) !== + undefined + ) { + return; + } const active = vscode.window.activeTextEditor; const filePath = active?.document.fileName; sendCaptureEvent("run", filePath, { @@ -1395,6 +1334,12 @@ export const activate = (context: vscode.ExtensionContext) => { }); }), vscode.debug.onDidTerminateDebugSession((session) => { + if ( + captureDisabledReason(loadStudySettings()) !== + undefined + ) { + return; + } const active = vscode.window.activeTextEditor; const filePath = active?.document.fileName; sendCaptureEvent("run_end", filePath, { @@ -1408,6 +1353,12 @@ export const activate = (context: vscode.ExtensionContext) => { // tasks. context.subscriptions.push( vscode.tasks.onDidStartTaskProcess((e) => { + if ( + captureDisabledReason(loadStudySettings()) !== + undefined + ) { + return; + } const active = vscode.window.activeTextEditor; const filePath = active?.document.fileName; const task = e.execution.task; @@ -1419,6 +1370,12 @@ export const activate = (context: vscode.ExtensionContext) => { }); }), vscode.tasks.onDidEndTaskProcess((e) => { + if ( + captureDisabledReason(loadStudySettings()) !== + undefined + ) { + return; + } const active = vscode.window.activeTextEditor; const filePath = active?.document.fileName; const task = e.execution.task; diff --git a/server/src/capture.rs b/server/src/capture.rs index 36c121c2..112929a9 100644 --- a/server/src/capture.rs +++ b/server/src/capture.rs @@ -49,8 +49,8 @@ // participant/date mappings instead of being configured by students. // * `event_id` – opaque stable per-event ID for correlation and future // deduplication across capture transports or retries. -// * `sequence_number` – ordered, session-local event counter for reconstructing -// event order and detecting gaps. +// * `sequence_number` – ordered event counter scoped by `session_id` and +// `event_source` for reconstructing event order and detecting gaps. // * `session_id`, `schema_version` – session grouping and payload versioning // metadata. // * `file_hash` – privacy-preserving SHA-256 hash of the local file path. @@ -85,9 +85,6 @@ use tokio::sync::mpsc; use tokio_postgres::{Client, NoTls}; use ts_rs::TS; -// ### Local -use crate::processing::StringDiff; - static NEXT_CAPTURE_EVENT_ID: AtomicU64 = AtomicU64::new(1); /// Canonical event types. Keep the serialized strings stable for analysis. @@ -134,11 +131,6 @@ pub enum CaptureEventType { HandoffEnd, /// A built-in reflection prompt was inserted into the active editor. ReflectionPromptInserted, - /// Code was inserted by a paste operation rather than typed incrementally. - CodePaste, - /// Code changed through a non-paste, non-incremental edit shape; the event - /// stores coarse classification metadata, not inserted code content. - CodeExternalInsertCandidate, } impl CaptureEventType { @@ -163,8 +155,6 @@ impl CaptureEventType { Self::HandoffStart => "handoff_start", Self::HandoffEnd => "handoff_end", Self::ReflectionPromptInserted => "reflection_prompt_inserted", - Self::CodePaste => "code_paste", - Self::CodeExternalInsertCandidate => "code_external_insert_candidate", } } } @@ -199,9 +189,9 @@ pub struct CaptureEventWire { /// across capture transports or retries. #[serde(skip_serializing_if = "Option::is_none")] pub event_id: Option, - /// Client-local event order for one extension session. Unlike `event_id`, - /// this is intentionally ordered so analysis can reconstruct event order and - /// detect gaps within a session. + /// Event order within one `(session_id, event_source)` stream. Unlike + /// `event_id`, this is intentionally ordered so analysis can reconstruct + /// event order and detect gaps within a stream. #[serde(skip_serializing_if = "Option::is_none")] pub sequence_number: Option, /// Capture payload schema version. @@ -243,10 +233,10 @@ pub struct CaptureEventWire { /// tool/run/build details (`reason`, `lineCount`, `sessionName`, /// `sessionType`, `taskName`, `taskSource`, `processId`, `exitCode`), write /// classification details (`source`, `classification_basis`, `diff`, - /// `doc_block_diff`, `block_kind`, `basis`, `confidence`, `size_band`), and - /// paste markers (`operation`, `pending_code_paste`). Add future keys only - /// when they support a specific analysis question and do not store source - /// text or raw local paths. + /// `doc_block_diff`, `doc_block_count_before`, + /// `doc_block_count_after`). Add future keys only when they support a + /// specific analysis question and do not store source text or raw local + /// paths. #[serde(skip_serializing_if = "Option::is_none")] #[ts(type = "unknown")] pub data: Option, @@ -274,9 +264,9 @@ pub(crate) struct CaptureContext { client_tz_offset_min: Option, /// Capture payload schema version from the extension. schema_version: Option, - /// One-shot marker set by the extension when the next classified write came - /// from a paste command. It is consumed by the next write classification. - pending_code_paste: bool, + /// Server-local event order for translation-generated events in this + /// capture context. Client events have their own extension-side sequence. + server_sequence_number: i64, } impl CaptureContext { @@ -302,6 +292,9 @@ impl CaptureContext { self.event_source = Some(event_source.clone()); } if let Some(session_id) = &wire.session_id { + if self.session_id.as_ref() != Some(session_id) { + self.server_sequence_number = 0; + } self.session_id = Some(session_id.clone()); } if let Some(schema_version) = wire.schema_version { @@ -319,27 +312,9 @@ impl CaptureContext { { self.active = active; } - if wire.event_type == CaptureEventType::CodePaste - && data - .get("pending_code_paste") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false) - { - self.pending_code_paste = true; - } } } - pub(crate) fn take_pending_code_paste(&mut self) -> bool { - let pending = self.pending_code_paste; - self.pending_code_paste = false; - pending - } - - pub(crate) fn clear_pending_code_paste(&mut self) { - self.pending_code_paste = false; - } - /// True when server-generated capture events should be logged for this /// participant/session context. pub(crate) fn is_active(&self) -> bool { @@ -347,7 +322,7 @@ impl CaptureContext { } pub(crate) fn capture_event( - &self, + &mut self, event_type: CaptureEventType, file_path: Option, data: serde_json::Value, @@ -372,13 +347,15 @@ impl CaptureContext { data.entry("source".to_string()) .or_insert_with(|| serde_json::json!("server_translation")); + self.server_sequence_number += 1; + Some(CaptureEventWire { event_id: None, - sequence_number: None, + sequence_number: Some(self.server_sequence_number), schema_version: self.schema_version, user_id: self.user_id.clone()?, session_id: self.session_id.clone(), - event_source: self.event_source.clone(), + event_source: Some("server_translation".to_string()), language_id: None, file_path, file_hash: None, @@ -403,117 +380,6 @@ pub(crate) fn capture_control_only(wire: &CaptureEventWire) -> bool { ) } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) struct CodeExternalInsertCandidate { - /// The edit-shape reason for treating this insert as likely external input. - pub(crate) basis: &'static str, - /// Coarse confidence label for analysis filters. - pub(crate) confidence: &'static str, - /// Coarse size label for the inserted code. - pub(crate) size_band: &'static str, -} - -// A single translation update above this size is unlikely to be ordinary -// incremental typing, even when no paste command was observed by the extension. -const LARGE_CODE_INSERT_LINE_THRESHOLD: usize = 10; - -fn inserted_logical_lines(insert: &str) -> usize { - if insert.trim().is_empty() { - return 0; - } - let newline_count = insert.matches('\n').count(); - if newline_count == 0 { - 1 - } else if insert.ends_with('\n') { - newline_count - } else { - newline_count + 1 - } -} - -fn size_band_for_inserted_lines(inserted_lines: usize) -> &'static str { - if inserted_lines > LARGE_CODE_INSERT_LINE_THRESHOLD { - "large_block" - } else if inserted_lines >= 2 { - "multi_line" - } else { - "single_line" - } -} - -pub(crate) fn classify_code_external_insert_candidate( - diff: &[StringDiff], -) -> Option { - // This classifies the shape of an already-computed code diff. The - // code_external_insert_candidate event emitted from this result stores only - // coarse basis/confidence/size metadata, not inserted text. - let inserted_hunks: Vec<_> = diff - .iter() - .filter(|hunk| !hunk.insert.trim().is_empty()) - .collect(); - if inserted_hunks.is_empty() { - return None; - } - - let total_inserted_lines: usize = inserted_hunks - .iter() - .map(|hunk| inserted_logical_lines(&hunk.insert)) - .sum(); - let max_inserted_lines = inserted_hunks - .iter() - .map(|hunk| inserted_logical_lines(&hunk.insert)) - .max() - .unwrap_or(0); - let size_band = size_band_for_inserted_lines(total_inserted_lines); - let has_large_block = total_inserted_lines > LARGE_CODE_INSERT_LINE_THRESHOLD - || max_inserted_lines > LARGE_CODE_INSERT_LINE_THRESHOLD; - - // Large inserts get the strongest signal because they are the least likely - // to be produced by normal typing within one translation transaction. - if has_large_block { - return Some(CodeExternalInsertCandidate { - basis: "large_single_transaction_insert", - confidence: "high", - size_band: "large_block", - }); - } - - // Replacement and multi-hunk edits are weaker signals: they can happen - // during structured editing, but they still indicate non-incremental code - // entry when no paste marker is pending. - let has_large_replacement = inserted_hunks.iter().any(|hunk| { - let deleted_units = hunk.to.map_or(0, |to| to.saturating_sub(hunk.from)); - let inserted_units = hunk.insert.encode_utf16().count(); - let inserted_lines = inserted_logical_lines(&hunk.insert); - deleted_units > 0 && inserted_lines >= 2 && inserted_units > deleted_units.saturating_mul(2) - }); - if has_large_replacement { - return Some(CodeExternalInsertCandidate { - basis: "large_replacement_insert", - confidence: "medium", - size_band, - }); - } - - if diff.len() >= 3 && inserted_hunks.len() >= 2 { - return Some(CodeExternalInsertCandidate { - basis: "multi_hunk_code_insert", - confidence: "medium", - size_band, - }); - } - - if total_inserted_lines >= 2 { - return Some(CodeExternalInsertCandidate { - basis: "multi_line_non_paste_insert", - confidence: "medium", - size_band, - }); - } - - None -} - /// Configuration used to construct the PostgreSQL connection string. /// /// You can populate this from a JSON file or environment variables in @@ -759,9 +625,9 @@ pub struct CaptureEvent { /// /// This is an opaque stable ID for correlation and possible future /// deduplication. It is not ordered; use `sequence_number` for event order - /// within one extension session. + /// within one `(session_id, event_source)` stream. pub event_id: Option, - /// Client-local event order for one extension session. + /// Event order within one `(session_id, event_source)` stream. /// /// This is intentionally ordered so analysis can reconstruct event order and /// detect missing events. It is not globally unique; use `event_id` for @@ -800,9 +666,7 @@ pub struct CaptureEvent { /// * Save/run/compile metadata: `reason`, `lineCount`, `sessionName`, /// `sessionType`, `taskName`, `taskSource`, `processId`, `exitCode`. /// * Write classification: `source`, `classification_basis`, `diff`, - /// `doc_block_diff`, `doc_block_count_before`, `doc_block_count_after`, - /// `block_kind`, `basis`, `confidence`, `size_band`. - /// * Paste evidence: `operation`, `pending_code_paste`. + /// `doc_block_diff`, `doc_block_count_before`, `doc_block_count_after`. /// /// Future keys should be documented here, tied to an analysis question, and /// privacy-reviewed before capture. Do not store raw source text or raw @@ -903,6 +767,59 @@ pub struct EventCapture { } impl EventCapture { + /// Create a capture worker that writes every event to local JSONL fallback. + /// + /// This mode is used for local debugging and for installations without a + /// configured PostgreSQL capture database. + pub fn fallback_only(fallback_path: PathBuf) -> Result { + let status = Arc::new(Mutex::new(CaptureStatus { + enabled: true, + state: CaptureState::Fallback, + queued_events: 0, + persisted_events: 0, + fallback_events: 0, + failed_events: 0, + last_error: Some("PostgreSQL capture config unavailable".to_string()), + fallback_path: Some(fallback_path.clone()), + })); + + info!( + "Capture: no PostgreSQL config available; writing events to fallback JSONL at {:?}.", + fallback_path + ); + + let (tx, mut rx) = mpsc::unbounded_channel::(); + let status_worker = status.clone(); + + thread::Builder::new() + .name("codechat-capture-fallback".to_string()) + .spawn(move || { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("Capture: failed to build fallback Tokio runtime"); + + runtime.block_on(async move { + while let Some(event) = rx.recv().await { + write_event_to_fallback( + &fallback_path, + &event, + &status_worker, + Some("PostgreSQL capture config unavailable".to_string()), + ); + } + warn!("Capture: event channel closed; fallback-only worker exiting."); + }); + }) + .map_err(|err| { + io::Error::other(format!( + "Capture: failed to start fallback worker thread: {err}" + )) + })?; + + Ok(Self { tx, status }) + } + /// Create a new `EventCapture` instance and spawn a background worker which /// consumes events and inserts them into PostgreSQL. /// @@ -1274,6 +1191,7 @@ async fn insert_legacy_event( mod tests { use super::*; use serde_json::json; + use std::{fs, thread, time::Duration}; fn valid_capture_config() -> CaptureConfig { CaptureConfig { @@ -1324,14 +1242,6 @@ mod tests { serde_json::to_value(CaptureEventType::CaptureSettingsChanged).unwrap(), json!("capture_settings_changed") ); - assert_eq!( - serde_json::to_value(CaptureEventType::CodePaste).unwrap(), - json!("code_paste") - ); - assert_eq!( - serde_json::to_value(CaptureEventType::CodeExternalInsertCandidate).unwrap(), - json!("code_external_insert_candidate") - ); assert!(serde_json::from_value::(json!("random")).is_err()); } @@ -1376,6 +1286,49 @@ mod tests { assert!(ev.timestamp <= after); } + #[test] + fn fallback_only_capture_writes_jsonl() { + let fallback_path = std::env::temp_dir().join(format!( + "codechat-capture-fallback-test-{}-{}.jsonl", + std::process::id(), + Utc::now().timestamp_nanos_opt().unwrap_or_default() + )); + let _ = fs::remove_file(&fallback_path); + + let capture = EventCapture::fallback_only(fallback_path.clone()) + .expect("fallback capture should start"); + capture.log(CaptureEvent::with_columns( + Some("event-1".to_string()), + Some(1), + Some(2), + "participant".to_string(), + Some("session".to_string()), + Some("test".to_string()), + Some("rust".to_string()), + Some("file-hash".to_string()), + CaptureEventType::Save, + Utc::now(), + Some(360), + json!({ "reason": "unit_test" }), + )); + + let mut text = String::new(); + for _ in 0..20 { + if let Ok(contents) = fs::read_to_string(&fallback_path) { + text = contents; + if text.contains("\"event_id\":\"event-1\"") { + break; + } + } + thread::sleep(Duration::from_millis(50)); + } + + assert!(text.contains("\"event_type\":\"save\"")); + assert!(text.contains("\"fallback_timestamp\"")); + assert_eq!(capture.status().state, CaptureState::Fallback); + let _ = fs::remove_file(&fallback_path); + } + #[test] fn capture_event_with_columns_sets_analysis_columns() { let ts = Utc::now(); @@ -1499,7 +1452,6 @@ mod tests { ); } - use std::fs; //use tokio::time::{sleep, Duration}; /// Integration-style test: verify that EventCapture inserts into the rich diff --git a/server/src/translation.rs b/server/src/translation.rs index ccee50a1..bfdaa3b8 100644 --- a/server/src/translation.rs +++ b/server/src/translation.rs @@ -228,10 +228,7 @@ use tokio::{ // ### Local use crate::{ - capture::{ - CaptureContext, CaptureEventType, CodeExternalInsertCandidate, capture_control_only, - classify_code_external_insert_candidate, - }, + capture::{CaptureContext, CaptureEventType, capture_control_only}, lexer::{CodeDocBlock, DocBlock, supported_languages::MARKDOWN_MODE}, processing::{ CodeChatForWeb, CodeMirror, CodeMirrorDiff, CodeMirrorDiffable, CodeMirrorDocBlock, @@ -747,7 +744,7 @@ impl TranslationTask { } fn log_server_capture_event( - &self, + &mut self, event_type: CaptureEventType, file_path: &Path, data: serde_json::Value, @@ -763,30 +760,9 @@ impl TranslationTask { log_capture_event(&self.app_state, capture_event); } - fn log_code_external_insert_candidate( - &self, - file_path: &Path, - classification_basis: &str, - candidate: CodeExternalInsertCandidate, - ) { - self.log_server_capture_event( - CaptureEventType::CodeExternalInsertCandidate, - file_path, - serde_json::json!({ - "source": "server_translation", - "classification_basis": classification_basis, - "block_kind": "code", - "basis": candidate.basis, - "confidence": candidate.confidence, - "size_band": candidate.size_band, - }), - ); - } - fn log_raw_write_event(&mut self, file_path: &Path, after: &str) { let before = self.source_code.as_str(); if before == after { - self.capture_context.clear_pending_code_paste(); return; } let code_diff = diff_str(before, after); @@ -799,22 +775,6 @@ impl TranslationTask { "diff": &code_diff, }), ); - // Direct paste evidence wins over the heuristic candidate event so a - // single code edit does not emit two competing external-entry signals. - if self.capture_context.take_pending_code_paste() { - self.log_server_capture_event( - CaptureEventType::CodePaste, - file_path, - serde_json::json!({ - "source": "server_translation", - "classification_basis": "raw_text", - "operation": "paste", - "block_kind": "code", - }), - ); - } else if let Some(candidate) = classify_code_external_insert_candidate(&code_diff) { - self.log_code_external_insert_candidate(file_path, "raw_text", candidate); - } } fn log_code_mirror_write_events( @@ -822,36 +782,51 @@ impl TranslationTask { file_path: &Path, metadata: &SourceFileMetadata, after: &CodeMirror, + after_source: Option<&str>, source: &str, ) { - let before_doc = self.code_mirror_doc.as_str(); - let before_doc_blocks = self.code_mirror_doc_blocks.as_ref(); - if metadata.mode == MARKDOWN_MODE { - let doc_changed = !compare_html(before_doc, &after.doc); - if doc_changed { + let markdown_diff = { + let before_source = self.source_code.as_str(); + let after_source = after_source.unwrap_or(&after.doc); + (before_source != after_source).then(|| diff_str(before_source, after_source)) + }; + if let Some(diff) = markdown_diff { self.log_server_capture_event( CaptureEventType::WriteDoc, file_path, serde_json::json!({ "source": source, - "classification_basis": "markdown_document", + "classification_basis": "markdown_source", "mode": metadata.mode, - "diff": diff_str(before_doc, &after.doc), + "diff": diff, }), ); } - self.capture_context.clear_pending_code_paste(); return; } - let mut code_changed = false; - let mut code_classification_basis = None; - let mut code_diff = None; - if before_doc != after.doc { - code_changed = true; - code_classification_basis = Some("codemirror_code_text"); - let diff = diff_str(before_doc, &after.doc); + let code_diff = { + let before_doc = self.code_mirror_doc.as_str(); + (before_doc != after.doc).then(|| diff_str(before_doc, &after.doc)) + }; + let (doc_blocks_changed, doc_block_count_before, doc_block_diff) = { + let before_doc_blocks = self.code_mirror_doc_blocks.as_ref(); + let doc_blocks_changed = match before_doc_blocks { + Some(before) => !doc_blocks_compare(before, &after.doc_blocks), + None => !after.doc_blocks.is_empty(), + }; + let doc_block_diff = before_doc_blocks.map(|before| { + serde_json::json!(diff_code_mirror_doc_blocks(before, &after.doc_blocks)) + }); + ( + doc_blocks_changed, + before_doc_blocks.map_or(0, Vec::len), + doc_block_diff, + ) + }; + + if let Some(diff) = code_diff { self.log_server_capture_event( CaptureEventType::WriteCode, file_path, @@ -862,17 +837,9 @@ impl TranslationTask { "diff": &diff, }), ); - code_diff = Some(diff); } - let doc_blocks_changed = match before_doc_blocks { - Some(before) => !doc_blocks_compare(before, &after.doc_blocks), - None => !after.doc_blocks.is_empty(), - }; if doc_blocks_changed { - let doc_block_diff = before_doc_blocks.map(|before| { - serde_json::json!(diff_code_mirror_doc_blocks(before, &after.doc_blocks)) - }); self.log_server_capture_event( CaptureEventType::WriteDoc, file_path, @@ -880,32 +847,12 @@ impl TranslationTask { "source": source, "classification_basis": "codemirror_doc_blocks", "mode": metadata.mode, - "doc_block_count_before": before_doc_blocks.map_or(0, Vec::len), + "doc_block_count_before": doc_block_count_before, "doc_block_count_after": after.doc_blocks.len(), "doc_block_diff": doc_block_diff, }), ); } - let pending_code_paste = self.capture_context.take_pending_code_paste(); - // Direct paste evidence wins over the heuristic candidate event so a - // single code edit does not emit two competing external-entry signals. - if pending_code_paste && code_changed { - self.log_server_capture_event( - CaptureEventType::CodePaste, - file_path, - serde_json::json!({ - "source": "server_translation", - "classification_basis": code_classification_basis.unwrap_or("codemirror_code_text"), - "operation": "paste", - "block_kind": "code", - }), - ); - } else if let (Some(diff), Some(classification_basis)) = - (code_diff.as_ref(), code_classification_basis) - && let Some(candidate) = classify_code_external_insert_candidate(diff) - { - self.log_code_external_insert_candidate(file_path, classification_basis, candidate); - } } // Pass a `Result` message to the Client, unless it's a `LoadFile` result. @@ -1108,6 +1055,7 @@ impl TranslationTask { &clean_file_path, &ccfw.metadata, code_mirror_translated, + Some(&code_mirror.doc), "ide", ); } @@ -1279,6 +1227,7 @@ impl TranslationTask { &clean_file_path, &cfw.metadata, code_mirror, + Some(&new_source_code), "client", ); } @@ -1639,11 +1588,8 @@ fn debug_shorten(val: T) -> String { #[cfg(test)] mod tests { use crate::{ - capture::{ - CaptureContext, CaptureEventType, CaptureEventWire, capture_control_only, - classify_code_external_insert_candidate, - }, - processing::{CodeMirrorDocBlock, StringDiff}, + capture::{CaptureContext, CaptureEventType, CaptureEventWire, capture_control_only}, + processing::CodeMirrorDocBlock, translation::doc_blocks_compare, }; @@ -1723,7 +1669,7 @@ mod tests { } #[test] - fn code_paste_marker_is_one_shot() { + fn server_generated_capture_events_have_session_sequence_numbers() { let mut context = CaptureContext::default(); context.update_from_wire(&capture_wire( CaptureEventType::SessionStart, @@ -1731,61 +1677,17 @@ mod tests { "capture_active": true, }), )); - context.update_from_wire(&capture_wire( - CaptureEventType::CodePaste, - serde_json::json!({ - "capture_active": true, - "capture_control_only": true, - "pending_code_paste": true, - }), - )); - - assert!(context.take_pending_code_paste()); - assert!(!context.take_pending_code_paste()); - } - - #[test] - fn code_external_insert_classifier_detects_multiline_insert() { - let diff = vec![StringDiff { - from: 0, - to: None, - insert: "let first = 1;\nlet second = 2;".to_string(), - }]; - - let candidate = classify_code_external_insert_candidate(&diff) - .expect("multi-line insert should be classified"); - assert_eq!(candidate.basis, "multi_line_non_paste_insert"); - assert_eq!(candidate.confidence, "medium"); - assert_eq!(candidate.size_band, "multi_line"); - } - - #[test] - fn code_external_insert_classifier_ignores_small_single_line_insert() { - let diff = vec![StringDiff { - from: 0, - to: None, - insert: "x".to_string(), - }]; - - assert!(classify_code_external_insert_candidate(&diff).is_none()); - } - - #[test] - fn code_external_insert_classifier_detects_large_block_insert() { - let diff = vec![StringDiff { - from: 0, - to: None, - insert: (0..11) - .map(|line| format!("let value_{line} = {line};")) - .collect::>() - .join("\n"), - }]; - - let candidate = classify_code_external_insert_candidate(&diff) - .expect("large block insert should be classified"); - assert_eq!(candidate.basis, "large_single_transaction_insert"); - assert_eq!(candidate.confidence, "high"); - assert_eq!(candidate.size_band, "large_block"); + let first = context + .capture_event(CaptureEventType::WriteCode, None, serde_json::json!({})) + .expect("active context should generate a capture event"); + let second = context + .capture_event(CaptureEventType::WriteDoc, None, serde_json::json!({})) + .expect("active context should generate a capture event"); + + assert_eq!(first.sequence_number, Some(1)); + assert_eq!(second.sequence_number, Some(2)); + assert_eq!(first.event_source.as_deref(), Some("server_translation")); + assert_eq!(second.event_source.as_deref(), Some("server_translation")); } #[test] diff --git a/server/src/webserver.rs b/server/src/webserver.rs index 078bf859..cd319010 100644 --- a/server/src/webserver.rs +++ b/server/src/webserver.rs @@ -1594,21 +1594,28 @@ pub fn configure_logger(level: LevelFilter) -> Result<(), Box) -> WebAppState { - // Initialize event capture from a config file (optional). + // Initialize event capture from DB config when available; otherwise still + // capture to JSONL fallback so local/debug runs produce inspectable data. let root_path = ROOT_PATH.lock().unwrap().clone(); - let capture: Option = load_capture_config(&root_path).and_then(|cfg| { - let summary = cfg.redacted_summary(); - match EventCapture::new(cfg) { - Ok(ec) => { - info!("Capture: enabled ({summary})"); - Some(ec) - } - Err(err) => { - warn!("Capture: failed to initialize ({summary}): {err}"); - None + let fallback_path = root_path.join("capture-events-fallback.jsonl"); + let capture: Option = match load_capture_config(&root_path) { + Some(cfg) => { + let summary = cfg.redacted_summary(); + match EventCapture::new(cfg) { + Ok(ec) => { + info!("Capture: enabled ({summary})"); + Some(ec) + } + Err(err) => { + warn!( + "Capture: failed to initialize database capture ({summary}); using fallback JSONL: {err}" + ); + EventCapture::fallback_only(fallback_path).ok() + } } } - }); + None => EventCapture::fallback_only(fallback_path).ok(), + }; web::Data::new(AppState { server_handle: Mutex::new(None), From 0a9198db162d83757d0d7be0dfa73fef99dd582a Mon Sep 17 00:00:00 2001 From: "JDS-WORKSTATION\\jspah" <44337821+jspahn80134@users.noreply.github.com> Date: Fri, 5 Jun 2026 05:47:43 -0600 Subject: [PATCH 40/45] Address VS Code extension review findings Serialize capture activity events, add closed_by to activity-ended doc sessions, skip activity classification while capture is off, tighten Markdown/RST code classification, normalize parsed CaptureStatus counters, make DomLocation cursor handling explicit, fix RST reflection prompt output, and reuse captureLog for failure messages. --- extensions/VSCode/src/extension.ts | 203 ++++++++++++++++++++--------- 1 file changed, 143 insertions(+), 60 deletions(-) diff --git a/extensions/VSCode/src/extension.ts b/extensions/VSCode/src/extension.ts index d06ab2b4..a3ee7b2f 100644 --- a/extensions/VSCode/src/extension.ts +++ b/extensions/VSCode/src/extension.ts @@ -127,48 +127,62 @@ let codeChatEditorServer: CodeChatEditorServer | undefined; // Capture uses these helpers only for documentation-like files. Source files // classify directly as code; Markdown/RST get a finer split so prose edits count // as documentation activity while embedded snippets count as code activity. +function markdownFenceMarker(text: string): "`" | "~" | undefined { + // Markdown fences may be indented up to three spaces. Do not trim, since a + // blockquoted fence (`> ````) should not toggle the outer document state. + const match = /^(?: {0,3})(`{3,}|~{3,})/.exec(text); + if (match === null) { + return undefined; + } + return match[1].startsWith("`") ? "`" : "~"; +} + function isInMarkdownCodeFence( doc: vscode.TextDocument, line: number, ): boolean { - // Very simple fence tracker: toggles when encountering ``` or ~~~ at - // start of line. Good enough for dissertation instrumentation; refine later - // if needed. - let inFence = false; - for (let i = 0; i <= line; i++) { - const t = doc.lineAt(i).text.trim(); - if (t.startsWith("```") || t.startsWith("~~~")) { - inFence = !inFence; + // The fence delimiter itself is Markdown markup, not code content. + if (markdownFenceMarker(doc.lineAt(line).text) !== undefined) { + return false; + } + + let activeFence: "`" | "~" | undefined; + for (let i = 0; i < line; i++) { + const marker = markdownFenceMarker(doc.lineAt(i).text); + if (marker === undefined) { + continue; + } + if (activeFence === undefined) { + activeFence = marker; + } else if (activeFence === marker) { + activeFence = undefined; } } - return inFence; + return activeFence !== undefined; } function isInRstCodeBlock(doc: vscode.TextDocument, line: number): boolean { // Heuristic: find the most recent ".. code-block::" (or "::") and see if - // we're in its indented region. This won’t be perfect, but it’s far better - // than file-level classification. - let blockLine = -1; - for (let i = line; i >= 0; i--) { + // the current line belongs to its immediately following indented region. + // A later non-indented paragraph closes the region, so don't keep scanning + // past it and accidentally classify later indented prose as code. + const cur = doc.lineAt(line).text; + if (cur.trim().length === 0 || !/^\s+/.test(cur)) { + return false; + } + + for (let i = line - 1; i >= 0; i--) { const t = doc.lineAt(i).text; const tt = t.trim(); if (tt.startsWith(".. code-block::") || tt === "::") { - blockLine = i; - break; + return true; + } + if (tt.length === 0 || /^\s+/.test(t)) { + continue; } - // If we hit a non-indented line after searching upward too far, keep - // going; rst blocks can be separated by blank lines. + return false; } - if (blockLine < 0) return false; - - // RST code block content usually begins after optional blank line(s), - // indented. Determine whether current line is indented relative to block - // directive line. - const cur = doc.lineAt(line).text; - if (cur.trim().length === 0) return false; - - // If it's indented at least one space/tab, treat it as inside block. - return /^\s+/.test(cur); + return false; } function classifyAtPosition( @@ -281,6 +295,9 @@ const DOC_LANG_IDS = new Set([ // started. let lastActivityKind: ActivityKind = "other"; let docSessionStart: number | null = null; +// Activity events can be generated by synchronous VS Code callbacks. Serialize +// their async capture sends so doc-session rows stay in causal order. +let captureActivityQueue: Promise = Promise.resolve(); function optionalString(value: unknown): string | undefined { return typeof value === "string" && value.trim().length > 0 @@ -466,6 +483,30 @@ function captureStatusSummary(status: CaptureStatus): string { .join(" "); } +type CaptureStatusJson = Omit< + CaptureStatus, + "queued_events" | "persisted_events" | "fallback_events" | "failed_events" +> & { + queued_events: number; + persisted_events: number; + fallback_events: number; + failed_events: number; +}; + +function parseCaptureStatus(json: string): CaptureStatus { + const status = JSON.parse(json) as CaptureStatusJson; + // Rust exports these counters as u64, which ts-rs maps to bigint. JSON + // carries them as numbers, so convert them immediately after parsing to + // keep the runtime value aligned with the generated TypeScript type. + return { + ...status, + queued_events: BigInt(status.queued_events), + persisted_events: BigInt(status.persisted_events), + fallback_events: BigInt(status.fallback_events), + failed_events: BigInt(status.failed_events), + }; +} + interface CaptureSendOptions { // Permit audit/control events even when normal capture is paused or waiting // for consent. @@ -564,9 +605,7 @@ function stringifyCapturePayload(payload: CaptureEventWire): string { } function reportCaptureFailure(message: string) { - capture_output_channel?.appendLine( - `${new Date().toISOString()} capture send failed: ${message}`, - ); + captureLog(`capture send failed: ${message}`); updateCaptureStatusBar("Capture: Error", message); if (captureFailureLogged) { return; @@ -602,9 +641,9 @@ async function refreshCaptureStatus(): Promise { } try { - const status = JSON.parse( + const status = parseCaptureStatus( codeChatEditorServer.getCaptureStatus(), - ) as CaptureStatus; + ); let label: string; switch (status.state) { case "database": @@ -937,7 +976,7 @@ function reflectionPromptText(languageId: string, prompt: string): string { return `\n\n### Reflection\n\n${prompt}\n\n`; } if (languageId === "restructuredtext") { - return `\n.. ${prompt}\n`; + return `\n\nReflection\n----------\n\n${prompt}\n\n`; } if (languageId === "plaintext" || languageId === "latex") { return `\n${prompt}\n`; @@ -1075,7 +1114,7 @@ async function closeDocSession( // server-side write events classify CodeChat doc-block edits precisely, while // this extension-side activity tracker uses the best cursor/file context // available before translation. -function noteActivity(kind: ActivityKind, filePath?: string) { +async function noteActivity(kind: ActivityKind, filePath?: string) { const now = Date.now(); // Handle entering / leaving a "doc" session. @@ -1083,21 +1122,25 @@ function noteActivity(kind: ActivityKind, filePath?: string) { if (docSessionStart === null) { // Starting a new reflective-writing session. docSessionStart = now; - sendCaptureEvent("session_start", filePath, { + await sendCaptureEvent("session_start", filePath, { mode: "doc", }); } } else { if (docSessionStart !== null) { // Ending a reflective-writing session. + const closedBy = + kind === "code" ? "switch_to_code" : "activity_change"; const durationMs = now - docSessionStart; docSessionStart = null; - sendCaptureEvent("doc_session", filePath, { + await sendCaptureEvent("doc_session", filePath, { duration_ms: durationMs, duration_seconds: durationMs / 1000.0, + closed_by: closedBy, }); - sendCaptureEvent("session_end", filePath, { + await sendCaptureEvent("session_end", filePath, { mode: "doc", + closed_by: closedBy, }); } } @@ -1109,7 +1152,7 @@ function noteActivity(kind: ActivityKind, filePath?: string) { docOrCode(kind) && kind !== lastActivityKind ) { - sendCaptureEvent("switch_pane", filePath, { + await sendCaptureEvent("switch_pane", filePath, { from: lastActivityKind, to: kind, }); @@ -1118,6 +1161,18 @@ function noteActivity(kind: ActivityKind, filePath?: string) { lastActivityKind = kind; } +function queueActivityCapture(kind: ActivityKind, filePath?: string): void { + captureActivityQueue = captureActivityQueue + .then(() => noteActivity(kind, filePath)) + .catch((err: unknown) => { + reportCaptureFailure( + `activity capture failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + }); +} + // Activation/deactivation // ----------------------- // @@ -1223,15 +1278,20 @@ export const activate = (context: vscode.ExtensionContext) => { // CAPTURE: update session/switch state. The server // classifies write_* events after parsing. - const doc = event.document; - const firstChange = event.contentChanges[0]; - const pos = firstChange.range.start; - const kind = classifyAtPosition(doc, pos); + if ( + captureDisabledReason(loadStudySettings()) === + undefined + ) { + const doc = event.document; + const firstChange = event.contentChanges[0]; + const pos = firstChange.range.start; + const kind = classifyAtPosition(doc, pos); - const filePath = doc.fileName; - // Update our notion of current activity + doc - // session. - noteActivity(kind, filePath); + const filePath = doc.fileName; + // Update our notion of current activity + doc + // session. + queueActivityCapture(kind, filePath); + } send_update(true); }), @@ -1260,14 +1320,19 @@ export const activate = (context: vscode.ExtensionContext) => { // CAPTURE: update activity + possible // switch\_pane/doc\_session. - const doc = event.document; - const pos = - event.selection?.active ?? - new vscode.Position(0, 0); - const kind = classifyAtPosition(doc, pos); + if ( + captureDisabledReason(loadStudySettings()) === + undefined + ) { + const doc = event.document; + const pos = + event.selection?.active ?? + new vscode.Position(0, 0); + const kind = classifyAtPosition(doc, pos); - const filePath = doc.fileName; - noteActivity(kind, filePath); + const filePath = doc.fileName; + queueActivityCapture(kind, filePath); + } send_update(true); }), @@ -1287,13 +1352,19 @@ export const activate = (context: vscode.ExtensionContext) => { // CAPTURE: treat a selection change as "activity" // in this document. - const doc = event.textEditor.document; - const pos = - event.selections?.[0]?.active ?? - event.textEditor.selection.active; - const kind = classifyAtPosition(doc, pos); - const filePath = doc.fileName; - noteActivity(kind, filePath); + if ( + captureDisabledReason( + loadStudySettings(), + ) === undefined + ) { + const doc = event.textEditor.document; + const pos = + event.selections?.[0]?.active ?? + event.textEditor.selection.active; + const kind = classifyAtPosition(doc, pos); + const filePath = doc.fileName; + queueActivityCapture(kind, filePath); + } send_update(false); }, @@ -1681,6 +1752,18 @@ export const activate = (context: vscode.ExtensionContext) => { // Instead, depend on the event to always clear // this flag (a source of potential bugs). } + if ( + cursor_position !== undefined && + typeof cursor_position === "object" && + "DomLocation" in cursor_position + ) { + // VS Code can only apply line-based cursor + // locations. DOM locations should be converted + // by the server before reaching the extension. + console_log( + "CodeChat Editor extension: ignoring DOM cursor location in VS Code update.", + ); + } await sendResult(id); break; } From 44364c926f63acba2e594701154dffcc4551fa34 Mon Sep 17 00:00:00 2001 From: "JDS-WORKSTATION\\jspah" <44337821+jspahn80134@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:21:38 -0600 Subject: [PATCH 41/45] Route capture through tokenized web service Replace direct capture DB access with the CaptureWebService bearer-token path. Store user-entered capture tokens in VS Code SecretStorage, validate token status before recording, and keep participant identity sourced from the service. Add durable FIFO spooling, retry behavior, token/service identity guards, and stale-token race protections so offline events upload only under the matching accepted token. Remove the JSON database-secret configuration path and document the new security model in the README, VS Code README, implementation notes, and schema comments. --- .gitignore | 5 +- README.md | 36 + capture_config.example.json | 9 - docs/implementation.md | 43 +- extensions/VSCode/.gitignore | 1 + extensions/VSCode/.vscodeignore | 2 + extensions/VSCode/Cargo.lock | 268 +- extensions/VSCode/README.md | 24 + extensions/VSCode/package.json | 26 +- extensions/VSCode/src/capture-policy.test.js | 193 ++ extensions/VSCode/src/capture-policy.ts | 188 ++ extensions/VSCode/src/extension.ts | 897 +++++- extensions/VSCode/src/lib.rs | 29 + server/Cargo.lock | 252 +- server/Cargo.toml | 3 +- server/scripts/capture_events_schema.sql | 22 +- server/src/capture.rs | 2589 +++++++++++------- server/src/ide.rs | 31 + server/src/translation.rs | 4 +- server/src/webserver.rs | 35 +- server/tests/overall_1.rs | 5 +- 21 files changed, 3130 insertions(+), 1532 deletions(-) delete mode 100644 capture_config.example.json create mode 100644 extensions/VSCode/src/capture-policy.test.js create mode 100644 extensions/VSCode/src/capture-policy.ts diff --git a/.gitignore b/.gitignore index 0f09637d..c355898c 100644 --- a/.gitignore +++ b/.gitignore @@ -23,9 +23,8 @@ target/ /server/bindings/ -# Runtime capture configuration and local fallback capture logs. -/capture_config.json -/capture-events-fallback.jsonl +# Runtime capture spools and generated capture exports. +/capture-spool/ /capture-metrics-*.csv /capture-analysis-*/ /server/scripts/output diff --git a/README.md b/README.md index 9b41a5fc..33a22e5d 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,42 @@ Install the [CodeChat Editor extension for Visual Studio code](extensions/VSCode/README.md). For developers, see [building from source](docs/design.md). +Research capture +---------------- + +The VS Code extension can record dissertation study capture events when a +participant explicitly opts in. A participant first registers in the capture +portal, which emails a capture token. In VS Code, run **Manage CodeChat Editor +Capture** or **CodeChat Editor: Enter Capture Token** from the command palette, +paste the token, then turn on consent and recording from the same capture +manager. + +The token is imported through the VS Code UI and persisted only in VS Code +SecretStorage. It is never written to workspace settings, repository files, or a +JSON configuration file. The extension asks CaptureWebService for token status; +the status item and capture manager show whether the token is accepted, +rejected, unavailable, or disabled by the portal. The participant ID used in +events comes from that status response, not from the token text. + +CodeChat no longer connects directly to the remote capture database and no +longer reads or stores database credentials. The old local JSON database-secret +configuration path has been removed. Capture events now leave CodeChat only by +calling CaptureWebService with the portal-issued bearer token; any database +writer role remains inside the service deployment. + +Events are sanitized, written to a durable local FIFO spool, then uploaded to +CaptureWebService. Spooled events carry only a non-secret token hash/service +identity so events from an old token are not uploaded under a new token. Offline +recording is allowed only after the same token and service URL have previously +been verified as capture-enabled; a token disabled by the portal remains +disabled while the service is unavailable. If the network or service is +unavailable after the token has been accepted at least once, queued events remain +in the spool and upload as soon as the matching token and service are available +again. The capture service endpoint can be changed in the user-level +`CodeChatEditor.Capture.ServiceBaseUrl` setting; workspace values are ignored +for this token-bearing endpoint. Token-bearing requests require HTTPS except for +localhost development endpoints. + Structure --------- diff --git a/capture_config.example.json b/capture_config.example.json deleted file mode 100644 index 6a2e24b8..00000000 --- a/capture_config.example.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "host": "your-aws-rds-endpoint.amazonaws.com", - "port": 5432, - "user": "codechat_capture_writer", - "password": "your-db-password", - "dbname": "your-db-name", - "app_id": "dissertation", - "fallback_path": "capture-events-fallback.jsonl" -} diff --git a/docs/implementation.md b/docs/implementation.md index 427831c9..0bfee307 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -60,9 +60,46 @@ Inside the client: The entire VSCode interface is contained in the extension, with the NAPI-RS glue in the corresponding library. -Does this make more sense to place in the TOC? Or is it too wordy there? I think -a diagram as an overview might be helpful. Perhaps the server, client, etc. -should have its of readme files providing some of this. +### Capture path + +Dissertation capture is a web-service-only path. The VS Code extension stores +the portal-issued capture token in VS Code SecretStorage after the user enters +it through the capture manager or **CodeChat Editor: Enter Capture Token** +command. The token is never written to settings, workspace files, or a JSON +configuration file. The extension keeps only non-secret participant/instance +metadata in VS Code global state and asks CaptureWebService for token status +before recording user events. The participant ID in each event comes from that +status response. The extension also caches the last capture-enabled decision for +the same token hash and service URL, so offline fallback cannot turn a +portal-disabled token back into a recordable state. + +When consent, recording, and token status allow capture, the extension sends the +same capture event shape to the local CodeChat server through the existing +NAPI-RS bridge. The server hashes raw local file paths, removes any raw path +fields from event data before local persistence, writes every event to a durable +`capture-spool` FIFO directory, and uploads batches to +`POST /v1/capture/events` using +`Authorization: Bearer `. CodeChat does not read database credentials +from disk and does not connect directly to PostgreSQL; the database schema under +`server/scripts/` documents the server-side table used by CaptureWebService. +The previous local JSON database-secret configuration path is intentionally +removed; any database writer role or password must remain server-side in the +CaptureWebService deployment. +The service endpoint is read only from the user/application-level +`CodeChatEditor.Capture.ServiceBaseUrl` setting; workspace values are ignored so +a repository cannot redirect a stored token. Bearer-token requests require +HTTPS, with `http://localhost` and `http://127.0.0.1` allowed only for local +development. + +The upload worker deletes spooled events only after the service returns `202`. +After a token has been accepted at least once, transient service or network +failures still allow local recording and keep events queued for retry. Each +spool record includes a non-secret token hash and service URL identity; the +worker uploads only records matching the currently configured token/service, so +events queued under an old token remain local until that matching token is +configured again. Authentication failures pause upload until a valid token is +entered, malformed or oversized events are moved to the spool quarantine +directory, and blocking service calls use bounded request timeouts. Architecture ------------------------------------------ diff --git a/extensions/VSCode/.gitignore b/extensions/VSCode/.gitignore index 3780ba9f..4d024c8a 100644 --- a/extensions/VSCode/.gitignore +++ b/extensions/VSCode/.gitignore @@ -22,6 +22,7 @@ # NPM node_modules/ out/ +.test-output/ # Server support files log4rs.yml diff --git a/extensions/VSCode/.vscodeignore b/extensions/VSCode/.vscodeignore index cd2f89a4..faaec241 100644 --- a/extensions/VSCode/.vscodeignore +++ b/extensions/VSCode/.vscodeignore @@ -27,6 +27,7 @@ src/** # Omit libraries, since `esbuild` takes care of packaging. node_modules/ +.test-output/** # Omit VSCode config. .vscode/** @@ -41,6 +42,7 @@ target/** # Misc files not needed in a package. .eslintrc.yml .gitignore +out/*.test.cjs build.rs Cargo.lock Cargo.toml diff --git a/extensions/VSCode/Cargo.lock b/extensions/VSCode/Cargo.lock index 98d28179..c6fd84c6 100644 --- a/extensions/VSCode/Cargo.lock +++ b/extensions/VSCode/Cargo.lock @@ -191,7 +191,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "smallvec", - "socket2 0.6.4", + "socket2 0.6.5", "time", "tracing", "url", @@ -410,17 +410,6 @@ dependencies = [ "walkdir", ] -[[package]] -name = "async-trait" -version = "0.1.89" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.118", -] - [[package]] name = "autocfg" version = "1.5.1" @@ -541,12 +530,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - [[package]] name = "bytes" version = "1.12.1" @@ -576,9 +559,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.66" +version = "1.2.67" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" dependencies = [ "find-msvc-tools", "jobserver", @@ -612,6 +595,7 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", + "serde", "wasm-bindgen", "windows-link", ] @@ -656,12 +640,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" -[[package]] -name = "cmov" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" - [[package]] name = "cobs" version = "0.3.0" @@ -715,11 +693,10 @@ dependencies = [ "regex", "serde", "serde_json", - "sha2 0.11.0", + "sha2", "test_utils", "thiserror", "tokio", - "tokio-postgres", "tracing", "tracing-log", "tracing-subscriber", @@ -959,15 +936,6 @@ version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fb22e947478ccf9dc44d8922042c677a63fbb88f2cb468521d1145816e5087cb" -[[package]] -name = "ctutils" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" -dependencies = [ - "cmov", -] - [[package]] name = "dashmap" version = "5.5.3" @@ -1046,7 +1014,6 @@ dependencies = [ "block-buffer", "const-oid", "crypto-common", - "ctutils", ] [[package]] @@ -1169,12 +1136,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "fallible-iterator" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" - [[package]] name = "fastrand" version = "2.4.1" @@ -1344,7 +1305,7 @@ checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "libc", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", ] [[package]] @@ -1455,15 +1416,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hmac" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" -dependencies = [ - "digest", -] - [[package]] name = "htmd" version = "0.5.4" @@ -1892,15 +1844,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "libredox" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" -dependencies = [ - "libc", -] - [[package]] name = "lightningcss" version = "1.0.0-alpha.71" @@ -2011,7 +1954,7 @@ dependencies = [ "log-mdc", "mock_instant", "parking_lot", - "rand 0.9.4", + "rand 0.9.5", "serde", "serde-value", "serde_json", @@ -2067,16 +2010,6 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" -[[package]] -name = "md-5" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" -dependencies = [ - "cfg-if", - "digest", -] - [[package]] name = "memchr" version = "2.8.3" @@ -2158,13 +2091,13 @@ checksum = "659579df697b372ef9e36f02fcbb41f6d6f157dcec7db9c9618fa0f23cf0fc20" [[package]] name = "mio" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "log", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", "windows-sys 0.61.2", ] @@ -2176,9 +2109,9 @@ checksum = "9bb517913cfcfb9eeda59f36020269075a152701a01606c612f547e4890be399" [[package]] name = "napi" -version = "3.10.3" +version = "3.10.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c71997d6f7ad4a756966e452426848ac27d3b37a295302d63afbbcce0270f93" +checksum = "6826e5ddc15589b2d68c8ad5321c18e85d40488e93e32962f362e572669bccf6" dependencies = [ "bitflags", "ctor", @@ -2198,9 +2131,9 @@ checksum = "c9c366d2c8c60b86fa632df75f745509b52f9128f91a6bad4c796e44abb505e1" [[package]] name = "napi-derive" -version = "3.5.9" +version = "3.5.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4ba572deef53e2c386759a8c2014175a62679d74ff83adc205c8bc0e0285727" +checksum = "b0fe526e81c105d3640516fcde83909dd1afe757c0d7a15af58830b5bc0fb9a1" dependencies = [ "convert_case 0.11.0", "ctor", @@ -2212,9 +2145,9 @@ dependencies = [ [[package]] name = "napi-derive-backend" -version = "5.1.1" +version = "5.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd961eb2aa8965e3f29722d754f3a86907eb1984e2fbcbe3fe87b9a02d6bfba" +checksum = "514281397bcddd9ea9a876c7a21a57bff2374237a000ca9a64ea0211ec1993e2" dependencies = [ "convert_case 0.11.0", "proc-macro2", @@ -2225,9 +2158,9 @@ dependencies = [ [[package]] name = "napi-sys" -version = "3.2.2" +version = "3.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f5bcdf71abd3a50d00b49c1c2c75251cb3c913777d6139cd37dabc093a5e400" +checksum = "73e43cf2eb0bd1bf95a43c07c076ebd2da5d1e015a71c3d201faeffffcc0ecac" dependencies = [ "libloading", ] @@ -2354,15 +2287,6 @@ dependencies = [ "objc2-encode", ] -[[package]] -name = "objc2-core-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" -dependencies = [ - "bitflags", -] - [[package]] name = "objc2-encode" version = "4.1.0" @@ -2379,15 +2303,6 @@ dependencies = [ "objc2", ] -[[package]] -name = "objc2-system-configuration" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" -dependencies = [ - "objc2-core-foundation", -] - [[package]] name = "once_cell" version = "1.21.4" @@ -2429,9 +2344,9 @@ checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" [[package]] name = "oxc-browserslist" -version = "3.0.9" +version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "373741e2febe6df186995b668f295e46fd402ee12f312ea2fda8b3b6312c0885" +checksum = "50a403a3c6be65be7bc7730d07d959ec6c143e4ff8117da485df78cd5582260a" dependencies = [ "miniz_oxide 0.9.1", "postcard", @@ -2966,7 +2881,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared 0.11.3", - "rand 0.8.6", + "rand 0.8.7", ] [[package]] @@ -3079,36 +2994,6 @@ dependencies = [ "serde", ] -[[package]] -name = "postgres-protocol" -version = "0.6.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08808e3c483c46e999108051c78334f473d5adb59d78bb80a1268c7e6aa6c514" -dependencies = [ - "base64", - "byteorder", - "bytes", - "fallible-iterator", - "hmac", - "md-5", - "memchr", - "rand 0.10.2", - "sha2", - "stringprep", -] - -[[package]] -name = "postgres-types" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "851ca9db4932932d69f3ea811b1abe63087a0f740a47692619dd40d4899b68be" -dependencies = [ - "bytes", - "chrono", - "fallible-iterator", - "postgres-protocol", -] - [[package]] name = "potential_utf" version = "0.1.5" @@ -3253,18 +3138,18 @@ checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" [[package]] name = "rand" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ "rand_core 0.6.4", ] [[package]] name = "rand" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha", "rand_core 0.9.5", @@ -3694,9 +3579,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -3739,17 +3624,6 @@ dependencies = [ "quote", ] -[[package]] -name = "stringprep" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" -dependencies = [ - "unicode-bidi", - "unicode-normalization", - "unicode-properties", -] - [[package]] name = "strsim" version = "0.11.1" @@ -3957,7 +3831,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.4", + "socket2 0.6.5", "tokio-macros", "windows-sys 0.61.2", ] @@ -3973,32 +3847,6 @@ dependencies = [ "syn 2.0.118", ] -[[package]] -name = "tokio-postgres" -version = "0.7.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a528f7d280f6d5b9cd149635c8705b0dd049754bc67d81d31fa25169a93809d3" -dependencies = [ - "async-trait", - "byteorder", - "bytes", - "fallible-iterator", - "futures-channel", - "futures-util", - "log", - "parking_lot", - "percent-encoding", - "phf 0.13.1", - "pin-project-lite", - "postgres-protocol", - "postgres-types", - "rand 0.10.2", - "socket2 0.6.4", - "tokio", - "tokio-util", - "whoami", -] - [[package]] name = "tokio-util" version = "0.7.18" @@ -4124,12 +3972,6 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" -[[package]] -name = "unicode-bidi" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" - [[package]] name = "unicode-id-start" version = "1.4.0" @@ -4148,21 +3990,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" -[[package]] -name = "unicode-normalization" -version = "0.1.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" -dependencies = [ - "tinyvec", -] - -[[package]] -name = "unicode-properties" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" - [[package]] name = "unicode-segmentation" version = "1.13.3" @@ -4228,9 +4055,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.4" +version = "1.23.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +checksum = "ea5fab0d6c3c01ae70085a09cb03d4c7a1d6314e2b3e075392783396d724ca0a" dependencies = [ "js-sys", "wasm-bindgen", @@ -4282,15 +4109,6 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasi" -version = "0.14.7+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" -dependencies = [ - "wasip2", -] - [[package]] name = "wasip2" version = "1.0.4+wasi-0.2.12" @@ -4300,15 +4118,6 @@ dependencies = [ "wit-bindgen", ] -[[package]] -name = "wasite" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" -dependencies = [ - "wasi 0.14.7+wasi-0.2.4", -] - [[package]] name = "wasm-bindgen" version = "0.2.126" @@ -4392,19 +4201,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "whoami" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" -dependencies = [ - "libc", - "libredox", - "objc2-system-configuration", - "wasite", - "web-sys", -] - [[package]] name = "winapi" version = "0.3.9" @@ -4832,9 +4628,9 @@ dependencies = [ [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zstd" diff --git a/extensions/VSCode/README.md b/extensions/VSCode/README.md index 195ac000..718cb388 100644 --- a/extensions/VSCode/README.md +++ b/extensions/VSCode/README.md @@ -35,6 +35,30 @@ Running 2. Run the extension again (close the existing window, type `Ctrl+Shift+P` then select Enable the CodeChat Editor). +Study capture +------------- + +Participants who have registered in the capture portal receive a capture token +by email. To use it, run **Manage CodeChat Editor Capture** or **CodeChat +Editor: Enter Capture Token** from the command palette, paste the token, then +turn on consent and recording. The capture status item shows whether the token +is accepted, rejected, unavailable, or disabled by the portal. + +The token is imported through the VS Code UI and persisted only in VS Code +SecretStorage. It is never written to settings, workspace files, or a JSON +configuration file. The participant identity used in capture events comes from +CaptureWebService token status, not from the token text. + +CodeChat sends capture events only to CaptureWebService and does not connect +directly to the capture database. The old JSON database-secret configuration +path is not used by the extension. Events are sanitized and written to a local +FIFO spool before upload, so events recorded offline after the token has been +accepted and capture-enabled upload automatically when the matching service is +reachable again. If the service endpoint changes, update the user-level +`CodeChatEditor.Capture.ServiceBaseUrl` setting. Workspace values are ignored +for this token-bearing endpoint. Token-bearing service requests must use HTTPS, +except for localhost development endpoints. + Additional documentation ------------------------ diff --git a/extensions/VSCode/package.json b/extensions/VSCode/package.json index 750feb0a..707a491a 100644 --- a/extensions/VSCode/package.json +++ b/extensions/VSCode/package.json @@ -44,7 +44,11 @@ "version": "0.1.61", "activationEvents": [ "onCommand:extension.codeChatEditorActivate", - "onCommand:extension.codeChatEditorDeactivate" + "onCommand:extension.codeChatEditorDeactivate", + "onCommand:extension.codeChatCaptureStatus", + "onCommand:extension.codeChatCaptureEnterToken", + "onCommand:extension.codeChatCaptureValidateToken", + "onCommand:extension.codeChatCaptureClearToken" ], "contributes": { "configuration": { @@ -68,10 +72,11 @@ "default": false, "markdownDescription": "Record that participant consent has been given for CodeChat dissertation capture. This defaults to off and persists after setting." }, - "CodeChatEditor.Capture.ParticipantId": { + "CodeChatEditor.Capture.ServiceBaseUrl": { "type": "string", - "default": "", - "markdownDescription": "Pseudonymous participant identifier used as the capture user_id. If left blank, CodeChat generates a UUID when the student gives consent." + "scope": "application", + "default": "https://9m2nbv2rvc.execute-api.us-east-2.amazonaws.com/dev", + "markdownDescription": "Application-level base URL for CaptureWebService. Workspace settings are ignored for this token-bearing endpoint. Capture tokens are stored separately in VS Code SecretStorage and are never written to settings." } } }, @@ -88,6 +93,18 @@ "command": "extension.codeChatCaptureStatus", "title": "Manage CodeChat Editor Capture" }, + { + "command": "extension.codeChatCaptureEnterToken", + "title": "CodeChat Editor: Enter Capture Token" + }, + { + "command": "extension.codeChatCaptureValidateToken", + "title": "CodeChat Editor: Validate Capture Token" + }, + { + "command": "extension.codeChatCaptureClearToken", + "title": "CodeChat Editor: Clear Capture Token" + }, { "command": "extension.codeChatInsertReflectionPrompt", "title": "CodeChat Editor: Insert Reflection Prompt" @@ -126,6 +143,7 @@ }, "scripts": { "compile": "cargo run --manifest-path=../../builder/Cargo.toml ext-build", + "test:capture-policy": "esbuild src/capture-policy.ts --platform=node --format=cjs --bundle --outfile=.test-output/capture-policy.test.cjs && node --test src/capture-policy.test.js", "vscode:prepublish": "cargo run --manifest-path=../../builder/Cargo.toml ext-build --dist" } } diff --git a/extensions/VSCode/src/capture-policy.test.js b/extensions/VSCode/src/capture-policy.test.js new file mode 100644 index 00000000..e3cf34fa --- /dev/null +++ b/extensions/VSCode/src/capture-policy.test.js @@ -0,0 +1,193 @@ +const assert = require("node:assert/strict"); +const test = require("node:test"); + +const { + captureRefreshStillCurrentSnapshot, + captureStatusFailureClearsIdentity, + captureTokenCanRecord, + captureTokenClearedState, + captureTokenSnapshotStillCurrent, + captureTokenStatusForStatusFailure, + normalizeCaptureServiceBaseUrl, + trustedCaptureServiceBaseUrl, +} = require("../.test-output/capture-policy.test.cjs"); + +test("capture service URL normalization strips known routes", () => { + assert.equal( + normalizeCaptureServiceBaseUrl( + "https://capture.example/dev/v1/capture/events/", + ), + "https://capture.example/dev", + ); + assert.equal( + normalizeCaptureServiceBaseUrl("http://localhost:8787/v1/capture/status"), + "http://localhost:8787", + ); +}); + +test("capture service URL normalization rejects unsafe token destinations", () => { + assert.throws( + () => normalizeCaptureServiceBaseUrl("http://capture.example/dev"), + /https:\/\/ except for localhost/, + ); + assert.throws( + () => normalizeCaptureServiceBaseUrl("http://localhost.evil/dev"), + /https:\/\/ except for localhost/, + ); + assert.throws( + () => normalizeCaptureServiceBaseUrl("postgres://capture.example/dev"), + /https:\/\/ except for localhost/, + ); + assert.throws( + () => normalizeCaptureServiceBaseUrl("https://user:pass@example.com/dev"), + /must not include credentials/, + ); +}); + +test("offline recording requires a cached capture-enabled token", () => { + assert.equal(captureTokenCanRecord("participant", true, "accepted"), true); + assert.equal( + captureTokenCanRecord("participant", true, "service_unavailable"), + true, + ); + assert.equal( + captureTokenCanRecord("participant", false, "service_unavailable"), + false, + ); + assert.equal( + captureTokenCanRecord("participant", undefined, "service_unavailable"), + false, + ); + assert.equal(captureTokenCanRecord("", true, "accepted"), false); +}); + +test("capture status HTTP failures map to service contract states", () => { + assert.equal(captureTokenStatusForStatusFailure(401), "rejected"); + assert.equal(captureTokenStatusForStatusFailure(403), "capture_disabled"); + assert.equal( + captureTokenStatusForStatusFailure(500), + "service_unavailable", + ); + assert.equal( + captureTokenStatusForStatusFailure(undefined), + "service_unavailable", + ); + assert.equal(captureStatusFailureClearsIdentity(401), true); + assert.equal(captureStatusFailureClearsIdentity(403), true); + assert.equal(captureStatusFailureClearsIdentity(500), false); + assert.equal(captureStatusFailureClearsIdentity(undefined), false); +}); + +test("capture service URL selection ignores workspace values", () => { + assert.equal( + trustedCaptureServiceBaseUrl( + { + globalValue: "https://trusted.example/dev", + workspaceValue: "https://workspace.example/dev", + workspaceFolderValue: "https://folder.example/dev", + }, + "https://default.example/dev", + ), + "https://trusted.example/dev", + ); + assert.equal( + trustedCaptureServiceBaseUrl( + { workspaceValue: "https://workspace.example/dev" }, + "https://default.example/dev", + ), + "https://default.example/dev", + ); +}); + +test("capture state changes invalidate an in-flight refresh", () => { + const inFlightGeneration = 7; + const generationAfterClear = 8; + + assert.equal( + captureRefreshStillCurrentSnapshot( + inFlightGeneration, + generationAfterClear, + "token-hash", + "token-hash", + "https://trusted.example/dev", + "https://trusted.example/dev", + ), + false, + ); + assert.equal( + captureRefreshStillCurrentSnapshot( + generationAfterClear, + generationAfterClear, + undefined, + "token-hash", + "https://trusted.example/dev", + "https://trusted.example/dev", + ), + false, + ); + assert.equal( + captureRefreshStillCurrentSnapshot( + generationAfterClear, + generationAfterClear, + "token-hash", + "token-hash", + "https://other.example/dev", + "https://trusted.example/dev", + ), + false, + ); + assert.deepEqual(captureTokenClearedState(), { + tokenStatus: "missing", + participantId: "", + instanceId: "", + studyId: "", + captureEnabled: false, + lastError: "Capture token is not configured.", + }); +}); + +test("refresh churn does not invalidate a token mutation", () => { + assert.equal( + captureTokenSnapshotStillCurrent( + { mutationGeneration: 3 }, + 3, + 10, + "token-hash", + "token-hash", + "https://trusted.example/dev", + "https://trusted.example/dev", + ), + true, + ); + assert.equal( + captureTokenSnapshotStillCurrent({ mutationGeneration: 3 }, 4, 10), + false, + ); +}); + +test("new refreshes and token mutations invalidate older refreshes", () => { + assert.equal( + captureTokenSnapshotStillCurrent( + { mutationGeneration: 3, refreshGeneration: 7 }, + 3, + 8, + ), + false, + ); + assert.equal( + captureTokenSnapshotStillCurrent( + { mutationGeneration: 3, refreshGeneration: 8 }, + 4, + 8, + ), + false, + ); + assert.equal( + captureTokenSnapshotStillCurrent( + { mutationGeneration: 3, refreshGeneration: 8 }, + 3, + 8, + ), + true, + ); +}); diff --git a/extensions/VSCode/src/capture-policy.ts b/extensions/VSCode/src/capture-policy.ts new file mode 100644 index 00000000..6ab932cf --- /dev/null +++ b/extensions/VSCode/src/capture-policy.ts @@ -0,0 +1,188 @@ +// Copyright (C) 2025 Bryan A. Jones. +// +// This file is part of the CodeChat Editor. + +export type CaptureTokenPolicyStatus = + | "missing" + | "unverified" + | "accepted" + | "rejected" + | "capture_disabled" + | "service_unavailable"; + +export interface CaptureServiceBaseUrlInspection { + globalValue?: unknown; +} + +export interface CaptureTokenClearedState { + tokenStatus: "missing"; + participantId: ""; + instanceId: ""; + studyId: ""; + captureEnabled: false; + lastError: string; +} + +export interface CaptureTokenCurrentnessSnapshot { + mutationGeneration: number; + refreshGeneration?: number; +} + +const CAPTURE_SERVICE_ROUTE_SUFFIXES = [ + "/v1/capture/events", + "/v1/capture/status", + "/v1/health", +]; + +function optionalString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : undefined; +} + +export function trustedCaptureServiceBaseUrl( + inspected: CaptureServiceBaseUrlInspection | undefined, + defaultBaseUrl: string, +): string { + return optionalString(inspected?.globalValue) ?? defaultBaseUrl; +} + +function isLocalHttpCaptureService(url: URL): boolean { + const hostname = url.hostname.toLowerCase(); + return ( + url.protocol === "http:" && + (hostname === "localhost" || + hostname === "127.0.0.1" || + hostname === "::1" || + hostname === "[::1]") + ); +} + +export function normalizeCaptureServiceBaseUrl(value: string): string { + let rawUrl = value.trim().replace(/\/+$/, ""); + if (rawUrl.length === 0) { + throw new Error("capture service URL must not be empty"); + } + + for (const suffix of CAPTURE_SERVICE_ROUTE_SUFFIXES) { + if (rawUrl.endsWith(suffix)) { + rawUrl = rawUrl.slice(0, -suffix.length).replace(/\/+$/, ""); + break; + } + } + + let url: URL; + try { + url = new URL(rawUrl); + } catch { + throw new Error("capture service URL must be an absolute URL"); + } + + if (url.username.length > 0 || url.password.length > 0) { + throw new Error("capture service URL must not include credentials"); + } + if (url.protocol !== "https:" && !isLocalHttpCaptureService(url)) { + throw new Error( + "capture service URL must use https:// except for localhost", + ); + } + + url.hash = ""; + url.search = ""; + url.pathname = url.pathname.replace(/\/+$/, ""); + return url.toString().replace(/\/+$/, ""); +} + +export function captureTokenCanRecord( + participantId: string, + captureEnabled: boolean | undefined, + tokenStatus: CaptureTokenPolicyStatus, +): boolean { + return ( + participantId.length > 0 && + captureEnabled === true && + (tokenStatus === "accepted" || tokenStatus === "service_unavailable") + ); +} + +export function captureTokenStatusForStatusFailure( + statusCode: number | undefined, +): CaptureTokenPolicyStatus { + switch (statusCode) { + case 401: + return "rejected"; + case 403: + return "capture_disabled"; + default: + return "service_unavailable"; + } +} + +export function captureStatusFailureClearsIdentity( + statusCode: number | undefined, +): boolean { + return statusCode === 401 || statusCode === 403; +} + +export function captureRefreshStillCurrentSnapshot( + refreshGeneration: number, + currentGeneration: number, + storedTokenHash?: string, + expectedTokenHash?: string, + currentBaseUrl?: string, + expectedBaseUrl?: string, +): boolean { + return captureTokenSnapshotStillCurrent( + { + mutationGeneration: refreshGeneration, + refreshGeneration, + }, + currentGeneration, + currentGeneration, + storedTokenHash, + expectedTokenHash, + currentBaseUrl, + expectedBaseUrl, + ); +} + +export function captureTokenSnapshotStillCurrent( + snapshot: CaptureTokenCurrentnessSnapshot, + currentMutationGeneration: number, + currentRefreshGeneration: number, + storedTokenHash?: string, + expectedTokenHash?: string, + currentBaseUrl?: string, + expectedBaseUrl?: string, +): boolean { + if (snapshot.mutationGeneration !== currentMutationGeneration) { + return false; + } + if ( + snapshot.refreshGeneration !== undefined && + snapshot.refreshGeneration !== currentRefreshGeneration + ) { + return false; + } + if ( + expectedTokenHash !== undefined && + storedTokenHash !== expectedTokenHash + ) { + return false; + } + if (expectedBaseUrl !== undefined && currentBaseUrl !== expectedBaseUrl) { + return false; + } + return true; +} + +export function captureTokenClearedState(): CaptureTokenClearedState { + return { + tokenStatus: "missing", + participantId: "", + instanceId: "", + studyId: "", + captureEnabled: false, + lastError: "Capture token is not configured.", + }; +} diff --git a/extensions/VSCode/src/extension.ts b/extensions/VSCode/src/extension.ts index ba9630b2..ac27c332 100644 --- a/extensions/VSCode/src/extension.ts +++ b/extensions/VSCode/src/extension.ts @@ -56,8 +56,21 @@ import { console_log, } from "../../../client/src/debug_enabled.mjs"; import { ResultErrTypes } from "../../../client/src/rust-types/ResultErrTypes.js"; +import { + captureStatusFailureClearsIdentity, + captureTokenCanRecord, + captureTokenClearedState, + CaptureTokenCurrentnessSnapshot, + CaptureTokenPolicyStatus, + captureTokenSnapshotStillCurrent, + captureTokenStatusForStatusFailure, + normalizeCaptureServiceBaseUrl, + trustedCaptureServiceBaseUrl, +} from "./capture-policy.js"; import * as crypto from "crypto"; +import * as http from "http"; +import * as https from "https"; // Globals // ------- @@ -217,29 +230,75 @@ type CaptureEventData = Record; type CaptureEventType = CaptureEventWire["event_type"]; // Student-facing capture settings. The setup is intentionally small: students -// give consent, toggle capture, and receive or reuse a pseudonymous participant -// UUID. Assignment, course, group, and study-condition metadata are inferred -// during analysis from that participant ID and event timestamps. +// give local consent, toggle recording, and paste a portal-issued capture +// token. The participant UUID comes from CaptureWebService token status. interface StudySettings { // True when the student wants capture enabled for the current work session. enabled: boolean; // True after the student has consented to study capture. consentEnabled: boolean; - // Pseudonymous UUID used as the event user ID; generated when absent. + // Pseudonymous UUID returned by CaptureWebService token status. + participantId: string; + // True only after CaptureWebService accepts the bearer token and capture is + // allowed for that participant/instance. + tokenAccepted: boolean; + // Non-secret token status used for user-facing capture feedback. + tokenStatus: CaptureTokenUiStatus; +} + +type CaptureTokenUiStatus = CaptureTokenPolicyStatus; + +interface CaptureServiceStatusResponse { + participant_id: string; + instance_id: string; + study_id: string; + capture_enabled: boolean; + participant_status: string; + consent_status: string; + instance_status: string; + token_expires_at?: string | null; + server_time: string; + service_version: string; +} + +interface CaptureTokenRuntimeState { + tokenStatus: CaptureTokenUiStatus; participantId: string; + instanceId: string; + studyId: string; + captureEnabled?: boolean; + participantStatus?: string; + consentStatus?: string; + instanceStatus?: string; + tokenExpiresAt?: string | null; + serviceVersion?: string; + lastStatusCheckAt?: string; + lastError?: string; } -// Derived state for the two user-visible capture checkboxes. This mirrors the -// table shown in Settings and is the single source of truth for whether events -// may be recorded. +// Derived state for local consent, session recording, and remote token status. +// This is the single source of truth for whether events may be recorded. type CaptureSettingsState = | "off" | "paused" | "recording" - | "waitingForConsent"; + | "waitingForConsent" + | "waitingForToken" + | "captureDisabled"; const CAPTURE_SCHEMA_VERSION = 2; const CAPTURE_EVENT_SOURCE = "vscode_extension"; +const CAPTURE_SERVICE_DEFAULT_BASE_URL = + "https://9m2nbv2rvc.execute-api.us-east-2.amazonaws.com/dev"; +const CAPTURE_TOKEN_SECRET_KEY = "CodeChatEditor.Capture.Token"; +const CAPTURE_PARTICIPANT_GLOBAL_KEY = + "CodeChatEditor.Capture.ServiceParticipantId"; +const CAPTURE_INSTANCE_GLOBAL_KEY = "CodeChatEditor.Capture.InstanceId"; +const CAPTURE_STUDY_GLOBAL_KEY = "CodeChatEditor.Capture.StudyId"; +const CAPTURE_ENABLED_GLOBAL_KEY = "CodeChatEditor.Capture.CaptureEnabled"; +const CAPTURE_TOKEN_HASH_GLOBAL_KEY = "CodeChatEditor.Capture.TokenHash"; +const CAPTURE_SERVICE_BASE_GLOBAL_KEY = + "CodeChatEditor.Capture.IdentityServiceBaseUrl"; // Audit label for the user-facing recording toggle. This is intentionally not // a persisted setting; recording is scoped to the current VS Code window. const CAPTURE_RECORD_AUDIT_LABEL = "RecordStudyEvents"; @@ -252,6 +311,14 @@ const DEFAULT_REFLECTION_PROMPTS = [ // Output channel used for capture diagnostics that should not interrupt normal // editor use. let capture_output_channel: vscode.OutputChannel | undefined; +// Extension context provides SecretStorage and global non-secret token metadata. +let extension_context: vscode.ExtensionContext | undefined; +let captureTokenRuntimeState: CaptureTokenRuntimeState = { + tokenStatus: "missing", + participantId: "", + instanceId: "", + studyId: "", +}; // True after the first failed send is logged to the console, suppressing repeat // console warnings while still writing detailed failures to the output channel. let captureFailureLogged = false; @@ -261,7 +328,7 @@ let captureTransportReady = false; // True after a capture-enabled extension session has emitted `session_start`. let extensionCaptureSessionStarted = false; // Recording is intentionally scoped to this VS Code extension host session. -// Consent and participant ID persist in settings, but recording must be +// Consent persists in settings, but recording must be // re-enabled after VS Code restarts and can be toggled independently in each // open VS Code window. let sessionRecordStudyEvents = false; @@ -276,6 +343,14 @@ let capture_status_timer: NodeJS.Timeout | undefined; // without double-logging when a command and VS Code's configuration event both // observe the same transition. let lastCaptureSettings: StudySettings | undefined; +// Token mutations are authoritative. They cancel refresh/status work, but +// refreshes must never cancel an enter or clear mutation. +let captureTokenMutationGeneration = 0; +let captureTokenRefreshGeneration = 0; +// Serialize token work so SecretStorage, persisted identity, and runtime state +// writes happen in a predictable order. Clear still invalidates immediately so +// it can stop Rust uploads ASAP. +let captureTokenOperationQueue: Promise = Promise.resolve(); // Simple classification of what the user is currently doing. `doc` means // prose/documentation activity, whether in a Markdown/RST document or a @@ -306,20 +381,68 @@ function optionalString(value: unknown): string | undefined { : undefined; } +function captureServiceBaseUrl(): string { + const inspected = vscode.workspace + .getConfiguration("CodeChatEditor.Capture") + .inspect("ServiceBaseUrl"); + // This endpoint receives bearer tokens. Honor only application/user-level + // settings so a repository cannot redirect a user's stored token. + return trustedCaptureServiceBaseUrl( + inspected, + CAPTURE_SERVICE_DEFAULT_BASE_URL, + ); +} + +function captureTokenStatusLabel(status: CaptureTokenUiStatus): string { + switch (status) { + case "missing": + return "Missing"; + case "unverified": + return "Stored, not verified"; + case "accepted": + return "Accepted"; + case "rejected": + return "Rejected"; + case "capture_disabled": + return "Capture disabled"; + case "service_unavailable": + return "Service unavailable"; + } +} + function loadStudySettings(): StudySettings { const config = vscode.workspace.getConfiguration("CodeChatEditor.Capture"); + const participantId = captureTokenRuntimeState.participantId; + const tokenAccepted = captureTokenCanRecord( + participantId, + captureTokenRuntimeState.captureEnabled, + captureTokenRuntimeState.tokenStatus, + ); return { // Recording is session-local so capture starts paused in every VS Code - // window/restart. Consent and participant ID remain persisted settings. + // window/restart. Consent persists in settings; token identity comes + // from CaptureWebService status. enabled: sessionRecordStudyEvents, consentEnabled: config.get("ConsentEnabled", false), - participantId: optionalString(config.get("ParticipantId")) ?? "", + participantId, + tokenAccepted, + tokenStatus: captureTokenRuntimeState.tokenStatus, }; } // Convert raw settings into the explicit four-row state table. Keeping this as // a separate helper prevents callers from inventing their own partial rules. function captureSettingsState(settings: StudySettings): CaptureSettingsState { + if ( + settings.tokenStatus === "capture_disabled" && + settings.consentEnabled && + settings.enabled + ) { + return "captureDisabled"; + } + if (settings.consentEnabled && settings.enabled && !settings.tokenAccepted) { + return "waitingForToken"; + } if (settings.consentEnabled && settings.enabled) { return "recording"; } @@ -338,7 +461,9 @@ function captureSettingsEqual(a: StudySettings, b: StudySettings): boolean { return ( a.enabled === b.enabled && a.consentEnabled === b.consentEnabled && - a.participantId === b.participantId + a.participantId === b.participantId && + a.tokenAccepted === b.tokenAccepted && + a.tokenStatus === b.tokenStatus ); } @@ -351,6 +476,10 @@ function captureStateDescription(state: CaptureSettingsState): string { return "Consent is retained, but recording is paused."; case "waitingForConsent": return "Capture waits for consent before recording."; + case "waitingForToken": + return "Capture waits for a valid portal token before recording."; + case "captureDisabled": + return "Capture is disabled by the portal or capture service."; case "off": return "Capture is off."; } @@ -375,6 +504,12 @@ function captureSettingsStatus(settings: StudySettings): { case "waitingForConsent": label = "Capture: Waiting for consent"; break; + case "waitingForToken": + label = "Capture: Waiting for token"; + break; + case "captureDisabled": + label = "Capture: Disabled by portal"; + break; case "off": label = "Capture: Off"; break; @@ -386,8 +521,20 @@ function captureSettingsStatus(settings: StudySettings): { tooltip: [ `Consent Enabled: ${settings.consentEnabled ? "On" : "Off"}`, `Record Study Events: ${settings.enabled ? "On" : "Off"}`, + `Token: ${captureTokenStatusLabel(settings.tokenStatus)}`, + settings.participantId + ? `Participant ID: ${settings.participantId}` + : "Participant ID: unavailable", + captureTokenRuntimeState.instanceId + ? `Instance ID: ${captureTokenRuntimeState.instanceId}` + : "", + captureTokenRuntimeState.lastError + ? `Capture Service: ${captureTokenRuntimeState.lastError}` + : "", `State: ${captureStateDescription(state)}`, - ].join("\n"), + ] + .filter((line) => line.length > 0) + .join("\n"), }; } @@ -409,20 +556,100 @@ async function updateCaptureSetting( await config.update(name, value, vscode.ConfigurationTarget.Global); } -async function ensureParticipantId(): Promise { - const config = vscode.workspace.getConfiguration("CodeChatEditor.Capture"); - const existing = optionalString(config.get("ParticipantId")); - if (existing !== undefined) { - return existing; +function getExtensionContext(): vscode.ExtensionContext { + if (extension_context === undefined) { + throw new Error("CodeChat extension context is not initialized."); + } + return extension_context; +} + +async function getStoredCaptureToken(): Promise { + return optionalString( + await getExtensionContext().secrets.get(CAPTURE_TOKEN_SECRET_KEY), + ); +} + +async function storeCaptureToken(token: string): Promise { + await getExtensionContext().secrets.store(CAPTURE_TOKEN_SECRET_KEY, token); +} + +async function deleteStoredCaptureToken(): Promise { + await getExtensionContext().secrets.delete(CAPTURE_TOKEN_SECRET_KEY); +} + +function loadPersistedCaptureIdentity(context: vscode.ExtensionContext): void { + const storedBaseUrl = optionalString( + context.globalState.get(CAPTURE_SERVICE_BASE_GLOBAL_KEY), + ); + let currentBaseUrl: string | undefined; + try { + currentBaseUrl = normalizeCaptureServiceBaseUrl(captureServiceBaseUrl()); + } catch { + currentBaseUrl = undefined; + } + if (storedBaseUrl === undefined || storedBaseUrl !== currentBaseUrl) { + captureTokenRuntimeState = { + ...captureTokenRuntimeState, + participantId: "", + instanceId: "", + studyId: "", + captureEnabled: false, + }; + return; } + const captureEnabled = context.globalState.get( + CAPTURE_ENABLED_GLOBAL_KEY, + ); + captureTokenRuntimeState = { + ...captureTokenRuntimeState, + participantId: + optionalString(context.globalState.get(CAPTURE_PARTICIPANT_GLOBAL_KEY)) ?? + "", + instanceId: + optionalString(context.globalState.get(CAPTURE_INSTANCE_GLOBAL_KEY)) ?? + "", + studyId: + optionalString(context.globalState.get(CAPTURE_STUDY_GLOBAL_KEY)) ?? + "", + captureEnabled: + typeof captureEnabled === "boolean" ? captureEnabled : undefined, + }; +} - const generated = crypto.randomUUID(); - await config.update( - "ParticipantId", - generated, - vscode.ConfigurationTarget.Global, +async function persistCaptureIdentity( + status: CaptureServiceStatusResponse, + tokenHash: string, + serviceBaseUrl: string, +): Promise { + const context = getExtensionContext(); + await context.globalState.update( + CAPTURE_PARTICIPANT_GLOBAL_KEY, + status.participant_id, + ); + await context.globalState.update( + CAPTURE_INSTANCE_GLOBAL_KEY, + status.instance_id, + ); + await context.globalState.update(CAPTURE_STUDY_GLOBAL_KEY, status.study_id); + await context.globalState.update( + CAPTURE_ENABLED_GLOBAL_KEY, + status.capture_enabled, ); - return generated; + await context.globalState.update(CAPTURE_TOKEN_HASH_GLOBAL_KEY, tokenHash); + await context.globalState.update( + CAPTURE_SERVICE_BASE_GLOBAL_KEY, + serviceBaseUrl, + ); +} + +async function clearPersistedCaptureIdentity(): Promise { + const context = getExtensionContext(); + await context.globalState.update(CAPTURE_PARTICIPANT_GLOBAL_KEY, undefined); + await context.globalState.update(CAPTURE_INSTANCE_GLOBAL_KEY, undefined); + await context.globalState.update(CAPTURE_STUDY_GLOBAL_KEY, undefined); + await context.globalState.update(CAPTURE_ENABLED_GLOBAL_KEY, undefined); + await context.globalState.update(CAPTURE_TOKEN_HASH_GLOBAL_KEY, undefined); + await context.globalState.update(CAPTURE_SERVICE_BASE_GLOBAL_KEY, undefined); } function hashText(value: string): string { @@ -472,13 +699,23 @@ function capturePayloadSummary(payload: CaptureEventWire): string { function captureStatusSummary(status: CaptureStatus): string { return [ `state=${status.state}`, + `token=${status.token_status}`, `enabled=${status.enabled}`, `queued=${status.queued_events}`, - `db=${status.persisted_events}`, - `fallback=${status.fallback_events}`, + `spooled=${status.spooled_events}`, + `uploaded=${status.uploaded_events}`, + `quarantined=${status.quarantined_events}`, `failed=${status.failed_events}`, + status.participant_id ? `participant_id=${status.participant_id}` : "", + status.instance_id ? `instance_id=${status.instance_id}` : "", + status.capture_enabled !== null + ? `capture_enabled=${status.capture_enabled}` + : "", + status.service_version + ? `service_version=${status.service_version}` + : "", status.last_error ? `last_error=${status.last_error}` : "", - status.fallback_path ? `fallback_path=${status.fallback_path}` : "", + status.spool_path ? `spool_path=${status.spool_path}` : "", ] .filter((part) => part.length > 0) .join(" "); @@ -486,12 +723,17 @@ function captureStatusSummary(status: CaptureStatus): string { type CaptureStatusJson = Omit< CaptureStatus, - "queued_events" | "persisted_events" | "fallback_events" | "failed_events" + | "queued_events" + | "spooled_events" + | "uploaded_events" + | "failed_events" + | "quarantined_events" > & { queued_events: number; - persisted_events: number; - fallback_events: number; + spooled_events: number; + uploaded_events: number; failed_events: number; + quarantined_events: number; }; function parseCaptureStatus(json: string): CaptureStatus { @@ -502,17 +744,432 @@ function parseCaptureStatus(json: string): CaptureStatus { return { ...status, queued_events: BigInt(status.queued_events), - persisted_events: BigInt(status.persisted_events), - fallback_events: BigInt(status.fallback_events), + spooled_events: BigInt(status.spooled_events), + uploaded_events: BigInt(status.uploaded_events), failed_events: BigInt(status.failed_events), + quarantined_events: BigInt(status.quarantined_events), + }; +} + +function captureStatusUrl(serviceBaseUrl: string): string { + return `${serviceBaseUrl}/v1/capture/status`; +} + +function leadingHttpStatusCode(message: string): number | undefined { + const match = /^(\d{3})(?:\s|$)/.exec(message); + return match === null ? undefined : Number.parseInt(match[1], 10); +} + +function requestCaptureStatus( + token: string, + serviceBaseUrl: string, +): Promise { + const url = new URL(captureStatusUrl(serviceBaseUrl)); + const client = url.protocol === "http:" ? http : https; + + return new Promise((resolve, reject) => { + const req = client.request( + url, + { + method: "GET", + headers: { + Accept: "application/json", + Authorization: `Bearer ${token}`, + }, + }, + (res) => { + const chunks: Buffer[] = []; + res.on("data", (chunk: Buffer) => chunks.push(chunk)); + res.on("end", () => { + const body = Buffer.concat(chunks).toString("utf8"); + const statusCode = res.statusCode ?? 0; + if (statusCode < 200 || statusCode >= 300) { + reject( + new Error( + `${statusCode} ${ + body || res.statusMessage || "request failed" + }`, + ), + ); + return; + } + try { + resolve(JSON.parse(body) as CaptureServiceStatusResponse); + } catch (err) { + reject( + new Error( + `Unable to parse capture status response: ${ + err instanceof Error + ? err.message + : String(err) + }`, + ), + ); + } + }); + }, + ); + req.setTimeout(10000, () => { + req.destroy(new Error("capture status request timed out")); + }); + req.on("error", reject); + req.end(); + }); +} + +async function applyCaptureServiceStatus( + status: CaptureServiceStatusResponse, + tokenHash: string, + serviceBaseUrl: string, +): Promise { + captureTokenRuntimeState = { + tokenStatus: status.capture_enabled ? "accepted" : "capture_disabled", + participantId: status.participant_id, + instanceId: status.instance_id, + studyId: status.study_id, + captureEnabled: status.capture_enabled, + participantStatus: status.participant_status, + consentStatus: status.consent_status, + instanceStatus: status.instance_status, + tokenExpiresAt: status.token_expires_at, + serviceVersion: status.service_version, + lastStatusCheckAt: new Date().toISOString(), + lastError: status.capture_enabled + ? undefined + : "Capture is disabled by the portal or capture service.", + }; + await persistCaptureIdentity(status, tokenHash, serviceBaseUrl); +} + +async function configureRustCaptureService( + token: string | undefined, + serviceBaseUrl?: string, +): Promise { + if (codeChatEditorServer === undefined) { + return true; + } + try { + if (token === undefined) { + codeChatEditorServer.clearCaptureToken(); + return true; + } + if (serviceBaseUrl === undefined) { + throw new Error("Capture service URL is not configured."); + } + codeChatEditorServer.configureCaptureService( + serviceBaseUrl, + token, + ); + return true; + } catch (err) { + reportCaptureFailure( + `capture service configuration failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + return false; + } +} + +type CaptureServiceConfigurationResult = "configured" | "stale" | "failed"; + +function beginCaptureTokenMutation(): CaptureTokenCurrentnessSnapshot { + captureTokenMutationGeneration += 1; + captureTokenRefreshGeneration += 1; + return { + mutationGeneration: captureTokenMutationGeneration, + }; +} + +function beginCaptureTokenRefresh(): CaptureTokenCurrentnessSnapshot { + captureTokenRefreshGeneration += 1; + return { + mutationGeneration: captureTokenMutationGeneration, + refreshGeneration: captureTokenRefreshGeneration, + }; +} + +function captureTokenOperationSnapshotIsCurrent( + snapshot: CaptureTokenCurrentnessSnapshot, +): boolean { + return captureTokenSnapshotStillCurrent( + snapshot, + captureTokenMutationGeneration, + captureTokenRefreshGeneration, + ); +} + +function enqueueCaptureTokenOperation( + operation: () => Promise, +): Promise { + const task = captureTokenOperationQueue + .catch(() => undefined) + .then(operation); + captureTokenOperationQueue = task.catch(() => undefined); + return task; +} + +async function captureTokenOperationStillCurrent( + snapshot: CaptureTokenCurrentnessSnapshot, + expectedTokenHash?: string, + expectedBaseUrl?: string, +): Promise { + if (!captureTokenOperationSnapshotIsCurrent(snapshot)) { + return false; + } + let storedTokenHash: string | undefined; + if (expectedTokenHash !== undefined) { + const token = await getStoredCaptureToken(); + storedTokenHash = token === undefined ? undefined : hashText(token); + } + let currentBaseUrl: string | undefined; + if (expectedBaseUrl !== undefined) { + try { + currentBaseUrl = normalizeCaptureServiceBaseUrl(captureServiceBaseUrl()); + } catch { + currentBaseUrl = undefined; + } + } + return captureTokenSnapshotStillCurrent( + snapshot, + captureTokenMutationGeneration, + captureTokenRefreshGeneration, + storedTokenHash, + expectedTokenHash, + currentBaseUrl, + expectedBaseUrl, + ); +} + +async function configureRustCaptureServiceIfCurrent( + snapshot: CaptureTokenCurrentnessSnapshot, + token: string | undefined, + serviceBaseUrl?: string, + expectedTokenHash?: string, + expectedBaseUrl?: string, +): Promise { + if ( + !(await captureTokenOperationStillCurrent( + snapshot, + expectedTokenHash, + expectedBaseUrl, + )) + ) { + return "stale"; + } + if (!captureTokenOperationSnapshotIsCurrent(snapshot)) { + return "stale"; + } + return (await configureRustCaptureService(token, serviceBaseUrl)) + ? "configured" + : "failed"; +} + +async function refreshCaptureTokenState( + notify: boolean = false, +): Promise { + const operationSnapshot = beginCaptureTokenRefresh(); + await enqueueCaptureTokenOperation(() => + refreshCaptureTokenStateInner(operationSnapshot, notify), + ); +} + +async function refreshCaptureTokenStateInner( + operationSnapshot: CaptureTokenCurrentnessSnapshot, + notify: boolean, +): Promise { + const token = await getStoredCaptureToken(); + if (!(await captureTokenOperationStillCurrent(operationSnapshot))) { + return; + } + + if (token === undefined) { + const configurationResult = await configureRustCaptureServiceIfCurrent( + operationSnapshot, + undefined, + ); + if (configurationResult === "stale") { + return; + } + captureTokenRuntimeState = captureTokenClearedState(); + await clearPersistedCaptureIdentity(); + await refreshCaptureStatus(); + return; + } + + let expectedBaseUrl: string; + try { + expectedBaseUrl = normalizeCaptureServiceBaseUrl(captureServiceBaseUrl()); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const configurationResult = await configureRustCaptureServiceIfCurrent( + operationSnapshot, + undefined, + ); + if (configurationResult === "stale") { + return; + } + captureTokenRuntimeState = { + ...captureTokenRuntimeState, + tokenStatus: "service_unavailable", + captureEnabled: false, + lastError: message, + }; + captureLog(`capture token status failed: ${message}`); + if (notify) { + vscode.window.showWarningMessage( + `CodeChat capture token could not be verified: ${message}`, + ); + } + await refreshCaptureStatus(); + return; + } + + const expectedTokenHash = hashText(token); + const configurationResult = await configureRustCaptureServiceIfCurrent( + operationSnapshot, + token, + expectedBaseUrl, + expectedTokenHash, + expectedBaseUrl, + ); + if (configurationResult === "stale") { + return; + } + if (configurationResult === "failed") { + captureTokenRuntimeState = { + ...captureTokenRuntimeState, + tokenStatus: "service_unavailable", + captureEnabled: false, + lastError: "Capture service configuration failed.", + }; + await refreshCaptureStatus(); + return; + } + + const context = getExtensionContext(); + const storedTokenHash = optionalString( + context.globalState.get(CAPTURE_TOKEN_HASH_GLOBAL_KEY), + ); + const storedBaseUrl = optionalString( + context.globalState.get(CAPTURE_SERVICE_BASE_GLOBAL_KEY), + ); + if ( + !(await captureTokenOperationStillCurrent( + operationSnapshot, + expectedTokenHash, + expectedBaseUrl, + )) + ) { + return; + } + if ( + storedTokenHash !== expectedTokenHash || + storedBaseUrl !== expectedBaseUrl + ) { + await clearPersistedCaptureIdentity(); + if ( + !(await captureTokenOperationStillCurrent( + operationSnapshot, + expectedTokenHash, + expectedBaseUrl, + )) + ) { + return; + } + captureTokenRuntimeState = { + ...captureTokenRuntimeState, + participantId: "", + instanceId: "", + studyId: "", + captureEnabled: false, + }; + } + + captureTokenRuntimeState = { + ...captureTokenRuntimeState, + tokenStatus: "unverified", + lastError: undefined, }; + + try { + const status = await requestCaptureStatus(token, expectedBaseUrl); + if ( + !(await captureTokenOperationStillCurrent( + operationSnapshot, + expectedTokenHash, + expectedBaseUrl, + )) + ) { + return; + } + await applyCaptureServiceStatus(status, expectedTokenHash, expectedBaseUrl); + captureLog( + `capture token status: ${captureTokenStatusLabel( + captureTokenRuntimeState.tokenStatus, + )} participant_id=${status.participant_id} instance_id=${status.instance_id}`, + ); + if (notify) { + vscode.window.showInformationMessage( + status.capture_enabled + ? "CodeChat capture token accepted." + : "CodeChat capture is disabled by the portal.", + ); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const statusCode = leadingHttpStatusCode(message); + if ( + !(await captureTokenOperationStillCurrent( + operationSnapshot, + expectedTokenHash, + expectedBaseUrl, + )) + ) { + return; + } + const clearIdentity = captureStatusFailureClearsIdentity(statusCode); + if (clearIdentity) { + await clearPersistedCaptureIdentity(); + if ( + !(await captureTokenOperationStillCurrent( + operationSnapshot, + expectedTokenHash, + expectedBaseUrl, + )) + ) { + return; + } + } + const tokenStatus = captureTokenStatusForStatusFailure(statusCode); + captureTokenRuntimeState = { + ...(clearIdentity + ? { + participantId: "", + instanceId: "", + studyId: "", + captureEnabled: false, + } + : captureTokenRuntimeState), + tokenStatus, + lastError: message, + }; + captureLog(`capture token status failed: ${message}`); + if (notify) { + vscode.window.showWarningMessage( + `CodeChat capture token could not be verified: ${message}`, + ); + } + } + + await refreshCaptureStatus(); } interface CaptureSendOptions { // Permit audit/control events even when normal capture is paused or waiting // for consent. ignoreCaptureSettings?: boolean; - // Update server-side capture state without inserting this event into the DB. + // Update server-side capture state without spooling this event for upload. controlOnly?: boolean; // Explicit active flag carried to the server so it can enable/disable // translation-generated write events. @@ -544,7 +1201,15 @@ async function sendCaptureEvent( ? options.userId : options.controlOnly ? settings.participantId || "capture_control" - : await ensureParticipantId(); + : settings.participantId; + if (!options.controlOnly && participantId.length === 0) { + captureLog(`capture skipped: ${eventType} (no verified capture token)`); + updateCaptureStatusBar( + "Capture: Waiting for token", + "Enter and verify a portal-issued capture token before recording.", + ); + return; + } const fileFields = buildFileFields(filePath); // The server uses `capture_active` to decide whether it may generate // classified write_doc/write_code rows from translated edits. @@ -628,7 +1293,7 @@ async function refreshCaptureStatus(): Promise { const settings = loadStudySettings(); const settingsStatus = captureSettingsStatus(settings); // When the settings are not in the recording row, the settings state is the - // authoritative status regardless of the server's DB/fallback state. + // authoritative status regardless of the server upload worker state. if (settingsStatus.state !== "recording") { updateCaptureStatusBar(settingsStatus.label, settingsStatus.tooltip); return; @@ -647,15 +1312,27 @@ async function refreshCaptureStatus(): Promise { ); let label: string; switch (status.state) { - case "database": - label = "Capture: DB"; + case "remote": + label = "Capture: Remote"; break; - case "fallback": - label = "Capture: Fallback"; + case "spooling": + label = "Capture: Queued"; + break; + case "uploading": + label = "Capture: Uploading"; break; case "starting": label = "Capture: Starting"; break; + case "auth_failed": + label = "Capture: Token rejected"; + break; + case "capture_disabled": + label = "Capture: Disabled by portal"; + break; + case "service_unavailable": + label = "Capture: Service unavailable"; + break; default: label = "Capture: Off"; break; @@ -705,7 +1382,7 @@ async function setRecordStudyEvents(enabled: boolean): Promise { ); } else if (enabled) { vscode.window.showInformationMessage( - "CodeChat capture is waiting for consent.", + captureStateDescription(captureSettingsState(updatedSettings)), ); } else { vscode.window.showInformationMessage( @@ -719,11 +1396,6 @@ async function setCaptureConsent(enabled: boolean): Promise { // consent transitions, including consent being turned off. const previousSettings = loadStudySettings(); - // Consent-on creates the pseudonymous participant ID up front, so the audit - // event and later study events use the same stable identifier. - if (enabled) { - await ensureParticipantId(); - } await updateCaptureSetting("ConsentEnabled", enabled); await reconcileCaptureSettings( "manage_capture_consent_enabled", @@ -751,7 +1423,6 @@ async function giveConsentAndRecordStudyEvents(): Promise { // then lets the common reconcile path emit one combined audit event. const previousSettings = loadStudySettings(); - await ensureParticipantId(); await updateCaptureSetting("ConsentEnabled", true); sessionRecordStudyEvents = true; await reconcileCaptureSettings( @@ -787,9 +1458,6 @@ async function sendCaptureSettingsChangedEvent( // turning consent off can still be attributed to the participant who opted // out. let participantId = current.participantId || previous.participantId; - if (current.consentEnabled && participantId.length === 0) { - participantId = await ensureParticipantId(); - } if (participantId.length === 0) { captureLog( `capture settings change skipped: ${changedSettings.join(",")} (no participant id)`, @@ -836,7 +1504,7 @@ async function reconcileCaptureSettings( const previous = lastCaptureSettings ?? previousSettings; // Commands update settings and VS Code then fires a configuration event. - // This guard keeps the DB audit trail to one row per actual transition. + // This guard keeps the capture audit trail to one row per actual transition. if ( lastCaptureSettings !== undefined && captureSettingsEqual(lastCaptureSettings, settings) @@ -884,13 +1552,97 @@ async function reconcileCaptureSettings( } async function copyParticipantId(): Promise { - const participantId = await ensureParticipantId(); + const participantId = captureTokenRuntimeState.participantId; + if (participantId.length === 0) { + vscode.window.showWarningMessage( + "Enter and verify a capture token before copying a participant ID.", + ); + return; + } await vscode.env.clipboard.writeText(participantId); vscode.window.showInformationMessage( "CodeChat capture participant ID copied.", ); } +async function enterCaptureToken(): Promise { + const previousSettings = loadStudySettings(); + const token = await vscode.window.showInputBox({ + title: "Enter CodeChat Capture Token", + prompt: "Paste the portal-issued capture token.", + password: true, + ignoreFocusOut: true, + validateInput: (value) => + value.trim().length === 0 ? "Capture token is required." : null, + }); + if (token === undefined) { + return; + } + const operationSnapshot = beginCaptureTokenMutation(); + const tokenText = token.trim(); + let mutationApplied = false; + await enqueueCaptureTokenOperation(async () => { + if (!(await captureTokenOperationStillCurrent(operationSnapshot))) { + return; + } + await storeCaptureToken(tokenText); + if (!(await captureTokenOperationStillCurrent(operationSnapshot))) { + return; + } + captureTokenRuntimeState = { + ...captureTokenRuntimeState, + tokenStatus: "unverified", + lastError: undefined, + }; + mutationApplied = true; + }); + if ( + mutationApplied && + captureTokenOperationSnapshotIsCurrent(operationSnapshot) + ) { + await refreshCaptureTokenState(true); + } + await reconcileCaptureSettings( + "manage_capture_enter_token", + previousSettings, + ); +} + +async function clearCaptureToken(): Promise { + const operationSnapshot = beginCaptureTokenMutation(); + // Reserve clear's queue position before awaiting Rust reconfiguration so + // later refreshes cannot observe the pre-clear token as current state. + const clearTask = enqueueCaptureTokenOperation(async () => { + if (!(await captureTokenOperationStillCurrent(operationSnapshot))) { + return; + } + await deleteStoredCaptureToken(); + if (!(await captureTokenOperationStillCurrent(operationSnapshot))) { + return; + } + await clearPersistedCaptureIdentity(); + if (!(await captureTokenOperationStillCurrent(operationSnapshot))) { + return; + } + captureTokenRuntimeState = captureTokenClearedState(); + await reconcileCaptureSettings("manage_capture_clear_token"); + vscode.window.showInformationMessage("CodeChat capture token cleared."); + }); + await Promise.all([ + clearTask, + configureRustCaptureServiceIfCurrent(operationSnapshot, undefined), + ]); +} + +async function validateCaptureToken(): Promise { + const previousSettings = loadStudySettings(); + await refreshCaptureTokenState(true); + await reconcileCaptureSettings( + "manage_capture_validate_token", + previousSettings, + ); +} + async function showCaptureStatus(): Promise { await refreshCaptureStatus(); const settings = loadStudySettings(); @@ -917,6 +1669,20 @@ async function showCaptureStatus(): Promise { }); } + actions.push({ + label: "Enter Capture Token", + description: `Token: ${captureTokenStatusLabel(settings.tokenStatus)}`, + run: enterCaptureToken, + }); + + if (settings.tokenStatus !== "missing") { + actions.push({ + label: "Validate Capture Token", + description: "Check token status with CaptureWebService.", + run: validateCaptureToken, + }); + } + actions.push({ label: settings.consentEnabled ? "Turn Consent Off" : "Turn Consent On", description: settings.consentEnabled @@ -935,10 +1701,18 @@ async function showCaptureStatus(): Promise { run: () => setRecordStudyEvents(!settings.enabled), }); + if (settings.tokenStatus !== "missing") { + actions.push({ + label: "Clear Capture Token", + description: "Remove the locally stored portal token.", + run: clearCaptureToken, + }); + } + actions.push( { label: "Copy Participant ID", - description: settings.participantId || "Generate a new UUID.", + description: settings.participantId || "Verify a token first.", run: copyParticipantId, }, { @@ -1073,7 +1847,7 @@ async function sendCaptureStopControl( } // This message is sent through the normal capture channel so the server can // clear its active capture context, but `capture_control_only` prevents it - // from becoming a DB row. + // from being spooled for upload. await sendCaptureEvent( "session_end", filePath, @@ -1180,6 +1954,8 @@ function queueActivityCapture(kind: ActivityKind, filePath?: string): void { // This is invoked when the extension is activated. It either creates a new // CodeChat Editor Server instance or reveals the currently running one. export const activate = (context: vscode.ExtensionContext) => { + extension_context = context; + loadPersistedCaptureIdentity(context); lastCaptureSettings = loadStudySettings(); capture_output_channel = vscode.window.createOutputChannel("CodeChat Capture"); @@ -1204,10 +1980,12 @@ export const activate = (context: vscode.ExtensionContext) => { context.subscriptions.push( vscode.workspace.onDidChangeConfiguration(async (event) => { if (event.affectsConfiguration("CodeChatEditor.Capture")) { + await refreshCaptureTokenState(); await reconcileCaptureSettings("settings_ui"); } }), ); + refreshCaptureTokenState(); refreshCaptureStatus(); context.subscriptions.push( @@ -1215,6 +1993,18 @@ export const activate = (context: vscode.ExtensionContext) => { "extension.codeChatCaptureStatus", showCaptureStatus, ), + vscode.commands.registerCommand( + "extension.codeChatCaptureEnterToken", + enterCaptureToken, + ), + vscode.commands.registerCommand( + "extension.codeChatCaptureValidateToken", + validateCaptureToken, + ), + vscode.commands.registerCommand( + "extension.codeChatCaptureClearToken", + clearCaptureToken, + ), vscode.commands.registerCommand( "extension.codeChatInsertReflectionPrompt", insertReflectionPrompt, @@ -1548,6 +2338,7 @@ export const activate = (context: vscode.ExtensionContext) => { captureFailureLogged = false; captureTransportReady = false; extensionCaptureSessionStarted = false; + await refreshCaptureTokenState(); refreshCaptureStatus(); const hosted_in_ide = diff --git a/extensions/VSCode/src/lib.rs b/extensions/VSCode/src/lib.rs index aa8169fa..52c0246f 100644 --- a/extensions/VSCode/src/lib.rs +++ b/extensions/VSCode/src/lib.rs @@ -93,6 +93,35 @@ impl CodeChatEditorServer { .map_err(|err| Error::new(Status::GenericFailure, err.to_string())) } + #[napi] + pub fn configure_capture_service( + &self, + base_url: String, + token: Option, + ) -> Result<(), Error> { + self.0 + .configure_capture_service(base_url, token) + .map_err(|err| Error::new(Status::GenericFailure, err)) + } + + #[napi] + pub fn clear_capture_token(&self) -> Result<(), Error> { + self.0 + .clear_capture_token() + .map_err(|err| Error::new(Status::GenericFailure, err)) + } + + #[napi] + pub fn check_capture_service_status(&self) -> Result { + serde_json::to_string( + &self + .0 + .check_capture_service_status() + .map_err(|err| Error::new(Status::GenericFailure, err))?, + ) + .map_err(|err| Error::new(Status::GenericFailure, err.to_string())) + } + #[napi] pub async fn send_message_current_file(&self, url: String) -> std::io::Result { self.0.send_message_current_file(url).await diff --git a/server/Cargo.lock b/server/Cargo.lock index 7e7d8ad8..feac575f 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -191,7 +191,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "smallvec", - "socket2 0.6.4", + "socket2 0.6.5", "time", "tracing", "url", @@ -595,12 +595,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - [[package]] name = "bytes" version = "1.12.1" @@ -630,9 +624,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.66" +version = "1.2.67" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" dependencies = [ "find-msvc-tools", "jobserver", @@ -672,6 +666,7 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", + "serde", "wasm-bindgen", "windows-link", ] @@ -725,12 +720,6 @@ dependencies = [ "cc", ] -[[package]] -name = "cmov" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" - [[package]] name = "cobs" version = "0.3.0" @@ -790,12 +779,11 @@ dependencies = [ "regex", "serde", "serde_json", - "sha2 0.11.0", + "sha2", "test_utils", "thirtyfour", "thiserror", "tokio", - "tokio-postgres", "tokio-tungstenite", "tracing", "tracing-log", @@ -1049,15 +1037,6 @@ dependencies = [ "syn 2.0.118", ] -[[package]] -name = "ctutils" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" -dependencies = [ - "cmov", -] - [[package]] name = "dashmap" version = "5.5.3" @@ -1152,7 +1131,6 @@ dependencies = [ "block-buffer 0.12.1", "const-oid", "crypto-common 0.2.2", - "ctutils", ] [[package]] @@ -1296,12 +1274,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "fallible-iterator" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" - [[package]] name = "fastrand" version = "2.4.1" @@ -1519,7 +1491,7 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", "wasm-bindgen", ] @@ -1633,15 +1605,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hmac" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" -dependencies = [ - "digest 0.11.3", -] - [[package]] name = "htmd" version = "0.5.4" @@ -1695,9 +1658,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", "http 1.4.2", @@ -1705,9 +1668,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" dependencies = [ "bytes", "futures-core", @@ -1801,7 +1764,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.4", + "socket2 0.6.5", "tokio", "tower-service", "tracing", @@ -2291,7 +2254,7 @@ dependencies = [ "log-mdc", "mock_instant", "parking_lot", - "rand 0.9.4", + "rand 0.9.5", "serde", "serde-value", "serde_json", @@ -2353,16 +2316,6 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" -[[package]] -name = "md-5" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" -dependencies = [ - "cfg-if", - "digest 0.11.3", -] - [[package]] name = "memchr" version = "2.8.3" @@ -2444,13 +2397,13 @@ checksum = "659579df697b372ef9e36f02fcbb41f6d6f157dcec7db9c9618fa0f23cf0fc20" [[package]] name = "mio" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "log", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", "windows-sys 0.61.2", ] @@ -2576,15 +2529,6 @@ dependencies = [ "objc2-encode", ] -[[package]] -name = "objc2-core-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" -dependencies = [ - "bitflags", -] - [[package]] name = "objc2-encode" version = "4.1.0" @@ -2601,15 +2545,6 @@ dependencies = [ "objc2", ] -[[package]] -name = "objc2-system-configuration" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" -dependencies = [ - "objc2-core-foundation", -] - [[package]] name = "once_cell" version = "1.21.4" @@ -2663,9 +2598,9 @@ checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" [[package]] name = "oxc-browserslist" -version = "3.0.9" +version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "373741e2febe6df186995b668f295e46fd402ee12f312ea2fda8b3b6312c0885" +checksum = "50a403a3c6be65be7bc7730d07d959ec6c143e4ff8117da485df78cd5582260a" dependencies = [ "miniz_oxide 0.9.1", "postcard", @@ -3206,7 +3141,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared 0.11.3", - "rand 0.8.6", + "rand 0.8.7", ] [[package]] @@ -3319,36 +3254,6 @@ dependencies = [ "serde", ] -[[package]] -name = "postgres-protocol" -version = "0.6.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08808e3c483c46e999108051c78334f473d5adb59d78bb80a1268c7e6aa6c514" -dependencies = [ - "base64", - "byteorder", - "bytes", - "fallible-iterator", - "hmac", - "md-5", - "memchr", - "rand 0.10.2", - "sha2", - "stringprep", -] - -[[package]] -name = "postgres-types" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "851ca9db4932932d69f3ea811b1abe63087a0f740a47692619dd40d4899b68be" -dependencies = [ - "bytes", - "chrono", - "fallible-iterator", - "postgres-protocol", -] - [[package]] name = "potential_utf" version = "0.1.5" @@ -3490,7 +3395,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.4", + "socket2 0.6.5", "thiserror", "tokio", "tracing", @@ -3529,7 +3434,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.4", + "socket2 0.6.5", "tracing", "windows-sys 0.61.2", ] @@ -3563,18 +3468,18 @@ checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" [[package]] name = "rand" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ "rand_core 0.6.4", ] [[package]] name = "rand" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha", "rand_core 0.9.5", @@ -3828,9 +3733,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.41" +version = "0.23.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" dependencies = [ "aws-lc-rs", "once_cell", @@ -4208,9 +4113,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -4262,17 +4167,6 @@ dependencies = [ "regex", ] -[[package]] -name = "stringprep" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" -dependencies = [ - "unicode-bidi", - "unicode-normalization", - "unicode-properties", -] - [[package]] name = "strsim" version = "0.11.1" @@ -4551,7 +4445,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.4", + "socket2 0.6.5", "tokio-macros", "windows-sys 0.61.2", ] @@ -4567,32 +4461,6 @@ dependencies = [ "syn 2.0.118", ] -[[package]] -name = "tokio-postgres" -version = "0.7.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a528f7d280f6d5b9cd149635c8705b0dd049754bc67d81d31fa25169a93809d3" -dependencies = [ - "async-trait", - "byteorder", - "bytes", - "fallible-iterator", - "futures-channel", - "futures-util", - "log", - "parking_lot", - "percent-encoding", - "phf 0.13.1", - "pin-project-lite", - "postgres-protocol", - "postgres-types", - "rand 0.10.2", - "socket2 0.6.4", - "tokio", - "tokio-util", - "whoami", -] - [[package]] name = "tokio-rustls" version = "0.26.4" @@ -4787,7 +4655,7 @@ dependencies = [ "http 1.4.2", "httparse", "log", - "rand 0.9.4", + "rand 0.9.5", "sha1 0.10.7", "thiserror", ] @@ -4825,12 +4693,6 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" -[[package]] -name = "unicode-bidi" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" - [[package]] name = "unicode-id-start" version = "1.4.0" @@ -4849,21 +4711,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" -[[package]] -name = "unicode-normalization" -version = "0.1.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" -dependencies = [ - "tinyvec", -] - -[[package]] -name = "unicode-properties" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" - [[package]] name = "unicode-segmentation" version = "1.13.3" @@ -4935,9 +4782,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.4" +version = "1.23.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +checksum = "ea5fab0d6c3c01ae70085a09cb03d4c7a1d6314e2b3e075392783396d724ca0a" dependencies = [ "js-sys", "wasm-bindgen", @@ -5007,15 +4854,6 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasi" -version = "0.14.7+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" -dependencies = [ - "wasip2", -] - [[package]] name = "wasip2" version = "1.0.4+wasi-0.2.12" @@ -5025,15 +4863,6 @@ dependencies = [ "wit-bindgen", ] -[[package]] -name = "wasite" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" -dependencies = [ - "wasi 0.14.7+wasi-0.2.4", -] - [[package]] name = "wasm-bindgen" version = "0.2.126" @@ -5159,19 +4988,6 @@ dependencies = [ "rustls-pki-types", ] -[[package]] -name = "whoami" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" -dependencies = [ - "libc", - "libredox", - "objc2-system-configuration", - "wasite", - "web-sys", -] - [[package]] name = "winapi" version = "0.3.9" @@ -5641,9 +5457,9 @@ checksum = "b142a20ec14a91d5bc708c1dc21b080c550113d8aa77afa29635673a65dd02c5" [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zopfli" diff --git a/server/Cargo.toml b/server/Cargo.toml index 765467f7..11aa4587 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -52,7 +52,7 @@ actix-ws = "0.4" ammonia = "4.1.2" anyhow = "1.0.100" bytes = { version = "1", features = ["serde"] } -chrono = "0.4" +chrono = { version = "0.4", features = ["serde"] } clap = { version = "4", features = ["derive"] } dprint-plugin-markdown = { git = "https://github.com/bjones1/dprint-plugin-markdown.git", branch = "all-fixes", version = "0.21.0" } dunce = "1.0.5" @@ -92,7 +92,6 @@ sha2 = "0.11" test_utils = { path = "../test_utils" } thiserror = "2.0.12" tokio = { version = "1", features = ["full"] } -tokio-postgres = { version = "0.7", features = ["with-chrono-0_4"] } tracing = "0.1.44" tracing-log = "0.2.0" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/server/scripts/capture_events_schema.sql b/server/scripts/capture_events_schema.sql index c517c7a0..9326e7a7 100644 --- a/server/scripts/capture_events_schema.sql +++ b/server/scripts/capture_events_schema.sql @@ -155,28 +155,16 @@ COMMENT ON TABLE public.events IS 'CodeChat dissertation capture events. Course, group, assignment, condition, and task context are joined during analysis from participant/date mappings.'; COMMENT ON COLUMN public.events.event_id IS 'Opaque stable per-event ID for correlation and future deduplication; not used for event ordering.'; COMMENT ON COLUMN public.events.sequence_number IS 'Client-local event order within one VS Code extension session, useful for ordering and detecting gaps.'; -COMMENT ON COLUMN public.events.user_id IS 'Pseudonymous participant UUID generated or supplied by the VS Code extension.'; +COMMENT ON COLUMN public.events.user_id IS 'Pseudonymous participant UUID authorized by the portal-issued capture token.'; COMMENT ON COLUMN public.events.session_id IS 'Capture session UUID emitted by the VS Code extension.'; COMMENT ON COLUMN public.events.file_hash IS 'SHA-256 hash of the local file path; raw local paths are not stored.'; COMMENT ON COLUMN public.events."timestamp" IS 'Server receive/record timestamp in UTC.'; COMMENT ON COLUMN public.events.client_tz_offset_min IS 'Client timezone offset in minutes, used with timestamp to derive local time of day without storing location or full timezone name.'; COMMENT ON COLUMN public.events.data IS 'Event-specific JSON payload. Known telemetry metadata lives in typed columns.'; --- Least-privilege deployment guidance: --- students or classroom machines should use a dedicated writer account, not a --- database owner or administrator account. After replacing the placeholder --- password/database/user names, a database administrator can grant only the --- permissions needed for capture inserts: --- --- ```sql --- CREATE ROLE codechat_capture_writer LOGIN PASSWORD 'replace-with-secret'; --- GRANT CONNECT ON DATABASE codechat_capture TO codechat_capture_writer; --- GRANT USAGE ON SCHEMA public TO codechat_capture_writer; --- GRANT INSERT ON public.events TO codechat_capture_writer; --- GRANT USAGE ON SEQUENCE public.events_id_seq TO codechat_capture_writer; --- ``` --- --- Do not grant SELECT, UPDATE, DELETE, CREATE, or ownership privileges to the --- writer account used in `capture_config.json`. +-- CodeChat clients do not connect to PostgreSQL. Public clients submit events +-- only to CaptureWebService with a portal-issued bearer token; any database +-- writer role and credentials must remain server-side inside the web service +-- deployment. COMMIT; diff --git a/server/src/capture.rs b/server/src/capture.rs index 112929a9..f45ce8ef 100644 --- a/server/src/capture.rs +++ b/server/src/capture.rs @@ -17,63 +17,28 @@ // `capture.rs` -- Capture CodeChat Editor Events // ============================================================================ // -// This module provides an asynchronous event capture facility backed by a -// PostgreSQL database. It is designed to support the dissertation study by -// recording process-level data such as: -// -// * Frequency and timing of writing entries -// * Edits to documentation and code -// * Switches between documentation and coding activity -// * Duration of engagement with reflective writing -// * Save, compile, and run events -// -// Events are sent from the client (browser and/or VS Code extension) to the -// server as JSON. The server enqueues events into an asynchronous worker which -// performs batched inserts into the `events` table. -// -// Database schema -// ---------------------------------------------------------------------------- -// -// The canonical schema and migration DDL lives in -// `server/scripts/capture_events_schema.sql`. The important analysis columns -// are: -// -// ```sql -// event_id, sequence_number, schema_version, -// user_id, session_id, event_source, language_id, file_hash, event_type, -// timestamp, client_tz_offset_min, data -// ``` -// -// * `user_id` – pseudonymous participant UUID. Course, group, assignment, and -// study condition are intentionally joined later from researcher-managed -// participant/date mappings instead of being configured by students. -// * `event_id` – opaque stable per-event ID for correlation and future -// deduplication across capture transports or retries. -// * `sequence_number` – ordered event counter scoped by `session_id` and -// `event_source` for reconstructing event order and detecting gaps. -// * `session_id`, `schema_version` – session grouping and payload versioning -// metadata. -// * `file_hash` – privacy-preserving SHA-256 hash of the local file path. -// * `event_type` – coarse event type (see `CaptureEventType` below). -// * `timestamp` – server receive/record timestamp (in UTC). -// * `client_tz_offset_min` – browser/VS Code timezone offset used to derive -// local time-of-day without storing location or full timezone identity. -// * `data` – JSONB payload with event-specific details. +// This module provides a durable local FIFO spool and an HTTPS upload worker for +// CaptureWebService. CodeChat never stores PostgreSQL credentials and never +// connects directly to the capture database. The VS Code extension stores the +// portal-issued bearer token in SecretStorage, then passes it to this worker in +// memory so pending capture events can upload through the public web service. // Imports // ------- // // ### Standard library use std::{ - env, - error::Error, - fs::{self, OpenOptions}, + fs::{self, File}, io::{self, Write}, path::{Path, PathBuf}, process, sync::atomic::{AtomicU64, Ordering}, - sync::{Arc, Mutex}, + sync::{ + Arc, Mutex, + mpsc::{self, RecvTimeoutError, Sender}, + }, thread, + time::Duration, }; // ### Third-party @@ -81,11 +46,17 @@ use chrono::{DateTime, Utc}; use log::{debug, error, info, warn}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; -use tokio::sync::mpsc; -use tokio_postgres::{Client, NoTls}; use ts_rs::TS; static NEXT_CAPTURE_EVENT_ID: AtomicU64 = AtomicU64::new(1); +static NEXT_SPOOL_FILE_ID: AtomicU64 = AtomicU64::new(1); + +const DEFAULT_CAPTURE_SCHEMA_VERSION: i32 = 2; +const MAX_CAPTURE_BATCH_EVENTS: usize = 100; +const MAX_CAPTURE_BATCH_BYTES: usize = 524_288; +const INITIAL_RETRY_DELAY_MS: u64 = 1_000; +const MAX_RETRY_DELAY_MS: u64 = 60_000; +const CAPTURE_SERVICE_HTTP_TIMEOUT_SECS: u64 = 10; /// Canonical event types. Keep the serialized strings stable for analysis. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, TS)] @@ -168,7 +139,15 @@ impl std::fmt::Display for CaptureEventType { /// Hash a local file path before it enters capture storage. The hash is stable /// enough to group edits to the same file while avoiding raw path collection. pub fn hash_capture_path(path: &str) -> String { - Sha256::digest(path.as_bytes()) + hash_capture_text(path) +} + +fn hash_capture_token(token: &str) -> String { + hash_capture_text(token) +} + +fn hash_capture_text(value: &str) -> String { + Sha256::digest(value.as_bytes()) .iter() .map(|byte| format!("{byte:02x}")) .collect() @@ -178,9 +157,8 @@ pub fn hash_capture_path(path: &str) -> String { /// /// The server supplies the authoritative timestamp and hashes any raw local file /// path before storage. Study metadata such as course, assignment, group, -/// condition, and task is not part of this wire type: those values are inferred -/// later from researcher-managed mappings keyed by the pseudonymous `user_id` -/// and event timestamps. +/// condition, and task is inferred later from researcher-managed mappings keyed +/// by the pseudonymous `user_id` and event timestamps. #[derive(Debug, Serialize, Deserialize, PartialEq, TS)] #[ts(export, optional_fields)] pub struct CaptureEventWire { @@ -189,15 +167,13 @@ pub struct CaptureEventWire { /// across capture transports or retries. #[serde(skip_serializing_if = "Option::is_none")] pub event_id: Option, - /// Event order within one `(session_id, event_source)` stream. Unlike - /// `event_id`, this is intentionally ordered so analysis can reconstruct - /// event order and detect gaps within a stream. + /// Event order within one `(session_id, event_source)` stream. #[serde(skip_serializing_if = "Option::is_none")] pub sequence_number: Option, /// Capture payload schema version. #[serde(skip_serializing_if = "Option::is_none")] pub schema_version: Option, - /// Pseudonymous participant UUID. This is not the student's real identity. + /// Pseudonymous participant UUID from CaptureWebService token status. pub user_id: String, /// Logical capture session UUID. #[serde(skip_serializing_if = "Option::is_none")] @@ -209,82 +185,44 @@ pub struct CaptureEventWire { #[serde(skip_serializing_if = "Option::is_none")] pub language_id: Option, /// Raw local file path from a trusted local client. This value exists only - /// long enough for the Rust server to normalize hashing; it is never written - /// to the capture database or fallback JSONL. + /// long enough for the Rust server to hash it; it is never spooled or sent. #[serde(skip_serializing_if = "Option::is_none")] pub file_path: Option, - /// SHA-256 hash of the local file path. Clients should prefer `file_path` - /// so the server owns hashing, but this remains for server-originated - /// events and backward-compatible local callers. + /// SHA-256 hash of the local file path. #[serde(skip_serializing_if = "Option::is_none")] pub file_hash: Option, /// Canonical capture event type. pub event_type: CaptureEventType, - /// Optional client timezone offset in minutes (JS Date().getTimezoneOffset()). - /// Combined with the server UTC timestamp, this allows local time-of-day - /// analysis without storing the student's location or full timezone name. #[serde(skip_serializing_if = "Option::is_none")] pub client_tz_offset_min: Option, - - /// Event-specific data stored as JSON. Known keys include capture controls - /// (`capture_active`, `capture_control_only`), activity/session details - /// (`mode`, `closed_by`, `duration_ms`, `duration_seconds`, `from`, `to`), - /// tool/run/build details (`reason`, `lineCount`, `sessionName`, - /// `sessionType`, `taskName`, `taskSource`, `processId`, `exitCode`), write - /// classification details (`source`, `classification_basis`, `diff`, - /// `doc_block_diff`, `doc_block_count_before`, - /// `doc_block_count_after`). Add future keys only when they support a - /// specific analysis question and do not store source text or raw local - /// paths. + /// Event-specific data. Do not store source text or raw local paths here. #[serde(skip_serializing_if = "Option::is_none")] #[ts(type = "unknown")] pub data: Option, } /// Participant and session metadata remembered from client capture events. -/// -/// The translation layer generates `write_doc`/`write_code` events after it has -/// parsed CodeChat content. Those events should share the same pseudonymous -/// participant and capture session as extension-side events, but the server -/// should not ask students for course/group/assignment/task setup values. #[derive(Clone, Debug, Default)] pub(crate) struct CaptureContext { - /// True only while capture is actively recording. The translation layer must - /// not generate write events from a stale participant/session context after - /// recording or consent is turned off. active: bool, - /// Pseudonymous participant UUID from the latest client capture event. user_id: Option, - /// Origin of the client event stream, such as the VS Code extension. event_source: Option, - /// Extension session UUID carried on the capture wire payload. session_id: Option, - /// Client timezone offset in minutes, retained for generated write events. client_tz_offset_min: Option, - /// Capture payload schema version from the extension. schema_version: Option, - /// Server-local event order for translation-generated events in this - /// capture context. Client events have their own extension-side sequence. server_sequence_number: i64, } impl CaptureContext { /// Refresh server-side capture identity and active/inactive state from an - /// extension capture message. This context is used only for server-generated - /// write classification events, not for deciding whether the original - /// extension event itself is inserted. + /// extension capture message. pub(crate) fn update_from_wire(&mut self, wire: &CaptureEventWire) { - // Session start/end are the coarse lifecycle signals; the explicit - // `capture_active` data field handles settings-change audit events that - // should be inserted while also disabling later translated writes. match wire.event_type { CaptureEventType::SessionStart => self.active = true, CaptureEventType::SessionEnd => self.active = false, _ => {} } - // Keep the most recent participant/session metadata so translated write - // events can be joined to the same participant as extension events. if !wire.user_id.trim().is_empty() { self.user_id = Some(wire.user_id.clone()); } @@ -303,20 +241,15 @@ impl CaptureContext { if let Some(client_tz_offset_min) = wire.client_tz_offset_min { self.client_tz_offset_min = Some(client_tz_offset_min); } - if let Some(serde_json::Value::Object(data)) = &wire.data { - // Settings-change audit events use this flag to tell the server - // whether future translation-generated write events are allowed. - if let Some(active) = data + if let Some(serde_json::Value::Object(data)) = &wire.data + && let Some(active) = data .get("capture_active") .and_then(serde_json::Value::as_bool) - { - self.active = active; - } + { + self.active = active; } } - /// True when server-generated capture events should be logged for this - /// participant/session context. pub(crate) fn is_active(&self) -> bool { self.active } @@ -327,13 +260,9 @@ impl CaptureContext { file_path: Option, data: serde_json::Value, ) -> Option { - // Do not generate server-side write_doc/write_code rows unless the - // latest settings state says capture is actively recording. if !self.active { return None; } - // Normalize arbitrary JSON payloads into objects so we can attach - // server-translation metadata consistently. let mut data = match data { serde_json::Value::Object(map) => map, other => { @@ -342,8 +271,6 @@ impl CaptureContext { map } }; - // Preserve any existing source field, but default server-generated - // events to `server_translation` for analysis. data.entry("source".to_string()) .or_insert_with(|| serde_json::json!("server_translation")); @@ -366,9 +293,7 @@ impl CaptureContext { } } -/// True for a capture message that should update `CaptureContext` only. These -/// messages are used to stop server-side write classification after the user -/// turns off consent or recording, without adding a synthetic DB row. +/// True for a capture message that should update `CaptureContext` only. pub(crate) fn capture_control_only(wire: &CaptureEventWire) -> bool { matches!( &wire.data, @@ -380,177 +305,98 @@ pub(crate) fn capture_control_only(wire: &CaptureEventWire) -> bool { ) } -/// Configuration used to construct the PostgreSQL connection string. -/// -/// You can populate this from a JSON file or environment variables in -/// `main.rs`; this module stays agnostic. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CaptureConfig { - /// PostgreSQL host name or address. - pub host: String, - /// Optional PostgreSQL port. Uses libpq's default when omitted. - #[serde(default)] - pub port: Option, - /// PostgreSQL user name. - pub user: String, - /// PostgreSQL password. Never included in redacted summaries. - pub password: String, - /// PostgreSQL database name. - pub dbname: String, - /// Optional: application-level identifier for this deployment (e.g., course - /// code or semester). Not stored in the DB directly; callers can embed this - /// in `data` if desired. - #[serde(default)] - pub app_id: Option, - /// Local JSONL file used when PostgreSQL is unavailable. - #[serde(default)] - pub fallback_path: Option, +/// Runtime service configuration supplied by the VS Code extension. The bearer +/// token is stored in VS Code SecretStorage and held only in memory here. +#[derive(Debug, Clone, Default)] +pub struct CaptureServiceConfig { + pub base_url: Option, + token: Option, + pub participant_id: Option, + pub instance_id: Option, + generation: u64, } -impl CaptureConfig { - /// Validate capture configuration before starting the worker. This catches - /// invalid setup early and avoids ambiguous "random port" behavior. - pub fn validate(&self) -> Result<(), String> { - if self.port == Some(0) { - return Err("capture database port must be between 1 and 65535".to_string()); - } - validate_conn_str_field("host", &self.host)?; - validate_conn_str_field("user", &self.user)?; - validate_conn_str_field("password", &self.password)?; - validate_conn_str_field("dbname", &self.dbname)?; - Ok(()) +impl CaptureServiceConfig { + fn configured(base_url: String, token: Option) -> Result { + let base_url = normalize_service_base_url(&base_url)?; + Ok(Self { + base_url: Some(base_url), + token: token.filter(|token| !token.trim().is_empty()), + participant_id: None, + instance_id: None, + generation: 0, + }) } - /// Build a libpq-style connection string. - pub fn to_conn_str(&self) -> String { - let mut parts = vec![ - format!("host={}", self.host), - format!("user={}", self.user), - format!("password={}", self.password), - format!("dbname={}", self.dbname), - ]; - if let Some(port) = self.port { - parts.push(format!("port={port}")); - } - parts.join(" ") + fn token(&self) -> Option<&str> { + self.token + .as_deref() + .filter(|token| !token.trim().is_empty()) } - /// Return a human-readable summary that never includes the password. - pub fn redacted_summary(&self) -> String { - format!( - "host={}, port={:?}, user={}, dbname={}, app_id={:?}, fallback_path={:?}", - self.host, self.port, self.user, self.dbname, self.app_id, self.fallback_path - ) + fn status_url(&self) -> Option { + self.base_url + .as_deref() + .map(|base_url| format!("{base_url}/v1/capture/status")) } - /// Build capture configuration from environment variables. If no capture - /// host is configured, return `Ok(None)` so callers can fall back to a file. - pub fn from_env() -> Result, String> { - let Some(host) = env_var_trimmed("CODECHAT_CAPTURE_HOST") else { - return Ok(None); - }; - - let port = match env_var_trimmed("CODECHAT_CAPTURE_PORT") { - Some(port) => Some(port.parse::().map_err(|err| { - format!("CODECHAT_CAPTURE_PORT must be a valid port number: {err}") - })?), - None => None, - }; - - let cfg = Self { - host, - port, - user: required_env_var("CODECHAT_CAPTURE_USER")?, - password: required_env_var("CODECHAT_CAPTURE_PASSWORD")?, - dbname: required_env_var("CODECHAT_CAPTURE_DBNAME")?, - app_id: env_var_trimmed("CODECHAT_CAPTURE_APP_ID"), - fallback_path: env_var_trimmed("CODECHAT_CAPTURE_FALLBACK_PATH").map(PathBuf::from), - }; - cfg.validate()?; - Ok(Some(cfg)) + fn events_url(&self) -> Option { + self.base_url + .as_deref() + .map(|base_url| format!("{base_url}/v1/capture/events")) } -} -fn validate_conn_str_field(field_name: &str, value: &str) -> Result<(), String> { - if value.trim().is_empty() { - return Err(format!("capture database {field_name} must not be empty")); - } - if value.chars().any(char::is_whitespace) { - return Err(format!( - "capture database {field_name} must not contain whitespace" - )); + fn token_hash(&self) -> Option { + self.token().map(hash_capture_token) } - Ok(()) -} -/// Load capture configuration from environment variables or the repo/runtime -/// `capture_config.json`. -/// -/// Environment variables take precedence so deployment can inject secrets -/// without writing them to disk. Local development and student-facing setup use -/// the single config file at `root_path/capture_config.json`. -pub fn load_capture_config(root_path: &Path) -> Option { - match CaptureConfig::from_env() { - Ok(Some(cfg)) => return Some(with_default_capture_fallback_path(cfg, root_path)), - Ok(None) => {} - Err(err) => { - warn!("Capture: invalid environment configuration: {err}"); + fn spool_identity(&self) -> Option { + if self.token().is_none() && self.base_url.is_none() { return None; } + Some(SpoolIdentity { + token_hash: self.token_hash(), + service_base_url: self.base_url.clone(), + participant_id: self.participant_id.clone(), + instance_id: self.instance_id.clone(), + }) } - let config_path = root_path.join("capture_config.json"); - - match fs::read_to_string(&config_path) { - Ok(json) => match serde_json::from_str::(&json) { - Ok(cfg) => match cfg.validate() { - Ok(()) => Some(with_default_capture_fallback_path(cfg, root_path)), - Err(err) => { - warn!("Capture: invalid configuration in {config_path:?}: {err}"); - None - } - }, - Err(err) => { - warn!("Capture: invalid JSON in {config_path:?}: {err}"); - None - } - }, - Err(err) => { - info!( - "Capture: disabled (no CODECHAT_CAPTURE_* env and no readable config at {config_path:?}: {err})" - ); - None - } + fn matches_request_snapshot(&self, snapshot: &Self) -> bool { + self.generation == snapshot.generation + && self.base_url == snapshot.base_url + && self.token_hash() == snapshot.token_hash() } } -/// Normalize the fallback JSONL path to the runtime root when a relative path -/// or no path is provided. -pub fn with_default_capture_fallback_path( - mut cfg: CaptureConfig, - root_path: &Path, -) -> CaptureConfig { - match &cfg.fallback_path { - Some(path) if path.is_relative() => { - cfg.fallback_path = Some(root_path.join(path)); - } - Some(_) => {} - None => { - cfg.fallback_path = Some(root_path.join("capture-events-fallback.jsonl")); +fn normalize_service_base_url(value: &str) -> Result { + let mut url = value.trim().trim_end_matches('/').to_string(); + if url.is_empty() { + return Err("capture service URL must not be empty".to_string()); + } + for suffix in ["/v1/capture/events", "/v1/capture/status", "/v1/health"] { + if let Some(base) = url.strip_suffix(suffix) { + url = base.trim_end_matches('/').to_string(); + break; } } - cfg -} -fn env_var_trimmed(name: &str) -> Option { - env::var(name) - .ok() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) -} + let mut parsed = + url::Url::parse(&url).map_err(|_| "capture service URL must be absolute".to_string())?; + if !parsed.username().is_empty() || parsed.password().is_some() { + return Err("capture service URL must not include credentials".to_string()); + } + let local_http = parsed.scheme() == "http" + && matches!(parsed.host_str(), Some("localhost" | "127.0.0.1" | "::1")); + if parsed.scheme() != "https" && !local_http { + return Err("capture service URL must use https:// except for localhost".to_string()); + } -fn required_env_var(name: &str) -> Result { - env_var_trimmed(name).ok_or_else(|| format!("{name} is required when capture env is used")) + parsed.set_query(None); + parsed.set_fragment(None); + let trimmed_path = parsed.path().trim_end_matches('/').to_string(); + parsed.set_path(&trimmed_path); + Ok(parsed.as_str().trim_end_matches('/').to_string()) } /// Known capture worker states reported to the VS Code status UI. @@ -558,36 +404,61 @@ fn required_env_var(name: &str) -> Result { #[serde(rename_all = "snake_case")] #[ts(export)] pub enum CaptureState { - /// Capture is not configured or the worker is unavailable. + /// Capture worker is not available. Disabled, - /// Capture worker is starting and attempting the first database connection. + /// Capture worker is starting. Starting, - /// Events are being persisted to PostgreSQL. - Database, - /// Events are being written to local JSONL fallback storage. - Fallback, + /// Events are being written to the local FIFO spool. + Spooling, + /// Events are being uploaded to CaptureWebService. + Uploading, + /// The local spool is empty and the remote service is reachable. + Remote, + /// The token was rejected by the service. + AuthFailed, + /// The service knows the token, but capture is currently not allowed. + CaptureDisabled, + /// The service or network is temporarily unavailable. + ServiceUnavailable, +} + +/// Non-secret token state shown in the capture UI. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export)] +pub enum CaptureTokenStatus { + Missing, + Unverified, + Accepted, + Rejected, + CaptureDisabled, } /// Capture worker health exposed to the VS Code status item. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] #[ts(export)] pub struct CaptureStatus { - /// True when the capture worker is configured and accepting events. pub enabled: bool, - /// Current worker state. pub state: CaptureState, - /// Number of events accepted into the worker queue. + pub token_status: CaptureTokenStatus, pub queued_events: u64, - /// Number of events inserted into PostgreSQL. - pub persisted_events: u64, - /// Number of events written to the local JSONL fallback. - pub fallback_events: u64, - /// Number of failed enqueue or fallback-write attempts. + pub spooled_events: u64, + pub uploaded_events: u64, pub failed_events: u64, - /// Most recent capture error, if one is known. + pub quarantined_events: u64, pub last_error: Option, - /// Local JSONL fallback path when fallback capture is configured. - pub fallback_path: Option, + pub spool_path: Option, + pub service_base_url: Option, + pub participant_id: Option, + pub instance_id: Option, + pub capture_enabled: Option, + pub participant_status: Option, + pub consent_status: Option, + pub instance_status: Option, + pub token_expires_at: Option, + pub service_version: Option, + pub last_status_check_at: Option, + pub last_upload_at: Option, } impl CaptureStatus { @@ -595,87 +466,57 @@ impl CaptureStatus { Self { enabled: false, state: CaptureState::Disabled, + token_status: CaptureTokenStatus::Missing, queued_events: 0, - persisted_events: 0, - fallback_events: 0, + spooled_events: 0, + uploaded_events: 0, failed_events: 0, + quarantined_events: 0, last_error: None, - fallback_path: None, + spool_path: None, + service_base_url: None, + participant_id: None, + instance_id: None, + capture_enabled: None, + participant_status: None, + consent_status: None, + instance_status: None, + token_expires_at: None, + service_version: None, + last_status_check_at: None, + last_upload_at: None, } } - fn starting(fallback_path: Option) -> Self { + fn starting(spool_path: PathBuf) -> Self { Self { enabled: true, state: CaptureState::Starting, - queued_events: 0, - persisted_events: 0, - fallback_events: 0, - failed_events: 0, - last_error: None, - fallback_path, + token_status: CaptureTokenStatus::Missing, + spool_path: Some(spool_path), + ..Self::disabled() } } } /// The in-memory representation of a single capture event. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct CaptureEvent { - /// Globally unique event identifier, generated by the client or server. - /// - /// This is an opaque stable ID for correlation and possible future - /// deduplication. It is not ordered; use `sequence_number` for event order - /// within one `(session_id, event_source)` stream. pub event_id: Option, - /// Event order within one `(session_id, event_source)` stream. - /// - /// This is intentionally ordered so analysis can reconstruct event order and - /// detect missing events. It is not globally unique; use `event_id` for - /// stable event identity. pub sequence_number: Option, - /// Capture payload schema version. pub schema_version: Option, - /// Pseudonymous participant UUID supplied by the extension. pub user_id: String, - /// Logical capture session UUID. pub session_id: Option, - /// Origin of the event stream, such as the VS Code extension. pub event_source: Option, - /// VS Code language identifier for the active file, when known. pub language_id: Option, - /// Privacy-preserving SHA-256 hash of the local file path. pub file_hash: Option, - /// Canonical type of the captured event. pub event_type: CaptureEventType, - /// Server receive/record timestamp, in UTC. pub timestamp: DateTime, - /// Client timezone offset in minutes. - /// - /// Combined with the server UTC timestamp, this supports local time-of-day - /// analysis without collecting student location or a full timezone name. pub client_tz_offset_min: Option, - /// Event-specific payload, stored as JSONB in the DB. - /// - /// Known keys include: - /// - /// * Capture/settings control: `capture_active`, `capture_control_only`, - /// `changed_by`, `changed_settings`, previous/new consent and recording - /// booleans. - /// * Activity/session summaries: `mode`, `closed_by`, `duration_ms`, - /// `duration_seconds`, `from`, `to`. - /// * Save/run/compile metadata: `reason`, `lineCount`, `sessionName`, - /// `sessionType`, `taskName`, `taskSource`, `processId`, `exitCode`. - /// * Write classification: `source`, `classification_basis`, `diff`, - /// `doc_block_diff`, `doc_block_count_before`, `doc_block_count_after`. - /// - /// Future keys should be documented here, tied to an analysis question, and - /// privacy-reviewed before capture. Do not store raw source text or raw - /// local file paths in this payload. pub data: serde_json::Value, } impl CaptureEvent { - /// Convenience constructor when the caller already has a timestamp. pub fn new( user_id: String, file_hash: Option, @@ -699,7 +540,6 @@ impl CaptureEvent { } } - /// Constructor for callers that already have first-class capture columns. #[allow(clippy::too_many_arguments)] pub fn with_columns( event_id: Option, @@ -731,7 +571,6 @@ impl CaptureEvent { } } - /// Convenience constructor which uses the current time. pub fn now( user_id: String, file_hash: Option, @@ -753,228 +592,291 @@ pub fn generate_capture_event_id(prefix: &str) -> String { ) } -/// Internal worker message. Identical to `CaptureEvent`, but separated in case -/// we later want to add batching / flush control signals. -type WorkerMsg = CaptureEvent; +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +struct SpoolIdentity { + token_hash: Option, + service_base_url: Option, + participant_id: Option, + instance_id: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct SpoolRecord { + spooled_at: DateTime, + #[serde(default)] + identity: Option, + event: CaptureEvent, +} + +#[derive(Debug, Serialize)] +struct CaptureBatchRequest { + schema_version: i32, + #[serde(skip_serializing_if = "Option::is_none")] + participant_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + instance_id: Option, + client_sent_at: String, + events: Vec, +} + +#[derive(Debug, Clone, Serialize)] +struct CaptureServiceEvent { + event_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + sequence_number: Option, + #[serde(skip_serializing_if = "Option::is_none")] + schema_version: Option, + user_id: String, + session_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + event_source: Option, + #[serde(skip_serializing_if = "Option::is_none")] + language_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + file_hash: Option, + event_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + client_tz_offset_min: Option, + client_event_time: String, + data: serde_json::Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CaptureServiceStatusResponse { + pub participant_id: String, + pub instance_id: String, + pub study_id: String, + pub capture_enabled: bool, + pub participant_status: String, + pub consent_status: String, + pub instance_status: String, + pub token_expires_at: Option, + pub server_time: String, + pub service_version: String, +} + +#[derive(Debug, Deserialize)] +struct CaptureBatchAcceptedResponse { + batch_id: String, + accepted: u64, + server_time: String, +} + +#[derive(Debug)] +struct SpoolBatch { + files: Vec, + body: Vec, + event_count: u64, +} + +#[derive(Debug)] +enum NextBatch { + Batch(SpoolBatch), + Empty, + NoMatchingIdentity, +} + +#[derive(Debug)] +struct CaptureHttpError { + status_code: Option, + message: String, +} + +impl CaptureHttpError { + fn transport(message: impl Into) -> Self { + Self { + status_code: None, + message: message.into(), + } + } + + fn response(status_code: i32, message: impl Into) -> Self { + Self { + status_code: Some(status_code), + message: message.into(), + } + } + + fn is_transient(&self) -> bool { + matches!(self.status_code, None | Some(429 | 500 | 503)) + || matches!(self.status_code, Some(code) if code >= 500) + } +} + +enum WorkerMsg { + Flush, +} /// Handle used by the rest of the server to record events. -/// -/// Cloning this handle is cheap: it only clones an `mpsc::UnboundedSender`. #[derive(Clone)] pub struct EventCapture { - tx: mpsc::UnboundedSender, + tx: Sender, status: Arc>, + config: Arc>, + spool_path: PathBuf, } impl EventCapture { - /// Create a capture worker that writes every event to local JSONL fallback. - /// - /// This mode is used for local debugging and for installations without a - /// configured PostgreSQL capture database. - pub fn fallback_only(fallback_path: PathBuf) -> Result { - let status = Arc::new(Mutex::new(CaptureStatus { - enabled: true, - state: CaptureState::Fallback, - queued_events: 0, - persisted_events: 0, - fallback_events: 0, - failed_events: 0, - last_error: Some("PostgreSQL capture config unavailable".to_string()), - fallback_path: Some(fallback_path.clone()), - })); + pub fn new(spool_path: PathBuf) -> Result { + fs::create_dir_all(&spool_path)?; + fs::create_dir_all(quarantine_path(&spool_path))?; - info!( - "Capture: no PostgreSQL config available; writing events to fallback JSONL at {:?}.", - fallback_path - ); + let status = Arc::new(Mutex::new(CaptureStatus::starting(spool_path.clone()))); + update_spool_count(&spool_path, &status); - let (tx, mut rx) = mpsc::unbounded_channel::(); + let config = Arc::new(Mutex::new(CaptureServiceConfig::default())); + let (tx, rx) = mpsc::channel::(); let status_worker = status.clone(); + let config_worker = config.clone(); + let spool_worker = spool_path.clone(); thread::Builder::new() - .name("codechat-capture-fallback".to_string()) - .spawn(move || { - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("Capture: failed to build fallback Tokio runtime"); - - runtime.block_on(async move { - while let Some(event) = rx.recv().await { - write_event_to_fallback( - &fallback_path, - &event, - &status_worker, - Some("PostgreSQL capture config unavailable".to_string()), - ); - } - warn!("Capture: event channel closed; fallback-only worker exiting."); - }); - }) + .name("codechat-capture-upload".to_string()) + .spawn(move || upload_worker(rx, config_worker, status_worker, spool_worker)) .map_err(|err| { - io::Error::other(format!( - "Capture: failed to start fallback worker thread: {err}" - )) + io::Error::other(format!("Capture: failed to start upload worker: {err}")) })?; - Ok(Self { tx, status }) + Ok(Self { + tx, + status, + config, + spool_path, + }) } - /// Create a new `EventCapture` instance and spawn a background worker which - /// consumes events and inserts them into PostgreSQL. - /// - /// This function is synchronous so it can be called from non-async server - /// setup code. It spawns an async task internally which performs the - /// database connection and event processing. - pub fn new(mut config: CaptureConfig) -> Result { - let fallback_path = config - .fallback_path - .get_or_insert_with(|| PathBuf::from("capture-events-fallback.jsonl")) - .clone(); - let conn_str = config.to_conn_str(); - let status = Arc::new(Mutex::new(CaptureStatus::starting(Some( - fallback_path.clone(), - )))); - - // High-level DB connection details (no password). - info!( - "Capture: preparing PostgreSQL connection ({})", - config.redacted_summary() - ); - - let (tx, mut rx) = mpsc::unbounded_channel::(); - let status_worker = status.clone(); - - // Create a dedicated runtime so capture can be started from sync code - // before the Actix/Tokio server runtime exists. - thread::Builder::new() - .name("codechat-capture".to_string()) - .spawn(move || { - let runtime = tokio::runtime::Builder::new_multi_thread() - .worker_threads(1) - .enable_all() - .build() - .expect("Capture: failed to build Tokio runtime"); - - runtime.block_on(async move { - info!("Capture: attempting to connect to PostgreSQL."); - - match tokio_postgres::connect(&conn_str, NoTls).await { - Ok((client, connection)) => { - info!("Capture: successfully connected to PostgreSQL."); - update_status(&status_worker, |status| { - status.state = CaptureState::Database; - status.last_error = None; - }); - - // Drive the connection in its own task. - let status_connection = status_worker.clone(); - tokio::spawn(async move { - if let Err(err) = connection.await { - error!("Capture PostgreSQL connection error: {err}"); - update_status(&status_connection, |status| { - status.state = CaptureState::Fallback; - status.last_error = Some(format!( - "PostgreSQL connection error: {err}" - )); - }); - } - }); - - // Main event loop: pull events off the channel and insert - // them into the database. - while let Some(event) = rx.recv().await { - debug!( - "Capture: inserting event: type={}, user_id={}, file_hash={:?}", - event.event_type, event.user_id, event.file_hash - ); - - if let Err(err) = insert_event(&client, &event).await { - error!( - "Capture: FAILED to insert event (type={}, user_id={}): {err}", - event.event_type, event.user_id - ); - update_status(&status_worker, |status| { - status.state = CaptureState::Fallback; - status.last_error = Some(format!( - "PostgreSQL insert failed: {err}" - )); - }); - write_event_to_fallback( - &fallback_path, - &event, - &status_worker, - Some(format!("PostgreSQL insert failed: {err}")), - ); - } else { - update_status(&status_worker, |status| { - status.persisted_events += 1; - if status.state != CaptureState::Database { - status.state = CaptureState::Database; - } - }); - debug!("Capture: event insert successful."); - } - } + pub fn configure_service(&self, base_url: String, token: Option) -> Result<(), String> { + let mut new_config = CaptureServiceConfig::configured(base_url, token)?; + let token_status = if new_config.token().is_some() { + CaptureTokenStatus::Unverified + } else { + CaptureTokenStatus::Missing + }; + { + let mut config = self + .config + .lock() + .map_err(|_| "capture service config lock is poisoned".to_string())?; + new_config.generation = config.generation.saturating_add(1); + *config = new_config.clone(); + } + update_status(&self.status, |status| { + status.enabled = true; + status.state = CaptureState::Spooling; + status.token_status = token_status; + status.service_base_url = new_config.base_url.clone(); + status.participant_id = None; + status.instance_id = None; + status.capture_enabled = None; + status.participant_status = None; + status.consent_status = None; + status.instance_status = None; + status.token_expires_at = None; + status.service_version = None; + status.last_error = None; + }); + update_spool_count(&self.spool_path, &self.status); + self.signal_flush(); + Ok(()) + } - info!("Capture: event channel closed; background worker exiting."); - } - - Err(err) => { - let ctx = format!( - "Capture: FAILED to connect to PostgreSQL (host={}, dbname={}, user={})", - config.host, config.dbname, config.user - ); - - log_pg_connect_error(&ctx, &err); - - update_status(&status_worker, |status| { - status.state = CaptureState::Fallback; - status.last_error = Some(format!( - "PostgreSQL connection failed: {err}" - )); - }); - - warn!( - "Capture: writing pending events to fallback JSONL at {:?}.", - fallback_path - ); - while let Some(event) = rx.recv().await { - write_event_to_fallback( - &fallback_path, - &event, - &status_worker, - Some("PostgreSQL connection unavailable".to_string()), - ); - } - warn!("Capture: event channel closed; fallback worker exiting."); - } - } - }); - }) - .map_err(|err| { - io::Error::other(format!("Capture: failed to start worker thread: {err}")) - })?; + pub fn clear_token(&self) { + if let Ok(mut config) = self.config.lock() { + config.generation = config.generation.saturating_add(1); + config.token = None; + config.participant_id = None; + config.instance_id = None; + } + update_status(&self.status, |status| { + status.state = CaptureState::Spooling; + status.token_status = CaptureTokenStatus::Missing; + status.participant_id = None; + status.instance_id = None; + status.capture_enabled = None; + status.participant_status = None; + status.consent_status = None; + status.instance_status = None; + status.token_expires_at = None; + status.service_version = None; + status.last_error = Some("Capture token is not configured".to_string()); + }); + update_spool_count(&self.spool_path, &self.status); + } - Ok(Self { tx, status }) + pub fn check_service_status(&self) -> Result { + let cfg = self + .config + .lock() + .map_err(|_| "capture service config lock is poisoned".to_string())? + .clone(); + let response = request_capture_status(&cfg).map_err(|err| { + apply_http_error_if_current(&self.config, &self.status, &cfg, &err); + err.message + })?; + if !apply_capture_service_status_if_current( + &self.config, + &self.status, + &cfg, + response.clone(), + ) { + return Err("Capture service status response was stale".to_string()); + } + Ok(response) } - /// Enqueue an event for insertion. This is non-blocking. + /// Durably append an event to the local FIFO spool, then ask the worker to + /// upload as soon as service access permits. pub fn log(&self, event: CaptureEvent) { debug!( - "Capture: queueing event: type={}, user_id={}, file_hash={:?}", + "Capture: spooling event: type={}, user_id={}, file_hash={:?}", event.event_type, event.user_id, event.file_hash ); - if let Err(err) = self.tx.send(event) { - error!("Capture: FAILED to enqueue capture event: {err}"); + let spool_identity = match self.config.lock() { + Ok(config) => config.spool_identity(), + Err(err) => { + error!("Capture: FAILED to read capture config before spooling: {err}"); + update_status(&self.status, |status| { + status.failed_events += 1; + status.last_error = Some(format!( + "Failed to read capture config before spooling: {err}" + )); + }); + return; + } + }; + + match append_spool_event(&self.spool_path, &event, spool_identity) { + Ok(()) => { + update_status(&self.status, |status| { + if matches!( + status.state, + CaptureState::Starting | CaptureState::Disabled + ) { + status.state = CaptureState::Spooling; + } + status.last_error = None; + }); + update_spool_count(&self.spool_path, &self.status); + self.signal_flush(); + } + Err(err) => { + error!("Capture: FAILED to append event to spool: {err}"); + update_status(&self.status, |status| { + status.failed_events += 1; + status.last_error = + Some(format!("Failed to append capture event to spool: {err}")); + }); + } + } + } + + fn signal_flush(&self) { + if let Err(err) = self.tx.send(WorkerMsg::Flush) { + error!("Capture: FAILED to notify upload worker: {err}"); update_status(&self.status, |status| { status.failed_events += 1; - status.last_error = Some(format!("Failed to enqueue capture event: {err}")); - }); - } else { - update_status(&self.status, |status| { - status.queued_events += 1; + status.last_error = Some(format!("Failed to notify upload worker: {err}")); }); } } @@ -998,234 +900,876 @@ fn update_status(status: &Arc>, f: impl FnOnce(&mut Capture } } -fn write_event_to_fallback( - fallback_path: &Path, - event: &CaptureEvent, - status: &Arc>, - last_error: Option, +fn upload_worker( + rx: mpsc::Receiver, + config: Arc>, + status: Arc>, + spool_path: PathBuf, ) { - match append_fallback_event(fallback_path, event) { - Ok(()) => update_status(status, |status| { - status.fallback_events += 1; - status.last_error = last_error; - }), + info!("Capture: upload worker started with spool at {spool_path:?}."); + let mut retry_delay = Duration::from_millis(INITIAL_RETRY_DELAY_MS); + loop { + match upload_next_batch(&spool_path, &config, &status) { + UploadOutcome::UploadedBatch => { + retry_delay = Duration::from_millis(INITIAL_RETRY_DELAY_MS); + continue; + } + UploadOutcome::NoEvents => { + retry_delay = Duration::from_millis(INITIAL_RETRY_DELAY_MS); + } + UploadOutcome::NotConfigured | UploadOutcome::Paused => {} + UploadOutcome::TransientFailure => { + warn!( + "Capture: transient upload failure; retrying in {} ms.", + retry_delay.as_millis() + ); + } + } + + let wait_for = if matches!( + capture_status_state(&status), + CaptureState::ServiceUnavailable + ) { + retry_delay + } else { + Duration::from_secs(30) + }; + + match rx.recv_timeout(wait_for) { + Ok(WorkerMsg::Flush) => {} + Err(RecvTimeoutError::Timeout) => {} + Err(RecvTimeoutError::Disconnected) => { + warn!("Capture: upload worker channel closed; worker exiting."); + break; + } + } + + if matches!( + capture_status_state(&status), + CaptureState::ServiceUnavailable + ) { + retry_delay = retry_delay + .saturating_mul(2) + .min(Duration::from_millis(MAX_RETRY_DELAY_MS)); + } + } +} + +fn capture_status_state(status: &Arc>) -> CaptureState { + status + .lock() + .map(|status| status.state) + .unwrap_or(CaptureState::Disabled) +} + +#[derive(Debug, PartialEq, Eq)] +enum UploadOutcome { + UploadedBatch, + NoEvents, + NotConfigured, + Paused, + TransientFailure, +} + +fn upload_next_batch( + spool_path: &Path, + config: &Arc>, + status: &Arc>, +) -> UploadOutcome { + update_spool_count(spool_path, status); + + let cfg = match config.lock() { + Ok(config) => config.clone(), Err(err) => { - error!( - "Capture: FAILED to write fallback event to {:?}: {err}", - fallback_path - ); update_status(status, |status| { status.failed_events += 1; - status.last_error = Some(format!("Fallback write failed: {err}")); + status.last_error = Some(format!("Capture config lock failed: {err}")); }); + return UploadOutcome::Paused; + } + }; + + let Some(token) = cfg.token().map(str::to_string) else { + update_status(status, |status| { + status.state = CaptureState::Spooling; + status.token_status = CaptureTokenStatus::Missing; + status.last_error = Some("Capture token is not configured".to_string()); + }); + return UploadOutcome::NotConfigured; + }; + let Some(events_url) = cfg.events_url() else { + update_status(status, |status| { + status.state = CaptureState::Spooling; + status.last_error = Some("Capture service URL is not configured".to_string()); + }); + return UploadOutcome::NotConfigured; + }; + + if cfg.participant_id.is_none() || cfg.instance_id.is_none() { + match request_capture_status(&cfg) { + Ok(service_status) => { + if !apply_capture_service_status_if_current(config, status, &cfg, service_status) { + return UploadOutcome::Paused; + } + } + Err(err) => { + apply_http_error_if_current(config, status, &cfg, &err); + return if err.is_transient() { + UploadOutcome::TransientFailure + } else { + UploadOutcome::Paused + }; + } } } + + let cfg = match config.lock() { + Ok(config) => config.clone(), + Err(err) => { + update_status(status, |status| { + status.failed_events += 1; + status.last_error = Some(format!("Capture config lock failed: {err}")); + }); + return UploadOutcome::Paused; + } + }; + + if !matches!( + capture_status_state(status), + CaptureState::Remote | CaptureState::Spooling | CaptureState::Uploading + ) { + return UploadOutcome::Paused; + } + + let batch = match build_next_batch(spool_path, &cfg, status) { + NextBatch::Batch(batch) => batch, + NextBatch::Empty => { + update_status(status, |status| { + if !matches!( + status.state, + CaptureState::AuthFailed | CaptureState::CaptureDisabled + ) { + status.state = CaptureState::Remote; + } + }); + return UploadOutcome::NoEvents; + } + NextBatch::NoMatchingIdentity => { + update_status(status, |status| { + status.state = CaptureState::Spooling; + status.last_error = + Some("Pending capture events belong to a different capture token".to_string()); + }); + return UploadOutcome::NoEvents; + } + }; + + if !run_if_capture_config_snapshot_is_current(config, &cfg, || { + update_status(status, |status| { + status.state = CaptureState::Uploading; + status.last_error = None; + }); + }) { + return UploadOutcome::Paused; + } + + match post_capture_batch(&events_url, &token, &batch.body) { + Ok(accepted) => { + if !run_if_capture_config_snapshot_is_current(config, &cfg, || { + for file in &batch.files { + if let Err(err) = fs::remove_file(file) { + warn!("Capture: unable to remove uploaded spool file {file:?}: {err}"); + } + } + update_status(status, |status| { + status.state = CaptureState::Remote; + status.uploaded_events += accepted.accepted; + status.spooled_events = status.spooled_events.saturating_sub(batch.event_count); + status.last_upload_at = Some(accepted.server_time.clone()); + status.last_error = None; + }); + }) { + return UploadOutcome::Paused; + } + update_spool_count(spool_path, status); + debug!( + "Capture: uploaded batch {} with {} event(s).", + accepted.batch_id, accepted.accepted + ); + UploadOutcome::UploadedBatch + } + Err(err) => match err.status_code { + Some(401) => { + apply_http_error_if_current(config, status, &cfg, &err); + UploadOutcome::Paused + } + Some(403) => { + apply_http_error_if_current(config, status, &cfg, &err); + UploadOutcome::Paused + } + Some(400 | 413) => { + if !run_if_capture_config_snapshot_is_current(config, &cfg, || { + quarantine_files( + spool_path, + &batch.files, + &format!("Capture service rejected batch: {}", err.message), + status, + ); + }) { + return UploadOutcome::Paused; + } + update_spool_count(spool_path, status); + UploadOutcome::UploadedBatch + } + _ if err.is_transient() => { + apply_http_error_if_current(config, status, &cfg, &err); + UploadOutcome::TransientFailure + } + _ => { + apply_http_error_if_current(config, status, &cfg, &err); + UploadOutcome::Paused + } + }, + } } -fn append_fallback_event(fallback_path: &Path, event: &CaptureEvent) -> io::Result<()> { - if let Some(parent) = fallback_path.parent() - && !parent.as_os_str().is_empty() +fn append_spool_event( + spool_path: &Path, + event: &CaptureEvent, + identity: Option, +) -> io::Result<()> { + fs::create_dir_all(spool_path)?; + let counter = NEXT_SPOOL_FILE_ID.fetch_add(1, Ordering::Relaxed); + let timestamp = Utc::now().format("%Y%m%d%H%M%S%6f"); + let path = spool_path.join(format!( + "{}-{}-{:020}.json", + timestamp, + process::id(), + counter + )); + let tmp_path = path.with_extension("tmp"); + let mut event = event.clone(); + event.data = sanitize_capture_data(event.data).map_err(io::Error::other)?; + let record = SpoolRecord { + spooled_at: Utc::now(), + identity, + event, + }; + { - fs::create_dir_all(parent)?; - } - - let mut file = OpenOptions::new() - .create(true) - .append(true) - .open(fallback_path)?; - let record = serde_json::json!({ - "fallback_timestamp": Utc::now().to_rfc3339(), - "event": { - "event_id": event.event_id, - "sequence_number": event.sequence_number, - "schema_version": event.schema_version, - "user_id": event.user_id, - "session_id": event.session_id, - "event_source": event.event_source, - "language_id": event.language_id, - "file_hash": event.file_hash, - "event_type": event.event_type.as_str(), - "timestamp": event.timestamp.to_rfc3339(), - "client_tz_offset_min": event.client_tz_offset_min, - "data": event.data, - } - }); - writeln!(file, "{record}")?; + let mut file = File::create(&tmp_path)?; + serde_json::to_writer(&mut file, &record) + .map_err(|err| io::Error::other(err.to_string()))?; + writeln!(file)?; + file.sync_all()?; + } + fs::rename(tmp_path, path)?; Ok(()) } -fn log_pg_connect_error(context: &str, err: &tokio_postgres::Error) { - // If Postgres returned a structured DbError, log it ONCE and bail. - if let Some(db) = err.as_db_error() { - // Example: 28P01 = invalid\_password - error!( - "{context}: PostgreSQL {} (SQLSTATE {})", - db.message(), - db.code().code() - ); - - if let Some(detail) = db.detail() { - error!("{context}: detail: {detail}"); +fn pending_spool_files(spool_path: &Path) -> io::Result> { + let mut files = Vec::new(); + if !spool_path.exists() { + return Ok(files); + } + for entry in fs::read_dir(spool_path)? { + let entry = entry?; + let path = entry.path(); + if path.is_file() && path.extension().and_then(|ext| ext.to_str()) == Some("json") { + files.push(path); } - if let Some(hint) = db.hint() { - error!("{context}: hint: {hint}"); + } + files.sort_by(|a, b| a.file_name().cmp(&b.file_name())); + Ok(files) +} + +fn update_spool_count(spool_path: &Path, status: &Arc>) { + match pending_spool_files(spool_path) { + Ok(files) => update_status(status, |status| { + let count = files.len() as u64; + status.queued_events = count; + status.spooled_events = count; + }), + Err(err) => update_status(status, |status| { + status.failed_events += 1; + status.last_error = Some(format!("Unable to inspect capture spool: {err}")); + }), + } +} + +fn build_next_batch( + spool_path: &Path, + cfg: &CaptureServiceConfig, + status: &Arc>, +) -> NextBatch { + let files = match pending_spool_files(spool_path) { + Ok(files) => files, + Err(err) => { + update_status(status, |status| { + status.failed_events += 1; + status.last_error = Some(format!("Unable to read capture spool: {err}")); + }); + return NextBatch::Empty; } - return; + }; + if files.is_empty() { + return NextBatch::Empty; } - // Otherwise, try to find an underlying std::io::Error (refused, timed out, - // DNS, etc.) - let mut current: &(dyn Error + 'static) = err; - while let Some(source) = current.source() { - if let Some(ioe) = source.downcast_ref::() { - error!( - "{context}: I/O error kind={:?} raw_os_error={:?} msg={}", - ioe.kind(), - ioe.raw_os_error(), - ioe + let mut selected_files = Vec::new(); + let mut events = Vec::new(); + let mut body = Vec::new(); + let mut skipped_identity_mismatch = false; + + for file in files { + if selected_files.len() >= MAX_CAPTURE_BATCH_EVENTS { + break; + } + + let record = match read_spool_record(&file) { + Ok(record) => record, + Err(err) => { + quarantine_files( + spool_path, + std::slice::from_ref(&file), + &format!("Invalid local spool record: {err}"), + status, + ); + continue; + } + }; + + if !spool_record_matches_current_identity(&record, cfg) { + skipped_identity_mismatch = true; + continue; + } + + if let Some(participant_id) = cfg.participant_id.as_deref() + && record.event.user_id != participant_id + { + quarantine_files( + spool_path, + std::slice::from_ref(&file), + "Capture event user_id does not match current capture token", + status, ); - return; + continue; + } + + let event = match service_event_from_capture_event(record.event) { + Ok(event) => event, + Err(err) => { + quarantine_files( + spool_path, + std::slice::from_ref(&file), + &format!("Invalid capture event for service upload: {err}"), + status, + ); + continue; + } + }; + + let mut candidate_events = events.clone(); + candidate_events.push(event); + let candidate_batch = CaptureBatchRequest { + schema_version: DEFAULT_CAPTURE_SCHEMA_VERSION, + participant_id: cfg.participant_id.clone(), + instance_id: cfg.instance_id.clone(), + client_sent_at: Utc::now().to_rfc3339(), + events: candidate_events, + }; + + let candidate_body = match serde_json::to_vec(&candidate_batch) { + Ok(body) => body, + Err(err) => { + quarantine_files( + spool_path, + std::slice::from_ref(&file), + &format!("Unable to serialize capture batch: {err}"), + status, + ); + continue; + } + }; + + if candidate_body.len() > MAX_CAPTURE_BATCH_BYTES { + if selected_files.is_empty() { + quarantine_files( + spool_path, + std::slice::from_ref(&file), + "Single capture event exceeds service payload size limit", + status, + ); + continue; + } + break; + } + + events = candidate_batch.events; + body = candidate_body; + selected_files.push(file); + } + + if selected_files.is_empty() { + if skipped_identity_mismatch { + NextBatch::NoMatchingIdentity + } else { + NextBatch::Empty } - current = source; + } else { + NextBatch::Batch(SpoolBatch { + event_count: events.len() as u64, + files: selected_files, + body, + }) + } +} + +fn spool_record_matches_current_identity(record: &SpoolRecord, cfg: &CaptureServiceConfig) -> bool { + if let Some(identity) = &record.identity { + return identity.token_hash == cfg.token_hash() + && identity.service_base_url == cfg.base_url; } - // Fallback: log once (Display) - error!("{context}: {err}"); + cfg.participant_id + .as_deref() + .map(|participant_id| record.event.user_id == participant_id) + .unwrap_or(false) } -fn should_retry_legacy_insert(err: &tokio_postgres::Error) -> bool { - matches!( - err.code().map(|code| code.code()), - Some("42703" | "42P01" | "42804") - ) +fn read_spool_record(path: &Path) -> Result { + let text = fs::read_to_string(path).map_err(|err| err.to_string())?; + serde_json::from_str(&text).map_err(|err| err.to_string()) } -/// Insert a single event into the `events` table. -async fn insert_event(client: &Client, event: &CaptureEvent) -> Result { - match insert_rich_event(client, event).await { - Ok(rows) => Ok(rows), - Err(err) if should_retry_legacy_insert(&err) => { - warn!( - "Capture: rich events insert failed against the current schema; retrying legacy insert: {err}" - ); - insert_legacy_event(client, event).await +fn service_event_from_capture_event( + mut event: CaptureEvent, +) -> Result { + let event_id = event + .event_id + .take() + .filter(|event_id| !event_id.trim().is_empty()) + .ok_or_else(|| "event_id is required".to_string())?; + if event_id.len() > 128 { + return Err("event_id exceeds 128 characters".to_string()); + } + let session_id = event + .session_id + .take() + .filter(|session_id| !session_id.trim().is_empty()) + .ok_or_else(|| "session_id is required".to_string())?; + if session_id.len() > 128 { + return Err("session_id exceeds 128 characters".to_string()); + } + + let data = sanitize_capture_data(event.data)?; + Ok(CaptureServiceEvent { + event_id, + sequence_number: event.sequence_number, + schema_version: event.schema_version, + user_id: event.user_id, + session_id, + event_source: event.event_source, + language_id: event.language_id, + file_hash: event.file_hash, + event_type: event.event_type.as_str().to_string(), + client_tz_offset_min: event.client_tz_offset_min, + client_event_time: event.timestamp.to_rfc3339(), + data, + }) +} + +fn sanitize_capture_data(value: serde_json::Value) -> Result { + let serde_json::Value::Object(map) = value else { + return Err("capture event data must be a JSON object".to_string()); + }; + Ok(serde_json::Value::Object(sanitize_capture_object(map))) +} + +fn sanitize_capture_object( + map: serde_json::Map, +) -> serde_json::Map { + const FORBIDDEN_KEYS: &[&str] = &["file_path", "path", "absolute_path", "workspace_path"]; + map.into_iter() + .filter_map(|(key, value)| { + if FORBIDDEN_KEYS.contains(&key.as_str()) { + return None; + } + let value = match value { + serde_json::Value::Object(map) => { + serde_json::Value::Object(sanitize_capture_object(map)) + } + serde_json::Value::Array(values) => serde_json::Value::Array( + values + .into_iter() + .map(|value| match value { + serde_json::Value::Object(map) => { + serde_json::Value::Object(sanitize_capture_object(map)) + } + other => other, + }) + .collect(), + ), + other => other, + }; + Some((key, value)) + }) + .collect() +} + +fn post_capture_batch( + events_url: &str, + token: &str, + body: &[u8], +) -> Result { + post_capture_batch_with_timeout(events_url, token, body, CAPTURE_SERVICE_HTTP_TIMEOUT_SECS) +} + +fn post_capture_batch_with_timeout( + events_url: &str, + token: &str, + body: &[u8], + timeout_secs: u64, +) -> Result { + let response = minreq::post(events_url) + .with_header("Authorization", format!("Bearer {token}")) + .with_header("Content-Type", "application/json") + .with_body(body.to_vec()) + .with_timeout(timeout_secs) + .send() + .map_err(|err| CaptureHttpError::transport(err.to_string()))?; + + if response.status_code != 202 { + return Err(CaptureHttpError::response( + i32::from(response.status_code), + response + .as_str() + .unwrap_or(response.reason_phrase.as_str()) + .to_string(), + )); + } + serde_json::from_slice(response.as_bytes()).map_err(|err| { + CaptureHttpError::response(202, format!("Invalid capture accepted response: {err}")) + }) +} + +fn request_capture_status( + cfg: &CaptureServiceConfig, +) -> Result { + request_capture_status_with_timeout(cfg, CAPTURE_SERVICE_HTTP_TIMEOUT_SECS) +} + +fn request_capture_status_with_timeout( + cfg: &CaptureServiceConfig, + timeout_secs: u64, +) -> Result { + let token = cfg + .token() + .ok_or_else(|| CaptureHttpError::response(401, "Capture token is not configured"))?; + let status_url = cfg + .status_url() + .ok_or_else(|| CaptureHttpError::transport("Capture service URL is not configured"))?; + let response = minreq::get(status_url) + .with_header("Authorization", format!("Bearer {token}")) + .with_timeout(timeout_secs) + .send() + .map_err(|err| CaptureHttpError::transport(err.to_string()))?; + + if response.status_code != 200 { + return Err(CaptureHttpError::response( + i32::from(response.status_code), + response + .as_str() + .unwrap_or(response.reason_phrase.as_str()) + .to_string(), + )); + } + serde_json::from_slice(response.as_bytes()).map_err(|err| { + CaptureHttpError::response(200, format!("Invalid capture status response: {err}")) + }) +} + +fn run_if_capture_config_snapshot_is_current( + config: &Arc>, + snapshot: &CaptureServiceConfig, + action: impl FnOnce(), +) -> bool { + let Ok(current) = config.lock() else { + return false; + }; + if !current.matches_request_snapshot(snapshot) { + return false; + } + action(); + true +} + +fn apply_capture_service_status_if_current( + config: &Arc>, + status: &Arc>, + snapshot: &CaptureServiceConfig, + response: CaptureServiceStatusResponse, +) -> bool { + if let Ok(mut config) = config.lock() { + if !config.matches_request_snapshot(snapshot) { + return false; } - Err(err) => Err(err), + config.participant_id = Some(response.participant_id.clone()); + config.instance_id = Some(response.instance_id.clone()); + update_status(status, |status| { + status.token_status = if response.capture_enabled { + CaptureTokenStatus::Accepted + } else { + CaptureTokenStatus::CaptureDisabled + }; + status.state = if response.capture_enabled { + CaptureState::Remote + } else { + CaptureState::CaptureDisabled + }; + status.participant_id = Some(response.participant_id); + status.instance_id = Some(response.instance_id); + status.capture_enabled = Some(response.capture_enabled); + status.participant_status = Some(response.participant_status); + status.consent_status = Some(response.consent_status); + status.instance_status = Some(response.instance_status); + status.token_expires_at = response.token_expires_at; + status.service_version = Some(response.service_version); + status.last_status_check_at = Some(Utc::now().to_rfc3339()); + status.last_error = if response.capture_enabled { + None + } else { + Some("Capture is disabled by the portal/service".to_string()) + }; + }); + } else { + return false; } + true } -async fn insert_rich_event( - client: &Client, - event: &CaptureEvent, -) -> Result { - let timestamp = event.timestamp.to_rfc3339(); - let data_text = event.data.to_string(); - let event_type = event.event_type.as_str(); - - debug!( - "Capture: executing rich INSERT for user_id={}, event_type={}, timestamp={}", - event.user_id, event_type, timestamp - ); - - client - .execute( - "INSERT INTO events \ - (event_id, sequence_number, schema_version, \ - user_id, session_id, \ - event_source, language_id, file_hash, \ - event_type, timestamp, client_tz_offset_min, data) \ - VALUES \ - ($1, $2, $3, \ - $4, $5, \ - $6, $7, $8, \ - $9, $10::text::timestamptz, $11, $12::text::jsonb)", - &[ - &event.event_id, - &event.sequence_number, - &event.schema_version, - &event.user_id, - &event.session_id, - &event.event_source, - &event.language_id, - &event.file_hash, - &event_type, - ×tamp, - &event.client_tz_offset_min, - &data_text, - ], - ) - .await +fn apply_http_error(status: &Arc>, err: &CaptureHttpError) { + update_status(status, |status| { + status.failed_events += 1; + match err.status_code { + Some(401) => { + status.state = CaptureState::AuthFailed; + status.token_status = CaptureTokenStatus::Rejected; + } + Some(403) => { + status.state = CaptureState::CaptureDisabled; + status.token_status = CaptureTokenStatus::CaptureDisabled; + } + _ if err.is_transient() => { + status.state = CaptureState::ServiceUnavailable; + } + _ => {} + } + status.last_error = Some(err.message.clone()); + }); } -async fn insert_legacy_event( - client: &Client, - event: &CaptureEvent, -) -> Result { - let timestamp = event.timestamp.to_rfc3339(); - let data_text = event.data.to_string(); - let event_type = event.event_type.as_str(); - - debug!( - "Capture: executing legacy INSERT for user_id={}, event_type={}, timestamp={}", - event.user_id, event_type, timestamp - ); - let file_path: Option = None; - - client - .execute( - "INSERT INTO events \ - (user_id, file_path, event_type, timestamp, data) \ - VALUES ($1, $2, $3, $4::text::timestamptz, $5::text::jsonb)", - &[ - &event.user_id, - &file_path, - &event_type, - ×tamp, - &data_text, - ], - ) - .await +fn apply_http_error_if_current( + config: &Arc>, + status: &Arc>, + snapshot: &CaptureServiceConfig, + err: &CaptureHttpError, +) -> bool { + run_if_capture_config_snapshot_is_current(config, snapshot, || { + apply_http_error(status, err); + }) +} + +fn quarantine_path(spool_path: &Path) -> PathBuf { + spool_path.join("quarantine") +} + +fn quarantine_files( + spool_path: &Path, + files: &[PathBuf], + reason: &str, + status: &Arc>, +) { + let quarantine = quarantine_path(spool_path); + if let Err(err) = fs::create_dir_all(&quarantine) { + update_status(status, |status| { + status.failed_events += 1; + status.last_error = Some(format!("Unable to create quarantine directory: {err}")); + }); + return; + } + + for file in files { + let file_name = file + .file_name() + .map(|name| name.to_owned()) + .unwrap_or_else(|| "capture-event.json".into()); + let target = quarantine.join(file_name); + if let Err(err) = fs::rename(file, &target).or_else(|_| { + fs::copy(file, &target)?; + fs::remove_file(file) + }) { + update_status(status, |status| { + status.failed_events += 1; + status.last_error = Some(format!("Unable to quarantine {file:?}: {err}")); + }); + continue; + } + let reason_path = target.with_extension("reason.txt"); + if let Err(err) = fs::write(&reason_path, reason) { + warn!("Capture: unable to write quarantine reason {reason_path:?}: {err}"); + } + update_status(status, |status| { + status.quarantined_events += 1; + status.last_error = Some(reason.to_string()); + }); + } } #[cfg(test)] mod tests { use super::*; use serde_json::json; - use std::{fs, thread, time::Duration}; - - fn valid_capture_config() -> CaptureConfig { - CaptureConfig { - host: "localhost".to_string(), - port: Some(5432), - user: "alice".to_string(), - password: "secret".to_string(), - dbname: "codechat_capture".to_string(), - app_id: None, - fallback_path: None, + use std::{ + fs, + io::{Read, Write}, + net::TcpListener, + thread, + time::{Duration, Instant}, + }; + + fn temp_spool_path(test_name: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "codechat-capture-{test_name}-{}-{}", + std::process::id(), + Utc::now().timestamp_nanos_opt().unwrap_or_default() + )) + } + + fn capture_test_event(user_id: &str, event_id: &str) -> CaptureEvent { + CaptureEvent::with_columns( + Some(event_id.to_string()), + Some(1), + Some(2), + user_id.to_string(), + Some("session-1".to_string()), + Some("vscode_extension".to_string()), + Some("rust".to_string()), + Some("file-hash".to_string()), + CaptureEventType::Save, + Utc::now(), + Some(360), + json!({ "reason": "unit_test" }), + ) + } + + fn capture_service_config(token: &str, generation: u64) -> CaptureServiceConfig { + let mut cfg = CaptureServiceConfig::configured( + "https://capture.example/dev".to_string(), + Some(token.to_string()), + ) + .expect("capture service config should parse"); + cfg.generation = generation; + cfg + } + + fn capture_service_config_with_base_url( + base_url: &str, + token: &str, + generation: u64, + participant_id: &str, + ) -> CaptureServiceConfig { + let mut cfg = + CaptureServiceConfig::configured(base_url.to_string(), Some(token.to_string())) + .expect("capture service config should parse"); + cfg.generation = generation; + cfg.participant_id = Some(participant_id.to_string()); + cfg.instance_id = Some(format!("{participant_id}-instance")); + cfg + } + + fn capture_service_status_response(participant_id: &str) -> CaptureServiceStatusResponse { + CaptureServiceStatusResponse { + participant_id: participant_id.to_string(), + instance_id: format!("{participant_id}-instance"), + study_id: "study-2026".to_string(), + capture_enabled: true, + participant_status: "active".to_string(), + consent_status: "consented".to_string(), + instance_status: "active".to_string(), + token_expires_at: None, + server_time: "2026-07-12T16:10:04Z".to_string(), + service_version: "0.1.0".to_string(), } } - #[test] - fn capture_config_to_conn_str_is_well_formed() { - let cfg = CaptureConfig { - host: "localhost".to_string(), - port: Some(5432), - user: "alice".to_string(), - password: "secret".to_string(), - dbname: "codechat_capture".to_string(), - app_id: Some("spring25-study".to_string()), - fallback_path: Some(PathBuf::from("capture-events-fallback.jsonl")), - }; + fn start_delayed_capture_events_server( + status_code: u16, + body: &'static str, + ) -> ( + String, + std::sync::mpsc::Receiver<()>, + std::sync::mpsc::Sender<()>, + thread::JoinHandle<()>, + ) { + let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind"); + let base_url = format!( + "http://{}", + listener.local_addr().expect("listener should have address") + ); + let (request_seen_tx, request_seen_rx) = std::sync::mpsc::channel(); + let (respond_tx, respond_rx) = std::sync::mpsc::channel(); + let handle = thread::spawn(move || { + let (mut stream, _addr) = listener.accept().expect("request should arrive"); + let mut request = Vec::new(); + let mut buffer = [0; 1024]; + loop { + let read = stream.read(&mut buffer).expect("request should read"); + if read == 0 { + break; + } + request.extend_from_slice(&buffer[..read]); + if request.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + request_seen_tx + .send(()) + .expect("request notification should send"); + respond_rx.recv().expect("response release should arrive"); + let reason = match status_code { + 202 => "Accepted", + 400 => "Bad Request", + 413 => "Payload Too Large", + _ => "Response", + }; + write!( + stream, + "HTTP/1.1 {status_code} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .expect("response should write"); + }); + (base_url, request_seen_rx, respond_tx, handle) + } - let conn = cfg.to_conn_str(); - // Very simple checks: we don't care about ordering beyond what we - // format. - assert!(conn.contains("host=localhost")); - assert!(conn.contains("user=alice")); - assert!(conn.contains("password=secret")); - assert!(conn.contains("dbname=codechat_capture")); - assert!(conn.contains("port=5432")); - assert!(!cfg.redacted_summary().contains("secret")); + fn mark_config_replaced_during_upload( + config: &Arc>, + status: &Arc>, + base_url: &str, + ) { + *config.lock().expect("config lock should not be poisoned") = + capture_service_config_with_base_url(base_url, "new-token", 2, "new-user"); + update_status(status, |status| { + status.state = CaptureState::Spooling; + status.token_status = CaptureTokenStatus::Unverified; + status.participant_id = None; + status.instance_id = None; + status.uploaded_events = 0; + status.quarantined_events = 0; + status.last_upload_at = None; + status.last_error = None; + }); } #[test] @@ -1280,23 +1824,16 @@ mod tests { assert!(ev.file_hash.is_none()); assert_eq!(ev.event_type, CaptureEventType::Save); assert_eq!(ev.data, json!({ "reason": "manual" })); - - // Timestamp sanity check: it should be between before and after assert!(ev.timestamp >= before); assert!(ev.timestamp <= after); } #[test] - fn fallback_only_capture_writes_jsonl() { - let fallback_path = std::env::temp_dir().join(format!( - "codechat-capture-fallback-test-{}-{}.jsonl", - std::process::id(), - Utc::now().timestamp_nanos_opt().unwrap_or_default() - )); - let _ = fs::remove_file(&fallback_path); + fn capture_spool_writes_fifo_json_records() { + let spool_path = temp_spool_path("spool-test"); + let _ = fs::remove_dir_all(&spool_path); - let capture = EventCapture::fallback_only(fallback_path.clone()) - .expect("fallback capture should start"); + let capture = EventCapture::new(spool_path.clone()).expect("capture worker should start"); capture.log(CaptureEvent::with_columns( Some("event-1".to_string()), Some(1), @@ -1314,7 +1851,10 @@ mod tests { let mut text = String::new(); for _ in 0..20 { - if let Ok(contents) = fs::read_to_string(&fallback_path) { + if let Ok(files) = pending_spool_files(&spool_path) + && let Some(path) = files.first() + && let Ok(contents) = fs::read_to_string(path) + { text = contents; if text.contains("\"event_id\":\"event-1\"") { break; @@ -1324,9 +1864,37 @@ mod tests { } assert!(text.contains("\"event_type\":\"save\"")); - assert!(text.contains("\"fallback_timestamp\"")); - assert_eq!(capture.status().state, CaptureState::Fallback); - let _ = fs::remove_file(&fallback_path); + assert!(text.contains("\"spooled_at\"")); + assert_eq!(capture.status().state, CaptureState::Spooling); + let _ = fs::remove_dir_all(&spool_path); + } + + #[test] + fn capture_spool_sanitizes_forbidden_path_keys_before_disk() { + let spool_path = temp_spool_path("spool-sanitize-test"); + let _ = fs::remove_dir_all(&spool_path); + let mut event = capture_test_event("participant", "event-sanitized"); + event.data = json!({ + "file_path": "C:/secret.rs", + "nested": { "path": "/secret" }, + "items": [{ "absolute_path": "/secret2", "ok": true }] + }); + + append_spool_event(&spool_path, &event, None).expect("event should spool"); + let files = pending_spool_files(&spool_path).expect("spool should list"); + let record = read_spool_record(&files[0]).expect("spool record should parse"); + + assert!(record.event.data.get("file_path").is_none()); + assert!(record.event.data.pointer("/nested/path").is_none()); + assert!( + record + .event + .data + .pointer("/items/0/absolute_path") + .is_none() + ); + assert_eq!(record.event.data.pointer("/items/0/ok"), Some(&json!(true))); + let _ = fs::remove_dir_all(&spool_path); } #[test] @@ -1360,274 +1928,373 @@ mod tests { } #[test] - fn capture_config_json_round_trip() { - let json_text = r#" - { - "host": "db.example.com", - "user": "bob", - "port": 5433, - "password": "hunter2", - "dbname": "cc_events", - "app_id": "fall25", - "fallback_path": "capture-events-fallback.jsonl" - } - "#; - - let cfg: CaptureConfig = serde_json::from_str(json_text).expect("JSON should parse"); - assert_eq!(cfg.host, "db.example.com"); - assert_eq!(cfg.port, Some(5433)); - assert_eq!(cfg.user, "bob"); - assert_eq!(cfg.password, "hunter2"); - assert_eq!(cfg.dbname, "cc_events"); - assert_eq!(cfg.app_id.as_deref(), Some("fall25")); + fn capture_service_payload_sanitizes_forbidden_path_keys() { + let ev = CaptureEvent::with_columns( + Some("event-1".to_string()), + Some(1), + Some(2), + "user123".to_string(), + Some("session-1".to_string()), + Some("vscode_extension".to_string()), + Some("rust".to_string()), + Some(hash_capture_path("src/lib.rs")), + CaptureEventType::Save, + Utc::now(), + Some(360), + json!({ + "reason": "manual", + "file_path": "C:/secret.rs", + "nested": { "path": "/secret" }, + "items": [{ "absolute_path": "/secret2", "ok": true }] + }), + ); + let service_event = service_event_from_capture_event(ev).expect("event should convert"); + + assert_eq!(service_event.event_type, "save"); + assert_eq!(service_event.session_id, "session-1"); + assert!(service_event.data.get("file_path").is_none()); + assert!(service_event.data.pointer("/nested/path").is_none()); + assert!( + service_event + .data + .pointer("/items/0/absolute_path") + .is_none() + ); assert_eq!( - cfg.fallback_path.as_deref(), - Some(std::path::Path::new("capture-events-fallback.jsonl")) + service_event.data.pointer("/items/0/ok"), + Some(&json!(true)) ); + } - // And it should serialize back to JSON without error - let _back = serde_json::to_string(&cfg).expect("Should serialize"); + #[test] + fn stale_capture_status_response_does_not_overwrite_current_token_identity() { + let old_snapshot = capture_service_config("old-token", 1); + let new_config = capture_service_config("new-token", 2); + let config = Arc::new(Mutex::new(new_config.clone())); + let status = Arc::new(Mutex::new(CaptureStatus::starting(temp_spool_path( + "stale-status-test", + )))); + + assert!(!apply_capture_service_status_if_current( + &config, + &status, + &old_snapshot, + capture_service_status_response("old-user"), + )); + + let current = config.lock().expect("config lock should not be poisoned"); + assert_eq!(current.token_hash(), new_config.token_hash()); + assert_eq!(current.participant_id, None); + assert_eq!(current.instance_id, None); + drop(current); + + let status = status.lock().expect("status lock should not be poisoned"); + assert_eq!(status.participant_id, None); + assert_ne!(status.token_status, CaptureTokenStatus::Accepted); } #[test] - fn capture_config_rejects_port_zero() { - let cfg = CaptureConfig { - host: "localhost".to_string(), - port: Some(0), - user: "alice".to_string(), - password: "secret".to_string(), - dbname: "codechat_capture".to_string(), - app_id: None, - fallback_path: None, - }; + fn stale_capture_http_error_does_not_mark_current_token_rejected() { + let old_snapshot = capture_service_config("old-token", 1); + let new_config = capture_service_config("new-token", 2); + let config = Arc::new(Mutex::new(new_config)); + let status = Arc::new(Mutex::new(CaptureStatus::starting(temp_spool_path( + "stale-error-test", + )))); + let err = CaptureHttpError::response(401, "old token rejected"); - assert!(cfg.validate().is_err()); + assert!(!apply_http_error_if_current( + &config, + &status, + &old_snapshot, + &err, + )); + + let status = status.lock().expect("status lock should not be poisoned"); + assert_ne!(status.state, CaptureState::AuthFailed); + assert_ne!(status.token_status, CaptureTokenStatus::Rejected); + assert_eq!(status.last_error, None); } #[test] - fn capture_config_rejects_unquoted_conn_str_whitespace() { - let mut cfg = valid_capture_config(); - cfg.host = "db host".to_string(); + fn stale_capture_post_success_does_not_delete_spooled_events() { + let spool_path = temp_spool_path("stale-post-success-test"); + let _ = fs::remove_dir_all(&spool_path); + let (base_url, request_seen_rx, respond_tx, server_handle) = + start_delayed_capture_events_server( + 202, + r#"{"batch_id":"batch-1","accepted":1,"server_time":"2026-07-12T16:10:05Z"}"#, + ); + let old_cfg = capture_service_config_with_base_url(&base_url, "old-token", 1, "old-user"); + append_spool_event( + &spool_path, + &capture_test_event("old-user", "old-post-success-event"), + old_cfg.spool_identity(), + ) + .expect("old event should spool"); + let config = Arc::new(Mutex::new(old_cfg)); + let status = Arc::new(Mutex::new(CaptureStatus::starting(spool_path.clone()))); + update_status(&status, |status| { + status.state = CaptureState::Spooling; + }); + let upload_config = config.clone(); + let upload_status = status.clone(); + let upload_spool_path = spool_path.clone(); + let upload_handle = thread::spawn(move || { + upload_next_batch(&upload_spool_path, &upload_config, &upload_status) + }); + + request_seen_rx + .recv_timeout(Duration::from_secs(2)) + .expect("upload request should start"); + mark_config_replaced_during_upload(&config, &status, &base_url); + respond_tx.send(()).expect("response should release"); + assert_eq!( - cfg.validate().unwrap_err(), - "capture database host must not contain whitespace" + upload_handle.join().expect("upload thread should finish"), + UploadOutcome::Paused, ); - - let mut cfg = valid_capture_config(); - cfg.user = "alice example".to_string(); + server_handle.join().expect("server thread should finish"); assert_eq!( - cfg.validate().unwrap_err(), - "capture database user must not contain whitespace" + pending_spool_files(&spool_path) + .expect("spool should list") + .len(), + 1 ); + let status = status.lock().expect("status lock should not be poisoned"); + assert_eq!(status.state, CaptureState::Spooling); + assert_eq!(status.uploaded_events, 0); + assert_eq!(status.last_upload_at, None); + drop(status); + let _ = fs::remove_dir_all(&spool_path); + } + + #[test] + fn stale_capture_post_validation_failure_does_not_quarantine_spooled_events() { + let spool_path = temp_spool_path("stale-post-validation-test"); + let _ = fs::remove_dir_all(&spool_path); + let (base_url, request_seen_rx, respond_tx, server_handle) = + start_delayed_capture_events_server(400, r#"{"error":{"message":"invalid"}}"#); + let old_cfg = capture_service_config_with_base_url(&base_url, "old-token", 1, "old-user"); + append_spool_event( + &spool_path, + &capture_test_event("old-user", "old-post-validation-event"), + old_cfg.spool_identity(), + ) + .expect("old event should spool"); + let config = Arc::new(Mutex::new(old_cfg)); + let status = Arc::new(Mutex::new(CaptureStatus::starting(spool_path.clone()))); + update_status(&status, |status| { + status.state = CaptureState::Spooling; + }); + let upload_config = config.clone(); + let upload_status = status.clone(); + let upload_spool_path = spool_path.clone(); + let upload_handle = thread::spawn(move || { + upload_next_batch(&upload_spool_path, &upload_config, &upload_status) + }); + + request_seen_rx + .recv_timeout(Duration::from_secs(2)) + .expect("upload request should start"); + mark_config_replaced_during_upload(&config, &status, &base_url); + respond_tx.send(()).expect("response should release"); - let mut cfg = valid_capture_config(); - cfg.password = "secret value".to_string(); assert_eq!( - cfg.validate().unwrap_err(), - "capture database password must not contain whitespace" + upload_handle.join().expect("upload thread should finish"), + UploadOutcome::Paused, + ); + server_handle.join().expect("server thread should finish"); + assert_eq!( + pending_spool_files(&spool_path) + .expect("spool should list") + .len(), + 1 ); - - let mut cfg = valid_capture_config(); - cfg.dbname = "codechat capture".to_string(); assert_eq!( - cfg.validate().unwrap_err(), - "capture database dbname must not contain whitespace" + pending_spool_files(&quarantine_path(&spool_path)) + .expect("quarantine should list") + .len(), + 0 ); + let status = status.lock().expect("status lock should not be poisoned"); + assert_eq!(status.state, CaptureState::Spooling); + assert_eq!(status.quarantined_events, 0); + assert_eq!(status.last_error, None); + drop(status); + let _ = fs::remove_dir_all(&spool_path); } #[test] - fn capture_config_rejects_empty_conn_str_fields() { - let mut cfg = valid_capture_config(); - cfg.host.clear(); + fn capture_batch_skips_spool_records_for_other_tokens() { + let spool_path = temp_spool_path("spool-token-test"); + let _ = fs::remove_dir_all(&spool_path); + + let mut old_cfg = CaptureServiceConfig::configured( + "https://capture.example/dev".to_string(), + Some("old-token".to_string()), + ) + .expect("old config should parse"); + old_cfg.participant_id = Some("old-user".to_string()); + old_cfg.instance_id = Some("old-instance".to_string()); + let mut new_cfg = CaptureServiceConfig::configured( + "https://capture.example/dev".to_string(), + Some("new-token".to_string()), + ) + .expect("new config should parse"); + new_cfg.participant_id = Some("new-user".to_string()); + new_cfg.instance_id = Some("new-instance".to_string()); + + append_spool_event( + &spool_path, + &capture_test_event("old-user", "old-event"), + old_cfg.spool_identity(), + ) + .expect("old event should spool"); + append_spool_event( + &spool_path, + &capture_test_event("new-user", "new-event"), + new_cfg.spool_identity(), + ) + .expect("new event should spool"); + + let status = Arc::new(Mutex::new(CaptureStatus::starting(spool_path.clone()))); + let batch = match build_next_batch(&spool_path, &new_cfg, &status) { + NextBatch::Batch(batch) => batch, + other => panic!("expected matching batch, got {other:?}"), + }; + let body: serde_json::Value = + serde_json::from_slice(&batch.body).expect("batch body should parse"); + + assert_eq!(batch.files.len(), 1); assert_eq!( - cfg.validate().unwrap_err(), - "capture database host must not be empty" + body.pointer("/events/0/event_id"), + Some(&json!("new-event")) ); - - let mut cfg = valid_capture_config(); - cfg.user = " \t".to_string(); + assert_eq!(body.pointer("/events/0/user_id"), Some(&json!("new-user"))); + assert_eq!(body.pointer("/events/1"), None); assert_eq!( - cfg.validate().unwrap_err(), - "capture database user must not be empty" + pending_spool_files(&spool_path) + .expect("spool should list") + .len(), + 2 ); + let _ = fs::remove_dir_all(&spool_path); } - //use tokio::time::{sleep, Duration}; - - /// Integration-style test: verify that EventCapture inserts into the rich - /// capture schema used by dissertation analysis. - /// - /// Reads connection parameters from the repo-root `capture_config.json`. - /// Logs the config and connection details via log4rs so you can confirm - /// what is used. - /// - /// Run this test with: - /// cargo test event\_capture\_inserts\_rich_schema\_event\_into\_db - /// -- --ignored --nocapture - /// - /// You must have a PostgreSQL database and a `capture_config.json` file - /// such as: { "host": "localhost", "user": "codechat\_test\_user", - /// "password": "codechat\_test\_password", "dbname": - /// "codechat\_capture\_test", "app\_id": "integration-test" } - #[tokio::test] - #[ignore] - async fn event_capture_inserts_rich_schema_event_into_db() - -> Result<(), Box> { - // Initialize logging for this test, using the same log4rs.yml as the - // server. If logging is already initialized, this will just return an - // error which we ignore. - let _ = log4rs::init_file("log4rs.yml", Default::default()); - - // 1. Load the capture configuration from file. - let cfg_text = fs::read_to_string("../capture_config.json") - .expect("capture_config.json must exist in the repo root for this test"); - let cfg: CaptureConfig = - serde_json::from_str(&cfg_text).expect("capture_config.json must be valid JSON"); - - log::info!( - "TEST: Loaded DB config from capture_config.json: host={}, user={}, dbname={}, app_id={:?}", - cfg.host, - cfg.user, - cfg.dbname, - cfg.app_id + #[test] + fn capture_batch_reports_when_only_other_token_records_are_pending() { + let spool_path = temp_spool_path("spool-token-mismatch-test"); + let _ = fs::remove_dir_all(&spool_path); + + let old_cfg = CaptureServiceConfig::configured( + "https://capture.example/dev".to_string(), + Some("old-token".to_string()), + ) + .expect("old config should parse"); + let mut new_cfg = CaptureServiceConfig::configured( + "https://capture.example/dev".to_string(), + Some("new-token".to_string()), + ) + .expect("new config should parse"); + new_cfg.participant_id = Some("new-user".to_string()); + + append_spool_event( + &spool_path, + &capture_test_event("old-user", "old-event"), + old_cfg.spool_identity(), + ) + .expect("old event should spool"); + + let status = Arc::new(Mutex::new(CaptureStatus::starting(spool_path.clone()))); + assert!(matches!( + build_next_batch(&spool_path, &new_cfg, &status), + NextBatch::NoMatchingIdentity + )); + assert_eq!( + pending_spool_files(&spool_path) + .expect("spool should list") + .len(), + 1 ); + let _ = fs::remove_dir_all(&spool_path); + } - // 2. Connect directly for setup + verification. - let conn_str = cfg.to_conn_str(); - log::info!("TEST: Attempting direct tokio_postgres connection for verification."); + #[test] + fn capture_batch_does_not_upload_legacy_records_before_identity_known() { + let spool_path = temp_spool_path("spool-legacy-unknown-identity-test"); + let _ = fs::remove_dir_all(&spool_path); - let (client, connection) = tokio_postgres::connect(&conn_str, NoTls).await?; - tokio::spawn(async move { - if let Err(e) = connection.await { - log::error!("TEST: direct connection error: {e}"); - } - }); + let cfg = CaptureServiceConfig::configured( + "https://capture.example/dev".to_string(), + Some("token".to_string()), + ) + .expect("config should parse"); - let required_columns = [ - "event_id", - "sequence_number", - "schema_version", - "session_id", - "event_source", - "language_id", - "file_hash", - "client_tz_offset_min", - ]; - for column in required_columns { - let row = client - .query_one( - r#" - SELECT data_type - FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = 'events' - AND column_name = $1 - "#, - &[&column], - ) - .await - .map_err(|err| { - format!( - "TEST SETUP ERROR: missing public.events.{column}; \ - run server/scripts/capture_events_schema.sql first: {err}" - ) - })?; - let data_type: String = row.get(0); - info!("TEST: public.events.{column} type={data_type}"); - } + append_spool_event( + &spool_path, + &capture_test_event("participant", "legacy-event"), + None, + ) + .expect("legacy event should spool"); - // 4. Start the EventCapture worker using the loaded config. - let capture = EventCapture::new(cfg.clone())?; - log::info!("TEST: EventCapture worker started."); - - // 5. Log a schema-v2 test event with all typed analysis metadata. - let test_suffix = Utc::now().timestamp_millis().to_string(); - let expected_event_id = format!("TEST_EVENT_{test_suffix}"); - let expected_user_id = format!("TEST_USER_{test_suffix}"); - let expected_session_id = format!("TEST_SESSION_{test_suffix}"); - let expected_file_hash = format!("TEST_FILE_HASH_{test_suffix}"); - let event_timestamp = Utc::now(); - let expected_data = json!({ - "chars_typed": 123, - "classification_basis": "integration_test" - }); - let event = CaptureEvent::with_columns( - Some(expected_event_id.clone()), - Some(42), - Some(2), - expected_user_id.clone(), - Some(expected_session_id.clone()), - Some("integration_test".to_string()), - Some("rust".to_string()), - Some(expected_file_hash.clone()), - CaptureEventType::WriteDoc, - event_timestamp, - Some(360), - expected_data.clone(), + let status = Arc::new(Mutex::new(CaptureStatus::starting(spool_path.clone()))); + assert!(matches!( + build_next_batch(&spool_path, &cfg, &status), + NextBatch::NoMatchingIdentity + )); + assert_eq!( + pending_spool_files(&spool_path) + .expect("spool should list") + .len(), + 1 ); + let _ = fs::remove_dir_all(&spool_path); + } - log::info!("TEST: logging a test capture event."); - capture.log(event); - - // 6. Wait (deterministically) for the background worker to insert the event, - // then fetch THAT row (instead of "latest row in the table"). - use tokio::time::{Duration, Instant, sleep}; - - let deadline = Instant::now() + Duration::from_secs(2); - - let row = loop { - match client - .query_one( - r#" - SELECT user_id, event_type, - event_id, sequence_number, schema_version, - session_id, event_source, language_id, file_hash, - client_tz_offset_min, data::text - FROM events - WHERE event_id = $1 - ORDER BY id DESC - LIMIT 1 - "#, - &[&expected_event_id], - ) - .await - { - Ok(row) => break row, // found it - Err(_) => { - if Instant::now() >= deadline { - return Err("Timed out waiting for EventCapture insert".into()); - } - sleep(Duration::from_millis(50)).await; - } + #[test] + fn capture_http_upload_uses_request_timeout() { + let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind"); + let addr = listener.local_addr().expect("listener should have address"); + thread::spawn(move || { + if let Ok((_stream, _addr)) = listener.accept() { + thread::sleep(Duration::from_secs(3)); } - }; + }); - let user_id: String = row.get("user_id"); - let event_type: String = row.get(1); - let event_id: Option = row.get(2); - let sequence_number: Option = row.get(3); - let schema_version: Option = row.get(4); - let session_id: Option = row.get(5); - let event_source: Option = row.get(6); - let language_id: Option = row.get(7); - let file_hash: Option = row.get(8); - let client_tz_offset_min: Option = row.get(9); - let data_text: String = row.get(10); - let data_value: serde_json::Value = serde_json::from_str(&data_text)?; - - assert_eq!(user_id, expected_user_id); - assert_eq!(event_type, CaptureEventType::WriteDoc.as_str()); - assert_eq!(event_id.as_deref(), Some(expected_event_id.as_str())); - assert_eq!(sequence_number, Some(42)); - assert_eq!(schema_version, Some(2)); - assert_eq!(session_id.as_deref(), Some(expected_session_id.as_str())); - assert_eq!(event_source.as_deref(), Some("integration_test")); - assert_eq!(language_id.as_deref(), Some("rust")); - assert_eq!(file_hash.as_deref(), Some(expected_file_hash.as_str())); - assert_eq!(client_tz_offset_min, Some(360)); - assert_eq!(data_value, expected_data); - - log::info!("✅ TEST: EventCapture integration test succeeded and wrote to database."); - Ok(()) + let started = Instant::now(); + let err = post_capture_batch_with_timeout( + &format!("http://{addr}/v1/capture/events"), + "token", + br#"{"events":[]}"#, + 1, + ) + .expect_err("silent local server should time out"); + + assert!(err.status_code.is_none()); + assert!(started.elapsed() < Duration::from_secs(3)); + } + + #[test] + fn service_url_normalization_accepts_dev_base_and_routes() { + assert_eq!( + normalize_service_base_url( + "https://9m2nbv2rvc.execute-api.us-east-2.amazonaws.com/dev/v1/capture/events" + ) + .unwrap(), + "https://9m2nbv2rvc.execute-api.us-east-2.amazonaws.com/dev" + ); + assert_eq!( + normalize_service_base_url("http://localhost:8787/v1/capture/status").unwrap(), + "http://localhost:8787" + ); + assert_eq!( + normalize_service_base_url("http://127.0.0.1:8787/dev/").unwrap(), + "http://127.0.0.1:8787/dev" + ); + assert!(normalize_service_base_url("postgres://example").is_err()); + assert!(normalize_service_base_url("http://capture.example/dev").is_err()); + assert!(normalize_service_base_url("http://localhost.evil/dev").is_err()); + assert!(normalize_service_base_url("https://user:pass@example.com/dev").is_err()); } } diff --git a/server/src/ide.rs b/server/src/ide.rs index b0e54960..02fefc69 100644 --- a/server/src/ide.rs +++ b/server/src/ide.rs @@ -267,6 +267,37 @@ impl CodeChatEditorServer { webserver::capture_status(&self.app_state) } + pub fn configure_capture_service( + &self, + base_url: String, + token: Option, + ) -> Result<(), String> { + self.app_state + .capture + .as_ref() + .ok_or_else(|| "Capture worker is not available".to_string())? + .configure_service(base_url, token) + } + + pub fn clear_capture_token(&self) -> Result<(), String> { + self.app_state + .capture + .as_ref() + .ok_or_else(|| "Capture worker is not available".to_string())? + .clear_token(); + Ok(()) + } + + pub fn check_capture_service_status( + &self, + ) -> Result { + self.app_state + .capture + .as_ref() + .ok_or_else(|| "Capture worker is not available".to_string())? + .check_service_status() + } + // Send a `CurrentFile` message. The other parameter (true if text/false if // binary/None if ignored) is ignored by the server, so it's always sent as // `None`. diff --git a/server/src/translation.rs b/server/src/translation.rs index 6f3ff097..393a22ed 100644 --- a/server/src/translation.rs +++ b/server/src/translation.rs @@ -530,7 +530,7 @@ pub async fn translation_task( EditorMessageContents::Result(_) => continue_loop = tt.ide_result(ide_message).await, EditorMessageContents::Update(_) => continue_loop = tt.ide_update(ide_message).await, EditorMessageContents::Capture(capture_event) => { - // Capture messages affect both DB storage and the + // Capture messages affect both upload spooling and the // translation-layer context used for future // server-classified write events. let control_only = capture_control_only(&capture_event); @@ -1642,7 +1642,7 @@ mod tests { }), )); // A session_end deactivates translated write capture so stale context - // cannot continue inserting DB rows. + // cannot continue generating spooled capture events. assert!( context .capture_event(CaptureEventType::WriteCode, None, serde_json::json!({})) diff --git a/server/src/webserver.rs b/server/src/webserver.rs index c6d71a4c..15681c17 100644 --- a/server/src/webserver.rs +++ b/server/src/webserver.rs @@ -99,7 +99,7 @@ use crate::{ use crate::capture::{ CaptureEvent, CaptureEventWire, CaptureStatus, EventCapture, generate_capture_event_id, - hash_capture_path, load_capture_config, + hash_capture_path, }; use chrono::Utc; @@ -595,7 +595,7 @@ pub fn log_capture_event(app_state: &WebAppState, wire: CaptureEventWire) -> Cap // Prefer hashing a raw local path on the server so all capture // transports use the same path-to-hash rule. The raw path is not stored; // `file_hash` remains only as a backward-compatible/server-originated - // fallback. + // alternative. let file_hash = wire .file_path .as_deref() @@ -1557,27 +1557,20 @@ pub fn configure_logger(level: LevelFilter) -> Result<(), Box) -> WebAppState { - // Initialize event capture from DB config when available; otherwise still - // capture to JSONL fallback so local/debug runs produce inspectable data. + // Initialize capture with a durable local upload spool. The VS Code + // extension supplies the CaptureWebService endpoint and bearer token at + // runtime; this server never reads database credentials from disk or env. let root_path = ROOT_PATH.lock().unwrap().clone(); - let fallback_path = root_path.join("capture-events-fallback.jsonl"); - let capture: Option = match load_capture_config(&root_path) { - Some(cfg) => { - let summary = cfg.redacted_summary(); - match EventCapture::new(cfg) { - Ok(ec) => { - info!("Capture: enabled ({summary})"); - Some(ec) - } - Err(err) => { - warn!( - "Capture: failed to initialize database capture ({summary}); using fallback JSONL: {err}" - ); - EventCapture::fallback_only(fallback_path).ok() - } - } + let capture_spool_path = root_path.join("capture-spool"); + let capture: Option = match EventCapture::new(capture_spool_path.clone()) { + Ok(ec) => { + info!("Capture: enabled with local upload spool at {capture_spool_path:?}"); + Some(ec) + } + Err(err) => { + warn!("Capture: failed to initialize local upload spool: {err}"); + None } - None => EventCapture::fallback_only(fallback_path).ok(), }; web::Data::new(AppState { diff --git a/server/tests/overall_1.rs b/server/tests/overall_1.rs index 291e76b4..4eae324e 100644 --- a/server/tests/overall_1.rs +++ b/server/tests/overall_1.rs @@ -43,8 +43,7 @@ use thirtyfour::{ // ### Local use crate::overall_common::{ CodeChatEditorServerLog, ExpectedMessages, TIMEOUT, assert_no_more_messages, beginning_of_line, - end_of_line, get_version, goto_line, optional_message, perform_loadfile, - select_codechat_iframe, + get_version, goto_line, optional_message, perform_loadfile, select_codechat_iframe, }; use code_chat_editor::{ lexer::supported_languages::MARKDOWN_MODE, @@ -778,7 +777,7 @@ async fn mocha_failure_text(driver: &WebDriver) -> String { make_test!(test_client_updates, test_client_updates_core); -async fn test_updates_core( +async fn test_client_updates_core( codechat_server: CodeChatEditorServerLog, driver: WebDriver, test_dir: PathBuf, From 6d597ae7b9f4d06d469a9731c813922fe94b869e Mon Sep 17 00:00:00 2001 From: "JDS-WORKSTATION\\jspah" <44337821+jspahn80134@users.noreply.github.com> Date: Mon, 13 Jul 2026 18:49:02 -0600 Subject: [PATCH 42/45] Fix VS Code capture lint checks Convert the capture policy test to ESM so extension ESLint no longer rejects require() usage, update the test bundle script, and apply the repo Prettier/ESLint fixes to the capture token code. --- extensions/VSCode/package.json | 2 +- ...policy.test.js => capture-policy.test.mjs} | 15 +++--- extensions/VSCode/src/extension.ts | 54 +++++++++++++------ 3 files changed, 47 insertions(+), 24 deletions(-) rename extensions/VSCode/src/{capture-policy.test.js => capture-policy.test.mjs} (94%) diff --git a/extensions/VSCode/package.json b/extensions/VSCode/package.json index 707a491a..97840bce 100644 --- a/extensions/VSCode/package.json +++ b/extensions/VSCode/package.json @@ -143,7 +143,7 @@ }, "scripts": { "compile": "cargo run --manifest-path=../../builder/Cargo.toml ext-build", - "test:capture-policy": "esbuild src/capture-policy.ts --platform=node --format=cjs --bundle --outfile=.test-output/capture-policy.test.cjs && node --test src/capture-policy.test.js", + "test:capture-policy": "esbuild src/capture-policy.ts --platform=node --format=esm --bundle --outfile=.test-output/capture-policy.test.mjs && node --test src/capture-policy.test.mjs", "vscode:prepublish": "cargo run --manifest-path=../../builder/Cargo.toml ext-build --dist" } } diff --git a/extensions/VSCode/src/capture-policy.test.js b/extensions/VSCode/src/capture-policy.test.mjs similarity index 94% rename from extensions/VSCode/src/capture-policy.test.js rename to extensions/VSCode/src/capture-policy.test.mjs index e3cf34fa..c57a0312 100644 --- a/extensions/VSCode/src/capture-policy.test.js +++ b/extensions/VSCode/src/capture-policy.test.mjs @@ -1,7 +1,7 @@ -const assert = require("node:assert/strict"); -const test = require("node:test"); +import assert from "node:assert/strict"; +import test from "node:test"; -const { +import { captureRefreshStillCurrentSnapshot, captureStatusFailureClearsIdentity, captureTokenCanRecord, @@ -10,7 +10,7 @@ const { captureTokenStatusForStatusFailure, normalizeCaptureServiceBaseUrl, trustedCaptureServiceBaseUrl, -} = require("../.test-output/capture-policy.test.cjs"); +} from "../.test-output/capture-policy.test.mjs"; test("capture service URL normalization strips known routes", () => { assert.equal( @@ -20,7 +20,9 @@ test("capture service URL normalization strips known routes", () => { "https://capture.example/dev", ); assert.equal( - normalizeCaptureServiceBaseUrl("http://localhost:8787/v1/capture/status"), + normalizeCaptureServiceBaseUrl( + "http://localhost:8787/v1/capture/status", + ), "http://localhost:8787", ); }); @@ -39,7 +41,8 @@ test("capture service URL normalization rejects unsafe token destinations", () = /https:\/\/ except for localhost/, ); assert.throws( - () => normalizeCaptureServiceBaseUrl("https://user:pass@example.com/dev"), + () => + normalizeCaptureServiceBaseUrl("https://user:pass@example.com/dev"), /must not include credentials/, ); }); diff --git a/extensions/VSCode/src/extension.ts b/extensions/VSCode/src/extension.ts index ac27c332..9a71dcdf 100644 --- a/extensions/VSCode/src/extension.ts +++ b/extensions/VSCode/src/extension.ts @@ -440,7 +440,11 @@ function captureSettingsState(settings: StudySettings): CaptureSettingsState { ) { return "captureDisabled"; } - if (settings.consentEnabled && settings.enabled && !settings.tokenAccepted) { + if ( + settings.consentEnabled && + settings.enabled && + !settings.tokenAccepted + ) { return "waitingForToken"; } if (settings.consentEnabled && settings.enabled) { @@ -583,7 +587,9 @@ function loadPersistedCaptureIdentity(context: vscode.ExtensionContext): void { ); let currentBaseUrl: string | undefined; try { - currentBaseUrl = normalizeCaptureServiceBaseUrl(captureServiceBaseUrl()); + currentBaseUrl = normalizeCaptureServiceBaseUrl( + captureServiceBaseUrl(), + ); } catch { currentBaseUrl = undefined; } @@ -603,11 +609,13 @@ function loadPersistedCaptureIdentity(context: vscode.ExtensionContext): void { captureTokenRuntimeState = { ...captureTokenRuntimeState, participantId: - optionalString(context.globalState.get(CAPTURE_PARTICIPANT_GLOBAL_KEY)) ?? - "", + optionalString( + context.globalState.get(CAPTURE_PARTICIPANT_GLOBAL_KEY), + ) ?? "", instanceId: - optionalString(context.globalState.get(CAPTURE_INSTANCE_GLOBAL_KEY)) ?? - "", + optionalString( + context.globalState.get(CAPTURE_INSTANCE_GLOBAL_KEY), + ) ?? "", studyId: optionalString(context.globalState.get(CAPTURE_STUDY_GLOBAL_KEY)) ?? "", @@ -649,7 +657,10 @@ async function clearPersistedCaptureIdentity(): Promise { await context.globalState.update(CAPTURE_STUDY_GLOBAL_KEY, undefined); await context.globalState.update(CAPTURE_ENABLED_GLOBAL_KEY, undefined); await context.globalState.update(CAPTURE_TOKEN_HASH_GLOBAL_KEY, undefined); - await context.globalState.update(CAPTURE_SERVICE_BASE_GLOBAL_KEY, undefined); + await context.globalState.update( + CAPTURE_SERVICE_BASE_GLOBAL_KEY, + undefined, + ); } function hashText(value: string): string { @@ -787,14 +798,18 @@ function requestCaptureStatus( reject( new Error( `${statusCode} ${ - body || res.statusMessage || "request failed" + body || + res.statusMessage || + "request failed" }`, ), ); return; } try { - resolve(JSON.parse(body) as CaptureServiceStatusResponse); + resolve( + JSON.parse(body) as CaptureServiceStatusResponse, + ); } catch (err) { reject( new Error( @@ -856,10 +871,7 @@ async function configureRustCaptureService( if (serviceBaseUrl === undefined) { throw new Error("Capture service URL is not configured."); } - codeChatEditorServer.configureCaptureService( - serviceBaseUrl, - token, - ); + codeChatEditorServer.configureCaptureService(serviceBaseUrl, token); return true; } catch (err) { reportCaptureFailure( @@ -925,7 +937,9 @@ async function captureTokenOperationStillCurrent( let currentBaseUrl: string | undefined; if (expectedBaseUrl !== undefined) { try { - currentBaseUrl = normalizeCaptureServiceBaseUrl(captureServiceBaseUrl()); + currentBaseUrl = normalizeCaptureServiceBaseUrl( + captureServiceBaseUrl(), + ); } catch { currentBaseUrl = undefined; } @@ -999,7 +1013,9 @@ async function refreshCaptureTokenStateInner( let expectedBaseUrl: string; try { - expectedBaseUrl = normalizeCaptureServiceBaseUrl(captureServiceBaseUrl()); + expectedBaseUrl = normalizeCaptureServiceBaseUrl( + captureServiceBaseUrl(), + ); } catch (err) { const message = err instanceof Error ? err.message : String(err); const configurationResult = await configureRustCaptureServiceIfCurrent( @@ -1103,7 +1119,11 @@ async function refreshCaptureTokenStateInner( ) { return; } - await applyCaptureServiceStatus(status, expectedTokenHash, expectedBaseUrl); + await applyCaptureServiceStatus( + status, + expectedTokenHash, + expectedBaseUrl, + ); captureLog( `capture token status: ${captureTokenStatusLabel( captureTokenRuntimeState.tokenStatus, @@ -1457,7 +1477,7 @@ async function sendCaptureSettingsChangedEvent( // Prefer the current participant ID, but fall back to the previous value so // turning consent off can still be attributed to the participant who opted // out. - let participantId = current.participantId || previous.participantId; + const participantId = current.participantId || previous.participantId; if (participantId.length === 0) { captureLog( `capture settings change skipped: ${changedSettings.join(",")} (no participant id)`, From 0f07fa33919445f7c6e53a9812c0f042f752aa4f Mon Sep 17 00:00:00 2001 From: "JDS-WORKSTATION\\jspah" <44337821+jspahn80134@users.noreply.github.com> Date: Mon, 13 Jul 2026 19:13:31 -0600 Subject: [PATCH 43/45] Fix overall test fixture lookup --- .../{test_updates => test_client_updates}/test.py | 0 .../{test_updates => test_client_updates}/toc.md | 0 test_utils/src/test_utils.rs | 13 +++++++++++++ 3 files changed, 13 insertions(+) rename server/tests/fixtures/overall_1/{test_updates => test_client_updates}/test.py (100%) rename server/tests/fixtures/overall_1/{test_updates => test_client_updates}/toc.md (100%) diff --git a/server/tests/fixtures/overall_1/test_updates/test.py b/server/tests/fixtures/overall_1/test_client_updates/test.py similarity index 100% rename from server/tests/fixtures/overall_1/test_updates/test.py rename to server/tests/fixtures/overall_1/test_client_updates/test.py diff --git a/server/tests/fixtures/overall_1/test_updates/toc.md b/server/tests/fixtures/overall_1/test_client_updates/toc.md similarity index 100% rename from server/tests/fixtures/overall_1/test_updates/toc.md rename to server/tests/fixtures/overall_1/test_client_updates/toc.md diff --git a/test_utils/src/test_utils.rs b/test_utils/src/test_utils.rs index f764323d..66007a23 100644 --- a/test_utils/src/test_utils.rs +++ b/test_utils/src/test_utils.rs @@ -132,6 +132,13 @@ pub fn prep_test_dir_impl( let source_path_tmp = source_path.clone(); let test_name = source_path_tmp.file_name().unwrap().to_str().unwrap(); source_path.pop(); + let fixture_dir = source_path.join(test_name); + if !fixture_dir.is_dir() { + panic!( + "Missing test fixture directory derived from {test_full_name}: {}", + fixture_dir.to_string_lossy() + ); + } // For debugging, append // [.into\_persistent()](https://docs.rs/assert_fs/latest/assert_fs/fixture/struct.TempDir.html#method.into_persistent). @@ -149,6 +156,12 @@ pub fn prep_test_dir_impl( // This is a path where testing takes place. let test_dir = temp_dir.path().join(test_name); + if !test_dir.is_dir() { + panic!( + "Test fixture copy did not create expected directory: {}", + test_dir.to_string_lossy() + ); + } (temp_dir, test_dir) } From 711aa0e330897bcec0791c86680ee4c463b60195 Mon Sep 17 00:00:00 2001 From: "JDS-WORKSTATION\\jspah" <44337821+jspahn80134@users.noreply.github.com> Date: Mon, 13 Jul 2026 19:35:53 -0600 Subject: [PATCH 44/45] Allow empty test fixture directories --- test_utils/src/test_utils.rs | 46 ++++++++++++++++-------------------- 1 file changed, 21 insertions(+), 25 deletions(-) diff --git a/test_utils/src/test_utils.rs b/test_utils/src/test_utils.rs index 66007a23..8ebfe318 100644 --- a/test_utils/src/test_utils.rs +++ b/test_utils/src/test_utils.rs @@ -20,7 +20,7 @@ // ------- // // ### Standard library -use std::env; +use std::{env, fs}; use std::path::MAIN_SEPARATOR_STR; use std::path::PathBuf; @@ -132,35 +132,31 @@ pub fn prep_test_dir_impl( let source_path_tmp = source_path.clone(); let test_name = source_path_tmp.file_name().unwrap().to_str().unwrap(); source_path.pop(); - let fixture_dir = source_path.join(test_name); - if !fixture_dir.is_dir() { - panic!( - "Missing test fixture directory derived from {test_full_name}: {}", - fixture_dir.to_string_lossy() - ); - } - // For debugging, append // [.into\_persistent()](https://docs.rs/assert_fs/latest/assert_fs/fixture/struct.TempDir.html#method.into_persistent). let temp_dir = TempDir::new().unwrap(); - // Create a temporary directory, then copy everything needed for this test - // to it. Since the `patterns` parameter is a glob, append `/**` to the - // directory to copy to get all files/subdirectories. - if let Err(err) = temp_dir.copy_from(&source_path, &[format!("{test_name}/**")]) { - panic!( - "Unable to copy files from {}{MAIN_SEPARATOR_STR}{}: {err}", - source_path.to_string_lossy(), - test_name - ); - } - // This is a path where testing takes place. let test_dir = temp_dir.path().join(test_name); - if !test_dir.is_dir() { - panic!( - "Test fixture copy did not create expected directory: {}", - test_dir.to_string_lossy() - ); + let fixture_dir = source_path.join(test_name); + if fixture_dir.is_dir() { + // Create a temporary directory, then copy everything needed for this test + // to it. Since the `patterns` parameter is a glob, append `/**` to the + // directory to copy to get all files/subdirectories. + if let Err(err) = temp_dir.copy_from(&source_path, &[format!("{test_name}/**")]) { + panic!( + "Unable to copy files from {}{MAIN_SEPARATOR_STR}{}: {err}", + source_path.to_string_lossy(), + test_name + ); + } + } else { + // Some tests only need an isolated empty directory, with no fixture files. + fs::create_dir_all(&test_dir).unwrap_or_else(|err| { + panic!( + "Unable to create empty test directory {}: {err}", + test_dir.to_string_lossy() + ) + }); } (temp_dir, test_dir) From 89fd55ed4b5123d0a22e28e4c621823c523f4c8b Mon Sep 17 00:00:00 2001 From: "JDS-WORKSTATION\\jspah" <44337821+jspahn80134@users.noreply.github.com> Date: Mon, 13 Jul 2026 19:44:08 -0600 Subject: [PATCH 45/45] Format test utility imports --- test_utils/src/test_utils.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/test_utils/src/test_utils.rs b/test_utils/src/test_utils.rs index 8ebfe318..44541001 100644 --- a/test_utils/src/test_utils.rs +++ b/test_utils/src/test_utils.rs @@ -20,9 +20,10 @@ // ------- // // ### Standard library -use std::{env, fs}; -use std::path::MAIN_SEPARATOR_STR; -use std::path::PathBuf; +use std::{ + env, fs, + path::{MAIN_SEPARATOR_STR, PathBuf}, +}; // ### Third-party use assert_fs::{TempDir, fixture::PathCopy};