Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ Scope key:
| `episteme detect [path]` | project | Score which harness type fits the project. |
| `episteme harness {list,apply}` | project | List available harnesses; apply one to a project. A harness defines execution profile + workflow constraints for a project type. |
| `episteme worktree` | project | Create a git worktree for a bounded task in the current repo. |
| `episteme viewer` | project | Live local governance dashboard (E174): global operator-home state + current-project surface/doc-map/staleness + the DOC ADVISORY feed, polling every 3s at `localhost:37776`; auto-opens the browser (`--no-open` to suppress). Menu-bar companion: `tools/xbar/episteme.30s.sh`. |
| `episteme viewer` | project | Live local governance dashboard (E174) + control plane (E175): global operator-home state, current-project surface/doc-map/staleness, the DOC ADVISORY feed (3s poll, `localhost:37776`, auto-opens browser; `--no-open`). Actions: Tier 1 diagnostics + Tier 2 reversible maintenance as token-authenticated buttons (loopback-only, Host-checked); Tier 3 governance mutations render as copyable commands — deliberately not executable from the UI. Menu-bar companion: `tools/xbar/episteme.30s.sh`. |
| `episteme capture` | project | Draft a `reasoning-surface.json` skeleton from unstructured text (Slack thread, PR desc, ticket, email). Reads stdin. |

## Framework internals
Expand Down
208 changes: 208 additions & 0 deletions src/episteme/viewer/control.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
"""Viewer control plane — operator-ratified action tiers (Event 175).

Positive system: an action exists ONLY if enumerated here. The tier boundary
is governance, not convenience — Tier 3 (governance mutations: rigor/mode
toggles, kernel manifest updates, review verdicts, profile acks) is
deliberately ABSENT from the executable registry and ships as a copyable
command catalog instead. The kernel's thesis is deliberate friction on
consequential ops; a UI that one-clicks them would bypass the exact ceremony
it visualizes. The absence of the endpoint IS the enforcement.

Security model (all enforced in server.py, tested in
tests/test_viewer_control.py): control is loopback-only (hard-disabled on any
other bind), every POST carries the per-server-start session token, and the
Host header must be in the loopback allowlist (DNS-rebinding defense).
"""

from __future__ import annotations

import subprocess
import sys
import threading
from dataclasses import dataclass
from typing import List, Optional

#: Hard cap on captured action output returned to the client.
OUTPUT_CAP_BYTES = 65_536
#: Per-action subprocess timeout (sync can take a few seconds; doctor too).
ACTION_TIMEOUT_S = 120

#: Marker appended when output is truncated — the UI must show the cut,
#: never render a silently shortened result as complete.
TRUNCATION_MARKER = "\n… [output truncated at 64KB]"


@dataclass(frozen=True)
class Action:
name: str # registry key, also the URL segment
tier: int # 1 = diagnostic, 2 = reversible maintenance (confirm-gated in UI)
label: str # button text
argv: tuple # episteme CLI args
description: str


#: The executable registry. Tier 3 is NOT here — by design, not omission.
ACTIONS: dict = {
a.name: a
for a in (
Action("doctor", 1, "doctor", ("doctor",),
"Verify runtime wiring (read-only)"),
Action("kernel_verify", 1, "kernel verify", ("kernel", "verify"),
"Check managed kernel files against the manifest (read-only)"),
Action("docs_lint", 1, "docs lint", ("docs", "lint"),
"Validate lifecycle markers on tracked docs (read-only)"),
Action("docs_index_check", 1, "docs index --check", ("docs", "index", "--check"),
"Verify the generated docs index is current (read-only)"),
Action("sync", 2, "sync", ("sync",),
"Propagate kernel memory + governance policies to agent surfaces"),
Action("docs_index_write", 2, "docs index", ("docs", "index"),
"Regenerate the docs/README.md index from lifecycle markers"),
Action("docmap_rebuild", 2, "rebuild doc map", (),
"Rebuild the code→doc reverse-index cache for this project"),
)
}

#: Tier 3 — rendered by the UI as copyable commands, never executable here.
#: Each entry names the ceremony the CLI flow enforces that a button would skip.
TIER3_CATALOG: List[dict] = [
{"command": "episteme kernel update",
"why_cli": "re-baselines the kernel integrity manifest — verify per-file provenance first"},
{"command": "episteme review",
"why_cli": "spot-check verdicts are operator judgment; each entry deserves the full context"},
{"command": "episteme profile audit ack <run_id>",
"why_cli": "acknowledging profile drift changes derived gate knobs"},
{"command": "episteme deferred list",
"why_cli": "closing deferred discoveries requires verification against reality (kernel rule 11)"},
]

