workmap 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- workmap/__init__.py +30 -0
- workmap/__main__.py +15 -0
- workmap/actions.py +350 -0
- workmap/audit.py +153 -0
- workmap/cli.py +789 -0
- workmap/config.py +589 -0
- workmap/demo.py +94 -0
- workmap/drivers/__init__.py +93 -0
- workmap/drivers/apple_terminal.py +578 -0
- workmap/layout.py +64 -0
- workmap/model.py +719 -0
- workmap/multiplexer.py +201 -0
- workmap/procs.py +504 -0
- workmap/scan.py +413 -0
- workmap/setup.py +400 -0
- workmap/shell.py +117 -0
- workmap/terminal.py +50 -0
- workmap/themes.py +45 -0
- workmap/tui/__init__.py +6 -0
- workmap/tui/app.py +1090 -0
- workmap/tui/onboarding.py +266 -0
- workmap/tui/text.py +156 -0
- workmap/tui/widgets.py +189 -0
- workmap-0.1.0.dist-info/METADATA +258 -0
- workmap-0.1.0.dist-info/RECORD +28 -0
- workmap-0.1.0.dist-info/WHEEL +4 -0
- workmap-0.1.0.dist-info/entry_points.txt +2 -0
- workmap-0.1.0.dist-info/licenses/LICENSE +21 -0
workmap/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""workmap: a desk map for the Terminal windows you already have.
|
|
2
|
+
|
|
3
|
+
Groups live Terminal.app windows and orphaned background processes by project,
|
|
4
|
+
attributes RAM to each, and gives you one key to clean up.
|
|
5
|
+
|
|
6
|
+
The layer order lives in ARCHITECTURE.md and is checked by
|
|
7
|
+
`test_scan.Layering`. It was written out here too, and the copy went stale:
|
|
8
|
+
it omitted themes.py and drivers/, and said terminal.py was "the only
|
|
9
|
+
AppleScript in the codebase" long after the driver seam moved every line of
|
|
10
|
+
it into drivers/apple_terminal.py. One place to keep right is enough.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from .model import Project, Session, fmt_mb, fmt_mem
|
|
15
|
+
from .scan import background_sessions, build_projects, snapshot
|
|
16
|
+
from .terminal import terminal_status
|
|
17
|
+
|
|
18
|
+
__version__ = "0.1.0"
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"Project",
|
|
22
|
+
"Session",
|
|
23
|
+
"background_sessions",
|
|
24
|
+
"build_projects",
|
|
25
|
+
"fmt_mb",
|
|
26
|
+
"fmt_mem",
|
|
27
|
+
"snapshot",
|
|
28
|
+
"terminal_status",
|
|
29
|
+
"__version__",
|
|
30
|
+
]
|
workmap/__main__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""`python3 -m workmap`, for when the installed script is not on PATH.
|
|
2
|
+
|
|
3
|
+
`install.sh` says so itself when the bin directory is missing from PATH, and
|
|
4
|
+
the next thing a reader tries is the module. Without this they get "'workmap'
|
|
5
|
+
is a package and cannot be directly executed", which reads like a broken
|
|
6
|
+
install rather than a PATH they have not exported yet.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
from .cli import main
|
|
13
|
+
|
|
14
|
+
if __name__ == "__main__":
|
|
15
|
+
sys.exit(main())
|
workmap/actions.py
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"""Everything that changes the world: kill, stop, organize, retitle, recolour.
|
|
2
|
+
|
|
3
|
+
Every function takes an optional `projects=` scan so a caller that already has
|
|
4
|
+
one doesn't pay for a second. For the kill path it matters more than that:
|
|
5
|
+
it is what makes an action work on exactly the sessions the user was shown."""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import subprocess
|
|
9
|
+
|
|
10
|
+
from . import audit, terminal
|
|
11
|
+
from .config import load_config, remembered_profile, save_config
|
|
12
|
+
from .layout import _usable_desktop, _grid_shape, tile_windows_in_rect
|
|
13
|
+
from .model import Project, Session, plural, short_tool
|
|
14
|
+
from .procs import kill_pids
|
|
15
|
+
from .scan import background_sessions, build_projects
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# Rewriting twelve of Terminal's built-in profiles is a change to the reader's
|
|
19
|
+
# own application settings, made on their behalf, by a tool they ran once.
|
|
20
|
+
# README.md discloses it in full and nothing on screen did, so somebody who
|
|
21
|
+
# ran ./install.sh and then pressed `o` had no reason to know it had happened.
|
|
22
|
+
TITLE_SETTINGS_NOTICE = (
|
|
23
|
+
"One thing worth knowing: workmap just turned off Terminal's own window "
|
|
24
|
+
"titles on its built-in profiles, so it can name windows after your "
|
|
25
|
+
"projects instead. To put them back, open Terminal, then Settings, then "
|
|
26
|
+
"Profiles, then the Window tab, and tick the boxes under Title."
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# The rewrite outlives the process, so the record of having mentioned it has
|
|
30
|
+
# to as well. A per-process flag would say this again on every single run.
|
|
31
|
+
TOLD_MARKER = "told-about-title-settings"
|
|
32
|
+
|
|
33
|
+
_title_notice = ""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def ensure_titles() -> None:
|
|
37
|
+
"""Turn Terminal's own title bits off, and line up the notice once.
|
|
38
|
+
|
|
39
|
+
Said once, because a paragraph repeated on every layout is a paragraph
|
|
40
|
+
nobody reads. Said again if the marker cannot be written, because at that
|
|
41
|
+
point we cannot show that we ever said it, and for a disclosure the honest
|
|
42
|
+
direction to fail in is repeating it rather than dropping it.
|
|
43
|
+
"""
|
|
44
|
+
global _title_notice
|
|
45
|
+
terminal.ensure_title_settings()
|
|
46
|
+
marker = audit.state_dir() / TOLD_MARKER
|
|
47
|
+
try:
|
|
48
|
+
if marker.exists():
|
|
49
|
+
return
|
|
50
|
+
except OSError:
|
|
51
|
+
pass
|
|
52
|
+
_title_notice = TITLE_SETTINGS_NOTICE
|
|
53
|
+
try:
|
|
54
|
+
marker.parent.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
marker.write_text("", encoding="utf-8")
|
|
56
|
+
except OSError:
|
|
57
|
+
# Said anyway. See above.
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def take_title_notice() -> str:
|
|
62
|
+
"""The notice, and nothing the next caller can say twice.
|
|
63
|
+
|
|
64
|
+
Draining it here is what makes it once per run as well as once per
|
|
65
|
+
machine: `organize_all` reaches `ensure_titles` through every project on
|
|
66
|
+
the desk, and the reader needs one paragraph, not one per project.
|
|
67
|
+
"""
|
|
68
|
+
global _title_notice
|
|
69
|
+
said, _title_notice = _title_notice, ""
|
|
70
|
+
return said
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def stuck_words(result: dict) -> str:
|
|
74
|
+
"""What to tell the reader about windows that would not move, or "".
|
|
75
|
+
|
|
76
|
+
One sentence, in one place, because `workmap organize` and the `o` and `O`
|
|
77
|
+
keys all have to say the same thing and none of them can measure it: only
|
|
78
|
+
the driver knows, and only after it has asked.
|
|
79
|
+
|
|
80
|
+
It names macOS tiling because that is the cause measured on a real
|
|
81
|
+
machine, and because the reader is holding the tool responsible until
|
|
82
|
+
something tells them otherwise. It gives the way out first in the form
|
|
83
|
+
they can do without leaving the keyboard they are at.
|
|
84
|
+
"""
|
|
85
|
+
stuck = result.get("stuck") or 0
|
|
86
|
+
if stuck <= 0:
|
|
87
|
+
return ""
|
|
88
|
+
one = stuck == 1
|
|
89
|
+
return (f"macOS is holding {plural(stuck, 'window')} in place, so workmap "
|
|
90
|
+
f"could not move {'it' if one else 'them'}. Drag "
|
|
91
|
+
f"{'it out of its tile' if one else 'them out of their tiles'}, "
|
|
92
|
+
f"or turn tiling off in System Settings, under Desktop and Dock.")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def focus_project(project: Project) -> None:
|
|
96
|
+
if project.window_ids:
|
|
97
|
+
terminal.focus_window(project.window_ids[0])
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def ensure_project_profile(name: str, cfg: dict) -> str:
|
|
101
|
+
"""The colour this project is painted in, chosen and remembered once.
|
|
102
|
+
|
|
103
|
+
The join between the settings file and the terminal driver, which is what
|
|
104
|
+
this module is for. config.py holds the preference and terminal.py knows
|
|
105
|
+
which colours the emulator actually has; neither is allowed to reach for
|
|
106
|
+
the other, so the pairing happens here.
|
|
107
|
+
"""
|
|
108
|
+
return remembered_profile(name, cfg, terminal.available_profile_names())
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def paint_project(name: str, *, all_windows: bool = False,
|
|
112
|
+
projects: list[Project] | None = None) -> str | None:
|
|
113
|
+
"""Apply remembered profile.
|
|
114
|
+
|
|
115
|
+
Default: paint the front tab only. No full desk scan, no profile-title
|
|
116
|
+
rewrite. Those are what freeze Terminal when Shell → New Tab is open.
|
|
117
|
+
Full project paint is opt-in for the TUI.
|
|
118
|
+
|
|
119
|
+
The cheap path exists for shell wrappers that call `workmap paint`
|
|
120
|
+
on every `cd` into a project. No such wrapper ships here.
|
|
121
|
+
"""
|
|
122
|
+
cfg = load_config()
|
|
123
|
+
profile = ensure_project_profile(name, cfg)
|
|
124
|
+
if all_windows:
|
|
125
|
+
scan = projects if projects is not None else build_projects()
|
|
126
|
+
hit = next((p for p in scan if p.name == name), None)
|
|
127
|
+
if hit and hit.window_ids:
|
|
128
|
+
terminal.apply_profile(hit.window_ids, profile)
|
|
129
|
+
return profile
|
|
130
|
+
terminal.paint_front_window(profile)
|
|
131
|
+
return profile
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def organize_project(name: str, *, projects: list[Project] | None = None) -> dict:
|
|
135
|
+
"""Tile one project's windows as a group on screen."""
|
|
136
|
+
ensure_titles()
|
|
137
|
+
cfg = load_config()
|
|
138
|
+
profile = ensure_project_profile(name, cfg)
|
|
139
|
+
scan = projects if projects is not None else build_projects()
|
|
140
|
+
hit = next((p for p in scan if p.name == name), None)
|
|
141
|
+
if not hit or not hit.window_ids:
|
|
142
|
+
return {"ok": False, "reason": "no Terminal windows", "merged": False, "windows": 0}
|
|
143
|
+
|
|
144
|
+
titles: dict[int, str] = {}
|
|
145
|
+
for s in hit.sessions:
|
|
146
|
+
if s.window_id is None:
|
|
147
|
+
continue
|
|
148
|
+
titles[s.window_id] = f"{name} · {short_tool(s.label, s.kind)}"
|
|
149
|
+
terminal.set_window_titles(titles)
|
|
150
|
+
terminal.apply_profile(hit.window_ids, profile)
|
|
151
|
+
ids = hit.window_ids
|
|
152
|
+
|
|
153
|
+
left, top, right, bottom = _usable_desktop()
|
|
154
|
+
placed = tile_windows_in_rect(ids, (left, top, right, bottom), front=True)
|
|
155
|
+
return {
|
|
156
|
+
"ok": True,
|
|
157
|
+
"project": name,
|
|
158
|
+
"merged": False,
|
|
159
|
+
"windows": len(ids),
|
|
160
|
+
"placed": placed,
|
|
161
|
+
"stuck": len(ids) - placed,
|
|
162
|
+
"mode": "tiled-group",
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def organize_all(*, projects: list[Project] | None = None) -> dict:
|
|
167
|
+
"""Give every project with Terminal windows its own screen region."""
|
|
168
|
+
ensure_titles()
|
|
169
|
+
scan = projects if projects is not None else build_projects()
|
|
170
|
+
scan = [p for p in scan if p.window_ids]
|
|
171
|
+
if not scan:
|
|
172
|
+
return {"ok": False, "reason": "no Terminal windows", "projects": 0}
|
|
173
|
+
|
|
174
|
+
titles: dict[int, str] = {}
|
|
175
|
+
for p in scan:
|
|
176
|
+
for s in p.sessions:
|
|
177
|
+
if s.window_id is None:
|
|
178
|
+
continue
|
|
179
|
+
titles[s.window_id] = f"{p.name} · {short_tool(s.label, s.kind)}"
|
|
180
|
+
terminal.set_window_titles(titles)
|
|
181
|
+
|
|
182
|
+
left, top, right, bottom = _usable_desktop()
|
|
183
|
+
width = right - left
|
|
184
|
+
height = bottom - top
|
|
185
|
+
n = len(scan)
|
|
186
|
+
rows, cols = _grid_shape(n)
|
|
187
|
+
cell_w = width // cols
|
|
188
|
+
cell_h = height // rows
|
|
189
|
+
|
|
190
|
+
placed = 0
|
|
191
|
+
windows = 0
|
|
192
|
+
moved = 0
|
|
193
|
+
for i, p in enumerate(scan):
|
|
194
|
+
terminal.apply_profile(p.window_ids, p.profile)
|
|
195
|
+
row, col = i // cols, i % cols
|
|
196
|
+
ids = list(p.window_ids)
|
|
197
|
+
r = (
|
|
198
|
+
left + col * cell_w + 3,
|
|
199
|
+
top + row * cell_h + 3,
|
|
200
|
+
left + (col + 1) * cell_w - 3,
|
|
201
|
+
top + (row + 1) * cell_h - 3,
|
|
202
|
+
)
|
|
203
|
+
moved += tile_windows_in_rect(ids, r, front=(i == 0))
|
|
204
|
+
windows += len(ids)
|
|
205
|
+
placed += 1
|
|
206
|
+
|
|
207
|
+
return {"ok": True, "projects": placed, "grid": f"{rows}x{cols}",
|
|
208
|
+
"windows": windows, "placed": moved, "stuck": windows - moved}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def retitle_all(*, projects: list[Project] | None = None) -> int:
|
|
212
|
+
"""Rename every tracked Terminal window to: project · what."""
|
|
213
|
+
ensure_titles()
|
|
214
|
+
titles: dict[int, str] = {}
|
|
215
|
+
for proj in (projects if projects is not None else build_projects()):
|
|
216
|
+
for s in proj.sessions:
|
|
217
|
+
if s.window_id is None:
|
|
218
|
+
continue
|
|
219
|
+
what = short_tool(s.label, s.kind)
|
|
220
|
+
titles[s.window_id] = f"{proj.name} · {what}"
|
|
221
|
+
terminal.set_window_titles(titles)
|
|
222
|
+
return len(titles)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def retitle_project(name: str, *, projects: list[Project] | None = None) -> int:
|
|
226
|
+
ensure_titles()
|
|
227
|
+
titles: dict[int, str] = {}
|
|
228
|
+
for proj in (projects if projects is not None else build_projects()):
|
|
229
|
+
if proj.name != name:
|
|
230
|
+
continue
|
|
231
|
+
for s in proj.sessions:
|
|
232
|
+
if s.window_id is None:
|
|
233
|
+
continue
|
|
234
|
+
what = short_tool(s.label, s.kind)
|
|
235
|
+
titles[s.window_id] = f"{proj.name} · {what}"
|
|
236
|
+
terminal.set_window_titles(titles)
|
|
237
|
+
return len(titles)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def set_project_profile(name: str, profile: str, apply: bool = True, *,
|
|
241
|
+
projects: list[Project] | None = None) -> Project | None:
|
|
242
|
+
cfg = load_config()
|
|
243
|
+
cfg.setdefault("profiles", {})[name] = profile
|
|
244
|
+
save_config(cfg)
|
|
245
|
+
scan = projects if projects is not None else build_projects()
|
|
246
|
+
hit = next((p for p in scan if p.name == name), None)
|
|
247
|
+
if hit and apply:
|
|
248
|
+
terminal.apply_profile(hit.window_ids, profile)
|
|
249
|
+
return hit
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def kill_sessions(sessions: list[Session], *, reason: str = "kill",
|
|
253
|
+
dry_run: bool = False) -> dict:
|
|
254
|
+
"""Kill exactly these sessions: the ones the caller already showed the user.
|
|
255
|
+
|
|
256
|
+
This is the honest entry point: the confirmation prompt quotes a count and
|
|
257
|
+
a size from a scan, and this kills that scan's pids. Re-scanning here (as
|
|
258
|
+
the old kill_background did) meant you confirmed one set and killed another.
|
|
259
|
+
|
|
260
|
+
Every call is written to the audit log, intent first and outcome after, so
|
|
261
|
+
a kill that goes wrong can be reconstructed afterwards. It is the only
|
|
262
|
+
place in the package that signals anything, which is what makes one log
|
|
263
|
+
call enough.
|
|
264
|
+
"""
|
|
265
|
+
pids: list[int] = []
|
|
266
|
+
expected: dict[int, str] = {}
|
|
267
|
+
targets: list[dict] = []
|
|
268
|
+
for s in sessions:
|
|
269
|
+
pids.extend(s.pids)
|
|
270
|
+
expected.update(s.starts)
|
|
271
|
+
for pid in s.pids:
|
|
272
|
+
targets.append({"pid": pid, "cmd": s.cmds.get(pid, ""),
|
|
273
|
+
"label": s.label, "mb": s.mb})
|
|
274
|
+
|
|
275
|
+
event_id = audit.record_intent(reason, targets, dry_run=dry_run)
|
|
276
|
+
outcomes: list[dict] = []
|
|
277
|
+
try:
|
|
278
|
+
signaled = kill_pids(pids, expected, dry_run=dry_run, outcomes=outcomes)
|
|
279
|
+
finally:
|
|
280
|
+
audit.record_outcome(event_id, outcomes)
|
|
281
|
+
return {
|
|
282
|
+
"sessions": len(sessions),
|
|
283
|
+
"pids": signaled,
|
|
284
|
+
"mb": sum(s.mb for s in sessions),
|
|
285
|
+
"dry_run": dry_run,
|
|
286
|
+
"event_id": event_id,
|
|
287
|
+
"outcomes": outcomes,
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def kill_background(
|
|
292
|
+
project_name: str | None = None, *, projects: list[Project] | None = None,
|
|
293
|
+
dry_run: bool = False,
|
|
294
|
+
) -> dict:
|
|
295
|
+
"""Kill orphaned processes that have no Terminal window.
|
|
296
|
+
|
|
297
|
+
project_name=None → all projects. Pass `projects` to act on a scan you
|
|
298
|
+
already have instead of paying for (and diverging from) a fresh one.
|
|
299
|
+
"""
|
|
300
|
+
found = background_sessions(project_name, projects=projects)
|
|
301
|
+
reason = f"orphans:{project_name}" if project_name else "orphans:all"
|
|
302
|
+
return kill_sessions([s for _, s in found], reason=reason, dry_run=dry_run)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def stop_project_stack(name: str, *, projects: list[Project] | None = None,
|
|
306
|
+
dry_run: bool = False) -> dict:
|
|
307
|
+
"""Bring a project's servers down, then quit whatever it left behind.
|
|
308
|
+
|
|
309
|
+
Servers are stopped by shelling out to `devstack`, which is optional. If
|
|
310
|
+
it isn't installed we still quit the orphans, and say which half ran
|
|
311
|
+
rather than reporting success for something that never happened.
|
|
312
|
+
"""
|
|
313
|
+
stopped, reason = False, ""
|
|
314
|
+
if dry_run:
|
|
315
|
+
bg = kill_background(name, projects=projects, dry_run=True)
|
|
316
|
+
return {"project": name, "servers_stopped": False,
|
|
317
|
+
"reason": "dry run: devstack not called", "background": bg}
|
|
318
|
+
try:
|
|
319
|
+
proc = subprocess.run(
|
|
320
|
+
["devstack", "down", name],
|
|
321
|
+
check=False, capture_output=True, text=True, timeout=60,
|
|
322
|
+
)
|
|
323
|
+
stopped = proc.returncode == 0
|
|
324
|
+
if not stopped:
|
|
325
|
+
# The other two reasons here are codes the desk translates before
|
|
326
|
+
# anybody sees them. This one is shown as written, because when
|
|
327
|
+
# there is output it is the tool's own diagnostic and that is the
|
|
328
|
+
# useful thing. So the fallback has to read as English and must
|
|
329
|
+
# not name a program the reader never installed: "devstack failed"
|
|
330
|
+
# went on screen, in a sentence that had just avoided saying it.
|
|
331
|
+
reason = (proc.stderr or proc.stdout
|
|
332
|
+
or "it exited without saying why").strip()
|
|
333
|
+
except FileNotFoundError:
|
|
334
|
+
reason = "devstack not installed"
|
|
335
|
+
except subprocess.TimeoutExpired:
|
|
336
|
+
reason = "devstack timed out"
|
|
337
|
+
except OSError as exc:
|
|
338
|
+
reason = str(exc)
|
|
339
|
+
bg = kill_background(name, projects=projects)
|
|
340
|
+
return {"project": name, "servers_stopped": stopped, "reason": reason,
|
|
341
|
+
"background": bg}
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def close_project_windows(name: str, *, projects: list[Project] | None = None) -> int:
|
|
345
|
+
"""Close Terminal windows belonging to a project. Returns count closed."""
|
|
346
|
+
scan = projects if projects is not None else build_projects()
|
|
347
|
+
hit = next((p for p in scan if p.name == name), None)
|
|
348
|
+
if not hit or not hit.window_ids:
|
|
349
|
+
return 0
|
|
350
|
+
return terminal.close_windows(hit.window_ids)
|
workmap/audit.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""The record of what workmap killed.
|
|
2
|
+
|
|
3
|
+
Every signal this tool sends is written here before and after it is sent. That
|
|
4
|
+
ordering is the point: a crash between the two leaves an "attempted" line, so
|
|
5
|
+
"we never got that far" and "we killed it and something went wrong" stay
|
|
6
|
+
distinguishable. A process killer with no trail cannot be debugged after the
|
|
7
|
+
fact, which is exactly when you need it.
|
|
8
|
+
|
|
9
|
+
The log lives outside the source tree and outside the config, in the state
|
|
10
|
+
directory, because it is neither settings nor code. It is JSON Lines: one
|
|
11
|
+
self-contained object per line, append-only, greppable, and safe to truncate
|
|
12
|
+
from the front.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import time
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
# Resolved per call, not at import: WORKMAP_STATE_DIR lets a test (or a
|
|
22
|
+
# sandboxed run) redirect the log without monkeypatching module constants.
|
|
23
|
+
# Nothing in the suite may append to the real user's kill history.
|
|
24
|
+
def state_dir() -> Path:
|
|
25
|
+
override = os.environ.get("WORKMAP_STATE_DIR")
|
|
26
|
+
if override:
|
|
27
|
+
return Path(override).expanduser()
|
|
28
|
+
return Path.home() / ".local" / "state" / "workmap"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def log_path() -> Path:
|
|
32
|
+
return state_dir() / "kills.jsonl"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# Keep the file bounded without needing logrotate. Trimming happens on write
|
|
36
|
+
# and keeps the newest records.
|
|
37
|
+
#
|
|
38
|
+
# Bounded by bytes, not by a line count times a guessed line length. One
|
|
39
|
+
# record holds every target's command line, so on this machine they average
|
|
40
|
+
# 3.4 KB and the old 200-byte guess put the "is it worth reading?" gate at
|
|
41
|
+
# about 600 records instead of the 10000 it was aiming for. Past that, every
|
|
42
|
+
# kill read and split the whole file before deciding it had nothing to do.
|
|
43
|
+
MAX_BYTES = 4 * 1024 * 1024
|
|
44
|
+
KEEP_BYTES = MAX_BYTES // 2
|
|
45
|
+
MAX_LINES = 5000
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _write(record: dict) -> None:
|
|
49
|
+
"""Append one record. Never raises: logging must not block a kill."""
|
|
50
|
+
try:
|
|
51
|
+
path = log_path()
|
|
52
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
line = json.dumps(record, sort_keys=True, default=str)
|
|
54
|
+
with open(path, "a", encoding="utf-8") as fh:
|
|
55
|
+
fh.write(line + "\n")
|
|
56
|
+
_trim_if_huge(path)
|
|
57
|
+
except OSError:
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _trim_if_huge(path: Path) -> None:
|
|
62
|
+
"""Drop the oldest records once the file gets large.
|
|
63
|
+
|
|
64
|
+
Cut back to half the ceiling rather than to just under it, so this runs
|
|
65
|
+
rarely instead of on every write from the moment the limit is first
|
|
66
|
+
reached.
|
|
67
|
+
"""
|
|
68
|
+
try:
|
|
69
|
+
if not path.exists() or path.stat().st_size < MAX_BYTES:
|
|
70
|
+
return
|
|
71
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
72
|
+
keep: list[str] = []
|
|
73
|
+
total = 0
|
|
74
|
+
for line in reversed(lines):
|
|
75
|
+
total += len(line.encode("utf-8")) + 1
|
|
76
|
+
if keep and (total > KEEP_BYTES or len(keep) >= MAX_LINES):
|
|
77
|
+
break
|
|
78
|
+
keep.append(line)
|
|
79
|
+
keep.reverse()
|
|
80
|
+
tmp = path.with_suffix(".jsonl.tmp")
|
|
81
|
+
tmp.write_text("\n".join(keep) + "\n", encoding="utf-8")
|
|
82
|
+
os.replace(tmp, path)
|
|
83
|
+
except OSError:
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# Two records written in the same millisecond by the same process used to get
|
|
88
|
+
# the same id, and `workmap log` pairs intents to outcomes by id: colliding
|
|
89
|
+
# records overwrite each other, so the log showed fewer kills than happened
|
|
90
|
+
# and could file one kill's outcome under another's intent. Twenty in a loop
|
|
91
|
+
# came back as two. A counter costs nothing and the id only has to be unique,
|
|
92
|
+
# not meaningful; the timestamp is still in there for reading by eye.
|
|
93
|
+
_event_seq = 0
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _next_event_id() -> str:
|
|
97
|
+
global _event_seq
|
|
98
|
+
_event_seq += 1
|
|
99
|
+
return f"{int(time.time() * 1000)}-{os.getpid()}-{_event_seq}"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def record_intent(reason: str, targets: list[dict], *, dry_run: bool) -> str:
|
|
103
|
+
"""Note what is about to be signalled, before anything is signalled.
|
|
104
|
+
|
|
105
|
+
Returns an id that ties this to the outcome record. `targets` is one dict
|
|
106
|
+
per pid: {"pid", "cmd", "project", "label"}.
|
|
107
|
+
"""
|
|
108
|
+
event_id = _next_event_id()
|
|
109
|
+
_write({
|
|
110
|
+
"event": "intent",
|
|
111
|
+
"id": event_id,
|
|
112
|
+
"at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
|
|
113
|
+
"reason": reason,
|
|
114
|
+
"dry_run": dry_run,
|
|
115
|
+
"count": len(targets),
|
|
116
|
+
"targets": targets,
|
|
117
|
+
})
|
|
118
|
+
return event_id
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def record_outcome(event_id: str, outcomes: list[dict]) -> None:
|
|
122
|
+
"""Note what actually happened to each pid.
|
|
123
|
+
|
|
124
|
+
`outcomes` is one dict per pid: {"pid", "signal", "result"} where result is
|
|
125
|
+
one of sent / already-gone / not-permitted / skipped-recycled /
|
|
126
|
+
skipped-unchecked / skipped-protected / dry-run.
|
|
127
|
+
"""
|
|
128
|
+
_write({
|
|
129
|
+
"event": "outcome",
|
|
130
|
+
"id": event_id,
|
|
131
|
+
"at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
|
|
132
|
+
"count": len(outcomes),
|
|
133
|
+
"outcomes": outcomes,
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def read_recent(limit: int = 20) -> list[dict]:
|
|
138
|
+
"""The most recent records, newest last. For `workmap log`."""
|
|
139
|
+
if limit <= 0:
|
|
140
|
+
# `lines[-0:]` is the whole list, so `workmap log 0` printed every
|
|
141
|
+
# record it had rather than none.
|
|
142
|
+
return []
|
|
143
|
+
try:
|
|
144
|
+
lines = log_path().read_text(encoding="utf-8").splitlines()
|
|
145
|
+
except OSError:
|
|
146
|
+
return []
|
|
147
|
+
out: list[dict] = []
|
|
148
|
+
for line in lines[-limit:]:
|
|
149
|
+
try:
|
|
150
|
+
out.append(json.loads(line))
|
|
151
|
+
except ValueError:
|
|
152
|
+
continue
|
|
153
|
+
return out
|