diff --git a/electron/jarvisActionClient.mjs b/electron/jarvisActionClient.mjs new file mode 100644 index 0000000..4ab0a8e --- /dev/null +++ b/electron/jarvisActionClient.mjs @@ -0,0 +1,117 @@ +/* + * Jarvis Action client — the Iris-side half of Action Transport v2 (P2.5). + * + * WHY THIS EXISTS AT ALL. jarvisBridgeClient.mjs reaches Jarvis by + * require()ing Jarvis's adapter module IN THE IRIS PROCESS. That is correct + * for READS (Ask Jarvis, Personal OS, jobs, goals, connections snapshot): + * they touch no approval state and no credentials. It is exactly WRONG for + * write actions — an in-process require gives Iris its own + * personal-os-action-service.cjs instance, i.e. a SECOND actionPreviews Map, + * a SECOND approval state machine and a secondary-approval gate that nobody + * else can see. And it could never execute a real Drive/Calendar write + * anyway: those credentials are safeStorage/Keychain-scoped to the Jarvis + * app identity, which Iris (its own app, its own --user-data-dir) can never + * assume. + * + * So propose/approve/secondaryApprove/cancel do NOT run here. They are sent + * to the ONE running Jarvis backend process over its loopback Action + * endpoint (Jarvis-Desktop/app/action-bridge-server.cjs) and executed there. + * This module owns no previewId list, no approval stage, no credentials and + * no execution state. A previewId is an opaque handle that is only ever + * meaningful inside Jarvis's own Action Service. + * + * Never throws: every failure — no Jarvis running, a dead port, a rejected + * token, a garbage response, a hang — comes back as {ok:false, error} so the + * renderer can show it. None of them may ever look like a successful + * approval. + */ +import { + createEndpointRequest, + createJarvisEndpointReader, + defaultActionEndpointPath, + DEFAULT_TIMEOUT_MS, + NO_BACKEND_ERROR, +} from "./jarvisEndpoint.mjs"; + +const ROUTES = Object.freeze({ + propose: "/action/propose", + approve: "/action/approve", + secondaryApprove: "/action/secondary-approve", + cancel: "/action/cancel", +}); + +/** + * loadActionEndpointReader() -> () => endpoint|null + * + * 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({ onUnavailable = () => {} } = {}) { + const storePath = defaultActionEndpointPath(); + const read = createJarvisEndpointReader({ storePath }); + return () => { + const endpoint = read(); + if (!endpoint) onUnavailable("no-endpoint"); + return endpoint; + }; +} + +function failure(error) { + return { ok: false, error: String(error) }; +} + +/** + * createJarvisActionClient({ readEndpoint, fetchImpl, timeoutMs }) + * + * readEndpoint is called on EVERY request, never cached: Jarvis restarts on + * a new ephemeral port with a new token, and a cached descriptor would + * outlive the process that published it. + */ +export function createJarvisActionClient({ + readEndpoint, + fetchImpl = globalThis.fetch, + timeoutMs = DEFAULT_TIMEOUT_MS, +} = {}) { + // 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() : ""; + if (!id) return Promise.resolve(failure("previewId fehlt.")); + return send(route, { previewId: id }); + } + + return { + /** Propose a write action from natural text. kind:"none" means "not an + * action" — the caller then falls through to the normal Ask Jarvis path. */ + proposeAction(question, { source = "text" } = {}) { + const text = typeof question === "string" ? question.trim() : ""; + if (!text) return Promise.resolve(failure("Frage fehlt.")); + return send(ROUTES.propose, { question: text, source }); + }, + /** First approval. For a high-risk action this only advances Jarvis's own + * state machine to secondary_approval_required and writes NOTHING. */ + approveAction(previewId) { + return withPreviewId(ROUTES.approve, previewId); + }, + /** The second, distinct approval — the only thing that completes a + * destructive action (Drive Trash, Calendar Delete). */ + secondaryApproveAction(previewId) { + return withPreviewId(ROUTES.secondaryApprove, previewId); + }, + cancelAction(previewId) { + return withPreviewId(ROUTES.cancel, previewId); + }, + }; +} + +export { ROUTES as JARVIS_ACTION_ROUTES, NO_BACKEND_ERROR }; diff --git a/electron/jarvisBackend.mjs b/electron/jarvisBackend.mjs new file mode 100644 index 0000000..62ca5c2 --- /dev/null +++ b/electron/jarvisBackend.mjs @@ -0,0 +1,124 @@ +/* + * Jarvis backend lifecycle — Iris is the only visible shell, Jarvis is its + * headless backend. + * + * WHY Iris has to start it at all: Jarvis owns the Connections Status + * producer (publishConnectionsStatus in Jarvis-Desktop/app/adapter/ + * iris-bridge.cjs, called from its electron-main.cjs). Only Jarvis's own + * Electron process can compute that status, because its Drive/Calendar + * credentials live in Electron safeStorage and its GMX/api keys behind the + * macOS Keychain — both keyed to the JARVIS app identity, which Iris (its + * own app, its own --user-data-dir) can never assume. + * + * WHY this is not a second backend: it launches Jarvis's real, unmodified + * electron-main.cjs with `--headless-backend`, which only suppresses the + * window/tray/shortcut. Jarvis's own single-instance lock guarantees it + * runs exactly once — a duplicate launch exits immediately, and a later + * `npm run jarvis` reaches the SAME process through its existing + * "second-instance" handler and simply shows the window. No parallel + * runtime, no second Action/Approval/Job state owner is created here. + * + * Never throws: a missing or unstartable Jarvis degrades to "no backend", + * and Iris still boots (Connections Status then honestly reports that + * Jarvis published nothing). + */ +import path from "node:path"; +import fs from "node:fs"; +import { spawn as defaultSpawn } from "node:child_process"; + +// The exact flag Jarvis's electron-main.cjs parses (headlessBackendMode). +export const HEADLESS_BACKEND_ARG = "--headless-backend"; + +const DEFAULT_PACKAGED_JARVIS = "/Applications/Jarvis.app/Contents/MacOS/Jarvis"; + +// A macOS .app bundle is a directory; the thing to exec lives inside it. +function executableForBundle(candidate) { + if (!candidate.endsWith(".app")) return candidate; + return path.join(candidate, "Contents", "MacOS", path.basename(candidate, ".app")); +} + +/** + * resolveJarvisLauncher({ repoRoot, env, platform, existsFn }) + * -> { command, args, mode } | null + * + * Order: an explicit JARVIS_APP_PATH, then the sibling dev checkout, then an + * installed Jarvis.app. The dev checkout deliberately outranks the installed + * app so a developer running Iris from source drives the source Jarvis. + */ +export function resolveJarvisLauncher({ + repoRoot, + env = process.env, + platform = process.platform, + existsFn = fs.existsSync, +} = {}) { + const explicit = env.JARVIS_APP_PATH; + if (explicit) { + return { command: executableForBundle(explicit), args: [HEADLESS_BACKEND_ARG], mode: "explicit" }; + } + + // Dev: the sibling Jarvis-Desktop checkout, launched through its OWN + // Electron install so the app identity (and therefore safeStorage) is + // Jarvis's, never Iris's. + const jarvisRoot = path.resolve(repoRoot, "..", "Jarvis-Desktop"); + const jarvisAppDir = path.join(jarvisRoot, "app"); + if (existsFn(jarvisAppDir)) { + // Jarvis-Desktop is a pnpm workspace and electron is a dependency of the + // `app` package, so the binary normally lives in app/node_modules/.bin; + // the repo-root path is only a fallback for a hoisted install. + const candidates = [ + path.join(jarvisAppDir, "node_modules", ".bin", "electron"), + path.join(jarvisRoot, "node_modules", ".bin", "electron"), + ]; + const devElectron = candidates.find((candidate) => existsFn(candidate)); + if (devElectron) { + return { command: devElectron, args: [jarvisAppDir, HEADLESS_BACKEND_ARG], mode: "dev" }; + } + } + + if (platform === "darwin" && existsFn(DEFAULT_PACKAGED_JARVIS)) { + return { command: DEFAULT_PACKAGED_JARVIS, args: [HEADLESS_BACKEND_ARG], mode: "packaged" }; + } + + return null; +} + +/** + * startJarvisBackend({ launcher, spawnFn, onLog }) -> child | null + * + * Deliberately NOT detached: the backend belongs to this Iris session and + * must go away with it. stdio is ignored so Jarvis's logs never interleave + * with Iris's own. + */ +export function startJarvisBackend({ launcher, spawnFn = defaultSpawn, onLog = () => {} } = {}) { + if (!launcher) { + onLog("Jarvis backend not found; Connections Status will report no published snapshot."); + return null; + } + try { + const child = spawnFn(launcher.command, launcher.args, { stdio: "ignore", detached: false }); + onLog(`Jarvis backend started headless (${launcher.mode}, pid ${child?.pid ?? "?"}).`); + return child; + } catch (error) { + onLog(`Jarvis backend could not be started: ${String(error?.message ?? error)}`); + return null; + } +} + +/** stopJarvisBackend(child) — terminate only a backend WE started. */ +export function stopJarvisBackend(child) { + if (!child || child.killed) return; + try { + child.kill("SIGTERM"); + } 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/electron/jarvisBridgeClient.mjs b/electron/jarvisBridgeClient.mjs new file mode 100644 index 0000000..640be01 --- /dev/null +++ b/electron/jarvisBridgeClient.mjs @@ -0,0 +1,183 @@ +/* + * Jarvis Bridge client — the Iris-side half of the thin adapter between + * Iris's voice pipeline and Jarvis's existing Ask Jarvis 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 { decideTurnOwner } from "./routingPolicy.mjs"; +import { createEndpointRequest } from "./jarvisEndpoint.mjs"; + +/* 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"; +} + +/** + * 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), + }; +} + +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 +// 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) }; + } +} + +// 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) }; + } +} + +// getConnectionsStatusForRenderer() — Jarvis Integrations/Connections +// readout (P2.4). Same never-throws, forward-the-bridge's-{ok,...}-result- +// as-is contract as every other *ForRenderer function above: no bridge is +// itself a distinct, honest {ok:false} rather than a silently empty list. +export async function getConnectionsStatusForRenderer(bridge) { + if (!bridge) return { ok: false, error: "Jarvis Bridge nicht verfügbar." }; + try { + return await bridge.getConnectionsStatus(); + } 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/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 2176562..35a4e84 100644 --- a/electron/main.mjs +++ b/electron/main.mjs @@ -1,5 +1,26 @@ import electron from "electron"; import { GoogleGenAI } from "@google/genai"; +import { + shouldAskJarvis, + createJarvisBridge, + askJarvisForTurn, + describeSmokeTranscript, + getTasksForRenderer, + getTopFocusForRenderer, + getCurrentContextForRenderer, + getLatestEngineeringJobForRenderer, + getActiveGoalForRenderer, + getConnectionsStatusForRenderer, +} from "./jarvisBridgeClient.mjs"; +import { + resolveJarvisLauncher, + startJarvisBackend, + stopJarvisBackend, +} from "./jarvisBackend.mjs"; +import { + createJarvisActionClient, + loadActionEndpointReader, +} from "./jarvisActionClient.mjs"; import { proposeHermesTask as gatePropose, claimConfirmedProposal, @@ -42,7 +63,7 @@ import { formatHermesCompletionEvent, normalizeHermesEvent, } from "./hermesEvents.mjs"; -import { classifyRoute, routingGuidance } from "./routingPolicy.mjs"; +import { classifyRoute, routingGuidance, decideTurnOwner } from "./routingPolicy.mjs"; import { APPROVAL_CHOICES, approvalAuthorized, @@ -80,6 +101,10 @@ const { app, BrowserWindow, ipcMain, session, nativeImage, Menu, Tray, screen, g const __dirname = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(__dirname, ".."); +// The headless Jarvis backend this Iris session started (null when Jarvis +// is not installed, or when starting it failed). Only ever a backend WE +// spawned — an already-running Jarvis is never touched. +let jarvisBackendProcess = null; // Name the app "Iris" (menu bar / about panel). The Dock tile fully reflects this // only in a packaged build; in dev the generic Electron bundle name is used. @@ -110,6 +135,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 +328,87 @@ 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. +// +// 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 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; +} + +// Action Transport v2 (P2.5) — write actions do NOT go through +// jarvisBridge() above. That path require()s Jarvis's adapter in THIS +// process, which for actions would mean a second actionPreviews Map, a +// second approval state machine and a secondary-approval gate nobody else +// can see (and which could not execute a Drive/Calendar write anyway — +// those credentials belong to the Jarvis app identity). Instead every +// propose/approve/secondaryApprove/cancel is sent to the ONE running Jarvis +// backend process over its loopback Action endpoint and executed there. +// Iris holds no previewId list and no approval state of its own; a +// previewId is an opaque handle into Jarvis's Action Service. +let cachedJarvisActionClient; +function jarvisActionClient() { + if (cachedJarvisActionClient === undefined) { + cachedJarvisActionClient = createJarvisActionClient({ readEndpoint: jarvisEndpointReader() }); + } + return cachedJarvisActionClient; +} + +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 +2181,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 +2561,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 +3252,7 @@ function handleLiveMessage(message) { scheduleUserTranscriptFlush(); if (userTranscriptBuffer.trim()) { lastUserRoute = classifyRoute(userTranscriptBuffer); + lastTurnOwner = decideTurnOwner(lastUserRoute); markUserSpoke(userTranscriptBuffer, { allowDuringReadback: modelTranscriptBuffer.trim().length >= MIN_AUDIBLE_READBACK_CHARS, @@ -3814,6 +3935,18 @@ app.whenReady().then(() => { } installAppMenu(); + // Jarvis backend — started headless so Iris stays the only visible shell. + // Jarvis owns the Connections Status producer (only its own process can + // read its safeStorage/Keychain-backed credentials), and its own + // single-instance lock guarantees exactly one Jarvis: a duplicate launch + // exits immediately. Never fatal — if Jarvis cannot be found or started, + // Iris boots anyway and Connections Status honestly reports that nothing + // was published. + jarvisBackendProcess = startJarvisBackend({ + launcher: resolveJarvisLauncher({ repoRoot }), + onLog: (message) => emitEvent({ type: "log", level: "info", message }), + }); + const devUrl = process.env.VITE_DEV_SERVER_URL ?? "http://127.0.0.1:5173"; const ipcTrust = { repoRoot, devUrl }; const trustedHandle = (channel, handler) => { @@ -3911,6 +4044,46 @@ 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())); + // 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())); + // Connections Status v1 (P2.4) — compact Jarvis integrations/connections + // readout (Personal OS/Drive/Calendar/GitHub/Web Research/Mail/Claude + // Worker) via the same in-process jarvisBridge() instance. Read-only, no + // second health engine — see Jarvis-Desktop/app/adapter/iris-bridge.cjs + // getConnectionsStatus(). + trustedHandle("jarvisBridge:getConnectionsStatus", () => getConnectionsStatusForRenderer(jarvisBridge())); + // Jarvis Actions & Approvals (P2.5) — the renderer's only path to a write + // action. Each handler is a pure forward into the running Jarvis backend + // process (electron/jarvisActionClient.mjs -> Jarvis's loopback Action + // endpoint -> personal-os-action-service.cjs). Nothing is decided here: + // no risk classification, no approval state, no execution. A high-risk + // action still stops at Jarvis's own secondary_approval_required gate and + // needs the separate jarvisAction:secondaryApprove call to complete. + trustedHandle("jarvisAction:propose", (_event, payload = {}) => + jarvisActionClient().proposeAction(String(payload.question || ""), { source: payload.source === "voice" ? "voice" : "text" })); + trustedHandle("jarvisAction:approve", (_event, previewId) => jarvisActionClient().approveAction(String(previewId || ""))); + trustedHandle("jarvisAction:secondaryApprove", (_event, previewId) => jarvisActionClient().secondaryApproveAction(String(previewId || ""))); + trustedHandle("jarvisAction:cancel", (_event, previewId) => jarvisActionClient().cancelAction(String(previewId || ""))); trustedHandle("app:open-external", (_event, url) => { const target = safeExternalUrl(url); if (target) return shell.openExternal(target); @@ -3935,6 +4108,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") { @@ -4003,7 +4184,13 @@ app.whenReady().then(() => { }); }); -app.on("will-quit", () => globalShortcut.unregisterAll()); +app.on("will-quit", () => { + globalShortcut.unregisterAll(); + // Iris is the visible shell and owns its backend's lifetime: the headless + // Jarvis it started goes away with it, so no orphan backend survives. + stopJarvisBackend(jarvisBackendProcess); + jarvisBackendProcess = null; +}); app.on("before-quit", () => { isQuitting = true; shuttingDown = true; diff --git a/electron/preload.cjs b/electron/preload.cjs index f1695c2..adce8dd 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -54,6 +54,23 @@ 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"), + getJarvisEngineeringJob: () => ipcRenderer.invoke("jarvisBridge:getLatestEngineeringJob"), + getJarvisActiveGoal: () => ipcRenderer.invoke("jarvisBridge:getActiveGoal"), + getJarvisConnectionsStatus: () => ipcRenderer.invoke("jarvisBridge:getConnectionsStatus"), + // Jarvis Actions & Approvals (P2.5). Every one of these is executed by the + // running Jarvis backend process, never here and never in Iris's main + // process — a previewId is an opaque handle into Jarvis's own Action + // Service. secondaryApprove is a SEPARATE call on purpose: a destructive + // action (Drive Trash, Calendar Delete) is not completed by approve alone. + proposeJarvisAction: (question, source) => ipcRenderer.invoke("jarvisAction:propose", { question, source }), + approveJarvisAction: (previewId) => ipcRenderer.invoke("jarvisAction:approve", previewId), + secondaryApproveJarvisAction: (previewId) => ipcRenderer.invoke("jarvisAction:secondaryApprove", previewId), + cancelJarvisAction: (previewId) => ipcRenderer.invoke("jarvisAction:cancel", previewId), 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..019b225 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", @@ -25,6 +25,9 @@ "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", + "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/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")); diff --git a/scripts/test-jarvis-action-smoke.mjs b/scripts/test-jarvis-action-smoke.mjs new file mode 100644 index 0000000..e369376 --- /dev/null +++ b/scripts/test-jarvis-action-smoke.mjs @@ -0,0 +1,242 @@ +/* + * P2.5 end-to-end smoke: Iris -> Ask Jarvis -> Action Preview -> Approval -> + * Jarvis backend process -> Result -> Iris. + * + * This is the whole point of P2.5 in one run, with nothing stubbed: + * - a real Iris Electron app, + * - which starts the real headless Jarvis backend process, + * - which binds its real loopback Action endpoint and publishes a real + * 0600 descriptor, + * - a real natural-language request typed into the real Ask Jarvis + * composer, + * - a real proposal produced by Jarvis's own capture/action engines, + * - a real human-style approval click in Iris, + * - a real write executed IN THE JARVIS PROCESS, + * - and a real result rendered back in Iris's transcript. + * + * SAFETY — read this before changing anything below. + * An approved Personal OS action performs a REAL file write. The vault it + * writes into is redirected to a throwaway directory via JARVIS_VAULT_PATH, + * and assertThrowawayVault() re-resolves Jarvis's OWN DEFAULT_VAULT under + * exactly the environment this script is about to use, then ABORTS BEFORE + * ELECTRON EVER LAUNCHES unless that path is inside the temp dir. The user's + * real Obsidian vault must never be reachable from this script — a variable + * that is set but not exported would silently fall back to it, which is + * exactly what this guard exists to make impossible. + * + * JARVIS_ENGINEERING_DIR is redirected the same way, for two reasons: the + * run leaves no state behind, and an unrelated Jarvis that happens to be + * running already (single-instance lock) publishes its endpoint to the + * DEFAULT dir, so this script simply will not find one and fails loudly + * instead of driving the real vault. + * + * Run: npm run test:jarvis-action-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 jarvisAppDir = path.resolve(root, "..", "Jarvis-Desktop", "app"); +const failureDir = path.join(root, "test-results"); +const failureScreenshot = path.join(failureDir, "jarvis-action-smoke-failure.png"); + +const REAL_VAULT = "/Users/cd/Documents/Obsidian-Mind/Cengiz-Mind"; +const REQUEST = "Notiere: P2.5 Action Smoke Aufgabe"; +const EXPECTED_NOTE = "00 Inbox/Capture/P2.5 Action Smoke Aufgabe.md"; + +const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "jarvis-action-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 }); + +// The production audit log must be byte-for-byte untouched by this run. Its +// size is recorded before launch and re-checked at the end — a positive +// assertion, not just a trust in the override. +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_START_PROD: "1", + IRIS_WAKE_WORD: "false", + IRIS_HERMES_AUTOSTART: "false", + IRIS_TEST_HOOKS: "1", + // 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, + // A propose arriving over the loopback Action endpoint carries only + // {question, source}, so it cannot use the Action Service's per-call + // params.auditPath injection and would otherwise append every lifecycle + // transition of this run to the user's REAL action-audit.jsonl. + JARVIS_ACTION_AUDIT_PATH: auditPath, +}; +delete env.ELECTRON_RUN_AS_NODE; + +/* + * The guard. Asks JARVIS'S OWN modules — not this script's assumptions — + * where they would write, under exactly the env we are about to hand them. + */ +function assertThrowawayVault() { + if (!env.JARVIS_VAULT_PATH) throw new Error("ABORT: JARVIS_VAULT_PATH is not set in the child environment."); + const resolved = execFileSync( + process.execPath, + ["-e", "process.stdout.write(require('./personal-os-reader.cjs').DEFAULT_VAULT)"], + { cwd: jarvisAppDir, env, encoding: "utf8" }, + ); + if (resolved === REAL_VAULT || resolved.startsWith(`${REAL_VAULT}/`)) { + throw new Error(`ABORT: Jarvis would write into the REAL Obsidian vault (${resolved}). Refusing to run.`); + } + if (path.resolve(resolved) !== path.resolve(vaultPath)) { + throw new Error(`ABORT: Jarvis resolved its vault to ${resolved}, expected the throwaway ${vaultPath}.`); + } + const resolvedEngineering = execFileSync( + process.execPath, + ["-e", "process.stdout.write(require('./engineering-runtime-paths.cjs').defaultEngineeringRuntimeDir())"], + { cwd: jarvisAppDir, env, encoding: "utf8" }, + ); + if (path.resolve(resolvedEngineering) !== path.resolve(engineeringDir)) { + throw new Error(`ABORT: Jarvis resolved its runtime dir to ${resolvedEngineering}, expected ${engineeringDir}.`); + } + if (!env.JARVIS_ACTION_AUDIT_PATH) throw new Error("ABORT: JARVIS_ACTION_AUDIT_PATH is not set in the child environment."); + const resolvedAudit = execFileSync( + process.execPath, + ["-e", "process.stdout.write(require('./personal-os-action-service.cjs').DEFAULT_ACTION_AUDIT_PATH)"], + { cwd: jarvisAppDir, env, encoding: "utf8" }, + ); + if (path.resolve(resolvedAudit) === path.resolve(PRODUCTION_AUDIT_PATH)) { + throw new Error(`ABORT: Jarvis would append to the REAL production audit log (${resolvedAudit}). Refusing to run.`); + } + if (path.resolve(resolvedAudit) !== path.resolve(auditPath)) { + throw new Error(`ABORT: Jarvis resolved its audit log to ${resolvedAudit}, expected the throwaway ${auditPath}.`); + } + console.log(`GUARD OK: Jarvis will write into the throwaway vault ${resolved}`); + console.log(`GUARD OK: Jarvis will append to the throwaway audit log ${resolvedAudit}`); +} + +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) throw new Error(`Timed out after ${timeoutMs}ms waiting for: ${label}`); + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } +} + +assertThrowawayVault(); + +const app = await electron.launch({ + args: [path.join(root, "electron", "main.mjs"), `--user-data-dir=${path.join(tmpRoot, "iris-user-data")}`], + cwd: root, + env, +}); + +try { + const page = await app.firstWindow(); + await page.waitForSelector(".deck", { timeout: 30000 }); + + // 1. The Jarvis backend process really came up and really published its + // loopback Action endpoint. Without this, everything below is theatre. + const endpointPath = path.join(engineeringDir, "action-endpoint.json"); + const endpoint = await waitFor( + "the Jarvis backend to publish its Action endpoint descriptor", + () => (fs.existsSync(endpointPath) ? JSON.parse(fs.readFileSync(endpointPath, "utf8")) : null), + 60000, + ); + if (!/^http:\/\/127\.0\.0\.1:\d+$/.test(endpoint.url)) throw new Error(`Action endpoint is not loopback: ${endpoint.url}`); + if (!endpoint.token) throw new Error("Action endpoint descriptor carries no token."); + if ((fs.statSync(endpointPath).mode & 0o077) !== 0) throw new Error("Action endpoint descriptor is group/world readable."); + if (endpoint.pid === process.pid) throw new Error("The Action endpoint must be owned by the Jarvis process, not this one."); + console.log(`PASS 1/6: Jarvis backend (pid ${endpoint.pid}) published a 0600 loopback endpoint at ${endpoint.url}`); + + // 2. A real natural request, typed into the existing Ask Jarvis composer. + const composer = page.locator('.comms-composer input[aria-label="Nachricht an Jarvis"]'); + await composer.waitFor({ timeout: 15000 }); + await composer.fill(REQUEST); + await composer.press("Enter"); + + // 3. Jarvis turned it into an Action and Iris is showing it for approval. + const card = page.locator(".jarvis-action").first(); + await card.waitFor({ timeout: 30000 }); + const proposed = await card.evaluate((node) => ({ + previewId: node.getAttribute("data-preview-id"), + risk: node.getAttribute("data-risk"), + status: node.getAttribute("data-status"), + summary: node.querySelector(".jarvis-action-summary")?.textContent ?? "", + })); + if (!proposed.previewId) throw new Error("The approval card carries no previewId."); + if (proposed.status !== "proposed") throw new Error(`Expected a proposed action, got status ${proposed.status}.`); + console.log(`PASS 2/6: Jarvis proposed an action for approval: ${JSON.stringify(proposed)}`); + + // 4. Nothing may have been written yet — approval is the gate, not the + // proposal. + if (fs.existsSync(path.join(vaultPath, EXPECTED_NOTE))) { + throw new Error("The note was written BEFORE approval — the approval gate is not doing its job."); + } + console.log("PASS 3/6: nothing was written before approval"); + + // 5. 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), 30000); + const note = fs.readFileSync(notePath, "utf8"); + if (!note.includes("P2.5 Action Smoke Aufgabe")) throw new Error("The written note does not contain the requested title."); + if (!note.includes("confirmation: yes")) throw new Error("The written note carries no confirmed audit trail entry."); + console.log(`PASS 4/6: the approved action really wrote ${EXPECTED_NOTE} inside the Jarvis process`); + + // 6. And the result came back into Iris's own transcript. + await page.waitForSelector(".comms-scroll .bubble.jarvis, .comms-scroll .bubble.jarvis-error", { timeout: 20000 }); + 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) { + throw new Error(`No execution result rendered in Iris. jarvis=${JSON.stringify(transcript.lines)} errors=${JSON.stringify(transcript.errors)}`); + } + if ((await page.locator(".jarvis-action").count()) !== 0) { + throw new Error("The approval card is still visible after the action completed."); + } + console.log(`PASS 5/6: the result reached Iris's transcript: ${JSON.stringify(done)}`); + + // 7. Isolation, proven both ways: the throwaway audit log really received + // this run's lifecycle transitions, and the production one is byte-for- + // byte untouched. + if (!fs.existsSync(auditPath)) throw new Error("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)); + const executed = auditEntries.filter((entry) => entry.actionId === proposed.previewId); + if (!executed.some((entry) => entry.status === "succeeded")) { + throw new Error(`The throwaway audit log holds no succeeded entry for ${proposed.previewId}: ${JSON.stringify(executed.map((entry) => entry.status))}`); + } + const productionAuditSizeAfter = fs.existsSync(PRODUCTION_AUDIT_PATH) ? fs.statSync(PRODUCTION_AUDIT_PATH).size : null; + if (productionAuditSizeAfter !== productionAuditSizeBefore) { + throw new Error(`The production audit log was modified (${productionAuditSizeBefore} -> ${productionAuditSizeAfter} bytes). This run must never touch it.`); + } + console.log(`PASS 6/6: ${executed.length} lifecycle entries landed in the throwaway audit log; production audit log untouched (${productionAuditSizeBefore} bytes)`); + + console.log("\nSMOKE PASS: Iris -> Ask Jarvis -> Action Preview -> Approval -> Jarvis backend -> Result -> Iris"); +} 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 + } + throw error; +} finally { + await app.close(); + // The temp tree only ever held throwaway state; leaving it behind would + // just accumulate stale bearer tokens. + try { fs.rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* best effort */ } +} 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/scripts/test-packaged-runtime-smoke.mjs b/scripts/test-packaged-runtime-smoke.mjs new file mode 100644 index 0000000..52059d5 --- /dev/null +++ b/scripts/test-packaged-runtime-smoke.mjs @@ -0,0 +1,517 @@ +/* + * 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"); +// 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"); + +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, + // 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; + +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) { + 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.`); +} + +// 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")}`], + cwd: root, + env, +}); + +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 }); + + // 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 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"); + + // 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(); + await page.screenshot({ path: failureScreenshot }); + console.error(`Failure screenshot: ${failureScreenshot}`); + } catch { + // best-effort screenshot only + } + await app.close().catch(() => {}); + if (secondApp) await secondApp.close().catch(() => {}); + throw error; + } + + // 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})`); + + // 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(() => {}); + } + + 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/src/App.tsx b/src/App.tsx index 4b59464..a0967f5 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,23 @@ 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); + // 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); + // Connections Status v1 (P2.4) — real Jarvis integrations/connections + // readout (Jarvis-Desktop/app/adapter/iris-bridge.cjs + // getConnectionsStatus()). Same null/{ok:false}/{ok:true} contract as the + // Personal OS states above, never demo data. + const [connectionsStatus, setConnectionsStatus] = useState(null); const [geminiStatus, setGeminiStatus] = useState("offline"); const [hermesStatus, setHermesStatus] = useState("offline"); const [audioState, setAudioState] = useState("idle"); @@ -251,13 +282,44 @@ 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); + window.iris.getJarvisEngineeringJob().then(setJarvisEngineeringJob); + window.iris.getJarvisActiveGoal().then(setJarvisActiveGoal); + }, [hasBridge]); + + // Connections Status v1 (P2.4) — refreshed on mount and then on a slow + // poll (integrations rarely flip mid-session, but this stays honest + // without requiring a manual reload). No live-write path exists here. + const CONNECTIONS_STATUS_POLL_MS = 60_000; + useEffect(() => { + if (!hasBridge) return; + let cancelled = false; + const refresh = () => { + window.iris.getJarvisConnectionsStatus().then((result) => { + if (!cancelled) setConnectionsStatus(result); + }); + }; + refresh(); + const id = window.setInterval(refresh, CONNECTIONS_STATUS_POLL_MS); + return () => { + cancelled = true; + window.clearInterval(id); + }; + }, [hasBridge]); + 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]); @@ -348,7 +410,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]); @@ -477,7 +541,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 +604,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 +680,158 @@ 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 text path: renderer -> preload + // -> main -> Jarvis Bridge -> askJarvis -> result -> back here, appended to + // 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. + function appendTranscriptLine(speaker: string, text: string) { + setTranscript((current) => [...current, { id: crypto.randomUUID(), speaker, text }].slice(-40)); + } + + // Jarvis Actions & Approvals (P2.5) — the previews Jarvis has proposed and + // is waiting on a human for. This is a RENDER LIST, not state Iris owns: + // every entry is just the display view of a preview that lives in Jarvis's + // Action Service, keyed by an opaque previewId. Iris never decides risk, + // never advances an approval stage, and never executes anything. + const [pendingActions, setPendingActions] = useState([]); + const [actionBusyId, setActionBusyId] = useState(null); + + function dropPendingAction(previewId: string) { + setPendingActions((current) => current.filter((preview) => preview.previewId !== previewId)); + } + + // askJarvisText — one natural request, two possible outcomes. + // + // Jarvis is asked FIRST whether the text is a write action (propose). If it + // is, the proposal is rendered for approval and nothing is executed; if it + // is not (kind "none"), the text falls through to the normal Ask Jarvis + // answer path exactly as before. Both the classification and the proposal + // are Jarvis's own (personal-os-capture-engine / action engines, reached + // through the running backend process) — Iris parses nothing. + async function askJarvisText(question: string) { + const trimmed = question.trim(); + if (!hasBridge || !trimmed) return; + appendTranscriptLine("you", trimmed); + + const proposal = await window.iris.proposeJarvisAction(trimmed, "text"); + if (!proposal.ok) { + // Visible and unambiguous: a text that MIGHT have been "notiere …" + // must never look like it was quietly handled. The read-only answer + // path below still runs, so Jarvis stays usable. + appendTranscriptLine("jarvis-error", `Aktionen nicht verfügbar: ${proposal.error}`); + } else if (proposal.kind === "clarification" && proposal.question) { + appendTranscriptLine("jarvis", proposal.question); + return; + } else if (proposal.previews?.length) { + setPendingActions(proposal.previews); + appendTranscriptLine( + "jarvis", + proposal.previews.length === 1 + ? "Ich habe eine Aktion vorbereitet — bitte freigeben oder verwerfen." + : `Ich habe ${proposal.previews.length} Aktionen vorbereitet — bitte einzeln freigeben oder verwerfen.`, + ); + return; + } + + const result = await window.iris.askJarvis(trimmed); + appendTranscriptLine( + result.ok ? "jarvis" : "jarvis-error", + result.ok ? (result.answer ?? "") : `Jarvis-Anfrage fehlgeschlagen: ${result.error}`, + ); + } + + // Approval handlers. Each one is a single call into the running Jarvis + // backend process; the outcome it renders is whatever Jarvis reports. + // requiresSecondaryApproval means NOTHING was written — the preview stays + // in the list, now in Jarvis's secondary_approval_required state, and the + // UI switches to the separate second button. + async function runActionCall( + preview: JarvisActionPreview, + call: (previewId: string) => Promise, + ) { + setActionBusyId(preview.previewId); + try { + const result = await call(preview.previewId); + if (!result.ok) { + appendTranscriptLine("jarvis-error", `Aktion fehlgeschlagen: ${result.error}`); + dropPendingAction(preview.previewId); + return; + } + if (result.requiresSecondaryApproval && result.preview) { + const advanced = result.preview; + setPendingActions((current) => + current.map((entry) => (entry.previewId === advanced.previewId ? advanced : entry)), + ); + appendTranscriptLine("jarvis", "Diese Aktion ist nicht umkehrbar und braucht eine zweite Freigabe."); + return; + } + dropPendingAction(preview.previewId); + const target = typeof result.object?.title === "string" ? result.object.title : preview.title; + appendTranscriptLine("jarvis", `${result.answer || "Erledigt."} (${result.action || preview.type}: ${target})`); + } finally { + setActionBusyId(null); + } + } + + function handleApproveAction(preview: JarvisActionPreview) { + void runActionCall(preview, (previewId) => window.iris.approveJarvisAction(previewId)); + } + + function handleSecondaryApproveAction(preview: JarvisActionPreview) { + void runActionCall(preview, (previewId) => window.iris.secondaryApproveJarvisAction(previewId)); + } + + async function handleCancelAction(preview: JarvisActionPreview) { + setActionBusyId(preview.previewId); + try { + const result = await window.iris.cancelJarvisAction(preview.previewId); + dropPendingAction(preview.previewId); + appendTranscriptLine( + result.ok ? "jarvis" : "jarvis-error", + result.ok ? "Aktion verworfen." : `Aktion konnte nicht verworfen werden: ${result.error}`, + ); + } finally { + setActionBusyId(null); + } + } + + 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 @@ -899,6 +1125,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 { @@ -1159,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], @@ -1481,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} @@ -1508,22 +1740,34 @@ export default function App() { setHandControl((current) => !current)} onOpenSettings={openSettings} + connectionsStatus={connectionsStatus} />
{/* LEFT — You */}
+ void resolveTaskApproval(task, choice)} + pendingApprovalTaskId={pendingApprovalTaskId} />
@@ -1621,7 +1869,12 @@ export default function App() { ) : null} {showHistory ? ( - setShowHistory(false)} /> + setShowHistory(false)} + pendingApprovalTaskId={pendingApprovalTaskId} + /> ) : null} {taskChooser ? ( diff --git a/src/components/CameraDock.tsx b/src/components/CameraDock.tsx index c2e3ff7..5cce248 100644 --- a/src/components/CameraDock.tsx +++ b/src/components/CameraDock.tsx @@ -70,8 +70,8 @@ export default function CameraDock({
- 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 f735b97..dc91b4c 100644 --- a/src/components/CommsPanel.tsx +++ b/src/components/CommsPanel.tsx @@ -1,23 +1,51 @@ -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"; +import JarvisActionApproval from "./JarvisActionApproval"; export default function CommsPanel({ transcript, scrollRef, testDataEnabled, onLoadDemo, + textDraft, + onTextDraftChange, + onSendText, + textSending, + actionPreviews = [], + actionBusyId = null, + onApproveAction = () => {}, + onSecondaryApproveAction = () => {}, + onCancelAction = () => {}, }: { transcript: TranscriptLine[]; scrollRef: RefObject; testDataEnabled: boolean; onLoadDemo: () => void; + textDraft: string; + onTextDraftChange: (value: string) => void; + onSendText: (text: string) => void; + textSending: boolean; + // Jarvis Actions & Approvals (P2.5) — rendered inside the existing Ask + // Jarvis surface, directly above the composer, so the approval sits next + // to the sentence that produced it. All optional with safe defaults, so + // nothing about the existing transcript/composer behaviour changes. + actionPreviews?: JarvisActionPreview[]; + actionBusyId?: string | null; + onApproveAction?: (preview: JarvisActionPreview) => void; + onSecondaryApproveAction?: (preview: JarvisActionPreview) => void; + onCancelAction?: (preview: JarvisActionPreview) => void; }) { + function handleSubmit(event: FormEvent) { + event.preventDefault(); + onSendText(textDraft); + } + return (
- Comms + Kommunikation
{transcript.length === 0 ? ( @@ -25,8 +53,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 ? ( +
); } diff --git a/src/components/ConnectionsStatus.tsx b/src/components/ConnectionsStatus.tsx new file mode 100644 index 0000000..abc008d --- /dev/null +++ b/src/components/ConnectionsStatus.tsx @@ -0,0 +1,87 @@ +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 +// Jarvis-Desktop/app/adapter/iris-bridge.cjs getConnectionsStatus()). No +// reconnect/retry control lives here — this only shows what Jarvis already +// knows about its own integrations, exactly like PersonalFocusPanel already +// does for Top Focus/Current Context: null = not yet loaded, {ok:false} = +// bridge unavailable, both rendered as an honest, distinct state, never a +// fabricated placeholder. +const STATE_LABEL: Record = { + connected: "VERBUNDEN", + not_connected: "NICHT VERBUNDEN", + unavailable: "NICHT ERREICHBAR", +}; + +export default function ConnectionsStatus({ + result, +}: { + result: JarvisConnectionsStatusResult | null; +}) { + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + + useEffect(() => { + if (!open) return; + const onPointerDown = (event: MouseEvent) => { + if (rootRef.current && !rootRef.current.contains(event.target as Node)) setOpen(false); + }; + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape") setOpen(false); + }; + document.addEventListener("mousedown", onPointerDown); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onPointerDown); + document.removeEventListener("keydown", onKey); + }; + }, [open]); + + const connections = result?.ok ? (result.data?.connections ?? []) : []; + const connectedCount = connections.filter((entry) => entry.status === "connected").length; + const summary = result === null ? LOADING_PLACEHOLDER : `${connectedCount}/${connections.length}`; + + return ( +
+ + {open ? ( +
+
+ + Jarvis-Verbindungen + {summary} +
+ {result && !result.ok ? ( +

{result.error || "Verbindungsstatus nicht verfügbar."}

+ ) : connections.length ? ( +
    + {connections.map((entry) => ( +
  • +
  • + ))} +
+ ) : ( +

Wird geprüft …

+ )} +
+ ) : null} +
+ ); +} 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/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/components/PersonalFocusPanel.tsx b/src/components/PersonalFocusPanel.tsx new file mode 100644 index 0000000..c31ffbf --- /dev/null +++ b/src/components/PersonalFocusPanel.tsx @@ -0,0 +1,97 @@ +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 +// 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, + activeGoal, +}: { + topFocus: JarvisTopFocusResult | null; + context: JarvisCurrentContextResult | null; + activeGoal: JarvisActiveGoalResult | 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; + + const goal = activeGoal?.ok ? activeGoal.data ?? null : null; + const goalError = activeGoal && !activeGoal.ok ? activeGoal.error : null; + + return ( + + ); +} diff --git a/src/components/SessionSwitcher.tsx b/src/components/SessionSwitcher.tsx index a0932e7..dc58349 100644 --- a/src/components/SessionSwitcher.tsx +++ b/src/components/SessionSwitcher.tsx @@ -44,6 +44,7 @@ export default function SessionSwitcher({ const [open, setOpen] = useState(false); const [sessions, setSessions] = useState([]); const [loading, setLoading] = useState(false); + const [hasFetched, setHasFetched] = useState(false); const rootRef = useRef(null); async function refresh() { @@ -52,6 +53,8 @@ export default function SessionSwitcher({ setSessions(result.ok ? result.sessions : []); } catch { setSessions([]); + } finally { + setHasFetched(true); } } @@ -90,7 +93,14 @@ export default function SessionSwitcher({ } const currentSession = sessions.find((session) => session.id === current); - const chipLabel = currentSession ? sessionLabel(currentSession) : current || "iris-voice"; + + // Nothing real to switch between (Hermes not installed/reachable) — hide + // entirely rather than exposing an internal default session id like + // "iris-voice" to the user. Functionality is unchanged: as soon as a real + // session exists, this renders exactly as before. + if (hasFetched && sessions.length === 0 && !currentSession) return null; + + const chipLabel = currentSession ? sessionLabel(currentSession) : current; // A thread can be missing from the list briefly (e.g. Hermes unreachable); // pin the current id on top so the selection is always visible. diff --git a/src/components/TopBar.tsx b/src/components/TopBar.tsx index 2365c41..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 ( @@ -12,21 +13,25 @@ function StatusDot({ tone, state, label }: { tone: string; state: string; label: export default function TopBar({ geminiDot, hermesDot, + hermesAvailable, audioDot, linked, pid, handControl, onToggleHand, onOpenSettings, + connectionsStatus, }: { geminiDot: string; hermesDot: string; + hermesAvailable: boolean; audioDot: string; linked: boolean; pid: number | null; handControl: boolean; onToggleHand: () => void; onOpenSettings: () => void; + connectionsStatus: JarvisConnectionsStatusResult | null; }) { return (
@@ -35,7 +40,10 @@ export default function TopBar({ hiddenInset) — padding in .deck-top-left clears their footprint. */}
- + {/* Never shown until Hermes has actually reported a real status at + least once — an unconfigured/not-installed Hermes must not sit + next to Gemini implying equal, always-expected availability. */} + {hermesAvailable ? : null}
@@ -43,6 +51,7 @@ export default function TopBar({ I.R.I.S
+
) : 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 f9a278c..bc20bd5 100644 --- a/src/components/WorkStream.tsx +++ b/src/components/WorkStream.tsx @@ -5,7 +5,187 @@ 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} + +
+ ))} +
+ ); + })} +
+ ); +} + +// 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.result?.text ? ( +
+ Ergebnis +

{job.result.text}

+
+ ) : null} + {job.events?.length ? ( +
+ Ereignisverlauf + {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, @@ -21,7 +201,10 @@ export default function WorkStream({ onFocusTask, onOpenTask, onApproveTask, + pendingApprovalTaskId, }: { + personalTasks: JarvisTasksResult | null; + engineeringJob: JarvisEngineeringJobResult | null; tasks: TaskCard[]; sortedTasks: TaskCard[]; scrollRef: RefObject; @@ -40,12 +223,13 @@ export default function WorkStream({ task: TaskCard, choice: "once" | "session" | "always" | "deny", ) => void; + pendingApprovalTaskId: string | null; }) { return (