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}")
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user