diff --git a/docs/design.md b/docs/design.md index 72e2f52..f4ea295 100644 --- a/docs/design.md +++ b/docs/design.md @@ -1059,6 +1059,33 @@ Acceptance Criteria: --- +## Phase 21: Read-Only Web Dashboard + +* [ ] Add a Flask-based `nightshift web` command +* [ ] Read run state from `.nightshift/runs/` +* [ ] Show latest run summary +* [ ] Show task status and retry count +* [ ] Show stage results and artifact links +* [ ] Render markdown/plain-text artifacts safely +* [ ] Add simple auto-refresh +* [ ] Keep the dashboard read-only +* [ ] Add tests for route rendering and missing artifact handling + +Acceptance Criteria: + +* User can monitor a run from a browser without controlling execution +* Dashboard works from existing artifact files +* Missing or partial run artifacts do not crash the server +* No config, task, command, or pipeline mutation is exposed from the UI + +Notes: + +* This phase should avoid websockets and process control at first. +* The dashboard should be artifact-driven so it remains decoupled from pipeline internals. +* Start/stop controls, authentication, live log streaming, and approval gates are separate future work. + +--- + # Appendix A: Design Decisions and Rationale ## A.1 Local-first architecture diff --git a/docs/devlog/phase12.md b/docs/devlog/phase12.md new file mode 100644 index 0000000..292c614 --- /dev/null +++ b/docs/devlog/phase12.md @@ -0,0 +1,18 @@ +# Phase 12 Devlog: Status Command + +## Implemented + +- Added `nightshift/status.py`. +- Implemented project status inspection for config path, project root, task counts, next runnable task, latest run directory, and warnings. +- Wired `nightshift status --config ...` into the CLI. +- Added status tests. + +## Decisions Made + +- Status is read-only and uses existing config/task/artifact files. +- The next task is dependency-aware, so blocked tasks are not reported as runnable. +- Latest run detection is filesystem-based and uses the newest run directory by modification time. + +## Notes + +- Status warnings currently focus on dependency problems. Broader validation warnings can be added without changing the CLI shape. diff --git a/docs/devlog/phase13.md b/docs/devlog/phase13.md new file mode 100644 index 0000000..c7f95e5 --- /dev/null +++ b/docs/devlog/phase13.md @@ -0,0 +1,20 @@ +# Phase 13 Devlog: Git Safety and Diff Artifacts + +## Implemented + +- Added `nightshift/git.py`. +- Implemented clean-worktree enforcement when `require_clean_worktree` is true. +- Captured pre-run and post-run git status artifacts. +- Wrote per-task `diff.patch` artifacts. +- Handled non-git repositories and git failures gracefully when clean worktree is not required. +- Added git tests with temporary repositories. + +## Decisions Made + +- Clean-worktree enforcement runs before artifact creation so NightShift does not dirty a repo before checking it. +- If clean worktree is required and git status cannot be read, execution fails safely. +- Diff artifacts are written even when git is unavailable, with a readable explanation instead of crashing. + +## Notes + +- Existing final reports already include modified files when git status is available. diff --git a/docs/devlog/phase14.md b/docs/devlog/phase14.md new file mode 100644 index 0000000..4aa2a41 --- /dev/null +++ b/docs/devlog/phase14.md @@ -0,0 +1,19 @@ +# Phase 14 Devlog: Task Completion Updates + +## Implemented + +- Added task-file mutation helper to mark successful tasks complete. +- Successful runs update the target task from `[ ]` to `[x]`. +- Failed runs leave tasks incomplete. +- Added `task-completion.md` artifacts recording the completion decision. +- Added tests for task completion mutation and pipeline completion artifacts. + +## Decisions Made + +- Task completion uses a minimal line edit instead of rewriting the parsed task file. +- Already-completed tasks are treated as no-op updates. +- Completion happens before final report generation so reports can include task-file changes when git status is available. + +## Notes + +- More advanced task-file formatting preservation can be revisited if broader markdown support is added. diff --git a/docs/devlog/phase15.md b/docs/devlog/phase15.md new file mode 100644 index 0000000..aff024b --- /dev/null +++ b/docs/devlog/phase15.md @@ -0,0 +1,22 @@ +# Phase 15 Devlog: Multi-Task Run Mode + +## Implemented + +- Added `nightshift run --all`. +- Added `PipelineRunner.run_tasks()`. +- Processes incomplete tasks in file order. +- Reuses one artifact store/run directory for the batch. +- Stops on first failure by default. +- Added `pipeline.continue_on_task_failure` config support, defaulting to false. +- Writes aggregate run summaries with completed and failed counts. +- Added multi-task tests. + +## Decisions Made + +- `--all` and `--task` are mutually exclusive. +- Failed and blocked tasks count as failed in aggregate summaries. +- The default remains conservative: stop on first failure unless explicitly configured otherwise. + +## Notes + +- Multi-task mode is still sequential. Parallel execution remains out of scope. diff --git a/docs/devlog/phase16.md b/docs/devlog/phase16.md new file mode 100644 index 0000000..511de62 --- /dev/null +++ b/docs/devlog/phase16.md @@ -0,0 +1,21 @@ +# Phase 16 Devlog: Dependency Handling + +## Implemented + +- Parsed existing `Dependencies:` bullets into dependency lists. +- Added dependency validation for missing references and simple cycles. +- Added dependency-aware next-task selection. +- Blocked specific task runs when dependencies are incomplete. +- Blocked multi-task entries when dependencies are not satisfied by completed or earlier successful tasks. +- Reported dependency warnings through status. +- Added dependency tests. + +## Decisions Made + +- Dependencies are simple task IDs listed as bullets under `Dependencies:`. +- Dependency enforcement is deterministic and follows task file order. +- Missing references and cycles are validation errors; incomplete dependencies are runtime blockers. + +## Notes + +- No dependency solver or reordering is implemented. File order remains the source of execution order. diff --git a/nightshift/cli.py b/nightshift/cli.py index 1506dbf..8bc0601 100644 --- a/nightshift/cli.py +++ b/nightshift/cli.py @@ -10,7 +10,14 @@ from .config import validate_config from .errors import NightShiftError from .init import init_project from .pipeline import PipelineRunner -from .tasks import parse_task_file, select_next_incomplete_task, select_task_by_id +from .status import build_status, format_status +from .tasks import ( + ensure_dependencies_satisfied, + parse_task_file, + select_next_runnable_task, + select_task_by_id, + validate_task_dependencies, +) def build_parser() -> argparse.ArgumentParser: @@ -29,8 +36,10 @@ def build_parser() -> argparse.ArgumentParser: run_parser = subparsers.add_parser("run", help="Run the configured pipeline for one task.") run_parser.add_argument("--config", default="nightshift.yaml", help="Config file to use.") run_parser.add_argument("--task", help="Specific task id to run.") + run_parser.add_argument("--all", action="store_true", help="Run all runnable incomplete tasks.") - subparsers.add_parser("status", help="Status reporting is planned for a later phase.") + status_parser = subparsers.add_parser("status", help="Inspect NightShift project status.") + status_parser.add_argument("--config", default="nightshift.yaml", help="Config file to inspect.") return parser @@ -50,6 +59,7 @@ def main(argv: list[str] | None = None) -> int: if args.command == "validate": config = validate_config(args.config) tasks = parse_task_file(config.project.root, config.project.task_file) + validate_task_dependencies(tasks) incomplete = sum(1 for task in tasks if not task.completed) print(f"Config valid: {config.path}") print(f"Project: {config.project.name}") @@ -61,8 +71,23 @@ def main(argv: list[str] | None = None) -> int: if args.command == "run": config = validate_config(args.config) tasks = parse_task_file(config.project.root, config.project.task_file) - task = select_task_by_id(tasks, args.task) if args.task else select_next_incomplete_task(tasks) - result = PipelineRunner(config).run_task(task) + validate_task_dependencies(tasks) + if args.all and args.task: + parser.error("run accepts either --all or --task, not both.") + runner = PipelineRunner(config) + if args.all: + selected = [task for task in tasks if not task.completed] + result = runner.run_tasks(selected) + print(f"Status: {result.status}") + print(f"Tasks run: {len(result.task_results)}") + print(f"Completed: {result.completed_count}") + print(f"Failed: {result.failed_count}") + print(f"Reason: {result.reason}") + return 0 if result.status == "complete" else 1 + + task = select_task_by_id(tasks, args.task) if args.task else select_next_runnable_task(tasks) + ensure_dependencies_satisfied(tasks, task) + result = runner.run_task(task) print(f"Task: {result.task_id}") print(f"Status: {result.status}") print(f"Retries: {result.retry_count}") @@ -70,8 +95,11 @@ def main(argv: list[str] | None = None) -> int: print(f"Reason: {result.reason}") return 0 if result.status == "complete" else 1 - if args.command in {"status"}: - parser.error(f"'{args.command}' is not implemented yet.") + if args.command == "status": + config = validate_config(args.config) + tasks = parse_task_file(config.project.root, config.project.task_file) + print(format_status(build_status(config, tasks))) + return 0 except NightShiftError as exc: print(str(exc), file=sys.stderr) diff --git a/nightshift/config.py b/nightshift/config.py index 541343a..73db4a9 100644 --- a/nightshift/config.py +++ b/nightshift/config.py @@ -58,6 +58,7 @@ class StageConfig: class PipelineConfig: max_task_retries: int stages: tuple[StageConfig, ...] + continue_on_task_failure: bool = False @dataclass(frozen=True) @@ -188,6 +189,10 @@ def parse_config(raw: dict[str, Any], config_path: Path) -> NightShiftConfig: ) if max_task_retries < 0: raise ConfigError("Config error: pipeline.max_task_retries must be zero or greater.") + continue_on_task_failure = _optional_bool( + pipeline_raw.get("continue_on_task_failure", False), + "pipeline.continue_on_task_failure", + ) stages_raw = pipeline_raw.get("stages") if not isinstance(stages_raw, list) or not stages_raw: @@ -254,7 +259,11 @@ def parse_config(raw: dict[str, Any], config_path: Path) -> NightShiftConfig: project=project, safety=safety, agents=agents, - pipeline=PipelineConfig(max_task_retries=max_task_retries, stages=tuple(stages)), + pipeline=PipelineConfig( + max_task_retries=max_task_retries, + stages=tuple(stages), + continue_on_task_failure=continue_on_task_failure, + ), ) diff --git a/nightshift/git.py b/nightshift/git.py new file mode 100644 index 0000000..368bb1d --- /dev/null +++ b/nightshift/git.py @@ -0,0 +1,89 @@ +"""Git safety and diff artifact helpers.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +import subprocess + +from .artifacts import ArtifactStore +from .errors import SafetyError + + +@dataclass(frozen=True) +class GitCommandResult: + available: bool + exit_code: int + stdout: str + stderr: str + + +def run_git(project_root: Path, args: list[str], timeout_seconds: int = 15) -> GitCommandResult: + try: + completed = subprocess.run( + ["git", *args], + cwd=project_root, + capture_output=True, + text=True, + timeout=timeout_seconds, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + return GitCommandResult(False, -1, "", str(exc)) + return GitCommandResult(completed.returncode == 0, completed.returncode, completed.stdout, completed.stderr) + + +def get_git_status(project_root: Path) -> GitCommandResult: + return run_git(project_root, ["status", "--short"]) + + +def ensure_clean_worktree(project_root: Path, require_clean: bool) -> None: + if not require_clean: + return + status = get_git_status(project_root) + if not status.available: + raise SafetyError( + "Safety error: clean worktree is required, but git status could not be read: " + f"{status.stderr.strip() or 'unknown git error'}" + ) + if status.stdout.strip(): + raise SafetyError("Safety error: clean worktree is required, but repository is dirty.") + + +def write_git_artifacts(artifacts: ArtifactStore, task_id: str, when: str) -> Path: + status = get_git_status(artifacts.project_root) + content = format_git_status(status, when) + return artifacts.write_stage_output(task_id, f"git-status-{when}.txt", content) + + +def write_diff_artifact(artifacts: ArtifactStore, task_id: str) -> Path: + diff = run_git(artifacts.project_root, ["diff", "--binary"], timeout_seconds=30) + if not diff.available: + content = "Git diff unavailable.\n\n" + (diff.stderr or "") + elif diff.stdout: + content = diff.stdout + else: + content = "No tracked-file diff detected.\n" + return artifacts.write_stage_output(task_id, "diff.patch", content) + + +def format_git_status(status: GitCommandResult, when: str) -> str: + lines = [ + f"# Git Status {when}", + "", + f"Available: {str(status.available).lower()}", + f"Exit code: {status.exit_code}", + "", + "## stdout", + "", + "```text", + status.stdout.rstrip(), + "```", + "", + "## stderr", + "", + "```text", + status.stderr.rstrip(), + "```", + "", + ] + return "\n".join(lines) diff --git a/nightshift/pipeline.py b/nightshift/pipeline.py index 995697e..9512b9e 100644 --- a/nightshift/pipeline.py +++ b/nightshift/pipeline.py @@ -12,9 +12,10 @@ from .config import COMMAND_STAGE_TYPES, NightShiftConfig, StageConfig from .context import ContextManager from .errors import PipelineError from .errors import NightShiftError +from .git import ensure_clean_worktree, write_diff_artifact, write_git_artifacts from .reports import ReportGenerator from .stages import StageResult -from .tasks import Task +from .tasks import Task, mark_task_completed @dataclass(frozen=True) @@ -27,6 +28,15 @@ class PipelineResult: reason: str +@dataclass(frozen=True) +class MultiTaskResult: + status: str + task_results: tuple[PipelineResult, ...] + completed_count: int + failed_count: int + reason: str + + class PipelineRunner: """Execute configured stages for one task.""" @@ -55,9 +65,11 @@ class PipelineRunner: ) def run_task(self, task: Task) -> PipelineResult: + ensure_clean_worktree(self.config.project.root, self.config.safety.require_clean_worktree) self.artifacts.initialize_run() self.artifacts.write_config_snapshot(self.config.path) self.artifacts.write_task_snapshot(task) + write_git_artifacts(self.artifacts, task.id, "before") self.context.ensure_project_context() self.context.create_task_context(task) @@ -133,6 +145,20 @@ class PipelineRunner: if result.context_update ], ) + completion_changed = False + if final_status == "complete": + completion_changed = mark_task_completed( + self.config.project.root, + self.config.project.task_file, + task.id, + ) + self.artifacts.write_stage_output( + task.id, + "task-completion.md", + format_task_completion(task, final_status, completion_changed), + ) + write_git_artifacts(self.artifacts, task.id, "after") + write_diff_artifact(self.artifacts, task.id) self.reports.write_reports( task, final_status, @@ -151,6 +177,59 @@ class PipelineRunner: reason=final_reason, ) + def run_tasks(self, tasks: list[Task] | tuple[Task, ...]) -> MultiTaskResult: + self.artifacts.initialize_run() + results: list[PipelineResult] = [] + known_ids = {task.id for task in tasks} + completed_ids = {task.id for task in tasks if task.completed} + for task in tasks: + if task.completed: + continue + missing_refs = [dependency for dependency in task.dependencies if dependency not in known_ids] + incomplete_deps = [ + dependency for dependency in task.dependencies if dependency in known_ids and dependency not in completed_ids + ] + if missing_refs or incomplete_deps: + reason_parts = [] + if missing_refs: + reason_parts.append(f"missing dependencies: {', '.join(missing_refs)}") + if incomplete_deps: + reason_parts.append(f"incomplete dependencies: {', '.join(incomplete_deps)}") + blocked = PipelineResult( + task_id=task.id, + status="blocked", + retry_count=0, + stage_results=(), + artifact_dir="", + reason="Task blocked by " + "; ".join(reason_parts), + ) + results.append(blocked) + if not self.config.pipeline.continue_on_task_failure: + break + continue + result = self.run_task(task) + results.append(result) + if result.status == "complete": + completed_ids.add(task.id) + if result.status != "complete" and not self.config.pipeline.continue_on_task_failure: + break + + completed_count = sum(1 for result in results if result.status == "complete") + failed_count = sum(1 for result in results if result.status != "complete") + status = "complete" if failed_count == 0 else "failed" + reason = "All selected tasks completed." if status == "complete" else "One or more tasks failed." + self.artifacts.run_summary_path.write_text( + format_aggregate_run_summary(results, status, reason), + encoding="utf-8", + ) + return MultiTaskResult( + status=status, + task_results=tuple(results), + completed_count=completed_count, + failed_count=failed_count, + reason=reason, + ) + def _run_stage( self, stage: StageConfig, @@ -217,3 +296,40 @@ def format_summary_stage( "", ] ) + + +def format_task_completion(task: Task, status: str, changed: bool) -> str: + return "\n".join( + [ + "# Task Completion", + "", + f"Task: `{task.id}`", + f"Pipeline status: {status}", + f"Marked complete: {str(changed).lower()}", + "", + ] + ) + + +def format_aggregate_run_summary(results: list[PipelineResult], status: str, reason: str) -> str: + lines = [ + "# Run Summary", + "", + f"Status: {status}", + f"Reason: {reason}", + f"Tasks run: {len(results)}", + f"Completed tasks: {sum(1 for result in results if result.status == 'complete')}", + f"Failed tasks: {sum(1 for result in results if result.status != 'complete')}", + "", + "## Tasks", + "", + ] + if not results: + lines.append("- None") + for result in results: + lines.append( + f"- `{result.task_id}`: {result.status} " + f"(retries: {result.retry_count}) - {result.reason}" + ) + lines.append("") + return "\n".join(lines) diff --git a/nightshift/status.py b/nightshift/status.py new file mode 100644 index 0000000..d2e3215 --- /dev/null +++ b/nightshift/status.py @@ -0,0 +1,67 @@ +"""Project status inspection.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from .config import NightShiftConfig +from .tasks import Task, dependency_problems, select_next_runnable_task + + +@dataclass(frozen=True) +class ProjectStatus: + config_path: Path + project_root: Path + task_count: int + completed_count: int + incomplete_count: int + next_task_id: str | None + latest_run_dir: Path | None + warnings: tuple[str, ...] + + +def build_status(config: NightShiftConfig, tasks: list[Task]) -> ProjectStatus: + latest = latest_run_dir(config.project.root / config.project.artifact_dir / "runs") + warnings = dependency_problems(tasks) + try: + next_task = select_next_runnable_task(tasks) + next_task_id = next_task.id + except Exception: + next_task_id = None + completed = sum(1 for task in tasks if task.completed) + return ProjectStatus( + config_path=config.path, + project_root=config.project.root, + task_count=len(tasks), + completed_count=completed, + incomplete_count=len(tasks) - completed, + next_task_id=next_task_id, + latest_run_dir=latest, + warnings=tuple(warnings), + ) + + +def latest_run_dir(runs_dir: Path) -> Path | None: + if not runs_dir.exists() or not runs_dir.is_dir(): + return None + candidates = [path for path in runs_dir.iterdir() if path.is_dir()] + if not candidates: + return None + return max(candidates, key=lambda path: path.stat().st_mtime) + + +def format_status(status: ProjectStatus) -> str: + lines = [ + f"Config: {status.config_path}", + f"Project root: {status.project_root}", + f"Tasks: {status.task_count}", + f"Completed tasks: {status.completed_count}", + f"Incomplete tasks: {status.incomplete_count}", + f"Next task: {status.next_task_id or ''}", + f"Latest run: {status.latest_run_dir or ''}", + ] + if status.warnings: + lines.append("Warnings:") + lines.extend(f"- {warning}" for warning in status.warnings) + return "\n".join(lines) diff --git a/nightshift/tasks.py b/nightshift/tasks.py index 6cf3cc8..b8bed76 100644 --- a/nightshift/tasks.py +++ b/nightshift/tasks.py @@ -41,6 +41,13 @@ def parse_task_file(project_root: str | Path, task_file: str | Path) -> list[Tas return parse_tasks(path.read_text(encoding="utf-8")) +def task_file_path(project_root: str | Path, task_file: str | Path) -> Path: + try: + return resolve_inside_root(project_root, task_file, "task file") + except SafetyError as exc: + raise TaskError(str(exc)) from exc + + def parse_tasks(markdown: str) -> list[Task]: """Parse NightShift's documented markdown checklist task format.""" @@ -114,6 +121,24 @@ def select_next_incomplete_task(tasks: list[Task] | tuple[Task, ...]) -> Task: raise TaskError("Task error: no incomplete tasks found.") +def select_next_runnable_task(tasks: list[Task] | tuple[Task, ...]) -> Task: + """Return the first incomplete task whose dependencies are complete.""" + + completed = {task.id for task in tasks if task.completed} + blocked: list[str] = [] + for task in tasks: + if task.completed: + continue + missing = [dependency for dependency in task.dependencies if dependency not in completed] + if missing: + blocked.append(f"{task.id} blocked by {', '.join(missing)}") + continue + return task + if blocked: + raise TaskError("Task error: no runnable incomplete tasks. " + "; ".join(blocked)) + raise TaskError("Task error: no incomplete tasks found.") + + def select_task_by_id(tasks: list[Task] | tuple[Task, ...], task_id: str) -> Task: """Return a task by id.""" @@ -124,6 +149,58 @@ def select_task_by_id(tasks: list[Task] | tuple[Task, ...], task_id: str) -> Tas raise TaskError(f"Task error: unknown task id '{task_id}'. Available tasks: {available}.") +def ensure_dependencies_satisfied(tasks: list[Task] | tuple[Task, ...], task: Task) -> None: + task_ids = {candidate.id for candidate in tasks} + completed = {candidate.id for candidate in tasks if candidate.completed} + missing_refs = [dependency for dependency in task.dependencies if dependency not in task_ids] + if missing_refs: + raise TaskError( + f"Task error: task '{task.id}' references missing dependencies: " + f"{', '.join(missing_refs)}." + ) + incomplete = [dependency for dependency in task.dependencies if dependency not in completed] + if incomplete: + raise TaskError( + f"Task error: task '{task.id}' is blocked by incomplete dependencies: " + f"{', '.join(incomplete)}." + ) + + +def dependency_problems(tasks: list[Task] | tuple[Task, ...]) -> list[str]: + task_ids = {task.id for task in tasks} + problems: list[str] = [] + for task in tasks: + for dependency in task.dependencies: + if dependency not in task_ids: + problems.append(f"Task '{task.id}' references missing dependency '{dependency}'.") + problems.extend(_cycle_problems(tasks)) + return problems + + +def validate_task_dependencies(tasks: list[Task] | tuple[Task, ...]) -> None: + problems = dependency_problems(tasks) + if problems: + raise TaskError("Task dependency error: " + " ".join(problems)) + + +def mark_task_completed(project_root: str | Path, task_file: str | Path, task_id: str) -> bool: + """Mark a task complete in the markdown task file with a minimal line edit.""" + + path = task_file_path(project_root, task_file) + if not path.exists(): + raise TaskError(f"Task error: task file does not exist: {path}") + lines = path.read_text(encoding="utf-8").splitlines(keepends=True) + for index, line in enumerate(lines): + match = TASK_HEADER_RE.match(line.rstrip("\r\n")) + if match and match.group("id") == task_id: + if match.group("mark").lower() == "x": + return False + lines[index] = re.sub(r"\[[ ]\]", "[x]", line, count=1) + path.write_text("".join(lines), encoding="utf-8") + return True + raise TaskError(f"Task error: cannot mark unknown task complete: {task_id}.") + + def _extract_section(block: list[str], section_name: str) -> str: start = _find_section_index(block, section_name) if start is None: @@ -161,3 +238,28 @@ def _find_section_index(block: list[str], section_name: str) -> int | None: if line.strip().lower() == expected: return index return None + + +def _cycle_problems(tasks: list[Task] | tuple[Task, ...]) -> list[str]: + graph = {task.id: tuple(dep for dep in task.dependencies if dep in {item.id for item in tasks}) for task in tasks} + visiting: set[str] = set() + visited: set[str] = set() + problems: list[str] = [] + + def visit(task_id: str, path: list[str]) -> None: + if task_id in visited: + return + if task_id in visiting: + cycle_start = path.index(task_id) if task_id in path else 0 + cycle = " -> ".join(path[cycle_start:] + [task_id]) + problems.append(f"Dependency cycle detected: {cycle}.") + return + visiting.add(task_id) + for dependency in graph.get(task_id, ()): + visit(dependency, path + [dependency]) + visiting.remove(task_id) + visited.add(task_id) + + for task_id in graph: + visit(task_id, [task_id]) + return problems diff --git a/tests/test_git.py b/tests/test_git.py new file mode 100644 index 0000000..15d2686 --- /dev/null +++ b/tests/test_git.py @@ -0,0 +1,43 @@ +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +from nightshift.artifacts import ArtifactStore +from nightshift.errors import SafetyError +from nightshift.git import ensure_clean_worktree, write_diff_artifact, write_git_artifacts + + +def git_available() -> bool: + return shutil.which("git") is not None + + +@unittest.skipUnless(git_available(), "git is not available") +class GitSafetyTests(unittest.TestCase): + def test_clean_worktree_requirement_blocks_dirty_repo(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + subprocess.run(["git", "init"], cwd=root, check=True, capture_output=True, text=True) + (root / "file.txt").write_text("dirty", encoding="utf-8") + + with self.assertRaisesRegex(SafetyError, "repository is dirty"): + ensure_clean_worktree(root, True) + + def test_git_artifacts_are_written_for_repo(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + subprocess.run(["git", "init"], cwd=root, check=True, capture_output=True, text=True) + (root / "file.txt").write_text("dirty", encoding="utf-8") + artifacts = ArtifactStore(root, ".nightshift", run_id="test-run") + + status_path = write_git_artifacts(artifacts, "TASK-001", "before") + diff_path = write_diff_artifact(artifacts, "TASK-001") + + self.assertTrue(status_path.exists()) + self.assertTrue(diff_path.exists()) + self.assertIn("Git Status before", status_path.read_text(encoding="utf-8")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index d5cfa22..c9b0853 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -145,6 +145,89 @@ class PipelineRunnerTests(unittest.TestCase): (root / ".nightshift" / "runs" / "test-run" / "tasks" / task.id / "final-notes.md").exists() ) + def test_successful_task_is_marked_complete_and_git_artifacts_exist(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + _write_common_files(root) + stages = ( + StageConfig(id="plan", type="agent", agent="planner", output="plan.md"), + ) + config = make_config(root, stages) + runner = PipelineRunner(config, ArtifactStore(root, ".nightshift", run_id="test-run")) + task = parse_tasks(TASK_MD)[0] + + result = runner.run_task(task) + + self.assertEqual(result.status, "complete") + self.assertIn("- [x] TASK-001", (root / "tasks.md").read_text(encoding="utf-8")) + task_dir = root / ".nightshift" / "runs" / "test-run" / "tasks" / task.id + self.assertTrue((task_dir / "task-completion.md").exists()) + self.assertTrue((task_dir / "git-status-before.txt").exists()) + self.assertTrue((task_dir / "git-status-after.txt").exists()) + self.assertTrue((task_dir / "diff.patch").exists()) + + def test_multi_task_run_writes_aggregate_summary_and_stops_on_failure(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + _write_common_files(root) + tasks_md = TASK_MD + """ + +- [ ] TASK-002: Second task + +Description: +Should not run after failure. + +Acceptance Criteria: +- skipped +""" + (root / "tasks.md").write_text(tasks_md, encoding="utf-8") + stages = ( + StageConfig( + id="test", + type="command", + commands=('python -c "print(\'missing\')"',), + output="../bad.txt", + ), + ) + config = make_config(root, stages, max_retries=0) + runner = PipelineRunner(config, ArtifactStore(root, ".nightshift", run_id="test-run")) + tasks = parse_tasks(tasks_md) + + result = runner.run_tasks(tasks) + + self.assertEqual(result.status, "failed") + self.assertEqual(len(result.task_results), 1) + summary = (root / ".nightshift" / "runs" / "test-run" / "run-summary.md").read_text(encoding="utf-8") + self.assertIn("Tasks run: 1", summary) + + def test_multi_task_run_blocks_incomplete_dependency(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + _write_common_files(root) + tasks_md = """# Tasks + +- [ ] TASK-001: Blocked + +Dependencies: +- TASK-002 + +Acceptance Criteria: +- blocked + +- [ ] TASK-002: Later + +Acceptance Criteria: +- later +""" + (root / "tasks.md").write_text(tasks_md, encoding="utf-8") + config = make_config(root, (), max_retries=0) + runner = PipelineRunner(config, ArtifactStore(root, ".nightshift", run_id="test-run")) + + result = runner.run_tasks(parse_tasks(tasks_md)) + + self.assertEqual(result.status, "failed") + self.assertEqual(result.task_results[0].status, "blocked") + def _write_common_files(root: Path) -> None: (root / "nightshift.yaml").write_text("project:\n name: test\n", encoding="utf-8") diff --git a/tests/test_status.py b/tests/test_status.py new file mode 100644 index 0000000..dd2fd87 --- /dev/null +++ b/tests/test_status.py @@ -0,0 +1,33 @@ +from pathlib import Path +import tempfile +import unittest + +from nightshift.artifacts import ArtifactStore +from nightshift.config import load_config +from nightshift.init import init_project +from nightshift.status import build_status, format_status +from nightshift.tasks import parse_task_file + + +class StatusTests(unittest.TestCase): + def test_status_reports_counts_and_latest_run(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + init_project(root) + ArtifactStore(root, ".nightshift", run_id="run-a").initialize_run() + config = load_config(root / "nightshift.yaml") + tasks = parse_task_file(config.project.root, config.project.task_file) + + status = build_status(config, tasks) + output = format_status(status) + + self.assertEqual(status.task_count, 1) + self.assertEqual(status.incomplete_count, 1) + self.assertEqual(status.next_task_id, "TASK-001") + self.assertIsNotNone(status.latest_run_dir) + self.assertIn("Project root:", output) + self.assertIn("Latest run:", output) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_tasks.py b/tests/test_tasks.py index fedf73d..710f004 100644 --- a/tests/test_tasks.py +++ b/tests/test_tasks.py @@ -4,10 +4,15 @@ import unittest from nightshift.errors import TaskError from nightshift.tasks import ( + dependency_problems, + ensure_dependencies_satisfied, + mark_task_completed, parse_task_file, parse_tasks, select_next_incomplete_task, + select_next_runnable_task, select_task_by_id, + validate_task_dependencies, ) @@ -107,6 +112,61 @@ No acceptance criteria. with self.assertRaisesRegex(TaskError, "no tasks found"): parse_tasks("# Tasks\n\nNothing here.\n") + def test_dependency_blocks_specific_task_selection(self) -> None: + tasks = parse_tasks(TASKS_MD.replace("[x] TASK-001", "[ ] TASK-001")) + + with self.assertRaisesRegex(TaskError, "blocked by incomplete dependencies"): + ensure_dependencies_satisfied(tasks, tasks[1]) + + def test_select_next_runnable_skips_blocked_tasks(self) -> None: + markdown = TASKS_MD.replace("[x] TASK-001", "[ ] TASK-001") + tasks = parse_tasks(markdown) + + selected = select_next_runnable_task(tasks) + + self.assertEqual(selected.id, "TASK-001") + + def test_dependency_validation_reports_missing_and_cycles(self) -> None: + markdown = """# Tasks + +- [ ] TASK-001: First + +Dependencies: +- TASK-002 + +Acceptance Criteria: +- ok + +- [ ] TASK-002: Second + +Dependencies: +- TASK-001 +- TASK-999 + +Acceptance Criteria: +- ok +""" + tasks = parse_tasks(markdown) + + problems = dependency_problems(tasks) + + self.assertTrue(any("TASK-999" in problem for problem in problems)) + self.assertTrue(any("cycle" in problem.lower() for problem in problems)) + with self.assertRaisesRegex(TaskError, "Task dependency error"): + validate_task_dependencies(tasks) + + def test_mark_task_completed_updates_only_target_line(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + task_path = root / "tasks.md" + task_path.write_text(TASKS_MD, encoding="utf-8") + + changed = mark_task_completed(root, "tasks.md", "TASK-002") + + self.assertTrue(changed) + content = task_path.read_text(encoding="utf-8") + self.assertIn("- [x] TASK-002: Add artifact directory creation", content) + if __name__ == "__main__": unittest.main()