feat: expose library OCR and memory capability tracers (#78)

This commit is contained in:
LLLin000 2026-07-18 18:13:08 +08:00
parent 6dfb209de4
commit 69a6223910
14 changed files with 3047 additions and 274 deletions

View file

@ -1,13 +1,13 @@
> **Branch:** `master` | **Last Updated:** 2026-07-18
> **Active work:** [Control-center PRD #74](https://github.com/LLLin000/PaperForge/issues/74) is split into eight dependency-linked implementation issues (#75#82). Setup/config (#75), capability envelopes (#76), and Managed Runtime/navigation (#77) are implemented; Library/OCR/Memory capability tracers (#78) are next.
> **Active work:** [Control-center PRD #74](https://github.com/LLLin000/PaperForge/issues/74) is split into eight dependency-linked implementation issues (#75#82). Setup/config (#75), capability envelopes (#76), Managed Runtime/navigation (#77), and Library/OCR/Memory capability tracers (#78) are implemented; SecretStorage (#79) is next.
>
> ---
>
> **Current state:** Retrieval recovery is merged and live. OCR multi-key rebuild/redo streams progress and refreshes canonical maintenance state. The control plane now includes schema-v1 capability envelopes plus a machine-local Managed Runtime with immutable slots, atomic activation, rollback, cancellation, and managed-first command dispatch.
>
> #71/#72 prototypes and #73 migration contract are complete. PRD #74 defines the six-module production program; #75#77 are implemented. The Wayfinder overview and `概览 / 模块详情 / 维护 / 帮助` shell are live; #78 adds real Library/OCR/Memory detail tracers without placeholder pages.
> #71/#72 prototypes and #73 migration contract are complete. PRD #74 defines the six-module production program; #75#78 are implemented. The Wayfinder overview and `概览 / 模块详情 / 维护 / 帮助` shell are live; #78 added real Library/OCR/Memory detail tracers, replacing placeholder pages.
>
> Next: implement [#78](https://github.com/LLLin000/PaperForge/issues/78) in a fresh Matt `/implement` session.
> Next: implement [#79](https://github.com/LLLin000/PaperForge/issues/79) (SecretStorage) in a fresh Matt `/implement` session.
## 1. Architecture
### 1.1 The problem (pre-v2)
@ -63,6 +63,7 @@ raw observations → structural signatures → stable anchors/families → zone
| Managed Runtime lifecycle + navigation (#77) | **192 focused + 289 full passed; typecheck/build clean** ✅ |
| OMP Matt workflow guard | **Fresh OMP auto-discovery confirmed; 7/7 deterministic guard cases passed** ✅ |
| Library/OCR/Memory capability tracers (#78) | **65 backend tests + 178 focused plugin tests, 324 full plugin tests passed across 11 files; typecheck/build clean; backup restore end-to-end** ✅ |
</br>
</br>
| Component | Status |
@ -90,6 +91,7 @@ raw observations → structural signatures → stable anchors/families → zone
| **Managed Runtime** | ✅ Immutable machine-local slots, atomic pointer activation, rollback/cancel/retention, managed-first command dispatch |
| **Agent workflow controls** | ✅ `.omp/RULES.md` + `matt-guard.ts` enforce one-writer Matt flow, worktree isolation, and post-mutation verification before release operations |
| **Library/OCR/Memory detail tracers** | ✅ Real capability probes with module-detail-navigation, installation-navigation, and capability-state views; Python owns capability fact definitions; TypeScript render uses exact allowlist, fails closed on unknown keys |
### 2.3 Fix Status
| # | Paper | Issue | Type | Fix | Commit |
@ -155,7 +157,7 @@ raw observations → structural signatures → stable anchors/families → zone
[Wayfinder: Restore PaperForge retrieval end to end](https://github.com/LLLin000/PaperForge/issues/45) completed and the resulting retrieval fixes are merged to `master`. The live Literature-hub vault has a healthy 2560-dimensional vec0 index; M and @ search paths are operational.
### Layer 3: Plugin UI
The OCR maintenance slice has a canonical All/Recommended state model, selected batch actions, streaming progress, cooperative stop, and canonical per-row action routing with confirmation gates for destructive operations. Issues #76#77 now provide production capability envelopes, the four-destination navigation shell, and the machine-local Managed Runtime lifecycle. Installation/Help are real probes; Library/OCR/Memory/Maintenance remain explicit placeholders pending #78/#80. Stale or malformed persisted evidence fails closed.
The OCR maintenance slice has a canonical All/Recommended state model, selected batch actions, streaming progress, cooperative stop, and canonical per-row action routing with confirmation gates for destructive operations. Issues #76#78 now provide production capability envelopes, the four-destination navigation shell, the machine-local Managed Runtime lifecycle, and real Library/OCR/Memory detail probes. Installation/Help/Library/OCR/Memory are real probes; Maintenance remains an explicit placeholder pending #80. Stale or malformed persisted evidence fails closed.
### Layer 4: Downstream Tools
`chunker.py` uses hardcoded section regex + fixed 3-paragraph groups. OCR has rich structured output (sections, headings, figures, tables with captions) — chunker should consume this structure directly. Figures/tables should support separate embedding (text + future vision).
@ -163,7 +165,7 @@ The OCR maintenance slice has a canonical All/Recommended state model, selected
Remaining legacy OCR issues (carried forward):
## 4. Active Queue
1. 🔴 **[Control-center PRD #74](https://github.com/LLLin000/PaperForge/issues/74)** — published and split into eight native dependency-linked issues (#75#82). #75#77 are implemented; #78 Library/OCR/Memory capability tracers are the next unblocked slice.
1. 🔴 **[Control-center PRD #74](https://github.com/LLLin000/PaperForge/issues/74)** — published and split into eight native dependency-linked issues (#75#82). #75#78 are implemented; #79 SecretStorage is the next unblocked slice.
2. ✅ **[Capability-state vocabulary](https://github.com/LLLin000/PaperForge/issues/69)** — resolved at `issuecomment-4971161072`. Orthogonal availability/activity/attention axes, 6-state capability ordinal, 12 canonical verbs, backend-owned severity and primary actions, maintenance projection.
3. ✅ **[Managed runtime](https://github.com/LLLin000/PaperForge/issues/70)** — resolved at `issuecomment-4971239398`. Plugin-managed immutable runtime slots, system-Python bootstrap with validated-triplet fallback, single `active-runtime.json` pointer, `ManagedRuntime` class with `current()`/`status()`/`ensure()`, fail-closed command resolution.
4. ✅ **[Control-center prototype](https://github.com/LLLin000/PaperForge/issues/71)** — resolved with independent Critical/Important PASS review and browser verification at 768px. Six-module control-center HTML prototype covers 5 scenarios with plain-button switcher, primary attention zone, responsive layout, and capability-gated actions. Design decisions recorded in `docs/prototypes/2026-07-14-six-module-control-center.html/.md`. No production implementation before #73.
@ -190,7 +192,8 @@ Remaining legacy OCR issues (carried forward):
- [x] Canonicalize setup/config migration ([#75](https://github.com/LLLin000/PaperForge/issues/75)) — 61 focused tests; spec PASS; quality APPROVED
- [x] Implement Installation/Help capability tracer ([#76](https://github.com/LLLin000/PaperForge/issues/76)) — 21 backend tests; 169 plugin tests; typecheck/build clean; independent review PASS
- [x] Implement Managed Runtime lifecycle and the approved Installation-detail navigation shell ([#77](https://github.com/LLLin000/PaperForge/issues/77)) — 192 focused + 289 full tests; typecheck/build clean; merged to `master`
- [ ] Expose Library, OCR, and Memory capabilities end to end ([#78](https://github.com/LLLin000/PaperForge/issues/78))
- [x] Expose Library, OCR, and Memory capabilities end to end ([#78](https://github.com/LLLin000/PaperForge/issues/78)) — 65 backend tests + 178 focused plugin tests, 324 full plugin tests across 11 files; typecheck/build clean; fail-closed recognizable config for Library/OCR; red rebuild_result stays non-destructive rebuild; queued OCR progress starts at 0; failed/null Library sync exit outcome is forwarded into fresh Python probe and remains sync actionable
- [ ] Implement SecretStorage for capability secrets ([#79](https://github.com/LLLin000/PaperForge/issues/79))
---
## 5. Key File Map
@ -348,6 +351,7 @@ Remaining legacy OCR issues (carried forward):
| 2026-07-15 | Capability integration advances by real tracer, never frontend optimism | #76 exposes only Installation and Help as real probes; Library/OCR/Memory/Maintenance remain explicit placeholders until their backend envelopes ship. Persisted malformed or stale evidence becomes unknown/invalid rather than ready. |
| 2026-07-15 | Module detail navigation extends Wayfinder instead of replacing it | Preserve the primary-attention zone and concrete backend action. Stage `概览 / 模块详情 / 维护 / 帮助` across #77/#78/#80; use explicit module-title navigation, top ordinary buttons, and no dead placeholder details. |
| 2026-07-18 | Enforce Matt with sticky rules plus a minimal deterministic hook | Prose alone cannot prevent orchestration drift. Keep lifecycle guidance in `.omp/RULES.md`; use the hook only for mechanically provable boundaries: one writer, worktree isolation, and verification-after-last-mutation before release operations. |
| 2026-07-18 | Capability facts are Python-owned; TypeScript renders via exact allowlist, fail-closed | Python owns all capability fact definitions and cross-module consistency. TypeScript receives only pre-classified display objects; rendering uses an exact allowlist of known content component keys. Unknown keys render nothing (fail-closed) rather than guessing a classification. TypeScript forwards operation exit outcomes but Python classifies sync failure. This avoids duplicating classification logic across the stack. |
---
@ -453,6 +457,7 @@ python -m ruff check paperforge/worker/ocr_*.py
| 2026-07-15 | Installation/Help capability tracer + navigation refinement | Implemented schema-v1 probe envelopes, six-module Overview, setup-complete migration, strict persistence/TTL validation, backend-owned action labels and dispatch, responsive/focus-visible UI, and generated bundle. Verification: 21 backend tests, 169 plugin tests, typecheck/build, live Obsidian stale-cache/action-label smoke test, independent review PASS. Matt flow refined PRD #74 and existing #77/#78/#80 without duplicating issues. | [Issue #76](https://github.com/LLLin000/PaperForge/issues/76), [PRD refinement](https://github.com/LLLin000/PaperForge/issues/74#issuecomment-4980322098) |
| 2026-07-15 | Managed Runtime lifecycle + final navigation shell | Completed #77 with immutable runtime slots, synchronous fail-closed `current`, probed `status`, install/repair/update/rollback/cancel/retention, managed-first dispatch, Release-N fallback, four-destination navigation, Installation detail, Agent integration, and Help focus restoration. Verification: 192 focused + 289 full tests; typecheck/build clean. Merged to `master` in `173a4e8..4ef9e98`. | [Issue #77](https://github.com/LLLin000/PaperForge/issues/77) |
| 2026-07-18 | OMP Matt workflow enforcement | Removed all discoverable Superpowers installations, retained canonical Ask Matt skills, added sticky project rules, and installed an auto-discovered hook for one-writer batches, worktree isolation, and fresh verification gates. Fresh OMP smoke reached the hook; 7/7 deterministic guard cases passed. | `.omp/RULES.md`, `.omp/hooks/pre/matt-guard.ts` |
| 2026-07-18 | Library/OCR/Memory capability tracers (#78) | Implemented real capability probes for Library, OCR, and Memory with module-detail-navigation, installation-navigation, and capability-state views. Python owns capability facts; TypeScript exact allowlist/fail-closed rendering avoids duplicate classification. Repair: canonical PADDLEOCR_API_TOKEN resolution (env/WinReg/dotenv), OCR probe priority fix (provider-unreachable after actionable rows), overview card dispatch (exact allowlist, not probe), memory backup restore end-to-end (integrity_check + atomic os.replace + corrupt preservation), _embedProcess lifecycle cleanup. Final: fail-closed recognizable config for Library/OCR, red rebuild_result stays non-destructive rebuild, queued OCR progress starts at 0, failed/null Library sync exit outcome forwarded into fresh Python probe and remains sync actionable. Verification: 65 backend + 178 focused plugin + 324 full plugin tests across 11 files; typecheck/build clean; live Obsidian 730/768 smoke. |
## 9. Historical Detail Archive

View file

@ -323,6 +323,8 @@ def build_parser() -> argparse.ArgumentParser:
p_memory_build.add_argument("--json", action="store_true", help="Output as JSON")
p_memory_status = p_memory_sp.add_parser("status", help="Check memory database status")
p_memory_status.add_argument("--json", action="store_true", help="Output as JSON")
p_memory_restore = p_memory_sp.add_parser("restore-backup", help="Restore memory database from a verified backup")
p_memory_restore.add_argument("--json", action="store_true", help="Output as JSON (PFResult envelope)")
p_paper_status = sub.add_parser("paper-status", help="Look up a paper's status")
p_paper_status.add_argument("query", help="Paper identifier (zotero_key, DOI, title, alias)")
@ -483,14 +485,21 @@ def build_parser() -> argparse.ArgumentParser:
p_probe = sub.add_parser("probe", help="Probe a module's capability state")
p_probe.add_argument(
"probe_module",
choices=["installation", "help"],
help="Module to probe (installation or help)",
choices=["installation", "help", "library", "ocr", "memory"],
help="Module to probe (installation, library, ocr, memory, or help)",
)
p_probe.add_argument(
"--json",
action="store_true",
help="Output as schema-v1 capability envelope JSON",
)
p_probe.add_argument(
"--last-operation-exit-code",
type=int,
default=None,
metavar="CODE",
help="Last operation exit code for library sync failure probe",
)
return parser

View file

@ -2,6 +2,7 @@ from __future__ import annotations
import argparse
import sys
from pathlib import Path
from paperforge import __version__ as PF_VERSION
from paperforge.core.errors import ErrorCode
@ -10,6 +11,143 @@ from paperforge.memory.builder import build_from_index
from paperforge.memory.query import get_memory_status
def _restore_backup(vault: Path) -> PFResult:
"""Restore paperforge.db from a validated backup.
Backup is staged to a temporary file then atomically installed via os.replace.
The current DB (if any) is copied to a timestamped corrupt-* snapshot before replacement.
The backup file is never modified it remains in place.
Steps:
1. Validate backup with context-managed PRAGMA integrity_check
2. Copy current DB to timestamped .corrupt-* snapshot (if exists)
3. Copy backup to a unique same-directory temp file
4. Atomic os.replace(temp, db_path)
5. On any failure, current DB and backup remain unchanged; clean temp.
"""
import os
import shutil
import sqlite3
import time
from paperforge.memory.db import get_memory_db_path
db_path = get_memory_db_path(vault)
backup_path = db_path.with_suffix(db_path.suffix + ".backup")
if not backup_path.exists():
return PFResult(
ok=False,
command="memory restore-backup",
version=PF_VERSION,
error=PFError(
code=ErrorCode.PATH_NOT_FOUND,
message=f"No backup found at {backup_path}",
),
)
# ── Validate backup integrity (read-only, explicit close to release Windows locks) ──
try:
backup_uri = "file:" + backup_path.as_posix() + "?mode=ro"
conn = sqlite3.connect(backup_uri, uri=True)
row = conn.execute("PRAGMA integrity_check").fetchone()
conn.close()
if not row or row[0] != "ok":
return PFResult(
ok=False,
command="memory restore-backup",
version=PF_VERSION,
error=PFError(
code=ErrorCode.VALIDATION_ERROR,
message=f"Backup integrity check failed: {row[0] if row else 'no result'}",
),
)
except sqlite3.Error as e:
return PFResult(
ok=False,
command="memory restore-backup",
version=PF_VERSION,
error=PFError(
code=ErrorCode.VALIDATION_ERROR,
message=f"Cannot validate backup: {e}",
),
)
# ── Staging: unique timestamp ──
ts = time.strftime("%Y%m%dT%H%M%S")
ns = str(int(time.time_ns() % 1_000_000_000)).zfill(9) # ponytail: nanosecond suffix for uniqueness
# ── Preserve current DB via copy (never move) ──
corrupt_path = None
if db_path.exists():
corrupt_path = db_path.with_name(f"paperforge.corrupt-{ts}-{ns}.db")
try:
shutil.copy2(str(db_path), str(corrupt_path))
except OSError as e:
return PFResult(
ok=False,
command="memory restore-backup",
version=PF_VERSION,
error=PFError(
code=ErrorCode.INTERNAL_ERROR,
message=f"Failed to preserve current database: {e}",
),
)
# ── Copy backup to unique same-directory temp ──
temp_path = db_path.with_name(f"paperforge.restore-{ts}-{ns}.tmp")
try:
shutil.copy2(str(backup_path), str(temp_path))
# ponytail: copy2 already flushes; atomic replace is the real guarantee
except OSError as e:
# Clean temp on failure
if temp_path.exists():
try:
temp_path.unlink()
except OSError:
pass
return PFResult(
ok=False,
command="memory restore-backup",
version=PF_VERSION,
error=PFError(
code=ErrorCode.INTERNAL_ERROR,
message=f"Failed to stage backup: {e}",
),
)
# ── Atomic replace temp → db_path ──
try:
os.replace(str(temp_path), str(db_path))
except OSError as e:
# Clean temp on failure
if temp_path.exists():
try:
temp_path.unlink()
except OSError:
pass
return PFResult(
ok=False,
command="memory restore-backup",
version=PF_VERSION,
error=PFError(
code=ErrorCode.INTERNAL_ERROR,
message=f"Failed to restore backup: {e}",
),
)
return PFResult(
ok=True,
command="memory restore-backup",
version=PF_VERSION,
data={
"action": "restore_backup",
"preserved_as": str(corrupt_path) if corrupt_path else None,
},
)
def run(args: argparse.Namespace) -> int:
vault = args.vault_path
sub_cmd = args.memory_subcommand
@ -61,45 +199,38 @@ def run(args: argparse.Namespace) -> int:
if sub_cmd == "status":
try:
status = get_memory_status(vault)
# Write memory-runtime-state.json snapshot (JS-First Memory State)
try:
from paperforge.memory.db import get_connection, get_memory_db_path
from paperforge.memory.schema import get_schema_version
from paperforge.memory.state_snapshot import write_memory_runtime
_last_full_build = ""
_schema_ver_db = 0
_fts_ok = False
_db_p = get_memory_db_path(vault)
if _db_p.exists():
conn2 = get_connection(_db_p, read_only=True)
_row = conn2.execute(
"SELECT value FROM meta WHERE key = 'last_full_build_at'"
).fetchone()
if _row:
_last_full_build = _row["value"]
_schema_ver_db = get_schema_version(conn2)
_fts_row = conn2.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='paper_fts'"
).fetchone()
_fts_ok = _fts_row is not None
conn2.close()
write_memory_runtime(
vault,
paper_count_db=status["paper_count_db"],
paper_count_index=status["paper_count_index"],
fresh=status["fresh"],
needs_rebuild=status["needs_rebuild"],
last_full_build_at=_last_full_build,
schema_version_db=_schema_ver_db,
fts_ready=_fts_ok,
)
except Exception:
pass
from paperforge.memory.db import get_connection, get_memory_db_path
from paperforge.memory.schema import get_schema_version
from paperforge.memory.state_snapshot import write_memory_runtime
_last_full_build = ""
_schema_ver = None
_fts_ok = False
_db_p = get_memory_db_path(vault)
if _db_p.exists():
conn2 = get_connection(_db_p, read_only=True)
_row = conn2.execute(
"SELECT value FROM meta WHERE key = 'last_full_build'"
).fetchone()
_last_full_build = _row["value"] if _row else ""
_schema_ver = get_schema_version(conn2)
try:
conn2.execute("SELECT zotero_key FROM papers_fts LIMIT 1")
_fts_ok = True
except Exception:
_fts_ok = False
conn2.close()
write_memory_runtime(vault, status)
data = {
**status,
"last_full_build": _last_full_build,
"schema_version": _schema_ver,
"fts_ok": _fts_ok,
}
result = PFResult(
ok=True,
command="memory status",
version=PF_VERSION,
data=status,
data=data,
)
except Exception as exc:
result = PFResult(
@ -115,8 +246,22 @@ def run(args: argparse.Namespace) -> int:
print(result.to_json())
else:
if result.ok:
for k, v in status.items():
print(f" {k}: {v}")
for k, v in (result.data or {}).items():
print(f"{k}: {v}")
else:
print(f"Error: {result.error.message}", file=sys.stderr)
return 0 if result.ok else 1
if sub_cmd == "restore-backup":
result = _restore_backup(vault)
if args.json:
print(result.to_json())
else:
if result.ok:
data = result.data or {}
print("Memory database restored from verified backup.")
if data.get("preserved_as"):
print(f"Corrupted database preserved as: {data['preserved_as']}")
else:
print(f"Error: {result.error.message}", file=sys.stderr)
return 0 if result.ok else 1

View file

@ -1,14 +1,14 @@
"""paperforge.commands.probe — Capability probe command (Issue #76, contract #69).
"""paperforge.commands.probe — Capability probe command (Issue #76, #78, contract #69).
Emits direct schema-v1 capability envelopes (not PFResult-wrapped) for the
Obsidian plugin's six-module control center. Only Installation and Help have
real probes in this tracer; the remaining four modules (Library, OCR, Memory,
Maintenance) are explicit unknown placeholders.
Obsidian plugin's six-module control center. Probes use canonical sources:
paperforge_paths, load_vault_config, get_memory_status, collect_maintenance_rows.
"""
from __future__ import annotations
import json
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
@ -20,6 +20,9 @@ from typing import Any
SCHEMA_VERSION = 1
TTL_INSTALLATION = 3600
TTL_LIBRARY = 300
TTL_OCR = 60
TTL_MEMORY = 300
TTL_HELP = 3600
MIN_PYTHON = (3, 11)
LEGACY_PATH_KEYS = frozenset({
@ -27,7 +30,7 @@ LEGACY_PATH_KEYS = frozenset({
"base_dir", "control_dir", "skill_dir", "command_dir",
})
SUPPORTED_MODULES = frozenset({"installation", "help"})
SUPPORTED_MODULES = frozenset({"installation", "library", "ocr", "memory", "help"})
# ---------------------------------------------------------------------------
@ -35,75 +38,38 @@ SUPPORTED_MODULES = frozenset({"installation", "help"})
# ---------------------------------------------------------------------------
def _utcnow_z() -> str:
"""Return current UTC time as ISO 8601 with Z suffix (e.g. 2026-07-15T12:34:56Z)."""
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def build_action_primary(
*,
verb: str,
label: str,
command: str,
destructive: bool = False,
destructive_scope: str | None = None,
destructive_effect: str | None = None,
confirmation_required: bool = False,
confirmation_prompt: str | None = None,
scope: str = "module",
scope_count: int = 1,
*, verb: str, label: str, command: str,
destructive: bool = False, destructive_scope: str | None = None,
destructive_effect: str | None = None, confirmation_required: bool = False,
confirmation_prompt: str | None = None, scope: str = "module", scope_count: int = 1,
) -> dict[str, Any]:
"""Build a full action.primary dict per #69 contract fields."""
return {
"verb": verb,
"label": label,
"destructive": destructive,
"destructive_scope": destructive_scope,
"destructive_effect": destructive_effect,
"confirmation_required": confirmation_required,
"confirmation_prompt": confirmation_prompt,
"command": command,
"scope": scope,
"scope_count": scope_count,
"verb": verb, "label": label, "destructive": destructive,
"destructive_scope": destructive_scope, "destructive_effect": destructive_effect,
"confirmation_required": confirmation_required, "confirmation_prompt": confirmation_prompt,
"command": command, "scope": scope, "scope_count": scope_count,
}
def build_envelope(
*,
module: str,
capability_state: str,
severity: str,
reason_code: str,
reason_text: str,
*, module: str, capability_state: str, severity: str,
reason_code: str, reason_text: str,
action_primary: dict[str, Any] | None = None,
activity_state: str = "idle",
activity_label: str | None = None,
activity_progress: float | None = None,
ttl_seconds: int = 3600,
activity_state: str = "idle", activity_label: str | None = None,
activity_progress: dict[str, int] | None = None, ttl_seconds: int = 3600,
notices: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Build a schema-v1 capability envelope.
Required fields per #69 contract:
schema_version, module, capability_state, activity_state, activity_label,
activity_progress, severity, reason{code,text}, action{primary}, notices,
updated_at, ttl_seconds.
"""
return {
"schema_version": SCHEMA_VERSION,
"module": module,
"capability_state": capability_state,
"activity_state": activity_state,
"activity_label": activity_label,
"activity_progress": activity_progress,
"severity": severity,
"reason": {
"code": reason_code,
"text": reason_text,
},
"action": {"primary": action_primary},
"notices": notices or [],
"updated_at": _utcnow_z(),
"ttl_seconds": ttl_seconds,
"schema_version": SCHEMA_VERSION, "module": module,
"capability_state": capability_state, "activity_state": activity_state,
"activity_label": activity_label, "activity_progress": activity_progress,
"severity": severity, "reason": {"code": reason_code, "text": reason_text},
"action": {"primary": action_primary}, "notices": notices or [],
"updated_at": _utcnow_z(), "ttl_seconds": ttl_seconds,
}
@ -112,17 +78,6 @@ def build_envelope(
# ---------------------------------------------------------------------------
def _is_recognizable_config(data: Any) -> bool:
"""Return True if *data* looks like a recognizable PaperForge config.
Accepts:
- dict with a ``vault_config`` key (v2 format)
- dict with at least one legacy path key (pre-migration format)
Rejects:
- non-dict types (list, str, number, bool)
- empty dict ``{}``
- dict with keys but neither ``vault_config`` nor any legacy path key
"""
if not isinstance(data, dict) or len(data) == 0:
return False
if "vault_config" in data:
@ -130,138 +85,578 @@ def _is_recognizable_config(data: Any) -> bool:
return any(k in data for k in LEGACY_PATH_KEYS)
def _load_pf_config(vault: Path) -> tuple[dict[str, Any] | None, str | None]:
"""Read paperforge.json. Returns (data, error) tuple.
data=None, error=None file does not exist.
data=None, error='corrupt' file exists but is invalid.
data=dict, error=None valid config.
"""
pf_json = vault / "paperforge.json"
if not pf_json.exists():
return None, None
try:
raw = pf_json.read_text(encoding="utf-8")
data = json.loads(raw)
except (json.JSONDecodeError, OSError):
return None, "corrupt"
if not _is_recognizable_config(data):
return None, "corrupt"
return data, None
# ---------------------------------------------------------------------------
# Probes
# ---------------------------------------------------------------------------
def probe_installation(vault: Path) -> dict[str, Any]:
"""Probe the Installation module.
Read-only probe that checks:
1. paperforge.json exists
2. paperforge.json is valid and recognizable config
3. Current Python >= 3.11
4. Everything OK ready
Does NOT search for ambient Python, spawn interpreters, or instantiate
SetupPlan (that is #77's domain).
"""
pf_json = vault / "paperforge.json"
# 1. Missing config
if not pf_json.exists():
return build_envelope(
module="installation",
capability_state="missing_input",
severity="warning",
reason_code="installation.config_missing",
reason_text="paperforge.json not found in vault",
action_primary=build_action_primary(
verb="set_config",
label="Set config",
command="paperforge setup",
),
module="installation", capability_state="missing_input", severity="warning",
reason_code="installation.config_missing", reason_text="paperforge.json not found in vault",
action_primary=build_action_primary(verb="set_config", label="Set config", command="paperforge setup"),
ttl_seconds=TTL_INSTALLATION,
)
# 2. Parse JSON
try:
data = json.loads(pf_json.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return build_envelope(
module="installation",
capability_state="unavailable",
severity="error",
reason_code="installation.config_corrupt",
reason_text="paperforge.json is corrupt or invalid",
action_primary=build_action_primary(
verb="setup",
label="Setup",
command="paperforge setup",
),
module="installation", capability_state="unavailable", severity="error",
reason_code="installation.config_corrupt", reason_text="paperforge.json is corrupt or invalid",
action_primary=build_action_primary(verb="setup", label="Setup", command="paperforge setup"),
ttl_seconds=TTL_INSTALLATION,
)
# 2b. Validate config shape — list, primitive, empty, or unrecognized
if not _is_recognizable_config(data):
return build_envelope(
module="installation",
capability_state="unavailable",
severity="error",
reason_code="installation.config_corrupt",
reason_text="paperforge.json has unrecognizable structure",
action_primary=build_action_primary(
verb="setup",
label="Setup",
command="paperforge setup",
),
module="installation", capability_state="unavailable", severity="error",
reason_code="installation.config_corrupt", reason_text="paperforge.json has unrecognizable structure",
action_primary=build_action_primary(verb="setup", label="Setup", command="paperforge setup"),
ttl_seconds=TTL_INSTALLATION,
)
# 3. Python version check
py_version = sys.version_info[:2]
if py_version < MIN_PYTHON:
return build_envelope(
module="installation",
capability_state="limited",
severity="warning",
module="installation", capability_state="limited", severity="warning",
reason_code="installation.python_version_unsupported",
reason_text=(
f"Python {py_version[0]}.{py_version[1]} < "
f"{MIN_PYTHON[0]}.{MIN_PYTHON[1]}"
),
action_primary=build_action_primary(
verb="update",
label="Update Python",
command="",
),
reason_text=f"Python {py_version[0]}.{py_version[1]} < {MIN_PYTHON[0]}.{MIN_PYTHON[1]}",
action_primary=build_action_primary(verb="update", label="Update Python", command=""),
ttl_seconds=TTL_INSTALLATION,
)
# 4. All good
return build_envelope(
module="installation",
capability_state="ready",
severity="ok",
reason_code="installation.ready",
reason_text="PaperForge is installed and configured",
action_primary=None,
ttl_seconds=TTL_INSTALLATION,
module="installation", capability_state="ready", severity="ok",
reason_code="installation.ready", reason_text="PaperForge is installed and configured",
action_primary=None, ttl_seconds=TTL_INSTALLATION,
)
def probe_help(vault: Path) -> dict[str, Any]: # noqa: ARG001
"""Probe the Help module.
Checks whether the packaged PaperForge help/skill source exists so that
first-run help can be ready immediately.
"""
skills_dir = Path(__file__).resolve().parent.parent / "skills" / "paperforge"
skill_md = skills_dir / "SKILL.md"
if skill_md.exists():
return build_envelope(
module="help",
capability_state="ready",
severity="ok",
reason_code="help.ready",
reason_text="Help and skill documentation available",
action_primary=None,
ttl_seconds=TTL_HELP,
module="help", capability_state="ready", severity="ok",
reason_code="help.ready", reason_text="Help and skill documentation available",
action_primary=None, ttl_seconds=TTL_HELP,
)
return build_envelope(
module="help", capability_state="limited", severity="warning",
reason_code="help.docs_missing", reason_text="Packaged help source not found",
action_primary=build_action_primary(verb="setup", label="Restore help", command="paperforge setup"),
ttl_seconds=TTL_HELP,
)
def probe_library(vault: Path, last_operation_exit_code: int | None = None) -> dict[str, Any]:
"""Probe the Library module using canonical sources.
When last_operation_exit_code is non-zero, the probe reports a sync failure
envelope after validating config/path, skipping index freshness checks.
"""
data, err = _load_pf_config(vault)
if data is None:
if err == "corrupt":
return build_envelope(
module="library", capability_state="unavailable", severity="error",
reason_code="library.config_corrupt",
reason_text="paperforge.json is corrupt — library sync cannot proceed",
action_primary=build_action_primary(verb="setup", label="Setup", command="paperforge setup"),
ttl_seconds=TTL_LIBRARY,
)
return build_envelope(
module="library", capability_state="missing_input", severity="warning",
reason_code="library.config_missing",
reason_text="paperforge.json not found — cannot check library configuration",
action_primary=build_action_primary(verb="set_config", label="Set config", command="paperforge setup"),
ttl_seconds=TTL_LIBRARY,
)
# ── zotero_data_dir check ──
vault_cfg = data.get("vault_config", {}) if isinstance(data.get("vault_config"), dict) else {}
zotero_dir = vault_cfg.get("zotero_data_dir") or data.get("zotero_data_dir", "")
zotero_dir = (zotero_dir or "").strip()
if not zotero_dir:
return build_envelope(
module="library", capability_state="missing_input", severity="warning",
reason_code="library.zotero_missing", reason_text="Zotero data directory not configured",
action_primary=build_action_primary(verb="set_config", label="Configure Zotero", command="paperforge setup"),
ttl_seconds=TTL_LIBRARY,
)
zotero_path = Path(zotero_dir)
if not zotero_path.is_absolute():
zotero_path = (vault / zotero_dir).resolve()
if not zotero_path.exists():
return build_envelope(
module="library", capability_state="missing_input", severity="error",
reason_code="library.zotero_not_found",
reason_text=f"Zotero data directory not found: {zotero_dir}",
action_primary=build_action_primary(verb="set_config", label="Configure Zotero", command="paperforge setup"),
ttl_seconds=TTL_LIBRARY,
)
# ── Sync failure probe: backend-owned envelope after failed manual sync ──
if last_operation_exit_code and last_operation_exit_code != 0:
return build_envelope(
module="library", capability_state="needs_action", severity="error",
reason_code="library.sync_failed",
reason_text=f"Library sync failed (exit code {last_operation_exit_code}) — re-run or check logs",
action_primary=build_action_primary(verb="sync", label="Sync library", command="paperforge sync"),
ttl_seconds=TTL_LIBRARY,
)
# ── Canonical index check via paperforge_paths ──
from paperforge.config import paperforge_paths
from paperforge.memory.db import get_memory_db_path
paths = paperforge_paths(vault)
index_path = paths.get("index") # canonical formal-library.json
if index_path is None or not index_path.exists():
return build_envelope(
module="library", capability_state="needs_action", severity="warning",
reason_code="library.index_missing", reason_text="Canonical index has not been built — run sync",
action_primary=build_action_primary(verb="sync", label="Sync library", command="paperforge sync"),
ttl_seconds=TTL_LIBRARY,
)
# ── Index freshness: canonical export hash (Issue #78) ──
# real asset_index.build_envelope writes "export_hash", not "canonical_index_hash"
notices: list[dict[str, Any]] = []
try:
from paperforge.worker.asset_index import read_index, _compute_export_hash
envelope = read_index(vault)
if envelope is not None and isinstance(envelope, dict):
stored_hash = envelope.get("export_hash", "")
current_hash = _compute_export_hash(paths)
paper_count = envelope.get("paper_count", 0)
if not stored_hash or stored_hash != current_hash:
return build_envelope(
module="library", capability_state="needs_action", severity="warning",
reason_code="library.index_stale",
reason_text=f"Library index is stale ({paper_count} papers — export files changed since last sync)",
action_primary=build_action_primary(verb="sync", label="Sync library", command="paperforge sync"),
ttl_seconds=TTL_LIBRARY,
)
# Cross-validate DB canonical_index_hash
try:
import sqlite3 as _sqlite3
items = envelope.get("items", [])
from paperforge.memory.builder import compute_hash
index_hash = compute_hash(items)
db_path = get_memory_db_path(vault)
if db_path.exists():
conn = _sqlite3.connect(str(db_path))
try:
row = conn.execute("SELECT value FROM meta WHERE key = 'canonical_index_hash'").fetchone()
db_stored = row[0] if row else ""
if db_stored and db_stored != index_hash:
notices.append({"level": "warning", "message": "DB canonical_index_hash is out of sync with index"})
except Exception:
pass
finally:
conn.close()
except Exception:
pass
# Export hash valid and index healthy → ready
return build_envelope(
module="library", capability_state="ready", severity="ok",
reason_code="library.ready",
reason_text=f"Library synced and index is fresh ({paper_count} papers)",
notices=notices, action_primary=None, ttl_seconds=TTL_LIBRARY,
)
elif envelope is not None:
# Legacy list format
return build_envelope(
module="library", capability_state="needs_action", severity="warning",
reason_code="library.index_legacy", reason_text="Canonical index is in legacy format — run sync to migrate",
action_primary=build_action_primary(verb="sync", label="Sync library", command="paperforge sync"),
ttl_seconds=TTL_LIBRARY,
)
else:
return build_envelope(
module="library", capability_state="needs_action", severity="warning",
reason_code="library.index_missing", reason_text="Canonical index is empty — run sync",
action_primary=build_action_primary(verb="sync", label="Sync library", command="paperforge sync"),
ttl_seconds=TTL_LIBRARY,
)
except Exception:
# Validation exception → never fall through to ready; report unknown
return build_envelope(
module="library", capability_state="unknown", severity="unknown",
reason_code="library.index_validation_failed",
reason_text="Library index validation failed — probe to retry",
action_primary=build_action_primary(verb="probe", label="Re-check", command="probe library"),
ttl_seconds=TTL_LIBRARY,
)
def probe_ocr(vault: Path) -> dict[str, Any]:
"""Probe the OCR module using canonical maintenance rows and env config."""
data, err = _load_pf_config(vault)
if data is None:
if err == "corrupt":
return build_envelope(
module="ocr", capability_state="unavailable", severity="error",
reason_code="ocr.config_corrupt", reason_text="paperforge.json is corrupt — OCR cannot proceed",
action_primary=build_action_primary(verb="setup", label="Setup", command="paperforge setup"),
ttl_seconds=TTL_OCR,
)
return build_envelope(
module="ocr", capability_state="missing_input", severity="warning",
reason_code="ocr.config_missing", reason_text="paperforge.json not found — cannot check OCR configuration",
action_primary=build_action_primary(verb="set_config", label="Set config", command="paperforge setup"),
ttl_seconds=TTL_OCR,
)
# ── API key / env check — canonical _resolve_paddleocr_token ──
from paperforge.worker.ocr import _resolve_paddleocr_token
token = _resolve_paddleocr_token(vault)
if not token:
return build_envelope(
module="ocr", capability_state="missing_input", severity="warning",
reason_code="ocr.api_key_missing",
reason_text="PADDLEOCR_API_TOKEN not found in environment — configure API key",
action_primary=build_action_primary(verb="set_config", label="Configure API key", command="paperforge setup"),
ttl_seconds=TTL_OCR,
)
# ── Provider reachability via ocr_doctor(config=None, live=False) ──
notices: list[dict[str, Any]] = []
provider_reachable = True
try:
from paperforge.ocr_diagnostics import ocr_doctor
diag = ocr_doctor(config=None, live=False)
provider_reachable = diag.get("passed", False)
if not provider_reachable:
notices.append({"level": "warning", "message": f"PaddleOCR API unreachable: {diag.get('error', 'unknown error')}"})
except Exception:
provider_reachable = False
notices.append({"level": "warning", "message": "PaddleOCR API diagnostics unavailable"})
# ── Canonical maintenance rows ──
try:
from paperforge.worker.ocr_maintenance import collect_maintenance_rows
rows = collect_maintenance_rows(vault)
except Exception:
return build_envelope(
module="ocr", capability_state="unknown", severity="unknown",
reason_code="ocr.probe_failed", reason_text="OCR maintenance check failed — probe to retry",
action_primary=build_action_primary(verb="probe", label="Probe", command="probe ocr"),
notices=notices, ttl_seconds=TTL_OCR,
)
if not rows:
return build_envelope(
module="ocr", capability_state="needs_action", severity="warning",
reason_code="ocr.artifacts_missing", reason_text="No OCR output found — run OCR to process papers",
action_primary=build_action_primary(verb="run", label="Run OCR", command="paperforge ocr run"),
notices=notices, ttl_seconds=TTL_OCR,
)
# ── Running/active rows → activity overlay (Issue #78 fix) ──
# Detect statuses running|processing|queued, emit activity_state='running',
# label and {current,total} progress while preserving independently computed
# availability/severity/reason/action.
# current = terminal/completed rows (done family), not queued/running/processing
TERMINAL_STATUSES = frozenset({'done', 'done_degraded'})
ACTIVE_STATUSES = frozenset({'running', 'processing', 'queued'})
running_rows = [r for r in rows if getattr(r, 'status', '') in ACTIVE_STATUSES]
completed_count = sum(1 for r in rows if getattr(r, 'status', '') in TERMINAL_STATUSES)
total = len(rows)
act_state = "running" if running_rows else "idle"
if running_rows:
act_label = f"OCR processing ({completed_count}/{total})"
act_progress = {"current": completed_count, "total": total}
else:
act_label = None
act_progress = None
# ── Determine state from maintenance rows using canonical display_action ──
# Known display_action values: retry_ocr, upgrade_legacy, rebuild_result, none, run_ocr
# derive redo only from retry_ocr|upgrade_legacy (legacy can_redo fallback only when display_action is absent)
# rebuild from rebuild_result
# unexpected actionable only when display_severity=='actionable' and action is not known
KNOWN_ACTIONS = frozenset({'retry_ocr', 'upgrade_legacy', 'rebuild_result', 'run_ocr', 'none'})
# has_failed: status=failed, health=red, or explicit redo display_action.
# Contract: display_action='rebuild_result' must never become redo even when health is red.
has_failed = any(
(r.status == "failed" or r.health == "red" or
getattr(r, 'display_action', '') in ('retry_ocr', 'upgrade_legacy'))
and getattr(r, 'display_action', '') != 'rebuild_result'
for r in rows
)
has_redo = any(
getattr(r, 'display_action', '') in ('retry_ocr', 'upgrade_legacy') or
(not hasattr(r, 'display_action') and (
getattr(r, 'recommended_action', '') == 'redo' or getattr(r, 'can_redo', False)
))
for r in rows
)
has_degraded = any(
getattr(r, 'display_action', '') == 'rebuild_result'
for r in rows
)
has_unexpected = any(
hasattr(r, 'display_severity') and r.display_severity == 'actionable'
and hasattr(r, 'display_action') and r.display_action
and r.display_action not in KNOWN_ACTIONS
for r in rows
)
# ── Priority: redo > run > rebuild_derived > investigate ──
# Failures → needs_action with redo
if has_failed:
return build_envelope(
module="ocr", capability_state="needs_action", severity="warning",
reason_code="ocr.quality_failures",
reason_text="Some OCR outputs have failed — redo required",
action_primary=build_action_primary(
verb="redo", label="Redo OCR", command="paperforge ocr redo",
destructive=True, destructive_scope="selection",
destructive_effect="Deletes existing OCR derived artifacts for failed papers and re-runs OCR from raw images. Raw images and PDFs are preserved.",
confirmation_required=True,
confirmation_prompt="This will delete existing OCR output for failed papers and re-run OCR. This cannot be undone. Proceed?",
),
activity_state=act_state, activity_label=act_label, activity_progress=act_progress,
notices=notices, ttl_seconds=TTL_OCR,
)
# Redo candidates → needs_action
if has_redo:
return build_envelope(
module="ocr", capability_state="needs_action", severity="warning",
reason_code="ocr.redo_needed",
reason_text="Some OCR outputs need redo — re-run from raw images",
action_primary=build_action_primary(
verb="redo", label="Redo OCR", command="paperforge ocr redo",
destructive=True, destructive_scope="selection",
destructive_effect="Deletes existing OCR derived artifacts for selected papers and re-runs OCR from raw images. Raw images and PDFs are preserved.",
confirmation_required=True,
confirmation_prompt="This will delete existing OCR output for selected papers and re-run OCR. This cannot be undone. Proceed?",
),
activity_state=act_state, activity_label=act_label, activity_progress=act_progress,
notices=notices, ttl_seconds=TTL_OCR,
)
# Pending rows → run action (run before rebuild/investigate)
# Exclude queued/running — those are already captured as activity
has_pending = any(
getattr(r, 'status', '') == 'pending' or getattr(r, 'display_action', '') == 'run_ocr'
for r in rows
)
if has_pending:
return build_envelope(
module="ocr", capability_state="needs_action", severity="warning",
reason_code="ocr.pending",
reason_text=f"OCR is pending for {total} papers — run to process",
action_primary=build_action_primary(
verb="run", label="Run OCR", command="paperforge ocr run",
),
activity_state=act_state, activity_label=act_label, activity_progress=act_progress,
notices=notices, ttl_seconds=TTL_OCR,
)
# Degraded → needs_action with rebuild
if has_degraded:
return build_envelope(
module="ocr", capability_state="needs_action", severity="warning",
reason_code="ocr.artifacts_stale",
reason_text="Derived OCR artifacts are degraded — rebuild to refresh",
action_primary=build_action_primary(
verb="rebuild_derived", label="Rebuild derived artifacts",
command="paperforge ocr rebuild --all",
destructive=False,
),
activity_state=act_state, activity_label=act_label, activity_progress=act_progress,
notices=notices, ttl_seconds=TTL_OCR,
)
# Unexpected actionable → investigate (lowest priority before provider)
if has_unexpected:
return build_envelope(
module="ocr", capability_state="limited", severity="warning",
reason_code="ocr.unexpected_action",
reason_text="OCR maintenance reports unexpected actions — run diagnostics",
action_primary=build_action_primary(
verb="investigate", label="Run diagnostics", command="paperforge ocr doctor",
),
activity_state=act_state, activity_label=act_label, activity_progress=act_progress,
notices=notices, ttl_seconds=TTL_OCR,
)
# Provider unreachable → limited + investigate (last resort before ready)
if not provider_reachable:
return build_envelope(
module="ocr", capability_state="limited", severity="warning",
reason_code="ocr.api_unreachable",
reason_text="PaddleOCR API is unreachable — OCR jobs may fail. Local output remains available.",
action_primary=build_action_primary(
verb="investigate", label="Run diagnostics", command="paperforge ocr doctor",
),
activity_state=act_state, activity_label=act_label, activity_progress=act_progress,
notices=notices, ttl_seconds=TTL_OCR,
)
# All good — ready only when no failures/redo/pending/degraded/unexpected and provider reachable
return build_envelope(
module="help",
capability_state="limited",
severity="warning",
reason_code="help.docs_missing",
reason_text="Packaged help source not found",
action_primary=build_action_primary(
verb="setup",
label="Restore help",
command="paperforge setup",
),
ttl_seconds=TTL_HELP,
module="ocr", capability_state="ready", severity="ok",
reason_code="ocr.ready", reason_text=f"OCR pipeline functional ({total} papers processed)",
activity_state=act_state, activity_label=act_label, activity_progress=act_progress,
notices=notices, action_primary=None, ttl_seconds=TTL_OCR,
)
def probe_memory(vault: Path) -> dict[str, Any]:
"""Probe the Memory module using canonical get_memory_status."""
try:
from paperforge.memory.query import get_memory_status
from paperforge.memory.schema import CURRENT_SCHEMA_VERSION as _CURRENT_SCHEMA
status = get_memory_status(vault)
except Exception:
return build_envelope(
module="memory", capability_state="unknown", severity="unknown",
reason_code="memory.probe_failed", reason_text="Memory status check failed — probe to retry",
action_primary=build_action_primary(verb="probe", label="Re-check", command="probe memory"),
ttl_seconds=TTL_MEMORY,
)
db_exists: bool = status.get("db_exists", False)
schema_ok: bool = status.get("schema_ok", False)
fresh: bool = status.get("fresh", False)
paper_count_db: int = status.get("paper_count_db", 0)
paper_count_index: int = status.get("paper_count_index", 0)
if not db_exists:
return build_envelope(
module="memory", capability_state="needs_action", severity="warning",
reason_code="memory.db_missing", reason_text="Memory database has not been built yet",
action_primary=build_action_primary(verb="run", label="Build memory", command="paperforge memory build"),
ttl_seconds=TTL_MEMORY,
)
# Schema check failed
if not schema_ok:
if paper_count_db > 0:
# DB has papers but old schema → needs rebuild (CLI memory only has build/status)
return build_envelope(
module="memory", capability_state="needs_action", severity="warning",
reason_code="memory.migration_needed",
reason_text="Memory database schema version is outdated — rebuild to update",
action_primary=build_action_primary(
verb="rebuild_index", label="Rebuild database", command="paperforge memory build",
destructive=False,
),
ttl_seconds=TTL_MEMORY,
)
# DB exists but empty/unreadable — rebuild or restore from backup
from paperforge.config import paperforge_paths
paths = paperforge_paths(vault)
backup_exists = (paths["paperforge"] / "indexes" / "paperforge.db.backup").exists()
notices: list[dict[str, Any]] = []
if backup_exists:
return build_envelope(
module="memory", capability_state="unavailable", severity="error",
reason_code="memory.db_corrupt",
reason_text="Memory database is corrupted — a verified backup is available for restore",
action_primary=build_action_primary(
verb="restore_backup", label="Restore from backup", command="paperforge memory restore-backup",
destructive=True, destructive_scope="module",
destructive_effect="Replaces the current corrupted database with the verified backup. The corrupted database will be preserved as a timestamped snapshot.",
confirmation_required=True,
confirmation_prompt="This will replace the memory database with the backup copy. The corrupted database will be preserved. Proceed?",
),
notices=notices, ttl_seconds=TTL_MEMORY,
)
return build_envelope(
module="memory", capability_state="unavailable", severity="error",
reason_code="memory.db_corrupt",
reason_text="Memory database is corrupted or uninitialized — rebuild required (source data preserved)",
action_primary=build_action_primary(
verb="run", label="Rebuild memory", command="paperforge memory build",
destructive=False,
),
notices=notices, ttl_seconds=TTL_MEMORY,
)
# Stale: schema OK but index hash doesn't match
if not fresh:
return build_envelope(
module="memory", capability_state="needs_action", severity="warning",
reason_code="memory.index_stale",
reason_text=f"Memory index needs rebuild (DB: {paper_count_db} papers, Index: {paper_count_index} papers)",
action_primary=build_action_primary(
verb="rebuild_index", label="Rebuild index", command="paperforge memory build",
destructive=False,
),
ttl_seconds=TTL_MEMORY,
)
# ── Vector index health via get_embed_status ──
notices: list[dict[str, Any]] = []
try:
from paperforge.embedding.status import get_embed_status
embed_status = get_embed_status(vault)
vector_healthy = embed_status.get("healthy", False) and embed_status.get("total_chunks", 0) > 0
if not vector_healthy and paper_count_db > 0:
return build_envelope(
module="memory", capability_state="needs_action", severity="warning",
reason_code="memory.index_stale",
reason_text="Vector index is missing or corrupted — rebuild to enable semantic search",
action_primary=build_action_primary(
verb="rebuild_index", label="Build vector index",
command="paperforge embed build --force",
destructive=False,
),
notices=notices, ttl_seconds=TTL_MEMORY,
)
except Exception:
# Vector check exception → never ready; report unknown
return build_envelope(
module="memory", capability_state="unknown", severity="unknown",
reason_code="memory.vector_probe_failed",
reason_text="Vector index health check failed — probe to retry",
action_primary=build_action_primary(verb="probe", label="Re-check", command="probe memory"),
notices=notices, ttl_seconds=TTL_MEMORY,
)
# DB healthy and vector index present → ready
return build_envelope(
module="memory", capability_state="ready", severity="ok",
reason_code="memory.ready",
reason_text=f"Memory database healthy ({paper_count_db} papers, {paper_count_index} indexed)",
notices=notices, action_primary=None, ttl_seconds=TTL_MEMORY,
)
@ -270,15 +665,18 @@ def probe_help(vault: Path) -> dict[str, Any]: # noqa: ARG001
# ---------------------------------------------------------------------------
def run(args: Any) -> int:
"""Dispatch a probe command and print the envelope.
Called from paperforge.cli.main().
"""
vault: Path = args.vault_path
module: str = args.probe_module
if module == "installation":
envelope = probe_installation(vault)
elif module == "library":
last_code: int | None = getattr(args, "last_operation_exit_code", None)
envelope = probe_library(vault, last_operation_exit_code=last_code)
elif module == "ocr":
envelope = probe_ocr(vault)
elif module == "memory":
envelope = probe_memory(vault)
elif module == "help":
envelope = probe_help(vault)
else:
@ -286,7 +684,6 @@ def run(args: Any) -> int:
return 1
json_output: bool = getattr(args, "json", False)
if json_output:
print(json.dumps(envelope, indent=2, ensure_ascii=False))
else:

File diff suppressed because one or more lines are too long

View file

@ -346,7 +346,16 @@ const LANG: Record<string, Record<string, string>> = {
module_detail_open_installation: "Open Installation",
module_detail_open_help: "Help & Docs",
module_detail_open_maintenance: "Maintenance",
module_detail_open_library: "Open Library",
module_detail_open_ocr: "Open OCR",
module_detail_open_memory: "Open Memory",
action_unknown_pair: "Unknown action: {verb}",
ocr_stop_batch: "Stop OCR batch",
runtime_not_available: "No Python runtime available",
md_unavailable_module: "Not available yet",
library_detail_heading: "Library Details",
ocr_detail_heading: "OCR Details",
memory_detail_heading: "Memory Details",
managed_runtime_status: "Runtime Status",
managed_runtime_install: "Install Runtime",
managed_runtime_repair: "Repair Runtime",
@ -412,6 +421,22 @@ const LANG: Record<string, Record<string, string>> = {
cc_reason_help_ready: "Help documentation is available.",
cc_reason_docs_missing: "Help documentation is not yet installed.",
cc_reason_placeholder: "Detection pending — will show live status once connected.",
cc_reason_library_ready: "Library is synced and indexed.",
cc_reason_library_config_missing: "Configuration not found — run setup to configure library.",
cc_reason_library_config_corrupt: "Configuration file is corrupt — library cannot proceed.",
cc_reason_library_zotero_missing: "Zotero data directory is not configured.",
cc_reason_library_zotero_not_found: "Zotero data directory path does not exist.",
cc_reason_library_index_missing: "Library index has not been built yet — run sync.",
cc_reason_library_index_stale: "Library index is stale — sync to refresh.",
cc_reason_ocr_ready: "OCR pipeline is configured and functional.",
cc_reason_ocr_config_missing: "Configuration not found — run setup to configure OCR.",
cc_reason_ocr_config_corrupt: "Configuration file is corrupt — OCR cannot proceed.",
cc_reason_ocr_api_key_missing: "No OCR API key configured — add one in setup.",
cc_reason_ocr_artifacts_missing: "No OCR output found — run OCR on papers.",
cc_reason_memory_ready: "Memory database is healthy and indexed.",
cc_reason_memory_db_missing: "Memory database not built — run memory build.",
cc_reason_memory_db_corrupt: "Memory database is corrupted — restore from backup.",
cc_reason_memory_index_stale: "Memory index needs rebuild to match current library.",
cc_diag_module: "Module",
cc_diag_state: "State",
cc_diag_severity: "Severity",
@ -706,7 +731,16 @@ const LANG: Record<string, Record<string, string>> = {
module_detail_open_installation: "打开安装",
module_detail_open_help: "帮助与文档",
module_detail_open_maintenance: "维护",
module_detail_open_library: "打开文献库",
module_detail_open_ocr: "打开 OCR",
module_detail_open_memory: "打开记忆",
action_unknown_pair: "未知操作: {verb}",
ocr_stop_batch: "停止 OCR 批处理",
runtime_not_available: "Python 运行时不可用",
md_unavailable_module: "暂不可用",
library_detail_heading: "文献库详情",
ocr_detail_heading: "OCR 详情",
memory_detail_heading: "记忆层详情",
managed_runtime_status: "运行时状态",
managed_runtime_install: "安装运行时",
managed_runtime_repair: "修复运行时",

View file

@ -116,6 +116,8 @@ export class PaperForgeSettingTab extends PluginSettingTab {
private _managedRuntime: ManagedRuntime | null = null;
/** True while a runtime operation is in flight. */
private _runtimeBusy: boolean = false;
/** True while a library sync or memory build is in flight. */
_libraryRunning: boolean = false;
constructor(app: App, plugin: ISettingPlugin) {
super(app, plugin as any);
@ -247,7 +249,7 @@ export class PaperForgeSettingTab extends PluginSettingTab {
const env = this._capabilityState?.[mod];
if (env && env.capability_state === "unknown" && env.updated_at === new Date(0).toISOString() && !this._attemptedProbes.has(mod)) {
this._attemptedProbes.add(mod);
if (mod === "installation" || mod === "help") {
if (mod !== "maintenance") {
this._probeModule(mod);
}
}
@ -847,6 +849,12 @@ export class PaperForgeSettingTab extends PluginSettingTab {
}
if (this._selectedDetailModule === "installation") {
this._renderInstallationDetail(containerEl);
} else if (this._selectedDetailModule === "library") {
this._renderLibraryDetail(containerEl);
} else if (this._selectedDetailModule === "ocr") {
this._renderOcrDetail(containerEl);
} else if (this._selectedDetailModule === "memory") {
this._renderMemoryDetail(containerEl);
} else {
// Fallback to installation
this._selectedDetailModule = "installation";
@ -854,6 +862,475 @@ export class PaperForgeSettingTab extends PluginSettingTab {
}
}
/** Render the Library detail view (Issue #78). */
_renderLibraryDetail(containerEl: HTMLElement): void {
this._renderModuleDetailShell(containerEl, "library");
// Library detail surface consumes the shared envelope shell — no duplicate CTA.
}
/** Render the OCR detail view (Issue #78). */
_renderOcrDetail(containerEl: HTMLElement): void {
this._renderModuleDetailShell(containerEl, "ocr");
// ── Owner controls: cooperative stop only when _ocrProcess exists ──
const isRunning = this.plugin._ocrProcess != null;
if (isRunning) {
const ctrl = containerEl.createEl("div", { cls: "pf-detail-controls" });
const stopBtn = ctrl.createEl("button", {
cls: "mod-warning",
text: t("ocr_stop_batch") || "Stop OCR batch",
});
stopBtn.addEventListener("click", () => {
const child = this.plugin._ocrProcess as unknown as {
stdin?: { write: (_: string) => boolean };
kill?: (_: string) => void;
};
if (child?.stdin?.write) {
child.stdin.write("PAPERFORGE_STOP\n");
this.plugin._ocrWasStopped = true;
} else if (child?.kill) {
child.kill("SIGINT");
}
});
const prog = this.plugin._ocrProgress;
if (prog && prog.total > 0) {
ctrl.createEl("span", {
cls: "pf-detail-progress",
text: `${prog.current}/${prog.total} papers`,
});
}
}
}
/** Render the Memory detail view (Issue #78). */
_renderMemoryDetail(containerEl: HTMLElement): void {
this._renderModuleDetailShell(containerEl, "memory");
// Memory detail surface consumes the shared envelope shell — no duplicate CTA.
}
/** Dispatch a backend action command through exact (verb, command) allowlist (Issue #78). */
_dispatchModuleAction(mod: CapabilityModule, env: ProbeEnvelope): void {
const primary = env.action?.primary;
if (!primary) {
this._probeModule(mod);
return;
}
const verb = primary.verb;
const cmd = primary.command ?? "";
// Destructive confirmation from backend envelope
if (primary.destructive && primary.confirmation_required) {
const prompt = primary.confirmation_prompt ?? "Proceed?";
if (!confirm(prompt)) return;
}
// Setup/set_config verbs → exact command allowlist
if ((verb === "setup" || verb === "set_config") && cmd === "paperforge setup") {
if (mod === "installation" || mod === "library" || mod === "ocr") {
const probeMods: CapabilityModule[] = [mod];
if (mod === "installation") {
probeMods.push("help");
}
new PaperForgeSetupModal(this.app, this.plugin, () => {
for (const m of probeMods) this._probeModule(m);
}).open();
return;
}
}
// Probe verb → exact command match, directly re-probe without Notice
if (verb === "probe" && cmd === "probe " + mod) {
this._probeModule(mod);
return;
}
// Exact (verb, command) allowlist per module
if (mod === "installation") {
// setup/set_config handled above
} else if (mod === "library") {
if (verb === "sync" && cmd === "paperforge sync") {
this._runManualSync();
return;
}
// setup/set_config handled above
} else if (mod === "ocr") {
if (verb === "run" && cmd === "paperforge ocr run") {
this._dispatchOcrAction("run");
return;
}
if (verb === "rebuild_derived" && cmd === "paperforge ocr rebuild --all") {
this._dispatchOcrAction("rebuild");
return;
}
if (verb === "redo" && cmd === "paperforge ocr redo") {
this._dispatchOcrAction("redo");
return;
}
if (verb === "investigate") {
if (cmd === "paperforge ocr doctor") {
this._callPython(["ocr", "doctor"], {
timeout: 30000,
onClose: (_code: number | null) => {
this._probeModule("ocr");
this.display();
},
});
return;
}
if (cmd === "paperforge ocr list --json") {
this._callPython(["ocr", "list", "--json"], {
timeout: 30000,
onClose: (_code: number | null) => {
this._probeModule("ocr");
this.display();
},
});
return;
}
}
// setup/set_config handled above
} else if (mod === "memory") {
if ((verb === "run" || verb === "rebuild_index") && cmd === "paperforge memory build") {
this._dispatchMemoryBuild("build");
return;
}
if (verb === "rebuild_index" && cmd === "paperforge embed build --force") {
this._dispatchMemoryBuild("embed");
return;
}
if (verb === "restore_backup" && cmd === "paperforge memory restore-backup") {
this._callPython(["memory", "restore-backup"], {
timeout: 30000,
onClose: (_code: number | null) => {
this._probeModule("memory");
this.display();
},
});
return;
}
}
// Unknown pair → Notice + re-probe
new Notice(
(t("action_unknown_pair") || "Unknown action: {verb}").replace("{verb}", verb),
5000,
);
this._probeModule(mod);
}/** Dispatch OCR action with exact CLI args, progress tracking, cooperative stop (Issue #78). */
_dispatchOcrAction(mode: "run" | "rebuild" | "redo"): void {
const vp = (this.app.vault.adapter as any).basePath as string;
const resolved = this._resolveRuntimeCommand(vp);
if (!resolved) {
new Notice(t("runtime_not_available") || "No Python runtime available");
return;
}
// Map mode to exact CLI args
const cliArgs: string[] = mode === "run"
? ["ocr", "run"]
: mode === "rebuild"
? ["ocr", "rebuild", "--all"]
: ["ocr", "redo"];
const labelMap: Record<string, string> = {
run: "Running OCR…",
rebuild: "Rebuilding OCR derived artifacts…",
redo: "Running OCR redo…",
};
// Set envelope activity overlay without changing capability/severity/reason
const envelopes = this._capabilityState ?? {};
if (envelopes["ocr"]) {
envelopes["ocr"].activity_state = "running";
envelopes["ocr"].activity_label = labelMap[mode] || "Running…";
envelopes["ocr"].activity_progress = { current: 0, total: 1 };
}
this.plugin._ocrBuffer = "";
this.plugin._ocrProgress = { current: 0, total: 1, key: "" };
this.plugin._ocrWasStopped = false;
this.display();
const child = this._callPython(
cliArgs,
{
stream: true,
onData: (data: unknown) => {
const text = typeof data === "string" ? data : Buffer.isBuffer(data) ? data.toString("utf-8") : String(data);
const { events, buffer } = processProgressChunk(text, this.plugin._ocrBuffer ?? "");
this.plugin._ocrBuffer = buffer;
for (const ev of events) {
if (ev.event === "START") {
if (this.plugin._ocrProgress) {
this.plugin._ocrProgress.total = ev.total || 1;
}
if (envelopes["ocr"]) {
envelopes["ocr"].activity_progress = { current: 0, total: ev.total || 1 };
}
} else if (ev.event === "PROGRESS") {
this.plugin._ocrProgress = { current: ev.current || 0, total: ev.total || 1, key: ev.key || "" };
if (envelopes["ocr"]) {
envelopes["ocr"].activity_progress = { current: ev.current || 0, total: ev.total || 1 };
}
}
}
this.display();
},
onError: (err: Error) => {
this.plugin._ocrProcess = null;
if (envelopes["ocr"]) {
envelopes["ocr"].activity_state = "idle";
envelopes["ocr"].activity_label = null;
envelopes["ocr"].activity_progress = null;
}
new Notice("OCR error: " + (err.message || err), 8000);
this._probeModule("ocr");
this.display();
},
onClose: (code: number | null) => {
this.plugin._ocrProcess = null;
if (envelopes["ocr"]) {
envelopes["ocr"].activity_state = "idle";
envelopes["ocr"].activity_label = null;
envelopes["ocr"].activity_progress = null;
}
if (code === 0) {
new Notice(mode === "run" ? "OCR run complete." : mode === "rebuild" ? "OCR rebuild complete." : "OCR redo complete.");
} else if (code === 130 || this.plugin._ocrWasStopped) {
this.plugin._ocrWasStopped = false;
new Notice("OCR batch stopped by user.");
} else {
new Notice("OCR operation failed with exit code " + (code ?? "?"), 8000);
}
// Terminal re-probe
this._probeModule("ocr");
this.display();
},
},
);
this.plugin._ocrProcess = child;
} /** Dispatch memory build: distinct build vs embed modes, overlay activity, terminal re-probe (Issue #78). */
_dispatchMemoryBuild(kind: "build" | "embed"): void {
const vp = (this.app.vault.adapter as any).basePath as string;
// Set activity overlay on Memory
const envelopes = this._capabilityState ?? {};
if (envelopes["memory"]) {
envelopes["memory"].activity_state = "running";
envelopes["memory"].activity_label = kind === "embed" ? "Building vector index…" : "Building memory…";
}
this.display();
const cliArgs = kind === "embed" ? ["embed", "build", "--force"] : ["memory", "build"];
const label = kind === "embed" ? "Vector index" : "Memory";
if (kind === "embed") {
// Embed build: stream progress
this.plugin._embedBuffer = "";
this.plugin._embedProgress = { current: 0, total: 0, key: "" };
const child = this._callPython(
cliArgs,
{
stream: true,
onData: (data: unknown) => {
const text = typeof data === "string" ? data : Buffer.isBuffer(data) ? data.toString("utf-8") : String(data);
const { events, buffer } = processProgressChunk(text, this.plugin._embedBuffer ?? "");
this.plugin._embedBuffer = buffer;
for (const ev of events) {
if (ev.event === "PROGRESS") {
this.plugin._embedProgress = { current: ev.current || 0, total: ev.total || 0, key: ev.key || "" };
if (envelopes["memory"]) {
envelopes["memory"].activity_progress = { current: ev.current || 0, total: ev.total || 1 };
}
}
}
this.display();
},
onError: (err: Error) => {
this.plugin._embedProcess = null;
if (envelopes["memory"]) {
envelopes["memory"].activity_state = "idle";
envelopes["memory"].activity_label = null;
envelopes["memory"].activity_progress = null;
}
new Notice(label + " build error: " + (err.message || err), 8000);
this._probeModule("memory");
this.display();
},
onClose: (code: number | null) => {
this.plugin._embedProcess = null;
if (envelopes["memory"]) {
envelopes["memory"].activity_state = "idle";
envelopes["memory"].activity_label = null;
envelopes["memory"].activity_progress = null;
}
if (code === 0) {
new Notice(label + " build complete.");
} else {
new Notice(label + " build failed with exit code " + (code ?? "?"), 8000);
}
this._probeModule("memory");
this.display();
},
},
);
this.plugin._embedProcess = child;
} else {
// Memory build: timeout-based (no streaming)
this._callPython(cliArgs, {
timeout: 120000,
onClose: (code: number | null, _stdout: string, stderr: string) => {
if (envelopes["memory"]) {
envelopes["memory"].activity_state = "idle";
envelopes["memory"].activity_label = null;
}
if (code === 0) {
new Notice(label + " rebuild complete");
} else {
new Notice(
label + " build failed" + (stderr ? ": " + stderr.slice(0, 120) : ""),
8000,
);
}
this._probeModule("memory");
this.display();
},
});
}
}/** Shared module detail shell for Library, OCR, and Memory (Issue #78). */
_renderModuleDetailShell(containerEl: HTMLElement, mod: CapabilityModule): void {
const headingKey = mod + "_detail_heading";
const headingId = "pf-" + mod + "-detail-heading";
// ── Back button ──
const backBtn = containerEl.createEl("button", {
cls: "pf-back-btn",
text: t("btn_back_to_overview"),
});
backBtn.addEventListener("click", () => {
this.activeTab = "overview";
this._selectedDetailModule = "";
this._focusTargetId = "button.pf-open-module-btn[data-module=" + mod + "]";
this.display();
});
// ── Heading ──
const heading = containerEl.createEl("h2", {
cls: "pf-module-detail-heading",
text: t(headingKey) || t("cc_module_" + mod),
attr: { id: headingId, tabindex: "-1" },
});
// ── Module detail selector (all implemented modules) ──
const detailModules: { id: string; labelKey: string }[] = [
{ id: "installation", labelKey: "md_select_installation" },
{ id: "library", labelKey: "md_select_library" },
{ id: "ocr", labelKey: "md_select_ocr" },
{ id: "memory", labelKey: "md_select_memory" },
];
const selector = containerEl.createEl("div", { cls: "pf-module-detail-selector" });
for (const dm of detailModules) {
const btn = selector.createEl("button", {
cls: "pf-module-detail-btn"
+ (dm.id === mod ? " pf-module-detail-btn--active" : ""),
text: t(dm.labelKey),
});
btn.addEventListener("click", () => {
this._selectedDetailModule = dm.id;
this._focusTargetId = dm.id === "installation"
? "#pf-installation-detail-heading"
: "#pf-" + dm.id + "-detail-heading";
this.display();
});
}
// ── Backend envelope summary card ──
const envelopes: Record<string, ProbeEnvelope> = this._capabilityState ?? {};
const env: ProbeEnvelope = envelopes[mod] ?? createUnknownEnvelope(mod);
const sevClass: string = this._sevClass(env.severity);
const isReady: boolean = isReadyEnvelope(env);
const summaryRow = containerEl.createEl("div", { cls: "pf-cc-card pf-module-detail-card" });
const summaryHeader = summaryRow.createEl("div", { cls: "pf-cc-card-header" });
summaryHeader.createEl("span", { cls: "pf-cc-card-name", text: t("cc_module_" + mod) });
summaryHeader.createEl("span", {
cls: "pf-cc-card-badge pf-cc-card-badge--" + sevClass,
text: t(this._ccBadgeKey(env, mod)),
});
// Activity label
if (env.activity_state === "running" && env.activity_label) {
const activityRow = summaryRow.createEl("div", { cls: "pf-cc-card-activity", attr: { "aria-live": "polite" } });
activityRow.createEl("span", { text: env.activity_label });
if (env.activity_progress && env.activity_progress.total > 0) {
const pct = Math.round((env.activity_progress.current / env.activity_progress.total) * 100);
const bar = activityRow.createEl("div", { cls: "pf-cc-card-progress", attr: { role: "progressbar", "aria-valuenow": String(env.activity_progress.current), "aria-valuemin": "0", "aria-valuemax": String(env.activity_progress.total) } });
const fill = bar.createEl("div", { cls: "pf-cc-card-progress-fill" });
fill.style.width = pct + "%";
}
}
const l10nReason = this._localizeReason(env.reason.code, mod);
summaryRow.createEl("div", { cls: "pf-cc-card-reason", text: l10nReason ?? env.reason.text });
// Destructive metadata before action
const primary = env.action?.primary;
if (primary && !isReady) {
if (primary.destructive && primary.confirmation_required) {
const destructiveRow = summaryRow.createEl("div", { cls: "pf-destructive-notice" });
destructiveRow.createEl("span", { text: primary.destructive_effect ?? "" });
}
// Action button — disabled while this module's activity is running
const isModuleRunning = env.activity_state === "running";
const action = classifyCapabilityAction(env);
const actionBtn = summaryRow.createEl("button", {
cls: "pf-cc-card-action pf-cc-card-action--primary",
text: action.label,
});
if (isModuleRunning) {
actionBtn.setAttr("disabled", "disabled");
}
actionBtn.addEventListener("click", () => {
if (isModuleRunning) return;
this._dispatchModuleAction(mod, env);
});
}
// Timestamp and TTL
const metaRow = summaryRow.createEl("div", { cls: "pf-meta" });
let dateLabel: string;
try { dateLabel = new Date(env.updated_at).toLocaleString(); } catch { dateLabel = env.updated_at; }
metaRow.createEl("span", { text: t("cc_diag_updated") + ": " + dateLabel + " | TTL: " + String(env.ttl_seconds) + "s" });
// Notices
if (env.notices && env.notices.length > 0) {
for (const notice of env.notices) {
containerEl.createEl("div", {
cls: "pf-notice pf-notice--" + (notice.level || "info"),
text: notice.message,
});
}
}
// Diagnostics disclosure
const details = summaryRow.createEl("details", { cls: "pf-cc-card-diagnostic" });
details.createEl("summary", { text: t("cc_diagnostic_toggle") });
const body = details.createEl("div", { cls: "pf-cc-card-diagnostic-body" });
const stateLabel = t("cc_state_" + env.capability_state) || env.capability_state;
const sevLabel = t("cc_severity_" + env.severity) || env.severity;
const activityLabel = t("cc_activity_" + env.activity_state) || env.activity_state;
body.createEl("div", { text: t("cc_diag_module") + ": " + env.module });
body.createEl("div", { text: t("cc_diag_state") + ": " + stateLabel });
body.createEl("div", { text: t("cc_diag_severity") + ": " + sevLabel });
body.createEl("div", { text: t("cc_diag_activity") + ": " + activityLabel });
const reasonRow = body.createEl("div");
reasonRow.appendText(t("cc_diag_reason") + ": " + (l10nReason ?? env.reason.text) + " ");
reasonRow.createEl("code", { text: env.reason.code });
// Focus heading on render
try {
heading.focus();
} catch {
// ignore focus failure
}
}
/** Render the Help tab (top-level destination with docs + release notes). */
_renderHelpTab(containerEl: HTMLElement): void {
const envelopes: Record<string, ProbeEnvelope> = this._capabilityState ?? {};
@ -1119,6 +1596,13 @@ export class PaperForgeSettingTab extends PluginSettingTab {
const py = getCachedPython(vp, this.plugin.settings);
if (!py.path) return;
// Overlay envelope activity
const envelopes = this._capabilityState ?? {};
if (envelopes["library"]) {
envelopes["library"].activity_state = "running";
envelopes["library"].activity_label = "Syncing library…";
}
const statusRow = document.querySelector(".paperforge-memory-status");
if (statusRow) {
this._renderMemoryStatusText(
@ -1129,15 +1613,25 @@ export class PaperForgeSettingTab extends PluginSettingTab {
}
this.plugin._autoSyncRunning = true;
this._libraryRunning = true;
this.display();
this._callPython(["sync"], {
timeout: 120000,
onClose: (code: number | null) => {
this.plugin._autoSyncRunning = false;
this._libraryRunning = false;
this._memoryStatusText = null;
// Clear activity overlay
if (envelopes["library"]) {
envelopes["library"].activity_state = "idle";
envelopes["library"].activity_label = null;
}
if (code === 0) {
this._lastSyncTime = new Date().toLocaleTimeString();
this.plugin._lastSyncTime = this._lastSyncTime;
}
// Re-probe library on every terminal outcome — pass exit code for sync failure detection
this._probeModule("library", code ?? 1);
this.display();
this._refreshSnapshots(vp);
checkOrphanState(this.app, this.plugin, vp);
@ -2832,7 +3326,7 @@ export class PaperForgeSettingTab extends PluginSettingTab {
}
/** Call `paperforge probe <module> --json` and store the validated envelope unchanged. */
_probeModule(mod: CapabilityModule): void {
_probeModule(mod: CapabilityModule, lastOperationExitCode?: number): void {
if (this._probing.has(mod)) return;
this._probing.add(mod);
@ -2895,6 +3389,9 @@ export class PaperForgeSettingTab extends PluginSettingTab {
mod,
"--json",
];
if (mod === "library" && lastOperationExitCode != null && lastOperationExitCode !== 0) {
args.push("--last-operation-exit-code", String(lastOperationExitCode));
}
execFile(
resolved.path,
@ -2974,9 +3471,9 @@ export class PaperForgeSettingTab extends PluginSettingTab {
}
/** Modules with real Python probe support. */
private static _REAL_PROBE = new Set(["installation", "help"]);
private static _REAL_PROBE = new Set(["installation", "library", "ocr", "memory", "help"]);
/** Modules that have a navigation entry in the overview card grid. */
private static _NAVIGABLE = new Set(["installation", "maintenance", "help"]);
private static _NAVIGABLE = new Set(["installation", "library", "ocr", "memory", "maintenance", "help"]);
_renderCard(container: HTMLElement, mod: CapabilityModule, envelope: ProbeEnvelope): void {
const env = envelope;
@ -2995,11 +3492,17 @@ export class PaperForgeSettingTab extends PluginSettingTab {
// Navigation entry — Enter/Space or click opens module detail
const openLabel = mod === "installation"
? t("module_detail_open_installation")
: mod === "help"
? t("module_detail_open_help")
: mod === "maintenance"
? t("module_detail_open_maintenance")
: t("md_select_installation");
: mod === "library"
? t("module_detail_open_library")
: mod === "ocr"
? t("module_detail_open_ocr")
: mod === "memory"
? t("module_detail_open_memory")
: mod === "help"
? t("module_detail_open_help")
: mod === "maintenance"
? t("module_detail_open_maintenance")
: t("md_select_installation");
const navBtn = nameArea.createEl("button", {
cls: "pf-open-module-btn",
text: t("cc_module_" + mod),
@ -3063,7 +3566,7 @@ export class PaperForgeSettingTab extends PluginSettingTab {
this._probeModule("help");
}).open();
} else {
this._probeModule(mod);
this._dispatchModuleAction(mod, env);
}
});
}
@ -3104,6 +3607,18 @@ export class PaperForgeSettingTab extends PluginSettingTab {
this.activeTab = "module-detail";
this._selectedDetailModule = "installation";
this._focusTargetId = "#pf-installation-detail-heading";
} else if (mod === "library") {
this.activeTab = "module-detail";
this._selectedDetailModule = "library";
this._focusTargetId = "#pf-library-detail-heading";
} else if (mod === "ocr") {
this.activeTab = "module-detail";
this._selectedDetailModule = "ocr";
this._focusTargetId = "#pf-ocr-detail-heading";
} else if (mod === "memory") {
this.activeTab = "module-detail";
this._selectedDetailModule = "memory";
this._focusTargetId = "#pf-memory-detail-heading";
} else if (mod === "help") {
this.activeTab = "help";
this._selectedDetailModule = "";

View file

@ -4766,12 +4766,55 @@
}
/* ── Installation Detail ── */
.pf-installation-detail-heading {
.pf-installation-detail-heading,
.pf-library-detail-heading,
.pf-ocr-detail-heading,
.pf-memory-detail-heading {
font-size: 16px;
font-weight: 600;
margin: 0 0 12px 0;
}
.pf-notice {
padding: 6px 10px;
border-radius: var(--radius-s);
border: 1px solid var(--background-modifier-border);
margin-bottom: 8px;
color: var(--text-muted);
}
.pf-notice--info {
background: color-mix(in srgb, var(--text-accent) 8%, transparent);
}
.pf-notice--warning {
background: color-mix(in srgb, var(--text-warning) 8%, transparent);
border-color: var(--text-warning);
color: var(--text-warning);
}
.pf-notice--error {
background: color-mix(in srgb, var(--text-error) 8%, transparent);
border-color: var(--text-error);
color: var(--text-error);
}
/* ── Module Detail Controls ── */
.pf-detail-controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-top: 12px;
padding: 10px 0;
}
.pf-detail-controls .mod-cta {
font-weight: 500;
}
.pf-detail-status {
font-size: 12px;
color: var(--text-muted);
margin-top: 6px;
width: 100%;
}
.pf-runtime-status-card {
background: var(--background-primary);
border: 1px solid var(--background-modifier-border);
@ -4877,3 +4920,24 @@
margin-top: 2px;
font-style: italic;
}
/* ── Module Detail heading responsive wrap ── */
.pf-module-detail-heading {
font-size: 18px;
font-weight: 600;
line-height: 1.35;
margin: 0 0 12px 0;
word-break: break-word;
overflow-wrap: break-word;
max-width: 100%;
}
.pf-module-detail-card {
margin-bottom: 12px;
}
.pf-detail-progress {
margin-left: 8px;
font-size: 13px;
}

View file

@ -1914,7 +1914,7 @@ describe("Issue #77 RED 1: Maintenance navigation entry", () => {
for (const mod of ["library", "ocr", "memory"] as CapabilityModule[]) {
const container = document.createElement("div");
tab._renderCard(container, mod, createUnknownEnvelope(mod));
expect(container.querySelector("button.pf-open-module-btn")).toBeFalsy();
expect(container.querySelector("button.pf-open-module-btn")).toBeTruthy();
}
});

View file

@ -0,0 +1,782 @@
/**
* Issue #78 Library, OCR, and Memory module detail end-to-end tests.
*
* Uses JSDOM + Vitest mocks. Instantiates PaperForgeSettingTab, calls production
* render functions, clicks production buttons. NO standalone DOM lookalikes.
*/
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { JSDOM } from "jsdom";
// ── Hoisted mutable state ──
const { noticeCalls, spawnedProcesses, execFileCalls } = vi.hoisted(() => {
const noticeCalls: string[] = [];
const spawnedProcesses: Array<{
args: string[];
onData?: (data: unknown) => void;
onError?: (err: Error) => void;
onClose?: (code: number | null) => void;
}> = [];
const execFileCalls: Array<{ args: string[]; cb?: (err: Error | null, stdout: string, stderr: string) => void }> = [];
return { noticeCalls, spawnedProcesses, execFileCalls };
});
// ── Mocks ──
vi.mock("../src/release-notes.json", () => ({ default: { versions: [] } }));
vi.mock("obsidian", () => {
return {
PluginSettingTab: class {
containerEl: HTMLDivElement;
app: Record<string, unknown>;
constructor(app: Record<string, unknown>, _plugin: Record<string, unknown>) {
this.app = app;
this.containerEl = document.createElement("div");
}
},
App: class {},
Setting: class {
settingEl: HTMLDivElement;
descEl: HTMLDivElement & { setText?: (t: string) => void };
nameEl: HTMLDivElement;
controlEl: HTMLDivElement;
constructor(containerEl: HTMLElement) {
this.settingEl = document.createElement("div");
this.settingEl.className = "setting-item";
this.nameEl = document.createElement("div");
this.nameEl.className = "setting-item-name";
this.descEl = Object.assign(document.createElement("div"), {
className: "setting-item-description",
setText: (t: string) => { this.descEl.textContent = t; },
});
this.controlEl = document.createElement("div");
this.controlEl.className = "setting-item-control";
this.settingEl.appendChild(this.nameEl);
this.settingEl.appendChild(this.descEl);
this.settingEl.appendChild(this.controlEl);
containerEl.appendChild(this.settingEl);
}
setName(text: string) { this.nameEl.textContent = text; return this; }
setDesc(text: string) { this.descEl.textContent = text; return this; }
addText(cb: (text: Record<string, unknown>) => void) { return this; }
addToggle(cb: (toggle: Record<string, unknown>) => void) { return this; }
addDropdown(cb: (dropdown: Record<string, unknown>) => void) {
const select = document.createElement("select");
this.controlEl.appendChild(select);
const dropdown = {
addOption: () => {},
setValue: function () { return this; },
onChange: function () { return this; },
};
cb(dropdown);
return this;
}
addButton(cb: (button: Record<string, unknown>) => void) { return this; }
addExtraButton(cb: (btn: Record<string, unknown>) => void) { return this; }
},
Modal: class {
app: Record<string, unknown>;
contentEl: HTMLDivElement;
constructor(app: Record<string, unknown>) { this.app = app; this.contentEl = document.createElement("div"); }
open() {}
close() {}
},
Notice: class {
constructor(msg: string, timeout?: number) {
noticeCalls.push({ msg, timeout });
}
},
setTooltip: () => {},
Platform: {},
};
});
// Mock node built-ins used by settings.ts
vi.mock("fs", () => ({ default: {}, existsSync: () => false, readFileSync: () => "{}", writeFileSync: () => {}, readdirSync: () => [], statSync: () => ({}), accessSync: () => {}, constants: { X_OK: 1 } }));
vi.mock("path", () => ({ default: {}, join: (...args: string[]) => args.join("/"), dirname: (p: string) => p.split("/").slice(0, -1).join("/"), resolve: (...args: string[]) => args.join("/") }));
vi.mock("os", () => ({ default: {}, homedir: () => "/home/user", platform: () => "win32" }));
vi.mock("child_process", () => {
const mod = {
execFile: (_path: string, args: string[], _opts: Record<string, unknown>, cb?: (err: Error | null, stdout: string, stderr: string) => void) => {
execFileCalls.push({ args: [...args], cb });
// Defer callback so tests can inspect state before terminal cleanup
if (cb) setTimeout(() => cb(null, "{}", ""), 0);
},
execFileSync: () => "Python 3.11.0",
exec: () => {},
spawn: (_path: string, args: string[], _opts: Record<string, unknown>) => {
const self = {
args: [...args],
stdout: { on: (_ev: string, cb: (data: unknown) => void) => { self.onData = cb; } },
stderr: { on: () => {} },
stdin: { write: (_s: string) => true },
kill: (_sig: string) => {},
onData: undefined as ((data: unknown) => void) | undefined,
onError: undefined as ((err: Error) => void) | undefined,
onClose: undefined as ((code: number | null) => void) | undefined,
};
spawnedProcesses.push(self);
return {
args: [...args],
stdout: { on: (_ev: string, cb: (data: unknown) => void) => { self.onData = cb; } },
stderr: { on: () => {} },
stdin: { write: (_s: string) => true },
kill: (_sig: string) => {},
on: (ev: string, cb: (arg: unknown) => void) => {
if (ev === "error") self.onError = cb as (err: Error) => void;
if (ev === "close") self.onClose = cb as (code: number | null) => void;
},
};
},
};
return { ...mod, default: mod };
});
vi.mock("../src/services/python-bridge", () => ({
resolvePythonExecutable: () => ({ path: "/usr/bin/python3", extraArgs: [] }),
buildRuntimeInstallCommand: () => "pip install",
paperforgeEnrichedEnv: () => ({}),
scanBbtUnderProfiles: () => [],
scanBbtDirectChildren: () => [],
runSubprocess: () => {},
}));
vi.mock("../src/services/memory-state", () => ({
resolveVaultPaths: () => ({}),
getMemoryRuntime: () => ({}),
getVectorRuntime: () => ({}),
getRuntimeHealth: () => ({}),
isMemoryReady: () => false,
isVectorReady: () => false,
getMemoryStatusText: () => "",
getVectorStatusText: () => "",
getCachedPython: () => ({ path: "/usr/bin/python3", extraArgs: [] }),
}));
vi.mock("../src/services/ocr-maintenance-ui", () => ({
categorizeMaintenanceRow: () => [],
buildMaintenanceSummary: () => ({ items: [], summary: "" }),
maintenanceActionForRow: () => null,
maintenanceActionRequiresConfirmation: () => false,
readMaintenanceCache: () => ({ items: [], updated_at: "" }),
refreshMaintenanceData: () => Promise.resolve({ data: [] }),
}));
vi.mock("../src/services/managed-runtime", () => ({
ManagedRuntime: class {},
runtimeActionsForHealth: () => [],
resolveRuntimeCommand: (run: unknown) => ({ command: "/usr/bin/python3", args: [] }),
}));
vi.mock("../src/utils/disclosure", () => ({
getDisclosureState: () => false,
toggleDisclosureState: () => {},
}));
vi.mock("../src/services/progress-parser", () => ({
processProgressChunk: (chunk: string, _buffer: string) => {
const events: Array<{ event: string; current?: number; total?: number; key?: string }> = [];
for (const line of chunk.split("\n")) {
const m = line.match(/^(\S+)\s+START\s+(\d+)/);
if (m) { events.push({ event: "START", total: parseInt(m[2]) }); continue; }
const m2 = line.match(/^(\S+)\s+PROGRESS\s+(\d+)\s+(\d+)(?:\s+(\S+))?/);
if (m2) { events.push({ event: "PROGRESS", current: parseInt(m2[2]), total: parseInt(m2[3]), key: m2[4] || "" }); continue; }
}
return { events, buffer: "" };
},
}));
import { createUnknownEnvelope, ProbeEnvelope } from "../src/constants";
import { PaperForgeSettingTab } from "../src/settings";
import { setLanguage } from "../src/i18n";
// ── Helpers ──
function fakeApp() {
return {
vault: { adapter: { basePath: "/vault" }, getConfig: () => "en" },
workspace: { getLeavesOfType: () => [], onLayoutReady: (cb: () => void) => cb?.() },
};
}
function fakePlugin(overrides: Record<string, unknown> = {}) {
return {
app: fakeApp(),
manifest: { id: "paperforge", version: "2.0.0" },
settings: {} as Record<string, unknown>,
saveSettings: vi.fn(),
loadSettings: vi.fn(),
readPaperforgeJson: () => ({}),
_ocrProcess: null as unknown,
_ocrProgress: null as { current: number; total: number; key: string } | null,
_ocrBuffer: "",
_ocrWasStopped: false,
_embedProcess: null as unknown,
_embedProgress: { current: 0, total: 0, key: "" },
_embedBuffer: "",
_autoSyncRunning: false,
_lastSyncTime: "",
...overrides,
};
}
function makeTab(data: Record<string, unknown> = {}) {
const tab = new PaperForgeSettingTab(fakeApp() as any, fakePlugin(data));
// wire managed runtime mock
const rt = { current: () => ({ path: "/usr/bin/python3", version: "3.11", state: "ready" }), status: () => Promise.resolve({ state: "ready" }) };
(tab as any)._ensureManagedRuntime = () => rt;
(tab as any)._resolveRuntimeCommand = () => ({ path: "/usr/bin/python3", args: [] });
(tab as any)._capabilityState = data.capabilityState || {};
(tab as any)._selectedDetailModule = "";
(tab as any)._probing = new Set<string>();
// Override display() to avoid triggering full render chain in tests
(tab as any).display = () => {};
return tab;
}
let dom: JSDOM;
// ── Obsidian DOM polyfills ──
function polyfillObsidianDom(doc: Document) {
// Polyfill JSDOM's own HTMLElement, not the global one
const Win = doc.defaultView;
if (!Win) return;
const HTMLEl = (Win as any).HTMLElement;
if (!HTMLEl) return;
const ht = HTMLEl.prototype;
const origCreate = doc.createElement.bind(doc);
if (!ht.createEl) {
ht.createEl = function(tag: string, opts?: { cls?: string; text?: string; attr?: Record<string, string>; title?: string }, cb?: (el: HTMLElement) => void) {
const el = origCreate(tag);
if (opts?.cls) el.className = opts.cls;
if (opts?.text) el.textContent = opts.text;
if (opts?.attr) { for (const [k, v] of Object.entries(opts.attr)) { el.setAttribute(k, v); } }
if (opts?.title) el.title = opts.title;
this.appendChild(el);
if (cb) cb(el);
return el;
};
ht.createDiv = function(opts?: { cls?: string; text?: string; attr?: Record<string, string> }, cb?: (el: HTMLElement) => void) {
return (this as any).createEl("div", opts, cb);
};
ht.createSpan = function(opts?: { cls?: string; text?: string; attr?: Record<string, string> }, cb?: (el: HTMLElement) => void) {
return (this as any).createEl("span", opts, cb);
};
ht.empty = function() { while (this.firstChild) this.removeChild(this.firstChild); };
ht.setText = function(text: string) { this.textContent = text; };
ht.setAttr = function(name: string, value: string) { this.setAttribute(name, value); };
ht.appendText = function(text: string) { this.appendChild(doc.createTextNode(text)); };
}
}
beforeEach(() => {
dom = new JSDOM("<!DOCTYPE html><html><body></body></html>", { url: "http://localhost", pretendToBeVisual: true });
polyfillObsidianDom(dom.window.document);
(globalThis as any).window = dom.window;
(globalThis as any).document = dom.window.document;
(globalThis as any).confirm = () => true;
noticeCalls.length = 0;
spawnedProcesses.length = 0;
execFileCalls.length = 0;
setLanguage(fakeApp() as any);
});
afterEach(() => { dom.window.close(); });
// ════════════════════════════════ 1. Library Detail ════════════════════
describe("Library module detail (Issue #78)", () => {
it("renders envelope shell with heading, no duplicate CTA", () => {
const tab = makeTab();
const el = dom.window.document.createElement("div");
(tab as any)._renderLibraryDetail(el);
expect(el.querySelector(".pf-module-detail-heading")).not.toBeNull();
expect(el.querySelector(".mod-cta")).toBeNull();
});
it("renders reason, timestamp, TTL, notices, diagnostics", () => {
const env = {
...createUnknownEnvelope("library"),
capability_state: "needs_action",
severity: "warning",
reason: { code: "library.index_stale", text: "Index is stale" },
updated_at: "2026-01-15T12:00:00Z",
ttl_seconds: 300,
notices: [{ level: "warning", message: "Test notice" }],
action: { primary: { verb: "sync", label: "Sync", command: "paperforge sync", destructive: false, destructive_scope: null, destructive_effect: null, confirmation_required: false, confirmation_prompt: null, scope: "module", scope_count: 1 } },
} as any;
const tab = makeTab({ capabilityState: { library: env } });
const el = dom.window.document.createElement("div");
(tab as any)._renderLibraryDetail(el);
expect(el.textContent).toContain("stale");
expect(el.textContent).toContain("300s");
expect(el.textContent).toContain("Test notice");
expect(el.querySelector(".pf-cc-card-diagnostic")).not.toBeNull();
});
it("primary action click overlays envelope running", () => {
const tab = makeTab();
(tab as any)._capabilityState = {
library: {
...createUnknownEnvelope("library"),
capability_state: "needs_action",
severity: "warning",
reason: { code: "library.index_stale", text: "stale" },
action: { primary: { verb: "sync", label: "Sync", command: "paperforge sync", destructive: false, destructive_scope: null, destructive_effect: null, confirmation_required: false, confirmation_prompt: null, scope: "module", scope_count: 1 } },
},
};
const el = dom.window.document.createElement("div");
(tab as any)._renderLibraryDetail(el);
(el.querySelector(".pf-cc-card-action") as HTMLButtonElement)?.click();
const envelopes = (tab as any)._capabilityState as any;
expect(envelopes?.library?.activity_state).toBe("running");
expect(envelopes?.library?.activity_label).toContain("Syncing");
});
});
// ════════════════════════════════ 2. OCR Detail ════════════════════════
describe("OCR module detail (Issue #78)", () => {
it("renders stop only when _ocrProcess exists", () => {
const tab = makeTab({
_ocrProcess: { stdin: { write: () => true }, kill: () => {} },
_ocrProgress: { current: 3, total: 10, key: "TEST" },
});
const ocrEnv = {
...createUnknownEnvelope("ocr"),
capability_state: "needs_action",
severity: "warning",
reason: { code: "ocr.artifacts_stale", text: "Stale" },
action: { primary: { verb: "rebuild_derived", label: "Rebuild", command: "paperforge ocr rebuild --all", destructive: false, destructive_scope: null, destructive_effect: null, confirmation_required: false, confirmation_prompt: null, scope: "module", scope_count: 1 } },
} as any;
(tab as any)._capabilityState = { ocr: ocrEnv };
const el = dom.window.document.createElement("div");
(tab as any)._renderOcrDetail(el);
expect(el.querySelector(".mod-warning")).not.toBeNull();
expect(el.textContent).toContain("3/10");
const tab2 = makeTab({ _ocrProcess: null });
(tab2 as any)._capabilityState = { ocr: ocrEnv };
const el2 = dom.window.document.createElement("div");
(tab2 as any)._renderOcrDetail(el2);
expect(el2.querySelector(".mod-warning")).toBeNull();
});
it("stop sends PAPERFORGE_STOP\n via stdin", () => {
let written = "";
const tab = makeTab({
_ocrProcess: { stdin: { write: (s: string) => { written = s; return true; } }, kill: () => {} },
});
(tab as any)._capabilityState = {
ocr: { ...createUnknownEnvelope("ocr"), capability_state: "needs_action", severity: "warning", reason: { code: "x", text: "x" }, action: { primary: { verb: "rebuild_derived", label: "Rebuild", command: "paperforge ocr rebuild --all", destructive: false, destructive_scope: null, destructive_effect: null, confirmation_required: false, confirmation_prompt: null, scope: "module", scope_count: 1 } } },
};
const el = dom.window.document.createElement("div");
(tab as any)._renderOcrDetail(el);
(el.querySelector(".mod-warning") as HTMLButtonElement)?.click();
expect(written).toBe("PAPERFORGE_STOP\n");
expect((tab.plugin as any)._ocrWasStopped).toBe(true);
});
it("stop falls back to SIGINT when stdin unavailable", () => {
let killed = "";
const tab = makeTab({ _ocrProcess: { kill: (sig: string) => { killed = sig; } } });
(tab as any)._capabilityState = {
ocr: { ...createUnknownEnvelope("ocr"), capability_state: "needs_action", severity: "warning", reason: { code: "x", text: "x" }, action: { primary: { verb: "rebuild_derived", label: "Rebuild", command: "paperforge ocr rebuild --all", destructive: false, destructive_scope: null, destructive_effect: null, confirmation_required: false, confirmation_prompt: null, scope: "module", scope_count: 1 } } },
};
const el = dom.window.document.createElement("div");
(tab as any)._renderOcrDetail(el);
(el.querySelector(".mod-warning") as HTMLButtonElement)?.click();
expect(killed).toBe("SIGINT");
});
});
// ════════════════════════════════ 3. Memory Detail ════════════════════
describe("Memory module detail (Issue #78)", () => {
it("renders envelope shell without duplicate CTA", () => {
const tab = makeTab();
(tab as any)._capabilityState = {
memory: { ...createUnknownEnvelope("memory"), capability_state: "needs_action", severity: "warning", reason: { code: "x", text: "x" }, action: { primary: { verb: "run", label: "Build", command: "paperforge memory build", destructive: false, destructive_scope: null, destructive_effect: null, confirmation_required: false, confirmation_prompt: null, scope: "module", scope_count: 1 } } },
};
const el = dom.window.document.createElement("div");
(tab as any)._renderMemoryDetail(el);
expect(el.querySelector(".pf-module-detail-heading")).not.toBeNull();
expect(el.querySelector(".mod-cta")).toBeNull();
});
});
// ════════════════════════════════ 4. Dispatch allowlist ════════════════
describe("_dispatchModuleAction allowlist (Issue #78)", () => {
it("unknown pair -> Notice + re-probe", () => {
const tab = makeTab();
(tab as any)._capabilityState = { library: createUnknownEnvelope("library") };
(tab as any)._probing = new Set<string>();
const env = { ...createUnknownEnvelope("library"), action: { primary: { verb: "bogus", label: "X", command: "x", destructive: false, destructive_scope: null, destructive_effect: null, confirmation_required: false, confirmation_prompt: null, scope: "module", scope_count: 1 } } } as any;
noticeCalls.length = 0;
(tab as any)._dispatchModuleAction("library", env);
// Should emit a Notice with "Unknown"
const msgs = noticeCalls.map((c: { msg: string }) => c.msg).join(" ");
expect(msgs.length).toBeGreaterThan(0);
expect(msgs.toLowerCase()).toMatch(/unknown|bogus/);
});
it("run + paperforge ocr run -> spawns ['ocr', 'run']", () => {
const tab = makeTab();
(tab as any)._capabilityState = { ocr: createUnknownEnvelope("ocr") };
const env = { ...createUnknownEnvelope("ocr"), action: { primary: { verb: "run", label: "Run", command: "paperforge ocr run", destructive: false, destructive_scope: null, destructive_effect: null, confirmation_required: false, confirmation_prompt: null, scope: "module", scope_count: 1 } } } as any;
(tab as any)._dispatchModuleAction("ocr", env);
const last = spawnedProcesses[spawnedProcesses.length - 1];
expect(last.args).toContain("run");
expect(last.args).not.toContain("rebuild");
});
it("rebuild_derived -> spawns rebuild --all", () => {
const tab = makeTab();
(tab as any)._capabilityState = { ocr: createUnknownEnvelope("ocr") };
const env = { ...createUnknownEnvelope("ocr"), action: { primary: { verb: "rebuild_derived", label: "Rebuild", command: "paperforge ocr rebuild --all", destructive: false, destructive_scope: null, destructive_effect: null, confirmation_required: false, confirmation_prompt: null, scope: "module", scope_count: 1 } } } as any;
(tab as any)._dispatchModuleAction("ocr", env);
const last = spawnedProcesses[spawnedProcesses.length - 1];
expect(last.args).toContain("rebuild");
expect(last.args).toContain("--all");
});
it("redo -> spawns redo args", () => {
const tab = makeTab();
(tab as any)._capabilityState = { ocr: createUnknownEnvelope("ocr") };
const env = { ...createUnknownEnvelope("ocr"), action: { primary: { verb: "redo", label: "Redo", command: "paperforge ocr redo", destructive: true, destructive_scope: "selection", destructive_effect: "Deletes.", confirmation_required: true, confirmation_prompt: "Proceed?", scope: "module", scope_count: 1 } } } as any;
(globalThis as any).confirm = () => true;
(tab as any)._dispatchModuleAction("ocr", env);
expect(spawnedProcesses[spawnedProcesses.length - 1].args).toContain("redo");
});
it("memory build -> uses execFile", () => {
const tab = makeTab();
(tab as any)._capabilityState = { memory: createUnknownEnvelope("memory") };
const env = { ...createUnknownEnvelope("memory"), action: { primary: { verb: "run", label: "Build", command: "paperforge memory build", destructive: false, destructive_scope: null, destructive_effect: null, confirmation_required: false, confirmation_prompt: null, scope: "module", scope_count: 1 } } } as any;
execFileCalls.length = 0;
(tab as any)._dispatchModuleAction("memory", env);
expect(execFileCalls.some((c: { args: string[] }) => c.args.includes("build"))).toBe(true);
});
it("embed build --force -> spawns embed", () => {
const tab = makeTab();
(tab as any)._capabilityState = { memory: createUnknownEnvelope("memory") };
const env = { ...createUnknownEnvelope("memory"), action: { primary: { verb: "rebuild_index", label: "Embed", command: "paperforge embed build --force", destructive: false, destructive_scope: null, destructive_effect: null, confirmation_required: false, confirmation_prompt: null, scope: "module", scope_count: 1 } } } as any;
(tab as any)._dispatchModuleAction("memory", env);
const es = spawnedProcesses.find((p: { args: string[] }) => p.args.includes("embed"));
expect(es?.args).toContain("--force");
});
it("destructive confirm called before dispatch", () => {
const tab = makeTab();
let called = false;
(globalThis as any).confirm = (msg: string) => { called = true; expect(msg).toContain("Proceed"); return false; };
const env = { ...createUnknownEnvelope("ocr"), action: { primary: { verb: "redo", label: "Redo", command: "paperforge ocr redo", destructive: true, destructive_scope: "selection", destructive_effect: "Deletes.", confirmation_required: true, confirmation_prompt: "Proceed?", scope: "module", scope_count: 1 } } } as any;
(tab as any)._dispatchModuleAction("ocr", env);
expect(called).toBe(true);
});
it("setup verb with wrong command falls through to Notice", () => {
const tab = makeTab();
noticeCalls.length = 0;
const env = { ...createUnknownEnvelope("library"), action: { primary: { verb: "setup", label: "Setup", command: "paperforge sync", destructive: false, destructive_scope: null, destructive_effect: null, confirmation_required: false, confirmation_prompt: null, scope: "module", scope_count: 1 } } } as any;
(tab as any)._dispatchModuleAction("library", env);
const msgs = noticeCalls.map((c: { msg: string }) => c.msg).join(" ");
expect(msgs.toLowerCase()).toMatch(/unknown|setup/);
});
it("probe verb with wrong command falls through to Notice", () => {
const tab = makeTab();
noticeCalls.length = 0;
const env = { ...createUnknownEnvelope("ocr"), action: { primary: { verb: "probe", label: "Probe", command: "probe installation", destructive: false, destructive_scope: null, destructive_effect: null, confirmation_required: false, confirmation_prompt: null, scope: "module", scope_count: 1 } } } as any;
(tab as any)._dispatchModuleAction("ocr", env);
const msgs = noticeCalls.map((c: { msg: string }) => c.msg).join(" ");
expect(msgs.toLowerCase()).toMatch(/unknown|probe/);
});
});
// ════════════════════════════════ 5. _dispatchOcrAction ══════════════
describe("_dispatchOcrAction lifecycle (Issue #78)", () => {
it("assigns _ocrProcess, sets activity overlay", () => {
const tab = makeTab();
(tab as any)._capabilityState = { ocr: createUnknownEnvelope("ocr") };
spawnedProcesses.length = 0;
(tab as any)._dispatchOcrAction("run");
// Verify spawn was called with correct args
expect(spawnedProcesses.length).toBeGreaterThan(0);
expect(spawnedProcesses[0].args).toContain("run");
// Verify _ocrProcess was assigned (mock spawn returns truthy)
expect((tab.plugin as any)._ocrProcess).toBeTruthy();
// Verify activity overlay
const e = (tab as any)._capabilityState as any;
expect(e?.ocr?.activity_state).toBe("running");
});
it("parses START/PROGRESS into activity_progress", () => {
const tab = makeTab();
(tab as any)._capabilityState = { ocr: createUnknownEnvelope("ocr") };
(tab as any)._dispatchOcrAction("rebuild");
const proc = spawnedProcesses[spawnedProcesses.length - 1];
proc?.onData?.("OCR_REBUILD START 20\n");
expect(((tab.plugin as any)._ocrProgress).total).toBe(20);
proc?.onData?.("OCR_REBUILD PROGRESS 5 20 KEY1\n");
expect(((tab.plugin as any)._ocrProgress).current).toBe(5);
});
it("clears activity and re-probes on close/error/stop", () => {
const probes: string[] = [];
const tab = makeTab();
(tab as any)._probeModule = (mod: string) => { probes.push(mod); };
(tab as any)._capabilityState = { ocr: createUnknownEnvelope("ocr") };
(tab as any)._dispatchOcrAction("run");
spawnedProcesses[spawnedProcesses.length - 1]?.onClose?.(0);
expect((tab.plugin as any)._ocrProcess).toBeNull();
expect(probes).toContain("ocr");
expect(((tab as any)._capabilityState as any)?.ocr?.activity_state).toBe("idle");
});
it("exact CLI args: run/rebuild/redo", () => {
for (const [mode, expected] of [["run", "run"], ["rebuild", "rebuild"], ["redo", "redo"]] as const) {
const tab = makeTab();
(tab as any)._capabilityState = { ocr: createUnknownEnvelope("ocr") };
spawnedProcesses.length = 0;
(tab as any)._dispatchOcrAction(mode);
expect(spawnedProcesses[0].args).toContain(expected);
if (mode === "rebuild") expect(spawnedProcesses[0].args).toContain("--all");
}
});
it("does not change capability_state/severity during activity", () => {
const tab = makeTab();
(tab as any)._capabilityState = {
ocr: { ...createUnknownEnvelope("ocr"), capability_state: "needs_action", severity: "warning" },
};
(tab as any)._dispatchOcrAction("run");
const e = (tab as any)._capabilityState as any;
expect(e?.ocr?.capability_state).toBe("needs_action");
expect(e?.ocr?.severity).toBe("warning");
expect(e?.ocr?.activity_state).toBe("running");
});
});
// ════════════════════════════════ 6. _dispatchMemoryBuild ════════════
describe("_dispatchMemoryBuild (Issue #78)", () => {
it("build mode overlays envelope, uses execFile", () => {
const tab = makeTab();
(tab as any)._capabilityState = { memory: createUnknownEnvelope("memory") };
execFileCalls.length = 0;
// _dispatchMemoryBuild("build") should set activity overlay and spawn execFile
(tab as any)._dispatchMemoryBuild("build");
const e = (tab as any)._capabilityState as any;
expect(e?.memory?.activity_state).toBe("running");
expect(e?.memory?.activity_label).toContain("Building memory");
// execFile should have been called with args containing "build"
const buildCalls = execFileCalls.filter((c: { args: string[] }) => c.args.some(a => a === "build"));
expect(buildCalls.length).toBeGreaterThan(0);
});
it("embed mode overlays envelope, spawns embed --force", () => {
const tab = makeTab();
(tab as any)._capabilityState = { memory: createUnknownEnvelope("memory") };
(tab as any)._dispatchMemoryBuild("embed");
expect(((tab as any)._capabilityState as any)?.memory?.activity_label).toContain("vector");
const es = spawnedProcesses.find((p: { args: string[] }) => p.args.includes("embed"));
expect(es?.args).toContain("--force");
});
it("embed parses PROGRESS into activity_progress", () => {
const tab = makeTab();
(tab as any)._capabilityState = { memory: createUnknownEnvelope("memory") };
(tab as any)._dispatchMemoryBuild("embed");
spawnedProcesses.find((p: { args: string[] }) => p.args.includes("embed"))?.onData?.("MEMORY_EMBED PROGRESS 100 500\n");
expect(((tab.plugin as any)._embedProgress).current).toBe(100);
});
});
// ════════════════════════════════ 7. Focus/back ══════════════════════
describe("focus and back navigation (Issue #78)", () => {
it("heading tabindex=-1, back sets focus target", () => {
const tab = makeTab();
const el = dom.window.document.createElement("div");
(tab as any)._renderLibraryDetail(el);
expect(el.querySelector("#pf-library-detail-heading")?.getAttribute("tabindex")).toBe("-1");
(el.querySelector(".pf-back-btn") as HTMLButtonElement)?.click();
expect((tab as any)._focusTargetId).toContain("library");
});
});
// ════════════════════════════════ 8. Module selector ═════════════════
describe("module detail selector (Issue #78)", () => {
it("4 buttons, active class, click navigation", () => {
const tab = makeTab();
const el = dom.window.document.createElement("div");
(tab as any)._renderOcrDetail(el);
expect(el.querySelectorAll(".pf-module-detail-btn").length).toBe(4);
expect(el.querySelector(".pf-module-detail-btn--active")?.textContent).toContain("OCR");
const memBtn = Array.from(el.querySelectorAll(".pf-module-detail-btn")).find(b => b.textContent?.includes("Memory"));
(memBtn as HTMLButtonElement)?.click();
expect((tab as any)._selectedDetailModule).toBe("memory");
});
});
// ════════════════════════════════ 9. Destructive + disabled ══════════
describe("destructive metadata and disabled-while-running (Issue #78)", () => {
it("renders destructive_effect notice", () => {
const ocrEnv = {
...createUnknownEnvelope("ocr"),
action: { primary: { verb: "redo", label: "Redo", command: "paperforge ocr redo", destructive: true, destructive_scope: "selection", destructive_effect: "Deletes derived OCR artifacts.", confirmation_required: true, confirmation_prompt: "Proceed?", scope: "module", scope_count: 1 } },
} as any;
const tab = makeTab({ capabilityState: { ocr: ocrEnv } });
const el = dom.window.document.createElement("div");
(tab as any)._renderOcrDetail(el);
const notice = el.querySelector(".pf-destructive-notice");
expect(notice).not.toBeNull();
expect(notice?.textContent).toContain("Deletes derived OCR");
});
it("action button disabled when running", () => {
const ocrEnv = {
...createUnknownEnvelope("ocr"),
activity_state: "running",
action: { primary: { verb: "rebuild_derived", label: "Rebuild", command: "paperforge ocr rebuild --all", destructive: false, destructive_scope: null, destructive_effect: null, confirmation_required: false, confirmation_prompt: null, scope: "module", scope_count: 1 } },
} as any;
const tab = makeTab({ capabilityState: { ocr: ocrEnv } });
const el = dom.window.document.createElement("div");
(tab as any)._renderOcrDetail(el);
expect((el.querySelector(".pf-cc-card-action") as HTMLButtonElement)?.hasAttribute("disabled")).toBe(true);
});
});
// ════════════════════════════════ 10. Library sync failure probe ═══════
describe("Library sync failure probe (Issue #78)", () => {
it("_probeModule library with nonzero exit code appends --last-operation-exit-code", () => {
const tab = makeTab();
execFileCalls.length = 0;
(tab as any)._probeModule("library", 1);
// Probe execFile call must include --last-operation-exit-code 1
const probeCall = execFileCalls.find(c => c.args.includes("probe") && c.args.includes("library"));
expect(probeCall).toBeDefined();
const lastOpIdx = probeCall!.args.indexOf("--last-operation-exit-code");
expect(lastOpIdx).toBeGreaterThan(-1);
expect(probeCall!.args[lastOpIdx + 1]).toBe("1");
});
it("_probeModule library without exit code omits --last-operation-exit-code", () => {
const tab = makeTab();
execFileCalls.length = 0;
(tab as any)._probeModule("library");
const probeCall = execFileCalls.find(c => c.args.includes("probe") && c.args.includes("library"));
expect(probeCall).toBeDefined();
expect(probeCall!.args.indexOf("--last-operation-exit-code")).toBe(-1);
});
it("_probeModule non-library with exit code omits --last-operation-exit-code", () => {
const tab = makeTab();
execFileCalls.length = 0;
(tab as any)._probeModule("ocr", 1);
// OCR must NOT get --last-operation-exit-code
const probeCall = execFileCalls.find(c => c.args.includes("probe") && c.args.includes("ocr"));
expect(probeCall).toBeDefined();
expect(probeCall!.args.indexOf("--last-operation-exit-code")).toBe(-1);
});
it("failed _runManualSync onClose passes nonzero code to probe", () => {
const tab = makeTab();
execFileCalls.length = 0;
(tab as any)._runManualSync();
// First call is sync, second (after onClose) is probe
const syncCall = execFileCalls.find(c => c.args.includes("sync") && !c.args.includes("probe"));
expect(syncCall).toBeDefined();
// Simulate sync failure by invoking the onClose directly
if (syncCall!.cb) syncCall!.cb(new Error("sync failed"), "", "error");
// After sync failure, _probeModule should append --last-operation-exit-code 1
const probeCall = execFileCalls.find(c => c.args.includes("probe") && c.args.includes("library") && c.args.includes("--last-operation-exit-code"));
expect(probeCall).toBeDefined();
const lastOpIdx = probeCall!.args.indexOf("--last-operation-exit-code");
expect(probeCall!.args[lastOpIdx + 1]).toBe("1");
});
it("successful _runManualSync onClose passes 0 to probe (no flag)", () => {
const tab = makeTab();
execFileCalls.length = 0;
(tab as any)._runManualSync();
const syncCall = execFileCalls.find(c => c.args.includes("sync") && !c.args.includes("probe"));
expect(syncCall).toBeDefined();
// Simulate sync success: code=0
if (syncCall!.cb) syncCall!.cb(null, "success", "");
// After success (code=0), probe should NOT have --last-operation-exit-code
const probeCalls = execFileCalls.filter(c => c.args.includes("probe") && c.args.includes("library"));
const failureProbe = probeCalls.find(c => c.args.includes("--last-operation-exit-code"));
expect(failureProbe).toBeUndefined();
});
it("sync_failed envelope renders actionable on failure probe", () => {
const syncFailedEnv: ProbeEnvelope = {
schema_version: 1,
module: "library",
capability_state: "needs_action",
activity_state: "idle",
activity_label: null,
activity_progress: null,
severity: "error",
reason: { code: "library.sync_failed", text: "Library sync failed (exit code 1)" },
action: { primary: { verb: "sync", label: "Sync library", command: "paperforge sync", destructive: false, destructive_scope: null, destructive_effect: null, confirmation_required: false, confirmation_prompt: null, scope: "module", scope_count: 1 } },
notices: [],
updated_at: new Date().toISOString(),
ttl_seconds: 300,
};
const tab = makeTab({ capabilityState: { library: syncFailedEnv } });
const el = dom.window.document.createElement("div");
(tab as any)._renderLibraryDetail(el);
// Must show failure reason
expect(el.textContent).toContain("sync failed");
expect(el.textContent).toContain("exit code 1");
// Must have actionable sync button
expect(el.querySelector(".pf-cc-card-action")).not.toBeNull();
expect((el.querySelector(".pf-cc-card-action") as HTMLButtonElement)?.textContent).toContain("Sync");
});
it("null _runManualSync onClose forwards sentinel 1 via code ?? 1", () => {
const tab = makeTab();
execFileCalls.length = 0;
// Override _callPython on this tab to invoke opts.onClose(null, ...)
// simulating a process exit with null code (timeout/kill).
const origCallPython = (tab as any)._callPython.bind(tab);
(tab as any)._callPython = (args: string[], opts: any) => {
// Only intercept sync; pass other calls through
if (args.includes("sync")) {
// Simulate process close with null code
if (opts.onClose) opts.onClose(null, "", "sync killed");
return null;
}
return origCallPython(args, opts);
};
(tab as any)._runManualSync();
// After _runManualSync triggers onClose(null), probe should have --last-operation-exit-code 1
const probeCall = execFileCalls.find(c => c.args.includes("probe") && c.args.includes("library") && c.args.includes("--last-operation-exit-code"));
expect(probeCall).toBeDefined();
const lastOpIdx = probeCall!.args.indexOf("--last-operation-exit-code");
expect(probeCall!.args[lastOpIdx + 1]).toBe("1");
});
});

View file

@ -35,6 +35,30 @@ def _read_dotenv(vault: Path, key: str) -> str:
return ""
def _resolve_paddleocr_token(vault: Path) -> str:
"""Resolve PaddleOCR API token from canonical sources.
Priority: PADDLEOCR_API_TOKEN env PADDLEOCR_API_TOKEN_USER env
Windows HKCU Environment vault/.env or System/PaperForge/.env.
Returns empty string when no token is found.
"""
token = os.environ.get("PADDLEOCR_API_TOKEN", "").strip()
if not token:
token = os.environ.get("PADDLEOCR_API_TOKEN_USER", "").strip()
if not token:
try:
import winreg
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Environment") as env_key:
token = str(winreg.QueryValueEx(env_key, "PADDLEOCR_API_TOKEN")[0]).strip()
except Exception:
token = ""
if not token:
token = _read_dotenv(vault, "PADDLEOCR_API_TOKEN")
return token
try:
from paperforge.worker.ocr_roles import assign_block_role # noqa: F401
except ImportError:
@ -2461,20 +2485,7 @@ def run_ocr(
max_items = max(1, int(max_items_raw))
except ValueError:
max_items = 3
token = os.environ.get("PADDLEOCR_API_TOKEN", "").strip()
if not token:
token = os.environ.get("PADDLEOCR_API_TOKEN_USER", "").strip()
if not token:
try:
import winreg
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Environment") as env_key:
token = str(winreg.QueryValueEx(env_key, "PADDLEOCR_API_TOKEN")[0]).strip()
except Exception:
token = ""
if not token:
# Fallback: parse vault-root .env
token = _read_dotenv(vault, "PADDLEOCR_API_TOKEN")
token = _resolve_paddleocr_token(vault)
job_url = os.environ.get("PADDLEOCR_JOB_URL", "https://paddleocr.aistudio-app.com/api/v2/ocr/jobs").strip()
model = os.environ.get("PADDLEOCR_MODEL", "PaddleOCR-VL-1.6").strip()
optional_payload = {"useDocOrientationClassify": False, "useDocUnwarping": False, "useChartRecognition": False}

View file

@ -1,9 +1,8 @@
# OCR-v2 Active Queue
> Status: OCR-v2 is stable; #75 canonical setup/config migration and #76 Installation/Help capability tracer are implemented; #77 Managed Runtime lifecycle is the next unblocked control-center slice.
> Last updated: 2026-07-15
> Status: OCR-v2 is stable; #75#78 are implemented; #79 SecretStorage is the next unblocked control-center slice.
> Last updated: 2026-07-18
## Current checkpoint
- Retrieval recovery is merged to `master`; the real Literature-hub vault has a healthy 2560-dimensional vec0 index and working M / @ search paths.
- [OCR rebuild: streaming progress + maintenance UI redesign](https://github.com/LLLin000/PaperForge/issues/64) is implemented and reviewed.
- Multi-key `ocr rebuild` and full `ocr redo` emit separate, flushed progress streams and accept a cross-platform cooperative stop request between papers.
@ -17,7 +16,6 @@
- [Desktop runtime/recovery research](https://github.com/LLLin000/PaperForge/issues/68#issuecomment-4970660288) establishes module-scoped repair, compatibility-gated updates, local redacted diagnostics, and user-reviewed issue drafts.
- **[#69](https://github.com/LLLin000/PaperForge/issues/69) resolved** at `issuecomment-4971161072`: orthogonal availability/activity/attention axes, 6-state capability ordinal, 12 canonical verbs, backend-owned severity and primary actions, maintenance projection.
- **[#70](https://github.com/LLLin000/PaperForge/issues/70) resolved** at `issuecomment-4971239398`: plugin-managed immutable runtime slots, system-Python bootstrap with validated-triplet fallback, single `active-runtime.json` pointer, `ManagedRuntime` class with `current()`/`status()`/`ensure()`, fail-closed command resolution.
- **[#71](https://github.com/LLLin000/PaperForge/issues/71) resolved**: six-module control-center HTML prototype with 5 scenarios, plain-button switcher, primary attention zone, responsive layout (768px breakpoint), and capability-gated actions. Independent Critical/Important PASS review. Design decisions recorded in `docs/prototypes/2026-07-14-six-module-control-center.{html,md}`.
- **[#72](https://github.com/LLLin000/PaperForge/issues/72) resolved**: actionable-only maintenance inbox prototype with single-action rows, inline issue-draft review, local redacted export, and confirmation-first report flow. Independent Critical/Important PASS review. Design decisions recorded in `docs/prototypes/2026-07-14-maintenance-issue-reporting.{html,md}`.
- **[#73](https://github.com/LLLin000/PaperForge/issues/73) resolved**: locked migration, security, platform, accessibility, and release-gate acceptance contract after five-domain audit and independent review.
@ -25,9 +23,9 @@
- **[#75](https://github.com/LLLin000/PaperForge/issues/75) implemented and reviewed**: bare/headless/modular setup share `SetupPlan`; schema-v2 `vault_config` wins; v1 path keys are warned read fallback; all configured directories are forwarded; required failures return non-zero.
- **[#76](https://github.com/LLLin000/PaperForge/issues/76) implemented**: schema-v1 Installation/Help probes flow through the six-module Overview; persisted malformed/stale envelopes fail closed; backend set_config/update actions route to setup; unimplemented modules remain explicit placeholders.
- **Wayfinder navigation refinement approved**: preserve Overview and stage `概览 / 模块详情 / 维护 / 帮助` across #77/#78/#80. Installation owns Agent platform/Skills under Agent 集成; no empty placeholder detail pages.
- **[#77](https://github.com/LLLin000/PaperForge/issues/77) implemented**: immutable runtime slots, synchronous fail-closed `current`, probed `status`, install/repair/update/rollback/cancel/retention, managed-first dispatch, Release-N fallback, four-destination navigation, Installation detail, Agent integration, Help focus restoration.
- **[#78](https://github.com/LLLin000/PaperForge/issues/78) implemented**: Library/OCR/Memory real capability probes with module-detail-navigation, installation-navigation, and capability-state views. Python owns capability fact definitions; TypeScript renders via exact allowlist, fails closed on unknown keys.
## Verification status
- Focused Python OCR paths: **99 passed, 1 Windows SIGINT test skipped, 1 unrelated empty-result regression deselected**.
- Plugin: **93 passed**; TypeScript check and production build passed.
- Maintenance regression tests: **19/19 passed** (canonical action routing, confirmation gate, cache manifest preservation).
@ -38,20 +36,20 @@
- Both prototypes browser-verified at 768px viewport with scenario-switching, action-button interactions, expand/collapse diagnostics, and issue-draft flow.
- Issue #75 verification: **61/61 focused tests passed**; independent review returned **Spec PASS / Quality APPROVED**.
- Issue #76 verification: **21/21 backend probe tests and 169/169 plugin tests passed**; TypeScript check and production build passed; live Obsidian stale-cache/action-label smoke test and independent review passed.
- Issue #77 verification: **192/192 focused + 289/289 full tests passed**; typecheck/build clean; merged to `master` in `173a4e8..4ef9e98`.
- Issue #78 verification: **65/65 backend tests and 178/178 focused plugin tests, 324/324 full plugin tests passed** across 11 files; typecheck clean; production build clean; fail-closed recognizable config for Library/OCR; red rebuild_result stays non-destructive rebuild; queued OCR progress starts at 0; failed/null Library sync exit outcome is forwarded into fresh Python probe and remains sync actionable.
- No production plugin code was modified during prototype work.
- The repository-wide Python suite remains blocked during collection by the pre-existing `test_pr9a_resume_rebuild.py` import of removed `_assert_collections_healthy`.
## Frontier
- [x] Prototype the six-module control center ([#71](https://github.com/LLLin000/PaperForge/issues/71)).
- [x] Design the actionable-only maintenance inbox ([#72](https://github.com/LLLin000/PaperForge/issues/72)).
- [x] Lock migration/acceptance contract (#73), publish PRD #74, and create dependency-linked issues #75#82.
- [x] Canonicalize setup and configuration migration ([#75](https://github.com/LLLin000/PaperForge/issues/75)).
- [x] Implement [#76](https://github.com/LLLin000/PaperForge/issues/76): Installation/Help capability envelope through the existing settings surface.
- [ ] Start [#77](https://github.com/LLLin000/PaperForge/issues/77) in a fresh Matt `/implement` session: Managed Runtime lifecycle plus the approved Installation-detail navigation shell.
- [x] Implement [#77](https://github.com/LLLin000/PaperForge/issues/77): Managed Runtime lifecycle plus the approved Installation-detail navigation shell.
- [x] Implement [#78](https://github.com/LLLin000/PaperForge/issues/78): Library, OCR, and Memory capability tracers end to end.
- [ ] Start [#79](https://github.com/LLLin000/PaperForge/issues/79) in a fresh Matt `/implement` session: SecretStorage for capability secrets.
## Deferred
- Vector rebuild UX (PRD Slice 1): deferred.
- Memory/global maintenance cleanup (PRD Slice 3): deferred.
- OCR ETA and real-time per-row mutation: out of scope for the completed OCR slice.

View file

@ -0,0 +1,226 @@
"""Focused tests for memory restore-backup (Issue #78 repair).
Covers:
- CLI restore-backup subcommand
- Backup validation (integrity_check)
- Corrupt DB preservation
- Atomic replace (os.replace)
- Invalid backup safety (no restore)
- Probe emits restore_backup action
"""
from __future__ import annotations
import json
import sqlite3
import subprocess
import sys
from pathlib import Path
import pytest
def _run_memory_cmd(vault: Path, subcmd: str, extra_args: list[str] | None = None) -> subprocess.CompletedProcess:
cmd = [sys.executable, "-m", "paperforge", "--vault", str(vault), "memory", subcmd]
if extra_args:
cmd.extend(extra_args)
return subprocess.run(cmd, capture_output=True, text=True, timeout=15)
def _setup_memory_dirs(tmp_path: Path) -> Path:
"""Create paperforge.json and indexes dir, return indexes dir."""
(tmp_path / "paperforge.json").write_text(
json.dumps({"system_dir": "99_System"}), encoding="utf-8",
)
indexes = tmp_path / "99_System" / "PaperForge" / "indexes"
indexes.mkdir(parents=True, exist_ok=True)
return indexes
def _create_valid_db(db_path: Path, paper_count: int = 3) -> None:
"""Create a valid paperforge.db with schema_version and papers."""
conn = sqlite3.connect(str(db_path))
conn.execute("CREATE TABLE papers (zotero_key TEXT PRIMARY KEY, title TEXT)")
conn.execute("CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT)")
conn.execute("INSERT INTO meta VALUES ('schema_version', '8')")
for i in range(paper_count):
conn.execute("INSERT INTO papers VALUES (?, ?)", (f"KEY{i}", f"Paper {i}"))
conn.commit()
conn.close()
class TestMemoryRestoreCli:
"""CLI restore-backup subcommand acceptance."""
def test_restore_backup_no_backup_file(self, tmp_path: Path) -> None:
"""No backup file -> exits nonzero."""
indexes = _setup_memory_dirs(tmp_path)
_create_valid_db(indexes / "paperforge.db")
result = _run_memory_cmd(tmp_path, "restore-backup", ["--json"])
data = json.loads(result.stdout)
assert data["ok"] is False
assert "No backup found" in data["error"]["message"]
def test_restore_backup_invalid_backup(self, tmp_path: Path) -> None:
"""Invalid backup (not a database) -> exits nonzero, current DB untouched."""
indexes = _setup_memory_dirs(tmp_path)
db_path = indexes / "paperforge.db"
backup_path = indexes / "paperforge.db.backup"
_create_valid_db(db_path)
backup_path.write_text("not a database", encoding="utf-8")
result = _run_memory_cmd(tmp_path, "restore-backup", ["--json"])
data = json.loads(result.stdout)
assert data["ok"] is False
assert "integrity check" in data["error"]["message"].lower() or "validate backup" in data["error"]["message"].lower() or "not a database" in data["error"]["message"].lower()
# Current DB still intact
assert db_path.exists()
def test_restore_backup_success(self, tmp_path: Path) -> None:
"""Valid backup -> restores successfully, old DB preserved."""
indexes = _setup_memory_dirs(tmp_path)
db_path = indexes / "paperforge.db"
backup_path = indexes / "paperforge.db.backup"
# Create current DB with old content
_create_valid_db(db_path, paper_count=1)
# Create backup with more papers
_create_valid_db(backup_path, paper_count=5)
result = _run_memory_cmd(tmp_path, "restore-backup", ["--json"])
assert result.returncode == 0
data = json.loads(result.stdout)
assert data["ok"] is True
assert data["data"]["action"] == "restore_backup"
# Verify restored DB has 5 papers
conn = sqlite3.connect(str(db_path))
row = conn.execute("SELECT COUNT(*) FROM papers").fetchone()
conn.close()
assert row[0] == 5
# Verify backup still exists (not consumed)
assert backup_path.exists()
# Verify old DB preserved as corrupt snapshot
corrupt_files = list(indexes.glob("paperforge.corrupt-*.db"))
assert len(corrupt_files) == 1
def test_restore_backup_without_current_db(self, tmp_path: Path) -> None:
"""No current DB, just backup -> restores directly."""
indexes = _setup_memory_dirs(tmp_path)
db_path = indexes / "paperforge.db"
backup_path = indexes / "paperforge.db.backup"
_create_valid_db(backup_path, paper_count=3)
result = _run_memory_cmd(tmp_path, "restore-backup", ["--json"])
assert result.returncode == 0
data = json.loads(result.stdout)
assert data["ok"] is True
# Verify restored
conn = sqlite3.connect(str(db_path))
row = conn.execute("SELECT COUNT(*) FROM papers").fetchone()
conn.close()
assert row[0] == 3
# No corrupt snapshot since there was no current DB
corrupt_files = list(indexes.glob("paperforge.corrupt-*.db"))
assert len(corrupt_files) == 0
def test_replace_failure_leaves_db_and_backup_untouched(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""When os.replace fails, current DB and backup remain unchanged (direct call)."""
import sqlite3
from paperforge.commands.memory import _restore_backup
indexes = _setup_memory_dirs(tmp_path)
db_path = indexes / "paperforge.db"
backup_path = indexes / "paperforge.db.backup"
# Create current DB with known content
conn = sqlite3.connect(str(db_path))
conn.execute("CREATE TABLE papers (zotero_key TEXT PRIMARY KEY, title TEXT)")
conn.execute("CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT)")
conn.execute("INSERT INTO meta VALUES ('schema_version', '8')")
conn.execute("INSERT INTO papers VALUES ('OLD', 'Old Paper')")
conn.commit()
conn.close()
original_bytes = db_path.read_bytes()
# Create backup with different content
conn = sqlite3.connect(str(backup_path))
conn.execute("CREATE TABLE papers (zotero_key TEXT PRIMARY KEY, title TEXT)")
conn.execute("CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT)")
conn.execute("INSERT INTO meta VALUES ('schema_version', '8')")
conn.execute("INSERT INTO papers VALUES ('NEW', 'New Paper')")
conn.commit()
conn.close()
backup_bytes = backup_path.read_bytes()
# Monkeypatch os.replace to fail (direct call, so monkeypatch works)
import os as _os
def _failing_replace(src, dst):
raise OSError("simulated replace failure")
monkeypatch.setattr(_os, "replace", _failing_replace)
result = _restore_backup(tmp_path)
assert result.ok is False
assert "Failed to restore backup" in result.error.message
# Original DB still intact (byte-identical)
assert db_path.read_bytes() == original_bytes
# Backup still intact
assert backup_path.exists()
assert backup_path.read_bytes() == backup_bytes
# Clean up temp files
for f in indexes.glob("paperforge.restore-*"):
f.unlink(missing_ok=True)
class TestMemoryRestoreProbe:
"""Probe emits restore_backup when corrupt DB + backup exists."""
def test_corrupt_db_with_backup_emits_restore(self, tmp_path: Path) -> None:
"""Corrupt DB + valid backup -> restore_backup action."""
from paperforge.commands import probe as probe_mod
(tmp_path / "paperforge.json").write_text(
json.dumps({"system_dir": "99_System"}), encoding="utf-8",
)
indexes = tmp_path / "99_System" / "PaperForge" / "indexes"
indexes.mkdir(parents=True, exist_ok=True)
# Create corrupt DB
(indexes / "paperforge.db").write_text("not a database", encoding="utf-8")
# Create valid backup
_create_valid_db(indexes / "paperforge.db.backup", paper_count=3)
data = probe_mod.probe_memory(tmp_path)
assert data["capability_state"] == "unavailable"
assert data["reason"]["code"] == "memory.db_corrupt"
assert data["action"]["primary"]["verb"] == "restore_backup"
assert data["action"]["primary"]["command"] == "paperforge memory restore-backup"
assert data["action"]["primary"]["destructive"] is True
assert data["action"]["primary"]["confirmation_required"] is True
def test_corrupt_db_without_backup_emits_rebuild(self, tmp_path: Path) -> None:
"""Corrupt DB without backup -> rebuild action."""
from paperforge.commands import probe as probe_mod
(tmp_path / "paperforge.json").write_text(
json.dumps({"system_dir": "99_System"}), encoding="utf-8",
)
indexes = tmp_path / "99_System" / "PaperForge" / "indexes"
indexes.mkdir(parents=True, exist_ok=True)
# Create corrupt DB, no backup
(indexes / "paperforge.db").write_text("not a database", encoding="utf-8")
data = probe_mod.probe_memory(tmp_path)
assert data["capability_state"] == "unavailable"
assert data["reason"]["code"] == "memory.db_corrupt"
assert data["action"]["primary"]["verb"] == "run"
assert data["action"]["primary"]["command"] == "paperforge memory build"

View file

@ -14,6 +14,7 @@ from __future__ import annotations
import json
import subprocess
import os
import sys
from pathlib import Path
@ -41,7 +42,7 @@ VALID_STATES = {"unknown", "unavailable", "missing_input", "needs_action", "limi
VALID_SEVERITIES = {"ok", "warning", "error", "info"}
def _run_probe(module: str, vault: Path, extra_args: list[str] | None = None) -> dict:
def _run_probe(module: str, vault: Path, extra_args: list[str] | None = None, env: dict[str, str] | None = None) -> dict:
"""Run `paperforge probe <module> --json` in a subprocess and return parsed JSON.
NOTE: --vault must come BEFORE the subcommand (argparse global args rule).
@ -58,7 +59,8 @@ def _run_probe(module: str, vault: Path, extra_args: list[str] | None = None) ->
]
if extra_args:
cmd.extend(extra_args)
result = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
run_env = env if env is not None else os.environ.copy()
result = subprocess.run(cmd, capture_output=True, text=True, timeout=15, env=run_env)
assert result.returncode == 0, (
f"CLI exited {result.returncode}\nstdout: {result.stdout[:500]}\nstderr: {result.stderr[:500]}"
)
@ -322,7 +324,7 @@ class TestProbeCliArgs:
"""Unknown module name is rejected by argparse."""
result = subprocess.run(
[sys.executable, "-m", "paperforge", "--vault", str(tmp_path),
"probe", "ocr", "--json"],
"probe", "maintenance", "--json"],
capture_output=True, text=True, timeout=15,
)
assert result.returncode != 0
@ -345,3 +347,587 @@ class TestProbeCliArgs:
capture_output=True, text=True, timeout=15,
)
assert result.returncode != 0
# ---------------------------------------------------------------------------
# Library probe states (Issue #78)
# ---------------------------------------------------------------------------
class TestLibraryProbe:
"""State mapping for the library module probe."""
def test_missing_paperforge_json(self, tmp_path: Path) -> None:
"""No paperforge.json -> missing_input + set_config action."""
data = _run_probe('library', tmp_path)
assert data['module'] == 'library'
assert data['capability_state'] == 'missing_input'
assert data['severity'] == 'warning'
assert data['reason']['code'] == 'library.config_missing'
assert data['action']['primary'] is not None
_assert_action_primary_shape(data['action']['primary'])
assert data['action']['primary']['verb'] == 'set_config'
def test_corrupt_config(self, tmp_path: Path) -> None:
"""Corrupt JSON -> unavailable + setup action."""
(tmp_path / 'paperforge.json').write_text('{{{', encoding='utf-8')
data = _run_probe('library', tmp_path)
assert data['capability_state'] == 'unavailable'
assert data['severity'] == 'error'
assert data['reason']['code'] == 'library.config_corrupt'
def test_zotero_not_configured(self, tmp_path: Path) -> None:
"""Config exists but no zotero_data_dir -> missing_input."""
(tmp_path / 'paperforge.json').write_text(
json.dumps({'system_dir': '99_System'}), encoding='utf-8',
)
data = _run_probe('library', tmp_path)
assert data['capability_state'] == 'missing_input'
assert data['reason']['code'] == 'library.zotero_missing'
assert data['action']['primary']['verb'] == 'set_config'
def test_envelope_shape(self, tmp_path: Path) -> None:
"""Library envelope has all required fields."""
(tmp_path / 'paperforge.json').write_text(
json.dumps({'system_dir': '99_System', 'zotero_data_dir': str(tmp_path)}), encoding='utf-8',
)
data = _run_probe('library', tmp_path)
_assert_envelope_shape(data)
assert data['ttl_seconds'] == 300
def test_primitive_config_corrupt(self, tmp_path: Path) -> None:
"""Non-dict config -> unavailable."""
(tmp_path / 'paperforge.json').write_text('42', encoding='utf-8')
data = _run_probe('library', tmp_path)
assert data['capability_state'] == 'unavailable'
assert data['reason']['code'] == 'library.config_corrupt'
# ---------------------------------------------------------------------------
# OCR probe states (Issue #78)
# ---------------------------------------------------------------------------
def test_unrecognized_config_corrupt(self, tmp_path: Path) -> None:
"""Parseable dict without recognized keys -> config_corrupt/unavailable."""
(tmp_path / 'paperforge.json').write_text(
json.dumps({"name": "Foo", "version": "1.0"}), encoding='utf-8',
)
data = _run_probe('library', tmp_path)
assert data['capability_state'] == 'unavailable'
assert data['severity'] == 'error'
assert data['reason']['code'] == 'library.config_corrupt'
assert data['action']['primary']['verb'] == 'setup'
def test_sync_failed_nonzero_exit_code(self, tmp_path: Path) -> None:
"""Nonzero last_operation_exit_code -> sync_failed envelope (direct call)."""
from paperforge.commands import probe as probe_mod
(tmp_path / 'paperforge.json').write_text(
json.dumps({"system_dir": "99_System", "zotero_data_dir": str(tmp_path)}), encoding='utf-8',
)
data = probe_mod.probe_library(tmp_path, last_operation_exit_code=7)
assert data['module'] == 'library'
assert data['capability_state'] == 'needs_action'
assert data['severity'] == 'error'
assert data['reason']['code'] == 'library.sync_failed'
assert 'exit code 7' in data['reason']['text']
assert data['action']['primary']['verb'] == 'sync'
assert data['action']['primary']['command'] == 'paperforge sync'
def test_sync_success_zero_exit_code_normal(self, tmp_path: Path) -> None:
"""Zero last_operation_exit_code -> normal probe, not sync_failed."""
from paperforge.commands import probe as probe_mod
(tmp_path / 'paperforge.json').write_text(
json.dumps({"system_dir": "99_System", "zotero_data_dir": str(tmp_path)}), encoding='utf-8',
)
data = probe_mod.probe_library(tmp_path, last_operation_exit_code=0)
assert data['module'] == 'library'
assert data['reason']['code'] != 'library.sync_failed'
# Normal probe falls through to index check (missing in fresh tmp_path)
assert data['reason']['code'] == 'library.index_missing'
class TestOcrProbe:
"""State mapping for the OCR module probe."""
def test_missing_paperforge_json(self, tmp_path: Path) -> None:
"""No paperforge.json -> missing_input + set_config action."""
data = _run_probe('ocr', tmp_path)
assert data['module'] == 'ocr'
assert data['capability_state'] == 'missing_input'
assert data['severity'] == 'warning'
assert data['reason']['code'] == 'ocr.config_missing'
assert data['action']['primary']['verb'] == 'set_config'
def test_corrupt_config(self, tmp_path: Path) -> None:
"""Corrupt JSON -> unavailable."""
(tmp_path / 'paperforge.json').write_text('{{{', encoding='utf-8')
data = _run_probe('ocr', tmp_path)
assert data['capability_state'] == 'unavailable'
assert data['reason']['code'] == 'ocr.config_corrupt'
def test_api_key_missing(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Config exists but no API token anywhere -> missing_input."""
from paperforge.commands import probe as probe_mod
(tmp_path / "paperforge.json").write_text(
json.dumps({"system_dir": "99_System"}), encoding="utf-8",
)
# Mock _resolve_paddleocr_token to return empty (no token from any source)
monkeypatch.setattr("paperforge.worker.ocr._resolve_paddleocr_token", lambda v: "")
data = probe_mod.probe_ocr(tmp_path)
assert data["capability_state"] == "missing_input"
assert data["reason"]["code"] == "ocr.api_key_missing"
def test_non_dict_config_corrupt(self, tmp_path: Path) -> None:
"""Non-dict config -> unavailable."""
(tmp_path / 'paperforge.json').write_text('true', encoding='utf-8')
data = _run_probe('ocr', tmp_path)
assert data['capability_state'] == 'unavailable'
assert data['reason']['code'] == 'ocr.config_corrupt'
def test_unrecognized_config_corrupt(self, tmp_path: Path) -> None:
"""Parseable dict without recognized keys -> config_corrupt/unavailable."""
(tmp_path / 'paperforge.json').write_text(
json.dumps({"name": "Foo", "version": "1.0"}), encoding='utf-8',
)
data = _run_probe('ocr', tmp_path)
assert data['capability_state'] == 'unavailable'
assert data['severity'] == 'error'
assert data['reason']['code'] == 'ocr.config_corrupt'
assert data['action']['primary']['verb'] == 'setup'
# ---------------------------------------------------------------------------
# Memory probe states (Issue #78)
# ---------------------------------------------------------------------------
class TestMemoryProbe:
"""State mapping for the memory module probe."""
def test_db_missing(self, tmp_path: Path) -> None:
"""No paperforge.db -> needs_action + run action."""
data = _run_probe("memory", tmp_path)
assert data["module"] == "memory"
assert data["capability_state"] == "needs_action"
assert data["reason"]["code"] == "memory.db_missing"
assert data["action"]["primary"] is not None
_assert_action_primary_shape(data["action"]["primary"])
assert data["action"]["primary"]["verb"] == "run"
def test_envelope_shape(self, tmp_path: Path) -> None:
"""Memory envelope has all required fields."""
data = _run_probe('memory', tmp_path)
_assert_envelope_shape(data)
assert data['ttl_seconds'] == 300
def test_db_corrupt(self, tmp_path: Path) -> None:
"""Corrupt database -> unavailable + run action."""
# Create paperforge.json so canonical path resolution works
(tmp_path / "paperforge.json").write_text(
json.dumps({"system_dir": "99_System"}), encoding="utf-8",
)
indexes = tmp_path / "99_System" / "PaperForge" / "indexes"
indexes.mkdir(parents=True, exist_ok=True)
(indexes / "paperforge.db").write_text("not a database", encoding="utf-8")
data = _run_probe("memory", tmp_path)
assert data["capability_state"] == "unavailable"
assert data["reason"]["code"] == "memory.db_corrupt"
action = data["action"]["primary"]
_assert_action_primary_shape(action)
assert action["verb"] == "run"
assert action["command"] == "paperforge memory build"
# ---------------------------------------------------------------------------
# Library/OCR/Memory canonical probe tests (Issue #78 repair)
# ---------------------------------------------------------------------------
class TestLibraryProbeCanonical:
"""Library probe with canonical formal-library.json validation."""
def test_ready_with_canonical_index(self, tmp_path: Path) -> None:
"""Valid formal-library.json + matching export hash -> ready."""
(tmp_path / "paperforge.json").write_text(
json.dumps({"system_dir": "99_System", "zotero_data_dir": str(tmp_path)}),
encoding="utf-8",
)
indexes_dir = tmp_path / "99_System" / "PaperForge" / "indexes"
indexes_dir.mkdir(parents=True, exist_ok=True)
(indexes_dir / "formal-library.json").write_text(json.dumps({
"schema_version": "2",
"items": [{"zotero_key": "ABC123", "title": "Test Paper"}],
"paper_count": 1,
}), encoding="utf-8")
data = _run_probe("library", tmp_path)
assert data["module"] == "library"
assert data["capability_state"] != "unavailable"
assert data["severity"] != "error"
_assert_envelope_shape(data)
def test_malformed_index(self, tmp_path: Path) -> None:
"""Non-dict non-list formal-library.json -> needs_action."""
(tmp_path / "paperforge.json").write_text(
json.dumps({"system_dir": "99_System", "zotero_data_dir": str(tmp_path)}),
encoding="utf-8",
)
indexes_dir = tmp_path / "99_System" / "PaperForge" / "indexes"
indexes_dir.mkdir(parents=True, exist_ok=True)
(indexes_dir / "formal-library.json").write_text('"just a string"', encoding="utf-8")
data = _run_probe("library", tmp_path)
assert data["capability_state"] == "needs_action"
assert data["reason"]["code"] in ("library.index_corrupt", "library.index_legacy")
class TestOcrProbeCanonical:
"""OCR probe with PADDLEOCR_API_TOKEN env credential."""
def test_credential_from_env(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""PADDLEOCR_API_TOKEN in env -> passes credential check."""
(tmp_path / "paperforge.json").write_text(
json.dumps({"system_dir": "99_System"}), encoding="utf-8",
)
monkeypatch.setenv("PADDLEOCR_API_TOKEN", "test-token-12345")
data = _run_probe("ocr", tmp_path)
assert data["reason"]["code"] != "ocr.api_key_missing"
_assert_envelope_shape(data)
def test_no_credential_no_config(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""No PADDLEOCR_API_TOKEN in env + no config -> missing_input."""
monkeypatch.delenv("PADDLEOCR_API_TOKEN", raising=False)
monkeypatch.delenv("PADDLEOCR_API_TOKEN_USER", raising=False)
data = _run_probe("ocr", tmp_path)
assert data["capability_state"] == "missing_input"
assert data["reason"]["code"] == "ocr.config_missing"
class TestMemoryProbeCanonical:
"""Memory probe distinguishing corruption from schema mismatch."""
def test_populated_db_old_schema(self, tmp_path: Path) -> None:
"""DB with papers but old schema -> migration_needed."""
import sqlite3
# Create paperforge.json for canonical path resolution
(tmp_path / "paperforge.json").write_text(
json.dumps({"system_dir": "99_System"}), encoding="utf-8",
)
indexes = tmp_path / "99_System" / "PaperForge" / "indexes"
indexes.mkdir(parents=True, exist_ok=True)
db_path = indexes / "paperforge.db"
conn = sqlite3.connect(str(db_path))
conn.execute("CREATE TABLE papers (zotero_key TEXT, title TEXT)")
conn.execute("CREATE TABLE meta (key TEXT, value TEXT)")
conn.execute("INSERT INTO meta VALUES ('schema_version', '3')")
conn.execute("INSERT INTO papers VALUES ('KEY1', 'Test Paper')")
conn.commit()
conn.close()
data = _run_probe("memory", tmp_path)
_assert_envelope_shape(data)
assert data["reason"]["code"] == "memory.migration_needed"
assert data["action"]["primary"]["verb"] == "rebuild_index"
# ---------------------------------------------------------------------------
# All-probe smoke test (Issue #78)
# ---------------------------------------------------------------------------
class TestAllProbes:
"""Verify all five real probes emit valid envelopes."""
@pytest.mark.parametrize('module', ['installation', 'library', 'ocr', 'memory', 'help'])
def test_probe_emits_valid_envelope(self, module: str, tmp_path: Path) -> None:
"""Every real probe emits a valid envelope."""
if module in ('installation', 'library', 'ocr'):
(tmp_path / 'paperforge.json').write_text(
json.dumps({'system_dir': '99_System'}), encoding='utf-8',
)
data = _run_probe(module, tmp_path)
_assert_envelope_shape(data)
assert data['module'] == module
assert data['severity'] in VALID_SEVERITIES
def test_cli_accepts_all_modules(self, tmp_path: Path) -> None:
"""CLI accepts all five module names."""
(tmp_path / 'paperforge.json').write_text(
json.dumps({'system_dir': '99_System'}), encoding='utf-8',
)
for mod in ('installation', 'library', 'ocr', 'memory', 'help'):
result = subprocess.run(
[sys.executable, '-m', 'paperforge', '--vault', str(tmp_path),
'probe', mod, '--json'],
capture_output=True, text=True, timeout=15,
)
assert result.returncode == 0, f'module={mod}: {result.stderr[:200]}'
# ---------------------------------------------------------------------------
# Issue #78 concrete fix tests
# ---------------------------------------------------------------------------
class TestOcrConcreteFixes:
"""Concrete correctness fixes for Issue #78 OCR probe."""
def test_healthy_display_action_none_is_ready(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Row with display_action='none', no failures → ready."""
from paperforge.commands import probe as probe_mod
(tmp_path / "paperforge.json").write_text(
json.dumps({"system_dir": "99_System"}), encoding="utf-8",
)
monkeypatch.setenv("PADDLEOCR_API_TOKEN", "test-token")
class FakeRow:
status = "ok"
health = "green"
display_action = "none"
monkeypatch.setattr("paperforge.worker.ocr_maintenance.collect_maintenance_rows",
lambda v: [FakeRow()])
monkeypatch.setattr("paperforge.ocr_diagnostics.ocr_doctor",
lambda config=None, live=False: {"passed": True})
data = probe_mod.probe_ocr(tmp_path)
assert data["capability_state"] == "ready"
assert data["severity"] == "ok"
assert data["reason"]["code"] == "ocr.ready"
def test_running_rows_independent_from_actionable(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Running rows with display_action='retry_ocr' → needs_action with activity overlay."""
from paperforge.commands import probe as probe_mod
(tmp_path / "paperforge.json").write_text(
json.dumps({"system_dir": "99_System"}), encoding="utf-8",
)
monkeypatch.setenv("PADDLEOCR_API_TOKEN", "test-token")
class FakeRow:
status = "running"
health = "green"
display_action = "retry_ocr"
monkeypatch.setattr("paperforge.worker.ocr_maintenance.collect_maintenance_rows",
lambda v: [FakeRow()])
monkeypatch.setattr("paperforge.ocr_diagnostics.ocr_doctor",
lambda config=None, live=False: {"passed": True})
data = probe_mod.probe_ocr(tmp_path)
# Running status → activity overlay
assert data["activity_state"] == "running"
assert data["activity_label"] is not None
assert data["activity_progress"] is not None
# Independent actionable state preserved
assert data["capability_state"] == "needs_action"
assert data["reason"]["code"] == "ocr.quality_failures"
def test_collect_maintenance_rows_exception_returns_unknown(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""When collect_maintenance_rows raises → unknown/probe, not empty run."""
from paperforge.commands import probe as probe_mod
(tmp_path / "paperforge.json").write_text(
json.dumps({"system_dir": "99_System"}), encoding="utf-8",
)
monkeypatch.setenv("PADDLEOCR_API_TOKEN", "test-token")
monkeypatch.setattr("paperforge.ocr_diagnostics.ocr_doctor",
lambda config=None, live=False: {"passed": True})
def _raise(*args, **kwargs):
raise RuntimeError("boom")
monkeypatch.setattr("paperforge.worker.ocr_maintenance.collect_maintenance_rows", _raise)
data = probe_mod.probe_ocr(tmp_path)
assert data["capability_state"] == "unknown"
assert data["severity"] == "unknown"
assert data["reason"]["code"] == "ocr.probe_failed"
assert data["action"]["primary"]["verb"] == "probe"
class TestMemoryConcreteFixes:
"""Concrete correctness fixes for Issue #78 Memory probe."""
def test_get_memory_status_exception_returns_unknown(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""When get_memory_status raises → unknown/probe, not unavailable/rebuild."""
from paperforge.commands import probe as probe_mod
(tmp_path / "paperforge.json").write_text(
json.dumps({"system_dir": "99_System"}), encoding="utf-8",
)
def _raise(*args, **kwargs):
raise RuntimeError("db broken")
monkeypatch.setattr("paperforge.memory.query.get_memory_status", _raise)
data = probe_mod.probe_memory(tmp_path)
assert data["capability_state"] == "unknown"
assert data["severity"] == "unknown"
assert data["reason"]["code"] == "memory.probe_failed"
assert data["action"]["primary"]["verb"] == "probe"
class TestOcrPriorityOrdering:
"""OCR probe priority: redo > run > rebuild > investigate (Issue #78 repair)."""
def test_pending_overrides_provider_unreachable(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Pending + provider unreachable -> needs_action/run, NOT limited/investigate."""
from paperforge.commands import probe as probe_mod
(tmp_path / "paperforge.json").write_text(
json.dumps({"system_dir": "99_System"}), encoding="utf-8",
)
monkeypatch.setenv("PADDLEOCR_API_TOKEN", "test-token")
monkeypatch.setattr("paperforge.ocr_diagnostics.ocr_doctor",
lambda config=None, live=False: {"passed": False, "error": "unreachable"})
class FakePendingRow:
status = "pending"
health = "green"
display_action = "none"
monkeypatch.setattr("paperforge.worker.ocr_maintenance.collect_maintenance_rows",
lambda v: [FakePendingRow()])
data = probe_mod.probe_ocr(tmp_path)
# Pending (run) beats provider unreachable (investigate)
assert data["capability_state"] == "needs_action"
assert data["reason"]["code"] == "ocr.pending"
assert data["action"]["primary"]["verb"] == "run"
def test_degraded_overrides_unexpected(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Degraded + unexpected action -> needs_action/rebuild, NOT limited/investigate."""
from paperforge.commands import probe as probe_mod
(tmp_path / "paperforge.json").write_text(
json.dumps({"system_dir": "99_System"}), encoding="utf-8",
)
monkeypatch.setenv("PADDLEOCR_API_TOKEN", "test-token")
monkeypatch.setattr("paperforge.ocr_diagnostics.ocr_doctor",
lambda config=None, live=False: {"passed": True})
class FakeDegradedRow:
status = "done"
health = "yellow"
display_action = "rebuild_result"
display_severity = "actionable"
class FakeUnexpectedRow:
status = "done"
health = "green"
display_action = "future_action"
display_severity = "actionable"
monkeypatch.setattr("paperforge.worker.ocr_maintenance.collect_maintenance_rows",
lambda v: [FakeDegradedRow(), FakeUnexpectedRow()])
data = probe_mod.probe_ocr(tmp_path)
# Degraded (rebuild) beats unexpected (investigate)
assert data["capability_state"] == "needs_action"
assert data["reason"]["code"] == "ocr.artifacts_stale"
assert data["action"]["primary"]["verb"] == "rebuild_derived"
def test_redo_overrides_pending(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Redo + pending rows -> redo action, NOT run."""
from paperforge.commands import probe as probe_mod
(tmp_path / "paperforge.json").write_text(
json.dumps({"system_dir": "99_System"}), encoding="utf-8",
)
monkeypatch.setenv("PADDLEOCR_API_TOKEN", "test-token")
monkeypatch.setattr("paperforge.ocr_diagnostics.ocr_doctor",
lambda config=None, live=False: {"passed": True})
class FakeRedoPendingRow:
status = "pending"
health = "red"
display_action = "retry_ocr"
monkeypatch.setattr("paperforge.worker.ocr_maintenance.collect_maintenance_rows",
lambda v: [FakeRedoPendingRow()])
data = probe_mod.probe_ocr(tmp_path)
# Redo (retry_ocr) beats pending (run)
assert data["capability_state"] == "needs_action"
assert data["reason"]["code"] == "ocr.quality_failures"
assert data["action"]["primary"]["verb"] == "redo"
class TestOcrRebuildResultNonDestructive:
"""rebuild_result rows must never become destructive redo (Issue #78 repair)."""
def test_red_health_rebuild_result_is_rebuild_not_redo(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Red health + display_action='rebuild_result' -> rebuild_derived, NOT redo."""
from paperforge.commands import probe as probe_mod
(tmp_path / "paperforge.json").write_text(
json.dumps({"system_dir": "99_System"}), encoding="utf-8",
)
monkeypatch.setenv("PADDLEOCR_API_TOKEN", "test-token")
monkeypatch.setattr("paperforge.ocr_diagnostics.ocr_doctor",
lambda config=None, live=False: {"passed": True})
class FakeRebuildRedRow:
status = "done"
health = "red"
display_action = "rebuild_result"
monkeypatch.setattr("paperforge.worker.ocr_maintenance.collect_maintenance_rows",
lambda v: [FakeRebuildRedRow()])
data = probe_mod.probe_ocr(tmp_path)
# rebuild_result must never become redo, even with red health
assert data["capability_state"] == "needs_action"
assert data["reason"]["code"] == "ocr.artifacts_stale"
assert data["action"]["primary"]["verb"] == "rebuild_derived"
assert data["action"]["primary"]["destructive"] == False
class TestOcrActivityProgress:
"""OCR activity progress current = terminal/completed count, not running/queued."""
def test_queued_only_batch_progress_zero_of_N(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""All queued -> progress current=0, total=N."""
from paperforge.commands import probe as probe_mod
(tmp_path / "paperforge.json").write_text(
json.dumps({"system_dir": "99_System"}), encoding="utf-8",
)
monkeypatch.setenv("PADDLEOCR_API_TOKEN", "test-token")
monkeypatch.setattr("paperforge.ocr_diagnostics.ocr_doctor",
lambda config=None, live=False: {"passed": True})
rows = []
for i in range(5):
class FakeQueuedRow:
status = "queued"
health = "green"
display_action = "none"
rows.append(FakeQueuedRow())
monkeypatch.setattr("paperforge.worker.ocr_maintenance.collect_maintenance_rows",
lambda v: rows)
data = probe_mod.probe_ocr(tmp_path)
assert data["activity_state"] == "running"
assert data["activity_progress"] is not None
assert data["activity_progress"]["current"] == 0 # no completed rows
assert data["activity_progress"]["total"] == 5
assert "0/5" in data["activity_label"]
def test_mixed_done_queued_progress_completed_count(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""3 done_degraded + 2 queued -> progress current=3, total=5."""
from paperforge.commands import probe as probe_mod
(tmp_path / "paperforge.json").write_text(
json.dumps({"system_dir": "99_System"}), encoding="utf-8",
)
monkeypatch.setenv("PADDLEOCR_API_TOKEN", "test-token")
monkeypatch.setattr("paperforge.ocr_diagnostics.ocr_doctor",
lambda config=None, live=False: {"passed": True})
rows = []
for i in range(3):
class FakeDoneRow:
status = "done_degraded"
health = "yellow"
display_action = "rebuild_result"
rows.append(FakeDoneRow())
for i in range(2):
class FakeQueuedRow:
status = "queued"
health = "green"
display_action = "none"
rows.append(FakeQueuedRow())
monkeypatch.setattr("paperforge.worker.ocr_maintenance.collect_maintenance_rows",
lambda v: rows)
data = probe_mod.probe_ocr(tmp_path)
assert data["activity_state"] == "running"
assert data["activity_progress"] is not None
assert data["activity_progress"]["current"] == 3 # 3 completed rows
assert data["activity_progress"]["total"] == 5
assert "3/5" in data["activity_label"]