From 77a9eea56d11888916d9a0dc9eb345fa0072c417 Mon Sep 17 00:00:00 2001 From: Cengiz Demirel Date: Fri, 21 Aug 2026 15:41:06 +0200 Subject: [PATCH 01/10] =?UTF-8?q?feat(jarvis-bridge):=20Iris=20Bridge=20v0?= =?UTF-8?q?.2/v0.3=20=E2=80=94=20Ask=20Jarvis=20relay=20+=20Personal=20OS?= =?UTF-8?q?=20reads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preserves prior uncommitted work: voice-turn routing to Jarvis's real askJarvis pipeline (routingPolicy-gated), Work Stream/PersonalFocus panels backed by getTasks/getTopFocus/getCurrentContext, and voice-state publish — all via an in-process require() of Jarvis-Desktop's adapter/iris-bridge.cjs, never a second retrieval/model/memory pipeline. --- electron/jarvisBridgeClient.mjs | 114 ++++++++++++ electron/main.mjs | 98 ++++++++++- electron/preload.cjs | 5 + electron/routingPolicy.mjs | 4 + package.json | 1 + scripts/test-jarvis-bridge-smoke.mjs | 78 ++++++++ src/App.tsx | 76 +++++++- src/components/CommsPanel.tsx | 8 +- src/components/PersonalFocusPanel.tsx | 77 ++++++++ src/components/WorkStream.tsx | 61 +++++++ src/styles/deck.css | 126 +++++++++++++ src/vite-env.d.ts | 98 +++++++++++ test/jarvisBridgeClient.test.mjs | 244 ++++++++++++++++++++++++++ test/routingPolicy.test.mjs | 14 +- test/turnOwnership.smoke.mjs | 60 +++++++ 15 files changed, 1054 insertions(+), 10 deletions(-) create mode 100644 electron/jarvisBridgeClient.mjs create mode 100644 scripts/test-jarvis-bridge-smoke.mjs create mode 100644 src/components/PersonalFocusPanel.tsx create mode 100644 test/jarvisBridgeClient.test.mjs create mode 100644 test/turnOwnership.smoke.mjs diff --git a/electron/jarvisBridgeClient.mjs b/electron/jarvisBridgeClient.mjs new file mode 100644 index 0000000..90e983e --- /dev/null +++ b/electron/jarvisBridgeClient.mjs @@ -0,0 +1,114 @@ +/* + * Jarvis Bridge client — the Iris-side half of the thin adapter between + * Iris's voice pipeline and Jarvis's existing Ask Jarvis pipeline. + * + * Jarvis and Iris are two separate local Electron apps/processes. There is + * no cross-process transport built for this round, so this module reaches + * Jarvis's already-shipped, already-tested askJarvis() the most direct way + * available: a plain Node require() of Jarvis's own adapter module + * (Jarvis-Desktop/app/adapter/iris-bridge.cjs), loaded in-process from + * Iris's Electron main process. This executes Jarvis's real code, unmodified + * and unduplicated — never a second retrieval/model/memory pipeline. + */ +import { createRequire } from "node:module"; +import path from "node:path"; +import fs from "node:fs"; +import { decideTurnOwner } from "./routingPolicy.mjs"; + +const defaultRequire = createRequire(import.meta.url); + +export function shouldAskJarvis(route) { + return decideTurnOwner(route) === "jarvis"; +} + +export function defaultJarvisBridgePath(repoRoot) { + if (process.env.JARVIS_BRIDGE_PATH) return process.env.JARVIS_BRIDGE_PATH; + return path.resolve(repoRoot, "..", "Jarvis-Desktop", "app", "adapter", "iris-bridge.cjs"); +} + +// Never throws: an unreachable/missing Jarvis checkout must degrade to "no +// bridge", not crash Iris's voice pipeline. +export function loadJarvisBridge( + repoRoot, + { requireFn = defaultRequire, existsFn = fs.existsSync, onUnavailable = () => {} } = {} +) { + const bridgePath = defaultJarvisBridgePath(repoRoot); + if (!existsFn(bridgePath)) { + onUnavailable("missing"); + return null; + } + try { + const { createIrisBridge } = requireFn(bridgePath); + return createIrisBridge(); + } catch { + onUnavailable("require-error"); + return null; + } +} + +// Never throws, never returns a blank/empty answer as success — a Comms +// bubble with no text would be a silent, confusing failure. Also the +// request/response IPC contract boundary: intentionally reconstructs +// {ok, answer|error} rather than spreading the raw askJarvis() result, so +// none of Jarvis's internal fields (sources, timings, mail/drive status, +// model name, ...) ever cross into the renderer. +export async function askJarvisForTurn(bridge, text) { + if (!bridge) return { ok: false, error: "Jarvis Bridge nicht verfügbar." }; + try { + const result = await bridge.askJarvis(text); + if (result?.ok && typeof result.answer === "string" && result.answer) { + return { ok: true, answer: result.answer }; + } + return { ok: false, error: result?.error || "Jarvis-Anfrage ohne Antwort." }; + } catch (error) { + return { ok: false, error: String(error?.message ?? error) }; + } +} + +// getTasksForRenderer/getTopFocusForRenderer/getCurrentContextForRenderer — +// the same never-throws, forward-the-bridge's-{ok,...}-result-as-is contract +// as askJarvisForTurn above, for the three read-only Personal OS bridge +// methods (getTasks/getTopFocus/getCurrentContext). No re-derivation, no +// invented fallback data: an unavailable bridge or reader surfaces as +// {ok:false, error}, never a silently empty/demo Work Stream or focus panel. +export async function getTasksForRenderer(bridge) { + if (!bridge) return { ok: false, error: "Jarvis Bridge nicht verfügbar." }; + try { + return bridge.getTasks(); + } catch (error) { + return { ok: false, error: String(error?.message ?? error) }; + } +} + +export async function getTopFocusForRenderer(bridge) { + if (!bridge) return { ok: false, error: "Jarvis Bridge nicht verfügbar." }; + try { + return await bridge.getTopFocus(); + } catch (error) { + return { ok: false, error: String(error?.message ?? error) }; + } +} + +export async function getCurrentContextForRenderer(bridge) { + if (!bridge) return { ok: false, error: "Jarvis Bridge nicht verfügbar." }; + try { + return await bridge.getCurrentContext(); + } catch (error) { + return { ok: false, error: String(error?.message ?? error) }; + } +} + +// Pure: builds the [you, jarvis|jarvis-error] pair the renderer appends to +// the existing Comms transcript for a request/response askJarvis call. +// "jarvis-error" is a distinct speaker value (never silently reused as +// "jarvis") so a failure is never visually indistinguishable from a real +// answer — see requirement #9, "Fehler müssen sichtbar und eindeutig +// propagiert werden". +export function describeSmokeTranscript(question, result) { + return [ + { speaker: "you", text: question }, + result.ok + ? { speaker: "jarvis", text: result.answer } + : { speaker: "jarvis-error", text: `Jarvis-Anfrage fehlgeschlagen: ${result.error}` }, + ]; +} diff --git a/electron/main.mjs b/electron/main.mjs index 2176562..72103f4 100644 --- a/electron/main.mjs +++ b/electron/main.mjs @@ -1,5 +1,14 @@ import electron from "electron"; import { GoogleGenAI } from "@google/genai"; +import { + shouldAskJarvis, + loadJarvisBridge, + askJarvisForTurn, + describeSmokeTranscript, + getTasksForRenderer, + getTopFocusForRenderer, + getCurrentContextForRenderer, +} from "./jarvisBridgeClient.mjs"; import { proposeHermesTask as gatePropose, claimConfirmedProposal, @@ -42,7 +51,7 @@ import { formatHermesCompletionEvent, normalizeHermesEvent, } from "./hermesEvents.mjs"; -import { classifyRoute, routingGuidance } from "./routingPolicy.mjs"; +import { classifyRoute, routingGuidance, decideTurnOwner } from "./routingPolicy.mjs"; import { APPROVAL_CHOICES, approvalAuthorized, @@ -110,6 +119,7 @@ let modelTranscriptTimer = null; let modelTranscriptSettled = false; const MIN_AUDIBLE_READBACK_CHARS = 48; let lastUserRoute = "direct"; +let lastTurnOwner = "gemini"; const hermesRuns = new Map(); const runRegistry = new RunRegistry(); const pendingHermesApprovals = new Map(); @@ -302,16 +312,46 @@ function isInternalSystemTranscript(text) { return /^\s*SYSTEM_EVENT_[A-Z_]+/i.test(String(text || "")); } +// Iris Bridge v0.2 — the point where a completed user turn's final +// transcript text exists. Gated on the existing "memory" route +// (routingPolicy.mjs — no new classification), so only Personal-OS-shaped +// questions reach Jarvis; every other turn is untouched. Jarvis's answer is +// pushed into the same Comms transcript stream as a distinctly-labeled +// "jarvis" line — never through TTS/live:audio — so it can never audibly +// compete with Gemini's own spoken turn for the same utterance. Gemini's own +// conversational reply (voice + its own memory tools) is NOT suppressed; +// see the integration report for why that overlap is a documented, not yet +// resolved, risk rather than something silently fixed here. +let cachedJarvisBridge; +function jarvisBridge() { + if (cachedJarvisBridge === undefined) { + cachedJarvisBridge = loadJarvisBridge(repoRoot, { + onUnavailable: (reason) => + emitEvent({ + type: "log", + level: "warn", + message: `Jarvis Bridge unavailable (${reason}); Ask Jarvis relay disabled this session.`, + }), + }); + } + return cachedJarvisBridge; +} + +async function relayTurnToJarvis(text) { + const result = await askJarvisForTurn(jarvisBridge(), text); + const [, jarvisLine] = describeSmokeTranscript(text, result); + emitEvent({ type: "transcript", ...jarvisLine }); +} + function flushUserTranscript() { if (userTranscriptTimer) { clearTimeout(userTranscriptTimer); userTranscriptTimer = null; } - if ( - userTranscriptBuffer.trim() && - !isInternalSystemTranscript(userTranscriptBuffer) - ) { - emitEvent({ type: "transcript", speaker: "you", text: userTranscriptBuffer.trim() }); + const text = userTranscriptBuffer.trim(); + if (text && !isInternalSystemTranscript(text)) { + emitEvent({ type: "transcript", speaker: "you", text }); + if (shouldAskJarvis(lastUserRoute)) void relayTurnToJarvis(text); } userTranscriptBuffer = ""; } @@ -2084,8 +2124,26 @@ async function executeTool(name, args = {}) { case "read_hermes_task_result": return readHermesTaskResult(args); case "search_brain": + if (lastTurnOwner === "jarvis") { + return { + ok: true, + results: [], + deferredToJarvis: true, + instructions: + "This question is being answered by Jarvis directly this turn. Do not answer from memory. Acknowledge briefly (e.g. 'Jarvis übernimmt das') and stop.", + }; + } return searchBrain(args.query, args.top_k); case "search_memory": + if (lastTurnOwner === "jarvis") { + return { + ok: true, + results: [], + deferredToJarvis: true, + instructions: + "This question is being answered by Jarvis directly this turn. Do not answer from memory. Acknowledge briefly (e.g. 'Jarvis übernimmt das') and stop.", + }; + } return searchMemory(args.query, args.top_k); case "read_memory_note": return readMemoryNote(args.path); @@ -2446,6 +2504,11 @@ if (process.env.IRIS_TEST_HOOKS === "1") { resumeHandles.corruptForTest(); }, pendingAnnouncements: () => announcementLedger.pendingCount, + simulateMemoryToolCall: (route, name, args) => { + lastUserRoute = route; + lastTurnOwner = decideTurnOwner(route); + return executeTool(name, args); + }, }; } @@ -3132,6 +3195,7 @@ function handleLiveMessage(message) { scheduleUserTranscriptFlush(); if (userTranscriptBuffer.trim()) { lastUserRoute = classifyRoute(userTranscriptBuffer); + lastTurnOwner = decideTurnOwner(lastUserRoute); markUserSpoke(userTranscriptBuffer, { allowDuringReadback: modelTranscriptBuffer.trim().length >= MIN_AUDIBLE_READBACK_CHARS, @@ -3911,6 +3975,20 @@ app.whenReady().then(() => { return { ok: false, error: error?.message || String(error) }; } }); + // Iris Bridge v0.3 — real request/response contract for the renderer, + // completing the reverse direction: Renderer -> preload -> main -> Jarvis + // Bridge -> askJarvis -> result -> main -> preload -> Renderer. Reuses the + // same askJarvisForTurn/jarvisBridge as the voice-triggered path above + // (relayTurnToJarvis); no second bridge, no second contract. + trustedHandle("jarvisBridge:askJarvis", (_event, text) => askJarvisForTurn(jarvisBridge(), String(text || ""))); + // Work Stream (right) + compact focus panel (left) — real Personal OS data + // via the same in-process jarvisBridge() instance, never a second + // retrieval path. Each handler forwards the bridge's own {ok, data|error} + // result unchanged (see jarvisBridgeClient.mjs); an unavailable bridge or + // reader surfaces as ok:false, never invented/demo data. + trustedHandle("jarvisBridge:getTasks", () => getTasksForRenderer(jarvisBridge())); + trustedHandle("jarvisBridge:getTopFocus", () => getTopFocusForRenderer(jarvisBridge())); + trustedHandle("jarvisBridge:getCurrentContext", () => getCurrentContextForRenderer(jarvisBridge())); trustedHandle("app:open-external", (_event, url) => { const target = safeExternalUrl(url); if (target) return shell.openExternal(target); @@ -3935,6 +4013,14 @@ app.whenReady().then(() => { : 0; if (byteLength > 0 && byteLength <= 256 * 1024) sendAudioChunk(chunk); }); + // Iris Bridge v0.2 — Iris reports its own (already-computed, renderer-side) + // canonical voice.state into the same in-process Jarvis bridge instance + // used for askJarvis above. publishVoiceState() itself validates against + // the canonical VOICE_STATES set; an invalid/unknown value is dropped, not + // forwarded — never a crash, never a silent new state invented here. + trustedOn("jarvisBridge:voiceState", (_event, state) => { + jarvisBridge()?.publishVoiceState(state); + }); trustedOn("iris:boot-done", () => sendWelcomeGreeting()); trustedOn("iris:ui-context", (_event, context) => { if (context && typeof context === "object") { diff --git a/electron/preload.cjs b/electron/preload.cjs index f1695c2..705d61e 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -54,6 +54,11 @@ contextBridge.exposeInMainWorld("iris", { sendUiContext: (context) => ipcRenderer.send("iris:ui-context", context), sendAudioChunk: (chunk) => ipcRenderer.send("live:audio", chunk), notifyBootDone: () => ipcRenderer.send("iris:boot-done"), + reportVoiceState: (state) => ipcRenderer.send("jarvisBridge:voiceState", state), + askJarvis: (text) => ipcRenderer.invoke("jarvisBridge:askJarvis", text), + getJarvisTasks: () => ipcRenderer.invoke("jarvisBridge:getTasks"), + getJarvisTopFocus: () => ipcRenderer.invoke("jarvisBridge:getTopFocus"), + getJarvisCurrentContext: () => ipcRenderer.invoke("jarvisBridge:getCurrentContext"), onUiAction: (callback) => { const handler = (_event, payload) => callback(payload); ipcRenderer.on("iris:ui-action", handler); diff --git a/electron/routingPolicy.mjs b/electron/routingPolicy.mjs index 5fd76c3..9d8ce17 100644 --- a/electron/routingPolicy.mjs +++ b/electron/routingPolicy.mjs @@ -18,6 +18,10 @@ export function classifyRoute(text) { return "direct"; } +export function decideTurnOwner(route) { + return route === "memory" ? "jarvis" : "gemini"; +} + export function routingGuidance(route) { return { direct: "Answer briefly without tools unless factual uncertainty requires search.", diff --git a/package.json b/package.json index 8a69839..7c7360f 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "test:live-sleep": "npm run build && node scripts/test-live-sleep-resume.mjs", "test:live-speech": "npm run build && node scripts/test-live-speech-standby.mjs", "test:hermes-wake": "npm run build && node scripts/test-live-hermes-wake.mjs", + "test:jarvis-bridge-smoke": "npm run build && node scripts/test-jarvis-bridge-smoke.mjs", "soak": "node scripts/soak-runtime.mjs", "preview": "vite preview", "start": "npm run build && node scripts/run-electron.mjs --prod", diff --git a/scripts/test-jarvis-bridge-smoke.mjs b/scripts/test-jarvis-bridge-smoke.mjs new file mode 100644 index 0000000..5f13e53 --- /dev/null +++ b/scripts/test-jarvis-bridge-smoke.mjs @@ -0,0 +1,78 @@ +// Gap 3 ROT: no Playwright/_electron E2E smoke exists yet that proves a real +// Iris Electron app can launch, show the Main Deck, trigger a real +// ask-Jarvis round trip (via the existing dev "j" hotkey / runJarvisSmoke() +// in src/App.tsx), and render the answer in CommsPanel. +// +// Run: npm run test:jarvis-bridge-smoke (or: node scripts/test-jarvis-bridge-smoke.mjs +// after `npm run build`) +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { _electron as electron } from "playwright-core"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const failureDir = path.join(root, "test-results"); +const failureScreenshot = path.join(failureDir, "jarvis-bridge-smoke-failure.png"); + +const env = { + ...process.env, + IRIS_START_PROD: "1", + IRIS_LOAD_TEST_DATA: "1", + IRIS_WAKE_WORD: "false", + IRIS_HERMES_AUTOSTART: "false", + IRIS_TEST_HOOKS: "1", +}; +delete env.ELECTRON_RUN_AS_NODE; + +const app = await electron.launch({ + args: [ + path.join(root, "electron", "main.mjs"), + `--user-data-dir=/tmp/iris-jarvis-bridge-smoke-test-${process.pid}`, + ], + cwd: root, + env, +}); + +try { + const page = await app.firstWindow(); + await page.waitForSelector(".deck", { timeout: 20000 }); + + await page.keyboard.press("j"); + + await page.waitForSelector(".comms-scroll .bubble.jarvis, .comms-scroll .bubble.jarvis-error", { + timeout: 20000, + }); + + const result = await page.evaluate(() => { + const errorBubble = document.querySelector(".comms-scroll .bubble.jarvis-error"); + const okBubble = document.querySelector(".comms-scroll .bubble.jarvis"); + return { + hasError: Boolean(errorBubble), + errorText: errorBubble?.textContent ?? null, + hasOk: Boolean(okBubble), + okText: okBubble?.textContent ?? null, + }; + }); + + if (result.hasError) { + throw new Error(`Jarvis bridge round trip failed with a jarvis-error bubble: ${result.errorText}`); + } + if (!result.hasOk || !result.okText || !result.okText.trim()) { + throw new Error( + `Expected a non-empty .bubble.jarvis in .comms-scroll after pressing "j", got: ${JSON.stringify(result)}`, + ); + } + + console.log(`PASS: real Jarvis bridge round trip rendered in CommsPanel: ${JSON.stringify(result)}`); +} catch (error) { + try { + fs.mkdirSync(failureDir, { recursive: true }); + const page = await app.firstWindow(); + await page.screenshot({ path: failureScreenshot }); + } catch { + // best-effort screenshot only + } + throw error; +} finally { + await app.close(); +} diff --git a/src/App.tsx b/src/App.tsx index 4b59464..5049b01 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,6 +17,7 @@ import { useHandControl, type HandState } from "./hooks/useHandControl"; import { useWakeWord } from "./hooks/useWakeWord"; import TopBar from "./components/TopBar"; import CommsPanel from "./components/CommsPanel"; +import PersonalFocusPanel from "./components/PersonalFocusPanel"; import CameraDock from "./components/CameraDock"; import CenterStage from "./components/CenterStage"; import { ORB_ACCENT } from "./components/ReactorCore"; @@ -34,6 +35,19 @@ import ApprovalPrompt from "./components/ApprovalPrompt"; import HermesInteractionPrompt from "./components/HermesInteractionPrompt"; const MAX_LOGS = 80; + +// Harmless, read-only smoke question — triggers no Action/Write/Approval. +const JARVIS_SMOKE_QUESTION = "Wie heißt du?"; + +// Canonical Jarvis voice.state vocabulary Iris's own ReactorState maps onto +// when reporting outward (see the "Iris Bridge v0.2" block in App()). +const CANONICAL_VOICE_STATE: Record = { + idle: "idle", + online: "idle", + listening: "listening", + working: "thinking", + speaking: "speaking", +}; const MAX_TASKS_TOTAL = 100; // Point-and-hold duration before the finger pointer "clicks" what it's over. const DWELL_MS = 300; @@ -41,6 +55,12 @@ const DWELL_MS = 300; export default function App() { const [sidecarRunning, setSidecarRunning] = useState(false); const [sidecarPid, setSidecarPid] = useState(null); + // Work Stream (right) + compact focus panel (left) — real Jarvis Bridge + // Personal OS data. null = not yet loaded; {ok:false} = bridge/reader + // unavailable; never demo data or an invented fallback. + const [jarvisTasks, setJarvisTasks] = useState(null); + const [jarvisTopFocus, setJarvisTopFocus] = useState(null); + const [jarvisContext, setJarvisContext] = useState(null); const [geminiStatus, setGeminiStatus] = useState("offline"); const [hermesStatus, setHermesStatus] = useState("offline"); const [audioState, setAudioState] = useState("idle"); @@ -251,6 +271,13 @@ export default function App() { return window.iris.onSidecarEvent((event) => handleSidecarEvent(event)); }, [hasBridge]); + useEffect(() => { + if (!hasBridge) return; + window.iris.getJarvisTasks().then(setJarvisTasks); + window.iris.getJarvisTopFocus().then(setJarvisTopFocus); + window.iris.getJarvisCurrentContext().then(setJarvisContext); + }, [hasBridge]); + useEffect(() => { if (!hasBridge) return; window.iris.getAppConfig().then((config) => { @@ -477,7 +504,14 @@ export default function App() { ); } }, - (message) => pushLog("error", `Wake word: ${message}`), + (message) => { + pushLog("error", `Wake word: ${message}`); + // Iris has no visible error/attention UI today (pushLog's own `logs` + // state is never rendered — see integration report). Per instruction + // #6, the error must not silently vanish from the canonical model even + // though the UI itself stays unchanged. + if (hasBridge) window.iris.reportVoiceState("error"); + }, wakeThreshold, fullConfig?.micDevice || "", ); @@ -533,6 +567,9 @@ export default function App() { } else if (key === "g" && testDataEnabled) { event.preventDefault(); simulateHandoff(); + } else if (key === "j" && testDataEnabled) { + event.preventDefault(); + void runJarvisSmoke(); } } window.addEventListener("keydown", onKey); @@ -606,6 +643,41 @@ export default function App() { return "idle"; }, [audioState, geminiStatus, sidecarRunning, webSearching, hermesSummarizing, working]); + // Iris Bridge v0.2 — Iris is voice-first and owns the real voice/turn + // pipeline; Jarvis runs in a separate Electron app/process, so there is no + // window.jarvisBridge in this renderer (that was round 1's incorrect + // assumption — corrected here rather than left as dead code). Iris instead + // REPORTS its own already-computed ReactorState outward, canonicalized to + // Jarvis's voice.state vocabulary, via window.iris.reportVoiceState -> + // main process -> electron/jarvisBridgeClient.mjs -> the in-process + // required Jarvis bridge module. The reverse direction (Jarvis's own + // voice.state driving Iris's UI) is not wired this round. + useEffect(() => { + if (!hasBridge) return; + window.iris.reportVoiceState(CANONICAL_VOICE_STATE[reactorState]); + }, [hasBridge, reactorState]); + + // Iris Bridge v0.3 — real request/response smoke path: renderer -> preload + // -> main -> Jarvis Bridge -> askJarvis -> result -> back here, appended to + // the existing Comms transcript. No mic/Gemini dependency; triggered via + // the existing dev testDataEnabled shortcut (see keydown handler below), + // not a new UI element. Mirrors jarvisBridgeClient.mjs's + // describeSmokeTranscript pairing (tested) so both paths render identically. + async function runJarvisSmoke() { + if (!hasBridge) return; + const result = await window.iris.askJarvis(JARVIS_SMOKE_QUESTION); + const jarvisLine = result.ok + ? { speaker: "jarvis", text: result.answer ?? "" } + : { speaker: "jarvis-error", text: `Jarvis-Anfrage fehlgeschlagen: ${result.error}` }; + setTranscript((current) => + [ + ...current, + { id: crypto.randomUUID(), speaker: "you", text: JARVIS_SMOKE_QUESTION }, + { id: crypto.randomUUID(), ...jarvisLine }, + ].slice(-40), + ); + } + function handleSidecarEvent(event: SidecarEvent) { if (event.type === "sidecar_status") { // Main flags resumed sessions (context intact) so the boot ceremony @@ -1519,6 +1591,7 @@ export default function App() {
{/* LEFT — You */}
+ { const self = /you|user/i.test(line.speaker); + const jarvisError = line.speaker === "jarvis-error"; + const fromJarvis = !jarvisError && /jarvis/i.test(line.speaker); + const who = self ? "You" : jarvisError ? "Jarvis ⚠" : fromJarvis ? "Jarvis" : "Iris"; + const kind = self ? "self" : jarvisError ? "jarvis-error" : fromJarvis ? "jarvis" : "iris"; return ( -
- {self ? "You" : "Iris"} +
+ {who} {line.text}
); diff --git a/src/components/PersonalFocusPanel.tsx b/src/components/PersonalFocusPanel.tsx new file mode 100644 index 0000000..91ca34b --- /dev/null +++ b/src/components/PersonalFocusPanel.tsx @@ -0,0 +1,77 @@ +import { Target } from "lucide-react"; + +// Compact left-column readout of real Jarvis Bridge Personal OS data: Top +// Focus, Current Context, Next. No demo data, no invented fallback — a +// null result (not yet loaded) or {ok:false} (bridge/reader unavailable) +// both render as an honest, distinct state, never a fabricated placeholder. +export default function PersonalFocusPanel({ + topFocus, + context, +}: { + topFocus: JarvisTopFocusResult | null; + context: JarvisCurrentContextResult | null; +}) { + const focusItem = topFocus?.ok ? topFocus.data?.top?.[0] ?? null : null; + const focusError = topFocus && !topFocus.ok ? topFocus.error : null; + + const ctx = context?.ok ? context.data ?? null : null; + const contextError = context && !context.ok ? context.error : null; + const recommended = ctx?.recommended ?? null; + + return ( + + ); +} diff --git a/src/components/WorkStream.tsx b/src/components/WorkStream.tsx index f9a278c..9c95c42 100644 --- a/src/components/WorkStream.tsx +++ b/src/components/WorkStream.tsx @@ -5,7 +5,66 @@ import { acceptedKey } from "../lib/tasks"; import WorkCard from "./WorkCard"; import SessionSwitcher from "./SessionSwitcher"; +const POS_GROUPS: Array<{ key: "now" | "next" | "waiting" | "overdue"; label: string }> = [ + { key: "now", label: "NOW" }, + { key: "next", label: "NEXT" }, + { key: "waiting", label: "WAITING" }, + { key: "overdue", label: "OVERDUE" }, +]; + +// Real Personal OS NOW/NEXT/WAITING/OVERDUE tasks, from the same +// window.iris.getJarvisTasks() bridge call — never demo data. `result` is +// null before the first load, {ok:false} when the bridge/reader is +// unavailable, or {ok:true, data} with the four real groups (possibly all +// empty, which is itself a real, honest state). +function PersonalOsTasks({ result }: { result: JarvisTasksResult | null }) { + if (!result) return null; + if (!result.ok) { + return ( +
+ Personal OS +

{result.error || "Personal OS Daten nicht verfügbar."}

+
+ ); + } + const data = result.data ?? { now: [], next: [], waiting: [], overdue: [] }; + const total = data.now.length + data.next.length + data.waiting.length + data.overdue.length; + if (total === 0) { + return ( +
+ Personal OS +

Keine offenen Aufgaben.

+
+ ); + } + return ( +
+ Personal OS + {POS_GROUPS.map(({ key, label }) => { + const items = data[key]; + if (!items.length) return null; + return ( +
+ + {label} {items.length} + + {items.map((item) => ( +
+ {item.title} + + {key === "waiting" ? (item as JarvisWaitingItem).waitingFor : (item as JarvisTaskItem).due} + +
+ ))} +
+ ); + })} +
+ ); +} + export default function WorkStream({ + personalTasks, tasks, sortedTasks, scrollRef, @@ -22,6 +81,7 @@ export default function WorkStream({ onOpenTask, onApproveTask, }: { + personalTasks: JarvisTasksResult | null; tasks: TaskCard[]; sortedTasks: TaskCard[]; scrollRef: RefObject; @@ -67,6 +127,7 @@ export default function WorkStream({ /> ) : null}
+ {tasks.length === 0 ? (
diff --git a/src/styles/deck.css b/src/styles/deck.css index 74b966f..7177558 100644 --- a/src/styles/deck.css +++ b/src/styles/deck.css @@ -249,6 +249,54 @@ min-height: 0; } +/* Compact Personal OS focus readout, above Comms in the left column. */ +.focus-panel { + flex: 0 0 auto; +} + +.focus-body { + display: flex; + flex-direction: column; + gap: var(--s-3); + padding: 0 var(--s-4) var(--s-4); +} + +.focus-section { + display: flex; + flex-direction: column; + gap: var(--s-05); +} + +.focus-label { + font-family: var(--font-mono); + font-size: var(--t-2xs); + font-weight: var(--w-medium); + letter-spacing: var(--tr-wide); + text-transform: uppercase; + color: var(--muted); +} + +.focus-title { + margin: 0; + font-size: var(--t-sm); + font-weight: var(--w-medium); + line-height: var(--lh-snug); + color: var(--text); +} + +.focus-sub { + margin: 0; + font-size: var(--t-xs); + line-height: var(--lh-body); + color: var(--text-soft); +} + +.focus-empty { + margin: 0; + font-size: var(--t-xs); + color: var(--muted); +} + .comms { flex: 1; min-height: 0; @@ -1136,6 +1184,84 @@ padding: var(--s-05) var(--s-3) var(--s-3); } +/* Real NOW/NEXT/WAITING/OVERDUE Personal OS tasks, above the Hermes run + list in the same scrollable Work Stream column. */ +.pos-block { + display: flex; + flex-direction: column; + gap: var(--s-3); + padding: var(--s-3); + border-radius: var(--r-md); + background: var(--panel-raised); + box-shadow: inset 0 0 0 1px var(--hairline); +} + +.pos-block-head { + font-family: var(--font-mono); + font-size: var(--t-2xs); + font-weight: var(--w-semi); + letter-spacing: var(--tr-wide); + text-transform: uppercase; + color: var(--muted); +} + +.pos-empty-text { + margin: 0; + font-size: var(--t-xs); + color: var(--muted); +} + +.pos-group { + display: flex; + flex-direction: column; + gap: var(--s-1); +} + +.pos-group-label { + display: inline-flex; + align-items: center; + gap: var(--s-1); + font-family: var(--font-mono); + font-size: var(--t-2xs); + font-weight: var(--w-semi); + letter-spacing: var(--tr-wide); + text-transform: uppercase; + color: var(--text-soft); +} + +.pos-group-count { + color: var(--muted); +} + +.pos-group-label.pos-overdue { color: var(--coral); } +.pos-group-label.pos-now { color: var(--amber); } +.pos-group-label.pos-next { color: var(--cyan-bright); } +.pos-group-label.pos-waiting { color: var(--violet-soft); } + +.pos-row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--s-2); + padding: var(--s-1) 0; +} + +.pos-title { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: var(--t-sm); + color: var(--text); +} + +.pos-meta { + flex: 0 0 auto; + font-family: var(--font-mono); + font-size: var(--t-2xs); + color: var(--muted); +} + /* ===== Work cards ===== Every card now carries a status-colored rail on its left edge, so you can read the state of the whole stream peripherally without reading a word. diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 535ef1b..74ea5cc 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -235,12 +235,110 @@ type IrisApi = { sendUiContext: (context: Record) => void; sendAudioChunk: (chunk: ArrayBuffer) => void; notifyBootDone: () => void; + reportVoiceState: (state: JarvisVoiceState) => void; + askJarvis: (text: string) => Promise; + getJarvisTasks: () => Promise; + getJarvisTopFocus: () => Promise; + getJarvisCurrentContext: () => Promise; onUiAction: (callback: (action: IrisUiAction) => void) => () => void; onAudioChunk: (callback: (chunk: LiveAudioChunk) => void) => () => void; onAudioInterrupt: (callback: () => void) => () => void; onSidecarEvent: (callback: (event: SidecarEvent) => void) => () => void; }; +// Iris Bridge — request/response contract for window.iris.askJarvis(text), +// reached via Iris's own main process (electron/jarvisBridgeClient.mjs), +// never a second parallel event bus. +type JarvisVoiceState = "idle" | "listening" | "thinking" | "speaking" | "error"; +type JarvisAskResult = { ok: boolean; answer?: string; error?: string }; + +// Personal OS bridge — verbatim field shapes from Jarvis's own +// personal-os-reader.cjs / daily-top-focus.cjs / daily-chief-of-staff.cjs +// (reached via getTasks/getTopFocus/getCurrentContext), never re-derived. +type JarvisTaskItem = { + title: string; + path: string; + status: string; + due: string; + priority: string; + project: string; + area: string; + overdue: boolean; + dueToday: boolean; + dueTomorrow: boolean; + nextAction: string; +}; + +type JarvisWaitingItem = { + title: string; + path: string; + status: "WAITING"; + waitingFor: string; + since: string; + followUp: string; + followUpDue: boolean; + project: string; + expected: string; +}; + +type JarvisTasksResult = { + ok: boolean; + error?: string; + data?: { + now: JarvisTaskItem[]; + next: JarvisTaskItem[]; + waiting: JarvisWaitingItem[]; + overdue: JarvisTaskItem[]; + }; +}; + +type JarvisTopFocusItem = { + id: string; + title: string; + project: string | null; + area: string | null; + priority: string | null; + whyNow: string; + nextAction: string | null; + deadline: string | null; + blocker: string | null; + tier: string; +}; + +type JarvisTopFocusResult = { + ok: boolean; + error?: string; + data?: { top: JarvisTopFocusItem[] }; +}; + +type JarvisNextAction = { + id: string; + source: string; + kind: string; + title: string; + when: string | null; + reason: string; + blocked: boolean; + blocker: string | null; +} | null; + +type JarvisCurrentContextResult = { + ok: boolean; + error?: string; + data?: { + today: string; + status: string; + summary: { + tasksOverdue: number; + tasksDueToday: number; + waitingFollowUpDue: number; + decisionsOpen: number; + projectsAttention: number; + }; + recommended: JarvisNextAction; + }; +}; + interface Window { iris: IrisApi; } diff --git a/test/jarvisBridgeClient.test.mjs b/test/jarvisBridgeClient.test.mjs new file mode 100644 index 0000000..8ad610d --- /dev/null +++ b/test/jarvisBridgeClient.test.mjs @@ -0,0 +1,244 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import path from "node:path"; +import { + shouldAskJarvis, + defaultJarvisBridgePath, + loadJarvisBridge, + askJarvisForTurn, + describeSmokeTranscript, + getTasksForRenderer, + getTopFocusForRenderer, + getCurrentContextForRenderer, +} from "../electron/jarvisBridgeClient.mjs"; + +function withEnv(key, value, fn) { + const original = process.env[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + try { + return fn(); + } finally { + if (original === undefined) delete process.env[key]; + else process.env[key] = original; + } +} + +test("shouldAskJarvis: only the existing 'memory' route reaches Jarvis (reuses routingPolicy classification, no new rule)", () => { + assert.equal(shouldAskJarvis("memory"), true); + assert.equal(shouldAskJarvis("direct"), false); + assert.equal(shouldAskJarvis("ui"), false); + assert.equal(shouldAskJarvis("web"), false); + assert.equal(shouldAskJarvis("hermes"), false); + assert.equal(shouldAskJarvis(undefined), false); +}); + +test("defaultJarvisBridgePath: honors a JARVIS_BRIDGE_PATH override", () => { + withEnv("JARVIS_BRIDGE_PATH", "/tmp/custom-bridge.cjs", () => { + assert.equal(defaultJarvisBridgePath("/whatever/repo"), "/tmp/custom-bridge.cjs"); + }); +}); + +test("defaultJarvisBridgePath: defaults to the sibling Jarvis-Desktop adapter path", () => { + withEnv("JARVIS_BRIDGE_PATH", undefined, () => { + const resolved = defaultJarvisBridgePath("/Users/x/Development/iris-test"); + assert.equal( + resolved, + path.resolve("/Users/x/Development/Jarvis-Desktop/app/adapter/iris-bridge.cjs"), + ); + }); +}); + +test("loadJarvisBridge: never throws and returns null when the bridge file does not exist", () => { + const bridge = loadJarvisBridge("/does/not/exist", { + existsFn: () => false, + requireFn: () => { throw new Error("must not be called when the file is missing"); }, + }); + assert.equal(bridge, null); +}); + +test("loadJarvisBridge: requires the resolved path and returns createIrisBridge()'s instance", () => { + let seenPath = null; + const fakeInstance = { askJarvis: async () => ({}) }; + const bridge = loadJarvisBridge("/whatever/repo", { + existsFn: () => true, + requireFn: (resolvedPath) => { + seenPath = resolvedPath; + return { createIrisBridge: () => fakeInstance }; + }, + }); + assert.equal(seenPath, path.resolve("/whatever/repo", "..", "Jarvis-Desktop", "app", "adapter", "iris-bridge.cjs")); + assert.equal(bridge, fakeInstance); +}); + +test("loadJarvisBridge: never throws, returns null if require() itself throws", () => { + const bridge = loadJarvisBridge("/whatever/repo", { + existsFn: () => true, + requireFn: () => { throw new Error("module not found"); }, + }); + assert.equal(bridge, null); +}); + +// Gap 2 ROT: loadJarvisBridge() currently swallows a require() failure +// silently — no diagnostics, no reason code. The approved fix adds a 3rd +// optional `onUnavailable(reasonCode)` option, called with exactly +// "require-error" here (never the resolved path, the raw Error, its +// message, or a stack trace — those can leak local absolute paths). +test("loadJarvisBridge: calls onUnavailable('require-error') when require() throws, without leaking the path/error", () => { + const seen = []; + const bridge = loadJarvisBridge("/fake/repo", { + existsFn: () => true, + requireFn: () => { + throw new Error("/Users/cd/Development/Jarvis-Desktop/app/adapter/iris-bridge.cjs: boom"); + }, + onUnavailable: (reason) => seen.push(reason), + }); + assert.equal(bridge, null); + assert.deepEqual(seen, ["require-error"]); +}); + +test("askJarvisForTurn: returns ok+answer when the bridge resolves a real answer (mocked final transcript -> bridge -> answer)", async () => { + const bridge = { + askJarvis: async (text) => { + assert.equal(text, "was steht heute an"); + return { ok: true, answer: "Heute: 2 Termine, 1 überfällige Aufgabe." }; + }, + }; + const result = await askJarvisForTurn(bridge, "was steht heute an"); + assert.deepEqual(result, { ok: true, answer: "Heute: 2 Termine, 1 überfällige Aufgabe." }); +}); + +test("askJarvisForTurn: returns ok:false without crashing when no bridge is connected", async () => { + const result = await askJarvisForTurn(null, "was steht heute an"); + assert.equal(result.ok, false); + assert.ok(result.error); +}); + +test("askJarvisForTurn: surfaces {ok:false, error} from the bridge as-is", async () => { + const bridge = { askJarvis: async () => ({ ok: false, error: "ASK_FAILED" }) }; + const result = await askJarvisForTurn(bridge, "x"); + assert.equal(result.ok, false); + assert.equal(result.error, "ASK_FAILED"); +}); + +test("askJarvisForTurn: catches a thrown error from the bridge and returns ok:false", async () => { + const bridge = { askJarvis: async () => { throw new Error("boom"); } }; + const result = await askJarvisForTurn(bridge, "x"); + assert.equal(result.ok, false); + assert.match(result.error, /boom/); +}); + +test("askJarvisForTurn: treats a missing/empty answer as a failure, never a silent empty Comms bubble", async () => { + const bridge = { askJarvis: async () => ({ ok: true, answer: "" }) }; + const result = await askJarvisForTurn(bridge, "x"); + assert.equal(result.ok, false); +}); + +test("askJarvisForTurn: never leaks internal askJarvis() fields (sources/timings/mail/...) across the IPC contract", async () => { + const bridge = { + askJarvis: async () => ({ + ok: true, + answer: "Ich bin Jarvis.", + intent: "SMALL_TALK", + sources: [{ type: "obsidian", id: "x" }], + mail: { kind: "results", resultCount: 3 }, + drive: { kind: "results", resultCount: 1 }, + timings: { totalMs: 42 }, + model: "anthropic/claude-sonnet-5", + }), + }; + const result = await askJarvisForTurn(bridge, "wie heißt du"); + assert.deepEqual(Object.keys(result).sort(), ["answer", "ok"]); +}); + +test("describeSmokeTranscript: success -> [you, jarvis] pair with the real answer, visible in the existing Comms transcript shape", () => { + const lines = describeSmokeTranscript("Wie heißt du?", { ok: true, answer: "Ich bin Jarvis." }); + assert.deepEqual(lines, [ + { speaker: "you", text: "Wie heißt du?" }, + { speaker: "jarvis", text: "Ich bin Jarvis." }, + ]); +}); + +test("describeSmokeTranscript: failure -> [you, jarvis-error] pair, error text never silently dropped", () => { + const lines = describeSmokeTranscript("Wie heißt du?", { ok: false, error: "Jarvis Bridge nicht verfügbar." }); + assert.deepEqual(lines, [ + { speaker: "you", text: "Wie heißt du?" }, + { speaker: "jarvis-error", text: "Jarvis-Anfrage fehlgeschlagen: Jarvis Bridge nicht verfügbar." }, + ]); +}); + +// Work Stream / left panel live data — real getTasks()/getTopFocus()/ +// getCurrentContext(), never demo data or an invented fallback shape. + +test("getTasksForRenderer: forwards the bridge's real {ok, data} result as-is", async () => { + const bridge = { + getTasks: () => ({ + ok: true, + data: { now: [{ title: "A" }], next: [], waiting: [], overdue: [] }, + }), + }; + const result = await getTasksForRenderer(bridge); + assert.deepEqual(result, { + ok: true, + data: { now: [{ title: "A" }], next: [], waiting: [], overdue: [] }, + }); +}); + +test("getTasksForRenderer: returns ok:false without crashing when no bridge is connected", async () => { + const result = await getTasksForRenderer(null); + assert.equal(result.ok, false); + assert.ok(result.error); +}); + +test("getTasksForRenderer: catches a thrown error from the bridge and returns ok:false", async () => { + const bridge = { getTasks: () => { throw new Error("boom"); } }; + const result = await getTasksForRenderer(bridge); + assert.equal(result.ok, false); + assert.match(result.error, /boom/); +}); + +test("getTopFocusForRenderer: forwards the bridge's real async {ok, data} result as-is", async () => { + const bridge = { getTopFocus: async () => ({ ok: true, data: { top: [{ title: "Ship X" }] } }) }; + const result = await getTopFocusForRenderer(bridge); + assert.deepEqual(result, { ok: true, data: { top: [{ title: "Ship X" }] } }); +}); + +test("getTopFocusForRenderer: surfaces {ok:false, error} from the bridge as-is (e.g. reader not configured)", async () => { + const bridge = { getTopFocus: async () => ({ ok: false, error: "Top Focus nicht verfügbar." }) }; + const result = await getTopFocusForRenderer(bridge); + assert.deepEqual(result, { ok: false, error: "Top Focus nicht verfügbar." }); +}); + +test("getTopFocusForRenderer: returns ok:false without crashing when no bridge is connected", async () => { + const result = await getTopFocusForRenderer(null); + assert.equal(result.ok, false); + assert.ok(result.error); +}); + +test("getTopFocusForRenderer: catches a thrown error from the bridge and returns ok:false", async () => { + const bridge = { getTopFocus: async () => { throw new Error("boom"); } }; + const result = await getTopFocusForRenderer(bridge); + assert.equal(result.ok, false); + assert.match(result.error, /boom/); +}); + +test("getCurrentContextForRenderer: forwards the bridge's real async {ok, data} result as-is", async () => { + const bridge = { + getCurrentContext: async () => ({ ok: true, data: { today: "2026-08-20", recommended: { title: "Follow up" } } }), + }; + const result = await getCurrentContextForRenderer(bridge); + assert.deepEqual(result, { ok: true, data: { today: "2026-08-20", recommended: { title: "Follow up" } } }); +}); + +test("getCurrentContextForRenderer: returns ok:false without crashing when no bridge is connected", async () => { + const result = await getCurrentContextForRenderer(null); + assert.equal(result.ok, false); + assert.ok(result.error); +}); + +test("getCurrentContextForRenderer: catches a thrown error from the bridge and returns ok:false", async () => { + const bridge = { getCurrentContext: async () => { throw new Error("boom"); } }; + const result = await getCurrentContextForRenderer(bridge); + assert.equal(result.ok, false); + assert.match(result.error, /boom/); +}); diff --git a/test/routingPolicy.test.mjs b/test/routingPolicy.test.mjs index 094fb69..5880a04 100644 --- a/test/routingPolicy.test.mjs +++ b/test/routingPolicy.test.mjs @@ -2,7 +2,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import fs from "node:fs"; import { fileURLToPath } from "node:url"; -import { classifyRoute, routingGuidance } from "../electron/routingPolicy.mjs"; +import { classifyRoute, routingGuidance, decideTurnOwner } from "../electron/routingPolicy.mjs"; test("recorded utterance corpus maps to the intended capability", () => { const fixture = new URL("./fixtures/routing-cases.json", import.meta.url); @@ -16,3 +16,15 @@ test("recorded utterance corpus maps to the intended capability", () => { assert.ok(routingGuidance(sample.route)); } }); + +// Gap 1 ROT: turn ownership does not exist yet. A "memory" route turn must be +// owned by Jarvis (which will answer via the bridge), never by Gemini itself +// (which today double-answers alongside Jarvis's relay). Every other route is +// owned by Gemini as before. +test("decideTurnOwner: memory route is owned by jarvis, everything else by gemini", () => { + assert.equal(decideTurnOwner("memory"), "jarvis"); + assert.equal(decideTurnOwner("direct"), "gemini"); + assert.equal(decideTurnOwner("web"), "gemini"); + assert.equal(decideTurnOwner("hermes"), "gemini"); + assert.equal(decideTurnOwner("ui"), "gemini"); +}); diff --git a/test/turnOwnership.smoke.mjs b/test/turnOwnership.smoke.mjs new file mode 100644 index 0000000..e5c338a --- /dev/null +++ b/test/turnOwnership.smoke.mjs @@ -0,0 +1,60 @@ +// Gap 1 ROT (manual smoke, NOT part of `npm test` / `node --test test/*.test.mjs`): +// boots a real Electron app, so it must be run directly: +// node test/turnOwnership.smoke.mjs +// +// Proves the missing turn-ownership gate: today, a "memory"-routed turn is +// answered by BOTH Gemini's own spoken transcript (flushModelTranscript, +// main.mjs:346-359) AND relayed to Jarvis (flushUserTranscript, main.mjs:333- +// 344) — a double answer. The fix under test exposes +// window.__irisTest.simulateMemoryToolCall(route, name, args), which for a +// "memory" route must defer to Jarvis instead of returning real tool data. +// +// EXPECTED RIGHT NOW: this hook does not exist yet, so the test fails with +// "window.__irisTest.simulateMemoryToolCall is not a function" (or similar). +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { _electron as electron } from "playwright-core"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +const env = { + ...process.env, + IRIS_START_PROD: "1", + IRIS_LOAD_TEST_DATA: "false", + IRIS_WAKE_WORD: "false", + IRIS_HERMES_AUTOSTART: "false", + IRIS_TEST_HOOKS: "1", +}; +delete env.ELECTRON_RUN_AS_NODE; + +const app = await electron.launch({ + args: [ + path.join(root, "electron", "main.mjs"), + `--user-data-dir=/tmp/iris-turn-ownership-test-${process.pid}`, + ], + cwd: root, + env, +}); + +try { + const page = await app.firstWindow(); + await page.waitForSelector(".deck", { timeout: 20000 }); + + // Note: __irisTest hooks live on the main-process globalThis (see + // electron/main.mjs:2440), not on the renderer's window — every existing + // caller (e.g. scripts/test-live-hermes-wake.mjs) reaches them via + // app.evaluate(), not page.evaluate(). Mirrored here for the same reason. + const result = await app.evaluate(() => + globalThis.__irisTest.simulateMemoryToolCall("memory", "search_memory", { query: "test" }), + ); + + if (result?.deferredToJarvis !== true) { + throw new Error( + `Expected simulateMemoryToolCall("memory", ...) to return deferredToJarvis === true, got: ${JSON.stringify(result)}`, + ); + } + + console.log("PASS: memory-route tool call deferred to Jarvis, no double answer."); +} finally { + await app.close(); +} From 1162cf79f89fc018dfa3967dea436cd9d3ef5646 Mon Sep 17 00:00:00 2001 From: Cengiz Demirel Date: Fri, 21 Aug 2026 15:51:26 +0200 Subject: [PATCH 02/10] feat(jarvis-bridge): show real Autonomy state (goal, job, Work Stream, approval) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consumes the new Jarvis-Desktop iris-bridge.cjs getActiveGoal()/ getLatestEngineeringJob() via two new jarvisBridge:* IPC channels — Iris never reads Jarvis's job/goal files directly, no second job system. - PersonalFocusPanel: new "Mission" section shows the active goal + next action. - WorkStream: new "Autonomie" block shows the latest job's status/worker/ attempts/approval banner (BEREIT ZUR FREIGABE / MENSCHLICHE ENTSCHEIDUNG NÖTIG, verbatim from Jarvis's own CommandCenter.jsx labels) and its last 5 Work Stream events. Read-only throughout — no merge/push/promote control in Iris, matching Jarvis's own approval boundary. Reuses existing pos-block/focus-section CSS and --mint/--amber tone tokens; no redesign, no new dashboard. --- electron/jarvisBridgeClient.mjs | 25 ++++++ electron/main.mjs | 10 +++ electron/preload.cjs | 2 + src/App.tsx | 11 ++- src/components/PersonalFocusPanel.tsx | 19 +++++ src/components/WorkStream.tsx | 117 ++++++++++++++++++++++++++ src/styles/deck.css | 3 + src/vite-env.d.ts | 78 +++++++++++++++++ 8 files changed, 264 insertions(+), 1 deletion(-) diff --git a/electron/jarvisBridgeClient.mjs b/electron/jarvisBridgeClient.mjs index 90e983e..0488257 100644 --- a/electron/jarvisBridgeClient.mjs +++ b/electron/jarvisBridgeClient.mjs @@ -98,6 +98,31 @@ export async function getCurrentContextForRenderer(bridge) { } } +// getLatestEngineeringJobForRenderer/getActiveGoalForRenderer — same +// never-throws, forward-the-bridge's-{ok,...}-result-as-is contract as the +// three Personal OS readers above, for Jarvis V1's autonomous engineering +// read surface (bridge.getLatestEngineeringJob/getActiveGoal — see +// Jarvis-Desktop/app/adapter/iris-bridge.cjs). A job already carries +// status/workerKind/attemptCount/verification/promotion/events verbatim; +// no re-derivation here, no invented "no job"/"no goal" fallback data. +export async function getLatestEngineeringJobForRenderer(bridge) { + if (!bridge) return { ok: false, error: "Jarvis Bridge nicht verfügbar." }; + try { + return bridge.getLatestEngineeringJob(); + } catch (error) { + return { ok: false, error: String(error?.message ?? error) }; + } +} + +export async function getActiveGoalForRenderer(bridge) { + if (!bridge) return { ok: false, error: "Jarvis Bridge nicht verfügbar." }; + try { + return bridge.getActiveGoal(); + } catch (error) { + return { ok: false, error: String(error?.message ?? error) }; + } +} + // Pure: builds the [you, jarvis|jarvis-error] pair the renderer appends to // the existing Comms transcript for a request/response askJarvis call. // "jarvis-error" is a distinct speaker value (never silently reused as diff --git a/electron/main.mjs b/electron/main.mjs index 72103f4..c4fa981 100644 --- a/electron/main.mjs +++ b/electron/main.mjs @@ -8,6 +8,8 @@ import { getTasksForRenderer, getTopFocusForRenderer, getCurrentContextForRenderer, + getLatestEngineeringJobForRenderer, + getActiveGoalForRenderer, } from "./jarvisBridgeClient.mjs"; import { proposeHermesTask as gatePropose, @@ -3989,6 +3991,14 @@ app.whenReady().then(() => { trustedHandle("jarvisBridge:getTasks", () => getTasksForRenderer(jarvisBridge())); trustedHandle("jarvisBridge:getTopFocus", () => getTopFocusForRenderer(jarvisBridge())); trustedHandle("jarvisBridge:getCurrentContext", () => getCurrentContextForRenderer(jarvisBridge())); + // Jarvis V1 Autonomy read surface — real goal/job/lifecycle/worker/ + // attempts/verification/approval/Work Stream state via the same + // in-process jarvisBridge() instance (bridge.getLatestEngineeringJob/ + // getActiveGoal — see Jarvis-Desktop/app/adapter/iris-bridge.cjs). Iris + // never reads job-store.cjs/goal-store.cjs files directly, and never gets + // a second job/memory system — this is the only path. + trustedHandle("jarvisBridge:getLatestEngineeringJob", () => getLatestEngineeringJobForRenderer(jarvisBridge())); + trustedHandle("jarvisBridge:getActiveGoal", () => getActiveGoalForRenderer(jarvisBridge())); trustedHandle("app:open-external", (_event, url) => { const target = safeExternalUrl(url); if (target) return shell.openExternal(target); diff --git a/electron/preload.cjs b/electron/preload.cjs index 705d61e..04f2506 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -59,6 +59,8 @@ contextBridge.exposeInMainWorld("iris", { getJarvisTasks: () => ipcRenderer.invoke("jarvisBridge:getTasks"), getJarvisTopFocus: () => ipcRenderer.invoke("jarvisBridge:getTopFocus"), getJarvisCurrentContext: () => ipcRenderer.invoke("jarvisBridge:getCurrentContext"), + getJarvisEngineeringJob: () => ipcRenderer.invoke("jarvisBridge:getLatestEngineeringJob"), + getJarvisActiveGoal: () => ipcRenderer.invoke("jarvisBridge:getActiveGoal"), onUiAction: (callback) => { const handler = (_event, payload) => callback(payload); ipcRenderer.on("iris:ui-action", handler); diff --git a/src/App.tsx b/src/App.tsx index 5049b01..f86bea8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -61,6 +61,12 @@ export default function App() { const [jarvisTasks, setJarvisTasks] = useState(null); const [jarvisTopFocus, setJarvisTopFocus] = useState(null); const [jarvisContext, setJarvisContext] = useState(null); + // Jarvis V1 Autonomy read surface — real goal + latest engineering job + // (Jarvis-Desktop/app/adapter/iris-bridge.cjs getActiveGoal/ + // getLatestEngineeringJob). Same null/{ok:false}/{ok:true} contract as + // the Personal OS states above, never demo data. + const [jarvisEngineeringJob, setJarvisEngineeringJob] = useState(null); + const [jarvisActiveGoal, setJarvisActiveGoal] = useState(null); const [geminiStatus, setGeminiStatus] = useState("offline"); const [hermesStatus, setHermesStatus] = useState("offline"); const [audioState, setAudioState] = useState("idle"); @@ -276,6 +282,8 @@ export default function App() { window.iris.getJarvisTasks().then(setJarvisTasks); window.iris.getJarvisTopFocus().then(setJarvisTopFocus); window.iris.getJarvisCurrentContext().then(setJarvisContext); + window.iris.getJarvisEngineeringJob().then(setJarvisEngineeringJob); + window.iris.getJarvisActiveGoal().then(setJarvisActiveGoal); }, [hasBridge]); useEffect(() => { @@ -1591,7 +1599,7 @@ export default function App() {
{/* LEFT — You */}
- +
@@ -25,6 +30,20 @@ export default function PersonalFocusPanel({ Focus
+
+ Mission + {goal ? ( + <> +

{goal.title}

+ {goal.nextAction ?

{goal.nextAction}

: null} + + ) : ( +

+ {goalError || (activeGoal ? "Kein Ziel definiert." : "…")} +

+ )} +
+
Top Focus {focusItem ? ( diff --git a/src/components/WorkStream.tsx b/src/components/WorkStream.tsx index 9c95c42..b73d550 100644 --- a/src/components/WorkStream.tsx +++ b/src/components/WorkStream.tsx @@ -63,8 +63,123 @@ function PersonalOsTasks({ result }: { result: JarvisTasksResult | null }) { ); } +// German labels reused verbatim from Jarvis-Desktop's own +// CommandCenter.jsx AutonomyView (JOB_STATUS_LABELS) — same source of +// truth, same wording, no re-derivation. +const JOB_STATUS_LABELS: Record = { + pending: "AUSSTEHEND", + scheduled: "GEPLANT", + preparing: "WIRD VORBEREITET", + running: "LÄUFT", + verifying: "WIRD VERIFIZIERT", + ready_for_approval: "BEREIT ZUR FREIGABE", + needs_human: "MENSCHLICHE ENTSCHEIDUNG NÖTIG", + completed: "ABGESCHLOSSEN", + partial: "TEILWEISE", + failed: "FEHLGESCHLAGEN", + timeout: "ZEITÜBERSCHREITUNG", + cancelled: "ABGEBROCHEN", + not_configured: "NICHT KONFIGURIERT", +}; +function jobStatusLabel(status: string): string { + return JOB_STATUS_LABELS[status] || status?.toUpperCase() || "UNBEKANNT"; +} +function jobStatusTone(status: string): "pos-ready" | "pos-human" | "pos-progress" | "" { + if (status === "ready_for_approval") return "pos-ready"; + if (status === "needs_human" || ["failed", "timeout", "cancelled", "not_configured"].includes(status)) return "pos-human"; + if (["running", "preparing", "verifying", "pending", "scheduled"].includes(status)) return "pos-progress"; + return ""; +} + +// Real autonomous engineering job state (window.iris.getJarvisEngineeringJob +// -> jarvisBridge.getLatestEngineeringJob() -> job-store.cjs/job-events.cjs, +// see Jarvis-Desktop/app/adapter/iris-bridge.cjs). Read-only, same boundary +// as Jarvis's own CommandCenter.jsx AutonomyView: shows lifecycle, worker, +// attempts, verification and approval state, never a merge/push/promote +// control. `result` is null before the first load, {ok:false} when the +// bridge/store is unavailable, or {ok:true,data:null} when no job exists +// yet — each a distinct, honest state, never fabricated. +function EngineeringJobBlock({ result }: { result: JarvisEngineeringJobResult | null }) { + if (!result) return null; + if (!result.ok) { + return ( +
+ Autonomie +

{result.error || "Engineering-Job-Daten nicht verfügbar."}

+
+ ); + } + const job = result.data; + if (!job) { + return ( +
+ Autonomie +

Kein autonomer Engineering-Job vorhanden.

+
+ ); + } + const verification = job.verification?.result; + const changedFileCount = + job.promotion?.actualChangedFiles?.length ?? verification?.actualChangedFiles?.length ?? null; + const warnings = [...(verification?.warnings || []), ...(verification?.reasons || [])]; + const approvalReason = + job.budgetState?.reason || verification?.reasons?.join("; ") || job.error?.message || null; + + return ( +
+ Autonomie +
+ {jobStatusLabel(job.status)} +
+ {job.task?.length > 72 ? `${job.task.slice(0, 72)}…` : job.task} + {job.id.slice(0, 8)} +
+
+ Worker: {job.workerKind || "nicht zugewiesen"} + + {job.attemptCount ?? 0} / {job.budgetState?.maxAttempts ?? 3} Versuche + +
+ {job.status === "ready_for_approval" ? ( +
+ + {job.promotion?.commitHash ? `Commit ${job.promotion.commitHash.slice(0, 10)}` : "Verifiziert"} · kein + automatisches Merge/Push + + {changedFileCount !== null ? {changedFileCount} Dateien : null} +
+ ) : null} + {job.status === "needs_human" && approvalReason ? ( +
+ {approvalReason} +
+ ) : null} + {warnings.map((warning, index) => ( +
+ {warning} +
+ ))} +
+ {job.events?.length ? ( +
+ Work Stream + {job.events.slice(-5).map((event, index) => ( +
+ {event.message} + + {new Date(event.timestamp).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })} + +
+ ))} +
+ ) : null} +
+ ); +} + export default function WorkStream({ personalTasks, + engineeringJob, tasks, sortedTasks, scrollRef, @@ -82,6 +197,7 @@ export default function WorkStream({ onApproveTask, }: { personalTasks: JarvisTasksResult | null; + engineeringJob: JarvisEngineeringJobResult | null; tasks: TaskCard[]; sortedTasks: TaskCard[]; scrollRef: RefObject; @@ -128,6 +244,7 @@ export default function WorkStream({ ) : null}
+ {tasks.length === 0 ? (
diff --git a/src/styles/deck.css b/src/styles/deck.css index 7177558..b3ea81b 100644 --- a/src/styles/deck.css +++ b/src/styles/deck.css @@ -1237,6 +1237,9 @@ .pos-group-label.pos-now { color: var(--amber); } .pos-group-label.pos-next { color: var(--cyan-bright); } .pos-group-label.pos-waiting { color: var(--violet-soft); } +.pos-group-label.pos-ready { color: var(--mint); } +.pos-group-label.pos-human { color: var(--amber); } +.pos-group-label.pos-progress { color: var(--violet-soft); } .pos-row { display: flex; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 74ea5cc..659f3b4 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -240,6 +240,8 @@ type IrisApi = { getJarvisTasks: () => Promise; getJarvisTopFocus: () => Promise; getJarvisCurrentContext: () => Promise; + getJarvisEngineeringJob: () => Promise; + getJarvisActiveGoal: () => Promise; onUiAction: (callback: (action: IrisUiAction) => void) => () => void; onAudioChunk: (callback: (chunk: LiveAudioChunk) => void) => () => void; onAudioInterrupt: (callback: () => void) => () => void; @@ -339,6 +341,82 @@ type JarvisCurrentContextResult = { }; }; +// Jarvis V1 Autonomy read surface — verbatim field shapes from +// Jarvis's own job-model.cjs/job-store.cjs/job-events.cjs/goal-store.cjs +// (reached via getLatestEngineeringJob/getActiveGoal), never re-derived. +// Promotion stops at ready_for_approval; there is no merge/push/promote +// control anywhere in Iris — read-only, same boundary as Jarvis's own +// CommandCenter.jsx AutonomyView. +type JarvisJobStatus = + | "pending" | "scheduled" | "preparing" | "running" | "verifying" + | "ready_for_approval" | "needs_human" | "completed" | "partial" + | "failed" | "timeout" | "cancelled" | "not_configured"; + +type JarvisJobEvent = { + timestamp: string; + jobId: string; + type: string; + status: string; + message: string; + metadata: Record | null; +}; + +type JarvisJobVerification = { + result?: { + actualChangedFiles?: string[]; + reportedChangedFiles?: string[]; + steps?: Array<{ name: string; passed: boolean; exitCode?: number }>; + warnings?: string[]; + reasons?: string[]; + }; +}; + +type JarvisJobPromotion = { + commitHash?: string; + actualChangedFiles?: string[]; + attempts?: number; +}; + +type JarvisEngineeringJob = { + id: string; + createdAt: string; + updatedAt: string; + status: JarvisJobStatus; + executionMode: string; + task: string; + source: string; + workerKind: string | null; + attemptCount: number | null; + budgetState: { maxAttempts?: number; reason?: string } | null; + verification: JarvisJobVerification | null; + promotion: JarvisJobPromotion | null; + metadata: { execution?: { branchName?: string; worktreePath?: string; baseRef?: string; preparedAt?: string } } | null; + error: { message: string } | null; + events: JarvisJobEvent[]; +}; + +type JarvisEngineeringJobResult = { + ok: boolean; + error?: string; + data?: JarvisEngineeringJob | null; +}; + +type JarvisGoal = { + id: string; + title: string; + status: "active" | "paused" | "done"; + nextAction: string | null; + linkedJobIds: string[]; + createdAt: string; + updatedAt: string; +}; + +type JarvisActiveGoalResult = { + ok: boolean; + error?: string; + data?: JarvisGoal | null; +}; + interface Window { iris: IrisApi; } From 51ca19289f03bdaf12f64d883c23a0b9af0aec31 Mon Sep 17 00:00:00 2001 From: Cengiz Demirel Date: Fri, 21 Aug 2026 18:45:47 +0200 Subject: [PATCH 03/10] fix(dev): connect Electron to Iris's own Vite port, never a stray one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit electron/main.mjs's dev-mode fallback hardcoded 127.0.0.1:5173. When that port was already taken by an unrelated process, Vite auto-selected a different port but "wait-on tcp:5173" just waited for anything to answer on 5173 and Electron connected to that wrong renderer instead. dev-electron.mjs starts Vite via its Node API, reads the port it actually bound to, and passes that exact URL to Electron via VITE_DEV_SERVER_URL — no polling a fixed port, no probing/reusing whatever else is listening there. package.json's "dev" script now runs it directly, replacing the concurrently+wait-on composition. Prod scripts (start/start:prod/electron) are untouched. Verified: target port free -> binds there, loads there. Target port occupied by another process -> Vite auto-shifts, Electron follows exactly, old port's occupant never rendered. --- package.json | 2 +- scripts/dev-electron.mjs | 59 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 scripts/dev-electron.mjs diff --git a/package.json b/package.json index 7c7360f..7f26105 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "type": "module", "main": "electron/main.mjs", "scripts": { - "dev": "concurrently -k \"vite --host 127.0.0.1\" \"wait-on tcp:5173 && node scripts/run-electron.mjs\"", + "dev": "node scripts/dev-electron.mjs", "build": "tsc --noEmit && vite build", "test": "node --test test/*.test.mjs", "check:electron": "node scripts/check-electron.mjs", diff --git a/scripts/dev-electron.mjs b/scripts/dev-electron.mjs new file mode 100644 index 0000000..5afb317 --- /dev/null +++ b/scripts/dev-electron.mjs @@ -0,0 +1,59 @@ +// Dev-mode launcher: starts Iris's OWN Vite dev server programmatically +// (via Vite's Node API, not the CLI + a separate "wait-on tcp:5173" race), +// so we know the EXACT origin it actually bound to, then passes that exact +// URL to Electron as VITE_DEV_SERVER_URL. This replaces a hardcoded-port +// assumption that could attach Electron to an unrelated process already +// listening on 5173 (e.g. another project's dev server) instead of Iris's +// own renderer. +// +// If 5173 is occupied, Vite's own default port-conflict handling (no +// strictPort in vite.config.ts) picks the next free port on its own — we +// never guess a port, never probe/attach to whatever already answers on +// 5173, and never touch that other process. +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawn } from "node:child_process"; +import { createServer } from "vite"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(__dirname, ".."); +const isWindows = process.platform === "win32"; +const electronBin = path.join(root, "node_modules", ".bin", isWindows ? "electron.cmd" : "electron"); + +const server = await createServer({ root, configFile: path.resolve(root, "vite.config.ts") }); +await server.listen(); + +const address = server.httpServer?.address(); +if (!address || typeof address === "string") { + await server.close(); + throw new Error("Iris Vite dev server did not bind to a TCP port as expected."); +} +// vite.config.ts pins server.host to "127.0.0.1" — hardcoding it here too +// (rather than trusting address.address, which can report "::" / "::1" +// depending on platform) keeps this the same loopback origin Electron's +// window-security allowlist (windowSecurity.mjs trustedRendererUrl) and the +// rest of the app already assume. +const devUrl = `http://127.0.0.1:${address.port}`; +server.config.logger.info(`\n Iris (Electron) will load: ${devUrl}\n`, { clear: false }); + +const env = { ...process.env, VITE_DEV_SERVER_URL: devUrl }; +delete env.ELECTRON_RUN_AS_NODE; + +const child = spawn(electronBin, ["."], { cwd: root, env, stdio: "inherit", shell: isWindows }); + +let shuttingDown = false; +async function shutdown(exitCode) { + if (shuttingDown) return; + shuttingDown = true; + await server.close().catch(() => {}); + process.exit(exitCode); +} + +child.on("exit", (code, signal) => { + void shutdown(signal ? 0 : (code ?? 0)); +}); + +// Ctrl+C / termination: stop Electron first, its own "exit" handler above +// then closes the Vite server and exits this process. +process.on("SIGINT", () => child.kill("SIGINT")); +process.on("SIGTERM", () => child.kill("SIGTERM")); From 5dd0447a5368fc72792f2a306a6c15d948b3627b Mon Sep 17 00:00:00 2001 From: Cengiz Demirel Date: Fri, 21 Aug 2026 20:15:26 +0200 Subject: [PATCH 04/10] feat(v1): text-first comms, German UI copy, and real worker result rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a always-available text composer to CommsPanel (independent of voice/sidecar/Gemini-key state) and stops forcing the onboarding wizard or auto-loading demo data on boot — a missing Gemini key is now a normal, fully text-usable state. CenterStage/TopBar reflect voice-configured state truthfully instead of implying voice is always available. SessionSwitcher hides when there are no real Hermes sessions instead of exposing an internal default id. Renders a completed engineering job's real job.result.text in WorkStream (the actual worker output, not just event log lines). Localizes the remaining English UI copy to German for consistency with the rest of the shell. Co-Authored-By: Claude Sonnet 5 --- src/App.tsx | 58 ++++++++++++++++---- src/components/CameraDock.tsx | 6 +-- src/components/CenterStage.tsx | 50 ++++++++++------- src/components/CommsPanel.tsx | 38 +++++++++++-- src/components/PersonalFocusPanel.tsx | 10 ++-- src/components/SessionSwitcher.tsx | 12 ++++- src/components/TopBar.tsx | 7 ++- src/components/WorkStream.tsx | 16 ++++-- src/styles/deck.css | 77 +++++++++++++++++++++++++++ src/vite-env.d.ts | 5 ++ 10 files changed, 228 insertions(+), 51 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index f86bea8..01e3ba8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -289,10 +289,12 @@ export default function App() { useEffect(() => { if (!hasBridge) return; window.iris.getAppConfig().then((config) => { + // IRIS_LOAD_TEST_DATA only unlocks the manual "Load demo" affordances + // (button/hotkeys) below — it must never auto-load fixture content on + // its own. Real Hermes history restore always runs on boot. setTestDataEnabled(Boolean(config.loadTestData)); setSoundsEnabled(config.sounds !== false); - if (config.loadTestData) loadUiTestData(); - else initHermesSession(); + initHermesSession(); }); }, [hasBridge]); @@ -383,7 +385,9 @@ export default function App() { setWakeWordEnabled(config.wakeWord); setWakeSensitivity(config.wakeSensitivity || "balanced"); setShowWakeDiagnostics(config.showWakeDiagnostics); - if (!config.configured) setSetup({ mode: "onboarding" }); + // A missing Gemini key is a normal, fully-usable state (text/Jarvis + // works without it) — it must never force the onboarding wizard open. + // Setup remains reachable any time via the Settings button. }); }, [hasBridge]); @@ -665,27 +669,49 @@ export default function App() { window.iris.reportVoiceState(CANONICAL_VOICE_STATE[reactorState]); }, [hasBridge, reactorState]); - // Iris Bridge v0.3 — real request/response smoke path: renderer -> preload + // Iris Bridge v0.3 — real request/response text path: renderer -> preload // -> main -> Jarvis Bridge -> askJarvis -> result -> back here, appended to - // the existing Comms transcript. No mic/Gemini dependency; triggered via - // the existing dev testDataEnabled shortcut (see keydown handler below), - // not a new UI element. Mirrors jarvisBridgeClient.mjs's + // the existing Comms transcript. No mic/Gemini dependency at all — this is + // the primary text-to-Jarvis path (composer below) and is also reused by + // the dev smoke hotkey. Mirrors jarvisBridgeClient.mjs's // describeSmokeTranscript pairing (tested) so both paths render identically. - async function runJarvisSmoke() { - if (!hasBridge) return; - const result = await window.iris.askJarvis(JARVIS_SMOKE_QUESTION); + async function askJarvisText(question: string) { + const trimmed = question.trim(); + if (!hasBridge || !trimmed) return; + const result = await window.iris.askJarvis(trimmed); const jarvisLine = result.ok ? { speaker: "jarvis", text: result.answer ?? "" } : { speaker: "jarvis-error", text: `Jarvis-Anfrage fehlgeschlagen: ${result.error}` }; setTranscript((current) => [ ...current, - { id: crypto.randomUUID(), speaker: "you", text: JARVIS_SMOKE_QUESTION }, + { id: crypto.randomUUID(), speaker: "you", text: trimmed }, { id: crypto.randomUUID(), ...jarvisLine }, ].slice(-40), ); } + async function runJarvisSmoke() { + await askJarvisText(JARVIS_SMOKE_QUESTION); + } + + // Text composer: fully independent of sidecarRunning/voice state — the + // user can type to Jarvis immediately after startup, asleep or awake, + // with or without a configured Gemini key. + const [textDraft, setTextDraft] = useState(""); + const [textSending, setTextSending] = useState(false); + async function handleComposerSubmit(text: string) { + const trimmed = text.trim(); + if (!trimmed || textSending) return; + setTextDraft(""); + setTextSending(true); + try { + await askJarvisText(trimmed); + } finally { + setTextSending(false); + } + } + function handleSidecarEvent(event: SidecarEvent) { if (event.type === "sidecar_status") { // Main flags resumed sessions (context intact) so the boot ceremony @@ -979,6 +1005,10 @@ export default function App() { pushLog("error", "Electron bridge unavailable. Launch with `npm run dev`."); return; } + if (!fullConfig?.geminiApiKeyConfigured) { + pushLog("info", "Voice is not configured — add a Gemini API key in Settings to enable it. Text still works."); + return; + } setWakeStarting(true); setAutoSlept(false); try { @@ -1588,6 +1618,7 @@ export default function App() {
- Camera / Gesture - {!handControl ? off : null} + Kamera / Geste + {!handControl ? aus : null} {/* Right-aligned + opens downward: the menu overlays the camera feed itself, never the conversation text above the panel. */}
) : ( -
Gesture control is off. Tap the hand icon to enable the camera.
+
Gestensteuerung ist aus. Tippe auf das Handsymbol, um die Kamera zu aktivieren.
)}
); diff --git a/src/components/CenterStage.tsx b/src/components/CenterStage.tsx index f3fa149..92d0373 100644 --- a/src/components/CenterStage.tsx +++ b/src/components/CenterStage.tsx @@ -63,6 +63,7 @@ export default function CenterStage({ orbFlash, onOrbFlashEnd, awake, + voiceConfigured, geminiStatus, hermesStatus, runs, @@ -88,6 +89,7 @@ export default function CenterStage({ orbFlash: { id: string; tone: HandoffTone } | null; onOrbFlashEnd: () => void; awake: boolean; + voiceConfigured: boolean; geminiStatus: string; hermesStatus: string; runs: number; @@ -168,36 +170,44 @@ export default function CenterStage({ ) : (
- {autoSlept ? ( + {!voiceConfigured ? ( + // Small, secondary status only — the orb stays the visual focus. + // Truthful about voice being unconfigured, without pretending + // it's ready and without dominating the screen the way the + // wake-prompt's large .wake-say pill does for configured states. +
Sprache nicht konfiguriert — Text funktioniert weiterhin
+ ) : autoSlept ? (
{hermesWorking - ? "On standby — Hermes is working; I'll wake when it's done" + ? "Standby — Jarvis arbeitet; ich wecke, sobald es fertig ist" : wakeWordEnabled - ? "On standby, saving tokens — say “Hey Iris”" - : "On standby, saving tokens"} + ? "Standby, spart Tokens — sag „Hey Iris“" + : "Standby, spart Tokens"}
) : wakeWordEnabled ? (
- Say “Hey Iris” + Sag „Hey Iris“
) : ( -
Iris is asleep
+
Sprache im Ruhemodus
)} -
- {wakeWordEnabled ? "or press" : "press"} - - - W - - wake - · - - - S - - sleep -
+ {voiceConfigured ? ( +
+ {wakeWordEnabled ? "oder drücke" : "drücke"} + + + W + + wecken + · + + + S + + ruhen +
+ ) : null}
)}
diff --git a/src/components/CommsPanel.tsx b/src/components/CommsPanel.tsx index 109dab8..3ddb3d0 100644 --- a/src/components/CommsPanel.tsx +++ b/src/components/CommsPanel.tsx @@ -1,5 +1,5 @@ -import { type RefObject } from "react"; -import { MessageSquare } from "lucide-react"; +import { type FormEvent, type RefObject } from "react"; +import { MessageSquare, Send } from "lucide-react"; import type { TranscriptLine } from "../types"; export default function CommsPanel({ @@ -7,17 +7,30 @@ export default function CommsPanel({ scrollRef, testDataEnabled, onLoadDemo, + textDraft, + onTextDraftChange, + onSendText, + textSending, }: { transcript: TranscriptLine[]; scrollRef: RefObject; testDataEnabled: boolean; onLoadDemo: () => void; + textDraft: string; + onTextDraftChange: (value: string) => void; + onSendText: (text: string) => void; + textSending: boolean; }) { + function handleSubmit(event: FormEvent) { + event.preventDefault(); + onSendText(textDraft); + } + return (
- Comms + Kommunikation
{transcript.length === 0 ? ( @@ -25,8 +38,8 @@ export default function CommsPanel({ -

No conversation yet

- Wake Iris and start talking — everything you say lands here. +

Noch kein Gespräch

+ Schreib unten, oder weck Iris und sprich – alles landet hier. {testDataEnabled ? (
+ {/* Always enabled — independent of voice/sidecar/wake state, so Jarvis + stays usable by text with no Gemini key and no active voice session. */} +
+ onTextDraftChange(event.target.value)} + placeholder="Nachricht an Jarvis…" + disabled={textSending} + aria-label="Nachricht an Jarvis" + /> + +
); } diff --git a/src/components/PersonalFocusPanel.tsx b/src/components/PersonalFocusPanel.tsx index 96cfaa9..bca4a39 100644 --- a/src/components/PersonalFocusPanel.tsx +++ b/src/components/PersonalFocusPanel.tsx @@ -27,7 +27,7 @@ export default function PersonalFocusPanel({
))}
+ {job.result?.text ? ( +
+ Ergebnis +

{job.result.text}

+
+ ) : null} {job.events?.length ? (
- Work Stream + Ereignisverlauf {job.events.slice(-5).map((event, index) => (
{event.message} @@ -221,7 +227,7 @@ export default function WorkStream({
+ ); +} diff --git a/src/components/TopBar.tsx b/src/components/TopBar.tsx index 494b63b..841fe6f 100644 --- a/src/components/TopBar.tsx +++ b/src/components/TopBar.tsx @@ -1,4 +1,5 @@ import { Hand, PictureInPicture2, Radio, Settings } from "lucide-react"; +import ConnectionsStatus from "./ConnectionsStatus"; function StatusDot({ tone, state, label }: { tone: string; state: string; label: string }) { return ( @@ -19,6 +20,7 @@ export default function TopBar({ handControl, onToggleHand, onOpenSettings, + connectionsStatus, }: { geminiDot: string; hermesDot: string; @@ -29,6 +31,7 @@ export default function TopBar({ handControl: boolean; onToggleHand: () => void; onOpenSettings: () => void; + connectionsStatus: JarvisConnectionsStatusResult | null; }) { return (
@@ -48,6 +51,7 @@ export default function TopBar({ I.R.I.S
+
+ {/* Always enabled — independent of voice/sidecar/wake state, so Jarvis stays usable by text with no Gemini key and no active voice session. */}
diff --git a/src/components/JarvisActionApproval.tsx b/src/components/JarvisActionApproval.tsx new file mode 100644 index 0000000..6971b92 --- /dev/null +++ b/src/components/JarvisActionApproval.tsx @@ -0,0 +1,88 @@ +import { ShieldAlert, Check, X } from "lucide-react"; + +/* + * Jarvis Action approval (P2.5) — the human gate in front of every Jarvis + * write action. + * + * Deliberately minimal and deliberately inside the existing Ask Jarvis + * surface (CommsPanel), not a new panel: the approval belongs next to the + * sentence that caused it. + * + * This component decides NOTHING. It renders the preview Jarvis proposed and + * calls back; the risk level, the "does this need a second approval" flag and + * the whole state machine live in Jarvis's Action Service, one process over. + * + * The one rule encoded here: a destructive action (which Jarvis reports back + * as status "secondary_approval_required") shows a SEPARATE, differently + * labeled second button. Approving it is never the same click as the first + * approval, and that button is never rendered until Jarvis itself asked for + * it. + */ +export default function JarvisActionApproval({ + previews, + busyPreviewId, + onApprove, + onSecondaryApprove, + onCancel, +}: { + previews: JarvisActionPreview[]; + busyPreviewId: string | null; + onApprove: (preview: JarvisActionPreview) => void; + onSecondaryApprove: (preview: JarvisActionPreview) => void; + onCancel: (preview: JarvisActionPreview) => void; +}) { + if (previews.length === 0) return null; + + return ( +
+ {previews.map((preview) => { + const awaitingSecond = preview.status === "secondary_approval_required"; + const busy = busyPreviewId === preview.previewId; + const warnings = preview.validation?.warnings ?? []; + return ( +
+
+ {preview.label || preview.type} + {preview.riskLevel} +
+

{preview.summary || preview.title}

+ {warnings.map((warning) => ( +

+ {warning} +

+ ))} + {awaitingSecond ? ( +

+ Diese Aktion ist nicht umkehrbar und braucht eine zweite, ausdrückliche Freigabe. +

+ ) : null} +
+ {awaitingSecond ? ( + + ) : ( + + )} + +
+
+ ); + })} +
+ ); +} diff --git a/src/styles/deck.css b/src/styles/deck.css index fca4b1f..b71b0e8 100644 --- a/src/styles/deck.css +++ b/src/styles/deck.css @@ -1903,3 +1903,108 @@ animation: none; } } + +/* Jarvis Actions & Approvals (P2.5) — the approval card inside the existing + Ask Jarvis surface (CommsPanel), between the transcript and the composer. + Reuses the panel's own tokens/hairlines rather than introducing a new + visual language: this is an addition to the existing UI, not a redesign. + The `.danger` variant marks an action Jarvis has escalated to + secondary_approval_required, so a destructive confirmation can never look + like an ordinary one. */ +.jarvis-actions { + flex: 0 0 auto; + display: flex; + flex-direction: column; + gap: var(--s-2); + padding: var(--s-3) var(--s-4) 0; +} + +.jarvis-action { + padding: var(--s-2) var(--s-3); + border-radius: var(--r-md); + background: rgba(3, 8, 16, 0.55); + box-shadow: inset 0 0 0 1px rgba(var(--cyan-rgb), 0.28); +} + +.jarvis-action.danger { + box-shadow: inset 0 0 0 1px rgba(240, 120, 90, 0.55); +} + +.jarvis-action-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--s-2); + margin-bottom: var(--s-1); +} + +.jarvis-action-label { + font-size: var(--t-sm); + font-weight: 600; + color: var(--cyan-bright); +} + +.jarvis-action-risk { + font-size: var(--t-xs); + letter-spacing: 0.06em; + text-transform: uppercase; + color: rgba(150, 166, 190, 0.85); +} + +.jarvis-action-risk.risk-high { + color: rgb(240, 140, 110); +} + +.jarvis-action-summary { + margin: 0; + font-size: var(--t-sm); + color: var(--text); +} + +.jarvis-action-warning { + display: flex; + align-items: center; + gap: 6px; + margin: var(--s-1) 0 0; + font-size: var(--t-xs); + color: rgb(240, 170, 120); +} + +.jarvis-action-buttons { + display: flex; + gap: var(--s-2); + margin-top: var(--s-2); +} + +.jarvis-action-buttons button { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 10px; + border-radius: var(--r-sm); + font-size: var(--t-xs); + font-family: var(--font-ui); + transition: background var(--dur-fast) ease, box-shadow var(--dur-fast) ease, opacity var(--dur-fast) ease; +} + +.jarvis-action-confirm { + color: var(--cyan-bright); + background: rgba(var(--cyan-rgb), 0.12); + box-shadow: inset 0 0 0 1px rgba(var(--cyan-rgb), 0.3); +} + +.jarvis-action-confirm.danger { + color: rgb(255, 190, 165); + background: rgba(240, 120, 90, 0.16); + box-shadow: inset 0 0 0 1px rgba(240, 120, 90, 0.5); +} + +.jarvis-action-reject { + color: rgba(150, 166, 190, 0.9); + box-shadow: inset 0 0 0 1px var(--hairline); +} + +.jarvis-action-buttons button:disabled { + opacity: 0.4; + cursor: default; +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index a130c2e..fc6ea99 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -243,6 +243,10 @@ type IrisApi = { getJarvisEngineeringJob: () => Promise; getJarvisActiveGoal: () => Promise; getJarvisConnectionsStatus: () => Promise; + proposeJarvisAction: (question: string, source?: "text" | "voice") => Promise; + approveJarvisAction: (previewId: string) => Promise; + secondaryApproveJarvisAction: (previewId: string) => Promise; + cancelJarvisAction: (previewId: string) => Promise; onUiAction: (callback: (action: IrisUiAction) => void) => () => void; onAudioChunk: (callback: (chunk: LiveAudioChunk) => void) => () => void; onAudioInterrupt: (callback: () => void) => () => void; @@ -255,6 +259,55 @@ type IrisApi = { type JarvisVoiceState = "idle" | "listening" | "thinking" | "speaking" | "error"; type JarvisAskResult = { ok: boolean; answer?: string; error?: string }; +// Jarvis Actions & Approvals (P2.5) — the whitelisted preview VIEW Jarvis's +// Action endpoint puts on the wire (Jarvis-Desktop/app/action-bridge- +// server.cjs toActionPreviewView). Display fields only: the live preview +// object, its execution context and every credential stay inside Jarvis's +// process. `previewId` is an opaque handle that is only ever meaningful to +// Jarvis's own Action Service — Iris stores no action state of its own. +type JarvisActionPreview = { + previewId: string; + domain: string; + type: string; + label: string; + title: string; + summary: string; + riskLevel: string; + requiresApproval: boolean; + requiresSecondaryApproval: boolean; + status: string; + target: Record; + changes: Record; + validation: { valid: boolean; errors: string[]; warnings: string[] }; + classification?: string; + duplicate?: boolean; +}; + +// kind "none" means "this text is not an action" — the caller then falls +// through to the normal Ask Jarvis answer path. +type JarvisActionProposeResult = { + ok: boolean; + kind?: "preview" | "capture-preview" | "clarification" | "none" | "existing"; + previews?: JarvisActionPreview[]; + question?: string; + error?: string; +}; + +// requiresSecondaryApproval:true means NOTHING was written yet — the action +// is waiting for the second, distinct approval. +type JarvisActionExecutionResult = { + ok: boolean; + requiresSecondaryApproval?: boolean; + preview?: JarvisActionPreview; + answer?: string; + action?: string; + object?: Record; + verified?: boolean; + error?: string; +}; + +type JarvisActionCancelResult = { ok: boolean; cancelled?: boolean; error?: string }; + // Personal OS bridge — verbatim field shapes from Jarvis's own // personal-os-reader.cjs / daily-top-focus.cjs / daily-chief-of-staff.cjs // (reached via getTasks/getTopFocus/getCurrentContext), never re-derived. diff --git a/test/jarvisActionClient.test.mjs b/test/jarvisActionClient.test.mjs new file mode 100644 index 0000000..c663839 --- /dev/null +++ b/test/jarvisActionClient.test.mjs @@ -0,0 +1,209 @@ +/* + * jarvisActionClient.test.mjs — the Iris-side half of Action Transport v2 + * (P2.5). + * + * The contract being pinned here is deliberately narrow: this client OWNS + * NOTHING. It holds no previewId list, no approval stage, no credentials — + * it resolves the running Jarvis backend's loopback endpoint, forwards the + * call, and returns what Jarvis said. Every failure mode (no Jarvis, dead + * port, rejected token, garbage response) must surface as an honest + * {ok:false, error}; none of them may ever look like a successful approval. + * + * Tests run against a real loopback http server, not a mocked fetch, because + * "did the bearer token actually go out on the wire" is the point. + */ +import assert from "node:assert/strict"; +import http from "node:http"; +import test from "node:test"; +import { createJarvisActionClient } from "../electron/jarvisActionClient.mjs"; + +const TOKEN = "test-token-0123456789abcdef"; + +// Records what actually arrived, so the assertions are about the wire, not +// about an argument we passed to a stub. +async function withEndpoint(handler, body) { + const received = []; + const server = http.createServer((request, response) => { + const chunks = []; + request.on("data", (chunk) => chunks.push(chunk)); + request.on("end", () => { + const entry = { + method: request.method, + url: request.url, + authorization: request.headers.authorization, + origin: request.headers.origin, + body: Buffer.concat(chunks).toString("utf8"), + }; + received.push(entry); + handler(entry, response); + }); + }); + await new Promise((resolve) => server.listen({ host: "127.0.0.1", port: 0 }, resolve)); + const { port } = server.address(); + const endpoint = { url: `http://127.0.0.1:${port}`, token: TOKEN, pid: 1, publishedAt: "2026-08-22T10:00:00.000Z" }; + try { + return await body({ endpoint, received, port }); + } finally { + server.closeAllConnections?.(); + await new Promise((resolve) => server.close(resolve)); + } +} + +function respondJson(response, payload, status = 200) { + const text = JSON.stringify(payload); + response.writeHead(status, { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(text) }); + response.end(text); +} + +test("proposeAction posts the question to the running Jarvis backend and returns its answer verbatim", async () => { + await withEndpoint( + (_entry, response) => respondJson(response, { ok: true, kind: "preview", previews: [{ previewId: "p1", title: "Rechnung prüfen", riskLevel: "low" }] }), + async ({ endpoint, received }) => { + const client = createJarvisActionClient({ readEndpoint: () => endpoint }); + const result = await client.proposeAction("Notiere: Rechnung prüfen", { source: "text" }); + assert.deepEqual(result, { ok: true, kind: "preview", previews: [{ previewId: "p1", title: "Rechnung prüfen", riskLevel: "low" }] }); + assert.equal(received[0].method, "POST"); + assert.equal(received[0].url, "/action/propose"); + assert.equal(received[0].authorization, `Bearer ${TOKEN}`); + assert.deepEqual(JSON.parse(received[0].body), { question: "Notiere: Rechnung prüfen", source: "text" }); + }, + ); +}); + +test("approve/secondaryApprove/cancel each hit their own route with the previewId and the bearer token", async () => { + await withEndpoint( + (_entry, response) => respondJson(response, { ok: true }), + async ({ endpoint, received }) => { + const client = createJarvisActionClient({ readEndpoint: () => endpoint }); + await client.approveAction("p1"); + await client.secondaryApproveAction("p2"); + await client.cancelAction("p3"); + assert.deepEqual(received.map((entry) => entry.url), ["/action/approve", "/action/secondary-approve", "/action/cancel"]); + assert.deepEqual(received.map((entry) => JSON.parse(entry.body).previewId), ["p1", "p2", "p3"]); + assert.ok(received.every((entry) => entry.authorization === `Bearer ${TOKEN}`)); + }, + ); +}); + +// Jarvis's endpoint refuses any request carrying an Origin (see +// action-bridge-server.cjs). Iris must therefore never send one. +test("no browser Origin header is ever sent", async () => { + await withEndpoint( + (_entry, response) => respondJson(response, { ok: true }), + async ({ endpoint, received }) => { + await createJarvisActionClient({ readEndpoint: () => endpoint }).approveAction("p1"); + assert.equal(received[0].origin, undefined); + }, + ); +}); + +// Jarvis restarts on a NEW ephemeral port with a NEW token. A client that +// cached the first descriptor would keep talking to a dead port forever. +test("the endpoint descriptor is re-read for every call, so a restarted Jarvis is followed", async () => { + await withEndpoint( + (_entry, response) => respondJson(response, { ok: true }), + async ({ endpoint, received }) => { + let reads = 0; + const client = createJarvisActionClient({ + readEndpoint: () => { reads += 1; return endpoint; }, + }); + await client.approveAction("p1"); + await client.approveAction("p2"); + assert.equal(reads, 2, "a cached descriptor would outlive the Jarvis process that published it"); + assert.equal(received.length, 2); + }, + ); +}); + +test("no published endpoint is an honest failure, never a fabricated approval", async () => { + const client = createJarvisActionClient({ + readEndpoint: () => null, + fetchImpl: () => { throw new Error("fetch must not be attempted without an endpoint"); }, + }); + for (const call of [ + () => client.proposeAction("Notiere: x"), + () => client.approveAction("p1"), + () => client.secondaryApproveAction("p1"), + () => client.cancelAction("p1"), + ]) { + const result = await call(); + assert.equal(result.ok, false); + assert.match(result.error, /Jarvis/i); + } +}); + +test("a dead port surfaces as ok:false instead of throwing into the IPC handler", async () => { + // The server is already closed again by the time the port is used. + const { port } = await withEndpoint( + (_entry, response) => respondJson(response, { ok: true }), + async (context) => context, + ); + const client = createJarvisActionClient({ + readEndpoint: () => ({ url: `http://127.0.0.1:${port}`, token: TOKEN }), + }); + const result = await client.approveAction("p1"); + assert.equal(result.ok, false); + assert.ok(result.error, "a refused connection must carry a reason"); +}); + +test("a rejected token surfaces as ok:false and never as a silent success", async () => { + await withEndpoint( + (_entry, response) => respondJson(response, { ok: false, error: "unauthorized" }, 401), + async ({ endpoint }) => { + const result = await createJarvisActionClient({ readEndpoint: () => endpoint }).approveAction("p1"); + assert.equal(result.ok, false); + assert.match(result.error, /401|unauthorized/i); + }, + ); +}); + +test("a non-JSON response surfaces as ok:false rather than crashing the caller", async () => { + await withEndpoint( + (_entry, response) => { response.writeHead(200, { "Content-Type": "text/html" }); response.end("nope"); }, + async ({ endpoint }) => { + const result = await createJarvisActionClient({ readEndpoint: () => endpoint }).approveAction("p1"); + assert.equal(result.ok, false); + assert.ok(result.error); + }, + ); +}); + +// An empty question is a UI-level mistake, not something to bother the +// backend (or the vault) with. +test("an empty question is refused locally without contacting Jarvis", async () => { + await withEndpoint( + (_entry, response) => respondJson(response, { ok: true }), + async ({ endpoint, received }) => { + const result = await createJarvisActionClient({ readEndpoint: () => endpoint }).proposeAction(" "); + assert.equal(result.ok, false); + assert.deepEqual(received, []); + }, + ); +}); + +test("a missing previewId is refused locally without contacting Jarvis", async () => { + await withEndpoint( + (_entry, response) => respondJson(response, { ok: true }), + async ({ endpoint, received }) => { + const client = createJarvisActionClient({ readEndpoint: () => endpoint }); + assert.equal((await client.approveAction("")).ok, false); + assert.equal((await client.secondaryApproveAction(null)).ok, false); + assert.equal((await client.cancelAction(undefined)).ok, false); + assert.deepEqual(received, []); + }, + ); +}); + +// A Jarvis that accepted the request but never answers must not wedge the +// Iris renderer's approval button forever. +test("a hanging backend is abandoned after the timeout with an honest error", async () => { + await withEndpoint( + () => { /* never responds */ }, + async ({ endpoint }) => { + const client = createJarvisActionClient({ readEndpoint: () => endpoint, timeoutMs: 150 }); + const result = await client.approveAction("p1"); + assert.equal(result.ok, false); + assert.ok(result.error); + }, + ); +}); From 6cfb78ca0e5c3bb0ad756bf26b292ed3fc35e820 Mon Sep 17 00:00:00 2001 From: Cengiz Demirel Date: Sat, 22 Aug 2026 23:48:10 +0200 Subject: [PATCH 07/10] feat(jarvis): support packaged Jarvis runtime Replace the sibling-source-checkout resolution in jarvisBridgeClient.mjs and jarvisActionClient.mjs with jarvisEndpoint.mjs, a resolver that finds Jarvis's loopback Action endpoint from the OS Application Support dir instead of a Jarvis-Desktop source directory. A packaged Iris.app has no such directory (repoRoot is inside app.asar), so Ask Jarvis, Connections Status and every Personal OS read were silently dead in the packaged app while working in dev. Reads now travel the same loopback endpoint, token and transport as the existing P2.5 write actions. Adds scripts/test-packaged-runtime-smoke.mjs, an end-to-end smoke against the real packaged Iris.app + Jarvis.app binaries (one window, one headless backend, Connections Status, Ask Jarvis, Action/Approval/Result, clean teardown), isolated to a throwaway vault/audit log/runtime dir. Co-Authored-By: Claude Sonnet 5 --- electron/jarvisActionClient.mjs | 114 ++------ electron/jarvisBridgeClient.mjs | 97 ++++--- electron/jarvisEndpoint.mjs | 174 +++++++++++ electron/main.mjs | 50 ++-- package.json | 1 + scripts/test-packaged-runtime-smoke.mjs | 368 ++++++++++++++++++++++++ test/jarvisBridgeClient.test.mjs | 135 ++++----- test/jarvisEndpoint.test.mjs | 195 +++++++++++++ 8 files changed, 931 insertions(+), 203 deletions(-) create mode 100644 electron/jarvisEndpoint.mjs create mode 100644 scripts/test-packaged-runtime-smoke.mjs create mode 100644 test/jarvisEndpoint.test.mjs diff --git a/electron/jarvisActionClient.mjs b/electron/jarvisActionClient.mjs index 58800ab..4ab0a8e 100644 --- a/electron/jarvisActionClient.mjs +++ b/electron/jarvisActionClient.mjs @@ -25,16 +25,13 @@ * renderer can show it. None of them may ever look like a successful * approval. */ -import { createRequire } from "node:module"; -import path from "node:path"; -import fs from "node:fs"; - -const defaultRequire = createRequire(import.meta.url); - -// 10s is generous for a local vault write and still short enough that a -// wedged backend never leaves an approval button spinning forever. Drive/ -// Calendar executions go through Jarvis's own already-bounded HTTP calls. -const DEFAULT_TIMEOUT_MS = 10000; +import { + createEndpointRequest, + createJarvisEndpointReader, + defaultActionEndpointPath, + DEFAULT_TIMEOUT_MS, + NO_BACKEND_ERROR, +} from "./jarvisEndpoint.mjs"; const ROUTES = Object.freeze({ propose: "/action/propose", @@ -43,46 +40,26 @@ const ROUTES = Object.freeze({ cancel: "/action/cancel", }); -const NO_BACKEND_ERROR = "Jarvis-Backend läuft nicht — keine Action möglich."; - -/** Mirrors defaultJarvisBridgePath() in jarvisBridgeClient.mjs, one file over. */ -export function defaultActionEndpointStorePath(repoRoot) { - if (process.env.JARVIS_ACTION_ENDPOINT_STORE) return process.env.JARVIS_ACTION_ENDPOINT_STORE; - return path.resolve(repoRoot, "..", "Jarvis-Desktop", "app", "action-endpoint-store.cjs"); -} - /** - * loadActionEndpointReader(repoRoot) -> () => endpoint|null + * loadActionEndpointReader() -> () => endpoint|null * - * Deliberately require()s JARVIS's own action-endpoint-store.cjs rather than - * re-deriving the descriptor path and re-implementing its validation here: - * that module is pure fs+JSON (no action state, no credentials, no approval - * logic), so reusing it keeps ONE path rule and ONE validation rule across - * both processes. A missing Jarvis checkout degrades to "no endpoint" — - * never a crash, never a guessed path. + * P2.6: this used to require() JARVIS's own action-endpoint-store.cjs out of + * a sibling source checkout, to keep ONE path rule across both processes. + * That reasoning was right and the mechanism was wrong: a packaged Iris.app + * has no Jarvis checkout to require(), so approvals were dead in the very + * build that matters. The shared rule now lives in jarvisEndpoint.mjs, which + * derives the SAME descriptor path from the OS Application Support dir (and + * honors the same JARVIS_ENGINEERING_DIR override Jarvis honors) without + * touching a single Jarvis file. */ -export function loadActionEndpointReader( - repoRoot, - { requireFn = defaultRequire, existsFn = fs.existsSync, onUnavailable = () => {} } = {}, -) { - const storeModulePath = defaultActionEndpointStorePath(repoRoot); - if (!existsFn(storeModulePath)) { - onUnavailable("missing"); - return () => null; - } - try { - const { readActionEndpoint, DEFAULT_ACTION_ENDPOINT_PATH } = requireFn(storeModulePath); - return () => { - try { - return readActionEndpoint({ storePath: DEFAULT_ACTION_ENDPOINT_PATH }); - } catch { - return null; - } - }; - } catch { - onUnavailable("require-error"); - return () => null; - } +export function loadActionEndpointReader({ onUnavailable = () => {} } = {}) { + const storePath = defaultActionEndpointPath(); + const read = createJarvisEndpointReader({ storePath }); + return () => { + const endpoint = read(); + if (!endpoint) onUnavailable("no-endpoint"); + return endpoint; + }; } function failure(error) { @@ -101,44 +78,11 @@ export function createJarvisActionClient({ fetchImpl = globalThis.fetch, timeoutMs = DEFAULT_TIMEOUT_MS, } = {}) { - async function send(route, payload) { - let endpoint = null; - try { - endpoint = readEndpoint?.(); - } catch { - endpoint = null; - } - if (!endpoint?.url || !endpoint?.token) return failure(NO_BACKEND_ERROR); - - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - try { - const response = await fetchImpl(`${endpoint.url}${route}`, { - method: "POST", - // No Origin header: Jarvis's endpoint refuses any request that - // carries one (that is its guard against local browser pages). - headers: { "content-type": "application/json", authorization: `Bearer ${endpoint.token}` }, - body: JSON.stringify(payload), - signal: controller.signal, - }); - let body; - try { - body = await response.json(); - } catch { - return failure(`Jarvis-Backend antwortete unlesbar (HTTP ${response.status}).`); - } - if (!response.ok) { - return failure(body?.error ? `${body.error} (HTTP ${response.status})` : `Jarvis-Backend antwortete HTTP ${response.status}.`); - } - return body; - } catch (error) { - return failure(error?.name === "AbortError" - ? `Jarvis-Backend hat nicht innerhalb von ${timeoutMs} ms geantwortet.` - : String(error?.message ?? error)); - } finally { - clearTimeout(timer); - } - } + // The transport (bearer token, the deliberately absent Origin header, the + // timeout, the error shape) lives in jarvisEndpoint.mjs and is shared with + // the read client — one implementation, so a security property can never + // hold on one path and not the other. + const send = createEndpointRequest({ readEndpoint, fetchImpl, timeoutMs }); function withPreviewId(route, previewId) { const id = typeof previewId === "string" ? previewId.trim() : ""; diff --git a/electron/jarvisBridgeClient.mjs b/electron/jarvisBridgeClient.mjs index 8a7b6db..640be01 100644 --- a/electron/jarvisBridgeClient.mjs +++ b/electron/jarvisBridgeClient.mjs @@ -2,49 +2,80 @@ * Jarvis Bridge client — the Iris-side half of the thin adapter between * Iris's voice pipeline and Jarvis's existing Ask Jarvis pipeline. * - * Jarvis and Iris are two separate local Electron apps/processes. There is - * no cross-process transport built for this round, so this module reaches - * Jarvis's already-shipped, already-tested askJarvis() the most direct way - * available: a plain Node require() of Jarvis's own adapter module - * (Jarvis-Desktop/app/adapter/iris-bridge.cjs), loaded in-process from - * Iris's Electron main process. This executes Jarvis's real code, unmodified - * and unduplicated — never a second retrieval/model/memory pipeline. + * HOW THIS USED TO WORK, AND WHY IT CHANGED (P2.6). Jarvis and Iris are two + * separate local Electron apps. This module used to reach Jarvis's + * already-shipped askJarvis() with a plain Node require() of Jarvis's adapter + * (Jarvis-Desktop/app/adapter/iris-bridge.cjs) IN THE IRIS MAIN PROCESS. + * Two things were wrong with that: + * + * 1. It only ever resolved from a SOURCE CHECKOUT. A packaged Iris.app has + * no sibling Jarvis-Desktop directory, so every read — Ask Jarvis, + * Connections Status, Work Stream, jobs, goals — failed there while + * working perfectly in dev. + * 2. It booted a SECOND in-process Jarvis runtime inside Iris. Reads touch + * no approval state, so that was survivable, but it was still a second + * retrieval/model/memory pipeline in the wrong process — and one with no + * access to Jarvis's safeStorage/Keychain-scoped credentials. + * + * Reads now travel the SAME loopback endpoint the write actions already use + * (jarvisActionClient.mjs, Jarvis's action-bridge-server.cjs): one running + * Jarvis backend process, one bearer token, one pipeline. Iris requires no + * Jarvis file at all any more. + * + * WHAT DID NOT CHANGE. Every *ForRenderer function below keeps its exact + * never-throws, forward-{ok,...}-as-is contract, and the bridge object they + * take still exposes the same method names as Jarvis's own bridge — so the + * IPC contract, the renderer and the security boundary are untouched. */ -import { createRequire } from "node:module"; -import path from "node:path"; -import fs from "node:fs"; import { decideTurnOwner } from "./routingPolicy.mjs"; +import { createEndpointRequest } from "./jarvisEndpoint.mjs"; -const defaultRequire = createRequire(import.meta.url); +/* Ask Jarvis runs real retrieval and a model call, so it needs far more room + * than an approval round-trip. Still bounded: a wedged backend must surface + * as an honest error, never as a Comms bubble that spins forever. */ +const ASK_TIMEOUT_MS = 120_000; + +// The route table Jarvis's action-bridge-server.cjs serves (READ_BRIDGE_ROUTES +// there). Kept next to the calls so a rename on either side fails loudly in +// the smoke test rather than silently degrading to "Jarvis not available". +const READ_ROUTES = Object.freeze({ + ask: "/read/ask", + connectionsStatus: "/read/connections-status", + tasks: "/read/tasks", + topFocus: "/read/top-focus", + currentContext: "/read/current-context", + latestEngineeringJob: "/read/latest-engineering-job", + activeGoal: "/read/active-goal", +}); export function shouldAskJarvis(route) { return decideTurnOwner(route) === "jarvis"; } -export function defaultJarvisBridgePath(repoRoot) { - if (process.env.JARVIS_BRIDGE_PATH) return process.env.JARVIS_BRIDGE_PATH; - return path.resolve(repoRoot, "..", "Jarvis-Desktop", "app", "adapter", "iris-bridge.cjs"); +/** + * createJarvisBridge({ readEndpoint, request }) -> bridge + * + * Returns an object with the SAME method names as Jarvis's own in-process + * bridge, so every *ForRenderer function below (and their tests) stay + * unaware of the transport. It holds no state, no credentials and no cached + * endpoint: the descriptor is re-read per request, because a restarted + * Jarvis has a new port and a new token. + */ +export function createJarvisBridge({ readEndpoint, request } = {}) { + const send = request || createEndpointRequest({ readEndpoint }); + const readData = (route) => send(route, {}); + return { + askJarvis: (question) => send(READ_ROUTES.ask, { question }, { timeoutMs: ASK_TIMEOUT_MS }), + getConnectionsStatus: () => readData(READ_ROUTES.connectionsStatus), + getTasks: () => readData(READ_ROUTES.tasks), + getTopFocus: () => readData(READ_ROUTES.topFocus), + getCurrentContext: () => readData(READ_ROUTES.currentContext), + getLatestEngineeringJob: () => readData(READ_ROUTES.latestEngineeringJob), + getActiveGoal: () => readData(READ_ROUTES.activeGoal), + }; } -// Never throws: an unreachable/missing Jarvis checkout must degrade to "no -// bridge", not crash Iris's voice pipeline. -export function loadJarvisBridge( - repoRoot, - { requireFn = defaultRequire, existsFn = fs.existsSync, onUnavailable = () => {} } = {} -) { - const bridgePath = defaultJarvisBridgePath(repoRoot); - if (!existsFn(bridgePath)) { - onUnavailable("missing"); - return null; - } - try { - const { createIrisBridge } = requireFn(bridgePath); - return createIrisBridge(); - } catch { - onUnavailable("require-error"); - return null; - } -} +export { READ_ROUTES as JARVIS_READ_ROUTES, ASK_TIMEOUT_MS }; // Never throws, never returns a blank/empty answer as success — a Comms // bubble with no text would be a silent, confusing failure. Also the diff --git a/electron/jarvisEndpoint.mjs b/electron/jarvisEndpoint.mjs new file mode 100644 index 0000000..6238452 --- /dev/null +++ b/electron/jarvisEndpoint.mjs @@ -0,0 +1,174 @@ +/* + * jarvisEndpoint.mjs — how a PACKAGED Iris finds and talks to the one running + * Jarvis backend (P2.6). + * + * THE PROBLEM THIS SOLVES. jarvisBridgeClient.mjs and jarvisActionClient.mjs + * both used to locate Jarvis by walking to a SOURCE CHECKOUT: + * path.resolve(repoRoot, "..", "Jarvis-Desktop", "app", ...). In a packaged + * Iris.app repoRoot is /Contents/Resources/app.asar, so that path + * points at /Contents/Resources/Jarvis-Desktop — a directory that + * does not exist. Every read and every approval failed in the packaged app + * while working perfectly in dev. Iris now depends on no Jarvis file at all. + * + * WHAT REPLACES IT. Jarvis publishes a descriptor (url + per-process bearer + * token) for its loopback endpoint into its own runtime dir. That file is a + * CROSS-PROCESS CONTRACT, the same way a socket path is: Jarvis writes it + * (action-endpoint-store.cjs), Iris reads it, and both derive its location + * from the OS Application Support dir rather than from each other's code. So + * it resolves identically whether either side runs from source or a bundle. + * + * WHY IRIS RE-VALIDATES THE URL. Jarvis refuses to PUBLISH a non-loopback + * descriptor. Iris independently refuses to USE one. That is not duplicated + * logic but a second, load-bearing check on the side that holds the secret: + * whatever wrote that file must never be able to make Iris POST a bearer + * token off-machine. + * + * Never throws. Every failure — no Jarvis running, a stale port, a rejected + * token, a garbage response, a hang — comes back as {ok:false, error} so the + * renderer can show it. None may ever look like success. + */ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// Long enough for a real local vault write or a bounded Drive/Calendar call, +// short enough that a wedged backend never spins an approval button forever. +const DEFAULT_TIMEOUT_MS = 10_000; + +// Only these hosts may ever be dialled — mirrors the LOOPBACK_HOSTS set +// Jarvis refuses to publish outside of. +const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]", "::1"]); + +const NO_BACKEND_ERROR = "Jarvis-Backend läuft nicht — keine Verbindung möglich."; + +/** The OS default Electron itself uses for appData; recomputed here so Iris + * never has to ask Jarvis's Electron instance where its profile lives. */ +function applicationSupportDir(platform, home, env) { + if (platform === "darwin") return path.join(home, "Library", "Application Support"); + if (platform === "win32") return env.APPDATA || path.join(home, "AppData", "Roaming"); + return env.XDG_CONFIG_HOME || path.join(home, ".config"); +} + +/** + * defaultActionEndpointPath(env, platform, home) -> absolute descriptor path. + * + * JARVIS_ACTION_ENDPOINT names the descriptor outright; JARVIS_ENGINEERING_DIR + * is the SAME override Jarvis's engineering-runtime-paths.cjs honors, so a + * smoke test can point both processes at one throwaway directory with a + * single variable and never touch the real runtime dir. + */ +export function defaultActionEndpointPath(env = process.env, platform = process.platform, home = os.homedir()) { + if (env.JARVIS_ACTION_ENDPOINT) return env.JARVIS_ACTION_ENDPOINT; + const runtimeDir = env.JARVIS_ENGINEERING_DIR + || path.join(applicationSupportDir(platform, home, env), "Jarvis", "jarvis-engineering"); + return path.join(runtimeDir, "action-endpoint.json"); +} + +function isLoopbackUrl(value) { + try { + return LOOPBACK_HOSTS.has(new URL(value).hostname); + } catch { + return false; + } +} + +function isValidEndpoint(endpoint) { + return Boolean( + endpoint + && typeof endpoint === "object" + && !Array.isArray(endpoint) + && typeof endpoint.url === "string" + && endpoint.url + && typeof endpoint.token === "string" + && endpoint.token + && isLoopbackUrl(endpoint.url), + ); +} + +/** + * readJarvisEndpoint({ storePath }) -> descriptor | null + * + * null means "no reachable Jarvis backend" and must be surfaced as an honest + * error by the caller — never as a fabricated endpoint or an empty success. + */ +export function readJarvisEndpoint({ storePath = defaultActionEndpointPath() } = {}) { + let parsed; + try { + if (!fs.existsSync(storePath)) return null; + parsed = JSON.parse(fs.readFileSync(storePath, "utf8")); + } catch { + return null; + } + return isValidEndpoint(parsed) ? parsed : null; +} + +/** + * createJarvisEndpointReader({ storePath }) -> () => descriptor | null + * + * Deliberately re-reads on every call and caches nothing: Jarvis restarts on + * a new ephemeral port with a new token, and a cached descriptor would + * outlive the process that published it. + */ +export function createJarvisEndpointReader({ storePath = defaultActionEndpointPath() } = {}) { + return () => readJarvisEndpoint({ storePath }); +} + +function failure(error) { + return { ok: false, error: String(error) }; +} + +/** + * createEndpointRequest({ readEndpoint, fetchImpl, timeoutMs }) + * -> request(route, payload, { timeoutMs }) -> Jarvis's body | {ok:false, error} + * + * The ONE place Iris speaks to the Jarvis backend. Both the read client and + * the action client go through it, so the token handling, the deliberate + * absence of an Origin header (Jarvis refuses any request carrying one), the + * timeout behaviour and the error shape exist exactly once. + */ +export function createEndpointRequest({ + readEndpoint, + fetchImpl = globalThis.fetch, + timeoutMs: defaultTimeoutMs = DEFAULT_TIMEOUT_MS, +} = {}) { + return async function request(route, payload, { timeoutMs = defaultTimeoutMs } = {}) { + let endpoint = null; + try { + endpoint = readEndpoint?.(); + } catch { + endpoint = null; + } + if (!endpoint?.url || !endpoint?.token) return failure(NO_BACKEND_ERROR); + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetchImpl(`${endpoint.url}${route}`, { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${endpoint.token}` }, + body: JSON.stringify(payload ?? {}), + signal: controller.signal, + }); + let body; + try { + body = await response.json(); + } catch { + return failure(`Jarvis-Backend antwortete unlesbar (HTTP ${response.status}).`); + } + if (!response.ok) { + return failure(body?.error + ? `${body.error} (HTTP ${response.status})` + : `Jarvis-Backend antwortete HTTP ${response.status}.`); + } + return body; + } catch (error) { + return failure(error?.name === "AbortError" + ? `Jarvis-Backend hat nicht innerhalb von ${timeoutMs} ms geantwortet.` + : String(error?.message ?? error)); + } finally { + clearTimeout(timer); + } + }; +} + +export { DEFAULT_TIMEOUT_MS, NO_BACKEND_ERROR }; diff --git a/electron/main.mjs b/electron/main.mjs index e573295..35a4e84 100644 --- a/electron/main.mjs +++ b/electron/main.mjs @@ -2,7 +2,7 @@ import electron from "electron"; import { GoogleGenAI } from "@google/genai"; import { shouldAskJarvis, - loadJarvisBridge, + createJarvisBridge, askJarvisForTurn, describeSmokeTranscript, getTasksForRenderer, @@ -338,18 +338,41 @@ function isInternalSystemTranscript(text) { // conversational reply (voice + its own memory tools) is NOT suppressed; // see the integration report for why that overlap is a documented, not yet // resolved, risk rather than something silently fixed here. -let cachedJarvisBridge; -function jarvisBridge() { - if (cachedJarvisBridge === undefined) { - cachedJarvisBridge = loadJarvisBridge(repoRoot, { - onUnavailable: (reason) => +// +// P2.6: the bridge no longer require()s Jarvis's adapter out of a sibling +// source checkout (which a packaged Iris.app does not have and which ran a +// second Jarvis runtime inside this process). Reads travel the SAME loopback +// endpoint as the write actions below — one running Jarvis backend, one +// pipeline — through the shared descriptor reader. +// +// One reader for BOTH halves: the descriptor is re-read per request (a +// restarted Jarvis has a new port and a new token), but the "no Jarvis +// running" warning is logged only once so a backend that never came up +// cannot flood the event log on every panel refresh. +let cachedEndpointReader; +function jarvisEndpointReader() { + if (cachedEndpointReader === undefined) { + let warned = false; + cachedEndpointReader = loadActionEndpointReader({ + onUnavailable: (reason) => { + if (warned) return; + warned = true; emitEvent({ type: "log", level: "warn", - message: `Jarvis Bridge unavailable (${reason}); Ask Jarvis relay disabled this session.`, - }), + message: `Jarvis backend endpoint unavailable (${reason}); Ask Jarvis, Connections Status and approvals are disabled until it publishes one.`, + }); + }, }); } + return cachedEndpointReader; +} + +let cachedJarvisBridge; +function jarvisBridge() { + if (cachedJarvisBridge === undefined) { + cachedJarvisBridge = createJarvisBridge({ readEndpoint: jarvisEndpointReader() }); + } return cachedJarvisBridge; } @@ -366,16 +389,7 @@ function jarvisBridge() { let cachedJarvisActionClient; function jarvisActionClient() { if (cachedJarvisActionClient === undefined) { - cachedJarvisActionClient = createJarvisActionClient({ - readEndpoint: loadActionEndpointReader(repoRoot, { - onUnavailable: (reason) => - emitEvent({ - type: "log", - level: "warn", - message: `Jarvis Action endpoint unavailable (${reason}); approvals disabled this session.`, - }), - }), - }); + cachedJarvisActionClient = createJarvisActionClient({ readEndpoint: jarvisEndpointReader() }); } return cachedJarvisActionClient; } diff --git a/package.json b/package.json index e743467..019b225 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "test:hermes-wake": "npm run build && node scripts/test-live-hermes-wake.mjs", "test:jarvis-bridge-smoke": "npm run build && node scripts/test-jarvis-bridge-smoke.mjs", "test:jarvis-action-smoke": "npm run build && node scripts/test-jarvis-action-smoke.mjs", + "test:packaged-runtime-smoke": "node scripts/test-packaged-runtime-smoke.mjs", "soak": "node scripts/soak-runtime.mjs", "preview": "vite preview", "start": "npm run build && node scripts/run-electron.mjs --prod", diff --git a/scripts/test-packaged-runtime-smoke.mjs b/scripts/test-packaged-runtime-smoke.mjs new file mode 100644 index 0000000..03525ae --- /dev/null +++ b/scripts/test-packaged-runtime-smoke.mjs @@ -0,0 +1,368 @@ +/* + * P2.6 end-to-end smoke: the PACKAGED runtime. + * + * P2.5's smoke (test-jarvis-action-smoke.mjs) proves the Iris -> Jarvis flow + * from SOURCE. This one proves the thing a user actually installs, and it is + * a different claim in every step that matters: + * + * - a real packaged Iris.app binary (not electron + electron/main.mjs), + * - loading its bundled renderer from file://, with NO Vite dev server, + * - which starts a real packaged Jarvis.app HEADLESS as its backend, + * - exactly one Jarvis backend process, owned by this Iris session, + * - Connections Status produced BY THAT BACKEND (proven by the snapshot + * file the Jarvis process itself wrote), + * - a real Ask Jarvis answer over the loopback read endpoint, + * - a real Action -> Approval -> write-inside-Jarvis -> Result in Iris, + * - and a clean teardown: quitting Iris takes the Jarvis process with it. + * + * WHY IT EXISTS. Before P2.6 both Iris clients located Jarvis by walking to a + * sibling SOURCE CHECKOUT (path.resolve(repoRoot, "..", "Jarvis-Desktop")). + * In a packaged Iris.app repoRoot is /Contents/Resources/app.asar, + * so that directory does not exist and every read and every approval failed — + * in dev only, everything looked perfect. A source-mode smoke could never + * have caught that. This one fails loudly if it ever comes back. + * + * SAFETY — read before changing anything below. + * An approved Personal OS action performs a REAL file write. The vault, the + * engineering runtime dir and the action audit log are all redirected to a + * throwaway temp tree, and assertThrowawayPaths() asks the PACKAGED JARVIS + * BINARY ITSELF (via ELECTRON_RUN_AS_NODE) where it would write under exactly + * the environment this script is about to hand it. It ABORTS BEFORE ANYTHING + * LAUNCHES unless every answer points inside the temp tree. The user's real + * Obsidian vault and the production audit log are additionally re-verified as + * untouched at the end — a positive assertion, not trust in an override. + * + * Prerequisites (both are asserted before launch): + * Jarvis: cd ../Jarvis-Desktop/app && pnpm dist:mac + * Iris: npm run package:mac + * + * Run: npm run test:packaged-runtime-smoke + */ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { _electron as electron } from "playwright-core"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const failureDir = path.join(root, "test-results"); +const failureScreenshot = path.join(failureDir, "packaged-runtime-smoke-failure.png"); + +const IRIS_APP = process.env.IRIS_APP_PATH || path.join(root, "release", "mac-arm64", "Iris.app"); +const IRIS_BINARY = path.join(IRIS_APP, "Contents", "MacOS", "Iris"); +const JARVIS_APP = process.env.JARVIS_APP_PATH + || path.resolve(root, "..", "Jarvis-Desktop", "app", "dist-electron", "mac-arm64", "Jarvis.app"); +const JARVIS_BINARY = path.join(JARVIS_APP, "Contents", "MacOS", "Jarvis"); +const JARVIS_ASAR = path.join(JARVIS_APP, "Contents", "Resources", "app.asar"); + +const REAL_VAULT = "/Users/cd/Documents/Obsidian-Mind/Cengiz-Mind"; +const REQUEST = "Notiere: P2.6 Packaged Smoke Aufgabe"; +const EXPECTED_NOTE = "00 Inbox/Capture/P2.6 Packaged Smoke Aufgabe.md"; +const ASK_QUESTION = "Wie heißt du?"; + +const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "jarvis-packaged-smoke-")); +const vaultPath = path.join(tmpRoot, "vault"); +const engineeringDir = path.join(tmpRoot, "engineering"); +const auditPath = path.join(tmpRoot, "action-audit.jsonl"); +fs.mkdirSync(vaultPath, { recursive: true }); +fs.mkdirSync(engineeringDir, { recursive: true }); + +const PRODUCTION_AUDIT_PATH = path.join(os.homedir(), "Library", "Application Support", "Jarvis", "action-audit.jsonl"); +const productionAuditSizeBefore = fs.existsSync(PRODUCTION_AUDIT_PATH) ? fs.statSync(PRODUCTION_AUDIT_PATH).size : null; + +const env = { + ...process.env, + IRIS_WAKE_WORD: "false", + IRIS_HERMES_AUTOSTART: "false", + IRIS_TEST_HOOKS: "1", + // Deliberately NOT set: IRIS_START_PROD. A packaged app must reach prod + // mode through app.isPackaged alone — if it needed the flag, it would be + // relying on a dev-mode escape hatch that no installed app ever gets. + // + // Both processes get these: Iris inherits them, and the Jarvis backend Iris + // spawns inherits them again (jarvisBackend.mjs passes no env of its own), + // so producer and consumer resolve the same isolated locations. + JARVIS_VAULT_PATH: vaultPath, + JARVIS_ENGINEERING_DIR: engineeringDir, + JARVIS_ACTION_AUDIT_PATH: auditPath, + // Pin the backend to the FRESHLY BUILT bundle instead of whatever happens + // to sit in /Applications, so this smoke tests the code under review. The + // no-env default (/Applications/Jarvis.app) is asserted separately below. + JARVIS_APP_PATH: JARVIS_APP, +}; +delete env.ELECTRON_RUN_AS_NODE; + +function fail(message) { + throw new Error(message); +} + +/** Ask the PACKAGED Jarvis binary itself — not this script's assumptions, and + * not the source checkout — where it would write under `env`. */ +function resolveInPackagedJarvis(expression) { + return execFileSync( + JARVIS_BINARY, + ["-e", `process.stdout.write(String(${expression}))`], + { env: { ...env, ELECTRON_RUN_AS_NODE: "1" }, encoding: "utf8" }, + ); +} + +function assertBuildsExist() { + if (!fs.existsSync(IRIS_BINARY)) fail(`ABORT: no packaged Iris at ${IRIS_BINARY}. Run: npm run package:mac`); + if (!fs.existsSync(JARVIS_BINARY)) fail(`ABORT: no packaged Jarvis at ${JARVIS_BINARY}. Run: cd ../Jarvis-Desktop/app && pnpm dist:mac`); + console.log(`GUARD OK: packaged Iris ${IRIS_BINARY}`); + console.log(`GUARD OK: packaged Jarvis ${JARVIS_BINARY}`); +} + +function assertThrowawayPaths() { + const vault = resolveInPackagedJarvis(`require(${JSON.stringify(path.join(JARVIS_ASAR, "personal-os-reader.cjs"))}).DEFAULT_VAULT`); + if (vault === REAL_VAULT || vault.startsWith(`${REAL_VAULT}/`)) { + fail(`ABORT: the packaged Jarvis would write into the REAL Obsidian vault (${vault}). Refusing to run.`); + } + if (path.resolve(vault) !== path.resolve(vaultPath)) { + fail(`ABORT: the packaged Jarvis resolved its vault to ${vault}, expected the throwaway ${vaultPath}.`); + } + + const runtimeDir = resolveInPackagedJarvis(`require(${JSON.stringify(path.join(JARVIS_ASAR, "engineering-runtime-paths.cjs"))}).defaultEngineeringRuntimeDir()`); + if (path.resolve(runtimeDir) !== path.resolve(engineeringDir)) { + fail(`ABORT: the packaged Jarvis resolved its runtime dir to ${runtimeDir}, expected ${engineeringDir}.`); + } + + const audit = resolveInPackagedJarvis(`require(${JSON.stringify(path.join(JARVIS_ASAR, "personal-os-action-service.cjs"))}).DEFAULT_ACTION_AUDIT_PATH`); + if (path.resolve(audit) === path.resolve(PRODUCTION_AUDIT_PATH)) { + fail(`ABORT: the packaged Jarvis would append to the REAL production audit log (${audit}). Refusing to run.`); + } + if (path.resolve(audit) !== path.resolve(auditPath)) { + fail(`ABORT: the packaged Jarvis resolved its audit log to ${audit}, expected the throwaway ${auditPath}.`); + } + console.log(`GUARD OK: packaged Jarvis writes into throwaway vault ${vault}`); + console.log(`GUARD OK: packaged Jarvis appends to throwaway audit log ${audit}`); +} + +/** The default (no JARVIS_APP_PATH) resolution a real installed Iris uses. It + * must NOT fall back to a source checkout — that regression is the entire + * reason P2.6 exists. */ +async function assertPackagedDefaultResolution() { + const { resolveJarvisLauncher } = await import(path.join(root, "electron", "jarvisBackend.mjs")); + const packagedRepoRoot = path.join(IRIS_APP, "Contents", "Resources", "app.asar"); + const launcher = resolveJarvisLauncher({ repoRoot: packagedRepoRoot, env: {}, platform: "darwin" }); + if (!launcher) fail("ABORT: a packaged Iris resolves NO Jarvis launcher at all."); + if (launcher.mode !== "packaged") { + fail(`ABORT: a packaged Iris resolved a '${launcher.mode}' launcher (${launcher.command}) — it must never depend on a source checkout.`); + } + console.log(`PASS 0/8: a packaged Iris resolves Jarvis in '${launcher.mode}' mode (${launcher.command}), never a source checkout`); +} + +async function waitFor(label, predicate, timeoutMs = 30000, intervalMs = 250) { + const deadline = Date.now() + timeoutMs; + for (;;) { + const value = await predicate(); + if (value) return value; + if (Date.now() > deadline) fail(`Timed out after ${timeoutMs}ms waiting for: ${label}`); + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } +} + +function processArgs(pid) { + try { + return execFileSync("/bin/ps", ["-p", String(pid), "-o", "args="], { encoding: "utf8" }).trim(); + } catch { + return ""; + } +} + +/** Every live process whose executable is the packaged Jarvis MAIN binary. + * Electron's helpers have their own distinct binaries, so this counts backend + * processes only. */ +function jarvisMainProcessPids() { + try { + const out = execFileSync("/usr/bin/pgrep", ["-f", `^${JARVIS_BINARY}`], { encoding: "utf8" }); + return out.split(/\s+/).filter(Boolean).map(Number); + } catch { + return []; + } +} + +assertBuildsExist(); +assertThrowawayPaths(); +await assertPackagedDefaultResolution(); + +const strayBefore = jarvisMainProcessPids(); +if (strayBefore.length) { + fail(`ABORT: a packaged Jarvis (pid ${strayBefore.join(", ")}) is already running. Quit it first — this smoke must own the only backend.`); +} + +const app = await electron.launch({ + executablePath: IRIS_BINARY, + args: [`--user-data-dir=${path.join(tmpRoot, "iris-user-data")}`], + cwd: root, + env, +}); + +let backendPid = null; + +try { + const page = await app.firstWindow(); + await page.waitForSelector(".deck", { timeout: 60000 }); + + // 1. A real packaged shell: exactly one window, rendered from the bundle, + // with no dev server anywhere in the picture. + const windows = app.windows(); + if (windows.length !== 1) fail(`Expected exactly one Iris window, got ${windows.length}.`); + const pageUrl = page.url(); + if (!pageUrl.startsWith("file://")) fail(`Iris is not running from its bundle — window URL is ${pageUrl}.`); + if (/localhost|127\.0\.0\.1:\d+/.test(pageUrl)) fail(`Iris is being served by a dev server: ${pageUrl}`); + if (!pageUrl.includes("app.asar")) fail(`Iris did not load its packaged renderer (${pageUrl}).`); + console.log(`PASS 1/8: exactly one Iris window, loaded from the bundle (${pageUrl})`); + + // 2. The backend really came up, headless, as the packaged Jarvis — and + // there is exactly one of it. + const endpointPath = path.join(engineeringDir, "action-endpoint.json"); + const endpoint = await waitFor( + "the packaged Jarvis backend to publish its loopback endpoint descriptor", + () => (fs.existsSync(endpointPath) ? JSON.parse(fs.readFileSync(endpointPath, "utf8")) : null), + 90000, + ); + backendPid = endpoint.pid; + if (!/^http:\/\/127\.0\.0\.1:\d+$/.test(endpoint.url)) fail(`The endpoint is not loopback: ${endpoint.url}`); + if (!endpoint.token) fail("The endpoint descriptor carries no token."); + if ((fs.statSync(endpointPath).mode & 0o077) !== 0) fail("The endpoint descriptor is group/world readable."); + if (endpoint.pid === process.pid) fail("The endpoint must be owned by the Jarvis process, not this script."); + + const backendArgs = processArgs(backendPid); + if (!backendArgs.startsWith(JARVIS_BINARY)) { + fail(`The backend is not the packaged Jarvis binary: ${backendArgs}`); + } + if (!backendArgs.includes("--headless-backend")) { + fail(`The backend was not started headless (no window suppression): ${backendArgs}`); + } + const running = jarvisMainProcessPids(); + if (running.length !== 1 || running[0] !== backendPid) { + fail(`Expected exactly one Jarvis backend process (${backendPid}), found: ${running.join(", ") || "none"}.`); + } + console.log(`PASS 2/8: exactly one packaged Jarvis backend (pid ${backendPid}), headless, on ${endpoint.url}`); + + // 3. Connections Status comes FROM THE BACKEND. The proof is not that Iris + // rendered something — it is that the Jarvis process wrote the snapshot + // file itself, and that Iris's own renderer API returns exactly it. + const snapshotPath = path.join(engineeringDir, "connections-status.json"); + const snapshot = await waitFor( + "the Jarvis backend to publish a Connections Status snapshot", + () => (fs.existsSync(snapshotPath) ? JSON.parse(fs.readFileSync(snapshotPath, "utf8")) : null), + 60000, + ); + if (!Array.isArray(snapshot.connections) || snapshot.connections.length === 0) { + fail(`The backend published an empty Connections Status snapshot: ${JSON.stringify(snapshot)}`); + } + const fromRenderer = await page.evaluate(() => window.iris.getJarvisConnectionsStatus()); + if (!fromRenderer?.ok) fail(`Iris could not read Connections Status from the backend: ${JSON.stringify(fromRenderer)}`); + const rendererIds = (fromRenderer.data?.connections ?? []).map((entry) => entry.id).sort(); + const snapshotIds = snapshot.connections.map((entry) => entry.id).sort(); + if (rendererIds.length === 0 || JSON.stringify(rendererIds) !== JSON.stringify(snapshotIds)) { + fail(`Iris's Connections Status does not match the backend's snapshot.\n renderer: ${JSON.stringify(rendererIds)}\n backend: ${JSON.stringify(snapshotIds)}`); + } + console.log(`PASS 3/8: Connections Status came from the backend (${rendererIds.length} connections, checkedAt ${fromRenderer.data.checkedAt})`); + + // 4. Ask Jarvis over the loopback read endpoint — the path that did not + // exist in a packaged build before P2.6. + const answer = await page.evaluate((question) => window.iris.askJarvis(question), ASK_QUESTION); + if (!answer?.ok) fail(`Ask Jarvis failed in the packaged runtime: ${JSON.stringify(answer)}`); + if (typeof answer.answer !== "string" || !answer.answer.trim()) { + fail(`Ask Jarvis returned an empty answer: ${JSON.stringify(answer)}`); + } + if (Object.keys(answer).sort().join(",") !== "answer,ok") { + fail(`Ask Jarvis leaked internal fields across the boundary: ${Object.keys(answer).join(", ")}`); + } + console.log(`PASS 4/8: Ask Jarvis answered over the packaged loopback endpoint: ${JSON.stringify(answer.answer.slice(0, 120))}`); + + // 5. A real request typed into the real composer becomes a real proposal. + const composer = page.locator('.comms-composer input[aria-label="Nachricht an Jarvis"]'); + await composer.waitFor({ timeout: 20000 }); + await composer.fill(REQUEST); + await composer.press("Enter"); + + const card = page.locator(".jarvis-action").first(); + await card.waitFor({ timeout: 60000 }); + const proposed = await card.evaluate((node) => ({ + previewId: node.getAttribute("data-preview-id"), + risk: node.getAttribute("data-risk"), + status: node.getAttribute("data-status"), + })); + if (!proposed.previewId) fail("The approval card carries no previewId."); + if (proposed.status !== "proposed") fail(`Expected a proposed action, got status ${proposed.status}.`); + if (fs.existsSync(path.join(vaultPath, EXPECTED_NOTE))) { + fail("The note was written BEFORE approval — the approval gate is not doing its job."); + } + console.log(`PASS 5/8: Jarvis proposed an action for approval and wrote nothing yet (${JSON.stringify(proposed)})`); + + // 6. The human approval, and the real write it triggers inside Jarvis. + await page.locator(".jarvis-action-confirm").first().click(); + const notePath = path.join(vaultPath, EXPECTED_NOTE); + await waitFor("the approved write to land in the throwaway vault", () => fs.existsSync(notePath), 60000); + const note = fs.readFileSync(notePath, "utf8"); + if (!note.includes("P2.6 Packaged Smoke Aufgabe")) fail("The written note does not contain the requested title."); + if (!note.includes("confirmation: yes")) fail("The written note carries no confirmed audit trail entry."); + console.log(`PASS 6/8: the approved action really wrote ${EXPECTED_NOTE} inside the packaged Jarvis process`); + + // 7. And the result came back into Iris's own transcript. + await page.waitForSelector(".comms-scroll .bubble.jarvis, .comms-scroll .bubble.jarvis-error", { timeout: 30000 }); + const transcript = await page.evaluate(() => ({ + errors: [...document.querySelectorAll(".comms-scroll .bubble.jarvis-error")].map((node) => node.textContent ?? ""), + lines: [...document.querySelectorAll(".comms-scroll .bubble.jarvis")].map((node) => node.textContent ?? ""), + })); + const done = transcript.lines.find((line) => line.includes("Erledigt")); + if (!done) { + fail(`No execution result rendered in Iris. jarvis=${JSON.stringify(transcript.lines)} errors=${JSON.stringify(transcript.errors)}`); + } + if ((await page.locator(".jarvis-action").count()) !== 0) { + fail("The approval card is still visible after the action completed."); + } + console.log(`PASS 7/8: the result reached Iris's transcript: ${JSON.stringify(done)}`); + + // Isolation, proven both ways. + if (!fs.existsSync(auditPath)) fail("The throwaway audit log was never written — the override did not take effect."); + const auditEntries = fs.readFileSync(auditPath, "utf8").trim().split("\n").filter(Boolean).map((line) => JSON.parse(line)); + if (!auditEntries.some((entry) => entry.actionId === proposed.previewId && entry.status === "succeeded")) { + fail(`The throwaway audit log holds no succeeded entry for ${proposed.previewId}.`); + } + const productionAuditSizeAfter = fs.existsSync(PRODUCTION_AUDIT_PATH) ? fs.statSync(PRODUCTION_AUDIT_PATH).size : null; + if (productionAuditSizeAfter !== productionAuditSizeBefore) { + fail(`The production audit log was modified (${productionAuditSizeBefore} -> ${productionAuditSizeAfter} bytes). This run must never touch it.`); + } + if (fs.existsSync(path.join(REAL_VAULT, EXPECTED_NOTE))) { + fail("The smoke wrote into the REAL Obsidian vault. Isolation failed."); + } + console.log("ISOLATION OK: throwaway audit log received this run; production audit log and the real vault untouched"); +} catch (error) { + try { + fs.mkdirSync(failureDir, { recursive: true }); + const page = await app.firstWindow(); + await page.screenshot({ path: failureScreenshot }); + console.error(`Failure screenshot: ${failureScreenshot}`); + } catch { + // best-effort screenshot only + } + await app.close().catch(() => {}); + throw error; +} finally { + try { fs.rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* best effort */ } +} + +// 8. Teardown: quitting Iris must take the backend it started with it. A +// surviving Jarvis would hold a stale single-instance lock and an orphaned +// Action endpoint — the next Iris launch would silently get no backend. +await app.close(); +const backendGone = async () => { + const deadline = Date.now() + 20000; + for (;;) { + if (!jarvisMainProcessPids().includes(backendPid)) return true; + if (Date.now() > deadline) return false; + await new Promise((resolve) => setTimeout(resolve, 250)); + } +}; +if (!(await backendGone())) { + fail(`Quitting Iris left the Jarvis backend (pid ${backendPid}) running. Cleanup is broken.`); +} +console.log(`PASS 8/8: quitting Iris cleaned up the Jarvis backend (pid ${backendPid})`); + +console.log("\nSMOKE PASS: packaged Iris.app -> one headless packaged Jarvis.app backend -> Connections Status, Ask Jarvis, Action/Approval/Result -> clean teardown"); diff --git a/test/jarvisBridgeClient.test.mjs b/test/jarvisBridgeClient.test.mjs index 9bd92e9..37d38dc 100644 --- a/test/jarvisBridgeClient.test.mjs +++ b/test/jarvisBridgeClient.test.mjs @@ -1,10 +1,10 @@ import test from "node:test"; import assert from "node:assert/strict"; -import path from "node:path"; import { shouldAskJarvis, - defaultJarvisBridgePath, - loadJarvisBridge, + createJarvisBridge, + JARVIS_READ_ROUTES, + ASK_TIMEOUT_MS, askJarvisForTurn, describeSmokeTranscript, getTasksForRenderer, @@ -13,18 +13,6 @@ import { getConnectionsStatusForRenderer, } from "../electron/jarvisBridgeClient.mjs"; -function withEnv(key, value, fn) { - const original = process.env[key]; - if (value === undefined) delete process.env[key]; - else process.env[key] = value; - try { - return fn(); - } finally { - if (original === undefined) delete process.env[key]; - else process.env[key] = original; - } -} - test("shouldAskJarvis: only the existing 'memory' route reaches Jarvis (reuses routingPolicy classification, no new rule)", () => { assert.equal(shouldAskJarvis("memory"), true); assert.equal(shouldAskJarvis("direct"), false); @@ -34,68 +22,81 @@ test("shouldAskJarvis: only the existing 'memory' route reaches Jarvis (reuses r assert.equal(shouldAskJarvis(undefined), false); }); -test("defaultJarvisBridgePath: honors a JARVIS_BRIDGE_PATH override", () => { - withEnv("JARVIS_BRIDGE_PATH", "/tmp/custom-bridge.cjs", () => { - assert.equal(defaultJarvisBridgePath("/whatever/repo"), "/tmp/custom-bridge.cjs"); - }); -}); +/* ------------------------------------------------------------------ * + * P2.6 — createJarvisBridge() + * + * REPLACES loadJarvisBridge(), which require()d Jarvis's adapter out of a + * sibling SOURCE CHECKOUT and ran it in the Iris process. A packaged + * Iris.app has no such checkout, so every read failed there. Reads now go + * to the one running Jarvis backend over the same loopback endpoint the + * write actions already use. + * + * These tests inject `request`, so they assert the ROUTE and the PAYLOAD + * that actually go on the wire — not a stub method name. + * ------------------------------------------------------------------ */ -test("defaultJarvisBridgePath: defaults to the sibling Jarvis-Desktop adapter path", () => { - withEnv("JARVIS_BRIDGE_PATH", undefined, () => { - const resolved = defaultJarvisBridgePath("/Users/x/Development/iris-test"); - assert.equal( - resolved, - path.resolve("/Users/x/Development/Jarvis-Desktop/app/adapter/iris-bridge.cjs"), - ); +function recordingBridge(reply = { ok: true, data: {} }) { + const sent = []; + const bridge = createJarvisBridge({ + request: async (route, payload, options) => { + sent.push({ route, payload, options }); + return typeof reply === "function" ? reply(route) : reply; + }, }); + return { bridge, sent }; +} + +test("createJarvisBridge: needs no repo root, no filesystem and no Jarvis source file to be constructed", () => { + const bridge = createJarvisBridge({ readEndpoint: () => null }); + assert.equal(typeof bridge.askJarvis, "function"); + assert.equal(typeof bridge.getConnectionsStatus, "function"); }); -test("loadJarvisBridge: never throws and returns null when the bridge file does not exist", () => { - const bridge = loadJarvisBridge("/does/not/exist", { - existsFn: () => false, - requireFn: () => { throw new Error("must not be called when the file is missing"); }, - }); - assert.equal(bridge, null); +test("createJarvisBridge: askJarvis posts the question to /read/ask with a model-sized timeout", async () => { + const { bridge, sent } = recordingBridge({ ok: true, answer: "Heute: 2 Termine." }); + const result = await bridge.askJarvis("was steht heute an"); + assert.deepEqual(result, { ok: true, answer: "Heute: 2 Termine." }); + assert.equal(sent[0].route, JARVIS_READ_ROUTES.ask); + assert.deepEqual(sent[0].payload, { question: "was steht heute an" }); + assert.equal(sent[0].options.timeoutMs, ASK_TIMEOUT_MS); + assert.ok(ASK_TIMEOUT_MS >= 60_000, "a real retrieval + model call must not be cut off by an approval-sized timeout"); }); -test("loadJarvisBridge: requires the resolved path and returns createIrisBridge()'s instance", () => { - let seenPath = null; - const fakeInstance = { askJarvis: async () => ({}) }; - const bridge = loadJarvisBridge("/whatever/repo", { - existsFn: () => true, - requireFn: (resolvedPath) => { - seenPath = resolvedPath; - return { createIrisBridge: () => fakeInstance }; - }, - }); - assert.equal(seenPath, path.resolve("/whatever/repo", "..", "Jarvis-Desktop", "app", "adapter", "iris-bridge.cjs")); - assert.equal(bridge, fakeInstance); +test("createJarvisBridge: every read method hits its own documented route", async () => { + const expected = [ + ["getConnectionsStatus", JARVIS_READ_ROUTES.connectionsStatus], + ["getTasks", JARVIS_READ_ROUTES.tasks], + ["getTopFocus", JARVIS_READ_ROUTES.topFocus], + ["getCurrentContext", JARVIS_READ_ROUTES.currentContext], + ["getLatestEngineeringJob", JARVIS_READ_ROUTES.latestEngineeringJob], + ["getActiveGoal", JARVIS_READ_ROUTES.activeGoal], + ]; + for (const [method, route] of expected) { + const { bridge, sent } = recordingBridge({ ok: true, data: { marker: method } }); + // eslint-disable-next-line no-await-in-loop + const result = await bridge[method](); + assert.equal(sent[0].route, route, `${method} must call ${route}`); + assert.deepEqual(result, { ok: true, data: { marker: method } }); + } }); -test("loadJarvisBridge: never throws, returns null if require() itself throws", () => { - const bridge = loadJarvisBridge("/whatever/repo", { - existsFn: () => true, - requireFn: () => { throw new Error("module not found"); }, - }); - assert.equal(bridge, null); +test("createJarvisBridge: a backend error is forwarded untouched, never turned into empty data", async () => { + const { bridge } = recordingBridge({ ok: false, error: "Jarvis-Backend läuft nicht — keine Verbindung möglich." }); + const result = await bridge.getConnectionsStatus(); + assert.equal(result.ok, false); + assert.match(result.error, /Jarvis-Backend/); }); -// Gap 2 ROT: loadJarvisBridge() currently swallows a require() failure -// silently — no diagnostics, no reason code. The approved fix adds a 3rd -// optional `onUnavailable(reasonCode)` option, called with exactly -// "require-error" here (never the resolved path, the raw Error, its -// message, or a stack trace — those can leak local absolute paths). -test("loadJarvisBridge: calls onUnavailable('require-error') when require() throws, without leaking the path/error", () => { - const seen = []; - const bridge = loadJarvisBridge("/fake/repo", { - existsFn: () => true, - requireFn: () => { - throw new Error("/Users/cd/Development/Jarvis-Desktop/app/adapter/iris-bridge.cjs: boom"); - }, - onUnavailable: (reason) => seen.push(reason), - }); - assert.equal(bridge, null); - assert.deepEqual(seen, ["require-error"]); +// The whole point of the *ForRenderer contract still holding after the +// transport swap: the bridge object is duck-typed, so a remote bridge and +// the old in-process one are indistinguishable to everything downstream. +test("createJarvisBridge: the remote bridge satisfies the existing *ForRenderer contract unchanged", async () => { + const { bridge } = recordingBridge((route) => + (route === JARVIS_READ_ROUTES.tasks + ? { ok: true, data: { now: [{ title: "A" }], next: [], waiting: [], overdue: [] } } + : { ok: true, data: {} })); + const result = await getTasksForRenderer(bridge); + assert.deepEqual(result, { ok: true, data: { now: [{ title: "A" }], next: [], waiting: [], overdue: [] } }); }); test("askJarvisForTurn: returns ok+answer when the bridge resolves a real answer (mocked final transcript -> bridge -> answer)", async () => { diff --git a/test/jarvisEndpoint.test.mjs b/test/jarvisEndpoint.test.mjs new file mode 100644 index 0000000..f664411 --- /dev/null +++ b/test/jarvisEndpoint.test.mjs @@ -0,0 +1,195 @@ +/* + * jarvisEndpoint.test.mjs — P2.6, the packaged-runtime resolver. + * + * THE BUG THIS PINS. Both Jarvis clients used to reach Jarvis through a + * SOURCE CHECKOUT: path.resolve(repoRoot, "..", "Jarvis-Desktop", "app", ...). + * In a packaged Iris.app repoRoot is /Contents/Resources/app.asar, + * so that path resolves to /Contents/Resources/Jarvis-Desktop — + * which does not exist. Ask Jarvis, Connections Status and every approval + * therefore failed in the packaged app while working perfectly in dev. + * + * The descriptor path below is a cross-process FILE CONTRACT (the same one + * Jarvis's engineering-runtime-paths.cjs computes), not a code dependency: + * it is derived from the OS Application Support dir, so it is identical + * whether either side runs from source or from a bundle. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + defaultActionEndpointPath, + readJarvisEndpoint, + createJarvisEndpointReader, + createEndpointRequest, +} from "../electron/jarvisEndpoint.mjs"; + +const LOOPBACK = { url: "http://127.0.0.1:51234", token: "test-token-0123456789abcdef", pid: 4242, publishedAt: "2026-08-22T10:00:00.000Z" }; + +function withTempDir(body) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "iris-endpoint-test-")); + try { + return body(dir); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +test("defaultActionEndpointPath: resolves without ANY reference to a Jarvis source checkout", () => { + const resolved = defaultActionEndpointPath({}); + assert.ok(!resolved.includes("Jarvis-Desktop"), "the packaged app has no Jarvis-Desktop checkout to point at"); + assert.ok(!resolved.includes("app.asar"), "the descriptor must never be looked for inside Iris's own bundle"); + assert.equal(path.basename(resolved), "action-endpoint.json"); +}); + +test("defaultActionEndpointPath: matches Jarvis's own engineering runtime dir contract on macOS", () => { + const resolved = defaultActionEndpointPath({}, "darwin", "/Users/x"); + assert.equal(resolved, path.join("/Users/x", "Library", "Application Support", "Jarvis", "jarvis-engineering", "action-endpoint.json")); +}); + +test("defaultActionEndpointPath: honors JARVIS_ENGINEERING_DIR, the SAME override Jarvis honors", () => { + const resolved = defaultActionEndpointPath({ JARVIS_ENGINEERING_DIR: "/tmp/throwaway-runtime" }); + assert.equal(resolved, path.join("/tmp/throwaway-runtime", "action-endpoint.json")); +}); + +test("defaultActionEndpointPath: honors an explicit JARVIS_ACTION_ENDPOINT descriptor path", () => { + const resolved = defaultActionEndpointPath({ JARVIS_ACTION_ENDPOINT: "/tmp/other/endpoint.json", JARVIS_ENGINEERING_DIR: "/tmp/ignored" }); + assert.equal(resolved, "/tmp/other/endpoint.json"); +}); + +test("readJarvisEndpoint: reads the descriptor Jarvis actually published", () => { + withTempDir((dir) => { + const storePath = path.join(dir, "action-endpoint.json"); + fs.writeFileSync(storePath, JSON.stringify(LOOPBACK), "utf8"); + assert.deepEqual(readJarvisEndpoint({ storePath }), LOOPBACK); + }); +}); + +test("readJarvisEndpoint: a missing descriptor means 'no Jarvis running' — null, never a throw and never a guess", () => { + withTempDir((dir) => { + assert.equal(readJarvisEndpoint({ storePath: path.join(dir, "nope.json") }), null); + }); +}); + +test("readJarvisEndpoint: malformed JSON degrades to null instead of crashing Iris's main process", () => { + withTempDir((dir) => { + const storePath = path.join(dir, "action-endpoint.json"); + fs.writeFileSync(storePath, "{not json", "utf8"); + assert.equal(readJarvisEndpoint({ storePath }), null); + }); +}); + +// Iris must not POST a bearer token wherever a file tells it to. Jarvis +// refuses to PUBLISH a non-loopback descriptor; Iris independently refuses +// to USE one. Both checks are load-bearing — neither trusts the other side. +test("readJarvisEndpoint: refuses a descriptor pointing off-machine, so the bearer token never leaves loopback", () => { + withTempDir((dir) => { + const storePath = path.join(dir, "action-endpoint.json"); + fs.writeFileSync(storePath, JSON.stringify({ ...LOOPBACK, url: "http://evil.example.com:80" }), "utf8"); + assert.equal(readJarvisEndpoint({ storePath }), null); + }); +}); + +test("readJarvisEndpoint: refuses a descriptor without a token", () => { + withTempDir((dir) => { + const storePath = path.join(dir, "action-endpoint.json"); + fs.writeFileSync(storePath, JSON.stringify({ url: LOOPBACK.url }), "utf8"); + assert.equal(readJarvisEndpoint({ storePath }), null); + }); +}); + +test("createJarvisEndpointReader: re-reads on EVERY call — a restarted Jarvis has a new port and a new token", () => { + withTempDir((dir) => { + const storePath = path.join(dir, "action-endpoint.json"); + const reader = createJarvisEndpointReader({ storePath }); + assert.equal(reader(), null); + fs.writeFileSync(storePath, JSON.stringify(LOOPBACK), "utf8"); + assert.deepEqual(reader(), LOOPBACK); + fs.writeFileSync(storePath, JSON.stringify({ ...LOOPBACK, url: "http://127.0.0.1:9999", token: "second-token-0123456789ab" }), "utf8"); + assert.equal(reader().token, "second-token-0123456789ab", "a cached descriptor would outlive the process that published it"); + }); +}); + +test("createEndpointRequest: sends the bearer token, no Origin header, and returns Jarvis's body verbatim", async () => { + const seen = []; + const request = createEndpointRequest({ + readEndpoint: () => LOOPBACK, + fetchImpl: async (url, init) => { + seen.push({ url, init }); + return { ok: true, status: 200, json: async () => ({ ok: true, answer: "Heute: 2 Termine." }) }; + }, + }); + const result = await request("/read/ask", { question: "was steht heute an" }); + assert.deepEqual(result, { ok: true, answer: "Heute: 2 Termine." }); + assert.equal(seen[0].url, `${LOOPBACK.url}/read/ask`); + assert.equal(seen[0].init.method, "POST"); + assert.equal(seen[0].init.headers.authorization, `Bearer ${LOOPBACK.token}`); + assert.ok(!("origin" in seen[0].init.headers), "Jarvis refuses any request carrying an Origin header"); + assert.deepEqual(JSON.parse(seen[0].init.body), { question: "was steht heute an" }); +}); + +test("createEndpointRequest: no running Jarvis is an honest error, never a fabricated success", async () => { + const request = createEndpointRequest({ + readEndpoint: () => null, + fetchImpl: async () => { throw new Error("must not be called without an endpoint"); }, + }); + const result = await request("/read/ask", { question: "x" }); + assert.equal(result.ok, false); + assert.ok(result.error); +}); + +test("createEndpointRequest: an unreadable body is an error, not a silent empty result", async () => { + const request = createEndpointRequest({ + readEndpoint: () => LOOPBACK, + fetchImpl: async () => ({ ok: true, status: 200, json: async () => { throw new Error("not json"); } }), + }); + const result = await request("/read/tasks", {}); + assert.equal(result.ok, false); + assert.match(result.error, /200/); +}); + +test("createEndpointRequest: a rejected token (HTTP 401) surfaces as an error, never as approval", async () => { + const request = createEndpointRequest({ + readEndpoint: () => LOOPBACK, + fetchImpl: async () => ({ ok: false, status: 401, json: async () => ({ ok: false, error: "unauthorized" }) }), + }); + const result = await request("/action/approve", { previewId: "p1" }); + assert.equal(result.ok, false); + assert.match(result.error, /unauthorized/); +}); + +test("createEndpointRequest: a wedged backend times out with a clear error instead of hanging forever", async () => { + const request = createEndpointRequest({ + readEndpoint: () => LOOPBACK, + timeoutMs: 20, + fetchImpl: (_url, init) => new Promise((_resolve, reject) => { + init.signal.addEventListener("abort", () => { + const error = new Error("aborted"); + error.name = "AbortError"; + reject(error); + }); + }), + }); + const result = await request("/read/ask", { question: "x" }); + assert.equal(result.ok, false); + assert.match(result.error, /20 ms/); +}); + +test("createEndpointRequest: a per-call timeout overrides the default (Ask Jarvis runs a model, approvals do not)", async () => { + let seenTimeout = null; + const request = createEndpointRequest({ + readEndpoint: () => LOOPBACK, + timeoutMs: 10, + fetchImpl: async (_url, init) => { + // A 10 ms default would already have aborted this signal by the time a + // real model answered; a per-call override is what keeps it alive. + seenTimeout = init.signal.aborted; + await new Promise((resolve) => setTimeout(resolve, 40)); + return { ok: true, status: 200, json: async () => ({ ok: true, answer: "spät, aber da." }) }; + }, + }); + const result = await request("/read/ask", { question: "x" }, { timeoutMs: 30_000 }); + assert.equal(seenTimeout, false); + assert.deepEqual(result, { ok: true, answer: "spät, aber da." }); +}); From 9fc826cbe39e2f27683c9d1542c46a88604de4a4 Mon Sep 17 00:00:00 2001 From: Cengiz Demirel Date: Sat, 22 Aug 2026 23:51:53 +0200 Subject: [PATCH 08/10] fix(smoke): stop pinning JARVIS_APP_PATH by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test:packaged-runtime-smoke always injected JARVIS_APP_PATH into the spawned Iris process, defaulting to the dist-electron sibling build. That meant it could never actually exercise the real production fallback chain end-to-end — only an isolated resolver check did. JARVIS_APP_PATH is now forwarded only when the caller sets it; otherwise the spawned Iris resolves Jarvis exactly like a real installed app, landing on /Applications/Jarvis.app. Co-Authored-By: Claude Sonnet 5 --- scripts/test-packaged-runtime-smoke.mjs | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/scripts/test-packaged-runtime-smoke.mjs b/scripts/test-packaged-runtime-smoke.mjs index 03525ae..6191bb9 100644 --- a/scripts/test-packaged-runtime-smoke.mjs +++ b/scripts/test-packaged-runtime-smoke.mjs @@ -51,8 +51,15 @@ const failureScreenshot = path.join(failureDir, "packaged-runtime-smoke-failure. const IRIS_APP = process.env.IRIS_APP_PATH || path.join(root, "release", "mac-arm64", "Iris.app"); const IRIS_BINARY = path.join(IRIS_APP, "Contents", "MacOS", "Iris"); -const JARVIS_APP = process.env.JARVIS_APP_PATH - || path.resolve(root, "..", "Jarvis-Desktop", "app", "dist-electron", "mac-arm64", "Jarvis.app"); +// PINNED_JARVIS_APP_PATH is only ever set here from the CALLER's own +// environment. When absent, this smoke does NOT default to (and does NOT +// inject) a dist-electron sibling build — it leaves JARVIS_APP_PATH unset in +// the spawned Iris process too, so resolveJarvisLauncher() runs its real +// production fallback chain and must land on the installed +// /Applications/Jarvis.app, exactly like a real user's Iris. JARVIS_APP below +// is then only the binary the GUARDS inspect to know what will actually run. +const PINNED_JARVIS_APP_PATH = process.env.JARVIS_APP_PATH || null; +const JARVIS_APP = PINNED_JARVIS_APP_PATH || "/Applications/Jarvis.app"; const JARVIS_BINARY = path.join(JARVIS_APP, "Contents", "MacOS", "Jarvis"); const JARVIS_ASAR = path.join(JARVIS_APP, "Contents", "Resources", "app.asar"); @@ -86,10 +93,11 @@ const env = { JARVIS_VAULT_PATH: vaultPath, JARVIS_ENGINEERING_DIR: engineeringDir, JARVIS_ACTION_AUDIT_PATH: auditPath, - // Pin the backend to the FRESHLY BUILT bundle instead of whatever happens - // to sit in /Applications, so this smoke tests the code under review. The - // no-env default (/Applications/Jarvis.app) is asserted separately below. - JARVIS_APP_PATH: JARVIS_APP, + // Only forwarded when the CALLER set it (e.g. to pin a not-yet-installed + // build before it replaces /Applications/Jarvis.app). Otherwise omitted + // entirely, so resolveJarvisLauncher() in the spawned Iris process runs its + // real fallback chain — see the PINNED_JARVIS_APP_PATH comment above. + ...(PINNED_JARVIS_APP_PATH ? { JARVIS_APP_PATH: PINNED_JARVIS_APP_PATH } : {}), }; delete env.ELECTRON_RUN_AS_NODE; From 1ef1f1e02566119fedc19889f76ad7b7daea2141 Mon Sep 17 00:00:00 2001 From: Cengiz Demirel Date: Sun, 23 Aug 2026 12:41:28 +0200 Subject: [PATCH 09/10] fix(jarvis): escalate to SIGKILL if the backend ignores SIGTERM stopJarvisBackend() only ever sent SIGTERM with no exit check or fallback, so a hung backend (e.g. stuck in a synchronous Keychain call) could survive Iris quitting and orphan the Jarvis single-instance lock, blocking the next restart. Mirrors the existing SIGTERM->SIGKILL escalation pattern already used for the Hermes gateway child in this repo (hermesGatewayClient.mjs). Also extends the packaged-runtime e2e smoke test with empirical coverage for double-start (AC2), endpoint-file cleanup on quit (AC3b), and restart-after-shutdown (AC4) - none of which had any coverage before. Co-Authored-By: Claude Sonnet 5 --- electron/jarvisBackend.mjs | 9 ++ scripts/test-packaged-runtime-smoke.mjs | 171 +++++++++++++++++++++--- test/jarvisBackend.test.mjs | 18 +++ 3 files changed, 183 insertions(+), 15 deletions(-) diff --git a/electron/jarvisBackend.mjs b/electron/jarvisBackend.mjs index e573012..62ca5c2 100644 --- a/electron/jarvisBackend.mjs +++ b/electron/jarvisBackend.mjs @@ -112,4 +112,13 @@ export function stopJarvisBackend(child) { } catch { // Already gone; nothing to clean up. } + setTimeout(() => { + if (child.exitCode == null) { + try { + child.kill("SIGKILL"); + } catch { + // Already gone; nothing to clean up. + } + } + }, 3000).unref(); } diff --git a/scripts/test-packaged-runtime-smoke.mjs b/scripts/test-packaged-runtime-smoke.mjs index 6191bb9..52059d5 100644 --- a/scripts/test-packaged-runtime-smoke.mjs +++ b/scripts/test-packaged-runtime-smoke.mjs @@ -105,6 +105,17 @@ function fail(message) { throw new Error(message); } +// Some new assertions below (AC3(b)) are proving a CONFIRMED, currently-open +// gap. A hard fail() would abort the script before the other new, currently- +// passing assertions (AC2, AC4) get a chance to run and be reported in the +// same pass. softFail() records the failure and keeps going; the script +// still exits non-zero at the end if anything was recorded. +const deferredFailures = []; +function softFail(message) { + deferredFailures.push(message); + console.error(`FAIL (recorded, continuing — see summary below): ${message}`); +} + /** Ask the PACKAGED Jarvis binary itself — not this script's assumptions, and * not the source checkout — where it would write under `env`. */ function resolveInPackagedJarvis(expression) { @@ -200,6 +211,10 @@ if (strayBefore.length) { fail(`ABORT: a packaged Jarvis (pid ${strayBefore.join(", ")}) is already running. Quit it first — this smoke must own the only backend.`); } +// Declared here (not inside the try below) so the new AC3(b)/AC4 assertions +// after step 8 can reuse the exact same path the original backend published. +const endpointPath = path.join(engineeringDir, "action-endpoint.json"); + const app = await electron.launch({ executablePath: IRIS_BINARY, args: [`--user-data-dir=${path.join(tmpRoot, "iris-user-data")}`], @@ -208,8 +223,12 @@ const app = await electron.launch({ }); let backendPid = null; +let secondApp = null; +let thirdApp = null; +let thirdBackendPid = null; try { + try { const page = await app.firstWindow(); await page.waitForSelector(".deck", { timeout: 60000 }); @@ -225,7 +244,6 @@ try { // 2. The backend really came up, headless, as the packaged Jarvis — and // there is exactly one of it. - const endpointPath = path.join(engineeringDir, "action-endpoint.json"); const endpoint = await waitFor( "the packaged Jarvis backend to publish its loopback endpoint descriptor", () => (fs.existsSync(endpointPath) ? JSON.parse(fs.readFileSync(endpointPath, "utf8")) : null), @@ -341,7 +359,42 @@ try { fail("The smoke wrote into the REAL Obsidian vault. Isolation failed."); } console.log("ISOLATION OK: throwaway audit log received this run; production audit log and the real vault untouched"); -} catch (error) { + + // AC2 (empirical, expected to already pass today): a SECOND, fully + // independent Iris instance (own --user-data-dir, so Iris's own + // requestSingleInstanceLock() does NOT block it — that lock is scoped per + // user-data-dir) must still never end up with a second Jarvis backend. The + // actual guard is Jarvis's OWN single-instance lock in its own + // electron-main.cjs: a duplicate launch is expected to exit immediately. + const secondUserDataDir = path.join(tmpRoot, "iris-user-data-2"); + secondApp = await electron.launch({ + executablePath: IRIS_BINARY, + args: [`--user-data-dir=${secondUserDataDir}`], + cwd: root, + env, + }); + try { + const secondPage = await secondApp.firstWindow(); + await secondPage.waitForSelector(".deck", { timeout: 60000 }); + + // Give a duplicate backend launch attempt time to appear (and be + // rejected by Jarvis's own lock) before asserting it never showed up. + let running = jarvisMainProcessPids(); + const pollDeadline = Date.now() + 5000; + while (Date.now() < pollDeadline) { + running = jarvisMainProcessPids(); + if (running.length !== 1 || running[0] !== backendPid) break; + await new Promise((resolve) => setTimeout(resolve, 250)); + } + if (running.length !== 1 || running[0] !== backendPid) { + fail(`AC2: a second Iris instance resulted in more than one Jarvis backend. Expected only pid ${backendPid}, found: ${running.join(", ") || "none"}.`); + } + console.log(`PASS AC2 (empirical): a second, independent Iris instance did not spawn a second Jarvis backend — still exactly pid ${backendPid}`); + } finally { + await secondApp.close().catch(() => {}); + secondApp = null; + } + } catch (error) { try { fs.mkdirSync(failureDir, { recursive: true }); const page = await app.firstWindow(); @@ -351,26 +404,114 @@ try { // best-effort screenshot only } await app.close().catch(() => {}); + if (secondApp) await secondApp.close().catch(() => {}); throw error; -} finally { - try { fs.rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* best effort */ } -} + } -// 8. Teardown: quitting Iris must take the backend it started with it. A -// surviving Jarvis would hold a stale single-instance lock and an orphaned -// Action endpoint — the next Iris launch would silently get no backend. -await app.close(); -const backendGone = async () => { + // 8. Teardown: quitting Iris must take the backend it started with it. A + // surviving Jarvis would hold a stale single-instance lock and an orphaned + // Action endpoint — the next Iris launch would silently get no backend. + await app.close(); + const backendGone = async () => { const deadline = Date.now() + 20000; for (;;) { if (!jarvisMainProcessPids().includes(backendPid)) return true; if (Date.now() > deadline) return false; await new Promise((resolve) => setTimeout(resolve, 250)); } -}; -if (!(await backendGone())) { + }; + if (!(await backendGone())) { fail(`Quitting Iris left the Jarvis backend (pid ${backendPid}) running. Cleanup is broken.`); -} -console.log(`PASS 8/8: quitting Iris cleaned up the Jarvis backend (pid ${backendPid})`); + } + console.log(`PASS 8/8: quitting Iris cleaned up the Jarvis backend (pid ${backendPid})`); + + // AC3(b): quitting Iris kills the Jarvis OS process (proven above), but + // that alone does not prove Jarvis's own will-quit cleanup + // (clearActionEndpoint) actually ran. electron-main.cjs installs no + // process.on("SIGTERM", ...) handler of its own; this was suspected to mean + // the endpoint file survives a SIGTERM-driven exit. EMPIRICALLY, against + // this build, that suspicion did NOT reproduce: Electron's own runtime + // already reacts to SIGTERM by running the normal quit chain (will-quit + // fires, clearActionEndpoint runs), confirmed both by this assertion and by + // a manual `kill -TERM` against the raw packaged binary. Kept as a real + // regression guard either way — if this ever starts failing, that is a + // genuine new gap. + if (fs.existsSync(endpointPath)) { + softFail( + `AC3(b): ${endpointPath} still exists after quitting Iris — Jarvis's ` + + `will-quit cleanup (clearActionEndpoint) did not run. electron-main.cjs ` + + `has no process.on("SIGTERM", ...) handler, so Iris's SIGTERM kills the ` + + `process via Node's default disposition instead of Electron's quit chain.`, + ); + } else { + console.log(`PASS AC3(b): quitting Iris also cleared the stale action-endpoint.json (${endpointPath})`); + } + + // AC4 (empirical, expected to already pass today): after a clean shutdown, + // relaunching Iris a THIRD time must produce a brand-new Jarvis backend + // (not reuse stale state) and the full Connections Status / Ask Jarvis path + // must keep working. + const thirdUserDataDir = path.join(tmpRoot, "iris-user-data-3"); + thirdApp = await electron.launch({ + executablePath: IRIS_BINARY, + args: [`--user-data-dir=${thirdUserDataDir}`], + cwd: root, + env, + }); + try { + const thirdPage = await thirdApp.firstWindow(); + await thirdPage.waitForSelector(".deck", { timeout: 60000 }); + + // A stale endpoint file (see AC3(b) above) may already exist with the + // ORIGINAL backendPid; wait specifically for a NEW pid, not just for the + // file to exist, so this assertion is meaningful either way. + const thirdEndpoint = await waitFor( + "a fresh packaged Jarvis backend to publish its own endpoint after a clean shutdown + restart", + () => { + if (!fs.existsSync(endpointPath)) return null; + const parsed = JSON.parse(fs.readFileSync(endpointPath, "utf8")); + return parsed.pid !== backendPid ? parsed : null; + }, + 90000, + ); + thirdBackendPid = thirdEndpoint.pid; + if (thirdBackendPid === backendPid) { + fail(`AC4: the restarted backend reused the original pid ${backendPid} instead of starting a fresh process.`); + } + + const thirdFromRenderer = await thirdPage.evaluate(() => window.iris.getJarvisConnectionsStatus()); + if (!thirdFromRenderer?.ok) { + fail(`AC4: Connections Status failed after a clean restart: ${JSON.stringify(thirdFromRenderer)}`); + } + + const thirdAnswer = await thirdPage.evaluate((question) => window.iris.askJarvis(question), ASK_QUESTION); + if (!thirdAnswer?.ok || typeof thirdAnswer.answer !== "string" || !thirdAnswer.answer.trim()) { + fail(`AC4: Ask Jarvis failed after a clean restart: ${JSON.stringify(thirdAnswer)}`); + } + + console.log(`PASS AC4 (empirical): Iris restarted cleanly after shutdown — fresh backend pid ${thirdBackendPid}, Connections Status ok, Ask Jarvis answered`); + } finally { + await thirdApp.close().catch(() => {}); + } -console.log("\nSMOKE PASS: packaged Iris.app -> one headless packaged Jarvis.app backend -> Connections Status, Ask Jarvis, Action/Approval/Result -> clean teardown"); + const thirdBackendGone = async () => { + const deadline = Date.now() + 20000; + for (;;) { + if (thirdBackendPid == null || !jarvisMainProcessPids().includes(thirdBackendPid)) return true; + if (Date.now() > deadline) return false; + await new Promise((resolve) => setTimeout(resolve, 250)); + } + }; + if (!(await thirdBackendGone())) { + fail(`AC4 teardown: the restarted Jarvis backend (pid ${thirdBackendPid}) survived quitting the third Iris instance.`); + } + console.log(`PASS AC4 teardown: the restarted backend (pid ${thirdBackendPid}) also shut down cleanly`); + + if (deferredFailures.length) { + throw new Error(`${deferredFailures.length} assertion(s) failed:\n${deferredFailures.join("\n")}`); + } + + console.log("\nSMOKE PASS: packaged Iris.app -> one headless packaged Jarvis.app backend -> Connections Status, Ask Jarvis, Action/Approval/Result -> clean teardown"); +} finally { + try { fs.rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* best effort */ } +} diff --git a/test/jarvisBackend.test.mjs b/test/jarvisBackend.test.mjs index 20a71fb..be7e490 100644 --- a/test/jarvisBackend.test.mjs +++ b/test/jarvisBackend.test.mjs @@ -151,3 +151,21 @@ test("stopJarvisBackend is a safe no-op for a missing or already-dead backend", assert.doesNotThrow(() => stopJarvisBackend(null)); assert.doesNotThrow(() => stopJarvisBackend({ killed: true, kill: () => { throw new Error("already gone"); } })); }); + +// Mirrors the grace-window escalation already used for the Hermes gateway +// child (hermesGatewayClient.mjs #stopProcess, ~line 269-280): SIGTERM first, +// then SIGKILL after a 3000ms grace period if the child never reports exit. +// A backend that ignores/survives SIGTERM must not be left running forever. +test("stopJarvisBackend escalates to SIGKILL if the backend ignores SIGTERM for 3000ms", (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + const signals = []; + const child = { + pid: 1, + killed: false, + exitCode: null, // never set — simulates a child that survives SIGTERM + kill: (signal) => { signals.push(signal); }, + }; + stopJarvisBackend(child); + t.mock.timers.tick(3000); + assert.deepEqual(signals, ["SIGTERM", "SIGKILL"]); +}); From a585635da32068266ffbab73b4052e3eba0b412f Mon Sep 17 00:00:00 2001 From: Cengiz Demirel Date: Sun, 23 Aug 2026 14:58:26 +0200 Subject: [PATCH 10/10] fix(ux): clarify approval and loading states --- src/App.tsx | 10 +++++++++- src/components/ConnectionsStatus.tsx | 3 ++- src/components/HistoryDrawer.tsx | 4 +++- src/components/HudShell.tsx | 3 +++ src/components/PersonalFocusPanel.tsx | 9 +++++---- src/components/WorkCard.tsx | 5 ++++- src/components/WorkStream.tsx | 3 +++ src/lib/loadingState.ts | 1 + src/lib/tasks.ts | 10 ++++++++++ test/loadingState.test.mjs | 12 ++++++++++++ test/tasksApproval.test.mjs | 21 +++++++++++++++++++++ 11 files changed, 73 insertions(+), 8 deletions(-) create mode 100644 src/lib/loadingState.ts create mode 100644 test/loadingState.test.mjs create mode 100644 test/tasksApproval.test.mjs diff --git a/src/App.tsx b/src/App.tsx index 717756f..a0967f5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1389,6 +1389,7 @@ export default function App() { sortedTasks.find((task) => Boolean(task.approval) && !task.interaction) ?? null, [sortedTasks], ); + const pendingApprovalTaskId = pendingApprovalTask?.id ?? null; const pendingInteractionTask = useMemo( () => sortedTasks.find((task) => Boolean(task.interaction)) ?? null, [sortedTasks], @@ -1711,6 +1712,7 @@ export default function App() { onFocusTask={setFocusedTaskId} onOpenTask={openTask} onApproveTask={(task, choice) => void resolveTaskApproval(task, choice)} + pendingApprovalTaskId={pendingApprovalTaskId} transcript={transcript} commsScrollRef={commsScrollRef} handControl={handControl} @@ -1827,6 +1829,7 @@ export default function App() { onFocusTask={setFocusedTaskId} onOpenTask={openTask} onApproveTask={(task, choice) => void resolveTaskApproval(task, choice)} + pendingApprovalTaskId={pendingApprovalTaskId} />
@@ -1866,7 +1869,12 @@ export default function App() { ) : null} {showHistory ? ( - setShowHistory(false)} /> + setShowHistory(false)} + pendingApprovalTaskId={pendingApprovalTaskId} + /> ) : null} {taskChooser ? ( diff --git a/src/components/ConnectionsStatus.tsx b/src/components/ConnectionsStatus.tsx index 028ac35..abc008d 100644 --- a/src/components/ConnectionsStatus.tsx +++ b/src/components/ConnectionsStatus.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from "react"; import { Plug } from "lucide-react"; +import { LOADING_PLACEHOLDER } from "../lib/loadingState"; // Jarvis Integrations/Connections readout (P2.4) — a compact, read-only // popover over the real getJarvisConnectionsStatus() bridge result (see @@ -41,7 +42,7 @@ export default function ConnectionsStatus({ const connections = result?.ok ? (result.data?.connections ?? []) : []; const connectedCount = connections.filter((entry) => entry.status === "connected").length; - const summary = result === null ? "…" : `${connectedCount}/${connections.length}`; + const summary = result === null ? LOADING_PLACEHOLDER : `${connectedCount}/${connections.length}`; return (
diff --git a/src/components/HistoryDrawer.tsx b/src/components/HistoryDrawer.tsx index 59c6acd..1d6bd5a 100644 --- a/src/components/HistoryDrawer.tsx +++ b/src/components/HistoryDrawer.tsx @@ -7,10 +7,12 @@ export default function HistoryDrawer({ tasks, onOpen, onClose, + pendingApprovalTaskId, }: { tasks: TaskCard[]; onOpen: (task: TaskCard) => void; onClose: () => void; + pendingApprovalTaskId: string | null; }) { useEffect(() => { function onKey(event: KeyboardEvent) { @@ -37,7 +39,7 @@ export default function HistoryDrawer({
{tasks.map((task) => ( - undefined} onOpen={() => onOpen(task)} /> + undefined} onOpen={() => onOpen(task)} pendingApprovalTaskId={pendingApprovalTaskId} /> ))}
diff --git a/src/components/HudShell.tsx b/src/components/HudShell.tsx index 0b90cca..1c77192 100644 --- a/src/components/HudShell.tsx +++ b/src/components/HudShell.tsx @@ -79,6 +79,7 @@ export default function HudShell({ onFocusTask, onOpenTask, onApproveTask, + pendingApprovalTaskId, transcript, commsScrollRef, handControl, @@ -122,6 +123,7 @@ export default function HudShell({ task: TaskCard, choice: "once" | "session" | "always" | "deny", ) => void; + pendingApprovalTaskId: string | null; transcript: TranscriptLine[]; commsScrollRef: RefObject; handControl: boolean; @@ -206,6 +208,7 @@ export default function HudShell({ onFocus={() => onFocusTask(task.id)} onOpen={() => onOpenTask(task)} onApprove={(choice) => onApproveTask(task, choice)} + pendingApprovalTaskId={pendingApprovalTaskId} /> ))}
diff --git a/src/components/PersonalFocusPanel.tsx b/src/components/PersonalFocusPanel.tsx index bca4a39..c31ffbf 100644 --- a/src/components/PersonalFocusPanel.tsx +++ b/src/components/PersonalFocusPanel.tsx @@ -1,4 +1,5 @@ import { Target } from "lucide-react"; +import { LOADING_PLACEHOLDER } from "../lib/loadingState"; // Compact left-column readout of real Jarvis Bridge Personal OS data: Top // Focus, Current Context, Next. No demo data, no invented fallback — a @@ -39,7 +40,7 @@ export default function PersonalFocusPanel({ ) : (

- {goalError || (activeGoal ? "Kein Ziel definiert." : "…")} + {goalError || (activeGoal ? "Kein Ziel definiert." : LOADING_PLACEHOLDER)}

)} @@ -53,7 +54,7 @@ export default function PersonalFocusPanel({ ) : (

- {focusError || (topFocus ? "Kein Top-Fokus." : "…")} + {focusError || (topFocus ? "Kein Top-Fokus." : LOADING_PLACEHOLDER)}

)} @@ -72,7 +73,7 @@ export default function PersonalFocusPanel({ ) : (

- {contextError || (context ? "Kein Kontext." : "…")} + {contextError || (context ? "Kein Kontext." : LOADING_PLACEHOLDER)}

)} @@ -86,7 +87,7 @@ export default function PersonalFocusPanel({ ) : (

- {context ? (context.ok ? "Keine nächste Aktion." : contextError) : "…"} + {context ? (context.ok ? "Keine nächste Aktion." : contextError) : LOADING_PLACEHOLDER}

)} diff --git a/src/components/WorkCard.tsx b/src/components/WorkCard.tsx index f79cdcb..7cbf8f6 100644 --- a/src/components/WorkCard.tsx +++ b/src/components/WorkCard.tsx @@ -6,6 +6,7 @@ import { normalizeMarkdown, prettyToolName, shortRunId, + shouldRenderInlineApproval, stepDetail, stepHeadline, toolCategory, @@ -60,6 +61,7 @@ export default function WorkCard({ onFocus, onOpen, onApprove, + pendingApprovalTaskId = null, }: { task: TaskCard; accepted?: boolean; @@ -68,6 +70,7 @@ export default function WorkCard({ onFocus: () => void; onOpen: () => void; onApprove?: (choice: "once" | "session" | "always" | "deny") => void; + pendingApprovalTaskId?: string | null; }) { const [localStepsOpen, setLocalStepsOpen] = useState(false); const showSteps = onToggleSteps ? stepsOpen : localStepsOpen; @@ -126,7 +129,7 @@ export default function WorkCard({
) : null} - {task.approval ? ( + {task.approval && shouldRenderInlineApproval(task.id, pendingApprovalTaskId ?? null) ? (
event.stopPropagation()}> Approval required {task.approval.command ? {task.approval.command} : null} diff --git a/src/components/WorkStream.tsx b/src/components/WorkStream.tsx index d6993b0..bc20bd5 100644 --- a/src/components/WorkStream.tsx +++ b/src/components/WorkStream.tsx @@ -201,6 +201,7 @@ export default function WorkStream({ onFocusTask, onOpenTask, onApproveTask, + pendingApprovalTaskId, }: { personalTasks: JarvisTasksResult | null; engineeringJob: JarvisEngineeringJobResult | null; @@ -222,6 +223,7 @@ export default function WorkStream({ task: TaskCard, choice: "once" | "session" | "always" | "deny", ) => void; + pendingApprovalTaskId: string | null; }) { return (