entropy-machines 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +93 -0
- package/README.md +68 -0
- package/agents/isolated-worker.md +128 -0
- package/agents/verifier.md +158 -0
- package/bin/dispatch +700 -0
- package/bin/doclint +460 -0
- package/bin/drain +507 -0
- package/bin/drain-pick.py +168 -0
- package/bin/drain-prompt.md +67 -0
- package/bin/drain-run.sh +342 -0
- package/bin/entropy-machines-init +285 -0
- package/bin/handoff +1151 -0
- package/bin/init +232 -0
- package/bin/post-fold-audit +377 -0
- package/bin/serve +724 -0
- package/bin/status +208 -0
- package/bin/tracker +153 -0
- package/docs/AGENT-QUICKSTART.md +86 -0
- package/docs/CONFIG.md +68 -0
- package/docs/NPM.md +91 -0
- package/docs/SERVE.md +74 -0
- package/docs/TRACKER-ADAPTER.md +66 -0
- package/doctrine/HANDOFF-PROMPT.md +63 -0
- package/doctrine/README.md +62 -0
- package/doctrine/ROLES.md +27 -0
- package/doctrine/WORKFLOW.md +87 -0
- package/hooks/commit-msg +24 -0
- package/hooks/post-checkout +354 -0
- package/hooks/pre-commit +33 -0
- package/lib/PRD-001-orientation.html +1180 -0
- package/lib/REPORT-TEMPLATE.html +413 -0
- package/lib/changelog-collate.mjs +328 -0
- package/lib/changelog-guard.sh +157 -0
- package/lib/changelog-new.mjs +70 -0
- package/lib/config.mjs +283 -0
- package/lib/config.py +317 -0
- package/lib/doc-template.html +807 -0
- package/lib/entropy-drain.plist.in +59 -0
- package/lib/entropy-drain.service.in +53 -0
- package/lib/entropy-drain.timer.in +36 -0
- package/lib/fail-first.mjs +901 -0
- package/lib/handoff-guard.sh +623 -0
- package/lib/install-hooks.sh +169 -0
- package/lib/notes.py +675 -0
- package/lib/preflight-tree.mjs +82 -0
- package/lib/roots.sh +212 -0
- package/lib/themes/daylight.css +84 -0
- package/lib/themes/high-contrast.css +36 -0
- package/lib/tracker-file +333 -0
- package/lib/tracker-view.py +784 -0
- package/package.json +38 -0
package/bin/drain
ADDED
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Arm, disarm, inspect, install, or hand-fire the unattended drainer.
|
|
3
|
+
#
|
|
4
|
+
# bin/drain on # arm it — the next scheduled fire does work
|
|
5
|
+
# bin/drain off # disarm — fires still happen and exit immediately
|
|
6
|
+
# bin/drain status # armed? scheduled? last fire? last quota wall? eligible?
|
|
7
|
+
# bin/drain now # run one fire in the foreground, right now
|
|
8
|
+
# bin/drain at HH:MM # one detached one-shot fire, today or tomorrow
|
|
9
|
+
# bin/drain cancel # cancel a pending one-shot
|
|
10
|
+
# bin/drain install # install the scheduled job (launchd on macOS,
|
|
11
|
+
# # a systemd --user timer on Linux)
|
|
12
|
+
#
|
|
13
|
+
# WHY A FLAG FILE RATHER THAN LOADING/UNLOADING THE SCHEDULER.
|
|
14
|
+
#
|
|
15
|
+
# Unloading a scheduled job is a stateful operation on the job definition that
|
|
16
|
+
# also has to survive reboots and login cycles, and getting it wrong leaves the
|
|
17
|
+
# job either permanently dead or permanently running with no obvious tell. A
|
|
18
|
+
# flag file is a boolean the runner reads at the top of every fire: `off` takes
|
|
19
|
+
# effect on the next fire with nothing to reload, `on` needs no privileges, and
|
|
20
|
+
# `status` can answer honestly by reading one path. The scheduled job stays
|
|
21
|
+
# installed either way.
|
|
22
|
+
set -e
|
|
23
|
+
|
|
24
|
+
. "$(dirname "$0")/../lib/roots.sh"
|
|
25
|
+
ENTROPY_MACHINES_HOME=$(entropy_machines_home "$0")
|
|
26
|
+
entropy_machines_require_root drain
|
|
27
|
+
os=$(uname)
|
|
28
|
+
|
|
29
|
+
# ---- config -----------------------------------------------------------------
|
|
30
|
+
# cfg <dotted.path> <default>
|
|
31
|
+
# One value out of config.json at the project root. A missing file, a missing
|
|
32
|
+
# key, or a value that is not a plain scalar all fall back to <default> — this
|
|
33
|
+
# harness has to keep running before a project has written every key, per
|
|
34
|
+
# docs/CONFIG.md rule 2 (a working default, or a refusal naming the key; the
|
|
35
|
+
# refusal is this script's job for the keys that truly have none, e.g. an
|
|
36
|
+
# unsupported scheduler below).
|
|
37
|
+
cfg() {
|
|
38
|
+
python3 - "$ENTROPY_MACHINES_HOME/config.json" "$1" "$2" <<'PY'
|
|
39
|
+
import json, sys
|
|
40
|
+
path, key, default = sys.argv[1], sys.argv[2], sys.argv[3]
|
|
41
|
+
try:
|
|
42
|
+
with open(path) as f:
|
|
43
|
+
data = json.load(f)
|
|
44
|
+
except Exception:
|
|
45
|
+
print(default); sys.exit(0)
|
|
46
|
+
cur = data
|
|
47
|
+
# TOLERATE A LEADING DOT. Every call site in this file and in bin/drain writes
|
|
48
|
+
# the key as '.unattended.x' (a jq habit), which split('.') turns into a first
|
|
49
|
+
# segment of '' that matches no dict key — so this helper silently returned its
|
|
50
|
+
# DEFAULT for every lookup, and no unattended setting in config.json had any
|
|
51
|
+
# effect. Fixed here rather than at a dozen call sites so both spellings work.
|
|
52
|
+
for part in key.lstrip('.').split('.'):
|
|
53
|
+
if isinstance(cur, dict) and part in cur:
|
|
54
|
+
cur = cur[part]
|
|
55
|
+
else:
|
|
56
|
+
print(default); sys.exit(0)
|
|
57
|
+
print(default if cur is None else (cur if isinstance(cur, str) else json.dumps(cur)))
|
|
58
|
+
PY
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
state_default=$(cfg '.unattended.stateHome' '~/.entropy')
|
|
62
|
+
case "$state_default" in
|
|
63
|
+
"~"*) state_default="$HOME${state_default#\~}" ;;
|
|
64
|
+
esac
|
|
65
|
+
state="${ENTROPY_DRAIN_HOME:-$state_default}"
|
|
66
|
+
label=$(cfg '.unattended.label' 'com.entropy.drain')
|
|
67
|
+
flag="$state/armed"
|
|
68
|
+
log="$state/last-run.json"
|
|
69
|
+
|
|
70
|
+
mkdir -p "$state"
|
|
71
|
+
|
|
72
|
+
# ---- which scheduler ---------------------------------------------------------
|
|
73
|
+
# Sets $scheduler, $scheduler_source, $have_launchd, $have_systemd. Called by
|
|
74
|
+
# both `status` and `install` so the two cannot disagree about what this machine
|
|
75
|
+
# can do — the previous split (status hardcoded "Darwin or unsupported", install
|
|
76
|
+
# hardcoded a Darwin refusal) is exactly the shape that let `status` claim a
|
|
77
|
+
# platform had no scheduler while an installer for it existed.
|
|
78
|
+
#
|
|
79
|
+
# AVAILABLE means the client binary is reachable, not that the platform sounds
|
|
80
|
+
# right: launchd needs macOS AND launchctl; systemd needs systemctl (present on
|
|
81
|
+
# Linux, and absent on a Linux running some other init, which is the case a
|
|
82
|
+
# uname check gets wrong).
|
|
83
|
+
#
|
|
84
|
+
# An explicit unattended.scheduler always wins, including when it names one this
|
|
85
|
+
# machine cannot run — a wrong value must be a refusal that says so, never a
|
|
86
|
+
# silent fallback to the other scheduler. Only an ABSENT key is detected.
|
|
87
|
+
drain_scheduler_probe() {
|
|
88
|
+
have_launchd=no
|
|
89
|
+
have_systemd=no
|
|
90
|
+
# `if`, not `[ test ] && VAR=value` — see the note in `install` below: as a
|
|
91
|
+
# bare statement under `set -e` that form EXITS the script when the test is
|
|
92
|
+
# false, which is how a probe would take the whole command down on Linux.
|
|
93
|
+
if [ "$os" = Darwin ] && command -v launchctl >/dev/null 2>&1; then
|
|
94
|
+
have_launchd=yes
|
|
95
|
+
fi
|
|
96
|
+
if command -v systemctl >/dev/null 2>&1; then
|
|
97
|
+
have_systemd=yes
|
|
98
|
+
fi
|
|
99
|
+
scheduler=$(cfg '.unattended.scheduler' '')
|
|
100
|
+
if [ -n "$scheduler" ]; then
|
|
101
|
+
scheduler_source=config
|
|
102
|
+
return 0
|
|
103
|
+
fi
|
|
104
|
+
scheduler_source=detected
|
|
105
|
+
if [ "$have_launchd" = yes ]; then
|
|
106
|
+
scheduler=launchd
|
|
107
|
+
elif [ "$have_systemd" = yes ]; then
|
|
108
|
+
scheduler=systemd
|
|
109
|
+
else
|
|
110
|
+
scheduler=none
|
|
111
|
+
fi
|
|
112
|
+
return 0
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
# What the probe looked for, printed verbatim by every refusal so the message
|
|
116
|
+
# names the check rather than the conclusion.
|
|
117
|
+
drain_scheduler_looked_for() {
|
|
118
|
+
echo " It looked for:" >&2
|
|
119
|
+
echo " launchd — macOS (uname reports '$os') and 'launchctl' on PATH: $have_launchd" >&2
|
|
120
|
+
echo " systemd — 'systemctl' on PATH: $have_systemd" >&2
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
case "${1:-status}" in
|
|
124
|
+
on)
|
|
125
|
+
date -u +%Y-%m-%dT%H:%M:%SZ > "$flag"
|
|
126
|
+
echo "drain: ARMED (since $(cat "$flag"))"
|
|
127
|
+
echo " the next scheduled fire will pick up work; 'bin/drain off' stops it."
|
|
128
|
+
;;
|
|
129
|
+
|
|
130
|
+
off)
|
|
131
|
+
rm -f "$flag"
|
|
132
|
+
echo "drain: DISARMED"
|
|
133
|
+
echo " fires still happen on schedule and exit immediately. Nothing to unload."
|
|
134
|
+
;;
|
|
135
|
+
|
|
136
|
+
status)
|
|
137
|
+
# Two independent switches, and confusing them is the likely support
|
|
138
|
+
# question: the job can be installed but disarmed (fires, does nothing), or
|
|
139
|
+
# armed but never installed (nothing ever fires). Report both.
|
|
140
|
+
scheduled=no
|
|
141
|
+
drain_scheduler_probe
|
|
142
|
+
case "$scheduler" in
|
|
143
|
+
launchd)
|
|
144
|
+
if [ "$have_launchd" != yes ]; then
|
|
145
|
+
scheduled=unavailable
|
|
146
|
+
elif launchctl list 2>/dev/null | grep -q "$label"; then
|
|
147
|
+
scheduled=yes
|
|
148
|
+
fi
|
|
149
|
+
;;
|
|
150
|
+
systemd)
|
|
151
|
+
if [ "$have_systemd" != yes ]; then
|
|
152
|
+
scheduled=unavailable
|
|
153
|
+
elif systemctl --user is-active --quiet "$label.timer" 2>/dev/null; then
|
|
154
|
+
# The TIMER is the thing that is loaded and waiting; the .service it
|
|
155
|
+
# fires is inactive almost all the time and would read as "no".
|
|
156
|
+
scheduled=yes
|
|
157
|
+
fi
|
|
158
|
+
;;
|
|
159
|
+
none)
|
|
160
|
+
# Nothing this installer implements exists on this machine, so there is
|
|
161
|
+
# no job of ours to look for — say that rather than pointing at an
|
|
162
|
+
# install that refuses.
|
|
163
|
+
scheduled=unsupported
|
|
164
|
+
;;
|
|
165
|
+
*)
|
|
166
|
+
# config.json names a scheduler this installer does not implement. A
|
|
167
|
+
# different message: the machine may well be fine, the config is not.
|
|
168
|
+
scheduled=unknown
|
|
169
|
+
;;
|
|
170
|
+
esac
|
|
171
|
+
if [ "$scheduled" = yes ]; then
|
|
172
|
+
echo "scheduled: yes (hourly, at :07, via $scheduler)"
|
|
173
|
+
elif [ "$scheduled" = unknown ]; then
|
|
174
|
+
echo "scheduled: n/a — config.json's unattended.scheduler is '$scheduler',"
|
|
175
|
+
echo " which 'bin/drain install' does not implement (launchd, systemd)"
|
|
176
|
+
elif [ "$scheduled" = unavailable ]; then
|
|
177
|
+
# The scheduler was NAMED (only config.json can name one that is not
|
|
178
|
+
# here — detection never picks an unusable one), and it is not usable on
|
|
179
|
+
# this machine. Say which one and which check failed, not "unsupported":
|
|
180
|
+
# the other scheduler may well be sitting right there.
|
|
181
|
+
echo "scheduled: n/a — unattended.scheduler is '$scheduler' ($scheduler_source),"
|
|
182
|
+
echo " not usable here (launchctl: $have_launchd, systemctl: $have_systemd, uname: $os);"
|
|
183
|
+
echo " 'bin/drain install' will refuse and say the same"
|
|
184
|
+
elif [ "$scheduled" = unsupported ]; then
|
|
185
|
+
echo "scheduled: n/a — no scheduler 'bin/drain install' can use is available"
|
|
186
|
+
echo " here (launchctl: $have_launchd, systemctl: $have_systemd, uname: $os);"
|
|
187
|
+
echo " fire bin/drain-run.sh from your own cron entry instead"
|
|
188
|
+
else
|
|
189
|
+
echo "scheduled: no — run 'bin/drain install'"
|
|
190
|
+
fi
|
|
191
|
+
if [ -f "$state/once.pid" ] && kill -0 "$(cat "$state/once.pid")" 2>/dev/null; then
|
|
192
|
+
echo "one-shot: $(cat "$state/once.at" 2>/dev/null) (pid $(cat "$state/once.pid"))"
|
|
193
|
+
else
|
|
194
|
+
echo "one-shot: none — 'bin/drain at HH:MM' to set one"
|
|
195
|
+
fi
|
|
196
|
+
if [ -f "$flag" ]; then
|
|
197
|
+
echo "armed: yes (since $(cat "$flag"))"
|
|
198
|
+
else
|
|
199
|
+
echo "armed: no — run 'bin/drain on'"
|
|
200
|
+
fi
|
|
201
|
+
if [ -f "$log" ]; then
|
|
202
|
+
# Heredoc + env var rather than `python3 -c '...'`: the inline form needs
|
|
203
|
+
# quotes nested three deep (sh, python, f-string) and the first version
|
|
204
|
+
# of this line printed its own source back four times.
|
|
205
|
+
DRAIN_LOG="$log" python3 <<'PY'
|
|
206
|
+
import json, os
|
|
207
|
+
try:
|
|
208
|
+
d = json.load(open(os.environ["DRAIN_LOG"]))
|
|
209
|
+
print(f'last fire: {d.get("finishedAt", "?")} — {d.get("outcome", "?")}'
|
|
210
|
+
f' ({d.get("detail", "")})')
|
|
211
|
+
except Exception as exc:
|
|
212
|
+
print(f"last fire: unreadable ({exc})")
|
|
213
|
+
PY
|
|
214
|
+
else
|
|
215
|
+
echo "last fire: never"
|
|
216
|
+
fi
|
|
217
|
+
# The quota probe lives in bin/drain-run.sh (--probe-quota) so there is
|
|
218
|
+
# exactly one implementation; see that file for what it assumes.
|
|
219
|
+
lookback=$(cfg '.unattended.agent.quotaProbe.lookbackSeconds' '3600')
|
|
220
|
+
l=$("$ENTROPY_MACHINES_HOME/bin/drain-run.sh" --probe-quota 2>/dev/null || true)
|
|
221
|
+
if [ -n "$l" ]; then
|
|
222
|
+
echo "quota wall: $l"
|
|
223
|
+
else
|
|
224
|
+
echo "quota wall: none seen in the last $((lookback / 60))m (or no probe configured)"
|
|
225
|
+
fi
|
|
226
|
+
# What it would actually take, so `status` answers "is there anything to
|
|
227
|
+
# do" and not merely "is the switch up".
|
|
228
|
+
if [ -x "$ENTROPY_MACHINES_HOME/bin/tracker" ]; then
|
|
229
|
+
n=$("$ENTROPY_MACHINES_HOME/bin/tracker" ready 2>/dev/null | grep -c . || true)
|
|
230
|
+
echo "eligible: ${n:-0} issue(s) an unattended run may take"
|
|
231
|
+
fi
|
|
232
|
+
;;
|
|
233
|
+
|
|
234
|
+
now)
|
|
235
|
+
echo "drain: firing once in the foreground (ignores the armed flag)"
|
|
236
|
+
exec "$ENTROPY_MACHINES_HOME/bin/drain-run.sh" --foreground
|
|
237
|
+
;;
|
|
238
|
+
|
|
239
|
+
at)
|
|
240
|
+
# One fire at a time you name — the answer to "can I just read a reset time
|
|
241
|
+
# off the agent's own usage panel and resume then". You can, and a rolling
|
|
242
|
+
# window means that reading is only true for today, which is exactly what a
|
|
243
|
+
# ONE-SHOT is for. The hourly probe chases a moving window; this hits a
|
|
244
|
+
# boundary you already know. They do not conflict and neither needs the
|
|
245
|
+
# other.
|
|
246
|
+
when="$2"
|
|
247
|
+
case "$when" in
|
|
248
|
+
[0-9][0-9]:[0-9][0-9]) ;;
|
|
249
|
+
*) echo "usage: bin/drain at HH:MM (24-hour, local)" >&2; exit 2 ;;
|
|
250
|
+
esac
|
|
251
|
+
hh=${when%:*}; mm=${when#*:}
|
|
252
|
+
# Strip a leading zero before arithmetic — 08 and 09 are invalid octal.
|
|
253
|
+
hh=$((10#$hh)); mm=$((10#$mm))
|
|
254
|
+
if [ "$hh" -gt 23 ] || [ "$mm" -gt 59 ]; then
|
|
255
|
+
echo "bin/drain at: $when is not a real time" >&2; exit 2
|
|
256
|
+
fi
|
|
257
|
+
# A DETACHED SLEEP, not a scheduled job. On macOS specifically, a repo
|
|
258
|
+
# under a TCC-protected folder (see `install` below) cannot be reached by
|
|
259
|
+
# a LaunchAgent at all, but a process spawned from your terminal inherits
|
|
260
|
+
# the terminal's TCC grant and reads the repo fine — so this is the form
|
|
261
|
+
# that works everywhere, no permission changes, no scheduler install.
|
|
262
|
+
#
|
|
263
|
+
# The trade: it does not survive a reboot, and if the machine sleeps
|
|
264
|
+
# through the target the timer resumes on wake and fires late rather than
|
|
265
|
+
# on time. For "resume at the reset I just read off my own usage panel",
|
|
266
|
+
# that is the right shape anyway — you are naming a moment tonight, not a
|
|
267
|
+
# standing schedule.
|
|
268
|
+
#
|
|
269
|
+
# Portable date math: BSD date (macOS) and GNU date (Linux) parse and
|
|
270
|
+
# format dates with incompatible flags (`-j -f` vs `-d`), and neither can
|
|
271
|
+
# be assumed present in the other's form. Routing through python3's
|
|
272
|
+
# stdlib `datetime` sidesteps both dialects entirely — it behaves
|
|
273
|
+
# identically on every platform this harness targets, unlike `date` itself.
|
|
274
|
+
now_s=$(date +%s)
|
|
275
|
+
tgt_s=$(python3 - "$hh" "$mm" <<'PY'
|
|
276
|
+
import datetime, sys
|
|
277
|
+
hh, mm = int(sys.argv[1]), int(sys.argv[2])
|
|
278
|
+
tgt = datetime.datetime.now().replace(hour=hh, minute=mm, second=0, microsecond=0)
|
|
279
|
+
print(int(tgt.timestamp()))
|
|
280
|
+
PY
|
|
281
|
+
)
|
|
282
|
+
[ -z "$tgt_s" ] && { echo "bin/drain at: could not compute a target time" >&2; exit 2; }
|
|
283
|
+
if [ "$tgt_s" -le "$now_s" ]; then
|
|
284
|
+
tgt_s=$((tgt_s + 86400)) # already past today → mean tomorrow
|
|
285
|
+
fi
|
|
286
|
+
wait_s=$((tgt_s - now_s))
|
|
287
|
+
tgt_str=$(python3 - "$tgt_s" <<'PY'
|
|
288
|
+
import datetime, sys
|
|
289
|
+
print(datetime.datetime.fromtimestamp(int(sys.argv[1])).strftime('%Y-%m-%d %H:%M'))
|
|
290
|
+
PY
|
|
291
|
+
)
|
|
292
|
+
# Replace any pending one-shot rather than stacking two fires.
|
|
293
|
+
if [ -f "$state/once.pid" ] && kill -0 "$(cat "$state/once.pid")" 2>/dev/null; then
|
|
294
|
+
kill "$(cat "$state/once.pid")" 2>/dev/null || true
|
|
295
|
+
fi
|
|
296
|
+
nohup sh -c "sleep $wait_s; exec '$ENTROPY_MACHINES_HOME/bin/drain-run.sh' --foreground" \
|
|
297
|
+
> "$state/once.log" 2>&1 &
|
|
298
|
+
echo $! > "$state/once.pid"
|
|
299
|
+
printf '%s\n' "$tgt_str" > "$state/once.at"
|
|
300
|
+
echo "drain: one fire at $tgt_str — in $((wait_s / 60)) min (pid $(cat "$state/once.pid"))"
|
|
301
|
+
echo " runs whether or not 'drain on' is set; log: $state/once.log"
|
|
302
|
+
echo " cancel with: bin/drain cancel"
|
|
303
|
+
echo " NOTE: a detached timer, so it does not survive a reboot."
|
|
304
|
+
;;
|
|
305
|
+
|
|
306
|
+
cancel)
|
|
307
|
+
if [ -f "$state/once.pid" ] && kill -0 "$(cat "$state/once.pid")" 2>/dev/null; then
|
|
308
|
+
kill "$(cat "$state/once.pid")" 2>/dev/null || true
|
|
309
|
+
echo "drain: cancelled the one-shot that was set for $(cat "$state/once.at" 2>/dev/null)"
|
|
310
|
+
else
|
|
311
|
+
echo "drain: no one-shot pending"
|
|
312
|
+
fi
|
|
313
|
+
rm -f "$state/once.pid" "$state/once.at"
|
|
314
|
+
if [ "$os" = "Darwin" ] && command -v launchctl >/dev/null 2>&1; then
|
|
315
|
+
launchctl bootout "gui/$(id -u)/$label.once" 2>/dev/null || true
|
|
316
|
+
rm -f "$HOME/Library/LaunchAgents/$label.once.plist"
|
|
317
|
+
fi
|
|
318
|
+
;;
|
|
319
|
+
|
|
320
|
+
install)
|
|
321
|
+
# ONLY THE INSTALLER IS PLATFORM-SPECIFIC. Everything else in this script —
|
|
322
|
+
# on, off, status, now, at, cancel — is plain POSIX sh and runs anywhere, so
|
|
323
|
+
# the platform check belongs here and nowhere higher up.
|
|
324
|
+
#
|
|
325
|
+
# Two schedulers, and nothing else. An installer that reports success and
|
|
326
|
+
# leaves a broken job behind is worse than no installer, because every
|
|
327
|
+
# surface (`status`, `launchctl list`, `systemctl --user is-active`) then
|
|
328
|
+
# agrees it is armed — so anything we cannot write a unit for is a refusal
|
|
329
|
+
# that installs nothing, and says which check failed.
|
|
330
|
+
drain_scheduler_probe
|
|
331
|
+
|
|
332
|
+
runner="$ENTROPY_MACHINES_HOME/bin/drain-run.sh"
|
|
333
|
+
|
|
334
|
+
if [ "$scheduler" = none ]; then
|
|
335
|
+
echo "bin/drain install: refusing — no scheduler this installer supports is" >&2
|
|
336
|
+
echo " available on this machine, and config.json's unattended.scheduler" >&2
|
|
337
|
+
echo " does not name one." >&2
|
|
338
|
+
drain_scheduler_looked_for
|
|
339
|
+
echo " Nothing was installed. Drive the fires yourself instead: put" >&2
|
|
340
|
+
echo " $runner" >&2
|
|
341
|
+
echo " on your own cron entry or timer. 'bin/drain on/off' is still the" >&2
|
|
342
|
+
echo " arm/disarm switch whatever triggers the fire, and 'bin/drain now'" >&2
|
|
343
|
+
echo " / 'bin/drain at HH:MM' need no scheduler at all." >&2
|
|
344
|
+
exit 3
|
|
345
|
+
fi
|
|
346
|
+
|
|
347
|
+
case "$scheduler" in
|
|
348
|
+
launchd)
|
|
349
|
+
# The platform gate that used to sit above the whole command, now
|
|
350
|
+
# scoped to the path it is actually about. Reached when this is the
|
|
351
|
+
# DETECTED scheduler (in which case it passes by construction) or when
|
|
352
|
+
# config.json asks for launchd on a machine that has no launchctl.
|
|
353
|
+
if [ "$have_launchd" != yes ]; then
|
|
354
|
+
echo "bin/drain install: refusing — unattended.scheduler is 'launchd'" >&2
|
|
355
|
+
echo " ($scheduler_source), and launchd is not usable on this machine." >&2
|
|
356
|
+
drain_scheduler_looked_for
|
|
357
|
+
if [ "$have_systemd" = yes ]; then
|
|
358
|
+
echo " systemd IS available here: set unattended.scheduler to 'systemd'" >&2
|
|
359
|
+
echo " in config.json, or remove the key and let this detect it." >&2
|
|
360
|
+
fi
|
|
361
|
+
echo " Nothing was installed. 'bin/drain now' / 'bin/drain at HH:MM'" >&2
|
|
362
|
+
echo " need no scheduler at all." >&2
|
|
363
|
+
exit 3
|
|
364
|
+
fi
|
|
365
|
+
# TCC (Transparency, Consent and Control) — a macOS-only OS security
|
|
366
|
+
# subsystem — refuses a LaunchAgent filesystem access to ~/Desktop,
|
|
367
|
+
# ~/Documents and ~/Downloads. Installing a job that is denied before
|
|
368
|
+
# its first line is worse than installing none, because `launchctl
|
|
369
|
+
# list` shows it loaded and everything looks armed. Check the actual
|
|
370
|
+
# repo location(s) and say so. This entire check is a macOS concept
|
|
371
|
+
# and does not apply on Linux.
|
|
372
|
+
#
|
|
373
|
+
# Both locations are checked: launchd has to reach ENTROPY_MACHINES_HOME to
|
|
374
|
+
# exec the runner script at all, and drain-run.sh then reads/writes
|
|
375
|
+
# inside ENTROPY_MACHINES_ROOT (git, config.json, .entropy/) to do the work.
|
|
376
|
+
# They are usually the same tree now, but not the same DIRECTORY when
|
|
377
|
+
# the harness is vendored in a subdirectory, so either one sitting
|
|
378
|
+
# under a protected folder denies the job — checked separately so the
|
|
379
|
+
# message names the one that is actually the problem.
|
|
380
|
+
tcc_blocked=""
|
|
381
|
+
for tcc_dir in "$ENTROPY_MACHINES_HOME" "$ENTROPY_MACHINES_ROOT"; do
|
|
382
|
+
case "$tcc_dir" in
|
|
383
|
+
"$HOME"/Desktop/*|"$HOME"/Documents/*|"$HOME"/Downloads/*)
|
|
384
|
+
echo "bin/drain install: refusing — this is in a TCC-protected folder:" >&2
|
|
385
|
+
echo " $tcc_dir" >&2
|
|
386
|
+
tcc_blocked=1
|
|
387
|
+
;;
|
|
388
|
+
esac
|
|
389
|
+
done
|
|
390
|
+
if [ -n "$tcc_blocked" ]; then
|
|
391
|
+
echo " A LaunchAgent is denied here before it runs a line. Three ways forward:" >&2
|
|
392
|
+
echo " 1. bin/drain at HH:MM — a detached timer, works today," >&2
|
|
393
|
+
echo " no permissions, no reboot survival" >&2
|
|
394
|
+
echo " 2. move it out of that folder (e.g. ~/code/<project>)" >&2
|
|
395
|
+
echo " 3. System Settings -> Privacy & Security -> Full Disk Access," >&2
|
|
396
|
+
echo " add /bin/sh — broad, since it grants every shell script" >&2
|
|
397
|
+
echo " Override once you have done 2 or 3: ENTROPY_DRAIN_FORCE=1" >&2
|
|
398
|
+
# `if`, not `[ ... ] && exit`. Under `set -e` a bare `[ test ] &&
|
|
399
|
+
# cmd` used as a STATEMENT exits the whole script when the test is
|
|
400
|
+
# FALSE — so with the override set, this line silently killed the
|
|
401
|
+
# install it was meant to permit. Also `${...:-}`: the variable is
|
|
402
|
+
# normally unset.
|
|
403
|
+
if [ -z "${ENTROPY_DRAIN_FORCE:-}" ]; then
|
|
404
|
+
exit 3
|
|
405
|
+
fi
|
|
406
|
+
echo " ENTROPY_DRAIN_FORCE set — installing anyway." >&2
|
|
407
|
+
fi
|
|
408
|
+
# A scheduler unit fires with no useful cwd. That used to be a gap:
|
|
409
|
+
# nothing carried the project root in, so an installed job only worked
|
|
410
|
+
# when the harness sat AT the project root. With one root it resolves
|
|
411
|
+
# itself — bin/drain-run.sh cd's into the harness directory, which is
|
|
412
|
+
# inside the repo, and git answers from there. No placeholder needed.
|
|
413
|
+
plist="$HOME/Library/LaunchAgents/$label.plist"
|
|
414
|
+
mkdir -p "$HOME/Library/LaunchAgents"
|
|
415
|
+
sed "s|__RUNNER__|$runner|g; s|__STATE__|$state|g; s|__LABEL__|$label|g" \
|
|
416
|
+
"$ENTROPY_MACHINES_HOME/lib/entropy-drain.plist.in" > "$plist"
|
|
417
|
+
launchctl bootout "gui/$(id -u)/$label" 2>/dev/null || true
|
|
418
|
+
launchctl bootstrap "gui/$(id -u)" "$plist"
|
|
419
|
+
echo "drain: launchd job installed and loaded (hourly probe)"
|
|
420
|
+
echo " it is INERT until you run 'bin/drain on'."
|
|
421
|
+
echo " to remove: launchctl bootout gui/$(id -u)/$label && rm $plist"
|
|
422
|
+
echo
|
|
423
|
+
echo " One step left, and it needs sudo so it is yours to run — without it"
|
|
424
|
+
echo " the job cannot fire while the machine is asleep:"
|
|
425
|
+
echo " sudo pmset repeat wakeorpoweron MTWRFSU 02:00:00"
|
|
426
|
+
;;
|
|
427
|
+
|
|
428
|
+
systemd)
|
|
429
|
+
if [ "$have_systemd" != yes ]; then
|
|
430
|
+
echo "bin/drain install: refusing — unattended.scheduler is 'systemd'" >&2
|
|
431
|
+
echo " ($scheduler_source), and there is no 'systemctl' on PATH here." >&2
|
|
432
|
+
drain_scheduler_looked_for
|
|
433
|
+
if [ "$have_launchd" = yes ]; then
|
|
434
|
+
echo " launchd IS available here: set unattended.scheduler to 'launchd'" >&2
|
|
435
|
+
echo " in config.json, or remove the key and let this detect it." >&2
|
|
436
|
+
fi
|
|
437
|
+
echo " Nothing was installed. 'bin/drain now' / 'bin/drain at HH:MM'" >&2
|
|
438
|
+
echo " need no scheduler at all." >&2
|
|
439
|
+
exit 3
|
|
440
|
+
fi
|
|
441
|
+
# systemctl EXISTS is not the same as a user manager we can talk to: in
|
|
442
|
+
# an ssh session or a container with no per-user systemd instance,
|
|
443
|
+
# `--user` fails on a missing DBUS_SESSION_BUS_ADDRESS/XDG_RUNTIME_DIR.
|
|
444
|
+
# Ask before writing anything, so the failure mode is a refusal rather
|
|
445
|
+
# than two unit files on disk that no daemon-reload ever picked up.
|
|
446
|
+
if ! systemctl --user show-environment >/dev/null 2>&1; then
|
|
447
|
+
echo "bin/drain install: refusing — 'systemctl --user' cannot reach a user" >&2
|
|
448
|
+
echo " manager for $(id -un) (no session bus; XDG_RUNTIME_DIR=${XDG_RUNTIME_DIR:-unset})." >&2
|
|
449
|
+
echo " This is usually an ssh or container session with no logind seat." >&2
|
|
450
|
+
echo " Nothing was installed. Either run this from a full login session," >&2
|
|
451
|
+
echo " or enable a lingering one first:" >&2
|
|
452
|
+
echo " sudo loginctl enable-linger $(id -un)" >&2
|
|
453
|
+
exit 3
|
|
454
|
+
fi
|
|
455
|
+
# No TCC equivalent to check: the macOS branch above refuses a repo
|
|
456
|
+
# under ~/Desktop, ~/Documents or ~/Downloads because a LaunchAgent is
|
|
457
|
+
# denied those paths by the OS before it runs a line. Linux has no such
|
|
458
|
+
# per-directory grant for a user unit — a user timer reads whatever the
|
|
459
|
+
# user can read — so there is nothing here to refuse for.
|
|
460
|
+
#
|
|
461
|
+
# A user unit, so no sudo and no system-wide state: everything lands
|
|
462
|
+
# under the operator's own XDG config dir and is undone by deleting two
|
|
463
|
+
# files. Same substitution as the plist, from templates beside it.
|
|
464
|
+
unit_dir="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
|
|
465
|
+
service="$unit_dir/$label.service"
|
|
466
|
+
timer="$unit_dir/$label.timer"
|
|
467
|
+
mkdir -p "$unit_dir"
|
|
468
|
+
sed "s|__RUNNER__|$runner|g; s|__STATE__|$state|g; s|__LABEL__|$label|g" \
|
|
469
|
+
"$ENTROPY_MACHINES_HOME/lib/entropy-drain.service.in" > "$service"
|
|
470
|
+
sed "s|__RUNNER__|$runner|g; s|__STATE__|$state|g; s|__LABEL__|$label|g" \
|
|
471
|
+
"$ENTROPY_MACHINES_HOME/lib/entropy-drain.timer.in" > "$timer"
|
|
472
|
+
systemctl --user daemon-reload
|
|
473
|
+
# --now starts the timer as well as enabling it, so the first fire does
|
|
474
|
+
# not wait for the next login. The TIMER is enabled, never the service:
|
|
475
|
+
# enabling a oneshot service would run it at boot instead of on
|
|
476
|
+
# schedule. It is still inert until `bin/drain on`.
|
|
477
|
+
systemctl --user enable --now "$label.timer"
|
|
478
|
+
echo "drain: systemd user timer installed and started (hourly probe)"
|
|
479
|
+
echo " it is INERT until you run 'bin/drain on'."
|
|
480
|
+
echo " to check: systemctl --user list-timers $label.timer"
|
|
481
|
+
echo " to remove: systemctl --user disable --now $label.timer &&"
|
|
482
|
+
echo " rm $timer $service && systemctl --user daemon-reload"
|
|
483
|
+
echo
|
|
484
|
+
echo " One step left, and it needs sudo so it is yours to run — without it"
|
|
485
|
+
echo " the timer is torn down when your last session logs out, and never"
|
|
486
|
+
echo " fires on a machine you are not sitting at:"
|
|
487
|
+
echo " sudo loginctl enable-linger $(id -un)"
|
|
488
|
+
;;
|
|
489
|
+
|
|
490
|
+
*)
|
|
491
|
+
echo "bin/drain install: unknown scheduler '$scheduler' (unattended.scheduler" >&2
|
|
492
|
+
echo " in config.json) — the schedulers this installer supports are" >&2
|
|
493
|
+
echo " 'launchd' (macOS) and 'systemd' (a systemd --user timer). Remove the" >&2
|
|
494
|
+
echo " key to have it detect one. On anything else, call bin/drain-run.sh" >&2
|
|
495
|
+
echo " from a timer you write yourself (cron, a CI schedule)." >&2
|
|
496
|
+
exit 2
|
|
497
|
+
;;
|
|
498
|
+
esac
|
|
499
|
+
;;
|
|
500
|
+
|
|
501
|
+
*)
|
|
502
|
+
echo "usage: bin/drain [status|now|at HH:MM|cancel|on|off|install]" >&2
|
|
503
|
+
echo " at HH:MM one fire at a time you name" >&2
|
|
504
|
+
echo " on/off the hourly probe, for windows you have not read" >&2
|
|
505
|
+
exit 2
|
|
506
|
+
;;
|
|
507
|
+
esac
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Choose the issues one unattended fire will take. Prints ids, space-separated.
|
|
3
|
+
|
|
4
|
+
bin/drain-pick.py [<repo-root>]
|
|
5
|
+
|
|
6
|
+
Its own file rather than a heredoc inside drain-run.sh, for two reasons. A
|
|
7
|
+
heredoc piped from `tracker ready` would have the pipe and the heredoc both
|
|
8
|
+
claiming stdin, which is a real way to have python read the TRACKER OUTPUT as
|
|
9
|
+
its own source and die on a syntax error. And a selection rule with a cap in
|
|
10
|
+
it is worth testing directly, which a heredoc cannot be.
|
|
11
|
+
|
|
12
|
+
THE RULE. A cap phrased as separate small/medium/large limits ("N small, M
|
|
13
|
+
medium, P large only") does not compose — it has nothing to say about a fire
|
|
14
|
+
that wants two smalls and one medium. Recasting the caps as unit costs that
|
|
15
|
+
share one budget makes mixing fall out for free instead of needing a new rule
|
|
16
|
+
per combination.
|
|
17
|
+
|
|
18
|
+
Costs and the budget are project config (`unattended.sizeCosts`,
|
|
19
|
+
`unattended.budget` in config.json) with defaults chosen so a few small caps
|
|
20
|
+
land on the same number:
|
|
21
|
+
|
|
22
|
+
S = 3 M = 4 L = 6 budget = 12
|
|
23
|
+
|
|
24
|
+
4 x S = 12 3 x M = 12 2 x L = 12
|
|
25
|
+
|
|
26
|
+
Every pure-class fire is therefore identical to what flat per-size caps of
|
|
27
|
+
4/3/2 would allow, and mixing now falls out of the same number: L + M + S is
|
|
28
|
+
13, over budget, so it takes L + M; L + S + S is 12, so it takes all three.
|
|
29
|
+
|
|
30
|
+
Selection walks the ranked list — the order `bin/tracker ready` printed it
|
|
31
|
+
in — and takes anything that still fits, skipping what does not and
|
|
32
|
+
continuing. Skipping rather than stopping matters: one large issue at rank
|
|
33
|
+
three should not strand a fire with 6 unspent points when there are smaller
|
|
34
|
+
items below it. Rank is never reordered — a cheaper issue is only ever
|
|
35
|
+
reached after every dearer one above it was considered.
|
|
36
|
+
|
|
37
|
+
ENTROPY_DRAIN_BUDGET overrides the configured budget for a bigger or smaller
|
|
38
|
+
fire without touching the per-size costs.
|
|
39
|
+
|
|
40
|
+
Eligibility is exactly what `bin/tracker ready` prints (docs/TRACKER-ADAPTER.md)
|
|
41
|
+
and nothing else — so an issue cannot be picked while blocked, held, or gated
|
|
42
|
+
on an open decision. There is no separate "autonomous" flag in the adapter
|
|
43
|
+
contract: an issue a human decided an unattended run should not touch is
|
|
44
|
+
`held`, via `bin/tracker set <id> heldWhy=...` (see bin/drain-prompt.md), and
|
|
45
|
+
`ready` already excludes it.
|
|
46
|
+
"""
|
|
47
|
+
import json
|
|
48
|
+
import os
|
|
49
|
+
import subprocess
|
|
50
|
+
import sys
|
|
51
|
+
|
|
52
|
+
DEFAULT_COST = {"S": 3, "M": 4, "L": 6}
|
|
53
|
+
DEFAULT_BUDGET = 12
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def load_cost_model(root):
|
|
57
|
+
"""(costs, budget) out of config.json's `unattended` block.
|
|
58
|
+
|
|
59
|
+
Not read through lib/config.py — that loader does not define these two
|
|
60
|
+
keys yet (see its DEFAULTS dict), and this script only needs two scalars,
|
|
61
|
+
not the full merge/validate pipeline. A missing file, a missing key, or a
|
|
62
|
+
malformed value all fall back to the defaults above rather than raising —
|
|
63
|
+
same posture as bin/drain's and bin/drain-run.sh's own `cfg()` helper.
|
|
64
|
+
"""
|
|
65
|
+
try:
|
|
66
|
+
with open(os.path.join(root, "config.json"), encoding="utf-8") as f:
|
|
67
|
+
data = json.load(f)
|
|
68
|
+
except (OSError, json.JSONDecodeError):
|
|
69
|
+
data = {}
|
|
70
|
+
unattended = data.get("unattended") or {}
|
|
71
|
+
costs = unattended.get("sizeCosts")
|
|
72
|
+
if not isinstance(costs, dict) or not costs:
|
|
73
|
+
costs = DEFAULT_COST
|
|
74
|
+
budget = unattended.get("budget")
|
|
75
|
+
if not isinstance(budget, (int, float)):
|
|
76
|
+
budget = DEFAULT_BUDGET
|
|
77
|
+
return costs, budget
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def parse(text):
|
|
81
|
+
"""[(id, effort)] in listed order — `bin/tracker ready`'s JSONL, one
|
|
82
|
+
issue record per line (docs/TRACKER-ADAPTER.md).
|
|
83
|
+
|
|
84
|
+
A line that fails to parse, or parses but carries no "id", is skipped
|
|
85
|
+
rather than aborting the whole pick — one malformed record from a
|
|
86
|
+
`command` backend should not zero out an otherwise-eligible fire.
|
|
87
|
+
`effort` is optional in the adapter contract; a record without one is
|
|
88
|
+
handled by the caller's fail-closed default, same as an unrecognised
|
|
89
|
+
value.
|
|
90
|
+
"""
|
|
91
|
+
out = []
|
|
92
|
+
for line in text.splitlines():
|
|
93
|
+
line = line.strip()
|
|
94
|
+
if not line:
|
|
95
|
+
continue
|
|
96
|
+
try:
|
|
97
|
+
rec = json.loads(line)
|
|
98
|
+
except json.JSONDecodeError:
|
|
99
|
+
continue
|
|
100
|
+
if not isinstance(rec, dict):
|
|
101
|
+
continue
|
|
102
|
+
iid = rec.get("id")
|
|
103
|
+
if not iid:
|
|
104
|
+
continue
|
|
105
|
+
out.append((iid, rec.get("effort")))
|
|
106
|
+
return out
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def pick(rows, costs, budget):
|
|
110
|
+
"""Walk the ranked list, take whatever still fits, return the ids.
|
|
111
|
+
|
|
112
|
+
An unrecognised or missing effort costs the most a fire can hold.
|
|
113
|
+
Fail-closed: a typo'd or newly-invented size (or an issue nobody sized at
|
|
114
|
+
all) must not become the cheapest thing on the list and let an
|
|
115
|
+
unattended run take an unbounded amount of it.
|
|
116
|
+
"""
|
|
117
|
+
left = budget
|
|
118
|
+
taken = []
|
|
119
|
+
fallback = max(costs.values()) if costs else budget
|
|
120
|
+
for iid, effort in rows:
|
|
121
|
+
c = costs.get(effort, fallback)
|
|
122
|
+
if c <= left:
|
|
123
|
+
taken.append(iid)
|
|
124
|
+
left -= c
|
|
125
|
+
return taken
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _tracker_path():
|
|
129
|
+
"""bin/tracker, resolved from THIS FILE rather than from the project root.
|
|
130
|
+
|
|
131
|
+
It used to be os.path.join(root, "bin", "tracker"), where root is the
|
|
132
|
+
PROJECT. That is only the same directory when the harness happens to sit
|
|
133
|
+
at the repo root; in the vendored-in-a-subdirectory layout it resolves to
|
|
134
|
+
<project>/bin/tracker, which does not exist, and every unattended pick
|
|
135
|
+
died with ENOENT. This file is a sibling of tracker inside the harness, so
|
|
136
|
+
its own location is the answer and is right in either layout.
|
|
137
|
+
"""
|
|
138
|
+
return os.path.join(os.path.dirname(os.path.abspath(__file__)), "tracker")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def main():
|
|
142
|
+
root = sys.argv[1] if len(sys.argv) > 1 else \
|
|
143
|
+
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
144
|
+
costs, budget = load_cost_model(root)
|
|
145
|
+
override = os.environ.get("ENTROPY_DRAIN_BUDGET")
|
|
146
|
+
if override:
|
|
147
|
+
try:
|
|
148
|
+
budget = float(override)
|
|
149
|
+
except ValueError:
|
|
150
|
+
print(f"drain-pick: ENTROPY_DRAIN_BUDGET={override!r} is not a "
|
|
151
|
+
f"number, ignoring", file=sys.stderr)
|
|
152
|
+
try:
|
|
153
|
+
r = subprocess.run(
|
|
154
|
+
[_tracker_path(), "ready"],
|
|
155
|
+
capture_output=True, text=True, timeout=60)
|
|
156
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
157
|
+
print(f"drain-pick: cannot reach the tracker: {exc}", file=sys.stderr)
|
|
158
|
+
return 1
|
|
159
|
+
if r.returncode != 0:
|
|
160
|
+
print(f"drain-pick: tracker exited {r.returncode}: {r.stderr.strip()}",
|
|
161
|
+
file=sys.stderr)
|
|
162
|
+
return 1
|
|
163
|
+
print(" ".join(pick(parse(r.stdout), costs, budget)))
|
|
164
|
+
return 0
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
if __name__ == "__main__":
|
|
168
|
+
sys.exit(main())
|