claude-dev-env 8.37.3 → 8.38.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/docs/account-broker.md +32 -0
- package/package.json +1 -1
- package/scripts/account_broker.py +387 -0
- package/scripts/account_broker_support.py +377 -0
- package/scripts/dev_env_scripts_constants/account_broker_constants.py +301 -0
- package/scripts/test_account_broker.py +501 -0
- package/scripts/test_account_broker_guard.py +109 -0
- package/scripts/test_account_broker_support.py +107 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Account broker
|
|
2
|
+
|
|
3
|
+
`account_broker.py` reads the Claude and Codex account rosters, checks each account's session and weekly usage, and chooses an account with room. It does not select a model or sleep. A Codex decision names the `normal` or `luna` tier for the caller.
|
|
4
|
+
|
|
5
|
+
Claude's main home is `~/.claude`. Its `claude-chain.json` file contains a `chain` list; each entry's `credentials_path` field identifies the credentials file whose parent is that account's home folder. The optional `extra-profiles.json` file in the main home lists additional profile names. Codex uses its configured account roster. When no Codex roster is configured, the broker chooses the default Codex home.
|
|
6
|
+
|
|
7
|
+
## Commands
|
|
8
|
+
|
|
9
|
+
```text
|
|
10
|
+
python scripts/account_broker.py accounts --product claude
|
|
11
|
+
python scripts/account_broker.py choose --product codex
|
|
12
|
+
python scripts/account_broker.py choose --product codex --spent account-name:1790000000
|
|
13
|
+
python scripts/account_broker.py check --product codex
|
|
14
|
+
python scripts/account_broker.py run --product claude --report report.json -- claude --output-format json -p "prompt"
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
`accounts` prints the roster without reading meters. `choose` prints `decision`, `accounts`, and `state_path`. Each decision contains `action`, `account`, `home`, `reason`, `tier`, and `resets_at`. A wait decision has no account or home and includes the soonest reset in UTC. A failed meter read excludes the account for that choice. `--spent` saves an exclusion until the supplied Unix reset, the account's soonest known meter reset, or one hour when neither is available. Meter reads younger than 60 seconds are reused.
|
|
18
|
+
|
|
19
|
+
`check` prints nothing and exits 3 while every account is below its floor. `run` sets only `CLAUDE_CONFIG_DIR` or `CODEX_HOME` for each attempt. It replays the same stdin bytes when a usage limit or start failure leads to another account. It writes the command's stdout to stdout and diagnostics to stderr. A resumed Claude session uses the account bound to its session when that account has room.
|
|
20
|
+
|
|
21
|
+
The broker stores cached meters, spent marks, and Claude session bindings in one JSON file under `~/.claude/account-broker`. State writes use a sibling temporary file and `os.replace`.
|
|
22
|
+
|
|
23
|
+
## Exit codes and report
|
|
24
|
+
|
|
25
|
+
| Code | Meaning |
|
|
26
|
+
| --- | --- |
|
|
27
|
+
| `0` | `choose` or `check` found an account, or the job exited successfully. |
|
|
28
|
+
| `3` | No account clears its floor. The decision includes `resets_at`. |
|
|
29
|
+
| `4` | A Claude job stopped with `advisor_blocked`. |
|
|
30
|
+
| Other command code | `run` returns the command's exit code after a served response. |
|
|
31
|
+
|
|
32
|
+
`run` writes a JSON report with `product`, `command`, `events`, and `final_decision`. A wait report has `final_decision.action` equal to `wait` and a UTC `final_decision.resets_at`.
|
package/package.json
CHANGED
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Choose and run Claude or Codex jobs through one account roster."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import datetime, timedelta, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Mapping, Sequence
|
|
15
|
+
|
|
16
|
+
import account_broker_support as support
|
|
17
|
+
from account_broker_support import (
|
|
18
|
+
all_product_adapters,
|
|
19
|
+
Account,
|
|
20
|
+
BrokerConfigurationError,
|
|
21
|
+
Decision,
|
|
22
|
+
JobOutcome,
|
|
23
|
+
Meters,
|
|
24
|
+
Product,
|
|
25
|
+
ProductAdapter,
|
|
26
|
+
Reading,
|
|
27
|
+
Report,
|
|
28
|
+
_load_state,
|
|
29
|
+
_meter_payload,
|
|
30
|
+
_resume_id,
|
|
31
|
+
_save_state,
|
|
32
|
+
_state_key,
|
|
33
|
+
broker_state_path,
|
|
34
|
+
extract_session_id_from_stdout,
|
|
35
|
+
load_claude_accounts,
|
|
36
|
+
load_codex_accounts,
|
|
37
|
+
override_subprocess_runner,
|
|
38
|
+
read_accounts,
|
|
39
|
+
read_claude_meters,
|
|
40
|
+
read_codex_account_meters,
|
|
41
|
+
)
|
|
42
|
+
from dev_env_scripts_constants.account_broker_constants import (
|
|
43
|
+
_account_reset,
|
|
44
|
+
_choose_claude,
|
|
45
|
+
_choose_codex,
|
|
46
|
+
_wait_decision,
|
|
47
|
+
COMMAND_MISSING_EXIT_CODE,
|
|
48
|
+
REPORT_INDENT_SPACES,
|
|
49
|
+
WAIT_EXIT_CODE,
|
|
50
|
+
utc_time_text as _time_text,
|
|
51
|
+
)
|
|
52
|
+
from dev_env_scripts_constants.codex_account_constants import (
|
|
53
|
+
CODEX_HOME_ENVIRONMENT_VARIABLE,
|
|
54
|
+
MAIN_CODEX_HOME_DIRECTORY_NAME,
|
|
55
|
+
TIER_NORMAL,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def choose_from_readings(
|
|
60
|
+
product: Product,
|
|
61
|
+
all_readings: Sequence[Reading],
|
|
62
|
+
*,
|
|
63
|
+
now: datetime,
|
|
64
|
+
all_spent_accounts: frozenset[Account] = frozenset(),
|
|
65
|
+
preferred_command: str | None = None,
|
|
66
|
+
adapter: ProductAdapter | None = None,
|
|
67
|
+
all_spent_resets: Mapping[Account, datetime] | None = None,
|
|
68
|
+
) -> Decision:
|
|
69
|
+
"""Choose an account or a wait.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
product: Account product.
|
|
73
|
+
all_readings: Roster and meter readings.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
A run or wait decision.
|
|
77
|
+
"""
|
|
78
|
+
if product is Product.CODEX and not all_readings:
|
|
79
|
+
home = Path(os.environ.get(CODEX_HOME_ENVIRONMENT_VARIABLE) or Path.home() / MAIN_CODEX_HOME_DIRECTORY_NAME).resolve()
|
|
80
|
+
return Decision("run", Account(product, "default", home, True), None, "no roster is configured", TIER_NORMAL)
|
|
81
|
+
active = adapter or all_product_adapters[product]
|
|
82
|
+
all_available = [each_reading for each_reading in all_readings if each_reading.account not in all_spent_accounts]
|
|
83
|
+
selected = (
|
|
84
|
+
_choose_claude(all_available, now, preferred_command, active.main_guard)
|
|
85
|
+
if product is Product.CLAUDE
|
|
86
|
+
else _choose_codex(all_available)
|
|
87
|
+
)
|
|
88
|
+
return selected or _wait_decision(all_readings, now, all_spent_accounts, all_spent_resets or {})
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def decision_payload(decision: Decision) -> dict[str, object]:
|
|
92
|
+
"""Serialize a choice.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
decision: Account choice.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
JSON-compatible decision fields.
|
|
99
|
+
"""
|
|
100
|
+
return {
|
|
101
|
+
"action": decision.action,
|
|
102
|
+
"account": decision.account.name if decision.account else None,
|
|
103
|
+
"home": str(decision.account.home) if decision.account else None,
|
|
104
|
+
"reason": decision.reason,
|
|
105
|
+
"tier": decision.tier,
|
|
106
|
+
"resets_at": _time_text(decision.resets_at),
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def readings_payload(all_readings: Sequence[Reading]) -> list[dict[str, object]]:
|
|
111
|
+
"""Serialize account readings.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
all_readings: Roster and meters.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
JSON-compatible account entries.
|
|
118
|
+
"""
|
|
119
|
+
return [
|
|
120
|
+
{
|
|
121
|
+
"name": each_reading.account.name,
|
|
122
|
+
"home": str(each_reading.account.home),
|
|
123
|
+
"is_main": each_reading.account.is_main,
|
|
124
|
+
"meters": _meter_payload(each_reading.meters),
|
|
125
|
+
}
|
|
126
|
+
for each_reading in all_readings
|
|
127
|
+
]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _spent_for_readings(all_readings: Sequence[Reading], all_state: dict[str, object], now: datetime) -> tuple[frozenset[Account], dict[Account, datetime]]:
|
|
132
|
+
marks = all_state["spent"]
|
|
133
|
+
spent = {}
|
|
134
|
+
for each_reading in all_readings:
|
|
135
|
+
raw = marks.get(_state_key(each_reading.account))
|
|
136
|
+
if isinstance(raw, (int, float)) and raw > now.timestamp():
|
|
137
|
+
spent[each_reading.account] = datetime.fromtimestamp(raw, timezone.utc)
|
|
138
|
+
return frozenset(spent), spent
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _mark_spent(all_state: dict[str, object], reading: Reading, now: datetime, reset: datetime | None = None) -> datetime:
|
|
142
|
+
until = reset or _account_reset(reading, is_spent=True) or now + timedelta(hours=1)
|
|
143
|
+
if until <= now:
|
|
144
|
+
until = now + timedelta(hours=1)
|
|
145
|
+
all_state["spent"][_state_key(reading.account)] = until.timestamp()
|
|
146
|
+
_save_state(broker_state_path(), all_state)
|
|
147
|
+
return until
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _write_report(path: Path, report: Report) -> None:
|
|
151
|
+
payload = {
|
|
152
|
+
"product": report.product.value,
|
|
153
|
+
"command": report.command,
|
|
154
|
+
"events": report.events,
|
|
155
|
+
"final_decision": decision_payload(report.final_decision) if report.final_decision else None,
|
|
156
|
+
}
|
|
157
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
158
|
+
path.write_text(json.dumps(payload, indent=REPORT_INDENT_SPACES) + "\n", encoding="utf-8", newline="\n")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@dataclass
|
|
162
|
+
class _RunContext:
|
|
163
|
+
product: Product
|
|
164
|
+
all_argv: list[str]
|
|
165
|
+
active: ProductAdapter
|
|
166
|
+
now: datetime
|
|
167
|
+
all_state: dict[str, object]
|
|
168
|
+
all_readings: tuple[Reading, ...]
|
|
169
|
+
report: Report
|
|
170
|
+
stdin_bytes: bytes | None
|
|
171
|
+
timeout_seconds: float | None
|
|
172
|
+
cwd: str | Path | None
|
|
173
|
+
encoding: str
|
|
174
|
+
errors: str
|
|
175
|
+
preferred_command: str | None
|
|
176
|
+
all_attempts: list[tuple[str, str]]
|
|
177
|
+
all_spent_accounts: set[Account]
|
|
178
|
+
all_spent_resets: dict[Account, datetime]
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _prepare_run(
|
|
182
|
+
product: Product, all_argv: Sequence[str], now: datetime, timeout_seconds: float | None,
|
|
183
|
+
stdin_text: str | bytes | None, cwd: str | Path | None, encoding: str, errors: str
|
|
184
|
+
) -> _RunContext:
|
|
185
|
+
all_state = _load_state(broker_state_path())
|
|
186
|
+
active = all_product_adapters[product]
|
|
187
|
+
all_readings = read_accounts(product, active, all_state=all_state, now=now)
|
|
188
|
+
session_id = _resume_id(all_argv) if product is Product.CLAUDE else None
|
|
189
|
+
preferred = all_state["affinity"].get(session_id) if session_id else None
|
|
190
|
+
spent, all_spent_resets = _spent_for_readings(all_readings, all_state, now)
|
|
191
|
+
stdin_bytes = stdin_text.encode(encoding, errors) if isinstance(stdin_text, str) else stdin_text
|
|
192
|
+
return _RunContext(
|
|
193
|
+
product, list(all_argv), active, now, all_state, all_readings, Report(product, list(all_argv)),
|
|
194
|
+
stdin_bytes, timeout_seconds, cwd, encoding, errors, preferred, [], set(spent), all_spent_resets
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _wait_outcome(context: _RunContext, decision: Decision) -> JobOutcome:
|
|
199
|
+
context.report.final_decision = decision
|
|
200
|
+
status = "exhausted" if context.all_attempts else "wait"
|
|
201
|
+
return JobOutcome(WAIT_EXIT_CODE, "", "", None, tuple(context.all_attempts), status, None, decision.resets_at)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _record_spent_attempt(context: _RunContext, account: Account, status: str, returncode: int | None) -> None:
|
|
205
|
+
context.all_attempts.append((account.name, status))
|
|
206
|
+
context.report.events.append({"type": "attempt", "account": account.name, "status": status, "exit_code": returncode})
|
|
207
|
+
if context.all_readings:
|
|
208
|
+
reading = next(each_reading for each_reading in context.all_readings if each_reading.account == account)
|
|
209
|
+
context.all_spent_accounts.add(account)
|
|
210
|
+
if status == "usage_limited":
|
|
211
|
+
context.all_spent_resets[account] = _mark_spent(context.all_state, reading, context.now)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _invoke(context: _RunContext, account: Account) -> subprocess.CompletedProcess[str]:
|
|
215
|
+
environment = {**os.environ, context.active.environment_variable: str(account.home)}
|
|
216
|
+
return support.subprocess_runner(
|
|
217
|
+
context.all_argv,
|
|
218
|
+
env=environment,
|
|
219
|
+
input=context.stdin_bytes,
|
|
220
|
+
timeout=context.timeout_seconds,
|
|
221
|
+
cwd=str(context.cwd) if context.cwd is not None else None,
|
|
222
|
+
encoding=context.encoding,
|
|
223
|
+
errors=context.errors,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _finish_attempt(context: _RunContext, decision: Decision, completion: subprocess.CompletedProcess[str]) -> JobOutcome:
|
|
228
|
+
account = decision.account
|
|
229
|
+
status = "advisor_blocked" if context.product is Product.CLAUDE and completion.returncode != 0 else "served"
|
|
230
|
+
context.all_attempts.append((account.name, status))
|
|
231
|
+
context.report.events.append({"type": "attempt", "account": account.name, "status": status, "exit_code": completion.returncode})
|
|
232
|
+
context.report.final_decision = decision
|
|
233
|
+
session_id = extract_session_id_from_stdout(completion.stdout) if completion.returncode == 0 else None
|
|
234
|
+
if context.product is Product.CLAUDE and session_id:
|
|
235
|
+
context.all_state["affinity"][session_id] = account.command or account.name
|
|
236
|
+
_save_state(broker_state_path(), context.all_state)
|
|
237
|
+
return JobOutcome(completion.returncode, completion.stdout, completion.stderr, account.name, tuple(context.all_attempts), status, session_id, None)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _attempt_once(context: _RunContext, decision: Decision) -> JobOutcome | None:
|
|
241
|
+
account = decision.account
|
|
242
|
+
try:
|
|
243
|
+
completion = _invoke(context, account)
|
|
244
|
+
except (OSError, subprocess.TimeoutExpired) as error:
|
|
245
|
+
status = "timeout" if isinstance(error, subprocess.TimeoutExpired) else "start_failed"
|
|
246
|
+
_record_spent_attempt(context, account, status, COMMAND_MISSING_EXIT_CODE)
|
|
247
|
+
if not context.all_readings:
|
|
248
|
+
context.report.final_decision = decision
|
|
249
|
+
return JobOutcome(COMMAND_MISSING_EXIT_CODE, "", str(error), account.name, tuple(context.all_attempts), "advisor_blocked", None, None)
|
|
250
|
+
return None
|
|
251
|
+
combined = f"{completion.stdout}{completion.stderr}".casefold()
|
|
252
|
+
is_limited = completion.returncode != 0 and any(
|
|
253
|
+
signature.casefold() in combined for signature in context.active.usage_limit_signatures
|
|
254
|
+
)
|
|
255
|
+
if is_limited:
|
|
256
|
+
_record_spent_attempt(context, account, "usage_limited", completion.returncode)
|
|
257
|
+
if not context.all_readings:
|
|
258
|
+
waiting = Decision("wait", None, context.now + timedelta(hours=1), "no account has room", "wait")
|
|
259
|
+
return _wait_outcome(context, waiting)
|
|
260
|
+
return None
|
|
261
|
+
return _finish_attempt(context, decision, completion)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _execute(
|
|
265
|
+
product: Product, all_argv: Sequence[str], *, now: datetime,
|
|
266
|
+
timeout_seconds: float | None = None, stdin_text: str | bytes | None = None,
|
|
267
|
+
cwd: str | Path | None = None, encoding: str = "utf-8", errors: str = "replace"
|
|
268
|
+
) -> tuple[JobOutcome, Report]:
|
|
269
|
+
context = _prepare_run(product, all_argv, now, timeout_seconds, stdin_text, cwd, encoding, errors)
|
|
270
|
+
while True:
|
|
271
|
+
decision = choose_from_readings(
|
|
272
|
+
product, context.all_readings, now=now,
|
|
273
|
+
all_spent_accounts=frozenset(context.all_spent_accounts),
|
|
274
|
+
preferred_command=context.preferred_command, adapter=context.active,
|
|
275
|
+
all_spent_resets=context.all_spent_resets
|
|
276
|
+
)
|
|
277
|
+
context.report.events.append({"type": "pick", "decision": decision_payload(decision)})
|
|
278
|
+
if decision.account is None:
|
|
279
|
+
return _wait_outcome(context, decision), context.report
|
|
280
|
+
outcome = _attempt_once(context, decision)
|
|
281
|
+
if outcome is not None:
|
|
282
|
+
return outcome, context.report
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def run_job(
|
|
286
|
+
product: Product,
|
|
287
|
+
all_argv: Sequence[str],
|
|
288
|
+
*,
|
|
289
|
+
timeout_seconds: float | None = None,
|
|
290
|
+
stdin_text: str | bytes | None = None,
|
|
291
|
+
cwd: str | Path | None = None,
|
|
292
|
+
encoding: str = "utf-8",
|
|
293
|
+
errors: str = "replace",
|
|
294
|
+
) -> JobOutcome:
|
|
295
|
+
return _execute(product, all_argv, now=datetime.now(timezone.utc), timeout_seconds=timeout_seconds, stdin_text=stdin_text, cwd=cwd, encoding=encoding, errors=errors)[0]
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _parser() -> argparse.ArgumentParser:
|
|
299
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
300
|
+
commands = parser.add_subparsers(dest="action", required=True)
|
|
301
|
+
for each_action in ("choose", "check", "accounts", "run"):
|
|
302
|
+
command = commands.add_parser(each_action)
|
|
303
|
+
command.add_argument("--product", choices=[each_product.value for each_product in Product], required=True)
|
|
304
|
+
if each_action == "choose":
|
|
305
|
+
command.add_argument("--spent", action="append", default=[])
|
|
306
|
+
if each_action == "run":
|
|
307
|
+
command.add_argument("--report", type=Path, required=True)
|
|
308
|
+
command.add_argument("command", nargs=argparse.REMAINDER)
|
|
309
|
+
return parser
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _parse_spent_mark(all_accounts_by_name: Mapping[str, Reading], mark: str) -> tuple[Reading, datetime | None]:
|
|
313
|
+
name, separator, reset_text = mark.partition(":")
|
|
314
|
+
if name not in all_accounts_by_name:
|
|
315
|
+
raise BrokerConfigurationError(f"unknown account {name}")
|
|
316
|
+
try:
|
|
317
|
+
reset = datetime.fromtimestamp(float(reset_text), timezone.utc) if separator else None
|
|
318
|
+
except ValueError as error:
|
|
319
|
+
raise BrokerConfigurationError(f"invalid reset for account {name}") from error
|
|
320
|
+
return all_accounts_by_name[name], reset
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _save_spent_arguments(all_marks: Sequence[str], all_readings: Sequence[Reading], all_state: dict[str, object], now: datetime) -> None:
|
|
324
|
+
all_accounts_by_name = {each_reading.account.name: each_reading for each_reading in all_readings}
|
|
325
|
+
for each_mark in all_marks:
|
|
326
|
+
reading, reset = _parse_spent_mark(all_accounts_by_name, each_mark)
|
|
327
|
+
_mark_spent(all_state, reading, now, reset)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _accounts_cli(product: Product) -> int:
|
|
331
|
+
roster = all_product_adapters[product].load_accounts()
|
|
332
|
+
print(json.dumps({"accounts": [{"name": each_account.name, "home": str(each_account.home), "is_main": each_account.is_main} for each_account in roster]}))
|
|
333
|
+
return 0
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _run_cli(product: Product, parsed: argparse.Namespace, parser: argparse.ArgumentParser) -> int:
|
|
337
|
+
command = list(parsed.command)
|
|
338
|
+
if command and command[0] == "--":
|
|
339
|
+
command.pop(0)
|
|
340
|
+
if not command:
|
|
341
|
+
parser.error("run needs a command after --")
|
|
342
|
+
stdin_bytes = sys.stdin.buffer.read()
|
|
343
|
+
outcome, report = _execute(product, command, stdin_text=stdin_bytes, now=datetime.now(timezone.utc))
|
|
344
|
+
_write_report(parsed.report, report)
|
|
345
|
+
sys.stdout.write(outcome.stdout)
|
|
346
|
+
sys.stderr.write(outcome.stderr)
|
|
347
|
+
return 4 if outcome.status == "advisor_blocked" else outcome.returncode
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _choose_cli(product: Product, parsed: argparse.Namespace) -> int:
|
|
351
|
+
now = datetime.now(timezone.utc)
|
|
352
|
+
all_state = _load_state(broker_state_path())
|
|
353
|
+
all_readings = read_accounts(product, all_state=all_state, now=now)
|
|
354
|
+
if parsed.action == "choose" and parsed.spent:
|
|
355
|
+
_save_spent_arguments(parsed.spent, all_readings, all_state, now)
|
|
356
|
+
spent, resets = _spent_for_readings(all_readings, all_state, now)
|
|
357
|
+
decision = choose_from_readings(product, all_readings, now=now, all_spent_accounts=spent, all_spent_resets=resets)
|
|
358
|
+
if parsed.action == "choose":
|
|
359
|
+
print(json.dumps({"decision": decision_payload(decision), "accounts": readings_payload(all_readings), "state_path": str(broker_state_path())}))
|
|
360
|
+
return WAIT_EXIT_CODE if decision.action == "wait" else 0
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def main(all_arguments: Sequence[str]) -> int:
|
|
364
|
+
"""Execute a broker command.
|
|
365
|
+
|
|
366
|
+
Args:
|
|
367
|
+
all_arguments: Command-line tokens.
|
|
368
|
+
|
|
369
|
+
Returns:
|
|
370
|
+
Process exit code.
|
|
371
|
+
"""
|
|
372
|
+
parser = _parser()
|
|
373
|
+
parsed = parser.parse_args(all_arguments)
|
|
374
|
+
product = Product(parsed.product)
|
|
375
|
+
try:
|
|
376
|
+
if parsed.action == "accounts":
|
|
377
|
+
return _accounts_cli(product)
|
|
378
|
+
if parsed.action == "run":
|
|
379
|
+
return _run_cli(product, parsed, parser)
|
|
380
|
+
return _choose_cli(product, parsed)
|
|
381
|
+
except BrokerConfigurationError as error:
|
|
382
|
+
print(str(error), file=sys.stderr)
|
|
383
|
+
return 2
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
if __name__ == "__main__":
|
|
387
|
+
raise SystemExit(main(sys.argv[1:]))
|