mirror of
https://github.com/khodges42/nightShift.git
synced 2026-06-14 10:08:37 +00:00
Add status, git artifacts, task completion, multi-task runs, and dependency handling
This commit is contained in:
parent
528c0ddeb5
commit
57608e9660
|
|
@ -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
|
# Appendix A: Design Decisions and Rationale
|
||||||
|
|
||||||
## A.1 Local-first architecture
|
## A.1 Local-first architecture
|
||||||
|
|
|
||||||
18
docs/devlog/phase12.md
Normal file
18
docs/devlog/phase12.md
Normal file
|
|
@ -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.
|
||||||
20
docs/devlog/phase13.md
Normal file
20
docs/devlog/phase13.md
Normal file
|
|
@ -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.
|
||||||
19
docs/devlog/phase14.md
Normal file
19
docs/devlog/phase14.md
Normal file
|
|
@ -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.
|
||||||
22
docs/devlog/phase15.md
Normal file
22
docs/devlog/phase15.md
Normal file
|
|
@ -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.
|
||||||
21
docs/devlog/phase16.md
Normal file
21
docs/devlog/phase16.md
Normal file
|
|
@ -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.
|
||||||
|
|
@ -10,7 +10,14 @@ from .config import validate_config
|
||||||
from .errors import NightShiftError
|
from .errors import NightShiftError
|
||||||
from .init import init_project
|
from .init import init_project
|
||||||
from .pipeline import PipelineRunner
|
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:
|
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 = 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("--config", default="nightshift.yaml", help="Config file to use.")
|
||||||
run_parser.add_argument("--task", help="Specific task id to run.")
|
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
|
return parser
|
||||||
|
|
||||||
|
|
@ -50,6 +59,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
if args.command == "validate":
|
if args.command == "validate":
|
||||||
config = validate_config(args.config)
|
config = validate_config(args.config)
|
||||||
tasks = parse_task_file(config.project.root, config.project.task_file)
|
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)
|
incomplete = sum(1 for task in tasks if not task.completed)
|
||||||
print(f"Config valid: {config.path}")
|
print(f"Config valid: {config.path}")
|
||||||
print(f"Project: {config.project.name}")
|
print(f"Project: {config.project.name}")
|
||||||
|
|
@ -61,8 +71,23 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
if args.command == "run":
|
if args.command == "run":
|
||||||
config = validate_config(args.config)
|
config = validate_config(args.config)
|
||||||
tasks = parse_task_file(config.project.root, config.project.task_file)
|
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)
|
validate_task_dependencies(tasks)
|
||||||
result = PipelineRunner(config).run_task(task)
|
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"Task: {result.task_id}")
|
||||||
print(f"Status: {result.status}")
|
print(f"Status: {result.status}")
|
||||||
print(f"Retries: {result.retry_count}")
|
print(f"Retries: {result.retry_count}")
|
||||||
|
|
@ -70,8 +95,11 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
print(f"Reason: {result.reason}")
|
print(f"Reason: {result.reason}")
|
||||||
return 0 if result.status == "complete" else 1
|
return 0 if result.status == "complete" else 1
|
||||||
|
|
||||||
if args.command in {"status"}:
|
if args.command == "status":
|
||||||
parser.error(f"'{args.command}' is not implemented yet.")
|
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:
|
except NightShiftError as exc:
|
||||||
print(str(exc), file=sys.stderr)
|
print(str(exc), file=sys.stderr)
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ class StageConfig:
|
||||||
class PipelineConfig:
|
class PipelineConfig:
|
||||||
max_task_retries: int
|
max_task_retries: int
|
||||||
stages: tuple[StageConfig, ...]
|
stages: tuple[StageConfig, ...]
|
||||||
|
continue_on_task_failure: bool = False
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|
@ -188,6 +189,10 @@ def parse_config(raw: dict[str, Any], config_path: Path) -> NightShiftConfig:
|
||||||
)
|
)
|
||||||
if max_task_retries < 0:
|
if max_task_retries < 0:
|
||||||
raise ConfigError("Config error: pipeline.max_task_retries must be zero or greater.")
|
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")
|
stages_raw = pipeline_raw.get("stages")
|
||||||
if not isinstance(stages_raw, list) or not stages_raw:
|
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,
|
project=project,
|
||||||
safety=safety,
|
safety=safety,
|
||||||
agents=agents,
|
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,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
89
nightshift/git.py
Normal file
89
nightshift/git.py
Normal file
|
|
@ -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)
|
||||||
|
|
@ -12,9 +12,10 @@ from .config import COMMAND_STAGE_TYPES, NightShiftConfig, StageConfig
|
||||||
from .context import ContextManager
|
from .context import ContextManager
|
||||||
from .errors import PipelineError
|
from .errors import PipelineError
|
||||||
from .errors import NightShiftError
|
from .errors import NightShiftError
|
||||||
|
from .git import ensure_clean_worktree, write_diff_artifact, write_git_artifacts
|
||||||
from .reports import ReportGenerator
|
from .reports import ReportGenerator
|
||||||
from .stages import StageResult
|
from .stages import StageResult
|
||||||
from .tasks import Task
|
from .tasks import Task, mark_task_completed
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|
@ -27,6 +28,15 @@ class PipelineResult:
|
||||||
reason: str
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MultiTaskResult:
|
||||||
|
status: str
|
||||||
|
task_results: tuple[PipelineResult, ...]
|
||||||
|
completed_count: int
|
||||||
|
failed_count: int
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
class PipelineRunner:
|
class PipelineRunner:
|
||||||
"""Execute configured stages for one task."""
|
"""Execute configured stages for one task."""
|
||||||
|
|
||||||
|
|
@ -55,9 +65,11 @@ class PipelineRunner:
|
||||||
)
|
)
|
||||||
|
|
||||||
def run_task(self, task: Task) -> PipelineResult:
|
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.initialize_run()
|
||||||
self.artifacts.write_config_snapshot(self.config.path)
|
self.artifacts.write_config_snapshot(self.config.path)
|
||||||
self.artifacts.write_task_snapshot(task)
|
self.artifacts.write_task_snapshot(task)
|
||||||
|
write_git_artifacts(self.artifacts, task.id, "before")
|
||||||
self.context.ensure_project_context()
|
self.context.ensure_project_context()
|
||||||
self.context.create_task_context(task)
|
self.context.create_task_context(task)
|
||||||
|
|
||||||
|
|
@ -133,6 +145,20 @@ class PipelineRunner:
|
||||||
if result.context_update
|
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(
|
self.reports.write_reports(
|
||||||
task,
|
task,
|
||||||
final_status,
|
final_status,
|
||||||
|
|
@ -151,6 +177,59 @@ class PipelineRunner:
|
||||||
reason=final_reason,
|
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(
|
def _run_stage(
|
||||||
self,
|
self,
|
||||||
stage: StageConfig,
|
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)
|
||||||
|
|
|
||||||
67
nightshift/status.py
Normal file
67
nightshift/status.py
Normal file
|
|
@ -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 '<none>'}",
|
||||||
|
f"Latest run: {status.latest_run_dir or '<none>'}",
|
||||||
|
]
|
||||||
|
if status.warnings:
|
||||||
|
lines.append("Warnings:")
|
||||||
|
lines.extend(f"- {warning}" for warning in status.warnings)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
@ -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"))
|
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]:
|
def parse_tasks(markdown: str) -> list[Task]:
|
||||||
"""Parse NightShift's documented markdown checklist task format."""
|
"""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.")
|
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:
|
def select_task_by_id(tasks: list[Task] | tuple[Task, ...], task_id: str) -> Task:
|
||||||
"""Return a task by id."""
|
"""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}.")
|
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:
|
def _extract_section(block: list[str], section_name: str) -> str:
|
||||||
start = _find_section_index(block, section_name)
|
start = _find_section_index(block, section_name)
|
||||||
if start is None:
|
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:
|
if line.strip().lower() == expected:
|
||||||
return index
|
return index
|
||||||
return None
|
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
|
||||||
|
|
|
||||||
43
tests/test_git.py
Normal file
43
tests/test_git.py
Normal file
|
|
@ -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()
|
||||||
|
|
@ -145,6 +145,89 @@ class PipelineRunnerTests(unittest.TestCase):
|
||||||
(root / ".nightshift" / "runs" / "test-run" / "tasks" / task.id / "final-notes.md").exists()
|
(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:
|
def _write_common_files(root: Path) -> None:
|
||||||
(root / "nightshift.yaml").write_text("project:\n name: test\n", encoding="utf-8")
|
(root / "nightshift.yaml").write_text("project:\n name: test\n", encoding="utf-8")
|
||||||
|
|
|
||||||
33
tests/test_status.py
Normal file
33
tests/test_status.py
Normal file
|
|
@ -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()
|
||||||
|
|
@ -4,10 +4,15 @@ import unittest
|
||||||
|
|
||||||
from nightshift.errors import TaskError
|
from nightshift.errors import TaskError
|
||||||
from nightshift.tasks import (
|
from nightshift.tasks import (
|
||||||
|
dependency_problems,
|
||||||
|
ensure_dependencies_satisfied,
|
||||||
|
mark_task_completed,
|
||||||
parse_task_file,
|
parse_task_file,
|
||||||
parse_tasks,
|
parse_tasks,
|
||||||
select_next_incomplete_task,
|
select_next_incomplete_task,
|
||||||
|
select_next_runnable_task,
|
||||||
select_task_by_id,
|
select_task_by_id,
|
||||||
|
validate_task_dependencies,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -107,6 +112,61 @@ No acceptance criteria.
|
||||||
with self.assertRaisesRegex(TaskError, "no tasks found"):
|
with self.assertRaisesRegex(TaskError, "no tasks found"):
|
||||||
parse_tasks("# Tasks\n\nNothing here.\n")
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user