Skip to content
Draft
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ All notable changes to this project will be documented in this file.
- Vue Templates tab: markdown + ADF preview, and **Open in ADF Viewer after write** (`open_viewer` on `/api/templates/render`)
- Vue SQLite work browser: `/api/sqlite/works` filter + jump to Templates / Issues / ADF with the Work ID filled
- Vue SQLite work detail: click a Work ID to read requirement / canvas / analysis via `POST /api/sqlite/work` (git files, not sqlite blobs)
- Vue Install tab: detect cards + parsed `/api/run` summary (would/created/checks/next steps); raw log remains
- Vue Dashboard jumps: active Work ID, suggestions, and activity open SQLite / Templates / Issues / ADF with that id filled
- Vue3 ops console **Dashboard** + **Issues** tabs (parity with the Flask console) and Playwright coverage for refresh, tracker save/toggle, link preview, and sync dry-run
- Vue3 Persistence **Check ledger parity** / **Parity + repair** buttons (same `/api/persistence/parity` as the Flask console)
Expand Down
2 changes: 1 addition & 1 deletion console-ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Vite + Vue3 shell that talks to the existing Flask installer JSON API (`/api/*`)
- **Dashboard** (default) → status / activity / suggestions; jumps to SQLite / Templates / Issues / ADF (`/api/dashboard/*`)
- **Persistence** → status + save + ledger parity (`/api/persistence/*`)
- **Templates** → list/render/write ADF with markdown preview; optional open-in-viewer (`/api/templates/*`)
- **Install** → detect + run/verify (`/api/detect`, `/api/run`)
- **Install** → detect cards + run/verify with parsed summary (`/api/detect`, `/api/run`)
- **SQLite** → status + rebuild + filterable work browser + requirement/canvas/analysis detail (`/api/sqlite/*`)
- **Rollback** → backups + restore (`/api/backups`, `/api/rollback`)
- **Guide** → config/probe/lifecycle (`/api/guide/*`)
Expand Down
70 changes: 70 additions & 0 deletions console-ui/src/components/InstallTab.vue
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const statusClass = ref("");
const log = ref("Awaiting action…");
const loading = ref(false);
const lastDetect = ref(null);
const lastResult = ref(null);

function assistants() {
if (asAll.value) return ["all"];
Expand Down Expand Up @@ -96,6 +97,7 @@ async function run(actionOverride) {
no_backup: noBackup.value,
with_python_engine: withEngine.value,
});
lastResult.value = data;
const cmd = (data.command || []).join(" ");
log.value = (cmd ? `$ ${cmd}\n\n` : "") + (data.log || data.error || "");
if (ok && data.ok !== false) {
Expand All @@ -115,8 +117,14 @@ async function run(actionOverride) {
}
}

function assistantNames(info) {
const a = (info && info.assistants) || {};
return ["cursor", "copilot", "claude"].filter((name) => a[name]);
}

