From ea25f6c9db089bc553370d0f354920b1f88f593e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 26 Jul 2026 00:01:06 +0000 Subject: [PATCH 1/4] Harden Windows desktop API startup against Failed to fetch Spawn Node with CREATE_NO_WINDOW, keep http://tauri.localhost (no mixed content), broaden CORS for *.localhost, improve offline UI with log path, and smoke-test the sidecar in CI. Co-authored-by: Damon --- .github/workflows/ci.yml | 33 +++++++++++++ apps/api/src/index.ts | 12 ++++- apps/desktop/src-tauri/src/main.rs | 76 +++++++++++++++++++++++++----- apps/ui/src/App.tsx | 18 ++++++- apps/ui/src/lib/api.ts | 4 ++ 5 files changed, 128 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4248baa..61bcc6f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,6 +82,39 @@ jobs: projectPath: apps/desktop args: ${{ matrix.args }} includeUpdaterJson: false + - name: Smoke API sidecar + shell: bash + run: | + set -euo pipefail + SIDE="apps/desktop/src-tauri/sidecars" + if [[ "${{ runner.os }}" == "Windows" ]]; then + NODE="$SIDE/node.exe" + else + NODE="$SIDE/node" + chmod +x "$NODE" || true + fi + test -f "$NODE" + test -f "$SIDE/prm-api.mjs" + DATA="${RUNNER_TEMP:-/tmp}/prm-sidecar-smoke" + rm -rf "$DATA" + mkdir -p "$DATA" + PRM_DATA_DIR="$DATA" PRM_PORT=8799 PRM_LAUNCH_TOKEN=ci-smoke \ + "$NODE" "$SIDE/prm-api.mjs" >"$DATA/out.log" 2>&1 & + PID=$! + cleanup() { kill "$PID" 2>/dev/null || true; } + trap cleanup EXIT + for i in $(seq 1 50); do + if curl -sf "http://127.0.0.1:8799/api/health" >/dev/null; then + echo "sidecar healthy" + curl -sf "http://127.0.0.1:8799/api/workspace/status" -H "x-prm-launch-token: ci-smoke" + echo + exit 0 + fi + sleep 0.2 + done + echo "sidecar failed to become healthy" >&2 + cat "$DATA/out.log" >&2 || true + exit 1 - name: Upload desktop installers uses: actions/upload-artifact@v4 with: diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 063e39a..5498183 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -137,12 +137,22 @@ const CORS_ORIGINS = [ "tauri://localhost", "https://tauri.localhost", "http://tauri.localhost", + "https://asset.localhost", + "http://asset.localhost", ]; +function corsOrigin(origin: string): string | null { + if (CORS_ORIGINS.includes(origin)) return origin; + // Windows/Android custom-protocol workaround hosts: http(s)://*.localhost + if (/^https?:\/\/(?:[a-z0-9-]+\.)*localhost(?::\d+)?$/i.test(origin)) return origin; + if (origin.startsWith("tauri://")) return origin; + return null; +} + app.use( "*", cors({ - origin: CORS_ORIGINS, + origin: (origin) => (origin ? corsOrigin(origin) : CORS_ORIGINS[0]), credentials: true, allowHeaders: ["Content-Type", "x-prm-token", "x-prm-launch-token"], }), diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 5e5a8df..2ada98d 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -11,6 +11,9 @@ use std::{ time::{Duration, Instant}, }; +#[cfg(windows)] +use std::os::windows::process::CommandExt; + use serde::Serialize; use tauri::{AppHandle, Manager, State, WebviewUrl, WebviewWindowBuilder}; @@ -18,6 +21,8 @@ const DEFAULT_API_HOST: &str = "127.0.0.1"; const DEFAULT_API_PORT: u16 = 8787; const DEFAULT_API_BASE: &str = "http://127.0.0.1:8787"; const DEV_UI_URL: &str = "http://localhost:5173"; +#[cfg(windows)] +const CREATE_NO_WINDOW: u32 = 0x0800_0000; #[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -143,12 +148,25 @@ fn maybe_spawn_api( let api_entry = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../api/src/index.ts"); let port = api_port(); - let mut program: Option = None; - let mut args: Vec = Vec::new(); + let mut program: Option = None; + let mut args: Vec = Vec::new(); let mut cwd: Option = None; for dir in candidate_sidecar_dirs(resource_dir) { let script = dir.join("prm-api.mjs"); + append_log( + log_path, + &format!( + "PRM: probe sidecar dir {} script_exists={} node_exists={}", + dir.display(), + script.exists(), + if cfg!(windows) { + dir.join("node.exe").exists() + } else { + dir.join("node").exists() + } + ), + ); if !script.exists() { continue; } @@ -158,18 +176,22 @@ fn maybe_spawn_api( dir.join("node") }; if bundled_node.exists() { - program = Some(bundled_node.to_string_lossy().to_string()); + program = Some(bundled_node); } else { - program = Some("node".into()); + program = Some(PathBuf::from("node")); } - args = vec![script.to_string_lossy().to_string()]; + args = vec![script]; cwd = Some(dir); break; } if program.is_none() && force && api_entry.exists() { - program = Some("pnpm".into()); - args = vec!["exec".into(), "tsx".into(), "src/index.ts".into()]; + program = Some(PathBuf::from("pnpm")); + args = vec![ + PathBuf::from("exec"), + PathBuf::from("tsx"), + PathBuf::from("src/index.ts"), + ]; cwd = api_entry.parent()?.parent().map(|p| p.to_path_buf()); } @@ -185,13 +207,15 @@ fn maybe_spawn_api( append_log( log_path, &format!( - "PRM: spawning API sidecar program={program} cwd={} data={} port={port}", + "PRM: spawning API sidecar program={} cwd={} data={} port={port}", + program.display(), cwd.display(), data.display() ), ); - match Command::new(&program) + let mut command = Command::new(&program); + command .args(&args) .current_dir(&cwd) .env("PRM_LAUNCH_TOKEN", token) @@ -199,9 +223,14 @@ fn maybe_spawn_api( .env("PRM_PORT", port.to_string()) .stdin(Stdio::null()) .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() + .stderr(Stdio::piped()); + // GUI subsystem apps on Windows must not attach a console to Node or spawn can hang/fail. + #[cfg(windows)] { + command.creation_flags(CREATE_NO_WINDOW); + } + + match command.spawn() { Ok(mut child) => { let log = log_path.to_path_buf(); if let Some(mut out) = child.stdout.take() { @@ -223,7 +252,25 @@ fn maybe_spawn_api( } }); } - append_log(log_path, &format!("PRM: spawned API sidecar ({program})")); + // Detect immediate crash (missing native module, bad path, etc.) + thread::sleep(Duration::from_millis(150)); + match child.try_wait() { + Ok(Some(status)) => { + append_log( + log_path, + &format!("PRM: API sidecar exited immediately with {status}"), + ); + return None; + } + Ok(None) => {} + Err(err) => { + append_log(log_path, &format!("PRM: try_wait failed: {err}")); + } + } + append_log( + log_path, + &format!("PRM: spawned API sidecar ({})", program.display()), + ); Some(child) } Err(err) => { @@ -315,11 +362,12 @@ fn main() { } let inject = format!( - "window.__PRM_LAUNCH_TOKEN__={};window.__PRM_API_BASE_URL__={};", + "window.__PRM_LAUNCH_TOKEN__={};window.__PRM_API_BASE_URL__={};window.__PRM_DESKTOP_LOG__={};window.__PRM_DESKTOP__=true;", serde_json::to_string(&launch.launch_token).unwrap_or_else(|_| "\"\"".into()), serde_json::to_string(&launch.api_base_url).unwrap_or_else(|_| { serde_json::to_string(DEFAULT_API_BASE).unwrap_or_else(|_| "\"\"".into()) }), + serde_json::to_string(&log_path.to_string_lossy()).unwrap_or_else(|_| "\"\"".into()), ); let url = if cfg!(debug_assertions) { @@ -328,11 +376,13 @@ fn main() { WebviewUrl::App("index.html".into()) }; if app.get_webview_window("main").is_none() { + // Keep http://tauri.localhost on Windows so fetch to http://127.0.0.1:8787 is not mixed-content blocked. WebviewWindowBuilder::new(app, "main", url) .title("Performance Review Manager") .inner_size(1280.0, 840.0) .resizable(true) .initialization_script(&inject) + .use_https_scheme(false) .build()?; } else if let Some(window) = app.get_webview_window("main") { let _ = window.eval(&inject); diff --git a/apps/ui/src/App.tsx b/apps/ui/src/App.tsx index fd3cef7..7c9fe9d 100644 --- a/apps/ui/src/App.tsx +++ b/apps/ui/src/App.tsx @@ -52,11 +52,27 @@ export function App() { ); if (error) { + const desktop = typeof window !== "undefined" && Boolean(window.__PRM_DESKTOP__ || window.__PRM_API_BASE_URL__); + const logPath = typeof window !== "undefined" ? window.__PRM_DESKTOP_LOG__ : undefined; + const apiBase = typeof window !== "undefined" ? window.__PRM_API_BASE_URL__ : undefined; return (

API offline

-

Start the API on port 8787 (`pnpm dev:api`), then refresh.

+ {desktop ? ( + <> +

+ The desktop API sidecar did not become ready. Retry in a moment. If this persists, check the + sidecar log and reinstall from the latest CI artifact. +

+ {apiBase ?

Expected API: {apiBase}

: null} + {logPath ?

Log: {logPath}

: ( +

Log: %APPDATA%\com.prm.workbench\desktop-api.log (Windows)

+ )} + + ) : ( +

Start the API on port 8787 (`pnpm dev:api`), then refresh.

+ )}

