diff --git a/.gitignore b/.gitignore index 7f3f076b..c355898c 100644 --- a/.gitignore +++ b/.gitignore @@ -21,5 +21,14 @@ # # dist build output target/ +/server/bindings/ + +# Runtime capture spools and generated capture exports. +/capture-spool/ +/capture-metrics-*.csv +/capture-analysis-*/ +/server/scripts/output +/server/scripts/capture-metrics-*.csv +/server/scripts/capture-analysis-*/ # CodeChat Editor lexer: python. See TODO. diff --git a/README.md b/README.md index 9b41a5fc..33a22e5d 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,42 @@ Install the [CodeChat Editor extension for Visual Studio code](extensions/VSCode/README.md). For developers, see [building from source](docs/design.md). +Research capture +---------------- + +The VS Code extension can record dissertation study capture events when a +participant explicitly opts in. A participant first registers in the capture +portal, which emails a capture token. In VS Code, run **Manage CodeChat Editor +Capture** or **CodeChat Editor: Enter Capture Token** from the command palette, +paste the token, then turn on consent and recording from the same capture +manager. + +The token is imported through the VS Code UI and persisted only in VS Code +SecretStorage. It is never written to workspace settings, repository files, or a +JSON configuration file. The extension asks CaptureWebService for token status; +the status item and capture manager show whether the token is accepted, +rejected, unavailable, or disabled by the portal. The participant ID used in +events comes from that status response, not from the token text. + +CodeChat no longer connects directly to the remote capture database and no +longer reads or stores database credentials. The old local JSON database-secret +configuration path has been removed. Capture events now leave CodeChat only by +calling CaptureWebService with the portal-issued bearer token; any database +writer role remains inside the service deployment. + +Events are sanitized, written to a durable local FIFO spool, then uploaded to +CaptureWebService. Spooled events carry only a non-secret token hash/service +identity so events from an old token are not uploaded under a new token. Offline +recording is allowed only after the same token and service URL have previously +been verified as capture-enabled; a token disabled by the portal remains +disabled while the service is unavailable. If the network or service is +unavailable after the token has been accepted at least once, queued events remain +in the spool and upload as soon as the matching token and service are available +again. The capture service endpoint can be changed in the user-level +`CodeChatEditor.Capture.ServiceBaseUrl` setting; workspace values are ignored +for this token-bearing endpoint. Token-bearing requests require HTTPS except for +localhost development endpoints. + Structure --------- diff --git a/client/src/CodeChatEditor-test.mts b/client/src/CodeChatEditor-test.mts index cf142563..d90e1535 100644 --- a/client/src/CodeChatEditor-test.mts +++ b/client/src/CodeChatEditor-test.mts @@ -45,6 +45,24 @@ import { // From [SO](https://stackoverflow.com/a/39914235). const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); +const RENDER_TIMEOUT_MS = 10000; +const MOCHA_TEST_TIMEOUT_MS = RENDER_TIMEOUT_MS + 5000; + +const waitFor = async ( + description: string, + predicate: () => boolean, + timeoutMs = RENDER_TIMEOUT_MS, +) => { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (predicate()) { + return; + } + await sleep(100); + } + assert.fail(`Timed out waiting for ${description}.`); +}; + // Tests // ----- // @@ -97,11 +115,18 @@ window.CodeChatEditor_test = () => { }); }); - test("GraphViz, Mathjax, Mermaid", async function () { - // Wait for the renderers to run. - await sleep(1500); + test("GraphViz, Mathjax, Mermaid", async function (this: Mocha.Context) { + this.timeout(MOCHA_TEST_TIMEOUT_MS); + // Make sure GraphViz includes a `div` at the top of the shadow // root, with a `svg` inside it. + const getGraphVizRoot = () => + document.getElementsByTagName("graphviz-graph")[0] + ?.shadowRoot?.children[0]; + await waitFor( + "GraphViz SVG", + () => getGraphVizRoot()?.children[0]?.tagName === "svg", + ); const gv = document.getElementsByTagName("graphviz-graph")[0] .shadowRoot!.children[0]; @@ -109,6 +134,13 @@ window.CodeChatEditor_test = () => { assert.equal(gv.children[0].tagName, "svg"); // Mermaid graphs start with a div. + const getMermaidRoot = () => + document.getElementsByTagName("wc-mermaid")[0]?.shadowRoot + ?.children[0]; + await waitFor( + "Mermaid SVG", + () => getMermaidRoot()?.children[0]?.tagName === "svg", + ); const mer = document.getElementsByTagName("wc-mermaid")[0].shadowRoot! .children[0]; @@ -116,6 +148,12 @@ window.CodeChatEditor_test = () => { assert.equal(mer.children[0].tagName, "svg"); // MathJax has its own stuff. + await waitFor( + "MathJax containers", + () => + document.getElementsByTagName("mjx-container") + .length === 2, + ); assert.equal( document.getElementsByTagName("mjx-container").length, 2, diff --git a/client/src/CodeChatEditor.mts b/client/src/CodeChatEditor.mts index f9a0cde6..31509692 100644 --- a/client/src/CodeChatEditor.mts +++ b/client/src/CodeChatEditor.mts @@ -716,6 +716,9 @@ export const on_error = (event: Event) => { let err_str: string; if (event instanceof ErrorEvent) { err_str = `${event.filename}:${event.lineno}: ${event.message}`; + if (event.error?.stack) { + err_str += `\n${event.error.stack}`; + } } else if (event instanceof PromiseRejectionEvent) { const reason = event.reason; let userMessage = "An unexpected error occurred. Please try again."; diff --git a/client/src/shared.mts b/client/src/shared.mts index d3550b0f..caadb971 100644 --- a/client/src/shared.mts +++ b/client/src/shared.mts @@ -43,6 +43,8 @@ import { CodeMirrorDocBlockTuple } from "./rust-types/CodeMirrorDocBlockTuple.js import { UpdateMessageContents } from "./rust-types/UpdateMessageContents.js"; import { ResultOkTypes } from "./rust-types/ResultOkTypes.js"; import { ResultErrTypes } from "./rust-types/ResultErrTypes.js"; +import { CaptureEventWire } from "./rust-types/CaptureEventWire.js"; +import { CaptureStatus } from "./rust-types/CaptureStatus.js"; // Manually define this, since `ts-rs` can't export `webserver.MessageResult`. type MessageResult = { Ok: ResultOkTypes } | { Err: ResultErrTypes }; @@ -55,6 +57,8 @@ export type { CodeMirror, CodeMirrorDiffable, CodeMirrorDocBlockTuple, + CaptureEventWire, + CaptureStatus, CodeChatForWeb, EditorMessage, EditorMessageContents, diff --git a/docs/implementation.md b/docs/implementation.md index 427831c9..0bfee307 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -60,9 +60,46 @@ Inside the client: The entire VSCode interface is contained in the extension, with the NAPI-RS glue in the corresponding library. -Does this make more sense to place in the TOC? Or is it too wordy there? I think -a diagram as an overview might be helpful. Perhaps the server, client, etc. -should have its of readme files providing some of this. +### Capture path + +Dissertation capture is a web-service-only path. The VS Code extension stores +the portal-issued capture token in VS Code SecretStorage after the user enters +it through the capture manager or **CodeChat Editor: Enter Capture Token** +command. The token is never written to settings, workspace files, or a JSON +configuration file. The extension keeps only non-secret participant/instance +metadata in VS Code global state and asks CaptureWebService for token status +before recording user events. The participant ID in each event comes from that +status response. The extension also caches the last capture-enabled decision for +the same token hash and service URL, so offline fallback cannot turn a +portal-disabled token back into a recordable state. + +When consent, recording, and token status allow capture, the extension sends the +same capture event shape to the local CodeChat server through the existing +NAPI-RS bridge. The server hashes raw local file paths, removes any raw path +fields from event data before local persistence, writes every event to a durable +`capture-spool` FIFO directory, and uploads batches to +`POST /v1/capture/events` using +`Authorization: Bearer `. CodeChat does not read database credentials +from disk and does not connect directly to PostgreSQL; the database schema under +`server/scripts/` documents the server-side table used by CaptureWebService. +The previous local JSON database-secret configuration path is intentionally +removed; any database writer role or password must remain server-side in the +CaptureWebService deployment. +The service endpoint is read only from the user/application-level +`CodeChatEditor.Capture.ServiceBaseUrl` setting; workspace values are ignored so +a repository cannot redirect a stored token. Bearer-token requests require +HTTPS, with `http://localhost` and `http://127.0.0.1` allowed only for local +development. + +The upload worker deletes spooled events only after the service returns `202`. +After a token has been accepted at least once, transient service or network +failures still allow local recording and keep events queued for retry. Each +spool record includes a non-secret token hash and service URL identity; the +worker uploads only records matching the currently configured token/service, so +events queued under an old token remain local until that matching token is +configured again. Authentication failures pause upload until a valid token is +entered, malformed or oversized events are moved to the spool quarantine +directory, and blocking service calls use bounded request timeouts. Architecture ------------------------------------------ diff --git a/extensions/VSCode/.gitignore b/extensions/VSCode/.gitignore index 8c5160c5..4d024c8a 100644 --- a/extensions/VSCode/.gitignore +++ b/extensions/VSCode/.gitignore @@ -22,6 +22,7 @@ # NPM node_modules/ out/ +.test-output/ # Server support files log4rs.yml @@ -33,5 +34,5 @@ src/index.d.ts src/index.js src/codechat-editor-client.win32-x64-msvc.node .windows/ - +*.log # CodeChat Editor lexer: python. See TODO. diff --git a/extensions/VSCode/.vscodeignore b/extensions/VSCode/.vscodeignore index cd2f89a4..faaec241 100644 --- a/extensions/VSCode/.vscodeignore +++ b/extensions/VSCode/.vscodeignore @@ -27,6 +27,7 @@ src/** # Omit libraries, since `esbuild` takes care of packaging. node_modules/ +.test-output/** # Omit VSCode config. .vscode/** @@ -41,6 +42,7 @@ target/** # Misc files not needed in a package. .eslintrc.yml .gitignore +out/*.test.cjs build.rs Cargo.lock Cargo.toml diff --git a/extensions/VSCode/Cargo.lock b/extensions/VSCode/Cargo.lock index 2f4bbfb7..c6fd84c6 100644 --- a/extensions/VSCode/Cargo.lock +++ b/extensions/VSCode/Cargo.lock @@ -191,7 +191,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "smallvec", - "socket2 0.6.4", + "socket2 0.6.5", "time", "tracing", "url", @@ -410,17 +410,6 @@ dependencies = [ "walkdir", ] -[[package]] -name = "async-trait" -version = "0.1.89" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.118", -] - [[package]] name = "autocfg" version = "1.5.1" @@ -541,12 +530,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - [[package]] name = "bytes" version = "1.12.1" @@ -576,9 +559,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.66" +version = "1.2.67" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" dependencies = [ "find-msvc-tools", "jobserver", @@ -612,6 +595,7 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", + "serde", "wasm-bindgen", "windows-link", ] @@ -656,12 +640,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" -[[package]] -name = "cmov" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" - [[package]] name = "cobs" version = "0.3.0" @@ -715,10 +693,10 @@ dependencies = [ "regex", "serde", "serde_json", + "sha2", "test_utils", "thiserror", "tokio", - "tokio-postgres", "tracing", "tracing-log", "tracing-subscriber", @@ -958,15 +936,6 @@ version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fb22e947478ccf9dc44d8922042c677a63fbb88f2cb468521d1145816e5087cb" -[[package]] -name = "ctutils" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" -dependencies = [ - "cmov", -] - [[package]] name = "dashmap" version = "5.5.3" @@ -1045,7 +1014,6 @@ dependencies = [ "block-buffer", "const-oid", "crypto-common", - "ctutils", ] [[package]] @@ -1168,12 +1136,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "fallible-iterator" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" - [[package]] name = "fastrand" version = "2.4.1" @@ -1343,7 +1305,7 @@ checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "libc", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", ] [[package]] @@ -1454,15 +1416,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hmac" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" -dependencies = [ - "digest", -] - [[package]] name = "htmd" version = "0.5.4" @@ -1891,15 +1844,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "libredox" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" -dependencies = [ - "libc", -] - [[package]] name = "lightningcss" version = "1.0.0-alpha.71" @@ -2010,7 +1954,7 @@ dependencies = [ "log-mdc", "mock_instant", "parking_lot", - "rand 0.9.4", + "rand 0.9.5", "serde", "serde-value", "serde_json", @@ -2066,16 +2010,6 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" -[[package]] -name = "md-5" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" -dependencies = [ - "cfg-if", - "digest", -] - [[package]] name = "memchr" version = "2.8.3" @@ -2157,13 +2091,13 @@ checksum = "659579df697b372ef9e36f02fcbb41f6d6f157dcec7db9c9618fa0f23cf0fc20" [[package]] name = "mio" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "log", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", "windows-sys 0.61.2", ] @@ -2175,9 +2109,9 @@ checksum = "9bb517913cfcfb9eeda59f36020269075a152701a01606c612f547e4890be399" [[package]] name = "napi" -version = "3.10.3" +version = "3.10.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c71997d6f7ad4a756966e452426848ac27d3b37a295302d63afbbcce0270f93" +checksum = "6826e5ddc15589b2d68c8ad5321c18e85d40488e93e32962f362e572669bccf6" dependencies = [ "bitflags", "ctor", @@ -2197,9 +2131,9 @@ checksum = "c9c366d2c8c60b86fa632df75f745509b52f9128f91a6bad4c796e44abb505e1" [[package]] name = "napi-derive" -version = "3.5.9" +version = "3.5.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4ba572deef53e2c386759a8c2014175a62679d74ff83adc205c8bc0e0285727" +checksum = "b0fe526e81c105d3640516fcde83909dd1afe757c0d7a15af58830b5bc0fb9a1" dependencies = [ "convert_case 0.11.0", "ctor", @@ -2211,9 +2145,9 @@ dependencies = [ [[package]] name = "napi-derive-backend" -version = "5.1.1" +version = "5.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd961eb2aa8965e3f29722d754f3a86907eb1984e2fbcbe3fe87b9a02d6bfba" +checksum = "514281397bcddd9ea9a876c7a21a57bff2374237a000ca9a64ea0211ec1993e2" dependencies = [ "convert_case 0.11.0", "proc-macro2", @@ -2224,9 +2158,9 @@ dependencies = [ [[package]] name = "napi-sys" -version = "3.2.2" +version = "3.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f5bcdf71abd3a50d00b49c1c2c75251cb3c913777d6139cd37dabc093a5e400" +checksum = "73e43cf2eb0bd1bf95a43c07c076ebd2da5d1e015a71c3d201faeffffcc0ecac" dependencies = [ "libloading", ] @@ -2353,15 +2287,6 @@ dependencies = [ "objc2-encode", ] -[[package]] -name = "objc2-core-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" -dependencies = [ - "bitflags", -] - [[package]] name = "objc2-encode" version = "4.1.0" @@ -2378,15 +2303,6 @@ dependencies = [ "objc2", ] -[[package]] -name = "objc2-system-configuration" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" -dependencies = [ - "objc2-core-foundation", -] - [[package]] name = "once_cell" version = "1.21.4" @@ -2428,9 +2344,9 @@ checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" [[package]] name = "oxc-browserslist" -version = "3.0.9" +version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "373741e2febe6df186995b668f295e46fd402ee12f312ea2fda8b3b6312c0885" +checksum = "50a403a3c6be65be7bc7730d07d959ec6c143e4ff8117da485df78cd5582260a" dependencies = [ "miniz_oxide 0.9.1", "postcard", @@ -2965,7 +2881,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared 0.11.3", - "rand 0.8.6", + "rand 0.8.7", ] [[package]] @@ -3078,36 +2994,6 @@ dependencies = [ "serde", ] -[[package]] -name = "postgres-protocol" -version = "0.6.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08808e3c483c46e999108051c78334f473d5adb59d78bb80a1268c7e6aa6c514" -dependencies = [ - "base64", - "byteorder", - "bytes", - "fallible-iterator", - "hmac", - "md-5", - "memchr", - "rand 0.10.2", - "sha2", - "stringprep", -] - -[[package]] -name = "postgres-types" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "851ca9db4932932d69f3ea811b1abe63087a0f740a47692619dd40d4899b68be" -dependencies = [ - "bytes", - "chrono", - "fallible-iterator", - "postgres-protocol", -] - [[package]] name = "potential_utf" version = "0.1.5" @@ -3252,18 +3138,18 @@ checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" [[package]] name = "rand" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ "rand_core 0.6.4", ] [[package]] name = "rand" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha", "rand_core 0.9.5", @@ -3693,9 +3579,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -3738,17 +3624,6 @@ dependencies = [ "quote", ] -[[package]] -name = "stringprep" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" -dependencies = [ - "unicode-bidi", - "unicode-normalization", - "unicode-properties", -] - [[package]] name = "strsim" version = "0.11.1" @@ -3956,7 +3831,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.4", + "socket2 0.6.5", "tokio-macros", "windows-sys 0.61.2", ] @@ -3972,32 +3847,6 @@ dependencies = [ "syn 2.0.118", ] -[[package]] -name = "tokio-postgres" -version = "0.7.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a528f7d280f6d5b9cd149635c8705b0dd049754bc67d81d31fa25169a93809d3" -dependencies = [ - "async-trait", - "byteorder", - "bytes", - "fallible-iterator", - "futures-channel", - "futures-util", - "log", - "parking_lot", - "percent-encoding", - "phf 0.13.1", - "pin-project-lite", - "postgres-protocol", - "postgres-types", - "rand 0.10.2", - "socket2 0.6.4", - "tokio", - "tokio-util", - "whoami", -] - [[package]] name = "tokio-util" version = "0.7.18" @@ -4123,12 +3972,6 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" -[[package]] -name = "unicode-bidi" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" - [[package]] name = "unicode-id-start" version = "1.4.0" @@ -4147,21 +3990,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" -[[package]] -name = "unicode-normalization" -version = "0.1.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" -dependencies = [ - "tinyvec", -] - -[[package]] -name = "unicode-properties" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" - [[package]] name = "unicode-segmentation" version = "1.13.3" @@ -4227,9 +4055,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.4" +version = "1.23.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +checksum = "ea5fab0d6c3c01ae70085a09cb03d4c7a1d6314e2b3e075392783396d724ca0a" dependencies = [ "js-sys", "wasm-bindgen", @@ -4281,15 +4109,6 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasi" -version = "0.14.7+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" -dependencies = [ - "wasip2", -] - [[package]] name = "wasip2" version = "1.0.4+wasi-0.2.12" @@ -4299,15 +4118,6 @@ dependencies = [ "wit-bindgen", ] -[[package]] -name = "wasite" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" -dependencies = [ - "wasi 0.14.7+wasi-0.2.4", -] - [[package]] name = "wasm-bindgen" version = "0.2.126" @@ -4391,19 +4201,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "whoami" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" -dependencies = [ - "libc", - "libredox", - "objc2-system-configuration", - "wasite", - "web-sys", -] - [[package]] name = "winapi" version = "0.3.9" @@ -4831,9 +4628,9 @@ dependencies = [ [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zstd" diff --git a/extensions/VSCode/README.md b/extensions/VSCode/README.md index 195ac000..718cb388 100644 --- a/extensions/VSCode/README.md +++ b/extensions/VSCode/README.md @@ -35,6 +35,30 @@ Running 2. Run the extension again (close the existing window, type `Ctrl+Shift+P` then select Enable the CodeChat Editor). +Study capture +------------- + +Participants who have registered in the capture portal receive a capture token +by email. To use it, run **Manage CodeChat Editor Capture** or **CodeChat +Editor: Enter Capture Token** from the command palette, paste the token, then +turn on consent and recording. The capture status item shows whether the token +is accepted, rejected, unavailable, or disabled by the portal. + +The token is imported through the VS Code UI and persisted only in VS Code +SecretStorage. It is never written to settings, workspace files, or a JSON +configuration file. The participant identity used in capture events comes from +CaptureWebService token status, not from the token text. + +CodeChat sends capture events only to CaptureWebService and does not connect +directly to the capture database. The old JSON database-secret configuration +path is not used by the extension. Events are sanitized and written to a local +FIFO spool before upload, so events recorded offline after the token has been +accepted and capture-enabled upload automatically when the matching service is +reachable again. If the service endpoint changes, update the user-level +`CodeChatEditor.Capture.ServiceBaseUrl` setting. Workspace values are ignored +for this token-bearing endpoint. Token-bearing service requests must use HTTPS, +except for localhost development endpoints. + Additional documentation ------------------------ diff --git a/extensions/VSCode/package.json b/extensions/VSCode/package.json index 286f244a..97840bce 100644 --- a/extensions/VSCode/package.json +++ b/extensions/VSCode/package.json @@ -44,7 +44,11 @@ "version": "0.1.61", "activationEvents": [ "onCommand:extension.codeChatEditorActivate", - "onCommand:extension.codeChatEditorDeactivate" + "onCommand:extension.codeChatEditorDeactivate", + "onCommand:extension.codeChatCaptureStatus", + "onCommand:extension.codeChatCaptureEnterToken", + "onCommand:extension.codeChatCaptureValidateToken", + "onCommand:extension.codeChatCaptureClearToken" ], "contributes": { "configuration": { @@ -62,6 +66,17 @@ "In the default external web browser" ], "markdownDescription": "Select the location of the CodeChat Editor Client. After changing this value, you **must** close then restart the CodeChat Editor extension." + }, + "CodeChatEditor.Capture.ConsentEnabled": { + "type": "boolean", + "default": false, + "markdownDescription": "Record that participant consent has been given for CodeChat dissertation capture. This defaults to off and persists after setting." + }, + "CodeChatEditor.Capture.ServiceBaseUrl": { + "type": "string", + "scope": "application", + "default": "https://9m2nbv2rvc.execute-api.us-east-2.amazonaws.com/dev", + "markdownDescription": "Application-level base URL for CaptureWebService. Workspace settings are ignored for this token-bearing endpoint. Capture tokens are stored separately in VS Code SecretStorage and are never written to settings." } } }, @@ -73,6 +88,26 @@ { "command": "extension.codeChatEditorDeactivate", "title": "Disable the CodeChat Editor" + }, + { + "command": "extension.codeChatCaptureStatus", + "title": "Manage CodeChat Editor Capture" + }, + { + "command": "extension.codeChatCaptureEnterToken", + "title": "CodeChat Editor: Enter Capture Token" + }, + { + "command": "extension.codeChatCaptureValidateToken", + "title": "CodeChat Editor: Validate Capture Token" + }, + { + "command": "extension.codeChatCaptureClearToken", + "title": "CodeChat Editor: Clear Capture Token" + }, + { + "command": "extension.codeChatInsertReflectionPrompt", + "title": "CodeChat Editor: Insert Reflection Prompt" } ] }, @@ -108,6 +143,7 @@ }, "scripts": { "compile": "cargo run --manifest-path=../../builder/Cargo.toml ext-build", + "test:capture-policy": "esbuild src/capture-policy.ts --platform=node --format=esm --bundle --outfile=.test-output/capture-policy.test.mjs && node --test src/capture-policy.test.mjs", "vscode:prepublish": "cargo run --manifest-path=../../builder/Cargo.toml ext-build --dist" } } diff --git a/extensions/VSCode/src/capture-policy.test.mjs b/extensions/VSCode/src/capture-policy.test.mjs new file mode 100644 index 00000000..c57a0312 --- /dev/null +++ b/extensions/VSCode/src/capture-policy.test.mjs @@ -0,0 +1,196 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + captureRefreshStillCurrentSnapshot, + captureStatusFailureClearsIdentity, + captureTokenCanRecord, + captureTokenClearedState, + captureTokenSnapshotStillCurrent, + captureTokenStatusForStatusFailure, + normalizeCaptureServiceBaseUrl, + trustedCaptureServiceBaseUrl, +} from "../.test-output/capture-policy.test.mjs"; + +test("capture service URL normalization strips known routes", () => { + assert.equal( + normalizeCaptureServiceBaseUrl( + "https://capture.example/dev/v1/capture/events/", + ), + "https://capture.example/dev", + ); + assert.equal( + normalizeCaptureServiceBaseUrl( + "http://localhost:8787/v1/capture/status", + ), + "http://localhost:8787", + ); +}); + +test("capture service URL normalization rejects unsafe token destinations", () => { + assert.throws( + () => normalizeCaptureServiceBaseUrl("http://capture.example/dev"), + /https:\/\/ except for localhost/, + ); + assert.throws( + () => normalizeCaptureServiceBaseUrl("http://localhost.evil/dev"), + /https:\/\/ except for localhost/, + ); + assert.throws( + () => normalizeCaptureServiceBaseUrl("postgres://capture.example/dev"), + /https:\/\/ except for localhost/, + ); + assert.throws( + () => + normalizeCaptureServiceBaseUrl("https://user:pass@example.com/dev"), + /must not include credentials/, + ); +}); + +test("offline recording requires a cached capture-enabled token", () => { + assert.equal(captureTokenCanRecord("participant", true, "accepted"), true); + assert.equal( + captureTokenCanRecord("participant", true, "service_unavailable"), + true, + ); + assert.equal( + captureTokenCanRecord("participant", false, "service_unavailable"), + false, + ); + assert.equal( + captureTokenCanRecord("participant", undefined, "service_unavailable"), + false, + ); + assert.equal(captureTokenCanRecord("", true, "accepted"), false); +}); + +test("capture status HTTP failures map to service contract states", () => { + assert.equal(captureTokenStatusForStatusFailure(401), "rejected"); + assert.equal(captureTokenStatusForStatusFailure(403), "capture_disabled"); + assert.equal( + captureTokenStatusForStatusFailure(500), + "service_unavailable", + ); + assert.equal( + captureTokenStatusForStatusFailure(undefined), + "service_unavailable", + ); + assert.equal(captureStatusFailureClearsIdentity(401), true); + assert.equal(captureStatusFailureClearsIdentity(403), true); + assert.equal(captureStatusFailureClearsIdentity(500), false); + assert.equal(captureStatusFailureClearsIdentity(undefined), false); +}); + +test("capture service URL selection ignores workspace values", () => { + assert.equal( + trustedCaptureServiceBaseUrl( + { + globalValue: "https://trusted.example/dev", + workspaceValue: "https://workspace.example/dev", + workspaceFolderValue: "https://folder.example/dev", + }, + "https://default.example/dev", + ), + "https://trusted.example/dev", + ); + assert.equal( + trustedCaptureServiceBaseUrl( + { workspaceValue: "https://workspace.example/dev" }, + "https://default.example/dev", + ), + "https://default.example/dev", + ); +}); + +test("capture state changes invalidate an in-flight refresh", () => { + const inFlightGeneration = 7; + const generationAfterClear = 8; + + assert.equal( + captureRefreshStillCurrentSnapshot( + inFlightGeneration, + generationAfterClear, + "token-hash", + "token-hash", + "https://trusted.example/dev", + "https://trusted.example/dev", + ), + false, + ); + assert.equal( + captureRefreshStillCurrentSnapshot( + generationAfterClear, + generationAfterClear, + undefined, + "token-hash", + "https://trusted.example/dev", + "https://trusted.example/dev", + ), + false, + ); + assert.equal( + captureRefreshStillCurrentSnapshot( + generationAfterClear, + generationAfterClear, + "token-hash", + "token-hash", + "https://other.example/dev", + "https://trusted.example/dev", + ), + false, + ); + assert.deepEqual(captureTokenClearedState(), { + tokenStatus: "missing", + participantId: "", + instanceId: "", + studyId: "", + captureEnabled: false, + lastError: "Capture token is not configured.", + }); +}); + +test("refresh churn does not invalidate a token mutation", () => { + assert.equal( + captureTokenSnapshotStillCurrent( + { mutationGeneration: 3 }, + 3, + 10, + "token-hash", + "token-hash", + "https://trusted.example/dev", + "https://trusted.example/dev", + ), + true, + ); + assert.equal( + captureTokenSnapshotStillCurrent({ mutationGeneration: 3 }, 4, 10), + false, + ); +}); + +test("new refreshes and token mutations invalidate older refreshes", () => { + assert.equal( + captureTokenSnapshotStillCurrent( + { mutationGeneration: 3, refreshGeneration: 7 }, + 3, + 8, + ), + false, + ); + assert.equal( + captureTokenSnapshotStillCurrent( + { mutationGeneration: 3, refreshGeneration: 8 }, + 4, + 8, + ), + false, + ); + assert.equal( + captureTokenSnapshotStillCurrent( + { mutationGeneration: 3, refreshGeneration: 8 }, + 3, + 8, + ), + true, + ); +}); diff --git a/extensions/VSCode/src/capture-policy.ts b/extensions/VSCode/src/capture-policy.ts new file mode 100644 index 00000000..6ab932cf --- /dev/null +++ b/extensions/VSCode/src/capture-policy.ts @@ -0,0 +1,188 @@ +// Copyright (C) 2025 Bryan A. Jones. +// +// This file is part of the CodeChat Editor. + +export type CaptureTokenPolicyStatus = + | "missing" + | "unverified" + | "accepted" + | "rejected" + | "capture_disabled" + | "service_unavailable"; + +export interface CaptureServiceBaseUrlInspection { + globalValue?: unknown; +} + +export interface CaptureTokenClearedState { + tokenStatus: "missing"; + participantId: ""; + instanceId: ""; + studyId: ""; + captureEnabled: false; + lastError: string; +} + +export interface CaptureTokenCurrentnessSnapshot { + mutationGeneration: number; + refreshGeneration?: number; +} + +const CAPTURE_SERVICE_ROUTE_SUFFIXES = [ + "/v1/capture/events", + "/v1/capture/status", + "/v1/health", +]; + +function optionalString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : undefined; +} + +export function trustedCaptureServiceBaseUrl( + inspected: CaptureServiceBaseUrlInspection | undefined, + defaultBaseUrl: string, +): string { + return optionalString(inspected?.globalValue) ?? defaultBaseUrl; +} + +function isLocalHttpCaptureService(url: URL): boolean { + const hostname = url.hostname.toLowerCase(); + return ( + url.protocol === "http:" && + (hostname === "localhost" || + hostname === "127.0.0.1" || + hostname === "::1" || + hostname === "[::1]") + ); +} + +export function normalizeCaptureServiceBaseUrl(value: string): string { + let rawUrl = value.trim().replace(/\/+$/, ""); + if (rawUrl.length === 0) { + throw new Error("capture service URL must not be empty"); + } + + for (const suffix of CAPTURE_SERVICE_ROUTE_SUFFIXES) { + if (rawUrl.endsWith(suffix)) { + rawUrl = rawUrl.slice(0, -suffix.length).replace(/\/+$/, ""); + break; + } + } + + let url: URL; + try { + url = new URL(rawUrl); + } catch { + throw new Error("capture service URL must be an absolute URL"); + } + + if (url.username.length > 0 || url.password.length > 0) { + throw new Error("capture service URL must not include credentials"); + } + if (url.protocol !== "https:" && !isLocalHttpCaptureService(url)) { + throw new Error( + "capture service URL must use https:// except for localhost", + ); + } + + url.hash = ""; + url.search = ""; + url.pathname = url.pathname.replace(/\/+$/, ""); + return url.toString().replace(/\/+$/, ""); +} + +export function captureTokenCanRecord( + participantId: string, + captureEnabled: boolean | undefined, + tokenStatus: CaptureTokenPolicyStatus, +): boolean { + return ( + participantId.length > 0 && + captureEnabled === true && + (tokenStatus === "accepted" || tokenStatus === "service_unavailable") + ); +} + +export function captureTokenStatusForStatusFailure( + statusCode: number | undefined, +): CaptureTokenPolicyStatus { + switch (statusCode) { + case 401: + return "rejected"; + case 403: + return "capture_disabled"; + default: + return "service_unavailable"; + } +} + +export function captureStatusFailureClearsIdentity( + statusCode: number | undefined, +): boolean { + return statusCode === 401 || statusCode === 403; +} + +export function captureRefreshStillCurrentSnapshot( + refreshGeneration: number, + currentGeneration: number, + storedTokenHash?: string, + expectedTokenHash?: string, + currentBaseUrl?: string, + expectedBaseUrl?: string, +): boolean { + return captureTokenSnapshotStillCurrent( + { + mutationGeneration: refreshGeneration, + refreshGeneration, + }, + currentGeneration, + currentGeneration, + storedTokenHash, + expectedTokenHash, + currentBaseUrl, + expectedBaseUrl, + ); +} + +export function captureTokenSnapshotStillCurrent( + snapshot: CaptureTokenCurrentnessSnapshot, + currentMutationGeneration: number, + currentRefreshGeneration: number, + storedTokenHash?: string, + expectedTokenHash?: string, + currentBaseUrl?: string, + expectedBaseUrl?: string, +): boolean { + if (snapshot.mutationGeneration !== currentMutationGeneration) { + return false; + } + if ( + snapshot.refreshGeneration !== undefined && + snapshot.refreshGeneration !== currentRefreshGeneration + ) { + return false; + } + if ( + expectedTokenHash !== undefined && + storedTokenHash !== expectedTokenHash + ) { + return false; + } + if (expectedBaseUrl !== undefined && currentBaseUrl !== expectedBaseUrl) { + return false; + } + return true; +} + +export function captureTokenClearedState(): CaptureTokenClearedState { + return { + tokenStatus: "missing", + participantId: "", + instanceId: "", + studyId: "", + captureEnabled: false, + lastError: "Capture token is not configured.", + }; +} diff --git a/extensions/VSCode/src/extension.ts b/extensions/VSCode/src/extension.ts index a5461cbd..9a71dcdf 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 @@ -40,6 +41,8 @@ import { CodeChatEditorServer, initServer } from "./index.js"; // ### Local packages import { auto_update_timeout_ms, + CaptureEventWire, + CaptureStatus, EditorMessage, EditorMessageContents, KeysOfRustEnum, @@ -53,63 +56,1916 @@ import { console_log, } from "../../../client/src/debug_enabled.mjs"; import { ResultErrTypes } from "../../../client/src/rust-types/ResultErrTypes.js"; +import { + captureStatusFailureClearsIdentity, + captureTokenCanRecord, + captureTokenClearedState, + CaptureTokenCurrentnessSnapshot, + CaptureTokenPolicyStatus, + captureTokenSnapshotStillCurrent, + captureTokenStatusForStatusFailure, + normalizeCaptureServiceBaseUrl, + trustedCaptureServiceBaseUrl, +} from "./capture-policy.js"; + +import * as crypto from "crypto"; +import * as http from "http"; +import * as https from "https"; + +// Globals +// ------- +enum CodeChatEditorClientLocation { + html, + 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"; + +// These globals are truly global: only one is needed for this entire plugin. +// +// Where the webclient resides: `html` for a webview panel embedded in VSCode; +// `browser` to use an external browser. +let codechat_client_location: CodeChatEditorClientLocation = + CodeChatEditorClientLocation.html; +// True if the subscriptions to IDE change notifications have been registered. +let subscribed = false; + +// A unique instance of these variables is required for each CodeChat panel. +// However, this code doesn't have a good UI way to deal with multiple panels, +// so only one is supported at this time. +// +// The webview panel used to display the CodeChat Client +let webview_panel: vscode.WebviewPanel | undefined; +// A timer used to wait for additional events (keystrokes, etc.) before +// performing a render. +let idle_timer: NodeJS.Timeout | undefined; +// The text editor containing the current file. +let current_editor: vscode.TextEditor | undefined; +// True to ignore the next change event, which is produced by applying an +// `Update` from the Client. +let ignore_text_document_change = false; +// True to ignore the next active editor change event, since a `CurrentFile` +// message from the Client caused this change. +let ignore_active_editor_change = false; +// True to ignore the next text selection change, since updates to the cursor or +// scroll position from the Client trigged this change. +let ignore_selection_change = false; +// True to not report the next error. +let quiet_next_error = false; +// True if the editor contents have changed (are dirty) from the perspective of +// the CodeChat Editor (not if the contents are saved to disk). +let is_dirty = false; +// The version of the current file. +let version = 0.0; + +// An object to start/stop the CodeChat Editor Server. +let codeChatEditorServer: CodeChatEditorServer | undefined; +// Before using `CodeChatEditorServer`, we must initialize it. +{ + const ext = vscode.extensions.getExtension( + "CodeChat.codechat-editor-client", + ); + assert(ext !== undefined); + initServer(ext.extensionPath); +} + +// --- +// +// CAPTURE (Dissertation instrumentation) +// -------------------------------------- + +// Capture uses these helpers only for documentation-like files. Source files +// classify directly as code; Markdown/RST get a finer split so prose edits count +// as documentation activity while embedded snippets count as code activity. +function markdownFenceMarker(text: string): "`" | "~" | undefined { + // Markdown fences may be indented up to three spaces. Do not trim, since a + // blockquoted fence (`> ````) should not toggle the outer document state. + const match = /^(?: {0,3})(`{3,}|~{3,})/.exec(text); + if (match === null) { + return undefined; + } + return match[1].startsWith("`") ? "`" : "~"; +} + +function isInMarkdownCodeFence( + doc: vscode.TextDocument, + line: number, +): boolean { + // The fence delimiter itself is Markdown markup, not code content. + if (markdownFenceMarker(doc.lineAt(line).text) !== undefined) { + return false; + } + + let activeFence: "`" | "~" | undefined; + for (let i = 0; i < line; i++) { + const marker = markdownFenceMarker(doc.lineAt(i).text); + if (marker === undefined) { + continue; + } + if (activeFence === undefined) { + activeFence = marker; + } else if (activeFence === marker) { + activeFence = undefined; + } + } + return activeFence !== undefined; +} + +function isInRstCodeBlock(doc: vscode.TextDocument, line: number): boolean { + // Heuristic: find the most recent ".. code-block::" (or "::") and see if + // the current line belongs to its immediately following indented region. + // A later non-indented paragraph closes the region, so don't keep scanning + // past it and accidentally classify later indented prose as code. + const cur = doc.lineAt(line).text; + if (cur.trim().length === 0 || !/^\s+/.test(cur)) { + return false; + } + + for (let i = line - 1; i >= 0; i--) { + const t = doc.lineAt(i).text; + const tt = t.trim(); + if (tt.startsWith(".. code-block::") || tt === "::") { + return true; + } + if (tt.length === 0 || /^\s+/.test(t)) { + continue; + } + return false; + } + return false; +} + +function classifyAtPosition( + doc: vscode.TextDocument, + pos: vscode.Position, +): ActivityKind { + // These helpers are only for documentation-like documents that may embed + // source snippets. Plain source files skip this branch and classify as + // code. + if (DOC_LANG_IDS.has(doc.languageId)) { + if (doc.languageId === "markdown") { + return isInMarkdownCodeFence(doc, pos.line) ? "code" : "doc"; + } + if (doc.languageId === "restructuredtext") { + return isInRstCodeBlock(doc, pos.line) ? "code" : "doc"; + } + // Other doc types: default to doc + return "doc"; + } + return "code"; +} + +// Event-specific payload attached to a capture event. Study metadata such as +// group, course, assignment, and condition is intentionally excluded from the +// student-facing capture settings; analysis can join those values later from a +// researcher-managed participant/date mapping. +type CaptureEventData = Record; + +// Event names are generated from the Rust `CaptureEventType` enum, keeping the +// extension and server in sync without re-declaring the string union here. +type CaptureEventType = CaptureEventWire["event_type"]; + +// Student-facing capture settings. The setup is intentionally small: students +// give local consent, toggle recording, and paste a portal-issued capture +// token. The participant UUID comes from CaptureWebService token status. +interface StudySettings { + // True when the student wants capture enabled for the current work session. + enabled: boolean; + // True after the student has consented to study capture. + consentEnabled: boolean; + // Pseudonymous UUID returned by CaptureWebService token status. + participantId: string; + // True only after CaptureWebService accepts the bearer token and capture is + // allowed for that participant/instance. + tokenAccepted: boolean; + // Non-secret token status used for user-facing capture feedback. + tokenStatus: CaptureTokenUiStatus; +} + +type CaptureTokenUiStatus = CaptureTokenPolicyStatus; + +interface CaptureServiceStatusResponse { + participant_id: string; + instance_id: string; + study_id: string; + capture_enabled: boolean; + participant_status: string; + consent_status: string; + instance_status: string; + token_expires_at?: string | null; + server_time: string; + service_version: string; +} + +interface CaptureTokenRuntimeState { + tokenStatus: CaptureTokenUiStatus; + participantId: string; + instanceId: string; + studyId: string; + captureEnabled?: boolean; + participantStatus?: string; + consentStatus?: string; + instanceStatus?: string; + tokenExpiresAt?: string | null; + serviceVersion?: string; + lastStatusCheckAt?: string; + lastError?: string; +} + +// Derived state for local consent, session recording, and remote token status. +// This is the single source of truth for whether events may be recorded. +type CaptureSettingsState = + | "off" + | "paused" + | "recording" + | "waitingForConsent" + | "waitingForToken" + | "captureDisabled"; + +const CAPTURE_SCHEMA_VERSION = 2; +const CAPTURE_EVENT_SOURCE = "vscode_extension"; +const CAPTURE_SERVICE_DEFAULT_BASE_URL = + "https://9m2nbv2rvc.execute-api.us-east-2.amazonaws.com/dev"; +const CAPTURE_TOKEN_SECRET_KEY = "CodeChatEditor.Capture.Token"; +const CAPTURE_PARTICIPANT_GLOBAL_KEY = + "CodeChatEditor.Capture.ServiceParticipantId"; +const CAPTURE_INSTANCE_GLOBAL_KEY = "CodeChatEditor.Capture.InstanceId"; +const CAPTURE_STUDY_GLOBAL_KEY = "CodeChatEditor.Capture.StudyId"; +const CAPTURE_ENABLED_GLOBAL_KEY = "CodeChatEditor.Capture.CaptureEnabled"; +const CAPTURE_TOKEN_HASH_GLOBAL_KEY = "CodeChatEditor.Capture.TokenHash"; +const CAPTURE_SERVICE_BASE_GLOBAL_KEY = + "CodeChatEditor.Capture.IdentityServiceBaseUrl"; +// Audit label for the user-facing recording toggle. This is intentionally not +// a persisted setting; recording is scoped to the current VS Code window. +const CAPTURE_RECORD_AUDIT_LABEL = "RecordStudyEvents"; +const DEFAULT_REFLECTION_PROMPTS = [ + "What changed in your understanding of this code?", + "What assumption are you making, and how could you test it?", + "What would another developer need to know before maintaining this?", +]; + +// Output channel used for capture diagnostics that should not interrupt normal +// editor use. +let capture_output_channel: vscode.OutputChannel | undefined; +// Extension context provides SecretStorage and global non-secret token metadata. +let extension_context: vscode.ExtensionContext | undefined; +let captureTokenRuntimeState: CaptureTokenRuntimeState = { + tokenStatus: "missing", + participantId: "", + instanceId: "", + studyId: "", +}; +// True after the first failed send is logged to the console, suppressing repeat +// console warnings while still writing detailed failures to the output channel. +let captureFailureLogged = false; +// True once the CodeChat Client and Server have completed enough startup +// handshake work for capture events to be accepted. +let captureTransportReady = false; +// True after a capture-enabled extension session has emitted `session_start`. +let extensionCaptureSessionStarted = false; +// Recording is intentionally scoped to this VS Code extension host session. +// Consent persists in settings, but recording must be +// re-enabled after VS Code restarts and can be toggled independently in each +// open VS Code window. +let sessionRecordStudyEvents = false; +// Monotonic per-extension event sequence number used to order events produced +// by this VS Code session. +let captureSequenceNumber = 0; +// Status bar item that reports capture health and opens the capture controls. +let capture_status_bar_item: vscode.StatusBarItem | undefined; +// Timer used to refresh capture status from the running server. +let capture_status_timer: NodeJS.Timeout | undefined; +// Last capture settings snapshot used to audit user-visible setting changes +// without double-logging when a command and VS Code's configuration event both +// observe the same transition. +let lastCaptureSettings: StudySettings | undefined; +// Token mutations are authoritative. They cancel refresh/status work, but +// refreshes must never cancel an enter or clear mutation. +let captureTokenMutationGeneration = 0; +let captureTokenRefreshGeneration = 0; +// Serialize token work so SecretStorage, persisted identity, and runtime state +// writes happen in a predictable order. Clear still invalidates immediately so +// it can stop Rust uploads ASAP. +let captureTokenOperationQueue: Promise = Promise.resolve(); + +// Simple classification of what the user is currently doing. `doc` means +// prose/documentation activity, whether in a Markdown/RST document or a +// CodeChat doc block; write events from the server provide the more precise +// doc-block classification when it is available. +type ActivityKind = "doc" | "code" | "other"; + +// Language IDs that we treat as "documentation" for the dissertation metrics. +// 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; +// Activity events can be generated by synchronous VS Code callbacks. Serialize +// their async capture sends so doc-session rows stay in causal order. +let captureActivityQueue: Promise = Promise.resolve(); + +function optionalString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : undefined; +} + +function captureServiceBaseUrl(): string { + const inspected = vscode.workspace + .getConfiguration("CodeChatEditor.Capture") + .inspect("ServiceBaseUrl"); + // This endpoint receives bearer tokens. Honor only application/user-level + // settings so a repository cannot redirect a user's stored token. + return trustedCaptureServiceBaseUrl( + inspected, + CAPTURE_SERVICE_DEFAULT_BASE_URL, + ); +} + +function captureTokenStatusLabel(status: CaptureTokenUiStatus): string { + switch (status) { + case "missing": + return "Missing"; + case "unverified": + return "Stored, not verified"; + case "accepted": + return "Accepted"; + case "rejected": + return "Rejected"; + case "capture_disabled": + return "Capture disabled"; + case "service_unavailable": + return "Service unavailable"; + } +} + +function loadStudySettings(): StudySettings { + const config = vscode.workspace.getConfiguration("CodeChatEditor.Capture"); + const participantId = captureTokenRuntimeState.participantId; + const tokenAccepted = captureTokenCanRecord( + participantId, + captureTokenRuntimeState.captureEnabled, + captureTokenRuntimeState.tokenStatus, + ); + return { + // Recording is session-local so capture starts paused in every VS Code + // window/restart. Consent persists in settings; token identity comes + // from CaptureWebService status. + enabled: sessionRecordStudyEvents, + consentEnabled: config.get("ConsentEnabled", false), + participantId, + tokenAccepted, + tokenStatus: captureTokenRuntimeState.tokenStatus, + }; +} + +// Convert raw settings into the explicit four-row state table. Keeping this as +// a separate helper prevents callers from inventing their own partial rules. +function captureSettingsState(settings: StudySettings): CaptureSettingsState { + if ( + settings.tokenStatus === "capture_disabled" && + settings.consentEnabled && + settings.enabled + ) { + return "captureDisabled"; + } + if ( + settings.consentEnabled && + settings.enabled && + !settings.tokenAccepted + ) { + return "waitingForToken"; + } + if (settings.consentEnabled && settings.enabled) { + return "recording"; + } + if (settings.consentEnabled) { + return "paused"; + } + if (settings.enabled) { + return "waitingForConsent"; + } + return "off"; +} + +// Compare complete settings snapshots so command-triggered changes and VS Code +// configuration notifications do not emit duplicate audit rows. +function captureSettingsEqual(a: StudySettings, b: StudySettings): boolean { + return ( + a.enabled === b.enabled && + a.consentEnabled === b.consentEnabled && + a.participantId === b.participantId && + a.tokenAccepted === b.tokenAccepted && + a.tokenStatus === b.tokenStatus + ); +} + +// Human-readable labels used in status-bar tooltips and QuickPick details. +function captureStateDescription(state: CaptureSettingsState): string { + switch (state) { + case "recording": + return "Capture records study events."; + case "paused": + return "Consent is retained, but recording is paused."; + case "waitingForConsent": + return "Capture waits for consent before recording."; + case "waitingForToken": + return "Capture waits for a valid portal token before recording."; + case "captureDisabled": + return "Capture is disabled by the portal or capture service."; + case "off": + return "Capture is off."; + } +} + +// Build the status bar text and tooltip from the same state table used for +// gating events. This keeps UI feedback and recording behavior aligned. +function captureSettingsStatus(settings: StudySettings): { + label: string; + tooltip: string; + state: CaptureSettingsState; +} { + const state = captureSettingsState(settings); + let label: string; + switch (state) { + case "recording": + label = "Capture: Recording"; + break; + case "paused": + label = "Capture: Paused"; + break; + case "waitingForConsent": + label = "Capture: Waiting for consent"; + break; + case "waitingForToken": + label = "Capture: Waiting for token"; + break; + case "captureDisabled": + label = "Capture: Disabled by portal"; + break; + case "off": + label = "Capture: Off"; + break; + } + + return { + label, + state, + tooltip: [ + `Consent Enabled: ${settings.consentEnabled ? "On" : "Off"}`, + `Record Study Events: ${settings.enabled ? "On" : "Off"}`, + `Token: ${captureTokenStatusLabel(settings.tokenStatus)}`, + settings.participantId + ? `Participant ID: ${settings.participantId}` + : "Participant ID: unavailable", + captureTokenRuntimeState.instanceId + ? `Instance ID: ${captureTokenRuntimeState.instanceId}` + : "", + captureTokenRuntimeState.lastError + ? `Capture Service: ${captureTokenRuntimeState.lastError}` + : "", + `State: ${captureStateDescription(state)}`, + ] + .filter((line) => line.length > 0) + .join("\n"), + }; +} + +// Normal capture events are allowed only in the `recording` row. Audit and +// control events can bypass this through explicit send options. +function captureDisabledReason(settings: StudySettings): string | undefined { + const state = captureSettingsState(settings); + if (state !== "recording") { + return captureStateDescription(state); + } + return undefined; +} + +async function updateCaptureSetting( + name: string, + value: string | boolean, +): Promise { + const config = vscode.workspace.getConfiguration("CodeChatEditor.Capture"); + await config.update(name, value, vscode.ConfigurationTarget.Global); +} + +function getExtensionContext(): vscode.ExtensionContext { + if (extension_context === undefined) { + throw new Error("CodeChat extension context is not initialized."); + } + return extension_context; +} + +async function getStoredCaptureToken(): Promise { + return optionalString( + await getExtensionContext().secrets.get(CAPTURE_TOKEN_SECRET_KEY), + ); +} + +async function storeCaptureToken(token: string): Promise { + await getExtensionContext().secrets.store(CAPTURE_TOKEN_SECRET_KEY, token); +} + +async function deleteStoredCaptureToken(): Promise { + await getExtensionContext().secrets.delete(CAPTURE_TOKEN_SECRET_KEY); +} + +function loadPersistedCaptureIdentity(context: vscode.ExtensionContext): void { + const storedBaseUrl = optionalString( + context.globalState.get(CAPTURE_SERVICE_BASE_GLOBAL_KEY), + ); + let currentBaseUrl: string | undefined; + try { + currentBaseUrl = normalizeCaptureServiceBaseUrl( + captureServiceBaseUrl(), + ); + } catch { + currentBaseUrl = undefined; + } + if (storedBaseUrl === undefined || storedBaseUrl !== currentBaseUrl) { + captureTokenRuntimeState = { + ...captureTokenRuntimeState, + participantId: "", + instanceId: "", + studyId: "", + captureEnabled: false, + }; + return; + } + const captureEnabled = context.globalState.get( + CAPTURE_ENABLED_GLOBAL_KEY, + ); + captureTokenRuntimeState = { + ...captureTokenRuntimeState, + participantId: + optionalString( + context.globalState.get(CAPTURE_PARTICIPANT_GLOBAL_KEY), + ) ?? "", + instanceId: + optionalString( + context.globalState.get(CAPTURE_INSTANCE_GLOBAL_KEY), + ) ?? "", + studyId: + optionalString(context.globalState.get(CAPTURE_STUDY_GLOBAL_KEY)) ?? + "", + captureEnabled: + typeof captureEnabled === "boolean" ? captureEnabled : undefined, + }; +} + +async function persistCaptureIdentity( + status: CaptureServiceStatusResponse, + tokenHash: string, + serviceBaseUrl: string, +): Promise { + const context = getExtensionContext(); + await context.globalState.update( + CAPTURE_PARTICIPANT_GLOBAL_KEY, + status.participant_id, + ); + await context.globalState.update( + CAPTURE_INSTANCE_GLOBAL_KEY, + status.instance_id, + ); + await context.globalState.update(CAPTURE_STUDY_GLOBAL_KEY, status.study_id); + await context.globalState.update( + CAPTURE_ENABLED_GLOBAL_KEY, + status.capture_enabled, + ); + await context.globalState.update(CAPTURE_TOKEN_HASH_GLOBAL_KEY, tokenHash); + await context.globalState.update( + CAPTURE_SERVICE_BASE_GLOBAL_KEY, + serviceBaseUrl, + ); +} + +async function clearPersistedCaptureIdentity(): Promise { + const context = getExtensionContext(); + await context.globalState.update(CAPTURE_PARTICIPANT_GLOBAL_KEY, undefined); + await context.globalState.update(CAPTURE_INSTANCE_GLOBAL_KEY, undefined); + await context.globalState.update(CAPTURE_STUDY_GLOBAL_KEY, undefined); + await context.globalState.update(CAPTURE_ENABLED_GLOBAL_KEY, undefined); + await context.globalState.update(CAPTURE_TOKEN_HASH_GLOBAL_KEY, undefined); + await context.globalState.update( + CAPTURE_SERVICE_BASE_GLOBAL_KEY, + undefined, + ); +} + +function hashText(value: string): string { + return crypto.createHash("sha256").update(value).digest("hex"); +} + +function buildFileFields( + filePath: string | undefined, +): Pick { + if (filePath === undefined) { + return { + language_id: vscode.window.activeTextEditor?.document.languageId, + }; + } + const document = get_document(filePath); + return { + // Send the path only to the local Rust server so it can apply the same + // privacy-preserving hash rule used by server-generated capture events. + file_path: filePath, + language_id: document?.languageId, + }; +} + +function captureLog(message: string): void { + capture_output_channel?.appendLine( + `${new Date().toISOString()} ${message}`, + ); +} + +function capturePayloadSummary(payload: CaptureEventWire): string { + return [ + `type=${payload.event_type}`, + `event_id=${payload.event_id}`, + `sequence=${payload.sequence_number?.toString()}`, + `schema=${payload.schema_version}`, + `user_id=${payload.user_id}`, + `session_id=${payload.session_id}`, + `source=${payload.event_source}`, + `language=${payload.language_id ?? ""}`, + payload.file_path ? "file_path=present" : "", + payload.file_hash ? `file_hash=${payload.file_hash}` : "", + ] + .filter((part) => part.length > 0) + .join(" "); +} + +function captureStatusSummary(status: CaptureStatus): string { + return [ + `state=${status.state}`, + `token=${status.token_status}`, + `enabled=${status.enabled}`, + `queued=${status.queued_events}`, + `spooled=${status.spooled_events}`, + `uploaded=${status.uploaded_events}`, + `quarantined=${status.quarantined_events}`, + `failed=${status.failed_events}`, + status.participant_id ? `participant_id=${status.participant_id}` : "", + status.instance_id ? `instance_id=${status.instance_id}` : "", + status.capture_enabled !== null + ? `capture_enabled=${status.capture_enabled}` + : "", + status.service_version + ? `service_version=${status.service_version}` + : "", + status.last_error ? `last_error=${status.last_error}` : "", + status.spool_path ? `spool_path=${status.spool_path}` : "", + ] + .filter((part) => part.length > 0) + .join(" "); +} + +type CaptureStatusJson = Omit< + CaptureStatus, + | "queued_events" + | "spooled_events" + | "uploaded_events" + | "failed_events" + | "quarantined_events" +> & { + queued_events: number; + spooled_events: number; + uploaded_events: number; + failed_events: number; + quarantined_events: number; +}; + +function parseCaptureStatus(json: string): CaptureStatus { + const status = JSON.parse(json) as CaptureStatusJson; + // Rust exports these counters as u64, which ts-rs maps to bigint. JSON + // carries them as numbers, so convert them immediately after parsing to + // keep the runtime value aligned with the generated TypeScript type. + return { + ...status, + queued_events: BigInt(status.queued_events), + spooled_events: BigInt(status.spooled_events), + uploaded_events: BigInt(status.uploaded_events), + failed_events: BigInt(status.failed_events), + quarantined_events: BigInt(status.quarantined_events), + }; +} + +function captureStatusUrl(serviceBaseUrl: string): string { + return `${serviceBaseUrl}/v1/capture/status`; +} + +function leadingHttpStatusCode(message: string): number | undefined { + const match = /^(\d{3})(?:\s|$)/.exec(message); + return match === null ? undefined : Number.parseInt(match[1], 10); +} + +function requestCaptureStatus( + token: string, + serviceBaseUrl: string, +): Promise { + const url = new URL(captureStatusUrl(serviceBaseUrl)); + const client = url.protocol === "http:" ? http : https; + + return new Promise((resolve, reject) => { + const req = client.request( + url, + { + method: "GET", + headers: { + Accept: "application/json", + Authorization: `Bearer ${token}`, + }, + }, + (res) => { + const chunks: Buffer[] = []; + res.on("data", (chunk: Buffer) => chunks.push(chunk)); + res.on("end", () => { + const body = Buffer.concat(chunks).toString("utf8"); + const statusCode = res.statusCode ?? 0; + if (statusCode < 200 || statusCode >= 300) { + reject( + new Error( + `${statusCode} ${ + body || + res.statusMessage || + "request failed" + }`, + ), + ); + return; + } + try { + resolve( + JSON.parse(body) as CaptureServiceStatusResponse, + ); + } catch (err) { + reject( + new Error( + `Unable to parse capture status response: ${ + err instanceof Error + ? err.message + : String(err) + }`, + ), + ); + } + }); + }, + ); + req.setTimeout(10000, () => { + req.destroy(new Error("capture status request timed out")); + }); + req.on("error", reject); + req.end(); + }); +} + +async function applyCaptureServiceStatus( + status: CaptureServiceStatusResponse, + tokenHash: string, + serviceBaseUrl: string, +): Promise { + captureTokenRuntimeState = { + tokenStatus: status.capture_enabled ? "accepted" : "capture_disabled", + participantId: status.participant_id, + instanceId: status.instance_id, + studyId: status.study_id, + captureEnabled: status.capture_enabled, + participantStatus: status.participant_status, + consentStatus: status.consent_status, + instanceStatus: status.instance_status, + tokenExpiresAt: status.token_expires_at, + serviceVersion: status.service_version, + lastStatusCheckAt: new Date().toISOString(), + lastError: status.capture_enabled + ? undefined + : "Capture is disabled by the portal or capture service.", + }; + await persistCaptureIdentity(status, tokenHash, serviceBaseUrl); +} + +async function configureRustCaptureService( + token: string | undefined, + serviceBaseUrl?: string, +): Promise { + if (codeChatEditorServer === undefined) { + return true; + } + try { + if (token === undefined) { + codeChatEditorServer.clearCaptureToken(); + return true; + } + if (serviceBaseUrl === undefined) { + throw new Error("Capture service URL is not configured."); + } + codeChatEditorServer.configureCaptureService(serviceBaseUrl, token); + return true; + } catch (err) { + reportCaptureFailure( + `capture service configuration failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + return false; + } +} + +type CaptureServiceConfigurationResult = "configured" | "stale" | "failed"; + +function beginCaptureTokenMutation(): CaptureTokenCurrentnessSnapshot { + captureTokenMutationGeneration += 1; + captureTokenRefreshGeneration += 1; + return { + mutationGeneration: captureTokenMutationGeneration, + }; +} + +function beginCaptureTokenRefresh(): CaptureTokenCurrentnessSnapshot { + captureTokenRefreshGeneration += 1; + return { + mutationGeneration: captureTokenMutationGeneration, + refreshGeneration: captureTokenRefreshGeneration, + }; +} + +function captureTokenOperationSnapshotIsCurrent( + snapshot: CaptureTokenCurrentnessSnapshot, +): boolean { + return captureTokenSnapshotStillCurrent( + snapshot, + captureTokenMutationGeneration, + captureTokenRefreshGeneration, + ); +} + +function enqueueCaptureTokenOperation( + operation: () => Promise, +): Promise { + const task = captureTokenOperationQueue + .catch(() => undefined) + .then(operation); + captureTokenOperationQueue = task.catch(() => undefined); + return task; +} + +async function captureTokenOperationStillCurrent( + snapshot: CaptureTokenCurrentnessSnapshot, + expectedTokenHash?: string, + expectedBaseUrl?: string, +): Promise { + if (!captureTokenOperationSnapshotIsCurrent(snapshot)) { + return false; + } + let storedTokenHash: string | undefined; + if (expectedTokenHash !== undefined) { + const token = await getStoredCaptureToken(); + storedTokenHash = token === undefined ? undefined : hashText(token); + } + let currentBaseUrl: string | undefined; + if (expectedBaseUrl !== undefined) { + try { + currentBaseUrl = normalizeCaptureServiceBaseUrl( + captureServiceBaseUrl(), + ); + } catch { + currentBaseUrl = undefined; + } + } + return captureTokenSnapshotStillCurrent( + snapshot, + captureTokenMutationGeneration, + captureTokenRefreshGeneration, + storedTokenHash, + expectedTokenHash, + currentBaseUrl, + expectedBaseUrl, + ); +} + +async function configureRustCaptureServiceIfCurrent( + snapshot: CaptureTokenCurrentnessSnapshot, + token: string | undefined, + serviceBaseUrl?: string, + expectedTokenHash?: string, + expectedBaseUrl?: string, +): Promise { + if ( + !(await captureTokenOperationStillCurrent( + snapshot, + expectedTokenHash, + expectedBaseUrl, + )) + ) { + return "stale"; + } + if (!captureTokenOperationSnapshotIsCurrent(snapshot)) { + return "stale"; + } + return (await configureRustCaptureService(token, serviceBaseUrl)) + ? "configured" + : "failed"; +} + +async function refreshCaptureTokenState( + notify: boolean = false, +): Promise { + const operationSnapshot = beginCaptureTokenRefresh(); + await enqueueCaptureTokenOperation(() => + refreshCaptureTokenStateInner(operationSnapshot, notify), + ); +} + +async function refreshCaptureTokenStateInner( + operationSnapshot: CaptureTokenCurrentnessSnapshot, + notify: boolean, +): Promise { + const token = await getStoredCaptureToken(); + if (!(await captureTokenOperationStillCurrent(operationSnapshot))) { + return; + } + + if (token === undefined) { + const configurationResult = await configureRustCaptureServiceIfCurrent( + operationSnapshot, + undefined, + ); + if (configurationResult === "stale") { + return; + } + captureTokenRuntimeState = captureTokenClearedState(); + await clearPersistedCaptureIdentity(); + await refreshCaptureStatus(); + return; + } + + let expectedBaseUrl: string; + try { + expectedBaseUrl = normalizeCaptureServiceBaseUrl( + captureServiceBaseUrl(), + ); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const configurationResult = await configureRustCaptureServiceIfCurrent( + operationSnapshot, + undefined, + ); + if (configurationResult === "stale") { + return; + } + captureTokenRuntimeState = { + ...captureTokenRuntimeState, + tokenStatus: "service_unavailable", + captureEnabled: false, + lastError: message, + }; + captureLog(`capture token status failed: ${message}`); + if (notify) { + vscode.window.showWarningMessage( + `CodeChat capture token could not be verified: ${message}`, + ); + } + await refreshCaptureStatus(); + return; + } + + const expectedTokenHash = hashText(token); + const configurationResult = await configureRustCaptureServiceIfCurrent( + operationSnapshot, + token, + expectedBaseUrl, + expectedTokenHash, + expectedBaseUrl, + ); + if (configurationResult === "stale") { + return; + } + if (configurationResult === "failed") { + captureTokenRuntimeState = { + ...captureTokenRuntimeState, + tokenStatus: "service_unavailable", + captureEnabled: false, + lastError: "Capture service configuration failed.", + }; + await refreshCaptureStatus(); + return; + } + + const context = getExtensionContext(); + const storedTokenHash = optionalString( + context.globalState.get(CAPTURE_TOKEN_HASH_GLOBAL_KEY), + ); + const storedBaseUrl = optionalString( + context.globalState.get(CAPTURE_SERVICE_BASE_GLOBAL_KEY), + ); + if ( + !(await captureTokenOperationStillCurrent( + operationSnapshot, + expectedTokenHash, + expectedBaseUrl, + )) + ) { + return; + } + if ( + storedTokenHash !== expectedTokenHash || + storedBaseUrl !== expectedBaseUrl + ) { + await clearPersistedCaptureIdentity(); + if ( + !(await captureTokenOperationStillCurrent( + operationSnapshot, + expectedTokenHash, + expectedBaseUrl, + )) + ) { + return; + } + captureTokenRuntimeState = { + ...captureTokenRuntimeState, + participantId: "", + instanceId: "", + studyId: "", + captureEnabled: false, + }; + } + + captureTokenRuntimeState = { + ...captureTokenRuntimeState, + tokenStatus: "unverified", + lastError: undefined, + }; + + try { + const status = await requestCaptureStatus(token, expectedBaseUrl); + if ( + !(await captureTokenOperationStillCurrent( + operationSnapshot, + expectedTokenHash, + expectedBaseUrl, + )) + ) { + return; + } + await applyCaptureServiceStatus( + status, + expectedTokenHash, + expectedBaseUrl, + ); + captureLog( + `capture token status: ${captureTokenStatusLabel( + captureTokenRuntimeState.tokenStatus, + )} participant_id=${status.participant_id} instance_id=${status.instance_id}`, + ); + if (notify) { + vscode.window.showInformationMessage( + status.capture_enabled + ? "CodeChat capture token accepted." + : "CodeChat capture is disabled by the portal.", + ); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const statusCode = leadingHttpStatusCode(message); + if ( + !(await captureTokenOperationStillCurrent( + operationSnapshot, + expectedTokenHash, + expectedBaseUrl, + )) + ) { + return; + } + const clearIdentity = captureStatusFailureClearsIdentity(statusCode); + if (clearIdentity) { + await clearPersistedCaptureIdentity(); + if ( + !(await captureTokenOperationStillCurrent( + operationSnapshot, + expectedTokenHash, + expectedBaseUrl, + )) + ) { + return; + } + } + const tokenStatus = captureTokenStatusForStatusFailure(statusCode); + captureTokenRuntimeState = { + ...(clearIdentity + ? { + participantId: "", + instanceId: "", + studyId: "", + captureEnabled: false, + } + : captureTokenRuntimeState), + tokenStatus, + lastError: message, + }; + captureLog(`capture token status failed: ${message}`); + if (notify) { + vscode.window.showWarningMessage( + `CodeChat capture token could not be verified: ${message}`, + ); + } + } + + await refreshCaptureStatus(); +} + +interface CaptureSendOptions { + // Permit audit/control events even when normal capture is paused or waiting + // for consent. + ignoreCaptureSettings?: boolean; + // Update server-side capture state without spooling this event for upload. + controlOnly?: boolean; + // Explicit active flag carried to the server so it can enable/disable + // translation-generated write events. + captureActive?: boolean; + // Audit rows for consent being turned off still need the participant ID + // that existed before the setting changed. + userId?: string; +} + +// Helper to send a capture event to the Rust server. +async function sendCaptureEvent( + eventType: CaptureEventType, + filePath?: string, + data: CaptureEventData = {}, + options: CaptureSendOptions = {}, +): Promise { + const settings = loadStudySettings(); + const disabledReason = captureDisabledReason(settings); + // User activity events stop here unless both consent and recording are on. + if (!options.ignoreCaptureSettings && disabledReason !== undefined) { + captureLog(`capture skipped: ${eventType} (${disabledReason})`); + const status = captureSettingsStatus(settings); + updateCaptureStatusBar(status.label, status.tooltip); + return; + } + // Control-only messages may run after consent is off, so they must not + // generate a fresh participant ID. + const participantId = options.userId + ? options.userId + : options.controlOnly + ? settings.participantId || "capture_control" + : settings.participantId; + if (!options.controlOnly && participantId.length === 0) { + captureLog(`capture skipped: ${eventType} (no verified capture token)`); + updateCaptureStatusBar( + "Capture: Waiting for token", + "Enter and verify a portal-issued capture token before recording.", + ); + return; + } + const fileFields = buildFileFields(filePath); + // The server uses `capture_active` to decide whether it may generate + // classified write_doc/write_code rows from translated edits. + const captureActive = + options.captureActive ?? + (eventType !== "session_end" && + captureSettingsState(settings) === "recording"); + const payload: CaptureEventWire = { + event_id: crypto.randomUUID(), + sequence_number: BigInt(++captureSequenceNumber), + schema_version: CAPTURE_SCHEMA_VERSION, + user_id: participantId, + session_id: CAPTURE_SESSION_ID, + event_source: CAPTURE_EVENT_SOURCE, + ...fileFields, + event_type: eventType, + client_tz_offset_min: new Date().getTimezoneOffset(), + data: { + ...data, + capture_active: captureActive, + // A control-only event updates the server's capture context but is + // intentionally not inserted into capture storage. + ...(options.controlOnly ? { capture_control_only: true } : {}), + }, + }; + + if (codeChatEditorServer === undefined) { + captureLog( + `capture skipped: ${capturePayloadSummary(payload)} (server not running)`, + ); + reportCaptureFailure("CodeChat server is not running"); + return; + } + if (!captureTransportReady) { + captureLog( + `capture skipped before server handshake: ${capturePayloadSummary(payload)}`, + ); + return; + } + + try { + const messageId = await codeChatEditorServer.sendCaptureEvent( + stringifyCapturePayload(payload), + ); + captureFailureLogged = false; + captureLog( + `${options.controlOnly ? "capture control queued" : "capture queued"} message_id=${messageId}: ${capturePayloadSummary(payload)}`, + ); + await refreshCaptureStatus(); + } catch (err) { + reportCaptureFailure(err instanceof Error ? err.message : String(err)); + } +} + +function stringifyCapturePayload(payload: CaptureEventWire): string { + return JSON.stringify(payload, (_key, value) => + typeof value === "bigint" ? Number(value) : value, + ); +} + +function reportCaptureFailure(message: string) { + captureLog(`capture send failed: ${message}`); + updateCaptureStatusBar("Capture: Error", message); + if (captureFailureLogged) { + return; + } + captureFailureLogged = true; + console.warn(`CodeChat capture event was not queued: ${message}`); +} + +function updateCaptureStatusBar(text: string, tooltip?: string) { + if (capture_status_bar_item === undefined) { + return; + } + capture_status_bar_item.text = text; + capture_status_bar_item.tooltip = tooltip; + capture_status_bar_item.show(); +} + +async function refreshCaptureStatus(): Promise { + const settings = loadStudySettings(); + const settingsStatus = captureSettingsStatus(settings); + // When the settings are not in the recording row, the settings state is the + // authoritative status regardless of the server upload worker state. + if (settingsStatus.state !== "recording") { + updateCaptureStatusBar(settingsStatus.label, settingsStatus.tooltip); + return; + } + if (codeChatEditorServer === undefined) { + updateCaptureStatusBar( + "Capture: Waiting", + `${settingsStatus.tooltip}\nServer: CodeChat server is not running`, + ); + return; + } + + try { + const status = parseCaptureStatus( + codeChatEditorServer.getCaptureStatus(), + ); + let label: string; + switch (status.state) { + case "remote": + label = "Capture: Remote"; + break; + case "spooling": + label = "Capture: Queued"; + break; + case "uploading": + label = "Capture: Uploading"; + break; + case "starting": + label = "Capture: Starting"; + break; + case "auth_failed": + label = "Capture: Token rejected"; + break; + case "capture_disabled": + label = "Capture: Disabled by portal"; + break; + case "service_unavailable": + label = "Capture: Service unavailable"; + break; + default: + label = "Capture: Off"; + break; + } + updateCaptureStatusBar( + label, + [ + settingsStatus.tooltip, + captureStatusSummary(status).split(" ").join("\n"), + ].join("\n"), + ); + } catch (err) { + updateCaptureStatusBar( + "Capture: Error", + err instanceof Error ? err.message : String(err), + ); + } +} + +// A status-bar QuickPick action. Each item owns the async work needed after the +// student chooses it, keeping the capture UI small and easy to scan. +interface CaptureStatusAction extends vscode.QuickPickItem { + run: () => Promise; +} + +function captureStatusDetails(): string { + const tooltip = capture_status_bar_item?.tooltip; + return typeof tooltip === "string" + ? tooltip + : (tooltip?.value ?? "Capture status unavailable"); +} + +async function setRecordStudyEvents(enabled: boolean): Promise { + // Save the previous settings before updating so the audit event can record + // exactly what changed. + const previousSettings = loadStudySettings(); + sessionRecordStudyEvents = enabled; + await reconcileCaptureSettings( + "manage_capture_record_study_events", + previousSettings, + ); + + const updatedSettings = loadStudySettings(); + if (enabled && captureSettingsState(updatedSettings) === "recording") { + vscode.window.showInformationMessage( + "CodeChat capture is recording study events.", + ); + } else if (enabled) { + vscode.window.showInformationMessage( + captureStateDescription(captureSettingsState(updatedSettings)), + ); + } else { + vscode.window.showInformationMessage( + "CodeChat capture recording is paused.", + ); + } +} + +async function setCaptureConsent(enabled: boolean): Promise { + // Save the previous settings before updating so the audit event can record + // consent transitions, including consent being turned off. + const previousSettings = loadStudySettings(); + + await updateCaptureSetting("ConsentEnabled", enabled); + await reconcileCaptureSettings( + "manage_capture_consent_enabled", + previousSettings, + ); + + const updatedSettings = loadStudySettings(); + if (enabled && captureSettingsState(updatedSettings) === "recording") { + vscode.window.showInformationMessage( + "CodeChat capture consent is recorded and recording is on.", + ); + } else if (enabled) { + vscode.window.showInformationMessage( + "CodeChat capture consent is recorded.", + ); + } else { + vscode.window.showInformationMessage( + "CodeChat capture consent is off.", + ); + } +} + +async function giveConsentAndRecordStudyEvents(): Promise { + // This command intentionally changes both user-facing settings together, + // then lets the common reconcile path emit one combined audit event. + const previousSettings = loadStudySettings(); + + await updateCaptureSetting("ConsentEnabled", true); + sessionRecordStudyEvents = true; + await reconcileCaptureSettings( + "manage_capture_give_consent_and_record", + previousSettings, + ); + vscode.window.showInformationMessage( + "CodeChat capture consent is recorded and recording is on.", + ); +} + +async function sendCaptureSettingsChangedEvent( + previous: StudySettings, + current: StudySettings, + changedBy: string, + filePath?: string, +): Promise { + // Only the consent and recording checkboxes are study-state transitions. + // Other capture settings, such as path hashing, should not create audit + // rows in the dissertation event stream. + const changedSettings: string[] = []; + if (previous.consentEnabled !== current.consentEnabled) { + changedSettings.push("ConsentEnabled"); + } + if (previous.enabled !== current.enabled) { + changedSettings.push(CAPTURE_RECORD_AUDIT_LABEL); + } + if (changedSettings.length === 0) { + return; + } -// Globals -// ------- -enum CodeChatEditorClientLocation { - html, - browser, + // Prefer the current participant ID, but fall back to the previous value so + // turning consent off can still be attributed to the participant who opted + // out. + const participantId = current.participantId || previous.participantId; + if (participantId.length === 0) { + captureLog( + `capture settings change skipped: ${changedSettings.join(",")} (no participant id)`, + ); + return; + } + + const previousState = captureSettingsState(previous); + const currentState = captureSettingsState(current); + // This audit event is deliberately allowed even when capture is no longer + // active, because the transition itself is analytically important. + await sendCaptureEvent( + "capture_settings_changed", + filePath, + { + changed_by: changedBy, + changed_settings: changedSettings, + previous_state: previousState, + new_state: currentState, + previous_consent_enabled: previous.consentEnabled, + new_consent_enabled: current.consentEnabled, + previous_record_study_events: previous.enabled, + new_record_study_events: current.enabled, + capture_active_before: previousState === "recording", + capture_active_after: currentState === "recording", + }, + { + ignoreCaptureSettings: true, + captureActive: currentState === "recording", + userId: participantId, + }, + ); } -// True on Windows, false on OS X / Linux. -const is_windows = process.platform === "win32"; +async function reconcileCaptureSettings( + changedBy: string = "settings_ui", + previousSettings?: StudySettings, +): Promise { + const active = vscode.window.activeTextEditor; + const filePath = active?.document.fileName; + const settings = loadStudySettings(); + // The first reconciliation after activation uses the snapshot captured at + // activation; command callers may also provide the pre-change snapshot. + const previous = lastCaptureSettings ?? previousSettings; -// These globals are truly global: only one is needed for this entire plugin. -// -// Where the webclient resides: `html` for a webview panel embedded in VSCode; -// `browser` to use an external browser. -let codechat_client_location: CodeChatEditorClientLocation = - CodeChatEditorClientLocation.html; -// True if the subscriptions to IDE change notifications have been registered. -let subscribed = false; + // Commands update settings and VS Code then fires a configuration event. + // This guard keeps the capture audit trail to one row per actual transition. + if ( + lastCaptureSettings !== undefined && + captureSettingsEqual(lastCaptureSettings, settings) + ) { + await refreshCaptureStatus(); + return; + } -// A unique instance of these variables is required for each CodeChat panel. -// However, this code doesn't have a good UI way to deal with multiple panels, -// so only one is supported at this time. -// -// The webview panel used to display the CodeChat Client -let webview_panel: vscode.WebviewPanel | undefined; -// A timer used to wait for additional events (keystrokes, etc.) before -// performing a render. -let idle_timer: NodeJS.Timeout | undefined; -// The text editor containing the current file. -let current_editor: vscode.TextEditor | undefined; -// True to ignore the next change event, which is produced by applying an -// `Update` from the Client. -let ignore_text_document_change = false; -// True to ignore the next active editor change event, since a `CurrentFile` -// message from the Client caused this change. -let ignore_active_editor_change = false; -// True to ignore the next text selection change, since updates to the cursor or -// scroll position from the Client trigged this change. -let ignore_selection_change = false; -// True to not report the next error. -let quiet_next_error = false; -// True if the editor contents have changed (are dirty) from the perspective of -// the CodeChat Editor (not if the contents are saved to disk). -let is_dirty = false; -// The version of the current file. -let version = 0.0; + // Write the audit row before changing the server active flag, so turning + // capture off records the transition but not any later edit events. + if (previous !== undefined) { + await sendCaptureSettingsChangedEvent( + previous, + settings, + changedBy, + filePath, + ); + } -// An object to start/stop the CodeChat Editor Server. -let codeChatEditorServer: CodeChatEditorServer | undefined; -// Before using `CodeChatEditorServer`, we must initialize it. -{ - const ext = vscode.extensions.getExtension( - "CodeChat.codechat-editor-client", + const updatedSettings = loadStudySettings(); + // Recording starts only when both checkboxes are on. + if (captureSettingsState(updatedSettings) === "recording") { + await startExtensionCaptureSession(filePath); + } else if ( + // If capture was active before this transition, send a control-only stop + // so the Rust translation layer stops emitting write_doc/write_code + // events from stale context. + extensionCaptureSessionStarted || + (previous !== undefined && + captureSettingsState(previous) === "recording") + ) { + await endExtensionCaptureSession(filePath, changedBy, { + controlOnly: true, + }); + } else { + // A stop-control is harmless when a server is present and keeps the + // server context inactive after settings-only transitions. + await sendCaptureStopControl(filePath, changedBy); + } + + // Refresh the dedupe snapshot after any participant ID generation or audit + // send that may have touched settings. + lastCaptureSettings = loadStudySettings(); + await refreshCaptureStatus(); +} + +async function copyParticipantId(): Promise { + const participantId = captureTokenRuntimeState.participantId; + if (participantId.length === 0) { + vscode.window.showWarningMessage( + "Enter and verify a capture token before copying a participant ID.", + ); + return; + } + await vscode.env.clipboard.writeText(participantId); + vscode.window.showInformationMessage( + "CodeChat capture participant ID copied.", ); - assert(ext !== undefined); - initServer(ext.extensionPath); +} + +async function enterCaptureToken(): Promise { + const previousSettings = loadStudySettings(); + const token = await vscode.window.showInputBox({ + title: "Enter CodeChat Capture Token", + prompt: "Paste the portal-issued capture token.", + password: true, + ignoreFocusOut: true, + validateInput: (value) => + value.trim().length === 0 ? "Capture token is required." : null, + }); + if (token === undefined) { + return; + } + const operationSnapshot = beginCaptureTokenMutation(); + const tokenText = token.trim(); + let mutationApplied = false; + await enqueueCaptureTokenOperation(async () => { + if (!(await captureTokenOperationStillCurrent(operationSnapshot))) { + return; + } + await storeCaptureToken(tokenText); + if (!(await captureTokenOperationStillCurrent(operationSnapshot))) { + return; + } + captureTokenRuntimeState = { + ...captureTokenRuntimeState, + tokenStatus: "unverified", + lastError: undefined, + }; + mutationApplied = true; + }); + if ( + mutationApplied && + captureTokenOperationSnapshotIsCurrent(operationSnapshot) + ) { + await refreshCaptureTokenState(true); + } + await reconcileCaptureSettings( + "manage_capture_enter_token", + previousSettings, + ); +} + +async function clearCaptureToken(): Promise { + const operationSnapshot = beginCaptureTokenMutation(); + // Reserve clear's queue position before awaiting Rust reconfiguration so + // later refreshes cannot observe the pre-clear token as current state. + const clearTask = enqueueCaptureTokenOperation(async () => { + if (!(await captureTokenOperationStillCurrent(operationSnapshot))) { + return; + } + await deleteStoredCaptureToken(); + if (!(await captureTokenOperationStillCurrent(operationSnapshot))) { + return; + } + await clearPersistedCaptureIdentity(); + if (!(await captureTokenOperationStillCurrent(operationSnapshot))) { + return; + } + captureTokenRuntimeState = captureTokenClearedState(); + await reconcileCaptureSettings("manage_capture_clear_token"); + vscode.window.showInformationMessage("CodeChat capture token cleared."); + }); + await Promise.all([ + clearTask, + configureRustCaptureServiceIfCurrent(operationSnapshot, undefined), + ]); +} + +async function validateCaptureToken(): Promise { + const previousSettings = loadStudySettings(); + await refreshCaptureTokenState(true); + await reconcileCaptureSettings( + "manage_capture_validate_token", + previousSettings, + ); +} + +async function showCaptureStatus(): Promise { + await refreshCaptureStatus(); + const settings = loadStudySettings(); + const settingsStatus = captureSettingsStatus(settings); + // The QuickPick exposes the same two independent switches as Settings, plus + // one convenience action that turns both on at once. + const actions: CaptureStatusAction[] = [ + { + label: "Show Current Capture State", + description: captureStateDescription(settingsStatus.state), + detail: settingsStatus.tooltip, + run: async () => { + captureLog(`capture status: ${settingsStatus.tooltip}`); + vscode.window.showInformationMessage(settingsStatus.tooltip); + }, + }, + ]; + + if (!settings.consentEnabled || !settings.enabled) { + actions.push({ + label: "Give Consent and Record Study Events", + description: "Turn both capture settings on.", + run: giveConsentAndRecordStudyEvents, + }); + } + + actions.push({ + label: "Enter Capture Token", + description: `Token: ${captureTokenStatusLabel(settings.tokenStatus)}`, + run: enterCaptureToken, + }); + + if (settings.tokenStatus !== "missing") { + actions.push({ + label: "Validate Capture Token", + description: "Check token status with CaptureWebService.", + run: validateCaptureToken, + }); + } + + actions.push({ + label: settings.consentEnabled ? "Turn Consent Off" : "Turn Consent On", + description: settings.consentEnabled + ? "Stop recording if active; keep the recording setting unchanged." + : "Record participant consent; keep the recording setting unchanged.", + run: () => setCaptureConsent(!settings.consentEnabled), + }); + + actions.push({ + label: settings.enabled + ? "Turn Record Study Events Off" + : "Turn Record Study Events On", + description: settings.enabled + ? "Stop recording; keep consent unchanged." + : "Start recording only if consent is already on.", + run: () => setRecordStudyEvents(!settings.enabled), + }); + + if (settings.tokenStatus !== "missing") { + actions.push({ + label: "Clear Capture Token", + description: "Remove the locally stored portal token.", + run: clearCaptureToken, + }); + } + + actions.push( + { + label: "Copy Participant ID", + description: settings.participantId || "Verify a token first.", + run: copyParticipantId, + }, + { + label: "Show Capture Details", + description: captureStatusDetails().split("\n")[0], + run: async () => { + captureLog(`capture status: ${captureStatusDetails()}`); + vscode.window.showInformationMessage(captureStatusDetails()); + }, + }, + ); + + const selected = await vscode.window.showQuickPick(actions, { + placeHolder: "Manage CodeChat capture", + }); + if (selected !== undefined) { + await selected.run(); + } +} + +async function recordStudyLifecycleEvent( + eventType: CaptureEventType, +): Promise { + if (captureDisabledReason(loadStudySettings()) !== undefined) { + return; + } + const active = vscode.window.activeTextEditor; + await sendCaptureEvent(eventType, active?.document.fileName, { + command: eventType, + languageId: active?.document.languageId, + }); +} + +function reflectionPromptText(languageId: string, prompt: string): string { + if (languageId === "markdown") { + return `\n\n### Reflection\n\n${prompt}\n\n`; + } + if (languageId === "restructuredtext") { + return `\n\nReflection\n----------\n\n${prompt}\n\n`; + } + if (languageId === "plaintext" || languageId === "latex") { + return `\n${prompt}\n`; + } + const commentPrefix = + languageId === "python" || + languageId === "shellscript" || + languageId === "powershell" || + languageId === "ruby" + ? "#" + : "//"; + return `\n${commentPrefix} Reflection: ${prompt}\n`; +} + +async function insertReflectionPrompt(): Promise { + const editor = vscode.window.activeTextEditor; + if (editor === undefined) { + vscode.window.showInformationMessage("Open a text editor first."); + return; + } + const prompt = await vscode.window.showQuickPick( + DEFAULT_REFLECTION_PROMPTS, + { + placeHolder: "Select a reflection prompt", + }, + ); + if (prompt === undefined) { + return; + } + + await editor.insertSnippet( + new vscode.SnippetString( + reflectionPromptText(editor.document.languageId, prompt), + ), + ); + await sendCaptureEvent( + "reflection_prompt_inserted", + editor.document.fileName, + { + prompt_hash: hashText(prompt), + prompt_length: prompt.length, + languageId: editor.document.languageId, + }, + ); +} + +async function startExtensionCaptureSession(filePath?: string) { + if (extensionCaptureSessionStarted) { + return; + } + if (captureDisabledReason(loadStudySettings()) !== undefined) { + return; + } + // Mark this before sending so recursive status refreshes do not emit a + // second session_start for the same extension session. + extensionCaptureSessionStarted = true; + await sendCaptureEvent("session_start", filePath, { + mode: "vscode_extension", + }); +} + +async function endExtensionCaptureSession( + filePath: string | undefined, + closedBy: string, + options: { controlOnly?: boolean } = {}, +): Promise { + if (!extensionCaptureSessionStarted) { + return; + } + if (options.controlOnly) { + // Consent/recording changes must stop server-side write classification + // without inserting a synthetic session_end row after the user opted + // out or paused recording. + docSessionStart = null; + await sendCaptureStopControl(filePath, closedBy); + extensionCaptureSessionStarted = false; + return; + } + await closeDocSession(filePath, closedBy); + await sendCaptureEvent("session_end", filePath, { + mode: "vscode_extension", + closed_by: closedBy, + }); + extensionCaptureSessionStarted = false; +} + +async function sendCaptureStopControl( + filePath: string | undefined, + closedBy: string, +): Promise { + if (codeChatEditorServer === undefined || !captureTransportReady) { + return; + } + // This message is sent through the normal capture channel so the server can + // clear its active capture context, but `capture_control_only` prevents it + // from being spooled for upload. + await sendCaptureEvent( + "session_end", + filePath, + { + mode: "vscode_extension", + closed_by: closedBy, + }, + { + ignoreCaptureSettings: true, + controlOnly: true, + captureActive: false, + }, + ); +} + +async function closeDocSession( + filePath: string | undefined, + closedBy: string, +): Promise { + if (docSessionStart === null) { + return; + } + + const durationMs = Date.now() - docSessionStart; + docSessionStart = null; + await sendCaptureEvent("doc_session", filePath, { + duration_ms: durationMs, + duration_seconds: durationMs / 1000.0, + closed_by: closedBy, + }); + await sendCaptureEvent("session_end", filePath, { + mode: "doc", + closed_by: closedBy, + }); +} + +// Update activity state and emit switch/doc-session events. Markdown/RST prose +// and CodeChat doc-block edits are both documentation activity for analysis; +// server-side write events classify CodeChat doc-block edits precisely, while +// this extension-side activity tracker uses the best cursor/file context +// available before translation. +async 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; + await sendCaptureEvent("session_start", filePath, { + mode: "doc", + }); + } + } else { + if (docSessionStart !== null) { + // Ending a reflective-writing session. + const closedBy = + kind === "code" ? "switch_to_code" : "activity_change"; + const durationMs = now - docSessionStart; + docSessionStart = null; + await sendCaptureEvent("doc_session", filePath, { + duration_ms: durationMs, + duration_seconds: durationMs / 1000.0, + closed_by: closedBy, + }); + await sendCaptureEvent("session_end", filePath, { + mode: "doc", + closed_by: closedBy, + }); + } + } + + // 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 + ) { + await sendCaptureEvent("switch_pane", filePath, { + from: lastActivityKind, + to: kind, + }); + } + + lastActivityKind = kind; +} + +function queueActivityCapture(kind: ActivityKind, filePath?: string): void { + captureActivityQueue = captureActivityQueue + .then(() => noteActivity(kind, filePath)) + .catch((err: unknown) => { + reportCaptureFailure( + `activity capture failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + }); } // Activation/deactivation @@ -118,7 +1974,88 @@ let codeChatEditorServer: CodeChatEditorServer | undefined; // This is invoked when the extension is activated. It either creates a new // CodeChat Editor Server instance or reveals the currently running one. export const activate = (context: vscode.ExtensionContext) => { + extension_context = context; + loadPersistedCaptureIdentity(context); + lastCaptureSettings = loadStudySettings(); + capture_output_channel = + vscode.window.createOutputChannel("CodeChat Capture"); + context.subscriptions.push(capture_output_channel); + capture_status_bar_item = vscode.window.createStatusBarItem( + vscode.StatusBarAlignment.Left, + 100, + ); + capture_status_bar_item.command = "extension.codeChatCaptureStatus"; + context.subscriptions.push(capture_status_bar_item); + capture_status_timer = setInterval(() => { + refreshCaptureStatus(); + }, 5000); + context.subscriptions.push({ + dispose: () => { + if (capture_status_timer !== undefined) { + clearInterval(capture_status_timer); + capture_status_timer = undefined; + } + }, + }); + context.subscriptions.push( + vscode.workspace.onDidChangeConfiguration(async (event) => { + if (event.affectsConfiguration("CodeChatEditor.Capture")) { + await refreshCaptureTokenState(); + await reconcileCaptureSettings("settings_ui"); + } + }), + ); + refreshCaptureTokenState(); + refreshCaptureStatus(); + context.subscriptions.push( + vscode.commands.registerCommand( + "extension.codeChatCaptureStatus", + showCaptureStatus, + ), + vscode.commands.registerCommand( + "extension.codeChatCaptureEnterToken", + enterCaptureToken, + ), + vscode.commands.registerCommand( + "extension.codeChatCaptureValidateToken", + validateCaptureToken, + ), + vscode.commands.registerCommand( + "extension.codeChatCaptureClearToken", + clearCaptureToken, + ), + vscode.commands.registerCommand( + "extension.codeChatInsertReflectionPrompt", + insertReflectionPrompt, + ), + // Study lifecycle commands are registered for optional study + // automation/keybindings, but they are not contributed to the Command + // Palette. Normal users should only see status and reflection commands. + vscode.commands.registerCommand( + "extension.codeChatCaptureTaskStart", + () => recordStudyLifecycleEvent("task_start"), + ), + vscode.commands.registerCommand( + "extension.codeChatCaptureTaskSubmit", + () => recordStudyLifecycleEvent("task_submit"), + ), + vscode.commands.registerCommand( + "extension.codeChatCaptureDebugTaskStart", + () => recordStudyLifecycleEvent("debug_task_start"), + ), + vscode.commands.registerCommand( + "extension.codeChatCaptureDebugTaskSubmit", + () => recordStudyLifecycleEvent("debug_task_submit"), + ), + vscode.commands.registerCommand( + "extension.codeChatCaptureHandoffStart", + () => recordStudyLifecycleEvent("handoff_start"), + ), + vscode.commands.registerCommand( + "extension.codeChatCaptureHandoffEnd", + () => recordStudyLifecycleEvent("handoff_end"), + ), vscode.commands.registerCommand( "extension.codeChatEditorDeactivate", deactivate, @@ -149,6 +2086,24 @@ export const activate = (context: vscode.ExtensionContext) => { event.reason }, ${format_struct(event.contentChanges)}.`, ); + + // CAPTURE: update session/switch state. The server + // classifies write_* events after parsing. + if ( + captureDisabledReason(loadStudySettings()) === + undefined + ) { + const doc = event.document; + const firstChange = event.contentChanges[0]; + const pos = firstChange.range.start; + const kind = classifyAtPosition(doc, pos); + + const filePath = doc.fileName; + // Update our notion of current activity + doc + // session. + queueActivityCapture(kind, filePath); + } + send_update(true); }), ); @@ -173,24 +2128,146 @@ export const activate = (context: vscode.ExtensionContext) => { ) { return; } + + // CAPTURE: update activity + possible + // switch\_pane/doc\_session. + if ( + captureDisabledReason(loadStudySettings()) === + undefined + ) { + const doc = event.document; + const pos = + event.selection?.active ?? + new vscode.Position(0, 0); + const kind = classifyAtPosition(doc, pos); + + const filePath = doc.fileName; + queueActivityCapture(kind, filePath); + } + send_update(true); }), ); context.subscriptions.push( vscode.window.onDidChangeTextEditorSelection( - (_event) => { + (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. + if ( + captureDisabledReason( + loadStudySettings(), + ) === undefined + ) { + const doc = event.textEditor.document; + const pos = + event.selections?.[0]?.active ?? + event.textEditor.selection.active; + const kind = classifyAtPosition(doc, pos); + const filePath = doc.fileName; + queueActivityCapture(kind, filePath); + } + send_update(false); }, ), ); + + // CAPTURE: listen for file saves. + context.subscriptions.push( + vscode.workspace.onDidSaveTextDocument((doc) => { + if ( + captureDisabledReason(loadStudySettings()) !== + undefined + ) { + return; + } + sendCaptureEvent("save", doc.fileName, { + reason: "manual_save", + languageId: doc.languageId, + lineCount: doc.lineCount, + }); + }), + ); + + // CAPTURE: start and end of a debug/run session. + context.subscriptions.push( + vscode.debug.onDidStartDebugSession((session) => { + if ( + captureDisabledReason(loadStudySettings()) !== + undefined + ) { + return; + } + const active = vscode.window.activeTextEditor; + const filePath = active?.document.fileName; + sendCaptureEvent("run", filePath, { + sessionName: session.name, + sessionType: session.type, + }); + }), + vscode.debug.onDidTerminateDebugSession((session) => { + if ( + captureDisabledReason(loadStudySettings()) !== + undefined + ) { + return; + } + const active = vscode.window.activeTextEditor; + const filePath = active?.document.fileName; + sendCaptureEvent("run_end", filePath, { + sessionName: session.name, + sessionType: session.type, + }); + }), + ); + + // CAPTURE: start and end compile/build events via VS Code + // tasks. + context.subscriptions.push( + vscode.tasks.onDidStartTaskProcess((e) => { + if ( + captureDisabledReason(loadStudySettings()) !== + undefined + ) { + return; + } + const active = vscode.window.activeTextEditor; + const filePath = active?.document.fileName; + const task = e.execution.task; + sendCaptureEvent("compile", filePath, { + taskName: task.name, + taskSource: task.source, + definition: task.definition, + processId: e.processId, + }); + }), + vscode.tasks.onDidEndTaskProcess((e) => { + if ( + captureDisabledReason(loadStudySettings()) !== + undefined + ) { + return; + } + const active = vscode.window.activeTextEditor; + const filePath = active?.document.fileName; + const task = e.execution.task; + sendCaptureEvent("compile_end", filePath, { + taskName: task.name, + taskSource: task.source, + exitCode: e.exitCode, + }); + }), + ); } // Get the CodeChat Client's location from the VSCode @@ -278,6 +2355,11 @@ export const activate = (context: vscode.ExtensionContext) => { // Start the server. console_log("CodeChat Editor extension: starting server."); codeChatEditorServer = new CodeChatEditorServer(); + captureFailureLogged = false; + captureTransportReady = false; + extensionCaptureSessionStarted = false; + await refreshCaptureTokenState(); + refreshCaptureStatus(); const hosted_in_ide = codechat_client_location === @@ -286,6 +2368,7 @@ export const activate = (context: vscode.ExtensionContext) => { `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). @@ -293,6 +2376,11 @@ export const activate = (context: vscode.ExtensionContext) => { codechat_client_location === CodeChatEditorClientLocation.browser ) { + captureTransportReady = true; + const active = vscode.window.activeTextEditor; + await startExtensionCaptureSession( + active?.document.fileName, + ); send_update(false); } @@ -302,6 +2390,7 @@ 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, @@ -336,16 +2425,19 @@ export const activate = (context: vscode.ExtensionContext) => { } if (current_update.contents !== undefined) { const source = current_update.contents.source; + // This will produce a change event, which we'll // ignore. The change may also produce a // selection change, which should also be // ignored. ignore_text_document_change = true; ignore_selection_change = true; + // Use a workspace edit, since calls to // `TextEditor.edit` must be made to the active // editor only. const wse = new vscode.WorkspaceEdit(); + // Is this plain text, or a diff? if ("Plain" in source) { wse.replace( @@ -362,6 +2454,7 @@ export const activate = (context: vscode.ExtensionContext) => { ); } else { assert("Diff" in source); + // If this diff was not made against the // text we currently have, reject it. if (source.Diff.version !== version) { @@ -381,8 +2474,8 @@ 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 + // 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); @@ -416,11 +2509,12 @@ export const activate = (context: vscode.ExtensionContext) => { // Update the cursor and scroll position if // provided. const editor = get_text_editor(doc); + const scroll_line = current_update.scroll_position; if (scroll_line !== undefined && editor) { - // Don't set `ignore_scroll_position` here, - // since `revealRange` doesn't change the - // editor's text selection. + // Don't set `ignore_selection_change` here: + // `revealRange` doesn't change the editor's + // text selection. const scroll_position = new vscode.Position( // The VSCode line is zero-based; the // CodeMirror line is one-based. @@ -440,9 +2534,15 @@ export const activate = (context: vscode.ExtensionContext) => { const cursor_position = current_update.cursor_position; - if (cursor_position !== undefined && editor) { - assert("Line" in cursor_position); - const cursor_line = cursor_position.Line; + if ( + cursor_position !== undefined && + typeof cursor_position === "object" && + "Line" in cursor_position && + editor + ) { + const cursor_line = ( + cursor_position as { Line: number } + ).Line; ignore_selection_change = true; const vscode_cursor_position = new vscode.Position( @@ -464,6 +2564,18 @@ export const activate = (context: vscode.ExtensionContext) => { // Instead, depend on the event to always clear // this flag (a source of potential bugs). } + if ( + cursor_position !== undefined && + typeof cursor_position === "object" && + "DomLocation" in cursor_position + ) { + // VS Code can only apply line-based cursor + // locations. DOM locations should be converted + // by the server before reaching the extension. + console_log( + "CodeChat Editor extension: ignoring DOM cursor location in VS Code update.", + ); + } await sendResult(id); break; } @@ -510,19 +2622,13 @@ export const activate = (context: vscode.ExtensionContext) => { .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], }), ); */ @@ -596,6 +2702,11 @@ export const activate = (context: vscode.ExtensionContext) => { assert(webview_panel !== undefined); webview_panel.webview.html = client_html; await sendResult(id); + captureTransportReady = true; + const active = vscode.window.activeTextEditor; + await startExtensionCaptureSession( + active?.document.fileName, + ); // Now that the Client is loaded, send the editor's // current file to the server. send_update(false); @@ -604,9 +2715,7 @@ export const activate = (context: vscode.ExtensionContext) => { default: console.error( - `Unhandled message ${key}(${format_struct( - value, - )}`, + `Unhandled message ${key}(${format_struct(value)}`, ); break; } @@ -619,6 +2728,13 @@ export const activate = (context: vscode.ExtensionContext) => { // On deactivation, close everything down. export const deactivate = async () => { console_log("CodeChat Editor extension: deactivating."); + + const active = vscode.window.activeTextEditor; + await endExtensionCaptureSession( + active?.document.fileName, + "extension_deactivate", + ); + await stop_client(); webview_panel?.dispose(); console_log("CodeChat Editor extension: deactivated."); @@ -641,7 +2757,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( @@ -702,13 +2820,17 @@ const send_update = (this_is_dirty: boolean) => { 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, @@ -725,14 +2847,19 @@ const send_update = (this_is_dirty: boolean) => { // well. const stop_client = async () => { console_log("CodeChat Editor extension: stopping client."); + const active = vscode.window.activeTextEditor; + await endExtensionCaptureSession( + active?.document.fileName, + "client_stopped", + ); if (codeChatEditorServer !== undefined) { console_log("CodeChat Editor extension: stopping server."); await codeChatEditorServer.stopServer(); codeChatEditorServer = undefined; } + captureTransportReady = false; + await refreshCaptureStatus(); - // 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; @@ -749,7 +2876,6 @@ 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