function clearLog() {
log.value = "Awaiting action…";
lastResult.value = null;
statusText.value = "Ready.";
statusClass.value = "";
}
Expand All @@ -139,6 +147,27 @@ function clearLog() {
<span class="mode-pill" :data-mode="modePill" data-testid="mode-pill">{{ modePill }}</span>
</div>
<p class="meta" data-testid="detect-detail">{{ detectDetail }}</p>
<div v-if="lastDetect && lastDetect.mode" class="stats" data-testid="detect-stats">
<div class="stat-card">
<div class="n" data-testid="detect-mode">{{ lastDetect.mode }}</div>
<div class="l">mode</div>
</div>
<div class="stat-card">
<div class="n" data-testid="detect-recommendation">{{ lastDetect.recommendation }}</div>
<div class="l">recommend</div>
</div>
<div class="stat-card">
<div class="n" data-testid="detect-marker-count">{{ (lastDetect.markers || []).length }}</div>
<div class="l">markers</div>
</div>
<div class="stat-card">
<div class="n" data-testid="detect-assistants">{{ assistantNames(lastDetect).join(", ") || "none" }}</div>
<div class="l">adapters</div>
</div>
</div>
<ul v-if="lastDetect && (lastDetect.markers || []).length" class="result-list" data-testid="detect-markers">
<li v-for="m in lastDetect.markers" :key="m">{{ m }}</li>
</ul>
<div class="checks">
<label class="check"><input v-model="action" type="radio" value="auto" data-testid="action-auto" /> Auto</label>
<label class="check"><input v-model="action" type="radio" value="install" data-testid="action-install" /> Force install</label>
Expand Down Expand Up @@ -166,6 +195,47 @@ function clearLog() {
<button class="btn btn-ghost" type="button" data-testid="btn-clear" @click="clearLog">Clear log</button>
</div>
<p class="status" :class="statusClass" data-testid="run-status">{{ statusText }}</p>
<section v-if="lastResult && lastResult.summary" class="detail-panel" data-testid="install-summary">
<h3 data-testid="install-headline">{{ lastResult.summary.headline || lastResult.summary.action }}</h3>
<div class="stats">
<div class="stat-card">
<div class="n" data-testid="install-exit">{{ lastResult.summary.exit_code }}</div>
<div class="l">exit</div>
</div>
<div v-if="lastResult.summary.dry_run" class="stat-card">
<div class="n" data-testid="install-would-count">{{ lastResult.summary.would_count }}</div>
<div class="l">would</div>
</div>
<div v-if="lastResult.summary.created_count" class="stat-card">
<div class="n" data-testid="install-created-count">{{ lastResult.summary.created_count }}</div>
<div class="l">created</div>
</div>
<div v-if="lastResult.summary.check_ok_count || lastResult.summary.check_fail_count" class="stat-card">
<div class="n" data-testid="install-check-counts">
{{ lastResult.summary.check_ok_count }}/{{ lastResult.summary.check_ok_count + lastResult.summary.check_fail_count }}
</div>
<div class="l">checks</div>
</div>
</div>
<p v-if="lastResult.summary.command" class="meta" data-testid="install-command">{{ lastResult.summary.command }}</p>
<p v-if="lastResult.summary.framework_home" class="meta" data-testid="install-home">{{ lastResult.summary.framework_home }}</p>
<p v-if="lastResult.summary.checks_summary" class="meta" data-testid="install-checks-summary">{{ lastResult.summary.checks_summary }}</p>
<ul v-if="lastResult.summary.next_steps.length" class="result-list" data-testid="install-next-steps">
<li v-for="(step, i) in lastResult.summary.next_steps" :key="i">{{ step }}</li>
</ul>
<ul v-if="lastResult.summary.warnings.length" class="result-list" data-testid="install-warnings">
<li v-for="(w, i) in lastResult.summary.warnings" :key="i">{{ w }}</li>
</ul>
<ul v-if="lastResult.summary.would.length" class="result-list" data-testid="install-would">
<li v-for="(item, i) in lastResult.summary.would" :key="i">{{ item }}</li>
</ul>
<ul v-if="lastResult.summary.created.length" class="result-list" data-testid="install-created">
<li v-for="(item, i) in lastResult.summary.created" :key="i">{{ item }}</li>
</ul>
<ul v-if="lastResult.summary.checks_fail.length" class="result-list" data-testid="install-checks-fail">
<li v-for="(item, i) in lastResult.summary.checks_fail" :key="i">{{ item }}</li>
</ul>
</section>
<pre class="log" data-testid="install-log">{{ log }}</pre>
</section>
</template>
15 changes: 15 additions & 0 deletions console-ui/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,21 @@ td {
border: 1px solid rgba(230, 184, 77, 0.4);
}

.result-list {
margin: 0 0 0.75rem;
padding-left: 1.15rem;
max-height: 12rem;
overflow: auto;
color: var(--ink);
font-family: var(--font-mono);
font-size: 0.8rem;
}

.result-list li {
margin: 0.15rem 0;
word-break: break-word;
}

.mode-pill {
display: inline-flex;
align-items: center;
Expand Down
2 changes: 1 addition & 1 deletion docs/ops-console.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ the `--root` passed when starting the ADF Viewer.
| Tab | What it does |
|-----|----------------|
| **Dashboard** | **Default landing tab.** Active Work ID, phase, gates, suggested next command, accepted vs staged lesson counts, backend status, integration shortcuts. Work ID / suggestions / activity jump to SQLite, Templates, Issues, or ADF with that id filled. |
| **Install / Upgrade** | Detect fresh vs upgrade; run setup/upgrade/verify (dry-run supported) |
| **Install / Upgrade** | Detect fresh vs upgrade (mode / markers / adapters). Run setup/upgrade/verify and show a parsed summary (would/created/checks/next steps) plus the raw log. Dry-run supported. |
| **Persistence** | Toggle `CONTEXT_BACKENDS` backends (`git-pointers`, `sqlite`, `guide-dice`); optional Guide URL + notes → `.sdlc/persistence-config.json`. **Check ledger parity** and **Parity + repair** buttons call `sdlc-engine context parity`. Operator guide: [triple-path-context.md](triple-path-context.md) |
| **Templates** | Render ADF combos for a Work ID with markdown + JSON preview; optional write to `adf/<work-id>.adf.json` and open the ADF Viewer on that file |
| **SQLite** | `.sdlc/index.sqlite` status + rebuild, plus a filterable work browser. Click a Work ID to read the requirement / canvas / analysis (from git files). Jump to Templates / Issues / ADF |
Expand Down
217 changes: 198 additions & 19 deletions engine/src/sdlc_engine/installer/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,185 @@
from __future__ import annotations

import os
import re
import subprocess
from pathlib import Path
from typing import Any

_LIST_CAP = 40
_SECTION_HEADERS = (
("Created or updated", "created"),
("Created (", "created"),
("Skipped existing", "skipped"),
("Updated framework files", "updated"),
("Unchanged framework files", "unchanged"),
("Preserved existing project content", "preserved"),
("Consolidated", "consolidated"),
("Backups", "backups"),
)
_HEADLINE_HINTS = (
"initialization complete",
"upgrade complete",
"setup complete",
"verification passed",
"verification failed",
)
_DRY_WOULD = re.compile(r"^\[dry-run\] would (.+)$")
_VERIFY_OK = re.compile(r"^ok\s+(.+)$")
_VERIFY_FAIL = re.compile(r"^fail\s+(.+)$")
_NUMBERED = re.compile(r"^\d+\.\s+")


def summarize_run_log(
*,
action: str,
log: str,
command: list[str] | None = None,
dry_run: bool = False,
ok: bool = False,
exit_code: int = 0,
) -> dict[str, Any]:
"""Parse install/upgrade/verify script output into console-friendly lists."""
command = command or []
buckets: dict[str, list[str]] = {
"would": [],
"created": [],
"skipped": [],
"updated": [],
"unchanged": [],
"preserved": [],
"consolidated": [],
"backups": [],
"checks_ok": [],
"checks_fail": [],
"warnings": [],
"next_steps": [],
}
headline = ""
home = ""
checks_summary = ""
section: str | None = None

def _section_header(stripped: str) -> str | None:
for prefix, key in _SECTION_HEADERS:
if stripped.startswith(prefix):
return key
return None

for raw in (log or "").splitlines():
stripped = raw.strip()
if not stripped:
if section and section != "next_steps":
section = None
continue

dry = _DRY_WOULD.match(stripped)
if dry:
buckets["would"].append(dry.group(1))
section = None
continue

if stripped.startswith("WARNING"):
buckets["warnings"].append(stripped)
section = None
continue

if stripped == "Next steps:":
section = "next_steps"
continue

header = _section_header(stripped)
if header:
section = header
continue

if section == "next_steps":
if _NUMBERED.match(stripped):
buckets["next_steps"].append(stripped)
elif buckets["next_steps"] and (raw.startswith(" ") or stripped.startswith("/")):
buckets["next_steps"][-1] = f"{buckets['next_steps'][-1]} {stripped}"
elif stripped.startswith("For "):
section = None
continue

if section and raw.startswith(" ") and stripped != "none":
buckets[section].append(stripped)
continue
if section and not raw.startswith(" "):
section = None

if stripped.startswith("Framework home:"):
home = stripped.split(":", 1)[1].strip()
if stripped.startswith("Recommended next step:"):
buckets["next_steps"].append(stripped.split(":", 1)[1].strip())
if stripped.startswith("Summary:") and "checks passed" in stripped:
checks_summary = stripped
if any(hint in stripped.lower() for hint in _HEADLINE_HINTS):
headline = stripped

verify_ok = _VERIFY_OK.match(stripped)
if verify_ok and raw.lstrip().startswith("ok"):
buckets["checks_ok"].append(verify_ok.group(1))
continue
verify_fail = _VERIFY_FAIL.match(stripped)
if verify_fail and raw.lstrip().startswith("fail"):
buckets["checks_fail"].append(verify_fail.group(1))

would_count = len(buckets["would"])
return {
"action": action,
"ok": ok,
"exit_code": exit_code,
"dry_run": dry_run,
"headline": headline,
"framework_home": home,
"command": " ".join(str(part) for part in command),
"would": buckets["would"][:_LIST_CAP],
"created": buckets["created"][:_LIST_CAP],
"skipped": buckets["skipped"][:_LIST_CAP],
"updated": buckets["updated"][:_LIST_CAP],
"backups": buckets["backups"][:_LIST_CAP],
"checks_ok": buckets["checks_ok"][:_LIST_CAP],
"checks_fail": buckets["checks_fail"][:_LIST_CAP],
"warnings": buckets["warnings"][:20],
"next_steps": buckets["next_steps"][:12],
"checks_summary": checks_summary,
"would_count": would_count,
"created_count": len(buckets["created"]),
"check_ok_count": len(buckets["checks_ok"]),
"check_fail_count": len(buckets["checks_fail"]),
}


def _run_payload(
*,
action: str,
ok: bool,
exit_code: int,
command: list[str],
log: str,
dry_run: bool = False,
engine_log: str = "",
) -> dict[str, Any]:
text = (log or "").strip()
return {
"ok": ok,
"action": action,
"exit_code": exit_code,
"command": command,
"log": text,
"engine_log": (engine_log or "").strip(),
"dry_run": dry_run,
"summary": summarize_run_log(
action=action,
log=text,
command=command,
dry_run=dry_run,
ok=ok,
exit_code=exit_code,
),
}


def orchestrator_root() -> Path:
"""Locate the SDLC-SPDD orchestrator repo (engine → repo root)."""
Expand Down Expand Up @@ -77,20 +252,22 @@ def run_action(
if want_all or name in selected:
cmd.append(f"--require-{name}")
else:
return {
"ok": False,
"exit_code": 2,
"command": [],
"log": f"Unknown action: {action}",
}
return _run_payload(
action=action,
ok=False,
exit_code=2,
command=[],
log=f"Unknown action: {action}",
)

if not script.is_file():
return {
"ok": False,
"exit_code": 2,
"command": cmd,
"log": f"Script not found: {script}",
}
return _run_payload(
action=action,
ok=False,
exit_code=2,
command=cmd,
log=f"Script not found: {script}",
)

proc = subprocess.run(
cmd,
Expand All @@ -106,13 +283,15 @@ def run_action(
engine_log = _install_python_engine(root, target_path, timeout_sec=timeout_sec)
log = (log + "\n" + engine_log).strip()

return {
"ok": proc.returncode == 0,
"exit_code": proc.returncode,
"command": cmd,
"log": log.strip(),
"engine_log": engine_log.strip() if engine_log else "",
}
return _run_payload(
action=action,
ok=proc.returncode == 0,
exit_code=proc.returncode,
command=cmd,
log=log,
dry_run=dry_run,
engine_log=engine_log,
)


def _install_python_engine(root: Path, target: Path, *, timeout_sec: int) -> str:
Expand Down
Loading
Loading