{error}

{themeToggle}
diff --git a/apps/ui/src/lib/api.ts b/apps/ui/src/lib/api.ts index be3dbf1..61bff2d 100644 --- a/apps/ui/src/lib/api.ts +++ b/apps/ui/src/lib/api.ts @@ -5,6 +5,10 @@ declare global { __PRM_LAUNCH_TOKEN__?: string; /** Absolute API origin injected by the Tauri shell (e.g. http://127.0.0.1:8787). */ __PRM_API_BASE_URL__?: string; + /** Packaged desktop marker. */ + __PRM_DESKTOP__?: boolean; + /** Path to desktop-api.log on packaged builds. */ + __PRM_DESKTOP_LOG__?: string; } } From e547532ec0715a577c45cc8bbad9059f93191887 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 26 Jul 2026 00:04:34 +0000 Subject: [PATCH 2/4] Serve desktop UI from API origin to fix Windows Failed to fetch Packaged builds now copy ui-dist into Tauri resources, pass PRM_UI_DIR to the sidecar, and open the webview at http://127.0.0.1:8787/ so /api is same-origin. Also harden Windows spawn logging and exe-relative sidecar discovery. Co-authored-by: Damon van der linde --- .github/workflows/ci.yml | 6 +- .gitignore | 1 + README.md | 2 +- apps/api/src/index.ts | 53 +++++++++++- apps/desktop/README.md | 4 +- apps/desktop/src-tauri/run-hook.mjs | 6 +- apps/desktop/src-tauri/src/main.rs | 110 ++++++++++++++++++------- apps/desktop/src-tauri/tauri.conf.json | 3 +- apps/ui/src/App.tsx | 16 +++- 9 files changed, 166 insertions(+), 35 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 61bcc6f..43ca4ec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,6 +87,7 @@ jobs: run: | set -euo pipefail SIDE="apps/desktop/src-tauri/sidecars" + UI="apps/desktop/src-tauri/ui-dist" if [[ "${{ runner.os }}" == "Windows" ]]; then NODE="$SIDE/node.exe" else @@ -95,10 +96,11 @@ jobs: fi test -f "$NODE" test -f "$SIDE/prm-api.mjs" + test -f "$UI/index.html" DATA="${RUNNER_TEMP:-/tmp}/prm-sidecar-smoke" rm -rf "$DATA" mkdir -p "$DATA" - PRM_DATA_DIR="$DATA" PRM_PORT=8799 PRM_LAUNCH_TOKEN=ci-smoke \ + PRM_DATA_DIR="$DATA" PRM_PORT=8799 PRM_LAUNCH_TOKEN=ci-smoke PRM_UI_DIR="$UI" \ "$NODE" "$SIDE/prm-api.mjs" >"$DATA/out.log" 2>&1 & PID=$! cleanup() { kill "$PID" 2>/dev/null || true; } @@ -108,6 +110,8 @@ jobs: echo "sidecar healthy" curl -sf "http://127.0.0.1:8799/api/workspace/status" -H "x-prm-launch-token: ci-smoke" echo + curl -sf "http://127.0.0.1:8799/" | head -c 200 + echo exit 0 fi sleep 0.2 diff --git a/.gitignore b/.gitignore index 562f402..87fa5be 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ apps/ui/dist apps/api/dist apps/desktop/src-tauri/target apps/desktop/src-tauri/sidecars/ +apps/desktop/src-tauri/ui-dist/ *.tsbuildinfo diff --git a/README.md b/README.md index 1d32376..cfa379a 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ pnpm desktop:build Packaged builds on `main` CI upload installers as Actions artifacts (`desktop-windows-x86_64`, `desktop-macos-aarch64`, `desktop-linux-x86_64`). Details: [`apps/desktop/README.md`](./apps/desktop/README.md). -The desktop shell spawns a bundled Node API sidecar, injects `window.__PRM_API_BASE_URL__` + launch token into the webview, and stores vault data under the OS app-data directory (not a CI build path). If unlock hangs, check `desktop-api.log` next to the vault. +The desktop shell spawns a bundled Node API sidecar, serves the UI from that same origin (avoids WebView `Failed to fetch`), injects a launch token into the webview, and stores vault data under the OS app-data directory (not a CI build path). If unlock hangs, check `desktop-api.log` next to the vault. ### Cursor MCP (bulk load) diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 5498183..62d135b 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,7 +1,8 @@ import { serve } from "@hono/node-server"; import { Hono } from "hono"; import { cors } from "hono/cors"; -import { basename } from "node:path"; +import { access, readFile } from "node:fs/promises"; +import { basename, extname, join, normalize, resolve, sep } from "node:path"; import { and, eq, isNull } from "drizzle-orm"; import { achievements, @@ -3833,6 +3834,56 @@ app.get("/api/exports/promotion/:id", (c) => { }); }); +/** Packaged desktop: serve the Vite UI from PRM_UI_DIR so the webview is same-origin with /api. */ +const uiDir = process.env.PRM_UI_DIR?.trim(); +if (uiDir) { + const root = resolve(uiDir); + const mime: Record = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".svg": "image/svg+xml", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".webp": "image/webp", + ".ico": "image/x-icon", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".ttf": "font/ttf", + ".json": "application/json", + ".map": "application/json", + }; + const inside = (file: string) => { + const f = resolve(file); + return f === root || f.startsWith(root + sep); + }; + app.get("*", async (c) => { + const urlPath = decodeURIComponent(new URL(c.req.url).pathname); + if (urlPath.startsWith("/api")) return c.json({ error: "Not found" }, 404); + const rel = + !urlPath || urlPath === "/" + ? "index.html" + : normalize(urlPath.replace(/^[/\\]+/, "")).replace(/^(\.\.([/\\]|$))+/g, ""); + const candidates = [join(root, rel)]; + if (!extname(rel)) candidates.push(join(root, rel, "index.html")); + candidates.push(join(root, "index.html")); + for (const file of candidates) { + if (!inside(file)) continue; + try { + await access(file); + const body = await readFile(file); + const type = mime[extname(file).toLowerCase()] ?? "application/octet-stream"; + return new Response(body, { headers: { "content-type": type } }); + } catch { + /* try next */ + } + } + return c.text("Not found", 404); + }); + console.log(`PRM UI static root: ${root}`); +} + const port = Number(process.env.PRM_PORT ?? 8787); console.log(`PRM API listening on http://127.0.0.1:${port}`); serve({ fetch: app.fetch, port, hostname: "127.0.0.1" }); diff --git a/apps/desktop/README.md b/apps/desktop/README.md index db8e887..29fcda5 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -38,7 +38,9 @@ pnpm desktop:build 1. Bundles `@prm/api` into `src-tauri/sidecars/prm-api.mjs` (esbuild) and copies a portable **Node** binary + `better-sqlite3` next to it 2. Builds the UI into `apps/ui/dist` (Tauri `frontendDist` is `../../ui/dist` from `src-tauri`) -At runtime the shell resolves `sidecars/` from the app resource dir (or `PRM_API_SIDECAR_DIR`), spawns bundled Node with the API entry, sets `PRM_LAUNCH_TOKEN` / `PRM_DATA_DIR`, waits until `/api/health` responds, and kills the child on exit — one process from the user’s point of view. +At runtime the shell resolves `sidecars/` from the app resource dir (or `PRM_API_SIDECAR_DIR`), spawns bundled Node with the API entry, sets `PRM_LAUNCH_TOKEN` / `PRM_DATA_DIR` / `PRM_UI_DIR`, waits until `/api/health` responds, then opens the webview at the API origin (same-origin `/api` + static UI from `ui-dist/`). On exit it kills the child — one process from the user’s point of view. + +On Windows the Node child is spawned with `CREATE_NO_WINDOW`. Sidecar stdout/stderr append to `%APPDATA%\com.prm.workbench\desktop-api.log`. Packaged data lives in the OS app-data directory (for example `%APPDATA%\com.prm.workbench` on Windows). Sidecar stdout/stderr is appended to `desktop-api.log` there for debugging startup failures. diff --git a/apps/desktop/src-tauri/run-hook.mjs b/apps/desktop/src-tauri/run-hook.mjs index fe60fb8..55ebec8 100644 --- a/apps/desktop/src-tauri/run-hook.mjs +++ b/apps/desktop/src-tauri/run-hook.mjs @@ -5,7 +5,7 @@ * when cwd is apps/desktop (CI / tauri-action). */ import { spawnSync } from "node:child_process"; -import { existsSync } from "node:fs"; +import { cpSync, existsSync, rmSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -13,6 +13,7 @@ const srcTauri = dirname(fileURLToPath(import.meta.url)); const desktopRoot = join(srcTauri, ".."); const repoRoot = join(desktopRoot, "../.."); const uiDist = join(repoRoot, "apps/ui/dist"); +const bundledUi = join(srcTauri, "ui-dist"); const hook = process.argv[2] ?? "build"; function run(command, args, opts = {}) { @@ -46,5 +47,8 @@ if (hook === "dev") { console.error("UI build missing — expected", join(uiDist, "index.html")); process.exit(1); } + rmSync(bundledUi, { recursive: true, force: true }); + cpSync(uiDist, bundledUi, { recursive: true }); console.log("UI dist ready:", uiDist); + console.log("Bundled UI for API same-origin serve:", bundledUi); } diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 2ada98d..18624b1 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -118,6 +118,12 @@ fn resolve_launch_config(data: &Path) -> LaunchConfig { } } +fn exe_dir() -> Option { + std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|d| d.to_path_buf())) +} + fn candidate_sidecar_dirs(resource_dir: Option<&Path>) -> Vec { let mut dirs = Vec::new(); if let Ok(custom) = std::env::var("PRM_API_SIDECAR_DIR") { @@ -127,10 +133,38 @@ fn candidate_sidecar_dirs(resource_dir: Option<&Path>) -> Vec { dirs.push(res.join("sidecars")); dirs.push(res.to_path_buf()); } + if let Some(exe) = exe_dir() { + dirs.push(exe.join("sidecars")); + dirs.push(exe.join("resources").join("sidecars")); + dirs.push(exe.clone()); + dirs.push(exe.join("resources")); + } dirs.push(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("sidecars")); dirs } +fn resolve_ui_dir(resource_dir: Option<&Path>) -> Option { + if let Ok(dir) = std::env::var("PRM_UI_DIR") { + let p = PathBuf::from(dir); + if p.join("index.html").exists() { + return Some(p); + } + } + let mut dirs = Vec::new(); + if let Some(res) = resource_dir { + dirs.push(res.join("ui-dist")); + dirs.push(res.to_path_buf()); + } + if let Some(exe) = exe_dir() { + dirs.push(exe.join("ui-dist")); + dirs.push(exe.join("resources").join("ui-dist")); + } + dirs.push(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("ui-dist")); + dirs + .into_iter() + .find(|d| d.join("index.html").exists()) +} + fn append_log(path: &Path, line: &str) { if let Ok(mut f) = fs::OpenOptions::new().create(true).append(true).open(path) { let _ = writeln!(f, "{line}"); @@ -142,6 +176,7 @@ fn maybe_spawn_api( token: &str, data: &Path, resource_dir: Option<&Path>, + ui_dir: Option<&Path>, log_path: &Path, ) -> Option { let force = std::env::var("PRM_SPAWN_API").ok().as_deref() == Some("1"); @@ -207,13 +242,30 @@ fn maybe_spawn_api( append_log( log_path, &format!( - "PRM: spawning API sidecar program={} cwd={} data={} port={port}", + "PRM: spawning API sidecar program={} cwd={} data={} port={port} ui={}", program.display(), cwd.display(), - data.display() + data.display(), + ui_dir.map(|p| p.display().to_string()).unwrap_or_else(|| "(none)".into()) ), ); + // Append child stdio to the same log (avoids pipe-buffer deadlocks on Windows). + let child_log = match fs::OpenOptions::new().create(true).append(true).open(log_path) { + Ok(f) => f, + Err(err) => { + append_log(log_path, &format!("PRM: could not open log for child stdio: {err}")); + return None; + } + }; + let child_err = match child_log.try_clone() { + Ok(f) => f, + Err(err) => { + append_log(log_path, &format!("PRM: could not clone log for stderr: {err}")); + return None; + } + }; + let mut command = Command::new(&program); command .args(&args) @@ -222,8 +274,11 @@ fn maybe_spawn_api( .env("PRM_DATA_DIR", data) .env("PRM_PORT", port.to_string()) .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); + .stdout(Stdio::from(child_log)) + .stderr(Stdio::from(child_err)); + if let Some(ui) = ui_dir { + command.env("PRM_UI_DIR", ui); + } // GUI subsystem apps on Windows must not attach a console to Node or spawn can hang/fail. #[cfg(windows)] { @@ -232,28 +287,8 @@ fn maybe_spawn_api( match command.spawn() { Ok(mut child) => { - let log = log_path.to_path_buf(); - if let Some(mut out) = child.stdout.take() { - thread::spawn(move || { - let mut buf = String::new(); - let _ = out.read_to_string(&mut buf); - if !buf.trim().is_empty() { - append_log(&log, &format!("PRM API stdout:\n{buf}")); - } - }); - } - let log = log_path.to_path_buf(); - if let Some(mut err) = child.stderr.take() { - thread::spawn(move || { - let mut buf = String::new(); - let _ = err.read_to_string(&mut buf); - if !buf.trim().is_empty() { - append_log(&log, &format!("PRM API stderr:\n{buf}")); - } - }); - } // Detect immediate crash (missing native module, bad path, etc.) - thread::sleep(Duration::from_millis(150)); + thread::sleep(Duration::from_millis(200)); match child.try_wait() { Ok(Some(status)) => { append_log( @@ -342,10 +377,18 @@ fn main() { } let resource_dir = app.path().resource_dir().ok(); + let ui_dir = resolve_ui_dir(resource_dir.as_deref()); + if let Some(ref ui) = ui_dir { + append_log(&log_path, &format!("PRM: UI dir {}", ui.display())); + } else { + append_log(&log_path, "PRM: UI dir not found (will use bundled webview assets)"); + } + let child = maybe_spawn_api( &launch.launch_token, &data, resource_dir.as_deref(), + ui_dir.as_deref(), &log_path, ); if let Ok(mut guard) = app.state::().0.lock() { @@ -364,15 +407,26 @@ fn main() { let inject = format!( "window.__PRM_LAUNCH_TOKEN__={};window.__PRM_API_BASE_URL__={};window.__PRM_DESKTOP_LOG__={};window.__PRM_DESKTOP__=true;", serde_json::to_string(&launch.launch_token).unwrap_or_else(|_| "\"\"".into()), - serde_json::to_string(&launch.api_base_url).unwrap_or_else(|_| { - serde_json::to_string(DEFAULT_API_BASE).unwrap_or_else(|_| "\"\"".into()) - }), + // Same-origin when loading the API-served UI: leave base empty so /api is relative. + // Offline bundled webview still needs an absolute base to reach the sidecar. + serde_json::to_string(if ready && ui_dir.is_some() { + "" + } else { + launch.api_base_url.as_str() + }) + .unwrap_or_else(|_| serde_json::to_string(DEFAULT_API_BASE).unwrap_or_else(|_| "\"\"".into())), serde_json::to_string(&log_path.to_string_lossy()).unwrap_or_else(|_| "\"\"".into()), ); let url = if cfg!(debug_assertions) { WebviewUrl::External(DEV_UI_URL.parse().expect("dev ui url")) + } else if ready && ui_dir.is_some() { + // Same-origin with the API — avoids WebView2 CORS / mixed-content / Failed to fetch. + let page = format!("{}/", launch.api_base_url.trim_end_matches('/')); + append_log(&log_path, &format!("PRM: opening same-origin UI at {page}")); + WebviewUrl::External(page.parse().expect("api ui url")) } else { + append_log(&log_path, "PRM: opening bundled offline UI assets"); WebviewUrl::App("index.html".into()) }; if app.get_webview_window("main").is_none() { diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 1dc6ab9..6060884 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -26,7 +26,8 @@ "icons/icon.ico" ], "resources": [ - "sidecars/**/*" + "sidecars/**/*", + "ui-dist/**/*" ], "longDescription": "Solo engineering manager performance review workbench with encrypted local vault.", "shortDescription": "EM performance review workbench", diff --git a/apps/ui/src/App.tsx b/apps/ui/src/App.tsx index 7c9fe9d..31a97fc 100644 --- a/apps/ui/src/App.tsx +++ b/apps/ui/src/App.tsx @@ -55,6 +55,20 @@ export function App() { const desktop = typeof window !== "undefined" && Boolean(window.__PRM_DESKTOP__ || window.__PRM_API_BASE_URL__); const logPath = typeof window !== "undefined" ? window.__PRM_DESKTOP_LOG__ : undefined; const apiBase = typeof window !== "undefined" ? window.__PRM_API_BASE_URL__ : undefined; + async function retryDesktop() { + // Prefer same-origin UI served by the sidecar once it is healthy. + const base = (apiBase || "http://127.0.0.1:8787").replace(/\/$/, ""); + try { + const res = await fetch(`${base}/api/health`); + if (res.ok) { + window.location.href = `${base}/`; + return; + } + } catch { + /* fall through */ + } + await refresh(); + } return (
@@ -74,7 +88,7 @@ export function App() {

Start the API on port 8787 (`pnpm dev:api`), then refresh.

)}

