@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.
Files changed (40) hide show
  1. package/CHANGELOG.md +27 -8
  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/hq-delegate-bundle.sh +307 -0
  5. package/assets/scaffold/core/scripts/hq-delegate-grant.sh +226 -0
  6. package/assets/scaffold/core/scripts/hq-delegate-publish.sh +91 -0
  7. package/assets/scaffold/core/scripts/hq-delegate-send.sh +259 -0
  8. package/assets/scaffold/core/scripts/hq-delegate-verify.sh +213 -0
  9. package/assets/scaffold/core/scripts/hq-job-notify.sh +389 -0
  10. package/assets/scaffold/core/scripts/hq-job-probe.sh +835 -0
  11. package/assets/scaffold/core/scripts/hq-job-remediate.sh +359 -0
  12. package/assets/scaffold/core/scripts/hq-job-run.sh +1025 -0
  13. package/assets/scaffold/core/scripts/outpost-jobs-reconcile.sh +830 -0
  14. package/dist/command-catalog.generated.d.ts +106 -1
  15. package/dist/command-catalog.generated.js +132 -1
  16. package/dist/commands/core-delegate-w1b.d.ts +8 -0
  17. package/dist/commands/core-delegate-w1b.js +1049 -0
  18. package/dist/commands/core-delegate.d.ts +28 -0
  19. package/dist/commands/core-delegate.js +15 -13
  20. package/dist/commands/core-handoff.js +152 -23
  21. package/dist/commands/core-jobs-native.d.ts +15 -0
  22. package/dist/commands/core-jobs-native.js +709 -2
  23. package/dist/commands/core-ontology.d.ts +22 -4
  24. package/dist/commands/core-ontology.js +90 -8
  25. package/dist/commands/core.js +10 -0
  26. package/dist/lib/checkpoint-ontology/drop-aliases.d.ts +41 -0
  27. package/dist/lib/checkpoint-ontology/drop-aliases.js +76 -0
  28. package/dist/lib/checkpoint-ontology/entity-file.d.ts +6 -3
  29. package/dist/lib/checkpoint-ontology/entity-file.js +12 -6
  30. package/dist/lib/checkpoint-ontology/merge-copies.d.ts +27 -8
  31. package/dist/lib/checkpoint-ontology/merge-copies.js +64 -97
  32. package/dist/lib/daemon/flag.d.ts +2 -0
  33. package/dist/lib/daemon/flag.js +2 -0
  34. package/dist/utils/scaffold-sibling-references.js +4 -4
  35. package/dist/utils/self-update.d.ts +6 -0
  36. package/dist/utils/self-update.js +28 -6
  37. package/dist/utils/staged-install.d.ts +34 -0
  38. package/dist/utils/staged-install.js +296 -0
  39. package/package.json +1 -1
  40. package/scripts/mesh-daemon-if-stale.mjs +4 -0
@@ -3,11 +3,14 @@ import * as path from "node:path";
3
3
  import { spawnSync } from "node:child_process";
4
4
  import { JSON_SCHEMA, load, loadAll } from "js-yaml";
5
5
  import { matchingSecretPatterns } from "../lib/core-utils/secret-patterns.js";
6
+ import { serializeJqJson } from "../lib/core-utils/jq-json-serializer.js";
7
+ import { resolveLiveRoot } from "../utils/hq-roots.js";
6
8
  import { runBundledScript } from "../utils/run-bundled-script.js";
7
9
  import { describeCoreCommand } from "./core-command-catalog.js";
8
10
  import { coreNativeJobsEnabled } from "./core-native-jobs-flag.js";
9
11
  const SCHEDULE_PARSE_ASSET = "core/scripts/jobs-schedule-parse.sh";
10
12
  const VALIDATE_ASSET = "core/scripts/jobs-validate.sh";
13
+ const RECONCILE_ASSET = "core/scripts/outpost-jobs-reconcile.sh";
11
14
  const SCHEDULE_PARSE_USAGE = 'usage: jobs-schedule-parse.sh [--tz IANA] "<nl or cron schedule>"\n';
12
15
  const VALIDATE_USAGE = "usage: jobs-validate.sh <file-or-dir> [file-or-dir...]\n";
13
16
  const CRON_RE = /^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)$/;
@@ -579,6 +582,704 @@ export function validateJobs(args) {
579
582
  }
580
583
  return 0;
581
584
  }
