mirror of
https://github.com/khodges42/nightShift.git
synced 2026-06-14 18:18:36 +00:00
memes
This commit is contained in:
parent
b0f8d59707
commit
75a8646708
|
|
@ -12,6 +12,7 @@ from .init import available_templates, init_project
|
||||||
from .pipeline import PipelineRunner
|
from .pipeline import PipelineRunner
|
||||||
from .runlog import RunLogger
|
from .runlog import RunLogger
|
||||||
from .status import build_status, format_status
|
from .status import build_status, format_status
|
||||||
|
from .terminal import format_banner, style_text
|
||||||
from .tasks import (
|
from .tasks import (
|
||||||
ensure_dependencies_satisfied,
|
ensure_dependencies_satisfied,
|
||||||
parse_task_file,
|
parse_task_file,
|
||||||
|
|
@ -62,6 +63,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
_emit_banner(args.command)
|
||||||
if args.command == "init":
|
if args.command == "init":
|
||||||
written = init_project(Path(args.root), force=args.force, template=args.template)
|
written = init_project(Path(args.root), force=args.force, template=args.template)
|
||||||
print("Created NightShift starter files:")
|
print("Created NightShift starter files:")
|
||||||
|
|
@ -102,7 +104,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
ensure_dependencies_satisfied(tasks, task)
|
ensure_dependencies_satisfied(tasks, task)
|
||||||
result = runner.run_task(task)
|
result = runner.run_task(task)
|
||||||
print(f"Task: {result.task_id}")
|
print(f"Task: {result.task_id}")
|
||||||
print(f"Status: {result.status}")
|
print(style_text(f"Status: {result.status}", color=_status_color(result.status), bold=True))
|
||||||
print(f"Retries: {result.retry_count}")
|
print(f"Retries: {result.retry_count}")
|
||||||
print(f"Artifacts: {result.artifact_dir}")
|
print(f"Artifacts: {result.artifact_dir}")
|
||||||
print(f"Reason: {result.reason}")
|
print(f"Reason: {result.reason}")
|
||||||
|
|
@ -127,5 +129,22 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _emit_banner(command: str) -> None:
|
||||||
|
if command == "web" or not sys.stdout.isatty():
|
||||||
|
return
|
||||||
|
print(format_banner())
|
||||||
|
|
||||||
|
|
||||||
|
def _status_color(status: str) -> str | None:
|
||||||
|
lowered = status.lower()
|
||||||
|
if lowered in {"complete", "pass", "success"}:
|
||||||
|
return "\x1b[32m"
|
||||||
|
if lowered in {"failed", "fail", "error"}:
|
||||||
|
return "\x1b[31m"
|
||||||
|
if lowered in {"retry", "blocked", "warning"}:
|
||||||
|
return "\x1b[33m"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
raise SystemExit(main())
|
raise SystemExit(main())
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ from pathlib import Path
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
from .artifacts import ArtifactStore
|
from .artifacts import ArtifactStore
|
||||||
|
from .terminal import format_console_event_line, format_plain_event_line
|
||||||
|
|
||||||
|
|
||||||
ConsoleWriter = Callable[[str], None]
|
ConsoleWriter = Callable[[str], None]
|
||||||
|
|
@ -40,9 +41,10 @@ class RunLogger:
|
||||||
|
|
||||||
def event(self, event: str, message: str, **fields: object) -> None:
|
def event(self, event: str, message: str, **fields: object) -> None:
|
||||||
safe_fields = _redact_fields(fields)
|
safe_fields = _redact_fields(fields)
|
||||||
line = format_log_line(LogEvent(event=event, message=message, fields=safe_fields))
|
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
line = format_plain_event_line(timestamp, event, message, safe_fields)
|
||||||
if self.console is not None:
|
if self.console is not None:
|
||||||
self.console(line)
|
self.console(format_console_event_line(timestamp, event, message, safe_fields))
|
||||||
for path in (self._run_log_path,):
|
for path in (self._run_log_path,):
|
||||||
if path is None:
|
if path is None:
|
||||||
continue
|
continue
|
||||||
|
|
@ -64,12 +66,7 @@ class NullRunLogger(RunLogger):
|
||||||
|
|
||||||
def format_log_line(log_event: LogEvent) -> str:
|
def format_log_line(log_event: LogEvent) -> str:
|
||||||
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
parts = [timestamp, log_event.event, log_event.message]
|
return format_plain_event_line(timestamp, log_event.event, log_event.message, log_event.fields)
|
||||||
for key, value in sorted(log_event.fields.items()):
|
|
||||||
if value is None or value == "":
|
|
||||||
continue
|
|
||||||
parts.append(f"{key}={_format_value(value)}")
|
|
||||||
return " | ".join(parts)
|
|
||||||
|
|
||||||
|
|
||||||
def tail_lines(path: Path, limit: int = 100) -> list[str]:
|
def tail_lines(path: Path, limit: int = 100) -> list[str]:
|
||||||
|
|
@ -80,11 +77,6 @@ def tail_lines(path: Path, limit: int = 100) -> list[str]:
|
||||||
return path.read_text(encoding="utf-8", errors="replace").splitlines()[-limit:]
|
return path.read_text(encoding="utf-8", errors="replace").splitlines()[-limit:]
|
||||||
|
|
||||||
|
|
||||||
def _format_value(value: object) -> str:
|
|
||||||
text = str(value).replace("\n", " ").replace("\r", " ")
|
|
||||||
return text if text else ""
|
|
||||||
|
|
||||||
|
|
||||||
def _redact_fields(fields: dict[str, object]) -> dict[str, object]:
|
def _redact_fields(fields: dict[str, object]) -> dict[str, object]:
|
||||||
redacted: dict[str, object] = {}
|
redacted: dict[str, object] = {}
|
||||||
for key, value in fields.items():
|
for key, value in fields.items():
|
||||||
|
|
|
||||||
145
nightshift/terminal.py
Normal file
145
nightshift/terminal.py
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
"""Terminal styling helpers for NightShift."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from typing import TextIO
|
||||||
|
import random
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
RESET = "\x1b[0m"
|
||||||
|
BOLD = "\x1b[1m"
|
||||||
|
DIM = "\x1b[2m"
|
||||||
|
GREEN = "\x1b[32m"
|
||||||
|
RED = "\x1b[31m"
|
||||||
|
YELLOW = "\x1b[33m"
|
||||||
|
BLUE = "\x1b[34m"
|
||||||
|
MAGENTA = "\x1b[35m"
|
||||||
|
CYAN = "\x1b[36m"
|
||||||
|
WHITE = "\x1b[37m"
|
||||||
|
|
||||||
|
BANNER_MESSAGES = [
|
||||||
|
"Real LARPer Hours",
|
||||||
|
"Who the heck is claude?",
|
||||||
|
"WHO UP BREAKIN THEY BUILD?",
|
||||||
|
"me and the boys at 2am lookin for BEANS",
|
||||||
|
"local-first autonomous coding pipeline",
|
||||||
|
"why break then build while you're awake?",
|
||||||
|
"compiling bad ideas into good software",
|
||||||
|
"local-first synthetic cognition",
|
||||||
|
"the graveyard shift for software engineering",
|
||||||
|
"pipeline humming at unsafe levels",
|
||||||
|
"generated at 3:14am with malicious intent",
|
||||||
|
"all outputs are guilty until proven correct",
|
||||||
|
"daemonized software production",
|
||||||
|
"running tests until morale improves",
|
||||||
|
"you wouldn't download a senior engineer",
|
||||||
|
"sleep is temporary. infrastructure is forever.",
|
||||||
|
]
|
||||||
|
quote = random.choice(BANNER_MESSAGES)
|
||||||
|
|
||||||
|
def should_style(stream: TextIO | None = None) -> bool:
|
||||||
|
stream = stream or sys.stdout
|
||||||
|
if os.environ.get("NO_COLOR"):
|
||||||
|
return False
|
||||||
|
if os.environ.get("TERM") == "dumb":
|
||||||
|
return False
|
||||||
|
return bool(getattr(stream, "isatty", lambda: False)())
|
||||||
|
|
||||||
|
|
||||||
|
def style_text(text: str, *, color: str | None = None, bold: bool = False, dim: bool = False, stream: TextIO | None = None) -> str:
|
||||||
|
if not should_style(stream):
|
||||||
|
return text
|
||||||
|
parts: list[str] = []
|
||||||
|
if bold:
|
||||||
|
parts.append(BOLD)
|
||||||
|
if dim:
|
||||||
|
parts.append(DIM)
|
||||||
|
if color:
|
||||||
|
parts.append(color)
|
||||||
|
if not parts:
|
||||||
|
return text
|
||||||
|
return "".join(parts) + text + RESET
|
||||||
|
|
||||||
|
|
||||||
|
def format_banner(stream: TextIO | None = None) -> str:
|
||||||
|
lines = [
|
||||||
|
"███╗ ██╗██╗ ██████╗ ██╗ ██╗████████╗███████╗██╗ ██╗██╗███████╗████████╗",
|
||||||
|
"████╗ ██║██║██╔════╝ ██║ ██║╚══██╔══╝██╔════╝██║ ██║██║██╔════╝╚══██╔══╝",
|
||||||
|
"██╔██╗ ██║██║██║ ███╗███████║ ██║ ███████╗███████║██║█████╗ ██║ ",
|
||||||
|
"██║╚██╗██║██║██║ ██║██╔══██║ ██║ ╚════██║██╔══██║██║██╔══╝ ██║ ",
|
||||||
|
"██║ ╚████║██║╚██████╔╝██║ ██║ ██║ ███████║██║ ██║██║██║ ██║ ",
|
||||||
|
"╚═╝ ╚═══╝╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝╚═╝╚═╝ ╚═╝ ",
|
||||||
|
"",
|
||||||
|
f" [ {quote} ]",
|
||||||
|
" [ planner | implementer | verifier | audit ]",
|
||||||
|
"",
|
||||||
|
" VERSION: 0.1.0-alpha-glizzy",
|
||||||
|
"-" * 50,
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
|
||||||
|
if not should_style(stream):
|
||||||
|
return "\n".join(lines)
|
||||||
|
styled: list[str] = []
|
||||||
|
for index, line in enumerate(lines):
|
||||||
|
if not line:
|
||||||
|
styled.append(line)
|
||||||
|
continue
|
||||||
|
color = CYAN if index < 8 else WHITE
|
||||||
|
bold = index < 8 or line == "NightShift"
|
||||||
|
styled.append(style_text(line, color=color, bold=bold, stream=stream))
|
||||||
|
return "\n".join(styled)
|
||||||
|
|
||||||
|
|
||||||
|
def format_console_event_line(
|
||||||
|
timestamp: str,
|
||||||
|
event: str,
|
||||||
|
message: str,
|
||||||
|
fields: dict[str, object],
|
||||||
|
*,
|
||||||
|
stream: TextIO | None = None,
|
||||||
|
) -> str:
|
||||||
|
line = format_plain_event_line(timestamp, event, message, fields)
|
||||||
|
if not should_style(stream):
|
||||||
|
return line
|
||||||
|
color = _event_color(event, fields)
|
||||||
|
if color is None:
|
||||||
|
return line
|
||||||
|
return style_text(line, color=color, stream=stream)
|
||||||
|
|
||||||
|
|
||||||
|
def format_plain_event_line(timestamp: str, event: str, message: str, fields: dict[str, object]) -> str:
|
||||||
|
parts = [timestamp, event, message]
|
||||||
|
for key, value in sorted(fields.items()):
|
||||||
|
if value is None or value == "":
|
||||||
|
continue
|
||||||
|
parts.append(f"{key}={_format_value(value)}")
|
||||||
|
return " | ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _event_color(event: str, fields: dict[str, object]) -> str | None:
|
||||||
|
status = str(fields.get("status", "")).lower()
|
||||||
|
reason = str(fields.get("reason", "")).lower()
|
||||||
|
event_name = event.lower()
|
||||||
|
if status in {"fail", "failed", "error"} or "fail" in reason or "error" in reason:
|
||||||
|
return RED
|
||||||
|
if status in {"complete", "pass", "success", "ok"}:
|
||||||
|
return GREEN
|
||||||
|
if status in {"retry", "warning", "warn", "blocked"}:
|
||||||
|
return YELLOW
|
||||||
|
if event_name.endswith(".start"):
|
||||||
|
return BLUE
|
||||||
|
if event_name.endswith(".finish"):
|
||||||
|
return GREEN
|
||||||
|
if "tool" in event_name:
|
||||||
|
return MAGENTA
|
||||||
|
if "command" in event_name:
|
||||||
|
return CYAN
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _format_value(value: object) -> str:
|
||||||
|
return str(value).replace("\n", " ").replace("\r", " ")
|
||||||
70
tests/test_terminal.py
Normal file
70
tests/test_terminal.py
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
from io import StringIO
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from nightshift.artifacts import ArtifactStore
|
||||||
|
from nightshift.runlog import RunLogger
|
||||||
|
from nightshift.terminal import format_banner, format_console_event_line
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTTY(StringIO):
|
||||||
|
def isatty(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class TerminalStylingTests(unittest.TestCase):
|
||||||
|
def test_banner_is_plain_without_tty(self) -> None:
|
||||||
|
banner = format_banner(stream=StringIO())
|
||||||
|
self.assertIn("NightShift", banner)
|
||||||
|
self.assertNotIn("\x1b[", banner)
|
||||||
|
|
||||||
|
def test_banner_uses_ansi_when_tty(self) -> None:
|
||||||
|
banner = format_banner(stream=FakeTTY())
|
||||||
|
self.assertIn("NightShift", banner)
|
||||||
|
self.assertIn("\x1b[", banner)
|
||||||
|
|
||||||
|
def test_console_event_line_colors_success_and_failure(self) -> None:
|
||||||
|
success = format_console_event_line(
|
||||||
|
"2026-05-17T00:00:00Z",
|
||||||
|
"task.finish",
|
||||||
|
"Finished task",
|
||||||
|
{"status": "complete"},
|
||||||
|
stream=FakeTTY(),
|
||||||
|
)
|
||||||
|
failure = format_console_event_line(
|
||||||
|
"2026-05-17T00:00:00Z",
|
||||||
|
"task.finish",
|
||||||
|
"Finished task",
|
||||||
|
{"status": "failed"},
|
||||||
|
stream=FakeTTY(),
|
||||||
|
)
|
||||||
|
self.assertIn("\x1b[32m", success)
|
||||||
|
self.assertIn("\x1b[31m", failure)
|
||||||
|
self.assertTrue(success.endswith("\x1b[0m"))
|
||||||
|
self.assertTrue(failure.endswith("\x1b[0m"))
|
||||||
|
|
||||||
|
def test_run_logger_console_output_is_separate_from_run_log(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
artifacts = ArtifactStore(root, ".nightshift", run_id="test-run")
|
||||||
|
console_lines: list[str] = []
|
||||||
|
logger = RunLogger(console=console_lines.append)
|
||||||
|
logger.bind(artifacts)
|
||||||
|
with patch(
|
||||||
|
"nightshift.runlog.format_console_event_line",
|
||||||
|
return_value="\x1b[32mstyled line\x1b[0m",
|
||||||
|
):
|
||||||
|
logger.event("task.finish", "Finished task", status="complete", token="abc")
|
||||||
|
|
||||||
|
self.assertEqual(console_lines[-1], "\x1b[32mstyled line\x1b[0m")
|
||||||
|
run_log = artifacts.run_log_path.read_text(encoding="utf-8")
|
||||||
|
self.assertIn("task.finish", run_log)
|
||||||
|
self.assertIn("status=complete", run_log)
|
||||||
|
self.assertNotIn("\x1b[", run_log)
|
||||||
|
self.assertNotIn("abc", run_log)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Loading…
Reference in New Issue
Block a user