_run_lock = threading.Lock()


def _cli_argv(extra: tuple) -> List[str]:
# `python -m episteme` has no __main__; spawn the CLI through -c. Trailing
# args land in the child's sys.argv[1:], which argparse consumes normally.
return [
sys.executable,
"-c",
"import sys; from episteme.cli import main; sys.exit(main())",
*extra,
]


def _run_docmap_rebuild(cwd: str) -> "tuple[int, str]":
from pathlib import Path

from episteme import doc_references

root = Path(cwd)
cache = root / ".episteme" / "cache" / "doc_map.json"
try:
cache.unlink(missing_ok=True)
except OSError:
pass
index = doc_references.cached_reverse_index(root)
return 0, (
f"doc map rebuilt: {len(index)} targets, "
f"{sum(len(v) for v in index.values())} edges"
)


def run_action(name: str, cwd: str) -> dict:
"""Execute one registered action; single-flight; output capped.

Returns a JSON-ready dict. ``busy`` (HTTP 409 upstream) when another
action is running — one operator, one mutation at a time.
"""
action: Optional[Action] = ACTIONS.get(name)
if action is None:
return {"error": "unknown_action", "name": name}
if not _run_lock.acquire(blocking=False):
return {"error": "busy"}
try:
# Broad guard (review finding): the in-process branch raised
# RuntimeError in a non-git project and escaped as a raw 500; every
# action failure must come back as clean JSON — a control plane that
# tracebacks teaches the operator to distrust its buttons.
try:
if name == "docmap_rebuild":
code, output = _run_docmap_rebuild(cwd)
else:
code, output = _run_subprocess(action.argv, cwd)
except _ActionTimeout:
return {
"error": "timeout",
"name": name,
"timeout_s": ACTION_TIMEOUT_S,
}
except Exception as exc:
return {
"error": "action_failed",
"name": name,
"detail": f"{type(exc).__name__}: {exc}"[:500],
}
data = output.encode("utf-8", errors="replace")
truncated = len(data) > OUTPUT_CAP_BYTES
if truncated:
# Byte-accurate cut (review nit: char slicing let multibyte
# output exceed the advertised cap ~4x); decode-with-ignore
# drops at most one split codepoint at the boundary.
output = data[:OUTPUT_CAP_BYTES].decode("utf-8", "ignore") + TRUNCATION_MARKER
return {
"name": name,
"tier": action.tier,
"exit_code": code,
"output": output,
"truncated": truncated,
}
finally:
_run_lock.release()


class _ActionTimeout(Exception):
pass


def _run_subprocess(argv_tail: tuple, cwd: str) -> "tuple[int, str]":
"""Run one CLI action in its own session; kill the whole group on timeout.

``subprocess.run(timeout=...)`` reaps only the direct child — a git
process spawned by sync/doctor would orphan (review nit). Popen +
start_new_session lets the timeout path kill the process group.
"""
import os
import signal

proc = subprocess.Popen(
_cli_argv(argv_tail),
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
cwd=cwd,
start_new_session=True,
)
try:
output, _ = proc.communicate(timeout=ACTION_TIMEOUT_S)
except subprocess.TimeoutExpired:
try:
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
except (OSError, ProcessLookupError):
proc.kill()
proc.wait()
raise _ActionTimeout() from None
return proc.returncode, output or ""


