@indigoai-us/hq-cli 5.345.114 → 5.345.115

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.
Files changed (54) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/assets/scaffold/core/scripts/hq-delegate-send.sh +5 -1
  3. package/dist/commands/bot-jobs.d.ts +1 -11
  4. package/dist/commands/bot-jobs.js +13 -34
  5. package/dist/commands/core-audit-log-native.js +1 -1
  6. package/dist/commands/core-checkpoint-native.d.ts +27 -0
  7. package/dist/commands/core-checkpoint-native.js +85 -2
  8. package/dist/commands/core-dm-bind-native.js +1 -1
  9. package/dist/commands/core-git-pack-extension-check-native.js +1 -1
  10. package/dist/commands/core-job-native-utils.d.ts +2 -2
  11. package/dist/commands/core-job-native-utils.js +3 -11
  12. package/dist/commands/core-job-notify-native.js +1 -2
  13. package/dist/commands/core-job-probe-native.d.ts +2 -3
  14. package/dist/commands/core-job-probe-native.js +14 -22
  15. package/dist/commands/core-job-remediate-native.js +1 -2
  16. package/dist/commands/core-job-run-native.js +58 -54
  17. package/dist/commands/core-jobs-native.d.ts +1 -3
  18. package/dist/commands/core-jobs-native.js +16 -271
  19. package/dist/commands/core-jobs-schedule.d.ts +7 -0
  20. package/dist/commands/core-jobs-schedule.js +243 -0
  21. package/dist/commands/core-native-utilities.d.ts +2 -3
  22. package/dist/commands/core-native-utilities.js +2 -19
  23. package/dist/commands/core-pin-native.js +1 -1
  24. package/dist/commands/core-refresh-vault-access-native.js +1 -1
  25. package/dist/commands/core-resolve-company-command.js +2 -17
  26. package/dist/commands/core-work-mesh-bind-trusted-native.js +1 -1
  27. package/dist/commands/core.js +1 -17
  28. package/dist/fast-scaffold.d.ts +1 -1
  29. package/dist/fast-scaffold.js +0 -38
  30. package/dist/lib/daemon/catalog.d.ts +0 -2
  31. package/dist/lib/daemon/catalog.js +3 -6
  32. package/dist/lib/daemon/run.js +0 -7
  33. package/dist/utils/resident-package-preload.js +2 -7
  34. package/package.json +1 -1
  35. package/assets/scaffold/core/scripts/audit-log.sh +0 -384
  36. package/assets/scaffold/core/scripts/git-pack-extension-check.sh +0 -164
  37. package/assets/scaffold/core/scripts/hq-dm-bind.sh +0 -590
  38. package/assets/scaffold/core/scripts/hq-job-notify.sh +0 -389
  39. package/assets/scaffold/core/scripts/hq-job-probe.sh +0 -865
  40. package/assets/scaffold/core/scripts/hq-job-remediate.sh +0 -359
  41. package/assets/scaffold/core/scripts/hq-job-run.sh +0 -1025
  42. package/assets/scaffold/core/scripts/hq-pin.sh +0 -118
  43. package/assets/scaffold/core/scripts/jobs-schedule-parse.sh +0 -310
  44. package/assets/scaffold/core/scripts/jobs-validate.sh +0 -482
  45. package/assets/scaffold/core/scripts/outpost-jobs-reconcile.sh +0 -836
  46. package/assets/scaffold/core/scripts/refresh-vault-access.sh +0 -188
  47. package/assets/scaffold/core/scripts/resolve-company.sh +0 -331
  48. package/assets/scaffold/core/scripts/work-mesh-live-bind-trusted.sh +0 -184
  49. package/dist/commands/core-job-post-exit-assets.d.ts +0 -8
  50. package/dist/commands/core-job-post-exit-assets.js +0 -37
  51. package/dist/commands/core-native-jobs-flag.d.ts +0 -4
  52. package/dist/commands/core-native-jobs-flag.js +0 -7
  53. package/dist/commands/core-native-utilities-flag.d.ts +0 -4
  54. package/dist/commands/core-native-utilities-flag.js +0 -7
