Web UI quality of life

This commit is contained in:
K. Hodges 2026-05-17 16:25:25 -07:00
parent 347ec583e6
commit 76b7942c4a
4 changed files with 114 additions and 6 deletions

View File

@ -174,6 +174,23 @@ class PipelineRunner:
retry_notes.append(f"Context update from '{stage.id}': {result.context_update}") retry_notes.append(f"Context update from '{stage.id}': {result.context_update}")
if result.status == "pass": if result.status == "pass":
if result.next_stage:
if result.next_stage not in stage_indexes:
final_status = "failed"
final_reason = (
f"Stage '{stage.id}' requested unknown next stage '{result.next_stage}'."
)
break
self.logger.event(
"stage.next",
"Jumping to requested next stage",
run_id=self.artifacts.run_id,
task_id=task.id,
stage_id=stage.id,
next_stage=result.next_stage,
)
index = stage_indexes[result.next_stage]
continue
index += 1 index += 1
continue continue
@ -595,11 +612,24 @@ class PipelineRunner:
except PipelineError: except PipelineError:
summary_filename = "implementation-summary.md" if retry_count == 0 else f"repair-summary-{retry_count}.md" summary_filename = "implementation-summary.md" if retry_count == 0 else f"repair-summary-{retry_count}.md"
reason = str(exc) reason = str(exc)
if "generated patch has no changes" in reason and retry_count: if "generated patch has no changes" in reason:
reason = ( next_stage = self._stage_after_patch_flow(stage.id)
"File writer error: repair output produced no changes relative to " reason = self._no_changes_reason(retry_count)
"the current workspace. The previous patch was applied, tests failed, " summary_path = self.artifacts.write_stage_output(
"and the repair attempt repeated the already-applied file content." task.id,
summary_filename,
f"# Implementation Summary\n\nStatus: pass\nReason: {reason}\n",
)
return StageResult(
stage.id,
"pass",
reason,
output_path=result.output_path,
next_stage=next_stage,
context_update=(
f"Implementation summary: "
f"{summary_path.relative_to(self.config.project.root).as_posix()}"
),
) )
self.artifacts.write_stage_output( self.artifacts.write_stage_output(
task.id, task.id,
@ -641,6 +671,32 @@ class PipelineRunner:
suffix = f"-{retry_count}" if retry_count else "" suffix = f"-{retry_count}" if retry_count else ""
return replace(stage, output=f"{stage.id}-agent-output{suffix}.md") return replace(stage, output=f"{stage.id}-agent-output{suffix}.md")
def _stage_after_patch_flow(self, current_stage_id: str) -> str | None:
stages = list(self.config.pipeline.stages)
stage_indexes = {stage.id: index for index, stage in enumerate(stages)}
start = stage_indexes.get(current_stage_id)
if start is None:
return None
patch_stage_types = {"patch_normalizer", "patch_validator", "patch_apply"}
for stage in stages[start + 1:]:
if stage.type in patch_stage_types:
continue
return stage.id
return None
def _no_changes_reason(self, retry_count: int) -> str:
if retry_count:
return (
"File writer produced no changes relative to the current workspace. "
"The previous patch may already be applied; skipping patch stages and "
"continuing with verification."
)
return (
"File writer produced no changes relative to the current workspace. "
"The task may already be applied locally; skipping patch stages and "
"continuing with verification."
)
def _run_patch_normalizer_stage( def _run_patch_normalizer_stage(
self, self,
stage: StageConfig, stage: StageConfig,

View File

@ -57,6 +57,7 @@ def read_artifact(run_path: Path, relative_path: str) -> str:
def render_dashboard(artifact_dir: str | Path) -> str: def render_dashboard(artifact_dir: str | Path) -> str:
artifact_path = Path(artifact_dir).resolve()
runs = list_runs(artifact_dir) runs = list_runs(artifact_dir)
body = [ body = [
'<meta http-equiv="refresh" content="5">', '<meta http-equiv="refresh" content="5">',
@ -64,7 +65,7 @@ def render_dashboard(artifact_dir: str | Path) -> str:
'<main class="shell">', '<main class="shell">',
'<header class="hero">', '<header class="hero">',
'<div class="brand"><img src="/assets/logo.png" alt="NightShift logo"><div><p class="eyebrow">Local artifact dashboard</p><h1>NightShift</h1></div></div>', '<div class="brand"><img src="/assets/logo.png" alt="NightShift logo"><div><p class="eyebrow">Local artifact dashboard</p><h1>NightShift</h1></div></div>',
'<div class="hero-copy">Read-only run review. Auto-refreshes every 5 seconds.</div>', f'<div class="hero-copy">Read-only run review. Auto-refreshes every 5 seconds.<br><span class="path-pill">{escape(str(artifact_path))}</span></div>',
"</header>", "</header>",
] ]
if not runs: if not runs:
@ -244,6 +245,7 @@ h1 { font-size: 40px; line-height: 1; }
h2 { font-size: 18px; } h2 { font-size: 18px; }
h3 { font-size: 13px; color: var(--muted); text-transform: uppercase; letter-spacing: .10em; margin-bottom: 12px; } h3 { font-size: 13px; color: var(--muted); text-transform: uppercase; letter-spacing: .10em; margin-bottom: 12px; }
.hero-copy { color: var(--muted); max-width: 420px; text-align: right; } .hero-copy { color: var(--muted); max-width: 420px; text-align: right; }
.path-pill { display: inline-block; margin-top: 8px; color: #c9d7ea; font: 11px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.run-card, .empty { .run-card, .empty {
border: 1px solid var(--line); border: 1px solid var(--line);
background: rgba(16, 22, 34, .84); background: rgba(16, 22, 34, .84);

View File

@ -498,6 +498,55 @@ Acceptance Criteria:
self.assertEqual(result.status, "complete") self.assertEqual(result.status, "complete")
self.assertIn("@@ -1 +1 @@", patch.read_text(encoding="utf-8")) self.assertIn("@@ -1 +1 @@", patch.read_text(encoding="utf-8"))
def test_file_writer_no_changes_skips_patch_stages_and_runs_tests(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
_write_common_files(root)
(root / "app.py").write_text("new\n", encoding="utf-8")
(root / "fake_writer.py").write_text(
"\n".join(
[
"print('```file:app.py')",
"print('new')",
"print('```')",
]
),
encoding="utf-8",
)
test_command = 'python -c "from pathlib import Path; raise SystemExit(0 if Path(\'app.py\').read_text() == \'new\\n\' else 1)"'
stages = (
StageConfig(id="write", type="file_writer", agent="writer"),
StageConfig(id="normalize", type="patch_normalizer"),
StageConfig(id="validate", type="patch_validator"),
StageConfig(id="apply", type="patch_apply", mode="apply"),
StageConfig(id="test", type="command", commands=(test_command,), output="test-output.txt"),
)
config = make_config(root, stages)
config = replace(
config,
safety=SafetyConfig(
require_clean_worktree=False,
scoped_paths=(".",),
allowed_commands=(test_command,),
forbidden_commands=("rm -rf",),
),
)
config.agents["writer"] = AgentConfig(
id="writer",
backend="command",
command="python fake_writer.py",
system_prompt=Path("planner.md"),
)
runner = PipelineRunner(config, ArtifactStore(root, ".nightshift", run_id="test-run"))
result = runner.run_task(parse_tasks(TASK_MD)[0])
task_dir = root / ".nightshift" / "runs" / "test-run" / "tasks" / "TASK-001"
self.assertEqual(result.status, "complete")
self.assertTrue((task_dir / "test-output.txt").exists())
self.assertFalse((task_dir / "normalized.patch").exists())
self.assertFalse((task_dir / "patch-validation.md").exists())
def test_patch_validator_rejects_unsafe_patch(self) -> None: def test_patch_validator_rejects_unsafe_patch(self) -> None:
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
root = Path(directory) root = Path(directory)

View File

@ -39,6 +39,7 @@ class WebDashboardTests(unittest.TestCase):
self.assertIn("Log Tail", dashboard) self.assertIn("Log Tail", dashboard)
self.assertIn("Planner proposed", dashboard) self.assertIn("Planner proposed", dashboard)
self.assertIn("FAILED", dashboard) self.assertIn("FAILED", dashboard)
self.assertIn(str((root / ".nightshift").resolve()), dashboard)
self.assertIn("/assets/logo.png", dashboard) self.assertIn("/assets/logo.png", dashboard)
self.assertIn("artifact-link", dashboard) self.assertIn("artifact-link", dashboard)
self.assertIn("line 119", dashboard) self.assertIn("line 119", dashboard)