app
SOLIDWORKS Bridge
Public Made by Adomby adom
Launch, model and quit SOLIDWORKS from any Adom container. Nothing is ever lost.
← Commit history
0.9.11: a session cannot be starting for hours (UNLOAD_ADDIN is not a load; long-running sessions; persisted ready latch); README lists all 67 verbs
17 files changed
+442−28
BRIDGE_VERSION+1−1Cargo.lock+1−1Cargo.toml+1−1README.md+15−2bridge.json+1−1docs/ECOSYSTEM-TABLE.md+1−1docs/LAUNCH-RESILIENCE.md+21−1install.shpackage.json+1−1references/launch-and-sign-in.md+4−1src/perflog.rs+82−15src/presence.rs+50src/readiness.rs+67src/verbs.rs+12−1tests/readiness.rs+184−1tests/wiring.rs+1−1uninstall.shBRIDGE_VERSION+1−1@@ -1 +1 @@-0.9.10+0.9.11
Cargo.lock+1−1@@ -107,7 +107,7 @@ dependencies = [ [[package]] name = "swx-bridge"-version = "0.9.10"+version = "0.9.11" dependencies = [ "serde", "serde_json",
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "swx-bridge"-version = "0.9.10"+version = "0.9.11" edition = "2021" rust-version = "1.80" description = "Adom SOLIDWORKS bridge — swx_* verbs over the SOLIDWORKS COM API, as a single native binary"
README.md+15−2@@ -9,13 +9,15 @@ One native binary. No Python, no PowerShell, no interpreter to provision: ab spawns `swx-bridge.exe` directly (`spawn.kind: "exe"`) and it is serving in about ten milliseconds. -## What it does — 63 verbs+## What it does — 67 verbs | Area | Verbs | |---|---| | **Create & open** | `swx_new_document` (part/assembly/drawing from the stock template), `swx_open`, `swx_import` (STEP/IGES/Parasolid/STL), `swx_save`, `swx_save_as`, `swx_close_doc`, `swx_find_files`, `swx_docs` | | **Model** | `swx_run_script` — a typed modelling DSL: `new_part`, `sketch_rect`, `sketch_circle`, `extrude`, `cut`, `set_material`, `set_prop`, `save_as`, `rebuild`, `zoom_fit`. Everything in millimetres. | | **Macros** | `swx_run_macro` — `ISldWorks::RunMacro2` on a `.swp`/`.swb`/`.dll`. Arbitrary code, so it needs the write opt-in every time. Scaffolding: not yet run live. |+| **Assemble** | `swx_add_component` — insert a part or sub-assembly into the active assembly, positioned and rotated. |+| **Close & quit safely** | `swx_close_all_safely` (saves every open document to a recovery folder, verifies each file, then closes), `swx_shutdown` (that sweep, then a graceful quit), `swx_recover_list` (what was preserved and where), `swx_close_import_temps`, `swx_user_presence` (is someone working in this session? the bridge refuses to end a session a person is using). | | Export | `swx_export` plus per-format aliases `swx_export_step/iges/stl/3mf/glb/sat/parasolid/pdf/dxf/dwg`, `swx_pack_and_go` | | Read the design back | `swx_design_info`, `swx_inspect_bodies` (geometry in mm), `swx_get_parameters`, `swx_set_parameter`, `swx_mass_properties`, `swx_bom`, `swx_interference`, `swx_rebuild_check`, `swx_document_info` | | Metadata | `swx_props_get`, `swx_props_set` (custom properties, file- or configuration-scope) |@@ -458,7 +460,18 @@ not. ## Roadmap -- v0.9.10 (this): **the bridge's documents are only the ones it opened.**+- v0.9.11 (this): **a session cannot be starting for hours.** Measured LIVE on+ 2026-10-01: Drew's three-hour-old session with 34 documents open was locked+ out of every gated verb after an update respawned the bridge — the log's+ `"UNLOAD_ADDIN" "SWK3DCompassAddin.dll"` line contains `LOAD_ADDIN` and was+ read as the platform add-in loading. Load events are now parsed as the exact+ token (the measured log is a fixture); a process older than ten minutes with+ COM's start complete holds "loading" only while startup lines are still+ changing; periodic `<PROCMEM>` lines no longer count as startup activity; the+ ready latch is persisted across bridge restarts; and `swx_user_presence` is+ exempt from the startup gate, reporting documents as unknown until the session+ is verified ready. 502 tests.+- v0.9.10: **the bridge's documents are only the ones it opened.** Adversary pass 46 found the user's own documents could enter the presence guard's baseline three ways (a session the user launched, a document opened before the bridge's first verb, and any later bridge verb re-reading the open
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "solidworks", "displayName": "SOLIDWORKS",- "version": "0.9.10",+ "version": "0.9.11", "description": "Bridge for SOLIDWORKS desktop CAD \u2014 create and open documents, a modeling DSL, file management (find/open/save/export/Pack and Go), custom properties, BOM generation, design analysis (mass properties, bodies, parameters, interference, rebuild health) and background window/dialog etiquette, over the SOLIDWORKS COM API. Single native binary: no interpreter, no PowerShell, ~10ms cold start.", "author": "Adom Inc.", "license": "MIT",
docs/ECOSYSTEM-TABLE.md+1−1@@ -114,7 +114,7 @@ templates**, alisamsam **22**. | 48 | **A title collision between SAVED documents is impossible here — measured, not assumed** | ✗ | **✓ MEASURED LIVE 2026-09-17** — `tools/drain-gate.py --fixture new` copied a bridge-created document's own file to a second directory under the same basename and called `swx_open` on it. SOLIDWORKS REFUSED: `errors = 65536` (`swFileWithSameTitleAlreadyOpen`). So the drain's title-collision guard — which refuses to close any document whose title another open document shares, and names both paths — can only ever fire for PATHLESS documents, which is exactly the state identity-at-birth removes. The guard stays (a document the bridge did not create can still be pathless, and an older bridge's leftovers are), and the measurement is recorded next to it in `com.rs` so nobody "simplifies" it on the assumption that saved collisions are the common case. No MCP server measures this at all | ✗ | ✗ | ✗ | ✗ | ✗ | | 52 | **Never ends a session the user is working in** (a measured user-presence guard on every quit and sweep) | ✗ | **✓ 0.9.9, from an event MEASURED 2026-10-01**: Drew took over a bridge-launched session — a "leave as found" quit met his File Open dialog, and minutes later the session held his 34-document crate assembly. Every quit, sweep and priming now measures presence (SOLIDWORKS foreground + input under 120 s, documents the bridge did not open, an earlier take-over), refuses `user_is_active` with the evidence, fails closed on an unreadable measurement, and marks the session taken over for good; only the user's explicit override proceeds, and the gates never pass it. 0.9.10: a document is the bridge's only if a bridge verb opened or created it — in a session the user launched, every document is theirs. No MCP server has a presence guard at all | ✗ | ✗ | ✗ | ✗ | ✗ | | 51 | **Dialogs judged by MEASURED MODALITY, not by existence** (a notification never blocks; a modal always does) | ✗ | **✓ LIVE-PROVEN 2026-10-01 on 0.9.4** — with the 3DEXPERIENCE "Connected" notice on screen, `swx_stop` quit in 5.1 s and reported it as a notification with its evidence. **0.9.4, from a blocker MEASURED 2026-10-01**: the 3DEXPERIENCE "Connected to …" notice — a title-less `#32770` tool window whose owner frame stayed ENABLED for 95 s, with no Button-class control — blocked every quit, sweep and import for minutes. Now a window blocks when its kind is known-blocking, its owner frame is disabled, or it has a Button-class control; it is a `notification` (reported with owner, ownerEnabled, styles, rect and child controls; never answered) only when all of that was measured; unread evidence fails closed. Template placeholder captions are flagged, never presented as the window's words. **0.9.5 (adversary pass 43): the window's WORDS count — a save/recovery/import kind in its text blocks whatever the evidence, two or more button-like captions in any control class block, and measured notification evidence overrides a licence-type title alone; 0.9.7: swx_dismiss_blocking_dialogs uses the same title-and-words classifier before it closes anything; 0.9.8, from a live measurement: an owner frame read disabled for an instant (SOLIDWORKS busy) is re-read before a notice is called a modal.** No MCP server looks at a dialog at all | ✗ | ✗ | ✗ | ✗ | ✗ |-| 50 | **Readiness gating — attached is not ready** (no document command reaches a SOLIDWORKS that has not finished starting) | ✗ | **✓ 0.9.3, from a crash MEASURED 2026-10-01**: `swx_new_document` 8 s after COM attach, `swx_open` at 9 s and `swx_close_all_safely` at 12 s crashed SOLIDWORKS while its 3DEXPERIENCE add-in was still initialising (performance log: LOAD_ADDIN still running, SWEXH/UNHANDLED, the 3DEXPERIENCE marker only after the document commands, SWERR_REPORT); a control launch with no document commands crashed nothing. Now a READY stage follows `attached`, decided by two independent signals — COM `StartupProcessCompleted` and the performance log's 3DEXPERIENCE marker after the last LOAD_ADDIN — with an honest grace and timeout that never promote to ready; every verb that reaches SOLIDWORKS refuses `host_app_starting` with `retryAfterSeconds` until then — **0.9.5: gated by default with a pinned 16-verb exemption list, the performance log bound to this session's process creation time (an earlier session's log is never "complete"), COM alone never enough on a machine that writes markers, an on-demand add-in told from a start by quiet + which add-in, the grace unlocking reads only, and the window-message quit rungs ungated under userAuthorized; LIVE 2026-10-01 a document verb was refused during startup. 0.9.6: every gated reply carries the readiness its gate evaluated, the log is read from its per-start header, and the on-demand rule keys on the platform DLL SWK3DCompassAddin.dll; 0.9.7 (LIVE-PROVEN 0.9.6): no marker history — a disabled platform add-in never locks the bridge out, and a log still being written holds even a machine's first start**; a sweep during which SOLIDWORKS dies says `host_app_gone` with the log's crash markers. The rescue held under the crash (26,129 bytes verified). No MCP server distinguishes attached from ready at all | ✗ | ✗ | ✗ | ✗ | ✗ |+| 50 | **Readiness gating — attached is not ready** (no document command reaches a SOLIDWORKS that has not finished starting) | ✗ | **✓ 0.9.3, from a crash MEASURED 2026-10-01**: `swx_new_document` 8 s after COM attach, `swx_open` at 9 s and `swx_close_all_safely` at 12 s crashed SOLIDWORKS while its 3DEXPERIENCE add-in was still initialising (performance log: LOAD_ADDIN still running, SWEXH/UNHANDLED, the 3DEXPERIENCE marker only after the document commands, SWERR_REPORT); a control launch with no document commands crashed nothing. Now a READY stage follows `attached`, decided by two independent signals — COM `StartupProcessCompleted` and the performance log's 3DEXPERIENCE marker after the last LOAD_ADDIN — with an honest grace and timeout that never promote to ready; every verb that reaches SOLIDWORKS refuses `host_app_starting` with `retryAfterSeconds` until then — **0.9.5: gated by default with a pinned 16-verb exemption list, the performance log bound to this session's process creation time (an earlier session's log is never "complete"), COM alone never enough on a machine that writes markers, an on-demand add-in told from a start by quiet + which add-in, the grace unlocking reads only, and the window-message quit rungs ungated under userAuthorized; LIVE 2026-10-01 a document verb was refused during startup. 0.9.6: every gated reply carries the readiness its gate evaluated, the log is read from its per-start header, and the on-demand rule keys on the platform DLL SWK3DCompassAddin.dll; 0.9.7 (LIVE-PROVEN 0.9.6): no marker history — a disabled platform add-in never locks the bridge out, and a log still being written holds even a machine's first start; 0.9.11, from a live lock-out: `UNLOAD_ADDIN` is never read as a load, a session older than ten minutes with COM's start complete is never held by a log rule unless startup lines are still changing, and the ready latch survives a bridge restart**; a sweep during which SOLIDWORKS dies says `host_app_gone` with the log's crash markers. The rescue held under the crash (26,129 bytes verified). No MCP server distinguishes attached from ready at all | ✗ | ✗ | ✗ | ✗ | ✗ | | 49 | **The user's rescued work survives the bridge's own updates — and ab's** (recovery never lives in a directory anything else owns) | ✗ | **✓ 0.9.1: the data root is `%LOCALAPPDATA%\adom-solidworks-bridge\`, a directory only this bridge writes to** — moved out of `Adom Bridge\` altogether after adversary pass 39 found 0.9.0's `Adom Bridge\solidworks-data` sitting inside the directory ab's installer and uninstaller own, whose fate on an ab uninstall or reinstall is documented nowhere; both earlier locations are migrated oldest first, every copy recorded by (root, path, size, mtime, sha256) so a legacy file that changes after its copy is copied again BESIDE it and reported `changedSinceCopy`, never settled by name — and since 0.9.2 a small file whose size and mtime match is hashed too, and two versions of the ledger or the ownership registry are MERGED (union, both versions kept beside) rather than one picked; a temp-directory fallback makes every sweep refuse (`data_root_is_temp`), and an unwritable recovery folder is refused once before the first save (`recovery_dir_unwritable`). **0.9.0, from a SEV-1 MEASURED 2026-09-30**: `bridges-cache\solidworks` was found CREATED 2026-09-18 09:42 by ab's auto-install of 0.8.9 — the updater re-extracts the whole directory, and every recovery folder from the day before, the ledger and the ownership registry went with it (test documents only; nothing of Drew's was lost). The data root is now `%LOCALAPPDATA%\Adom Bridge\solidworks-data\`, a sibling of the install cache; the first start MIGRATES what an older bridge left (copied under a temporary name and renamed into place once verified byte for byte — so a start that dies mid-copy never leaves a truncated rescue under the real name — never over an existing file, originals left in place, each file once, a copy that does not verify removed and retried next start); `swx_recover_list` still reads the old location and marks each rescue there `atRiskFromUpdate`, saying whether a verified copy exists — and in the data root calls a file whose copy has not landed `notCopiedYet` (pointing at its only copy), never "gone"; and a sweep REFUSES (`recovery_inside_install_dir`, nothing written, nothing closed) if its recovery folder would land under the install directory or the install cache — however the path is spelled (links, `..`, case). Every rule mutation-verified. No MCP server keeps a recovery folder at all | ✗ | ✗ | ✗ | ✗ | ✗ | | 47 | **Interconnect-aware import: refuses honestly, with the reason and the setting** | ✗ | **✓ MEASURED + shipped 0.8.2** — on SOLIDWORKS Connected 2026 with `swMultiCAD_Enable3DInterconnect` TRUE there is NO programmatic neutral-file import route at all: LoadFile4 returns nothing (`errors=1`) both with `Nothing` and with a real `GetImportFileData` object, LoadFile2 returns false, OpenDoc6 answers `swFileRequiresRepairError` (2097152) across four option values and `swInvalidFileTypeError` (1024) for docType 0. So `swx_import` makes ONE LoadFile4 attempt (a future edition that accepts it simply works) and then fails with `interconnect_blocks_import`, naming the exact setting — Tools > Options > System Options > Import > Enable 3D Interconnect — the measurement date, and the fact that **this bridge does not change user settings, not even temporarily**. No MCP server reads that toggle at all: they report a translator failure, which sends the reader hunting a missing translator that is not the problem | ✗ | ✗ | ✗ | ✗ | ✗ | | 46 | **3D Interconnect is understood, obeyed and reported** (the user's own setting decides how a neutral file comes in; the bridge reads it and never writes it) | ✗ | **✓ LIVE-MEASURED 2026-09-17, routed in 0.8.0** — `GetUserPreferenceToggle(691)` = TRUE on the install, so a STEP is not translated at all: `LoadFile4` executes and returns nothing with `errors=1`, and SOLIDWORKS instead OPENS the file with `OpenDoc6(path, swDocPART, …)` as a part holding a LINKED 3D Interconnect feature. `swx_import` reads the toggle, routes accordingly, and reports `interconnect` / `linked` / `sourcePath` so a caller knows the part is not self-contained. The preference is NEVER written — not even flip-and-restore, because a crash between the halves would leave the user's CAD configured differently than they left it — and `dissolve:true` is REFUSED until the link-breaking call is measured on this edition. Import-diagnostics auto-run (toggle 291) is also TRUE, so the modal dialog it raises is classified, reported as `stoppedByDialog`, and never answered. Toggle ids read from the installed `SolidWorks.Interop.swconst.dll` | ✗ | ✗ | ✗ | ✗ | ✗ |
docs/LAUNCH-RESILIENCE.md+21−1@@ -1085,7 +1085,7 @@ launch gate now waits for READY before any probe of its own. | | | |---|---|-| Gated by default | Every verb waits for readiness unless it is on `STARTUP_EXEMPT` (16 verbs, each with its reason in `src/verbs.rs`: the status/start/describe probes, the bridge-local and disk verbs, the window-census and window-message verbs, the screenshot, the badge, and `swx_kill`). The 15-name gate list had left `swx_show_*` (an `open`), `swx_export` and the ten `swx_export_*` (`SaveAs3` — the crash log's `CMD:DocSave`), `swx_com_eval`, `swx_set_parameter`, `swx_props_set`, `swx_rebuild_check`, `swx_bom`, `swx_pack_and_go`, `swx_activate_document` and `swx_set_preference` reachable during add-in load. The gate also moved BEFORE the handler table (several arms return directly). `tests/wiring.rs` pins the exemption list name by name; a new verb is gated until someone adds it there |+| Gated by default | Every verb waits for readiness unless it is on `STARTUP_EXEMPT` (16 verbs in 0.9.5, 17 from 0.9.11 with `swx_user_presence`, each with its reason in `src/verbs.rs`: the status/start/describe probes, the bridge-local and disk verbs, the window-census and window-message verbs, the screenshot, the badge, and `swx_kill`). The 15-name gate list had left `swx_show_*` (an `open`), `swx_export` and the ten `swx_export_*` (`SaveAs3` — the crash log's `CMD:DocSave`), `swx_com_eval`, `swx_set_parameter`, `swx_props_set`, `swx_rebuild_check`, `swx_bom`, `swx_pack_and_go`, `swx_activate_document` and `swx_set_preference` reachable during add-in load. The gate also moved BEFORE the handler table (several arms return directly). `tests/wiring.rs` pins the exemption list name by name; a new verb is gated until someone adds it there | | The log speaks only for THIS session | A log last written before this process's creation time (census; the host's own word if there is no census) is `notThisSession` — never `complete`. Within a bound log the latest start is located by position (the format has no timestamps this bridge relies on) | | COM alone is never enough on a machine that writes markers | Measured: `StartupProcessCompleted` was TRUE before the marker. Once a marker has been seen in this machine's log (remembered in the data root, `perflog-markers-seen`), a session with no marker of its own is `loading`, and an untied log makes the session `unverifiable` — COM's `true` does not decide. Only an edition whose log has never held a marker lets COM decide alone | | An add-in loaded ON DEMAND | A `LOAD_ADDIN` after this session's marker is read as on-demand (ready) only when COM says the start completed, the log has been quiet for 60 s (`ON_DEMAND_QUIET_SECS`), and none of the `LOAD_ADDIN` lines after the marker names the 3DEXPERIENCE add-in (every start loads it — it writes the marker — so naming it means a start, possibly wedged). The verdict `onDemandAddInAfterComplete` carries this basis in `why` |@@ -1167,6 +1167,26 @@ gap between COM true and the marker. Read-only, no document commands; it waits until Drew is no longer working in the session (0.9.9's presence guard is what tells the harness so). +**MEASURED LOCK-OUT, 2026-10-01 on hosted 0.9.10, and 0.9.11.** Drew's session+— pid 26924, started 14:59:33, 34 documents open, nearly three hours old — had+every gated verb refused `host_app_starting`: the update had respawned the+bridge (the ready latch was gone) and the log read "loading … names the+platform add-in". The log, verbatim: lines 93–101 `<AD2> "LOAD_ADDIN" …` (100 =+`"SWK3DCompassAddin.dll" "3DEXPERIENCE for WORKS"`), 106 `<3DXINITERROR>0`, 107+`<3DXLOAD_TIME>19000</3DXLOAD_TIME>`, 114–120 `<AD2> "UNLOAD_ADDIN" …` (119 =+the platform add-in UNLOADING), 124 `<AD2> "LOAD_ADDIN" "sldfuncfeat.dll"`, then+periodic `<PROCMEM> CMD:AppRunTime` lines (last write 15 min before the read).+**The bug:** `UNLOAD_ADDIN` contains `LOAD_ADDIN`, so line 119 read as the+platform add-in loading after the marker — "a start".++| 0.9.11 | |+|---|---|+| L1 — parse the token | A load event is the quoted token `"LOAD_ADDIN"` (or the `<LOAD_ADDIN>` tag), never a substring; `"UNLOAD_ADDIN"` is its own event and never evidence of loading. The measured log is a fixture: it reads `onDemandAddInAfterComplete` → ready |+| L2 — a session cannot be starting for hours | Process older than 10 min (`SWX_LONG_RUNNING_SECS`) AND COM StartupProcessCompleted true: "loading" holds only if a startup line changed within the quiet window; otherwise ready, basis `longRunningSession` |+| L3 — startup activity, not file activity | An active session writes `<PROCMEM> CMD:AppRunTime` periodically, so the FILE is never quiet. The quiet window now counts only add-in loads and markers: the bridge remembers, per session, when those lines last changed (all were written at or before the file's mtime when first seen) |+| L4 — swx_user_presence is exempt | It answers during a start or a wedge: foreground and last input from the process census; documents asked of COM only on a verified-ready session, otherwise `documentsUnknown:true`, which counts as present. The exemption list is 17 verbs |+| L5 — the latch survives a respawn | A session seen ready is persisted in the data root (`ready-sessions.json`, by pid + creation time); a respawned bridge reads it (`readyBeforeThisBridgeRestarted`) |+ ## Live proof 4 — 0.9.3 on Drew's machine (2026-10-01) | | Measured |
install.shpackage.json+1−1@@ -1,6 +1,6 @@ { "slug": "solidworks-bridge",- "version": "0.9.10",+ "version": "0.9.11", "type": "app", "description": "Skills for your container so your AI knows how to drive the SOLIDWORKS bridge (swx_* verbs). The bridge runtime itself is the release zip \u2014 a single native swx-bridge.exe; Adom Bridge loads that.", "tags": [
references/launch-and-sign-in.md+4−1@@ -86,7 +86,10 @@ Read this before starting SOLIDWORKS, while waiting through a launch or a 3DEXPE after the start (a quiet log, an add-in other than 3DEXPERIENCE, COM true) does not make a started session "starting" again. A session that never loads the platform add-in (it can be disabled) is ready once its log is quiet- and COM agrees; a log still being written holds the start.+ and COM agrees; a log still being written holds the start. A session older+ than ten minutes whose COM says the start completed is never held by the log+ unless add-in loads are still appearing (`longRunningSession`), and a session+ seen ready stays ready across a bridge update. - **`state:"unverifiable"`** — nothing can verify the start. After the grace (`readsAllowed:true`) READ verbs run; anything that creates, opens, saves, closes or changes a document refuses with `startingUnverifiable:true`
src/perflog.rs+82−15@@ -118,6 +118,56 @@ impl LogVerdict { } } +/// IS THIS LINE AN ADD-IN *LOAD* EVENT? Parsed as a token, never a substring+/// (0.9.11): MEASURED 2026-10-01, `<AD2> "UNLOAD_ADDIN" "SWK3DCompassAddin.dll"`+/// CONTAINS `LOAD_ADDIN`, and reading it as the platform add-in loading after the+/// marker locked a three-hour-old session out as "a start". The measured format+/// is `<AD2> "LOAD_ADDIN" "<dll>" "<name>" …` — the quoted token exactly; the+/// `<LOAD_ADDIN> …` tag form is accepted too. `"UNLOAD_ADDIN"` is its own event+/// and never evidence of loading.+pub fn is_load_event(line: &str) -> bool {+ let t = line.trim_start();+ t.contains("\"LOAD_ADDIN\"") || t.starts_with("<LOAD_ADDIN>")+}++fn is_marker_line(line: &str) -> bool {+ LOAD_MARKERS.iter().any(|m| line.contains(m))+}++/// The quiet window, overridable (`SWX_STARTUP_QUIET_SECS`) for the suite.+pub fn quiet_window() -> u64 {+ std::env::var("SWX_STARTUP_QUIET_SECS").ok().and_then(|v| v.trim().parse().ok()).unwrap_or(ON_DEMAND_QUIET_SECS)+}++/// STARTUP ACTIVITY, not file activity (0.9.11, L3): an ACTIVE session writes+/// `<PROCMEM> CMD:AppRunTime` lines periodically, so "the file was written+/// recently" says nothing about add-ins. What counts is when the STARTUP lines+/// (add-in loads, markers) last changed: all of them were written at or before+/// the file's mtime when the bridge first saw them, and the bridge remembers+/// that time per session until they change again.+static STARTUP_SEEN: std::sync::Mutex<Option<std::collections::HashMap<u64, (String, u64)>>> =+ std::sync::Mutex::new(None);++/// Seconds since this session's STARTUP lines last changed, as last computed —+/// for the long-running-session rule in readiness.rs. `None` = never computed.+pub fn startup_quiet_of(session_start: u64) -> Option<u64> {+ let g = STARTUP_SEEN.lock().unwrap_or_else(|e| e.into_inner());+ g.as_ref()+ .and_then(|m| m.get(&session_start))+ .map(|e| crate::launchos::epoch_secs().saturating_sub(e.1))+}++fn startup_quiet(session_start: u64, fingerprint: String, mtime: u64) -> u64 {+ let now = crate::launchos::epoch_secs();+ let mut g = STARTUP_SEEN.lock().unwrap_or_else(|e| e.into_inner());+ let m = g.get_or_insert_with(std::collections::HashMap::new);+ let e = m.entry(session_start).or_insert_with(|| (fingerprint.clone(), mtime));+ if e.0 != fingerprint {+ *e = (fingerprint, mtime);+ }+ now.saturating_sub(e.1)+}+ fn mtime_secs(path: &std::path::Path) -> Option<u64> { std::fs::metadata(path) .and_then(|md| md.modified())@@ -134,7 +184,7 @@ fn mtime_secs(path: &std::path::Path) -> Option<u64> { /// /// * a marker after the last `LOAD_ADDIN` -> complete; /// * a `LOAD_ADDIN` after the marker: the platform DLL -> loading;-/// anything else, log quiet >= ON_DEMAND_QUIET_SECS -> on demand;+/// anything else, log quiet >= quiet_window() -> on demand; /// log still being written -> loading; /// * NO marker: log still being written (quiet < the window) -> loading — /// a log THIS session is actively writing is evidence on its own, whatever@@ -189,19 +239,36 @@ pub fn startup_complete(session_start: Option<u64>) -> (LogVerdict, String) { } None => return (LogVerdict::NotThisSession, "the log's modification time could not be read".into()), };- let quiet = crate::launchos::epoch_secs().saturating_sub(mt);- let is_platform = |l: &str| l.contains("LOAD_ADDIN") && l.to_ascii_lowercase().contains(PLATFORM_ADDIN_DLL);+ let is_platform = |l: &str| is_load_event(l) && l.to_ascii_lowercase().contains(PLATFORM_ADDIN_DLL);+ // Every line with its byte offset, so positions compare exactly.+ let mut lines: Vec<(usize, &str)> = Vec::new();+ {+ let mut off = 0usize;+ for l in t.split_inclusive('\n') {+ lines.push((off, l));+ off += l.len();+ }+ }+ let startup_lines: Vec<&str> =+ lines.iter().map(|(_, l)| *l).filter(|l| is_load_event(l) || is_marker_line(l)).collect();+ let fingerprint = format!(+ "{}|{}",+ startup_lines.len(),+ startup_lines.last().map(|l| l.trim()).unwrap_or("")+ );+ let quiet = startup_quiet(start, fingerprint, mt); let last_marker = LOAD_MARKERS.iter().filter_map(|m| t.rfind(m).map(|i| (i, *m))).max(); let Some((m, name)) = last_marker else {- let addins = t.lines().filter(|l| l.contains("LOAD_ADDIN")).count();+ let addins = lines.iter().filter(|(_, l)| is_load_event(l)).count(); let platform = t.lines().any(is_platform);- if quiet < ON_DEMAND_QUIET_SECS {+ if quiet < quiet_window() { return ( LogVerdict::Loading, format!(- "this session's log is still being written ({quiet}s ago, {addins} LOAD_ADDIN \- line(s)) and holds no 3DEXPERIENCE marker yet: a start in progress — held until \- the marker arrives or the log has been quiet for {ON_DEMAND_QUIET_SECS}s"+ "this session's startup lines changed {quiet}s ago ({addins} LOAD_ADDIN \+ line(s)) and the log holds no 3DEXPERIENCE marker yet: a start in progress — \+ held until the marker arrives or no startup line changes for {}s",+ quiet_window() ), ); }@@ -210,7 +277,7 @@ pub fn startup_complete(session_start: Option<u64>) -> (LogVerdict, String) { LogVerdict::Loading, format!( "the platform add-in ({PLATFORM_ADDIN_DLL}) loaded in this session but its marker \- has not been written, and the log has been quiet for {quiet}s: the platform \+ has not been written, and no startup line has changed for {quiet}s: the platform \ load has not finished (a dialog, a sign-in?)" ), );@@ -219,13 +286,13 @@ pub fn startup_complete(session_start: Option<u64>) -> (LogVerdict, String) { LogVerdict::PlatformAddInNotLoaded, format!( "this session never loaded the platform add-in ({PLATFORM_ADDIN_DLL}), so no marker \- is coming; {addins} LOAD_ADDIN line(s) and no log activity for {quiet}s (>= \+ is coming; {addins} LOAD_ADDIN line(s) and no startup activity for {quiet}s (>= \ {ON_DEMAND_QUIET_SECS}s): the add-in load phase is over — ready only if COM also \ says the start completed" ), ); };- let last_addin = t.rfind("LOAD_ADDIN");+ let last_addin = lines.iter().rev().find(|(_, l)| is_load_event(l)).map(|(o, _)| *o); match last_addin { Some(a) if a > m => { // A START loads the PLATFORM add-in — it is what writes the marker —@@ -237,12 +304,12 @@ pub fn startup_complete(session_start: Option<u64>) -> (LogVerdict, String) { // ("3DEXPERIENCE files on This PC") and SWK3DCompassAddin // ("3DEXPERIENCE for WORKS"); only the last is the platform. let start_block = t[m..].lines().any(is_platform);- if quiet >= ON_DEMAND_QUIET_SECS && !start_block {+ if quiet >= quiet_window() && !start_block { ( LogVerdict::OnDemandAfterComplete, format!( "a {name} marker, then LOAD_ADDIN line(s) not naming the platform add-in \- ({PLATFORM_ADDIN_DLL}), then no log activity for {quiet}s (>= \+ ({PLATFORM_ADDIN_DLL}), then no startup activity for {quiet}s (>= \ {ON_DEMAND_QUIET_SECS}s): an add-in loaded on demand after the start \ completed — ready only if COM also says the start completed" ),@@ -252,7 +319,7 @@ pub fn startup_complete(session_start: Option<u64>) -> (LogVerdict, String) { LogVerdict::Loading, format!( "add-ins are still loading: the last LOAD_ADDIN is newer than the last {name}{}, \- and the log was written {quiet}s ago",+ and the startup lines last changed {quiet}s ago", if start_block { " and names the platform add-in (a start, not an on-demand load)" } else {@@ -296,7 +363,7 @@ pub fn crash_evidence() -> Vec<String> { let lines: Vec<&str> = t.lines().collect(); // Only what was written after the last start's add-ins began loading: an // older session's crash is not this one's.- let from = lines.iter().rposition(|l| l.contains("LOAD_ADDIN")).unwrap_or(0);+ let from = lines.iter().rposition(|l| is_load_event(l)).unwrap_or(0); let hits: Vec<String> = lines[from..] .iter() .filter(|l| CRASH_MARKERS.iter().any(|m| l.contains(m)))
src/presence.rs+50@@ -230,6 +230,56 @@ fn mark_taken_over(pid: u32, created: Option<u64>, evidence: &Value) { crate::ledger::record("session_taken_over_by_user", json!({"pid": pid, "createdAt": created, "evidence": evidence})); } +/// PRESENCE WITHOUT COM (0.9.11, L4): for swx_user_presence while SOLIDWORKS+/// has not finished starting (or COM cannot answer) — the foreground window and+/// the last input are measured exactly as always, from the process census; the+/// DOCUMENT part is unknown, and unknown counts as present.+pub fn measure_without_com() -> Option<(bool, Value)> {+ let os = crate::launchos::os();+ let census = os.processes().ok()?;+ let sw: Vec<&crate::launchos::ProcInfo> =+ census.iter().filter(|p| p.image.eq_ignore_ascii_case("SLDWORKS.exe")).collect();+ let [one] = sw.as_slice() else { return None };+ let (pid, created) = (one.pid, one.created_at);+ let children: Vec<u32> = census.iter().filter(|p| p.ppid == Some(pid)).map(|p| p.pid).collect();+ let fg = os.foreground_window();+ let input = os.seconds_since_last_input().ok();+ let (fg_json, fg_is_sw) = match &fg {+ Ok(Some(w)) => (+ json!({"hwnd": w.hwnd, "pid": w.pid, "title": w.title, "className": w.class_name}),+ w.pid == pid || children.contains(&w.pid),+ ),+ Ok(None) => (json!({"unreadable": "no window has the foreground"}), false),+ Err(e) => (json!({"unreadable": e}), false),+ };+ let at_keyboard = fg_is_sw && input.map(|n| n < idle_secs()).unwrap_or(false);+ let taken = taken_over(pid, created).is_some();+ let evidence = json!({+ "present": true,+ "foregroundWindow": fg_json,+ "foregroundIsSolidworks": fg_is_sw,+ "secondsSinceLastInput": input,+ "idleThresholdSecs": idle_secs(),+ "foreignDocuments": Value::Null,+ "documentsUnknown": true,+ "takenOverByUser": taken || at_keyboard,+ "takenOverEarlier": taken,+ "processId": pid,+ "processCreatedAt": created,+ "basis": if at_keyboard {+ "SOLIDWORKS is the foreground window and the user gave input recently"+ } else {+ "SOLIDWORKS cannot be asked about its documents right now (it has not finished starting, \+ or COM cannot answer), so whether the user has documents open is UNKNOWN — which counts \+ as present"+ },+ });+ if at_keyboard {+ mark_taken_over(pid, created, &evidence);+ }+ Some((true, evidence))+}+ /// MEASURE PRESENCE. `(present, evidence)`. pub fn measure(ctx: &crate::verbs::Ctx) -> Option<(bool, Value)> { let s = session(ctx)?;
src/readiness.rs+67@@ -117,6 +117,14 @@ pub fn assess(ctx: &crate::verbs::Ctx) -> Value { let age = seen.first.elapsed().as_secs(); let mut based_on: Vec<&str> = Vec::new(); // Some(true) verified ready, Some(false) verified starting, None unverifiable.+ // THE READY LATCH SURVIVES A BRIDGE RESPAWN (0.9.11, L5): every update+ // respawns the bridge, and MEASURED 2026-10-01 a respawned bridge re-opened+ // the question for a three-hour-old session. Persisted in the data root by+ // (pid, creation time).+ if !seen.ready && pid.is_some() && created.is_some() && persisted_ready(pid.unwrap_or(-1), created) {+ seen.ready = true;+ based_on.push("readyBeforeThisBridgeRestarted");+ } let verdict: Option<bool> = if seen.ready && pid.is_some() { based_on.push("alreadyReadyThisSession"); Some(true)@@ -127,8 +135,23 @@ pub fn assess(ctx: &crate::verbs::Ctx) -> Value { if log != L::Unavailable { based_on.push("performanceLog"); }+ // A SESSION CANNOT BE STARTING FOR HOURS (0.9.11, L2). MEASURED+ // 2026-10-01: a three-hour-old session with 34 documents open read+ // "loading" and every gated verb refused. When the process is older+ // than long_running_secs() and COM says the start completed, "loading"+ // HOLDS only if a startup line (an add-in load, a marker) changed within+ // the quiet window; otherwise ready, basis `longRunningSession`.+ let process_age = created.map(|c| crate::launchos::epoch_secs().saturating_sub(c));+ let startup_quiet = created.and_then(crate::perflog::startup_quiet_of);+ let long_running = process_age.map(|a| a > long_running_secs()).unwrap_or(false)+ && com == Some(true)+ && startup_quiet.map(|q| q >= crate::perflog::quiet_window()).unwrap_or(false); match (log, com) { (_, Some(false)) => Some(false),+ (L::Loading, Some(true)) if long_running => {+ based_on.push("longRunningSession");+ Some(true)+ } (L::Loading, _) => Some(false), (L::Complete, _) => Some(true), (L::OnDemandAfterComplete, Some(true)) => Some(true),@@ -146,6 +169,9 @@ pub fn assess(ctx: &crate::verbs::Ctx) -> Value { }; let ready = verdict == Some(true); if ready && pid.is_some() {+ if !seen.ready {+ persist_ready(pid.unwrap_or(-1), created);+ } seen.ready = true; } drop(guard);@@ -198,6 +224,47 @@ pub fn assess(ctx: &crate::verbs::Ctx) -> Value { }) } +/// A session is long-running past this many seconds (`SWX_LONG_RUNNING_SECS`).+pub fn long_running_secs() -> u64 {+ std::env::var("SWX_LONG_RUNNING_SECS").ok().and_then(|v| v.trim().parse().ok()).unwrap_or(600)+}++fn ready_path() -> std::path::PathBuf {+ crate::scratch::root().join("ready-sessions.json")+}++fn persisted_ready(pid: i64, created: Option<u64>) -> bool {+ let Some(c) = created else { return false };+ std::fs::read_to_string(ready_path())+ .ok()+ .and_then(|t| serde_json::from_str::<Value>(&t).ok())+ .and_then(|v| v.get(format!("{pid}:{c}")).cloned())+ .is_some()+}++fn persist_ready(pid: i64, created: Option<u64>) {+ let Some(c) = created else { return };+ let mut m = std::fs::read_to_string(ready_path())+ .ok()+ .and_then(|t| serde_json::from_str::<Value>(&t).ok())+ .and_then(|v| v.as_object().cloned())+ .unwrap_or_default();+ m.insert(format!("{pid}:{c}"), json!({"readyAtMs": crate::launchos::epoch_ms() as u64}));+ while m.len() > 20 {+ let oldest = m+ .iter()+ .min_by_key(|(_, v)| v.get("readyAtMs").and_then(|a| a.as_u64()).unwrap_or(0))+ .map(|(k, _)| k.clone());+ match oldest {+ Some(o) => {+ m.remove(&o);+ }+ None => break,+ }+ }+ let _ = crate::launch::write_atomic(&ready_path(), &Value::Object(m).to_string());+}+ /// Has this bridge VERIFIED that session `pid` finished starting? Read from the /// per-session cache; no COM. pub fn known_ready(pid: u32, created: Option<u64>) -> bool {
src/verbs.rs+12−1@@ -3045,7 +3045,17 @@ pub const CATALOG: &[(&str, &str, &str)] = &[ /// guarded by, on its own (0.9.9). Read-only towards SOLIDWORKS; positive /// evidence marks the session taken over by the user, exactly as the guard does. fn swx_user_presence(ctx: &Ctx, _args: &Value) -> Value {- match crate::presence::measure(ctx) {+ // EXEMPT FROM THE STARTUP GATE (0.9.11, L4): a read about the USER, needed+ // most while SOLIDWORKS is starting or wedged. It asks COM for documents+ // only when the session is verified ready; otherwise it measures without+ // COM and says the document part is unknown.+ let ready = crate::readiness::assess(ctx).get("ready").and_then(|v| v.as_bool()) == Some(true);+ let measured = if ready {+ crate::presence::measure(ctx).or_else(crate::presence::measure_without_com)+ } else {+ crate::presence::measure_without_com()+ };+ match measured { None => ok(json!({"present": Value::Null, "running": false, "_hint": "No running SOLIDWORKS session to measure."})), Some((present, evidence)) => ok(json!({@@ -3120,6 +3130,7 @@ pub const STARTUP_EXEMPT: &[(&str, &str)] = &[ ("swx_screenshot", "through ab's generic screenshot; no com host"), ("swx_set_driving_badge", "the taskbar badge; no com host"), ("swx_kill", "the last resort for a wedged start (reason + confirm); reads the document count only to say what is lost"),+ ("swx_user_presence", "a read about the USER, needed most during a start; asks COM for documents only on a verified-ready session"), ]; /// swx_stop is gated with one carve-out: under `userAuthorized`, a plan that
tests/readiness.rs+184−1@@ -374,7 +374,7 @@ const EXEMPT: &[&str] = &[ "swx_status", "swx_readiness", "swx_start", "swx_describe", "swx_progress", "swx_action_ledger", "swx_verb_times", "swx_uninstall", "swx_find_files", "swx_recover_list", "swx_window_info", "swx_dismiss_blocking_dialogs",- "swx_close_window", "swx_screenshot", "swx_set_driving_badge", "swx_kill",+ "swx_close_window", "swx_screenshot", "swx_set_driving_badge", "swx_kill", "swx_user_presence", ]; #[test]@@ -527,6 +527,9 @@ fn an_on_demand_add_in_needs_quiet_and_is_never_the_3dexperience_add_in() { ] { let scratch = TempDir::new(tag); session(&scratch, json!([]));+ // A START is a young process (0.9.11: past ten minutes, a session with+ // COM true and quiet startup lines is long-running, never starting).+ created_ago(&scratch, 400); let logp = write_log(&scratch, text, age); let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[("SWX_PERF_LOG", &logp)]); b.call("swx_start", json!({}));@@ -714,6 +717,7 @@ fn a_platform_load_without_its_marker_is_held_and_the_logs_shape_is_recorded() { const COMPASS: &str = r#"<AD2> "LOAD_ADDIN" "SWK3DCompassAddin.dll" "3DEXPERIENCE for WORKS" [2.18] "6.34.0.2027" 14 COM.new 1770885081 13"#; let scratch = TempDir::new("ready-platform-no-marker"); session(&scratch, json!([]));+ created_ago(&scratch, 400); // a start: a young process (0.9.11), its log written since let logp = write_log( &scratch, &format!("; SolidWorks generated log file - do not edit.\n<VERSION> 6</VERSION>\n{COMPASS}\n"),@@ -886,3 +890,182 @@ fn a_missing_configured_log_is_unverifiable_not_absent() { s(&r) ); }++// ── 0.9.11: MEASURED 2026-10-01 — a three-hour-old session locked out ──────++/// The performance log of Drew's session pid 26924 (started 14:59:33, 34+/// documents open, nearly three hours old), as measured — lines 93–101 add-in+/// loads (100 = the platform add-in), 106/107 the markers, 114–120 add-in+/// UNLOADS (119 = the platform add-in), 124 an on-demand load, then periodic+/// `<PROCMEM> CMD:AppRunTime` lines. 0.9.10 read `"UNLOAD_ADDIN"` as containing+/// `LOAD_ADDIN`, called line 119 the platform add-in LOADING after the marker —+/// "a start" — and refused every gated verb on this session.+fn drews_log() -> String {+ let mut l = vec![+ "; SolidWorks generated log file - do not edit.".to_string(),+ "<VERSION> 6</VERSION>".into(),+ "<PROC_NUM>16</PROC_NUM>".into(),+ "<PROC_ARCH>9</PROC_ARCH>".into(),+ "<OS>10.0.26100</OS>".into(),+ ];+ for (dll, name) in [+ ("BendSequenceSwu.dll", "Bend Sequence"),+ ("SldAuraEx.DLL", "SOLIDWORKS Aura"),+ ("SWToolbox.dll", "SOLIDWORKS Toolbox Library"),+ ("SaveFolderTo3DEXPERIENCEAddin2.DLL", "Batch Save to 3DEXPERIENCE"),+ ("TaskPaneCacheExplorerAddin.DLL", "3DEXPERIENCE files on This PC"),+ ("SWK3DCompassAddin.dll", "3DEXPERIENCE for WORKS"),+ ] {+ l.push(format!(r#"<AD2> "LOAD_ADDIN" "{dll}" "{name}" [2.18] "6.34.0.2027" 14 COM.new 1770885081 13"#));+ }+ l.push("<3DXINITERROR>0".into());+ l.push("<3DXLOAD_TIME>19000</3DXLOAD_TIME>".into());+ l.push(r#"<AD2> "UNLOAD_ADDIN" "BendSequenceSwu.dll" "" [0.00] 0"#.into());+ l.push(r#"<AD2> "UNLOAD_ADDIN" "SWK3DCompassAddin.dll" "3DEXPERIENCE for WORKS" [0.50] 13"#.into());+ l.push(r#"<AD2> "UNLOAD_ADDIN" "SldAuraEx.DLL" "" [0.00] 0"#.into());+ l.push(r#"<AD2> "LOAD_ADDIN" "sldfuncfeat.dll" "sldFuncFeat" [0.16] "34.0" 14 COM.new 1770885300 13"#.into());+ for _ in 0..4 {+ l.push("<PROCMEM> CMD:AppRunTime 1832.5 12873 MB".into());+ }+ l.join("\n") + "\n"+}++fn created_ago(scratch: &TempDir, secs: u64) {+ let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();+ std::fs::write(scratch.join("mock-sldworks-created"), (now - secs).to_string()).unwrap();+}++#[test]+fn an_unload_is_never_read_as_a_load_and_the_measured_log_is_ready() {+ let scratch = TempDir::new("ready-drews-log");+ session(&scratch, json!([]));+ created_ago(&scratch, 3 * 3600);+ let logp = write_log(&scratch, &drews_log(), 900);+ let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[("SWX_PERF_LOG", &logp)]);+ let st = b.call("swx_start", json!({}));+ check!(+ b,+ "swx_start on the measured log: ready, BY THE LOG (an on-demand add-in after the marker) — \+ the platform add-in's UNLOAD is not a load",+ st["ready"] == true+ && st["readiness"]["performanceLog"]["verdict"] == "onDemandAddInAfterComplete"+ && !st["readiness"]["basedOn"].to_string().contains("longRunningSession"),+ s(&st["readiness"])+ );+}++/// A SESSION CANNOT BE STARTING FOR HOURS (L2): the platform add-in loaded after+/// the marker (the shape of a start), COM says complete, and no startup line has+/// changed for five minutes — on a process three hours old that is ready+/// (`longRunningSession`); on one two minutes old it still holds.+#[test]+fn a_long_running_session_is_never_locked_out_by_a_log_rule() {+ let text = "<LOAD_ADDIN> \"SWK3DCompassAddin.dll\"\n<3DXLOAD_TIME>24000</3DXLOAD_TIME>\n\+ <AD2> \"LOAD_ADDIN\" \"SWK3DCompassAddin.dll\" \"3DEXPERIENCE for WORKS\"\n";+ let scratch = TempDir::new("ready-long-running");+ session(&scratch, json!([]));+ created_ago(&scratch, 3 * 3600);+ let logp = write_log(&scratch, text, 300);+ let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[("SWX_PERF_LOG", &logp)]);+ let st = b.call("swx_start", json!({}));+ check!(+ b,+ "a three-hour-old session, COM true, startup lines quiet 300 s: ready, basis longRunningSession",+ st["ready"] == true && st["readiness"]["basedOn"].to_string().contains("longRunningSession"),+ s(&st["readiness"])+ );++ let scratch = TempDir::new("ready-young-holds");+ session(&scratch, json!([]));+ created_ago(&scratch, 120);+ let logp = write_log(&scratch, text, 100);+ let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[("SWX_PERF_LOG", &logp)]);+ let r = b.call("swx_new_document", json!({"type": "part"}));+ check!(+ b,+ "the same log on a two-minute-old process: still loading, refused",+ r["errorCode"] == "host_app_starting" && r["readiness"]["performanceLog"]["verdict"] == "loading",+ s(&r)+ );+}++/// PERIODIC RUN-TIME LINES ARE NOT STARTUP ACTIVITY (L3): an active session+/// keeps writing `<PROCMEM> CMD:AppRunTime`, so the FILE is never quiet. The+/// quiet window counts only add-in loads and markers.+#[test]+fn periodic_runtime_lines_do_not_keep_a_session_starting() {+ let scratch = TempDir::new("ready-procmem");+ session(&scratch, json!([]));+ created_ago(&scratch, 300);+ let base = "<LOAD_ADDIN> \"SWK3DCompassAddin.dll\"\n<3DXLOAD_TIME>24000</3DXLOAD_TIME>\n\+ <AD2> \"LOAD_ADDIN\" \"SWToolbox.dll\" \"SOLIDWORKS Toolbox Library\"\n\+ <PROCMEM> CMD:AppRunTime 300.1 9000 MB\n";+ let logp = write_log(&scratch, base, 0);+ let b = Bridge::start_in(+ TempDir::borrowed(&scratch.0),+ &[("SWX_PERF_LOG", &logp), ("SWX_STARTUP_QUIET_SECS", "2")],+ );+ let first = b.call("swx_start", json!({}));+ check!(b, "first look: the startup lines are new to the bridge — held", first["ready"] == false, s(&first["readiness"]));+ std::thread::sleep(std::time::Duration::from_millis(2500));+ // The session keeps working: another run-time line, written NOW.+ write_log(&scratch, &format!("{base}<PROCMEM> CMD:AppRunTime 302.6 9001 MB\n"), 0);+ let st = b.call("swx_status", json!({}));+ check!(+ b,+ "a fresh PROCMEM line does not reset the quiet window: an on-demand add-in, ready",+ st["solidworks"]["ready"] == true+ && st["solidworks"]["readiness"]["performanceLog"]["verdict"] == "onDemandAddInAfterComplete",+ s(&st["solidworks"]["readiness"])+ );+}++/// THE LATCH SURVIVES A BRIDGE RESPAWN (L5): every update respawns the bridge;+/// a session already seen ready stays ready under the new one, whatever its+/// log looks like now.+#[test]+fn a_session_seen_ready_stays_ready_across_a_bridge_restart() {+ let scratch = TempDir::new("ready-latch-persist");+ session(&scratch, json!([]));+ created_ago(&scratch, 300);+ let logp = write_log(&scratch, "<LOAD_ADDIN> x\n<3DXLOAD_TIME>24000</3DXLOAD_TIME>\n", 0);+ {+ let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[("SWX_PERF_LOG", &logp)]);+ let st = b.call("swx_start", json!({}));+ check!(b, "precondition: ready under the first bridge", st["ready"] == true, s(&st["readiness"]));+ }+ // The update respawns the bridge; the log now looks like loading.+ write_log(&scratch, "<LOAD_ADDIN> x\n<3DXLOAD_TIME>24000</3DXLOAD_TIME>\n<LOAD_ADDIN> \"SWK3DCompassAddin.dll\"\n", 0);+ let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[("SWX_PERF_LOG", &logp)]);+ let st = b.call("swx_start", json!({}));+ check!(+ b,+ "under the respawned bridge: still ready, because this session was",+ st["ready"] == true && st["readiness"]["basedOn"].to_string().contains("readyBeforeThisBridgeRestarted"),+ s(&st["readiness"])+ );+}++/// swx_user_presence IS EXEMPT (L4) and works while SOLIDWORKS is starting:+/// foreground and last input are measured, the document part is unknown, and+/// unknown counts as present.+#[test]+fn user_presence_answers_during_startup_with_documents_unknown() {+ let scratch = TempDir::new("ready-presence-starting");+ session(&scratch, json!([]));+ std::fs::write(scratch.join("mock-startup-incomplete"), "").unwrap();+ let machine = a_visible_machine("ready-presence-starting-m");+ let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[("SWX_LAUNCH_FAKE_DIR", &machine.path())]);+ b.call("swx_start", json!({}));+ b.call("swx_status", json!({}));+ let p = b.call("swx_user_presence", json!({}));+ check!(+ b,+ "swx_user_presence during startup: answered, present, documents unknown, foreground measured",+ p["success"] == true+ && p["present"] == true+ && p["evidence"]["documentsUnknown"] == true+ && p["evidence"]["foregroundWindow"].is_object(),+ s(&p)+ );+}
tests/wiring.rs+1−1@@ -1068,7 +1068,7 @@ fn the_startup_exemption_list_is_pinned_name_by_name() { "swx_status", "swx_readiness", "swx_start", "swx_describe", "swx_progress", "swx_action_ledger", "swx_verb_times", "swx_uninstall", "swx_find_files", "swx_recover_list", "swx_window_info", "swx_dismiss_blocking_dialogs",- "swx_close_window", "swx_screenshot", "swx_set_driving_badge", "swx_kill",+ "swx_close_window", "swx_screenshot", "swx_set_driving_badge", "swx_kill", "swx_user_presence", ]; pinned.sort(); assert_eq!(
uninstall.sh