@indigoai-us/hq-cli 5.291.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.
- package/CHANGELOG.md +27 -8
- package/assets/scaffold/core/scripts/handoff-finalize.sh +18 -0
- package/assets/scaffold/core/scripts/handoff-post.sh +71 -6
- package/assets/scaffold/core/scripts/hq-delegate-bundle.sh +307 -0
- package/assets/scaffold/core/scripts/hq-delegate-grant.sh +226 -0
- package/assets/scaffold/core/scripts/hq-delegate-publish.sh +91 -0
- package/assets/scaffold/core/scripts/hq-delegate-send.sh +259 -0
- package/assets/scaffold/core/scripts/hq-delegate-verify.sh +213 -0
- package/assets/scaffold/core/scripts/hq-job-notify.sh +389 -0
- package/assets/scaffold/core/scripts/hq-job-probe.sh +835 -0
- package/assets/scaffold/core/scripts/hq-job-remediate.sh +359 -0
- package/assets/scaffold/core/scripts/hq-job-run.sh +1025 -0
- package/assets/scaffold/core/scripts/outpost-jobs-reconcile.sh +830 -0
- package/dist/command-catalog.generated.d.ts +106 -1
- package/dist/command-catalog.generated.js +132 -1
- package/dist/commands/core-delegate-w1b.d.ts +8 -0
- package/dist/commands/core-delegate-w1b.js +1049 -0
- package/dist/commands/core-delegate.d.ts +28 -0
- package/dist/commands/core-delegate.js +15 -13
- package/dist/commands/core-handoff.js +152 -23
- package/dist/commands/core-jobs-native.d.ts +15 -0
- package/dist/commands/core-jobs-native.js +709 -2
- package/dist/commands/core-ontology.d.ts +22 -4
- package/dist/commands/core-ontology.js +90 -8
- package/dist/commands/core.js +10 -0
- package/dist/lib/checkpoint-ontology/drop-aliases.d.ts +41 -0
- package/dist/lib/checkpoint-ontology/drop-aliases.js +76 -0
- package/dist/lib/checkpoint-ontology/entity-file.d.ts +6 -3
- package/dist/lib/checkpoint-ontology/entity-file.js +12 -6
- package/dist/lib/checkpoint-ontology/merge-copies.d.ts +27 -8
- package/dist/lib/checkpoint-ontology/merge-copies.js +64 -97
- package/dist/lib/daemon/flag.d.ts +2 -0
- package/dist/lib/daemon/flag.js +2 -0
- package/dist/utils/scaffold-sibling-references.js +4 -4
- package/dist/utils/self-update.d.ts +6 -0
- package/dist/utils/self-update.js +28 -6
- package/dist/utils/staged-install.d.ts +34 -0
- package/dist/utils/staged-install.js +296 -0
- package/package.json +1 -1
- package/scripts/mesh-daemon-if-stale.mjs +4 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# hq-core: public
|
|
3
|
+
# hq-delegate-verify.sh — prove a delegation can actually be picked up
|
|
4
|
+
# before anyone is told it is theirs, or print the full plan without
|
|
5
|
+
# touching anything.
|
|
6
|
+
#
|
|
7
|
+
# Usage (probe mode — after grants, before the DM):
|
|
8
|
+
# core/scripts/hq-delegate-verify.sh --manifest <path>
|
|
9
|
+
#
|
|
10
|
+
# Probes every manifest vaultPrefix with `hq files browse` and requires it
|
|
11
|
+
# reachable AND non-empty. All pass -> manifest advances granted -> verified
|
|
12
|
+
# (send is gated on this). Any failure -> non-zero, the failing prefix and
|
|
13
|
+
# likely cause named, status left at its last successful value so a re-run
|
|
14
|
+
# resumes without re-granting.
|
|
15
|
+
#
|
|
16
|
+
# Usage (dry-run mode — full plan, zero mutation):
|
|
17
|
+
# core/scripts/hq-delegate-verify.sh --dry-run \
|
|
18
|
+
# --company <slug> --project <name> --to <principal> [--mode transfer|share]
|
|
19
|
+
#
|
|
20
|
+
# Prints recipient, mode, every prefix with its permission, the secret
|
|
21
|
+
# names an .env.schema would grant, repo + branch, and the DM headline that
|
|
22
|
+
# would be sent. Exits 0. Creates nothing under workspace/delegations/ and
|
|
23
|
+
# invokes no mutating command.
|
|
24
|
+
#
|
|
25
|
+
# Env: HQ_ROOT — HQ root override (default: resolved from script location)
|
|
26
|
+
|
|
27
|
+
set -euo pipefail
|
|
28
|
+
|
|
29
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
30
|
+
HQ_ROOT="${HQ_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd)}"
|
|
31
|
+
|
|
32
|
+
usage() { sed -n '3,25p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; }
|
|
33
|
+
die() { echo "hq-delegate-verify: $*" >&2; exit 1; }
|
|
34
|
+
|
|
35
|
+
command -v jq >/dev/null 2>&1 || die "jq is required but not installed"
|
|
36
|
+
|
|
37
|
+
MANIFEST="" DRY_RUN=0 COMPANY="" PROJECT="" TO="" MODE="transfer"
|
|
38
|
+
while [ $# -gt 0 ]; do
|
|
39
|
+
case "$1" in
|
|
40
|
+
--manifest) MANIFEST="${2:-}"; shift 2 ;;
|
|
41
|
+
--dry-run) DRY_RUN=1; shift ;;
|
|
42
|
+
--company) COMPANY="${2:-}"; shift 2 ;;
|
|
43
|
+
--project) PROJECT="${2:-}"; shift 2 ;;
|
|
44
|
+
--to) TO="${2:-}"; shift 2 ;;
|
|
45
|
+
--mode) MODE="${2:-}"; shift 2 ;;
|
|
46
|
+
-h|--help) usage; exit 0 ;;
|
|
47
|
+
*) die "unknown argument: $1" ;;
|
|
48
|
+
esac
|
|
49
|
+
done
|
|
50
|
+
|
|
51
|
+
# =============================================================================
|
|
52
|
+
# Dry-run mode: full plan from the builder's own derivation, zero mutation
|
|
53
|
+
# =============================================================================
|
|
54
|
+
|
|
55
|
+
if [ "$DRY_RUN" -eq 1 ]; then
|
|
56
|
+
[ -n "$COMPANY" ] || die "--dry-run requires --company"
|
|
57
|
+
[ -n "$PROJECT" ] || die "--dry-run requires --project"
|
|
58
|
+
[ -n "$TO" ] || die "--dry-run requires --to"
|
|
59
|
+
|
|
60
|
+
# The builder's --dry-run prints the manifest it WOULD write — single
|
|
61
|
+
# derivation source, nothing lands on disk.
|
|
62
|
+
PLAN_JSON="$(HQ_ROOT="$HQ_ROOT" bash "$SCRIPT_DIR/hq-delegate-bundle.sh" build \
|
|
63
|
+
--company "$COMPANY" --project "$PROJECT" --to "$TO" --mode "$MODE" --dry-run)" \
|
|
64
|
+
|| die "could not derive the delegation plan (see builder error above)"
|
|
65
|
+
|
|
66
|
+
# Secret names the US-005 step would grant (same .env.schema derivation).
|
|
67
|
+
REPO_REL="$(printf '%s' "$PLAN_JSON" | jq -r '.repo.path // empty')"
|
|
68
|
+
SCHEMA=""
|
|
69
|
+
for candidate in \
|
|
70
|
+
"$HQ_ROOT/companies/$COMPANY/projects/$PROJECT/.env.schema" \
|
|
71
|
+
"${REPO_REL:+$HQ_ROOT/$REPO_REL/.env.schema}"; do
|
|
72
|
+
[ -n "$candidate" ] && [ -f "$candidate" ] && { SCHEMA="$candidate"; break; }
|
|
73
|
+
done
|
|
74
|
+
SECRET_NAMES="(none — no .env.schema found)"
|
|
75
|
+
if [ -n "$SCHEMA" ]; then
|
|
76
|
+
SECRET_NAMES="$(grep -E '^[A-Za-z_][A-Za-z0-9_]*=' "$SCHEMA" | cut -d= -f1 | sort -u | tr '\n' ' ')"
|
|
77
|
+
[ -n "$SECRET_NAMES" ] || SECRET_NAMES="(none declared)"
|
|
78
|
+
fi
|
|
79
|
+
|
|
80
|
+
echo "Delegation dry run — nothing will be pushed, granted, transferred, or sent."
|
|
81
|
+
echo
|
|
82
|
+
echo " Recipient: $TO"
|
|
83
|
+
echo " Mode: $MODE"
|
|
84
|
+
echo " Company: $COMPANY"
|
|
85
|
+
echo " Project: $PROJECT"
|
|
86
|
+
echo
|
|
87
|
+
echo " Vault grants that would be written:"
|
|
88
|
+
printf '%s' "$PLAN_JSON" | jq -r '.vaultPrefixes[] | " \(.permission)\ton \(.prefix)\t(\(.reason // ""))"'
|
|
89
|
+
echo
|
|
90
|
+
echo " Secret names that would be granted (read): $SECRET_NAMES"
|
|
91
|
+
echo
|
|
92
|
+
if printf '%s' "$PLAN_JSON" | jq -e '.repo != null' >/dev/null; then
|
|
93
|
+
printf '%s' "$PLAN_JSON" | jq -r '" Repo handover: \(.repo.path) — branch \(.repo.branch // "(none)") (base \(.repo.baseBranch))"'
|
|
94
|
+
else
|
|
95
|
+
echo " Repo handover: none (project has no repoPath)"
|
|
96
|
+
fi
|
|
97
|
+
echo
|
|
98
|
+
echo " DM that would be sent to $TO:"
|
|
99
|
+
echo " \"Project handoff: $PROJECT is yours now — open details for the brief, paste the prompt into your agent to pick it up\""
|
|
100
|
+
echo " (prompt: generated pickup prompt; details: the delegation brief)"
|
|
101
|
+
exit 0
|
|
102
|
+
fi
|
|
103
|
+
|
|
104
|
+
# =============================================================================
|
|
105
|
+
# Probe mode: reachability of every granted prefix
|
|
106
|
+
# =============================================================================
|
|
107
|
+
|
|
108
|
+
[ -n "$MANIFEST" ] || die "--manifest is required (or --dry-run with --company/--project/--to)"
|
|
109
|
+
[ -f "$MANIFEST" ] || die "manifest not found: $MANIFEST"
|
|
110
|
+
jq -e . "$MANIFEST" >/dev/null 2>&1 || die "manifest is not valid JSON: $MANIFEST"
|
|
111
|
+
|
|
112
|
+
COMPANY="$(jq -r '.company // empty' "$MANIFEST")"
|
|
113
|
+
LOCK="$MANIFEST.send-lock"
|
|
114
|
+
mkdir "$LOCK" 2>/dev/null || die "verification or delivery is active; retry after it finishes"
|
|
115
|
+
REF_BROWSE_CACHE=""
|
|
116
|
+
trap '[[ -z "$REF_BROWSE_CACHE" ]] || rm -rf "$REF_BROWSE_CACHE"; rmdir "$LOCK"' EXIT
|
|
117
|
+
STATUS="$(jq -r '.status // empty' "$MANIFEST")"
|
|
118
|
+
[ -n "$COMPANY" ] || die "manifest has no company"
|
|
119
|
+
|
|
120
|
+
case "$STATUS" in
|
|
121
|
+
granted|verified) ;;
|
|
122
|
+
building) die "manifest status is 'building' — run hq-delegate-grant.sh first (probe verifies grants, it cannot create them)" ;;
|
|
123
|
+
*) die "manifest status is '$STATUS' — probe runs from 'granted' (or re-runs from 'verified')" ;;
|
|
124
|
+
esac
|
|
125
|
+
|
|
126
|
+
PREFIX_COUNT="$(jq '.vaultPrefixes | length' "$MANIFEST")"
|
|
127
|
+
[ "$PREFIX_COUNT" -gt 0 ] || die "manifest has no vaultPrefixes to probe"
|
|
128
|
+
|
|
129
|
+
echo "hq-delegate-verify: probing $PREFIX_COUNT prefix(es) in company '$COMPANY'"
|
|
130
|
+
FAILED=0
|
|
131
|
+
i=0
|
|
132
|
+
while [ "$i" -lt "$PREFIX_COUNT" ]; do
|
|
133
|
+
PFX="$(jq -r ".vaultPrefixes[$i].prefix" "$MANIFEST")"
|
|
134
|
+
BROWSE_OUT="$(hq files browse "$PFX" --company "$COMPANY" 2>&1)" && BROWSE_RC=0 || BROWSE_RC=$?
|
|
135
|
+
if [ "$BROWSE_RC" -ne 0 ]; then
|
|
136
|
+
echo " FAIL $PFX — browse errored (likely cause: the grant did not land, or the caller's session expired — re-run /hq-login and hq-delegate-grant.sh)"
|
|
137
|
+
FAILED=1
|
|
138
|
+
elif [ -z "$(printf '%s' "$BROWSE_OUT" | tr -d '[:space:]')" ]; then
|
|
139
|
+
echo " FAIL $PFX — reachable but EMPTY (likely cause: the dossier was never pushed to the vault — hq sync push companies/$COMPANY/... and re-run)"
|
|
140
|
+
FAILED=1
|
|
141
|
+
else
|
|
142
|
+
echo " pass $PFX"
|
|
143
|
+
fi
|
|
144
|
+
i=$((i + 1))
|
|
145
|
+
done
|
|
146
|
+
|
|
147
|
+
# --- referenced files: prefix reachability is not enough ---------------------
|
|
148
|
+
# (Live finding: a knowledge prefix can be reachable and non-empty while the
|
|
149
|
+
# SPECIFIC referenced file is absent — the recipient then pulls a folder that
|
|
150
|
+
# silently misses the note the brief points at. Probe each referenced file.)
|
|
151
|
+
|
|
152
|
+
# Memoized, throttle-tolerant browse for the referenced-file loop. Multiple
|
|
153
|
+
# referenced files can share a parent prefix (e.g. two policies under
|
|
154
|
+
# policies/), and back-to-back vault browses of the same prefix can be throttled
|
|
155
|
+
# to an EMPTY page — which the grep below would misread as "file not in vault".
|
|
156
|
+
# Browse each distinct parent at most once, and retry an empty result a few
|
|
157
|
+
# times before trusting it. (Cache dir cleaned on exit.)
|
|
158
|
+
REF_BROWSE_CACHE="$(mktemp -d)"
|
|
159
|
+
browse_parent() { # parent -> listing (memoized per run, retried on empty)
|
|
160
|
+
local parent="$1" key out attempt
|
|
161
|
+
key="$REF_BROWSE_CACHE/$(printf '%s' "$parent" | tr '/@:' '___')"
|
|
162
|
+
if [ -f "$key" ]; then cat "$key"; return 0; fi
|
|
163
|
+
attempt=1
|
|
164
|
+
out=""
|
|
165
|
+
while :; do
|
|
166
|
+
out="$(hq files browse "$parent" --company "$COMPANY" 2>/dev/null || true)"
|
|
167
|
+
[ -n "$(printf '%s' "$out" | tr -d '[:space:]')" ] && break
|
|
168
|
+
[ "$attempt" -ge "${HQ_DELEGATE_BROWSE_RETRIES:-3}" ] && break
|
|
169
|
+
attempt=$((attempt + 1))
|
|
170
|
+
sleep "${HQ_DELEGATE_BROWSE_RETRY_SLEEP:-2}"
|
|
171
|
+
done
|
|
172
|
+
printf '%s' "$out" > "$key"
|
|
173
|
+
printf '%s' "$out"
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
REF_COUNT="$(jq -r --arg co "$COMPANY" \
|
|
177
|
+
'[((.knowledge // []) + (.policies // []))[] | select(startswith("companies/" + $co + "/"))] | length' "$MANIFEST")"
|
|
178
|
+
if [ "$REF_COUNT" -gt 0 ]; then
|
|
179
|
+
echo "hq-delegate-verify: probing $REF_COUNT referenced knowledge/policy file(s)"
|
|
180
|
+
while IFS= read -r kpath; do
|
|
181
|
+
[ -n "$kpath" ] || continue
|
|
182
|
+
REL="${kpath#companies/$COMPANY/}"
|
|
183
|
+
PARENT="$(dirname "$REL")/"
|
|
184
|
+
BASE="$(basename "$REL")"
|
|
185
|
+
LISTING="$(browse_parent "$PARENT")"
|
|
186
|
+
# Pipe-free substring test: `printf | grep -q` lets grep close the pipe on an
|
|
187
|
+
# early match, which under `set -o pipefail` surfaces printf's SIGPIPE as a
|
|
188
|
+
# non-zero pipeline and FALSE-FAILS a file that is genuinely present (it bit
|
|
189
|
+
# exactly the referenced files that sort early in a large parent listing).
|
|
190
|
+
if [[ "$LISTING" == *"$BASE"* ]]; then
|
|
191
|
+
echo " pass $REL"
|
|
192
|
+
else
|
|
193
|
+
echo " FAIL $REL — granted prefix is reachable but this referenced file is NOT in the vault (likely cause: it was never pushed — hq sync push $kpath --company $COMPANY and re-run)"
|
|
194
|
+
FAILED=1
|
|
195
|
+
fi
|
|
196
|
+
done <<EOF
|
|
197
|
+
$(jq -r --arg co "$COMPANY" '((.knowledge // []) + (.policies // []))[] | select(startswith("companies/" + $co + "/"))' "$MANIFEST")
|
|
198
|
+
EOF
|
|
199
|
+
fi
|
|
200
|
+
|
|
201
|
+
if [ "$FAILED" -ne 0 ]; then
|
|
202
|
+
echo "hq-delegate-verify: probe FAILED — the DM must not be sent; fix the failing prefix(es) above and re-run (manifest status left at '$STATUS')" >&2
|
|
203
|
+
exit 1
|
|
204
|
+
fi
|
|
205
|
+
|
|
206
|
+
HQ_ROOT="$HQ_ROOT" bash "$SCRIPT_DIR/hq-delegate-publish.sh" --manifest "$MANIFEST" --lock-held \
|
|
207
|
+
|| die "final dossier publication failed; notification blocked"
|
|
208
|
+
NOW="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
209
|
+
TMP_MANIFEST="$(mktemp)"
|
|
210
|
+
jq --arg now "$NOW" '.status = "verified" | .verifiedAt = $now' \
|
|
211
|
+
"$MANIFEST" > "$TMP_MANIFEST" && mv "$TMP_MANIFEST" "$MANIFEST"
|
|
212
|
+
|
|
213
|
+
echo "hq-delegate-verify: all $PREFIX_COUNT prefix(es) reachable — manifest status advanced to 'verified'"
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# hq-core: public
|
|
3
|
+
# hq-job-notify.sh — dm-only remediation alerts for Outpost scheduled jobs (US-006).
|
|
4
|
+
#
|
|
5
|
+
# Delivers via schedule-alerts profile with v1 channel=dm only (`hq dm` to owner).
|
|
6
|
+
# If profile channel is slack|email|outpost-session, still deliver dm and log
|
|
7
|
+
# channel_unimplemented. Never targets the original /schedule create session.
|
|
8
|
+
#
|
|
9
|
+
# Escalation collapse:
|
|
10
|
+
# - first failure → DM immediately
|
|
11
|
+
# - consecutive failures → at most one failure DM per 24h per job
|
|
12
|
+
# - recovery after failures → recovered DM
|
|
13
|
+
# - success after success → ok DM (every successful run)
|
|
14
|
+
#
|
|
15
|
+
# Notify delivery failure is logged and never fails the caller (exit 0).
|
|
16
|
+
#
|
|
17
|
+
# Usage:
|
|
18
|
+
# core/scripts/hq-job-notify.sh \
|
|
19
|
+
# --job-id <id> --job-name <name> --outcome <ok|failed|blocked> \
|
|
20
|
+
# --summary <one-line> --duration <sec> --log-path <path> \
|
|
21
|
+
# [--failure-class <class>] [--next-action <text>] \
|
|
22
|
+
# [--owner <email|prs_*>] [--notify <dm|none|profile>] \
|
|
23
|
+
# [--hq-root <dir>]
|
|
24
|
+
#
|
|
25
|
+
# Env / overrides (tests):
|
|
26
|
+
# HQ_ROOT, HOME, HQ_JOB_ALERT_DIR, HQ_JOB_NOTIFY_DM_BIN, HQ_JOB_NOTIFY_NOW_EPOCH
|
|
27
|
+
# HQ_JOB_NOTIFY_COLLAPSE_SEC (default 86400)
|
|
28
|
+
#
|
|
29
|
+
# Exit: 0 always (delivery errors logged only); 2 usage.
|
|
30
|
+
|
|
31
|
+
set -euo pipefail
|
|
32
|
+
|
|
33
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
34
|
+
HQ_ROOT="${HQ_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd)}"
|
|
35
|
+
|
|
36
|
+
JOB_ID=""
|
|
37
|
+
JOB_NAME=""
|
|
38
|
+
OUTCOME=""
|
|
39
|
+
SUMMARY=""
|
|
40
|
+
DURATION=""
|
|
41
|
+
LOG_PATH=""
|
|
42
|
+
FAILURE_CLASS=""
|
|
43
|
+
NEXT_ACTION=""
|
|
44
|
+
OWNER=""
|
|
45
|
+
NOTIFY_MODE=""
|
|
46
|
+
|
|
47
|
+
usage() {
|
|
48
|
+
sed -n '3,32p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
die() {
|
|
52
|
+
echo "hq-job-notify: $*" >&2
|
|
53
|
+
exit 2
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
log() {
|
|
57
|
+
echo "hq-job-notify: $*" >&2
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
while [ $# -gt 0 ]; do
|
|
61
|
+
case "$1" in
|
|
62
|
+
--hq-root)
|
|
63
|
+
HQ_ROOT="${2:-}"
|
|
64
|
+
[ -n "$HQ_ROOT" ] || die "--hq-root requires a directory"
|
|
65
|
+
shift 2
|
|
66
|
+
;;
|
|
67
|
+
--job-id)
|
|
68
|
+
JOB_ID="${2:-}"
|
|
69
|
+
[ -n "$JOB_ID" ] || die "--job-id requires a value"
|
|
70
|
+
shift 2
|
|
71
|
+
;;
|
|
72
|
+
--job-name)
|
|
73
|
+
JOB_NAME="${2:-}"
|
|
74
|
+
shift 2
|
|
75
|
+
;;
|
|
76
|
+
--outcome)
|
|
77
|
+
OUTCOME="${2:-}"
|
|
78
|
+
[ -n "$OUTCOME" ] || die "--outcome requires a value"
|
|
79
|
+
shift 2
|
|
80
|
+
;;
|
|
81
|
+
--summary)
|
|
82
|
+
SUMMARY="${2:-}"
|
|
83
|
+
shift 2
|
|
84
|
+
;;
|
|
85
|
+
--duration)
|
|
86
|
+
DURATION="${2:-}"
|
|
87
|
+
shift 2
|
|
88
|
+
;;
|
|
89
|
+
--log-path)
|
|
90
|
+
LOG_PATH="${2:-}"
|
|
91
|
+
shift 2
|
|
92
|
+
;;
|
|
93
|
+
--failure-class)
|
|
94
|
+
FAILURE_CLASS="${2:-}"
|
|
95
|
+
shift 2
|
|
96
|
+
;;
|
|
97
|
+
--next-action)
|
|
98
|
+
NEXT_ACTION="${2:-}"
|
|
99
|
+
shift 2
|
|
100
|
+
;;
|
|
101
|
+
--owner)
|
|
102
|
+
OWNER="${2:-}"
|
|
103
|
+
shift 2
|
|
104
|
+
;;
|
|
105
|
+
--notify)
|
|
106
|
+
NOTIFY_MODE="${2:-}"
|
|
107
|
+
shift 2
|
|
108
|
+
;;
|
|
109
|
+
-h|--help)
|
|
110
|
+
usage
|
|
111
|
+
exit 0
|
|
112
|
+
;;
|
|
113
|
+
--)
|
|
114
|
+
shift
|
|
115
|
+
break
|
|
116
|
+
;;
|
|
117
|
+
-*)
|
|
118
|
+
die "unknown option: $1"
|
|
119
|
+
;;
|
|
120
|
+
*)
|
|
121
|
+
die "unexpected argument: $1"
|
|
122
|
+
;;
|
|
123
|
+
esac
|
|
124
|
+
done
|
|
125
|
+
|
|
126
|
+
[ -n "$JOB_ID" ] || die "usage: hq-job-notify.sh --job-id <id> --outcome <ok|failed|blocked> ..."
|
|
127
|
+
case "$OUTCOME" in
|
|
128
|
+
ok|failed|blocked) ;;
|
|
129
|
+
*) die "--outcome must be ok|failed|blocked" ;;
|
|
130
|
+
esac
|
|
131
|
+
|
|
132
|
+
[ -n "$JOB_NAME" ] || JOB_NAME="$JOB_ID"
|
|
133
|
+
[ -n "$SUMMARY" ] || SUMMARY="$OUTCOME"
|
|
134
|
+
[ -n "$DURATION" ] || DURATION="0"
|
|
135
|
+
[ -n "$LOG_PATH" ] || LOG_PATH=""
|
|
136
|
+
|
|
137
|
+
COLLAPSE_SEC="${HQ_JOB_NOTIFY_COLLAPSE_SEC:-86400}"
|
|
138
|
+
ALERT_BASE="${HQ_JOB_ALERT_DIR:-${HOME}/.hq/jobs/alerts}"
|
|
139
|
+
STATE_DIR="$ALERT_BASE/$JOB_ID"
|
|
140
|
+
mkdir -p "$STATE_DIR"
|
|
141
|
+
STATE_FILE="$STATE_DIR/state.json"
|
|
142
|
+
DELIVERY_LOG="$STATE_DIR/delivery.log"
|
|
143
|
+
|
|
144
|
+
epoch_now() {
|
|
145
|
+
if [ -n "${HQ_JOB_NOTIFY_NOW_EPOCH:-}" ]; then
|
|
146
|
+
printf '%s' "$HQ_JOB_NOTIFY_NOW_EPOCH"
|
|
147
|
+
return
|
|
148
|
+
fi
|
|
149
|
+
date -u +%s 2>/dev/null || date +%s
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
iso_now() {
|
|
153
|
+
date -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null \
|
|
154
|
+
|| date -u +"%Y-%m-%dT%H:%M:%S+00:00"
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
read_state() {
|
|
158
|
+
if [ -f "$STATE_FILE" ]; then
|
|
159
|
+
cat "$STATE_FILE"
|
|
160
|
+
else
|
|
161
|
+
echo '{}'
|
|
162
|
+
fi
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
write_state() {
|
|
166
|
+
local json="$1"
|
|
167
|
+
printf '%s\n' "$json" >"$STATE_FILE"
|
|
168
|
+
chmod 600 "$STATE_FILE" 2>/dev/null || true
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
# Resolve notify mode + profile channel.
|
|
172
|
+
PROFILE_CHANNEL="dm"
|
|
173
|
+
PROFILE_PATH="$HQ_ROOT/personal/settings/schedule-alerts.yaml"
|
|
174
|
+
if [ -z "$NOTIFY_MODE" ] || [ "$NOTIFY_MODE" = "null" ]; then
|
|
175
|
+
NOTIFY_MODE="profile"
|
|
176
|
+
fi
|
|
177
|
+
|
|
178
|
+
case "$NOTIFY_MODE" in
|
|
179
|
+
none)
|
|
180
|
+
log "notify=none — skipping delivery for job $JOB_ID"
|
|
181
|
+
echo '{"delivered":false,"reason":"notify_none"}'
|
|
182
|
+
exit 0
|
|
183
|
+
;;
|
|
184
|
+
dm)
|
|
185
|
+
PROFILE_CHANNEL="dm"
|
|
186
|
+
;;
|
|
187
|
+
profile)
|
|
188
|
+
if [ -f "$PROFILE_PATH" ] && command -v yq >/dev/null 2>&1; then
|
|
189
|
+
PROFILE_CHANNEL="$(yq -r '.channel // "dm"' "$PROFILE_PATH" 2>/dev/null || echo dm)"
|
|
190
|
+
else
|
|
191
|
+
PROFILE_CHANNEL="dm"
|
|
192
|
+
fi
|
|
193
|
+
;;
|
|
194
|
+
*)
|
|
195
|
+
log "unknown notify mode '$NOTIFY_MODE' — falling back to dm"
|
|
196
|
+
PROFILE_CHANNEL="dm"
|
|
197
|
+
;;
|
|
198
|
+
esac
|
|
199
|
+
|
|
200
|
+
CHANNEL_UNIMPLEMENTED=0
|
|
201
|
+
case "$PROFILE_CHANNEL" in
|
|
202
|
+
dm|"")
|
|
203
|
+
PROFILE_CHANNEL="dm"
|
|
204
|
+
;;
|
|
205
|
+
slack|email|outpost-session)
|
|
206
|
+
CHANNEL_UNIMPLEMENTED=1
|
|
207
|
+
log "channel_unimplemented=$PROFILE_CHANNEL — delivering dm fallback"
|
|
208
|
+
PROFILE_CHANNEL="dm"
|
|
209
|
+
;;
|
|
210
|
+
*)
|
|
211
|
+
log "channel_unimplemented=$PROFILE_CHANNEL — delivering dm fallback"
|
|
212
|
+
CHANNEL_UNIMPLEMENTED=1
|
|
213
|
+
PROFILE_CHANNEL="dm"
|
|
214
|
+
;;
|
|
215
|
+
esac
|
|
216
|
+
|
|
217
|
+
# Resolve owner (recipient). Prefer explicit --owner, else job owner left to caller.
|
|
218
|
+
if [ -z "$OWNER" ] || [ "$OWNER" = "null" ]; then
|
|
219
|
+
if command -v hq >/dev/null 2>&1; then
|
|
220
|
+
OWNER="$(hq whoami 2>/dev/null | awk -F': ' '/^email:/{print $2; exit}' || true)"
|
|
221
|
+
fi
|
|
222
|
+
fi
|
|
223
|
+
# hq dm accepts personUid recipients; keep an explicit owner UID unchanged.
|
|
224
|
+
if [ -z "$OWNER" ]; then
|
|
225
|
+
log "no owner recipient — cannot dm (logged only); never fails the job"
|
|
226
|
+
echo '{"delivered":false,"reason":"missing_owner"}' | tee -a "$DELIVERY_LOG" >/dev/null
|
|
227
|
+
# Still update local state so collapse logic stays coherent.
|
|
228
|
+
:
|
|
229
|
+
fi
|
|
230
|
+
|
|
231
|
+
NOW_EPOCH="$(epoch_now)"
|
|
232
|
+
PREV="$(read_state)"
|
|
233
|
+
PREV_OUTCOME="$(printf '%s' "$PREV" | jq -r '.last_outcome // empty')"
|
|
234
|
+
PREV_FAIL_DM="$(printf '%s' "$PREV" | jq -r '.last_failure_dm_epoch // 0')"
|
|
235
|
+
case "$PREV_FAIL_DM" in
|
|
236
|
+
''|*[!0-9]*) PREV_FAIL_DM=0 ;;
|
|
237
|
+
esac
|
|
238
|
+
|
|
239
|
+
SEND_KIND="" # ok | failed | recovered | suppressed
|
|
240
|
+
case "$OUTCOME" in
|
|
241
|
+
ok)
|
|
242
|
+
if [ "$PREV_OUTCOME" = "failed" ] || [ "$PREV_OUTCOME" = "blocked" ]; then
|
|
243
|
+
SEND_KIND="recovered"
|
|
244
|
+
else
|
|
245
|
+
SEND_KIND="ok"
|
|
246
|
+
fi
|
|
247
|
+
;;
|
|
248
|
+
failed|blocked)
|
|
249
|
+
if [ "$PREV_OUTCOME" = "failed" ] || [ "$PREV_OUTCOME" = "blocked" ]; then
|
|
250
|
+
elapsed=$((NOW_EPOCH - PREV_FAIL_DM))
|
|
251
|
+
if [ "$PREV_FAIL_DM" -gt 0 ] && [ "$elapsed" -lt "$COLLAPSE_SEC" ]; then
|
|
252
|
+
SEND_KIND="suppressed"
|
|
253
|
+
else
|
|
254
|
+
SEND_KIND="failed"
|
|
255
|
+
fi
|
|
256
|
+
else
|
|
257
|
+
SEND_KIND="failed"
|
|
258
|
+
fi
|
|
259
|
+
;;
|
|
260
|
+
esac
|
|
261
|
+
|
|
262
|
+
STATUS_WORD="$OUTCOME"
|
|
263
|
+
case "$SEND_KIND" in
|
|
264
|
+
recovered) STATUS_WORD="recovered" ;;
|
|
265
|
+
suppressed)
|
|
266
|
+
log "collapse: suppressing failure dm for job $JOB_ID (within ${COLLAPSE_SEC}s window)"
|
|
267
|
+
NEW_STATE="$(jq -nc \
|
|
268
|
+
--arg outcome "$OUTCOME" \
|
|
269
|
+
--argjson now "$NOW_EPOCH" \
|
|
270
|
+
--argjson last_dm "$PREV_FAIL_DM" \
|
|
271
|
+
--arg updated "$(iso_now)" \
|
|
272
|
+
--arg fc "$FAILURE_CLASS" \
|
|
273
|
+
'{
|
|
274
|
+
last_outcome: $outcome,
|
|
275
|
+
last_failure_dm_epoch: $last_dm,
|
|
276
|
+
last_updated_at: $updated,
|
|
277
|
+
last_failure_class: $fc,
|
|
278
|
+
last_delivery: "suppressed"
|
|
279
|
+
}')"
|
|
280
|
+
write_state "$NEW_STATE"
|
|
281
|
+
jq -nc \
|
|
282
|
+
--arg reason "collapsed_24h" \
|
|
283
|
+
--argjson channel_unimplemented "$CHANNEL_UNIMPLEMENTED" \
|
|
284
|
+
'{delivered:false, reason:$reason, channel_unimplemented:($channel_unimplemented==1)}'
|
|
285
|
+
exit 0
|
|
286
|
+
;;
|
|
287
|
+
esac
|
|
288
|
+
|
|
289
|
+
# Build message body (no create-thread targeting — hq dm only).
|
|
290
|
+
CLASS_PART=""
|
|
291
|
+
if [ -n "$FAILURE_CLASS" ] && [ "$FAILURE_CLASS" != "null" ]; then
|
|
292
|
+
CLASS_PART=" class=$FAILURE_CLASS"
|
|
293
|
+
fi
|
|
294
|
+
ACTION_PART=""
|
|
295
|
+
if [ -n "$NEXT_ACTION" ]; then
|
|
296
|
+
ACTION_PART=" next: $NEXT_ACTION"
|
|
297
|
+
fi
|
|
298
|
+
|
|
299
|
+
case "$SEND_KIND" in
|
|
300
|
+
ok)
|
|
301
|
+
HEADLINE="Scheduled job '${JOB_NAME}' ok (${DURATION}s)"
|
|
302
|
+
;;
|
|
303
|
+
recovered)
|
|
304
|
+
HEADLINE="Scheduled job '${JOB_NAME}' recovered (${DURATION}s)"
|
|
305
|
+
;;
|
|
306
|
+
failed)
|
|
307
|
+
HEADLINE="Scheduled job '${JOB_NAME}' ${STATUS_WORD}${CLASS_PART} (${DURATION}s)"
|
|
308
|
+
;;
|
|
309
|
+
*)
|
|
310
|
+
HEADLINE="Scheduled job '${JOB_NAME}' ${STATUS_WORD} (${DURATION}s)"
|
|
311
|
+
;;
|
|
312
|
+
esac
|
|
313
|
+
|
|
314
|
+
DETAILS="$(printf 'job=%s\nstatus=%s\nclassification=%s\nsummary=%s\nduration_seconds=%s\nlog=%s%s\n' \
|
|
315
|
+
"$JOB_ID" "$STATUS_WORD" "${FAILURE_CLASS:-none}" "$SUMMARY" "$DURATION" "${LOG_PATH:-none}" "$ACTION_PART")"
|
|
316
|
+
|
|
317
|
+
DELIVERED=0
|
|
318
|
+
DELIVER_RC=0
|
|
319
|
+
DM_BIN="${HQ_JOB_NOTIFY_DM_BIN:-}"
|
|
320
|
+
if [ -z "$DM_BIN" ]; then
|
|
321
|
+
if command -v hq >/dev/null 2>&1; then
|
|
322
|
+
DM_BIN="hq"
|
|
323
|
+
fi
|
|
324
|
+
fi
|
|
325
|
+
|
|
326
|
+
if [ -z "$OWNER" ]; then
|
|
327
|
+
log "skip dm send — missing owner"
|
|
328
|
+
DELIVER_RC=1
|
|
329
|
+
elif [ -z "$DM_BIN" ]; then
|
|
330
|
+
log "skip dm send — hq CLI not found"
|
|
331
|
+
DELIVER_RC=1
|
|
332
|
+
else
|
|
333
|
+
set +e
|
|
334
|
+
if [ "$DM_BIN" = "hq" ]; then
|
|
335
|
+
hq dm "$OWNER" "$HEADLINE" --details "$DETAILS" >>"$DELIVERY_LOG" 2>&1
|
|
336
|
+
DELIVER_RC=$?
|
|
337
|
+
else
|
|
338
|
+
# Custom binary: invoked as <bin> <owner> <headline> <details>
|
|
339
|
+
"$DM_BIN" "$OWNER" "$HEADLINE" "$DETAILS" >>"$DELIVERY_LOG" 2>&1
|
|
340
|
+
DELIVER_RC=$?
|
|
341
|
+
fi
|
|
342
|
+
set -e
|
|
343
|
+
if [ "$DELIVER_RC" -eq 0 ]; then
|
|
344
|
+
DELIVERED=1
|
|
345
|
+
log "dm delivered to $OWNER kind=$SEND_KIND job=$JOB_ID"
|
|
346
|
+
else
|
|
347
|
+
log "dm delivery failed rc=$DELIVER_RC job=$JOB_ID (logged; not failing run)"
|
|
348
|
+
fi
|
|
349
|
+
fi
|
|
350
|
+
|
|
351
|
+
FAIL_DM_EPOCH="$PREV_FAIL_DM"
|
|
352
|
+
if [ "$SEND_KIND" = "failed" ] && [ "$DELIVERED" -eq 1 ]; then
|
|
353
|
+
FAIL_DM_EPOCH="$NOW_EPOCH"
|
|
354
|
+
elif [ "$SEND_KIND" = "ok" ] || [ "$SEND_KIND" = "recovered" ]; then
|
|
355
|
+
FAIL_DM_EPOCH=0
|
|
356
|
+
fi
|
|
357
|
+
|
|
358
|
+
NEW_STATE="$(jq -nc \
|
|
359
|
+
--arg outcome "$OUTCOME" \
|
|
360
|
+
--argjson now "$NOW_EPOCH" \
|
|
361
|
+
--argjson last_dm "$FAIL_DM_EPOCH" \
|
|
362
|
+
--arg updated "$(iso_now)" \
|
|
363
|
+
--arg fc "$FAILURE_CLASS" \
|
|
364
|
+
--arg kind "$SEND_KIND" \
|
|
365
|
+
--argjson delivered "$DELIVERED" \
|
|
366
|
+
'{
|
|
367
|
+
last_outcome: $outcome,
|
|
368
|
+
last_failure_dm_epoch: $last_dm,
|
|
369
|
+
last_updated_at: $updated,
|
|
370
|
+
last_failure_class: $fc,
|
|
371
|
+
last_delivery: $kind,
|
|
372
|
+
last_delivered: ($delivered == 1)
|
|
373
|
+
}')"
|
|
374
|
+
write_state "$NEW_STATE"
|
|
375
|
+
|
|
376
|
+
jq -nc \
|
|
377
|
+
--argjson delivered "$DELIVERED" \
|
|
378
|
+
--arg kind "$SEND_KIND" \
|
|
379
|
+
--arg owner "$OWNER" \
|
|
380
|
+
--argjson channel_unimplemented "$CHANNEL_UNIMPLEMENTED" \
|
|
381
|
+
--argjson deliver_rc "$DELIVER_RC" \
|
|
382
|
+
'{
|
|
383
|
+
delivered: ($delivered == 1),
|
|
384
|
+
kind: $kind,
|
|
385
|
+
owner: $owner,
|
|
386
|
+
channel_unimplemented: ($channel_unimplemented == 1),
|
|
387
|
+
deliver_rc: $deliver_rc
|
|
388
|
+
}'
|
|
389
|
+
exit 0
|