nightshift-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,425 @@
1
+ """The gate: decide whether to run, pick the work, run it, record it.
2
+
3
+ ``nightshift run`` fires from cron every hour and is expected to do nothing
4
+ most of the time. Every refusal exits 0 with a single line of explanation —
5
+ cron mail full of stack traces is how a tool gets uninstalled.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from dataclasses import dataclass, field
12
+ from datetime import date, datetime, timedelta
13
+
14
+ from nightshift import adapters as adapter_registry
15
+ from nightshift import events, prompts, report
16
+ from nightshift.adapters.base import Adapter, OnEvent, RunResult
17
+ from nightshift.budget import Ledger
18
+ from nightshift.config import Config, Project, Provider
19
+ from nightshift.lock import Lock, LockBusy
20
+ from nightshift.queue import Queue
21
+
22
+ log = logging.getLogger("nightshift")
23
+
24
+ #: A failed or timed-out run gets exactly one immediate retry. Both attempts
25
+ #: count against budget, so this can never be more than one.
26
+ MAX_ATTEMPTS = 2
27
+
28
+
29
+ @dataclass
30
+ class Outcome:
31
+ """What one invocation of ``nightshift run`` did."""
32
+
33
+ ran: bool
34
+ reason: str = ""
35
+ results: list[RunResult] = field(default_factory=list)
36
+
37
+ @property
38
+ def status(self) -> str:
39
+ return self.results[-1].status if self.results else "skipped"
40
+
41
+
42
+ def _now() -> datetime:
43
+ return datetime.now()
44
+
45
+
46
+ def _project_by_name(cfg: Config, name: str) -> Project | None:
47
+ for p in cfg.projects:
48
+ if p.name == name:
49
+ return p
50
+ return None
51
+
52
+
53
+ def is_idle(adapter: Adapter, idle_minutes: int, now: datetime) -> tuple[bool, str]:
54
+ """Has the human left this provider alone for long enough?"""
55
+ if idle_minutes <= 0:
56
+ return True, ""
57
+ try:
58
+ last = adapter.last_human_use()
59
+ except Exception as exc: # pragma: no cover - defensive
60
+ log.debug("idle check failed for %s: %s", adapter.name, exc)
61
+ return True, ""
62
+ if last is None:
63
+ # Can't tell — don't let an unknowable signal block every run.
64
+ return True, ""
65
+ quiet_for = now - last
66
+ if quiet_for < timedelta(minutes=idle_minutes):
67
+ mins = int(quiet_for.total_seconds() // 60)
68
+ return False, f"{adapter.name} used {mins}m ago (needs {idle_minutes}m idle)"
69
+ return True, ""
70
+
71
+
72
+ def _already_logged_budget_skip(cfg: Config, provider: str, on: date) -> bool:
73
+ """Has a budget skip for this provider already been recorded today?
74
+
75
+ Cron ticks hourly; without this the digest run log would carry a wall of
76
+ identical "skipped · budget" rows for every hour after the cap was hit.
77
+ """
78
+ for r in report.load_results(cfg, on):
79
+ if r.provider == provider and r.status == "skipped" and "budget" in r.detail:
80
+ return True
81
+ return False
82
+
83
+
84
+ def _record_skip(cfg: Config, provider: str, detail: str, now: datetime) -> RunResult:
85
+ result = RunResult(
86
+ provider=provider,
87
+ project=report.PLACEHOLDER,
88
+ task=report.PLACEHOLDER,
89
+ status="skipped",
90
+ findings_md="",
91
+ started_at=now,
92
+ duration_s=0.0,
93
+ detail=detail,
94
+ )
95
+ report.store_result(cfg, result)
96
+ return result
97
+
98
+
99
+ @dataclass
100
+ class Usable:
101
+ """Which providers could run something right now, and why the rest can't."""
102
+
103
+ by_name: dict[str, tuple[Provider, Adapter]] = field(default_factory=dict)
104
+ reasons: list[str] = field(default_factory=list)
105
+ skips: list[RunResult] = field(default_factory=list)
106
+
107
+ def __bool__(self) -> bool:
108
+ return bool(self.by_name)
109
+
110
+
111
+ def usable_providers(
112
+ cfg: Config,
113
+ ledger: Ledger,
114
+ now: datetime,
115
+ *,
116
+ force: bool = False,
117
+ only: str | None = None,
118
+ get_adapter=None,
119
+ ) -> Usable:
120
+ """Every enabled provider that is installed, idle, and under budget.
121
+
122
+ Evaluated once per run and for all providers, because a project may pin any
123
+ one of them — and because ``availability()`` shells out to ``--version``,
124
+ which is not something to repeat per project.
125
+
126
+ ``--now`` (``force``) skips the idle check but never the budget check —
127
+ budget is the promise that nightshift won't eat someone's quota.
128
+ """
129
+ # Resolved here rather than as a default argument: a default would bind the
130
+ # real registry at import time and quietly ignore any later patching.
131
+ get_adapter = get_adapter or adapter_registry.get
132
+ enabled = cfg.enabled_providers()
133
+ if only:
134
+ enabled = [p for p in enabled if p.name == only]
135
+ if not enabled:
136
+ return Usable(reasons=[f"provider {only!r} is not enabled"])
137
+
138
+ found = Usable()
139
+ for provider in enabled:
140
+ try:
141
+ adapter = get_adapter(provider.name, provider.binary)
142
+ except Exception as exc:
143
+ found.reasons.append(f"{provider.name}: {exc}")
144
+ continue
145
+
146
+ availability = adapter.availability()
147
+ if not availability.ok:
148
+ found.reasons.append(f"{provider.name}: {availability.reason}")
149
+ continue
150
+
151
+ if not force:
152
+ idle, why = is_idle(adapter, cfg.schedule.idle_minutes, now)
153
+ if not idle:
154
+ found.reasons.append(why)
155
+ continue
156
+
157
+ usage = ledger.usage(provider.name, provider.budget, now)
158
+ if usage.exhausted:
159
+ detail = f"budget · {usage.reason()}"
160
+ found.reasons.append(f"{provider.name}: {usage.reason()}")
161
+ if not _already_logged_budget_skip(cfg, provider.name, now.date()):
162
+ found.skips.append(_record_skip(cfg, provider.name, detail, now))
163
+ continue
164
+
165
+ found.by_name[provider.name] = (provider, adapter)
166
+
167
+ if not found.by_name and not found.reasons:
168
+ found.reasons.append("no providers enabled")
169
+ return found
170
+
171
+
172
+ @dataclass
173
+ class WorkChoice:
174
+ """The pair to run and who will run it."""
175
+
176
+ provider: Provider | None = None
177
+ adapter: Adapter | None = None
178
+ pair: tuple[str, str] | None = None
179
+ reason: str = ""
180
+
181
+
182
+ def choose_work(cfg: Config, queue: Queue, usable: Usable) -> WorkChoice:
183
+ """The first pair in rotation order whose provider can run it.
184
+
185
+ A project's ``provider:`` pin is hard — it is never handed to a different
186
+ provider. But an unrunnable pin skips that project's *turn*, not the whole
187
+ run: one project pinned to an exhausted provider must not stop every other
188
+ project being reviewed, which is the starvation ``Queue.pop`` already
189
+ refuses on the failure path.
190
+ """
191
+ pairs = cfg.pairs()
192
+ if not pairs:
193
+ return WorkChoice(reason="no (project, task) pairs configured")
194
+
195
+ reasons: list[str] = []
196
+ for pair in queue.rotation(pairs):
197
+ project = _project_by_name(cfg, pair[0])
198
+ if project is None: # pragma: no cover - pairs() derives from cfg
199
+ continue
200
+
201
+ if project.provider:
202
+ entry = usable.by_name.get(project.provider)
203
+ if entry is None:
204
+ reasons.append(
205
+ f"{project.name}: pinned to {project.provider}, which is unavailable"
206
+ )
207
+ continue
208
+ else:
209
+ entry = _first_usable(cfg, usable)
210
+ if entry is None:
211
+ break # nothing is usable; no later unpinned pair will fare better
212
+
213
+ provider, adapter = entry
214
+ return WorkChoice(provider=provider, adapter=adapter, pair=pair)
215
+
216
+ return WorkChoice(reason="; ".join(reasons + usable.reasons))
217
+
218
+
219
+ def _first_usable(cfg: Config, usable: Usable) -> tuple[Provider, Adapter] | None:
220
+ """The usable provider that comes first in config order."""
221
+ for provider in cfg.enabled_providers():
222
+ entry = usable.by_name.get(provider.name)
223
+ if entry is not None:
224
+ return entry
225
+ return None
226
+
227
+
228
+ def _tee(*sinks: OnEvent | None) -> OnEvent:
229
+ """Fan one event out to the event log and, if attended, the renderer."""
230
+ live = [s for s in sinks if s is not None]
231
+
232
+ def fan(event) -> None:
233
+ for sink in live:
234
+ try:
235
+ sink(event)
236
+ except Exception: # noqa: BLE001 - a sink must not fail a run
237
+ log.debug("event sink raised", exc_info=True)
238
+
239
+ return fan
240
+
241
+
242
+ def _attempt(
243
+ adapter: Adapter,
244
+ prompt: str,
245
+ project: Project,
246
+ task: str,
247
+ timeout_s: int,
248
+ attempt: int,
249
+ on_event: OnEvent | None = None,
250
+ ) -> RunResult:
251
+ started = _now()
252
+ try:
253
+ result = adapter.run(prompt, project.path, timeout_s, on_event=on_event)
254
+ except NotImplementedError as exc:
255
+ return RunResult(
256
+ provider=adapter.name,
257
+ project=project.name,
258
+ task=task,
259
+ status="failed",
260
+ findings_md="",
261
+ started_at=started,
262
+ duration_s=0.0,
263
+ detail=str(exc),
264
+ attempt=attempt,
265
+ )
266
+ except Exception as exc: # adapter blew up in a way it didn't anticipate
267
+ log.debug("adapter %s raised", adapter.name, exc_info=True)
268
+ return RunResult(
269
+ provider=adapter.name,
270
+ project=project.name,
271
+ task=task,
272
+ status="failed",
273
+ findings_md="",
274
+ started_at=started,
275
+ duration_s=(_now() - started).total_seconds(),
276
+ detail=f"{type(exc).__name__}: {exc}",
277
+ attempt=attempt,
278
+ )
279
+ result.attempt = attempt
280
+ # Trust the adapter's own timing, but never its bookkeeping.
281
+ result.project = project.name
282
+ result.task = task
283
+ result.provider = adapter.name
284
+ return result
285
+
286
+
287
+ def run_once(
288
+ cfg: Config,
289
+ *,
290
+ now: datetime | None = None,
291
+ force: bool = False,
292
+ provider: str | None = None,
293
+ ledger: Ledger | None = None,
294
+ queue: Queue | None = None,
295
+ get_adapter=None,
296
+ on_event: OnEvent | None = None,
297
+ ) -> Outcome:
298
+ """One gated run. Returns without acting whenever a gate says no.
299
+
300
+ ``on_event`` is passed straight to the adapter: pass a renderer for an
301
+ attended run, leave it ``None`` under cron.
302
+ """
303
+ get_adapter = get_adapter or adapter_registry.get
304
+ now = now or _now()
305
+ ledger = ledger if ledger is not None else Ledger()
306
+ ledger.prune(now.date())
307
+ ledger.save()
308
+ queue = queue if queue is not None else Queue()
309
+
310
+ # 1. Window — skipped by --now.
311
+ if not force and not cfg.schedule.is_open(now):
312
+ windows = ", ".join(w.raw for w in cfg.schedule.windows)
313
+ return Outcome(False, f"outside configured windows ({windows})")
314
+
315
+ # 2 + 3. Idle and budget, for every provider — a project may pin any of them.
316
+ usable = usable_providers(
317
+ cfg, ledger, now, force=force, only=provider, get_adapter=get_adapter
318
+ )
319
+ if not usable:
320
+ # Nothing can run, so don't take the lock or disturb the rotation.
321
+ return Outcome(False, "; ".join(usable.reasons), results=usable.skips)
322
+
323
+ # 4. Lock.
324
+ events.prune()
325
+ # The lock must size its stale threshold against the whole budget we might
326
+ # spend, not one attempt of it, or a healthy run that retries looks dead.
327
+ lock = Lock(timeout_s=cfg.timeout_s, attempts=MAX_ATTEMPTS)
328
+ try:
329
+ lock.acquire()
330
+ except LockBusy as exc:
331
+ return Outcome(False, str(exc), results=usable.skips)
332
+
333
+ results = list(usable.skips)
334
+ try:
335
+ # 5. Whose turn is it, and can anyone take it?
336
+ choice = choose_work(cfg, queue, usable)
337
+ if choice.pair is None or choice.provider is None or choice.adapter is None:
338
+ return Outcome(False, choice.reason, results=results)
339
+ # Only now is the position spent: a pair we couldn't run never claimed a
340
+ # turn, and the one we can run claims its own even if it fails below.
341
+ queue.take(choice.pair)
342
+ project_name, task = choice.pair
343
+ project = _project_by_name(cfg, project_name)
344
+ if project is None: # pragma: no cover - choose_work resolved it already
345
+ return Outcome(False, f"project {project_name!r} vanished from config")
346
+
347
+ if not project.path.is_dir():
348
+ result = RunResult(
349
+ provider=choice.provider.name,
350
+ project=project.name,
351
+ task=task,
352
+ status="failed",
353
+ findings_md="",
354
+ started_at=now,
355
+ duration_s=0.0,
356
+ detail=f"project path does not exist: {project.path}",
357
+ )
358
+ report.store_result(cfg, result)
359
+ results.append(result)
360
+ return Outcome(True, result.detail, results=results)
361
+
362
+ try:
363
+ prompt = prompts.load(task)
364
+ except prompts.PromptError as exc:
365
+ result = RunResult(
366
+ provider=choice.provider.name,
367
+ project=project.name,
368
+ task=task,
369
+ status="failed",
370
+ findings_md="",
371
+ started_at=now,
372
+ duration_s=0.0,
373
+ detail=str(exc),
374
+ )
375
+ report.store_result(cfg, result)
376
+ results.append(result)
377
+ return Outcome(True, str(exc), results=results)
378
+
379
+ for attempt in range(1, MAX_ATTEMPTS + 1):
380
+ # Every run publishes, attended or not: `nightshift watch` is a
381
+ # separate process and cannot subscribe to a callback in this one.
382
+ event_log = events.EventLog.open(
383
+ project.name, task, choice.provider.name, _now(), attempt
384
+ )
385
+ try:
386
+ result = _attempt(
387
+ choice.adapter,
388
+ prompt,
389
+ project,
390
+ task,
391
+ cfg.timeout_s,
392
+ attempt,
393
+ _tee(event_log.write, on_event),
394
+ )
395
+ except BaseException:
396
+ # Ctrl-C and the like: leave the log unfinished, which is the
397
+ # truth — the run did not end, it was interrupted.
398
+ event_log.close_quietly()
399
+ raise
400
+ event_log.finish(result, len(report.parse_findings(result)))
401
+ # Spend is recorded before the report is written: if the disk is
402
+ # full we would rather lose the findings than lose the count.
403
+ if result.billed:
404
+ ledger.increment(choice.provider.name, now)
405
+ report.store_result(cfg, result)
406
+ results.append(result)
407
+
408
+ if result.status == "ok":
409
+ break
410
+ if attempt >= MAX_ATTEMPTS:
411
+ break
412
+ usage = ledger.usage(choice.provider.name, choice.provider.budget, now)
413
+ if usage.exhausted:
414
+ log.info("not retrying %s/%s — %s", project.name, task, usage.reason())
415
+ break
416
+ log.info("retrying %s/%s after %s", project.name, task, result.status)
417
+
418
+ return Outcome(True, "", results=results)
419
+ finally:
420
+ lock.release()
421
+
422
+
423
+ def next_eligible(cfg: Config, now: datetime | None = None) -> datetime | None:
424
+ now = now or _now()
425
+ return cfg.schedule.next_open(now)
nightshift/sessions.py ADDED
@@ -0,0 +1,62 @@
1
+ """Which AI CLI sessions were ours rather than a human's.
2
+
3
+ ``last_human_use`` decides whether a human is at the keyboard by reading the
4
+ newest mtime under the CLI's own session directory — ``~/.claude/projects`` for
5
+ Claude Code, ``$CODEX_HOME/sessions`` for Codex. nightshift's own runs write
6
+ their transcripts into that same directory — the same directory, not merely a
7
+ similar one — so without this every run would leave a fresh mtime that the next
8
+ cron tick reads as human activity, and nightshift would gate itself out for
9
+ ``idle_minutes`` after each run. It would put itself to sleep.
10
+
11
+ Filtering by project path cannot fix that: a human running the CLI inside a
12
+ registered project is precisely when nightshift must stay away. The only honest
13
+ discriminator is which session the transcript belongs to, and both CLIs hand us
14
+ an id for the session they just ran — ``session_id`` from Claude Code,
15
+ ``thread_id`` from Codex's ``thread.started``.
16
+
17
+ Ids from every provider share one set. They are opaque and provider-unique, so a
18
+ Codex id can never match a Claude transcript or the reverse; keeping one set
19
+ means one thing to prune and one thing to lose.
20
+
21
+ This is a cache, not a record. Losing it makes nightshift shy for an hour, not
22
+ wrong, so every function here fails quiet.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from nightshift.config import state_dir
28
+ from nightshift.store import read_json, write_json
29
+
30
+ #: Session ids kept. Only ones newer than the idle window matter, so this need
31
+ #: only outlive a night's runs — it is not history.
32
+ KEEP = 64
33
+
34
+
35
+ def path():
36
+ return state_dir() / "sessions.json"
37
+
38
+
39
+ def ours() -> set[str]:
40
+ """Session ids nightshift started."""
41
+ data = read_json(path(), {})
42
+ ids = data.get("ids") if isinstance(data, dict) else None
43
+ return {str(i) for i in ids} if isinstance(ids, list) else set()
44
+
45
+
46
+ def record(session_id: str) -> None:
47
+ """Remember that ``session_id`` was ours, not a human's."""
48
+ session_id = (session_id or "").strip()
49
+ if not session_id:
50
+ return
51
+ data = read_json(path(), {})
52
+ ids = data.get("ids") if isinstance(data, dict) else None
53
+ kept = [str(i) for i in ids] if isinstance(ids, list) else []
54
+ if session_id in kept:
55
+ return
56
+ kept.append(session_id)
57
+ try:
58
+ write_json(path(), {"ids": kept[-KEEP:]})
59
+ except OSError:
60
+ # A run that cannot write this is still a correct run; the cost is one
61
+ # idle window of unnecessary shyness.
62
+ pass
nightshift/store.py ADDED
@@ -0,0 +1,48 @@
1
+ """Small JSON state files, written atomically.
2
+
3
+ nightshift is invoked from cron and may be killed mid-write; every state file
4
+ goes through a temp file + ``os.replace`` so a torn write can never leave a
5
+ half-parsed ledger behind.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import tempfile
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+
17
+ def read_json(path: Path, default: Any) -> Any:
18
+ """Read ``path``, returning ``default`` if it is missing or unreadable.
19
+
20
+ A corrupt state file is not worth crashing a cron job over — nightshift
21
+ starts from ``default`` and carries on.
22
+ """
23
+ try:
24
+ with path.open("r", encoding="utf-8") as fh:
25
+ return json.load(fh)
26
+ except FileNotFoundError:
27
+ return default
28
+ except (json.JSONDecodeError, OSError, UnicodeDecodeError):
29
+ return default
30
+
31
+
32
+ def write_json(path: Path, data: Any) -> None:
33
+ """Serialise ``data`` to ``path`` atomically."""
34
+ path.parent.mkdir(parents=True, exist_ok=True)
35
+ fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=f".{path.name}.", suffix=".tmp")
36
+ try:
37
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
38
+ json.dump(data, fh, indent=2, sort_keys=True)
39
+ fh.write("\n")
40
+ fh.flush()
41
+ os.fsync(fh.fileno())
42
+ os.replace(tmp, path)
43
+ except BaseException:
44
+ try:
45
+ os.unlink(tmp)
46
+ except OSError:
47
+ pass
48
+ raise