Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions electron/jarvisActionClient.mjs
Original file line number Diff line number Diff line change
@@ -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 };
124 changes: 124 additions & 0 deletions electron/jarvisBackend.mjs
Original file line number Diff line number Diff line change
@@ -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();
}
Loading