@@ -1,836 +0,0 @@
1
- #!/usr/bin/env bash
2
- # hq-core: public
3
- # outpost-jobs-reconcile.sh — registry YAML → systemd user timers (US-004).
4
- #
5
- # Reads personal/jobs/*.yaml and companies/*/jobs/*.yaml from the box HQ tree,
6
- # validates each with jobs-validate.sh, and materializes disposable units:
7
- # ~/.config/systemd/user/hq-job-{id}.service
8
- # ~/.config/systemd/user/hq-job-{id}.timer
9
- # with Persistent=true and RandomizedDelaySec. Timers are armed only when
10
- # hq-pro jobs status readiness=ready (box cache from probe ingest, or live
11
- # GET when available). Disabled/deleted jobs have units removed.
12
- #
13
- # Company-scoped jobs materialize only when job.owner matches this Outpost's
14
- # owner identity (creator's box only in v1).
15
- #
16
- # Usage:
17
- # core/scripts/outpost-jobs-reconcile.sh [options]
18
- # core/scripts/outpost-jobs-reconcile.sh --after-sync # post-sync entrypoint
19
- #
20
- # Options:
21
- # --hq-root DIR HQ tree root (default: HQ_ROOT or repo root above core/)
22
- # --dry-run Write units to unit dir / print plan; skip systemctl
23
- # --no-probe Do not probe non-ready jobs
24
- # --probe-only Probe non-ready jobs then exit (no unit changes)
25
- # --ensure-hook Install personal/hooks/Stop/90-outpost-jobs-reconcile.sh
26
- # --after-sync Alias for default reconcile + probe path (sync Stop hook)
27
- # -h, --help
28
- #
29
- # Env / overrides (tests):
30
- # HQ_ROOT, HOME, HQ_JOB_UNIT_DIR, HQ_JOB_SYSTEMCTL, HQ_JOB_STATUS_CACHE_DIR
31
- # HQ_JOB_OWNER_EMAIL, HQ_JOB_OWNER_UID, OUTPOST_USER_ID, OUTPOST_ID
32
- # HQ_JOB_RANDOMIZE_SEC (default 60), HQ_JOB_RECONCILE_STATE
33
- # HQ_PRO_API_URL / HQ_API_URL / HQ_VAULT_API_URL, HQ_ACCESS_TOKEN (optional GET)
34
- #
35
- # Exit: 0 success; 1 partial failures; 2 usage/config.
36
-
37
- set -euo pipefail
38
-
39
- SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
40
- HQ_ROOT="${HQ_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd)}"
41
- VALIDATE="$SCRIPT_DIR/jobs-validate.sh"
42
- PROBE="$SCRIPT_DIR/hq-job-probe.sh"
43
-
44
- DRY_RUN=0
45
- NO_PROBE=0
46
- PROBE_ONLY=0
47
- ENSURE_HOOK=0
48
-
49
- usage() {
50
- sed -n '3,36p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
51
- }
52
-
53
- die() {
54
- echo "outpost-jobs-reconcile: $*" >&2
55
- exit 2
56
- }
57
-
58
- log() {
59
- echo "outpost-jobs-reconcile: $*" >&2
60
- }
61
-
62
- while [ $# -gt 0 ]; do
63
- case "$1" in
64
- --hq-root)
65
- HQ_ROOT="${2:-}"
66
- [ -n "$HQ_ROOT" ] || die "--hq-root requires a directory"
67
- shift 2
68
- ;;
69
- --dry-run)
70
- DRY_RUN=1
71
- shift
72
- ;;
73
- --no-probe)
74
- NO_PROBE=1
75
- shift
76
- ;;
77
- --probe-only)
78
- PROBE_ONLY=1
79
- shift
80
- ;;
81
- --ensure-hook)
82
- ENSURE_HOOK=1
83
- shift
84
- ;;
85
- --after-sync)
86
- shift
87
- ;;
88
- -h|--help)
89
- usage
90
- exit 0
91
- ;;
92
- -*)
93
- die "unknown option: $1"
94
- ;;
95
- *)
96
- die "unexpected argument: $1"
97
- ;;
98
- esac
99
- done
100
-
101
- # The shell script is also bundled inside hq-cli. Its own SCRIPT_DIR then
102
- # points into the npm package, while job units must always target the live
103
- # scaffold forwarder so they survive CLI upgrades and Node switches.
104
- RUNNER="$HQ_ROOT/core/scripts/hq-job-run.sh"
105
-
106
- command -v yq >/dev/null 2>&1 || die "yq is required (mikefarah/yq)"
107
- command -v jq >/dev/null 2>&1 || die "jq is required"
108
- [ -x "$VALIDATE" ] || [ -f "$VALIDATE" ] || die "missing jobs-validate.sh"
109
- [ -f "$RUNNER" ] || die "missing hq-job-run.sh"
110
- [ -f "$PROBE" ] || die "missing hq-job-probe.sh"
111
-
112
- UNIT_DIR="${HQ_JOB_UNIT_DIR:-${HOME}/.config/systemd/user}"
113
- STATUS_CACHE_DIR="${HQ_JOB_STATUS_CACHE_DIR:-${HOME}/.hq/jobs/status-cache}"
114
- STATE_DIR="${HQ_JOB_RECONCILE_STATE:-${HOME}/.hq/jobs/reconcile}"
115
- SYSTEMCTL_BIN="${HQ_JOB_SYSTEMCTL:-systemctl}"
116
- RANDOMIZE_SEC="${HQ_JOB_RANDOMIZE_SEC:-60}"
117
- mkdir -p "$UNIT_DIR" "$STATUS_CACHE_DIR" "$STATE_DIR"
118
-
119
- iso_now() {
120
- date -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null \
121
- || date -u +"%Y-%m-%dT%H:%M:%S+00:00"
122
- }
123
-
124
- api_base() {
125
- local base
126
- base="${HQ_PRO_API_URL:-${HQ_API_URL:-${HQ_VAULT_API_URL:-https://hqapi.hq.computer}}}"
127
- printf '%s' "${base%/}"
128
- }
129
-
130
- # --- owner identity (company jobs: creator's Outpost only) -------------------
131
-
132
- resolve_box_owners() {
133
- # Emits newline-separated owner identity tokens this box may claim.
134
- local tokens=""
135
- add_token() {
136
- local t="$1"
137
- [ -n "$t" ] && [ "$t" != "null" ] || return 0
138
- case $'\n'"$tokens"$'\n' in
139
- *$'\n'"$t"$'\n'*) ;;
140
- *) tokens="${tokens:+${tokens}$'\n'}${t}" ;;
141
- esac
142
- }
143
-
144
- add_token "${HQ_JOB_OWNER_EMAIL:-}"
145
- add_token "${HQ_JOB_OWNER_UID:-}"
146
- add_token "${OUTPOST_USER_ID:-}"
147
-
148
- if command -v hq >/dev/null 2>&1; then
149
- local who
150
- who="$(hq whoami --json 2>/dev/null || hq whoami 2>/dev/null || true)"
151
- if [ -n "$who" ]; then
152
- local email uid
153
- email="$(printf '%s' "$who" | jq -r '.email // .user.email // empty' 2>/dev/null || true)"
154
- uid="$(printf '%s' "$who" | jq -r '.personUid // .person_uid // .uid // empty' 2>/dev/null || true)"
155
- if [ -z "$email" ]; then
156
- email="$(printf '%s' "$who" | sed -n 's/.*Email:[[:space:]]*//Ip;s/.*email[[:space:]]*[:=][[:space:]]*//Ip' | head -1 | tr -d '[:space:]')"
157
- fi
158
- add_token "$email"
159
- add_token "$uid"
160
- fi
161
- fi
162
-
163
- # Common on-box identity files.
164
- if [ -f /etc/outpost/codex-identity.env ]; then
165
- # shellcheck disable=SC1091
166
- . /etc/outpost/codex-identity.env 2>/dev/null || true
167
- add_token "${OUTPOST_USER_ID:-}"
168
- add_token "${OUTPOST_OWNER_EMAIL:-}"
169
- add_token "${OWNER_EMAIL:-}"
170
- fi
171
-
172
- printf '%s' "$tokens"
173
- }
174
-
175
- owner_matches() {
176
- local job_owner="$1"
177
- local box_owners="$2"
178
- [ -n "$job_owner" ] || return 1
179
- local t
180
- while IFS= read -r t; do
181
- [ -n "$t" ] || continue
182
- if [ "$t" = "$job_owner" ]; then
183
- return 0
184
- fi
185
- # Case-insensitive email compare
186
- if printf '%s' "$t" | grep -qi '@' \
187
- && [ "$(printf '%s' "$t" | tr '[:upper:]' '[:lower:]')" = "$(printf '%s' "$job_owner" | tr '[:upper:]' '[:lower:]')" ]; then
188
- return 0
189
- fi
190
- done <<EOF
191
- $box_owners
192
- EOF
193
- return 1
194
- }
195
-
196
- # --- readiness cache ---------------------------------------------------------
197
-
198
- status_cache_path() {
199
- printf '%s/%s.json' "$STATUS_CACHE_DIR" "$1"
200
- }
201
-
202
- read_cached_readiness() {
203
- local id="$1" path
204
- path="$(status_cache_path "$id")"
205
- if [ -f "$path" ]; then
206
- yq -r '.readiness // "unknown"' "$path" 2>/dev/null || echo unknown
207
- return
208
- fi
209
- # Fall back to probe last-attempt marker when it recorded a successful ingest.
210
- local probe_marker="${HOME}/.hq/jobs/probes/${id}/last-attempt.json"
211
- if [ -f "$probe_marker" ]; then
212
- local reported
213
- reported="$(jq -r '.reported_readiness // empty' "$probe_marker" 2>/dev/null || true)"
214
- if [ -n "$reported" ] && [ "$reported" != "null" ]; then
215
- printf '%s' "$reported"
216
- return
217
- fi
218
- fi
219
- printf 'unknown'
220
- }
221
-
222
- write_status_cache() {
223
- local id="$1" readiness="$2" source="${3:-reconcile}"
224
- local path
225
- path="$(status_cache_path "$id")"
226
- jq -nc \
227
- --arg job_id "$id" \
228
- --arg readiness "$readiness" \
229
- --arg updated_at "$(iso_now)" \
230
- --arg source "$source" \
231
- '{job_id:$job_id,readiness:$readiness,updated_at:$updated_at,source:$source}' \
232
- >"$path"
233
- chmod 600 "$path" 2>/dev/null || true
234
- }
235
-
236
- # Optional live GET when HQ_ACCESS_TOKEN is present (laptop JWT on box is rare).
237
- fetch_live_readiness() {
238
- local id="$1"
239
- [ -n "${HQ_ACCESS_TOKEN:-}" ] || return 1
240
- local url curl_bin http body tmp
241
- url="$(api_base)/outpost/jobs/status/${id}"
242
- curl_bin="${HQ_JOB_PROBE_CURL:-curl}"
243
- command -v "$curl_bin" >/dev/null 2>&1 || return 1
244
- tmp="$(mktemp "${TMPDIR:-/tmp}/hq-job-status.XXXXXX")"
245
- http=0
246
- set +e
247
- "$curl_bin" -sS -m 15 \
248
- -H "authorization: Bearer ${HQ_ACCESS_TOKEN}" \
249
- -H "accept: application/json" \
250
- -o "$tmp" \
251
- -w '%{http_code}' \
252
- "$url" >"${tmp}.code"
253
- set -e
254
- http="$(cat "${tmp}.code" 2>/dev/null || echo 0)"
255
- body="$(cat "$tmp" 2>/dev/null || true)"
256
- rm -f "$tmp" "${tmp}.code"
257
- if [ "$http" -ge 200 ] && [ "$http" -lt 300 ]; then
258
- local r
259
- r="$(printf '%s' "$body" | jq -r '.status.readiness // .readiness // empty' 2>/dev/null || true)"
260
- if [ -n "$r" ]; then
261
- write_status_cache "$id" "$r" "api-get"
262
- printf '%s' "$r"
263
- return 0
264
- fi
265
- fi
266
- return 1
267
- }
268
-
269
- job_readiness() {
270
- local id="$1" r
271
- if r="$(fetch_live_readiness "$id")"; then
272
- printf '%s' "$r"
273
- return
274
- fi
275
- read_cached_readiness "$id"
276
- }
277
-
278
- # --- cron → systemd OnCalendar -----------------------------------------------
279
-
280
- # Map cron DOW (0/7=Sun … 6=Sat) to systemd weekday names.
281
- cron_dow_to_systemd() {
282
- local field="$1"
283
- case "$field" in
284
- '*'|'*/1') printf ''; return ;;
285
- esac
286
- local out="" part IFS=','
287
- # shellcheck disable=SC2086
288
- set -f
289
- set -- $field
290
- set +f
291
- for part in "$@"; do
292
- local mapped
293
- mapped="$(cron_dow_token_to_systemd "$part")" || return 1
294
- [ -n "$mapped" ] || continue
295
- out="${out:+$out,}$mapped"
296
- done
297
- printf '%s' "$out"
298
- }
299
-
300
- cron_dow_token_to_systemd() {
301
- local tok="$1"
302
- # ranges like 1-5 or Mon-Fri
303
- if printf '%s' "$tok" | grep -Eq '^[0-7]+-[0-7]+$'; then
304
- local a b
305
- a="${tok%-*}"; b="${tok#*-}"
306
- printf '%s..%s' "$(cron_dow_num_to_name "$a")" "$(cron_dow_num_to_name "$b")"
307
- return
308
- fi
309
- case "$(printf '%s' "$tok" | tr '[:upper:]' '[:lower:]')" in
310
- sun|0|7) printf 'Sun' ;;
311
- mon|1) printf 'Mon' ;;
312
- tue|2) printf 'Tue' ;;
313
- wed|3) printf 'Wed' ;;
314
- thu|4) printf 'Thu' ;;
315
- fri|5) printf 'Fri' ;;
316
- sat|6) printf 'Sat' ;;
317
- mon..fri|1-5) printf 'Mon..Fri' ;;
318
- *) return 1 ;;
319
- esac
320
- }
321
-
322
- cron_dow_num_to_name() {
323
- case "$1" in
324
- 0|7) printf 'Sun' ;;
325
- 1) printf 'Mon' ;;
326
- 2) printf 'Tue' ;;
327
- 3) printf 'Wed' ;;
328
- 4) printf 'Thu' ;;
329
- 5) printf 'Fri' ;;
330
- 6) printf 'Sat' ;;
331
- *) return 1 ;;
332
- esac
333
- }
334
-
335
- pad2() {
336
- local n="$1"
337
- if [ "${#n}" -eq 1 ]; then
338
- printf '0%s' "$n"
339
- else
340
- printf '%s' "$n"
341
- fi
342
- }
343
-
344
- # Convert a single cron field that is a fixed number or */n or a-b into
345
- # systemd calendar fragment for hour/minute. Returns empty for '*'.
346
- cron_time_field() {
347
- local field="$1"
348
- case "$field" in
349
- '*') printf ''; return ;;
350
- */*)
351
- local step="${field#*/}"
352
- printf '*/%s' "$step"
353
- return
354
- ;;
355
- *-*)
356
- printf '%s' "$field"
357
- return
358
- ;;
359
- *)
360
- if printf '%s' "$field" | grep -Eq '^[0-9]+$'; then
361
- pad2 "$field"
362
- return
363
- fi
364
- printf '%s' "$field"
365
- ;;
366
- esac
367
- }
368
-
369
- cron_to_oncalendar() {
370
- # stdin unused; args: minute hour dom month dow → prints OnCalendar value
371
- local minute="$1" hour="$2" dom="$3" month="$4" dow="$5"
372
- local dow_s date_s time_s h m
373
-
374
- dow_s="$(cron_dow_to_systemd "$dow" || true)"
375
-
376
- # Date part: year-month-day → *-month-day
377
- local mon_s day_s
378
- case "$month" in
379
- '*') mon_s='*' ;;
380
- *) mon_s="$(pad2 "$month" 2>/dev/null || printf '%s' "$month")" ;;
381
- esac
382
- case "$dom" in
383
- '*') day_s='*' ;;
384
- *) day_s="$(pad2 "$dom" 2>/dev/null || printf '%s' "$dom")" ;;
385
- esac
386
- date_s="*-${mon_s}-${day_s}"
387
-
388
- m="$(cron_time_field "$minute")"
389
- h="$(cron_time_field "$hour")"
390
- [ -n "$m" ] || m='00'
391
- [ -n "$h" ] || h='*'
392
- # Normalize pure numbers already padded; handle hour=*
393
- if [ "$h" = '*' ]; then
394
- time_s="*:${m}:00"
395
- elif printf '%s' "$h" | grep -q '/'; then
396
- # e.g. */6 → 0/6
397
- local step="${h#*/}"
398
- time_s="0/${step}:${m}:00"
399
- else
400
- time_s="${h}:${m}:00"
401
- fi
402
-
403
- if [ -n "$dow_s" ]; then
404
- printf '%s %s %s' "$dow_s" "$date_s" "$time_s"
405
- else
406
- printf '%s %s' "$date_s" "$time_s"
407
- fi
408
- }
409
-
410
- # --- unit writers ------------------------------------------------------------
411
-
412
- unit_service_path() { printf '%s/hq-job-%s.service' "$UNIT_DIR" "$1"; }
413
- unit_timer_path() { printf '%s/hq-job-%s.timer' "$UNIT_DIR" "$1"; }
414
-
415
- normalize_service_path() {
416
- local remaining="$1" entry existing normalized="" has_more seen entry_count=0 index
417
- local -a entries=()
418
- while :; do
419
- if [[ "$remaining" == *:* ]]; then
420
- entry="${remaining%%:*}"
421
- remaining="${remaining#*:}"
422
- has_more=1
423
- else
424
- entry="$remaining"
425
- remaining=""
426
- has_more=0
427
- fi
428
- case "$entry" in
429
- /*)
430
- seen=0
431
- for ((index = 0; index < entry_count; index++)); do
432
- existing="${entries[index]}"
433
- if [ "$existing" = "$entry" ]; then
434
- seen=1
435
- break
436
- fi
437
- done
438
- if [ "$seen" -eq 0 ]; then
439
- entries+=("$entry")
440
- entry_count=$((entry_count + 1))
441
- fi
442
- ;;
443
- esac
444
- [ "$has_more" -eq 1 ] || break
445
- done
446
- if [ "$entry_count" -gt 0 ]; then
447
- local IFS=:
448
- normalized="${entries[*]}"
449
- fi
450
- printf '%s' "$normalized"
451
- }
452
-
453
- unit_service_path_value() {
454
- local service_path hq_bin npm_prefix hq_bin_dir
455
- service_path="${PATH:-/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin}"
456
- service_path="$(normalize_service_path "$service_path")"
457
- hq_bin=""
458
- if [ -n "$service_path" ]; then
459
- hq_bin="$(PATH="$service_path" type -P hq 2>/dev/null || true)"
460
- fi
461
- if [ -z "$hq_bin" ] && PATH="$service_path" command -v npm >/dev/null 2>&1; then
462
- npm_prefix="$(PATH="$service_path" npm prefix -g 2>/dev/null || true)"
463
- if [ -n "$npm_prefix" ] && [ -x "$npm_prefix/bin/hq" ]; then
464
- hq_bin="$npm_prefix/bin/hq"
465
- fi
466
- fi
467
- if [ -n "$hq_bin" ] && [ -x "$hq_bin" ]; then
468
- hq_bin_dir="$(cd "$(dirname "$hq_bin")" 2>/dev/null && pwd -P || true)"
469
- if [ -n "$hq_bin_dir" ]; then
470
- case ":$service_path:" in
471
- *":$hq_bin_dir:"*) ;;
472
- *) service_path="$hq_bin_dir:$service_path" ;;
473
- esac
474
- fi
475
- fi
476
- printf '%s' "$service_path"
477
- }
478
-
479
- systemd_quote() {
480
- local value="$1"
481
- value="${value//%/%%}"
482
- value="${value//\\/\\\\}"
483
- value="${value//\"/\\\"}"
484
- value="${value//$'\n'/\\n}"
485
- value="${value//$'\r'/\\r}"
486
- value="${value//$'\t'/\\t}"
487
- printf '"%s"' "$value"
488
- }
489
-
490
- write_service_unit() {
491
- local id="$1" path service_path
492
- path="$(unit_service_path "$id")"
493
- service_path="$(unit_service_path_value)"
494
- local runner_abs
495
- runner_abs="$(cd "$(dirname "$RUNNER")" && pwd)/$(basename "$RUNNER")"
496
- cat >"$path" <<EOF
497
- # Generated by outpost-jobs-reconcile.sh — disposable derived state. Do not edit.
498
- [Unit]
499
- Description=HQ Outpost scheduled job: ${id}
500
- Documentation=file://${HQ_ROOT}/core/knowledge/public/hq-core/outpost-jobs-spec.md
501
- After=network-online.target
502
- Wants=network-online.target
503
-
504
- [Service]
505
- Type=oneshot
506
- Environment=HQ_ROOT=${HQ_ROOT}
507
- Environment=$(systemd_quote "PATH=$service_path")
508
- ExecStart=${runner_abs} --hq-root ${HQ_ROOT} --job-id ${id}
509
- Nice=10
510
-
511
- [Install]
512
- WantedBy=default.target
513
- EOF
514
- }
515
-
516
- write_timer_unit() {
517
- local id="$1" oncal="$2" path
518
- path="$(unit_timer_path "$id")"
519
- cat >"$path" <<EOF
520
- # Generated by outpost-jobs-reconcile.sh — disposable derived state. Do not edit.
521
- [Unit]
522
- Description=HQ Outpost scheduled job timer: ${id}
523
- Documentation=file://${HQ_ROOT}/core/knowledge/public/hq-core/outpost-jobs-spec.md
524
-
525
- [Timer]
526
- OnCalendar=${oncal}
527
- Persistent=true
528
- RandomizedDelaySec=${RANDOMIZE_SEC}
529
- Unit=hq-job-${id}.service
530
-
531
- [Install]
532
- WantedBy=timers.target
533
- EOF
534
- }
535
-
536
- systemctl_user() {
537
- if [ "$DRY_RUN" -eq 1 ]; then
538
- log "dry-run systemctl --user $*"
539
- return 0
540
- fi
541
- if [ "$SYSTEMCTL_BIN" = ":" ] || [ "$SYSTEMCTL_BIN" = "true" ]; then
542
- return 0
543
- fi
544
- if ! command -v "$SYSTEMCTL_BIN" >/dev/null 2>&1; then
545
- log "systemctl not available — units written but not enabled ($*)"
546
- return 0
547
- fi
548
- "$SYSTEMCTL_BIN" --user "$@"
549
- }
550
-
551
- enable_timer() {
552
- local id="$1"
553
- local output rc=0
554
- systemctl_user daemon-reload || true
555
- output="$(systemctl_user enable --now "hq-job-${id}.timer" 2>&1)" || rc=$?
556
- if [ "$rc" -ne 0 ]; then
557
- log "systemctl enable timer failed for job $id exit=$rc: ${output:-no error message}"
558
- return "$rc"
559
- fi
560
- if [ -n "$output" ]; then
561
- printf '%s\n' "$output" >&2
562
- fi
563
- return 0
564
- }
565
-
566
- disable_remove_units() {
567
- local id="$1"
568
- systemctl_user disable --now "hq-job-${id}.timer" 2>/dev/null || true
569
- systemctl_user stop "hq-job-${id}.service" 2>/dev/null || true
570
- rm -f "$(unit_service_path "$id")" "$(unit_timer_path "$id")"
571
- }
572
-
573
- units_fingerprint() {
574
- local id="$1"
575
- local sp tp
576
- sp="$(unit_service_path "$id")"
577
- tp="$(unit_timer_path "$id")"
578
- if [ ! -f "$sp" ] || [ ! -f "$tp" ]; then
579
- printf ''
580
- return
581
- fi
582
- # Content hash for idempotency compare
583
- cat "$sp" "$tp" | shasum -a 256 2>/dev/null | awk '{print $1}' \
584
- || cat "$sp" "$tp" | cksum | awk '{print $1}'
585
- }
586
-
587
- # --- registry scan -----------------------------------------------------------
588
-
589
- collect_job_files() {
590
- {
591
- # Only top-level registry YAMLs — skip sync conflict sidecars and quarantine dirs.
592
- find "$HQ_ROOT/personal/jobs" -maxdepth 1 \( -name '*.yaml' -o -name '*.yml' \) -type f 2>/dev/null || true
593
- find "$HQ_ROOT/companies" -path '*/jobs/*' -maxdepth 3 \( -name '*.yaml' -o -name '*.yml' \) -type f 2>/dev/null || true
594
- } | grep -Ev '\.conflict-|/\.conflicts/' | sort -u
595
- }
596
-
597
- is_company_job_path() {
598
- case "$1" in
599
- */companies/*/jobs/*) return 0 ;;
600
- *) return 1 ;;
601
- esac
602
- }
603
-
604
- ensure_sync_hook() {
605
- local hook_dir="$HQ_ROOT/personal/hooks/Stop"
606
- local hook_path="$hook_dir/90-outpost-jobs-reconcile.sh"
607
- mkdir -p "$hook_dir"
608
- cat >"$hook_path" <<EOF
609
- #!/usr/bin/env bash
610
- # Auto-installed by outpost-jobs-reconcile.sh --ensure-hook (US-004).
611
- # hq-sync-runner executes personal/hooks/Stop/* after each sync cycle.
612
- set -u
613
- HQ_ROOT="\${OUTPOST_HQ_ROOT:-\${HQ_ROOT:-/home/ec2-user/hq}}"
614
- RECONCILE="\$HQ_ROOT/core/scripts/outpost-jobs-reconcile.sh"
615
- if [ -x "\$RECONCILE" ] || [ -f "\$RECONCILE" ]; then
616
- bash "\$RECONCILE" --hq-root "\$HQ_ROOT" --after-sync >>"\${HOME}/.hq/jobs/reconcile/after-sync.log" 2>&1 || true
617
- fi
618
- EOF
619
- chmod +x "$hook_path"
620
- log "installed sync Stop hook: $hook_path"
621
- }
622
-
623
- # --- main reconcile ----------------------------------------------------------
624
-
625
- BOX_OWNERS="$(resolve_box_owners)"
626
- DESIRED_IDS=""
627
- ERRORS=0
628
- ARMED=0
629
- SKIPPED=0
630
- REMOVED=0
631
- PROBE_RAN=0
632
-
633
- if [ "$ENSURE_HOOK" -eq 1 ]; then
634
- ensure_sync_hook
635
- fi
636
-
637
- # First pass: probe jobs that are not ready (unless --no-probe).
638
- if [ "$NO_PROBE" -eq 0 ]; then
639
- while IFS= read -r jf; do
640
- [ -n "$jf" ] || continue
641
- if ! bash "$VALIDATE" "$jf" >/dev/null 2>&1; then
642
- continue
643
- fi
644
- jid="$(yq -r '.id // ""' "$jf")"
645
- [ -n "$jid" ] || continue
646
- if is_company_job_path "$jf"; then
647
- jowner="$(yq -r '.owner // ""' "$jf")"
648
- if ! owner_matches "$jowner" "$BOX_OWNERS"; then
649
- continue
650
- fi
651
- fi
652
- readiness="$(job_readiness "$jid")"
653
- if [ "$readiness" != "ready" ]; then
654
- log "probe $jid (cached readiness=$readiness)"
655
- set +e
656
- bash "$PROBE" --hq-root "$HQ_ROOT" "$jf" >/tmp/hq-job-probe-out.$$ 2>/tmp/hq-job-probe-err.$$
657
- prc=$?
658
- set -e
659
- PROBE_RAN=$((PROBE_RAN + 1))
660
- # Refresh cache from probe JSON stdout when present.
661
- if [ -f /tmp/hq-job-probe-out.$$ ]; then
662
- pready="$(jq -r '.readiness // empty' /tmp/hq-job-probe-out.$$ 2>/dev/null || true)"
663
- if [ -n "$pready" ]; then
664
- # Only cache definitive ready/blocked from a successful ingest path.
665
- ingest="$(jq -r '.ingest // empty' /tmp/hq-job-probe-out.$$ 2>/dev/null || true)"
666
- if [ "$ingest" = "ok" ] || [ "$ingest" = "dry_run" ]; then
667
- write_status_cache "$jid" "$pready" "probe"
668
- elif [ "$pready" = "blocked" ]; then
669
- write_status_cache "$jid" "blocked" "probe-local"
670
- fi
671
- fi
672
- fi
673
- rm -f /tmp/hq-job-probe-out.$$ /tmp/hq-job-probe-err.$$
674
- if [ "$prc" -ge 2 ] && [ "$prc" -ne 3 ]; then
675
- ERRORS=$((ERRORS + 1))
676
- fi
677
- fi
678
- done < <(collect_job_files)
679
- fi
680
-
681
- if [ "$PROBE_ONLY" -eq 1 ]; then
682
- log "probe-only done (probed=$PROBE_RAN errors=$ERRORS)"
683
- [ "$ERRORS" -eq 0 ] || exit 1
684
- exit 0
685
- fi
686
-
687
- # Second pass: materialize / remove units.
688
- while IFS= read -r jf; do
689
- [ -n "$jf" ] || continue
690
-
691
- job_id_hint="$(yq -r '.id // ""' "$jf" 2>/dev/null || true)"
692
- tz="$(yq -r '.timezone // ""' "$jf" 2>/dev/null || true)"
693
- if [ -n "$tz" ] && [[ ! "$tz" =~ ^[A-Za-z0-9/_+-]+$ ]]; then
694
- log "skip ${job_id_hint:-unknown} — invalid timezone contains unsafe characters"
695
- ERRORS=$((ERRORS + 1))
696
- continue
697
- fi
698
-
699
- if ! bash "$VALIDATE" "$jf" >/dev/null 2>&1; then
700
- log "skip invalid job YAML: $jf"
701
- ERRORS=$((ERRORS + 1))
702
- continue
703
- fi
704
-
705
- jid="$job_id_hint"
706
- jname="$(yq -r '.name // ""' "$jf")"
707
- jowner="$(yq -r '.owner // ""' "$jf")"
708
- enabled="$(yq -o=json '.' "$jf" | jq -r 'if has("enabled") then (.enabled|tostring) else "true" end')"
709
- schedule="$(yq -r '.schedule // ""' "$jf")"
710
- if is_company_job_path "$jf"; then
711
- if ! owner_matches "$jowner" "$BOX_OWNERS"; then
712
- log "skip company job $jid — owner '$jowner' does not match this Outpost"
713
- SKIPPED=$((SKIPPED + 1))
714
- # Ensure we do not leave a stale timer if ownership changed.
715
- if [ -f "$(unit_timer_path "$jid")" ]; then
716
- disable_remove_units "$jid"
717
- REMOVED=$((REMOVED + 1))
718
- fi
719
- continue
720
- fi
721
- fi
722
-
723
- if [ "$enabled" = "false" ] || [ "$enabled" = "False" ] || [ "$enabled" = "0" ]; then
724
- log "remove units for disabled job $jid"
725
- if [ -f "$(unit_timer_path "$jid")" ] || [ -f "$(unit_service_path "$jid")" ]; then
726
- disable_remove_units "$jid"
727
- REMOVED=$((REMOVED + 1))
728
- fi
729
- continue
730
- fi
731
-
732
- readiness="$(job_readiness "$jid")"
733
- if [ "$readiness" != "ready" ]; then
734
- log "skip arming $jid — readiness=$readiness (need ready)"
735
- SKIPPED=$((SKIPPED + 1))
736
- if [ -f "$(unit_timer_path "$jid")" ]; then
737
- disable_remove_units "$jid"
738
- REMOVED=$((REMOVED + 1))
739
- log "stopped timer for non-ready job $jid"
740
- fi
741
- continue
742
- fi
743
-
744
- # Parse 5-field cron
745
- set -f
746
- # shellcheck disable=SC2086
747
- set -- $schedule
748
- set +f
749
- if [ "$#" -ne 5 ]; then
750
- log "skip $jid — schedule not 5-field cron: $schedule"
751
- ERRORS=$((ERRORS + 1))
752
- continue
753
- fi
754
- oncal="$(cron_to_oncalendar "$1" "$2" "$3" "$4" "$5")" || {
755
- log "skip $jid — cannot convert cron to OnCalendar: $schedule"
756
- ERRORS=$((ERRORS + 1))
757
- continue
758
- }
759
- if [ -n "$tz" ]; then
760
- case " $oncal " in
761
- *" $tz "*) ;;
762
- *) oncal="$oncal $tz" ;;
763
- esac
764
- fi
765
-
766
- DESIRED_IDS="${DESIRED_IDS:+${DESIRED_IDS}$'\n'}${jid}"
767
-
768
- # Write to temp then compare for idempotency.
769
- tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/hq-job-unit.XXXXXX")"
770
- OLD_UNIT_DIR="$UNIT_DIR"
771
- UNIT_DIR="$tmp_dir"
772
- write_service_unit "$jid"
773
- write_timer_unit "$jid" "$oncal"
774
- UNIT_DIR="$OLD_UNIT_DIR"
775
-
776
- new_hash="$(cat "$tmp_dir/hq-job-${jid}.service" "$tmp_dir/hq-job-${jid}.timer" | shasum -a 256 2>/dev/null | awk '{print $1}' \
777
- || cat "$tmp_dir/hq-job-${jid}.service" "$tmp_dir/hq-job-${jid}.timer" | cksum | awk '{print $1}')"
778
- old_hash="$(units_fingerprint "$jid")"
779
-
780
- if [ "$new_hash" = "$old_hash" ] && [ -n "$old_hash" ]; then
781
- log "noop $jid — units unchanged ($jname)"
782
- rm -rf "$tmp_dir"
783
- # Ensure timer still enabled
784
- if enable_timer "$jid"; then
785
- ARMED=$((ARMED + 1))
786
- else
787
- SKIPPED=$((SKIPPED + 1))
788
- ERRORS=$((ERRORS + 1))
789
- fi
790
- continue
791
- fi
792
-
793
- cp "$tmp_dir/hq-job-${jid}.service" "$(unit_service_path "$jid")"
794
- cp "$tmp_dir/hq-job-${jid}.timer" "$(unit_timer_path "$jid")"
795
- rm -rf "$tmp_dir"
796
- if enable_timer "$jid"; then
797
- log "armed $jid OnCalendar=$oncal readiness=ready"
798
- ARMED=$((ARMED + 1))
799
- else
800
- SKIPPED=$((SKIPPED + 1))
801
- ERRORS=$((ERRORS + 1))
802
- fi
803
- done < <(collect_job_files)
804
-
805
- # Remove units for jobs no longer in the desired set (deleted from registry).
806
- for unit_file in "$UNIT_DIR"/hq-job-*.timer; do
807
- [ -e "$unit_file" ] || continue
808
- base="$(basename "$unit_file" .timer)"
809
- uid="${base#hq-job-}"
810
- found=0
811
- while IFS= read -r d; do
812
- [ "$d" = "$uid" ] && found=1 && break
813
- done <<EOF
814
- $DESIRED_IDS
815
- EOF
816
- if [ "$found" -eq 0 ]; then
817
- log "remove orphaned units for deleted/unarmed job $uid"
818
- disable_remove_units "$uid"
819
- REMOVED=$((REMOVED + 1))
820
- fi
821
- done
822
-
823
- SUMMARY="$(jq -nc \
824
- --argjson armed "$ARMED" \
825
- --argjson skipped "$SKIPPED" \
826
- --argjson removed "$REMOVED" \
827
- --argjson probed "$PROBE_RAN" \
828
- --argjson errors "$ERRORS" \
829
- --arg dry_run "$DRY_RUN" \
830
- --arg at "$(iso_now)" \
831
- '{armed:$armed,skipped:$skipped,removed:$removed,probed:$probed,errors:$errors,dry_run:($dry_run=="1"),at:$at}')"
832
- printf '%s\n' "$SUMMARY" | tee "$STATE_DIR/last-reconcile.json" >/dev/null
833
- log "done armed=$ARMED skipped=$SKIPPED removed=$REMOVED probed=$PROBE_RAN errors=$ERRORS"
834
-
835
- [ "$ERRORS" -eq 0 ] || exit 1
836
- exit 0