feat: add OCR rebuild UX and capability tracer

This commit is contained in:
LLLin000 2026-07-15 20:36:15 +08:00
parent e56a5faf44
commit 85a7ae30a2
12 changed files with 2415 additions and 287 deletions

View file

@ -1,13 +1,13 @@
> **Branch:** `feat/ocr-rebuild-ux` | **Last Updated:** 2026-07-15
> **Active work:** [Control-center PRD #74](https://github.com/LLLin000/PaperForge/issues/74) is split into eight dependency-linked implementation issues (#75#82). The first slice, canonical setup/config migration (#75), is implemented and reviewed; Installation capability integration (#76) is the next unblocked issue.
> **Active work:** [Control-center PRD #74](https://github.com/LLLin000/PaperForge/issues/74) is split into eight dependency-linked implementation issues (#75#82). Canonical setup/config migration (#75) and the Installation/Help capability tracer (#76) are implemented; Managed Runtime lifecycle (#77) is the next production slice.
>
> ---
>
> **Current state:** Retrieval recovery is merged and live. OCR multi-key rebuild/redo streams progress and refreshes canonical maintenance state. The control-plane capability vocabulary, managed-runtime architecture, prototypes, migration contract, and PRD are resolved. The first production slice now routes bare, headless, and modular setup through one `SetupPlan`, writes schema-v2 `vault_config`, reads legacy path keys only as warned fallback, forwards all configured paths, and returns non-zero on required-step failure.
> **Current state:** Retrieval recovery is merged and live. OCR multi-key rebuild/redo streams progress and refreshes canonical maintenance state. The control plane now has schema-v1 capability envelopes from Python through the Obsidian settings surface: Installation and Help use real probes, stale/malformed cached evidence fails closed, and Library/OCR/Memory/Maintenance remain explicit placeholders for later tracers.
>
> #71/#72 prototypes and #73 migration contract are complete. PRD #74 defines the six-module production program and #75#82 form its native dependency graph. Issue #75 passed focused verification and independent spec/quality review.
> #71/#72 prototypes and #73 migration contract are complete. PRD #74 defines the six-module production program; #75 and #76 are implemented. The approved navigation refinement preserves the Wayfinder overview and stages `概览 / 模块详情 / 维护 / 帮助` across #77/#78/#80 instead of creating dead placeholder detail pages.
>
> Next: start [#76](https://github.com/LLLin000/PaperForge/issues/76) in a fresh Matt `/implement` session; do not continue in this context.
> Next: implement [#77](https://github.com/LLLin000/PaperForge/issues/77) in a fresh Matt `/implement` session.
## 1. Architecture
### 1.1 The problem (pre-v2)
@ -59,6 +59,7 @@ raw observations → structural signatures → stable anchors/families → zone
| Live Literature-hub maintenance UI | **734 All / 700 Recommended; no captured errors** ✅ |
| Maintenance regression action model | **19/19 passed** ✅ (per-row canonical action routing, redo confirmation gate, cache manifest preservation) |
| Canonical setup/config migration (#75) | **61 passed, 0 failed** ✅ (fresh/v1/v2 config, CLI routing, path forwarding, failure exit, idempotent rerun) |
| Installation/Help capability tracer (#76) | **21 backend tests + 169 plugin tests passed; typecheck/build clean; independent review PASS** ✅ |
</br>
</br>
@ -83,6 +84,7 @@ raw observations → structural signatures → stable anchors/families → zone
| **Vector build control** | ✅ canonical SQLite lifecycle and health state restored |
| **Control-plane contracts** | ✅ Orthogonal capability/activity/attention model and managed-runtime immutable-slot architecture chosen; documented in `docs/research/2026-07-14-capability-state-action-contract.md` and `docs/research/2026-07-14-managed-runtime-architecture.md` |
| **Setup/config migration** | ✅ Bare, headless, and modular setup share `SetupPlan`; schema-v2 `vault_config` is authoritative with warned v1 read fallback |
| **Capability tracer** | ✅ Installation/Help backend envelopes feed the six-module Overview; malformed/stale persistence fails closed; setup/update actions route to the setup flow |
### 2.3 Fix Status
@ -148,7 +150,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. The orthogonal capability/activity/attention model (`docs/research/2026-07-14-capability-state-action-contract.md`) and managed-runtime immutable-slot architecture (`docs/research/2026-07-14-managed-runtime-architecture.md`) are chosen, unblocking #71/#72 prototypes. The remaining control-plane contract gaps are recorded in `docs/research/2026-07-14-control-center-contract-audit.md`.
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. Issue #76 now provides the first production capability-envelope tracer: real Installation/Help probes, a six-module Overview, strict persisted-envelope validation, stale fail-closed behavior, and backend-owned actions. Library/OCR/Memory/Maintenance remain explicit placeholders until #78/#80. The approved Wayfinder navigation refinement is recorded on PRD #74 and staged across #77/#78/#80.
### 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).
@ -156,7 +158,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 is complete; #76 is the next unblocked production tracer.
1. 🔴 **[Control-center PRD #74](https://github.com/LLLin000/PaperForge/issues/74)** — published and split into eight native dependency-linked issues (#75#82). #75 and #76 are implemented; #77 Managed Runtime lifecycle is the next unblocked production tracer.
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.
@ -181,7 +183,8 @@ Remaining legacy OCR issues (carried forward):
- [x] Design actionable-only maintenance inbox ([#72](https://github.com/LLLin000/PaperForge/issues/72))
- [x] Publish PRD #74 and eight agent-ready issues (#75#82) with native dependencies
- [x] Canonicalize setup/config migration ([#75](https://github.com/LLLin000/PaperForge/issues/75)) — 61 focused tests; spec PASS; quality APPROVED
- [ ] Implement Installation/Help capability tracer in a fresh session ([#76](https://github.com/LLLin000/PaperForge/issues/76))
- [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
- [ ] Implement Managed Runtime lifecycle and the approved Installation-detail navigation shell ([#77](https://github.com/LLLin000/PaperForge/issues/77))
---
## 5. Key File Map
@ -336,6 +339,8 @@ Remaining legacy OCR issues (carried forward):
| 2026-07-15 | Issue-draft flow is confirmation-first, inline redacted, never auto-submitted | User reviews a prefilled GitHub draft panel inline, edits freely, then clicks "Open GitHub" to create. No token storage, no automatic submission. Docker-style opaque upload rejected. |
| 2026-07-15 | Prototype reviews use independent Critical/Important pass/fail gates | Both #71 and #72 prototypes were reviewed by independent reviewer subagents on two dimensions: Critical items (correctness/safety) and Important items (readability/maintainability). All items passed before acceptance. |
| 2026-07-15 | Canonical setup is one `SetupPlan`; configuration writes converge to schema v2 | Duplicate headless/modular/bare engines dropped path inputs and disagreed on success. One engine plus `vault_config`-first reads makes migration observable, idempotent, and reversible through the warned v1 read fallback. |
| 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. |
---
@ -438,6 +443,7 @@ python -m ruff check paperforge/worker/ocr_*.py
| 2026-07-14 | Obsidian and desktop recovery research | Closed #67 and #68 with primary-source pattern reports. Preserved the six-module IA, independent capability probes, Obsidian-managed plugin updates, module-scoped recovery, local diagnostics, and user-reviewed issue drafts; unblocked #70. | [#67](https://github.com/LLLin000/PaperForge/issues/67#issuecomment-4970653461), [#68](https://github.com/LLLin000/PaperForge/issues/68#issuecomment-4970660288) |
| 2026-07-15 | Six-module control-center + maintenance inbox prototypes completed | Closed #71 (six-module HTML prototype, 5 scenarios, plain-button switcher, responsive 768px) with independent Critical/Important PASS review and browser verification. Closed #72 (actionable-only inbox, inline issue-draft review, confirmation-first report) with same review gate. Both prototype pairs passed all review dimensions. No production code changed. | `docs/prototypes/2026-07-14-six-module-control-center.{html,md}`, `docs/prototypes/2026-07-14-maintenance-issue-reporting.{html,md}` |
| 2026-07-15 | Control-center PRD split + first production slice | Published PRD #74 and eight native dependency-linked issues (#75#82). Implemented #75: one SetupPlan for all setup entry points, schema-v2 `vault_config`, warned v1 read fallback, complete path forwarding, visible deprecation, and non-zero required-step failures. Verification: 61/61 focused tests; independent Spec PASS / Quality APPROVED. | [PRD #74](https://github.com/LLLin000/PaperForge/issues/74), [Issue #75](https://github.com/LLLin000/PaperForge/issues/75) |
| 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) |
## 9. Historical Detail Archive

View file

@ -479,6 +479,19 @@ def build_parser() -> argparse.ArgumentParser:
p_scoped_fetch.add_argument("--json", action="store_true", help="Output JSON")
p_scoped_fetch.add_argument("--limit", type=int, default=5, help="Max results (default 5)")
# probe
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)",
)
p_probe.add_argument(
"--json",
action="store_true",
help="Output as schema-v1 capability envelope JSON",
)
return parser
@ -529,6 +542,7 @@ def main(argv: list[str] | None = None) -> int:
"project-log", "search", "agent-context", "runtime-health", "doctor",
"update", "setup", "selection-sync", "index-refresh", "base-refresh",
"paper-lookup", "content-discovery", "paper-navigation", "scoped-fetch",
"probe",
}
if args.command in lightweight_commands:
_resolve_pipeline()
@ -761,6 +775,11 @@ def main(argv: list[str] | None = None) -> int:
)
return plan.execute(json_output=getattr(args, "json_output", False))
if args.command == "probe":
from paperforge.commands.probe import run as run_probe
return run_probe(args)
print(f"Error: unknown command {args.command}", file=sys.stderr)
return 1

View file

@ -0,0 +1,297 @@
"""paperforge.commands.probe — Capability probe command (Issue #76, 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.
"""
from __future__ import annotations
import json
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
SCHEMA_VERSION = 1
TTL_INSTALLATION = 3600
TTL_HELP = 3600
MIN_PYTHON = (3, 11)
LEGACY_PATH_KEYS = frozenset({
"system_dir", "resources_dir", "literature_dir",
"base_dir", "control_dir", "skill_dir", "command_dir",
})
SUPPORTED_MODULES = frozenset({"installation", "help"})
# ---------------------------------------------------------------------------
# Envelope builder
# ---------------------------------------------------------------------------
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,
) -> 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,
}
def build_envelope(
*,
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,
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,
}
# ---------------------------------------------------------------------------
# Config validation helper
# ---------------------------------------------------------------------------
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:
return True
return any(k in data for k in LEGACY_PATH_KEYS)
# ---------------------------------------------------------------------------
# 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",
),
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",
),
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",
),
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",
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="",
),
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,
)
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,
)
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,
)
# ---------------------------------------------------------------------------
# CLI dispatch
# ---------------------------------------------------------------------------
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 == "help":
envelope = probe_help(vault)
else:
print(f"Error: unsupported probe module '{module}'", file=sys.stderr)
return 1
json_output: bool = getattr(args, "json", False)
if json_output:
print(json.dumps(envelope, indent=2, ensure_ascii=False))
else:
state = envelope["capability_state"]
reason = envelope["reason"]["text"]
print(f"[{module}] {state}: {reason}")
return 0

File diff suppressed because one or more lines are too long

View file

@ -88,6 +88,7 @@ export interface PaperForgeSettings {
vector_db_api_base: string;
vector_db_api_model: string;
last_seen_version: string;
capabilityState: Record<string, ProbeEnvelope>;
_python_path_stale?: boolean;
[key: string]: unknown;
}
@ -115,6 +116,7 @@ export const DEFAULT_SETTINGS: PaperForgeSettings = {
resources_dir: "",
literature_dir: "",
base_dir: "",
capabilityState: {},
last_seen_version: "",
};
@ -141,3 +143,304 @@ export function overlayEntryWorkflowState(app: any, entry: any): WorkflowState {
export function patchEntryWorkflowState(entry: any, patch: Partial<WorkflowState>): any {
return entry ? { ...entry, ...patch } : entry;
}
// ── Capability probe types (schema-v1) ──
export const SCHEMA_VERSION = 1;
export type CapabilityModule = "installation" | "help" | "library" | "ocr" | "memory" | "maintenance";
export const CAPABILITY_MODULES: readonly CapabilityModule[] = [
"installation",
"library",
"ocr",
"memory",
"maintenance",
"help",
] as const;
export type CapabilityState = "unknown" | "unavailable" | "missing_input" | "needs_action" | "limited" | "ready";
export type ActivityState = "idle" | "running";
export type Severity = "unknown" | "ok" | "warning" | "error";
export interface ActionPrimary {
verb: string;
label: string;
destructive: boolean;
destructive_scope: string | null;
destructive_effect: string | null;
confirmation_required: boolean;
confirmation_prompt: string | null;
command: string;
scope: string;
scope_count: number;
}
export interface ProbeReason {
code: string;
text: string;
}
export interface ProbeAction {
primary: ActionPrimary | null;
}
export interface ProbeNotice {
level: string;
message: string;
}
export interface ProbeActivityProgress {
current: number;
total: number;
}
export interface ProbeEnvelope {
schema_version: number;
module: string;
capability_state: CapabilityState;
activity_state: ActivityState;
activity_label: string | null;
activity_progress: ProbeActivityProgress | null;
severity: Severity;
reason: ProbeReason;
action: ProbeAction;
notices: ProbeNotice[];
updated_at: string;
ttl_seconds: number;
}
const VALID_CAPABILITY_STATES: readonly string[] = ["unknown", "unavailable", "missing_input", "needs_action", "limited", "ready"];
const VALID_SEVERITIES: readonly string[] = ["unknown", "ok", "warning", "error"];
const VALID_ACTIVITY_STATES: readonly string[] = ["idle", "running"];
function isValidActionPrimary(p: unknown): p is ActionPrimary {
if (!p || typeof p !== "object" || Array.isArray(p)) return false;
const a = p as Record<string, unknown>;
if (typeof a.verb !== "string") return false;
if (typeof a.label !== "string") return false;
if (typeof a.destructive !== "boolean") return false;
if (a.destructive_scope !== null && typeof a.destructive_scope !== "string") return false;
if (a.destructive_effect !== null && typeof a.destructive_effect !== "string") return false;
if (typeof a.confirmation_required !== "boolean") return false;
if (a.confirmation_prompt !== null && typeof a.confirmation_prompt !== "string") return false;
if (typeof a.command !== "string") return false;
if (typeof a.scope !== "string") return false;
if (typeof a.scope_count !== "number") return false;
return true;
}
/** Build a full probe action (non-destructive, probe verb). */
export function probeAction(module: string): ActionPrimary {
return {
verb: "probe",
label: "Check",
destructive: false,
destructive_scope: null,
destructive_effect: null,
confirmation_required: false,
confirmation_prompt: null,
command: `probe ${module}`,
scope: module,
scope_count: 1,
};
}
/** Build a full setup action. */
export function setupAction(): ActionPrimary {
return {
verb: "setup",
label: "Open Setup Wizard",
destructive: false,
destructive_scope: null,
destructive_effect: null,
confirmation_required: false,
confirmation_prompt: null,
command: "setup",
scope: "installation",
scope_count: 1,
};
}
/**
* Validate that a parsed JSON value is a valid ProbeEnvelope.
* When expectedModule is supplied, also checks module field matches.
* Never reconstructs or coerces passes validated object through unchanged.
*/
export function isValidEnvelope(raw: unknown, expectedModule?: string): raw is ProbeEnvelope {
if (!raw || typeof raw !== "object") return false;
const e = raw as Record<string, unknown>;
if (e.schema_version !== 1) return false;
if (typeof e.module !== "string" || !e.module) return false;
if (!CAPABILITY_MODULES.includes(e.module as CapabilityModule)) return false;
if (expectedModule !== undefined && e.module !== expectedModule) return false;
if (typeof e.capability_state !== "string" || !VALID_CAPABILITY_STATES.includes(e.capability_state)) return false;
if (typeof e.severity !== "string" || !VALID_SEVERITIES.includes(e.severity)) return false;
if (typeof e.activity_state !== "string" || !VALID_ACTIVITY_STATES.includes(e.activity_state)) return false;
// activity_label: null or string (required)
if (e.activity_label !== null && typeof e.activity_label !== "string") return false;
// activity_progress: null or {current:number, total:number} (required)
if (e.activity_progress !== null) {
if (typeof e.activity_progress !== "object") return false;
const ap = e.activity_progress as Record<string, unknown>;
if (typeof ap.current !== "number" || typeof ap.total !== "number") return false;
}
if (!Array.isArray(e.notices)) return false;
if (!e.reason || typeof e.reason !== "object") return false;
const r = e.reason as Record<string, unknown>;
if (typeof r.code !== "string" || typeof r.text !== "string") return false;
if (!e.action || typeof e.action !== "object") return false;
const a = e.action as Record<string, unknown>;
if (a.primary !== null && !isValidActionPrimary(a.primary)) return false;
if (typeof e.updated_at !== "string" || !e.updated_at) return false;
if (typeof e.ttl_seconds !== "number") return false;
return true;
}
/** Unknown envelope — all modules get verb=probe; setup verb is backend-only. */
export function createUnknownEnvelope(module: CapabilityModule): ProbeEnvelope {
return {
schema_version: SCHEMA_VERSION, module,
capability_state: "unknown",
activity_state: "idle",
activity_label: null,
activity_progress: null,
severity: "unknown",
reason: { code: `${module}.no_probe`, text: `${module} has not been probed yet.` },
action: { primary: probeAction(module) },
notices: [],
updated_at: new Date(0).toISOString(),
ttl_seconds: 0,
};
}
export function createStaleEnvelope(module: CapabilityModule): ProbeEnvelope {
return {
schema_version: SCHEMA_VERSION, module,
capability_state: "unknown",
activity_state: "idle",
activity_label: null,
activity_progress: null,
severity: "unknown",
reason: { code: `${module}.stale`, text: `Cached probe data for ${module} is stale.` },
action: { primary: probeAction(module) },
notices: [],
updated_at: new Date(0).toISOString(),
ttl_seconds: 0,
};
}
export function createInvalidEnvelope(module: CapabilityModule): ProbeEnvelope {
return {
schema_version: SCHEMA_VERSION, module,
capability_state: "unknown",
activity_state: "idle",
activity_label: null,
activity_progress: null,
severity: "unknown",
reason: { code: `${module}.invalid_response`, text: `Probe response for ${module} was invalid.` },
action: { primary: probeAction(module) },
notices: [],
updated_at: new Date(0).toISOString(),
ttl_seconds: 0,
};
}
export function isEnvelopeStale(e: ProbeEnvelope): boolean {
// Running state is always fresh — don't erase in-progress probes
if (e.activity_state === "running") return false;
if (e.ttl_seconds <= 0) return true;
const updated = new Date(e.updated_at).getTime();
if (isNaN(updated)) return true;
return Date.now() - updated > e.ttl_seconds * 1000;
}
export function isReadyEnvelope(e: ProbeEnvelope): boolean {
return e.capability_state === "ready" && e.action.primary === null;
}
/**
* Classify a capability envelope's primary action.
* - 'set_config' and 'update' verbs 'setup' kind (dispatch to setup flow)
* - 'probe' verb 'probe' kind (trigger a re-probe)
* - all others 'action' kind (execute the action directly)
*/
export function classifyCapabilityAction(
envelope: ProbeEnvelope
): { kind: 'setup' | 'probe' | 'action'; verb: string; label: string } {
const primary = envelope.action?.primary;
const verb = primary?.verb ?? 'probe';
const label = primary?.label ?? verb;
if (verb === 'setup' || verb === 'set_config' || verb === 'update') {
return { kind: 'setup', verb, label };
}
if (verb === 'probe') {
return { kind: 'probe', verb, label };
}
return { kind: 'action', verb, label };
}
/**
* Compute a summary from a capability-state map.
* Returns { coreReady: true } when all REAL_PROBED_MODULES are 'ready',
* otherwise { coreReady: false, attentionModules: [...] } with unknown
* degraded modules counted as attention.
*/
export function computeModuleSummary(
stateMap: Record<string, ProbeEnvelope>,
realModules: readonly string[]
): { coreReady: boolean; attentionModules: string[] } {
const attentionModules: string[] = [];
for (const mod of realModules) {
const env = stateMap[mod];
if (!env || !isReadyEnvelope(env)) {
attentionModules.push(mod);
}
}
return {
coreReady: attentionModules.length === 0,
attentionModules,
};
}
/**
* Validate a persisted capability-state map before render.
* - Missing required modules replaced with unknown envelope
* - Malformed entries (failing isValidEnvelope) replaced with invalid envelope
* - Stale entries (past TTL) replaced with stale envelope via createStaleEnvelope
* Valid entries pass through unchanged.
*/
export function validatePersistedEnvelopes(
stateMap: Record<string, unknown>,
allModules: readonly string[]
): Record<string, ProbeEnvelope> {
const result: Record<string, ProbeEnvelope> = {};
for (const mod of allModules) {
const raw = stateMap[mod];
if (!raw || typeof raw !== 'object') {
result[mod] = createUnknownEnvelope(mod as CapabilityModule);
continue;
}
if (!isValidEnvelope(raw, mod)) {
result[mod] = createInvalidEnvelope(mod as CapabilityModule);
continue;
}
if (isEnvelopeStale(raw as ProbeEnvelope)) {
result[mod] = createStaleEnvelope(mod as CapabilityModule);
continue;
}
result[mod] = raw as ProbeEnvelope;
}
return result;
}

View file

@ -332,8 +332,69 @@ const LANG: Record<string, Record<string, string>> = {
retrieval_force_rebuild: "Force Rebuild",
retrieval_rebuild_warning:
"Rebuild will replace {n} existing chunk(s). Continue?",
// ── Control Center (Issue #76) ──
cc_summary_ok: "All systems ready",
cc_summary_core_ok: "Core environment ready; {n} modules pending detection",
cc_summary_attention: "Some modules need attention",
cc_summary_ok_body: "PaperForge environment is fully operational. Installation and documentation are verified.",
cc_summary_core_ok_body: "Installation and Help modules are active. Library, OCR, Memory, and Maintenance will show live status once their backends are connected.",
cc_summary_attention_body: "One or more core modules require your attention to function properly.",
cc_badge_ok: "Ready",
cc_badge_pending: "Pending",
cc_badge_setup: "Setup needed",
cc_badge_attention: "Needs attention",
cc_diagnostic_toggle: "Details",
cc_n_ready: "{n} ready",
cc_n_pending: "{n} pending",
cc_title: "System Status",
cc_desc:
"Real-time status of PaperForge core modules. Modules with a pending action need your attention.",
cc_zone_attention: "Needs Attention",
cc_zone_modules: "All Modules",
cc_module_installation: "Installation",
cc_module_help: "Help & Docs",
cc_module_library: "Library Index",
cc_module_ocr: "OCR Engine",
cc_module_memory: "Memory Layer",
cc_module_maintenance: "Maintenance",
cc_state_ready: "Ready",
cc_state_limited: "Limited",
cc_state_unavailable: "Unavailable",
cc_state_unknown: "Unknown",
cc_severity_ok: "OK",
cc_severity_unknown: "Unknown",
cc_severity_warning: "Warning",
cc_severity_error: "Error",
cc_state_missing_input: "Missing Input",
cc_state_needs_action: "Needs Action",
cc_action_setup: "Open Setup Wizard",
cc_action_probe: "Check",
cc_action_set_config: "Set Config",
cc_action_update: "Update",
cc_reason_installation_ready: "PaperForge environment is set up correctly.",
cc_reason_config_missing: "Configuration file is missing. Run setup to create one.",
cc_reason_config_corrupt: "Configuration file is corrupt. Run setup to repair.",
cc_reason_python_version_unsupported: "Python version is not supported. Install Python 3.11+.",
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_diag_module: "Module",
cc_diag_state: "State",
cc_diag_severity: "Severity",
cc_diag_activity: "Activity",
cc_diag_reason: "Reason",
cc_diag_ttl: "TTL",
cc_diag_updated: "Updated",
cc_reason_no_probe: "{module} has not been probed yet.",
cc_reason_stale: "Cached probe data for {module} is stale.",
cc_reason_invalid_response: "Probe response for {module} was invalid.",
cc_reason_probing: "Checking {module} status...",
cc_notice_placeholder: "{module} module probing is not yet available; it will be added in a future update.",
cc_notice_refreshed: "Module status refreshed.",
cc_notice_refresh_failed: "Failed to refresh module status.",
cc_activity_idle: "Idle",
cc_activity_running: "Running",
},
zh: {
action_running: "正在执行 ",
api_key_missing: "未配置 ✗",
@ -627,6 +688,10 @@ const LANG: Record<string, Record<string, string>> = {
retrieval_build_idle: "向量数据库就绪",
retrieval_build_ready: "已构建 {n} 个向量",
retrieval_build_stopping: "正在停止...",
cc_summary_attention: "部分模块需要关注",
cc_summary_ok_body: "PaperForge 环境已完全就绪。安装和帮助文档均正常可用。",
cc_summary_core_ok_body: "安装和帮助模块已可用。文献索引、OCR、记忆层和维护模块将在后端接入后显示实时状态。",
cc_summary_attention_body: "一个或多个核心模块需要您的关注才能正常运行。",
retrieval_build_stopped: "构建已停止({n}/{t} 篇)",
retrieval_build_failed: "构建失败",
retrieval_build_stale: "向量已过期 — 建议重建",
@ -637,6 +702,63 @@ const LANG: Record<string, Record<string, string>> = {
retrieval_internal_error: "发生内部错误",
retrieval_force_rebuild: "强制重建",
retrieval_rebuild_warning: "重建将替换 {n} 个现有文本块,是否继续?",
cc_summary_ok: "全部正常",
cc_summary_core_ok: "核心环境正常;{n} 个模块状态检测待接入",
cc_badge_ok: "已就绪",
cc_badge_pending: "待接入",
cc_badge_setup: "需要安装",
cc_badge_attention: "需要注意",
cc_diagnostic_toggle: "详情",
cc_n_ready: "{n} 已就绪",
cc_n_pending: "{n} 待接入",
// ── Control Center (Issue #76) ──
cc_title: "系统状态",
cc_desc: "PaperForge 核心模块的实时状态。有待处理操作的模块需要您的关注。",
cc_zone_attention: "需要关注",
cc_zone_modules: "所有模块",
cc_module_installation: "安装",
cc_module_help: "帮助与文档",
cc_module_library: "文献索引",
cc_module_ocr: "OCR 引擎",
cc_module_memory: "记忆层",
cc_module_maintenance: "维护",
cc_state_ready: "就绪",
cc_state_limited: "受限",
cc_state_unavailable: "不可用",
cc_state_unknown: "未知",
cc_severity_ok: "正常",
cc_severity_unknown: "未知",
cc_severity_warning: "警告",
cc_severity_error: "错误",
cc_state_missing_input: "缺少输入",
cc_state_needs_action: "需要操作",
cc_action_setup: "打开安装向导",
cc_action_probe: "检测",
cc_action_set_config: "配置设置",
cc_action_update: "更新",
cc_reason_installation_ready: "PaperForge 环境已正确配置。",
cc_reason_config_missing: "配置文件缺失,请运行安装向导。",
cc_reason_config_corrupt: "配置文件损坏,请运行安装向导修复。",
cc_reason_python_version_unsupported: "Python 版本不受支持,请安装 Python 3.11+。",
cc_reason_help_ready: "帮助文档已可用。",
cc_reason_docs_missing: "帮助文档尚未安装。",
cc_reason_placeholder: "状态检测待接入,接入后将显示实时状态。",
cc_diag_module: "模块",
cc_diag_state: "状态",
cc_diag_severity: "严重程度",
cc_diag_activity: "活动",
cc_diag_reason: "原因",
cc_diag_ttl: "TTL",
cc_diag_updated: "更新时间",
cc_reason_no_probe: "{module} 尚未检测。",
cc_reason_stale: "{module} 的检测数据已过期。",
cc_reason_invalid_response: "{module} 的检测响应无效。",
cc_reason_probing: "正在检测 {module} 状态...",
cc_notice_placeholder: "{module} 模块检测功能尚未可用,将在后续版本中添加。",
cc_notice_refreshed: "模块状态已刷新。",
cc_notice_refresh_failed: "模块状态刷新失败。",
cc_activity_idle: "空闲",
cc_activity_running: "运行中",
},
};

View file

@ -4,7 +4,7 @@ import * as path from "path";
import * as os from "os";
import { execFile, execFileSync, spawn, exec } from "child_process";
import { t, setLanguage } from "./i18n";
import { PaperForgeSettings } from "./constants";
import { PaperForgeSettings, ProbeEnvelope, CapabilityModule, CAPABILITY_MODULES, createUnknownEnvelope, createStaleEnvelope, createInvalidEnvelope, isValidEnvelope, isEnvelopeStale, isReadyEnvelope, probeAction, validatePersistedEnvelopes, classifyCapabilityAction } from "./constants";
import releaseNotesData from "./release-notes.json";
import {
resolvePythonExecutable,
@ -90,6 +90,12 @@ export class PaperForgeSettingTab extends PluginSettingTab {
total: 0,
key: "",
};
/** Cached capability probe envelopes, keyed by module name. */
private _capabilityState: Record<string, ProbeEnvelope> | null = null;
/** Tracks which modules are currently being probed. */
private _probing: Set<string> = new Set();
/** Modules that have already been auto-probed (prevents endless re-probe). */
private _attemptedProbes: Set<string> = new Set();
constructor(app: App, plugin: ISettingPlugin) {
super(app, plugin as any);
@ -105,6 +111,8 @@ export class PaperForgeSettingTab extends PluginSettingTab {
const { containerEl } = this;
containerEl.empty();
this._refreshPfConfig();
this._initCapabilityState();
this._applyStaleTolerance();
// Inject tab CSS once
if (!document.getElementById("paperforge-tab-styles")) {
@ -181,7 +189,6 @@ export class PaperForgeSettingTab extends PluginSettingTab {
this._renderReleaseNotesTab(tabContents["release-notes"]);
}
}
_renderSetupTab(containerEl: HTMLElement) {
const vaultPath = (this.app.vault.adapter as any).basePath as string;
if (!this.plugin.settings.vault_path) {
@ -189,259 +196,24 @@ export class PaperForgeSettingTab extends PluginSettingTab {
this._debouncedSave();
}
/* Validate setup_complete against paperforge.json */
if (this.plugin.settings.setup_complete) {
if (!fs.existsSync(path.join(vaultPath, "paperforge.json"))) {
this.plugin.settings.setup_complete = false;
this._debouncedSave();
}
}
/* Header */
/* Header — brief, not authoritative status (that's the control center) */
containerEl.createEl("h2", { text: t("header_title") || "PaperForge" });
containerEl.createEl("p", {
text: t("desc"),
cls: "paperforge-settings-desc",
});
/* Setup Status */
const statusRow = containerEl.createEl("div", {
cls: "paperforge-setup-bar",
});
const statusLabel = statusRow.createEl("span", {
cls: "paperforge-setup-label",
});
if (this.plugin.settings.setup_complete) {
statusLabel.setText(t("setup_done"));
statusLabel.addClass("paperforge-setup-done");
} else {
statusLabel.setText(t("setup_pending"));
statusLabel.addClass("paperforge-setup-pending");
}
// ── Control Center (Issue #76) ──
this._renderControlCenter(containerEl);
/* Python Interpreter Section */
const vaultPathForPython = (this.app.vault.adapter as any)
.basePath as string;
const pyResult = resolvePythonExecutable(
vaultPathForPython,
this.plugin.settings,
undefined,
undefined
);
const pyPath = pyResult.path;
const pySource = this.plugin.settings._python_path_stale
? "stale"
: pyResult.source;
const pyInterpSetting = new Setting(containerEl)
.setName(t("field_python_interp"))
.setDesc(this._getPythonDesc(pyPath, pySource));
this._pythonInterpDescEl = pyInterpSetting.descEl;
const customSetting = new Setting(containerEl)
.setName(t("field_python_custom"))
.setDesc("");
this._customPathDescEl = customSetting.descEl;
customSetting.addText((text) => {
text
.setPlaceholder("e.g. C:\\Python310\\python.exe")
.setValue(this.plugin.settings.python_path || "")
.onChange((value) => {
this.plugin.settings.python_path = value;
this.plugin.saveSettings();
if (value && value.trim()) {
const exists = fs.existsSync(value.trim());
this.plugin.settings._python_path_stale = !exists;
} else {
this.plugin.settings._python_path_stale = false;
}
const pyResult2 = resolvePythonExecutable(
(this.app.vault.adapter as any).basePath as string,
this.plugin.settings,
undefined,
undefined
);
const pySource2 = this.plugin.settings._python_path_stale
? "stale"
: pyResult2.source;
if (this._pythonInterpDescEl) {
this._pythonInterpDescEl.textContent = this._getPythonDesc(
pyResult2.path,
pySource2
);
}
});
});
customSetting.addButton((btn) => {
btn
.setButtonText(t("btn_validate"))
.onClick(() => this._validatePythonOverride());
});
/* Runtime Health Section */
containerEl.createEl("h3", { text: t("runtime_health") });
containerEl.createEl("p", {
text: t("runtime_health_desc"),
cls: "paperforge-settings-desc",
});
const versionRow = new Setting(containerEl)
.setName("PaperForge")
.setDesc(t("runtime_health_checking"));
const badgeEl = versionRow.descEl.createEl("span", {
cls: "paperforge-runtime-badge",
});
let syncBtn: any = null;
versionRow.addButton((btn) => {
syncBtn = btn;
btn
.setButtonText(t("runtime_health_sync"))
.setDisabled(true)
.onClick(() => this._syncRuntime(btn));
});
{
const vp = (this.app.vault.adapter as any).basePath as string;
const { path: pythonExe, extraArgs = [] } = resolvePythonExecutable(
vp,
this.plugin.settings,
undefined,
undefined
);
const pluginVer = this.plugin.manifest.version || "?";
execFile(
pythonExe,
[
...extraArgs,
"-c",
"import paperforge; print(paperforge.__version__)",
],
{ cwd: vp, timeout: 10000 },
(err, stdout) => {
const setupDone = this.plugin.settings.setup_complete;
const pyVer = !err && stdout ? stdout.trim() : null;
const descText = pyVer
? `${t("runtime_health_plugin_ver").replace("{0}", pluginVer)} \u2192 ${t("runtime_health_package_ver").replace("{0}", pyVer)}`
: setupDone
? `Plugin v${pluginVer} \u2192 Python package not installed. Click "Sync Runtime" to install.`
: `Plugin v${pluginVer} \u2192 Not configured. Please open the setup wizard first.`;
versionRow.setDesc(descText);
if (pyVer === pluginVer) {
badgeEl.setText(t("runtime_health_match"));
badgeEl.className = "paperforge-runtime-badge match";
if (syncBtn) syncBtn.setDisabled(true);
} else if (pyVer) {
badgeEl.setText(t("runtime_health_mismatch"));
badgeEl.className = "paperforge-runtime-badge mismatch";
if (syncBtn) syncBtn.setDisabled(false);
} else {
badgeEl.setText(setupDone ? "Not installed" : "Setup needed");
badgeEl.className = "paperforge-runtime-badge missing";
if (syncBtn) syncBtn.setDisabled(false);
}
// Auto-probe never-probed/migrated modules once per session
for (const mod of CAPABILITY_MODULES) {
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") {
this._probeModule(mod);
}
);
}
/* Preparation Guide */
containerEl.createEl("h3", { text: t("section_prep") });
containerEl.createEl("p", {
text: t("section_prep_desc"),
cls: "paperforge-settings-desc",
});
const prep = containerEl.createEl("div", { cls: "paperforge-guide" });
const prepData = [
["prep_python", "prep_python_desc"],
["prep_zotero", "prep_zotero_desc"],
["prep_bbt", "prep_bbt_desc"],
["prep_key", "prep_key_desc"],
];
for (const [kTitle, kDesc] of prepData) {
const row = prep.createEl("div", { cls: "paperforge-guide-item" });
row.createEl("strong", { text: t(kTitle) });
row.createEl("span", { text: " \u2014 " + t(kDesc) });
}
/* Pre-check status area */
this._checkEl = containerEl.createEl("div", { cls: "paperforge-message" });
/* Install / Reconfigure Button */
const needSetup = !this.plugin.settings.setup_complete;
new Setting(containerEl)
.setName(t(needSetup ? "btn_install" : "btn_reconfig"))
.setDesc(t(needSetup ? "btn_install_desc" : "btn_reconfig_desc"))
.addButton((btn) => {
btn
.setButtonText(t(needSetup ? "btn_install" : "btn_reconfig"))
.setCta()
.onClick(() => {
if (!needSetup) {
new PaperForgeSetupModal(this.app, this.plugin).open();
} else {
this._preCheck(() => {
new PaperForgeSetupModal(this.app, this.plugin).open();
});
}
});
});
/* Operation Guide */
containerEl.createEl("h3", { text: t("section_guide") });
const guide = containerEl.createEl("div", { cls: "paperforge-guide" });
const guideData = [
["guide_open", "guide_open_desc"],
["guide_sync", "guide_sync_desc"],
["guide_ocr", "guide_ocr_desc"],
];
for (const [kTitle, kDesc] of guideData) {
const row = guide.createEl("div", { cls: "paperforge-guide-item" });
row.createEl("strong", { text: t(kTitle) });
row.createEl("span", { text: " \u2014 " + t(kDesc) });
}
/* Config Summary */
if (this.plugin.settings.setup_complete) {
containerEl.createEl("h3", { text: t("section_config") });
const summary = containerEl.createEl("div", {
cls: "paperforge-summary",
});
const s = this.plugin.settings;
const pf = this._pfConfig;
const items = [
{ label: t("dir_vault"), val: vaultPath },
{ label: t("dir_resources"), val: `${vaultPath}/${pf?.resources_dir}` },
{
label: " " + t("dir_notes"),
val: `${vaultPath}/${pf?.resources_dir}/${pf?.literature_dir}`,
},
{ label: t("dir_base"), val: `${vaultPath}/${pf?.base_dir}` },
{ label: t("dir_system"), val: `${vaultPath}/${pf?.system_dir}` },
{
label: "API Key",
val: s.paddleocr_api_key ? t("api_key_set") : t("api_key_missing"),
},
{
label: t("field_zotero_data"),
val: s.zotero_data_dir || t("not_set"),
},
];
for (const item of items) {
const row = summary.createEl("div", { cls: "paperforge-summary-row" });
row.createEl("span", {
cls: "paperforge-summary-label",
text: item.label,
});
row.createEl("span", {
cls: "paperforge-summary-value",
text: item.val,
});
}
}
}
@ -2593,4 +2365,328 @@ export class PaperForgeSettingTab extends PluginSettingTab {
});
manualLink.setAttr("target", "_blank");
}
// ── Capability state management (Issue #76) ──
/**
* Ensure capabilityState exists for all six modules.
* Always materializes unknown envelopes when stored map is absent/partial,
* regardless of setup_complete, so first-run immediately probes Installation+Help.
*/
_initCapabilityState(): void {
const stored = this.plugin.settings.capabilityState;
this._capabilityState = validatePersistedEnvelopes(
(stored ?? {}) as Record<string, unknown>,
CAPABILITY_MODULES as unknown as string[]
);
this._persistCapabilityState();
}
/** Persist capability state to plugin settings. */
_persistCapabilityState(): void {
if (!this._capabilityState) return;
this.plugin.settings.capabilityState = this._capabilityState;
this.plugin.saveSettings();
}
/** Call `paperforge probe <module> --json` and store the validated envelope unchanged. */
_probeModule(mod: CapabilityModule): void {
if (this._probing.has(mod)) return;
this._probing.add(mod);
// Show probing state immediately
const current = this._capabilityState?.[mod];
const probing: ProbeEnvelope = {
schema_version: 1,
module: mod,
capability_state: current?.capability_state ?? "unknown",
activity_state: "running",
activity_label: "Probing...",
activity_progress: null,
severity: "unknown",
reason: { code: `${mod}.probing`, text: `Checking ${mod} status...` },
action: { primary: probeAction(mod) },
notices: current?.notices ?? [],
updated_at: new Date().toISOString(),
ttl_seconds: current?.ttl_seconds ?? 0,
};
this._updateCapabilityEnvelope(mod, probing);
const vp = (this.app.vault.adapter as any).basePath as string;
const py = getCachedPython(vp, this.plugin.settings);
if (!py.path) {
this._probing.delete(mod);
this._updateCapabilityEnvelope(mod, createInvalidEnvelope(mod));
return;
}
const args = [
...py.extraArgs,
"-m",
"paperforge",
"--vault",
vp,
"probe",
mod,
"--json",
];
execFile(
py.path,
args,
{ cwd: vp, timeout: 15000 },
(err: Error | null, stdout: string, stderr: string) => {
this._probing.delete(mod);
if (err) {
console.warn(`[PaperForge] Probe ${mod} failed:`, err.message);
this._updateCapabilityEnvelope(mod, createInvalidEnvelope(mod));
return;
}
try {
const parsed: unknown = JSON.parse(stdout);
// Backend JSON passed through unchanged after strict validation
if (isValidEnvelope(parsed, mod)) {
this._updateCapabilityEnvelope(mod, parsed as ProbeEnvelope);
} else {
console.warn(`[PaperForge] Probe ${mod}: invalid envelope schema`, stdout?.slice(0, 200));
this._updateCapabilityEnvelope(mod, createInvalidEnvelope(mod));
}
} catch {
console.warn(`[PaperForge] Probe ${mod}: unparseable JSON`, stdout?.slice(0, 200));
this._updateCapabilityEnvelope(mod, createInvalidEnvelope(mod));
}
}
);
}
/** Update a single module envelope and refresh the display. */
_updateCapabilityEnvelope(mod: string, envelope: ProbeEnvelope): void {
if (!this._capabilityState) this._capabilityState = {};
const prev = this._capabilityState[envelope.module];
this._capabilityState[envelope.module] = envelope;
this._persistCapabilityState();
// Show notice when probe completes (transition from running to idle)
if (prev?.activity_state === "running" && envelope.activity_state !== "running") {
new Notice(t("cc_notice_refreshed"), 3000);
}
// Re-render the current tab to reflect changes
this.display();
}
/** Derive badge i18n key from envelope severity + module. */
private _ccBadgeKey(env: ProbeEnvelope, mod: CapabilityModule): string {
if (env.severity === "ok") return "cc_badge_ok";
if (env.severity === "error" && mod === "installation") return "cc_badge_setup";
if (env.severity === "warning" || env.severity === "error") return "cc_badge_attention";
return "cc_badge_pending";
}
/** CSS severity class from backend severity string. Unknown maps to neutral. */
_sevClass(severity: string): string {
if (severity === "error") return "error";
if (severity === "warning") return "warn";
if (severity === "unknown") return "unknown";
return "ok";
}
/** Reason code localized string via i18n key, or null if unmapped.
* Tries full dotted code normalized to underscores first (e.g. "installation.ready" "cc_reason_installation_ready"),
* then falls back to bare code (e.g. "ready" "cc_reason_ready"). */
private _localizeReason(code: string, module: string): string | null {
// Try full dotted code: "installation.ready" → "cc_reason_installation_ready"
const fullKey = "cc_reason_" + code.replace(/\./g, "_");
const fullTranslated = t(fullKey);
if (fullTranslated !== fullKey) {
return fullTranslated.replace("{module}", module);
}
// Fallback to bare code: "installation.ready" → "ready" → "cc_reason_ready"
const bare = code.replace(/^[a-z]+\./, "");
const bareKey = "cc_reason_" + bare;
const bareTranslated = t(bareKey);
if (bareTranslated === bareKey) return null;
return bareTranslated.replace("{module}", module);
}
/** Modules with real Python probe support. */
private static _REAL_PROBE = new Set(["installation", "help"]);
_renderCard(container: HTMLElement, mod: CapabilityModule, envelope: ProbeEnvelope): void {
const env = envelope;
const sevClass = this._sevClass(env.severity);
const isReal = PaperForgeSettingTab._REAL_PROBE.has(mod);
const card = container.createEl("div", {
cls: "pf-cc-card",
attr: { role: "listitem", tabindex: "0", "aria-label": `${t("cc_module_" + mod)}${t(this._ccBadgeKey(env, mod))}` },
});
// Header: name + badge
const header = card.createEl("div", { cls: "pf-cc-card-header" });
header.createEl("div", { cls: "pf-cc-card-name", text: t("cc_module_" + mod) });
header.createEl("div", {
cls: `pf-cc-card-badge pf-cc-card-badge--${sevClass}`,
text: t(this._ccBadgeKey(env, mod)),
});
// Reason text — localized via code map, fallback to backend text
// Placeholder modules (library, ocr, memory, maintenance) show "pending integration"
let reasonText: string;
if (!isReal) {
reasonText = t("cc_reason_placeholder").replace("{module}", t("cc_module_" + mod));
} else {
const l10nReason = this._localizeReason(env.reason.code, mod);
reasonText = l10nReason ?? env.reason.text;
}
card.createEl("div", { cls: "pf-cc-card-reason", text: reasonText });
// Activity label + progress bar (DOM style.width, never inline attribute)
if (env.activity_state === "running" && env.activity_label) {
const activityRow = card.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 + "%";
}
}
// Footer: action button + diagnostics
const footer = card.createEl("div", { cls: "pf-cc-card-footer" });
// Action button — only for real modules (installation/help); placeholders show no action
if (isReal && env.action.primary && !isReadyEnvelope(env)) {
const action = classifyCapabilityAction(env);
const isCta = action.kind === "setup";
const btnCls = isCta ? "pf-cc-card-action pf-cc-card-action--primary" : "pf-cc-card-action";
const btn = footer.createEl("button", {
cls: btnCls,
text: action.label,
attr: { "aria-label": action.label },
});
btn.addEventListener("click", () => {
if (action.kind === "setup") {
new PaperForgeSetupModal(this.app, this.plugin, () => {
this._probeModule("installation");
this._probeModule("help");
}).open();
} else {
this._probeModule(mod);
}
});
}
// Diagnostics — native <details><summary> with localized field labels and values
const details = card.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" });
// Localized values
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;
// Format updated_at with locale
let dateLabel: string;
try {
dateLabel = new Date(env.updated_at).toLocaleString();
} catch {
dateLabel = env.updated_at;
}
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}` });
// Reason: localized text (or placeholder message) plus technical code in <code>
const reasonRow = body.createEl("div");
reasonRow.appendText(t("cc_diag_reason") + ": " + reasonText + " ");
const codeEl = reasonRow.createEl("code", { text: env.reason.code });
body.createEl("div", { text: `${t("cc_diag_ttl")}: ${String(env.ttl_seconds)}s` });
body.createEl("div", { text: `${t("cc_diag_updated")}: ${dateLabel}` });
}
/** Render Vercel-inspired control center: summary card + six-card responsive grid. */
_renderControlCenter(containerEl: HTMLElement): void {
const cc = containerEl.createEl("div", { cls: "pf-control-center" });
// Compute summary counts from envelopes
const modules = CAPABILITY_MODULES;
const envelopes: Record<string, ProbeEnvelope> = this._capabilityState ?? {};
let realReady = 0;
let realAttention = 0;
let placeholderCount = 0;
for (const mod of modules) {
const env = envelopes[mod] ?? createUnknownEnvelope(mod);
if (env.severity === "ok" && env.capability_state === "ready" && env.action.primary === null) {
// Backend-confirmed ready, no pending action
realReady++;
} else if (PaperForgeSettingTab._REAL_PROBE.has(mod)) {
// Real module that has been probed but isn't ready
if (env.severity === "error" || env.severity === "warning" || env.severity === "unknown") {
realAttention++;
}
} else {
// Placeholder module (library, ocr, memory, maintenance) — not yet connected
placeholderCount++;
}
}
// ── Summary Card ──
const summaryEl = cc.createEl("div", { cls: "pf-cc-summary" });
summaryEl.createEl("div", { cls: "pf-cc-summary-eyebrow", text: t("cc_title") });
// Decisive title based on state
let summaryTitle: string;
let summaryBodyText: string;
if (realAttention > 0) {
summaryTitle = t("cc_summary_attention");
summaryBodyText = t("cc_summary_attention_body");
} else if (realReady === modules.length) {
summaryTitle = t("cc_summary_ok");
summaryBodyText = t("cc_summary_ok_body");
} else if (realReady > 0 && placeholderCount > 0 && realAttention === 0) {
summaryTitle = t("cc_summary_core_ok").replace("{n}", String(placeholderCount));
summaryBodyText = t("cc_summary_core_ok_body");
} else {
summaryTitle = t("cc_summary_core_ok").replace("{n}", String(modules.length - realReady));
summaryBodyText = t("cc_desc");
}
summaryEl.createEl("div", { cls: "pf-cc-summary-title", text: summaryTitle });
summaryEl.createEl("div", { cls: "pf-cc-summary-body", text: summaryBodyText });
// Summary counts row
const countsEl = summaryEl.createEl("div", { cls: "pf-cc-summary-counts" });
countsEl.createEl("div", {
cls: "pf-cc-summary-count",
text: t("cc_n_ready").replace("{n}", String(realReady)),
});
if (placeholderCount > 0) {
countsEl.createEl("div", {
cls: "pf-cc-summary-count",
text: t("cc_n_pending").replace("{n}", String(placeholderCount)),
});
}
// ── Module Grid ──
const grid = cc.createEl("div", { cls: "pf-cc-grid", attr: { role: "list", "aria-label": t("cc_zone_modules") } });
for (const mod of modules) {
const env = envelopes[mod] ?? createUnknownEnvelope(mod);
this._renderCard(grid, mod, env);
}
}
/** Apply stale-tolerance: if an envelope is stale, replace with unknown+probe. */
_applyStaleTolerance(): void {
if (!this._capabilityState) return;
let changed = false;
for (const mod of CAPABILITY_MODULES) {
const env = this._capabilityState[mod];
if (env && isEnvelopeStale(env)) {
this._capabilityState[mod] = createStaleEnvelope(mod);
changed = true;
}
}
if (changed) this._persistCapabilityState();
}
}

View file

@ -212,11 +212,13 @@ export class PaperForgeSetupModal extends Modal {
private _apiKeyValidated!: boolean;
private _apiKeyStatus!: HTMLElement;
private _showSkipConfirm: boolean = false;
private _onComplete: (() => void) | undefined;
constructor(app: App, plugin: IPluginRef) {
constructor(app: App, plugin: IPluginRef, onComplete?: () => void) {
super(app);
this.plugin = plugin;
this._step = 1;
this._onComplete = onComplete;
}
onOpen() {
@ -704,6 +706,7 @@ export class PaperForgeSetupModal extends Modal {
this._log(t('install_complete'));
s.setup_complete = true;
await this.plugin.saveSettings();
if (this._onComplete) this._onComplete();
setTimeout(() => { this._step = 5; this._render(); }, 800);
} catch (err) {
console.error('PaperForge setup failed:', (err as Error).message);

View file

@ -4412,3 +4412,270 @@
max-height: 60px;
overflow-y: auto;
}
/* ==========================================================================
SECTION 14 Control Center (Issue #76) Capability state cards
Vercel-inspired status dashboard. Self-contained under .pf-control-center.
CSS tokens bridge Obsidian variables; no dependency on .paperforge-status-panel.
========================================================================== */
.pf-control-center {
container-type: inline-size;
container-name: pfcc;
}
/* ── Summary Card ── */
.pf-control-center .pf-cc-summary {
background: var(--background-primary);
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-m);
padding: 16px 20px;
margin-bottom: 16px;
display: flex;
flex-direction: column;
gap: 8px;
}
.pf-control-center .pf-cc-summary-eyebrow {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
line-height: 1.3;
}
.pf-control-center .pf-cc-summary-title {
font-size: 16px;
font-weight: 600;
color: var(--text-normal);
line-height: 1.35;
margin: 0;
}
.pf-control-center .pf-cc-summary-body {
font-size: 13px;
color: var(--text-muted);
line-height: 1.5;
margin: 0;
}
.pf-control-center .pf-cc-summary-counts {
display: flex;
gap: 12px;
align-items: center;
}
.pf-control-center .pf-cc-summary-count {
font-size: 12px;
color: var(--text-muted);
line-height: 1.4;
}
.pf-control-center .pf-cc-summary-count strong {
font-weight: 600;
color: var(--text-normal);
}
/* ── Module Grid ── */
.pf-control-center .pf-cc-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
@container pfcc (max-width: 620px) {
.pf-control-center .pf-cc-grid {
grid-template-columns: 1fr;
}
}
/* ── Module Card ── */
.pf-control-center .pf-cc-card {
background: var(--background-primary);
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-m);
padding: 16px;
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
transition: border-color 0.15s;
}
.pf-control-center .pf-cc-card:hover {
border-color: var(--background-modifier-border-hover);
}
.pf-control-center .pf-cc-card:focus-visible {
outline: 2px solid var(--interactive-accent);
outline-offset: 2px;
}
.pf-control-center .pf-cc-card-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.pf-control-center .pf-cc-card-name {
font-size: 13px;
font-weight: 600;
color: var(--text-normal);
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pf-control-center .pf-cc-card-badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 500;
line-height: 1.4;
white-space: nowrap;
flex-shrink: 0;
}
.pf-control-center .pf-cc-card-badge--ok {
background: color-mix(in srgb, var(--text-success) 10%, transparent);
color: var(--text-success);
}
.pf-control-center .pf-cc-card-badge--warn {
background: color-mix(in srgb, var(--text-warning) 10%, transparent);
color: var(--text-warning);
}
.pf-control-center .pf-cc-card-badge--error {
background: color-mix(in srgb, var(--text-error) 10%, transparent);
color: var(--text-error);
}
.pf-control-center .pf-cc-card-badge--unknown {
background: var(--background-secondary);
color: var(--text-muted);
}
.pf-control-center .pf-cc-card-reason {
font-size: 12px;
color: var(--text-muted);
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
min-height: 0;
margin: 0;
}
/* ── Activity / Progress ── */
.pf-control-center .pf-cc-card-activity {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
color: var(--text-muted);
line-height: 1.3;
}
.pf-control-center .pf-cc-card-progress {
flex: 1;
height: 4px;
border-radius: 2px;
background: var(--background-modifier-border);
overflow: hidden;
min-width: 40px;
}
.pf-control-center .pf-cc-card-progress-fill {
height: 100%;
border-radius: 2px;
background: var(--interactive-accent);
transition: width 0.3s;
}
/* ── Card Footer ── */
.pf-control-center .pf-cc-card-footer {
display: flex;
align-items: center;
gap: 8px;
margin-top: auto;
padding-top: 4px;
}
.pf-control-center .pf-cc-card-action {
display: inline-flex;
align-items: center;
gap: 4px;
background: var(--interactive-normal);
border: 1px solid var(--background-modifier-border);
color: var(--text-normal);
border-radius: var(--button-radius, 4px);
font-size: 11px;
font-weight: 500;
padding: 3px 10px;
cursor: pointer;
transition: background 0.15s;
white-space: nowrap;
}
.pf-control-center .pf-cc-card-action:hover {
background: var(--interactive-hover);
border-color: var(--background-modifier-border-hover);
}
.pf-control-center .pf-cc-card-action--primary {
background: var(--interactive-accent);
color: var(--text-on-accent);
border: none;
}
.pf-control-center .pf-cc-card-action--primary:hover {
opacity: 0.85;
}
.pf-control-center .pf-cc-card-diagnostic {
font-size: 11px;
color: var(--text-faint);
cursor: pointer;
user-select: none;
line-height: 1.3;
margin-left: auto;
}
.pf-control-center .pf-cc-card-diagnostic > summary {
cursor: pointer;
user-select: none;
color: var(--text-faint);
font-size: 11px;
line-height: 1.3;
display: inline;
}
.pf-control-center .pf-cc-card-diagnostic > summary:hover {
color: var(--text-muted);
}
.pf-control-center .pf-cc-card-diagnostic:hover {
color: var(--text-muted);
}
.pf-control-center .pf-cc-card-diagnostic-body {
font-size: 11px;
color: var(--text-faint);
line-height: 1.4;
padding: 4px 0 0 0;
word-break: break-all;
white-space: pre-wrap;
}
/* ── Reduced Motion ── */
@media (prefers-reduced-motion: reduce) {
.pf-control-center .pf-cc-card-progress-fill {
transition: none;
}
.pf-control-center .pf-cc-card {
transition: none;
}
}

View file

@ -0,0 +1,664 @@
/**
* Focused tests for capability-state types, envelope validation, helpers,
* and the six-module control center rendering.
*/
import { describe, expect, it } from "vitest";
import {
isValidEnvelope,
createUnknownEnvelope,
createStaleEnvelope,
createInvalidEnvelope,
isEnvelopeStale,
isReadyEnvelope,
classifyCapabilityAction,
computeModuleSummary,
validatePersistedEnvelopes,
CAPABILITY_MODULES,
SCHEMA_VERSION,
ProbeEnvelope,
ActionPrimary,
probeAction,
setupAction,
} from "../src/constants";
// ── Helpers ──
/** Minimal valid envelope matching backend int/schema. */
function validEnvelope(overrides: Partial<Record<string, unknown>> = {}): Record<string, unknown> {
return {
schema_version: 1,
module: "installation",
capability_state: "ready",
severity: "ok",
activity_state: "idle",
activity_label: null,
activity_progress: null,
notices: [],
reason: { code: "ok", text: "All good" },
action: { primary: null },
updated_at: "2026-01-15T00:00:00.000Z",
ttl_seconds: 3600,
...overrides,
};
}
/** Full setup action from backend. */
const FULL_SETUP_ACTION: ActionPrimary = {
verb: "setup",
label: "Open Setup Wizard",
destructive: false,
destructive_scope: null,
destructive_effect: null,
confirmation_required: false,
confirmation_prompt: null,
command: "setup",
scope: "installation",
scope_count: 0,
};
// ── 1. Schema validation ──
describe("isValidEnvelope", () => {
it("passes for a valid envelope", () => {
expect(isValidEnvelope(validEnvelope())).toBe(true);
});
it("passes with backend-typical null activity_label and null activity_progress", () => {
expect(isValidEnvelope(validEnvelope({
activity_label: null,
activity_progress: null,
}))).toBe(true);
});
it("passes with full setup action primary", () => {
expect(isValidEnvelope(validEnvelope({
action: { primary: FULL_SETUP_ACTION },
}))).toBe(true);
});
it("rejects null input", () => {
expect(isValidEnvelope(null)).toBe(false);
});
it("rejects undefined input", () => {
expect(isValidEnvelope(undefined)).toBe(false);
});
it("rejects non-object input", () => {
expect(isValidEnvelope("string")).toBe(false);
expect(isValidEnvelope(42)).toBe(false);
});
it("rejects wrong schema_version", () => {
expect(isValidEnvelope(validEnvelope({ schema_version: "v1" }))).toBe(false);
expect(isValidEnvelope(validEnvelope({ schema_version: 2 }))).toBe(false);
});
it("rejects unknown module", () => {
expect(isValidEnvelope(validEnvelope({ module: "bogus" }))).toBe(false);
});
it("rejects module mismatch", () => {
expect(isValidEnvelope(validEnvelope({ module: "help" }), "installation")).toBe(false);
});
it("accepts matching module", () => {
expect(isValidEnvelope(validEnvelope({ module: "help" }), "help")).toBe(true);
});
it("accepts all six capability_states", () => {
for (const s of ["unknown", "unavailable", "missing_input", "needs_action", "limited", "ready"]) {
expect(isValidEnvelope(validEnvelope({ capability_state: s }))).toBe(true);
}
});
it("rejects invalid capability_state", () => {
expect(isValidEnvelope(validEnvelope({ capability_state: "bogus" }))).toBe(false);
});
it("accepts all four severities", () => {
for (const s of ["unknown", "ok", "warning", "error"]) {
expect(isValidEnvelope(validEnvelope({ severity: s }))).toBe(true);
}
});
it("rejects invalid severity", () => {
expect(isValidEnvelope(validEnvelope({ severity: "critical" }))).toBe(false);
});
it("rejects missing activity_state", () => {
const { activity_state, ...rest } = validEnvelope();
expect(isValidEnvelope(rest)).toBe(false);
});
it("rejects missing activity_label", () => {
const { activity_label, ...rest } = validEnvelope();
expect(isValidEnvelope(rest)).toBe(false);
});
it("rejects missing activity_progress", () => {
const { activity_progress, ...rest } = validEnvelope();
expect(isValidEnvelope(rest)).toBe(false);
});
it("accepts valid activity_progress object", () => {
expect(isValidEnvelope(validEnvelope({
activity_state: "running",
activity_label: "Probing...",
activity_progress: { current: 1, total: 5 },
}))).toBe(true);
});
it("rejects activity_progress with string current", () => {
expect(isValidEnvelope(validEnvelope({
activity_progress: { current: "1", total: 3 },
}))).toBe(false);
});
it("accepts notices array", () => {
expect(isValidEnvelope(validEnvelope({
notices: [{ level: "info", message: "test" }],
}))).toBe(true);
});
it("rejects missing notices", () => {
const { notices, ...rest } = validEnvelope();
expect(isValidEnvelope(rest)).toBe(false);
});
it("rejects missing reason", () => {
const { reason, ...rest } = validEnvelope();
expect(isValidEnvelope(rest)).toBe(false);
});
it("rejects reason missing code", () => {
expect(isValidEnvelope(validEnvelope({ reason: { text: "nope" } }))).toBe(false);
});
it("rejects reason missing text", () => {
expect(isValidEnvelope(validEnvelope({ reason: { code: "nope" } }))).toBe(false);
});
it("rejects missing action", () => {
const { action, ...rest } = validEnvelope();
expect(isValidEnvelope(rest)).toBe(false);
});
it("rejects action with string primary", () => {
expect(isValidEnvelope(validEnvelope({ action: { primary: "setup" } }))).toBe(false);
});
it("rejects action with array primary", () => {
expect(isValidEnvelope(validEnvelope({ action: { primary: [] } }))).toBe(false);
});
it("rejects action with incomplete primary (missing verb)", () => {
expect(isValidEnvelope(validEnvelope({ action: { primary: { label: "x" } } }))).toBe(false);
});
it("accepts action with null primary", () => {
expect(isValidEnvelope(validEnvelope({ action: { primary: null } }))).toBe(true);
});
it("accepts action with full setup primary", () => {
expect(isValidEnvelope(validEnvelope({ action: { primary: FULL_SETUP_ACTION } }))).toBe(true);
});
it("rejects missing updated_at", () => {
const { updated_at, ...rest } = validEnvelope();
expect(isValidEnvelope(rest)).toBe(false);
});
it("rejects missing ttl_seconds", () => {
const { ttl_seconds, ...rest } = validEnvelope();
expect(isValidEnvelope(rest)).toBe(false);
});
it("rejects non-number ttl_seconds", () => {
expect(isValidEnvelope(validEnvelope({ ttl_seconds: "3600" }))).toBe(false);
});
});
// ── 2. Stale / Invalid helpers (severity unknown, full probe action) ──
describe("isEnvelopeStale", () => {
function makeEnv(overrides: Partial<ProbeEnvelope> = {}): ProbeEnvelope {
return {
schema_version: 1, module: "installation",
capability_state: "ready", severity: "ok",
activity_state: "idle",
activity_label: null,
activity_progress: null,
notices: [],
reason: { code: "ok", text: "ok" },
action: { primary: null },
updated_at: new Date().toISOString(),
ttl_seconds: 3600,
...overrides,
};
}
it("returns true when ttl_seconds is 0", () => {
expect(isEnvelopeStale(makeEnv({ ttl_seconds: 0 }))).toBe(true);
});
it("returns true for epoch date", () => {
expect(isEnvelopeStale(makeEnv({ updated_at: new Date(0).toISOString() }))).toBe(true);
});
it("returns false when recent within TTL", () => {
expect(isEnvelopeStale(makeEnv({ ttl_seconds: 3600 }))).toBe(false);
});
});
describe("createStaleEnvelope", () => {
it("produces unknown state, module-prefixed reason, probe action", () => {
const env = createStaleEnvelope("help");
expect(env.capability_state).toBe("unknown");
expect(env.severity).toBe("unknown");
expect(env.reason.code).toBe("help.stale");
expect(env.ttl_seconds).toBe(0);
expect(env.activity_label).toBeNull();
expect(env.activity_progress).toBeNull();
expect(env.action.primary).not.toBeNull();
expect(env.action.primary!.verb).toBe("probe");
});
});
describe("createInvalidEnvelope", () => {
it("produces unknown state, module-prefixed reason, probe action", () => {
const env = createInvalidEnvelope("library");
expect(env.capability_state).toBe("unknown");
expect(env.severity).toBe("unknown");
expect(env.reason.code).toBe("library.invalid_response");
expect(env.activity_label).toBeNull();
expect(env.activity_progress).toBeNull();
expect(env.action.primary!.verb).toBe("probe");
});
});
// ── 3. createUnknownEnvelope ──
describe("createUnknownEnvelope", () => {
it("produces six distinct unknown envelopes", () => {
for (const mod of CAPABILITY_MODULES) {
const env = createUnknownEnvelope(mod);
expect(env.module).toBe(mod);
expect(env.capability_state).toBe("unknown");
expect(env.schema_version).toBe(SCHEMA_VERSION);
expect(env.activity_label).toBeNull();
expect(env.activity_progress).toBeNull();
}
});
it("ALL modules get verb=probe (never setup) in unknown state", () => {
for (const mod of CAPABILITY_MODULES) {
const env = createUnknownEnvelope(mod);
expect(env.action.primary).not.toBeNull();
expect(env.action.primary!.verb).toBe("probe");
}
});
it("probe action has full ActionPrimary shape", () => {
const env = createUnknownEnvelope("installation");
const p = env.action.primary!;
expect(typeof p.verb).toBe("string");
expect(typeof p.label).toBe("string");
expect(typeof p.destructive).toBe("boolean");
expect(typeof p.command).toBe("string");
expect(typeof p.scope).toBe("string");
expect(typeof p.scope_count).toBe("number");
});
});
// ── 4. Six-module grid & order ──
describe("CAPABILITY_MODULES", () => {
it("contains exactly six modules", () => {
expect(CAPABILITY_MODULES).toHaveLength(6);
});
it("renders in approved prototype order: installation, library, ocr, memory, maintenance, help", () => {
expect(CAPABILITY_MODULES[0]).toBe("installation");
expect(CAPABILITY_MODULES[1]).toBe("library");
expect(CAPABILITY_MODULES[2]).toBe("ocr");
expect(CAPABILITY_MODULES[3]).toBe("memory");
expect(CAPABILITY_MODULES[4]).toBe("maintenance");
expect(CAPABILITY_MODULES[5]).toBe("help");
});
it("includes all expected modules", () => {
expect(CAPABILITY_MODULES).toContain("installation");
expect(CAPABILITY_MODULES).toContain("library");
expect(CAPABILITY_MODULES).toContain("ocr");
expect(CAPABILITY_MODULES).toContain("memory");
expect(CAPABILITY_MODULES).toContain("maintenance");
expect(CAPABILITY_MODULES).toContain("help");
});
});
// ── 5. probeAction / setupAction builders ──
describe("probeAction", () => {
it("returns full ActionPrimary with correct verb", () => {
const a = probeAction("installation");
expect(a.verb).toBe("probe");
expect(a.label).toBe("Check");
expect(a.destructive).toBe(false);
expect(a.command).toBe("probe installation");
expect(a.scope).toBe("installation");
expect(a.scope_count).toBe(1);
});
});
describe("setupAction", () => {
it("returns full ActionPrimary with setup verb", () => {
const a = setupAction();
expect(a.verb).toBe("setup");
expect(a.label).toBe("Open Setup Wizard");
expect(a.destructive).toBe(false);
});
});
// ── 5. Ready envelope ──
describe("isReadyEnvelope", () => {
function make(overrides: Partial<ProbeEnvelope> = {}): ProbeEnvelope {
return {
schema_version: 1, module: "installation",
capability_state: "ready", severity: "ok",
activity_state: "idle",
activity_label: null,
activity_progress: null,
reason: { code: "ok", text: "ok" },
action: { primary: null },
notices: [],
updated_at: new Date().toISOString(),
ttl_seconds: 3600,
...overrides,
};
}
it("returns true for ready with null primary", () => {
expect(isReadyEnvelope(make())).toBe(true);
});
it("returns false for ready with non-null action", () => {
expect(isReadyEnvelope(make({ action: { primary: FULL_SETUP_ACTION } }))).toBe(false);
});
it("returns false for non-ready state", () => {
expect(isReadyEnvelope(make({ capability_state: "unavailable" }))).toBe(false);
});
it("returns false for unknown state", () => {
expect(isReadyEnvelope(createUnknownEnvelope("installation"))).toBe(false);
});
});
// ── 6. Six-module grid ──
describe("CAPABILITY_MODULES", () => {
it("contains exactly six modules", () => {
expect(CAPABILITY_MODULES).toHaveLength(6);
});
it("includes all expected modules", () => {
expect(CAPABILITY_MODULES).toContain("installation");
expect(CAPABILITY_MODULES).toContain("help");
expect(CAPABILITY_MODULES).toContain("library");
expect(CAPABILITY_MODULES).toContain("ocr");
expect(CAPABILITY_MODULES).toContain("memory");
expect(CAPABILITY_MODULES).toContain("maintenance");
});
});
// ── 7. Literal backend envelope test ──
describe("literal backend envelope", () => {
/** Exact shape a real `paperforge probe installation --json` would emit. */
const BACKEND_INSTALLATION_READY: Record<string, unknown> = {
schema_version: 1,
module: "installation",
capability_state: "ready",
activity_state: "idle",
activity_label: null,
activity_progress: null,
severity: "ok",
reason: { code: "ok", text: "PaperForge environment is set up correctly." },
action: { primary: null },
notices: [{ level: "info", message: "Installation verified at 2026-01-15" }],
updated_at: "2026-01-15T00:00:00.000Z",
ttl_seconds: 3600,
};
it("validates a ready backend envelope", () => {
expect(isValidEnvelope(BACKEND_INSTALLATION_READY)).toBe(true);
});
it("validates a backend envelope with full setup action", () => {
const envelope: Record<string, unknown> = {
...BACKEND_INSTALLATION_READY,
capability_state: "needs_action",
severity: "warning",
reason: { code: "setup_required", text: "Initial setup not yet complete." },
action: { primary: FULL_SETUP_ACTION },
};
expect(isValidEnvelope(envelope)).toBe(true);
});
it("validates a running backend envelope", () => {
const envelope: Record<string, unknown> = {
...BACKEND_INSTALLATION_READY,
capability_state: "unknown",
activity_state: "running",
activity_label: "Probing PaperForge installation...",
activity_progress: { current: 2, total: 5 },
severity: "unknown",
};
expect(isValidEnvelope(envelope)).toBe(true);
});
});
// ── 8. classifyCapabilityAction ──
describe("classifyCapabilityAction", () => {
const base: Partial<ProbeEnvelope> = {
schema_version: 1, module: "installation", capability_state: "needs_action",
activity_state: "idle", severity: "warning", reason: { code: "test", text: "test" },
updated_at: new Date().toISOString(), ttl_seconds: 3600,
};
it("preserves the backend-selected action label", () => {
const env: ProbeEnvelope = { ...base as ProbeEnvelope,
module: "help",
action: { primary: { verb: "setup", label: "Restore help", destructive: false } },
};
expect(classifyCapabilityAction(env).label).toBe("Restore help");
});
it("classifies set_config verb as setup kind", () => {
const env: ProbeEnvelope = { ...base as ProbeEnvelope,
action: { primary: { verb: "set_config", label: "Configure", destructive: false } },
};
const result = classifyCapabilityAction(env);
expect(result.kind).toBe("setup");
expect(result.verb).toBe("set_config");
});
it("classifies update verb as setup kind", () => {
const env: ProbeEnvelope = { ...base as ProbeEnvelope,
action: { primary: { verb: "update", label: "Update", destructive: false } },
};
const result = classifyCapabilityAction(env);
expect(result.kind).toBe("setup");
expect(result.verb).toBe("update");
});
it("classifies probe verb as probe kind", () => {
const env: ProbeEnvelope = { ...base as ProbeEnvelope,
action: { primary: { verb: "probe", label: "Refresh", destructive: false } },
};
const result = classifyCapabilityAction(env);
expect(result.kind).toBe("probe");
});
it("classifies sync/run/rebuild_index as action kind", () => {
for (const verb of ["sync", "run", "rebuild_index", "migrate"]) {
const env: ProbeEnvelope = { ...base as ProbeEnvelope,
action: { primary: { verb, label: verb, destructive: false } },
};
expect(classifyCapabilityAction(env).kind).toBe("action");
}
});
it("defaults to probe when action is null", () => {
const env: ProbeEnvelope = { ...base as ProbeEnvelope, action: null };
expect(classifyCapabilityAction(env).kind).toBe("probe");
});
it("defaults to probe when primary action is null", () => {
const env: ProbeEnvelope = { ...base as ProbeEnvelope, action: { primary: null } };
expect(classifyCapabilityAction(env).kind).toBe("probe");
});
});
// ── 9. computeModuleSummary ──
describe("computeModuleSummary", () => {
const readyEnv: ProbeEnvelope = {
schema_version: 1, module: "installation", capability_state: "ready",
activity_state: "idle", severity: "ok", reason: null,
action: { primary: null }, updated_at: new Date().toISOString(), ttl_seconds: 3600,
};
const unknownEnv: ProbeEnvelope = {
schema_version: 1, module: "installation", capability_state: "unknown",
activity_state: "idle", severity: "unknown", reason: { code: "test", text: "test" },
action: { primary: { verb: "probe", label: "Probe", destructive: false } },
updated_at: new Date(0).toISOString(), ttl_seconds: 0,
};
const realModules = ["installation", "help"];
it("reports coreReady=true when all real modules are ready", () => {
const map: Record<string, ProbeEnvelope> = {
installation: { ...readyEnv, module: "installation" },
help: { ...readyEnv, module: "help" },
};
expect(computeModuleSummary(map, realModules).coreReady).toBe(true);
});
it("reports coreReady=false when a real module is unknown", () => {
const map: Record<string, ProbeEnvelope> = {
installation: unknownEnv,
help: { ...readyEnv, module: "help" },
};
const result = computeModuleSummary(map, realModules);
expect(result.coreReady).toBe(false);
expect(result.attentionModules).toContain("installation");
});
it("includes missing module as attention", () => {
const map: Record<string, ProbeEnvelope> = {
installation: { ...readyEnv, module: "installation" },
// help is missing
};
const result = computeModuleSummary(map, realModules);
expect(result.coreReady).toBe(false);
expect(result.attentionModules).toContain("help");
});
});
describe("validatePersistedEnvelopes", () => {
const allModules = ["installation", "help"];
const fresh = Date.now();
/** Build a full valid envelope matching worktree isValidEnvelope strict checks. */
function validEnv(mod: string): Record<string, unknown> {
return {
schema_version: 1,
module: mod,
capability_state: "ready",
activity_state: "idle",
activity_label: null,
activity_progress: null,
severity: "ok",
reason: { code: `${mod}.ready`, text: `${mod} is fully functional.` },
action: { primary: null },
notices: [],
updated_at: new Date(fresh).toISOString(),
ttl_seconds: 86400,
};
}
it("passes through valid fresh envelopes", () => {
const input: Record<string, unknown> = {
installation: validEnv("installation"),
help: validEnv("help"),
};
const result = validatePersistedEnvelopes(input, allModules);
expect(result["installation"].capability_state).toBe("ready");
expect(result["help"].capability_state).toBe("ready");
});
it("replaces malformed entries with invalid envelopes", () => {
const input: Record<string, unknown> = {
installation: { schema_version: "not-a-number", module: "installation" },
};
const result = validatePersistedEnvelopes(input, allModules);
expect(result["installation"].capability_state).toBe("unknown");
expect(result["installation"].reason?.code).toBe("installation.invalid_response");
});
it("replaces stale entries with stale envelopes", () => {
const oldDate = new Date(0).toISOString();
const input: Record<string, unknown> = {
installation: {
...validEnv("installation"),
updated_at: oldDate,
ttl_seconds: 1,
},
};
const result = validatePersistedEnvelopes(input, allModules);
expect(result["installation"].capability_state).toBe("unknown");
expect(result["installation"].reason?.code).toBe("installation.stale");
});
it("creates unknown envelopes for missing modules", () => {
const result = validatePersistedEnvelopes({}, allModules);
expect(result["installation"].capability_state).toBe("unknown");
expect(result["help"].capability_state).toBe("unknown");
});
it("replaces non-object entries with unknown envelopes", () => {
const input: Record<string, unknown> = { installation: "string-value" };
const result = validatePersistedEnvelopes(input, allModules);
expect(result["installation"].capability_state).toBe("unknown");
expect(result["installation"].reason?.code).toBe("installation.no_probe");
});
it("replaces entries with wrong module name with invalid envelope", () => {
const input: Record<string, unknown> = {
installation: { ...validEnv("help") }, // expects "installation" key but module="help"
};
const result = validatePersistedEnvelopes(input, allModules);
expect(result["installation"].capability_state).toBe("unknown");
expect(result["installation"].reason?.code).toBe("installation.invalid_response");
});
it("replaces entries with non-ready severity/state mismatch with invalid envelope", () => {
// isValidEnvelope checks validity not correctness — this passes validation
const input: Record<string, unknown> = {
installation: {
...validEnv("installation"),
capability_state: "unknown",
severity: "unknown",
updated_at: new Date(0).toISOString(),
ttl_seconds: 0,
},
};
const result = validatePersistedEnvelopes(input, allModules);
// Should be replaced as stale (TTL zero => always stale per isEnvelopeStale)
expect(result["installation"].capability_state).toBe("unknown");
expect(result["installation"].reason?.code).toBe("installation.stale");
});
});

View file

@ -1,5 +1,5 @@
# OCR-v2 Active Queue
> Status: OCR-v2 is stable; the control-center contract and PRD are resolved; #75 canonical setup/config migration is complete and #76 is the next unblocked production slice.
> 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
## Current checkpoint
@ -23,6 +23,8 @@
- **[#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.
- **[#74](https://github.com/LLLin000/PaperForge/issues/74) published**: split into eight agent-ready issues (#75#82) with native dependencies.
- **[#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.
## Verification status
@ -35,6 +37,7 @@
- Prototype #72 (maintenance inbox): **Critical PASS (4/4), Important PASS (6/6)** — independent reviewer dimensions confirmed.
- 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.
- 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`.
@ -44,7 +47,8 @@
- [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)).
- [ ] Start [#76](https://github.com/LLLin000/PaperForge/issues/76) in a fresh Matt `/implement` session: Installation/Help capability envelope through the existing settings surface.
- [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.
## Deferred

347
tests/test_probe.py Normal file
View file

@ -0,0 +1,347 @@
"""Tests for the paperforge probe CLI command (Issue #76, Issue #69 contract).
Tests cover:
- Envelope structure (all required fields, correct types)
- Reason codes are module-prefixed snake_case
- action.primary has full field set when non-null, null when ready
- Installation probe state mapping (missing/corrupt/invalid-shape/ready/old-python)
- Help probe state mapping (ready/limited)
- Config shape validation (list/primitive/empty dict config_corrupt)
- CLI subprocess boundary
"""
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
import pytest
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
REQUIRED_ENVELOPE_FIELDS = {
"schema_version", "module", "capability_state",
"activity_state", "activity_label", "activity_progress",
"severity", "reason", "action", "notices", "updated_at", "ttl_seconds",
}
REQUIRED_REASON_FIELDS = {"code", "text"}
REQUIRED_ACTION_PRIMARY_FIELDS = {
"verb", "label", "destructive", "destructive_scope",
"destructive_effect", "confirmation_required", "confirmation_prompt",
"command", "scope", "scope_count",
}
VALID_STATES = {"unknown", "unavailable", "missing_input", "needs_action", "limited", "ready"}
VALID_SEVERITIES = {"ok", "warning", "error", "info"}
def _run_probe(module: str, vault: Path, extra_args: list[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).
"""
cmd = [
sys.executable,
"-m",
"paperforge",
"--vault",
str(vault),
"probe",
module,
"--json",
]
if extra_args:
cmd.extend(extra_args)
result = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
assert result.returncode == 0, (
f"CLI exited {result.returncode}\nstdout: {result.stdout[:500]}\nstderr: {result.stderr[:500]}"
)
return json.loads(result.stdout)
def _assert_envelope_shape(data: dict) -> None:
"""Assert envelope has all required fields with correct structural types."""
missing = REQUIRED_ENVELOPE_FIELDS - set(data.keys())
assert not missing, f"Missing envelope fields: {missing}"
assert isinstance(data["schema_version"], int), f"schema_version must be int, got {type(data['schema_version']).__name__}"
assert data["schema_version"] == 1, f"schema_version must be 1, got {data['schema_version']}"
assert isinstance(data["module"], str), "module must be str"
assert data["capability_state"] in VALID_STATES, f"invalid capability_state: {data['capability_state']}"
assert data["activity_state"] in ("idle", "running"), f"invalid activity_state: {data['activity_state']}"
assert data["activity_label"] is None or isinstance(data["activity_label"], str)
assert data["activity_progress"] is None or isinstance(data["activity_progress"], (int, float))
assert data["severity"] in VALID_SEVERITIES, f"invalid severity: {data['severity']}"
assert isinstance(data["reason"], dict), "reason must be dict"
reason_missing = REQUIRED_REASON_FIELDS - set(data["reason"].keys())
assert not reason_missing, f"Missing reason fields: {reason_missing}"
assert isinstance(data["reason"]["code"], str) and len(data["reason"]["code"]) > 0
assert isinstance(data["action"], dict), "action must be dict"
assert isinstance(data["notices"], list), "notices must be list"
assert isinstance(data["updated_at"], str), "updated_at must be str"
assert data["updated_at"].endswith("Z"), f"updated_at must be Z-suffixed, got {data['updated_at']}"
assert isinstance(data["ttl_seconds"], int), "ttl_seconds must be int"
assert data["ttl_seconds"] > 0, "ttl_seconds must be positive"
def _assert_action_primary_shape(action_primary: dict | None) -> None:
"""Assert action.primary has all required fields when non-null, or is None."""
if action_primary is None:
return
missing = REQUIRED_ACTION_PRIMARY_FIELDS - set(action_primary.keys())
assert not missing, f"Missing action.primary fields: {missing}"
assert isinstance(action_primary["verb"], str)
assert isinstance(action_primary["label"], str)
assert isinstance(action_primary["destructive"], bool)
assert action_primary["destructive_scope"] is None or isinstance(action_primary["destructive_scope"], str)
assert action_primary["destructive_effect"] is None or isinstance(action_primary["destructive_effect"], str)
assert isinstance(action_primary["confirmation_required"], bool)
assert isinstance(action_primary["scope"], str), f"scope must be str, got {type(action_primary['scope']).__name__}"
assert isinstance(action_primary["scope_count"], int), f"scope_count must be int, got {type(action_primary['scope_count']).__name__}"
assert isinstance(action_primary["command"], str)
# ---------------------------------------------------------------------------
# Envelope contract
# ---------------------------------------------------------------------------
class TestEnvelopeContract:
"""Validate the schema-v1 envelope structure itself."""
def test_envelope_all_required_fields_present(self, tmp_path: Path) -> None:
"""Installation probe output contains all required envelope fields."""
(tmp_path / "paperforge.json").write_text(
json.dumps({"system_dir": "99_System"}), encoding="utf-8",
)
data = _run_probe("installation", tmp_path)
_assert_envelope_shape(data)
def test_envelope_is_direct_not_pfresult_wrapped(self, tmp_path: Path) -> None:
"""Output is a direct envelope, not wrapped in PFResult ok/command/data envelope."""
(tmp_path / "paperforge.json").write_text(
json.dumps({"system_dir": "99_System"}), encoding="utf-8",
)
data = _run_probe("installation", tmp_path)
assert "ok" not in data, "Output is PFResult-wrapped, expected bare envelope"
assert "command" not in data, "Output is PFResult-wrapped, expected bare envelope"
assert "module" in data
assert "capability_state" in data
def test_schema_version_is_integer_1(self, tmp_path: Path) -> None:
"""schema_version must be the integer 1, never a string."""
(tmp_path / "paperforge.json").write_text(
json.dumps({"system_dir": "99_System"}), encoding="utf-8",
)
data = _run_probe("installation", tmp_path)
assert data["schema_version"] == 1
assert isinstance(data["schema_version"], int)
assert data["schema_version"] is not True # not a bool masquerading
def test_ready_state_action_primary_null(self, tmp_path: Path) -> None:
"""When capability_state is 'ready', action.primary must be null."""
(tmp_path / "paperforge.json").write_text(
json.dumps({"system_dir": "99_System"}), encoding="utf-8",
)
data = _run_probe("installation", tmp_path)
if data["capability_state"] == "ready":
assert data["action"]["primary"] is None
def test_envelope_ttl_3600_for_installation(self, tmp_path: Path) -> None:
"""Installation probe has ttl_seconds=3600."""
(tmp_path / "paperforge.json").write_text(
json.dumps({"system_dir": "99_System"}), encoding="utf-8",
)
data = _run_probe("installation", tmp_path)
assert data["ttl_seconds"] == 3600
def test_envelope_ttl_3600_for_help(self, tmp_path: Path) -> None:
"""Help probe has ttl_seconds=3600."""
data = _run_probe("help", tmp_path)
assert data["ttl_seconds"] == 3600
def test_action_primary_full_fields_non_null(self, tmp_path: Path) -> None:
"""Non-null action.primary has all required fields with correct types."""
(tmp_path / "paperforge.json").write_text(
json.dumps({"system_dir": "99_System"}), encoding="utf-8",
)
# Use the worktrees vault which has no paperforge.json → missing_input → has action
empty_vault = tmp_path / "no_config"
empty_vault.mkdir()
data = _run_probe("installation", empty_vault)
assert data["action"]["primary"] is not None
_assert_action_primary_shape(data["action"]["primary"])
# ---------------------------------------------------------------------------
# Installation probe states
# ---------------------------------------------------------------------------
class TestInstallationProbe:
"""State mapping for the installation module probe."""
def test_missing_paperforge_json(self, tmp_path: Path) -> None:
"""No paperforge.json -> missing_input + set_config action."""
data = _run_probe("installation", tmp_path)
assert data["module"] == "installation"
assert data["capability_state"] == "missing_input"
assert data["severity"] == "warning"
assert data["reason"]["code"] == "installation.config_missing"
assert data["action"]["primary"] is not None
_assert_action_primary_shape(data["action"]["primary"])
assert data["action"]["primary"]["verb"] == "set_config"
assert data["action"]["primary"]["label"] == "Set config"
def test_corrupt_paperforge_json(self, tmp_path: Path) -> None:
"""Invalid JSON in paperforge.json -> unavailable + setup action."""
(tmp_path / "paperforge.json").write_text("not valid json {{{", encoding="utf-8")
data = _run_probe("installation", tmp_path)
assert data["capability_state"] == "unavailable"
assert data["severity"] == "error"
assert data["reason"]["code"] == "installation.config_corrupt"
assert data["action"]["primary"] is not None
_assert_action_primary_shape(data["action"]["primary"])
assert data["action"]["primary"]["verb"] == "setup"
def test_empty_object_config_corrupt(self, tmp_path: Path) -> None:
"""Empty dict {} → config_corrupt/unavailable."""
(tmp_path / "paperforge.json").write_text("{}", encoding="utf-8")
data = _run_probe("installation", tmp_path)
assert data["capability_state"] == "unavailable"
assert data["severity"] == "error"
assert data["reason"]["code"] == "installation.config_corrupt"
assert data["action"]["primary"] is not None
assert data["action"]["primary"]["verb"] == "setup"
def test_list_config_corrupt(self, tmp_path: Path) -> None:
"""JSON array → config_corrupt/unavailable."""
(tmp_path / "paperforge.json").write_text('["a", "b"]', encoding="utf-8")
data = _run_probe("installation", tmp_path)
assert data["capability_state"] == "unavailable"
assert data["reason"]["code"] == "installation.config_corrupt"
assert data["action"]["primary"]["verb"] == "setup"
def test_primitive_config_corrupt(self, tmp_path: Path) -> None:
"""JSON string/number/true → config_corrupt/unavailable."""
for content in ('"just a string"', '42', 'true'):
(tmp_path / "paperforge.json").write_text(content, encoding="utf-8")
data = _run_probe("installation", tmp_path)
assert data["capability_state"] == "unavailable", f"content={content}"
assert data["reason"]["code"] == "installation.config_corrupt"
def test_unrecognized_keys_config_corrupt(self, tmp_path: Path) -> None:
"""Dict with keys but no vault_config or legacy path keys → config_corrupt."""
(tmp_path / "paperforge.json").write_text(
json.dumps({"name": "Foo", "version": "1.0"}), encoding="utf-8",
)
data = _run_probe("installation", tmp_path)
assert data["capability_state"] == "unavailable"
assert data["reason"]["code"] == "installation.config_corrupt"
assert data["action"]["primary"]["verb"] == "setup"
def test_v2_vault_config_accepted(self, tmp_path: Path) -> None:
"""v2 format with vault_config is accepted as valid config."""
(tmp_path / "paperforge.json").write_text(
json.dumps({"vault_config": {"system_dir": "99_System"}}), encoding="utf-8",
)
data = _run_probe("installation", tmp_path)
assert data["capability_state"] == "ready"
assert data["reason"]["code"] == "installation.ready"
def test_legacy_path_keys_accepted(self, tmp_path: Path) -> None:
"""Legacy format with at least one legacy path key is accepted."""
(tmp_path / "paperforge.json").write_text(
json.dumps({"system_dir": "99_System", "resources_dir": "Resources"}), encoding="utf-8",
)
data = _run_probe("installation", tmp_path)
assert data["capability_state"] == "ready"
assert data["reason"]["code"] == "installation.ready"
def test_ready_reason_code(self, tmp_path: Path) -> None:
"""Ready state has correct reason code and null action."""
(tmp_path / "paperforge.json").write_text(
json.dumps({"system_dir": "99_System"}), encoding="utf-8",
)
data = _run_probe("installation", tmp_path)
assert data["capability_state"] == "ready"
assert data["severity"] == "ok"
assert data["reason"]["code"] == "installation.ready"
assert data["action"]["primary"] is None
# ---------------------------------------------------------------------------
# Help probe states
# ---------------------------------------------------------------------------
class TestHelpProbe:
"""State mapping for the help module probe."""
def test_ready(self, tmp_path: Path) -> None:
"""Help probe returns ready when skill source is available."""
data = _run_probe("help", tmp_path)
assert data["module"] == "help"
assert data["capability_state"] == "ready"
assert data["severity"] == "ok"
assert data["action"]["primary"] is None
assert data["reason"]["code"] == "help.ready"
def test_limited_when_skill_source_missing(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""When SKILL.md is absent, help probe returns limited with setup recovery."""
from paperforge.commands.probe import probe_help
orig = Path.exists
def fake_exists(self) -> bool:
s = str(self)
if "SKILL.md" in s and "skills" in s:
return False
return orig(self)
monkeypatch.setattr(Path, "exists", fake_exists)
envelope = probe_help(tmp_path)
assert envelope["capability_state"] == "limited"
assert envelope["severity"] == "warning"
assert envelope["reason"]["code"] == "help.docs_missing"
action = envelope["action"]["primary"]
_assert_action_primary_shape(action)
assert action["verb"] == "setup"
# ---------------------------------------------------------------------------
# CLI argument validation
# ---------------------------------------------------------------------------
class TestProbeCliArgs:
"""CLI argument validation for the probe command."""
def test_rejects_unsupported_module(self, tmp_path: Path) -> None:
"""Unknown module name is rejected by argparse."""
result = subprocess.run(
[sys.executable, "-m", "paperforge", "--vault", str(tmp_path),
"probe", "ocr", "--json"],
capture_output=True, text=True, timeout=15,
)
assert result.returncode != 0
def test_rejects_help_module(self, tmp_path: Path) -> None:
"""--help/-h on probe subcommand shows usage."""
result = subprocess.run(
[sys.executable, "-m", "paperforge", "--vault", str(tmp_path),
"probe", "--help"],
capture_output=True, text=True, timeout=15,
)
assert result.returncode == 0
assert "usage:" in result.stdout.lower() or "usage:" in result.stderr.lower()
def test_module_required(self, tmp_path: Path) -> None:
"""probe without a module argument fails."""
result = subprocess.run(
[sys.executable, "-m", "paperforge", "--vault", str(tmp_path),
"probe", "--json"],
capture_output=True, text=True, timeout=15,
)
assert result.returncode != 0