") ) { diff --git a/extensions/VSCode/src/lib.rs b/extensions/VSCode/src/lib.rs index ceec586a..52c0246f 100644 --- a/extensions/VSCode/src/lib.rs +++ b/extensions/VSCode/src/lib.rs @@ -80,6 +80,48 @@ impl CodeChatEditorServer { self.0.send_message_opened(hosted_in_ide).await } + #[napi] + pub async fn send_capture_event(&self, capture_event_json: String) -> std::io::Result { + let capture_event = serde_json::from_str(&capture_event_json) + .map_err(|err| std::io::Error::other(err.to_string()))?; + self.0.send_capture_event(capture_event).await + } + + #[napi] + pub fn get_capture_status(&self) -> Result { + serde_json::to_string(&self.0.capture_status()) + .map_err(|err| Error::new(Status::GenericFailure, err.to_string())) + } + + #[napi] + pub fn configure_capture_service( + &self, + base_url: String, + token: Option, + ) -> Result<(), Error> { + self.0 + .configure_capture_service(base_url, token) + .map_err(|err| Error::new(Status::GenericFailure, err)) + } + + #[napi] + pub fn clear_capture_token(&self) -> Result<(), Error> { + self.0 + .clear_capture_token() + .map_err(|err| Error::new(Status::GenericFailure, err)) + } + + #[napi] + pub fn check_capture_service_status(&self) -> Result { + serde_json::to_string( + &self + .0 + .check_capture_service_status() + .map_err(|err| Error::new(Status::GenericFailure, err))?, + ) + .map_err(|err| Error::new(Status::GenericFailure, err.to_string())) + } + #[napi] pub async fn send_message_current_file(&self, url: String) -> std::io::Result { self.0.send_message_current_file(url).await diff --git a/server/Cargo.lock b/server/Cargo.lock index c402182f..feac575f 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -191,7 +191,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "smallvec", - "socket2 0.6.4", + "socket2 0.6.5", "time", "tracing", "url", @@ -595,12 +595,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - [[package]] name = "bytes" version = "1.12.1" @@ -630,9 +624,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.66" +version = "1.2.67" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" dependencies = [ "find-msvc-tools", "jobserver", @@ -672,6 +666,7 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", + "serde", "wasm-bindgen", "windows-link", ] @@ -725,12 +720,6 @@ dependencies = [ "cc", ] -[[package]] -name = "cmov" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" - [[package]] name = "cobs" version = "0.3.0" @@ -790,11 +779,11 @@ dependencies = [ "regex", "serde", "serde_json", + "sha2", "test_utils", "thirtyfour", "thiserror", "tokio", - "tokio-postgres", "tokio-tungstenite", "tracing", "tracing-log", @@ -1048,15 +1037,6 @@ dependencies = [ "syn 2.0.118", ] -[[package]] -name = "ctutils" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" -dependencies = [ - "cmov", -] - [[package]] name = "dashmap" version = "5.5.3" @@ -1151,7 +1131,6 @@ dependencies = [ "block-buffer 0.12.1", "const-oid", "crypto-common 0.2.2", - "ctutils", ] [[package]] @@ -1295,12 +1274,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "fallible-iterator" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" - [[package]] name = "fastrand" version = "2.4.1" @@ -1518,7 +1491,7 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", "wasm-bindgen", ] @@ -1632,15 +1605,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hmac" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" -dependencies = [ - "digest 0.11.3", -] - [[package]] name = "htmd" version = "0.5.4" @@ -1694,9 +1658,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", "http 1.4.2", @@ -1704,9 +1668,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" dependencies = [ "bytes", "futures-core", @@ -1800,7 +1764,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.4", + "socket2 0.6.5", "tokio", "tower-service", "tracing", @@ -2290,7 +2254,7 @@ dependencies = [ "log-mdc", "mock_instant", "parking_lot", - "rand 0.9.4", + "rand 0.9.5", "serde", "serde-value", "serde_json", @@ -2352,16 +2316,6 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" -[[package]] -name = "md-5" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" -dependencies = [ - "cfg-if", - "digest 0.11.3", -] - [[package]] name = "memchr" version = "2.8.3" @@ -2443,13 +2397,13 @@ checksum = "659579df697b372ef9e36f02fcbb41f6d6f157dcec7db9c9618fa0f23cf0fc20" [[package]] name = "mio" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "log", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", "windows-sys 0.61.2", ] @@ -2575,15 +2529,6 @@ dependencies = [ "objc2-encode", ] -[[package]] -name = "objc2-core-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" -dependencies = [ - "bitflags", -] - [[package]] name = "objc2-encode" version = "4.1.0" @@ -2600,15 +2545,6 @@ dependencies = [ "objc2", ] -[[package]] -name = "objc2-system-configuration" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" -dependencies = [ - "objc2-core-foundation", -] - [[package]] name = "once_cell" version = "1.21.4" @@ -2662,9 +2598,9 @@ checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" [[package]] name = "oxc-browserslist" -version = "3.0.9" +version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "373741e2febe6df186995b668f295e46fd402ee12f312ea2fda8b3b6312c0885" +checksum = "50a403a3c6be65be7bc7730d07d959ec6c143e4ff8117da485df78cd5582260a" dependencies = [ "miniz_oxide 0.9.1", "postcard", @@ -3205,7 +3141,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared 0.11.3", - "rand 0.8.6", + "rand 0.8.7", ] [[package]] @@ -3318,36 +3254,6 @@ dependencies = [ "serde", ] -[[package]] -name = "postgres-protocol" -version = "0.6.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08808e3c483c46e999108051c78334f473d5adb59d78bb80a1268c7e6aa6c514" -dependencies = [ - "base64", - "byteorder", - "bytes", - "fallible-iterator", - "hmac", - "md-5", - "memchr", - "rand 0.10.2", - "sha2", - "stringprep", -] - -[[package]] -name = "postgres-types" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "851ca9db4932932d69f3ea811b1abe63087a0f740a47692619dd40d4899b68be" -dependencies = [ - "bytes", - "chrono", - "fallible-iterator", - "postgres-protocol", -] - [[package]] name = "potential_utf" version = "0.1.5" @@ -3489,7 +3395,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.4", + "socket2 0.6.5", "thiserror", "tokio", "tracing", @@ -3528,7 +3434,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.4", + "socket2 0.6.5", "tracing", "windows-sys 0.61.2", ] @@ -3562,18 +3468,18 @@ checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" [[package]] name = "rand" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ "rand_core 0.6.4", ] [[package]] name = "rand" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha", "rand_core 0.9.5", @@ -3827,9 +3733,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.41" +version = "0.23.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" dependencies = [ "aws-lc-rs", "once_cell", @@ -4207,9 +4113,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -4261,17 +4167,6 @@ dependencies = [ "regex", ] -[[package]] -name = "stringprep" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" -dependencies = [ - "unicode-bidi", - "unicode-normalization", - "unicode-properties", -] - [[package]] name = "strsim" version = "0.11.1" @@ -4550,7 +4445,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.4", + "socket2 0.6.5", "tokio-macros", "windows-sys 0.61.2", ] @@ -4566,32 +4461,6 @@ dependencies = [ "syn 2.0.118", ] -[[package]] -name = "tokio-postgres" -version = "0.7.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a528f7d280f6d5b9cd149635c8705b0dd049754bc67d81d31fa25169a93809d3" -dependencies = [ - "async-trait", - "byteorder", - "bytes", - "fallible-iterator", - "futures-channel", - "futures-util", - "log", - "parking_lot", - "percent-encoding", - "phf 0.13.1", - "pin-project-lite", - "postgres-protocol", - "postgres-types", - "rand 0.10.2", - "socket2 0.6.4", - "tokio", - "tokio-util", - "whoami", -] - [[package]] name = "tokio-rustls" version = "0.26.4" @@ -4786,7 +4655,7 @@ dependencies = [ "http 1.4.2", "httparse", "log", - "rand 0.9.4", + "rand 0.9.5", "sha1 0.10.7", "thiserror", ] @@ -4824,12 +4693,6 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" -[[package]] -name = "unicode-bidi" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" - [[package]] name = "unicode-id-start" version = "1.4.0" @@ -4848,21 +4711,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" -[[package]] -name = "unicode-normalization" -version = "0.1.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" -dependencies = [ - "tinyvec", -] - -[[package]] -name = "unicode-properties" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" - [[package]] name = "unicode-segmentation" version = "1.13.3" @@ -4934,9 +4782,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.4" +version = "1.23.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +checksum = "ea5fab0d6c3c01ae70085a09cb03d4c7a1d6314e2b3e075392783396d724ca0a" dependencies = [ "js-sys", "wasm-bindgen", @@ -5006,15 +4854,6 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasi" -version = "0.14.7+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" -dependencies = [ - "wasip2", -] - [[package]] name = "wasip2" version = "1.0.4+wasi-0.2.12" @@ -5024,15 +4863,6 @@ dependencies = [ "wit-bindgen", ] -[[package]] -name = "wasite" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" -dependencies = [ - "wasi 0.14.7+wasi-0.2.4", -] - [[package]] name = "wasm-bindgen" version = "0.2.126" @@ -5158,19 +4988,6 @@ dependencies = [ "rustls-pki-types", ] -[[package]] -name = "whoami" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" -dependencies = [ - "libc", - "libredox", - "objc2-system-configuration", - "wasite", - "web-sys", -] - [[package]] name = "winapi" version = "0.3.9" @@ -5640,9 +5457,9 @@ checksum = "b142a20ec14a91d5bc708c1dc21b080c550113d8aa77afa29635673a65dd02c5" [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zopfli" diff --git a/server/Cargo.toml b/server/Cargo.toml index f0aa59e0..11aa4587 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -52,7 +52,7 @@ actix-ws = "0.4" ammonia = "4.1.2" anyhow = "1.0.100" bytes = { version = "1", features = ["serde"] } -chrono = "0.4" +chrono = { version = "0.4", features = ["serde"] } clap = { version = "4", features = ["derive"] } dprint-plugin-markdown = { git = "https://github.com/bjones1/dprint-plugin-markdown.git", branch = "all-fixes", version = "0.21.0" } dunce = "1.0.5" @@ -88,10 +88,10 @@ rand = "0.10" regex = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" +sha2 = "0.11" test_utils = { path = "../test_utils" } thiserror = "2.0.12" tokio = { version = "1", features = ["full"] } -tokio-postgres = { version = "0.7", features = ["with-chrono-0_4"] } tracing = "0.1.44" tracing-log = "0.2.0" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/server/log4rs.yml b/server/log4rs.yml index 544068f2..d534ba2a 100644 --- a/server/log4rs.yml +++ b/server/log4rs.yml @@ -40,7 +40,7 @@ loggers: level: warn root: - level: info + level: debug appenders: - console_appender - file_appender \ No newline at end of file diff --git a/server/scripts/capture_events_schema.sql b/server/scripts/capture_events_schema.sql new file mode 100644 index 00000000..9326e7a7 --- /dev/null +++ b/server/scripts/capture_events_schema.sql @@ -0,0 +1,170 @@ +-- Capture Events Schema +-- ===================== +-- +-- CodeChat capture event schema for dissertation analysis. +-- +-- This script updates an existing legacy `events` table to the lean capture +-- schema used for dissertation telemetry. It converts `timestamp` and `data` to +-- analysis-friendly PostgreSQL types and backfills typed telemetry from +-- older JSON payloads where possible. New capture code writes known telemetry +-- metadata to first-class columns and reserves `data` for event-specific +-- details. Study metadata such as course, group, +-- assignment, condition, and task is intentionally omitted: those values are +-- joined during analysis from researcher-managed participant/date mappings. + +BEGIN; + +CREATE TABLE IF NOT EXISTS public.events ( + id BIGSERIAL PRIMARY KEY, + event_id TEXT, + sequence_number BIGINT, + schema_version INTEGER, + user_id TEXT NOT NULL, + session_id TEXT, + event_source TEXT, + language_id TEXT, + file_hash TEXT, + event_type TEXT NOT NULL, + "timestamp" TIMESTAMPTZ NOT NULL DEFAULT now(), + client_tz_offset_min INTEGER, + data JSONB NOT NULL DEFAULT '{}'::jsonb, + inserted_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +ALTER TABLE public.events ADD COLUMN IF NOT EXISTS event_id TEXT; +ALTER TABLE public.events ADD COLUMN IF NOT EXISTS sequence_number BIGINT; +ALTER TABLE public.events ADD COLUMN IF NOT EXISTS schema_version INTEGER; +ALTER TABLE public.events ADD COLUMN IF NOT EXISTS session_id TEXT; +ALTER TABLE public.events ADD COLUMN IF NOT EXISTS event_source TEXT; +ALTER TABLE public.events ADD COLUMN IF NOT EXISTS language_id TEXT; +ALTER TABLE public.events ADD COLUMN IF NOT EXISTS file_hash TEXT; +ALTER TABLE public.events ADD COLUMN IF NOT EXISTS client_tz_offset_min INTEGER; +ALTER TABLE public.events ADD COLUMN IF NOT EXISTS inserted_at TIMESTAMPTZ NOT NULL DEFAULT now(); + +ALTER TABLE public.events DROP COLUMN IF EXISTS assignment_id; +ALTER TABLE public.events DROP COLUMN IF EXISTS group_id; +ALTER TABLE public.events DROP COLUMN IF EXISTS condition; +ALTER TABLE public.events DROP COLUMN IF EXISTS course_id; +ALTER TABLE public.events DROP COLUMN IF EXISTS task_id; +ALTER TABLE public.events DROP COLUMN IF EXISTS capture_mode; +ALTER TABLE public.events DROP COLUMN IF EXISTS file_path; +ALTER TABLE public.events DROP COLUMN IF EXISTS path_privacy; +ALTER TABLE public.events DROP COLUMN IF EXISTS server_timestamp_ms; +ALTER TABLE public.events DROP COLUMN IF EXISTS client_timestamp_ms; + +DO $$ +DECLARE + current_type TEXT; +BEGIN + SELECT data_type INTO current_type + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'events' + AND column_name = 'timestamp'; + + IF current_type IS DISTINCT FROM 'timestamp with time zone' THEN + ALTER TABLE public.events + ALTER COLUMN "timestamp" TYPE TIMESTAMPTZ + USING COALESCE(NULLIF("timestamp"::text, '')::timestamptz, now()); + END IF; +END $$; + +DO $$ +DECLARE + current_type TEXT; +BEGIN + SELECT data_type INTO current_type + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'events' + AND column_name = 'data'; + + IF current_type IS DISTINCT FROM 'jsonb' THEN + ALTER TABLE public.events + ALTER COLUMN data TYPE JSONB + USING CASE + WHEN data IS NULL OR btrim(data::text) = '' THEN '{}'::jsonb + ELSE data::jsonb + END; + END IF; +END $$; + +UPDATE public.events +SET data = '{}'::jsonb +WHERE data IS NULL; + +ALTER TABLE public.events ALTER COLUMN data SET DEFAULT '{}'::jsonb; +ALTER TABLE public.events ALTER COLUMN data SET NOT NULL; +ALTER TABLE public.events ALTER COLUMN "timestamp" SET DEFAULT now(); +ALTER TABLE public.events ALTER COLUMN "timestamp" SET NOT NULL; + +UPDATE public.events +SET + event_id = COALESCE(event_id, NULLIF(data->>'event_id', '')), + sequence_number = COALESCE( + sequence_number, + CASE + WHEN data->>'sequence_number' ~ '^-?[0-9]+$' + THEN (data->>'sequence_number')::bigint + END + ), + schema_version = COALESCE( + schema_version, + CASE + WHEN data->>'schema_version' ~ '^-?[0-9]+$' + THEN (data->>'schema_version')::integer + END + ), + session_id = COALESCE(session_id, NULLIF(data->>'session_id', '')), + event_source = COALESCE(event_source, NULLIF(data->>'event_source', '')), + language_id = COALESCE( + language_id, + NULLIF(data->>'language_id', ''), + NULLIF(data->>'languageId', '') + ), + file_hash = COALESCE(file_hash, NULLIF(data->>'file_hash', '')), + client_tz_offset_min = COALESCE( + client_tz_offset_min, + CASE + WHEN data->>'client_tz_offset_min' ~ '^-?[0-9]+$' + THEN (data->>'client_tz_offset_min')::integer + END + ); + +CREATE INDEX IF NOT EXISTS events_timestamp_idx + ON public.events ("timestamp"); + +CREATE INDEX IF NOT EXISTS events_type_timestamp_idx + ON public.events (event_type, "timestamp"); + +CREATE INDEX IF NOT EXISTS events_participant_session_idx + ON public.events (user_id, session_id); + +CREATE INDEX IF NOT EXISTS events_file_hash_idx + ON public.events (file_hash) + WHERE file_hash IS NOT NULL; + +CREATE INDEX IF NOT EXISTS events_event_id_idx + ON public.events (event_id) + WHERE event_id IS NOT NULL; + +CREATE INDEX IF NOT EXISTS events_data_gin_idx + ON public.events USING GIN (data); + +COMMENT ON TABLE public.events IS + 'CodeChat dissertation capture events. Course, group, assignment, condition, and task context are joined during analysis from participant/date mappings.'; +COMMENT ON COLUMN public.events.event_id IS 'Opaque stable per-event ID for correlation and future deduplication; not used for event ordering.'; +COMMENT ON COLUMN public.events.sequence_number IS 'Client-local event order within one VS Code extension session, useful for ordering and detecting gaps.'; +COMMENT ON COLUMN public.events.user_id IS 'Pseudonymous participant UUID authorized by the portal-issued capture token.'; +COMMENT ON COLUMN public.events.session_id IS 'Capture session UUID emitted by the VS Code extension.'; +COMMENT ON COLUMN public.events.file_hash IS 'SHA-256 hash of the local file path; raw local paths are not stored.'; +COMMENT ON COLUMN public.events."timestamp" IS 'Server receive/record timestamp in UTC.'; +COMMENT ON COLUMN public.events.client_tz_offset_min IS 'Client timezone offset in minutes, used with timestamp to derive local time of day without storing location or full timezone name.'; +COMMENT ON COLUMN public.events.data IS 'Event-specific JSON payload. Known telemetry metadata lives in typed columns.'; + +-- CodeChat clients do not connect to PostgreSQL. Public clients submit events +-- only to CaptureWebService with a portal-issued bearer token; any database +-- writer role and credentials must remain server-side inside the web service +-- deployment. + +COMMIT; diff --git a/server/src/capture.rs b/server/src/capture.rs index 3f8f7c15..f45ce8ef 100644 --- a/server/src/capture.rs +++ b/server/src/capture.rs @@ -13,227 +13,2288 @@ // 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 + +// `capture.rs` -- Capture CodeChat Editor Events +// ============================================================================ // -// ## Imports +// This module provides a durable local FIFO spool and an HTTPS upload worker for +// CaptureWebService. CodeChat never stores PostgreSQL credentials and never +// connects directly to the capture database. The VS Code extension stores the +// portal-issued bearer token in SecretStorage, then passes it to this worker in +// memory so pending capture events can upload through the public web service. + +// Imports +// ------- // -// Standard library -use indoc::indoc; -use std::fs; -use std::io; -use std::path::Path; -use std::sync::Arc; - -// Third-party -use chrono::Local; -use log::{error, info}; +// ### Standard library +use std::{ + fs::{self, File}, + io::{self, Write}, + path::{Path, PathBuf}, + process, + sync::atomic::{AtomicU64, Ordering}, + sync::{ + Arc, Mutex, + mpsc::{self, RecvTimeoutError, Sender}, + }, + thread, + time::Duration, +}; + +// ### Third-party +use chrono::{DateTime, Utc}; +use log::{debug, error, info, warn}; use serde::{Deserialize, Serialize}; -use tokio::sync::Mutex; -use tokio_postgres::{Client, NoTls}; +use sha2::{Digest, Sha256}; +use ts_rs::TS; -// Local +static NEXT_CAPTURE_EVENT_ID: AtomicU64 = AtomicU64::new(1); +static NEXT_SPOOL_FILE_ID: AtomicU64 = AtomicU64::new(1); -/* ## The Event Structure: +const DEFAULT_CAPTURE_SCHEMA_VERSION: i32 = 2; +const MAX_CAPTURE_BATCH_EVENTS: usize = 100; +const MAX_CAPTURE_BATCH_BYTES: usize = 524_288; +const INITIAL_RETRY_DELAY_MS: u64 = 1_000; +const MAX_RETRY_DELAY_MS: u64 = 60_000; +const CAPTURE_SERVICE_HTTP_TIMEOUT_SECS: u64 = 10; - The `Event` struct represents an event to be stored in the database. +/// Canonical event types. Keep the serialized strings stable for analysis. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export)] +pub enum CaptureEventType { + /// Edit to documentation/prose. In CodeChat files this means doc blocks; + /// fenced or embedded code content is classified as `WriteCode`. + WriteDoc, + /// Edit to executable source code, including code inside CodeChat blocks. + WriteCode, + /// Editor activity moved between documentation and code contexts. + SwitchPane, + /// Duration summary for a documentation/prose activity interval. + DocSession, + /// File save observed by the editor. + Save, + /// Compile/build task started. + Compile, + /// Debug/run session started. + Run, + /// Capture or activity session started. + SessionStart, + /// Capture or activity session ended. + SessionEnd, + /// Consent or recording settings changed. + CaptureSettingsChanged, + /// Compile/build task ended. + CompileEnd, + /// Debug/run session ended. + RunEnd, + /// Study task started by an external study workflow. + TaskStart, + /// Study task submitted by an external study workflow. + TaskSubmit, + /// Debugging study task started by an external study workflow. + DebugTaskStart, + /// Debugging study task submitted by an external study workflow. + DebugTaskSubmit, + /// Collaboration handoff interval started. + HandoffStart, + /// Collaboration handoff interval ended. + HandoffEnd, + /// A built-in reflection prompt was inserted into the active editor. + ReflectionPromptInserted, +} - 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. +impl CaptureEventType { + pub const fn as_str(self) -> &'static str { + match self { + Self::WriteDoc => "write_doc", + Self::WriteCode => "write_code", + Self::SwitchPane => "switch_pane", + Self::DocSession => "doc_session", + Self::Save => "save", + Self::Compile => "compile", + Self::Run => "run", + Self::SessionStart => "session_start", + Self::SessionEnd => "session_end", + Self::CaptureSettingsChanged => "capture_settings_changed", + Self::CompileEnd => "compile_end", + Self::RunEnd => "run_end", + Self::TaskStart => "task_start", + Self::TaskSubmit => "task_submit", + Self::DebugTaskStart => "debug_task_start", + Self::DebugTaskSubmit => "debug_task_submit", + Self::HandoffStart => "handoff_start", + Self::HandoffEnd => "handoff_end", + Self::ReflectionPromptInserted => "reflection_prompt_inserted", + } + } +} + +impl std::fmt::Display for CaptureEventType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Hash a local file path before it enters capture storage. The hash is stable +/// enough to group edits to the same file while avoiding raw path collection. +pub fn hash_capture_path(path: &str) -> String { + hash_capture_text(path) +} - ### Example +fn hash_capture_token(token: &str) -> String { + hash_capture_text(token) +} - let event = Event { user_id: "user123".to_string(), event_type: - "keystroke".to_string(), data: Some("Pressed key A".to_string()), }; -*/ +fn hash_capture_text(value: &str) -> String { + Sha256::digest(value.as_bytes()) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} -#[derive(Deserialize, Debug)] -pub struct Event { +/// JSON payload received from local clients for capture events. +/// +/// The server supplies the authoritative timestamp and hashes any raw local file +/// path before storage. Study metadata such as course, assignment, group, +/// condition, and task is inferred later from researcher-managed mappings keyed +/// by the pseudonymous `user_id` and event timestamps. +#[derive(Debug, Serialize, Deserialize, PartialEq, TS)] +#[ts(export, optional_fields)] +pub struct CaptureEventWire { + /// Client-generated unique event identifier. Unlike `sequence_number`, this + /// is an opaque stable ID for correlation and possible future deduplication + /// across capture transports or retries. + #[serde(skip_serializing_if = "Option::is_none")] + pub event_id: Option, + /// Event order within one `(session_id, event_source)` stream. + #[serde(skip_serializing_if = "Option::is_none")] + pub sequence_number: Option, + /// Capture payload schema version. + #[serde(skip_serializing_if = "Option::is_none")] + pub schema_version: Option, + /// Pseudonymous participant UUID from CaptureWebService token status. pub user_id: String, - pub event_type: String, - pub data: Option, + /// Logical capture session UUID. + #[serde(skip_serializing_if = "Option::is_none")] + pub session_id: Option, + /// Source of this event, such as the VS Code extension or server translation. + #[serde(skip_serializing_if = "Option::is_none")] + pub event_source: Option, + /// VS Code language identifier for the active file, when known. + #[serde(skip_serializing_if = "Option::is_none")] + pub language_id: Option, + /// Raw local file path from a trusted local client. This value exists only + /// long enough for the Rust server to hash it; it is never spooled or sent. + #[serde(skip_serializing_if = "Option::is_none")] + pub file_path: Option, + /// SHA-256 hash of the local file path. + #[serde(skip_serializing_if = "Option::is_none")] + pub file_hash: Option, + /// Canonical capture event type. + pub event_type: CaptureEventType, + /// Optional client timezone offset in minutes (JS Date().getTimezoneOffset()). + #[serde(skip_serializing_if = "Option::is_none")] + pub client_tz_offset_min: Option, + /// Event-specific data. Do not store source text or raw local paths here. + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(type = "unknown")] + pub data: Option, } -/* - ## The Config Structure: +/// Participant and session metadata remembered from client capture events. +#[derive(Clone, Debug, Default)] +pub(crate) struct CaptureContext { + active: bool, + user_id: Option, + event_source: Option, + session_id: Option, + client_tz_offset_min: Option, + schema_version: Option, + server_sequence_number: i64, +} - The `Config` struct represents the database connection parameters read from - `config.json`. +impl CaptureContext { + /// Refresh server-side capture identity and active/inactive state from an + /// extension capture message. + pub(crate) fn update_from_wire(&mut self, wire: &CaptureEventWire) { + match wire.event_type { + CaptureEventType::SessionStart => self.active = true, + CaptureEventType::SessionEnd => self.active = false, + _ => {} + } + if !wire.user_id.trim().is_empty() { + self.user_id = Some(wire.user_id.clone()); + } + if let Some(event_source) = &wire.event_source { + self.event_source = Some(event_source.clone()); + } + if let Some(session_id) = &wire.session_id { + if self.session_id.as_ref() != Some(session_id) { + self.server_sequence_number = 0; + } + self.session_id = Some(session_id.clone()); + } + if let Some(schema_version) = wire.schema_version { + self.schema_version = Some(schema_version); + } + if let Some(client_tz_offset_min) = wire.client_tz_offset_min { + self.client_tz_offset_min = Some(client_tz_offset_min); + } + if let Some(serde_json::Value::Object(data)) = &wire.data + && let Some(active) = data + .get("capture_active") + .and_then(serde_json::Value::as_bool) + { + self.active = active; + } + } - 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. + pub(crate) fn is_active(&self) -> bool { + self.active + } - 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(), }; -*/ + pub(crate) fn capture_event( + &mut self, + event_type: CaptureEventType, + file_path: Option, + data: serde_json::Value, + ) -> Option { + if !self.active { + return None; + } + let mut data = match data { + serde_json::Value::Object(map) => map, + other => { + let mut map = serde_json::Map::new(); + map.insert("value".to_string(), other); + map + } + }; + data.entry("source".to_string()) + .or_insert_with(|| serde_json::json!("server_translation")); + + self.server_sequence_number += 1; -#[derive(Deserialize, Serialize, Debug)] -pub struct Config { - pub db_ip: String, - pub db_user: String, - pub db_password: String, - pub db_name: String, + Some(CaptureEventWire { + event_id: None, + sequence_number: Some(self.server_sequence_number), + schema_version: self.schema_version, + user_id: self.user_id.clone()?, + session_id: self.session_id.clone(), + event_source: Some("server_translation".to_string()), + language_id: None, + file_path, + file_hash: None, + event_type, + client_tz_offset_min: self.client_tz_offset_min, + data: Some(serde_json::Value::Object(data)), + }) + } } -/* +/// True for a capture message that should update `CaptureContext` only. +pub(crate) fn capture_control_only(wire: &CaptureEventWire) -> bool { + matches!( + &wire.data, + Some(serde_json::Value::Object(data)) + if data + .get("capture_control_only") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + ) +} - ## The EventCapture Structure: +/// Runtime service configuration supplied by the VS Code extension. The bearer +/// token is stored in VS Code SecretStorage and held only in memory here. +#[derive(Debug, Clone, Default)] +pub struct CaptureServiceConfig { + pub base_url: Option, + token: Option, + pub participant_id: Option, + pub instance_id: Option, + generation: u64, +} - The `EventCapture` struct provides methods to interact with the database. It -holds a `tokio_postgres::Client` for database operations. +impl CaptureServiceConfig { + fn configured(base_url: String, token: Option) -> Result { + let base_url = normalize_service_base_url(&base_url)?; + Ok(Self { + base_url: Some(base_url), + token: token.filter(|token| !token.trim().is_empty()), + participant_id: None, + instance_id: None, + generation: 0, + }) + } -### Usage Example + fn token(&self) -> Option<&str> { + self.token + .as_deref() + .filter(|token| !token.trim().is_empty()) + } -#\[tokio::main\] async fn main() -> Result<(), Box> { + fn status_url(&self) -> Option { + self.base_url + .as_deref() + .map(|base_url| format!("{base_url}/v1/capture/status")) + } -``` - // Create an instance of EventCapture using the configuration file - let event_capture = EventCapture::new("config.json").await?; + fn events_url(&self) -> Option { + self.base_url + .as_deref() + .map(|base_url| format!("{base_url}/v1/capture/events")) + } - // Create an event - let event = Event { - user_id: "user123".to_string(), - event_type: "keystroke".to_string(), - data: Some("Pressed key A".to_string()), - }; + fn token_hash(&self) -> Option { + self.token().map(hash_capture_token) + } - // Insert the event into the database - event_capture.insert_event(event).await?; + fn spool_identity(&self) -> Option { + if self.token().is_none() && self.base_url.is_none() { + return None; + } + Some(SpoolIdentity { + token_hash: self.token_hash(), + service_base_url: self.base_url.clone(), + participant_id: self.participant_id.clone(), + instance_id: self.instance_id.clone(), + }) + } - Ok(()) -``` -} */ + fn matches_request_snapshot(&self, snapshot: &Self) -> bool { + self.generation == snapshot.generation + && self.base_url == snapshot.base_url + && self.token_hash() == snapshot.token_hash() + } +} -pub struct EventCapture { - db_client: Arc>, +fn normalize_service_base_url(value: &str) -> Result { + let mut url = value.trim().trim_end_matches('/').to_string(); + if url.is_empty() { + return Err("capture service URL must not be empty".to_string()); + } + for suffix in ["/v1/capture/events", "/v1/capture/status", "/v1/health"] { + if let Some(base) = url.strip_suffix(suffix) { + url = base.trim_end_matches('/').to_string(); + break; + } + } + + let mut parsed = + url::Url::parse(&url).map_err(|_| "capture service URL must be absolute".to_string())?; + if !parsed.username().is_empty() || parsed.password().is_some() { + return Err("capture service URL must not include credentials".to_string()); + } + let local_http = parsed.scheme() == "http" + && matches!(parsed.host_str(), Some("localhost" | "127.0.0.1" | "::1")); + if parsed.scheme() != "https" && !local_http { + return Err("capture service URL must use https:// except for localhost".to_string()); + } + + parsed.set_query(None); + parsed.set_fragment(None); + let trimmed_path = parsed.path().trim_end_matches('/').to_string(); + parsed.set_path(&trimmed_path); + Ok(parsed.as_str().trim_end_matches('/').to_string()) +} + +/// Known capture worker states reported to the VS Code status UI. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export)] +pub enum CaptureState { + /// Capture worker is not available. + Disabled, + /// Capture worker is starting. + Starting, + /// Events are being written to the local FIFO spool. + Spooling, + /// Events are being uploaded to CaptureWebService. + Uploading, + /// The local spool is empty and the remote service is reachable. + Remote, + /// The token was rejected by the service. + AuthFailed, + /// The service knows the token, but capture is currently not allowed. + CaptureDisabled, + /// The service or network is temporarily unavailable. + ServiceUnavailable, +} + +/// Non-secret token state shown in the capture UI. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export)] +pub enum CaptureTokenStatus { + Missing, + Unverified, + Accepted, + Rejected, + CaptureDisabled, +} + +/// Capture worker health exposed to the VS Code status item. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] +#[ts(export)] +pub struct CaptureStatus { + pub enabled: bool, + pub state: CaptureState, + pub token_status: CaptureTokenStatus, + pub queued_events: u64, + pub spooled_events: u64, + pub uploaded_events: u64, + pub failed_events: u64, + pub quarantined_events: u64, + pub last_error: Option, + pub spool_path: Option, + pub service_base_url: Option, + pub participant_id: Option, + pub instance_id: Option, + pub capture_enabled: Option, + pub participant_status: Option, + pub consent_status: Option, + pub instance_status: Option, + pub token_expires_at: Option, + pub service_version: Option, + pub last_status_check_at: Option, + pub last_upload_at: Option, +} + +impl CaptureStatus { + pub fn disabled() -> Self { + Self { + enabled: false, + state: CaptureState::Disabled, + token_status: CaptureTokenStatus::Missing, + queued_events: 0, + spooled_events: 0, + uploaded_events: 0, + failed_events: 0, + quarantined_events: 0, + last_error: None, + spool_path: None, + service_base_url: None, + participant_id: None, + instance_id: None, + capture_enabled: None, + participant_status: None, + consent_status: None, + instance_status: None, + token_expires_at: None, + service_version: None, + last_status_check_at: None, + last_upload_at: None, + } + } + + fn starting(spool_path: PathBuf) -> Self { + Self { + enabled: true, + state: CaptureState::Starting, + token_status: CaptureTokenStatus::Missing, + spool_path: Some(spool_path), + ..Self::disabled() + } + } +} + +/// The in-memory representation of a single capture event. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CaptureEvent { + pub event_id: Option, + pub sequence_number: Option, + pub schema_version: Option, + pub user_id: String, + pub session_id: Option, + pub event_source: Option, + pub language_id: Option, + pub file_hash: Option, + pub event_type: CaptureEventType, + pub timestamp: DateTime, + pub client_tz_offset_min: Option, + pub data: serde_json::Value, +} + +impl CaptureEvent { + pub fn new( + user_id: String, + file_hash: Option, + event_type: CaptureEventType, + timestamp: DateTime, + data: serde_json::Value, + ) -> Self { + Self { + event_id: None, + sequence_number: None, + schema_version: None, + user_id, + session_id: None, + event_source: None, + language_id: None, + file_hash, + event_type, + timestamp, + client_tz_offset_min: None, + data, + } + } + + #[allow(clippy::too_many_arguments)] + pub fn with_columns( + event_id: Option, + sequence_number: Option, + schema_version: Option, + user_id: String, + session_id: Option, + event_source: Option, + language_id: Option, + file_hash: Option, + event_type: CaptureEventType, + timestamp: DateTime, + client_tz_offset_min: Option, + data: serde_json::Value, + ) -> Self { + Self { + event_id, + sequence_number, + schema_version, + user_id, + session_id, + event_source, + language_id, + file_hash, + event_type, + timestamp, + client_tz_offset_min, + data, + } + } + + pub fn now( + user_id: String, + file_hash: Option, + event_type: CaptureEventType, + data: serde_json::Value, + ) -> Self { + Self::new(user_id, file_hash, event_type, Utc::now(), data) + } +} + +/// Generate a server-side event ID for events classified after the original +/// extension message has been processed. +pub fn generate_capture_event_id(prefix: &str) -> String { + let counter = NEXT_CAPTURE_EVENT_ID.fetch_add(1, Ordering::Relaxed); + format!( + "{prefix}-{}-{}-{counter}", + process::id(), + Utc::now().timestamp_micros() + ) +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +struct SpoolIdentity { + token_hash: Option, + service_base_url: Option, + participant_id: Option, + instance_id: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct SpoolRecord { + spooled_at: DateTime, + #[serde(default)] + identity: Option, + event: CaptureEvent, +} + +#[derive(Debug, Serialize)] +struct CaptureBatchRequest { + schema_version: i32, + #[serde(skip_serializing_if = "Option::is_none")] + participant_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + instance_id: Option, + client_sent_at: String, + events: Vec, +} + +#[derive(Debug, Clone, Serialize)] +struct CaptureServiceEvent { + event_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + sequence_number: Option, + #[serde(skip_serializing_if = "Option::is_none")] + schema_version: Option, + user_id: String, + session_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + event_source: Option, + #[serde(skip_serializing_if = "Option::is_none")] + language_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + file_hash: Option, + event_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + client_tz_offset_min: Option, + client_event_time: String, + data: serde_json::Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CaptureServiceStatusResponse { + pub participant_id: String, + pub instance_id: String, + pub study_id: String, + pub capture_enabled: bool, + pub participant_status: String, + pub consent_status: String, + pub instance_status: String, + pub token_expires_at: Option, + pub server_time: String, + pub service_version: String, +} + +#[derive(Debug, Deserialize)] +struct CaptureBatchAcceptedResponse { + batch_id: String, + accepted: u64, + server_time: String, +} + +#[derive(Debug)] +struct SpoolBatch { + files: Vec, + body: Vec, + event_count: u64, +} + +#[derive(Debug)] +enum NextBatch { + Batch(SpoolBatch), + Empty, + NoMatchingIdentity, +} + +#[derive(Debug)] +struct CaptureHttpError { + status_code: Option, + message: String, +} + +impl CaptureHttpError { + fn transport(message: impl Into) -> Self { + Self { + status_code: None, + message: message.into(), + } + } + + fn response(status_code: i32, message: impl Into) -> Self { + Self { + status_code: Some(status_code), + message: message.into(), + } + } + + fn is_transient(&self) -> bool { + matches!(self.status_code, None | Some(429 | 500 | 503)) + || matches!(self.status_code, Some(code) if code >= 500) + } +} + +enum WorkerMsg { + Flush, } -/* - ## The EventCapture Implementation -*/ +/// Handle used by the rest of the server to record events. +#[derive(Clone)] +pub struct EventCapture { + tx: Sender, + status: Arc>, + config: Arc>, + spool_path: PathBuf, +} 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. + pub fn new(spool_path: PathBuf) -> Result { + fs::create_dir_all(&spool_path)?; + fs::create_dir_all(quarantine_path(&spool_path))?; - # Returns + let status = Arc::new(Mutex::new(CaptureStatus::starting(spool_path.clone()))); + update_spool_count(&spool_path, &status); - A `Result` containing an `EventCapture` instance - */ + let config = Arc::new(Mutex::new(CaptureServiceConfig::default())); + let (tx, rx) = mpsc::channel::(); + let status_worker = status.clone(); + let config_worker = config.clone(); + let spool_worker = spool_path.clone(); - 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))?; + thread::Builder::new() + .name("codechat-capture-upload".to_string()) + .spawn(move || upload_worker(rx, config_worker, status_worker, spool_worker)) + .map_err(|err| { + io::Error::other(format!("Capture: failed to start upload worker: {err}")) + })?; - // 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 - ); + Ok(Self { + tx, + status, + config, + spool_path, + }) + } + + pub fn configure_service(&self, base_url: String, token: Option) -> Result<(), String> { + let mut new_config = CaptureServiceConfig::configured(base_url, token)?; + let token_status = if new_config.token().is_some() { + CaptureTokenStatus::Unverified + } else { + CaptureTokenStatus::Missing + }; + { + let mut config = self + .config + .lock() + .map_err(|_| "capture service config lock is poisoned".to_string())?; + new_config.generation = config.generation.saturating_add(1); + *config = new_config.clone(); + } + update_status(&self.status, |status| { + status.enabled = true; + status.state = CaptureState::Spooling; + status.token_status = token_status; + status.service_base_url = new_config.base_url.clone(); + status.participant_id = None; + status.instance_id = None; + status.capture_enabled = None; + status.participant_status = None; + status.consent_status = None; + status.instance_status = None; + status.token_expires_at = None; + status.service_version = None; + status.last_error = None; + }); + update_spool_count(&self.spool_path, &self.status); + self.signal_flush(); + Ok(()) + } + + pub fn clear_token(&self) { + if let Ok(mut config) = self.config.lock() { + config.generation = config.generation.saturating_add(1); + config.token = None; + config.participant_id = None; + config.instance_id = None; + } + update_status(&self.status, |status| { + status.state = CaptureState::Spooling; + status.token_status = CaptureTokenStatus::Missing; + status.participant_id = None; + status.instance_id = None; + status.capture_enabled = None; + status.participant_status = None; + status.consent_status = None; + status.instance_status = None; + status.token_expires_at = None; + status.service_version = None; + status.last_error = Some("Capture token is not configured".to_string()); + }); + update_spool_count(&self.spool_path, &self.status); + } - info!( - "Attempting Capture Database Connection. IP:[{}] Username:[{}] Database Name:[{}]", - config.db_ip, config.db_user, config.db_name + pub fn check_service_status(&self) -> Result { + let cfg = self + .config + .lock() + .map_err(|_| "capture service config lock is poisoned".to_string())? + .clone(); + let response = request_capture_status(&cfg).map_err(|err| { + apply_http_error_if_current(&self.config, &self.status, &cfg, &err); + err.message + })?; + if !apply_capture_service_status_if_current( + &self.config, + &self.status, + &cfg, + response.clone(), + ) { + return Err("Capture service status response was stale".to_string()); + } + Ok(response) + } + + /// Durably append an event to the local FIFO spool, then ask the worker to + /// upload as soon as service access permits. + pub fn log(&self, event: CaptureEvent) { + debug!( + "Capture: spooling event: type={}, user_id={}, file_hash={:?}", + event.event_type, event.user_id, event.file_hash ); - // 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 spool_identity = match self.config.lock() { + Ok(config) => config.spool_identity(), + Err(err) => { + error!("Capture: FAILED to read capture config before spooling: {err}"); + update_status(&self.status, |status| { + status.failed_events += 1; + status.last_error = Some(format!( + "Failed to read capture config before spooling: {err}" + )); + }); + return; + } + }; + + match append_spool_event(&self.spool_path, &event, spool_identity) { + Ok(()) => { + update_status(&self.status, |status| { + if matches!( + status.state, + CaptureState::Starting | CaptureState::Disabled + ) { + status.state = CaptureState::Spooling; + } + status.last_error = None; + }); + update_spool_count(&self.spool_path, &self.status); + self.signal_flush(); + } + Err(err) => { + error!("Capture: FAILED to append event to spool: {err}"); + update_status(&self.status, |status| { + status.failed_events += 1; + status.last_error = + Some(format!("Failed to append capture event to spool: {err}")); + }); + } + } + } + + fn signal_flush(&self) { + if let Err(err) = self.tx.send(WorkerMsg::Flush) { + error!("Capture: FAILED to notify upload worker: {err}"); + update_status(&self.status, |status| { + status.failed_events += 1; + status.last_error = Some(format!("Failed to notify upload worker: {err}")); + }); + } + } + + pub fn status(&self) -> CaptureStatus { + self.status + .lock() + .map(|status| status.clone()) + .unwrap_or_else(|_| { + let mut status = CaptureStatus::disabled(); + status.last_error = Some("Capture status lock is poisoned".to_string()); + status + }) + } +} + +fn update_status(status: &Arc>, f: impl FnOnce(&mut CaptureStatus)) { + match status.lock() { + Ok(mut guard) => f(&mut guard), + Err(err) => error!("Capture: unable to update status: {err}"), + } +} + +fn upload_worker( + rx: mpsc::Receiver, + config: Arc>, + status: Arc>, + spool_path: PathBuf, +) { + info!("Capture: upload worker started with spool at {spool_path:?}."); + let mut retry_delay = Duration::from_millis(INITIAL_RETRY_DELAY_MS); + loop { + match upload_next_batch(&spool_path, &config, &status) { + UploadOutcome::UploadedBatch => { + retry_delay = Duration::from_millis(INITIAL_RETRY_DELAY_MS); + continue; + } + UploadOutcome::NoEvents => { + retry_delay = Duration::from_millis(INITIAL_RETRY_DELAY_MS); + } + UploadOutcome::NotConfigured | UploadOutcome::Paused => {} + UploadOutcome::TransientFailure => { + warn!( + "Capture: transient upload failure; retrying in {} ms.", + retry_delay.as_millis() + ); + } + } + + let wait_for = if matches!( + capture_status_state(&status), + CaptureState::ServiceUnavailable + ) { + retry_delay + } else { + Duration::from_secs(30) + }; + + match rx.recv_timeout(wait_for) { + Ok(WorkerMsg::Flush) => {} + Err(RecvTimeoutError::Timeout) => {} + Err(RecvTimeoutError::Disconnected) => { + warn!("Capture: upload worker channel closed; worker exiting."); + break; + } + } + + if matches!( + capture_status_state(&status), + CaptureState::ServiceUnavailable + ) { + retry_delay = retry_delay + .saturating_mul(2) + .min(Duration::from_millis(MAX_RETRY_DELAY_MS)); + } + } +} + +fn capture_status_state(status: &Arc>) -> CaptureState { + status + .lock() + .map(|status| status.state) + .unwrap_or(CaptureState::Disabled) +} + +#[derive(Debug, PartialEq, Eq)] +enum UploadOutcome { + UploadedBatch, + NoEvents, + NotConfigured, + Paused, + TransientFailure, +} + +fn upload_next_batch( + spool_path: &Path, + config: &Arc>, + status: &Arc>, +) -> UploadOutcome { + update_spool_count(spool_path, status); - // Spawn a task to manage the database connection in the background - tokio::spawn(async move { - if let Err(e) = connection.await { - error!("Database connection error: [{e}]"); + let cfg = match config.lock() { + Ok(config) => config.clone(), + Err(err) => { + update_status(status, |status| { + status.failed_events += 1; + status.last_error = Some(format!("Capture config lock failed: {err}")); + }); + return UploadOutcome::Paused; + } + }; + + let Some(token) = cfg.token().map(str::to_string) else { + update_status(status, |status| { + status.state = CaptureState::Spooling; + status.token_status = CaptureTokenStatus::Missing; + status.last_error = Some("Capture token is not configured".to_string()); + }); + return UploadOutcome::NotConfigured; + }; + let Some(events_url) = cfg.events_url() else { + update_status(status, |status| { + status.state = CaptureState::Spooling; + status.last_error = Some("Capture service URL is not configured".to_string()); + }); + return UploadOutcome::NotConfigured; + }; + + if cfg.participant_id.is_none() || cfg.instance_id.is_none() { + match request_capture_status(&cfg) { + Ok(service_status) => { + if !apply_capture_service_status_if_current(config, status, &cfg, service_status) { + return UploadOutcome::Paused; + } + } + Err(err) => { + apply_http_error_if_current(config, status, &cfg, &err); + return if err.is_transient() { + UploadOutcome::TransientFailure + } else { + UploadOutcome::Paused + }; } + } + } + + let cfg = match config.lock() { + Ok(config) => config.clone(), + Err(err) => { + update_status(status, |status| { + status.failed_events += 1; + status.last_error = Some(format!("Capture config lock failed: {err}")); + }); + return UploadOutcome::Paused; + } + }; + + if !matches!( + capture_status_state(status), + CaptureState::Remote | CaptureState::Spooling | CaptureState::Uploading + ) { + return UploadOutcome::Paused; + } + + let batch = match build_next_batch(spool_path, &cfg, status) { + NextBatch::Batch(batch) => batch, + NextBatch::Empty => { + update_status(status, |status| { + if !matches!( + status.state, + CaptureState::AuthFailed | CaptureState::CaptureDisabled + ) { + status.state = CaptureState::Remote; + } + }); + return UploadOutcome::NoEvents; + } + NextBatch::NoMatchingIdentity => { + update_status(status, |status| { + status.state = CaptureState::Spooling; + status.last_error = + Some("Pending capture events belong to a different capture token".to_string()); + }); + return UploadOutcome::NoEvents; + } + }; + + if !run_if_capture_config_snapshot_is_current(config, &cfg, || { + update_status(status, |status| { + status.state = CaptureState::Uploading; + status.last_error = None; }); + }) { + return UploadOutcome::Paused; + } - info!( - "Connected to Database [{}] as User [{}]", - config.db_name, config.db_user - ); + match post_capture_batch(&events_url, &token, &batch.body) { + Ok(accepted) => { + if !run_if_capture_config_snapshot_is_current(config, &cfg, || { + for file in &batch.files { + if let Err(err) = fs::remove_file(file) { + warn!("Capture: unable to remove uploaded spool file {file:?}: {err}"); + } + } + update_status(status, |status| { + status.state = CaptureState::Remote; + status.uploaded_events += accepted.accepted; + status.spooled_events = status.spooled_events.saturating_sub(batch.event_count); + status.last_upload_at = Some(accepted.server_time.clone()); + status.last_error = None; + }); + }) { + return UploadOutcome::Paused; + } + update_spool_count(spool_path, status); + debug!( + "Capture: uploaded batch {} with {} event(s).", + accepted.batch_id, accepted.accepted + ); + UploadOutcome::UploadedBatch + } + Err(err) => match err.status_code { + Some(401) => { + apply_http_error_if_current(config, status, &cfg, &err); + UploadOutcome::Paused + } + Some(403) => { + apply_http_error_if_current(config, status, &cfg, &err); + UploadOutcome::Paused + } + Some(400 | 413) => { + if !run_if_capture_config_snapshot_is_current(config, &cfg, || { + quarantine_files( + spool_path, + &batch.files, + &format!("Capture service rejected batch: {}", err.message), + status, + ); + }) { + return UploadOutcome::Paused; + } + update_spool_count(spool_path, status); + UploadOutcome::UploadedBatch + } + _ if err.is_transient() => { + apply_http_error_if_current(config, status, &cfg, &err); + UploadOutcome::TransientFailure + } + _ => { + apply_http_error_if_current(config, status, &cfg, &err); + UploadOutcome::Paused + } + }, + } +} + +fn append_spool_event( + spool_path: &Path, + event: &CaptureEvent, + identity: Option, +) -> io::Result<()> { + fs::create_dir_all(spool_path)?; + let counter = NEXT_SPOOL_FILE_ID.fetch_add(1, Ordering::Relaxed); + let timestamp = Utc::now().format("%Y%m%d%H%M%S%6f"); + let path = spool_path.join(format!( + "{}-{}-{:020}.json", + timestamp, + process::id(), + counter + )); + let tmp_path = path.with_extension("tmp"); + let mut event = event.clone(); + event.data = sanitize_capture_data(event.data).map_err(io::Error::other)?; + let record = SpoolRecord { + spooled_at: Utc::now(), + identity, + event, + }; + + { + let mut file = File::create(&tmp_path)?; + serde_json::to_writer(&mut file, &record) + .map_err(|err| io::Error::other(err.to_string()))?; + writeln!(file)?; + file.sync_all()?; + } + fs::rename(tmp_path, path)?; + Ok(()) +} + +fn pending_spool_files(spool_path: &Path) -> io::Result> { + let mut files = Vec::new(); + if !spool_path.exists() { + return Ok(files); + } + for entry in fs::read_dir(spool_path)? { + let entry = entry?; + let path = entry.path(); + if path.is_file() && path.extension().and_then(|ext| ext.to_str()) == Some("json") { + files.push(path); + } + } + files.sort_by(|a, b| a.file_name().cmp(&b.file_name())); + Ok(files) +} + +fn update_spool_count(spool_path: &Path, status: &Arc>) { + match pending_spool_files(spool_path) { + Ok(files) => update_status(status, |status| { + let count = files.len() as u64; + status.queued_events = count; + status.spooled_events = count; + }), + Err(err) => update_status(status, |status| { + status.failed_events += 1; + status.last_error = Some(format!("Unable to inspect capture spool: {err}")); + }), + } +} + +fn build_next_batch( + spool_path: &Path, + cfg: &CaptureServiceConfig, + status: &Arc>, +) -> NextBatch { + let files = match pending_spool_files(spool_path) { + Ok(files) => files, + Err(err) => { + update_status(status, |status| { + status.failed_events += 1; + status.last_error = Some(format!("Unable to read capture spool: {err}")); + }); + return NextBatch::Empty; + } + }; + if files.is_empty() { + return NextBatch::Empty; + } + + let mut selected_files = Vec::new(); + let mut events = Vec::new(); + let mut body = Vec::new(); + let mut skipped_identity_mismatch = false; + + for file in files { + if selected_files.len() >= MAX_CAPTURE_BATCH_EVENTS { + break; + } + + let record = match read_spool_record(&file) { + Ok(record) => record, + Err(err) => { + quarantine_files( + spool_path, + std::slice::from_ref(&file), + &format!("Invalid local spool record: {err}"), + status, + ); + continue; + } + }; + + if !spool_record_matches_current_identity(&record, cfg) { + skipped_identity_mismatch = true; + continue; + } + + if let Some(participant_id) = cfg.participant_id.as_deref() + && record.event.user_id != participant_id + { + quarantine_files( + spool_path, + std::slice::from_ref(&file), + "Capture event user_id does not match current capture token", + status, + ); + continue; + } - Ok(EventCapture { - db_client: Arc::new(Mutex::new(client)), + let event = match service_event_from_capture_event(record.event) { + Ok(event) => event, + Err(err) => { + quarantine_files( + spool_path, + std::slice::from_ref(&file), + &format!("Invalid capture event for service upload: {err}"), + status, + ); + continue; + } + }; + + let mut candidate_events = events.clone(); + candidate_events.push(event); + let candidate_batch = CaptureBatchRequest { + schema_version: DEFAULT_CAPTURE_SCHEMA_VERSION, + participant_id: cfg.participant_id.clone(), + instance_id: cfg.instance_id.clone(), + client_sent_at: Utc::now().to_rfc3339(), + events: candidate_events, + }; + + let candidate_body = match serde_json::to_vec(&candidate_batch) { + Ok(body) => body, + Err(err) => { + quarantine_files( + spool_path, + std::slice::from_ref(&file), + &format!("Unable to serialize capture batch: {err}"), + status, + ); + continue; + } + }; + + if candidate_body.len() > MAX_CAPTURE_BATCH_BYTES { + if selected_files.is_empty() { + quarantine_files( + spool_path, + std::slice::from_ref(&file), + "Single capture event exceeds service payload size limit", + status, + ); + continue; + } + break; + } + + events = candidate_batch.events; + body = candidate_body; + selected_files.push(file); + } + + if selected_files.is_empty() { + if skipped_identity_mismatch { + NextBatch::NoMatchingIdentity + } else { + NextBatch::Empty + } + } else { + NextBatch::Batch(SpoolBatch { + event_count: events.len() as u64, + files: selected_files, + body, }) } +} - /* - Inserts an event into the database. - - # Arguments - - `event`: An `Event` instance containing the event data to insert. - - # Returns - A `Result` indicating success or containing a `tokio_postgres::Error`. - - # Example - #[tokio::main] - async fn main() -> Result<(), Box> { - let event_capture = EventCapture::new("config.json").await?; - - let event = Event { - user_id: "user123".to_string(), - event_type: "keystroke".to_string(), - data: Some("Pressed key A".to_string()), - }; - - event_capture.insert_event(event).await?; - Ok(()) - } - */ - - pub async fn insert_event(&self, event: Event) -> Result<(), io::Error> { - let current_time = Local::now(); - let formatted_time = current_time.to_rfc3339(); - - // 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) - "}; - - // Acquire a lock on the database client for thread-safe access - let client = self.db_client.lock().await; - - // Execute the SQL statement with the event data - client - .execute( - stmt, - &[ - &event.user_id, - &event.event_type, - &formatted_time, - &event.data, - ], - ) - .await - .map_err(io::Error::other)?; +fn spool_record_matches_current_identity(record: &SpoolRecord, cfg: &CaptureServiceConfig) -> bool { + if let Some(identity) = &record.identity { + return identity.token_hash == cfg.token_hash() + && identity.service_base_url == cfg.base_url; + } - info!("Event inserted into database: {event:?}"); + cfg.participant_id + .as_deref() + .map(|participant_id| record.event.user_id == participant_id) + .unwrap_or(false) +} - Ok(()) +fn read_spool_record(path: &Path) -> Result { + let text = fs::read_to_string(path).map_err(|err| err.to_string())?; + serde_json::from_str(&text).map_err(|err| err.to_string()) +} + +fn service_event_from_capture_event( + mut event: CaptureEvent, +) -> Result { + let event_id = event + .event_id + .take() + .filter(|event_id| !event_id.trim().is_empty()) + .ok_or_else(|| "event_id is required".to_string())?; + if event_id.len() > 128 { + return Err("event_id exceeds 128 characters".to_string()); + } + let session_id = event + .session_id + .take() + .filter(|session_id| !session_id.trim().is_empty()) + .ok_or_else(|| "session_id is required".to_string())?; + if session_id.len() > 128 { + return Err("session_id exceeds 128 characters".to_string()); + } + + let data = sanitize_capture_data(event.data)?; + Ok(CaptureServiceEvent { + event_id, + sequence_number: event.sequence_number, + schema_version: event.schema_version, + user_id: event.user_id, + session_id, + event_source: event.event_source, + language_id: event.language_id, + file_hash: event.file_hash, + event_type: event.event_type.as_str().to_string(), + client_tz_offset_min: event.client_tz_offset_min, + client_event_time: event.timestamp.to_rfc3339(), + data, + }) +} + +fn sanitize_capture_data(value: serde_json::Value) -> Result { + let serde_json::Value::Object(map) = value else { + return Err("capture event data must be a JSON object".to_string()); + }; + Ok(serde_json::Value::Object(sanitize_capture_object(map))) +} + +fn sanitize_capture_object( + map: serde_json::Map, +) -> serde_json::Map { + const FORBIDDEN_KEYS: &[&str] = &["file_path", "path", "absolute_path", "workspace_path"]; + map.into_iter() + .filter_map(|(key, value)| { + if FORBIDDEN_KEYS.contains(&key.as_str()) { + return None; + } + let value = match value { + serde_json::Value::Object(map) => { + serde_json::Value::Object(sanitize_capture_object(map)) + } + serde_json::Value::Array(values) => serde_json::Value::Array( + values + .into_iter() + .map(|value| match value { + serde_json::Value::Object(map) => { + serde_json::Value::Object(sanitize_capture_object(map)) + } + other => other, + }) + .collect(), + ), + other => other, + }; + Some((key, value)) + }) + .collect() +} + +fn post_capture_batch( + events_url: &str, + token: &str, + body: &[u8], +) -> Result { + post_capture_batch_with_timeout(events_url, token, body, CAPTURE_SERVICE_HTTP_TIMEOUT_SECS) +} + +fn post_capture_batch_with_timeout( + events_url: &str, + token: &str, + body: &[u8], + timeout_secs: u64, +) -> Result { + let response = minreq::post(events_url) + .with_header("Authorization", format!("Bearer {token}")) + .with_header("Content-Type", "application/json") + .with_body(body.to_vec()) + .with_timeout(timeout_secs) + .send() + .map_err(|err| CaptureHttpError::transport(err.to_string()))?; + + if response.status_code != 202 { + return Err(CaptureHttpError::response( + i32::from(response.status_code), + response + .as_str() + .unwrap_or(response.reason_phrase.as_str()) + .to_string(), + )); + } + serde_json::from_slice(response.as_bytes()).map_err(|err| { + CaptureHttpError::response(202, format!("Invalid capture accepted response: {err}")) + }) +} + +fn request_capture_status( + cfg: &CaptureServiceConfig, +) -> Result { + request_capture_status_with_timeout(cfg, CAPTURE_SERVICE_HTTP_TIMEOUT_SECS) +} + +fn request_capture_status_with_timeout( + cfg: &CaptureServiceConfig, + timeout_secs: u64, +) -> Result { + let token = cfg + .token() + .ok_or_else(|| CaptureHttpError::response(401, "Capture token is not configured"))?; + let status_url = cfg + .status_url() + .ok_or_else(|| CaptureHttpError::transport("Capture service URL is not configured"))?; + let response = minreq::get(status_url) + .with_header("Authorization", format!("Bearer {token}")) + .with_timeout(timeout_secs) + .send() + .map_err(|err| CaptureHttpError::transport(err.to_string()))?; + + if response.status_code != 200 { + return Err(CaptureHttpError::response( + i32::from(response.status_code), + response + .as_str() + .unwrap_or(response.reason_phrase.as_str()) + .to_string(), + )); + } + serde_json::from_slice(response.as_bytes()).map_err(|err| { + CaptureHttpError::response(200, format!("Invalid capture status response: {err}")) + }) +} + +fn run_if_capture_config_snapshot_is_current( + config: &Arc>, + snapshot: &CaptureServiceConfig, + action: impl FnOnce(), +) -> bool { + let Ok(current) = config.lock() else { + return false; + }; + if !current.matches_request_snapshot(snapshot) { + return false; + } + action(); + true +} + +fn apply_capture_service_status_if_current( + config: &Arc>, + status: &Arc>, + snapshot: &CaptureServiceConfig, + response: CaptureServiceStatusResponse, +) -> bool { + if let Ok(mut config) = config.lock() { + if !config.matches_request_snapshot(snapshot) { + return false; + } + config.participant_id = Some(response.participant_id.clone()); + config.instance_id = Some(response.instance_id.clone()); + update_status(status, |status| { + status.token_status = if response.capture_enabled { + CaptureTokenStatus::Accepted + } else { + CaptureTokenStatus::CaptureDisabled + }; + status.state = if response.capture_enabled { + CaptureState::Remote + } else { + CaptureState::CaptureDisabled + }; + status.participant_id = Some(response.participant_id); + status.instance_id = Some(response.instance_id); + status.capture_enabled = Some(response.capture_enabled); + status.participant_status = Some(response.participant_status); + status.consent_status = Some(response.consent_status); + status.instance_status = Some(response.instance_status); + status.token_expires_at = response.token_expires_at; + status.service_version = Some(response.service_version); + status.last_status_check_at = Some(Utc::now().to_rfc3339()); + status.last_error = if response.capture_enabled { + None + } else { + Some("Capture is disabled by the portal/service".to_string()) + }; + }); + } else { + return false; } + true } -/* Database Schema (SQL DDL) +fn apply_http_error(status: &Arc>, err: &CaptureHttpError) { + update_status(status, |status| { + status.failed_events += 1; + match err.status_code { + Some(401) => { + status.state = CaptureState::AuthFailed; + status.token_status = CaptureTokenStatus::Rejected; + } + Some(403) => { + status.state = CaptureState::CaptureDisabled; + status.token_status = CaptureTokenStatus::CaptureDisabled; + } + _ if err.is_transient() => { + status.state = CaptureState::ServiceUnavailable; + } + _ => {} + } + status.last_error = Some(err.message.clone()); + }); +} + +fn apply_http_error_if_current( + config: &Arc>, + status: &Arc>, + snapshot: &CaptureServiceConfig, + err: &CaptureHttpError, +) -> bool { + run_if_capture_config_snapshot_is_current(config, snapshot, || { + apply_http_error(status, err); + }) +} + +fn quarantine_path(spool_path: &Path) -> PathBuf { + spool_path.join("quarantine") +} + +fn quarantine_files( + spool_path: &Path, + files: &[PathBuf], + reason: &str, + status: &Arc>, +) { + let quarantine = quarantine_path(spool_path); + if let Err(err) = fs::create_dir_all(&quarantine) { + update_status(status, |status| { + status.failed_events += 1; + status.last_error = Some(format!("Unable to create quarantine directory: {err}")); + }); + return; + } -The following SQL statement creates the `events` table used by this library: + for file in files { + let file_name = file + .file_name() + .map(|name| name.to_owned()) + .unwrap_or_else(|| "capture-event.json".into()); + let target = quarantine.join(file_name); + if let Err(err) = fs::rename(file, &target).or_else(|_| { + fs::copy(file, &target)?; + fs::remove_file(file) + }) { + update_status(status, |status| { + status.failed_events += 1; + status.last_error = Some(format!("Unable to quarantine {file:?}: {err}")); + }); + continue; + } + let reason_path = target.with_extension("reason.txt"); + if let Err(err) = fs::write(&reason_path, reason) { + warn!("Capture: unable to write quarantine reason {reason_path:?}: {err}"); + } + update_status(status, |status| { + status.quarantined_events += 1; + status.last_error = Some(reason.to_string()); + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::{ + fs, + io::{Read, Write}, + net::TcpListener, + thread, + time::{Duration, Instant}, + }; + + fn temp_spool_path(test_name: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "codechat-capture-{test_name}-{}-{}", + std::process::id(), + Utc::now().timestamp_nanos_opt().unwrap_or_default() + )) + } -CREATE TABLE events ( id SERIAL PRIMARY KEY, user_id TEXT NOT NULL, -event_type TEXT NOT NULL, timestamp TEXT NOT NULL, data TEXT ); + fn capture_test_event(user_id: &str, event_id: &str) -> CaptureEvent { + CaptureEvent::with_columns( + Some(event_id.to_string()), + Some(1), + Some(2), + user_id.to_string(), + Some("session-1".to_string()), + Some("vscode_extension".to_string()), + Some("rust".to_string()), + Some("file-hash".to_string()), + CaptureEventType::Save, + Utc::now(), + Some(360), + json!({ "reason": "unit_test" }), + ) + } + + fn capture_service_config(token: &str, generation: u64) -> CaptureServiceConfig { + let mut cfg = CaptureServiceConfig::configured( + "https://capture.example/dev".to_string(), + Some(token.to_string()), + ) + .expect("capture service config should parse"); + cfg.generation = generation; + cfg + } + + fn capture_service_config_with_base_url( + base_url: &str, + token: &str, + generation: u64, + participant_id: &str, + ) -> CaptureServiceConfig { + let mut cfg = + CaptureServiceConfig::configured(base_url.to_string(), Some(token.to_string())) + .expect("capture service config should parse"); + cfg.generation = generation; + cfg.participant_id = Some(participant_id.to_string()); + cfg.instance_id = Some(format!("{participant_id}-instance")); + cfg + } + + fn capture_service_status_response(participant_id: &str) -> CaptureServiceStatusResponse { + CaptureServiceStatusResponse { + participant_id: participant_id.to_string(), + instance_id: format!("{participant_id}-instance"), + study_id: "study-2026".to_string(), + capture_enabled: true, + participant_status: "active".to_string(), + consent_status: "consented".to_string(), + instance_status: "active".to_string(), + token_expires_at: None, + server_time: "2026-07-12T16:10:04Z".to_string(), + service_version: "0.1.0".to_string(), + } + } + + fn start_delayed_capture_events_server( + status_code: u16, + body: &'static str, + ) -> ( + String, + std::sync::mpsc::Receiver<()>, + std::sync::mpsc::Sender<()>, + thread::JoinHandle<()>, + ) { + let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind"); + let base_url = format!( + "http://{}", + listener.local_addr().expect("listener should have address") + ); + let (request_seen_tx, request_seen_rx) = std::sync::mpsc::channel(); + let (respond_tx, respond_rx) = std::sync::mpsc::channel(); + let handle = thread::spawn(move || { + let (mut stream, _addr) = listener.accept().expect("request should arrive"); + let mut request = Vec::new(); + let mut buffer = [0; 1024]; + loop { + let read = stream.read(&mut buffer).expect("request should read"); + if read == 0 { + break; + } + request.extend_from_slice(&buffer[..read]); + if request.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + request_seen_tx + .send(()) + .expect("request notification should send"); + respond_rx.recv().expect("response release should arrive"); + let reason = match status_code { + 202 => "Accepted", + 400 => "Bad Request", + 413 => "Payload Too Large", + _ => "Response", + }; + write!( + stream, + "HTTP/1.1 {status_code} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .expect("response should write"); + }); + (base_url, request_seen_rx, respond_tx, handle) + } + + fn mark_config_replaced_during_upload( + config: &Arc>, + status: &Arc>, + base_url: &str, + ) { + *config.lock().expect("config lock should not be poisoned") = + capture_service_config_with_base_url(base_url, "new-token", 2, "new-user"); + update_status(status, |status| { + status.state = CaptureState::Spooling; + status.token_status = CaptureTokenStatus::Unverified; + status.participant_id = None; + status.instance_id = None; + status.uploaded_events = 0; + status.quarantined_events = 0; + status.last_upload_at = None; + status.last_error = None; + }); + } + + #[test] + fn capture_event_type_uses_stable_serialized_strings() { + assert_eq!( + serde_json::to_value(CaptureEventType::WriteDoc).unwrap(), + json!("write_doc") + ); + assert_eq!( + serde_json::from_value::(json!("compile_end")).unwrap(), + CaptureEventType::CompileEnd + ); + assert_eq!( + serde_json::to_value(CaptureEventType::CaptureSettingsChanged).unwrap(), + json!("capture_settings_changed") + ); + assert!(serde_json::from_value::(json!("random")).is_err()); + } + + #[test] + fn capture_event_new_sets_all_fields() { + let ts = Utc::now(); + + let ev = CaptureEvent::new( + "user123".to_string(), + Some("hashed-path".to_string()), + CaptureEventType::WriteDoc, + ts, + json!({ "chars_typed": 42 }), + ); + + assert_eq!(ev.user_id, "user123"); + assert_eq!(ev.file_hash.as_deref(), Some("hashed-path")); + assert_eq!(ev.event_type, CaptureEventType::WriteDoc); + assert_eq!(ev.timestamp, ts); + assert!(ev.event_id.is_none()); + assert_eq!(ev.data, json!({ "chars_typed": 42 })); + } -- **`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. */ + #[test] + fn capture_event_now_uses_current_time_and_fields() { + let before = Utc::now(); + let ev = CaptureEvent::now( + "user123".to_string(), + None, + CaptureEventType::Save, + json!({ "reason": "manual" }), + ); + let after = Utc::now(); + + assert_eq!(ev.user_id, "user123"); + assert!(ev.file_hash.is_none()); + assert_eq!(ev.event_type, CaptureEventType::Save); + assert_eq!(ev.data, json!({ "reason": "manual" })); + assert!(ev.timestamp >= before); + assert!(ev.timestamp <= after); + } + + #[test] + fn capture_spool_writes_fifo_json_records() { + let spool_path = temp_spool_path("spool-test"); + let _ = fs::remove_dir_all(&spool_path); + + let capture = EventCapture::new(spool_path.clone()).expect("capture worker should start"); + capture.log(CaptureEvent::with_columns( + Some("event-1".to_string()), + Some(1), + Some(2), + "participant".to_string(), + Some("session".to_string()), + Some("test".to_string()), + Some("rust".to_string()), + Some("file-hash".to_string()), + CaptureEventType::Save, + Utc::now(), + Some(360), + json!({ "reason": "unit_test" }), + )); + + let mut text = String::new(); + for _ in 0..20 { + if let Ok(files) = pending_spool_files(&spool_path) + && let Some(path) = files.first() + && let Ok(contents) = fs::read_to_string(path) + { + text = contents; + if text.contains("\"event_id\":\"event-1\"") { + break; + } + } + thread::sleep(Duration::from_millis(50)); + } + + assert!(text.contains("\"event_type\":\"save\"")); + assert!(text.contains("\"spooled_at\"")); + assert_eq!(capture.status().state, CaptureState::Spooling); + let _ = fs::remove_dir_all(&spool_path); + } + + #[test] + fn capture_spool_sanitizes_forbidden_path_keys_before_disk() { + let spool_path = temp_spool_path("spool-sanitize-test"); + let _ = fs::remove_dir_all(&spool_path); + let mut event = capture_test_event("participant", "event-sanitized"); + event.data = json!({ + "file_path": "C:/secret.rs", + "nested": { "path": "/secret" }, + "items": [{ "absolute_path": "/secret2", "ok": true }] + }); + + append_spool_event(&spool_path, &event, None).expect("event should spool"); + let files = pending_spool_files(&spool_path).expect("spool should list"); + let record = read_spool_record(&files[0]).expect("spool record should parse"); + + assert!(record.event.data.get("file_path").is_none()); + assert!(record.event.data.pointer("/nested/path").is_none()); + assert!( + record + .event + .data + .pointer("/items/0/absolute_path") + .is_none() + ); + assert_eq!(record.event.data.pointer("/items/0/ok"), Some(&json!(true))); + let _ = fs::remove_dir_all(&spool_path); + } + + #[test] + fn capture_event_with_columns_sets_analysis_columns() { + let ts = Utc::now(); + + let ev = CaptureEvent::with_columns( + Some("abc-123".to_string()), + Some(42), + Some(2), + "user123".to_string(), + Some("session-1".to_string()), + Some("vscode_extension".to_string()), + Some("rust".to_string()), + Some("hash".to_string()), + CaptureEventType::WriteCode, + ts, + Some(-360), + json!({ "chars_typed": 42 }), + ); + + assert_eq!(ev.event_id.as_deref(), Some("abc-123")); + assert_eq!(ev.sequence_number, Some(42)); + assert_eq!(ev.schema_version, Some(2)); + assert_eq!(ev.session_id.as_deref(), Some("session-1")); + assert_eq!(ev.event_source.as_deref(), Some("vscode_extension")); + assert_eq!(ev.language_id.as_deref(), Some("rust")); + assert_eq!(ev.file_hash.as_deref(), Some("hash")); + assert_eq!(ev.client_tz_offset_min, Some(-360)); + assert_eq!(ev.data, json!({ "chars_typed": 42 })); + } + + #[test] + fn capture_service_payload_sanitizes_forbidden_path_keys() { + let ev = CaptureEvent::with_columns( + Some("event-1".to_string()), + Some(1), + Some(2), + "user123".to_string(), + Some("session-1".to_string()), + Some("vscode_extension".to_string()), + Some("rust".to_string()), + Some(hash_capture_path("src/lib.rs")), + CaptureEventType::Save, + Utc::now(), + Some(360), + json!({ + "reason": "manual", + "file_path": "C:/secret.rs", + "nested": { "path": "/secret" }, + "items": [{ "absolute_path": "/secret2", "ok": true }] + }), + ); + let service_event = service_event_from_capture_event(ev).expect("event should convert"); + + assert_eq!(service_event.event_type, "save"); + assert_eq!(service_event.session_id, "session-1"); + assert!(service_event.data.get("file_path").is_none()); + assert!(service_event.data.pointer("/nested/path").is_none()); + assert!( + service_event + .data + .pointer("/items/0/absolute_path") + .is_none() + ); + assert_eq!( + service_event.data.pointer("/items/0/ok"), + Some(&json!(true)) + ); + } + + #[test] + fn stale_capture_status_response_does_not_overwrite_current_token_identity() { + let old_snapshot = capture_service_config("old-token", 1); + let new_config = capture_service_config("new-token", 2); + let config = Arc::new(Mutex::new(new_config.clone())); + let status = Arc::new(Mutex::new(CaptureStatus::starting(temp_spool_path( + "stale-status-test", + )))); + + assert!(!apply_capture_service_status_if_current( + &config, + &status, + &old_snapshot, + capture_service_status_response("old-user"), + )); + + let current = config.lock().expect("config lock should not be poisoned"); + assert_eq!(current.token_hash(), new_config.token_hash()); + assert_eq!(current.participant_id, None); + assert_eq!(current.instance_id, None); + drop(current); + + let status = status.lock().expect("status lock should not be poisoned"); + assert_eq!(status.participant_id, None); + assert_ne!(status.token_status, CaptureTokenStatus::Accepted); + } + + #[test] + fn stale_capture_http_error_does_not_mark_current_token_rejected() { + let old_snapshot = capture_service_config("old-token", 1); + let new_config = capture_service_config("new-token", 2); + let config = Arc::new(Mutex::new(new_config)); + let status = Arc::new(Mutex::new(CaptureStatus::starting(temp_spool_path( + "stale-error-test", + )))); + let err = CaptureHttpError::response(401, "old token rejected"); + + assert!(!apply_http_error_if_current( + &config, + &status, + &old_snapshot, + &err, + )); + + let status = status.lock().expect("status lock should not be poisoned"); + assert_ne!(status.state, CaptureState::AuthFailed); + assert_ne!(status.token_status, CaptureTokenStatus::Rejected); + assert_eq!(status.last_error, None); + } + + #[test] + fn stale_capture_post_success_does_not_delete_spooled_events() { + let spool_path = temp_spool_path("stale-post-success-test"); + let _ = fs::remove_dir_all(&spool_path); + let (base_url, request_seen_rx, respond_tx, server_handle) = + start_delayed_capture_events_server( + 202, + r#"{"batch_id":"batch-1","accepted":1,"server_time":"2026-07-12T16:10:05Z"}"#, + ); + let old_cfg = capture_service_config_with_base_url(&base_url, "old-token", 1, "old-user"); + append_spool_event( + &spool_path, + &capture_test_event("old-user", "old-post-success-event"), + old_cfg.spool_identity(), + ) + .expect("old event should spool"); + let config = Arc::new(Mutex::new(old_cfg)); + let status = Arc::new(Mutex::new(CaptureStatus::starting(spool_path.clone()))); + update_status(&status, |status| { + status.state = CaptureState::Spooling; + }); + let upload_config = config.clone(); + let upload_status = status.clone(); + let upload_spool_path = spool_path.clone(); + let upload_handle = thread::spawn(move || { + upload_next_batch(&upload_spool_path, &upload_config, &upload_status) + }); + + request_seen_rx + .recv_timeout(Duration::from_secs(2)) + .expect("upload request should start"); + mark_config_replaced_during_upload(&config, &status, &base_url); + respond_tx.send(()).expect("response should release"); + + assert_eq!( + upload_handle.join().expect("upload thread should finish"), + UploadOutcome::Paused, + ); + server_handle.join().expect("server thread should finish"); + assert_eq!( + pending_spool_files(&spool_path) + .expect("spool should list") + .len(), + 1 + ); + let status = status.lock().expect("status lock should not be poisoned"); + assert_eq!(status.state, CaptureState::Spooling); + assert_eq!(status.uploaded_events, 0); + assert_eq!(status.last_upload_at, None); + drop(status); + let _ = fs::remove_dir_all(&spool_path); + } + + #[test] + fn stale_capture_post_validation_failure_does_not_quarantine_spooled_events() { + let spool_path = temp_spool_path("stale-post-validation-test"); + let _ = fs::remove_dir_all(&spool_path); + let (base_url, request_seen_rx, respond_tx, server_handle) = + start_delayed_capture_events_server(400, r#"{"error":{"message":"invalid"}}"#); + let old_cfg = capture_service_config_with_base_url(&base_url, "old-token", 1, "old-user"); + append_spool_event( + &spool_path, + &capture_test_event("old-user", "old-post-validation-event"), + old_cfg.spool_identity(), + ) + .expect("old event should spool"); + let config = Arc::new(Mutex::new(old_cfg)); + let status = Arc::new(Mutex::new(CaptureStatus::starting(spool_path.clone()))); + update_status(&status, |status| { + status.state = CaptureState::Spooling; + }); + let upload_config = config.clone(); + let upload_status = status.clone(); + let upload_spool_path = spool_path.clone(); + let upload_handle = thread::spawn(move || { + upload_next_batch(&upload_spool_path, &upload_config, &upload_status) + }); + + request_seen_rx + .recv_timeout(Duration::from_secs(2)) + .expect("upload request should start"); + mark_config_replaced_during_upload(&config, &status, &base_url); + respond_tx.send(()).expect("response should release"); + + assert_eq!( + upload_handle.join().expect("upload thread should finish"), + UploadOutcome::Paused, + ); + server_handle.join().expect("server thread should finish"); + assert_eq!( + pending_spool_files(&spool_path) + .expect("spool should list") + .len(), + 1 + ); + assert_eq!( + pending_spool_files(&quarantine_path(&spool_path)) + .expect("quarantine should list") + .len(), + 0 + ); + let status = status.lock().expect("status lock should not be poisoned"); + assert_eq!(status.state, CaptureState::Spooling); + assert_eq!(status.quarantined_events, 0); + assert_eq!(status.last_error, None); + drop(status); + let _ = fs::remove_dir_all(&spool_path); + } + + #[test] + fn capture_batch_skips_spool_records_for_other_tokens() { + let spool_path = temp_spool_path("spool-token-test"); + let _ = fs::remove_dir_all(&spool_path); + + let mut old_cfg = CaptureServiceConfig::configured( + "https://capture.example/dev".to_string(), + Some("old-token".to_string()), + ) + .expect("old config should parse"); + old_cfg.participant_id = Some("old-user".to_string()); + old_cfg.instance_id = Some("old-instance".to_string()); + let mut new_cfg = CaptureServiceConfig::configured( + "https://capture.example/dev".to_string(), + Some("new-token".to_string()), + ) + .expect("new config should parse"); + new_cfg.participant_id = Some("new-user".to_string()); + new_cfg.instance_id = Some("new-instance".to_string()); + + append_spool_event( + &spool_path, + &capture_test_event("old-user", "old-event"), + old_cfg.spool_identity(), + ) + .expect("old event should spool"); + append_spool_event( + &spool_path, + &capture_test_event("new-user", "new-event"), + new_cfg.spool_identity(), + ) + .expect("new event should spool"); + + let status = Arc::new(Mutex::new(CaptureStatus::starting(spool_path.clone()))); + let batch = match build_next_batch(&spool_path, &new_cfg, &status) { + NextBatch::Batch(batch) => batch, + other => panic!("expected matching batch, got {other:?}"), + }; + let body: serde_json::Value = + serde_json::from_slice(&batch.body).expect("batch body should parse"); + + assert_eq!(batch.files.len(), 1); + assert_eq!( + body.pointer("/events/0/event_id"), + Some(&json!("new-event")) + ); + assert_eq!(body.pointer("/events/0/user_id"), Some(&json!("new-user"))); + assert_eq!(body.pointer("/events/1"), None); + assert_eq!( + pending_spool_files(&spool_path) + .expect("spool should list") + .len(), + 2 + ); + let _ = fs::remove_dir_all(&spool_path); + } + + #[test] + fn capture_batch_reports_when_only_other_token_records_are_pending() { + let spool_path = temp_spool_path("spool-token-mismatch-test"); + let _ = fs::remove_dir_all(&spool_path); + + let old_cfg = CaptureServiceConfig::configured( + "https://capture.example/dev".to_string(), + Some("old-token".to_string()), + ) + .expect("old config should parse"); + let mut new_cfg = CaptureServiceConfig::configured( + "https://capture.example/dev".to_string(), + Some("new-token".to_string()), + ) + .expect("new config should parse"); + new_cfg.participant_id = Some("new-user".to_string()); + + append_spool_event( + &spool_path, + &capture_test_event("old-user", "old-event"), + old_cfg.spool_identity(), + ) + .expect("old event should spool"); + + let status = Arc::new(Mutex::new(CaptureStatus::starting(spool_path.clone()))); + assert!(matches!( + build_next_batch(&spool_path, &new_cfg, &status), + NextBatch::NoMatchingIdentity + )); + assert_eq!( + pending_spool_files(&spool_path) + .expect("spool should list") + .len(), + 1 + ); + let _ = fs::remove_dir_all(&spool_path); + } + + #[test] + fn capture_batch_does_not_upload_legacy_records_before_identity_known() { + let spool_path = temp_spool_path("spool-legacy-unknown-identity-test"); + let _ = fs::remove_dir_all(&spool_path); + + let cfg = CaptureServiceConfig::configured( + "https://capture.example/dev".to_string(), + Some("token".to_string()), + ) + .expect("config should parse"); + + append_spool_event( + &spool_path, + &capture_test_event("participant", "legacy-event"), + None, + ) + .expect("legacy event should spool"); + + let status = Arc::new(Mutex::new(CaptureStatus::starting(spool_path.clone()))); + assert!(matches!( + build_next_batch(&spool_path, &cfg, &status), + NextBatch::NoMatchingIdentity + )); + assert_eq!( + pending_spool_files(&spool_path) + .expect("spool should list") + .len(), + 1 + ); + let _ = fs::remove_dir_all(&spool_path); + } + + #[test] + fn capture_http_upload_uses_request_timeout() { + let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind"); + let addr = listener.local_addr().expect("listener should have address"); + thread::spawn(move || { + if let Ok((_stream, _addr)) = listener.accept() { + thread::sleep(Duration::from_secs(3)); + } + }); + + let started = Instant::now(); + let err = post_capture_batch_with_timeout( + &format!("http://{addr}/v1/capture/events"), + "token", + br#"{"events":[]}"#, + 1, + ) + .expect_err("silent local server should time out"); + + assert!(err.status_code.is_none()); + assert!(started.elapsed() < Duration::from_secs(3)); + } + + #[test] + fn service_url_normalization_accepts_dev_base_and_routes() { + assert_eq!( + normalize_service_base_url( + "https://9m2nbv2rvc.execute-api.us-east-2.amazonaws.com/dev/v1/capture/events" + ) + .unwrap(), + "https://9m2nbv2rvc.execute-api.us-east-2.amazonaws.com/dev" + ); + assert_eq!( + normalize_service_base_url("http://localhost:8787/v1/capture/status").unwrap(), + "http://localhost:8787" + ); + assert_eq!( + normalize_service_base_url("http://127.0.0.1:8787/dev/").unwrap(), + "http://127.0.0.1:8787/dev" + ); + assert!(normalize_service_base_url("postgres://example").is_err()); + assert!(normalize_service_base_url("http://capture.example/dev").is_err()); + assert!(normalize_service_base_url("http://localhost.evil/dev").is_err()); + assert!(normalize_service_base_url("https://user:pass@example.com/dev").is_err()); + } +} diff --git a/server/src/ide.rs b/server/src/ide.rs index eb352945..02fefc69 100644 --- a/server/src/ide.rs +++ b/server/src/ide.rs @@ -63,6 +63,7 @@ use tokio::{ // ### Local use crate::{ + capture::CaptureEventWire, ide::vscode::{connection_id_raw_to_str, vscode_ide_core}, processing::{CodeChatForWeb, CodeMirror, CodeMirrorDiffable, SourceFileMetadata}, translation::{CreatedTranslationQueues, create_translation_queues}, @@ -94,6 +95,7 @@ async fn start_server( // Provide a class to start and stop the server. All its fields are opaque, // since only Rust should use them. pub struct CodeChatEditorServer { + app_state: WebAppState, server_handle: ServerHandle, from_ide_tx: Sender, to_ide_rx: Arc>>, @@ -142,6 +144,7 @@ impl CodeChatEditorServer { let (expired_messages_tx, expired_messages_rx) = mpsc::channel(100); Ok(CodeChatEditorServer { + app_state, server_handle, from_ide_tx: websocket_queues.from_websocket_tx, to_ide_rx: Arc::new(Mutex::new(websocket_queues.to_websocket_rx)), @@ -252,6 +255,49 @@ impl CodeChatEditorServer { .await } + pub async fn send_capture_event( + &self, + capture_event: CaptureEventWire, + ) -> std::io::Result { + self.send_message_timeout(EditorMessageContents::Capture(Box::new(capture_event))) + .await + } + + pub fn capture_status(&self) -> crate::capture::CaptureStatus { + webserver::capture_status(&self.app_state) + } + + pub fn configure_capture_service( + &self, + base_url: String, + token: Option, + ) -> Result<(), String> { + self.app_state + .capture + .as_ref() + .ok_or_else(|| "Capture worker is not available".to_string())? + .configure_service(base_url, token) + } + + pub fn clear_capture_token(&self) -> Result<(), String> { + self.app_state + .capture + .as_ref() + .ok_or_else(|| "Capture worker is not available".to_string())? + .clear_token(); + Ok(()) + } + + pub fn check_capture_service_status( + &self, + ) -> Result { + self.app_state + .capture + .as_ref() + .ok_or_else(|| "Capture worker is not available".to_string())? + .check_service_status() + } + // Send a `CurrentFile` message. The other parameter (true if text/false if // binary/None if ignored) is ignored by the server, so it's always sent as // `None`. diff --git a/server/src/ide/filewatcher.rs b/server/src/ide/filewatcher.rs index 308056cc..3a524c8a 100644 --- a/server/src/ide/filewatcher.rs +++ b/server/src/ide/filewatcher.rs @@ -674,6 +674,7 @@ async fn processing_task( EditorMessageContents::Opened(_) | EditorMessageContents::OpenUrl(_) | + EditorMessageContents::Capture(_) | EditorMessageContents::ClientHtml(_) | EditorMessageContents::RequestClose => { let err = ResultErrTypes::ClientIllegalMessage; diff --git a/server/src/main.rs b/server/src/main.rs index 21e504fb..99c06299 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -332,10 +332,15 @@ fn port_in_range(s: &str) -> Result { fn parse_credentials(s: &str) -> Result { // For simplicity, require a username to have no colons. - let split: Vec<_> = s.splitn(2, ":").collect(); + let Some((username, password)) = s.split_once(':') else { + return Err("auth must use the form username:password".to_string()); + }; + if username.is_empty() { + return Err("auth username may not be empty".to_string()); + } Ok(Credentials { - username: split[0].to_string(), - password: split[1].to_string(), + username: username.to_string(), + password: password.to_string(), }) } diff --git a/server/src/translation.rs b/server/src/translation.rs index c467b044..393a22ed 100644 --- a/server/src/translation.rs +++ b/server/src/translation.rs @@ -206,7 +206,13 @@ // ------- // // ### Standard library -use std::{collections::HashMap, ffi::OsStr, fmt::Debug, path::PathBuf, rc::Rc}; +use std::{ + collections::HashMap, + ffi::OsStr, + fmt::Debug, + path::{Path, PathBuf}, + rc::Rc, +}; use htmd::Node; // ### Third-party @@ -222,6 +228,7 @@ use tokio::{ // ### Local use crate::{ + capture::{CaptureContext, CaptureEventType, capture_control_only}, lexer::{CodeDocBlock, DocBlock, supported_languages::MARKDOWN_MODE}, processing::{ CodeChatForWeb, CodeMirror, CodeMirrorDiff, CodeMirrorDiffable, CodeMirrorDocBlock, @@ -235,8 +242,8 @@ use crate::{ CursorPosition, EditorMessage, EditorMessageContents, INITIAL_MESSAGE_ID, MESSAGE_ID_INCREMENT, ProcessingTaskHttpRequest, ProcessingTaskHttpRequestFlags, ResultErrTypes, ResultOkTypes, SimpleHttpResponse, SimpleHttpResponseError, - UpdateMessageContents, WebAppState, WebsocketQueues, file_to_response, path_to_url, - send_response, try_canonicalize, try_read_as_text, url_to_path, + UpdateMessageContents, WebAppState, WebsocketQueues, file_to_response, log_capture_event, + path_to_url, send_response, try_canonicalize, try_read_as_text, url_to_path, }, }; @@ -387,6 +394,7 @@ pub fn create_translation_queues( /// allows factoring out lengthy contents in the loop into subfunctions. struct TranslationTask { // These parameters are passed to us. + app_state: WebAppState, connection_id_raw: String, prefix: &'static [&'static str], allow_source_diffs: bool, @@ -435,6 +443,10 @@ struct TranslationTask { /// Has the full (non-diff) version of the current file been sent? Don't /// send diffs until this is sent. sent_full: bool, + /// Most recent capture metadata supplied by the IDE. Server-generated + /// capture events reuse this so translated write events retain the same + /// participant/session identity as the extension events. + capture_context: CaptureContext, } /// This is the processing task for the Visual Studio Code IDE. It handles all @@ -466,6 +478,7 @@ pub async fn translation_task( let mut continue_loop = true; let mut tt = TranslationTask { + app_state: app_state.clone(), connection_id_raw, prefix, allow_source_diffs, @@ -489,6 +502,7 @@ pub async fn translation_task( version: 0.0, // Don't send diffs until this is sent. sent_full: false, + capture_context: CaptureContext::default(), }; while continue_loop { select! { @@ -515,6 +529,19 @@ pub async fn translation_task( EditorMessageContents::Result(_) => continue_loop = tt.ide_result(ide_message).await, EditorMessageContents::Update(_) => continue_loop = tt.ide_update(ide_message).await, + EditorMessageContents::Capture(capture_event) => { + // Capture messages affect both upload spooling and the + // translation-layer context used for future + // server-classified write events. + let control_only = capture_control_only(&capture_event); + tt.capture_context.update_from_wire(&capture_event); + if control_only { + debug!("Updated capture context from control-only IDE event."); + } else { + log_capture_event(&app_state, *capture_event); + } + send_response(&tt.to_ide_tx, ide_message.id, Ok(ResultOkTypes::Void)).await; + }, // Update the current file; translate it to a URL then // pass it to the Client. @@ -610,6 +637,18 @@ pub async fn translation_task( }, EditorMessageContents::Update(_) => continue_loop = tt.client_update(client_message).await, + EditorMessageContents::Capture(capture_event) => { + // Same capture handling as IDE messages: update the + // context first, then store only non-control events. + let control_only = capture_control_only(&capture_event); + tt.capture_context.update_from_wire(&capture_event); + if control_only { + debug!("Updated capture context from control-only Client event."); + } else { + log_capture_event(&app_state, *capture_event); + } + send_response(&tt.to_client_tx, client_message.id, Ok(ResultOkTypes::Void)).await; + }, // Update the current file; translate it to a URL then // pass it to the IDE. @@ -700,6 +739,122 @@ pub async fn translation_task( // These provide translation for messages passing through the Server. impl TranslationTask { + fn capture_file_path(file_path: &Path) -> Option { + file_path.to_str().map(str::to_string) + } + + fn log_server_capture_event( + &mut self, + event_type: CaptureEventType, + file_path: &Path, + data: serde_json::Value, + ) { + let Some(capture_event) = self.capture_context.capture_event( + event_type, + Self::capture_file_path(file_path), + data, + ) else { + debug!("Skipping server-classified capture event; capture identity is not known yet."); + return; + }; + log_capture_event(&self.app_state, capture_event); + } + + fn log_raw_write_event(&mut self, file_path: &Path, after: &str) { + let before = self.source_code.as_str(); + if before == after { + return; + } + let code_diff = diff_str(before, after); + self.log_server_capture_event( + CaptureEventType::WriteCode, + file_path, + serde_json::json!({ + "source": "server_translation", + "classification_basis": "raw_text", + "diff": &code_diff, + }), + ); + } + + fn log_code_mirror_write_events( + &mut self, + file_path: &Path, + metadata: &SourceFileMetadata, + after: &CodeMirror, + after_source: Option<&str>, + source: &str, + ) { + if metadata.mode == MARKDOWN_MODE { + let markdown_diff = { + let before_source = self.source_code.as_str(); + let after_source = after_source.unwrap_or(&after.doc); + (before_source != after_source).then(|| diff_str(before_source, after_source)) + }; + if let Some(diff) = markdown_diff { + self.log_server_capture_event( + CaptureEventType::WriteDoc, + file_path, + serde_json::json!({ + "source": source, + "classification_basis": "markdown_source", + "mode": metadata.mode, + "diff": diff, + }), + ); + } + return; + } + + let code_diff = { + let before_doc = self.code_mirror_doc.as_str(); + (before_doc != after.doc).then(|| diff_str(before_doc, &after.doc)) + }; + let (doc_blocks_changed, doc_block_count_before, doc_block_diff) = { + let before_doc_blocks = self.code_mirror_doc_blocks.as_ref(); + let doc_blocks_changed = match before_doc_blocks { + Some(before) => !doc_blocks_compare(before, &after.doc_blocks), + None => !after.doc_blocks.is_empty(), + }; + let doc_block_diff = before_doc_blocks.map(|before| { + serde_json::json!(diff_code_mirror_doc_blocks(before, &after.doc_blocks)) + }); + ( + doc_blocks_changed, + before_doc_blocks.map_or(0, Vec::len), + doc_block_diff, + ) + }; + + if let Some(diff) = code_diff { + self.log_server_capture_event( + CaptureEventType::WriteCode, + file_path, + serde_json::json!({ + "source": source, + "classification_basis": "codemirror_code_text", + "mode": metadata.mode, + "diff": &diff, + }), + ); + } + + if doc_blocks_changed { + self.log_server_capture_event( + CaptureEventType::WriteDoc, + file_path, + serde_json::json!({ + "source": source, + "classification_basis": "codemirror_doc_blocks", + "mode": metadata.mode, + "doc_block_count_before": doc_block_count_before, + "doc_block_count_after": after.doc_blocks.len(), + "doc_block_diff": doc_block_diff, + }), + ); + } + } + // Pass a `Result` message to the Client, unless it's a `LoadFile` result. async fn ide_result(&mut self, ide_message: EditorMessage) -> bool { let EditorMessageContents::Result(ref result) = ide_message.message else { @@ -895,6 +1050,15 @@ impl TranslationTask { else { panic!("Unexpected diff value."); }; + if self.capture_context.is_active() { + self.log_code_mirror_write_events( + &clean_file_path, + &ccfw.metadata, + code_mirror_translated, + Some(&code_mirror.doc), + "ide", + ); + } // Send a diff if possible. let client_contents = if self.sent_full { self.diff_code_mirror( @@ -940,6 +1104,12 @@ impl TranslationTask { Err(ResultErrTypes::TodoBinarySupport) } TranslationResultsString::Unknown => { + if self.capture_context.is_active() { + self.log_raw_write_event( + &clean_file_path, + &code_mirror.doc, + ); + } // Send the new raw contents. debug!("Sending translated contents to Client."); queue_send_func!(self.to_client_tx.send(EditorMessage { @@ -956,13 +1126,16 @@ impl TranslationTask { mode: "".to_string(), }, source: CodeMirrorDiffable::Plain(CodeMirror { - doc: code_mirror.doc, + doc: code_mirror.doc.clone(), doc_blocks: vec![] }), version: contents.version }), }), })); + self.source_code = code_mirror.doc; + self.code_mirror_doc = self.source_code.clone(); + self.code_mirror_doc_blocks = Some(vec![]); Ok(ResultOkTypes::Void) } TranslationResultsString::Toc(_) => { @@ -1045,12 +1218,21 @@ impl TranslationTask { // what we just received. This must be updated // before we can translate back to check for changes // (the next step). - let CodeMirrorDiffable::Plain(code_mirror) = cfw.source else { + let CodeMirrorDiffable::Plain(ref code_mirror) = cfw.source else { // TODO: support diffable! panic!("Diff not supported."); }; - self.code_mirror_doc = code_mirror.doc; - self.code_mirror_doc_blocks = Some(code_mirror.doc_blocks); + if self.capture_context.is_active() { + self.log_code_mirror_write_events( + &clean_file_path, + &cfw.metadata, + code_mirror, + Some(&new_source_code), + "client", + ); + } + self.code_mirror_doc = code_mirror.doc.clone(); + self.code_mirror_doc_blocks = Some(code_mirror.doc_blocks.clone()); // We may need to change this version if we send a // diff back to the Client. let mut cfw_version = cfw.version; @@ -1402,7 +1584,108 @@ fn debug_shorten(val: T) -> String { // ----- #[cfg(test)] mod tests { - use crate::{processing::CodeMirrorDocBlock, translation::doc_blocks_compare}; + use crate::{ + capture::{CaptureContext, CaptureEventType, CaptureEventWire, capture_control_only}, + processing::CodeMirrorDocBlock, + translation::doc_blocks_compare, + }; + + /// Minimal test helper for feeding lifecycle/control messages into the + /// translation-layer capture context. + fn capture_wire( + event_type: crate::capture::CaptureEventType, + data: serde_json::Value, + ) -> CaptureEventWire { + CaptureEventWire { + event_id: None, + sequence_number: None, + schema_version: Some(2), + user_id: "participant".to_string(), + session_id: Some("session".to_string()), + event_source: Some("vscode_extension".to_string()), + language_id: None, + file_path: None, + file_hash: None, + event_type, + client_tz_offset_min: Some(360), + data: Some(data), + } + } + + #[test] + fn capture_context_only_generates_events_while_active() { + let mut context = CaptureContext::default(); + // Without an active capture session, translated writes must be skipped. + assert!( + context + .capture_event(CaptureEventType::WriteCode, None, serde_json::json!({})) + .is_none() + ); + + context.update_from_wire(&capture_wire( + CaptureEventType::SessionStart, + serde_json::json!({ + "capture_active": true, + }), + )); + // A session_start activates server-side translated write capture. + assert!( + context + .capture_event(CaptureEventType::WriteCode, None, serde_json::json!({})) + .is_some() + ); + + context.update_from_wire(&capture_wire( + CaptureEventType::SessionEnd, + serde_json::json!({ + "capture_active": false, + }), + )); + // A session_end deactivates translated write capture so stale context + // cannot continue generating spooled capture events. + assert!( + context + .capture_event(CaptureEventType::WriteCode, None, serde_json::json!({})) + .is_none() + ); + } + + #[test] + fn capture_control_only_is_detected_from_data() { + // Control-only events are the extension's way to update server capture + // state without storing the stop signal as a normal event row. + let wire = capture_wire( + CaptureEventType::SessionEnd, + serde_json::json!({ + "capture_active": false, + "capture_control_only": true, + }), + ); + + assert!(capture_control_only(&wire)); + } + + #[test] + fn server_generated_capture_events_have_session_sequence_numbers() { + let mut context = CaptureContext::default(); + context.update_from_wire(&capture_wire( + CaptureEventType::SessionStart, + serde_json::json!({ + "capture_active": true, + }), + )); + let first = context + .capture_event(CaptureEventType::WriteCode, None, serde_json::json!({})) + .expect("active context should generate a capture event"); + let second = context + .capture_event(CaptureEventType::WriteDoc, None, serde_json::json!({})) + .expect("active context should generate a capture event"); + + assert_eq!(first.sequence_number, Some(1)); + assert_eq!(second.sequence_number, Some(2)); + assert_eq!(first.event_source.as_deref(), Some("server_translation")); + assert_eq!(second.event_source.as_deref(), Some("server_translation")); + } #[test] fn test_x1() { diff --git a/server/src/webserver.rs b/server/src/webserver.rs index e3c76a06..15681c17 100644 --- a/server/src/webserver.rs +++ b/server/src/webserver.rs @@ -38,6 +38,7 @@ use std::{ // ### Third-party use actix_files; + use actix_web::{ App, HttpRequest, HttpResponse, HttpServer, dev::{Server, ServerHandle, ServiceFactory, ServiceRequest}, @@ -47,6 +48,7 @@ use actix_web::{ middleware, web::{self, Data}, }; + use actix_web_httpauth::{extractors::basic::BasicAuth, middleware::HttpAuthentication}; use actix_ws::AggregatedMessage; use bytes::Bytes; @@ -95,6 +97,13 @@ use crate::{ }, }; +use crate::capture::{ + CaptureEvent, CaptureEventWire, CaptureStatus, EventCapture, generate_capture_event_id, + hash_capture_path, +}; + +use chrono::Utc; + // Data structures // --------------- // @@ -201,6 +210,8 @@ pub enum EditorMessageContents { // Server will determine the value if needed. Option, ), + /// Record an instrumentation event. Valid destinations: Server. + Capture(Box), // #### These messages may only be sent by the IDE. /// This is the first message sent when the IDE starts up. It may only be @@ -405,6 +416,8 @@ pub struct AppState { pub connection_id: Mutex>, /// The auth credentials if authentication is used. credentials: Option, + // Added to support capture - JDS - 11/2025 + pub capture: Option, } pub type WebAppState = web::Data; @@ -449,7 +462,7 @@ macro_rules! queue_send_func { // The timeout for a reply from a websocket, in ms. Use a short timeout to speed // up unit tests. pub const REPLY_TIMEOUT_MS: Duration = if cfg!(test) { - Duration::from_millis(500) + Duration::from_millis(2500) } else { Duration::from_millis(15000) }; @@ -567,6 +580,61 @@ async fn stop(app_state: WebAppState) -> HttpResponse { HttpResponse::NoContent().finish() } +/// Log a capture event if capture is enabled. +pub fn log_capture_event(app_state: &WebAppState, wire: CaptureEventWire) -> CaptureStatus { + if let Some(capture) = &app_state.capture { + let server_timestamp = Utc::now(); + // Default missing data to empty object + let data = wire.data.unwrap_or_else(|| serde_json::json!({})); + + let data = if data.is_object() { + data + } else { + serde_json::json!({ "value": data }) + }; + // Prefer hashing a raw local path on the server so all capture + // transports use the same path-to-hash rule. The raw path is not stored; + // `file_hash` remains only as a backward-compatible/server-originated + // alternative. + let file_hash = wire + .file_path + .as_deref() + .map(hash_capture_path) + .or(wire.file_hash); + + let event = CaptureEvent::with_columns( + Some( + wire.event_id + .unwrap_or_else(|| generate_capture_event_id("server")), + ), + wire.sequence_number, + wire.schema_version, + wire.user_id, + wire.session_id, + wire.event_source, + wire.language_id, + file_hash, + wire.event_type, + server_timestamp, + wire.client_tz_offset_min, + data, + ); + + capture.log(event); + capture.status() + } else { + CaptureStatus::disabled() + } +} + +pub fn capture_status(app_state: &WebAppState) -> CaptureStatus { + app_state + .capture + .as_ref() + .map(EventCapture::status) + .unwrap_or_else(CaptureStatus::disabled) +} + // Get the `mode` query parameter to determine `is_test_mode`; default to // `false`. pub fn get_test_mode(req: &HttpRequest) -> bool { @@ -1414,9 +1482,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; let app_data = make_app_data(credentials); @@ -1492,6 +1557,22 @@ pub fn configure_logger(level: LevelFilter) -> Result<(), Box) -> WebAppState { + // Initialize capture with a durable local upload spool. The VS Code + // extension supplies the CaptureWebService endpoint and bearer token at + // runtime; this server never reads database credentials from disk or env. + let root_path = ROOT_PATH.lock().unwrap().clone(); + let capture_spool_path = root_path.join("capture-spool"); + let capture: Option = match EventCapture::new(capture_spool_path.clone()) { + Ok(ec) => { + info!("Capture: enabled with local upload spool at {capture_spool_path:?}"); + Some(ec) + } + Err(err) => { + warn!("Capture: failed to initialize local upload spool: {err}"); + None + } + }; + web::Data::new(AppState { server_handle: Mutex::new(None), filewatcher_next_connection_id: Mutex::new(0), @@ -1502,6 +1583,7 @@ pub fn make_app_data(credentials: Option) -> WebAppState { client_queues: Arc::new(Mutex::new(HashMap::new())), connection_id: Mutex::new(HashSet::new()), credentials, + capture, }) } diff --git a/server/tests/fixtures/overall_1/test_updates/test.py b/server/tests/fixtures/overall_1/test_client_updates/test.py similarity index 100% rename from server/tests/fixtures/overall_1/test_updates/test.py rename to server/tests/fixtures/overall_1/test_client_updates/test.py diff --git a/server/tests/fixtures/overall_1/test_updates/toc.md b/server/tests/fixtures/overall_1/test_client_updates/toc.md similarity index 100% rename from server/tests/fixtures/overall_1/test_updates/toc.md rename to server/tests/fixtures/overall_1/test_client_updates/toc.md diff --git a/server/tests/overall_1.rs b/server/tests/overall_1.rs index 9b223351..4eae324e 100644 --- a/server/tests/overall_1.rs +++ b/server/tests/overall_1.rs @@ -36,14 +36,14 @@ use std::{path::PathBuf, time::Duration}; use dunce::canonicalize; use indoc::indoc; use pretty_assertions::assert_eq; -use thirtyfour::{By, Key, WebDriver, error::WebDriverError}; -use tokio::time::sleep; +use thirtyfour::{ + By, Key, WebDriver, WebElement, error::WebDriverError, prelude::ElementQueryable, +}; // ### Local use crate::overall_common::{ CodeChatEditorServerLog, ExpectedMessages, TIMEOUT, assert_no_more_messages, beginning_of_line, - end_of_line, get_version, goto_line, optional_message, perform_loadfile, - select_codechat_iframe, + get_version, goto_line, optional_message, perform_loadfile, select_codechat_iframe, }; use code_chat_editor::{ lexer::supported_languages::MARKDOWN_MODE, @@ -714,13 +714,9 @@ async fn test_client_core( .await .unwrap(); - // Wait for the tests to run. - sleep(Duration::from_millis(3000)).await; - // Look for the test results. codechat_iframe.clone().enter_frame().await.unwrap(); - let mocha_results = driver.find(By::Css("#mocha-stats .result")).await.unwrap(); - assert_eq!(mocha_results.inner_html().await.unwrap(), "✓"); + wait_for_mocha_success(&driver).await.unwrap(); server_id -= MESSAGE_ID_INCREMENT; assert_eq!( @@ -737,9 +733,51 @@ async fn test_client_core( Ok(()) } -make_test!(test_updates, test_updates_core); +async fn wait_for_mocha_success(driver: &WebDriver) -> Result<(), WebDriverError> { + const MOCHA_TEST_TIMEOUT: Duration = Duration::from_millis(30000); + + let mocha_results = driver + .query(By::Css("#mocha-stats .result")) + .wait(MOCHA_TEST_TIMEOUT, Duration::from_millis(200)) + .with_filter(|element: WebElement| async move { + let result = element.inner_html().await?; + Ok(result == "✓" || result == "✖") + }) + .first() + .await?; + let result = mocha_results.inner_html().await?; + if result == "✓" { + Ok(()) + } else { + panic!( + "Browser Mocha tests failed:\n{}", + mocha_failure_text(driver).await + ); + } +} + +async fn mocha_failure_text(driver: &WebDriver) -> String { + let failures = driver + .find_all(By::Css("#mocha-report .fail")) + .await + .unwrap_or_default(); + let mut failure_texts = Vec::new(); + for failure in failures { + let text = failure.text().await.unwrap_or_default(); + if !text.trim().is_empty() { + failure_texts.push(text); + } + } + if failure_texts.is_empty() { + "Mocha reported a failure, but no failure details were found.".to_string() + } else { + failure_texts.join("\n\n") + } +} + +make_test!(test_client_updates, test_client_updates_core); -async fn test_updates_core( +async fn test_client_updates_core( codechat_server: CodeChatEditorServerLog, driver: WebDriver, test_dir: PathBuf, @@ -770,53 +808,52 @@ async fn test_updates_core( // Target the iframe containing the Client. select_codechat_iframe(&driver).await; - // Select the doc block and add to the line, causing a word wrap. + // Focus the doc block, then wait for the async handoff to the shared inline + // TinyMCE editor before typing. Otherwise WebDriver can type into the + // transient contenteditable div, moving the cursor without marking the doc + // block dirty on macOS Chrome. let contents_css = ".CodeChat-CodeMirror .CodeChat-doc-contents"; let doc_block_contents = driver.find(By::Css(contents_css)).await.unwrap(); doc_block_contents.click().await.unwrap(); - let mut client_id = INITIAL_CLIENT_MESSAGE_ID; - assert_eq!( - codechat_server.get_message_timeout(TIMEOUT).await.unwrap(), - EditorMessage { - id: client_id, - message: EditorMessageContents::Update(UpdateMessageContents { - file_path: path_str.clone(), - cursor_position: Some(CursorPosition::Line(1)), - scroll_position: Some(1.0), - is_re_translation: false, - contents: None, - }) - } - ); - codechat_server.send_result(client_id, None).await.unwrap(); - client_id += MESSAGE_ID_INCREMENT; + let doc_block_contents = driver + .query(By::Css( + ".CodeChat-CodeMirror #TinyMCE-inst:not(.CodeChat-doc-hidden)", + )) + .first() + .await + .unwrap(); - let doc_block_contents = driver.find(By::Css(contents_css)).await.unwrap(); - end_of_line(&doc_block_contents, " testing").await.unwrap(); + // Add to the line, causing a word wrap. + doc_block_contents + .send_keys(Key::End + " testing") + .await + .unwrap(); - // Get the next message, which could be a cursor update followed by a text + // Get the next message, which could be cursor updates followed by a text // update, or just the text update. - let mut msg = codechat_server.get_message_timeout(TIMEOUT).await.unwrap(); - if let EditorMessageContents::Update(ref update) = msg.message + let mut client_id = INITIAL_CLIENT_MESSAGE_ID; + let mut msg = codechat_server + .get_message_timeout(TIMEOUT) + .await + .expect("expected client update after editing doc block"); + while let EditorMessageContents::Update(ref update) = msg.message && update.contents.is_none() { - // Sometimes, we get just a cursor update. If so, verify this then wait - // for the text update. - assert_eq!( - msg, - EditorMessage { - id: client_id, - message: EditorMessageContents::Update(UpdateMessageContents { - file_path: path_str.clone(), - cursor_position: Some(CursorPosition::Line(1)), - scroll_position: Some(1.0), - is_re_translation: false, - contents: None, - }) - } + // Sometimes, we get cursor-only updates. If so, verify and acknowledge + // them, then keep waiting for the text update. + assert_eq!(msg.id, client_id); + assert_eq!(update.file_path, path_str); + assert!(!update.is_re_translation); + assert!( + update.cursor_position.is_some() || update.scroll_position.is_some(), + "cursor-only update must include cursor or scroll state: {msg:#?}", ); + codechat_server.send_result(client_id, None).await.unwrap(); client_id += MESSAGE_ID_INCREMENT; - msg = codechat_server.get_message_timeout(TIMEOUT).await.unwrap(); + msg = codechat_server + .get_message_timeout(TIMEOUT) + .await + .expect("expected content update after cursor-only update"); } // Verify the updated text. @@ -850,6 +887,7 @@ async fn test_updates_core( ); codechat_server.send_result(client_id, None).await.unwrap(); client_id += MESSAGE_ID_INCREMENT; + assert!(client_id >= 7.0); // The Server sends the Client a wrapped version of the text; the Client // replies with a Result(Ok). @@ -862,7 +900,6 @@ async fn test_updates_core( ); server_id += MESSAGE_ID_INCREMENT; - // After this, ID is 13. goto_line(&codechat_server, &driver, &mut client_id, &path_str, 4) .await .unwrap(); diff --git a/server/tests/overall_common/mod.rs b/server/tests/overall_common/mod.rs index be812320..2139e86e 100644 --- a/server/tests/overall_common/mod.rs +++ b/server/tests/overall_common/mod.rs @@ -237,8 +237,14 @@ impl ExpectedMessages { } } -// Time to wait for `ExpectedMessages`. -pub const TIMEOUT: Duration = Duration::from_millis(3000); +// Time to wait for browser/WebDriver-backed client-server messages. This +// matches the client-side response window and gives CI enough room for autosave +// and loadfile acknowledgements under matrix load. +pub const TIMEOUT: Duration = Duration::from_millis(15000); + +// Browser-backed tests share a single WebDriver endpoint. Safari on macOS CI is +// unreliable with overlapping sessions, so serialize the harness. +pub(crate) static WEB_DRIVER_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); // ### Test harness // @@ -253,6 +259,7 @@ pub async fn harness< // The output from calling `prep_test_dir!()`. prep_test_dir: (TempDir, PathBuf), ) -> Result<(), Box> { + let _webdriver_test_lock = WEB_DRIVER_TEST_LOCK.lock().await; // Send log events to the tracing subscriber, since the code currently uses // a log-based framework. As below, ignore re-initialization errors. let _ = LogTracer::init(); @@ -355,11 +362,10 @@ pub async fn harness< // Report any errors produced when removing the temporary directory. temp_dir.close()?; - ret.unwrap_or_else(|err| - // Convert a panic to an error. - Err::<(), Box>(Box::from(format!( - "{err:#?}" - )))) + ret.unwrap_or_else( + // Convert a panic to an error. + |err| Err::<(), Box>(Box::from(format!("{err:#?}"))), + ) } /// Decode a `BrowserLogEntry::message` produced by a `console.*` call. diff --git a/test_utils/src/test_utils.rs b/test_utils/src/test_utils.rs index f764323d..44541001 100644 --- a/test_utils/src/test_utils.rs +++ b/test_utils/src/test_utils.rs @@ -20,9 +20,10 @@ // ------- // // ### Standard library -use std::env; -use std::path::MAIN_SEPARATOR_STR; -use std::path::PathBuf; +use std::{ + env, fs, + path::{MAIN_SEPARATOR_STR, PathBuf}, +}; // ### Third-party use assert_fs::{TempDir, fixture::PathCopy}; @@ -132,23 +133,32 @@ pub fn prep_test_dir_impl( let source_path_tmp = source_path.clone(); let test_name = source_path_tmp.file_name().unwrap().to_str().unwrap(); source_path.pop(); - // For debugging, append // [.into\_persistent()](https://docs.rs/assert_fs/latest/assert_fs/fixture/struct.TempDir.html#method.into_persistent). let temp_dir = TempDir::new().unwrap(); - // Create a temporary directory, then copy everything needed for this test - // to it. Since the `patterns` parameter is a glob, append `/**` to the - // directory to copy to get all files/subdirectories. - if let Err(err) = temp_dir.copy_from(&source_path, &[format!("{test_name}/**")]) { - panic!( - "Unable to copy files from {}{MAIN_SEPARATOR_STR}{}: {err}", - source_path.to_string_lossy(), - test_name - ); - } - // This is a path where testing takes place. let test_dir = temp_dir.path().join(test_name); + let fixture_dir = source_path.join(test_name); + if fixture_dir.is_dir() { + // Create a temporary directory, then copy everything needed for this test + // to it. Since the `patterns` parameter is a glob, append `/**` to the + // directory to copy to get all files/subdirectories. + if let Err(err) = temp_dir.copy_from(&source_path, &[format!("{test_name}/**")]) { + panic!( + "Unable to copy files from {}{MAIN_SEPARATOR_STR}{}: {err}", + source_path.to_string_lossy(), + test_name + ); + } + } else { + // Some tests only need an isolated empty directory, with no fixture files. + fs::create_dir_all(&test_dir).unwrap_or_else(|err| { + panic!( + "Unable to create empty test directory {}: {err}", + test_dir.to_string_lossy() + ) + }); + } (temp_dir, test_dir) } diff --git a/toc.md b/toc.md index 2d11d4d5..e2baa292 100644 --- a/toc.md +++ b/toc.md @@ -30,6 +30,7 @@ Implementation * [python.pest](server/src/lexer/pest/python.pest) * [webserver.rs](server/src/webserver.rs) * [log4rs.yml](server/log4rs.yml) + * [Capture events schema](server/scripts/capture_events_schema.sql) * [ide.rs](server/src/ide.rs) * [filewatcher.rs](server/src/ide/filewatcher.rs) * [vscode.rs](server/src/ide/vscode.rs)