diff --git a/.changelog/NEXT.md b/.changelog/NEXT.md index db560925a4..37cf37b1d4 100644 --- a/.changelog/NEXT.md +++ b/.changelog/NEXT.md @@ -35,5 +35,11 @@ TBD - Series detail page: when the Story Bible drawer is open, the Series Arc + Editorial Roadmap split and the inner text + 260px Themes panel split now respond to the actual content-area width instead of viewport width. Switched to Tailwind v4 container queries — Roadmap drops below Arc when the content area is < 1024px, and Themes stacks below the logline/summary column when the Arc card is < 672px, preventing the text column from being squeezed into an unreadable strip. - Vite dev server: allow `*.ts.net` hosts so `npm run dev` works when launched via Tailscale MagicDNS (previously rejected with `Blocked request. This host ("…ts.net") is not allowed`). - Universe Builder base style image lightbox: clicking the rendered probe thumb opened the preview modal with `"Base style"` in the prompt field instead of the actual prompt sent to the renderer. The Universe Builder hydrates each preview item from `galleryByFilename` (built from `listImageGallery()`) so the modal sees the real sidecar prompt/seed/model; that map only refreshes on `runs.length` change or an explicit `bumpGalleryRefresh()` call, and the style-probe path never called the refresh, so its entry fell through to the row label. `StyleProbeImage` now fires an `onRenderComplete` callback after the new filename persists, and `UniverseBuilder` wires it to `bumpGalleryRefresh`. +- Video Gen "Local Python not configured" warning now exposes the Detect / install / Create-venv flow inline on the page instead of linking out to the settings drawer — `LocalSetupPanel` renders directly below the disconnected status pill, wired to the same settings PATCH the drawer uses. Saving a new `pythonPath` re-polls status so the pill flips green without a manual refresh. +- Local video gen on macOS was installing the wrong `mlx_video` PyPI package. The plain `mlx_video` package is unrelated (video classification/captioning) and lacks the `mlx_video.generate_av` CLI that the LTX renderer shells into — both packages publish an `import mlx_video` namespace so the missing-package check passed, but the spawn died with `No module named mlx_video.generate_av`. Fixed: `pipNameFor('mlx_video')` now returns `mlx-video-with-audio>=0.1.35` on macOS; the import probe now checks `import mlx_video.generate_av` instead of `import mlx_video` so the wrong package fails fast and the UI surfaces an install button; `installPackages()` gained a pre-uninstall step driven by a `PIP_PRE_UNINSTALL` map so the conflicting plain `mlx_video` is removed before pip refuses to "downgrade" across the name collision. Mirrors the same conflict-resolution flow `scripts/setup-image-video.sh` already used out-of-band. +- Media job worker now re-resolves `imageGen.local.pythonPath` from live settings at run time for every video job (and every non-codex image job), instead of using the snapshot captured at enqueue time. Symptom this fixes: user switches their Python in the UI, clicks Generate, and the worker still shells out to the previous (broken) interpreter — because both the in-memory queue and the on-disk `media-jobs.json` carried the old path. Now the persisted snapshot is irrelevant; live settings always win. Codex image jobs are unaffected (they don't run a local Python). +- `isExternallyManaged()` no longer false-positives on PortOS-owned venvs created from a PEP 668 base (e.g. Homebrew Python). Inside a venv, `sysconfig.get_path("stdlib")` resolves to the base interpreter's stdlib, so a venv created from Homebrew inherited the `EXTERNALLY-MANAGED` marker even though pip-in-venv ignores PEP 668 entirely. The check now also reads `sys.prefix` and `sys.base_prefix` and short-circuits to `false` when they differ (the canonical "am I in a venv?" test). Symptom this fixes: after switching from Anaconda to Homebrew and clicking "Create PortOS venv", the panel showed the new venv path but still asked to create a venv — because the new venv looked externally-managed too, hiding the regular "Install N missing packages" button. +- Video Gen status pill no longer lies when the saved Python is missing required packages. Previously `/api/video-gen/status` returned `connected: true` whenever any `pythonPath` was stored, so a Python with no `mflux` / `mlx` / `mlx_video` installed showed a green pill until the user clicked Generate and the renderer crashed. `/status` now probes the imports via `checkPackages()` and returns `connected: false` + a `missingPackages` list when anything is absent, which triggers the same inline `LocalSetupPanel`. The panel's header copy adapts: "Set up Local Python" when no path is selected, "Install missing Python packages" (with the count) when the path is valid but packages are missing. +- Local Python auto-detection on Apple Silicon Macs now skips x86_64 candidates: a default Anaconda install (`/opt/anaconda3/bin/python3`) was winning over `/opt/homebrew/bin/python3` and then failing at install time with `No matching distribution found for mlx` because `mlx` ships arm64-only wheels. `detectPython()` now probes `platform.machine()` of each candidate on `darwin/arm64` and prefers a matching interpreter. The `/api/image-gen/setup/check` response also gains `interpreterArch`, `hostArch`, `archMismatch`, and `suggestedArm64Python` fields; `LocalSetupPanel` surfaces a warning and a one-click "Switch to detected arm64 Python" button when the user's saved path is x86_64 on an Apple Silicon host. ## Removed diff --git a/PLAN.md b/PLAN.md index 312233b5e1..526c07f292 100644 --- a/PLAN.md +++ b/PLAN.md @@ -8,10 +8,17 @@ _Batch-cleared 2026-05-25: 23 Next Up items shipped together via parallel sub-ag - [ ] [flux2-multi-reference-python-runner] **FLUX.2 multi-reference Python runner.** The UI + server contract for multi-reference editing shipped 2026-05-17 (slug `multi-reference-image-editing-for-flux-2-ui`); the Python runner (`scripts/flux2_macos.py`) currently ignores the `--reference-images`/`--reference-strengths` args that `local.js` now passes. Wire diffusers' multi-reference API in the runner and swap `server/lib/mediaModels.js#flux2-klein-9b` `tokenizerRepo` to `FLUX.2-klein-9B-kv` (gated repo — requires the user to accept the license on HF). Validate end-to-end with 2–4 uploaded refs. _(Deferred 2026-05-25 from the batch-clear: blocked on gated HF model + GPU validation; can't be verified autonomously.)_ - [ ] [apple-health-integration-live-sync] **Apple Health integration for MeatSpace.** iOS live sync (HealthKit Shortcut → `POST /api/meatspace/apple-health` endpoint) plus a bulk historical import path for an exported `export.xml`. Wire into existing MeatSpace tabs so steps / sleep / heart rate / VO2 max / resting HR show alongside the alcohol / blood / body / epigenetic tracks already shipped. GOALS.md flags this as a documented Secondary Goal ("Apple Health integration planned") but no implementation tracking existed until this entry. When this lands, also fold an Apple-Health-imported signal into the Capabilities page "Genome & Health" row (`server/lib/capabilityMap.js#genomeRow` + the route's `genome` fetch) so a health-only setup no longer reports "Not set up" purely because no genome is uploaded (codex review of `[codex5-onboarding-capability-map]`, 2026-05-24). _(Deferred 2026-05-25 from the batch-clear: multi-file feature with an iOS Shortcut component that needs a device to verify.)_ +- [ ] [triage-gemini-pr515-out-of-scope-findings] **Triage repo-wide findings gemini surfaced during PR #515 review (UNVERIFIED, out-of-scope from the local-Python-setup PR).** Same root cause as the PR #508 entry below — gemini's `/do:review` reviews the whole repo instead of the branch diff. Items, in files PR #515 does NOT touch: (1) `server/services/commands.js:~28` — `executeCommand` uses naive `split(/\s+/)` instead of `validateCommand` / `parseCommandArgs` from `commandSecurity.js`; quoted args containing spaces break. (2) `server/lib/fileUtils.js:~482` — `createCachedStore.save` uses `writeFile` directly instead of the canonical `atomicWrite`; mid-write crash corrupts the cache file. (3) `server/services/cosAgents.js:~52` — `saveAgentIndex` re-implements atomic-temp-rename logic instead of using `atomicWrite`. (4) `server/services/cosAgents.js:~181` — `completeAgent` writes `metadata.json` non-atomically. (Gemini also flagged `server/index.js` body limit + `*` CORS; both are intentional per CLAUDE.md Security Model and were dismissed.) Surfaced 2026-05-28. Verify each is still reproducible before acting; gemini's `/do:review` has a track record of pattern-matching false-positives. - [ ] [triage-gemini-out-of-scope-findings] **Triage repo-wide findings gemini surfaced during PR #508 review (UNVERIFIED, out-of-scope from the voice PR).** A `/do:review` gemini pass reviewed the whole repo instead of the branch diff and flagged these pre-existing items in files PR #508 doesn't touch. Not applied to that PR (scope creep); several may be moot under the single-user/private-network trust model — verify before acting: (1) `server/routes/database.js:~236` — DB sync builds a `bash -c` string with `dumpFile` interpolated; consider `spawn` + piped stdin instead of shell string interpolation. (2) `server/services/apps.js:~304` — `updateAppTaskTypeOverride`/`bulkUpdateAppTaskTypeOverride` don't validate `taskType` against `SELF_IMPROVEMENT_TASK_TYPES`. (3) `server/routes/apps.js:~888` — `open-editor`/`open-claude` use `shell:false` for commands that are `.cmd` shims on Windows (would fail on Windows); needs a `needsShell` check + Windows metachar validation. (4) `server/lib/httpClient.js:~110` — `insecureFetch` does a bare `JSON.parse(buffer)` that can throw synchronously; wrap or reject. (5) `server/index.js` god-file (already noted in Deferred Architecture). Surfaced 2026-05-26. - [ ] [voice-code-agent-target-managed-app] **Voice code-agent delegation can't target a managed app yet.** The `dispatch_code_agent` voice tool (`server/services/voice/tools.js`) creates a CoS user task with no `app` set, so the agent always runs against the PortOS repo (the CoS default workspace). Add an optional spoken target ("…in BookLoom") → resolve to an app id and pass `app` through to `addTask` (cos.js already persists `metadata.app`, and `agentLifecycle.js#registerAgent` already reads it). Needs a phrase→app resolver (fuzzy match against managed app names) and a guard for "app not found." Deferred from the initial build (2026-05-26) to keep v1 scoped to the self-repo case. - [ ] [voice-code-agent-status-query] **No mid-task voice status query for dispatched coding agents.** Once a task is dispatched via `dispatch_code_agent`, the user can only learn the outcome from the completion announcement — there's no "how's that coding task going?" tool. Add a `code_agent_status` voice tool that reads `data/cos/state.json` for running agents tagged `metadata.voiceDispatch` and reports phase/elapsed. Deferred from the initial build (2026-05-26); the completion announcement covers the common case. - [ ] [voice-code-agent-announce-pr-url] **Completion announcement can't speak the PR link.** `agent:completed` (cosAgents.js) fires BEFORE `cleanupAgentWorktree` creates the PR (agentLifecycle.js), so `formatAgentCompletionLine` (proactiveTriggers.js) only speaks success/failure + the task description, not the PR. To include "PR #512 is up," either (a) emit a later `agent:pr-opened` event from the cleanup path carrying `{ taskId, prUrl, voiceDispatch }` and announce on that instead, or (b) have the announcement defer until the agent record gains a `prUrl`. Deferred 2026-05-26 — a spoken GitHub URL is poor UX anyway and the user reviews the PR visually; the "done" announcement is enough for v1. +- [ ] [patch-settings-slice-helper] **Add a `patchSettingsSlice(slicePath, partial)` helper in `client/src/services/`.** The settings PUT shallow-merges top-level keys, so every caller that updates a nested field (`imageGen.local.pythonPath`, `sharing.*`, `backup.*`, etc.) re-implements the same fetch-then-spread pattern. Current sites: `ImageGenTab.handleSave` (`client/src/components/settings/ImageGenTab.jsx:211`), `VideoGen.handleSavePythonPath`, `SharingTab`, `BackupTab`, `MortalLoomTab`, `Sharing.jsx`, `StoryboardPanel`, `NounsStage`, `ComicScriptStage` — 9+ sites. A `patchSettingsSlice('imageGen.local', { pythonPath })` helper would eliminate the slice-clobbering bug class. Surfaced by /simplify on 2026-05-28; deferred because it's a cross-cutting refactor of every settings consumer. +- [ ] [warning-banner-component] **Extract a `` component.** The `bg-port-warning/10 border border-port-warning/30 rounded p-2 + AlertTriangle icon` pattern is used 30+ places across `client/src/` (Loras, Security, CreateApp, MemoryTab, ScheduleTab, EditAppModal, BrainGraph, LocalSetupPanel, etc.). No shared component exists — every site re-inlines the same Tailwind classes. Same applies to the matching success/error/info banner variants. Surfaced by /simplify on 2026-05-28. +- [ ] [setup-check-cache] **Server-side cache for `/api/image-gen/setup/check` results, keyed by `(pythonPath, stat(pythonPath).mtimeMs)`.** The `LocalSetupPanel` calls `/setup/check` on debounced (400ms) keystrokes AND on mount AND on refresh-button — each call spawns a python subprocess (~0.5-1s warm). A 30s TTL cache, busted by `/setup/install` completion + settings-PUT-of-pythonPath, would collapse most repeats to memo hits. Less critical now that the 3 subprocesses are consolidated into one, but still hot for typing flows. Surfaced by /simplify on 2026-05-28. +- [ ] [client-use-previous-hook] **Extract `usePrevious(value)` hook in `client/src/hooks/`.** The "compare current to last render via `useRef` + `useEffect`" pattern appears in `LocalSetupPanel.jsx:52-61` (transition-detection for `onPackagesChanged`) and `useMediaJobProgress.js:44` (`prevJobIdRef`). A shared hook + barrel + README row would shrink both to one line each. Surfaced by /simplify on 2026-05-28. +- [ ] [mediajobqueue-resolve-live-params] **Extract `resolveLiveParams(job, safeParams)` in `server/services/mediaJobQueue/index.js#runJob`.** The 8-line block at line 605 that re-resolves `pythonPath` from live settings mixes a settings-read concern into the (already-long) sanitize-uploads section. Pulling it into its own helper makes `runJob` easier to skim and gives the live-settings concern its own seam for future fields (e.g. `model.runtime`-aware overrides). Surfaced by /simplify on 2026-05-28. +- [ ] [pythonsetup-arch-tests] **Test coverage for arch-aware `detectPython()` + `/setup/check` arch fields.** Added 2026-05-28 with the VideoGen inline Local Python setup fix. New helpers (`probePythonArch`, `isArchMismatch`, `detectArm64Python`, `HOST_ARCH`) and the new `/api/image-gen/setup/check` response fields (`interpreterArch`, `hostArch`, `archMismatch`, `suggestedArm64Python`) have no test coverage — the existing `server/routes/imageGen.test.js` skips all `/setup/*` routes entirely. Worth a `pythonSetup.test.js` that mocks `node:os` + `node:child_process` to verify: (1) on `darwin/arm64`, `detectPython` prefers arm64 candidates over x86_64; (2) `/setup/check` includes the new arch fields and only sets `archMismatch: true` when the host is arm64; (3) `suggestedArm64Python` is null when no viable arm64 candidate exists. Deferred from the fix because the `/setup/*` route family has zero existing test harness and adding one is its own piece of work. - [ ] [mediacard-use-mediaimage-for-syncing-assets] **`MediaCard.jsx` grid thumbnails still use a raw ``.** Same peer-sync placeholder/live-swap gap that `[peer-sync-medialightbox-use-mediaimage-for-syncing-assets]` fixed for the lightbox — `client/src/components/media/MediaCard.jsx` (~line 42) doesn't get the "Syncing" placeholder or the `peerSync:asset-arrived` atomic swap. Swap the raw `` for `MediaImage`. Surfaced by that item's cross-check during the batch-clear (2026-05-25); was outside its stated scope. - [ ] [chrome-canary-followups] **Hardening for the custom-Chrome-binary feature (xhigh code-review 2026-05-25).** _DONE in the v2.10.0 release review: (b) both `spawn()` calls now have `.on('error', …)` listeners; (d) `browser/server.js#loadConfig` now try/catches the `JSON.parse`; (e) `setup-browser.js#loadConfig` now warns + returns `null` and `applyCanaryToConfig` skips the save when the existing config is unreadable; (f) top-level `runCanarySetup()` is now `.catch()`-wrapped. (c) is tracked separately in `[setup-browser-canary-headless]`. Remaining: (a), (g)–(o)._ **High-severity:** (a) On macOS headed mode (the default), `browser/server.js:208` uses `macAppBundle` only and silently ignores `chromePath` — a UI user who fills in `chromePath` for Canary/Chromium/Brave but leaves `macAppBundle` empty gets stock Chrome and the log misreports the binary; couple the two fields in the UI (or auto-derive `macAppBundle` from `chromePath` when the latter is inside a `.app`). (b) Neither `spawn(chromePath, …)` (line 216) nor `spawn('/usr/bin/open', …)` (line 208) has an `.on('error', …)` listener — a typo in `chromePath` emits 'error' with no listener → `uncaughtException` → portos-browser PM2 child crashes and restart-loops. (c) `scripts/setup-browser.js#applyCanaryToConfig` writes `chromePath` + `macAppBundle` but doesn't flip `headless: false`; combined with the seed default of `headless: true` (`data.reference/browser-config.json`), a fresh install accepting Canary runs Canary invisibly. (d) `browser/server.js#loadConfig` does bare `JSON.parse(raw)` with no try/catch — combined with setup-browser's non-atomic `writeFileSync` and the `cachedConfig` race, a partial-file write crashes the supervisor on next start (PM2 restart-loop forever). **Medium-severity:** (e) `setup-browser.js#loadConfig` silently catches all JSON parse errors and returns `{}`, then `applyCanaryToConfig` saves `{chromePath, macAppBundle}` only — wiping every other user-customized key. (f) Top-level `await runCanarySetup()` has no try/catch — any EACCES on data/browser-config.json aborts `npm run setup` / `update.sh` at what was previously a no-op step. (g) Idempotency guard only checks `chromePath`: users who decline get re-prompted on every update, and users who set only `macAppBundle` keep getting re-prompted. (h) `PORTOS_USE_CANARY` only matches literal `'0'`/`'false'` (opt-out) or `'1'`/`'true'` (opt-in) — `'no'`/`'off'`/`'yes'`/`'on'`/`'True'` fall through both branches. (i) `spawnSync(install.cmd, …, { stdio: 'inherit' })` for brew/winget can hang on a sudo password prompt under non-TTY + `PORTOS_USE_CANARY=1` (update.sh stalls). (j) `cachedConfig` in `browserService.js` is stale relative to setup-browser's direct write — GET /api/browser/config returns pre-update values until process restart. (k) `saveConfig` uses bare `writeFileSync` — switch to the canonical `atomicWrite` pattern (`server/lib/fileUtils.js`). **Low-severity:** (l) `spawnSync` failure-status check `result.status !== 0` treats `status: null` (spawn-failure / signal kill) identically to a non-zero exit and never logs `result.error` — masks ENOENT/EPERM/SIGKILL. (m) `optionalPath` Zod schema accepts any string up to 1024 chars; no `.app`/`.exe` sanity check — user pastes the bundle into `chromePath` → spawn() EISDIR. (n) `launchBrowser`'s reuse-existing-Chrome early-return (line 167) fires BEFORE `headlessMode = config.headless === true` (line 170), leaving the module-level default `false` after a PM2 restart that reuses Chrome — /health reports wrong mode (pre-existing, but in a function touched by this change). (o) `detectCanary` on macOS only checks `/Applications/...`; misses per-user `~/Applications/Google Chrome Canary.app` installs (corporate Macs, `HOMEBREW_CASK_OPTS=--appdir=$HOME/Applications`). diff --git a/client/src/components/settings/LocalSetupPanel.jsx b/client/src/components/settings/LocalSetupPanel.jsx index 537d3ff1a4..0728574258 100644 --- a/client/src/components/settings/LocalSetupPanel.jsx +++ b/client/src/components/settings/LocalSetupPanel.jsx @@ -1,9 +1,9 @@ import { useState, useEffect, useCallback, useRef } from 'react'; -import { CheckCircle2, XCircle, Wand2, RefreshCw, Terminal, AlertTriangle, Box } from 'lucide-react'; +import { CheckCircle2, XCircle, Wand2, RefreshCw, Terminal, AlertTriangle, Box, Cpu } from 'lucide-react'; import toast from '../ui/Toast'; import BrailleSpinner from '../BrailleSpinner'; -export default function LocalSetupPanel({ pythonPath, onPythonPathChange }) { +export default function LocalSetupPanel({ pythonPath, onPythonPathChange, onPackagesChanged }) { const [detecting, setDetecting] = useState(false); const [check, setCheck] = useState(null); // { required, installed, missing, missingPip } const [checking, setChecking] = useState(false); @@ -12,6 +12,25 @@ export default function LocalSetupPanel({ pythonPath, onPythonPathChange }) { const [creatingVenv, setCreatingVenv] = useState(false); const logRef = useRef(null); const installEsRef = useRef(null); + // Decouple the input from the parent's persisted path. The VideoGen + // consumer saves on every onPythonPathChange, so wiring the input to the + // prop directly fires a settings PATCH + ~1-2s status re-probe per + // keystroke. Typed edits commit on debounce/blur; programmatic updates + // (Detect, Switch-to-arm64, Create-venv) still call onPythonPathChange + // directly so they take effect immediately. + const [draftPath, setDraftPath] = useState(pythonPath || ''); + const commitTimerRef = useRef(null); + useEffect(() => { setDraftPath(pythonPath || ''); }, [pythonPath]); + useEffect(() => () => clearTimeout(commitTimerRef.current), []); + const commitDraft = (value) => { + clearTimeout(commitTimerRef.current); + if (value !== (pythonPath || '')) onPythonPathChange(value); + }; + const handleDraftChange = (value) => { + setDraftPath(value); + clearTimeout(commitTimerRef.current); + commitTimerRef.current = setTimeout(() => commitDraft(value), 800); + }; // Closing the install EventSource on unmount stops setInstalling / // setInstallLog calls firing on a torn-down component if the user @@ -46,6 +65,17 @@ export default function LocalSetupPanel({ pythonPath, onPythonPathChange }) { if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight; }, [installLog]); + // Notify the parent whenever local check transitions from "had missing + // packages" to "all installed" — covers manual refresh, terminal installs, + // and the SSE-complete path. Without this, parent state (e.g. VideoGen's + // status pill) stays stale until the user manually clicks its own refresh. + const prevHadMissingRef = useRef(false); + useEffect(() => { + const allInstalled = !!check && Array.isArray(check.missing) && check.missing.length === 0; + if (allInstalled && prevHadMissingRef.current) onPackagesChanged?.(); + prevHadMissingRef.current = !!check && Array.isArray(check.missing) && check.missing.length > 0; + }, [check, onPackagesChanged]); + const handleDetect = async () => { setDetecting(true); try { @@ -132,8 +162,9 @@ export default function LocalSetupPanel({ pythonPath, onPythonPathChange }) {
onPythonPathChange(e.target.value)} + value={draftPath} + onChange={(e) => handleDraftChange(e.target.value)} + onBlur={() => commitDraft(draftPath)} className="flex-1 bg-port-bg border border-port-border rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-port-accent" placeholder="/usr/local/bin/python3" /> @@ -167,6 +198,26 @@ export default function LocalSetupPanel({ pythonPath, onPythonPathChange }) {

{checking ? 'Checking…' : 'Set a Python path to check installed packages.'}

) : ( <> + {check.archMismatch && ( +
+ +
+
+ This Python reports {check.interpreterArch} but your Mac is {check.hostArch}. + mlx ships arm64-only wheels — installing it here will fail. +
+ {check.suggestedArm64Python && ( + + )} +
+
+ )}
+ {status && status.connected === false && (() => { + const missingCount = status.missingPackages?.length || 0; + const hasPath = !!status.pythonPath; + return ( +
+
+

+ {hasPath ? 'Install missing Python packages' : 'Set up Local Python'} +

+

+ {hasPath + ? `Your Python is selected (${status.pythonPath}), but ${missingCount} required ${missingCount === 1 ? "package isn't" : "packages aren't"} installed. Click "Install" below — PortOS will pip-install them into this interpreter.` + : 'Pick a Python 3.10+ interpreter — PortOS auto-detects venvs and conda installs and can install missing packages directly.'} +

+
+ +
+ ); + })()} + {/* Mode switch — segmented control above the form. Sets state that both the form rendering and the submit payload react to. Implemented as plain toggle buttons with `aria-pressed` rather than diff --git a/server/lib/pythonSetup.js b/server/lib/pythonSetup.js index c69d37cb64..6c4bf82052 100644 --- a/server/lib/pythonSetup.js +++ b/server/lib/pythonSetup.js @@ -1,6 +1,6 @@ import { execFile, spawn } from 'node:child_process'; import { existsSync } from 'node:fs'; -import { homedir, platform } from 'node:os'; +import { arch, homedir, platform } from 'node:os'; import { join } from 'node:path'; import { promisify } from 'node:util'; import { PATHS } from './fileUtils.js'; @@ -8,6 +8,10 @@ import { PATHS } from './fileUtils.js'; const execFileAsync = promisify(execFile); const IS_WIN = platform() === 'win32'; const IS_DARWIN = platform() === 'darwin'; +// Node's os.arch() reports 'arm64' on Apple Silicon, 'x64' on Intel — the +// platform.machine() probe below reports 'arm64' / 'x86_64'. Normalize both +// onto the python convention so callers compare apples to apples. +export const HOST_ARCH = ({ arm64: 'arm64', x64: 'x86_64' })[arch()] || arch(); export const REQUIRED_PACKAGES = IS_DARWIN ? ['mflux', 'mlx', 'mlx_vlm', 'mlx_video', 'transformers', 'safetensors', 'huggingface_hub', 'numpy', 'cv2', 'tqdm'] @@ -15,11 +19,34 @@ export const REQUIRED_PACKAGES = IS_DARWIN ? ['transformers', 'safetensors', 'huggingface_hub', 'numpy', 'cv2', 'tqdm', 'torch', 'diffusers'] : ['mflux', 'transformers', 'safetensors', 'huggingface_hub', 'numpy', 'cv2', 'tqdm']; +// Some package identifiers in REQUIRED_PACKAGES need to be probed via a +// deeper submodule import to distinguish two PyPI packages that publish the +// same top-level namespace. `mlx_video` is the prime case: the plain PyPI +// package `mlx_video` is unrelated (video classification) and lacks the +// `generate_av` CLI the LTX renderer shells into. We want the wrong package +// to FAIL the check so the UI's "Install missing" button reappears and the +// `installPackages` pre-uninstall path (PIP_PRE_UNINSTALL) can swap it out. +const IMPORT_PROBE_PATHS = IS_DARWIN ? { mlx_video: 'mlx_video.generate_av' } : {}; +const importProbePathFor = (importName) => IMPORT_PROBE_PATHS[importName] || importName; + +// The PyPI package literally named `mlx_video` is unrelated (a video +// classification lib); the one shipping `mlx_video.generate_av` is +// `mlx-video-with-audio`. Both expose `import mlx_video`, so the conflict +// hides at namespace-probe time — `IMPORT_PROBE_PATHS` + `PIP_PRE_UNINSTALL` +// below force a deeper probe and uninstall the wrong package first. +const MLX_VIDEO_PIP = 'mlx-video-with-audio>=0.1.35'; + const PIP_NAMES = { cv2: 'opencv-python', - // mlx-compatible transformers must stay <5 — pin only on macOS where the - // mlx path matters; Windows torch path uses latest. + // mlx-compatible transformers must stay <5; Windows torch path uses latest. ...(IS_DARWIN ? { transformers: 'transformers<5' } : {}), + ...(IS_DARWIN ? { mlx_video: MLX_VIDEO_PIP } : {}), +}; + +// Keys are pipNameFor-output specs; values are the conflicting package names +// to remove before install. Mirrors `scripts/setup-image-video.sh`. +const PIP_PRE_UNINSTALL = { + [MLX_VIDEO_PIP]: ['mlx_video'], }; export const pipNameFor = (importName) => PIP_NAMES[importName] || importName; @@ -58,16 +85,48 @@ const PYTHON_CANDIDATES = IS_WIN '/usr/bin/python3', ]; +export async function probePythonArch(pythonPath) { + const { stdout } = await execFileAsync(pythonPath, [ + '-c', 'import platform; print(platform.machine())' + ], { timeout: 10_000 }).catch(() => ({ stdout: '' })); + return stdout.trim() || null; +} + +export async function isArchMismatch(pythonPath) { + if (!IS_DARWIN) return false; + const interp = await probePythonArch(pythonPath); + if (!interp) return false; + return interp !== HOST_ARCH; +} + +// Find first candidate matching `predicate(arch)` by probing arches in parallel. +const firstArchMatch = async (candidates, predicate) => { + const arches = await Promise.all(candidates.map(probePythonArch)); + const idx = arches.findIndex((a) => a && predicate(a)); + return idx >= 0 ? candidates[idx] : null; +}; + export async function detectPython() { - for (const p of PYTHON_CANDIDATES) { - if (existsSync(p)) return p; + // mlx ships arm64-only wheels; prefer an arm64 interpreter on Apple Silicon + // so /opt/anaconda3 (often x86_64) doesn't beat /opt/homebrew/bin/python3. + const present = PYTHON_CANDIDATES.filter((p) => existsSync(p)); + if (IS_DARWIN && HOST_ARCH === 'arm64' && present.length > 1) { + const match = await firstArchMatch(present, (a) => a === HOST_ARCH); + if (match) return match; } + if (present.length) return present[0]; const which = IS_WIN ? 'where' : 'which'; const name = IS_WIN ? 'python' : 'python3'; const { stdout } = await execFileAsync(which, [name], { timeout: 5000 }).catch(() => ({ stdout: '' })); return stdout.trim().split(/\r?\n/)[0] || null; } +export async function detectArm64Python() { + if (!IS_DARWIN || HOST_ARCH !== 'arm64') return null; + const present = PYTHON_CANDIDATES.filter((p) => existsSync(p)); + return firstArchMatch(present, (a) => a === 'arm64'); +} + // FLUX.2 runs in its own venv because mflux (MLX) and torch+diffusers-from-git // have hostile dependency trees. Bootstrap with `INSTALL_FLUX2=1 // scripts/setup-image-video.sh`. We probe a small candidate list rather than @@ -136,17 +195,6 @@ export function isAllowedPython(pythonPath) { return false; } -// Returns true if `pythonPath` has a PEP 668 EXTERNALLY-MANAGED marker next -// to its stdlib — pip will refuse to install into it. -export async function isExternallyManaged(pythonPath) { - const { stdout } = await execFileAsync(pythonPath, [ - '-c', 'import sysconfig; print(sysconfig.get_path("stdlib"))' - ], { timeout: 10_000 }).catch(() => ({ stdout: '' })); - const stdlib = stdout.trim(); - if (!stdlib) return false; - return existsSync(join(stdlib, 'EXTERNALLY-MANAGED')); -} - // Idempotent: if the venv exists, returns its python path without recreating. // Windows venvs put the interpreter at Scripts\python.exe, POSIX at bin/python3. export async function createVenv(basePython, targetDir) { @@ -161,62 +209,109 @@ export async function createVenv(basePython, targetDir) { return venvPython; } -export async function checkPackages(pythonPath) { - const probe = REQUIRED_PACKAGES.map(pkg => - `try:\n import ${pkg}\n print("OK:${pkg}")\nexcept Exception:\n print("MISSING:${pkg}")` +export async function probePythonHealth(pythonPath) { + const importLines = REQUIRED_PACKAGES.map((pkg) => + `try:\n import ${importProbePathFor(pkg)}\n imports["${pkg}"] = True\nexcept Exception:\n imports["${pkg}"] = False`, ).join('\n'); - + const probe = [ + 'import sys, sysconfig, platform, json', + 'imports = {}', + importLines, + 'print(json.dumps({', + ' "prefix": sys.prefix,', + ' "basePrefix": sys.base_prefix,', + ' "stdlib": sysconfig.get_path("stdlib"),', + ' "arch": platform.machine(),', + ' "imports": imports,', + '}))', + ].join('\n'); const { stdout } = await execFileAsync(pythonPath, ['-c', probe], { timeout: 30_000 }); - + const data = JSON.parse(stdout.trim().split(/\r?\n/).pop()); const installed = []; const missing = []; - for (const line of stdout.trim().split(/\r?\n/)) { - if (line.startsWith('OK:')) installed.push(line.slice(3).trim()); - else if (line.startsWith('MISSING:')) missing.push(line.slice(8).trim()); + for (const pkg of REQUIRED_PACKAGES) { + (data.imports[pkg] ? installed : missing).push(pkg); } - return { installed, missing, missingPip: missing.map(pipNameFor) }; + // Inside a venv, sysconfig.get_path("stdlib") resolves to the base + // interpreter's stdlib — so a venv from PEP 668 Homebrew Python would + // inherit the marker even though pip-in-venv ignores PEP 668. Skip the + // marker check when sys.prefix != sys.base_prefix. + const inVenv = data.prefix && data.basePrefix && data.prefix !== data.basePrefix; + const externallyManaged = !inVenv && data.stdlib + ? existsSync(join(data.stdlib, 'EXTERNALLY-MANAGED')) + : false; + return { + installed, + missing, + missingPip: missing.map(pipNameFor), + externallyManaged, + interpreterArch: data.arch || null, + }; } -// Spawn pip install; emit each line via onLog. Resolves on exit. -// onLog gets `{ type: 'log' | 'error' | 'complete', message }`. -// Returns `{ promise, kill }` so the route can SIGTERM the pip child if -// the SSE client disconnects mid-install (otherwise a 10-minute torch -// upgrade would keep running invisibly). -export function installPackages(pythonPath, importNames, onLog) { - const pipSpecs = importNames.map(pipNameFor); - onLog({ type: 'log', message: `pip install ${pipSpecs.join(' ')}` }); - - const proc = spawn(pythonPath, [ - '-m', 'pip', 'install', '--upgrade', '--progress-bar', 'on', - ...pipSpecs, - ], { stdio: ['ignore', 'pipe', 'pipe'] }); +export async function checkPackages(pythonPath) { + const { installed, missing, missingPip } = await probePythonHealth(pythonPath); + return { installed, missing, missingPip }; +} - const promise = new Promise((resolve) => { - const handleOutput = (chunk) => { +// Spawn a child, stream its stdout+stderr line-by-line via `onLog`, resolve +// with the exit code (or -1 on spawn error). `onProc` is invoked with the +// live child handle so the caller's outer closure can track it for SIGTERM. +function streamSpawn(bin, args, onLog, onProc) { + return new Promise((resolve) => { + const proc = spawn(bin, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + onProc(proc); + const onChunk = (chunk) => { for (const line of chunk.toString().split(/[\r\n]+/)) { - const trimmed = line.trim(); - if (trimmed) onLog({ type: 'log', message: trimmed }); + const t = line.trim(); + if (t) onLog({ type: 'log', message: t }); } }; - proc.stdout.on('data', handleOutput); - proc.stderr.on('data', handleOutput); - - proc.on('close', (code) => { - if (code === 0) { - onLog({ type: 'complete', message: 'All packages installed successfully.' }); - resolve({ ok: true, code: 0 }); - } else { - onLog({ type: 'error', message: `pip exited with code ${code}` }); - resolve({ ok: false, code }); - } - }); - proc.on('error', (err) => { - onLog({ type: 'error', message: err.message }); - resolve({ ok: false, code: -1 }); - }); + proc.stdout.on('data', onChunk); + proc.stderr.on('data', onChunk); + proc.on('close', (code) => { onProc(null); resolve(code ?? -1); }); + proc.on('error', (err) => { onLog({ type: 'error', message: err.message }); onProc(null); resolve(-1); }); }); +} + +// Returns `{ promise, kill }` so the route can SIGTERM the pip child if the +// SSE client disconnects mid-install — a 10-minute torch upgrade would +// otherwise keep running invisibly. +export function installPackages(pythonPath, importNames, onLog) { + const pipSpecs = importNames.map(pipNameFor); + const conflicts = [...new Set(pipSpecs.flatMap((s) => PIP_PRE_UNINSTALL[s] || []))]; + + let currentProc = null; + let killed = false; + const trackProc = (p) => { currentProc = p; }; + const runPip = (args) => streamSpawn(pythonPath, ['-m', 'pip', ...args], onLog, trackProc); + + const promise = (async () => { + if (conflicts.length) { + onLog({ type: 'log', message: `pip uninstall -y ${conflicts.join(' ')} (resolving package-name conflict)` }); + // Uninstall isn't allowed to fail the run — when the conflicting + // package isn't installed pip exits non-zero with a "not installed" + // message that's noise, not an error. + await runPip(['uninstall', '--yes', ...conflicts]); + if (killed) return { ok: false, code: -1 }; + } + onLog({ type: 'log', message: `pip install ${pipSpecs.join(' ')}` }); + const code = await runPip(['install', '--upgrade', '--progress-bar', 'on', ...pipSpecs]); + if (code === 0) { + onLog({ type: 'complete', message: 'All packages installed successfully.' }); + return { ok: true, code: 0 }; + } + onLog({ type: 'error', message: `pip exited with code ${code}` }); + return { ok: false, code }; + })(); - return { promise, kill: () => { if (!proc.killed) proc.kill('SIGTERM'); } }; + return { + promise, + kill: () => { + killed = true; + if (currentProc && !currentProc.killed) currentProc.kill('SIGTERM'); + }, + }; } // Pip specs for the FLUX.2 venv. Mirrors scripts/setup-image-video.sh so the @@ -250,20 +345,9 @@ export function installFlux2Venv(onLog) { const stage = (name, message) => onLog({ type: 'stage', stage: name, message }); const log = (message) => onLog({ type: 'log', message }); - const runPython = (args) => new Promise((resolve) => { - const proc = spawn(args[0], args.slice(1), { stdio: ['ignore', 'pipe', 'pipe'] }); - currentProc = proc; - const onChunk = (chunk) => { - for (const line of chunk.toString().split(/[\r\n]+/)) { - const t = line.trim(); - if (t) log(t); - } - }; - proc.stdout.on('data', onChunk); - proc.stderr.on('data', onChunk); - proc.on('close', (code) => { currentProc = null; resolve(code === 0); }); - proc.on('error', (err) => { onLog({ type: 'error', message: err.message }); currentProc = null; resolve(false); }); - }); + const trackProc = (p) => { currentProc = p; }; + const runPython = async (args) => + (await streamSpawn(args[0], args.slice(1), onLog, trackProc)) === 0; const promise = (async () => { stage('detect', 'Looking for system Python…'); diff --git a/server/routes/imageGen.js b/server/routes/imageGen.js index 254a31e613..d647793022 100644 --- a/server/routes/imageGen.js +++ b/server/routes/imageGen.js @@ -25,9 +25,10 @@ import { getSettings, saveSettings } from '../services/settings.js'; import { getHfToken, getHfTokenInfo, HF_TOKEN_REGEX } from '../lib/hfToken.js'; import { getImageModels, isFlux2, isZImage, isErnie } from '../lib/mediaModels.js'; import { - REQUIRED_PACKAGES, detectPython, checkPackages, installPackages, - isExternallyManaged, createVenv, isAllowedPython, pipNameFor, + REQUIRED_PACKAGES, detectPython, installPackages, + createVenv, isAllowedPython, pipNameFor, resolveFlux2Python, FLUX2_VENV_DEFAULT, installFlux2Venv, isFlux2VenvHealthy, + detectArm64Python, HOST_ARCH, probePythonHealth, } from '../lib/pythonSetup.js'; import { PATHS, ensureDir, resolveGalleryImage } from '../lib/fileUtils.js'; import { join } from 'node:path'; @@ -683,15 +684,22 @@ router.get('/setup/check', asyncHandler(async (req, res) => { if (!isAllowedPython(pythonPath)) { return res.status(400).json({ error: 'pythonPath must be a python interpreter (basename python/python3/python3.NN)' }); } - const [pkgs, externallyManaged] = await Promise.all([ - checkPackages(pythonPath), - isExternallyManaged(pythonPath), - ]); + const health = await probePythonHealth(pythonPath); + // The arch warning is specifically about mlx wheels (arm64-only) on Apple + // Silicon. A generic interpreterArch !== HOST_ARCH compare would false- + // positive on Windows (Python reports `AMD64`, Node reports `x86_64`) and + // on hypothetical arm64 Linux — where mlx isn't even in REQUIRED_PACKAGES. + const archMismatch = process.platform === 'darwin' + && HOST_ARCH === 'arm64' + && health.interpreterArch === 'x86_64'; + const suggestedArm64Python = archMismatch ? await detectArm64Python() : null; res.json({ pythonPath, - externallyManaged, required: REQUIRED_PACKAGES, - ...pkgs, + hostArch: HOST_ARCH, + archMismatch, + suggestedArm64Python, + ...health, }); })); diff --git a/server/routes/videoGen.js b/server/routes/videoGen.js index 9bb7e51715..c04150becb 100644 --- a/server/routes/videoGen.js +++ b/server/routes/videoGen.js @@ -17,6 +17,7 @@ import { uploadFields } from '../lib/multipart.js'; import { PATHS, ensureDir, resolveGalleryImage } from '../lib/fileUtils.js'; import { safeUnder } from '../lib/ffmpeg.js'; import { getSettings } from '../services/settings.js'; +import { checkPackages, isAllowedPython } from '../lib/pythonSetup.js'; import { listVideoModels, defaultVideoModelId, @@ -140,17 +141,39 @@ const generateBodySchema = z.object({ ), }); +// Probes required-package imports on each call so a half-installed Python +// can't masquerade as connected. /status isn't polled (mount + manual +// refresh only), so the ~1-2s subprocess cost is acceptable. router.get('/status', asyncHandler(async (_req, res) => { const s = await getSettings(); const py = s.imageGen?.local?.pythonPath || null; + const { connected, reason, missing } = await resolveLocalPythonHealth(py); res.json({ - connected: !!py, + connected, pythonPath: py, + reason, + missingPackages: missing, models: listVideoModels(), defaultModel: defaultVideoModelId(), }); })); +async function resolveLocalPythonHealth(py) { + if (!py) return { connected: false, reason: 'Local Python not configured', missing: [] }; + if (!isAllowedPython(py)) return { connected: false, reason: 'Saved pythonPath is not a python interpreter', missing: [] }; + try { + const { missing } = await checkPackages(py); + if (missing.length === 0) return { connected: true, reason: null, missing }; + return { + connected: false, + reason: `${missing.length} python package${missing.length === 1 ? '' : 's'} missing: ${missing.join(', ')}`, + missing, + }; + } catch (err) { + return { connected: false, reason: `Python probe failed: ${err.message || err}`, missing: [] }; + } +} + router.get('/models', (_req, res) => { res.json(listVideoModels()); }); diff --git a/server/routes/videoGen.test.js b/server/routes/videoGen.test.js index e9c6c5f9e0..af0b2a8864 100644 --- a/server/routes/videoGen.test.js +++ b/server/routes/videoGen.test.js @@ -6,6 +6,11 @@ vi.mock('../services/settings.js', () => ({ getSettings: vi.fn(async () => ({ imageGen: { local: { pythonPath: '/usr/bin/python3' } } })), })); +vi.mock('../lib/pythonSetup.js', () => ({ + checkPackages: vi.fn(async () => ({ installed: ['mflux', 'mlx'], missing: [], missingPip: [] })), + isAllowedPython: vi.fn(() => true), +})); + vi.mock('../services/videoGen/local.js', () => ({ // The route checks `runtime` on the default model when validating a2v — // include it so the a2v happy-path tests don't trip the A2V_REQUIRES_LTX2 @@ -154,13 +159,29 @@ describe('videoGen routes', () => { }); describe('GET /status', () => { - it('reports connected when pythonPath is set', async () => { + it('reports connected when pythonPath is set AND required packages all import', async () => { const r = await request(app).get('/api/video-gen/status'); expect(r.status).toBe(200); expect(r.body.connected).toBe(true); expect(r.body.pythonPath).toBe('/usr/bin/python3'); + expect(r.body.missingPackages).toEqual([]); expect(r.body.defaultModel).toBe('ltx2_unified'); }); + + it('reports disconnected with reason + missingPackages when packages fail to import', async () => { + const { checkPackages } = await import('../lib/pythonSetup.js'); + checkPackages.mockResolvedValueOnce({ + installed: ['numpy', 'tqdm'], + missing: ['mflux', 'mlx', 'mlx_video'], + missingPip: ['mflux', 'mlx', 'mlx_video'], + }); + const r = await request(app).get('/api/video-gen/status'); + expect(r.status).toBe(200); + expect(r.body.connected).toBe(false); + expect(r.body.pythonPath).toBe('/usr/bin/python3'); + expect(r.body.missingPackages).toEqual(['mflux', 'mlx', 'mlx_video']); + expect(r.body.reason).toMatch(/3 python packages missing/); + }); }); describe('GET /models', () => { diff --git a/server/services/mediaJobQueue/index.js b/server/services/mediaJobQueue/index.js index 16f08d4b78..15a92828c4 100644 --- a/server/services/mediaJobQueue/index.js +++ b/server/services/mediaJobQueue/index.js @@ -603,6 +603,18 @@ async function runJob(job) { // to an out-of-range value, bypassing the route-layer Zod validation. safeParams.chunks = Math.min(8, Math.max(1, Math.trunc(Number(safeParams.chunks) || 1))); + // Drop the params snapshot of pythonPath; live settings always win for + // local-Python jobs so a stale persisted snapshot can't poison the spawn. + const usesLocalPython = job.kind === 'video' || (job.kind === 'image' && job.params?.mode !== IMAGE_GEN_MODE.CODEX); + if (usesLocalPython) { + const live = await getSettings().catch(() => null); + const livePythonPath = live?.imageGen?.local?.pythonPath || null; + if (livePythonPath && livePythonPath !== safeParams.pythonPath) { + console.log(`🐍 media-job [${job.id.slice(0, 8)}] pythonPath re-resolved from settings: ${safeParams.pythonPath} → ${livePythonPath}`); + } + safeParams.pythonPath = livePythonPath; + } + const emitter = job.kind === 'video' ? videoGenEvents : imageGenEvents; const dispatcher = makeGenDispatcher(emitter, job, handlers); dispatcher.attach(); diff --git a/server/services/mediaJobQueue/index.test.js b/server/services/mediaJobQueue/index.test.js index fde24a77a3..d2088b5870 100644 --- a/server/services/mediaJobQueue/index.test.js +++ b/server/services/mediaJobQueue/index.test.js @@ -678,6 +678,42 @@ describe('chunks dispatch', () => { }); }); +describe('live pythonPath re-resolution', () => { + it('video job spawn uses the pythonPath currently in settings, not the snapshot at enqueue', async () => { + // Job was enqueued with a stale pythonPath (e.g. user fixed their config + // after submission, or the persisted file from a previous session is + // being replayed). The worker must overwrite from live settings before + // calling generateVideo so the stale snapshot can't poison the spawn. + writeFileSync( + join(tempDataDir, 'settings.json'), + JSON.stringify({ imageGen: { local: { pythonPath: '/live/path/python3' } } }), + ); + const job = mediaJobQueue.enqueueJob({ + kind: 'video', + params: { prompt: 'stale-snapshot', pythonPath: '/stale/anaconda/python3' }, + }); + await waitFor(() => stubs.generateVideo.mock.calls.length === 1); + expect(stubs.generateVideo.mock.calls[0][0].pythonPath).toBe('/live/path/python3'); + videoGenEvents.emit('completed', { generationId: job.jobId, filename: `${job.jobId}.mp4` }); + await waitFor(() => mediaJobQueue.getJob(job.jobId).status === 'completed'); + }); + + it('codex image job leaves params.pythonPath untouched', async () => { + writeFileSync( + join(tempDataDir, 'settings.json'), + JSON.stringify({ imageGen: { local: { pythonPath: '/live/path/python3' } } }), + ); + const job = mediaJobQueue.enqueueJob({ + kind: 'image', + params: { prompt: 'codex', mode: 'codex' }, + }); + await waitFor(() => stubs.generateImageCodex.mock.calls.length === 1); + expect(stubs.generateImageCodex.mock.calls[0][0].pythonPath).toBeUndefined(); + imageGenEvents.emit('completed', { generationId: job.jobId, filename: `${job.jobId}.png` }); + await waitFor(() => mediaJobQueue.getJob(job.jobId).status === 'completed'); + }); +}); + describe('cancelJob running-Codex branch', () => { it('canceling a running Codex job calls imageGen/codex.js#cancel, not the local cancel', async () => { // Codex job hangs indefinitely so it stays in 'running' for the cancel.