mirror of
https://github.com/lllin000/PaperForge.git
synced 2026-07-22 06:50:53 +00:00
feat: expose library OCR and memory capability tracers (#78)
This commit is contained in:
parent
6dfb209de4
commit
69a6223910
14 changed files with 3047 additions and 274 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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: "修复运行时",
|
||||
|
|
|
|||
|
|
@ -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 = "";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
782
paperforge/plugin/tests/module-detail-navigation.test.ts
Normal file
782
paperforge/plugin/tests/module-detail-navigation.test.ts
Normal 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");
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
226
tests/test_memory_restore.py
Normal file
226
tests/test_memory_restore.py
Normal 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"
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
Loading…
Reference in a new issue