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 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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())