mirror of
https://github.com/lllin000/PaperForge.git
synced 2026-07-22 06:50:53 +00:00
feat(#81): Release N+1 owner cutover — Python 3.11+ gate, fail-closed dispatch
- managed-runtime: collapse MIN_PYTHON_LEGACY/MIN_PYTHON_NEW → MIN_PYTHON (3.11) - managed-runtime: remove PYTHON_310_DEPRECATED warning (3.10 now needs_repair) - managed-runtime: single 3.11+ hard gate for all installations - settings: cut getCachedPython fallback in _resolveRuntimeCommand → fail-closed - settings: update Python version notices 3.10→3.11 - i18n: update user-facing Python version messages 3.10→3.11 - Python backend: checker.py, setup_wizard.py, status.py → 3.11 minimum - Test updates: legacy fallback tests now verify fail-closed, 3.10 gating removed Build: 264.4KB | Tests: 384/384 passed | Typecheck: clean
This commit is contained in:
parent
b9f3ca1719
commit
0859652396
9 changed files with 3203 additions and 1780 deletions
|
|
@ -149,7 +149,7 @@ const LANG: Record<string, Record<string, string>> = {
|
|||
not_set: "Not entered",
|
||||
notice_check_fail: "Missing: ",
|
||||
notice_python_missing:
|
||||
"Python was not detected. Install Python 3.10+ and add it to PATH.",
|
||||
"Python was not detected. Install Python 3.11+ and add it to PATH.",
|
||||
ocr_privacy_title: "OCR Privacy Notice",
|
||||
ocr_privacy_warning:
|
||||
"OCR will upload PDFs to the PaddleOCR API. Do not upload sensitive or confidential documents.",
|
||||
|
|
@ -178,7 +178,7 @@ const LANG: Record<string, Record<string, string>> = {
|
|||
prep_export_path_label: "Save the exported JSON file into this folder:",
|
||||
prep_key: "PaddleOCR Key",
|
||||
prep_key_desc: "Get your API key from https://aistudio.baidu.com/paddleocr",
|
||||
prep_python: "Python 3.10+",
|
||||
prep_python: "Python 3.11+",
|
||||
prep_python_desc:
|
||||
"Python must be available from the command line. If you are not sure, click below to auto-detect.",
|
||||
prep_zotero: "Zotero Desktop",
|
||||
|
|
@ -636,8 +636,7 @@ const LANG: Record<string, Record<string, string>> = {
|
|||
nav_prev: "← 上一步",
|
||||
no_pending_ocr: "所有 OCR 任务已完成",
|
||||
not_set: "未设置",
|
||||
notice_check_fail: "未通过: ",
|
||||
notice_python_missing: "Python 未检测到,请先安装 Python 3.10+ 并加入 PATH",
|
||||
notice_python_missing: "Python 未检测到,请先安装 Python 3.11+ 并加入 PATH",
|
||||
ocr_privacy_title: "OCR 隐私提示",
|
||||
ocr_privacy_warning:
|
||||
"OCR 会将 PDF 上传到 PaddleOCR API 进行处理。请不要上传包含敏感信息或无法外传的文献。",
|
||||
|
|
@ -664,8 +663,7 @@ const LANG: Record<string, Record<string, string>> = {
|
|||
prep_export_desc:
|
||||
"右键文献子分类 → 导出分类 → BetterBibTeX JSON → 勾选保持更新 → 导出到(JSON 文件名即为 Base 名):",
|
||||
prep_key: "PaddleOCR Key",
|
||||
prep_key_desc: "在 https://aistudio.baidu.com/paddleocr 获取 API Key",
|
||||
prep_python: "Python 3.10+",
|
||||
prep_python: "Python 3.11+",
|
||||
prep_python_desc: "确保 Python 可命令行调用。点击下方按钮自动检测。",
|
||||
prep_zotero: "Zotero 桌面版",
|
||||
prep_zotero_desc: "安装 Zotero (https://www.zotero.org)",
|
||||
|
|
|
|||
|
|
@ -18,7 +18,10 @@
|
|||
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import { execFile as cpExecFile, execFileSync as cpExecFileSync } from "child_process";
|
||||
import {
|
||||
execFile as cpExecFile,
|
||||
execFileSync as cpExecFileSync,
|
||||
} from "child_process";
|
||||
import * as os from "os";
|
||||
|
||||
// ── Public types ──
|
||||
|
|
@ -86,25 +89,45 @@ export interface RuntimeAction {
|
|||
export interface FsOps {
|
||||
existsSync(p: string): boolean;
|
||||
readFileSync(p: string, encoding?: string | null): string;
|
||||
writeFileSync(p: string, data: string | NodeJS.ArrayBufferView, encoding?: string | null): void;
|
||||
writeFileSync(
|
||||
p: string,
|
||||
data: string | NodeJS.ArrayBufferView,
|
||||
encoding?: string | null
|
||||
): void;
|
||||
renameSync(oldP: string, newP: string): void;
|
||||
mkdirSync(p: string, opts?: { recursive?: boolean }): string | undefined;
|
||||
rmSync(p: string, opts?: { recursive?: boolean; force?: boolean }): void;
|
||||
readdirSync(p: string, opts?: { withFileTypes?: boolean }): fs.Dirent[];
|
||||
}
|
||||
|
||||
export type ExecFileCallback = (error: Error | null, stdout: string, stderr: string) => void;
|
||||
export type ExecFileFn = (command: string, args: readonly string[], opts: { timeout?: number; encoding?: string; signal?: AbortSignal }, cb: ExecFileCallback) => void;
|
||||
export type ExecFileSyncFn = (command: string, args: readonly string[], opts: { encoding: string; timeout: number }) => string;
|
||||
export type ExecFileCallback = (
|
||||
error: Error | null,
|
||||
stdout: string,
|
||||
stderr: string
|
||||
) => void;
|
||||
export type ExecFileFn = (
|
||||
command: string,
|
||||
args: readonly string[],
|
||||
opts: { timeout?: number; encoding?: string; signal?: AbortSignal },
|
||||
cb: ExecFileCallback
|
||||
) => void;
|
||||
export type ExecFileSyncFn = (
|
||||
command: string,
|
||||
args: readonly string[],
|
||||
opts: { encoding: string; timeout: number }
|
||||
) => string;
|
||||
|
||||
// ── Constants ──
|
||||
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
const MIN_PYTHON_NEW = "3.11";
|
||||
const MIN_PYTHON_LEGACY = "3.10";
|
||||
const MIN_PYTHON = "3.11";
|
||||
|
||||
/** ES2018-compatible Promise.withResolvers polyfill. */
|
||||
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void; reject: (err: unknown) => void } {
|
||||
function deferred<T>(): {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (err: unknown) => void;
|
||||
} {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (err: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
|
|
@ -146,7 +169,12 @@ function detectContainer(): boolean {
|
|||
if (fs.existsSync("/.dockerenv")) return true;
|
||||
if (fs.existsSync("/run/.containerenv")) return true;
|
||||
const cgroup = fs.readFileSync("/proc/1/cgroup", "utf-8");
|
||||
if (cgroup.includes("docker") || cgroup.includes("flatpak") || cgroup.includes("snap")) return true;
|
||||
if (
|
||||
cgroup.includes("docker") ||
|
||||
cgroup.includes("flatpak") ||
|
||||
cgroup.includes("snap")
|
||||
)
|
||||
return true;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
|
@ -154,15 +182,19 @@ function detectContainer(): boolean {
|
|||
}
|
||||
|
||||
function detectFlatpak(): boolean {
|
||||
return process.env.FLATPAK_ID !== undefined ||
|
||||
return (
|
||||
process.env.FLATPAK_ID !== undefined ||
|
||||
(process.env.XDG_DATA_DIRS ?? "").includes("flatpak") ||
|
||||
false;
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
function detectSnap(): boolean {
|
||||
return process.env.SNAP !== undefined ||
|
||||
return (
|
||||
process.env.SNAP !== undefined ||
|
||||
process.env.SNAP_NAME !== undefined ||
|
||||
false;
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
export function getOsArch(osPlatform: string, osArch: string): string {
|
||||
|
|
@ -192,10 +224,20 @@ export function isSnapEnv(): boolean {
|
|||
// ── Canonical actions per health state ──
|
||||
|
||||
/** Return internal actions with id/primary/destructive. Used by DI tests. */
|
||||
export function runtimeActionsForHealth(health: RuntimeHealth): readonly RuntimeAction[];
|
||||
export function runtimeActionsForHealth(
|
||||
health: RuntimeHealth
|
||||
): readonly RuntimeAction[];
|
||||
/** Return UI actions with verb/label. Used by settings rendering. */
|
||||
export function runtimeActionsForHealth(health: RuntimeHealth, targetVersion: string, running: boolean): readonly RuntimeUiAction[];
|
||||
export function runtimeActionsForHealth(health: RuntimeHealth, targetVersion?: string, running?: boolean): readonly RuntimeAction[] | readonly RuntimeUiAction[] {
|
||||
export function runtimeActionsForHealth(
|
||||
health: RuntimeHealth,
|
||||
targetVersion: string,
|
||||
running: boolean
|
||||
): readonly RuntimeUiAction[];
|
||||
export function runtimeActionsForHealth(
|
||||
health: RuntimeHealth,
|
||||
targetVersion?: string,
|
||||
running?: boolean
|
||||
): readonly RuntimeAction[] | readonly RuntimeUiAction[] {
|
||||
// 3-param UI path (settings rendering)
|
||||
if (targetVersion !== undefined || running !== undefined) {
|
||||
if (running) {
|
||||
|
|
@ -235,33 +277,83 @@ export function runtimeActionsForHealth(health: RuntimeHealth, targetVersion?: s
|
|||
// 1-param internal path (unchanged, for DI tests)
|
||||
switch (health.state) {
|
||||
case "not_installed":
|
||||
return [{ id: "install", label: "Install Runtime", primary: true, destructive: false }];
|
||||
return [
|
||||
{
|
||||
id: "install",
|
||||
label: "Install Runtime",
|
||||
primary: true,
|
||||
destructive: false,
|
||||
},
|
||||
];
|
||||
case "needs_repair": {
|
||||
const actions: RuntimeAction[] = [
|
||||
{ id: "repair", label: "Repair Runtime", primary: true, destructive: false },
|
||||
{
|
||||
id: "repair",
|
||||
label: "Repair Runtime",
|
||||
primary: true,
|
||||
destructive: false,
|
||||
},
|
||||
];
|
||||
if (health.pythonPath) {
|
||||
actions.push({ id: "rollback", label: "Rollback", primary: false, destructive: false });
|
||||
actions.push({
|
||||
id: "rollback",
|
||||
label: "Rollback",
|
||||
primary: false,
|
||||
destructive: false,
|
||||
});
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
case "ready":
|
||||
return [
|
||||
{ id: "status", label: "Check Status", primary: false, destructive: false },
|
||||
{ id: "update", label: "Update Runtime", primary: false, destructive: false },
|
||||
{
|
||||
id: "status",
|
||||
label: "Check Status",
|
||||
primary: false,
|
||||
destructive: false,
|
||||
},
|
||||
{
|
||||
id: "update",
|
||||
label: "Update Runtime",
|
||||
primary: false,
|
||||
destructive: false,
|
||||
},
|
||||
];
|
||||
case "unknown":
|
||||
return [{ id: "probe", label: "Refresh Status", primary: true, destructive: false }];
|
||||
return [
|
||||
{
|
||||
id: "probe",
|
||||
label: "Refresh Status",
|
||||
primary: true,
|
||||
destructive: false,
|
||||
},
|
||||
];
|
||||
case "unavailable":
|
||||
return [{ id: "setup", label: "Manual Setup", primary: true, destructive: false }];
|
||||
return [
|
||||
{
|
||||
id: "setup",
|
||||
label: "Manual Setup",
|
||||
primary: true,
|
||||
destructive: false,
|
||||
},
|
||||
];
|
||||
default:
|
||||
return [{ id: "probe", label: "Refresh Status", primary: true, destructive: false }];
|
||||
return [
|
||||
{
|
||||
id: "probe",
|
||||
label: "Refresh Status",
|
||||
primary: true,
|
||||
destructive: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Resolve runtime command from health ──
|
||||
|
||||
export function resolveRuntimeCommand(health: RuntimeHealth): RuntimeRun | null {
|
||||
export function resolveRuntimeCommand(
|
||||
health: RuntimeHealth
|
||||
): RuntimeRun | null {
|
||||
if (health.state !== "ready" || !health.pythonPath) return null;
|
||||
return { command: health.pythonPath, args: [] };
|
||||
}
|
||||
|
|
@ -324,7 +416,8 @@ export class ManagedRuntime {
|
|||
this.pointerPath = path.join(this.rootDir, "active-runtime.json");
|
||||
this._fs = opts.fs ?? (fs as unknown as FsOps);
|
||||
this._execFile = opts.execFile ?? (cpExecFile as unknown as ExecFileFn);
|
||||
this._execFileSync = opts.execFileSync ?? (cpExecFileSync as unknown as ExecFileSyncFn);
|
||||
this._execFileSync =
|
||||
opts.execFileSync ?? (cpExecFileSync as unknown as ExecFileSyncFn);
|
||||
}
|
||||
|
||||
// ── Sync: fails closed on cold/stale cache ──
|
||||
|
|
@ -377,10 +470,18 @@ export class ManagedRuntime {
|
|||
const ptr: Record<string, unknown> = JSON.parse(raw);
|
||||
pointerVersion = typeof ptr.version === "string" ? ptr.version : null;
|
||||
const pp = typeof ptr.pythonPath === "string" ? ptr.pythonPath : null;
|
||||
pointerPythonPath = pp ? path.resolve(path.dirname(this.pointerPath), pp) : null;
|
||||
pointerPrevVersion = typeof ptr.previousVersion === "string" ? ptr.previousVersion : null;
|
||||
pointerPrevPythonPath = typeof ptr.previousPythonPath === "string" ? ptr.previousPythonPath : null;
|
||||
pointerWarnings = Array.isArray(ptr.warnings) ? ptr.warnings as WarningInfo[] : [];
|
||||
pointerPythonPath = pp
|
||||
? path.resolve(path.dirname(this.pointerPath), pp)
|
||||
: null;
|
||||
pointerPrevVersion =
|
||||
typeof ptr.previousVersion === "string" ? ptr.previousVersion : null;
|
||||
pointerPrevPythonPath =
|
||||
typeof ptr.previousPythonPath === "string"
|
||||
? ptr.previousPythonPath
|
||||
: null;
|
||||
pointerWarnings = Array.isArray(ptr.warnings)
|
||||
? (ptr.warnings as WarningInfo[])
|
||||
: [];
|
||||
} catch {
|
||||
// No pointer → not installed
|
||||
return this._setCache({
|
||||
|
|
@ -441,28 +542,7 @@ export class ManagedRuntime {
|
|||
try {
|
||||
const result = await this._probe(pointerPythonPath);
|
||||
|
||||
// Check Python version and add Release-N warning for 3.10
|
||||
const probeWarnings = [...pointerWarnings];
|
||||
try {
|
||||
const pyVerOut = this._execFileSync(pointerPythonPath, ["--version"], {
|
||||
encoding: "utf-8",
|
||||
timeout: 5000,
|
||||
});
|
||||
const pyVer = parsePythonVersion(pyVerOut);
|
||||
if (pyVer && isAtLeast(pyVer, MIN_PYTHON_LEGACY) && !isAtLeast(pyVer, MIN_PYTHON_NEW)) {
|
||||
// Only add if not already present
|
||||
if (!probeWarnings.some((w) => w.code === "PYTHON_310_DEPRECATED")) {
|
||||
probeWarnings.push({
|
||||
code: "PYTHON_310_DEPRECATED",
|
||||
message: `Python ${pyVer} is running. Python 3.10 support enters legacy phase in Release N — upgrade to Python ${MIN_PYTHON_NEW}+ recommended.`,
|
||||
platformAction: `Upgrade to Python ${MIN_PYTHON_NEW}+`,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Version check is best-effort; ignore failures
|
||||
}
|
||||
|
||||
return this._setCache({
|
||||
state: "ready",
|
||||
pythonPath: pointerPythonPath,
|
||||
|
|
@ -476,7 +556,8 @@ export class ManagedRuntime {
|
|||
previousPythonPath: pointerPrevPythonPath,
|
||||
});
|
||||
} catch (probeErr: unknown) {
|
||||
const msg = probeErr instanceof Error ? probeErr.message : String(probeErr);
|
||||
const msg =
|
||||
probeErr instanceof Error ? probeErr.message : String(probeErr);
|
||||
return this._setCache({
|
||||
state: "needs_repair",
|
||||
pythonPath: pointerPythonPath,
|
||||
|
|
@ -530,8 +611,10 @@ export class ManagedRuntime {
|
|||
source: "none",
|
||||
error: {
|
||||
code: "FLATPAK_SNAP_UNSUPPORTED",
|
||||
message: "Flatpak and Snap are not supported. Install Python 3.11+ natively.",
|
||||
platformAction: "Install Python 3.11+ from python.org or package manager",
|
||||
message:
|
||||
"Flatpak and Snap are not supported. Install Python 3.11+ natively.",
|
||||
platformAction:
|
||||
"Install Python 3.11+ from python.org or package manager",
|
||||
},
|
||||
lastVerifiedAt: null,
|
||||
stale: false,
|
||||
|
|
@ -554,7 +637,8 @@ export class ManagedRuntime {
|
|||
source: "none",
|
||||
error: {
|
||||
code: "NO_PYTHON",
|
||||
message: "No Python 3.11+ found. macOS auto-download disabled until signed/notarized artifacts exist.",
|
||||
message:
|
||||
"No Python 3.11+ found. macOS auto-download disabled until signed/notarized artifacts exist.",
|
||||
platformAction: "Install Python 3.11+ from python.org or Homebrew",
|
||||
},
|
||||
lastVerifiedAt: null,
|
||||
|
|
@ -592,7 +676,8 @@ export class ManagedRuntime {
|
|||
source: "none",
|
||||
error: {
|
||||
code: "FALLBACK_UNAVAILABLE",
|
||||
message: "No Python found and this platform has no validated fallback.",
|
||||
message:
|
||||
"No Python found and this platform has no validated fallback.",
|
||||
platformAction: "Install Python 3.11+ manually from python.org",
|
||||
},
|
||||
lastVerifiedAt: null,
|
||||
|
|
@ -605,10 +690,8 @@ export class ManagedRuntime {
|
|||
|
||||
if (signal?.aborted) return this._abortedHealth();
|
||||
|
||||
// Step 2: Compatibility gate — version check
|
||||
const isExistingInstall = this._currentSlotExists(version);
|
||||
|
||||
if (!isAtLeast(bootstrap.version, MIN_PYTHON_LEGACY)) {
|
||||
// All installations require 3.11+
|
||||
if (!isAtLeast(bootstrap.version, MIN_PYTHON)) {
|
||||
return this._setCache({
|
||||
state: "unavailable",
|
||||
pythonPath: null,
|
||||
|
|
@ -616,28 +699,8 @@ export class ManagedRuntime {
|
|||
source: "none",
|
||||
error: {
|
||||
code: "PYTHON_TOO_OLD",
|
||||
message: `Python ${bootstrap.version} is too old. Python 3.10+ required.`,
|
||||
platformAction: "Install Python 3.10+",
|
||||
},
|
||||
lastVerifiedAt: null,
|
||||
stale: false,
|
||||
warnings: [],
|
||||
previousVersion: null,
|
||||
previousPythonPath: null,
|
||||
});
|
||||
}
|
||||
|
||||
// New installs / repairs require 3.11+
|
||||
if (!isExistingInstall && !isAtLeast(bootstrap.version, MIN_PYTHON_NEW)) {
|
||||
return this._setCache({
|
||||
state: "needs_repair",
|
||||
pythonPath: null,
|
||||
version: bootstrap.version,
|
||||
source: "none",
|
||||
error: {
|
||||
code: "PYTHON_VERSION_WARNING",
|
||||
message: `Python ${bootstrap.version} found. New installations require Python ${MIN_PYTHON_NEW}+.`,
|
||||
platformAction: `Upgrade to Python ${MIN_PYTHON_NEW}+`,
|
||||
message: `Python ${bootstrap.version} is too old. Python 3.11+ required.`,
|
||||
platformAction: "Install Python 3.11+",
|
||||
},
|
||||
lastVerifiedAt: null,
|
||||
stale: false,
|
||||
|
|
@ -648,30 +711,30 @@ export class ManagedRuntime {
|
|||
}
|
||||
|
||||
// Step 2.5: Rollback/retained-slot fast path — if the version slot
|
||||
// already exists, we are not forcing a rebuild, AND the currently
|
||||
// active pointer points to a different version, verify the retained
|
||||
// already exists and we are not forcing a rebuild, verify the retained
|
||||
// immutable slot and atomically rewrite the pointer without creating
|
||||
// a new venv or running pip. This preserves the slot exactly as it
|
||||
// was built and is never a bootstrap / venv / pip operation.
|
||||
if (isExistingInstall && !force) {
|
||||
// a new venv or running pip. Only enters when rolling back to a
|
||||
// *different* version; same-version ensure falls through to full build.
|
||||
if (this._currentSlotExists(version) && !force) {
|
||||
// Determine whether this is a rollback (active version != requested)
|
||||
let isRollback = false;
|
||||
try {
|
||||
const curRaw = this._fs.readFileSync(this.pointerPath, "utf-8");
|
||||
const curPtr: Record<string, unknown> = JSON.parse(curRaw);
|
||||
const activeVer = typeof curPtr.version === "string" ? curPtr.version : null;
|
||||
const activeVer =
|
||||
typeof curPtr.version === "string" ? curPtr.version : null;
|
||||
isRollback = activeVer !== null && activeVer !== version;
|
||||
} catch {
|
||||
// No pointer — not a rollback
|
||||
}
|
||||
|
||||
if (isRollback) {
|
||||
|
||||
const rollbackSlotDir = path.join(this.runtimeDir, `v${version}`);
|
||||
const rollbackVenvDir = path.join(rollbackSlotDir, "venv");
|
||||
const rollbackPythonExe = this.osPlatform === "win32"
|
||||
? path.join(rollbackVenvDir, "Scripts", "python.exe")
|
||||
: path.join(rollbackVenvDir, "bin", "python");
|
||||
const rollbackPythonExe =
|
||||
this.osPlatform === "win32"
|
||||
? path.join(rollbackVenvDir, "Scripts", "python.exe")
|
||||
: path.join(rollbackVenvDir, "bin", "python");
|
||||
|
||||
// Verify the retained slot's Python can import paperforge
|
||||
try {
|
||||
|
|
@ -680,7 +743,8 @@ export class ManagedRuntime {
|
|||
if (probeErr instanceof Error && probeErr.name === "AbortError") {
|
||||
return this._abortedHealth();
|
||||
}
|
||||
const msg = probeErr instanceof Error ? probeErr.message : String(probeErr);
|
||||
const msg =
|
||||
probeErr instanceof Error ? probeErr.message : String(probeErr);
|
||||
return this._setCache({
|
||||
state: "needs_repair",
|
||||
pythonPath: rollbackPythonExe,
|
||||
|
|
@ -705,8 +769,10 @@ export class ManagedRuntime {
|
|||
try {
|
||||
const oldRaw = this._fs.readFileSync(this.pointerPath, "utf-8");
|
||||
const oldPtr: Record<string, unknown> = JSON.parse(oldRaw);
|
||||
prevVersion = typeof oldPtr.version === "string" ? oldPtr.version : null;
|
||||
prevPythonPath = typeof oldPtr.pythonPath === "string" ? oldPtr.pythonPath : null;
|
||||
prevVersion =
|
||||
typeof oldPtr.version === "string" ? oldPtr.version : null;
|
||||
prevPythonPath =
|
||||
typeof oldPtr.pythonPath === "string" ? oldPtr.pythonPath : null;
|
||||
} catch {
|
||||
// No previous pointer — first install
|
||||
}
|
||||
|
|
@ -717,7 +783,10 @@ export class ManagedRuntime {
|
|||
this._fs.mkdirSync(pointerDir, { recursive: true });
|
||||
}
|
||||
|
||||
const relativePyPath = path.relative(path.dirname(this.pointerPath), rollbackPythonExe);
|
||||
const relativePyPath = path.relative(
|
||||
path.dirname(this.pointerPath),
|
||||
rollbackPythonExe
|
||||
);
|
||||
const pointerContent = JSON.stringify(
|
||||
{
|
||||
schema_version: 1,
|
||||
|
|
@ -728,7 +797,7 @@ export class ManagedRuntime {
|
|||
previousPythonPath: prevPythonPath,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
2
|
||||
);
|
||||
|
||||
const tmpPath = this.pointerPath + ".tmp";
|
||||
|
|
@ -755,52 +824,79 @@ export class ManagedRuntime {
|
|||
this._cleanupOldSlots(version);
|
||||
|
||||
return health;
|
||||
}
|
||||
} // end if (isRollback)
|
||||
}
|
||||
|
||||
if (signal?.aborted) return this._abortedHealth();
|
||||
|
||||
|
||||
// Step 3: Build immutable version slot
|
||||
const slotDir = force
|
||||
? path.join(this.runtimeDir, `v${version}_build2`)
|
||||
: path.join(this.runtimeDir, `v${version}`);
|
||||
const venvDir = path.join(slotDir, "venv");
|
||||
const pythonExe = this.osPlatform === "win32"
|
||||
? path.join(venvDir, "Scripts", "python.exe")
|
||||
: path.join(venvDir, "bin", "python");
|
||||
const pythonExe =
|
||||
this.osPlatform === "win32"
|
||||
? path.join(venvDir, "Scripts", "python.exe")
|
||||
: path.join(venvDir, "bin", "python");
|
||||
|
||||
try {
|
||||
this._fs.mkdirSync(slotDir, { recursive: true });
|
||||
|
||||
// Create venv
|
||||
const { promise: venvPromise, reject: venvReject, resolve: venvResolve } = deferred<void>();
|
||||
this._execFile(bootstrap.path, ["-m", "venv", venvDir], { timeout: 60000, signal }, (err) => {
|
||||
if (err) venvReject(err);
|
||||
else venvResolve();
|
||||
});
|
||||
const {
|
||||
promise: venvPromise,
|
||||
reject: venvReject,
|
||||
resolve: venvResolve,
|
||||
} = deferred<void>();
|
||||
this._execFile(
|
||||
bootstrap.path,
|
||||
["-m", "venv", venvDir],
|
||||
{ timeout: 60000, signal },
|
||||
(err) => {
|
||||
if (err) venvReject(err);
|
||||
else venvResolve();
|
||||
}
|
||||
);
|
||||
await venvPromise;
|
||||
} catch (venvErr: unknown) {
|
||||
if (venvErr instanceof Error && venvErr.name === "AbortError") {
|
||||
try { this._fs.rmSync(slotDir, { recursive: true, force: true }); } catch {}
|
||||
try {
|
||||
this._fs.rmSync(slotDir, { recursive: true, force: true });
|
||||
} catch {}
|
||||
return this._abortedHealth();
|
||||
}
|
||||
return this._slotFailed(version, "VENV_CREATION_FAILED", venvErr, slotDir);
|
||||
return this._slotFailed(
|
||||
version,
|
||||
"VENV_CREATION_FAILED",
|
||||
venvErr,
|
||||
slotDir
|
||||
);
|
||||
}
|
||||
|
||||
if (signal?.aborted) return this._abortedHealth();
|
||||
|
||||
try {
|
||||
// pip install paperforge
|
||||
const { promise: pipPromise, reject: pipReject, resolve: pipResolve } = deferred<void>();
|
||||
this._execFile(pythonExe, ["-m", "pip", "install", `paperforge==${version}`], { timeout: 120000, signal }, (err) => {
|
||||
if (err) pipReject(err);
|
||||
else pipResolve();
|
||||
});
|
||||
const {
|
||||
promise: pipPromise,
|
||||
reject: pipReject,
|
||||
resolve: pipResolve,
|
||||
} = deferred<void>();
|
||||
this._execFile(
|
||||
pythonExe,
|
||||
["-m", "pip", "install", `paperforge==${version}`],
|
||||
{ timeout: 120000, signal },
|
||||
(err) => {
|
||||
if (err) pipReject(err);
|
||||
else pipResolve();
|
||||
}
|
||||
);
|
||||
await pipPromise;
|
||||
} catch (pipErr: unknown) {
|
||||
if (pipErr instanceof Error && pipErr.name === "AbortError") {
|
||||
try { this._fs.rmSync(slotDir, { recursive: true, force: true }); } catch {}
|
||||
try {
|
||||
this._fs.rmSync(slotDir, { recursive: true, force: true });
|
||||
} catch {}
|
||||
return this._abortedHealth();
|
||||
}
|
||||
return this._slotFailed(version, "PIP_INSTALL_FAILED", pipErr, slotDir);
|
||||
|
|
@ -810,15 +906,26 @@ export class ManagedRuntime {
|
|||
|
||||
try {
|
||||
// Verify with isolated import
|
||||
const { promise: verifyPromise, reject: verifyReject, resolve: verifyResolve } = deferred<void>();
|
||||
this._execFile(pythonExe, ["-I", "-c", `import paperforge; print(paperforge.__version__)`], { timeout: 30000, signal }, (err) => {
|
||||
if (err) verifyReject(err);
|
||||
else verifyResolve();
|
||||
});
|
||||
const {
|
||||
promise: verifyPromise,
|
||||
reject: verifyReject,
|
||||
resolve: verifyResolve,
|
||||
} = deferred<void>();
|
||||
this._execFile(
|
||||
pythonExe,
|
||||
["-I", "-c", `import paperforge; print(paperforge.__version__)`],
|
||||
{ timeout: 30000, signal },
|
||||
(err) => {
|
||||
if (err) verifyReject(err);
|
||||
else verifyResolve();
|
||||
}
|
||||
);
|
||||
await verifyPromise;
|
||||
} catch (verifyErr: unknown) {
|
||||
if (verifyErr instanceof Error && verifyErr.name === "AbortError") {
|
||||
try { this._fs.rmSync(slotDir, { recursive: true, force: true }); } catch {}
|
||||
try {
|
||||
this._fs.rmSync(slotDir, { recursive: true, force: true });
|
||||
} catch {}
|
||||
return this._abortedHealth();
|
||||
}
|
||||
return this._slotFailed(version, "VERIFY_FAILED", verifyErr, slotDir);
|
||||
|
|
@ -831,7 +938,8 @@ export class ManagedRuntime {
|
|||
const oldRaw = this._fs.readFileSync(this.pointerPath, "utf-8");
|
||||
const oldPtr: Record<string, unknown> = JSON.parse(oldRaw);
|
||||
prevVersion = typeof oldPtr.version === "string" ? oldPtr.version : null;
|
||||
prevPythonPath = typeof oldPtr.pythonPath === "string" ? oldPtr.pythonPath : null;
|
||||
prevPythonPath =
|
||||
typeof oldPtr.pythonPath === "string" ? oldPtr.pythonPath : null;
|
||||
} catch {
|
||||
// No previous pointer — first install
|
||||
}
|
||||
|
|
@ -842,7 +950,10 @@ export class ManagedRuntime {
|
|||
this._fs.mkdirSync(pointerDir, { recursive: true });
|
||||
}
|
||||
|
||||
const relativePyPath = path.relative(path.dirname(this.pointerPath), pythonExe);
|
||||
const relativePyPath = path.relative(
|
||||
path.dirname(this.pointerPath),
|
||||
pythonExe
|
||||
);
|
||||
|
||||
const pointerContent = JSON.stringify(
|
||||
{
|
||||
|
|
@ -854,7 +965,7 @@ export class ManagedRuntime {
|
|||
previousPythonPath: prevPythonPath,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
2
|
||||
);
|
||||
|
||||
const tmpPath = this.pointerPath + ".tmp";
|
||||
|
|
@ -897,7 +1008,11 @@ export class ManagedRuntime {
|
|||
pythonPath: null,
|
||||
version: null,
|
||||
source: "none",
|
||||
error: { code: "ABORTED", message: "Operation was cancelled", platformAction: "Retry operation" },
|
||||
error: {
|
||||
code: "ABORTED",
|
||||
message: "Operation was cancelled",
|
||||
platformAction: "Retry operation",
|
||||
},
|
||||
lastVerifiedAt: null,
|
||||
stale: false,
|
||||
warnings: [],
|
||||
|
|
@ -906,7 +1021,12 @@ export class ManagedRuntime {
|
|||
};
|
||||
}
|
||||
|
||||
private _slotFailed(version: string, code: string, err: unknown, slotDir: string): RuntimeHealth {
|
||||
private _slotFailed(
|
||||
version: string,
|
||||
code: string,
|
||||
err: unknown,
|
||||
slotDir: string
|
||||
): RuntimeHealth {
|
||||
// Clean up failed slot
|
||||
try {
|
||||
this._fs.rmSync(slotDir, { recursive: true, force: true });
|
||||
|
|
@ -941,20 +1061,20 @@ export class ManagedRuntime {
|
|||
{ path: "py", args: ["-3.11"] },
|
||||
{ path: "py", args: ["-3.10"] },
|
||||
{ path: "py", args: ["-3"] },
|
||||
{ path: "python", args: [] },
|
||||
{ path: "python", args: [] }
|
||||
);
|
||||
} else if (this.osPlatform === "darwin") {
|
||||
candidates.push(
|
||||
{ path: "/usr/bin/python3", args: [] },
|
||||
{ path: "python3", args: [] },
|
||||
{ path: "python", args: [] },
|
||||
{ path: "python", args: [] }
|
||||
);
|
||||
} else {
|
||||
// Linux
|
||||
candidates.push(
|
||||
{ path: "/usr/bin/python3", args: [] },
|
||||
{ path: "python3", args: [] },
|
||||
{ path: "python", args: [] },
|
||||
{ path: "python", args: [] }
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -973,25 +1093,38 @@ export class ManagedRuntime {
|
|||
}
|
||||
}
|
||||
|
||||
throw new Error("No Python 3.10+ found on system");
|
||||
throw new Error("No Python 3.11+ found on system");
|
||||
}
|
||||
|
||||
private _probe(pythonPath: string, signal?: AbortSignal): Promise<{ version: string | null }> {
|
||||
private _probe(
|
||||
pythonPath: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ version: string | null }> {
|
||||
const { promise, resolve, reject } = deferred<{ version: string | null }>();
|
||||
this._execFile(pythonPath, ["-I", "-c", "import paperforge; print(paperforge.__version__)"], { timeout: 30000, signal }, (err, stdout) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
const version = (stdout ?? "").trim() || null;
|
||||
resolve({ version });
|
||||
this._execFile(
|
||||
pythonPath,
|
||||
["-I", "-c", "import paperforge; print(paperforge.__version__)"],
|
||||
{ timeout: 30000, signal },
|
||||
(err, stdout) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
const version = (stdout ?? "").trim() || null;
|
||||
resolve({ version });
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
return promise;
|
||||
}
|
||||
|
||||
private _cleanupOldSlots(currentVersion: string, keepOldSlots: number = 2): void {
|
||||
private _cleanupOldSlots(
|
||||
currentVersion: string,
|
||||
keepOldSlots: number = 2
|
||||
): void {
|
||||
try {
|
||||
const entries = this._fs.readdirSync(this.runtimeDir, { withFileTypes: true });
|
||||
const entries = this._fs.readdirSync(this.runtimeDir, {
|
||||
withFileTypes: true,
|
||||
});
|
||||
const slots = entries
|
||||
.filter((e) => e.isDirectory() && e.name.startsWith("v"))
|
||||
.map((e) => {
|
||||
|
|
@ -1003,7 +1136,10 @@ export class ManagedRuntime {
|
|||
|
||||
// Keep at most keepOldSlots old slots
|
||||
for (let i = keepOldSlots; i < slots.length; i++) {
|
||||
this._fs.rmSync(path.join(this.runtimeDir, slots[i].name), { recursive: true, force: true });
|
||||
this._fs.rmSync(path.join(this.runtimeDir, slots[i].name), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// best-effort
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -24,7 +24,9 @@ import {
|
|||
// ── Helpers ──
|
||||
|
||||
/** Minimal valid envelope matching backend int/schema. */
|
||||
function validEnvelope(overrides: Partial<Record<string, unknown>> = {}): Record<string, unknown> {
|
||||
function validEnvelope(
|
||||
overrides: Partial<Record<string, unknown>> = {}
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
schema_version: 1,
|
||||
module: "installation",
|
||||
|
|
@ -64,16 +66,24 @@ describe("isValidEnvelope", () => {
|
|||
});
|
||||
|
||||
it("passes with backend-typical null activity_label and null activity_progress", () => {
|
||||
expect(isValidEnvelope(validEnvelope({
|
||||
activity_label: null,
|
||||
activity_progress: null,
|
||||
}))).toBe(true);
|
||||
expect(
|
||||
isValidEnvelope(
|
||||
validEnvelope({
|
||||
activity_label: null,
|
||||
activity_progress: null,
|
||||
})
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("passes with full setup action primary", () => {
|
||||
expect(isValidEnvelope(validEnvelope({
|
||||
action: { primary: FULL_SETUP_ACTION },
|
||||
}))).toBe(true);
|
||||
expect(
|
||||
isValidEnvelope(
|
||||
validEnvelope({
|
||||
action: { primary: FULL_SETUP_ACTION },
|
||||
})
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects null input", () => {
|
||||
|
|
@ -90,7 +100,9 @@ describe("isValidEnvelope", () => {
|
|||
});
|
||||
|
||||
it("rejects wrong schema_version", () => {
|
||||
expect(isValidEnvelope(validEnvelope({ schema_version: "v1" }))).toBe(false);
|
||||
expect(isValidEnvelope(validEnvelope({ schema_version: "v1" }))).toBe(
|
||||
false
|
||||
);
|
||||
expect(isValidEnvelope(validEnvelope({ schema_version: 2 }))).toBe(false);
|
||||
});
|
||||
|
||||
|
|
@ -99,21 +111,36 @@ describe("isValidEnvelope", () => {
|
|||
});
|
||||
|
||||
it("rejects module mismatch", () => {
|
||||
expect(isValidEnvelope(validEnvelope({ module: "help" }), "installation")).toBe(false);
|
||||
expect(
|
||||
isValidEnvelope(validEnvelope({ module: "help" }), "installation")
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts matching module", () => {
|
||||
expect(isValidEnvelope(validEnvelope({ module: "help" }), "help")).toBe(true);
|
||||
expect(isValidEnvelope(validEnvelope({ module: "help" }), "help")).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts all six capability_states", () => {
|
||||
for (const s of ["unknown", "unavailable", "missing_input", "needs_action", "limited", "ready"]) {
|
||||
expect(isValidEnvelope(validEnvelope({ capability_state: s }))).toBe(true);
|
||||
for (const s of [
|
||||
"unknown",
|
||||
"unavailable",
|
||||
"missing_input",
|
||||
"needs_action",
|
||||
"limited",
|
||||
"ready",
|
||||
]) {
|
||||
expect(isValidEnvelope(validEnvelope({ capability_state: s }))).toBe(
|
||||
true
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid capability_state", () => {
|
||||
expect(isValidEnvelope(validEnvelope({ capability_state: "bogus" }))).toBe(false);
|
||||
expect(isValidEnvelope(validEnvelope({ capability_state: "bogus" }))).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts all four severities", () => {
|
||||
|
|
@ -123,7 +150,9 @@ describe("isValidEnvelope", () => {
|
|||
});
|
||||
|
||||
it("rejects invalid severity", () => {
|
||||
expect(isValidEnvelope(validEnvelope({ severity: "critical" }))).toBe(false);
|
||||
expect(isValidEnvelope(validEnvelope({ severity: "critical" }))).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects missing activity_state", () => {
|
||||
|
|
@ -142,23 +171,35 @@ describe("isValidEnvelope", () => {
|
|||
});
|
||||
|
||||
it("accepts valid activity_progress object", () => {
|
||||
expect(isValidEnvelope(validEnvelope({
|
||||
activity_state: "running",
|
||||
activity_label: "Probing...",
|
||||
activity_progress: { current: 1, total: 5 },
|
||||
}))).toBe(true);
|
||||
expect(
|
||||
isValidEnvelope(
|
||||
validEnvelope({
|
||||
activity_state: "running",
|
||||
activity_label: "Probing...",
|
||||
activity_progress: { current: 1, total: 5 },
|
||||
})
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects activity_progress with string current", () => {
|
||||
expect(isValidEnvelope(validEnvelope({
|
||||
activity_progress: { current: "1", total: 3 },
|
||||
}))).toBe(false);
|
||||
expect(
|
||||
isValidEnvelope(
|
||||
validEnvelope({
|
||||
activity_progress: { current: "1", total: 3 },
|
||||
})
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts notices array", () => {
|
||||
expect(isValidEnvelope(validEnvelope({
|
||||
notices: [{ level: "info", message: "test" }],
|
||||
}))).toBe(true);
|
||||
expect(
|
||||
isValidEnvelope(
|
||||
validEnvelope({
|
||||
notices: [{ level: "info", message: "test" }],
|
||||
})
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects missing notices", () => {
|
||||
|
|
@ -172,11 +213,15 @@ describe("isValidEnvelope", () => {
|
|||
});
|
||||
|
||||
it("rejects reason missing code", () => {
|
||||
expect(isValidEnvelope(validEnvelope({ reason: { text: "nope" } }))).toBe(false);
|
||||
expect(isValidEnvelope(validEnvelope({ reason: { text: "nope" } }))).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects reason missing text", () => {
|
||||
expect(isValidEnvelope(validEnvelope({ reason: { code: "nope" } }))).toBe(false);
|
||||
expect(isValidEnvelope(validEnvelope({ reason: { code: "nope" } }))).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects missing action", () => {
|
||||
|
|
@ -185,23 +230,33 @@ describe("isValidEnvelope", () => {
|
|||
});
|
||||
|
||||
it("rejects action with string primary", () => {
|
||||
expect(isValidEnvelope(validEnvelope({ action: { primary: "setup" } }))).toBe(false);
|
||||
expect(
|
||||
isValidEnvelope(validEnvelope({ action: { primary: "setup" } }))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects action with array primary", () => {
|
||||
expect(isValidEnvelope(validEnvelope({ action: { primary: [] } }))).toBe(false);
|
||||
expect(isValidEnvelope(validEnvelope({ action: { primary: [] } }))).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects action with incomplete primary (missing verb)", () => {
|
||||
expect(isValidEnvelope(validEnvelope({ action: { primary: { label: "x" } } }))).toBe(false);
|
||||
expect(
|
||||
isValidEnvelope(validEnvelope({ action: { primary: { label: "x" } } }))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts action with null primary", () => {
|
||||
expect(isValidEnvelope(validEnvelope({ action: { primary: null } }))).toBe(true);
|
||||
expect(isValidEnvelope(validEnvelope({ action: { primary: null } }))).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts action with full setup primary", () => {
|
||||
expect(isValidEnvelope(validEnvelope({ action: { primary: FULL_SETUP_ACTION } }))).toBe(true);
|
||||
expect(
|
||||
isValidEnvelope(validEnvelope({ action: { primary: FULL_SETUP_ACTION } }))
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects missing updated_at", () => {
|
||||
|
|
@ -224,8 +279,10 @@ describe("isValidEnvelope", () => {
|
|||
describe("isEnvelopeStale", () => {
|
||||
function makeEnv(overrides: Partial<ProbeEnvelope> = {}): ProbeEnvelope {
|
||||
return {
|
||||
schema_version: 1, module: "installation",
|
||||
capability_state: "ready", severity: "ok",
|
||||
schema_version: 1,
|
||||
module: "installation",
|
||||
capability_state: "ready",
|
||||
severity: "ok",
|
||||
activity_state: "idle",
|
||||
activity_label: null,
|
||||
activity_progress: null,
|
||||
|
|
@ -243,7 +300,9 @@ describe("isEnvelopeStale", () => {
|
|||
});
|
||||
|
||||
it("returns true for epoch date", () => {
|
||||
expect(isEnvelopeStale(makeEnv({ updated_at: new Date(0).toISOString() }))).toBe(true);
|
||||
expect(
|
||||
isEnvelopeStale(makeEnv({ updated_at: new Date(0).toISOString() }))
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when recent within TTL", () => {
|
||||
|
|
@ -357,8 +416,10 @@ describe("setupAction", () => {
|
|||
describe("isReadyEnvelope", () => {
|
||||
function make(overrides: Partial<ProbeEnvelope> = {}): ProbeEnvelope {
|
||||
return {
|
||||
schema_version: 1, module: "installation",
|
||||
capability_state: "ready", severity: "ok",
|
||||
schema_version: 1,
|
||||
module: "installation",
|
||||
capability_state: "ready",
|
||||
severity: "ok",
|
||||
activity_state: "idle",
|
||||
activity_label: null,
|
||||
activity_progress: null,
|
||||
|
|
@ -376,11 +437,15 @@ describe("isReadyEnvelope", () => {
|
|||
});
|
||||
|
||||
it("returns false for ready with non-null action", () => {
|
||||
expect(isReadyEnvelope(make({ action: { primary: FULL_SETUP_ACTION } }))).toBe(false);
|
||||
expect(
|
||||
isReadyEnvelope(make({ action: { primary: FULL_SETUP_ACTION } }))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for non-ready state", () => {
|
||||
expect(isReadyEnvelope(make({ capability_state: "unavailable" }))).toBe(false);
|
||||
expect(isReadyEnvelope(make({ capability_state: "unavailable" }))).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false for unknown state", () => {
|
||||
|
|
@ -419,7 +484,9 @@ describe("literal backend envelope", () => {
|
|||
severity: "ok",
|
||||
reason: { code: "ok", text: "PaperForge environment is set up correctly." },
|
||||
action: { primary: null },
|
||||
notices: [{ level: "info", message: "Installation verified at 2026-01-15" }],
|
||||
notices: [
|
||||
{ level: "info", message: "Installation verified at 2026-01-15" },
|
||||
],
|
||||
updated_at: "2026-01-15T00:00:00.000Z",
|
||||
ttl_seconds: 3600,
|
||||
};
|
||||
|
|
@ -433,7 +500,10 @@ describe("literal backend envelope", () => {
|
|||
...BACKEND_INSTALLATION_READY,
|
||||
capability_state: "needs_action",
|
||||
severity: "warning",
|
||||
reason: { code: "setup_required", text: "Initial setup not yet complete." },
|
||||
reason: {
|
||||
code: "setup_required",
|
||||
text: "Initial setup not yet complete.",
|
||||
},
|
||||
action: { primary: FULL_SETUP_ACTION },
|
||||
};
|
||||
expect(isValidEnvelope(envelope)).toBe(true);
|
||||
|
|
@ -456,22 +526,33 @@ describe("literal backend envelope", () => {
|
|||
|
||||
describe("classifyCapabilityAction", () => {
|
||||
const base: Partial<ProbeEnvelope> = {
|
||||
schema_version: 1, module: "installation", capability_state: "needs_action",
|
||||
activity_state: "idle", severity: "warning", reason: { code: "test", text: "test" },
|
||||
updated_at: new Date().toISOString(), ttl_seconds: 3600,
|
||||
schema_version: 1,
|
||||
module: "installation",
|
||||
capability_state: "needs_action",
|
||||
activity_state: "idle",
|
||||
severity: "warning",
|
||||
reason: { code: "test", text: "test" },
|
||||
updated_at: new Date().toISOString(),
|
||||
ttl_seconds: 3600,
|
||||
};
|
||||
|
||||
it("preserves the backend-selected action label", () => {
|
||||
const env: ProbeEnvelope = { ...base as ProbeEnvelope,
|
||||
const env: ProbeEnvelope = {
|
||||
...(base as ProbeEnvelope),
|
||||
module: "help",
|
||||
action: { primary: { verb: "setup", label: "Restore help", destructive: false } },
|
||||
action: {
|
||||
primary: { verb: "setup", label: "Restore help", destructive: false },
|
||||
},
|
||||
};
|
||||
expect(classifyCapabilityAction(env).label).toBe("Restore help");
|
||||
});
|
||||
|
||||
it("classifies set_config verb as setup kind", () => {
|
||||
const env: ProbeEnvelope = { ...base as ProbeEnvelope,
|
||||
action: { primary: { verb: "set_config", label: "Configure", destructive: false } },
|
||||
const env: ProbeEnvelope = {
|
||||
...(base as ProbeEnvelope),
|
||||
action: {
|
||||
primary: { verb: "set_config", label: "Configure", destructive: false },
|
||||
},
|
||||
};
|
||||
const result = classifyCapabilityAction(env);
|
||||
expect(result.kind).toBe("setup");
|
||||
|
|
@ -479,8 +560,11 @@ describe("classifyCapabilityAction", () => {
|
|||
});
|
||||
|
||||
it("classifies update verb as setup kind", () => {
|
||||
const env: ProbeEnvelope = { ...base as ProbeEnvelope,
|
||||
action: { primary: { verb: "update", label: "Update", destructive: false } },
|
||||
const env: ProbeEnvelope = {
|
||||
...(base as ProbeEnvelope),
|
||||
action: {
|
||||
primary: { verb: "update", label: "Update", destructive: false },
|
||||
},
|
||||
};
|
||||
const result = classifyCapabilityAction(env);
|
||||
expect(result.kind).toBe("setup");
|
||||
|
|
@ -488,8 +572,11 @@ describe("classifyCapabilityAction", () => {
|
|||
});
|
||||
|
||||
it("classifies probe verb as probe kind", () => {
|
||||
const env: ProbeEnvelope = { ...base as ProbeEnvelope,
|
||||
action: { primary: { verb: "probe", label: "Refresh", destructive: false } },
|
||||
const env: ProbeEnvelope = {
|
||||
...(base as ProbeEnvelope),
|
||||
action: {
|
||||
primary: { verb: "probe", label: "Refresh", destructive: false },
|
||||
},
|
||||
};
|
||||
const result = classifyCapabilityAction(env);
|
||||
expect(result.kind).toBe("probe");
|
||||
|
|
@ -497,7 +584,8 @@ describe("classifyCapabilityAction", () => {
|
|||
|
||||
it("classifies sync/run/rebuild_index as action kind", () => {
|
||||
for (const verb of ["sync", "run", "rebuild_index", "migrate"]) {
|
||||
const env: ProbeEnvelope = { ...base as ProbeEnvelope,
|
||||
const env: ProbeEnvelope = {
|
||||
...(base as ProbeEnvelope),
|
||||
action: { primary: { verb, label: verb, destructive: false } },
|
||||
};
|
||||
expect(classifyCapabilityAction(env).kind).toBe("action");
|
||||
|
|
@ -505,12 +593,15 @@ describe("classifyCapabilityAction", () => {
|
|||
});
|
||||
|
||||
it("defaults to probe when action is null", () => {
|
||||
const env: ProbeEnvelope = { ...base as ProbeEnvelope, action: null };
|
||||
const env: ProbeEnvelope = { ...(base as ProbeEnvelope), action: null };
|
||||
expect(classifyCapabilityAction(env).kind).toBe("probe");
|
||||
});
|
||||
|
||||
it("defaults to probe when primary action is null", () => {
|
||||
const env: ProbeEnvelope = { ...base as ProbeEnvelope, action: { primary: null } };
|
||||
const env: ProbeEnvelope = {
|
||||
...(base as ProbeEnvelope),
|
||||
action: { primary: null },
|
||||
};
|
||||
expect(classifyCapabilityAction(env).kind).toBe("probe");
|
||||
});
|
||||
});
|
||||
|
|
@ -519,15 +610,26 @@ describe("classifyCapabilityAction", () => {
|
|||
|
||||
describe("computeModuleSummary", () => {
|
||||
const readyEnv: ProbeEnvelope = {
|
||||
schema_version: 1, module: "installation", capability_state: "ready",
|
||||
activity_state: "idle", severity: "ok", reason: null,
|
||||
action: { primary: null }, updated_at: new Date().toISOString(), ttl_seconds: 3600,
|
||||
schema_version: 1,
|
||||
module: "installation",
|
||||
capability_state: "ready",
|
||||
activity_state: "idle",
|
||||
severity: "ok",
|
||||
reason: null,
|
||||
action: { primary: null },
|
||||
updated_at: new Date().toISOString(),
|
||||
ttl_seconds: 3600,
|
||||
};
|
||||
const unknownEnv: ProbeEnvelope = {
|
||||
schema_version: 1, module: "installation", capability_state: "unknown",
|
||||
activity_state: "idle", severity: "unknown", reason: { code: "test", text: "test" },
|
||||
schema_version: 1,
|
||||
module: "installation",
|
||||
capability_state: "unknown",
|
||||
activity_state: "idle",
|
||||
severity: "unknown",
|
||||
reason: { code: "test", text: "test" },
|
||||
action: { primary: { verb: "probe", label: "Probe", destructive: false } },
|
||||
updated_at: new Date(0).toISOString(), ttl_seconds: 0,
|
||||
updated_at: new Date(0).toISOString(),
|
||||
ttl_seconds: 0,
|
||||
};
|
||||
const realModules = ["installation", "help"];
|
||||
|
||||
|
|
@ -598,7 +700,9 @@ describe("validatePersistedEnvelopes", () => {
|
|||
};
|
||||
const result = validatePersistedEnvelopes(input, allModules);
|
||||
expect(result["installation"].capability_state).toBe("unknown");
|
||||
expect(result["installation"].reason?.code).toBe("installation.invalid_response");
|
||||
expect(result["installation"].reason?.code).toBe(
|
||||
"installation.invalid_response"
|
||||
);
|
||||
});
|
||||
|
||||
it("replaces stale entries with stale envelopes", () => {
|
||||
|
|
@ -634,7 +738,9 @@ describe("validatePersistedEnvelopes", () => {
|
|||
};
|
||||
const result = validatePersistedEnvelopes(input, allModules);
|
||||
expect(result["installation"].capability_state).toBe("unknown");
|
||||
expect(result["installation"].reason?.code).toBe("installation.invalid_response");
|
||||
expect(result["installation"].reason?.code).toBe(
|
||||
"installation.invalid_response"
|
||||
);
|
||||
});
|
||||
|
||||
it("replaces entries with non-ready severity/state mismatch with invalid envelope", () => {
|
||||
|
|
@ -723,32 +829,48 @@ describe("production-seam runtime dispatch", () => {
|
|||
polyfill("appendText", function (this: HTMLElement, text: string) {
|
||||
this.appendChild(this.ownerDocument.createTextNode(text));
|
||||
});
|
||||
polyfill("createDiv", function (this: HTMLElement, opts?: Record<string, unknown>): HTMLElement {
|
||||
const el = document.createElement("div");
|
||||
if (opts?.cls) el.className = String(opts.cls);
|
||||
if (opts?.text) el.textContent = String(opts.text);
|
||||
if (opts?.attr) {
|
||||
const attrs = opts.attr as Record<string, string>;
|
||||
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
|
||||
polyfill(
|
||||
"createDiv",
|
||||
function (
|
||||
this: HTMLElement,
|
||||
opts?: Record<string, unknown>
|
||||
): HTMLElement {
|
||||
const el = document.createElement("div");
|
||||
if (opts?.cls) el.className = String(opts.cls);
|
||||
if (opts?.text) el.textContent = String(opts.text);
|
||||
if (opts?.attr) {
|
||||
const attrs = opts.attr as Record<string, string>;
|
||||
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
|
||||
}
|
||||
this.appendChild(el);
|
||||
return el;
|
||||
}
|
||||
this.appendChild(el);
|
||||
return el;
|
||||
});
|
||||
polyfill("createEl", function (this: HTMLElement, tag: string, opts?: Record<string, unknown>): HTMLElement {
|
||||
const el = document.createElement(tag);
|
||||
if (opts?.cls) el.className = String(opts.cls);
|
||||
if (opts?.text) el.textContent = String(opts.text);
|
||||
if (opts?.attr) {
|
||||
const attrs = opts.attr as Record<string, string>;
|
||||
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
|
||||
);
|
||||
polyfill(
|
||||
"createEl",
|
||||
function (
|
||||
this: HTMLElement,
|
||||
tag: string,
|
||||
opts?: Record<string, unknown>
|
||||
): HTMLElement {
|
||||
const el = document.createElement(tag);
|
||||
if (opts?.cls) el.className = String(opts.cls);
|
||||
if (opts?.text) el.textContent = String(opts.text);
|
||||
if (opts?.attr) {
|
||||
const attrs = opts.attr as Record<string, string>;
|
||||
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
|
||||
}
|
||||
this.appendChild(el);
|
||||
return el;
|
||||
}
|
||||
this.appendChild(el);
|
||||
return el;
|
||||
});
|
||||
polyfill("setAttr", function (this: HTMLElement, attr: string, value: string): HTMLElement {
|
||||
this.setAttribute(attr, value);
|
||||
return this;
|
||||
});
|
||||
);
|
||||
polyfill(
|
||||
"setAttr",
|
||||
function (this: HTMLElement, attr: string, value: string): HTMLElement {
|
||||
this.setAttribute(attr, value);
|
||||
return this;
|
||||
}
|
||||
);
|
||||
polyfill("setText", function (this: HTMLElement, text: string): void {
|
||||
this.textContent = text;
|
||||
});
|
||||
|
|
@ -800,7 +922,9 @@ describe("production-seam runtime dispatch", () => {
|
|||
ttl_seconds: 3600,
|
||||
};
|
||||
}
|
||||
(plugin.settings as Record<string, unknown>).capabilityState = { ...tab._capabilityState };
|
||||
(plugin.settings as Record<string, unknown>).capabilityState = {
|
||||
...tab._capabilityState,
|
||||
};
|
||||
});
|
||||
|
||||
it("uses managed python path in _probeModule when runtime is ready", () => {
|
||||
|
|
@ -876,7 +1000,7 @@ describe("production-seam runtime dispatch", () => {
|
|||
expect(mockExecFile.mock.calls[0][3]).toBeInstanceOf(Function);
|
||||
});
|
||||
|
||||
it("falls back to legacy resolver with Release-N warning when managed is cold/stale", () => {
|
||||
it("fails closed when managed runtime is cold/stale — no legacy fallback", () => {
|
||||
setManagedHealth({
|
||||
state: "unknown",
|
||||
pythonPath: null,
|
||||
|
|
@ -895,14 +1019,11 @@ describe("production-seam runtime dispatch", () => {
|
|||
|
||||
tab._probeModule("installation");
|
||||
|
||||
expect(warnMessages.some((w) => w.includes("Release N"))).toBe(true);
|
||||
expect(mockExecFile).toHaveBeenCalledTimes(1);
|
||||
const execPath: string = mockExecFile.mock.calls[0][0];
|
||||
expect(execPath).toBe("/legacy/python");
|
||||
const execArgs: string[] = mockExecFile.mock.calls[0][1];
|
||||
expect(execArgs).toContain("-3");
|
||||
expect(execArgs).toContain("-m");
|
||||
expect(execArgs).toContain("paperforge");
|
||||
// Legacy fallback is removed — no execFile, no warning, setup envelope
|
||||
expect(mockExecFile).not.toHaveBeenCalled();
|
||||
const env = tab._capabilityState?.["installation"];
|
||||
expect(env?.reason?.code).toBe("installation.no_python");
|
||||
expect(env?.action?.primary?.verb).toBe("setup");
|
||||
});
|
||||
|
||||
it("RED Gap 2 follow-up: null resolver produces persistent setup envelope (installation.no_python + setup verb)", () => {
|
||||
|
|
@ -968,7 +1089,7 @@ describe("production-seam runtime dispatch", () => {
|
|||
expect(execOpts.timeout).toBe(8000);
|
||||
});
|
||||
|
||||
it("reproduces managed-install->legacy-invalid regression: managed absent and legacy empty path", () => {
|
||||
it("managed absent and legacy empty path produces stale envelope without fallback warning", () => {
|
||||
(tab as any)._managedRuntime = null;
|
||||
mockResolveRuntimeCommand.mockReturnValue(null);
|
||||
mockGetCachedPython.mockReturnValue({
|
||||
|
|
@ -980,7 +1101,10 @@ describe("production-seam runtime dispatch", () => {
|
|||
tab._probeModule("help");
|
||||
|
||||
expect(mockExecFile).not.toHaveBeenCalled();
|
||||
expect(warnMessages.some((w) => w.includes("Release N"))).toBe(true);
|
||||
expect(tab._capabilityState?.["help"]?.reason?.code).toBe("help.stale");
|
||||
// No Release N legacy fallback warning — fail closed
|
||||
expect(warnMessages.some((w) => w.includes("Release N"))).toBe(false);
|
||||
expect(tab._capabilityState?.["help"]?.reason?.code).toBe(
|
||||
"help.invalid_response"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -13,7 +13,7 @@ from paperforge.setup import SetupStepResult
|
|||
class SetupChecker:
|
||||
"""Validate all preconditions before any installation step."""
|
||||
|
||||
MIN_PYTHON = (3, 10)
|
||||
MIN_PYTHON = (3, 11)
|
||||
|
||||
def __init__(self, vault: Path):
|
||||
self.vault = vault
|
||||
|
|
|
|||
|
|
@ -85,12 +85,12 @@ class EnvChecker:
|
|||
def check_python(self) -> CheckResult:
|
||||
r = self.results["python"]
|
||||
v = sys.version_info
|
||||
if v >= (3, 10):
|
||||
if v >= (3, 11):
|
||||
r.passed = True
|
||||
r.detail = f"Python {v.major}.{v.minor}.{v.micro}"
|
||||
else:
|
||||
r.passed = False
|
||||
r.detail = f"Python {v.major}.{v.minor}.{v.micro} (需要 >= 3.10)"
|
||||
r.detail = f"Python {v.major}.{v.minor}.{v.micro} (需要 >= 3.11)"
|
||||
r.action_required = True
|
||||
return r
|
||||
|
||||
|
|
|
|||
|
|
@ -392,7 +392,7 @@ def run_doctor(vault: Path, verbose: bool = False, json_output: bool = False) ->
|
|||
current_process_status = "pass" if sys_version.major >= 3 and sys_version.minor >= 10 else "warn"
|
||||
current_process_fix = ""
|
||||
if current_process_status == "warn":
|
||||
current_process_fix = "建议使用插件已解析的解释器运行 doctor,或升级当前 shell Python 到 3.10+"
|
||||
current_process_fix = "建议使用插件已解析的解释器运行 doctor,或升级当前 shell Python 到 3.11+"
|
||||
add_check(
|
||||
"Python 环境",
|
||||
current_process_status,
|
||||
|
|
@ -403,14 +403,14 @@ def run_doctor(vault: Path, verbose: bool = False, json_output: bool = False) ->
|
|||
# --- Plugin-resolved interpreter checks ---
|
||||
add_check("Python 环境 (插件)", "pass", f"已解析解释器: {interp} (来源: {source})")
|
||||
|
||||
if version_tuple is not None and version_tuple >= (3, 10, 0):
|
||||
if version_tuple is not None and version_tuple >= (3, 11, 0):
|
||||
add_check("Python 环境 (插件)", "pass", f"Python {version_str}")
|
||||
elif version_tuple is not None:
|
||||
add_check(
|
||||
"Python 环境 (插件)",
|
||||
"fail",
|
||||
f"Python {version_str} (需要 3.10+)",
|
||||
f"升级解释器 {interp} 到 Python 3.10 或更高版本",
|
||||
f"Python {version_str} (需要 3.11+)",
|
||||
f"升级解释器 {interp} 到 Python 3.11 或更高版本",
|
||||
)
|
||||
else:
|
||||
add_check(
|
||||
|
|
@ -823,7 +823,7 @@ def run_doctor(vault: Path, verbose: bool = False, json_output: bool = False) ->
|
|||
if "PaperForge 包" in _fail_categories and not bool(_fail_categories - {"PaperForge 包"}):
|
||||
_next_action = "Recommended: run pip install via the resolved interpreter"
|
||||
elif "Python 环境" in _fail_categories or "Python 环境 (插件)" in _fail_categories:
|
||||
_next_action = "Recommended: install/upgrade Python to 3.10+"
|
||||
_next_action = "Recommended: install/upgrade Python to 3.11+"
|
||||
elif "Vault 结构" in _fail_categories or "Config Migration" in _fail_categories:
|
||||
_next_action = "Recommended: run `paperforge sync`"
|
||||
elif "OCR 配置" in _fail_categories:
|
||||
|
|
@ -901,7 +901,7 @@ def run_doctor(vault: Path, verbose: bool = False, json_output: bool = False) ->
|
|||
if "PaperForge 包" in fail_categories and not bool(fail_categories - {"PaperForge 包"}):
|
||||
next_action = "Recommended: run pip install via the resolved interpreter"
|
||||
elif "Python 环境" in fail_categories or "Python 环境 (插件)" in fail_categories:
|
||||
next_action = "Recommended: install/upgrade Python to 3.10+"
|
||||
next_action = "Recommended: install/upgrade Python to 3.11+"
|
||||
elif "Vault 结构" in fail_categories or "Config Migration" in fail_categories:
|
||||
next_action = "Recommended: run `paperforge sync`"
|
||||
elif "OCR 配置" in fail_categories:
|
||||
|
|
|
|||
Loading…
Reference in a new issue