def catalog() -> dict:
"""The full action surface for the UI: executable tiers + Tier 3 display."""
return {
"actions": [
{
"name": a.name,
"tier": a.tier,
"label": a.label,
"description": a.description,
}
for a in sorted(ACTIONS.values(), key=lambda a: (a.tier, a.name))
],
"tier3": TIER3_CATALOG,
}
85 changes: 85 additions & 0 deletions src/episteme/viewer/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,21 @@
.feed td.t { white-space: nowrap; color: var(--dim); }
.empty { color: var(--dim); padding: 8px 0; }
.q { color: var(--dim); font-size: 12px; margin-top: 8px; overflow-wrap: anywhere; }
.btnrow { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 8px; }
.btnrow button { background: transparent; border: 1px solid var(--edge); color: var(--text);
font: 12px var(--mono); padding: 6px 12px; border-radius: 6px; cursor: pointer; }
.btnrow button:hover { border-color: var(--accent); }
.btnrow button:disabled { opacity: .4; cursor: wait; }
.btnrow button.t2 { border-style: dashed; }
.actout { background: var(--bg); border: 1px solid var(--edge); border-radius: 6px;
padding: 10px 12px; margin: 8px 0; max-height: 320px; overflow: auto;
font-size: 12px; white-space: pre-wrap; overflow-wrap: anywhere; }
.actout.err { border-color: var(--bad); }
.t3 { margin-top: 10px; }
.t3 h3 { font-size: 11px; color: var(--dim); font-weight: 500; margin-bottom: 6px; }
.t3 .cmd { display: flex; gap: 10px; align-items: baseline; padding: 2px 0; }
.t3 code { color: var(--warn); cursor: copy; }
.t3 .why { color: var(--dim); font-size: 12px; }
</style>
</head>
<body>
Expand Down Expand Up @@ -63,6 +78,17 @@ <h2>Global — operator home</h2>
<div class="row"><span class="k">noise watch</span><span class="v" id="g-noise">…</span></div>
</section>

<section class="feed" id="actions-panel">
<h2>Actions</h2>
<div id="act-t1" class="btnrow"></div>
<div id="act-t2" class="btnrow"></div>
<pre id="act-out" class="actout" hidden></pre>
<div class="t3">
<h3>Operator-gated (run in a terminal — deliberate friction is the point)</h3>
<div id="act-t3"></div>
</div>
</section>

<section class="feed">
<h2>Doc advisories — docs tracked as code changes</h2>
<div id="feed-empty" class="empty">No advisories logged yet in this project. Edit an implementation file that a doc cites and it will appear here.</div>
Expand All @@ -75,8 +101,66 @@ <h2>Doc advisories — docs tracked as code changes</h2>

<script>
const POLL_MS = 3000;
const TOKEN = "{{EPISTEME_TOKEN}}";
const $ = (id) => document.getElementById(id);

async function loadActions() {
let cat;
try { cat = await getJSON("/api/control/catalog"); } catch { return; }
const t1 = $("act-t1"), t2 = $("act-t2"), t3 = $("act-t3");
if (!cat.control_enabled) {
t1.textContent = "control disabled (non-loopback bind)";
return;
}
for (const a of cat.actions) {
const b = document.createElement("button");
b.textContent = a.label;
b.title = a.description;
if (a.tier === 2) b.classList.add("t2");
b.addEventListener("click", () => runAction(a, b));
(a.tier === 1 ? t1 : t2).appendChild(b);
}
for (const c of cat.tier3) {
const row = document.createElement("div"); row.className = "cmd";
const code = document.createElement("code"); code.textContent = c.command;
code.title = "click to copy";
code.addEventListener("click", () => navigator.clipboard?.writeText(c.command));
const why = document.createElement("span"); why.className = "why"; why.textContent = c.why_cli;
row.append(code, why);
t3.appendChild(row);
}
}

async function runAction(a, btn) {
if (a.tier === 2 && !confirm(`Run '${a.label}'? (${a.description})`)) return;
const out = $("act-out");
btn.disabled = true;
out.hidden = false; out.className = "actout";
out.textContent = `$ ${a.label} …`;
try {
const res = await fetch(`/api/control/${a.name}`, {
method: "POST",
headers: { "X-Episteme-Token": TOKEN },
});
const body = await res.json();
if (!res.ok) {
out.className = "actout err";
out.textContent = body.error === "token_mismatch"
? "session token expired (server restarted) — reload the page"
: `error: ${body.error}`;
return;
}
out.className = body.exit_code === 0 ? "actout" : "actout err";
out.textContent = `$ ${a.label} → exit ${body.exit_code}` +
(body.truncated ? " [truncated]" : "") + "\n\n" + body.output;
} catch (err) {
out.className = "actout err";
out.textContent = `request failed: ${err.message}`;
} finally {
btn.disabled = false;
}
}

function fmtAge(mins) {
if (mins == null) return "—";
return mins < 90 ? `${Math.round(mins)}m` : `${(mins / 60).toFixed(1)}h`;
Expand Down Expand Up @@ -169,6 +253,7 @@ <h2>Doc advisories — docs tracked as code changes</h2>
}
}
tick();
loadActions();
</script>
</body>
</html>
Loading
Loading