relay-codex 0.4.1 → 0.5.0
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.
- package/README.md +26 -7
- package/docs/commands.md +123 -39
- package/docs/publishing.md +29 -13
- package/docs/troubleshooting.md +15 -3
- package/package.json +1 -1
- package/python/Menu.swift +169 -51
- package/python/commands.py +219 -20
- package/python/compatibility.py +3 -1
- package/python/desktop.py +131 -12
- package/python/execution.py +551 -0
- package/python/launch.py +375 -75
- package/python/pool.py +7 -0
- package/python/router.py +305 -70
- package/python/service.py +216 -142
- package/python/settings.py +1 -0
- package/python/tools.py +233 -0
- package/python/watch.py +74 -0
- package/tests/fake_native.py +4 -2
- package/tests/test_audit.py +320 -0
- package/tests/test_controller.py +3 -5
- package/tests/test_features.py +350 -0
- package/tests/test_launch.py +150 -0
- package/tests/test_services.py +6 -0
package/python/launch.py
CHANGED
|
@@ -1,44 +1,121 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Keep native argument boundaries while adapting interactive remote launches."""
|
|
2
2
|
|
|
3
|
+
import json
|
|
4
|
+
import functools
|
|
5
|
+
import subprocess
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import tomllib
|
|
9
|
+
from pathlib import Path
|
|
3
10
|
|
|
4
|
-
|
|
11
|
+
# Values must be consumed together: a value resembling another flag is still a
|
|
12
|
+
# value. Unknown options remain untouched for the installed native CLI to validate.
|
|
13
|
+
RESUME_FIELDS = {
|
|
14
|
+
"model",
|
|
15
|
+
"modelProvider",
|
|
16
|
+
"cwd",
|
|
17
|
+
"approvalPolicy",
|
|
18
|
+
"approvalsReviewer",
|
|
19
|
+
"sandbox",
|
|
20
|
+
"permissions",
|
|
21
|
+
"serviceTier",
|
|
22
|
+
"runtimeWorkspaceRoots",
|
|
23
|
+
"config",
|
|
24
|
+
"personality",
|
|
25
|
+
"baseInstructions",
|
|
26
|
+
"developerInstructions",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
VALUE_FLAGS = {
|
|
31
|
+
"-c": "--config",
|
|
32
|
+
"-p": "--profile",
|
|
33
|
+
"-m": "--model",
|
|
34
|
+
"-C": "--cd",
|
|
35
|
+
"-i": "--image",
|
|
36
|
+
"-s": "--sandbox",
|
|
37
|
+
"-a": "--ask-for-approval",
|
|
38
|
+
"-P": "--permission-profile",
|
|
39
|
+
"--enable": "--enable",
|
|
40
|
+
"--disable": "--disable",
|
|
41
|
+
"--local-provider": "--local-provider",
|
|
42
|
+
"--add-dir": "--add-dir",
|
|
43
|
+
"--remote": "--remote",
|
|
44
|
+
"--remote-auth-token-env": "--remote-auth-token-env",
|
|
45
|
+
"--thread-source": "--thread-source",
|
|
46
|
+
"--cyber-access-program": "--cyber-access-program",
|
|
47
|
+
"--output-schema": "--output-schema",
|
|
48
|
+
"--color": "--color",
|
|
49
|
+
"-o": "--output-last-message",
|
|
50
|
+
"--base": "--base",
|
|
51
|
+
"--commit": "--commit",
|
|
52
|
+
"--title": "--title",
|
|
53
|
+
}
|
|
54
|
+
VALUE_FLAGS.update({value: value for value in list(VALUE_FLAGS.values())})
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def arguments_only(arguments):
|
|
58
|
+
arguments = list(arguments)
|
|
59
|
+
return arguments[1:] if arguments[:1] == ["--"] else arguments
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def tokens(arguments):
|
|
63
|
+
"""Yield (canonical option, value, original tokens); stop parsing at --."""
|
|
5
64
|
arguments = list(arguments)
|
|
6
|
-
if arguments and arguments[0] == "--":
|
|
7
|
-
arguments.pop(0)
|
|
8
|
-
frontend, overrides = [], {}
|
|
9
65
|
i = 0
|
|
10
66
|
while i < len(arguments):
|
|
11
67
|
arg = arguments[i]
|
|
12
68
|
if arg == "--":
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
69
|
+
yield None, None, arguments[i:]
|
|
70
|
+
return
|
|
71
|
+
name, sep, value = arg.partition("=")
|
|
72
|
+
if name in VALUE_FLAGS:
|
|
73
|
+
if sep:
|
|
74
|
+
yield VALUE_FLAGS[name], value, [arg]
|
|
75
|
+
else:
|
|
76
|
+
if i + 1 == len(arguments):
|
|
77
|
+
raise ValueError(f"{arg} requires a value")
|
|
78
|
+
yield VALUE_FLAGS[name], arguments[i + 1], arguments[i : i + 2]
|
|
79
|
+
i += 1
|
|
80
|
+
elif len(arg) > 2 and arg[:2] in VALUE_FLAGS and not arg.startswith("--"):
|
|
81
|
+
yield VALUE_FLAGS[arg[:2]], arg[2:].removeprefix("="), [arg]
|
|
82
|
+
else:
|
|
83
|
+
yield arg if arg.startswith("-") else None, arg, [arg]
|
|
84
|
+
i += 1
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def working_directory(arguments, cwd):
|
|
88
|
+
target = Path(cwd)
|
|
89
|
+
for name, value, _ in tokens(arguments_only(arguments)):
|
|
90
|
+
if name == "--cd":
|
|
91
|
+
path = Path(value).expanduser()
|
|
92
|
+
target = path if path.is_absolute() else Path(cwd) / path
|
|
93
|
+
return str(target.resolve())
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def frontend_arguments(arguments, cwd):
|
|
97
|
+
"""Remote Codex needs an explicit cwd to offer its Cwd session filter."""
|
|
98
|
+
arguments = arguments_only(arguments)
|
|
99
|
+
target = working_directory(arguments, cwd)
|
|
100
|
+
result = ["-C", target]
|
|
101
|
+
for name, _, original in tokens(arguments):
|
|
102
|
+
if name != "--cd":
|
|
103
|
+
result.extend(original)
|
|
104
|
+
return result
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def translate(arguments):
|
|
108
|
+
frontend, overrides = [], {}
|
|
109
|
+
for name, value, original in tokens(arguments_only(arguments)):
|
|
110
|
+
if name in ("--yolo", "--dangerously-bypass-approvals-and-sandbox"):
|
|
16
111
|
overrides.update(approvalPolicy="never", sandbox="danger-full-access")
|
|
17
|
-
elif
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
):
|
|
25
|
-
if i + 1 == len(arguments):
|
|
26
|
-
raise ValueError(f"{arg} requires a value")
|
|
27
|
-
i += 1
|
|
28
|
-
key = {
|
|
29
|
-
"-s": "sandbox",
|
|
30
|
-
"--sandbox": "sandbox",
|
|
31
|
-
"-a": "approvalPolicy",
|
|
32
|
-
"--ask-for-approval": "approvalPolicy",
|
|
33
|
-
"-P": "permissions",
|
|
34
|
-
"--permission-profile": "permissions",
|
|
35
|
-
}[arg]
|
|
36
|
-
overrides[key] = arguments[i]
|
|
37
|
-
elif any(
|
|
38
|
-
arg.startswith(flag + "=")
|
|
39
|
-
for flag in ("--sandbox", "--ask-for-approval", "--permission-profile")
|
|
40
|
-
):
|
|
41
|
-
name, value = arg.split("=", 1)
|
|
112
|
+
elif name == "--approve-for-me":
|
|
113
|
+
overrides.update(
|
|
114
|
+
approvalPolicy="on-request",
|
|
115
|
+
sandbox="workspace-write",
|
|
116
|
+
approvalsReviewer="auto_review",
|
|
117
|
+
)
|
|
118
|
+
elif name in ("--sandbox", "--ask-for-approval", "--permission-profile"):
|
|
42
119
|
overrides[
|
|
43
120
|
{
|
|
44
121
|
"--sandbox": "sandbox",
|
|
@@ -46,9 +123,9 @@ def translate(arguments):
|
|
|
46
123
|
"--permission-profile": "permissions",
|
|
47
124
|
}[name]
|
|
48
125
|
] = value
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
126
|
+
elif name not in {"--no-daemon", "--profile", "--add-dir"}:
|
|
127
|
+
# Relay already gives each managed task its own private app server.
|
|
128
|
+
frontend.extend(original)
|
|
52
129
|
if "sandbox" in overrides and "permissions" in overrides:
|
|
53
130
|
raise ValueError(
|
|
54
131
|
"Choose either --sandbox/--yolo or a named permission profile."
|
|
@@ -72,46 +149,269 @@ def translate(arguments):
|
|
|
72
149
|
|
|
73
150
|
|
|
74
151
|
def backend_arguments(arguments):
|
|
75
|
-
"""
|
|
152
|
+
"""Mirror server config without reading prompts or other options' values."""
|
|
76
153
|
result = []
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
"
|
|
86
|
-
|
|
87
|
-
"-m",
|
|
88
|
-
"--model",
|
|
89
|
-
} and i + 1 < len(arguments):
|
|
90
|
-
result.extend([arg, arguments[i + 1]])
|
|
91
|
-
i += 2
|
|
92
|
-
continue
|
|
93
|
-
if arg.startswith(
|
|
94
|
-
("--config=", "--profile=", "--enable=", "--disable=", "--model=")
|
|
95
|
-
):
|
|
96
|
-
result.append(arg)
|
|
97
|
-
elif arg == "--search":
|
|
154
|
+
parsed = list(tokens(arguments_only(arguments)))
|
|
155
|
+
for name, value, _ in parsed:
|
|
156
|
+
if name == "--profile":
|
|
157
|
+
result.extend(profile_arguments(value))
|
|
158
|
+
for name, value, original in parsed:
|
|
159
|
+
if name in {"--config", "--enable", "--disable"}:
|
|
160
|
+
result.extend(original)
|
|
161
|
+
elif name == "--model":
|
|
162
|
+
result.extend(["-c", "model=" + json.dumps(value)])
|
|
163
|
+
elif name == "--search":
|
|
98
164
|
result.extend(["-c", 'web_search="live"'])
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
i = 0
|
|
103
|
-
while i < len(result):
|
|
104
|
-
if result[i] in {"-m", "--model"}:
|
|
105
|
-
import json
|
|
165
|
+
elif name == "--strict-config":
|
|
166
|
+
result.append(name)
|
|
167
|
+
return result
|
|
106
168
|
|
|
107
|
-
converted.extend(["-c", "model=" + json.dumps(result[i + 1])])
|
|
108
|
-
i += 2
|
|
109
|
-
elif result[i].startswith("--model="):
|
|
110
|
-
import json
|
|
111
169
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
170
|
+
NATIVE_COMMANDS = {
|
|
171
|
+
"agents",
|
|
172
|
+
"login",
|
|
173
|
+
"logout",
|
|
174
|
+
"mcp",
|
|
175
|
+
"plugin",
|
|
176
|
+
"app-server",
|
|
177
|
+
"remote-control",
|
|
178
|
+
"app",
|
|
179
|
+
"completion",
|
|
180
|
+
"update",
|
|
181
|
+
"doctor",
|
|
182
|
+
"sandbox",
|
|
183
|
+
"debug",
|
|
184
|
+
"apply",
|
|
185
|
+
"a",
|
|
186
|
+
"queue",
|
|
187
|
+
"archive",
|
|
188
|
+
"delete",
|
|
189
|
+
"migrate-rollouts",
|
|
190
|
+
"migrate-nfs-db",
|
|
191
|
+
"unarchive",
|
|
192
|
+
"cloud",
|
|
193
|
+
"exec-server",
|
|
194
|
+
"features",
|
|
195
|
+
"help",
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@functools.lru_cache(maxsize=8)
|
|
200
|
+
def native_commands(binary):
|
|
201
|
+
result = subprocess.run(
|
|
202
|
+
[binary, "--help"], capture_output=True, text=True, timeout=15
|
|
203
|
+
)
|
|
204
|
+
if result.returncode:
|
|
205
|
+
return NATIVE_COMMANDS
|
|
206
|
+
section = result.stdout.split("Commands:", 1)
|
|
207
|
+
commands = set(NATIVE_COMMANDS)
|
|
208
|
+
if len(section) > 1:
|
|
209
|
+
for line in section[1].splitlines():
|
|
210
|
+
if line and not line.startswith(" "):
|
|
211
|
+
break
|
|
212
|
+
match = re.match(r" ([a-z][a-z0-9-]*)\s", line)
|
|
213
|
+
if match:
|
|
214
|
+
commands.add(match.group(1))
|
|
215
|
+
return commands - {"exec", "e", "review", "resume", "fork"}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def native_mode(arguments, binary=None):
|
|
219
|
+
"""Modes outside the remote interactive protocol run native, unchanged."""
|
|
220
|
+
commands = native_commands(binary) if binary else NATIVE_COMMANDS
|
|
221
|
+
first_positional = True
|
|
222
|
+
initial_command = None
|
|
223
|
+
for name, value, original in tokens(arguments_only(arguments)):
|
|
224
|
+
if name in {
|
|
225
|
+
"--help",
|
|
226
|
+
"-h",
|
|
227
|
+
"--version",
|
|
228
|
+
"-V",
|
|
229
|
+
"--remote",
|
|
230
|
+
"--oss",
|
|
231
|
+
"--local-provider",
|
|
232
|
+
"--ignore-user-config",
|
|
233
|
+
"--ignore-rules",
|
|
234
|
+
"--dangerously-bypass-hook-trust",
|
|
235
|
+
}:
|
|
236
|
+
return True
|
|
237
|
+
if name is None and len(original) == 1 and first_positional:
|
|
238
|
+
first_positional = False
|
|
239
|
+
initial_command = value
|
|
240
|
+
if value in commands:
|
|
241
|
+
return True
|
|
242
|
+
elif name is None and value == "help" and initial_command in {"exec", "e"}:
|
|
243
|
+
return True
|
|
244
|
+
return False
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def toml_value(value):
|
|
248
|
+
if isinstance(value, bool):
|
|
249
|
+
return "true" if value else "false"
|
|
250
|
+
if isinstance(value, str):
|
|
251
|
+
return json.dumps(value)
|
|
252
|
+
if isinstance(value, (int, float)):
|
|
253
|
+
return str(value)
|
|
254
|
+
if isinstance(value, list):
|
|
255
|
+
return "[" + ", ".join(toml_value(v) for v in value) + "]"
|
|
256
|
+
if isinstance(value, dict):
|
|
257
|
+
return (
|
|
258
|
+
"{"
|
|
259
|
+
+ ", ".join(json.dumps(k) + "=" + toml_value(v) for k, v in value.items())
|
|
260
|
+
+ "}"
|
|
261
|
+
)
|
|
262
|
+
if hasattr(value, "isoformat"):
|
|
263
|
+
return value.isoformat()
|
|
264
|
+
raise ValueError("Unsupported profile value")
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def profile_arguments(name, home=None):
|
|
268
|
+
if not re.fullmatch(r"[A-Za-z0-9_-]+", name):
|
|
269
|
+
raise ValueError(
|
|
270
|
+
"Choose a native profile name containing letters, numbers, - or _"
|
|
271
|
+
)
|
|
272
|
+
home = Path(home or os.environ.get("CODEX_HOME", Path.home() / ".codex"))
|
|
273
|
+
path = home / (name + ".config.toml")
|
|
274
|
+
if not path.is_file():
|
|
275
|
+
raise ValueError("Native Codex profile does not exist: " + name)
|
|
276
|
+
data = tomllib.loads(path.read_text())
|
|
277
|
+
result = []
|
|
278
|
+
|
|
279
|
+
def flatten(value, keys):
|
|
280
|
+
if any(
|
|
281
|
+
re.search(r"token|secret|password|api_key|authorization|headers", key, re.I)
|
|
282
|
+
for key in keys
|
|
283
|
+
):
|
|
284
|
+
return
|
|
285
|
+
if isinstance(value, dict) and value:
|
|
286
|
+
if all(re.fullmatch(r"[A-Za-z0-9_-]+", key) for key in value):
|
|
287
|
+
for key, child in value.items():
|
|
288
|
+
flatten(child, [*keys, key])
|
|
289
|
+
return
|
|
290
|
+
if sensitive_config(value):
|
|
291
|
+
return
|
|
292
|
+
if isinstance(value, list) and sensitive_config(value):
|
|
293
|
+
return
|
|
294
|
+
result.extend(["-c", ".".join(keys) + "=" + toml_value(value)])
|
|
295
|
+
|
|
296
|
+
for key, value in data.items():
|
|
297
|
+
flatten(value, [key])
|
|
298
|
+
return result
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def additional_roots(arguments, cwd):
|
|
302
|
+
roots = []
|
|
303
|
+
for name, value, _ in tokens(arguments_only(arguments)):
|
|
304
|
+
if name == "--add-dir":
|
|
305
|
+
path = Path(value).expanduser()
|
|
306
|
+
roots.append(
|
|
307
|
+
str((path if path.is_absolute() else Path(cwd) / path).resolve())
|
|
308
|
+
)
|
|
309
|
+
return roots
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def prepare_worktree(arguments, state, cwd):
|
|
313
|
+
"""Create an isolated Git worktree once; never remove a user's working files."""
|
|
314
|
+
import subprocess
|
|
315
|
+
import uuid
|
|
316
|
+
|
|
317
|
+
parsed = list(tokens(arguments_only(arguments)))
|
|
318
|
+
if not any(name == "--worktree" for name, _, _ in parsed):
|
|
319
|
+
return list(arguments), cwd
|
|
320
|
+
positional = [
|
|
321
|
+
value for name, value, raw in parsed if name is None and len(raw) == 1
|
|
322
|
+
]
|
|
323
|
+
flags = {name for name, _, _ in parsed}
|
|
324
|
+
if (
|
|
325
|
+
"resume" in positional[:2]
|
|
326
|
+
or "review" in positional[:2]
|
|
327
|
+
or flags & {"--ephemeral", "--ignore-user-config"}
|
|
328
|
+
):
|
|
329
|
+
raise ValueError(
|
|
330
|
+
"--worktree cannot be combined with resume, review, --ephemeral, or --ignore-user-config"
|
|
331
|
+
)
|
|
332
|
+
if "fork" in positional[:2] and (
|
|
333
|
+
"--last" in flags or len(positional) < positional.index("fork") + 2
|
|
334
|
+
):
|
|
335
|
+
raise ValueError("A worktree fork requires an explicit session ID")
|
|
336
|
+
probe = subprocess.run(
|
|
337
|
+
["git", "-C", cwd, "rev-parse", "--show-toplevel"],
|
|
338
|
+
capture_output=True,
|
|
339
|
+
text=True,
|
|
340
|
+
)
|
|
341
|
+
if probe.returncode:
|
|
342
|
+
raise ValueError("--worktree requires a Git repository with a commit")
|
|
343
|
+
root = state / "worktrees"
|
|
344
|
+
root.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
345
|
+
target = root / uuid.uuid4().hex[:12]
|
|
346
|
+
create = subprocess.run(
|
|
347
|
+
["git", "-C", cwd, "worktree", "add", "--detach", str(target), "HEAD"],
|
|
348
|
+
capture_output=True,
|
|
349
|
+
text=True,
|
|
350
|
+
)
|
|
351
|
+
if create.returncode:
|
|
352
|
+
raise ValueError(
|
|
353
|
+
"Git could not create a worktree. Check the repository and available disk space."
|
|
354
|
+
)
|
|
355
|
+
result = []
|
|
356
|
+
for name, _, raw in parsed:
|
|
357
|
+
if name != "--worktree":
|
|
358
|
+
result.extend(raw)
|
|
359
|
+
result = frontend_arguments(result, cwd)
|
|
360
|
+
result[result.index("-C") + 1] = str(target.resolve())
|
|
361
|
+
return result, str(target.resolve())
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def sensitive_config(value):
|
|
365
|
+
if isinstance(value, dict):
|
|
366
|
+
return any(
|
|
367
|
+
re.search(r"token|secret|password|api_key|authorization|headers", key, re.I)
|
|
368
|
+
or sensitive_config(child)
|
|
369
|
+
for key, child in value.items()
|
|
370
|
+
)
|
|
371
|
+
if isinstance(value, list):
|
|
372
|
+
return any(sensitive_config(child) for child in value)
|
|
373
|
+
return False
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def profile_config(arguments, home=None):
|
|
377
|
+
"""Secret-bearing profile fields travel over private RPC, not process argv."""
|
|
378
|
+
home = Path(home or os.environ.get("CODEX_HOME", Path.home() / ".codex"))
|
|
379
|
+
result = {}
|
|
380
|
+
for name, value, _ in tokens(arguments_only(arguments)):
|
|
381
|
+
if name == "--profile":
|
|
382
|
+
# Validate names and existence through the same path as backend arguments.
|
|
383
|
+
profile_arguments(value, home)
|
|
384
|
+
data = tomllib.loads((home / (value + ".config.toml")).read_text())
|
|
385
|
+
|
|
386
|
+
def contains_sensitive(child, keys=()):
|
|
387
|
+
if any(
|
|
388
|
+
re.search(
|
|
389
|
+
r"token|secret|password|api_key|authorization|headers",
|
|
390
|
+
key,
|
|
391
|
+
re.I,
|
|
392
|
+
)
|
|
393
|
+
for key in keys
|
|
394
|
+
):
|
|
395
|
+
return True
|
|
396
|
+
if isinstance(child, dict):
|
|
397
|
+
return any(
|
|
398
|
+
contains_sensitive(v, (*keys, k)) for k, v in child.items()
|
|
399
|
+
)
|
|
400
|
+
if isinstance(child, list):
|
|
401
|
+
return any(contains_sensitive(v, keys) for v in child)
|
|
402
|
+
return False
|
|
403
|
+
|
|
404
|
+
for key, child in data.items():
|
|
405
|
+
if contains_sensitive(child, (key,)):
|
|
406
|
+
result[key] = child
|
|
407
|
+
# Explicit native overrides retain higher precedence than a profile layer.
|
|
408
|
+
for name, value, _ in tokens(arguments_only(arguments)):
|
|
409
|
+
if name == "--config" and "=" in value:
|
|
410
|
+
key, raw = value.split("=", 1)
|
|
411
|
+
if key.split(".")[0] in result:
|
|
412
|
+
try:
|
|
413
|
+
parsed = tomllib.loads("value=" + raw)["value"]
|
|
414
|
+
except tomllib.TOMLDecodeError:
|
|
415
|
+
parsed = raw
|
|
416
|
+
result[key] = parsed
|
|
417
|
+
return result
|
package/python/pool.py
CHANGED
|
@@ -433,6 +433,13 @@ class Pool:
|
|
|
433
433
|
(self.clock() - settings.load(self.state)["history_days"] * 86400,),
|
|
434
434
|
)
|
|
435
435
|
|
|
436
|
+
def checked_at(self, label):
|
|
437
|
+
with self.db() as db:
|
|
438
|
+
row = db.execute(
|
|
439
|
+
"SELECT checked FROM usage WHERE label=?", (label,)
|
|
440
|
+
).fetchone()
|
|
441
|
+
return row[0] if row else None
|
|
442
|
+
|
|
436
443
|
def cached(self, label, max_age=60, not_before=None):
|
|
437
444
|
with self.db() as db:
|
|
438
445
|
row = db.execute(
|