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