@delorenj/pjangler 1.4.2 → 1.4.4
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/README.md +528 -0
- package/contracts/fleet-contract.yaml +513 -0
- package/dist/index.js +9333 -1509
- package/dist/mcp-server.js +6634 -1096
- package/dist/prompt.js +2 -1
- package/package.json +10 -4
- package/templates/hermes-agent/copier.yml +16 -3
- package/templates/hermes-agent/template/.runtime-scaffold/memories/MEMORY.md +7 -4
- package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +88 -94
- package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +44 -21
- package/templates/hermes-agent/template/.scripts/30-telegram.sh +182 -171
- package/templates/hermes-agent/template/.scripts/31-slack.sh +260 -165
- package/templates/hermes-agent/template/.scripts/40-plane.sh +45 -36
- package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +210 -41
- package/templates/hermes-agent/template/.scripts/70-systemd.sh +129 -15
- package/templates/hermes-agent/template/.scripts/80-registry.sh +42 -6
- package/templates/hermes-agent/template/.scripts/99-summary.sh +69 -16
- package/templates/hermes-agent/template/.scripts/_lib.sh +773 -0
- package/templates/hermes-agent/template/.scripts/channel-transaction.py +2340 -0
- package/templates/hermes-agent/template/.scripts/config.example.toml +8 -2
- package/templates/hermes-agent/template/.scripts/credential-launch.sh +5 -1
- package/templates/hermes-agent/template/.scripts/heartbeat.sh +2 -3
- package/templates/hermes-agent/template/.scripts/lib/profile-config-lock.py +182 -0
- package/templates/hermes-agent/template/.scripts/lib/profile-config-seed.py +108 -0
- package/templates/hermes-agent/template/.scripts/lib/ticket-provider.sh +93 -4
- package/templates/hermes-agent/template/.scripts/lib/voice-config.py +546 -0
- package/templates/hermes-agent/template/.scripts/providers/linear.sh +138 -25
- package/templates/hermes-agent/template/.scripts/providers/plane.sh +408 -51
- package/templates/hermes-agent/template/.scripts/providers/trello.sh +54 -6
- package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-autonomous-review.sh +257 -43
- package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-close-gate.sh +142 -25
- package/templates/hermes-agent/template/.scripts/sentinel/docs/autonomous-delegated-review.md +13 -19
- package/templates/hermes-agent/template/.scripts/sentinel/docs/bloodbank-events.md +29 -36
- package/templates/hermes-agent/template/.scripts/sentinel/docs/continuous-ticket-orchestration.md +3 -1
- package/templates/hermes-agent/template/.scripts/sentinel.prompt.md.jinja +7 -8
- package/templates/hermes-agent/template/.scripts/store-onepassword-secret.py +260 -0
- package/templates/hermes-agent/template/SOUL.md.jinja +14 -16
- package/templates/hermes-agent/template/hermes.jinja +1 -1
- package/templates/hermes-agent/template/role.yaml.jinja +15 -4
|
@@ -106,6 +106,562 @@ p.write_text("".join(lines), encoding="utf-8")
|
|
|
106
106
|
PYEOF
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
# Upsert one scalar below a top-level mapping without requiring the mapping to
|
|
110
|
+
# have existed in an older rendered role.yaml. This is intentionally narrower
|
|
111
|
+
# than a general YAML editor: lifecycle scripts use it only for safe, generated
|
|
112
|
+
# status metadata such as service_state.gateway.
|
|
113
|
+
yaml_upsert_block_value() {
|
|
114
|
+
# yaml_upsert_block_value PARENT KEY VALUE [string|bool]
|
|
115
|
+
python3 - "$ROLE_YAML" "$1" "$2" "$3" "${4:-string}" <<'PYEOF'
|
|
116
|
+
import json, pathlib, re, sys
|
|
117
|
+
|
|
118
|
+
path, parent, key, value, kind = sys.argv[1:6]
|
|
119
|
+
if not all(re.fullmatch(r"[A-Za-z_][A-Za-z0-9_-]*", item) for item in (parent, key)):
|
|
120
|
+
raise SystemExit("yaml_upsert_block_value: unsafe key")
|
|
121
|
+
if kind == "bool":
|
|
122
|
+
if value not in {"true", "false"}:
|
|
123
|
+
raise SystemExit("yaml_upsert_block_value: invalid boolean")
|
|
124
|
+
rendered_value = value
|
|
125
|
+
elif kind == "string":
|
|
126
|
+
rendered_value = json.dumps(value, ensure_ascii=False)
|
|
127
|
+
else:
|
|
128
|
+
raise SystemExit("yaml_upsert_block_value: unsupported scalar type")
|
|
129
|
+
p = pathlib.Path(path)
|
|
130
|
+
text = p.read_text(encoding="utf-8")
|
|
131
|
+
match = re.search(
|
|
132
|
+
rf"(?ms)^{re.escape(parent)}:\s*\n(?P<body>(?:^[ \t]+.*\n?)*)", text
|
|
133
|
+
)
|
|
134
|
+
replacement = f" {key}: {rendered_value}"
|
|
135
|
+
if match:
|
|
136
|
+
body = match.group("body")
|
|
137
|
+
body, count = re.subn(
|
|
138
|
+
rf"(?m)^[ \t]+{re.escape(key)}:\s*.*$", replacement, body, count=1
|
|
139
|
+
)
|
|
140
|
+
if count == 0:
|
|
141
|
+
if body and not body.endswith("\n"):
|
|
142
|
+
body += "\n"
|
|
143
|
+
body += replacement + "\n"
|
|
144
|
+
text = text[: match.start("body")] + body + text[match.end("body") :]
|
|
145
|
+
else:
|
|
146
|
+
if text and not text.endswith("\n"):
|
|
147
|
+
text += "\n"
|
|
148
|
+
text += f"\n{parent}:\n{replacement}\n"
|
|
149
|
+
p.write_text(text, encoding="utf-8")
|
|
150
|
+
PYEOF
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
# Test whether an ignored dotenv file contains one non-empty exact assignment.
|
|
154
|
+
# Values remain inside the child process and are never printed or imported into
|
|
155
|
+
# the provisioning shell.
|
|
156
|
+
dotenv_has_nonempty() {
|
|
157
|
+
python3 - "$1" "$2" <<'PYEOF'
|
|
158
|
+
import pathlib, re, sys
|
|
159
|
+
|
|
160
|
+
path = pathlib.Path(sys.argv[1])
|
|
161
|
+
key = sys.argv[2]
|
|
162
|
+
if not re.fullmatch(r"[A-Z][A-Z0-9_]*", key) or not path.is_file() or path.is_symlink():
|
|
163
|
+
raise SystemExit(1)
|
|
164
|
+
for raw in path.read_text(encoding="utf-8").splitlines():
|
|
165
|
+
line = raw.strip()
|
|
166
|
+
if not line or line.startswith("#"):
|
|
167
|
+
continue
|
|
168
|
+
if line.startswith("export "):
|
|
169
|
+
line = line[7:].lstrip()
|
|
170
|
+
name, sep, value = line.partition("=")
|
|
171
|
+
if not sep or name.strip() != key:
|
|
172
|
+
continue
|
|
173
|
+
value = value.strip()
|
|
174
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
|
|
175
|
+
value = value[1:-1]
|
|
176
|
+
raise SystemExit(0 if value else 1)
|
|
177
|
+
raise SystemExit(1)
|
|
178
|
+
PYEOF
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
# Persist one process-only value to 1Password. The helper prints only the
|
|
182
|
+
# resulting op:// reference; the value crosses stdin and an anonymous pipe.
|
|
183
|
+
store_onepassword_secret() {
|
|
184
|
+
# store_onepassword_secret ITEM_NAME VALUE
|
|
185
|
+
local item="$1" value="$2" vault
|
|
186
|
+
vault="${HERMES_ONEPASSWORD_VAULT:-$(config_get fleet.onepassword_vault 'DeLoSecrets')}"
|
|
187
|
+
[[ -n "$vault" ]] || die "fleet.onepassword_vault is required for channel credentials"
|
|
188
|
+
[[ -f "$ROLE_DIR/.scripts/store-onepassword-secret.py" ]] \
|
|
189
|
+
|| die "trusted 1Password storage helper is missing"
|
|
190
|
+
printf '%s' "$value" \
|
|
191
|
+
| python3 -I "$ROLE_DIR/.scripts/store-onepassword-secret.py" "$vault" "$item"
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
# Stage one credential in a new immutable 1Password item. Output is two lines:
|
|
195
|
+
# item id, then op:// reference. Nothing is active until the channel transaction
|
|
196
|
+
# commits that reference.
|
|
197
|
+
stage_onepassword_secret() {
|
|
198
|
+
# stage_onepassword_secret ITEM_PREFIX FIELD VALUE
|
|
199
|
+
local item="$1" field="$2" value="$3" vault
|
|
200
|
+
vault="${HERMES_ONEPASSWORD_VAULT:-$(config_get fleet.onepassword_vault 'DeLoSecrets')}"
|
|
201
|
+
[[ -n "$vault" ]] || die "fleet.onepassword_vault is required for channel credentials"
|
|
202
|
+
[[ -f "$ROLE_DIR/.scripts/store-onepassword-secret.py" ]] \
|
|
203
|
+
|| die "trusted 1Password storage helper is missing"
|
|
204
|
+
printf '%s' "$value" \
|
|
205
|
+
| python3 -I "$ROLE_DIR/.scripts/store-onepassword-secret.py" \
|
|
206
|
+
--store-staged "$vault" "$item" "$field"
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
# Stage a credential pair with one atomic 1Password item create. Output is
|
|
210
|
+
# immutable item id followed by both verified op:// references.
|
|
211
|
+
stage_onepassword_secret_pair() {
|
|
212
|
+
# stage_onepassword_secret_pair ITEM_NAME FIELD_ONE VALUE_ONE FIELD_TWO VALUE_TWO
|
|
213
|
+
local item="$1" field_one="$2" value_one="$3" field_two="$4" value_two="$5" vault
|
|
214
|
+
vault="${HERMES_ONEPASSWORD_VAULT:-$(config_get fleet.onepassword_vault 'DeLoSecrets')}"
|
|
215
|
+
[[ -n "$vault" ]] || die "fleet.onepassword_vault is required for channel credentials"
|
|
216
|
+
[[ -f "$ROLE_DIR/.scripts/store-onepassword-secret.py" ]] \
|
|
217
|
+
|| die "trusted 1Password storage helper is missing"
|
|
218
|
+
printf '%s\n%s' "$value_one" "$value_two" \
|
|
219
|
+
| python3 -I "$ROLE_DIR/.scripts/store-onepassword-secret.py" \
|
|
220
|
+
--store-pair "$vault" "$item" "$field_one" "$field_two"
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
delete_staged_onepassword_item() {
|
|
224
|
+
# delete_staged_onepassword_item IMMUTABLE_ITEM_ID
|
|
225
|
+
local item_id="$1" vault
|
|
226
|
+
vault="${HERMES_ONEPASSWORD_VAULT:-$(config_get fleet.onepassword_vault 'DeLoSecrets')}"
|
|
227
|
+
[[ -n "$vault" && -n "$item_id" ]] || return 1
|
|
228
|
+
python3 -I "$ROLE_DIR/.scripts/store-onepassword-secret.py" \
|
|
229
|
+
--delete-item-id "$vault" "$item_id"
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
# Update one supported profile override and immediately regenerate config.yaml
|
|
233
|
+
# from fleet base + delta. Keeping both operations in one helper prevents the
|
|
234
|
+
# first deploy and fleet-sync paths from disagreeing about which file is the
|
|
235
|
+
# source of truth.
|
|
236
|
+
profile_config_delta_update() {
|
|
237
|
+
# profile_config_delta_update PROFILE_HOME secret-ref ENV_NAME OP_REFERENCE
|
|
238
|
+
# profile_config_delta_update PROFILE_HOME secret-ref-pair ENV REF ENV REF
|
|
239
|
+
# profile_config_delta_update PROFILE_HOME channel-enabled telegram|slack true|false
|
|
240
|
+
# profile_config_delta_update PROFILE_HOME voice PLUGIN VOICE
|
|
241
|
+
local profile_lock_tool="$ROLE_DIR/.scripts/lib/profile-config-lock.py"
|
|
242
|
+
[[ -f "$profile_lock_tool" && ! -L "$profile_lock_tool" ]] \
|
|
243
|
+
|| die "trusted PM profile config lock helper is unavailable"
|
|
244
|
+
if [[ "${2:-}" == "voice" ]]; then
|
|
245
|
+
[[ $# -eq 4 ]] || die "voice requires a profile, plugin, and voice"
|
|
246
|
+
local voice_tool="$ROLE_DIR/.scripts/lib/voice-config.py"
|
|
247
|
+
[[ -f "$voice_tool" && ! -L "$voice_tool" ]] \
|
|
248
|
+
|| die "trusted PM voice config helper is unavailable"
|
|
249
|
+
python3 -I "$voice_tool" reconcile \
|
|
250
|
+
--base "$1/../../config.yaml" \
|
|
251
|
+
--delta "$1/config.delta.yaml" \
|
|
252
|
+
--generated "$1/config.yaml" \
|
|
253
|
+
--plugin "$3" \
|
|
254
|
+
--voice "$4"
|
|
255
|
+
return
|
|
256
|
+
fi
|
|
257
|
+
python3 - "$profile_lock_tool" "$@" <<'PYEOF'
|
|
258
|
+
import atexit, copy, importlib.util, os, pathlib, re, sys, tempfile
|
|
259
|
+
try:
|
|
260
|
+
import yaml
|
|
261
|
+
except ImportError:
|
|
262
|
+
raise SystemExit("PyYAML is required for Hermes profile config")
|
|
263
|
+
|
|
264
|
+
lock_source = pathlib.Path(sys.argv[1])
|
|
265
|
+
spec = importlib.util.spec_from_file_location("pjangler_profile_config_lock", lock_source)
|
|
266
|
+
if spec is None or spec.loader is None:
|
|
267
|
+
raise SystemExit(f"cannot load profile config lock helper: {lock_source}")
|
|
268
|
+
profile_lock_module = importlib.util.module_from_spec(spec)
|
|
269
|
+
spec.loader.exec_module(profile_lock_module)
|
|
270
|
+
profile = pathlib.Path(sys.argv[2])
|
|
271
|
+
profile_lock = profile_lock_module.ProfileConfigLock(profile)
|
|
272
|
+
try:
|
|
273
|
+
profile_lock.acquire()
|
|
274
|
+
except profile_lock_module.ProfileConfigLockError as exc:
|
|
275
|
+
raise SystemExit(str(exc)) from exc
|
|
276
|
+
atexit.register(profile_lock.release)
|
|
277
|
+
# A profile root is a security boundary. Never follow the legacy symlink form:
|
|
278
|
+
# even a read would escape the named profile and a later atomic replace could
|
|
279
|
+
# mutate shared/runtime state. Migration must happen in the lifecycle step.
|
|
280
|
+
if profile.is_symlink():
|
|
281
|
+
raise SystemExit(
|
|
282
|
+
f"refusing symlinked profile root: {profile}; "
|
|
283
|
+
"run the pjangler Hermes runtime-singleton migration before provisioning channels"
|
|
284
|
+
)
|
|
285
|
+
if not profile.is_dir():
|
|
286
|
+
raise SystemExit(f"required profile root is unavailable: {profile}")
|
|
287
|
+
args = sys.argv[3:]
|
|
288
|
+
if not args:
|
|
289
|
+
raise SystemExit("profile config update mode is required")
|
|
290
|
+
mode = args[0]
|
|
291
|
+
base_path = profile.parent.parent / "config.yaml"
|
|
292
|
+
delta_path = profile / "config.delta.yaml"
|
|
293
|
+
generated_path = profile / "config.yaml"
|
|
294
|
+
|
|
295
|
+
def load(path):
|
|
296
|
+
if not path.is_file() or path.is_symlink():
|
|
297
|
+
raise SystemExit(f"required config source is unavailable: {path}")
|
|
298
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
299
|
+
if not isinstance(data, dict):
|
|
300
|
+
raise SystemExit(f"config source must be a mapping: {path}")
|
|
301
|
+
return data
|
|
302
|
+
|
|
303
|
+
LIST_PATCH_KEY = "x-pjangler-merge"
|
|
304
|
+
|
|
305
|
+
def plain_merge(base, override):
|
|
306
|
+
result = copy.deepcopy(base)
|
|
307
|
+
for key, value in override.items():
|
|
308
|
+
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
|
309
|
+
result[key] = plain_merge(result[key], value)
|
|
310
|
+
elif key in result and isinstance(result[key], dict) and value is None:
|
|
311
|
+
continue
|
|
312
|
+
else:
|
|
313
|
+
result[key] = copy.deepcopy(value)
|
|
314
|
+
return result
|
|
315
|
+
|
|
316
|
+
def apply_list_patches(result, directive):
|
|
317
|
+
if directive is None:
|
|
318
|
+
return
|
|
319
|
+
if not isinstance(directive, dict) or not isinstance(directive.get("list_patches", {}), dict):
|
|
320
|
+
raise SystemExit(f"{LIST_PATCH_KEY}.list_patches must be a mapping")
|
|
321
|
+
for dotted, rule in directive.get("list_patches", {}).items():
|
|
322
|
+
if not isinstance(dotted, str) or not dotted or not isinstance(rule, dict):
|
|
323
|
+
raise SystemExit("invalid list patch")
|
|
324
|
+
additions = rule.get("add", []) or []
|
|
325
|
+
removals = rule.get("remove", []) or []
|
|
326
|
+
if not isinstance(additions, list) or not isinstance(removals, list) or not all(
|
|
327
|
+
isinstance(item, str) for item in [*additions, *removals]
|
|
328
|
+
):
|
|
329
|
+
raise SystemExit(f"list patch for {dotted} must contain string lists")
|
|
330
|
+
cursor = result
|
|
331
|
+
parts = dotted.split(".")
|
|
332
|
+
for part in parts[:-1]:
|
|
333
|
+
child = cursor.setdefault(part, {})
|
|
334
|
+
if not isinstance(child, dict):
|
|
335
|
+
raise SystemExit(f"list patch parent for {dotted} is not a mapping")
|
|
336
|
+
cursor = child
|
|
337
|
+
current = cursor.get(parts[-1], []) or []
|
|
338
|
+
if not isinstance(current, list):
|
|
339
|
+
raise SystemExit(f"list patch target {dotted} is not a list")
|
|
340
|
+
removed = set(removals)
|
|
341
|
+
merged = [item for item in current if item not in removed]
|
|
342
|
+
for item in additions:
|
|
343
|
+
if item not in merged:
|
|
344
|
+
merged.append(item)
|
|
345
|
+
cursor[parts[-1]] = merged
|
|
346
|
+
|
|
347
|
+
def merge(base, override):
|
|
348
|
+
directive = override.get(LIST_PATCH_KEY)
|
|
349
|
+
ordinary = {key: value for key, value in override.items() if key != LIST_PATCH_KEY}
|
|
350
|
+
result = plain_merge(base, ordinary)
|
|
351
|
+
apply_list_patches(result, directive)
|
|
352
|
+
return result
|
|
353
|
+
|
|
354
|
+
def atomic_write(path, content):
|
|
355
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
356
|
+
fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
357
|
+
try:
|
|
358
|
+
os.fchmod(fd, 0o600)
|
|
359
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
360
|
+
handle.write(content)
|
|
361
|
+
handle.flush()
|
|
362
|
+
os.fsync(handle.fileno())
|
|
363
|
+
if path.is_symlink():
|
|
364
|
+
path.unlink()
|
|
365
|
+
os.replace(temporary, path)
|
|
366
|
+
os.chmod(path, 0o600)
|
|
367
|
+
except BaseException:
|
|
368
|
+
try: os.unlink(temporary)
|
|
369
|
+
except FileNotFoundError: pass
|
|
370
|
+
raise
|
|
371
|
+
|
|
372
|
+
base = load(base_path)
|
|
373
|
+
if delta_path.exists():
|
|
374
|
+
delta = load(delta_path)
|
|
375
|
+
else:
|
|
376
|
+
delta = {}
|
|
377
|
+
if mode == "secret-ref":
|
|
378
|
+
if len(args) != 3:
|
|
379
|
+
raise SystemExit("secret-ref requires an environment name and reference")
|
|
380
|
+
name, value = args[1:]
|
|
381
|
+
if not re.fullmatch(r"[A-Z][A-Z0-9_]*", name):
|
|
382
|
+
raise SystemExit("invalid secret environment variable name")
|
|
383
|
+
if not value.startswith("op://") or any(ch in value for ch in "\r\n\0"):
|
|
384
|
+
raise SystemExit("invalid 1Password reference")
|
|
385
|
+
onepassword = delta.setdefault("secrets", {}).setdefault("onepassword", {})
|
|
386
|
+
if not isinstance(onepassword, dict):
|
|
387
|
+
raise SystemExit("secrets.onepassword delta must be a mapping")
|
|
388
|
+
onepassword["enabled"] = True
|
|
389
|
+
env = onepassword.setdefault("env", {})
|
|
390
|
+
if not isinstance(env, dict):
|
|
391
|
+
raise SystemExit("secrets.onepassword.env delta must be a mapping")
|
|
392
|
+
env[name] = value
|
|
393
|
+
elif mode == "secret-ref-pair":
|
|
394
|
+
if len(args) != 5:
|
|
395
|
+
raise SystemExit("secret-ref-pair requires two environment/reference pairs")
|
|
396
|
+
name_one, value_one, name_two, value_two = args[1:]
|
|
397
|
+
pairs = ((name_one, value_one), (name_two, value_two))
|
|
398
|
+
if name_one == name_two:
|
|
399
|
+
raise SystemExit("secret-ref-pair environment names must be distinct")
|
|
400
|
+
for env_name, reference in pairs:
|
|
401
|
+
if not re.fullmatch(r"[A-Z][A-Z0-9_]*", env_name):
|
|
402
|
+
raise SystemExit("invalid secret environment variable name")
|
|
403
|
+
if not reference.startswith("op://") or any(ch in reference for ch in "\r\n\0"):
|
|
404
|
+
raise SystemExit("invalid 1Password reference")
|
|
405
|
+
onepassword = delta.setdefault("secrets", {}).setdefault("onepassword", {})
|
|
406
|
+
if not isinstance(onepassword, dict):
|
|
407
|
+
raise SystemExit("secrets.onepassword delta must be a mapping")
|
|
408
|
+
onepassword["enabled"] = True
|
|
409
|
+
env = onepassword.setdefault("env", {})
|
|
410
|
+
if not isinstance(env, dict):
|
|
411
|
+
raise SystemExit("secrets.onepassword.env delta must be a mapping")
|
|
412
|
+
# Both refs enter the same in-memory document and one atomic delta write.
|
|
413
|
+
for env_name, reference in pairs:
|
|
414
|
+
env[env_name] = reference
|
|
415
|
+
elif mode == "channel-enabled":
|
|
416
|
+
if len(args) != 3:
|
|
417
|
+
raise SystemExit("channel-enabled requires a channel and boolean")
|
|
418
|
+
name, value = args[1:]
|
|
419
|
+
if name not in {"telegram", "slack"}:
|
|
420
|
+
raise SystemExit("unsupported channel enablement key")
|
|
421
|
+
if value not in {"true", "false"}:
|
|
422
|
+
raise SystemExit("channel enablement must be true or false")
|
|
423
|
+
platforms = delta.setdefault("platforms", {})
|
|
424
|
+
if not isinstance(platforms, dict):
|
|
425
|
+
raise SystemExit("platforms delta must be a mapping")
|
|
426
|
+
channel = platforms.setdefault(name, {})
|
|
427
|
+
if not isinstance(channel, dict):
|
|
428
|
+
raise SystemExit(f"platforms.{name} delta must be a mapping")
|
|
429
|
+
channel["enabled"] = value == "true"
|
|
430
|
+
else:
|
|
431
|
+
raise SystemExit("unsupported profile config delta update")
|
|
432
|
+
existing_comments = []
|
|
433
|
+
if delta_path.is_file() and not delta_path.is_symlink():
|
|
434
|
+
for line in delta_path.read_text(encoding="utf-8").splitlines():
|
|
435
|
+
if line.lstrip().startswith("#") and line not in existing_comments:
|
|
436
|
+
existing_comments.append(line)
|
|
437
|
+
standard_comments = [
|
|
438
|
+
"# Override-only delta for this Hermes profile.",
|
|
439
|
+
"# Contains configuration and secret references only; secret values remain in 1Password.",
|
|
440
|
+
]
|
|
441
|
+
comments = [*standard_comments, *(line for line in existing_comments if line not in standard_comments)]
|
|
442
|
+
atomic_write(delta_path, "\n".join(comments) + "\n" + yaml.safe_dump(delta, sort_keys=False))
|
|
443
|
+
header = (
|
|
444
|
+
"# GENERATED FILE -- DO NOT EDIT.\n"
|
|
445
|
+
"# source: fleet config.yaml + profile config.delta.yaml\n"
|
|
446
|
+
)
|
|
447
|
+
atomic_write(generated_path, header + yaml.safe_dump(merge(base, delta), sort_keys=False))
|
|
448
|
+
PYEOF
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
profile_voice_contract_set() {
|
|
452
|
+
# profile_voice_contract_set PROFILE_HOME PLUGIN VOICE
|
|
453
|
+
profile_config_delta_update "$1" voice "$2" "$3"
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
profile_root_require_real() {
|
|
457
|
+
# profile_root_require_real PROFILE_HOME
|
|
458
|
+
[[ ! -L "$1" ]] \
|
|
459
|
+
|| die "refusing symlinked profile root: $1; run the pjangler Hermes runtime-singleton migration before provisioning channels"
|
|
460
|
+
[[ -d "$1" ]] || die "required profile root is unavailable: $1"
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
profile_onepassword_ref_exists() {
|
|
464
|
+
# profile_onepassword_ref_exists PROFILE_HOME ENV_NAME
|
|
465
|
+
# This is a read-only service eligibility probe. Channel transactions do not
|
|
466
|
+
# use it: their refs and identity are captured under registry -> profile lock.
|
|
467
|
+
python3 - "$1/config.delta.yaml" "$2" <<'PYEOF'
|
|
468
|
+
import pathlib, sys
|
|
469
|
+
try:
|
|
470
|
+
import yaml
|
|
471
|
+
except ImportError:
|
|
472
|
+
raise SystemExit(1)
|
|
473
|
+
path = pathlib.Path(sys.argv[1])
|
|
474
|
+
if not path.is_file() or path.is_symlink():
|
|
475
|
+
raise SystemExit(1)
|
|
476
|
+
try:
|
|
477
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
478
|
+
ref = data["secrets"]["onepassword"]["env"].get(sys.argv[2], "")
|
|
479
|
+
except (KeyError, TypeError, ValueError, yaml.YAMLError):
|
|
480
|
+
raise SystemExit(1)
|
|
481
|
+
raise SystemExit(0 if isinstance(ref, str) and ref.startswith("op://") else 1)
|
|
482
|
+
PYEOF
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
profile_onepassword_ref_get() {
|
|
486
|
+
# profile_onepassword_ref_get PROFILE_HOME ENV_NAME
|
|
487
|
+
python3 - "$1/config.delta.yaml" "$2" <<'PYEOF'
|
|
488
|
+
import pathlib, sys
|
|
489
|
+
try:
|
|
490
|
+
import yaml
|
|
491
|
+
data = yaml.safe_load(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")) or {}
|
|
492
|
+
reference = data["secrets"]["onepassword"]["env"].get(sys.argv[2], "")
|
|
493
|
+
except Exception:
|
|
494
|
+
reference = ""
|
|
495
|
+
if not isinstance(reference, str) or not reference.startswith("op://"):
|
|
496
|
+
raise SystemExit(1)
|
|
497
|
+
print(reference)
|
|
498
|
+
PYEOF
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
profile_onepassword_ref_validate() {
|
|
502
|
+
# profile_onepassword_ref_validate PROFILE_HOME ENV_NAME
|
|
503
|
+
# Return 0 when the reference resolves, 2 when no syntactically valid
|
|
504
|
+
# mapping exists, and 75 when a valid mapping cannot currently be checked.
|
|
505
|
+
# This is a read-only service eligibility probe, never a transaction input.
|
|
506
|
+
local reference helper="$ROLE_DIR/.scripts/store-onepassword-secret.py"
|
|
507
|
+
[[ -f "$helper" ]] || return 75
|
|
508
|
+
reference="$(profile_onepassword_ref_get "$1" "$2")" || return 2
|
|
509
|
+
if python3 -I "$helper" --validate-reference "$reference" >/dev/null 2>&1; then
|
|
510
|
+
return 0
|
|
511
|
+
fi
|
|
512
|
+
return 75
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
channel_transaction_telegram() {
|
|
516
|
+
# channel_transaction_telegram PROFILE_HOME RUNTIME_ENV REF USERNAME BOT_ID ALLOWED
|
|
517
|
+
local helper="$ROLE_DIR/.scripts/channel-transaction.py"
|
|
518
|
+
[[ -f "$helper" && ! -L "$helper" ]] || die "trusted channel transaction helper is missing"
|
|
519
|
+
[[ -f "$ROLE_DIR/.scripts/lib/profile-config-lock.py" \
|
|
520
|
+
&& ! -L "$ROLE_DIR/.scripts/lib/profile-config-lock.py" ]] \
|
|
521
|
+
|| die "trusted PM profile config lock helper is unavailable"
|
|
522
|
+
python3 -I "$helper" \
|
|
523
|
+
--channel telegram \
|
|
524
|
+
--profile "$1" \
|
|
525
|
+
--role-yaml "$ROLE_YAML" \
|
|
526
|
+
--registry "$REGISTRY_FILE" \
|
|
527
|
+
--runtime-env "$2" \
|
|
528
|
+
--done-marker "$ROLE_DIR/.scripts/.done-30-telegram" \
|
|
529
|
+
--agent-id "$AGENT_ID" \
|
|
530
|
+
--role-dir "$ROLE_DIR" \
|
|
531
|
+
--profile-name "$PROFILE_NAME" \
|
|
532
|
+
--allowed-value "$6" \
|
|
533
|
+
--reference TELEGRAM_BOT_TOKEN "$3" \
|
|
534
|
+
--metadata provisioning_status verified \
|
|
535
|
+
--metadata bot_username "$4" \
|
|
536
|
+
--metadata bot_id "$5"
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
channel_transaction_telegram_existing() {
|
|
540
|
+
# channel_transaction_telegram_existing PROFILE_HOME RUNTIME_ENV
|
|
541
|
+
# Caller holds the fleet registry lock; the helper acquires the profile lock
|
|
542
|
+
# and reads refs plus role metadata only after both locks are held.
|
|
543
|
+
local helper="$ROLE_DIR/.scripts/channel-transaction.py"
|
|
544
|
+
local validator="$ROLE_DIR/.scripts/store-onepassword-secret.py"
|
|
545
|
+
[[ -f "$helper" && ! -L "$helper" ]] \
|
|
546
|
+
|| die "trusted channel transaction helper is missing"
|
|
547
|
+
[[ -f "$validator" && ! -L "$validator" ]] \
|
|
548
|
+
|| die "trusted 1Password reference validator is missing"
|
|
549
|
+
[[ -f "$ROLE_DIR/.scripts/lib/profile-config-lock.py" \
|
|
550
|
+
&& ! -L "$ROLE_DIR/.scripts/lib/profile-config-lock.py" ]] \
|
|
551
|
+
|| die "trusted PM profile config lock helper is unavailable"
|
|
552
|
+
python3 -I "$helper" \
|
|
553
|
+
--channel telegram \
|
|
554
|
+
--profile "$1" \
|
|
555
|
+
--role-yaml "$ROLE_YAML" \
|
|
556
|
+
--registry "$REGISTRY_FILE" \
|
|
557
|
+
--runtime-env "$2" \
|
|
558
|
+
--done-marker "$ROLE_DIR/.scripts/.done-30-telegram" \
|
|
559
|
+
--agent-id "$AGENT_ID" \
|
|
560
|
+
--role-dir "$ROLE_DIR" \
|
|
561
|
+
--profile-name "$PROFILE_NAME" \
|
|
562
|
+
--reconcile-existing \
|
|
563
|
+
--reference-validator "$validator"
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
channel_transaction_telegram_prepare_unconfigured() {
|
|
567
|
+
# Caller holds the fleet registry lock. The helper disables only a channel
|
|
568
|
+
# whose locked role snapshot is not verified; exit 3 means verified/no-op.
|
|
569
|
+
local helper="$ROLE_DIR/.scripts/channel-transaction.py"
|
|
570
|
+
[[ -f "$helper" && ! -L "$helper" ]] \
|
|
571
|
+
|| die "trusted channel transaction helper is missing"
|
|
572
|
+
[[ -f "$ROLE_DIR/.scripts/lib/profile-config-lock.py" \
|
|
573
|
+
&& ! -L "$ROLE_DIR/.scripts/lib/profile-config-lock.py" ]] \
|
|
574
|
+
|| die "trusted PM profile config lock helper is unavailable"
|
|
575
|
+
python3 -I "$helper" \
|
|
576
|
+
--channel telegram \
|
|
577
|
+
--profile "$1" \
|
|
578
|
+
--role-yaml "$ROLE_YAML" \
|
|
579
|
+
--registry "$REGISTRY_FILE" \
|
|
580
|
+
--runtime-env "$2" \
|
|
581
|
+
--done-marker "$ROLE_DIR/.scripts/.done-30-telegram" \
|
|
582
|
+
--agent-id "$AGENT_ID" \
|
|
583
|
+
--role-dir "$ROLE_DIR" \
|
|
584
|
+
--profile-name "$PROFILE_NAME" \
|
|
585
|
+
--prepare-unconfigured
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
channel_transaction_slack() {
|
|
589
|
+
# channel_transaction_slack PROFILE ENV BOT_REF APP_REF TEAM_ID TEAM_NAME USER_ID BOT_ID USERNAME ALLOWED
|
|
590
|
+
local helper="$ROLE_DIR/.scripts/channel-transaction.py"
|
|
591
|
+
[[ -f "$helper" && ! -L "$helper" ]] || die "trusted channel transaction helper is missing"
|
|
592
|
+
[[ -f "$ROLE_DIR/.scripts/lib/profile-config-lock.py" \
|
|
593
|
+
&& ! -L "$ROLE_DIR/.scripts/lib/profile-config-lock.py" ]] \
|
|
594
|
+
|| die "trusted PM profile config lock helper is unavailable"
|
|
595
|
+
python3 -I "$helper" \
|
|
596
|
+
--channel slack \
|
|
597
|
+
--profile "$1" \
|
|
598
|
+
--role-yaml "$ROLE_YAML" \
|
|
599
|
+
--registry "$REGISTRY_FILE" \
|
|
600
|
+
--runtime-env "$2" \
|
|
601
|
+
--done-marker "$ROLE_DIR/.scripts/.done-31-slack" \
|
|
602
|
+
--agent-id "$AGENT_ID" \
|
|
603
|
+
--role-dir "$ROLE_DIR" \
|
|
604
|
+
--profile-name "$PROFILE_NAME" \
|
|
605
|
+
--allowed-value "${10}" \
|
|
606
|
+
--reference SLACK_BOT_TOKEN "$3" \
|
|
607
|
+
--reference SLACK_APP_TOKEN "$4" \
|
|
608
|
+
--metadata provisioning_status verified \
|
|
609
|
+
--metadata team_id "$5" \
|
|
610
|
+
--metadata team_name "$6" \
|
|
611
|
+
--metadata bot_user_id "$7" \
|
|
612
|
+
--metadata bot_id "$8" \
|
|
613
|
+
--metadata bot_username "$9"
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
channel_transaction_slack_existing() {
|
|
617
|
+
# channel_transaction_slack_existing PROFILE_HOME RUNTIME_ENV
|
|
618
|
+
# Caller holds the fleet registry lock; the helper acquires the profile lock
|
|
619
|
+
# and reads refs plus role metadata only after both locks are held.
|
|
620
|
+
local helper="$ROLE_DIR/.scripts/channel-transaction.py"
|
|
621
|
+
local validator="$ROLE_DIR/.scripts/store-onepassword-secret.py"
|
|
622
|
+
[[ -f "$helper" && ! -L "$helper" ]] \
|
|
623
|
+
|| die "trusted channel transaction helper is missing"
|
|
624
|
+
[[ -f "$validator" && ! -L "$validator" ]] \
|
|
625
|
+
|| die "trusted 1Password reference validator is missing"
|
|
626
|
+
[[ -f "$ROLE_DIR/.scripts/lib/profile-config-lock.py" \
|
|
627
|
+
&& ! -L "$ROLE_DIR/.scripts/lib/profile-config-lock.py" ]] \
|
|
628
|
+
|| die "trusted PM profile config lock helper is unavailable"
|
|
629
|
+
python3 -I "$helper" \
|
|
630
|
+
--channel slack \
|
|
631
|
+
--profile "$1" \
|
|
632
|
+
--role-yaml "$ROLE_YAML" \
|
|
633
|
+
--registry "$REGISTRY_FILE" \
|
|
634
|
+
--runtime-env "$2" \
|
|
635
|
+
--done-marker "$ROLE_DIR/.scripts/.done-31-slack" \
|
|
636
|
+
--agent-id "$AGENT_ID" \
|
|
637
|
+
--role-dir "$ROLE_DIR" \
|
|
638
|
+
--profile-name "$PROFILE_NAME" \
|
|
639
|
+
--reconcile-existing \
|
|
640
|
+
--reference-validator "$validator"
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
channel_transaction_slack_prepare_unconfigured() {
|
|
644
|
+
# Caller holds the fleet registry lock. The helper disables only a channel
|
|
645
|
+
# whose locked role snapshot is not verified; exit 3 means verified/no-op.
|
|
646
|
+
local helper="$ROLE_DIR/.scripts/channel-transaction.py"
|
|
647
|
+
[[ -f "$helper" && ! -L "$helper" ]] \
|
|
648
|
+
|| die "trusted channel transaction helper is missing"
|
|
649
|
+
[[ -f "$ROLE_DIR/.scripts/lib/profile-config-lock.py" \
|
|
650
|
+
&& ! -L "$ROLE_DIR/.scripts/lib/profile-config-lock.py" ]] \
|
|
651
|
+
|| die "trusted PM profile config lock helper is unavailable"
|
|
652
|
+
python3 -I "$helper" \
|
|
653
|
+
--channel slack \
|
|
654
|
+
--profile "$1" \
|
|
655
|
+
--role-yaml "$ROLE_YAML" \
|
|
656
|
+
--registry "$REGISTRY_FILE" \
|
|
657
|
+
--runtime-env "$2" \
|
|
658
|
+
--done-marker "$ROLE_DIR/.scripts/.done-31-slack" \
|
|
659
|
+
--agent-id "$AGENT_ID" \
|
|
660
|
+
--role-dir "$ROLE_DIR" \
|
|
661
|
+
--profile-name "$PROFILE_NAME" \
|
|
662
|
+
--prepare-unconfigured
|
|
663
|
+
}
|
|
664
|
+
|
|
109
665
|
# ─── Distributable config (~/.config/hermes-agent-template/config.toml) ──────
|
|
110
666
|
# Single source of truth for environment-specific defaults so this template can
|
|
111
667
|
# be handed to someone else without editing any script. Ship config.example.toml
|
|
@@ -265,6 +821,34 @@ fleet_lock_acquire() {
|
|
|
265
821
|
[[ ! -L "$lock_file" ]] || die "refusing fleet lock symlink: $lock_file"
|
|
266
822
|
exec {FLEET_LOCK_FD}>"$lock_file"
|
|
267
823
|
chmod 600 "$lock_file"
|
|
824
|
+
if [[ -n "${PJANGLER_TEST_FLEET_LOCK_BARRIER:-}" ]]; then
|
|
825
|
+
[[ -n "${PYTEST_CURRENT_TEST:-}" ]] \
|
|
826
|
+
|| die "PJANGLER_TEST_FLEET_LOCK_BARRIER is test-only and requires pytest"
|
|
827
|
+
local barrier_file="$PJANGLER_TEST_FLEET_LOCK_BARRIER"
|
|
828
|
+
local barrier_parent="${barrier_file%/*}"
|
|
829
|
+
local barrier_timeout="${PJANGLER_TEST_FLEET_LOCK_BARRIER_TIMEOUT_SECONDS:-15}"
|
|
830
|
+
[[ "$barrier_parent" != "$barrier_file" ]] || barrier_parent="."
|
|
831
|
+
[[ ! -L "$barrier_file" && ! -L "$barrier_parent" && -d "$barrier_parent" ]] \
|
|
832
|
+
|| die "unsafe test fleet-lock barrier path: $barrier_file"
|
|
833
|
+
[[ "$barrier_timeout" =~ ^[1-9][0-9]*$ ]] \
|
|
834
|
+
|| die "PJANGLER_TEST_FLEET_LOCK_BARRIER_TIMEOUT_SECONDS must be a positive integer"
|
|
835
|
+
printf '%s\n' 'fleet-prelock' > "${barrier_file}.ready"
|
|
836
|
+
local barrier_started=$SECONDS
|
|
837
|
+
while [[ ! -f "${barrier_file}.resume" ]]; do
|
|
838
|
+
(( SECONDS - barrier_started < barrier_timeout )) \
|
|
839
|
+
|| die "timed out waiting at test fleet-lock barrier: $barrier_file"
|
|
840
|
+
sleep 0.02
|
|
841
|
+
done
|
|
842
|
+
fi
|
|
843
|
+
if [[ -n "${PJANGLER_TEST_FLEET_LOCK_ATTEMPT:-}" ]]; then
|
|
844
|
+
[[ -n "${PYTEST_CURRENT_TEST:-}" ]] \
|
|
845
|
+
|| die "PJANGLER_TEST_FLEET_LOCK_ATTEMPT is test-only and requires pytest"
|
|
846
|
+
local attempt_file="$PJANGLER_TEST_FLEET_LOCK_ATTEMPT"
|
|
847
|
+
[[ ! -L "$attempt_file" && ! -L "$(dirname "$attempt_file")" \
|
|
848
|
+
&& -d "$(dirname "$attempt_file")" ]] \
|
|
849
|
+
|| die "unsafe test fleet-lock attempt path: $attempt_file"
|
|
850
|
+
printf '%s\n' "$lock_file" > "$attempt_file"
|
|
851
|
+
fi
|
|
268
852
|
flock -w "${FLEET_LOCK_TIMEOUT_SECONDS:-30}" "$FLEET_LOCK_FD" \
|
|
269
853
|
|| die "timed out waiting for fleet registry lock: $lock_file"
|
|
270
854
|
}
|
|
@@ -339,6 +923,195 @@ systemctl_user_unit_state() {
|
|
|
339
923
|
esac
|
|
340
924
|
}
|
|
341
925
|
|
|
926
|
+
# Read-only live health checks for the unit states lifecycle scripts persist in
|
|
927
|
+
# role.yaml. They deliberately include process result, main exit status,
|
|
928
|
+
# restart count, and activation/substate; `is-active` alone can briefly report
|
|
929
|
+
# success for a launcher that is already on its way to exit 78.
|
|
930
|
+
systemctl_user_show() {
|
|
931
|
+
# systemctl_user_show UNIT PROPERTY...
|
|
932
|
+
local unit="$1" output rc first_line
|
|
933
|
+
shift
|
|
934
|
+
local -a arguments=(--user show "$unit" --no-pager)
|
|
935
|
+
local property
|
|
936
|
+
for property in "$@"; do arguments+=("--property=$property"); done
|
|
937
|
+
set +e
|
|
938
|
+
output="$(LC_ALL=C systemctl "${arguments[@]}" 2>&1)"
|
|
939
|
+
rc=$?
|
|
940
|
+
set -e
|
|
941
|
+
if [[ $rc -ne 0 ]]; then
|
|
942
|
+
first_line="${output%%$'\n'*}"
|
|
943
|
+
first_line="${first_line//$'\t'/ }"
|
|
944
|
+
first_line="${first_line//|/}"
|
|
945
|
+
[[ -n "$first_line" ]] || first_line="exit $rc with no state"
|
|
946
|
+
printf 'error|%s' "$first_line"
|
|
947
|
+
return 0
|
|
948
|
+
fi
|
|
949
|
+
printf 'ok|%s' "$output"
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
systemd_service_health_snapshot() {
|
|
953
|
+
# systemd_service_health_snapshot UNIT running|oneshot
|
|
954
|
+
local unit="$1" kind="$2" active enabled shown payload
|
|
955
|
+
local load_state="" active_state="" sub_state="" result=""
|
|
956
|
+
local exec_status="" restarts="" key value
|
|
957
|
+
active="$(systemctl_user_unit_state is-active "$unit")"
|
|
958
|
+
enabled="$(systemctl_user_unit_state is-enabled "$unit")"
|
|
959
|
+
[[ "$active" == "ok|active" ]] \
|
|
960
|
+
|| { printf 'error|activity=%s' "${active#*|}"; return 0; }
|
|
961
|
+
[[ "$enabled" =~ ^ok\|(enabled|enabled-runtime)$ ]] \
|
|
962
|
+
|| { printf 'error|enablement=%s' "${enabled#*|}"; return 0; }
|
|
963
|
+
shown="$(systemctl_user_show "$unit" LoadState ActiveState SubState Result ExecMainStatus NRestarts)"
|
|
964
|
+
[[ "$shown" == ok\|* ]] || { printf '%s' "$shown"; return 0; }
|
|
965
|
+
payload="${shown#ok|}"
|
|
966
|
+
while IFS='=' read -r key value; do
|
|
967
|
+
case "$key" in
|
|
968
|
+
LoadState) load_state="$value" ;;
|
|
969
|
+
ActiveState) active_state="$value" ;;
|
|
970
|
+
SubState) sub_state="$value" ;;
|
|
971
|
+
Result) result="$value" ;;
|
|
972
|
+
ExecMainStatus) exec_status="$value" ;;
|
|
973
|
+
NRestarts) restarts="$value" ;;
|
|
974
|
+
esac
|
|
975
|
+
done <<< "$payload"
|
|
976
|
+
[[ "$load_state" == loaded && "$active_state" == active \
|
|
977
|
+
&& "$result" == success && "$exec_status" == 0 \
|
|
978
|
+
&& "$restarts" =~ ^[0-9]+$ ]] \
|
|
979
|
+
|| { printf 'error|load=%s active=%s sub=%s result=%s status=%s restarts=%s' \
|
|
980
|
+
"$load_state" "$active_state" "$sub_state" "$result" "$exec_status" "$restarts"; return 0; }
|
|
981
|
+
if [[ "$kind" == running && "$sub_state" != running ]]; then
|
|
982
|
+
printf 'error|substate=%s' "$sub_state"
|
|
983
|
+
return 0
|
|
984
|
+
fi
|
|
985
|
+
printf 'ok|%s' "$restarts"
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
systemd_timer_health_snapshot() {
|
|
989
|
+
# systemd_timer_health_snapshot TIMER HEARTBEAT_SERVICE
|
|
990
|
+
local timer="$1" service="$2" active enabled shown payload
|
|
991
|
+
local load_state="" active_state="" sub_state="" key value
|
|
992
|
+
local svc_shown svc_payload svc_load="" svc_active="" svc_sub=""
|
|
993
|
+
local svc_result="" svc_status="" svc_restarts=""
|
|
994
|
+
local svc_started="" svc_exited=""
|
|
995
|
+
active="$(systemctl_user_unit_state is-active "$timer")"
|
|
996
|
+
enabled="$(systemctl_user_unit_state is-enabled "$timer")"
|
|
997
|
+
[[ "$active" == "ok|active" ]] \
|
|
998
|
+
|| { printf 'error|timer-activity=%s' "${active#*|}"; return 0; }
|
|
999
|
+
[[ "$enabled" =~ ^ok\|(enabled|enabled-runtime)$ ]] \
|
|
1000
|
+
|| { printf 'error|timer-enablement=%s' "${enabled#*|}"; return 0; }
|
|
1001
|
+
shown="$(systemctl_user_show "$timer" LoadState ActiveState SubState)"
|
|
1002
|
+
[[ "$shown" == ok\|* ]] || { printf '%s' "$shown"; return 0; }
|
|
1003
|
+
payload="${shown#ok|}"
|
|
1004
|
+
while IFS='=' read -r key value; do
|
|
1005
|
+
case "$key" in
|
|
1006
|
+
LoadState) load_state="$value" ;;
|
|
1007
|
+
ActiveState) active_state="$value" ;;
|
|
1008
|
+
SubState) sub_state="$value" ;;
|
|
1009
|
+
esac
|
|
1010
|
+
done <<< "$payload"
|
|
1011
|
+
[[ "$load_state" == loaded && "$active_state" == active \
|
|
1012
|
+
&& "$sub_state" =~ ^(waiting|running|elapsed)$ ]] \
|
|
1013
|
+
|| { printf 'error|timer-load=%s active=%s sub=%s' "$load_state" "$active_state" "$sub_state"; return 0; }
|
|
1014
|
+
|
|
1015
|
+
# Inspect the latest oneshot result separately from the timer. An inactive
|
|
1016
|
+
# (dead) service with Result=success/ExecMainStatus=0 is the healthy steady
|
|
1017
|
+
# state between ticks; failed/78 is never accepted.
|
|
1018
|
+
svc_shown="$(systemctl_user_show "$service" LoadState ActiveState SubState Result \
|
|
1019
|
+
ExecMainStatus NRestarts ExecMainStartTimestampMonotonic \
|
|
1020
|
+
ExecMainExitTimestampMonotonic)"
|
|
1021
|
+
[[ "$svc_shown" == ok\|* ]] || { printf '%s' "$svc_shown"; return 0; }
|
|
1022
|
+
svc_payload="${svc_shown#ok|}"
|
|
1023
|
+
while IFS='=' read -r key value; do
|
|
1024
|
+
case "$key" in
|
|
1025
|
+
LoadState) svc_load="$value" ;;
|
|
1026
|
+
ActiveState) svc_active="$value" ;;
|
|
1027
|
+
SubState) svc_sub="$value" ;;
|
|
1028
|
+
Result) svc_result="$value" ;;
|
|
1029
|
+
ExecMainStatus) svc_status="$value" ;;
|
|
1030
|
+
NRestarts) svc_restarts="$value" ;;
|
|
1031
|
+
ExecMainStartTimestampMonotonic) svc_started="$value" ;;
|
|
1032
|
+
ExecMainExitTimestampMonotonic) svc_exited="$value" ;;
|
|
1033
|
+
esac
|
|
1034
|
+
done <<< "$svc_payload"
|
|
1035
|
+
# A oneshot is healthy only after its main process has exited successfully.
|
|
1036
|
+
# systemd initializes Result=success/ExecMainStatus=0 before the first exit,
|
|
1037
|
+
# so accepting activating/start would turn a pre-exit sample into a false
|
|
1038
|
+
# completion claim. Monotonic start/exit timestamps prove a real invocation
|
|
1039
|
+
# completed and also make a new invocation visible to the stability window.
|
|
1040
|
+
[[ "$svc_load" == loaded && "$svc_active" == inactive \
|
|
1041
|
+
&& "$svc_sub" == dead \
|
|
1042
|
+
&& "$svc_result" == success && "$svc_status" == 0 \
|
|
1043
|
+
&& "$svc_restarts" =~ ^[0-9]+$ \
|
|
1044
|
+
&& "$svc_started" =~ ^[1-9][0-9]*$ \
|
|
1045
|
+
&& "$svc_exited" =~ ^[1-9][0-9]*$ \
|
|
1046
|
+
&& "$svc_exited" -ge "$svc_started" ]] \
|
|
1047
|
+
|| { printf 'error|heartbeat-load=%s active=%s sub=%s result=%s status=%s restarts=%s started=%s exited=%s' \
|
|
1048
|
+
"$svc_load" "$svc_active" "$svc_sub" "$svc_result" "$svc_status" \
|
|
1049
|
+
"$svc_restarts" "$svc_started" "$svc_exited"; return 0; }
|
|
1050
|
+
printf 'ok|timer=%s:result=%s:status=%s:restarts=%s:started=%s:exited=%s' \
|
|
1051
|
+
"$sub_state" "$svc_result" "$svc_status" "$svc_restarts" "$svc_started" "$svc_exited"
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
systemd_gateway_deferred_snapshot() {
|
|
1055
|
+
local unit="$1" active enabled
|
|
1056
|
+
active="$(systemctl_user_unit_state is-active "$unit")"
|
|
1057
|
+
enabled="$(systemctl_user_unit_state is-enabled "$unit")"
|
|
1058
|
+
if [[ "$active" == "ok|inactive" \
|
|
1059
|
+
&& "$enabled" =~ ^ok\|(disabled|masked|masked-runtime)$ ]]; then
|
|
1060
|
+
printf 'ok|deferred'
|
|
1061
|
+
else
|
|
1062
|
+
printf 'error|enablement=%s activity=%s' "${enabled#*|}" "${active#*|}"
|
|
1063
|
+
fi
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
systemd_wait_for_stable_health() {
|
|
1067
|
+
# systemd_wait_for_stable_health CHECK_FUNCTION ARGS...
|
|
1068
|
+
local checker="$1"
|
|
1069
|
+
shift
|
|
1070
|
+
local attempts="${SYSTEMD_STABILIZATION_ATTEMPTS:-6}"
|
|
1071
|
+
local required="${SYSTEMD_STABLE_SAMPLES:-3}"
|
|
1072
|
+
local interval="${SYSTEMD_STABILIZATION_INTERVAL_SECONDS:-1}"
|
|
1073
|
+
[[ "$attempts" =~ ^[1-9][0-9]*$ && "$required" =~ ^[1-9][0-9]*$ \
|
|
1074
|
+
&& "$interval" =~ ^[0-9]+([.][0-9]+)?$ ]] \
|
|
1075
|
+
|| { printf 'error|invalid stabilization settings'; return 1; }
|
|
1076
|
+
(( attempts >= required )) \
|
|
1077
|
+
|| { printf 'error|stabilization attempts must cover required samples'; return 1; }
|
|
1078
|
+
local sample="" previous="" last_error="" stable=0 attempt
|
|
1079
|
+
local ever_healthy=0 unstable_after_health=0
|
|
1080
|
+
for ((attempt = 1; attempt <= attempts; attempt++)); do
|
|
1081
|
+
sample="$($checker "$@")"
|
|
1082
|
+
if [[ "$sample" == ok\|* ]]; then
|
|
1083
|
+
if [[ -z "$previous" ]]; then
|
|
1084
|
+
previous="$sample"
|
|
1085
|
+
stable=1
|
|
1086
|
+
elif [[ "$sample" == "$previous" ]]; then
|
|
1087
|
+
stable=$((stable + 1))
|
|
1088
|
+
else
|
|
1089
|
+
(( ever_healthy == 0 )) || unstable_after_health=1
|
|
1090
|
+
previous="$sample"
|
|
1091
|
+
stable=1
|
|
1092
|
+
fi
|
|
1093
|
+
ever_healthy=1
|
|
1094
|
+
else
|
|
1095
|
+
[[ -z "$sample" ]] || last_error="$sample"
|
|
1096
|
+
(( ever_healthy == 0 )) || unstable_after_health=1
|
|
1097
|
+
previous=""
|
|
1098
|
+
stable=0
|
|
1099
|
+
fi
|
|
1100
|
+
if (( attempt < attempts )) && [[ "$interval" != 0 ]]; then
|
|
1101
|
+
sleep "$interval"
|
|
1102
|
+
fi
|
|
1103
|
+
done
|
|
1104
|
+
# Never return early: every configured sample belongs to the declared
|
|
1105
|
+
# observation window. Once a unit looked healthy, a later failure, restart,
|
|
1106
|
+
# or invocation timestamp change makes the whole window unstable.
|
|
1107
|
+
if (( stable >= required && unstable_after_health == 0 )); then
|
|
1108
|
+
printf '%s' "$sample"
|
|
1109
|
+
return 0
|
|
1110
|
+
fi
|
|
1111
|
+
printf '%s' "${last_error:-${sample:-error|no health sample}}"
|
|
1112
|
+
return 1
|
|
1113
|
+
}
|
|
1114
|
+
|
|
342
1115
|
# Resolve project repo path (the repo that holds agents/hermes/<role>/).
|
|
343
1116
|
# Walk up from $ROLE_DIR until we find a git root that isn't us.
|
|
344
1117
|
project_repo_path() {
|