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
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
"""The Codex adapter.
|
|
2
|
+
|
|
3
|
+
Read-only is enforced by Codex's own sandbox — Seatbelt on macOS, Landlock plus
|
|
4
|
+
seccomp on Linux, restricted tokens on Windows. That is a stronger guarantee
|
|
5
|
+
than the tool allowlist the Claude Code adapter relies on: an allowlist works
|
|
6
|
+
because the agent honours its own tool gating, whereas here the kernel refuses
|
|
7
|
+
the write. A bug in the model cannot touch the disk.
|
|
8
|
+
|
|
9
|
+
The flags below were read off the published `codex exec` reference rather than
|
|
10
|
+
assumed, and two of them are load-bearing in ways worth stating:
|
|
11
|
+
|
|
12
|
+
- ``--sandbox read-only`` is passed explicitly even though a fresh install
|
|
13
|
+
already defaults to it, because the reference is explicit that ``--sandbox``
|
|
14
|
+
"defaults to configuration settings". A user's ``~/.codex/config.toml``
|
|
15
|
+
setting ``workspace-write`` would otherwise silently become nightshift's
|
|
16
|
+
default too. "It defaults to read-only" is a fact about a fresh machine, not
|
|
17
|
+
a guarantee, and this product's whole claim rests on the difference.
|
|
18
|
+
|
|
19
|
+
- ``--ignore-user-config`` is belt-and-braces of the same kind as the Claude
|
|
20
|
+
adapter's denylist — the explicit flags above should already settle it. It
|
|
21
|
+
earns its place on a second count: MCP servers are configured in that same
|
|
22
|
+
``config.toml``, and an MCP tool is not a shell command, so it is not obvious
|
|
23
|
+
that the filesystem sandbox constrains one at all. Refusing to load the file
|
|
24
|
+
means there are no MCP servers to find out about. Authentication is
|
|
25
|
+
unaffected — the reference states auth still resolves via ``CODEX_HOME``.
|
|
26
|
+
|
|
27
|
+
The cost of that second flag is real and worth knowing: a user's configured
|
|
28
|
+
model choice is ignored too, so runs use Codex's default model.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import os
|
|
34
|
+
import shutil
|
|
35
|
+
import subprocess
|
|
36
|
+
from datetime import datetime
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
|
|
39
|
+
from nightshift import sessions
|
|
40
|
+
from nightshift.adapters._process import (
|
|
41
|
+
clip,
|
|
42
|
+
emit,
|
|
43
|
+
stream_ndjson,
|
|
44
|
+
summarize,
|
|
45
|
+
)
|
|
46
|
+
from nightshift.adapters.base import Availability, Event, OnEvent, RunResult
|
|
47
|
+
|
|
48
|
+
#: The only sandbox nightshift will ever ask for. Not a default — an assertion.
|
|
49
|
+
SANDBOX = "read-only"
|
|
50
|
+
|
|
51
|
+
#: Never pause for a human: there isn't one, and a run that could ask for
|
|
52
|
+
#: approval is a run that could be granted an escalation out of the sandbox.
|
|
53
|
+
APPROVAL = "never"
|
|
54
|
+
|
|
55
|
+
#: Where Codex records its sessions. ``CODEX_HOME`` relocates the whole tree.
|
|
56
|
+
CODEX_HOME = "~/.codex"
|
|
57
|
+
|
|
58
|
+
_READ_ONLY_PREAMBLE = (
|
|
59
|
+
"You are running unattended as part of an automated read-only review.\n"
|
|
60
|
+
"You are in a read-only sandbox: you cannot write, edit, or delete files, "
|
|
61
|
+
"and nothing you do can change this repository. Do not attempt it, and do "
|
|
62
|
+
"not ask questions. Inspect the repository and report findings only.\n\n"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _first_token(command: str) -> tuple[str, str]:
|
|
67
|
+
"""Split a shell command into (program, rest) for display.
|
|
68
|
+
|
|
69
|
+
``rg -n foo src/`` reads better as ``rg(-n foo src/)`` than as
|
|
70
|
+
``Shell(rg -n foo src/)``, and lines up with how the Claude adapter names
|
|
71
|
+
the tool it is running.
|
|
72
|
+
"""
|
|
73
|
+
command = " ".join(str(command or "").split())
|
|
74
|
+
if not command:
|
|
75
|
+
return "shell", ""
|
|
76
|
+
head, _, rest = command.partition(" ")
|
|
77
|
+
return head, rest
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class _Collector:
|
|
81
|
+
"""Turns Codex's NDJSON event stream into findings, events, and alarms.
|
|
82
|
+
|
|
83
|
+
One of these per run. It is the ``on_line`` sink for ``stream_ndjson``, and
|
|
84
|
+
it is deliberately the only place that knows Codex's event schema.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
def __init__(self, project_dir: Path, on_event: OnEvent | None = None):
|
|
88
|
+
self.project_dir = project_dir
|
|
89
|
+
self.on_event = on_event
|
|
90
|
+
#: Every ``agent_message`` seen, in order.
|
|
91
|
+
self.messages: list[str] = []
|
|
92
|
+
#: Paths Codex reported it *changed*. Must stay empty; see :meth:`_item`.
|
|
93
|
+
self.wrote: list[str] = []
|
|
94
|
+
#: A turn- or stream-level error message, preferred over raw stderr.
|
|
95
|
+
self.error = ""
|
|
96
|
+
self._claimed = False
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def findings(self) -> str:
|
|
100
|
+
"""The final assistant message.
|
|
101
|
+
|
|
102
|
+
Codex's own ``--output-last-message`` treats the last one as *the*
|
|
103
|
+
answer, so we do too. Joining every message instead would file the
|
|
104
|
+
model's running narration into the digest alongside the findings.
|
|
105
|
+
"""
|
|
106
|
+
return self.messages[-1] if self.messages else ""
|
|
107
|
+
|
|
108
|
+
def _emit(self, event: Event) -> None:
|
|
109
|
+
if self.on_event is not None:
|
|
110
|
+
emit(self.on_event, event)
|
|
111
|
+
|
|
112
|
+
def __call__(self, payload: dict) -> None:
|
|
113
|
+
kind = payload.get("type")
|
|
114
|
+
|
|
115
|
+
if kind == "thread.started":
|
|
116
|
+
thread_id = str(payload.get("thread_id") or "")
|
|
117
|
+
if thread_id and not self._claimed:
|
|
118
|
+
# Claim on sight, not at the end: a run killed at the deadline
|
|
119
|
+
# still left a session file behind, and an unclaimed session is
|
|
120
|
+
# one last_human_use later mistakes for a human's.
|
|
121
|
+
sessions.record(thread_id)
|
|
122
|
+
self._claimed = True
|
|
123
|
+
self._emit(Event("start", text=str(self.project_dir)))
|
|
124
|
+
|
|
125
|
+
elif kind == "turn.failed":
|
|
126
|
+
error = payload.get("error")
|
|
127
|
+
message = error.get("message") if isinstance(error, dict) else None
|
|
128
|
+
self.error = str(message or "the turn failed")
|
|
129
|
+
self._emit(Event("error", text=self.error))
|
|
130
|
+
|
|
131
|
+
elif kind == "error":
|
|
132
|
+
self.error = str(payload.get("message") or "the run reported an error")
|
|
133
|
+
self._emit(Event("error", text=self.error))
|
|
134
|
+
|
|
135
|
+
elif kind in ("item.started", "item.completed"):
|
|
136
|
+
item = payload.get("item")
|
|
137
|
+
if isinstance(item, dict):
|
|
138
|
+
self._item(kind, item)
|
|
139
|
+
|
|
140
|
+
def _item(self, kind: str, item: dict) -> None:
|
|
141
|
+
item_type = item.get("type")
|
|
142
|
+
done = kind == "item.completed"
|
|
143
|
+
|
|
144
|
+
if item_type == "agent_message" and done:
|
|
145
|
+
text = str(item.get("text") or "").strip()
|
|
146
|
+
if text:
|
|
147
|
+
self.messages.append(text)
|
|
148
|
+
self._emit(Event("text", text=text))
|
|
149
|
+
|
|
150
|
+
elif item_type == "reasoning" and done:
|
|
151
|
+
self._emit(Event("thinking"))
|
|
152
|
+
|
|
153
|
+
elif item_type == "command_execution":
|
|
154
|
+
if done:
|
|
155
|
+
self._emit(Event("tool_result", text=summarize(item.get("aggregated_output"))))
|
|
156
|
+
else:
|
|
157
|
+
program, rest = _first_token(str(item.get("command") or ""))
|
|
158
|
+
self._emit(Event("tool", tool=program, detail=clip(rest, 60)))
|
|
159
|
+
|
|
160
|
+
elif item_type == "file_change" and done:
|
|
161
|
+
# The tripwire. A read-only sandbox cannot let this happen, so if it
|
|
162
|
+
# does, either the sandbox did not apply or it did not hold — and
|
|
163
|
+
# every claim nightshift makes about this provider is void for this
|
|
164
|
+
# run. `status` matters: a *failed* file_change is the sandbox doing
|
|
165
|
+
# its job and refusing a write, which is not an alarm.
|
|
166
|
+
if str(item.get("status") or "") != "completed":
|
|
167
|
+
return
|
|
168
|
+
changes = item.get("changes")
|
|
169
|
+
paths = [
|
|
170
|
+
str(c.get("path"))
|
|
171
|
+
for c in (changes if isinstance(changes, list) else [])
|
|
172
|
+
if isinstance(c, dict) and c.get("path")
|
|
173
|
+
]
|
|
174
|
+
self.wrote.extend(paths or ["<unnamed file>"])
|
|
175
|
+
self._emit(Event("error", text=f"file changed: {', '.join(self.wrote)}"))
|
|
176
|
+
|
|
177
|
+
elif item_type == "mcp_tool_call" and not done:
|
|
178
|
+
# --ignore-user-config means no MCP servers are configured, so this
|
|
179
|
+
# should never arrive. Render it rather than drop it: a tool we did
|
|
180
|
+
# not expect is worth seeing in the live view.
|
|
181
|
+
server = str(item.get("server") or "?")
|
|
182
|
+
self._emit(Event("tool", tool=f"{server}:{item.get('tool') or '?'}"))
|
|
183
|
+
|
|
184
|
+
elif item_type == "web_search" and done:
|
|
185
|
+
self._emit(Event("tool", tool="web_search", detail=clip(item.get("query"), 60)))
|
|
186
|
+
|
|
187
|
+
elif item_type == "error" and done:
|
|
188
|
+
self._emit(Event("error", text=str(item.get("message") or "")))
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class CodexAdapter:
|
|
192
|
+
name = "codex"
|
|
193
|
+
|
|
194
|
+
def __init__(self, binary: str = "codex"):
|
|
195
|
+
self.binary = binary
|
|
196
|
+
|
|
197
|
+
# ---- availability -------------------------------------------------
|
|
198
|
+
|
|
199
|
+
def _which(self) -> str | None:
|
|
200
|
+
return shutil.which(self.binary)
|
|
201
|
+
|
|
202
|
+
def availability(self) -> Availability:
|
|
203
|
+
path = self._which()
|
|
204
|
+
if path is None:
|
|
205
|
+
return Availability(
|
|
206
|
+
ok=False,
|
|
207
|
+
reason=f"`{self.binary}` is not on PATH — install Codex, see "
|
|
208
|
+
f"https://developers.openai.com/codex",
|
|
209
|
+
)
|
|
210
|
+
try:
|
|
211
|
+
proc = subprocess.run(
|
|
212
|
+
[self.binary, "--version"],
|
|
213
|
+
capture_output=True,
|
|
214
|
+
text=True,
|
|
215
|
+
timeout=20,
|
|
216
|
+
check=False,
|
|
217
|
+
)
|
|
218
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
219
|
+
return Availability(ok=False, reason=f"`{self.binary} --version` failed: {exc}")
|
|
220
|
+
if proc.returncode != 0:
|
|
221
|
+
detail = (proc.stderr or proc.stdout or "").strip().splitlines()
|
|
222
|
+
first = detail[0] if detail else f"exit {proc.returncode}"
|
|
223
|
+
return Availability(ok=False, reason=f"`{self.binary} --version` failed: {first}")
|
|
224
|
+
return Availability(ok=True, reason=(proc.stdout or "").strip())
|
|
225
|
+
|
|
226
|
+
def available(self) -> bool:
|
|
227
|
+
return self.availability().ok
|
|
228
|
+
|
|
229
|
+
# ---- idle detection -----------------------------------------------
|
|
230
|
+
|
|
231
|
+
def _sessions_dir(self) -> Path:
|
|
232
|
+
home = os.environ.get("CODEX_HOME") or CODEX_HOME
|
|
233
|
+
return Path(os.path.expanduser(home)) / "sessions"
|
|
234
|
+
|
|
235
|
+
def last_human_use(self) -> datetime | None:
|
|
236
|
+
"""Newest mtime under ``$CODEX_HOME/sessions`` that was not ours.
|
|
237
|
+
|
|
238
|
+
``None`` when the directory is absent: a Codex that has never run reads
|
|
239
|
+
as idle, not as busy.
|
|
240
|
+
|
|
241
|
+
Our own runs write session files here too, so they are skipped — count
|
|
242
|
+
them and every run would gate the next one, and nightshift would put
|
|
243
|
+
itself to sleep. Codex names these ``rollout-<timestamp>-<thread_id>``
|
|
244
|
+
rather than after the id alone, so this matches on *containment* where
|
|
245
|
+
the Claude adapter can compare the stem outright.
|
|
246
|
+
|
|
247
|
+
Only files count. A directory's mtime bumps when anything inside it is
|
|
248
|
+
written, including our own session, so it cannot be attributed to a
|
|
249
|
+
human once nightshift shares the tree.
|
|
250
|
+
"""
|
|
251
|
+
root = self._sessions_dir()
|
|
252
|
+
if not root.is_dir():
|
|
253
|
+
return None
|
|
254
|
+
mine = sessions.ours()
|
|
255
|
+
newest: float | None = None
|
|
256
|
+
try:
|
|
257
|
+
for path in root.rglob("*"):
|
|
258
|
+
try:
|
|
259
|
+
if not path.is_file():
|
|
260
|
+
continue
|
|
261
|
+
if any(sid and sid in path.name for sid in mine):
|
|
262
|
+
continue
|
|
263
|
+
mtime = path.stat().st_mtime
|
|
264
|
+
except OSError:
|
|
265
|
+
continue
|
|
266
|
+
if newest is None or mtime > newest:
|
|
267
|
+
newest = mtime
|
|
268
|
+
except OSError:
|
|
269
|
+
return None
|
|
270
|
+
if newest is None:
|
|
271
|
+
return None
|
|
272
|
+
return datetime.fromtimestamp(newest)
|
|
273
|
+
|
|
274
|
+
# ---- running ------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
def command(self, prompt: str) -> list[str]:
|
|
277
|
+
return [
|
|
278
|
+
self.binary,
|
|
279
|
+
"exec",
|
|
280
|
+
"--sandbox",
|
|
281
|
+
SANDBOX,
|
|
282
|
+
"--ask-for-approval",
|
|
283
|
+
APPROVAL,
|
|
284
|
+
# nightshift registers directories, not repositories; codex exec
|
|
285
|
+
# refuses to run outside a git repo without this. The check exists
|
|
286
|
+
# to protect uncommitted work from edits, which is not a risk we
|
|
287
|
+
# have.
|
|
288
|
+
"--skip-git-repo-check",
|
|
289
|
+
"--ignore-user-config",
|
|
290
|
+
# Project-local execpolicy rules cannot widen an OS sandbox, but
|
|
291
|
+
# they can make one project behave unlike another. A review that
|
|
292
|
+
# depends on a file in the repo being reviewed is not one we want.
|
|
293
|
+
"--ignore-rules",
|
|
294
|
+
"--json",
|
|
295
|
+
_READ_ONLY_PREAMBLE + prompt,
|
|
296
|
+
]
|
|
297
|
+
|
|
298
|
+
def run(
|
|
299
|
+
self,
|
|
300
|
+
prompt: str,
|
|
301
|
+
project_dir: Path,
|
|
302
|
+
timeout_s: int,
|
|
303
|
+
on_event: OnEvent | None = None,
|
|
304
|
+
) -> RunResult:
|
|
305
|
+
"""One read-only review.
|
|
306
|
+
|
|
307
|
+
Unlike the Claude adapter there is no separate buffered path: NDJSON is
|
|
308
|
+
the only output format worth parsing, and ``stream_ndjson`` is the only
|
|
309
|
+
implementation that reliably kills the process tree at the deadline.
|
|
310
|
+
An unattended run is the same run with nobody listening.
|
|
311
|
+
"""
|
|
312
|
+
started = datetime.now()
|
|
313
|
+
|
|
314
|
+
def finish(status: str, findings_md: str, detail: str = "") -> RunResult:
|
|
315
|
+
return RunResult(
|
|
316
|
+
provider=self.name,
|
|
317
|
+
project=project_dir.name,
|
|
318
|
+
task="",
|
|
319
|
+
status=status, # type: ignore[arg-type]
|
|
320
|
+
findings_md=findings_md,
|
|
321
|
+
started_at=started,
|
|
322
|
+
duration_s=(datetime.now() - started).total_seconds(),
|
|
323
|
+
detail=detail,
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
if not project_dir.is_dir():
|
|
327
|
+
return finish("failed", "", f"project path does not exist: {project_dir}")
|
|
328
|
+
|
|
329
|
+
collector = _Collector(project_dir, on_event)
|
|
330
|
+
try:
|
|
331
|
+
outcome = stream_ndjson(self.command(prompt), project_dir, timeout_s, collector)
|
|
332
|
+
except FileNotFoundError:
|
|
333
|
+
return finish("failed", "", f"`{self.binary}` is not on PATH")
|
|
334
|
+
except OSError as exc:
|
|
335
|
+
return finish("failed", "", f"could not start `{self.binary}`: {exc}")
|
|
336
|
+
|
|
337
|
+
findings = collector.findings
|
|
338
|
+
|
|
339
|
+
if collector.wrote:
|
|
340
|
+
# Before anything else, including the exit code: a run that changed
|
|
341
|
+
# files broke the promise the provider is here to keep, and "it also
|
|
342
|
+
# exited 0" is not the headline. Fail loudly and keep the output, so
|
|
343
|
+
# the digest carries the evidence.
|
|
344
|
+
return finish(
|
|
345
|
+
"failed",
|
|
346
|
+
findings,
|
|
347
|
+
f"sandbox breach — codex reported changing {', '.join(collector.wrote)}; "
|
|
348
|
+
"refusing to treat this run as read-only",
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
if outcome.timed_out:
|
|
352
|
+
return finish("timeout", findings, f"no output after {timeout_s}s")
|
|
353
|
+
|
|
354
|
+
if outcome.returncode != 0:
|
|
355
|
+
return finish("failed", findings, collector.error or outcome.stderr_head)
|
|
356
|
+
|
|
357
|
+
if not findings:
|
|
358
|
+
return finish("failed", "", collector.error or "no output")
|
|
359
|
+
|
|
360
|
+
return finish("ok", findings)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Copilot adapter — a documented stub, and currently a blocked one.
|
|
2
|
+
|
|
3
|
+
**Help wanted, but the obstacle is upstream, not effort.** The scheduler, ledger,
|
|
4
|
+
queue, and digest are all provider-agnostic, so this is one class against the
|
|
5
|
+
contract in ``nightshift.adapters.base`` — perhaps an afternoon. It is not
|
|
6
|
+
written because Copilot CLI has no enforcement primitive that clears our bar.
|
|
7
|
+
|
|
8
|
+
What was checked, as of 2026-07, so the next person need not repeat it:
|
|
9
|
+
|
|
10
|
+
- ``--allow-tool`` / ``--deny-tool`` exist, and deny beats allow. That much is
|
|
11
|
+
the right shape.
|
|
12
|
+
- But denials are per-tool, not per-resource: a denied ``read(x)`` does not stop
|
|
13
|
+
``shell(cat x)``. A rule one tool honours and another ignores is not a
|
|
14
|
+
boundary, it is a suggestion.
|
|
15
|
+
- https://github.com/github/copilot-cli/issues/2722 (open) reports
|
|
16
|
+
``--deny-tool="read(...)"`` blocking *all* file reads regardless of pattern,
|
|
17
|
+
and no persistent permission profile for non-interactive use.
|
|
18
|
+
- The docs do not say what happens when the model reaches for a tool that was
|
|
19
|
+
never allowed in programmatic mode. That is the whole guarantee, undocumented.
|
|
20
|
+
|
|
21
|
+
So the blocker is not "nobody wrote it". It is that nightshift cannot promise
|
|
22
|
+
"0 files touched" on top of that, and the promise is the product. Compare
|
|
23
|
+
``codex.py``, which is implemented precisely because Codex hands us an OS-level
|
|
24
|
+
sandbox to stand on.
|
|
25
|
+
|
|
26
|
+
If upstream ships a real allowlist — one that binds every tool, and is
|
|
27
|
+
documented for non-interactive runs — then this becomes worth writing:
|
|
28
|
+
|
|
29
|
+
1. ``availability()`` — is the GitHub Copilot CLI on PATH and authenticated?
|
|
30
|
+
2. ``last_human_use()`` — newest mtime of whatever Copilot writes per session,
|
|
31
|
+
so nightshift stays out of the user's way. Return ``None`` if unknowable.
|
|
32
|
+
3. ``run()`` — invoke Copilot headlessly against ``project_dir`` **read-only**,
|
|
33
|
+
and map its exit into ``ok`` / ``failed`` / ``timeout``.
|
|
34
|
+
|
|
35
|
+
``claude_code.py`` and ``codex.py`` are both reference shapes: the first enforces
|
|
36
|
+
read-only with CLI permission flags, the second with a kernel sandbox. Either is
|
|
37
|
+
acceptable. Asking the model nicely is not.
|
|
38
|
+
|
|
39
|
+
https://github.com/kishormorol/nightshift/issues
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
from dataclasses import dataclass
|
|
45
|
+
|
|
46
|
+
from nightshift.adapters.base import StubAdapter
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class CopilotAdapter(StubAdapter):
|
|
51
|
+
name: str = "copilot"
|
nightshift/budget.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Per-provider run ledger.
|
|
2
|
+
|
|
3
|
+
Counts *attempts*, not successes: a failed or timed-out run still consumed the
|
|
4
|
+
user's quota, so it still costs budget. The ledger is the only thing standing
|
|
5
|
+
between a cron job and someone's monthly limit, so it errs toward counting.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from datetime import date, datetime, timedelta
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from nightshift.config import Budget, state_dir
|
|
15
|
+
from nightshift.store import read_json, write_json
|
|
16
|
+
|
|
17
|
+
#: Ledger entries older than this are dropped when the file is loaded.
|
|
18
|
+
RETENTION_DAYS = 30
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def day_key(d: date) -> str:
|
|
22
|
+
return d.isoformat()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def week_key(d: date) -> str:
|
|
26
|
+
"""ISO week key, e.g. ``2026-W29``. ISO weeks start on Monday."""
|
|
27
|
+
year, week, _ = d.isocalendar()
|
|
28
|
+
return f"{year}-W{week:02d}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _week_start(key: str) -> date | None:
|
|
32
|
+
"""The Monday of an ISO week key, or ``None`` if unparseable."""
|
|
33
|
+
try:
|
|
34
|
+
year_s, week_s = key.split("-W", 1)
|
|
35
|
+
return date.fromisocalendar(int(year_s), int(week_s), 1)
|
|
36
|
+
except (ValueError, TypeError):
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _day_start(key: str) -> date | None:
|
|
41
|
+
try:
|
|
42
|
+
return date.fromisoformat(key)
|
|
43
|
+
except (ValueError, TypeError):
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def entry_date(key: str) -> date | None:
|
|
48
|
+
"""The date a ledger key refers to, whichever form it takes."""
|
|
49
|
+
return _week_start(key) if "-W" in key else _day_start(key)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class Usage:
|
|
54
|
+
"""What a provider has spent, against what it is allowed."""
|
|
55
|
+
|
|
56
|
+
day: int
|
|
57
|
+
week: int
|
|
58
|
+
max_day: int
|
|
59
|
+
max_week: int
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def day_exhausted(self) -> bool:
|
|
63
|
+
return self.day >= self.max_day
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def week_exhausted(self) -> bool:
|
|
67
|
+
return self.week >= self.max_week
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def exhausted(self) -> bool:
|
|
71
|
+
return self.day_exhausted or self.week_exhausted
|
|
72
|
+
|
|
73
|
+
def reason(self) -> str:
|
|
74
|
+
if self.day_exhausted:
|
|
75
|
+
return f"daily budget spent ({self.day}/{self.max_day} today)"
|
|
76
|
+
if self.week_exhausted:
|
|
77
|
+
return f"weekly budget spent ({self.week}/{self.max_week} this week)"
|
|
78
|
+
return ""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def ledger_path() -> Path:
|
|
82
|
+
return state_dir() / "ledger.json"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class Ledger:
|
|
86
|
+
"""A JSON map of ``{provider: {day-or-week key: count}}``."""
|
|
87
|
+
|
|
88
|
+
def __init__(self, path: Path | None = None):
|
|
89
|
+
self.path = path or ledger_path()
|
|
90
|
+
self._data: dict[str, dict[str, int]] = self._load()
|
|
91
|
+
|
|
92
|
+
def _load(self) -> dict[str, dict[str, int]]:
|
|
93
|
+
raw = read_json(self.path, default={})
|
|
94
|
+
if not isinstance(raw, dict):
|
|
95
|
+
return {}
|
|
96
|
+
data: dict[str, dict[str, int]] = {}
|
|
97
|
+
for provider, entries in raw.items():
|
|
98
|
+
if not isinstance(provider, str) or not isinstance(entries, dict):
|
|
99
|
+
continue
|
|
100
|
+
clean: dict[str, int] = {}
|
|
101
|
+
for key, count in entries.items():
|
|
102
|
+
if isinstance(key, str) and isinstance(count, int) and not isinstance(count, bool):
|
|
103
|
+
clean[key] = count
|
|
104
|
+
data[provider] = clean
|
|
105
|
+
return data
|
|
106
|
+
|
|
107
|
+
def prune(self, today: date | None = None) -> int:
|
|
108
|
+
"""Drop entries older than :data:`RETENTION_DAYS`. Returns count removed."""
|
|
109
|
+
today = today or date.today()
|
|
110
|
+
cutoff = today - timedelta(days=RETENTION_DAYS)
|
|
111
|
+
removed = 0
|
|
112
|
+
for provider, entries in self._data.items():
|
|
113
|
+
for key in list(entries):
|
|
114
|
+
when = entry_date(key)
|
|
115
|
+
if when is None or when < cutoff:
|
|
116
|
+
del entries[key]
|
|
117
|
+
removed += 1
|
|
118
|
+
return removed
|
|
119
|
+
|
|
120
|
+
def count(self, provider: str, key: str) -> int:
|
|
121
|
+
return self._data.get(provider, {}).get(key, 0)
|
|
122
|
+
|
|
123
|
+
def usage(self, provider: str, budget: Budget, when: datetime | None = None) -> Usage:
|
|
124
|
+
when = when or datetime.now()
|
|
125
|
+
d = when.date()
|
|
126
|
+
return Usage(
|
|
127
|
+
day=self.count(provider, day_key(d)),
|
|
128
|
+
week=self.count(provider, week_key(d)),
|
|
129
|
+
max_day=budget.max_runs_per_day,
|
|
130
|
+
max_week=budget.max_runs_per_week,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def increment(self, provider: str, when: datetime | None = None, by: int = 1) -> None:
|
|
134
|
+
"""Record ``by`` attempts against ``provider`` and persist immediately.
|
|
135
|
+
|
|
136
|
+
Persisting eagerly matters: if the process dies after spending quota but
|
|
137
|
+
before writing, the next run would happily spend it again.
|
|
138
|
+
"""
|
|
139
|
+
when = when or datetime.now()
|
|
140
|
+
d = when.date()
|
|
141
|
+
entries = self._data.setdefault(provider, {})
|
|
142
|
+
for key in (day_key(d), week_key(d)):
|
|
143
|
+
entries[key] = entries.get(key, 0) + by
|
|
144
|
+
self.save()
|
|
145
|
+
|
|
146
|
+
def save(self) -> None:
|
|
147
|
+
write_json(self.path, self._data)
|
|
148
|
+
|
|
149
|
+
def as_dict(self) -> dict[str, dict[str, int]]:
|
|
150
|
+
return {p: dict(e) for p, e in self._data.items()}
|