{error}

- +
{themeToggle}
From dacf3d12cba9af734c4cf1a5b10ecf2860c0e91c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 26 Jul 2026 00:20:42 +0000 Subject: [PATCH 3/4] Add P1 core-loop slice: meetings, faithfulness, calib, search Ship MeetingSummary (consent + em_only), deeper faithfulness apply-all with allowlists and expanded golden evals, due-this-week tray + OS notifications, calibration export/import, framework owner/dates/migrate, finalized review FTS + stagnation hints, and desktop version/update check on top of the same-origin Windows API fix. Co-authored-by: Damon van der linde --- apps/api/src/ai.ts | 3 +- apps/api/src/faithfulness.test.ts | 37 ++ apps/api/src/index.ts | 83 ++- apps/api/src/meetingStagnation.test.ts | 39 ++ apps/api/src/meetingSummary.ts | 80 +++ apps/api/src/p1Routes.ts | 626 ++++++++++++++++++ apps/api/src/reviewSearch.ts | 82 +++ apps/api/src/seed.ts | 3 + apps/api/src/stagnation.ts | 53 ++ apps/api/src/store.ts | 6 + apps/desktop/README.md | 4 + .../src-tauri/capabilities/default.json | 3 +- .../permissions/get-launch-config.toml | 5 + apps/desktop/src-tauri/src/main.rs | 7 +- apps/ui/src/App.tsx | 3 + apps/ui/src/pages/ConsistencyPage.tsx | 64 +- apps/ui/src/pages/HomePage.tsx | 47 +- apps/ui/src/pages/PersonPage.tsx | 86 +++ apps/ui/src/pages/RolesPage.tsx | 109 ++- apps/ui/src/pages/SearchPage.tsx | 121 ++++ apps/ui/src/pages/SettingsPage.tsx | 48 ++ apps/ui/src/pages/WritingDeskPage.tsx | 75 ++- docs/CORE_LOOP_EVAL.md | 19 +- packages/db/src/index.ts | 48 +- packages/db/src/schema.ts | 21 + packages/shared/src/index.ts | 82 +++ 26 files changed, 1710 insertions(+), 44 deletions(-) create mode 100644 apps/api/src/meetingStagnation.test.ts create mode 100644 apps/api/src/meetingSummary.ts create mode 100644 apps/api/src/p1Routes.ts create mode 100644 apps/api/src/reviewSearch.ts create mode 100644 apps/api/src/stagnation.ts create mode 100644 apps/ui/src/pages/SearchPage.tsx diff --git a/apps/api/src/ai.ts b/apps/api/src/ai.ts index df9fd0c..1a7e857 100644 --- a/apps/api/src/ai.ts +++ b/apps/api/src/ai.ts @@ -11,7 +11,8 @@ export type AiFeature = | "chat_dossier" | "bias_lint" | "peer_synthesize" - | "framework_extract"; + | "framework_extract" + | "meeting_summary"; export type ChatMessage = { role: "system" | "user" | "assistant"; content: string }; diff --git a/apps/api/src/faithfulness.test.ts b/apps/api/src/faithfulness.test.ts index f34782d..001ae42 100644 --- a/apps/api/src/faithfulness.test.ts +++ b/apps/api/src/faithfulness.test.ts @@ -37,6 +37,21 @@ const GOLDEN = { allowlist: ["ach_1"], expectInvention: false, }, + thinEvidenceHonesty: { + text: "Evidence is thin this cycle, so this assessment is provisional pending more dossier capture.", + allowlist: [] as string[], + expectInvention: false, + }, + multiInventedCite: { + text: "Claim A [ach_x] and claim B [ach_y] without allowlist support.", + allowlist: ["ach_real"], + expectIds: ["unknown_cite_ach_x", "unknown_cite_ach_y"], + }, + absolutePlusMetric: { + text: "They always delivered 100% of roadmap commits with zero misses across the org.", + allowlist: ["ach_1"], + expectMetricOrAbsolute: true, + }, }; describe("faithfulness lint", () => { @@ -105,6 +120,8 @@ describe("faithfulness golden eval set", () => { GOLDEN.inventedCite, GOLDEN.ungroundedAbsolutes, GOLDEN.bareMetric, + GOLDEN.multiInventedCite, + GOLDEN.absolutePlusMetric, ]; for (const c of cases) { const findings = runFaithfulnessLint(c.text, c.allowlist); @@ -114,4 +131,24 @@ describe("faithfulness golden eval set", () => { ); } }); + + it("does not punish thin-evidence honesty", () => { + const findings = runFaithfulnessLint( + GOLDEN.thinEvidenceHonesty.text, + GOLDEN.thinEvidenceHonesty.allowlist, + ); + assert.equal( + findings.filter((f) => f.severity === "warn" && f.category === "invention").length, + 0, + ); + }); + + it("flags multiple invented cites", () => { + const findings = runFaithfulnessLint( + GOLDEN.multiInventedCite.text, + GOLDEN.multiInventedCite.allowlist, + ); + assert.ok(findings.some((f) => f.id === "unknown_cite_ach_x")); + assert.ok(findings.some((f) => f.id === "unknown_cite_ach_y")); + }); }); diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 62d135b..7b4c234 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -27,6 +27,7 @@ import { roleFrameworks, workspace, concernLogs, + meetingSummaries, integrationSettings, personExternalLinks, } from "@prm/db"; @@ -60,6 +61,7 @@ import { encryptSecret, encryptionStatus, getDb, + getSqlite, getSessionToken, getWorkspaceSecret, id, @@ -92,6 +94,8 @@ import { import { extractCitationIds, filterCitationsAgainstAllowlist } from "./citations.js"; import { runBiasToneLint } from "./bias.js"; import { mergeLintFindings, runFaithfulnessLint } from "./faithfulness.js"; +import { registerP1Routes } from "./p1Routes.js"; +import { indexReviewFts } from "./reviewSearch.js"; import { renderPromotionPacketHtml, renderSubjectPacketHtml } from "./packetHtml.js"; import { localPeerSynthesize } from "./peerSynth.js"; import { bitbucketSearchMembers, bitbucketTestConnection } from "./bitbucket.js"; @@ -547,6 +551,25 @@ function ensurePublishedVersion() { return db.select().from(roleFrameworkVersions).where(eq(roleFrameworkVersions.id, fwvId)).all()[0]!; } +function frameworkInsufficient(versionId: string): string[] { + const db = getDb(); + const warnings: string[] = []; + const roles = db.select().from(roleDefinitions).where(eq(roleDefinitions.frameworkVersionId, versionId)).all(); + if (roles.length === 0) warnings.push("No role definitions in the published version."); + const keys = new Set(roles.map((r) => r.key)); + for (const role of roles) { + const exps = db.select().from(roleExpectations).where(eq(roleExpectations.roleDefinitionId, role.id)).all(); + if (exps.length === 0) warnings.push(`Role “${role.title}” has no expectations.`); + for (const next of JSON.parse(role.nextRoleKeysJson) as string[]) { + if (!keys.has(next)) warnings.push(`Role “${role.title}” points to missing next key “${next}”.`); + } + } + const directs = db.select().from(people).all().filter((p) => p.managerId && p.status === "active"); + const unassigned = directs.filter((p) => !assignmentFor(p.id)); + if (unassigned.length) warnings.push(`${unassigned.length} active direct(s) have no role assignment.`); + return warnings; +} + function frameworkInfo(): RoleFrameworkInfo | null { const db = getDb(); const version = getPublishedVersion(); @@ -564,6 +587,10 @@ function frameworkInfo(): RoleFrameworkInfo | null { versionLabel: version.versionLabel, sourceFilename: version.sourceFilename, roleCount, + ownerName: version.ownerName ?? null, + effectiveFrom: version.effectiveFrom ?? null, + effectiveTo: version.effectiveTo ?? null, + insufficient: frameworkInsufficient(version.id), }; } @@ -1751,6 +1778,19 @@ app.put("/api/reviews/manager", async (c) => { evidenceLinks: links.length, timelineEntries: frozenTimeline.length, }); + + try { + const person = db.select().from(people).where(eq(people.id, body.subjectPersonId)).all()[0]; + const cycle = db.select().from(cycles).where(eq(cycles.id, body.cycleId)).all()[0]; + indexReviewFts(getSqlite(), { + reviewId, + personName: person?.name ?? "", + cycleName: cycle?.name ?? "", + body: narrative, + }); + } catch { + /* FTS optional */ + } } if (ratingChanges.length && reviewId) { @@ -3260,8 +3300,8 @@ app.post("/api/ai/bias-lint", async (c) => { }>(); let text = body.text?.trim() ?? ""; let allowlist: string[] = []; - if (!text && body.cycleId && body.personId) { - const db = getDb(); + const db = getDb(); + if (body.cycleId && body.personId) { const manager = db .select() .from(reviews) @@ -3274,28 +3314,32 @@ app.post("/api/ai/bias-lint", async (c) => { ) .all() .find((r) => r.status !== "superseded"); - if (manager) { + if (!text && manager) { const p = JSON.parse(manager.payloadJson) as Record; text = [p.summary, p.strengths, p.growth_areas, p.dev_plan, p.role_fit].map((x) => String(x ?? "")).join("\n\n"); + } + if (manager) { allowlist = db .select() .from(evidenceLinks) .where(eq(evidenceLinks.reviewId, manager.id)) .all() .map((l) => l.sourceId); - allowlist.push( - ...db.select().from(achievements).where(eq(achievements.personId, body.personId)).all().map((a) => a.id), - ...db.select().from(goals).where(eq(goals.personId, body.personId)).all().map((g) => g.id), - ...db.select().from(feedbackItems).where(eq(feedbackItems.toPersonId, body.personId)).all().map((f) => f.id), - ...db.select().from(documents).where(eq(documents.personId, body.personId)).all().map((d) => d.id), - ); } + allowlist.push( + ...db.select().from(achievements).where(eq(achievements.personId, body.personId)).all().map((a) => a.id), + ...db.select().from(goals).where(eq(goals.personId, body.personId)).all().map((g) => g.id), + ...db.select().from(feedbackItems).where(eq(feedbackItems.toPersonId, body.personId)).all().map((f) => f.id), + ...db.select().from(documents).where(eq(documents.personId, body.personId)).all().map((d) => d.id), + ...db.select().from(meetingSummaries).where(eq(meetingSummaries.personId, body.personId)).all().map((m) => m.id), + ); + allowlist = [...new Set(allowlist)]; } const findings = mergeLintFindings( runBiasToneLint(text), runFaithfulnessLint(text, allowlist), ); - return c.json({ findings, ephemeral: true }); + return c.json({ findings, ephemeral: true, allowlistSize: allowlist.length }); }); app.get("/api/sprint-pulse", (c) => { @@ -3834,6 +3878,25 @@ app.get("/api/exports/promotion/:id", (c) => { }); }); +registerP1Routes(app, { + loadRole, + assignmentFor, + getPublishedVersion: () => { + const v = getPublishedVersion(); + if (!v) return null; + return { + id: v.id, + frameworkId: v.frameworkId, + versionLabel: v.versionLabel, + sourceFilename: v.sourceFilename, + ownerName: v.ownerName ?? null, + effectiveFrom: v.effectiveFrom ?? null, + effectiveTo: v.effectiveTo ?? null, + }; + }, + frameworkInsufficient, +}); + /** Packaged desktop: serve the Vite UI from PRM_UI_DIR so the webview is same-origin with /api. */ const uiDir = process.env.PRM_UI_DIR?.trim(); if (uiDir) { diff --git a/apps/api/src/meetingStagnation.test.ts b/apps/api/src/meetingStagnation.test.ts new file mode 100644 index 0000000..ecb2f73 --- /dev/null +++ b/apps/api/src/meetingStagnation.test.ts @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { summarizeTranscriptLocally } from "./meetingSummary.js"; +import { computeStagnationHints } from "./stagnation.js"; + +describe("meeting summary local extract", () => { + it("pulls bullets and themes from a transcript paste", () => { + const raw = ` +1:1 with Alex — private coaching notes (consent noted). +- Shipping checkout migration this sprint +- Coaching two engineers on design docs +- Concern about on-call load +Alex said reliability work is going well. We agreed to revisit promotion readiness next quarter. +`; + const { summaryText, themes } = summarizeTranscriptLocally(raw); + assert.match(summaryText, /checkout|coaching|on-call/i); + assert.ok(themes.length >= 1); + }); +}); + +describe("stagnation hints", () => { + it("flags flat overall across cycles", () => { + const hints = computeStagnationHints([ + { personId: "p1", personName: "Ada", overall: 3, evidenceLinkCount: 5, cycleEnd: "2024-01" }, + { personId: "p1", personName: "Ada", overall: 3, evidenceLinkCount: 4, cycleEnd: "2024-06" }, + { personId: "p1", personName: "Ada", overall: 3, evidenceLinkCount: 6, cycleEnd: "2025-01" }, + ]); + assert.ok(hints.some((h) => h.kind === "flat_overall")); + }); + + it("flags thin evidence streak", () => { + const hints = computeStagnationHints([ + { personId: "p2", personName: "Bo", overall: 4, evidenceLinkCount: 0, cycleEnd: "2024-01" }, + { personId: "p2", personName: "Bo", overall: 5, evidenceLinkCount: 1, cycleEnd: "2024-06" }, + { personId: "p2", personName: "Bo", overall: 4, evidenceLinkCount: 0, cycleEnd: "2025-01" }, + ]); + assert.ok(hints.some((h) => h.kind === "thin_evidence")); + }); +}); diff --git a/apps/api/src/meetingSummary.ts b/apps/api/src/meetingSummary.ts new file mode 100644 index 0000000..4e7c894 --- /dev/null +++ b/apps/api/src/meetingSummary.ts @@ -0,0 +1,80 @@ +import type { MeetingSummaryDTO, Visibility } from "@prm/shared"; + +/** Heuristic meeting summary when AI is off — keeps consent/privacy path usable offline. */ +export function summarizeTranscriptLocally(raw: string, maxChars = 1200): { + summaryText: string; + themes: string[]; +} { + const text = raw.replace(/\r\n/g, "\n").trim(); + if (!text) return { summaryText: "", themes: [] }; + + const lines = text + .split("\n") + .map((l) => l.trim()) + .filter(Boolean); + const bullets = lines.filter((l) => /^[-*•]|\d+\./.test(l)).slice(0, 8); + const sentences = text + .split(/(?<=[.!?])\s+/) + .map((s) => s.trim()) + .filter((s) => s.length > 40) + .slice(0, 6); + + const parts = [ + ...(bullets.length ? ["Key lines:", ...bullets] : []), + ...(sentences.length ? ["Highlights:", ...sentences] : [text.slice(0, maxChars)]), + ]; + let summaryText = parts.join("\n"); + if (summaryText.length > maxChars) summaryText = `${summaryText.slice(0, maxChars - 1)}…`; + + const themeBag = new Map(); + for (const token of text.toLowerCase().match(/\b[a-z]{4,}\b/g) ?? []) { + if ( + /^(this|that|with|from|have|been|were|they|their|about|would|could|should|there|which|while|meeting|notes)$/.test( + token, + ) + ) { + continue; + } + themeBag.set(token, (themeBag.get(token) ?? 0) + 1); + } + const themes = [...themeBag.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 5) + .map(([t]) => t); + + return { summaryText, themes }; +} + +export function toMeetingSummaryDto(row: { + id: string; + personId: string; + sourceDocumentId: string | null; + occurredAt: string; + title: string; + summaryText: string; + themesJson: string; + visibility: string; + consentNoted: number; + createdAt: string; + updatedAt: string; +}): MeetingSummaryDTO { + let themes: string[] = []; + try { + themes = JSON.parse(row.themesJson) as string[]; + } catch { + themes = []; + } + return { + id: row.id, + personId: row.personId, + sourceDocumentId: row.sourceDocumentId, + occurredAt: row.occurredAt, + title: row.title, + summaryText: row.summaryText, + themes: Array.isArray(themes) ? themes : [], + visibility: (row.visibility as Visibility) || "em_only", + consentNoted: Boolean(row.consentNoted), + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} diff --git a/apps/api/src/p1Routes.ts b/apps/api/src/p1Routes.ts new file mode 100644 index 0000000..03cd14a --- /dev/null +++ b/apps/api/src/p1Routes.ts @@ -0,0 +1,626 @@ +import type { Hono } from "hono"; +import { and, eq } from "drizzle-orm"; +import { + achievements, + cycleParticipants, + cycles, + documents, + evidenceLinks, + feedbackItems, + goals, + meetingSummaries, + people, + reviews, + roleAssignments, + roleDefinitions, + roleExpectations, + roleFrameworkVersions, + roleFrameworks, +} from "@prm/db"; +import type { + BiasFinding, + CalibBundleDTO, + CalibDecisionInput, + DueThisWeekItem, + ReviewSearchHit, + StagnationHint, + Visibility, +} from "@prm/shared"; +import { summarizeTranscriptLocally, toMeetingSummaryDto } from "./meetingSummary.js"; +import { computeStagnationHints } from "./stagnation.js"; +import { rebuildReviewsFts, searchReviewsFts } from "./reviewSearch.js"; +import { applyFaithfulnessFix, mergeLintFindings, runFaithfulnessLint } from "./faithfulness.js"; +import { runBiasToneLint } from "./bias.js"; +import { getAiConfig, runChat, storeGeneration } from "./ai.js"; +import { getDb, getSqlite, id, logActivity, nowIso, writeEncryptedDocument } from "./store.js"; + +type RoleLite = { + id: string; + title: string; + key: string; + expectations: Array<{ id: string; kind: string; text: string }>; + nextRoleKeys: string[]; +} | null; + +type Helpers = { + loadRole: (id: string | null | undefined) => RoleLite; + assignmentFor: (personId: string) => { roleDefinitionId: string; frameworkVersionId: string } | null; + getPublishedVersion: () => { + id: string; + frameworkId: string; + versionLabel: string; + sourceFilename: string | null; + ownerName: string | null; + effectiveFrom: string | null; + effectiveTo: string | null; + } | null; + frameworkInsufficient: (versionId: string) => string[]; +}; + +function phaseStatus(json: string): Record { + try { + return JSON.parse(json) as Record; + } catch { + return {}; + } +} + +function evidenceAllowlist(personId: string, cycleId?: string): string[] { + const db = getDb(); + const ids = [ + ...db.select().from(achievements).where(eq(achievements.personId, personId)).all().map((a) => a.id), + ...db.select().from(goals).where(eq(goals.personId, personId)).all().map((g) => g.id), + ...db.select().from(feedbackItems).where(eq(feedbackItems.toPersonId, personId)).all().map((f) => f.id), + ...db.select().from(documents).where(eq(documents.personId, personId)).all().map((d) => d.id), + ...db.select().from(meetingSummaries).where(eq(meetingSummaries.personId, personId)).all().map((m) => m.id), + ]; + if (cycleId) { + const manager = db + .select() + .from(reviews) + .where( + and(eq(reviews.cycleId, cycleId), eq(reviews.subjectPersonId, personId), eq(reviews.type, "manager")), + ) + .all() + .find((r) => r.status !== "superseded"); + if (manager) { + ids.push( + ...db.select().from(evidenceLinks).where(eq(evidenceLinks.reviewId, manager.id)).all().map((l) => l.sourceId), + ); + } + } + return [...new Set(ids)]; +} + +export function registerP1Routes(app: Hono, helpers: Helpers) { + const { loadRole, assignmentFor, getPublishedVersion, frameworkInsufficient } = helpers; + + app.get("/api/people/:id/meeting-summaries", (c) => { + const personId = c.req.param("id"); + const rows = getDb() + .select() + .from(meetingSummaries) + .where(eq(meetingSummaries.personId, personId)) + .all() + .sort((a, b) => b.occurredAt.localeCompare(a.occurredAt)); + return c.json(rows.map(toMeetingSummaryDto)); + }); + + app.post("/api/people/:id/meeting-summaries", async (c) => { + const personId = c.req.param("id"); + const db = getDb(); + const person = db.select().from(people).where(eq(people.id, personId)).all()[0]; + if (!person) return c.json({ error: "Person not found" }, 404); + + const body = await c.req.json<{ + title?: string; + occurredAt?: string; + transcript?: string; + summaryText?: string; + themes?: string[]; + visibility?: Visibility; + consentNoted?: boolean; + useAi?: boolean; + sourceDocumentId?: string; + }>(); + + if (!body.consentNoted) { + return c.json( + { + error: + "Consent required: confirm the participant knows notes may be stored in your local EM vault (private by default; not a legal privilege).", + code: "meeting_consent_required", + }, + 400, + ); + } + + const transcript = (body.transcript ?? "").trim(); + let sourceDocumentId = body.sourceDocumentId ?? null; + const title = (body.title ?? "1:1 / meeting summary").trim() || "1:1 / meeting summary"; + const occurredAt = body.occurredAt ?? nowIso().slice(0, 10); + let summaryText = (body.summaryText ?? "").trim(); + let themes = body.themes ?? []; + + if (!summaryText) { + if (!transcript) return c.json({ error: "Provide transcript text or summaryText" }, 400); + if (body.useAi) { + const cfg = getAiConfig(); + if (!cfg.enabled) return c.json({ error: "AI is disabled — paste a summary or turn AI on" }, 400); + if (!cfg.localOnly && !cfg.meetingSummaryEgress) { + return c.json( + { + error: + "Meeting summary cloud egress is off. Enable it in Settings → AI, use local-only, or save a manual summary.", + }, + 400, + ); + } + try { + const result = await runChat("meeting_summary", [ + { + role: "system", + content: + "You summarize 1:1 / meeting transcripts for a solo engineering manager. Return concise bullet highlights, decisions, and follow-ups. Do not invent facts.", + }, + { + role: "user", + content: `Person: ${person.name}\nTitle: ${title}\nTranscript:\n${transcript.slice(0, 80_000)}`, + }, + ]); + summaryText = result.text.trim(); + themes = summarizeTranscriptLocally(transcript).themes; + storeGeneration({ + feature: "meeting_summary", + subjectPersonId: personId, + outputText: summaryText, + model: result.model, + citations: [], + dataClassesSent: ["meeting_summary"], + }); + } catch (e) { + return c.json({ error: e instanceof Error ? e.message : "AI summary failed" }, 500); + } + } else { + const local = summarizeTranscriptLocally(transcript); + summaryText = local.summaryText; + themes = local.themes; + } + } + + if (transcript && !sourceDocumentId) { + const filename = `meeting-${occurredAt}.txt`; + const saved = writeEncryptedDocument(filename, Buffer.from(transcript, "utf8")); + const docId = id("doc"); + db.insert(documents) + .values({ + id: docId, + personId, + title: `${title} (raw transcript)`, + filename, + mimeType: "text/plain", + storagePath: saved.storagePath, + visibility: "em_only", + occurredAt, + scanStatus: "allowlist_ok", + createdAt: nowIso(), + }) + .run(); + sourceDocumentId = docId; + } + + const ts = nowIso(); + const mid = id("meet"); + db.insert(meetingSummaries) + .values({ + id: mid, + personId, + sourceDocumentId, + occurredAt, + title, + summaryText, + themesJson: JSON.stringify(themes), + visibility: body.visibility ?? "em_only", + consentNoted: 1, + createdAt: ts, + updatedAt: ts, + }) + .run(); + logActivity("meeting_summary.create", "meeting_summary", mid, { + personId, + visibility: body.visibility ?? "em_only", + }); + return c.json( + toMeetingSummaryDto(db.select().from(meetingSummaries).where(eq(meetingSummaries.id, mid)).all()[0]!), + ); + }); + + app.get("/api/due-this-week", (c) => { + const db = getDb(); + const withinDays = Math.min(30, Math.max(1, Number(c.req.query("days") ?? 7))); + const today = nowIso().slice(0, 10); + const items: DueThisWeekItem[] = []; + for (const cycle of db.select().from(cycles).all()) { + if (cycle.status === "closed") continue; + const daysUntilDue = Math.ceil( + (Date.parse(cycle.windowEnd) - Date.parse(today)) / (24 * 60 * 60 * 1000), + ); + if (Number.isNaN(daysUntilDue) || daysUntilDue < 0 || daysUntilDue > withinDays) continue; + for (const cp of db.select().from(cycleParticipants).where(eq(cycleParticipants.cycleId, cycle.id)).all()) { + const person = db.select().from(people).where(eq(people.id, cp.personId)).all()[0]; + if (!person) continue; + const phase = phaseStatus(cp.phaseStatusJson); + const manager = db + .select() + .from(reviews) + .where( + and(eq(reviews.cycleId, cycle.id), eq(reviews.subjectPersonId, cp.personId), eq(reviews.type, "manager")), + ) + .all() + .find((r) => r.status !== "superseded"); + const selfStatus = phase.self ?? "pending"; + const peerStatus = phase.peer ?? "pending"; + items.push({ + personId: person.id, + personName: person.name, + cycleId: cycle.id, + cycleName: cycle.name, + windowEnd: cycle.windowEnd, + daysUntilDue, + missingSelf: selfStatus !== "returned" && selfStatus !== "waived", + missingPeer: peerStatus !== "returned" && peerStatus !== "waived", + managerStatus: manager?.status ?? "missing", + }); + } + } + items.sort((a, b) => a.daysUntilDue - b.daysUntilDue || a.personName.localeCompare(b.personName)); + return c.json({ withinDays, items }); + }); + + app.post("/api/calib/export", async (c) => { + const body = await c.req.json<{ cycleId: string; personIds?: string[] }>(); + const db = getDb(); + const cycle = db.select().from(cycles).where(eq(cycles.id, body.cycleId)).all()[0]; + if (!cycle) return c.json({ error: "Cycle not found" }, 404); + const want = body.personIds?.length ? new Set(body.personIds) : null; + const cards: CalibBundleDTO["cards"] = []; + for (const cp of db.select().from(cycleParticipants).where(eq(cycleParticipants.cycleId, cycle.id)).all()) { + if (want && !want.has(cp.personId)) continue; + const person = db.select().from(people).where(eq(people.id, cp.personId)).all()[0]; + if (!person) continue; + const a = assignmentFor(cp.personId); + const role = loadRole(cp.roleDefinitionId ?? a?.roleDefinitionId); + const manager = db + .select() + .from(reviews) + .where( + and(eq(reviews.cycleId, cycle.id), eq(reviews.subjectPersonId, cp.personId), eq(reviews.type, "manager")), + ) + .all() + .find((r) => r.status !== "superseded"); + const payload = manager ? (JSON.parse(manager.payloadJson) as Record) : {}; + const overall = typeof payload.overall === "number" ? payload.overall : null; + const comps = (payload.competencies ?? {}) as Record; + const links = manager + ? db.select().from(evidenceLinks).where(eq(evidenceLinks.reviewId, manager.id)).all() + : []; + cards.push({ + personId: person.id, + personName: person.name, + title: person.title, + roleTitle: role?.title ?? null, + overall, + competencies: comps, + evidenceLinkCount: links.length, + evidenceGaps: 0, + managerStatus: manager?.status ?? "missing", + flags: [], + summaryExcerpt: String(payload.summary ?? "").slice(0, 280), + }); + } + const bundle: CalibBundleDTO = { + version: 1, + kind: "calib", + cycleId: cycle.id, + cycleName: cycle.name, + exportedAt: nowIso(), + cards, + }; + logActivity("calib.export", "cycle", cycle.id, { cardCount: cards.length }); + return c.json(bundle); + }); + + app.post("/api/calib/import", async (c) => { + const body = await c.req.json<{ bundle: CalibBundleDTO; decisions: CalibDecisionInput[] }>(); + if (!body.bundle || body.bundle.kind !== "calib") return c.json({ error: "Invalid calib bundle" }, 400); + const db = getDb(); + const cycle = db.select().from(cycles).where(eq(cycles.id, body.bundle.cycleId)).all()[0]; + if (!cycle) return c.json({ error: "Cycle not found for this calib bundle" }, 404); + + let applied = 0; + for (const decision of body.decisions ?? []) { + const manager = db + .select() + .from(reviews) + .where( + and( + eq(reviews.cycleId, cycle.id), + eq(reviews.subjectPersonId, decision.personId), + eq(reviews.type, "manager"), + ), + ) + .all() + .find((r) => r.status !== "superseded"); + if (!manager || manager.status === "finalized" || manager.status === "shared") continue; + const payload = JSON.parse(manager.payloadJson) as Record; + if (decision.overall != null) payload.overall = decision.overall; + if (decision.competencies) { + payload.competencies = { ...(payload.competencies as object), ...decision.competencies }; + } + if (decision.note?.trim()) { + payload.private_notes = `${String(payload.private_notes ?? "")}\n[Calib] ${decision.note.trim()}`.trim(); + } + db.update(reviews) + .set({ payloadJson: JSON.stringify(payload), updatedAt: nowIso() }) + .where(eq(reviews.id, manager.id)) + .run(); + logActivity("calib.decision", "review", manager.id, { + personId: decision.personId, + overall: decision.overall ?? null, + }); + applied += 1; + } + return c.json({ applied, cycleId: cycle.id }); + }); + + app.patch("/api/roles/framework", async (c) => { + const body = await c.req.json<{ + ownerName?: string | null; + effectiveFrom?: string | null; + effectiveTo?: string | null; + versionLabel?: string; + frameworkName?: string; + }>(); + const version = getPublishedVersion(); + if (!version) return c.json({ error: "No published framework" }, 404); + const db = getDb(); + db.update(roleFrameworkVersions) + .set({ + ownerName: body.ownerName === undefined ? version.ownerName : body.ownerName, + effectiveFrom: body.effectiveFrom === undefined ? version.effectiveFrom : body.effectiveFrom, + effectiveTo: body.effectiveTo === undefined ? version.effectiveTo : body.effectiveTo, + versionLabel: body.versionLabel?.trim() || version.versionLabel, + }) + .where(eq(roleFrameworkVersions.id, version.id)) + .run(); + if (body.frameworkName?.trim()) { + db.update(roleFrameworks) + .set({ name: body.frameworkName.trim() }) + .where(eq(roleFrameworks.id, version.frameworkId)) + .run(); + } + logActivity("framework.update_meta", "framework_version", version.id, {}); + return c.json({ ok: true, insufficient: frameworkInsufficient(version.id) }); + }); + + app.post("/api/roles/framework/migrate", async (c) => { + const body = await c.req.json<{ + versionLabel: string; + keyMap?: Record; + ownerName?: string; + effectiveFrom?: string; + effectiveTo?: string; + }>(); + if (!body.versionLabel?.trim()) return c.json({ error: "versionLabel required" }, 400); + const from = getPublishedVersion(); + if (!from) return c.json({ error: "No published framework to migrate from" }, 404); + const db = getDb(); + const keyMap = body.keyMap ?? {}; + const ts = nowIso(); + const newVersionId = id("fwv"); + + db.update(roleFrameworkVersions) + .set({ status: "superseded" }) + .where(eq(roleFrameworkVersions.id, from.id)) + .run(); + + db.insert(roleFrameworkVersions) + .values({ + id: newVersionId, + frameworkId: from.frameworkId, + versionLabel: body.versionLabel.trim(), + status: "published", + publishedAt: ts, + sourceFilename: from.sourceFilename, + ownerName: body.ownerName ?? from.ownerName, + effectiveFrom: body.effectiveFrom ?? ts.slice(0, 10), + effectiveTo: body.effectiveTo ?? null, + }) + .run(); + + const oldRoles = db + .select() + .from(roleDefinitions) + .where(eq(roleDefinitions.frameworkVersionId, from.id)) + .all(); + const idMap = new Map(); + for (const role of oldRoles) { + const newRoleId = id("role"); + idMap.set(role.id, newRoleId); + const nextKeys = (JSON.parse(role.nextRoleKeysJson) as string[]).map((k) => keyMap[k] ?? k); + db.insert(roleDefinitions) + .values({ + id: newRoleId, + frameworkVersionId: newVersionId, + key: keyMap[role.key] ?? role.key, + title: role.title, + track: role.track, + levelKey: role.levelKey, + summary: role.summary, + nextRoleKeysJson: JSON.stringify(nextKeys), + competencyKeysJson: role.competencyKeysJson, + }) + .run(); + for (const exp of db + .select() + .from(roleExpectations) + .where(eq(roleExpectations.roleDefinitionId, role.id)) + .all()) { + db.insert(roleExpectations) + .values({ + id: id("rexp"), + roleDefinitionId: newRoleId, + kind: exp.kind, + text: exp.text, + sortOrder: exp.sortOrder, + }) + .run(); + } + } + + let remappedAssignments = 0; + for (const a of db.select().from(roleAssignments).where(eq(roleAssignments.frameworkVersionId, from.id)).all()) { + const newRoleId = idMap.get(a.roleDefinitionId); + if (!newRoleId) continue; + db.update(roleAssignments) + .set({ + roleDefinitionId: newRoleId, + frameworkVersionId: newVersionId, + targetNextRoleDefinitionId: a.targetNextRoleDefinitionId + ? idMap.get(a.targetNextRoleDefinitionId) ?? null + : null, + }) + .where(eq(roleAssignments.id, a.id)) + .run(); + remappedAssignments += 1; + } + + logActivity("framework.migrate", "framework_version", newVersionId, { + fromVersionId: from.id, + remappedAssignments, + }); + return c.json({ + versionId: newVersionId, + remappedAssignments, + insufficient: frameworkInsufficient(newVersionId), + }); + }); + + app.get("/api/search/reviews", (c) => { + const q = c.req.query("q")?.trim() ?? ""; + if (!q) return c.json({ hits: [] as ReviewSearchHit[] }); + const db = getDb(); + const hits: ReviewSearchHit[] = []; + for (const hit of searchReviewsFts(getSqlite(), q)) { + const review = db.select().from(reviews).where(eq(reviews.id, hit.reviewId)).all()[0]; + if (!review || (review.status !== "finalized" && review.status !== "shared")) continue; + const person = db.select().from(people).where(eq(people.id, review.subjectPersonId)).all()[0]; + const cycle = db.select().from(cycles).where(eq(cycles.id, review.cycleId)).all()[0]; + const payload = JSON.parse(review.payloadJson) as Record; + hits.push({ + reviewId: review.id, + personId: review.subjectPersonId, + personName: person?.name ?? hit.personName, + cycleId: review.cycleId, + cycleName: cycle?.name ?? hit.cycleName, + status: review.status, + overall: typeof payload.overall === "number" ? payload.overall : null, + snippet: hit.snippet, + updatedAt: review.updatedAt, + }); + } + return c.json({ hits }); + }); + + app.post("/api/search/reviews/reindex", (c) => { + const db = getDb(); + const rows: Array<{ reviewId: string; personName: string; cycleName: string; body: string }> = []; + for (const review of db.select().from(reviews).all()) { + if (review.status !== "finalized" && review.status !== "shared") continue; + const person = db.select().from(people).where(eq(people.id, review.subjectPersonId)).all()[0]; + const cycle = db.select().from(cycles).where(eq(cycles.id, review.cycleId)).all()[0]; + const payload = JSON.parse(review.payloadJson) as Record; + rows.push({ + reviewId: review.id, + personName: person?.name ?? "", + cycleName: cycle?.name ?? "", + body: [payload.summary, payload.strengths, payload.growth_areas, payload.dev_plan, payload.role_fit] + .map((x) => String(x ?? "")) + .join("\n"), + }); + } + rebuildReviewsFts(getSqlite(), rows); + return c.json({ indexed: rows.length }); + }); + + app.get("/api/stagnation", (c) => { + const db = getDb(); + const rows: Array<{ + personId: string; + personName: string; + overall: number | null; + evidenceLinkCount: number; + cycleEnd: string; + }> = []; + for (const review of db.select().from(reviews).all()) { + if (review.type !== "manager") continue; + if (review.status !== "finalized" && review.status !== "shared") continue; + const person = db.select().from(people).where(eq(people.id, review.subjectPersonId)).all()[0]; + const cycle = db.select().from(cycles).where(eq(cycles.id, review.cycleId)).all()[0]; + if (!person || !cycle) continue; + const payload = JSON.parse(review.payloadJson) as Record; + rows.push({ + personId: person.id, + personName: person.name, + overall: typeof payload.overall === "number" ? payload.overall : null, + evidenceLinkCount: db.select().from(evidenceLinks).where(eq(evidenceLinks.reviewId, review.id)).all() + .length, + cycleEnd: cycle.windowEnd, + }); + } + const hints: StagnationHint[] = computeStagnationHints(rows); + return c.json({ hints }); + }); + + app.post("/api/ai/apply-faithfulness-fix", async (c) => { + const body = await c.req.json<{ text: string; finding: Pick }>(); + const next = applyFaithfulnessFix(body.text, body.finding); + if (next == null) return c.json({ error: "Excerpt not found in text" }, 400); + return c.json({ text: next }); + }); + + app.post("/api/ai/apply-all-faithfulness-fixes", async (c) => { + const body = await c.req.json<{ + fields: Record; + cycleId?: string; + personId?: string; + }>(); + const fields = { ...body.fields }; + const allowlist = + body.personId != null ? evidenceAllowlist(body.personId, body.cycleId) : []; + const findings = mergeLintFindings( + runBiasToneLint(Object.values(fields).join("\n\n")), + runFaithfulnessLint(Object.values(fields).join("\n\n"), allowlist), + ); + let applied = 0; + for (const finding of findings) { + if (!finding.suggestedFix || !finding.excerpt) continue; + for (const [key, value] of Object.entries(fields)) { + const next = applyFaithfulnessFix(value, finding); + if (next != null) { + fields[key] = next; + applied += 1; + break; + } + } + } + return c.json({ + fields, + applied, + findings: mergeLintFindings( + runBiasToneLint(Object.values(fields).join("\n\n")), + runFaithfulnessLint(Object.values(fields).join("\n\n"), allowlist), + ), + }); + }); +} diff --git a/apps/api/src/reviewSearch.ts b/apps/api/src/reviewSearch.ts new file mode 100644 index 0000000..207f0f4 --- /dev/null +++ b/apps/api/src/reviewSearch.ts @@ -0,0 +1,82 @@ +import type Database from "better-sqlite3"; + +/** Upsert finalized/shared manager review text into FTS for workspace search. */ +export function indexReviewFts( + sqlite: Database.Database, + row: { + reviewId: string; + personName: string; + cycleName: string; + body: string; + }, +) { + sqlite.prepare("DELETE FROM reviews_fts WHERE review_id = ?").run(row.reviewId); + sqlite + .prepare( + "INSERT INTO reviews_fts (review_id, person_name, cycle_name, body) VALUES (?, ?, ?, ?)", + ) + .run(row.reviewId, row.personName, row.cycleName, row.body); +} + +export function searchReviewsFts( + sqlite: Database.Database, + query: string, + limit = 40, +): Array<{ reviewId: string; personName: string; cycleName: string; snippet: string }> { + const q = query.trim(); + if (!q) return []; + // Escape FTS5 special chars lightly; treat as phrase + prefix tokens + const safe = q.replace(/["']/g, " ").replace(/\s+/g, " ").trim(); + if (!safe) return []; + const match = safe + .split(" ") + .filter(Boolean) + .map((t) => `"${t}"*`) + .join(" AND "); + try { + return sqlite + .prepare( + `SELECT review_id as reviewId, person_name as personName, cycle_name as cycleName, + snippet(reviews_fts, 3, '', '', '…', 12) as snippet + FROM reviews_fts + WHERE reviews_fts MATCH ? + LIMIT ?`, + ) + .all(match, limit) as Array<{ + reviewId: string; + personName: string; + cycleName: string; + snippet: string; + }>; + } catch { + // Fallback: LIKE over body if MATCH parse fails + return sqlite + .prepare( + `SELECT review_id as reviewId, person_name as personName, cycle_name as cycleName, + substr(body, 1, 160) as snippet + FROM reviews_fts + WHERE body LIKE ? OR person_name LIKE ? OR cycle_name LIKE ? + LIMIT ?`, + ) + .all(`%${safe}%`, `%${safe}%`, `%${safe}%`, limit) as Array<{ + reviewId: string; + personName: string; + cycleName: string; + snippet: string; + }>; + } +} + +export function rebuildReviewsFts( + sqlite: Database.Database, + rows: Array<{ reviewId: string; personName: string; cycleName: string; body: string }>, +) { + sqlite.exec("DELETE FROM reviews_fts"); + const ins = sqlite.prepare( + "INSERT INTO reviews_fts (review_id, person_name, cycle_name, body) VALUES (?, ?, ?, ?)", + ); + const tx = sqlite.transaction((items: typeof rows) => { + for (const r of items) ins.run(r.reviewId, r.personName, r.cycleName, r.body); + }); + tx(rows); +} diff --git a/apps/api/src/seed.ts b/apps/api/src/seed.ts index dba19b3..4ca56ce 100644 --- a/apps/api/src/seed.ts +++ b/apps/api/src/seed.ts @@ -153,6 +153,9 @@ export async function seedWorkspace(opts: { status: "published", publishedAt: createdAt, sourceFilename: "seeded", + ownerName: "Seed EM", + effectiveFrom: createdAt.slice(0, 10), + effectiveTo: null, }).run(); const roleIds: Record = {}; diff --git a/apps/api/src/stagnation.ts b/apps/api/src/stagnation.ts new file mode 100644 index 0000000..44e4468 --- /dev/null +++ b/apps/api/src/stagnation.ts @@ -0,0 +1,53 @@ +import type { StagnationHint } from "@prm/shared"; + +/** Flag directs whose overall stayed flat (±0) across ≥minCycles finalized manager reviews, or thin evidence repeatedly. */ +export function computeStagnationHints( + rows: Array<{ + personId: string; + personName: string; + overall: number | null; + evidenceLinkCount: number; + cycleEnd: string; + }>, + opts?: { minCycles?: number; thinEvidenceMax?: number }, +): StagnationHint[] { + const minCycles = opts?.minCycles ?? 3; + const thinMax = opts?.thinEvidenceMax ?? 1; + const byPerson = new Map(); + for (const row of rows) { + const list = byPerson.get(row.personId) ?? []; + list.push(row); + byPerson.set(row.personId, list); + } + + const hints: StagnationHint[] = []; + for (const [personId, list] of byPerson) { + const ordered = [...list].sort((a, b) => a.cycleEnd.localeCompare(b.cycleEnd)); + const overalls = ordered.map((r) => r.overall).filter((n): n is number => typeof n === "number"); + if (overalls.length >= minCycles) { + const recent = overalls.slice(-minCycles); + if (recent.every((n) => n === recent[0])) { + hints.push({ + personId, + personName: ordered[0]!.personName, + kind: "flat_overall", + detail: `Overall ${recent[0]} across last ${minCycles} cycles — check for stagnation or under-calibration.`, + overalls: recent, + cycleCount: overalls.length, + }); + } + } + const thinStreak = ordered.slice(-minCycles).filter((r) => r.evidenceLinkCount <= thinMax); + if (ordered.length >= minCycles && thinStreak.length >= minCycles) { + hints.push({ + personId, + personName: ordered[0]!.personName, + kind: "thin_evidence", + detail: `≤${thinMax} evidence link(s) in each of the last ${minCycles} cycles.`, + overalls: overalls.slice(-minCycles), + cycleCount: ordered.length, + }); + } + } + return hints; +} diff --git a/apps/api/src/store.ts b/apps/api/src/store.ts index 4ca8615..505cc5c 100644 --- a/apps/api/src/store.ts +++ b/apps/api/src/store.ts @@ -166,6 +166,12 @@ export function getDb(): Db { return dbHandle; } +export function getSqlite() { + getDb(); + if (!sqliteHandle) throw new Error("Workspace locked or not initialized"); + return sqliteHandle; +} + export function isInitialized(): boolean { return Boolean(readVault()) || existsSync(DB_PATH) || existsSync(DB_ENC_PATH); } diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 29fcda5..05e6295 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -42,6 +42,10 @@ At runtime the shell resolves `sidecars/` from the app resource dir (or `PRM_API On Windows the Node child is spawned with `CREATE_NO_WINDOW`. Sidecar stdout/stderr append to `%APPDATA%\com.prm.workbench\desktop-api.log`. +### Updates + +Settings → **Check for updates** compares the packaged version (`get_app_version` IPC) to the latest GitHub Release. Fully signed silent auto-update still needs `tauri-plugin-updater` + CI signing keys. + Packaged data lives in the OS app-data directory (for example `%APPDATA%\com.prm.workbench` on Windows). Sidecar stdout/stderr is appended to `desktop-api.log` there for debugging startup failures. CI builds the same matrix (Ubuntu / macOS / Windows) on pushes to `main`. Each green desktop job uploads an Actions artifact (retained 30 days): diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json index b9aad0f..7ce11a9 100644 --- a/apps/desktop/src-tauri/capabilities/default.json +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -5,6 +5,7 @@ "windows": ["main"], "permissions": [ "core:default", - "allow-get-launch-config" + "allow-get-launch-config", + "allow-get-app-version" ] } diff --git a/apps/desktop/src-tauri/permissions/get-launch-config.toml b/apps/desktop/src-tauri/permissions/get-launch-config.toml index d3327fb..36cc8cc 100644 --- a/apps/desktop/src-tauri/permissions/get-launch-config.toml +++ b/apps/desktop/src-tauri/permissions/get-launch-config.toml @@ -2,3 +2,8 @@ identifier = "allow-get-launch-config" description = "Allows the frontend to read API base URL and desktop launch token" commands.allow = ["get_launch_config"] + +[[permission]] +identifier = "allow-get-app-version" +description = "Allows the frontend to read the packaged app version" +commands.allow = ["get_app_version"] diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 18624b1..45a886a 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -43,6 +43,11 @@ fn get_launch_config(state: State<'_, LaunchState>) -> Result String { + env!("CARGO_PKG_VERSION").into() +} + fn api_port() -> u16 { std::env::var("PRM_PORT") .ok() @@ -363,7 +368,7 @@ fn main() { launch_token: String::new(), }))) .manage(ApiChild(Mutex::new(None))) - .invoke_handler(tauri::generate_handler![get_launch_config]) + .invoke_handler(tauri::generate_handler![get_launch_config, get_app_version]) .setup(move |app| { let data = resolve_data_dir(app.handle()); let _ = fs::create_dir_all(&data); diff --git a/apps/ui/src/App.tsx b/apps/ui/src/App.tsx index 31a97fc..e84d86a 100644 --- a/apps/ui/src/App.tsx +++ b/apps/ui/src/App.tsx @@ -17,6 +17,7 @@ import { ChatPage } from "./pages/ChatPage"; import { ConsistencyPage } from "./pages/ConsistencyPage"; import { TeamPerformancePage } from "./pages/TeamPerformancePage"; import { SprintPulsePage } from "./pages/SprintPulsePage"; +import { SearchPage } from "./pages/SearchPage"; import { BackfillPage } from "./pages/BackfillPage"; export function App() { @@ -123,6 +124,7 @@ export function App() { Cycles Promotions Consistency + Search Team performance Sprint pulse Backfill @@ -154,6 +156,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/ui/src/pages/ConsistencyPage.tsx b/apps/ui/src/pages/ConsistencyPage.tsx index e37cfb2..61c27ad 100644 --- a/apps/ui/src/pages/ConsistencyPage.tsx +++ b/apps/ui/src/pages/ConsistencyPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import { Link, useSearchParams } from "react-router-dom"; -import type { ConsistencyDTO, CycleDTO } from "@prm/shared"; +import type { CalibBundleDTO, ConsistencyDTO, CycleDTO } from "@prm/shared"; import { competencyLabel } from "@prm/shared"; import { api } from "../lib/api"; import { NextStep, PageHeader } from "../components/PageChrome"; @@ -10,6 +10,8 @@ export function ConsistencyPage() { const [cycles, setCycles] = useState([]); const [data, setData] = useState(null); const [error, setError] = useState(null); + const [calibMsg, setCalibMsg] = useState(null); + const [importJson, setImportJson] = useState(""); const cycleId = params.get("cycleId") ?? ""; async function load(cid?: string) { @@ -86,6 +88,66 @@ export function ConsistencyPage() {
+ {data && ( +
+

Calibration bundles

+

+ Export candidate cards for a director, then import decisions back into draft manager reviews (same cycle id). +

+
+ +
+
+ +