omnilane 0.20.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,670 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Foreman-driven goal ledger around normal background dispatch.
5
+
6
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
7
+ REPO="$(cd "$SCRIPT_DIR/../.." && pwd -P)"
8
+ # shellcheck disable=SC1091
9
+ source "$SCRIPT_DIR/common.sh"
10
+
11
+ DISPATCH="$REPO/scripts/dispatch.sh"
12
+ DEFAULT_BUDGET_JOBS=""
13
+ DEFAULT_BUDGET_SECONDS=""
14
+ GOAL_ID_PATTERN='^[0-9]{8}-[0-9]{6}-[0-9]+-[0-9]+$'
15
+ LOCK_HELD=0
16
+ GOAL_DIR=""
17
+
18
+ usage() {
19
+ cat >&2 <<'EOF'
20
+ usage: omnilane goal open "TEXT" [--budget-jobs N] [--budget-seconds S] [--workdir DIR]
21
+ omnilane goal dispatch GOAL_ID [dispatch.sh args...]
22
+ omnilane goal note GOAL_ID "TEXT"
23
+ omnilane goal status GOAL_ID
24
+ omnilane goal close GOAL_ID [--summary "TEXT"]
25
+ EOF
26
+ exit 2
27
+ }
28
+
29
+ die() {
30
+ local rc="$1"
31
+ shift
32
+ printf 'omnilane goal: %s\n' "$*" >&2
33
+ exit "$rc"
34
+ }
35
+
36
+ require_python() {
37
+ command -v python3 >/dev/null 2>&1 || die 1 "Python 3 required for goal records"
38
+ }
39
+
40
+ validate_positive_integer() {
41
+ local label="$1" value="$2"
42
+ [[ "$value" =~ ^[1-9][0-9]{0,8}$ ]] ||
43
+ die 2 "invalid $label value (want 1..999999999)"
44
+ }
45
+
46
+ load_goal() {
47
+ local goal_id="$1" goals_root="$OMNILANE_HOME/goals"
48
+ [[ "$goal_id" =~ $GOAL_ID_PATTERN ]] || die 2 "invalid goal id"
49
+ GOAL_DIR="$goals_root/$goal_id"
50
+ [[ -d "$GOAL_DIR" && ! -L "$GOAL_DIR" ]] || die 1 "no such goal: $goal_id"
51
+ [[ -f "$GOAL_DIR/goal.txt" && ! -L "$GOAL_DIR/goal.txt" ]] ||
52
+ die 1 "goal text is missing or unsafe: $goal_id"
53
+ [[ -f "$GOAL_DIR/budget.json" && ! -L "$GOAL_DIR/budget.json" ]] ||
54
+ die 1 "goal budget is missing or unsafe: $goal_id"
55
+ }
56
+
57
+ release_lock() {
58
+ if [[ "$LOCK_HELD" -eq 1 ]]; then
59
+ [[ ! -e "$GOAL_DIR/.lock/pid" ]] || rm "$GOAL_DIR/.lock/pid" 2>/dev/null || true
60
+ rmdir "$GOAL_DIR/.lock" 2>/dev/null || true
61
+ LOCK_HELD=0
62
+ fi
63
+ }
64
+
65
+ acquire_lock() {
66
+ local tries=0
67
+ while ! mkdir -m 700 "$GOAL_DIR/.lock" 2>/dev/null; do
68
+ tries=$((tries + 1))
69
+ [[ "$tries" -lt 50 ]] || die 75 "goal is busy: $(basename "$GOAL_DIR")"
70
+ sleep 0.1
71
+ done
72
+ LOCK_HELD=1
73
+ printf '%s\n' "$$" > "$GOAL_DIR/.lock/pid"
74
+ chmod 600 "$GOAL_DIR/.lock/pid"
75
+ trap release_lock EXIT
76
+ }
77
+
78
+ refresh_goal() {
79
+ python3 - "$GOAL_DIR" "$OMNILANE_HOME" "$(date +%s)" <<'PY'
80
+ import datetime
81
+ import glob
82
+ import json
83
+ import os
84
+ import re
85
+ import sys
86
+
87
+ goal_dir, home, now_text = sys.argv[1:]
88
+ now = int(now_text)
89
+
90
+ def load_regular_json(path, limit):
91
+ if not os.path.isfile(path) or os.path.islink(path):
92
+ raise ValueError(f"unsafe or missing JSON record: {path}")
93
+ if os.path.getsize(path) > limit:
94
+ raise ValueError(f"oversized JSON record: {path}")
95
+ with open(path, encoding="utf-8") as handle:
96
+ return json.load(handle)
97
+
98
+ def write_json(path, value):
99
+ tmp = f"{path}.tmp.{os.getpid()}"
100
+ with open(tmp, "w", encoding="utf-8") as handle:
101
+ json.dump(value, handle, separators=(",", ":"), ensure_ascii=False)
102
+ handle.write("\n")
103
+ os.chmod(tmp, 0o600)
104
+ os.replace(tmp, path)
105
+
106
+ budget_path = os.path.join(goal_dir, "budget.json")
107
+ failures_path = os.path.join(goal_dir, "failures.json")
108
+ budget = load_regular_json(budget_path, 16384)
109
+ failures = load_regular_json(failures_path, 1048576)
110
+ if not isinstance(budget, dict) or not isinstance(failures, dict):
111
+ raise ValueError("invalid goal state")
112
+
113
+ records = []
114
+ pattern = os.path.join(goal_dir, "jobs", "job-*.json")
115
+ for record_path in sorted(glob.glob(pattern)):
116
+ record = load_regular_json(record_path, 262144)
117
+ job_id = record.get("job_id", "")
118
+ if not re.fullmatch(r"[0-9]{8}-[0-9]{6}-[0-9]+-[0-9]+", job_id):
119
+ raise ValueError(f"invalid recorded job id: {record_path}")
120
+ job_dir = os.path.join(home, "jobs", job_id)
121
+ if os.path.isdir(job_dir) and not os.path.islink(job_dir):
122
+ meta_path = os.path.join(job_dir, "meta.json")
123
+ if os.path.isfile(meta_path) and not os.path.islink(meta_path):
124
+ meta = load_regular_json(meta_path, 16384)
125
+ for key in ("lane", "vendor", "model", "mode", "workdir", "started"):
126
+ if key in meta:
127
+ record[key] = meta[key]
128
+ exit_path = os.path.join(job_dir, "exit")
129
+ if os.path.isfile(exit_path) and not os.path.islink(exit_path):
130
+ if os.path.getsize(exit_path) > 32:
131
+ raise ValueError(f"oversized job exit record: {job_id}")
132
+ with open(exit_path, encoding="ascii") as handle:
133
+ exit_text = handle.read().strip()
134
+ if not re.fullmatch(r"[0-9]+", exit_text):
135
+ raise ValueError(f"invalid job exit record: {job_id}")
136
+ record["exit"] = int(exit_text)
137
+ record["state"] = "done"
138
+ record["finished"] = datetime.datetime.fromtimestamp(
139
+ os.path.getmtime(exit_path), datetime.timezone.utc
140
+ ).strftime("%Y-%m-%dT%H:%M:%SZ")
141
+ submitted = int(record.get("submitted_epoch", now))
142
+ record["seconds"] = max(0, int(os.path.getmtime(exit_path)) - submitted)
143
+ inbox_path = os.path.join(home, "inbox", f"{job_id}.json")
144
+ if os.path.isfile(inbox_path) and not os.path.islink(inbox_path):
145
+ completion = load_regular_json(inbox_path, 1048576)
146
+ if isinstance(completion.get("finished"), str):
147
+ record["finished"] = completion["finished"]
148
+ if isinstance(completion.get("tail"), str):
149
+ record["tail"] = completion["tail"][-2000:]
150
+ if record["exit"] != 0 and not record.get("failure_counted", False):
151
+ fingerprint = record.get("fingerprint", "")
152
+ if re.fullmatch(r"[0-9a-f]{64}", fingerprint):
153
+ failures[fingerprint] = int(failures.get(fingerprint, 0)) + 1
154
+ record["failure_counted"] = True
155
+ else:
156
+ record["state"] = "running"
157
+ record["exit"] = None
158
+ submitted = int(record.get("submitted_epoch", now))
159
+ record["seconds"] = max(0, now - submitted)
160
+ else:
161
+ record["state"] = "missing"
162
+ record["exit"] = None
163
+ submitted = int(record.get("submitted_epoch", now))
164
+ record["seconds"] = max(0, now - submitted)
165
+ write_json(record_path, record)
166
+ records.append(record)
167
+
168
+ budget["spent_jobs"] = len(records)
169
+ if budget.get("status") == "open":
170
+ budget["spent_seconds"] = max(0, now - int(budget["started_epoch"]))
171
+ write_json(failures_path, failures)
172
+ write_json(budget_path, budget)
173
+ PY
174
+ }
175
+
176
+ state_fields() {
177
+ python3 - "$GOAL_DIR/budget.json" <<'PY'
178
+ import json
179
+ import os
180
+ import sys
181
+
182
+ path = sys.argv[1]
183
+ if not os.path.isfile(path) or os.path.islink(path) or os.path.getsize(path) > 16384:
184
+ raise SystemExit("invalid goal budget")
185
+ with open(path, encoding="utf-8") as handle:
186
+ state = json.load(handle)
187
+ required = {
188
+ "status", "budget_jobs", "budget_seconds", "spent_jobs",
189
+ "spent_seconds", "fuse_trips", "started_epoch", "workdir",
190
+ }
191
+ if not isinstance(state, dict) or not required.issubset(state):
192
+ raise SystemExit("invalid goal budget")
193
+ def field_value(key):
194
+ value = state[key]
195
+ if key in {"budget_jobs", "budget_seconds"} and value is None:
196
+ return "unlimited"
197
+ return str(value)
198
+
199
+ print("\t".join(field_value(key) for key in (
200
+ "status", "spent_jobs", "budget_jobs", "spent_seconds",
201
+ "budget_seconds", "fuse_trips", "workdir",
202
+ )))
203
+ PY
204
+ }
205
+
206
+ failure_count() {
207
+ local fingerprint="$1"
208
+ python3 - "$GOAL_DIR/failures.json" "$fingerprint" <<'PY'
209
+ import json
210
+ import os
211
+ import sys
212
+
213
+ path, fingerprint = sys.argv[1:]
214
+ if not os.path.isfile(path) or os.path.islink(path) or os.path.getsize(path) > 1048576:
215
+ raise SystemExit("invalid failure record")
216
+ with open(path, encoding="utf-8") as handle:
217
+ failures = json.load(handle)
218
+ print(int(failures.get(fingerprint, 0)))
219
+ PY
220
+ }
221
+
222
+ record_fuse_trip() {
223
+ local fingerprint="$1" lane="$2" task="$3" failures="$4"
224
+ GOAL_FINGERPRINT="$fingerprint" GOAL_LANE="$lane" GOAL_TASK="$task" \
225
+ GOAL_FAILURES="$failures" python3 - "$GOAL_DIR" <<'PY'
226
+ import datetime
227
+ import json
228
+ import os
229
+ import sys
230
+
231
+ goal_dir = sys.argv[1]
232
+ budget_path = os.path.join(goal_dir, "budget.json")
233
+ with open(budget_path, encoding="utf-8") as handle:
234
+ budget = json.load(handle)
235
+ budget["fuse_trips"] = int(budget.get("fuse_trips", 0)) + 1
236
+ tmp = f"{budget_path}.tmp.{os.getpid()}"
237
+ with open(tmp, "w", encoding="utf-8") as handle:
238
+ json.dump(budget, handle, separators=(",", ":"), ensure_ascii=False)
239
+ handle.write("\n")
240
+ os.chmod(tmp, 0o600)
241
+ os.replace(tmp, budget_path)
242
+ record = {
243
+ "timestamp": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
244
+ "fingerprint": os.environ["GOAL_FINGERPRINT"],
245
+ "lane": os.environ["GOAL_LANE"],
246
+ "task": os.environ["GOAL_TASK"][:2000],
247
+ "failures": int(os.environ["GOAL_FAILURES"]),
248
+ }
249
+ path = os.path.join(goal_dir, "fuses.jsonl")
250
+ with open(path, "a", encoding="utf-8") as handle:
251
+ handle.write(json.dumps(record, separators=(",", ":"), ensure_ascii=False) + "\n")
252
+ os.chmod(path, 0o600)
253
+ PY
254
+ }
255
+
256
+ record_dispatch() {
257
+ local job_id="$1" lane="$2" fingerprint="$3" task="$4"
258
+ GOAL_JOB_ID="$job_id" GOAL_LANE="$lane" GOAL_FINGERPRINT="$fingerprint" \
259
+ GOAL_TASK="$task" python3 - "$GOAL_DIR" "$OMNILANE_HOME" "$(date +%s)" <<'PY'
260
+ import datetime
261
+ import json
262
+ import os
263
+ import sys
264
+
265
+ goal_dir, home, submitted_text = sys.argv[1:]
266
+ budget_path = os.path.join(goal_dir, "budget.json")
267
+ with open(budget_path, encoding="utf-8") as handle:
268
+ budget = json.load(handle)
269
+ ordinal = int(budget["spent_jobs"]) + 1
270
+ job_id = os.environ["GOAL_JOB_ID"]
271
+ record = {
272
+ "ordinal": ordinal,
273
+ "job_id": job_id,
274
+ "lane": os.environ["GOAL_LANE"],
275
+ "vendor": "unknown",
276
+ "model": "",
277
+ "mode": "unknown",
278
+ "workdir": budget["workdir"],
279
+ "task": os.environ["GOAL_TASK"][:2000],
280
+ "fingerprint": os.environ["GOAL_FINGERPRINT"],
281
+ "submitted_epoch": int(submitted_text),
282
+ "submitted": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
283
+ "state": "running",
284
+ "exit": None,
285
+ "seconds": 0,
286
+ "failure_counted": False,
287
+ }
288
+ meta_path = os.path.join(home, "jobs", job_id, "meta.json")
289
+ if os.path.isfile(meta_path) and not os.path.islink(meta_path) and os.path.getsize(meta_path) <= 16384:
290
+ with open(meta_path, encoding="utf-8") as handle:
291
+ meta = json.load(handle)
292
+ for key in ("lane", "vendor", "model", "mode", "workdir", "started"):
293
+ if key in meta:
294
+ record[key] = meta[key]
295
+ jobs_dir = os.path.join(goal_dir, "jobs")
296
+ record_path = os.path.join(jobs_dir, f"job-{ordinal:08d}.json")
297
+ if os.path.exists(record_path):
298
+ raise SystemExit("goal job record collision")
299
+ with open(record_path, "x", encoding="utf-8") as handle:
300
+ json.dump(record, handle, separators=(",", ":"), ensure_ascii=False)
301
+ handle.write("\n")
302
+ os.chmod(record_path, 0o600)
303
+ budget["spent_jobs"] = ordinal
304
+ budget["spent_seconds"] = max(0, int(submitted_text) - int(budget["started_epoch"]))
305
+ tmp = f"{budget_path}.tmp.{os.getpid()}"
306
+ with open(tmp, "w", encoding="utf-8") as handle:
307
+ json.dump(budget, handle, separators=(",", ":"), ensure_ascii=False)
308
+ handle.write("\n")
309
+ os.chmod(tmp, 0o600)
310
+ os.replace(tmp, budget_path)
311
+ PY
312
+ }
313
+
314
+ open_goal() {
315
+ local goal_text="${1:-}" budget_jobs="$DEFAULT_BUDGET_JOBS"
316
+ local budget_seconds="$DEFAULT_BUDGET_SECONDS" workdir="$PWD"
317
+ local goals_root goal_id goal_dir started
318
+ [[ $# -ge 1 && -n "$goal_text" ]] || usage
319
+ shift
320
+ while [[ $# -gt 0 ]]; do
321
+ case "$1" in
322
+ --budget-jobs)
323
+ [[ $# -ge 2 ]] || usage
324
+ budget_jobs="$2"
325
+ shift 2 ;;
326
+ --budget-seconds)
327
+ [[ $# -ge 2 ]] || usage
328
+ budget_seconds="$2"
329
+ shift 2 ;;
330
+ --workdir)
331
+ [[ $# -ge 2 ]] || usage
332
+ workdir="$2"
333
+ shift 2 ;;
334
+ *) usage ;;
335
+ esac
336
+ done
337
+ [[ -z "$budget_jobs" ]] || validate_positive_integer "--budget-jobs" "$budget_jobs"
338
+ [[ -z "$budget_seconds" ]] || validate_positive_integer "--budget-seconds" "$budget_seconds"
339
+ [[ -d "$workdir" ]] || die 2 "workdir is not a directory: $workdir"
340
+ workdir="$(cd "$workdir" && pwd -P)"
341
+ [[ -x "$DISPATCH" ]] || die 1 "dispatch helper unavailable"
342
+ goals_root="$OMNILANE_HOME/goals"
343
+ prepare_private_store "$goals_root" "goals store" || die 1 "could not prepare goals store"
344
+ goal_id="$(date +%Y%m%d-%H%M%S)-$$-$RANDOM"
345
+ goal_dir="$goals_root/$goal_id"
346
+ mkdir -m 700 "$goal_dir"
347
+ mkdir -m 700 "$goal_dir/jobs"
348
+ printf '%s\n' "$goal_text" > "$goal_dir/goal.txt"
349
+ printf '{}\n' > "$goal_dir/failures.json"
350
+ : > "$goal_dir/notes.jsonl"
351
+ chmod 600 "$goal_dir/goal.txt" "$goal_dir/failures.json" "$goal_dir/notes.jsonl"
352
+ started="$(date +%s)"
353
+ GOAL_WORKDIR="$workdir" python3 - "$goal_dir/budget.json" "$budget_jobs" \
354
+ "$budget_seconds" "$started" <<'PY'
355
+ import json
356
+ import os
357
+ import sys
358
+
359
+ path, jobs, seconds, started = sys.argv[1:]
360
+ state = {
361
+ "schema_version": 3,
362
+ "budget_jobs": None if jobs == "" else int(jobs),
363
+ "budget_seconds": None if seconds == "" else int(seconds),
364
+ "spent_jobs": 0,
365
+ "spent_seconds": 0,
366
+ "fuse_trips": 0,
367
+ "status": "open",
368
+ "started_epoch": int(started),
369
+ "workdir": os.environ["GOAL_WORKDIR"],
370
+ }
371
+ with open(path, "x", encoding="utf-8") as handle:
372
+ json.dump(state, handle, separators=(",", ":"), ensure_ascii=False)
373
+ handle.write("\n")
374
+ os.chmod(path, 0o600)
375
+ PY
376
+ printf '%s\n' "$goal_id"
377
+ }
378
+
379
+ dispatch_goal() {
380
+ local goal_id="${1:-}" lane task task_value fingerprint failures
381
+ local status spent_jobs budget_jobs spent_seconds budget_seconds workdir lane_index
382
+ local dispatch_output dispatch_rc error_path
383
+ [[ $# -ge 3 ]] || usage
384
+ shift
385
+ load_goal "$goal_id"
386
+ acquire_lock
387
+ refresh_goal
388
+ IFS=$'\t' read -r status spent_jobs budget_jobs spent_seconds budget_seconds \
389
+ _ workdir < <(state_fields)
390
+ [[ "$status" == "open" ]] || die 75 "goal is closed: $goal_id"
391
+ if [[ "$budget_jobs" != "unlimited" && "$spent_jobs" -ge "$budget_jobs" ]]; then
392
+ die 75 "jobs budget exhausted: $spent_jobs/$budget_jobs"
393
+ fi
394
+ if [[ "$budget_seconds" != "unlimited" && "$spent_seconds" -ge "$budget_seconds" ]]; then
395
+ die 75 "seconds budget exhausted: ${spent_seconds}s/${budget_seconds}s"
396
+ fi
397
+
398
+ lane_index=$(($# - 1))
399
+ lane="${!lane_index}"
400
+ task="${!#}"
401
+ task_value="$task"
402
+ if [[ "$task" == "-" ]]; then
403
+ task_value="$(cat)"
404
+ fi
405
+ fingerprint="$(python3 - "$lane" "$task_value" <<'PY'
406
+ import hashlib
407
+ import sys
408
+ print(hashlib.sha256((sys.argv[1] + "\0" + sys.argv[2]).encode("utf-8")).hexdigest())
409
+ PY
410
+ )"
411
+ failures="$(failure_count "$fingerprint")"
412
+ if [[ "$failures" -ge 2 ]]; then
413
+ record_fuse_trip "$fingerprint" "$lane" "$task_value" "$failures"
414
+ die 75 "failure fuse tripped: lane=$lane failures=$failures fingerprint=$fingerprint"
415
+ fi
416
+
417
+ error_path="$GOAL_DIR/dispatch-error-$(date +%s)-$$.txt"
418
+ set +e
419
+ if [[ "$task" == "-" ]]; then
420
+ dispatch_output="$(printf '%s' "$task_value" | OMNILANE_HOME="$OMNILANE_HOME" \
421
+ "$DISPATCH" --background --workdir "$workdir" "$@" 2>"$error_path")"
422
+ dispatch_rc=$?
423
+ else
424
+ dispatch_output="$(OMNILANE_HOME="$OMNILANE_HOME" "$DISPATCH" --background \
425
+ --workdir "$workdir" "$@" 2>"$error_path")"
426
+ dispatch_rc=$?
427
+ fi
428
+ set -e
429
+ if [[ "$dispatch_rc" -ne 0 ]]; then
430
+ chmod 600 "$error_path" 2>/dev/null || true
431
+ die "$dispatch_rc" "dispatch failed (exit $dispatch_rc; details: $error_path)"
432
+ fi
433
+ if [[ ! "$dispatch_output" =~ $GOAL_ID_PATTERN ]]; then
434
+ chmod 600 "$error_path" 2>/dev/null || true
435
+ die 1 "dispatch returned invalid job id (details: $error_path)"
436
+ fi
437
+ [[ ! -s "$error_path" ]] || chmod 600 "$error_path"
438
+ [[ -s "$error_path" ]] || rm "$error_path"
439
+ record_dispatch "$dispatch_output" "$lane" "$fingerprint" "$task_value"
440
+ release_lock
441
+ trap - EXIT
442
+ printf '%s\n' "$dispatch_output"
443
+ }
444
+
445
+ note_goal() {
446
+ local goal_id="${1:-}" note_text="${2:-}"
447
+ [[ $# -eq 2 && -n "$note_text" ]] || usage
448
+ load_goal "$goal_id"
449
+ acquire_lock
450
+ refresh_goal
451
+ GOAL_NOTE="$note_text" python3 - "$GOAL_DIR/notes.jsonl" <<'PY'
452
+ import datetime
453
+ import json
454
+ import os
455
+ import sys
456
+
457
+ path = sys.argv[1]
458
+ record = {
459
+ "timestamp": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
460
+ "text": os.environ["GOAL_NOTE"],
461
+ }
462
+ with open(path, "a", encoding="utf-8") as handle:
463
+ handle.write(json.dumps(record, separators=(",", ":"), ensure_ascii=False) + "\n")
464
+ os.chmod(path, 0o600)
465
+ PY
466
+ release_lock
467
+ trap - EXIT
468
+ }
469
+
470
+ status_goal() {
471
+ local goal_id="${1:-}"
472
+ [[ $# -eq 1 ]] || usage
473
+ load_goal "$goal_id"
474
+ acquire_lock
475
+ refresh_goal
476
+ python3 - "$GOAL_DIR" <<'PY'
477
+ import glob
478
+ import json
479
+ import os
480
+ import sys
481
+
482
+ try:
483
+ goal_dir = sys.argv[1]
484
+ with open(os.path.join(goal_dir, "budget.json"), encoding="utf-8") as handle:
485
+ budget = json.load(handle)
486
+
487
+ def budget_limit(value):
488
+ return "unlimited" if value is None else str(value)
489
+
490
+ print(f"status: {budget['status']}")
491
+ print(f"jobs: {budget['spent_jobs']} / {budget_limit(budget['budget_jobs'])}")
492
+ print(f"seconds: {budget['spent_seconds']} / {budget_limit(budget['budget_seconds'])}")
493
+ print(f"fuse trips: {budget.get('fuse_trips', 0)}")
494
+ for path in sorted(glob.glob(os.path.join(goal_dir, "jobs", "job-*.json"))):
495
+ with open(path, encoding="utf-8") as handle:
496
+ record = json.load(handle)
497
+ exit_value = record.get("exit")
498
+ exit_text = "running" if exit_value is None else str(exit_value)
499
+ print(
500
+ f"job {record['job_id']}: lane={record.get('lane', 'unknown')} "
501
+ f"vendor={record.get('vendor', 'unknown')} exit={exit_text} "
502
+ f"seconds={record.get('seconds', 0)}"
503
+ )
504
+ sys.stdout.flush()
505
+ except BrokenPipeError:
506
+ # Prevent Python's shutdown flush from reporting the same closed pipe again.
507
+ with open(os.devnull, "w", encoding="utf-8") as devnull:
508
+ os.dup2(devnull.fileno(), sys.stdout.fileno())
509
+ sys.exit(0)
510
+ PY
511
+ release_lock
512
+ trap - EXIT
513
+ }
514
+
515
+ close_goal() {
516
+ local goal_id="${1:-}" summary="" report_path
517
+ [[ $# -ge 1 ]] || usage
518
+ shift
519
+ while [[ $# -gt 0 ]]; do
520
+ case "$1" in
521
+ --summary)
522
+ [[ $# -ge 2 ]] || usage
523
+ summary="$2"
524
+ shift 2 ;;
525
+ *) usage ;;
526
+ esac
527
+ done
528
+ load_goal "$goal_id"
529
+ acquire_lock
530
+ refresh_goal
531
+ GOAL_SUMMARY="$summary" python3 - "$GOAL_DIR" "$goal_id" "$(date +%s)" <<'PY'
532
+ import glob
533
+ import json
534
+ import os
535
+ import re
536
+ import sys
537
+
538
+ goal_dir, goal_id, closed_text = sys.argv[1:]
539
+
540
+ def read_text(name):
541
+ path = os.path.join(goal_dir, name)
542
+ if not os.path.isfile(path) or os.path.islink(path):
543
+ raise ValueError(f"unsafe or missing goal record: {name}")
544
+ with open(path, encoding="utf-8") as handle:
545
+ return handle.read().rstrip("\n")
546
+
547
+ def data_block(value):
548
+ longest = max((len(item) for item in re.findall(r"`+", value)), default=0)
549
+ fence = "`" * max(3, longest + 1)
550
+ suffix = "" if value.endswith("\n") else "\n"
551
+ return f"{fence}text\n{value}{suffix}{fence}"
552
+
553
+ def budget_limit(value):
554
+ return "unlimited" if value is None else str(value)
555
+
556
+ budget_path = os.path.join(goal_dir, "budget.json")
557
+ with open(budget_path, encoding="utf-8") as handle:
558
+ budget = json.load(handle)
559
+ if budget.get("status") != "open":
560
+ raise SystemExit("goal is already closed")
561
+ budget["status"] = "closed"
562
+ budget["spent_seconds"] = max(0, int(closed_text) - int(budget["started_epoch"]))
563
+ budget["closed_epoch"] = int(closed_text)
564
+ tmp = f"{budget_path}.tmp.{os.getpid()}"
565
+ with open(tmp, "w", encoding="utf-8") as handle:
566
+ json.dump(budget, handle, separators=(",", ":"), ensure_ascii=False)
567
+ handle.write("\n")
568
+ os.chmod(tmp, 0o600)
569
+ os.replace(tmp, budget_path)
570
+
571
+ notes = []
572
+ notes_path = os.path.join(goal_dir, "notes.jsonl")
573
+ with open(notes_path, encoding="utf-8") as handle:
574
+ for line in handle:
575
+ if line.strip():
576
+ notes.append(json.loads(line))
577
+ summary = os.environ.get("GOAL_SUMMARY", "")
578
+ if not summary:
579
+ summary = "\n".join(f"[{note['timestamp']}] {note['text']}" for note in notes)
580
+ if not summary:
581
+ summary = "No foreman summary provided."
582
+ summary_path = os.path.join(goal_dir, "summary.txt")
583
+ with open(summary_path, "w", encoding="utf-8") as handle:
584
+ handle.write(summary + "\n")
585
+ os.chmod(summary_path, 0o600)
586
+
587
+ jobs = []
588
+ for path in sorted(glob.glob(os.path.join(goal_dir, "jobs", "job-*.json"))):
589
+ with open(path, encoding="utf-8") as handle:
590
+ jobs.append(json.load(handle))
591
+ job_lines = []
592
+ for job in jobs:
593
+ exit_value = job.get("exit")
594
+ exit_text = "running" if exit_value is None else str(exit_value)
595
+ job_lines.append(
596
+ f"job {job['job_id']}: lane={job.get('lane', 'unknown')} "
597
+ f"vendor={job.get('vendor', 'unknown')} exit={exit_text} "
598
+ f"seconds={job.get('seconds', 0)} task={job.get('task', '')}"
599
+ )
600
+ if not job_lines:
601
+ job_lines.append("No jobs recorded.")
602
+ note_lines = [f"[{note['timestamp']}] {note['text']}" for note in notes]
603
+ if not note_lines:
604
+ note_lines.append("No foreman notes recorded.")
605
+
606
+ artifacts = []
607
+ for value in (summary, *(note["text"] for note in notes)):
608
+ for candidate in re.findall(r"`([^`\r\n]+)`", value):
609
+ if candidate.startswith(("/", "./", "../", "~/")) or "/" in candidate:
610
+ if candidate not in artifacts:
611
+ artifacts.append(candidate)
612
+
613
+ lines = [
614
+ f"# Goal report: {goal_id}",
615
+ "",
616
+ "## Goal",
617
+ "",
618
+ data_block(read_text("goal.txt")),
619
+ "",
620
+ "## Outcome",
621
+ "",
622
+ "- Status: closed",
623
+ "",
624
+ "## Budget",
625
+ "",
626
+ f"- Jobs: {budget['spent_jobs']} / {budget_limit(budget['budget_jobs'])}",
627
+ f"- Seconds: {budget['spent_seconds']} / {budget_limit(budget['budget_seconds'])}",
628
+ f"- Fuse trips: {budget.get('fuse_trips', 0)}",
629
+ "",
630
+ "## Jobs",
631
+ "",
632
+ data_block("\n".join(job_lines)),
633
+ "",
634
+ "## Foreman notes (data)",
635
+ "",
636
+ data_block("\n".join(note_lines)),
637
+ "",
638
+ "## Foreman summary (data)",
639
+ "",
640
+ data_block(summary),
641
+ "",
642
+ "## Artifact paths named by foreman",
643
+ "",
644
+ ]
645
+ lines.append(data_block("\n".join(artifacts)) if artifacts else "None recorded.")
646
+ lines.append("")
647
+ report_path = os.path.join(goal_dir, "report.md")
648
+ report_tmp = f"{report_path}.tmp.{os.getpid()}"
649
+ with open(report_tmp, "w", encoding="utf-8") as handle:
650
+ handle.write("\n".join(lines))
651
+ os.chmod(report_tmp, 0o600)
652
+ os.replace(report_tmp, report_path)
653
+ PY
654
+ report_path="$GOAL_DIR/report.md"
655
+ release_lock
656
+ trap - EXIT
657
+ printf '%s\n' "$report_path"
658
+ }
659
+
660
+ require_python
661
+ subcommand="${1:-}"
662
+ shift || true
663
+ case "$subcommand" in
664
+ open) open_goal "$@" ;;
665
+ dispatch) dispatch_goal "$@" ;;
666
+ note) note_goal "$@" ;;
667
+ status) status_goal "$@" ;;
668
+ close) close_goal "$@" ;;
669
+ *) usage ;;
670
+ esac