@indigoai-us/hq-cli 5.290.0 → 5.292.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +66 -3
- 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/hook-lib.sh +345 -50
- 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/hq-session.sh +182 -21
- package/assets/scaffold/core/scripts/jobs-schedule-parse.sh +310 -0
- package/assets/scaffold/core/scripts/jobs-validate.sh +451 -0
- package/assets/scaffold/core/scripts/lib/session-id.sh +3 -2
- package/assets/scaffold/core/scripts/lib/work-mesh-enqueue.sh +2 -1
- package/assets/scaffold/core/scripts/outpost-jobs-reconcile.sh +830 -0
- package/dist/command-catalog.generated.d.ts +165 -1
- package/dist/command-catalog.generated.js +205 -1
- package/dist/commands/cloud.d.ts +26 -14
- package/dist/commands/cloud.js +207 -56
- 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 +23 -0
- package/dist/commands/core-jobs-native.js +1331 -0
- package/dist/commands/core-native-jobs-flag.d.ts +4 -0
- package/dist/commands/core-native-jobs-flag.js +7 -0
- package/dist/commands/core-ontology.d.ts +22 -4
- package/dist/commands/core-ontology.js +90 -8
- package/dist/commands/core-policy.js +24 -5
- package/dist/commands/core.js +17 -0
- package/dist/commands/integrations-core.js +7 -0
- package/dist/commands/lanes.js +111 -35
- package/dist/commands/members.js +5 -0
- package/dist/commands/mesh.js +46 -25
- package/dist/commands/personal-targeted-push-flag.d.ts +4 -0
- package/dist/commands/personal-targeted-push-flag.js +7 -0
- package/dist/commands/personal-targeted-push-paths.d.ts +3 -0
- package/dist/commands/personal-targeted-push-paths.js +67 -0
- package/dist/commands/secrets.js +9 -0
- package/dist/commands/skill.d.ts +3 -0
- package/dist/commands/skill.js +28 -0
- package/dist/fast-core.js +1 -0
- package/dist/lib/billing/free-month.d.ts +18 -0
- package/dist/lib/billing/free-month.js +31 -0
- package/dist/lib/billing/plan-limit-refusals.d.ts +69 -0
- package/dist/lib/billing/plan-limit-refusals.js +187 -0
- package/dist/lib/billing/plan-lock.d.ts +2 -6
- package/dist/lib/billing/plan-lock.js +5 -16
- 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/core-utils/secret-patterns.d.ts +3 -1
- package/dist/lib/core-utils/secret-patterns.js +3 -2
- package/dist/lib/daemon/flag.d.ts +2 -0
- package/dist/lib/daemon/flag.js +2 -0
- package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
- package/dist/lib/flag-registry-worker-source.generated.js +1 -1
- package/dist/lib/lanes/autopilot.d.ts +69 -0
- package/dist/lib/lanes/autopilot.js +418 -0
- package/dist/lib/lanes/mesh-events.js +2 -0
- package/dist/lib/lanes/store.js +11 -6
- package/dist/lib/mesh/api.js +4 -0
- package/dist/lib/mesh/live/daemon/state.d.ts +7 -1
- package/dist/lib/mesh/live/daemon/state.js +34 -2
- package/dist/main.js +23 -12
- package/dist/utils/plan-gate-error.d.ts +24 -0
- package/dist/utils/plan-gate-error.js +219 -9
- 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 +2 -2
- package/scripts/mesh-daemon-if-stale.mjs +4 -0
|
@@ -0,0 +1,1331 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { JSON_SCHEMA, load, loadAll } from "js-yaml";
|
|
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";
|
|
8
|
+
import { runBundledScript } from "../utils/run-bundled-script.js";
|
|
9
|
+
import { describeCoreCommand } from "./core-command-catalog.js";
|
|
10
|
+
import { coreNativeJobsEnabled } from "./core-native-jobs-flag.js";
|
|
11
|
+
const SCHEDULE_PARSE_ASSET = "core/scripts/jobs-schedule-parse.sh";
|
|
12
|
+
const VALIDATE_ASSET = "core/scripts/jobs-validate.sh";
|
|
13
|
+
const RECONCILE_ASSET = "core/scripts/outpost-jobs-reconcile.sh";
|
|
14
|
+
const SCHEDULE_PARSE_USAGE = 'usage: jobs-schedule-parse.sh [--tz IANA] "<nl or cron schedule>"\n';
|
|
15
|
+
const VALIDATE_USAGE = "usage: jobs-validate.sh <file-or-dir> [file-or-dir...]\n";
|
|
16
|
+
const CRON_RE = /^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)$/;
|
|
17
|
+
const DAY_NAMES = {
|
|
18
|
+
sun: 0, sunday: 0, mon: 1, monday: 1, tue: 2, tues: 2, tuesday: 2,
|
|
19
|
+
wed: 3, wednesday: 3, thu: 4, thur: 4, thurs: 4, thursday: 4,
|
|
20
|
+
fri: 5, friday: 5, sat: 6, saturday: 6,
|
|
21
|
+
};
|
|
22
|
+
const DAY_LABELS = {
|
|
23
|
+
"0": "Sunday", "1": "Monday", "2": "Tuesday", "3": "Wednesday",
|
|
24
|
+
"4": "Thursday", "5": "Friday", "6": "Saturday", "1-5": "weekday",
|
|
25
|
+
"*": "day",
|
|
26
|
+
};
|
|
27
|
+
function parseClock(text) {
|
|
28
|
+
const match = /^(\d{1,2})(?::(\d{2}))?\s*(am|pm)?$/i.exec(text.trim().toLowerCase().replace(/ /g, ""));
|
|
29
|
+
if (!match)
|
|
30
|
+
return undefined;
|
|
31
|
+
let hour = Number.parseInt(match[1], 10);
|
|
32
|
+
const minute = Number.parseInt(match[2] || "0", 10);
|
|
33
|
+
const meridiem = match[3];
|
|
34
|
+
if (minute > 59)
|
|
35
|
+
return undefined;
|
|
36
|
+
if (meridiem) {
|
|
37
|
+
if (hour < 1 || hour > 12)
|
|
38
|
+
return undefined;
|
|
39
|
+
hour = meridiem === "am"
|
|
40
|
+
? (hour === 12 ? 0 : hour)
|
|
41
|
+
: (hour === 12 ? 12 : hour + 12);
|
|
42
|
+
}
|
|
43
|
+
else if (hour > 23) {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
return [hour, minute];
|
|
47
|
+
}
|
|
48
|
+
function formatClock(hour, minute) {
|
|
49
|
+
const hour12 = hour % 12 || 12;
|
|
50
|
+
return `${hour12}:${String(minute).padStart(2, "0")} ${hour < 12 ? "AM" : "PM"}`;
|
|
51
|
+
}
|
|
52
|
+
function humanizeCron(cron, timezone) {
|
|
53
|
+
const fields = cron.split(/\s+/);
|
|
54
|
+
if (fields.length !== 5)
|
|
55
|
+
return `Cron \`${cron}\` (${timezone})`;
|
|
56
|
+
const [minute, hour, dayOfMonth, month, dayOfWeek] = fields;
|
|
57
|
+
if (/^\d+$/.test(minute) && /^\d+$/.test(hour) && dayOfMonth === "*"
|
|
58
|
+
&& month === "*" && (dayOfWeek === "*" || dayOfWeek === "1-5")) {
|
|
59
|
+
const day = dayOfWeek === "1-5" ? "Every weekday" : "Every day";
|
|
60
|
+
return `${day} at ${formatClock(+hour, +minute)} (${timezone})`;
|
|
61
|
+
}
|
|
62
|
+
if (/^\d+$/.test(minute) && /^\d+$/.test(hour) && dayOfMonth === "*"
|
|
63
|
+
&& month === "*" && /^\d+$/.test(dayOfWeek)) {
|
|
64
|
+
const day = DAY_LABELS[dayOfWeek] || `day ${dayOfWeek}`;
|
|
65
|
+
return `Every ${day} at ${formatClock(+hour, +minute)} (${timezone})`;
|
|
66
|
+
}
|
|
67
|
+
if (minute.startsWith("*/") && hour === "*" && dayOfMonth === "*"
|
|
68
|
+
&& month === "*" && dayOfWeek === "*") {
|
|
69
|
+
return `Every ${minute.slice(2)} minutes (${timezone})`;
|
|
70
|
+
}
|
|
71
|
+
if (minute === "0" && hour.startsWith("*/") && dayOfMonth === "*"
|
|
72
|
+
&& month === "*" && dayOfWeek === "*") {
|
|
73
|
+
return `Every ${hour.slice(2)} hours (${timezone})`;
|
|
74
|
+
}
|
|
75
|
+
if (minute === "0" && hour === "*" && dayOfMonth === "*"
|
|
76
|
+
&& month === "*" && dayOfWeek === "*") {
|
|
77
|
+
return `Every hour on the hour (${timezone})`;
|
|
78
|
+
}
|
|
79
|
+
return `Cron \`${cron}\` (${timezone})`;
|
|
80
|
+
}
|
|
81
|
+
function parseNaturalLanguage(text) {
|
|
82
|
+
const normalized = text.trim().toLowerCase();
|
|
83
|
+
let match = /^every\s+(\d+)\s+minutes?$/.exec(normalized);
|
|
84
|
+
if (match) {
|
|
85
|
+
const minutes = Number(match[1]);
|
|
86
|
+
if (minutes < 1 || minutes > 59)
|
|
87
|
+
return [null, "minute interval must be 1..59"];
|
|
88
|
+
return [`*/${minutes} * * * *`, `Every ${minutes} minutes`];
|
|
89
|
+
}
|
|
90
|
+
if (normalized === "every hour" || normalized === "hourly") {
|
|
91
|
+
return ["0 * * * *", "Every hour on the hour"];
|
|
92
|
+
}
|
|
93
|
+
match = /^every\s+(\d+)\s+hours?$/.exec(normalized);
|
|
94
|
+
if (match) {
|
|
95
|
+
const hours = Number(match[1]);
|
|
96
|
+
if (hours < 1 || hours > 23)
|
|
97
|
+
return [null, "hour interval must be 1..23"];
|
|
98
|
+
return [`0 */${hours} * * *`, `Every ${hours} hours`];
|
|
99
|
+
}
|
|
100
|
+
match = /^(?:every\s+)?(weekday|weekdays|day|daily)\s+(?:at\s+)?(.+)$/.exec(normalized);
|
|
101
|
+
if (match) {
|
|
102
|
+
const clock = parseClock(match[2]);
|
|
103
|
+
if (!clock)
|
|
104
|
+
return [null, `could not parse time: ${match[2]}`];
|
|
105
|
+
const [hour, minute] = clock;
|
|
106
|
+
const weekdays = match[1] === "weekday" || match[1] === "weekdays";
|
|
107
|
+
return [
|
|
108
|
+
`${minute} ${hour} * * ${weekdays ? "1-5" : "*"}`,
|
|
109
|
+
`${weekdays ? "Every weekday" : "Every day"} at ${formatClock(hour, minute)}`,
|
|
110
|
+
];
|
|
111
|
+
}
|
|
112
|
+
match = /^(?:every\s+)?(sun(?:day)?|mon(?:day)?|tue(?:s|sday)?|wed(?:nesday)?|thu(?:r|rs|rsday)?|fri(?:day)?|sat(?:urday)?)s?\s+(?:at\s+)?(.+)$/.exec(normalized);
|
|
113
|
+
if (match) {
|
|
114
|
+
const day = DAY_NAMES[match[1]];
|
|
115
|
+
const clock = parseClock(match[2]);
|
|
116
|
+
if (!clock)
|
|
117
|
+
return [null, `could not parse time: ${match[2]}`];
|
|
118
|
+
return [
|
|
119
|
+
`${clock[1]} ${clock[0]} * * ${day}`,
|
|
120
|
+
`Every ${DAY_LABELS[String(day)]} at ${formatClock(...clock)}`,
|
|
121
|
+
];
|
|
122
|
+
}
|
|
123
|
+
match = /^at\s+(.+?)\s+every\s+(weekday|weekdays|day|daily)$/.exec(normalized);
|
|
124
|
+
if (match) {
|
|
125
|
+
const clock = parseClock(match[1]);
|
|
126
|
+
if (!clock)
|
|
127
|
+
return [null, `could not parse time: ${match[1]}`];
|
|
128
|
+
const weekdays = match[2] === "weekday" || match[2] === "weekdays";
|
|
129
|
+
return [
|
|
130
|
+
`${clock[1]} ${clock[0]} * * ${weekdays ? "1-5" : "*"}`,
|
|
131
|
+
`${weekdays ? "Every weekday" : "Every day"} at ${formatClock(...clock)}`,
|
|
132
|
+
];
|
|
133
|
+
}
|
|
134
|
+
return [
|
|
135
|
+
null,
|
|
136
|
+
"unrecognized schedule — try 'every weekday at 9am', 'every day at 14:30', "
|
|
137
|
+
+ "'every monday at 9am', a 5-field cron, or 'every 15 minutes'",
|
|
138
|
+
];
|
|
139
|
+
}
|
|
140
|
+
function isValidTimezone(timezone) {
|
|
141
|
+
const wellFormed = /^(UTC|[A-Za-z0-9_+-]+(\/[A-Za-z0-9_+-]+)+)$/;
|
|
142
|
+
if (!wellFormed.test(timezone))
|
|
143
|
+
return false;
|
|
144
|
+
try {
|
|
145
|
+
new Intl.DateTimeFormat(undefined, { timeZone: timezone });
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
// Hosts without tzdata accept the same well-formed names as the shell.
|
|
150
|
+
return wellFormed.test(timezone);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
export function detectHostTimezone(override, resolveLocaltime = fs.realpathSync, readTimedatectl = () => {
|
|
154
|
+
const result = spawnSync("timedatectl", ["show", "-p", "Timezone", "--value"], { encoding: "utf8", timeout: 2000, stdio: ["ignore", "pipe", "ignore"] });
|
|
155
|
+
if (result.error || result.status !== 0)
|
|
156
|
+
return "";
|
|
157
|
+
return result.stdout || "";
|
|
158
|
+
}) {
|
|
159
|
+
if (override)
|
|
160
|
+
return override;
|
|
161
|
+
const environmentTimezone = (process.env.TZ || "").trim();
|
|
162
|
+
if (environmentTimezone && environmentTimezone.includes("/"))
|
|
163
|
+
return environmentTimezone;
|
|
164
|
+
try {
|
|
165
|
+
const realpath = resolveLocaltime("/etc/localtime");
|
|
166
|
+
const parts = realpath.split(path.sep).filter(Boolean);
|
|
167
|
+
const zoneinfo = parts.indexOf("zoneinfo");
|
|
168
|
+
if (zoneinfo >= 0 && parts.slice(zoneinfo + 1).length) {
|
|
169
|
+
return parts.slice(zoneinfo + 1).join("/");
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
// Ignore an unavailable local timezone link.
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
const timezone = readTimedatectl().trim();
|
|
177
|
+
if (timezone && timezone.includes("/"))
|
|
178
|
+
return timezone;
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
// Ignore unavailable timedatectl.
|
|
182
|
+
}
|
|
183
|
+
return "UTC";
|
|
184
|
+
}
|
|
185
|
+
export function runScheduleParse(argv) {
|
|
186
|
+
let timezoneOverride = "";
|
|
187
|
+
const args = [...argv];
|
|
188
|
+
while (args.length) {
|
|
189
|
+
const argument = args[0];
|
|
190
|
+
if (argument === "--tz") {
|
|
191
|
+
if (args.length < 2) {
|
|
192
|
+
process.stderr.write(SCHEDULE_PARSE_USAGE);
|
|
193
|
+
return 2;
|
|
194
|
+
}
|
|
195
|
+
timezoneOverride = args[1];
|
|
196
|
+
args.splice(0, 2);
|
|
197
|
+
}
|
|
198
|
+
else if (argument === "--json") {
|
|
199
|
+
args.shift();
|
|
200
|
+
}
|
|
201
|
+
else if (argument === "-h" || argument === "--help") {
|
|
202
|
+
process.stderr.write(SCHEDULE_PARSE_USAGE);
|
|
203
|
+
return 2;
|
|
204
|
+
}
|
|
205
|
+
else if (argument === "--") {
|
|
206
|
+
args.shift();
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
else if (argument.startsWith("-")) {
|
|
210
|
+
process.stderr.write(`jobs-schedule-parse: unknown flag: ${argument}\n${SCHEDULE_PARSE_USAGE}`);
|
|
211
|
+
return 2;
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (!args.length) {
|
|
218
|
+
process.stderr.write(SCHEDULE_PARSE_USAGE);
|
|
219
|
+
return 2;
|
|
220
|
+
}
|
|
221
|
+
const input = args.join(" ").trim();
|
|
222
|
+
if (!input) {
|
|
223
|
+
process.stdout.write('{"ok":false,"error":"empty schedule"}\n');
|
|
224
|
+
return 1;
|
|
225
|
+
}
|
|
226
|
+
const timezone = detectHostTimezone(timezoneOverride);
|
|
227
|
+
if (!isValidTimezone(timezone)) {
|
|
228
|
+
process.stdout.write(`${JSON.stringify({ ok: false, error: `invalid IANA timezone: ${timezone}` })}\n`);
|
|
229
|
+
return 1;
|
|
230
|
+
}
|
|
231
|
+
if (CRON_RE.test(input) && !/[A-Za-z]/.test(input)) {
|
|
232
|
+
process.stdout.write(`${JSON.stringify({ ok: true, cron: input, timezone, human: humanizeCron(input, timezone), source: "cron" })}\n`);
|
|
233
|
+
return 0;
|
|
234
|
+
}
|
|
235
|
+
const namedCron = /^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(mon-fri|monday-friday)$/i.exec(input);
|
|
236
|
+
if (namedCron) {
|
|
237
|
+
const cron = `${namedCron[1]} ${namedCron[2]} ${namedCron[3]} ${namedCron[4]} 1-5`;
|
|
238
|
+
process.stdout.write(`${JSON.stringify({ ok: true, cron, timezone, human: humanizeCron(cron, timezone), source: "cron" })}\n`);
|
|
239
|
+
return 0;
|
|
240
|
+
}
|
|
241
|
+
const [cron, message] = parseNaturalLanguage(input);
|
|
242
|
+
if (cron === null) {
|
|
243
|
+
process.stdout.write(`${JSON.stringify({ ok: false, error: message })}\n`);
|
|
244
|
+
return 1;
|
|
245
|
+
}
|
|
246
|
+
process.stdout.write(`${JSON.stringify({ ok: true, cron, timezone, human: `${message} (${timezone})`, source: "nl" })}\n`);
|
|
247
|
+
return 0;
|
|
248
|
+
}
|
|
249
|
+
function isValidCronField(value, minimum, maximum) {
|
|
250
|
+
return value.split(",").every((part) => {
|
|
251
|
+
if (part === "*")
|
|
252
|
+
return true;
|
|
253
|
+
if (/^\*\/[1-9]\d*$/.test(part))
|
|
254
|
+
return true;
|
|
255
|
+
if (/^(sun|mon|tue|wed|thu|fri|sat)$/i.test(part))
|
|
256
|
+
return maximum === 7;
|
|
257
|
+
if (/^(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)$/i.test(part))
|
|
258
|
+
return maximum === 12;
|
|
259
|
+
const range = /^(\d+)-(\d+)(?:\/([1-9]\d*))?$/.exec(part);
|
|
260
|
+
if (range) {
|
|
261
|
+
return Number(range[1]) >= minimum && Number(range[2]) <= maximum
|
|
262
|
+
&& Number(range[1]) <= Number(range[2]);
|
|
263
|
+
}
|
|
264
|
+
const number = /^(\d+)(?:\/([1-9]\d*))?$/.exec(part);
|
|
265
|
+
if (!number)
|
|
266
|
+
return false;
|
|
267
|
+
return Number(number[1]) >= minimum && Number(number[1]) <= maximum;
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
function validateCron(file, cron, emit) {
|
|
271
|
+
const fields = cron.trim().split(/\s+/);
|
|
272
|
+
if (fields.length !== 5) {
|
|
273
|
+
emit(file, "schedule", `invalid cron — expected 5 fields (minute hour dom month dow), got ${fields.length}`);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
const names = ["minute", "hour", "day-of-month", "month", "day-of-week"];
|
|
277
|
+
const ranges = [[0, 59], [0, 23], [1, 31], [1, 12], [0, 7]];
|
|
278
|
+
fields.forEach((field, index) => {
|
|
279
|
+
const [minimum, maximum] = ranges[index];
|
|
280
|
+
if (!isValidCronField(field, minimum, maximum)) {
|
|
281
|
+
emit(file, "schedule", `invalid cron ${names[index]} field '${field}'`);
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
function asText(value) {
|
|
286
|
+
if (value === null || value === undefined || value === false)
|
|
287
|
+
return "";
|
|
288
|
+
if (typeof value === "string")
|
|
289
|
+
return value;
|
|
290
|
+
if (typeof value === "object")
|
|
291
|
+
return JSON.stringify(value, null, 2);
|
|
292
|
+
return String(value);
|
|
293
|
+
}
|
|
294
|
+
function matchingJobSecretPatterns(value) {
|
|
295
|
+
// jobs-validate.sh calls grep with a leading-hyphen pattern and omits `--`.
|
|
296
|
+
// Preserve that shell behavior: grep interprets this class as an option.
|
|
297
|
+
return matchingSecretPatterns(value, { requireBoundary: false })
|
|
298
|
+
.filter((name) => name !== "Private key");
|
|
299
|
+
}
|
|
300
|
+
function validateCwd(file, field, cwd, emit) {
|
|
301
|
+
if (cwd.startsWith("/")) {
|
|
302
|
+
emit(file, field, "absolute cwd not allowed — use an HQ-root-relative path");
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (cwd.split("/").includes("..")) {
|
|
306
|
+
emit(file, field, "cwd must not contain '..' path components");
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
function validateJobId(file, value, seenIds, emit) {
|
|
310
|
+
if (!value)
|
|
311
|
+
return;
|
|
312
|
+
if (!/^[a-z][a-z0-9-]{1,62}$/.test(value)) {
|
|
313
|
+
emit(file, "id", `must match [a-z][a-z0-9-]{1,62}, got '${value}'`);
|
|
314
|
+
}
|
|
315
|
+
if (seenIds.includes(value))
|
|
316
|
+
emit(file, "id", `duplicate id '${value}'`);
|
|
317
|
+
seenIds.push(value);
|
|
318
|
+
}
|
|
319
|
+
function validateExec(file, exec, emit) {
|
|
320
|
+
if (exec === undefined || exec === null) {
|
|
321
|
+
emit(file, "exec", "missing required field");
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (typeof exec !== "object" || Array.isArray(exec)) {
|
|
325
|
+
emit(file, "exec", "must be an object with prompt or skill");
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
const definition = exec;
|
|
329
|
+
const prompt = asText(definition.prompt);
|
|
330
|
+
const skill = asText(definition.skill);
|
|
331
|
+
if (!prompt && !skill)
|
|
332
|
+
emit(file, "exec", "must set prompt or skill");
|
|
333
|
+
else if (prompt && skill)
|
|
334
|
+
emit(file, "exec", "set exactly one of prompt or skill, not both");
|
|
335
|
+
for (const [field, value] of [["exec.prompt", prompt], ["exec.skill", skill]]) {
|
|
336
|
+
for (const name of matchingJobSecretPatterns(value)) {
|
|
337
|
+
emit(file, field, `inline credential-shaped value matched pattern '${name}' (names only; never values)`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
if (definition.args !== undefined) {
|
|
341
|
+
if (!definition.args || typeof definition.args !== "object" || Array.isArray(definition.args)) {
|
|
342
|
+
emit(file, "exec.args", "must be a mapping when present");
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
const collectStrings = (value) => {
|
|
346
|
+
if (typeof value === "string")
|
|
347
|
+
return [value];
|
|
348
|
+
if (Array.isArray(value))
|
|
349
|
+
return value.flatMap(collectStrings);
|
|
350
|
+
if (value && typeof value === "object")
|
|
351
|
+
return Object.values(value).flatMap(collectStrings);
|
|
352
|
+
return [];
|
|
353
|
+
};
|
|
354
|
+
for (const value of collectStrings(definition.args)) {
|
|
355
|
+
for (const name of matchingJobSecretPatterns(value)) {
|
|
356
|
+
emit(file, "exec.args", `inline credential-shaped value matched pattern '${name}' (names only; never values)`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
const surface = asText(definition.surface);
|
|
362
|
+
if (surface && !["headless", "remote"].includes(surface)) {
|
|
363
|
+
emit(file, "exec.surface", `unknown surface '${surface}' (allowed: headless|remote)`);
|
|
364
|
+
}
|
|
365
|
+
for (const key of Object.keys(definition)) {
|
|
366
|
+
if (!["prompt", "skill", "args", "surface"].includes(key)) {
|
|
367
|
+
emit(file, `exec.${key}`, "unknown exec key (allowed: prompt, skill, args, surface)");
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
function validateRequirements(file, requirements, runtime, emit) {
|
|
372
|
+
if (requirements === undefined || requirements === null)
|
|
373
|
+
return;
|
|
374
|
+
if (!requirements || typeof requirements !== "object" || Array.isArray(requirements)) {
|
|
375
|
+
emit(file, "requirements", "must be a mapping");
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
const value = requirements;
|
|
379
|
+
for (const key of Object.keys(value)) {
|
|
380
|
+
if (["runtime", "secrets", "company", "cwd", "tools"].includes(key))
|
|
381
|
+
continue;
|
|
382
|
+
const message = ["readiness", "last_probe", "last_run", "failure_class", "pending_probe"].includes(key)
|
|
383
|
+
? "unknown requirements key (status belongs in hq-pro, not requirements)"
|
|
384
|
+
: "unknown requirements key (allowed: runtime, secrets, company, cwd, tools)";
|
|
385
|
+
emit(file, `requirements.${key}`, message);
|
|
386
|
+
}
|
|
387
|
+
const requiredRuntime = asText(value.runtime);
|
|
388
|
+
if (requiredRuntime && !["claude", "codex"].includes(requiredRuntime)) {
|
|
389
|
+
emit(file, "requirements.runtime", `unknown runtime '${requiredRuntime}' (allowed: claude|codex)`);
|
|
390
|
+
}
|
|
391
|
+
if (requiredRuntime && runtime && value.runtime !== runtime) {
|
|
392
|
+
emit(file, "requirements.runtime", `mismatches top-level runtime ('${requiredRuntime}' vs '${runtime}')`);
|
|
393
|
+
}
|
|
394
|
+
if (value.secrets !== undefined) {
|
|
395
|
+
if (!Array.isArray(value.secrets)) {
|
|
396
|
+
emit(file, "requirements.secrets", "must be an array of vault key names");
|
|
397
|
+
}
|
|
398
|
+
else {
|
|
399
|
+
for (const secret of value.secrets) {
|
|
400
|
+
const name = asText(secret);
|
|
401
|
+
for (const pattern of matchingJobSecretPatterns(name)) {
|
|
402
|
+
emit(file, "requirements.secrets", `inline credential-shaped value matched pattern '${pattern}' (names only; never values)`);
|
|
403
|
+
}
|
|
404
|
+
if (name && !/^[A-Za-z][A-Za-z0-9._:/-]{0,127}$/.test(name)) {
|
|
405
|
+
emit(file, "requirements.secrets", `invalid vault key name '${name}' (use names only)`);
|
|
406
|
+
}
|
|
407
|
+
if (name.includes("=")) {
|
|
408
|
+
emit(file, "requirements.secrets", "looks like KEY=value inline credential — names only");
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
if (value.tools !== undefined && !Array.isArray(value.tools)) {
|
|
414
|
+
emit(file, "requirements.tools", "must be an array of strings");
|
|
415
|
+
}
|
|
416
|
+
if (value.cwd)
|
|
417
|
+
validateCwd(file, "requirements.cwd", asText(value.cwd), emit);
|
|
418
|
+
const company = asText(value.company);
|
|
419
|
+
if (company && !/^[a-z0-9]([a-z0-9_-]*[a-z0-9])?$/.test(company)) {
|
|
420
|
+
emit(file, "requirements.company", `invalid company slug '${company}' (lowercase kebab/underscore)`);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
function validateJob(file, document, seenIds, emit) {
|
|
424
|
+
if (!document || typeof document !== "object" || Array.isArray(document)) {
|
|
425
|
+
emit(file, "yaml", "job root must be a mapping");
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
const job = document;
|
|
429
|
+
for (const field of ["id", "name", "schedule", "runtime", "timeout_seconds", "notify", "enabled", "owner", "created_at"]) {
|
|
430
|
+
const missing = field === "enabled" ? !Object.hasOwn(job, field) : asText(job[field]) === "";
|
|
431
|
+
if (missing)
|
|
432
|
+
emit(file, field, "missing required field");
|
|
433
|
+
}
|
|
434
|
+
const id = asText(job.id);
|
|
435
|
+
const schedule = asText(job.schedule);
|
|
436
|
+
const timezone = asText(job.timezone);
|
|
437
|
+
const runtime = asText(job.runtime);
|
|
438
|
+
const timeout = asText(job.timeout_seconds);
|
|
439
|
+
const notify = asText(job.notify);
|
|
440
|
+
if (job.timezone !== undefined && (typeof job.timezone !== "string" || !job.timezone)) {
|
|
441
|
+
emit(file, "timezone", "must be a non-empty string when present");
|
|
442
|
+
}
|
|
443
|
+
validateJobId(file, id, seenIds, emit);
|
|
444
|
+
if (schedule) {
|
|
445
|
+
const fields = schedule.trim().split(/\s+/);
|
|
446
|
+
if (fields.length !== 5) {
|
|
447
|
+
const message = /[a-z]/i.test(schedule)
|
|
448
|
+
? "natural-language schedule is not allowed — store canonical cron only"
|
|
449
|
+
: `invalid cron — expected 5 fields (minute hour dom month dow), got ${fields.length}`;
|
|
450
|
+
emit(file, "schedule", message);
|
|
451
|
+
}
|
|
452
|
+
else {
|
|
453
|
+
validateCron(file, schedule, emit);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
if (timezone && !(/^[A-Za-z_]+(\/[A-Za-z0-9_+-]+)+$/.test(timezone) || timezone === "UTC")) {
|
|
457
|
+
emit(file, "timezone", `not an IANA-looking timezone '${timezone}'`);
|
|
458
|
+
}
|
|
459
|
+
if (runtime && !["claude", "codex"].includes(runtime)) {
|
|
460
|
+
emit(file, "runtime", `unknown runtime '${runtime}' (allowed: claude|codex)`);
|
|
461
|
+
}
|
|
462
|
+
validateExec(file, job.exec, emit);
|
|
463
|
+
if (timeout) {
|
|
464
|
+
if (!/^\d+$/.test(timeout))
|
|
465
|
+
emit(file, "timeout_seconds", `must be an integer, got '${timeout}'`);
|
|
466
|
+
else if (+timeout < 60 || +timeout > 14400) {
|
|
467
|
+
emit(file, "timeout_seconds", `must be between 60 and 14400, got ${timeout}`);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
if (notify && !["dm", "none", "profile"].includes(notify)) {
|
|
471
|
+
emit(file, "notify", `unknown notify '${notify}' (allowed: dm|none|profile)`);
|
|
472
|
+
}
|
|
473
|
+
if (job.enabled !== undefined && typeof job.enabled !== "boolean") {
|
|
474
|
+
emit(file, "enabled", `must be boolean true|false, got '${String(job.enabled)}'`);
|
|
475
|
+
}
|
|
476
|
+
const createdAt = asText(job.created_at);
|
|
477
|
+
if (createdAt && !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})$/.test(createdAt)) {
|
|
478
|
+
emit(file, "created_at", `must be ISO-8601 datetime, got '${createdAt}'`);
|
|
479
|
+
}
|
|
480
|
+
validateRequirements(file, job.requirements, runtime, emit);
|
|
481
|
+
if (job.cwd)
|
|
482
|
+
validateCwd(file, "cwd", asText(job.cwd), emit);
|
|
483
|
+
}
|
|
484
|
+
function parseJobFile(contents) {
|
|
485
|
+
const documents = [];
|
|
486
|
+
loadAll(contents, (document) => documents.push(document), { schema: JSON_SCHEMA, json: true });
|
|
487
|
+
if (documents.length > 1)
|
|
488
|
+
return [];
|
|
489
|
+
const parsed = documents.length === 0
|
|
490
|
+
? load(contents, { schema: JSON_SCHEMA, json: true })
|
|
491
|
+
: documents[0];
|
|
492
|
+
return expandYamlMergeKeys(parsed);
|
|
493
|
+
}
|
|
494
|
+
function expandYamlMergeKeys(value) {
|
|
495
|
+
if (Array.isArray(value))
|
|
496
|
+
return value.map(expandYamlMergeKeys);
|
|
497
|
+
if (!value || typeof value !== "object")
|
|
498
|
+
return value;
|
|
499
|
+
const mapping = value;
|
|
500
|
+
const merged = mapping["<<"];
|
|
501
|
+
const sources = Array.isArray(merged) ? merged : merged === undefined ? [] : [merged];
|
|
502
|
+
const result = {};
|
|
503
|
+
for (const source of [...sources].reverse()) {
|
|
504
|
+
if (source && typeof source === "object" && !Array.isArray(source)) {
|
|
505
|
+
Object.assign(result, expandYamlMergeKeys(source));
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
for (const [key, entry] of Object.entries(mapping)) {
|
|
509
|
+
if (key !== "<<")
|
|
510
|
+
result[key] = expandYamlMergeKeys(entry);
|
|
511
|
+
}
|
|
512
|
+
return result;
|
|
513
|
+
}
|
|
514
|
+
function collectJobFiles(args, emit) {
|
|
515
|
+
const files = [];
|
|
516
|
+
const collect = (argument) => {
|
|
517
|
+
try {
|
|
518
|
+
const stat = fs.statSync(argument);
|
|
519
|
+
if (stat.isFile()) {
|
|
520
|
+
if (/\.(yaml|yml)$/.test(argument))
|
|
521
|
+
files.push(argument);
|
|
522
|
+
else
|
|
523
|
+
emit(argument, "path", "not a .yaml/.yml job file");
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
if (stat.isDirectory()) {
|
|
527
|
+
const walk = (directory) => {
|
|
528
|
+
const entries = fs.readdirSync(directory, { withFileTypes: true });
|
|
529
|
+
for (const entry of entries) {
|
|
530
|
+
const file = path.join(directory, entry.name);
|
|
531
|
+
if (entry.isDirectory())
|
|
532
|
+
walk(file);
|
|
533
|
+
else if (entry.isFile() && /\.(yaml|yml)$/.test(entry.name))
|
|
534
|
+
files.push(file);
|
|
535
|
+
}
|
|
536
|
+
};
|
|
537
|
+
walk(argument);
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
emit(argument, "path", "file or directory not found");
|
|
541
|
+
}
|
|
542
|
+
catch {
|
|
543
|
+
emit(argument, "path", "file or directory not found");
|
|
544
|
+
}
|
|
545
|
+
};
|
|
546
|
+
args.forEach(collect);
|
|
547
|
+
return files;
|
|
548
|
+
}
|
|
549
|
+
function comparePathsLikeSort(left, right) {
|
|
550
|
+
return Buffer.compare(Buffer.from(left), Buffer.from(right));
|
|
551
|
+
}
|
|
552
|
+
export function validateJobs(args) {
|
|
553
|
+
if (!args.length) {
|
|
554
|
+
process.stderr.write(VALIDATE_USAGE);
|
|
555
|
+
return 2;
|
|
556
|
+
}
|
|
557
|
+
const errors = [];
|
|
558
|
+
const emit = (file, field, message) => {
|
|
559
|
+
errors.push(`jobs-validate: ${file}: ${field}: ${message}`);
|
|
560
|
+
};
|
|
561
|
+
const seenIds = [];
|
|
562
|
+
const files = collectJobFiles(args, emit).sort(comparePathsLikeSort);
|
|
563
|
+
if (!files.length) {
|
|
564
|
+
for (const error of errors)
|
|
565
|
+
process.stderr.write(`${error}\n`);
|
|
566
|
+
process.stderr.write("jobs-validate: no job YAML files found\n");
|
|
567
|
+
return 1;
|
|
568
|
+
}
|
|
569
|
+
for (const file of files) {
|
|
570
|
+
try {
|
|
571
|
+
validateJob(file, parseJobFile(fs.readFileSync(file, "utf8")), seenIds, emit);
|
|
572
|
+
}
|
|
573
|
+
catch {
|
|
574
|
+
emit(file, "yaml", "failed to parse YAML");
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
for (const error of errors)
|
|
578
|
+
process.stderr.write(`${error}\n`);
|
|
579
|
+
if (errors.length) {
|
|
580
|
+
process.stderr.write(`jobs-validate: ${errors.length} error(s)\n`);
|
|
581
|
+
return 1;
|
|
582
|
+
}
|
|
583
|
+
return 0;
|
|
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
|
+
}
|
|
1283
|
+
export function registerCoreJobsNativeCommand(core) {
|
|
1284
|
+
const jobs = core.command("jobs").description("Scheduled jobs helpers");
|
|
1285
|
+
const register = (name, asset, summary, run) => {
|
|
1286
|
+
const command = jobs.command(name)
|
|
1287
|
+
.description(summary)
|
|
1288
|
+
.helpOption(false)
|
|
1289
|
+
.allowUnknownOption()
|
|
1290
|
+
.allowExcessArguments(true)
|
|
1291
|
+
.argument("[args...]", "Arguments passed to the scaffold helper")
|
|
1292
|
+
.action(async (args, _options, current) => {
|
|
1293
|
+
const argv = rawArgumentsForCommand(current, args);
|
|
1294
|
+
const groupHqRoot = core.opts().hqRoot;
|
|
1295
|
+
if (!await coreNativeJobsEnabled()) {
|
|
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 } : {}) });
|
|
1301
|
+
if (result.code !== 0)
|
|
1302
|
+
process.exit(result.code);
|
|
1303
|
+
return;
|
|
1304
|
+
}
|
|
1305
|
+
const result = await run(argv, groupHqRoot ?? process.env.HQ_ROOT);
|
|
1306
|
+
if (result !== 0)
|
|
1307
|
+
process.exit(result);
|
|
1308
|
+
});
|
|
1309
|
+
describeCoreCommand(command, { execution: "native", asset, root: "live" });
|
|
1310
|
+
};
|
|
1311
|
+
register("schedule-parse", SCHEDULE_PARSE_ASSET, "Resolve natural-language or raw cron schedules", runScheduleParse);
|
|
1312
|
+
register("validate", VALIDATE_ASSET, "Validate scheduled-job YAML files", validateJobs);
|
|
1313
|
+
register("reconcile", RECONCILE_ASSET, "Materialize scheduled jobs on this outpost", runReconcile);
|
|
1314
|
+
}
|
|
1315
|
+
export function rawArgumentsForCommand(command, fallback, raw = process.argv) {
|
|
1316
|
+
const names = [];
|
|
1317
|
+
for (let current = command; current?.parent; current = current.parent) {
|
|
1318
|
+
names.unshift(current.name());
|
|
1319
|
+
}
|
|
1320
|
+
let start = -1;
|
|
1321
|
+
let cursor = 0;
|
|
1322
|
+
for (const name of names) {
|
|
1323
|
+
const index = raw.indexOf(name, cursor);
|
|
1324
|
+
if (index < 0)
|
|
1325
|
+
return command.args.length ? command.args : fallback;
|
|
1326
|
+
cursor = index + 1;
|
|
1327
|
+
start = cursor;
|
|
1328
|
+
}
|
|
1329
|
+
return start >= 0 ? raw.slice(start) : (command.args.length ? command.args : fallback);
|
|
1330
|
+
}
|
|
1331
|
+
//# sourceMappingURL=core-jobs-native.js.map
|