mirror of
https://github.com/lllin000/PaperForge.git
synced 2026-07-22 06:50:53 +00:00
feat: add OCR rebuild UX and capability tracer
This commit is contained in:
parent
e56a5faf44
commit
85a7ae30a2
12 changed files with 2415 additions and 287 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
297
paperforge/commands/probe.py
Normal file
297
paperforge/commands/probe.py
Normal 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
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: "运行中",
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
664
paperforge/plugin/tests/capability-state.test.ts
Normal file
664
paperforge/plugin/tests/capability-state.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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
347
tests/test_probe.py
Normal 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
|
||||
Loading…
Reference in a new issue