mirror of
https://github.com/khodges42/nightShift.git
synced 2026-06-14 10:08:37 +00:00
Web UI quality of life
This commit is contained in:
parent
347ec583e6
commit
76b7942c4a
|
|
@ -174,6 +174,23 @@ class PipelineRunner:
|
|||
retry_notes.append(f"Context update from '{stage.id}': {result.context_update}")
|
||||
|
||||
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
|
||||
continue
|
||||
|
||||
|
|
@ -595,11 +612,24 @@ class PipelineRunner:
|
|||
except PipelineError:
|
||||
summary_filename = "implementation-summary.md" if retry_count == 0 else f"repair-summary-{retry_count}.md"
|
||||
reason = str(exc)
|
||||
if "generated patch has no changes" in reason and retry_count:
|
||||
reason = (
|
||||
"File writer error: repair output produced no changes relative to "
|
||||
"the current workspace. The previous patch was applied, tests failed, "
|
||||
"and the repair attempt repeated the already-applied file content."
|
||||
if "generated patch has no changes" in reason:
|
||||
next_stage = self._stage_after_patch_flow(stage.id)
|
||||
reason = self._no_changes_reason(retry_count)
|
||||
summary_path = self.artifacts.write_stage_output(
|
||||
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(
|
||||
task.id,
|
||||
|
|
@ -641,6 +671,32 @@ class PipelineRunner:
|
|||
suffix = f"-{retry_count}" if retry_count else ""
|
||||
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(
|
||||
self,
|
||||
stage: StageConfig,
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ def read_artifact(run_path: Path, relative_path: str) -> str:
|
|||
|
||||
|
||||
def render_dashboard(artifact_dir: str | Path) -> str:
|
||||
artifact_path = Path(artifact_dir).resolve()
|
||||
runs = list_runs(artifact_dir)
|
||||
body = [
|
||||
'<meta http-equiv="refresh" content="5">',
|
||||
|
|
@ -64,7 +65,7 @@ def render_dashboard(artifact_dir: str | Path) -> str:
|
|||
'<main class="shell">',
|
||||
'<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="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>",
|
||||
]
|
||||
if not runs:
|
||||
|
|
@ -244,6 +245,7 @@ h1 { font-size: 40px; line-height: 1; }
|
|||
h2 { font-size: 18px; }
|
||||
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; }
|
||||
.path-pill { display: inline-block; margin-top: 8px; color: #c9d7ea; font: 11px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||
.run-card, .empty {
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(16, 22, 34, .84);
|
||||
|
|
|
|||
|
|
@ -498,6 +498,55 @@ Acceptance Criteria:
|
|||
self.assertEqual(result.status, "complete")
|
||||
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:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ class WebDashboardTests(unittest.TestCase):
|
|||
self.assertIn("Log Tail", dashboard)
|
||||
self.assertIn("Planner proposed", dashboard)
|
||||
self.assertIn("FAILED", dashboard)
|
||||
self.assertIn(str((root / ".nightshift").resolve()), dashboard)
|
||||
self.assertIn("/assets/logo.png", dashboard)
|
||||
self.assertIn("artifact-link", dashboard)
|
||||
self.assertIn("line 119", dashboard)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user