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.
- nightshift/__init__.py +8 -0
- nightshift/__main__.py +4 -0
- nightshift/adapters/__init__.py +61 -0
- nightshift/adapters/_process.py +219 -0
- nightshift/adapters/base.py +171 -0
- nightshift/adapters/claude_code.py +372 -0
- nightshift/adapters/codex.py +360 -0
- nightshift/adapters/copilot.py +51 -0
- nightshift/budget.py +150 -0
- nightshift/cli.py +602 -0
- nightshift/config.py +447 -0
- nightshift/cron.py +96 -0
- nightshift/events.py +293 -0
- nightshift/lock.py +197 -0
- nightshift/prompts/code_review.md +24 -0
- nightshift/prompts/dead_links.md +21 -0
- nightshift/prompts/deps_audit.md +23 -0
- nightshift/prompts/docs_drift.md +21 -0
- nightshift/prompts/security_audit.md +23 -0
- nightshift/prompts.py +66 -0
- nightshift/queue.py +92 -0
- nightshift/report.py +394 -0
- nightshift/scheduler.py +425 -0
- nightshift/sessions.py +62 -0
- nightshift/store.py +48 -0
- nightshift_cli-0.1.0.dist-info/METADATA +430 -0
- nightshift_cli-0.1.0.dist-info/RECORD +30 -0
- nightshift_cli-0.1.0.dist-info/WHEEL +4 -0
- nightshift_cli-0.1.0.dist-info/entry_points.txt +2 -0
- nightshift_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
nightshift/scheduler.py
ADDED
|
@@ -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
|