fbtodo 4.30.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fbtodo/__init__.py +2271 -0
- fbtodo/alerts.py +331 -0
- fbtodo/base.py +1493 -0
- fbtodo/desktop.py +275 -0
- fbtodo/locks.py +191 -0
- fbtodo/nas.py +418 -0
- fbtodo/panes.py +1600 -0
- fbtodo/render.py +1663 -0
- fbtodo/scan.py +691 -0
- fbtodo/sources.py +372 -0
- fbtodo/tasks.py +1834 -0
- fbtodo-4.30.0.dist-info/METADATA +285 -0
- fbtodo-4.30.0.dist-info/RECORD +17 -0
- fbtodo-4.30.0.dist-info/WHEEL +5 -0
- fbtodo-4.30.0.dist-info/entry_points.txt +2 -0
- fbtodo-4.30.0.dist-info/licenses/LICENSE +21 -0
- fbtodo-4.30.0.dist-info/top_level.txt +1 -0
fbtodo/__init__.py
ADDED
|
@@ -0,0 +1,2271 @@
|
|
|
1
|
+
"""fbtodo — watch the Freebuff agent's todo list.
|
|
2
|
+
|
|
3
|
+
fbtodo live pane (default); auto-starts the watcher, exits with freebuff
|
|
4
|
+
fbtodo snap one snapshot, plain text
|
|
5
|
+
fbtodo json one snapshot, clean JSON
|
|
6
|
+
fbtodo bar "todos 3/5" — for a tmux status bar
|
|
7
|
+
fbtodo daemon start the background watcher (-f for foreground)
|
|
8
|
+
fbtodo stop stop the watcher
|
|
9
|
+
fbtodo pane-watch keep the todo panes open, for as long as freebuff runs
|
|
10
|
+
(started for you; --once for one pass)
|
|
11
|
+
fbtodo pin pin the list pane's side or size for one window
|
|
12
|
+
(--side v|h|auto, --size N, --role local|nas|both,
|
|
13
|
+
--window TARGET, --list, --clear)
|
|
14
|
+
fbtodo why why each list pane is where it is: the side and size in force,
|
|
15
|
+
the source that supplied them, the pane it sits beside
|
|
16
|
+
(--window TARGET, --json)
|
|
17
|
+
fbtodo status instance, watcher, state-file and scratch footprint
|
|
18
|
+
fbtodo ledger every step's forecast vector beside the outcome it was scored
|
|
19
|
+
against — the rows behind the scoreboard
|
|
20
|
+
(--days N, --limit N, --model M, --json)
|
|
21
|
+
fbtodo prune enforce retention now (defaults: 2000 records / 60 days /
|
|
22
|
+
1 MiB log); runs by itself about hourly either way
|
|
23
|
+
|
|
24
|
+
The watcher (`daemon`) follows one running Freebuff process: while it lives, the
|
|
25
|
+
todo state is refreshed into `fbtodo-state.json` in the state directory
|
|
26
|
+
(`$XDG_STATE_HOME/fbtodo`, i.e. `~/.local/state/fbtodo`, or `FBTODO_HOME`); when the process
|
|
27
|
+
exits, the watcher shuts itself down and removes its lock. A second process, the pane
|
|
28
|
+
keeper (`pane-watch`), exists because a pane cannot be watched by itself: it puts a
|
|
29
|
+
todo pane back when one is killed mid-session, for every local session, and exits when
|
|
30
|
+
the last freebuff does. Either pane is also put back under the pane its session is drawn
|
|
31
|
+
in — the freebuff pane for a local session, the ssh for a NAS one — since that is where
|
|
32
|
+
a glance looks for it. `--pane-seconds N` sets how often it looks (3s), `--ask-seconds
|
|
33
|
+
N` how often a waiting question is looked for (3s; see `ask-bell.py`), and 0 switches
|
|
34
|
+
either off — as does FBTODO_NO_PANE for the panes, the same switch the shell wrapper
|
|
35
|
+
honours.
|
|
36
|
+
|
|
37
|
+
Every one of those failures is quiet by construction: the keeper has no stderr anybody
|
|
38
|
+
reads, and its log records a pane that came BACK, never one that did not. So a fourth
|
|
39
|
+
watch reports the watcher itself — `--pane-bell-seconds N` (60s; 0 = never) asks
|
|
40
|
+
`pane-bell.py` whether a session has no todo pane the keeper has failed to put back, or
|
|
41
|
+
whether there is no keeper at all while one is needed, and pushes to your phone once per
|
|
42
|
+
occurrence. `fbtodo status` says what it currently thinks.
|
|
43
|
+
|
|
44
|
+
The colour pane is framed, and its palette is themeable: FBTODO_ACCENT (the title's
|
|
45
|
+
badge), FBTODO_FAINT (the frame's own ink and the right-hand metadata), FBTODO_MUTED (the
|
|
46
|
+
grey of secondary text — step durations, the goal's label, the status strip), FBTODO_TRACK
|
|
47
|
+
(the progress bar's empty cells) and FBTODO_GRADIENT_START / FBTODO_GRADIENT_END (a
|
|
48
|
+
`#rrggbb` colour, or — for the accent — a raw SGR code like `1;36`) override
|
|
49
|
+
`./.fbtodo-theme.json`, which overrides `~/.config/fbtodo/theme.json`. Layout never names
|
|
50
|
+
a colour: `_styles` resolves the palette into roles, and the drawing code asks for a role.
|
|
51
|
+
Progress is a gradient bar of `█` over a visible `░` track — an eighth block for the one
|
|
52
|
+
cell at the boundary — drawn in 24-bit colour where the terminal takes it and in the nearest
|
|
53
|
+
256-colour entry where it does not, always as a glyph in a FOREGROUND colour rather than a
|
|
54
|
+
filled background cell. The title sits on an accent badge (reverse video, so no background
|
|
55
|
+
selector is needed), a step's duration is right-aligned against the frame's right wall, the
|
|
56
|
+
status strip badges the live state and spins while a step runs, and a heading the agent
|
|
57
|
+
never wrote is not drawn at all. The plain renderer is deliberately untouched: it is the
|
|
58
|
+
machine-readable path, and the patch row below appears there too, but only when the state
|
|
59
|
+
carries it.
|
|
60
|
+
|
|
61
|
+
Above that strip is one more row when there is something to say: `PATCH` — the last
|
|
62
|
+
outcome of the CLI-patch step, with the binary's version and how long ago — and `ALERT` —
|
|
63
|
+
the last thing the phone was told. Both are read from the log the producing step already
|
|
64
|
+
writes (`~/.config/freebuff-patch-watch/watch.log` and `~/.config/freebuff-notify/phone.log`,
|
|
65
|
+
or the NAS hook's own two, read at the far end of the ssh the pane already makes), so
|
|
66
|
+
neither has to be fetched with a probe of its own; a state with neither fact renders
|
|
67
|
+
exactly as before. `fbtodo status` prints the same pair in the same words.
|
|
68
|
+
|
|
69
|
+
Three stores: the two this machine keeps, and the one a remote host keeps when you run
|
|
70
|
+
freebuff there over ssh (`docker exec -it <container> freebuff`):
|
|
71
|
+
CLI ~/.config/manicode/projects/<project>/chats/<ISO>/log.jsonl live, mid-turn
|
|
72
|
+
Desktop ~/.config/freebuff-desktop/projects/<slug>/desktop-v2.db per turn, app only
|
|
73
|
+
NAS <host>:<root>/<project>/chats/<ISO>/log.jsonl live, over ssh
|
|
74
|
+
`--source auto` prefers a CLI chat for the cwd. `-s nas` reads the remote store
|
|
75
|
+
over ssh (see --nas-*, and set them: there is no built-in host), and follows the remote
|
|
76
|
+
freebuff PROCESS rather than a local pid, so the pane lives as long as the remote
|
|
77
|
+
session does.
|
|
78
|
+
|
|
79
|
+
Every new list replaces the old one: state is never merged, and when the session
|
|
80
|
+
changes the previous list is dropped immediately instead of lingering.
|
|
81
|
+
|
|
82
|
+
Each list is headed by its big goal: the agent's own one-line `Goal:` statement, which
|
|
83
|
+
AGENTS.md asks for and the journal keeps in `fullResponse`. The request the list was
|
|
84
|
+
written for is never shown as the goal — a quote is the phrasing, not the objective — so
|
|
85
|
+
a session that skips the line says `big goal · — none stated` in the plain renderer, and
|
|
86
|
+
in the framed pane simply draws no heading row. When a newer
|
|
87
|
+
request has arrived since, a `now` line says so: a list can sit unchanged for hours while
|
|
88
|
+
the session works on.
|
|
89
|
+
|
|
90
|
+
A comment that begins `# In plain words:` explains the code under it for a reader who does
|
|
91
|
+
not program. They are asides, not part of the program: read them straight down for a tour
|
|
92
|
+
of the file, or skip every one with a search for `In plain words`.
|
|
93
|
+
"""
|
|
94
|
+
from __future__ import annotations
|
|
95
|
+
|
|
96
|
+
import argparse
|
|
97
|
+
import calendar
|
|
98
|
+
import copy
|
|
99
|
+
import glob
|
|
100
|
+
import hashlib
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
# POSIX only, and the watcher's claim is a real one because of it: see `write_lock`.
|
|
104
|
+
import fcntl
|
|
105
|
+
except ImportError: # pragma: no cover - no flock, so a claim is the record alone
|
|
106
|
+
fcntl = None
|
|
107
|
+
|
|
108
|
+
import re
|
|
109
|
+
import shlex
|
|
110
|
+
import shutil as _shutil
|
|
111
|
+
import json
|
|
112
|
+
import os
|
|
113
|
+
import signal
|
|
114
|
+
import sqlite3
|
|
115
|
+
import subprocess
|
|
116
|
+
import sys
|
|
117
|
+
import tempfile
|
|
118
|
+
import textwrap
|
|
119
|
+
import time
|
|
120
|
+
import unicodedata
|
|
121
|
+
import zlib
|
|
122
|
+
|
|
123
|
+
# Running this file AS A SCRIPT — `python3 src/fbtodo/__init__.py …`, which is what a copy
|
|
124
|
+
# carrying the package without the launcher ends up doing (`self_argv` names the launcher
|
|
125
|
+
# when there is one). Left alone it would be a module called `__main__` sitting beside a
|
|
126
|
+
# package called `fbtodo`: every file loaded twice and no relative import resolvable. So it
|
|
127
|
+
# re-enters as the package, where there is exactly one of everything, and exits there.
|
|
128
|
+
if __name__ == "__main__" and not __package__:
|
|
129
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
130
|
+
from fbtodo import main as _main
|
|
131
|
+
|
|
132
|
+
sys.exit(_main())
|
|
133
|
+
|
|
134
|
+
from .render import * # noqa: F401,F403 — the package is one namespace
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
from .panes import * # noqa: F401,F403 — the package is one namespace
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
from .sources import * # noqa: F401,F403 — the package is one namespace
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# The program, module by module: `base` is the floor (paths, settings, the generic tools), and
|
|
144
|
+
# each layer above it is imported with `*` so that everything stays reachable as `fbtodo.X` —
|
|
145
|
+
# one namespace, however many files it is written in. What each module holds is its `__all__`;
|
|
146
|
+
# the self-check reads the package statically to keep this promise honest.
|
|
147
|
+
from .base import * # noqa: F401,F403 — the package is one namespace
|
|
148
|
+
from .locks import * # noqa: F401,F403 — the package is one namespace
|
|
149
|
+
from .alerts import * # noqa: F401,F403 — the package is one namespace
|
|
150
|
+
from .scan import * # noqa: F401,F403 — the package is one namespace
|
|
151
|
+
from .desktop import * # noqa: F401,F403 — the package is one namespace
|
|
152
|
+
from .nas import * # noqa: F401,F403 — the package is one namespace
|
|
153
|
+
from .tasks import * # noqa: F401,F403 — the package is one namespace
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def list_fingerprint(state: dict) -> str:
|
|
157
|
+
"""Identity of one todo list: the session plus the *set of tasks*.
|
|
158
|
+
|
|
159
|
+
Completion flags are deliberately excluded — ticking a task off is progress
|
|
160
|
+
within a list, not a new list.
|
|
161
|
+
"""
|
|
162
|
+
tasks = [str(t.get("task", "")) for t in (state.get("todos") or [])]
|
|
163
|
+
payload = json.dumps(
|
|
164
|
+
{"s": state.get("session"), "t": tasks}, ensure_ascii=False, sort_keys=True
|
|
165
|
+
)
|
|
166
|
+
return hashlib.sha1(payload.encode("utf-8")).hexdigest()[:12]
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def finish_state(state: dict, prev: dict | None, claim_first: bool = False) -> dict:
|
|
170
|
+
"""Apply the replace-on-new-list, drop-on-new-session and drop-on-new-turn rules.
|
|
171
|
+
|
|
172
|
+
`claim_first` belongs to the watcher: only it may declare an unseen list to be
|
|
173
|
+
"list #1". A one-shot probe cannot see the list history, so it must borrow the
|
|
174
|
+
counter from the watcher (see `adopt_version`) rather than invent one.
|
|
175
|
+
"""
|
|
176
|
+
todos = state.get("todos") or []
|
|
177
|
+
# In plain words: a list that is FINISHED does not belong to the turn that comes after it.
|
|
178
|
+
# Left standing, it reads as this turn's progress — the previous project's steps, at 100%,
|
|
179
|
+
# while the agent is on something else entirely — and it stays that way until a new list
|
|
180
|
+
# happens to arrive, which can be many minutes. So a finished list is dropped the moment a
|
|
181
|
+
# newer request or a newer turn shows up. A list with work LEFT on it is never dropped:
|
|
182
|
+
# that is the agent's standing plan, and `now`/`NUDGE` is how its drift gets reported.
|
|
183
|
+
written = int(state.get("ts") or state.get("source_updated_ms") or 0)
|
|
184
|
+
turn_start = int((state.get("turn") or {}).get("start_ms") or 0)
|
|
185
|
+
after_turn = bool(turn_start and written and turn_start > written)
|
|
186
|
+
after_request = bool(state.get("now") or state.get("nudge"))
|
|
187
|
+
drop_turn = bool(
|
|
188
|
+
todos and all(t.get("completed") for t in todos) and (after_turn or after_request)
|
|
189
|
+
)
|
|
190
|
+
if drop_turn:
|
|
191
|
+
todos = []
|
|
192
|
+
state["todos"] = []
|
|
193
|
+
# What goes with the list is the objective it was written FOR — a pane still headed
|
|
194
|
+
# with the previous turn's goal is the same misreading in prose, and this turn's own
|
|
195
|
+
# heading arrives with its own list. `now` and `nudge` deliberately stay: they describe
|
|
196
|
+
# the request that replaced the list, not the list, and with nothing on screen they are
|
|
197
|
+
# the only thing left naming what this turn is about.
|
|
198
|
+
for gone in ("goal", "goal_source"):
|
|
199
|
+
state[gone] = None
|
|
200
|
+
fp = list_fingerprint(state)
|
|
201
|
+
version = int((prev or {}).get("list_version") or 0)
|
|
202
|
+
session_changed = bool(prev) and prev.get("session") != state.get("session")
|
|
203
|
+
list_changed = bool(prev) and prev.get("list_id") != fp
|
|
204
|
+
cleared = False
|
|
205
|
+
|
|
206
|
+
if session_changed:
|
|
207
|
+
# A new session owns the pane now: its list is not the previous one, and
|
|
208
|
+
# the old list must not linger while the new one is being written.
|
|
209
|
+
version = 0
|
|
210
|
+
if not todos:
|
|
211
|
+
cleared = True
|
|
212
|
+
if drop_turn:
|
|
213
|
+
# Note that this does NOT touch the counter: the list number identifies a list, and a
|
|
214
|
+
# dropped one is still the list it was. The new turn's list is what increments it.
|
|
215
|
+
cleared = True
|
|
216
|
+
if list_changed and todos:
|
|
217
|
+
version += 1
|
|
218
|
+
elif not prev and claim_first:
|
|
219
|
+
version = 1 if todos else 0
|
|
220
|
+
|
|
221
|
+
state["list_id"] = fp
|
|
222
|
+
state["list_version"] = version
|
|
223
|
+
if session_changed or prev is None:
|
|
224
|
+
state["session_changed_ms"] = state.get("probed_ms") or int(time.time() * 1000)
|
|
225
|
+
else:
|
|
226
|
+
state["session_changed_ms"] = (prev or {}).get("session_changed_ms")
|
|
227
|
+
state["cleared"] = cleared
|
|
228
|
+
# Which of the two drops it was decides the sentence the pane prints, so it is carried
|
|
229
|
+
# rather than inferred: "a new session" and "a finished turn" are different news.
|
|
230
|
+
state["cleared_turn"] = drop_turn
|
|
231
|
+
state["replaced"] = bool(list_changed and todos and not session_changed)
|
|
232
|
+
state["done"] = sum(1 for t in todos if t.get("completed"))
|
|
233
|
+
state["total"] = len(todos)
|
|
234
|
+
return state
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def finish_probe(state: dict) -> dict:
|
|
238
|
+
"""`finish_state` for a one-shot probe, which may be reading a FILE.
|
|
239
|
+
|
|
240
|
+
A probe that read a file has that file's own list identity in hand, so the file is
|
|
241
|
+
compared with itself: renumbering it would let a recorded state say `#7` and the pane
|
|
242
|
+
print `#0`. A probe that read a journal or a database has no history and passes None,
|
|
243
|
+
borrowing the counter from the watcher (`adopt_version`).
|
|
244
|
+
"""
|
|
245
|
+
if state.get("backend") == "file":
|
|
246
|
+
# The file states its own list number, so the comparison is the file with itself and
|
|
247
|
+
# nothing moves: the number it recorded is the number the pane prints, and a file that
|
|
248
|
+
# recorded none starts at the first (`1`), not at a borrowed watcher's counter.
|
|
249
|
+
recorded = state.get("list_version")
|
|
250
|
+
state["list_version"] = int(recorded) if recorded is not None else (
|
|
251
|
+
1 if state.get("todos") else 0)
|
|
252
|
+
state["list_id"] = list_fingerprint(state)
|
|
253
|
+
return finish_state(state, dict(state))
|
|
254
|
+
return adopt_version(finish_state(state, None))
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# ==================================================================== push
|
|
258
|
+
# In plain words: the generic source. A journal, a database and an ssh session are the three
|
|
259
|
+
# ways THIS machine finds a list; anything else — another agent, a script, a CI job, a hand
|
|
260
|
+
# written file — can say what the list is in one JSON object. `fbtodo push` takes one on
|
|
261
|
+
# stdin and makes it the live state, and `-s file:PATH` reads the same shape back. Neither
|
|
262
|
+
# knows what a Freebuff is.
|
|
263
|
+
PUSH_FIELDS = (
|
|
264
|
+
"session", "title", "summary", "goal", "goal_source", "now", "nudge", "todos",
|
|
265
|
+
"model", "turn_ended", "iteration", "list_id", "list_version", "ts",
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
# The ingress caps, shared by every path that turns untrusted JSON into state — `fbtodo
|
|
269
|
+
# push` on stdin and `-s file:PATH` off disk. They are limits, not truncations: a payload
|
|
270
|
+
# over one is refused with the data error (65) and the state it would have replaced stands.
|
|
271
|
+
# 1 MiB of stdin is far past any real list; 200 steps is past anything a session writes;
|
|
272
|
+
# 500 characters is past any goal or step name, and a task is a STRING — coercing a number
|
|
273
|
+
# or an object with `str()` was how a dict of junk reached a step row.
|
|
274
|
+
PUSH_MAX_BYTES = 1 << 20
|
|
275
|
+
PUSH_MAX_STEPS = 200
|
|
276
|
+
PUSH_MAX_STRING = 500
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def check_ingress(data) -> None:
|
|
280
|
+
"""Refuse a payload that breaks an ingress cap. Raises ValueError; writes nothing.
|
|
281
|
+
|
|
282
|
+
Shared by `state_from_push` and the `file:PATH` source, so the two doorways answer to
|
|
283
|
+
one contract rather than each having its own idea of "too big".
|
|
284
|
+
"""
|
|
285
|
+
if not isinstance(data, dict):
|
|
286
|
+
raise ValueError("stdin must be a JSON object, not an array or a scalar")
|
|
287
|
+
todos = data.get("todos")
|
|
288
|
+
if todos is None:
|
|
289
|
+
todos = []
|
|
290
|
+
if not isinstance(todos, list):
|
|
291
|
+
raise ValueError("todos must be a list")
|
|
292
|
+
if len(todos) > PUSH_MAX_STEPS:
|
|
293
|
+
raise ValueError(f"too many steps: {len(todos)} (limit {PUSH_MAX_STEPS})")
|
|
294
|
+
for t in todos:
|
|
295
|
+
if not isinstance(t, dict):
|
|
296
|
+
raise ValueError("every todo must be an object")
|
|
297
|
+
task = t.get("task")
|
|
298
|
+
if not isinstance(task, str):
|
|
299
|
+
raise ValueError("a todo's task must be a string")
|
|
300
|
+
if len(task) > PUSH_MAX_STRING:
|
|
301
|
+
raise ValueError(f"a todo's task is longer than {PUSH_MAX_STRING} characters")
|
|
302
|
+
if not task.strip():
|
|
303
|
+
raise ValueError("a todo with no task text")
|
|
304
|
+
done = t.get("completed")
|
|
305
|
+
# Absent (or an explicit null) means "not done"; anything present must be a real
|
|
306
|
+
# boolean. The coercion `bool(value)` counted the STRING "false" as a finished step,
|
|
307
|
+
# so a pusher that sent a string got its list inverted, not refused.
|
|
308
|
+
if done is not None and not isinstance(done, bool):
|
|
309
|
+
raise ValueError("a todo's completed must be true or false")
|
|
310
|
+
for key in PUSH_FIELDS:
|
|
311
|
+
value = data.get(key)
|
|
312
|
+
if isinstance(value, str) and len(value) > PUSH_MAX_STRING:
|
|
313
|
+
raise ValueError(f"{key} is longer than {PUSH_MAX_STRING} characters")
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def check_file_ingress(path: str) -> int | None:
|
|
317
|
+
"""The `file:PATH` doorway to `check_ingress`, refused the way `push` refuses a payload.
|
|
318
|
+
|
|
319
|
+
The file is an ingress like stdin, so it gets the same byte cap before it is parsed (a
|
|
320
|
+
1 MiB file cannot make the reader allocate the denial-of-service it was capped against)
|
|
321
|
+
and a recursive payload gets the same data error the parser's `RecursionError` would
|
|
322
|
+
otherwise turn into "no state file". A simply absent file is not an error here — that is
|
|
323
|
+
the source's own "no state file" answer — so it returns None and lets the source speak.
|
|
324
|
+
Returns an exit code to stop on, or None when the file is fine or missing.
|
|
325
|
+
"""
|
|
326
|
+
try:
|
|
327
|
+
with open(path, "rb") as fh:
|
|
328
|
+
chunk = fh.read(PUSH_MAX_BYTES + 1)
|
|
329
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
330
|
+
return None
|
|
331
|
+
except OSError as exc:
|
|
332
|
+
print(f"fbtodo: {path}: {exc}", file=sys.stderr)
|
|
333
|
+
return EX_CODES["ioerr"]
|
|
334
|
+
if len(chunk) > PUSH_MAX_BYTES:
|
|
335
|
+
print(f"fbtodo: {path}: larger than {PUSH_MAX_BYTES} bytes — refused", file=sys.stderr)
|
|
336
|
+
return EX_CODES["dataerr"]
|
|
337
|
+
try:
|
|
338
|
+
data = json.loads(chunk.decode("utf-8", "replace"))
|
|
339
|
+
except RecursionError:
|
|
340
|
+
print(f"fbtodo: {path}: too deeply nested to parse", file=sys.stderr)
|
|
341
|
+
return EX_CODES["dataerr"]
|
|
342
|
+
except ValueError as exc:
|
|
343
|
+
print(f"fbtodo: {path}: not JSON: {exc}", file=sys.stderr)
|
|
344
|
+
return EX_CODES["dataerr"]
|
|
345
|
+
try:
|
|
346
|
+
check_ingress(data)
|
|
347
|
+
except ValueError as exc:
|
|
348
|
+
print(f"fbtodo: {path}: {exc}", file=sys.stderr)
|
|
349
|
+
return EX_CODES["dataerr"]
|
|
350
|
+
return None
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def state_from_push(data: dict) -> dict:
|
|
354
|
+
"""A pushed JSON object as a state the rest of the tool can read.
|
|
355
|
+
|
|
356
|
+
Only the fields a list is MADE of are taken — the steps, the goal, the session, and the
|
|
357
|
+
identity a pusher may carry over (`list_id`/`list_version`) — and everything else is
|
|
358
|
+
stamped here: the pusher does not need to know the tool's clock fields, and a pusher that
|
|
359
|
+
sent the whole object `fbtodo json` printed is not punished for the extra keys. What it
|
|
360
|
+
WILL refuse is a payload over the ingress caps (`check_ingress`), before anything is
|
|
361
|
+
built from it.
|
|
362
|
+
"""
|
|
363
|
+
check_ingress(data)
|
|
364
|
+
todos = data.get("todos") or []
|
|
365
|
+
steps = [{"task": t["task"].strip(), "completed": bool(t.get("completed"))}
|
|
366
|
+
for t in todos]
|
|
367
|
+
now = int(time.time() * 1000)
|
|
368
|
+
st = {k: data[k] for k in PUSH_FIELDS if k in data}
|
|
369
|
+
st["todos"] = steps
|
|
370
|
+
st.update({
|
|
371
|
+
"ts": int(st.get("ts") or now),
|
|
372
|
+
"backend": "push",
|
|
373
|
+
"source": "push",
|
|
374
|
+
"status": "watching",
|
|
375
|
+
"tool_version": VERSION,
|
|
376
|
+
"heartbeat_ms": now,
|
|
377
|
+
"probed_ms": now,
|
|
378
|
+
"source_updated_ms": int(st.get("source_updated_ms") or st.get("ts") or now),
|
|
379
|
+
})
|
|
380
|
+
# The pusher's prose is filtered here like any other source's: the state file is read by
|
|
381
|
+
# `json` and `status` too, so cleaning only at render would leave them exposed.
|
|
382
|
+
return clean_observation(st)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def cmd_push(args) -> int:
|
|
386
|
+
"""Make a state JSON on stdin the live state, so anything can drive the pane.
|
|
387
|
+
|
|
388
|
+
The pushed object is normalized to the fields a list is made of and put through the same
|
|
389
|
+
`finish_state` a watched list goes through, so its counts, its session and its list number
|
|
390
|
+
are the tool's rather than the pusher's — a pusher that says `list_version: 9` gets 9 only
|
|
391
|
+
if it is the same list the state already held, and one that says nothing gets 1. `--to
|
|
392
|
+
PATH` writes that file instead of the live state (which is how the demo does not touch
|
|
393
|
+
yours), `--dry-run` writes nothing at all, `--quiet` prints nothing, and anything that is
|
|
394
|
+
not an object with a list of steps is refused with the state left alone.
|
|
395
|
+
"""
|
|
396
|
+
# Bounded read: one byte past the cap is enough to know it was broken, and reading the
|
|
397
|
+
# whole thing first is exactly the denial-of-service the cap exists to refuse.
|
|
398
|
+
stream = getattr(sys.stdin, "buffer", sys.stdin)
|
|
399
|
+
chunk = stream.read(PUSH_MAX_BYTES + 1)
|
|
400
|
+
if isinstance(chunk, bytes):
|
|
401
|
+
oversize, raw = len(chunk) > PUSH_MAX_BYTES, chunk.decode("utf-8", "replace")
|
|
402
|
+
else:
|
|
403
|
+
oversize, raw = len(chunk) > PUSH_MAX_BYTES, chunk
|
|
404
|
+
if oversize:
|
|
405
|
+
print(f"fbtodo push: stdin is larger than {PUSH_MAX_BYTES} bytes — refused",
|
|
406
|
+
file=sys.stderr)
|
|
407
|
+
return EX_CODES["dataerr"]
|
|
408
|
+
if not raw.strip():
|
|
409
|
+
print("fbtodo push: nothing on stdin — expected a state JSON (see docs/SOURCES.md)",
|
|
410
|
+
file=sys.stderr)
|
|
411
|
+
return EX_CODES["noinput"]
|
|
412
|
+
try:
|
|
413
|
+
data = json.loads(raw)
|
|
414
|
+
except RecursionError:
|
|
415
|
+
# A pathological run of `[`/`{` recurses the parser past its limit; that is a
|
|
416
|
+
# malformed payload, so it is the same data error as any other bad JSON.
|
|
417
|
+
print("fbtodo push: stdin is too deeply nested to parse", file=sys.stderr)
|
|
418
|
+
return EX_CODES["dataerr"]
|
|
419
|
+
except ValueError as exc:
|
|
420
|
+
print(f"fbtodo push: stdin is not JSON: {exc}", file=sys.stderr)
|
|
421
|
+
return EX_CODES["dataerr"]
|
|
422
|
+
if not isinstance(data, dict):
|
|
423
|
+
print("fbtodo push: stdin must be a JSON object, not an array or a scalar",
|
|
424
|
+
file=sys.stderr)
|
|
425
|
+
return EX_CODES["dataerr"]
|
|
426
|
+
try:
|
|
427
|
+
state = state_from_push(data)
|
|
428
|
+
except ValueError as exc:
|
|
429
|
+
print(f"fbtodo push: {exc}", file=sys.stderr)
|
|
430
|
+
return EX_CODES["dataerr"]
|
|
431
|
+
target = getattr(args, "push_to", None) or STATE_PATH
|
|
432
|
+
state = finish_state(state, read_json(target, None), claim_first=True)
|
|
433
|
+
if not args.dry_run:
|
|
434
|
+
atomic_write_json(target, state)
|
|
435
|
+
if not args.quiet:
|
|
436
|
+
json.dump(state, sys.stdout, ensure_ascii=False)
|
|
437
|
+
sys.stdout.write("\n")
|
|
438
|
+
return 0
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def state_matches_request(state: dict | None, args) -> bool:
|
|
442
|
+
"""Is the cached watcher state an answer to THIS question?
|
|
443
|
+
|
|
444
|
+
`auto` takes whatever is live, but an explicit `-s nas` must not be answered by a
|
|
445
|
+
state file describing the local CLI session: a status bar asking for the NAS list
|
|
446
|
+
printed the Mac's own list instead, which looked like the NAS had one. The
|
|
447
|
+
producer's version is checked for the same reason: a watcher left running across an
|
|
448
|
+
upgrade keeps writing the old layout, and then a pane that trusts it silently loses
|
|
449
|
+
every field the new one added (that is how a goal line would go missing).
|
|
450
|
+
"""
|
|
451
|
+
if not state:
|
|
452
|
+
return False
|
|
453
|
+
if state.get("tool_version") != VERSION:
|
|
454
|
+
return False
|
|
455
|
+
source = source_kind(getattr(args, "source", "auto"))
|
|
456
|
+
return source == "auto" or (state.get("backend") or "") == source
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
# =================================================================== commands
|
|
460
|
+
def ensure_daemon(args, quiet: bool = True) -> int | None:
|
|
461
|
+
# The panes are kept by their own process, asked for here because this is the moment
|
|
462
|
+
# a session and its pane appear — and asked for BEFORE the watcher lock is consulted,
|
|
463
|
+
# so a `-s nas` daemon holding that lock cannot leave a local pane unguarded.
|
|
464
|
+
ensure_pane_keeper(args, quiet=quiet)
|
|
465
|
+
running = live_watcher_pid(LOCK_PATH) # an upgrade's leftover is replaced, not adopted
|
|
466
|
+
if running:
|
|
467
|
+
return running
|
|
468
|
+
cwd = os.path.realpath(os.getcwd())
|
|
469
|
+
if args.source == "nas":
|
|
470
|
+
# Nothing local to adopt: the session is a process on the NAS, and the watcher
|
|
471
|
+
# finds out about it from the probe rather than from this machine's process table.
|
|
472
|
+
return spawn_daemon(args, cwd, None, quiet=quiet)
|
|
473
|
+
inst, _ = find_instance(
|
|
474
|
+
cwd, getattr(args, "watch_pid", None), getattr(args, "instance_of", None)
|
|
475
|
+
)
|
|
476
|
+
if not inst:
|
|
477
|
+
if not quiet:
|
|
478
|
+
print(f"no running Freebuff instance for {cwd}", file=sys.stderr)
|
|
479
|
+
return None
|
|
480
|
+
return spawn_daemon(args, cwd, inst, quiet=quiet)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def spawn_daemon(args, cwd: str, instance_pid: int, quiet: bool = True) -> int | None:
|
|
484
|
+
argv = [
|
|
485
|
+
*self_argv(),
|
|
486
|
+
"daemon",
|
|
487
|
+
"--foreground",
|
|
488
|
+
"--quiet",
|
|
489
|
+
"--cwd",
|
|
490
|
+
cwd,
|
|
491
|
+
"-i",
|
|
492
|
+
str(args.interval),
|
|
493
|
+
"-s",
|
|
494
|
+
args.source,
|
|
495
|
+
"--cli-root",
|
|
496
|
+
args.cli_root,
|
|
497
|
+
"--db",
|
|
498
|
+
args.db,
|
|
499
|
+
"--state",
|
|
500
|
+
args.state,
|
|
501
|
+
# The pane draws what the WATCHER wrote, so how many threads to stack has to reach
|
|
502
|
+
# it: a daemon that fell back to the module default would quietly unstack a pane
|
|
503
|
+
# that asked for two.
|
|
504
|
+
"--threads",
|
|
505
|
+
str(getattr(args, "threads", DESKTOP_MAX_THREADS)),
|
|
506
|
+
"--thread-live",
|
|
507
|
+
str(getattr(args, "thread_live", DESKTOP_LIVE_MS // 60_000)),
|
|
508
|
+
# Passed on for the same reason as the NAS knobs below: a daemon that inherited
|
|
509
|
+
# the module default would keep a killed pane reopened for a caller who asked it
|
|
510
|
+
# not to (or never notice one, for a caller who asked it to be quicker).
|
|
511
|
+
"--pane-seconds",
|
|
512
|
+
str(args.pane_seconds),
|
|
513
|
+
"--ask-seconds",
|
|
514
|
+
str(args.ask_seconds),
|
|
515
|
+
"--pause-seconds",
|
|
516
|
+
str(args.pause_seconds),
|
|
517
|
+
]
|
|
518
|
+
if instance_pid:
|
|
519
|
+
argv += ["--instance-pid", str(instance_pid)]
|
|
520
|
+
if args.project:
|
|
521
|
+
argv += ["--project", args.project]
|
|
522
|
+
if args.chat:
|
|
523
|
+
argv += ["--chat", args.chat]
|
|
524
|
+
# Only meaningful for -s nas, but passed unconditionally so the daemon never falls back
|
|
525
|
+
# to the module defaults when the caller overrode them.
|
|
526
|
+
argv += [
|
|
527
|
+
"--nas-host",
|
|
528
|
+
args.nas_host,
|
|
529
|
+
"--nas-root",
|
|
530
|
+
args.nas_root,
|
|
531
|
+
"--nas-project",
|
|
532
|
+
args.nas_project,
|
|
533
|
+
]
|
|
534
|
+
# Which session the daemon watches. Absent this, a `-s nas` pane that named its own
|
|
535
|
+
# marker spawned a daemon watching `$HOME/.fb-session` instead, and then read that
|
|
536
|
+
# daemon's answer about a DIFFERENT session: a pane for a live marker saw "no session"
|
|
537
|
+
# (and closed early), and one whose marker had ended stayed up because the file the
|
|
538
|
+
# daemon watched belonged to something else that was still alive.
|
|
539
|
+
if getattr(args, "fb_marker", None):
|
|
540
|
+
argv += ["--fb-marker", args.fb_marker]
|
|
541
|
+
# The estimate knobs, forwarded only when the caller actually set them: the watcher is
|
|
542
|
+
# the process that builds the memories, so a flag it never saw would be silently halved.
|
|
543
|
+
if args.label_floor is not None:
|
|
544
|
+
argv += ["--label-floor", str(args.label_floor)]
|
|
545
|
+
if args.blend_weight is not None:
|
|
546
|
+
argv += ["--blend-weight", str(args.blend_weight)]
|
|
547
|
+
log = open(LOG_PATH, "ab", buffering=0)
|
|
548
|
+
try:
|
|
549
|
+
proc = subprocess.Popen(
|
|
550
|
+
argv, stdout=log, stderr=log, stdin=subprocess.DEVNULL, start_new_session=True
|
|
551
|
+
)
|
|
552
|
+
finally:
|
|
553
|
+
log.close()
|
|
554
|
+
# wait for the lock so callers can rely on the daemon existing
|
|
555
|
+
for _ in range(60):
|
|
556
|
+
time.sleep(0.05)
|
|
557
|
+
if daemon_pid():
|
|
558
|
+
break
|
|
559
|
+
pid = daemon_pid()
|
|
560
|
+
if not quiet and not pid:
|
|
561
|
+
print("watcher failed to start; see " + LOG_PATH, file=sys.stderr)
|
|
562
|
+
return pid
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def resolve_instance(args):
|
|
566
|
+
"""(cwd to serve, instance pid) — or exit 66 when Freebuff is not running.
|
|
567
|
+
|
|
568
|
+
Started from a shell hook (no --cwd, no --watch-pid) the watcher deliberately
|
|
569
|
+
adopts whichever instance is running and serves *its* directory, so one
|
|
570
|
+
watcher covers the machine instead of only the shell's own cwd.
|
|
571
|
+
"""
|
|
572
|
+
served = os.path.realpath(os.path.expanduser(args.cwd)) if args.cwd else None
|
|
573
|
+
cwd = served or os.path.realpath(os.getcwd())
|
|
574
|
+
if args.source == "nas":
|
|
575
|
+
# The session is on the NAS, so there is no local pid to report — the lock carries
|
|
576
|
+
# None and liveness comes from the probe.
|
|
577
|
+
return cwd, None
|
|
578
|
+
if args.instance_pid is not None:
|
|
579
|
+
return cwd, args.instance_pid
|
|
580
|
+
inst, inst_cwd = find_instance(cwd, args.watch_pid, args.instance_of)
|
|
581
|
+
if not inst:
|
|
582
|
+
return None, None
|
|
583
|
+
if served is None and args.watch_pid is None and args.instance_of is None and inst_cwd:
|
|
584
|
+
cwd = os.path.realpath(inst_cwd)
|
|
585
|
+
return cwd, inst
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def cmd_daemon(args) -> int:
|
|
589
|
+
if not args.foreground:
|
|
590
|
+
# The shell autostart comes through here, so a keeper appears with the first
|
|
591
|
+
# freebuff of the day even if nothing opens a pane in this terminal.
|
|
592
|
+
ensure_pane_keeper(args, quiet=args.quiet)
|
|
593
|
+
if live_watcher_pid(LOCK_PATH) and not args.force:
|
|
594
|
+
if not args.quiet:
|
|
595
|
+
print(f"watcher already running (pid {daemon_pid()})", file=sys.stderr)
|
|
596
|
+
return 0
|
|
597
|
+
cwd, inst = resolve_instance(args)
|
|
598
|
+
if not inst:
|
|
599
|
+
if not args.quiet:
|
|
600
|
+
print("no running Freebuff instance", file=sys.stderr)
|
|
601
|
+
return EX_CODES["nofile"]
|
|
602
|
+
pid = spawn_daemon(args, cwd, inst, quiet=args.quiet)
|
|
603
|
+
if not pid:
|
|
604
|
+
return EX_CODES["tempfail"]
|
|
605
|
+
if not args.quiet:
|
|
606
|
+
print(f"fbtodo watcher started (pid {pid}, following freebuff pid {inst})", file=sys.stderr)
|
|
607
|
+
return 0
|
|
608
|
+
return daemon_loop(args)
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def daemon_loop(args) -> int:
|
|
612
|
+
if daemon_pid() and not args.force:
|
|
613
|
+
if not args.quiet:
|
|
614
|
+
print(f"watcher already running (pid {daemon_pid()})", file=sys.stderr)
|
|
615
|
+
return 0
|
|
616
|
+
|
|
617
|
+
cwd, instance_pid = resolve_instance(args)
|
|
618
|
+
remote = args.source == "nas"
|
|
619
|
+
if not instance_pid and not remote:
|
|
620
|
+
if not args.quiet:
|
|
621
|
+
print("no running Freebuff instance", file=sys.stderr)
|
|
622
|
+
return EX_CODES["nofile"]
|
|
623
|
+
remote_seen = False
|
|
624
|
+
|
|
625
|
+
prev = read_json(STATE_PATH, None)
|
|
626
|
+
# What the last poll had to say (the clock taken out), and when the heartbeat on disk
|
|
627
|
+
# next needs refreshing. `0.0` is due immediately, so the first poll always writes.
|
|
628
|
+
prev_evidence = state_evidence(prev)
|
|
629
|
+
heartbeat_due = 0.0
|
|
630
|
+
if not claim_or_force(LOCK_PATH, cwd, instance_pid, bool(getattr(args, "force", False))):
|
|
631
|
+
# Between the check above and here another watcher could have taken the claim; the
|
|
632
|
+
# claim is what decides, so losing that race means standing down rather than
|
|
633
|
+
# writing a second watcher's state over the same files.
|
|
634
|
+
if not args.quiet:
|
|
635
|
+
print(f"watcher already running (pid {daemon_pid() or '—'})", file=sys.stderr)
|
|
636
|
+
return 0
|
|
637
|
+
# The ask watch's own clock: one scan of the panes every `--ask-seconds`, whatever the
|
|
638
|
+
# render interval is, because a question can arrive between two of them.
|
|
639
|
+
ask_due = 0.0
|
|
640
|
+
# ...and the stall watch's, asked on its own pace: a stop is measured in minutes, so
|
|
641
|
+
# looking more often than `--pause-seconds` buys nothing.
|
|
642
|
+
pause_due = 0.0
|
|
643
|
+
# ...and the pane watch's, on a third clock. It starts one whole interval out, and that
|
|
644
|
+
# is not a detail: it only reports a session that has had no pane for longer than its
|
|
645
|
+
# 30s grace, so an ask before that can never have anything to say — while costing the
|
|
646
|
+
# same half second as the other two, in the very iteration whose heartbeat a reader is
|
|
647
|
+
# most likely to be watching. The ask watch is the opposite case (a question can be on
|
|
648
|
+
# screen at any moment) and starts at once.
|
|
649
|
+
bell_due = time.monotonic() + max(1.0, args.pane_bell_seconds)
|
|
650
|
+
stop_reason = "stopped"
|
|
651
|
+
stop_code = 0
|
|
652
|
+
|
|
653
|
+
def shutdown(signum=None, _frame=None):
|
|
654
|
+
nonlocal stop_reason, stop_code
|
|
655
|
+
stop_reason = f"signal-{signum}" if signum else "stopped"
|
|
656
|
+
stop_code = 128 + signum if signum else 0
|
|
657
|
+
raise SystemExit(stop_code)
|
|
658
|
+
|
|
659
|
+
for sig in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP):
|
|
660
|
+
signal.signal(sig, shutdown)
|
|
661
|
+
|
|
662
|
+
try:
|
|
663
|
+
while True:
|
|
664
|
+
if not remote and not pid_alive(instance_pid):
|
|
665
|
+
stop_reason = "instance-exited"
|
|
666
|
+
stop_code = 0
|
|
667
|
+
break
|
|
668
|
+
if not lock_ours(LOCK_PATH):
|
|
669
|
+
# the claim is this watcher's hold on the state directory; if it is gone
|
|
670
|
+
# (removed, or the directory itself), or if the file is no longer the one
|
|
671
|
+
# this process locked (something replaced it), the watcher is no longer
|
|
672
|
+
# the owner — stop instead of re-creating the directory.
|
|
673
|
+
stop_reason = "lock-removed"
|
|
674
|
+
stop_code = 0
|
|
675
|
+
break
|
|
676
|
+
st = snapshot(args, cwd=cwd, instance_pid=instance_pid)
|
|
677
|
+
if remote:
|
|
678
|
+
# There is no local pid to lose, so the session ending is read from the probe:
|
|
679
|
+
# close only after one has actually been SEEN, because the pane is opened by the
|
|
680
|
+
# ssh alias and `fb` may not be typed for minutes afterwards.
|
|
681
|
+
if st.get("instance_alive"):
|
|
682
|
+
remote_seen = True
|
|
683
|
+
elif remote_seen:
|
|
684
|
+
stop_reason = "instance-exited"
|
|
685
|
+
stop_code = 0
|
|
686
|
+
break
|
|
687
|
+
st = finish_state(st, prev, claim_first=True)
|
|
688
|
+
st = track_tasks(st)
|
|
689
|
+
prune_scratch() # no-op unless an hour has passed
|
|
690
|
+
st["daemon_pid"] = os.getpid()
|
|
691
|
+
st["heartbeat_ms"] = int(time.time() * 1000)
|
|
692
|
+
st["tool_version"] = VERSION
|
|
693
|
+
st["status"] = "watching"
|
|
694
|
+
st["stop_reason"] = None
|
|
695
|
+
# Write when there is something to say, and otherwise only to keep the
|
|
696
|
+
# heartbeat inside the window a reader calls fresh — measured on a live list:
|
|
697
|
+
# 0.96 rewrites a second (26.8 MB/hour) of a file whose only change was the
|
|
698
|
+
# clock. A rewrite that carries no evidence also carries no fsync: there is
|
|
699
|
+
# nothing in it worth flushing.
|
|
700
|
+
evidence = state_evidence(st)
|
|
701
|
+
if evidence != prev_evidence or time.monotonic() >= heartbeat_due:
|
|
702
|
+
atomic_write_json(STATE_PATH, st, fsync=evidence != prev_evidence)
|
|
703
|
+
heartbeat_due = time.monotonic() + HB_REFRESH
|
|
704
|
+
prev = st
|
|
705
|
+
prev_evidence = evidence
|
|
706
|
+
if args.ask_seconds > 0 and time.monotonic() >= ask_due:
|
|
707
|
+
ask_due = time.monotonic() + args.ask_seconds
|
|
708
|
+
ask_notify_once(args, quiet=args.quiet)
|
|
709
|
+
if not remote and args.pause_seconds > 0 and time.monotonic() >= pause_due:
|
|
710
|
+
pause_due = time.monotonic() + args.pause_seconds
|
|
711
|
+
pause_notify_once(args, instance_pid, quiet=args.quiet)
|
|
712
|
+
# Not gated on `remote`: what it reports is this machine's tmux, and a `-s
|
|
713
|
+
# nas` daemon is often the only thing running here to ask.
|
|
714
|
+
if args.pane_bell_seconds > 0 and time.monotonic() >= bell_due:
|
|
715
|
+
bell_due = time.monotonic() + args.pane_bell_seconds
|
|
716
|
+
pane_notify_once(args, quiet=args.quiet)
|
|
717
|
+
time.sleep(max(0.2, args.interval))
|
|
718
|
+
except SystemExit:
|
|
719
|
+
pass
|
|
720
|
+
except Exception as exc: # never die silently leaving a stale lock
|
|
721
|
+
stop_reason = f"error: {exc.__class__.__name__}"
|
|
722
|
+
stop_code = EX_CODES["tempfail"]
|
|
723
|
+
finally:
|
|
724
|
+
try:
|
|
725
|
+
if os.path.isdir(os.path.dirname(LOCK_PATH)):
|
|
726
|
+
# leave a tombstone so consumers can tell "stopped" from "never
|
|
727
|
+
# ran" — but never resurrect a directory that was removed
|
|
728
|
+
final = dict(prev or {})
|
|
729
|
+
final.update(
|
|
730
|
+
status="stopped",
|
|
731
|
+
stop_reason=stop_reason,
|
|
732
|
+
heartbeat_ms=int(time.time() * 1000),
|
|
733
|
+
daemon_pid=None,
|
|
734
|
+
)
|
|
735
|
+
final.pop("todos", None) # a stopped watcher owns no list
|
|
736
|
+
atomic_write_json(STATE_PATH, final)
|
|
737
|
+
except Exception:
|
|
738
|
+
pass
|
|
739
|
+
clear_lock(os.getpid())
|
|
740
|
+
if not args.quiet and stop_reason:
|
|
741
|
+
print(f"fbtodo watcher stopped ({stop_reason})", file=sys.stderr)
|
|
742
|
+
return stop_code
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def cmd_doctor(args) -> int:
|
|
746
|
+
"""Whether this machine can show a pane, and what to fix when it cannot.
|
|
747
|
+
|
|
748
|
+
Every question here is one the tool asks for real somewhere else in this file — tmux,
|
|
749
|
+
a writable scratch dir, the terminal's colour story, the tools discovery needs — asked
|
|
750
|
+
all at once and ANSWERED rather than assumed. `ok` needs no action, `warn` is a
|
|
751
|
+
preference that is not met (a pane still works), `FAIL` is something that stops one,
|
|
752
|
+
and any FAIL exits non-zero: that is what makes this a gate a wrapper or a CI job can
|
|
753
|
+
call, rather than a paragraph of advice. `--json` is the same list for a machine.
|
|
754
|
+
"""
|
|
755
|
+
checks: list = [] # (level, name, detail)
|
|
756
|
+
|
|
757
|
+
py = sys.version_info
|
|
758
|
+
checks.append(("ok" if py >= (3, 9) else "FAIL", "python",
|
|
759
|
+
f"{py.major}.{py.minor}.{py.micro} at {sys.executable}"))
|
|
760
|
+
|
|
761
|
+
tmux_bin = TMUX_BIN[0] if TMUX_BIN else "tmux"
|
|
762
|
+
if not _shutil.which(tmux_bin):
|
|
763
|
+
checks.append(("FAIL", "tmux", f"no {tmux_bin} on PATH — a pane cannot be opened"))
|
|
764
|
+
else:
|
|
765
|
+
ver = ""
|
|
766
|
+
try:
|
|
767
|
+
ver = subprocess.run(TMUX_BIN + ["-V"], capture_output=True, text=True,
|
|
768
|
+
timeout=5).stdout.strip()
|
|
769
|
+
except Exception:
|
|
770
|
+
ver = ""
|
|
771
|
+
checks.append(("ok" if ver else "warn", "tmux", ver or "present, but would not answer -V"))
|
|
772
|
+
feats = ""
|
|
773
|
+
try:
|
|
774
|
+
feats = subprocess.run(TMUX_BIN + ["show", "-gv", "terminal-features"],
|
|
775
|
+
capture_output=True, text=True, timeout=5).stdout.strip()
|
|
776
|
+
except Exception:
|
|
777
|
+
feats = ""
|
|
778
|
+
if not feats:
|
|
779
|
+
checks.append(("warn", "tmux colour", "no server to ask (a pane will start one)"))
|
|
780
|
+
elif "RGB" in feats:
|
|
781
|
+
checks.append(("ok", "tmux colour", "RGB in terminal-features"))
|
|
782
|
+
else:
|
|
783
|
+
# tmux's own forwarding story, which is NOT what the pane's colours are decided
|
|
784
|
+
# by (that is the `colour` line below, from COLORTERM/TERM): a server without
|
|
785
|
+
# RGB takes the 256-colour palette, and the pane knows it.
|
|
786
|
+
checks.append(("warn", "tmux colour",
|
|
787
|
+
"no RGB in terminal-features — tmux forwards 256 colours"))
|
|
788
|
+
|
|
789
|
+
truecolor = _supports_truecolor()
|
|
790
|
+
checks.append(("ok" if truecolor else "warn", "colour",
|
|
791
|
+
f"{'24-bit' if truecolor else '256-colour'} "
|
|
792
|
+
f"(TERM={os.environ.get('TERM') or '—'}, "
|
|
793
|
+
f"COLORTERM={os.environ.get('COLORTERM') or '—'})"
|
|
794
|
+
+ ("" if truecolor else " — COLORTERM=truecolor gets the designed palette")))
|
|
795
|
+
loc = os.environ.get("LC_ALL") or os.environ.get("LC_CTYPE") or os.environ.get("LANG") or ""
|
|
796
|
+
enc = sys.stdout.encoding or ""
|
|
797
|
+
utf8 = "utf" in loc.lower() or "utf" in enc.lower()
|
|
798
|
+
checks.append(("ok" if utf8 else "warn", "locale",
|
|
799
|
+
f"{loc or 'unset'} / stdout {enc or '—'}"
|
|
800
|
+
+ ("" if utf8 else " — the frame's glyphs need a UTF-8 locale")))
|
|
801
|
+
|
|
802
|
+
for tool, level, why in (
|
|
803
|
+
("ps", "FAIL", "the process table is how the instance is found"),
|
|
804
|
+
("lsof", "warn" if os.path.isdir(PROC_ROOT) else "FAIL", "cwd discovery"),
|
|
805
|
+
):
|
|
806
|
+
found = _shutil.which(tool)
|
|
807
|
+
checks.append(("ok", tool, found) if found else (level, tool, f"missing — {why}"))
|
|
808
|
+
checks.append(("ok", "cwd source",
|
|
809
|
+
f"/proc at {PROC_ROOT}" if os.path.isdir(PROC_ROOT) else "lsof (no /proc)"))
|
|
810
|
+
|
|
811
|
+
try:
|
|
812
|
+
os.makedirs(SCRATCH, mode=0o700, exist_ok=True)
|
|
813
|
+
probe = os.path.join(SCRATCH, ".fbtodo-doctor-probe")
|
|
814
|
+
with open(probe, "w", encoding="utf-8") as fh:
|
|
815
|
+
fh.write("ok")
|
|
816
|
+
os.unlink(probe)
|
|
817
|
+
checks.append(("ok", "scratch", SCRATCH))
|
|
818
|
+
except OSError as exc:
|
|
819
|
+
checks.append(("FAIL", "scratch", f"{SCRATCH}: {exc.__class__.__name__}"))
|
|
820
|
+
if STATE_NOTE == "moved":
|
|
821
|
+
checks.append(("ok", "state", f"{SCRATCH} (moved from {LEGACY_SCRATCH})"))
|
|
822
|
+
elif STATE_NOTE == "live":
|
|
823
|
+
checks.append(("warn", "state",
|
|
824
|
+
f"still {SCRATCH} — a live watcher or keeper owns it; "
|
|
825
|
+
f"stop both and the next run moves it to {STATE_DIR}"))
|
|
826
|
+
elif STATE_NOTE == "failed":
|
|
827
|
+
checks.append(("warn", "state", f"still {SCRATCH} — could not move to {STATE_DIR}"))
|
|
828
|
+
holder = lock_holder(LOCK_PATH)
|
|
829
|
+
checks.append(("ok" if holder else "warn", "watcher",
|
|
830
|
+
f"pid {holder} holds {os.path.basename(LOCK_PATH)}" if holder
|
|
831
|
+
else "not running (a pane starts one)"))
|
|
832
|
+
|
|
833
|
+
kit = os.path.dirname(NAS_NOTIFY)
|
|
834
|
+
if not os.path.isdir(kit):
|
|
835
|
+
checks.append(("warn", "notify kit", f"not installed ({kit}) — panes work, bells do not"))
|
|
836
|
+
else:
|
|
837
|
+
missing = [n for n in ("phone.sh", "todo-bell.py", "bell.sh")
|
|
838
|
+
if not os.path.exists(os.path.join(kit, n))]
|
|
839
|
+
checks.append(("warn", "notify kit", f"missing {', '.join(missing)}") if missing
|
|
840
|
+
else ("ok", "notify kit", kit))
|
|
841
|
+
|
|
842
|
+
inst, inst_cwd = find_instance(os.path.realpath(os.getcwd()))
|
|
843
|
+
checks.append(("ok", "instance", f"freebuff pid {inst} in {inst_cwd}") if inst
|
|
844
|
+
else ("warn", "instance",
|
|
845
|
+
"no freebuff for this directory — a watcher here would exit 66"))
|
|
846
|
+
|
|
847
|
+
fails = [c for c in checks if c[0] == "FAIL"]
|
|
848
|
+
warns = [c for c in checks if c[0] == "warn"]
|
|
849
|
+
if args.json:
|
|
850
|
+
json.dump({"version": VERSION, "ok": not fails, "fail": len(fails),
|
|
851
|
+
"warn": len(warns),
|
|
852
|
+
"checks": [{"level": lv, "name": name, "detail": detail}
|
|
853
|
+
for lv, name, detail in checks]},
|
|
854
|
+
sys.stdout, ensure_ascii=False)
|
|
855
|
+
sys.stdout.write("\n")
|
|
856
|
+
return 1 if fails else 0
|
|
857
|
+
ink = {"ok": "2", "warn": "33", "FAIL": "1;31"}
|
|
858
|
+
c = paint(use_color())
|
|
859
|
+
print(f"{c('1', 'fbtodo doctor')} {c('2', VERSION)}")
|
|
860
|
+
for level, name, detail in checks:
|
|
861
|
+
print(f" {c(ink[level], level.ljust(4))} {name.ljust(11)} {detail}")
|
|
862
|
+
print(f" {len(checks) - len(warns) - len(fails)} ok · {len(warns)} warn · {len(fails)} fail"
|
|
863
|
+
+ (f" first problem: {fails[0][1]}" if fails else ""))
|
|
864
|
+
return 1 if fails else 0
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
def cmd_stop(args) -> int:
|
|
868
|
+
pid = daemon_pid()
|
|
869
|
+
if not pid:
|
|
870
|
+
clear_lock()
|
|
871
|
+
print("fbtodo watcher: not running", file=sys.stderr)
|
|
872
|
+
return 0
|
|
873
|
+
try:
|
|
874
|
+
os.kill(pid, signal.SIGTERM)
|
|
875
|
+
except OSError:
|
|
876
|
+
pass
|
|
877
|
+
for _ in range(40):
|
|
878
|
+
if not pid_alive(pid):
|
|
879
|
+
break
|
|
880
|
+
time.sleep(0.05)
|
|
881
|
+
clear_lock()
|
|
882
|
+
if not args.quiet:
|
|
883
|
+
print(f"fbtodo watcher stopped (pid {pid})", file=sys.stderr)
|
|
884
|
+
return 0
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
def cmd_status(args) -> int:
|
|
888
|
+
cwd = os.path.realpath(os.getcwd())
|
|
889
|
+
inst, inst_cwd = find_instance(cwd, args.watch_pid, args.instance_of)
|
|
890
|
+
pid = daemon_pid()
|
|
891
|
+
st = read_json(STATE_PATH, None)
|
|
892
|
+
# The state file is whatever the LOCAL watcher follows. `-s nas` asks about the NAS, so
|
|
893
|
+
# ask it (one probe): reporting the local session's list against "nas session: — no
|
|
894
|
+
# session found" was a lie about a box that was running a freebuff at the time.
|
|
895
|
+
live = adopt_version(finish_state(snapshot(args), None)) if args.source == "nas" else None
|
|
896
|
+
view = live or st
|
|
897
|
+
read_theme() # the palette resolves its refusals here, so both branches can report them
|
|
898
|
+
if args.json:
|
|
899
|
+
json.dump(
|
|
900
|
+
{
|
|
901
|
+
"instance_pid": inst,
|
|
902
|
+
"instance_cwd": inst_cwd,
|
|
903
|
+
"watcher_pid": pid,
|
|
904
|
+
"state_file": STATE_PATH,
|
|
905
|
+
"state_fresh": state_is_fresh(st),
|
|
906
|
+
"theme_problems": [list(p) for p in THEME_PROBLEMS],
|
|
907
|
+
"refit_readiness": refit_readiness(
|
|
908
|
+
load_tasklog().get("tasks") or {}, model=(st or {}).get("model")
|
|
909
|
+
),
|
|
910
|
+
"state": st,
|
|
911
|
+
},
|
|
912
|
+
sys.stdout,
|
|
913
|
+
ensure_ascii=False,
|
|
914
|
+
)
|
|
915
|
+
sys.stdout.write("\n")
|
|
916
|
+
return 0
|
|
917
|
+
c = paint(use_color())
|
|
918
|
+
print(f"{c('1', 'fbtodo')} {c('2', cwd)}")
|
|
919
|
+
print(f" freebuff instance : {inst or '—'}{f' ({inst_cwd})' if inst_cwd else ''}")
|
|
920
|
+
others = [p for p in freebuff_pids() if p != inst]
|
|
921
|
+
if others:
|
|
922
|
+
ages = ages_for(others)
|
|
923
|
+
shown = ", ".join(
|
|
924
|
+
f"{p} ({short_duration(ages.get(p, 0) * 1000)} old)" for p in others
|
|
925
|
+
)
|
|
926
|
+
print(f" other instances : {shown} — pin one with --instance-of/--watch-pid")
|
|
927
|
+
print(f" watcher : {pid or '— not running'}")
|
|
928
|
+
# A theme value the palette REFUSED (see `read_theme`) is reported here rather than at
|
|
929
|
+
# render time: the pane falls back to the default and looks right, which is exactly
|
|
930
|
+
# how a bad value survived unnoticed before.
|
|
931
|
+
read_theme()
|
|
932
|
+
if THEME_PROBLEMS:
|
|
933
|
+
shown = ", ".join(f"{name}={value!r}" for name, value in THEME_PROBLEMS)
|
|
934
|
+
print(f" theme values : {len(THEME_PROBLEMS)} ignored ({shown})")
|
|
935
|
+
# In plain words: which build of fbtodo is actually running. The version is
|
|
936
|
+
# hand-maintained, so a watcher left over from an earlier copy keeps working while the
|
|
937
|
+
# pane beside it is a different build — the one way this pane has ever misled its
|
|
938
|
+
# owner. Both numbers on one line make that self-diagnosing: they agree when the pane
|
|
939
|
+
# and its watcher are the same build, and the state's own stamp is exactly what a
|
|
940
|
+
# version-stale watcher is killed for on the next start (see `live_watcher_pid`).
|
|
941
|
+
watching = (st or {}).get("tool_version")
|
|
942
|
+
stale_note = (
|
|
943
|
+
f" (watcher {watching} — stale, replaced on the next start)"
|
|
944
|
+
if watching and watching != VERSION else ""
|
|
945
|
+
)
|
|
946
|
+
print(f" tool version : {VERSION}{stale_note}")
|
|
947
|
+
sess = (st or {}).get("session")
|
|
948
|
+
reason = (st or {}).get("stop_reason")
|
|
949
|
+
bits = ["fresh" if state_is_fresh(st) else "stale"]
|
|
950
|
+
if sess:
|
|
951
|
+
bits.append(f"session {sess}")
|
|
952
|
+
if reason:
|
|
953
|
+
bits.append(str(reason))
|
|
954
|
+
print(f" state file : {' '.join(bits)}")
|
|
955
|
+
# The patches' own two facts, in the same words the pane's row uses and from the same
|
|
956
|
+
# reader: what the last patch pass did, and when the phone was last told something.
|
|
957
|
+
# The NAS answer rides in the probe above; locally it is two tail reads.
|
|
958
|
+
rep = (
|
|
959
|
+
{"patch": (view or {}).get("patch"), "alert": (view or {}).get("alert")}
|
|
960
|
+
if args.source == "nas"
|
|
961
|
+
else local_patch_alert()
|
|
962
|
+
)
|
|
963
|
+
seen = {label: text for label, text, _sev in patch_row(rep)}
|
|
964
|
+
print(f" cli patches : {seen.get('PATCH') or '— nothing logged'}")
|
|
965
|
+
print(f" last alert : {seen.get('ALERT') or '— nothing logged'}")
|
|
966
|
+
if args.source == "nas" or (st or {}).get("backend") == "nas":
|
|
967
|
+
nas = (view or {}).get("nas") or {}
|
|
968
|
+
print(
|
|
969
|
+
f" nas session : {nas.get('dir') or '— no session found'}"
|
|
970
|
+
f" ({'live' if nas.get('live') else 'not running'})"
|
|
971
|
+
)
|
|
972
|
+
if nas.get("fb") and nas.get("fb") != "-":
|
|
973
|
+
marker = "live" if nas.get("fb") == "1" else "stale"
|
|
974
|
+
print(f" fb marker : {marker} {nas.get('fb_project') or '-'}")
|
|
975
|
+
if view and not view.get("todos"):
|
|
976
|
+
# Said out loud, in the pane's own words. Until this line existed a session with no
|
|
977
|
+
# list had no `todos` row at all, and a missing row reads as a broken command rather
|
|
978
|
+
# than as "the agent has not written one yet" — which is the whole question this
|
|
979
|
+
# answers. Same sentence as the pane, from the same function.
|
|
980
|
+
print(f" todos : — none yet ({no_list_reason(view)})")
|
|
981
|
+
# The second basket, in the same words the pane uses: what the session has DONE
|
|
982
|
+
# instead of writing a list. Absent when the state carries no calls, so a fixture
|
|
983
|
+
# or a brand-new session prints exactly as it did before this line existed.
|
|
984
|
+
feed = observed_rows(view, 4)
|
|
985
|
+
for i, seg in enumerate(feed):
|
|
986
|
+
print(f" {'last actions ' if not i else ' '}: {seg}")
|
|
987
|
+
if view and view.get("todos"):
|
|
988
|
+
counts = f"{view.get('done', 0)}/{view.get('total', len(view['todos']))} done"
|
|
989
|
+
if view.get("list_version"):
|
|
990
|
+
counts += f", list #{view['list_version']}"
|
|
991
|
+
print(f" todos : {counts}")
|
|
992
|
+
# The list's own age. The pane has this number too, but only in the strip's
|
|
993
|
+
# parentheses and only while the state is idle — a clock on a step takes that slot,
|
|
994
|
+
# by design. Said here it is always answered, which is what makes "an agent that
|
|
995
|
+
# stopped calling `write_todos` leaves the old progress up" a question the command
|
|
996
|
+
# line can settle.
|
|
997
|
+
print(
|
|
998
|
+
" list written : "
|
|
999
|
+
+ (fmt_age(view.get("source_updated_ms") or view.get("ts")) or "?")
|
|
1000
|
+
)
|
|
1001
|
+
if list_behind(view, int(time.time() * 1000)):
|
|
1002
|
+
moved = int(view.get("store_mtime_ms") or 0) - int(
|
|
1003
|
+
view.get("ts") or view.get("source_updated_ms") or 0
|
|
1004
|
+
)
|
|
1005
|
+
print(
|
|
1006
|
+
" list behind : yes — every step is ticked, and no newer"
|
|
1007
|
+
f" `write_todos` in {short_duration(moved) or '10m'}"
|
|
1008
|
+
)
|
|
1009
|
+
if view and (view.get("goal") or view.get("todos")):
|
|
1010
|
+
goal = str(view.get("goal") or "")
|
|
1011
|
+
if not goal:
|
|
1012
|
+
print(" big goal : — none stated (the agent owes a `Goal:` line)")
|
|
1013
|
+
else:
|
|
1014
|
+
over = len(goal) - GOAL_MAX_CHARS
|
|
1015
|
+
note = "" if over <= 0 else f" — {over} chars over the {GOAL_MAX_CHARS} that fit"
|
|
1016
|
+
print(f" big goal : {goal}{note}")
|
|
1017
|
+
print(f" goal source : agent's `Goal:` line")
|
|
1018
|
+
if view and view.get("now"):
|
|
1019
|
+
# "has not caught up" is a claim about a list: with none on screen (a finished one was
|
|
1020
|
+
# just dropped), the request is simply the newer thing, and nothing is behind it.
|
|
1021
|
+
behind = " — the list has not caught up" if view.get("todos") else ""
|
|
1022
|
+
print(f" newer request : {view.get('now')}{behind}")
|
|
1023
|
+
if view:
|
|
1024
|
+
# The turn, for both cases: with a list it says which turn the list belongs to, and
|
|
1025
|
+
# without one it is the only measured thing there is. Same sentence as the pane's.
|
|
1026
|
+
turn_line = turn_note(view, int(time.time() * 1000))
|
|
1027
|
+
if turn_line:
|
|
1028
|
+
print(f" turn : {turn_line}")
|
|
1029
|
+
if view and view.get("nudge"):
|
|
1030
|
+
print(
|
|
1031
|
+
f" pending nudge : {view.get('nudge')} — "
|
|
1032
|
+
"re-write the list, then continue"
|
|
1033
|
+
)
|
|
1034
|
+
if view and view.get("backend") == "cli" and view.get("todos"):
|
|
1035
|
+
# What the finished-task bell rings on, said out loud: a ticked-off list with the
|
|
1036
|
+
# agent still working is exactly the case that must stay silent.
|
|
1037
|
+
if view.get("turn_ended"):
|
|
1038
|
+
print(" agent : turn ended — waiting for you")
|
|
1039
|
+
else:
|
|
1040
|
+
print(" agent : working (the list is not the finish line)")
|
|
1041
|
+
# The ask watch, said out loud like the other notifiers: a question on screen stops
|
|
1042
|
+
# the agent until it is answered, so "is one up right now" belongs in a status line.
|
|
1043
|
+
if not os.path.exists(PAUSE_NOTIFY):
|
|
1044
|
+
print(" stall watch : none installed (~/.config/freebuff-notify/pause-bell.py)")
|
|
1045
|
+
elif args.pause_seconds <= 0:
|
|
1046
|
+
print(" stall watch : off (--pause-seconds 0)")
|
|
1047
|
+
else:
|
|
1048
|
+
print(f" stall watch : on, every {args.pause_seconds:.0f}s")
|
|
1049
|
+
if not os.path.exists(ASK_NOTIFY):
|
|
1050
|
+
print(" ask watch : none installed (~/.config/freebuff-notify/ask-bell.py)")
|
|
1051
|
+
elif args.ask_seconds <= 0:
|
|
1052
|
+
print(" ask watch : off (--ask-seconds 0)")
|
|
1053
|
+
else:
|
|
1054
|
+
asking = ""
|
|
1055
|
+
try:
|
|
1056
|
+
proc = subprocess.run(
|
|
1057
|
+
[ASK_NOTIFY, "--print"], capture_output=True, text=True, timeout=10
|
|
1058
|
+
)
|
|
1059
|
+
line = (proc.stdout or "").strip()
|
|
1060
|
+
if line.startswith("ASK: "):
|
|
1061
|
+
asking = " — a question is on screen now: " + line.split(" — ", 1)[-1][:60]
|
|
1062
|
+
except (OSError, subprocess.SubprocessError):
|
|
1063
|
+
pass
|
|
1064
|
+
print(f" ask watch : on, every {args.ask_seconds:.0f}s{asking}")
|
|
1065
|
+
# The pane watch, in the same three states — and it says what it currently thinks,
|
|
1066
|
+
# because "is the pane watcher working" is otherwise only answerable by killing a pane
|
|
1067
|
+
# and waiting to see whether it comes back.
|
|
1068
|
+
if not os.path.exists(PANE_NOTIFY):
|
|
1069
|
+
print(" pane watch : none installed (~/.config/freebuff-notify/pane-bell.py)")
|
|
1070
|
+
elif getattr(args, "pane_bell_seconds", 0) <= 0:
|
|
1071
|
+
print(" pane watch : off (--pane-bell-seconds 0)")
|
|
1072
|
+
else:
|
|
1073
|
+
verdict = ""
|
|
1074
|
+
try:
|
|
1075
|
+
proc = subprocess.run(
|
|
1076
|
+
[PANE_NOTIFY, "--print", "--keeper", PANE_KEEPER_PATH],
|
|
1077
|
+
capture_output=True, text=True, timeout=30,
|
|
1078
|
+
)
|
|
1079
|
+
line = (proc.stdout or "").strip()
|
|
1080
|
+
if line.startswith("PANE: "):
|
|
1081
|
+
verdict = " — FAILING NOW: " + line.split(" — ", 1)[-1].splitlines()[0][:70]
|
|
1082
|
+
elif line.startswith("silent: "):
|
|
1083
|
+
verdict = " — " + line.split(": ", 1)[-1][:70]
|
|
1084
|
+
except (OSError, subprocess.SubprocessError):
|
|
1085
|
+
pass
|
|
1086
|
+
print(
|
|
1087
|
+
f" pane watch : on, every {args.pane_bell_seconds:.0f}s{verdict}"
|
|
1088
|
+
)
|
|
1089
|
+
# Where this session's list is drawn. It is put back within seconds of being killed,
|
|
1090
|
+
# so "none" is a fact about right now rather than a broken setup — and outside tmux
|
|
1091
|
+
# there is nothing to show, which is not an error either.
|
|
1092
|
+
if inst:
|
|
1093
|
+
mine = local_pane_ids(inst)
|
|
1094
|
+
print(f" todo pane : {', '.join(mine) if mine else '— none right now'}")
|
|
1095
|
+
log = load_tasklog()
|
|
1096
|
+
last = log.get("pruned_ms")
|
|
1097
|
+
when = f"{short_duration((time.time() * 1000 - last))} ago" if last else "never"
|
|
1098
|
+
print(
|
|
1099
|
+
f" task records : {len(log.get('tasks') or {})} kept "
|
|
1100
|
+
f"(caps {MAX_TASK_RECORDS} records / {MAX_TASK_AGE_DAYS}d), pruned {when}"
|
|
1101
|
+
)
|
|
1102
|
+
# What the estimates ride on, and where each waiting step's number comes from: the
|
|
1103
|
+
# feature is measured from the log, so this is the block that answers "why is that
|
|
1104
|
+
# step ~5m?" without reading the JSON by hand.
|
|
1105
|
+
model = (view or {}).get("model") or None
|
|
1106
|
+
history = task_history_from_log(log.get("tasks") or {}, model=model)
|
|
1107
|
+
shapes = shape_history_from_log(log.get("tasks") or {}, model=model)
|
|
1108
|
+
calls_mem = call_memory_from_log(log.get("tasks") or {}, model=model)
|
|
1109
|
+
todos = (view or {}).get("todos") or []
|
|
1110
|
+
times = (view or {}).get("task_times") or {}
|
|
1111
|
+
now_est = int(time.time() * 1000)
|
|
1112
|
+
pace = step_pace_ms(times, todos, now_est, history)
|
|
1113
|
+
pace_spread = pace_spread_ms(times, todos, now_est, history)
|
|
1114
|
+
n_samples = sum(int((v or {}).get("n") or 1) for v in history.values())
|
|
1115
|
+
origin = f"{n_samples} remembered step(s)" if history else "no remembered steps yet"
|
|
1116
|
+
# The spread is printed with the pace it belongs to: `2m` from 10s and 20m of finished
|
|
1117
|
+
# steps is a different claim from `2m` from 2m and 2m, and only the spread says which.
|
|
1118
|
+
if pace_spread and is_wide(*pace_spread):
|
|
1119
|
+
origin += f", spread {fmt_range(*pace_spread)}"
|
|
1120
|
+
print(f" remembered pace : {short_duration(pace, seconds=True)} ({origin})")
|
|
1121
|
+
# The memory that can improve with use: keyed by what a step DID rather than what it was
|
|
1122
|
+
# called, so its samples accumulate where the wording memory has almost none. Printed
|
|
1123
|
+
# with its own sample counts, because "is this getting better?" should be answerable
|
|
1124
|
+
# from the terminal and not from faith.
|
|
1125
|
+
if shapes:
|
|
1126
|
+
# What the rung can actually READ first: a bucket the ladder would never look up is
|
|
1127
|
+
# not evidence that the memory is pulling its weight, so the held-back ones are
|
|
1128
|
+
# counted beside it rather than shown as if they were in use.
|
|
1129
|
+
readable = {b: e for b, e in shapes.items() if sized_entry(shapes, b)}
|
|
1130
|
+
young = [b for b in shapes if (bucket_ordinal(b) or 0) < SHAPE_MIN_BUCKET]
|
|
1131
|
+
thin = [b for b in shapes if b not in young and sized_entry(shapes, b) is None]
|
|
1132
|
+
held = ""
|
|
1133
|
+
if young or thin:
|
|
1134
|
+
why = []
|
|
1135
|
+
if young:
|
|
1136
|
+
why.append(f"{len(young)} under the calls{SHAPE_MIN_BUCKET} floor")
|
|
1137
|
+
if thin:
|
|
1138
|
+
why.append(f"{len(thin)} short of {SHAPE_MIN_SAMPLES} sample(s)")
|
|
1139
|
+
held = f" · {len(young) + len(thin)} not read yet ({', '.join(why)})"
|
|
1140
|
+
top = sorted(readable.items(), key=lambda kv: -int(kv[1].get("n") or 0))[:3]
|
|
1141
|
+
shown = ", ".join(
|
|
1142
|
+
f"{bucket} {fmt_estimate(entry.get('med') or 0)} (n={entry.get('n')})"
|
|
1143
|
+
for bucket, entry in top
|
|
1144
|
+
)
|
|
1145
|
+
if top:
|
|
1146
|
+
print(f" remembered sizes : {len(shapes)} kept — {shown}{held}")
|
|
1147
|
+
else:
|
|
1148
|
+
print(f" remembered sizes : {len(shapes)} kept, none the rung may read yet{held}")
|
|
1149
|
+
else:
|
|
1150
|
+
print(" remembered sizes : none yet (a size is learned when a step finishes)")
|
|
1151
|
+
# What a WAITING step is priced from: its wording's kind, the calls that kind usually
|
|
1152
|
+
# takes, and what each of those calls costs. Printed because it is the memory behind
|
|
1153
|
+
# every row that has not started, and those are most of what `EST REM` sums.
|
|
1154
|
+
if calls_mem:
|
|
1155
|
+
kinds = ", ".join(
|
|
1156
|
+
f"{k} {v}" for k, v in sorted((calls_mem.get("classes") or {}).items())
|
|
1157
|
+
)
|
|
1158
|
+
print(f" remembered calls : {calls_mem.get('calls')} calls x "
|
|
1159
|
+
f"{short_duration(calls_mem.get('rate_ms') or 0, seconds=True)} "
|
|
1160
|
+
f"({kinds})")
|
|
1161
|
+
else:
|
|
1162
|
+
print(" remembered calls : none yet (a kind is learned when a step finishes)")
|
|
1163
|
+
# The one line that answers "is this getting better?" — and the only honest way to
|
|
1164
|
+
# answer it, since the estimate and the outcome are both in the log. Split by source so
|
|
1165
|
+
# a shape that is pulling its weight shows up as a lower median than the list's pace.
|
|
1166
|
+
err = estimate_error(log.get("tasks") or {}, model=model)
|
|
1167
|
+
parts = [
|
|
1168
|
+
f"{src} {err[src]['med']:.2f}x median over {err[src]['n']}"
|
|
1169
|
+
for src in ("shape", "blend", "pace")
|
|
1170
|
+
if err.get(src)
|
|
1171
|
+
]
|
|
1172
|
+
if parts:
|
|
1173
|
+
print(f" estimate error : {' · '.join(parts)} (factor error of closed steps)")
|
|
1174
|
+
else:
|
|
1175
|
+
print(
|
|
1176
|
+
" estimate error : — nothing closed yet since estimates were stamped "
|
|
1177
|
+
"(a factor of 1.0x would be exact)"
|
|
1178
|
+
)
|
|
1179
|
+
# ...and the honest half: the same rungs scored on the forecast each step got on the
|
|
1180
|
+
# FIRST poll that saw it running, before its size was known. The line above scores the
|
|
1181
|
+
# number the pane was showing as the step closed, whose size key is built from calls the
|
|
1182
|
+
# step had already made; this one cannot flatter a rung that recognises.
|
|
1183
|
+
ferr = forecast_error(log.get("tasks") or {}, model=model)
|
|
1184
|
+
# The interval rides beside the median, because the median alone cannot tell a rung that
|
|
1185
|
+
# misses by a tenth every time from one that misses by a tenth on average.
|
|
1186
|
+
fparts = [f"{src} {fmt_rung(ferr[src])}" for src in SHIPPED_RUNGS if ferr.get(src)]
|
|
1187
|
+
skipped = (ferr.get("late") or {}).get("n") or 0
|
|
1188
|
+
tail = f" · {skipped} stamped late, not scored" if skipped else ""
|
|
1189
|
+
if fparts:
|
|
1190
|
+
print(
|
|
1191
|
+
f" forecast error : {' · '.join(fparts)} "
|
|
1192
|
+
f"(first poll, no answer in hand{tail})"
|
|
1193
|
+
)
|
|
1194
|
+
elif skipped:
|
|
1195
|
+
print(f" forecast error : — {skipped} step(s) stamped late, not scored")
|
|
1196
|
+
else:
|
|
1197
|
+
print(" forecast error : — no step has started since the ledger was added")
|
|
1198
|
+
# ...and the rungs that are scored but never picked. Their own line, because it has to be
|
|
1199
|
+
# visible that the pane is carrying an experiment and that none of it moves a row on
|
|
1200
|
+
# screen: `recent` is judged here and can be picked by nothing.
|
|
1201
|
+
sparts = [f"{src} {fmt_rung(ferr[src])}" for src in SHADOW_RUNGS if ferr.get(src)]
|
|
1202
|
+
if sparts:
|
|
1203
|
+
print(f" shadow rungs : {' · '.join(sparts)} (scored, never picked)")
|
|
1204
|
+
else:
|
|
1205
|
+
print(" shadow rungs : — nothing scored yet (stamped into the vector, never picked)")
|
|
1206
|
+
# ...and whether a gap between two of those rungs is the rung or the sample. Only pairs
|
|
1207
|
+
# the log can actually judge are printed: a duel nobody has the steps for is not a draw,
|
|
1208
|
+
# and showing it as one would read as "these two rungs are equivalent".
|
|
1209
|
+
verdicts = duel_summaries(ledger_rows(log.get("tasks") or {}, now_est, model=model))
|
|
1210
|
+
if verdicts:
|
|
1211
|
+
for a_rung, b_rung, verdict in verdicts:
|
|
1212
|
+
print(f" rung duel : {duel_note(a_rung, b_rung, verdict)}")
|
|
1213
|
+
else:
|
|
1214
|
+
print(" rung duel : — no pair of rungs has been scored on the same steps yet")
|
|
1215
|
+
# ...and whether there is yet enough of that to re-choose the constants from the log
|
|
1216
|
+
# rather than from a replay of the journals. Both counts are what the constants' own
|
|
1217
|
+
# comments say they need; the second is the one that governs the pace bound, because the
|
|
1218
|
+
# clip is consulted on the first few steps of every list and changes nothing on most.
|
|
1219
|
+
rr = refit_readiness(log.get("tasks") or {}, model=model)
|
|
1220
|
+
if not rr["scored"]:
|
|
1221
|
+
print(" refit readiness : — no closed step carries a forecast yet")
|
|
1222
|
+
else:
|
|
1223
|
+
short = max(0, REFIT_MIN_SCORED - rr["scored"])
|
|
1224
|
+
head = f"{rr['scored']} closed step(s) scored"
|
|
1225
|
+
if short:
|
|
1226
|
+
head += f", {short} short of the {REFIT_MIN_SCORED} a median needs"
|
|
1227
|
+
else:
|
|
1228
|
+
head += f" — a median can be read from these ({REFIT_MIN_SCORED}+)"
|
|
1229
|
+
print(f" refit readiness : {head}")
|
|
1230
|
+
detail = (f"the bound was consulted on {rr['eligible']} of them and moved the number "
|
|
1231
|
+
f"on {rr['decided']}")
|
|
1232
|
+
if rr["spans_needed"]:
|
|
1233
|
+
print(f" {detail}; judging the clip needs ~"
|
|
1234
|
+
f"{REFIT_MIN_DECIDED} decided, which at this rate is ~{rr['spans_needed']} "
|
|
1235
|
+
f"spans")
|
|
1236
|
+
elif rr["decided"]:
|
|
1237
|
+
print(f" {detail}; too few to say how many spans judging the "
|
|
1238
|
+
f"clip would take")
|
|
1239
|
+
else:
|
|
1240
|
+
print(f" {detail} — the clip has not changed a single number "
|
|
1241
|
+
f"here yet")
|
|
1242
|
+
# whose steps those are: the memory is per model, so a number without its model is
|
|
1243
|
+
# not auditable — a fast model remembered here must not project a slow one
|
|
1244
|
+
if model:
|
|
1245
|
+
print(f" remembered from : {model}")
|
|
1246
|
+
for t in [t for t in todos if not t.get("completed")][:5]:
|
|
1247
|
+
task = str(t.get("task", ""))
|
|
1248
|
+
bucket = step_shape_bucket(times, task)
|
|
1249
|
+
entry = sized_entry(shapes, bucket)
|
|
1250
|
+
samples = len(step_spans_ms(times, todos, now_est))
|
|
1251
|
+
blended = pending_blend_ms(task, pace, calls_mem)
|
|
1252
|
+
if entry:
|
|
1253
|
+
source = f"size {bucket}, {entry.get('n')} sample(s)"
|
|
1254
|
+
elif blended:
|
|
1255
|
+
source = f"label blend, {label_class(task)}"
|
|
1256
|
+
else:
|
|
1257
|
+
source = f"list pace, {samples} sample(s)" if samples else "list pace"
|
|
1258
|
+
spread = estimate_spread_ms(times, task, shapes, bucket) or pace_spread
|
|
1259
|
+
print(
|
|
1260
|
+
f" estimate : {_clip(task, 44)} "
|
|
1261
|
+
f"{fmt_estimate_spread(estimate_for(times, task, pace, shapes, calls_mem), spread)}"
|
|
1262
|
+
f" ({source})"
|
|
1263
|
+
)
|
|
1264
|
+
print(f" scratch : {scratch_size()} bytes in {SCRATCH}")
|
|
1265
|
+
if STATE_NOTE == "moved":
|
|
1266
|
+
print(f" state : {SCRATCH} (moved from {LEGACY_SCRATCH})")
|
|
1267
|
+
elif STATE_NOTE in ("live", "failed"):
|
|
1268
|
+
print(f" state : {SCRATCH} — "
|
|
1269
|
+
+ ("a watcher or the keeper still holds it; stop them to finish the move to "
|
|
1270
|
+
f"{STATE_DIR}" if STATE_NOTE == "live" else f"could not move to {STATE_DIR}"))
|
|
1271
|
+
return 0
|
|
1272
|
+
|
|
1273
|
+
|
|
1274
|
+
def cmd_prune(args) -> int:
|
|
1275
|
+
report = prune_scratch(
|
|
1276
|
+
max_records=args.max_records,
|
|
1277
|
+
max_age_days=args.max_age_days,
|
|
1278
|
+
log_cap=(args.log_cap_kb * 1024) if args.log_cap_kb is not None else None,
|
|
1279
|
+
force=True,
|
|
1280
|
+
)
|
|
1281
|
+
if args.json:
|
|
1282
|
+
json.dump(
|
|
1283
|
+
{"scratch": SCRATCH, "scratch_bytes": scratch_size(), **report},
|
|
1284
|
+
sys.stdout,
|
|
1285
|
+
ensure_ascii=False,
|
|
1286
|
+
)
|
|
1287
|
+
sys.stdout.write("\n")
|
|
1288
|
+
return 0
|
|
1289
|
+
c = paint(use_color())
|
|
1290
|
+
trunc = report["log_truncated_bytes"]
|
|
1291
|
+
print(f"{c('1', 'fbtodo prune')} {c('2', SCRATCH)}")
|
|
1292
|
+
print(f" task records : kept {report['records_kept']}, removed {report['records_removed']}")
|
|
1293
|
+
print(f" temp files swept : {report['temp_removed']}")
|
|
1294
|
+
print(f" daemon log : {'truncated ' + str(trunc) + ' bytes' if trunc else 'within cap'}")
|
|
1295
|
+
print(f" scratch after : {scratch_size()} bytes")
|
|
1296
|
+
return 0
|
|
1297
|
+
|
|
1298
|
+
|
|
1299
|
+
# Which rungs are worth judging against each other, in the order a reader reads them. The
|
|
1300
|
+
# three the pane can pick between, paired: the question "is this one better" only has a
|
|
1301
|
+
# meaning between two rungs that were both asked the same steps.
|
|
1302
|
+
DUEL_PAIRS = (("pace", "blend"), ("pace", "shape"), ("blend", "shape"),
|
|
1303
|
+
("pace", "recent")) # ...and the shadow rung against the one it would replace
|
|
1304
|
+
|
|
1305
|
+
|
|
1306
|
+
def ledger_rows(
|
|
1307
|
+
tasks: dict,
|
|
1308
|
+
now_ms: int | None = None,
|
|
1309
|
+
days: float | None = None,
|
|
1310
|
+
model: str | None = None,
|
|
1311
|
+
) -> list[dict]:
|
|
1312
|
+
"""One row per step that carries a forecast vector, newest first.
|
|
1313
|
+
|
|
1314
|
+
`fbtodo status` gives the SCOREBOARD; this gives the numbers it was computed from, which
|
|
1315
|
+
is the only way to see why a rung scores what it scores: the vector as it stood before the
|
|
1316
|
+
work started, the span it was scored against, and the factor error of each rung on it.
|
|
1317
|
+
A row carries no errors when there is nothing to score it against — the step is still
|
|
1318
|
+
running, its span is under the evidence floor, or the vector was stamped too late to be a
|
|
1319
|
+
forecast — and `why` says which of those it is rather than letting the row read as a miss.
|
|
1320
|
+
"""
|
|
1321
|
+
now_ms = now_ms or int(time.time() * 1000)
|
|
1322
|
+
days = HISTORY_MAX_AGE_DAYS if days is None else days
|
|
1323
|
+
cutoff = now_ms - int(days * 86400 * 1000)
|
|
1324
|
+
rows: list[dict] = []
|
|
1325
|
+
for key, rec in (tasks or {}).items():
|
|
1326
|
+
if not isinstance(rec, dict):
|
|
1327
|
+
continue
|
|
1328
|
+
fc = rec.get("fc")
|
|
1329
|
+
if not isinstance(fc, dict):
|
|
1330
|
+
continue
|
|
1331
|
+
started, stopped = rec.get("started_ms"), rec.get("done_ms")
|
|
1332
|
+
if not started or started < cutoff:
|
|
1333
|
+
continue
|
|
1334
|
+
session, _, label = str(key).partition("\x1f")
|
|
1335
|
+
if model and (rec.get("model") or "") != model:
|
|
1336
|
+
continue
|
|
1337
|
+
span = (stopped - started) if (stopped and stopped > started) else None
|
|
1338
|
+
late = int(fc.get("late") or 0)
|
|
1339
|
+
# How far into the step the vector was written. A step that is ticked, unticked and
|
|
1340
|
+
# worked again restarts its clock while KEEPING the vector it earned on the first
|
|
1341
|
+
# sighting, so the stamp can read as negative — it was written before this run began,
|
|
1342
|
+
# which is the safest kind of forecast there is, but it is not what a reader assumes.
|
|
1343
|
+
stamp_in = (int(fc.get("at") or 0) - started) if fc.get("at") else None
|
|
1344
|
+
preds = {}
|
|
1345
|
+
for rung in SCORED_RUNGS:
|
|
1346
|
+
try:
|
|
1347
|
+
value = int(fc.get(rung) or 0)
|
|
1348
|
+
except (TypeError, ValueError):
|
|
1349
|
+
value = 0
|
|
1350
|
+
if value > 0:
|
|
1351
|
+
preds[rung] = value
|
|
1352
|
+
errs: dict[str, float] = {}
|
|
1353
|
+
why = ""
|
|
1354
|
+
if late:
|
|
1355
|
+
why = f"stamped {short_duration(late, seconds=True)} in — late, not scored"
|
|
1356
|
+
elif span is None:
|
|
1357
|
+
why = "no outcome yet"
|
|
1358
|
+
elif span < label_floor_ms():
|
|
1359
|
+
why = f"span under the {short_duration(label_floor_ms())} floor — not scored"
|
|
1360
|
+
else:
|
|
1361
|
+
errs = {r: round(max(p / span, span / p), 2) for r, p in preds.items()}
|
|
1362
|
+
ver = f"v{fc.get('v')}" if fc.get("v") else ""
|
|
1363
|
+
if late or stamp_in is None:
|
|
1364
|
+
# a late row's `why` already says when the stamp landed, so do not say it twice
|
|
1365
|
+
note = ver
|
|
1366
|
+
elif stamp_in < 0:
|
|
1367
|
+
note = f"{ver} · stamped {short_duration(-stamp_in, seconds=True)} before this run"
|
|
1368
|
+
elif stamp_in < 1000:
|
|
1369
|
+
# the normal case: the poll that started the clock wrote the vector on the same
|
|
1370
|
+
# pass, and `short_duration(0)` is an empty string — which read as "stamped in"
|
|
1371
|
+
note = f"{ver} · stamped on the first poll"
|
|
1372
|
+
else:
|
|
1373
|
+
note = f"{ver} · stamped {short_duration(stamp_in, seconds=True)} in"
|
|
1374
|
+
rows.append({
|
|
1375
|
+
"session": session, "label": label, "model": rec.get("model") or "",
|
|
1376
|
+
"started_ms": started, "span_ms": span, "fc_at": int(fc.get("at") or 0),
|
|
1377
|
+
"v": fc.get("v") or "", "pick": fc.get("pick") or "",
|
|
1378
|
+
"preds": preds, "errors": errs, "late_ms": late, "stamp_in_ms": stamp_in,
|
|
1379
|
+
"scored": bool(errs), "why": why, "note": note,
|
|
1380
|
+
})
|
|
1381
|
+
rows.sort(key=lambda r: -r["started_ms"])
|
|
1382
|
+
return rows
|
|
1383
|
+
|
|
1384
|
+
|
|
1385
|
+
def duel_summaries(rows: list[dict]) -> list[tuple]:
|
|
1386
|
+
"""The rung pairs this set of ledger rows can actually judge, with their verdicts.
|
|
1387
|
+
|
|
1388
|
+
Returned rather than printed so `doctor` and `fbtodo ledger` say the same sentence over
|
|
1389
|
+
the same evidence — and so the pair nobody has the steps for is left out of both instead
|
|
1390
|
+
of being reported as a draw. The rows are the whole window, not the page a `--limit`
|
|
1391
|
+
shows: the verdict is about the log, and truncating the display must not change it.
|
|
1392
|
+
"""
|
|
1393
|
+
out = []
|
|
1394
|
+
for a_rung, b_rung in DUEL_PAIRS:
|
|
1395
|
+
verdict = rung_duel(rows, a_rung, b_rung)
|
|
1396
|
+
if verdict["steps"]:
|
|
1397
|
+
out.append((a_rung, b_rung, verdict))
|
|
1398
|
+
return out
|
|
1399
|
+
|
|
1400
|
+
|
|
1401
|
+
def fmt_ledger(
|
|
1402
|
+
rows: list[dict],
|
|
1403
|
+
now_ms: int,
|
|
1404
|
+
days: float | None = None,
|
|
1405
|
+
limit: int = 20,
|
|
1406
|
+
color: bool = False,
|
|
1407
|
+
empty: str = "",
|
|
1408
|
+
) -> str:
|
|
1409
|
+
"""The `fbtodo ledger` report as text — split out from the command so a fixture can
|
|
1410
|
+
assert on what a reader would actually see, honesty notes included."""
|
|
1411
|
+
c = paint(color)
|
|
1412
|
+
days = HISTORY_MAX_AGE_DAYS if days is None else days
|
|
1413
|
+
out = [f"{c('1', 'fbtodo ledger')} {c('2', TASKS_PATH)}"]
|
|
1414
|
+
if not rows:
|
|
1415
|
+
out.append(empty or (
|
|
1416
|
+
" no step carries a forecast yet — one is written on the first poll "
|
|
1417
|
+
"that finds a step running, and scored when that step closes"
|
|
1418
|
+
))
|
|
1419
|
+
return "\n".join(out)
|
|
1420
|
+
scored = sum(1 for r in rows if r["scored"])
|
|
1421
|
+
late = sum(1 for r in rows if r["late_ms"])
|
|
1422
|
+
out.append(f" {len(rows)} step(s) with a forecast · {scored} scored · {late} stamped "
|
|
1423
|
+
f"late (not scored) · evidence floor {short_duration(label_floor_ms())} · "
|
|
1424
|
+
f"last {days:g}d")
|
|
1425
|
+
# ...and the verdict on the rungs those rows are evidence for, above them: the rows say
|
|
1426
|
+
# what each step predicted, and this says what the difference between two of them is worth
|
|
1427
|
+
for a_rung, b_rung, verdict in duel_summaries(rows):
|
|
1428
|
+
out.append(f" rung duel: {duel_note(a_rung, b_rung, verdict)}")
|
|
1429
|
+
shown = rows if not limit else rows[:limit]
|
|
1430
|
+
for r in shown:
|
|
1431
|
+
span = short_duration(r["span_ms"], seconds=True) if r["span_ms"] else "—"
|
|
1432
|
+
label = r["label"] if len(r["label"]) <= 62 else r["label"][:61] + "…"
|
|
1433
|
+
out.append(f" {span:<8} {label:<62} {c('2', fmt_age(r['started_ms'], now_ms))}")
|
|
1434
|
+
bits = []
|
|
1435
|
+
# the shipped rungs in their reading order, then any shadow rung the row actually
|
|
1436
|
+
# carries: a row recorded before the shadow rung existed is not shown a `recent —` it
|
|
1437
|
+
# never had, and a row that has one shows it beside the rungs it is competing with
|
|
1438
|
+
shown_rungs = list(SHIPPED_RUNGS) + [x for x in SHADOW_RUNGS if x in r["preds"]]
|
|
1439
|
+
for rung in shown_rungs:
|
|
1440
|
+
pred = r["preds"].get(rung)
|
|
1441
|
+
if pred is None:
|
|
1442
|
+
if rung != "pace":
|
|
1443
|
+
bits.append(f"{rung} —")
|
|
1444
|
+
continue
|
|
1445
|
+
text = f"{rung} {short_duration(pred, seconds=True)}"
|
|
1446
|
+
miss = r["errors"].get(rung)
|
|
1447
|
+
if miss is not None:
|
|
1448
|
+
text += " ×%.2f" % miss
|
|
1449
|
+
bits.append(text)
|
|
1450
|
+
picked = f" · pick {r['pick']}" if r["pick"] else ""
|
|
1451
|
+
detail = " · ".join(x for x in (r["model"], r["note"], r["why"]) if x)
|
|
1452
|
+
suffix = f" ({detail})" if detail else ""
|
|
1453
|
+
out.append(f" {c('2', ' · '.join(bits) + picked)}{c('2', suffix)}")
|
|
1454
|
+
if len(shown) < len(rows):
|
|
1455
|
+
out.append(f" … {len(rows) - len(shown)} more (--limit 0 for all, --days N to widen)")
|
|
1456
|
+
return "\n".join(out)
|
|
1457
|
+
|
|
1458
|
+
|
|
1459
|
+
def cmd_ledger(args) -> int:
|
|
1460
|
+
"""Every step's forecast vector, next to the outcome it was scored against.
|
|
1461
|
+
|
|
1462
|
+
The ledger is the honest half of the scoreboard: each step's prediction is written once,
|
|
1463
|
+
on the first poll that saw it running, so the rungs are compared before anything about
|
|
1464
|
+
the step's size was known. `fbtodo status` prints the summary; this prints the rows,
|
|
1465
|
+
newest first, with each rung's factor error — and says when a row could not be scored
|
|
1466
|
+
rather than showing it as a miss. Read-only: it changes nothing. `--days N` (default 60),
|
|
1467
|
+
`--limit N` (default 20, 0 for all), `--model M`, `--json` for the same fields.
|
|
1468
|
+
"""
|
|
1469
|
+
now_ms = int(time.time() * 1000)
|
|
1470
|
+
rows = ledger_rows(load_tasklog().get("tasks") or {}, now_ms, args.days, args.model)
|
|
1471
|
+
if args.json:
|
|
1472
|
+
json.dump({"at": now_ms, "count": len(rows), "ledger": rows}, sys.stdout,
|
|
1473
|
+
ensure_ascii=False)
|
|
1474
|
+
sys.stdout.write("\n")
|
|
1475
|
+
return 0
|
|
1476
|
+
if args.quiet:
|
|
1477
|
+
return 0
|
|
1478
|
+
filters = []
|
|
1479
|
+
if args.model:
|
|
1480
|
+
filters.append(f"--model {args.model}")
|
|
1481
|
+
if args.days is not None:
|
|
1482
|
+
filters.append(f"--days {args.days:g}")
|
|
1483
|
+
empty = (f" no step matches {' '.join(filters)} — drop the filter to see what there is"
|
|
1484
|
+
if filters else "")
|
|
1485
|
+
sys.stdout.write(
|
|
1486
|
+
fmt_ledger(rows, now_ms, args.days, args.limit, use_color(), empty) + "\n"
|
|
1487
|
+
)
|
|
1488
|
+
return 0
|
|
1489
|
+
|
|
1490
|
+
|
|
1491
|
+
def cmd_pane(args) -> int:
|
|
1492
|
+
cwd = os.path.realpath(os.getcwd())
|
|
1493
|
+
if not args.no_daemon:
|
|
1494
|
+
ensure_daemon(args)
|
|
1495
|
+
if not sys.stdout.isatty() and not args.once:
|
|
1496
|
+
print("refusing to poll without a TTY (use `fbtodo snap`)", file=sys.stderr)
|
|
1497
|
+
return EX_CODES["usage"]
|
|
1498
|
+
|
|
1499
|
+
color = use_color()
|
|
1500
|
+
hide = False
|
|
1501
|
+
saw_instance = False
|
|
1502
|
+
last_sig = None
|
|
1503
|
+
last_draw = 0.0
|
|
1504
|
+
last_frame = None
|
|
1505
|
+
tick = max(1.0, args.tick)
|
|
1506
|
+
stale_after_s = max(0.0, args.stale_after) * 60.0
|
|
1507
|
+
last_act = None
|
|
1508
|
+
last_activity = time.time()
|
|
1509
|
+
# Poll and paint run on separate clocks. A poll is expensive — on the NAS it is an
|
|
1510
|
+
# ssh round trip plus a remote probe — while a repaint is free, so the pane wakes
|
|
1511
|
+
# often enough to move its own numbers at 1Hz and only reaches for the store every
|
|
1512
|
+
# `args.interval`. Sleeping the whole gap is what made a NAS pane look stuck: its
|
|
1513
|
+
# clock and its "N ago" could not move until the next ssh had come back.
|
|
1514
|
+
WAKE = 0.25
|
|
1515
|
+
next_poll = 0.0
|
|
1516
|
+
state = watching = inst = None
|
|
1517
|
+
|
|
1518
|
+
def restore(*_a):
|
|
1519
|
+
if hide:
|
|
1520
|
+
sys.stderr.write("\x1b[?25h")
|
|
1521
|
+
sys.stderr.flush()
|
|
1522
|
+
raise SystemExit(130)
|
|
1523
|
+
|
|
1524
|
+
for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
|
|
1525
|
+
signal.signal(sig, restore)
|
|
1526
|
+
|
|
1527
|
+
def draw(text: str) -> None:
|
|
1528
|
+
# Row by row against the last paint (see `pane_repaint`): the clock moves every tick and
|
|
1529
|
+
# only its own row changes, so a full clear-and-paint would flash the whole pane to move
|
|
1530
|
+
# one line — and would write kilobytes a second to move it.
|
|
1531
|
+
nonlocal last_frame
|
|
1532
|
+
frame = text.splitlines()
|
|
1533
|
+
rows = _shutil.get_terminal_size(fallback=(80, 24)).lines
|
|
1534
|
+
out = pane_repaint(last_frame, frame, rows)
|
|
1535
|
+
last_frame = frame
|
|
1536
|
+
if not out:
|
|
1537
|
+
return
|
|
1538
|
+
sys.stdout.write(out)
|
|
1539
|
+
sys.stdout.flush()
|
|
1540
|
+
|
|
1541
|
+
try:
|
|
1542
|
+
if not args.once:
|
|
1543
|
+
sys.stdout.write("\x1b[?25l")
|
|
1544
|
+
sys.stdout.flush()
|
|
1545
|
+
hide = True
|
|
1546
|
+
while True:
|
|
1547
|
+
now = time.time()
|
|
1548
|
+
if state is None or now >= next_poll:
|
|
1549
|
+
remote = args.source == "nas"
|
|
1550
|
+
if remote:
|
|
1551
|
+
inst = None # decided from the state below: a live remote session, or nothing
|
|
1552
|
+
else:
|
|
1553
|
+
inst, _ = find_instance(cwd, args.watch_pid, args.instance_of)
|
|
1554
|
+
st = read_json(STATE_PATH, None)
|
|
1555
|
+
if state_is_fresh(st, max(HEARTBEAT_GRACE, args.interval * 3)) and state_matches_request(st, args):
|
|
1556
|
+
# Only this source's watcher state: a `-s nas` pane must not render the
|
|
1557
|
+
# local CLI watcher's list just because that one happens to be fresh.
|
|
1558
|
+
state = st
|
|
1559
|
+
watching = st.get("daemon_pid") or daemon_pid()
|
|
1560
|
+
else:
|
|
1561
|
+
state = finish_probe(snapshot(args, cwd=cwd, instance_pid=inst))
|
|
1562
|
+
# No watcher is serving THIS request (that is why we snapshotted), so name
|
|
1563
|
+
# one only if the cached state actually describes it: a `-s nas` pane was
|
|
1564
|
+
# crediting the local CLI watcher for a remote session it never watched.
|
|
1565
|
+
watching = daemon_pid() if (st and state_matches_request(st, args)) else None
|
|
1566
|
+
if remote:
|
|
1567
|
+
# A live session on the NAS is the "instance" this pane follows; once it is gone
|
|
1568
|
+
# the pane closes itself (and until one appears, it says it is waiting).
|
|
1569
|
+
inst = True if state.get("instance_alive") else None
|
|
1570
|
+
if not state.get("error") and not state.get("task_times"):
|
|
1571
|
+
# either no watcher, or one from before timings existed — track
|
|
1572
|
+
# here so task clocks are never silently missing
|
|
1573
|
+
state = track_tasks(state)
|
|
1574
|
+
next_poll = time.time() + max(0.2, args.interval)
|
|
1575
|
+
now = time.time()
|
|
1576
|
+
# “stale” means the *store* stopped moving, not that the list stopped:
|
|
1577
|
+
# the journal is appended every iteration while the agent works, so a
|
|
1578
|
+
# long single step keeps the pane alive while a finished or abandoned
|
|
1579
|
+
# session lets it expire.
|
|
1580
|
+
act = (
|
|
1581
|
+
state.get("store_mtime_ms"),
|
|
1582
|
+
state.get("iteration"),
|
|
1583
|
+
state.get("list_id"),
|
|
1584
|
+
state.get("done"),
|
|
1585
|
+
state.get("total"),
|
|
1586
|
+
state.get("session"),
|
|
1587
|
+
state.get("goal"),
|
|
1588
|
+
state.get("now"),
|
|
1589
|
+
)
|
|
1590
|
+
if act != last_act:
|
|
1591
|
+
last_act, last_activity = act, now
|
|
1592
|
+
idle_s = now - last_activity
|
|
1593
|
+
text = render(
|
|
1594
|
+
state,
|
|
1595
|
+
color,
|
|
1596
|
+
watching=watching,
|
|
1597
|
+
width=width_of_default(),
|
|
1598
|
+
idle_s=idle_s,
|
|
1599
|
+
stale_after_s=stale_after_s,
|
|
1600
|
+
goal_lines=args.goal_lines,
|
|
1601
|
+
height=_shutil.get_terminal_size(fallback=(80, 24)).lines,
|
|
1602
|
+
)
|
|
1603
|
+
if args.once:
|
|
1604
|
+
print(text)
|
|
1605
|
+
return 0
|
|
1606
|
+
# repaint when the *content* changes, and otherwise on a tick so the
|
|
1607
|
+
# clock in the footer keeps moving — a still pane must still look alive
|
|
1608
|
+
sig = (
|
|
1609
|
+
state.get("list_id"),
|
|
1610
|
+
state.get("list_version"),
|
|
1611
|
+
state.get("done"),
|
|
1612
|
+
state.get("total"),
|
|
1613
|
+
state.get("backend"),
|
|
1614
|
+
state.get("session"),
|
|
1615
|
+
state.get("goal"),
|
|
1616
|
+
state.get("now"),
|
|
1617
|
+
state.get("cleared"),
|
|
1618
|
+
state.get("error"),
|
|
1619
|
+
watching,
|
|
1620
|
+
)
|
|
1621
|
+
if sig != last_sig or now - last_draw >= (
|
|
1622
|
+
1.0 if has_running_clock(state, int(now * 1000)) else tick
|
|
1623
|
+
):
|
|
1624
|
+
draw(text)
|
|
1625
|
+
last_sig, last_draw = sig, now
|
|
1626
|
+
if stale_after_s and idle_s >= stale_after_s:
|
|
1627
|
+
draw(text)
|
|
1628
|
+
print(c_stale_notice(color, idle_s, stale_after_s))
|
|
1629
|
+
return 0
|
|
1630
|
+
if inst is not None:
|
|
1631
|
+
saw_instance = True
|
|
1632
|
+
elif saw_instance and not args.wait:
|
|
1633
|
+
# the instance this pane was watching is gone: don't linger.
|
|
1634
|
+
# --wait is for a pane whose real lifetime is the shell that owns
|
|
1635
|
+
# it (an ssh into the remote host: freebuff can be run again in it).
|
|
1636
|
+
draw(text)
|
|
1637
|
+
print(c_notice(color))
|
|
1638
|
+
return 0
|
|
1639
|
+
time.sleep(min(WAKE, max(0.05, next_poll - time.time())))
|
|
1640
|
+
except KeyboardInterrupt:
|
|
1641
|
+
restore()
|
|
1642
|
+
return 0
|
|
1643
|
+
finally:
|
|
1644
|
+
if hide:
|
|
1645
|
+
sys.stderr.write("\x1b[?25h")
|
|
1646
|
+
sys.stderr.flush()
|
|
1647
|
+
|
|
1648
|
+
|
|
1649
|
+
def c_notice(color: bool) -> str:
|
|
1650
|
+
return paint(color)("2", "freebuff instance exited — fbtodo pane closing")
|
|
1651
|
+
|
|
1652
|
+
|
|
1653
|
+
def c_stale_notice(color: bool, idle_s: float, limit_s: float) -> str:
|
|
1654
|
+
return paint(color)(
|
|
1655
|
+
"2",
|
|
1656
|
+
f"todo list idle {short_duration((idle_s or 0) * 1000)} "
|
|
1657
|
+
f"(limit {short_duration(limit_s * 1000)}) — fbtodo pane closing",
|
|
1658
|
+
)
|
|
1659
|
+
|
|
1660
|
+
|
|
1661
|
+
def cmd_snap(args) -> int:
|
|
1662
|
+
state = finish_probe(snapshot(args))
|
|
1663
|
+
if not state.get("error"):
|
|
1664
|
+
state = track_tasks(state)
|
|
1665
|
+
print(render(state, use_color(), width=width_of_default(), goal_lines=args.goal_lines))
|
|
1666
|
+
return 0
|
|
1667
|
+
|
|
1668
|
+
|
|
1669
|
+
def cmd_json(args) -> int:
|
|
1670
|
+
st = read_json(STATE_PATH, None)
|
|
1671
|
+
state = (
|
|
1672
|
+
st
|
|
1673
|
+
if state_is_fresh(st) and state_matches_request(st, args)
|
|
1674
|
+
else finish_probe(snapshot(args))
|
|
1675
|
+
)
|
|
1676
|
+
if not state.get("error") and not state.get("task_times"):
|
|
1677
|
+
state = track_tasks(state)
|
|
1678
|
+
json.dump(state, sys.stdout, ensure_ascii=False)
|
|
1679
|
+
sys.stdout.write("\n")
|
|
1680
|
+
return 0
|
|
1681
|
+
|
|
1682
|
+
|
|
1683
|
+
def cmd_bar(args) -> int:
|
|
1684
|
+
st = read_json(STATE_PATH, None)
|
|
1685
|
+
state = (
|
|
1686
|
+
st
|
|
1687
|
+
if state_is_fresh(st) and state_matches_request(st, args)
|
|
1688
|
+
else finish_probe(snapshot(args))
|
|
1689
|
+
)
|
|
1690
|
+
print(bar_text(state))
|
|
1691
|
+
return 0
|
|
1692
|
+
|
|
1693
|
+
|
|
1694
|
+
def nas_anchor_in_window(window: str | None, nas_host: str | None) -> str | None:
|
|
1695
|
+
"""The ssh pane a NAS list in THIS window belongs under, when exactly one login is in it.
|
|
1696
|
+
|
|
1697
|
+
Only panes already on this server are looked at — no ssh, no marker: a pin set at the
|
|
1698
|
+
keyboard has to be answered at keyboard speed, and the watcher does the authoritative
|
|
1699
|
+
placement while a session runs anyway. Several logins in one window is ambiguous (which
|
|
1700
|
+
one runs `fb` takes the far side's marker), so the pin is left to the watcher then.
|
|
1701
|
+
"""
|
|
1702
|
+
if not window or not nas_host:
|
|
1703
|
+
return None
|
|
1704
|
+
rows, table = pane_rows(), process_table()
|
|
1705
|
+
here = [
|
|
1706
|
+
cand["pane"] for cand in ssh_session_candidates(nas_host, rows, table)
|
|
1707
|
+
if window_of(cand["pane"]) == window
|
|
1708
|
+
]
|
|
1709
|
+
return here[0] if len(here) == 1 else None
|
|
1710
|
+
|
|
1711
|
+
|
|
1712
|
+
def apply_pin(window: str, role: str = "both", nas_host: str | None = None) -> list[str]:
|
|
1713
|
+
"""Put this window's list panes where the pin now says; their ids back.
|
|
1714
|
+
|
|
1715
|
+
Run when a pin is set, so the effect is visible at once instead of on the next keeper
|
|
1716
|
+
pass, and with the same two halves the keeper and the NAS watcher enforce: the side the
|
|
1717
|
+
pane sits on, and the size it holds. `role` limits it to one of them — that is what
|
|
1718
|
+
`--role nas` is for, and a window holding both a session and an ssh has two panes.
|
|
1719
|
+
"""
|
|
1720
|
+
rows, table = pane_rows(), process_table()
|
|
1721
|
+
rects = pane_rects()
|
|
1722
|
+
touched = []
|
|
1723
|
+
for one in (("local", "nas") if role == "both" else (role,)):
|
|
1724
|
+
layout = pane_layout(window, one)
|
|
1725
|
+
anchors = []
|
|
1726
|
+
if one == "local":
|
|
1727
|
+
for inst, win in session_windows(rows, table).items():
|
|
1728
|
+
if win != window:
|
|
1729
|
+
continue
|
|
1730
|
+
inst_pane = freebuff_pane_id(inst, rows=rows, table=table)
|
|
1731
|
+
if inst_pane:
|
|
1732
|
+
anchors.append((inst_pane, local_pane_ids(inst, rows, table)))
|
|
1733
|
+
else:
|
|
1734
|
+
anchor = nas_anchor_in_window(window, nas_host or NAS_HOST)
|
|
1735
|
+
if anchor:
|
|
1736
|
+
anchors.append(
|
|
1737
|
+
(anchor, [pane for pane in nas_pane_ids() if window_of(pane) == window])
|
|
1738
|
+
)
|
|
1739
|
+
for anchor, panes in anchors:
|
|
1740
|
+
for pane in panes:
|
|
1741
|
+
if place_pane_beside(pane, anchor, rects, layout["side"]):
|
|
1742
|
+
touched.append(pane)
|
|
1743
|
+
rects = pane_rects()
|
|
1744
|
+
if resize_pane_to(pane, rects, layout["side"], layout["size"]):
|
|
1745
|
+
touched.append(pane)
|
|
1746
|
+
rects = pane_rects()
|
|
1747
|
+
return touched
|
|
1748
|
+
|
|
1749
|
+
def pane_reason(pane, role: str, window: str | None, anchor: str | None, rects: dict,
|
|
1750
|
+
layout: dict, anchor_note: str) -> dict:
|
|
1751
|
+
"""What decided one list pane's place: the rule, the anchor, and the numbers.
|
|
1752
|
+
|
|
1753
|
+
Four answers, always the same four, because that is what a person asks about a pane:
|
|
1754
|
+
which pane and role; where it should be (the anchor it is placed against, and the side
|
|
1755
|
+
that decides which edge of it); what side and size are in force and which source
|
|
1756
|
+
supplied them; and whether it is actually there.
|
|
1757
|
+
"""
|
|
1758
|
+
here = rects.get(pane) if pane else None
|
|
1759
|
+
there = rects.get(anchor) if anchor else None
|
|
1760
|
+
# A pane in ANOTHER window than its session's is an arrangement the owner made, and both
|
|
1761
|
+
# the keeper and the watcher leave it alone — so a report that called it misplaced would
|
|
1762
|
+
# be sending somebody to fix what is not broken (`place_pane_beside`).
|
|
1763
|
+
elsewhere = bool(here and there and here["window"] != there["window"])
|
|
1764
|
+
# The side as it stands for this pane, not only as the file remembers it: a pane kept on
|
|
1765
|
+
# the other axis of its session reads `seen` until the keeper has filed it (kept_layout).
|
|
1766
|
+
kept = kept_layout(pane, anchor, rects, layout) if pane else layout
|
|
1767
|
+
placed = bool(pane and anchor) and placed_beside(anchor, pane, rects, kept["side"])
|
|
1768
|
+
return {
|
|
1769
|
+
"pane": pane,
|
|
1770
|
+
"role": role,
|
|
1771
|
+
"elsewhere": elsewhere,
|
|
1772
|
+
"window_id": window,
|
|
1773
|
+
"window": layout["window"] or (window or ""),
|
|
1774
|
+
"anchor": anchor,
|
|
1775
|
+
"anchor_note": anchor_note,
|
|
1776
|
+
"side": kept["side"],
|
|
1777
|
+
"side_source": kept["side_source"],
|
|
1778
|
+
"size": layout["size"],
|
|
1779
|
+
"size_source": layout["size_source"],
|
|
1780
|
+
"placed": placed,
|
|
1781
|
+
"now": (f"L{here['left']} T{here['top']} {here['width']}x{here['height']}" if here else ""),
|
|
1782
|
+
"expected": (f"below {anchor}" if kept["side"] == "v" else f"beside {anchor}")
|
|
1783
|
+
if anchor else "no pane to place it against",
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
|
|
1787
|
+
def why_records(pins: dict | None = None, last: dict | None = None) -> list[dict]:
|
|
1788
|
+
"""One record per list pane on this server — and per session with no pane at all.
|
|
1789
|
+
|
|
1790
|
+
The missing ones are records too (pane None): "why is there no list here" is the same
|
|
1791
|
+
question as "why is it there", and the answer is the same layout — the pane keeper has
|
|
1792
|
+
not opened it yet, at the side and size the sources below it say.
|
|
1793
|
+
"""
|
|
1794
|
+
rows, table = pane_rows(), process_table()
|
|
1795
|
+
rects = pane_rects()
|
|
1796
|
+
pins = load_pins() if pins is None else pins
|
|
1797
|
+
last = load_last() if last is None else last
|
|
1798
|
+
records = []
|
|
1799
|
+
for inst, window in session_windows(rows, table).items():
|
|
1800
|
+
layout = pane_layout(window, "local", pins, last)
|
|
1801
|
+
anchor = freebuff_pane_id(inst, rows=rows, table=table)
|
|
1802
|
+
panes = local_pane_ids(inst, rows, table) or [None]
|
|
1803
|
+
for pane in panes:
|
|
1804
|
+
records.append(
|
|
1805
|
+
pane_reason(pane, "local", window, anchor, rects, layout, f"freebuff pid {inst}")
|
|
1806
|
+
)
|
|
1807
|
+
# The NAS half needs no ssh: the panes are on this server, and the ssh anchor is the
|
|
1808
|
+
# login already sitting here. WHICH login runs `fb` over there takes the marker on the
|
|
1809
|
+
# far side, so with several candidates the record says which rule the watcher applies.
|
|
1810
|
+
cands = ssh_session_candidates(NAS_HOST)
|
|
1811
|
+
nas_window = None
|
|
1812
|
+
nas_panes = nas_pane_ids()
|
|
1813
|
+
for pane in nas_panes:
|
|
1814
|
+
if nas_window is None:
|
|
1815
|
+
nas_window = window_of(pane)
|
|
1816
|
+
layout = pane_layout(nas_window, "nas", pins, last)
|
|
1817
|
+
if len(cands) == 1:
|
|
1818
|
+
anchor, note = cands[0]["pane"], f"the only ssh login to {NAS_HOST}"
|
|
1819
|
+
elif cands:
|
|
1820
|
+
anchor, note = None, (
|
|
1821
|
+
f"{len(cands)} ssh logins ({', '.join(c['pane'] for c in cands)}) — "
|
|
1822
|
+
"the watcher picks the newest that began before the session"
|
|
1823
|
+
)
|
|
1824
|
+
else:
|
|
1825
|
+
anchor, note = None, f"no ssh login to {NAS_HOST} on this server"
|
|
1826
|
+
records.append(pane_reason(pane, "nas", nas_window, anchor, rects, layout, note))
|
|
1827
|
+
return records
|
|
1828
|
+
|
|
1829
|
+
|
|
1830
|
+
def cmd_why(args) -> int:
|
|
1831
|
+
"""Say why every list pane is where it is — the rule, the anchor, and the numbers.
|
|
1832
|
+
|
|
1833
|
+
The pane keeper moves panes and the NAS watcher opens them, and both answer to the same
|
|
1834
|
+
four things: the window's pin (that role's own, then the window's shared one), what the
|
|
1835
|
+
pane was left at last time, FBTODO_SPLIT / FBTODO_PANE_SIZE, and the built-in default.
|
|
1836
|
+
Which one won is invisible in a layout that merely looks right — and the first question
|
|
1837
|
+
when one is wrong — so this prints, per pane, the side and size in force WITH the source
|
|
1838
|
+
that supplied them, the pane it is placed against, and whether it is actually there.
|
|
1839
|
+
A pane the owner has moved to the other axis of its session is not one of the four
|
|
1840
|
+
sources winning but a decision being kept: it reads `seen (kept, not filed yet)` until
|
|
1841
|
+
the keeper has seen it twice and written it down as the remembered side (`kept_layout`).
|
|
1842
|
+
`--window TARGET` limits it to one window; `--json` is the same thing for a script.
|
|
1843
|
+
"""
|
|
1844
|
+
pins, last = load_pins(), load_last()
|
|
1845
|
+
records = why_records(pins, last)
|
|
1846
|
+
asked = (getattr(args, "window", None) or "").strip()
|
|
1847
|
+
if asked:
|
|
1848
|
+
want = window_of(asked)
|
|
1849
|
+
if not want:
|
|
1850
|
+
print(f"fbtodo why: no such window: {asked}", file=sys.stderr)
|
|
1851
|
+
return EX_CODES["nofile"]
|
|
1852
|
+
records = [rec for rec in records if rec["window_id"] == want]
|
|
1853
|
+
if args.json:
|
|
1854
|
+
json.dump(
|
|
1855
|
+
{
|
|
1856
|
+
"panes": records,
|
|
1857
|
+
"pins": pins,
|
|
1858
|
+
"last": last,
|
|
1859
|
+
"knobs": {
|
|
1860
|
+
"FBTODO_SPLIT": os.environ.get("FBTODO_SPLIT") or None,
|
|
1861
|
+
"FBTODO_PANE_SIZE": os.environ.get("FBTODO_PANE_SIZE") or None,
|
|
1862
|
+
},
|
|
1863
|
+
},
|
|
1864
|
+
sys.stdout, ensure_ascii=False,
|
|
1865
|
+
)
|
|
1866
|
+
sys.stdout.write("\n")
|
|
1867
|
+
return 0
|
|
1868
|
+
if args.quiet:
|
|
1869
|
+
return 0
|
|
1870
|
+
if not records:
|
|
1871
|
+
print("no list pane, and no session to put one beside")
|
|
1872
|
+
return 0
|
|
1873
|
+
for rec in records:
|
|
1874
|
+
rule = (
|
|
1875
|
+
f"side={rec['side']} ({source_note(rec['side_source'], rec['window'])})"
|
|
1876
|
+
f" size={rec['size']} ({source_note(rec['size_source'], rec['window'])})"
|
|
1877
|
+
)
|
|
1878
|
+
if not rec["pane"]:
|
|
1879
|
+
tail = "no list pane yet — a keeper pass opens it there"
|
|
1880
|
+
elif rec["elsewhere"]:
|
|
1881
|
+
tail = f"in another window than its session — left alone, by design {rec['now']}"
|
|
1882
|
+
else:
|
|
1883
|
+
tail = rec["expected"] + (
|
|
1884
|
+
", placed" if rec["placed"] else ", MISPLACED (a pass moves it back)"
|
|
1885
|
+
)
|
|
1886
|
+
tail += f" {rec['now']}"
|
|
1887
|
+
print(f"{rec['window']:<12} {rec['role']:<5} {rec['pane'] or '(none)':<6} {rule}")
|
|
1888
|
+
print(f"{'':<12} {'':<5} {'':<6} {tail} — {rec['anchor_note']}")
|
|
1889
|
+
bad = [
|
|
1890
|
+
rec["pane"] for rec in records
|
|
1891
|
+
if rec["pane"] and not rec["placed"] and not rec["elsewhere"]
|
|
1892
|
+
]
|
|
1893
|
+
print(
|
|
1894
|
+
f"{len(records)} list pane(s), {len(bad)} misplaced"
|
|
1895
|
+
+ (f": {' '.join(bad)} — tell the keeper to move them: fbtodo pane-watch --once" if bad else "")
|
|
1896
|
+
)
|
|
1897
|
+
return 0
|
|
1898
|
+
|
|
1899
|
+
|
|
1900
|
+
def cmd_pin(args) -> int:
|
|
1901
|
+
"""Pin the list pane's side or size, per window.
|
|
1902
|
+
|
|
1903
|
+
The pane follows its session, but a window can have a mind of its own about where that
|
|
1904
|
+
is: a wide window reads better with the list beside the session, a tall one with it
|
|
1905
|
+
below. `fbtodo pin --side h --size 16` run in the window you mean (or with `--window`
|
|
1906
|
+
naming another, `main:2` or `@71`) files that under `<session>:<index>` in
|
|
1907
|
+
`fbtodo-pins.json` in the state directory, and the pane keeper enforces both halves — the side it
|
|
1908
|
+
splits on and puts a drifted pane back on, and the size it resizes back to.
|
|
1909
|
+
|
|
1910
|
+
`--role` says which list pane it is for: `local` (a session's), `nas` (the one a NAS
|
|
1911
|
+
ssh pulls in), or `both` (the default) for the window's shared answer. A window holding
|
|
1912
|
+
both needs the distinction — the two panes need not be the same size — so the role's own
|
|
1913
|
+
pin outranks the shared one for that pane, and only that one.
|
|
1914
|
+
|
|
1915
|
+
Bare `fbtodo pin` reports what this window is set to, per role, and where each half came
|
|
1916
|
+
from (a pin, the remembered size, FBTODO_SPLIT/FBTODO_PANE_SIZE, or the default);
|
|
1917
|
+
`--list` reports every pin; `--clear` drops the window's pin whole; `--side auto` and
|
|
1918
|
+
`--size 0` drop one half of it.
|
|
1919
|
+
"""
|
|
1920
|
+
pins = load_pins()
|
|
1921
|
+
if args.list:
|
|
1922
|
+
if args.json:
|
|
1923
|
+
json.dump(pins, sys.stdout, ensure_ascii=False)
|
|
1924
|
+
sys.stdout.write("\n")
|
|
1925
|
+
return 0
|
|
1926
|
+
if not pins:
|
|
1927
|
+
print("no pins (set one with: fbtodo pin --side h --size 16)")
|
|
1928
|
+
return 0
|
|
1929
|
+
for key in sorted(pins):
|
|
1930
|
+
entry = pins[key] if isinstance(pins[key], dict) else {}
|
|
1931
|
+
state = "live" if window_key(key) else "no such window"
|
|
1932
|
+
shared = [f"{f}={entry[f]}" for f in ("side", "size") if entry.get(f)]
|
|
1933
|
+
print(f"{key:<16} {'both':<6} {' '.join(shared) or '-':<16} {state}")
|
|
1934
|
+
for one in ("local", "nas"):
|
|
1935
|
+
sub = entry.get(one)
|
|
1936
|
+
sub = sub if isinstance(sub, dict) else {}
|
|
1937
|
+
got = [f"{f}={sub[f]}" for f in ("side", "size") if sub.get(f)]
|
|
1938
|
+
if got:
|
|
1939
|
+
print(f"{key:<16} {one:<6} {' '.join(got):<16} {state}")
|
|
1940
|
+
return 0
|
|
1941
|
+
window = (getattr(args, "window", None) or "").strip()
|
|
1942
|
+
if not window:
|
|
1943
|
+
out = tmux_run("display-message", "-p", "#{window_id}")
|
|
1944
|
+
window = (out or "").strip()
|
|
1945
|
+
key = window_key(window)
|
|
1946
|
+
if not key:
|
|
1947
|
+
print(
|
|
1948
|
+
"fbtodo pin: no tmux window to pin (run it inside a pane, or name one with "
|
|
1949
|
+
"--window main:2)",
|
|
1950
|
+
file=sys.stderr,
|
|
1951
|
+
)
|
|
1952
|
+
return EX_CODES["nofile"]
|
|
1953
|
+
entry = dict(pins.get(key)) if isinstance(pins.get(key), dict) else {}
|
|
1954
|
+
role = getattr(args, "role", None) or "both"
|
|
1955
|
+
if args.clear:
|
|
1956
|
+
had = key in pins
|
|
1957
|
+
pins.pop(key, None)
|
|
1958
|
+
save_pins(pins)
|
|
1959
|
+
apply_pin(window, nas_host=args.nas_host)
|
|
1960
|
+
if not args.quiet:
|
|
1961
|
+
print(f"{key}: {'pin cleared' if had else 'nothing was pinned'}")
|
|
1962
|
+
return 0
|
|
1963
|
+
asked = args.side is not None or args.size is not None
|
|
1964
|
+
# `both` writes the window's shared halves — what every pin written before there was a
|
|
1965
|
+
# role is; `local`/`nas` write that role's own, which outranks the shared one for it.
|
|
1966
|
+
target = entry if role == "both" else dict(entry.get(role) or {})
|
|
1967
|
+
if getattr(args, "side", None):
|
|
1968
|
+
if args.side == "auto":
|
|
1969
|
+
target.pop("side", None)
|
|
1970
|
+
elif args.side in ("v", "h"):
|
|
1971
|
+
target["side"] = args.side
|
|
1972
|
+
else:
|
|
1973
|
+
print("fbtodo pin: --side takes v, h or auto", file=sys.stderr)
|
|
1974
|
+
return EX_CODES["usage"]
|
|
1975
|
+
if args.size is not None:
|
|
1976
|
+
if args.size <= 0:
|
|
1977
|
+
target.pop("size", None)
|
|
1978
|
+
else:
|
|
1979
|
+
target["size"] = args.size
|
|
1980
|
+
if role != "both":
|
|
1981
|
+
if target:
|
|
1982
|
+
entry[role] = target
|
|
1983
|
+
else:
|
|
1984
|
+
entry.pop(role, None)
|
|
1985
|
+
if asked:
|
|
1986
|
+
if entry:
|
|
1987
|
+
pins[key] = entry
|
|
1988
|
+
else:
|
|
1989
|
+
pins.pop(key, None)
|
|
1990
|
+
save_pins(pins)
|
|
1991
|
+
roles = ("local", "nas") if role == "both" else (role,)
|
|
1992
|
+
layouts = {one: pane_layout(window, one, pins) for one in roles}
|
|
1993
|
+
touched = list(dict.fromkeys(apply_pin(window, role, args.nas_host))) if asked else []
|
|
1994
|
+
first = layouts["local" if "local" in layouts else roles[0]]
|
|
1995
|
+
if args.json:
|
|
1996
|
+
json.dump(
|
|
1997
|
+
{
|
|
1998
|
+
"window": key, "role": role, "pin": entry, "layouts": layouts,
|
|
1999
|
+
"side": first["side"], "size": first["size"], "panes": touched,
|
|
2000
|
+
},
|
|
2001
|
+
sys.stdout, ensure_ascii=False,
|
|
2002
|
+
)
|
|
2003
|
+
sys.stdout.write("\n")
|
|
2004
|
+
return 0
|
|
2005
|
+
if args.quiet:
|
|
2006
|
+
return 0
|
|
2007
|
+
lines = [f"{key} ({'pinned' if entry else 'nothing pinned'})"]
|
|
2008
|
+
for one, layout in layouts.items():
|
|
2009
|
+
lines.append(
|
|
2010
|
+
f" {one:<5} side={layout['side']}"
|
|
2011
|
+
f" ({source_note(layout['side_source'], key)})"
|
|
2012
|
+
f" size={layout['size']}"
|
|
2013
|
+
f" ({source_note(layout['size_source'], key)})"
|
|
2014
|
+
)
|
|
2015
|
+
if touched:
|
|
2016
|
+
lines.append(f" list pane {', '.join(touched)} re-placed")
|
|
2017
|
+
print("\n".join(lines))
|
|
2018
|
+
return 0
|
|
2019
|
+
|
|
2020
|
+
|
|
2021
|
+
# ======================================================================== main
|
|
2022
|
+
def build_parser():
|
|
2023
|
+
ap = argparse.ArgumentParser(
|
|
2024
|
+
prog="fbtodo",
|
|
2025
|
+
description="Watch the Freebuff agent's todo list.",
|
|
2026
|
+
add_help=False,
|
|
2027
|
+
)
|
|
2028
|
+
ap.add_argument("command", nargs="?", default="pane",
|
|
2029
|
+
choices=["pane", "snap", "json", "bar", "daemon", "stop", "status",
|
|
2030
|
+
"prune", "nas", "pane-watch", "pin", "why", "ledger", "doctor",
|
|
2031
|
+
"push"])
|
|
2032
|
+
ap.add_argument(
|
|
2033
|
+
"--label-floor", type=float, default=None, metavar="SEC",
|
|
2034
|
+
help="estimates: a finished span under SEC is not evidence — it sets no pace and "
|
|
2035
|
+
"moves no memory (0 = keep every span; FBTODO_LABEL_FLOOR)",
|
|
2036
|
+
)
|
|
2037
|
+
ap.add_argument(
|
|
2038
|
+
"--blend-weight", type=float, default=None, metavar="W",
|
|
2039
|
+
help="estimates: 0..1, how much a WAITING step's wording counts against the list's "
|
|
2040
|
+
"pace (0 = pace only, 1 = wording only; FBTODO_BLEND_WEIGHT)",
|
|
2041
|
+
)
|
|
2042
|
+
ap.add_argument("--days", type=float, metavar="N",
|
|
2043
|
+
help="ledger: how far back to list (default 60, the retention window)")
|
|
2044
|
+
ap.add_argument("--limit", type=int, default=20, metavar="N",
|
|
2045
|
+
help="ledger: rows to print, newest first (default 20, 0 = all)")
|
|
2046
|
+
ap.add_argument("--model", metavar="NAME", help="ledger: only this model's steps")
|
|
2047
|
+
ap.add_argument("--max-records", type=int, help="prune: cap on kept task records")
|
|
2048
|
+
ap.add_argument("--max-age-days", type=float, help="prune: drop records older than this")
|
|
2049
|
+
ap.add_argument("--log-cap-kb", type=int, help="prune: truncate the daemon log above this")
|
|
2050
|
+
ap.add_argument("-h", "--help", action="store_true")
|
|
2051
|
+
ap.add_argument("-V", "--version", action="store_true")
|
|
2052
|
+
ap.add_argument("--once", action="store_true", help="pane: render once and exit")
|
|
2053
|
+
ap.add_argument("--tick", type=float, default=5.0,
|
|
2054
|
+
help="pane: repaint at least this often while nothing moves "
|
|
2055
|
+
"(1s while a step's clock is counting up, 1s for a store "
|
|
2056
|
+
"that is still being written)")
|
|
2057
|
+
ap.add_argument("--stale-after", type=float, default=60.0, metavar="MIN",
|
|
2058
|
+
help="pane: close after MIN minutes of no store activity (0 = never)")
|
|
2059
|
+
ap.add_argument("-i", "--interval", type=float, default=1.0,
|
|
2060
|
+
help="pane: seconds between polls (the clock repaints far more "
|
|
2061
|
+
"often than this; 5s for -s nas)")
|
|
2062
|
+
ap.add_argument(
|
|
2063
|
+
"-s", "--source", default="auto", metavar="SOURCE",
|
|
2064
|
+
help="where the list comes from: auto, cli, nas, desktop, or file:PATH (a state "
|
|
2065
|
+
"JSON on disk, the same shape `fbtodo push` writes)",
|
|
2066
|
+
)
|
|
2067
|
+
ap.add_argument(
|
|
2068
|
+
"--to", dest="push_to", metavar="PATH",
|
|
2069
|
+
help="push: write this state file instead of the live state (the list a `-s file:PATH` "
|
|
2070
|
+
"then reads back)",
|
|
2071
|
+
)
|
|
2072
|
+
ap.add_argument(
|
|
2073
|
+
"--nas-host", default=NAS_HOST, metavar="USER@HOST",
|
|
2074
|
+
help="nas: ssh target holding the freebuff store (FBTODO_NAS; required for -s nas)",
|
|
2075
|
+
)
|
|
2076
|
+
ap.add_argument(
|
|
2077
|
+
"--nas-root", default=NAS_ROOT, metavar="DIR",
|
|
2078
|
+
help="nas: remote projects directory (FBTODO_NAS_ROOT; required for -s nas)",
|
|
2079
|
+
)
|
|
2080
|
+
ap.add_argument(
|
|
2081
|
+
"--nas-project", default=NAS_PROJECT, metavar="NAME",
|
|
2082
|
+
help="nas: project name inside that directory (FBTODO_NAS_PROJECT; required for -s nas)",
|
|
2083
|
+
)
|
|
2084
|
+
ap.add_argument(
|
|
2085
|
+
"--fb-marker",
|
|
2086
|
+
default=os.environ.get("FBTODO_FB_MARKER") or "$HOME/.fb-session",
|
|
2087
|
+
help="nas: the host `fb` wrapper's session marker (pid, started, dir), read on the NAS",
|
|
2088
|
+
)
|
|
2089
|
+
ap.add_argument(
|
|
2090
|
+
"--wait",
|
|
2091
|
+
action="store_true",
|
|
2092
|
+
help="pane: stay open when the session goes away (a remote pane is closed with its ssh)",
|
|
2093
|
+
)
|
|
2094
|
+
ap.add_argument(
|
|
2095
|
+
"--goal-lines",
|
|
2096
|
+
type=int,
|
|
2097
|
+
default=3,
|
|
2098
|
+
metavar="N",
|
|
2099
|
+
help="pane: lines for the goal heading the list (0 hides it)",
|
|
2100
|
+
)
|
|
2101
|
+
ap.add_argument("--chat")
|
|
2102
|
+
ap.add_argument("--cli-root", default=DEFAULT_CLI_ROOT)
|
|
2103
|
+
ap.add_argument("-p", "--project")
|
|
2104
|
+
ap.add_argument("--db", default=DEFAULT_DB_GLOB)
|
|
2105
|
+
ap.add_argument("--state", default=DEFAULT_WORKSPACE_STATE)
|
|
2106
|
+
ap.add_argument("-t", "--thread")
|
|
2107
|
+
ap.add_argument("--last", action="store_true")
|
|
2108
|
+
ap.add_argument("-A", "--follow-app", action="store_true")
|
|
2109
|
+
# The desktop store holds every thread of a project, and the app can have several of
|
|
2110
|
+
# them running at once: `--threads` is how many the pane stacks (the one it follows
|
|
2111
|
+
# included, 0 for that one alone) and `--thread-live` how quiet a thread's own list may
|
|
2112
|
+
# be and still be one of them (0 = no window at all). Neither means anything to a
|
|
2113
|
+
# journal, a NAS session or a file, which have exactly one list by construction.
|
|
2114
|
+
ap.add_argument("--threads", type=int, default=DESKTOP_MAX_THREADS)
|
|
2115
|
+
ap.add_argument("--thread-live", type=int, default=DESKTOP_LIVE_MS // 60_000)
|
|
2116
|
+
ap.add_argument("-f", "--foreground", action="store_true", help="daemon: don't detach")
|
|
2117
|
+
ap.add_argument("--force", action="store_true", help="daemon: start even if one runs")
|
|
2118
|
+
ap.add_argument("--quiet", action="store_true")
|
|
2119
|
+
ap.add_argument("--json", action="store_true", help="status: JSON output")
|
|
2120
|
+
ap.add_argument("--no-daemon", action="store_true", help="pane: read stores directly")
|
|
2121
|
+
# --- the NAS pane watcher (`nas`): a local daemon because `fb` over there cannot
|
|
2122
|
+
# reach this Mac's tmux, so something here has to notice the session starting.
|
|
2123
|
+
ap.add_argument("--stop", action="store_true", help="nas: stop the pane watcher")
|
|
2124
|
+
ap.add_argument("--status", action="store_true", help="nas: the watcher, the session, and the pane")
|
|
2125
|
+
ap.add_argument("--poll", type=float,
|
|
2126
|
+
default=float(os.environ.get("FBTODO_NAS_POLL") or 5.0),
|
|
2127
|
+
help="nas: seconds between polls while you are at the keyboard")
|
|
2128
|
+
ap.add_argument("--poll-idle", type=float,
|
|
2129
|
+
default=float(os.environ.get("FBTODO_NAS_POLL_IDLE") or 60.0),
|
|
2130
|
+
help="nas: seconds between polls when no tmux client is attached")
|
|
2131
|
+
|
|
2132
|
+
ap.add_argument("--poll-live", type=float, default=2.5,
|
|
2133
|
+
help="nas: seconds between polls while a NAS session runs")
|
|
2134
|
+
ap.add_argument(
|
|
2135
|
+
"--notify-seconds", type=float,
|
|
2136
|
+
default=float(os.environ.get("FBTODO_NOTIFY_SECONDS") or 15.0),
|
|
2137
|
+
help="nas: ask the phone notifier this often while a session runs (0 = never; "
|
|
2138
|
+
"it is skipped when ~/.config/freebuff-notify/todo-bell.py is missing)",
|
|
2139
|
+
)
|
|
2140
|
+
ap.add_argument(
|
|
2141
|
+
"--pause-seconds", type=float,
|
|
2142
|
+
default=float(os.environ.get("FBTODO_PAUSE_SECONDS") or 30.0),
|
|
2143
|
+
help="watcher: ask the stall watch this often whether the session has stopped "
|
|
2144
|
+
"mid-task, local sessions only (0 = never; it is skipped when "
|
|
2145
|
+
"~/.config/freebuff-notify/pause-bell.py is missing)",
|
|
2146
|
+
)
|
|
2147
|
+
ap.add_argument(
|
|
2148
|
+
"--pane-seconds", type=float,
|
|
2149
|
+
default=float(os.environ.get("FBTODO_PANE_SECONDS") or 3.0),
|
|
2150
|
+
help="watcher: this often, put the local todo pane back if it was killed while "
|
|
2151
|
+
"the session runs (0 = never; FBTODO_NO_PANE=1 disables it too)",
|
|
2152
|
+
)
|
|
2153
|
+
ap.add_argument(
|
|
2154
|
+
"--pane-bell-seconds", type=float,
|
|
2155
|
+
default=float(os.environ.get("FBTODO_PANE_BELL_SECONDS") or 60.0),
|
|
2156
|
+
help="watcher: ask the pane watch this often whether a session has no todo pane "
|
|
2157
|
+
"or the keeper is gone (0 = never; it is skipped when "
|
|
2158
|
+
"~/.config/freebuff-notify/pane-bell.py is missing)",
|
|
2159
|
+
)
|
|
2160
|
+
ap.add_argument(
|
|
2161
|
+
"--ask-seconds", type=float,
|
|
2162
|
+
default=float(os.environ.get("FBTODO_ASK_SECONDS") or 3.0),
|
|
2163
|
+
help="watcher: look for a freebuff pane waiting on a question this often, "
|
|
2164
|
+
"local and NAS alike (0 = never; it is skipped when "
|
|
2165
|
+
"~/.config/freebuff-notify/ask-bell.py is missing)",
|
|
2166
|
+
)
|
|
2167
|
+
# --- per-window pins (`pin`): where the list pane goes in THIS window, and how big
|
|
2168
|
+
ap.add_argument("--side", choices=["v", "h", "auto"],
|
|
2169
|
+
help="pin: v puts the list below the session, h beside it, auto unpins the side")
|
|
2170
|
+
ap.add_argument("--size", type=int, metavar="N",
|
|
2171
|
+
help="pin: keep the list pane N lines (0 unpins the size)")
|
|
2172
|
+
ap.add_argument("--role", choices=["local", "nas", "both"], default="both",
|
|
2173
|
+
help="pin: which list pane the pin is for — a session's (`local`), the "
|
|
2174
|
+
"one a NAS ssh pulls in (`nas`), or the window's shared answer "
|
|
2175
|
+
"for any of them (`both`, the default)")
|
|
2176
|
+
ap.add_argument("--window", metavar="TARGET",
|
|
2177
|
+
help="pin, why: the tmux window to act on (default: the pane you are in)")
|
|
2178
|
+
ap.add_argument("--list", action="store_true", help="pin: every pin, and whether its window exists")
|
|
2179
|
+
ap.add_argument("--clear", action="store_true", help="pin: drop this window's pin")
|
|
2180
|
+
ap.add_argument("--dry-run", action="store_true", help="nas: say what it would do, change nothing")
|
|
2181
|
+
ap.add_argument("--idle-exit", type=float, default=30.0, metavar="MIN",
|
|
2182
|
+
help="nas: quit after MIN of no session and no client (0 = run forever)")
|
|
2183
|
+
ap.add_argument("--instance-pid", type=int, help="daemon: watch this pid (internal)")
|
|
2184
|
+
ap.add_argument("--watch-pid", type=int, help="treat this pid as the instance (internal)")
|
|
2185
|
+
ap.add_argument("--instance-of", type=int, metavar="PID",
|
|
2186
|
+
help="follow the freebuff process launched by PID (the shell that started it)")
|
|
2187
|
+
ap.add_argument("--cwd", help="daemon: directory to serve")
|
|
2188
|
+
return ap
|
|
2189
|
+
|
|
2190
|
+
|
|
2191
|
+
def main(argv=None) -> int:
|
|
2192
|
+
# The state root is chosen at import but ACTED ON only here: importing the package must
|
|
2193
|
+
# not create a directory or move the legacy store (see `init_state_root`). Before the
|
|
2194
|
+
# parser is built, so a flag whose default is a path defaults to the real one.
|
|
2195
|
+
init_state_root()
|
|
2196
|
+
ap = build_parser()
|
|
2197
|
+
try:
|
|
2198
|
+
args, extra = ap.parse_known_args(argv)
|
|
2199
|
+
except SystemExit:
|
|
2200
|
+
return EX_CODES["usage"]
|
|
2201
|
+
if extra:
|
|
2202
|
+
print(f"unexpected arguments: {' '.join(extra)}", file=sys.stderr)
|
|
2203
|
+
return EX_CODES["usage"]
|
|
2204
|
+
if args.help:
|
|
2205
|
+
print(__doc__)
|
|
2206
|
+
return 0
|
|
2207
|
+
if args.version:
|
|
2208
|
+
print(VERSION)
|
|
2209
|
+
return 0
|
|
2210
|
+
# The two estimate knobs, applied through the one table every reader asks (see
|
|
2211
|
+
# `set_estimate_knobs`): the env vars are read at import, and a flag beats them, as the
|
|
2212
|
+
# precedence promises.
|
|
2213
|
+
set_estimate_knobs(args.label_floor, args.blend_weight)
|
|
2214
|
+
# `-s` is a word or a `file:PATH`, so the word set is checked here rather than by
|
|
2215
|
+
# argparse's choices: a typo is the caller's usage error (64), and a `file:` prefix
|
|
2216
|
+
# must reach `sources_for` instead of being rejected before it.
|
|
2217
|
+
if args.source not in ("auto", "cli", "nas", "desktop") and not source_path(args.source):
|
|
2218
|
+
print(f"fbtodo: unknown source {args.source!r} — expected auto, cli, nas, desktop, "
|
|
2219
|
+
"or file:PATH", file=sys.stderr)
|
|
2220
|
+
return EX_CODES["ex_usage"]
|
|
2221
|
+
if args.source == "nas" and args.interval <= 1.0:
|
|
2222
|
+
# One ssh per poll, and the pane no longer needs a poll to move its own numbers:
|
|
2223
|
+
# the clock and the "N ago" repaint locally (see the pane loop), so the store is
|
|
2224
|
+
# reached every 5s — half the ssh traffic of the old 2.5s — while the pane still
|
|
2225
|
+
# looks alive every second.
|
|
2226
|
+
args.interval = 5.0
|
|
2227
|
+
os.makedirs(SCRATCH, mode=0o700, exist_ok=True)
|
|
2228
|
+
# A `file:PATH` state is an ingress like push, so the same caps apply and a payload over
|
|
2229
|
+
# one is the same data error (65), not a state the pane is asked to draw. A file that is
|
|
2230
|
+
# simply absent is left to the source's own "no state file" answer.
|
|
2231
|
+
spath = source_path(args.source)
|
|
2232
|
+
if spath:
|
|
2233
|
+
code = check_file_ingress(state_file_in(spath))
|
|
2234
|
+
if code is not None:
|
|
2235
|
+
return code
|
|
2236
|
+
|
|
2237
|
+
if args.command == "push":
|
|
2238
|
+
return cmd_push(args)
|
|
2239
|
+
if args.command == "pane":
|
|
2240
|
+
return cmd_pane(args)
|
|
2241
|
+
if args.command == "snap":
|
|
2242
|
+
return cmd_snap(args)
|
|
2243
|
+
if args.command == "json":
|
|
2244
|
+
return cmd_json(args)
|
|
2245
|
+
if args.command == "bar":
|
|
2246
|
+
return cmd_bar(args)
|
|
2247
|
+
if args.command == "daemon":
|
|
2248
|
+
return cmd_daemon(args)
|
|
2249
|
+
if args.command == "stop":
|
|
2250
|
+
return cmd_stop(args)
|
|
2251
|
+
if args.command == "status":
|
|
2252
|
+
return cmd_status(args)
|
|
2253
|
+
if args.command == "doctor":
|
|
2254
|
+
return cmd_doctor(args)
|
|
2255
|
+
if args.command == "prune":
|
|
2256
|
+
return cmd_prune(args)
|
|
2257
|
+
if args.command == "ledger":
|
|
2258
|
+
return cmd_ledger(args)
|
|
2259
|
+
if args.command == "nas":
|
|
2260
|
+
return cmd_nas(args)
|
|
2261
|
+
if args.command == "pane-watch":
|
|
2262
|
+
return cmd_pane_watch(args)
|
|
2263
|
+
if args.command == "pin":
|
|
2264
|
+
return cmd_pin(args)
|
|
2265
|
+
if args.command == "why":
|
|
2266
|
+
return cmd_why(args)
|
|
2267
|
+
return EX_CODES["usage"]
|
|
2268
|
+
|
|
2269
|
+
|
|
2270
|
+
if __name__ == "__main__":
|
|
2271
|
+
sys.exit(main())
|