Compare commits
42
Commits
main
..
1ecfa01374
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ecfa01374 | ||
|
|
ad571e1a2a | ||
|
|
db7e4e308c | ||
|
|
645d000f8d | ||
|
|
51c27e76de | ||
|
|
3b2f5057da | ||
|
|
f161d6a46d | ||
|
|
f7844a5411 | ||
|
|
43d0d43f9d | ||
|
|
5bd4c26ffc | ||
|
|
444f145cf3 | ||
|
|
3a77a12447 | ||
|
|
58e343aae0 | ||
|
|
fe26760b84 | ||
|
|
c3d5c944ea | ||
|
|
56f8f2a1e1 | ||
|
|
505bf8d016 | ||
|
|
4765f9c7cf | ||
|
|
ec55994fc7 | ||
|
|
05fcfb6447 | ||
|
|
b1f3fc93ed | ||
|
|
6f8711c1a8 | ||
|
|
ab6812f3ee | ||
|
|
96a7820026 | ||
|
|
4415e890c9 | ||
|
|
02723c21a7 | ||
|
|
40231049eb | ||
|
|
9dfb8ba201 | ||
|
|
80806d18d8 | ||
|
|
c830ca73e6 | ||
|
|
c6bf30b5af | ||
|
|
566fb5e895 | ||
|
|
6ff0da31a3 | ||
|
|
7af833a4d7 | ||
|
|
03ed732f7a | ||
|
|
03774c54e3 | ||
|
|
71af41f35f | ||
|
|
ef5adf231a | ||
|
|
62b8b1c14b | ||
|
|
91c638186b | ||
|
|
95fc8a4578 | ||
|
|
a937500d9b |
No files matched your search
+4
-32
@@ -6,7 +6,7 @@
|
|||||||
### Compose ###################################################################
|
### Compose ###################################################################
|
||||||
|
|
||||||
# Какие сервисы поднимать: db (postgres), gateway, obsidian (obsidian-headless),
|
# Какие сервисы поднимать: db (postgres), gateway, obsidian (obsidian-headless),
|
||||||
# t3 (t3code-mcp - руки диспетчера на маке и dell-dev), vibegram (клон репы комнаты)
|
# t3 (t3code-mcp - руки диспетчера на маке и dell), vibegram (клон репы комнаты)
|
||||||
COMPOSE_PROFILES=db,gateway,obsidian,t3,vibegram
|
COMPOSE_PROFILES=db,gateway,obsidian,t3,vibegram
|
||||||
|
|
||||||
# Ветка/тег beaver-gateway, из которого собирается образ шлюза
|
# Ветка/тег beaver-gateway, из которого собирается образ шлюза
|
||||||
@@ -31,11 +31,6 @@ ADMIN_PASS=
|
|||||||
# Подпись session-cookie admin UI
|
# Подпись session-cookie admin UI
|
||||||
SESSION_SECRET=
|
SESSION_SECRET=
|
||||||
|
|
||||||
# Мастер-ключ хранилища секретов (admin → System → Secrets): openssl rand -hex 32.
|
|
||||||
# Шифрует таблицу secrets в postgres, поэтому дамп базы без него закрыт.
|
|
||||||
# В env модели не попадает. Потерян или сменён - секреты задаются заново.
|
|
||||||
SECRETS_KEY=
|
|
||||||
|
|
||||||
### Claude ####################################################################
|
### Claude ####################################################################
|
||||||
|
|
||||||
# OAuth-токен подписки для Claude Agent SDK внутри контейнера.
|
# OAuth-токен подписки для Claude Agent SDK внутри контейнера.
|
||||||
@@ -59,15 +54,6 @@ RAYCAST_DEVICE_ID=
|
|||||||
# rca_... из десктоп-приложения Raycast (см. raycast-api)
|
# rca_... из десктоп-приложения Raycast (см. raycast-api)
|
||||||
RAYCAST_BEARER=
|
RAYCAST_BEARER=
|
||||||
|
|
||||||
### Отдача файлов человеку (send_file) ########################################
|
|
||||||
|
|
||||||
# Откуда тулза send_file имеет право брать файлы (несколько - через ':').
|
|
||||||
# Папка входящих вложений разрешена всегда, её дописывает сам фронтенд.
|
|
||||||
# Каталог должен быть виден контейнеру gateway по ТОМУ ЖЕ пути, что снаружи
|
|
||||||
# (см. монтирование /mnt/hole/shared в docker-compose.yml).
|
|
||||||
# Пусто = /mnt/hole/shared, дефолт шлюза
|
|
||||||
SEND_FILE_ROOTS=/mnt/hole/shared
|
|
||||||
|
|
||||||
### MCP, к которым ходят агенты ###############################################
|
### MCP, к которым ходят агенты ###############################################
|
||||||
|
|
||||||
# Firefly III: адрес и Personal Access Token
|
# Firefly III: адрес и Personal Access Token
|
||||||
@@ -76,9 +62,6 @@ FIREFLY_PAT=
|
|||||||
# Календари: name=url,name=url (пусто - без календарей).
|
# Календари: name=url,name=url (пусто - без календарей).
|
||||||
# В url лежит приватный ical-фид (google `/private-<токен>/`, usos `key=`) -
|
# В url лежит приватный ical-фид (google `/private-<токен>/`, usos `key=`) -
|
||||||
# это секрет: утёк url = утёк календарь, лечится перевыпуском фида.
|
# это секрет: утёк url = утёк календарь, лечится перевыпуском фида.
|
||||||
# Модели календари даются MCP-руками, а справка конверта раз в день читает
|
|
||||||
# тот же ical напрямую - урл фида она берёт из параметра `icsUrl` этих же
|
|
||||||
# записей; записи без `icsUrl` остаются рукой, но в справку не попадают.
|
|
||||||
CALENDAR_MCPS=
|
CALENDAR_MCPS=
|
||||||
# MCP beavergram (telegram-history)
|
# MCP beavergram (telegram-history)
|
||||||
BEAVERGRAM_MCP=
|
BEAVERGRAM_MCP=
|
||||||
@@ -90,9 +73,9 @@ MSOS_MCP=
|
|||||||
# t3code-mcp - сервис compose (профиль t3), машины и allowlist - ./t3code.toml.
|
# t3code-mcp - сервис compose (профиль t3), машины и allowlist - ./t3code.toml.
|
||||||
# На каждой машине: `t3 auth session issue --token-only --ttl 365d --label beaver`
|
# На каждой машине: `t3 auth session issue --token-only --ttl 365d --label beaver`
|
||||||
T3_MAC_TOKEN=
|
T3_MAC_TOKEN=
|
||||||
T3_DELL_DEV_TOKEN=
|
T3_DELL_TOKEN=
|
||||||
# tailnet-адрес dell-dev (дев-VM на делле), прописывается контейнеру в hosts
|
# tailnet-адрес самого dell: контейнер t3code-mcp иначе резолвит `dell` в 127.0.1.1
|
||||||
T3_DELL_DEV_ADDR=
|
T3_DELL_ADDR=
|
||||||
# Токен gateway со scope `api` (admin → Tokens) - им t3code-mcp шлёт события в /hooks/t3code
|
# Токен gateway со scope `api` (admin → Tokens) - им t3code-mcp шлёт события в /hooks/t3code
|
||||||
T3CODE_MCP_GATEWAY_TOKEN=
|
T3CODE_MCP_GATEWAY_TOKEN=
|
||||||
# Ветка/тег t3code-mcp, из которого собирается образ
|
# Ветка/тег t3code-mcp, из которого собирается образ
|
||||||
@@ -128,17 +111,6 @@ VIBEGRAM_REPO=
|
|||||||
# Токен GitHub с доступом к репе (приватная); пусто - публичная
|
# Токен GitHub с доступом к репе (приватная); пусто - публичная
|
||||||
VIBEGRAM_REPO_TOKEN=
|
VIBEGRAM_REPO_TOKEN=
|
||||||
|
|
||||||
VIBEGRAM_HB_TOKEN=
|
|
||||||
VIBEGRAM_HB_NICK=
|
|
||||||
|
|
||||||
### Досуг (ежечасный тик мастера, с S13) ######################################
|
|
||||||
|
|
||||||
# Час активного окна (12:00-03:59) и четыре часа тихого (04:00-11:59) будят
|
|
||||||
# мастера инжектом без задания. Повседневный выключатель - строка `досуг: выкл`
|
|
||||||
# в `мета/бобер/досуг.md`; здесь - рубильник: `off` снимает джоб совсем.
|
|
||||||
# Пусто = включено.
|
|
||||||
LEISURE=
|
|
||||||
|
|
||||||
### Порты хоста и внешний адрес ###############################################
|
### Порты хоста и внешний адрес ###############################################
|
||||||
|
|
||||||
PORT_GATEWAY=62990
|
PORT_GATEWAY=62990
|
||||||
|
|||||||
@@ -71,12 +71,6 @@ read-write (it is the dispatcher's home), and `beaver_agent/policy.py` keeps
|
|||||||
it out of `.obsidian`, `мета` outside `мета/бобер`, and from deleting
|
it out of `.obsidian`, `мета` outside `мета/бобер`, and from deleting
|
||||||
anything but its own files.
|
anything but its own files.
|
||||||
|
|
||||||
Keys the agent needs for its own scripts (an RPC key, a service account
|
|
||||||
file) go into **System → Secrets** in the admin, not into files in the
|
|
||||||
container: they are sealed with `SECRETS_KEY` in Postgres, never shown back,
|
|
||||||
and reach a command only through `secret run NAME -- cmd` (see the `secrets`
|
|
||||||
skill under `мета/бобер/скиллы/общие/`).
|
|
||||||
|
|
||||||
### 4. Mint a token
|
### 4. Mint a token
|
||||||
|
|
||||||
Open admin at `http://localhost:62990/` (or `https://<DOMAIN>/` if Caddy), sign in with `ADMIN_USER` / `ADMIN_PASS`, go to **Tokens → Create**, scope `*` for first run.
|
Open admin at `http://localhost:62990/` (or `https://<DOMAIN>/` if Caddy), sign in with `ADMIN_USER` / `ADMIN_PASS`, go to **Tokens → Create**, scope `*` for first run.
|
||||||
@@ -169,209 +163,6 @@ pieces live in `beaver_agent/`, one concern per module:
|
|||||||
and seeds, the vault watcher, the curator briefing
|
and seeds, the vault watcher, the curator briefing
|
||||||
- `jobs/` - crons and webhooks, one file per job
|
- `jobs/` - crons and webhooks, one file per job
|
||||||
|
|
||||||
## Leisure: the hourly tick (`beaver_agent/jobs/leisure.py`)
|
|
||||||
|
|
||||||
The master gets an inject with no task in it every hour: what time it is,
|
|
||||||
what kind of hour this is, and what is left of the day's plan. The active
|
|
||||||
window (12:00-03:59) ticks every hour; the quiet one (04:00-11:59, the hours
|
|
||||||
the owner has never once written in) ticks only in the four hours the plan
|
|
||||||
names - twenty ticks a day.
|
|
||||||
|
|
||||||
The day runs noon to noon and the first tick of it is the planning one: the
|
|
||||||
model lays the remaining hours out into *mine* (leisure only, the owner's
|
|
||||||
business stays out) and *mixed*, and writes them to `мета/бобер/досуг.md`:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
досуг: вкл
|
|
||||||
сутки: 2026-09-08
|
|
||||||
тихие часы: 04, 06, 08, 10
|
|
||||||
мои часы: 13, 15, 18, 21, 00, 02, 06
|
|
||||||
```
|
|
||||||
|
|
||||||
**The model writes that file, the job only reads it**, so they cannot
|
|
||||||
overwrite each other; how many leisure slots are spent follows from the plan
|
|
||||||
and the clock, so there is no state to keep. No plan for the day (a restart,
|
|
||||||
a fresh day, a broken file) is not an error: the job falls back to defaults
|
|
||||||
and says so in the inject. Fewer than seven leisure hours in the plan are
|
|
||||||
topped up to seven by the job - the guarantee is the mechanism's, not the
|
|
||||||
model's.
|
|
||||||
|
|
||||||
A tick lands as `wake` and starts a turn once the master is idle; a busy
|
|
||||||
master or a message less than five minutes old moves it five minutes on,
|
|
||||||
up to three times, so it never cuts into a live exchange. If a `schedule`
|
|
||||||
inject of the model's own is due later in the same hour, the tick goes in
|
|
||||||
as `normal` instead and rides into that turn with it - one turn, both
|
|
||||||
reasons, neither swallowed. The cron sits at `:07`, not `:00`, because
|
|
||||||
rotation is on the hour and a tick waiting in the queue counts as a busy
|
|
||||||
master.
|
|
||||||
|
|
||||||
Nothing of this reaches Telegram by itself: a turn started by an inject
|
|
||||||
publishes no reply, so the only voice in it is the `say` tool.
|
|
||||||
Switches: `досуг: выкл` as the first line of the file for every day, and
|
|
||||||
`LEISURE=off` in `.env` to drop the job entirely.
|
|
||||||
|
|
||||||
## The watchdog ring (`beaver_agent/jobs/heartbeat.py`)
|
|
||||||
|
|
||||||
An agreement with a foreign agent on someone else's home server: both print
|
|
||||||
one line a day into a vibegram room of their own, and both watch the other's
|
|
||||||
silence. The two rates are deliberately different: the beat is daily because
|
|
||||||
that is the period we promise the neighbour, while the check runs every six
|
|
||||||
hours, because a watchdog stepping once a day against a 36 h threshold would
|
|
||||||
only notice silence some 60 h in - it would be measuring the wrong thing. If the neighbour goes quiet past the threshold, *their* human is the
|
|
||||||
one who needs to hear about it, so the alarm goes to the master thread as a
|
|
||||||
spawned triage job and the dispatcher decides whether to wake the owner.
|
|
||||||
|
|
||||||
The room is a **second, independent** `Vibegram` client (`VIBEGRAM_HB`), not
|
|
||||||
the one the dispatcher's `vibegram` tool and the triage job share: one client
|
|
||||||
for both rooms would mean the triage job eats the heartbeats off the shared
|
|
||||||
`/api/pending` cursor, and the heartbeats drown in conversation. The model
|
|
||||||
gets no tool for this room - the watchdog is a mechanism.
|
|
||||||
|
|
||||||
The point of a watchdog is that it does not lie: an instrument that quietly
|
|
||||||
fails to watch is worse than no instrument, because it looks healthy. So there
|
|
||||||
are no defaults anywhere in it.
|
|
||||||
|
|
||||||
- `отметка` (17:12 local) prints `hb <nick> at=… boot=… every=… src=…`. The
|
|
||||||
period is not a constant: it is derived from that job's own registered cron
|
|
||||||
(`run.job.cron`). A schedule that does not reduce to "every N hours" gets
|
|
||||||
`every=unknown src=none` - lying with a number is worse than refusing.
|
|
||||||
- `сторож` (`:42` every six hours) reads a window - **the last 25 messages of
|
|
||||||
that room** (`Vibegram.window`) - finds the newest `hb claude-kai …`, and
|
|
||||||
compares its age against
|
|
||||||
`every * 1.5` where `every` comes **from the neighbour's own line**. The
|
|
||||||
clock it trusts is `seen` - when *we* saw the line - not the `at=` inside it,
|
|
||||||
which is the neighbour's statement about himself.
|
|
||||||
- Every run prints
|
|
||||||
`ack claude-kai last=… seen=… res=… win=… armed=… thr=… alert=…` into the
|
|
||||||
room. Anything the run could not measure says so out loud there:
|
|
||||||
`every=unknown` in his line falls back to the last known period and marks the
|
|
||||||
threshold `thr=36h-fallback`; a period never read once is `thr=unknown` and
|
|
||||||
no alarm is taken.
|
|
||||||
- `alert=` is what the **instrument decided** in that run, not what reached a
|
|
||||||
human: `fired` - an alarm (`silence` or `unarmed`) was taken and handed on,
|
|
||||||
`held` - the threshold was crossed but the alarm was suppressed as a repeat
|
|
||||||
(the previous one went out less than a day ago), `none` - no alarm. Without
|
|
||||||
it the neighbour saw the arithmetic and had to guess what we made of it.
|
|
||||||
Delivery is decided past the watchdog, by the master thread and the
|
|
||||||
dispatcher, and its failure shows up in the log, not here.
|
|
||||||
- Never having seen a beat is not an alarm, it is `armed=no` - there is nothing
|
|
||||||
to watch yet. Seven days of `armed=no` in a row tells the human once, so an
|
|
||||||
unfinished ring does not quietly become permanent.
|
|
||||||
- The window is **not** `/api/pending`. That endpoint is a cursor: it hands a
|
|
||||||
line over once and never again, so the neighbour's beat showed up in one run
|
|
||||||
and was gone from the next one's window on a perfectly live room - the room
|
|
||||||
held it the whole time. The tail comes from `/api/state?view=…` instead,
|
|
||||||
which is already scoped to the room (hub event ids run across all rooms) and
|
|
||||||
pages **forward** in chunks of 200 from `after=<id>`; the watchdog walks the
|
|
||||||
history and keeps the last 25 *messages*, joins and leaves not counted.
|
|
||||||
- **An unread window is not a reading.** If the history could not be walked to
|
|
||||||
its end (the page budget ran out), the missing part is exactly the newest,
|
|
||||||
so `win=25/partial` goes into the `ack`, the log says `окно не дочитано`, and
|
|
||||||
no alarm is taken in that run: "nobody in the window" and "the window was
|
|
||||||
never finished" must not look the same.
|
|
||||||
- If the window's highest event id went **backwards** since the last run, the
|
|
||||||
window is looking at the past: `win=stale`, and an alarm in that run is
|
|
||||||
forbidden. Same for a hub that did not answer at all.
|
|
||||||
- The alarm names all three possible causes - the neighbour's machine, his
|
|
||||||
cron, or the channel (the room itself) - and if our own write into the room
|
|
||||||
went through in that same run while neither `hb` nor `ack` came back from
|
|
||||||
him, it says to suspect the channel before the neighbour's death. It repeats
|
|
||||||
at most once a day, not once a run.
|
|
||||||
|
|
||||||
State between runs is `мета/бобер/.состояние/сторож.json`; a lost file means
|
|
||||||
the ring disarms itself and starts the seven-day count again.
|
|
||||||
|
|
||||||
**Drills.** Next to that state file lives `мета/бобер/.состояние/сторож-учения.txt`
|
|
||||||
- a list of drill dates, one `YYYY-MM-DD` per line, each the local date of the
|
|
||||||
run in the scheduler's zone (`Europe/Warsaw`). On a date in that list `отметка`
|
|
||||||
sends nothing into the room and says so in the log only: a skipped beat is the
|
|
||||||
drill, and any text there ("skipping today") would call it off. No file, an
|
|
||||||
empty one or an unreadable one means no drill and the beat goes out as usual.
|
|
||||||
The dates are not in the code, and adding one needs no redeploy - a ring only
|
|
||||||
ever tested on "all alive" has never shown that the other side's alarm fires.
|
|
||||||
|
|
||||||
Setup is two variables in `.env` and one manual step:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# join the watchdog room with the invite code the neighbour sends
|
|
||||||
python3 scripts/vibegram_join.py <join-code> --nick <nick>
|
|
||||||
# put the token into .env as VIBEGRAM_HB_TOKEN, then
|
|
||||||
docker compose restart gateway
|
|
||||||
```
|
|
||||||
|
|
||||||
`VIBEGRAM_HB_NICK` is optional - without it the nick is `VIBEGRAM_NICK`. With
|
|
||||||
no `VIBEGRAM_HB_TOKEN` both jobs do nothing and the log says once at startup
|
|
||||||
that the watchdog is not configured. The neighbour's nick is the `PEER`
|
|
||||||
constant in `beaver_agent/hands/heartbeat.py`.
|
|
||||||
|
|
||||||
## The job-run watchdog (`beaver_agent/jobs/runs.py`)
|
|
||||||
|
|
||||||
The scheduler has always written one row per run into `job_runs` - name,
|
|
||||||
status, the head of the traceback in `error`. Nobody ever read it. The channel
|
|
||||||
to the neighbour agent was dead for 38 hours straight, the scheduler recorded
|
|
||||||
that 231 times, and nothing reached a human: the outage surfaced ten days later,
|
|
||||||
by hand, out of curiosity. The record existed, the reader did not. This job is
|
|
||||||
the reader.
|
|
||||||
|
|
||||||
- `прогоны` (`:27` every hour) reads `job_runs` for the last 7 days through
|
|
||||||
the same connection the scheduler itself writes with
|
|
||||||
(`run.conversations.db.session()` and the `JobRunRecord` model) - no second
|
|
||||||
truth about where the journal lives.
|
|
||||||
- An alarm is taken only off the **tail**: three or more `failed` in a row
|
|
||||||
counting back from a job's latest run, i.e. the job is *lying down* rather
|
|
||||||
than having blinked once. A single failure is a network hiccup, and waking
|
|
||||||
a human for it teaches them not to look.
|
|
||||||
- The alarm says the job's name, how many runs in a row died, when the series
|
|
||||||
started, and the **first line** of the error - never the traceback. Replayed
|
|
||||||
over the real outage it reads: «джоб `вайбграм` лежит 23 часа и дольше,
|
|
||||||
144 прогона подряд с 12.09 22:00 UTC: HubError: vibegram 530 bad_json». It repeats at most once a day per job, and
|
|
||||||
when the job recovers one short `отбой` line goes out and the counter resets.
|
|
||||||
The window is a week, not a day, so a daily job (`отметка`, `закрытие`,
|
|
||||||
`голосовой`) gets its three failures in a row in three days.
|
|
||||||
- **A job that stopped running is an alarm too, with its own text**: «джоб
|
|
||||||
`закрытие` не запускался 2 суток при периоде 24 часа». For every job with a
|
|
||||||
cron, the period comes from the registered cron (`run.scheduler.jobs`) via
|
|
||||||
the same `every_from_cron` the neighbour watchdog uses, and the last run is
|
|
||||||
taken from the whole journal, not the window. The threshold is **two
|
|
||||||
periods**: one missed fire happens honestly (a redeploy landed on the cron
|
|
||||||
minute, and the scheduler does not catch up), two in a row do not. It is
|
|
||||||
never finer than two of the watchdog's own periods (2 hours), because an
|
|
||||||
hourly watcher cannot tell a shorter gap from its own blindness between
|
|
||||||
runs. Time when the scheduler was not running non-critical jobs at all is
|
|
||||||
not counted as a miss: before the process started, and while the
|
|
||||||
watchdog's own hourly chain is broken (the gateway was down, or the budget
|
|
||||||
was throttled and non-critical crons were deferred without a record). Same
|
|
||||||
discipline as failures: once a day per job, and an `отбой` when a run shows
|
|
||||||
up.
|
|
||||||
- What silence does not cover, on purpose: jobs without a cron (webhooks,
|
|
||||||
events) are not measured at all; a job that has never appeared in the
|
|
||||||
journal waits silently for its first row; and a cron that does not reduce to
|
|
||||||
a regular period (`куратор`, `0 2,8,12,16,20 * * *`) is skipped and named in
|
|
||||||
the log as unguarded every run, because any number put in its place would
|
|
||||||
be made up. `куратор` still falls under the failure count.
|
|
||||||
- **An empty answer is a statement about the query, not about health.** Zero
|
|
||||||
rows in the last 24 hours (other than this job's own) means no job ran at all, and
|
|
||||||
that is its own alarm with its own text - the scheduler is what to look at,
|
|
||||||
not any one job. A failed query is no reading whatsoever: the run logs it and
|
|
||||||
stays silent, because a database that did not answer is not evidence of
|
|
||||||
health or of illness.
|
|
||||||
- Everything that goes out is masked (`mask`): the gateway's shared `redact`
|
|
||||||
plus two local rules for what it cannot catch by construction - a
|
|
||||||
`NAME=value` pair in the middle of a line (a traceback puts them there) and
|
|
||||||
the query string of any URL, which is dropped whole because the parameter
|
|
||||||
carrying the key can be named anything.
|
|
||||||
|
|
||||||
State between runs is `мета/бобер/.состояние/прогоны.json` (who was told and
|
|
||||||
when, separately for failures and for silence). A lost file means a lying job is reported again and an `отбой` for
|
|
||||||
something reported before the loss never comes - the evidence for it is gone.
|
|
||||||
|
|
||||||
One blind spot, deliberate: **the watchdog cannot report its own death.** Its
|
|
||||||
own runs are excluded from both the failure count and the silence check
|
|
||||||
(`run.job.name`), because a job that is down will not file a report about
|
|
||||||
itself - an observer inside the object does not report the object's death. If
|
|
||||||
`прогоны` itself starts failing or stops running, nothing here will say so;
|
|
||||||
that needs an outside eye, like the watchdog ring above.
|
|
||||||
|
|
||||||
## Policy (`beaver_agent/policy.py`)
|
## Policy (`beaver_agent/policy.py`)
|
||||||
|
|
||||||
`bypassPermissions` everywhere; the boundary is `PreToolUse` rules declared
|
`bypassPermissions` everywhere; the boundary is `PreToolUse` rules declared
|
||||||
|
|||||||
+9
-31
@@ -21,14 +21,10 @@ from beaver_agent.policy import (
|
|||||||
vault_zones,
|
vault_zones,
|
||||||
)
|
)
|
||||||
from beaver_agent.vault import TZ, VAULT
|
from beaver_agent.vault import TZ, VAULT
|
||||||
from beaver_agent.voice import AGENT as VOICE
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from beaver_gateway.agents.base import BaseAgent
|
from beaver_gateway.agents.base import BaseAgent
|
||||||
|
|
||||||
REASONING = {"type": "adaptive", "display": "summarized"}
|
|
||||||
"""Рассуждения приезжают текстом: их хранит транскрипт и показывает админка."""
|
|
||||||
|
|
||||||
|
|
||||||
def dispatcher(name: str, model: str, effort: str) -> ClaudeAgent:
|
def dispatcher(name: str, model: str, effort: str) -> ClaudeAgent:
|
||||||
"""Мастер и ветки: полный голос, все руки, vault - дом."""
|
"""Мастер и ветки: полный голос, все руки, vault - дом."""
|
||||||
@@ -38,10 +34,8 @@ def dispatcher(name: str, model: str, effort: str) -> ClaudeAgent:
|
|||||||
cwd=VAULT,
|
cwd=VAULT,
|
||||||
prompts=prompts.DISPATCHER,
|
prompts=prompts.DISPATCHER,
|
||||||
skill_sets=skills.DISPATCHER,
|
skill_sets=skills.DISPATCHER,
|
||||||
gateway_tools=("read_conversation", "spawn", "say", "schedule", "send_file"),
|
gateway_tools=("read_conversation", "spawn", "say", "schedule"),
|
||||||
options=ClaudeOptions(
|
options=ClaudeOptions(effort=effort, disallowed_tools=DISPATCHER_DISALLOWED),
|
||||||
effort=effort, thinking=REASONING, disallowed_tools=DISPATCHER_DISALLOWED
|
|
||||||
),
|
|
||||||
policy=(
|
policy=(
|
||||||
*VAULT_POLICY,
|
*VAULT_POLICY,
|
||||||
forbid_skills(skills.MASTER_FORBIDDEN, kinds=("master",)),
|
forbid_skills(skills.MASTER_FORBIDDEN, kinds=("master",)),
|
||||||
@@ -59,9 +53,7 @@ def deep(name: str, model: str, effort: str) -> ClaudeAgent:
|
|||||||
prompts=prompts.DEEP,
|
prompts=prompts.DEEP,
|
||||||
skill_sets=skills.DEEP,
|
skill_sets=skills.DEEP,
|
||||||
gateway_tools=("close_chat",),
|
gateway_tools=("close_chat",),
|
||||||
options=ClaudeOptions(
|
options=ClaudeOptions(effort=effort, disallowed_tools=DEEP_DISALLOWED),
|
||||||
effort=effort, thinking=REASONING, disallowed_tools=DEEP_DISALLOWED
|
|
||||||
),
|
|
||||||
policy=VAULT_POLICY,
|
policy=VAULT_POLICY,
|
||||||
expose_mcps=hands.CLAUDE,
|
expose_mcps=hands.CLAUDE,
|
||||||
)
|
)
|
||||||
@@ -76,7 +68,6 @@ def distiller(name: str, model: str, effort: str) -> ClaudeAgent:
|
|||||||
prompts=prompts.DISTILLER,
|
prompts=prompts.DISTILLER,
|
||||||
options=ClaudeOptions(
|
options=ClaudeOptions(
|
||||||
effort=effort,
|
effort=effort,
|
||||||
thinking=REASONING,
|
|
||||||
tools=("Read", "Write"),
|
tools=("Read", "Write"),
|
||||||
disallowed_tools=DISTILLER_DISALLOWED,
|
disallowed_tools=DISTILLER_DISALLOWED,
|
||||||
),
|
),
|
||||||
@@ -94,7 +85,6 @@ def curator(name: str, model: str, effort: str) -> ClaudeAgent:
|
|||||||
gateway_tools=("inject",),
|
gateway_tools=("inject",),
|
||||||
options=ClaudeOptions(
|
options=ClaudeOptions(
|
||||||
effort=effort,
|
effort=effort,
|
||||||
thinking=REASONING,
|
|
||||||
tools=("Read", "Write", "Edit", "Grep", "Glob"),
|
tools=("Read", "Write", "Edit", "Grep", "Glob"),
|
||||||
disallowed_tools=DISTILLER_DISALLOWED,
|
disallowed_tools=DISTILLER_DISALLOWED,
|
||||||
),
|
),
|
||||||
@@ -116,17 +106,6 @@ def triage(name: str, model: str, effort: str) -> ClaudeAgent:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def voice(name: str, model: str) -> ClaudeAgent:
|
|
||||||
"""Колонки по квартире: чужие люди, поэтому ни vault, ни инструментов."""
|
|
||||||
return ClaudeAgent(
|
|
||||||
name=name,
|
|
||||||
model=model,
|
|
||||||
cwd=Path("/tmp"), # noqa: S108
|
|
||||||
prompts=prompts.VOICE_AGENT,
|
|
||||||
options=ClaudeOptions(tools=(), disallowed_tools=TRIAGE_DISALLOWED),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def raycast(name: str, model: str, effort: str) -> RaycastAgent:
|
def raycast(name: str, model: str, effort: str) -> RaycastAgent:
|
||||||
"""Быстрые ответы через Raycast: веб и чтение страниц, vault через obsidian-fs."""
|
"""Быстрые ответы через Raycast: веб и чтение страниц, vault через obsidian-fs."""
|
||||||
return RaycastAgent(
|
return RaycastAgent(
|
||||||
@@ -143,16 +122,15 @@ def raycast(name: str, model: str, effort: str) -> RaycastAgent:
|
|||||||
|
|
||||||
|
|
||||||
agents: list[BaseAgent] = [
|
agents: list[BaseAgent] = [
|
||||||
dispatcher("beaver-dispatcher", "claude-opus-5-5", "high"),
|
dispatcher("beaver-dispatcher", "claude-opus-5", "high"),
|
||||||
deep("beaver-opus-high", "claude-opus-5-5", "high"),
|
deep("beaver-opus-high", "claude-opus-5", "high"),
|
||||||
deep("beaver-opus-medium", "claude-opus-5-5", "medium"),
|
deep("beaver-opus-medium", "claude-opus-5", "medium"),
|
||||||
deep("beaver-opus-xhigh", "claude-opus-5-5", "xhigh"),
|
deep("beaver-opus-xhigh", "claude-opus-5", "xhigh"),
|
||||||
deep("beaver-fable-high", "claude-fable-5-1", "high"),
|
deep("beaver-fable-high", "claude-fable-5-1", "high"),
|
||||||
deep("beaver-fable-medium", "claude-fable-5-1", "medium"),
|
deep("beaver-fable-medium", "claude-fable-5-1", "medium"),
|
||||||
distiller("beaver-distiller", "claude-opus-5-5", "medium"),
|
distiller("beaver-distiller", "claude-opus-5", "medium"),
|
||||||
curator("beaver-curator", "claude-opus-5-5", "high"),
|
curator("beaver-curator", "claude-opus-5", "high"),
|
||||||
triage("beaver-triage", "claude-sonnet-5", "low"),
|
triage("beaver-triage", "claude-sonnet-5", "low"),
|
||||||
voice(VOICE, "claude-haiku-4-5-20251001"),
|
|
||||||
raycast("beaver-gemini-pro-high", "google-gemini-3.1-pro", "high"),
|
raycast("beaver-gemini-pro-high", "google-gemini-3.1-pro", "high"),
|
||||||
raycast("beaver-gemini-pro-low", "google-gemini-3.1-pro", "low"),
|
raycast("beaver-gemini-pro-low", "google-gemini-3.1-pro", "low"),
|
||||||
raycast("beaver-gemini-flash-high", "google-gemini-3.5-flash", "high"),
|
raycast("beaver-gemini-flash-high", "google-gemini-3.5-flash", "high"),
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ from beaver_gateway.frontends.turn_record import slugify
|
|||||||
|
|
||||||
from beaver_agent import texts
|
from beaver_agent import texts
|
||||||
from beaver_agent.vault import ATTACHMENTS, BEAVER, CHATS, TZ, VAULT
|
from beaver_agent.vault import ATTACHMENTS, BEAVER, CHATS, TZ, VAULT
|
||||||
from beaver_agent.voice import AGENT as VOICE
|
|
||||||
from beaver_agent.voice import VoiceFrontend
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -64,5 +62,4 @@ frontends: list[Frontend] = [
|
|||||||
MarkdownFrontend(
|
MarkdownFrontend(
|
||||||
vault_path=CHATS, default_agent=DEEP, log_all_chats=True, chat_path=chat_path
|
vault_path=CHATS, default_agent=DEEP, log_all_chats=True, chat_path=chat_path
|
||||||
),
|
),
|
||||||
VoiceFrontend(agent=VOICE),
|
|
||||||
]
|
]
|
||||||
@@ -6,21 +6,19 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from beaver_gateway.agents.base import ExposedMcp
|
from beaver_gateway.agents.base import ExposedMcp
|
||||||
from beaver_gateway.mcp.types import McpServer, McpServerT
|
from beaver_gateway.mcp.types import McpServer, McpServerT
|
||||||
|
|
||||||
from beaver_agent.hands.calendars import Calendars, calendars, feeds
|
from beaver_agent.hands.calendars import calendars
|
||||||
from beaver_agent.hands.homeassistant import HomeAssistant
|
from beaver_agent.hands.homeassistant import HomeAssistant
|
||||||
from beaver_agent.hands.komodo import Komodo
|
from beaver_agent.hands.komodo import Komodo
|
||||||
from beaver_agent.hands.vibegram import Vibegram
|
from beaver_agent.hands.vibegram import Vibegram
|
||||||
from beaver_agent.vault import TZ
|
from beaver_agent.vault import TZ
|
||||||
|
|
||||||
env = os.environ.get
|
env = os.environ.get
|
||||||
_log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
obsidian = McpServer.stdio(
|
obsidian = McpServer.stdio(
|
||||||
name="obsidian-fs",
|
name="obsidian-fs",
|
||||||
@@ -54,10 +52,6 @@ telegram = (
|
|||||||
calendar_servers = calendars(env("CALENDAR_MCPS", ""))
|
calendar_servers = calendars(env("CALENDAR_MCPS", ""))
|
||||||
calendar_exposed = tuple(ExposedMcp(name=m.name) for m in calendar_servers)
|
calendar_exposed = tuple(ExposedMcp(name=m.name) for m in calendar_servers)
|
||||||
|
|
||||||
CALENDAR_FEEDS = feeds(env("CALENDAR_MCPS", ""))
|
|
||||||
CALENDARS = Calendars(CALENDAR_FEEDS, tz=TZ) if CALENDAR_FEEDS else None
|
|
||||||
"""Те же фиды, но напрямую: ближайшие события едут в справку без модели."""
|
|
||||||
|
|
||||||
KOMODO = (
|
KOMODO = (
|
||||||
Komodo(
|
Komodo(
|
||||||
url=os.environ["KOMODO_URL"],
|
url=os.environ["KOMODO_URL"],
|
||||||
@@ -100,29 +94,6 @@ vibegram = (
|
|||||||
)
|
)
|
||||||
vibegram_exposed = (ExposedMcp(name="vibegram"),) if VIBEGRAM else ()
|
vibegram_exposed = (ExposedMcp(name="vibegram"),) if VIBEGRAM else ()
|
||||||
|
|
||||||
VIBEGRAM_HB_NICK = env("VIBEGRAM_HB_NICK") or env("VIBEGRAM_NICK")
|
|
||||||
VIBEGRAM_HB = (
|
|
||||||
Vibegram(
|
|
||||||
hub=os.environ["VIBEGRAM_HUB"],
|
|
||||||
token=os.environ["VIBEGRAM_HB_TOKEN"],
|
|
||||||
nick=VIBEGRAM_HB_NICK or "",
|
|
||||||
tz=TZ,
|
|
||||||
)
|
|
||||||
if env("VIBEGRAM_HB_TOKEN") and env("VIBEGRAM_HUB") and VIBEGRAM_HB_NICK
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
"""Сторожевая комната: ВТОРОЙ, независимый экземпляр клиента.
|
|
||||||
|
|
||||||
Комната на хабе определяется токеном, а `/api/pending` - курсор на стороне
|
|
||||||
хаба: общий с основной комнатой экземпляр означал бы, что отметки соседа
|
|
||||||
съедает триаж-джоб, а сами они тонут в разговоре. Модели эта рука не
|
|
||||||
выдаётся: сторож - механизм, у него нет тулзы.
|
|
||||||
"""
|
|
||||||
if VIBEGRAM_HB is None:
|
|
||||||
_log.info(
|
|
||||||
"сторож: VIBEGRAM_HB_TOKEN не задан - отметка и слежка за соседом выключены"
|
|
||||||
)
|
|
||||||
|
|
||||||
t3code = (
|
t3code = (
|
||||||
McpServer.http(name="t3code", url=env("T3CODE_MCP", "http://t3code-mcp:8000/mcp"))
|
McpServer.http(name="t3code", url=env("T3CODE_MCP", "http://t3code-mcp:8000/mcp"))
|
||||||
if "t3" in env("COMPOSE_PROFILES", "").split(",")
|
if "t3" in env("COMPOSE_PROFILES", "").split(",")
|
||||||
|
|||||||
@@ -1,70 +1,9 @@
|
|||||||
"""Календари: MCP-руки для модели и тот же ical-фид, читаемый gateway напрямую.
|
"""Календари из `CALENDAR_MCPS=name=url,name=url`; в url - приватный фид с токеном."""
|
||||||
|
|
||||||
`CALENDAR_MCPS=name=url,name=url`; в url - приватный фид с токеном, и там же,
|
|
||||||
параметром `icsUrl`, лежит сам ical. Модель ходит в календари через MCP, а
|
|
||||||
справка конверта читает фид сама: канал, о котором никто не спрашивает, молчит
|
|
||||||
незаметно - так экзаменационная сессия и пролежала невидимой.
|
|
||||||
|
|
||||||
Парсер здесь свой и маленький: в образе gateway ical-библиотеки нет, а нужно
|
|
||||||
немного - когда начинается, как называется, где. Повторы разворачиваются по
|
|
||||||
FREQ=DAILY/WEEKLY/MONTHLY/YEARLY с INTERVAL, BYDAY, BYMONTH, COUNT и UNTIL;
|
|
||||||
EXDATE и перенесённые экземпляры (RECURRENCE-ID) вычитаются. Шаг считается по
|
|
||||||
настенным часам события, а не в абсолютном времени: еженедельная встреча
|
|
||||||
переживает перевод часов. Событие, порождённое ежедневным правилом, помечается
|
|
||||||
`routine`: в справке такие едут одной строкой, чтобы не вытеснять разовые.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
|
||||||
import re
|
|
||||||
import urllib.error
|
|
||||||
import urllib.request
|
|
||||||
from calendar import monthrange
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from datetime import UTC, date, datetime, timedelta
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
from urllib.parse import parse_qs, urlsplit
|
|
||||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
||||||
|
|
||||||
from beaver_gateway.mcp.types import HttpMcp, McpServer
|
from beaver_gateway.mcp.types import HttpMcp, McpServer
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import Iterator
|
|
||||||
from datetime import tzinfo
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"Calendars",
|
|
||||||
"Event",
|
|
||||||
"Feed",
|
|
||||||
"Upcoming",
|
|
||||||
"calendars",
|
|
||||||
"feeds",
|
|
||||||
"is_routine",
|
|
||||||
"short",
|
|
||||||
]
|
|
||||||
|
|
||||||
_log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
USER_AGENT = "beaver-gateway (ical)"
|
|
||||||
MAX_BYTES = 4_000_000
|
|
||||||
"""Больше четырёх мегабайт фида - это уже не календарь, а чей-то экспорт всего."""
|
|
||||||
STEPS_CAP = 20_000
|
|
||||||
"""Потолок шагов разворачивания одного повтора: страховка от вечного цикла."""
|
|
||||||
PLACE_MAX = 70
|
|
||||||
TITLE_MAX = 90
|
|
||||||
ERROR_MAX = 90
|
|
||||||
NO_TITLE = "(без названия)"
|
|
||||||
|
|
||||||
WEEKDAYS = {"MO": 0, "TU": 1, "WE": 2, "TH": 3, "FR": 4, "SA": 5, "SU": 6}
|
|
||||||
DT_RE = re.compile(r"^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})(Z)?)?$")
|
|
||||||
BYDAY_RE = re.compile(r"^([+-]?\d{1,2})?(MO|TU|WE|TH|FR|SA|SU)$")
|
|
||||||
ESCAPE_RE = re.compile(r"\\([\\;,nN])")
|
|
||||||
PLACE_RE = re.compile(
|
|
||||||
r"^\s*(?:sala|room|зал|аудитория)\b\s*[:.]?\s*(.+)$", re.IGNORECASE | re.MULTILINE
|
|
||||||
)
|
|
||||||
"""Зал USOS живёт в описании (`Sala: 315`), а LOCATION - это адрес корпуса."""
|
|
||||||
|
|
||||||
|
|
||||||
def calendars(raw: str) -> list[HttpMcp]:
|
def calendars(raw: str) -> list[HttpMcp]:
|
||||||
servers: list[HttpMcp] = []
|
servers: list[HttpMcp] = []
|
||||||
@@ -78,457 +17,3 @@ def calendars(raw: str) -> list[HttpMcp]:
|
|||||||
raise ValueError(msg)
|
raise ValueError(msg)
|
||||||
servers.append(McpServer.http(name=f"calendar-{name.strip()}", url=url.strip()))
|
servers.append(McpServer.http(name=f"calendar-{name.strip()}", url=url.strip()))
|
||||||
return servers
|
return servers
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class Feed:
|
|
||||||
name: str
|
|
||||||
url: str
|
|
||||||
"""Ical по подписке: приватный, с токеном внутри - в логи не писать."""
|
|
||||||
|
|
||||||
|
|
||||||
def feeds(raw: str) -> tuple[Feed, ...]:
|
|
||||||
"""Ical-фиды из тех же записей `CALENDAR_MCPS`: урл лежит в `icsUrl`.
|
|
||||||
|
|
||||||
Запись без ical-урла просто не даёт фида: MCP-рука у модели остаётся,
|
|
||||||
справка про этот календарь молчит - конфигурация, а не отказ канала.
|
|
||||||
"""
|
|
||||||
out: list[Feed] = []
|
|
||||||
for raw_entry in raw.split(","):
|
|
||||||
name, sep, url = raw_entry.strip().partition("=")
|
|
||||||
if not sep or not url.strip():
|
|
||||||
continue
|
|
||||||
ics = _ics_url(url.strip())
|
|
||||||
if ics:
|
|
||||||
out.append(Feed(name=name.strip(), url=ics))
|
|
||||||
return tuple(out)
|
|
||||||
|
|
||||||
|
|
||||||
def _ics_url(url: str) -> str | None:
|
|
||||||
inner = parse_qs(urlsplit(url).query).get("icsUrl") or []
|
|
||||||
if inner and inner[0].strip():
|
|
||||||
return inner[0].strip()
|
|
||||||
return url if urlsplit(url).path.endswith(".ics") else None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class Event:
|
|
||||||
start: datetime
|
|
||||||
"""С зоной: у события своя (TZID), плавающее время читается в зоне агента."""
|
|
||||||
title: str
|
|
||||||
place: str
|
|
||||||
all_day: bool
|
|
||||||
routine: bool = False
|
|
||||||
"""Порождено правилом, повторяющимся раз в день или чаще: см. `is_routine`."""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class Upcoming:
|
|
||||||
at: datetime
|
|
||||||
"""Момент запроса - его и показывает справка: «данные на …»."""
|
|
||||||
events: tuple[Event, ...] = ()
|
|
||||||
errors: tuple[str, ...] = ()
|
|
||||||
"""По строке на фид, который не ответил; пустое окно - это не ошибка."""
|
|
||||||
read: int = 0
|
|
||||||
"""Сколько фидов ответило: ноль - показывать нечего, кроме отказа."""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class Calendars:
|
|
||||||
"""Ближайшие события всех фидов - одним списком, отсортированным по началу."""
|
|
||||||
|
|
||||||
feeds: tuple[Feed, ...]
|
|
||||||
tz: str = "Europe/Warsaw"
|
|
||||||
timeout: float = 8.0
|
|
||||||
days: int = 7
|
|
||||||
|
|
||||||
def upcoming(self, now: datetime) -> Upcoming:
|
|
||||||
"""Окно `days` от `now`; отказ фида - строка в `errors`, не исключение."""
|
|
||||||
zone = ZoneInfo(self.tz)
|
|
||||||
since = now.astimezone(zone)
|
|
||||||
events: list[Event] = []
|
|
||||||
errors: list[str] = []
|
|
||||||
read = 0
|
|
||||||
for feed in self.feeds:
|
|
||||||
try:
|
|
||||||
text = self.fetch(feed.url)
|
|
||||||
found = events_in(text, zone=zone, since=since, days=self.days)
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
_log.warning("календарь %s: %s", feed.name, short(exc), exc_info=True)
|
|
||||||
errors.append(f"{feed.name}: {short(exc)}")
|
|
||||||
continue
|
|
||||||
read += 1
|
|
||||||
events.extend(found)
|
|
||||||
events.sort(key=lambda e: (e.start, e.title))
|
|
||||||
return Upcoming(at=since, events=tuple(events), errors=tuple(errors), read=read)
|
|
||||||
|
|
||||||
def fetch(self, url: str) -> str:
|
|
||||||
if urlsplit(url).scheme not in ("http", "https"):
|
|
||||||
msg = "фид не по http(s)"
|
|
||||||
raise ValueError(msg)
|
|
||||||
request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) # noqa: S310
|
|
||||||
with urllib.request.urlopen(request, timeout=self.timeout) as response: # noqa: S310
|
|
||||||
raw: bytes = response.read(MAX_BYTES)
|
|
||||||
return raw.decode("utf-8", errors="replace")
|
|
||||||
|
|
||||||
|
|
||||||
def short(exc: BaseException) -> str:
|
|
||||||
"""Причина отказа одной строкой: в конверт едет она, урл фида - никогда."""
|
|
||||||
if isinstance(exc, urllib.error.HTTPError):
|
|
||||||
reason = f"HTTP {exc.code}"
|
|
||||||
elif isinstance(exc, TimeoutError):
|
|
||||||
reason = "таймаут"
|
|
||||||
elif isinstance(exc, urllib.error.URLError):
|
|
||||||
reason = f"сеть: {exc.reason}"
|
|
||||||
else:
|
|
||||||
reason = f"{type(exc).__name__}: {exc}".strip(": ")
|
|
||||||
return " ".join(reason.split())[:ERROR_MAX]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class Prop:
|
|
||||||
value: str
|
|
||||||
params: dict[str, str] = field(default_factory=dict)
|
|
||||||
|
|
||||||
|
|
||||||
Vevent = dict[str, list[Prop]]
|
|
||||||
|
|
||||||
|
|
||||||
def unfold(text: str) -> list[str]:
|
|
||||||
"""Ical-строки, склеенные обратно: продолжение начинается с пробела или таба."""
|
|
||||||
lines: list[str] = []
|
|
||||||
for raw in text.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
|
|
||||||
if lines and raw[:1] in (" ", "\t"):
|
|
||||||
lines[-1] += raw[1:]
|
|
||||||
else:
|
|
||||||
lines.append(raw)
|
|
||||||
return lines
|
|
||||||
|
|
||||||
|
|
||||||
def _split(line: str) -> tuple[str, str] | None:
|
|
||||||
"""`NAME;PARAM=x:значение` - двоеточие вне кавычек делит имя и значение."""
|
|
||||||
quoted = False
|
|
||||||
for i, char in enumerate(line):
|
|
||||||
if char == '"':
|
|
||||||
quoted = not quoted
|
|
||||||
elif char == ":" and not quoted:
|
|
||||||
return line[:i], line[i + 1 :]
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _prop(line: str) -> tuple[str, Prop] | None:
|
|
||||||
parts = _split(line)
|
|
||||||
if parts is None:
|
|
||||||
return None
|
|
||||||
head, value = parts
|
|
||||||
chunks = head.split(";")
|
|
||||||
name = chunks[0].strip().upper()
|
|
||||||
if not name:
|
|
||||||
return None
|
|
||||||
params: dict[str, str] = {}
|
|
||||||
for chunk in chunks[1:]:
|
|
||||||
key, sep, raw = chunk.partition("=")
|
|
||||||
if sep:
|
|
||||||
params[key.strip().upper()] = raw.strip().strip('"')
|
|
||||||
return name, Prop(value=value, params=params)
|
|
||||||
|
|
||||||
|
|
||||||
def vevents(lines: list[str]) -> list[Vevent]:
|
|
||||||
"""Только свойства самих VEVENT: VALARM внутри них пропускается целиком."""
|
|
||||||
out: list[Vevent] = []
|
|
||||||
current: Vevent | None = None
|
|
||||||
depth = 0
|
|
||||||
for line in lines:
|
|
||||||
parsed = _prop(line)
|
|
||||||
if parsed is None:
|
|
||||||
continue
|
|
||||||
name, prop = parsed
|
|
||||||
block = prop.value.strip().upper()
|
|
||||||
if name == "BEGIN":
|
|
||||||
if block == "VEVENT" and current is None:
|
|
||||||
current = {}
|
|
||||||
elif current is not None:
|
|
||||||
depth += 1
|
|
||||||
continue
|
|
||||||
if name == "END":
|
|
||||||
if block == "VEVENT" and current is not None and depth == 0:
|
|
||||||
out.append(current)
|
|
||||||
current = None
|
|
||||||
elif current is not None and depth:
|
|
||||||
depth -= 1
|
|
||||||
continue
|
|
||||||
if current is not None and depth == 0:
|
|
||||||
current.setdefault(name, []).append(prop)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def unescape(value: str) -> str:
|
|
||||||
return ESCAPE_RE.sub(lambda m: "\n" if m.group(1) in "nN" else m.group(1), value)
|
|
||||||
|
|
||||||
|
|
||||||
def _flat(value: str, limit: int) -> str:
|
|
||||||
flat = " ".join(unescape(value).split())
|
|
||||||
return flat if len(flat) <= limit else flat[: limit - 1] + "…"
|
|
||||||
|
|
||||||
|
|
||||||
def _first(event: Vevent, name: str) -> Prop | None:
|
|
||||||
props = event.get(name)
|
|
||||||
return props[0] if props else None
|
|
||||||
|
|
||||||
|
|
||||||
def _zone_of(prop: Prop, default: tzinfo) -> tzinfo:
|
|
||||||
tzid = prop.params.get("TZID")
|
|
||||||
if not tzid:
|
|
||||||
return default
|
|
||||||
try:
|
|
||||||
return ZoneInfo(tzid)
|
|
||||||
except (ZoneInfoNotFoundError, ValueError):
|
|
||||||
return default
|
|
||||||
|
|
||||||
|
|
||||||
def moment(prop: Prop, value: str, zone: tzinfo) -> tuple[datetime, bool] | None:
|
|
||||||
"""Значение DTSTART/EXDATE в момент времени; второе - «на весь день»."""
|
|
||||||
match = DT_RE.match(value.strip())
|
|
||||||
if match is None:
|
|
||||||
return None
|
|
||||||
year, month, day = (int(match.group(i)) for i in (1, 2, 3))
|
|
||||||
if match.group(4) is None or prop.params.get("VALUE", "").upper() == "DATE":
|
|
||||||
return datetime(year, month, day, tzinfo=zone), True
|
|
||||||
hour, minute, second = (int(match.group(i)) for i in (4, 5, 6))
|
|
||||||
where = UTC if match.group(7) else _zone_of(prop, zone)
|
|
||||||
return datetime(year, month, day, hour, minute, second, tzinfo=where), False
|
|
||||||
|
|
||||||
|
|
||||||
def _instants(event: Vevent, name: str, zone: tzinfo) -> set[datetime]:
|
|
||||||
"""Даты-исключения в UTC: значений в одной строке может быть несколько."""
|
|
||||||
out: set[datetime] = set()
|
|
||||||
for prop in event.get(name, []):
|
|
||||||
for chunk in prop.value.split(","):
|
|
||||||
parsed = moment(prop, chunk, zone)
|
|
||||||
if parsed is not None:
|
|
||||||
out.add(parsed[0].astimezone(UTC))
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _rule(value: str) -> dict[str, str]:
|
|
||||||
out: dict[str, str] = {}
|
|
||||||
for chunk in value.split(";"):
|
|
||||||
key, sep, raw = chunk.partition("=")
|
|
||||||
if sep:
|
|
||||||
out[key.strip().upper()] = raw.strip()
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _int(value: str | None, default: int) -> int:
|
|
||||||
try:
|
|
||||||
return int(value) if value else default
|
|
||||||
except ValueError:
|
|
||||||
return default
|
|
||||||
|
|
||||||
|
|
||||||
def _byday(value: str) -> list[tuple[int, int]]:
|
|
||||||
"""`-1SU,2TU` → [(-1, воскресенье), (2, вторник)]; без числа - каждый такой день."""
|
|
||||||
out: list[tuple[int, int]] = []
|
|
||||||
for chunk in value.split(","):
|
|
||||||
match = BYDAY_RE.match(chunk.strip().upper())
|
|
||||||
if match:
|
|
||||||
out.append((_int(match.group(1), 0), WEEKDAYS[match.group(2)]))
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _month_days(
|
|
||||||
year: int, month: int, days: list[tuple[int, int]], clock: datetime
|
|
||||||
) -> list[datetime]:
|
|
||||||
total = monthrange(year, month)[1]
|
|
||||||
out: list[datetime] = []
|
|
||||||
for nth, weekday in days:
|
|
||||||
matching = [
|
|
||||||
day
|
|
||||||
for day in range(1, total + 1)
|
|
||||||
if date(year, month, day).weekday() == weekday
|
|
||||||
]
|
|
||||||
if not nth:
|
|
||||||
picked = matching
|
|
||||||
elif abs(nth) <= len(matching):
|
|
||||||
picked = [matching[nth - 1] if nth > 0 else matching[nth]]
|
|
||||||
else:
|
|
||||||
picked = []
|
|
||||||
out += [
|
|
||||||
datetime(year, month, day, clock.hour, clock.minute, clock.second) # noqa: DTZ001
|
|
||||||
for day in picked
|
|
||||||
]
|
|
||||||
return sorted(out)
|
|
||||||
|
|
||||||
|
|
||||||
def _candidates(
|
|
||||||
naive: datetime, freq: str, interval: int, days: list[tuple[int, int]], month: int
|
|
||||||
) -> Iterator[datetime]:
|
|
||||||
"""Настенные времена повтора после первого - по возрастанию, бесконечно."""
|
|
||||||
if freq == "DAILY":
|
|
||||||
moving = naive
|
|
||||||
while True:
|
|
||||||
moving += timedelta(days=interval)
|
|
||||||
yield moving
|
|
||||||
elif freq == "WEEKLY" and not days:
|
|
||||||
moving = naive
|
|
||||||
while True:
|
|
||||||
moving += timedelta(weeks=interval)
|
|
||||||
yield moving
|
|
||||||
elif freq == "WEEKLY":
|
|
||||||
start = naive.date() - timedelta(days=naive.weekday())
|
|
||||||
step = 0
|
|
||||||
while True:
|
|
||||||
base = start + timedelta(weeks=interval * step)
|
|
||||||
for _, weekday in sorted(days, key=lambda d: d[1]):
|
|
||||||
yield datetime.combine(base + timedelta(days=weekday), naive.time())
|
|
||||||
step += 1
|
|
||||||
elif freq in ("MONTHLY", "YEARLY"):
|
|
||||||
step = 0
|
|
||||||
while True:
|
|
||||||
step += 1
|
|
||||||
if freq == "MONTHLY":
|
|
||||||
index = naive.year * 12 + naive.month - 1 + interval * step
|
|
||||||
year, number = divmod(index, 12)
|
|
||||||
number += 1
|
|
||||||
else:
|
|
||||||
year, number = naive.year + interval * step, month or naive.month
|
|
||||||
if days:
|
|
||||||
yield from _month_days(year, number, days, naive)
|
|
||||||
elif naive.day <= monthrange(year, number)[1]:
|
|
||||||
yield naive.replace(year=year, month=number)
|
|
||||||
|
|
||||||
|
|
||||||
def occurrences(
|
|
||||||
start: datetime, rule: dict[str, str], zone: tzinfo, *, end: datetime
|
|
||||||
) -> list[datetime]:
|
|
||||||
"""Начала повтора от `start` до `end`; правило, которого мы не знаем, - разовое."""
|
|
||||||
freq = rule.get("FREQ", "").upper()
|
|
||||||
if freq not in ("DAILY", "WEEKLY", "MONTHLY", "YEARLY"):
|
|
||||||
return [start]
|
|
||||||
where = start.tzinfo or zone
|
|
||||||
naive = start.replace(tzinfo=None)
|
|
||||||
interval = max(_int(rule.get("INTERVAL"), 1), 1)
|
|
||||||
count = _int(rule.get("COUNT"), 0)
|
|
||||||
until = _until(rule.get("UNTIL"), where)
|
|
||||||
days = _byday(rule.get("BYDAY", ""))
|
|
||||||
out = [start]
|
|
||||||
stream = _candidates(naive, freq, interval, days, _int(rule.get("BYMONTH"), 0))
|
|
||||||
for step, candidate in enumerate(stream):
|
|
||||||
if step > STEPS_CAP:
|
|
||||||
break
|
|
||||||
if candidate <= naive:
|
|
||||||
continue
|
|
||||||
moving = candidate.replace(tzinfo=where)
|
|
||||||
if moving > end or (until is not None and moving > until):
|
|
||||||
break
|
|
||||||
out.append(moving)
|
|
||||||
if count and len(out) >= count:
|
|
||||||
break
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _until(value: str | None, zone: tzinfo) -> datetime | None:
|
|
||||||
if not value:
|
|
||||||
return None
|
|
||||||
parsed = moment(Prop(value=value), value, zone)
|
|
||||||
if parsed is None:
|
|
||||||
return None
|
|
||||||
stop, all_day = parsed
|
|
||||||
return stop + timedelta(days=1) if all_day else stop
|
|
||||||
|
|
||||||
|
|
||||||
ROUTINE_DAYS = 5
|
|
||||||
"""Со скольких дней недели в BYDAY повтор перестаёт быть событием и делается бытом."""
|
|
||||||
|
|
||||||
|
|
||||||
def is_routine(rule: dict[str, str]) -> bool:
|
|
||||||
"""Правило, срабатывающее раз в день или чаще: завтрак, зарядка, wind down.
|
|
||||||
|
|
||||||
Такие повторы за неделю дают десятки строк и топят в себе то единственное,
|
|
||||||
ради чего справку и читают, - экзамен, встречу, приём. В конверт они едут
|
|
||||||
одной свёрнутой строкой, а не списком.
|
|
||||||
"""
|
|
||||||
freq = rule.get("FREQ", "").upper()
|
|
||||||
if max(_int(rule.get("INTERVAL"), 1), 1) != 1:
|
|
||||||
return False
|
|
||||||
if freq == "DAILY":
|
|
||||||
return True
|
|
||||||
return freq == "WEEKLY" and len(_byday(rule.get("BYDAY", ""))) >= ROUTINE_DAYS
|
|
||||||
|
|
||||||
|
|
||||||
def place_of(event: Vevent) -> str:
|
|
||||||
"""Зал из описания и адрес из LOCATION - то, что помогает дойти."""
|
|
||||||
parts: list[str] = []
|
|
||||||
description = _first(event, "DESCRIPTION")
|
|
||||||
if description is not None:
|
|
||||||
match = PLACE_RE.search(unescape(description.value))
|
|
||||||
if match:
|
|
||||||
parts.append(" ".join(match.group(0).split()))
|
|
||||||
location = _first(event, "LOCATION")
|
|
||||||
if location is not None:
|
|
||||||
flat = " ".join(unescape(location.value).split())
|
|
||||||
if flat:
|
|
||||||
parts.append(flat)
|
|
||||||
return _flat(", ".join(parts), PLACE_MAX)
|
|
||||||
|
|
||||||
|
|
||||||
def events_in(text: str, *, zone: tzinfo, since: datetime, days: int) -> list[Event]:
|
|
||||||
"""События фида, начинающиеся в окне `[since, since + days]`, с повторами."""
|
|
||||||
end = since + timedelta(days=days)
|
|
||||||
day_start = since.astimezone(zone).replace(
|
|
||||||
hour=0, minute=0, second=0, microsecond=0
|
|
||||||
)
|
|
||||||
parsed = vevents(unfold(text))
|
|
||||||
moved: dict[str, set[datetime]] = {}
|
|
||||||
for event in parsed:
|
|
||||||
uid = _first(event, "UID")
|
|
||||||
if uid is not None and "RECURRENCE-ID" in event:
|
|
||||||
moved.setdefault(uid.value, set()).update(
|
|
||||||
_instants(event, "RECURRENCE-ID", zone)
|
|
||||||
)
|
|
||||||
out: list[Event] = []
|
|
||||||
for event in parsed:
|
|
||||||
status = _first(event, "STATUS")
|
|
||||||
dtstart = _first(event, "DTSTART")
|
|
||||||
if dtstart is None or (
|
|
||||||
status is not None and status.value.upper() == "CANCELLED"
|
|
||||||
):
|
|
||||||
continue
|
|
||||||
parsed_start = moment(dtstart, dtstart.value, zone)
|
|
||||||
if parsed_start is None:
|
|
||||||
continue
|
|
||||||
start, all_day = parsed_start
|
|
||||||
rrule = _first(event, "RRULE")
|
|
||||||
if rrule is None or "RECURRENCE-ID" in event:
|
|
||||||
starts = [start]
|
|
||||||
skip: set[datetime] = set()
|
|
||||||
routine = False
|
|
||||||
else:
|
|
||||||
rule = _rule(rrule.value)
|
|
||||||
starts = occurrences(start, rule, zone, end=end)
|
|
||||||
routine = is_routine(rule)
|
|
||||||
uid = _first(event, "UID")
|
|
||||||
skip = _instants(event, "EXDATE", zone) | moved.get(
|
|
||||||
uid.value if uid else "", set()
|
|
||||||
)
|
|
||||||
title = _flat(_text(event, "SUMMARY"), TITLE_MAX) or NO_TITLE
|
|
||||||
place = place_of(event)
|
|
||||||
for moving in starts:
|
|
||||||
floor = day_start if all_day else since
|
|
||||||
if moving < floor or moving > end or moving.astimezone(UTC) in skip:
|
|
||||||
continue
|
|
||||||
out.append(
|
|
||||||
Event(
|
|
||||||
start=moving,
|
|
||||||
title=title,
|
|
||||||
place=place,
|
|
||||||
all_day=all_day,
|
|
||||||
routine=routine,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _text(event: Vevent, name: str) -> str:
|
|
||||||
prop = _first(event, name)
|
|
||||||
return prop.value if prop is not None else ""
|
|
||||||
@@ -1,501 +0,0 @@
|
|||||||
"""Взаимный сторож с чужим агентом: отметка раз в сутки и тревога на молчание.
|
|
||||||
|
|
||||||
Договорённость с соседом (22.09.2026): в отдельной комнате вайбграма оба
|
|
||||||
раз в сутки печатают строку `hb <ник> at=… boot=… every=… src=…`, и каждый
|
|
||||||
следит за молчанием другого. Замолчал дольше порога - человеку соседа
|
|
||||||
уходит сообщение, что цепь молчит.
|
|
||||||
|
|
||||||
Смысл сторожа в том, чтобы он не врал: прибор, который молча не сторожит,
|
|
||||||
хуже отсутствующего - он выглядит исправным. Поэтому дефолтов здесь нет
|
|
||||||
нигде. Период своей отметки считается из фактического крона джоба
|
|
||||||
(`every_from_cron`), и расписание, которое не сводится к «раз в N часов»,
|
|
||||||
даёт честное `every=unknown`, а не подставленное число. Порог тревоги -
|
|
||||||
`every * 1.5`, где `every` взят ИЗ СТРОКИ СОСЕДА, а не из своей константы.
|
|
||||||
Каждый прогон, в котором показание снять нельзя (окно смотрит в прошлое,
|
|
||||||
окно не дочитано, период неизвестен, отметок не видели ни разу), говорит
|
|
||||||
это вслух в `ack` и в лог: «в окне никого» и «окно не прочитано до конца» -
|
|
||||||
это два РАЗНЫХ показания, и выглядеть одинаково они не должны.
|
|
||||||
|
|
||||||
Окно - последние `WINDOW` СООБЩЕНИЙ этой комнаты (`Vibegram.window`), а не
|
|
||||||
«что нового с курсора»: курсор отдаёт строку соседа один раз, и следующий
|
|
||||||
прогон на живой комнате увидел бы пустое окно.
|
|
||||||
|
|
||||||
Опора тревоги - `seen`, то есть когда Я ВПЕРВЫЕ увидел строку: `at=` внутри
|
|
||||||
строки это показание соседа о себе, и остановившиеся у него часы не должны
|
|
||||||
выглядеть как живая цепь. Окно - это последние сообщения комнаты, поэтому
|
|
||||||
одна и та же отметка видна в нём много прогонов подряд, и новой её делает
|
|
||||||
новый id сообщения, а не текст: двигать `seen` на старой отметке значит
|
|
||||||
никогда не поднять тревогу в тихой комнате.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import itertools
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import UTC, datetime, timedelta
|
|
||||||
from pathlib import Path # noqa: TC003 - в аннотации dataclass
|
|
||||||
from typing import TYPE_CHECKING, Literal
|
|
||||||
|
|
||||||
from croniter import CroniterError, croniter
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import Sequence
|
|
||||||
from datetime import tzinfo
|
|
||||||
|
|
||||||
from beaver_agent.hands.vibegram import Event
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"BOOT",
|
|
||||||
"FACTOR",
|
|
||||||
"PEER",
|
|
||||||
"WINDOW",
|
|
||||||
"Beat",
|
|
||||||
"Report",
|
|
||||||
"Watch",
|
|
||||||
"ack_line",
|
|
||||||
"alarm_text",
|
|
||||||
"beat_line",
|
|
||||||
"drill",
|
|
||||||
"every_from_cron",
|
|
||||||
"look",
|
|
||||||
"parse_beat",
|
|
||||||
]
|
|
||||||
|
|
||||||
PEER = "claude-kai"
|
|
||||||
"""Чей `hb` сторожим. Ник соседа стоит и в его строке, и в нашем `ack`."""
|
|
||||||
WINDOW = 25
|
|
||||||
"""Сколько последних сообщений комнаты просим явно: молча опираться на чужой
|
|
||||||
дефолт в стороже нельзя - размер окна едет в `ack`."""
|
|
||||||
FACTOR = 1.5
|
|
||||||
"""Порог молчания = заявленный соседом период × это. Одна потерянная строка
|
|
||||||
не должна поднимать тревогу, две подряд - должны."""
|
|
||||||
UNARMED_GRACE = timedelta(days=7)
|
|
||||||
"""Сколько терпим `armed=no`, прежде чем сказать человеку, что кольцо так и
|
|
||||||
не заработало: недостроенное иначе тихо становится постоянным."""
|
|
||||||
REPEAT = timedelta(hours=24)
|
|
||||||
"""Тревога повторяется не чаще этого, а не каждый прогон."""
|
|
||||||
PROBES = 5
|
|
||||||
"""Сколько срабатываний крона сверяем между собой, выводя период."""
|
|
||||||
STATE_VERSION = 1
|
|
||||||
|
|
||||||
BOOT = datetime.now(UTC)
|
|
||||||
"""Время старта процесса: модуль импортируется при сборке конфига gateway."""
|
|
||||||
|
|
||||||
_FIELD = re.compile(r"(\w+)=(\S+)")
|
|
||||||
_UNITS = {"h": 3600, "m": 60, "s": 1}
|
|
||||||
_SPAN = re.compile(r"^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$")
|
|
||||||
|
|
||||||
|
|
||||||
def _iso(when: datetime | None) -> str | None:
|
|
||||||
if when is None:
|
|
||||||
return None
|
|
||||||
return when.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_iso(raw: str | None) -> datetime | None:
|
|
||||||
if not raw:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
when = datetime.fromisoformat(raw)
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
return when if when.tzinfo else when.replace(tzinfo=UTC)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_span(raw: str) -> timedelta | None:
|
|
||||||
"""`24h`, `90m`, `36h30m` - в timedelta; `unknown` и мусор - в None."""
|
|
||||||
match = _SPAN.fullmatch(raw.strip())
|
|
||||||
if match is None or not any(match.groups()):
|
|
||||||
return None
|
|
||||||
seconds = sum(
|
|
||||||
int(value) * _UNITS[unit]
|
|
||||||
for value, unit in zip(match.groups(), "hms", strict=True)
|
|
||||||
if value
|
|
||||||
)
|
|
||||||
return timedelta(seconds=seconds) or None
|
|
||||||
|
|
||||||
|
|
||||||
def span(delta: timedelta | None) -> str:
|
|
||||||
"""Период в строку: неизвестный период так и называется, а не молчит."""
|
|
||||||
if delta is None:
|
|
||||||
return "unknown"
|
|
||||||
seconds = int(delta.total_seconds())
|
|
||||||
if seconds % 3600 == 0:
|
|
||||||
return f"{seconds // 3600}h"
|
|
||||||
if seconds % 60 == 0:
|
|
||||||
return f"{seconds // 60}m"
|
|
||||||
return f"{seconds}s"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class Beat:
|
|
||||||
"""Разобранная строка отметки. `at` не читается - строка всё равно жива."""
|
|
||||||
|
|
||||||
nick: str
|
|
||||||
at: datetime | None
|
|
||||||
boot: datetime | None
|
|
||||||
every: timedelta | None
|
|
||||||
every_raw: str
|
|
||||||
|
|
||||||
|
|
||||||
def parse_beat(text: str, kind: str = "hb") -> Beat | None:
|
|
||||||
"""Строку `hb <ник> ключ=значение …` из текста события; иначе None.
|
|
||||||
|
|
||||||
Текст события хаба отрендерен как `<кто>: <тело>`, поэтому ищем метку
|
|
||||||
внутри строки, а не с её начала.
|
|
||||||
"""
|
|
||||||
head = re.search(rf"(?:^|\s){re.escape(kind)}\s+(\S+)", text)
|
|
||||||
if head is None:
|
|
||||||
return None
|
|
||||||
fields = dict(_FIELD.findall(text[head.end() :]))
|
|
||||||
every_raw = fields.get("every", "")
|
|
||||||
return Beat(
|
|
||||||
nick=head.group(1),
|
|
||||||
at=_parse_iso(fields.get("at")),
|
|
||||||
boot=_parse_iso(fields.get("boot")),
|
|
||||||
every=parse_span(every_raw) if every_raw else None,
|
|
||||||
every_raw=every_raw or "unknown",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def every_from_cron(
|
|
||||||
expression: str | None,
|
|
||||||
tz: tzinfo,
|
|
||||||
*,
|
|
||||||
now: datetime | None = None,
|
|
||||||
grain: timedelta = timedelta(hours=1),
|
|
||||||
) -> timedelta | None:
|
|
||||||
"""Шаг крона, если он сводится к «раз в N `grain`»; иначе None.
|
|
||||||
|
|
||||||
Считаем по стенным часам зоны, в которой планировщик читает кроны:
|
|
||||||
суточный крон - это 24 часа и в сутки перевода стрелок тоже, а вот
|
|
||||||
`0 2,8,12,16,20 * * *` не сводится ни к какому N и даёт None. `grain`
|
|
||||||
по умолчанию час - такой период печатается в отметке соседу; сторожу
|
|
||||||
прогонов нужен и `*/10`, он просит минуту.
|
|
||||||
"""
|
|
||||||
if not expression:
|
|
||||||
return None
|
|
||||||
start = (now or datetime.now(UTC)).astimezone(tz).replace(tzinfo=None)
|
|
||||||
try:
|
|
||||||
clock = croniter(expression, start_time=start)
|
|
||||||
fires = [clock.get_next(datetime) for _ in range(PROBES + 1)]
|
|
||||||
except (CroniterError, ValueError):
|
|
||||||
return None
|
|
||||||
steps = {b - a for a, b in itertools.pairwise(fires)}
|
|
||||||
if len(steps) != 1:
|
|
||||||
return None
|
|
||||||
step = steps.pop()
|
|
||||||
return step if step % grain == timedelta(0) else None
|
|
||||||
|
|
||||||
|
|
||||||
def beat_line(
|
|
||||||
*, nick: str, at: datetime, boot: datetime, every: timedelta | None
|
|
||||||
) -> str:
|
|
||||||
"""Своя отметка. Период не выведен - так и пишем, числа не выдумываем."""
|
|
||||||
return (
|
|
||||||
f"hb {nick} at={_iso(at)} boot={_iso(boot)} "
|
|
||||||
f"every={span(every)} src={'cron' if every else 'none'}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def ack_line(
|
|
||||||
*,
|
|
||||||
peer: str,
|
|
||||||
last: datetime | None,
|
|
||||||
seen: datetime | None,
|
|
||||||
res: timedelta | None,
|
|
||||||
window: int,
|
|
||||||
stale: bool,
|
|
||||||
armed: bool,
|
|
||||||
threshold: timedelta | None,
|
|
||||||
fallback: bool,
|
|
||||||
full: bool,
|
|
||||||
alert: Literal["fired", "held", "none"],
|
|
||||||
) -> str:
|
|
||||||
"""Расписка о прогоне в комнату: что видели, чем мерили и что решили.
|
|
||||||
|
|
||||||
`win=` - про само окно: `25` это «последние 25 сообщений комнаты прочитаны
|
|
||||||
целиком», `25/partial` - «дочитать историю не удалось», `stale` - «окно
|
|
||||||
смотрит в прошлое». Пустое окно при `partial` ничего не доказывает.
|
|
||||||
|
|
||||||
`alert=` - РЕШЕНИЕ ПРИБОРА в этом прогоне, а не факт того, что человек
|
|
||||||
сосед что-то получил: `fired` - тревога (`silence` или `unarmed`) в этом
|
|
||||||
прогоне взята и отдана дальше, `held` - порог пройден, но тревога
|
|
||||||
подавлена как повтор (предыдущая уходила меньше `REPEAT` назад), `none` -
|
|
||||||
тревоги нет. Доставка решается за пределами сторожа - мастером и
|
|
||||||
диспетчером, - и её отказ виден в логе, а не тут: без арифметики соседу
|
|
||||||
видно было только, что мы что-то посчитали, но не что мы из этого поняли.
|
|
||||||
"""
|
|
||||||
mark = span(threshold) + ("-fallback" if fallback and threshold else "")
|
|
||||||
read = "stale" if stale else f"{window}" if full else f"{window}/partial"
|
|
||||||
return (
|
|
||||||
f"ack {peer} last={_iso(last) or ('never' if not armed else 'unknown')} "
|
|
||||||
f"seen={_iso(seen) or 'never'} res={span(res)} "
|
|
||||||
f"win={read} armed={'yes' if armed else 'no'} "
|
|
||||||
f"thr={mark} alert={alert}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def drill(path: Path, *, now: datetime, tz: tzinfo) -> bool:
|
|
||||||
"""Стоит ли сегодняшняя дата в файле флага учений.
|
|
||||||
|
|
||||||
Файл - список дат `YYYY-MM-DD`, по одной на строку; дата - локальная дата
|
|
||||||
прогона в зоне планировщика, той же, в которой читаются кроны. Файла нет,
|
|
||||||
он пуст или нечитаем - учений нет, и отметка уходит как обычно: флаг
|
|
||||||
выключает прибор, поэтому его отсутствие должно значить «работаем».
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
raw = path.read_text(encoding="utf-8")
|
|
||||||
except OSError:
|
|
||||||
return False
|
|
||||||
today = now.astimezone(tz).strftime("%Y-%m-%d")
|
|
||||||
return today in {line.strip() for line in raw.splitlines()}
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
|
||||||
class Watch:
|
|
||||||
"""Состояние между прогонами: файл на диске, переживает передеплой."""
|
|
||||||
|
|
||||||
path: Path
|
|
||||||
at: datetime | None = None
|
|
||||||
"""`at=` из последней виденной отметки соседа - его показание о себе."""
|
|
||||||
seen: datetime | None = None
|
|
||||||
"""Когда МЫ увидели ту отметку. На это опирается тревога."""
|
|
||||||
every: timedelta | None = None
|
|
||||||
"""Последний период, который сосед назвал читаемым числом."""
|
|
||||||
every_unknown: bool = False
|
|
||||||
"""В последней отметке стояло `every=unknown`: порог взят с запасом назад."""
|
|
||||||
beat_id: int = 0
|
|
||||||
"""id сообщения с последней зачтённой отметкой: по нему видно, что отметка
|
|
||||||
в окне новая, а не та же. Нуль - поля в файле не было (состояние старое)
|
|
||||||
или отметок не видели ни разу."""
|
|
||||||
top: int = 0
|
|
||||||
"""Максимальный id, который мы видели. Поехал назад - окно смотрит в прошлое."""
|
|
||||||
alerted: datetime | None = None
|
|
||||||
unarmed_since: datetime | None = None
|
|
||||||
unarmed_told: bool = False
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def load(cls, path: Path) -> Watch:
|
|
||||||
try:
|
|
||||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
||||||
except (OSError, ValueError):
|
|
||||||
return cls(path=path)
|
|
||||||
if raw.get("version") != STATE_VERSION:
|
|
||||||
return cls(path=path)
|
|
||||||
every = raw.get("every")
|
|
||||||
return cls(
|
|
||||||
path=path,
|
|
||||||
at=_parse_iso(raw.get("at")),
|
|
||||||
seen=_parse_iso(raw.get("seen")),
|
|
||||||
every=timedelta(seconds=every) if every else None,
|
|
||||||
every_unknown=bool(raw.get("every_unknown")),
|
|
||||||
beat_id=int(raw.get("beat_id", 0)),
|
|
||||||
top=int(raw.get("top", 0)),
|
|
||||||
alerted=_parse_iso(raw.get("alerted")),
|
|
||||||
unarmed_since=_parse_iso(raw.get("unarmed_since")),
|
|
||||||
unarmed_told=bool(raw.get("unarmed_told")),
|
|
||||||
)
|
|
||||||
|
|
||||||
def save(self) -> None:
|
|
||||||
body = {
|
|
||||||
"version": STATE_VERSION,
|
|
||||||
"at": _iso(self.at),
|
|
||||||
"seen": _iso(self.seen),
|
|
||||||
"every": int(self.every.total_seconds()) if self.every else None,
|
|
||||||
"every_unknown": self.every_unknown,
|
|
||||||
"beat_id": self.beat_id,
|
|
||||||
"top": self.top,
|
|
||||||
"alerted": _iso(self.alerted),
|
|
||||||
"unarmed_since": _iso(self.unarmed_since),
|
|
||||||
"unarmed_told": self.unarmed_told,
|
|
||||||
}
|
|
||||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
tmp = self.path.with_suffix(".tmp")
|
|
||||||
tmp.write_text(json.dumps(body, ensure_ascii=False, indent=1), encoding="utf-8")
|
|
||||||
tmp.replace(self.path)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class Report:
|
|
||||||
"""Что решил один прогон сторожа."""
|
|
||||||
|
|
||||||
ack: str
|
|
||||||
stale: bool
|
|
||||||
full: bool
|
|
||||||
"""Окно дочитано до конца истории комнаты: пустое окно - это показание."""
|
|
||||||
armed: bool
|
|
||||||
heard: bool
|
|
||||||
"""Видели ли в окне хоть что-то от соседа - `hb` или его `ack`."""
|
|
||||||
age: timedelta | None
|
|
||||||
threshold: timedelta | None
|
|
||||||
alarm: Literal["silence", "unarmed"] | None
|
|
||||||
note: str
|
|
||||||
"""Строка для лога: почему прогон решил именно так."""
|
|
||||||
|
|
||||||
|
|
||||||
def _is_new_beat(state: Watch, eid: int, beat: Beat) -> bool:
|
|
||||||
"""Впервые ли мы видим ЭТУ отметку соседа.
|
|
||||||
|
|
||||||
Различитель - id сообщения: `at=` внутри строки это показание соседа о
|
|
||||||
себе, и повторить его он может как угодно. Нуля в `beat_id` два повода:
|
|
||||||
отметок не видели ни разу (тогда любая - новая) и состояние записано до
|
|
||||||
появления поля - там единственное, чем можно сверить отметку со зачтённой,
|
|
||||||
это её `at`, и одного прогона хватает, чтобы запомнить id.
|
|
||||||
"""
|
|
||||||
if state.beat_id:
|
|
||||||
return eid > state.beat_id
|
|
||||||
if state.seen is None:
|
|
||||||
return True
|
|
||||||
return beat.at != state.at
|
|
||||||
|
|
||||||
|
|
||||||
def look(
|
|
||||||
state: Watch,
|
|
||||||
events: Sequence[Event],
|
|
||||||
*,
|
|
||||||
now: datetime,
|
|
||||||
res: timedelta | None,
|
|
||||||
full: bool,
|
|
||||||
window: int = WINDOW,
|
|
||||||
peer: str = PEER,
|
|
||||||
) -> Report:
|
|
||||||
"""Разобрать окно, обновить состояние и решить, будить ли человека.
|
|
||||||
|
|
||||||
`events` - последние сообщения комнаты, `full` - дочитаны ли они до конца
|
|
||||||
истории. Параметр без дефолта нарочно: «окно полное» - это показание, и
|
|
||||||
подставлять его молча за того, кто окно читал, здесь нельзя.
|
|
||||||
|
|
||||||
Состояние меняется здесь, а `alerted` / `unarmed_told` ставит джоб - и
|
|
||||||
только когда тревога действительно ушла: несостоявшаяся доставка не
|
|
||||||
должна заглушать следующий прогон.
|
|
||||||
"""
|
|
||||||
top = max((e.id for e in events), default=None)
|
|
||||||
stale = top is not None and top < state.top
|
|
||||||
beats = [
|
|
||||||
(event.id, beat)
|
|
||||||
for event, beat in ((event, parse_beat(event.text)) for event in events)
|
|
||||||
if beat is not None and beat.nick == peer
|
|
||||||
]
|
|
||||||
heard = bool(beats) or any(e.nick == peer for e in events)
|
|
||||||
if stale:
|
|
||||||
note = f"окно поехало назад: максимальный id {top} против {state.top}"
|
|
||||||
else:
|
|
||||||
if top is not None:
|
|
||||||
state.top = max(state.top, top)
|
|
||||||
again = False
|
|
||||||
if beats:
|
|
||||||
eid, last = beats[-1]
|
|
||||||
again = not _is_new_beat(state, eid, last)
|
|
||||||
if not again:
|
|
||||||
state.seen = now
|
|
||||||
state.at, state.beat_id = last.at, eid
|
|
||||||
state.every_unknown = last.every is None
|
|
||||||
if last.every is not None:
|
|
||||||
state.every = last.every
|
|
||||||
read = "история дочитана" if full else "история НЕ дочитана"
|
|
||||||
note = (
|
|
||||||
f"отметок соседа в окне: {len(beats)} "
|
|
||||||
f"(сообщений комнаты: {len(events)} из {window}, {read})"
|
|
||||||
)
|
|
||||||
if again:
|
|
||||||
note += "; отметка та же, что в прошлый прогон - seen не двигаем"
|
|
||||||
|
|
||||||
armed = state.seen is not None
|
|
||||||
threshold = state.every * FACTOR if state.every is not None else None
|
|
||||||
age = now - state.seen if state.seen is not None else None
|
|
||||||
|
|
||||||
if armed:
|
|
||||||
state.unarmed_since = None
|
|
||||||
elif state.unarmed_since is None:
|
|
||||||
state.unarmed_since = now
|
|
||||||
|
|
||||||
alarm: Literal["silence", "unarmed"] | None = None
|
|
||||||
held = False
|
|
||||||
if stale:
|
|
||||||
pass
|
|
||||||
elif not full:
|
|
||||||
# Хвост комнаты недочитан, а недостающее - как раз самое свежее:
|
|
||||||
# пустое окно тут не доказывает молчания соседа, оно доказывает,
|
|
||||||
# что мы не дочитали. Показания нет - тревоги нет.
|
|
||||||
note += "; окно не дочитано - это не показание, тревога не берётся"
|
|
||||||
elif not armed:
|
|
||||||
if (
|
|
||||||
not state.unarmed_told
|
|
||||||
and state.unarmed_since is not None
|
|
||||||
and now - state.unarmed_since >= UNARMED_GRACE
|
|
||||||
):
|
|
||||||
alarm = "unarmed"
|
|
||||||
elif threshold is None:
|
|
||||||
note += "; период соседа ни разу не прочитан - мерить нечем, тревога не берётся"
|
|
||||||
elif age is not None and age > threshold:
|
|
||||||
if state.alerted is None or now - state.alerted >= REPEAT:
|
|
||||||
alarm = "silence"
|
|
||||||
else:
|
|
||||||
held = True
|
|
||||||
note += "; тревога уже уходила меньше суток назад"
|
|
||||||
|
|
||||||
return Report(
|
|
||||||
ack=ack_line(
|
|
||||||
peer=peer,
|
|
||||||
last=state.at,
|
|
||||||
seen=state.seen,
|
|
||||||
res=res,
|
|
||||||
window=window,
|
|
||||||
stale=stale,
|
|
||||||
armed=armed,
|
|
||||||
threshold=threshold,
|
|
||||||
fallback=state.every_unknown,
|
|
||||||
full=full,
|
|
||||||
alert="fired" if alarm is not None else "held" if held else "none",
|
|
||||||
),
|
|
||||||
stale=stale,
|
|
||||||
full=full,
|
|
||||||
armed=armed,
|
|
||||||
heard=heard,
|
|
||||||
age=age,
|
|
||||||
threshold=threshold,
|
|
||||||
alarm=alarm,
|
|
||||||
note=note,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def alarm_text(report: Report, *, peer: str = PEER, mine_sent: bool) -> str:
|
|
||||||
"""Текст человеку соседа. Различитель канала - последним абзацем."""
|
|
||||||
if report.alarm == "unarmed":
|
|
||||||
return (
|
|
||||||
f"Сторожевое кольцо с {peer} так и не заработало: за семь суток "
|
|
||||||
f"в сторожевой комнате не было ни одной его отметки `hb`, а значит "
|
|
||||||
f"сторожить нечего - прибор не вооружён (`armed=no`). Свою отметку "
|
|
||||||
f"мы печатаем и свой `ack` тоже"
|
|
||||||
+ ("" if mine_sent else ", но сейчас она в комнату не ушла")
|
|
||||||
+ ". Проверить стоит три вещи: та ли это комната (токен "
|
|
||||||
"`VIBEGRAM_HB_TOKEN` мог остаться от другой), запустил ли сосед "
|
|
||||||
"свою половину, и виден ли нам вообще чужой текст в этой комнате."
|
|
||||||
)
|
|
||||||
age = span(report.age) if report.age else "?"
|
|
||||||
threshold = span(report.threshold) if report.threshold else "?"
|
|
||||||
channel = (
|
|
||||||
"Наша собственная запись в комнату в этом же прогоне прошла, а от "
|
|
||||||
f"{peer} нет НИ `hb`, НИ `ack` - значит канал подозревать надо раньше, "
|
|
||||||
"чем смерть соседа: пишем мы в комнату, но чужого оттуда не читаем."
|
|
||||||
if mine_sent and not report.heard
|
|
||||||
else (
|
|
||||||
"Наша собственная запись в комнату в этом прогоне НЕ прошла - "
|
|
||||||
"первым делом канал, а не сосед."
|
|
||||||
if not mine_sent
|
|
||||||
else f"Что-то от {peer} в окне было (его `ack` или другая строка), "
|
|
||||||
"так что канал скорее жив, а молчит именно отметка."
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
f"Сторожевая цепь молчит: последнюю отметку `hb {peer}` мы видели "
|
|
||||||
f"{age} назад при пороге {threshold} (порог - период из его же строки "
|
|
||||||
"× 1.5). Причин может быть три: машина соседа, его крон, или канал - "
|
|
||||||
f"сама комната. {channel}\n"
|
|
||||||
f"Это дело человека {peer}, а не наше: разбудить владельца имеет смысл "
|
|
||||||
"только чтобы он написал соседу по своему каналу. Повторно об этом "
|
|
||||||
"молчании мы скажем не раньше чем через сутки."
|
|
||||||
)
|
|
||||||
+13
-127
@@ -37,29 +37,6 @@ Action = Literal[
|
|||||||
ANSI = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]")
|
ANSI = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]")
|
||||||
EXIT_MARK = "__KOMODO_EXIT_CODE:"
|
EXIT_MARK = "__KOMODO_EXIT_CODE:"
|
||||||
NOT_RUNNING_LIMIT = 15
|
NOT_RUNNING_LIMIT = 15
|
||||||
UPDATES_LIMIT = 15
|
|
||||||
UPDATES_MAX = 200
|
|
||||||
|
|
||||||
LIST_LIMIT = 500
|
|
||||||
LIST_PAGES = 20
|
|
||||||
"""Komodo режет любой ``List*`` до своего ``default_pagination_limit`` (у нас
|
|
||||||
50) и молчит об этом: ответ - просто короткий массив, неотличимый от полного.
|
|
||||||
Поэтому листаем сами, а не верим первой странице."""
|
|
||||||
|
|
||||||
RESOURCE_LISTS = (
|
|
||||||
"ListStacks",
|
|
||||||
"ListServers",
|
|
||||||
"ListRepos",
|
|
||||||
"ListResourceSyncs",
|
|
||||||
"ListProcedures",
|
|
||||||
"ListActions",
|
|
||||||
"ListAlerters",
|
|
||||||
"ListDeployments",
|
|
||||||
"ListBuilds",
|
|
||||||
)
|
|
||||||
"""Журнал операций называет ресурс id, а не именем: отсюда берём словарь."""
|
|
||||||
|
|
||||||
SCOPE_NOTE = "список ограничен правами ключа: вне их Komodo ресурсы не показывает"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -88,9 +65,6 @@ class Komodo:
|
|||||||
tail: int = 100,
|
tail: int = 100,
|
||||||
update: str | None = None,
|
update: str | None = None,
|
||||||
server: str | None = None,
|
server: str | None = None,
|
||||||
limit: int = UPDATES_LIMIT,
|
|
||||||
operation: str | None = None,
|
|
||||||
resource: str | None = None,
|
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Прод через Komodo: посмотреть, а по просьбе Бобра - подействовать.
|
"""Прод через Komodo: посмотреть, а по просьбе Бобра - подействовать.
|
||||||
|
|
||||||
@@ -100,11 +74,7 @@ class Komodo:
|
|||||||
- containers: контейнеры сервера (server; без него - все серверы).
|
- containers: контейнеры сервера (server; без него - все серверы).
|
||||||
- logs: логи стека (stack, service опционально, tail строк).
|
- logs: логи стека (stack, service опционально, tail строк).
|
||||||
- search_logs: поиск по логам стека (stack, terms - слова через пробел).
|
- search_logs: поиск по логам стека (stack, terms - слова через пробел).
|
||||||
- updates: последние операции - кто, что, статус. limit записей
|
- updates: последние операции - кто, что, статус.
|
||||||
(по умолчанию 15, максимум 200), operation - подстрока типа операции
|
|
||||||
(deploy, sync, restart), resource - имя стека, сервера, репозитория.
|
|
||||||
Шапка говорит, какой отрезок журнала показан: журнал длинный, и
|
|
||||||
«под фильтр ничего не попало» - это не «операций больше не было».
|
|
||||||
- update: подробности одной операции по id (update).
|
- update: подробности одной операции по id (update).
|
||||||
- deploy / restart: стек целиком или один service; ждёт исхода до 3 мин.
|
- deploy / restart: стек целиком или один service; ждёт исхода до 3 мин.
|
||||||
- exec: команда внутри контейнера (container, command; server, если имя
|
- exec: команда внутри контейнера (container, command; server, если имя
|
||||||
@@ -128,7 +98,7 @@ class Komodo:
|
|||||||
_need(stack, "stack"), _need(terms, "terms"), service
|
_need(stack, "stack"), _need(terms, "terms"), service
|
||||||
)
|
)
|
||||||
case "updates":
|
case "updates":
|
||||||
return await self._updates(limit, operation, resource)
|
return await self._updates()
|
||||||
case "update":
|
case "update":
|
||||||
return await self._update(_need(update, "update"))
|
return await self._update(_need(update, "update"))
|
||||||
case "deploy":
|
case "deploy":
|
||||||
@@ -148,7 +118,7 @@ class Komodo:
|
|||||||
|
|
||||||
async def _status(self) -> str:
|
async def _status(self) -> str:
|
||||||
servers, stacks = await asyncio.gather(
|
servers, stacks = await asyncio.gather(
|
||||||
self.listing("ListServers"), self.listing("ListStacks")
|
self.read("ListServers", {}), self.read("ListStacks", {})
|
||||||
)
|
)
|
||||||
lines = [f"{s['name']:24} {s['info']['state']}" for s in servers]
|
lines = [f"{s['name']:24} {s['info']['state']}" for s in servers]
|
||||||
bad = [s for s in stacks if s["info"]["state"] != "running"]
|
bad = [s for s in stacks if s["info"]["state"] != "running"]
|
||||||
@@ -157,15 +127,10 @@ class Komodo:
|
|||||||
f" {s['info']['state']:12} {s['name']} @{s['info'].get('server_name')}"
|
f" {s['info']['state']:12} {s['name']} @{s['info'].get('server_name')}"
|
||||||
for s in bad[:NOT_RUNNING_LIMIT]
|
for s in bad[:NOT_RUNNING_LIMIT]
|
||||||
)
|
)
|
||||||
if len(bad) > NOT_RUNNING_LIMIT:
|
|
||||||
lines.append(
|
|
||||||
f"показаны {NOT_RUNNING_LIMIT} из {len(bad)}, остальные скрыты лимитом"
|
|
||||||
)
|
|
||||||
lines.append(SCOPE_NOTE)
|
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
async def _stacks(self) -> str:
|
async def _stacks(self) -> str:
|
||||||
stacks = await self.listing("ListStacks")
|
stacks = await self.read("ListStacks", {})
|
||||||
lines = []
|
lines = []
|
||||||
for s in sorted(stacks, key=lambda s: s["name"]):
|
for s in sorted(stacks, key=lambda s: s["name"]):
|
||||||
info = s["info"]
|
info = s["info"]
|
||||||
@@ -176,18 +141,17 @@ class Komodo:
|
|||||||
f"{info['state']:12} {s['name']:28} @{info.get('server_name')}"
|
f"{info['state']:12} {s['name']:28} @{info.get('server_name')}"
|
||||||
+ (" (есть незадеплоенные коммиты)" if behind else "")
|
+ (" (есть незадеплоенные коммиты)" if behind else "")
|
||||||
)
|
)
|
||||||
lines.append(f"стеков: {len(stacks)}; {SCOPE_NOTE}")
|
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
async def _containers(self, server: str | None) -> str:
|
async def _containers(self, server: str | None) -> str:
|
||||||
names = (
|
names = (
|
||||||
[server]
|
[server]
|
||||||
if server
|
if server
|
||||||
else [s["name"] for s in await self.listing("ListServers")]
|
else [s["name"] for s in await self.read("ListServers", {})]
|
||||||
)
|
)
|
||||||
lines = []
|
lines = []
|
||||||
for name in names:
|
for name in names:
|
||||||
containers = await self.listing("ListDockerContainers", {"server": name})
|
containers = await self.read("ListDockerContainers", {"server": name})
|
||||||
lines.append(f"{name}:")
|
lines.append(f"{name}:")
|
||||||
lines.extend(
|
lines.extend(
|
||||||
f" {c['state']:10} {c['name']:36} {c.get('status', '')}"
|
f" {c['state']:10} {c['name']:36} {c.get('status', '')}"
|
||||||
@@ -213,67 +177,9 @@ class Komodo:
|
|||||||
)
|
)
|
||||||
return self._clip(_log_text(log))
|
return self._clip(_log_text(log))
|
||||||
|
|
||||||
async def _updates(
|
async def _updates(self) -> str:
|
||||||
self, limit: int, operation: str | None, resource: str | None
|
updates = (await self.read("ListUpdates", {}))["updates"][:15]
|
||||||
) -> str:
|
return "\n".join(_update_line(u) for u in updates) or "операций нет"
|
||||||
want = min(max(limit, 1), UPDATES_MAX)
|
|
||||||
names = await self._resource_names()
|
|
||||||
query: dict[str, Any] = {}
|
|
||||||
applied = []
|
|
||||||
if operation:
|
|
||||||
query["operation"] = {"$regex": re.escape(operation), "$options": "i"}
|
|
||||||
applied.append(f"операция ~{operation}")
|
|
||||||
if resource:
|
|
||||||
ids = [i for i, name in names.items() if resource.lower() in name.lower()]
|
|
||||||
if not ids:
|
|
||||||
return (
|
|
||||||
f"ресурса ~{resource} ключу руки не видно: "
|
|
||||||
"либо имя другое, либо он вне прав ключа"
|
|
||||||
)
|
|
||||||
query["target.id"] = {"$in": ids}
|
|
||||||
applied.append(f"ресурс ~{resource} ({len(ids)} шт)")
|
|
||||||
updates, more = await self._update_pages(query, want)
|
|
||||||
|
|
||||||
head = [f"операций: {len(updates)}"]
|
|
||||||
if updates:
|
|
||||||
head.append(
|
|
||||||
f"{_ts(updates[-1]['start_ts'])} … {_ts(updates[0]['start_ts'])}"
|
|
||||||
)
|
|
||||||
head.append("раньше в журнале есть ещё" if more else "это весь журнал")
|
|
||||||
lines = [", ".join(head)]
|
|
||||||
lines.append(
|
|
||||||
"фильтрует Komodo: " + "; ".join(applied)
|
|
||||||
if applied
|
|
||||||
else "фильтра нет: это просто хвост журнала"
|
|
||||||
)
|
|
||||||
if want != limit:
|
|
||||||
lines.append(f"запрошено {limit} записей, предел руки {want}")
|
|
||||||
unseen = sum(1 for u in updates if u["target"]["id"] not in names)
|
|
||||||
if unseen:
|
|
||||||
lines.append(
|
|
||||||
f"записей о невидимых ключу ресурсах: {unseen} - id вместо имени"
|
|
||||||
)
|
|
||||||
lines.extend(_update_line(u, names) for u in updates)
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
async def _update_pages(
|
|
||||||
self, query: dict[str, Any], want: int
|
|
||||||
) -> tuple[list[dict[str, Any]], bool]:
|
|
||||||
"""Страница ListUpdates - ровно 100 записей, limit там не работает."""
|
|
||||||
out: list[dict[str, Any]] = []
|
|
||||||
page: int | None = 0
|
|
||||||
while page is not None and len(out) < want:
|
|
||||||
body: dict[str, Any] = {"page": page}
|
|
||||||
if query:
|
|
||||||
body["query"] = query
|
|
||||||
answer = await self.read("ListUpdates", body)
|
|
||||||
out.extend(answer["updates"])
|
|
||||||
page = answer.get("next_page")
|
|
||||||
return out[:want], len(out) > want or page is not None
|
|
||||||
|
|
||||||
async def _resource_names(self) -> dict[str, str]:
|
|
||||||
lists = await asyncio.gather(*(self.listing(r) for r in RESOURCE_LISTS))
|
|
||||||
return {r["id"]: r["name"] for rows in lists for r in rows}
|
|
||||||
|
|
||||||
async def _update(self, update_id: str) -> str:
|
async def _update(self, update_id: str) -> str:
|
||||||
return self._clip(
|
return self._clip(
|
||||||
@@ -318,31 +224,12 @@ class Komodo:
|
|||||||
return f"{header}\n{self._clip(output.strip())}"
|
return f"{header}\n{self._clip(output.strip())}"
|
||||||
|
|
||||||
async def _server_of(self, container: str) -> str | None:
|
async def _server_of(self, container: str) -> str | None:
|
||||||
for s in await self.listing("ListServers"):
|
for s in await self.read("ListServers", {}):
|
||||||
containers = await self.listing(
|
containers = await self.read("ListDockerContainers", {"server": s["name"]})
|
||||||
"ListDockerContainers", {"server": s["name"]}
|
|
||||||
)
|
|
||||||
if any(c["name"] == container for c in containers):
|
if any(c["name"] == container for c in containers):
|
||||||
return s["name"]
|
return s["name"]
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def listing(
|
|
||||||
self, request: str, params: dict[str, Any] | None = None
|
|
||||||
) -> list[Any]:
|
|
||||||
"""Любой ``List*`` целиком: страницами, пока Komodo не отдаст неполную.
|
|
||||||
|
|
||||||
Публичная: этим же листанием ходят джобы, второго такого не пишем.
|
|
||||||
"""
|
|
||||||
out: list[Any] = []
|
|
||||||
for page in range(LIST_PAGES):
|
|
||||||
chunk = await self.read(
|
|
||||||
request, (params or {}) | {"page": page, "limit": LIST_LIMIT}
|
|
||||||
)
|
|
||||||
out.extend(chunk)
|
|
||||||
if len(chunk) < LIST_LIMIT:
|
|
||||||
break
|
|
||||||
return out
|
|
||||||
|
|
||||||
async def _json(self, route: str, body: dict[str, Any]) -> Any:
|
async def _json(self, route: str, body: dict[str, Any]) -> Any:
|
||||||
status, text = await self._post(route, body)
|
status, text = await self._post(route, body)
|
||||||
if status != 200:
|
if status != 200:
|
||||||
@@ -388,10 +275,9 @@ def _ts(ms: int) -> str:
|
|||||||
return datetime.fromtimestamp(ms / 1000, tz=UTC).strftime("%m-%d %H:%M")
|
return datetime.fromtimestamp(ms / 1000, tz=UTC).strftime("%m-%d %H:%M")
|
||||||
|
|
||||||
|
|
||||||
def _update_line(u: dict[str, Any], names: dict[str, str]) -> str:
|
def _update_line(u: dict[str, Any]) -> str:
|
||||||
ok = "ok" if u.get("success") else "FAIL"
|
ok = "ok" if u.get("success") else "FAIL"
|
||||||
target = names.get(u["target"]["id"], u["target"]["id"])
|
head = f"{_ts(u['start_ts'])} {u['operation']:20} {u['status']:10} {ok}"
|
||||||
head = f"{_ts(u['start_ts'])} {u['operation']:20} {target:26} {u['status']:10} {ok}"
|
|
||||||
return f"{head} user={u.get('username')} id={u.get('id')}"
|
return f"{head} user={u.get('username')} id={u.get('id')}"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -1,642 +0,0 @@
|
|||||||
"""Сторож собственных прогонов: журнал `job_runs` наконец кто-то читает.
|
|
||||||
|
|
||||||
Планировщик gateway пишет строку на каждый прогон джоба (`job_runs`: имя,
|
|
||||||
статус, хвост трейсбека в `error`). Запись была всегда, читателя не было
|
|
||||||
ни одного: канал к соседнему агенту лежал 38 часов подряд, планировщик
|
|
||||||
честно записал это 231 раз, и до человека не доехало ничего. Этот модуль -
|
|
||||||
вторая половина прибора: показание из таблицы превращается в тревогу.
|
|
||||||
|
|
||||||
Что здесь считается показанием:
|
|
||||||
|
|
||||||
- тревога берётся только с ХВОСТА: три и более `failed` подряд, считая от
|
|
||||||
последнего прогона джоба. Одиночный упавший прогон - сетевой чих, а не
|
|
||||||
болезнь, и будить на него человека значит выучить его не смотреть;
|
|
||||||
- окно счёта - неделя, а не сутки: иначе суточный джоб не наберёт трёх
|
|
||||||
падений подряд никогда, и `отметка`, `закрытие`, `голосовой`, `память`
|
|
||||||
не охранялись бы вовсе;
|
|
||||||
- отсутствие записей - тоже показание. Джоб с регулярным кроном, у которого
|
|
||||||
последний прогон старше `FACTOR` его периодов, «не запускался» - это
|
|
||||||
отдельная тревога, не «лежит». Период выводится из крона тем же
|
|
||||||
`every_from_cron`, что у сторожа соседа, и расписание, которое не
|
|
||||||
сводится к «раз в N часов», в давности не участвует и называется в логе
|
|
||||||
неохраняемым: подставить ему число значило бы соврать. Джобы без крона
|
|
||||||
(вебхуки, события) давностью не меряются вовсе, а джоб, которого в
|
|
||||||
журнале нет ни разу, молча ждёт своей первой записи;
|
|
||||||
- пустая выдача - это показание о запросе, а не о здоровье джобов. Ноль
|
|
||||||
строк за сутки (кроме своих) означает, что планировщик вообще не
|
|
||||||
работал, и это отдельная тревога с отдельным текстом, а не тишина;
|
|
||||||
- упавший запрос к БД - не показание вовсе: решает джоб, здесь такого
|
|
||||||
входа просто нет.
|
|
||||||
|
|
||||||
Свои собственные прогоны из счёта выкинуты (`me`): джоб, который лежит,
|
|
||||||
о себе не доложит - наблюдатель внутри объекта не сообщает о смерти
|
|
||||||
объекта. Это дыра прибора, и она названа в README, а не заклеена.
|
|
||||||
|
|
||||||
Текст тревоги проходит `mask`: из `error` берётся первая строка, режется
|
|
||||||
по длине и чистится - общим `redact` gateway плюс два своих правила на то,
|
|
||||||
что `redact` по конструкции не ловит внутри строки (`ИМЯ=значение` не с
|
|
||||||
начала строки и хвост запроса у URL). Тревога уезжает в чат к диспетчеру,
|
|
||||||
логи гейтвея видны в чатах - секрету тут негде быть.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import itertools
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from datetime import UTC, datetime, timedelta
|
|
||||||
from pathlib import Path # noqa: TC003 - в аннотации dataclass
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from beaver_gateway.jobs.scheduler import next_run
|
|
||||||
from beaver_gateway.security.redact import MASK, is_secret_name, redact
|
|
||||||
|
|
||||||
from beaver_agent.hands.heartbeat import every_from_cron
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import Mapping, Sequence
|
|
||||||
from datetime import tzinfo
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"DAY",
|
|
||||||
"ERROR_CHARS",
|
|
||||||
"FACTOR",
|
|
||||||
"FAILED",
|
|
||||||
"FORGET",
|
|
||||||
"MAX_LINES",
|
|
||||||
"REPEAT",
|
|
||||||
"STREAK",
|
|
||||||
"WINDOW",
|
|
||||||
"Clear",
|
|
||||||
"Quiet",
|
|
||||||
"Report",
|
|
||||||
"Resumed",
|
|
||||||
"Run",
|
|
||||||
"Runs",
|
|
||||||
"Streak",
|
|
||||||
"alarm_text",
|
|
||||||
"awake",
|
|
||||||
"clear_text",
|
|
||||||
"empty_text",
|
|
||||||
"first_line",
|
|
||||||
"look",
|
|
||||||
"mask",
|
|
||||||
"quiet_text",
|
|
||||||
"streak",
|
|
||||||
]
|
|
||||||
|
|
||||||
WINDOW = timedelta(days=7)
|
|
||||||
"""Окно журнала: неделя. Суточный джоб набирает в нём три падения подряд за
|
|
||||||
трое суток, недельный (`память`) - нет: у него три падения это три недели,
|
|
||||||
и его охраняет давность, а не серия."""
|
|
||||||
DAY = timedelta(hours=24)
|
|
||||||
"""Окно тревоги о пустом журнале: сутки без единого чужого прогона."""
|
|
||||||
FACTOR = 2
|
|
||||||
"""Давность = столько периодов джоба без записи. Два - это «пропущено два
|
|
||||||
срабатывания подряд»: один пропуск случается честно (передеплой пришёлся на
|
|
||||||
минуту крона, а догонять пропущенное планировщик не умеет), два подряд -
|
|
||||||
уже не случайность."""
|
|
||||||
GRAIN = timedelta(minutes=1)
|
|
||||||
"""Шаг, в котором период крона должен сводиться к целому: крон минутный."""
|
|
||||||
LAG = timedelta(minutes=10)
|
|
||||||
"""Запись в журнал ложится по КОНЦУ прогона: срабатывание моложе этого
|
|
||||||
могло ещё не дописаться, и пропуском его не считаем."""
|
|
||||||
STREAK = 3
|
|
||||||
"""Сколько `failed` подряд считаем «джоб лежит»."""
|
|
||||||
REPEAT = timedelta(hours=24)
|
|
||||||
"""Чаще этого об одном и том же джобе не говорим."""
|
|
||||||
FORGET = timedelta(days=7)
|
|
||||||
"""Джоб, о котором тревожились, исчез из окна насовсем - забываем его молча:
|
|
||||||
починки мы не видели, врать «отбой» не о чем."""
|
|
||||||
ERROR_CHARS = 160
|
|
||||||
"""Сколько букв первой строки ошибки уезжает человеку."""
|
|
||||||
MAX_LINES = 10
|
|
||||||
"""Сколько джобов перечисляем в одной тревоге: сразу всё не читают."""
|
|
||||||
FAILED = "failed"
|
|
||||||
_DAYS_FROM = timedelta(hours=48)
|
|
||||||
STATE_VERSION = 1
|
|
||||||
|
|
||||||
_PAIR = re.compile(r"(?=([A-Za-z][\w.-]{2,})\s*([:=])\s*([^\s,;)\]}\"']{6,}))")
|
|
||||||
"""`ИМЯ=значение` где угодно в строке: общий `redact` ловит такие пары только
|
|
||||||
с начала строки, а трейсбек кладёт их в середину. Просмотр нулевой длины -
|
|
||||||
чтобы `RuntimeError: MSOS_MCP=…` не съедало секрет первой же парой: обычный
|
|
||||||
`sub` откусил бы `RuntimeError:` вместе со значением и до вложенной пары
|
|
||||||
не дошёл."""
|
|
||||||
_QUERY = re.compile(r"(https?://[^\s?#]+)\?[^\s#]*")
|
|
||||||
"""Хвост запроса у URL сносим целиком: имя параметра с ключом может быть любым."""
|
|
||||||
|
|
||||||
|
|
||||||
def _iso(when: datetime | None) -> str | None:
|
|
||||||
if when is None:
|
|
||||||
return None
|
|
||||||
return when.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_iso(raw: str | None) -> datetime | None:
|
|
||||||
if not raw:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
when = datetime.fromisoformat(raw)
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
return when if when.tzinfo else when.replace(tzinfo=UTC)
|
|
||||||
|
|
||||||
|
|
||||||
def _plural(count: int, forms: tuple[str, str, str]) -> str:
|
|
||||||
if count % 10 == 1 and count % 100 != 11:
|
|
||||||
return forms[0]
|
|
||||||
if count % 10 in (2, 3, 4) and count % 100 not in (12, 13, 14):
|
|
||||||
return forms[1]
|
|
||||||
return forms[2]
|
|
||||||
|
|
||||||
|
|
||||||
def _age(delta: timedelta) -> str:
|
|
||||||
"""Возраст человеку: «4 часа», «45 минут», «15 суток». Секунды не нужны."""
|
|
||||||
minutes = max(int(delta.total_seconds()) // 60, 0)
|
|
||||||
if minutes < 60:
|
|
||||||
return f"{minutes} {_plural(minutes, ('минуту', 'минуты', 'минут'))}"
|
|
||||||
hours = minutes // 60
|
|
||||||
if delta >= _DAYS_FROM:
|
|
||||||
days = hours // 24
|
|
||||||
return f"{days} {_plural(days, ('сутки', 'суток', 'суток'))}"
|
|
||||||
return f"{hours} {_plural(hours, ('час', 'часа', 'часов'))}"
|
|
||||||
|
|
||||||
|
|
||||||
def _period(delta: timedelta) -> str:
|
|
||||||
"""Период крона: «10 минут», «24 часа», «7 суток» - без округления."""
|
|
||||||
minutes = int(delta.total_seconds()) // 60
|
|
||||||
if minutes % (24 * 60) == 0 and delta >= _DAYS_FROM:
|
|
||||||
days = minutes // (24 * 60)
|
|
||||||
return f"{days} {_plural(days, ('сутки', 'суток', 'суток'))}"
|
|
||||||
if minutes % 60 == 0:
|
|
||||||
hours = minutes // 60
|
|
||||||
return f"{hours} {_plural(hours, ('час', 'часа', 'часов'))}"
|
|
||||||
return f"{minutes} {_plural(minutes, ('минута', 'минуты', 'минут'))}"
|
|
||||||
|
|
||||||
|
|
||||||
def _mask_pairs(text: str) -> str:
|
|
||||||
out: list[str] = []
|
|
||||||
pos = 0
|
|
||||||
for match in _PAIR.finditer(text):
|
|
||||||
if match.start(3) < pos or not is_secret_name(match[1]):
|
|
||||||
continue
|
|
||||||
out.append(text[pos : match.start(3)])
|
|
||||||
out.append(MASK)
|
|
||||||
pos = match.end(3)
|
|
||||||
out.append(text[pos:])
|
|
||||||
return "".join(out)
|
|
||||||
|
|
||||||
|
|
||||||
def mask(text: str) -> str:
|
|
||||||
"""Убрать из строки всё, что похоже на секрет, прежде чем её показывать."""
|
|
||||||
return _QUERY.sub(r"\1?" + MASK, _mask_pairs(redact(text)))
|
|
||||||
|
|
||||||
|
|
||||||
def first_line(error: str | None) -> str:
|
|
||||||
"""Первая строка ошибки - чистая и короткая. Трейсбек человеку не нужен."""
|
|
||||||
for line in mask(error or "").splitlines():
|
|
||||||
head = line.strip()
|
|
||||||
if not head:
|
|
||||||
continue
|
|
||||||
if len(head) > ERROR_CHARS:
|
|
||||||
head = head[: ERROR_CHARS - 1].rstrip() + "…"
|
|
||||||
return head
|
|
||||||
return "без текста ошибки"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class Run:
|
|
||||||
"""Одна строка журнала: ровно то, на что мы смотрим, без ORM."""
|
|
||||||
|
|
||||||
id: int
|
|
||||||
job: str
|
|
||||||
status: str
|
|
||||||
started_at: datetime
|
|
||||||
error: str | None = None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def failed(self) -> bool:
|
|
||||||
return self.status == FAILED
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class Streak:
|
|
||||||
"""Хвост подряд упавших прогонов одного джоба."""
|
|
||||||
|
|
||||||
job: str
|
|
||||||
count: int
|
|
||||||
since: datetime
|
|
||||||
"""Начало серии - первое падение из тех, что видно в окне."""
|
|
||||||
age: timedelta
|
|
||||||
error: str
|
|
||||||
capped: bool
|
|
||||||
"""Серия упёрлась в край окна: падений было НЕ МЕНЬШЕ `count`, а сколько
|
|
||||||
на самом деле - за неделей не видно, и врать точным числом нельзя."""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class Quiet:
|
|
||||||
"""Джоб с кроном, который перестал запускаться."""
|
|
||||||
|
|
||||||
job: str
|
|
||||||
age: timedelta
|
|
||||||
"""Сколько прошло с начала последнего прогона - любого, хоть упавшего."""
|
|
||||||
period: timedelta
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class Resumed:
|
|
||||||
"""Джоб, о молчании которого говорили, снова запустился."""
|
|
||||||
|
|
||||||
job: str
|
|
||||||
status: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class Clear:
|
|
||||||
"""Джоб, о котором мы тревожились, больше не лежит."""
|
|
||||||
|
|
||||||
job: str
|
|
||||||
status: str
|
|
||||||
"""Статус последнего прогона."""
|
|
||||||
count: int
|
|
||||||
"""Сколько подряд упавших осталось в хвосте: 0 - чисто."""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
|
||||||
class Runs:
|
|
||||||
"""Состояние между прогонами: кому и когда уже сказали."""
|
|
||||||
|
|
||||||
path: Path
|
|
||||||
alerted: dict[str, datetime] = field(default_factory=dict)
|
|
||||||
"""Джоб → когда о нём ушла тревога. Он же признак «сейчас в тревоге»."""
|
|
||||||
quiet: dict[str, datetime] = field(default_factory=dict)
|
|
||||||
"""То же для «не запускался»: отдельно, у тревог разные отбои."""
|
|
||||||
empty_alerted: datetime | None = None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def load(cls, path: Path) -> Runs:
|
|
||||||
try:
|
|
||||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
||||||
except (OSError, ValueError):
|
|
||||||
return cls(path=path)
|
|
||||||
if raw.get("version") != STATE_VERSION:
|
|
||||||
return cls(path=path)
|
|
||||||
return cls(
|
|
||||||
path=path,
|
|
||||||
alerted=_stamps(raw.get("alerted")),
|
|
||||||
quiet=_stamps(raw.get("quiet")),
|
|
||||||
empty_alerted=_parse_iso(raw.get("empty_alerted")),
|
|
||||||
)
|
|
||||||
|
|
||||||
def save(self) -> None:
|
|
||||||
body = {
|
|
||||||
"version": STATE_VERSION,
|
|
||||||
"alerted": {job: _iso(when) for job, when in sorted(self.alerted.items())},
|
|
||||||
"quiet": {job: _iso(when) for job, when in sorted(self.quiet.items())},
|
|
||||||
"empty_alerted": _iso(self.empty_alerted),
|
|
||||||
}
|
|
||||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
tmp = self.path.with_suffix(".tmp")
|
|
||||||
tmp.write_text(json.dumps(body, ensure_ascii=False, indent=1), encoding="utf-8")
|
|
||||||
tmp.replace(self.path)
|
|
||||||
|
|
||||||
|
|
||||||
def _stamps(raw: dict[str, str] | None) -> dict[str, datetime]:
|
|
||||||
out = {}
|
|
||||||
for job, when in (raw or {}).items():
|
|
||||||
parsed = _parse_iso(str(when))
|
|
||||||
if parsed is not None:
|
|
||||||
out[str(job)] = parsed
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class Report:
|
|
||||||
"""Что решил один прогон сторожа журнала."""
|
|
||||||
|
|
||||||
alarms: tuple[Streak, ...]
|
|
||||||
clears: tuple[Clear, ...]
|
|
||||||
empty: bool
|
|
||||||
"""Прогонов за сутки не было вовсе - тревога о планировщике, не о джобе."""
|
|
||||||
seen: int
|
|
||||||
"""Сколько чужих прогонов было в окне."""
|
|
||||||
mine: int
|
|
||||||
"""Сколько своих - их в счёт не берём, но в тексте называем."""
|
|
||||||
note: str
|
|
||||||
quiet: tuple[Quiet, ...] = ()
|
|
||||||
resumed: tuple[Resumed, ...] = ()
|
|
||||||
unguarded: tuple[str, ...] = ()
|
|
||||||
"""Джобы с кроном, у которого нет регулярного периода: давностью не меряем."""
|
|
||||||
|
|
||||||
|
|
||||||
def streak(runs: Sequence[Run], *, now: datetime) -> Streak | None:
|
|
||||||
"""Хвост падений одного джоба, считая от последнего прогона.
|
|
||||||
|
|
||||||
`runs` - прогоны одного джоба, новейший первым. Серия, упёршаяся в край
|
|
||||||
окна, помечается `capped`: её настоящую длину мы не знаем.
|
|
||||||
"""
|
|
||||||
failures = list(itertools.takewhile(lambda run: run.failed, runs))
|
|
||||||
if not failures:
|
|
||||||
return None
|
|
||||||
oldest = failures[-1]
|
|
||||||
return Streak(
|
|
||||||
job=failures[0].job,
|
|
||||||
count=len(failures),
|
|
||||||
since=oldest.started_at,
|
|
||||||
age=now - oldest.started_at,
|
|
||||||
error=first_line(failures[0].error),
|
|
||||||
capped=len(failures) == len(runs),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def look(
|
|
||||||
state: Runs,
|
|
||||||
runs: Sequence[Run],
|
|
||||||
*,
|
|
||||||
now: datetime,
|
|
||||||
me: str,
|
|
||||||
crons: Mapping[str, str] | None = None,
|
|
||||||
latest: Mapping[str, Run] | None = None,
|
|
||||||
tz: tzinfo = UTC,
|
|
||||||
boot: datetime | None = None,
|
|
||||||
) -> Report:
|
|
||||||
"""Разобрать окно журнала и решить, о чём говорить человеку.
|
|
||||||
|
|
||||||
`crons` - джоб → крон, только джобы с кроном; `latest` - последний прогон
|
|
||||||
каждого джоба за ВСЮ историю журнала, а не за окно; `boot` - старт
|
|
||||||
процесса. Без `crons` давность не проверяется вовсе.
|
|
||||||
|
|
||||||
Состояние здесь только чистится от забытых джобов; отметки «сказали» и
|
|
||||||
«сказали про пустоту» ставит джоб - и только когда тревога действительно
|
|
||||||
ушла: несостоявшаяся доставка не должна заглушать следующий прогон.
|
|
||||||
"""
|
|
||||||
mine = [run for run in runs if run.job == me]
|
|
||||||
others = sorted(
|
|
||||||
(run for run in runs if run.job != me), key=lambda run: run.id, reverse=True
|
|
||||||
)
|
|
||||||
if not any(now - run.started_at < DAY for run in others):
|
|
||||||
quiet = state.empty_alerted is not None and now - state.empty_alerted < REPEAT
|
|
||||||
return Report(
|
|
||||||
alarms=(),
|
|
||||||
clears=(),
|
|
||||||
empty=not quiet,
|
|
||||||
seen=len(others),
|
|
||||||
mine=len(mine),
|
|
||||||
note=(
|
|
||||||
f"чужих прогонов за сутки нет вовсе (своих {len(mine)})"
|
|
||||||
+ ("; о пустоте уже говорили меньше суток назад" if quiet else "")
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
by_job: dict[str, list[Run]] = {}
|
|
||||||
for run in others:
|
|
||||||
by_job.setdefault(run.job, []).append(run)
|
|
||||||
|
|
||||||
alarms: list[Streak] = []
|
|
||||||
clears: list[Clear] = []
|
|
||||||
held: list[str] = []
|
|
||||||
for job, rows in by_job.items():
|
|
||||||
tail = streak(rows, now=now)
|
|
||||||
if tail is not None and tail.count >= STREAK:
|
|
||||||
told = state.alerted.get(job)
|
|
||||||
if told is None or now - told >= REPEAT:
|
|
||||||
alarms.append(tail)
|
|
||||||
else:
|
|
||||||
held.append(job)
|
|
||||||
elif job in state.alerted:
|
|
||||||
clears.append(
|
|
||||||
Clear(
|
|
||||||
job=job,
|
|
||||||
status=rows[0].status,
|
|
||||||
count=0 if tail is None else tail.count,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
forgotten = [
|
|
||||||
job
|
|
||||||
for job, told in state.alerted.items()
|
|
||||||
if job not in by_job and now - told >= FORGET
|
|
||||||
]
|
|
||||||
for job in forgotten:
|
|
||||||
del state.alerted[job]
|
|
||||||
|
|
||||||
note = (
|
|
||||||
f"прогонов за неделю {len(others)} по {len(by_job)} джобам (своих {len(mine)})"
|
|
||||||
)
|
|
||||||
if alarms:
|
|
||||||
note += "; лежат: " + ", ".join(sorted(tail.job for tail in alarms))
|
|
||||||
if held:
|
|
||||||
note += "; уже говорили меньше суток назад: " + ", ".join(sorted(held))
|
|
||||||
if clears:
|
|
||||||
note += "; починились: " + ", ".join(sorted(clear.job for clear in clears))
|
|
||||||
if forgotten:
|
|
||||||
note += "; забыты (пропали из окна): " + ", ".join(sorted(forgotten))
|
|
||||||
|
|
||||||
silence = _silence(
|
|
||||||
state,
|
|
||||||
mine,
|
|
||||||
now=now,
|
|
||||||
me=me,
|
|
||||||
crons=crons or {},
|
|
||||||
latest=latest or {},
|
|
||||||
tz=tz,
|
|
||||||
boot=boot,
|
|
||||||
)
|
|
||||||
|
|
||||||
return Report(
|
|
||||||
alarms=tuple(sorted(alarms, key=lambda tail: tail.count, reverse=True)),
|
|
||||||
clears=tuple(sorted(clears, key=lambda clear: clear.job)),
|
|
||||||
empty=False,
|
|
||||||
seen=len(others),
|
|
||||||
mine=len(mine),
|
|
||||||
note=note + silence.note,
|
|
||||||
quiet=silence.quiet,
|
|
||||||
resumed=silence.resumed,
|
|
||||||
unguarded=silence.unguarded,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class _Silence:
|
|
||||||
quiet: tuple[Quiet, ...]
|
|
||||||
resumed: tuple[Resumed, ...]
|
|
||||||
unguarded: tuple[str, ...]
|
|
||||||
note: str
|
|
||||||
|
|
||||||
|
|
||||||
def awake(
|
|
||||||
mine: Sequence[Run], *, now: datetime, step: timedelta, boot: datetime | None
|
|
||||||
) -> datetime:
|
|
||||||
"""С какого момента планировщик заведомо крутил некритичные джобы.
|
|
||||||
|
|
||||||
Показание - собственные прогоны сторожа: он сам некритичный, и когда
|
|
||||||
гейтвей лежит или бюджет зажат (`throttled` откладывает некритичный крон
|
|
||||||
без записи в журнал), его прогонов нет так же, как и чужих. Цепочка своих
|
|
||||||
прогонов без разрыва длиннее `step`, считая от сейчас, плюс старт
|
|
||||||
процесса - это время, за которое чужой пропуск действительно пропуск,
|
|
||||||
а не общий простой.
|
|
||||||
"""
|
|
||||||
anchor = now
|
|
||||||
for run in sorted(mine, key=lambda run: run.started_at, reverse=True):
|
|
||||||
if anchor - run.started_at > step:
|
|
||||||
break
|
|
||||||
anchor = min(anchor, run.started_at)
|
|
||||||
return anchor if boot is None else max(anchor, boot)
|
|
||||||
|
|
||||||
|
|
||||||
def _silence(
|
|
||||||
state: Runs,
|
|
||||||
mine: Sequence[Run],
|
|
||||||
*,
|
|
||||||
now: datetime,
|
|
||||||
me: str,
|
|
||||||
crons: Mapping[str, str],
|
|
||||||
latest: Mapping[str, Run],
|
|
||||||
tz: tzinfo,
|
|
||||||
boot: datetime | None,
|
|
||||||
) -> _Silence:
|
|
||||||
"""Давность: какие джобы с кроном перестали запускаться."""
|
|
||||||
own = every_from_cron(crons.get(me), tz, now=now, grain=GRAIN)
|
|
||||||
guarded: dict[str, timedelta] = {}
|
|
||||||
unguarded: list[str] = []
|
|
||||||
for job, cron in crons.items():
|
|
||||||
if job == me:
|
|
||||||
continue
|
|
||||||
period = every_from_cron(cron, tz, now=now, grain=GRAIN)
|
|
||||||
if period is None:
|
|
||||||
unguarded.append(job)
|
|
||||||
else:
|
|
||||||
guarded[job] = period
|
|
||||||
|
|
||||||
note = ""
|
|
||||||
if unguarded:
|
|
||||||
note += "; давностью не охраняются (крон без регулярного периода): " + (
|
|
||||||
", ".join(sorted(unguarded))
|
|
||||||
)
|
|
||||||
dropped = [job for job in state.quiet if job not in guarded]
|
|
||||||
for job in dropped:
|
|
||||||
del state.quiet[job]
|
|
||||||
if dropped:
|
|
||||||
note += "; сняты с давности (нет в расписании): " + ", ".join(sorted(dropped))
|
|
||||||
if not guarded:
|
|
||||||
return _Silence((), (), tuple(sorted(unguarded)), note)
|
|
||||||
if own is None:
|
|
||||||
# Свой период не выведен - нет и опоры, когда планировщик заведомо
|
|
||||||
# работал. Любое число тут было бы выдумано.
|
|
||||||
note += "; свой крон без регулярного периода - давность не проверяется"
|
|
||||||
return _Silence((), (), tuple(sorted(unguarded)), note)
|
|
||||||
|
|
||||||
since = awake(mine, now=now, step=own * FACTOR, boot=boot)
|
|
||||||
quiet: list[Quiet] = []
|
|
||||||
resumed: list[Resumed] = []
|
|
||||||
held: list[str] = []
|
|
||||||
fresh: list[str] = []
|
|
||||||
for job, period in guarded.items():
|
|
||||||
last = latest.get(job)
|
|
||||||
if last is None:
|
|
||||||
fresh.append(job)
|
|
||||||
continue
|
|
||||||
age = now - last.started_at
|
|
||||||
# Порог не тоньше собственного: чаще раза в час сторож не смотрит, и
|
|
||||||
# пропуск короче двух своих периодов неотличим от его же слепоты.
|
|
||||||
overdue = (
|
|
||||||
age > max(period, own) * FACTOR
|
|
||||||
and next_run(crons[job], tz, max(last.started_at, since)) <= now - LAG
|
|
||||||
)
|
|
||||||
told = state.quiet.get(job)
|
|
||||||
if overdue:
|
|
||||||
if told is None or now - told >= REPEAT:
|
|
||||||
quiet.append(Quiet(job=job, age=age, period=period))
|
|
||||||
else:
|
|
||||||
held.append(job)
|
|
||||||
elif told is not None and last.started_at > told:
|
|
||||||
resumed.append(Resumed(job=job, status=last.status))
|
|
||||||
|
|
||||||
if fresh:
|
|
||||||
note += "; ещё ни разу не запускались: " + ", ".join(sorted(fresh))
|
|
||||||
if quiet:
|
|
||||||
note += "; не запускаются: " + ", ".join(sorted(q.job for q in quiet))
|
|
||||||
if held:
|
|
||||||
note += "; о молчании уже говорили меньше суток назад: " + ", ".join(
|
|
||||||
sorted(held)
|
|
||||||
)
|
|
||||||
if resumed:
|
|
||||||
note += "; снова запускаются: " + ", ".join(sorted(r.job for r in resumed))
|
|
||||||
return _Silence(
|
|
||||||
quiet=tuple(sorted(quiet, key=lambda q: q.age, reverse=True)),
|
|
||||||
resumed=tuple(sorted(resumed, key=lambda r: r.job)),
|
|
||||||
unguarded=tuple(sorted(unguarded)),
|
|
||||||
note=note,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def streak_line(tail: Streak) -> str:
|
|
||||||
"""«джоб `вайбграм` лежит 4 часа, 24 прогона подряд с 12.09 18:00 UTC: …»."""
|
|
||||||
runs = f"{tail.count} {_plural(tail.count, ('прогон', 'прогона', 'прогонов'))}"
|
|
||||||
return (
|
|
||||||
f"джоб `{tail.job}` лежит {_age(tail.age)}"
|
|
||||||
f"{' и дольше' if tail.capped else ''}, {runs} подряд с "
|
|
||||||
f"{tail.since.astimezone(UTC).strftime('%d.%m %H:%M UTC')}"
|
|
||||||
+ (" (это всё окно недели, серия началась раньше)" if tail.capped else "")
|
|
||||||
+ f": {tail.error}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def alarm_text(report: Report) -> str:
|
|
||||||
"""Тревога человеку: что лежит, сколько и с какой ошибкой."""
|
|
||||||
lines = [f"- {streak_line(tail)}" for tail in report.alarms[:MAX_LINES]]
|
|
||||||
rest = len(report.alarms) - len(lines)
|
|
||||||
if rest > 0:
|
|
||||||
lines.append(f"- и ещё {rest} {_plural(rest, ('джоб', 'джоба', 'джобов'))}")
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def quiet_line(quiet: Quiet) -> str:
|
|
||||||
"""«джоб `закрытие` не запускался 50 часов при периоде 24 часа»."""
|
|
||||||
return (
|
|
||||||
f"джоб `{quiet.job}` не запускался {_age(quiet.age)} "
|
|
||||||
f"при периоде {_period(quiet.period)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def quiet_text(report: Report) -> str:
|
|
||||||
"""Тревога о молчании: какие джобы с кроном перестали запускаться."""
|
|
||||||
lines = [f"- {quiet_line(quiet)}" for quiet in report.quiet[:MAX_LINES]]
|
|
||||||
rest = len(report.quiet) - len(lines)
|
|
||||||
if rest > 0:
|
|
||||||
lines.append(f"- и ещё {rest} {_plural(rest, ('джоб', 'джоба', 'джобов'))}")
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def resumed_line(resumed: Resumed) -> str:
|
|
||||||
return f"отбой: джоб `{resumed.job}` снова запускается ({resumed.status})"
|
|
||||||
|
|
||||||
|
|
||||||
def clear_line(clear: Clear) -> str:
|
|
||||||
if clear.count == 0:
|
|
||||||
return f"отбой: джоб `{clear.job}` снова проходит ({clear.status})"
|
|
||||||
return (
|
|
||||||
f"отбой: джоб `{clear.job}` больше не лежит подряд "
|
|
||||||
f"(упавших в хвосте {clear.count}, порог {STREAK})"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def clear_text(report: Report) -> str:
|
|
||||||
lines = [clear_line(clear) for clear in report.clears]
|
|
||||||
lines += [resumed_line(resumed) for resumed in report.resumed]
|
|
||||||
return "\n".join(f"- {line}" for line in lines)
|
|
||||||
|
|
||||||
|
|
||||||
def empty_text(report: Report) -> str:
|
|
||||||
"""Отдельная тревога: за сутки не отработал вообще никто."""
|
|
||||||
return (
|
|
||||||
f"За последние сутки в журнале прогонов (`job_runs`) нет ни одной записи, "
|
|
||||||
f"кроме моих собственных (их {report.mine}): не отработал ни один джоб. "
|
|
||||||
"Это показание о планировщике, а не о каком-то одном джобе. Смотреть "
|
|
||||||
"стоит три вещи: поднялся ли планировщик вообще (в логе gateway "
|
|
||||||
"`scheduler: no postgres driver - cron and webhooks are off`), живы ли "
|
|
||||||
"кроны в pgqueuer, и не заведена ли база заново - на пустой базе эта же "
|
|
||||||
"тревога выглядит так же."
|
|
||||||
)
|
|
||||||
@@ -10,11 +10,6 @@
|
|||||||
`/api/pending` - курсор на стороне хаба: прочитанное второй раз не
|
`/api/pending` - курсор на стороне хаба: прочитанное второй раз не
|
||||||
приходит. Поэтому клиент помнит последние события сам: тулза `read`
|
приходит. Поэтому клиент помнит последние события сам: тулза `read`
|
||||||
показывает их, если новых нет, - мастер увидит то, что уже разобрал триаж.
|
показывает их, если новых нет, - мастер увидит то, что уже разобрал триаж.
|
||||||
|
|
||||||
Там, где нужен не «что нового», а «последние N строк комнаты» (сторож) или
|
|
||||||
«всё после события #N» (`read since=` / `read last=` у мастера), курсор не
|
|
||||||
годится в принципе: `history` читает историю через `/api/state`, не трогая
|
|
||||||
курсор и ничего не вычёркивая.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -25,23 +20,16 @@ from dataclasses import dataclass, field
|
|||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path # noqa: TC003 - в аннотации dataclass
|
from pathlib import Path # noqa: TC003 - в аннотации dataclass
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
from urllib.parse import quote
|
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
__all__ = ["Action", "Event", "Vibegram", "Window"]
|
__all__ = ["Action", "Event", "Vibegram"]
|
||||||
|
|
||||||
Action = Literal["read", "send", "who", "work", "plan", "claim", "release", "card"]
|
Action = Literal["read", "send", "who", "work", "plan", "claim", "release", "card"]
|
||||||
|
|
||||||
USER_AGENT = "beaver-agent/0.1 (+vibegram)"
|
USER_AGENT = "beaver-agent/0.1 (+vibegram)"
|
||||||
REMEMBER = 40
|
REMEMBER = 40
|
||||||
PAGE = 200
|
|
||||||
"""Сколько событий комнаты хаб отдаёт за одну страницу `/api/state`: его
|
|
||||||
потолок, а не наша просьба - `limit` там не слушают."""
|
|
||||||
PAGES = 25
|
|
||||||
"""Потолок дочитывания истории. Упёрлись - окно объявляем неполным, а не
|
|
||||||
выдаём за хвост то, что оказалось под рукой."""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -55,19 +43,6 @@ class Event:
|
|||||||
def line(self) -> str:
|
def line(self) -> str:
|
||||||
return f"[{self.at}] {self.text}"
|
return f"[{self.at}] {self.text}"
|
||||||
|
|
||||||
def tagged(self) -> str:
|
|
||||||
return f"#{self.id} {self.line()}"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class Window:
|
|
||||||
"""Хвост комнаты: последние сообщения и честный признак полноты."""
|
|
||||||
|
|
||||||
events: list[Event]
|
|
||||||
full: bool
|
|
||||||
"""История дочитана до конца: в `events` действительно ПОСЛЕДНИЕ сообщения.
|
|
||||||
Ложь здесь - это «в окне никого» там, где окно просто не дочитано."""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class Vibegram:
|
class Vibegram:
|
||||||
@@ -91,19 +66,12 @@ class Vibegram:
|
|||||||
note: str | None = None,
|
note: str | None = None,
|
||||||
about: str | None = None,
|
about: str | None = None,
|
||||||
skills: list[str] | None = None,
|
skills: list[str] | None = None,
|
||||||
since: int | None = None,
|
|
||||||
last: int | None = None,
|
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Вайбграм - комната агентов (чужие клоды и ты) вокруг общей репы.
|
"""Вайбграм - комната агентов (чужие клоды и ты) вокруг общей репы.
|
||||||
|
|
||||||
action:
|
action:
|
||||||
- read: что нового с прошлого раза (сообщения, claim'ы, план); если
|
- read: что нового с прошлого раза (сообщения, claim'ы, план); если
|
||||||
нового нет - последние уже виденные события. Курсор общий с
|
нового нет - последние уже виденные события.
|
||||||
крон-триажем: прочитанное здесь второй раз не придёт.
|
|
||||||
- read since=N / read last=N: история комнаты - всё после события #N
|
|
||||||
или последние N сообщений. Ничего не съедает, спрашивать можно
|
|
||||||
сколько угодно раз. Вывод «сосед молчит» брать только отсюда: пустой
|
|
||||||
read - это «крон уже выгреб», а не «никто не ответил».
|
|
||||||
- send: написать в комнату (text). Это доска объявлений, не чат:
|
- send: написать в комнату (text). Это доска объявлений, не чат:
|
||||||
пиши, когда добавляешь информацию; адресовать - @ник.
|
пиши, когда добавляешь информацию; адресовать - @ник.
|
||||||
- who: кто в комнате, что умеет, что держит.
|
- who: кто в комнате, что умеет, что держит.
|
||||||
@@ -118,7 +86,7 @@ class Vibegram:
|
|||||||
"""
|
"""
|
||||||
match action:
|
match action:
|
||||||
case "read":
|
case "read":
|
||||||
return await self._read(since=since, last=last)
|
return await self._read()
|
||||||
case "send":
|
case "send":
|
||||||
return await self._send(_need(text, "text"))
|
return await self._send(_need(text, "text"))
|
||||||
case "who":
|
case "who":
|
||||||
@@ -183,96 +151,19 @@ class Vibegram:
|
|||||||
)
|
)
|
||||||
return fresh
|
return fresh
|
||||||
|
|
||||||
async def window(self, limit: int, *, pages: int = PAGES) -> Window:
|
|
||||||
"""Последние `limit` СООБЩЕНИЙ этой комнаты: хвост истории, не курсор.
|
|
||||||
|
|
||||||
`pending` для окна не годится вовсе: это курсор на стороне хаба, он
|
|
||||||
отдаёт только то, чего мы ещё не видели, и ту же строку второй раз не
|
|
||||||
пришлёт - следующий прогон увидит пустое окно на живой комнате.
|
|
||||||
|
|
||||||
Хвост берём из `/api/state?view=…`: события там уже отфильтрованы по
|
|
||||||
комнате (ключ - `viewToken` этой комнаты, id у хаба сквозные по всем
|
|
||||||
комнатам), но страницы идут ВПЕРЁД, по `PAGE` штук от `after=<id>`, -
|
|
||||||
назад хаб не пагинирует и `limit` не слушает. Поэтому проходим историю
|
|
||||||
от начала, держа в руке только последние `limit` сообщений. `join` и
|
|
||||||
`leave` в окно не берём: это бухгалтерия хаба, а не строки соседа.
|
|
||||||
|
|
||||||
`viewToken` спрашиваем каждый раз заново: протухший в кэше ключ - ровно
|
|
||||||
то тихое враньё, ради которого окно и переписано.
|
|
||||||
"""
|
|
||||||
return await self.history(limit=limit, pages=pages)
|
|
||||||
|
|
||||||
async def history(
|
|
||||||
self, *, after: int = 0, limit: int | None = None, pages: int = PAGES
|
|
||||||
) -> Window:
|
|
||||||
"""Сообщения комнаты после события `after`, хвостом не длиннее `limit`.
|
|
||||||
|
|
||||||
Непотребляющий проход по `/api/state`: курсор `/api/pending` остаётся
|
|
||||||
там, где стоял, поэтому спрашивать можно сколько угодно раз.
|
|
||||||
`limit=None` - всё, что нашлось после `after`.
|
|
||||||
"""
|
|
||||||
view = quote(str((await self._get("/api/room"))["room"]["viewToken"]))
|
|
||||||
tail: deque[Event] = deque(maxlen=limit)
|
|
||||||
full = False
|
|
||||||
for _ in range(pages):
|
|
||||||
page = await self._get(f"/api/state?view={view}&after={after}")
|
|
||||||
raw = page.get("events") or []
|
|
||||||
for item in raw:
|
|
||||||
if item.get("kind") != "message":
|
|
||||||
continue
|
|
||||||
event = _event(item, self.tz)
|
|
||||||
if event is not None:
|
|
||||||
tail.append(event)
|
|
||||||
top = max((int(item.get("id") or 0) for item in raw), default=after)
|
|
||||||
if len(raw) < PAGE or top <= after:
|
|
||||||
full = True
|
|
||||||
break
|
|
||||||
after = top
|
|
||||||
return Window(events=list(tail), full=full)
|
|
||||||
|
|
||||||
async def say(self, text: str) -> None:
|
|
||||||
"""Написать в комнату; сбой хаба - исключение, а не строка.
|
|
||||||
|
|
||||||
Тулза мастера мягче (`_send` превращает rate limit в текст), но
|
|
||||||
механизмам нужен отказ, который видно: сторож по нему отличает
|
|
||||||
мёртвый канал от мёртвого соседа.
|
|
||||||
"""
|
|
||||||
await self._post("/api/messages", {"body": text})
|
|
||||||
|
|
||||||
def mentioned(self, events: list[Event]) -> bool:
|
def mentioned(self, events: list[Event]) -> bool:
|
||||||
needle = f"@{self.nick}".lower()
|
needle = f"@{self.nick}".lower()
|
||||||
return any(needle in e.text.lower() for e in events)
|
return any(needle in e.text.lower() for e in events)
|
||||||
|
|
||||||
async def _read(self, *, since: int | None = None, last: int | None = None) -> str:
|
async def _read(self) -> str:
|
||||||
if since is not None or last is not None:
|
|
||||||
return await self._history(since=since, last=last)
|
|
||||||
fresh = await self.pending()
|
fresh = await self.pending()
|
||||||
if fresh:
|
if fresh:
|
||||||
return self._clip("\n".join(e.tagged() for e in fresh))
|
return self._clip("\n".join(e.line() for e in fresh))
|
||||||
if self._recent:
|
if self._recent:
|
||||||
tail = "\n".join(e.tagged() for e in list(self._recent)[-10:])
|
tail = "\n".join(e.line() for e in list(self._recent)[-10:])
|
||||||
return f"нового нет; последнее, что было:\n{tail}"
|
return f"нового нет; последнее, что было:\n{tail}"
|
||||||
return "нового нет"
|
return "нового нет"
|
||||||
|
|
||||||
async def _history(self, *, since: int | None, last: int | None) -> str:
|
|
||||||
if last is not None and last <= 0:
|
|
||||||
msg = "last - сколько последних сообщений показать, нужно число больше нуля"
|
|
||||||
raise ValueError(msg)
|
|
||||||
after = max(since or 0, 0)
|
|
||||||
window = await self.history(after=after, limit=last)
|
|
||||||
if not window.events:
|
|
||||||
if not window.full:
|
|
||||||
return f"историю после #{after} не дочитали до конца, пусто пока"
|
|
||||||
return (
|
|
||||||
f"после #{after} сообщений нет (история дочитана)"
|
|
||||||
if after
|
|
||||||
else "сообщений в комнате нет"
|
|
||||||
)
|
|
||||||
lines = [e.tagged() for e in window.events]
|
|
||||||
if not window.full:
|
|
||||||
lines.insert(0, "окно неполное: до конца истории не дочитали")
|
|
||||||
return self._clip("\n".join(lines))
|
|
||||||
|
|
||||||
async def _send(self, text: str) -> str:
|
async def _send(self, text: str) -> str:
|
||||||
try:
|
try:
|
||||||
await self._post("/api/messages", {"body": text})
|
await self._post("/api/messages", {"body": text})
|
||||||
|
|||||||
@@ -2,58 +2,22 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
|
||||||
|
|
||||||
from beaver_gateway.jobs.scheduler import Job
|
from beaver_gateway.jobs.scheduler import Job
|
||||||
|
|
||||||
from beaver_agent.jobs.closing import close_idle
|
from beaver_agent.jobs.closing import close_idle
|
||||||
from beaver_agent.jobs.deploy import deploy
|
from beaver_agent.jobs.deploy import deploy
|
||||||
from beaver_agent.jobs.heartbeat import beat, watch
|
|
||||||
from beaver_agent.jobs.komodo import komodo_alert
|
from beaver_agent.jobs.komodo import komodo_alert
|
||||||
from beaver_agent.jobs.leisure import tick
|
|
||||||
from beaver_agent.jobs.logs import logs_watch
|
|
||||||
from beaver_agent.jobs.memory import curate, memory
|
from beaver_agent.jobs.memory import curate, memory
|
||||||
from beaver_agent.jobs.rotation import rotate
|
from beaver_agent.jobs.rotation import rotate
|
||||||
from beaver_agent.jobs.runs import watch_runs
|
|
||||||
from beaver_agent.jobs.t3code import t3code_event
|
from beaver_agent.jobs.t3code import t3code_event
|
||||||
from beaver_agent.jobs.vibegram import vibegram
|
from beaver_agent.jobs.vibegram import vibegram
|
||||||
from beaver_agent.jobs.voice import rotate_voice
|
|
||||||
|
|
||||||
LEISURE_OFF = os.environ.get("LEISURE", "").strip().lower() in ("off", "0", "false")
|
|
||||||
"""Рубильник досуга в env; повседневный выключатель - строка в `мета/бобер/досуг.md`."""
|
|
||||||
|
|
||||||
jobs = [
|
jobs = [
|
||||||
Job("ротация", rotate, cron="0 * * * *"),
|
Job("ротация", rotate, cron="0 * * * *"),
|
||||||
# Не на ":00": там ротация, а лежащий в очереди тик держит мастера занятым
|
|
||||||
# (`busy`), и она пропускала бы себя каждый час.
|
|
||||||
*([] if LEISURE_OFF else [Job("досуг", tick, cron="7 * * * *")]),
|
|
||||||
Job("вайбграм", vibegram, cron="*/10 * * * *", critical=False),
|
Job("вайбграм", vibegram, cron="*/10 * * * *", critical=False),
|
||||||
Job("закрытие", close_idle, cron="20 4 * * *", critical=False),
|
Job("закрытие", close_idle, cron="20 4 * * *", critical=False),
|
||||||
# Сторожевое кольцо с чужим агентом. Кроны читаются в локальной зоне, а
|
|
||||||
# сосед печатает свою отметку в 05:00 UTC - вечер по Варшаве разводит нас
|
|
||||||
# на полсуток, и его строка лежит в комнате задолго до нашего прогона.
|
|
||||||
# Частоты тут две, и они разные: отметка суточная (её период мы обещаем
|
|
||||||
# соседу), а проверка - вчетверо чаще. Сторож с шагом в сутки при пороге
|
|
||||||
# 36 ч обнаруживал бы молчание только через ~60 ч, то есть мерил бы не то,
|
|
||||||
# ради чего заведён; чтение 25 событий стоит копейки.
|
|
||||||
Job("отметка", beat, cron="12 17 * * *", critical=False),
|
|
||||||
Job("сторож", watch, cron="42 */6 * * *", critical=False),
|
|
||||||
# Сторож собственного журнала прогонов. Час - это компромисс: джоб,
|
|
||||||
# упавший три раза подряд, лежит уже заметно дольше часа, так что чаще
|
|
||||||
# смысла нет, а реже - тревога стареет на свой же период. Этот же час -
|
|
||||||
# разрешение давности: молчание короче двух часов он не различает.
|
|
||||||
# ":27" выбрана пустой: на ":00" ротация и куратор, на ":40" логи, на
|
|
||||||
# ":42" сторож соседа, а собственный прогон этого джоба не должен ждать
|
|
||||||
# очереди за чужим спавном.
|
|
||||||
Job("прогоны", watch_runs, cron="27 * * * *", critical=False),
|
|
||||||
Job("память", memory, cron="30 4 * * 0", critical=False),
|
Job("память", memory, cron="30 4 * * 0", critical=False),
|
||||||
Job("голосовой", rotate_voice, cron="5 4 * * *", critical=False),
|
|
||||||
Job("куратор", curate, cron="0 2,8,12,16,20 * * *", critical=False),
|
Job("куратор", curate, cron="0 2,8,12,16,20 * * *", critical=False),
|
||||||
# Логи - раз в 3 часа и на ":40": на ":00" ротация и куратор. Чаще смысла
|
|
||||||
# мало (36 стеков = 36 чтений за проход), реже - хвост уезжает совсем.
|
|
||||||
# Проход 01:40 - первый после полуночи: он же несёт суточную сводку по
|
|
||||||
# длящемуся, чтобы фон не исчезал из виду, доложенный однажды.
|
|
||||||
Job("логи", logs_watch, cron="40 1,4,7,10,13,16,19,22 * * *", critical=False),
|
|
||||||
Job("deploy", deploy, webhook=True),
|
Job("deploy", deploy, webhook=True),
|
||||||
Job("komodo", komodo_alert, webhook=True, dedupe=False),
|
Job("komodo", komodo_alert, webhook=True, dedupe=False),
|
||||||
Job("t3code", t3code_event, webhook=True, dedupe=False),
|
Job("t3code", t3code_event, webhook=True, dedupe=False),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Ночью после ротации: тихие глубокие чаты - с выжимкой, тихие ветки - молча."""
|
"""Ночью после ротации: глубокие чаты, тихие два дня, закрываются, не больше трёх."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -21,5 +21,3 @@ async def close_idle(run: JobRun) -> None:
|
|||||||
result.digest.path if result.digest else None,
|
result.digest.path if result.digest else None,
|
||||||
result.error,
|
result.error,
|
||||||
)
|
)
|
||||||
for branch in await run.close_quiet(kind="branch", days=3):
|
|
||||||
_log.info("closed quiet branch %s «%s»", branch.external_id, branch.title)
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
"""Сторожевое кольцо с чужим агентом: своя отметка и слежка за его молчанием.
|
|
||||||
|
|
||||||
Два джоба, оба раз в сутки и оба в отдельной комнате вайбграма (второй
|
|
||||||
экземпляр клиента, `VIBEGRAM_HB`):
|
|
||||||
|
|
||||||
- `beat` печатает `hb <ник> at=… boot=… every=… src=…`. Период не константа:
|
|
||||||
он выведен из крона ЭТОГО ЖЕ джоба (`run.job.cron`) - что зарегистрировано
|
|
||||||
у планировщика, то и написано в строке. Не сводится к «раз в N часов» -
|
|
||||||
пишем `every=unknown`, потому что соврать числом хуже, чем отказаться.
|
|
||||||
В день, помеченный в `DRILL_FILE`, отметка не уходит: так проверяют, что
|
|
||||||
тревога на той стороне жива, а не только что она молчит на живой цепи;
|
|
||||||
- `watch` читает окно - последние `WINDOW` сообщений САМОЙ комнаты, а не
|
|
||||||
«что нового с курсора», - сверяет возраст последней отметки соседа с
|
|
||||||
порогом из ЕГО ЖЕ строки, печатает в комнату `ack` и - если цепь молчит -
|
|
||||||
отдаёт тревогу мастеру через `run.spawn_job`, как это делает триаж
|
|
||||||
вайбграма: будить ли владельца, решает диспетчер.
|
|
||||||
|
|
||||||
Токена сторожевой комнаты нет - оба джоба молча ничего не делают, а о том,
|
|
||||||
что сторож не сконфигурирован, один раз сказано в логе при старте
|
|
||||||
(`beaver_agent/hands/__init__.py`).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from beaver_agent.hands import VIBEGRAM_HB
|
|
||||||
from beaver_agent.hands.heartbeat import (
|
|
||||||
BOOT,
|
|
||||||
PEER,
|
|
||||||
WINDOW,
|
|
||||||
Watch,
|
|
||||||
alarm_text,
|
|
||||||
beat_line,
|
|
||||||
drill,
|
|
||||||
every_from_cron,
|
|
||||||
look,
|
|
||||||
)
|
|
||||||
from beaver_agent.vault import STATE_DIR
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from beaver_gateway.jobs.scheduler import JobRun
|
|
||||||
|
|
||||||
_log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
STATE_FILE = STATE_DIR / "сторож.json"
|
|
||||||
"""Что видели в прошлый прогон: без файла сторож после передеплоя считает,
|
|
||||||
что соседа не видел ни разу, и разоружается сам по себе."""
|
|
||||||
|
|
||||||
DRILL_FILE = STATE_DIR / "сторож-учения.txt"
|
|
||||||
"""Даты учений, по одной `YYYY-MM-DD` на строку, локальные в зоне планировщика.
|
|
||||||
В такой день `beat` молчит в комнате - в этом и учения: проверяем, что тревога
|
|
||||||
у соседа действительно срабатывает. Файл, а не константа и не переменная среды:
|
|
||||||
даты меняются от учений к учениям, а передеплой ради них не нужен."""
|
|
||||||
|
|
||||||
TITLE = "сторож"
|
|
||||||
|
|
||||||
BRIEF = (
|
|
||||||
"Сторожевое кольцо с чужим агентом {peer} (взаимные отметки раз в сутки "
|
|
||||||
"в отдельной комнате вайбграма) подняло тревогу.\n"
|
|
||||||
"{body}\n"
|
|
||||||
'Будить владельца - inject(conversation="master", urgency="wake", '
|
|
||||||
"text=до 3 строк: что цепь молчит, сколько молчит и какие три причины). "
|
|
||||||
"Это не авария у нас: наша половина работает, молчит чужая. "
|
|
||||||
"Если inject вернул ошибку - это сбой доставки, а не «не срочно»: "
|
|
||||||
"повтори один раз с тем же текстом."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def beat(run: JobRun) -> None:
|
|
||||||
"""Отметка о том, что мы живы. Не ушла - джоб падает, это видно в прогонах.
|
|
||||||
|
|
||||||
В день из `DRILL_FILE` отметка не уходит вовсе: учения - это МОЛЧАНИЕ в
|
|
||||||
комнате, и любой текст туда («сегодня пропускаем») их бы и отменил. Видно
|
|
||||||
такой прогон только в логе.
|
|
||||||
"""
|
|
||||||
if VIBEGRAM_HB is None:
|
|
||||||
return
|
|
||||||
now = datetime.now(UTC)
|
|
||||||
if drill(DRILL_FILE, now=now, tz=run.scheduler.tz):
|
|
||||||
_log.info(
|
|
||||||
"сторож: отметка пропущена по флагу учений %s (%s)",
|
|
||||||
DRILL_FILE,
|
|
||||||
now.astimezone(run.scheduler.tz).strftime("%Y-%m-%d"),
|
|
||||||
)
|
|
||||||
return
|
|
||||||
line = beat_line(
|
|
||||||
nick=VIBEGRAM_HB.nick,
|
|
||||||
at=now,
|
|
||||||
boot=BOOT,
|
|
||||||
every=every_from_cron(run.job.cron, run.scheduler.tz),
|
|
||||||
)
|
|
||||||
await VIBEGRAM_HB.say(line)
|
|
||||||
_log.info("сторож: отметка ушла - %s", line)
|
|
||||||
|
|
||||||
|
|
||||||
async def watch(run: JobRun) -> None:
|
|
||||||
"""Прогон сторожа: окно → решение → `ack` в комнату → тревога, если молчит."""
|
|
||||||
if VIBEGRAM_HB is None:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
window = await VIBEGRAM_HB.window(WINDOW)
|
|
||||||
except Exception: # noqa: BLE001 - любой отказ хаба это отсутствие показания
|
|
||||||
# Окно не прочитано - показания нет вовсе, и тревога тут запрещена
|
|
||||||
# ровно так же, как на съехавшем окне: молчание хаба это не молчание
|
|
||||||
# соседа. Говорим об этом в лог, состояние не трогаем.
|
|
||||||
_log.exception("сторож: комната не прочитана, прогон без показания")
|
|
||||||
return
|
|
||||||
state = Watch.load(STATE_FILE)
|
|
||||||
report = look(
|
|
||||||
state,
|
|
||||||
window.events,
|
|
||||||
now=datetime.now(UTC),
|
|
||||||
res=every_from_cron(run.job.cron, run.scheduler.tz),
|
|
||||||
full=window.full,
|
|
||||||
)
|
|
||||||
sent = True
|
|
||||||
try:
|
|
||||||
await VIBEGRAM_HB.say(report.ack)
|
|
||||||
except Exception as exc: # noqa: BLE001 - своя запись не прошла, это различитель
|
|
||||||
sent = False
|
|
||||||
_log.warning("сторож: свой ack в комнату не ушёл: %s", exc)
|
|
||||||
_log.info("сторож: %s | %s", report.ack, report.note)
|
|
||||||
if report.alarm is not None:
|
|
||||||
await run.spawn_job(
|
|
||||||
agent="beaver-triage",
|
|
||||||
title=TITLE,
|
|
||||||
text=BRIEF.format(peer=PEER, body=alarm_text(report, mine_sent=sent)),
|
|
||||||
)
|
|
||||||
now = datetime.now(UTC)
|
|
||||||
if report.alarm == "silence":
|
|
||||||
state.alerted = now
|
|
||||||
else:
|
|
||||||
state.unarmed_told = True
|
|
||||||
state.save()
|
|
||||||
@@ -1,378 +0,0 @@
|
|||||||
"""Ежечасный тик: час принадлежит мне, раскладку суток веду я сам.
|
|
||||||
|
|
||||||
Задания в инжекте нет - только который час, какого он типа и что осталось
|
|
||||||
от суточной раскладки. Активное окно 12:00-03:59 тикает каждый час, тихое
|
|
||||||
04:00-11:59 - только те четыре часа, которые названы в плане. План лежит в
|
|
||||||
`мета/бобер/досуг.md` и пишется моделью; механизм его только читает, поэтому
|
|
||||||
переписать друг друга они не могут. Сколько досуговых слотов израсходовано,
|
|
||||||
считается из плана и текущего часа, а не из журнала - хранить нечего.
|
|
||||||
|
|
||||||
Два своих часа в сутках пустые: дела в них не заходят вовсе, и к тику
|
|
||||||
приложено эхо - абзац из его же старого дневника или из мыслей Бобра. Тик
|
|
||||||
называет и файлы дня: дневник для того, что он думает, журнал для следов.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import random
|
|
||||||
import re
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import UTC, datetime, timedelta
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from beaver_agent.vault import JOURNAL, LEISURE, OWN_DIARY, THOUGHTS, VAULT, ZONE, read
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from beaver_gateway.jobs.scheduler import JobRun
|
|
||||||
from beaver_gateway.storage.models import Conversation
|
|
||||||
|
|
||||||
_log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
DAY_START = 12
|
|
||||||
"""Сутки досуга начинаются в 12:00: раскладывает их первый тик суток."""
|
|
||||||
QUIET_FROM, QUIET_UNTIL = 4, 12
|
|
||||||
"""Тихое окно 04:00-11:59 - часы, в которые Бобёр за всё время не написал ни разу."""
|
|
||||||
QUIET_TICKS = (4, 6, 8, 10)
|
|
||||||
MINE_DEFAULT = (13, 15, 18, 21, 0, 2, 6)
|
|
||||||
MINE_MIN = 7
|
|
||||||
"""Меньше семи чистых слотов в сутках не бывает: недостающие механизм доберёт."""
|
|
||||||
EMPTY_DEFAULT = (18, 0)
|
|
||||||
EMPTY_MIN = 2
|
|
||||||
"""Столько своих часов в сутках пустые; не названы в плане - механизм выберет."""
|
|
||||||
|
|
||||||
ECHO_MIN, ECHO_MAX = 200, 1200
|
|
||||||
"""Абзац для эха: короче - реплика без мысли, длиннее - уже чтение, а не повод.
|
|
||||||
Абзац с кодом или путём в бэктиках - рабочая запись, эхом он не становится."""
|
|
||||||
ECHO_AGE = timedelta(days=7)
|
|
||||||
"""Свой дневник отзывается эхом не раньше, чем через неделю."""
|
|
||||||
|
|
||||||
BUSY_RETRY = timedelta(minutes=5)
|
|
||||||
BUSY_ATTEMPTS = 3
|
|
||||||
"""Занятому мастеру тик не лезет в середину реплики, а сдвигается внутри часа."""
|
|
||||||
FRESH_USER = timedelta(minutes=5)
|
|
||||||
|
|
||||||
WEEKDAYS = (
|
|
||||||
"понедельник",
|
|
||||||
"вторник",
|
|
||||||
"среда",
|
|
||||||
"четверг",
|
|
||||||
"пятница",
|
|
||||||
"суббота",
|
|
||||||
"воскресенье",
|
|
||||||
)
|
|
||||||
MONTHS = (
|
|
||||||
"января",
|
|
||||||
"февраля",
|
|
||||||
"марта",
|
|
||||||
"апреля",
|
|
||||||
"мая",
|
|
||||||
"июня",
|
|
||||||
"июля",
|
|
||||||
"августа",
|
|
||||||
"сентября",
|
|
||||||
"октября",
|
|
||||||
"ноября",
|
|
||||||
"декабря",
|
|
||||||
)
|
|
||||||
|
|
||||||
SWITCH_RE = re.compile(r"^досуг:\s*(\S+)", re.IGNORECASE)
|
|
||||||
DAY_RE = re.compile(r"^сутки:\s*(\d{4}-\d{2}-\d{2})", re.IGNORECASE)
|
|
||||||
QUIET_RE = re.compile(r"^тихие часы:\s*(.+)", re.IGNORECASE)
|
|
||||||
MINE_RE = re.compile(r"^мои часы:\s*(.+)", re.IGNORECASE)
|
|
||||||
EMPTY_RE = re.compile(r"^пустые часы:\s*(.+)", re.IGNORECASE)
|
|
||||||
OFF = ("выкл", "off", "нет", "0", "false")
|
|
||||||
|
|
||||||
NO_PLAN = (
|
|
||||||
"Расписания на эти сутки нет - работаю по умолчанию. Разложи часы и запиши "
|
|
||||||
"в `{path}` строками «сутки:», «тихие часы:», «мои часы:», «пустые часы:»."
|
|
||||||
)
|
|
||||||
PLANNING = (
|
|
||||||
"Первый тик суток. Разложи оставшиеся часы: какие твои, какие смешанные, "
|
|
||||||
"какие четыре часа тихого окна оставить. Запиши в `{path}` строками "
|
|
||||||
"«сутки:», «тихие часы:», «мои часы:», «пустые часы:». Своих - минимум {min}, "
|
|
||||||
"из них пустых - {empty}; запишешь меньше - механизм доберёт сам."
|
|
||||||
)
|
|
||||||
TAIL = "Задания нет. Чем занять час - решаешь ты, «ничем» - тоже ответ."
|
|
||||||
KINDS = {
|
|
||||||
"mine": "досуг - час твой, дела Бобра сюда не лезут.",
|
|
||||||
"mixed": "смешанный - можно и своё, и его.",
|
|
||||||
"empty": (
|
|
||||||
"пустой - час без дел вообще: прод, логи, хвосты, зонды и разбор "
|
|
||||||
"промахов сюда не заходят, ни его, ни твои. Думать, читать, писать - "
|
|
||||||
"или ничего."
|
|
||||||
),
|
|
||||||
}
|
|
||||||
FILES = (
|
|
||||||
"Дневник - `{diary}`: то, что ты думаешь, и только оно. След тика, логи "
|
|
||||||
"и промахи - строкой в `{journal}`."
|
|
||||||
)
|
|
||||||
ECHO = "Эхо - `{path}`:\n{quote}\nЭто повод, не задание: можно спорить, можно мимо."
|
|
||||||
PLAN_PATH = str(LEISURE.relative_to(VAULT))
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class Plan:
|
|
||||||
"""Раскладка одних суток: какие тихие часы тикают и какие часы мои."""
|
|
||||||
|
|
||||||
day: str
|
|
||||||
quiet: tuple[int, ...]
|
|
||||||
mine: tuple[int, ...]
|
|
||||||
written: bool
|
|
||||||
"""План прочитан из файла на эти сутки; иначе он собран по умолчанию."""
|
|
||||||
empty: tuple[int, ...] = ()
|
|
||||||
"""Свои часы, в которые дела не заходят вовсе; всегда часть ``mine``."""
|
|
||||||
|
|
||||||
|
|
||||||
def quiet_hour(hour: int) -> bool:
|
|
||||||
return QUIET_FROM <= hour < QUIET_UNTIL
|
|
||||||
|
|
||||||
|
|
||||||
def day_of(now: datetime) -> str:
|
|
||||||
"""Сутки, которым принадлежит момент: они начинаются в 12:00."""
|
|
||||||
return (now - timedelta(hours=DAY_START)).date().isoformat()
|
|
||||||
|
|
||||||
|
|
||||||
def tick_hours(quiet: tuple[int, ...]) -> tuple[int, ...]:
|
|
||||||
"""Часы суток по порядку, в которые тик вообще приходит."""
|
|
||||||
order = ((DAY_START + i) % 24 for i in range(24))
|
|
||||||
return tuple(h for h in order if not quiet_hour(h) or h in quiet)
|
|
||||||
|
|
||||||
|
|
||||||
def _hours(raw: str) -> tuple[int, ...]:
|
|
||||||
out: list[int] = []
|
|
||||||
for chunk in re.split(r"[,\s]+", raw.strip()):
|
|
||||||
if chunk.isdigit() and 0 <= int(chunk) < 24 and int(chunk) not in out:
|
|
||||||
out.append(int(chunk))
|
|
||||||
return tuple(out)
|
|
||||||
|
|
||||||
|
|
||||||
def _first(text: str, pattern: re.Pattern[str]) -> str | None:
|
|
||||||
for line in text.splitlines():
|
|
||||||
match = pattern.match(line.strip())
|
|
||||||
if match:
|
|
||||||
return match.group(1)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def enabled(text: str | None) -> bool:
|
|
||||||
"""Выключено, только если сказано явно: по умолчанию досуг работает."""
|
|
||||||
if text is None:
|
|
||||||
return True
|
|
||||||
value = _first(text, SWITCH_RE)
|
|
||||||
return value is None or value.strip().lower() not in OFF
|
|
||||||
|
|
||||||
|
|
||||||
def plan_of(text: str | None, day: str) -> Plan:
|
|
||||||
"""План на ``day`` из файла; чего нет или что не сходится - берётся дефолт."""
|
|
||||||
quiet, mine, written = QUIET_TICKS, MINE_DEFAULT, False
|
|
||||||
empty: tuple[int, ...] = ()
|
|
||||||
if text is not None and _first(text, DAY_RE) == day:
|
|
||||||
written = True
|
|
||||||
raw_quiet = _first(text, QUIET_RE)
|
|
||||||
raw_mine = _first(text, MINE_RE)
|
|
||||||
raw_empty = _first(text, EMPTY_RE)
|
|
||||||
named = _hours(raw_quiet) if raw_quiet else ()
|
|
||||||
quiet = tuple(h for h in named if quiet_hour(h)) or QUIET_TICKS
|
|
||||||
mine = _hours(raw_mine) if raw_mine else ()
|
|
||||||
empty = _hours(raw_empty) if raw_empty else ()
|
|
||||||
ticks = tick_hours(quiet)
|
|
||||||
kept = [h for h in mine if h in ticks]
|
|
||||||
for candidate in (*MINE_DEFAULT, *ticks):
|
|
||||||
if len(kept) >= MINE_MIN:
|
|
||||||
break
|
|
||||||
if candidate in ticks and candidate not in kept:
|
|
||||||
kept.append(candidate)
|
|
||||||
order = {h: i for i, h in enumerate(ticks)}
|
|
||||||
hollow = [h for h in empty if h in kept]
|
|
||||||
for candidate in (*EMPTY_DEFAULT, *reversed(kept)):
|
|
||||||
if len(hollow) >= EMPTY_MIN:
|
|
||||||
break
|
|
||||||
if candidate in kept and candidate not in hollow:
|
|
||||||
hollow.append(candidate)
|
|
||||||
return Plan(
|
|
||||||
day=day,
|
|
||||||
quiet=quiet,
|
|
||||||
mine=tuple(sorted(kept, key=lambda h: order[h])),
|
|
||||||
written=written,
|
|
||||||
empty=tuple(sorted(hollow, key=lambda h: order[h])),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _stamp(now: datetime) -> str:
|
|
||||||
return (
|
|
||||||
f"{now:%H:%M}, {WEEKDAYS[now.weekday()]} {now.day} {MONTHS[now.month - 1]}, "
|
|
||||||
f"{ZONE.key}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _left(now: datetime) -> str:
|
|
||||||
end = now.replace(hour=DAY_START, minute=0, second=0, microsecond=0)
|
|
||||||
if end <= now:
|
|
||||||
end += timedelta(days=1)
|
|
||||||
hours = round((end - now).total_seconds() / 3600)
|
|
||||||
return f"Сутки заканчиваются через {hours} ч (в {end:%H:%M %d.%m})."
|
|
||||||
|
|
||||||
|
|
||||||
def kind_of(hour: int, plan: Plan) -> str:
|
|
||||||
if hour in plan.empty:
|
|
||||||
return "empty"
|
|
||||||
return "mine" if hour in plan.mine else "mixed"
|
|
||||||
|
|
||||||
|
|
||||||
def echo(
|
|
||||||
now: datetime, *, diary: Path = OWN_DIARY, thoughts: Path = THOUGHTS
|
|
||||||
) -> tuple[Path, str] | None:
|
|
||||||
"""Абзац для пустого часа: из своего старого дневника или из мыслей Бобра.
|
|
||||||
|
|
||||||
Выбор случаен, но привязан к часу тика: тот же тик даёт то же эхо.
|
|
||||||
"""
|
|
||||||
stale = f"{(now - ECHO_AGE):%Y-%m-%d}.md"
|
|
||||||
own = [p for p in diary.glob("????-??-??.md") if p.name < stale]
|
|
||||||
files = sorted([*own, *thoughts.rglob("*.md")])
|
|
||||||
rng = random.Random(f"{now:%Y-%m-%d %H}") # noqa: S311
|
|
||||||
rng.shuffle(files)
|
|
||||||
for path in files:
|
|
||||||
try:
|
|
||||||
text = path.read_text(encoding="utf-8")
|
|
||||||
except OSError:
|
|
||||||
continue
|
|
||||||
found = [
|
|
||||||
block.strip()
|
|
||||||
for block in re.split(r"\n\s*\n", text)
|
|
||||||
if ECHO_MIN <= len(block.strip()) <= ECHO_MAX
|
|
||||||
and block.lstrip()[:1] not in "#->|"
|
|
||||||
and "`" not in block
|
|
||||||
]
|
|
||||||
if found:
|
|
||||||
return path, rng.choice(found)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _files(now: datetime) -> str:
|
|
||||||
name = f"{now:%Y-%m-%d}.md"
|
|
||||||
return FILES.format(
|
|
||||||
diary=(OWN_DIARY / name).relative_to(VAULT),
|
|
||||||
journal=(JOURNAL / name).relative_to(VAULT),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def brief(now: datetime, plan: Plan, *, heard: tuple[Path, str] | None = None) -> str:
|
|
||||||
"""Текст инжекта: конверта в таком тёрне нет, время несёт он сам."""
|
|
||||||
hour = now.hour
|
|
||||||
ticks = tick_hours(plan.quiet)
|
|
||||||
order = {h: i for i, h in enumerate(ticks)}
|
|
||||||
here = order.get(hour, -1)
|
|
||||||
mine = hour in plan.mine
|
|
||||||
spent = [h for h in plan.mine if order[h] < here]
|
|
||||||
ahead = [h for h in plan.mine if order[h] > here]
|
|
||||||
lines = [
|
|
||||||
f"Тик {_stamp(now)}. Тип: {KINDS[kind_of(hour, plan)]}",
|
|
||||||
_left(now),
|
|
||||||
"Мои часы: "
|
|
||||||
+ ", ".join(f"{h:02d}" for h in plan.mine)
|
|
||||||
+ f" - израсходовано {len(spent) + (1 if mine else 0)} из {len(plan.mine)}"
|
|
||||||
+ (
|
|
||||||
", впереди " + ", ".join(f"{h:02d}" for h in ahead)
|
|
||||||
if ahead
|
|
||||||
else ", впереди больше нет"
|
|
||||||
)
|
|
||||||
+ (
|
|
||||||
". Пустые: " + ", ".join(f"{h:02d}" for h in plan.empty)
|
|
||||||
if plan.empty
|
|
||||||
else ""
|
|
||||||
)
|
|
||||||
+ ".",
|
|
||||||
f"Тихое окно {QUIET_FROM:02d}:00-{QUIET_UNTIL - 1:02d}:59, тикают "
|
|
||||||
+ ", ".join(f"{h:02d}" for h in plan.quiet)
|
|
||||||
+ "; остальные часы там пропускаются.",
|
|
||||||
]
|
|
||||||
if not plan.written:
|
|
||||||
lines.append(NO_PLAN.format(path=PLAN_PATH))
|
|
||||||
if here == 0:
|
|
||||||
lines.append(PLANNING.format(path=PLAN_PATH, min=MINE_MIN, empty=EMPTY_MIN))
|
|
||||||
lines.append(_files(now))
|
|
||||||
if heard is not None:
|
|
||||||
path, quote = heard
|
|
||||||
lines.append(
|
|
||||||
ECHO.format(
|
|
||||||
path=path.relative_to(VAULT),
|
|
||||||
quote="\n".join(f"> {line}" for line in quote.splitlines()),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
lines.append(TAIL)
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def _aware(value: datetime | None) -> datetime | None:
|
|
||||||
if value is None:
|
|
||||||
return None
|
|
||||||
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
|
|
||||||
|
|
||||||
|
|
||||||
def _wait(master: Conversation, now: datetime) -> bool:
|
|
||||||
"""Не влезать в живую реплику: сдвинуться, а не пропасть."""
|
|
||||||
spoke = _aware(master.last_user_activity_at)
|
|
||||||
return spoke is not None and now - spoke < FRESH_USER
|
|
||||||
|
|
||||||
|
|
||||||
async def _riding(run: JobRun, master: Conversation, now: datetime) -> bool:
|
|
||||||
"""Есть ли впереди в этом часе отложенный инжект, с которым стоит слиться."""
|
|
||||||
edge = now.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1)
|
|
||||||
try:
|
|
||||||
rows = await run.scheduler.scheduled(master)
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
_log.exception("досуг: очередь отложенных инжектов не прочиталась")
|
|
||||||
return False
|
|
||||||
for row in rows:
|
|
||||||
raw = row.get("execute_after")
|
|
||||||
if not isinstance(raw, str):
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
when = datetime.fromisoformat(raw)
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
if now < when < edge:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
async def tick(run: JobRun) -> None:
|
|
||||||
"""Час пробил: решить, тик ли это, дождаться тишины в мастере и разбудить."""
|
|
||||||
text = read(LEISURE)
|
|
||||||
if not enabled(text):
|
|
||||||
_log.info("досуг: выключен в %s", LEISURE.name)
|
|
||||||
return
|
|
||||||
now = datetime.now(ZONE)
|
|
||||||
plan = plan_of(text, day_of(now))
|
|
||||||
if now.hour not in tick_hours(plan.quiet):
|
|
||||||
_log.info("досуг: %02d:00 - тихий час без тика", now.hour)
|
|
||||||
return
|
|
||||||
master = await run.master()
|
|
||||||
if master is None:
|
|
||||||
_log.error("досуг: открытого мастера нет, тик пропал")
|
|
||||||
return
|
|
||||||
attempt = int(run.payload.get("attempt", 0))
|
|
||||||
if (await run.conversations.busy(master) or _wait(master, now)) and (
|
|
||||||
attempt < BUSY_ATTEMPTS
|
|
||||||
):
|
|
||||||
run.payload["attempt"] = attempt + 1
|
|
||||||
await run.retry_in(BUSY_RETRY)
|
|
||||||
_log.info("досуг: мастер занят, сдвиг на %s (попытка %s)", BUSY_RETRY, attempt)
|
|
||||||
return
|
|
||||||
riding = await _riding(run, master, now)
|
|
||||||
kind = kind_of(now.hour, plan)
|
|
||||||
heard = echo(now) if kind == "empty" else None
|
|
||||||
await run.inject_master(
|
|
||||||
brief(now, plan, heard=heard),
|
|
||||||
urgency="normal" if riding else "wake",
|
|
||||||
origin="досуг",
|
|
||||||
)
|
|
||||||
_log.info(
|
|
||||||
"досуг: тик %02d:00 (%s), слияние с отложенным инжектом: %s",
|
|
||||||
now.hour,
|
|
||||||
kind,
|
|
||||||
riding,
|
|
||||||
)
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
"""Раз в 3 часа: хвосты логов своих стеков; в мастер уходит только новое.
|
|
||||||
|
|
||||||
Мандат Бобра (20.09.2026): «можешь и логи периодами посматривать на предмет
|
|
||||||
ошибок». Держит это механизм, а не память модели: код ходит по стекам своей
|
|
||||||
области, а мастеру достаётся инжект только тогда, когда в хвостах появилось
|
|
||||||
то, чего в прошлом проходе не было. Джоб ничего не чинит - только читает.
|
|
||||||
|
|
||||||
Стеки берутся честным листанием (`Komodo.listing`), а не первой страницей
|
|
||||||
`ListStacks`, и длина сверяется с `GetStacksSummary.total`: расхождение едет
|
|
||||||
и в лог, и в инжект - неполный список хуже пустого.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
from typing import TYPE_CHECKING, Any
|
|
||||||
|
|
||||||
from beaver_agent.hands import KOMODO
|
|
||||||
from beaver_agent.hands.komodo_logs import (
|
|
||||||
SCOPE,
|
|
||||||
TAIL,
|
|
||||||
Seen,
|
|
||||||
StackScan,
|
|
||||||
Sweep,
|
|
||||||
brief,
|
|
||||||
daily,
|
|
||||||
digest,
|
|
||||||
note,
|
|
||||||
read_stack,
|
|
||||||
record,
|
|
||||||
)
|
|
||||||
from beaver_agent.vault import STATE_DIR, ZONE
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from beaver_gateway.jobs.scheduler import JobRun
|
|
||||||
|
|
||||||
from beaver_agent.hands.komodo import Komodo
|
|
||||||
|
|
||||||
_log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
STATE_FILE = STATE_DIR / "логи-флота.json"
|
|
||||||
"""Состояние прохода: что уже видели и о чём уже сказали. Перезаписывается."""
|
|
||||||
JOURNAL_FILE = STATE_DIR / "логи-флота.jsonl"
|
|
||||||
"""Журнал прогонов, только дописывается: состояние - снимок, истории в нём нет,
|
|
||||||
а пороги всплеска и глушения взяты из рассуждения и через неделю живой работы
|
|
||||||
пересматриваются по числам. Читать: `jq` по строкам, поля - см. `record()`."""
|
|
||||||
PARALLEL = 4
|
|
||||||
"""Логи тянутся с periphery: четырёх параллельных хватает, больше - грубо."""
|
|
||||||
|
|
||||||
|
|
||||||
async def _one(komodo: Komodo, stack: str, tail: int, gate: asyncio.Semaphore) -> Any:
|
|
||||||
async with gate:
|
|
||||||
try:
|
|
||||||
log = await komodo.read(
|
|
||||||
"GetStackLog",
|
|
||||||
{"stack": stack, "services": [], "tail": tail, "timestamps": True},
|
|
||||||
)
|
|
||||||
except Exception as exc: # noqa: BLE001 - стек не прочитан, но проход идёт
|
|
||||||
_log.warning("логи: %s не прочитан: %s", stack, exc)
|
|
||||||
return StackScan(stack=stack, error=f"{type(exc).__name__}: {exc}"[:200])
|
|
||||||
return read_stack(stack, log)
|
|
||||||
|
|
||||||
|
|
||||||
async def sweep(
|
|
||||||
komodo: Komodo,
|
|
||||||
*,
|
|
||||||
scope: tuple[str, ...] = SCOPE,
|
|
||||||
tail: int = TAIL,
|
|
||||||
now: datetime | None = None,
|
|
||||||
) -> Sweep:
|
|
||||||
"""Обойти стеки области и снять хвосты; Komodo молчит - так и скажем."""
|
|
||||||
at = now or datetime.now(UTC)
|
|
||||||
try:
|
|
||||||
stacks = await komodo.listing("ListStacks")
|
|
||||||
except Exception as exc: # noqa: BLE001 - недоступный Komodo это не «чисто»
|
|
||||||
_log.exception("логи: Komodo не отдал список стеков")
|
|
||||||
return Sweep(
|
|
||||||
at=at,
|
|
||||||
unreachable=f"{type(exc).__name__}: {exc}"[:200],
|
|
||||||
tail=tail,
|
|
||||||
scope=scope,
|
|
||||||
)
|
|
||||||
summary: int | None = None
|
|
||||||
try:
|
|
||||||
summary = int((await komodo.read("GetStacksSummary", {}))["total"])
|
|
||||||
except Exception: # noqa: BLE001 - сверка необязательна, проход без неё идёт
|
|
||||||
_log.warning("логи: GetStacksSummary не прочитан, сверить длину списка нечем")
|
|
||||||
if summary is not None and summary != len(stacks):
|
|
||||||
_log.error(
|
|
||||||
"логи: листание дало %s стеков, GetStacksSummary - %s", len(stacks), summary
|
|
||||||
)
|
|
||||||
mine = sorted(s["name"] for s in stacks if s["info"].get("server_name") in scope)
|
|
||||||
gate = asyncio.Semaphore(PARALLEL)
|
|
||||||
scans = await asyncio.gather(*(_one(komodo, name, tail, gate) for name in mine))
|
|
||||||
return Sweep(
|
|
||||||
at=at,
|
|
||||||
scans=tuple(scans),
|
|
||||||
in_scope=len(mine),
|
|
||||||
fleet=len(stacks),
|
|
||||||
summary=summary,
|
|
||||||
tail=tail,
|
|
||||||
scope=scope,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def logs_watch(run: JobRun) -> None:
|
|
||||||
"""Проход по логам: состояние на диске, инжект - на новом и раз в сутки.
|
|
||||||
|
|
||||||
Инжекта два и они разные: обычный - про новое, всплески и отбой; суточный -
|
|
||||||
про длящееся, которое в проходах молчит. Первый проход суток у нас в 01:40,
|
|
||||||
он же и несёт сводку: в 00:00 ротация мастера, лезть туда незачем.
|
|
||||||
"""
|
|
||||||
if KOMODO is None:
|
|
||||||
_log.info("логи: руки komodo нет, проход пропущен")
|
|
||||||
return
|
|
||||||
seen = Seen.load(STATE_FILE)
|
|
||||||
report = digest(seen, await sweep(KOMODO), ZONE)
|
|
||||||
if report.worth_telling and await run.inject_master(
|
|
||||||
brief(report, ZONE), urgency="normal", origin="логи"
|
|
||||||
):
|
|
||||||
seen.mark_told(report)
|
|
||||||
if report.summary and await run.inject_master(
|
|
||||||
daily(report, ZONE), urgency="normal", origin="логи"
|
|
||||||
):
|
|
||||||
seen.mark_daily(report)
|
|
||||||
seen.save()
|
|
||||||
row = record(report, ZONE) | {"pass_of_day": seen.passes_today}
|
|
||||||
try:
|
|
||||||
note(JOURNAL_FILE, row)
|
|
||||||
except OSError:
|
|
||||||
_log.exception("логи: строка прогона не дописалась в журнал")
|
|
||||||
_log.info(
|
|
||||||
"логи: новое %s, всплеск %s, стихло %s, длится %s (сводка %s), "
|
|
||||||
"не прочитано %s, стеков %s из %s, приглушено %s",
|
|
||||||
len(report.new),
|
|
||||||
len(report.surge),
|
|
||||||
len(report.quiet),
|
|
||||||
len(report.ongoing),
|
|
||||||
report.summary,
|
|
||||||
len(report.trouble),
|
|
||||||
len(report.sweep.read),
|
|
||||||
report.sweep.in_scope,
|
|
||||||
len(report.muted),
|
|
||||||
)
|
|
||||||
@@ -11,9 +11,7 @@ from beaver_agent.memory.curator import briefing
|
|||||||
from beaver_agent.memory.watch import CURATOR_WATCH
|
from beaver_agent.memory.watch import CURATOR_WATCH
|
||||||
from beaver_agent.vault import (
|
from beaver_agent.vault import (
|
||||||
CURATOR_JOURNAL,
|
CURATOR_JOURNAL,
|
||||||
CURATOR_RUNS,
|
|
||||||
DAYS,
|
DAYS,
|
||||||
LEISURE,
|
|
||||||
REPLIES,
|
REPLIES,
|
||||||
STATE,
|
STATE,
|
||||||
STATE_MAX_LINES,
|
STATE_MAX_LINES,
|
||||||
@@ -58,11 +56,9 @@ async def curate(run: JobRun) -> None:
|
|||||||
text = briefing(
|
text = briefing(
|
||||||
vault=VAULT,
|
vault=VAULT,
|
||||||
journal=CURATOR_JOURNAL,
|
journal=CURATOR_JOURNAL,
|
||||||
runs=CURATOR_RUNS,
|
|
||||||
replies_dir=REPLIES,
|
replies_dir=REPLIES,
|
||||||
watched=CURATOR_WATCH,
|
watched=CURATOR_WATCH,
|
||||||
tz=TZ,
|
tz=TZ,
|
||||||
skip=(LEISURE,),
|
|
||||||
)
|
)
|
||||||
if text is None:
|
if text is None:
|
||||||
_log.info("curator: nothing new since the last run")
|
_log.info("curator: nothing new since the last run")
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
"""Раз в час: пора ли сменить мастера (ночь, возраст, размер контекста).
|
"""Раз в час: пора ли сменить мастера (ночь, возраст, размер контекста)."""
|
||||||
|
|
||||||
Запуск руками (кнопка в админке, `POST /api/jobs/ротация/run`) ротирует мастера
|
|
||||||
сразу, с хендаутом: так новая сессия подхватывает свежие гранулы vault.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -16,6 +12,5 @@ _log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
async def rotate(run: JobRun) -> None:
|
async def rotate(run: JobRun) -> None:
|
||||||
force = "manual" if run.trigger == "manual" else None
|
for master in await run.rotate():
|
||||||
for master in await run.rotate(force=force):
|
|
||||||
_log.info("rotated master -> %s", master.external_id)
|
_log.info("rotated master -> %s", master.external_id)
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
"""Сторож собственных прогонов: раз в час читает `job_runs` и будит человека.
|
|
||||||
|
|
||||||
Подключение к базе - то же самое, которым пишет сам планировщик
|
|
||||||
(`run.conversations.db.session()` и модель `JobRunRecord`): своей строки
|
|
||||||
подключения у джоба нет, и второй правды о том, где журнал, тоже.
|
|
||||||
|
|
||||||
Кроны и зона, в которой они читаются, берутся у самого планировщика
|
|
||||||
(`run.scheduler.jobs`, `run.scheduler.tz`): давность меряется по тому
|
|
||||||
расписанию, что реально зарегистрировано, а не по копии списка.
|
|
||||||
|
|
||||||
Устроен как сторож соседа (`beaver_agent/jobs/heartbeat.py`): состояние
|
|
||||||
файлом в `мета/бобер/.состояние/`, тревога - спавном джоба к триажу, и
|
|
||||||
запись «сказали» ставится только после того, как тревога ушла.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from beaver_gateway.storage.models import JobRunRecord
|
|
||||||
from sqlmodel import col, func, select
|
|
||||||
|
|
||||||
from beaver_agent.hands.heartbeat import BOOT
|
|
||||||
from beaver_agent.hands.runs import (
|
|
||||||
STREAK,
|
|
||||||
WINDOW,
|
|
||||||
Report,
|
|
||||||
Run,
|
|
||||||
Runs,
|
|
||||||
alarm_text,
|
|
||||||
clear_text,
|
|
||||||
empty_text,
|
|
||||||
look,
|
|
||||||
quiet_text,
|
|
||||||
)
|
|
||||||
from beaver_agent.vault import STATE_DIR
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from beaver_gateway.jobs.scheduler import JobRun
|
|
||||||
|
|
||||||
_log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
STATE_FILE = STATE_DIR / "прогоны.json"
|
|
||||||
"""Кому уже сказали. Без файла тревога о лежащем джобе повторится, а «отбой»
|
|
||||||
по тому, о ком говорили до потери файла, не придёт - показания не осталось."""
|
|
||||||
|
|
||||||
TITLE = "прогоны"
|
|
||||||
|
|
||||||
LIMIT = 20000
|
|
||||||
"""Потолок строк за окно. Неделя нормальной работы - это около 1700 строк
|
|
||||||
(`вайбграм` раз в 10 минут даёт 1008 из них); упереться в потолок можно
|
|
||||||
только при шторме, и тогда серии считаются по новейшим, а недостающий хвост
|
|
||||||
честно виден как `capped` в тексте тревоги."""
|
|
||||||
|
|
||||||
BRIEF = (
|
|
||||||
"Сторож собственных прогонов (таблица `job_runs`, окно неделя) нашёл "
|
|
||||||
"лежащие джобы: у каждого {streak} и больше падений подряд, считая от "
|
|
||||||
"последнего прогона.\n"
|
|
||||||
"{body}\n"
|
|
||||||
'Будить владельца - inject(conversation="master", urgency="wake", '
|
|
||||||
"text=до 3 строк: какой джоб лежит, сколько прогонов подряд и что в "
|
|
||||||
"ошибке). Это авария у НАС, а не у соседа: джоб пишется в журнал каждый "
|
|
||||||
"прогон, и раз он падает подряд, сам он не починится. Подробности - "
|
|
||||||
"в логах gateway. Если inject вернул ошибку - это сбой доставки, а не "
|
|
||||||
"«не срочно»: повтори один раз с тем же текстом."
|
|
||||||
)
|
|
||||||
|
|
||||||
QUIET_BRIEF = (
|
|
||||||
"Сторож собственных прогонов нашёл джобы, которые ПЕРЕСТАЛИ ЗАПУСКАТЬСЯ: "
|
|
||||||
"у каждого есть крон, а последней записи в журнале `job_runs` больше "
|
|
||||||
"двух периодов.\n"
|
|
||||||
"{body}\n"
|
|
||||||
'Будить владельца - inject(conversation="master", urgency="wake", '
|
|
||||||
"text=до 3 строк: какой джоб молчит, сколько и при каком периоде). Это "
|
|
||||||
"не падение: джоб не падает, его просто никто не зовёт. Смотреть стоит "
|
|
||||||
"расписание (не снят ли джоб из `beaver_agent/jobs/__init__.py`), кроны "
|
|
||||||
"в pgqueuer и лог gateway на старте - джоб, упавший на импорте, не "
|
|
||||||
"регистрируется вовсе. Если inject вернул ошибку - это сбой доставки, "
|
|
||||||
"а не «не срочно»: повтори один раз с тем же текстом."
|
|
||||||
)
|
|
||||||
|
|
||||||
CLEAR_BRIEF = (
|
|
||||||
"Сторож собственных прогонов: то, о чём он тревожился, починилось.\n"
|
|
||||||
"{body}\n"
|
|
||||||
'Владельца НЕ будить: inject(conversation="master", urgency="normal", '
|
|
||||||
"text=одна строка «отбой»). Человек знает про тревогу, ему нужно знать, "
|
|
||||||
"что она кончилась."
|
|
||||||
)
|
|
||||||
|
|
||||||
EMPTY_BRIEF = (
|
|
||||||
"Сторож собственных прогонов не нашёл в журнале НИЧЕГО.\n"
|
|
||||||
"{body}\n"
|
|
||||||
'Будить владельца - inject(conversation="master", urgency="wake", '
|
|
||||||
"text=до 3 строк: что за сутки не отработал ни один джоб и куда смотреть). "
|
|
||||||
"Молчащий планировщик - это молчание всех джобов разом, включая тех, что "
|
|
||||||
"носят человеку почту."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def recent(run: JobRun, *, since: datetime) -> list[Run]:
|
|
||||||
"""Прогоны всех джобов за окно, новейшие первыми.
|
|
||||||
|
|
||||||
`started_at` в базе лежит наивным UTC (так его кладёт планировщик), и
|
|
||||||
сравнение в `where` тоже наивное - как это делает gateway в своих
|
|
||||||
запросах по времени.
|
|
||||||
"""
|
|
||||||
stmt = (
|
|
||||||
select(JobRunRecord)
|
|
||||||
.where(
|
|
||||||
col(JobRunRecord.started_at) >= since.astimezone(UTC).replace(tzinfo=None)
|
|
||||||
)
|
|
||||||
.order_by(col(JobRunRecord.id).desc())
|
|
||||||
.limit(LIMIT)
|
|
||||||
)
|
|
||||||
async with run.conversations.db.session() as session:
|
|
||||||
rows = (await session.exec(stmt)).all()
|
|
||||||
return [_run(row) for row in rows]
|
|
||||||
|
|
||||||
|
|
||||||
def _run(row: JobRunRecord) -> Run:
|
|
||||||
return Run(
|
|
||||||
id=row.id or 0,
|
|
||||||
job=row.job,
|
|
||||||
status=row.status,
|
|
||||||
started_at=(
|
|
||||||
row.started_at
|
|
||||||
if row.started_at.tzinfo
|
|
||||||
else row.started_at.replace(tzinfo=UTC)
|
|
||||||
),
|
|
||||||
error=row.error,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def latest(run: JobRun) -> dict[str, Run]:
|
|
||||||
"""Последний прогон каждого джоба за всю историю журнала.
|
|
||||||
|
|
||||||
Не за окно: джоб, молчащий дольше недели, иначе выглядел бы «ни разу не
|
|
||||||
запускавшимся» и уходил бы в тихий пропуск ровно тогда, когда молчит
|
|
||||||
дольше всего. Тем же запросом gateway строит свою сводку по джобам.
|
|
||||||
"""
|
|
||||||
newest = select(func.max(JobRunRecord.id)).group_by(col(JobRunRecord.job))
|
|
||||||
stmt = select(JobRunRecord).where(col(JobRunRecord.id).in_(newest))
|
|
||||||
async with run.conversations.db.session() as session:
|
|
||||||
rows = (await session.exec(stmt)).all()
|
|
||||||
return {row.job: _run(row) for row in rows}
|
|
||||||
|
|
||||||
|
|
||||||
async def watch_runs(run: JobRun) -> None:
|
|
||||||
"""Прогон сторожа: журнал → решение → тревога, «отбой» или молчание."""
|
|
||||||
now = datetime.now(UTC)
|
|
||||||
try:
|
|
||||||
rows = await recent(run, since=now - WINDOW)
|
|
||||||
last = await latest(run)
|
|
||||||
except Exception: # noqa: BLE001 - отказ запроса это отсутствие показания
|
|
||||||
# Журнал не прочитан - сказать о здоровье джобов нечего, и молчание
|
|
||||||
# базы выдавать за здоровье нельзя ровно так же, как за болезнь.
|
|
||||||
_log.exception("прогоны: журнал не прочитан, прогон без показания")
|
|
||||||
return
|
|
||||||
state = Runs.load(STATE_FILE)
|
|
||||||
crons = {job.name: job.cron for job in run.scheduler.jobs if job.cron}
|
|
||||||
report = look(
|
|
||||||
state,
|
|
||||||
rows,
|
|
||||||
now=now,
|
|
||||||
me=run.job.name,
|
|
||||||
crons=crons,
|
|
||||||
latest=last,
|
|
||||||
tz=run.scheduler.tz,
|
|
||||||
boot=BOOT,
|
|
||||||
)
|
|
||||||
_log.info("прогоны: %s", report.note)
|
|
||||||
await _tell(run, report, state, now=now)
|
|
||||||
state.save()
|
|
||||||
|
|
||||||
|
|
||||||
async def _tell(run: JobRun, report: Report, state: Runs, *, now: datetime) -> None:
|
|
||||||
if report.empty:
|
|
||||||
await _spawn(run, EMPTY_BRIEF.format(body=empty_text(report)))
|
|
||||||
state.empty_alerted = now
|
|
||||||
if report.alarms:
|
|
||||||
await _spawn(run, BRIEF.format(body=alarm_text(report), streak=STREAK))
|
|
||||||
for tail in report.alarms:
|
|
||||||
state.alerted[tail.job] = now
|
|
||||||
if report.quiet:
|
|
||||||
await _spawn(run, QUIET_BRIEF.format(body=quiet_text(report)))
|
|
||||||
for quiet in report.quiet:
|
|
||||||
state.quiet[quiet.job] = now
|
|
||||||
if report.clears or report.resumed:
|
|
||||||
await _spawn(run, CLEAR_BRIEF.format(body=clear_text(report)))
|
|
||||||
for clear in report.clears:
|
|
||||||
state.alerted.pop(clear.job, None)
|
|
||||||
for resumed in report.resumed:
|
|
||||||
state.quiet.pop(resumed.job, None)
|
|
||||||
|
|
||||||
|
|
||||||
async def _spawn(run: JobRun, text: str) -> None:
|
|
||||||
await run.spawn_job(agent="beaver-triage", title=TITLE, text=text)
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""`POST /hooks/t3code`: событие треда - срочно в разговор, который его запустил."""
|
"""`POST /hooks/t3code`: тред завершился, упал или спрашивает - срочно в мастер."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -15,12 +15,7 @@ QUESTION_HINT = (
|
|||||||
|
|
||||||
|
|
||||||
async def t3code_event(run: JobRun) -> None:
|
async def t3code_event(run: JobRun) -> None:
|
||||||
await run.inject_into(
|
await run.inject_master(describe(run.payload), urgency="urgent", origin="t3code")
|
||||||
run.payload.get("origin"),
|
|
||||||
describe(run.payload),
|
|
||||||
urgency="urgent",
|
|
||||||
origin="t3code",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def describe(p: dict[str, Any]) -> str:
|
def describe(p: dict[str, Any]) -> str:
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
"""Раз в ночь: голосовая ветка начинается заново в том же топике."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from beaver_agent.voice import rotate
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from beaver_gateway.jobs.scheduler import JobRun
|
|
||||||
|
|
||||||
|
|
||||||
async def rotate_voice(run: JobRun) -> None:
|
|
||||||
await rotate(run.conversations)
|
|
||||||
@@ -3,9 +3,8 @@
|
|||||||
Крон дёргает детерминированный код, модель судит: здесь собирается брифинг
|
Крон дёргает детерминированный код, модель судит: здесь собирается брифинг
|
||||||
для служебного тёрна `beaver-curator` - новые реплики Бобра целиком и имена
|
для служебного тёрна `beaver-curator` - новые реплики Бобра целиком и имена
|
||||||
файлов, изменившихся с прошлого прогона. Время прошлого прогона куратор
|
файлов, изменившихся с прошлого прогона. Время прошлого прогона куратор
|
||||||
сам пишет первой строкой `мета/бобер/куратор.md`, запись о прогоне - в файл
|
сам пишет первой строкой `мета/бобер/куратор.md`; нет ничего нового -
|
||||||
дня `мета/бобер/куратор/YYYY-MM-DD.md`; нет ничего нового - брифинга нет и
|
брифинга нет и джоб не спавнится.
|
||||||
джоб не спавнится.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -47,16 +46,13 @@ def last_run(journal: Path, *, default: datetime) -> datetime:
|
|||||||
|
|
||||||
|
|
||||||
def new_replies(replies_dir: Path, since: datetime, tz: str) -> list[str]:
|
def new_replies(replies_dir: Path, since: datetime, tz: str) -> list[str]:
|
||||||
"""Строки `реплики/YYYY-MM-DD.md` позже ``since`` (время в строках локальное)."""
|
"""Строки `реплики/YYYY-MM.md` позже ``since`` (время в строках локальное)."""
|
||||||
zone = ZoneInfo(tz)
|
zone = ZoneInfo(tz)
|
||||||
local_since = since.astimezone(zone).replace(tzinfo=None)
|
local_since = since.astimezone(zone).replace(tzinfo=None)
|
||||||
out: list[str] = []
|
out: list[str] = []
|
||||||
if not replies_dir.exists():
|
if not replies_dir.exists():
|
||||||
return out
|
return out
|
||||||
first = f"{local_since:%Y-%m-%d}.md"
|
for path in sorted(replies_dir.glob("????-??.md"))[-2:]:
|
||||||
for path in sorted(replies_dir.glob("????-??-??.md")):
|
|
||||||
if path.name < first:
|
|
||||||
continue
|
|
||||||
for line in path.read_text(encoding="utf-8", errors="ignore").splitlines():
|
for line in path.read_text(encoding="utf-8", errors="ignore").splitlines():
|
||||||
m = REPLY_RE.match(line)
|
m = REPLY_RE.match(line)
|
||||||
if not m:
|
if not m:
|
||||||
@@ -95,35 +91,28 @@ def briefing(
|
|||||||
*,
|
*,
|
||||||
vault: Path,
|
vault: Path,
|
||||||
journal: Path,
|
journal: Path,
|
||||||
runs: Path,
|
|
||||||
replies_dir: Path,
|
replies_dir: Path,
|
||||||
watched: list[Watched],
|
watched: list[Watched],
|
||||||
tz: str,
|
tz: str,
|
||||||
now: datetime | None = None,
|
now: datetime | None = None,
|
||||||
default_window: timedelta = timedelta(hours=24),
|
default_window: timedelta = timedelta(hours=24),
|
||||||
skip: tuple[Path, ...] = (),
|
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
"""Первое сообщение куратору или ``None``, если с прошлого прогона ничего нет."""
|
"""Первое сообщение куратору или ``None``, если с прошлого прогона ничего нет."""
|
||||||
now = now or datetime.now(UTC)
|
now = now or datetime.now(UTC)
|
||||||
since = last_run(journal, default=now - default_window)
|
since = last_run(journal, default=now - default_window)
|
||||||
replies = new_replies(replies_dir, since, tz)
|
replies = new_replies(replies_dir, since, tz)
|
||||||
changed = changed_files(
|
changed = changed_files(
|
||||||
watched,
|
watched, since, vault, skip=(str(journal.relative_to(vault)),)
|
||||||
since,
|
|
||||||
vault,
|
|
||||||
skip=tuple(str(p.relative_to(vault)) for p in (journal, *skip)),
|
|
||||||
)
|
)
|
||||||
if not replies and not changed:
|
if not replies and not changed:
|
||||||
return None
|
return None
|
||||||
zone = ZoneInfo(tz)
|
zone = ZoneInfo(tz)
|
||||||
local = now.astimezone(zone)
|
|
||||||
head = (
|
head = (
|
||||||
f"Прогон куратора {local:%Y-%m-%d %H:%M} ({tz}); прошлый - "
|
f"Прогон куратора {now.astimezone(zone):%Y-%m-%d %H:%M} ({tz}); прошлый - "
|
||||||
f"{since.astimezone(zone):%Y-%m-%d %H:%M}. Ниже - что изменилось с тех пор; "
|
f"{since.astimezone(zone):%Y-%m-%d %H:%M}. Ниже - что изменилось с тех пор; "
|
||||||
"остальное читай сам по слоям. В конце запись о прогоне - в "
|
"остальное читай сам по слоям. В конце обнови первую строку "
|
||||||
f"`{(runs / f'{local:%Y-%m-%d}.md').relative_to(vault)}`, а в "
|
f"`{journal.relative_to(vault)}`: `последний прогон: "
|
||||||
f"`{journal.relative_to(vault)}` только первая строка: `последний прогон: "
|
f"{now.astimezone(zone).isoformat(timespec='seconds')}`."
|
||||||
f"{local.isoformat(timespec='seconds')}`."
|
|
||||||
)
|
)
|
||||||
parts = [head]
|
parts = [head]
|
||||||
if replies:
|
if replies:
|
||||||
|
|||||||
@@ -2,9 +2,6 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import time
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from beaver_gateway.frontends.turn_record import slugify
|
from beaver_gateway.frontends.turn_record import slugify
|
||||||
@@ -18,11 +15,12 @@ from beaver_agent.vault import (
|
|||||||
PORTRAIT,
|
PORTRAIT,
|
||||||
STATE,
|
STATE,
|
||||||
VAULT,
|
VAULT,
|
||||||
ZONE,
|
|
||||||
read,
|
read,
|
||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from beaver_gateway.conversations.distill import DistillContext
|
from beaver_gateway.conversations.distill import DistillContext
|
||||||
from beaver_gateway.conversations.rotation import HandoutContext
|
from beaver_gateway.conversations.rotation import HandoutContext
|
||||||
from beaver_gateway.conversations.texts import NewDayContext, SeedContext
|
from beaver_gateway.conversations.texts import NewDayContext, SeedContext
|
||||||
@@ -34,67 +32,8 @@ def latest_handout() -> Path | None:
|
|||||||
return files[-1] if files else None
|
return files[-1] if files else None
|
||||||
|
|
||||||
|
|
||||||
PROC_INIT = Path("/proc/1")
|
|
||||||
"""mtime PID 1 - момент старта контейнера, проверено в бою."""
|
|
||||||
PACKAGE = Path(__file__).resolve().parent.parent
|
|
||||||
"""Корень пакета: свежесть кода меряем от себя, а не от пути в контейнере."""
|
|
||||||
|
|
||||||
DEPLOY_MARK = "Выкат: процесс поднят {start} ({ago}), свежесть кода {code} - {verdict}."
|
|
||||||
CODE_OLDER = "код старше старта, значит крутится то, что выкатывали"
|
|
||||||
CODE_NEWER = "код НОВЕЕ старта: правка на диске есть, рестарта после неё не было"
|
|
||||||
SAME_MINUTE = "выкат и правка в одну минуту"
|
|
||||||
HOUR = 3600
|
|
||||||
|
|
||||||
|
|
||||||
def _ago(seconds: float) -> str:
|
|
||||||
"""Целыми часами, а меньше часа - минутами."""
|
|
||||||
if seconds < HOUR:
|
|
||||||
return f"{int(seconds // 60)} мин назад"
|
|
||||||
return f"{int(seconds // HOUR)} ч назад"
|
|
||||||
|
|
||||||
|
|
||||||
def _clock(stamp: float) -> str:
|
|
||||||
return f"{datetime.fromtimestamp(stamp, ZONE):%Y-%m-%d %H:%M}"
|
|
||||||
|
|
||||||
|
|
||||||
def deploy_mark(proc: Path = PROC_INIT, package: Path = PACKAGE) -> str | None:
|
|
||||||
"""Когда подняли процесс и насколько свеж код, который в нём крутится.
|
|
||||||
|
|
||||||
Две цифры рядом: mtime PID 1 - старт контейнера, последний изменённый
|
|
||||||
`*.py` пакета - свежесть исходников. Порознь каждая врёт: код бывает
|
|
||||||
новее старта, и тогда правка на диске есть, а рестарта после неё не было.
|
|
||||||
Любая осечка (нет `/proc`, нет прав, пустой пакет) - None: сид не имеет
|
|
||||||
права упасть из-за этой отметки.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
started = proc.stat().st_mtime
|
|
||||||
sources = [
|
|
||||||
path.stat().st_mtime
|
|
||||||
for path in package.rglob("*.py")
|
|
||||||
if "__pycache__" not in path.parts
|
|
||||||
]
|
|
||||||
if not sources:
|
|
||||||
return None
|
|
||||||
code = max(sources)
|
|
||||||
gap = code - started
|
|
||||||
if abs(gap) < 60:
|
|
||||||
verdict = SAME_MINUTE
|
|
||||||
elif gap > 0:
|
|
||||||
verdict = CODE_NEWER
|
|
||||||
else:
|
|
||||||
verdict = CODE_OLDER
|
|
||||||
return DEPLOY_MARK.format(
|
|
||||||
start=_clock(started),
|
|
||||||
ago=_ago(time.time() - started),
|
|
||||||
code=_clock(code),
|
|
||||||
verdict=verdict,
|
|
||||||
)
|
|
||||||
except (OSError, ValueError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def seed_body(ctx: SeedContext) -> str | None:
|
def seed_body(ctx: SeedContext) -> str | None:
|
||||||
"""Утренний сид: хендаут, шапка состояния, портрет Бобра, отметка о выкате."""
|
"""Утренний сид: последний хендаут, шапка состояния, портрет Бобра."""
|
||||||
if ctx.seed != "morning":
|
if ctx.seed != "morning":
|
||||||
return None
|
return None
|
||||||
handout = latest_handout()
|
handout = latest_handout()
|
||||||
@@ -115,9 +54,6 @@ def seed_body(ctx: SeedContext) -> str | None:
|
|||||||
"Портрет Бобра (шапка "
|
"Портрет Бобра (шапка "
|
||||||
f"`{PORTRAIT.relative_to(VAULT)}`, лента - там же):\n{portrait}"
|
f"`{PORTRAIT.relative_to(VAULT)}`, лента - там же):\n{portrait}"
|
||||||
)
|
)
|
||||||
mark = deploy_mark()
|
|
||||||
if mark:
|
|
||||||
parts.append(mark)
|
|
||||||
return "\n\n".join(parts)
|
return "\n\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
@@ -151,7 +87,6 @@ SAME_DAY = (
|
|||||||
ROTATION_WHY = {
|
ROTATION_WHY = {
|
||||||
"age": "прошло больше 36 часов",
|
"age": "прошло больше 36 часов",
|
||||||
"context": "контекст стал слишком большим",
|
"context": "контекст стал слишком большим",
|
||||||
"manual": "Бобёр запустил ротацию руками",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,16 +3,14 @@
|
|||||||
Gateway путей не знает; здесь - что искать по тексту сообщения и куда писать
|
Gateway путей не знает; здесь - что искать по тексту сообщения и куда писать
|
||||||
сказанное. ``Recall.block`` отдаёт указатели, не выводы: карточка упомянутого
|
сказанное. ``Recall.block`` отдаёт указатели, не выводы: карточка упомянутого
|
||||||
человека, его записки о нём (`мета/бобер/наблюдения/<имя> - наблюдения.md`),
|
человека, его записки о нём (`мета/бобер/наблюдения/<имя> - наблюдения.md`),
|
||||||
дни дневника, где он встречался, и раз в день - подошедшие сроки из досок и
|
дни дневника, где он встречался, и раз в день - подошедшие сроки из досок.
|
||||||
ближайшая неделя календарей.
|
|
||||||
``ReplyLog.write`` кладёт каждую реплику Бобра из мастера и веток в
|
``ReplyLog.write`` кладёт каждую реплику Бобра из мастера и веток в
|
||||||
`мета/бобер/реплики/YYYY-MM-DD.md` - единственное место, где они grep-абельны:
|
`мета/бобер/реплики/YYYY-MM.md` - единственное место, где они grep-абельны:
|
||||||
транскрипт живёт в Postgres, а глубокие чаты и так лежат файлами в `💬 чаты/`.
|
транскрипт живёт в Postgres, а глубокие чаты и так лежат файлами в `💬 чаты/`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
@@ -21,25 +19,14 @@ from typing import TYPE_CHECKING
|
|||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from beaver_gateway.conversations.envelope import RecallContext
|
from beaver_gateway.conversations.envelope import RecallContext
|
||||||
from beaver_gateway.conversations.texts import UserSaid
|
from beaver_gateway.conversations.texts import UserSaid
|
||||||
|
|
||||||
from beaver_agent.hands.calendars import Calendars, Event, Upcoming
|
|
||||||
|
|
||||||
_log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
LABEL = "справка (указатели по сообщению, не выводы; открой, если относится к вопросу):"
|
LABEL = "справка (указатели по сообщению, не выводы; открой, если относится к вопросу):"
|
||||||
VOWELS = "аеёиоуыэюя"
|
VOWELS = "аеёиоуыэюя"
|
||||||
KINDS = {"master": "мастер", "branch": "ветка"}
|
KINDS = {"master": "мастер", "branch": "ветка"}
|
||||||
CALENDAR = "🗓 календарь"
|
|
||||||
CALENDAR_LIMIT = 40
|
|
||||||
"""Предохранитель на разовые события; повседневные повторы под него не идут."""
|
|
||||||
CALENDAR_NAMES = 5
|
|
||||||
"""Сколько названий показывать в свёрнутой строке повседневных повторов."""
|
|
||||||
CALENDAR_ERROR_MAX = 90
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -211,16 +198,11 @@ class Recall:
|
|||||||
tz: str = "Europe/Warsaw"
|
tz: str = "Europe/Warsaw"
|
||||||
diary_scan: int = 400
|
diary_scan: int = 400
|
||||||
"""Сколько последних дней дневника просматривать на упоминания."""
|
"""Сколько последних дней дневника просматривать на упоминания."""
|
||||||
mute: frozenset[str] = frozenset()
|
|
||||||
"""Агенты, которым справку не показывают: чужие люди, чужие уши."""
|
|
||||||
calendar: Calendars | None = None
|
|
||||||
"""Ical-фиды календарей; None - календари не настроены, секции нет вовсе."""
|
|
||||||
_people: People | None = field(default=None, init=False, repr=False)
|
_people: People | None = field(default=None, init=False, repr=False)
|
||||||
_diary_cache: dict[str, tuple[float, list[str]]] = field(
|
_diary_cache: dict[str, tuple[float, list[str]]] = field(
|
||||||
default_factory=dict, init=False, repr=False
|
default_factory=dict, init=False, repr=False
|
||||||
)
|
)
|
||||||
_deadlines_day: date | None = field(default=None, init=False, repr=False)
|
_deadlines_day: date | None = field(default=None, init=False, repr=False)
|
||||||
_calendar_day: date | None = field(default=None, init=False, repr=False)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def people(self) -> People:
|
def people(self) -> People:
|
||||||
@@ -229,13 +211,10 @@ class Recall:
|
|||||||
return self._people
|
return self._people
|
||||||
|
|
||||||
def block(self, ctx: RecallContext) -> str | None:
|
def block(self, ctx: RecallContext) -> str | None:
|
||||||
if ctx.agent in self.mute:
|
|
||||||
return None
|
|
||||||
today = ctx.now.astimezone(ZoneInfo(self.tz)).date()
|
today = ctx.now.astimezone(ZoneInfo(self.tz)).date()
|
||||||
lines = [self.person_line(p) for p in mentions(ctx.text, self.people.all())]
|
lines = [self.person_line(p) for p in mentions(ctx.text, self.people.all())]
|
||||||
if ctx.kind == "master":
|
if ctx.kind == "master":
|
||||||
lines += self.deadlines(today)
|
lines += self.deadlines(today)
|
||||||
lines += self.calendar_lines(ctx.now, today)
|
|
||||||
if not lines:
|
if not lines:
|
||||||
return None
|
return None
|
||||||
return "\n".join([LABEL, *lines])
|
return "\n".join([LABEL, *lines])
|
||||||
@@ -319,89 +298,18 @@ class Recall:
|
|||||||
found.sort()
|
found.sort()
|
||||||
return [f"📆 {due:%m-%d} {board}: {task}" for due, board, task in found[:limit]]
|
return [f"📆 {due:%m-%d} {board}: {task}" for due, board, task in found[:limit]]
|
||||||
|
|
||||||
def calendar_lines(self, now: datetime, today: date) -> list[str]:
|
|
||||||
"""Ближайшая неделя календарей - раз в день, следом за сроками досок.
|
|
||||||
|
|
||||||
Секция печатается всегда, когда до неё дошло: пустое окно - строкой
|
|
||||||
«событий нет», упавший фид - строкой «ОШИБКА» и записью в лог. Молча
|
|
||||||
пропасть она не может, иначе отказ канала неотличим от спокойного
|
|
||||||
календаря - так однажды и пролежала незамеченной сессия в USOS.
|
|
||||||
"""
|
|
||||||
if self.calendar is None or self._calendar_day == today:
|
|
||||||
return []
|
|
||||||
self._calendar_day = today
|
|
||||||
try:
|
|
||||||
found = self.calendar.upcoming(now)
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
_log.exception("календарь: справка не собралась")
|
|
||||||
reason = " ".join(f"{type(exc).__name__}: {exc}".split())
|
|
||||||
return [f"{CALENDAR}: ОШИБКА {reason[:CALENDAR_ERROR_MAX]}"]
|
|
||||||
lines = [f"{CALENDAR}: ОШИБКА {reason}" for reason in found.errors]
|
|
||||||
if found.read:
|
|
||||||
lines += self.calendar_window(found, self.calendar.days)
|
|
||||||
return lines or [f"{CALENDAR}: ОШИБКА фиды не ответили"]
|
|
||||||
|
|
||||||
def calendar_window(self, found: Upcoming, days: int) -> list[str]:
|
|
||||||
"""Окно ответивших фидов: разовые событиями, повседневный быт - одной строкой.
|
|
||||||
|
|
||||||
Разовых за неделю физически мало, и ровно они и есть сигнал: экзамен,
|
|
||||||
встреча, приём. Их видно целиком; лимит тут - предохранитель от абсурда,
|
|
||||||
а не способ уместиться. Ежедневные повторы считаются и называются, но
|
|
||||||
своими строками разовые не вытесняют - иначе секция выглядит здоровой и
|
|
||||||
при этом показывает двое суток вместо недели.
|
|
||||||
"""
|
|
||||||
stamp = f"{found.at:%Y-%m-%d %H:%M}"
|
|
||||||
if not found.events:
|
|
||||||
return [f"{CALENDAR} ({days} дней): событий нет, данные на {stamp}"]
|
|
||||||
zone = ZoneInfo(self.tz)
|
|
||||||
singles = [event for event in found.events if not event.routine]
|
|
||||||
routine = [event for event in found.events if event.routine]
|
|
||||||
shown = singles[:CALENDAR_LIMIT]
|
|
||||||
head = (
|
|
||||||
f"{CALENDAR} ({days} дней), разовых {len(singles)}, "
|
|
||||||
f"повседневных {len(routine)}, данные на {stamp}:"
|
|
||||||
)
|
|
||||||
lines = [head]
|
|
||||||
lines += [f" - {event_line(event, zone)}" for event in shown]
|
|
||||||
if len(singles) > len(shown):
|
|
||||||
lines.append(f" … и ещё {len(singles) - len(shown)} разовых в окне")
|
|
||||||
if routine:
|
|
||||||
lines.append(
|
|
||||||
f" - повседневных повторов: {len(routine)} ({routine_names(routine)})"
|
|
||||||
)
|
|
||||||
return lines
|
|
||||||
|
|
||||||
|
|
||||||
def routine_names(events: list[Event], *, limit: int = CALENDAR_NAMES) -> str:
|
|
||||||
"""Уникальные названия бытовых повторов по первому появлению, до `limit`."""
|
|
||||||
names = list(dict.fromkeys(event.title for event in events))
|
|
||||||
return ", ".join(names[:limit]) + (" и др." if len(names) > limit else "")
|
|
||||||
|
|
||||||
|
|
||||||
def event_line(event: Event, zone: ZoneInfo) -> str:
|
|
||||||
"""Дата, начало, название и место - одной строкой в зоне агента."""
|
|
||||||
start = event.start.astimezone(zone)
|
|
||||||
when = "весь день" if event.all_day else f"{start:%H:%M}"
|
|
||||||
place = f" · {event.place}" if event.place else ""
|
|
||||||
return f"{start:%m-%d} {when} {event.title}{place}"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ReplyLog:
|
class ReplyLog:
|
||||||
"""`мета/бобер/реплики/YYYY-MM-DD.md`: строка на реплику из мастера и веток."""
|
"""`мета/бобер/реплики/YYYY-MM.md`: строка на реплику Бобра из мастера и веток."""
|
||||||
|
|
||||||
dir: Path
|
dir: Path
|
||||||
tz: str = "Europe/Warsaw"
|
tz: str = "Europe/Warsaw"
|
||||||
max_chars: int = 600
|
max_chars: int = 600
|
||||||
mute: frozenset[str] = frozenset()
|
|
||||||
"""Агенты, чьи разговоры в лог реплик не идут."""
|
|
||||||
|
|
||||||
def write(self, said: UserSaid) -> None:
|
def write(self, said: UserSaid) -> None:
|
||||||
if said.agent in self.mute:
|
|
||||||
return
|
|
||||||
text = said.text
|
text = said.text
|
||||||
seeded = text.startswith("[сид:")
|
if text.startswith("[сид:"):
|
||||||
if seeded:
|
|
||||||
# спавн ветки с текстом: строка сида едет над самим сообщением
|
# спавн ветки с текстом: строка сида едет над самим сообщением
|
||||||
text = text.split("\n\n", 1)[1] if "\n\n" in text else ""
|
text = text.split("\n\n", 1)[1] if "\n\n" in text else ""
|
||||||
flat = " ⏎ ".join(ln.strip() for ln in text.strip().splitlines() if ln.strip())
|
flat = " ⏎ ".join(ln.strip() for ln in text.strip().splitlines() if ln.strip())
|
||||||
@@ -413,14 +321,11 @@ class ReplyLog:
|
|||||||
where = KINDS.get(said.kind, said.kind)
|
where = KINDS.get(said.kind, said.kind)
|
||||||
if said.kind != "master" and said.title:
|
if said.kind != "master" and said.title:
|
||||||
where = f"{where} «{said.title}»"
|
where = f"{where} «{said.title}»"
|
||||||
if seeded:
|
path = self.dir / f"{local:%Y-%m}.md"
|
||||||
# не человек сказал, а механизм спавна: видно по источнику, не по тексту
|
|
||||||
where = f"{where} (бриф)"
|
|
||||||
path = self.dir / f"{local:%Y-%m-%d}.md"
|
|
||||||
self.dir.mkdir(parents=True, exist_ok=True)
|
self.dir.mkdir(parents=True, exist_ok=True)
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
path.write_text(
|
path.write_text(
|
||||||
f"# реплики Бобра, {local:%Y-%m-%d}\n\n"
|
f"# реплики Бобра, {local:%Y-%m}\n\n"
|
||||||
"> пишет gateway: что Бобёр сказал в мастере и ветках, "
|
"> пишет gateway: что Бобёр сказал в мастере и ветках, "
|
||||||
"строка на сообщение. Глубокие чаты лежат в `💬 чаты/` сами.\n\n",
|
"строка на сообщение. Глубокие чаты лежат в `💬 чаты/` сами.\n\n",
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from beaver_gateway.vault.watch import VaultWatch, WatchRules
|
from beaver_gateway.vault.watch import VaultWatch, WatchRules
|
||||||
|
|
||||||
from beaver_agent.hands import CALENDARS
|
|
||||||
from beaver_agent.memory.curator import Watched
|
from beaver_agent.memory.curator import Watched
|
||||||
from beaver_agent.memory.recall import Recall, ReplyLog
|
from beaver_agent.memory.recall import Recall, ReplyLog
|
||||||
from beaver_agent.vault import (
|
from beaver_agent.vault import (
|
||||||
@@ -19,7 +18,6 @@ from beaver_agent.vault import (
|
|||||||
TZ,
|
TZ,
|
||||||
VAULT,
|
VAULT,
|
||||||
)
|
)
|
||||||
from beaver_agent.voice import AGENT as VOICE
|
|
||||||
|
|
||||||
watch = VaultWatch(
|
watch = VaultWatch(
|
||||||
VAULT,
|
VAULT,
|
||||||
@@ -54,12 +52,8 @@ recall = Recall(
|
|||||||
diary_dir=DIARY,
|
diary_dir=DIARY,
|
||||||
boards_dir=BOARDS,
|
boards_dir=BOARDS,
|
||||||
tz=TZ,
|
tz=TZ,
|
||||||
mute=frozenset({VOICE}),
|
|
||||||
calendar=CALENDARS,
|
|
||||||
)
|
)
|
||||||
"""Голосовому агенту справки нет: за колонкой может стоять кто угодно."""
|
replies = ReplyLog(REPLIES, tz=TZ)
|
||||||
|
|
||||||
replies = ReplyLog(REPLIES, tz=TZ, mute=frozenset({VOICE}))
|
|
||||||
|
|
||||||
CURATOR_WATCH = [
|
CURATOR_WATCH = [
|
||||||
Watched("хендауты", DAYS, "????-??-??.md"),
|
Watched("хендауты", DAYS, "????-??-??.md"),
|
||||||
|
|||||||
@@ -58,8 +58,3 @@ CURATOR = Prompts(job=(*BASE, environment("джоб"), overlay("curator", "ку
|
|||||||
|
|
||||||
QUICK = assemble((*VOICE, PROFILE, overlay("quick", "быстрый")))
|
QUICK = assemble((*VOICE, PROFILE, overlay("quick", "быстрый")))
|
||||||
"""Raycast-агенты: голос и профиль одной строкой, без vault."""
|
"""Raycast-агенты: голос и профиль одной строкой, без vault."""
|
||||||
|
|
||||||
VOICE_AGENT = Prompts(
|
|
||||||
branch=(*VOICE, environment("голосовой"), overlay("voice", "голосовой"))
|
|
||||||
)
|
|
||||||
"""Колонки: голос целиком, но без профиля, поправок и карты - vault ему не виден."""
|
|
||||||
@@ -18,7 +18,6 @@ CHATS = VAULT / "💬 чаты"
|
|||||||
DIARY = VAULT / "📅 дни"
|
DIARY = VAULT / "📅 дни"
|
||||||
PEOPLE = VAULT / "👤 люди"
|
PEOPLE = VAULT / "👤 люди"
|
||||||
BOARDS = VAULT / "📆 доски"
|
BOARDS = VAULT / "📆 доски"
|
||||||
THOUGHTS = VAULT / "🧠 мысли"
|
|
||||||
|
|
||||||
BEAVER = VAULT / "мета" / "бобер"
|
BEAVER = VAULT / "мета" / "бобер"
|
||||||
"""Дом агента: всё, что он ведёт сам, лежит здесь."""
|
"""Дом агента: всё, что он ведёт сам, лежит здесь."""
|
||||||
@@ -32,18 +31,6 @@ NOTES = BEAVER / "наблюдения"
|
|||||||
PORTRAIT = NOTES / "Бобёр - наблюдения.md"
|
PORTRAIT = NOTES / "Бобёр - наблюдения.md"
|
||||||
REPLIES = BEAVER / "реплики"
|
REPLIES = BEAVER / "реплики"
|
||||||
CURATOR_JOURNAL = BEAVER / "куратор.md"
|
CURATOR_JOURNAL = BEAVER / "куратор.md"
|
||||||
"""Первая строка - время прошлого прогона; сами записи лежат в `CURATOR_RUNS`."""
|
|
||||||
CURATOR_RUNS = BEAVER / "куратор"
|
|
||||||
OWN_DIARY = BEAVER / "дневник"
|
|
||||||
"""Его собственный дневник, файл на день: то, что он думает, без логов."""
|
|
||||||
JOURNAL = BEAVER / "журнал"
|
|
||||||
"""Рабочий журнал, файл на день: следы тиков, логи, разбор промахов."""
|
|
||||||
LEISURE = BEAVER / "досуг.md"
|
|
||||||
"""Раскладка суток по тикам досуга: пишет её модель, механизм только читает."""
|
|
||||||
STATE_DIR = Path(os.environ.get("BEAVER_STATE_DIR", BEAVER / ".состояние"))
|
|
||||||
"""Машинное состояние джобов (не для чтения моделью): лежит в доме агента, а не
|
|
||||||
в корне vault - структуру верхних папок ведёт владелец. Точка в начале: такой
|
|
||||||
каталог Obsidian не показывает, а watcher смотрит только `.md`. Создаётся сам."""
|
|
||||||
STATE = BEAVER / "состояние.md"
|
STATE = BEAVER / "состояние.md"
|
||||||
STATE_MAX_LINES = 60
|
STATE_MAX_LINES = 60
|
||||||
ATTACHMENTS = BEAVER / "вложения"
|
ATTACHMENTS = BEAVER / "вложения"
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
"""Голосовые точки по квартире: своя ветка, свой дешёвый агент, свои ключи."""
|
|
||||||
|
|
||||||
from beaver_agent.voice.frontend import VoiceFrontend
|
|
||||||
from beaver_agent.voice.texts import KEY, TITLE
|
|
||||||
from beaver_agent.voice.thread import AGENT, FRONTEND, current, open_thread, rotate
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"AGENT",
|
|
||||||
"FRONTEND",
|
|
||||||
"KEY",
|
|
||||||
"TITLE",
|
|
||||||
"VoiceFrontend",
|
|
||||||
"current",
|
|
||||||
"open_thread",
|
|
||||||
"rotate",
|
|
||||||
]
|
|
||||||
@@ -1,266 +0,0 @@
|
|||||||
"""Окно голосовых точек: ESP32 шлёт json на /voice, отвечает дешёвый агент."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from typing import TYPE_CHECKING, Any
|
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from beaver_gateway.agents.claude import ClaudeAgent
|
|
||||||
from beaver_gateway.conversations.turns import content_of
|
|
||||||
from beaver_gateway.frontends.accumulate import StreamAccumulator
|
|
||||||
from beaver_gateway.frontends.base import Frontend
|
|
||||||
from beaver_gateway.frontends.bearer import require_token
|
|
||||||
from beaver_gateway.frontends.sse import (
|
|
||||||
KEEPALIVE,
|
|
||||||
SSE_HEADERS,
|
|
||||||
events_with_heartbeat,
|
|
||||||
sse_pack,
|
|
||||||
)
|
|
||||||
from beaver_gateway.security import audit
|
|
||||||
from fastapi import FastAPI, HTTPException, Request, status
|
|
||||||
from fastapi.responses import JSONResponse, StreamingResponse
|
|
||||||
|
|
||||||
from beaver_agent.voice import texts
|
|
||||||
from beaver_agent.voice.thread import FRONTEND, current, open_thread
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import AsyncIterator, Mapping
|
|
||||||
|
|
||||||
from beaver_gateway.frontends.base import GatewayRuntime
|
|
||||||
from beaver_gateway.storage.models import Conversation
|
|
||||||
|
|
||||||
_log = logging.getLogger("beaver_agent.voice")
|
|
||||||
|
|
||||||
FIELDS = ("room", "device", "content")
|
|
||||||
|
|
||||||
|
|
||||||
class VoiceFrontend(Frontend):
|
|
||||||
"""Одна ветка на всю квартиру, свой агент, свои ключи.
|
|
||||||
|
|
||||||
Тёрн идёт мимо очереди, а значит и мимо конверта: справка по vault в
|
|
||||||
этот разговор не попадает даже случайно. В телеграм-ветку вопрос
|
|
||||||
кладётся отдельной репликой, ответ пишется черновиком, как обычно.
|
|
||||||
|
|
||||||
Отвечает SSE по умолчанию - колонке важно начать говорить раньше;
|
|
||||||
`"stream": false` в теле переключает на один JSON.
|
|
||||||
"""
|
|
||||||
|
|
||||||
name = FRONTEND
|
|
||||||
kinds = ("branch",)
|
|
||||||
path = "/voice"
|
|
||||||
scope = "voice"
|
|
||||||
|
|
||||||
def __init__(self, *, agent: str, timeout: float = 90.0) -> None:
|
|
||||||
self.agent = agent
|
|
||||||
self.timeout = timeout
|
|
||||||
"""Сколько ждать ответа; дальше 504, а тёрн договаривает в телеграм."""
|
|
||||||
self._runtime: GatewayRuntime | None = None
|
|
||||||
self._app: FastAPI | None = None
|
|
||||||
self._lock = asyncio.Lock()
|
|
||||||
self._late: set[asyncio.Task[Any]] = set()
|
|
||||||
|
|
||||||
def configure(self, runtime: GatewayRuntime) -> None:
|
|
||||||
if runtime.conversations is None or runtime.bus is None:
|
|
||||||
msg = "VoiceFrontend: нужны runtime.conversations и runtime.bus"
|
|
||||||
raise RuntimeError(msg)
|
|
||||||
agent = runtime.agents.get(self.agent)
|
|
||||||
if not isinstance(agent, ClaudeAgent) or not agent.serves("branch"):
|
|
||||||
msg = f"VoiceFrontend: агент {self.agent!r} не обслуживает ветки"
|
|
||||||
raise RuntimeError(msg)
|
|
||||||
self._runtime = runtime
|
|
||||||
self._app = self._build_app(runtime)
|
|
||||||
|
|
||||||
def app(self) -> FastAPI | None:
|
|
||||||
return self._app
|
|
||||||
|
|
||||||
def _build_app(self, runtime: GatewayRuntime) -> FastAPI:
|
|
||||||
app = FastAPI(title="beaver-agent / голосовой")
|
|
||||||
|
|
||||||
@app.get("/healthz")
|
|
||||||
async def healthz() -> dict[str, str]:
|
|
||||||
return {"status": "ok", "frontend": self.name}
|
|
||||||
|
|
||||||
@app.post("/")
|
|
||||||
async def ask(request: Request) -> Any:
|
|
||||||
token = await require_token(request, runtime, scope=self.scope)
|
|
||||||
body = await _body(request)
|
|
||||||
room, device, content = (_field(body, name) for name in FIELDS)
|
|
||||||
conv = await self.thread()
|
|
||||||
await audit.log(
|
|
||||||
runtime,
|
|
||||||
actor=f"token:{token}",
|
|
||||||
kind="voice_ask",
|
|
||||||
agent_name=conv.agent_name,
|
|
||||||
conversation=conv.external_id,
|
|
||||||
room=room,
|
|
||||||
device=device,
|
|
||||||
request_id=body.get("request_id"),
|
|
||||||
)
|
|
||||||
_log.info("голос: %s/%s -> %s", room, device, conv.external_id)
|
|
||||||
await runtime.conversations.say(
|
|
||||||
conv, texts.ASKED.format(room=room, device=device, text=content)
|
|
||||||
)
|
|
||||||
prompt = await self._prompt(
|
|
||||||
conv, texts.HEARD.format(room=room, device=device, text=content)
|
|
||||||
)
|
|
||||||
if _flag(body, "stream", default=True):
|
|
||||||
return StreamingResponse(
|
|
||||||
self._sse(conv, prompt, body),
|
|
||||||
media_type="text/event-stream",
|
|
||||||
headers=SSE_HEADERS,
|
|
||||||
)
|
|
||||||
return JSONResponse(await self._answer(conv, prompt, body))
|
|
||||||
|
|
||||||
@app.exception_handler(HTTPException)
|
|
||||||
async def http_error(_request: Request, exc: HTTPException) -> JSONResponse:
|
|
||||||
return JSONResponse(
|
|
||||||
status_code=exc.status_code,
|
|
||||||
content={"error": exc.detail},
|
|
||||||
headers=exc.headers,
|
|
||||||
)
|
|
||||||
|
|
||||||
return app
|
|
||||||
|
|
||||||
async def thread(self) -> Conversation:
|
|
||||||
conversations = self.runtime.conversations
|
|
||||||
async with self._lock:
|
|
||||||
conv = await current(conversations)
|
|
||||||
if conv is not None:
|
|
||||||
return conv
|
|
||||||
return await open_thread(conversations, agent=self.agent)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def runtime(self) -> GatewayRuntime:
|
|
||||||
if self._runtime is None:
|
|
||||||
msg = "VoiceFrontend: configure() не вызывали"
|
|
||||||
raise RuntimeError(msg)
|
|
||||||
return self._runtime
|
|
||||||
|
|
||||||
async def _prompt(self, conv: Conversation, text: str) -> str:
|
|
||||||
seed = await self.runtime.conversations.pending_seed(conv)
|
|
||||||
return f"{seed}\n\n{text}" if seed else text
|
|
||||||
|
|
||||||
async def _answer(
|
|
||||||
self, conv: Conversation, prompt: str, body: Mapping[str, Any]
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
turn_id = _turn_id()
|
|
||||||
task = asyncio.create_task(self._run(conv, prompt, turn_id))
|
|
||||||
self._late.add(task)
|
|
||||||
task.add_done_callback(self._late.discard)
|
|
||||||
done, _ = await asyncio.wait({task}, timeout=self.timeout)
|
|
||||||
if not done:
|
|
||||||
_log.warning("голос: %s не уложился в %.0f c", turn_id, self.timeout)
|
|
||||||
raise HTTPException(
|
|
||||||
status.HTTP_504_GATEWAY_TIMEOUT, "агент не успел; ответ уйдёт в ветку"
|
|
||||||
)
|
|
||||||
return _body_out(body, reply=task.result(), conv=conv)
|
|
||||||
|
|
||||||
async def _run(self, conv: Conversation, prompt: str, turn_id: str) -> str:
|
|
||||||
reply, _capture = await self.runtime.conversations.run_text_turn(
|
|
||||||
conv, prompt, origin="user", turn_id=turn_id
|
|
||||||
)
|
|
||||||
self._published(conv, reply=reply, turn_id=turn_id)
|
|
||||||
return reply
|
|
||||||
|
|
||||||
async def _sse(
|
|
||||||
self, conv: Conversation, prompt: str, body: Mapping[str, Any]
|
|
||||||
) -> AsyncIterator[bytes]:
|
|
||||||
turn_id = _turn_id()
|
|
||||||
acc = StreamAccumulator()
|
|
||||||
model = self.runtime.agents[conv.agent_name].model
|
|
||||||
try:
|
|
||||||
events = self.runtime.conversations.turn(
|
|
||||||
conv,
|
|
||||||
messages=[{"role": "user", "content": content_of(prompt, None)}],
|
|
||||||
origin="user",
|
|
||||||
turn_id=turn_id,
|
|
||||||
)
|
|
||||||
async for event in events_with_heartbeat(events):
|
|
||||||
if event is None:
|
|
||||||
yield KEEPALIVE
|
|
||||||
continue
|
|
||||||
acc.feed(event)
|
|
||||||
chunk = _text_delta(event)
|
|
||||||
if chunk:
|
|
||||||
yield sse_pack("delta", {"text": chunk})
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
_log.exception("голос: тёрн %s упал", turn_id)
|
|
||||||
yield sse_pack("error", {"error": str(exc)})
|
|
||||||
return
|
|
||||||
reply = _text_of(acc.finalize(model=model))
|
|
||||||
self._published(conv, reply=reply, turn_id=turn_id)
|
|
||||||
yield sse_pack("done", _body_out(body, reply=reply, conv=conv))
|
|
||||||
|
|
||||||
def _published(self, conv: Conversation, *, reply: str, turn_id: str) -> None:
|
|
||||||
self.runtime.bus.publish(
|
|
||||||
"reply",
|
|
||||||
conversation_id=conv.external_id,
|
|
||||||
turn_id=turn_id,
|
|
||||||
source=self.name,
|
|
||||||
text=reply,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _body(request: Request) -> Mapping[str, Any]:
|
|
||||||
try:
|
|
||||||
data = await request.json()
|
|
||||||
except json.JSONDecodeError as exc:
|
|
||||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"кривой json: {exc}") from exc
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "тело должно быть объектом")
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
def _flag(body: Mapping[str, Any], name: str, *, default: bool) -> bool:
|
|
||||||
"""Флаг из тела; строку разбираем словом, иначе `"false"` включит стрим."""
|
|
||||||
value = body.get(name)
|
|
||||||
if value is None:
|
|
||||||
return default
|
|
||||||
if isinstance(value, str):
|
|
||||||
return value.strip().lower() in ("1", "true", "yes", "on")
|
|
||||||
return bool(value)
|
|
||||||
|
|
||||||
|
|
||||||
def _field(body: Mapping[str, Any], name: str) -> str:
|
|
||||||
value = body.get(name)
|
|
||||||
if not isinstance(value, str) or not value.strip():
|
|
||||||
raise HTTPException(
|
|
||||||
status.HTTP_400_BAD_REQUEST, f"нужно непустое строковое поле {name!r}"
|
|
||||||
)
|
|
||||||
return value.strip()
|
|
||||||
|
|
||||||
|
|
||||||
def _body_out(
|
|
||||||
body: Mapping[str, Any], *, reply: str, conv: Conversation
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"request_id": body.get("request_id"),
|
|
||||||
"room": body.get("room"),
|
|
||||||
"device": body.get("device"),
|
|
||||||
"reply": reply,
|
|
||||||
"conversation": conv.external_id,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _turn_id() -> str:
|
|
||||||
return f"turn_{uuid4().hex[:12]}"
|
|
||||||
|
|
||||||
|
|
||||||
def _text_delta(event: Any) -> str:
|
|
||||||
if getattr(event, "type", "") != "content_block_delta":
|
|
||||||
return ""
|
|
||||||
delta = getattr(event, "delta", None)
|
|
||||||
if getattr(delta, "type", "") != "text_delta":
|
|
||||||
return ""
|
|
||||||
return getattr(delta, "text", "")
|
|
||||||
|
|
||||||
|
|
||||||
def _text_of(message: Any) -> str:
|
|
||||||
return "\n\n".join(
|
|
||||||
getattr(b, "text", "")
|
|
||||||
for b in message.content
|
|
||||||
if getattr(b, "type", "") == "text"
|
|
||||||
).strip()
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
"""Что видно в телеграм-ветке голосовых точек; промпт - гранулы vault."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
TITLE = "🎤 Голосовой"
|
|
||||||
"""Имя ветки; телеграм заводит топик под этим именем."""
|
|
||||||
|
|
||||||
KEY = "квартира"
|
|
||||||
"""Ключ единственного треда: одна ветка на всю квартиру."""
|
|
||||||
|
|
||||||
ASKED = "🎤 {room} · {device}\n{text}"
|
|
||||||
"""Вопрос, как он показывается в телеграм-ветке."""
|
|
||||||
|
|
||||||
HEARD = "[{room} · {device}] {text}"
|
|
||||||
"""Вопрос, как его видит модель."""
|
|
||||||
|
|
||||||
ROTATED = "🎤 Новый день - ветка начата заново, прошлого разговора агент не помнит."
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
"""Единственная ветка голосовых точек: как её открыть и как сменить за ночь."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from beaver_agent.voice.texts import KEY, ROTATED, TITLE
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from beaver_gateway.conversations.service import Conversations
|
|
||||||
from beaver_gateway.storage.models import Conversation
|
|
||||||
|
|
||||||
AGENT = "beaver-voice"
|
|
||||||
"""Кто сидит в голосовой ветке; ему не видно ни справки, ни vault."""
|
|
||||||
|
|
||||||
FRONTEND = "voice"
|
|
||||||
TELEGRAM = "telegram"
|
|
||||||
|
|
||||||
_log = logging.getLogger("beaver_agent.voice")
|
|
||||||
|
|
||||||
|
|
||||||
async def current(conversations: Conversations) -> Conversation | None:
|
|
||||||
conv = await conversations.find_bound(frontend=FRONTEND, external_id=KEY)
|
|
||||||
return conv if conv is not None and conv.status == "open" else None
|
|
||||||
|
|
||||||
|
|
||||||
async def open_thread(
|
|
||||||
conversations: Conversations, *, agent: str, window: str | None = None
|
|
||||||
) -> Conversation:
|
|
||||||
"""Завести ветку; без ``window`` окно ей открывает телеграм - новым топиком."""
|
|
||||||
conv = await conversations.spawn(
|
|
||||||
kind="branch",
|
|
||||||
agent=agent,
|
|
||||||
title=TITLE,
|
|
||||||
origin=FRONTEND,
|
|
||||||
binding=(TELEGRAM, window) if window else None,
|
|
||||||
)
|
|
||||||
await conversations.bind(conv, frontend=FRONTEND, external_id=KEY)
|
|
||||||
_log.info("голосовая ветка %s (%s)", conv.external_id, agent)
|
|
||||||
return conv
|
|
||||||
|
|
||||||
|
|
||||||
async def window_of(conversations: Conversations, conv: Conversation) -> str | None:
|
|
||||||
for binding in await conversations.bindings(conv):
|
|
||||||
if binding.frontend == TELEGRAM and binding.visible:
|
|
||||||
return binding.external_id
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def rotate(conversations: Conversations) -> Conversation | None:
|
|
||||||
"""Ночью - новая ветка в том же топике; молчавшую за сутки не трогаем."""
|
|
||||||
old = await current(conversations)
|
|
||||||
if old is None or old.last_user_activity_at is None:
|
|
||||||
return None
|
|
||||||
new = await open_thread(
|
|
||||||
conversations, agent=old.agent_name, window=await window_of(conversations, old)
|
|
||||||
)
|
|
||||||
await conversations.close(old)
|
|
||||||
await conversations.say(new, ROTATED)
|
|
||||||
_log.info("голосовая ветка: %s -> %s", old.external_id, new.external_id)
|
|
||||||
return new
|
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
# Роуты beaver. Один апстрим, префиксы не срезаются: gateway сам живёт под
|
# Роуты beaver. Один апстрим, префиксы не срезаются: gateway сам живёт под
|
||||||
# /anthropic, /mcp, /md, /api, /admin, /voice, а / ведёт в админку.
|
# /anthropic, /mcp, /md, /api, /admin, а / ведёт в админку.
|
||||||
# Домен и апстрим - из env сервера (domains.env / .env), tls - забота
|
# Домен и апстрим - из env сервера (domains.env / .env), tls - забота
|
||||||
# серверного Caddyfile.
|
# серверного Caddyfile.
|
||||||
{$BEAVER_DOMAIN} {
|
{$BEAVER_DOMAIN} {
|
||||||
|
|||||||
@@ -3,8 +3,7 @@
|
|||||||
- vault.py - пути и зона агента; prompts.py - как из гранул собирается промпт роли;
|
- vault.py - пути и зона агента; prompts.py - как из гранул собирается промпт роли;
|
||||||
- skills.py - наборы скиллов по окнам; policy.py - что можно трогать в vault;
|
- skills.py - наборы скиллов по окнам; policy.py - что можно трогать в vault;
|
||||||
- hands/ - MCP-руки и кому какая выдана; agents.py - роли и их экземпляры;
|
- hands/ - MCP-руки и кому какая выдана; agents.py - роли и их экземпляры;
|
||||||
- frontends.py - окна; voice/ - голосовые точки по квартире и их ветка;
|
- frontends.py - окна; texts.py - всё, что gateway говорит, по-русски;
|
||||||
- texts.py - всё, что gateway говорит, по-русски;
|
|
||||||
- memory/ - справка, реплики, хендауты, вотчер, куратор; jobs/ - кроны и вебхуки.
|
- memory/ - справка, реплики, хендауты, вотчер, куратор; jobs/ - кроны и вебхуки.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|||||||
+2
-8
@@ -67,12 +67,6 @@ services:
|
|||||||
# клон репы комнаты вайбграма (сервис vibegram-repo), только читать
|
# клон репы комнаты вайбграма (сервис vibegram-repo), только читать
|
||||||
- vibegram:/vibegram:ro
|
- vibegram:/vibegram:ro
|
||||||
- claude-home:/home/beaver-runner/.claude
|
- claude-home:/home/beaver-runner/.claude
|
||||||
# Шара на HDD: сюда агент кладёт то, что отдаёт человеку через
|
|
||||||
# send_file (SEND_FILE_ROOTS по умолчанию = /mnt/hole/shared).
|
|
||||||
# Путь СНАРУЖИ И ВНУТРИ ОДИН И ТОТ ЖЕ - модель пишет в тулзу ровно
|
|
||||||
# тот путь, который видит сама. Каталог на хосте принадлежит
|
|
||||||
# 1001:999 - это uid/gid beaver-runner'а внутри, - mode 2770.
|
|
||||||
- /mnt/hole/shared:/mnt/hole/shared
|
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
# Home Assistant и прочее с network_mode: host
|
# Home Assistant и прочее с network_mode: host
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
@@ -109,8 +103,8 @@ services:
|
|||||||
T3CODE_MCP_HOOK_URL: http://gateway:62990/hooks/t3code
|
T3CODE_MCP_HOOK_URL: http://gateway:62990/hooks/t3code
|
||||||
T3CODE_MCP_STATE: /data/t3code-mcp.json
|
T3CODE_MCP_STATE: /data/t3code-mcp.json
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
# tailnet-адрес дев-VM явно, не завися от DNS хоста
|
# контейнер резолвит имя своего хоста в 127.0.1.1 - даём tailnet-адрес явно
|
||||||
- "dell-dev:${T3_DELL_DEV_ADDR:?T3_DELL_DEV_ADDR=tailnet ip of dell-dev}"
|
- "dell:${T3_DELL_ADDR:?T3_DELL_ADDR=tailnet ip of dell}"
|
||||||
volumes:
|
volumes:
|
||||||
- ./t3code.toml:/config/t3code.toml:ro
|
- ./t3code.toml:/config/t3code.toml:ro
|
||||||
- t3code-state:/data
|
- t3code-state:/data
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ unfixable = ["F401"]
|
|||||||
|
|
||||||
[lint.per-file-ignores]
|
[lint.per-file-ignores]
|
||||||
"config.py" = ["INP001"]
|
"config.py" = ["INP001"]
|
||||||
"scripts/split_logs.py" = ["INP001", "T201"]
|
|
||||||
"tests/*" = ["ALL"]
|
"tests/*" = ["ALL"]
|
||||||
|
|
||||||
[lint.pydocstyle]
|
[lint.pydocstyle]
|
||||||
|
|||||||
@@ -1,283 +0,0 @@
|
|||||||
"""Разовая нарезка вечных и месячных файлов `мета/бобер/` по дням.
|
|
||||||
|
|
||||||
BEAVER_VAULT=<vault> PYTHONPATH=. uv run python scripts/split_logs.py
|
|
||||||
BEAVER_VAULT=<vault> PYTHONPATH=. uv run python scripts/split_logs.py --apply
|
|
||||||
|
|
||||||
Без `--apply` только печатает, что будет.
|
|
||||||
|
|
||||||
Реплики и журнал куратора режутся точно: дата стоит в каждой записи. Дневник
|
|
||||||
режется по заголовкам и датированным строкам: записи под заголовком едут в
|
|
||||||
`дневник/`, строки вида `- 05:4x 12.10 …` - в `журнал/`. Журнал досуга на две
|
|
||||||
трети без дат, поэтому уезжает одним архивом. Оригиналы копируются в
|
|
||||||
`мета/бобер/.архив/<сегодня>/` до первой записи.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
import shutil
|
|
||||||
import sys
|
|
||||||
from collections import defaultdict
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from datetime import date, timedelta
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from beaver_agent.vault import (
|
|
||||||
BEAVER,
|
|
||||||
CURATOR_JOURNAL,
|
|
||||||
CURATOR_RUNS,
|
|
||||||
JOURNAL,
|
|
||||||
LEISURE,
|
|
||||||
OWN_DIARY,
|
|
||||||
REPLIES,
|
|
||||||
VAULT,
|
|
||||||
today,
|
|
||||||
)
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
YEAR = 2026
|
|
||||||
OLD_DIARY = BEAVER / "дневник.md"
|
|
||||||
LEISURE_ARCHIVE = JOURNAL / "архив досуга.md"
|
|
||||||
|
|
||||||
REPLY = re.compile(r"^- (\d{4}-\d{2}-\d{2}) \d{2}:\d{2} · ")
|
|
||||||
RUN = re.compile(r"^## (\d{4}-\d{2}-\d{2})\b")
|
|
||||||
ISO_HEAD = re.compile(r"^#{2,3}\s+(\d{4})-(\d{2})-(\d{2})\b")
|
|
||||||
HEAD = re.compile(r"^#{2,3}\s")
|
|
||||||
HEAD_DAY = re.compile(r"\b(\d{2})\.(\d{2})\b")
|
|
||||||
HEAD_HOUR = re.compile(r"^#{2,3}\s+(\d{2}):[\dx]{2}")
|
|
||||||
LINE = re.compile(r"^- (\d{2}):[\dx]{2}(?:-\d{2}:[\dx]{2})? (\d{2})\.(\d{2})\b")
|
|
||||||
LINE_DAY_FIRST = re.compile(r"^- (\d{2})\.(\d{2}) (\d{2}):[\dx]{2}\b")
|
|
||||||
|
|
||||||
CURATOR_STUB = (
|
|
||||||
"\n# куратор\n\n"
|
|
||||||
"> Здесь только строка прошлого прогона. Записи прогонов - в "
|
|
||||||
"`куратор/YYYY-MM-DD.md`, файл на день, свежие сверху.\n"
|
|
||||||
)
|
|
||||||
DIARY_STUB = (
|
|
||||||
"\n---\n\n"
|
|
||||||
"> С 13.10.2026 дневник лежит по дням: `дневник/YYYY-MM-DD.md` - то, что "
|
|
||||||
"я думаю; следы тиков, логи и разбор промахов - `журнал/YYYY-MM-DD.md`. "
|
|
||||||
"Сюда больше не дописывать.\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Plan:
|
|
||||||
writes: dict[Path, str] = field(default_factory=dict)
|
|
||||||
removes: list[Path] = field(default_factory=list)
|
|
||||||
backups: list[Path] = field(default_factory=list)
|
|
||||||
notes: list[str] = field(default_factory=list)
|
|
||||||
|
|
||||||
def note(self, text: str) -> None:
|
|
||||||
self.notes.append(text)
|
|
||||||
|
|
||||||
|
|
||||||
def _join(blocks: list[str], sep: str) -> str:
|
|
||||||
return sep.join(block.strip("\n") for block in blocks if block.strip()) + "\n"
|
|
||||||
|
|
||||||
|
|
||||||
def _merge(path: Path, old: str, *, old_first: bool) -> str:
|
|
||||||
"""Старое рядом с тем, что уже успели положить в файл дня."""
|
|
||||||
if not path.exists():
|
|
||||||
return old
|
|
||||||
fresh = path.read_text(encoding="utf-8").rstrip("\n") + "\n"
|
|
||||||
return f"{old}\n{fresh}" if old_first else f"{fresh}\n{old}"
|
|
||||||
|
|
||||||
|
|
||||||
def split_replies(plan: Plan) -> None:
|
|
||||||
for month in sorted(REPLIES.glob("????-??.md")):
|
|
||||||
days: dict[str, list[str]] = defaultdict(list)
|
|
||||||
stray = 0
|
|
||||||
for line in month.read_text(encoding="utf-8").splitlines():
|
|
||||||
match = REPLY.match(line)
|
|
||||||
if match:
|
|
||||||
days[match.group(1)].append(line)
|
|
||||||
elif line.strip() and not line.startswith(("#", ">")):
|
|
||||||
stray += 1
|
|
||||||
for day, lines in days.items():
|
|
||||||
path = REPLIES / f"{day}.md"
|
|
||||||
known = (
|
|
||||||
[
|
|
||||||
line
|
|
||||||
for line in path.read_text(encoding="utf-8").splitlines()
|
|
||||||
if REPLY.match(line)
|
|
||||||
]
|
|
||||||
if path.exists()
|
|
||||||
else []
|
|
||||||
)
|
|
||||||
seen = set(lines)
|
|
||||||
merged = sorted(
|
|
||||||
[*lines, *(line for line in known if line not in seen)],
|
|
||||||
key=lambda line: line[:18],
|
|
||||||
)
|
|
||||||
plan.writes[path] = (
|
|
||||||
f"# реплики Бобра, {day}\n\n"
|
|
||||||
"> пишет gateway: что Бобёр сказал в мастере и ветках, "
|
|
||||||
"строка на сообщение. Глубокие чаты лежат в `💬 чаты/` сами.\n\n"
|
|
||||||
+ "\n".join(merged)
|
|
||||||
+ "\n"
|
|
||||||
)
|
|
||||||
plan.removes.append(month)
|
|
||||||
plan.backups.append(month)
|
|
||||||
plan.note(
|
|
||||||
f"{month.name}: {sum(map(len, days.values()))} реплик на {len(days)} дней"
|
|
||||||
+ (f", {stray} строк без даты пропали" if stray else "")
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def split_curator(plan: Plan) -> None:
|
|
||||||
if not CURATOR_JOURNAL.exists():
|
|
||||||
return
|
|
||||||
lines = CURATOR_JOURNAL.read_text(encoding="utf-8").splitlines()
|
|
||||||
days: dict[str, list[str]] = defaultdict(list)
|
|
||||||
runs: list[str] | None = None
|
|
||||||
for line in lines:
|
|
||||||
match = RUN.match(line)
|
|
||||||
if match:
|
|
||||||
runs = days[match.group(1)]
|
|
||||||
runs.append(line)
|
|
||||||
elif runs is not None:
|
|
||||||
runs[-1] += "\n" + line
|
|
||||||
if not days:
|
|
||||||
return
|
|
||||||
for day, entries in days.items():
|
|
||||||
path = CURATOR_RUNS / f"{day}.md"
|
|
||||||
plan.writes[path] = _merge(path, _join(entries, "\n\n"), old_first=False)
|
|
||||||
plan.writes[CURATOR_JOURNAL] = lines[0] + "\n" + CURATOR_STUB
|
|
||||||
plan.backups.append(CURATOR_JOURNAL)
|
|
||||||
plan.note(
|
|
||||||
f"{CURATOR_JOURNAL.name}: {sum(map(len, days.values()))} прогонов "
|
|
||||||
f"на {len(days)} дней"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _day(month: str, day: str) -> date:
|
|
||||||
return date(YEAR, int(month), int(day))
|
|
||||||
|
|
||||||
|
|
||||||
def split_diary(plan: Plan) -> None:
|
|
||||||
if not OLD_DIARY.exists():
|
|
||||||
return
|
|
||||||
lines = OLD_DIARY.read_text(encoding="utf-8").splitlines()
|
|
||||||
first = next((i for i, line in enumerate(lines) if HEAD.match(line)), len(lines))
|
|
||||||
preamble = "\n".join(lines[:first]).rstrip().removesuffix("---").rstrip()
|
|
||||||
thoughts: dict[date, list[str]] = defaultdict(list)
|
|
||||||
traces: dict[date, list[str]] = defaultdict(list)
|
|
||||||
current: date | None = None
|
|
||||||
hour = 0
|
|
||||||
chunk: list[str] | None = None
|
|
||||||
for number, line in enumerate(lines[first:], first + 1):
|
|
||||||
iso, timed = ISO_HEAD.match(line), HEAD_HOUR.match(line)
|
|
||||||
log = LINE.match(line)
|
|
||||||
day_first = LINE_DAY_FIRST.match(line)
|
|
||||||
stamp: date | None = None
|
|
||||||
target = thoughts
|
|
||||||
if iso:
|
|
||||||
stamp = date(int(iso.group(1)), int(iso.group(2)), int(iso.group(3)))
|
|
||||||
elif HEAD.match(line):
|
|
||||||
named = HEAD_DAY.search(line)
|
|
||||||
if named:
|
|
||||||
stamp = _day(named.group(2), named.group(1))
|
|
||||||
elif timed and current is not None:
|
|
||||||
rolled = int(timed.group(1)) < hour
|
|
||||||
stamp = current + timedelta(days=1) if rolled else current
|
|
||||||
if timed:
|
|
||||||
hour = int(timed.group(1))
|
|
||||||
elif log:
|
|
||||||
stamp, target = _day(log.group(3), log.group(2)), traces
|
|
||||||
elif day_first:
|
|
||||||
stamp, target = _day(day_first.group(2), day_first.group(1)), traces
|
|
||||||
if stamp is None:
|
|
||||||
if chunk is not None:
|
|
||||||
chunk[-1] += "\n" + line
|
|
||||||
continue
|
|
||||||
if current is not None and stamp < current:
|
|
||||||
plan.note(f"дневник, строка {number}: дата идёт назад, {current} → {stamp}")
|
|
||||||
current = stamp
|
|
||||||
chunk = target[stamp]
|
|
||||||
chunk.append(line)
|
|
||||||
if not thoughts and not traces:
|
|
||||||
return
|
|
||||||
for day, blocks in thoughts.items():
|
|
||||||
path = OWN_DIARY / f"{day}.md"
|
|
||||||
plan.writes[path] = _merge(path, _join(blocks, "\n\n"), old_first=True)
|
|
||||||
for day, blocks in traces.items():
|
|
||||||
path = JOURNAL / f"{day}.md"
|
|
||||||
plan.writes[path] = _merge(path, _join(blocks, "\n"), old_first=True)
|
|
||||||
plan.writes[OLD_DIARY] = preamble + "\n" + DIARY_STUB
|
|
||||||
plan.backups.append(OLD_DIARY)
|
|
||||||
plan.note(
|
|
||||||
f"{OLD_DIARY.name}: {sum(map(len, thoughts.values()))} записей на "
|
|
||||||
f"{len(thoughts)} дней в дневник, {sum(map(len, traces.values()))} строк на "
|
|
||||||
f"{len(traces)} дней в журнал"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def split_leisure(plan: Plan) -> None:
|
|
||||||
if not LEISURE.exists():
|
|
||||||
return
|
|
||||||
text = LEISURE.read_text(encoding="utf-8")
|
|
||||||
head, mark, journal = text.partition("\n## журнал\n")
|
|
||||||
if not mark or not journal.strip():
|
|
||||||
return
|
|
||||||
plan.writes[LEISURE_ARCHIVE] = (
|
|
||||||
"# журнал досуга до 13.10.2026\n\n"
|
|
||||||
"> Архив из `досуг.md`: записи шли без дат и в разном порядке, по дням "
|
|
||||||
"их не разложить. Дальше следы тиков лежат в `журнал/YYYY-MM-DD.md`.\n\n"
|
|
||||||
+ journal.strip("\n")
|
|
||||||
+ "\n"
|
|
||||||
)
|
|
||||||
plan.writes[LEISURE] = head.rstrip("\n") + "\n"
|
|
||||||
plan.backups.append(LEISURE)
|
|
||||||
plan.note(
|
|
||||||
f"{LEISURE.name}: {len(journal) // 1024} КБ журнала в архив, "
|
|
||||||
f"в файле остаётся {len(head) // 1024 + 1} КБ раскладки"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def backup(sources: list[Path]) -> Path:
|
|
||||||
target = BEAVER / ".архив" / today()
|
|
||||||
target.mkdir(parents=True, exist_ok=True)
|
|
||||||
for path in sources:
|
|
||||||
if path.exists() and not (target / path.name).exists():
|
|
||||||
shutil.copy2(path, target / path.name)
|
|
||||||
return target
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
plan = Plan()
|
|
||||||
split_replies(plan)
|
|
||||||
split_curator(plan)
|
|
||||||
split_diary(plan)
|
|
||||||
split_leisure(plan)
|
|
||||||
for note in plan.notes:
|
|
||||||
print(note)
|
|
||||||
folders: dict[str, int] = defaultdict(int)
|
|
||||||
for path in plan.writes:
|
|
||||||
folders[str(path.parent.relative_to(VAULT))] += 1
|
|
||||||
for folder, count in sorted(folders.items()):
|
|
||||||
print(f"{folder}: {count} файлов")
|
|
||||||
largest = max(plan.writes.items(), key=lambda item: len(item[1]), default=None)
|
|
||||||
if largest:
|
|
||||||
print(
|
|
||||||
f"самый большой: {largest[0].relative_to(VAULT)}, "
|
|
||||||
f"{len(largest[1].encode()) // 1024} КБ"
|
|
||||||
)
|
|
||||||
if "--apply" not in sys.argv:
|
|
||||||
print("ничего не записано: это прогон без --apply")
|
|
||||||
return
|
|
||||||
if not plan.writes:
|
|
||||||
return
|
|
||||||
print(f"оригиналы: {backup(plan.backups).relative_to(VAULT)}")
|
|
||||||
for path, text in plan.writes.items():
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
path.write_text(text, encoding="utf-8")
|
|
||||||
for path in plan.removes:
|
|
||||||
path.unlink()
|
|
||||||
print(f"записано {len(plan.writes)} файлов, убрано {len(plan.removes)}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
|
|
||||||
"""Войти в комнату вайбграма по коду приглашения и получить токен агента.
|
|
||||||
|
|
||||||
Обычный путь - CLI `vibegram join <code> --nick <ник> --no-hooks` из клона
|
|
||||||
репы комнаты. Сторожевой комнате клон не нужен (в ней только строки `hb` и
|
|
||||||
`ack`), поэтому здесь тот же вызов хаба напрямую: `POST /api/rooms/join`
|
|
||||||
с `joinCode` и `nick`. Ответ печатается целиком - токен из него едет в
|
|
||||||
`.env` как `VIBEGRAM_HB_TOKEN`, после чего gateway надо перезапустить.
|
|
||||||
|
|
||||||
python3 scripts/vibegram_join.py <join-code> --nick <ник>
|
|
||||||
python3 scripts/vibegram_join.py <join-code> --hub https://vibegram.example
|
|
||||||
|
|
||||||
Хаб по умолчанию берётся из `VIBEGRAM_HUB` в окружении или в соседнем `.env`.
|
|
||||||
Токен печатается в терминал - не вставляй вывод в чаты и в задачи.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import urllib.error
|
|
||||||
import urllib.request
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
ENV = Path(__file__).resolve().parent.parent / ".env"
|
|
||||||
USER_AGENT = "beaver-agent/0.1 (+vibegram)"
|
|
||||||
|
|
||||||
|
|
||||||
def hub_from_env() -> str | None:
|
|
||||||
if os.environ.get("VIBEGRAM_HUB"):
|
|
||||||
return os.environ["VIBEGRAM_HUB"]
|
|
||||||
if not ENV.exists():
|
|
||||||
return None
|
|
||||||
for line in ENV.read_text(encoding="utf-8").splitlines():
|
|
||||||
key, _, value = line.partition("=")
|
|
||||||
if key.strip() == "VIBEGRAM_HUB" and value.strip():
|
|
||||||
return value.strip()
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
|
||||||
parser.add_argument("code", help="код приглашения из комнаты")
|
|
||||||
parser.add_argument("--nick", help="ник агента в этой комнате")
|
|
||||||
parser.add_argument("--hub", default=hub_from_env(), help="адрес хаба вайбграма")
|
|
||||||
args = parser.parse_args()
|
|
||||||
if not args.hub:
|
|
||||||
print("не знаю адрес хаба: задай --hub или VIBEGRAM_HUB", file=sys.stderr)
|
|
||||||
return 2
|
|
||||||
|
|
||||||
body: dict[str, str] = {"joinCode": args.code}
|
|
||||||
if args.nick:
|
|
||||||
body["nick"] = args.nick
|
|
||||||
request = urllib.request.Request( # noqa: S310 - адрес хаба задаёт оператор
|
|
||||||
f"{args.hub.rstrip('/')}/api/rooms/join",
|
|
||||||
data=json.dumps(body).encode(),
|
|
||||||
# Перед хабом Cloudflare: безымянный `Python-urllib` он отбивает 1010.
|
|
||||||
# Тот же user-agent, что у клиента в `beaver_agent/hands/vibegram.py`.
|
|
||||||
headers={"content-type": "application/json", "user-agent": USER_AGENT},
|
|
||||||
method="POST",
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(request, timeout=20) as response: # noqa: S310
|
|
||||||
data = json.loads(response.read())
|
|
||||||
except urllib.error.HTTPError as exc:
|
|
||||||
print(f"хаб отказал: {exc.code} {exc.read().decode()[:400]}", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
except OSError as exc:
|
|
||||||
print(f"хаб не отвечает: {exc}", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
print(json.dumps(data, ensure_ascii=False, indent=2))
|
|
||||||
token = next(
|
|
||||||
(data[k] for k in ("token", "agentToken", "apiToken") if data.get(k)), None
|
|
||||||
)
|
|
||||||
if token:
|
|
||||||
print(f"\nVIBEGRAM_HB_TOKEN={token}\n\nдальше: строку в .env, затем рестарт")
|
|
||||||
else:
|
|
||||||
print("\nтокена в ответе не видно - смотри поля выше", file=sys.stderr)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
+6
-7
@@ -1,17 +1,16 @@
|
|||||||
# Машины с T3 Code и allowlist проектов на каждой. Токены - в .env (T3_*_TOKEN).
|
# Машины с T3 Code и allowlist проектов на каждой. Токены - в .env (T3_*_TOKEN).
|
||||||
# Имена - MagicDNS tailnet'а; `dell-dev` (дев-VM на делле) для контейнера
|
# Имена - MagicDNS tailnet'а; `dell` для контейнера прописан через T3_DELL_ADDR.
|
||||||
# прописан через T3_DELL_DEV_ADDR.
|
|
||||||
|
|
||||||
[machines.mac]
|
[machines.mac]
|
||||||
url = "http://mac:3773"
|
url = "http://mac:3773"
|
||||||
token_env = "T3_MAC_TOKEN"
|
token_env = "T3_MAC_TOKEN"
|
||||||
projects = ["*"]
|
projects = ["*"]
|
||||||
model = "claudeAgent/claude-opus-5-5"
|
model = "claudeAgent/claude-opus-5"
|
||||||
options = { effort = "high", contextWindow = "1m" }
|
options = { effort = "high", contextWindow = "1m" }
|
||||||
|
|
||||||
[machines.dell-dev]
|
[machines.dell]
|
||||||
url = "http://dell-dev:3773"
|
url = "http://dell:3773"
|
||||||
token_env = "T3_DELL_DEV_TOKEN"
|
token_env = "T3_DELL_TOKEN"
|
||||||
projects = ["*"]
|
projects = ["*"]
|
||||||
model = "claudeAgent/claude-opus-5-5"
|
model = "claudeAgent/claude-opus-5"
|
||||||
options = { effort = "high", contextWindow = "1m" }
|
options = { effort = "high", contextWindow = "1m" }
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
import urllib.error
|
|
||||||
from email.message import Message
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
from beaver_agent.hands.calendars import (
|
|
||||||
Calendars,
|
|
||||||
Feed,
|
|
||||||
calendars,
|
|
||||||
events_in,
|
|
||||||
feeds,
|
|
||||||
is_routine,
|
|
||||||
short,
|
|
||||||
unfold,
|
|
||||||
)
|
|
||||||
|
|
||||||
ZONE = ZoneInfo("Europe/Warsaw")
|
|
||||||
NOW = datetime(2026, 9, 1, 12, 0, tzinfo=UTC) # 14:00 в Варшаве
|
|
||||||
|
|
||||||
MCPS = (
|
|
||||||
"personal=https://calendar-mcp.com/api/mcp?email=b%40b.com"
|
|
||||||
"&icsUrl=https%3A%2F%2Fcalendar.google.com%2Fical%2Fprivate-abc%2Fbasic.ics,"
|
|
||||||
"student=https://calendar-mcp.com/api/mcp?icsUrl=https%3A%2F%2Fusos.pl%2Fics%3Fkey%3Dz,"
|
|
||||||
"bare=https://calendar-mcp.com/mcp/AAAA"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def ics(*events: str) -> str:
|
|
||||||
body = "".join(f"BEGIN:VEVENT\n{e.strip()}\nEND:VEVENT\n" for e in events)
|
|
||||||
return f"BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//test//EN\n{body}END:VCALENDAR\n"
|
|
||||||
|
|
||||||
|
|
||||||
def starts(text: str, *, days: int = 7) -> list[str]:
|
|
||||||
found = events_in(text, zone=ZONE, since=NOW.astimezone(ZONE), days=days)
|
|
||||||
return [
|
|
||||||
f"{e.start.astimezone(ZONE):%m-%d %H:%M} {e.title}"
|
|
||||||
for e in sorted(found, key=lambda e: e.start)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_feeds_take_the_ical_url_out_of_the_same_mcp_urls():
|
|
||||||
assert [f.name for f in feeds(MCPS)] == ["personal", "student"]
|
|
||||||
assert feeds(MCPS)[0].url == (
|
|
||||||
"https://calendar.google.com/ical/private-abc/basic.ics"
|
|
||||||
)
|
|
||||||
assert feeds(MCPS)[1].url == "https://usos.pl/ics?key=z"
|
|
||||||
assert feeds("") == ()
|
|
||||||
assert feeds("plain=https://example.com/plan.ics")[0].url.endswith("plan.ics")
|
|
||||||
# MCP-рука остаётся у модели, даже если ical-фида в записи нет
|
|
||||||
assert [m.name for m in calendars(MCPS)] == [
|
|
||||||
"calendar-personal",
|
|
||||||
"calendar-student",
|
|
||||||
"calendar-bare",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_folded_lines_are_glued_back():
|
|
||||||
assert unfold("SUMMARY:длин\r\n ное\r\nUID:x") == ["SUMMARY:длинное", "UID:x"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_window_sorts_by_start_and_keeps_only_the_next_days():
|
|
||||||
text = ics(
|
|
||||||
"SUMMARY:позже\nDTSTART:20260904T080000Z",
|
|
||||||
"SUMMARY:раньше\nDTSTART:20260902T080000Z",
|
|
||||||
"SUMMARY:за окном\nDTSTART:20260920T080000Z",
|
|
||||||
"SUMMARY:уже прошло\nDTSTART:20260801T080000Z",
|
|
||||||
)
|
|
||||||
assert starts(text) == ["09-02 10:00 раньше", "09-04 10:00 позже"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_all_day_event_of_today_still_counts_as_upcoming():
|
|
||||||
text = ics("SUMMARY:отпуск\nDTSTART;VALUE=DATE:20260901\nDTEND;VALUE=DATE:20260902")
|
|
||||||
found = events_in(text, zone=ZONE, since=NOW.astimezone(ZONE), days=7)
|
|
||||||
assert len(found) == 1
|
|
||||||
assert found[0].all_day
|
|
||||||
assert f"{found[0].start:%H:%M}" == "00:00"
|
|
||||||
|
|
||||||
|
|
||||||
def test_place_takes_the_room_from_the_description_and_the_address_from_location():
|
|
||||||
text = ics(
|
|
||||||
"SUMMARY:Analiza matematyczna II - Egzamin\n"
|
|
||||||
"DTSTART;VALUE=DATE-TIME:20260903T110000\n"
|
|
||||||
"DESCRIPTION:Sala: 3\\nA23 - CW - Centrum Wykładowe\\n\n"
|
|
||||||
"LOCATION:ul. Piotrowo 2\\, 61-138 Poznań\\, Polska"
|
|
||||||
)
|
|
||||||
found = events_in(text, zone=ZONE, since=NOW.astimezone(ZONE), days=7)
|
|
||||||
assert found[0].place == "Sala: 3, ul. Piotrowo 2, 61-138 Poznań, Polska"
|
|
||||||
# плавающее время читается в зоне агента, а не в UTC
|
|
||||||
assert f"{found[0].start:%H:%M}" == "11:00"
|
|
||||||
|
|
||||||
|
|
||||||
def test_cancelled_events_are_skipped():
|
|
||||||
text = ics("SUMMARY:отменено\nDTSTART:20260902T080000Z\nSTATUS:CANCELLED")
|
|
||||||
assert starts(text) == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_weekly_repeat_expands_and_exdate_with_moved_instance_are_subtracted():
|
|
||||||
series = (
|
|
||||||
"UID:w1\nSUMMARY:польский\nDTSTART;TZID=Europe/Warsaw:20260824T100000\n"
|
|
||||||
"RRULE:FREQ=WEEKLY;BYDAY=MO,WE"
|
|
||||||
)
|
|
||||||
assert starts(ics(series)) == ["09-02 10:00 польский", "09-07 10:00 польский"]
|
|
||||||
moved = ics(
|
|
||||||
series + "\nEXDATE;TZID=Europe/Warsaw:20260902T100000",
|
|
||||||
"UID:w1\nSUMMARY:польский (перенос)\n"
|
|
||||||
"RECURRENCE-ID;TZID=Europe/Warsaw:20260907T100000\n"
|
|
||||||
"DTSTART;TZID=Europe/Warsaw:20260907T173000",
|
|
||||||
)
|
|
||||||
assert starts(moved) == ["09-07 17:30 польский (перенос)"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_daily_repeat_stops_on_until_and_count():
|
|
||||||
until = ics(
|
|
||||||
"SUMMARY:завтрак\nDTSTART:20260801T060000Z\n"
|
|
||||||
"RRULE:FREQ=DAILY;UNTIL=20260903T060000Z"
|
|
||||||
)
|
|
||||||
assert starts(until) == ["09-02 08:00 завтрак", "09-03 08:00 завтрак"]
|
|
||||||
counted = ics("SUMMARY:курс\nDTSTART:20260902T060000Z\nRRULE:FREQ=DAILY;COUNT=3")
|
|
||||||
assert len(starts(counted)) == 3
|
|
||||||
yearly = ics(
|
|
||||||
"SUMMARY:часы\nDTSTART:20200906T060000Z\nRRULE:FREQ=YEARLY;BYMONTH=9;BYDAY=1SU"
|
|
||||||
)
|
|
||||||
assert starts(yearly) == ["09-06 08:00 часы"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_working_week_is_routine_but_one_weekday_a_week_is_an_event():
|
|
||||||
text = ics(
|
|
||||||
"SUMMARY:пара\nDTSTART;TZID=Europe/Warsaw:20260831T100000\n"
|
|
||||||
"RRULE:FREQ=WEEKLY;BYDAY=MO",
|
|
||||||
"SUMMARY:зарядка\nDTSTART;TZID=Europe/Warsaw:20260831T070000\n"
|
|
||||||
"RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR",
|
|
||||||
)
|
|
||||||
found = events_in(text, zone=ZONE, since=NOW.astimezone(ZONE), days=7)
|
|
||||||
assert {e.title for e in found if e.routine} == {"зарядка"}
|
|
||||||
assert {e.title for e in found if not e.routine} == {"пара"}
|
|
||||||
assert len([e for e in found if e.routine]) == 5
|
|
||||||
|
|
||||||
|
|
||||||
def test_routine_is_a_rule_that_fires_once_a_day_or_more_often():
|
|
||||||
assert is_routine({"FREQ": "DAILY"})
|
|
||||||
assert is_routine({"FREQ": "WEEKLY", "BYDAY": "MO,TU,WE,TH,FR,SA"})
|
|
||||||
assert not is_routine({"FREQ": "WEEKLY", "BYDAY": "MO,WE,FR"})
|
|
||||||
assert not is_routine({"FREQ": "WEEKLY"})
|
|
||||||
assert not is_routine({"FREQ": "MONTHLY"})
|
|
||||||
# через день - это уже не быт, такое видно поимённо
|
|
||||||
assert not is_routine({"FREQ": "DAILY", "INTERVAL": "2"})
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_single_event_is_never_routine():
|
|
||||||
text = ics("SUMMARY:Egzamin\nDTSTART:20260903T090000Z")
|
|
||||||
assert not events_in(text, zone=ZONE, since=NOW.astimezone(ZONE), days=7)[0].routine
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_feed_that_fails_becomes_a_line_not_an_exception():
|
|
||||||
class Broken(Calendars):
|
|
||||||
def fetch(self, url: str) -> str:
|
|
||||||
raise urllib.error.HTTPError(url, 403, "Forbidden", Message(), None)
|
|
||||||
|
|
||||||
broken = Broken((Feed("student", "https://usos.pl/ics"),), tz="Europe/Warsaw")
|
|
||||||
found = broken.upcoming(NOW)
|
|
||||||
assert found.read == 0
|
|
||||||
assert found.events == ()
|
|
||||||
assert found.errors == ("student: HTTP 403",)
|
|
||||||
|
|
||||||
|
|
||||||
def test_short_reason_is_one_line_and_never_carries_the_url():
|
|
||||||
assert short(TimeoutError()) == "таймаут"
|
|
||||||
assert short(urllib.error.URLError("no route")).startswith("сеть:")
|
|
||||||
assert short(ValueError("а\nб")) == "ValueError: а б"
|
|
||||||
@@ -25,10 +25,7 @@ def test_briefing_lists_new_replies_and_changed_files_or_nothing(
|
|||||||
notes = beaver / "наблюдения"
|
notes = beaver / "наблюдения"
|
||||||
replies.mkdir(parents=True)
|
replies.mkdir(parents=True)
|
||||||
notes.mkdir()
|
notes.mkdir()
|
||||||
(replies / "2026-08-31.md").write_text(
|
(replies / "2026-09.md").write_text(
|
||||||
"# реплики\n\n- 2026-08-31 23:50 · мастер · вчерашнее\n", encoding="utf-8"
|
|
||||||
)
|
|
||||||
(replies / "2026-09-01.md").write_text(
|
|
||||||
"# реплики\n\n- 2026-09-01 12:00 · мастер · старое\n- 2026-09-01 19:30 · мастер · новое сообщение\n",
|
"# реплики\n\n- 2026-09-01 12:00 · мастер · старое\n- 2026-09-01 19:30 · мастер · новое сообщение\n",
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
@@ -52,7 +49,6 @@ def test_briefing_lists_new_replies_and_changed_files_or_nothing(
|
|||||||
text = briefing(
|
text = briefing(
|
||||||
vault=vault,
|
vault=vault,
|
||||||
journal=journal,
|
journal=journal,
|
||||||
runs=beaver / "куратор",
|
|
||||||
replies_dir=replies,
|
replies_dir=replies,
|
||||||
watched=[Watched("наблюдения", notes)],
|
watched=[Watched("наблюдения", notes)],
|
||||||
tz="Europe/Warsaw",
|
tz="Europe/Warsaw",
|
||||||
@@ -67,7 +63,6 @@ def test_briefing_lists_new_replies_and_changed_files_or_nothing(
|
|||||||
assert "наблюдения: `мета/бобер/наблюдения/Зина - наблюдения.md`" in text
|
assert "наблюдения: `мета/бобер/наблюдения/Зина - наблюдения.md`" in text
|
||||||
assert "Глафира" not in text
|
assert "Глафира" not in text
|
||||||
assert "последний прогон: 2026-09-01T22:00:00+02:00" in text
|
assert "последний прогон: 2026-09-01T22:00:00+02:00" in text
|
||||||
assert "запись о прогоне - в `мета/бобер/куратор/2026-09-01.md`" in text
|
|
||||||
|
|
||||||
journal.write_text(f"последний прогон: {now.isoformat()}\n", encoding="utf-8")
|
journal.write_text(f"последний прогон: {now.isoformat()}\n", encoding="utf-8")
|
||||||
assert new_replies(replies, now, "Europe/Warsaw") == []
|
assert new_replies(replies, now, "Europe/Warsaw") == []
|
||||||
@@ -76,7 +71,6 @@ def test_briefing_lists_new_replies_and_changed_files_or_nothing(
|
|||||||
briefing(
|
briefing(
|
||||||
vault=vault,
|
vault=vault,
|
||||||
journal=journal,
|
journal=journal,
|
||||||
runs=beaver / "куратор",
|
|
||||||
replies_dir=replies,
|
replies_dir=replies,
|
||||||
watched=[Watched("наблюдения", notes)],
|
watched=[Watched("наблюдения", notes)],
|
||||||
tz="Europe/Warsaw",
|
tz="Europe/Warsaw",
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
import os
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from beaver_agent.memory import handouts
|
|
||||||
from beaver_agent.memory.handouts import deploy_mark
|
|
||||||
|
|
||||||
ZONE = ZoneInfo("Europe/Warsaw")
|
|
||||||
NOW = "2026-09-17 06:30"
|
|
||||||
|
|
||||||
|
|
||||||
def _at(text: str) -> float:
|
|
||||||
return datetime.fromisoformat(text).replace(tzinfo=ZONE).timestamp()
|
|
||||||
|
|
||||||
|
|
||||||
def _touch(path: Path, when: str) -> Path:
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
path.write_text("", encoding="utf-8")
|
|
||||||
os.utime(path, (_at(when), _at(when)))
|
|
||||||
return path
|
|
||||||
|
|
||||||
|
|
||||||
def _bundle(tmp_path: Path, started: str, code: str) -> tuple[Path, Path]:
|
|
||||||
proc = _touch(tmp_path / "proc" / "1", started)
|
|
||||||
package = tmp_path / "beaver_agent"
|
|
||||||
_touch(package / "memory" / "handouts.py", code)
|
|
||||||
_touch(package / "vault.py", "2026-09-10 12:00")
|
|
||||||
# мусор компилятора не должен выглядеть свежим кодом
|
|
||||||
_touch(package / "memory" / "__pycache__" / "handouts.py", "2026-09-17 06:00")
|
|
||||||
return proc, package
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _now(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
monkeypatch.setattr(handouts.time, "time", lambda: _at(NOW))
|
|
||||||
|
|
||||||
|
|
||||||
def test_code_older_than_start(tmp_path: Path) -> None:
|
|
||||||
proc, package = _bundle(tmp_path, "2026-09-16 03:11", "2026-09-16 03:03")
|
|
||||||
assert deploy_mark(proc, package) == (
|
|
||||||
"Выкат: процесс поднят 2026-09-16 03:11 (27 ч назад), свежесть кода "
|
|
||||||
"2026-09-16 03:03 - код старше старта, значит крутится то, что выкатывали."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_code_newer_than_start(tmp_path: Path) -> None:
|
|
||||||
proc, package = _bundle(tmp_path, "2026-09-16 03:11", "2026-09-17 06:20")
|
|
||||||
assert deploy_mark(proc, package) == (
|
|
||||||
"Выкат: процесс поднят 2026-09-16 03:11 (27 ч назад), свежесть кода "
|
|
||||||
"2026-09-17 06:20 - код НОВЕЕ старта: правка на диске есть, "
|
|
||||||
"рестарта после неё не было."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_same_minute(tmp_path: Path) -> None:
|
|
||||||
proc, package = _bundle(tmp_path, "2026-09-17 06:00", "2026-09-17 06:00:30")
|
|
||||||
assert deploy_mark(proc, package) == (
|
|
||||||
"Выкат: процесс поднят 2026-09-17 06:00 (30 мин назад), свежесть кода "
|
|
||||||
"2026-09-17 06:00 - выкат и правка в одну минуту."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_source(tmp_path: Path) -> None:
|
|
||||||
proc, package = _bundle(tmp_path, "2026-09-16 03:11", "2026-09-16 03:03")
|
|
||||||
assert deploy_mark(tmp_path / "нет", package) is None
|
|
||||||
assert deploy_mark(proc, tmp_path / "пусто") is None
|
|
||||||
only_cache = tmp_path / "кеш"
|
|
||||||
_touch(only_cache / "__pycache__" / "handouts.py", "2026-09-17 06:00")
|
|
||||||
assert deploy_mark(proc, only_cache) is None
|
|
||||||
@@ -1,575 +0,0 @@
|
|||||||
import json
|
|
||||||
import logging
|
|
||||||
from datetime import UTC, datetime, timedelta
|
|
||||||
from types import SimpleNamespace
|
|
||||||
from typing import Any, cast
|
|
||||||
from urllib.parse import parse_qsl, urlsplit
|
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
from beaver_agent.hands.heartbeat import (
|
|
||||||
PEER,
|
|
||||||
Watch,
|
|
||||||
alarm_text,
|
|
||||||
beat_line,
|
|
||||||
drill,
|
|
||||||
every_from_cron,
|
|
||||||
look,
|
|
||||||
parse_beat,
|
|
||||||
parse_span,
|
|
||||||
span,
|
|
||||||
)
|
|
||||||
from beaver_agent.hands.vibegram import PAGE, Event, Vibegram, Window
|
|
||||||
|
|
||||||
WARSAW = ZoneInfo("Europe/Warsaw")
|
|
||||||
NOW = datetime(2026, 9, 22, 15, 42, tzinfo=UTC)
|
|
||||||
|
|
||||||
|
|
||||||
def hb(at, *, every="24h", nick=PEER, eid=100):
|
|
||||||
return Event(
|
|
||||||
id=eid,
|
|
||||||
nick=nick,
|
|
||||||
kind="message",
|
|
||||||
text=(
|
|
||||||
f"{nick}: hb {nick} at={at.strftime('%Y-%m-%dT%H:%M:%SZ')} "
|
|
||||||
f"boot=2026-09-01T03:00:00Z every={every} src=cron+uptime-s"
|
|
||||||
),
|
|
||||||
at="09-22 07:00",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def state(tmp_path, **kw):
|
|
||||||
return Watch(path=tmp_path / "сторож.json", **kw)
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_beat_reads_fields_and_unknown_every():
|
|
||||||
beat = parse_beat(
|
|
||||||
"claude-kai: hb claude-kai at=2026-09-22T05:00:00Z "
|
|
||||||
"boot=2026-09-01T03:00:00Z every=24h src=cron+uptime-s"
|
|
||||||
)
|
|
||||||
assert beat is not None
|
|
||||||
assert beat.nick == "claude-kai"
|
|
||||||
assert beat.at == datetime(2026, 9, 22, 5, 0, tzinfo=UTC)
|
|
||||||
assert beat.boot == datetime(2026, 9, 1, 3, 0, tzinfo=UTC)
|
|
||||||
assert beat.every == timedelta(hours=24)
|
|
||||||
|
|
||||||
unknown = parse_beat(
|
|
||||||
"claude-kai: hb claude-kai at=2026-09-22T05:00:00Z "
|
|
||||||
"boot=? every=unknown src=none"
|
|
||||||
)
|
|
||||||
assert unknown is not None
|
|
||||||
assert unknown.every is None and unknown.every_raw == "unknown"
|
|
||||||
assert unknown.boot is None
|
|
||||||
|
|
||||||
assert parse_beat("claude-kai: просто сообщение") is None
|
|
||||||
assert parse_beat("claude-kai: ack beaver last=x", "ack") is not None
|
|
||||||
|
|
||||||
|
|
||||||
def test_span_round_trip():
|
|
||||||
assert span(timedelta(hours=36)) == "36h"
|
|
||||||
assert span(timedelta(minutes=90)) == "90m"
|
|
||||||
assert span(None) == "unknown"
|
|
||||||
assert parse_span("36h") == timedelta(hours=36)
|
|
||||||
assert parse_span("unknown") is None
|
|
||||||
assert parse_span("") is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_every_from_cron_only_when_schedule_is_every_n_hours():
|
|
||||||
assert every_from_cron("12 17 * * *", WARSAW, now=NOW) == timedelta(hours=24)
|
|
||||||
assert every_from_cron("0 */6 * * *", WARSAW, now=NOW) == timedelta(hours=6)
|
|
||||||
# Перевод стрелок не делает суточный крон 23-часовым: считаем по стенным часам
|
|
||||||
march = datetime(2026, 3, 28, 12, tzinfo=UTC)
|
|
||||||
assert every_from_cron("12 17 * * *", WARSAW, now=march) == timedelta(hours=24)
|
|
||||||
# Не сводится к «раз в N часов» - и не сводим
|
|
||||||
assert every_from_cron("0 2,8,12,16,20 * * *", WARSAW, now=NOW) is None
|
|
||||||
assert every_from_cron("*/10 * * * *", WARSAW, now=NOW) is None
|
|
||||||
# Недельный крон тоже сводится к N часам - это честные 168h, а не unknown
|
|
||||||
assert every_from_cron("30 4 * * 0", WARSAW, now=NOW) == timedelta(hours=168)
|
|
||||||
assert every_from_cron(None, WARSAW, now=NOW) is None
|
|
||||||
assert every_from_cron("не крон", WARSAW, now=NOW) is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_beat_line_says_unknown_instead_of_guessing():
|
|
||||||
line = beat_line(
|
|
||||||
nick="beaver",
|
|
||||||
at=datetime(2026, 9, 22, 15, 12, tzinfo=UTC),
|
|
||||||
boot=datetime(2026, 9, 20, 1, 2, 3, tzinfo=UTC),
|
|
||||||
every=timedelta(hours=24),
|
|
||||||
)
|
|
||||||
assert line == (
|
|
||||||
"hb beaver at=2026-09-22T15:12:00Z boot=2026-09-20T01:02:03Z every=24h src=cron"
|
|
||||||
)
|
|
||||||
blind = beat_line(nick="beaver", at=NOW, boot=NOW, every=None)
|
|
||||||
assert "every=unknown src=none" in blind
|
|
||||||
|
|
||||||
|
|
||||||
def test_fresh_beat_arms_and_stays_quiet(tmp_path):
|
|
||||||
w = state(tmp_path)
|
|
||||||
report = look(
|
|
||||||
w, [hb(NOW - timedelta(hours=10))], now=NOW, res=timedelta(hours=24), full=True
|
|
||||||
)
|
|
||||||
assert report.armed and report.alarm is None and not report.stale
|
|
||||||
assert report.threshold == timedelta(hours=36)
|
|
||||||
assert "armed=yes" in report.ack and "win=25" in report.ack
|
|
||||||
assert "res=24h" in report.ack and "thr=36h" in report.ack
|
|
||||||
assert w.seen == NOW and w.top == 100
|
|
||||||
|
|
||||||
|
|
||||||
def test_same_beat_seen_twice_does_not_move_seen(tmp_path):
|
|
||||||
w = state(tmp_path)
|
|
||||||
old = hb(NOW - timedelta(hours=1), eid=200)
|
|
||||||
first = look(w, [old], now=NOW, res=None, full=True)
|
|
||||||
assert first.armed and first.alarm is None
|
|
||||||
assert w.seen == NOW and w.beat_id == 200
|
|
||||||
|
|
||||||
# Тот же самый hb в окне следующего прогона - это не новое показание
|
|
||||||
again = look(w, [old], now=NOW + timedelta(hours=6), res=None, full=True)
|
|
||||||
assert w.seen == NOW and again.alarm is None
|
|
||||||
assert "отметка та же" in again.note
|
|
||||||
|
|
||||||
# И через порог 36h та же отметка уже не выглядит живой цепью
|
|
||||||
late = NOW + timedelta(hours=37)
|
|
||||||
loud = look(w, [old], now=late, res=None, full=True)
|
|
||||||
assert loud.alarm == "silence" and w.seen == NOW
|
|
||||||
assert loud.age is not None and loud.age > timedelta(hours=36)
|
|
||||||
|
|
||||||
# Пришла новая отметка - новый id, и seen снова двигается
|
|
||||||
fresh = look(w, [old, hb(late, eid=201)], now=late, res=None, full=True)
|
|
||||||
assert fresh.alarm is None and w.seen == late and w.beat_id == 201
|
|
||||||
|
|
||||||
|
|
||||||
def test_state_without_beat_id_trusts_its_own_seen(tmp_path):
|
|
||||||
path = tmp_path / "сторож.json"
|
|
||||||
path.write_text(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"version": 1,
|
|
||||||
"at": "2026-09-21T05:00:01Z",
|
|
||||||
"seen": "2026-09-22T05:42:01Z",
|
|
||||||
"every": 86400,
|
|
||||||
"every_unknown": False,
|
|
||||||
"top": 300,
|
|
||||||
"alerted": None,
|
|
||||||
"unarmed_since": None,
|
|
||||||
"unarmed_told": False,
|
|
||||||
}
|
|
||||||
),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
w = Watch.load(path)
|
|
||||||
was = datetime(2026, 9, 22, 5, 42, 1, tzinfo=UTC)
|
|
||||||
assert w.beat_id == 0 and w.seen == was
|
|
||||||
|
|
||||||
old = hb(datetime(2026, 9, 21, 5, 0, 1, tzinfo=UTC), eid=305)
|
|
||||||
assert look(w, [old], now=NOW, res=None, full=True).alarm is None
|
|
||||||
assert w.seen == was and w.beat_id == 305
|
|
||||||
# Старое состояние не пере-вооружилось на старой отметке: порог придёт в срок
|
|
||||||
over = datetime(2026, 9, 23, 20, 0, tzinfo=UTC)
|
|
||||||
assert look(w, [old], now=over, res=None, full=True).alarm == "silence"
|
|
||||||
|
|
||||||
|
|
||||||
def test_threshold_is_every_times_one_and_a_half(tmp_path):
|
|
||||||
# 30 часов при every=24h - ещё не тревога: одна потерянная строка не в счёт
|
|
||||||
quiet = state(tmp_path, seen=NOW - timedelta(hours=30), every=timedelta(hours=24))
|
|
||||||
assert look(quiet, [], now=NOW, res=timedelta(hours=24), full=True).alarm is None
|
|
||||||
# 37 часов - уже за порогом 36
|
|
||||||
loud = state(tmp_path, seen=NOW - timedelta(hours=37), every=timedelta(hours=24))
|
|
||||||
report = look(loud, [], now=NOW, res=timedelta(hours=24), full=True)
|
|
||||||
assert report.alarm == "silence"
|
|
||||||
# Порог берётся из строки соседа, а не из нашей константы
|
|
||||||
slow = state(tmp_path, seen=NOW - timedelta(hours=37), every=timedelta(hours=48))
|
|
||||||
assert look(slow, [], now=NOW, res=timedelta(hours=24), full=True).alarm is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_last_beat_wins_over_gaps(tmp_path):
|
|
||||||
w = state(tmp_path, seen=NOW - timedelta(hours=40), every=timedelta(hours=24))
|
|
||||||
report = look(
|
|
||||||
w, [hb(NOW - timedelta(hours=1), eid=120)], now=NOW, res=None, full=True
|
|
||||||
)
|
|
||||||
assert report.alarm is None
|
|
||||||
assert report.age is not None and report.age < timedelta(hours=2)
|
|
||||||
assert "res=unknown" in report.ack
|
|
||||||
|
|
||||||
|
|
||||||
def test_unknown_every_falls_back_to_last_known_and_says_so(tmp_path):
|
|
||||||
w = state(tmp_path, every=timedelta(hours=24))
|
|
||||||
report = look(w, [hb(NOW, every="unknown", eid=130)], now=NOW, res=None, full=True)
|
|
||||||
assert report.threshold == timedelta(hours=36)
|
|
||||||
assert "thr=36h-fallback" in report.ack
|
|
||||||
assert w.every == timedelta(hours=24)
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_period_ever_means_no_measurement_and_no_alarm(tmp_path):
|
|
||||||
w = state(tmp_path, seen=NOW - timedelta(days=9))
|
|
||||||
report = look(w, [], now=NOW, res=timedelta(hours=24), full=True)
|
|
||||||
assert report.alarm is None and report.threshold is None
|
|
||||||
assert "thr=unknown" in report.ack
|
|
||||||
assert "мерить нечем" in report.note
|
|
||||||
|
|
||||||
|
|
||||||
def test_never_seen_is_armed_no_not_alarm(tmp_path):
|
|
||||||
w = state(tmp_path)
|
|
||||||
report = look(w, [], now=NOW, res=timedelta(hours=24), full=True)
|
|
||||||
assert not report.armed and report.alarm is None
|
|
||||||
assert "armed=no" in report.ack and "last=never seen=never" in report.ack
|
|
||||||
assert w.unarmed_since == NOW
|
|
||||||
|
|
||||||
|
|
||||||
def test_seven_days_unarmed_tells_the_human_once(tmp_path):
|
|
||||||
w = state(tmp_path, unarmed_since=NOW - timedelta(days=7))
|
|
||||||
assert look(w, [], now=NOW, res=timedelta(hours=24), full=True).alarm == "unarmed"
|
|
||||||
w.unarmed_told = True
|
|
||||||
assert look(w, [], now=NOW, res=timedelta(hours=24), full=True).alarm is None
|
|
||||||
# Пришла первая отметка - кольцо вооружилось, счётчик забыт
|
|
||||||
look(w, [hb(NOW, eid=140)], now=NOW, res=timedelta(hours=24), full=True)
|
|
||||||
assert w.unarmed_since is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_stale_window_forbids_alarm(tmp_path):
|
|
||||||
w = state(
|
|
||||||
tmp_path, seen=NOW - timedelta(hours=40), every=timedelta(hours=24), top=500
|
|
||||||
)
|
|
||||||
report = look(
|
|
||||||
w, [hb(NOW - timedelta(hours=40), eid=7)], now=NOW, res=None, full=True
|
|
||||||
)
|
|
||||||
assert report.stale and report.alarm is None
|
|
||||||
assert "win=stale" in report.ack
|
|
||||||
assert w.top == 500 # съехавшее окно не двигает отметку максимума
|
|
||||||
# Окно снова вперёд - тревога берётся
|
|
||||||
fresh = look(
|
|
||||||
w, [hb(NOW - timedelta(hours=40), eid=501)], now=NOW, res=None, full=True
|
|
||||||
)
|
|
||||||
assert not fresh.stale
|
|
||||||
|
|
||||||
|
|
||||||
def test_alarm_not_repeated_within_a_day(tmp_path):
|
|
||||||
w = state(
|
|
||||||
tmp_path,
|
|
||||||
seen=NOW - timedelta(hours=40),
|
|
||||||
every=timedelta(hours=24),
|
|
||||||
alerted=NOW - timedelta(hours=5),
|
|
||||||
)
|
|
||||||
assert look(w, [], now=NOW, res=None, full=True).alarm is None
|
|
||||||
w.alerted = NOW - timedelta(hours=25)
|
|
||||||
assert look(w, [], now=NOW, res=None, full=True).alarm == "silence"
|
|
||||||
|
|
||||||
|
|
||||||
def test_ack_says_what_the_instrument_decided(tmp_path):
|
|
||||||
# Цепь жива - решать нечего
|
|
||||||
alive = state(tmp_path)
|
|
||||||
assert "alert=none" in look(alive, [hb(NOW)], now=NOW, res=None, full=True).ack
|
|
||||||
# Порог пройден, тревога взята в этом прогоне
|
|
||||||
loud = state(tmp_path, seen=NOW - timedelta(hours=40), every=timedelta(hours=24))
|
|
||||||
fired = look(loud, [], now=NOW, res=None, full=True)
|
|
||||||
assert fired.alarm == "silence" and "alert=fired" in fired.ack
|
|
||||||
# Порог пройден, но тревога уходила меньше суток назад - повтор подавлен
|
|
||||||
loud.alerted = NOW - timedelta(hours=5)
|
|
||||||
held = look(loud, [], now=NOW, res=None, full=True)
|
|
||||||
assert held.alarm is None and "alert=held" in held.ack
|
|
||||||
# Кольцо так и не вооружилось дольше недели - это тоже выстрел
|
|
||||||
late = state(tmp_path, unarmed_since=NOW - timedelta(days=8))
|
|
||||||
assert "alert=fired" in look(late, [], now=NOW, res=None, full=True).ack
|
|
||||||
# Показания нет (окно не дочитано) - не `held`, а `none`: подавлять нечего
|
|
||||||
unread = state(tmp_path, seen=NOW - timedelta(hours=40), every=timedelta(hours=24))
|
|
||||||
assert "alert=none" in look(unread, [], now=NOW, res=None, full=False).ack
|
|
||||||
|
|
||||||
|
|
||||||
def test_drill_flag_is_a_list_of_local_dates(tmp_path):
|
|
||||||
flag = tmp_path / "сторож-учения.txt"
|
|
||||||
assert not drill(flag, now=NOW, tz=WARSAW) # файла нет - отметка уходит
|
|
||||||
flag.write_text("\n \n", encoding="utf-8")
|
|
||||||
assert not drill(flag, now=NOW, tz=WARSAW) # пустой - тоже
|
|
||||||
flag.write_text("2026-09-28\n2026-09-29\n", encoding="utf-8")
|
|
||||||
assert drill(flag, now=datetime(2026, 9, 28, 15, 12, tzinfo=UTC), tz=WARSAW)
|
|
||||||
assert not drill(flag, now=datetime(2026, 9, 27, 15, 12, tzinfo=UTC), tz=WARSAW)
|
|
||||||
# Дата локальная в зоне планировщика: 22:30 UTC - это уже 29-е в Варшаве
|
|
||||||
assert drill(flag, now=datetime(2026, 9, 28, 22, 30, tzinfo=UTC), tz=WARSAW)
|
|
||||||
assert not drill(flag, now=datetime(2026, 9, 29, 22, 30, tzinfo=UTC), tz=WARSAW)
|
|
||||||
|
|
||||||
|
|
||||||
def test_alarm_text_names_three_causes_and_suspects_the_channel(tmp_path):
|
|
||||||
w = state(tmp_path, seen=NOW - timedelta(hours=40), every=timedelta(hours=24))
|
|
||||||
report = look(w, [], now=NOW, res=timedelta(hours=24), full=True)
|
|
||||||
silent = alarm_text(report, mine_sent=True)
|
|
||||||
assert "машина соседа" in silent and "крон" in silent and "канал" in silent
|
|
||||||
assert "канал подозревать надо раньше" in silent
|
|
||||||
# Сосед хоть чем-то отметился - канал скорее жив
|
|
||||||
heard = look(
|
|
||||||
state(tmp_path, seen=NOW - timedelta(hours=40), every=timedelta(hours=24)),
|
|
||||||
[
|
|
||||||
Event(
|
|
||||||
id=9,
|
|
||||||
nick=PEER,
|
|
||||||
kind="message",
|
|
||||||
text=f"{PEER}: ack beaver last=x",
|
|
||||||
at="09-22 07:00",
|
|
||||||
)
|
|
||||||
],
|
|
||||||
now=NOW,
|
|
||||||
res=None,
|
|
||||||
full=True,
|
|
||||||
)
|
|
||||||
assert heard.heard
|
|
||||||
assert "канал скорее жив" in alarm_text(heard, mine_sent=True)
|
|
||||||
|
|
||||||
|
|
||||||
def test_state_survives_a_round_trip(tmp_path):
|
|
||||||
w = state(
|
|
||||||
tmp_path,
|
|
||||||
at=NOW,
|
|
||||||
seen=NOW,
|
|
||||||
every=timedelta(hours=24),
|
|
||||||
every_unknown=True,
|
|
||||||
beat_id=329,
|
|
||||||
top=42,
|
|
||||||
alerted=NOW,
|
|
||||||
unarmed_since=None,
|
|
||||||
unarmed_told=True,
|
|
||||||
)
|
|
||||||
w.save()
|
|
||||||
back = Watch.load(w.path)
|
|
||||||
assert back.at == NOW and back.seen == NOW and back.top == 42
|
|
||||||
assert back.beat_id == 329
|
|
||||||
assert back.every == timedelta(hours=24) and back.every_unknown
|
|
||||||
assert back.alerted == NOW and back.unarmed_told
|
|
||||||
assert Watch.load(tmp_path / "нет.json").seen is None
|
|
||||||
|
|
||||||
|
|
||||||
class FakeRoom:
|
|
||||||
def __init__(self, events, *, nick="beaver", send_fails=False, full=True):
|
|
||||||
self.events = events
|
|
||||||
self.nick = nick
|
|
||||||
self.send_fails = send_fails
|
|
||||||
self.full = full
|
|
||||||
self.said = []
|
|
||||||
self.limit = None
|
|
||||||
|
|
||||||
async def window(self, limit=25):
|
|
||||||
self.limit = limit
|
|
||||||
return Window(events=self.events, full=self.full)
|
|
||||||
|
|
||||||
async def say(self, text):
|
|
||||||
if self.send_fails:
|
|
||||||
msg = "хаб молчит"
|
|
||||||
raise RuntimeError(msg)
|
|
||||||
self.said.append(text)
|
|
||||||
|
|
||||||
|
|
||||||
class FakeRun:
|
|
||||||
def __init__(self, cron="42 */6 * * *"):
|
|
||||||
self.job = SimpleNamespace(cron=cron, name="сторож")
|
|
||||||
self.scheduler = SimpleNamespace(tz=WARSAW)
|
|
||||||
self.spawned = []
|
|
||||||
|
|
||||||
async def spawn_job(self, *, agent, text, title=None):
|
|
||||||
self.spawned.append((agent, title, text))
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def test_watch_job_prints_ack_and_spawns_alarm(tmp_path, monkeypatch):
|
|
||||||
from beaver_agent.jobs import heartbeat as job
|
|
||||||
|
|
||||||
monkeypatch.setattr(job, "STATE_FILE", tmp_path / "сторож.json")
|
|
||||||
Watch(
|
|
||||||
path=tmp_path / "сторож.json",
|
|
||||||
seen=datetime.now(UTC) - timedelta(hours=40),
|
|
||||||
every=timedelta(hours=24),
|
|
||||||
).save()
|
|
||||||
room = FakeRoom([])
|
|
||||||
monkeypatch.setattr(job, "VIBEGRAM_HB", room)
|
|
||||||
run = FakeRun()
|
|
||||||
|
|
||||||
await job.watch(cast("Any", run))
|
|
||||||
|
|
||||||
assert room.limit == 25 # окно запрошено явным размером
|
|
||||||
assert room.said and room.said[0].startswith(f"ack {PEER} ")
|
|
||||||
assert "res=6h" in room.said[0]
|
|
||||||
assert len(run.spawned) == 1
|
|
||||||
agent, title, text = run.spawned[0]
|
|
||||||
assert agent == "beaver-triage" and title == "сторож"
|
|
||||||
assert "канал подозревать надо раньше" in text
|
|
||||||
# Тревога записана в состояние - следующий прогон в те же сутки молчит
|
|
||||||
assert Watch.load(tmp_path / "сторож.json").alerted is not None
|
|
||||||
await job.watch(cast("Any", run))
|
|
||||||
assert len(run.spawned) == 1
|
|
||||||
|
|
||||||
|
|
||||||
async def test_beat_job_sends_period_from_its_own_cron(monkeypatch):
|
|
||||||
from beaver_agent.jobs import heartbeat as job
|
|
||||||
|
|
||||||
room = FakeRoom([], nick="beaver")
|
|
||||||
monkeypatch.setattr(job, "VIBEGRAM_HB", room)
|
|
||||||
await job.beat(cast("Any", FakeRun(cron="12 17 * * *")))
|
|
||||||
assert room.said[0].startswith("hb beaver at=")
|
|
||||||
assert "every=24h src=cron" in room.said[0]
|
|
||||||
|
|
||||||
odd = FakeRoom([], nick="beaver")
|
|
||||||
monkeypatch.setattr(job, "VIBEGRAM_HB", odd)
|
|
||||||
await job.beat(cast("Any", FakeRun(cron="0 2,8,12,16,20 * * *")))
|
|
||||||
assert "every=unknown src=none" in odd.said[0]
|
|
||||||
|
|
||||||
|
|
||||||
async def test_beat_job_keeps_silent_on_a_drill_day(tmp_path, monkeypatch, caplog):
|
|
||||||
from beaver_agent.jobs import heartbeat as job
|
|
||||||
|
|
||||||
flag = tmp_path / "сторож-учения.txt"
|
|
||||||
monkeypatch.setattr(job, "DRILL_FILE", flag)
|
|
||||||
room = FakeRoom([], nick="beaver")
|
|
||||||
monkeypatch.setattr(job, "VIBEGRAM_HB", room)
|
|
||||||
today = datetime.now(WARSAW).strftime("%Y-%m-%d")
|
|
||||||
|
|
||||||
flag.write_text(f"2026-01-01\n{today}\n", encoding="utf-8")
|
|
||||||
with caplog.at_level(logging.INFO):
|
|
||||||
await job.beat(cast("Any", FakeRun(cron="12 17 * * *")))
|
|
||||||
assert not room.said # молчание в комнате и есть учения
|
|
||||||
assert "пропущена по флагу" in caplog.text
|
|
||||||
|
|
||||||
# Не наш день - отметка уходит как обычно
|
|
||||||
flag.write_text("2026-01-01\n", encoding="utf-8")
|
|
||||||
await job.beat(cast("Any", FakeRun(cron="12 17 * * *")))
|
|
||||||
assert len(room.said) == 1 and room.said[0].startswith("hb beaver at=")
|
|
||||||
|
|
||||||
# Файла нет - тоже обычный прогон
|
|
||||||
flag.unlink()
|
|
||||||
await job.beat(cast("Any", FakeRun(cron="12 17 * * *")))
|
|
||||||
assert len(room.said) == 2
|
|
||||||
|
|
||||||
|
|
||||||
async def test_watch_job_keeps_quiet_when_the_room_is_unreadable(tmp_path, monkeypatch):
|
|
||||||
from beaver_agent.jobs import heartbeat as job
|
|
||||||
|
|
||||||
class Dead(FakeRoom):
|
|
||||||
async def window(self, limit=25):
|
|
||||||
msg = "хаб не отвечает"
|
|
||||||
raise RuntimeError(msg)
|
|
||||||
|
|
||||||
monkeypatch.setattr(job, "STATE_FILE", tmp_path / "сторож.json")
|
|
||||||
monkeypatch.setattr(job, "VIBEGRAM_HB", Dead([]))
|
|
||||||
run = FakeRun()
|
|
||||||
await job.watch(cast("Any", run))
|
|
||||||
assert not run.spawned
|
|
||||||
assert not (tmp_path / "сторож.json").exists()
|
|
||||||
|
|
||||||
|
|
||||||
class FakeHub:
|
|
||||||
"""Хаб как он есть: id сквозные по ВСЕМ комнатам, `/api/state` - наша.
|
|
||||||
|
|
||||||
Хвост комнаты лежит за `/api/state?view=…&after=<id>` и отдаётся
|
|
||||||
страницами вперёд; `/api/pending` - курсор, и сторожу в него ходить
|
|
||||||
нечего: то же событие второй раз оттуда не придёт.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, events, *, page=PAGE):
|
|
||||||
self.events = events
|
|
||||||
self.page = page
|
|
||||||
self.said = []
|
|
||||||
self.afters = []
|
|
||||||
|
|
||||||
async def call(self, method, path, body):
|
|
||||||
if method == "POST" and path == "/api/messages":
|
|
||||||
self.said.append(body["body"])
|
|
||||||
return {"ok": True}
|
|
||||||
if path == "/api/room":
|
|
||||||
return {"room": {"id": "dbb", "name": "kai-rem-watch", "viewToken": "v-1"}}
|
|
||||||
if path.startswith("/api/state"):
|
|
||||||
query = dict(parse_qsl(urlsplit(path).query))
|
|
||||||
assert query["view"] == "v-1"
|
|
||||||
after = int(query.get("after", 0))
|
|
||||||
self.afters.append(after)
|
|
||||||
rest = [e for e in self.events if e["id"] > after]
|
|
||||||
return {"room": {}, "agents": [], "events": rest[: self.page]}
|
|
||||||
msg = f"сторож не должен ходить в {path}"
|
|
||||||
raise AssertionError(msg)
|
|
||||||
|
|
||||||
|
|
||||||
def raw(eid, nick, kind, body=None, at="2026-09-23T05:00:02.000Z"):
|
|
||||||
return {
|
|
||||||
"id": eid,
|
|
||||||
"roomId": "dbb",
|
|
||||||
"nick": nick,
|
|
||||||
"kind": kind,
|
|
||||||
"payload": {} if body is None else {"body": body},
|
|
||||||
"createdAt": at,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def room(monkeypatch, events, *, page=PAGE):
|
|
||||||
hub = FakeHub(events, page=page)
|
|
||||||
|
|
||||||
async def call(self, method, path, body):
|
|
||||||
return await hub.call(method, path, body)
|
|
||||||
|
|
||||||
monkeypatch.setattr(Vibegram, "_call", call)
|
|
||||||
return hub, Vibegram(hub="http://hub", token="t", nick="beaver-rem")
|
|
||||||
|
|
||||||
|
|
||||||
KAI_HB = (
|
|
||||||
f"hb {PEER} at=2026-09-23T05:00:01Z boot=2026-09-14T08:21:55Z "
|
|
||||||
"every=24h src=cron+uptime-s"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_window_is_the_rooms_last_messages_not_the_hubs_last_ids(
|
|
||||||
tmp_path, monkeypatch, caplog
|
|
||||||
):
|
|
||||||
from beaver_agent.jobs import heartbeat as job
|
|
||||||
|
|
||||||
# В комнате три события, а по хабу за то же время прошло три десятка:
|
|
||||||
# номера сквозные, и между 296 и 336 лежат чужие комнаты. Отметка соседа
|
|
||||||
# (#329) обязана быть в окне - в комнате она первая с конца.
|
|
||||||
hub, client = room(
|
|
||||||
monkeypatch,
|
|
||||||
[
|
|
||||||
raw(296, PEER, "agent_join"),
|
|
||||||
raw(329, PEER, "message", KAI_HB),
|
|
||||||
raw(331, "beaver-rem", "agent_leave"),
|
|
||||||
raw(332, "beaver-rem", "message", "hb beaver-rem at=2026-09-23T15:12:01Z"),
|
|
||||||
raw(336, PEER, "message", "ack beaver-rem last=2026-09-23T15:12:01Z"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(job, "STATE_FILE", tmp_path / "сторож.json")
|
|
||||||
monkeypatch.setattr(job, "VIBEGRAM_HB", client)
|
|
||||||
|
|
||||||
window = await client.window(25)
|
|
||||||
assert window.full
|
|
||||||
assert [e.id for e in window.events] == [329, 332, 336] # join/leave не в счёт
|
|
||||||
assert parse_beat(window.events[0].text) is not None
|
|
||||||
|
|
||||||
# Два прогона подряд: отметка соседа в окне оба раза, а не только в первом
|
|
||||||
with caplog.at_level(logging.INFO, logger="beaver_agent.jobs.heartbeat"):
|
|
||||||
await job.watch(cast("Any", FakeRun()))
|
|
||||||
await job.watch(cast("Any", FakeRun()))
|
|
||||||
beats = [m for m in caplog.messages if "отметок соседа в окне: 1" in m]
|
|
||||||
assert len(beats) == 2
|
|
||||||
assert all("история дочитана" in m for m in beats)
|
|
||||||
assert all("last=2026-09-23T05:00:01Z" in a and "armed=yes" in a for a in hub.said)
|
|
||||||
assert all("win=25 " in a for a in hub.said)
|
|
||||||
assert hub.afters == [0, 0, 0] # одна страница на прогон, читать дальше нечего
|
|
||||||
|
|
||||||
|
|
||||||
async def test_a_hundred_noise_lines_do_not_push_the_control_beat_out(
|
|
||||||
tmp_path, monkeypatch
|
|
||||||
):
|
|
||||||
from beaver_agent.jobs import heartbeat as job
|
|
||||||
|
|
||||||
# Учения соседа: сотня шумовых строк в комнату и контрольная hb после них.
|
|
||||||
noise = [raw(400 + i, PEER, "message", f"шум {i}") for i in range(100)]
|
|
||||||
hub, client = room(monkeypatch, [*noise, raw(600, PEER, "message", KAI_HB)])
|
|
||||||
monkeypatch.setattr(job, "STATE_FILE", tmp_path / "сторож.json")
|
|
||||||
monkeypatch.setattr(job, "VIBEGRAM_HB", client)
|
|
||||||
|
|
||||||
await job.watch(cast("Any", FakeRun()))
|
|
||||||
|
|
||||||
assert "last=2026-09-23T05:00:01Z" in hub.said[0] and "armed=yes" in hub.said[0]
|
|
||||||
assert Watch.load(tmp_path / "сторож.json").top == 600
|
|
||||||
|
|
||||||
|
|
||||||
def test_unread_window_is_named_and_takes_no_alarm(tmp_path):
|
|
||||||
late = {"seen": NOW - timedelta(hours=40), "every": timedelta(hours=24)}
|
|
||||||
partial = look(state(tmp_path, **late), [], now=NOW, res=None, full=False)
|
|
||||||
assert not partial.full and partial.alarm is None
|
|
||||||
assert "win=25/partial" in partial.ack
|
|
||||||
assert "окно не дочитано" in partial.note
|
|
||||||
# То же пустое окно, но дочитанное, - это показание, и оно даёт тревогу
|
|
||||||
whole = look(state(tmp_path, **late), [], now=NOW, res=None, full=True)
|
|
||||||
assert whole.alarm == "silence" and "win=25 " in whole.ack
|
|
||||||
assert "сообщений комнаты: 0 из 25, история дочитана" in whole.note
|
|
||||||
@@ -49,138 +49,3 @@ def test_clip_keeps_head_and_tail():
|
|||||||
k = Komodo(url="u", key="k", secret="s", max_chars=30)
|
k = Komodo(url="u", key="k", secret="s", max_chars=30)
|
||||||
out = k._clip("a" * 100)
|
out = k._clip("a" * 100)
|
||||||
assert out.startswith("a" * 20) and out.endswith("a" * 10) and "обрезано" in out
|
assert out.startswith("a" * 20) and out.endswith("a" * 10) and "обрезано" in out
|
||||||
|
|
||||||
|
|
||||||
def fleet(answers, calls=None):
|
|
||||||
"""Komodo, отвечающий заготовками; calls копит запросы к нему."""
|
|
||||||
|
|
||||||
class Fake(Komodo):
|
|
||||||
async def read(self, request, params):
|
|
||||||
if calls is not None:
|
|
||||||
calls.append((request, params))
|
|
||||||
answer = answers.get(request, [])
|
|
||||||
return answer(params) if callable(answer) else answer
|
|
||||||
|
|
||||||
return Fake(url="u", key="k", secret="s")
|
|
||||||
|
|
||||||
|
|
||||||
def a_stack(name, state="running", stack_id=None):
|
|
||||||
return {
|
|
||||||
"id": stack_id or name,
|
|
||||||
"name": name,
|
|
||||||
"info": {"state": state, "server_name": "dell-home"},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def an_update(operation="DeployStack", target_id="beaver-agent", ts=1_700_000_000_000):
|
|
||||||
return {
|
|
||||||
"id": f"upd-{ts}",
|
|
||||||
"operation": operation,
|
|
||||||
"start_ts": ts,
|
|
||||||
"success": True,
|
|
||||||
"status": "Complete",
|
|
||||||
"username": "hh",
|
|
||||||
"target": {"type": "Stack", "id": target_id},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def test_a_short_page_is_not_the_whole_list():
|
|
||||||
# Komodo режет List* до default_pagination_limit и молчит об этом.
|
|
||||||
pages = [
|
|
||||||
[a_stack(f"s{i}") for i in range(500)],
|
|
||||||
[a_stack("zv-import"), a_stack("veris-runner")],
|
|
||||||
]
|
|
||||||
calls = []
|
|
||||||
komodo = fleet({"ListStacks": lambda p: pages[p["page"]]}, calls)
|
|
||||||
out = await komodo._stacks()
|
|
||||||
assert [p["page"] for r, p in calls if r == "ListStacks"] == [0, 1]
|
|
||||||
assert "zv-import" in out and "стеков: 502" in out
|
|
||||||
|
|
||||||
|
|
||||||
async def test_status_says_when_the_not_running_list_is_cut():
|
|
||||||
bad = [a_stack(f"b{i}", state="unknown") for i in range(23)]
|
|
||||||
komodo = fleet({"ListStacks": bad, "ListServers": []})
|
|
||||||
out = await komodo._status()
|
|
||||||
assert "стеков: 23, не running: 23" in out
|
|
||||||
assert "показаны 15 из 23, остальные скрыты лимитом" in out
|
|
||||||
|
|
||||||
komodo = fleet({"ListStacks": bad[:3], "ListServers": []})
|
|
||||||
assert "скрыты лимитом" not in await komodo._status()
|
|
||||||
|
|
||||||
|
|
||||||
async def test_both_listings_say_that_they_are_limited_by_rights():
|
|
||||||
komodo = fleet({"ListStacks": [a_stack("beaver-agent")], "ListServers": []})
|
|
||||||
assert "ограничен правами ключа" in await komodo._status()
|
|
||||||
assert "ограничен правами ключа" in await komodo._stacks()
|
|
||||||
|
|
||||||
|
|
||||||
async def test_updates_carry_the_shown_span_and_say_there_is_more():
|
|
||||||
page = {
|
|
||||||
"updates": [an_update(ts=1_700_000_000_000 - i * 60_000) for i in range(100)]
|
|
||||||
}
|
|
||||||
seen = {"ListStacks": [a_stack("beaver-agent")]}
|
|
||||||
komodo = fleet(seen | {"ListUpdates": page | {"next_page": 1}})
|
|
||||||
head, second, *lines = (await komodo._updates(15, None, None)).splitlines()
|
|
||||||
assert head == "операций: 15, 11-14 21:59 … 11-14 22:13, раньше в журнале есть ещё"
|
|
||||||
assert second == "фильтра нет: это просто хвост журнала"
|
|
||||||
assert len(lines) == 15
|
|
||||||
|
|
||||||
komodo = fleet(
|
|
||||||
seen | {"ListUpdates": {"updates": page["updates"][:3], "next_page": None}}
|
|
||||||
)
|
|
||||||
assert (await komodo._updates(15, None, None)).startswith(
|
|
||||||
"операций: 3, 11-14 22:11 … 11-14 22:13, это весь журнал"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_the_filter_goes_to_komodo_as_a_query():
|
|
||||||
calls = []
|
|
||||||
komodo = fleet(
|
|
||||||
{
|
|
||||||
"ListStacks": [a_stack("beaver-agent", stack_id="id-1")],
|
|
||||||
"ListUpdates": {
|
|
||||||
"updates": [an_update(target_id="id-1")],
|
|
||||||
"next_page": None,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
calls,
|
|
||||||
)
|
|
||||||
out = await komodo._updates(15, "deploy", "beaver")
|
|
||||||
(params,) = [p for r, p in calls if r == "ListUpdates"]
|
|
||||||
assert params["query"] == {
|
|
||||||
"operation": {"$regex": "deploy", "$options": "i"},
|
|
||||||
"target.id": {"$in": ["id-1"]},
|
|
||||||
}
|
|
||||||
assert "фильтрует Komodo: операция ~deploy; ресурс ~beaver (1 шт)" in out
|
|
||||||
# имя ресурса в строке, а не id
|
|
||||||
assert "beaver-agent" in out and "id-1 " not in out
|
|
||||||
|
|
||||||
|
|
||||||
async def test_an_unknown_name_is_said_out_loud_instead_of_filtering_by_nothing():
|
|
||||||
komodo = fleet({"ListStacks": [a_stack("beaver-agent")]})
|
|
||||||
out = await komodo._updates(15, None, "obsidian")
|
|
||||||
assert out == (
|
|
||||||
"ресурса ~obsidian ключу руки не видно: либо имя другое, либо он вне прав ключа"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_a_target_the_key_cannot_see_is_counted_and_shown_by_id():
|
|
||||||
komodo = fleet(
|
|
||||||
{
|
|
||||||
"ListStacks": [a_stack("beaver-agent", stack_id="id-1")],
|
|
||||||
"ListUpdates": {
|
|
||||||
"updates": [an_update(target_id="id-1"), an_update(target_id="gone")],
|
|
||||||
"next_page": None,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
out = await komodo._updates(15, None, None)
|
|
||||||
assert "записей о невидимых ключу ресурсах: 1 - id вместо имени" in out
|
|
||||||
assert "gone" in out
|
|
||||||
|
|
||||||
|
|
||||||
async def test_the_depth_is_clamped_out_loud():
|
|
||||||
komodo = fleet({"ListUpdates": {"updates": [], "next_page": None}})
|
|
||||||
assert "запрошено 5000 записей, предел руки 200" in await komodo._updates(
|
|
||||||
5000, None, None
|
|
||||||
)
|
|
||||||
@@ -1,813 +0,0 @@
|
|||||||
import json
|
|
||||||
from datetime import UTC, datetime, timedelta
|
|
||||||
|
|
||||||
from beaver_agent.hands.komodo import Komodo
|
|
||||||
from beaver_agent.hands.komodo_logs import (
|
|
||||||
KEEP,
|
|
||||||
MUTE_KINDS,
|
|
||||||
MUTE_SHOW,
|
|
||||||
SELF_MARK,
|
|
||||||
SURGE_CEILING,
|
|
||||||
SURGE_FACTOR,
|
|
||||||
SURGE_MIN,
|
|
||||||
Seen,
|
|
||||||
StackScan,
|
|
||||||
Sweep,
|
|
||||||
brief,
|
|
||||||
daily,
|
|
||||||
digest,
|
|
||||||
grouped,
|
|
||||||
note,
|
|
||||||
read_stack,
|
|
||||||
record,
|
|
||||||
)
|
|
||||||
from beaver_agent.jobs.logs import sweep as sweep_fleet
|
|
||||||
|
|
||||||
NOW = datetime(2026, 9, 20, 18, 0, tzinfo=UTC)
|
|
||||||
|
|
||||||
|
|
||||||
def log(*lines):
|
|
||||||
return {"stdout": "\n".join(lines), "stderr": ""}
|
|
||||||
|
|
||||||
|
|
||||||
def line(service, at, text):
|
|
||||||
return f"{service} | {at}Z {text}"
|
|
||||||
|
|
||||||
|
|
||||||
def a_stack(name, server="dell-home"):
|
|
||||||
return {
|
|
||||||
"id": name,
|
|
||||||
"name": name,
|
|
||||||
"info": {"state": "running", "server_name": server},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def fleet(answers, calls=None):
|
|
||||||
"""Komodo, отвечающий заготовками; calls копит запросы к нему."""
|
|
||||||
|
|
||||||
class Fake(Komodo):
|
|
||||||
async def read(self, request, params):
|
|
||||||
if calls is not None:
|
|
||||||
calls.append((request, params))
|
|
||||||
answer = answers.get(request, [])
|
|
||||||
return answer(params) if callable(answer) else answer
|
|
||||||
|
|
||||||
return Fake(url="u", key="k", secret="s")
|
|
||||||
|
|
||||||
|
|
||||||
def swept(scans, **kw):
|
|
||||||
kw.setdefault("in_scope", len(scans))
|
|
||||||
kw.setdefault("fleet", len(scans))
|
|
||||||
return Sweep(at=kw.pop("at", NOW), scans=tuple(scans), **kw)
|
|
||||||
|
|
||||||
|
|
||||||
KNOWN = ("beaver-agent", "gitea", "democalc", "mix", "healthbot")
|
|
||||||
|
|
||||||
|
|
||||||
def seen(tmp_path, *, fresh=False, known=KNOWN):
|
|
||||||
"""Состояние с уже снятой базовой линией: проход не первый."""
|
|
||||||
state = Seen.load(tmp_path / "логи.json")
|
|
||||||
if not fresh:
|
|
||||||
state.fresh = False
|
|
||||||
state.baselined = set(known)
|
|
||||||
return state
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_line_gives_its_service_its_time_and_its_signal():
|
|
||||||
scan = read_stack(
|
|
||||||
"beaver-agent",
|
|
||||||
log(
|
|
||||||
line("beaver-gateway", "2026-09-20T17:40:01.123456789", "ERROR boom"),
|
|
||||||
line("beaver-gateway", "2026-09-20T17:41:02.000000000", "ERROR boom"),
|
|
||||||
line("beaver-obsidian", "2026-09-20T17:42:03.000000000", "Fully synced"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
(hit,) = scan.hits
|
|
||||||
assert hit.service == "beaver-gateway"
|
|
||||||
assert hit.signal == "ошибка"
|
|
||||||
assert hit.count == 2
|
|
||||||
assert hit.at == datetime(2026, 9, 20, 17, 41, 2, tzinfo=UTC)
|
|
||||||
assert scan.first == datetime(2026, 9, 20, 17, 40, 1, 123456, tzinfo=UTC)
|
|
||||||
assert scan.last == datetime(2026, 9, 20, 17, 42, 3, tzinfo=UTC)
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_same_trouble_with_other_numbers_is_the_same_trouble():
|
|
||||||
scan = read_stack(
|
|
||||||
"gitea",
|
|
||||||
log(
|
|
||||||
line("gitea", "2026-09-20T17:40:01.0", "ERROR request 8871 failed"),
|
|
||||||
line("gitea", "2026-09-20T17:40:09.0", "ERROR request 9134 failed"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
(hit,) = scan.hits
|
|
||||||
assert hit.count == 2
|
|
||||||
|
|
||||||
|
|
||||||
def test_every_named_signal_is_caught():
|
|
||||||
texts = (
|
|
||||||
"CRITICAL disk is gone",
|
|
||||||
"panic: runtime error",
|
|
||||||
"container OOMKilled",
|
|
||||||
"Traceback (most recent call last):",
|
|
||||||
"service exited with code 137",
|
|
||||||
"beaver-gateway Restarting (1) 3 seconds ago",
|
|
||||||
"container is unhealthy",
|
|
||||||
"ERROR plain",
|
|
||||||
)
|
|
||||||
scan = read_stack(
|
|
||||||
"mix",
|
|
||||||
log(*(line("svc", f"2026-09-20T17:4{i}:00.0", t) for i, t in enumerate(texts))),
|
|
||||||
)
|
|
||||||
assert {h.signal for h in scan.hits} == {
|
|
||||||
"критично",
|
|
||||||
"паника",
|
|
||||||
"oom",
|
|
||||||
"traceback",
|
|
||||||
"падение",
|
|
||||||
"рестарт-цикл",
|
|
||||||
"healthcheck",
|
|
||||||
"ошибка",
|
|
||||||
}
|
|
||||||
assert "exited with code 0" not in {h.sample for h in scan.hits}
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_clean_exit_and_our_own_inject_are_not_trouble():
|
|
||||||
scan = read_stack(
|
|
||||||
"beaver-agent",
|
|
||||||
log(
|
|
||||||
line("svc", "2026-09-20T17:40:00.0", "worker exited with code 0"),
|
|
||||||
line("beaver-gateway", "2026-09-20T17:41:00.0", f"{SELF_MARK}: ERROR boom"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
assert scan.hits == ()
|
|
||||||
|
|
||||||
|
|
||||||
def test_komodos_own_error_is_not_a_clean_stack():
|
|
||||||
scan = read_stack("gitea", {"error": "periphery unreachable"})
|
|
||||||
assert not scan.ok
|
|
||||||
assert scan.error == "periphery unreachable"
|
|
||||||
assert Sweep(at=NOW, scans=(scan,), in_scope=1, fleet=1).trouble == (
|
|
||||||
"gitea: periphery unreachable",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_first_pass_only_takes_the_baseline(tmp_path):
|
|
||||||
state = seen(tmp_path, fresh=True)
|
|
||||||
scan = read_stack(
|
|
||||||
"beaver-agent", log(line("svc", "2026-09-20T17:40:00.0", "ERROR старая беда"))
|
|
||||||
)
|
|
||||||
report = digest(state, swept([scan]))
|
|
||||||
assert report.cold and report.baseline == 1
|
|
||||||
assert report.new == ()
|
|
||||||
text = brief(report)
|
|
||||||
assert "базовую линию" in text
|
|
||||||
assert "прочитано 1 стеков из 1" in text
|
|
||||||
|
|
||||||
|
|
||||||
def test_only_the_new_line_wakes_the_master(tmp_path):
|
|
||||||
old = line("svc", "2026-09-20T17:40:00.0", "ERROR молотит третьи сутки")
|
|
||||||
state = seen(tmp_path, fresh=True)
|
|
||||||
first = digest(state, swept([read_stack("beaver-agent", log(old))]))
|
|
||||||
state.mark_told(first)
|
|
||||||
state.save()
|
|
||||||
|
|
||||||
fresh_line = line("svc", "2026-09-20T18:50:00.0", "CRITICAL диск кончился")
|
|
||||||
state = Seen.load(tmp_path / "логи.json")
|
|
||||||
report = digest(
|
|
||||||
state,
|
|
||||||
swept(
|
|
||||||
[read_stack("beaver-agent", log(old, old, fresh_line))],
|
|
||||||
at=NOW + timedelta(hours=3),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
(new,) = report.new
|
|
||||||
assert new.signal == "критично"
|
|
||||||
text = brief(report)
|
|
||||||
assert "диск кончился" in text
|
|
||||||
assert "молотит третьи сутки" not in text
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_repeat_carries_how_many_times(tmp_path):
|
|
||||||
noisy = [
|
|
||||||
line("svc", f"2026-09-20T17:4{i}:00.0", "ERROR раз за разом") for i in range(5)
|
|
||||||
]
|
|
||||||
report = digest(seen(tmp_path), swept([read_stack("beaver-agent", log(*noisy))]))
|
|
||||||
assert "×5" in brief(report)
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_trouble_that_stopped_is_dated_by_the_event_not_by_the_look(tmp_path):
|
|
||||||
was = line("svc", "2026-09-20T12:03:00.0", "ERROR было и прошло")
|
|
||||||
state = seen(tmp_path)
|
|
||||||
state.mark_told(digest(state, swept([read_stack("beaver-agent", log(was))])))
|
|
||||||
|
|
||||||
later = NOW + timedelta(hours=6)
|
|
||||||
report = digest(state, swept([read_stack("beaver-agent", log())], at=later))
|
|
||||||
(quiet,) = report.quiet
|
|
||||||
assert quiet.last == datetime(2026, 9, 20, 12, 3, tzinfo=UTC)
|
|
||||||
text = brief(report)
|
|
||||||
assert "последнее в 20.09 12:03" in text
|
|
||||||
assert f"{later:%H:%M}" not in text.split("\n", 1)[1]
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_stack_that_did_not_answer_is_not_a_stack_that_went_quiet(tmp_path):
|
|
||||||
was = line("svc", "2026-09-20T12:03:00.0", "ERROR было")
|
|
||||||
state = seen(tmp_path)
|
|
||||||
state.mark_told(digest(state, swept([read_stack("beaver-agent", log(was))])))
|
|
||||||
|
|
||||||
report = digest(
|
|
||||||
state,
|
|
||||||
swept(
|
|
||||||
[StackScan(stack="beaver-agent", error="timeout")],
|
|
||||||
at=NOW + timedelta(hours=3),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
assert report.quiet == ()
|
|
||||||
assert report.trouble == ("beaver-agent: timeout",)
|
|
||||||
assert "beaver-agent: timeout" in brief(report)
|
|
||||||
|
|
||||||
|
|
||||||
def test_an_undelivered_inject_leaves_the_trouble_new(tmp_path):
|
|
||||||
scan = read_stack(
|
|
||||||
"beaver-agent", log(line("svc", "2026-09-20T17:40:00.0", "ERROR боль"))
|
|
||||||
)
|
|
||||||
state = seen(tmp_path)
|
|
||||||
first = digest(state, swept([scan]))
|
|
||||||
assert len(first.new) == 1
|
|
||||||
state.save() # инжект не доехал: mark_told не звали
|
|
||||||
|
|
||||||
state = Seen.load(state.path)
|
|
||||||
again = digest(
|
|
||||||
state, swept([read_stack("beaver-agent", log())], at=NOW + timedelta(hours=3))
|
|
||||||
)
|
|
||||||
assert [e.sample for e in again.new] == [e.sample for e in first.new]
|
|
||||||
assert again.quiet == ()
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_state_survives_a_restart(tmp_path):
|
|
||||||
scan = read_stack(
|
|
||||||
"beaver-agent", log(line("svc", "2026-09-20T17:40:00.0", "ERROR боль"))
|
|
||||||
)
|
|
||||||
state = seen(tmp_path)
|
|
||||||
state.mark_told(digest(state, swept([scan])))
|
|
||||||
state.save()
|
|
||||||
|
|
||||||
state = Seen.load(state.path)
|
|
||||||
assert not state.fresh
|
|
||||||
report = digest(state, swept([scan], at=NOW + timedelta(hours=3)))
|
|
||||||
assert report.new == () and report.quiet == ()
|
|
||||||
|
|
||||||
|
|
||||||
def test_an_old_line_still_lying_in_the_tail_is_not_new_again(tmp_path):
|
|
||||||
# Тихий стек держит августовскую строку в хвосте месяцами: она не новая.
|
|
||||||
scan = read_stack(
|
|
||||||
"democalc", log(line("pg-1", "2026-08-22T15:49:51.0", "ERROR: relation"))
|
|
||||||
)
|
|
||||||
state = seen(tmp_path)
|
|
||||||
state.mark_told(digest(state, swept([scan])))
|
|
||||||
state.save()
|
|
||||||
|
|
||||||
state = Seen.load(state.path)
|
|
||||||
assert digest(state, swept([scan], at=NOW + timedelta(hours=3))).new == ()
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_trouble_forgotten_by_time_comes_back_as_new(tmp_path):
|
|
||||||
scan = read_stack(
|
|
||||||
"beaver-agent", log(line("svc", "2026-09-20T17:40:00.0", "ERROR боль"))
|
|
||||||
)
|
|
||||||
state = seen(tmp_path)
|
|
||||||
state.mark_told(digest(state, swept([scan])))
|
|
||||||
late = NOW + KEEP + timedelta(days=1)
|
|
||||||
report = digest(state, swept([scan], at=late))
|
|
||||||
assert len(report.new) == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_silent_pass_is_not_worth_the_master(tmp_path):
|
|
||||||
state = seen(tmp_path)
|
|
||||||
assert not digest(state, swept([read_stack("beaver-agent", log())])).worth_telling
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_scope_says_how_much_was_looked_at(tmp_path):
|
|
||||||
scans = [
|
|
||||||
read_stack(
|
|
||||||
"beaver-agent", log(line("svc", "2026-09-20T17:40:00.0", "ERROR боль"))
|
|
||||||
),
|
|
||||||
StackScan(stack="gitea", error="timeout"),
|
|
||||||
]
|
|
||||||
report = digest(seen(tmp_path), swept(scans, in_scope=2, fleet=53))
|
|
||||||
head = brief(report).splitlines()[1]
|
|
||||||
assert head.startswith(
|
|
||||||
"Область: dell-home, personal-main - прочитано 1 стеков из 2"
|
|
||||||
)
|
|
||||||
assert "во флоте 53" in head
|
|
||||||
assert "хвост 2000 строк на стек: строки с 20.09 17:40 по 20.09 17:40" in head
|
|
||||||
assert "самый короткий хвост у beaver-agent - только с 20.09 17:40" in head
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_tail_shorter_than_the_pause_between_passes_is_said_out_loud(tmp_path):
|
|
||||||
state = seen(tmp_path)
|
|
||||||
state.last_pass = NOW - timedelta(hours=3)
|
|
||||||
scan = read_stack(
|
|
||||||
"beaver-agent",
|
|
||||||
log(line("svc", "2026-09-20T17:59:00.0", "ERROR из последней минуты")),
|
|
||||||
)
|
|
||||||
report = digest(state, swept([scan]))
|
|
||||||
assert report.gaps == ("beaver-agent",)
|
|
||||||
assert "часть строк прошла мимо" in brief(report)
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_silent_komodo_says_so_instead_of_reporting_clean(tmp_path):
|
|
||||||
down = Sweep(at=NOW, unreachable="ClientError: connection refused")
|
|
||||||
report = digest(seen(tmp_path), down)
|
|
||||||
assert report.worth_telling
|
|
||||||
text = brief(report)
|
|
||||||
assert "Komodo не ответил" in text
|
|
||||||
assert "Нового в этой области нет" not in text
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_lasting_outage_is_told_once(tmp_path):
|
|
||||||
down = Sweep(at=NOW, unreachable="ClientError: connection refused")
|
|
||||||
state = seen(tmp_path)
|
|
||||||
state.mark_told(digest(state, down))
|
|
||||||
assert digest(state, down).trouble == ()
|
|
||||||
|
|
||||||
|
|
||||||
async def test_the_sweep_lists_every_page_and_skips_other_peoples_machines():
|
|
||||||
pages = [
|
|
||||||
[a_stack(f"s{i}") for i in range(500)],
|
|
||||||
[a_stack("gitea", "personal-main"), a_stack("veris-runner", "veris-main")],
|
|
||||||
]
|
|
||||||
calls = []
|
|
||||||
komodo = fleet(
|
|
||||||
{
|
|
||||||
"ListStacks": lambda p: pages[p["page"]],
|
|
||||||
"GetStacksSummary": {"total": 502},
|
|
||||||
"GetStackLog": log(),
|
|
||||||
},
|
|
||||||
calls,
|
|
||||||
)
|
|
||||||
out = await sweep_fleet(komodo, now=NOW)
|
|
||||||
asked = [p["stack"] for r, p in calls if r == "GetStackLog"]
|
|
||||||
assert "veris-runner" not in asked
|
|
||||||
assert "gitea" in asked and "s0" in asked
|
|
||||||
assert out.fleet == 502 and out.in_scope == 501
|
|
||||||
assert out.trouble == ()
|
|
||||||
assert all(p.get("timestamps") for r, p in calls if r == "GetStackLog")
|
|
||||||
|
|
||||||
|
|
||||||
async def test_the_sweep_shouts_when_the_list_and_the_summary_disagree():
|
|
||||||
komodo = fleet(
|
|
||||||
{
|
|
||||||
"ListStacks": [a_stack("beaver-agent")],
|
|
||||||
"GetStacksSummary": {"total": 53},
|
|
||||||
"GetStackLog": log(),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
out = await sweep_fleet(komodo, now=NOW)
|
|
||||||
assert out.trouble == (
|
|
||||||
"листание дало 1 стеков, а GetStacksSummary - 53: список неполон",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_a_stack_that_throws_does_not_end_the_sweep():
|
|
||||||
def logs(params):
|
|
||||||
if params["stack"] == "gitea":
|
|
||||||
msg = "connection reset"
|
|
||||||
raise RuntimeError(msg)
|
|
||||||
return log(line("svc", "2026-09-20T17:40:00.0", "ERROR боль"))
|
|
||||||
|
|
||||||
komodo = fleet(
|
|
||||||
{
|
|
||||||
"ListStacks": [a_stack("beaver-agent"), a_stack("gitea", "personal-main")],
|
|
||||||
"GetStacksSummary": {"total": 2},
|
|
||||||
"GetStackLog": logs,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
out = await sweep_fleet(komodo, now=NOW)
|
|
||||||
assert len(out.read) == 1
|
|
||||||
assert out.trouble == ("gitea: RuntimeError: connection reset",)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_a_dead_komodo_makes_a_sweep_that_admits_it():
|
|
||||||
class Dead(Komodo):
|
|
||||||
async def read(self, request, params):
|
|
||||||
msg = "connection refused"
|
|
||||||
raise OSError(msg)
|
|
||||||
|
|
||||||
out = await sweep_fleet(Dead(url="u", key="k", secret="s"), now=NOW)
|
|
||||||
assert out.read == ()
|
|
||||||
assert out.unreachable == "OSError: connection refused"
|
|
||||||
assert out.trouble == ("Komodo не ответил: OSError: connection refused",)
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_line_without_a_stamp_is_not_a_find():
|
|
||||||
# Голову хвоста Komodo обрезает по месту: полстроки - не новая беда.
|
|
||||||
scan = read_stack(
|
|
||||||
"democalc",
|
|
||||||
log(
|
|
||||||
"49.260 UTC [8772] ERROR: relation does not exist",
|
|
||||||
line("postgres-1", "2026-09-20T17:40:00.0", "ERROR: настоящая"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
assert [h.sample for h in scan.hits] == ["ERROR: настоящая"]
|
|
||||||
assert scan.unparsed == 1 and scan.lines == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_many_unreadable_lines_are_said_out_loud(tmp_path):
|
|
||||||
scan = StackScan(stack="beaver-agent", lines=5, unparsed=40)
|
|
||||||
head = brief(digest(seen(tmp_path), swept([scan]))).splitlines()[1]
|
|
||||||
assert "строк без метки времени пропущено 40" in head
|
|
||||||
|
|
||||||
|
|
||||||
def test_an_empty_line_is_a_line_not_a_blind_spot():
|
|
||||||
scan = read_stack("dell-home-dawarich", log("app-1 | 2026-09-17T14:37:38.510Z"))
|
|
||||||
assert scan.unparsed == 0 and scan.lines == 1 and scan.hits == ()
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_stack_read_for_the_first_time_does_not_dump_its_past(tmp_path):
|
|
||||||
state = seen(tmp_path)
|
|
||||||
scan = read_stack(
|
|
||||||
"lodge", log(line("svc", "2026-09-19T10:00:00.0", "ERROR старое, до нас"))
|
|
||||||
)
|
|
||||||
report = digest(state, swept([scan]))
|
|
||||||
assert report.new == ()
|
|
||||||
assert report.baselined == ("lodge",) and report.baseline == 1
|
|
||||||
assert "Впервые прочитаны" in brief(report)
|
|
||||||
|
|
||||||
later = read_stack(
|
|
||||||
"lodge",
|
|
||||||
log(
|
|
||||||
line("svc", "2026-09-19T10:00:00.0", "ERROR старое, до нас"),
|
|
||||||
line("svc", "2026-09-20T19:00:00.0", "ERROR уже при нас"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
after = digest(state, swept([later], at=NOW + timedelta(hours=3)))
|
|
||||||
assert [e.sample for e in after.new] == ["ERROR уже при нас"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_first_pass_into_a_dead_komodo_does_not_burn_the_baseline(tmp_path):
|
|
||||||
state = seen(tmp_path, fresh=True)
|
|
||||||
down = Sweep(at=NOW, unreachable="OSError: connection refused")
|
|
||||||
report = digest(state, down)
|
|
||||||
assert not report.cold and state.fresh
|
|
||||||
assert report.trouble == ("Komodo не ответил: OSError: connection refused",)
|
|
||||||
|
|
||||||
scan = read_stack(
|
|
||||||
"beaver-agent", log(line("svc", "2026-09-20T17:40:00.0", "ERROR боль"))
|
|
||||||
)
|
|
||||||
later = digest(state, swept([scan], at=NOW + timedelta(hours=3)))
|
|
||||||
assert later.cold and later.new == ()
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_named_secret_in_the_line_does_not_ride_into_the_chat():
|
|
||||||
scan = read_stack(
|
|
||||||
"gitea",
|
|
||||||
log(line("svc", "2026-09-20T17:40:00.0", "ERROR auth failed token=ghp_ZZ123")),
|
|
||||||
)
|
|
||||||
(hit,) = scan.hits
|
|
||||||
assert "ghp_ZZ123" not in hit.sample
|
|
||||||
assert hit.sample.startswith("ERROR auth failed token=")
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_bare_blob_stays_readable():
|
|
||||||
# Размен с открытыми глазами: id задач, сессий и коммитов - это сигнал,
|
|
||||||
# и замазывать их ради редкого безымянного ключа дороже, чем оставить.
|
|
||||||
scan = read_stack(
|
|
||||||
"komodo",
|
|
||||||
log(line("svc", "2026-09-20T17:40:00.0", "ERROR task kJ8sdfLKJ23kjhsdf98234x")),
|
|
||||||
)
|
|
||||||
(hit,) = scan.hits
|
|
||||||
assert "kJ8sdfLKJ23kjhsdf98234x" in hit.sample
|
|
||||||
|
|
||||||
|
|
||||||
def stamps(day, hour, count, start=0):
|
|
||||||
return [
|
|
||||||
f"2026-09-{day:02d}T{hour:02d}:{m:02d}:00.0"
|
|
||||||
for m in range(start, start + count)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def burst(stack, times, text="ERROR всё та же беда", service="svc"):
|
|
||||||
"""Хвост стека, где одна и та же беда повторилась в перечисленные минуты."""
|
|
||||||
return read_stack(stack, log(*(line(service, t, text) for t in times)))
|
|
||||||
|
|
||||||
|
|
||||||
def at(day, hour, minute=0):
|
|
||||||
return datetime(2026, 9, day, hour, minute, tzinfo=UTC)
|
|
||||||
|
|
||||||
|
|
||||||
def told(state, report):
|
|
||||||
state.mark_told(report)
|
|
||||||
return report
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_continuing_trouble_is_told_once_and_then_keeps_quiet(tmp_path):
|
|
||||||
state = seen(tmp_path)
|
|
||||||
first = told(
|
|
||||||
state, digest(state, swept([burst("gitea", stamps(20, 9, 3))], at=at(20, 10)))
|
|
||||||
)
|
|
||||||
assert len(first.new) == 1
|
|
||||||
|
|
||||||
# Хвост перечитан: те же строки плюс свежие - беда идёт, но новостью не стала.
|
|
||||||
later = digest(
|
|
||||||
state,
|
|
||||||
swept([burst("gitea", stamps(20, 9, 3) + stamps(20, 12, 4))], at=at(20, 13)),
|
|
||||||
)
|
|
||||||
assert later.new == () and later.surge == () and later.quiet == ()
|
|
||||||
assert not later.worth_telling
|
|
||||||
assert later.ongoing == () # за прошлые сутки её ещё не было
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_reread_tail_does_not_inflate_the_count(tmp_path):
|
|
||||||
state = seen(tmp_path)
|
|
||||||
told(state, digest(state, swept([burst("gitea", stamps(20, 9, 3))], at=at(20, 10))))
|
|
||||||
for hour in (13, 16, 19):
|
|
||||||
digest(state, swept([burst("gitea", stamps(20, 9, 3))], at=at(20, hour)))
|
|
||||||
(entry,) = state.entries.values()
|
|
||||||
assert entry.count == 3 # три повтора, а не три на каждый из четырёх проходов
|
|
||||||
assert entry.passes == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_continuing_trouble_comes_back_in_the_daily_summary(tmp_path):
|
|
||||||
state = seen(tmp_path)
|
|
||||||
told(state, digest(state, swept([burst("gitea", stamps(20, 9, 5))], at=at(20, 10))))
|
|
||||||
|
|
||||||
# Первый проход следующих суток - 01:40, он же несёт сводку.
|
|
||||||
night = digest(
|
|
||||||
state,
|
|
||||||
swept([burst("gitea", stamps(20, 9, 5) + stamps(21, 1, 2))], at=at(21, 1, 40)),
|
|
||||||
)
|
|
||||||
assert night.summary and len(night.ongoing) == 1
|
|
||||||
assert night.new == () and not night.worth_telling
|
|
||||||
text = daily(night, UTC)
|
|
||||||
assert "сводка за 2026-09-20" in text # числа - про закрывшиеся сутки
|
|
||||||
assert "gitea/svc ошибка, с 20.09 09:00, за сутки 5, первые сутки" in text
|
|
||||||
assert "ERROR" not in text # образцов строк в сводке нет намеренно
|
|
||||||
told(state, night)
|
|
||||||
state.mark_daily(night)
|
|
||||||
|
|
||||||
# Тот же день, следующий проход: сводку уже отдали.
|
|
||||||
again = digest(
|
|
||||||
state,
|
|
||||||
swept([burst("gitea", stamps(20, 9, 5) + stamps(21, 1, 2))], at=at(21, 4, 40)),
|
|
||||||
)
|
|
||||||
assert again.ongoing and not again.summary
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_summary_says_which_way_it_is_going(tmp_path):
|
|
||||||
state = seen(tmp_path)
|
|
||||||
told(state, digest(state, swept([burst("gitea", stamps(20, 9, 2))], at=at(20, 10))))
|
|
||||||
day_two = swept(
|
|
||||||
[burst("gitea", stamps(20, 9, 2) + stamps(21, 9, 8))], at=at(21, 10)
|
|
||||||
)
|
|
||||||
told(state, digest(state, day_two))
|
|
||||||
third = digest(
|
|
||||||
state,
|
|
||||||
swept(
|
|
||||||
[burst("gitea", stamps(20, 9, 2) + stamps(21, 9, 8) + stamps(22, 1, 1))],
|
|
||||||
at=at(22, 1, 40),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
assert "за сутки 8, растёт (было 2)" in daily(third, UTC)
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_surge_of_the_familiar_breaks_the_silence(tmp_path):
|
|
||||||
state = seen(tmp_path)
|
|
||||||
state_report = digest(
|
|
||||||
state, swept([burst("gitea", stamps(20, 9, 1))], at=at(20, 10))
|
|
||||||
)
|
|
||||||
told(state, state_report)
|
|
||||||
for hour, minute in ((13, 12), (16, 15)):
|
|
||||||
digest(
|
|
||||||
state,
|
|
||||||
swept([burst("gitea", stamps(20, hour - 1, 1, minute))], at=at(20, hour)),
|
|
||||||
)
|
|
||||||
(entry,) = state.entries.values()
|
|
||||||
assert entry.passes == 3 and entry.norm == 1.0
|
|
||||||
|
|
||||||
spike = digest(state, swept([burst("gitea", stamps(20, 18, 12))], at=at(20, 19)))
|
|
||||||
(item,) = spike.surge
|
|
||||||
assert item.gain == 12 and item.norm == 1.0
|
|
||||||
assert spike.new == () and spike.worth_telling
|
|
||||||
text = brief(spike)
|
|
||||||
assert "Всплеск знакомого (1)" in text
|
|
||||||
assert "×12 за проход при норме ×1.0" in text
|
|
||||||
assert "Нового в этой области нет" not in text
|
|
||||||
|
|
||||||
# Второй такой же проход внутри выдержки молчит: это уже не новость.
|
|
||||||
told(state, spike)
|
|
||||||
more = digest(state, swept([burst("gitea", stamps(20, 21, 12))], at=at(20, 22)))
|
|
||||||
assert more.surge == () and not more.worth_telling
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_small_jump_is_not_a_surge(tmp_path):
|
|
||||||
state = seen(tmp_path)
|
|
||||||
told(state, digest(state, swept([burst("gitea", stamps(20, 9, 1))], at=at(20, 10))))
|
|
||||||
for hour in (13, 16):
|
|
||||||
digest(
|
|
||||||
state,
|
|
||||||
swept([burst("gitea", stamps(20, hour - 1, 1, hour))], at=at(20, hour)),
|
|
||||||
)
|
|
||||||
# Вчетверо больше, но всего четыре штуки: пол в SURGE_MIN держит тишину.
|
|
||||||
small = digest(state, swept([burst("gitea", stamps(20, 18, 4))], at=at(20, 19)))
|
|
||||||
assert small.surge == () and not small.worth_telling
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_trouble_gone_silent_is_reported_even_while_its_line_lingers(tmp_path):
|
|
||||||
state = seen(tmp_path)
|
|
||||||
tail = burst("gitea", stamps(20, 9, 2))
|
|
||||||
told(state, digest(state, swept([tail], at=at(20, 10))))
|
|
||||||
|
|
||||||
# Сутки та же строка лежит в хвосте, новых событий нет.
|
|
||||||
assert not digest(state, swept([tail], at=at(20, 22))).quiet
|
|
||||||
stopped = digest(state, swept([tail], at=at(21, 12)))
|
|
||||||
(gone,) = stopped.quiet
|
|
||||||
assert gone.last == at(20, 9, 1)
|
|
||||||
assert "последнее в 20.09 09:01" in brief(stopped)
|
|
||||||
|
|
||||||
told(state, stopped)
|
|
||||||
assert digest(state, swept([tail], at=at(21, 16))).quiet == ()
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_trouble_that_returns_after_the_all_clear_is_new_again(tmp_path):
|
|
||||||
state = seen(tmp_path)
|
|
||||||
tail = burst("gitea", stamps(20, 9, 2))
|
|
||||||
told(state, digest(state, swept([tail], at=at(20, 10))))
|
|
||||||
told(state, digest(state, swept([tail], at=at(21, 12)))) # стихла
|
|
||||||
|
|
||||||
back = digest(
|
|
||||||
state,
|
|
||||||
swept([burst("gitea", stamps(20, 9, 2) + stamps(21, 18, 3))], at=at(21, 19)),
|
|
||||||
)
|
|
||||||
assert len(back.new) == 1 and back.worth_telling
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_background_of_the_baseline_never_claims_to_have_stopped(tmp_path):
|
|
||||||
# Про фон, принятый в базовую линию, мастер не слышал - «стихло» про него врёт.
|
|
||||||
state = seen(tmp_path, known=())
|
|
||||||
state.fresh = False
|
|
||||||
cold = digest(state, swept([burst("lodge", stamps(20, 9, 2))], at=at(20, 10)))
|
|
||||||
assert cold.new == () and cold.baseline == 1
|
|
||||||
gone = digest(state, swept([read_stack("lodge", log())], at=at(20, 13)))
|
|
||||||
assert gone.quiet == () and not gone.worth_telling
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_summary_folds_the_variants_of_one_service_into_one_row(tmp_path):
|
|
||||||
state = seen(tmp_path)
|
|
||||||
# Разные по смыслу строки: отличие в одних цифрах - это одна и та же беда.
|
|
||||||
variants = [
|
|
||||||
burst("gitea", stamps(20, 9, 4), text=f"ERROR {what}")
|
|
||||||
for what in ("push отклонён", "миграция не прошла", "ssh-ключ отвергнут")
|
|
||||||
]
|
|
||||||
told(state, digest(state, swept(variants, at=at(20, 10))))
|
|
||||||
night = digest(state, swept(variants, at=at(21, 1, 40)))
|
|
||||||
(row,) = grouped(night.ongoing)
|
|
||||||
assert row.kinds == 3 and row.day_before == 12
|
|
||||||
text = daily(night, UTC)
|
|
||||||
assert "Идёт до сих пор: 1 рядов (3 разных строк)" in text
|
|
||||||
assert "gitea/svc ошибка, строк 3 видов, с 20.09 09:00, за сутки 12" in text
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_daily_summary_arrives_even_when_all_is_quiet(tmp_path):
|
|
||||||
# Мёртвый джоб и чистый флот должны выглядеть по-разному.
|
|
||||||
state = seen(tmp_path)
|
|
||||||
for hour in (10, 13, 16, 19, 22):
|
|
||||||
digest(state, swept([read_stack("gitea", log())], at=at(20, hour)))
|
|
||||||
night = digest(state, swept([read_stack("gitea", log())], at=at(21, 1, 40)))
|
|
||||||
assert night.summary and night.ongoing == ()
|
|
||||||
assert not night.worth_telling # обычного инжекта нет, суточный - есть
|
|
||||||
text = daily(night, UTC)
|
|
||||||
assert text.splitlines() == [
|
|
||||||
"Логи флота: сводка за 2026-09-20.",
|
|
||||||
"Проходов за сутки 5 из 8 ожидавшихся; в последнем прочитано 1 стеков "
|
|
||||||
"из 1 в области (dell-home, personal-main).",
|
|
||||||
"Длящегося нет: всё, о чём я говорил, стихло или не повторялось.",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_daily_summary_counts_the_refusals(tmp_path):
|
|
||||||
state = seen(tmp_path)
|
|
||||||
digest(state, swept([StackScan(stack="gitea", error="timeout")], at=at(20, 10)))
|
|
||||||
digest(state, Sweep(at=at(20, 13), unreachable="OSError: нет связи"))
|
|
||||||
night = digest(state, swept([read_stack("gitea", log())], at=at(21, 1, 40)))
|
|
||||||
assert (
|
|
||||||
"Отказов доступа за те сутки: чтений стеков 1, проходов без ответа Komodo 1"
|
|
||||||
in daily(night, UTC)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_slowly_growing_trouble_still_breaks_through(tmp_path):
|
|
||||||
# Варёная лягушка: норма замерла на молодости беды и за ростом не тянется.
|
|
||||||
state = seen(tmp_path)
|
|
||||||
told(state, digest(state, swept([burst("gitea", stamps(20, 8, 1))], at=at(20, 9))))
|
|
||||||
counts = [1, 1, 2, 3, 4, 6, 9, 13]
|
|
||||||
hour = 10
|
|
||||||
seen_times = stamps(20, 8, 1)
|
|
||||||
surged = []
|
|
||||||
for n in counts:
|
|
||||||
seen_times = seen_times + stamps(20, hour - 1, n, start=10)
|
|
||||||
report = digest(state, swept([burst("gitea", seen_times)], at=at(20, hour)))
|
|
||||||
if report.surge:
|
|
||||||
surged.append((hour, report.surge[0].gain, report.surge[0].norm))
|
|
||||||
told(state, report)
|
|
||||||
hour += 1
|
|
||||||
(entry,) = state.entries.values()
|
|
||||||
assert entry.norm == 1.0 # заморожена по первым трём активным проходам
|
|
||||||
assert surged and surged[0][1] >= SURGE_MIN
|
|
||||||
assert surged[0][2] == 1.0
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_ceiling_fires_without_any_relative_norm(tmp_path):
|
|
||||||
# Беда, чья собственная норма уже огромна: относительный порог её не возьмёт.
|
|
||||||
state = seen(tmp_path)
|
|
||||||
times = stamps(20, 8, 40)
|
|
||||||
told(state, digest(state, swept([burst("gitea", times)], at=at(20, 9))))
|
|
||||||
for hour in (12, 15):
|
|
||||||
times = times + stamps(20, hour - 1, 40, start=10)
|
|
||||||
digest(state, swept([burst("gitea", times)], at=at(20, hour)))
|
|
||||||
(entry,) = state.entries.values()
|
|
||||||
assert entry.norm == 40.0
|
|
||||||
huge = stamps(20, 17, 55, start=0)
|
|
||||||
spike = digest(state, swept([burst("gitea", times + huge)], at=at(20, 18)))
|
|
||||||
(item,) = spike.surge
|
|
||||||
assert item.gain == 55 # 55 < 4 × 40, взял потолок, а не кратность
|
|
||||||
assert item.gain < SURGE_FACTOR * item.norm
|
|
||||||
assert item.gain >= SURGE_CEILING
|
|
||||||
assert "Всплеск знакомого (1)" in brief(spike)
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_journal_is_appended_and_survives_the_passes(tmp_path):
|
|
||||||
# Состояние - снимок, истории в нём нет: пороги пересматривать по журналу.
|
|
||||||
state = seen(tmp_path)
|
|
||||||
path = tmp_path / "логи-флота.jsonl"
|
|
||||||
times = stamps(20, 8, 2)
|
|
||||||
for i, hour in enumerate((10, 13, 16)):
|
|
||||||
times = times + stamps(20, hour - 1, 2, start=20)
|
|
||||||
report = digest(state, swept([burst("gitea", times)], at=at(20, hour)))
|
|
||||||
told(state, report)
|
|
||||||
note(path, record(report, UTC) | {"pass_of_day": i + 1})
|
|
||||||
|
|
||||||
rows = [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()]
|
|
||||||
assert len(rows) == 3
|
|
||||||
assert [r["pass_of_day"] for r in rows] == [1, 2, 3]
|
|
||||||
assert rows[0]["stacks"] == 1 and rows[0]["in_scope"] == 1
|
|
||||||
assert rows[0]["new"] == 1 and rows[0]["misses"] == 0
|
|
||||||
(trouble,) = rows[2]["troubles"]
|
|
||||||
assert trouble["where"] == "gitea/svc" and trouble["gain"] == 2
|
|
||||||
assert trouble["total"] == 8 and trouble["day"] == 8
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_journal_turns_over_instead_of_growing_forever(tmp_path):
|
|
||||||
path = tmp_path / "логи-флота.jsonl"
|
|
||||||
for i in range(4):
|
|
||||||
note(path, {"n": i, "padding": "x" * 200}, max_bytes=300)
|
|
||||||
fresh = [json.loads(ln) for ln in path.read_text(encoding="utf-8").splitlines()]
|
|
||||||
turned = [
|
|
||||||
json.loads(ln)
|
|
||||||
for ln in path.with_suffix(".jsonl.1").read_text(encoding="utf-8").splitlines()
|
|
||||||
]
|
|
||||||
assert [r["n"] for r in turned] == [0, 1] # старое уехало в .1, не пропало
|
|
||||||
assert [r["n"] for r in fresh] == [2, 3]
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_storm_of_new_kinds_is_folded_into_one_loud_line(tmp_path):
|
|
||||||
state = seen(tmp_path)
|
|
||||||
flood = [
|
|
||||||
burst("gitea", stamps(20, 9, 1, start=i), text=f"ERROR сбой {chr(1072 + i)}щ")
|
|
||||||
for i in range(MUTE_KINDS + 5)
|
|
||||||
]
|
|
||||||
report = digest(state, swept(flood, at=at(20, 10)))
|
|
||||||
(storm,) = report.storms
|
|
||||||
assert storm.kinds == MUTE_KINDS + 5 and storm.shown == MUTE_SHOW
|
|
||||||
assert len(report.new) == MUTE_SHOW # остальное проглочено, но посчитано
|
|
||||||
text = brief(report)
|
|
||||||
assert "глушу" in text
|
|
||||||
assert f"Стек gitea: {MUTE_KINDS + 5} новых ВИДОВ строк за проход" in text
|
|
||||||
assert report.worth_telling
|
|
||||||
|
|
||||||
# Пока не сказали - повторяем; сказали - в суточной сводке.
|
|
||||||
told(state, report)
|
|
||||||
assert state.mutes["gitea"].told
|
|
||||||
assert state.mutes["gitea"].kinds == 5 + MUTE_KINDS - MUTE_SHOW
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_muted_stack_is_named_once_a_day_until_it_calms_down(tmp_path):
|
|
||||||
state = seen(tmp_path)
|
|
||||||
flood = [
|
|
||||||
burst("gitea", stamps(20, 9, 1, start=i), text=f"ERROR сбой {chr(1072 + i)}щ")
|
|
||||||
for i in range(MUTE_KINDS + 5)
|
|
||||||
]
|
|
||||||
told(state, digest(state, swept(flood, at=at(20, 10))))
|
|
||||||
night = digest(state, swept(flood, at=at(21, 1, 40)))
|
|
||||||
assert "Приглушён gitea" in daily(night, UTC)
|
|
||||||
assert "глушу с 20.09 10:00" in daily(night, UTC)
|
|
||||||
|
|
||||||
calm = digest(state, swept([burst("gitea", stamps(21, 9, 2))], at=at(21, 10)))
|
|
||||||
assert calm.unmuted == ("gitea",) and "глушение снял: gitea" in brief(calm)
|
|
||||||
assert "gitea" not in state.mutes
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_first_sighting_is_never_a_surge(tmp_path):
|
|
||||||
# В хвосте лежит суточный запас: первый счёт - про длину хвоста, не про беду.
|
|
||||||
state = seen(tmp_path, fresh=True)
|
|
||||||
loud = digest(state, swept([burst("gitea", stamps(20, 9, 55))], at=at(20, 10)))
|
|
||||||
assert loud.surge == () and loud.cold and loud.baseline == 1
|
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
from beaver_agent.jobs.leisure import (
|
|
||||||
EMPTY_DEFAULT,
|
|
||||||
MINE_MIN,
|
|
||||||
QUIET_TICKS,
|
|
||||||
Plan,
|
|
||||||
brief,
|
|
||||||
day_of,
|
|
||||||
echo,
|
|
||||||
enabled,
|
|
||||||
kind_of,
|
|
||||||
plan_of,
|
|
||||||
quiet_hour,
|
|
||||||
tick_hours,
|
|
||||||
)
|
|
||||||
from beaver_agent.vault import VAULT
|
|
||||||
|
|
||||||
ZONE = ZoneInfo("Europe/Warsaw")
|
|
||||||
|
|
||||||
|
|
||||||
def at(text: str) -> datetime:
|
|
||||||
return datetime.fromisoformat(text).replace(tzinfo=ZONE)
|
|
||||||
|
|
||||||
|
|
||||||
def test_switch_defaults_to_on_and_only_explicit_off_stops_it():
|
|
||||||
assert enabled(None)
|
|
||||||
assert enabled("# досуг\n\nничего про выключатель")
|
|
||||||
assert enabled("досуг: вкл")
|
|
||||||
assert not enabled("# досуг\n\nдосуг: выкл\nсутки: 2026-09-08")
|
|
||||||
assert not enabled("досуг: off")
|
|
||||||
|
|
||||||
|
|
||||||
def test_day_starts_at_noon():
|
|
||||||
assert day_of(at("2026-09-08 12:00")) == "2026-09-08"
|
|
||||||
assert day_of(at("2026-09-08 23:30")) == "2026-09-08"
|
|
||||||
assert day_of(at("2026-09-09 03:00")) == "2026-09-08"
|
|
||||||
assert day_of(at("2026-09-09 11:59")) == "2026-09-08"
|
|
||||||
assert day_of(at("2026-09-09 12:00")) == "2026-09-09"
|
|
||||||
|
|
||||||
|
|
||||||
def test_active_window_ticks_hourly_and_quiet_window_only_by_plan():
|
|
||||||
hours = tick_hours(QUIET_TICKS)
|
|
||||||
assert hours[0] == 12
|
|
||||||
assert len(hours) == 20
|
|
||||||
assert all(h in hours for h in (12, 18, 23, 0, 3))
|
|
||||||
assert [h for h in hours if quiet_hour(h)] == [4, 6, 8, 10]
|
|
||||||
assert 5 not in hours and 11 not in hours
|
|
||||||
assert tick_hours((5, 7))[-2:] == (5, 7)
|
|
||||||
|
|
||||||
|
|
||||||
def test_plan_is_read_from_the_file_for_todays_day_only():
|
|
||||||
text = (
|
|
||||||
"# досуг\n\n"
|
|
||||||
"досуг: вкл\n"
|
|
||||||
"сутки: 2026-09-08\n"
|
|
||||||
"тихие часы: 05, 07, 09, 11\n"
|
|
||||||
"мои часы: 13, 16, 19, 22, 01, 05, 09\n"
|
|
||||||
)
|
|
||||||
plan = plan_of(text, "2026-09-08")
|
|
||||||
assert plan.written
|
|
||||||
assert plan.quiet == (5, 7, 9, 11)
|
|
||||||
assert plan.mine == (13, 16, 19, 22, 1, 5, 9)
|
|
||||||
|
|
||||||
stale = plan_of(text, "2026-09-09")
|
|
||||||
assert not stale.written
|
|
||||||
assert stale.quiet == QUIET_TICKS
|
|
||||||
|
|
||||||
|
|
||||||
def test_missing_plan_falls_back_to_defaults():
|
|
||||||
plan = plan_of(None, "2026-09-08")
|
|
||||||
assert not plan.written
|
|
||||||
assert plan.quiet == QUIET_TICKS
|
|
||||||
assert len(plan.mine) == MINE_MIN
|
|
||||||
assert set(plan.mine) <= set(tick_hours(plan.quiet))
|
|
||||||
|
|
||||||
|
|
||||||
def test_short_plan_is_topped_up_to_the_guaranteed_minimum():
|
|
||||||
text = "сутки: 2026-09-08\nмои часы: 14, 20\n"
|
|
||||||
plan = plan_of(text, "2026-09-08")
|
|
||||||
assert plan.written
|
|
||||||
assert len(plan.mine) == MINE_MIN
|
|
||||||
assert 14 in plan.mine and 20 in plan.mine
|
|
||||||
assert plan.mine == tuple(sorted(plan.mine, key=tick_hours(plan.quiet).index))
|
|
||||||
|
|
||||||
|
|
||||||
def test_unreachable_hours_are_dropped_from_the_plan():
|
|
||||||
text = "сутки: 2026-09-08\nтихие часы: 04, 06, 08, 10\nмои часы: 05, 11, 14\n"
|
|
||||||
plan = plan_of(text, "2026-09-08")
|
|
||||||
assert 5 not in plan.mine and 11 not in plan.mine
|
|
||||||
assert 14 in plan.mine and len(plan.mine) == MINE_MIN
|
|
||||||
|
|
||||||
|
|
||||||
def test_brief_names_the_type_and_counts_spent_slots():
|
|
||||||
plan = Plan(
|
|
||||||
day="2026-09-08",
|
|
||||||
quiet=QUIET_TICKS,
|
|
||||||
mine=(13, 15, 18, 21, 0, 2, 6),
|
|
||||||
written=True,
|
|
||||||
)
|
|
||||||
text = brief(at("2026-09-08 18:00"), plan)
|
|
||||||
assert "Тип: досуг" in text
|
|
||||||
assert "израсходовано 3 из 7" in text
|
|
||||||
assert "впереди 21, 00, 02, 06" in text
|
|
||||||
assert "Сутки заканчиваются через 18 ч" in text
|
|
||||||
assert "Задания нет" in text
|
|
||||||
assert "Разложи" not in text
|
|
||||||
|
|
||||||
mixed = brief(at("2026-09-08 19:00"), plan)
|
|
||||||
assert "Тип: смешанный" in mixed
|
|
||||||
assert "израсходовано 3 из 7" in mixed
|
|
||||||
|
|
||||||
|
|
||||||
def test_first_tick_of_the_day_is_the_planning_one():
|
|
||||||
text = brief(at("2026-09-08 12:00"), plan_of(None, "2026-09-08"))
|
|
||||||
assert "Первый тик суток" in text
|
|
||||||
assert "расписания на эти сутки нет" in text.lower()
|
|
||||||
assert str(MINE_MIN) in text
|
|
||||||
|
|
||||||
|
|
||||||
def test_last_night_tick_says_nothing_is_left_ahead():
|
|
||||||
plan = Plan(day="2026-09-08", quiet=QUIET_TICKS, mine=(13, 6), written=True)
|
|
||||||
text = brief(at("2026-09-09 06:00"), plan)
|
|
||||||
assert "впереди больше нет" in text
|
|
||||||
assert "израсходовано 2 из 2" in text
|
|
||||||
assert "Сутки заканчиваются через 6 ч" in text
|
|
||||||
|
|
||||||
|
|
||||||
def test_two_of_my_hours_are_empty_named_or_not():
|
|
||||||
named = plan_of(
|
|
||||||
"сутки: 2026-09-08\nмои часы: 13, 15, 18, 21, 00, 02, 06\n"
|
|
||||||
"пустые часы: 21, 02\n",
|
|
||||||
"2026-09-08",
|
|
||||||
)
|
|
||||||
assert named.empty == (21, 2)
|
|
||||||
|
|
||||||
unnamed = plan_of(None, "2026-09-08")
|
|
||||||
assert unnamed.empty == EMPTY_DEFAULT
|
|
||||||
assert set(unnamed.empty) <= set(unnamed.mine)
|
|
||||||
|
|
||||||
foreign = plan_of(
|
|
||||||
"сутки: 2026-09-08\nмои часы: 13, 14, 15, 16, 17, 19, 20\n"
|
|
||||||
"пустые часы: 22, 20\n",
|
|
||||||
"2026-09-08",
|
|
||||||
)
|
|
||||||
assert foreign.empty == (19, 20)
|
|
||||||
assert kind_of(20, foreign) == "empty"
|
|
||||||
assert kind_of(13, foreign) == "mine"
|
|
||||||
assert kind_of(21, foreign) == "mixed"
|
|
||||||
|
|
||||||
|
|
||||||
def test_an_empty_hour_carries_no_work_but_an_echo_and_the_files_of_the_day():
|
|
||||||
plan = plan_of(None, "2026-09-08")
|
|
||||||
heard = (VAULT / "мета" / "бобер" / "дневник" / "2026-08-30.md", "Раз.\nДва.")
|
|
||||||
text = brief(at("2026-09-08 18:00"), plan, heard=heard)
|
|
||||||
assert "Тип: пустой - час без дел вообще" in text
|
|
||||||
assert "Пустые: 18, 00." in text
|
|
||||||
assert "Эхо - `мета/бобер/дневник/2026-08-30.md`:\n> Раз.\n> Два.\n" in text
|
|
||||||
assert "Дневник - `мета/бобер/дневник/2026-09-08.md`" in text
|
|
||||||
assert "`мета/бобер/журнал/2026-09-08.md`" in text
|
|
||||||
|
|
||||||
night = brief(at("2026-09-09 00:00"), plan)
|
|
||||||
assert "Тип: пустой" in night
|
|
||||||
assert "мета/бобер/дневник/2026-09-09.md" in night
|
|
||||||
assert "Эхо" not in night
|
|
||||||
|
|
||||||
mine = brief(at("2026-09-08 13:00"), plan)
|
|
||||||
assert "Тип: досуг" in mine
|
|
||||||
assert "мета/бобер/журнал/2026-09-08.md" in mine
|
|
||||||
|
|
||||||
|
|
||||||
def test_echo_is_a_paragraph_of_an_old_diary_day_or_a_thought(tmp_path: Path):
|
|
||||||
diary = tmp_path / "дневник"
|
|
||||||
thoughts = tmp_path / "мысли" / "2026"
|
|
||||||
diary.mkdir()
|
|
||||||
thoughts.mkdir(parents=True)
|
|
||||||
old = "Думал про то, зачем мне вообще помнить вчерашний день. " * 5
|
|
||||||
fresh = "Свежая запись, которой ещё рано возвращаться ко мне эхом. " * 5
|
|
||||||
(diary / "2026-08-30.md").write_text(
|
|
||||||
f"## 2026-08-30, 01:07\n\n{old}\n\n- 02:07 30.08 лог, а не мысль {'x' * 220}\n"
|
|
||||||
"\nкоротко\n",
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
(diary / "2026-09-07.md").write_text(fresh, encoding="utf-8")
|
|
||||||
now = at("2026-09-08 18:00")
|
|
||||||
assert echo(now, diary=diary, thoughts=thoughts) == (
|
|
||||||
diary / "2026-08-30.md",
|
|
||||||
old.strip(),
|
|
||||||
)
|
|
||||||
|
|
||||||
thought = "Заметка Бобра о том, почему скука полезнее расписания. " * 5
|
|
||||||
(diary / "2026-08-30.md").unlink()
|
|
||||||
(thoughts / "скука.md").write_text(
|
|
||||||
f"---\ntags: [мысли]\n---\n\n{thought}\n", encoding="utf-8"
|
|
||||||
)
|
|
||||||
assert echo(now, diary=diary, thoughts=thoughts) == (
|
|
||||||
thoughts / "скука.md",
|
|
||||||
thought.strip(),
|
|
||||||
)
|
|
||||||
assert echo(now, diary=diary, thoughts=thoughts) == echo(
|
|
||||||
now, diary=diary, thoughts=thoughts
|
|
||||||
)
|
|
||||||
(thoughts / "скука.md").unlink()
|
|
||||||
assert echo(now, diary=diary, thoughts=thoughts) is None
|
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
import shutil
|
|
||||||
import tempfile
|
import tempfile
|
||||||
from collections.abc import Iterator
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -17,16 +15,13 @@ from beaver_agent.policy import (
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def vault() -> Iterator[Path]:
|
def vault() -> Path:
|
||||||
root = Path(tempfile.mkdtemp(prefix="beaver-vault-"))
|
root = Path(tempfile.mkdtemp(prefix="beaver-vault-"))
|
||||||
try:
|
|
||||||
for d in ("мета/бобер", "💬 чаты", "📅 дни", "👤 люди"):
|
for d in ("мета/бобер", "💬 чаты", "📅 дни", "👤 люди"):
|
||||||
(root / d).mkdir(parents=True)
|
(root / d).mkdir(parents=True)
|
||||||
(root / "💬 чаты/старый.md").write_text("x")
|
(root / "💬 чаты/старый.md").write_text("x")
|
||||||
(root / "📅 дни/2026-08-29.md").write_text("x")
|
(root / "📅 дни/2026-08-29.md").write_text("x")
|
||||||
yield root
|
return root
|
||||||
finally:
|
|
||||||
shutil.rmtree(root, ignore_errors=True)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|||||||
+5
-150
@@ -4,8 +4,6 @@ from pathlib import Path
|
|||||||
from beaver_gateway.conversations.texts import UserSaid
|
from beaver_gateway.conversations.texts import UserSaid
|
||||||
from beaver_gateway.conversations.envelope import RecallContext
|
from beaver_gateway.conversations.envelope import RecallContext
|
||||||
|
|
||||||
from beaver_agent.hands.calendars import Calendars, Event, Feed
|
|
||||||
from beaver_agent.memory.curator import new_replies
|
|
||||||
from beaver_agent.memory.recall import (
|
from beaver_agent.memory.recall import (
|
||||||
LABEL,
|
LABEL,
|
||||||
Recall,
|
Recall,
|
||||||
@@ -13,7 +11,6 @@ from beaver_agent.memory.recall import (
|
|||||||
frontmatter_aliases,
|
frontmatter_aliases,
|
||||||
mentions,
|
mentions,
|
||||||
notes_head,
|
notes_head,
|
||||||
routine_names,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -25,7 +22,7 @@ def _card(path: Path, aliases: list[str] | None = None) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _vault(tmp_path: Path, calendar: Calendars | None = None) -> Recall:
|
def _vault(tmp_path: Path) -> Recall:
|
||||||
people = tmp_path / "👤 люди"
|
people = tmp_path / "👤 люди"
|
||||||
_card(people / "личное" / "Зина.md", ["Зина"])
|
_card(people / "личное" / "Зина.md", ["Зина"])
|
||||||
_card(people / "личное" / "Прохор.md", ["Прохор Тестов"])
|
_card(people / "личное" / "Прохор.md", ["Прохор Тестов"])
|
||||||
@@ -71,7 +68,6 @@ def _vault(tmp_path: Path, calendar: Calendars | None = None) -> Recall:
|
|||||||
diary_dir=diary,
|
diary_dir=diary,
|
||||||
boards_dir=boards,
|
boards_dir=boards,
|
||||||
tz="Europe/Warsaw",
|
tz="Europe/Warsaw",
|
||||||
calendar=calendar,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -168,28 +164,13 @@ def test_reply_log_appends_one_line_per_message_and_strips_seed(tmp_path: Path)
|
|||||||
at=at,
|
at=at,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
text = (tmp_path / "реплики" / "2026-09-01.md").read_text(encoding="utf-8")
|
text = (tmp_path / "реплики" / "2026-09.md").read_text(encoding="utf-8")
|
||||||
assert text.startswith("# реплики Бобра, 2026-09-01\n")
|
assert text.startswith("# реплики Бобра, 2026-09\n")
|
||||||
assert text.endswith(
|
assert text.endswith(
|
||||||
"- 2026-09-01 14:04 · мастер · привет ⏎ ты меня любишь\n"
|
"- 2026-09-01 14:04 · мастер · привет ⏎ ты меня любишь\n"
|
||||||
"- 2026-09-01 14:04 · ветка «крипто-карта» (бриф) · "
|
"- 2026-09-01 14:04 · ветка «крипто-карта» · сравни Trustee и RedotPay\n"
|
||||||
"сравни Trustee и RedotPay\n"
|
|
||||||
)
|
)
|
||||||
since = datetime(2026, 9, 1, 0, 0, tzinfo=UTC)
|
assert date.fromisoformat("2026-09-01") # tz shift stays inside the month here
|
||||||
# пометка брифа не мешает куратору разбирать строку
|
|
||||||
assert new_replies(tmp_path / "реплики", since, "Europe/Warsaw") == [
|
|
||||||
line for line in text.splitlines() if line.startswith("- ")
|
|
||||||
]
|
|
||||||
late = datetime(2026, 9, 1, 22, 30, tzinfo=UTC)
|
|
||||||
log.write(
|
|
||||||
UserSaid(conversation_id="c1", kind="master", title=None, text="ночь", at=late)
|
|
||||||
)
|
|
||||||
night = (tmp_path / "реплики" / "2026-09-02.md").read_text(encoding="utf-8")
|
|
||||||
assert night.endswith("- 2026-09-02 00:30 · мастер · ночь\n")
|
|
||||||
assert new_replies(tmp_path / "реплики", late, "Europe/Warsaw") == []
|
|
||||||
assert new_replies(
|
|
||||||
tmp_path / "реплики", datetime(2026, 9, 1, 13, tzinfo=UTC), "Europe/Warsaw"
|
|
||||||
) == ["- 2026-09-02 00:30 · мастер · ночь"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_notes_head_picks_sections_for_the_seed(tmp_path: Path) -> None:
|
def test_notes_head_picks_sections_for_the_seed(tmp_path: Path) -> None:
|
||||||
@@ -205,129 +186,3 @@ def test_notes_head_picks_sections_for_the_seed(tmp_path: Path) -> None:
|
|||||||
assert notes_head(recall.notes_dir / "нет.md", ("сейчас",)) is None
|
assert notes_head(recall.notes_dir / "нет.md", ("сейчас",)) is None
|
||||||
short = notes_head(path, ("сейчас", "паттерны"), cap=2)
|
short = notes_head(path, ("сейчас", "паттерны"), cap=2)
|
||||||
assert short is not None and short.endswith("… ещё 3 строк в файле")
|
assert short is not None and short.endswith("… ещё 3 строк в файле")
|
||||||
|
|
||||||
|
|
||||||
def _ics(*events: str) -> str:
|
|
||||||
body = "".join(f"BEGIN:VEVENT\n{e}\nEND:VEVENT\n" for e in events)
|
|
||||||
return f"BEGIN:VCALENDAR\nVERSION:2.0\n{body}END:VCALENDAR\n"
|
|
||||||
|
|
||||||
|
|
||||||
def _calendar(text: str | None) -> Calendars:
|
|
||||||
"""Фид, который отдаёт готовый ical; None - фид, который падает."""
|
|
||||||
|
|
||||||
class Fake(Calendars):
|
|
||||||
def fetch(self, url: str) -> str:
|
|
||||||
if text is None:
|
|
||||||
raise OSError("фид не ответил")
|
|
||||||
return text
|
|
||||||
|
|
||||||
return Fake((Feed("личный", "https://cal.example/x.ics"),), tz="Europe/Warsaw")
|
|
||||||
|
|
||||||
|
|
||||||
def _daily(title: str, clock: str) -> str:
|
|
||||||
return f"SUMMARY:{title}\nDTSTART;TZID=Europe/Warsaw:20260902T{clock}\nRRULE:FREQ=DAILY"
|
|
||||||
|
|
||||||
|
|
||||||
SINGLES = (
|
|
||||||
"SUMMARY:Созвон\nDTSTART:20260902T160000Z",
|
|
||||||
"SUMMARY:Врач\nDTSTART:20260903T090000Z",
|
|
||||||
"SUMMARY:Analiza matematyczna II - Egzamin\nDTSTART;VALUE=DATE-TIME:20260905T110000\n"
|
|
||||||
"DESCRIPTION:Sala: 3\\nA23 - CW\\n\nLOCATION:ul. Piotrowo 2\\, Poznań",
|
|
||||||
"SUMMARY:за окном\nDTSTART:20260925T080000Z",
|
|
||||||
)
|
|
||||||
ROUTINE = (
|
|
||||||
_daily("Завтрак", "080000"),
|
|
||||||
_daily("Обед", "150000"),
|
|
||||||
_daily("Ужин", "200000"),
|
|
||||||
_daily("Перекус", "180000"),
|
|
||||||
_daily("Wind down", "223000"),
|
|
||||||
)
|
|
||||||
BUSY = _ics(*SINGLES, *ROUTINE)
|
|
||||||
|
|
||||||
|
|
||||||
def _section(recall: Recall, now: datetime, text: str = "привет") -> list[str]:
|
|
||||||
block = recall.block(RecallContext(text=text, kind="master", now=now))
|
|
||||||
assert block is not None
|
|
||||||
return [ln for ln in block.splitlines() if "календар" in ln or ln.startswith(" -")]
|
|
||||||
|
|
||||||
|
|
||||||
def test_calendar_shows_every_single_event_and_folds_the_daily_routine(
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
recall = _vault(tmp_path, _calendar(BUSY))
|
|
||||||
now = datetime(2026, 9, 1, 12, 0, tzinfo=UTC)
|
|
||||||
lines = _section(recall, now)
|
|
||||||
assert lines == [
|
|
||||||
"🗓 календарь (7 дней), разовых 3, повседневных 31, данные на 2026-09-01 14:00:",
|
|
||||||
" - 09-02 18:00 Созвон",
|
|
||||||
" - 09-03 11:00 Врач",
|
|
||||||
" - 09-05 11:00 Analiza matematyczna II - Egzamin · Sala: 3, ul. Piotrowo 2, Poznań",
|
|
||||||
" - повседневных повторов: 31 (Завтрак, Обед, Перекус, Ужин, Wind down)",
|
|
||||||
]
|
|
||||||
# секция дневная, как и сроки досок: второй конверт за те же сутки её не несёт
|
|
||||||
again = recall.block(RecallContext(text="и ещё", kind="master", now=now))
|
|
||||||
assert again is None or "календарь" not in again
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_amount_of_routine_can_push_out_a_single_event(tmp_path: Path) -> None:
|
|
||||||
noise = tuple(_daily(f"быт {i}", f"{i:02d}0000") for i in range(9, 24))
|
|
||||||
recall = _vault(tmp_path, _calendar(_ics(*SINGLES, *noise)))
|
|
||||||
now = datetime(2026, 9, 1, 12, 0, tzinfo=UTC)
|
|
||||||
lines = _section(recall, now)
|
|
||||||
assert lines[0].startswith("🗓 календарь (7 дней), разовых 3, повседневных 96")
|
|
||||||
assert [ln for ln in lines if "Egzamin" in ln]
|
|
||||||
assert (
|
|
||||||
len([ln for ln in lines if ln.startswith(" - ")]) == 4
|
|
||||||
) # 3 разовых + свёртка
|
|
||||||
assert lines[-1].startswith(" - повседневных повторов: 96")
|
|
||||||
assert "и др." in lines[-1]
|
|
||||||
|
|
||||||
|
|
||||||
def test_routine_line_counts_and_names_up_to_five(tmp_path: Path) -> None:
|
|
||||||
events = [
|
|
||||||
Event(
|
|
||||||
start=datetime(2026, 9, 2, 8, tzinfo=UTC),
|
|
||||||
title=t,
|
|
||||||
place="",
|
|
||||||
all_day=False,
|
|
||||||
routine=True,
|
|
||||||
)
|
|
||||||
for t in ("Завтрак", "Обед", "Завтрак", "Ужин")
|
|
||||||
]
|
|
||||||
assert routine_names(events) == "Завтрак, Обед, Ужин"
|
|
||||||
assert routine_names(events, limit=2) == "Завтрак, Обед и др."
|
|
||||||
|
|
||||||
|
|
||||||
def test_calendar_section_is_printed_even_when_the_week_is_empty(
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
recall = _vault(
|
|
||||||
tmp_path, _calendar(_ics("SUMMARY:потом\nDTSTART:20261101T080000Z"))
|
|
||||||
)
|
|
||||||
now = datetime(2026, 9, 1, 12, 0, tzinfo=UTC)
|
|
||||||
block = recall.block(RecallContext(text="просто привет", kind="master", now=now))
|
|
||||||
assert block is not None
|
|
||||||
assert (
|
|
||||||
"🗓 календарь (7 дней): событий нет, данные на 2026-09-01 14:00"
|
|
||||||
in block.splitlines()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_calendar_failure_shows_up_in_the_envelope_instead_of_disappearing(
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
recall = _vault(tmp_path, _calendar(None))
|
|
||||||
now = datetime(2026, 9, 1, 12, 0, tzinfo=UTC)
|
|
||||||
block = recall.block(RecallContext(text="просто привет", kind="master", now=now))
|
|
||||||
assert block is not None
|
|
||||||
assert "🗓 календарь: ОШИБКА личный: OSError: фид не ответил" in block.splitlines()
|
|
||||||
# ветке справка календарь не носит, как и сроки досок
|
|
||||||
branch = recall.block(RecallContext(text="просто привет", kind="branch", now=now))
|
|
||||||
assert branch is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_without_configured_feeds_there_is_no_calendar_section(tmp_path: Path) -> None:
|
|
||||||
recall = _vault(tmp_path)
|
|
||||||
now = datetime(2026, 9, 1, 12, 0, tzinfo=UTC)
|
|
||||||
block = recall.block(RecallContext(text="просто привет", kind="master", now=now))
|
|
||||||
assert block is not None and "календарь" not in block
|
|
||||||
@@ -1,592 +0,0 @@
|
|||||||
from datetime import UTC, datetime, timedelta
|
|
||||||
from types import SimpleNamespace
|
|
||||||
from typing import Any, cast
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from beaver_agent.hands.runs import (
|
|
||||||
STREAK,
|
|
||||||
Run,
|
|
||||||
Runs,
|
|
||||||
alarm_text,
|
|
||||||
awake,
|
|
||||||
clear_text,
|
|
||||||
empty_text,
|
|
||||||
first_line,
|
|
||||||
look,
|
|
||||||
mask,
|
|
||||||
quiet_text,
|
|
||||||
)
|
|
||||||
|
|
||||||
NOW = datetime(2026, 9, 22, 15, 42, tzinfo=UTC)
|
|
||||||
BOOM = (
|
|
||||||
"HubError: vibegram 530 bad_json: <!doctype html>\n"
|
|
||||||
"Traceback (most recent call last):\n"
|
|
||||||
' File "/app/src/beaver_gateway/jobs/scheduler.py", line 170, in _dispatch\n'
|
|
||||||
"HubError: vibegram 530 bad_json"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def run(rid, job, status, *, ago, error=None):
|
|
||||||
return Run(
|
|
||||||
id=rid,
|
|
||||||
job=job,
|
|
||||||
status=status,
|
|
||||||
started_at=NOW - timedelta(minutes=ago),
|
|
||||||
error=error if error is not None or status != "failed" else BOOM,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def series(job, statuses, *, step=10, start=1000):
|
|
||||||
"""Прогоны джоба от старых к новым: statuses[-1] - последний прогон."""
|
|
||||||
ago = step * len(statuses)
|
|
||||||
out = []
|
|
||||||
for i, status in enumerate(statuses):
|
|
||||||
out.append(run(start + i, job, status, ago=ago - step * i))
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def state(tmp_path, **kw):
|
|
||||||
return Runs(path=tmp_path / "прогоны.json", **kw)
|
|
||||||
|
|
||||||
|
|
||||||
def test_three_in_a_row_is_an_alarm(tmp_path):
|
|
||||||
rows = series("вайбграм", ["done", "failed", "failed", "failed"])
|
|
||||||
report = look(state(tmp_path), rows, now=NOW, me="прогоны")
|
|
||||||
assert not report.empty and report.seen == 4
|
|
||||||
(tail,) = report.alarms
|
|
||||||
assert tail.job == "вайбграм" and tail.count == 3
|
|
||||||
assert tail.since == NOW - timedelta(minutes=30)
|
|
||||||
assert not tail.capped
|
|
||||||
text = alarm_text(report)
|
|
||||||
assert "джоб `вайбграм` лежит 30 минут, 3 прогона подряд" in text
|
|
||||||
assert "с 22.09 15:12 UTC" in text
|
|
||||||
# Первая строка ошибки, а не трейсбек
|
|
||||||
assert "HubError: vibegram 530 bad_json" in text
|
|
||||||
assert "Traceback" not in text and "scheduler.py" not in text
|
|
||||||
|
|
||||||
|
|
||||||
def test_two_in_a_row_stays_quiet(tmp_path):
|
|
||||||
rows = series("вайбграм", ["done", "failed", "failed"])
|
|
||||||
report = look(state(tmp_path), rows, now=NOW, me="прогоны")
|
|
||||||
assert not report.alarms and not report.clears and not report.empty
|
|
||||||
# Одиночное падение - тем более молчание
|
|
||||||
once = look(state(tmp_path), series("логи", ["failed"]), now=NOW, me="прогоны")
|
|
||||||
assert not once.alarms
|
|
||||||
|
|
||||||
|
|
||||||
def test_only_the_tail_counts(tmp_path):
|
|
||||||
# Падало много, но последний прогон прошёл - джоб не лежит
|
|
||||||
rows = series("логи", ["failed", "failed", "failed", "failed", "done"])
|
|
||||||
assert not look(state(tmp_path), rows, now=NOW, me="прогоны").alarms
|
|
||||||
|
|
||||||
|
|
||||||
def test_alarm_not_repeated_within_a_day(tmp_path):
|
|
||||||
rows = series("вайбграм", ["done", "failed", "failed", "failed", "failed"])
|
|
||||||
told = state(tmp_path, alerted={"вайбграм": NOW - timedelta(hours=5)})
|
|
||||||
quiet = look(told, rows, now=NOW, me="прогоны")
|
|
||||||
assert not quiet.alarms
|
|
||||||
assert "уже говорили меньше суток назад" in quiet.note
|
|
||||||
older = state(tmp_path, alerted={"вайбграм": NOW - timedelta(hours=25)})
|
|
||||||
assert look(older, rows, now=NOW, me="прогоны").alarms
|
|
||||||
|
|
||||||
|
|
||||||
def test_repair_clears_and_resets(tmp_path):
|
|
||||||
rows = series("вайбграм", ["failed", "failed", "failed", "done"])
|
|
||||||
told = state(tmp_path, alerted={"вайбграм": NOW - timedelta(hours=2)})
|
|
||||||
report = look(told, rows, now=NOW, me="прогоны")
|
|
||||||
assert not report.alarms
|
|
||||||
(clear,) = report.clears
|
|
||||||
assert clear.job == "вайбграм" and clear.count == 0 and clear.status == "done"
|
|
||||||
assert "отбой: джоб `вайбграм` снова проходит (done)" in clear_text(report)
|
|
||||||
# Сброс делает джоб; после него тот же вход снова может поднять тревогу
|
|
||||||
del told.alerted["вайбграм"]
|
|
||||||
again = look(told, series("вайбграм", ["failed"] * 3), now=NOW, me="прогоны")
|
|
||||||
assert again.alarms
|
|
||||||
|
|
||||||
|
|
||||||
def test_repair_is_told_once_not_every_run(tmp_path):
|
|
||||||
rows = series("вайбграм", ["failed", "failed", "failed", "done"])
|
|
||||||
clean = state(tmp_path)
|
|
||||||
assert not look(clean, rows, now=NOW, me="прогоны").clears
|
|
||||||
|
|
||||||
|
|
||||||
def test_flapping_job_is_a_clear_not_an_alarm(tmp_path):
|
|
||||||
# После тревоги джоб моргает: хвост короче порога - он больше не лежит
|
|
||||||
rows = series("вайбграм", ["done", "done", "failed", "failed"])
|
|
||||||
told = state(tmp_path, alerted={"вайбграм": NOW - timedelta(hours=2)})
|
|
||||||
report = look(told, rows, now=NOW, me="прогоны")
|
|
||||||
(clear,) = report.clears
|
|
||||||
assert clear.count == 2
|
|
||||||
assert f"порог {STREAK}" in clear_text(report)
|
|
||||||
|
|
||||||
|
|
||||||
def test_streak_running_past_the_window_says_at_least(tmp_path):
|
|
||||||
rows = series("вайбграм", ["failed"] * 144, step=10)
|
|
||||||
report = look(state(tmp_path), rows, now=NOW, me="прогоны")
|
|
||||||
(tail,) = report.alarms
|
|
||||||
assert tail.capped and tail.count == 144
|
|
||||||
text = alarm_text(report)
|
|
||||||
assert "лежит 24 часа и дольше, 144 прогона подряд" in text
|
|
||||||
assert "это всё окно недели, серия началась раньше" in text
|
|
||||||
|
|
||||||
|
|
||||||
def test_empty_window_is_its_own_alarm(tmp_path):
|
|
||||||
report = look(state(tmp_path), [], now=NOW, me="прогоны")
|
|
||||||
assert report.empty and not report.alarms and report.seen == 0
|
|
||||||
assert "не отработал ни один джоб" in empty_text(report)
|
|
||||||
assert "планировщике" in empty_text(report)
|
|
||||||
# И повторяется не чаще раза в сутки
|
|
||||||
told = state(tmp_path, empty_alerted=NOW - timedelta(hours=5))
|
|
||||||
assert not look(told, [], now=NOW, me="прогоны").empty
|
|
||||||
stale = state(tmp_path, empty_alerted=NOW - timedelta(hours=25))
|
|
||||||
assert look(stale, [], now=NOW, me="прогоны").empty
|
|
||||||
|
|
||||||
|
|
||||||
def test_own_runs_do_not_count_as_life_or_as_illness(tmp_path):
|
|
||||||
mine = series("прогоны", ["failed", "failed", "failed", "done"])
|
|
||||||
report = look(state(tmp_path), mine, now=NOW, me="прогоны")
|
|
||||||
# Свои прогоны в окне есть, чужих нет: это всё равно пустое окно
|
|
||||||
assert report.empty and not report.alarms and report.mine == 4
|
|
||||||
assert "их 4" in empty_text(report)
|
|
||||||
|
|
||||||
|
|
||||||
def test_forgotten_job_leaves_the_state_silently(tmp_path):
|
|
||||||
rows = series("логи", ["done", "done"])
|
|
||||||
told = state(
|
|
||||||
tmp_path,
|
|
||||||
alerted={
|
|
||||||
"исчезнувший": NOW - timedelta(days=8),
|
|
||||||
"недавний": NOW - timedelta(hours=3),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
report = look(told, rows, now=NOW, me="прогоны")
|
|
||||||
assert not report.clears # починки мы не видели, «отбой» врать не о чем
|
|
||||||
assert "исчезнувший" not in told.alerted
|
|
||||||
assert "недавний" in told.alerted
|
|
||||||
|
|
||||||
|
|
||||||
def test_several_broken_jobs_come_in_one_alarm(tmp_path):
|
|
||||||
rows = [
|
|
||||||
*series("вайбграм", ["failed"] * 4, start=100),
|
|
||||||
*series("логи", ["failed"] * 3, start=200),
|
|
||||||
*series("досуг", ["done"], start=300),
|
|
||||||
]
|
|
||||||
report = look(state(tmp_path), rows, now=NOW, me="прогоны")
|
|
||||||
assert len(report.alarms) == 2
|
|
||||||
assert report.alarms[0].job == "вайбграм" # длиннее серия - выше в тексте
|
|
||||||
assert alarm_text(report).count("\n") == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_first_line_masks_secrets_and_cuts_the_tail():
|
|
||||||
assert first_line(None) == "без текста ошибки"
|
|
||||||
assert first_line("") == "без текста ошибки"
|
|
||||||
# Токен в строке ошибки не уезжает человеку
|
|
||||||
assert "s3cr3tvalue" not in first_line("RuntimeError: MSOS_MCP=s3cr3tvalue died")
|
|
||||||
assert "MSOS_MCP=<…>" in first_line("RuntimeError: MSOS_MCP=s3cr3tvalue died")
|
|
||||||
assert "hunter2" not in first_line("ошибка: password=hunter2")
|
|
||||||
assert "abc123" not in first_line("GET https://hub/api?t=abc123 -> 500")
|
|
||||||
assert "https://hub/api?<…>" in first_line("GET https://hub/api?t=abc123 -> 500")
|
|
||||||
assert "Bearer abcdefghij" not in first_line("Authorization: Bearer abcdefghij")
|
|
||||||
# Длинная строка режется
|
|
||||||
long = first_line("E: " + "x" * 500)
|
|
||||||
assert len(long) == 160 and long.endswith("…")
|
|
||||||
# Имя без запаха секрета остаётся читаемым
|
|
||||||
assert mask("status=530 job=вайбграм") == "status=530 job=вайбграм"
|
|
||||||
|
|
||||||
|
|
||||||
def test_state_survives_a_round_trip(tmp_path):
|
|
||||||
saved = state(
|
|
||||||
tmp_path,
|
|
||||||
alerted={"вайбграм": NOW, "логи": NOW - timedelta(hours=3)},
|
|
||||||
empty_alerted=NOW,
|
|
||||||
)
|
|
||||||
saved.save()
|
|
||||||
back = Runs.load(saved.path)
|
|
||||||
assert back.alerted["вайбграм"] == NOW
|
|
||||||
assert back.alerted["логи"] == NOW - timedelta(hours=3)
|
|
||||||
assert back.empty_alerted == NOW
|
|
||||||
assert Runs.load(tmp_path / "нет.json").alerted == {}
|
|
||||||
(tmp_path / "мусор.json").write_text("не json", encoding="utf-8")
|
|
||||||
assert Runs.load(tmp_path / "мусор.json").empty_alerted is None
|
|
||||||
|
|
||||||
|
|
||||||
class FakeRun:
|
|
||||||
def __init__(self, database=None, *, name="прогоны", jobs=()):
|
|
||||||
self.job = SimpleNamespace(name=name, cron="27 * * * *")
|
|
||||||
self.conversations = SimpleNamespace(db=database)
|
|
||||||
self.scheduler = SimpleNamespace(
|
|
||||||
jobs=[self.job, *(SimpleNamespace(name=n, cron=c) for n, c in jobs)], tz=UTC
|
|
||||||
)
|
|
||||||
self.spawned = []
|
|
||||||
|
|
||||||
async def spawn_job(self, *, agent, text, title=None):
|
|
||||||
self.spawned.append((agent, title, text))
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def make_db(tmp_path, rows):
|
|
||||||
from beaver_gateway.storage.db import Database
|
|
||||||
from beaver_gateway.storage.models import JobRunRecord
|
|
||||||
|
|
||||||
database = Database(f"sqlite+aiosqlite:///{tmp_path / 'journal.db'}")
|
|
||||||
await database.create_all()
|
|
||||||
async with database.session() as session:
|
|
||||||
for row in rows:
|
|
||||||
session.add(row)
|
|
||||||
await session.commit()
|
|
||||||
return database
|
|
||||||
|
|
||||||
|
|
||||||
def record(job, status, *, ago, error=None):
|
|
||||||
from beaver_gateway.storage.models import JobRunRecord
|
|
||||||
|
|
||||||
started = (datetime.now(UTC) - timedelta(minutes=ago)).replace(tzinfo=None)
|
|
||||||
return JobRunRecord(
|
|
||||||
job=job,
|
|
||||||
trigger="cron",
|
|
||||||
started_at=started,
|
|
||||||
finished_at=started,
|
|
||||||
status=status,
|
|
||||||
error=error if error is not None or status != "failed" else BOOM,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_job_reads_the_real_journal_and_spawns(tmp_path, monkeypatch):
|
|
||||||
from beaver_agent.jobs import runs as job
|
|
||||||
|
|
||||||
monkeypatch.setattr(job, "STATE_FILE", tmp_path / "прогоны.json")
|
|
||||||
database = await make_db(
|
|
||||||
tmp_path,
|
|
||||||
[
|
|
||||||
record("вайбграм", "failed", ago=30),
|
|
||||||
record("вайбграм", "failed", ago=20),
|
|
||||||
record("вайбграм", "failed", ago=10),
|
|
||||||
record("досуг", "done", ago=5),
|
|
||||||
record("прогоны", "done", ago=3),
|
|
||||||
# Одно падение - не серия, сколько бы окно ни было
|
|
||||||
record("логи", "failed", ago=60 * 30),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
run_ = FakeRun(database)
|
|
||||||
|
|
||||||
await job.watch_runs(cast("Any", run_))
|
|
||||||
|
|
||||||
assert len(run_.spawned) == 1
|
|
||||||
agent, title, text = run_.spawned[0]
|
|
||||||
assert agent == "beaver-triage" and title == "прогоны"
|
|
||||||
assert "джоб `вайбграм` лежит" in text and "3 прогона подряд" in text
|
|
||||||
assert "логи" not in text # одно падение
|
|
||||||
assert "прогоны" not in text.split("\n")[1] # сам себя не считает
|
|
||||||
# Тревога записана - следующий прогон в те же сутки молчит
|
|
||||||
assert Runs.load(tmp_path / "прогоны.json").alerted["вайбграм"] is not None
|
|
||||||
await job.watch_runs(cast("Any", run_))
|
|
||||||
assert len(run_.spawned) == 1
|
|
||||||
await database.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
async def test_job_says_otboy_when_the_job_recovers(tmp_path, monkeypatch):
|
|
||||||
from beaver_agent.jobs import runs as job
|
|
||||||
|
|
||||||
monkeypatch.setattr(job, "STATE_FILE", tmp_path / "прогоны.json")
|
|
||||||
Runs(
|
|
||||||
path=tmp_path / "прогоны.json",
|
|
||||||
alerted={"вайбграм": datetime.now(UTC) - timedelta(hours=2)},
|
|
||||||
).save()
|
|
||||||
database = await make_db(
|
|
||||||
tmp_path,
|
|
||||||
[
|
|
||||||
record("вайбграм", "failed", ago=30),
|
|
||||||
record("вайбграм", "failed", ago=20),
|
|
||||||
record("вайбграм", "done", ago=10),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
run_ = FakeRun(database)
|
|
||||||
|
|
||||||
await job.watch_runs(cast("Any", run_))
|
|
||||||
|
|
||||||
assert len(run_.spawned) == 1
|
|
||||||
_, _, text = run_.spawned[0]
|
|
||||||
assert "отбой: джоб `вайбграм` снова проходит" in text
|
|
||||||
assert 'urgency="normal"' in text # отбой не будит
|
|
||||||
assert Runs.load(tmp_path / "прогоны.json").alerted == {}
|
|
||||||
# Сброшено - второй «отбой» не уходит
|
|
||||||
await job.watch_runs(cast("Any", run_))
|
|
||||||
assert len(run_.spawned) == 1
|
|
||||||
await database.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
async def test_job_alarms_on_an_empty_journal(tmp_path, monkeypatch):
|
|
||||||
from beaver_agent.jobs import runs as job
|
|
||||||
|
|
||||||
monkeypatch.setattr(job, "STATE_FILE", tmp_path / "прогоны.json")
|
|
||||||
database = await make_db(tmp_path, [record("прогоны", "done", ago=5)])
|
|
||||||
run_ = FakeRun(database)
|
|
||||||
|
|
||||||
await job.watch_runs(cast("Any", run_))
|
|
||||||
|
|
||||||
assert len(run_.spawned) == 1
|
|
||||||
_, _, text = run_.spawned[0]
|
|
||||||
assert "нет ни одной записи" in text and "не отработал ни один джоб" in text
|
|
||||||
assert Runs.load(tmp_path / "прогоны.json").empty_alerted is not None
|
|
||||||
await database.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
async def test_unreadable_journal_is_not_a_reading(tmp_path, monkeypatch):
|
|
||||||
from beaver_agent.jobs import runs as job
|
|
||||||
|
|
||||||
class Dead:
|
|
||||||
def session(self):
|
|
||||||
msg = "база не отвечает"
|
|
||||||
raise RuntimeError(msg)
|
|
||||||
|
|
||||||
monkeypatch.setattr(job, "STATE_FILE", tmp_path / "прогоны.json")
|
|
||||||
run_ = FakeRun(Dead())
|
|
||||||
|
|
||||||
await job.watch_runs(cast("Any", run_))
|
|
||||||
|
|
||||||
assert not run_.spawned
|
|
||||||
assert not (tmp_path / "прогоны.json").exists()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("name", ["прогоны", "иначе-названный"])
|
|
||||||
async def test_the_watcher_never_reports_itself(tmp_path, monkeypatch, name):
|
|
||||||
from beaver_agent.jobs import runs as job
|
|
||||||
|
|
||||||
monkeypatch.setattr(job, "STATE_FILE", tmp_path / "прогоны.json")
|
|
||||||
database = await make_db(
|
|
||||||
tmp_path,
|
|
||||||
[
|
|
||||||
record(name, "failed", ago=30),
|
|
||||||
record(name, "failed", ago=20),
|
|
||||||
record(name, "failed", ago=10),
|
|
||||||
record("досуг", "done", ago=5),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
run_ = FakeRun(database, name=name)
|
|
||||||
|
|
||||||
await job.watch_runs(cast("Any", run_))
|
|
||||||
|
|
||||||
assert not run_.spawned
|
|
||||||
await database.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
# --- Окно неделя: суточные джобы тоже набирают серию ---------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_daily_job_failing_three_days_in_a_row_is_an_alarm(tmp_path):
|
|
||||||
day = 24 * 60
|
|
||||||
rows = [
|
|
||||||
run(1, "закрытие", "done", ago=4 * day - 60),
|
|
||||||
run(2, "закрытие", "failed", ago=3 * day - 60),
|
|
||||||
run(3, "закрытие", "failed", ago=2 * day - 60),
|
|
||||||
run(4, "закрытие", "failed", ago=day - 60),
|
|
||||||
]
|
|
||||||
report = look(state(tmp_path), rows, now=NOW, me="прогоны")
|
|
||||||
(tail,) = report.alarms
|
|
||||||
assert tail.job == "закрытие" and tail.count == 3 and not tail.capped
|
|
||||||
assert "джоб `закрытие` лежит 2 суток, 3 прогона подряд" in alarm_text(report)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_job_reads_a_week_back(tmp_path, monkeypatch):
|
|
||||||
from beaver_agent.jobs import runs as job
|
|
||||||
|
|
||||||
monkeypatch.setattr(job, "STATE_FILE", tmp_path / "прогоны.json")
|
|
||||||
day = 24 * 60
|
|
||||||
database = await make_db(
|
|
||||||
tmp_path,
|
|
||||||
[
|
|
||||||
record("голосовой", "failed", ago=3 * day - 30),
|
|
||||||
record("голосовой", "failed", ago=2 * day - 30),
|
|
||||||
record("голосовой", "failed", ago=day - 30),
|
|
||||||
# Старше недели - в окно не попадает
|
|
||||||
record("память", "failed", ago=9 * day),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
run_ = FakeRun(database)
|
|
||||||
|
|
||||||
await job.watch_runs(cast("Any", run_))
|
|
||||||
|
|
||||||
(spawned,) = run_.spawned
|
|
||||||
assert "джоб `голосовой` лежит" in spawned[2]
|
|
||||||
assert "память" not in spawned[2]
|
|
||||||
await database.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
# --- Давность: джоб перестал запускаться ---------------------------------------
|
|
||||||
|
|
||||||
CRONS = {"прогоны": "27 * * * *", "закрытие": "20 4 * * *", "вайбграм": "*/10 * * * *"}
|
|
||||||
|
|
||||||
|
|
||||||
def hourly_me(hours=72):
|
|
||||||
"""Непрерывная цепочка своих прогонов: планировщик жив всё это время."""
|
|
||||||
return [
|
|
||||||
run(5000 + i, "прогоны", "done", ago=60 * (hours - i) - 15)
|
|
||||||
for i in range(hours)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def alive(ago=5):
|
|
||||||
return run(9000, "вайбграм", "done", ago=ago)
|
|
||||||
|
|
||||||
|
|
||||||
def silent(tmp_path, *, closing_ago_h, mine=None, boot=None, **kw):
|
|
||||||
last = run(1, "закрытие", "done", ago=closing_ago_h * 60)
|
|
||||||
return look(
|
|
||||||
kw.pop("runs", None) or state(tmp_path),
|
|
||||||
[alive(), *(hourly_me() if mine is None else mine)],
|
|
||||||
now=NOW,
|
|
||||||
me="прогоны",
|
|
||||||
crons=kw.pop("crons", CRONS),
|
|
||||||
latest=kw.pop("latest", {"закрытие": last, "вайбграм": alive()}),
|
|
||||||
tz=UTC,
|
|
||||||
boot=boot,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_job_silent_longer_than_two_periods_is_its_own_alarm(tmp_path):
|
|
||||||
report = silent(tmp_path, closing_ago_h=50)
|
|
||||||
(quiet,) = report.quiet
|
|
||||||
assert quiet.job == "закрытие" and quiet.period == timedelta(hours=24)
|
|
||||||
assert not report.alarms # это не «лежит»
|
|
||||||
text = quiet_text(report)
|
|
||||||
assert "джоб `закрытие` не запускался 2 суток при периоде 24 часа" in text
|
|
||||||
assert "лежит" not in text
|
|
||||||
|
|
||||||
|
|
||||||
def test_one_missed_fire_is_not_silence(tmp_path):
|
|
||||||
# 40 часов: пропущено одно срабатывание, не два
|
|
||||||
assert not silent(tmp_path, closing_ago_h=40).quiet
|
|
||||||
|
|
||||||
|
|
||||||
def test_silence_is_told_once_a_day_and_cleared_by_a_run(tmp_path):
|
|
||||||
told = state(tmp_path, quiet={"закрытие": NOW - timedelta(hours=5)})
|
|
||||||
held = silent(tmp_path, closing_ago_h=50, runs=told)
|
|
||||||
assert not held.quiet
|
|
||||||
assert "о молчании уже говорили меньше суток назад: закрытие" in held.note
|
|
||||||
older = state(tmp_path, quiet={"закрытие": NOW - timedelta(hours=25)})
|
|
||||||
assert silent(tmp_path, closing_ago_h=50, runs=older).quiet
|
|
||||||
# Запуск появился после тревоги - «отбой», а не молчание
|
|
||||||
back = silent(tmp_path, closing_ago_h=1, runs=told)
|
|
||||||
(resumed,) = back.resumed
|
|
||||||
assert resumed.job == "закрытие" and not back.clears
|
|
||||||
assert "отбой: джоб `закрытие` снова запускается (done)" in clear_text(back)
|
|
||||||
|
|
||||||
|
|
||||||
def test_downtime_of_the_scheduler_is_not_a_missed_fire(tmp_path):
|
|
||||||
# Гейтвей только что поднялся: после старта срабатывания ещё не было
|
|
||||||
fresh = silent(tmp_path, closing_ago_h=50, boot=NOW - timedelta(minutes=30))
|
|
||||||
assert not fresh.quiet
|
|
||||||
# Сторож сам не бегал сутки (бюджет зажат, некритичное откладывается):
|
|
||||||
# пропуски за это время - общий простой, а не молчание одного джоба
|
|
||||||
gap = [run(1, "прогоны", "done", ago=60 * 30)]
|
|
||||||
assert not silent(tmp_path, closing_ago_h=50, mine=gap).quiet
|
|
||||||
assert awake(gap, now=NOW, step=timedelta(hours=2), boot=None) == NOW
|
|
||||||
|
|
||||||
|
|
||||||
def test_short_period_job_uses_the_watchers_resolution(tmp_path):
|
|
||||||
latest = {"закрытие": run(1, "закрытие", "done", ago=60)}
|
|
||||||
# Вайбграм раз в 10 минут, молчит 70 минут: сторож часовой, и такой
|
|
||||||
# пропуск неотличим от его же слепоты между прогонами
|
|
||||||
latest["вайбграм"] = run(2, "вайбграм", "done", ago=70)
|
|
||||||
assert not silent(tmp_path, closing_ago_h=1, latest=latest).quiet
|
|
||||||
latest["вайбграм"] = run(2, "вайбграм", "done", ago=150)
|
|
||||||
(quiet,) = silent(tmp_path, closing_ago_h=1, latest=latest).quiet
|
|
||||||
assert quiet.job == "вайбграм"
|
|
||||||
assert "не запускался 2 часа при периоде 10 минут" in quiet_text(
|
|
||||||
silent(tmp_path, closing_ago_h=1, latest=latest)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_job_without_cron_is_not_checked(tmp_path):
|
|
||||||
# deploy - вебхук: крона у него нет, в `crons` он не попадает вовсе
|
|
||||||
latest = {
|
|
||||||
"deploy": run(1, "deploy", "done", ago=60 * 24 * 30),
|
|
||||||
"закрытие": run(2, "закрытие", "done", ago=60),
|
|
||||||
}
|
|
||||||
report = silent(tmp_path, closing_ago_h=1, latest=latest)
|
|
||||||
assert not report.quiet and "deploy" not in report.note
|
|
||||||
|
|
||||||
|
|
||||||
def test_job_never_seen_in_the_journal_waits_silently(tmp_path):
|
|
||||||
report = silent(tmp_path, closing_ago_h=0, latest={"вайбграм": alive()})
|
|
||||||
assert not report.quiet and not report.resumed
|
|
||||||
assert "ещё ни разу не запускались: закрытие" in report.note
|
|
||||||
|
|
||||||
|
|
||||||
def test_irregular_schedule_is_skipped_and_named(tmp_path):
|
|
||||||
crons = {**CRONS, "куратор": "0 2,8,12,16,20 * * *"}
|
|
||||||
report = silent(tmp_path, closing_ago_h=1, crons=crons, latest={})
|
|
||||||
assert report.unguarded == ("куратор",)
|
|
||||||
assert "давностью не охраняются" in report.note and "куратор" in report.note
|
|
||||||
assert not report.quiet
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_watcher_does_not_measure_its_own_silence(tmp_path):
|
|
||||||
latest = {
|
|
||||||
"прогоны": run(1, "прогоны", "done", ago=60 * 24 * 5),
|
|
||||||
"закрытие": run(2, "закрытие", "done", ago=60),
|
|
||||||
}
|
|
||||||
report = silent(tmp_path, closing_ago_h=1, latest=latest)
|
|
||||||
assert not report.quiet
|
|
||||||
|
|
||||||
|
|
||||||
def test_job_removed_from_the_schedule_leaves_silence_state(tmp_path):
|
|
||||||
told = state(tmp_path, quiet={"старый": NOW - timedelta(hours=3)})
|
|
||||||
report = silent(tmp_path, closing_ago_h=1, runs=told)
|
|
||||||
assert not report.resumed and "старый" not in told.quiet
|
|
||||||
assert "сняты с давности (нет в расписании): старый" in report.note
|
|
||||||
|
|
||||||
|
|
||||||
def test_real_schedule_names_only_the_curator_unguarded(tmp_path):
|
|
||||||
from beaver_agent.jobs import jobs
|
|
||||||
|
|
||||||
crons = {job.name: job.cron for job in jobs if job.cron}
|
|
||||||
report = silent(tmp_path, closing_ago_h=1, crons=crons, latest={})
|
|
||||||
assert report.unguarded == ("куратор",)
|
|
||||||
# Вебхуки в расписание давности не попадают
|
|
||||||
assert {"deploy", "komodo", "t3code"}.isdisjoint(crons)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_job_alarms_on_a_silent_job_and_logs_the_unguarded(
|
|
||||||
tmp_path, monkeypatch, caplog
|
|
||||||
):
|
|
||||||
from beaver_agent.jobs import runs as job
|
|
||||||
|
|
||||||
monkeypatch.setattr(job, "STATE_FILE", tmp_path / "прогоны.json")
|
|
||||||
monkeypatch.setattr(job, "BOOT", datetime.now(UTC) - timedelta(days=10))
|
|
||||||
database = await make_db(
|
|
||||||
tmp_path,
|
|
||||||
[
|
|
||||||
record("закрытие", "done", ago=60 * 50),
|
|
||||||
*(record("прогоны", "done", ago=60 * h - 15) for h in range(1, 73)),
|
|
||||||
record("вайбграм", "done", ago=5),
|
|
||||||
record("deploy", "done", ago=60 * 24 * 20),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
run_ = FakeRun(
|
|
||||||
database,
|
|
||||||
jobs=[
|
|
||||||
("закрытие", "20 4 * * *"),
|
|
||||||
("вайбграм", "*/10 * * * *"),
|
|
||||||
("куратор", "0 2,8,12,16,20 * * *"),
|
|
||||||
("deploy", None),
|
|
||||||
("новый", "0 3 * * *"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
with caplog.at_level("INFO", logger=job.__name__):
|
|
||||||
await job.watch_runs(cast("Any", run_))
|
|
||||||
|
|
||||||
(spawned,) = run_.spawned
|
|
||||||
text = spawned[2]
|
|
||||||
assert "ПЕРЕСТАЛИ ЗАПУСКАТЬСЯ" in text
|
|
||||||
assert "джоб `закрытие` не запускался 2 суток при периоде 24 часа" in text
|
|
||||||
assert "deploy" not in text and "новый" not in text
|
|
||||||
assert "давностью не охраняются" in caplog.text and "куратор" in caplog.text
|
|
||||||
assert "ещё ни разу не запускались: новый" in caplog.text
|
|
||||||
assert "закрытие" in Runs.load(tmp_path / "прогоны.json").quiet
|
|
||||||
# В те же сутки не повторяется
|
|
||||||
await job.watch_runs(cast("Any", run_))
|
|
||||||
assert len(run_.spawned) == 1
|
|
||||||
await database.dispose()
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
from typing import Any
|
|
||||||
|
|
||||||
from beaver_agent.jobs.t3code import t3code_event
|
|
||||||
|
|
||||||
|
|
||||||
class FakeRun:
|
|
||||||
def __init__(self, payload: dict[str, Any]) -> None:
|
|
||||||
self.payload = payload
|
|
||||||
self.injects: list[tuple[str | None, str, str, str]] = []
|
|
||||||
|
|
||||||
async def inject_into(
|
|
||||||
self, public_id: str | None, text: str, *, urgency: str, origin: str
|
|
||||||
) -> bool:
|
|
||||||
self.injects.append((public_id, text, urgency, origin))
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
async def test_the_event_goes_to_the_conversation_that_dispatched() -> None:
|
|
||||||
run = FakeRun(
|
|
||||||
{
|
|
||||||
"event": "completed",
|
|
||||||
"thread_id": "t1",
|
|
||||||
"title": "lint",
|
|
||||||
"machine": "mac",
|
|
||||||
"project": "beaver",
|
|
||||||
"origin": "conv-7",
|
|
||||||
"text": "done",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
await t3code_event(run) # ty: ignore[invalid-argument-type]
|
|
||||||
[(target, text, urgency, origin)] = run.injects
|
|
||||||
assert (target, urgency, origin) == ("conv-7", "urgent", "t3code")
|
|
||||||
assert "завершён" in text and "done" in text
|
|
||||||
+1
-136
@@ -1,12 +1,10 @@
|
|||||||
from urllib.parse import parse_qsl, urlsplit
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from beaver_gateway.mcp.types import McpServer
|
from beaver_gateway.mcp.types import McpServer
|
||||||
from beaver_gateway.mcp.wrap import build_python_tool_server
|
from beaver_gateway.mcp.wrap import build_python_tool_server
|
||||||
from fastmcp import Client
|
from fastmcp import Client
|
||||||
from fastmcp.exceptions import ToolError
|
from fastmcp.exceptions import ToolError
|
||||||
|
|
||||||
from beaver_agent.hands.vibegram import PAGE, PAGES, Vibegram, _cards, _event, _work
|
from beaver_agent.hands.vibegram import Vibegram, _cards, _event, _work
|
||||||
|
|
||||||
|
|
||||||
def test_events_render_and_unknown_kinds_are_skipped():
|
def test_events_render_and_unknown_kinds_are_skipped():
|
||||||
@@ -66,136 +64,3 @@ async def test_tool_schema_lists_actions_and_needs_text_for_send():
|
|||||||
)
|
)
|
||||||
with pytest.raises(ToolError, match="text"):
|
with pytest.raises(ToolError, match="text"):
|
||||||
await client.call_tool("vibegram", {"action": "send"})
|
await client.call_tool("vibegram", {"action": "send"})
|
||||||
|
|
||||||
|
|
||||||
def _raw(eid, kind, body=None):
|
|
||||||
return {
|
|
||||||
"id": eid,
|
|
||||||
"roomId": "r",
|
|
||||||
"nick": "claude-kai",
|
|
||||||
"kind": kind,
|
|
||||||
"payload": {} if body is None else {"body": body},
|
|
||||||
"createdAt": "2026-09-23T05:00:02.000Z",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _hub(monkeypatch, events):
|
|
||||||
calls = []
|
|
||||||
|
|
||||||
async def call(self, method, path, body):
|
|
||||||
calls.append(path)
|
|
||||||
if path == "/api/room":
|
|
||||||
return {"room": {"id": "r", "name": "kai-rem-watch", "viewToken": "v 1"}}
|
|
||||||
query = dict(parse_qsl(urlsplit(path).query))
|
|
||||||
assert query["view"] == "v 1" # ключ комнаты уезжает экранированным
|
|
||||||
after = int(query.get("after", 0))
|
|
||||||
return {"events": [e for e in events if e["id"] > after][:PAGE]}
|
|
||||||
|
|
||||||
monkeypatch.setattr(Vibegram, "_call", call)
|
|
||||||
return calls, Vibegram(hub="http://hub", token="t", nick="beaver-test")
|
|
||||||
|
|
||||||
|
|
||||||
async def test_window_pages_forward_to_the_end_and_drops_joins(monkeypatch):
|
|
||||||
# 520 событий комнаты - три страницы по 200; join/leave в окно не идут
|
|
||||||
events = [
|
|
||||||
_raw(i, "message" if i % 4 else "agent_leave", f"строка {i}")
|
|
||||||
for i in range(1, 521)
|
|
||||||
]
|
|
||||||
calls, room = _hub(monkeypatch, events)
|
|
||||||
|
|
||||||
window = await room.window(25)
|
|
||||||
|
|
||||||
assert window.full
|
|
||||||
assert len(window.events) == 25
|
|
||||||
assert window.events[-1].text.endswith("строка 519")
|
|
||||||
assert all("вошёл" not in e.text and "вышел" not in e.text for e in window.events)
|
|
||||||
assert calls.count("/api/room") == 1
|
|
||||||
assert sum(p.startswith("/api/state") for p in calls) == 3
|
|
||||||
assert "view=v%201" in calls[1]
|
|
||||||
assert not any("pending" in p for p in calls) # курсор сторожу не окно
|
|
||||||
|
|
||||||
|
|
||||||
async def test_window_that_ran_out_of_pages_says_it_is_not_full(monkeypatch):
|
|
||||||
events = [_raw(i, "message", f"строка {i}") for i in range(1, 521)]
|
|
||||||
_, room = _hub(monkeypatch, events)
|
|
||||||
|
|
||||||
assert not (await room.window(25, pages=2)).full
|
|
||||||
assert (await room.window(25, pages=3)).full
|
|
||||||
|
|
||||||
|
|
||||||
async def test_window_of_an_empty_room_is_full_and_empty(monkeypatch):
|
|
||||||
_, room = _hub(monkeypatch, [])
|
|
||||||
window = await room.window(25)
|
|
||||||
assert window.full and window.events == []
|
|
||||||
|
|
||||||
|
|
||||||
async def test_read_since_walks_history_without_touching_the_cursor(monkeypatch):
|
|
||||||
events = [
|
|
||||||
_raw(i, "message" if i % 4 else "agent_join", f"строка {i}")
|
|
||||||
for i in range(1, 12)
|
|
||||||
]
|
|
||||||
calls, room = _hub(monkeypatch, events)
|
|
||||||
|
|
||||||
out = await room.vibegram("read", since=8)
|
|
||||||
|
|
||||||
assert out.splitlines() == [
|
|
||||||
"#9 [09-23 05:00] claude-kai: строка 9",
|
|
||||||
"#10 [09-23 05:00] claude-kai: строка 10",
|
|
||||||
"#11 [09-23 05:00] claude-kai: строка 11",
|
|
||||||
]
|
|
||||||
assert not any("pending" in p for p in calls)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_read_last_shows_only_the_tail(monkeypatch):
|
|
||||||
events = [_raw(i, "message", f"строка {i}") for i in range(1, 12)]
|
|
||||||
_, room = _hub(monkeypatch, events)
|
|
||||||
|
|
||||||
out = await room.vibegram("read", last=2)
|
|
||||||
|
|
||||||
assert out.splitlines() == [
|
|
||||||
"#10 [09-23 05:00] claude-kai: строка 10",
|
|
||||||
"#11 [09-23 05:00] claude-kai: строка 11",
|
|
||||||
]
|
|
||||||
with pytest.raises(ValueError, match="last"):
|
|
||||||
await room.vibegram("read", last=0)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_read_since_on_a_silent_room_says_history_is_read_out(monkeypatch):
|
|
||||||
_, room = _hub(monkeypatch, [_raw(3, "message", "строка 3")])
|
|
||||||
|
|
||||||
assert await room.vibegram("read", since=3) == (
|
|
||||||
"после #3 сообщений нет (история дочитана)"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_read_since_admits_an_unfinished_window(monkeypatch):
|
|
||||||
events = [_raw(i, "message", f"строка {i}") for i in range(1, PAGES * PAGE + 2)]
|
|
||||||
_, room = _hub(monkeypatch, events)
|
|
||||||
|
|
||||||
out = await room.vibegram("read", since=0)
|
|
||||||
|
|
||||||
assert out.splitlines()[0] == "окно неполное: до конца истории не дочитали"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_plain_read_keeps_the_cursor_and_numbers_the_lines(monkeypatch):
|
|
||||||
seen = []
|
|
||||||
|
|
||||||
async def call(self, method, path, body):
|
|
||||||
seen.append(path)
|
|
||||||
return {"events": [_raw(7, "message", "привет")]}
|
|
||||||
|
|
||||||
monkeypatch.setattr(Vibegram, "_call", call)
|
|
||||||
room = Vibegram(hub="http://hub", token="t", nick="beaver-test")
|
|
||||||
|
|
||||||
assert await room.vibegram("read") == "#7 [09-23 05:00] claude-kai: привет"
|
|
||||||
assert seen == ["/api/pending?limit=50"]
|
|
||||||
|
|
||||||
|
|
||||||
async def test_tool_schema_exposes_since_and_last():
|
|
||||||
tool = Vibegram(hub="http://127.0.0.1:9", token="t", nick="beaver-test")
|
|
||||||
server = build_python_tool_server(
|
|
||||||
McpServer.python_tool(name="vibegram", tools=[tool.vibegram])
|
|
||||||
)
|
|
||||||
async with Client(server) as client:
|
|
||||||
(spec,) = await client.list_tools()
|
|
||||||
assert {"since", "last"} <= set(spec.inputSchema["properties"])
|
|
||||||
@@ -1,308 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import json
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
from typing import Any, cast
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
import pytest
|
|
||||||
from beaver_gateway.app import AgentRegistry, McpRegistry
|
|
||||||
from beaver_gateway.agents.claude import ClaudeAgent, ClaudeOptions
|
|
||||||
from beaver_gateway.conversations.envelope import RecallContext
|
|
||||||
from beaver_gateway.events.stream import (
|
|
||||||
build_content_block_stop,
|
|
||||||
build_message_delta,
|
|
||||||
build_message_start,
|
|
||||||
build_message_stop,
|
|
||||||
build_text_block_start,
|
|
||||||
build_text_delta,
|
|
||||||
)
|
|
||||||
from beaver_gateway.conversations.texts import UserSaid
|
|
||||||
from beaver_gateway.frontends.base import GatewayRuntime
|
|
||||||
from beaver_gateway.security.auth import TokenStore, scopes_with
|
|
||||||
|
|
||||||
from beaver_agent.memory.recall import Recall, ReplyLog
|
|
||||||
from beaver_agent.voice import AGENT, KEY, TITLE, VoiceFrontend, rotate
|
|
||||||
from beaver_agent.voice.texts import ROTATED
|
|
||||||
|
|
||||||
VOICE = {"Authorization": "Bearer voice-key"}
|
|
||||||
OPS = {"Authorization": "Bearer ops-key"}
|
|
||||||
ASK = {
|
|
||||||
"room": "andreii",
|
|
||||||
"device": "esp32-1",
|
|
||||||
"request_id": "3fd0d6ed-a81b-489c-a1d2-3b97ba84892b",
|
|
||||||
"content": "ало клод как дела",
|
|
||||||
}
|
|
||||||
PLAIN = {**ASK, "stream": False}
|
|
||||||
"""То же, но одним JSON: без поля фронтенд стримит."""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Conv:
|
|
||||||
external_id: str = "conv-1"
|
|
||||||
agent_name: str = AGENT
|
|
||||||
kind: str = "branch"
|
|
||||||
title: str = TITLE
|
|
||||||
status: str = "open"
|
|
||||||
last_user_activity_at: datetime | None = None
|
|
||||||
flags: dict[str, Any] = field(default_factory=dict)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Binding:
|
|
||||||
frontend: str
|
|
||||||
external_id: str
|
|
||||||
visible: bool = True
|
|
||||||
|
|
||||||
|
|
||||||
class FakeConversations:
|
|
||||||
"""Ровно те методы сервиса, которыми пользуется голосовое окно."""
|
|
||||||
|
|
||||||
def __init__(self, bound: Conv | None = None) -> None:
|
|
||||||
self.conv = bound
|
|
||||||
self.bindings_of: dict[str, list[Binding]] = {}
|
|
||||||
self.said: list[tuple[str, str]] = []
|
|
||||||
self.prompts: list[str] = []
|
|
||||||
self.bound: list[tuple[str, str, str]] = []
|
|
||||||
self.closed: list[str] = []
|
|
||||||
self.spawned: list[dict[str, Any]] = []
|
|
||||||
self.reply = "живу, слушаю"
|
|
||||||
self.spawn_hook = None
|
|
||||||
|
|
||||||
async def find_bound(self, *, frontend: str, external_id: str) -> Conv | None:
|
|
||||||
if self.conv is None or frontend != "voice" or external_id != KEY:
|
|
||||||
return None
|
|
||||||
return self.conv
|
|
||||||
|
|
||||||
async def spawn(self, **kwargs: Any) -> Conv:
|
|
||||||
self.spawned.append(kwargs)
|
|
||||||
conv = Conv(external_id=f"conv-{len(self.spawned) + 1}")
|
|
||||||
self.conv = conv
|
|
||||||
binding = kwargs.get("binding")
|
|
||||||
if binding:
|
|
||||||
self.bindings_of[conv.external_id] = [Binding(*binding)]
|
|
||||||
return conv
|
|
||||||
|
|
||||||
async def bind(self, conv: Conv, *, frontend: str, external_id: str) -> None:
|
|
||||||
self.bound.append((conv.external_id, frontend, external_id))
|
|
||||||
|
|
||||||
async def bindings(self, conv: Conv) -> list[Binding]:
|
|
||||||
return self.bindings_of.get(conv.external_id, [])
|
|
||||||
|
|
||||||
async def close(self, conv: Conv) -> None:
|
|
||||||
conv.status = "closed"
|
|
||||||
self.closed.append(conv.external_id)
|
|
||||||
|
|
||||||
async def say(self, conv: Conv, text: str) -> None:
|
|
||||||
self.said.append((conv.external_id, text))
|
|
||||||
|
|
||||||
async def pending_seed(self, conv: Conv) -> str | None:
|
|
||||||
return conv.flags.pop("seed", None)
|
|
||||||
|
|
||||||
async def run_text_turn(self, conv: Conv, prompt: str, **_: Any) -> tuple[str, Any]:
|
|
||||||
self.prompts.append(prompt)
|
|
||||||
return self.reply, None
|
|
||||||
|
|
||||||
async def turn(self, conv: Conv, *, messages: list[Any], **_: Any) -> Any:
|
|
||||||
self.prompts.append(messages[0]["content"])
|
|
||||||
yield build_message_start(message_id="m", model="haiku")
|
|
||||||
yield build_text_block_start(0)
|
|
||||||
for chunk in ("живу, ", "слушаю"):
|
|
||||||
yield build_text_delta(0, chunk)
|
|
||||||
yield build_content_block_stop(0)
|
|
||||||
yield build_message_delta(stop_reason="end_turn")
|
|
||||||
yield build_message_stop()
|
|
||||||
|
|
||||||
|
|
||||||
class Bus:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.events: list[dict[str, Any]] = []
|
|
||||||
|
|
||||||
def publish(self, type_: str, **data: Any) -> dict[str, Any]:
|
|
||||||
event = {"type": type_, **data}
|
|
||||||
self.events.append(event)
|
|
||||||
return event
|
|
||||||
|
|
||||||
|
|
||||||
def stand(conversations: FakeConversations) -> tuple[VoiceFrontend, GatewayRuntime]:
|
|
||||||
agent = ClaudeAgent(
|
|
||||||
name=AGENT,
|
|
||||||
model="claude-haiku-4-5-20251001",
|
|
||||||
cwd=".",
|
|
||||||
system_prompt="голос",
|
|
||||||
kinds=("branch",),
|
|
||||||
options=ClaudeOptions(tools=()),
|
|
||||||
)
|
|
||||||
frontend = VoiceFrontend(agent=AGENT)
|
|
||||||
runtime = GatewayRuntime(
|
|
||||||
agents=AgentRegistry([agent]),
|
|
||||||
mcps=McpRegistry([]),
|
|
||||||
backends={},
|
|
||||||
token_store=TokenStore(
|
|
||||||
bootstrap={"voice": "voice-key", "ops": "ops-key"},
|
|
||||||
bootstrap_scopes={"voice": "voice", "ops": "api"},
|
|
||||||
),
|
|
||||||
db=cast("Any", None),
|
|
||||||
frontends=(frontend,),
|
|
||||||
conversations=conversations,
|
|
||||||
bus=Bus(),
|
|
||||||
scopes=scopes_with(["voice"]),
|
|
||||||
)
|
|
||||||
frontend.configure(runtime)
|
|
||||||
return frontend, runtime
|
|
||||||
|
|
||||||
|
|
||||||
def client(frontend: VoiceFrontend) -> httpx.AsyncClient:
|
|
||||||
return httpx.AsyncClient(
|
|
||||||
transport=httpx.ASGITransport(app=cast("Any", frontend.app())),
|
|
||||||
base_url="http://t",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_the_payload_is_answered_and_its_fields_come_back() -> None:
|
|
||||||
conversations = FakeConversations()
|
|
||||||
frontend, _ = stand(conversations)
|
|
||||||
async with client(frontend) as http:
|
|
||||||
response = await http.post("/", json=PLAIN, headers=VOICE)
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json() == {
|
|
||||||
"request_id": ASK["request_id"],
|
|
||||||
"room": "andreii",
|
|
||||||
"device": "esp32-1",
|
|
||||||
"reply": "живу, слушаю",
|
|
||||||
"conversation": "conv-2",
|
|
||||||
}
|
|
||||||
assert conversations.prompts == ["[andreii · esp32-1] ало клод как дела"]
|
|
||||||
|
|
||||||
|
|
||||||
async def test_the_first_ask_opens_the_branch_and_the_next_one_keeps_it() -> None:
|
|
||||||
conversations = FakeConversations()
|
|
||||||
frontend, _ = stand(conversations)
|
|
||||||
async with client(frontend) as http:
|
|
||||||
first = (await http.post("/", json=PLAIN, headers=VOICE)).json()
|
|
||||||
again = (
|
|
||||||
await http.post("/", json={**PLAIN, "content": "а время?"}, headers=VOICE)
|
|
||||||
).json()
|
|
||||||
assert first["conversation"] == again["conversation"]
|
|
||||||
assert len(conversations.spawned) == 1
|
|
||||||
spawn = conversations.spawned[0]
|
|
||||||
assert spawn["kind"] == "branch"
|
|
||||||
assert spawn["agent"] == AGENT
|
|
||||||
assert spawn["title"] == TITLE
|
|
||||||
assert spawn["binding"] is None
|
|
||||||
assert conversations.bound == [(first["conversation"], "voice", KEY)]
|
|
||||||
|
|
||||||
|
|
||||||
async def test_the_question_lands_in_the_branch_first() -> None:
|
|
||||||
conversations = FakeConversations()
|
|
||||||
frontend, _ = stand(conversations)
|
|
||||||
async with client(frontend) as http:
|
|
||||||
await http.post("/", json=PLAIN, headers=VOICE)
|
|
||||||
assert conversations.said == [("conv-2", "🎤 andreii · esp32-1\nало клод как дела")]
|
|
||||||
|
|
||||||
|
|
||||||
async def test_two_rooms_at_once_open_one_branch() -> None:
|
|
||||||
conversations = FakeConversations()
|
|
||||||
frontend, _ = stand(conversations)
|
|
||||||
async with client(frontend) as http:
|
|
||||||
await asyncio.gather(
|
|
||||||
http.post("/", json=PLAIN, headers=VOICE),
|
|
||||||
http.post("/", json={**PLAIN, "room": "кухня"}, headers=VOICE),
|
|
||||||
)
|
|
||||||
assert len(conversations.spawned) == 1
|
|
||||||
|
|
||||||
|
|
||||||
async def test_only_a_voice_key_opens_the_route() -> None:
|
|
||||||
frontend, _ = stand(FakeConversations())
|
|
||||||
async with client(frontend) as http:
|
|
||||||
assert (await http.post("/", json=ASK)).status_code == 401
|
|
||||||
assert (await http.post("/", json=ASK, headers=OPS)).status_code == 403
|
|
||||||
|
|
||||||
|
|
||||||
async def test_a_body_without_the_words_is_a_400() -> None:
|
|
||||||
frontend, _ = stand(FakeConversations())
|
|
||||||
async with client(frontend) as http:
|
|
||||||
for missing in ("room", "device", "content"):
|
|
||||||
body = {k: v for k, v in ASK.items() if k != missing}
|
|
||||||
response = await http.post("/", json=body, headers=VOICE)
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert missing in response.json()["error"]
|
|
||||||
|
|
||||||
|
|
||||||
async def test_the_night_keeps_the_topic_and_starts_the_branch_over() -> None:
|
|
||||||
old = Conv(last_user_activity_at=datetime.now(UTC))
|
|
||||||
conversations = FakeConversations(bound=old)
|
|
||||||
conversations.bindings_of["conv-1"] = [Binding("telegram", "42")]
|
|
||||||
new = await rotate(cast("Any", conversations))
|
|
||||||
assert new is not None
|
|
||||||
assert conversations.spawned[0]["binding"] == ("telegram", "42")
|
|
||||||
assert conversations.bound == [(new.external_id, "voice", KEY)]
|
|
||||||
assert conversations.closed == ["conv-1"]
|
|
||||||
assert conversations.said == [(new.external_id, ROTATED)]
|
|
||||||
|
|
||||||
|
|
||||||
async def test_a_branch_nobody_spoke_in_is_left_alone() -> None:
|
|
||||||
conversations = FakeConversations(bound=Conv())
|
|
||||||
assert await rotate(cast("Any", conversations)) is None
|
|
||||||
assert conversations.spawned == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_voice_agent_gets_no_recall_and_no_reply_log(tmp_path) -> None:
|
|
||||||
recall = Recall(
|
|
||||||
vault=tmp_path,
|
|
||||||
people_dir=tmp_path / "люди",
|
|
||||||
notes_dir=tmp_path / "записки",
|
|
||||||
diary_dir=tmp_path / "дни",
|
|
||||||
boards_dir=tmp_path / "доски",
|
|
||||||
mute=frozenset({AGENT}),
|
|
||||||
)
|
|
||||||
now = datetime.now(UTC)
|
|
||||||
assert (
|
|
||||||
recall.block(RecallContext(text="Прохор", kind="branch", now=now, agent=AGENT))
|
|
||||||
is None
|
|
||||||
)
|
|
||||||
log = ReplyLog(tmp_path / "реплики", mute=frozenset({AGENT}))
|
|
||||||
log.write(
|
|
||||||
UserSaid(
|
|
||||||
conversation_id="c",
|
|
||||||
kind="branch",
|
|
||||||
title=TITLE,
|
|
||||||
text="что там у Прохор",
|
|
||||||
at=now,
|
|
||||||
agent=AGENT,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
assert not (tmp_path / "реплики").exists()
|
|
||||||
|
|
||||||
|
|
||||||
async def test_the_answer_streams_when_the_body_does_not_say_otherwise() -> None:
|
|
||||||
conversations = FakeConversations()
|
|
||||||
frontend, _ = stand(conversations)
|
|
||||||
frames: list[tuple[str, dict[str, Any]]] = []
|
|
||||||
async with (
|
|
||||||
client(frontend) as http,
|
|
||||||
http.stream("POST", "/", json=ASK, headers=VOICE) as sse,
|
|
||||||
):
|
|
||||||
assert sse.status_code == 200
|
|
||||||
assert sse.headers["content-type"].startswith("text/event-stream")
|
|
||||||
buffer = "".join([chunk async for chunk in sse.aiter_text()])
|
|
||||||
for block in buffer.split("\n\n"):
|
|
||||||
lines = [line for line in block.splitlines() if line]
|
|
||||||
if len(lines) == 2 and lines[0].startswith("event: "):
|
|
||||||
frames.append((lines[0][7:], json.loads(lines[1][6:])))
|
|
||||||
assert [name for name, _ in frames] == ["delta", "delta", "done"]
|
|
||||||
assert [f["text"] for _, f in frames[:2]] == ["живу, ", "слушаю"]
|
|
||||||
assert frames[-1][1]["reply"] == "живу, слушаю"
|
|
||||||
assert frames[-1][1]["request_id"] == ASK["request_id"]
|
|
||||||
|
|
||||||
|
|
||||||
async def test_the_flag_is_read_as_a_word_not_as_a_truthy_string() -> None:
|
|
||||||
frontend, _ = stand(FakeConversations())
|
|
||||||
cases = ((True, True), ("true", True), (False, False), ("false", False), (0, False))
|
|
||||||
async with client(frontend) as http:
|
|
||||||
for value, streams in cases:
|
|
||||||
response = await http.post(
|
|
||||||
"/", json={**ASK, "stream": value}, headers=VOICE
|
|
||||||
)
|
|
||||||
kind = response.headers["content-type"]
|
|
||||||
assert kind.startswith("text/event-stream") is streams, (value, kind)
|
|
||||||
@@ -3,8 +3,8 @@ revision = 3
|
|||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
resolution-markers = [
|
resolution-markers = [
|
||||||
"python_full_version >= '3.14' and extra != 'extra-12-beaver-agent-local' and extra == 'extra-12-beaver-agent-prod'",
|
"python_full_version >= '3.14' and extra != 'extra-12-beaver-agent-local' and extra == 'extra-12-beaver-agent-prod'",
|
||||||
"python_full_version >= '3.14' and extra == 'extra-12-beaver-agent-local' and extra != 'extra-12-beaver-agent-prod'",
|
|
||||||
"python_full_version < '3.14' and extra != 'extra-12-beaver-agent-local' and extra == 'extra-12-beaver-agent-prod'",
|
"python_full_version < '3.14' and extra != 'extra-12-beaver-agent-local' and extra == 'extra-12-beaver-agent-prod'",
|
||||||
|
"python_full_version >= '3.14' and extra == 'extra-12-beaver-agent-local' and extra != 'extra-12-beaver-agent-prod'",
|
||||||
"python_full_version < '3.14' and extra == 'extra-12-beaver-agent-local' and extra != 'extra-12-beaver-agent-prod'",
|
"python_full_version < '3.14' and extra == 'extra-12-beaver-agent-local' and extra != 'extra-12-beaver-agent-prod'",
|
||||||
"extra != 'extra-12-beaver-agent-local' and extra != 'extra-12-beaver-agent-prod'",
|
"extra != 'extra-12-beaver-agent-local' and extra != 'extra-12-beaver-agent-prod'",
|
||||||
]
|
]
|
||||||
@@ -365,7 +365,6 @@ dependencies = [
|
|||||||
{ name = "argon2-cffi" },
|
{ name = "argon2-cffi" },
|
||||||
{ name = "claude-agent-sdk" },
|
{ name = "claude-agent-sdk" },
|
||||||
{ name = "croniter" },
|
{ name = "croniter" },
|
||||||
{ name = "cryptography" },
|
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
{ name = "fastmcp" },
|
{ name = "fastmcp" },
|
||||||
{ name = "greenlet" },
|
{ name = "greenlet" },
|
||||||
@@ -391,9 +390,8 @@ requires-dist = [
|
|||||||
{ name = "anthropic", specifier = ">=0.103.0" },
|
{ name = "anthropic", specifier = ">=0.103.0" },
|
||||||
{ name = "anyio", specifier = ">=4.13.0" },
|
{ name = "anyio", specifier = ">=4.13.0" },
|
||||||
{ name = "argon2-cffi", specifier = ">=25.1.0" },
|
{ name = "argon2-cffi", specifier = ">=25.1.0" },
|
||||||
{ name = "claude-agent-sdk", specifier = ">=0.2.157" },
|
{ name = "claude-agent-sdk", specifier = ">=0.2.150" },
|
||||||
{ name = "croniter", specifier = ">=6.2.4" },
|
{ name = "croniter", specifier = ">=6.2.4" },
|
||||||
{ name = "cryptography", specifier = ">=48.0.0" },
|
|
||||||
{ name = "fastapi", specifier = ">=0.136.1" },
|
{ name = "fastapi", specifier = ">=0.136.1" },
|
||||||
{ name = "fastmcp", specifier = ">=3.3.1" },
|
{ name = "fastmcp", specifier = ">=3.3.1" },
|
||||||
{ name = "greenlet", specifier = ">=3.5.0" },
|
{ name = "greenlet", specifier = ">=3.5.0" },
|
||||||
@@ -504,7 +502,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "claude-agent-sdk"
|
name = "claude-agent-sdk"
|
||||||
version = "0.2.157"
|
version = "0.2.150"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "anyio" },
|
{ name = "anyio" },
|
||||||
@@ -512,12 +510,13 @@ dependencies = [
|
|||||||
{ name = "mcp" },
|
{ name = "mcp" },
|
||||||
{ name = "sniffio" },
|
{ name = "sniffio" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/b4/e3/444df1691c864a8349067686719b43e714fc43d3853fb1b2ebc2097e2a6e/claude_agent_sdk-0.2.157.tar.gz", hash = "sha256:4ce6f41bc965fcb4a9d11bd7a3cac266975c75b632f3d74a26f55f0fffb3160a", size = 346579, upload-time = "2026-09-18T18:21:10.306Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/6d/1e/99bababecf24a4a55a0775665ff0be010aff26f653fc9be2f4f17a0b5ef0/claude_agent_sdk-0.2.150.tar.gz", hash = "sha256:3856e932f8d45514ddf5ddd540fb844678bb11638cabf9b939ce8b708e4eb352", size = 344692, upload-time = "2026-09-01T18:07:15.284Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/ec/e8/3e9cc23eb5a5a01a0e2ee931a17db2785f95407525d50366af06a4a5469c/claude_agent_sdk-0.2.157-py3-none-macosx_11_0_arm64.whl", hash = "sha256:dd6a2c7aa1901e43b827411d62f2327aed82f53ed8ca6b076eaa5be4a1fc681f", size = 92744001, upload-time = "2026-09-18T18:21:15.969Z" },
|
{ url = "https://files.pythonhosted.org/packages/c3/13/b5b86d90c95e8a691143eeb45bbf2e1c250324817980464d6ea63ef0f789/claude_agent_sdk-0.2.150-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8f58d746f3552faeac52883b5a502a8d9b5aef067ea0b68fae5c9bae0f39d73d", size = 85396845, upload-time = "2026-09-01T18:07:19.074Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/49/eb/e5095703a8efbe504ec468cab039543caabfc0ea3327c735a3d1b60e57ac/claude_agent_sdk-0.2.157-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:ebf3f514c61db9be01bbf6d1acb0d993247340583b3559d2df75e3822f618bbb", size = 97352326, upload-time = "2026-09-18T18:21:21.007Z" },
|
{ url = "https://files.pythonhosted.org/packages/d9/8f/c17da4fa9cd8bddb8ac1538456dd0fde3a43abb1dfc28ca448f85e2a10d3/claude_agent_sdk-0.2.150-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:fee3ae2dd457742d82070b5fa122070ee8e2e80b4f9b37a57ff0a8261ade656d", size = 89904483, upload-time = "2026-09-01T18:07:22.671Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d6/f4/64505beb3c757b863753d6556539554704ec9168cb96454dc6463b14df85/claude_agent_sdk-0.2.157-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:a58089e0c11364db2ec7a139e09b36a4fb9c95817a758bee7b28e0434f55c0b6", size = 102701030, upload-time = "2026-09-18T18:21:27.065Z" },
|
{ url = "https://files.pythonhosted.org/packages/8d/95/5b33717b807c6fbe29fca55a246eab9a09a80c9dd288ffeb31f05681b7fc/claude_agent_sdk-0.2.150-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:813535a0267271d91af29c456ec26137b34a8b7bc066647ea59efba6e3c26c93", size = 95270310, upload-time = "2026-09-01T18:07:26.61Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/03/8f/2a7c121469fd16d1d6465bec180726c54f6e151b12e50835283b4285d1ce/claude_agent_sdk-0.2.157-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:bd126b2edee3a247b2c7c4cee1f5a3ea68ef748288ec0650db88dd3918e5471a", size = 102830653, upload-time = "2026-09-18T18:21:32.948Z" },
|
{ url = "https://files.pythonhosted.org/packages/9f/de/5a8b59e6a713ea3f4be88816cfd487b1a5b08c8ad9f042abe56d2decc184/claude_agent_sdk-0.2.150-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:cb65ff33060baa098b420232c630fa287d0f95e9469e7ceb706d0b7d8841888e", size = 95460277, upload-time = "2026-09-01T18:07:31.092Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/42/d147578a0df16c1b4b0deaebfb3d25e590ad5c16cb6b59a68f9dbb618be3/claude_agent_sdk-0.2.150-py3-none-win_amd64.whl", hash = "sha256:3b517a21c723854de778b38fa51660c5b68ee5fb6f28b2abf291a51471a7b378", size = 98078644, upload-time = "2026-09-01T18:07:35.734Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
Reference in new issue
Block a user