585
+ const RECONCILE_USAGE = `outpost-jobs-reconcile.sh — registry YAML → systemd user timers (US-004).
586
+
587
+ Reads personal/jobs/*.yaml and companies/*/jobs/*.yaml from the box HQ tree,
588
+ validates each with jobs-validate.sh, and materializes disposable units:
589
+ ~/.config/systemd/user/hq-job-{id}.service
590
+ ~/.config/systemd/user/hq-job-{id}.timer
591
+ with Persistent=true and RandomizedDelaySec. Timers are armed only when
592
+ hq-pro jobs status readiness=ready (box cache from probe ingest, or live
593
+ GET when available). Disabled/deleted jobs have units removed.
594
+
595
+ Company-scoped jobs materialize only when job.owner matches this Outpost's
596
+ owner identity (creator's box only in v1).
597
+
598
+ Usage:
599
+ core/scripts/outpost-jobs-reconcile.sh [options]
600
+ core/scripts/outpost-jobs-reconcile.sh --after-sync # post-sync entrypoint
601
+
602
+ Options:
603
+ --hq-root DIR HQ tree root (default: HQ_ROOT or repo root above core/)
604
+ --dry-run Write units to unit dir / print plan; skip systemctl
605
+ --no-probe Do not probe non-ready jobs
606
+ --probe-only Probe non-ready jobs then exit (no unit changes)
607
+ --ensure-hook Install personal/hooks/Stop/90-outpost-jobs-reconcile.sh
608
+ --after-sync Alias for default reconcile + probe path (sync Stop hook)
609
+ -h, --help
610
+
611
+ Env / overrides (tests):
612
+ HQ_ROOT, HOME, HQ_JOB_UNIT_DIR, HQ_JOB_SYSTEMCTL, HQ_JOB_STATUS_CACHE_DIR
613
+ HQ_JOB_OWNER_EMAIL, HQ_JOB_OWNER_UID, OUTPOST_USER_ID, OUTPOST_ID
614
+ HQ_JOB_RANDOMIZE_SEC (default 60), HQ_JOB_RECONCILE_STATE
615
+ HQ_PRO_API_URL / HQ_API_URL / HQ_VAULT_API_URL, HQ_ACCESS_TOKEN (optional GET)
616
+
617
+ Exit: 0 success; 1 partial failures; 2 usage/config.
618
+ `;
619
+ function commandResult(command, args) {
620
+ const result = spawnSync(command, args, {
621
+ encoding: "utf8",
622
+ stdio: ["ignore", "pipe", "pipe"],
623
+ });
624
+ return {
625
+ status: result.status ?? (result.error ? 127 : 1),
626
+ stdout: result.stdout ?? "",
627
+ stderr: result.stderr ?? "",
628
+ };
629
+ }
630
+ function isExecutable(file) {
631
+ try {
632
+ fs.accessSync(file, fs.constants.X_OK);
633
+ return fs.statSync(file).isFile();
634
+ }
635
+ catch {
636
+ return false;
637
+ }
638
+ }
639
+ function findExecutable(command, searchPath) {
640
+ if (path.isAbsolute(command) || command.includes(path.sep)) {
641
+ return isExecutable(command) ? command : undefined;
642
+ }
643
+ for (const directory of searchPath.split(path.delimiter)) {
644
+ if (!directory)
645
+ continue;
646
+ const candidate = path.join(directory, command);
647
+ if (isExecutable(candidate))
648
+ return candidate;
649
+ }
650
+ return undefined;
651
+ }
652
+ function shellText(value) {
653
+ if (value === undefined || value === null)
654
+ return "";
655
+ if (typeof value === "string")
656
+ return value;
657
+ if (typeof value === "object")
658
+ return JSON.stringify(value);
659
+ return String(value);
660
+ }
661
+ function parseIdentityAssignments(contents) {
662
+ const values = [];
663
+ for (const line of contents.split(/\r?\n/)) {
664
+ const assignment = /^\s*(?:export\s+)?(OUTPOST_USER_ID|OUTPOST_OWNER_EMAIL|OWNER_EMAIL)\s*=\s*(.*?)\s*$/.exec(line);
665
+ if (!assignment)
666
+ continue;
667
+ let value = assignment[2] ?? "";
668
+ if ((value.startsWith("'") && value.endsWith("'")) || (value.startsWith('"') && value.endsWith('"'))) {
669
+ value = value.slice(1, -1);
670
+ }
671
+ else {
672
+ value = value.replace(/\s+#.*$/, "");
673
+ }
674
+ values.push(value);
675
+ }
676
+ return values;
677
+ }
678
+ function identityFileTokens(options) {
679
+ if (process.env.HQ_JOB_SKIP_SYSTEM_IDENTITY === "1")
680
+ return [];
681
+ const filename = "/etc/outpost/codex-identity.env";
682
+ try {
683
+ const contents = options.identityFileReader
684
+ ? options.identityFileReader(filename)
685
+ : fs.readFileSync(filename, "utf8");
686
+ return contents ? parseIdentityAssignments(contents) : [];
687
+ }
688
+ catch {
689
+ return [];
690
+ }
691
+ }
692
+ function resolveBoxOwners(options) {
693
+ const owners = [];
694
+ const add = (value) => {
695
+ if (!value || value === "null" || owners.includes(value))
696
+ return;
697
+ owners.push(value);
698
+ };
699
+ add(process.env.HQ_JOB_OWNER_EMAIL);
700
+ add(process.env.HQ_JOB_OWNER_UID);
701
+ add(process.env.OUTPOST_USER_ID);
702
+ const searchPath = process.env.PATH ?? "";
703
+ if (findExecutable("hq", searchPath)) {
704
+ const run = options.commandRunner ?? commandResult;
705
+ let result = run("hq", ["whoami", "--json"]);
706
+ if (result.status !== 0)
707
+ result = run("hq", ["whoami"]);
708
+ const who = result.stdout;
709
+ if (who) {
710
+ let parsed;
711
+ try {
712
+ parsed = JSON.parse(who);
713
+ }
714
+ catch {
715
+ parsed = undefined;
716
+ }
717
+ let email = "";
718
+ if (parsed && typeof parsed === "object") {
719
+ const record = parsed;
720
+ const user = record.user && typeof record.user === "object"
721
+ ? record.user
722
+ : undefined;
723
+ email = shellText(record.email ?? user?.email);
724
+ add(email);
725
+ add(shellText(record.personUid ?? record.person_uid ?? record.uid));
726
+ }
727
+ if (!email) {
728
+ const humanEmail = /.*Email:\s*(.*)/i.exec(who)?.[1]
729
+ ?? /.*email\s*[:=]\s*(.*)/i.exec(who)?.[1];
730
+ if (humanEmail)
731
+ add(humanEmail.replace(/\s/g, ""));
732
+ }
733
+ }
734
+ }
735
+ for (const value of identityFileTokens(options))
736
+ add(value);
737
+ return owners;
738
+ }
739
+ function ownerMatches(jobOwner, owners) {
740
+ if (!jobOwner)
741
+ return false;
742
+ return owners.some((owner) => owner === jobOwner
743
+ || (owner.includes("@") && owner.toLowerCase() === jobOwner.toLowerCase()));
744
+ }
745
+ function compareReconcilePaths(left, right) {
746
+ return Buffer.compare(Buffer.from(left), Buffer.from(right));
747
+ }
748
+ function addYamlFilesAtDepth(directory, maxDepth) {
749
+ const result = [];
750
+ const walk = (current, depth) => {
751
+ let entries;
752
+ try {
753
+ entries = fs.readdirSync(current, { withFileTypes: true });
754
+ }
755
+ catch {
756
+ return;
757
+ }
758
+ for (const entry of entries) {
759
+ const file = path.join(current, entry.name);
760
+ if (entry.isFile() && depth <= maxDepth && /\.(yaml|yml)$/.test(entry.name)) {
761
+ result.push(file);
762
+ }
763
+ else if (entry.isDirectory() && depth < maxDepth) {
764
+ walk(file, depth + 1);
765
+ }
766
+ }
767
+ };
768
+ walk(directory, 1);
769
+ return result;
770
+ }
771
+ function collectReconcileJobs(root) {
772
+ const files = addYamlFilesAtDepth(path.join(root, "personal", "jobs"), 1);
773
+ const companies = path.join(root, "companies");
774
+ let companyEntries = [];
775
+ try {
776
+ companyEntries = fs.readdirSync(companies, { withFileTypes: true });
777
+ }
778
+ catch { /* A missing companies tree is empty. */ }
779
+ for (const company of companyEntries) {
780
+ if (!company.isDirectory())
781
+ continue;
782
+ files.push(...addYamlFilesAtDepth(path.join(companies, company.name, "jobs"), 1));
783
+ }
784
+ return files
785
+ .filter((file) => !file.includes(".conflict-") && !file.includes(`${path.sep}.conflicts${path.sep}`))
786
+ .sort(compareReconcilePaths);
787
+ }
788
+ function readJobValue(file) {
789
+ try {
790
+ const parsed = parseJobFile(fs.readFileSync(file, "utf8"));
791
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
792
+ ? parsed
793
+ : {};
794
+ }
795
+ catch {
796
+ return {};
797
+ }
798
+ }
799
+ function reconcileJobIsValid(file) {
800
+ const errors = [];
801
+ try {
802
+ validateJob(file, parseJobFile(fs.readFileSync(file, "utf8")), [], (_path, _field, message) => errors.push(message));
803
+ return errors.length === 0;
804
+ }
805
+ catch {
806
+ return false;
807
+ }
808
+ }
809
+ function parseData(contents) {
810
+ return load(contents, { schema: JSON_SCHEMA, json: true });
811
+ }
812
+ function recordOf(value) {
813
+ return value !== null && typeof value === "object" && !Array.isArray(value)
814
+ ? value
815
+ : undefined;
816
+ }
817
+ function writeReadinessCache(cacheDir, id, readiness, source, options) {
818
+ const filename = path.join(cacheDir, `${id}.json`);
819
+ const updatedAt = (options.now?.() ?? new Date()).toISOString().replace(/\.\d{3}Z$/, "Z");
820
+ fs.writeFileSync(filename, serializeJqJson({ job_id: id, readiness, updated_at: updatedAt, source }, "compact"));
821
+ try {
822
+ fs.chmodSync(filename, 0o600);
823
+ }
824
+ catch { /* The shell ignores chmod failure. */ }
825
+ }
826
+ function cachedReadiness(cacheDir, home, id) {
827
+ try {
828
+ const record = recordOf(parseData(fs.readFileSync(path.join(cacheDir, `${id}.json`), "utf8")));
829
+ const value = record?.readiness;
830
+ if (value !== undefined && value !== null && value !== false)
831
+ return shellText(value);
832
+ if (value === false)
833
+ return "unknown";
834
+ }
835
+ catch { /* The shell falls through to the probe marker. */ }
836
+ try {
837
+ const marker = JSON.parse(fs.readFileSync(path.join(home, ".hq", "jobs", "probes", id, "last-attempt.json"), "utf8"));
838
+ const reported = marker.reported_readiness;
839
+ if (reported !== undefined && reported !== null && reported !== false && reported !== "")
840
+ return shellText(reported);
841
+ }
842
+ catch { /* No successful prior probe marker. */ }
843
+ return "unknown";
844
+ }
845
+ async function liveReadiness(id, cacheDir, options) {
846
+ if (process.env.HQ_JOB_DISABLE_LIVE_READINESS === "1")
847
+ return undefined;
848
+ const token = process.env.HQ_ACCESS_TOKEN;
849
+ if (!token)
850
+ return undefined;
851
+ const api = process.env.HQ_PRO_API_URL || process.env.HQ_API_URL || process.env.HQ_VAULT_API_URL || "https://hqapi.hq.computer";
852
+ const url = `${api.replace(/\/$/, "")}/outpost/jobs/status/${id}`;
853
+ try {
854
+ const response = await (options.fetcher ?? fetch)(url, {
855
+ method: "GET",
856
+ headers: { authorization: `Bearer ${token}`, accept: "application/json" },
857
+ signal: AbortSignal.timeout(15_000),
858
+ });
859
+ if (!response.ok)
860
+ return undefined;
861
+ const body = recordOf(await response.json());
862
+ const status = recordOf(body?.status);
863
+ const readiness = status?.readiness !== undefined && status.readiness !== null && status.readiness !== false
864
+ ? status.readiness
865
+ : body?.readiness;
866
+ if (readiness === undefined || readiness === null || readiness === false || readiness === "")
867
+ return undefined;
868
+ const result = shellText(readiness);
869
+ writeReadinessCache(cacheDir, id, result, "api-get", options);
870
+ return result;
871
+ }
872
+ catch {
873
+ return undefined;
874
+ }
875
+ }
876
+ async function jobReadiness(id, cacheDir, home, options) {
877
+ return await liveReadiness(id, cacheDir, options) ?? cachedReadiness(cacheDir, home, id);
878
+ }
879
+ function probeValue(output, field) {
880
+ try {
881
+ const record = recordOf(JSON.parse(output));
882
+ const value = record?.[field];
883
+ return value === undefined || value === null ? "" : shellText(value);
884
+ }
885
+ catch {
886
+ return "";
887
+ }
888
+ }
889
+ function cronDowNumber(value) {
890
+ const names = { "0": "Sun", "1": "Mon", "2": "Tue", "3": "Wed", "4": "Thu", "5": "Fri", "6": "Sat", "7": "Sun" };
891
+ return names[value];
892
+ }
893
+ function cronDowTokenToSystemd(token) {
894
+ if (/^[0-7]+-[0-7]+$/.test(token)) {
895
+ const [from, to] = token.split("-");
896
+ const start = cronDowNumber(from ?? "");
897
+ const end = cronDowNumber(to ?? "");
898
+ return start && end ? `${start}..${end}` : undefined;
899
+ }
900
+ const lower = token.toLowerCase();
901
+ const names = {
902
+ sun: "Sun", "0": "Sun", "7": "Sun", mon: "Mon", "1": "Mon",
903
+ tue: "Tue", "2": "Tue", wed: "Wed", "3": "Wed", thu: "Thu", "4": "Thu",
904
+ fri: "Fri", "5": "Fri", sat: "Sat", "6": "Sat", "mon..fri": "Mon..Fri", "1-5": "Mon..Fri",
905
+ };
906
+ return names[lower];
907
+ }
908
+ function cronDowToSystemd(field) {
909
+ if (field === "*" || field === "*/1")
910
+ return "";
911
+ const mapped = [];
912
+ for (const part of field.split(/[\s,]+/)) {
913
+ const value = cronDowTokenToSystemd(part);
914
+ if (!value)
915
+ return undefined;
916
+ mapped.push(value);
917
+ }
918
+ return mapped.join(",");
919
+ }
920
+ function padCronField(value) {
921
+ return value.length === 1 ? `0${value}` : value;
922
+ }
923
+ function cronTimeField(field) {
924
+ if (field === "*")
925
+ return "";
926
+ if (field.includes("/"))
927
+ return `*/${field.slice(field.indexOf("/") + 1)}`;
928
+ if (field.includes("-"))
929
+ return field;
930
+ return /^\d+$/.test(field) ? padCronField(field) : field;
931
+ }
932
+ function cronToOnCalendar(fields) {
933
+ const [minute = "", hour = "", dom = "", month = "", dow = ""] = fields;
934
+ const dowValue = cronDowToSystemd(dow) ?? "";
935
+ const monthValue = month === "*" ? "*" : padCronField(month);
936
+ const dayValue = dom === "*" ? "*" : padCronField(dom);
937
+ const date = `*-${monthValue}-${dayValue}`;
938
+ const minuteValue = cronTimeField(minute) || "00";
939
+ const hourValue = cronTimeField(hour) || "*";
940
+ let time;
941
+ if (hourValue === "*")
942
+ time = `*:${minuteValue}:00`;
943
+ else if (hourValue.includes("/"))
944
+ time = `0/${hourValue.slice(hourValue.indexOf("/") + 1)}:${minuteValue}:00`;
945
+ else
946
+ time = `${hourValue}:${minuteValue}:00`;
947
+ return dowValue ? `${dowValue} ${date} ${time}` : `${date} ${time}`;
948
+ }
949
+ function normalizeServicePath(value) {
950
+ const entries = [];
951
+ for (const entry of value.split(":")) {
952
+ if (entry.startsWith("/") && !entries.includes(entry))
953
+ entries.push(entry);
954
+ }
955
+ return entries.join(":");
956
+ }
957
+ function servicePathValue() {
958
+ let servicePath = normalizeServicePath(process.env.PATH || "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin");
959
+ const hq = findExecutable("hq", servicePath);
960
+ if (hq) {
961
+ let binDirectory = "";
962
+ try {
963
+ binDirectory = fs.realpathSync(path.dirname(hq));
964
+ }
965
+ catch { /* `pwd -P` failed in the shell equivalent. */ }
966
+ if (binDirectory && !servicePath.split(":").includes(binDirectory))
967
+ servicePath = [binDirectory, servicePath].filter(Boolean).join(":");
968
+ }
969
+ return servicePath;
970
+ }
971
+ function systemdQuote(value) {
972
+ return `"${value.replace(/%/g, "%%").replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t")}"`;
973
+ }
974
+ function serviceUnit(root, runner, id) {
975
+ const pathValue = systemdQuote(`PATH=${servicePathValue()}`);
976
+ return `# Generated by outpost-jobs-reconcile.sh — disposable derived state. Do not edit.\n[Unit]\nDescription=HQ Outpost scheduled job: ${id}\nDocumentation=file://${root}/core/knowledge/public/hq-core/outpost-jobs-spec.md\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=oneshot\nEnvironment=HQ_ROOT=${root}\nEnvironment=${pathValue}\nExecStart=${runner} --hq-root ${root} --job-id ${id}\nNice=10\n\n[Install]\nWantedBy=default.target\n`;
977
+ }
978
+ function timerUnit(id, onCalendar, randomize, root) {
979
+ return `# Generated by outpost-jobs-reconcile.sh — disposable derived state. Do not edit.\n[Unit]\nDescription=HQ Outpost scheduled job timer: ${id}\nDocumentation=file://${root}/core/knowledge/public/hq-core/outpost-jobs-spec.md\n\n[Timer]\nOnCalendar=${onCalendar}\nPersistent=true\nRandomizedDelaySec=${randomize}\nUnit=hq-job-${id}.service\n\n[Install]\nWantedBy=timers.target\n`;
980
+ }
981
+ function systemctlResult(args, dryRun, command, options) {
982
+ if (dryRun) {
983
+ process.stderr.write(`outpost-jobs-reconcile: dry-run systemctl --user ${args.join(" ")}\n`);
984
+ return { status: 0, stdout: "", stderr: "" };
985
+ }
986
+ if (command === ":" || command === "true")
987
+ return { status: 0, stdout: "", stderr: "" };
988
+ if (!options.schedulerRunner && !findExecutable(command, process.env.PATH ?? "")) {
989
+ process.stderr.write(`outpost-jobs-reconcile: systemctl not available — units written but not enabled (${args.join(" ")})\n`);
990
+ return { status: 0, stdout: "", stderr: "" };
991
+ }
992
+ if (process.env.HQ_JOB_TEST_SCHEDULER_STUB === "1" && !options.schedulerRunner) {
993
+ return { status: 127, stdout: "", stderr: "scheduler stub was requested but no injected runner was supplied" };
994
+ }
995
+ try {
996
+ const runner = options.schedulerRunner ?? commandResult;
997
+ return runner(command, ["--user", ...args]);
998
+ }
999
+ catch (error) {
1000
+ return { status: 127, stdout: "", stderr: error instanceof Error ? error.message : String(error) };
1001
+ }
1002
+ }
1003
+ function installSyncHook(root) {
1004
+ const directory = path.join(root, "personal", "hooks", "Stop");
1005
+ const filename = path.join(directory, "90-outpost-jobs-reconcile.sh");
1006
+ fs.mkdirSync(directory, { recursive: true });
1007
+ const contents = [
1008
+ "#!/usr/bin/env bash",
1009
+ "# Auto-installed by outpost-jobs-reconcile.sh --ensure-hook (US-004).",
1010
+ "# hq-sync-runner executes personal/hooks/Stop/* after each sync cycle.",
1011
+ "set -u",
1012
+ 'HQ_ROOT="${OUTPOST_HQ_ROOT:-${HQ_ROOT:-/home/ec2-user/hq}}"',
1013
+ 'RECONCILE="$HQ_ROOT/core/scripts/outpost-jobs-reconcile.sh"',
1014
+ 'if [ -x "$RECONCILE" ] || [ -f "$RECONCILE" ]; then',
1015
+ ' bash "$RECONCILE" --hq-root "$HQ_ROOT" --after-sync >>"${HOME}/.hq/jobs/reconcile/after-sync.log" 2>&1 || true',
1016
+ "fi",
1017
+ "",
1018
+ ].join("\n");
1019
+ fs.writeFileSync(filename, contents);
1020
+ fs.chmodSync(filename, 0o755);
1021
+ process.stderr.write(`outpost-jobs-reconcile: installed sync Stop hook: ${filename}\n`);
1022
+ }
1023
+ function disableRemoveUnits(id, unitDir, command, dryRun, options) {
1024
+ for (const args of [["disable", "--now", `hq-job-${id}.timer`], ["stop", `hq-job-${id}.service`]]) {
1025
+ const result = systemctlResult(args, dryRun, command, options);
1026
+ if (result.stdout)
1027
+ process.stdout.write(result.stdout);
1028
+ }
1029
+ fs.rmSync(path.join(unitDir, `hq-job-${id}.service`), { force: true });
1030
+ fs.rmSync(path.join(unitDir, `hq-job-${id}.timer`), { force: true });
1031
+ }
1032
+ function jobEnabled(job) {
1033
+ return job.enabled === undefined ? "true" : shellText(job.enabled);
1034
+ }
1035
+ function isCompanyJobPath(file) {
1036
+ return file.split(path.sep).join("/").includes("/companies/");
1037
+ }
1038
+ function reconcileDie(message) {
1039
+ process.stderr.write(`outpost-jobs-reconcile: ${message}\n`);
1040
+ return 2;
1041
+ }
1042
+ export async function runReconcile(argv, rootArg, options = {}) {
1043
+ let root = rootArg ?? process.env.HQ_ROOT ?? "";
1044
+ let dryRun = false;
1045
+ let noProbe = false;
1046
+ let probeOnly = false;
1047
+ let ensureHook = false;
1048
+ const args = [...argv];
1049
+ for (let index = 0; index < args.length;) {
1050
+ const arg = args[index];
1051
+ if (arg === "--hq-root") {
1052
+ const value = args[index + 1] ?? "";
1053
+ if (!value)
1054
+ return reconcileDie("--hq-root requires a directory");
1055
+ root = value;
1056
+ index += 2;
1057
+ }
1058
+ else if (arg === "--dry-run") {
1059
+ dryRun = true;
1060
+ index += 1;
1061
+ }
1062
+ else if (arg === "--no-probe") {
1063
+ noProbe = true;
1064
+ index += 1;
1065
+ }
1066
+ else if (arg === "--probe-only") {
1067
+ probeOnly = true;
1068
+ index += 1;
1069
+ }
1070
+ else if (arg === "--ensure-hook") {
1071
+ ensureHook = true;
1072
+ index += 1;
1073
+ }
1074
+ else if (arg === "--after-sync") {
1075
+ index += 1;
1076
+ }
1077
+ else if (arg === "-h" || arg === "--help") {
1078
+ process.stdout.write(`${RECONCILE_USAGE}\n`);
1079
+ return 0;
1080
+ }
1081
+ else if (arg?.startsWith("-")) {
1082
+ return reconcileDie(`unknown option: ${arg}`);
1083
+ }
1084
+ else if (arg !== undefined) {
1085
+ return reconcileDie(`unexpected argument: ${arg}`);
1086
+ }
1087
+ }
1088
+ if (!root)
1089
+ root = resolveLiveRoot();
1090
+ const validator = path.join(root, "core", "scripts", "jobs-validate.sh");
1091
+ const runner = path.join(root, "core", "scripts", "hq-job-run.sh");
1092
+ const probe = path.join(root, "core", "scripts", "hq-job-probe.sh");
1093
+ if (!fs.existsSync(validator))
1094
+ return reconcileDie("missing jobs-validate.sh");
1095
+ if (!fs.existsSync(runner))
1096
+ return reconcileDie("missing hq-job-run.sh");
1097
+ if (!fs.existsSync(probe))
1098
+ return reconcileDie("missing hq-job-probe.sh");
1099
+ const home = process.env.HOME ?? "";
1100
+ const unitDir = process.env.HQ_JOB_UNIT_DIR || path.join(home, ".config", "systemd", "user");
1101
+ const cacheDir = process.env.HQ_JOB_STATUS_CACHE_DIR || path.join(home, ".hq", "jobs", "status-cache");
1102
+ const stateDir = process.env.HQ_JOB_RECONCILE_STATE || path.join(home, ".hq", "jobs", "reconcile");
1103
+ const systemctl = process.env.HQ_JOB_SYSTEMCTL || "systemctl";
1104
+ const randomize = process.env.HQ_JOB_RANDOMIZE_SEC || "60";
1105
+ for (const directory of [unitDir, cacheDir, stateDir])
1106
+ fs.mkdirSync(directory, { recursive: true });
1107
+ const log = (message) => {
1108
+ process.stderr.write(`outpost-jobs-reconcile: ${message}\n`);
1109
+ };
1110
+ const commandRunner = options.commandRunner ?? commandResult;
1111
+ const owners = resolveBoxOwners(options);
1112
+ let errors = 0;
1113
+ let armed = 0;
1114
+ let skipped = 0;
1115
+ let removed = 0;
1116
+ let probed = 0;
1117
+ const desired = new Set();
1118
+ if (ensureHook)
1119
+ installSyncHook(root);
1120
+ if (!noProbe) {
1121
+ for (const file of collectReconcileJobs(root)) {
1122
+ if (!reconcileJobIsValid(file))
1123
+ continue;
1124
+ const job = readJobValue(file);
1125
+ const id = shellText(job.id);
1126
+ if (!id)
1127
+ continue;
1128
+ if (isCompanyJobPath(file) && !ownerMatches(shellText(job.owner), owners))
1129
+ continue;
1130
+ const readiness = await jobReadiness(id, cacheDir, home, options);
1131
+ if (readiness === "ready")
1132
+ continue;
1133
+ log(`probe ${id} (cached readiness=${readiness})`);
1134
+ const result = commandRunner("bash", [probe, "--hq-root", root, file]);
1135
+ probed += 1;
1136
+ const probeReadiness = probeValue(result.stdout, "readiness");
1137
+ if (probeReadiness) {
1138
+ const ingest = probeValue(result.stdout, "ingest");
1139
+ if (ingest === "ok" || ingest === "dry_run")
1140
+ writeReadinessCache(cacheDir, id, probeReadiness, "probe", options);
1141
+ else if (probeReadiness === "blocked")
1142
+ writeReadinessCache(cacheDir, id, "blocked", "probe-local", options);
1143
+ }
1144
+ if (result.status >= 2 && result.status !== 3)
1145
+ errors += 1;
1146
+ }
1147
+ }
1148
+ if (probeOnly) {
1149
+ log(`probe-only done (probed=${probed} errors=${errors})`);
1150
+ return errors === 0 ? 0 : 1;
1151
+ }
1152
+ for (const file of collectReconcileJobs(root)) {
1153
+ const job = readJobValue(file);
1154
+ const idHint = shellText(job.id);
1155
+ const timezone = shellText(job.timezone);
1156
+ if (timezone && !/^[A-Za-z0-9/_+-]+$/.test(timezone)) {
1157
+ log(`skip ${idHint || "unknown"} — invalid timezone contains unsafe characters`);
1158
+ errors += 1;
1159
+ continue;
1160
+ }
1161
+ if (!reconcileJobIsValid(file)) {
1162
+ log(`skip invalid job YAML: ${file}`);
1163
+ errors += 1;
1164
+ continue;
1165
+ }
1166
+ const id = idHint;
1167
+ const name = shellText(job.name);
1168
+ const owner = shellText(job.owner);
1169
+ const schedule = shellText(job.schedule);
1170
+ const companyJob = isCompanyJobPath(file);
1171
+ const timerPath = path.join(unitDir, `hq-job-${id}.timer`);
1172
+ const servicePath = path.join(unitDir, `hq-job-${id}.service`);
1173
+ if (companyJob && !ownerMatches(owner, owners)) {
1174
+ log(`skip company job ${id} — owner '${owner}' does not match this Outpost`);
1175
+ skipped += 1;
1176
+ if (fs.existsSync(timerPath)) {
1177
+ disableRemoveUnits(id, unitDir, systemctl, dryRun, options);
1178
+ removed += 1;
1179
+ }
1180
+ continue;
1181
+ }
1182
+ const enabled = jobEnabled(job);
1183
+ if (enabled === "false" || enabled === "False" || enabled === "0") {
1184
+ log(`remove units for disabled job ${id}`);
1185
+ if (fs.existsSync(timerPath) || fs.existsSync(servicePath)) {
1186
+ disableRemoveUnits(id, unitDir, systemctl, dryRun, options);
1187
+ removed += 1;
1188
+ }
1189
+ continue;
1190
+ }
1191
+ const readiness = await jobReadiness(id, cacheDir, home, options);
1192
+ if (readiness !== "ready") {
1193
+ log(`skip arming ${id} — readiness=${readiness} (need ready)`);
1194
+ skipped += 1;
1195
+ if (fs.existsSync(timerPath)) {
1196
+ disableRemoveUnits(id, unitDir, systemctl, dryRun, options);
1197
+ removed += 1;
1198
+ log(`stopped timer for non-ready job ${id}`);
1199
+ }
1200
+ continue;
1201
+ }
1202
+ const cron = schedule.trim().split(/\s+/);
1203
+ if (cron.length !== 5) {
1204
+ log(`skip ${id} — schedule not 5-field cron: ${schedule}`);
1205
+ errors += 1;
1206
+ continue;
1207
+ }
1208
+ let onCalendar = cronToOnCalendar(cron);
1209
+ if (timezone && !` ${onCalendar} `.includes(` ${timezone} `))
1210
+ onCalendar = `${onCalendar} ${timezone}`;
1211
+ desired.add(id);
1212
+ const service = serviceUnit(root, runner, id);
1213
+ const timer = timerUnit(id, onCalendar, randomize, root);
1214
+ const unchanged = fs.existsSync(servicePath) && fs.existsSync(timerPath)
1215
+ && fs.readFileSync(servicePath, "utf8") === service
1216
+ && fs.readFileSync(timerPath, "utf8") === timer;
1217
+ if (unchanged) {
1218
+ log(`noop ${id} — units unchanged (${name})`);
1219
+ const reload = systemctlResult(["daemon-reload"], dryRun, systemctl, options);
1220
+ if (reload.stdout)
1221
+ process.stdout.write(reload.stdout);
1222
+ if (reload.stderr)
1223
+ process.stderr.write(reload.stderr);
1224
+ const enabledResult = systemctlResult(["enable", "--now", `hq-job-${id}.timer`], dryRun, systemctl, options);
1225
+ const output = `${enabledResult.stdout}${enabledResult.stderr}`.replace(/\n+$/, "");
1226
+ if (enabledResult.status !== 0) {
1227
+ log(`systemctl enable timer failed for job ${id} exit=${enabledResult.status}: ${output || "no error message"}`);
1228
+ skipped += 1;
1229
+ errors += 1;
1230
+ }
1231
+ else {
1232
+ if (output)
1233
+ process.stderr.write(`${output}\n`);
1234
+ armed += 1;
1235
+ }
1236
+ continue;
1237
+ }
1238
+ fs.writeFileSync(servicePath, service);
1239
+ fs.writeFileSync(timerPath, timer);
1240
+ const reload = systemctlResult(["daemon-reload"], dryRun, systemctl, options);
1241
+ if (reload.stdout)
1242
+ process.stdout.write(reload.stdout);
1243
+ if (reload.stderr)
1244
+ process.stderr.write(reload.stderr);
1245
+ const enabledResult = systemctlResult(["enable", "--now", `hq-job-${id}.timer`], dryRun, systemctl, options);
1246
+ const output = `${enabledResult.stdout}${enabledResult.stderr}`.replace(/\n+$/, "");
1247
+ if (enabledResult.status === 0) {
1248
+ if (output)
1249
+ process.stderr.write(`${output}\n`);
1250
+ if (!dryRun)
1251
+ log(`armed ${id} OnCalendar=${onCalendar} readiness=ready`);
1252
+ armed += 1;
1253
+ }
1254
+ else {
1255
+ log(`systemctl enable timer failed for job ${id} exit=${enabledResult.status}: ${output || "no error message"}`);
1256
+ skipped += 1;
1257
+ errors += 1;
1258
+ }
1259
+ }
1260
+ let unitNames = [];
1261
+ try {
1262
+ unitNames = fs.readdirSync(unitDir);
1263
+ }
1264
+ catch { /* The shell's unmatched glob is ignored. */ }
1265
+ for (const unitName of unitNames) {
1266
+ const match = /^hq-job-(.+)\.timer$/.exec(unitName);
1267
+ if (!match)
1268
+ continue;
1269
+ const id = match[1];
1270
+ if (desired.has(id))
1271
+ continue;
1272
+ log(`remove orphaned units for deleted/unarmed job ${id}`);
1273
+ disableRemoveUnits(id, unitDir, systemctl, dryRun, options);
1274
+ removed += 1;
1275
+ }
1276
+ const timestamp = (options.now?.() ?? new Date()).toISOString().replace(/\.\d{3}Z$/, "Z");
1277
+ fs.writeFileSync(path.join(stateDir, "last-reconcile.json"), serializeJqJson({
1278
+ armed, skipped, removed, probed, errors, dry_run: dryRun, at: timestamp,
1279
+ }, "compact"));
1280
+ log(`done armed=${armed} skipped=${skipped} removed=${removed} probed=${probed} errors=${errors}`);
1281
+ return errors === 0 ? 0 : 1;
1282
+ }
582
1283
  export function registerCoreJobsNativeCommand(core) {
583
1284
  const jobs = core.command("jobs").description("Scheduled jobs helpers");
584
1285
  const register = (name, asset, summary, run) => {
@@ -590,13 +1291,18 @@ export function registerCoreJobsNativeCommand(core) {
590
1291
  .argument("[args...]", "Arguments passed to the scaffold helper")
591
1292
  .action(async (args, _options, current) => {
592
1293
  const argv = rawArgumentsForCommand(current, args);
1294
+ const groupHqRoot = core.opts().hqRoot;
593
1295
  if (!await coreNativeJobsEnabled()) {
594
- const result = runBundledScript({ asset, args: argv, cwd: process.cwd() });
1296
+ const hqRoot = asset === RECONCILE_ASSET && !argv.includes("--hq-root")
1297
+ && !argv.includes("--help") && !argv.includes("-h")
1298
+ ? resolveLiveRoot({ hqRoot: groupHqRoot })
1299
+ : undefined;
1300
+ const result = runBundledScript({ asset, args: argv, cwd: process.cwd(), ...(hqRoot ? { hqRoot } : {}) });
595
1301
  if (result.code !== 0)
596
1302
  process.exit(result.code);
597
1303
  return;
598
1304
  }
599
- const result = run(argv);
1305
+ const result = await run(argv, groupHqRoot ?? process.env.HQ_ROOT);
600
1306
  if (result !== 0)
601
1307
  process.exit(result);
602
1308
  });
@@ -604,6 +1310,7 @@ export function registerCoreJobsNativeCommand(core) {
604
1310
  };
605
1311
  register("schedule-parse", SCHEDULE_PARSE_ASSET, "Resolve natural-language or raw cron schedules", runScheduleParse);
606
1312
  register("validate", VALIDATE_ASSET, "Validate scheduled-job YAML files", validateJobs);
1313
+ register("reconcile", RECONCILE_ASSET, "Materialize scheduled jobs on this outpost", runReconcile);
607
1314
  }
608
1315
  export function rawArgumentsForCommand(command, fallback, raw = process.argv) {
609
1316
  const names = [];